Files
scanengine-3/motion_worker.py
T
2026-07-28 09:20:58 -05:00

396 lines
14 KiB
Python
Executable File

"""
Motion Controller Worker Thread
Handles all motion control operations in a separate thread to keep the UI responsive.
Provides async command queueing and position updates via Qt signals.
"""
from PyQt6 import QtCore
from hardware.pybbd202 import ThorlabsServoDriver, AXIS_X, AXIS_Y
import queue
import time
from typing import Optional, Dict, Any
class MotionCommand:
"""Represents a motion command"""
def __init__(self, cmd_type: str, **kwargs):
self.cmd_type = cmd_type
self.params = kwargs
class MotionWorker(QtCore.QObject):
"""
Worker object for handling motion control in a separate thread.
Signals:
connected: Emitted when controller connects successfully
disconnected: Emitted when controller disconnects
connection_failed: Emitted when connection fails (error_msg: str)
position_updated: Emitted when position changes (x: float, y: float)
homed_status: Emitted with home status (x_homed: bool, y_homed: bool)
move_completed: Emitted when a move completes (axis: str)
error_occurred: Emitted when an error occurs (error_msg: str)
"""
# Signals
connected = QtCore.pyqtSignal()
disconnected = QtCore.pyqtSignal()
connection_failed = QtCore.pyqtSignal(str)
position_updated = QtCore.pyqtSignal(float, float) # x, y in mm
homed_status = QtCore.pyqtSignal(bool, bool) # x_homed, y_homed
motion_status = QtCore.pyqtSignal(bool, bool) # x_moving, y_moving
move_completed = QtCore.pyqtSignal(str) # axis name
error_occurred = QtCore.pyqtSignal(str) # error message
def __init__(self):
super().__init__()
self.controller: Optional[ThorlabsServoDriver] = None
self.is_connected = False
self.command_queue = queue.Queue()
self.running = True
# Default parameters
self.jog_speed = 20.0 # mm/s
self.acceleration = 50.0 # mm/s^2
self.step_size = 1.0 # mm
# Position tracking
self.last_x = None
self.last_y = None
# Status tracking
self.last_x_homed = None
self.last_y_homed = None
self.last_x_moving = None
self.last_y_moving = None
# Position update throttling
self.last_position_update_time = 0
self.position_update_interval = 0.2 # seconds between position reads
# Flag to pause polling during scanning (scan worker handles its own position queries)
self.scanning_active = False
@QtCore.pyqtSlot()
def run(self):
"""Main worker loop - processes commands from queue"""
print("Motion worker thread started")
while self.running:
try:
# Check for commands with timeout to allow periodic position updates
try:
cmd = self.command_queue.get(timeout=0.05) # 50ms timeout
self.process_command(cmd)
except queue.Empty:
pass
# Periodically update position and status if connected
# Skip updates during scanning - scan worker handles its own position queries
if self.is_connected and self.controller and not self.scanning_active:
self.update_position()
self.update_home_status()
self.update_motion_status()
except Exception as e:
print(f"Error in motion worker loop: {e}")
self.error_occurred.emit(str(e))
# Cleanup on exit
if self.controller:
try:
self.controller.disconnect()
except:
pass
print("Motion worker thread stopped")
def process_command(self, cmd: MotionCommand):
"""Process a motion command"""
try:
if cmd.cmd_type == 'connect':
self.do_connect()
elif cmd.cmd_type == 'disconnect':
self.do_disconnect()
elif cmd.cmd_type == 'jog':
self.do_jog(cmd.params['axis'], cmd.params['direction'])
elif cmd.cmd_type == 'home':
self.do_home(cmd.params['axis'])
elif cmd.cmd_type == 'set_velocity':
self.do_set_velocity(cmd.params['speed'], cmd.params['accel'])
elif cmd.cmd_type == 'set_step_size':
self.step_size = cmd.params['step_size']
elif cmd.cmd_type == 'set_axis_enable':
self.do_set_axis_enable(cmd.params['axis'], cmd.params['enabled'])
elif cmd.cmd_type == 'stop':
self.running = False
except Exception as e:
print(f"Error processing command {cmd.cmd_type}: {e}")
self.error_occurred.emit(f"Command '{cmd.cmd_type}' failed: {str(e)}")
def do_connect(self):
"""Connect to the motion controller"""
try:
self.controller = ThorlabsServoDriver()
self.controller.connect()
# Enable channels
self.controller.enable_axis(AXIS_X)
self.controller.enable_axis(AXIS_Y)
# Start polling to populate cached state (positions, homed, moving, errors)
self.controller.start_polling(interval=0.2)
# Wait for first polling cycle to populate status
time.sleep(0.3)
# Set initial velocity parameters
for dest in [AXIS_X, AXIS_Y]:
self.controller.set_velocity_params(
dest,
max_velocity=self.jog_speed,
acceleration=self.acceleration
)
self.is_connected = True
# Force initial updates (they will be emitted because last values are None)
self.update_position()
self.update_home_status()
self.update_motion_status()
self.connected.emit()
print("Motion controller connected successfully")
except Exception as e:
print(f"Failed to connect to motion controller: {e}")
self.connection_failed.emit(str(e))
def do_disconnect(self):
"""Disconnect from the motion controller"""
if self.controller:
try:
self.controller.disconnect()
print("Motion controller disconnected")
except Exception as e:
print(f"Error during disconnect: {e}")
self.controller = None
self.is_connected = False
self.disconnected.emit()
def do_jog(self, axis: str, direction: int):
"""Execute a jog move"""
if not self.is_connected or not self.controller:
return
try:
dest = AXIS_X if axis == 'x' else AXIS_Y
# Calculate relative distance
distance = self.step_size * direction
# Execute the move (blocking, with short timeout for continuous jogging)
self.controller.move_axis_relative(dest, distance, timeout=0.5)
# Update position
self.update_position()
self.move_completed.emit(axis)
except TimeoutError:
# Timeout is expected during continuous jog - don't report as error
pass
except Exception as e:
print(f"Jog error: {e}")
self.error_occurred.emit(f"Jog failed: {str(e)}")
def do_home(self, axis: str):
"""Home an axis"""
if not self.is_connected or not self.controller:
return
try:
dest = AXIS_X if axis == 'x' else AXIS_Y
print(f"Homing {axis.upper()} axis...")
self.controller.home_axis(dest, timeout=60.0)
# Update position and status after homing
self.update_position()
self.update_home_status()
print(f"{axis.upper()} axis homed successfully")
except TimeoutError:
print(f"Home timeout: {axis.upper()} axis")
self.error_occurred.emit(f"Homing {axis.upper()} timed out")
except Exception as e:
print(f"Home error: {e}")
self.error_occurred.emit(f"Homing {axis.upper()} failed: {str(e)}")
def do_set_velocity(self, speed: float, accel: float):
"""Set velocity parameters"""
if not self.is_connected or not self.controller:
self.jog_speed = speed
self.acceleration = accel
return
try:
self.jog_speed = speed
self.acceleration = accel
for dest in [AXIS_X, AXIS_Y]:
self.controller.set_velocity_params(
dest,
max_velocity=self.jog_speed,
acceleration=self.acceleration
)
except Exception as e:
print(f"Set velocity error: {e}")
def do_set_axis_enable(self, axis: str, enabled: bool):
"""Enable or disable an axis for manual movement"""
if not self.is_connected or not self.controller:
return
try:
dest = AXIS_X if axis == 'x' else AXIS_Y
if enabled:
self.controller.enable_axis(dest)
else:
self.controller.disable_axis(dest)
state_str = "enabled" if enabled else "disabled"
print(f"{axis.upper()} axis {state_str}")
except Exception as e:
print(f"Set axis enable error: {e}")
self.error_occurred.emit(f"Failed to {'enable' if enabled else 'disable'} {axis.upper()} axis: {str(e)}")
def update_position(self):
"""Update current position and emit signal if changed"""
if not self.is_connected or not self.controller:
return
# Throttle position reads to avoid excessive signal emission
current_time = time.time()
if current_time - self.last_position_update_time < self.position_update_interval:
return
self.last_position_update_time = current_time
try:
# Read cached positions (populated by polling worker)
x_pos = self.controller.positions[0]
y_pos = self.controller.positions[1]
# Always emit on first update, or if position changed significantly (> 0.001mm)
if (self.last_x is None or self.last_y is None or
abs(x_pos - self.last_x) > 0.001 or abs(y_pos - self.last_y) > 0.001):
self.last_x = x_pos
self.last_y = y_pos
print(f"Position update: X={x_pos:.3f}mm, Y={y_pos:.3f}mm")
self.position_updated.emit(x_pos, y_pos)
except Exception as e:
print(f"Error updating position: {e}")
import traceback
traceback.print_exc()
def update_home_status(self):
"""Update home status and emit signal if changed"""
if not self.is_connected or not self.controller:
return
try:
x_homed = self.controller.am_homed[0]
y_homed = self.controller.am_homed[1]
# Only emit if status changed
if x_homed != self.last_x_homed or y_homed != self.last_y_homed:
self.last_x_homed = x_homed
self.last_y_homed = y_homed
self.homed_status.emit(x_homed, y_homed)
except Exception as e:
print(f"Error updating home status: {e}")
import traceback
traceback.print_exc()
def update_motion_status(self):
"""Update motion status and emit signal if changed.
The new driver's polling worker keeps am_moving[], am_error[]
up to date automatically via status update messages.
"""
if not self.is_connected or not self.controller:
return
try:
# Check for any error conditions
if self.controller.am_error[0]:
self.error_occurred.emit("X-axis error detected")
if self.controller.am_error[1]:
self.error_occurred.emit("Y-axis error detected")
# Read cached motion status (updated by polling worker)
x_moving = self.controller.am_moving[0]
y_moving = self.controller.am_moving[1]
# Only emit if status changed
if x_moving != self.last_x_moving or y_moving != self.last_y_moving:
self.last_x_moving = x_moving
self.last_y_moving = y_moving
self.motion_status.emit(x_moving, y_moving)
except Exception as e:
print(f"Error updating motion status: {e}")
import traceback
traceback.print_exc()
# Slot methods for queuing commands
@QtCore.pyqtSlot()
def queue_connect(self):
"""Queue a connect command"""
self.command_queue.put(MotionCommand('connect'))
@QtCore.pyqtSlot()
def queue_disconnect(self):
"""Queue a disconnect command"""
self.command_queue.put(MotionCommand('disconnect'))
@QtCore.pyqtSlot(str, int)
def queue_jog(self, axis: str, direction: int):
"""Queue a jog command"""
self.command_queue.put(MotionCommand('jog', axis=axis, direction=direction))
@QtCore.pyqtSlot(str)
def queue_home(self, axis: str):
"""Queue a home command"""
self.command_queue.put(MotionCommand('home', axis=axis))
@QtCore.pyqtSlot(float, float)
def queue_set_velocity(self, speed: float, accel: float):
"""Queue a set velocity command"""
self.command_queue.put(MotionCommand('set_velocity', speed=speed, accel=accel))
@QtCore.pyqtSlot(float)
def queue_set_step_size(self, step_size: float):
"""Queue a set step size command"""
self.command_queue.put(MotionCommand('set_step_size', step_size=step_size))
@QtCore.pyqtSlot(str, bool)
def queue_set_axis_enable(self, axis: str, enabled: bool):
"""Queue a command to enable or disable an axis"""
self.command_queue.put(MotionCommand('set_axis_enable', axis=axis, enabled=enabled))
@QtCore.pyqtSlot()
def stop(self):
"""Stop the worker thread"""
# Set running to False immediately so the main loop can exit
# even if it's blocked waiting for a response from the controller
self.running = False
# Also queue a stop command to ensure the command_queue.get() returns
self.command_queue.put(MotionCommand('stop'))