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:
+5
-23
@@ -10,8 +10,6 @@ import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SAMPLE_RATE_HZ = 6.25e9 # 6.25 GS/s → 160 ps/sample
|
||||
@@ -66,6 +64,11 @@ def configure_acquisition(scope) -> int:
|
||||
scope.set_trigger_mode("NORMAL") # wait for trigger (don't auto-sweep)
|
||||
scope.set_acquire_mode("SAMPLE")
|
||||
scope.set_fastframe_state(False)
|
||||
# Pin the transfer format instead of inheriting front-panel state — the
|
||||
# file header hardcodes bytes_per_sample=1, and a scope left on 2 bytes
|
||||
# would corrupt every frame written.
|
||||
scope.set_data_encoding("RIBinary")
|
||||
scope.set_data_width(1)
|
||||
scope.set_sample_rate(SAMPLE_RATE_HZ)
|
||||
scope.write("HORizontal:POSition 30") # 10 % trigger offset
|
||||
time.sleep(0.3) # let the timebase settle before reading back
|
||||
@@ -140,28 +143,7 @@ def finish_row(scope) -> None:
|
||||
scope.write("ACQuire:STATE STOP")
|
||||
|
||||
|
||||
def frames_acquired(scope) -> int:
|
||||
return int(scope.query("ACQuire:NUMFRAMESACQuired?"))
|
||||
|
||||
|
||||
def transfer_channel(scope, ch: int) -> list[bytes]:
|
||||
"""Fetch one channel's FastFrame block as raw int8 frames."""
|
||||
scope.set_data_source(ch)
|
||||
return scope.transfer_fastframe(parse=False)
|
||||
|
||||
|
||||
def frame_means(waveforms: list[bytes]) -> list[float]:
|
||||
"""Per-frame DC mean of a raw int8 FastFrame block.
|
||||
|
||||
numpy over the joined buffer: the per-frame struct.unpack this replaces
|
||||
allocated a tuple of Python ints per frame (~16k frames per row).
|
||||
"""
|
||||
if not waveforms:
|
||||
return []
|
||||
n = len(waveforms[0])
|
||||
if n == 0 or any(len(w) != n for w in waveforms):
|
||||
# Ragged block (shouldn't happen) — fall back to per-frame means.
|
||||
return [float(np.frombuffer(w, dtype=np.int8).mean()) if len(w) else 0.0
|
||||
for w in waveforms]
|
||||
block = np.frombuffer(b"".join(waveforms), dtype=np.int8).reshape(len(waveforms), n)
|
||||
return block.mean(axis=1, dtype=np.float32).tolist()
|
||||
|
||||
Reference in New Issue
Block a user