251 lines
10 KiB
Python
Executable File
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)
|