校验 Autocal 运动当量并拒绝失配计划
保留已确认配置下的轴号、目标数值和发送顺序,拒绝不匹配的单位或过期计划。136 项相关检查通过,8256 条运动调用与修改前一致;全套既有失败见运动单位契约。
This commit is contained in:
@@ -0,0 +1,36 @@
|
|||||||
|
# 运动单位校验与现有输出保持
|
||||||
|
|
||||||
|
用途:当前实现契约。范围:架构审查两项 P1 的保守修复。旋转标定比例、X 经验校正、坐标轴方向、姿态算法、UV 映射和原有运动目标保持原值;三项 P2 按用户要求暂缓。
|
||||||
|
|
||||||
|
## 配置、读回与执行
|
||||||
|
|
||||||
|
[`units.py`](../src/orisys_autocal/motion/units.py) 拥有物理单位换算。线性换算使用已有 `PULSES_PER_MM` 与每轴预期 `UNITS`;旋转保持轴 2 的 12500、轴 3 的 29500 用户单位/90°,并显式绑定测量时 `ROTARY_CALIBRATION_UNITS=10`。改变旋转轴当量后没有新标定,则角度换算返回 NaN,姿态移动被拒绝,不按推算的新比例驱动机器。
|
||||||
|
|
||||||
|
[`MotionService`](../src/orisys_autocal/motion/service.py) 的 `_params` 表示操作者配置的预期参数,`_last_axes` 表示控制器最近成功轮询的实际状态。二者不是可以互相覆盖的默认值。物理单位移动要求预期与读回当量为有限正数且一致;相对容差为 `1e-7`。没有读回、读取失败或不一致时明确拒绝。`to_mm/to_deg` 在这些条件不满足时返回 NaN,避免用错误比例判定到位。
|
||||||
|
|
||||||
|
修改轴参数时立即使旧读回失效,即使参数写入只完成一部分后失败,也不能沿用旧状态。下一次成功轮询恢复观测;程序不自动把实际值当成新标定,也不自动改写操作者配置。
|
||||||
|
|
||||||
|
[`PoseMover`](../src/orisys_autocal/motion/pose_move.py) 启动前检查计划中的轴编号、物理目标和已换算的用户单位目标仍对应当前配置。工作线程执行前再次读各目标轴的 `UNITS`,全部校验完成后才按原顺序发送原绝对目标。配置或角色映射在排队期间改变时,旧计划被拒绝,要求重新规划;不会偷偷套用新设置。
|
||||||
|
|
||||||
|
移动器按命令 token 接收工作线程的拒绝或失败,立即标记该次移动失败,不再靠后续位置恰好相同判定到位。其他命令的结果不会取消当前计划;界面继续使用已有的移动失败处理和停止路径。
|
||||||
|
|
||||||
|
执行前增加的是只读参数检查,可能增加通信延迟,不保证与旧版完全相同的发送时刻。控制器被其他软件在最终读取和运动指令之间改动的并发窗口,本次也不能消除;操作期间仍需保证设备配置的单一写入者。
|
||||||
|
|
||||||
|
## 输出保持边界
|
||||||
|
|
||||||
|
| 已确认配置下保持的行为 | 新增的拒绝条件 |
|
||||||
|
|---|---|
|
||||||
|
| 原来的驱动轴编号、绝对目标和发送顺序 | 实际当量与预期不一致或无法读取 |
|
||||||
|
| 原有速度、加速度、对位与归零目标 | 旋转当量偏离已有标定条件 |
|
||||||
|
| X 的 `-10/11` 校正与 yaw/pitch 符号 | 排队后的轴映射或数值换算与原计划不符 |
|
||||||
|
| 原始用户单位的手动调试、jog、stop 与 home 接口 | 未对这些原始单位接口扩展物理单位保护 |
|
||||||
|
|
||||||
|
常驻读数和轴调试页的旋转轴毫米显示属于 P2,本次不修改。姿态实际角度显示会显式携带读回当量,未校准时不显示成有效角度。连续参考力录制及接入恒力执行器也不属于本次修复。
|
||||||
|
|
||||||
|
## 验证与限制
|
||||||
|
|
||||||
|
回归测试见 [`test_motion_unit_guards.py`](../tests/test_motion_unit_guards.py)。它覆盖既有五轴命令的确定数值和顺序、配置一致的线性换算、异常读回、旋转标定条件、排队期间配置改变、读取失败时整组零发送,以及原始单位调试移动的兼容性。现有测试夹具补充了真实流程所需的单位观测,没有降低断言或放宽限位。
|
||||||
|
|
||||||
|
修改前从本地 design 导出保存 1032 顶点,使用默认安装与当前偏好配置,共 2064 组姿态和规划目标。修改后逐元素严格相等,最大差值为 0;使用修改前源码与当前源码在记录型 DemoTransport 上分别执行相同计划,产生的 8256 条绝对运动调用也完全一致。这验证软件输出保持,不构成实机轨迹或物理接触精度证明。
|
||||||
|
|
||||||
|
最终相关检查 136 项通过;Autocal 全套为 720 项通过、3 项失败,无跳过。两项模拟采集失败及一项旧 scratch 路径假设与原始审查记录一致,本轮没有修复。回归测试在修复前复现单位风险,移除当量一致性判断的内存变异也被检出。详细结果见[架构审查](../../../../docs/platform/autocal-architecture-review.zh.md)。证据位于 app `.scratch/autocal-unit-guards/`,不属于长期归档。
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
"""Keep commissioned command numbers; reject unverified physical-unit moves."""
|
||||||
|
|
||||||
|
from dataclasses import replace
|
||||||
|
import math
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from orisys_autocal.motion.models import AxisParams, BusStatus, ConnectionSpec
|
||||||
|
from orisys_autocal.motion.pose_move import PoseMover, plan_pose_move
|
||||||
|
from orisys_autocal.motion.service import MotionService
|
||||||
|
from orisys_autocal.motion.transport import DemoTransport
|
||||||
|
from orisys_autocal.motion.units import deg_to_user_units, user_units_to_deg
|
||||||
|
from orisys_app_shared.zmotion import ZAuxError
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingTransport(DemoTransport):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.moves = []
|
||||||
|
|
||||||
|
def move_abs(self, axis, value):
|
||||||
|
self.moves.append((axis, value))
|
||||||
|
return super().move_abs(axis, value)
|
||||||
|
|
||||||
|
|
||||||
|
def service():
|
||||||
|
transport = RecordingTransport()
|
||||||
|
instance = MotionService(
|
||||||
|
transport,
|
||||||
|
language="en_US",
|
||||||
|
limits={"max_travel_mm": 100.0, "max_yaw_deg": 180.0, "max_pitch_deg": 90.0},
|
||||||
|
)
|
||||||
|
instance.connect(ConnectionSpec(mode="demo"))
|
||||||
|
instance.poll()
|
||||||
|
instance._bus = BusStatus(init_status=1, total_axes=5)
|
||||||
|
return instance, transport
|
||||||
|
|
||||||
|
|
||||||
|
def mover_and_actions(instance, targets):
|
||||||
|
actions = []
|
||||||
|
|
||||||
|
def submit(action):
|
||||||
|
actions.append(action)
|
||||||
|
return len(actions)
|
||||||
|
|
||||||
|
mover = PoseMover(instance, submit)
|
||||||
|
plan = plan_pose_move(instance, targets, together=True)
|
||||||
|
assert mover.start(plan).ok
|
||||||
|
return mover, actions
|
||||||
|
|
||||||
|
|
||||||
|
def test_commissioned_command_numbers_and_order_remain_exact(qt_app):
|
||||||
|
instance, transport = service()
|
||||||
|
mover, actions = mover_and_actions(
|
||||||
|
instance, {"x": -10.0, "y": 2.0, "yaw": 90.0, "pitch": -45.0, "z": 3.0}
|
||||||
|
)
|
||||||
|
assert actions[0](instance).ok
|
||||||
|
assert transport.moves == [
|
||||||
|
(2, 12500.0),
|
||||||
|
(3, -14750.0),
|
||||||
|
(0, -5000.0),
|
||||||
|
(1, 1000.0),
|
||||||
|
(4, 1500.0),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_readback_mismatch_refuses_instead_of_changing_the_command():
|
||||||
|
instance, transport = service()
|
||||||
|
transport.set_units(0, 5.0)
|
||||||
|
instance.poll([0])
|
||||||
|
instance._bus = BusStatus(init_status=1, total_axes=5)
|
||||||
|
refusal = instance.check_axes_move({"x": 2.0})
|
||||||
|
assert refusal is not None and "UNITS" in refusal.message
|
||||||
|
assert math.isnan(instance.to_mm(0, 1000.0))
|
||||||
|
assert transport.moves == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_matching_linear_units_are_supported_without_rescaling_other_axes():
|
||||||
|
instance, transport = service()
|
||||||
|
assert instance.set_axis_params(0, AxisParams(units=5.0)).ok
|
||||||
|
instance.poll([0])
|
||||||
|
instance._bus = BusStatus(init_status=1, total_axes=5)
|
||||||
|
assert instance.check_axes_move({"x": 2.0}) is None
|
||||||
|
assert instance.to_units("x", 2.0) == 2000.0
|
||||||
|
assert instance.to_mm(0, 1000.0) == 1.0
|
||||||
|
assert instance.to_units("yaw", 90.0) == 12500.0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("units", [0.0, -1.0, math.nan, math.inf])
|
||||||
|
def test_invalid_unit_readback_is_not_a_physical_measurement(units):
|
||||||
|
instance, transport = service()
|
||||||
|
transport.set_units(0, units)
|
||||||
|
instance.poll([0])
|
||||||
|
instance._bus = BusStatus(init_status=1, total_axes=5)
|
||||||
|
assert instance.check_axes_move({"x": 2.0}) is not None
|
||||||
|
assert math.isnan(instance.to_mm(0, 1000.0))
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_or_failed_readback_cannot_reuse_the_last_good_scale():
|
||||||
|
instance, transport = service()
|
||||||
|
instance._last_axes.clear()
|
||||||
|
assert instance.check_axes_move({"x": 2.0}) is not None
|
||||||
|
instance.poll([0])
|
||||||
|
instance._last_axes[0] = replace(instance._last_axes[0], read_ok=False)
|
||||||
|
assert instance.check_axes_move({"x": 2.0}) is not None
|
||||||
|
assert math.isnan(instance.to_mm(0, 1000.0))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("axis,role", [(2, "yaw"), (3, "pitch")])
|
||||||
|
def test_changed_rotary_pulse_equivalent_requires_new_calibration(axis, role):
|
||||||
|
instance, transport = service()
|
||||||
|
assert instance.set_axis_params(axis, AxisParams(units=5.0)).ok
|
||||||
|
instance.poll([axis])
|
||||||
|
instance._bus = BusStatus(init_status=1, total_axes=5)
|
||||||
|
refusal = instance.check_axes_move({role: 45.0})
|
||||||
|
assert refusal is not None and "calibrat" in refusal.message
|
||||||
|
assert math.isnan(instance.to_units(role, 45.0))
|
||||||
|
assert math.isnan(instance.to_deg(axis, 1000.0))
|
||||||
|
assert math.isnan(user_units_to_deg(axis, 1000.0, units=5.0))
|
||||||
|
assert math.isnan(deg_to_user_units(axis, 45.0, units=5.0))
|
||||||
|
|
||||||
|
|
||||||
|
def test_change_after_queueing_is_refused_before_any_axis_moves(qt_app):
|
||||||
|
instance, transport = service()
|
||||||
|
mover, actions = mover_and_actions(instance, {"x": 2.0, "yaw": 10.0})
|
||||||
|
# No poll between the external parameter change and worker execution.
|
||||||
|
transport.set_units(0, 5.0)
|
||||||
|
result = actions[0](instance)
|
||||||
|
assert not result.ok
|
||||||
|
mover.on_command_finished(1, result)
|
||||||
|
assert not mover.running and "UNITS" in mover.state.failure
|
||||||
|
assert transport.moves == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_unrelated_command_result_does_not_cancel_a_pose_move(qt_app):
|
||||||
|
from orisys_autocal.motion.models import MotionResult
|
||||||
|
|
||||||
|
instance, transport = service()
|
||||||
|
mover, actions = mover_and_actions(instance, {"x": 2.0})
|
||||||
|
mover.on_command_finished(99, MotionResult.refused("another command"))
|
||||||
|
assert mover.running
|
||||||
|
assert actions[0](instance).ok
|
||||||
|
mover.on_command_failed(1, "read failed")
|
||||||
|
assert not mover.running and mover.state.failure == "read failed"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("change", ["parameters", "mapping"])
|
||||||
|
def test_a_queued_plan_cannot_be_reinterpreted_under_new_settings(qt_app, change):
|
||||||
|
instance, transport = service()
|
||||||
|
mover, actions = mover_and_actions(instance, {"x": 2.0, "y": 1.0})
|
||||||
|
if change == "parameters":
|
||||||
|
instance.set_axis_params(0, AxisParams(units=5.0))
|
||||||
|
else:
|
||||||
|
instance.set_axis_roles({"x": 1, "y": 0, "yaw": 2, "pitch": 3, "z": 4})
|
||||||
|
assert not actions[0](instance).ok
|
||||||
|
assert transport.moves == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_stale_plan_is_refused_before_it_is_queued(qt_app):
|
||||||
|
instance, transport = service()
|
||||||
|
plan = plan_pose_move(instance, {"x": 2.0}, together=True)
|
||||||
|
instance.set_axis_params(0, AxisParams(units=5.0))
|
||||||
|
instance.poll([0])
|
||||||
|
instance._bus = BusStatus(init_status=1, total_axes=5)
|
||||||
|
actions = []
|
||||||
|
mover = PoseMover(instance, lambda action: actions.append(action))
|
||||||
|
assert not mover.start(plan).ok
|
||||||
|
assert actions == [] and transport.moves == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_unit_read_error_before_execution_refuses_the_entire_group(qt_app):
|
||||||
|
instance, transport = service()
|
||||||
|
mover, actions = mover_and_actions(instance, {"x": 2.0, "yaw": 10.0})
|
||||||
|
|
||||||
|
def unreadable(axis):
|
||||||
|
raise ZAuxError(30500, "unit read failed")
|
||||||
|
|
||||||
|
transport.get_units = unreadable
|
||||||
|
assert not actions[0](instance).ok
|
||||||
|
assert transport.moves == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_raw_debug_moves_keep_the_existing_user_unit_contract():
|
||||||
|
instance, transport = service()
|
||||||
|
transport.set_units(2, 5.0)
|
||||||
|
assert instance.move_abs(2, 90.0).ok
|
||||||
|
assert transport.moves == [(2, 90.0)]
|
||||||
Reference in New Issue
Block a user