aa06fa1460
A multi-angle scan runs for hours, but every angle was referenced against
one background captured before the first row of the first angle. That
reference has drifted by the last angle, and comparing angles — the whole
point of a multi-angle scan — was comparing each one against a noise floor
measured at whichever angle came first.
Every angle now captures its own. Before each angle's rows, the operator is
prompted to switch the Genesis laser off, the engine averages a fresh CH1
record, and the operator switches it back on. The data block therefore reads
[background][scan][background][scan] …, one pair per angle.
Format v7 (scan) and v11 (SAW check) carry the background inside the data
block, one length-prefixed block ahead of each angle's rows; the single
block that sat between the preambles and the data is gone. Per-angle offsets
now come from a walk of the data block at parse time rather than arithmetic
over the geometry table, and an angle whose background is not fully on disk
is the frontier — nothing of it was written yet.
v6/v10 files still read: SrasFile hands their one background to every angle,
so readers never branch on the version. Nothing writes them, and a resume
refuses them, since a re-acquired angle writes a block the old layout has no
room for. A resumed v7 angle rewrites its background in place, and the
engine checks the new block fits the room the file has before writing it —
anything else would shift every row behind it.
Two fixes made along the way:
* QtScanController never accepted file_version, so every scan launched
from the app raised TypeError at construction.
* angle_status() left its cursor parked at the frontier, so every angle
past it reported the frontier's own data_offset — which handed a resumed
scan the same write position for several angles. Two recorded offsets in
tests/golden/sras_expected.json are corrected accordingly.
The v6 goldens stay as parser fixtures; the writer is now locked against
bytes the test lays out from scan_format.md itself.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
282 lines
9.8 KiB
Python
282 lines
9.8 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.
|
|
|
|
The stage and scope are wired together the way the rig is: an X move at scan
|
|
velocity with the trigger gate armed feeds frames into a running acquisition,
|
|
at the real 20 kHz / 100 mm/s rate. Per-row and burst acquisition therefore
|
|
get their frame counts from the same model, which is what makes a
|
|
byte-identity comparison between the two paths meaningful — and it means a
|
|
gate the engine forgets to drop shows up as extra frames instead of passing
|
|
silently.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from core.scan_engine import (
|
|
AXIS_X, LASER_FREQ_HZ, SCAN_RAMP_BUFFER_MM, SCAN_RAMP_MM,
|
|
SCAN_VELOCITY_MM_S,
|
|
)
|
|
|
|
RAMP_TOTAL_MM = SCAN_RAMP_MM + SCAN_RAMP_BUFFER_MM
|
|
|
|
|
|
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),
|
|
scope=None):
|
|
self._t = trace
|
|
self.am_homed = list(homed)
|
|
self.am_enabled = list(enabled)
|
|
self.positions = [0.0, 0.0]
|
|
self._scope = scope
|
|
self.gate_armed = False
|
|
|
|
def attach_scope(self, scope):
|
|
"""Route gated motion into `scope`, as the TRIGOUT pin does on the rig."""
|
|
self._scope = scope
|
|
|
|
def enable_axis(self, axis):
|
|
self._t.record("enable_axis", axis)
|
|
self.am_enabled[0 if axis == AXIS_X else 1] = True
|
|
|
|
def home_axis(self, axis, timeout=0.0):
|
|
self._t.record("home_axis", axis)
|
|
self.am_homed[0 if axis == AXIS_X 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)
|
|
if axis == AXIS_X:
|
|
self.gate_armed = True
|
|
|
|
def set_trigger_gate_off(self, axis):
|
|
self._t.record("set_trigger_gate_off", axis)
|
|
if axis == AXIS_X:
|
|
self.gate_armed = False
|
|
|
|
def arm_scan_gate(self, axis, armed, verify=True):
|
|
self._t.record("arm_scan_gate", axis, bool(armed))
|
|
if axis == AXIS_X:
|
|
self.gate_armed = bool(armed)
|
|
|
|
def move_axis_absolute(self, axis, pos, timeout=0.0):
|
|
idx = 0 if axis == AXIS_X else 1
|
|
prev = self.positions[idx]
|
|
self._t.record("move_axis_absolute", axis, round(pos, 6))
|
|
self.positions[idx] = pos
|
|
|
|
# The gate is high only at max velocity, i.e. over the move minus its
|
|
# two ramps — direction-agnostic, so a flyback the engine failed to
|
|
# gate off produces frames instead of quietly producing none.
|
|
if axis == AXIS_X and self.gate_armed and self._scope is not None:
|
|
at_speed_mm = abs(pos - prev) - 2 * RAMP_TOTAL_MM
|
|
if at_speed_mm > 0:
|
|
self._scope.acquire_frames(
|
|
round(at_speed_mm * LASER_FREQ_HZ / SCAN_VELOCITY_MM_S))
|
|
|
|
|
|
def background_record(n: int, samples_per_frame: int) -> bytes:
|
|
"""The waveform FakeScope returns from its n-th background capture.
|
|
|
|
Every angle captures its own, so the tests need to tell one from the
|
|
next: a background that landed under the wrong angle would otherwise
|
|
look exactly like the right one.
|
|
"""
|
|
return bytes((n * 17 + s) % 256 for s in range(samples_per_frame))
|
|
|
|
|
|
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, max_frames=4096):
|
|
self._t = trace
|
|
self.samples_per_frame = samples_per_frame
|
|
self.max_frames = max_frames
|
|
self._acq_polls = 0
|
|
self._running = False
|
|
self._acquired = 0
|
|
self._backgrounds_taken = 0
|
|
# Per-channel running frame index. Frame content is a function of
|
|
# (channel, index) alone, so the same total frame sequence yields the
|
|
# same bytes however it is chopped into transfers.
|
|
self._next_frame: dict[int, int] = {}
|
|
|
|
# -- driven by FakeStage ------------------------------------------------
|
|
def acquire_frames(self, n):
|
|
if self._running:
|
|
self._acquired += n
|
|
|
|
# -- writes / queries ---------------------------------------------------
|
|
def write(self, cmd):
|
|
self._t.record("write", cmd)
|
|
if cmd == "ACQuire:STATE RUN":
|
|
self._running = True
|
|
self._acquired = 0
|
|
elif cmd == "ACQuire:STATE STOP":
|
|
self._running = False
|
|
|
|
def query(self, cmd):
|
|
self._t.record("query", cmd)
|
|
if cmd == "ACQuire:STATE?":
|
|
self._acq_polls += 1
|
|
# STOPAfter SEQuence self-stops when the sequence completes, so
|
|
# reporting "stopped" and staying armed would be inconsistent.
|
|
self._running = False
|
|
return "0" # background average finished
|
|
if cmd == "ACQuire:NUMFRAMESACQuired?":
|
|
return str(self._acquired)
|
|
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)
|
|
|
|
def get_fastframe_state(self):
|
|
return 1
|
|
|
|
def get_fastframe_max_frames(self):
|
|
self._t.record("get_fastframe_max_frames")
|
|
return self.max_frames
|
|
|
|
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 set_data_encoding(self, encoding):
|
|
self._t.record("set_data_encoding", encoding)
|
|
|
|
def set_data_width(self, width):
|
|
self._t.record("set_data_width", width)
|
|
|
|
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")
|
|
n = self._backgrounds_taken
|
|
self._backgrounds_taken += 1
|
|
return background_record(n, self.samples_per_frame)
|
|
|
|
def _frames(self, ch, count):
|
|
spf = self.samples_per_frame
|
|
start = self._next_frame.get(ch, 0)
|
|
self._next_frame[ch] = start + count
|
|
return [bytes((ch * 31 + g + s) % 256 for s in range(spf))
|
|
for g in range(start, start + count)]
|
|
|
|
def transfer_fastframe(self, parse=True, byte_count=1, signed=True,
|
|
byte_order='MSB'):
|
|
self._t.record("transfer_fastframe", self._source)
|
|
return self._frames(self._source, self._acquired)
|
|
|
|
def transfer_fastframe_bulk(self, frame_count, samples_per_frame,
|
|
bytes_per_sample=1):
|
|
self._t.record("transfer_fastframe_bulk", self._source, frame_count)
|
|
return bytearray(b"".join(self._frames(self._source, frame_count)))
|
|
|
|
# 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
|