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:
@@ -14,8 +14,8 @@ from core.scan_engine import (
|
||||
ResumeTarget,
|
||||
)
|
||||
from core.scan_geometry import ScanGeometryError, build_plan
|
||||
from core.sras_format import SCAN_CHANNELS, SrasFile
|
||||
from fakes import FakeScope, FakeStage, FakeT3R, Trace
|
||||
from core.sras_format import BG_LEN_SIZE, SCAN_CHANNELS, SrasFile
|
||||
from fakes import FakeScope, FakeStage, FakeT3R, Trace, background_record
|
||||
|
||||
SPF = 8
|
||||
|
||||
@@ -57,7 +57,41 @@ def test_single_angle_scan_writes_readable_file(tmp_path):
|
||||
# File is complete: every declared row present on disk
|
||||
assert [s.status for s in sras.angle_status()] == ["OK"]
|
||||
assert len(sras.preambles) == 3
|
||||
assert sras.background == bytes(range(SPF))
|
||||
assert sras.backgrounds == [background_record(0, SPF)]
|
||||
|
||||
|
||||
def test_each_angle_captures_and_stores_its_own_background(tmp_path):
|
||||
"""One background per angle, taken after the rotation, kept ahead of it."""
|
||||
prompts = []
|
||||
engine, trace, plan = build(
|
||||
tmp_path, num_angles=3,
|
||||
callbacks=ScanCallbacks(prompt=lambda title, msg: prompts.append(title)))
|
||||
engine.run()
|
||||
|
||||
sras = SrasFile(tmp_path / "out.sras")
|
||||
assert trace.count("transfer_curve") == 3
|
||||
assert sras.backgrounds == [background_record(i, SPF) for i in range(3)]
|
||||
for st in sras.angle_status():
|
||||
assert st.bg_bytes == BG_LEN_SIZE + SPF
|
||||
assert st.status == "OK"
|
||||
|
||||
# Each capture follows the rotation to the angle it belongs to (the last
|
||||
# rotation is the return to home after the final angle).
|
||||
assert [c[0] for c in trace.calls
|
||||
if c[0] in ("t3r_rotate", "transfer_curve")] == [
|
||||
"transfer_curve", "t3r_rotate", "transfer_curve",
|
||||
"t3r_rotate", "transfer_curve", "t3r_rotate"]
|
||||
|
||||
# Two prompts per angle: Genesis off for the capture, back on to scan.
|
||||
assert prompts == [title for i in range(3) for title in
|
||||
(f"Background Capture — Angle {i + 1}/3",
|
||||
f"Begin Angle {i + 1}/3")]
|
||||
|
||||
# The scope goes back to the scan trigger after every capture, not just
|
||||
# once at the start — the capture needs the single-record edge trigger.
|
||||
cmds = [c[1] for c in trace.of("write")]
|
||||
assert cmds.count("TRIGger:A:TYPe EDGE") == 4 # prepare + one per angle
|
||||
assert cmds.count("TRIGger:A:TYPe LOGIc") == 4
|
||||
|
||||
|
||||
def test_command_sequence_order(tmp_path):
|
||||
@@ -236,8 +270,9 @@ def test_resume_seeks_to_angle_offset_and_skips_others(tmp_path):
|
||||
target = statuses[1]
|
||||
resume = ResumeState(
|
||||
path=path,
|
||||
targets=[ResumeTarget(target.index, target.data_offset,
|
||||
target.n_rows, target.angle_deg)],
|
||||
targets=[ResumeTarget(target.index, target.bg_offset,
|
||||
target.data_offset, target.n_rows,
|
||||
target.angle_deg)],
|
||||
samples_per_frame=SPF,
|
||||
)
|
||||
|
||||
@@ -249,19 +284,51 @@ def test_resume_seeks_to_angle_offset_and_skips_others(tmp_path):
|
||||
assert result.rows_written == plan.per_angle[1].n_rows
|
||||
rewritten = path.read_bytes()
|
||||
assert len(rewritten) == len(original)
|
||||
# Angle 0's block is untouched; angle 1's changed (fresh frame data)
|
||||
a1_start, a1_end = target.data_offset, target.data_offset + target.row_bytes * target.n_rows
|
||||
# Angle 0's block is untouched; angle 1's changed — background included,
|
||||
# since a re-acquired angle captures a fresh one over the old.
|
||||
a1_start = target.bg_offset
|
||||
a1_end = target.data_offset + target.row_bytes * target.n_rows
|
||||
assert rewritten[:a1_start] == original[:a1_start]
|
||||
assert rewritten[a1_start:a1_end] != original[a1_start:a1_end]
|
||||
assert rewritten[a1_end:] == original[a1_end:]
|
||||
|
||||
# The new background is angle 1's own, written in place of its old one.
|
||||
reread = SrasFile(path)
|
||||
assert reread.backgrounds[1] == background_record(0, SPF)
|
||||
assert reread.backgrounds[0] == SrasFile(path).backgrounds[0]
|
||||
assert [s.status for s in reread.angle_status()] == ["OK"] * 3
|
||||
|
||||
|
||||
def test_resume_rejects_a_background_that_would_shift_the_file(tmp_path):
|
||||
"""A re-acquired angle's background must fit the room the file has.
|
||||
|
||||
Nothing else in the file records where an angle's rows begin, so a longer
|
||||
or shorter background would push every row behind it out of position.
|
||||
"""
|
||||
engine, _, _ = build(tmp_path, num_angles=2)
|
||||
engine.run()
|
||||
path = tmp_path / "out.sras"
|
||||
target = SrasFile(path).angle_status()[0]
|
||||
resume = ResumeState(
|
||||
path=path,
|
||||
targets=[ResumeTarget(0, target.bg_offset, target.data_offset,
|
||||
target.n_rows, target.angle_deg)],
|
||||
samples_per_frame=SPF,
|
||||
)
|
||||
engine2, _, _ = build(tmp_path, num_angles=2, resume=resume)
|
||||
# A scope that hands back a longer record than the file was written with
|
||||
engine2._scope.transfer_curve = lambda: bytes(SPF + 4)
|
||||
|
||||
with pytest.raises(RuntimeError, match="shift every row"):
|
||||
engine2.run()
|
||||
|
||||
|
||||
def test_resume_record_length_mismatch_rejected(tmp_path):
|
||||
engine, trace, plan = build(tmp_path)
|
||||
engine.run()
|
||||
path = tmp_path / "out.sras"
|
||||
resume = ResumeState(path=path,
|
||||
targets=[ResumeTarget(0, 0, 1, 0.0)],
|
||||
targets=[ResumeTarget(0, 0, 0, 1, 0.0)],
|
||||
samples_per_frame=SPF + 1) # scope changed
|
||||
engine2, _, _ = build(tmp_path, resume=resume)
|
||||
with pytest.raises(RuntimeError, match="record length"):
|
||||
@@ -461,7 +528,7 @@ def test_strict_row_packing_writes_nothing_for_the_failed_row(tmp_path):
|
||||
# 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
|
||||
written = (tmp_path / "out.sras").stat().st_size - sras.angle_data_offset(0)
|
||||
assert written == sras.row_bytes(0)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user