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:
+48
-16
@@ -1,7 +1,7 @@
|
||||
"""Middle-row SAW quality check: plan reduction, the v10 file, and the read-out.
|
||||
"""Middle-row SAW quality check: plan reduction, the v11 file, and the read-out.
|
||||
|
||||
The acquisition half runs on the same fake rig as the scan tests; the
|
||||
analysis half runs on a synthetic v10 file whose CH1 is a pure sine at a
|
||||
analysis half runs on a synthetic v11 file whose CH1 is a pure sine at a
|
||||
known FFT bin, so the frequency a trace reports is a number the test knows
|
||||
in advance rather than one it copies from the implementation.
|
||||
"""
|
||||
@@ -19,6 +19,7 @@ from core.scan_engine import ScanCallbacks, ScanEngine
|
||||
from core.scan_geometry import ScanGeometryError, build_plan
|
||||
from core.sras_format import (
|
||||
SCAN_CHANNELS, VERSION, VERSION_SAW_CHECK, SrasFile, create_scan_file,
|
||||
write_background_block,
|
||||
)
|
||||
from fakes import FakeScope, FakeStage, FakeT3R, Trace
|
||||
|
||||
@@ -51,13 +52,19 @@ def sine_frame(k: int) -> bytes:
|
||||
return np.round(100 * np.sin(2 * math.pi * k * n / SPF)).astype(np.int8).tobytes()
|
||||
|
||||
|
||||
def write_check(path, bins, n_masked_frames=0, plan=None):
|
||||
"""A synthetic v10 file: angle `i`'s CH1 is a sine at FFT bin `bins[i]`."""
|
||||
def write_check(path, bins, n_masked_frames=0, plan=None, backgrounds=None):
|
||||
"""A synthetic v11 file: angle `i`'s CH1 is a sine at FFT bin `bins[i]`.
|
||||
|
||||
``backgrounds`` supplies each angle's own background block; the default
|
||||
is a flat zero one per angle, which subtracts to nothing.
|
||||
"""
|
||||
plan = plan if plan is not None else middle_row_plan(full_plan(len(bins)))
|
||||
f = create_scan_file(path, plan, SPF, SAMPLE_RATE, PREAMBLES, bytes(SPF),
|
||||
f = create_scan_file(path, plan, SPF, SAMPLE_RATE, PREAMBLES,
|
||||
version=VERSION_SAW_CHECK)
|
||||
try:
|
||||
for ai, pa in enumerate(plan.per_angle):
|
||||
write_background_block(
|
||||
f, bytes(SPF) if backgrounds is None else backgrounds[ai])
|
||||
wave = sine_frame(bins[ai])
|
||||
for ch in SCAN_CHANNELS:
|
||||
for fi in range(pa.n_frames):
|
||||
@@ -127,9 +134,9 @@ def test_middle_row_plan_rejects_an_angle_with_no_rows():
|
||||
middle_row_plan(plan)
|
||||
|
||||
|
||||
# ── The v10 file ─────────────────────────────────────────────────────────────
|
||||
# ── The v11 file ─────────────────────────────────────────────────────────────
|
||||
|
||||
def test_v10_write_read_roundtrip(tmp_path):
|
||||
def test_saw_check_write_read_roundtrip(tmp_path):
|
||||
out = tmp_path / "check.sras"
|
||||
plan = write_check(out, bins=(8, 8, 8))
|
||||
|
||||
@@ -141,24 +148,25 @@ def test_v10_write_read_roundtrip(tmp_path):
|
||||
sras.close()
|
||||
|
||||
|
||||
def test_v10_rejects_a_multi_row_plan(tmp_path):
|
||||
def test_saw_check_rejects_a_multi_row_plan(tmp_path):
|
||||
plan = full_plan(num_angles=2)
|
||||
assert any(pa.n_rows > 1 for pa in plan.per_angle)
|
||||
with pytest.raises(ValueError, match="exactly one row per angle"):
|
||||
create_scan_file(tmp_path / "bad.sras", plan, SPF, SAMPLE_RATE,
|
||||
PREAMBLES, bytes(SPF), version=VERSION_SAW_CHECK)
|
||||
PREAMBLES, version=VERSION_SAW_CHECK)
|
||||
assert not (tmp_path / "bad.sras").exists()
|
||||
|
||||
|
||||
def test_unknown_version_rejected_at_write(tmp_path):
|
||||
with pytest.raises(ValueError, match="version 7"):
|
||||
with pytest.raises(ValueError, match="version 99"):
|
||||
create_scan_file(tmp_path / "bad.sras", middle_row_plan(full_plan(1)),
|
||||
SPF, SAMPLE_RATE, PREAMBLES, bytes(SPF), version=7)
|
||||
SPF, SAMPLE_RATE, PREAMBLES, version=99)
|
||||
|
||||
|
||||
def test_v6_file_is_not_a_saw_check():
|
||||
sras = SrasFile("tests/golden/complete.sras")
|
||||
assert sras.version == VERSION and not sras.is_saw_check
|
||||
def test_a_scan_is_not_a_saw_check():
|
||||
"""Legacy and current scans alike: only the check versions say check."""
|
||||
assert not SrasFile("tests/golden/complete.sras").is_saw_check
|
||||
assert VERSION not in (10, VERSION_SAW_CHECK)
|
||||
|
||||
|
||||
# ── Acquisition through the engine ───────────────────────────────────────────
|
||||
@@ -176,7 +184,7 @@ def run_engine(tmp_path, num_angles=3):
|
||||
return engine.run(), plan, check, trace
|
||||
|
||||
|
||||
def test_engine_writes_a_complete_v10_check(tmp_path):
|
||||
def test_engine_writes_a_complete_saw_check(tmp_path):
|
||||
result, plan, check, _ = run_engine(tmp_path)
|
||||
|
||||
assert not result.aborted
|
||||
@@ -200,7 +208,7 @@ def test_engine_visits_each_middle_row_once(tmp_path):
|
||||
assert y_moves == [round(pa.y_positions[0], 4) for pa in check.per_angle]
|
||||
|
||||
|
||||
def test_engine_still_writes_v6_by_default(tmp_path):
|
||||
def test_engine_still_writes_a_full_scan_by_default(tmp_path):
|
||||
trace = Trace()
|
||||
scope = FakeScope(trace, samples_per_frame=SPF)
|
||||
stage = FakeStage(trace, scope=scope)
|
||||
@@ -229,6 +237,30 @@ def test_traces_report_the_injected_frequency(tmp_path):
|
||||
assert trace.drift_mhz_per_mm == pytest.approx(0.0, abs=1e-6)
|
||||
|
||||
|
||||
def test_background_subtraction_uses_each_angles_own(tmp_path):
|
||||
"""Every angle is referenced against its own background, not angle 1's.
|
||||
|
||||
Each angle's background here is a copy of that angle's own CH1 wave, so
|
||||
subtracting the right one leaves nothing to read at any angle — where
|
||||
reusing angle 1's everywhere would leave angles 2 and 3 reporting their
|
||||
sine unchanged.
|
||||
"""
|
||||
out = tmp_path / "check.sras"
|
||||
bins = (8, 9, 10)
|
||||
backgrounds = [sine_frame(k) for k in bins]
|
||||
write_check(out, bins=bins, backgrounds=backgrounds)
|
||||
|
||||
with SrasFile(out) as sras:
|
||||
assert sras.backgrounds == backgrounds
|
||||
plain = frequency_traces(sras, dc_threshold_mv=DC_THRESHOLD_MV)
|
||||
subtracted = frequency_traces(sras, dc_threshold_mv=DC_THRESHOLD_MV,
|
||||
subtract_background=True)
|
||||
|
||||
assert [t.median_mhz for t in plain] == [pytest.approx(bin_mhz(k)) for k in bins]
|
||||
for trace in subtracted:
|
||||
assert np.isnan(trace.freq_mhz).all()
|
||||
|
||||
|
||||
def test_masked_pixels_become_nan_not_zero(tmp_path):
|
||||
out = tmp_path / "check.sras"
|
||||
write_check(out, bins=(8, 8, 8), n_masked_frames=2)
|
||||
|
||||
Reference in New Issue
Block a user