Phase 4: extract headless ScanEngine; de-Qt the T3R driver

The headline of the refactor. Scan orchestration no longer lives inside a
QObject that reaches through Qt workers for its hardware handles.

core/scan_engine.py — ScanEngine(stage, scope, rotator, plan, out_path,
resume, callbacks). Takes the concrete drivers, blocks in run(), reports
via plain callables, and prompts through an injected blocking callable.
No Qt import anywhere in the path (test_engine_imports_without_qt proves
it), so a simpler GUI or a CLI can drive the identical acquisition.

Supporting extractions, all Qt-free:
- core/scope_sras.py  — SCPI policy: channel profiles, trigger programming,
  background average, per-row FastFrame transfer
- core/rotation.py    — RotationAxis + RotationSettings (the GR_* constants)
- core/scan_resume.py — frontier contiguity rule + settings compatibility
- gui/scan_bridge.py  — QtScanController, exposing exactly the signal
  surface the old ScanWorker had, so MainWindow's connections are unchanged

hardware/t3r_driver.py is now Qt-free: a plain Signal class, a threading
reader, and a polling thread instead of QObject/QThread/QTimer.
gui/qt_t3r.py re-emits its callbacks as queued Qt signals for the panels.

Fixes carried by the extraction:
- rotation waits on the driver's MOTION_DONE event instead of
  time.sleep(estimate + 0.5)
- abort during an operator prompt now takes effect; the old
  _prompt_event.wait() had no timeout and could not be interrupted
- the poll timer is a thread, so an I/O error tearing down the driver no
  longer calls QTimer.stop() from the wrong thread
- T3RDriver.disconnect() renamed close(); it shadowed QObject.disconnect()
- per-frame DC means use np.frombuffer over the joined block instead of
  struct.unpack per frame (~16k tuple allocations per row)

