Make row packing toggleable: pad (default) or strict abort

A mis-triggered row cannot be written as it arrived — v6 declares n_frames per
row in the header and has no per-row length field, so a short or long row
would shift every later row in the file. Until now the only policy was to
square it up, which keeps the scan running but leaves the affected row
indistinguishable from a good one afterwards: nothing in the file records that
it was padded.

strict_rows selects the other trade. On any frame-count mismatch the scan
stops instead of writing the row, so a data run either produces rows that mean
what the header says they mean or fails loudly. Default stays pad, so existing
behaviour is unchanged.

_warn_frame_delta becomes _check_frame_delta, since it now decides rather than
just reports. Both acquisition paths already call it before writing anything
for the row (CH1 leads SCAN_CHANNELS, and the burst path checks every row up
front), so an abort leaves the file on a whole-row boundary rather than a
half-written row — test_strict_row_packing_writes_nothing_for_the_failed_row
pins that.

Plumbed through QtScanController to a checkbox in the scan panel, persisted in
ScanDefaults alongside burst_mode. scan_format.md documents both policies and
notes that the choice is not recorded in the file.

The row-clipping setup in the padding test is now a _clip_one_row helper,
reused by the strict tests. 92 tests passing, ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Thomas Ales
2026-09-02 12:32:09 -05:00
parent ab5f3166f4
commit 52fdcdd9f3
7 changed files with 139 additions and 22 deletions
+1
View File
@@ -24,6 +24,7 @@ class ScanDefaults:
save_dir: str = str(DEFAULTS_PATH.parent / "scans")
helios_port: str = "/dev/ttyUSB2"
burst_mode: bool = False
strict_rows: bool = False
@classmethod
def load(cls, path: Path = DEFAULTS_PATH) -> "ScanDefaults":
+23 -4
View File
@@ -96,7 +96,7 @@ class ScanEngine:
plan: ScanPlan, out_path: Path,
resume: ResumeState | None = None,
callbacks: ScanCallbacks | None = None,
burst_mode: bool = False):
burst_mode: bool = False, strict_rows: bool = False):
self._stage = stage
self._scope = scope
self._rotator = rotator
@@ -107,6 +107,9 @@ class ScanEngine:
# 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
# Strict row packing stops the scan on a frame-count mismatch
# instead of squaring the row up (see _check_frame_delta).
self._strict_rows = strict_rows
self._max_frames = 0
self._preflight_done = False
@@ -434,7 +437,7 @@ class ScanEngine:
self._cb.on_status(f"Fetching CH{ch} data …")
waveforms = scope_sras.transfer_channel(scope, ch)
if ch == SCAN_CHANNELS[0]:
self._warn_frame_delta(row_idx, len(waveforms), n_frames)
self._check_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:
@@ -577,7 +580,7 @@ class ScanEngine:
total_frames = sum(counts)
for r, count in enumerate(counts):
self._warn_frame_delta(first_row + r, count, n_frames)
self._check_frame_delta(first_row + r, count, n_frames)
for ch_idx, ch in enumerate(SCAN_CHANNELS):
if ch == 3:
@@ -608,10 +611,26 @@ class ScanEngine:
scan_file.seek(burst_start + len(counts) * row_bytes)
def _warn_frame_delta(self, row_idx: int, count: int, n_frames: int):
def _check_frame_delta(self, row_idx: int, count: int, n_frames: int):
"""Decide what to do with a row that did not acquire n_frames frames.
v6 declares n_frames per row in the header and has no per-row length
field, so a mismatched row cannot just be written as-is — that would
shift every later row in the file. The only two safe options are to
square it up or to stop, which is what strict_rows selects between.
Called before anything for the row is written (CH1 leads
SCAN_CHANNELS), so raising here leaves no partial row behind.
"""
if count == n_frames:
return
verb = "zero-padded" if count < n_frames else "truncated"
if self._strict_rows:
raise RuntimeError(
f"Row {row_idx + 1}: {count} frames acquired, {n_frames} "
f"expected. Strict row packing is on, so the scan stops here "
f"rather than writing a row that would be {verb}."
)
msg = (f"Row {row_idx + 1}: {count} frames acquired, {n_frames} "
f"expected — {verb} to keep the file layout intact.")
logger.warning(msg)