Files
scanengine-3/tests/test_sras_format.py
T
Thomas K Ales [MSE] 880b05fe86 working state commit
2026-09-25 08:13:03 -05:00

251 lines
10 KiB
Python
Executable File

"""core.sras_format: the current writer against the spec, and the parser
against the pre-refactor golden fixtures.
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.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, PREAMBLES, SAMPLE_RATE, SPF, angle_background,
synthetic_frame, tiny_plan, write_v7,
)
GOLDEN = Path(__file__).parent / "golden"
@pytest.fixture(scope="module")
def expected():
with open(GOLDEN / "sras_expected.json") as f:
return json.load(f)
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 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_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):
sras = SrasFile(GOLDEN / "complete.sras")
h = expected["header"]
assert asdict(sras.header) == {
"n_angles": h["n_angles"],
"x_start_nominal": h["x_start_nominal"], "y_start_nominal": h["y_start_nominal"],
"x_delta_nominal": h["x_delta_nominal"], "y_delta_nominal": h["y_delta_nominal"],
"row_spacing": h["row_spacing"], "velocity": h["velocity"],
"laser_freq": h["laser_freq"],
"samples_per_frame": h["samples_per_frame"], "sample_rate": h["sample_rate"],
"bytes_per_sample": h["bytes_per_sample"], "n_channels": h["n_channels"],
}
assert sras.data_start_offset == h["data_start_offset"]
assert [pa.angle_deg for pa in sras.per_angle] == h["angles"]
for pa, exp in zip(sras.per_angle, h["per_angle"], strict=True):
assert pa.angle_deg == exp["angle"]
assert pa.x_start == exp["x_start"]
assert pa.x_delta == exp["x_delta"]
assert pa.n_frames == exp["n_frames"]
assert pa.n_rows == exp["n_rows"]
assert pa.y_positions == exp["y_positions"]
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 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_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.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():
with SrasFile(GOLDEN / "complete.sras") as sras:
raw = (GOLDEN / "complete.sras").read_bytes()
for ai, pa in enumerate(sras.per_angle):
view = sras.load_angle(ai)
h = sras.header
assert view.shape == (pa.n_rows, h.n_channels, pa.n_frames, h.samples_per_frame)
start = sras.angle_data_offset(ai)
eager = np.frombuffer(
raw, dtype=np.int8, offset=start, count=view.size
).reshape(view.shape)
assert np.array_equal(view, eager)
assert not view.flags.writeable
def test_load_row_matches_synthetic_pattern():
with SrasFile(GOLDEN / "complete.sras") as sras:
for ai in range(2):
for ri in range(3):
for ci in range(3):
row = sras.load_row(ai, ri, ci)
expected_bytes = b"".join(
synthetic_frame(ai, ri, ci, fi)
for fi in range(sras.per_angle[ai].n_frames)
)
assert row.tobytes() == expected_bytes
def test_truncated_load_angle_partial_rows():
sras = SrasFile(GOLDEN / "trunc_rowboundary_a1.sras")
st = sras.angle_status()[1]
assert st.status == "TRUNCATED" and st.n_rows_available == 2
view = sras.load_angle(1, n_rows=st.n_rows_available)
assert view.shape[0] == 2
sras.close()
def test_bad_magic_and_version_rejected(tmp_path):
bad = tmp_path / "bad.sras"
bad.write_bytes(b"XXXX" + bytes(60))
with pytest.raises(ValueError, match="bad magic"):
SrasFile(bad)
data = bytearray((GOLDEN / "complete.sras").read_bytes())
data[4] = 5 # version byte
v5 = tmp_path / "v5.sras"
v5.write_bytes(bytes(data))
with pytest.raises(ValueError, match="version 5"):
SrasFile(v5)