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
+103
View File
@@ -0,0 +1,103 @@
"""Burst sizing and row-splitting, exercised without any instrument."""
import pytest
from core.scope_burst import (
frame_means_block, normalize_row, rows_per_burst, split_row_counts,
)
SPF = 8
# ── rows_per_burst ───────────────────────────────────────────────────────────
def test_rows_per_burst_rounds_down():
# 9.7 rows' worth of capacity is 9 rows: a partial row is unusable.
assert rows_per_burst(97, 10, SPF, rows_remaining=100) == 9
assert rows_per_burst(100, 10, SPF, rows_remaining=100) == 10
def test_rows_per_burst_clamped_by_rows_remaining():
assert rows_per_burst(1000, 10, SPF, rows_remaining=3) == 3
def test_rows_per_burst_clamped_by_memory_budget():
# Budget holds 4 rows of 10 frames × 8 samples; the scope would hold 100.
assert rows_per_burst(1000, 10, SPF, rows_remaining=100,
memory_budget=4 * 10 * SPF) == 4
def test_rows_per_burst_headroom_reserves_slack_per_row():
assert rows_per_burst(100, 10, SPF, rows_remaining=100, headroom=0) == 10
assert rows_per_burst(100, 10, SPF, rows_remaining=100, headroom=2) == 8
def test_rows_per_burst_never_returns_zero():
"""A row too big for any budget still goes, or the scan cannot progress."""
assert rows_per_burst(5, 10, SPF, rows_remaining=100) == 1
assert rows_per_burst(1000, 10, SPF, rows_remaining=100,
memory_budget=1) == 1
def test_rows_per_burst_rejects_degenerate_geometry():
with pytest.raises(ValueError):
rows_per_burst(100, 0, SPF, rows_remaining=1)
# ── split_row_counts ─────────────────────────────────────────────────────────
def test_split_row_counts_differences_the_cumulative_counter():
assert split_row_counts([4, 8, 12]) == [4, 4, 4]
assert split_row_counts([4, 7, 12]) == [4, 3, 5]
assert split_row_counts([]) == []
def test_split_row_counts_rejects_a_counter_that_went_backwards():
# Only happens if the acquisition restarted mid-burst, which would
# misattribute every later row.
with pytest.raises(RuntimeError, match="backwards"):
split_row_counts([8, 4])
# ── normalize_row ────────────────────────────────────────────────────────────
def test_normalize_row_passes_an_exact_row_through():
buf = bytes(range(4 * SPF))
assert bytes(normalize_row(buf, 0, 4, 4, SPF)) == buf
def test_normalize_row_pads_a_short_row():
buf = bytes(range(3 * SPF))
out = bytes(normalize_row(buf, 0, 3, 4, SPF))
assert len(out) == 4 * SPF
assert out[:3 * SPF] == buf
assert out[3 * SPF:] == bytes(SPF)
def test_normalize_row_truncates_a_long_row():
buf = bytes(range(6 * SPF))
out = bytes(normalize_row(buf, 0, 6, 4, SPF))
assert out == buf[:4 * SPF]
def test_normalize_row_reads_at_an_offset():
buf = bytes(range(8 * SPF))
out = bytes(normalize_row(buf, 2 * SPF, 4, 4, SPF))
assert out == buf[2 * SPF:6 * SPF]
def test_normalize_row_pads_a_buffer_that_ends_early():
"""Defensive: a truncated transfer must not shorten the row on disk."""
out = bytes(normalize_row(bytes(2 * SPF), 0, 4, 4, SPF))
assert len(out) == 4 * SPF
# ── frame_means_block ────────────────────────────────────────────────────────
def test_frame_means_block_is_per_frame():
buf = bytes([1] * SPF + [3] * SPF)
assert frame_means_block(buf, 0, 2, SPF) == [1.0, 3.0]
def test_frame_means_block_reads_signed_samples_at_an_offset():
buf = bytes([0] * SPF) + bytes([0xFF] * SPF) # 0xFF == -1 as int8
assert frame_means_block(buf, SPF, 1, SPF) == [-1.0]