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
+80 -13
View File
@@ -27,7 +27,8 @@ def make_plan(num_angles=1, y_delta=0.005):
def build(tmp_path, num_angles=1, callbacks=None, resume=None, plan=None,
burst_mode=False, max_frames=4096, out_name="out.sras", **kw):
burst_mode=False, max_frames=4096, out_name="out.sras",
strict_rows=False, **kw):
trace = Trace()
scope = FakeScope(trace, samples_per_frame=SPF, max_frames=max_frames)
stage = FakeStage(trace, scope=scope)
@@ -37,7 +38,7 @@ def build(tmp_path, num_angles=1, callbacks=None, resume=None, plan=None,
engine = ScanEngine(stage, scope, rotator, plan, tmp_path / out_name,
resume=resume,
callbacks=callbacks or ScanCallbacks(),
burst_mode=burst_mode)
burst_mode=burst_mode, strict_rows=strict_rows)
return engine, trace, plan
@@ -356,6 +357,26 @@ def test_burst_preflight_rejects_a_dark_laser(tmp_path):
engine.run()
def _clip_one_row(engine, which_pass=2, lost=1):
"""Make one acquiring pass come up `lost` frames short.
`which_pass` counts acquiring passes from 1, so the default clips the
second data row (row 2) — far enough in that a mishandled short row shows
up as a shift in the rows after it.
"""
scope = engine._scope
real_acquire = scope.acquire_frames
passes = {"n": 0}
def clipped(n):
if scope._running:
passes["n"] += 1
if passes["n"] == which_pass:
n -= lost
real_acquire(n)
scope.acquire_frames = clipped
@pytest.mark.parametrize("burst_mode", [False, True])
def test_short_row_is_padded_to_declared_frame_count(tmp_path, burst_mode):
"""A clipped row must not shift every later row in the file.
@@ -374,17 +395,7 @@ def test_short_row_is_padded_to_declared_frame_count(tmp_path, burst_mode):
# acquiring-pass count below identical in both modes.
engine._preflight_done = True
scope = engine._scope
real_acquire = scope.acquire_frames
passes = {"n": 0}
def clipped(n):
if scope._running:
passes["n"] += 1
if passes["n"] == 2: # second data row loses one frame
n -= 1
real_acquire(n)
scope.acquire_frames = clipped
_clip_one_row(engine)
result = engine.run()
@@ -398,6 +409,62 @@ def test_short_row_is_padded_to_declared_frame_count(tmp_path, burst_mode):
assert bytes(sras.load_row(0, 2, 0)[0]) != bytes(SPF)
# ── Strict row packing ───────────────────────────────────────────────────────
@pytest.mark.parametrize("burst_mode", [False, True])
def test_strict_row_packing_aborts_on_a_short_row(tmp_path, burst_mode):
"""Strict mode fails the scan instead of silently squaring a row up.
The default padding keeps the file readable but makes a mis-triggered row
indistinguishable from a good one after the fact, since v6 records no
per-row frame count. Strict mode trades the salvaged rows for knowing.
"""
plan = make_plan(**BURST_PLAN)
engine, _, _ = build(tmp_path, plan=plan, burst_mode=burst_mode,
max_frames=BURST_MAX_FRAMES, strict_rows=True)
engine._preflight_done = True
_clip_one_row(engine)
with pytest.raises(RuntimeError, match="Row 2: 3 frames acquired, 4 expected"):
engine.run()
@pytest.mark.parametrize("burst_mode", [False, True])
def test_strict_row_packing_does_not_disturb_a_clean_scan(tmp_path, burst_mode):
"""Strict mode is inert when every row acquires what it declared."""
plan = make_plan(**BURST_PLAN)
engine, _, _ = build(tmp_path, plan=plan, burst_mode=burst_mode,
max_frames=BURST_MAX_FRAMES, strict_rows=True)
engine._preflight_done = True
result = engine.run()
assert result.rows_written == plan.total_rows
sras = SrasFile(tmp_path / "out.sras")
assert [s.status for s in sras.angle_status()] == ["OK"]
def test_strict_row_packing_writes_nothing_for_the_failed_row(tmp_path):
"""The abort must not leave a half-written row behind.
CH1 leads SCAN_CHANNELS, so the frame count is known before any of the
row's channels are written — the file should end on a whole-row boundary.
"""
plan = make_plan(**BURST_PLAN)
engine, _, _ = build(tmp_path, plan=plan, strict_rows=True)
engine._preflight_done = True
_clip_one_row(engine)
with pytest.raises(RuntimeError, match="Strict row packing"):
engine.run()
# Row 1 was written in full; row 2 aborted before writing anything, so
# the file ends exactly on a row boundary.
sras = SrasFile(tmp_path / "out.sras")
written = (tmp_path / "out.sras").stat().st_size - sras.data_start_offset
assert written == sras.row_bytes(0)
def test_engine_imports_without_qt():
"""The engine must be usable from a non-Qt front end."""
import subprocess