Phase 2: extract headless core modules (sras_format, scan_geometry, config)
- core/sras_format.py: THE v6 implementation — create_scan_file (writer, byte-identical to the old one, enforced against the Phase-0 goldens), SrasFile parser with frontier/truncation walk, and zero-copy mmap load_angle/load_row views for multi-GB files - core/scan_geometry.py: ScanPlan/AngleGeometry dataclasses, build_plan (rotated-bbox trig from MainWindow._build_scan_params), travel-limit validate_plan (limits now a StageLimits dataclass, not literals buried in the worker), format_eta + EtaEstimator (bounded deque) - core/config.py: ScanDefaults dataclass replaces the module-import-time dict globals. FIXES: editing any main-window port used to rewrite aui_defaults.json without helios_port, silently reverting the Helios port every time (test_helios_port_survives_partial_update covers it). Also drops the inert laser_freq_hz plumbing — scans always used the LASER_FREQ_HZ constant. - hardware/serial_util.py: shared 8N1 open + scored port enumeration (promoted from t3r_control_panel); helios_laser and the panel use it - sc3_aui_app.py and sras_scan_manager.py migrated onto core (three format implementations down to one); ScanWorker now takes a ScanPlan - tests: byte-identical writer vs golden, frontier over every truncation variant, mmap==eager, geometry vs golden fixtures + invariants, config round-trip. 28 passing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,173 +0,0 @@
|
||||
"""Generate golden fixtures capturing PRE-REFACTOR behavior (Phase 0).
|
||||
|
||||
Run once against the un-refactored sc3_aui_app.py; the outputs are committed
|
||||
under tests/golden/. After the refactor, tests compare the new
|
||||
implementations against these committed files — do NOT regenerate them
|
||||
against refactored code, that would defeat the purpose.
|
||||
|
||||
Usage: .venv/bin/python tests/gen_goldens.py
|
||||
"""
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
import conftest # noqa: F401 (pyueye stub + offscreen)
|
||||
|
||||
import sc3_aui_app as app
|
||||
|
||||
GOLDEN = Path(__file__).parent / "golden"
|
||||
|
||||
# ── Geometry fixtures ────────────────────────────────────────────────────────
|
||||
|
||||
GEOMETRY_CASES = [
|
||||
# label, XS, YS, XD, YD, num_angles, row_spacing
|
||||
("single_angle_square", "10.0", "10.0", "10.0", "10.0", "1", "0.25"),
|
||||
("single_angle_rect", "10.0", "10.0", "80.0", "50.0", "1", "0.25"),
|
||||
("three_angles", "5.0", "5.0", "20.0", "12.0", "3", "0.1"),
|
||||
("five_angles", "0.0", "0.0", "30.0", "30.0", "5", "0.5"),
|
||||
("seven_angles_asym", "12.5", "7.5", "42.0", "17.0", "7", "0.05"),
|
||||
("two_angles_tiny", "1.0", "1.0", "0.02", "0.02", "2", "0.01"),
|
||||
("flat_line_ydelta_zero", "10.0", "10.0", "5.0", "0.0", "1", "0.25"),
|
||||
("fine_spacing", "3.0", "4.0", "2.5", "1.5", "4", "0.025"),
|
||||
]
|
||||
|
||||
GEOMETRY_ERROR_CASES = [
|
||||
("xd_zero", "10.0", "10.0", "0.0", "10.0", "1", "0.25"),
|
||||
("xd_negative", "10.0", "10.0", "-5.0", "10.0", "1", "0.25"),
|
||||
("spacing_zero", "10.0", "10.0", "10.0", "10.0", "1", "0.0"),
|
||||
("angles_zero", "10.0", "10.0", "10.0", "10.0", "0", "0.25"),
|
||||
("xs_not_number", "abc", "10.0", "10.0", "10.0", "1", "0.25"),
|
||||
]
|
||||
|
||||
|
||||
class _W:
|
||||
"""Stands in for a QLineEdit: _build_scan_params only calls .text()."""
|
||||
def __init__(self, text):
|
||||
self._text = text
|
||||
|
||||
def text(self):
|
||||
return self._text
|
||||
|
||||
|
||||
def _fake_window(xs, ys, xd, yd, na, rs):
|
||||
fs = type("FakeMainWindow", (), {})()
|
||||
fs.x_start_edit = _W(xs)
|
||||
fs.y_start_edit = _W(ys)
|
||||
fs.x_delta_edit = _W(xd)
|
||||
fs.y_delta_edit = _W(yd)
|
||||
fs.num_angles_edit = _W(na)
|
||||
fs.row_spacing_edit = _W(rs)
|
||||
fs.scan_prefix_edit = _W("golden")
|
||||
fs.scan_save_dir_edit = _W("/tmp/golden-scans")
|
||||
return fs
|
||||
|
||||
|
||||
def build_geometry_fixtures():
|
||||
out = {"constants": {
|
||||
"LASER_FREQ_HZ": app.LASER_FREQ_HZ,
|
||||
"SCAN_VELOCITY_MM_S": app.SCAN_VELOCITY_MM_S,
|
||||
"GR_ROTATION_SIGN": app.GR_ROTATION_SIGN,
|
||||
}, "cases": {}, "error_cases": {}}
|
||||
|
||||
for label, *inputs in GEOMETRY_CASES:
|
||||
params = app.MainWindow._build_scan_params(_fake_window(*inputs))
|
||||
out["cases"][label] = {
|
||||
"inputs": dict(zip(("XS", "YS", "XD", "YD", "num_angles", "row_spacing"), inputs)),
|
||||
"params": params,
|
||||
}
|
||||
|
||||
for label, *inputs in GEOMETRY_ERROR_CASES:
|
||||
try:
|
||||
app.MainWindow._build_scan_params(_fake_window(*inputs))
|
||||
raise AssertionError(f"error case {label} did not raise")
|
||||
except ValueError as e:
|
||||
out["error_cases"][label] = {
|
||||
"inputs": dict(zip(("XS", "YS", "XD", "YD", "num_angles", "row_spacing"), inputs)),
|
||||
"message": str(e),
|
||||
}
|
||||
|
||||
with open(GOLDEN / "geometry.json", "w") as f:
|
||||
json.dump(out, f, indent=2)
|
||||
print(f"geometry.json: {len(out['cases'])} cases, {len(out['error_cases'])} error cases")
|
||||
|
||||
|
||||
# ── SRAS v6 file fixtures ────────────────────────────────────────────────────
|
||||
|
||||
SPF = 8 # samples per frame (synthetic, tiny)
|
||||
SAMPLE_RATE = 6.25e9
|
||||
PREAMBLES = [f"WFMOUTPRE:CH{ch};SYNTHETIC;PT_FMT Y;XINCR 1.6E-10" for ch in app.SCAN_CHANNELS]
|
||||
BACKGROUND = bytes(range(SPF))
|
||||
|
||||
|
||||
def _synthetic_frame(ai, ri, ci, fi):
|
||||
return bytes((ai * 7 + ri * 5 + ci * 3 + fi + s) % 256 for s in range(SPF))
|
||||
|
||||
|
||||
def build_sras_fixtures():
|
||||
# Geometry via the real pre-refactor code path: 2 angles, 3 rows, 4 frames.
|
||||
params = app.MainWindow._build_scan_params(
|
||||
_fake_window("1.0", "1.0", "0.02", "0.02", "2", "0.01"))
|
||||
per_angle = params["per_angle"]
|
||||
angles = [pa["angle"] for pa in per_angle]
|
||||
|
||||
complete = GOLDEN / "complete.sras"
|
||||
f = app._open_scan_file(
|
||||
complete, angles, per_angle,
|
||||
params["x_start_nominal"], params["y_start_nominal"],
|
||||
params["x_delta_nominal"], params["y_delta_nominal"],
|
||||
params["row_spacing"],
|
||||
app.SCAN_VELOCITY_MM_S, app.LASER_FREQ_HZ,
|
||||
SPF, SAMPLE_RATE, PREAMBLES, BACKGROUND,
|
||||
)
|
||||
try:
|
||||
data_start = f.tell()
|
||||
for ai, pa in enumerate(per_angle):
|
||||
for ri in range(pa["n_rows"]):
|
||||
for ci in range(len(app.SCAN_CHANNELS)):
|
||||
for fi in range(pa["n_frames"]):
|
||||
f.write(_synthetic_frame(ai, ri, ci, fi))
|
||||
finally:
|
||||
f.close()
|
||||
|
||||
info = app._read_sras_header(complete)
|
||||
assert info["data_start_offset"] == data_start
|
||||
row_bytes = info["n_channels"] * per_angle[0]["n_frames"] * SPF * info["bytes_per_sample"]
|
||||
angle_bytes = [row_bytes * pa["n_rows"] for pa in per_angle]
|
||||
|
||||
def _truncate(name, size):
|
||||
dst = GOLDEN / name
|
||||
shutil.copyfile(complete, dst)
|
||||
with open(dst, "r+b") as g:
|
||||
g.truncate(data_start + size)
|
||||
return dst
|
||||
|
||||
variants = {
|
||||
"complete.sras": complete,
|
||||
"trunc_midrow_a1.sras": _truncate(
|
||||
"trunc_midrow_a1.sras", angle_bytes[0] + row_bytes + row_bytes // 2),
|
||||
"trunc_rowboundary_a1.sras": _truncate(
|
||||
"trunc_rowboundary_a1.sras", angle_bytes[0] + 2 * row_bytes),
|
||||
"trunc_angleboundary.sras": _truncate(
|
||||
"trunc_angleboundary.sras", angle_bytes[0]),
|
||||
"trunc_midrow_a0.sras": _truncate(
|
||||
"trunc_midrow_a0.sras", row_bytes + row_bytes // 2),
|
||||
"header_only.sras": _truncate("header_only.sras", 0),
|
||||
}
|
||||
|
||||
expected = {"header": info, "statuses": {}}
|
||||
for name, path in variants.items():
|
||||
expected["statuses"][name] = app._compute_angle_status(path, info)
|
||||
|
||||
with open(GOLDEN / "sras_expected.json", "w") as f_json:
|
||||
json.dump(expected, f_json, indent=2)
|
||||
print(f"sras fixtures: {len(variants)} files, "
|
||||
f"data block {sum(angle_bytes)} bytes, row_bytes={row_bytes}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
GOLDEN.mkdir(exist_ok=True)
|
||||
build_geometry_fixtures()
|
||||
build_sras_fixtures()
|
||||
print("done")
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Shared constants for the golden .sras 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.
|
||||
"""
|
||||
SPF = 8
|
||||
SAMPLE_RATE = 6.25e9
|
||||
CHANNELS = [1, 3, 4]
|
||||
PREAMBLES = [f"WFMOUTPRE:CH{ch};SYNTHETIC;PT_FMT Y;XINCR 1.6E-10" for ch in CHANNELS]
|
||||
BACKGROUND = bytes(range(SPF))
|
||||
|
||||
# build_plan inputs for the fixture geometry: 2 angles × 3 rows × 4 frames
|
||||
TINY_PLAN_ARGS = dict(x_start=1.0, y_start=1.0, x_delta=0.02, y_delta=0.02,
|
||||
num_angles=2, row_spacing=0.01)
|
||||
LASER_FREQ_HZ = 20000.0
|
||||
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))
|
||||
@@ -0,0 +1,50 @@
|
||||
"""core.config: round-trip, tolerance, and the helios_port regression.
|
||||
|
||||
The old dict-based writer rebuilt the JSON from only the main window's
|
||||
fields, silently discarding helios_port every time a port was edited.
|
||||
ScanDefaults.save() always writes every field.
|
||||
"""
|
||||
import json
|
||||
|
||||
from core.config import ScanDefaults
|
||||
|
||||
|
||||
def test_roundtrip(tmp_path):
|
||||
p = tmp_path / "defaults.json"
|
||||
d = ScanDefaults(t3r_port="/dev/ttyACM3", helios_port="/dev/ttyUSB9")
|
||||
d.save(p)
|
||||
loaded = ScanDefaults.load(p)
|
||||
assert loaded == d
|
||||
|
||||
|
||||
def test_missing_file_creates_defaults(tmp_path):
|
||||
p = tmp_path / "defaults.json"
|
||||
d = ScanDefaults.load(p)
|
||||
assert d == ScanDefaults()
|
||||
assert p.exists()
|
||||
|
||||
|
||||
def test_corrupt_file_falls_back(tmp_path):
|
||||
p = tmp_path / "defaults.json"
|
||||
p.write_text("{not json")
|
||||
assert ScanDefaults.load(p) == ScanDefaults()
|
||||
|
||||
|
||||
def test_unknown_keys_ignored(tmp_path):
|
||||
p = tmp_path / "defaults.json"
|
||||
p.write_text(json.dumps({"t3r_port": "/dev/ttyACM7", "laser_freq_hz": 20000.0}))
|
||||
d = ScanDefaults.load(p)
|
||||
assert d.t3r_port == "/dev/ttyACM7"
|
||||
assert d.helios_port == ScanDefaults().helios_port
|
||||
|
||||
|
||||
def test_helios_port_survives_partial_update(tmp_path):
|
||||
"""Regression: editing main-window ports must not clobber helios_port."""
|
||||
p = tmp_path / "defaults.json"
|
||||
ScanDefaults(helios_port="/dev/ttyUSB7").save(p)
|
||||
|
||||
d = ScanDefaults.load(p)
|
||||
d.t3r_port = "/dev/ttyACM1" # what _persist_defaults does
|
||||
d.save(p)
|
||||
|
||||
assert ScanDefaults.load(p).helios_port == "/dev/ttyUSB7"
|
||||
@@ -1,81 +0,0 @@
|
||||
"""Golden-fixture consistency tests.
|
||||
|
||||
Phase 0: assert the pre-refactor implementations reproduce the committed
|
||||
fixtures. Phase 2 migrates these assertions onto core.sras_format /
|
||||
core.scan_geometry; the fixtures themselves never change.
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import sc3_aui_app as app
|
||||
from gen_goldens import (
|
||||
BACKGROUND, PREAMBLES, SAMPLE_RATE, SPF, _fake_window, _synthetic_frame,
|
||||
)
|
||||
|
||||
GOLDEN = Path(__file__).parent / "golden"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def geometry():
|
||||
with open(GOLDEN / "geometry.json") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def sras_expected():
|
||||
with open(GOLDEN / "sras_expected.json") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def test_geometry_cases_match(geometry):
|
||||
for label, case in geometry["cases"].items():
|
||||
inputs = case["inputs"]
|
||||
params = app.MainWindow._build_scan_params(_fake_window(*inputs.values()))
|
||||
assert params == case["params"], f"geometry mismatch for case {label}"
|
||||
|
||||
|
||||
def test_geometry_error_cases_raise(geometry):
|
||||
for label, case in geometry["error_cases"].items():
|
||||
with pytest.raises(ValueError):
|
||||
app.MainWindow._build_scan_params(_fake_window(*case["inputs"].values()))
|
||||
|
||||
|
||||
def test_writer_output_byte_identical(tmp_path, sras_expected):
|
||||
params = app.MainWindow._build_scan_params(
|
||||
_fake_window("1.0", "1.0", "0.02", "0.02", "2", "0.01"))
|
||||
per_angle = params["per_angle"]
|
||||
angles = [pa["angle"] for pa in per_angle]
|
||||
|
||||
out = tmp_path / "rewrite.sras"
|
||||
f = app._open_scan_file(
|
||||
out, angles, per_angle,
|
||||
params["x_start_nominal"], params["y_start_nominal"],
|
||||
params["x_delta_nominal"], params["y_delta_nominal"],
|
||||
params["row_spacing"],
|
||||
app.SCAN_VELOCITY_MM_S, app.LASER_FREQ_HZ,
|
||||
SPF, SAMPLE_RATE, PREAMBLES, BACKGROUND,
|
||||
)
|
||||
try:
|
||||
for ai, pa in enumerate(per_angle):
|
||||
for ri in range(pa["n_rows"]):
|
||||
for ci in range(len(app.SCAN_CHANNELS)):
|
||||
for fi in range(pa["n_frames"]):
|
||||
f.write(_synthetic_frame(ai, ri, ci, fi))
|
||||
finally:
|
||||
f.close()
|
||||
|
||||
assert out.read_bytes() == (GOLDEN / "complete.sras").read_bytes()
|
||||
|
||||
|
||||
def test_header_parse_matches(sras_expected):
|
||||
info = app._read_sras_header(GOLDEN / "complete.sras")
|
||||
assert info == sras_expected["header"]
|
||||
|
||||
|
||||
def test_angle_status_all_variants(sras_expected):
|
||||
info = app._read_sras_header(GOLDEN / "complete.sras")
|
||||
for name, expected in sras_expected["statuses"].items():
|
||||
statuses = app._compute_angle_status(GOLDEN / name, info)
|
||||
assert statuses == expected, f"status mismatch for {name}"
|
||||
@@ -0,0 +1,151 @@
|
||||
"""core.scan_geometry vs the pre-refactor golden geometry fixtures,
|
||||
plus structural invariants and travel-limit validation."""
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from core.scan_geometry import (
|
||||
EtaEstimator, ScanGeometryError, StageLimits, build_plan, format_eta,
|
||||
validate_plan,
|
||||
)
|
||||
|
||||
GOLDEN = Path(__file__).parent / "golden"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def geometry():
|
||||
with open(GOLDEN / "geometry.json") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _plan_from_case(case, consts):
|
||||
i = case["inputs"]
|
||||
return build_plan(
|
||||
float(i["XS"]), float(i["YS"]), float(i["XD"]), float(i["YD"]),
|
||||
int(i["num_angles"]), float(i["row_spacing"]),
|
||||
laser_freq_hz=consts["LASER_FREQ_HZ"],
|
||||
velocity_mm_s=consts["SCAN_VELOCITY_MM_S"],
|
||||
rotation_sign=consts["GR_ROTATION_SIGN"],
|
||||
)
|
||||
|
||||
|
||||
def test_all_golden_cases_match(geometry):
|
||||
consts = geometry["constants"]
|
||||
for label, case in geometry["cases"].items():
|
||||
plan = _plan_from_case(case, consts)
|
||||
exp = case["params"]
|
||||
assert plan.x_start_nominal == exp["x_start_nominal"], label
|
||||
assert plan.y_start_nominal == exp["y_start_nominal"], label
|
||||
assert plan.x_delta_nominal == exp["x_delta_nominal"], label
|
||||
assert plan.y_delta_nominal == exp["y_delta_nominal"], label
|
||||
assert plan.row_spacing == exp["row_spacing"], label
|
||||
assert plan.n_angles == exp["num_angles"], label
|
||||
assert len(plan.per_angle) == len(exp["per_angle"]), label
|
||||
for pa, e in zip(plan.per_angle, exp["per_angle"], strict=True):
|
||||
assert pa.angle_deg == e["angle"], label
|
||||
assert pa.x_start == e["x_start"], label
|
||||
assert pa.x_delta == e["x_delta"], label
|
||||
assert pa.n_frames == e["n_frames"], label
|
||||
assert pa.n_rows == e["n_rows"], label
|
||||
assert pa.y_positions == e["y_positions"], label
|
||||
|
||||
|
||||
def test_golden_error_cases_raise(geometry):
|
||||
consts = geometry["constants"]
|
||||
for case in geometry["error_cases"].values():
|
||||
with pytest.raises(ValueError):
|
||||
_plan_from_case(case, consts)
|
||||
|
||||
|
||||
def test_zero_degree_bbox_equals_nominal_roi():
|
||||
plan = build_plan(10.0, 5.0, 20.0, 8.0, 1, 0.5,
|
||||
laser_freq_hz=20000.0, velocity_mm_s=100.0)
|
||||
pa = plan.per_angle[0]
|
||||
assert pa.angle_deg == 0.0
|
||||
assert math.isclose(pa.x_start, 10.0)
|
||||
assert math.isclose(pa.x_delta, 20.0)
|
||||
assert math.isclose(pa.y_positions[0], 5.0)
|
||||
|
||||
|
||||
def test_rotated_bbox_contains_all_roi_corners():
|
||||
plan = build_plan(30.0, 20.0, 24.0, 10.0, 7, 0.1,
|
||||
laser_freq_hz=20000.0, velocity_mm_s=100.0)
|
||||
cy = 20.0 + 5.0
|
||||
corners = [(-12.0, -5.0), (12.0, -5.0), (-12.0, 5.0), (12.0, 5.0)]
|
||||
for pa in plan.per_angle:
|
||||
r = math.radians(pa.angle_deg)
|
||||
half_w = pa.x_delta / 2.0
|
||||
y_lo, y_hi = min(pa.y_positions), max(pa.y_positions)
|
||||
for dx, dy in corners:
|
||||
# ROI corner in the rotated frame
|
||||
rx = dx * math.cos(r) - dy * math.sin(r)
|
||||
ry = dx * math.sin(r) + dy * math.cos(r)
|
||||
assert abs(rx) <= half_w + 1e-9, pa.angle_deg
|
||||
# Row grid covers within one row-spacing at the edges
|
||||
assert y_lo - 0.1 - 1e-9 <= cy + ry <= y_hi + 0.1 + 1e-9, pa.angle_deg
|
||||
|
||||
|
||||
def test_plus_minus_theta_symmetry():
|
||||
a = build_plan(10, 10, 20, 10, 5, 0.25, laser_freq_hz=20000.0,
|
||||
velocity_mm_s=100.0, rotation_sign=1)
|
||||
b = build_plan(10, 10, 20, 10, 5, 0.25, laser_freq_hz=20000.0,
|
||||
velocity_mm_s=100.0, rotation_sign=-1)
|
||||
for pa, pb in zip(a.per_angle, b.per_angle, strict=True):
|
||||
assert pa.angle_deg == -pb.angle_deg
|
||||
assert math.isclose(pa.x_delta, pb.x_delta)
|
||||
assert pa.n_rows == pb.n_rows
|
||||
assert pa.n_frames == pb.n_frames
|
||||
|
||||
|
||||
def test_validate_plan_limit_violations():
|
||||
limits = StageLimits()
|
||||
ramp, buf = 100.0**2 / (2 * 1500.0), 1.0
|
||||
|
||||
ok = build_plan(20.0, 10.0, 40.0, 30.0, 3, 0.25,
|
||||
laser_freq_hz=20000.0, velocity_mm_s=100.0)
|
||||
validate_plan(ok, ramp, buf, limits) # must not raise
|
||||
|
||||
too_left = build_plan(2.0, 10.0, 40.0, 30.0, 1, 0.25,
|
||||
laser_freq_hz=20000.0, velocity_mm_s=100.0)
|
||||
with pytest.raises(ScanGeometryError, match="pre-ramp start"):
|
||||
validate_plan(too_left, ramp, buf, limits)
|
||||
|
||||
too_right = build_plan(80.0, 10.0, 40.0, 30.0, 1, 0.25,
|
||||
laser_freq_hz=20000.0, velocity_mm_s=100.0)
|
||||
with pytest.raises(ScanGeometryError, match="run-off end"):
|
||||
validate_plan(too_right, ramp, buf, limits)
|
||||
|
||||
too_low = build_plan(30.0, -5.0, 40.0, 30.0, 1, 0.25,
|
||||
laser_freq_hz=20000.0, velocity_mm_s=100.0)
|
||||
with pytest.raises(ScanGeometryError, match="Y axis minimum"):
|
||||
validate_plan(too_low, ramp, buf, limits)
|
||||
|
||||
too_high = build_plan(30.0, 60.0, 40.0, 30.0, 1, 0.25,
|
||||
laser_freq_hz=20000.0, velocity_mm_s=100.0)
|
||||
with pytest.raises(ScanGeometryError, match="Y axis maximum"):
|
||||
validate_plan(too_high, ramp, buf, limits)
|
||||
|
||||
|
||||
def test_format_eta():
|
||||
assert format_eta(-3) == "0s"
|
||||
assert format_eta(42) == "42s"
|
||||
assert format_eta(90) == "1m 30s"
|
||||
assert format_eta(3720) == "1h 02m"
|
||||
|
||||
|
||||
def test_eta_estimator_rolls_and_resets_on_angle_change():
|
||||
eta = EtaEstimator(window=3)
|
||||
assert eta.eta_secs(5) is None
|
||||
t = 100.0
|
||||
for dur in (2.0, 4.0, 6.0, 8.0):
|
||||
eta.row_started(now=t)
|
||||
eta.row_finished(angle_idx=1, now=t + dur)
|
||||
t += dur
|
||||
# window=3 keeps [4, 6, 8] → avg 6
|
||||
assert eta.eta_secs(2) == pytest.approx(12.0)
|
||||
# angle change wipes history
|
||||
eta.row_started(now=t)
|
||||
eta.row_finished(angle_idx=2, now=t + 10.0)
|
||||
assert eta.eta_secs(3) == pytest.approx(30.0)
|
||||
@@ -0,0 +1,137 @@
|
||||
"""core.sras_format vs 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).
|
||||
"""
|
||||
import json
|
||||
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 golden_util import (
|
||||
BACKGROUND, CHANNELS, LASER_FREQ_HZ, PREAMBLES, SAMPLE_RATE, SPF,
|
||||
TINY_PLAN_ARGS, VELOCITY_MM_S, synthetic_frame,
|
||||
)
|
||||
|
||||
GOLDEN = Path(__file__).parent / "golden"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def expected():
|
||||
with open(GOLDEN / "sras_expected.json") as f:
|
||||
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 _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_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_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):
|
||||
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}"
|
||||
|
||||
|
||||
def test_preambles_and_background_roundtrip():
|
||||
sras = SrasFile(GOLDEN / "complete.sras")
|
||||
assert sras.preambles == PREAMBLES
|
||||
assert sras.background == BACKGROUND
|
||||
|
||||
|
||||
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)
|
||||
Reference in New Issue
Block a user