diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index 39c606f..8a8b393 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -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 diff --git a/gui/qt_workers.py b/gui/qt_workers.py new file mode 100644 index 0000000..f7719ca --- /dev/null +++ b/gui/qt_workers.py @@ -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.""" diff --git a/gui/widgets.py b/gui/widgets.py new file mode 100644 index 0000000..aaace8a --- /dev/null +++ b/gui/widgets.py @@ -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 "") diff --git a/hardware/helios_laser.py b/hardware/helios_laser.py index 1f12117..de9dff4 100755 --- a/hardware/helios_laser.py +++ b/hardware/helios_laser.py @@ -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. diff --git a/hardware/pybbd202/bbd20x.py b/hardware/pybbd202/bbd20x.py index f02dbe4..71cc892 100755 --- a/hardware/pybbd202/bbd20x.py +++ b/hardware/pybbd202/bbd20x.py @@ -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: diff --git a/helios_test_app.py b/helios_test_app.py index 358ce86..0d4d307 100755 --- a/helios_test_app.py +++ b/helios_test_app.py @@ -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.""" diff --git a/sc3_aui_app.py b/sc3_aui_app.py index 01b0c94..d7ca45f 100755 --- a/sc3_aui_app.py +++ b/sc3_aui_app.py @@ -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() diff --git a/tests/test_qt_workers.py b/tests/test_qt_workers.py new file mode 100644 index 0000000..cec6c17 --- /dev/null +++ b/tests/test_qt_workers.py @@ -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()