fix(autocal): 校验运动单位并等待命令成功后判断到位

阻止单位不一致的姿态命令,避免提前到达的位置快照误报成功或推进下一轴。
相关检查 164 项通过,2064 组目标及 8256 条运动调用与原基线完全一致。
This commit is contained in:
ykcheungab
2026-10-18 20:24:45 +08:00
parent 9352d0c816
commit cf3e638176
8 changed files with 259 additions and 56 deletions
@@ -31,11 +31,10 @@ import time
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from PySide6.QtCore import QObject, Signal
from PySide6.QtCore import QObject, Signal, Slot
from .models import MotionResult, Snapshot
from .service import LINEAR_ROLES, MotionService
from .units import user_units_to_deg
#: How close an axis must be to its target before the next one starts, per unit:
#: millimetres for the linear roles, degrees for the rotary ones. One user unit is
@@ -141,6 +140,33 @@ class MoveStep:
return f"{self.role} {self.target:+.4f} {self.unit}"
def _check_steps(
service: MotionService, steps: Sequence[MoveStep], *, refresh_units: bool = False
) -> MotionResult | None:
"""Reject stale axis/scale bindings; never reinterpret an existing plan."""
refusal = service.check_axes_move(
{step.role: step.target for step in steps}, refresh_units=refresh_units
)
if refusal is not None:
return refusal
for step in steps:
if step.axis != service.role_axis(step.role) or step.units != service.to_units(
step.role, step.target
):
return MotionResult.refused(
f"{step.role} 的轴映射或单位已改变,请重新规划 / axis mapping or units changed; replan"
)
return None
def _execute_steps(service: MotionService, steps: Sequence[MoveStep]) -> MotionResult:
"""Worker-only read check, then the original absolute targets in their order."""
refusal = _check_steps(service, steps, refresh_units=True)
if refusal is not None:
return refusal
return service.move_abs_many(tuple((step.axis, step.units) for step in steps))
@dataclass(frozen=True)
class MovePlan:
"""A sequence of steps, and what it is for."""
@@ -254,6 +280,7 @@ class PoseMover(QObject):
self._service = service
self._submit = submit
self.state = MoveState()
self._command_token: int | None = None
# ------------------------------------------------------------------ control
@@ -271,9 +298,7 @@ class PoseMover(QObject):
"""Validate and begin. Refusal is returned, not raised."""
if self.running:
return MotionResult.refused("已有运动在执行 / a move is already running")
refusal = self._service.check_axes_move(
{step.role: step.target for step in plan.steps}
)
refusal = _check_steps(self._service, plan.steps)
if refusal is not None:
return refusal
if not plan.steps:
@@ -312,19 +337,21 @@ class PoseMover(QObject):
if not step.units == step.units: # NaN: no calibration for this role
self._fail(f"{step.role} 无法换算成用户单位 / cannot convert {step.role}")
return
self._submit(lambda service, step=step: service.move_abs(step.axis, step.units))
self._command_token = self._submit(
lambda service, step=step: _execute_steps(service, (step,))
)
self.stepStarted.emit(step)
def _issue_together(self, plan: MovePlan) -> None:
"""Command every step in one worker call."""
moves: list[tuple[int, float]] = []
for step in plan.steps:
if not step.units == step.units:
self._fail(f"{step.role} 无法换算成用户单位 / cannot convert {step.role}")
return
moves.append((step.axis, step.units))
self.state._group_idle = {}
self._submit(lambda service, moves=tuple(moves): service.move_abs_many(moves))
self._command_token = self._submit(
lambda service, steps=plan.steps: _execute_steps(service, steps)
)
if plan.steps:
self.stepStarted.emit(plan.steps[0])
@@ -339,6 +366,20 @@ class PoseMover(QObject):
self.state.plan = None
self.failed.emit(message)
@Slot(int, object)
def on_command_finished(self, token: int, result: object) -> None:
"""A rejected queued move must not later be reported as an arrival."""
if self.running and token == self._command_token:
self._command_token = None
if isinstance(result, MotionResult) and not result.ok:
self._fail(result.message)
@Slot(int, str)
def on_command_failed(self, token: int, message: str) -> None:
if self.running and token == self._command_token:
self._command_token = None
self._fail(message)
# ---------------------------------------------------------------- progress
def on_snapshot(self, snapshot: Snapshot) -> None:
@@ -364,12 +405,11 @@ class PoseMover(QObject):
self._fail(f"{step.role} 报警 / alarm on {step.role}")
return
# A linear role reads in millimetres, a rotary one in degrees; the service
# converts lengths, the units module converts calibrated angles.
# Arrival uses the same verified units as the planned physical target.
if step.unit == "mm":
travelled = self._service.to_mm(step.axis, state.dpos)
else:
travelled = user_units_to_deg(step.axis, state.dpos)
travelled = self._service.to_deg(step.axis, state.dpos)
tolerance = SETTLE_MM if step.unit == "mm" else SETTLE_DEG
if not state.idle:
@@ -410,7 +450,7 @@ class PoseMover(QObject):
if step.unit == "mm":
travelled = self._service.to_mm(step.axis, state.dpos)
else:
travelled = user_units_to_deg(step.axis, state.dpos)
travelled = self._service.to_deg(step.axis, state.dpos)
tolerance = SETTLE_MM if step.unit == "mm" else SETTLE_DEG
arrived = state.idle and abs(travelled - step.target) <= tolerance
if arrived:
@@ -43,6 +43,7 @@ from .units import (
mm_to_user_units,
safe_float,
units_per_degree,
user_units_to_deg,
user_units_to_mm,
)
@@ -175,6 +176,7 @@ class MotionService:
self._limits: dict[str, float] = dict(limits or {})
self._spec: ConnectionSpec | None = None
self._last_axes: dict[int, AxisState] = {}
self._bus = BusStatus()
self._params: dict[int, AxisParams] = {
axis: AxisParams() for axis in range(self.axis_count)
@@ -273,25 +275,62 @@ class MotionService:
key = "max_travel_mm" if role in LINEAR_ROLES else f"max_{role}_deg"
return safe_float(self._limits.get(key), 0.0)
def units_problem(self, role: str, axis: int) -> str:
"""Why a role cannot be commanded in its own unit, or ``""``."""
if role in LINEAR_ROLES:
return ""
scale = units_per_degree(axis)
if not math.isfinite(scale) or scale <= 0:
def units_problem(self, role: str, axis: int, *, refresh: bool = False) -> str:
"""Validate expected versus observed units, without changing either.
``refresh`` is only for the controller worker immediately before execution.
UI/planning checks use the last poll and never call the transport.
"""
expected = self.params(axis).units
if not math.isfinite(expected) or expected <= 0:
return self._text(
f"{role} 用轴 {axis},但该轴没有角度标定(用户单位/度)",
f"{role} uses axis {axis}, which has no user-units-per-degree "
f"calibration",
f"轴 {axis} 的 UNITS 无效", f"Axis {axis} has invalid expected UNITS"
)
if role not in LINEAR_ROLES and not math.isfinite(
units_per_degree(axis, units=expected)
):
return self._text(
f"{role} 用轴 {axis},但该轴没有当前 UNITS={expected:g} 对应的角度标定",
f"{role} uses axis {axis}, which has no rotary calibration for UNITS={expected:g}",
)
if refresh:
try:
observed = self._transport.get_units(axis)
except (ZAuxError, OSError) as exc:
return self._text(
f"无法读取轴 {axis} 的 UNITS:{exc}",
f"Cannot read axis {axis} UNITS: {exc}",
)
else:
state = self._last_axes.get(axis)
if state is None or not state.read_ok:
return self._text(
f"轴 {axis} 的 UNITS 尚未成功读回",
f"Axis {axis} UNITS has no successful readback",
)
observed = state.units
if (
not math.isfinite(observed)
or observed <= 0
or not math.isclose(expected, observed, rel_tol=1e-7, abs_tol=0.0)
):
return self._text(
f"轴 {axis} 的 UNITS 不一致:配置 {expected:g},读回 {observed:g};未重新换算运动目标",
f"Axis {axis} UNITS mismatch: expected {expected:g}, read {observed:g}; motion was not rescaled",
)
return ""
def check_axes_move(self, targets: Mapping[str, float]) -> MotionResult | None:
def check_axes_move(
self, targets: Mapping[str, float], *, refresh_units: bool = False
) -> MotionResult | None:
"""Refuse a coordinated pose move, or return ``None`` to allow it.
``targets`` is per role, in the role's own unit: millimetres for x / y / z,
degrees for yaw / pitch.
``refresh_units`` reads the current pulse equivalents and is worker-only.
It never writes them or rescales any already planned target.
Every reason a move can be wrong is checked *before* the first axis is
commanded. A pose move is several coordinated moves, and discovering at axis
three that axis four is out of range would leave the machine stopped
@@ -321,7 +360,7 @@ class MotionService:
self._text(f"{role} 目标不是有效数字", f"{role} target is not a number")
)
axis = self._axis_roles[role]
units_problem = self.units_problem(role, axis)
units_problem = self.units_problem(role, axis, refresh=refresh_units)
if units_problem:
return MotionResult.refused(units_problem)
for role, value in targets.items():
@@ -350,11 +389,11 @@ class MotionService:
if axis is None:
return math.nan
if role in LINEAR_ROLES:
units = self.params(axis).units or self._units_per_mm
units = self.params(axis).units
return mm_to_user_units(
value, units=units, pulses_per_mm=self._pulses_per_mm
)
return deg_to_user_units(axis, value)
return deg_to_user_units(axis, value, units=self.params(axis).units)
def set_language(self, language: str) -> None:
self.language = language
@@ -399,6 +438,7 @@ class MotionService:
return MotionResult.failed(code, message)
self._spec = spec
self._last_axes.clear()
# A controller may already have a BAS program running from a previous
# session; adopt it rather than claiming the bus is uninitialised. The
# adopted state is *not* marked verified: its globals are a latch, and
@@ -610,6 +650,7 @@ class MotionService:
)
)
self._last_axes.update({state.axis: state for state in states})
return Snapshot(
connected=True,
spec=self._spec,
@@ -724,7 +765,6 @@ class MotionService:
home_status = 0
self._home_done[axis] = home_status == 1
params = self.params(axis)
return AxisState(
axis=axis,
enabled=transport.get_axis_enable(axis) != 0,
@@ -748,6 +788,8 @@ class MotionService:
return MotionResult.refused(self._text("未连接控制器", "Not connected"))
if not self._known_axis(axis):
return MotionResult.refused(self._axis_range_message(axis))
# A partial write may already change UNITS even if speed/accel then fails.
self._last_axes.pop(axis, None)
try:
self._transport.set_units(axis, params.units)
self._transport.set_speed(axis, params.speed)
@@ -979,7 +1021,7 @@ class MotionService:
return MotionResult.success(self._text("回零已启动", "Homing started"))
def _log_axis_readback(self, action: str) -> None:
"""Read every axis and log it. Does not write parameters or start motion."""
"""Log the last poll without issuing extra controller reads."""
for axis in range(self.axis_count):
self._log_command(action, axis, MotionResult.success("read"))
@@ -1000,23 +1042,17 @@ class MotionService:
def _position_text(self, axis: int) -> str:
"""Commanded and measured position, for the command log.
Reads only. A failed read is part of the line rather than a second error,
because the command result itself is what the caller is waiting on.
These are cached poll values, not a synchronous post-command read. Logging
must not access the controller from the calling thread.
"""
if not self.connected:
state = self._last_axes.get(axis)
if not self.connected or state is None or not state.read_ok:
return "dpos=unread mpos=unread status=unread enable=unread"
try:
return (
f"dpos={self._transport.get_dpos(axis)} "
f"mpos={self._transport.get_mpos(axis)} "
f"status={self._transport.get_axis_status(axis)} "
f"enable={self._transport.get_axis_enable(axis)} "
f"atype={self._transport.get_atype(axis)} "
f"units={self._transport.get_units(axis)} "
f"speed={self._transport.get_speed(axis)}"
)
except (ZAuxError, OSError) as exc:
return f"dpos=unread mpos=unread ({exc})"
return (
f"last_poll dpos={state.dpos} mpos={state.mpos} "
f"status={state.status.raw} enable={state.enabled} "
f"atype={state.atype} units={state.units} speed={state.speed}"
)
def _known_axis(self, axis: int) -> bool:
return 0 <= int(axis) < self.axis_count
@@ -1099,11 +1135,19 @@ class MotionService:
def to_mm(self, axis: int, user_units: float) -> float:
"""Displayed millimetres for a reading. Derived, never a measurement."""
units = self.params(axis).units or self._units_per_mm
if self.units_problem("x", axis):
return math.nan
units = self.params(axis).units
return user_units_to_mm(
user_units, units=units, pulses_per_mm=self._pulses_per_mm
)
def to_deg(self, axis: int, user_units: float) -> float:
"""Rotary readback in degrees, or NaN without matching calibrated units."""
if self.units_problem("yaw", axis):
return math.nan
return user_units_to_deg(axis, user_units, units=self.params(axis).units)
def _replace_bus_type(bus: BusStatus, bus_type: BusType) -> BusStatus:
return BusStatus(
@@ -34,6 +34,10 @@ ROTARY_UNITS_PER_90_DEG: dict[int, float] = {
3: 29500.0,
}
#: Pulse equivalent at which both rotary user-unit scales were measured.
#: Other equivalents require a new calibration; do not rescale commissioned moves.
ROTARY_CALIBRATION_UNITS = 10.0
def safe_float(text: object, fallback: float) -> float:
"""Parse ``text`` as a finite float, falling back on anything unusable.
@@ -80,25 +84,33 @@ def mm_to_user_units(
return float(value) * float(pulses_per_mm) / float(units)
def units_per_degree(axis: int) -> float:
def units_per_degree(axis: int, *, units: float = ROTARY_CALIBRATION_UNITS) -> float:
"""User units per degree for a rotary axis, or NaN if not calibrated."""
per_90 = ROTARY_UNITS_PER_90_DEG.get(int(axis))
if per_90 is None or per_90 <= 0:
if (
per_90 is None
or per_90 <= 0
or not math.isclose(units, ROTARY_CALIBRATION_UNITS, rel_tol=1e-7, abs_tol=0.0)
):
return math.nan
return float(per_90) / 90.0
def user_units_to_deg(axis: int, value: float) -> float:
def user_units_to_deg(
axis: int, value: float, *, units: float = ROTARY_CALIBRATION_UNITS
) -> float:
"""Convert controller user units to degrees for a calibrated rotary axis."""
scale = units_per_degree(axis)
scale = units_per_degree(axis, units=units)
if not math.isfinite(scale) or scale == 0.0:
return math.nan
return float(value) / scale
def deg_to_user_units(axis: int, degrees: float) -> float:
def deg_to_user_units(
axis: int, degrees: float, *, units: float = ROTARY_CALIBRATION_UNITS
) -> float:
"""Inverse of :func:`user_units_to_deg`."""
scale = units_per_degree(axis)
scale = units_per_degree(axis, units=units)
if not math.isfinite(scale):
return math.nan
return float(degrees) * scale
+3 -1
View File
@@ -857,6 +857,8 @@ class AutoCalMainWindow(QMainWindow):
# hold the worker thread and a stop would have to wait for the move it is
# meant to interrupt.
self.mover = PoseMover(self.poller.service, self.poller.submit, self)
self.poller.commandFinished.connect(self.mover.on_command_finished)
self.poller.commandFailed.connect(self.mover.on_command_failed)
self.mover.failed.connect(self._on_move_failed)
self.mover.finished.connect(self._on_move_finished)
self.mover.cancelled.connect(self._on_move_cancelled)
@@ -912,7 +914,7 @@ class AutoCalMainWindow(QMainWindow):
if state is None or not state.read_ok:
return None
if rotary:
return user_units_to_deg(int(axis), state.dpos)
return user_units_to_deg(int(axis), state.dpos, units=state.units)
units = state.units or float(self.prefs.get("units_per_mm") or DEFAULT_UNITS)
return user_units_to_mm(state.dpos, units=units)