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