dff9f69d78
- 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>
152 lines
5.7 KiB
Python
152 lines
5.7 KiB
Python
"""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)
|