116c9c07c7
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>
104 lines
3.9 KiB
Python
104 lines
3.9 KiB
Python
"""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]
|