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:
+185
@@ -0,0 +1,185 @@
|
||||
"""Recording fake hardware for headless ScanEngine tests.
|
||||
|
||||
Each fake records an ordered call trace, so a test can assert the exact
|
||||
command sequence the engine issues — the property that matters when the
|
||||
real rig isn't available.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class Trace:
|
||||
"""Ordered record of hardware calls, shared by all fakes in one test."""
|
||||
|
||||
def __init__(self):
|
||||
self.calls: list[tuple] = []
|
||||
|
||||
def record(self, *entry):
|
||||
self.calls.append(entry)
|
||||
|
||||
def names(self) -> list[str]:
|
||||
return [c[0] for c in self.calls]
|
||||
|
||||
def of(self, name: str) -> list[tuple]:
|
||||
return [c for c in self.calls if c[0] == name]
|
||||
|
||||
def count(self, name: str) -> int:
|
||||
return len(self.of(name))
|
||||
|
||||
|
||||
class FakeStage:
|
||||
"""Stands in for ThorlabsServoDriver."""
|
||||
|
||||
def __init__(self, trace: Trace, homed=(True, True), enabled=(True, True)):
|
||||
self._t = trace
|
||||
self.am_homed = list(homed)
|
||||
self.am_enabled = list(enabled)
|
||||
self.positions = [0.0, 0.0]
|
||||
|
||||
def enable_axis(self, axis):
|
||||
self._t.record("enable_axis", axis)
|
||||
self.am_enabled[0 if axis == 0x21 else 1] = True
|
||||
|
||||
def home_axis(self, axis, timeout=0.0):
|
||||
self._t.record("home_axis", axis)
|
||||
self.am_homed[0 if axis == 0x21 else 1] = True
|
||||
|
||||
def set_velocity_params(self, axis, max_velocity=None, acceleration=None):
|
||||
self._t.record("set_velocity_params", axis, max_velocity, acceleration)
|
||||
|
||||
def set_trigger_trigout_maxv(self, axis):
|
||||
self._t.record("set_trigger_trigout_maxv", axis)
|
||||
|
||||
def move_axis_absolute(self, axis, pos, timeout=0.0):
|
||||
self._t.record("move_axis_absolute", axis, round(pos, 6))
|
||||
self.positions[0 if axis == 0x21 else 1] = pos
|
||||
|
||||
|
||||
class FakeScope:
|
||||
"""Stands in for TektronixOscilloscopeBase.
|
||||
|
||||
Returns deterministic frame bytes so the written file can be compared
|
||||
against an expected byte pattern.
|
||||
"""
|
||||
|
||||
def __init__(self, trace: Trace, samples_per_frame=8, n_frames=4):
|
||||
self._t = trace
|
||||
self.samples_per_frame = samples_per_frame
|
||||
self._n_frames = n_frames
|
||||
self._acq_polls = 0
|
||||
self.frame_seq = 0
|
||||
|
||||
# -- writes / queries ---------------------------------------------------
|
||||
def write(self, cmd):
|
||||
self._t.record("write", cmd)
|
||||
|
||||
def query(self, cmd):
|
||||
self._t.record("query", cmd)
|
||||
if cmd == "ACQuire:STATE?":
|
||||
self._acq_polls += 1
|
||||
return "0" # background average finished
|
||||
if cmd == "ACQuire:NUMFRAMESACQuired?":
|
||||
return str(self._n_frames)
|
||||
return ""
|
||||
|
||||
# -- typed setters used by core.scope_sras ------------------------------
|
||||
def set_trigger_source(self, ch):
|
||||
self._t.record("set_trigger_source", ch)
|
||||
|
||||
def set_trigger_slope(self, slope):
|
||||
self._t.record("set_trigger_slope", slope)
|
||||
|
||||
def set_trigger_level(self, ch, level):
|
||||
self._t.record("set_trigger_level", ch, level)
|
||||
|
||||
def set_trigger_mode(self, mode):
|
||||
self._t.record("set_trigger_mode", mode)
|
||||
|
||||
def set_acquire_mode(self, mode):
|
||||
self._t.record("set_acquire_mode", mode)
|
||||
|
||||
def set_fastframe_state(self, on):
|
||||
self._t.record("set_fastframe_state", on)
|
||||
|
||||
def set_fastframe_count(self, n):
|
||||
self._t.record("set_fastframe_count", n)
|
||||
self._n_frames = n
|
||||
|
||||
def set_sample_rate(self, sr):
|
||||
self._t.record("set_sample_rate", sr)
|
||||
|
||||
def get_record_length(self):
|
||||
return self.samples_per_frame
|
||||
|
||||
def set_data_source(self, ch):
|
||||
self._t.record("set_data_source", ch)
|
||||
self._source = ch
|
||||
|
||||
def query_wfmoutpre(self):
|
||||
return f"WFMOUTPRE:CH{self._source};YMULT 1.5625E-3;YOFF -87.04;YZERO 0.0"
|
||||
|
||||
def transfer_curve(self):
|
||||
self._t.record("transfer_curve")
|
||||
return bytes(range(self.samples_per_frame))
|
||||
|
||||
def transfer_fastframe(self, parse=True):
|
||||
self._t.record("transfer_fastframe", self._source)
|
||||
frames = []
|
||||
for i in range(self._n_frames):
|
||||
frames.append(bytes((self.frame_seq + i + s) % 256
|
||||
for s in range(self.samples_per_frame)))
|
||||
self.frame_seq += 1
|
||||
return frames
|
||||
|
||||
# channel config (only used by configure_channels)
|
||||
def set_channel_label_name(self, ch, name):
|
||||
self._t.record("set_channel_label_name", ch, name)
|
||||
|
||||
def set_channel_scale(self, ch, v):
|
||||
self._t.record("set_channel_scale", ch, v)
|
||||
|
||||
def set_channel_position(self, ch, v):
|
||||
self._t.record("set_channel_position", ch, v)
|
||||
|
||||
def set_channel_termination(self, ch, v):
|
||||
self._t.record("set_channel_termination", ch, v)
|
||||
|
||||
def set_channel_coupling(self, ch, v):
|
||||
self._t.record("set_channel_coupling", ch, v)
|
||||
|
||||
def set_channel_bandwidth(self, ch, v):
|
||||
self._t.record("set_channel_bandwidth", ch, v)
|
||||
|
||||
|
||||
class FakeT3R:
|
||||
"""Stands in for the (Qt-free) T3RDriver, for RotationAxis."""
|
||||
|
||||
GR_AXIS_CH = 3
|
||||
MOTOR_FULL_STEPS_PER_REV = 200
|
||||
GEAR_TEETH_MOTOR = 10
|
||||
GEAR_TEETH_STAGE = 125
|
||||
|
||||
def __init__(self, trace: Trace, is_open=True, motion_completes=True):
|
||||
self._t = trace
|
||||
self.is_open = is_open
|
||||
self._motion_completes = motion_completes
|
||||
|
||||
def set_microstep(self, ch, micro):
|
||||
self._t.record("t3r_set_microstep", ch, micro)
|
||||
|
||||
def set_current(self, ch, run_ma, hold_ma, ihold):
|
||||
self._t.record("t3r_set_current", ch, run_ma, hold_ma, ihold)
|
||||
|
||||
def enable(self, ch):
|
||||
self._t.record("t3r_enable", ch)
|
||||
|
||||
def steps_for_angle(self, angle_deg, microsteps):
|
||||
ratio = self.GEAR_TEETH_STAGE / self.GEAR_TEETH_MOTOR
|
||||
return round(self.MOTOR_FULL_STEPS_PER_REV * microsteps * ratio
|
||||
* angle_deg / 360.0)
|
||||
|
||||
def rotate_stage(self, angle_deg, microsteps, velocity, accel):
|
||||
self._t.record("t3r_rotate", round(angle_deg, 6))
|
||||
|
||||
def wait_motion_done(self, ch, timeout):
|
||||
self._t.record("t3r_wait_motion_done", ch)
|
||||
return self._motion_completes
|
||||
Reference in New Issue
Block a user