Files
scanengine-3/tests/fakes.py
T
Thomas Ales 116c9c07c7 Burst acquisition: many whole rows per FastFrame acquisition
Per-row acquisition pays a full arm/stop/transfer round trip for every row,
and the transfer is one IEEE-488.2 block read per frame (~16k frames a row).
Burst mode runs one FastFrame acquisition across as many complete rows as the
scope's frame memory holds and pulls each burst in a single CURVe?
transaction, amortising the round trip over the whole burst.

It is opt-in (ScanEngine(burst_mode=...), default False) and writes
byte-identical files to the per-row path — test_burst_and_serial_produce_
identical_files runs the same plan both ways and compares the bytes, which is
the property the whole feature rests on.

core/scope_burst.py — the new policy module. Everything that computes rather
than talks to hardware is a free function, so sizing and row-splitting are
testable without a rig: rows_per_burst() (rounds down, since a partial row
can't be written, and clamps to a transfer-buffer budget), split_row_counts(),
normalize_row(), frame_means_block().

The hard part is that a burst carries no row markers — the scope returns one
flat run of frames. Boundaries come from ACQuire:NUMFRAMESACQuired? sampled
after each acquiring pass while the stage gate is already low, rebased on a
baseline read back at RUN rather than assuming the counter resets. A counter
that goes backwards means the acquisition restarted mid-burst and is now a
hard error instead of silently misattributing every later row.

core/scan_engine.py — the row loop splits into _scan_rows_serial and
_scan_rows_burst. The wire is channel-major and the file is row-major with
channels inner, so _write_burst deinterleaves by writing one channel at a
time to strided offsets; peak memory stays at a single channel's burst
instead of the whole thing.

_gate_off_preflight is what makes this trustworthy on real hardware. The BBD
value that idles the trigger output low is not settled by the protocol docs
(see TRIGOUT_GATE_OFF), and getting it wrong fills every burst with flyback
frames that silently shift the file. The scope already measures the gate on
CH3, so the check needs no bench probe: one gated-off flyback must acquire
nothing, and one gated pass must acquire something — the second half is what
stops a dark laser from making the first half pass vacuously. It runs once
per scan and costs two row-times.

Two fixes fall out of this work and apply to both paths:
- Rows are now squared up to the declared n_frames (short rows zero-padded,
  long rows truncated, both warned). v6 commits to n_frames per row in the
  header and has no per-row length field, so an over- or under-triggered row
  used to shift every later row in the file.
- The X trigger output is returned to idle in the run() finally block. The
  per-row path left TRIGOUT_MAXV armed for the rest of the session, so the
  gate line kept being driven on every later jog.

core/scope_sras.py — pins DATa:ENCdg RIBinary and DATa:WIDth 1 during setup
instead of inheriting front-panel state. The file header hardcodes
bytes_per_sample=1; a scope left on 2 bytes would have corrupted every frame
written. frames_acquired/frame_means move to scope_burst, where the offset-
based variants serve both paths.

tests/fakes.py — FakeStage and FakeScope are now wired together the way the
rig is: a gated X move at scan velocity feeds frames into a running
acquisition at the real 20 kHz / 100 mm/s rate, direction-agnostic. Both
paths therefore derive frame counts from one model, which is what makes the
byte-identity comparison meaningful, and a gate the engine forgets to drop
shows up as extra frames instead of passing silently. Frame content is a
function of (channel, index) alone, so the same frame sequence yields the
same bytes however it is chopped into transfers.

87 tests passing, ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 12:17:24 -05:00

269 lines
9.3 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))
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
# 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")
return bytes(range(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