diff --git a/core/config.py b/core/config.py
index 6327e9e..0a7c8d8 100644
--- a/core/config.py
+++ b/core/config.py
@@ -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":
diff --git a/core/scan_engine.py b/core/scan_engine.py
index a268dfc..aac953e 100644
--- a/core/scan_engine.py
+++ b/core/scan_engine.py
@@ -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)
diff --git a/gui/scan_bridge.py b/gui/scan_bridge.py
index 136be41..950bec7 100644
--- a/gui/scan_bridge.py
+++ b/gui/scan_bridge.py
@@ -32,7 +32,8 @@ class QtScanController(QObject):
paused_changed = pyqtSignal(bool) # True while paused at a row boundary
def __init__(self, stage, scope, rotator, plan, out_path,
- resume=None, on_scan_active=None, burst_mode=False):
+ resume=None, on_scan_active=None, burst_mode=False,
+ strict_rows=False):
super().__init__()
self._prompt_event = threading.Event()
self._on_scan_active = on_scan_active
@@ -48,7 +49,8 @@ class QtScanController(QObject):
)
self._engine = ScanEngine(stage, scope, rotator, plan, out_path,
resume=resume, callbacks=callbacks,
- burst_mode=burst_mode)
+ burst_mode=burst_mode,
+ strict_rows=strict_rows)
# ── Engine control (called from the GUI thread) ───────────────────────────
diff --git a/sc3-aui-main.ui b/sc3-aui-main.ui
index 72b5095..b08c1de 100755
--- a/sc3-aui-main.ui
+++ b/sc3-aui-main.ui
@@ -1007,6 +1007,16 @@
+ -
+
+
+ Stop the scan if a row does not acquire the expected number of frames, instead of zero-padding a short row or truncating a long one. Use for data runs where a silently squared-up row would be worse than a failed scan.
+
+
+ Strict row packing (abort on frame-count mismatch)
+
+
+
-
diff --git a/sc3_aui_app.py b/sc3_aui_app.py
index 7a53df5..58bd640 100755
--- a/sc3_aui_app.py
+++ b/sc3_aui_app.py
@@ -918,6 +918,7 @@ class MainWindow(QMainWindow):
self.scan_prefix_edit.setText("scan")
self.scan_save_dir_edit.setText(DEFAULTS.save_dir)
self.burst_mode_check.setChecked(DEFAULTS.burst_mode)
+ self.strict_rows_check.setChecked(DEFAULTS.strict_rows)
# Hide the old T3R manual controls; the connect toggle becomes the panel button.
for w in (
@@ -979,6 +980,7 @@ class MainWindow(QMainWindow):
self.bbd202_comport_edit.editingFinished.connect(self._persist_defaults)
self.oscope_ip_edit.editingFinished.connect(self._persist_defaults)
self.burst_mode_check.toggled.connect(self._persist_defaults)
+ self.strict_rows_check.toggled.connect(self._persist_defaults)
# Camera
self.show_camera_toggle.toggled.connect(self._on_camera_toggle)
@@ -1137,6 +1139,7 @@ class MainWindow(QMainWindow):
DEFAULTS.oscope_ip = self.oscope_ip_edit.text().strip()
DEFAULTS.save_dir = self.scan_save_dir_edit.text().strip()
DEFAULTS.burst_mode = self.burst_mode_check.isChecked()
+ DEFAULTS.strict_rows = self.strict_rows_check.isChecked()
DEFAULTS.save()
# ── Camera toggle ─────────────────────────────────────────────────────────
@@ -1261,6 +1264,7 @@ class MainWindow(QMainWindow):
on_scan_active=lambda active: setattr(
self._bbd_worker, "scanning_active", active),
burst_mode=self.burst_mode_check.isChecked(),
+ strict_rows=self.strict_rows_check.isChecked(),
)
self._scan_worker.moveToThread(self._scan_thread)
self._scan_thread.started.connect(self._scan_worker.run)
diff --git a/scan_format.md b/scan_format.md
index b7bdb0e..ad69ab8 100755
--- a/scan_format.md
+++ b/scan_format.md
@@ -222,9 +222,23 @@ re-armed for each acquiring pass. Row boundaries inside the burst come from
`ACQuire:NUMFRAMESACQuired?` sampled after each pass — the burst itself carries
no row markers. See `core/scope_burst.py`.
-Either path squares each row up to the declared `n_frames` (zero-padding a
-short row, dropping the tail of a long one), because the format has no per-row
-length field and a mismatch would shift every later row.
+### Row packing
+
+The format has no per-row length field, so a row that over- or under-triggers
+cannot be written as it arrived — that would shift every later row. Two
+policies are selectable (`ScanEngine(strict_rows=…)`, a checkbox in the app),
+and the choice is not recorded in the file:
+
+| | Pad (default) | Strict |
+|---|---|---|
+| Short row | zero-padded to `n_frames`, warned | scan stops |
+| Long row | trailing frames dropped, warned | scan stops |
+
+Pad keeps a scan running through an occasional mis-trigger, at the cost that
+the affected row is indistinguishable from a good one afterwards — nothing in
+the file records that it was padded. Strict is for data runs where that
+ambiguity is worse than a failed scan: it aborts before writing the row, so
+the file always ends on a whole-row boundary.
---
diff --git a/tests/test_scan_engine.py b/tests/test_scan_engine.py
index f338929..b315d1e 100644
--- a/tests/test_scan_engine.py
+++ b/tests/test_scan_engine.py
@@ -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