"""The unit table: what is configured, what the controller reads, and the verdict.""" from __future__ import annotations import math import pytest from orisys_autocal.motion import AxisState, Snapshot from orisys_autocal.motion.models import AxisStatusBits from orisys_autocal.motion.service import DEFAULT_AXIS_ROLES from orisys_autocal.motion.unit_report import ( INVALID, MISMATCH, OK, UNCALIBRATED, UNMAPPED, UNREAD, build_unit_rows, summarise, ) ROLES = dict(DEFAULT_AXIS_ROLES) # x0 y1 yaw2 pitch3 z4 def _snapshot(units: dict[int, float], *, connected: bool = True) -> Snapshot: axes = tuple( AxisState(axis=axis, units=value, status=AxisStatusBits(0), read_ok=True) for axis, value in units.items() ) return Snapshot(connected=connected, axes=axes) def _rows(expected: float | dict[int, float], observed: dict[int, float] | None, **kw): def expected_of(axis: int) -> float: return expected[axis] if isinstance(expected, dict) else expected snapshot = None if observed is None else _snapshot(observed, **kw) return {row.role: row for row in build_unit_rows(ROLES, expected_of, snapshot)} def test_a_matching_platform_is_all_ok_with_the_bench_scales(): rows = _rows(10.0, {axis: 10.0 for axis in range(5)}) assert [row.status for row in rows.values()] == [OK] * 5 # Linear: 5000 pulses per millimetre and a pulse equivalent of 10. assert rows["x"].per_unit == pytest.approx(10.0 / 5000.0) assert rows["x"].per_mm == pytest.approx(500.0) assert rows["z"].kind == "linear" # Rotary: 12500 and 29500 user units per quarter turn. assert rows["yaw"].axis == 2 assert rows["yaw"].per_mm == pytest.approx(12500.0 / 90.0) assert rows["pitch"].per_mm == pytest.approx(29500.0 / 90.0) assert rows["yaw"].per_unit == pytest.approx(90.0 / 12500.0) assert [row.role for row in rows.values()] == ["x", "y", "z", "yaw", "pitch"] def test_no_connection_or_no_readback_is_unread_not_fine(): assert {row.status for row in _rows(10.0, None).values()} == {UNREAD} offline = _rows(10.0, {axis: 10.0 for axis in range(5)}, connected=False) assert {row.status for row in offline.values()} == {UNREAD} assert all(row.observed is None for row in offline.values()) def test_a_controller_that_disagrees_is_a_mismatch_for_that_axis_only(): observed = {axis: 10.0 for axis in range(5)} observed[1] = 5.0 rows = _rows(10.0, observed) assert rows["y"].status == MISMATCH assert rows["y"].observed == 5.0 assert [row.status for role, row in rows.items() if role != "y"] == [OK] * 4 good, total, problems = summarise(list(rows.values())) assert (good, total, [row.role for row in problems]) == (4, 5, ["y"]) def test_rotary_axes_are_not_calibrated_away_from_the_bench_equivalent(): # Same on both sides, so the controller agrees, but there is no angle scale. rows = _rows({0: 10.0, 1: 10.0, 2: 5.0, 3: 10.0, 4: 10.0}, {0: 10.0, 1: 10.0, 2: 5.0, 3: 10.0, 4: 10.0}) assert rows["yaw"].status == UNCALIBRATED assert math.isnan(rows["yaw"].per_unit) and math.isnan(rows["yaw"].per_mm) # A linear axis rescales freely: the scale follows the equivalent. linear = _rows({0: 5.0, 1: 10.0, 2: 10.0, 3: 10.0, 4: 10.0}, {0: 5.0, 1: 10.0, 2: 10.0, 3: 10.0, 4: 10.0}) assert linear["x"].status == OK assert linear["x"].per_mm == pytest.approx(1000.0) @pytest.mark.parametrize("bad", [0.0, -1.0, math.nan, math.inf]) def test_an_unusable_configured_value_is_invalid(bad): rows = _rows(bad, {axis: 10.0 for axis in range(5)}) # inf is not finite either; every row is refused, none shows a made-up scale. assert {row.status for row in rows.values()} == {INVALID} assert all(math.isnan(row.per_unit) for row in rows.values()) def test_a_role_without_a_drive_is_unmapped(): roles = {key: value for key, value in ROLES.items() if key != "z"} rows = {r.role: r for r in build_unit_rows(roles, lambda axis: 10.0, _snapshot({0: 10.0}))} assert rows["z"].status == UNMAPPED assert rows["z"].axis == -1 def test_the_verdict_agrees_with_the_motion_guard(): """The page must never say fine where :meth:`MotionService.units_problem` refuses.""" from orisys_autocal.motion.service import MotionService service = MotionService() observed = {axis: 10.0 for axis in range(5)} snapshot = _snapshot(observed) service._last_axes = {state.axis: state for state in snapshot.axes} # noqa: SLF001 rows = {r.role: r for r in build_unit_rows(service.axis_roles, lambda a: service.params(a).units, snapshot)} for role, row in rows.items(): assert (service.units_problem(role, row.axis) == "") == row.ok, role