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:
@@ -0,0 +1,87 @@
|
||||
"""GR rotation axis: the T3R configuration and move policy for scanning.
|
||||
|
||||
Qt-free façade over hardware.t3r_driver.T3RDriver that owns the drive
|
||||
settings the scan depends on, so the engine never re-derives them.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RotationSettings:
|
||||
"""Drive settings for the GR axis during a scan."""
|
||||
microsteps: int = 8 # microsteps/full-step on the GR axis (ch3)
|
||||
velocity: int = 4000 # steps/s for inter-angle moves
|
||||
accel: int = 2000 # steps/s² for inter-angle moves
|
||||
run_current_ma: int = 1200 # drive current while moving
|
||||
hold_current_ma: int = 400 # standstill current
|
||||
ihold_delay: int = 6 # run→hold current ramp delay (TMC IHOLDDELAY units)
|
||||
# The sample rotates CW instead of CCW to clear wiring and avoid a stall.
|
||||
rotation_sign: int = -1
|
||||
|
||||
|
||||
DEFAULT_ROTATION = RotationSettings()
|
||||
|
||||
|
||||
class RotationAxis:
|
||||
"""Blocking rotation control for the GR axis.
|
||||
|
||||
``configure()`` must run before any move: ``steps_for_angle()`` assumes
|
||||
the configured microstep setting, so the device has to be told to match
|
||||
rather than trusting whatever the T3R panel or firmware default left it
|
||||
at.
|
||||
"""
|
||||
|
||||
def __init__(self, driver, settings: RotationSettings = DEFAULT_ROTATION):
|
||||
self.driver = driver
|
||||
self.settings = settings
|
||||
self._current_deg = 0.0
|
||||
|
||||
@property
|
||||
def is_available(self) -> bool:
|
||||
return self.driver is not None and self.driver.is_open
|
||||
|
||||
@property
|
||||
def current_deg(self) -> float:
|
||||
return self._current_deg
|
||||
|
||||
def configure(self) -> None:
|
||||
s = self.settings
|
||||
ch = self.driver.GR_AXIS_CH
|
||||
self.driver.set_microstep(ch, s.microsteps)
|
||||
self.driver.set_current(ch, s.run_current_ma, s.hold_current_ma, s.ihold_delay)
|
||||
self.driver.enable(ch)
|
||||
|
||||
def estimate_move_secs(self, delta_deg: float) -> float:
|
||||
"""Trapezoidal move time: cruise + accel/decel ramps."""
|
||||
s = self.settings
|
||||
steps = abs(self.driver.steps_for_angle(delta_deg, s.microsteps))
|
||||
return steps / s.velocity + s.velocity / s.accel
|
||||
|
||||
def rotate_to(self, angle_deg: float, timeout_margin_s: float = 5.0) -> float:
|
||||
"""Rotate to an absolute angle and block until the move completes.
|
||||
|
||||
Returns the estimated move time (for status reporting). Waits on the
|
||||
driver's MOTION_DONE event rather than sleeping for a guessed
|
||||
duration; falls back to the estimate only if the event never arrives.
|
||||
"""
|
||||
delta_deg = angle_deg - self._current_deg
|
||||
if abs(delta_deg) <= 0.001:
|
||||
return 0.0
|
||||
s = self.settings
|
||||
est_secs = self.estimate_move_secs(delta_deg)
|
||||
self.driver.rotate_stage(delta_deg, s.microsteps, s.velocity, s.accel)
|
||||
if not self.driver.wait_motion_done(self.driver.GR_AXIS_CH,
|
||||
est_secs + timeout_margin_s):
|
||||
logger.warning(
|
||||
"GR axis did not report MOTION_DONE within %.1f s for a "
|
||||
"%.1f° move; continuing", est_secs + timeout_margin_s, delta_deg)
|
||||
self._current_deg = angle_deg
|
||||
return est_secs
|
||||
|
||||
def return_to_zero(self) -> float:
|
||||
return self.rotate_to(0.0)
|
||||
Reference in New Issue
Block a user