autocal: 新增单位与换算页(默认锁定)及轴页报警与事件日志
- 设备设置新增“单位与换算”页:显示各轴 UNITS、读回值、脉冲/mm 换算与核对状态; 默认锁定,点击“修改”后才可编辑,应用前弹窗确认,旋转轴偏离标定值时额外警告 - 轴调试页:脉冲当量改为只读并提供入口;新增报警与事件日志(报警、使能、 回零、读取失败、单位不符等)及单位核对摘要 - 新增 motion/unit_report.py、motion/events.py 及对应测试
This commit is contained in:
@@ -51,7 +51,8 @@
|
|||||||
"press_set": "Calibrated: press target = zero ± contact height.",
|
"press_set": "Calibrated: press target = zero ± contact height.",
|
||||||
"press_unset": "Not calibrated: Execute holds the press axis.",
|
"press_unset": "Not calibrated: Execute holds the press axis.",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
"chip_hint": "Open device setup — {name}"
|
"chip_hint": "Open device setup — {name}",
|
||||||
|
"tab_units": "Units"
|
||||||
},
|
},
|
||||||
"move": {
|
"move": {
|
||||||
"execute": "Execute",
|
"execute": "Execute",
|
||||||
@@ -164,7 +165,25 @@
|
|||||||
"home": "Home",
|
"home": "Home",
|
||||||
"controls": "Actions",
|
"controls": "Actions",
|
||||||
"mm_derived_tip": "Millimetres are derived from the pulse equivalent: a display value, not a calibrated length.",
|
"mm_derived_tip": "Millimetres are derived from the pulse equivalent: a display value, not a calibrated length.",
|
||||||
"enabled_count": "{on}/{total} axes enabled"
|
"enabled_count": "{on}/{total} axes enabled",
|
||||||
|
"units_open": "Units…",
|
||||||
|
"units_locked_tip": "Units are locked and read-only here. Use Device setup → Units and press Modify to change them.",
|
||||||
|
"events_title": "Alarms and events",
|
||||||
|
"events_clear": "Clear log",
|
||||||
|
"events_empty": "No events yet. Changes in alarms, enable, homing, read failures and unit mismatches are logged here.",
|
||||||
|
"ev_connected": "Controller connected",
|
||||||
|
"ev_disconnected": "Controller disconnected",
|
||||||
|
"ev_alarm_on": "Axis {axis} alarm",
|
||||||
|
"ev_alarm_off": "Axis {axis} alarm cleared",
|
||||||
|
"ev_enabled": "Axis {axis} enabled",
|
||||||
|
"ev_disabled": "Axis {axis} disabled",
|
||||||
|
"ev_read_failed": "Axis {axis} read failed: {detail}",
|
||||||
|
"ev_read_ok": "Axis {axis} readable again",
|
||||||
|
"ev_home_started": "Axis {axis} homing started",
|
||||||
|
"ev_home_done": "Axis {axis} homing complete",
|
||||||
|
"ev_home_incomplete": "Axis {axis} homing ended without reporting complete",
|
||||||
|
"ev_units_mismatch": "Axis {axis} UNITS mismatch: configured {expected}, read {observed}",
|
||||||
|
"ev_units_ok": "Axis {axis} UNITS agree again"
|
||||||
},
|
},
|
||||||
"busdata": {
|
"busdata": {
|
||||||
"title": "Bus data exchange",
|
"title": "Bus data exchange",
|
||||||
@@ -911,5 +930,49 @@
|
|||||||
"close": "Close",
|
"close": "Close",
|
||||||
"max_required": "Required; the program does not assume a value"
|
"max_required": "Required; the program does not assume a value"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"units": {
|
||||||
|
"hint": "One place to check each direction's drive, its pulse equivalent (UNITS) and the resulting scale. Locked and read-only by default; press Modify to change a value, and the change is listed for confirmation before anything is written.",
|
||||||
|
"col_role": "Direction",
|
||||||
|
"col_axis": "Drive",
|
||||||
|
"col_kind": "Type",
|
||||||
|
"col_configured": "Configured UNITS",
|
||||||
|
"col_observed": "Controller read-back",
|
||||||
|
"col_scale": "Scale",
|
||||||
|
"col_check": "Check",
|
||||||
|
"kind_linear": "Linear (mm)",
|
||||||
|
"kind_rotary": "Rotary (°)",
|
||||||
|
"scale_linear": "1 unit = {mm} mm; {per} units/mm",
|
||||||
|
"scale_rotary": "1 unit = {deg}°; {per} units/°",
|
||||||
|
"status_ok": "Agree",
|
||||||
|
"status_unread": "Not read",
|
||||||
|
"status_mismatch": "Mismatch",
|
||||||
|
"status_uncalibrated": "No angle scale",
|
||||||
|
"status_invalid": "Invalid",
|
||||||
|
"status_unmapped": "Unmapped",
|
||||||
|
"tip_unread": "Not connected, or this axis's UNITS has not been read back yet.",
|
||||||
|
"tip_mismatch": "The controller reads a different UNITS from the configured one: pose moves are refused and targets are not rescaled.",
|
||||||
|
"tip_uncalibrated": "The rotary angle scale was only measured at the calibration equivalent; any other value needs a new calibration.",
|
||||||
|
"tip_invalid": "UNITS must be a finite number above 0.",
|
||||||
|
"tip_unmapped": "No drive is assigned to this direction; see Axis mapping.",
|
||||||
|
"summary_ok": "Unit check: all {total} directions agree",
|
||||||
|
"summary_bad": "Unit check: {bad} of {total} directions need attention",
|
||||||
|
"summary_unread": "Unit check: not read yet (not connected)",
|
||||||
|
"constants": "Fixed calibration constants (measured on the bench, not editable here): linear axes {ppm} pulses/mm; rotary axes {rotary}; all measured at UNITS={cal}. Millimetres and degrees are displayed values derived from these constants, not new measurements.",
|
||||||
|
"rotary_item": "axis {axis}: {units} user units = 90°",
|
||||||
|
"modify": "Modify",
|
||||||
|
"apply": "Apply to controller",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"modify_offline": "Not connected to a controller; nothing can be modified.",
|
||||||
|
"locked_note": "Locked: read-only.",
|
||||||
|
"unlocked_note": "Unlocked: enter new values in the Configured UNITS column, then press Apply to controller.",
|
||||||
|
"mapping_changed": "The axis mapping changed; unapplied edits were dropped and the page is locked again.",
|
||||||
|
"invalid": "UNITS for {role} is not valid: enter a number above 0.",
|
||||||
|
"no_change": "No change; locked again.",
|
||||||
|
"applied": "Submitted UNITS for {count} drive(s); the read-back will show after the next refresh.",
|
||||||
|
"change_line": "{role} · axis {axis}: {old} → {new}",
|
||||||
|
"confirm_title": "Write UNITS",
|
||||||
|
"confirm_body": "The following UNITS will be written to the controller:\n{changes}\n\nAfterwards every millimetre and degree reading, and every pose-move target, is recomputed with the new equivalent. Confirm that the platform is stopped and the mechanical ratio has been checked.",
|
||||||
|
"confirm_rotary": "Note: UNITS for {roles} is not the calibration equivalent {cal}; the rotary axis has no angle scale at that value, so pose moves are refused until it is recalibrated."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,8 @@
|
|||||||
"press_set": "已标定:压轴目标 = 零点 ± 接触高度。",
|
"press_set": "已标定:压轴目标 = 零点 ± 接触高度。",
|
||||||
"press_unset": "未标定:执行动作会保持压轴不动。",
|
"press_unset": "未标定:执行动作会保持压轴不动。",
|
||||||
"close": "关闭",
|
"close": "关闭",
|
||||||
"chip_hint": "点击打开设备设置 — {name}"
|
"chip_hint": "点击打开设备设置 — {name}",
|
||||||
|
"tab_units": "单位与换算"
|
||||||
},
|
},
|
||||||
"move": {
|
"move": {
|
||||||
"execute": "执行动作",
|
"execute": "执行动作",
|
||||||
@@ -164,7 +165,25 @@
|
|||||||
"home": "启动回零",
|
"home": "启动回零",
|
||||||
"controls": "操作",
|
"controls": "操作",
|
||||||
"mm_derived_tip": "毫米值由脉冲当量换算得出,是显示值,不是标定长度。",
|
"mm_derived_tip": "毫米值由脉冲当量换算得出,是显示值,不是标定长度。",
|
||||||
"enabled_count": "已使能 {on}/{total} 轴"
|
"enabled_count": "已使能 {on}/{total} 轴",
|
||||||
|
"units_open": "单位与换算…",
|
||||||
|
"units_locked_tip": "单位已锁定,这里只读。到“设备设置 → 单位与换算”点“修改”才能更改。",
|
||||||
|
"events_title": "报警与事件",
|
||||||
|
"events_clear": "清空记录",
|
||||||
|
"events_empty": "暂无事件。报警、使能、回零、读取失败和单位不一致发生变化时,会记录在这里。",
|
||||||
|
"ev_connected": "控制器已连接",
|
||||||
|
"ev_disconnected": "控制器已断开",
|
||||||
|
"ev_alarm_on": "轴 {axis} 报警",
|
||||||
|
"ev_alarm_off": "轴 {axis} 报警已解除",
|
||||||
|
"ev_enabled": "轴 {axis} 已使能",
|
||||||
|
"ev_disabled": "轴 {axis} 已关闭使能",
|
||||||
|
"ev_read_failed": "轴 {axis} 读取失败:{detail}",
|
||||||
|
"ev_read_ok": "轴 {axis} 读取恢复",
|
||||||
|
"ev_home_started": "轴 {axis} 开始回零",
|
||||||
|
"ev_home_done": "轴 {axis} 回零完成",
|
||||||
|
"ev_home_incomplete": "轴 {axis} 回零结束,但未报告完成",
|
||||||
|
"ev_units_mismatch": "轴 {axis} UNITS 不一致:配置 {expected},读回 {observed}",
|
||||||
|
"ev_units_ok": "轴 {axis} UNITS 已一致"
|
||||||
},
|
},
|
||||||
"busdata": {
|
"busdata": {
|
||||||
"title": "总线数据交互",
|
"title": "总线数据交互",
|
||||||
@@ -911,5 +930,49 @@
|
|||||||
"close": "关闭",
|
"close": "关闭",
|
||||||
"max_required": "必须填写,程序不预设"
|
"max_required": "必须填写,程序不预设"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"units": {
|
||||||
|
"hint": "集中检查每个方向用的驱动器、脉冲当量(UNITS)和换算。默认锁定为只读;需要改动时点“修改”,写入前会列出改动并要求确认。",
|
||||||
|
"col_role": "方向",
|
||||||
|
"col_axis": "驱动器",
|
||||||
|
"col_kind": "类型",
|
||||||
|
"col_configured": "配置 UNITS",
|
||||||
|
"col_observed": "控制器读回",
|
||||||
|
"col_scale": "换算",
|
||||||
|
"col_check": "检查",
|
||||||
|
"kind_linear": "线性 (mm)",
|
||||||
|
"kind_rotary": "旋转 (°)",
|
||||||
|
"scale_linear": "1 单位 = {mm} mm;{per} 单位/mm",
|
||||||
|
"scale_rotary": "1 单位 = {deg}°;{per} 单位/°",
|
||||||
|
"status_ok": "一致",
|
||||||
|
"status_unread": "未读回",
|
||||||
|
"status_mismatch": "不一致",
|
||||||
|
"status_uncalibrated": "无角度标定",
|
||||||
|
"status_invalid": "配置无效",
|
||||||
|
"status_unmapped": "未映射",
|
||||||
|
"tip_unread": "未连接,或该轴的 UNITS 尚未成功读回。",
|
||||||
|
"tip_mismatch": "控制器读回的 UNITS 与配置不同:姿态移动会被拒绝,不会重新换算目标。",
|
||||||
|
"tip_uncalibrated": "旋转轴的角度比例只在标定当量下测量过;其他值需要重新标定。",
|
||||||
|
"tip_invalid": "UNITS 必须是大于 0 的有限数。",
|
||||||
|
"tip_unmapped": "该方向没有分配驱动器,见“轴映射”。",
|
||||||
|
"summary_ok": "单位检查:{total} 个方向全部一致",
|
||||||
|
"summary_bad": "单位检查:{bad}/{total} 个方向需要处理",
|
||||||
|
"summary_unread": "单位检查:尚未读回(未连接)",
|
||||||
|
"constants": "固定标定常数(来自实机测量,不能在此修改):线性轴 {ppm} 脉冲/mm;旋转轴 {rotary};以上在 UNITS={cal} 下测得。毫米与角度都是由这些常数换算的显示值,不是新的测量。",
|
||||||
|
"rotary_item": "轴 {axis}:{units} 用户单位 = 90°",
|
||||||
|
"modify": "修改",
|
||||||
|
"apply": "应用到控制器",
|
||||||
|
"cancel": "取消",
|
||||||
|
"modify_offline": "未连接控制器,无法修改。",
|
||||||
|
"locked_note": "已锁定:只读显示。",
|
||||||
|
"unlocked_note": "已解锁:在“配置 UNITS”列输入新值,然后点“应用到控制器”。",
|
||||||
|
"mapping_changed": "轴映射已改变,未应用的修改已放弃并重新锁定。",
|
||||||
|
"invalid": "{role} 的 UNITS 无效:需要大于 0 的数字。",
|
||||||
|
"no_change": "没有改动,已重新锁定。",
|
||||||
|
"applied": "已提交 {count} 个驱动器的 UNITS;读回结果将在下一次刷新后显示。",
|
||||||
|
"change_line": "{role} · 轴 {axis}:{old} → {new}",
|
||||||
|
"confirm_title": "写入 UNITS",
|
||||||
|
"confirm_body": "将向控制器写入以下 UNITS:\n{changes}\n\n写入后,全部毫米和角度读数以及姿态移动的目标都按新当量重新计算。请确认平台已停止,并已核对机械传动比。",
|
||||||
|
"confirm_rotary": "注意:{roles} 的 UNITS 不是标定当量 {cal},旋转轴在该当量下没有角度标定,姿态移动会被拒绝,直到重新标定。"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
"""Edges worth remembering from the controller's poll stream (Qt-free).
|
||||||
|
|
||||||
|
The axis table shows *now*. An alarm that came and went between two glances, a
|
||||||
|
drive that stopped answering for a second, or a homing run that ended without
|
||||||
|
completing leaves nothing on screen, and the operator is left with a machine that
|
||||||
|
"did something". The tracker turns the poll stream into a short list of dated
|
||||||
|
events: only changes, never one line per poll.
|
||||||
|
|
||||||
|
Nothing here talks to the controller; it reads :class:`~.models.Snapshot` values.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import time
|
||||||
|
from collections.abc import Callable, Mapping
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from .models import AxisState, Snapshot
|
||||||
|
|
||||||
|
#: Event kinds. The page maps each one to a locale string.
|
||||||
|
CONNECTED = "connected"
|
||||||
|
DISCONNECTED = "disconnected"
|
||||||
|
ALARM_ON = "alarm_on"
|
||||||
|
ALARM_OFF = "alarm_off"
|
||||||
|
ENABLED = "enabled"
|
||||||
|
DISABLED = "disabled"
|
||||||
|
READ_FAILED = "read_failed"
|
||||||
|
READ_OK = "read_ok"
|
||||||
|
HOME_STARTED = "home_started"
|
||||||
|
HOME_DONE = "home_done"
|
||||||
|
HOME_INCOMPLETE = "home_incomplete"
|
||||||
|
UNITS_MISMATCH = "units_mismatch"
|
||||||
|
UNITS_OK = "units_ok"
|
||||||
|
|
||||||
|
#: How serious each kind is, for the colour the page gives the line.
|
||||||
|
SEVERITY = {
|
||||||
|
CONNECTED: "info",
|
||||||
|
DISCONNECTED: "warning",
|
||||||
|
ALARM_ON: "error",
|
||||||
|
ALARM_OFF: "info",
|
||||||
|
ENABLED: "info",
|
||||||
|
DISABLED: "info",
|
||||||
|
READ_FAILED: "warning",
|
||||||
|
READ_OK: "info",
|
||||||
|
HOME_STARTED: "info",
|
||||||
|
HOME_DONE: "info",
|
||||||
|
HOME_INCOMPLETE: "warning",
|
||||||
|
UNITS_MISMATCH: "error",
|
||||||
|
UNITS_OK: "info",
|
||||||
|
}
|
||||||
|
|
||||||
|
#: Platform-wide events carry this instead of an axis number.
|
||||||
|
NO_AXIS = -1
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AxisEvent:
|
||||||
|
"""One dated change. ``detail`` is raw data (a reason, a number), not prose."""
|
||||||
|
|
||||||
|
time: float
|
||||||
|
axis: int
|
||||||
|
kind: str
|
||||||
|
detail: str = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def severity(self) -> str:
|
||||||
|
return SEVERITY.get(self.kind, "info")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _AxisMemory:
|
||||||
|
alarm: bool
|
||||||
|
enabled: bool
|
||||||
|
read_ok: bool
|
||||||
|
homing: bool
|
||||||
|
units_ok: bool | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AxisEventTracker:
|
||||||
|
"""Remember the last state of each axis and report what changed."""
|
||||||
|
|
||||||
|
limit: int = 200
|
||||||
|
clock: Callable[[], float] = time.time
|
||||||
|
_events: list[AxisEvent] = field(default_factory=list)
|
||||||
|
_connected: bool | None = None
|
||||||
|
_axes: dict[int, _AxisMemory] = field(default_factory=dict)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def events(self) -> list[AxisEvent]:
|
||||||
|
"""Oldest first."""
|
||||||
|
return list(self._events)
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
self._events.clear()
|
||||||
|
|
||||||
|
def update(
|
||||||
|
self,
|
||||||
|
snapshot: Snapshot,
|
||||||
|
expected_units: Mapping[int, float] | None = None,
|
||||||
|
) -> list[AxisEvent]:
|
||||||
|
"""Compare one poll with the previous one; return and keep the new events."""
|
||||||
|
fresh: list[AxisEvent] = []
|
||||||
|
now = float(self.clock())
|
||||||
|
|
||||||
|
def emit(axis: int, kind: str, detail: str = "") -> None:
|
||||||
|
fresh.append(AxisEvent(now, axis, kind, detail))
|
||||||
|
|
||||||
|
if self._connected is not None and snapshot.connected != self._connected:
|
||||||
|
emit(NO_AXIS, CONNECTED if snapshot.connected else DISCONNECTED)
|
||||||
|
self._connected = snapshot.connected
|
||||||
|
|
||||||
|
if not snapshot.connected:
|
||||||
|
# Whatever the axes did while the link was down is unknown: forget them,
|
||||||
|
# so the first poll after a reconnect is a baseline and not a flood.
|
||||||
|
self._axes.clear()
|
||||||
|
else:
|
||||||
|
for state in snapshot.axes:
|
||||||
|
self._update_axis(state, expected_units, emit)
|
||||||
|
|
||||||
|
self._events.extend(fresh)
|
||||||
|
if len(self._events) > self.limit:
|
||||||
|
del self._events[: len(self._events) - self.limit]
|
||||||
|
return fresh
|
||||||
|
|
||||||
|
def _update_axis(self, state: AxisState, expected_units, emit) -> None:
|
||||||
|
axis = state.axis
|
||||||
|
memory = self._axes.get(axis)
|
||||||
|
units_ok = self._units_ok(state, expected_units)
|
||||||
|
current = _AxisMemory(
|
||||||
|
alarm=bool(state.read_ok and state.status.alarm),
|
||||||
|
enabled=bool(state.read_ok and state.enabled),
|
||||||
|
read_ok=bool(state.read_ok),
|
||||||
|
homing=bool(state.read_ok and state.status.home_in_progress),
|
||||||
|
units_ok=units_ok,
|
||||||
|
)
|
||||||
|
if memory is None:
|
||||||
|
# The first sight of an axis is a baseline, except for the two facts that
|
||||||
|
# matter whenever they are true: an alarm already raised, and a drive that
|
||||||
|
# cannot be read.
|
||||||
|
if current.alarm:
|
||||||
|
emit(axis, ALARM_ON)
|
||||||
|
if not current.read_ok:
|
||||||
|
emit(axis, READ_FAILED, state.error)
|
||||||
|
if units_ok is False:
|
||||||
|
emit(axis, UNITS_MISMATCH, _units_detail(state, expected_units))
|
||||||
|
self._axes[axis] = current
|
||||||
|
return
|
||||||
|
|
||||||
|
if current.read_ok != memory.read_ok:
|
||||||
|
if current.read_ok:
|
||||||
|
emit(axis, READ_OK)
|
||||||
|
else:
|
||||||
|
emit(axis, READ_FAILED, state.error)
|
||||||
|
if current.read_ok and memory.read_ok:
|
||||||
|
if current.alarm != memory.alarm:
|
||||||
|
emit(axis, ALARM_ON if current.alarm else ALARM_OFF)
|
||||||
|
if current.enabled != memory.enabled:
|
||||||
|
emit(axis, ENABLED if current.enabled else DISABLED)
|
||||||
|
if current.homing != memory.homing:
|
||||||
|
if current.homing:
|
||||||
|
emit(axis, HOME_STARTED)
|
||||||
|
else:
|
||||||
|
emit(axis, HOME_DONE if state.home_status == 1 else HOME_INCOMPLETE)
|
||||||
|
if units_ok is not None and units_ok != memory.units_ok:
|
||||||
|
if units_ok:
|
||||||
|
if memory.units_ok is False:
|
||||||
|
emit(axis, UNITS_OK)
|
||||||
|
else:
|
||||||
|
emit(axis, UNITS_MISMATCH, _units_detail(state, expected_units))
|
||||||
|
self._axes[axis] = current
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _units_ok(state: AxisState, expected_units) -> bool | None:
|
||||||
|
"""``None`` when there is nothing to compare, else whether the two agree."""
|
||||||
|
if expected_units is None or not state.read_ok:
|
||||||
|
return None
|
||||||
|
expected = expected_units.get(state.axis)
|
||||||
|
if expected is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
expected = float(expected)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if not math.isfinite(expected) or expected <= 0:
|
||||||
|
return None
|
||||||
|
observed = float(state.units)
|
||||||
|
return bool(
|
||||||
|
math.isfinite(observed)
|
||||||
|
and observed > 0
|
||||||
|
and math.isclose(expected, observed, rel_tol=1e-7, abs_tol=0.0)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _units_detail(state: AxisState, expected_units) -> str:
|
||||||
|
expected = (expected_units or {}).get(state.axis)
|
||||||
|
return f"{_g(expected)}|{_g(state.units)}"
|
||||||
|
|
||||||
|
|
||||||
|
def _g(value: object) -> str:
|
||||||
|
try:
|
||||||
|
return f"{float(value):g}"
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return "—"
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"""What the platform's unit settings say, one row per direction (Qt-free).
|
||||||
|
|
||||||
|
The motion guards already refuse a move when the configured pulse equivalent
|
||||||
|
(``UNITS``) and the one the controller reports disagree. This module answers the
|
||||||
|
operator's question *before* a move is refused: which direction uses which drive,
|
||||||
|
what ``UNITS`` is configured, what the controller reads back, what one user unit
|
||||||
|
means in millimetres or degrees, and whether all of it holds together.
|
||||||
|
|
||||||
|
The verdicts follow :meth:`MotionService.units_problem` in the same order, so the
|
||||||
|
page and the guard cannot disagree about what is wrong. Nothing here writes to a
|
||||||
|
controller or changes a setting.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from collections.abc import Callable, Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from .models import Snapshot
|
||||||
|
from .service import AXIS_ROLES, LINEAR_ROLES
|
||||||
|
from .units import PULSES_PER_MM, units_per_degree
|
||||||
|
|
||||||
|
#: Relative tolerance of the expected-versus-read-back comparison. The guard uses
|
||||||
|
#: the same number; a different one here would show "ok" for a move it refuses.
|
||||||
|
UNITS_TOLERANCE = 1e-7
|
||||||
|
|
||||||
|
OK = "ok"
|
||||||
|
UNREAD = "unread"
|
||||||
|
MISMATCH = "mismatch"
|
||||||
|
UNCALIBRATED = "uncalibrated"
|
||||||
|
INVALID = "invalid"
|
||||||
|
UNMAPPED = "unmapped"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class UnitRow:
|
||||||
|
"""One direction's unit facts.
|
||||||
|
|
||||||
|
``per_unit`` is millimetres (linear) or degrees (rotary) per controller user
|
||||||
|
unit, ``per_mm`` the inverse (user units per millimetre or per degree). Both
|
||||||
|
are NaN when the setting does not define them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
role: str
|
||||||
|
axis: int
|
||||||
|
kind: str
|
||||||
|
expected: float
|
||||||
|
observed: float | None
|
||||||
|
status: str
|
||||||
|
per_unit: float
|
||||||
|
per_mm: float
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ok(self) -> bool:
|
||||||
|
return self.status == OK
|
||||||
|
|
||||||
|
|
||||||
|
def build_unit_rows(
|
||||||
|
roles: Mapping[str, int],
|
||||||
|
expected_of: Callable[[int], float],
|
||||||
|
snapshot: Snapshot | None,
|
||||||
|
) -> list[UnitRow]:
|
||||||
|
"""One row per platform direction, in the order the machine is described."""
|
||||||
|
rows: list[UnitRow] = []
|
||||||
|
for role in AXIS_ROLES:
|
||||||
|
kind = "linear" if role in LINEAR_ROLES else "rotary"
|
||||||
|
axis = roles.get(role)
|
||||||
|
if axis is None:
|
||||||
|
rows.append(UnitRow(role, -1, kind, math.nan, None, UNMAPPED, math.nan, math.nan))
|
||||||
|
continue
|
||||||
|
axis = int(axis)
|
||||||
|
expected = _number(expected_of(axis))
|
||||||
|
observed = _observed(snapshot, axis)
|
||||||
|
per_unit, per_mm = _scale(kind, axis, expected)
|
||||||
|
status = _status(kind, axis, expected, observed)
|
||||||
|
rows.append(UnitRow(role, axis, kind, expected, observed, status, per_unit, per_mm))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def summarise(rows: list[UnitRow]) -> tuple[int, int, list[UnitRow]]:
|
||||||
|
"""``(rows that are fine, rows in all, the rows that are not)``."""
|
||||||
|
problems = [row for row in rows if not row.ok]
|
||||||
|
return len(rows) - len(problems), len(rows), problems
|
||||||
|
|
||||||
|
|
||||||
|
def _number(value: object) -> float:
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return math.nan
|
||||||
|
|
||||||
|
|
||||||
|
def _observed(snapshot: Snapshot | None, axis: int) -> float | None:
|
||||||
|
if snapshot is None or not snapshot.connected:
|
||||||
|
return None
|
||||||
|
state = snapshot.axis(axis)
|
||||||
|
if state is None or not state.read_ok:
|
||||||
|
return None
|
||||||
|
return float(state.units)
|
||||||
|
|
||||||
|
|
||||||
|
def _scale(kind: str, axis: int, expected: float) -> tuple[float, float]:
|
||||||
|
"""Per-unit and per-millimetre (or per-degree) factors for the configured value."""
|
||||||
|
if not math.isfinite(expected) or expected <= 0:
|
||||||
|
return math.nan, math.nan
|
||||||
|
if kind == "linear":
|
||||||
|
return expected / PULSES_PER_MM, PULSES_PER_MM / expected
|
||||||
|
per_degree = units_per_degree(axis, units=expected)
|
||||||
|
if not math.isfinite(per_degree) or per_degree <= 0:
|
||||||
|
return math.nan, math.nan
|
||||||
|
return 1.0 / per_degree, per_degree
|
||||||
|
|
||||||
|
|
||||||
|
def _status(kind: str, axis: int, expected: float, observed: float | None) -> str:
|
||||||
|
if not math.isfinite(expected) or expected <= 0:
|
||||||
|
return INVALID
|
||||||
|
if kind == "rotary" and not math.isfinite(units_per_degree(axis, units=expected)):
|
||||||
|
return UNCALIBRATED
|
||||||
|
if observed is None:
|
||||||
|
return UNREAD
|
||||||
|
if (
|
||||||
|
not math.isfinite(observed)
|
||||||
|
or observed <= 0
|
||||||
|
or not math.isclose(expected, observed, rel_tol=UNITS_TOLERANCE, abs_tol=0.0)
|
||||||
|
):
|
||||||
|
return MISMATCH
|
||||||
|
return OK
|
||||||
@@ -15,6 +15,9 @@ five-axis platform, acting on the wrong axis is the expensive mistake.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import math
|
import math
|
||||||
|
import time
|
||||||
|
from collections.abc import Iterable, Mapping
|
||||||
|
from dataclasses import replace
|
||||||
|
|
||||||
from orisys_app_shared.i18n import t
|
from orisys_app_shared.i18n import t
|
||||||
from PySide6.QtCore import Qt, Signal
|
from PySide6.QtCore import Qt, Signal
|
||||||
@@ -25,6 +28,8 @@ from PySide6.QtWidgets import (
|
|||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QLabel,
|
QLabel,
|
||||||
QLineEdit,
|
QLineEdit,
|
||||||
|
QListWidget,
|
||||||
|
QListWidgetItem,
|
||||||
QPushButton,
|
QPushButton,
|
||||||
QTableWidget,
|
QTableWidget,
|
||||||
QTableWidgetItem,
|
QTableWidgetItem,
|
||||||
@@ -42,6 +47,8 @@ from ..motion import (
|
|||||||
safe_float,
|
safe_float,
|
||||||
user_units_to_mm,
|
user_units_to_mm,
|
||||||
)
|
)
|
||||||
|
from ..motion.events import UNITS_MISMATCH, AxisEvent, AxisEventTracker
|
||||||
|
from ..motion.unit_report import UNREAD, UnitRow, summarise
|
||||||
|
|
||||||
#: Data columns in the state table, in display order.
|
#: Data columns in the state table, in display order.
|
||||||
#:
|
#:
|
||||||
@@ -113,6 +120,8 @@ class AxisPanel(QGroupBox):
|
|||||||
moveRelRequested = Signal(int, float)
|
moveRelRequested = Signal(int, float)
|
||||||
homeRequested = Signal(int)
|
homeRequested = Signal(int)
|
||||||
clearAlarmRequested = Signal(int)
|
clearAlarmRequested = Signal(int)
|
||||||
|
#: The operator wants the unit settings (shown and changed in the device setup).
|
||||||
|
unitsRequested = Signal()
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -133,6 +142,8 @@ class AxisPanel(QGroupBox):
|
|||||||
self._selected_axis = 0
|
self._selected_axis = 0
|
||||||
self._snapshot = Snapshot()
|
self._snapshot = Snapshot()
|
||||||
self._rows: list[dict] = []
|
self._rows: list[dict] = []
|
||||||
|
self._tracker = AxisEventTracker()
|
||||||
|
self._unit_rows: list[UnitRow] = []
|
||||||
|
|
||||||
outer = QVBoxLayout(self)
|
outer = QVBoxLayout(self)
|
||||||
outer.setContentsMargins(10, 8, 10, 10)
|
outer.setContentsMargins(10, 8, 10, 10)
|
||||||
@@ -140,11 +151,12 @@ class AxisPanel(QGroupBox):
|
|||||||
|
|
||||||
outer.addWidget(self._build_params())
|
outer.addWidget(self._build_params())
|
||||||
outer.addWidget(self._build_target_row())
|
outer.addWidget(self._build_target_row())
|
||||||
# Stretch factor 1: the table is the only part of this panel that may
|
# The table is exactly as tall as its axis rows: the spare height belongs to
|
||||||
# grow, so every spare pixel goes to a further axis row rather than to
|
# the event log below, which is where an operator looks after something went
|
||||||
# padding around the parameter form.
|
# wrong, and not to blank rows nobody can use.
|
||||||
outer.addWidget(self._build_table(), 1)
|
outer.addWidget(self._build_table())
|
||||||
outer.addWidget(self._build_actions())
|
outer.addWidget(self._build_actions())
|
||||||
|
outer.addWidget(self._build_events(), 1)
|
||||||
|
|
||||||
self.retranslate()
|
self.retranslate()
|
||||||
self._reread_params()
|
self._reread_params()
|
||||||
@@ -181,6 +193,10 @@ class AxisPanel(QGroupBox):
|
|||||||
|
|
||||||
self.units_label = label("axes.units")
|
self.units_label = label("axes.units")
|
||||||
self.units_edit = edit(80)
|
self.units_edit = edit(80)
|
||||||
|
# Units are shown here and changed in the device setup, where they are locked
|
||||||
|
# until the operator asks to modify them: a pulse equivalent typed into a form
|
||||||
|
# that is always live rescales every position the machine is told about.
|
||||||
|
self.units_edit.setReadOnly(True)
|
||||||
self.speed_label = label("axes.speed")
|
self.speed_label = label("axes.speed")
|
||||||
self.speed_edit = edit(80)
|
self.speed_edit = edit(80)
|
||||||
self.accel_label = label("axes.accel")
|
self.accel_label = label("axes.accel")
|
||||||
@@ -211,6 +227,12 @@ class AxisPanel(QGroupBox):
|
|||||||
lambda: self.clearAlarmRequested.emit(self.selected_axis)
|
lambda: self.clearAlarmRequested.emit(self.selected_axis)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self.units_button = QPushButton(box)
|
||||||
|
self.units_button.setObjectName("axisUnitsOpen")
|
||||||
|
self.units_button.setProperty("rail", True)
|
||||||
|
self.units_button.setFixedHeight(FIELD_HEIGHT)
|
||||||
|
self.units_button.clicked.connect(self.unitsRequested)
|
||||||
|
|
||||||
grid.addWidget(self.axis_label, 0, 0)
|
grid.addWidget(self.axis_label, 0, 0)
|
||||||
grid.addWidget(self.axis_index_label, 0, 1)
|
grid.addWidget(self.axis_index_label, 0, 1)
|
||||||
grid.addWidget(self.units_label, 0, 2)
|
grid.addWidget(self.units_label, 0, 2)
|
||||||
@@ -232,6 +254,7 @@ class AxisPanel(QGroupBox):
|
|||||||
grid.addWidget(self.apply_button, 2, 0, 1, 2)
|
grid.addWidget(self.apply_button, 2, 0, 1, 2)
|
||||||
grid.addWidget(self.home_button, 2, 2, 1, 2)
|
grid.addWidget(self.home_button, 2, 2, 1, 2)
|
||||||
grid.addWidget(self.clear_alarm_button, 2, 4, 1, 2)
|
grid.addWidget(self.clear_alarm_button, 2, 4, 1, 2)
|
||||||
|
grid.addWidget(self.units_button, 2, 6, 1, 2)
|
||||||
return box
|
return box
|
||||||
|
|
||||||
def _build_target_row(self) -> QWidget:
|
def _build_target_row(self) -> QWidget:
|
||||||
@@ -275,6 +298,7 @@ class AxisPanel(QGroupBox):
|
|||||||
# and the splitter then clips the last drive — which reads as a missing
|
# and the splitter then clips the last drive — which reads as a missing
|
||||||
# axis. The table scrolls if it is ever squeezed below this.
|
# axis. The table scrolls if it is ever squeezed below this.
|
||||||
self.table.setMinimumHeight(40 + ROW_HEIGHT * MIN_VISIBLE_ROWS)
|
self.table.setMinimumHeight(40 + ROW_HEIGHT * MIN_VISIBLE_ROWS)
|
||||||
|
self.table.setMaximumHeight(40 + ROW_HEIGHT * max(self.axis_count, MIN_VISIBLE_ROWS) + 4)
|
||||||
self.table.cellClicked.connect(self._on_cell_clicked)
|
self.table.cellClicked.connect(self._on_cell_clicked)
|
||||||
|
|
||||||
for row in range(self.axis_count):
|
for row in range(self.axis_count):
|
||||||
@@ -347,6 +371,97 @@ class AxisPanel(QGroupBox):
|
|||||||
column.addLayout(row)
|
column.addLayout(row)
|
||||||
return box
|
return box
|
||||||
|
|
||||||
|
def _build_events(self) -> QWidget:
|
||||||
|
box = QWidget(self)
|
||||||
|
column = QVBoxLayout(box)
|
||||||
|
column.setContentsMargins(0, 0, 0, 0)
|
||||||
|
column.setSpacing(4)
|
||||||
|
header = QHBoxLayout()
|
||||||
|
header.setSpacing(8)
|
||||||
|
self.events_title = QLabel(box)
|
||||||
|
self.events_title.setObjectName("statusValue")
|
||||||
|
self.units_chip = QLabel(box)
|
||||||
|
self.units_chip.setObjectName("hintLabel")
|
||||||
|
self.units_chip.setWordWrap(True)
|
||||||
|
self.clear_events_button = QPushButton(box)
|
||||||
|
self.clear_events_button.setObjectName("axisEventsClear")
|
||||||
|
self.clear_events_button.setProperty("rail", True)
|
||||||
|
self.clear_events_button.setFixedHeight(FIELD_HEIGHT)
|
||||||
|
self.clear_events_button.clicked.connect(self._on_clear_events)
|
||||||
|
header.addWidget(self.events_title)
|
||||||
|
header.addWidget(self.units_chip, 1)
|
||||||
|
header.addWidget(self.clear_events_button)
|
||||||
|
column.addLayout(header)
|
||||||
|
self.event_log = QListWidget(box)
|
||||||
|
self.event_log.setObjectName("axisEventLog")
|
||||||
|
self.event_log.setSelectionMode(QListWidget.SelectionMode.NoSelection)
|
||||||
|
self.event_log.setAlternatingRowColors(True)
|
||||||
|
self.event_log.setMinimumHeight(90)
|
||||||
|
column.addWidget(self.event_log, 1)
|
||||||
|
self.events_empty = QLabel(box)
|
||||||
|
self.events_empty.setObjectName("hintLabel")
|
||||||
|
self.events_empty.setWordWrap(True)
|
||||||
|
column.addWidget(self.events_empty)
|
||||||
|
return box
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ events
|
||||||
|
|
||||||
|
def _append_event(self, event: AxisEvent) -> None:
|
||||||
|
stamp = time.strftime("%H:%M:%S", time.localtime(event.time))
|
||||||
|
item = QListWidgetItem(f"{stamp} {event_text(event)}")
|
||||||
|
colour = _EVENT_COLOURS.get(event.severity)
|
||||||
|
if colour:
|
||||||
|
item.setForeground(QBrush(QColor(colour)))
|
||||||
|
self.event_log.insertItem(0, item)
|
||||||
|
while self.event_log.count() > self._tracker.limit:
|
||||||
|
self.event_log.takeItem(self.event_log.count() - 1)
|
||||||
|
|
||||||
|
def _render_events(self) -> None:
|
||||||
|
"""Rebuild the list from the tracker (language change, clear)."""
|
||||||
|
self.event_log.clear()
|
||||||
|
for event in self._tracker.events:
|
||||||
|
self._append_event(event)
|
||||||
|
self.events_empty.setVisible(self.event_log.count() == 0)
|
||||||
|
|
||||||
|
def _on_clear_events(self) -> None:
|
||||||
|
self._tracker.clear()
|
||||||
|
self._render_events()
|
||||||
|
|
||||||
|
def set_unit_rows(self, rows: Iterable[UnitRow], connected: bool) -> None:
|
||||||
|
"""One line about the unit check; the full table lives in the device setup."""
|
||||||
|
self._unit_rows = list(rows)
|
||||||
|
if not self._unit_rows:
|
||||||
|
self.units_chip.setText("")
|
||||||
|
return
|
||||||
|
_good, total, problems = summarise(self._unit_rows)
|
||||||
|
if not problems:
|
||||||
|
self.units_chip.setText(t("units.summary_ok", total=total))
|
||||||
|
elif all(row.status == UNREAD for row in problems):
|
||||||
|
self.units_chip.setText(t("units.summary_unread"))
|
||||||
|
else:
|
||||||
|
self.units_chip.setText(
|
||||||
|
t("units.summary_bad", bad=len(problems), total=total)
|
||||||
|
)
|
||||||
|
|
||||||
|
def apply_units(self, changes: Mapping[int, float]) -> None:
|
||||||
|
"""Send confirmed unit changes through the ordinary axis-parameter path."""
|
||||||
|
for axis, value in sorted(changes.items()):
|
||||||
|
if not 0 <= int(axis) < self.axis_count:
|
||||||
|
continue
|
||||||
|
params = replace(self._params.get(int(axis), AxisParams()), units=float(value))
|
||||||
|
self._params[int(axis)] = params
|
||||||
|
self.paramsApplied.emit(int(axis), params)
|
||||||
|
self._reread_params()
|
||||||
|
|
||||||
|
def expected_units(self, axis: int) -> float:
|
||||||
|
"""The pulse equivalent configured for a drive."""
|
||||||
|
return self._params.get(int(axis), AxisParams()).units
|
||||||
|
|
||||||
|
@property
|
||||||
|
def snapshot(self) -> Snapshot:
|
||||||
|
"""The last poll the panel rendered."""
|
||||||
|
return self._snapshot
|
||||||
|
|
||||||
# ------------------------------------------------------------------- state
|
# ------------------------------------------------------------------- state
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -473,6 +588,10 @@ class AxisPanel(QGroupBox):
|
|||||||
)
|
)
|
||||||
self._refresh_availability()
|
self._refresh_availability()
|
||||||
self._recolour_rows()
|
self._recolour_rows()
|
||||||
|
expected = {axis: self.expected_units(axis) for axis in range(self.axis_count)}
|
||||||
|
for event in self._tracker.update(snapshot, expected):
|
||||||
|
self._append_event(event)
|
||||||
|
self.events_empty.setVisible(self.event_log.count() == 0)
|
||||||
|
|
||||||
def _detail_text(self) -> str:
|
def _detail_text(self) -> str:
|
||||||
"""Everything about the selected axis that the seven columns cannot hold.
|
"""Everything about the selected axis that the seven columns cannot hold.
|
||||||
@@ -571,6 +690,12 @@ class AxisPanel(QGroupBox):
|
|||||||
self.move_rel_button.setText(t("axes.move_rel"))
|
self.move_rel_button.setText(t("axes.move_rel"))
|
||||||
self.stop_all_button.setText(t("axes.stop_all"))
|
self.stop_all_button.setText(t("axes.stop_all"))
|
||||||
self.mm_hint.setText(t("axes.mm_derived_tip"))
|
self.mm_hint.setText(t("axes.mm_derived_tip"))
|
||||||
|
self.units_button.setText(t("axes.units_open"))
|
||||||
|
self.units_edit.setToolTip(t("axes.units_locked_tip"))
|
||||||
|
self.events_title.setText(t("axes.events_title"))
|
||||||
|
self.clear_events_button.setText(t("axes.events_clear"))
|
||||||
|
self.events_empty.setText(t("axes.events_empty"))
|
||||||
|
self._render_events()
|
||||||
if hasattr(self, "_detail_label"):
|
if hasattr(self, "_detail_label"):
|
||||||
self._detail_label.setText(self._detail_text())
|
self._detail_label.setText(self._detail_text())
|
||||||
self.table.setHorizontalHeaderLabels(
|
self.table.setHorizontalHeaderLabels(
|
||||||
@@ -584,6 +709,19 @@ class AxisPanel(QGroupBox):
|
|||||||
self._rows[row]["stop_button"].setText(t("axes.stop"))
|
self._rows[row]["stop_button"].setText(t("axes.stop"))
|
||||||
|
|
||||||
|
|
||||||
|
#: Line colours for the event log. Info lines keep the theme's own text colour.
|
||||||
|
_EVENT_COLOURS = {"error": "#c62828", "warning": "#b26a00"}
|
||||||
|
|
||||||
|
|
||||||
|
def event_text(event: AxisEvent) -> str:
|
||||||
|
"""One event as a sentence in the current language."""
|
||||||
|
fields: dict[str, object] = {"axis": event.axis, "detail": event.detail}
|
||||||
|
if event.kind == UNITS_MISMATCH:
|
||||||
|
expected, _, observed = event.detail.partition("|")
|
||||||
|
fields.update(expected=expected or "—", observed=observed or "—")
|
||||||
|
return t(f"axes.ev_{event.kind}", **fields)
|
||||||
|
|
||||||
|
|
||||||
def _number(value: float, places: int) -> str:
|
def _number(value: float, places: int) -> str:
|
||||||
try:
|
try:
|
||||||
return f"{float(value):.{places}f}"
|
return f"{float(value):.{places}f}"
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ from ..motion import ConnectionSpec
|
|||||||
from ..motion.service import AXIS_ROLES, DEFAULT_AXIS_ROLES, PLATFORM_AXES
|
from ..motion.service import AXIS_ROLES, DEFAULT_AXIS_ROLES, PLATFORM_AXES
|
||||||
from .bus_panel import BusPanel
|
from .bus_panel import BusPanel
|
||||||
from .connection_panel import ConnectionPanel
|
from .connection_panel import ConnectionPanel
|
||||||
|
from .units_page import UnitsPage
|
||||||
|
|
||||||
#: Tab identity -> locale key, in the order they are shown. The order is the work:
|
#: Tab identity -> locale key, in the order they are shown. The order is the work:
|
||||||
#: reach the machine, bring its bus up, then say what each drive moves and how the
|
#: reach the machine, bring its bus up, then say what each drive moves and how the
|
||||||
@@ -52,6 +53,7 @@ TABS: tuple[tuple[str, str], ...] = (
|
|||||||
("bus", "setup.tab_bus"),
|
("bus", "setup.tab_bus"),
|
||||||
("axes", "setup.tab_axes"),
|
("axes", "setup.tab_axes"),
|
||||||
("press", "setup.tab_press"),
|
("press", "setup.tab_press"),
|
||||||
|
("units", "setup.tab_units"),
|
||||||
)
|
)
|
||||||
|
|
||||||
#: Role -> locale key for the mapping rows.
|
#: Role -> locale key for the mapping rows.
|
||||||
@@ -311,6 +313,7 @@ class DeviceSetupDialog(QDialog):
|
|||||||
self.bus_panel = BusPanel(bas_path, self.tabs)
|
self.bus_panel = BusPanel(bas_path, self.tabs)
|
||||||
self.roles_page = AxisRolesPage(roles, self.tabs)
|
self.roles_page = AxisRolesPage(roles, self.tabs)
|
||||||
self.press_page = PressAxisPage(press, self.tabs)
|
self.press_page = PressAxisPage(press, self.tabs)
|
||||||
|
self.units_page = UnitsPage(self.tabs)
|
||||||
for key, locale_key in TABS:
|
for key, locale_key in TABS:
|
||||||
self.tabs.addTab(self.panel(key), "")
|
self.tabs.addTab(self.panel(key), "")
|
||||||
column.addWidget(self.tabs, 1)
|
column.addWidget(self.tabs, 1)
|
||||||
@@ -331,11 +334,13 @@ class DeviceSetupDialog(QDialog):
|
|||||||
# ----------------------------------------------------------------- content
|
# ----------------------------------------------------------------- content
|
||||||
|
|
||||||
def panel(self, key: str) -> QWidget:
|
def panel(self, key: str) -> QWidget:
|
||||||
"""One of the four pages, by tab key."""
|
"""One of the five pages, by tab key."""
|
||||||
if key == "connection":
|
if key == "connection":
|
||||||
return self.connection_panel
|
return self.connection_panel
|
||||||
if key == "bus":
|
if key == "bus":
|
||||||
return self.bus_panel
|
return self.bus_panel
|
||||||
|
if key == "units":
|
||||||
|
return self.units_page
|
||||||
return self.roles_page if key == "axes" else self.press_page
|
return self.roles_page if key == "axes" else self.press_page
|
||||||
|
|
||||||
def open_for(self, tab: str | None = None) -> None:
|
def open_for(self, tab: str | None = None) -> None:
|
||||||
@@ -402,3 +407,4 @@ class DeviceSetupDialog(QDialog):
|
|||||||
self.bus_panel.retranslate()
|
self.bus_panel.retranslate()
|
||||||
self.roles_page.retranslate()
|
self.roles_page.retranslate()
|
||||||
self.press_page.retranslate()
|
self.press_page.retranslate()
|
||||||
|
self.units_page.retranslate()
|
||||||
|
|||||||
@@ -0,0 +1,405 @@
|
|||||||
|
"""The units page of the device setup: what the platform's scales are, and whether they hold.
|
||||||
|
|
||||||
|
Every millimetre and degree the app shows or commands is *derived* from a drive's
|
||||||
|
pulse equivalent (``UNITS``) and a measured constant. If the equivalent configured here
|
||||||
|
and the one the controller reports differ, the motion guards refuse the move; this page
|
||||||
|
puts the same facts in front of the operator one step earlier, so a wrong scale is seen
|
||||||
|
at start-up and not as a refused move.
|
||||||
|
|
||||||
|
It is **locked by default**. Changing ``UNITS`` rescales every position the machine is
|
||||||
|
told about, so the cells are read-only until the operator presses *Modify*, and a change
|
||||||
|
is written only after a confirmation that lists what will change. The page itself
|
||||||
|
writes nothing: it emits :attr:`UnitsPage.unitsApplied` and the window sends the values
|
||||||
|
through the same axis-parameter path the axis page uses.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from collections.abc import Iterable
|
||||||
|
|
||||||
|
from orisys_app_shared.i18n import t
|
||||||
|
from PySide6.QtCore import Qt, Signal
|
||||||
|
from PySide6.QtGui import QBrush, QColor
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
|
QMessageBox,
|
||||||
|
QPushButton,
|
||||||
|
QTableWidget,
|
||||||
|
QTableWidgetItem,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ..motion.unit_report import (
|
||||||
|
INVALID,
|
||||||
|
MISMATCH,
|
||||||
|
OK,
|
||||||
|
UNCALIBRATED,
|
||||||
|
UNMAPPED,
|
||||||
|
UNREAD,
|
||||||
|
UnitRow,
|
||||||
|
summarise,
|
||||||
|
)
|
||||||
|
from ..motion.units import (
|
||||||
|
PULSES_PER_MM,
|
||||||
|
ROTARY_CALIBRATION_UNITS,
|
||||||
|
ROTARY_UNITS_PER_90_DEG,
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Table columns, in display order.
|
||||||
|
COLUMNS = (
|
||||||
|
"units.col_role",
|
||||||
|
"units.col_axis",
|
||||||
|
"units.col_kind",
|
||||||
|
"units.col_configured",
|
||||||
|
"units.col_observed",
|
||||||
|
"units.col_scale",
|
||||||
|
"units.col_check",
|
||||||
|
)
|
||||||
|
CONFIGURED_COLUMN = 3
|
||||||
|
ROW_HEIGHT = 34
|
||||||
|
|
||||||
|
_ROLE_KEYS = {
|
||||||
|
"x": "setup.role_x",
|
||||||
|
"y": "setup.role_y",
|
||||||
|
"z": "setup.role_z",
|
||||||
|
"yaw": "setup.role_yaw",
|
||||||
|
"pitch": "setup.role_pitch",
|
||||||
|
}
|
||||||
|
_STATUS_KEYS = {
|
||||||
|
OK: "units.status_ok",
|
||||||
|
UNREAD: "units.status_unread",
|
||||||
|
MISMATCH: "units.status_mismatch",
|
||||||
|
UNCALIBRATED: "units.status_uncalibrated",
|
||||||
|
INVALID: "units.status_invalid",
|
||||||
|
UNMAPPED: "units.status_unmapped",
|
||||||
|
}
|
||||||
|
_TIP_KEYS = {
|
||||||
|
UNREAD: "units.tip_unread",
|
||||||
|
MISMATCH: "units.tip_mismatch",
|
||||||
|
UNCALIBRATED: "units.tip_uncalibrated",
|
||||||
|
INVALID: "units.tip_invalid",
|
||||||
|
UNMAPPED: "units.tip_unmapped",
|
||||||
|
}
|
||||||
|
_GOOD = "#2e7d32"
|
||||||
|
_BAD = "#c62828"
|
||||||
|
_QUIET = "#7a7a7a"
|
||||||
|
|
||||||
|
|
||||||
|
def _g(value: float | None, places: int = 6) -> str:
|
||||||
|
"""A number as the operator would write it, or a dash when there is none."""
|
||||||
|
if value is None:
|
||||||
|
return "—"
|
||||||
|
try:
|
||||||
|
number = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return "—"
|
||||||
|
return f"{number:.{places}g}" if math.isfinite(number) else "—"
|
||||||
|
|
||||||
|
|
||||||
|
class UnitsPage(QWidget):
|
||||||
|
"""Read-only unit table with a guarded way to change it."""
|
||||||
|
|
||||||
|
#: ``{drive: new UNITS}`` for the drives the operator changed and confirmed.
|
||||||
|
unitsApplied = Signal(object)
|
||||||
|
|
||||||
|
def __init__(self, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._rows: list[UnitRow] = []
|
||||||
|
self._connected = False
|
||||||
|
self._locked = True
|
||||||
|
self._unlocked_axes: dict[str, int] = {}
|
||||||
|
self._edits: dict[str, QLineEdit] = {}
|
||||||
|
|
||||||
|
column = QVBoxLayout(self)
|
||||||
|
column.setContentsMargins(8, 8, 8, 8)
|
||||||
|
column.setSpacing(8)
|
||||||
|
|
||||||
|
self.hint_label = QLabel(self)
|
||||||
|
self.hint_label.setObjectName("hintLabel")
|
||||||
|
self.hint_label.setWordWrap(True)
|
||||||
|
column.addWidget(self.hint_label)
|
||||||
|
|
||||||
|
top = QHBoxLayout()
|
||||||
|
top.setSpacing(8)
|
||||||
|
self.summary_label = QLabel(self)
|
||||||
|
self.summary_label.setObjectName("statusValue")
|
||||||
|
self.summary_label.setWordWrap(True)
|
||||||
|
self.modify_button = QPushButton(self)
|
||||||
|
self.modify_button.setObjectName("unitsModify")
|
||||||
|
self.modify_button.setProperty("rail", True)
|
||||||
|
self.modify_button.clicked.connect(self._unlock)
|
||||||
|
self.apply_button = QPushButton(self)
|
||||||
|
self.apply_button.setObjectName("unitsApply")
|
||||||
|
self.apply_button.setProperty("rail", True)
|
||||||
|
self.apply_button.clicked.connect(self._on_apply)
|
||||||
|
self.cancel_button = QPushButton(self)
|
||||||
|
self.cancel_button.setObjectName("unitsCancel")
|
||||||
|
self.cancel_button.setProperty("rail", True)
|
||||||
|
self.cancel_button.clicked.connect(self._on_cancel)
|
||||||
|
top.addWidget(self.summary_label, 1)
|
||||||
|
top.addWidget(self.modify_button)
|
||||||
|
top.addWidget(self.apply_button)
|
||||||
|
top.addWidget(self.cancel_button)
|
||||||
|
column.addLayout(top)
|
||||||
|
|
||||||
|
self.table = QTableWidget(5, len(COLUMNS), self)
|
||||||
|
self.table.setObjectName("unitsTable")
|
||||||
|
self.table.verticalHeader().setVisible(False)
|
||||||
|
self.table.setSelectionMode(QTableWidget.SelectionMode.NoSelection)
|
||||||
|
self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
||||||
|
self.table.setAlternatingRowColors(True)
|
||||||
|
self.table.verticalHeader().setDefaultSectionSize(ROW_HEIGHT)
|
||||||
|
self.table.horizontalHeader().setStretchLastSection(True)
|
||||||
|
for index, width in enumerate((110, 60, 90, 110, 100, 230)):
|
||||||
|
self.table.setColumnWidth(index, width)
|
||||||
|
self.table.setMinimumHeight(40 + ROW_HEIGHT * 5 + 4)
|
||||||
|
for row in range(5):
|
||||||
|
for col in range(len(COLUMNS)):
|
||||||
|
if col != CONFIGURED_COLUMN:
|
||||||
|
item = QTableWidgetItem("")
|
||||||
|
if col in (1, 3, 4):
|
||||||
|
item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
self.table.setItem(row, col, item)
|
||||||
|
column.addWidget(self.table)
|
||||||
|
|
||||||
|
self.constants_label = QLabel(self)
|
||||||
|
self.constants_label.setObjectName("hintLabel")
|
||||||
|
self.constants_label.setWordWrap(True)
|
||||||
|
column.addWidget(self.constants_label)
|
||||||
|
|
||||||
|
self.message_label = QLabel(self)
|
||||||
|
self.message_label.setObjectName("hintLabel")
|
||||||
|
self.message_label.setWordWrap(True)
|
||||||
|
column.addWidget(self.message_label)
|
||||||
|
column.addStretch(1)
|
||||||
|
|
||||||
|
self._apply_lock()
|
||||||
|
self.retranslate()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ data
|
||||||
|
|
||||||
|
@property
|
||||||
|
def locked(self) -> bool:
|
||||||
|
return self._locked
|
||||||
|
|
||||||
|
def set_rows(self, rows: Iterable[UnitRow], connected: bool) -> None:
|
||||||
|
"""Show one poll's worth of unit facts.
|
||||||
|
|
||||||
|
While the page is unlocked the configured column belongs to the operator and
|
||||||
|
is not overwritten by a refresh; if the drive mapping changes underneath an
|
||||||
|
unlocked edit, the edit is abandoned instead of being applied to another drive.
|
||||||
|
"""
|
||||||
|
self._rows = list(rows)
|
||||||
|
self._connected = bool(connected)
|
||||||
|
if not self._locked and {r.role: r.axis for r in self._rows} != self._unlocked_axes:
|
||||||
|
self._lock()
|
||||||
|
self.message_label.setText(t("units.mapping_changed"))
|
||||||
|
for index, row in enumerate(self._rows[: self.table.rowCount()]):
|
||||||
|
self._render_row(index, row)
|
||||||
|
self._render_summary()
|
||||||
|
self._apply_lock()
|
||||||
|
|
||||||
|
def _render_row(self, index: int, row: UnitRow) -> None:
|
||||||
|
def put(column: int, text: str, *, colour: str | None = None, tip: str = "") -> None:
|
||||||
|
item = self.table.item(index, column)
|
||||||
|
if item is None:
|
||||||
|
return
|
||||||
|
item.setText(text)
|
||||||
|
item.setToolTip(tip)
|
||||||
|
if colour:
|
||||||
|
item.setForeground(QBrush(QColor(colour)))
|
||||||
|
else:
|
||||||
|
item.setData(Qt.ItemDataRole.ForegroundRole, None)
|
||||||
|
|
||||||
|
put(0, t(_ROLE_KEYS.get(row.role, row.role)))
|
||||||
|
put(1, "—" if row.axis < 0 else str(row.axis))
|
||||||
|
put(2, t("units.kind_linear" if row.kind == "linear" else "units.kind_rotary"))
|
||||||
|
put(4, _g(row.observed, 6))
|
||||||
|
put(5, self._scale_text(row))
|
||||||
|
colour = _GOOD if row.ok else (_QUIET if row.status in {UNREAD, UNMAPPED} else _BAD)
|
||||||
|
put(
|
||||||
|
6,
|
||||||
|
t(_STATUS_KEYS[row.status]),
|
||||||
|
colour=colour,
|
||||||
|
tip=t(_TIP_KEYS[row.status]) if row.status in _TIP_KEYS else "",
|
||||||
|
)
|
||||||
|
edit = self._edit_for(index, row)
|
||||||
|
if edit is not None and (self._locked or not edit.isModified()):
|
||||||
|
edit.setText(_g(row.expected, 9))
|
||||||
|
edit.setModified(False)
|
||||||
|
|
||||||
|
def _edit_for(self, index: int, row: UnitRow) -> QLineEdit | None:
|
||||||
|
"""The configured-UNITS cell of one direction, created on first use."""
|
||||||
|
if row.axis < 0:
|
||||||
|
if self._edits.pop(row.role, None) is not None:
|
||||||
|
self.table.removeCellWidget(index, CONFIGURED_COLUMN)
|
||||||
|
return None
|
||||||
|
edit = self._edits.get(row.role)
|
||||||
|
if edit is None:
|
||||||
|
edit = QLineEdit(self.table)
|
||||||
|
edit.setObjectName(f"unitsEdit_{row.role}")
|
||||||
|
edit.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
edit.setFixedHeight(28)
|
||||||
|
self._edits[row.role] = edit
|
||||||
|
self.table.setCellWidget(index, CONFIGURED_COLUMN, edit)
|
||||||
|
edit.setReadOnly(self._locked)
|
||||||
|
return edit
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _scale_text(row: UnitRow) -> str:
|
||||||
|
if not math.isfinite(row.per_unit) or not math.isfinite(row.per_mm):
|
||||||
|
return "—"
|
||||||
|
if row.kind == "linear":
|
||||||
|
return t("units.scale_linear", mm=_g(row.per_unit), per=_g(row.per_mm))
|
||||||
|
return t("units.scale_rotary", deg=_g(row.per_unit), per=_g(row.per_mm))
|
||||||
|
|
||||||
|
def _render_summary(self) -> None:
|
||||||
|
if not self._rows:
|
||||||
|
self.summary_label.setText("")
|
||||||
|
return
|
||||||
|
good, total, problems = summarise(self._rows)
|
||||||
|
only_unread = bool(problems) and all(row.status == UNREAD for row in problems)
|
||||||
|
if not problems:
|
||||||
|
self.summary_label.setText(t("units.summary_ok", total=total))
|
||||||
|
colour = _GOOD
|
||||||
|
elif only_unread:
|
||||||
|
self.summary_label.setText(t("units.summary_unread"))
|
||||||
|
colour = _QUIET
|
||||||
|
else:
|
||||||
|
self.summary_label.setText(
|
||||||
|
t("units.summary_bad", bad=len(problems), total=total)
|
||||||
|
)
|
||||||
|
colour = _BAD
|
||||||
|
self.summary_label.setStyleSheet(f"color: {colour};")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- locking
|
||||||
|
|
||||||
|
def _apply_lock(self) -> None:
|
||||||
|
for edit in self._edits.values():
|
||||||
|
edit.setReadOnly(self._locked)
|
||||||
|
self.modify_button.setVisible(self._locked)
|
||||||
|
self.modify_button.setEnabled(self._connected)
|
||||||
|
self.modify_button.setToolTip("" if self._connected else t("units.modify_offline"))
|
||||||
|
self.apply_button.setVisible(not self._locked)
|
||||||
|
self.apply_button.setEnabled(self._connected)
|
||||||
|
self.cancel_button.setVisible(not self._locked)
|
||||||
|
|
||||||
|
def _unlock(self) -> None:
|
||||||
|
if not self._connected:
|
||||||
|
return
|
||||||
|
self._locked = False
|
||||||
|
self._unlocked_axes = {row.role: row.axis for row in self._rows}
|
||||||
|
self.message_label.setText(t("units.unlocked_note"))
|
||||||
|
self._apply_lock()
|
||||||
|
|
||||||
|
def _lock(self) -> None:
|
||||||
|
self._locked = True
|
||||||
|
self._unlocked_axes = {}
|
||||||
|
for row in self._rows:
|
||||||
|
edit = self._edits.get(row.role)
|
||||||
|
if edit is not None:
|
||||||
|
edit.setText(_g(row.expected, 9))
|
||||||
|
edit.setModified(False)
|
||||||
|
self._apply_lock()
|
||||||
|
|
||||||
|
def _on_cancel(self) -> None:
|
||||||
|
self._lock()
|
||||||
|
self.message_label.setText(t("units.locked_note"))
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ apply
|
||||||
|
|
||||||
|
def collect_changes(self) -> tuple[dict[int, float], str]:
|
||||||
|
"""``(changes, problem)`` from the typed values; a problem means nothing is applied."""
|
||||||
|
changes: dict[int, float] = {}
|
||||||
|
for row in self._rows:
|
||||||
|
edit = self._edits.get(row.role)
|
||||||
|
if edit is None or row.axis < 0:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
value = float(edit.text().strip())
|
||||||
|
except ValueError:
|
||||||
|
return {}, t("units.invalid", role=t(_ROLE_KEYS.get(row.role, row.role)))
|
||||||
|
if not math.isfinite(value) or value <= 0:
|
||||||
|
return {}, t("units.invalid", role=t(_ROLE_KEYS.get(row.role, row.role)))
|
||||||
|
if not math.isclose(value, row.expected, rel_tol=1e-9, abs_tol=0.0):
|
||||||
|
changes[row.axis] = value
|
||||||
|
return changes, ""
|
||||||
|
|
||||||
|
def _on_apply(self) -> None:
|
||||||
|
changes, problem = self.collect_changes()
|
||||||
|
if problem:
|
||||||
|
self.message_label.setText(problem)
|
||||||
|
return
|
||||||
|
if not changes:
|
||||||
|
self._lock()
|
||||||
|
self.message_label.setText(t("units.no_change"))
|
||||||
|
return
|
||||||
|
if not self._confirm(changes):
|
||||||
|
return
|
||||||
|
self._lock()
|
||||||
|
self.message_label.setText(t("units.applied", count=len(changes)))
|
||||||
|
self.unitsApplied.emit(dict(changes))
|
||||||
|
|
||||||
|
def _confirm(self, changes: dict[int, float]) -> bool:
|
||||||
|
by_axis = {row.axis: row for row in self._rows if row.axis >= 0}
|
||||||
|
lines = []
|
||||||
|
off_calibration = []
|
||||||
|
for axis, value in sorted(changes.items()):
|
||||||
|
row = by_axis.get(axis)
|
||||||
|
role = t(_ROLE_KEYS.get(row.role, row.role)) if row else "?"
|
||||||
|
lines.append(
|
||||||
|
t("units.change_line", role=role, axis=axis, old=_g(row.expected if row else None, 9), new=_g(value, 9))
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
row is not None
|
||||||
|
and row.kind == "rotary"
|
||||||
|
and axis in ROTARY_UNITS_PER_90_DEG
|
||||||
|
and not math.isclose(value, ROTARY_CALIBRATION_UNITS, rel_tol=1e-7, abs_tol=0.0)
|
||||||
|
):
|
||||||
|
off_calibration.append(role)
|
||||||
|
body = t("units.confirm_body", changes="\n".join(lines))
|
||||||
|
if off_calibration:
|
||||||
|
body += "\n\n" + t(
|
||||||
|
"units.confirm_rotary",
|
||||||
|
roles=", ".join(off_calibration),
|
||||||
|
cal=_g(ROTARY_CALIBRATION_UNITS),
|
||||||
|
)
|
||||||
|
answer = QMessageBox.question(
|
||||||
|
self,
|
||||||
|
t("units.confirm_title"),
|
||||||
|
body,
|
||||||
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||||
|
QMessageBox.StandardButton.No,
|
||||||
|
)
|
||||||
|
return answer == QMessageBox.StandardButton.Yes
|
||||||
|
|
||||||
|
# ----------------------------------------------------------- translations
|
||||||
|
|
||||||
|
def retranslate(self) -> None:
|
||||||
|
self.hint_label.setText(t("units.hint"))
|
||||||
|
self.modify_button.setText(t("units.modify"))
|
||||||
|
self.apply_button.setText(t("units.apply"))
|
||||||
|
self.cancel_button.setText(t("units.cancel"))
|
||||||
|
self.table.setHorizontalHeaderLabels([t(key) for key in COLUMNS])
|
||||||
|
rotary = ";".join(
|
||||||
|
t("units.rotary_item", axis=axis, units=_g(value, 9))
|
||||||
|
for axis, value in sorted(ROTARY_UNITS_PER_90_DEG.items())
|
||||||
|
)
|
||||||
|
self.constants_label.setText(
|
||||||
|
t(
|
||||||
|
"units.constants",
|
||||||
|
ppm=_g(PULSES_PER_MM, 9),
|
||||||
|
rotary=rotary,
|
||||||
|
cal=_g(ROTARY_CALIBRATION_UNITS),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not self.message_label.text():
|
||||||
|
self.message_label.setText(t("units.locked_note"))
|
||||||
|
for index, row in enumerate(self._rows[: self.table.rowCount()]):
|
||||||
|
self._render_row(index, row)
|
||||||
|
self._render_summary()
|
||||||
|
self._apply_lock()
|
||||||
@@ -46,6 +46,7 @@ from ..motion.pose_move import (
|
|||||||
press_targets,
|
press_targets,
|
||||||
)
|
)
|
||||||
from ..motion.service import clamp_bas_task
|
from ..motion.service import clamp_bas_task
|
||||||
|
from ..motion.unit_report import build_unit_rows
|
||||||
from ..motion.units import DEFAULT_UNITS, user_units_to_deg, user_units_to_mm
|
from ..motion.units import DEFAULT_UNITS, user_units_to_deg, user_units_to_mm
|
||||||
from ..preferences import load_preferences, save_preferences
|
from ..preferences import load_preferences, save_preferences
|
||||||
from ..sensor import (
|
from ..sensor import (
|
||||||
@@ -367,6 +368,8 @@ class AutoCalMainWindow(QMainWindow):
|
|||||||
self.axis_panel.moveRelRequested.connect(self._on_move_rel)
|
self.axis_panel.moveRelRequested.connect(self._on_move_rel)
|
||||||
self.axis_panel.homeRequested.connect(self._on_home)
|
self.axis_panel.homeRequested.connect(self._on_home)
|
||||||
self.axis_panel.clearAlarmRequested.connect(self._on_clear_alarm)
|
self.axis_panel.clearAlarmRequested.connect(self._on_clear_alarm)
|
||||||
|
self.axis_panel.unitsRequested.connect(lambda: self._show_device_setup("units"))
|
||||||
|
self.device_setup.units_page.unitsApplied.connect(self._on_units_applied)
|
||||||
self.bus_data_panel.sdoReadRequested.connect(self._on_sdo_read)
|
self.bus_data_panel.sdoReadRequested.connect(self._on_sdo_read)
|
||||||
self.bus_data_panel.sdoWriteRequested.connect(self._on_sdo_write)
|
self.bus_data_panel.sdoWriteRequested.connect(self._on_sdo_write)
|
||||||
self.bus_data_panel.rtexReadRequested.connect(self._on_rtex_read)
|
self.bus_data_panel.rtexReadRequested.connect(self._on_rtex_read)
|
||||||
@@ -423,6 +426,28 @@ class AutoCalMainWindow(QMainWindow):
|
|||||||
problem = self.poller.service.roles_problem()
|
problem = self.poller.service.roles_problem()
|
||||||
if problem:
|
if problem:
|
||||||
self.finger_panel.set_move_status(f"✗ {problem}")
|
self.finger_panel.set_move_status(f"✗ {problem}")
|
||||||
|
self._refresh_unit_report()
|
||||||
|
|
||||||
|
def _refresh_unit_report(self) -> None:
|
||||||
|
"""Feed the unit table and the axis page's one-line check from one computation.
|
||||||
|
|
||||||
|
Both read the same rows, so the line on the axis page and the table in the
|
||||||
|
device setup cannot disagree. Nothing here writes to the controller.
|
||||||
|
"""
|
||||||
|
panel = getattr(self, "axis_panel", None)
|
||||||
|
if panel is None:
|
||||||
|
return
|
||||||
|
snapshot = panel.snapshot
|
||||||
|
rows = build_unit_rows(
|
||||||
|
self.device_setup.roles_page.roles(), panel.expected_units, snapshot
|
||||||
|
)
|
||||||
|
self.device_setup.units_page.set_rows(rows, snapshot.connected)
|
||||||
|
panel.set_unit_rows(rows, snapshot.connected)
|
||||||
|
|
||||||
|
def _on_units_applied(self, changes: dict) -> None:
|
||||||
|
"""Confirmed unit changes go out through the ordinary axis-parameter path."""
|
||||||
|
self.axis_panel.apply_units(changes)
|
||||||
|
self._refresh_unit_report()
|
||||||
|
|
||||||
def _capture_bus_problem(self) -> str:
|
def _capture_bus_problem(self) -> str:
|
||||||
"""Why the capture page's bus check fails, or ``""`` when it passes.
|
"""Why the capture page's bus check fails, or ``""`` when it passes.
|
||||||
@@ -942,6 +967,7 @@ class AutoCalMainWindow(QMainWindow):
|
|||||||
self.bus_data_panel.set_connected(snapshot.connected)
|
self.bus_data_panel.set_connected(snapshot.connected)
|
||||||
self.bus_data_panel.set_bus_type(snapshot.bus.bus_type)
|
self.bus_data_panel.set_bus_type(snapshot.bus.bus_type)
|
||||||
self.axis_panel.update_snapshot(snapshot)
|
self.axis_panel.update_snapshot(snapshot)
|
||||||
|
self._refresh_unit_report()
|
||||||
self.axis_readout.update_snapshot(snapshot)
|
self.axis_readout.update_snapshot(snapshot)
|
||||||
# Progress the pose move. It waits on the axis readings in this same
|
# Progress the pose move. It waits on the axis readings in this same
|
||||||
# snapshot, so it costs the poll nothing extra and cannot run ahead of the
|
# snapshot, so it costs the poll nothing extra and cannot run ahead of the
|
||||||
|
|||||||
@@ -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 == []
|
||||||
@@ -180,7 +180,7 @@ def test_the_axis_mapping_page_holds_the_mapping_and_refuses_duplicates(make_win
|
|||||||
window = make_window()
|
window = make_window()
|
||||||
try:
|
try:
|
||||||
dialog = window.device_setup
|
dialog = window.device_setup
|
||||||
assert dialog.tabs.count() == 4
|
assert dialog.tabs.count() == 5
|
||||||
page = dialog.roles_page
|
page = dialog.roles_page
|
||||||
assert page.roles() == DEFAULT_AXIS_ROLES
|
assert page.roles() == DEFAULT_AXIS_ROLES
|
||||||
assert page.problem() == ""
|
assert page.problem() == ""
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user