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
[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
`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 "")
+57 -125
View File
@@ -115,37 +115,36 @@ class HeliosLaser:
return False
def _query(self, command: str) -> Optional[str]:
"""
Send a query and read response.
"""Send a query and return the value from its response.
Args:
command: ASCII query command (without CR or ?)
Returns:
Response value string or None if error
Reads until the CR terminator rather than sleeping a fixed interval:
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
take ~2 s — longer than the 1 s interval that scheduled it.
"""
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()
time.sleep(0.05)
if not self._send_command(command):
return None
time.sleep(0.2) # Give device time to respond
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}")
# Helios format: "COMMAND = VALUE UNIT"
# Extract just the value part
# Helios format: "COMMAND = VALUE UNIT" — take just the value
if '=' in response:
parts = response.split('=')
if len(parts) >= 2:
value_part = parts[1].strip()
# Remove unit suffix if present (e.g., "ns", "mA", "mW")
value = value_part.split()[0]
return value
# Strip the unit suffix if present (e.g. "ns", "mA", "mW")
fields = value_part.split()
if fields:
return fields[0]
return response
@@ -153,6 +152,17 @@ class HeliosLaser:
logger.error(f"Failed to read response for '{command}': {e}")
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:
"""
Set laser pulse frequency in Hz.
@@ -228,60 +238,24 @@ class HeliosLaser:
return success
def is_laser_enabled(self) -> bool:
"""
Check if laser is currently enabled.
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
"""True if laser emission is currently enabled."""
return self._query_int("LDO") == 1
def get_frequency_hz(self) -> Optional[int]:
"""
Get current laser frequency in Hz.
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
"""Current laser frequency in Hz, or None on error."""
period_ns = self._query_int("LDF")
if not period_ns:
return None
return int(1e9 / period_ns)
def get_current_ma(self) -> Optional[int]:
"""
Get current pump diode current in mA.
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
"""Current pump diode current in mA, or None on error."""
return self._query_int("LDS")
def _query_millicelsius(self, command: str) -> Optional[float]:
"""Query a temperature register (returns milli-°C) and convert to °C."""
response = self._query(command)
if response:
try:
return int(response) / 1000.0
except ValueError:
logger.error(f"Invalid response for {command}: {response}")
return None
"""Query a temperature register (milli-°C) and convert to °C."""
value = self._query_int(command)
return None if value is None else value / 1000.0
def get_diode_temp_c(self) -> Optional[float]:
"""Diode temperature in °C (LTA, 5000–50000 milli-°C)."""
@@ -296,46 +270,22 @@ class HeliosLaser:
return self._query_millicelsius("EOA")
def get_controller_serial(self) -> Optional[str]:
"""
Get controller serial number.
Returns:
Serial number string or None if error
"""
"""Controller serial number, or None on error."""
return self._query("CSR")
def get_head_serial(self) -> Optional[str]:
"""
Get laser head serial number.
Returns:
Serial number string or None if error
"""
"""Laser head serial number, or None on error."""
return self._query("HSR")
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.
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)
return (self._query_int("LER"), self._query_int("LCE"),
self._query_int("CCE"))
def reset_faults(self) -> bool:
"""
@@ -357,38 +307,19 @@ class HeliosLaser:
return ok
def get_remote_enable(self) -> Optional[bool]:
"""
Query the remote enable state (LRE - activates utility connector pin 8).
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
"""Remote enable state (LRE — utility connector pin 8); None on error."""
value = self._query_int("LRE")
return None if value is None else value == 1
def send_raw_command(self, command: str) -> Optional[str]:
"""
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.
"""
"""Send a raw command and return the unparsed response (diagnostics)."""
if not self.is_connected or not self.serial:
logger.error("Not connected to laser")
return None
try:
self.serial.reset_input_buffer()
time.sleep(0.05)
self.serial.write((command + '\r').encode('ascii'))
time.sleep(0.3)
if not self._send_command(command):
return None
raw = self.serial.read_until(b'\r')
if not raw:
raw = self.serial.read(self.serial.in_waiting)
@@ -410,6 +341,7 @@ class HeliosLaser:
command = f"LRE {1 if enable else 0}"
return self._send_command(command)
def __del__(self):
"""Destructor - ensure cleanup"""
self.disconnect()
# No __del__: it used to call disconnect(), which disables the laser and
# writes to the serial port from the garbage collector at an
# 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
# 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:
+87 -65
View File
@@ -12,7 +12,7 @@ from PyQt6.QtWidgets import (
QGroupBox, QLabel, QLineEdit, QPushButton, QComboBox, QSpinBox,
QMessageBox, QTabWidget, QTextEdit
)
from PyQt6.QtCore import QThread, pyqtSignal, QObject
from PyQt6.QtCore import QThread, pyqtSignal, pyqtSlot, QObject
from PyQt6.QtGui import QFont
from hardware.helios_laser import HeliosLaser, PulseMode
@@ -140,6 +140,20 @@ class LaserWorker(QObject):
super().__init__()
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):
try:
success = self.laser.set_frequency_hz(freq)
@@ -197,15 +211,13 @@ class LaserWorker(QObject):
self.operation_complete.emit(False, str(e))
def query_power(self):
try:
power = self.laser.get_power_mw()
if power is not None:
self.power_updated.emit(power)
self.operation_complete.emit(True, f"Power: {power:.2f} mW")
else:
self.operation_complete.emit(False, "Failed to query power")
except Exception as e:
self.operation_complete.emit(False, str(e))
# HeliosLaser has no power query: the driver README advertises
# get_power_mw(), but no such method exists and the protocol
# mnemonic for an output-power read is not documented anywhere in
# this repo. This used to raise AttributeError into a popup.
# See KNOWN_ISSUES.md — needs the command from the Helios manual.
self.operation_complete.emit(
False, "Power query is not implemented (no known protocol command)")
def query_enabled(self):
try:
@@ -289,6 +301,9 @@ class LaserWorker(QObject):
class HeliosTestApp(QMainWindow):
"""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):
super().__init__()
self.laser = HeliosLaser()
@@ -586,6 +601,10 @@ class HeliosTestApp(QMainWindow):
power_layout = QHBoxLayout()
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)
power_layout.addWidget(btn_get_power)
@@ -697,6 +716,9 @@ class HeliosTestApp(QMainWindow):
self.worker = LaserWorker(self.laser)
self.worker_thread = QThread()
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.frequency_updated.connect(self.on_frequency_updated)
self.worker.current_updated.connect(self.on_current_updated)
@@ -713,11 +735,27 @@ class HeliosTestApp(QMainWindow):
else:
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):
"""Disconnect from the laser."""
if self.worker_thread:
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.lbl_status.setText("Status: Disconnected")
@@ -729,137 +767,121 @@ class HeliosTestApp(QMainWindow):
def on_set_frequency(self):
"""Set the laser frequency."""
if not self.laser.is_connected:
QMessageBox.warning(self, "Error", "Not connected to laser")
if not self._require_connection():
return
freq = self.spin_frequency.value()
self.worker.set_frequency(freq)
self._call_worker("set_frequency", freq)
def on_query_frequency(self):
"""Query the laser frequency."""
if not self.laser.is_connected:
QMessageBox.warning(self, "Error", "Not connected to laser")
if not self._require_connection():
return
self.worker.query_frequency()
self._call_worker("query_frequency")
def on_set_current(self):
"""Set the laser current."""
if not self.laser.is_connected:
QMessageBox.warning(self, "Error", "Not connected to laser")
if not self._require_connection():
return
current = self.spin_current.value()
self.worker.set_current(current)
self._call_worker("set_current", current)
def on_query_current(self):
"""Query the laser current."""
if not self.laser.is_connected:
QMessageBox.warning(self, "Error", "Not connected to laser")
if not self._require_connection():
return
self.worker.query_current()
self._call_worker("query_current")
def on_set_mode(self):
"""Set the laser pulse mode."""
if not self.laser.is_connected:
QMessageBox.warning(self, "Error", "Not connected to laser")
if not self._require_connection():
return
mode = self.combo_mode.currentData()
self.worker.set_pulse_mode(mode)
self._call_worker("set_pulse_mode", mode)
def on_enable_laser(self):
"""Enable the laser."""
if not self.laser.is_connected:
QMessageBox.warning(self, "Error", "Not connected to laser")
if not self._require_connection():
return
self.worker.set_laser_enable(True)
self._call_worker("set_laser_enable", True)
def on_disable_laser(self):
"""Disable the laser."""
if not self.laser.is_connected:
QMessageBox.warning(self, "Error", "Not connected to laser")
if not self._require_connection():
return
self.worker.set_laser_enable(False)
self._call_worker("set_laser_enable", False)
def on_query_enabled(self):
"""Query if laser is enabled."""
if not self.laser.is_connected:
QMessageBox.warning(self, "Error", "Not connected to laser")
if not self._require_connection():
return
self.worker.query_enabled()
self._call_worker("query_enabled")
def on_query_power(self):
"""Query the laser output power."""
if not self.laser.is_connected:
QMessageBox.warning(self, "Error", "Not connected to laser")
if not self._require_connection():
return
self.worker.query_power()
self._call_worker("query_power")
def on_query_all(self):
"""Query all laser parameters."""
if not self.laser.is_connected:
QMessageBox.warning(self, "Error", "Not connected to laser")
if not self._require_connection():
return
self.worker.query_serials()
self.worker.query_frequency()
self.worker.query_current()
self.worker.query_power()
self.worker.query_enabled()
self.worker.query_status_registers()
self.worker.query_remote_enable()
self._call_worker("query_serials")
self._call_worker("query_frequency")
self._call_worker("query_current")
self._call_worker("query_power")
self._call_worker("query_enabled")
self._call_worker("query_status_registers")
self._call_worker("query_remote_enable")
def on_query_status(self):
"""Query LER/LCE/CCE status registers."""
if not self.laser.is_connected:
QMessageBox.warning(self, "Error", "Not connected to laser")
if not self._require_connection():
return
self.worker.query_status_registers()
self._call_worker("query_status_registers")
def on_reset_faults(self):
"""Send the fault reset sequence."""
if not self.laser.is_connected:
QMessageBox.warning(self, "Error", "Not connected to laser")
if not self._require_connection():
return
self.worker.do_reset_faults()
self._call_worker("do_reset_faults")
def on_query_remote_enable(self):
"""Query the remote enable (LRE) state."""
if not self.laser.is_connected:
QMessageBox.warning(self, "Error", "Not connected to laser")
if not self._require_connection():
return
self.worker.query_remote_enable()
self._call_worker("query_remote_enable")
def on_set_remote_enable(self, enable: bool):
"""Set the remote enable (LRE) state."""
if not self.laser.is_connected:
QMessageBox.warning(self, "Error", "Not connected to laser")
if not self._require_connection():
return
self.worker.set_remote_enable(enable)
self._call_worker("set_remote_enable", enable)
def on_ler_reset(self):
"""Send LER 0 only."""
if not self.laser.is_connected:
QMessageBox.warning(self, "Error", "Not connected to laser")
if not self._require_connection():
return
self.worker.do_ler_reset()
self._call_worker("do_ler_reset")
def on_send_raw(self):
"""Send the raw command from the terminal input."""
if not self.laser.is_connected:
QMessageBox.warning(self, "Error", "Not connected to laser")
if not self._require_connection():
return
cmd = self.le_raw_cmd.text().strip()
if not cmd:
return
self.worker.send_raw(cmd)
self._call_worker("send_raw", cmd)
def on_raw_response(self, cmd: str, response: str):
"""Display raw TX/RX pair in the terminal log."""
+150 -214
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.
"""
import queue
import struct
import sys
import time
@@ -14,7 +13,7 @@ from threading import Thread
import numpy as np
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.QtWidgets import (
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.sras_format import SCAN_CHANNELS, SrasFile, plan_from_header
from gui.qt_t3r import QtT3RAdapter
from gui.qt_workers import PollingQueueWorker, QueueWorker
from gui.scan_bridge import QtScanController
from hardware.helios_laser import HeliosLaser
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):
"""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):
fig = Figure(tight_layout=True)
@@ -65,36 +71,59 @@ class DCBiasImageWidget(FigureCanvas):
QSizePolicy.Policy.Expanding,
)
self.ax = fig.add_subplot(111)
self._row_data: list[np.ndarray] = []
self._img: np.ndarray | None = None
self._im = None
self.ax.set_title("DC Bias Preview (ADC counts)")
self.ax.set_xlabel("Frame")
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
self._placeholder = None
self._reset_axes()
def _reset_axes(self):
self.ax.clear()
self.ax.set_title("DC Bias Preview (ADC counts)")
self.ax.set_xlabel("Frame")
self.ax.set_ylabel("Row")
self.ax.imshow(
img, aspect="auto", origin="upper",
cmap="viridis", interpolation="nearest",
)
self._placeholder = self.ax.text(
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",
)
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()
@@ -103,61 +132,35 @@ BBD_JOG_ACCEL_MM_S2 = 50.0
# ── BBD202 worker ─────────────────────────────────────────────────────────────
class BBD202Worker(QObject):
class BBD202Worker(PollingQueueWorker):
"""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
homed_status = pyqtSignal(bool, bool) # x_homed, y_homed
error_occurred = pyqtSignal(str)
homed_status = pyqtSignal(bool, bool) # x_homed, y_homed
POLL_INTERVAL_S = 0.2
def __init__(self):
super().__init__()
super().__init__(poll_interval_s=self.POLL_INTERVAL_S)
self.controller: ThorlabsServoDriver | None = None
self._cmd_q: queue.Queue = queue.Queue()
self._running = False
self.is_connected = False
self.scanning_active = False # pause polling during scan
self.scanning_active = False # pause polling during a scan
self._jog_step = BBD_DEFAULT_JOG_MM
self._last_x: float | None = None
self._last_y: float | None = None
self._last_xh: bool | None = None
self._last_yh: bool | None = None
self._last_poll_t = 0.0
self._last_pos: tuple[float, float] | None = None
self._last_homed: tuple[bool, bool] | None = None
self._handlers.update({
"connect": self._do_connect,
"disconnect": self._do_disconnect,
"jog": self._do_jog,
"home_all": self._do_home_all,
"enable_all": self._do_enable_all,
})
@pyqtSlot()
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()
def _on_stop(self):
if self.controller:
try:
self.controller.disconnect()
except Exception:
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):
try:
self.controller = ThorlabsServoDriver()
@@ -173,10 +176,12 @@ class BBD202Worker(QObject):
acceleration=BBD_JOG_ACCEL_MM_S2)
self.is_connected = True
self.connected.emit()
self.start_polling()
except Exception as e:
self.connection_failed.emit(str(e))
def _do_disconnect(self):
self.stop_polling()
if self.controller:
try:
self.controller.disconnect()
@@ -194,8 +199,6 @@ class BBD202Worker(QObject):
self.controller.move_axis_relative(dest, self._jog_step * direction, timeout=2.0)
except TimeoutError:
pass
except Exception as e:
self.error_occurred.emit(str(e))
def _do_home_all(self):
if not self.controller:
@@ -214,79 +217,54 @@ class BBD202Worker(QObject):
self.controller.toggle_enabled_state(AXIS_X)
self.controller.toggle_enabled_state(AXIS_Y)
def _poll(self):
now = time.time()
if now - self._last_poll_t < 0.2:
def _poll_once(self):
"""Emit position/homed updates, but only when they actually change."""
if self.scanning_active or not self.controller:
return
self._last_poll_t = now
try:
x = self.controller.positions[0]
y = self.controller.positions[1]
if x != self._last_x or y != self._last_y:
self._last_x = x
self._last_y = y
self.position_updated.emit(x, y)
xh = self.controller.am_homed[0]
yh = self.controller.am_homed[1]
if xh != self._last_xh or yh != self._last_yh:
self._last_xh = xh
self._last_yh = yh
self.homed_status.emit(xh, yh)
pos = (self.controller.positions[0], self.controller.positions[1])
if pos != self._last_pos:
self._last_pos = pos
self.position_updated.emit(*pos)
homed = (self.controller.am_homed[0], self.controller.am_homed[1])
if homed != self._last_homed:
self._last_homed = homed
self.homed_status.emit(*homed)
except Exception:
pass
# ── Queue API ─────────────────────────────────────────────────────────────
def queue_connect(self, port: str):
self._cmd_q.put({"type": "connect", "port": port})
self._enqueue("connect", port=port)
def queue_disconnect(self):
self._cmd_q.put({"type": "disconnect"})
self._enqueue("disconnect")
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):
self._cmd_q.put({"type": "home_all"})
self._enqueue("home_all")
def queue_enable_all(self):
self._cmd_q.put({"type": "enable_all"})
def stop_worker(self):
self._cmd_q.put({"type": "stop"})
self._enqueue("enable_all")
# ── Oscilloscope worker ───────────────────────────────────────────────────────
class OscopeWorker(QObject):
class OscopeWorker(QueueWorker):
"""Manages TektronixOscilloscopeBase in a worker thread."""
connected = pyqtSignal()
disconnected = pyqtSignal()
connection_failed = pyqtSignal(str)
error_occurred = pyqtSignal(str)
def __init__(self):
super().__init__()
self.scope: TektronixOscilloscopeBase | None = None
self._cmd_q: queue.Queue = queue.Queue()
self._running = False
self.is_connected = False
self._handlers.update({
"connect": self._do_connect,
"disconnect": self._do_disconnect,
})
@pyqtSlot()
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
def _on_stop(self):
if self.scope:
try:
self.scope.disconnect()
@@ -314,67 +292,48 @@ class OscopeWorker(QObject):
self.disconnected.emit()
def queue_connect(self, ip: str):
self._cmd_q.put({"type": "connect", "ip": ip})
self._enqueue("connect", ip=ip)
def queue_disconnect(self):
self._cmd_q.put({"type": "disconnect"})
def stop_worker(self):
self._cmd_q.put({"type": "stop"})
self._enqueue("disconnect")
# ── Helios laser worker ───────────────────────────────────────────────────────
class HeliosWorker(QObject):
"""Manages HeliosLaser in a worker thread using a command queue."""
connected = pyqtSignal()
disconnected = pyqtSignal()
connection_failed = pyqtSignal(str)
class HeliosWorker(PollingQueueWorker):
"""Manages HeliosLaser in a worker thread using a command queue.
Status polling is self-rescheduling: the next poll is queued only once
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)
current_updated = pyqtSignal(int)
diode_temp_updated = pyqtSignal(float)
pstage_temp_updated = pyqtSignal(float)
qswitch_temp_updated = pyqtSignal(float)
status_registers_updated = pyqtSignal(object, object, object) # LER, LCE, CCE (int|None)
error_occurred = pyqtSignal(str)
POLL_INTERVAL_S = 1.0
def __init__(self):
super().__init__()
super().__init__(poll_interval_s=self.POLL_INTERVAL_S)
self._laser: HeliosLaser | None = None
self._cmd_q: queue.Queue = queue.Queue()
self._running = False
self.is_connected = False
self._handlers.update({
"connect": self._do_connect,
"disconnect": self._do_disconnect,
"set_enable": self._do_set_enable,
"set_current": self._do_set_current,
})
@pyqtSlot()
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
def _on_stop(self):
if self._laser:
try:
self._laser.disconnect()
except Exception:
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):
try:
self._laser = HeliosLaser(timeout=1.0)
@@ -384,12 +343,13 @@ class HeliosWorker(QObject):
return
self.is_connected = True
self.connected.emit()
self._do_poll_status()
self.start_polling()
except Exception as e:
self._laser = None
self.connection_failed.emit(str(e))
def _do_disconnect(self):
self.stop_polling()
if self._laser:
try:
self._laser.set_laser_enable(False)
@@ -406,75 +366,52 @@ class HeliosWorker(QObject):
def _do_set_enable(self, enable: bool):
if not self._laser:
return
try:
self._laser.set_laser_enable(enable)
self.enabled_updated.emit(self._laser.is_laser_enabled())
except Exception as e:
self.error_occurred.emit(str(e))
self._laser.set_laser_enable(enable)
self.enabled_updated.emit(self._laser.is_laser_enabled())
def _do_set_current(self, current_ma: int):
if not self._laser:
return
try:
self._laser.set_current_ma(current_ma)
self.current_updated.emit(current_ma)
except Exception as e:
self.error_occurred.emit(str(e))
self._laser.set_current_ma(current_ma)
self.current_updated.emit(current_ma)
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:
return
try:
ler, lce, cce = self._laser.get_status_registers()
self.status_registers_updated.emit(ler, lce, cce)
self.status_registers_updated.emit(*self._laser.get_status_registers())
except Exception:
pass
try:
self.enabled_updated.emit(self._laser.is_laser_enabled())
except Exception:
pass
try:
current = self._laser.get_current_ma()
if current is not None:
self.current_updated.emit(current)
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:
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:
value = getter()
if value is not None:
signal.emit(value)
except Exception:
pass
def queue_connect(self, port: str):
self._cmd_q.put({"type": "connect", "port": port})
self._enqueue("connect", port=port)
def queue_disconnect(self):
self._cmd_q.put({"type": "disconnect"})
self._enqueue("disconnect")
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):
self._cmd_q.put({"type": "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"})
self._enqueue("set_current", current_ma=current_ma)
# ── Camera window popup ───────────────────────────────────────────────────────
@@ -633,17 +570,13 @@ class HeliosWindow(QWidget):
self.setWindowTitle("Helios Laser")
self._worker = worker
self._poll_timer = QTimer(self)
self._poll_timer.setInterval(1000)
self._poll_timer.timeout.connect(self._worker.queue_poll_status)
# Polling is driven by the worker itself (self-rescheduling), so
# there is no timer here to outpace the device.
self._wire_signals()
self._update_controls(False)
self._set_emission_indicator(False)
def closeEvent(self, event):
self._poll_timer.stop()
if self._worker.is_connected:
self._worker.queue_disconnect()
super().closeEvent(event)
@@ -683,7 +616,6 @@ class HeliosWindow(QWidget):
DEFAULTS.save()
self._worker.queue_connect(port)
else:
self._poll_timer.stop()
self._worker.queue_disconnect()
def _on_connected(self):
@@ -691,10 +623,8 @@ class HeliosWindow(QWidget):
self.helios_connect_toggle.setText("Disconnect")
self.helios_status_label.setText("Connected")
self._update_controls(True)
self._poll_timer.start()
def _on_disconnected(self):
self._poll_timer.stop()
self.helios_connect_toggle.blockSignals(True)
self.helios_connect_toggle.setChecked(False)
self.helios_connect_toggle.setText("Connect")
@@ -905,6 +835,10 @@ class ScanProgressWindow(QWidget):
def update_dc_bias_plot(self, row: int, 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 ───────────────────────────────────────────────────────────────
@@ -1342,6 +1276,8 @@ class MainWindow(QMainWindow):
0, plan.per_angle[ai0].n_rows,
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_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()