Files
scanengine-3/tests/fakes.py
T
Thomas Ales afe33249d1 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>
2026-07-28 11:11:09 -05:00

186 lines
5.9 KiB
Python

"""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