Phase 5: shared worker base, self-rescheduling polls, driver robustness
gui/qt_workers.py — one QueueWorker base replaces the per-device command queue + dispatch + signal boilerplate. The loop blocks on the queue instead of waking 10-20x/second forever (test_idle_worker_does_not_spin asserts an idle worker burns ~no CPU). PollingQueueWorker adds self-rescheduling polling: the next poll is queued only after the previous finishes, so a device slower than the interval can't accumulate a backlog (test_polling_never_overlaps_or_backs_up). Helios responsiveness — the concrete bug that motivated the above: a free running 1 s QTimer queued a status poll that took ~2 s, so the queue grew for as long as the panel stayed connected. - helios_laser._query reads until the CR terminator instead of sleeping a fixed 0.05 + 0.2 s per query - one _query_int() helper replaces five copies of parse-with-logging - polling is now driven by the worker; HeliosWindow's QTimer is gone - dropped __del__, which disabled the laser and wrote to the serial port from the garbage collector at an unpredictable time helios_test_app.py — the worker was moveToThread'd but every call site invoked its methods directly, so all serial I/O (including the sleeps) ran on the GUI thread; Query All froze the UI for ~2 s. Calls now go through a queued signal to a pyqtSlot. Also: connect/disconnect cycles leaked a QThread + worker + 9 connections each time; 16 copies of the not-connected guard collapse to _require_connection(); the Query Power button called a method that has never existed (AttributeError popup) and is now disabled and documented in KNOWN_ISSUES. DCBiasImageWidget preallocates its image and uses set_data/set_clim, so the live preview stops rebuilding the array and the whole artist tree per row (O(rows^2) over a scan). bbd20x: connect() now raises when no bays respond instead of reporting success on the wrong port; disconnect() joins with a timeout so a wedged reader can't hang shutdown; one _channel_for() helper replaces four copy-pasted axis mappings; hardcoded travel limits become TRAVEL_MM; the joke error strings are gone. gui/widgets.py adds the shared ConnectionBar / PortSelector / bounded LogConsole / StatusGrid for the test benches to adopt. 65 tests passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+48
-40
@@ -12,12 +12,18 @@ from .apt_messages import APTProtocol
|
||||
from .serial_comms import SerialSnooper
|
||||
|
||||
|
||||
# APT bay addresses for the two stage axes
|
||||
AXIS_X_ADDR = 0x21
|
||||
AXIS_Y_ADDR = 0x22
|
||||
|
||||
|
||||
class ThorlabsServoDriver():
|
||||
# These are specific to the MLS203-1
|
||||
# change for a different application
|
||||
counts_per_mm = 20000
|
||||
accel_scaling = 13.744
|
||||
velocity_scaling = 134217.73
|
||||
TRAVEL_MM = (110.0, 75.0) # usable travel per channel (X, Y)
|
||||
|
||||
def __init__(self):
|
||||
self.am_connected = False
|
||||
@@ -83,10 +89,27 @@ class ThorlabsServoDriver():
|
||||
pass
|
||||
|
||||
if not self.bays_present:
|
||||
print(" [WARN] No bays detected!")
|
||||
# Fail loudly: reporting success with no bays made connecting to
|
||||
# the wrong port look like it worked, and every later command
|
||||
# then silently went nowhere.
|
||||
self.disconnect()
|
||||
raise RuntimeError(
|
||||
f"No BBD202 bays responded on {port}. Check the port, the "
|
||||
f"controller power, and that no other process holds the device."
|
||||
)
|
||||
|
||||
self.am_connected = True
|
||||
|
||||
@staticmethod
|
||||
def _channel_for(axis):
|
||||
"""Map an APT axis address to this driver's 0-based channel index."""
|
||||
if axis == AXIS_X_ADDR:
|
||||
return 0
|
||||
if axis == AXIS_Y_ADDR:
|
||||
return 1
|
||||
raise ValueError(f"Unknown axis address 0x{axis:02x} "
|
||||
f"(expected 0x{AXIS_X_ADDR:02x} or 0x{AXIS_Y_ADDR:02x})")
|
||||
|
||||
# ── Worker threads ───────────────────────────────────────────
|
||||
|
||||
def _tx_worker(self):
|
||||
@@ -223,15 +246,18 @@ class ThorlabsServoDriver():
|
||||
APTProtocol.build_message(0x0002, destination=addr,
|
||||
source=0x01))
|
||||
time.sleep(0.2) # let TX worker flush them out
|
||||
# Stop all worker loops, then wait for threads to exit
|
||||
# Stop all worker loops, then wait for threads to exit. Joins
|
||||
# are bounded: disconnect() runs from closeEvent, and a wedged
|
||||
# reader must not hang application shutdown.
|
||||
self.am_listening = False
|
||||
self.serial_snoop.stop()
|
||||
self._rx_thread.join()
|
||||
self._tx_thread.join()
|
||||
self._poll_thread.join()
|
||||
self.serial_snoop.join()
|
||||
for thread in (self._rx_thread, self._tx_thread, self._poll_thread,
|
||||
self.serial_snoop):
|
||||
if thread is not None:
|
||||
thread.join(timeout=2.0)
|
||||
# Close port only after all threads are done
|
||||
self.serial_snoop.close()
|
||||
self.am_connected = False
|
||||
|
||||
# ── State update handlers ────────────────────────────────────
|
||||
|
||||
@@ -305,13 +331,8 @@ class ThorlabsServoDriver():
|
||||
toggle_enabled_state(axis) - enables the axis if disabled. disables
|
||||
if enabled. not much more to it.
|
||||
'''
|
||||
if axis == 0x21:
|
||||
ch = 0
|
||||
elif axis == 0x22:
|
||||
ch = 1
|
||||
else:
|
||||
raise ValueError("I don't know that axis!")
|
||||
# get the old state and flip it like a sample
|
||||
ch = self._channel_for(axis)
|
||||
# Read the cached state and invert it
|
||||
new_state = not self.am_enabled[ch]
|
||||
self.send_message(0x0210, chan_ident=1,
|
||||
enable_state=0x01 if new_state else 0x02,
|
||||
@@ -323,12 +344,7 @@ class ThorlabsServoDriver():
|
||||
power up. Default timeout is 60s, but 20-30s is fine as well if
|
||||
you're in that much of a hurry.
|
||||
'''
|
||||
if axis == 0x21:
|
||||
ch = 0
|
||||
elif axis == 0x22:
|
||||
ch = 1
|
||||
else:
|
||||
raise ValueError("I don't know that axis!")
|
||||
ch = self._channel_for(axis)
|
||||
|
||||
self.send_and_wait(0x0443, timeout=timeout, retries=0, chan_ident=1,
|
||||
destination=axis, source=0x01)
|
||||
@@ -340,11 +356,11 @@ class ThorlabsServoDriver():
|
||||
moves the specified axis a specified distance in mm.
|
||||
Timeout defaults to ten seconds.
|
||||
'''
|
||||
# sanity check
|
||||
if axis == 0x21 and abs(distance_in_mm) > 110.0:
|
||||
raise ValueError("You can't move farther than the stage is long.")
|
||||
elif axis == 0x22 and abs(distance_in_mm) > 75.0:
|
||||
raise ValueError("You can't move farther than the stage is wide.")
|
||||
travel = self.TRAVEL_MM[self._channel_for(axis)]
|
||||
if abs(distance_in_mm) > travel:
|
||||
raise ValueError(
|
||||
f"Relative move of {distance_in_mm:.3f} mm exceeds the "
|
||||
f"{travel:g} mm travel of this axis.")
|
||||
|
||||
_distance_in_encoder = int(round(distance_in_mm * self.counts_per_mm))
|
||||
self.send_and_wait(0x0448, timeout=timeout, chan_ident=1,
|
||||
@@ -358,10 +374,12 @@ class ThorlabsServoDriver():
|
||||
moves the specified axis to an absolute position in mm.
|
||||
Timeout defaults to ten seconds.
|
||||
'''
|
||||
if axis == 0x21 and (position_in_mm < 0.0 or position_in_mm > 110.0):
|
||||
raise ValueError("Position out of range for X axis (0-110 mm).")
|
||||
elif axis == 0x22 and (position_in_mm < 0.0 or position_in_mm > 75.0):
|
||||
raise ValueError("Position out of range for Y axis (0-75 mm).")
|
||||
ch = self._channel_for(axis)
|
||||
travel = self.TRAVEL_MM[ch]
|
||||
if not 0.0 <= position_in_mm <= travel:
|
||||
raise ValueError(
|
||||
f"Position {position_in_mm:.3f} mm is out of range for the "
|
||||
f"{'XY'[ch]} axis (0-{travel:g} mm).")
|
||||
|
||||
_position_in_encoder = int(round(position_in_mm * self.counts_per_mm))
|
||||
self.send_and_wait(0x0453, timeout=timeout, chan_ident=1,
|
||||
@@ -377,12 +395,7 @@ class ThorlabsServoDriver():
|
||||
for the specified axis. Returns a dict with keys:
|
||||
min_velocity (mm/s), acceleration (mm/s2), max_velocity (mm/s)
|
||||
'''
|
||||
if axis == 0x21:
|
||||
ch = 0
|
||||
elif axis == 0x22:
|
||||
ch = 1
|
||||
else:
|
||||
raise ValueError("I don't know that axis!")
|
||||
ch = self._channel_for(axis)
|
||||
|
||||
result = self.send_and_wait(0x0414, timeout=timeout, chan_ident=1,
|
||||
zero_this=0x00, destination=axis,
|
||||
@@ -405,12 +418,7 @@ class ThorlabsServoDriver():
|
||||
Values are in mm/s and mm/s2 respectively. Any parameter
|
||||
left as None keeps its current value.
|
||||
'''
|
||||
if axis == 0x21:
|
||||
ch = 0
|
||||
elif axis == 0x22:
|
||||
ch = 1
|
||||
else:
|
||||
raise ValueError("I don't know that axis!")
|
||||
ch = self._channel_for(axis)
|
||||
|
||||
# Only query current params if we need to fill in a missing value
|
||||
if max_velocity is None or acceleration is None:
|
||||
|
||||
Reference in New Issue
Block a user