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
+9 -8
View File
@@ -27,8 +27,9 @@ from PyQt6.QtWidgets import (
QSpinBox, QSplitter, QVBoxLayout, QWidget,
)
from hardware.t3r_driver import T3RDriver
from gui.qt_t3r import QtT3RAdapter
from hardware.serial_util import scored_ports
from hardware.t3r_driver import T3RDriver # class constants (gear train, channel names)
import hardware.t3r_protocol as proto
@@ -62,7 +63,7 @@ def _spin(lo: int, hi: int, val: int) -> QSpinBox:
class ChannelPanel(QGroupBox):
"""Controls and live readouts for one T3R axis."""
def __init__(self, ch: int, driver: T3RDriver):
def __init__(self, ch: int, driver: QtT3RAdapter):
label = f"Axis {ch} — {T3RDriver.CHANNEL_NAMES[ch]}"
super().__init__(label)
self.ch = ch
@@ -278,7 +279,7 @@ class ChannelPanel(QGroupBox):
class GroupPanel(QGroupBox):
"""Ganged motion — selected axes step in lockstep."""
def __init__(self, driver: T3RDriver, log_fn):
def __init__(self, driver: QtT3RAdapter, log_fn):
super().__init__("Ganged / synchronised motion — selected axes move in lockstep")
self._driver = driver
self._log = log_fn
@@ -389,7 +390,7 @@ class RotationPanel(QGroupBox):
Ratio = 125/10 = 12.5
"""
def __init__(self, driver: T3RDriver):
def __init__(self, driver: QtT3RAdapter):
super().__init__(
f"Stage Rotation (GR-axis ch{T3RDriver.GR_AXIS_CH}) — "
f"gear: {T3RDriver.GEAR_TEETH_MOTOR}T motor → 30T idler → "
@@ -481,12 +482,12 @@ class RotationPanel(QGroupBox):
class T3RControlPanel(QDialog):
"""User-hidable T3R control window.
Pass a T3RDriver instance. The panel connects to its signals and forwards
Pass a QtT3RAdapter instance. The panel connects to its signals and forwards
commands via its API. Connection management (port open/close) is handled
inside the panel itself.
"""
def __init__(self, driver: T3RDriver, parent=None):
def __init__(self, driver: QtT3RAdapter, parent=None):
super().__init__(parent)
self.setWindowTitle("T3R Stepper Controller")
self.setWindowFlags(
@@ -624,14 +625,14 @@ class T3RControlPanel(QDialog):
def _toggle_connect(self):
if self._driver.is_open:
self._driver.disconnect()
self._driver.close()
return
port = self.port_combo.currentData()
if not port:
self._log("No serial port selected", "err")
return
try:
self._driver.connect(port)
self._driver.open(port)
except Exception as exc:
self._log(f"Connect failed: {exc}", "err")
self.conn_lbl.setText("connect failed")