"""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)