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
-1
@@ -99,6 +99,16 @@ class FakeStage:
|
||||
round(at_speed_mm * LASER_FREQ_HZ / SCAN_VELOCITY_MM_S))
|
||||
|
||||
|
||||
def background_record(n: int, samples_per_frame: int) -> bytes:
|
||||
"""The waveform FakeScope returns from its n-th background capture.
|
||||
|
||||
Every angle captures its own, so the tests need to tell one from the
|
||||
next: a background that landed under the wrong angle would otherwise
|
||||
look exactly like the right one.
|
||||
"""
|
||||
return bytes((n * 17 + s) % 256 for s in range(samples_per_frame))
|
||||
|
||||
|
||||
class FakeScope:
|
||||
"""Stands in for TektronixOscilloscopeBase.
|
||||
|
||||
@@ -113,6 +123,7 @@ class FakeScope:
|
||||
self._acq_polls = 0
|
||||
self._running = False
|
||||
self._acquired = 0
|
||||
self._backgrounds_taken = 0
|
||||
# Per-channel running frame index. Frame content is a function of
|
||||
# (channel, index) alone, so the same total frame sequence yields the
|
||||
# same bytes however it is chopped into transfers.
|
||||
@@ -194,7 +205,9 @@ class FakeScope:
|
||||
|
||||
def transfer_curve(self):
|
||||
self._t.record("transfer_curve")
|
||||
return bytes(range(self.samples_per_frame))
|
||||
n = self._backgrounds_taken
|
||||
self._backgrounds_taken += 1
|
||||
return background_record(n, self.samples_per_frame)
|
||||
|
||||
def _frames(self, ch, count):
|
||||
spf = self.samples_per_frame
|
||||
|
||||
@@ -141,7 +141,7 @@
|
||||
"angle_deg": -180.0,
|
||||
"n_rows": 3,
|
||||
"row_bytes": 96,
|
||||
"data_offset": 265,
|
||||
"data_offset": 553,
|
||||
"n_rows_available": 0,
|
||||
"status": "MISSING"
|
||||
}
|
||||
@@ -161,10 +161,10 @@
|
||||
"angle_deg": -180.0,
|
||||
"n_rows": 3,
|
||||
"row_bytes": 96,
|
||||
"data_offset": 265,
|
||||
"data_offset": 553,
|
||||
"n_rows_available": 0,
|
||||
"status": "MISSING"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+41
-4
@@ -1,9 +1,18 @@
|
||||
"""Shared constants for the golden .sras fixtures.
|
||||
"""Shared constants and writers for the .sras test fixtures.
|
||||
|
||||
These mirror the values tests/gen_goldens.py used when the fixtures were
|
||||
generated against the pre-refactor code (commit d185676); they must never
|
||||
change, or the byte-identical comparisons stop meaning anything.
|
||||
The constants mirror the values tests/gen_goldens.py used when the committed
|
||||
golden files were generated against the pre-refactor code (commit d185676);
|
||||
they must never change, or the comparisons against those files stop meaning
|
||||
anything. Those goldens are legacy v6 files — the one background per file
|
||||
layout — and are now read-only fixtures for the parser.
|
||||
|
||||
``write_v7`` builds the current layout (one background per angle, inside the
|
||||
data block) over the same geometry, for the tests that need a file this
|
||||
version of the app could actually have written.
|
||||
"""
|
||||
from core.scan_geometry import build_plan
|
||||
from core.sras_format import VERSION, create_scan_file, write_background_block
|
||||
|
||||
SPF = 8
|
||||
SAMPLE_RATE = 6.25e9
|
||||
CHANNELS = [1, 3, 4]
|
||||
@@ -19,3 +28,31 @@ VELOCITY_MM_S = 100.0
|
||||
|
||||
def synthetic_frame(ai, ri, ci, fi):
|
||||
return bytes((ai * 7 + ri * 5 + ci * 3 + fi + s) % 256 for s in range(SPF))
|
||||
|
||||
|
||||
def tiny_plan():
|
||||
"""The fixture geometry: 2 angles × 3 rows × 4 frames."""
|
||||
return build_plan(**TINY_PLAN_ARGS, laser_freq_hz=LASER_FREQ_HZ,
|
||||
velocity_mm_s=VELOCITY_MM_S)
|
||||
|
||||
|
||||
def angle_background(ai):
|
||||
"""A background that differs per angle, so tests can tell them apart."""
|
||||
return bytes((ai * 11 + s) % 256 for s in range(SPF))
|
||||
|
||||
|
||||
def write_v7(path, plan=None, version=None):
|
||||
"""Write a complete current-format file: [background][rows] per angle."""
|
||||
plan = plan if plan is not None else tiny_plan()
|
||||
f = create_scan_file(path, plan, SPF, SAMPLE_RATE, PREAMBLES,
|
||||
version=VERSION if version is None else version)
|
||||
try:
|
||||
for ai, pa in enumerate(plan.per_angle):
|
||||
write_background_block(f, angle_background(ai))
|
||||
for ri in range(pa.n_rows):
|
||||
for ci in range(len(CHANNELS)):
|
||||
for fi in range(pa.n_frames):
|
||||
f.write(synthetic_frame(ai, ri, ci, fi))
|
||||
finally:
|
||||
f.close()
|
||||
return plan
|
||||
|
||||
+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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ from pathlib import Path
|
||||
|
||||
from core.scan_resume import is_compatible, plan_resume
|
||||
from core.sras_format import SrasFile
|
||||
from golden_util import write_v7
|
||||
|
||||
GOLDEN = Path(__file__).parent / "golden"
|
||||
|
||||
@@ -47,11 +48,17 @@ def test_selecting_only_a_later_angle_pulls_in_the_frontier():
|
||||
assert plan.auto_added == [0]
|
||||
|
||||
|
||||
def test_targets_carry_offsets_and_rows():
|
||||
st = _statuses("complete.sras")
|
||||
def test_targets_carry_offsets_and_rows(tmp_path):
|
||||
out = tmp_path / "v7.sras"
|
||||
write_v7(out)
|
||||
st = SrasFile(out).angle_status()
|
||||
plan = plan_resume(st, selected={0, 1})
|
||||
for target, status in zip(plan.targets, st, strict=True):
|
||||
# A re-acquired angle rewrites its background too, so a target has to
|
||||
# know where the block starts as well as where the rows do.
|
||||
assert target.bg_offset == status.bg_offset
|
||||
assert target.data_offset == status.data_offset
|
||||
assert target.bg_offset < target.data_offset
|
||||
assert target.n_rows == status.n_rows
|
||||
assert target.angle_deg == status.angle_deg
|
||||
assert plan.total_rows == sum(s.n_rows for s in st)
|
||||
@@ -65,9 +72,19 @@ def test_to_state_carries_samples_per_frame():
|
||||
assert state.target_indices == {0}
|
||||
|
||||
|
||||
def test_is_compatible_checks_acquisition_settings():
|
||||
def test_is_compatible_rejects_a_legacy_file():
|
||||
"""A v6 file has no room for the background block each angle now writes."""
|
||||
sras = SrasFile(GOLDEN / "complete.sras")
|
||||
h = sras.header
|
||||
assert not is_compatible(sras, velocity=h.velocity, laser_freq=h.laser_freq,
|
||||
sample_rate=h.sample_rate, n_channels=h.n_channels)
|
||||
|
||||
|
||||
def test_is_compatible_checks_acquisition_settings(tmp_path):
|
||||
out = tmp_path / "v7.sras"
|
||||
write_v7(out)
|
||||
sras = SrasFile(out)
|
||||
h = sras.header
|
||||
ok = dict(velocity=h.velocity, laser_freq=h.laser_freq,
|
||||
sample_rate=h.sample_rate, n_channels=h.n_channels)
|
||||
assert is_compatible(sras, **ok)
|
||||
|
||||
@@ -22,7 +22,9 @@ def _loaded_scan(name="complete.sras"):
|
||||
def test_loaded_scan_basics():
|
||||
scan = _loaded_scan()
|
||||
assert scan.rows_available == [3, 3]
|
||||
assert scan.background is not None and len(scan.background) == 8
|
||||
# A legacy v6 fixture: its one background stands in for every angle.
|
||||
assert all(scan.background(ai) is not None and len(scan.background(ai)) == 8
|
||||
for ai in (0, 1))
|
||||
assert len(scan.calib.ymult_mv) == 3
|
||||
view = scan.angle_view(0)
|
||||
assert view.shape == (3, 3, 4, 8)
|
||||
|
||||
+142
-29
@@ -1,21 +1,27 @@
|
||||
"""core.sras_format vs the pre-refactor golden fixtures.
|
||||
"""core.sras_format: the current writer against the spec, and the parser
|
||||
against the pre-refactor golden fixtures.
|
||||
|
||||
The goldens were produced by the original sc3_aui_app implementation; the
|
||||
extracted module must reproduce them byte-for-byte (writer) and
|
||||
field-for-field (parser + frontier walk).
|
||||
The goldens are legacy v6 files produced by the original sc3_aui_app
|
||||
implementation — one background for the whole file. Nothing writes that
|
||||
layout any more, so they lock the parser (field-for-field, including the
|
||||
frontier walk) rather than the writer. The writer is locked instead against
|
||||
bytes this test lays out from scan_format.md itself.
|
||||
"""
|
||||
import json
|
||||
import struct
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from core.scan_geometry import build_plan
|
||||
from core.sras_format import SrasFile, create_scan_file
|
||||
from core.sras_format import (
|
||||
BG_LEN_FMT, GEOM_FMT, HDR_FMT, MAGIC, VERSION, VERSION_SAW_CHECK,
|
||||
SrasFile, create_scan_file,
|
||||
)
|
||||
from golden_util import (
|
||||
BACKGROUND, CHANNELS, LASER_FREQ_HZ, PREAMBLES, SAMPLE_RATE, SPF,
|
||||
TINY_PLAN_ARGS, VELOCITY_MM_S, synthetic_frame,
|
||||
BACKGROUND, CHANNELS, PREAMBLES, SAMPLE_RATE, SPF, angle_background,
|
||||
synthetic_frame, tiny_plan, write_v7,
|
||||
)
|
||||
|
||||
GOLDEN = Path(__file__).parent / "golden"
|
||||
@@ -27,28 +33,43 @@ def expected():
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _tiny_plan():
|
||||
return build_plan(**TINY_PLAN_ARGS, laser_freq_hz=LASER_FREQ_HZ,
|
||||
velocity_mm_s=VELOCITY_MM_S)
|
||||
def spec_bytes(plan):
|
||||
"""The v7 layout spelled out from scan_format.md, writer not involved."""
|
||||
buf = bytearray()
|
||||
buf += struct.pack(HDR_FMT, MAGIC, VERSION, plan.n_angles,
|
||||
plan.x_start_nominal, plan.y_start_nominal,
|
||||
plan.x_delta_nominal, plan.y_delta_nominal,
|
||||
plan.row_spacing, plan.velocity_mm_s, plan.laser_freq_hz,
|
||||
SPF, SAMPLE_RATE, 1, len(CHANNELS))
|
||||
buf += struct.pack(f">{plan.n_angles}f", *plan.angles)
|
||||
for pa in plan.per_angle:
|
||||
buf += struct.pack(GEOM_FMT, pa.x_start, pa.x_delta, pa.n_frames, pa.n_rows)
|
||||
for pa in plan.per_angle:
|
||||
buf += struct.pack(f">{pa.n_rows}f", *pa.y_positions)
|
||||
for pre in PREAMBLES:
|
||||
buf += struct.pack(">H", len(pre)) + pre.encode("utf-8")
|
||||
for ai, pa in enumerate(plan.per_angle):
|
||||
bg = angle_background(ai)
|
||||
buf += struct.pack(BG_LEN_FMT, len(bg)) + bg
|
||||
for ri in range(pa.n_rows):
|
||||
for ci in range(len(CHANNELS)):
|
||||
for fi in range(pa.n_frames):
|
||||
buf += synthetic_frame(ai, ri, ci, fi)
|
||||
return bytes(buf)
|
||||
|
||||
|
||||
def _write_complete(path):
|
||||
plan = _tiny_plan()
|
||||
f = create_scan_file(path, plan, SPF, SAMPLE_RATE, PREAMBLES, BACKGROUND)
|
||||
try:
|
||||
for ai, pa in enumerate(plan.per_angle):
|
||||
for ri in range(pa.n_rows):
|
||||
for ci in range(len(CHANNELS)):
|
||||
for fi in range(pa.n_frames):
|
||||
f.write(synthetic_frame(ai, ri, ci, fi))
|
||||
finally:
|
||||
f.close()
|
||||
def test_writer_matches_the_spec_byte_for_byte(tmp_path):
|
||||
out = tmp_path / "v7.sras"
|
||||
plan = write_v7(out)
|
||||
assert out.read_bytes() == spec_bytes(plan)
|
||||
|
||||
|
||||
def test_writer_byte_identical_to_golden(tmp_path):
|
||||
out = tmp_path / "rewrite.sras"
|
||||
_write_complete(out)
|
||||
assert out.read_bytes() == (GOLDEN / "complete.sras").read_bytes()
|
||||
def test_writer_refuses_the_legacy_versions(tmp_path):
|
||||
for version in (6, 10):
|
||||
with pytest.raises(ValueError, match=f"version {version}"):
|
||||
create_scan_file(tmp_path / "bad.sras", tiny_plan(), SPF,
|
||||
SAMPLE_RATE, PREAMBLES, version=version)
|
||||
assert not (tmp_path / "bad.sras").exists()
|
||||
|
||||
|
||||
def test_header_matches_golden(expected):
|
||||
@@ -75,15 +96,107 @@ def test_header_matches_golden(expected):
|
||||
|
||||
|
||||
def test_frontier_all_truncation_variants(expected):
|
||||
"""Every field the goldens recorded, plus the one added since.
|
||||
|
||||
The expectations predate AngleStatus.bg_offset, so they are compared key
|
||||
by key; a v6 angle has no background block of its own, which is exactly
|
||||
what bg_offset == data_offset says.
|
||||
|
||||
Two of the recorded data_offsets were corrected when the walk moved into
|
||||
the parser: an angle past the frontier used to report the frontier's own
|
||||
offset, because the old walk stopped advancing its cursor there, which
|
||||
handed a resumed scan the same write position for every missing angle.
|
||||
They are now the declared position each angle will be written at.
|
||||
"""
|
||||
for name, exp_statuses in expected["statuses"].items():
|
||||
statuses = SrasFile(GOLDEN / name).angle_status()
|
||||
assert [asdict(s) for s in statuses] == exp_statuses, f"mismatch for {name}"
|
||||
assert len(statuses) == len(exp_statuses), f"mismatch for {name}"
|
||||
for status, exp in zip(statuses, exp_statuses, strict=True):
|
||||
got = asdict(status)
|
||||
assert {k: got[k] for k in exp} == exp, f"mismatch for {name}"
|
||||
assert status.bg_offset == status.data_offset
|
||||
assert status.bg_bytes == 0
|
||||
|
||||
|
||||
def test_preambles_and_background_roundtrip():
|
||||
def test_legacy_preambles_and_shared_background():
|
||||
"""A v6 file's one background stands in for every angle's."""
|
||||
sras = SrasFile(GOLDEN / "complete.sras")
|
||||
assert sras.preambles == PREAMBLES
|
||||
assert sras.background == BACKGROUND
|
||||
assert sras.is_legacy_layout
|
||||
assert sras.backgrounds == [BACKGROUND] * sras.header.n_angles
|
||||
assert np.array_equal(sras.background_array(1),
|
||||
np.frombuffer(BACKGROUND, dtype=np.int8))
|
||||
|
||||
|
||||
# ── Per-angle backgrounds (v7/v11) ───────────────────────────────────────────
|
||||
|
||||
def test_each_angle_keeps_its_own_background(tmp_path):
|
||||
out = tmp_path / "v7.sras"
|
||||
plan = write_v7(out)
|
||||
|
||||
sras = SrasFile(out)
|
||||
assert not sras.is_legacy_layout
|
||||
assert sras.backgrounds == [angle_background(ai)
|
||||
for ai in range(plan.n_angles)]
|
||||
assert [s.status for s in sras.angle_status()] == ["OK"] * plan.n_angles
|
||||
# Every angle's rows start just past its own background block …
|
||||
for st in sras.angle_status():
|
||||
assert st.bg_bytes == 4 + SPF
|
||||
assert st.data_offset == st.bg_offset + st.bg_bytes
|
||||
# … and the data itself still reads back frame for frame.
|
||||
assert sras.load_row(1, 2, 0).tobytes() == b"".join(
|
||||
synthetic_frame(1, 2, 0, fi) for fi in range(plan.per_angle[1].n_frames))
|
||||
sras.close()
|
||||
|
||||
|
||||
def test_saw_check_version_also_carries_per_angle_backgrounds(tmp_path):
|
||||
from core.saw_check import middle_row_plan
|
||||
out = tmp_path / "check.sras"
|
||||
plan = write_v7(out, plan=middle_row_plan(tiny_plan()),
|
||||
version=VERSION_SAW_CHECK)
|
||||
|
||||
sras = SrasFile(out)
|
||||
assert sras.is_saw_check and not sras.is_legacy_layout
|
||||
assert sras.backgrounds == [angle_background(ai)
|
||||
for ai in range(plan.n_angles)]
|
||||
sras.close()
|
||||
|
||||
|
||||
def test_angle_missing_its_background_is_the_frontier(tmp_path):
|
||||
"""A file cut inside a background block stops at that angle.
|
||||
|
||||
Nothing of that angle is on disk yet — not even the reference its rows
|
||||
would be read against — so it is MISSING rather than TRUNCATED, and its
|
||||
predicted offsets are where a resumed scan would write.
|
||||
"""
|
||||
out = tmp_path / "v7.sras"
|
||||
write_v7(out)
|
||||
whole = out.read_bytes()
|
||||
bg1 = SrasFile(out).angle_status()[1].bg_offset
|
||||
|
||||
for cut, expected_status in ((bg1, "MISSING"), (bg1 + 4 + SPF // 2, "MISSING")):
|
||||
out.write_bytes(whole[:cut])
|
||||
statuses = SrasFile(out).angle_status()
|
||||
assert [s.status for s in statuses] == ["OK", expected_status]
|
||||
assert statuses[1].n_rows_available == 0
|
||||
assert statuses[1].bg_offset == bg1
|
||||
# The absent block is predicted at a full record's worth of bytes,
|
||||
# which is what the writer will produce when the scan resumes.
|
||||
assert statuses[1].data_offset == bg1 + 4 + SPF
|
||||
|
||||
|
||||
def test_rows_after_a_background_still_truncate_by_row(tmp_path):
|
||||
out = tmp_path / "v7.sras"
|
||||
plan = write_v7(out)
|
||||
whole = out.read_bytes()
|
||||
st1 = SrasFile(out).angle_status()[1]
|
||||
|
||||
out.write_bytes(whole[:st1.data_offset + 2 * st1.row_bytes])
|
||||
statuses = SrasFile(out).angle_status()
|
||||
assert [s.status for s in statuses] == ["OK", "TRUNCATED"]
|
||||
assert statuses[1].n_rows_available == 2
|
||||
assert SrasFile(out).load_angle(1, n_rows=2).shape[0] == 2
|
||||
assert plan.per_angle[1].n_rows == 3
|
||||
|
||||
|
||||
def test_load_angle_memmap_equals_eager():
|
||||
|
||||
Reference in New Issue
Block a user