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>
This commit is contained in:
Thomas Ales
2026-09-02 12:17:24 -05:00
parent d6a56266b7
commit 116c9c07c7
6 changed files with 767 additions and 92 deletions
+268 -43
View File
@@ -15,7 +15,7 @@ from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable
from core import scope_sras
from core import scope_burst, scope_sras
from core.rotation import RotationAxis
from core.scan_geometry import ScanPlan, validate_plan
from core.sras_format import SCAN_CHANNELS, create_scan_file
@@ -95,7 +95,8 @@ class ScanEngine:
def __init__(self, stage, scope, rotator: RotationAxis | None,
plan: ScanPlan, out_path: Path,
resume: ResumeState | None = None,
callbacks: ScanCallbacks | None = None):
callbacks: ScanCallbacks | None = None,
burst_mode: bool = False):
self._stage = stage
self._scope = scope
self._rotator = rotator
@@ -103,6 +104,11 @@ class ScanEngine:
self._out_path = Path(out_path)
self._resume = resume
self._cb = callbacks if callbacks is not None else ScanCallbacks()
# Burst mode acquires as many whole rows per FastFrame acquisition as
# the scope's frame memory holds, instead of one row per acquisition.
self._burst_mode = burst_mode
self._max_frames = 0
self._preflight_done = False
self._abort = threading.Event()
self._resume_event = threading.Event()
@@ -207,6 +213,14 @@ class ScanEngine:
self._scan_loop(scan_file, samples_per_frame, result)
finally:
scan_file.close()
# Leave the X trigger output inactive. Burst mode toggles it every
# row and could exit from either state; the per-row path used to
# leave TRIGOUT_MAXV armed for the rest of the session, which keeps
# driving the gate line on every later jog.
try:
self._stage.set_trigger_gate_off(AXIS_X)
except Exception:
logger.exception("Could not return the X trigger output to idle")
# Return the GR axis home regardless of abort or error
if rotator_ready and abs(self._rotator.current_deg) > 0.001:
self._cb.on_status("Returning GR to home …")
@@ -242,8 +256,14 @@ class ScanEngine:
acceleration=SCAN_ACCEL_MM_S2)
ctrl.set_velocity_params(AXIS_Y, max_velocity=SCAN_VELOCITY_MM_S,
acceleration=SCAN_ACCEL_MM_S2)
# X trigger: logic-high output while the stage is at maximum velocity
ctrl.set_trigger_trigout_maxv(AXIS_X)
# X trigger: logic-high output while the stage is at maximum velocity.
# Burst mode arms it per acquiring pass instead — a burst spans several
# rows with the scope running throughout, so leaving it armed would let
# the flyback trigger frames between rows.
if self._burst_mode:
ctrl.arm_scan_gate(AXIS_X, False)
else:
ctrl.set_trigger_trigout_maxv(AXIS_X)
def _prepare_scope(self) -> int:
self._cb.on_status("Configuring oscilloscope …")
@@ -290,6 +310,16 @@ class ScanEngine:
)
scope_sras.configure_scan_trigger(self._scope)
if self._burst_mode:
# Horizontal settings are fixed by now, so the capacity is stable
# for the whole scan; only rows-per-burst varies (n_frames is
# per-angle).
self._max_frames = scope_burst.max_frames(self._scope)
self._cb.on_status(
f"Burst mode: scope holds {self._max_frames} frames "
f"({samples_per_frame} samples/frame)"
)
return samples_per_frame
def _open_output(self, samples_per_frame: int, result: ScanResult):
@@ -309,7 +339,6 @@ class ScanEngine:
plan = self._plan
n_angles = plan.n_angles
x_ramp_total = SCAN_RAMP_MM + SCAN_RAMP_BUFFER_MM
ctrl = self._stage
scope = self._scope
targets_by_ai = None
@@ -337,57 +366,253 @@ class ScanEngine:
f"Rotating GR to {pa.angle_deg:.1f}° (Δ{delta:+.1f}°) …")
self._rotator.rotate_to(pa.angle_deg)
# Each angle's bounding box gives it its own points/row count, so
# the scope's FastFrame count must be re-armed per angle.
scope.set_fastframe_count(pa.n_frames)
for ri, y_pos in enumerate(pa.y_positions):
self._pause_point()
self._cb.on_row_started(ri + 1, pa.n_rows, ai + 1, n_angles)
self._cb.on_status(
f"Angle {ai+1}/{n_angles} Row {ri+1}/{pa.n_rows} "
f"(Y={y_pos:.3f} mm)"
)
# Position the stage one ramp-length + buffer before the data
# window so it is at full velocity before x_start.
ctrl.move_axis_absolute(AXIS_Y, y_pos, timeout=60.0)
ctrl.move_axis_absolute(AXIS_X, pa.x_start - x_ramp_total, timeout=30.0)
scope_sras.arm_row(scope)
# Data window + ramp + buffer run-off, so the stage does not
# begin decelerating before the last point.
x_end = pa.x_start + pa.x_delta + x_ramp_total
ctrl.move_axis_absolute(AXIS_X, x_end, timeout=120.0)
scope_sras.finish_row(scope)
self._write_row(scan_file, samples_per_frame, ri)
result.rows_written += 1
self._cb.on_row_done(ri + 1, pa.n_rows, ai + 1, n_angles)
if self._burst_mode:
# Burst mode sizes the FastFrame count from the scope's whole
# capacity instead (see scope_burst.start_burst), so there is
# nothing to re-arm per angle here.
self._scan_rows_burst(scan_file, pa, ai, n_angles,
samples_per_frame, result, x_ramp_total)
else:
# Each angle's bounding box gives it its own points/row count,
# so the scope's FastFrame count must be re-armed per angle.
scope.set_fastframe_count(pa.n_frames)
self._scan_rows_serial(scan_file, pa, ai, n_angles,
samples_per_frame, result, x_ramp_total)
result.angles_acquired.append(ai)
def _write_row(self, scan_file, samples_per_frame: int, row_idx: int):
# ── Per-row acquisition (one FastFrame acquisition per row) ───────────────
def _scan_rows_serial(self, scan_file, pa, ai: int, n_angles: int,
samples_per_frame: int, result: ScanResult,
x_ramp_total: float):
ctrl = self._stage
scope = self._scope
for ri, y_pos in enumerate(pa.y_positions):
self._pause_point()
self._cb.on_row_started(ri + 1, pa.n_rows, ai + 1, n_angles)
self._cb.on_status(
f"Angle {ai+1}/{n_angles} Row {ri+1}/{pa.n_rows} "
f"(Y={y_pos:.3f} mm)"
)
# Position the stage one ramp-length + buffer before the data
# window so it is at full velocity before x_start.
ctrl.move_axis_absolute(AXIS_Y, y_pos, timeout=60.0)
ctrl.move_axis_absolute(AXIS_X, pa.x_start - x_ramp_total, timeout=30.0)
scope_sras.arm_row(scope)
# Data window + ramp + buffer run-off, so the stage does not
# begin decelerating before the last point.
x_end = pa.x_start + pa.x_delta + x_ramp_total
ctrl.move_axis_absolute(AXIS_X, x_end, timeout=120.0)
scope_sras.finish_row(scope)
self._write_row(scan_file, samples_per_frame, ri, pa.n_frames)
result.rows_written += 1
self._cb.on_row_done(ri + 1, pa.n_rows, ai + 1, n_angles)
def _write_row(self, scan_file, samples_per_frame: int, row_idx: int,
n_frames: int):
"""Stream every channel from the scope into the file.
CH3 is the max-vel gate signal — no useful waveform data — so zeroed
frames are written to keep the file layout intact.
"""
scope = self._scope
ch_bytes = n_frames * samples_per_frame
for ch in SCAN_CHANNELS:
if ch == 3:
self._cb.on_status("Writing zeroed CH3 frames …")
zero_frame = bytes(samples_per_frame)
for _ in range(scope_sras.frames_acquired(scope)):
scan_file.write(zero_frame)
scan_file.write(bytes(ch_bytes))
continue
self._cb.on_status(f"Fetching CH{ch} data …")
waveforms = scope_sras.transfer_channel(scope, ch)
if ch == 4 and waveforms:
self._cb.on_dc_bias(row_idx + 1, scope_sras.frame_means(waveforms))
for w in waveforms:
scan_file.write(w)
if ch == SCAN_CHANNELS[0]:
self._warn_frame_delta(row_idx, len(waveforms), n_frames)
row = scope_burst.normalize_row(
b"".join(waveforms), 0, len(waveforms), n_frames, samples_per_frame)
if ch == 4:
self._cb.on_dc_bias(row_idx + 1, scope_burst.frame_means_block(
row, 0, n_frames, samples_per_frame))
scan_file.write(row)
# ── Burst acquisition (many whole rows per FastFrame acquisition) ─────────
def _scan_rows_burst(self, scan_file, pa, ai: int, n_angles: int,
samples_per_frame: int, result: ScanResult,
x_ramp_total: float):
"""Acquire the angle in bursts of as many whole rows as the scope holds.
One ACQuire:STATE RUN spans the whole burst, so the gate is armed only
for each acquiring pass and dropped for the flyback — otherwise the
return move would reach max velocity and inject frames between rows.
"""
scope = self._scope
n_frames = pa.n_frames
x_lead_in = pa.x_start - x_ramp_total
x_end = pa.x_start + pa.x_delta + x_ramp_total
if not self._preflight_done:
# Once per scan: the gate wiring can't change between angles, and
# the check costs two row-times.
self._gate_off_preflight(x_lead_in, x_end)
self._preflight_done = True
row = 0
while row < pa.n_rows:
self._pause_point()
n_burst = scope_burst.rows_per_burst(
self._max_frames, n_frames, samples_per_frame, pa.n_rows - row)
self._cb.on_status(
f"Angle {ai+1}/{n_angles} Rows {row+1}-{row+n_burst}/{pa.n_rows} "
f"in one acquisition ({n_burst * n_frames} frames) …"
)
burst_start = scan_file.tell()
cumulative = []
baseline = scope_burst.start_burst(scope, self._max_frames)
try:
for r in range(n_burst):
self._check_abort()
self._cb.on_row_started(row + r + 1, pa.n_rows,
ai + 1, n_angles)
self._acquire_gated_row(pa.y_positions[row + r],
x_lead_in, x_end)
total = scope_burst.frames_acquired(scope)
if total >= self._max_frames:
raise RuntimeError(
f"FastFrame buffer full ({total}/{self._max_frames} "
f"frames) at row {row + r + 1} — later rows in this "
"burst would be misattributed. Raise "
"scope_burst.BURST_FRAME_HEADROOM and rerun."
)
cumulative.append(total - baseline)
finally:
scope_burst.stop_burst(scope)
counts = scope_burst.split_row_counts(cumulative)
self._write_burst(scan_file, burst_start, row, counts,
n_frames, samples_per_frame)
for r in range(n_burst):
result.rows_written += 1
self._cb.on_row_done(row + r + 1, pa.n_rows, ai + 1, n_angles)
row += n_burst
def _acquire_gated_row(self, y_pos: float, x_lead_in: float, x_end: float):
"""One row: step Y, fly back gated off, then acquire on the +X pass."""
ctrl = self._stage
ctrl.move_axis_absolute(AXIS_Y, y_pos, timeout=60.0)
ctrl.move_axis_absolute(AXIS_X, x_lead_in, timeout=30.0)
ctrl.arm_scan_gate(AXIS_X, True)
ctrl.move_axis_absolute(AXIS_X, x_end, timeout=120.0)
ctrl.arm_scan_gate(AXIS_X, False)
time.sleep(scope_burst.BURST_ROW_SETTLE_S)
def _gate_off_preflight(self, x_lead_in: float, x_end: float):
"""Prove the gate really gates before trusting a multi-row burst.
The value that makes the BBD trigger output idle low is not settled by
the protocol docs (see apt_constants.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 this 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.
Leaves the stage parked at x_end, where the burst loop expects it.
"""
ctrl, scope = self._stage, self._scope
self._cb.on_status("Burst preflight: checking the stage gate …")
ctrl.arm_scan_gate(AXIS_X, False)
ctrl.move_axis_absolute(AXIS_X, x_end, timeout=120.0)
baseline = scope_burst.start_burst(scope, self._max_frames)
ctrl.move_axis_absolute(AXIS_X, x_lead_in, timeout=120.0)
scope_burst.stop_burst(scope)
leaked = scope_burst.frames_acquired(scope) - baseline
ctrl.arm_scan_gate(AXIS_X, True)
baseline = scope_burst.start_burst(scope, self._max_frames)
ctrl.move_axis_absolute(AXIS_X, x_end, timeout=120.0)
ctrl.arm_scan_gate(AXIS_X, False)
scope_burst.stop_burst(scope)
gated = scope_burst.frames_acquired(scope) - baseline
if gated <= 0:
raise RuntimeError(
"Burst preflight: no frames acquired with the gate armed. "
"Check that the Genesis laser is pulsing (CH2) and that the "
"BBD X trigger output reaches CH3 before scanning."
)
if leaked:
raise RuntimeError(
f"Burst preflight: {leaked} frame(s) acquired during a flyback "
"that should have been gated off — the BBD trigger output is "
"not idling low. Set apt_constants.TRIGOUT_GATE_OFF to "
"TriggerBitsServo.TRIGOUT_HIGH and retry, or use per-row "
"acquisition."
)
self._cb.on_status(
f"Burst preflight OK ({gated} frames gated on, 0 leaked).")
def _write_burst(self, scan_file, burst_start: int, first_row: int,
counts: list[int], n_frames: int, samples_per_frame: int):
"""Deinterleave one burst into the file's per-row, per-channel blocks.
The wire is channel-major (every row of CH1, then every row of CH4);
the file is row-major with channels inner. Writing one channel at a
time to strided offsets keeps peak memory at a single channel's burst
instead of the whole thing.
"""
scope = self._scope
ch_bytes = n_frames * samples_per_frame
row_bytes = len(SCAN_CHANNELS) * ch_bytes
total_frames = sum(counts)
for r, count in enumerate(counts):
self._warn_frame_delta(first_row + r, count, n_frames)
for ch_idx, ch in enumerate(SCAN_CHANNELS):
if ch == 3:
self._cb.on_status("Writing zeroed CH3 frames …")
blob = None
else:
self._cb.on_status(
f"Fetching CH{ch} burst ({total_frames} frames) …")
blob = scope_burst.transfer_burst(scope, ch, total_frames,
samples_per_frame)
src = 0
zeros = bytes(ch_bytes) if blob is None else None
for r, count in enumerate(counts):
scan_file.seek(burst_start + r * row_bytes + ch_idx * ch_bytes)
if blob is None:
scan_file.write(zeros)
else:
row = scope_burst.normalize_row(
blob, src, count, n_frames, samples_per_frame)
if ch == 4:
self._cb.on_dc_bias(
first_row + r + 1,
scope_burst.frame_means_block(
row, 0, n_frames, samples_per_frame))
scan_file.write(row)
src += count * samples_per_frame
del blob
scan_file.seek(burst_start + len(counts) * row_bytes)
def _warn_frame_delta(self, row_idx: int, count: int, n_frames: int):
if count == n_frames:
return
verb = "zero-padded" if count < n_frames else "truncated"
msg = (f"Row {row_idx + 1}: {count} frames acquired, {n_frames} "
f"expected — {verb} to keep the file layout intact.")
logger.warning(msg)
self._cb.on_status(msg)