autocal: 新增单位与换算页(默认锁定)及轴页报警与事件日志

- 设备设置新增“单位与换算”页:显示各轴 UNITS、读回值、脉冲/mm 换算与核对状态;
  默认锁定,点击“修改”后才可编辑,应用前弹窗确认,旋转轴偏离标定值时额外警告
- 轴调试页:脉冲当量改为只读并提供入口;新增报警与事件日志(报警、使能、
  回零、读取失败、单位不符等)及单位核对摘要
- 新增 motion/unit_report.py、motion/events.py 及对应测试
This commit is contained in:
ykcheungab
2026-10-19 10:14:55 +08:00
parent c6c16df753
commit 22658dbfc1
12 changed files with 1462 additions and 10 deletions
+95
View File
@@ -0,0 +1,95 @@
"""The axis event log: only changes, a baseline on first sight, nothing across a gap."""
from __future__ import annotations
from orisys_autocal.motion import AxisState, Snapshot
from orisys_autocal.motion import events as ev
from orisys_autocal.motion.models import AxisStatusBits
from orisys_autocal.motion.events import AxisEventTracker
def _axis(axis=0, *, alarm=False, enabled=True, read_ok=True, homing=False, home_status=0, units=10.0, error=""):
raw = (512 if alarm else 0) | (64 if homing else 0)
return AxisState(
axis=axis,
enabled=enabled,
units=units,
status=AxisStatusBits(raw),
home_status=home_status,
read_ok=read_ok,
error=error,
)
def _snap(*axes, connected=True) -> Snapshot:
return Snapshot(connected=connected, axes=tuple(axes))
def _kinds(events):
return [(event.axis, event.kind) for event in events]
def test_the_first_poll_is_a_baseline_and_a_steady_state_is_silent():
tracker = AxisEventTracker(clock=lambda: 1.0)
assert tracker.update(_snap(_axis(0), _axis(1))) == []
assert tracker.update(_snap(_axis(0), _axis(1))) == []
assert tracker.events == []
def test_an_alarm_that_is_already_up_when_first_seen_is_reported():
tracker = AxisEventTracker()
assert _kinds(tracker.update(_snap(_axis(2, alarm=True)))) == [(2, ev.ALARM_ON)]
def test_alarm_enable_and_homing_edges_are_reported_once():
tracker = AxisEventTracker()
tracker.update(_snap(_axis(0, enabled=False)))
assert _kinds(tracker.update(_snap(_axis(0, enabled=True)))) == [(0, ev.ENABLED)]
assert _kinds(tracker.update(_snap(_axis(0, alarm=True)))) == [(0, ev.ALARM_ON)]
assert tracker.update(_snap(_axis(0, alarm=True))) == []
assert _kinds(tracker.update(_snap(_axis(0)))) == [(0, ev.ALARM_OFF)]
assert _kinds(tracker.update(_snap(_axis(0, homing=True)))) == [(0, ev.HOME_STARTED)]
assert _kinds(tracker.update(_snap(_axis(0, home_status=1)))) == [(0, ev.HOME_DONE)]
tracker.update(_snap(_axis(0, homing=True)))
assert _kinds(tracker.update(_snap(_axis(0, home_status=0)))) == [(0, ev.HOME_INCOMPLETE)]
def test_a_read_failure_and_its_recovery_are_reported():
tracker = AxisEventTracker()
tracker.update(_snap(_axis(1)))
failed = tracker.update(_snap(_axis(1, read_ok=False, error="timeout")))
assert [(e.axis, e.kind, e.detail) for e in failed] == [(1, ev.READ_FAILED, "timeout")]
assert tracker.update(_snap(_axis(1, read_ok=False, error="timeout"))) == []
assert _kinds(tracker.update(_snap(_axis(1)))) == [(1, ev.READ_OK)]
def test_a_gap_in_the_connection_resets_the_baseline():
tracker = AxisEventTracker()
tracker.update(_snap(_axis(0)))
assert _kinds(tracker.update(_snap(connected=False))) == [(ev.NO_AXIS, ev.DISCONNECTED)]
# Back again with the axis alarmed: the connection edge, then the alarm as a
# first sight; nothing is compared with a state from before the gap.
back = tracker.update(_snap(_axis(0, alarm=True)))
assert _kinds(back) == [(ev.NO_AXIS, ev.CONNECTED), (0, ev.ALARM_ON)]
def test_units_disagreeing_with_the_configuration_is_an_event_both_ways():
tracker = AxisEventTracker()
expected = {0: 10.0}
assert tracker.update(_snap(_axis(0, units=10.0)), expected) == []
mismatch = tracker.update(_snap(_axis(0, units=5.0)), expected)
assert [(e.kind, e.detail) for e in mismatch] == [(ev.UNITS_MISMATCH, "10|5")]
assert tracker.update(_snap(_axis(0, units=5.0)), expected) == []
assert _kinds(tracker.update(_snap(_axis(0, units=10.0)), expected)) == [(0, ev.UNITS_OK)]
# With nothing to compare against no verdict is invented.
assert AxisEventTracker().update(_snap(_axis(0, units=5.0))) == []
def test_the_log_is_bounded_and_can_be_cleared():
tracker = AxisEventTracker(limit=3)
tracker.update(_snap(_axis(0)))
for index in range(10):
tracker.update(_snap(_axis(0, alarm=bool(index % 2 == 0))))
assert len(tracker.events) == 3
tracker.clear()
assert tracker.events == []
+1 -1
View File
@@ -180,7 +180,7 @@ def test_the_axis_mapping_page_holds_the_mapping_and_refuses_duplicates(make_win
window = make_window()
try:
dialog = window.device_setup
assert dialog.tabs.count() == 4
assert dialog.tabs.count() == 5
page = dialog.roles_page
assert page.roles() == DEFAULT_AXIS_ROLES
assert page.problem() == ""
+110
View File
@@ -0,0 +1,110 @@
"""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
+213
View File
@@ -0,0 +1,213 @@
"""The units page (locked by default) and the axis page's alarm and event log.
Interface tests: they drive the widgets an operator uses. The controller is never
reached; snapshots are built by hand, and ``QMessageBox.question`` is answered by the
test, so nothing here opens a dialog.
"""
from __future__ import annotations
import pytest
from orisys_app_shared.i18n import t
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 build_unit_rows
from PySide6.QtWidgets import QMessageBox
ROLES = dict(DEFAULT_AXIS_ROLES)
def _snapshot(*, connected: bool = True, alarm_axis: int | None = None, units: float = 10.0):
axes = tuple(
AxisState(
axis=axis,
enabled=True,
units=units,
status=AxisStatusBits(512 if axis == alarm_axis else 0),
read_ok=True,
)
for axis in range(5)
)
return Snapshot(connected=connected, axes=axes)
def _rows(connected: bool = True, expected: float = 10.0):
return build_unit_rows(ROLES, lambda axis: expected, _snapshot(connected=connected))
def _answer(monkeypatch, button) -> list[str]:
"""Answer the confirmation with ``button`` and record what it asked."""
asked: list[str] = []
def question(parent, title, body, *args, **kwargs):
asked.append(body)
return button
monkeypatch.setattr(QMessageBox, "question", question)
return asked
def test_the_page_is_locked_until_the_operator_presses_modify(make_window):
window = make_window()
page = window.device_setup.units_page
page.set_rows(_rows(connected=False), False)
assert page.locked
assert all(edit.isReadOnly() for edit in page._edits.values()) # noqa: SLF001
# Nothing to change on a machine that is not connected.
assert not page.modify_button.isEnabled()
page.set_rows(_rows(), True)
assert page.modify_button.isEnabled()
page.modify_button.click()
assert not page.locked
assert not any(edit.isReadOnly() for edit in page._edits.values()) # noqa: SLF001
assert page.apply_button.isEnabled()
window.close()
def test_a_confirmed_change_is_emitted_and_the_page_locks_again(make_window, monkeypatch):
window = make_window()
page = window.device_setup.units_page
page.set_rows(_rows(), True)
emitted: list[dict] = []
page.unitsApplied.connect(emitted.append)
asked = _answer(monkeypatch, QMessageBox.StandardButton.Yes)
page.modify_button.click()
page._edits["x"].setText("5") # noqa: SLF001
page.apply_button.click()
assert emitted == [{0: 5.0}]
assert page.locked
assert "5" in asked[0] and "10" in asked[0]
window.close()
def test_declining_the_confirmation_changes_nothing(make_window, monkeypatch):
window = make_window()
page = window.device_setup.units_page
page.set_rows(_rows(), True)
emitted: list[dict] = []
page.unitsApplied.connect(emitted.append)
_answer(monkeypatch, QMessageBox.StandardButton.No)
page.modify_button.click()
page._edits["y"].setText("20") # noqa: SLF001
page.apply_button.click()
assert emitted == []
assert not page.locked # still the operator's to finish or cancel
page.cancel_button.click()
assert page.locked
assert page._edits["y"].text() == "10" # noqa: SLF001
window.close()
def test_a_rotary_value_off_the_calibration_is_warned_about(make_window, monkeypatch):
window = make_window()
page = window.device_setup.units_page
page.set_rows(_rows(), True)
asked = _answer(monkeypatch, QMessageBox.StandardButton.No)
page.modify_button.click()
page._edits["yaw"].setText("5") # noqa: SLF001
page.apply_button.click()
assert t("units.confirm_rotary", roles=t("setup.role_yaw"), cal="10") in asked[0]
window.close()
@pytest.mark.parametrize("bad", ["", "abc", "0", "-3", "nan", "inf"])
def test_an_unusable_value_is_refused_before_any_question(make_window, monkeypatch, bad):
window = make_window()
page = window.device_setup.units_page
page.set_rows(_rows(), True)
emitted: list[dict] = []
page.unitsApplied.connect(emitted.append)
asked = _answer(monkeypatch, QMessageBox.StandardButton.Yes)
page.modify_button.click()
page._edits["z"].setText(bad) # noqa: SLF001
page.apply_button.click()
assert emitted == [] and asked == []
assert t("units.invalid", role=t("setup.role_z")) == page.message_label.text()
window.close()
def test_applying_without_a_change_just_locks(make_window, monkeypatch):
window = make_window()
page = window.device_setup.units_page
page.set_rows(_rows(), True)
emitted: list[dict] = []
page.unitsApplied.connect(emitted.append)
asked = _answer(monkeypatch, QMessageBox.StandardButton.Yes)
page.modify_button.click()
page.apply_button.click()
assert emitted == [] and asked == []
assert page.locked
window.close()
def test_a_changed_axis_mapping_drops_an_open_edit(make_window):
window = make_window()
page = window.device_setup.units_page
page.set_rows(_rows(), True)
page.modify_button.click()
page._edits["x"].setText("5") # noqa: SLF001
swapped = dict(ROLES, x=1, y=0)
page.set_rows(build_unit_rows(swapped, lambda axis: 10.0, _snapshot()), True)
assert page.locked
assert page._edits["x"].text() == "10" # noqa: SLF001
window.close()
def test_the_units_tab_and_the_axis_page_are_wired(make_window, monkeypatch):
window = make_window()
monkeypatch.setattr(window.poller, "submit", lambda *args, **kwargs: 0)
panel = window.axis_panel
# The always-live form no longer takes a pulse equivalent.
assert panel.units_edit.isReadOnly()
# The button on the axis page opens the units tab.
panel.unitsRequested.emit()
assert window.device_setup.current_tab() == "units"
# A confirmed change goes out as an ordinary axis-parameter apply.
applied: list[tuple] = []
panel.paramsApplied.connect(lambda axis, params: applied.append((axis, params.units)))
window.device_setup.units_page.unitsApplied.emit({1: 5.0})
assert applied == [(1, 5.0)]
assert panel.expected_units(1) == 5.0
window.close()
def test_the_axis_page_logs_alarms_and_can_clear_the_log(make_window):
window = make_window()
panel = window.axis_panel
panel.update_snapshot(_snapshot())
assert panel.event_log.count() == 0
assert not panel.events_empty.isHidden()
panel.update_snapshot(_snapshot(alarm_axis=2))
assert panel.event_log.count() == 1
assert t("axes.ev_alarm_on", axis=2) in panel.event_log.item(0).text()
assert panel.events_empty.isHidden()
panel.clear_events_button.click()
assert panel.event_log.count() == 0
window.close()
def test_a_unit_mismatch_reaches_the_axis_page_summary(make_window):
window = make_window()
window.axis_panel.update_snapshot(_snapshot(units=5.0))
window._refresh_unit_report() # noqa: SLF001 - the window does this on every poll
assert t("units.summary_bad", bad=5, total=5) == window.axis_panel.units_chip.text()
window.axis_panel.update_snapshot(_snapshot(units=10.0))
window._refresh_unit_report() # noqa: SLF001
assert t("units.summary_ok", total=5) == window.axis_panel.units_chip.text()
window.close()