Per-angle background capture: v7/v11 .sras layout

A multi-angle scan runs for hours, but every angle was referenced against
one background captured before the first row of the first angle. That
reference has drifted by the last angle, and comparing angles — the whole
point of a multi-angle scan — was comparing each one against a noise floor
measured at whichever angle came first.

Every angle now captures its own. Before each angle's rows, the operator is
prompted to switch the Genesis laser off, the engine averages a fresh CH1
record, and the operator switches it back on. The data block therefore reads
[background][scan][background][scan] …, one pair per angle.

Format v7 (scan) and v11 (SAW check) carry the background inside the data
block, one length-prefixed block ahead of each angle's rows; the single
block that sat between the preambles and the data is gone. Per-angle offsets
now come from a walk of the data block at parse time rather than arithmetic
over the geometry table, and an angle whose background is not fully on disk
is the frontier — nothing of it was written yet.

v6/v10 files still read: SrasFile hands their one background to every angle,
so readers never branch on the version. Nothing writes them, and a resume
refuses them, since a re-acquired angle writes a block the old layout has no
room for. A resumed v7 angle rewrites its background in place, and the
engine checks the new block fits the room the file has before writing it —
anything else would shift every row behind it.

Two fixes made along the way:

  * QtScanController never accepted file_version, so every scan launched
    from the app raised TypeError at construction.
  * angle_status() left its cursor parked at the frontier, so every angle
    past it reported the frontier's own data_offset — which handed a resumed
    scan the same write position for several angles. Two recorded offsets in
    tests/golden/sras_expected.json are corrected accordingly.

