Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbfb306903 | ||
|
|
4d8a9b7724 | ||
|
|
d043f2709d | ||
|
|
84c96d7cf8 | ||
|
|
0df9e9bcfc | ||
|
|
4f6981d08b | ||
|
|
2c128c1f54 | ||
|
|
6d22d5600a | ||
|
|
f795c06d44 | ||
|
|
08996434e5 | ||
|
|
687a0b401a | ||
|
|
2a12eea4d5 | ||
|
|
fae5a560fb | ||
|
|
bfd50e1035 |
@@ -205,6 +205,7 @@
|
||||
* 类型标注方式。
|
||||
* 注释风格。
|
||||
* 测试组织方式。
|
||||
* 新生成的 Markdown(`.md`)文档统一使用中文撰写。
|
||||
|
||||
如果项目已有 lint、format 或 test 命令,优先使用项目已有命令,不要擅自更换工具链。
|
||||
|
||||
@@ -227,7 +228,9 @@
|
||||
|
||||
## Git 与提交
|
||||
|
||||
除非用户明确要求,否则不要自动提交、推送、创建分支或修改远程仓库。
|
||||
除非用户明确要求,否则不要自动提交、推送或修改远程仓库。
|
||||
|
||||
使用 Superpowers 执行计划时,允许 subagent 按相关 skill 创建和使用独立 worktree 及其配套本地分支;其他情况下,除非用户明确要求,不要自动创建分支。
|
||||
|
||||
如果用户要求生成提交信息,提交信息应:
|
||||
|
||||
|
||||
@@ -7,11 +7,12 @@ PICO/XR 双手柄 UDP JSON
|
||||
-> xr_rm_input/udp_controller_receiver
|
||||
-> /xr/left_controller 与 /xr/right_controller
|
||||
-> xr_rm_teleop/single_arm_velocity_teleop
|
||||
-> 左右 RM75 笛卡尔相对位姿透传控制
|
||||
-> Placo QP 单步逆解
|
||||
-> 左右 RM75 七关节角透传控制
|
||||
-> /xr_rm/<arm_name>/current_pose、raw_target_pose、target_pose、cmd_vel、target_clamped 调试话题
|
||||
```
|
||||
|
||||
当前控制方式是“手柄相对位姿透传”遥操作:按住 `grip` 时锁定当前手柄位姿和机械臂 TCP 位姿,之后根据手柄相对位移和相对旋转生成目标 TCP,经过工作空间限幅、目标低通、姿态低通和单帧步长限制后,通过 `rm_movep_canfd` 下发目标位姿。松开 `grip`、UDP 超时或节点退出时会请求机械臂慢停。
|
||||
当前控制方式是“手柄相对位姿 + 单步 QP”遥操作:按住 `grip` 时锁定当前手柄位姿和机械臂 TCP 位姿,之后根据手柄相对位移和相对旋转生成目标 TCP。姿态目标使用旋转矩阵和 SO(3) 最短路径完成死区、滤波与限速,不经过 RPY。每个控制周期执行一次 Placo QP,并通过 `rm_movej_canfd` 下发 7 个关节目标。松开 `grip`、UDP 或关节反馈超时、节点退出时会请求机械臂慢停。
|
||||
|
||||
## 当前范围
|
||||
|
||||
@@ -19,7 +20,8 @@ PICO/XR 双手柄 UDP JSON
|
||||
|
||||
- PICO/XR 手柄 UDP 数据接收,并分发到左右手柄 ROS2 话题。
|
||||
- 通过统一的 `arm_debug.launch.py` 支持左臂、右臂、双臂的 mock 调试和真机调试。
|
||||
- RM75 真机连接适配,包含 `rm_movep_canfd` 位姿透传、安全速度/加速度配置、可选初始化点位移动。
|
||||
- RM75 真机连接适配,包含关节反馈缓存、`rm_movej_canfd` 关节透传、安全速度/加速度配置、可选初始化点位移动。
|
||||
- Placo 0.9.4 RM75 QP 逆解;收到首帧有效关节反馈后才启用,求解失败时保留上一组有效关节目标。
|
||||
- 真机模式下,点击对应手柄 `trigger` 可切换并保持对应夹爪开/关状态。
|
||||
- Tkinter 启动面板 `launcher_ui.py`,用于现场快速启动、监控 topic、检查环境和清理进程。
|
||||
- 自定义 PICO 4 Ultra UDP Sender Unity 工程,负责发送左右手柄 pose、`grip`、`trigger` 和 pose 诊断字段。
|
||||
@@ -67,7 +69,11 @@ src/
|
||||
│ └── msg/
|
||||
│ └── XrController.msg # hand/grip/trigger/pose
|
||||
└── xr_rm_teleop/
|
||||
├── models/
|
||||
│ ├── rm75/ # 旧 RM75 模型资源(launch 不再选用)
|
||||
│ └── rm75_omnipicker/ # RM75 + OmniPicker fixed URDF 与网格
|
||||
└── xr_rm_teleop/
|
||||
├── placo_ik_solver.py # Placo 0.9.4 单步 QP 逆解
|
||||
├── single_arm_velocity_teleop.py
|
||||
├── realman_adapter.py
|
||||
└── fun_peripheral.py
|
||||
@@ -90,6 +96,17 @@ source install/setup.bash
|
||||
|
||||
真机模式还需要安装睿尔曼 Python API2。若未安装,mock 模式仍可正常使用;真机启动时会提示缺少 `Robotic_Arm` 包。
|
||||
|
||||
遥操作节点固定由 `/home/robot/miniconda3/envs/xr/bin/python` 启动,并复用其中的 Python 3.10、Placo 0.9.4、Pinocchio 3.7.0 和 NumPy 2.2.6。`ros2`、`colcon` 和 `udp_controller_receiver` 仍使用系统 Python。禁止通过 `pip --user`、`sudo pip` 或系统安装升级 Placo、Pinocchio、EigenPy 和 NumPy。
|
||||
|
||||
只读检查 Placo 版本:
|
||||
|
||||
```bash
|
||||
/home/robot/miniconda3/envs/xr/bin/python -c \
|
||||
"import importlib.metadata; print(importlib.metadata.version('placo'))"
|
||||
```
|
||||
|
||||
输出必须为 `0.9.4`。
|
||||
|
||||
如果希望 `launcher_ui.py` 从任意目录找到工作空间,可以设置:
|
||||
|
||||
```bash
|
||||
@@ -150,17 +167,25 @@ sudo update-alternatives --config x-terminal-emulator
|
||||
|
||||
打开 `launcher_ui.py`,点击 `Check Env`。如果 `install/setup.bash` 缺失,先回工作空间根目录重新执行 `colcon build --symlink-install`。
|
||||
|
||||
第二步:跑 mock 闭环。
|
||||
第二步:分别跑左、右臂 mock 闭环。
|
||||
|
||||
在 `Simulation` 模式运行 `One-Click Dual Mock Demo`,或分开运行:
|
||||
分两个终端依次验证左臂:
|
||||
|
||||
```bash
|
||||
ros2 launch xr_rm_bringup arm_debug.launch.py arm:=both use_mock:=true
|
||||
ros2 run xr_rm_input sample_udp_sender --hand both --host 127.0.0.1 --port 15000 \
|
||||
--pattern axis_sweep --seconds 60 --both-mode staggered
|
||||
ros2 launch xr_rm_bringup arm_debug.launch.py arm:=left use_mock:=true
|
||||
ros2 run xr_rm_input sample_udp_sender --hand left --host 127.0.0.1 --port 15000 \
|
||||
--pattern axis_sweep --seconds 30
|
||||
```
|
||||
|
||||
`sample_udp_sender` 默认使用 `axis_sweep` 成对扫轴轨迹,并在终端打印 `XR +X/-X/+Y/-Y/+Z/-Z` 标签。`--hand both --both-mode staggered --seconds 60` 会先左后右,适合肉眼确认左右臂方向;如果只想左右同时动,可用 `--both-mode synchronized`。需要检查末端姿态时可增加 `--rotation-pattern rpy_steps --rotation-amplitude-deg 25`。
|
||||
停止左臂进程后,再分别验证右臂:
|
||||
|
||||
```bash
|
||||
ros2 launch xr_rm_bringup arm_debug.launch.py arm:=right use_mock:=true
|
||||
ros2 run xr_rm_input sample_udp_sender --hand right --host 127.0.0.1 --port 15000 \
|
||||
--pattern axis_sweep --seconds 30
|
||||
```
|
||||
|
||||
`sample_udp_sender` 默认使用 `axis_sweep` 扫轴轨迹,并在终端打印 `XR +X/-X/+Y/-Y/+Z/-Z` 标签。需要检查末端姿态时可增加 `--rotation-pattern rpy_steps --rotation-amplitude-deg 25`。
|
||||
|
||||
观察:
|
||||
|
||||
@@ -182,23 +207,18 @@ ros2 launch xr_rm_bringup arm_debug.launch.py arm:=left use_mock:=false
|
||||
ros2 launch xr_rm_bringup arm_debug.launch.py arm:=right use_mock:=false
|
||||
```
|
||||
|
||||
单臂真机默认执行配置文件中的 `movej(initial_joint_pose)`;现场需要跳过时,显式传入
|
||||
`move_to_initial_pose_on_connect:=false`。
|
||||
所有 YAML 默认都不会执行 `movej(initial_joint_pose)`。只有确认安全区清空后,才可在当前使用的
|
||||
`left_arm_rm75.yaml`、`right_arm_rm75.yaml` 或 `dual_arm_rm75.yaml` 中将
|
||||
`move_to_initial_pose_on_connect` 改为 `true`。
|
||||
|
||||
第四步:双臂真机。
|
||||
|
||||
```bash
|
||||
ros2 launch xr_rm_bringup arm_debug.launch.py arm:=both use_mock:=false \
|
||||
left_robot_ip:=192.168.192.18 \
|
||||
right_robot_ip:=192.168.192.19
|
||||
ros2 launch xr_rm_bringup arm_debug.launch.py arm:=both use_mock:=false
|
||||
```
|
||||
|
||||
双臂默认不会自动移动到初始化点。以后需要启用时,在确认安全区清空后显式打开:
|
||||
|
||||
```bash
|
||||
ros2 launch xr_rm_bringup arm_debug.launch.py arm:=both use_mock:=false \
|
||||
move_to_initial_pose_on_connect:=true
|
||||
```
|
||||
双臂默认不会自动移动到初始化点;机器人地址、控制参数和初始化移动开关均从
|
||||
`dual_arm_rm75.yaml` 读取。
|
||||
|
||||
## Launch 入口说明
|
||||
|
||||
@@ -211,20 +231,9 @@ ros2 launch xr_rm_bringup arm_debug.launch.py arm:=both use_mock:=false \
|
||||
- `udp_host`:UDP 监听地址,默认 `0.0.0.0`。
|
||||
- `udp_port`:UDP 监听端口,默认 `15000`。
|
||||
- `udp_timer_hz`:UDP receiver 轮询频率,默认 `200.0`。
|
||||
- `left_robot_ip`:左臂 IP,默认 `192.168.192.18`。
|
||||
- `right_robot_ip`:右臂 IP,默认 `192.168.192.19`。
|
||||
- `robot_port`:RM75 TCP 端口,默认 `8080`。
|
||||
- `left_avoid_singularity` / `right_avoid_singularity`:左右臂避奇异参数,默认左 `0`、右 `1`。
|
||||
- `avoid_singularity`:非空时覆盖左右臂避奇异参数。
|
||||
- `frame_type`:`rm_movep_canfd` 坐标系类型,默认 `1`。
|
||||
- `control_rate_hz`:`rm_movep_canfd` 目标位姿发送频率,默认 `90.0`。
|
||||
- `follow`:传给 `rm_movep_canfd` 的跟随标志,默认 `false`。
|
||||
- `configure_safety_limits`:连接真机后是否配置速度/加速度安全参数,默认 `true`。
|
||||
- `enable_tool_control`:是否在遥操作节点内启用末端工具控制 topic,默认 `true`。
|
||||
- `enable_trigger_gripper_control`:是否允许用 `trigger` 点击切换对应夹爪状态,默认 `true`。
|
||||
- `trigger_close_threshold`:trigger 点击判定阈值,默认 `0.95`。
|
||||
- `configure_peripheral_on_connect`:遥操作节点连接真机后是否配置末端外设,默认 `true`;工具控制会复用同一个 RealMan 连接,避免两个进程同时抢占同一机械臂。
|
||||
- `move_to_initial_pose_on_connect`:连接后是否执行 `movej(initial_joint_pose)`;默认 `auto`,单臂配置启用、双臂配置禁用,也可显式传 `true`/`false` 覆盖。
|
||||
|
||||
机器人 IP/端口、控制频率、CANFD、限速、工具和初始化位姿等行为参数只由对应 YAML
|
||||
配置,launch 不再提供同名覆盖项。
|
||||
|
||||
## 配置文件说明
|
||||
|
||||
@@ -235,7 +244,7 @@ ros2 launch xr_rm_bringup arm_debug.launch.py arm:=both use_mock:=false \
|
||||
|
||||
`left_arm_rm75.yaml` 和 `right_arm_rm75.yaml` 用于 `arm_debug.launch.py arm:=left/right` 的单臂调试,因为单臂节点名是 `single_arm_velocity_teleop`。
|
||||
|
||||
`xr_rm_bringup/config/peripherals_rm75.yaml` 保存末端工具坐标、负载和左右臂外设选择。当前配置为左臂 `scissorgripper=2`、右臂 `scissorgripper=1`,真机连接阶段会初始化外设,后续开合命令复用同一个 RealMan 连接。
|
||||
`xr_rm_bringup/config/peripherals_rm75.yaml` 保存真实控制器使用的末端工具坐标、负载和左右臂外设选择,文件内容保持原状。Placo 使用 `xr_rm_teleop/models/rm75_omnipicker` 中的一体化 fixed URDF,直接控制相对 `omnipicker_base_link` 沿 `+Z` 偏移 `0.16 m` 的 `omnipicker_tcp`,不再把外设 YAML 的工具位姿重复转换到 QP。真机连接阶段仍会初始化外设,关节反馈、关节指令、慢停和开合命令复用该单臂节点的同一个 RealMan 连接。
|
||||
|
||||
重点控制参数:
|
||||
|
||||
@@ -245,14 +254,19 @@ ros2 launch xr_rm_bringup arm_debug.launch.py arm:=both use_mock:=false \
|
||||
- `target_filter_fast_threshold_m`:进入快速滤波区间的目标变化阈值。
|
||||
- `max_linear_speed`:目标位姿单帧步长限制对应的最大线速度。
|
||||
- `enable_orientation_control`:是否把手柄相对旋转映射到 TCP 姿态。
|
||||
- `orientation_filter_alpha` / `orientation_deadband_rad`:目标 TCP 姿态低通和死区。
|
||||
- `max_orientation_speed`:目标姿态单帧步长限制对应的最大角速度。
|
||||
- `orientation_filter_alpha` / `orientation_deadband_rad`:按 SO(3) 最短旋转角处理的目标 TCP 姿态滤波和死区。
|
||||
- `max_orientation_speed`:目标 TCP 姿态沿 SO(3) 最短路径的最大角速度,当前为 `0.5 rad/s`。
|
||||
- `workspace_min` / `workspace_max`:笛卡尔工作空间边界。
|
||||
- `cyl_radius_limit`:基座圆柱半径限制。
|
||||
- `xr_to_robot_matrix`:`/xr/*_controller` Project 位移到 RM75 base 坐标的映射矩阵。
|
||||
- `current_pose_poll_hz`:低频读取真机当前 TCP 的频率;控制中不再每帧阻塞读取状态。
|
||||
- `mock_initial_pose`:mock 模式初始 TCP 位姿。
|
||||
- `initial_joint_pose`:可选真机初始关节角。
|
||||
- `robot_ip` / `robot_port`:RM75 TCP 控制连接地址。
|
||||
- `realtime_push_host_ip`:连接机械臂 Wi-Fi 后本机实际 IPv4;可用
|
||||
`ip -4 route get 192.168.192.19` 查看输出中的 `src`,当前为 `192.168.192.148`。
|
||||
- `realtime_push_port`:UDP 主动反馈端口;左臂 `8089`、右臂 `8090`,同机双臂不能重复。
|
||||
- `realtime_push_cycle_ms`:UDP 主动反馈周期,当前为厂商支持的 `5 ms`。
|
||||
- `follow` / `canfd_trajectory_mode`:`rm_movej_canfd` 的高跟随和轨迹模式参数。
|
||||
- 当前三份 YAML 默认均使用 `follow: false` 完成安全基线验证;确认关节加速度与反馈稳定后,再单独测试高跟随。
|
||||
- `initial_joint_pose`:mock 的初始关节反馈,以及显式开启初始化移动时的真机初始关节角。
|
||||
|
||||
当前 `/xr/*_controller` 的 Project 坐标约定:
|
||||
|
||||
@@ -410,7 +424,7 @@ ros2 topic echo /xr/right_controller --field trigger
|
||||
|
||||
1. 确认急停、网络、机械臂工作区和人员位置。
|
||||
2. `launcher_ui.py` 中先 `Ping Left RM75` 或 `Ping Right RM75`。
|
||||
3. 单臂启动,`move_to_initial_pose_on_connect:=false`。
|
||||
3. 确认对应 YAML 中 `move_to_initial_pose_on_connect: false` 后单臂启动。
|
||||
4. 手握急停,按住 `grip` 后只做小幅单轴移动。
|
||||
5. 逐个确认上/下、前/后、左/右方向。
|
||||
6. 小角度转动手柄,确认 `/xr_rm/<arm>/target_pose` 姿态和 `/xr_rm/<arm>/cmd_vel.twist.angular` 变化符合预期。
|
||||
|
||||
@@ -0,0 +1,964 @@
|
||||
# RM75 关节反馈与故障恢复实施计划
|
||||
|
||||
> **执行要求:** 使用 `superpowers:executing-plans` 按任务逐项实施。只有用户明确授权 subagent 后,才允许使用 `superpowers:subagent-driven-development`、独立 worktree 或本地分支。所有步骤使用复选框跟踪。
|
||||
|
||||
**目标:** 启动时用 `rm_get_joint_degree()` 初始化 RM75 QP;运行时以 UDP `joint_position` 作为实际反馈;短暂丢包时保持最后安全目标;持续丢包或 CANFD 错误时安全同步、停止或等待人工重新使能。
|
||||
|
||||
**实现方式:** 继续复用现有唯一 `RealManAdapter` 连接,只增加一个同步读取关节角的方法。恢复决策仍放在 `SingleArmVelocityTeleop`,用少量布尔状态复用现有 slow-stop、Grip 重新使能和关节限速逻辑,不新增状态机类、线程、连接或依赖。
|
||||
|
||||
**技术栈:** Python 3.10、ROS2 Humble `rclpy`、睿尔曼 Python API2、Placo、pytest、ament/colcon。
|
||||
|
||||
**设计文档:** `docs/superpowers/specs/2026-07-29-rm75-feedback-recovery-design.md`
|
||||
|
||||
**厂商接口依据:**
|
||||
|
||||
- `rm_get_joint_degree() -> tuple[int, list[float]]`:<https://develop.realman-robotics.com/robot/apipython/classes/armState/>
|
||||
- `rm_movej_canfd(..., follow=False, ...)` 为低跟随:<https://develop.realman-robotics.com/robot/apipython/classes/movePlan/>
|
||||
|
||||
## 仓库约束
|
||||
|
||||
- 所有构建、测试和启动命令均在工作空间根目录 `/home/robot/WS_xr` 执行。
|
||||
- 所有 Git 命令均在仓库根目录 `/home/robot/WS_xr/src` 执行。
|
||||
- 每次 ROS2 构建、测试或启动前先执行 `source /opt/ros/humble/setup.bash`。
|
||||
- 不自动提交、推送、创建分支或 worktree;只有用户明确要求后才执行。
|
||||
- 验证只通过 `xr_rm_bringup/launch/arm_debug.launch.py use_mock:=true`。
|
||||
- 不连接真机,不发送真实 CANFD,不移动机械臂,不操作夹爪或末端外设。
|
||||
- 不修改依赖、锁文件、CI、格式化配置、公开入口和无关代码。
|
||||
|
||||
## 文件范围
|
||||
|
||||
- 修改 `src/xr_rm_teleop/xr_rm_teleop/realman_adapter.py`
|
||||
- 增加同步关节查询。
|
||||
- 复用 UDP 与同步查询的角度校验。
|
||||
- 修改 `src/xr_rm_teleop/xr_rm_teleop/single_arm_velocity_teleop.py`
|
||||
- 启动同步、UDP保持/恢复、CANFD恢复和故障锁存。
|
||||
- 修改 `src/xr_rm_teleop/test/test_initial_joint_pose.py`
|
||||
- 适配器同步查询测试。
|
||||
- 修改 `src/xr_rm_teleop/test/test_joint_control.py`
|
||||
- 启动、超时、恢复和锁存测试。
|
||||
- 同步修改:
|
||||
- `src/xr_rm_bringup/config/dual_arm_rm75.yaml`
|
||||
- `src/xr_rm_bringup/config/left_arm_rm75.yaml`
|
||||
- `src/xr_rm_bringup/config/right_arm_rm75.yaml`
|
||||
|
||||
---
|
||||
|
||||
## 任务一:给现有适配器增加同步关节查询
|
||||
|
||||
**修改文件:**
|
||||
|
||||
- `src/xr_rm_teleop/test/test_initial_joint_pose.py`
|
||||
- `src/xr_rm_teleop/xr_rm_teleop/realman_adapter.py`
|
||||
|
||||
- [x] **步骤1:先写失败测试**
|
||||
|
||||
在 `test_initial_joint_pose.py` 增加:
|
||||
|
||||
```python
|
||||
def test_joint_degree_query_returns_validated_radians() -> None:
|
||||
class FakeArm:
|
||||
def rm_get_joint_degree(self):
|
||||
return 0, [0.0, 10.0, -20.0, 30.0, -40.0, 50.0, -60.0]
|
||||
|
||||
adapter = RealManAdapter(
|
||||
"127.0.0.1",
|
||||
8080,
|
||||
0,
|
||||
"127.0.0.1",
|
||||
8090,
|
||||
)
|
||||
adapter._arm = FakeArm()
|
||||
|
||||
snapshot = adapter.read_joint_state()
|
||||
|
||||
assert snapshot.positions == pytest.approx(
|
||||
[math.radians(value) for value in [0, 10, -20, 30, -40, 50, -60]]
|
||||
)
|
||||
assert snapshot.read_duration_ms is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"result",
|
||||
[
|
||||
(7, [0.0] * 7),
|
||||
(0, [0.0] * 6),
|
||||
(0, [0.0, 0.0, 0.0, math.nan, 0.0, 0.0, 0.0]),
|
||||
],
|
||||
)
|
||||
def test_joint_degree_query_rejects_sdk_errors_and_invalid_values(result) -> None:
|
||||
adapter = RealManAdapter(
|
||||
"127.0.0.1",
|
||||
8080,
|
||||
0,
|
||||
"127.0.0.1",
|
||||
8090,
|
||||
)
|
||||
adapter._arm = SimpleNamespace(rm_get_joint_degree=lambda: result)
|
||||
|
||||
with pytest.raises((RuntimeError, ValueError)):
|
||||
adapter.read_joint_state()
|
||||
|
||||
|
||||
def test_mock_joint_query_uses_current_mock_positions() -> None:
|
||||
adapter = MockRealManAdapter([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0])
|
||||
|
||||
snapshot = adapter.read_joint_state()
|
||||
|
||||
assert snapshot.positions == pytest.approx(
|
||||
[math.radians(value) for value in [1, 2, 3, 4, 5, 6, 7]]
|
||||
)
|
||||
```
|
||||
|
||||
把现有 `test_invalid_udp_feedback_does_not_replace_snapshot` 的最终断言改为:
|
||||
|
||||
```python
|
||||
after = adapter.get_latest_joint_state()
|
||||
assert after is not None
|
||||
assert before is not None
|
||||
assert after.positions == before.positions
|
||||
assert after.received_at == before.received_at
|
||||
assert after.motion_ready is False
|
||||
```
|
||||
|
||||
该断言要求无效 UDP 帧保留最后已知角度,但立即禁止这些角度继续参与运动。
|
||||
|
||||
- [x] **步骤2:运行测试并确认 RED**
|
||||
|
||||
```bash
|
||||
source /opt/ros/humble/setup.bash
|
||||
python3 -m pytest src/xr_rm_teleop/test/test_initial_joint_pose.py \
|
||||
-k 'joint_degree_query or mock_joint_query or invalid_udp_feedback' -v
|
||||
```
|
||||
|
||||
预期:测试失败;原因是适配器还没有 `read_joint_state()`,且无效 UDP 帧仍被标记为可运动。
|
||||
|
||||
- [x] **步骤3:实现最小同步查询**
|
||||
|
||||
给 `MockRealManAdapter` 增加:
|
||||
|
||||
```python
|
||||
def read_joint_state(self) -> JointStateSnapshot:
|
||||
return self.get_latest_joint_state()
|
||||
```
|
||||
|
||||
给 `RealManAdapter` 增加:
|
||||
|
||||
```python
|
||||
def read_joint_state(self) -> JointStateSnapshot:
|
||||
self._require_arm()
|
||||
started_at = time.monotonic()
|
||||
result = self._arm.rm_get_joint_degree()
|
||||
finished_at = time.monotonic()
|
||||
if not isinstance(result, tuple) or len(result) != 2:
|
||||
raise RuntimeError(
|
||||
f"rm_get_joint_degree returned invalid result: {result!r}"
|
||||
)
|
||||
self._check_return(result, "rm_get_joint_degree")
|
||||
return JointStateSnapshot(
|
||||
self._joint_positions_from_degrees(
|
||||
result[1],
|
||||
"rm_get_joint_degree",
|
||||
),
|
||||
finished_at,
|
||||
(finished_at - started_at) * 1000.0,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _joint_positions_from_degrees(
|
||||
values: Any,
|
||||
source: str,
|
||||
) -> list[float]:
|
||||
try:
|
||||
degrees = list(values)
|
||||
except TypeError as exc:
|
||||
raise ValueError(f"{source} must contain 7 numeric joints") from exc
|
||||
if len(degrees) != 7 or not all(
|
||||
isinstance(value, Number) for value in degrees
|
||||
):
|
||||
raise ValueError(f"{source} must contain 7 numeric joints")
|
||||
positions = [math.radians(float(value)) for value in degrees]
|
||||
if not all(math.isfinite(value) for value in positions):
|
||||
raise ValueError(f"{source} contains NaN/Inf")
|
||||
return positions
|
||||
```
|
||||
|
||||
把 UDP 回调中重复的角度转换替换为:
|
||||
|
||||
```python
|
||||
positions = self._joint_positions_from_degrees(
|
||||
data.joint_status.joint_position,
|
||||
"RM75 UDP feedback",
|
||||
)
|
||||
```
|
||||
|
||||
在 UDP 回调的异常分支中,保留最后角度但标记为不可运动:
|
||||
|
||||
```python
|
||||
with self._joint_state_lock:
|
||||
if self._latest_joint_state is not None:
|
||||
current = self._latest_joint_state
|
||||
self._latest_joint_state = JointStateSnapshot(
|
||||
list(current.positions),
|
||||
current.received_at,
|
||||
current.read_duration_ms,
|
||||
current.update_interval_ms,
|
||||
False,
|
||||
)
|
||||
```
|
||||
|
||||
- [x] **步骤4:运行适配器测试并确认 GREEN**
|
||||
|
||||
```bash
|
||||
source /opt/ros/humble/setup.bash
|
||||
python3 -m pytest src/xr_rm_teleop/test/test_initial_joint_pose.py -v
|
||||
```
|
||||
|
||||
预期:该文件全部测试通过。
|
||||
|
||||
- [x] **步骤5:检查本任务差异**
|
||||
|
||||
```bash
|
||||
git diff --check
|
||||
git diff -- \
|
||||
xr_rm_teleop/xr_rm_teleop/realman_adapter.py \
|
||||
xr_rm_teleop/test/test_initial_joint_pose.py
|
||||
```
|
||||
|
||||
预期:只有同步查询、共用角度校验、无效反馈安全标记及对应测试。
|
||||
|
||||
---
|
||||
|
||||
## 任务二:用启动查询结果初始化 QP 和关节命令历史
|
||||
|
||||
**修改文件:**
|
||||
|
||||
- `src/xr_rm_teleop/test/test_joint_control.py`
|
||||
- `src/xr_rm_teleop/xr_rm_teleop/single_arm_velocity_teleop.py`
|
||||
|
||||
- [x] **步骤1:先写启动同步失败测试**
|
||||
|
||||
增加:
|
||||
|
||||
```python
|
||||
def test_startup_joint_query_initializes_qp_and_command_history() -> None:
|
||||
positions = [0.1] * 7
|
||||
pose = np.eye(4)
|
||||
teleop = object.__new__(SingleArmVelocityTeleop)
|
||||
teleop._arm_name = "right_rm75"
|
||||
teleop._adapter = SimpleNamespace(
|
||||
read_joint_state=lambda: JointStateSnapshot(
|
||||
positions,
|
||||
time.monotonic(),
|
||||
)
|
||||
)
|
||||
teleop._ik_solver = SimpleNamespace(
|
||||
update_joint_state=lambda joints: pose
|
||||
)
|
||||
teleop.get_logger = lambda: FakeLogger()
|
||||
|
||||
teleop._initialize_joint_state()
|
||||
|
||||
assert teleop._latest_joint_positions == positions
|
||||
assert teleop._last_valid_joint_target == positions
|
||||
assert teleop._last_joint_command_target == positions
|
||||
assert teleop._last_joint_command_velocity == [0.0] * 7
|
||||
assert teleop._last_current_pose is pose
|
||||
|
||||
|
||||
def test_startup_joint_query_failure_closes_adapter() -> None:
|
||||
class FailingAdapter:
|
||||
def __init__(self):
|
||||
self.close_calls = 0
|
||||
|
||||
def read_joint_state(self):
|
||||
raise RuntimeError("rm_get_joint_degree failed with code 7")
|
||||
|
||||
def close(self):
|
||||
self.close_calls += 1
|
||||
|
||||
errors = []
|
||||
teleop = object.__new__(SingleArmVelocityTeleop)
|
||||
teleop._arm_name = "left_rm75"
|
||||
teleop._adapter = FailingAdapter()
|
||||
teleop.get_logger = lambda: SimpleNamespace(
|
||||
error=lambda message: errors.append(message)
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="code 7"):
|
||||
teleop._initialize_joint_state()
|
||||
|
||||
assert teleop._adapter.close_calls == 1
|
||||
assert "left_rm75" in errors[0]
|
||||
assert "启动关节同步失败" in errors[0]
|
||||
```
|
||||
|
||||
- [x] **步骤2:运行测试并确认 RED**
|
||||
|
||||
```bash
|
||||
source /opt/ros/humble/setup.bash
|
||||
python3 -m pytest src/xr_rm_teleop/test/test_joint_control.py \
|
||||
-k 'startup_joint_query' -v
|
||||
```
|
||||
|
||||
预期:测试因 `_initialize_joint_state()` 尚不存在而失败。
|
||||
|
||||
- [x] **步骤3:增加启动同步**
|
||||
|
||||
增加:
|
||||
|
||||
```python
|
||||
def _initialize_joint_state(self) -> None:
|
||||
try:
|
||||
self._reset_joint_state(self._adapter.read_joint_state())
|
||||
except Exception as exc:
|
||||
self.get_logger().error(
|
||||
f"{self._arm_name} 启动关节同步失败:{exc}"
|
||||
)
|
||||
self._adapter.close()
|
||||
raise
|
||||
|
||||
def _reset_joint_state(
|
||||
self,
|
||||
snapshot: JointStateSnapshot,
|
||||
) -> np.ndarray:
|
||||
current_pose = self._ik_solver.update_joint_state(snapshot.positions)
|
||||
positions = list(snapshot.positions)
|
||||
self._latest_joint_positions = positions
|
||||
self._last_current_pose = current_pose
|
||||
self._last_valid_joint_target = list(positions)
|
||||
self._last_joint_command_target = list(positions)
|
||||
self._last_joint_command_velocity = [0.0] * 7
|
||||
return current_pose
|
||||
```
|
||||
|
||||
在现有适配器连接之后、外设初始化之前调用:
|
||||
|
||||
```python
|
||||
self._adapter = self._make_adapter()
|
||||
self._adapter.connect()
|
||||
self._initialize_joint_state()
|
||||
self._setup_tool_control()
|
||||
```
|
||||
|
||||
同步查询结果不得写入 `RealManAdapter._latest_joint_state`;该缓存继续只代表 UDP 反馈。
|
||||
|
||||
- [x] **步骤4:运行启动与适配器测试**
|
||||
|
||||
```bash
|
||||
source /opt/ros/humble/setup.bash
|
||||
python3 -m pytest src/xr_rm_teleop/test/test_joint_control.py \
|
||||
-k 'startup_joint_query or first_feedback' -v
|
||||
python3 -m pytest src/xr_rm_teleop/test/test_initial_joint_pose.py -v
|
||||
```
|
||||
|
||||
预期:所选控制测试和全部适配器测试通过。
|
||||
|
||||
- [x] **步骤5:检查本任务差异**
|
||||
|
||||
```bash
|
||||
git diff --check
|
||||
git diff -- \
|
||||
xr_rm_teleop/xr_rm_teleop/single_arm_velocity_teleop.py \
|
||||
xr_rm_teleop/test/test_joint_control.py
|
||||
```
|
||||
|
||||
预期:启动阶段只增加一次同步读取,并初始化现有 QP/关节命令字段。
|
||||
|
||||
---
|
||||
|
||||
## 任务三:UDP 短暂超时保持,持续超时重新同步
|
||||
|
||||
**修改文件:**
|
||||
|
||||
- `src/xr_rm_teleop/test/test_joint_control.py`
|
||||
- `src/xr_rm_teleop/xr_rm_teleop/single_arm_velocity_teleop.py`
|
||||
|
||||
- [x] **步骤1:先写超时行为测试**
|
||||
|
||||
在测试文件增加最小构造器:
|
||||
|
||||
```python
|
||||
def _timeout_teleop(adapter) -> SingleArmVelocityTeleop:
|
||||
teleop = object.__new__(SingleArmVelocityTeleop)
|
||||
teleop._adapter = adapter
|
||||
teleop._arm_name = "right_rm75"
|
||||
teleop._follow = False
|
||||
teleop._active = True
|
||||
teleop._joint_feedback_ready = True
|
||||
teleop._grip_rearm_required = False
|
||||
teleop._feedback_resync_attempted = False
|
||||
teleop._control_fault_latched = False
|
||||
teleop._last_joint_command_target = [0.1] * 7
|
||||
teleop._last_joint_command_velocity = [0.0] * 7
|
||||
teleop._latest_joint_positions = [0.1] * 7
|
||||
teleop._last_valid_joint_target = [0.1] * 7
|
||||
teleop._last_current_pose = np.eye(4)
|
||||
teleop._controller_start = None
|
||||
teleop._controller_orientation_start = None
|
||||
teleop._robot_start_transform = None
|
||||
teleop._filtered_target = None
|
||||
teleop._filtered_orientation_target = None
|
||||
teleop._last_sent_target = None
|
||||
teleop._last_sent_orientation = None
|
||||
teleop._last_command_time = None
|
||||
teleop._ik_solver = SimpleNamespace(
|
||||
update_joint_state=lambda joints: np.eye(4)
|
||||
)
|
||||
teleop._stop_sent = False
|
||||
teleop._feedback_resync_timeout_sec = 0.5
|
||||
teleop._publish_stop_debug = lambda: None
|
||||
teleop.get_logger = lambda: FakeLogger()
|
||||
return teleop
|
||||
```
|
||||
|
||||
增加:
|
||||
|
||||
```python
|
||||
def test_missing_or_disabled_joint_snapshot_is_not_motion_ready() -> None:
|
||||
assert not SingleArmVelocityTeleop._joint_snapshot_is_motion_ready(None)
|
||||
assert not SingleArmVelocityTeleop._joint_snapshot_is_motion_ready(
|
||||
JointStateSnapshot(
|
||||
[0.0] * 7,
|
||||
time.monotonic(),
|
||||
motion_ready=False,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_short_udp_timeout_repeats_last_limited_target_without_query() -> None:
|
||||
sends = []
|
||||
adapter = SimpleNamespace(
|
||||
send_joint_target=lambda joints, follow: sends.append(
|
||||
(list(joints), follow)
|
||||
),
|
||||
read_joint_state=lambda: pytest.fail("query must not run"),
|
||||
stop=lambda: pytest.fail("stop must not run"),
|
||||
)
|
||||
teleop = _timeout_teleop(adapter)
|
||||
|
||||
teleop._handle_stale_joint_feedback(0.2)
|
||||
|
||||
assert sends == [([0.1] * 7, False)]
|
||||
assert teleop._last_joint_command_target == [0.1] * 7
|
||||
assert teleop._grip_rearm_required
|
||||
|
||||
|
||||
def test_short_udp_timeout_without_active_target_stays_stopped() -> None:
|
||||
stop_calls = []
|
||||
adapter = SimpleNamespace(
|
||||
send_joint_target=lambda joints, follow: pytest.fail(
|
||||
"inactive control must not start CANFD output"
|
||||
),
|
||||
read_joint_state=lambda: pytest.fail("query must not run"),
|
||||
stop=lambda: stop_calls.append(True),
|
||||
)
|
||||
teleop = _timeout_teleop(adapter)
|
||||
teleop._active = False
|
||||
|
||||
teleop._handle_stale_joint_feedback(0.2)
|
||||
|
||||
assert len(stop_calls) == 1
|
||||
|
||||
|
||||
def test_persistent_udp_timeout_queries_once_and_holds_actual_position() -> None:
|
||||
sends = []
|
||||
query_calls = []
|
||||
adapter = SimpleNamespace(
|
||||
send_joint_target=lambda joints, follow: sends.append(list(joints)),
|
||||
read_joint_state=lambda: (
|
||||
query_calls.append(True)
|
||||
or JointStateSnapshot([0.2] * 7, time.monotonic())
|
||||
),
|
||||
stop=lambda: None,
|
||||
)
|
||||
teleop = _timeout_teleop(adapter)
|
||||
|
||||
teleop._handle_stale_joint_feedback(0.5)
|
||||
teleop._handle_stale_joint_feedback(0.6)
|
||||
|
||||
assert len(query_calls) == 1
|
||||
assert sends == [[0.2] * 7, [0.2] * 7]
|
||||
assert teleop._last_valid_joint_target == [0.2] * 7
|
||||
assert teleop._last_joint_command_velocity == [0.0] * 7
|
||||
|
||||
|
||||
def test_persistent_udp_timeout_query_failure_latches_control() -> None:
|
||||
stop_calls = []
|
||||
adapter = SimpleNamespace(
|
||||
send_joint_target=lambda joints, follow: pytest.fail(
|
||||
"CANFD must stop after query failure"
|
||||
),
|
||||
read_joint_state=lambda: (_ for _ in ()).throw(
|
||||
RuntimeError("rm_get_joint_degree failed with code 7")
|
||||
),
|
||||
stop=lambda: stop_calls.append(True),
|
||||
)
|
||||
teleop = _timeout_teleop(adapter)
|
||||
|
||||
teleop._handle_stale_joint_feedback(0.5)
|
||||
teleop._handle_stale_joint_feedback(0.6)
|
||||
|
||||
assert teleop._control_fault_latched
|
||||
assert len(stop_calls) == 1
|
||||
```
|
||||
|
||||
删除旧的 `_fresh_joint_state()` 直接测试,并用
|
||||
`test_short_udp_timeout_without_active_target_stays_stopped` 替换旧的
|
||||
`test_stale_feedback_stops_before_active_control`。
|
||||
|
||||
在 `test_feedback_fault_blocks_grip_until_release` 中补齐:
|
||||
|
||||
```python
|
||||
teleop._control_fault_latched = False
|
||||
teleop._feedback_resync_attempted = False
|
||||
```
|
||||
|
||||
- [x] **步骤2:运行测试并确认 RED**
|
||||
|
||||
```bash
|
||||
source /opt/ros/humble/setup.bash
|
||||
python3 -m pytest src/xr_rm_teleop/test/test_joint_control.py \
|
||||
-k 'udp_timeout or joint_snapshot' -v
|
||||
```
|
||||
|
||||
预期:测试因超时处理和锁存状态尚不存在而失败。
|
||||
|
||||
- [x] **步骤3:增加参数与最小状态**
|
||||
|
||||
参数默认值:
|
||||
|
||||
```python
|
||||
self.declare_parameter("control_rate_hz", 90.0)
|
||||
self.declare_parameter("command_timeout_sec", 0.12)
|
||||
self.declare_parameter("feedback_resync_timeout_sec", 0.5)
|
||||
```
|
||||
|
||||
读取并初始化:
|
||||
|
||||
```python
|
||||
self._feedback_resync_timeout_sec = float(
|
||||
self.get_parameter("feedback_resync_timeout_sec").value
|
||||
)
|
||||
self._feedback_resync_attempted = False
|
||||
self._control_fault_latched = False
|
||||
```
|
||||
|
||||
在 `_validate_parameters()` 中增加:
|
||||
|
||||
```python
|
||||
if self._feedback_resync_timeout_sec <= self._command_timeout_sec:
|
||||
raise ValueError(
|
||||
"feedback_resync_timeout_sec must be greater than command_timeout_sec"
|
||||
)
|
||||
```
|
||||
|
||||
- [x] **步骤4:增加保持、重新同步和锁存逻辑**
|
||||
|
||||
增加:
|
||||
|
||||
```python
|
||||
def _handle_stale_joint_feedback(self, age: float) -> None:
|
||||
if self._control_fault_latched:
|
||||
return
|
||||
self._grip_rearm_required = True
|
||||
if self._joint_feedback_ready:
|
||||
self.get_logger().warn(
|
||||
f"{self._arm_name} UDP关节反馈超时,保持最后安全目标。"
|
||||
)
|
||||
self._joint_feedback_ready = False
|
||||
|
||||
if (
|
||||
age >= self._feedback_resync_timeout_sec
|
||||
and not self._feedback_resync_attempted
|
||||
):
|
||||
self._feedback_resync_attempted = True
|
||||
self.get_logger().warn(
|
||||
f"{self._arm_name} UDP关节反馈持续超时,"
|
||||
"尝试rm_get_joint_degree重新同步。"
|
||||
)
|
||||
try:
|
||||
self._reset_joint_state(self._adapter.read_joint_state())
|
||||
except Exception as exc:
|
||||
self._latch_control_fault(
|
||||
f"UDP关节反馈持续超时且重新同步失败:{exc}"
|
||||
)
|
||||
return
|
||||
self.get_logger().info(
|
||||
f"{self._arm_name} 已通过rm_get_joint_degree重新同步,"
|
||||
"继续保持并等待UDP恢复。"
|
||||
)
|
||||
|
||||
if self._active and self._last_joint_command_target is not None:
|
||||
self._repeat_last_joint_target()
|
||||
else:
|
||||
self._safe_stop(reset_active=True)
|
||||
|
||||
def _repeat_last_joint_target(self) -> None:
|
||||
target = self._last_joint_command_target
|
||||
if target is None:
|
||||
return
|
||||
self._adapter.send_joint_target(list(target), self._follow)
|
||||
self._stop_sent = False
|
||||
|
||||
def _latch_control_fault(self, message: str) -> None:
|
||||
if self._control_fault_latched:
|
||||
return
|
||||
self._control_fault_latched = True
|
||||
self._grip_rearm_required = True
|
||||
self.get_logger().error(
|
||||
f"{self._arm_name} 控制故障已锁存:{message}"
|
||||
)
|
||||
self._safe_stop(reset_active=True)
|
||||
```
|
||||
|
||||
- [x] **步骤5:在 QP 之前处理反馈状态**
|
||||
|
||||
在 `_control_tick()` 开头用以下逻辑替换现有 `_fresh_joint_state()` 分支:
|
||||
|
||||
```python
|
||||
if self._control_fault_latched:
|
||||
return
|
||||
|
||||
snapshot = self._adapter.get_latest_joint_state()
|
||||
if not self._joint_snapshot_is_motion_ready(snapshot):
|
||||
self._grip_rearm_required = True
|
||||
if self._joint_feedback_ready:
|
||||
self.get_logger().warn(
|
||||
f"{self._arm_name} 关节反馈无效或机械臂未就绪,机械臂停止。"
|
||||
)
|
||||
self._joint_feedback_ready = False
|
||||
self._safe_stop(reset_active=True)
|
||||
return
|
||||
|
||||
feedback_age = time.monotonic() - snapshot.received_at
|
||||
if feedback_age < 0.0:
|
||||
self._grip_rearm_required = True
|
||||
self._joint_feedback_ready = False
|
||||
self._safe_stop(reset_active=True)
|
||||
return
|
||||
if feedback_age > self._command_timeout_sec:
|
||||
self._handle_stale_joint_feedback(feedback_age)
|
||||
return
|
||||
|
||||
self._feedback_resync_attempted = False
|
||||
```
|
||||
|
||||
在成功执行 `_sync_joint_feedback(snapshot)` 后,用以下逻辑替换现有首次反馈日志:
|
||||
|
||||
```python
|
||||
if not self._joint_feedback_ready:
|
||||
if self._grip_rearm_required:
|
||||
message = (
|
||||
f"{self._arm_name} UDP关节反馈已恢复,"
|
||||
"等待Grip松开后重新使能。"
|
||||
)
|
||||
else:
|
||||
message = (
|
||||
f"{self._arm_name} 已收到首帧有效关节反馈,QP可以启用。"
|
||||
)
|
||||
self.get_logger().info(message)
|
||||
self._joint_feedback_ready = True
|
||||
```
|
||||
|
||||
用以下静态校验替换 `_fresh_joint_state()`:
|
||||
|
||||
```python
|
||||
@staticmethod
|
||||
def _joint_snapshot_is_motion_ready(
|
||||
snapshot: JointStateSnapshot | None,
|
||||
) -> bool:
|
||||
return (
|
||||
snapshot is not None
|
||||
and len(snapshot.positions) == 7
|
||||
and all(math.isfinite(value) for value in snapshot.positions)
|
||||
and snapshot.motion_ready
|
||||
)
|
||||
```
|
||||
|
||||
XR手柄消息超时、Grip逻辑、工作空间/圆柱限位、姿态限速和关节限速保持原样。
|
||||
|
||||
- [x] **步骤6:运行超时及反馈安全测试**
|
||||
|
||||
```bash
|
||||
source /opt/ros/humble/setup.bash
|
||||
python3 -m pytest src/xr_rm_teleop/test/test_joint_control.py \
|
||||
-k 'udp_timeout or feedback_fault or joint_snapshot' -v
|
||||
```
|
||||
|
||||
预期:所选测试通过;短暂超时不调用 QP 和同步查询,机械臂未就绪仍立即停止。
|
||||
|
||||
- [x] **步骤7:检查本任务差异**
|
||||
|
||||
```bash
|
||||
git diff --check
|
||||
git diff --stat
|
||||
```
|
||||
|
||||
预期:没有新增状态机类、线程、依赖、连接或常态轮询。
|
||||
|
||||
---
|
||||
|
||||
## 任务四:CANFD 错误后停止、查询并等待人工恢复
|
||||
|
||||
**修改文件:**
|
||||
|
||||
- `src/xr_rm_teleop/test/test_joint_control.py`
|
||||
- `src/xr_rm_teleop/xr_rm_teleop/single_arm_velocity_teleop.py`
|
||||
|
||||
- [x] **步骤1:先写 CANFD 恢复测试**
|
||||
|
||||
用以下测试替换旧的 `test_joint_send_failure_requests_slow_stop_and_resets_control`:
|
||||
|
||||
```python
|
||||
def test_canfd_error_stops_queries_and_requires_grip_rearm() -> None:
|
||||
class RecoveringAdapter:
|
||||
def __init__(self):
|
||||
self.stop_calls = 0
|
||||
self.read_calls = 0
|
||||
|
||||
def send_joint_target(self, joints, follow):
|
||||
raise RuntimeError("rm_movej_canfd failed with code 9")
|
||||
|
||||
def stop(self):
|
||||
self.stop_calls += 1
|
||||
|
||||
def read_joint_state(self):
|
||||
self.read_calls += 1
|
||||
return JointStateSnapshot([0.2] * 7, time.monotonic())
|
||||
|
||||
teleop = _timeout_teleop(RecoveringAdapter())
|
||||
teleop._joint_command_max_speed = math.radians(180.0)
|
||||
teleop._joint_command_max_acceleration = math.radians(300.0)
|
||||
teleop._dt = 1.0 / 90.0
|
||||
|
||||
sent = teleop._send_joint_target([0.3] * 7)
|
||||
|
||||
assert not sent
|
||||
assert teleop._adapter.stop_calls == 1
|
||||
assert teleop._adapter.read_calls == 1
|
||||
assert not teleop._control_fault_latched
|
||||
assert teleop._grip_rearm_required
|
||||
assert teleop._last_joint_command_target == [0.2] * 7
|
||||
|
||||
|
||||
def test_canfd_error_latches_when_joint_query_also_fails() -> None:
|
||||
class FailingAdapter:
|
||||
def __init__(self):
|
||||
self.stop_calls = 0
|
||||
|
||||
def send_joint_target(self, joints, follow):
|
||||
raise RuntimeError("rm_movej_canfd failed with code 9")
|
||||
|
||||
def stop(self):
|
||||
self.stop_calls += 1
|
||||
|
||||
def read_joint_state(self):
|
||||
raise RuntimeError("rm_get_joint_degree failed with code 7")
|
||||
|
||||
teleop = _timeout_teleop(FailingAdapter())
|
||||
teleop._joint_command_max_speed = math.radians(180.0)
|
||||
teleop._joint_command_max_acceleration = math.radians(300.0)
|
||||
teleop._dt = 1.0 / 90.0
|
||||
|
||||
assert not teleop._send_joint_target([0.3] * 7)
|
||||
assert teleop._control_fault_latched
|
||||
assert teleop._adapter.stop_calls == 1
|
||||
```
|
||||
|
||||
- [x] **步骤2:运行测试并确认 RED**
|
||||
|
||||
```bash
|
||||
source /opt/ros/humble/setup.bash
|
||||
python3 -m pytest src/xr_rm_teleop/test/test_joint_control.py \
|
||||
-k 'canfd_error' -v
|
||||
```
|
||||
|
||||
预期:测试失败;当前发送错误只会 slow-stop,不会查询实际关节角或锁存查询失败。
|
||||
|
||||
- [x] **步骤3:增加统一 CANFD 恢复路径**
|
||||
|
||||
增加:
|
||||
|
||||
```python
|
||||
def _recover_from_canfd_error(self, send_error: Exception) -> None:
|
||||
self.get_logger().error(
|
||||
f"{self._arm_name} rm_movej_canfd发送失败:{send_error}"
|
||||
)
|
||||
self._grip_rearm_required = True
|
||||
self._send_stop_once()
|
||||
self._safe_stop(reset_active=True)
|
||||
try:
|
||||
snapshot = self._adapter.read_joint_state()
|
||||
self._reset_joint_state(snapshot)
|
||||
except Exception as query_error:
|
||||
self._latch_control_fault(
|
||||
"CANFD错误后关节同步失败:"
|
||||
f"send={send_error}; query={query_error}"
|
||||
)
|
||||
return
|
||||
self.get_logger().info(
|
||||
f"{self._arm_name} CANFD错误后已同步实际关节角,"
|
||||
"等待UDP恢复及Grip重新使能。"
|
||||
)
|
||||
```
|
||||
|
||||
把 `_send_joint_target()` 的异常分支替换为:
|
||||
|
||||
```python
|
||||
except Exception as exc:
|
||||
self._recover_from_canfd_error(exc)
|
||||
return False
|
||||
```
|
||||
|
||||
让短暂超时重发也走相同错误恢复:
|
||||
|
||||
```python
|
||||
def _repeat_last_joint_target(self) -> None:
|
||||
target = self._last_joint_command_target
|
||||
if target is None:
|
||||
return
|
||||
try:
|
||||
self._adapter.send_joint_target(list(target), self._follow)
|
||||
self._stop_sent = False
|
||||
except Exception as exc:
|
||||
self._recover_from_canfd_error(exc)
|
||||
```
|
||||
|
||||
- [x] **步骤4:运行全部关节控制测试**
|
||||
|
||||
```bash
|
||||
source /opt/ros/humble/setup.bash
|
||||
python3 -m pytest src/xr_rm_teleop/test/test_joint_control.py -v
|
||||
```
|
||||
|
||||
预期:全部关节控制测试通过。
|
||||
|
||||
- [x] **步骤5:检查日志与差异**
|
||||
|
||||
```bash
|
||||
rg -n "rm_movej_canfd发送失败|控制故障已锁存|rm_get_joint_degree" \
|
||||
src/xr_rm_teleop/xr_rm_teleop
|
||||
git diff --check
|
||||
```
|
||||
|
||||
预期:
|
||||
|
||||
- CANFD错误和查询错误都包含机械臂名称及失败阶段。
|
||||
- 锁存分支不会每周期重复打印错误。
|
||||
- 正常QP输出和超时保持使用同一个CANFD恢复入口。
|
||||
|
||||
---
|
||||
|
||||
## 任务五:同步90 Hz配置并完成mock验证
|
||||
|
||||
**修改文件:**
|
||||
|
||||
- `src/xr_rm_bringup/config/dual_arm_rm75.yaml`
|
||||
- `src/xr_rm_bringup/config/left_arm_rm75.yaml`
|
||||
- `src/xr_rm_bringup/config/right_arm_rm75.yaml`
|
||||
|
||||
- [x] **步骤1:只修改请求中的控制参数**
|
||||
|
||||
三份配置的每个机械臂条目统一为:
|
||||
|
||||
```yaml
|
||||
control_rate_hz: 90.0
|
||||
command_timeout_sec: 0.12
|
||||
feedback_resync_timeout_sec: 0.5
|
||||
```
|
||||
|
||||
保留低跟随:
|
||||
|
||||
```yaml
|
||||
follow: false
|
||||
```
|
||||
|
||||
不得修改:
|
||||
|
||||
- 工作空间与圆柱限位。
|
||||
- TCP线速度、角速度及关节速度/加速度限制。
|
||||
- `configure_safety_limits: true`。
|
||||
- `move_to_initial_pose_on_connect: false`。
|
||||
- 机械臂IP、端口、初始位姿和末端工具配置。
|
||||
- 双臂节点名 `left_arm_teleop`、`right_arm_teleop`。
|
||||
|
||||
- [x] **步骤2:机械检查三份配置**
|
||||
|
||||
```bash
|
||||
rg -n "control_rate_hz|command_timeout_sec|feedback_resync_timeout_sec|follow:" \
|
||||
src/xr_rm_bringup/config/dual_arm_rm75.yaml \
|
||||
src/xr_rm_bringup/config/left_arm_rm75.yaml \
|
||||
src/xr_rm_bringup/config/right_arm_rm75.yaml
|
||||
```
|
||||
|
||||
预期:共四个机械臂配置条目,每个条目均为90.0、0.12、0.5和`follow: false`;三份文件不再出现125.0。
|
||||
|
||||
- [x] **步骤3:运行相关测试**
|
||||
|
||||
```bash
|
||||
source /opt/ros/humble/setup.bash
|
||||
python3 -m pytest src/xr_rm_teleop/test/test_initial_joint_pose.py -v
|
||||
python3 -m pytest src/xr_rm_teleop/test/test_joint_control.py -v
|
||||
python3 -m pytest src/xr_rm_teleop/test/test_orientation_control.py -v
|
||||
```
|
||||
|
||||
预期:三个命令均以0退出且无失败。
|
||||
|
||||
- [x] **步骤4:构建ROS2工作空间**
|
||||
|
||||
```bash
|
||||
source /opt/ros/humble/setup.bash
|
||||
colcon build --symlink-install
|
||||
```
|
||||
|
||||
预期:`xr_rm_interfaces`、`xr_rm_input`、`xr_rm_teleop`、`xr_rm_bringup` 构建成功。
|
||||
|
||||
- [x] **步骤5:通过统一入口进行mock启动验证**
|
||||
|
||||
```bash
|
||||
source /opt/ros/humble/setup.bash
|
||||
source install/setup.bash
|
||||
if timeout --signal=INT 10s ros2 launch xr_rm_bringup arm_debug.launch.py \
|
||||
arm:=right use_mock:=true udp_port:=15123
|
||||
then
|
||||
true
|
||||
else
|
||||
launch_status=$?
|
||||
test "$launch_status" -eq 124
|
||||
fi
|
||||
```
|
||||
|
||||
预期:
|
||||
|
||||
- `udp_controller_receiver` 与 `single_arm_velocity_teleop` 正常启动。
|
||||
- 遥操作节点报告90 Hz、低跟随,并完成mock关节状态初始化。
|
||||
- 不导入厂商SDK,不建立RealMan连接,不发送CANFD,不移动机械臂,不操作夹爪。
|
||||
- 10秒后由`timeout`结束;仅该超时允许退出码124。
|
||||
|
||||
- [x] **步骤6:最终范围与安全审计**
|
||||
|
||||
```bash
|
||||
git diff --check
|
||||
git status --short
|
||||
git diff --stat
|
||||
git diff -- \
|
||||
xr_rm_teleop/xr_rm_teleop/realman_adapter.py \
|
||||
xr_rm_teleop/xr_rm_teleop/single_arm_velocity_teleop.py \
|
||||
xr_rm_teleop/test/test_initial_joint_pose.py \
|
||||
xr_rm_teleop/test/test_joint_control.py \
|
||||
xr_rm_bringup/config/dual_arm_rm75.yaml \
|
||||
xr_rm_bringup/config/left_arm_rm75.yaml \
|
||||
xr_rm_bringup/config/right_arm_rm75.yaml
|
||||
```
|
||||
|
||||
逐项确认:
|
||||
|
||||
- 没有无关文件或格式化改动。
|
||||
- 没有提交、推送、分支、worktree、锁文件、CI、格式化规则或依赖变化。
|
||||
- 没有新增线程、ROS包、launch入口、并发RealMan连接或常态SDK轮询。
|
||||
- mock模式不导入、不依赖厂商SDK。
|
||||
- `configure_safety_limits` 默认仍为开启。
|
||||
- `move_to_initial_pose_on_connect` 默认仍为关闭。
|
||||
- `left_arm_teleop`、`right_arm_teleop` 节点名不变。
|
||||
- 工作空间/圆柱限位、TCP与关节限速、XR命令超时和slow-stop逻辑仍保留。
|
||||
- 验证期间未连接真机、移动机械臂或操作夹爪。
|
||||
@@ -0,0 +1,412 @@
|
||||
# RM75 QP 收敛优化实施计划
|
||||
|
||||
> **执行要求:** 使用 `superpowers:executing-plans` 逐项执行。用户未授权
|
||||
> subagent、独立worktree或本地分支,因此本计划只允许当前会话内联实施。所有
|
||||
> 步骤使用复选框跟踪。
|
||||
|
||||
**目标:** 将当前每周期单步QP改为有界迭代QP,使低跟随RM75在手柄移动10 cm
|
||||
后约1秒内稳定到位,并消除由近距离台阶目标造成的持续轻微晃动。
|
||||
|
||||
**实现方式:** 每个正常控制周期仍先用UDP实际关节角同步Placo,然后在一次
|
||||
`PlacoIkSolver.solve()`内部最多迭代30次,提前达到1 mm位置误差和0.005 rad
|
||||
姿态误差即返回。最终关节解继续经过现有90 Hz关节速度与加速度限幅后,以
|
||||
`follow=false`发送;不增加预测状态、线程、连接、依赖或配置参数。
|
||||
|
||||
**技术栈:** Python 3.10、ROS2 Humble、Placo 0.9.4、NumPy、pytest、
|
||||
ament/colcon。
|
||||
|
||||
**设计文档:**
|
||||
`docs/superpowers/specs/2026-07-29-rm75-qp-convergence-design.md`
|
||||
|
||||
---
|
||||
|
||||
## 仓库与安全约束
|
||||
|
||||
- 构建、测试和启动命令在 `/home/robot/WS_xr` 执行。
|
||||
- Git命令在 `/home/robot/WS_xr/src` 执行。
|
||||
- 每次构建、测试或启动前执行 `source /opt/ros/humble/setup.bash`。
|
||||
- 不自动提交、推送、创建分支或worktree。
|
||||
- 不连接真机,不发送真实CANFD,不移动机械臂,不操作夹爪。
|
||||
- 只通过 `arm_debug.launch.py arm:=right use_mock:=true`进行启动验证。
|
||||
- 不修改三份机械臂YAML、RealMan适配器、launch、UI、依赖或公开入口。
|
||||
- 保留工作空间、圆柱、TCP速度、姿态速度、关节速度、关节加速度、反馈超时、
|
||||
CANFD恢复、Grip重新使能和安全停止逻辑。
|
||||
|
||||
## 文件范围
|
||||
|
||||
- 修改 `xr_rm_teleop/test/test_placo_transforms.py`
|
||||
- 增加真实Placo 7 cm目标收敛回归测试。
|
||||
- 修改 `xr_rm_teleop/xr_rm_teleop/placo_ik_solver.py`
|
||||
- 增加固定上限、提前收敛和逐步安全校验。
|
||||
|
||||
不需要修改 `single_arm_velocity_teleop.py`;现有 `_solve_joint_target()` 已负责
|
||||
QP异常时打印限频警告并保持上一组安全关节目标,现有
|
||||
`_limit_joint_command_step()` 已负责最终90 Hz真实命令限速。
|
||||
|
||||
---
|
||||
|
||||
## 任务一:用真实Placo复现单步QP不收敛
|
||||
|
||||
**修改文件:**
|
||||
|
||||
- `xr_rm_teleop/test/test_placo_transforms.py`
|
||||
|
||||
- [x] **步骤1:增加测试辅助函数**
|
||||
|
||||
在文件顶部增加:
|
||||
|
||||
```python
|
||||
import math
|
||||
```
|
||||
|
||||
在现有URDF结构测试之后增加:
|
||||
|
||||
```python
|
||||
def _rm75_placo_solver() -> tuple[PlacoIkSolver, list[float]]:
|
||||
pytest.importorskip("placo")
|
||||
urdf_path = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "models"
|
||||
/ "rm75_omnipicker"
|
||||
/ "urdf"
|
||||
/ "RM75-B_OmniPicker_fixed.urdf"
|
||||
)
|
||||
joints = [
|
||||
math.radians(value)
|
||||
for value in [
|
||||
-90.14,
|
||||
3.76,
|
||||
-86.89,
|
||||
87.89,
|
||||
-96.53,
|
||||
-79.62,
|
||||
-90.04,
|
||||
]
|
||||
]
|
||||
return PlacoIkSolver(str(urdf_path), 1.0 / 90.0), joints
|
||||
```
|
||||
|
||||
`importorskip()`只让没有Placo的普通系统Python跳过真模型用例;下面的RED/GREEN
|
||||
命令会显式加入项目现有Placo 0.9.4路径,因此该用例必须实际执行而不能跳过。
|
||||
|
||||
- [x] **步骤2:增加7 cm目标收敛测试**
|
||||
|
||||
增加:
|
||||
|
||||
```python
|
||||
def test_qp_solve_converges_to_reachable_tcp_target() -> None:
|
||||
solver, joints = _rm75_placo_solver()
|
||||
start_pose = solver.update_joint_state(joints)
|
||||
target_pose = start_pose.copy()
|
||||
target_pose[0, 3] += 0.07
|
||||
|
||||
result = solver.solve(target_pose)
|
||||
reached_pose = solver.update_joint_state(result)
|
||||
position_error = np.linalg.norm(
|
||||
target_pose[:3, 3] - reached_pose[:3, 3]
|
||||
)
|
||||
rotation_delta = (
|
||||
target_pose[:3, :3] @ reached_pose[:3, :3].T
|
||||
)
|
||||
orientation_error = math.acos(
|
||||
float(
|
||||
np.clip(
|
||||
(np.trace(rotation_delta) - 1.0) * 0.5,
|
||||
-1.0,
|
||||
1.0,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assert position_error <= 1e-3
|
||||
assert orientation_error <= 5e-3
|
||||
```
|
||||
|
||||
该测试验证一次公开 `solve()` 调用返回当前TCP目标对应的收敛关节解,而不是验证
|
||||
内部迭代次数。
|
||||
|
||||
- [x] **步骤3:运行测试并确认RED**
|
||||
|
||||
```bash
|
||||
source /opt/ros/humble/setup.bash
|
||||
export RM75_PLACO_TEST_PATH="/home/robot/WS_xr/src/xr_rm_teleop:/home/robot/miniconda3/envs/xr/lib/python3.10/site-packages:/home/robot/miniconda3/envs/xr/lib/python3.10/site-packages/cmeel.prefix/lib/python3.10/site-packages"
|
||||
PYTHONPATH="${RM75_PLACO_TEST_PATH}:${PYTHONPATH:-}" \
|
||||
python3 -m pytest \
|
||||
src/xr_rm_teleop/test/test_placo_transforms.py::test_qp_solve_converges_to_reachable_tcp_target \
|
||||
-v
|
||||
```
|
||||
|
||||
预期:测试以位置误差约0.063 m大于0.001 m失败,证明当前单步QP确实不能在一次
|
||||
调用内给出收敛关节目标。测试不得因导入错误或跳过而结束。
|
||||
|
||||
---
|
||||
|
||||
## 任务二:实现有界迭代QP
|
||||
|
||||
**修改文件:**
|
||||
|
||||
- `xr_rm_teleop/xr_rm_teleop/placo_ik_solver.py`
|
||||
|
||||
- [x] **步骤1:增加固定收敛常量**
|
||||
|
||||
把模块说明改为:
|
||||
|
||||
```python
|
||||
"""RM75 的 Placo 0.9.4 有界迭代 QP 逆解。"""
|
||||
```
|
||||
|
||||
在现有常量后增加:
|
||||
|
||||
```python
|
||||
QP_MAX_ITERATIONS = 30
|
||||
QP_POSITION_TOLERANCE_M = 1e-3
|
||||
QP_ORIENTATION_TOLERANCE_RAD = 5e-3
|
||||
```
|
||||
|
||||
这些值是本次已确认的算法边界,不新增ROS参数。
|
||||
|
||||
- [x] **步骤2:增加任务误差读取**
|
||||
|
||||
在 `solve()` 前增加:
|
||||
|
||||
```python
|
||||
def _target_errors(self) -> tuple[float, float]:
|
||||
position_task = self._frame_task.position()
|
||||
orientation_task = self._frame_task.orientation()
|
||||
position_task.update()
|
||||
orientation_task.update()
|
||||
return (
|
||||
float(position_task.error_norm()),
|
||||
float(orientation_task.error_norm()),
|
||||
)
|
||||
```
|
||||
|
||||
Placo在 `solve(True)` 后只更新关节状态;先更新机器人运动学,再显式更新两个任务,
|
||||
确保 `error_norm()`对应当前迭代后的状态而不是前一迭代。
|
||||
|
||||
- [x] **步骤3:把单步求解改为最多30次且提前收敛**
|
||||
|
||||
用以下实现替换现有 `solve()`:
|
||||
|
||||
```python
|
||||
def solve(self, target_tool_pose: np.ndarray) -> list[float]:
|
||||
if self._actual_joints is None:
|
||||
raise RuntimeError(
|
||||
"joint state must be initialized before QP solve"
|
||||
)
|
||||
self._frame_task.T_world_frame = _validated_transform(
|
||||
target_tool_pose
|
||||
)
|
||||
result = np.asarray(
|
||||
self._robot.state.q[RM75_Q_SLICE],
|
||||
dtype=float,
|
||||
).copy()
|
||||
position_error, orientation_error = self._target_errors()
|
||||
if (
|
||||
position_error <= QP_POSITION_TOLERANCE_M
|
||||
and orientation_error <= QP_ORIENTATION_TOLERANCE_RAD
|
||||
):
|
||||
return result.tolist()
|
||||
|
||||
for _ in range(QP_MAX_ITERATIONS):
|
||||
previous = result
|
||||
self._solver.solve(True)
|
||||
self._robot.update_kinematics()
|
||||
result = np.asarray(
|
||||
self._robot.state.q[RM75_Q_SLICE],
|
||||
dtype=float,
|
||||
).copy()
|
||||
self._validate_result(result, previous)
|
||||
position_error, orientation_error = self._target_errors()
|
||||
if (
|
||||
position_error <= QP_POSITION_TOLERANCE_M
|
||||
and orientation_error <= QP_ORIENTATION_TOLERANCE_RAD
|
||||
):
|
||||
return result.tolist()
|
||||
|
||||
raise RuntimeError(
|
||||
"QP did not converge after "
|
||||
f"{QP_MAX_ITERATIONS} iterations: "
|
||||
f"position_error={position_error:.6f} m, "
|
||||
f"orientation_error={orientation_error:.6f} rad"
|
||||
)
|
||||
```
|
||||
|
||||
目标已到达时直接返回当前关节角,避免静止时进行不必要的数值迭代。
|
||||
|
||||
- [x] **步骤4:让速度校验针对每次数值迭代**
|
||||
|
||||
把 `_validate_result()` 签名改为:
|
||||
|
||||
```python
|
||||
def _validate_result(
|
||||
self,
|
||||
result: np.ndarray,
|
||||
reference: np.ndarray | None = None,
|
||||
) -> None:
|
||||
```
|
||||
|
||||
保留现有有限值和关节位置检查,把速度检查替换为:
|
||||
|
||||
```python
|
||||
if reference is None:
|
||||
reference = self._actual_joints
|
||||
if reference is None:
|
||||
raise RuntimeError("joint state has not been initialized")
|
||||
reference = np.asarray(reference, dtype=float)
|
||||
if reference.shape != (7,) or not np.isfinite(reference).all():
|
||||
raise ValueError("QP reference must contain 7 finite values")
|
||||
max_step = self._velocity_limits * self._dt + 1e-9
|
||||
if np.any(np.abs(result - reference) > max_step):
|
||||
raise ValueError(
|
||||
"QP result violates RM75 one-cycle velocity limits"
|
||||
)
|
||||
```
|
||||
|
||||
这样每次内部数值迭代继续满足Placo的URDF关节速度边界;最终收敛解仍由节点现有
|
||||
`_limit_joint_command_step()`按真实90 Hz周期限制后才发送。
|
||||
|
||||
- [x] **步骤5:运行目标测试并确认GREEN**
|
||||
|
||||
```bash
|
||||
source /opt/ros/humble/setup.bash
|
||||
export RM75_PLACO_TEST_PATH="/home/robot/WS_xr/src/xr_rm_teleop:/home/robot/miniconda3/envs/xr/lib/python3.10/site-packages:/home/robot/miniconda3/envs/xr/lib/python3.10/site-packages/cmeel.prefix/lib/python3.10/site-packages"
|
||||
PYTHONPATH="${RM75_PLACO_TEST_PATH}:${PYTHONPATH:-}" \
|
||||
python3 -m pytest \
|
||||
src/xr_rm_teleop/test/test_placo_transforms.py::test_qp_solve_converges_to_reachable_tcp_target \
|
||||
src/xr_rm_teleop/test/test_placo_transforms.py::test_qp_result_rejects_nan_position_and_velocity_violations \
|
||||
-v
|
||||
```
|
||||
|
||||
预期:两个测试通过;真实Placo用例不被跳过。
|
||||
|
||||
- [x] **步骤6:运行Placo变换测试文件**
|
||||
|
||||
```bash
|
||||
source /opt/ros/humble/setup.bash
|
||||
export RM75_PLACO_TEST_PATH="/home/robot/WS_xr/src/xr_rm_teleop:/home/robot/miniconda3/envs/xr/lib/python3.10/site-packages:/home/robot/miniconda3/envs/xr/lib/python3.10/site-packages/cmeel.prefix/lib/python3.10/site-packages"
|
||||
PYTHONPATH="${RM75_PLACO_TEST_PATH}:${PYTHONPATH:-}" \
|
||||
python3 -m pytest \
|
||||
src/xr_rm_teleop/test/test_placo_transforms.py \
|
||||
-v
|
||||
```
|
||||
|
||||
预期:全部通过,无失败或跳过。
|
||||
|
||||
---
|
||||
|
||||
## 任务三:回归、安全和mock验证
|
||||
|
||||
**验证范围:**
|
||||
|
||||
- `xr_rm_teleop`全部测试;
|
||||
- ROS2工作空间构建;
|
||||
- 统一launch的右臂mock启动;
|
||||
- 最终差异与安全配置审计。
|
||||
|
||||
- [x] **步骤1:运行遥操作包全部测试**
|
||||
|
||||
```bash
|
||||
source /opt/ros/humble/setup.bash
|
||||
export RM75_PLACO_TEST_PATH="/home/robot/WS_xr/src/xr_rm_teleop:/home/robot/miniconda3/envs/xr/lib/python3.10/site-packages:/home/robot/miniconda3/envs/xr/lib/python3.10/site-packages/cmeel.prefix/lib/python3.10/site-packages"
|
||||
PYTHONPATH="${RM75_PLACO_TEST_PATH}:${PYTHONPATH:-}" \
|
||||
python3 -m pytest src/xr_rm_teleop/test -v
|
||||
```
|
||||
|
||||
预期:全部测试通过,真实Placo收敛用例被执行。
|
||||
|
||||
- [x] **步骤2:按项目规则单独运行姿态控制测试**
|
||||
|
||||
```bash
|
||||
source /opt/ros/humble/setup.bash
|
||||
python3 -m pytest \
|
||||
src/xr_rm_teleop/test/test_orientation_control.py \
|
||||
-v
|
||||
```
|
||||
|
||||
预期:全部通过。
|
||||
|
||||
- [x] **步骤3:构建ROS2工作空间**
|
||||
|
||||
```bash
|
||||
source /opt/ros/humble/setup.bash
|
||||
colcon build --symlink-install
|
||||
```
|
||||
|
||||
预期:`xr_rm_interfaces`、`xr_rm_input`、`xr_rm_teleop`和
|
||||
`xr_rm_bringup`全部构建成功。
|
||||
|
||||
- [x] **步骤4:通过统一入口进行右臂mock启动验证**
|
||||
|
||||
```bash
|
||||
source /opt/ros/humble/setup.bash
|
||||
source install/setup.bash
|
||||
if timeout --signal=INT 10s ros2 launch \
|
||||
xr_rm_bringup arm_debug.launch.py \
|
||||
arm:=right use_mock:=true udp_port:=15123
|
||||
then
|
||||
true
|
||||
else
|
||||
launch_status=$?
|
||||
test "$launch_status" -eq 124
|
||||
fi
|
||||
```
|
||||
|
||||
预期:
|
||||
|
||||
- `udp_controller_receiver`和`single_arm_velocity_teleop`正常启动;
|
||||
- 节点报告 `dt=0.0111s`、`follow=False`;
|
||||
- mock关节初始化成功;
|
||||
- 不导入RealMan SDK,不建立真机连接,不发送CANFD;
|
||||
- 10秒后仅由 `timeout`结束。
|
||||
|
||||
- [x] **步骤5:最终差异和安全审计**
|
||||
|
||||
在 `/home/robot/WS_xr/src` 执行:
|
||||
|
||||
```bash
|
||||
git diff --check
|
||||
git status --short
|
||||
git diff -- \
|
||||
xr_rm_teleop/xr_rm_teleop/placo_ik_solver.py \
|
||||
xr_rm_teleop/test/test_placo_transforms.py
|
||||
rg -n \
|
||||
"control_rate_hz|follow:|configure_safety_limits|move_to_initial_pose_on_connect" \
|
||||
xr_rm_bringup/config/dual_arm_rm75.yaml \
|
||||
xr_rm_bringup/config/left_arm_rm75.yaml \
|
||||
xr_rm_bringup/config/right_arm_rm75.yaml
|
||||
```
|
||||
|
||||
预期:
|
||||
|
||||
- 生产代码只修改Placo求解器;
|
||||
- 测试只增加真实模型收敛验证;
|
||||
- 三份配置继续使用90 Hz、`follow: false`、
|
||||
`configure_safety_limits: true`和
|
||||
`move_to_initial_pose_on_connect: false`;
|
||||
- 不改变此前由用户保留的 `AGENTS.md` 修改;
|
||||
- 不自动提交或推送。
|
||||
|
||||
---
|
||||
|
||||
## 真机交接验收
|
||||
|
||||
Codex不执行本节。自动验证全部通过后,由用户在安全工作区使用:
|
||||
|
||||
```bash
|
||||
ros2 launch xr_rm_bringup arm_debug.launch.py \
|
||||
arm:=right use_mock:=false
|
||||
```
|
||||
|
||||
验收步骤:
|
||||
|
||||
1. 急停可用、Grip松开、工作区无人后启动。
|
||||
2. 按住Grip,快速移动手柄约10 cm后保持不动。
|
||||
3. 机械臂应在约1秒内稳定到位,无持续肉眼可见晃动。
|
||||
4. 连续观察四个5秒 timing 窗口,`total max`均低于11.111 ms。
|
||||
5. 不应出现QP未收敛、反馈超时、CANFD错误或故障锁存日志。
|
||||
6. 松开Grip后机械臂按现有逻辑安全停止。
|
||||
|
||||
若任一窗口 `total max`达到或超过11.111 ms,或机械臂出现明显振荡,立即松开
|
||||
Grip并停止测试,把完整timing和错误日志返回后再调整;不得直接提高控制频率、
|
||||
关闭限速或改成高跟随。
|
||||
@@ -0,0 +1,309 @@
|
||||
# RM75 关节命令提前制动实施计划
|
||||
|
||||
> **供智能体执行者:** 必须使用 `superpowers:subagent-driven-development`
|
||||
>(推荐)或 `superpowers:executing-plans` 逐项实施;所有步骤使用复选框跟踪。
|
||||
|
||||
**目标:** 修复 90 Hz 关节命令在稳定目标附近反复越界的问题,使 RM75 在保留
|
||||
现有速度、加速度限制和低跟随模式的前提下提前制动并稳定停止。
|
||||
|
||||
**架构:** 保留当前 QP、反馈和故障恢复链路,只替换
|
||||
`SingleArmVelocityTeleop._limit_joint_command_step()` 内部的关节命令生成规则。
|
||||
每个关节根据离散制动距离决定继续加速或开始减速,最终命令仍由现有
|
||||
`_send_joint_target()` 发送。
|
||||
|
||||
**技术栈:** Python 3.10、ROS2 Humble、NumPy、pytest、ament/colcon。
|
||||
|
||||
---
|
||||
|
||||
## 执行约束
|
||||
|
||||
- 设计文档:
|
||||
`docs/superpowers/specs/2026-07-30-rm75-joint-command-braking-design.md`。
|
||||
- 构建、测试和启动命令在 `/home/robot/WS_xr` 执行,并先运行
|
||||
`source /opt/ros/humble/setup.bash`。
|
||||
- 不连接真机,不发送真实 CANFD,不移动机械臂,不操作夹爪。
|
||||
- 启动验证只使用
|
||||
`xr_rm_bringup/launch/arm_debug.launch.py arm:=right use_mock:=true`。
|
||||
- 不修改 QP、YAML、RealMan 适配器、launch、UI、依赖和公开 API。
|
||||
- 保留工作空间、圆柱、TCP 速度、姿态速度、关节速度、关节加速度、超时保持、
|
||||
CANFD 恢复、Grip 重新使能和安全停止逻辑。
|
||||
- 用户未要求 Git 提交,因此本计划不执行 `git commit` 或 `git push`。
|
||||
- 保留工作区中已有的其他修改,不回退、不覆盖:
|
||||
`placo_ik_solver.py`、`test_placo_transforms.py` 及现有 Superpowers 文档。
|
||||
|
||||
## 文件范围
|
||||
|
||||
- 修改 `xr_rm_teleop/test/test_joint_control.py`
|
||||
- 增加固定目标提前制动回归测试。
|
||||
- 修改 `xr_rm_teleop/xr_rm_teleop/single_arm_velocity_teleop.py`
|
||||
- 在现有关节限幅入口实现离散制动距离判断。
|
||||
- 不创建新的运行时代码文件或配置项。
|
||||
|
||||
### 任务一:增加持续振荡回归测试
|
||||
|
||||
**文件:**
|
||||
|
||||
- 修改:`xr_rm_teleop/test/test_joint_control.py:206`
|
||||
- 测试:`xr_rm_teleop/test/test_joint_control.py`
|
||||
|
||||
- [x] **步骤 1:在现有首周期加速度测试后增加固定目标测试**
|
||||
|
||||
增加以下测试:
|
||||
|
||||
```python
|
||||
def test_joint_command_step_brakes_before_fixed_target_without_overshoot() -> None:
|
||||
dt = 1.0 / 90.0
|
||||
max_speed = math.radians(180.0)
|
||||
max_acceleration = math.radians(300.0)
|
||||
target = np.radians(
|
||||
[10.0, -10.0, 3.0, -3.0, 1.0, -1.0, 0.1]
|
||||
).tolist()
|
||||
command = [0.0] * 7
|
||||
velocity = [0.0] * 7
|
||||
|
||||
for _ in range(180):
|
||||
previous_velocity = list(velocity)
|
||||
command, velocity = (
|
||||
SingleArmVelocityTeleop._limit_joint_command_step(
|
||||
target=target,
|
||||
previous_target=command,
|
||||
previous_velocity=velocity,
|
||||
max_speed=max_speed,
|
||||
max_acceleration=max_acceleration,
|
||||
dt=dt,
|
||||
)
|
||||
)
|
||||
|
||||
for index in range(7):
|
||||
assert min(0.0, target[index]) - 1e-12 <= command[index]
|
||||
assert command[index] <= max(0.0, target[index]) + 1e-12
|
||||
assert abs(velocity[index]) <= max_speed + 1e-12
|
||||
assert (
|
||||
abs(velocity[index] - previous_velocity[index])
|
||||
<= max_acceleration * dt + 1e-12
|
||||
)
|
||||
|
||||
assert command == pytest.approx(target, abs=1e-12)
|
||||
assert velocity == pytest.approx([0.0] * 7, abs=1e-12)
|
||||
```
|
||||
|
||||
该测试同时覆盖正负方向、不同目标距离、最大速度、最大加速度、禁止越过固定目标
|
||||
和最终停止。
|
||||
|
||||
- [x] **步骤 2:运行新增测试并确认失败**
|
||||
|
||||
运行:
|
||||
|
||||
```bash
|
||||
source /opt/ros/humble/setup.bash
|
||||
PYTHONPATH="/home/robot/WS_xr/src/xr_rm_teleop:${PYTHONPATH:-}" \
|
||||
python3 -m pytest \
|
||||
src/xr_rm_teleop/test/test_joint_control.py::test_joint_command_step_brakes_before_fixed_target_without_overshoot \
|
||||
-v
|
||||
```
|
||||
|
||||
预期:`FAIL`,现有实现会让至少一个关节命令越过固定目标。失败原因必须来自新增
|
||||
越界断言,不能是导入或环境错误。
|
||||
|
||||
### 任务二:实现离散提前制动
|
||||
|
||||
**文件:**
|
||||
|
||||
- 修改:
|
||||
`xr_rm_teleop/xr_rm_teleop/single_arm_velocity_teleop.py:1232-1263`
|
||||
- 测试:`xr_rm_teleop/test/test_joint_control.py`
|
||||
|
||||
- [x] **步骤 1:用离散制动逻辑替换现有限幅计算**
|
||||
|
||||
保留方法签名和现有长度、参数校验,将
|
||||
`desired_velocity = np.clip(...)` 到返回值的部分替换为:
|
||||
|
||||
```python
|
||||
values = np.asarray(
|
||||
[target, previous_target, previous_velocity],
|
||||
dtype=float,
|
||||
)
|
||||
if not np.isfinite(values).all():
|
||||
raise ValueError("joint command contains NaN/Inf")
|
||||
|
||||
velocity_step = max_acceleration * dt
|
||||
arrival_distance = velocity_step * dt
|
||||
limited_target = []
|
||||
limited_velocity = []
|
||||
for desired_target, last_target, last_velocity in zip(
|
||||
target,
|
||||
previous_target,
|
||||
previous_velocity,
|
||||
):
|
||||
error = desired_target - last_target
|
||||
if (
|
||||
abs(last_velocity) <= 1e-12
|
||||
and abs(error) <= arrival_distance
|
||||
):
|
||||
velocity = error / dt
|
||||
position = desired_target
|
||||
else:
|
||||
direction = (
|
||||
math.copysign(1.0, error)
|
||||
if abs(error) > 1e-12
|
||||
else 0.0
|
||||
)
|
||||
accelerated_speed = min(
|
||||
abs(last_velocity) + velocity_step,
|
||||
max_speed,
|
||||
)
|
||||
braking_steps = max(
|
||||
0,
|
||||
math.ceil(accelerated_speed / velocity_step) - 1,
|
||||
)
|
||||
braking_distance = accelerated_speed * dt + dt * (
|
||||
braking_steps * accelerated_speed
|
||||
- velocity_step
|
||||
* braking_steps
|
||||
* (braking_steps + 1)
|
||||
/ 2.0
|
||||
)
|
||||
desired_velocity = direction * max_speed
|
||||
if (
|
||||
last_velocity * error > 0.0
|
||||
and abs(error) <= braking_distance
|
||||
):
|
||||
desired_velocity = 0.0
|
||||
velocity = _clamp(
|
||||
desired_velocity,
|
||||
last_velocity - velocity_step,
|
||||
last_velocity + velocity_step,
|
||||
)
|
||||
velocity = _clamp(velocity, -max_speed, max_speed)
|
||||
position = last_target + velocity * dt
|
||||
|
||||
limited_target.append(position)
|
||||
limited_velocity.append(velocity)
|
||||
|
||||
if not np.isfinite(limited_target).all():
|
||||
raise ValueError("joint command contains NaN/Inf")
|
||||
return limited_target, limited_velocity
|
||||
```
|
||||
|
||||
不要增加 ROS 参数或辅助类。制动距离直接使用当前方法已有的
|
||||
`max_acceleration`、`max_speed` 和 `dt`。
|
||||
|
||||
- [x] **步骤 2:运行新增测试并确认通过**
|
||||
|
||||
运行:
|
||||
|
||||
```bash
|
||||
source /opt/ros/humble/setup.bash
|
||||
PYTHONPATH="/home/robot/WS_xr/src/xr_rm_teleop:${PYTHONPATH:-}" \
|
||||
python3 -m pytest \
|
||||
src/xr_rm_teleop/test/test_joint_control.py::test_joint_command_step_brakes_before_fixed_target_without_overshoot \
|
||||
-v
|
||||
```
|
||||
|
||||
预期:`PASS`。
|
||||
|
||||
- [x] **步骤 3:运行关节控制测试文件**
|
||||
|
||||
运行:
|
||||
|
||||
```bash
|
||||
source /opt/ros/humble/setup.bash
|
||||
PYTHONPATH="/home/robot/WS_xr/src/xr_rm_teleop:${PYTHONPATH:-}" \
|
||||
python3 -m pytest src/xr_rm_teleop/test/test_joint_control.py -v
|
||||
```
|
||||
|
||||
预期:全部通过;现有
|
||||
`test_joint_command_step_limits_acceleration_from_rest` 继续通过,证明首周期
|
||||
加速度行为没有回归。
|
||||
|
||||
### 任务三:完整验证
|
||||
|
||||
**文件:**
|
||||
|
||||
- 不修改文件。
|
||||
|
||||
- [x] **步骤 1:运行 `xr_rm_teleop` 全部测试**
|
||||
|
||||
运行:
|
||||
|
||||
```bash
|
||||
source /opt/ros/humble/setup.bash
|
||||
export RM75_TEST_PYTHONPATH="/home/robot/WS_xr/src/xr_rm_teleop:/home/robot/miniconda3/envs/xr/lib/python3.10/site-packages:/home/robot/miniconda3/envs/xr/lib/python3.10/site-packages/cmeel.prefix/lib/python3.10/site-packages"
|
||||
PYTHONPATH="${RM75_TEST_PYTHONPATH}:${PYTHONPATH:-}" \
|
||||
python3 -m pytest \
|
||||
src/xr_rm_teleop/test -v
|
||||
```
|
||||
|
||||
预期:全部测试通过,无失败;真实 Placo 回归测试必须执行,不能因缺少模块而跳过。
|
||||
|
||||
- [x] **步骤 2:单独运行姿态控制测试**
|
||||
|
||||
运行:
|
||||
|
||||
```bash
|
||||
source /opt/ros/humble/setup.bash
|
||||
PYTHONPATH="/home/robot/WS_xr/src/xr_rm_teleop:${PYTHONPATH:-}" \
|
||||
python3 -m pytest \
|
||||
src/xr_rm_teleop/test/test_orientation_control.py -v
|
||||
```
|
||||
|
||||
预期:全部通过。
|
||||
|
||||
- [x] **步骤 3:构建工作空间**
|
||||
|
||||
运行:
|
||||
|
||||
```bash
|
||||
source /opt/ros/humble/setup.bash
|
||||
colcon build --symlink-install
|
||||
```
|
||||
|
||||
预期:`xr_rm_input`、`xr_rm_interfaces`、`xr_rm_teleop` 和 `xr_rm_bringup`
|
||||
全部构建成功。
|
||||
|
||||
- [x] **步骤 4:使用 mock 启动右臂统一 launch**
|
||||
|
||||
运行:
|
||||
|
||||
```bash
|
||||
source /opt/ros/humble/setup.bash
|
||||
source install/setup.bash
|
||||
timeout 10s ros2 launch xr_rm_bringup arm_debug.launch.py \
|
||||
arm:=right use_mock:=true
|
||||
```
|
||||
|
||||
预期:
|
||||
|
||||
- 节点日志显示控制周期约 `dt=0.0111s`;
|
||||
- 日志显示 `follow=False`;
|
||||
- 不连接厂商 SDK,不发送真实 CANFD;
|
||||
- 除 `timeout` 主动结束产生的退出状态外,没有 Python 异常或 ROS 错误。
|
||||
|
||||
- [x] **步骤 5:检查最终差异**
|
||||
|
||||
运行:
|
||||
|
||||
```bash
|
||||
git -C /home/robot/WS_xr/src diff --check
|
||||
git -C /home/robot/WS_xr/src status --short
|
||||
git -C /home/robot/WS_xr/src diff -- \
|
||||
xr_rm_teleop/xr_rm_teleop/single_arm_velocity_teleop.py \
|
||||
xr_rm_teleop/test/test_joint_control.py
|
||||
```
|
||||
|
||||
预期:
|
||||
|
||||
- `diff --check` 无输出;
|
||||
- 本次运行时代码改动只涉及上述两个文件;
|
||||
- 原有工作区修改仍保留;
|
||||
- 不存在提交或远程推送。
|
||||
|
||||
## 用户真机验证边界
|
||||
|
||||
自动验证完成后,只提供手动验证步骤,不由 Codex 操作真机:
|
||||
|
||||
1. 保持低跟随,从安全姿态和小于 5 mm 的上下位移开始;
|
||||
2. 手柄停止后观察机械臂是否立即减振并稳定;
|
||||
3. 确认无持续 QP、UDP、CANFD 或故障锁存错误后,再测试 10 mm;
|
||||
4. 若不再振荡但仍有不可接受的整臂大幅构型变化,停止扩大位移,转入独立的奇异点
|
||||
处理设计。
|
||||
@@ -0,0 +1,352 @@
|
||||
# RM75 QP 与 UDP 反馈周期修复实施计划
|
||||
|
||||
> **供代理执行:** 必须使用 `superpowers:subagent-driven-development`(推荐)或
|
||||
> `superpowers:executing-plans` 子技能,按任务逐项实施。步骤使用复选框
|
||||
>(`- [ ]`)跟踪。
|
||||
|
||||
**目标:** 将 QP 位置收敛阈值调整为 2 mm,并把项目配置的毫秒周期正确换算为
|
||||
睿尔曼 SDK 的 5 ms 周期单位,从根因上降低误触发 UDP 反馈超时的概率。
|
||||
|
||||
**架构:** 保持 ROS 参数和 YAML 中 `realtime_push_cycle_ms` 的毫秒语义,仅在
|
||||
`RealManAdapter.connect()` 的 SDK 边界执行单位换算。QP 只调整现有位置收敛常量;
|
||||
反馈超时状态机保持不变,只在首次超时日志中增加实际反馈年龄。
|
||||
|
||||
**技术栈:** Python 3.10、ROS2 Humble、pytest、ament/colcon、睿尔曼 Python
|
||||
API2、Placo 0.9.4。
|
||||
|
||||
---
|
||||
|
||||
## 文件范围
|
||||
|
||||
- 修改 `xr_rm_teleop/xr_rm_teleop/placo_ik_solver.py`:QP 位置收敛阈值。
|
||||
- 修改 `xr_rm_teleop/test/test_placo_transforms.py`:1.5 mm 近收敛结果测试。
|
||||
- 修改 `xr_rm_teleop/xr_rm_teleop/realman_adapter.py`:毫秒到 SDK 周期单位换算。
|
||||
- 修改 `xr_rm_teleop/test/test_initial_joint_pose.py`:5 ms、10 ms 换算测试。
|
||||
- 修改 `xr_rm_teleop/xr_rm_teleop/single_arm_velocity_teleop.py`:超时日志增加年龄。
|
||||
- 修改 `xr_rm_teleop/test/test_joint_control.py`:超时年龄日志测试。
|
||||
- 不修改 YAML、launch、UI、消息定义或依赖。
|
||||
|
||||
## 测试环境
|
||||
|
||||
所有命令从工作空间根目录 `/home/robot/WS_xr` 执行:
|
||||
|
||||
```bash
|
||||
source /opt/ros/humble/setup.bash
|
||||
export PYTHONPATH=/home/robot/WS_xr/src/xr_rm_teleop:/home/robot/miniconda3/envs/xr/lib/python3.10/site-packages:/home/robot/miniconda3/envs/xr/lib/python3.10/site-packages/cmeel.prefix/lib/python3.10/site-packages:${PYTHONPATH}
|
||||
```
|
||||
|
||||
### 任务 1:接受 2 mm 内的 QP 位置残差
|
||||
|
||||
**文件:**
|
||||
|
||||
- 修改:`xr_rm_teleop/test/test_placo_transforms.py`
|
||||
- 修改:`xr_rm_teleop/xr_rm_teleop/placo_ik_solver.py:14`
|
||||
|
||||
- [ ] **步骤 1:编写 1.5 mm 近收敛结果的失败测试**
|
||||
|
||||
在 `test_placo_transforms.py` 的 QP 测试附近增加:
|
||||
|
||||
```python
|
||||
def test_qp_solve_accepts_position_error_within_two_millimeters() -> None:
|
||||
solver = object.__new__(PlacoIkSolver)
|
||||
solver._actual_joints = np.zeros(7)
|
||||
solver._robot = SimpleNamespace(
|
||||
state=SimpleNamespace(q=np.zeros(14))
|
||||
)
|
||||
solver._frame_task = SimpleNamespace(T_world_frame=None)
|
||||
solver._target_errors = lambda: (1.5e-3, 0.0)
|
||||
|
||||
result = solver.solve(np.eye(4))
|
||||
|
||||
assert result == pytest.approx([0.0] * 7)
|
||||
```
|
||||
|
||||
并在文件顶部加入现有标准库类型:
|
||||
|
||||
```python
|
||||
from types import SimpleNamespace
|
||||
```
|
||||
|
||||
同时从 `placo_ik_solver` 导入现有收敛常量:
|
||||
|
||||
```python
|
||||
from xr_rm_teleop.placo_ik_solver import (
|
||||
QP_POSITION_TOLERANCE_M,
|
||||
PlacoIkSolver,
|
||||
_validated_transform,
|
||||
)
|
||||
```
|
||||
|
||||
将真实 Placo 可达目标测试的位置断言改为引用同一收敛常量:
|
||||
|
||||
```python
|
||||
assert position_error <= QP_POSITION_TOLERANCE_M
|
||||
```
|
||||
|
||||
再增加超过 2 mm 时仍拒绝结果的边界测试:
|
||||
|
||||
```python
|
||||
def test_qp_solve_rejects_position_error_above_two_millimeters() -> None:
|
||||
solver = object.__new__(PlacoIkSolver)
|
||||
solver._actual_joints = np.zeros(7)
|
||||
solver._robot = SimpleNamespace(
|
||||
state=SimpleNamespace(q=np.zeros(14)),
|
||||
update_kinematics=lambda: None,
|
||||
)
|
||||
solver._frame_task = SimpleNamespace(T_world_frame=None)
|
||||
solver._solver = SimpleNamespace(solve=lambda update: None)
|
||||
solver._validate_result = lambda result, previous: None
|
||||
solver._target_errors = lambda: (2.1e-3, 0.0)
|
||||
|
||||
with pytest.raises(RuntimeError, match="QP did not converge after 30"):
|
||||
solver.solve(np.eye(4))
|
||||
```
|
||||
|
||||
- [ ] **步骤 2:运行测试并确认当前实现失败**
|
||||
|
||||
```bash
|
||||
python3 -m pytest src/xr_rm_teleop/test/test_placo_transforms.py::test_qp_solve_accepts_position_error_within_two_millimeters -q
|
||||
```
|
||||
|
||||
预期:失败;当前 1 mm 阈值不会直接接收 1.5 mm 残差,测试对象缺少后续 QP
|
||||
求解器。
|
||||
|
||||
- [ ] **步骤 3:最小修改 QP 位置阈值**
|
||||
|
||||
在 `placo_ik_solver.py` 修改现有常量:
|
||||
|
||||
```python
|
||||
QP_POSITION_TOLERANCE_M = 2e-3
|
||||
```
|
||||
|
||||
保留 `QP_MAX_ITERATIONS = 30`、姿态阈值、有限值检查、关节位置和速度限制不变。
|
||||
|
||||
- [ ] **步骤 4:运行局部测试并确认通过**
|
||||
|
||||
```bash
|
||||
python3 -m pytest src/xr_rm_teleop/test/test_placo_transforms.py -q
|
||||
```
|
||||
|
||||
预期:该文件全部测试通过;1.5 mm 残差被接受,2.1 mm 残差仍在 30 次后被拒绝。
|
||||
|
||||
- [ ] **步骤 5:提交 QP 修改**
|
||||
|
||||
```bash
|
||||
git add src/xr_rm_teleop/xr_rm_teleop/placo_ik_solver.py src/xr_rm_teleop/test/test_placo_transforms.py
|
||||
git commit -m "fix: 放宽 RM75 QP 位置收敛阈值"
|
||||
```
|
||||
|
||||
### 任务 2:修正睿尔曼 UDP 实时上报周期单位
|
||||
|
||||
**文件:**
|
||||
|
||||
- 修改:`xr_rm_teleop/test/test_initial_joint_pose.py`
|
||||
- 修改:`xr_rm_teleop/xr_rm_teleop/realman_adapter.py:190-196`
|
||||
|
||||
- [ ] **步骤 1:将现有连接测试改为周期换算参数化测试**
|
||||
|
||||
将
|
||||
`test_connect_configures_udp_feedback_and_waits_for_first_frame` 改为:
|
||||
|
||||
```python
|
||||
@pytest.mark.parametrize(
|
||||
("cycle_ms", "sdk_cycle"),
|
||||
[(5, 1), (10, 2)],
|
||||
)
|
||||
def test_connect_converts_udp_feedback_cycle_to_sdk_units(
|
||||
monkeypatch,
|
||||
cycle_ms,
|
||||
sdk_cycle,
|
||||
) -> None:
|
||||
fake_sdk = _install_fake_sdk(monkeypatch)
|
||||
adapter = RealManAdapter(
|
||||
"127.0.0.1",
|
||||
8080,
|
||||
0,
|
||||
"192.168.192.148",
|
||||
8090,
|
||||
realtime_push_cycle_ms=cycle_ms,
|
||||
configure_safety_limits=False,
|
||||
)
|
||||
|
||||
adapter.connect()
|
||||
|
||||
arm = fake_sdk.RoboticArm.instance
|
||||
assert arm is not None
|
||||
assert arm.config.args == (
|
||||
sdk_cycle,
|
||||
True,
|
||||
8090,
|
||||
0,
|
||||
"192.168.192.148",
|
||||
)
|
||||
assert arm.callback is adapter._realtime_callback
|
||||
assert adapter.get_latest_joint_state() is not None
|
||||
assert not hasattr(adapter, "_feedback_thread")
|
||||
```
|
||||
|
||||
- [ ] **步骤 2:运行参数化测试并确认当前实现失败**
|
||||
|
||||
```bash
|
||||
python3 -m pytest src/xr_rm_teleop/test/test_initial_joint_pose.py::test_connect_converts_udp_feedback_cycle_to_sdk_units -q
|
||||
```
|
||||
|
||||
预期:两个用例均失败;当前代码把 5 和 10 直接传给 SDK,而预期分别为 1 和 2。
|
||||
|
||||
- [ ] **步骤 3:在 SDK 边界执行单位换算**
|
||||
|
||||
在 `RealManAdapter.connect()` 创建 `rm_realtime_push_config_t` 时修改第一个参数:
|
||||
|
||||
```python
|
||||
config = rm_realtime_push_config_t(
|
||||
self._realtime_push_cycle_ms // 5,
|
||||
True,
|
||||
self._realtime_push_port,
|
||||
0,
|
||||
self._realtime_push_host_ip,
|
||||
)
|
||||
```
|
||||
|
||||
构造函数已有“正的 5 ms 整数倍”校验,因此不新增辅助函数或重复校验。启动日志继续
|
||||
输出项目参数的真实毫秒值。
|
||||
|
||||
- [ ] **步骤 4:运行适配器测试并确认通过**
|
||||
|
||||
```bash
|
||||
python3 -m pytest src/xr_rm_teleop/test/test_initial_joint_pose.py -q
|
||||
```
|
||||
|
||||
预期:该文件全部测试通过;mock 测试仍不依赖厂商 SDK。
|
||||
|
||||
- [ ] **步骤 5:提交周期换算修改**
|
||||
|
||||
```bash
|
||||
git add src/xr_rm_teleop/xr_rm_teleop/realman_adapter.py src/xr_rm_teleop/test/test_initial_joint_pose.py
|
||||
git commit -m "fix: 修正 RM75 UDP 上报周期单位"
|
||||
```
|
||||
|
||||
### 任务 3:在 UDP 超时日志中打印反馈年龄
|
||||
|
||||
**文件:**
|
||||
|
||||
- 修改:`xr_rm_teleop/test/test_joint_control.py`
|
||||
- 修改:`xr_rm_teleop/xr_rm_teleop/single_arm_velocity_teleop.py:1036-1044`
|
||||
|
||||
- [ ] **步骤 1:扩展现有短暂超时测试,捕获并断言日志**
|
||||
|
||||
在
|
||||
`test_short_udp_timeout_repeats_last_limited_target_without_query` 中创建遥操对象后加入:
|
||||
|
||||
```python
|
||||
warnings = []
|
||||
teleop.get_logger = lambda: SimpleNamespace(
|
||||
warn=lambda message: warnings.append(message)
|
||||
)
|
||||
```
|
||||
|
||||
在现有断言末尾加入:
|
||||
|
||||
```python
|
||||
assert warnings == [
|
||||
"right_rm75 UDP关节反馈超时(age=200.0 ms),保持最后安全目标。"
|
||||
]
|
||||
```
|
||||
|
||||
- [ ] **步骤 2:运行测试并确认当前实现失败**
|
||||
|
||||
```bash
|
||||
python3 -m pytest src/xr_rm_teleop/test/test_joint_control.py::test_short_udp_timeout_repeats_last_limited_target_without_query -q
|
||||
```
|
||||
|
||||
预期:失败;当前日志中没有 `age=200.0 ms`。
|
||||
|
||||
- [ ] **步骤 3:给首次超时警告增加实际反馈年龄**
|
||||
|
||||
在 `_handle_stale_joint_feedback()` 中仅修改现有警告:
|
||||
|
||||
```python
|
||||
self.get_logger().warn(
|
||||
f"{self._arm_name} UDP关节反馈超时"
|
||||
f"(age={age * 1000.0:.1f} ms),保持最后安全目标。"
|
||||
)
|
||||
```
|
||||
|
||||
不改变 `_joint_feedback_ready` 的一次性日志条件、最后安全目标重发、500 ms
|
||||
重新同步、Grip 重使能和故障锁存逻辑。
|
||||
|
||||
- [ ] **步骤 4:运行关节控制测试并确认通过**
|
||||
|
||||
```bash
|
||||
python3 -m pytest src/xr_rm_teleop/test/test_joint_control.py -q
|
||||
```
|
||||
|
||||
预期:该文件全部测试通过。
|
||||
|
||||
- [ ] **步骤 5:提交诊断日志修改**
|
||||
|
||||
```bash
|
||||
git add src/xr_rm_teleop/xr_rm_teleop/single_arm_velocity_teleop.py src/xr_rm_teleop/test/test_joint_control.py
|
||||
git commit -m "fix: 补充 RM75 UDP 超时反馈年龄"
|
||||
```
|
||||
|
||||
### 任务 4:完整回归与 mock 启动验证
|
||||
|
||||
**文件:**
|
||||
|
||||
- 不修改生产代码。
|
||||
|
||||
- [ ] **步骤 1:运行 `xr_rm_teleop` 全部测试**
|
||||
|
||||
```bash
|
||||
python3 -m pytest src/xr_rm_teleop/test -q
|
||||
```
|
||||
|
||||
预期:全部通过,无失败或错误。
|
||||
|
||||
- [ ] **步骤 2:单独运行姿态控制测试**
|
||||
|
||||
```bash
|
||||
python3 -m pytest src/xr_rm_teleop/test/test_orientation_control.py -q
|
||||
```
|
||||
|
||||
预期:全部通过。
|
||||
|
||||
- [ ] **步骤 3:构建完整工作空间**
|
||||
|
||||
```bash
|
||||
colcon build --symlink-install
|
||||
```
|
||||
|
||||
预期:`xr_rm_interfaces`、`xr_rm_input`、`xr_rm_teleop`、`xr_rm_bringup`
|
||||
全部构建成功。
|
||||
|
||||
- [ ] **步骤 4:使用右臂 mock 启动统一 launch**
|
||||
|
||||
```bash
|
||||
timeout 15s ros2 launch xr_rm_bringup arm_debug.launch.py arm:=right use_mock:=true
|
||||
```
|
||||
|
||||
预期:节点正常启动,无 Python 异常或厂商 SDK 导入错误;因 `timeout` 主动终止,
|
||||
命令退出码可以是 124。禁止将 `use_mock` 改为 `false`。
|
||||
|
||||
- [ ] **步骤 5:检查最终差异**
|
||||
|
||||
```bash
|
||||
git status --short
|
||||
git diff HEAD~3 --check
|
||||
git diff HEAD~3 --stat
|
||||
```
|
||||
|
||||
预期:除本实施计划文档外,代码差异仅包含计划列出的 6 个代码/测试文件;无空白
|
||||
错误,不包含 YAML、launch、UI、依赖或用户其他改动。
|
||||
|
||||
## 真机验收
|
||||
|
||||
自动验证完成后,由用户在安全条件下启动真机右臂模式并观察:
|
||||
|
||||
1. `feedback_interval mean` 从约 25 ms 降到接近 5 ms;
|
||||
2. 正常遥操不再频繁触发 `UDP关节反馈超时`;
|
||||
3. 反馈确实超过 120 ms 时,日志显示实际 `age`,且仍保持最后安全目标;
|
||||
4. 位置残差小于 2 mm 时不再出现 QP 未收敛警告;
|
||||
5. 若仍频繁出现超过 120 ms 的中断,保留当前阈值,依据日志继续排查网络、SDK
|
||||
回调或控制器固件。
|
||||
@@ -0,0 +1,151 @@
|
||||
# RM75 关节反馈与故障恢复设计
|
||||
|
||||
## 目标
|
||||
|
||||
将当前 RM75 QP 遥操作链路调整为:
|
||||
|
||||
- 启动时使用 `rm_get_joint_degree()` 获取实际关节角并初始化 QP。
|
||||
- 运行时只把 RealMan UDP `joint_position` 作为连续实际关节反馈。
|
||||
- UDP 短暂超时时保持最后一次已限速的安全关节目标,不生成新运动。
|
||||
- UDP 持续超时或 CANFD 发送错误时,按明确的同步、停止和人工恢复流程处理。
|
||||
- `rm_movej_canfd()` 保持低跟随,控制频率使用 `xr_rm_teleop` 分支的 90 Hz。
|
||||
|
||||
不改变现有工作空间/圆柱限位、TCP与关节速度和加速度限制、XR命令超时、安全停止、外设控制及双臂节点名。
|
||||
|
||||
## 控制数据源
|
||||
|
||||
启动初始化与运行反馈使用不同的数据源:
|
||||
|
||||
1. `RealManAdapter` 建立现有唯一厂商连接。
|
||||
2. 节点同步调用一次 `rm_get_joint_degree()`。
|
||||
3. 查询成功后把7个角度转换为弧度,用于初始化 Placo QP、最后安全目标和关节限速历史。
|
||||
4. 查询失败时关闭适配器、打印错误并使节点启动失败,不发送 CANFD。
|
||||
5. 正常运行后,QP 的连续实际状态只来自已校验且运动状态正常的 UDP `joint_position`。
|
||||
|
||||
同步查询只用于启动、持续反馈超时恢复和 CANFD 错误恢复,不新增连接,不进行常态轮询。
|
||||
|
||||
## 状态与转换
|
||||
|
||||
### 正常运行
|
||||
|
||||
控制定时器以 90 Hz 执行。每个周期读取最新 UDP 关节快照,同步 QP,执行现有目标生成、安全限位、单步 QP、关节速度/加速度限制,然后调用:
|
||||
|
||||
```text
|
||||
rm_movej_canfd(target_degrees, follow=false, ...)
|
||||
```
|
||||
|
||||
只有已经通过关节限速并成功发送的目标才能成为“最后安全目标”。
|
||||
|
||||
### UDP 短暂超时
|
||||
|
||||
UDP 快照年龄超过现有 `command_timeout_sec=0.12` 秒、但未达到 `feedback_resync_timeout_sec=0.5` 秒时:
|
||||
|
||||
- 不使用过期反馈同步 QP。
|
||||
- 不运行目标生成和 QP。
|
||||
- 不更新任何目标、滤波器或限速历史。
|
||||
- 若超时前正在遥操作且已有成功发送的安全目标,以 90 Hz 原样重发该目标。
|
||||
- 若超时前未在遥操作或没有成功发送的目标,保持停止,不开始 CANFD 输出。
|
||||
- 首次进入时打印节流后的警告。
|
||||
|
||||
机械臂报警、关节掉使能、关节错误或非有限关节值不是普通超时,仍立即执行安全停止。
|
||||
|
||||
### UDP 持续超时
|
||||
|
||||
UDP 快照年龄达到 0.5 秒时,每次中断只同步调用一次 `rm_get_joint_degree()`:
|
||||
|
||||
- 查询成功:用实际角度重置 QP、最后安全目标和关节限速历史;不生成新运动,继续保持并等待 UDP 恢复。
|
||||
- 查询失败:调用 slow-stop,停止 CANFD,进入锁存故障并打印错误。
|
||||
|
||||
同一次中断不会反复查询。收到新的有效 UDP 反馈后,查询标志才复位。
|
||||
|
||||
### UDP 恢复
|
||||
|
||||
UDP 恢复后先持续同步实际关节状态,但不能直接恢复运动:
|
||||
|
||||
1. 当前 Grip 必须松开。
|
||||
2. 节点清除重新使能要求。
|
||||
3. 操作者再次按下 Grip,节点以新的手柄和机械臂实际位姿建立相对控制起点。
|
||||
|
||||
### CANFD 错误
|
||||
|
||||
`rm_movej_canfd()` 返回错误或抛出异常时:
|
||||
|
||||
1. 立即停止后续 CANFD 发送。
|
||||
2. 调用 slow-stop。
|
||||
3. 打印包含机械臂名称、命令名称和原始错误的终端错误日志。
|
||||
4. 调用 `rm_get_joint_degree()` 查询实际关节角。
|
||||
5. 查询成功时重置 QP和关节命令历史,但不再发送保持命令;等待有效 UDP 和 Grip 松开后重新按下。
|
||||
6. 查询失败时进入锁存故障并打印查询错误。
|
||||
|
||||
### 锁存故障
|
||||
|
||||
锁存故障只作用于发生错误的机械臂节点:
|
||||
|
||||
- 控制定时器不再查询、运行 QP或发送 CANFD。
|
||||
- slow-stop 只发送一次。
|
||||
- 后续 UDP 恢复或 Grip 操作不能自动解锁。
|
||||
- 终端保留明确错误信息,但不在每个周期重复刷屏。
|
||||
- 操作者检查后必须重启对应遥操作节点才能恢复。
|
||||
|
||||
## 代码边界
|
||||
|
||||
### `xr_rm_teleop/xr_rm_teleop/realman_adapter.py`
|
||||
|
||||
- 给真实与 mock 适配器增加同步关节角查询能力。
|
||||
- 复用现有厂商连接。
|
||||
- 校验返回码、数量和有限值,统一返回弧度。
|
||||
- 保留 UDP 回调作为运行时快照来源。
|
||||
|
||||
### `xr_rm_teleop/xr_rm_teleop/single_arm_velocity_teleop.py`
|
||||
|
||||
- 启动时查询并初始化 QP。
|
||||
- 增加 `feedback_resync_timeout_sec` 参数,默认 0.5 秒。
|
||||
- 校验 `feedback_resync_timeout_sec > command_timeout_sec > 0`。
|
||||
- 在现有控制周期内加入保持、一次性重新同步、等待 Grip 重使能和锁存判断。
|
||||
- 复用现有 `_safe_stop()`、Grip 重使能和关节限速逻辑,不新增状态机类。
|
||||
|
||||
### 配置
|
||||
|
||||
以下配置的 `control_rate_hz` 从 125 Hz 改为 90 Hz,并增加相同的 0.5 秒持续超时参数:
|
||||
|
||||
- `xr_rm_bringup/config/dual_arm_rm75.yaml`
|
||||
- `xr_rm_bringup/config/left_arm_rm75.yaml`
|
||||
- `xr_rm_bringup/config/right_arm_rm75.yaml`
|
||||
|
||||
三份配置继续使用 `follow: false`,双臂节点名保持 `left_arm_teleop` 和 `right_arm_teleop`。
|
||||
|
||||
## 错误日志
|
||||
|
||||
以下转换必须写入 ROS2 终端日志:
|
||||
|
||||
- 启动关节查询失败:`error`。
|
||||
- 首次进入 UDP 短暂超时:`warn`。
|
||||
- 持续超时查询开始及成功:`warn`/`info`。
|
||||
- 持续超时查询失败并锁存:`error`。
|
||||
- CANFD 发送失败:`error`。
|
||||
- CANFD 后关节查询失败并锁存:`error`。
|
||||
- UDP 恢复并等待 Grip 人工重使能:`info`。
|
||||
|
||||
日志包含机械臂名称和失败阶段;周期性路径使用状态转换或节流避免刷屏。
|
||||
|
||||
## 测试与验证
|
||||
|
||||
使用现有 mock 和单元测试完成,不连接真机:
|
||||
|
||||
1. 适配器正确解析 `rm_get_joint_degree()` 成功结果,并拒绝错误码、错误数量和 NaN/Inf。
|
||||
2. 启动查询结果初始化 QP 和安全目标;查询失败时节点不能进入控制。
|
||||
3. 0.12~0.5 秒反馈超时期间不调用 QP,只重发同一安全目标。
|
||||
4. 0.5 秒持续超时只查询一次;成功后等待 UDP 与 Grip,失败后锁存。
|
||||
5. CANFD 错误后停止发送并查询;查询成功要求 Grip 重使能,查询失败锁存。
|
||||
6. 机械臂报警或掉使能仍立即停止,不能进入保持路径。
|
||||
7. 三份配置均使用 90 Hz、0.5 秒持续超时和低跟随。
|
||||
|
||||
在工作空间根目录 `/home/robot/WS_xr` 执行:
|
||||
|
||||
```bash
|
||||
source /opt/ros/humble/setup.bash
|
||||
pytest src/xr_rm_teleop/test/test_initial_joint_pose.py
|
||||
pytest src/xr_rm_teleop/test/test_joint_control.py
|
||||
pytest src/xr_rm_teleop/test/test_orientation_control.py
|
||||
colcon build --symlink-install
|
||||
```
|
||||
@@ -0,0 +1,192 @@
|
||||
# RM75 QP 收敛与低跟随稳定性优化设计
|
||||
|
||||
## 背景
|
||||
|
||||
右臂真机以90 Hz、`follow: false`运行时,用户观察到:
|
||||
|
||||
- 手柄移动约10 cm后,`target_pose`很快稳定;
|
||||
- `current_pose`仍需约3秒缓慢追赶;
|
||||
- 运动过程中机械臂存在肉眼可见的轻微晃动。
|
||||
|
||||
现场 timing 日志同时表明:
|
||||
|
||||
- 控制周期约11.111 ms;
|
||||
- 控制回调平均约2.6 ms,最大约6.0 ms;
|
||||
- QP平均约0.39 ms;
|
||||
- CANFD发送平均约0.21 ms;
|
||||
- UDP实际关节反馈平均约25 ms一帧,即约40 Hz。
|
||||
|
||||
因此,控制线程、QP单次计算和CANFD调用本身没有耗尽90 Hz周期;慢速发生在
|
||||
`target_pose`生成之后。
|
||||
|
||||
## 根因
|
||||
|
||||
当前 `PlacoIkSolver.solve()` 每次只调用一次:
|
||||
|
||||
```python
|
||||
self._solver.solve(True)
|
||||
```
|
||||
|
||||
该调用把一次QP增量应用为 `q + Δq`。与此同时,90 Hz控制循环每次都会先用
|
||||
最新实际关节反馈重置Placo模型。由于实际反馈约40 Hz,同一帧反馈通常会被重复
|
||||
使用两到三次。
|
||||
|
||||
结果是每次下发的关节目标只位于实际关节角前方一小步,而不是当前TCP目标对应的
|
||||
收敛关节解。低跟随控制器持续追逐这个短距离移动点,表现为:
|
||||
|
||||
- 对稳定TCP目标呈缓慢的渐近追赶;
|
||||
- 实际反馈每约25 ms更新一次时,关节目标随反馈发生台阶式修正;
|
||||
- 低跟随内部平滑与台阶式关节目标叠加,形成轻微晃动。
|
||||
|
||||
本地RM75模型对照结果支持该判断:从右臂初始姿态求解7 cm平移目标时,单次QP
|
||||
只产生约7.6 mm TCP位移;在同一次逆解中连续迭代30次后,目标误差可降至接近
|
||||
零,计算耗时约3.56 ms。
|
||||
|
||||
## 目标
|
||||
|
||||
保持现有安全基线并实现:
|
||||
|
||||
- 手柄移动10 cm后,机械臂约1秒内稳定到位;
|
||||
- 运动和到位后无持续肉眼可见晃动;
|
||||
- 控制频率保持90 Hz;
|
||||
- `rm_movej_canfd()`保持低跟随;
|
||||
- 运行时仍以UDP `joint_position`作为实际关节反馈;
|
||||
- 保留工作空间、圆柱、TCP速度、姿态速度、关节速度与关节加速度限制;
|
||||
- 保留反馈超时、CANFD错误恢复、Grip重新使能和安全停止逻辑。
|
||||
|
||||
## 不在本次范围
|
||||
|
||||
- 不启用高跟随;
|
||||
- 不提高TCP或关节安全上限;
|
||||
- 不修改XR手柄滤波和坐标映射;
|
||||
- 不修改UDP反馈周期或增加反馈预测器;
|
||||
- 不新增线程、RealMan连接、依赖或状态机;
|
||||
- 不处理双臂碰撞检测。
|
||||
|
||||
## 方案比较
|
||||
|
||||
### 方案一:有限次数迭代QP
|
||||
|
||||
每个控制周期仍从实际关节角开始,但在一次 `solve()` 调用内部迭代QP,直到TCP
|
||||
目标收敛或达到固定迭代上限。得到的完整关节目标继续经过现有关节速度与加速度
|
||||
限幅后才发送。
|
||||
|
||||
优点:
|
||||
|
||||
- 直接修复单步QP只生成近距离移动点的根因;
|
||||
- 不需要预测状态,不会在反馈中断时继续外推;
|
||||
- 复用现有限速、错误回退和CANFD发送路径;
|
||||
- 本地测量表明计算量可放入90 Hz周期。
|
||||
|
||||
缺点:
|
||||
|
||||
- 单周期QP耗时会高于当前单步求解;
|
||||
- 不可达目标需要明确的未收敛处理。
|
||||
|
||||
### 方案二:反馈帧之间维护预测关节状态
|
||||
|
||||
仅在新UDP反馈到达时校正模型,其余90 Hz周期从上一条关节命令继续积分QP。
|
||||
|
||||
优点:
|
||||
|
||||
- 每周期仍只求解一次QP;
|
||||
- 可避免同一反馈帧反复重置模型。
|
||||
|
||||
缺点:
|
||||
|
||||
- 引入预测状态、反馈校正和漂移处理;
|
||||
- 反馈与预测偏差可能在校正时产生新的关节跳动;
|
||||
- 超时与恢复逻辑需要同时管理实际状态和预测状态。
|
||||
|
||||
### 方案三:只调整滤波、速度或高跟随参数
|
||||
|
||||
`target_pose`已经快速稳定,继续提高 `max_linear_speed` 或减小目标滤波不能解决
|
||||
下游渐近追赶。启用高跟随则违反本次低跟随约束。
|
||||
|
||||
## 决策
|
||||
|
||||
采用方案一。它在不引入预测状态的情况下直接修复根因,改动范围只涉及Placo
|
||||
求解器及其测试。
|
||||
|
||||
## 控制数据流
|
||||
|
||||
正常运行时的数据流调整为:
|
||||
|
||||
```text
|
||||
UDP实际关节反馈
|
||||
→ 更新Placo实际关节状态和current_pose
|
||||
→ 现有XR相对位姿、工作空间、圆柱、滤波和TCP限速
|
||||
→ 有限次数迭代QP,得到收敛关节目标
|
||||
→ 现有关节速度与加速度限幅
|
||||
→ rm_movej_canfd(..., follow=false)
|
||||
```
|
||||
|
||||
反馈短暂超时仍只以90 Hz重发最后一次已通过限速的关节目标,不运行QP。反馈持续
|
||||
超时和CANFD错误仍沿用现有同步、停止与故障锁存逻辑。
|
||||
|
||||
## QP迭代规则
|
||||
|
||||
`PlacoIkSolver.solve()`按以下规则执行:
|
||||
|
||||
1. 校验目标变换。
|
||||
2. 记录本次内部迭代前的关节状态。
|
||||
3. 调用一次 `self._solver.solve(True)`。
|
||||
4. 更新Placo运动学。
|
||||
5. 校验本次候选关节状态:
|
||||
- 7个有限数值;
|
||||
- 不违反RM75关节位置限制;
|
||||
- 本次数值迭代步长不超过Placo按 `dt=1/90` 应用的URDF关节速度限制。
|
||||
6. 使用位置任务与姿态任务的 `error_norm()`检查收敛:
|
||||
- 位置误差不超过1 mm;
|
||||
- 姿态误差不超过0.005 rad。
|
||||
7. 未收敛则继续迭代,最多30次。
|
||||
|
||||
30次后仍未收敛,或任一迭代产生非法结果时,抛出异常。节点复用现有
|
||||
`_solve_joint_target()`错误路径,在终端限频打印QP失败原因,并保持上一组安全
|
||||
关节目标。
|
||||
|
||||
内部迭代得到的是逆解目标,不会直接绕过发送限速。最终下发仍必须经过
|
||||
`_limit_joint_command_step()`,因此每个90 Hz真实命令继续满足现有
|
||||
`joint_max_speed` 和 `joint_max_acc`。
|
||||
|
||||
## 晃动抑制
|
||||
|
||||
本次不再叠加新的低通滤波器。晃动通过两层现有机制抑制:
|
||||
|
||||
1. QP先收敛到当前TCP目标对应的关节解,避免关节目标随40 Hz反馈只前进一小步;
|
||||
2. 最终关节目标由现有关节速度与加速度限幅器生成连续90 Hz命令。
|
||||
|
||||
若真机仍存在晃动,再根据“目标关节角与实际关节角误差”追加诊断;本次不预先
|
||||
引入预测器或额外滤波。
|
||||
|
||||
## 性能与安全验收
|
||||
|
||||
自动验证:
|
||||
|
||||
- 7 cm可达TCP平移目标在一次 `solve()` 后位置误差不超过1 mm;
|
||||
- 姿态误差满足0.005 rad阈值;
|
||||
- 非法结果和未收敛目标继续触发现有安全回退;
|
||||
- 关节命令速度与加速度限幅测试继续通过;
|
||||
- `xr_rm_teleop`全部pytest通过;
|
||||
- `colcon build --symlink-install`通过;
|
||||
- `arm_debug.launch.py arm:=right use_mock:=true`正常启动。
|
||||
|
||||
真机由用户验证:
|
||||
|
||||
- 手柄快速移动10 cm并保持不动,机械臂约1秒内稳定;
|
||||
- 无持续肉眼可见晃动;
|
||||
- 连续四个5秒 timing 窗口中 `total` 最大值低于11.111 ms;
|
||||
- 无QP失败、反馈超时、CANFD错误或故障锁存日志;
|
||||
- 松开Grip后仍立即退出遥操作并执行安全停止。
|
||||
|
||||
若单周期 `total` 达到或超过11.111 ms,停止真机运动并降低最大QP迭代次数,
|
||||
不得通过提高控制频率或关闭安全检查规避计算超时。
|
||||
|
||||
## 文件范围
|
||||
|
||||
- 修改 `xr_rm_teleop/xr_rm_teleop/placo_ik_solver.py`。
|
||||
- 修改 `xr_rm_teleop/test/test_placo_transforms.py`。
|
||||
- 如现有QP失败测试需要补充未收敛原因断言,只精确修改
|
||||
`xr_rm_teleop/test/test_joint_control.py`。
|
||||
|
||||
不修改三份机械臂YAML、RealMan适配器、launch、UI、依赖或公开入口。
|
||||
@@ -0,0 +1,99 @@
|
||||
# RM75 关节命令提前制动设计
|
||||
|
||||
## 背景
|
||||
|
||||
右臂真机保持 90 Hz 和 `follow: false`。有界迭代 QP 提高跟随速度后,手柄上下
|
||||
移动约 10 mm 时出现整臂剧烈晃动,并且到达目标后仍持续振荡。
|
||||
|
||||
现场日志表明控制计算未超时,但存在少量 QP 未收敛警告。离线检查确认:
|
||||
|
||||
- 手柄上下移动按现有映射对应机器人 X 方向;
|
||||
- 当前初始姿态的关节雅可比条件数约为 116,该方向的逆解对关节运动较敏感;
|
||||
- 机器人 X 方向 10 mm 的收敛逆解可能包含最大约 17° 的关节变化;
|
||||
- 现有关节命令限幅器只限制速度和加速度,没有根据剩余距离提前制动。
|
||||
|
||||
固定 10° 关节目标的离线复现中,现有限幅器运行 3 秒后仍处于约 10.56°、
|
||||
-20°/s,证明稳定目标本身也会被反复越过。这与真机“到位后继续晃动”的现象
|
||||
一致。
|
||||
|
||||
## 目标
|
||||
|
||||
- 关节目标稳定后,90 Hz 关节命令提前减速并停止在目标上;
|
||||
- 不再因命令限幅器反复越过目标而持续振荡;
|
||||
- 保留当前较快的有界迭代 QP;
|
||||
- 保留现有关节最大速度和最大加速度限制;
|
||||
- 保持 `rm_movej_canfd(..., follow=false)`;
|
||||
- 不改变 UDP 反馈、超时保持、CANFD 错误恢复和安全停止行为。
|
||||
|
||||
## 不在本次范围
|
||||
|
||||
- 不修改 QP 迭代次数、收敛阈值或失败回退;
|
||||
- 不修改初始姿态、XR 坐标映射或姿态控制;
|
||||
- 不增加奇异点阻尼、预测器、新线程、新依赖或新 ROS 参数;
|
||||
- 不修改三份机械臂 YAML、RealMan 适配器、launch 或 UI;
|
||||
- 不连接真机,不由 Codex 发送运动命令。
|
||||
|
||||
若修复制动后机械臂运动已经平稳,但上下运动仍伴随不可接受的整臂大幅构型变化,
|
||||
再单独设计奇异点处理;本次不把两个问题混在同一改动中。
|
||||
|
||||
## 方案
|
||||
|
||||
只修改 `SingleArmVelocityTeleop._limit_joint_command_step()`。
|
||||
|
||||
现有逻辑在目标仍位于运动方向前方时持续加速,只有到达或越过目标后才开始反向
|
||||
减速。新逻辑对每个关节使用同一组现有状态:
|
||||
|
||||
- 上一次已发送的关节目标;
|
||||
- 上一次关节命令速度;
|
||||
- 当前 QP 关节目标;
|
||||
- 现有关节最大速度、最大加速度和控制周期。
|
||||
|
||||
每周期按以下规则生成命令:
|
||||
|
||||
1. 计算关节剩余距离和单周期最大速度变化
|
||||
`velocity_step = max_acceleration * dt`。
|
||||
2. 根据 90 Hz 离散积分规则,计算“本周期再加速一次、随后以最大允许减速度制动”
|
||||
所需的总距离。
|
||||
3. 若关节正在朝目标运动,并且剩余距离已经不大于该制动距离,则本周期开始减速;
|
||||
否则继续朝目标加速,但不超过现有最大速度。
|
||||
4. 使用 `velocity_step` 限制本周期速度变化,保持现有加速度上限。
|
||||
5. 使用新速度积分得到本周期关节目标。
|
||||
6. 当关节已经停下且剩余距离不超过一个最大加速度位移
|
||||
`max_acceleration * dt²` 时,在不违反单周期加速度限制的前提下精确落到目标,
|
||||
避免离散步长形成极小往复振荡。
|
||||
|
||||
该逻辑只负责命令轨迹制动,不改变 QP 输出。对于持续移动的目标,若目标突然越过
|
||||
当前命令位置,控制器仍优先遵守加速度限制,以最大允许减速度反向;不会为禁止
|
||||
瞬时越界而跳变速度。
|
||||
|
||||
## 安全行为
|
||||
|
||||
- 每周期命令速度绝对值不超过 `joint_max_speed`;
|
||||
- 相邻周期间速度变化不超过 `joint_max_acc * dt`;
|
||||
- 输出继续校验 NaN 和 Inf;
|
||||
- QP 异常仍保持上一组安全目标并在终端限频打印警告;
|
||||
- UDP 短暂超时仍以 90 Hz 重发最后一次已限速目标,不运行 QP;
|
||||
- UDP 持续超时、CANFD 错误、Grip 重新使能和安全停止逻辑保持不变。
|
||||
|
||||
## 测试
|
||||
|
||||
先增加失败测试,再修改实现:
|
||||
|
||||
1. 对固定 10° 关节目标连续运行限幅器,验证命令不越过目标并最终停止;
|
||||
2. 在整个序列中验证速度不超过现有上限;
|
||||
3. 验证相邻命令速度变化不超过现有加速度上限;
|
||||
4. 保留现有“从静止开始限制首周期加速度”测试;
|
||||
5. 运行 `xr_rm_teleop` 全部测试;
|
||||
6. 按项目规则运行 `colcon build --symlink-install`;
|
||||
7. 使用 `arm_debug.launch.py arm:=right use_mock:=true`验证 90 Hz、低跟随和启动路径。
|
||||
|
||||
真机只由用户分阶段验证:先小位移、低风险姿态,确认不再到位后持续振荡,再逐步
|
||||
增加位移。若仍有明显整臂构型变化但不再振荡,应停止扩大位移并转入奇异点处理,
|
||||
不得通过提高速度、加速度或启用高跟随规避。
|
||||
|
||||
## 文件范围
|
||||
|
||||
- 修改 `xr_rm_teleop/xr_rm_teleop/single_arm_velocity_teleop.py`;
|
||||
- 修改 `xr_rm_teleop/test/test_joint_control.py`;
|
||||
- 新增本中文设计文档;
|
||||
- 后续新增一份中文实施计划。
|
||||
@@ -0,0 +1,176 @@
|
||||
# RM75 QP 阈值与 UDP 反馈周期修复设计
|
||||
|
||||
## 背景
|
||||
|
||||
右臂真机在 90 Hz、`follow: false` 遥操过程中频繁出现两类警告:
|
||||
|
||||
```text
|
||||
QP did not converge after 30 iterations:
|
||||
position_error=0.001245~0.001609 m
|
||||
|
||||
UDP关节反馈超时,保持最后安全目标。
|
||||
```
|
||||
|
||||
现场 timing 日志同时表明:
|
||||
|
||||
- 控制回调最大约 5.1 ms,没有耗尽 11.111 ms 周期;
|
||||
- UDP 反馈间隔均值约 25 ms,即实际约 40 Hz;
|
||||
- UDP 间隔存在 37~71 ms 的明显抖动;
|
||||
- UDP 短暂超时后约 44 ms 收到新帧,但现有安全状态机要求先松开 Grip。
|
||||
|
||||
因此,控制线程计算量不是这两类警告的原因。
|
||||
|
||||
## 根因
|
||||
|
||||
### QP 近阈值失败
|
||||
|
||||
当前 QP 最多迭代 30 次,并要求:
|
||||
|
||||
```text
|
||||
位置误差 <= 1 mm
|
||||
姿态误差 <= 0.005 rad
|
||||
```
|
||||
|
||||
现场失败时姿态误差约 0.0007 rad,已经满足要求;位置误差仅比 1 mm 高
|
||||
0.245~0.609 mm。30 次迭代中的每一步已经通过关节有限值、关节位置限制和单步
|
||||
速度限制校验,但最终结果仍因严格的 1 mm 判定被整体丢弃。
|
||||
|
||||
用户已明确确认 2 mm 位置残差可接受;该数值也与现有 1 mm 手柄位置死区处于
|
||||
同一量级。将位置收敛阈值改为 2 mm,可以接收现场这类安全的近收敛
|
||||
结果,同时继续拒绝此前出现过的 7.5 mm 等明显未收敛结果。
|
||||
|
||||
### UDP 周期单位错误
|
||||
|
||||
项目参数 `realtime_push_cycle_ms` 的单位是毫秒,三份机械臂配置均填写 `5`。
|
||||
当前适配器把这个值原样传给:
|
||||
|
||||
```python
|
||||
rm_realtime_push_config_t(cycle, ...)
|
||||
```
|
||||
|
||||
但睿尔曼 SDK 的 `cycle` 单位不是毫秒,而是 5 ms 的倍数。因此:
|
||||
|
||||
```text
|
||||
当前传入 cycle=5
|
||||
实际周期 = 5 × 5 ms = 25 ms
|
||||
实际频率 = 40 Hz
|
||||
```
|
||||
|
||||
这与现场 `feedback_interval mean≈25 ms` 完全一致。期望 5 ms 上报时,SDK
|
||||
参数应为 `cycle=1`。
|
||||
|
||||
在 120 ms 反馈超时窗口内,25 ms 上报只有约 5 次发送机会;修正为 5 ms 后有
|
||||
约 24 次发送机会,能显著提高对偶发丢包和调度抖动的容忍度。若网络或 SDK
|
||||
回调整体停顿超过 120 ms,仍应触发现有安全超时。
|
||||
|
||||
## 目标
|
||||
|
||||
- QP 位置收敛阈值由 1 mm 调整为 2 mm;
|
||||
- `realtime_push_cycle_ms: 5` 实际配置成 SDK `cycle=1`,恢复 5 ms 上报;
|
||||
- UDP 超时警告打印触发时的实际反馈年龄;
|
||||
- 保持控制频率 90 Hz 和 `follow: false`;
|
||||
- 保持 UDP 短超时保持、持续超时重同步、Grip 重使能、CANFD 恢复和故障锁存;
|
||||
- 保留工作空间、圆柱、TCP、姿态和关节安全限制。
|
||||
|
||||
## 不在本次范围
|
||||
|
||||
- 不增加 QP 最大迭代次数;
|
||||
- 不修改姿态收敛阈值;
|
||||
- 不放宽 `command_timeout_sec=0.12`;
|
||||
- 不修改 `feedback_resync_timeout_sec=0.5`;
|
||||
- 不允许短超时后自动恢复 Grip;
|
||||
- 不修改三份机械臂 YAML;
|
||||
- 不修改 UDP 目标 IP、端口、线程模式或增加新连接;
|
||||
- 不处理网卡、交换机或控制器固件问题;
|
||||
- 不修改用户当前未提交的 `right_arm_rm75.yaml` 参数调整。
|
||||
|
||||
## 修改方案
|
||||
|
||||
### QP 收敛
|
||||
|
||||
在 `placo_ik_solver.py` 中只修改:
|
||||
|
||||
```python
|
||||
QP_POSITION_TOLERANCE_M = 2e-3
|
||||
```
|
||||
|
||||
30 次迭代、逐步关节安全校验、姿态阈值和未收敛异常格式保持不变。
|
||||
|
||||
### UDP 周期换算
|
||||
|
||||
保留公开参数 `realtime_push_cycle_ms` 的毫秒语义和“正数且为 5 ms 倍数”的现有
|
||||
校验。构造 SDK 配置时执行:
|
||||
|
||||
```python
|
||||
sdk_cycle = self._realtime_push_cycle_ms // 5
|
||||
```
|
||||
|
||||
示例:
|
||||
|
||||
| 项目参数 | SDK `cycle` | 实际周期 |
|
||||
|---:|---:|---:|
|
||||
| 5 ms | 1 | 5 ms |
|
||||
| 10 ms | 2 | 10 ms |
|
||||
| 25 ms | 5 | 25 ms |
|
||||
|
||||
启动日志仍打印毫秒值,避免把 SDK 内部单位暴露为用户配置。
|
||||
|
||||
### UDP 超时日志
|
||||
|
||||
首次进入短暂超时时打印:
|
||||
|
||||
```text
|
||||
right_rm75 UDP关节反馈超时(age=xxx.x ms),保持最后安全目标。
|
||||
```
|
||||
|
||||
日志只增加诊断值,不改变节流、保持目标、QP 停止和 Grip 重使能行为。
|
||||
|
||||
## 数据流与安全
|
||||
|
||||
正常路径:
|
||||
|
||||
```text
|
||||
YAML 5 ms
|
||||
→ 适配器换算 SDK cycle=1
|
||||
→ 控制器约每 5 ms UDP 上报
|
||||
→ 回调校验并缓存 joint_position
|
||||
→ 90 Hz 控制读取最新实际反馈
|
||||
→ 最多 30 次 QP,位置阈值 2 mm
|
||||
→ 现有关节提前制动限幅
|
||||
→ rm_movej_canfd(..., follow=false)
|
||||
```
|
||||
|
||||
反馈年龄超过 120 ms 时仍停止生成新目标和 QP,以 90 Hz 重发最后安全目标,并
|
||||
要求 Grip 松开后重新使能。达到 500 ms 时仍只尝试一次
|
||||
`rm_get_joint_degree()`;失败时仍停止并锁存故障。
|
||||
|
||||
## 测试与验证
|
||||
|
||||
自动测试:
|
||||
|
||||
1. 增加 QP 1.5 mm 位置残差的行为测试,验证其被 2 mm 阈值接受;
|
||||
2. 保留明显未收敛结果抛出异常的行为;
|
||||
3. 修改 SDK 配置测试,验证项目 5 ms 参数传入 SDK 时为 `cycle=1`;
|
||||
4. 增加 10 ms 到 `cycle=2` 的换算覆盖;
|
||||
5. 验证 UDP 超时日志包含实际反馈年龄;
|
||||
6. 运行 `xr_rm_teleop` 全部测试和姿态控制测试;
|
||||
7. 运行 `colcon build --symlink-install`;
|
||||
8. 使用 `arm_debug.launch.py arm:=right use_mock:=true`验证启动路径。
|
||||
|
||||
真机由用户验证:
|
||||
|
||||
- 连续 timing 窗口中 `feedback_interval mean` 从约 25 ms 降到接近 5 ms;
|
||||
- 正常遥操不再频繁出现 UDP 超时;
|
||||
- 位置残差小于 2 mm 时不再出现 QP 未收敛警告;
|
||||
- 真正超过 120 ms 的反馈中断仍打印带 `age` 的警告并执行现有安全保持;
|
||||
- 若修正后仍频繁出现超过 120 ms 的中断,再依据 `age` 和间隔数据排查网络、
|
||||
SDK 回调或控制器固件,不继续盲目放宽超时。
|
||||
|
||||
## 文件范围
|
||||
|
||||
- 修改 `xr_rm_teleop/xr_rm_teleop/placo_ik_solver.py`;
|
||||
- 修改 `xr_rm_teleop/xr_rm_teleop/realman_adapter.py`;
|
||||
- 修改 `xr_rm_teleop/xr_rm_teleop/single_arm_velocity_teleop.py`;
|
||||
- 修改相关现有测试;
|
||||
- 新增本中文设计文档和后续中文实施计划;
|
||||
- 不修改 YAML、launch、UI 或依赖。
|
||||
@@ -1,20 +0,0 @@
|
||||
schema: spec-driven
|
||||
|
||||
# Project context (optional)
|
||||
# This is shown to AI when creating artifacts.
|
||||
# Add your tech stack, conventions, style guides, domain knowledge, etc.
|
||||
# Example:
|
||||
# context: |
|
||||
# Tech stack: TypeScript, React, Node.js
|
||||
# We use conventional commits
|
||||
# Domain: e-commerce platform
|
||||
|
||||
# Per-artifact rules (optional)
|
||||
# Add custom rules for specific artifacts.
|
||||
# Example:
|
||||
# rules:
|
||||
# proposal:
|
||||
# - Keep proposals under 500 words
|
||||
# - Always include a "Non-goals" section
|
||||
# tasks:
|
||||
# - Break tasks into chunks of max 2 hours
|
||||
@@ -1,10 +1,10 @@
|
||||
# 阶段一:PICO 遥操作双 RM75 平台配置。
|
||||
#
|
||||
# 当前控制方式是“相对位姿透传”:
|
||||
# 当前控制方式是“相对 TCP + 单步 QP”:
|
||||
# 按下 grip 时锁定当前手柄位姿和 TCP 位姿,之后将手柄相对位移和相对旋转
|
||||
# 映射为目标 TCP 位姿,经过工作空间限幅、目标低通、姿态低通和单帧步长
|
||||
# 限制后,通过 rm_movep_canfd 下发。cmd_vel 仅作为目标位姿变化率调试话题,
|
||||
# 不是机械臂执行命令。
|
||||
# 限制后,通过 Placo 单步 QP 和 rm_movej_canfd 下发 7 个关节目标。
|
||||
# cmd_vel 仅作为目标位姿变化率调试话题,不是机械臂执行命令。
|
||||
# 末端外设由 peripherals_rm75.yaml 配置,真机连接阶段初始化后由遥操作节点复用。
|
||||
|
||||
left_arm_teleop:
|
||||
@@ -13,6 +13,7 @@ left_arm_teleop:
|
||||
controller_topic: /xr/left_controller
|
||||
control_rate_hz: 90.0
|
||||
command_timeout_sec: 0.12
|
||||
feedback_resync_timeout_sec: 0.5
|
||||
|
||||
# 位姿目标生成与平滑参数。
|
||||
scale: 0.75
|
||||
@@ -26,9 +27,7 @@ left_arm_teleop:
|
||||
enable_orientation_axes: [true, true, true]
|
||||
orientation_deadband_rad: 0.005
|
||||
orientation_filter_alpha: 0.65
|
||||
max_orientation_speed: 0.6
|
||||
current_pose_poll_hz: 10.0
|
||||
|
||||
max_orientation_speed: 0.5
|
||||
workspace_min: [-0.70, -0.60, 0.10]
|
||||
workspace_max: [0.70, 0.40, 0.70]
|
||||
cyl_radius_limit: [0.20, 0.60]
|
||||
@@ -41,16 +40,20 @@ left_arm_teleop:
|
||||
0.0, 0.0, 1.0,
|
||||
-1.0, 0.0, 0.0]
|
||||
|
||||
use_mock: false
|
||||
mock_initial_pose: [-0.2562, -0.2765, 0.1489, -3.0190, -0.1010, 3.1400]
|
||||
robot_ip: 192.168.192.18
|
||||
robot_port: 8080
|
||||
realtime_push_host_ip: 192.168.192.148
|
||||
realtime_push_port: 8089
|
||||
realtime_push_cycle_ms: 5
|
||||
avoid_singularity: 0
|
||||
frame_type: 1
|
||||
follow: false
|
||||
canfd_trajectory_mode: 2
|
||||
canfd_radio: 0
|
||||
configure_safety_limits: true
|
||||
enable_tool_control: true
|
||||
enable_trigger_gripper_control: true
|
||||
trigger_close_threshold: 0.95
|
||||
configure_peripheral_on_connect: true
|
||||
max_line_speed: 1.0
|
||||
max_angular_speed: 1.5
|
||||
max_line_acc: 1.0
|
||||
@@ -68,6 +71,7 @@ right_arm_teleop:
|
||||
controller_topic: /xr/right_controller
|
||||
control_rate_hz: 90.0
|
||||
command_timeout_sec: 0.12
|
||||
feedback_resync_timeout_sec: 0.5
|
||||
|
||||
scale: 0.75
|
||||
deadband_m: 0.001
|
||||
@@ -80,9 +84,7 @@ right_arm_teleop:
|
||||
enable_orientation_axes: [true, true, true]
|
||||
orientation_deadband_rad: 0.005
|
||||
orientation_filter_alpha: 0.65
|
||||
max_orientation_speed: 0.6
|
||||
current_pose_poll_hz: 10.0
|
||||
|
||||
max_orientation_speed: 0.5
|
||||
workspace_min: [-0.70, -0.60, 0.10]
|
||||
workspace_max: [0.70, 0.40, 0.70]
|
||||
cyl_radius_limit: [0.20, 0.60]
|
||||
@@ -95,16 +97,20 @@ right_arm_teleop:
|
||||
0.0, 0.0, 1.0,
|
||||
1.0, 0.0, 0.0]
|
||||
|
||||
use_mock: false
|
||||
mock_initial_pose: [0.2663, -0.2606, 0.1027, 3.0330, 0.0000, 1.0910]
|
||||
robot_ip: 192.168.192.19
|
||||
robot_port: 8080
|
||||
realtime_push_host_ip: 192.168.192.148
|
||||
realtime_push_port: 8090
|
||||
realtime_push_cycle_ms: 5
|
||||
avoid_singularity: 1
|
||||
frame_type: 1
|
||||
follow: false
|
||||
canfd_trajectory_mode: 2
|
||||
canfd_radio: 0
|
||||
configure_safety_limits: true
|
||||
enable_tool_control: true
|
||||
enable_trigger_gripper_control: true
|
||||
trigger_close_threshold: 0.95
|
||||
configure_peripheral_on_connect: true
|
||||
max_line_speed: 1.0
|
||||
max_angular_speed: 1.5
|
||||
max_line_acc: 1.0
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# 左臂单独调试配置:XR 相对位姿透传控制 RM75 TCP。
|
||||
# 左臂单独调试配置:XR TCP 目标经 Placo QP 转换为 RM75 关节目标。
|
||||
# 末端外设由 peripherals_rm75.yaml 配置,真机连接阶段初始化后由遥操作节点复用。
|
||||
|
||||
single_arm_velocity_teleop:
|
||||
@@ -7,6 +7,7 @@ single_arm_velocity_teleop:
|
||||
controller_topic: /xr/left_controller
|
||||
control_rate_hz: 90.0
|
||||
command_timeout_sec: 0.12
|
||||
feedback_resync_timeout_sec: 0.5
|
||||
|
||||
# 手柄相对位姿 -> 目标 TCP 位姿;随后做目标低通、姿态低通和单帧步长限制。
|
||||
scale: 1.0
|
||||
@@ -20,9 +21,7 @@ single_arm_velocity_teleop:
|
||||
enable_orientation_axes: [true, true, true]
|
||||
orientation_deadband_rad: 0.005
|
||||
orientation_filter_alpha: 0.65
|
||||
max_orientation_speed: 0.6
|
||||
current_pose_poll_hz: 10.0
|
||||
|
||||
max_orientation_speed: 0.5
|
||||
workspace_min: [-0.70, -0.60, 0.10]
|
||||
workspace_max: [0.70, 0.40, 0.70]
|
||||
cyl_radius_limit: [0.20, 0.60]
|
||||
@@ -34,23 +33,27 @@ single_arm_velocity_teleop:
|
||||
0.0, 0.0, 1.0,
|
||||
-1.0, 0.0, 0.0]
|
||||
|
||||
use_mock: false
|
||||
mock_initial_pose: [-0.2562, -0.2765, 0.1489, -3.0190, -0.1010, 3.1400]
|
||||
robot_ip: 192.168.192.18
|
||||
robot_port: 8080
|
||||
realtime_push_host_ip: 192.168.192.148
|
||||
realtime_push_port: 8089
|
||||
realtime_push_cycle_ms: 5
|
||||
avoid_singularity: 0
|
||||
frame_type: 1
|
||||
follow: false
|
||||
canfd_trajectory_mode: 2
|
||||
canfd_radio: 0
|
||||
configure_safety_limits: true
|
||||
enable_tool_control: true
|
||||
enable_trigger_gripper_control: true
|
||||
trigger_close_threshold: 0.95
|
||||
configure_peripheral_on_connect: true
|
||||
max_line_speed: 1.0
|
||||
max_angular_speed: 1.5
|
||||
max_line_acc: 1.0
|
||||
max_angular_acc: 2.0
|
||||
joint_max_speed: 180.0
|
||||
joint_max_acc: 180.0
|
||||
move_to_initial_pose_on_connect: true
|
||||
move_to_initial_pose_on_connect: false
|
||||
initial_joint_pose: [-79.55, -9.99, 71.01, 101.45, 95.07, -84.47, -74.52]
|
||||
init_move_speed: 20
|
||||
debug_topic_prefix: /xr_rm
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# 右臂单独调试配置:XR 相对位姿透传控制 RM75 TCP。
|
||||
# 右臂单独调试配置:XR TCP 目标经 Placo QP 转换为 RM75 关节目标。
|
||||
# 末端外设由 peripherals_rm75.yaml 配置,真机连接阶段初始化后由遥操作节点复用。
|
||||
|
||||
single_arm_velocity_teleop:
|
||||
@@ -7,6 +7,7 @@ single_arm_velocity_teleop:
|
||||
controller_topic: /xr/right_controller
|
||||
control_rate_hz: 90.0
|
||||
command_timeout_sec: 0.12
|
||||
feedback_resync_timeout_sec: 0.5
|
||||
|
||||
scale: 0.7
|
||||
deadband_m: 0.001
|
||||
@@ -20,11 +21,9 @@ single_arm_velocity_teleop:
|
||||
orientation_deadband_rad: 0.005
|
||||
orientation_filter_alpha: 0.65
|
||||
max_orientation_speed: 0.5
|
||||
current_pose_poll_hz: 10.0
|
||||
|
||||
workspace_min: [-0.60, -0.60, 0.10]
|
||||
workspace_max: [0.60, 0.70, 0.55]
|
||||
cyl_radius_limit: [0.10, 0.70]
|
||||
workspace_min: [-0.70, -0.70, 0.10]
|
||||
workspace_max: [0.70, 0.70, 0.75]
|
||||
cyl_radius_limit: [0.10, 0.80]
|
||||
low_z_threshold: 0.1
|
||||
low_z_min_radius: 0.1
|
||||
|
||||
@@ -33,23 +32,28 @@ single_arm_velocity_teleop:
|
||||
0.0, 0.0, 1.0,
|
||||
1.0, 0.0, 0.0]
|
||||
|
||||
use_mock: false
|
||||
mock_initial_pose: [0.2663, -0.2606, 0.1027, 3.0330, 0.0000, 1.0910]
|
||||
robot_ip: 192.168.192.19
|
||||
robot_port: 8080
|
||||
realtime_push_host_ip: 192.168.192.148
|
||||
realtime_push_port: 8090
|
||||
realtime_push_cycle_ms: 5
|
||||
avoid_singularity: 1
|
||||
frame_type: 1
|
||||
# 厂商 MovejCANFD 示例默认低跟随;高跟随仅在验证规划轨迹后单独开启。
|
||||
follow: false
|
||||
canfd_trajectory_mode: 2
|
||||
canfd_radio: 0
|
||||
configure_safety_limits: true
|
||||
max_line_speed: 1.0
|
||||
max_angular_speed: 1.5
|
||||
max_line_acc: 1.0
|
||||
max_angular_acc: 2.0
|
||||
enable_tool_control: true
|
||||
enable_trigger_gripper_control: true
|
||||
trigger_close_threshold: 0.95
|
||||
configure_peripheral_on_connect: true
|
||||
max_line_speed: 0.25
|
||||
max_angular_speed: 0.6
|
||||
max_line_acc: 1.3
|
||||
max_angular_acc: 3.0
|
||||
joint_max_speed: 180.0
|
||||
joint_max_acc: 180.0
|
||||
move_to_initial_pose_on_connect: true
|
||||
joint_max_acc: 300.0
|
||||
move_to_initial_pose_on_connect: false
|
||||
initial_joint_pose: [-90.14, 3.76, -86.89, 87.89, -96.53, -79.62, -90.04]
|
||||
init_move_speed: 20
|
||||
debug_topic_prefix: /xr_rm
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
手柄接收节点,再根据 `arm:=left|right|both` 选择对应的遥操作节点。
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from launch import LaunchDescription
|
||||
from launch.actions import DeclareLaunchArgument, OpaqueFunction
|
||||
from launch.substitutions import LaunchConfiguration, PathJoinSubstitution
|
||||
@@ -12,6 +14,9 @@ from launch_ros.actions import Node
|
||||
from launch_ros.substitutions import FindPackageShare
|
||||
|
||||
|
||||
XR_PYTHON = "/home/robot/miniconda3/envs/xr/bin/python"
|
||||
|
||||
|
||||
def _as_bool(value: str) -> bool:
|
||||
"""把 launch 字符串参数转换成 Python bool,便于在 OpaqueFunction 中分支。"""
|
||||
return value.strip().lower() in ("1", "true", "yes", "on")
|
||||
@@ -26,8 +31,14 @@ def _config_file(name: str) -> PathJoinSubstitution:
|
||||
])
|
||||
|
||||
|
||||
def _initial_pose_override(value: str) -> dict[str, bool]:
|
||||
return {} if value == "auto" else {"move_to_initial_pose_on_connect": _as_bool(value)}
|
||||
def _rm75_urdf() -> PathJoinSubstitution:
|
||||
return PathJoinSubstitution([
|
||||
FindPackageShare("xr_rm_teleop"),
|
||||
"models",
|
||||
"rm75_omnipicker",
|
||||
"urdf",
|
||||
"RM75-B_OmniPicker_fixed.urdf",
|
||||
])
|
||||
|
||||
|
||||
def _udp_receiver_node() -> Node:
|
||||
@@ -50,42 +61,21 @@ def _udp_receiver_node() -> Node:
|
||||
def _single_arm_node(
|
||||
arm: str,
|
||||
use_mock: bool,
|
||||
move_to_initial_pose: str,
|
||||
avoid_singularity: int,
|
||||
frame_type: int,
|
||||
control_rate_hz: float,
|
||||
follow: bool,
|
||||
configure_safety_limits: bool,
|
||||
enable_tool_control: bool,
|
||||
enable_trigger_gripper_control: bool,
|
||||
trigger_close_threshold: float,
|
||||
configure_peripheral_on_connect: bool,
|
||||
) -> Node:
|
||||
"""创建单臂调试节点;左/右臂分别使用独立 YAML,节点名保持单臂默认名。"""
|
||||
config_name = "left_arm_rm75.yaml" if arm == "left" else "right_arm_rm75.yaml"
|
||||
robot_ip = LaunchConfiguration("left_robot_ip" if arm == "left" else "right_robot_ip")
|
||||
arm_name = _arm_name(arm)
|
||||
return Node(
|
||||
package="xr_rm_teleop",
|
||||
executable="single_arm_velocity_teleop",
|
||||
name="single_arm_velocity_teleop",
|
||||
output="screen",
|
||||
prefix=[XR_PYTHON],
|
||||
parameters=[
|
||||
_config_file(config_name),
|
||||
{
|
||||
"use_mock": use_mock,
|
||||
"robot_ip": robot_ip,
|
||||
"robot_port": LaunchConfiguration("robot_port"),
|
||||
"avoid_singularity": avoid_singularity,
|
||||
"frame_type": frame_type,
|
||||
"control_rate_hz": control_rate_hz,
|
||||
"follow": follow,
|
||||
"configure_safety_limits": configure_safety_limits,
|
||||
**_initial_pose_override(move_to_initial_pose),
|
||||
"enable_tool_control": enable_tool_control,
|
||||
"enable_trigger_gripper_control": enable_trigger_gripper_control,
|
||||
"trigger_close_threshold": trigger_close_threshold,
|
||||
"configure_peripheral_on_connect": configure_peripheral_on_connect,
|
||||
"robot_urdf_path": _rm75_urdf(),
|
||||
"peripheral_config_file": _config_file("peripherals_rm75.yaml"),
|
||||
"peripheral_arm": arm,
|
||||
"tool_command_topic": f"/xr_rm/{arm_name}/tool_enable",
|
||||
@@ -98,20 +88,7 @@ def _arm_name(arm: str) -> str:
|
||||
return "left_rm75" if arm == "left" else "right_rm75"
|
||||
|
||||
|
||||
def _dual_arm_nodes(
|
||||
use_mock: bool,
|
||||
move_to_initial_pose: str,
|
||||
left_avoid_singularity: int,
|
||||
right_avoid_singularity: int,
|
||||
frame_type: int,
|
||||
control_rate_hz: float,
|
||||
follow: bool,
|
||||
configure_safety_limits: bool,
|
||||
enable_tool_control: bool,
|
||||
enable_trigger_gripper_control: bool,
|
||||
trigger_close_threshold: float,
|
||||
configure_peripheral_on_connect: bool,
|
||||
) -> list[Node]:
|
||||
def _dual_arm_nodes(use_mock: bool) -> list[Node]:
|
||||
"""创建双臂节点;两个节点共用双臂 YAML,但节点名区分左右臂参数命名空间。"""
|
||||
config_file = _config_file("dual_arm_rm75.yaml")
|
||||
return [
|
||||
@@ -120,22 +97,12 @@ def _dual_arm_nodes(
|
||||
executable="single_arm_velocity_teleop",
|
||||
name="left_arm_teleop",
|
||||
output="screen",
|
||||
prefix=[XR_PYTHON],
|
||||
parameters=[
|
||||
config_file,
|
||||
{
|
||||
"use_mock": use_mock,
|
||||
"robot_ip": LaunchConfiguration("left_robot_ip"),
|
||||
"robot_port": LaunchConfiguration("robot_port"),
|
||||
"avoid_singularity": left_avoid_singularity,
|
||||
"frame_type": frame_type,
|
||||
"control_rate_hz": control_rate_hz,
|
||||
"follow": follow,
|
||||
"configure_safety_limits": configure_safety_limits,
|
||||
**_initial_pose_override(move_to_initial_pose),
|
||||
"enable_tool_control": enable_tool_control,
|
||||
"enable_trigger_gripper_control": enable_trigger_gripper_control,
|
||||
"trigger_close_threshold": trigger_close_threshold,
|
||||
"configure_peripheral_on_connect": configure_peripheral_on_connect,
|
||||
"robot_urdf_path": _rm75_urdf(),
|
||||
"peripheral_config_file": _config_file("peripherals_rm75.yaml"),
|
||||
"peripheral_arm": "left",
|
||||
"tool_command_topic": "/xr_rm/left_rm75/tool_enable",
|
||||
@@ -147,22 +114,12 @@ def _dual_arm_nodes(
|
||||
executable="single_arm_velocity_teleop",
|
||||
name="right_arm_teleop",
|
||||
output="screen",
|
||||
prefix=[XR_PYTHON],
|
||||
parameters=[
|
||||
config_file,
|
||||
{
|
||||
"use_mock": use_mock,
|
||||
"robot_ip": LaunchConfiguration("right_robot_ip"),
|
||||
"robot_port": LaunchConfiguration("robot_port"),
|
||||
"avoid_singularity": right_avoid_singularity,
|
||||
"frame_type": frame_type,
|
||||
"control_rate_hz": control_rate_hz,
|
||||
"follow": follow,
|
||||
"configure_safety_limits": configure_safety_limits,
|
||||
**_initial_pose_override(move_to_initial_pose),
|
||||
"enable_tool_control": enable_tool_control,
|
||||
"enable_trigger_gripper_control": enable_trigger_gripper_control,
|
||||
"trigger_close_threshold": trigger_close_threshold,
|
||||
"configure_peripheral_on_connect": configure_peripheral_on_connect,
|
||||
"robot_urdf_path": _rm75_urdf(),
|
||||
"peripheral_config_file": _config_file("peripherals_rm75.yaml"),
|
||||
"peripheral_arm": "right",
|
||||
"tool_command_topic": "/xr_rm/right_rm75/tool_enable",
|
||||
@@ -175,76 +132,22 @@ def _dual_arm_nodes(
|
||||
def _launch_setup(context, *args, **kwargs):
|
||||
"""运行时读取 launch 参数,决定启动单臂还是双臂。"""
|
||||
del args, kwargs
|
||||
if not Path(XR_PYTHON).is_file():
|
||||
raise RuntimeError(
|
||||
f"XR Python not found: {XR_PYTHON}; "
|
||||
"Placo 0.9.4 must not be installed globally"
|
||||
)
|
||||
arm = LaunchConfiguration("arm").perform(context).strip().lower()
|
||||
use_mock = _as_bool(LaunchConfiguration("use_mock").perform(context))
|
||||
move_to_initial_pose = LaunchConfiguration(
|
||||
"move_to_initial_pose_on_connect"
|
||||
).perform(context).strip().lower()
|
||||
avoid_override = LaunchConfiguration("avoid_singularity").perform(context).strip()
|
||||
left_avoid_singularity = int(
|
||||
avoid_override or LaunchConfiguration("left_avoid_singularity").perform(context)
|
||||
)
|
||||
right_avoid_singularity = int(
|
||||
avoid_override or LaunchConfiguration("right_avoid_singularity").perform(context)
|
||||
)
|
||||
frame_type = int(LaunchConfiguration("frame_type").perform(context))
|
||||
control_rate_hz = float(LaunchConfiguration("control_rate_hz").perform(context))
|
||||
follow = _as_bool(LaunchConfiguration("follow").perform(context))
|
||||
configure_safety_limits = _as_bool(
|
||||
LaunchConfiguration("configure_safety_limits").perform(context)
|
||||
)
|
||||
configure_peripheral_on_connect = _as_bool(
|
||||
LaunchConfiguration("configure_peripheral_on_connect").perform(context)
|
||||
)
|
||||
enable_tool_control = _as_bool(
|
||||
LaunchConfiguration("enable_tool_control").perform(context)
|
||||
)
|
||||
enable_trigger_gripper_control = _as_bool(
|
||||
LaunchConfiguration("enable_trigger_gripper_control").perform(context)
|
||||
)
|
||||
trigger_close_threshold = float(
|
||||
LaunchConfiguration("trigger_close_threshold").perform(context)
|
||||
)
|
||||
|
||||
if arm not in ("left", "right", "both"):
|
||||
raise ValueError("arm must be one of: left, right, both")
|
||||
|
||||
nodes = [_udp_receiver_node()]
|
||||
if arm == "both":
|
||||
nodes.extend(
|
||||
_dual_arm_nodes(
|
||||
use_mock,
|
||||
move_to_initial_pose,
|
||||
left_avoid_singularity,
|
||||
right_avoid_singularity,
|
||||
frame_type,
|
||||
control_rate_hz,
|
||||
follow,
|
||||
configure_safety_limits,
|
||||
enable_tool_control,
|
||||
enable_trigger_gripper_control,
|
||||
trigger_close_threshold,
|
||||
configure_peripheral_on_connect,
|
||||
)
|
||||
)
|
||||
nodes.extend(_dual_arm_nodes(use_mock))
|
||||
else:
|
||||
avoid_singularity = left_avoid_singularity if arm == "left" else right_avoid_singularity
|
||||
nodes.append(
|
||||
_single_arm_node(
|
||||
arm,
|
||||
use_mock,
|
||||
move_to_initial_pose,
|
||||
avoid_singularity,
|
||||
frame_type,
|
||||
control_rate_hz,
|
||||
follow,
|
||||
configure_safety_limits,
|
||||
enable_tool_control,
|
||||
enable_trigger_gripper_control,
|
||||
trigger_close_threshold,
|
||||
configure_peripheral_on_connect,
|
||||
)
|
||||
)
|
||||
nodes.append(_single_arm_node(arm, use_mock))
|
||||
return nodes
|
||||
|
||||
|
||||
@@ -259,30 +162,6 @@ def generate_launch_description() -> LaunchDescription:
|
||||
DeclareLaunchArgument("udp_port", default_value="15000"),
|
||||
# UDP receiver 轮询频率高于 PICO 发送频率,减少 socket 中等待时间。
|
||||
DeclareLaunchArgument("udp_timer_hz", default_value="200.0"),
|
||||
# 左右 RM75 默认 IP,可在命令行中按现场网络覆盖。
|
||||
DeclareLaunchArgument("left_robot_ip", default_value="192.168.192.18"),
|
||||
DeclareLaunchArgument("right_robot_ip", default_value="192.168.192.19"),
|
||||
DeclareLaunchArgument("robot_port", default_value="8080"),
|
||||
# 真机位姿透传与安全配置参数。
|
||||
DeclareLaunchArgument("left_avoid_singularity", default_value="0"),
|
||||
DeclareLaunchArgument("right_avoid_singularity", default_value="1"),
|
||||
# 非空时作为左右臂全局覆盖,例如 avoid_singularity:=0。
|
||||
DeclareLaunchArgument("avoid_singularity", default_value=""),
|
||||
DeclareLaunchArgument("frame_type", default_value="1"),
|
||||
# 现场调参入口:默认按 PICO 90Hz 输入节奏发送 rm_movep_canfd。
|
||||
DeclareLaunchArgument("control_rate_hz", default_value="90.0"),
|
||||
# 默认低跟随;高跟随请确认控制器和网络能稳定满足厂商周期要求后再打开。
|
||||
DeclareLaunchArgument("follow", default_value="false"),
|
||||
DeclareLaunchArgument("configure_safety_limits", default_value="true"),
|
||||
# 工具控制通过遥操作节点复用同一个 RealMan 连接,避免两个进程抢同一机械臂连接。
|
||||
DeclareLaunchArgument("enable_tool_control", default_value="true"),
|
||||
# trigger 上升沿切换夹爪开/关;grip 仍只控制机械臂运动。
|
||||
DeclareLaunchArgument("enable_trigger_gripper_control", default_value="true"),
|
||||
DeclareLaunchArgument("trigger_close_threshold", default_value="0.95"),
|
||||
# 连接成功后是否配置外设;关闭后仅订阅开合话题,但开合前需要另行完成外设配置。
|
||||
DeclareLaunchArgument("configure_peripheral_on_connect", default_value="true"),
|
||||
# auto 时由单/双臂 YAML 决定;也可显式传 true/false 覆盖。
|
||||
DeclareLaunchArgument("move_to_initial_pose_on_connect", default_value="auto"),
|
||||
# OpaqueFunction 允许根据 arm/use_mock 等运行时参数动态生成节点。
|
||||
OpaqueFunction(function=_launch_setup),
|
||||
])
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import importlib.util
|
||||
import signal
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
MODULE_PATH = Path(__file__).parents[1] / "tools" / "launcher_ui.py"
|
||||
SPEC = importlib.util.spec_from_file_location("launcher_ui", MODULE_PATH)
|
||||
launcher_ui = importlib.util.module_from_spec(SPEC)
|
||||
assert SPEC.loader is not None
|
||||
SPEC.loader.exec_module(launcher_ui)
|
||||
|
||||
|
||||
class LauncherCleanupTest(unittest.TestCase):
|
||||
def test_xrobotoolkit_cleanup_policy_only_stops_service_on_window_close(self) -> None:
|
||||
stop_all_patterns = set(
|
||||
launcher_ui._xrobotoolkit_cleanup_patterns(stop_pc_service=False)
|
||||
)
|
||||
window_close_patterns = set(
|
||||
launcher_ui._xrobotoolkit_cleanup_patterns(stop_pc_service=True)
|
||||
)
|
||||
|
||||
self.assertLessEqual(
|
||||
{"RobotLinuxDemo.x86_64", "PXREAClientUnity"},
|
||||
stop_all_patterns,
|
||||
)
|
||||
self.assertNotIn("RoboticsServiceProcess", stop_all_patterns)
|
||||
self.assertIn("RoboticsServiceProcess", window_close_patterns)
|
||||
|
||||
def test_close_and_stop_all_select_different_pc_service_policies(self) -> None:
|
||||
app = object.__new__(launcher_ui.LauncherApp)
|
||||
calls = []
|
||||
|
||||
class Root:
|
||||
destroyed = False
|
||||
|
||||
def destroy(self) -> None:
|
||||
self.destroyed = True
|
||||
|
||||
app.root = Root()
|
||||
app.stop_launched_processes = lambda **kwargs: calls.append(kwargs) or True
|
||||
|
||||
app.kill_launched_processes()
|
||||
app.on_close_requested()
|
||||
|
||||
self.assertEqual(
|
||||
calls,
|
||||
[
|
||||
{"confirm": True, "notify": True, "stop_pc_service": False},
|
||||
{"confirm": True, "notify": False, "stop_pc_service": True},
|
||||
],
|
||||
)
|
||||
self.assertTrue(app.root.destroyed)
|
||||
|
||||
def test_stop_all_keeps_oldest_pc_service_and_stops_duplicates(self) -> None:
|
||||
app = object.__new__(launcher_ui.LauncherApp)
|
||||
app.status = mock.Mock()
|
||||
app.close_related_terminal_windows = lambda: 0
|
||||
|
||||
def fake_check_output(command, **_kwargs):
|
||||
if command == ["pgrep", "-o", "-f", "RoboticsServiceProcess"]:
|
||||
return "101\n"
|
||||
if command == ["pgrep", "-f", "RoboticsServiceProcess"]:
|
||||
return "101\n202\n303\n"
|
||||
raise subprocess.CalledProcessError(1, command)
|
||||
|
||||
with (
|
||||
mock.patch.object(
|
||||
launcher_ui.subprocess,
|
||||
"check_output",
|
||||
side_effect=fake_check_output,
|
||||
),
|
||||
mock.patch.object(launcher_ui.os, "kill") as kill,
|
||||
mock.patch.object(launcher_ui.time, "sleep"),
|
||||
):
|
||||
app.stop_launched_processes(
|
||||
confirm=False,
|
||||
notify=False,
|
||||
stop_pc_service=False,
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
kill.call_args_list,
|
||||
[
|
||||
mock.call(202, signal.SIGTERM),
|
||||
mock.call(303, signal.SIGTERM),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -121,6 +121,17 @@ def _xrobotoolkit_bridge_command() -> str:
|
||||
)
|
||||
|
||||
|
||||
def _xrobotoolkit_cleanup_patterns(*, stop_pc_service: bool) -> tuple[str, ...]:
|
||||
patterns = (
|
||||
XROBOTOOLKIT_BRIDGE_EXECUTABLE,
|
||||
"RobotLinuxDemo.x86_64",
|
||||
"PXREAClientUnity",
|
||||
)
|
||||
if stop_pc_service:
|
||||
return (*patterns, "RoboticsServiceProcess")
|
||||
return patterns
|
||||
|
||||
|
||||
def _tool_command(arm: str, open_tool: bool) -> str:
|
||||
arm_name = "left_rm75" if arm == "left" else "right_rm75"
|
||||
value = "true" if open_tool else "false"
|
||||
@@ -289,8 +300,7 @@ def build_commands_by_mode(mode: str) -> list[tuple[str, str]]:
|
||||
("Ping Left RM75", f"ping -c 4 {DEFAULT_LEFT_IP}"),
|
||||
(
|
||||
"Left Arm RealMan Launch",
|
||||
"ros2 launch xr_rm_bringup arm_debug.launch.py arm:=left use_mock:=false "
|
||||
f"left_robot_ip:={DEFAULT_LEFT_IP}",
|
||||
"ros2 launch xr_rm_bringup arm_debug.launch.py arm:=left use_mock:=false",
|
||||
),
|
||||
("XRobotoolkit UDP Bridge (90 Hz)", _xrobotoolkit_bridge_command()),
|
||||
("Left Tool Open", _tool_command("left", True)),
|
||||
@@ -306,8 +316,7 @@ def build_commands_by_mode(mode: str) -> list[tuple[str, str]]:
|
||||
("Ping Right RM75", f"ping -c 4 {DEFAULT_RIGHT_IP}"),
|
||||
(
|
||||
"Right Arm RealMan Launch",
|
||||
"ros2 launch xr_rm_bringup arm_debug.launch.py arm:=right use_mock:=false "
|
||||
f"right_robot_ip:={DEFAULT_RIGHT_IP}",
|
||||
"ros2 launch xr_rm_bringup arm_debug.launch.py arm:=right use_mock:=false",
|
||||
),
|
||||
("XRobotoolkit UDP Bridge (90 Hz)", _xrobotoolkit_bridge_command()),
|
||||
("Right Tool Open", _tool_command("right", True)),
|
||||
@@ -324,9 +333,7 @@ def build_commands_by_mode(mode: str) -> list[tuple[str, str]]:
|
||||
("Ping Right RM75", f"ping -c 4 {DEFAULT_RIGHT_IP}"),
|
||||
(
|
||||
"Dual Arm RealMan Launch",
|
||||
"ros2 launch xr_rm_bringup arm_debug.launch.py arm:=both use_mock:=false "
|
||||
f"left_robot_ip:={DEFAULT_LEFT_IP} right_robot_ip:={DEFAULT_RIGHT_IP} "
|
||||
"move_to_initial_pose_on_connect:=false",
|
||||
"ros2 launch xr_rm_bringup arm_debug.launch.py arm:=both use_mock:=false",
|
||||
),
|
||||
("XRobotoolkit UDP Bridge (90 Hz)", _xrobotoolkit_bridge_command()),
|
||||
(
|
||||
@@ -1283,13 +1290,27 @@ class LauncherApp:
|
||||
return closed
|
||||
|
||||
def on_close_requested(self) -> None:
|
||||
if self.stop_launched_processes(confirm=True, notify=False):
|
||||
if self.stop_launched_processes(
|
||||
confirm=True,
|
||||
notify=False,
|
||||
stop_pc_service=True,
|
||||
):
|
||||
self.root.destroy()
|
||||
|
||||
def kill_launched_processes(self) -> None:
|
||||
self.stop_launched_processes(confirm=True, notify=True)
|
||||
self.stop_launched_processes(
|
||||
confirm=True,
|
||||
notify=True,
|
||||
stop_pc_service=False,
|
||||
)
|
||||
|
||||
def stop_launched_processes(self, *, confirm: bool, notify: bool) -> bool:
|
||||
def stop_launched_processes(
|
||||
self,
|
||||
*,
|
||||
confirm: bool,
|
||||
notify: bool,
|
||||
stop_pc_service: bool,
|
||||
) -> bool:
|
||||
if confirm and not messagebox.askyesno(
|
||||
"Confirm Stop",
|
||||
"Stop XR-RM launcher terminals, topic monitors, ROS nodes, and bridge processes started from this workspace?",
|
||||
@@ -1302,7 +1323,7 @@ class LauncherApp:
|
||||
"ros2 run xr_rm_input",
|
||||
"ros2 run xr_rm_teleop",
|
||||
"XR_RM_LAUNCHER_SESSION=1",
|
||||
XROBOTOOLKIT_BRIDGE_EXECUTABLE,
|
||||
*_xrobotoolkit_cleanup_patterns(stop_pc_service=stop_pc_service),
|
||||
TERMINAL_TITLE_PREFIX,
|
||||
TOPIC_MONITOR_TITLE,
|
||||
CMD_VEL_MONITOR_TITLE,
|
||||
@@ -1326,6 +1347,20 @@ class LauncherApp:
|
||||
"ros2 topic list",
|
||||
"ros2 node list",
|
||||
]
|
||||
pc_service_keep_pid: int | None = None
|
||||
if not stop_pc_service:
|
||||
try:
|
||||
output = subprocess.check_output(
|
||||
["pgrep", "-o", "-f", "RoboticsServiceProcess"],
|
||||
text=True,
|
||||
)
|
||||
pc_service_keep_pid = int(output.strip())
|
||||
patterns.append("RoboticsServiceProcess")
|
||||
except (subprocess.CalledProcessError, ValueError):
|
||||
pass
|
||||
except Exception as exc:
|
||||
print(f"Failed to find the oldest RoboticsServiceProcess: {exc}")
|
||||
|
||||
protected = {os.getpid(), os.getppid()}
|
||||
killed: set[int] = set()
|
||||
|
||||
@@ -1345,6 +1380,8 @@ class LauncherApp:
|
||||
continue
|
||||
if pid in protected or pid in killed:
|
||||
continue
|
||||
if pattern == "RoboticsServiceProcess" and pid == pc_service_keep_pid:
|
||||
continue
|
||||
try:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
killed.add(pid)
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- This URDF was automatically created by SolidWorks to URDF Exporter! Originally created by Stephen Brawner (brawner@gmail.com)
|
||||
Commit Version: 1.6.0-1-g15f4949 Build Version: 1.6.7594.29634
|
||||
For more information, please see http://wiki.ros.org/sw_urdf_exporter -->
|
||||
<robot
|
||||
name="RM75-B">
|
||||
<link
|
||||
name="base_link">
|
||||
<inertial>
|
||||
<origin
|
||||
xyz="0.00049987 5.2709E-05 0.060019"
|
||||
rpy="0 0 0" />
|
||||
<mass
|
||||
value="1.862" />
|
||||
<inertia
|
||||
ixx="0.0017232"
|
||||
ixy="-3.1058E-06"
|
||||
ixz="-3.7924E-05"
|
||||
iyy="0.0017051"
|
||||
iyz="1.3691E-06"
|
||||
izz="0.00090158" />
|
||||
</inertial>
|
||||
<visual>
|
||||
<origin
|
||||
xyz="0 0 0"
|
||||
rpy="0 0 0" />
|
||||
<geometry>
|
||||
<mesh
|
||||
filename="meshes/base_link.STL" />
|
||||
</geometry>
|
||||
<material
|
||||
name="">
|
||||
<color
|
||||
rgba="1 1 1 1" />
|
||||
</material>
|
||||
</visual>
|
||||
<collision>
|
||||
<origin
|
||||
xyz="0 0 0"
|
||||
rpy="0 0 0" />
|
||||
<geometry>
|
||||
<mesh
|
||||
filename="meshes/base_link.STL" />
|
||||
</geometry>
|
||||
</collision>
|
||||
</link>
|
||||
<link
|
||||
name="link_1">
|
||||
<inertial>
|
||||
<origin
|
||||
xyz="0.000241 -0.013273 -0.00995"
|
||||
rpy="0 0 0" />
|
||||
<mass
|
||||
value="1.574" />
|
||||
<inertia
|
||||
ixx="0.002487573"
|
||||
ixy="0.000009663"
|
||||
ixz="-0.000007909"
|
||||
iyy="0.002321038"
|
||||
iyz="0.000179393"
|
||||
izz="0.001450554" />
|
||||
</inertial>
|
||||
<visual>
|
||||
<origin
|
||||
xyz="0 0 0"
|
||||
rpy="0 0 0" />
|
||||
<geometry>
|
||||
<mesh
|
||||
filename="meshes/link_1.STL" />
|
||||
</geometry>
|
||||
<material
|
||||
name="">
|
||||
<color
|
||||
rgba="1 1 1 1" />
|
||||
</material>
|
||||
</visual>
|
||||
<collision>
|
||||
<origin
|
||||
xyz="0 0 0"
|
||||
rpy="0 0 0" />
|
||||
<geometry>
|
||||
<mesh
|
||||
filename="meshes/link_1.STL" />
|
||||
</geometry>
|
||||
</collision>
|
||||
</link>
|
||||
<joint
|
||||
name="joint_1"
|
||||
type="revolute">
|
||||
<origin
|
||||
xyz="0 0 0.2405"
|
||||
rpy="0 0 0" />
|
||||
<parent
|
||||
link="base_link" />
|
||||
<child
|
||||
link="link_1" />
|
||||
<axis
|
||||
xyz="0 0 1" />
|
||||
<limit
|
||||
lower="-3.106"
|
||||
upper="3.106"
|
||||
effort="60"
|
||||
velocity="3.14" />
|
||||
</joint>
|
||||
<link
|
||||
name="link_2">
|
||||
<inertial>
|
||||
<origin
|
||||
xyz="-0.000357 -0.106789 0.005329"
|
||||
rpy="0 0 0" />
|
||||
<mass
|
||||
value="1.217" />
|
||||
<inertia
|
||||
ixx="0.003494121"
|
||||
ixy="0.000002921"
|
||||
ixz="-0.000005613"
|
||||
iyy="0.000892721"
|
||||
iyz="-0.000583884"
|
||||
izz="0.003444080" />
|
||||
</inertial>
|
||||
<visual>
|
||||
<origin
|
||||
xyz="0 0 0"
|
||||
rpy="0 0 0" />
|
||||
<geometry>
|
||||
<mesh
|
||||
filename="meshes/link_2.STL" />
|
||||
</geometry>
|
||||
<material
|
||||
name="">
|
||||
<color
|
||||
rgba="1 1 1 1" />
|
||||
</material>
|
||||
</visual>
|
||||
<collision>
|
||||
<origin
|
||||
xyz="0 0 0"
|
||||
rpy="0 0 0" />
|
||||
<geometry>
|
||||
<mesh
|
||||
filename="meshes/link_2.STL" />
|
||||
</geometry>
|
||||
</collision>
|
||||
</link>
|
||||
<joint
|
||||
name="joint_2"
|
||||
type="revolute">
|
||||
<origin
|
||||
xyz="0 0 0"
|
||||
rpy="-1.5708 0 0" />
|
||||
<parent
|
||||
link="link_1" />
|
||||
<child
|
||||
link="link_2" />
|
||||
<axis
|
||||
xyz="0 0 1" />
|
||||
<limit
|
||||
lower="-2.2689"
|
||||
upper="2.2689"
|
||||
effort="60"
|
||||
velocity="3.14" />
|
||||
</joint>
|
||||
<link
|
||||
name="link_3">
|
||||
<inertial>
|
||||
<origin
|
||||
xyz="0.000003 -0.01398 -0.011324"
|
||||
rpy="0 0 0" />
|
||||
<mass
|
||||
value="1.11" />
|
||||
<inertia
|
||||
ixx="0.001836663"
|
||||
ixy="0.000002259"
|
||||
ixz="-0.000004216"
|
||||
iyy="0.001498875"
|
||||
iyz="0.000037167"
|
||||
izz="0.001062545" />
|
||||
</inertial>
|
||||
<visual>
|
||||
<origin
|
||||
xyz="0 0 0"
|
||||
rpy="0 0 0" />
|
||||
<geometry>
|
||||
<mesh
|
||||
filename="meshes/link_3.STL" />
|
||||
</geometry>
|
||||
<material
|
||||
name="">
|
||||
<color
|
||||
rgba="1 1 1 1" />
|
||||
</material>
|
||||
</visual>
|
||||
<collision>
|
||||
<origin
|
||||
xyz="0 0 0"
|
||||
rpy="0 0 0" />
|
||||
<geometry>
|
||||
<mesh
|
||||
filename="meshes/link_3.STL" />
|
||||
</geometry>
|
||||
</collision>
|
||||
</link>
|
||||
<joint
|
||||
name="joint_3"
|
||||
type="revolute">
|
||||
<origin
|
||||
xyz="0 -0.256 0"
|
||||
rpy="1.5708 0 0" />
|
||||
<parent
|
||||
link="link_2" />
|
||||
<child
|
||||
link="link_3" />
|
||||
<axis
|
||||
xyz="0 0 1" />
|
||||
<limit
|
||||
lower="-3.106"
|
||||
upper="3.106"
|
||||
effort="30"
|
||||
velocity="3.14" />
|
||||
</joint>
|
||||
<link
|
||||
name="link_4">
|
||||
<inertial>
|
||||
<origin
|
||||
xyz="-0.000005 -0.084658 0.004747"
|
||||
rpy="0 0 0" />
|
||||
<mass
|
||||
value="0.685" />
|
||||
<inertia
|
||||
ixx="0.001282444"
|
||||
ixy="-0.000000551"
|
||||
ixz="-0.000000630"
|
||||
iyy="0.000373013"
|
||||
iyz="-0.000232084"
|
||||
izz="0.001256177" />
|
||||
</inertial>
|
||||
<visual>
|
||||
<origin
|
||||
xyz="0 0 0"
|
||||
rpy="0 0 0" />
|
||||
<geometry>
|
||||
<mesh
|
||||
filename="meshes/link_4.STL" />
|
||||
</geometry>
|
||||
<material
|
||||
name="">
|
||||
<color
|
||||
rgba="1 1 1 1" />
|
||||
</material>
|
||||
</visual>
|
||||
<collision>
|
||||
<origin
|
||||
xyz="0 0 0"
|
||||
rpy="0 0 0" />
|
||||
<geometry>
|
||||
<mesh
|
||||
filename="meshes/link_4.STL" />
|
||||
</geometry>
|
||||
</collision>
|
||||
</link>
|
||||
<joint
|
||||
name="joint_4"
|
||||
type="revolute">
|
||||
<origin
|
||||
xyz="0 0 0"
|
||||
rpy="-1.5708 0 0" />
|
||||
<parent
|
||||
link="link_3" />
|
||||
<child
|
||||
link="link_4" />
|
||||
<axis
|
||||
xyz="0 0 1" />
|
||||
<limit
|
||||
lower="-2.356"
|
||||
upper="2.356"
|
||||
effort="30"
|
||||
velocity="3.14" />
|
||||
</joint>
|
||||
<link
|
||||
name="link_5">
|
||||
<inertial>
|
||||
<origin
|
||||
xyz="0.000078 -0.012937 -0.008781"
|
||||
rpy="0 0 0" />
|
||||
<mass
|
||||
value="0.619" />
|
||||
<inertia
|
||||
ixx="0.000627336"
|
||||
ixy="0.000001636"
|
||||
ixz="-0.000001345"
|
||||
iyy="0.000542455"
|
||||
iyz="0.000034970"
|
||||
izz="0.000370291" />
|
||||
</inertial>
|
||||
<visual>
|
||||
<origin
|
||||
xyz="0 0 0"
|
||||
rpy="0 0 0" />
|
||||
<geometry>
|
||||
<mesh
|
||||
filename="meshes/link_5.STL" />
|
||||
</geometry>
|
||||
<material
|
||||
name="">
|
||||
<color
|
||||
rgba="1 1 1 1" />
|
||||
</material>
|
||||
</visual>
|
||||
<collision>
|
||||
<origin
|
||||
xyz="0 0 0"
|
||||
rpy="0 0 0" />
|
||||
<geometry>
|
||||
<mesh
|
||||
filename="meshes/link_5.STL" />
|
||||
</geometry>
|
||||
</collision>
|
||||
</link>
|
||||
<joint
|
||||
name="joint_5"
|
||||
type="revolute">
|
||||
<origin
|
||||
xyz="0 -0.21 0"
|
||||
rpy="1.5708 0 0" />
|
||||
<parent
|
||||
link="link_4" />
|
||||
<child
|
||||
link="link_5" />
|
||||
<axis
|
||||
xyz="0 0 1" />
|
||||
<limit
|
||||
lower="-3.106"
|
||||
upper="3.106"
|
||||
effort="10"
|
||||
velocity="3.14" />
|
||||
</joint>
|
||||
<link
|
||||
name="link_6">
|
||||
<inertial>
|
||||
<origin
|
||||
xyz="-0.000014 -0.078524 0.002819"
|
||||
rpy="0 0 0" />
|
||||
<mass
|
||||
value="0.602" />
|
||||
<inertia
|
||||
ixx="0.000780774"
|
||||
ixy="-0.000000121"
|
||||
ixz="-0.000000469"
|
||||
iyy="0.000289973"
|
||||
iyz="-0.000120513"
|
||||
izz="0.000763955" />
|
||||
</inertial>
|
||||
<visual>
|
||||
<origin
|
||||
xyz="0 0 0"
|
||||
rpy="0 0 0" />
|
||||
<geometry>
|
||||
<mesh
|
||||
filename="meshes/link_6.STL" />
|
||||
</geometry>
|
||||
<material
|
||||
name="">
|
||||
<color
|
||||
rgba="1 1 1 1" />
|
||||
</material>
|
||||
</visual>
|
||||
<collision>
|
||||
<origin
|
||||
xyz="0 0 0"
|
||||
rpy="0 0 0" />
|
||||
<geometry>
|
||||
<mesh
|
||||
filename="meshes/link_6.STL" />
|
||||
</geometry>
|
||||
</collision>
|
||||
</link>
|
||||
<joint
|
||||
name="joint_6"
|
||||
type="revolute">
|
||||
<origin
|
||||
xyz="0 0 0"
|
||||
rpy="-1.5708 0 0" />
|
||||
<parent
|
||||
link="link_5" />
|
||||
<child
|
||||
link="link_6" />
|
||||
<axis
|
||||
xyz="0 0 1" />
|
||||
<limit
|
||||
lower="-2.234"
|
||||
upper="2.234"
|
||||
effort="10"
|
||||
velocity="3.14" />
|
||||
</joint>
|
||||
<link
|
||||
name="link_7">
|
||||
<inertial>
|
||||
<origin
|
||||
xyz="0.001094 -0.000077 -0.010119"
|
||||
rpy="0 0 0" />
|
||||
<mass
|
||||
value="0.107" />
|
||||
<inertia
|
||||
ixx="0.000044123"
|
||||
ixy="-0.000000064"
|
||||
ixz="0.0000003"
|
||||
iyy="0.000035078"
|
||||
iyz="-0.000000029"
|
||||
izz="0.000065445" />
|
||||
</inertial>
|
||||
<visual>
|
||||
<origin
|
||||
xyz="0 0 0"
|
||||
rpy="0 0 0" />
|
||||
<geometry>
|
||||
<mesh
|
||||
filename="meshes/link_7.STL" />
|
||||
</geometry>
|
||||
<material
|
||||
name="">
|
||||
<color
|
||||
rgba="1 1 1 1" />
|
||||
</material>
|
||||
</visual>
|
||||
<collision>
|
||||
<origin
|
||||
xyz="0 0 0"
|
||||
rpy="0 0 0" />
|
||||
<geometry>
|
||||
<mesh
|
||||
filename="meshes/link_7.STL" />
|
||||
</geometry>
|
||||
</collision>
|
||||
</link>
|
||||
<joint
|
||||
name="joint_7"
|
||||
type="revolute">
|
||||
<origin
|
||||
xyz="0 -0.144 0"
|
||||
rpy="1.5708 0 0" />
|
||||
<parent
|
||||
link="link_6" />
|
||||
<child
|
||||
link="link_7" />
|
||||
<axis
|
||||
xyz="0 0 1" />
|
||||
<limit
|
||||
lower="-6.28"
|
||||
upper="6.28"
|
||||
effort="10"
|
||||
velocity="3.14" />
|
||||
</joint>
|
||||
</robot>
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,503 @@
|
||||
<?xml version='1.0' encoding='UTF-8'?>
|
||||
<robot name="RM75_B_OmniPicker_fixed">
|
||||
<link name="base_link">
|
||||
<inertial>
|
||||
<origin xyz="0.00049987 5.2709E-05 0.060019" rpy="0 0 0"/>
|
||||
<mass value="1.862"/>
|
||||
<inertia ixx="0.0017232" ixy="-3.1058E-06" ixz="-3.7924E-05" iyy="0.0017051" iyz="1.3691E-06" izz="0.00090158"/>
|
||||
</inertial>
|
||||
<visual>
|
||||
<origin xyz="0 0 0" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="package://xr_rm_teleop/models/rm75_omnipicker/meshes/rm75/base_link.STL"/>
|
||||
</geometry>
|
||||
<material name="">
|
||||
<color rgba="1 1 1 1"/>
|
||||
</material>
|
||||
</visual>
|
||||
<collision>
|
||||
<origin xyz="0 0 0" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="package://xr_rm_teleop/models/rm75_omnipicker/meshes/rm75/base_link.STL"/>
|
||||
</geometry>
|
||||
</collision>
|
||||
</link>
|
||||
<link name="link_1">
|
||||
<inertial>
|
||||
<origin xyz="0.000241 -0.013273 -0.00995" rpy="0 0 0"/>
|
||||
<mass value="1.574"/>
|
||||
<inertia ixx="0.002487573" ixy="0.000009663" ixz="-0.000007909" iyy="0.002321038" iyz="0.000179393" izz="0.001450554"/>
|
||||
</inertial>
|
||||
<visual>
|
||||
<origin xyz="0 0 0" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="package://xr_rm_teleop/models/rm75_omnipicker/meshes/rm75/link_1.STL"/>
|
||||
</geometry>
|
||||
<material name="">
|
||||
<color rgba="1 1 1 1"/>
|
||||
</material>
|
||||
</visual>
|
||||
<collision>
|
||||
<origin xyz="0 0 0" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="package://xr_rm_teleop/models/rm75_omnipicker/meshes/rm75/link_1.STL"/>
|
||||
</geometry>
|
||||
</collision>
|
||||
</link>
|
||||
<joint name="joint_1" type="revolute">
|
||||
<origin xyz="0 0 0.2405" rpy="0 0 0"/>
|
||||
<parent link="base_link"/>
|
||||
<child link="link_1"/>
|
||||
<axis xyz="0 0 1"/>
|
||||
<limit lower="-3.106" upper="3.106" effort="60" velocity="3.14"/>
|
||||
</joint>
|
||||
<link name="link_2">
|
||||
<inertial>
|
||||
<origin xyz="-0.000357 -0.106789 0.005329" rpy="0 0 0"/>
|
||||
<mass value="1.217"/>
|
||||
<inertia ixx="0.003494121" ixy="0.000002921" ixz="-0.000005613" iyy="0.000892721" iyz="-0.000583884" izz="0.003444080"/>
|
||||
</inertial>
|
||||
<visual>
|
||||
<origin xyz="0 0 0" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="package://xr_rm_teleop/models/rm75_omnipicker/meshes/rm75/link_2.STL"/>
|
||||
</geometry>
|
||||
<material name="">
|
||||
<color rgba="1 1 1 1"/>
|
||||
</material>
|
||||
</visual>
|
||||
<collision>
|
||||
<origin xyz="0 0 0" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="package://xr_rm_teleop/models/rm75_omnipicker/meshes/rm75/link_2.STL"/>
|
||||
</geometry>
|
||||
</collision>
|
||||
</link>
|
||||
<joint name="joint_2" type="revolute">
|
||||
<origin xyz="0 0 0" rpy="-1.5708 0 0"/>
|
||||
<parent link="link_1"/>
|
||||
<child link="link_2"/>
|
||||
<axis xyz="0 0 1"/>
|
||||
<limit lower="-2.2689" upper="2.2689" effort="60" velocity="3.14"/>
|
||||
</joint>
|
||||
<link name="link_3">
|
||||
<inertial>
|
||||
<origin xyz="0.000003 -0.01398 -0.011324" rpy="0 0 0"/>
|
||||
<mass value="1.11"/>
|
||||
<inertia ixx="0.001836663" ixy="0.000002259" ixz="-0.000004216" iyy="0.001498875" iyz="0.000037167" izz="0.001062545"/>
|
||||
</inertial>
|
||||
<visual>
|
||||
<origin xyz="0 0 0" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="package://xr_rm_teleop/models/rm75_omnipicker/meshes/rm75/link_3.STL"/>
|
||||
</geometry>
|
||||
<material name="">
|
||||
<color rgba="1 1 1 1"/>
|
||||
</material>
|
||||
</visual>
|
||||
<collision>
|
||||
<origin xyz="0 0 0" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="package://xr_rm_teleop/models/rm75_omnipicker/meshes/rm75/link_3.STL"/>
|
||||
</geometry>
|
||||
</collision>
|
||||
</link>
|
||||
<joint name="joint_3" type="revolute">
|
||||
<origin xyz="0 -0.256 0" rpy="1.5708 0 0"/>
|
||||
<parent link="link_2"/>
|
||||
<child link="link_3"/>
|
||||
<axis xyz="0 0 1"/>
|
||||
<limit lower="-3.106" upper="3.106" effort="30" velocity="3.14"/>
|
||||
</joint>
|
||||
<link name="link_4">
|
||||
<inertial>
|
||||
<origin xyz="-0.000005 -0.084658 0.004747" rpy="0 0 0"/>
|
||||
<mass value="0.685"/>
|
||||
<inertia ixx="0.001282444" ixy="-0.000000551" ixz="-0.000000630" iyy="0.000373013" iyz="-0.000232084" izz="0.001256177"/>
|
||||
</inertial>
|
||||
<visual>
|
||||
<origin xyz="0 0 0" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="package://xr_rm_teleop/models/rm75_omnipicker/meshes/rm75/link_4.STL"/>
|
||||
</geometry>
|
||||
<material name="">
|
||||
<color rgba="1 1 1 1"/>
|
||||
</material>
|
||||
</visual>
|
||||
<collision>
|
||||
<origin xyz="0 0 0" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="package://xr_rm_teleop/models/rm75_omnipicker/meshes/rm75/link_4.STL"/>
|
||||
</geometry>
|
||||
</collision>
|
||||
</link>
|
||||
<joint name="joint_4" type="revolute">
|
||||
<origin xyz="0 0 0" rpy="-1.5708 0 0"/>
|
||||
<parent link="link_3"/>
|
||||
<child link="link_4"/>
|
||||
<axis xyz="0 0 1"/>
|
||||
<limit lower="-2.356" upper="2.356" effort="30" velocity="3.14"/>
|
||||
</joint>
|
||||
<link name="link_5">
|
||||
<inertial>
|
||||
<origin xyz="0.000078 -0.012937 -0.008781" rpy="0 0 0"/>
|
||||
<mass value="0.619"/>
|
||||
<inertia ixx="0.000627336" ixy="0.000001636" ixz="-0.000001345" iyy="0.000542455" iyz="0.000034970" izz="0.000370291"/>
|
||||
</inertial>
|
||||
<visual>
|
||||
<origin xyz="0 0 0" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="package://xr_rm_teleop/models/rm75_omnipicker/meshes/rm75/link_5.STL"/>
|
||||
</geometry>
|
||||
<material name="">
|
||||
<color rgba="1 1 1 1"/>
|
||||
</material>
|
||||
</visual>
|
||||
<collision>
|
||||
<origin xyz="0 0 0" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="package://xr_rm_teleop/models/rm75_omnipicker/meshes/rm75/link_5.STL"/>
|
||||
</geometry>
|
||||
</collision>
|
||||
</link>
|
||||
<joint name="joint_5" type="revolute">
|
||||
<origin xyz="0 -0.21 0" rpy="1.5708 0 0"/>
|
||||
<parent link="link_4"/>
|
||||
<child link="link_5"/>
|
||||
<axis xyz="0 0 1"/>
|
||||
<limit lower="-3.106" upper="3.106" effort="10" velocity="3.14"/>
|
||||
</joint>
|
||||
<link name="link_6">
|
||||
<inertial>
|
||||
<origin xyz="-0.000014 -0.078524 0.002819" rpy="0 0 0"/>
|
||||
<mass value="0.602"/>
|
||||
<inertia ixx="0.000780774" ixy="-0.000000121" ixz="-0.000000469" iyy="0.000289973" iyz="-0.000120513" izz="0.000763955"/>
|
||||
</inertial>
|
||||
<visual>
|
||||
<origin xyz="0 0 0" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="package://xr_rm_teleop/models/rm75_omnipicker/meshes/rm75/link_6.STL"/>
|
||||
</geometry>
|
||||
<material name="">
|
||||
<color rgba="1 1 1 1"/>
|
||||
</material>
|
||||
</visual>
|
||||
<collision>
|
||||
<origin xyz="0 0 0" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="package://xr_rm_teleop/models/rm75_omnipicker/meshes/rm75/link_6.STL"/>
|
||||
</geometry>
|
||||
</collision>
|
||||
</link>
|
||||
<joint name="joint_6" type="revolute">
|
||||
<origin xyz="0 0 0" rpy="-1.5708 0 0"/>
|
||||
<parent link="link_5"/>
|
||||
<child link="link_6"/>
|
||||
<axis xyz="0 0 1"/>
|
||||
<limit lower="-2.234" upper="2.234" effort="10" velocity="3.14"/>
|
||||
</joint>
|
||||
<link name="link_7">
|
||||
<inertial>
|
||||
<origin xyz="0.001094 -0.000077 -0.010119" rpy="0 0 0"/>
|
||||
<mass value="0.107"/>
|
||||
<inertia ixx="0.000044123" ixy="-0.000000064" ixz="0.0000003" iyy="0.000035078" iyz="-0.000000029" izz="0.000065445"/>
|
||||
</inertial>
|
||||
<visual>
|
||||
<origin xyz="0 0 0" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="package://xr_rm_teleop/models/rm75_omnipicker/meshes/rm75/link_7.STL"/>
|
||||
</geometry>
|
||||
<material name="">
|
||||
<color rgba="1 1 1 1"/>
|
||||
</material>
|
||||
</visual>
|
||||
<collision>
|
||||
<origin xyz="0 0 0" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="package://xr_rm_teleop/models/rm75_omnipicker/meshes/rm75/link_7.STL"/>
|
||||
</geometry>
|
||||
</collision>
|
||||
</link>
|
||||
<joint name="joint_7" type="revolute">
|
||||
<origin xyz="0 -0.144 0" rpy="1.5708 0 0"/>
|
||||
<parent link="link_6"/>
|
||||
<child link="link_7"/>
|
||||
<axis xyz="0 0 1"/>
|
||||
<limit lower="-6.28" upper="6.28" effort="10" velocity="3.14"/>
|
||||
</joint>
|
||||
<!-- RM75 end-flange alias. link_7 is treated as the tool mounting frame. -->
|
||||
<link name="rm75_flange"/>
|
||||
<joint name="rm75_link7_to_flange" type="fixed">
|
||||
<origin xyz="0 0 0" rpy="0 0 0"/>
|
||||
<parent link="link_7"/>
|
||||
<child link="rm75_flange"/>
|
||||
</joint>
|
||||
<!-- OmniPicker mounting transform. Adjust xyz/rpy here if an adapter plate or different clocking is used. -->
|
||||
<joint name="rm75_flange_to_omnipicker" type="fixed">
|
||||
<origin xyz="0 0 0" rpy="0 0 0"/>
|
||||
<parent link="rm75_flange"/>
|
||||
<child link="omnipicker_base_link"/>
|
||||
</joint>
|
||||
<link name="omnipicker_base_link">
|
||||
<inertial>
|
||||
<origin xyz="-0.00005520 1.2341E-05 0.03296193" rpy="0 0 0"/>
|
||||
<mass value="0.25641368"/>
|
||||
<inertia ixx="4.6351E-04" ixy="-1.0E-08" ixz="-1.04E-06" iyy="4.4525E-04" iyz="-5.00E-08" izz="1.0438E-04"/>
|
||||
</inertial>
|
||||
<visual>
|
||||
<origin rpy="0 0 0" xyz="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="package://xr_rm_teleop/models/rm75_omnipicker/meshes/omnipicker/base_link.STL"/>
|
||||
</geometry>
|
||||
<material name="">
|
||||
<color rgba="0.89804 0.91765 0.92941 1"/>
|
||||
</material>
|
||||
</visual>
|
||||
</link>
|
||||
<link name="omnipicker_hand_narrow1_Link">
|
||||
<inertial>
|
||||
<origin xyz="0.0094685 0.0068806 6.5437E-05" rpy="0 0 0"/>
|
||||
<mass value="0.025428"/>
|
||||
<inertia ixx="1.9574E-06" ixy="-4.2911E-07" ixz="-3.7111E-11" iyy="2.3919E-06" iyz="-3.008E-10" izz="1.5501E-06"/>
|
||||
</inertial>
|
||||
<visual>
|
||||
<origin xyz="0 0 0" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="package://xr_rm_teleop/models/rm75_omnipicker/meshes/omnipicker/narrow1_Link.STL"/>
|
||||
</geometry>
|
||||
<material name="">
|
||||
<color rgba="0.75294 0.75294 0.75294 1"/>
|
||||
</material>
|
||||
</visual>
|
||||
<collision>
|
||||
<origin xyz="0 0 0" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="package://xr_rm_teleop/models/rm75_omnipicker/meshes/omnipicker/narrow1_Link.STL"/>
|
||||
</geometry>
|
||||
</collision>
|
||||
</link>
|
||||
<joint name="omnipicker_hand_narrow1_joint" type="fixed">
|
||||
<origin xyz="0 -0.0195 0.0565" rpy="-2.9951 -1.5708 -0.15964"/>
|
||||
<parent link="omnipicker_base_link"/>
|
||||
<child link="omnipicker_hand_narrow1_Link"/>
|
||||
</joint>
|
||||
<link name="omnipicker_hand_narrow2_Link">
|
||||
<inertial>
|
||||
<origin xyz="0.0088027 -0.007035 1.6424E-05" rpy="0 0 0"/>
|
||||
<mass value="0.0040132"/>
|
||||
<inertia ixx="1.0307E-07" ixy="5.7851E-08" ixz="9.5801E-11" iyy="1.1385E-07" iyz="-2.81E-11" izz="1.7009E-07"/>
|
||||
</inertial>
|
||||
<visual>
|
||||
<origin xyz="0 0 0" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="package://xr_rm_teleop/models/rm75_omnipicker/meshes/omnipicker/narrow2_Link.STL"/>
|
||||
</geometry>
|
||||
<material name="">
|
||||
<color rgba="0.75294 0.75294 0.75294 1"/>
|
||||
</material>
|
||||
</visual>
|
||||
<collision>
|
||||
<origin xyz="0 0 0" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="package://xr_rm_teleop/models/rm75_omnipicker/meshes/omnipicker/narrow2_Link.STL"/>
|
||||
</geometry>
|
||||
</collision>
|
||||
</link>
|
||||
<joint name="omnipicker_hand_narrow2_joint" type="fixed">
|
||||
<origin xyz="0.030852 0.018551 0" rpy="0 0 0"/>
|
||||
<parent link="omnipicker_hand_narrow1_Link"/>
|
||||
<child link="omnipicker_hand_narrow2_Link"/>
|
||||
</joint>
|
||||
<link name="omnipicker_hand_narrow3_Link">
|
||||
<inertial>
|
||||
<origin xyz="0.012508 -0.0079729 9.4339E-05" rpy="0 0 0"/>
|
||||
<mass value="0.018029"/>
|
||||
<inertia ixx="1.1403E-06" ixy="5.5159E-07" ixz="-4.0096E-13" iyy="2.5704E-06" iyz="1.0951E-12" izz="2.3252E-06"/>
|
||||
</inertial>
|
||||
<visual>
|
||||
<origin xyz="0 0 0" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="package://xr_rm_teleop/models/rm75_omnipicker/meshes/omnipicker/narrow3_Link.STL"/>
|
||||
</geometry>
|
||||
<material name="">
|
||||
<color rgba="0.75294 0.75294 0.75294 1"/>
|
||||
</material>
|
||||
</visual>
|
||||
<collision>
|
||||
<origin xyz="0 0 0" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="package://xr_rm_teleop/models/rm75_omnipicker/meshes/omnipicker/narrow3_Link.STL"/>
|
||||
</geometry>
|
||||
</collision>
|
||||
</link>
|
||||
<joint name="omnipicker_hand_narrow3_joint" type="fixed">
|
||||
<origin xyz="0.018118 -0.01574 0" rpy="0 0 0"/>
|
||||
<parent link="omnipicker_hand_narrow2_Link"/>
|
||||
<child link="omnipicker_hand_narrow3_Link"/>
|
||||
</joint>
|
||||
<link name="omnipicker_hand_narrow4_Link">
|
||||
</link>
|
||||
<joint name="omnipicker_hand_narrow4_joint" type="fixed">
|
||||
<origin xyz="0 -0.0104 0" rpy="0 0 0"/>
|
||||
<parent link="omnipicker_hand_narrow3_Link"/>
|
||||
<child link="omnipicker_hand_narrow4_Link"/>
|
||||
<axis xyz="0 0 1"/>
|
||||
<limit lower="-3.14" upper="3.14" effort="0" velocity="0"/>
|
||||
</joint>
|
||||
<link name="omnipicker_hand_narrow_loop_Link">
|
||||
<inertial>
|
||||
<origin xyz="0.014869 -0.0036066 0.00029307" rpy="0 0 0"/>
|
||||
<mass value="0.022591"/>
|
||||
<inertia ixx="4.3916E-06" ixy="1.114E-07" ixz="-4.9655E-12" iyy="4.737E-06" iyz="1.7121E-11" izz="6.0445E-07"/>
|
||||
</inertial>
|
||||
<visual>
|
||||
<origin xyz="0 0 0" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="package://xr_rm_teleop/models/rm75_omnipicker/meshes/omnipicker/narrow_loop_Link.STL"/>
|
||||
</geometry>
|
||||
<material name="">
|
||||
<color rgba="0.75294 0.75294 0.75294 1"/>
|
||||
</material>
|
||||
</visual>
|
||||
<collision>
|
||||
<origin xyz="0 0 0" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="package://xr_rm_teleop/models/rm75_omnipicker/meshes/omnipicker/narrow_loop_Link.STL"/>
|
||||
</geometry>
|
||||
</collision>
|
||||
</link>
|
||||
<joint name="omnipicker_hand_narrow_loop_joint" type="fixed">
|
||||
<origin xyz="0 -0.021633 0.07387" rpy="-2.9951 -1.5708 -0.15964"/>
|
||||
<parent link="omnipicker_base_link"/>
|
||||
<child link="omnipicker_hand_narrow_loop_Link"/>
|
||||
</joint>
|
||||
<link name="omnipicker_hand_wide1_Link">
|
||||
<inertial>
|
||||
<origin xyz="0.0095051 -0.0068479 6.8268E-05" rpy="0 0 0"/>
|
||||
<mass value="0.025428"/>
|
||||
<inertia ixx="1.9565E-06" ixy="4.2798E-07" ixz="2.3844E-10" iyy="2.3928E-06" iyz="-1.4454E-10" izz="1.5501E-06"/>
|
||||
</inertial>
|
||||
<visual>
|
||||
<origin xyz="0 0 0" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="package://xr_rm_teleop/models/rm75_omnipicker/meshes/omnipicker/wide1_Link.STL"/>
|
||||
</geometry>
|
||||
<material name="">
|
||||
<color rgba="0.75294 0.75294 0.75294 1"/>
|
||||
</material>
|
||||
</visual>
|
||||
<collision>
|
||||
<origin xyz="0 0 0" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="package://xr_rm_teleop/models/rm75_omnipicker/meshes/omnipicker/wide1_Link.STL"/>
|
||||
</geometry>
|
||||
</collision>
|
||||
</link>
|
||||
<joint name="omnipicker_hand_wide1_joint" type="fixed">
|
||||
<origin xyz="0 0.0195 0.0565" rpy="-2.9951 -1.5708 -0.15964"/>
|
||||
<parent link="omnipicker_base_link"/>
|
||||
<child link="omnipicker_hand_wide1_Link"/>
|
||||
</joint>
|
||||
<link name="omnipicker_hand_wide2_Link">
|
||||
<inertial>
|
||||
<origin xyz="0.0088027 0.007035 -1.6424E-05" rpy="0 0 0"/>
|
||||
<mass value="0.0040132"/>
|
||||
<inertia ixx="1.0307E-07" ixy="-5.7851E-08" ixz="-9.58E-11" iyy="1.1385E-07" iyz="-2.81E-11" izz="1.7009E-07"/>
|
||||
</inertial>
|
||||
<visual>
|
||||
<origin xyz="0 0 0" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="package://xr_rm_teleop/models/rm75_omnipicker/meshes/omnipicker/wide2_Link.STL"/>
|
||||
</geometry>
|
||||
<material name="">
|
||||
<color rgba="0.75294 0.75294 0.75294 1"/>
|
||||
</material>
|
||||
</visual>
|
||||
<collision>
|
||||
<origin xyz="0 0 0" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="package://xr_rm_teleop/models/rm75_omnipicker/meshes/omnipicker/wide2_Link.STL"/>
|
||||
</geometry>
|
||||
</collision>
|
||||
</link>
|
||||
<joint name="omnipicker_hand_wide2_joint" type="fixed">
|
||||
<origin xyz="0.030852 -0.018551 0" rpy="0 0 0"/>
|
||||
<parent link="omnipicker_hand_wide1_Link"/>
|
||||
<child link="omnipicker_hand_wide2_Link"/>
|
||||
</joint>
|
||||
<link name="omnipicker_hand_wide3_Link">
|
||||
<inertial>
|
||||
<origin xyz="0.016206 0.0094593 4.7668E-05" rpy="0 0 0"/>
|
||||
<mass value="0.035835"/>
|
||||
<inertia ixx="8.5056E-06" ixy="-1.1363E-06" ixz="-4.2908E-11" iyy="1.1235E-05" iyz="-2.9251E-11" izz="4.6309E-06"/>
|
||||
</inertial>
|
||||
<visual>
|
||||
<origin xyz="0 0 0" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="package://xr_rm_teleop/models/rm75_omnipicker/meshes/omnipicker/wide3_Link.STL"/>
|
||||
</geometry>
|
||||
<material name="">
|
||||
<color rgba="0.75294 0.75294 0.75294 1"/>
|
||||
</material>
|
||||
</visual>
|
||||
<collision>
|
||||
<origin xyz="0 0 0" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="package://xr_rm_teleop/models/rm75_omnipicker/meshes/omnipicker/wide3_Link.STL"/>
|
||||
</geometry>
|
||||
</collision>
|
||||
</link>
|
||||
<joint name="omnipicker_hand_wide3_joint" type="fixed">
|
||||
<origin xyz="0.018118 0.01574 0" rpy="0 0 0"/>
|
||||
<parent link="omnipicker_hand_wide2_Link"/>
|
||||
<child link="omnipicker_hand_wide3_Link"/>
|
||||
</joint>
|
||||
<link name="omnipicker_hand_wide4_Link">
|
||||
</link>
|
||||
<joint name="omnipicker_hand_wide4_joint" type="fixed">
|
||||
<origin xyz="0 0.0104 0" rpy="0 0 0"/>
|
||||
<parent link="omnipicker_hand_wide3_Link"/>
|
||||
<child link="omnipicker_hand_wide4_Link"/>
|
||||
<axis xyz="0 0 1"/>
|
||||
<limit lower="-3.14" upper="3.14" effort="0" velocity="0"/>
|
||||
</joint>
|
||||
<link name="omnipicker_hand_wide_loop_Link">
|
||||
<inertial>
|
||||
<origin xyz="0.016268 0.0040555 0.00030323" rpy="0 0 0"/>
|
||||
<mass value="0.025142"/>
|
||||
<inertia ixx="5.887E-06" ixy="-1.1234E-07" ixz="2.1954E-11" iyy="6.236E-06" iyz="-9.473E-12" izz="6.2389E-07"/>
|
||||
</inertial>
|
||||
<visual>
|
||||
<origin xyz="0 0 0" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="package://xr_rm_teleop/models/rm75_omnipicker/meshes/omnipicker/wide_loop_Link.STL"/>
|
||||
</geometry>
|
||||
<material name="">
|
||||
<color rgba="0.75294 0.75294 0.75294 1"/>
|
||||
</material>
|
||||
</visual>
|
||||
<collision>
|
||||
<origin xyz="0 0 0" rpy="0 0 0"/>
|
||||
<geometry>
|
||||
<mesh filename="package://xr_rm_teleop/models/rm75_omnipicker/meshes/omnipicker/wide_loop_Link.STL"/>
|
||||
</geometry>
|
||||
</collision>
|
||||
</link>
|
||||
<joint name="omnipicker_hand_wide_loop_joint" type="fixed">
|
||||
<origin xyz="0 0.021633 0.07387" rpy="-2.9951 -1.5708 -0.15964"/>
|
||||
<parent link="omnipicker_base_link"/>
|
||||
<child link="omnipicker_hand_wide_loop_Link"/>
|
||||
</joint>
|
||||
<link name="omnipicker_mount_frame"/>
|
||||
<joint name="omnipicker_base_to_mount_frame" type="fixed">
|
||||
<origin xyz="0 0 0" rpy="0 0 0"/>
|
||||
<parent link="omnipicker_base_link"/>
|
||||
<child link="omnipicker_mount_frame"/>
|
||||
</joint>
|
||||
<link name="omnipicker_tcp"/>
|
||||
<joint name="omnipicker_tcp_joint" type="fixed">
|
||||
<parent link="omnipicker_base_link"/>
|
||||
<child link="omnipicker_tcp"/>
|
||||
<origin xyz="0 0 0.16" rpy="0 0 0"/>
|
||||
</joint>
|
||||
</robot>
|
||||
+23
-1
@@ -1,8 +1,10 @@
|
||||
"""xr_rm_teleop 包安装配置。
|
||||
|
||||
该包提供基于 XR 相对位姿的 RM75 笛卡尔位姿透传遥操作节点。
|
||||
该包提供基于 XR 相对位姿和 Placo QP 的 RM75 遥操作节点。
|
||||
"""
|
||||
|
||||
from glob import glob
|
||||
|
||||
from setuptools import setup
|
||||
|
||||
package_name = "xr_rm_teleop"
|
||||
@@ -14,6 +16,26 @@ setup(
|
||||
data_files=[
|
||||
("share/ament_index/resource_index/packages", [f"resource/{package_name}"]),
|
||||
(f"share/{package_name}", ["package.xml"]),
|
||||
(
|
||||
f"share/{package_name}/models/rm75",
|
||||
["models/rm75/RM75-B.urdf"],
|
||||
),
|
||||
(
|
||||
f"share/{package_name}/models/rm75/meshes",
|
||||
glob("models/rm75/meshes/*.STL"),
|
||||
),
|
||||
(
|
||||
f"share/{package_name}/models/rm75_omnipicker/urdf",
|
||||
glob("models/rm75_omnipicker/urdf/*.urdf"),
|
||||
),
|
||||
(
|
||||
f"share/{package_name}/models/rm75_omnipicker/meshes/rm75",
|
||||
glob("models/rm75_omnipicker/meshes/rm75/*.STL"),
|
||||
),
|
||||
(
|
||||
f"share/{package_name}/models/rm75_omnipicker/meshes/omnipicker",
|
||||
glob("models/rm75_omnipicker/meshes/omnipicker/*.STL"),
|
||||
),
|
||||
],
|
||||
install_requires=["setuptools"],
|
||||
zip_safe=True,
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from xr_rm_teleop.placo_ik_solver import PlacoIkSolver
|
||||
|
||||
|
||||
CASES = {
|
||||
"left": [-79.55, -9.99, 71.01, 101.45, 95.07, -84.47, -74.52],
|
||||
"right": [-90.14, 3.76, -86.89, 87.89, -96.53, -79.62, -90.04],
|
||||
}
|
||||
|
||||
|
||||
def rotation_z(angle: float) -> np.ndarray:
|
||||
cosine = math.cos(angle)
|
||||
sine = math.sin(angle)
|
||||
return np.asarray(
|
||||
[
|
||||
[cosine, -sine, 0.0],
|
||||
[sine, cosine, 0.0],
|
||||
[0.0, 0.0, 1.0],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def angle_error(actual: np.ndarray, target: np.ndarray) -> float:
|
||||
cosine = np.clip((np.trace(target @ actual.T) - 1.0) * 0.5, -1.0, 1.0)
|
||||
return float(math.acos(cosine))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
urdf_path = Path(sys.argv[1]).resolve()
|
||||
for arm, joint_degrees in CASES.items():
|
||||
initial_joints = np.deg2rad(joint_degrees)
|
||||
drift_solver = PlacoIkSolver(str(urdf_path), 1.0 / 125.0)
|
||||
joints = initial_joints.tolist()
|
||||
stationary_target = drift_solver.update_joint_state(joints)
|
||||
flange = drift_solver._robot.get_T_world_frame("link_7")
|
||||
flange_to_tcp = np.linalg.inv(flange) @ stationary_target
|
||||
assert np.allclose(flange_to_tcp[:3, 3], [0.0, 0.0, 0.16])
|
||||
assert np.allclose(flange_to_tcp[:3, :3], np.eye(3))
|
||||
for _ in range(250):
|
||||
drift_solver.update_joint_state(joints)
|
||||
joints = drift_solver.solve(stationary_target)
|
||||
drift_degrees = float(
|
||||
np.max(np.abs(np.rad2deg(np.asarray(joints) - initial_joints)))
|
||||
)
|
||||
assert drift_degrees <= 0.05, (
|
||||
f"{arm} stationary target drifted {drift_degrees:.3f}deg"
|
||||
)
|
||||
|
||||
solver = PlacoIkSolver(str(urdf_path), 1.0 / 125.0)
|
||||
joints = initial_joints.tolist()
|
||||
current = solver.update_joint_state(joints)
|
||||
assert current.shape == (4, 4)
|
||||
target = current.copy()
|
||||
target[0, 3] += 0.01
|
||||
target[:3, :3] = rotation_z(0.05) @ target[:3, :3]
|
||||
|
||||
solve_durations = []
|
||||
for _ in range(250):
|
||||
solver.update_joint_state(joints)
|
||||
started_at = time.perf_counter()
|
||||
joints = solver.solve(target)
|
||||
solve_durations.append(time.perf_counter() - started_at)
|
||||
|
||||
actual = solver.update_joint_state(joints)
|
||||
position_error = np.linalg.norm(actual[:3, 3] - target[:3, 3])
|
||||
orientation_error = angle_error(actual[:3, :3], target[:3, :3])
|
||||
assert len(joints) == 7
|
||||
assert np.isfinite(joints).all()
|
||||
assert np.allclose(
|
||||
solver.base_configuration,
|
||||
[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0],
|
||||
)
|
||||
assert position_error <= 0.005
|
||||
assert orientation_error <= math.radians(2.0)
|
||||
print(
|
||||
f"{arm}: position_error={position_error:.6f}m, "
|
||||
f"orientation_error={math.degrees(orientation_error):.3f}deg, "
|
||||
f"stationary_drift={drift_degrees:.3f}deg, "
|
||||
f"solve_avg={1000.0 * np.mean(solve_durations):.3f}ms, "
|
||||
f"solve_max={1000.0 * max(solve_durations):.3f}ms, "
|
||||
f"solve_overruns={sum(value > 1.0 / 125.0 for value in solve_durations)}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,4 +1,13 @@
|
||||
import math
|
||||
import sys
|
||||
from types import ModuleType, SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from xr_rm_teleop import realman_adapter
|
||||
from xr_rm_teleop.realman_adapter import RealManAdapter
|
||||
from xr_rm_teleop.realman_adapter import MockRealManAdapter
|
||||
from xr_rm_teleop.fun_peripheral import PeripheralConfig, _configure_tool_frame
|
||||
|
||||
|
||||
def test_initial_pose_uses_joint_move_only() -> None:
|
||||
@@ -11,9 +20,509 @@ def test_initial_pose_uses_joint_move_only() -> None:
|
||||
return 0
|
||||
|
||||
joints = [-167.21, 28.48, 28.21, 61.35, -14.40, 84.49, -124.51]
|
||||
adapter = RealManAdapter("127.0.0.1", 8080, 0, 1, initial_joint_pose=joints)
|
||||
adapter = RealManAdapter(
|
||||
"127.0.0.1",
|
||||
8080,
|
||||
0,
|
||||
"127.0.0.1",
|
||||
8090,
|
||||
initial_joint_pose=joints,
|
||||
)
|
||||
adapter._arm = FakeArm()
|
||||
|
||||
adapter._move_to_initial_pose()
|
||||
|
||||
assert adapter._arm.calls == [(joints, 20, 0, 0, 1)]
|
||||
|
||||
|
||||
def test_peripheral_config_exposes_selected_tool() -> None:
|
||||
config = PeripheralConfig(
|
||||
scissorgripper=1,
|
||||
tools_in_ee={
|
||||
"first": [[0.0] * 7, [0.0] * 7],
|
||||
"second": [[0.0, 0.0, 0.16, 0.0, 0.0, 0.0, 1.0], [0.0] * 7],
|
||||
},
|
||||
)
|
||||
|
||||
assert config.tool_name == "second"
|
||||
assert config.tool_pose == [0.0, 0.0, 0.16, 0.0, 0.0, 0.0, 1.0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("existing", "expected_operation"),
|
||||
[(False, "create"), (True, "update")],
|
||||
)
|
||||
def test_tool_frame_is_created_or_updated(existing, expected_operation) -> None:
|
||||
class FakeArm:
|
||||
def __init__(self) -> None:
|
||||
self.calls = []
|
||||
|
||||
def rm_get_total_tool_frame(self):
|
||||
self.calls.append(("get",))
|
||||
names = ["omnipic"] if existing else []
|
||||
return {"return_code": 0, "tool_names": names}
|
||||
|
||||
def rm_set_manual_tool_frame(self, *, frame):
|
||||
self.calls.append(("create", frame))
|
||||
return 0
|
||||
|
||||
def rm_update_tool_frame(self, *, frame):
|
||||
self.calls.append(("update", frame))
|
||||
return 0
|
||||
|
||||
def rm_change_tool_frame(self, tool_name):
|
||||
self.calls.append(("change", tool_name))
|
||||
return 0
|
||||
|
||||
arm = FakeArm()
|
||||
frame = object()
|
||||
|
||||
_configure_tool_frame(arm, frame, "omnipic")
|
||||
|
||||
assert arm.calls == [
|
||||
("get",),
|
||||
(expected_operation, frame),
|
||||
("change", "omnipic"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("existing", "failure", "operation"),
|
||||
[
|
||||
(False, "query", "rm_get_total_tool_frame"),
|
||||
(False, "create", "rm_set_manual_tool_frame"),
|
||||
(True, "update", "rm_update_tool_frame"),
|
||||
(False, "change", "rm_change_tool_frame"),
|
||||
],
|
||||
)
|
||||
def test_tool_frame_sdk_failures_are_reported(existing, failure, operation) -> None:
|
||||
class FakeArm:
|
||||
def rm_get_total_tool_frame(self):
|
||||
names = ["omnipic"] if existing else []
|
||||
return {
|
||||
"return_code": 1 if failure == "query" else 0,
|
||||
"tool_names": names,
|
||||
}
|
||||
|
||||
def rm_set_manual_tool_frame(self, *, frame):
|
||||
del frame
|
||||
return 1 if failure == "create" else 0
|
||||
|
||||
def rm_update_tool_frame(self, *, frame):
|
||||
del frame
|
||||
return 1 if failure == "update" else 0
|
||||
|
||||
def rm_change_tool_frame(self, tool_name):
|
||||
del tool_name
|
||||
return 1 if failure == "change" else 0
|
||||
|
||||
with pytest.raises(RuntimeError, match=operation):
|
||||
_configure_tool_frame(FakeArm(), object(), "omnipic")
|
||||
|
||||
|
||||
def _udp_state(
|
||||
*,
|
||||
robot_ip: str = "127.0.0.1",
|
||||
joints=None,
|
||||
error_code: int = 0,
|
||||
joint_enabled=None,
|
||||
joint_error_codes=None,
|
||||
arm_error_codes=None,
|
||||
arm_current_status: int = 0,
|
||||
):
|
||||
if joints is None:
|
||||
joints = [0.0, 10.0, -20.0, 30.0, -40.0, 50.0, -60.0]
|
||||
if joint_enabled is None:
|
||||
joint_enabled = [True] * 7
|
||||
if joint_error_codes is None:
|
||||
joint_error_codes = [0] * 7
|
||||
if arm_error_codes is None:
|
||||
arm_error_codes = []
|
||||
return SimpleNamespace(
|
||||
errCode=error_code,
|
||||
arm_ip=robot_ip.encode(),
|
||||
joint_status=SimpleNamespace(
|
||||
joint_position=joints,
|
||||
joint_en_flag=joint_enabled,
|
||||
joint_err_code=joint_error_codes,
|
||||
),
|
||||
err=SimpleNamespace(
|
||||
err_len=len(arm_error_codes),
|
||||
err=list(arm_error_codes),
|
||||
),
|
||||
arm_current_status=arm_current_status,
|
||||
)
|
||||
|
||||
|
||||
def _install_fake_sdk(monkeypatch, *, push_return=0, send_feedback=True):
|
||||
class FakeThreadMode:
|
||||
RM_TRIPLE_MODE_E = 2
|
||||
|
||||
class FakePushConfig:
|
||||
def __init__(self, *args):
|
||||
self.args = args
|
||||
|
||||
class FakeArm:
|
||||
instance = None
|
||||
|
||||
def __init__(self, mode):
|
||||
self.mode = mode
|
||||
self.callback = None
|
||||
self.config = None
|
||||
self.delete_calls = 0
|
||||
FakeArm.instance = self
|
||||
|
||||
def rm_create_robot_arm(self, robot_ip, robot_port):
|
||||
self.robot_ip = robot_ip
|
||||
self.robot_port = robot_port
|
||||
return SimpleNamespace(id=1)
|
||||
|
||||
def rm_realtime_arm_state_call_back(self, callback):
|
||||
self.callback = callback
|
||||
|
||||
def rm_set_realtime_push(self, config):
|
||||
self.config = config
|
||||
if push_return == 0 and send_feedback:
|
||||
self.callback(_udp_state())
|
||||
return push_return
|
||||
|
||||
def rm_delete_robot_arm(self):
|
||||
self.delete_calls += 1
|
||||
return 0
|
||||
|
||||
sdk = ModuleType("Robotic_Arm.rm_robot_interface")
|
||||
sdk.RoboticArm = FakeArm
|
||||
sdk.rm_thread_mode_e = FakeThreadMode
|
||||
sdk.rm_realtime_push_config_t = FakePushConfig
|
||||
sdk.rm_realtime_arm_state_callback_ptr = lambda callback: callback
|
||||
package = ModuleType("Robotic_Arm")
|
||||
package.rm_robot_interface = sdk
|
||||
monkeypatch.setitem(sys.modules, "Robotic_Arm", package)
|
||||
monkeypatch.setitem(sys.modules, "Robotic_Arm.rm_robot_interface", sdk)
|
||||
return SimpleNamespace(RoboticArm=FakeArm)
|
||||
|
||||
|
||||
def test_joint_degree_query_returns_validated_radians() -> None:
|
||||
class FakeArm:
|
||||
def rm_get_joint_degree(self):
|
||||
return 0, [0.0, 10.0, -20.0, 30.0, -40.0, 50.0, -60.0]
|
||||
|
||||
adapter = RealManAdapter(
|
||||
"127.0.0.1",
|
||||
8080,
|
||||
0,
|
||||
"127.0.0.1",
|
||||
8090,
|
||||
)
|
||||
adapter._arm = FakeArm()
|
||||
|
||||
snapshot = adapter.read_joint_state()
|
||||
|
||||
assert snapshot.positions == pytest.approx(
|
||||
[math.radians(value) for value in [0, 10, -20, 30, -40, 50, -60]]
|
||||
)
|
||||
assert snapshot.read_duration_ms is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"result",
|
||||
[
|
||||
(7, [0.0] * 7),
|
||||
(0, [0.0] * 6),
|
||||
(0, [0.0, 0.0, 0.0, math.nan, 0.0, 0.0, 0.0]),
|
||||
],
|
||||
)
|
||||
def test_joint_degree_query_rejects_sdk_errors_and_invalid_values(result) -> None:
|
||||
adapter = RealManAdapter(
|
||||
"127.0.0.1",
|
||||
8080,
|
||||
0,
|
||||
"127.0.0.1",
|
||||
8090,
|
||||
)
|
||||
adapter._arm = SimpleNamespace(rm_get_joint_degree=lambda: result)
|
||||
|
||||
with pytest.raises((RuntimeError, ValueError)):
|
||||
adapter.read_joint_state()
|
||||
|
||||
|
||||
def test_mock_joint_query_uses_current_mock_positions() -> None:
|
||||
adapter = MockRealManAdapter([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0])
|
||||
|
||||
snapshot = adapter.read_joint_state()
|
||||
|
||||
assert snapshot.positions == pytest.approx(
|
||||
[math.radians(value) for value in [1, 2, 3, 4, 5, 6, 7]]
|
||||
)
|
||||
|
||||
|
||||
def test_udp_feedback_is_cached_in_radians(monkeypatch) -> None:
|
||||
monotonic = iter([10.0, 10.005])
|
||||
monkeypatch.setattr(realman_adapter.time, "monotonic", lambda: next(monotonic))
|
||||
adapter = RealManAdapter(
|
||||
"127.0.0.1",
|
||||
8080,
|
||||
0,
|
||||
"127.0.0.1",
|
||||
8090,
|
||||
)
|
||||
adapter._accept_realtime_feedback = True
|
||||
|
||||
adapter._on_realtime_arm_state(_udp_state())
|
||||
first = adapter.get_latest_joint_state()
|
||||
adapter._on_realtime_arm_state(_udp_state())
|
||||
second = adapter.get_latest_joint_state()
|
||||
|
||||
assert first is not None
|
||||
assert first.positions == pytest.approx(
|
||||
[math.radians(value) for value in [0, 10, -20, 30, -40, 50, -60]]
|
||||
)
|
||||
assert first.read_duration_ms is None
|
||||
assert first.update_interval_ms is None
|
||||
assert second is not None
|
||||
assert second.read_duration_ms is None
|
||||
assert second.update_interval_ms == pytest.approx(5.0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"state",
|
||||
[
|
||||
_udp_state(error_code=-3),
|
||||
_udp_state(robot_ip="192.168.192.18"),
|
||||
_udp_state(joints=[0.0] * 6),
|
||||
_udp_state(joints=[0.0, 0.0, 0.0, math.nan, 0.0, 0.0, 0.0]),
|
||||
],
|
||||
)
|
||||
def test_invalid_udp_feedback_does_not_replace_snapshot(state) -> None:
|
||||
adapter = RealManAdapter(
|
||||
"127.0.0.1",
|
||||
8080,
|
||||
0,
|
||||
"127.0.0.1",
|
||||
8090,
|
||||
)
|
||||
adapter._accept_realtime_feedback = True
|
||||
adapter._on_realtime_arm_state(_udp_state(joints=[1.0] * 7))
|
||||
before = adapter.get_latest_joint_state()
|
||||
|
||||
adapter._on_realtime_arm_state(state)
|
||||
|
||||
after = adapter.get_latest_joint_state()
|
||||
assert after is not None
|
||||
assert before is not None
|
||||
assert after.positions == before.positions
|
||||
assert after.received_at == before.received_at
|
||||
assert after.motion_ready is False
|
||||
|
||||
|
||||
def test_udp_joint_fault_marks_snapshot_unready() -> None:
|
||||
adapter = RealManAdapter(
|
||||
"127.0.0.1",
|
||||
8080,
|
||||
0,
|
||||
"127.0.0.1",
|
||||
8090,
|
||||
)
|
||||
adapter._accept_realtime_feedback = True
|
||||
|
||||
adapter._on_realtime_arm_state(
|
||||
_udp_state(
|
||||
joint_enabled=[True, True, False, True, True, True, True],
|
||||
joint_error_codes=[0, 0, 17, 0, 0, 0, 0],
|
||||
arm_error_codes=[42],
|
||||
arm_current_status=9,
|
||||
)
|
||||
)
|
||||
|
||||
snapshot = adapter.get_latest_joint_state()
|
||||
assert snapshot is not None
|
||||
assert snapshot.motion_ready is False
|
||||
|
||||
|
||||
def test_udp_stop_status_marks_snapshot_unready_without_joint_error() -> None:
|
||||
adapter = RealManAdapter(
|
||||
"127.0.0.1",
|
||||
8080,
|
||||
0,
|
||||
"127.0.0.1",
|
||||
8090,
|
||||
)
|
||||
adapter._accept_realtime_feedback = True
|
||||
|
||||
adapter._on_realtime_arm_state(_udp_state(arm_current_status=9))
|
||||
|
||||
snapshot = adapter.get_latest_joint_state()
|
||||
assert snapshot is not None
|
||||
assert snapshot.motion_ready is False
|
||||
|
||||
|
||||
def test_udp_zero_arm_error_code_is_motion_ready() -> None:
|
||||
adapter = RealManAdapter(
|
||||
"127.0.0.1",
|
||||
8080,
|
||||
0,
|
||||
"127.0.0.1",
|
||||
8090,
|
||||
)
|
||||
adapter._accept_realtime_feedback = True
|
||||
|
||||
adapter._on_realtime_arm_state(_udp_state(arm_error_codes=[0]))
|
||||
|
||||
snapshot = adapter.get_latest_joint_state()
|
||||
assert snapshot is not None
|
||||
assert snapshot.motion_ready is True
|
||||
|
||||
|
||||
def test_udp_fault_and_recovery_are_logged_once_per_transition() -> None:
|
||||
class FakeLogger:
|
||||
def __init__(self) -> None:
|
||||
self.infos = []
|
||||
self.warnings = []
|
||||
|
||||
def info(self, message):
|
||||
self.infos.append(message)
|
||||
|
||||
def warn(self, message):
|
||||
self.warnings.append(message)
|
||||
|
||||
logger = FakeLogger()
|
||||
adapter = RealManAdapter(
|
||||
"127.0.0.1",
|
||||
8080,
|
||||
0,
|
||||
"127.0.0.1",
|
||||
8090,
|
||||
logger=logger,
|
||||
)
|
||||
adapter._accept_realtime_feedback = True
|
||||
adapter._on_realtime_arm_state(_udp_state())
|
||||
fault = _udp_state(
|
||||
joint_enabled=[False] * 7,
|
||||
joint_error_codes=[17, 0, 0, 0, 0, 0, 0],
|
||||
arm_error_codes=[42],
|
||||
arm_current_status=9,
|
||||
)
|
||||
|
||||
adapter._on_realtime_arm_state(fault)
|
||||
adapter._on_realtime_arm_state(fault)
|
||||
adapter._on_realtime_arm_state(_udp_state())
|
||||
|
||||
assert len(logger.warnings) == 1
|
||||
assert "joint_errors=[17, 0, 0, 0, 0, 0, 0]" in logger.warnings[0]
|
||||
assert len([message for message in logger.infos if "恢复正常" in message]) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("cycle_ms", "sdk_cycle"),
|
||||
[(5, 1), (10, 2)],
|
||||
)
|
||||
def test_connect_converts_udp_feedback_cycle_to_sdk_units(
|
||||
monkeypatch,
|
||||
cycle_ms,
|
||||
sdk_cycle,
|
||||
) -> None:
|
||||
fake_sdk = _install_fake_sdk(monkeypatch)
|
||||
adapter = RealManAdapter(
|
||||
"127.0.0.1",
|
||||
8080,
|
||||
0,
|
||||
"192.168.192.148",
|
||||
8090,
|
||||
realtime_push_cycle_ms=cycle_ms,
|
||||
configure_safety_limits=False,
|
||||
)
|
||||
|
||||
adapter.connect()
|
||||
|
||||
arm = fake_sdk.RoboticArm.instance
|
||||
assert arm is not None
|
||||
assert arm.config.args == (
|
||||
sdk_cycle,
|
||||
True,
|
||||
8090,
|
||||
0,
|
||||
"192.168.192.148",
|
||||
)
|
||||
assert arm.callback is adapter._realtime_callback
|
||||
assert adapter.get_latest_joint_state() is not None
|
||||
assert not hasattr(adapter, "_feedback_thread")
|
||||
|
||||
|
||||
def test_udp_configuration_failure_cleans_up_robot_handle(monkeypatch) -> None:
|
||||
fake_sdk = _install_fake_sdk(monkeypatch, push_return=1)
|
||||
adapter = RealManAdapter(
|
||||
"127.0.0.1",
|
||||
8080,
|
||||
0,
|
||||
"192.168.192.148",
|
||||
8090,
|
||||
configure_safety_limits=False,
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="rm_set_realtime_push"):
|
||||
adapter.connect()
|
||||
|
||||
assert fake_sdk.RoboticArm.instance.delete_calls == 1
|
||||
assert adapter._arm is None
|
||||
|
||||
|
||||
def test_udp_first_frame_timeout_cleans_up_robot_handle(monkeypatch) -> None:
|
||||
fake_sdk = _install_fake_sdk(monkeypatch, send_feedback=False)
|
||||
adapter = RealManAdapter(
|
||||
"127.0.0.1",
|
||||
8080,
|
||||
0,
|
||||
"192.168.192.148",
|
||||
8090,
|
||||
configure_safety_limits=False,
|
||||
)
|
||||
adapter._feedback_ready = SimpleNamespace(
|
||||
clear=lambda: None,
|
||||
set=lambda: None,
|
||||
wait=lambda timeout: False,
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="within 2 seconds"):
|
||||
adapter.connect()
|
||||
|
||||
assert fake_sdk.RoboticArm.instance.delete_calls == 1
|
||||
assert adapter._arm is None
|
||||
|
||||
|
||||
def test_joint_target_uses_movej_canfd_in_degrees() -> None:
|
||||
class FakeArm:
|
||||
def __init__(self) -> None:
|
||||
self.calls = []
|
||||
|
||||
def rm_movej_canfd(self, *args):
|
||||
self.calls.append(args)
|
||||
return 0
|
||||
|
||||
adapter = RealManAdapter(
|
||||
"127.0.0.1",
|
||||
8080,
|
||||
0,
|
||||
"127.0.0.1",
|
||||
8090,
|
||||
)
|
||||
adapter._arm = FakeArm()
|
||||
target = [math.radians(value) for value in [1, 2, 3, 4, 5, 6, 7]]
|
||||
|
||||
adapter.send_joint_target(target, follow=False)
|
||||
|
||||
assert len(adapter._arm.calls) == 1
|
||||
degrees, follow, expand, trajectory_mode, radio = adapter._arm.calls[0]
|
||||
assert degrees == pytest.approx([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0])
|
||||
assert (follow, expand, trajectory_mode, radio) == (False, 0, 2, 0)
|
||||
|
||||
|
||||
def test_mock_joint_feedback_is_available_without_vendor_sdk() -> None:
|
||||
adapter = MockRealManAdapter([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0])
|
||||
|
||||
adapter.connect()
|
||||
snapshot = adapter.get_latest_joint_state()
|
||||
|
||||
assert snapshot is not None
|
||||
assert snapshot.positions == pytest.approx(
|
||||
[math.radians(value) for value in [1, 2, 3, 4, 5, 6, 7]]
|
||||
)
|
||||
|
||||
@@ -0,0 +1,601 @@
|
||||
import math
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from xr_rm_teleop.realman_adapter import JointStateSnapshot
|
||||
from xr_rm_teleop.single_arm_velocity_teleop import (
|
||||
SingleArmVelocityTeleop,
|
||||
_make_transform,
|
||||
_so3_exp,
|
||||
)
|
||||
|
||||
|
||||
class FakeLogger:
|
||||
def info(self, *args, **kwargs):
|
||||
del args, kwargs
|
||||
|
||||
def warn(self, *args, **kwargs):
|
||||
del args, kwargs
|
||||
|
||||
def error(self, *args, **kwargs):
|
||||
del args, kwargs
|
||||
|
||||
|
||||
class FakeTime:
|
||||
def __sub__(self, other):
|
||||
del other
|
||||
return SimpleNamespace(nanoseconds=0)
|
||||
|
||||
|
||||
def test_startup_joint_query_initializes_qp_and_command_history() -> None:
|
||||
positions = [0.1] * 7
|
||||
pose = np.eye(4)
|
||||
teleop = object.__new__(SingleArmVelocityTeleop)
|
||||
teleop._arm_name = "right_rm75"
|
||||
teleop._adapter = SimpleNamespace(
|
||||
read_joint_state=lambda: JointStateSnapshot(
|
||||
positions,
|
||||
time.monotonic(),
|
||||
)
|
||||
)
|
||||
teleop._ik_solver = SimpleNamespace(
|
||||
update_joint_state=lambda joints: pose
|
||||
)
|
||||
teleop.get_logger = lambda: FakeLogger()
|
||||
|
||||
teleop._initialize_joint_state()
|
||||
|
||||
assert teleop._latest_joint_positions == positions
|
||||
assert teleop._last_valid_joint_target == positions
|
||||
assert teleop._last_joint_command_target == positions
|
||||
assert teleop._last_joint_command_velocity == [0.0] * 7
|
||||
assert teleop._last_current_pose is pose
|
||||
|
||||
|
||||
def test_startup_joint_query_failure_closes_adapter() -> None:
|
||||
class FailingAdapter:
|
||||
def __init__(self):
|
||||
self.close_calls = 0
|
||||
|
||||
def read_joint_state(self):
|
||||
raise RuntimeError("rm_get_joint_degree failed with code 7")
|
||||
|
||||
def close(self):
|
||||
self.close_calls += 1
|
||||
|
||||
errors = []
|
||||
teleop = object.__new__(SingleArmVelocityTeleop)
|
||||
teleop._arm_name = "left_rm75"
|
||||
teleop._adapter = FailingAdapter()
|
||||
teleop.get_logger = lambda: SimpleNamespace(
|
||||
error=lambda message: errors.append(message)
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="code 7"):
|
||||
teleop._initialize_joint_state()
|
||||
|
||||
assert teleop._adapter.close_calls == 1
|
||||
assert "left_rm75" in errors[0]
|
||||
assert "启动关节同步失败" in errors[0]
|
||||
|
||||
|
||||
def _timeout_teleop(adapter) -> SingleArmVelocityTeleop:
|
||||
teleop = object.__new__(SingleArmVelocityTeleop)
|
||||
teleop._adapter = adapter
|
||||
teleop._arm_name = "right_rm75"
|
||||
teleop._follow = False
|
||||
teleop._active = True
|
||||
teleop._joint_feedback_ready = True
|
||||
teleop._grip_rearm_required = False
|
||||
teleop._feedback_resync_attempted = False
|
||||
teleop._control_fault_latched = False
|
||||
teleop._last_joint_command_target = [0.1] * 7
|
||||
teleop._last_joint_command_velocity = [0.0] * 7
|
||||
teleop._latest_joint_positions = [0.1] * 7
|
||||
teleop._last_valid_joint_target = [0.1] * 7
|
||||
teleop._last_current_pose = np.eye(4)
|
||||
teleop._controller_start = None
|
||||
teleop._controller_orientation_start = None
|
||||
teleop._robot_start_transform = None
|
||||
teleop._filtered_target = None
|
||||
teleop._filtered_orientation_target = None
|
||||
teleop._last_sent_target = None
|
||||
teleop._last_sent_orientation = None
|
||||
teleop._last_command_time = None
|
||||
teleop._ik_solver = SimpleNamespace(
|
||||
update_joint_state=lambda joints: np.eye(4)
|
||||
)
|
||||
teleop._stop_sent = False
|
||||
teleop._feedback_resync_timeout_sec = 0.5
|
||||
teleop._publish_stop_debug = lambda: None
|
||||
teleop.get_logger = lambda: FakeLogger()
|
||||
return teleop
|
||||
|
||||
|
||||
def test_missing_or_disabled_joint_snapshot_is_not_motion_ready() -> None:
|
||||
assert not SingleArmVelocityTeleop._joint_snapshot_is_motion_ready(None)
|
||||
assert not SingleArmVelocityTeleop._joint_snapshot_is_motion_ready(
|
||||
JointStateSnapshot(
|
||||
[0.0] * 7,
|
||||
time.monotonic(),
|
||||
motion_ready=False,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_short_udp_timeout_repeats_last_limited_target_without_query() -> None:
|
||||
sends = []
|
||||
adapter = SimpleNamespace(
|
||||
send_joint_target=lambda joints, follow: sends.append(
|
||||
(list(joints), follow)
|
||||
),
|
||||
read_joint_state=lambda: pytest.fail("query must not run"),
|
||||
stop=lambda: pytest.fail("stop must not run"),
|
||||
)
|
||||
teleop = _timeout_teleop(adapter)
|
||||
warnings = []
|
||||
teleop.get_logger = lambda: SimpleNamespace(
|
||||
warn=lambda message: warnings.append(message)
|
||||
)
|
||||
|
||||
teleop._handle_stale_joint_feedback(0.2)
|
||||
|
||||
assert sends == [([0.1] * 7, False)]
|
||||
assert teleop._last_joint_command_target == [0.1] * 7
|
||||
assert teleop._grip_rearm_required
|
||||
assert warnings == [
|
||||
"right_rm75 UDP关节反馈超时(age=200.0 ms),保持最后安全目标。"
|
||||
]
|
||||
|
||||
|
||||
def test_short_udp_timeout_without_active_target_stays_stopped() -> None:
|
||||
stop_calls = []
|
||||
adapter = SimpleNamespace(
|
||||
send_joint_target=lambda joints, follow: pytest.fail(
|
||||
"inactive control must not start CANFD output"
|
||||
),
|
||||
read_joint_state=lambda: pytest.fail("query must not run"),
|
||||
stop=lambda: stop_calls.append(True),
|
||||
)
|
||||
teleop = _timeout_teleop(adapter)
|
||||
teleop._active = False
|
||||
|
||||
teleop._handle_stale_joint_feedback(0.2)
|
||||
|
||||
assert len(stop_calls) == 1
|
||||
|
||||
|
||||
def test_persistent_udp_timeout_queries_once_and_holds_actual_position() -> None:
|
||||
sends = []
|
||||
query_calls = []
|
||||
adapter = SimpleNamespace(
|
||||
send_joint_target=lambda joints, follow: sends.append(list(joints)),
|
||||
read_joint_state=lambda: (
|
||||
query_calls.append(True)
|
||||
or JointStateSnapshot([0.2] * 7, time.monotonic())
|
||||
),
|
||||
stop=lambda: None,
|
||||
)
|
||||
teleop = _timeout_teleop(adapter)
|
||||
|
||||
teleop._handle_stale_joint_feedback(0.5)
|
||||
teleop._handle_stale_joint_feedback(0.6)
|
||||
|
||||
assert len(query_calls) == 1
|
||||
assert sends == [[0.2] * 7, [0.2] * 7]
|
||||
assert teleop._last_valid_joint_target == [0.2] * 7
|
||||
assert teleop._last_joint_command_velocity == [0.0] * 7
|
||||
|
||||
|
||||
def test_persistent_udp_timeout_query_failure_latches_control() -> None:
|
||||
stop_calls = []
|
||||
adapter = SimpleNamespace(
|
||||
send_joint_target=lambda joints, follow: pytest.fail(
|
||||
"CANFD must stop after query failure"
|
||||
),
|
||||
read_joint_state=lambda: (_ for _ in ()).throw(
|
||||
RuntimeError("rm_get_joint_degree failed with code 7")
|
||||
),
|
||||
stop=lambda: stop_calls.append(True),
|
||||
)
|
||||
teleop = _timeout_teleop(adapter)
|
||||
|
||||
teleop._handle_stale_joint_feedback(0.5)
|
||||
teleop._handle_stale_joint_feedback(0.6)
|
||||
|
||||
assert teleop._control_fault_latched
|
||||
assert len(stop_calls) == 1
|
||||
|
||||
|
||||
def test_joint_command_step_limits_acceleration_from_rest() -> None:
|
||||
dt = 1.0 / 125.0
|
||||
target, velocity = SingleArmVelocityTeleop._limit_joint_command_step(
|
||||
target=[0.2] * 7,
|
||||
previous_target=[0.0] * 7,
|
||||
previous_velocity=[0.0] * 7,
|
||||
max_speed=math.radians(180.0),
|
||||
max_acceleration=math.radians(300.0),
|
||||
dt=dt,
|
||||
)
|
||||
|
||||
assert velocity == pytest.approx([math.radians(2.4)] * 7)
|
||||
assert target == pytest.approx([math.radians(0.0192)] * 7)
|
||||
|
||||
|
||||
def test_joint_command_step_rejects_non_finite_limits() -> None:
|
||||
for max_speed, max_acceleration, dt in (
|
||||
(math.inf, 1.0, 0.1),
|
||||
(1.0, math.inf, 0.1),
|
||||
(1.0, 1.0, math.inf),
|
||||
):
|
||||
with pytest.raises(ValueError, match="finite and positive"):
|
||||
SingleArmVelocityTeleop._limit_joint_command_step(
|
||||
target=[0.5] * 7,
|
||||
previous_target=[0.0] * 7,
|
||||
previous_velocity=[0.0] * 7,
|
||||
max_speed=max_speed,
|
||||
max_acceleration=max_acceleration,
|
||||
dt=dt,
|
||||
)
|
||||
|
||||
|
||||
def test_joint_command_step_arrival_respects_max_speed() -> None:
|
||||
target, velocity = SingleArmVelocityTeleop._limit_joint_command_step(
|
||||
target=[0.5] * 7,
|
||||
previous_target=[0.0] * 7,
|
||||
previous_velocity=[0.0] * 7,
|
||||
max_speed=1.0,
|
||||
max_acceleration=100.0,
|
||||
dt=0.1,
|
||||
)
|
||||
|
||||
assert velocity == pytest.approx([1.0] * 7)
|
||||
assert target == pytest.approx([0.1] * 7)
|
||||
|
||||
|
||||
def test_joint_command_step_reverses_with_acceleration_limit() -> None:
|
||||
command = [0.0] * 7
|
||||
velocity = [0.0] * 7
|
||||
for _ in range(5):
|
||||
command, velocity = SingleArmVelocityTeleop._limit_joint_command_step(
|
||||
target=[1.0] * 7,
|
||||
previous_target=command,
|
||||
previous_velocity=velocity,
|
||||
max_speed=1.0,
|
||||
max_acceleration=1.0,
|
||||
dt=0.1,
|
||||
)
|
||||
|
||||
previous_command = list(command)
|
||||
previous_velocity = list(velocity)
|
||||
command, velocity = SingleArmVelocityTeleop._limit_joint_command_step(
|
||||
target=[-1.0] * 7,
|
||||
previous_target=command,
|
||||
previous_velocity=velocity,
|
||||
max_speed=1.0,
|
||||
max_acceleration=1.0,
|
||||
dt=0.1,
|
||||
)
|
||||
|
||||
assert previous_velocity == pytest.approx([0.5] * 7)
|
||||
assert velocity == pytest.approx([0.4] * 7)
|
||||
assert [
|
||||
current - previous
|
||||
for current, previous in zip(command, previous_command)
|
||||
] == pytest.approx([value * 0.1 for value in velocity])
|
||||
assert all(
|
||||
current > previous
|
||||
for current, previous in zip(command, previous_command)
|
||||
)
|
||||
|
||||
|
||||
def test_joint_command_step_brakes_before_fixed_target_without_overshoot() -> None:
|
||||
dt = 1.0 / 90.0
|
||||
max_speed = math.radians(180.0)
|
||||
max_acceleration = math.radians(300.0)
|
||||
target = np.radians(
|
||||
[10.0, -10.0, 3.0, -3.0, 1.0, -1.0, 0.1]
|
||||
).tolist()
|
||||
command = [0.0] * 7
|
||||
velocity = [0.0] * 7
|
||||
|
||||
for _ in range(180):
|
||||
previous_command = list(command)
|
||||
previous_velocity = list(velocity)
|
||||
command, velocity = (
|
||||
SingleArmVelocityTeleop._limit_joint_command_step(
|
||||
target=target,
|
||||
previous_target=command,
|
||||
previous_velocity=velocity,
|
||||
max_speed=max_speed,
|
||||
max_acceleration=max_acceleration,
|
||||
dt=dt,
|
||||
)
|
||||
)
|
||||
|
||||
for index in range(7):
|
||||
assert min(0.0, target[index]) - 1e-12 <= command[index]
|
||||
assert command[index] <= max(0.0, target[index]) + 1e-12
|
||||
assert abs(velocity[index]) <= max_speed + 1e-12
|
||||
assert (
|
||||
abs(velocity[index] - previous_velocity[index])
|
||||
<= max_acceleration * dt + 1e-12
|
||||
)
|
||||
assert command[index] - previous_command[index] == pytest.approx(
|
||||
velocity[index] * dt,
|
||||
abs=1e-12,
|
||||
)
|
||||
|
||||
assert command == pytest.approx(target, abs=1e-12)
|
||||
assert velocity == pytest.approx([0.0] * 7, abs=1e-12)
|
||||
|
||||
|
||||
def test_feedback_fault_blocks_grip_until_release() -> None:
|
||||
class FakeClock:
|
||||
def now(self):
|
||||
return FakeTime()
|
||||
|
||||
teleop = object.__new__(SingleArmVelocityTeleop)
|
||||
teleop._adapter = SimpleNamespace(
|
||||
get_latest_joint_state=lambda: JointStateSnapshot(
|
||||
[0.1] * 7,
|
||||
time.monotonic(),
|
||||
)
|
||||
)
|
||||
teleop._command_timeout_sec = 0.12
|
||||
teleop._joint_feedback_ready = True
|
||||
teleop._arm_name = "right_rm75"
|
||||
teleop._last_msg = SimpleNamespace(
|
||||
grip=True,
|
||||
pose=SimpleNamespace(
|
||||
position=SimpleNamespace(x=0.0, y=0.0, z=0.0),
|
||||
orientation=SimpleNamespace(x=0.0, y=0.0, z=0.0, w=1.0),
|
||||
),
|
||||
)
|
||||
teleop._last_msg_time = FakeTime()
|
||||
teleop._active = False
|
||||
teleop._enable_orientation_control = False
|
||||
teleop._last_valid_joint_target = None
|
||||
teleop._last_current_pose = None
|
||||
teleop._ik_solver = SimpleNamespace(
|
||||
update_joint_state=lambda joints: np.eye(4)
|
||||
)
|
||||
teleop._grip_rearm_required = True
|
||||
teleop._control_fault_latched = False
|
||||
teleop._feedback_resync_attempted = False
|
||||
teleop.get_clock = lambda: FakeClock()
|
||||
teleop.get_logger = lambda: FakeLogger()
|
||||
stopped = []
|
||||
entered = []
|
||||
teleop._safe_stop = lambda reset_active: stopped.append(reset_active)
|
||||
teleop._enter_active_control = lambda *args: entered.append(args)
|
||||
|
||||
teleop._control_tick()
|
||||
assert entered == []
|
||||
|
||||
teleop._last_msg.grip = False
|
||||
teleop._control_tick()
|
||||
assert teleop._grip_rearm_required is False
|
||||
|
||||
teleop._last_msg.grip = True
|
||||
teleop._control_tick()
|
||||
assert len(entered) == 1
|
||||
|
||||
|
||||
def test_first_feedback_initializes_last_valid_target_without_solving() -> None:
|
||||
class FakeSolver:
|
||||
def __init__(self) -> None:
|
||||
self.solve_calls = 0
|
||||
|
||||
def update_joint_state(self, joints):
|
||||
assert joints == [0.1] * 7
|
||||
transform = np.eye(4)
|
||||
transform[:3, 3] = [0.3, 0.0, 0.2]
|
||||
return transform
|
||||
|
||||
def solve(self, target):
|
||||
del target
|
||||
self.solve_calls += 1
|
||||
return [0.2] * 7
|
||||
|
||||
teleop = object.__new__(SingleArmVelocityTeleop)
|
||||
teleop._ik_solver = FakeSolver()
|
||||
teleop._active = False
|
||||
teleop._last_valid_joint_target = None
|
||||
teleop._last_current_pose = None
|
||||
|
||||
pose = teleop._sync_joint_feedback(
|
||||
JointStateSnapshot([0.1] * 7, time.monotonic())
|
||||
)
|
||||
|
||||
assert pose == pytest.approx(
|
||||
_make_transform([0.3, 0.0, 0.2], np.eye(3))
|
||||
)
|
||||
assert teleop._last_valid_joint_target == [0.1] * 7
|
||||
assert teleop._ik_solver.solve_calls == 0
|
||||
|
||||
|
||||
def test_qp_failure_returns_last_known_good_target() -> None:
|
||||
class FailingSolver:
|
||||
def solve(self, target):
|
||||
del target
|
||||
raise RuntimeError("NaN in QP solution")
|
||||
|
||||
teleop = object.__new__(SingleArmVelocityTeleop)
|
||||
teleop._ik_solver = FailingSolver()
|
||||
teleop._last_valid_joint_target = [0.1] * 7
|
||||
teleop._arm_name = "right_rm75"
|
||||
teleop.get_logger = lambda: FakeLogger()
|
||||
|
||||
target = teleop._solve_joint_target(np.eye(4))
|
||||
|
||||
assert target == pytest.approx([0.1] * 7)
|
||||
assert teleop._last_valid_joint_target == pytest.approx([0.1] * 7)
|
||||
|
||||
|
||||
def test_qp_success_updates_last_known_good_target() -> None:
|
||||
class SuccessfulSolver:
|
||||
def solve(self, target):
|
||||
del target
|
||||
return [0.2] * 7
|
||||
|
||||
teleop = object.__new__(SingleArmVelocityTeleop)
|
||||
teleop._ik_solver = SuccessfulSolver()
|
||||
teleop._last_valid_joint_target = [0.1] * 7
|
||||
teleop._arm_name = "left_rm75"
|
||||
teleop.get_logger = lambda: FakeLogger()
|
||||
|
||||
target = teleop._solve_joint_target(np.eye(4))
|
||||
|
||||
assert target == pytest.approx([0.2] * 7)
|
||||
assert teleop._last_valid_joint_target == pytest.approx([0.2] * 7)
|
||||
|
||||
|
||||
def test_enter_active_control_initializes_se3_orientation_state() -> None:
|
||||
teleop = object.__new__(SingleArmVelocityTeleop)
|
||||
transform = _make_transform(
|
||||
[0.3, -0.1, 0.2],
|
||||
_so3_exp(np.asarray([0.1, -0.2, 0.3])),
|
||||
)
|
||||
published = []
|
||||
teleop._arm_name = "right_rm75"
|
||||
teleop.get_logger = lambda: FakeLogger()
|
||||
teleop._publish_debug = lambda *args: published.append(args)
|
||||
|
||||
teleop._enter_active_control(
|
||||
[0.0, 0.0, 0.0],
|
||||
(0.0, 0.0, 0.0, 1.0),
|
||||
transform,
|
||||
FakeTime(),
|
||||
)
|
||||
|
||||
assert teleop._robot_start_transform == pytest.approx(transform)
|
||||
assert teleop._filtered_target == pytest.approx(transform[:3, 3])
|
||||
assert teleop._filtered_orientation_target == pytest.approx(transform[:3, :3])
|
||||
assert teleop._last_sent_orientation == pytest.approx(transform[:3, :3])
|
||||
assert len(published) == 1
|
||||
|
||||
|
||||
def test_command_angular_velocity_uses_so3_rotation_vector() -> None:
|
||||
teleop = object.__new__(SingleArmVelocityTeleop)
|
||||
teleop._dt = 0.1
|
||||
teleop._last_sent_target = [0.0, 0.0, 0.0]
|
||||
teleop._last_sent_orientation = np.eye(3)
|
||||
teleop._last_command_time = None
|
||||
|
||||
velocity = teleop._estimate_command_velocity(
|
||||
[0.0, 0.0, 0.0],
|
||||
_so3_exp(np.asarray([0.0, 0.0, 0.1])),
|
||||
FakeTime(),
|
||||
)
|
||||
|
||||
assert velocity == pytest.approx([0.0, 0.0, 0.0, 0.0, 0.0, 1.0])
|
||||
|
||||
|
||||
def test_timing_stats_logs_summary_and_clears_window() -> None:
|
||||
messages = []
|
||||
teleop = object.__new__(SingleArmVelocityTeleop)
|
||||
teleop._arm_name = "right_rm75"
|
||||
teleop._dt = 0.008
|
||||
teleop._timing_stats_window = 3
|
||||
teleop._timing_samples = {
|
||||
name: []
|
||||
for name in (
|
||||
"period",
|
||||
"total",
|
||||
"qp",
|
||||
"send",
|
||||
"feedback_age",
|
||||
"feedback_read",
|
||||
"feedback_interval",
|
||||
)
|
||||
}
|
||||
teleop._last_timing_feedback_received_at = None
|
||||
teleop.get_logger = lambda: SimpleNamespace(
|
||||
info=lambda message: messages.append(message)
|
||||
)
|
||||
first_feedback = JointStateSnapshot([0.0] * 7, 10.0, 2.0, None)
|
||||
second_feedback = JointStateSnapshot([0.0] * 7, 10.011, 3.0, 11.0)
|
||||
|
||||
teleop._record_timing_sample(7.0, 6.0, 1.0, 0.5, 3.0, first_feedback)
|
||||
teleop._record_timing_sample(8.0, 8.0, 1.5, 0.6, 3.5, first_feedback)
|
||||
assert messages == []
|
||||
|
||||
teleop._record_timing_sample(9.0, 10.0, 2.0, 0.7, 4.0, second_feedback)
|
||||
|
||||
assert len(messages) == 1
|
||||
assert "right_rm75 timing n=3 deadline=8.000 ms" in messages[0]
|
||||
assert (
|
||||
"period[n=3 mean=8.000 p95=8.900 p99=8.980 "
|
||||
"max=9.000 ms overruns=1]"
|
||||
) in messages[0]
|
||||
assert (
|
||||
"total[n=3 mean=8.000 p95=9.800 p99=9.960 "
|
||||
"max=10.000 ms overruns=1]"
|
||||
) in messages[0]
|
||||
assert "qp[n=3" in messages[0]
|
||||
assert "send[n=3" in messages[0]
|
||||
assert "feedback_age[n=3" in messages[0]
|
||||
assert "feedback_read[n=2" in messages[0]
|
||||
assert "feedback_interval[n=1" in messages[0]
|
||||
assert all(not samples for samples in teleop._timing_samples.values())
|
||||
|
||||
|
||||
def test_canfd_error_stops_queries_and_requires_grip_rearm() -> None:
|
||||
class RecoveringAdapter:
|
||||
def __init__(self):
|
||||
self.stop_calls = 0
|
||||
self.read_calls = 0
|
||||
|
||||
def send_joint_target(self, joints, follow):
|
||||
del joints, follow
|
||||
raise RuntimeError("rm_movej_canfd failed with code 9")
|
||||
|
||||
def stop(self):
|
||||
self.stop_calls += 1
|
||||
|
||||
def read_joint_state(self):
|
||||
self.read_calls += 1
|
||||
return JointStateSnapshot([0.2] * 7, time.monotonic())
|
||||
|
||||
teleop = _timeout_teleop(RecoveringAdapter())
|
||||
teleop._joint_command_max_speed = math.radians(180.0)
|
||||
teleop._joint_command_max_acceleration = math.radians(300.0)
|
||||
teleop._dt = 1.0 / 90.0
|
||||
|
||||
sent = teleop._send_joint_target([0.3] * 7)
|
||||
|
||||
assert not sent
|
||||
assert teleop._adapter.stop_calls == 1
|
||||
assert teleop._adapter.read_calls == 1
|
||||
assert not teleop._control_fault_latched
|
||||
assert teleop._grip_rearm_required
|
||||
assert teleop._last_joint_command_target == [0.2] * 7
|
||||
|
||||
|
||||
def test_canfd_error_latches_when_joint_query_also_fails() -> None:
|
||||
class FailingAdapter:
|
||||
def __init__(self):
|
||||
self.stop_calls = 0
|
||||
|
||||
def send_joint_target(self, joints, follow):
|
||||
del joints, follow
|
||||
raise RuntimeError("rm_movej_canfd failed with code 9")
|
||||
|
||||
def stop(self):
|
||||
self.stop_calls += 1
|
||||
|
||||
def read_joint_state(self):
|
||||
raise RuntimeError("rm_get_joint_degree failed with code 7")
|
||||
|
||||
teleop = _timeout_teleop(FailingAdapter())
|
||||
teleop._joint_command_max_speed = math.radians(180.0)
|
||||
teleop._joint_command_max_acceleration = math.radians(300.0)
|
||||
teleop._dt = 1.0 / 90.0
|
||||
|
||||
assert not teleop._send_joint_target([0.3] * 7)
|
||||
assert teleop._control_fault_latched
|
||||
assert teleop._adapter.stop_calls == 1
|
||||
@@ -1,14 +1,20 @@
|
||||
import math
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from xr_rm_teleop.realman_adapter import ArmPose, MockRealManAdapter
|
||||
from xr_rm_teleop.realman_adapter import JointStateSnapshot
|
||||
from xr_rm_teleop.single_arm_velocity_teleop import (
|
||||
SingleArmVelocityTeleop,
|
||||
_euler_to_quaternion,
|
||||
_make_transform,
|
||||
_matrix_to_quaternion,
|
||||
_normalize_quaternion,
|
||||
_quaternion_to_euler,
|
||||
_project_rotation,
|
||||
_quaternion_to_matrix,
|
||||
_so3_exp,
|
||||
_so3_log,
|
||||
)
|
||||
|
||||
|
||||
@@ -17,7 +23,10 @@ def _make_teleop_for_orientation() -> SingleArmVelocityTeleop:
|
||||
teleop._enable_orientation_control = True
|
||||
teleop._enable_orientation_axes = [True, True, True]
|
||||
teleop._controller_orientation_start = (0.0, 0.0, 0.0, 1.0)
|
||||
teleop._robot_start_pose = ArmPose(0.3, 0.0, 0.2, 0.1, -0.2, 0.3)
|
||||
teleop._robot_start_transform = _make_transform(
|
||||
[0.3, 0.0, 0.2],
|
||||
_so3_exp(np.asarray([0.1, -0.2, 0.3])),
|
||||
)
|
||||
teleop._xr_to_robot_matrix = [
|
||||
0.0, 1.0, 0.0,
|
||||
0.0, 0.0, 1.0,
|
||||
@@ -26,47 +35,111 @@ def _make_teleop_for_orientation() -> SingleArmVelocityTeleop:
|
||||
return teleop
|
||||
|
||||
|
||||
def assert_angles_close(actual: list[float] | tuple[float, ...], expected: list[float]) -> None:
|
||||
assert len(actual) == len(expected)
|
||||
for actual_value, expected_value in zip(actual, expected):
|
||||
assert math.atan2(math.sin(actual_value - expected_value), math.cos(actual_value - expected_value)) == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_identity_controller_orientation_keeps_tcp_orientation() -> None:
|
||||
teleop = _make_teleop_for_orientation()
|
||||
|
||||
target = teleop._raw_orientation_from_controller((0.0, 0.0, 0.0, 1.0))
|
||||
|
||||
assert_angles_close(target, teleop._robot_start_pose.rpy())
|
||||
assert target == pytest.approx(teleop._robot_start_transform[:3, :3])
|
||||
|
||||
|
||||
def test_xr_relative_rotation_maps_through_xr_to_robot_matrix() -> None:
|
||||
teleop = _make_teleop_for_orientation()
|
||||
teleop._robot_start_pose = ArmPose(0.3, 0.0, 0.2, 0.0, 0.0, 0.0)
|
||||
xr_roll = _euler_to_quaternion(0.2, 0.0, 0.0)
|
||||
teleop._robot_start_transform = np.eye(4)
|
||||
xr_roll = _matrix_to_quaternion(_so3_exp(np.asarray([0.2, 0.0, 0.0])))
|
||||
|
||||
target = teleop._raw_orientation_from_controller(xr_roll)
|
||||
|
||||
assert_angles_close(target, [0.0, 0.0, 0.2])
|
||||
assert _so3_log(target) == pytest.approx([0.0, 0.0, 0.2])
|
||||
|
||||
|
||||
def test_orientation_deadband_filter_and_speed_limit() -> None:
|
||||
def test_quaternion_sign_does_not_change_rotation() -> None:
|
||||
quaternion = _normalize_quaternion((0.2, -0.3, 0.1, 0.9))
|
||||
|
||||
assert _quaternion_to_matrix(quaternion) == pytest.approx(
|
||||
_quaternion_to_matrix(tuple(-value for value in quaternion))
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pitch", [math.pi / 2.0 - 1e-5, -math.pi / 2.0 + 1e-5])
|
||||
def test_small_rotation_near_gimbal_lock_stays_small(pitch: float) -> None:
|
||||
teleop = _make_teleop_for_orientation()
|
||||
start_rotation = _so3_exp(np.asarray([0.0, pitch, 0.0]))
|
||||
teleop._robot_start_transform = _make_transform([0.3, 0.0, 0.2], start_rotation)
|
||||
teleop._xr_to_robot_matrix = np.eye(3).reshape(-1).tolist()
|
||||
controller = _matrix_to_quaternion(_so3_exp(np.asarray([0.01, 0.0, 0.0])))
|
||||
|
||||
target = teleop._raw_orientation_from_controller(controller)
|
||||
|
||||
error = _so3_log(target @ start_rotation.T)
|
||||
assert np.linalg.norm(error) == pytest.approx(0.01)
|
||||
|
||||
|
||||
def test_crossing_old_rpy_branch_uses_shortest_rotation() -> None:
|
||||
teleop = _make_teleop_for_orientation()
|
||||
start_rotation = _so3_exp(np.asarray([0.0, math.pi / 2.0 - 0.001, 0.0]))
|
||||
teleop._robot_start_transform = _make_transform([0.3, 0.0, 0.2], start_rotation)
|
||||
teleop._xr_to_robot_matrix = np.eye(3).reshape(-1).tolist()
|
||||
controller = _matrix_to_quaternion(_so3_exp(np.asarray([0.0, 0.002, 0.0])))
|
||||
|
||||
target = teleop._raw_orientation_from_controller(controller)
|
||||
|
||||
assert _so3_log(target @ start_rotation.T) == pytest.approx(
|
||||
[0.0, 0.002, 0.0],
|
||||
abs=1e-9,
|
||||
)
|
||||
|
||||
|
||||
def test_disabled_orientation_axis_zeros_robot_rotation_vector_component() -> None:
|
||||
teleop = _make_teleop_for_orientation()
|
||||
teleop._robot_start_transform = np.eye(4)
|
||||
teleop._xr_to_robot_matrix = np.eye(3).reshape(-1).tolist()
|
||||
teleop._enable_orientation_axes = [True, False, True]
|
||||
controller = _matrix_to_quaternion(_so3_exp(np.asarray([0.1, 0.2, 0.3])))
|
||||
|
||||
target = teleop._raw_orientation_from_controller(controller)
|
||||
|
||||
assert _so3_log(target) == pytest.approx([0.1, 0.0, 0.3])
|
||||
|
||||
|
||||
def test_orientation_deadband_filter_and_speed_limit_use_so3_angle() -> None:
|
||||
teleop = object.__new__(SingleArmVelocityTeleop)
|
||||
teleop._orientation_deadband_rad = 0.01
|
||||
teleop._orientation_filter_alpha = 0.5
|
||||
teleop._max_orientation_speed = 0.5
|
||||
teleop._dt = 0.1
|
||||
teleop._last_sent_orientation = [0.0, 0.0, 0.0]
|
||||
teleop._filtered_orientation_target = [0.0, 0.0, 0.0]
|
||||
teleop._dt = 1.0 / 125.0
|
||||
teleop._last_sent_orientation = np.eye(3)
|
||||
teleop._filtered_orientation_target = np.eye(3)
|
||||
|
||||
assert teleop._apply_orientation_deadband([0.001, 0.0, 0.0]) == [0.0, 0.0, 0.0]
|
||||
inside_deadband = _so3_exp(np.asarray([0.006, 0.006, 0.0]))
|
||||
assert teleop._apply_orientation_deadband(inside_deadband) == pytest.approx(np.eye(3))
|
||||
|
||||
filtered = teleop._filter_orientation_target([0.2, 0.0, 0.0])
|
||||
assert_angles_close(filtered, [0.1, 0.0, 0.0])
|
||||
target = _so3_exp(np.asarray([0.2, 0.0, 0.0]))
|
||||
filtered = teleop._filter_orientation_target(target)
|
||||
assert _so3_log(filtered) == pytest.approx([0.1, 0.0, 0.0])
|
||||
|
||||
limited, was_limited = teleop._limit_orientation_step([0.2, 0.0, 0.0])
|
||||
limited, was_limited = teleop._limit_orientation_step(target)
|
||||
assert was_limited
|
||||
assert_angles_close(limited, [0.05, 0.0, 0.0])
|
||||
assert np.linalg.norm(_so3_log(limited)) == pytest.approx(0.5 / 125.0)
|
||||
|
||||
|
||||
def test_rotation_matrix_to_debug_quaternion_is_normalized() -> None:
|
||||
quaternion = _matrix_to_quaternion(_so3_exp(np.asarray([0.2, -0.1, 0.3])))
|
||||
|
||||
assert np.isfinite(quaternion).all()
|
||||
assert np.linalg.norm(quaternion) == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_rotation_projection_accepts_small_error_and_rejects_invalid_matrix() -> None:
|
||||
near_rotation = np.eye(3)
|
||||
near_rotation[0, 1] = 1e-5
|
||||
|
||||
projected = _project_rotation(near_rotation)
|
||||
|
||||
assert projected.T @ projected == pytest.approx(np.eye(3))
|
||||
assert np.linalg.det(projected) == pytest.approx(1.0)
|
||||
with pytest.raises(ValueError):
|
||||
_project_rotation(np.diag([2.0, 1.0, 1.0]))
|
||||
|
||||
|
||||
def test_invalid_controller_quaternion_stops_current_tick() -> None:
|
||||
@@ -95,6 +168,19 @@ def test_invalid_controller_quaternion_stops_current_tick() -> None:
|
||||
teleop._arm_name = "test_rm75"
|
||||
teleop._command_timeout_sec = 0.12
|
||||
teleop._enable_orientation_control = True
|
||||
teleop._adapter = SimpleNamespace(
|
||||
get_latest_joint_state=lambda: JointStateSnapshot(
|
||||
[0.1] * 7,
|
||||
time.monotonic(),
|
||||
)
|
||||
)
|
||||
teleop._ik_solver = SimpleNamespace(update_joint_state=lambda joints: np.eye(4))
|
||||
teleop._active = False
|
||||
teleop._last_valid_joint_target = None
|
||||
teleop._last_current_pose = None
|
||||
teleop._joint_feedback_ready = True
|
||||
teleop._control_fault_latched = False
|
||||
teleop._feedback_resync_attempted = False
|
||||
stopped = []
|
||||
teleop.get_clock = lambda: FakeClock()
|
||||
teleop.get_logger = lambda: FakeLogger()
|
||||
@@ -105,20 +191,6 @@ def test_invalid_controller_quaternion_stops_current_tick() -> None:
|
||||
assert stopped == [True]
|
||||
|
||||
|
||||
def test_quaternion_roundtrip_for_small_rpy() -> None:
|
||||
quat = _normalize_quaternion(_euler_to_quaternion(0.2, -0.1, 0.3))
|
||||
assert_angles_close(_quaternion_to_euler(quat), [0.2, -0.1, 0.3])
|
||||
|
||||
|
||||
def test_zero_quaternion_is_invalid() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
_normalize_quaternion([0.0, 0.0, 0.0, 0.0])
|
||||
|
||||
|
||||
def test_mock_adapter_uses_shortest_angular_velocity() -> None:
|
||||
adapter = MockRealManAdapter([0.0, 0.0, 0.0, 3.13, 0.0, -3.13], 0.1)
|
||||
|
||||
adapter.send_cartesian_target(ArmPose(0.0, 0.0, 0.0, -3.13, 0.0, 3.13), False)
|
||||
|
||||
assert abs(adapter.last_velocity[3]) < 1.0
|
||||
assert abs(adapter.last_velocity[5]) < 1.0
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import math
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from xml.etree import ElementTree
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from xr_rm_teleop.placo_ik_solver import (
|
||||
QP_POSITION_TOLERANCE_M,
|
||||
PlacoIkSolver,
|
||||
_validated_transform,
|
||||
)
|
||||
|
||||
|
||||
def test_fixed_urdf_has_seven_moving_joints_and_omnipicker_tcp() -> None:
|
||||
urdf_path = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "models"
|
||||
/ "rm75_omnipicker"
|
||||
/ "urdf"
|
||||
/ "RM75-B_OmniPicker_fixed.urdf"
|
||||
)
|
||||
root = ElementTree.parse(urdf_path).getroot()
|
||||
moving_joint_names = [
|
||||
joint.attrib["name"]
|
||||
for joint in root.findall("joint")
|
||||
if joint.attrib["type"] != "fixed"
|
||||
]
|
||||
tcp_joint = root.find("joint[@name='omnipicker_tcp_joint']")
|
||||
mesh_filenames = [
|
||||
mesh.attrib["filename"]
|
||||
for mesh in root.findall(".//mesh")
|
||||
]
|
||||
|
||||
assert moving_joint_names == [f"joint_{index}" for index in range(1, 8)]
|
||||
assert all(
|
||||
filename.startswith(
|
||||
"package://xr_rm_teleop/models/rm75_omnipicker/meshes/"
|
||||
)
|
||||
for filename in mesh_filenames
|
||||
)
|
||||
assert tcp_joint is not None
|
||||
assert tcp_joint.attrib["type"] == "fixed"
|
||||
assert tcp_joint.find("parent").attrib["link"] == "omnipicker_base_link"
|
||||
assert tcp_joint.find("child").attrib["link"] == "omnipicker_tcp"
|
||||
assert tcp_joint.find("origin").attrib["xyz"] == "0 0 0.16"
|
||||
assert tcp_joint.find("origin").attrib["rpy"] == "0 0 0"
|
||||
|
||||
|
||||
def _rm75_placo_solver() -> tuple[PlacoIkSolver, list[float]]:
|
||||
pytest.importorskip("placo")
|
||||
urdf_path = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "models"
|
||||
/ "rm75_omnipicker"
|
||||
/ "urdf"
|
||||
/ "RM75-B_OmniPicker_fixed.urdf"
|
||||
)
|
||||
joints = [
|
||||
math.radians(value)
|
||||
for value in [
|
||||
-90.14,
|
||||
3.76,
|
||||
-86.89,
|
||||
87.89,
|
||||
-96.53,
|
||||
-79.62,
|
||||
-90.04,
|
||||
]
|
||||
]
|
||||
return PlacoIkSolver(str(urdf_path), 1.0 / 90.0), joints
|
||||
|
||||
|
||||
def test_qp_solve_converges_to_reachable_tcp_target() -> None:
|
||||
solver, joints = _rm75_placo_solver()
|
||||
start_pose = solver.update_joint_state(joints)
|
||||
target_pose = start_pose.copy()
|
||||
target_pose[0, 3] += 0.07
|
||||
|
||||
result = solver.solve(target_pose)
|
||||
reached_pose = solver.update_joint_state(result)
|
||||
position_error = np.linalg.norm(
|
||||
target_pose[:3, 3] - reached_pose[:3, 3]
|
||||
)
|
||||
rotation_delta = (
|
||||
target_pose[:3, :3] @ reached_pose[:3, :3].T
|
||||
)
|
||||
orientation_error = math.acos(
|
||||
float(
|
||||
np.clip(
|
||||
(np.trace(rotation_delta) - 1.0) * 0.5,
|
||||
-1.0,
|
||||
1.0,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assert position_error <= QP_POSITION_TOLERANCE_M
|
||||
assert orientation_error <= 5e-3
|
||||
|
||||
|
||||
def test_qp_solve_accepts_position_error_within_two_millimeters() -> None:
|
||||
solver = object.__new__(PlacoIkSolver)
|
||||
solver._actual_joints = np.zeros(7)
|
||||
solver._robot = SimpleNamespace(
|
||||
state=SimpleNamespace(q=np.zeros(14))
|
||||
)
|
||||
solver._frame_task = SimpleNamespace(T_world_frame=None)
|
||||
solver._target_errors = lambda: (1.5e-3, 0.0)
|
||||
|
||||
result = solver.solve(np.eye(4))
|
||||
|
||||
assert result == pytest.approx([0.0] * 7)
|
||||
|
||||
|
||||
def test_qp_solve_rejects_position_error_above_two_millimeters() -> None:
|
||||
solver = object.__new__(PlacoIkSolver)
|
||||
solver._actual_joints = np.zeros(7)
|
||||
solver._robot = SimpleNamespace(
|
||||
state=SimpleNamespace(q=np.zeros(14)),
|
||||
update_kinematics=lambda: None,
|
||||
)
|
||||
solver._frame_task = SimpleNamespace(T_world_frame=None)
|
||||
solver._solver = SimpleNamespace(solve=lambda update: None)
|
||||
solver._validate_result = lambda result, previous: None
|
||||
solver._target_errors = lambda: (2.1e-3, 0.0)
|
||||
|
||||
with pytest.raises(RuntimeError, match="QP did not converge after 30"):
|
||||
solver.solve(np.eye(4))
|
||||
|
||||
|
||||
def test_validated_transform_accepts_finite_se3_and_returns_a_copy() -> None:
|
||||
transform = np.eye(4)
|
||||
transform[:3, 3] = [0.3, -0.1, 0.2]
|
||||
|
||||
actual = _validated_transform(transform)
|
||||
|
||||
assert actual == pytest.approx(transform)
|
||||
assert actual is not transform
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"transform",
|
||||
[
|
||||
np.eye(3),
|
||||
np.full((4, 4), np.nan),
|
||||
np.vstack([np.eye(3, 4), [0.0, 0.0, 0.0, 2.0]]),
|
||||
np.diag([2.0, 1.0, 1.0, 1.0]),
|
||||
],
|
||||
)
|
||||
def test_validated_transform_rejects_invalid_se3(transform: np.ndarray) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
_validated_transform(transform)
|
||||
|
||||
|
||||
def test_qp_result_rejects_nan_position_and_velocity_violations() -> None:
|
||||
solver = object.__new__(PlacoIkSolver)
|
||||
solver._joint_limits = np.asarray([[-1.0, 1.0]] * 7)
|
||||
solver._velocity_limits = np.ones(7)
|
||||
solver._dt = 0.1
|
||||
solver._actual_joints = np.zeros(7)
|
||||
|
||||
with pytest.raises(ValueError, match="finite"):
|
||||
solver._validate_result(np.full(7, np.nan))
|
||||
with pytest.raises(ValueError, match="position"):
|
||||
solver._validate_result(np.full(7, 2.0))
|
||||
with pytest.raises(ValueError, match="velocity"):
|
||||
solver._validate_result(np.full(7, 0.2))
|
||||
@@ -25,6 +25,14 @@ class PeripheralConfig:
|
||||
tools_in_ee: dict[str, list[list[float]]]
|
||||
set_initial_tool_state: bool = False
|
||||
|
||||
@property
|
||||
def tool_name(self) -> str:
|
||||
return list(self.tools_in_ee)[self.scissorgripper]
|
||||
|
||||
@property
|
||||
def tool_pose(self) -> list[float]:
|
||||
return list(self.tools_in_ee[self.tool_name][0])
|
||||
|
||||
|
||||
def load_peripheral_config(config_file: str, arm: str) -> PeripheralConfig:
|
||||
"""从 bringup YAML 读取指定左右臂的外设配置。"""
|
||||
@@ -94,6 +102,40 @@ def _tool_name_for_index(tools_in_ee: dict[str, list[list[float]]], scissorgripp
|
||||
return list(tools_in_ee.keys())[scissorgripper]
|
||||
|
||||
|
||||
def _check_sdk_return(result: Any, operation: str) -> None:
|
||||
if result != 0:
|
||||
raise RuntimeError(f"{operation} failed with code {result}: {result!r}")
|
||||
|
||||
|
||||
def _configure_tool_frame(robot, tool_frame, tool_name: str) -> None:
|
||||
frames = robot.rm_get_total_tool_frame()
|
||||
if not isinstance(frames, dict):
|
||||
raise RuntimeError(
|
||||
f"rm_get_total_tool_frame returned invalid data: {frames!r}"
|
||||
)
|
||||
_check_sdk_return(
|
||||
frames.get("return_code"),
|
||||
"rm_get_total_tool_frame",
|
||||
)
|
||||
tool_names = frames.get("tool_names")
|
||||
if not isinstance(tool_names, (list, tuple)):
|
||||
raise RuntimeError(
|
||||
f"rm_get_total_tool_frame returned invalid tool_names: {tool_names!r}"
|
||||
)
|
||||
|
||||
if tool_name in tool_names:
|
||||
operation = "rm_update_tool_frame"
|
||||
result = robot.rm_update_tool_frame(frame=tool_frame)
|
||||
else:
|
||||
operation = "rm_set_manual_tool_frame"
|
||||
result = robot.rm_set_manual_tool_frame(frame=tool_frame)
|
||||
_check_sdk_return(result, operation)
|
||||
_check_sdk_return(
|
||||
robot.rm_change_tool_frame(tool_name),
|
||||
"rm_change_tool_frame",
|
||||
)
|
||||
|
||||
|
||||
def cal_tool_frame(handle, scissorgripper, tools_in_ee):
|
||||
"""根据末端工具配置生成 RealMan 工具坐标系。"""
|
||||
from Robotic_Arm.rm_robot_interface import rm_frame_t
|
||||
@@ -152,8 +194,7 @@ def peripheral_cfg(
|
||||
|
||||
time.sleep(0.2)
|
||||
tool_frame, tool_name = cal_tool_frame(robot, scissorgripper, tools_in_ee)
|
||||
robot.rm_set_manual_tool_frame(frame=tool_frame)
|
||||
robot.rm_change_tool_frame(tool_name)
|
||||
_configure_tool_frame(robot, tool_frame, tool_name)
|
||||
|
||||
if scissorgripper == 0:
|
||||
# 剪刀夹爪通过工具板数字输出控制。
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
"""RM75 的 Placo 0.9.4 有界迭代 QP 逆解。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
EXPECTED_PLACO_VERSION = "0.9.4"
|
||||
RM75_JOINT_NAMES = [f"joint_{index}" for index in range(1, 8)]
|
||||
RM75_Q_SLICE = slice(7, 14)
|
||||
QP_MAX_ITERATIONS = 30
|
||||
QP_POSITION_TOLERANCE_M = 2e-3
|
||||
QP_ORIENTATION_TOLERANCE_RAD = 5e-3
|
||||
|
||||
|
||||
def _validated_transform(transform: np.ndarray) -> np.ndarray:
|
||||
values = np.asarray(transform, dtype=float)
|
||||
if values.shape != (4, 4) or not np.isfinite(values).all():
|
||||
raise ValueError("target transform must be a finite 4x4 matrix")
|
||||
if not np.allclose(values[3], [0.0, 0.0, 0.0, 1.0], atol=1e-9):
|
||||
raise ValueError("target transform must have a valid homogeneous row")
|
||||
|
||||
rotation = values[:3, :3]
|
||||
if (
|
||||
np.linalg.norm(rotation.T @ rotation - np.eye(3)) > 1e-3
|
||||
or np.linalg.det(rotation) <= 0.0
|
||||
):
|
||||
raise ValueError("target transform must contain a valid rotation")
|
||||
u, _, vt = np.linalg.svd(rotation)
|
||||
projected = u @ vt
|
||||
if np.linalg.det(projected) <= 0.0:
|
||||
raise ValueError("target transform must contain a proper rotation")
|
||||
|
||||
result = values.copy()
|
||||
result[:3, :3] = projected
|
||||
return result
|
||||
|
||||
|
||||
class PlacoIkSolver:
|
||||
def __init__(
|
||||
self,
|
||||
urdf_path: str,
|
||||
dt: float,
|
||||
) -> None:
|
||||
if dt <= 0.0:
|
||||
raise ValueError("dt must be positive")
|
||||
try:
|
||||
installed_version = version("placo")
|
||||
import placo
|
||||
except (ImportError, PackageNotFoundError) as exc:
|
||||
raise RuntimeError(
|
||||
"Placo 0.9.4 must come from "
|
||||
"/home/robot/miniconda3/envs/xr"
|
||||
) from exc
|
||||
if installed_version != EXPECTED_PLACO_VERSION:
|
||||
raise RuntimeError(
|
||||
f"Placo {EXPECTED_PLACO_VERSION} is required, got {installed_version}"
|
||||
)
|
||||
|
||||
model_path = Path(urdf_path).expanduser().resolve()
|
||||
if not model_path.is_file():
|
||||
raise FileNotFoundError(f"RM75 URDF not found: {model_path}")
|
||||
|
||||
self._dt = dt
|
||||
self._robot = placo.RobotWrapper(str(model_path))
|
||||
if self._robot.state.q.shape != (14,):
|
||||
raise RuntimeError(
|
||||
f"expected Placo q shape (14,), got {self._robot.state.q.shape}"
|
||||
)
|
||||
if list(self._robot.joint_names()) != RM75_JOINT_NAMES:
|
||||
raise RuntimeError(
|
||||
f"unexpected RM75 joint order: {list(self._robot.joint_names())}"
|
||||
)
|
||||
offsets = [
|
||||
self._robot.get_joint_offset(name) for name in RM75_JOINT_NAMES
|
||||
]
|
||||
if offsets != list(range(7, 14)):
|
||||
raise RuntimeError(f"unexpected RM75 q offsets: {offsets}")
|
||||
|
||||
self._joint_limits = np.asarray(
|
||||
[self._robot.get_joint_limits(name) for name in RM75_JOINT_NAMES]
|
||||
)
|
||||
velocity_offsets = [
|
||||
self._robot.get_joint_v_offset(name) for name in RM75_JOINT_NAMES
|
||||
]
|
||||
self._velocity_limits = np.asarray(
|
||||
[
|
||||
self._robot.model.velocityLimit[index]
|
||||
for index in velocity_offsets
|
||||
]
|
||||
)
|
||||
self._actual_joints: np.ndarray | None = None
|
||||
|
||||
self._solver = placo.KinematicsSolver(self._robot)
|
||||
self._solver.dt = dt
|
||||
self._solver.mask_fbase(True)
|
||||
self._solver.enable_velocity_limits(True)
|
||||
self._frame_task = self._solver.add_frame_task(
|
||||
"omnipicker_tcp",
|
||||
np.eye(4),
|
||||
)
|
||||
self._frame_task.configure("rm75_frame", "soft", 1.0)
|
||||
self._solver.add_kinetic_energy_regularization_task(1e-6)
|
||||
|
||||
@property
|
||||
def base_configuration(self) -> list[float]:
|
||||
return self._robot.state.q[:7].tolist()
|
||||
|
||||
def update_joint_state(self, joints: list[float]) -> np.ndarray:
|
||||
values = np.asarray(joints, dtype=float)
|
||||
if values.shape != (7,) or not np.isfinite(values).all():
|
||||
raise ValueError("joint state must contain 7 finite values")
|
||||
is_first_feedback = self._actual_joints is None
|
||||
self._actual_joints = values.copy()
|
||||
self._robot.state.q[RM75_Q_SLICE] = values
|
||||
self._robot.update_kinematics()
|
||||
base_to_tool = self._robot.get_T_world_frame("omnipicker_tcp")
|
||||
if is_first_feedback:
|
||||
self._frame_task.T_world_frame = base_to_tool.copy()
|
||||
return base_to_tool.copy()
|
||||
|
||||
def _target_errors(self) -> tuple[float, float]:
|
||||
position_task = self._frame_task.position()
|
||||
orientation_task = self._frame_task.orientation()
|
||||
position_task.update()
|
||||
orientation_task.update()
|
||||
return (
|
||||
float(position_task.error_norm()),
|
||||
float(orientation_task.error_norm()),
|
||||
)
|
||||
|
||||
def solve(self, target_tool_pose: np.ndarray) -> list[float]:
|
||||
if self._actual_joints is None:
|
||||
raise RuntimeError("joint state must be initialized before QP solve")
|
||||
self._frame_task.T_world_frame = _validated_transform(
|
||||
target_tool_pose
|
||||
)
|
||||
result = np.asarray(
|
||||
self._robot.state.q[RM75_Q_SLICE],
|
||||
dtype=float,
|
||||
).copy()
|
||||
position_error, orientation_error = self._target_errors()
|
||||
if (
|
||||
position_error <= QP_POSITION_TOLERANCE_M
|
||||
and orientation_error <= QP_ORIENTATION_TOLERANCE_RAD
|
||||
):
|
||||
return result.tolist()
|
||||
|
||||
for _ in range(QP_MAX_ITERATIONS):
|
||||
previous = result
|
||||
self._solver.solve(True)
|
||||
self._robot.update_kinematics()
|
||||
result = np.asarray(
|
||||
self._robot.state.q[RM75_Q_SLICE],
|
||||
dtype=float,
|
||||
).copy()
|
||||
self._validate_result(result, previous)
|
||||
position_error, orientation_error = self._target_errors()
|
||||
if (
|
||||
position_error <= QP_POSITION_TOLERANCE_M
|
||||
and orientation_error <= QP_ORIENTATION_TOLERANCE_RAD
|
||||
):
|
||||
return result.tolist()
|
||||
|
||||
raise RuntimeError(
|
||||
"QP did not converge after "
|
||||
f"{QP_MAX_ITERATIONS} iterations: "
|
||||
f"position_error={position_error:.6f} m, "
|
||||
f"orientation_error={orientation_error:.6f} rad"
|
||||
)
|
||||
|
||||
def _validate_result(
|
||||
self,
|
||||
result: np.ndarray,
|
||||
reference: np.ndarray | None = None,
|
||||
) -> None:
|
||||
if result.shape != (7,) or not np.isfinite(result).all():
|
||||
raise ValueError("QP result must contain 7 finite values")
|
||||
lower = self._joint_limits[:, 0]
|
||||
upper = self._joint_limits[:, 1]
|
||||
if np.any(result < lower - 1e-9) or np.any(result > upper + 1e-9):
|
||||
raise ValueError("QP result violates RM75 joint position limits")
|
||||
if reference is None:
|
||||
reference = self._actual_joints
|
||||
if reference is None:
|
||||
raise RuntimeError("joint state has not been initialized")
|
||||
reference = np.asarray(reference, dtype=float)
|
||||
if reference.shape != (7,) or not np.isfinite(reference).all():
|
||||
raise ValueError("QP reference must contain 7 finite values")
|
||||
max_step = self._velocity_limits * self._dt + 1e-9
|
||||
if np.any(np.abs(result - reference) > max_step):
|
||||
raise ValueError("QP result violates RM75 one-cycle velocity limits")
|
||||
@@ -1,21 +1,16 @@
|
||||
"""RM75 机械臂适配层。
|
||||
|
||||
对上提供统一的当前位姿读取、笛卡尔位姿目标发送和停止接口;对下根据配置
|
||||
选择 mock 积分模拟器或睿尔曼 Python API2 真机通信。
|
||||
"""
|
||||
"""RM75 机械臂关节反馈、关节透传和停止适配层。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import math
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from numbers import Number
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _angle_delta(target: float, current: float) -> float:
|
||||
return math.atan2(math.sin(target - current), math.cos(target - current))
|
||||
|
||||
|
||||
@dataclass
|
||||
class ArmPose:
|
||||
x: float
|
||||
@@ -32,55 +27,72 @@ class ArmPose:
|
||||
return [self.rx, self.ry, self.rz]
|
||||
|
||||
|
||||
class MockRealManAdapter:
|
||||
"""无机械臂时使用的运动学模拟器,用于验证 ROS2 遥操链路。"""
|
||||
@dataclass(frozen=True)
|
||||
class JointStateSnapshot:
|
||||
positions: list[float]
|
||||
received_at: float
|
||||
read_duration_ms: float | None = None
|
||||
update_interval_ms: float | None = None
|
||||
motion_ready: bool = True
|
||||
|
||||
def __init__(self, initial_pose: list[float], dt: float) -> None:
|
||||
self._pose = ArmPose(*initial_pose[:6])
|
||||
self._dt = dt
|
||||
self.last_velocity = [0.0] * 6
|
||||
|
||||
class MockRealManAdapter:
|
||||
"""不导入厂商 SDK 的关节状态 mock。"""
|
||||
|
||||
def __init__(self, initial_joint_degrees: list[float]) -> None:
|
||||
if len(initial_joint_degrees) != 7 or not all(
|
||||
math.isfinite(value) for value in initial_joint_degrees
|
||||
):
|
||||
raise ValueError("initial joint pose must contain 7 finite values")
|
||||
self._joint_positions = [
|
||||
math.radians(value) for value in initial_joint_degrees
|
||||
]
|
||||
self.last_joint_target: list[float] | None = None
|
||||
self.last_tool_open: bool | None = None
|
||||
|
||||
def connect(self) -> None:
|
||||
return
|
||||
|
||||
def get_current_pose(self) -> ArmPose:
|
||||
return self._pose
|
||||
def get_latest_joint_state(self) -> JointStateSnapshot:
|
||||
return JointStateSnapshot(
|
||||
list(self._joint_positions),
|
||||
time.monotonic(),
|
||||
)
|
||||
|
||||
def send_cartesian_target(self, pose: ArmPose, follow: bool) -> None:
|
||||
def read_joint_state(self) -> JointStateSnapshot:
|
||||
return self.get_latest_joint_state()
|
||||
|
||||
def send_joint_target(self, joints: list[float], follow: bool) -> None:
|
||||
del follow
|
||||
self.last_velocity = [
|
||||
(pose.x - self._pose.x) / self._dt,
|
||||
(pose.y - self._pose.y) / self._dt,
|
||||
(pose.z - self._pose.z) / self._dt,
|
||||
_angle_delta(pose.rx, self._pose.rx) / self._dt,
|
||||
_angle_delta(pose.ry, self._pose.ry) / self._dt,
|
||||
_angle_delta(pose.rz, self._pose.rz) / self._dt,
|
||||
]
|
||||
self._pose = pose
|
||||
if len(joints) != 7 or not all(math.isfinite(value) for value in joints):
|
||||
raise ValueError("joint target must contain 7 finite values")
|
||||
self._joint_positions = list(joints)
|
||||
self.last_joint_target = list(joints)
|
||||
|
||||
def stop(self) -> None:
|
||||
self.last_velocity = [0.0] * 6
|
||||
return
|
||||
|
||||
def close(self) -> None:
|
||||
self.stop()
|
||||
|
||||
def configure_peripheral(self, config_file: str, peripheral_arm: str) -> None:
|
||||
del config_file, peripheral_arm
|
||||
def configure_peripheral(self, config: Any, peripheral_arm: str) -> None:
|
||||
del config, peripheral_arm
|
||||
|
||||
def set_tool_enabled(self, open_tool: bool) -> None:
|
||||
self.last_tool_open = open_tool
|
||||
|
||||
|
||||
class RealManAdapter:
|
||||
"""睿尔曼 Python API2 的笛卡尔位姿透传适配层。"""
|
||||
"""复用一个睿尔曼 Python API2 连接的关节适配层。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
robot_ip: str,
|
||||
robot_port: int,
|
||||
avoid_singularity: int,
|
||||
frame_type: int,
|
||||
realtime_push_host_ip: str,
|
||||
realtime_push_port: int,
|
||||
realtime_push_cycle_ms: int = 5,
|
||||
logger: Any | None = None,
|
||||
configure_safety_limits: bool = True,
|
||||
max_line_speed: float = 1.0,
|
||||
@@ -98,7 +110,22 @@ class RealManAdapter:
|
||||
self._robot_ip = robot_ip
|
||||
self._robot_port = robot_port
|
||||
self._avoid_singularity = avoid_singularity
|
||||
self._frame_type = frame_type
|
||||
try:
|
||||
self._realtime_push_host_ip = str(
|
||||
ipaddress.IPv4Address(realtime_push_host_ip)
|
||||
)
|
||||
except ipaddress.AddressValueError as exc:
|
||||
raise ValueError(
|
||||
"realtime_push_host_ip must be a valid IPv4 address"
|
||||
) from exc
|
||||
if not 1 <= realtime_push_port <= 65535:
|
||||
raise ValueError("realtime_push_port must be between 1 and 65535")
|
||||
if realtime_push_cycle_ms <= 0 or realtime_push_cycle_ms % 5 != 0:
|
||||
raise ValueError(
|
||||
"realtime_push_cycle_ms must be a positive multiple of 5"
|
||||
)
|
||||
self._realtime_push_port = realtime_push_port
|
||||
self._realtime_push_cycle_ms = realtime_push_cycle_ms
|
||||
self._logger = logger
|
||||
self._configure_safety_limits = configure_safety_limits
|
||||
self._max_line_speed = max_line_speed
|
||||
@@ -114,54 +141,134 @@ class RealManAdapter:
|
||||
self._canfd_radio = canfd_radio
|
||||
self._scissorgripper: int | None = None
|
||||
self._arm: Any | None = None
|
||||
self._joint_state_lock = threading.Lock()
|
||||
self._latest_joint_state: JointStateSnapshot | None = None
|
||||
self._feedback_ready = threading.Event()
|
||||
self._realtime_callback: Any | None = None
|
||||
self._accept_realtime_feedback = False
|
||||
self._feedback_fault_logged = False
|
||||
self._last_motion_status: tuple[Any, ...] | None = None
|
||||
|
||||
def connect(self) -> None:
|
||||
try:
|
||||
from Robotic_Arm.rm_robot_interface import RoboticArm, rm_thread_mode_e
|
||||
from Robotic_Arm.rm_robot_interface import (
|
||||
RoboticArm,
|
||||
rm_realtime_arm_state_callback_ptr,
|
||||
rm_realtime_push_config_t,
|
||||
rm_thread_mode_e,
|
||||
)
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"未安装睿尔曼 Python API2。请安装厂商 SDK,或用 use_mock:=true 先跑模拟模式。"
|
||||
) from exc
|
||||
|
||||
self._arm = RoboticArm(rm_thread_mode_e.RM_TRIPLE_MODE_E)
|
||||
handle = self._arm.rm_create_robot_arm(self._robot_ip, self._robot_port)
|
||||
self._check_robot_handle(handle)
|
||||
self._log_info(
|
||||
"RealMan connected: "
|
||||
f"ip={self._robot_ip}, port={self._robot_port}, "
|
||||
f"avoid_singularity={self._avoid_singularity}, "
|
||||
f"frame_type={self._frame_type}, command=rm_movep_canfd"
|
||||
try:
|
||||
handle = self._arm.rm_create_robot_arm(
|
||||
self._robot_ip,
|
||||
self._robot_port,
|
||||
)
|
||||
self._check_robot_handle(handle)
|
||||
self._log_info(
|
||||
"RealMan connected: "
|
||||
f"ip={self._robot_ip}, port={self._robot_port}, "
|
||||
f"avoid_singularity={self._avoid_singularity}, "
|
||||
"command=rm_movej_canfd"
|
||||
)
|
||||
if self._configure_safety_limits:
|
||||
self._apply_safety_limits()
|
||||
if self._move_to_initial_pose_on_connect:
|
||||
self._move_to_initial_pose()
|
||||
self._feedback_ready.clear()
|
||||
self._accept_realtime_feedback = True
|
||||
self._realtime_callback = rm_realtime_arm_state_callback_ptr(
|
||||
self._on_realtime_arm_state
|
||||
)
|
||||
self._arm.rm_realtime_arm_state_call_back(
|
||||
self._realtime_callback
|
||||
)
|
||||
config = rm_realtime_push_config_t(
|
||||
self._realtime_push_cycle_ms // 5,
|
||||
True,
|
||||
self._realtime_push_port,
|
||||
0,
|
||||
self._realtime_push_host_ip,
|
||||
)
|
||||
self._check_return(
|
||||
self._arm.rm_set_realtime_push(config),
|
||||
"rm_set_realtime_push",
|
||||
)
|
||||
if not self._feedback_ready.wait(timeout=2.0):
|
||||
raise RuntimeError(
|
||||
"RealMan UDP realtime feedback did not receive a valid "
|
||||
"frame within 2 seconds"
|
||||
)
|
||||
self._log_info(
|
||||
"RealMan UDP realtime feedback ready: "
|
||||
f"host={self._realtime_push_host_ip}:"
|
||||
f"{self._realtime_push_port}, "
|
||||
f"cycle={self._realtime_push_cycle_ms} ms"
|
||||
)
|
||||
except Exception:
|
||||
self._accept_realtime_feedback = False
|
||||
try:
|
||||
self._arm.rm_delete_robot_arm()
|
||||
except Exception:
|
||||
pass
|
||||
self._arm = None
|
||||
self._realtime_callback = None
|
||||
raise
|
||||
|
||||
def get_latest_joint_state(self) -> JointStateSnapshot | None:
|
||||
with self._joint_state_lock:
|
||||
if self._latest_joint_state is None:
|
||||
return None
|
||||
return JointStateSnapshot(
|
||||
list(self._latest_joint_state.positions),
|
||||
self._latest_joint_state.received_at,
|
||||
self._latest_joint_state.read_duration_ms,
|
||||
self._latest_joint_state.update_interval_ms,
|
||||
self._latest_joint_state.motion_ready,
|
||||
)
|
||||
|
||||
def read_joint_state(self) -> JointStateSnapshot:
|
||||
self._require_arm()
|
||||
started_at = time.monotonic()
|
||||
result = self._arm.rm_get_joint_degree()
|
||||
finished_at = time.monotonic()
|
||||
if not isinstance(result, tuple) or len(result) != 2:
|
||||
raise RuntimeError(
|
||||
f"rm_get_joint_degree returned invalid result: {result!r}"
|
||||
)
|
||||
self._check_return(result, "rm_get_joint_degree")
|
||||
return JointStateSnapshot(
|
||||
self._joint_positions_from_degrees(
|
||||
result[1],
|
||||
"rm_get_joint_degree",
|
||||
),
|
||||
finished_at,
|
||||
(finished_at - started_at) * 1000.0,
|
||||
)
|
||||
if self._configure_safety_limits:
|
||||
self._apply_safety_limits()
|
||||
if self._move_to_initial_pose_on_connect:
|
||||
self._move_to_initial_pose()
|
||||
|
||||
def get_current_pose(self) -> ArmPose:
|
||||
def send_joint_target(self, joints: list[float], follow: bool) -> None:
|
||||
self._require_arm()
|
||||
state = self._arm.rm_get_current_arm_state()
|
||||
pose = self._find_pose(state)
|
||||
if pose is None:
|
||||
raise RuntimeError(f"无法从睿尔曼状态中解析当前 TCP 位姿:{state!r}")
|
||||
return ArmPose(*pose[:6])
|
||||
|
||||
def send_cartesian_target(self, pose: ArmPose, follow: bool) -> None:
|
||||
self._require_arm()
|
||||
ret = self._arm.rm_movep_canfd(
|
||||
[pose.x, pose.y, pose.z, pose.rx, pose.ry, pose.rz],
|
||||
if len(joints) != 7 or not all(math.isfinite(value) for value in joints):
|
||||
raise ValueError("joint target must contain 7 finite values")
|
||||
ret = self._arm.rm_movej_canfd(
|
||||
[math.degrees(value) for value in joints],
|
||||
follow,
|
||||
0,
|
||||
self._canfd_trajectory_mode,
|
||||
self._canfd_radio,
|
||||
)
|
||||
self._check_return(ret, "rm_movep_canfd")
|
||||
self._check_return(ret, "rm_movej_canfd")
|
||||
|
||||
def configure_peripheral(self, config_file: str, peripheral_arm: str) -> None:
|
||||
def configure_peripheral(self, config: Any, peripheral_arm: str) -> None:
|
||||
self._require_arm()
|
||||
from .fun_peripheral import load_peripheral_config, peripheral_cfg
|
||||
from .fun_peripheral import peripheral_cfg
|
||||
|
||||
config = load_peripheral_config(config_file, peripheral_arm)
|
||||
self._scissorgripper = config.scissorgripper
|
||||
tool_name = list(config.tools_in_ee.keys())[config.scissorgripper]
|
||||
tool_name = config.tool_name
|
||||
self._log_info(
|
||||
"开始配置 RealMan 末端外设:"
|
||||
f"arm={peripheral_arm}, scissorgripper={config.scissorgripper}, "
|
||||
@@ -195,16 +302,144 @@ class RealManAdapter:
|
||||
def close(self) -> None:
|
||||
if self._arm is None:
|
||||
return
|
||||
self._accept_realtime_feedback = False
|
||||
self.stop()
|
||||
try:
|
||||
self._arm.rm_delete_robot_arm()
|
||||
finally:
|
||||
self._arm = None
|
||||
self._realtime_callback = None
|
||||
|
||||
def _require_arm(self) -> None:
|
||||
if self._arm is None:
|
||||
raise RuntimeError("睿尔曼机械臂尚未连接")
|
||||
|
||||
def _on_realtime_arm_state(self, data: Any) -> None:
|
||||
if not self._accept_realtime_feedback:
|
||||
return
|
||||
try:
|
||||
if data is None or int(data.errCode) != 0:
|
||||
raise ValueError("invalid realtime feedback error code")
|
||||
arm_ip = data.arm_ip
|
||||
if isinstance(arm_ip, bytes):
|
||||
arm_ip = arm_ip.decode("utf-8").split("\x00", 1)[0]
|
||||
if str(arm_ip) != self._robot_ip:
|
||||
raise ValueError(
|
||||
f"unexpected realtime feedback source: {arm_ip}"
|
||||
)
|
||||
positions = self._joint_positions_from_degrees(
|
||||
data.joint_status.joint_position,
|
||||
"RM75 UDP feedback",
|
||||
)
|
||||
joint_enabled = [
|
||||
bool(value) for value in data.joint_status.joint_en_flag
|
||||
]
|
||||
joint_errors = [
|
||||
int(value) for value in data.joint_status.joint_err_code
|
||||
]
|
||||
if len(joint_enabled) != 7 or len(joint_errors) != 7:
|
||||
raise ValueError(
|
||||
"RM75 UDP feedback must contain 7 joint states"
|
||||
)
|
||||
arm_error_count = int(data.err.err_len)
|
||||
arm_errors = [
|
||||
int(value) for value in list(data.err.err)[:arm_error_count]
|
||||
]
|
||||
arm_errors = [code for code in arm_errors if code != 0]
|
||||
arm_current_status = int(data.arm_current_status)
|
||||
motion_ready = (
|
||||
0 <= arm_current_status <= 8
|
||||
and all(joint_enabled)
|
||||
and not any(joint_errors)
|
||||
and not arm_errors
|
||||
)
|
||||
motion_status = (
|
||||
arm_current_status,
|
||||
tuple(joint_enabled),
|
||||
tuple(joint_errors),
|
||||
tuple(arm_errors),
|
||||
motion_ready,
|
||||
)
|
||||
received_at = time.monotonic()
|
||||
with self._joint_state_lock:
|
||||
update_interval_ms = (
|
||||
None
|
||||
if self._latest_joint_state is None
|
||||
else (
|
||||
received_at
|
||||
- self._latest_joint_state.received_at
|
||||
)
|
||||
* 1000.0
|
||||
)
|
||||
self._latest_joint_state = JointStateSnapshot(
|
||||
positions,
|
||||
received_at,
|
||||
None,
|
||||
update_interval_ms,
|
||||
motion_ready,
|
||||
)
|
||||
self._log_motion_status_transition(motion_status)
|
||||
self._feedback_fault_logged = False
|
||||
self._feedback_ready.set()
|
||||
except Exception as exc:
|
||||
with self._joint_state_lock:
|
||||
if self._latest_joint_state is not None:
|
||||
current = self._latest_joint_state
|
||||
self._latest_joint_state = JointStateSnapshot(
|
||||
list(current.positions),
|
||||
current.received_at,
|
||||
current.read_duration_ms,
|
||||
current.update_interval_ms,
|
||||
False,
|
||||
)
|
||||
if not self._feedback_fault_logged:
|
||||
self._log_warn(
|
||||
f"RealMan UDP realtime feedback invalid: {exc}"
|
||||
)
|
||||
self._feedback_fault_logged = True
|
||||
|
||||
@staticmethod
|
||||
def _joint_positions_from_degrees(
|
||||
values: Any,
|
||||
source: str,
|
||||
) -> list[float]:
|
||||
try:
|
||||
degrees = list(values)
|
||||
except TypeError as exc:
|
||||
raise ValueError(
|
||||
f"{source} must contain 7 numeric joints"
|
||||
) from exc
|
||||
if len(degrees) != 7 or not all(
|
||||
isinstance(value, Number) for value in degrees
|
||||
):
|
||||
raise ValueError(
|
||||
f"{source} must contain 7 numeric joints"
|
||||
)
|
||||
positions = [math.radians(float(value)) for value in degrees]
|
||||
if not all(math.isfinite(value) for value in positions):
|
||||
raise ValueError(f"{source} contains NaN/Inf")
|
||||
return positions
|
||||
|
||||
def _log_motion_status_transition(
|
||||
self,
|
||||
status: tuple[Any, ...],
|
||||
) -> None:
|
||||
previous = self._last_motion_status
|
||||
if status == previous:
|
||||
return
|
||||
self._last_motion_status = status
|
||||
arm_status, joint_enabled, joint_errors, arm_errors, ready = status
|
||||
details = (
|
||||
f"arm_status={arm_status}, "
|
||||
f"joint_enabled={list(joint_enabled)}, "
|
||||
f"joint_errors={list(joint_errors)}, "
|
||||
f"arm_errors={list(arm_errors)}"
|
||||
)
|
||||
if not ready:
|
||||
self._log_warn(f"RealMan UDP 报警或掉使能:{details}")
|
||||
elif previous is not None and not previous[-1]:
|
||||
self._log_info(f"RealMan UDP 运动状态恢复正常:{details}")
|
||||
|
||||
def _apply_safety_limits(self) -> None:
|
||||
# 真机安全限幅尽量下发到控制器;不支持的 SDK 接口会在 _try_call 中降级为警告。
|
||||
self._try_call("rm_set_avoid_singularity_mode", int(self._avoid_singularity))
|
||||
@@ -260,74 +495,3 @@ class RealManAdapter:
|
||||
@staticmethod
|
||||
def _return_code(ret: Any) -> Any:
|
||||
return ret[0] if isinstance(ret, tuple) and ret else ret
|
||||
|
||||
@classmethod
|
||||
def _find_pose(cls, obj: Any) -> list[float] | None:
|
||||
# 不同 SDK 版本返回字段可能略有差异,因此递归查找常见 TCP 位姿字段。
|
||||
if isinstance(obj, dict):
|
||||
for key in ("pose", "tool_pose", "tcp_pose", "current_pose"):
|
||||
pose = cls._as_pose(obj.get(key))
|
||||
if pose is not None:
|
||||
return pose
|
||||
for value in obj.values():
|
||||
pose = cls._find_pose(value)
|
||||
if pose is not None:
|
||||
return pose
|
||||
elif isinstance(obj, (list, tuple)):
|
||||
pose = cls._as_pose(obj)
|
||||
if pose is not None:
|
||||
return pose
|
||||
for value in obj:
|
||||
pose = cls._find_pose(value)
|
||||
if pose is not None:
|
||||
return pose
|
||||
elif hasattr(obj, "to_dictionary"):
|
||||
try:
|
||||
return cls._find_pose(obj.to_dictionary(7))
|
||||
except TypeError:
|
||||
return cls._find_pose(obj.to_dictionary())
|
||||
elif hasattr(obj, "to_dict"):
|
||||
return cls._find_pose(obj.to_dict())
|
||||
else:
|
||||
for key in ("pose", "tool_pose", "tcp_pose", "current_pose"):
|
||||
if hasattr(obj, key):
|
||||
pose = cls._as_pose(getattr(obj, key))
|
||||
if pose is not None:
|
||||
return pose
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _as_pose(value: Any) -> list[float] | None:
|
||||
if isinstance(value, (list, tuple)) and len(value) >= 6:
|
||||
if all(isinstance(item, Number) for item in value[:6]):
|
||||
return [float(item) for item in value[:6]]
|
||||
if isinstance(value, dict):
|
||||
position = value.get("position")
|
||||
euler = value.get("euler")
|
||||
if isinstance(position, dict) and isinstance(euler, dict):
|
||||
keys = ("x", "y", "z")
|
||||
rpy_keys = ("rx", "ry", "rz")
|
||||
if all(key in position for key in keys) and all(key in euler for key in rpy_keys):
|
||||
return [
|
||||
float(position["x"]),
|
||||
float(position["y"]),
|
||||
float(position["z"]),
|
||||
float(euler["rx"]),
|
||||
float(euler["ry"]),
|
||||
float(euler["rz"]),
|
||||
]
|
||||
if all(hasattr(value, attr) for attr in ("position", "euler")):
|
||||
position = getattr(value, "position")
|
||||
euler = getattr(value, "euler")
|
||||
if all(hasattr(position, key) for key in ("x", "y", "z")) and all(
|
||||
hasattr(euler, key) for key in ("rx", "ry", "rz")
|
||||
):
|
||||
return [
|
||||
float(position.x),
|
||||
float(position.y),
|
||||
float(position.z),
|
||||
float(euler.rx),
|
||||
float(euler.ry),
|
||||
float(euler.rz),
|
||||
]
|
||||
return None
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user