tests/fakes.py + test_scan_engine.py (14 tests) assert the exact command
sequence, file layout, resume seeking, abort/pause, and geometry
rejection before any hardware call; test_scan_resume.py covers the
frontier rule. 58 passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Thomas Ales
2026-07-28 11:11:09 -05:00
parent d2734c45d6
commit afe33249d1
13 changed files with 1585 additions and 613 deletions
+119 -63
View File
@@ -1,8 +1,12 @@
"""T3R Stepper Controller driver for ScanEngine-3.
Qt-based driver that owns the serial connection and an internal reader QThread.
All events arrive as Qt signals; all commands are fire-and-forget writes.
Create in the main (GUI) thread; no additional thread management required.
Owns the serial connection and an internal reader thread. Events are
delivered as plain-Python callbacks (see ``Signal``); commands are
fire-and-forget writes. No Qt — GUIs wrap this with gui.qt_t3r.QtT3RAdapter,
which re-emits every event as a queued Qt signal on the GUI thread.
Callbacks run on the reader thread. Keep them short, and never touch Qt
widgets from one directly.
Gear train (stage rotation via GR-axis, ch3):
Motor → 10T pinion → 30T idler → 125T index gear (stage)
@@ -11,23 +15,53 @@ Gear train (stage rotation via GR-axis, ch3):
from __future__ import annotations
import logging
import threading
import serial
from PyQt6.QtCore import QObject, QThread, QTimer, pyqtSignal
from . import t3r_protocol as proto
from .serial_util import open_8n1
logger = logging.getLogger(__name__)
class _T3RReader(QThread):
"""Blocking read loop — runs on its own QThread."""
class Signal:
"""Minimal observer slot: ``connect(fn)`` then ``emit(*args)``.
frame = pyqtSignal(int, bytes) # (cmd, payload) for each valid frame
finished_reason = pyqtSignal(str) # "" = clean stop, else I/O error string
Mirrors the pyqtSignal API used by the existing panels so the same call
sites work against either this driver or a Qt adapter over it. A raising
subscriber is logged and skipped so one bad listener cannot kill the
reader thread.
"""
def __init__(self, ser: serial.Serial):
super().__init__()
__slots__ = ("_subs", "_name")
def __init__(self, name: str = ""):
self._subs: list = []
self._name = name
def connect(self, fn) -> None:
self._subs.append(fn)
def disconnect(self, fn) -> None:
if fn in self._subs:
self._subs.remove(fn)
def emit(self, *args) -> None:
for fn in list(self._subs):
try:
fn(*args)
except Exception:
logger.exception("T3R %s subscriber failed", self._name)
class _Reader(threading.Thread):
"""Blocking read loop — decodes frames and hands them to `on_frame`."""
def __init__(self, ser, on_frame, on_finished):
super().__init__(daemon=True, name="T3RReader")
self._ser = ser
self._on_frame = on_frame
self._on_finished = on_finished
self._running = True
self._parser = proto.FrameParser()
@@ -44,20 +78,20 @@ class _T3RReader(QThread):
break
if data:
for cmd, payload in self._parser.feed(data):
self.frame.emit(cmd, payload)
self.finished_reason.emit(reason)
self._on_frame(cmd, payload)
self._on_finished(reason)
class T3RDriver(QObject):
"""Qt-based driver for the T3R four-channel stepper controller.
class T3RDriver:
"""Driver for the T3R four-channel stepper controller.
Usage::
driver = T3RDriver()
driver.handshake_ok.connect(lambda pv, fw, nc: print("connected"))
driver.info_updated.connect(on_info)
driver.connect("/dev/ttyUSB0")
driver.handshake_ok.connect(lambda pv, fw, nc: ...)
driver.open("/dev/ttyUSB0")
driver.move(0, steps=3200, velocity=8000, accel=4000)
driver.wait_motion_done(0, timeout=30.0)
"""
CHANNEL_NAMES = ["T-axis (focus)", "Axis 1", "Axis 2", "GR-axis"]
@@ -68,32 +102,33 @@ class T3RDriver(QObject):
GEAR_TEETH_MOTOR = 10
GEAR_TEETH_STAGE = 125 # idler is 30T but does not change ratio
# ── Signals ───────────────────────────────────────────────────────────────
POLL_INTERVAL_S = 0.25
port_opened = pyqtSignal() # serial port open; PING sent
handshake_ok = pyqtSignal(int, int, int) # proto_ver, fw_ver, num_channels
disconnected = pyqtSignal(str) # reason ("" = user-initiated)
info_updated = pyqtSignal(int, object) # ch, proto.Info
drv_status_updated = pyqtSignal(int, object) # ch, proto.DrvStatus
position_updated = pyqtSignal(int, int) # ch, position (microsteps)
motion_done = pyqtSignal(int, int) # ch, final_position
stopped = pyqtSignal(int, int) # ch, final_position
fault_occurred = pyqtSignal(int, int) # ch, fault_mask
ack_received = pyqtSignal(int, int) # req_cmd, status (0=OK)
frame_received = pyqtSignal(int, bytes) # raw (cmd, payload) for log
def __init__(self, parent=None):
super().__init__(parent)
self._ser: serial.Serial | None = None
self._reader: _T3RReader | None = None
def __init__(self):
self._ser = None
self._reader: _Reader | None = None
self._write_lock = threading.Lock()
self._tearing_down = False
self._is_open = False
self._poll_timer = QTimer(self)
self._poll_timer.setInterval(250)
self._poll_timer.timeout.connect(self._poll)
self._poll_stop = threading.Event()
self._poll_thread: threading.Thread | None = None
# Per-channel motion-completion events, so a caller can block on a
# move finishing instead of guessing its duration.
self._motion_events = [threading.Event() for _ in range(proto.NUM_CHANNELS)]
self.port_opened = Signal("port_opened") # ()
self.handshake_ok = Signal("handshake_ok") # proto_ver, fw_ver, n_ch
self.disconnected = Signal("disconnected") # reason ("" = user)
self.info_updated = Signal("info_updated") # ch, proto.Info
self.drv_status_updated = Signal("drv_status_updated") # ch, proto.DrvStatus
self.position_updated = Signal("position_updated") # ch, position
self.motion_done = Signal("motion_done") # ch, final_position
self.stopped = Signal("stopped") # ch, final_position
self.fault_occurred = Signal("fault_occurred") # ch, fault_mask
self.ack_received = Signal("ack_received") # req_cmd, status
self.frame_received = Signal("frame_received") # raw cmd, payload
# ── Connection ────────────────────────────────────────────────────────────
@@ -101,26 +136,24 @@ class T3RDriver(QObject):
def is_open(self) -> bool:
return self._is_open
def connect(self, port: str, baud: int = 115200) -> None:
"""Open the serial port and start the reader. Emits port_opened on success."""
def open(self, port: str, baud: int = 115200) -> None:
"""Open the serial port and start the reader; emits port_opened."""
if self._is_open:
self.disconnect()
self.close()
try:
self._ser = serial.Serial(port, baudrate=baud, timeout=0.05)
self._ser = open_8n1(port, baudrate=baud, timeout=0.05)
except Exception as exc:
raise RuntimeError(f"Cannot open {port}: {exc}") from exc
self._tearing_down = False
self._is_open = True
self._reader = _T3RReader(self._ser)
self._reader.frame.connect(self._on_frame)
self._reader.finished_reason.connect(self._on_reader_finished)
self._reader = _Reader(self._ser, self._on_frame, self._on_reader_finished)
self._reader.start()
self.port_opened.emit()
self.send_frame(proto.ping()) # handshake; polling starts on PONG
def disconnect(self) -> None:
"""Close port and stop polling."""
def close(self) -> None:
"""Close the port and stop polling."""
self._teardown("")
def _on_reader_finished(self, reason: str):
@@ -131,16 +164,21 @@ class T3RDriver(QObject):
if self._tearing_down or not self._is_open:
return
self._tearing_down = True
self._poll_timer.stop()
self.stop_polling()
self._is_open = False
# Release anyone blocked in wait_motion_done so a disconnect during a
# move raises there instead of hanging until the timeout.
for ev in self._motion_events:
ev.set()
reader, self._reader = self._reader, None
ser, self._ser = self._ser, None
if reader is not None:
reader.stop()
if QThread.currentThread() is not reader:
reader.wait(1000)
if threading.current_thread() is not reader:
reader.join(1.0)
if ser is not None:
try:
ser.close()
@@ -186,6 +224,7 @@ class T3RDriver(QObject):
self.send_frame(proto.set_current(ch, run_ma, hold_ma, ihold_delay))
def move(self, ch: int, steps: int, velocity: int, accel: int):
self._motion_events[ch].clear()
self.send_frame(proto.move(ch, steps, velocity, accel))
def jog(self, ch: int, velocity: int, accel: int):
@@ -215,30 +254,44 @@ class T3RDriver(QObject):
# ── Rotation helpers ──────────────────────────────────────────────────────
def steps_for_angle(self, angle_deg: float, microsteps: int) -> int:
"""Compute GR-axis microsteps needed to rotate the stage by angle_deg."""
"""GR-axis microsteps needed to rotate the stage by angle_deg."""
gear_ratio = self.GEAR_TEETH_STAGE / self.GEAR_TEETH_MOTOR
steps_per_stage_rev = self.MOTOR_FULL_STEPS_PER_REV * microsteps * gear_ratio
return round(steps_per_stage_rev * angle_deg / 360.0)
def rotate_stage(self, angle_deg: float, microsteps: int,
velocity: int = 8000, accel: int = 4000):
"""Move GR-axis by the number of steps that rotate the stage by angle_deg."""
steps = self.steps_for_angle(angle_deg, microsteps)
self.move(self.GR_AXIS_CH, steps, velocity, accel)
"""Move GR-axis by the steps that rotate the stage by angle_deg."""
self.move(self.GR_AXIS_CH, self.steps_for_angle(angle_deg, microsteps),
velocity, accel)
def wait_motion_done(self, ch: int, timeout: float) -> bool:
"""Block until the channel reports MOTION_DONE. False on timeout.
Cleared by ``move()``, set by the MOTION_DONE event and by teardown,
so a disconnect mid-move unblocks immediately.
"""
return self._motion_events[ch].wait(timeout)
# ── Polling ───────────────────────────────────────────────────────────────
def start_polling(self):
self._poll_timer.start()
if self._poll_thread is not None and self._poll_thread.is_alive():
return
self._poll_stop.clear()
self._poll_thread = threading.Thread(target=self._poll_loop, daemon=True,
name="T3RPoll")
self._poll_thread.start()
def stop_polling(self):
self._poll_timer.stop()
self._poll_stop.set()
def _poll(self):
if not self._is_open:
return
for ch in range(proto.NUM_CHANNELS):
self.send_frame(proto.get_info(ch))
def _poll_loop(self):
while not self._poll_stop.wait(self.POLL_INTERVAL_S):
if not self._is_open:
break
for ch in range(proto.NUM_CHANNELS):
self.send_frame(proto.get_info(ch))
# ── Frame dispatcher ──────────────────────────────────────────────────────
@@ -274,14 +327,17 @@ class T3RDriver(QObject):
elif cmd == proto.EVT_MOTION_DONE:
ev = proto.decode_event_position(payload)
if ev and 0 <= ev.ch < proto.NUM_CHANNELS:
self._motion_events[ev.ch].set()
self.motion_done.emit(ev.ch, ev.position)
elif cmd == proto.EVT_STOPPED:
ev = proto.decode_event_position(payload)
if ev and 0 <= ev.ch < proto.NUM_CHANNELS:
self._motion_events[ev.ch].set()
self.stopped.emit(ev.ch, ev.position)
elif cmd == proto.EVT_FAULT:
ev = proto.decode_fault(payload)
if ev and 0 <= ev.ch < proto.NUM_CHANNELS:
self._motion_events[ev.ch].set()
self.fault_occurred.emit(ev.ch, ev.position)