fix(autocal): 校验运动单位并等待命令成功后判断到位
阻止单位不一致的姿态命令,避免提前到达的位置快照误报成功或推进下一轴。 相关检查 164 项通过,2064 组目标及 8256 条运动调用与原基线完全一致。
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user