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:
Thomas Ales
2026-07-28 11:21:29 -05:00
parent afe33249d1
commit 44febe34b8
8 changed files with 833 additions and 444 deletions
+14
View File
@@ -17,6 +17,20 @@ If they appear, the setters need a stop-live/apply/restart sequence
(re-create the helper around the two call sites in (re-create the helper around the two call sites in
[uc480_camera.py](hardware/uc480_camera.py)). [uc480_camera.py](hardware/uc480_camera.py)).
## Helios: no output-power query
`docs/hardware/HELIOS_DRIVER_README.md` documents `driver.get_power_mw()`,
but `HeliosLaser` has no such method and no output-power mnemonic appears
anywhere in this repo's protocol notes. `helios_test_app.py` called it
anyway and raised `AttributeError` into a popup; the button is now disabled
and the handler reports the gap instead.
**Bench check:** find the power-read command in the Helios manual (the
other reads are three-letter mnemonics like `LDO`, `LDS`, `LTA`). If one
exists, add `get_power_mw()` to `hardware/helios_laser.py` using
`_query_int`, then re-enable the button. If it doesn't, delete the Power
Monitoring group from the test app and fix the README.
## Genesis laser: forked protocol implementations disagree ## Genesis laser: forked protocol implementations disagree
`hardware/genesis_core.py` and the reference implementation `hardware/genesis_core.py` and the reference implementation
+118
View File
@@ -0,0 +1,118 @@
"""Shared Qt worker base for hardware that must be driven off the GUI thread.
Every device worker in this project was the same shape: a command queue, a
`while running: get(timeout=…)` loop, an if/elif dispatch, and a standard
connected/disconnected/failed signal trio. The timeout-poll versions woke
10–20 times a second forever, even with nothing to do; this base blocks on
the queue instead and wakes only when there is work.
"""
from __future__ import annotations
import queue
from PyQt6.QtCore import QObject, pyqtSignal, pyqtSlot
_STOP = object()
class QueueWorker(QObject):
"""Base for a device worker living on its own QThread.
Subclasses register handlers in ``self._handlers`` (command name →
callable) and call ``self._enqueue(name, **kwargs)`` from the GUI thread.
Override ``_on_stop`` to release hardware when the loop exits.
"""
connected = pyqtSignal()
disconnected = pyqtSignal()
connection_failed = pyqtSignal(str)
error_occurred = pyqtSignal(str)
def __init__(self):
super().__init__()
self._cmd_q: queue.Queue = queue.Queue()
self._handlers: dict[str, callable] = {}
self._running = False
self.is_connected = False
# ── Command submission (GUI thread) ───────────────────────────────────────
def _enqueue(self, cmd_type: str, **kwargs):
self._cmd_q.put((cmd_type, kwargs))
def stop_worker(self):
self._cmd_q.put(_STOP)
# ── Worker loop ───────────────────────────────────────────────────────────
@pyqtSlot()
def run(self):
self._running = True
while self._running:
item = self._cmd_q.get() # blocks — no idle wake-ups
if item is _STOP:
break
cmd_type, kwargs = item
handler = self._handlers.get(cmd_type)
if handler is None:
self.error_occurred.emit(f"Unknown command: {cmd_type}")
continue
try:
handler(**kwargs)
except Exception as exc:
self.error_occurred.emit(str(exc))
self._running = False
self._on_stop()
def _on_stop(self):
"""Release hardware when the loop exits. Override as needed."""
class PollingQueueWorker(QueueWorker):
"""QueueWorker that also polls the device on an interval.
The poll is self-rescheduling: the next one is queued only after the
previous finishes, so a device slower than the interval can never
accumulate a backlog of stale poll commands (which is exactly what the
old free-running QTimer did to the Helios laser).
"""
POLL_CMD = "_poll"
def __init__(self, poll_interval_s: float = 1.0):
super().__init__()
self._poll_interval_s = poll_interval_s
self._polling = False
self._handlers[self.POLL_CMD] = self._poll_and_reschedule
def start_polling(self):
if not self._polling:
self._polling = True
self._enqueue(self.POLL_CMD)
def stop_polling(self):
self._polling = False
def _poll_and_reschedule(self):
if not self._polling or not self.is_connected:
self._polling = False
return
try:
self._poll_once()
finally:
if self._polling and self.is_connected:
self._schedule_next_poll()
def _schedule_next_poll(self):
# A timer thread rather than a sleep here, so the worker stays
# responsive to commands during the interval.
import threading
t = threading.Timer(self._poll_interval_s,
lambda: self._enqueue(self.POLL_CMD))
t.daemon = True
t.start()
self._poll_timer = t
def _poll_once(self):
"""Read device state and emit updates. Implemented by subclasses."""
+187
View File
@@ -0,0 +1,187 @@
"""Widgets shared by the main app and the per-device test benches.
Each of these was hand-rolled several times across the apps, with slightly
different behaviour every time (only one log console bounded its buffer,
only one port picker sorted by device type).
"""
from __future__ import annotations
from PyQt6.QtCore import Qt, pyqtSignal
from PyQt6.QtGui import QFont
from PyQt6.QtWidgets import (
QComboBox, QGroupBox, QHBoxLayout, QLabel, QPlainTextEdit, QPushButton,
QVBoxLayout, QWidget,
)
from hardware.serial_util import scored_ports
def set_toggle(btn, checked: bool, text: str, enabled: bool = True):
"""Update a checkable button without re-triggering its toggled signal."""
btn.blockSignals(True)
btn.setChecked(checked)
btn.setText(text)
btn.setEnabled(enabled)
btn.blockSignals(False)
class PortSelector(QWidget):
"""Serial port combo + refresh button, likeliest device first."""
def __init__(self, parent=None):
super().__init__(parent)
row = QHBoxLayout(self)
row.setContentsMargins(0, 0, 0, 0)
self.combo = QComboBox()
self.combo.setMinimumWidth(220)
refresh = QPushButton("⟳")
refresh.setFixedWidth(30)
refresh.setToolTip("Rescan serial ports")
refresh.clicked.connect(self.refresh)
row.addWidget(self.combo, stretch=1)
row.addWidget(refresh)
self.refresh()
def refresh(self):
"""Repopulate the list, preserving the current selection if present."""
current = self.current_port()
self.combo.clear()
for device, label in scored_ports():
self.combo.addItem(label, device)
if self.combo.count() == 0:
self.combo.addItem("(no serial ports found)", None)
elif current:
idx = self.combo.findData(current)
if idx >= 0:
self.combo.setCurrentIndex(idx)
def current_port(self) -> str | None:
return self.combo.currentData()
def set_port(self, device: str):
idx = self.combo.findData(device)
if idx >= 0:
self.combo.setCurrentIndex(idx)
class ConnectionBar(QGroupBox):
"""Port picker + connect toggle + status label.
Emits connect_requested(port) / disconnect_requested(); the owner drives
the state back through on_connected/on_disconnected/on_failed so the
button can never disagree with the hardware.
"""
connect_requested = pyqtSignal(str)
disconnect_requested = pyqtSignal()
def __init__(self, title: str = "Connection", parent=None):
super().__init__(title, parent)
layout = QVBoxLayout(self)
self.port_selector = PortSelector()
layout.addWidget(self.port_selector)
row = QHBoxLayout()
self.toggle = QPushButton("Connect")
self.toggle.setCheckable(True)
self.toggle.toggled.connect(self._on_toggled)
self.status = QLabel("Disconnected")
self.status.setStyleSheet("font-weight: bold;")
row.addWidget(self.toggle)
row.addWidget(self.status, stretch=1)
layout.addLayout(row)
def _on_toggled(self, checked: bool):
if checked:
port = self.port_selector.current_port()
if not port:
set_toggle(self.toggle, False, "Connect")
self.status.setText("No port selected")
return
self.toggle.setText("Connecting…")
self.toggle.setEnabled(False)
self.connect_requested.emit(port)
else:
self.disconnect_requested.emit()
def on_connected(self, detail: str = "Connected"):
set_toggle(self.toggle, True, "Disconnect")
self.status.setText(detail)
self.status.setStyleSheet("font-weight: bold; color: green;")
def on_disconnected(self, detail: str = "Disconnected"):
set_toggle(self.toggle, False, "Connect")
self.status.setText(detail)
self.status.setStyleSheet("font-weight: bold;")
def on_failed(self, message: str):
set_toggle(self.toggle, False, "Connect")
self.status.setText(f"Failed: {message}")
self.status.setStyleSheet("font-weight: bold; color: red;")
class LogConsole(QWidget):
"""Bounded, monospace, auto-scrolling log view with a Clear button.
The block cap is the point: unbounded QTextEdit logs grew for the whole
session in every app that hand-rolled one.
"""
KINDS = {"tx": "→", "rx": "←", "info": "●", "err": "!"}
def __init__(self, max_blocks: int = 2000, parent=None):
super().__init__(parent)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
self.view = QPlainTextEdit()
self.view.setReadOnly(True)
self.view.setMaximumBlockCount(max_blocks)
font = QFont("Menlo")
font.setStyleHint(QFont.StyleHint.Monospace)
font.setPointSize(11)
self.view.setFont(font)
layout.addWidget(self.view)
row = QHBoxLayout()
row.addStretch(1)
clear = QPushButton("Clear")
clear.clicked.connect(self.view.clear)
row.addWidget(clear)
layout.addLayout(row)
def log(self, message: str, kind: str = "info"):
self.view.appendPlainText(f"{self.KINDS.get(kind, '●')} {message}")
self.view.verticalScrollBar().setValue(
self.view.verticalScrollBar().maximum())
class StatusGrid(QWidget):
"""Label/value rows with consistent ok/warn/error colouring."""
_COLORS = {"ok": "green", "warn": "#b8860b", "error": "red", "": ""}
def __init__(self, fields: list[str], parent=None):
super().__init__(parent)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
self._values: dict[str, QLabel] = {}
for name in fields:
row = QHBoxLayout()
label = QLabel(f"{name}:")
value = QLabel("—")
value.setAlignment(Qt.AlignmentFlag.AlignRight
| Qt.AlignmentFlag.AlignVCenter)
row.addWidget(label)
row.addWidget(value, stretch=1)
layout.addLayout(row)
self._values[name] = value
def set(self, name: str, text: str, state: str = ""):
label = self._values.get(name)
if label is None:
return
label.setText(text)
color = self._COLORS.get(state, "")
label.setStyleSheet(f"color: {color};" if color else "")
+56 -124
View File
@@ -115,37 +115,36 @@ class HeliosLaser:
return False return False
def _query(self, command: str) -> Optional[str]: def _query(self, command: str) -> Optional[str]:
""" """Send a query and return the value from its response.
Send a query and read response.
Args: Reads until the CR terminator rather than sleeping a fixed interval:
command: ASCII query command (without CR or ?) the device usually answers in a few ms, so the old unconditional
0.05 + 0.2 s cost ~250 ms per query and made an 8-query status poll
Returns: take ~2 s — longer than the 1 s interval that scheduled it.
Response value string or None if error
""" """
try: try:
# Clear any pending data in the buffer # Clear any stale bytes so a previous timed-out reply can't be
# mistaken for this command's response.
self.serial.reset_input_buffer() self.serial.reset_input_buffer()
time.sleep(0.05)
if not self._send_command(command): if not self._send_command(command):
return None return None
time.sleep(0.2) # Give device time to respond
response = self.serial.read_until(b'\r').decode('ascii', errors='replace').strip() response = self.serial.read_until(b'\r').decode('ascii', errors='replace').strip()
if not response:
logger.warning(f"Query '{command}' timed out after {self.timeout}s")
return None
logger.debug(f"Query '{command}' response: {response}") logger.debug(f"Query '{command}' response: {response}")
# Helios format: "COMMAND = VALUE UNIT" # Helios format: "COMMAND = VALUE UNIT" — take just the value
# Extract just the value part
if '=' in response: if '=' in response:
parts = response.split('=') parts = response.split('=')
if len(parts) >= 2: if len(parts) >= 2:
value_part = parts[1].strip() value_part = parts[1].strip()
# Remove unit suffix if present (e.g., "ns", "mA", "mW") # Strip the unit suffix if present (e.g. "ns", "mA", "mW")
value = value_part.split()[0] fields = value_part.split()
return value if fields:
return fields[0]
return response return response
@@ -153,6 +152,17 @@ class HeliosLaser:
logger.error(f"Failed to read response for '{command}': {e}") logger.error(f"Failed to read response for '{command}': {e}")
return None return None
def _query_int(self, command: str) -> Optional[int]:
"""Query a value that should parse as an int; None if absent/unparseable."""
raw = self._query(command)
if raw is None:
return None
try:
return int(raw)
except ValueError:
logger.error(f"Query '{command}' returned non-integer {raw!r}")
return None
def set_frequency_hz(self, frequency: int) -> bool: def set_frequency_hz(self, frequency: int) -> bool:
""" """
Set laser pulse frequency in Hz. Set laser pulse frequency in Hz.
@@ -228,60 +238,24 @@ class HeliosLaser:
return success return success
def is_laser_enabled(self) -> bool: def is_laser_enabled(self) -> bool:
""" """True if laser emission is currently enabled."""
Check if laser is currently enabled. return self._query_int("LDO") == 1
Returns:
True if laser is enabled
"""
response = self._query("LDO")
if response:
try:
return int(response) == 1
except ValueError:
logger.error(f"Invalid response for LDO: {response}")
return False
def get_frequency_hz(self) -> Optional[int]: def get_frequency_hz(self) -> Optional[int]:
""" """Current laser frequency in Hz, or None on error."""
Get current laser frequency in Hz. period_ns = self._query_int("LDF")
if not period_ns:
Returns:
Frequency in Hz or None if error
"""
response = self._query("LDF")
if response:
try:
period_ns = int(response)
return int(1e9 / period_ns)
except (ValueError, ZeroDivisionError):
logger.error(f"Invalid response for LDF: {response}")
return None return None
return int(1e9 / period_ns)
def get_current_ma(self) -> Optional[int]: def get_current_ma(self) -> Optional[int]:
""" """Current pump diode current in mA, or None on error."""
Get current pump diode current in mA. return self._query_int("LDS")
Returns:
Current in mA or None if error
"""
response = self._query("LDS")
if response:
try:
return int(response)
except ValueError:
logger.error(f"Invalid response for LDS: {response}")
return None
def _query_millicelsius(self, command: str) -> Optional[float]: def _query_millicelsius(self, command: str) -> Optional[float]:
"""Query a temperature register (returns milli-°C) and convert to °C.""" """Query a temperature register (milli-°C) and convert to °C."""
response = self._query(command) value = self._query_int(command)
if response: return None if value is None else value / 1000.0
try:
return int(response) / 1000.0
except ValueError:
logger.error(f"Invalid response for {command}: {response}")
return None
def get_diode_temp_c(self) -> Optional[float]: def get_diode_temp_c(self) -> Optional[float]:
"""Diode temperature in °C (LTA, 5000–50000 milli-°C).""" """Diode temperature in °C (LTA, 5000–50000 milli-°C)."""
@@ -296,46 +270,22 @@ class HeliosLaser:
return self._query_millicelsius("EOA") return self._query_millicelsius("EOA")
def get_controller_serial(self) -> Optional[str]: def get_controller_serial(self) -> Optional[str]:
""" """Controller serial number, or None on error."""
Get controller serial number.
Returns:
Serial number string or None if error
"""
return self._query("CSR") return self._query("CSR")
def get_head_serial(self) -> Optional[str]: def get_head_serial(self) -> Optional[str]:
""" """Laser head serial number, or None on error."""
Get laser head serial number.
Returns:
Serial number string or None if error
"""
return self._query("HSR") return self._query("HSR")
def get_status_registers(self) -> tuple: def get_status_registers(self) -> tuple:
"""Query the LER, LCE and CCE status registers.
Each is a bitmask (sum of flags); non-zero means active faults,
cleared with reset_faults(). Returns (ler, lce, cce), any of which
is None if that register could not be read.
""" """
Query LER, LCE, and CCE status registers. return (self._query_int("LER"), self._query_int("LCE"),
self._query_int("CCE"))
Each register is a bitmask (sum of flags). Non-zero values indicate
active faults. Reset with reset_faults().
Returns:
Tuple of (ler, lce, cce) as ints, or None for each on error.
"""
def _read_reg(cmd):
resp = self._query(cmd)
if resp is not None:
try:
return int(resp)
except ValueError:
logger.error(f"Invalid response for {cmd}: {resp}")
return None
ler = _read_reg("LER")
lce = _read_reg("LCE")
cce = _read_reg("CCE")
return (ler, lce, cce)
def reset_faults(self) -> bool: def reset_faults(self) -> bool:
""" """
@@ -357,38 +307,19 @@ class HeliosLaser:
return ok return ok
def get_remote_enable(self) -> Optional[bool]: def get_remote_enable(self) -> Optional[bool]:
""" """Remote enable state (LRE — utility connector pin 8); None on error."""
Query the remote enable state (LRE - activates utility connector pin 8). value = self._query_int("LRE")
return None if value is None else value == 1
Returns:
True if remote enable is active, False if not, None on error
"""
response = self._query("LRE")
if response is not None:
try:
return int(response) == 1
except ValueError:
logger.error(f"Invalid response for LRE: {response}")
return None
def send_raw_command(self, command: str) -> Optional[str]: def send_raw_command(self, command: str) -> Optional[str]:
""" """Send a raw command and return the unparsed response (diagnostics)."""
Send a raw command string and return the raw response.
Useful for diagnostics. Sends *command* + CR, waits briefly,
then reads whatever the device returns (up to the first CR or timeout).
Returns:
Raw response string (decoded, stripped) or None on error.
"""
if not self.is_connected or not self.serial: if not self.is_connected or not self.serial:
logger.error("Not connected to laser") logger.error("Not connected to laser")
return None return None
try: try:
self.serial.reset_input_buffer() self.serial.reset_input_buffer()
time.sleep(0.05) if not self._send_command(command):
self.serial.write((command + '\r').encode('ascii')) return None
time.sleep(0.3)
raw = self.serial.read_until(b'\r') raw = self.serial.read_until(b'\r')
if not raw: if not raw:
raw = self.serial.read(self.serial.in_waiting) raw = self.serial.read(self.serial.in_waiting)
@@ -410,6 +341,7 @@ class HeliosLaser:
command = f"LRE {1 if enable else 0}" command = f"LRE {1 if enable else 0}"
return self._send_command(command) return self._send_command(command)
def __del__(self): # No __del__: it used to call disconnect(), which disables the laser and
"""Destructor - ensure cleanup""" # writes to the serial port from the garbage collector at an
self.disconnect() # unpredictable time (including interpreter shutdown, when the port may
# already be torn down). Callers close the driver explicitly.
+48 -40
View File
@@ -12,12 +12,18 @@ from .apt_messages import APTProtocol
from .serial_comms import SerialSnooper from .serial_comms import SerialSnooper
# APT bay addresses for the two stage axes
AXIS_X_ADDR = 0x21
AXIS_Y_ADDR = 0x22
class ThorlabsServoDriver(): class ThorlabsServoDriver():
# These are specific to the MLS203-1 # These are specific to the MLS203-1
# change for a different application # change for a different application
counts_per_mm = 20000 counts_per_mm = 20000
accel_scaling = 13.744 accel_scaling = 13.744
velocity_scaling = 134217.73 velocity_scaling = 134217.73
TRAVEL_MM = (110.0, 75.0) # usable travel per channel (X, Y)
def __init__(self): def __init__(self):
self.am_connected = False self.am_connected = False
@@ -83,10 +89,27 @@ class ThorlabsServoDriver():
pass pass
if not self.bays_present: 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 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 ─────────────────────────────────────────── # ── Worker threads ───────────────────────────────────────────
def _tx_worker(self): def _tx_worker(self):
@@ -223,15 +246,18 @@ class ThorlabsServoDriver():
APTProtocol.build_message(0x0002, destination=addr, APTProtocol.build_message(0x0002, destination=addr,
source=0x01)) source=0x01))
time.sleep(0.2) # let TX worker flush them out 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.am_listening = False
self.serial_snoop.stop() self.serial_snoop.stop()
self._rx_thread.join() for thread in (self._rx_thread, self._tx_thread, self._poll_thread,
self._tx_thread.join() self.serial_snoop):
self._poll_thread.join() if thread is not None:
self.serial_snoop.join() thread.join(timeout=2.0)
# Close port only after all threads are done # Close port only after all threads are done
self.serial_snoop.close() self.serial_snoop.close()
self.am_connected = False
# ── State update handlers ──────────────────────────────────── # ── State update handlers ────────────────────────────────────
@@ -305,13 +331,8 @@ class ThorlabsServoDriver():
toggle_enabled_state(axis) - enables the axis if disabled. disables toggle_enabled_state(axis) - enables the axis if disabled. disables
if enabled. not much more to it. if enabled. not much more to it.
''' '''
if axis == 0x21: ch = self._channel_for(axis)
ch = 0 # Read the cached state and invert it
elif axis == 0x22:
ch = 1
else:
raise ValueError("I don't know that axis!")
# get the old state and flip it like a sample
new_state = not self.am_enabled[ch] new_state = not self.am_enabled[ch]
self.send_message(0x0210, chan_ident=1, self.send_message(0x0210, chan_ident=1,
enable_state=0x01 if new_state else 0x02, 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 power up. Default timeout is 60s, but 20-30s is fine as well if
you're in that much of a hurry. you're in that much of a hurry.
''' '''
if axis == 0x21: ch = self._channel_for(axis)
ch = 0
elif axis == 0x22:
ch = 1
else:
raise ValueError("I don't know that axis!")
self.send_and_wait(0x0443, timeout=timeout, retries=0, chan_ident=1, self.send_and_wait(0x0443, timeout=timeout, retries=0, chan_ident=1,
destination=axis, source=0x01) destination=axis, source=0x01)
@@ -340,11 +356,11 @@ class ThorlabsServoDriver():
moves the specified axis a specified distance in mm. moves the specified axis a specified distance in mm.
Timeout defaults to ten seconds. Timeout defaults to ten seconds.
''' '''
# sanity check travel = self.TRAVEL_MM[self._channel_for(axis)]
if axis == 0x21 and abs(distance_in_mm) > 110.0: if abs(distance_in_mm) > travel:
raise ValueError("You can't move farther than the stage is long.") raise ValueError(
elif axis == 0x22 and abs(distance_in_mm) > 75.0: f"Relative move of {distance_in_mm:.3f} mm exceeds the "
raise ValueError("You can't move farther than the stage is wide.") f"{travel:g} mm travel of this axis.")
_distance_in_encoder = int(round(distance_in_mm * self.counts_per_mm)) _distance_in_encoder = int(round(distance_in_mm * self.counts_per_mm))
self.send_and_wait(0x0448, timeout=timeout, chan_ident=1, 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. moves the specified axis to an absolute position in mm.
Timeout defaults to ten seconds. Timeout defaults to ten seconds.
''' '''
if axis == 0x21 and (position_in_mm < 0.0 or position_in_mm > 110.0): ch = self._channel_for(axis)
raise ValueError("Position out of range for X axis (0-110 mm).") travel = self.TRAVEL_MM[ch]
elif axis == 0x22 and (position_in_mm < 0.0 or position_in_mm > 75.0): if not 0.0 <= position_in_mm <= travel:
raise ValueError("Position out of range for Y axis (0-75 mm).") 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)) _position_in_encoder = int(round(position_in_mm * self.counts_per_mm))
self.send_and_wait(0x0453, timeout=timeout, chan_ident=1, 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: for the specified axis. Returns a dict with keys:
min_velocity (mm/s), acceleration (mm/s2), max_velocity (mm/s) min_velocity (mm/s), acceleration (mm/s2), max_velocity (mm/s)
''' '''
if axis == 0x21: ch = self._channel_for(axis)
ch = 0
elif axis == 0x22:
ch = 1
else:
raise ValueError("I don't know that axis!")
result = self.send_and_wait(0x0414, timeout=timeout, chan_ident=1, result = self.send_and_wait(0x0414, timeout=timeout, chan_ident=1,
zero_this=0x00, destination=axis, zero_this=0x00, destination=axis,
@@ -405,12 +418,7 @@ class ThorlabsServoDriver():
Values are in mm/s and mm/s2 respectively. Any parameter Values are in mm/s and mm/s2 respectively. Any parameter
left as None keeps its current value. left as None keeps its current value.
''' '''
if axis == 0x21: ch = self._channel_for(axis)
ch = 0
elif axis == 0x22:
ch = 1
else:
raise ValueError("I don't know that axis!")
# Only query current params if we need to fill in a missing value # Only query current params if we need to fill in a missing value
if max_velocity is None or acceleration is None: if max_velocity is None or acceleration is None:
+87 -65
View File
@@ -12,7 +12,7 @@ from PyQt6.QtWidgets import (
QGroupBox, QLabel, QLineEdit, QPushButton, QComboBox, QSpinBox, QGroupBox, QLabel, QLineEdit, QPushButton, QComboBox, QSpinBox,
QMessageBox, QTabWidget, QTextEdit QMessageBox, QTabWidget, QTextEdit
) )
from PyQt6.QtCore import QThread, pyqtSignal, QObject from PyQt6.QtCore import QThread, pyqtSignal, pyqtSlot, QObject
from PyQt6.QtGui import QFont from PyQt6.QtGui import QFont
from hardware.helios_laser import HeliosLaser, PulseMode from hardware.helios_laser import HeliosLaser, PulseMode
@@ -140,6 +140,20 @@ class LaserWorker(QObject):
super().__init__() super().__init__()
self.laser = laser self.laser = laser
@pyqtSlot(str, object)
def invoke(self, method_name: str, args: tuple):
"""Run one of this worker's methods on the worker thread.
Reached through a queued signal connection, so the serial I/O (and
its blocking reads) stays off the GUI thread. Calling the methods
directly, as this app used to, executes them in the caller's thread
and freezes the UI for the duration.
"""
try:
getattr(self, method_name)(*args)
except Exception as exc:
self.operation_complete.emit(False, str(exc))
def set_frequency(self, freq: int): def set_frequency(self, freq: int):
try: try:
success = self.laser.set_frequency_hz(freq) success = self.laser.set_frequency_hz(freq)
@@ -197,15 +211,13 @@ class LaserWorker(QObject):
self.operation_complete.emit(False, str(e)) self.operation_complete.emit(False, str(e))
def query_power(self): def query_power(self):
try: # HeliosLaser has no power query: the driver README advertises
power = self.laser.get_power_mw() # get_power_mw(), but no such method exists and the protocol
if power is not None: # mnemonic for an output-power read is not documented anywhere in
self.power_updated.emit(power) # this repo. This used to raise AttributeError into a popup.
self.operation_complete.emit(True, f"Power: {power:.2f} mW") # See KNOWN_ISSUES.md — needs the command from the Helios manual.
else: self.operation_complete.emit(
self.operation_complete.emit(False, "Failed to query power") False, "Power query is not implemented (no known protocol command)")
except Exception as e:
self.operation_complete.emit(False, str(e))
def query_enabled(self): def query_enabled(self):
try: try:
@@ -289,6 +301,9 @@ class LaserWorker(QObject):
class HeliosTestApp(QMainWindow): class HeliosTestApp(QMainWindow):
"""Main application window for Helios laser testing.""" """Main application window for Helios laser testing."""
# Dispatches a worker method name + args across the thread boundary.
worker_call = pyqtSignal(str, object)
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.laser = HeliosLaser() self.laser = HeliosLaser()
@@ -586,6 +601,10 @@ class HeliosTestApp(QMainWindow):
power_layout = QHBoxLayout() power_layout = QHBoxLayout()
btn_get_power = QPushButton("Query Power") btn_get_power = QPushButton("Query Power")
btn_get_power.setEnabled(False)
btn_get_power.setToolTip(
"Not implemented: no documented Helios protocol command for "
"output power (see KNOWN_ISSUES.md).")
btn_get_power.clicked.connect(self.on_query_power) btn_get_power.clicked.connect(self.on_query_power)
power_layout.addWidget(btn_get_power) power_layout.addWidget(btn_get_power)
@@ -697,6 +716,9 @@ class HeliosTestApp(QMainWindow):
self.worker = LaserWorker(self.laser) self.worker = LaserWorker(self.laser)
self.worker_thread = QThread() self.worker_thread = QThread()
self.worker.moveToThread(self.worker_thread) self.worker.moveToThread(self.worker_thread)
# Queued (cross-thread) connection: the worker's methods run on
# the worker thread, not on whichever thread emits.
self.worker_call.connect(self.worker.invoke)
self.worker.operation_complete.connect(self.on_operation_complete) self.worker.operation_complete.connect(self.on_operation_complete)
self.worker.frequency_updated.connect(self.on_frequency_updated) self.worker.frequency_updated.connect(self.on_frequency_updated)
self.worker.current_updated.connect(self.on_current_updated) self.worker.current_updated.connect(self.on_current_updated)
@@ -713,11 +735,27 @@ class HeliosTestApp(QMainWindow):
else: else:
QMessageBox.critical(self, "Connection Error", f"Failed to connect to {port}") QMessageBox.critical(self, "Connection Error", f"Failed to connect to {port}")
def _require_connection(self) -> bool:
"""Warn and return False when no laser is connected."""
if self.laser.is_connected and self.worker is not None:
return True
QMessageBox.warning(self, "Error", "Not connected to laser")
return False
def _call_worker(self, method_name: str, *args):
"""Run a worker method on the worker thread via a queued signal."""
if self.worker is not None:
self.worker_call.emit(method_name, args)
def disconnect_laser(self): def disconnect_laser(self):
"""Disconnect from the laser.""" """Disconnect from the laser."""
if self.worker_thread: if self.worker_thread:
self.worker_thread.quit() self.worker_thread.quit()
self.worker_thread.wait() self.worker_thread.wait(2000)
# Drop both references: a reconnect used to leak the previous
# QThread, worker, and all nine signal connections.
self.worker = None
self.worker_thread = None
self.laser.disconnect() self.laser.disconnect()
self.lbl_status.setText("Status: Disconnected") self.lbl_status.setText("Status: Disconnected")
@@ -729,137 +767,121 @@ class HeliosTestApp(QMainWindow):
def on_set_frequency(self): def on_set_frequency(self):
"""Set the laser frequency.""" """Set the laser frequency."""
if not self.laser.is_connected: if not self._require_connection():
QMessageBox.warning(self, "Error", "Not connected to laser")
return return
freq = self.spin_frequency.value() freq = self.spin_frequency.value()
self.worker.set_frequency(freq) self._call_worker("set_frequency", freq)
def on_query_frequency(self): def on_query_frequency(self):
"""Query the laser frequency.""" """Query the laser frequency."""
if not self.laser.is_connected: if not self._require_connection():
QMessageBox.warning(self, "Error", "Not connected to laser")
return return
self.worker.query_frequency() self._call_worker("query_frequency")
def on_set_current(self): def on_set_current(self):
"""Set the laser current.""" """Set the laser current."""
if not self.laser.is_connected: if not self._require_connection():
QMessageBox.warning(self, "Error", "Not connected to laser")
return return
current = self.spin_current.value() current = self.spin_current.value()
self.worker.set_current(current) self._call_worker("set_current", current)
def on_query_current(self): def on_query_current(self):
"""Query the laser current.""" """Query the laser current."""
if not self.laser.is_connected: if not self._require_connection():
QMessageBox.warning(self, "Error", "Not connected to laser")
return return
self.worker.query_current() self._call_worker("query_current")
def on_set_mode(self): def on_set_mode(self):
"""Set the laser pulse mode.""" """Set the laser pulse mode."""
if not self.laser.is_connected: if not self._require_connection():
QMessageBox.warning(self, "Error", "Not connected to laser")
return return
mode = self.combo_mode.currentData() mode = self.combo_mode.currentData()
self.worker.set_pulse_mode(mode) self._call_worker("set_pulse_mode", mode)
def on_enable_laser(self): def on_enable_laser(self):
"""Enable the laser.""" """Enable the laser."""
if not self.laser.is_connected: if not self._require_connection():
QMessageBox.warning(self, "Error", "Not connected to laser")
return return
self.worker.set_laser_enable(True) self._call_worker("set_laser_enable", True)
def on_disable_laser(self): def on_disable_laser(self):
"""Disable the laser.""" """Disable the laser."""
if not self.laser.is_connected: if not self._require_connection():
QMessageBox.warning(self, "Error", "Not connected to laser")
return return
self.worker.set_laser_enable(False) self._call_worker("set_laser_enable", False)
def on_query_enabled(self): def on_query_enabled(self):
"""Query if laser is enabled.""" """Query if laser is enabled."""
if not self.laser.is_connected: if not self._require_connection():
QMessageBox.warning(self, "Error", "Not connected to laser")
return return
self.worker.query_enabled() self._call_worker("query_enabled")
def on_query_power(self): def on_query_power(self):
"""Query the laser output power.""" """Query the laser output power."""
if not self.laser.is_connected: if not self._require_connection():
QMessageBox.warning(self, "Error", "Not connected to laser")
return return
self.worker.query_power() self._call_worker("query_power")
def on_query_all(self): def on_query_all(self):
"""Query all laser parameters.""" """Query all laser parameters."""
if not self.laser.is_connected: if not self._require_connection():
QMessageBox.warning(self, "Error", "Not connected to laser")
return return
self.worker.query_serials() self._call_worker("query_serials")
self.worker.query_frequency() self._call_worker("query_frequency")
self.worker.query_current() self._call_worker("query_current")
self.worker.query_power() self._call_worker("query_power")
self.worker.query_enabled() self._call_worker("query_enabled")
self.worker.query_status_registers() self._call_worker("query_status_registers")
self.worker.query_remote_enable() self._call_worker("query_remote_enable")
def on_query_status(self): def on_query_status(self):
"""Query LER/LCE/CCE status registers.""" """Query LER/LCE/CCE status registers."""
if not self.laser.is_connected: if not self._require_connection():
QMessageBox.warning(self, "Error", "Not connected to laser")
return return
self.worker.query_status_registers() self._call_worker("query_status_registers")
def on_reset_faults(self): def on_reset_faults(self):
"""Send the fault reset sequence.""" """Send the fault reset sequence."""
if not self.laser.is_connected: if not self._require_connection():
QMessageBox.warning(self, "Error", "Not connected to laser")
return return
self.worker.do_reset_faults() self._call_worker("do_reset_faults")
def on_query_remote_enable(self): def on_query_remote_enable(self):
"""Query the remote enable (LRE) state.""" """Query the remote enable (LRE) state."""
if not self.laser.is_connected: if not self._require_connection():
QMessageBox.warning(self, "Error", "Not connected to laser")
return return
self.worker.query_remote_enable() self._call_worker("query_remote_enable")
def on_set_remote_enable(self, enable: bool): def on_set_remote_enable(self, enable: bool):
"""Set the remote enable (LRE) state.""" """Set the remote enable (LRE) state."""
if not self.laser.is_connected: if not self._require_connection():
QMessageBox.warning(self, "Error", "Not connected to laser")
return return
self.worker.set_remote_enable(enable) self._call_worker("set_remote_enable", enable)
def on_ler_reset(self): def on_ler_reset(self):
"""Send LER 0 only.""" """Send LER 0 only."""
if not self.laser.is_connected: if not self._require_connection():
QMessageBox.warning(self, "Error", "Not connected to laser")
return return
self.worker.do_ler_reset() self._call_worker("do_ler_reset")
def on_send_raw(self): def on_send_raw(self):
"""Send the raw command from the terminal input.""" """Send the raw command from the terminal input."""
if not self.laser.is_connected: if not self._require_connection():
QMessageBox.warning(self, "Error", "Not connected to laser")
return return
cmd = self.le_raw_cmd.text().strip() cmd = self.le_raw_cmd.text().strip()
if not cmd: if not cmd:
return return
self.worker.send_raw(cmd) self._call_worker("send_raw", cmd)
def on_raw_response(self, cmd: str, response: str): def on_raw_response(self, cmd: str, response: str):
"""Display raw TX/RX pair in the terminal log.""" """Display raw TX/RX pair in the terminal log."""
+140 -204
View File
@@ -5,7 +5,6 @@ Loads sc3-aui-main.ui, sc3-aui-camera.ui, sc3-aui-scanprogress.ui via uic
and wires up T3R, BBD202, oscilloscope, and camera hardware workers. and wires up T3R, BBD202, oscilloscope, and camera hardware workers.
""" """
import queue
import struct import struct
import sys import sys
import time import time
@@ -14,7 +13,7 @@ from threading import Thread
import numpy as np import numpy as np
from PyQt6 import uic from PyQt6 import uic
from PyQt6.QtCore import QObject, QThread, QTimer, Qt, pyqtSignal, pyqtSlot from PyQt6.QtCore import QObject, QThread, QTimer, Qt, pyqtSignal
from PyQt6.QtGui import QImage, QPixmap from PyQt6.QtGui import QImage, QPixmap
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QApplication, QDialog, QDialogButtonBox, QFileDialog, QHBoxLayout, QLabel, QApplication, QDialog, QDialogButtonBox, QFileDialog, QHBoxLayout, QLabel,
@@ -38,6 +37,7 @@ from core.scan_resume import is_compatible, plan_resume
from core.scope_sras import SAMPLE_RATE_HZ, configure_channels from core.scope_sras import SAMPLE_RATE_HZ, configure_channels
from core.sras_format import SCAN_CHANNELS, SrasFile, plan_from_header from core.sras_format import SCAN_CHANNELS, SrasFile, plan_from_header
from gui.qt_t3r import QtT3RAdapter from gui.qt_t3r import QtT3RAdapter
from gui.qt_workers import PollingQueueWorker, QueueWorker
from gui.scan_bridge import QtScanController from gui.scan_bridge import QtScanController
from hardware.helios_laser import HeliosLaser from hardware.helios_laser import HeliosLaser
from hardware.pybbd202 import AXIS_X, AXIS_Y, ThorlabsServoDriver from hardware.pybbd202 import AXIS_X, AXIS_Y, ThorlabsServoDriver
@@ -53,7 +53,13 @@ BBD_DEFAULT_JOG_MM = 0.5 # default jog step for BBD202
class DCBiasImageWidget(FigureCanvas): class DCBiasImageWidget(FigureCanvas):
"""Live 2D DC-bias image: rows × frames, matching sras_viewer's compute_dc_image view.""" """Live 2D DC-bias image: rows × frames, matching the viewer's DC image.
The image buffer is allocated once per scan and updated in place. The
previous version rebuilt the whole array and the entire matplotlib
artist tree on every row, so the live preview got steadily slower as a
scan progressed (O(rows²) work for one scan).
"""
def __init__(self, parent=None): def __init__(self, parent=None):
fig = Figure(tight_layout=True) fig = Figure(tight_layout=True)
@@ -65,36 +71,59 @@ class DCBiasImageWidget(FigureCanvas):
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding,
) )
self.ax = fig.add_subplot(111) self.ax = fig.add_subplot(111)
self._row_data: list[np.ndarray] = [] self._img: np.ndarray | None = None
self._im = None self._im = None
self.ax.set_title("DC Bias Preview (ADC counts)") self._placeholder = None
self.ax.set_xlabel("Frame") self._reset_axes()
self.ax.set_ylabel("Row")
self.ax.text(0.5, 0.5, "Waiting for data…",
transform=self.ax.transAxes, ha="center", va="center", color="gray")
self.draw()
def add_row(self, row_index: int, values):
"""Store per-frame DC mean values for row_index (1-based) and refresh the image."""
idx = row_index - 1
while len(self._row_data) <= idx:
self._row_data.append(np.empty(0, dtype=np.float32))
self._row_data[idx] = np.asarray(values, dtype=np.float32)
# Build rectangular image — pad shorter rows with NaN so they render distinctly
max_len = max(len(r) for r in self._row_data)
img = np.full((len(self._row_data), max_len), np.nan, dtype=np.float32)
for i, row in enumerate(self._row_data):
img[i, :len(row)] = row
def _reset_axes(self):
self.ax.clear() self.ax.clear()
self.ax.set_title("DC Bias Preview (ADC counts)") self.ax.set_title("DC Bias Preview (ADC counts)")
self.ax.set_xlabel("Frame") self.ax.set_xlabel("Frame")
self.ax.set_ylabel("Row") self.ax.set_ylabel("Row")
self.ax.imshow( self._placeholder = self.ax.text(
img, aspect="auto", origin="upper", 0.5, 0.5, "Waiting for data…", transform=self.ax.transAxes,
ha="center", va="center", color="gray")
self._im = None
self.draw_idle()
def begin_scan(self, n_rows: int, n_frames: int):
"""Preallocate the image for a scan of this shape."""
self._img = np.full((max(1, n_rows), max(1, n_frames)), np.nan,
dtype=np.float32)
self._reset_axes()
def add_row(self, row_index: int, values):
"""Store per-frame DC means for row_index (1-based) and refresh."""
row = np.asarray(values, dtype=np.float32)
idx = row_index - 1
# Grow only if the scan turned out taller/wider than announced.
if self._img is None:
self.begin_scan(idx + 1, len(row))
if idx >= self._img.shape[0] or len(row) > self._img.shape[1]:
grown = np.full((max(idx + 1, self._img.shape[0]),
max(len(row), self._img.shape[1])),
np.nan, dtype=np.float32)
grown[:self._img.shape[0], :self._img.shape[1]] = self._img
self._img = grown
self._im = None # extent changed; rebuild the artist once
self._img[idx, :len(row)] = row
if self._im is None:
if self._placeholder is not None:
self._placeholder.remove()
self._placeholder = None
self._im = self.ax.imshow(
self._img, aspect="auto", origin="upper",
cmap="viridis", interpolation="nearest", cmap="viridis", interpolation="nearest",
) )
else:
self._im.set_data(self._img)
finite = self._img[np.isfinite(self._img)]
if finite.size:
self._im.set_clim(float(finite.min()), float(finite.max()))
self.draw_idle() self.draw_idle()
@@ -103,61 +132,35 @@ BBD_JOG_ACCEL_MM_S2 = 50.0
# ── BBD202 worker ───────────────────────────────────────────────────────────── # ── BBD202 worker ─────────────────────────────────────────────────────────────
class BBD202Worker(QObject): class BBD202Worker(PollingQueueWorker):
"""Manages ThorlabsServoDriver (MLS203-1) in a worker thread.""" """Manages ThorlabsServoDriver (MLS203-1) in a worker thread."""
connected = pyqtSignal()
disconnected = pyqtSignal()
connection_failed = pyqtSignal(str)
position_updated = pyqtSignal(float, float) # x_mm, y_mm position_updated = pyqtSignal(float, float) # x_mm, y_mm
homed_status = pyqtSignal(bool, bool) # x_homed, y_homed homed_status = pyqtSignal(bool, bool) # x_homed, y_homed
error_occurred = pyqtSignal(str)
POLL_INTERVAL_S = 0.2
def __init__(self): def __init__(self):
super().__init__() super().__init__(poll_interval_s=self.POLL_INTERVAL_S)
self.controller: ThorlabsServoDriver | None = None self.controller: ThorlabsServoDriver | None = None
self._cmd_q: queue.Queue = queue.Queue() self.scanning_active = False # pause polling during a scan
self._running = False
self.is_connected = False
self.scanning_active = False # pause polling during scan
self._jog_step = BBD_DEFAULT_JOG_MM self._jog_step = BBD_DEFAULT_JOG_MM
self._last_x: float | None = None self._last_pos: tuple[float, float] | None = None
self._last_y: float | None = None self._last_homed: tuple[bool, bool] | None = None
self._last_xh: bool | None = None self._handlers.update({
self._last_yh: bool | None = None "connect": self._do_connect,
self._last_poll_t = 0.0 "disconnect": self._do_disconnect,
"jog": self._do_jog,
"home_all": self._do_home_all,
"enable_all": self._do_enable_all,
})
@pyqtSlot() def _on_stop(self):
def run(self):
self._running = True
while self._running:
try:
cmd = self._cmd_q.get(timeout=0.05)
self._dispatch(cmd)
except queue.Empty:
pass
if self.is_connected and self.controller and not self.scanning_active:
self._poll()
if self.controller: if self.controller:
try: try:
self.controller.disconnect() self.controller.disconnect()
except Exception: except Exception:
pass pass
def _dispatch(self, cmd: dict):
t = cmd["type"]
if t == "connect":
self._do_connect(cmd["port"])
elif t == "disconnect":
self._do_disconnect()
elif t == "jog":
self._do_jog(cmd["axis"], cmd["direction"])
elif t == "home_all":
self._do_home_all()
elif t == "enable_all":
self._do_enable_all()
elif t == "stop":
self._running = False
def _do_connect(self, port: str): def _do_connect(self, port: str):
try: try:
self.controller = ThorlabsServoDriver() self.controller = ThorlabsServoDriver()
@@ -173,10 +176,12 @@ class BBD202Worker(QObject):
acceleration=BBD_JOG_ACCEL_MM_S2) acceleration=BBD_JOG_ACCEL_MM_S2)
self.is_connected = True self.is_connected = True
self.connected.emit() self.connected.emit()
self.start_polling()
except Exception as e: except Exception as e:
self.connection_failed.emit(str(e)) self.connection_failed.emit(str(e))
def _do_disconnect(self): def _do_disconnect(self):
self.stop_polling()
if self.controller: if self.controller:
try: try:
self.controller.disconnect() self.controller.disconnect()
@@ -194,8 +199,6 @@ class BBD202Worker(QObject):
self.controller.move_axis_relative(dest, self._jog_step * direction, timeout=2.0) self.controller.move_axis_relative(dest, self._jog_step * direction, timeout=2.0)
except TimeoutError: except TimeoutError:
pass pass
except Exception as e:
self.error_occurred.emit(str(e))
def _do_home_all(self): def _do_home_all(self):
if not self.controller: if not self.controller:
@@ -214,79 +217,54 @@ class BBD202Worker(QObject):
self.controller.toggle_enabled_state(AXIS_X) self.controller.toggle_enabled_state(AXIS_X)
self.controller.toggle_enabled_state(AXIS_Y) self.controller.toggle_enabled_state(AXIS_Y)
def _poll(self): def _poll_once(self):
now = time.time() """Emit position/homed updates, but only when they actually change."""
if now - self._last_poll_t < 0.2: if self.scanning_active or not self.controller:
return return
self._last_poll_t = now
try: try:
x = self.controller.positions[0] pos = (self.controller.positions[0], self.controller.positions[1])
y = self.controller.positions[1] if pos != self._last_pos:
if x != self._last_x or y != self._last_y: self._last_pos = pos
self._last_x = x self.position_updated.emit(*pos)
self._last_y = y homed = (self.controller.am_homed[0], self.controller.am_homed[1])
self.position_updated.emit(x, y) if homed != self._last_homed:
xh = self.controller.am_homed[0] self._last_homed = homed
yh = self.controller.am_homed[1] self.homed_status.emit(*homed)
if xh != self._last_xh or yh != self._last_yh:
self._last_xh = xh
self._last_yh = yh
self.homed_status.emit(xh, yh)
except Exception: except Exception:
pass pass
# ── Queue API ───────────────────────────────────────────────────────────── # ── Queue API ─────────────────────────────────────────────────────────────
def queue_connect(self, port: str): def queue_connect(self, port: str):
self._cmd_q.put({"type": "connect", "port": port}) self._enqueue("connect", port=port)
def queue_disconnect(self): def queue_disconnect(self):
self._cmd_q.put({"type": "disconnect"}) self._enqueue("disconnect")
def queue_jog(self, axis: str, direction: int): def queue_jog(self, axis: str, direction: int):
self._cmd_q.put({"type": "jog", "axis": axis, "direction": direction}) self._enqueue("jog", axis=axis, direction=direction)
def queue_home_all(self): def queue_home_all(self):
self._cmd_q.put({"type": "home_all"}) self._enqueue("home_all")
def queue_enable_all(self): def queue_enable_all(self):
self._cmd_q.put({"type": "enable_all"}) self._enqueue("enable_all")
def stop_worker(self):
self._cmd_q.put({"type": "stop"})
# ── Oscilloscope worker ─────────────────────────────────────────────────────── # ── Oscilloscope worker ───────────────────────────────────────────────────────
class OscopeWorker(QObject): class OscopeWorker(QueueWorker):
"""Manages TektronixOscilloscopeBase in a worker thread.""" """Manages TektronixOscilloscopeBase in a worker thread."""
connected = pyqtSignal()
disconnected = pyqtSignal()
connection_failed = pyqtSignal(str)
error_occurred = pyqtSignal(str)
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.scope: TektronixOscilloscopeBase | None = None self.scope: TektronixOscilloscopeBase | None = None
self._cmd_q: queue.Queue = queue.Queue() self._handlers.update({
self._running = False "connect": self._do_connect,
self.is_connected = False "disconnect": self._do_disconnect,
})
@pyqtSlot() def _on_stop(self):
def run(self):
self._running = True
while self._running:
try:
cmd = self._cmd_q.get(timeout=0.1)
t = cmd["type"]
if t == "connect":
self._do_connect(cmd["ip"])
elif t == "disconnect":
self._do_disconnect()
elif t == "stop":
self._running = False
except queue.Empty:
pass
if self.scope: if self.scope:
try: try:
self.scope.disconnect() self.scope.disconnect()
@@ -314,67 +292,48 @@ class OscopeWorker(QObject):
self.disconnected.emit() self.disconnected.emit()
def queue_connect(self, ip: str): def queue_connect(self, ip: str):
self._cmd_q.put({"type": "connect", "ip": ip}) self._enqueue("connect", ip=ip)
def queue_disconnect(self): def queue_disconnect(self):
self._cmd_q.put({"type": "disconnect"}) self._enqueue("disconnect")
def stop_worker(self):
self._cmd_q.put({"type": "stop"})
# ── Helios laser worker ─────────────────────────────────────────────────────── # ── Helios laser worker ───────────────────────────────────────────────────────
class HeliosWorker(QObject): class HeliosWorker(PollingQueueWorker):
"""Manages HeliosLaser in a worker thread using a command queue.""" """Manages HeliosLaser in a worker thread using a command queue.
connected = pyqtSignal()
disconnected = pyqtSignal() Status polling is self-rescheduling: the next poll is queued only once
connection_failed = pyqtSignal(str) the previous completes. A free-running 1 s timer used to queue a ~2 s
job, so the queue grew without bound and the panel fell further behind
the longer it stayed connected.
"""
enabled_updated = pyqtSignal(bool) enabled_updated = pyqtSignal(bool)
current_updated = pyqtSignal(int) current_updated = pyqtSignal(int)
diode_temp_updated = pyqtSignal(float) diode_temp_updated = pyqtSignal(float)
pstage_temp_updated = pyqtSignal(float) pstage_temp_updated = pyqtSignal(float)
qswitch_temp_updated = pyqtSignal(float) qswitch_temp_updated = pyqtSignal(float)
status_registers_updated = pyqtSignal(object, object, object) # LER, LCE, CCE (int|None) status_registers_updated = pyqtSignal(object, object, object) # LER, LCE, CCE (int|None)
error_occurred = pyqtSignal(str)
POLL_INTERVAL_S = 1.0
def __init__(self): def __init__(self):
super().__init__() super().__init__(poll_interval_s=self.POLL_INTERVAL_S)
self._laser: HeliosLaser | None = None self._laser: HeliosLaser | None = None
self._cmd_q: queue.Queue = queue.Queue() self._handlers.update({
self._running = False "connect": self._do_connect,
self.is_connected = False "disconnect": self._do_disconnect,
"set_enable": self._do_set_enable,
"set_current": self._do_set_current,
})
@pyqtSlot() def _on_stop(self):
def run(self):
self._running = True
while self._running:
try:
cmd = self._cmd_q.get(timeout=0.1)
self._dispatch(cmd)
except queue.Empty:
pass
if self._laser: if self._laser:
try: try:
self._laser.disconnect() self._laser.disconnect()
except Exception: except Exception:
pass pass
def _dispatch(self, cmd: dict):
t = cmd["type"]
if t == "connect":
self._do_connect(cmd["port"])
elif t == "disconnect":
self._do_disconnect()
elif t == "set_enable":
self._do_set_enable(cmd["enable"])
elif t == "set_current":
self._do_set_current(cmd["current_ma"])
elif t == "poll_status":
self._do_poll_status()
elif t == "stop":
self._running = False
def _do_connect(self, port: str): def _do_connect(self, port: str):
try: try:
self._laser = HeliosLaser(timeout=1.0) self._laser = HeliosLaser(timeout=1.0)
@@ -384,12 +343,13 @@ class HeliosWorker(QObject):
return return
self.is_connected = True self.is_connected = True
self.connected.emit() self.connected.emit()
self._do_poll_status() self.start_polling()
except Exception as e: except Exception as e:
self._laser = None self._laser = None
self.connection_failed.emit(str(e)) self.connection_failed.emit(str(e))
def _do_disconnect(self): def _do_disconnect(self):
self.stop_polling()
if self._laser: if self._laser:
try: try:
self._laser.set_laser_enable(False) self._laser.set_laser_enable(False)
@@ -406,75 +366,52 @@ class HeliosWorker(QObject):
def _do_set_enable(self, enable: bool): def _do_set_enable(self, enable: bool):
if not self._laser: if not self._laser:
return return
try:
self._laser.set_laser_enable(enable) self._laser.set_laser_enable(enable)
self.enabled_updated.emit(self._laser.is_laser_enabled()) self.enabled_updated.emit(self._laser.is_laser_enabled())
except Exception as e:
self.error_occurred.emit(str(e))
def _do_set_current(self, current_ma: int): def _do_set_current(self, current_ma: int):
if not self._laser: if not self._laser:
return return
try:
self._laser.set_current_ma(current_ma) self._laser.set_current_ma(current_ma)
self.current_updated.emit(current_ma) self.current_updated.emit(current_ma)
except Exception as e:
self.error_occurred.emit(str(e))
def _do_poll_status(self): def _poll_once(self):
"""Read the full status set. Individual reads are allowed to fail
(a timed-out register shouldn't suppress the rest of the panel)."""
if not self._laser or not self._laser.is_connected: if not self._laser or not self._laser.is_connected:
return return
try: try:
ler, lce, cce = self._laser.get_status_registers() self.status_registers_updated.emit(*self._laser.get_status_registers())
self.status_registers_updated.emit(ler, lce, cce)
except Exception: except Exception:
pass pass
try: try:
self.enabled_updated.emit(self._laser.is_laser_enabled()) self.enabled_updated.emit(self._laser.is_laser_enabled())
except Exception: except Exception:
pass pass
for getter, signal in (
(self._laser.get_current_ma, self.current_updated),
(self._laser.get_diode_temp_c, self.diode_temp_updated),
(self._laser.get_power_stage_temp_c, self.pstage_temp_updated),
(self._laser.get_qswitch_temp_c, self.qswitch_temp_updated),
):
try: try:
current = self._laser.get_current_ma() value = getter()
if current is not None: if value is not None:
self.current_updated.emit(current) signal.emit(value)
except Exception:
pass
try:
t = self._laser.get_diode_temp_c()
if t is not None:
self.diode_temp_updated.emit(t)
except Exception:
pass
try:
t = self._laser.get_power_stage_temp_c()
if t is not None:
self.pstage_temp_updated.emit(t)
except Exception:
pass
try:
t = self._laser.get_qswitch_temp_c()
if t is not None:
self.qswitch_temp_updated.emit(t)
except Exception: except Exception:
pass pass
def queue_connect(self, port: str): def queue_connect(self, port: str):
self._cmd_q.put({"type": "connect", "port": port}) self._enqueue("connect", port=port)
def queue_disconnect(self): def queue_disconnect(self):
self._cmd_q.put({"type": "disconnect"}) self._enqueue("disconnect")
def queue_set_enable(self, enable: bool): def queue_set_enable(self, enable: bool):
self._cmd_q.put({"type": "set_enable", "enable": enable}) self._enqueue("set_enable", enable=enable)
def queue_set_current(self, current_ma: int): def queue_set_current(self, current_ma: int):
self._cmd_q.put({"type": "set_current", "current_ma": current_ma}) self._enqueue("set_current", current_ma=current_ma)
def queue_poll_status(self):
self._cmd_q.put({"type": "poll_status"})
def stop_worker(self):
self._cmd_q.put({"type": "stop"})
# ── Camera window popup ─────────────────────────────────────────────────────── # ── Camera window popup ───────────────────────────────────────────────────────
@@ -633,17 +570,13 @@ class HeliosWindow(QWidget):
self.setWindowTitle("Helios Laser") self.setWindowTitle("Helios Laser")
self._worker = worker self._worker = worker
# Polling is driven by the worker itself (self-rescheduling), so
self._poll_timer = QTimer(self) # there is no timer here to outpace the device.
self._poll_timer.setInterval(1000)
self._poll_timer.timeout.connect(self._worker.queue_poll_status)
self._wire_signals() self._wire_signals()
self._update_controls(False) self._update_controls(False)
self._set_emission_indicator(False) self._set_emission_indicator(False)
def closeEvent(self, event): def closeEvent(self, event):
self._poll_timer.stop()
if self._worker.is_connected: if self._worker.is_connected:
self._worker.queue_disconnect() self._worker.queue_disconnect()
super().closeEvent(event) super().closeEvent(event)
@@ -683,7 +616,6 @@ class HeliosWindow(QWidget):
DEFAULTS.save() DEFAULTS.save()
self._worker.queue_connect(port) self._worker.queue_connect(port)
else: else:
self._poll_timer.stop()
self._worker.queue_disconnect() self._worker.queue_disconnect()
def _on_connected(self): def _on_connected(self):
@@ -691,10 +623,8 @@ class HeliosWindow(QWidget):
self.helios_connect_toggle.setText("Disconnect") self.helios_connect_toggle.setText("Disconnect")
self.helios_status_label.setText("Connected") self.helios_status_label.setText("Connected")
self._update_controls(True) self._update_controls(True)
self._poll_timer.start()
def _on_disconnected(self): def _on_disconnected(self):
self._poll_timer.stop()
self.helios_connect_toggle.blockSignals(True) self.helios_connect_toggle.blockSignals(True)
self.helios_connect_toggle.setChecked(False) self.helios_connect_toggle.setChecked(False)
self.helios_connect_toggle.setText("Connect") self.helios_connect_toggle.setText("Connect")
@@ -905,6 +835,10 @@ class ScanProgressWindow(QWidget):
def update_dc_bias_plot(self, row: int, values): def update_dc_bias_plot(self, row: int, values):
self.bias_image_widget.add_row(row, values) self.bias_image_widget.add_row(row, values)
def begin_scan(self, n_rows: int, n_frames: int):
"""Size the DC preview for the scan about to run."""
self.bias_image_widget.begin_scan(n_rows, n_frames)
# ── Scan worker ─────────────────────────────────────────────────────────────── # ── Scan worker ───────────────────────────────────────────────────────────────
@@ -1342,6 +1276,8 @@ class MainWindow(QMainWindow):
0, plan.per_angle[ai0].n_rows, 0, plan.per_angle[ai0].n_rows,
0 if resume is None else ai0 + 1, plan.n_angles 0 if resume is None else ai0 + 1, plan.n_angles
) )
self._scan_progress.begin_scan(plan.per_angle[ai0].n_rows,
plan.per_angle[ai0].n_frames)
self._scan_progress.show() self._scan_progress.show()
self._scan_thread.start() self._scan_thread.start()
+172
View File
@@ -0,0 +1,172 @@
"""gui.qt_workers: the shared queue/poll worker base."""
import threading
import time
import pytest
from PyQt6.QtCore import QThread
from PyQt6.QtWidgets import QApplication
from gui.qt_workers import PollingQueueWorker, QueueWorker
@pytest.fixture(scope="module")
def qapp():
yield QApplication.instance() or QApplication([])
class _Recorder(QueueWorker):
def __init__(self):
super().__init__()
self.seen = []
self.stopped = threading.Event()
self._handlers.update({
"note": self._note,
"boom": self._boom,
})
def _note(self, value):
self.seen.append(value)
def _boom(self):
raise RuntimeError("handler failed")
def _on_stop(self):
self.stopped.set()
def _run_until(worker, predicate, timeout=5.0):
"""Run the worker loop on a plain thread until predicate() is true.
Pumps the Qt event loop while waiting: signals emitted from the worker
thread are delivered as queued events on this (main) thread.
"""
app = QApplication.instance()
t = threading.Thread(target=worker.run, daemon=True)
t.start()
deadline = time.monotonic() + timeout
while not predicate() and time.monotonic() < deadline:
app.processEvents()
time.sleep(0.01)
app.processEvents()
return t
def test_commands_dispatch_in_order(qapp):
w = _Recorder()
for i in range(5):
w._enqueue("note", value=i)
t = _run_until(w, lambda: len(w.seen) == 5)
w.stop_worker()
t.join(timeout=5)
assert w.seen == [0, 1, 2, 3, 4]
assert w.stopped.is_set()
def test_handler_exception_is_reported_not_fatal(qapp):
w = _Recorder()
errors = []
w.error_occurred.connect(errors.append)
w._enqueue("boom")
w._enqueue("note", value="after")
t = _run_until(w, lambda: w.seen == ["after"])
w.stop_worker()
t.join(timeout=5)
assert w.seen == ["after"], "loop died on a failing handler"
assert errors and "handler failed" in errors[0]
def test_unknown_command_reported(qapp):
w = _Recorder()
errors = []
w.error_occurred.connect(errors.append)
w._enqueue("nope")
t = _run_until(w, lambda: bool(errors))
w.stop_worker()
t.join(timeout=5)
assert errors and "Unknown command" in errors[0]
def test_idle_worker_does_not_spin(qapp):
"""The loop must block on the queue, not poll it on a timeout."""
w = _Recorder()
t = threading.Thread(target=w.run, daemon=True)
t.start()
time.sleep(0.3) # idle
cpu_before = time.process_time()
time.sleep(0.5) # still idle
cpu_used = time.process_time() - cpu_before
w.stop_worker()
t.join(timeout=5)
# A 10–20 Hz timeout-poll loop burns measurable CPU here; blocking uses ~0.
assert cpu_used < 0.05, f"idle worker used {cpu_used:.3f}s CPU"
class _Poller(PollingQueueWorker):
def __init__(self):
super().__init__(poll_interval_s=0.02)
self.polls = 0
self.in_flight = 0
self.overlaps = 0
self.is_connected = True
def _poll_once(self):
self.in_flight += 1
if self.in_flight > 1:
self.overlaps += 1
time.sleep(0.05) # deliberately slower than the poll interval
self.polls += 1
self.in_flight -= 1
def test_polling_never_overlaps_or_backs_up(qapp):
"""A device slower than the interval must not accumulate stale polls."""
w = _Poller()
t = threading.Thread(target=w.run, daemon=True)
t.start()
w.start_polling()
time.sleep(0.6)
w.stop_polling()
time.sleep(0.15)
queued = w._cmd_q.qsize()
w.stop_worker()
t.join(timeout=5)
assert w.polls >= 3, "polling did not run"
assert w.overlaps == 0, "polls overlapped"
# Self-rescheduling means at most one poll is ever pending.
assert queued <= 1, f"{queued} stale polls queued up"
def test_stop_polling_halts_the_cycle(qapp):
w = _Poller()
t = threading.Thread(target=w.run, daemon=True)
t.start()
w.start_polling()
time.sleep(0.2)
w.stop_polling()
time.sleep(0.2)
settled = w.polls
time.sleep(0.2)
w.stop_worker()
t.join(timeout=5)
assert w.polls == settled, "polling continued after stop_polling()"
def test_worker_runs_on_its_qthread(qapp):
"""Sanity check the intended usage: run() executes on the QThread."""
w = _Recorder()
thread = QThread()
w.moveToThread(thread)
thread.started.connect(w.run)
ids = []
w._handlers["note"] = lambda value: ids.append(threading.get_ident())
thread.start()
w._enqueue("note", value=None)
deadline = time.monotonic() + 5
while not ids and time.monotonic() < deadline:
qapp.processEvents()
time.sleep(0.01)
w.stop_worker()
thread.quit()
assert thread.wait(5000)
assert ids and ids[0] != threading.get_ident()