Files
ykcheungab 22658dbfc1 autocal: 新增单位与换算页(默认锁定)及轴页报警与事件日志
- 设备设置新增“单位与换算”页:显示各轴 UNITS、读回值、脉冲/mm 换算与核对状态;
  默认锁定,点击“修改”后才可编辑,应用前弹窗确认,旋转轴偏离标定值时额外警告
- 轴调试页:脉冲当量改为只读并提供入口;新增报警与事件日志(报警、使能、
  回零、读取失败、单位不符等)及单位核对摘要
- 新增 motion/unit_report.py、motion/events.py 及对应测试
2026-10-19 10:14:55 +08:00

111 lines
4.6 KiB
Python

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