The v6 goldens stay as parser fixtures; the writer is now locked against
bytes the test lays out from scan_format.md itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Thomas Ales
2026-09-04 12:44:25 -05:00
parent 83744c3337
commit aa06fa1460
21 changed files with 823 additions and 241 deletions
+71 -27
View File
@@ -18,7 +18,10 @@ from typing import Callable
from core import scope_burst, scope_sras
from core.rotation import RotationAxis
from core.scan_geometry import ScanPlan, validate_plan
from core.sras_format import SCAN_CHANNELS, VERSION, create_scan_file
from core.sras_format import (
BG_LEN_SIZE, SCAN_CHANNELS, VERSION, create_scan_file,
write_background_block,
)
logger = logging.getLogger(__name__)
@@ -42,8 +45,15 @@ class ScanAborted(Exception):
@dataclass
class ResumeTarget:
"""One angle selected for (re)acquisition in an existing file."""
"""One angle selected for (re)acquisition in an existing file.
``bg_offset`` is where the angle's background block starts and
``data_offset`` where its rows do; the gap between them is the room the
file already has for a background, which a re-acquired angle must fill
exactly or every row behind it would shift.
"""
angle_idx: int
bg_offset: int
data_offset: int
n_rows: int
angle_deg: float
@@ -280,27 +290,14 @@ class ScanEngine:
if self._resume is None:
self._preambles = scope_sras.read_preambles(self._scope, SCAN_CHANNELS)
self._prompt(
"Background Capture",
"Please ensure the Helios laser is ON and the Genesis laser is OFF,\n"
"then click OK to capture the background waveform."
)
self._background = scope_sras.capture_background(
self._scope, should_abort=self._abort.is_set,
on_status=self._cb.on_status)
self._prompt(
"Begin Scanning",
"Background captured successfully.\n\n"
"Please ensure the Genesis laser is back ON,\n"
"then click OK to begin scanning."
)
else:
# Resuming: the file's existing background waveform and channel
# preambles are reused as-is (the format has no way to replace
# them without rewriting the whole file), so background capture is
# skipped. Sanity-check that this scope still produces the same
# record length the file was started with — a mismatch would
# silently corrupt the ragged per-row byte layout on append.
# Resuming: the file's channel preambles are reused as-is (the
# format has no way to replace them without rewriting the whole
# file). Each re-acquired angle still captures its own fresh
# background, which is rewritten in place over the old one.
# Sanity-check that this scope still produces the same record
# length the file was started with — a mismatch would silently
# corrupt the ragged per-row byte layout on append.
if samples_per_frame != self._resume.samples_per_frame:
raise RuntimeError(
f"Oscilloscope record length ({samples_per_frame} samples/frame) "
@@ -314,8 +311,8 @@ class ScanEngine:
f"angle(s) {targets} of {self._plan.n_angles}.\n\n"
"Please re-home the GR axis to 0° before continuing — the scan "
"will rotate it directly from angle to angle before scanning resumes.\n\n"
"Please ensure the Genesis laser is ON,\n"
"then click OK to continue scanning."
"Each angle begins with its own background capture, so you will "
"be asked to switch the Genesis laser off and on again per angle."
)
scope_sras.configure_scan_trigger(self._scope)
@@ -341,7 +338,7 @@ class ScanEngine:
return open(self._resume.path, "r+b")
return create_scan_file(
self._out_path, self._plan, samples_per_frame,
scope_sras.SAMPLE_RATE_HZ, self._preambles, self._background,
scope_sras.SAMPLE_RATE_HZ, self._preambles,
version=self._file_version,
)
@@ -363,11 +360,12 @@ class ScanEngine:
continue # not selected for (re)acquisition
self._pause_point()
if targets_by_ai is not None:
target = None if targets_by_ai is None else targets_by_ai[ai]
if target is not None:
# Interior angles may already have valid data on either side,
# so seek to this angle's fixed offset rather than relying on
# the file's current position.
scan_file.seek(targets_by_ai[ai].data_offset)
scan_file.seek(target.bg_offset)
if self._rotator is not None and self._rotator.is_available:
delta = pa.angle_deg - self._rotator.current_deg
@@ -376,6 +374,9 @@ class ScanEngine:
f"Rotating GR to {pa.angle_deg:.1f}° (Δ{delta:+.1f}°) …")
self._rotator.rotate_to(pa.angle_deg)
self._write_angle_background(scan_file, ai, n_angles,
pa.angle_deg, target)
if self._burst_mode:
# Burst mode sizes the FastFrame count from the scope's whole
# capacity instead (see scope_burst.start_burst), so there is
@@ -391,6 +392,49 @@ class ScanEngine:
result.angles_acquired.append(ai)
def _write_angle_background(self, scan_file, ai: int, n_angles: int,
angle_deg: float, target: ResumeTarget | None):
"""Capture this angle's background and write it ahead of its rows.
The Genesis laser has to be off for the capture and back on for the
scan, so every angle costs two operator prompts and one averaged
record. That buys a background taken minutes from the data it will
be subtracted from, instead of one taken hours earlier at angle 1.
On resume the block is overwritten in place, so it has to be exactly
as long as the one already there — anything else would shift every
row behind it. Checked before the write, not after.
"""
scope = self._scope
scope_sras.configure_background_trigger(scope)
self._prompt(
f"Background Capture — Angle {ai + 1}/{n_angles}",
f"Angle {ai + 1} of {n_angles} ({angle_deg:.1f}°) starts with its "
"own background capture.\n\n"
"Please switch the Genesis laser OFF — leave the Helios laser ON —\n"
"then click OK to capture the background waveform."
)
background = scope_sras.capture_background(
scope, should_abort=self._abort.is_set, on_status=self._cb.on_status)
self._prompt(
f"Begin Angle {ai + 1}/{n_angles}",
"Background captured successfully.\n\n"
"Please switch the Genesis laser back ON,\n"
f"then click OK to scan angle {ai + 1} of {n_angles}."
)
scope_sras.configure_scan_trigger(scope)
if target is not None:
room = target.data_offset - target.bg_offset
if BG_LEN_SIZE + len(background) != room:
raise RuntimeError(
f"Angle {ai + 1}: the new background block is "
f"{BG_LEN_SIZE + len(background)} bytes but the file has room "
f"for {room} — writing it would shift every row behind it, "
"so the scan stops here."
)
write_background_block(scan_file, background)
# ── Per-row acquisition (one FastFrame acquisition per row) ───────────────
def _scan_rows_serial(self, scan_file, pa, ai: int, n_angles: int,