# 双臂 MuJoCo 运动学遥操作实施计划
> **面向执行代理:** 必须逐任务执行本计划,并使用 `superpowers:subagent-driven-development`(推荐)或 `superpowers:executing-plans`。所有步骤使用复选框跟踪。
**目标:** 新增独立的 `xr_rm_mujoco` ROS2 包,用现有双臂 URDF 实时显示 Mock 或真机反馈,并保持现有 PICO、Placo QP、真机控制和安全行为不变。
**架构:** 左右 `single_arm_velocity_teleop` 节点继续负责所有控制逻辑,并用标准 `sensor_msgs/JointState` 发布当前适配器反馈和限速后的关节目标。单个 `dual_arm_simulator` 订阅左右反馈,按关节名写入 MuJoCo `qpos` 并调用 `mj_forward()`,只做运动学显示。现有 `arm_debug.launch.py` 用新增的 `use_mujoco` 参数选择是否启动显示进程。
**技术栈:** Ubuntu 22.04、ROS2 Humble、Python 3.10、ament_python、MuJoCo 3.10.0、Placo 0.9.4、NumPy、sensor_msgs、pytest、colcon。
---
## 执行约束
- 所有构建、测试和启动命令均在 `/home/robot/WS_xr` 执行,并先运行:
```bash
source /opt/ros/humble/setup.bash
```
- MuJoCo 和 Placo 测试使用 `/home/robot/miniconda3/envs/xr/bin/python`;不得用系统
pip、`pip --user` 或 `sudo pip` 改动现有环境。
- 自动化和启动验收只允许 `use_mock:=true`,不得连接真机、移动机械臂或操作夹爪。
- `move_to_initial_pose_on_connect` 保持 `false`,不得关闭现有安全限位、超时和停止
逻辑。
- 不修改左右节点名 `left_arm_teleop`、`right_arm_teleop`,不复制 URDF/mesh,不
生成持久化 MJCF,不实现动力学、碰撞或执行器。
- 每个任务只提交列出的文件,不提交无关工作树内容,不推送远程。
## 文件结构
**新建:**
- `xr_rm_mujoco/package.xml`:ROS2 包依赖。
- `xr_rm_mujoco/setup.py`:包安装和 `dual_arm_simulator` 入口。
- `xr_rm_mujoco/setup.cfg`:ament_python 脚本安装位置。
- `xr_rm_mujoco/resource/xr_rm_mujoco`:ament 索引标记。
- `xr_rm_mujoco/xr_rm_mujoco/__init__.py`:Python 包标记。
- `xr_rm_mujoco/xr_rm_mujoco/dual_arm_simulator.py`:MuJoCo 模型映射、ROS 订阅和 viewer。
- `xr_rm_mujoco/test/test_dual_arm_simulator.py`:模型加载、映射、校验与配置测试。
- `xr_rm_bringup/config/dual_arm_mujoco.yaml`:MuJoCo 专属刷新参数。
- `xr_rm_bringup/test/test_arm_debug_launch.py`:launch 参数和模式约束测试。
**修改:**
- `xr_rm_teleop/package.xml`:增加 `sensor_msgs` 运行依赖。
- `xr_rm_teleop/xr_rm_teleop/placo_ik_solver.py`:只读暴露当前侧关节名称。
- `xr_rm_teleop/xr_rm_teleop/single_arm_velocity_teleop.py`:发布关节反馈/目标并调整 Mock Reset 后的重新锚定。
- `xr_rm_teleop/test/test_joint_control.py`:关节话题和 Reset 回归测试。
- `xr_rm_bringup/package.xml`:增加 `xr_rm_mujoco` 运行依赖。
- `xr_rm_bringup/launch/arm_debug.launch.py`:增加 `use_mujoco` 和仿真节点。
- `README.md`:记录目录、启动方式、频率、话题和 Reset 行为。
`xr_rm_bringup/CMakeLists.txt` 已安装整个 `config` 目录,无需修改。
### 任务一:建立 MuJoCo 双臂运动学模型
**文件:**
- 新建:`xr_rm_mujoco/package.xml`
- 新建:`xr_rm_mujoco/setup.py`
- 新建:`xr_rm_mujoco/setup.cfg`
- 新建:`xr_rm_mujoco/resource/xr_rm_mujoco`
- 新建:`xr_rm_mujoco/xr_rm_mujoco/__init__.py`
- 新建:`xr_rm_mujoco/xr_rm_mujoco/dual_arm_simulator.py`
- 新建:`xr_rm_mujoco/test/test_dual_arm_simulator.py`
- [ ] **步骤 1:先写失败的 MuJoCo 模型测试**
创建 `xr_rm_mujoco/test/test_dual_arm_simulator.py`:
```python
import math
from pathlib import Path
import pytest
import yaml
from xr_rm_mujoco.dual_arm_simulator import (
ARM_JOINT_NAMES,
DualArmKinematicModel,
)
SRC_DIR = Path(__file__).resolve().parents[2]
URDF_PATH = (
SRC_DIR / "xr_rm_teleop" / "models" / "dual_rm75" / "Dual_arm.urdf"
)
DUAL_CONFIG_PATH = SRC_DIR / "xr_rm_bringup" / "config" / "dual_arm_rm75.yaml"
def test_dual_urdf_loads_with_expected_joint_mapping() -> None:
simulation = DualArmKinematicModel(str(URDF_PATH))
assert simulation.model.nq == 14
assert simulation.model.nv == 14
assert ARM_JOINT_NAMES == {
"left": tuple(f"scissor_joint_{index}" for index in range(1, 8)),
"right": tuple(f"omnipic_joint_{index}" for index in range(1, 8)),
}
assert not simulation.ready
def test_joint_messages_are_mapped_by_name_not_array_order() -> None:
simulation = DualArmKinematicModel(str(URDF_PATH))
names = list(reversed(ARM_JOINT_NAMES["left"]))
values = [float(index) / 10.0 for index in range(7)]
simulation.apply_arm_state("left", names, values)
by_name = dict(zip(names, values))
assert simulation.joint_positions("left") == pytest.approx(
[by_name[name] for name in ARM_JOINT_NAMES["left"]]
)
assert not simulation.ready
def test_yaml_initial_poses_populate_both_arms() -> None:
simulation = DualArmKinematicModel(str(URDF_PATH))
with DUAL_CONFIG_PATH.open(encoding="utf-8") as stream:
config = yaml.safe_load(stream)
for arm, node_name in (
("left", "left_arm_teleop"),
("right", "right_arm_teleop"),
):
degrees = config[node_name]["ros__parameters"]["initial_joint_pose"]
radians = [math.radians(value) for value in degrees]
simulation.apply_arm_state(arm, ARM_JOINT_NAMES[arm], radians)
assert simulation.joint_positions(arm) == pytest.approx(radians)
assert simulation.ready
@pytest.mark.parametrize(
("names", "positions", "match"),
[
(list(ARM_JOINT_NAMES["left"][:-1]), [0.0] * 6, "expected"),
(list(ARM_JOINT_NAMES["left"]), [0.0] * 6, "same length"),
(
[ARM_JOINT_NAMES["left"][0]] * 7,
[0.0] * 7,
"unique",
),
(
list(ARM_JOINT_NAMES["left"]),
[0.0] * 6 + [math.nan],
"finite",
),
],
)
def test_invalid_joint_state_is_rejected_without_partial_update(
names: list[str],
positions: list[float],
match: str,
) -> None:
simulation = DualArmKinematicModel(str(URDF_PATH))
valid = [0.1] * 7
simulation.apply_arm_state("left", ARM_JOINT_NAMES["left"], valid)
with pytest.raises(ValueError, match=match):
simulation.apply_arm_state("left", names, positions)
assert simulation.joint_positions("left") == pytest.approx(valid)
```
- [ ] **步骤 2:运行测试并确认因新包不存在而失败**
运行:
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
PYTHONPATH=src/xr_rm_mujoco \
/home/robot/miniconda3/envs/xr/bin/python -m pytest \
src/xr_rm_mujoco/test/test_dual_arm_simulator.py -v
```
预期:收集失败,提示 `ModuleNotFoundError: No module named 'xr_rm_mujoco'` 或缺少
`dual_arm_simulator`。
- [ ] **步骤 3:创建最小 ROS2 Python 包元数据**
创建空文件 `xr_rm_mujoco/resource/xr_rm_mujoco` 和
`xr_rm_mujoco/xr_rm_mujoco/__init__.py`。
创建 `xr_rm_mujoco/setup.cfg`:
```ini
[develop]
script_dir=$base/lib/xr_rm_mujoco
[install]
install_scripts=$base/lib/xr_rm_mujoco
```
创建 `xr_rm_mujoco/setup.py`:
```python
"""MuJoCo 双 RM75 运动学显示包安装配置。"""
from setuptools import setup
package_name = "xr_rm_mujoco"
setup(
name=package_name,
version="0.1.0",
packages=[package_name],
data_files=[
("share/ament_index/resource_index/packages", [f"resource/{package_name}"]),
(f"share/{package_name}", ["package.xml"]),
],
install_requires=["setuptools"],
zip_safe=True,
maintainer="Yikai Fu",
maintainer_email="user@example.com",
description="MuJoCo kinematic visualization for the dual RM75 platform.",
license="Apache-2.0",
tests_require=["pytest"],
)
```
创建 `xr_rm_mujoco/package.xml`:
```xml
xr_rm_mujoco
0.1.0
MuJoCo kinematic visualization for the dual RM75 platform.
Yikai Fu
Apache-2.0
ament_python
rclpy
sensor_msgs
xr_rm_teleop
ament_lint_auto
ament_lint_common
python3-pytest
python3-yaml
ament_python
```
不要把 `mujoco` 加入 `install_requires`,避免 colcon 构建时通过 pip 改动已经固定的
Conda 环境;运行入口后续继续使用项目现有 `XR_PYTHON`。
- [ ] **步骤 4:实现最小的名称映射和运动学状态类**
创建 `xr_rm_mujoco/xr_rm_mujoco/dual_arm_simulator.py`:
```python
"""使用现有双 RM75 URDF 的 MuJoCo 运动学状态映射。"""
from __future__ import annotations
import math
from pathlib import Path
import mujoco
ARM_JOINT_NAMES = {
"left": tuple(f"scissor_joint_{index}" for index in range(1, 8)),
"right": tuple(f"omnipic_joint_{index}" for index in range(1, 8)),
}
class DualArmKinematicModel:
"""加载双臂 URDF,并按关节名更新 MuJoCo qpos。"""
def __init__(self, urdf_path: str) -> None:
path = Path(urdf_path).expanduser().resolve()
if not path.is_file():
raise FileNotFoundError(f"dual RM75 URDF not found: {path}")
self.model = mujoco.MjModel.from_xml_path(str(path))
self.data = mujoco.MjData(self.model)
self._qpos_addresses: dict[str, dict[str, int]] = {}
self._received_arms: set[str] = set()
for arm, names in ARM_JOINT_NAMES.items():
addresses = {}
for name in names:
joint_id = mujoco.mj_name2id(
self.model,
mujoco.mjtObj.mjOBJ_JOINT,
name,
)
if joint_id < 0:
raise RuntimeError(f"MuJoCo joint not found: {name}")
if self.model.jnt_type[joint_id] != mujoco.mjtJoint.mjJNT_HINGE:
raise RuntimeError(f"MuJoCo joint must be hinge: {name}")
addresses[name] = int(self.model.jnt_qposadr[joint_id])
self._qpos_addresses[arm] = addresses
@property
def ready(self) -> bool:
return self._received_arms == set(ARM_JOINT_NAMES)
def apply_arm_state(
self,
arm: str,
names: list[str] | tuple[str, ...],
positions: list[float] | tuple[float, ...],
) -> None:
if arm not in ARM_JOINT_NAMES:
raise ValueError("arm must be left or right")
if len(names) != len(positions):
raise ValueError("joint names and positions must have the same length")
if len(set(names)) != len(names):
raise ValueError("joint names must be unique")
expected = set(ARM_JOINT_NAMES[arm])
if set(names) != expected:
raise ValueError(f"joint names must match expected {arm} joints")
values = [float(value) for value in positions]
if not all(math.isfinite(value) for value in values):
raise ValueError("joint positions must be finite")
by_name = dict(zip(names, values))
updates = [
(self._qpos_addresses[arm][name], by_name[name])
for name in ARM_JOINT_NAMES[arm]
]
for address, value in updates:
self.data.qpos[address] = value
mujoco.mj_forward(self.model, self.data)
self._received_arms.add(arm)
def joint_positions(self, arm: str) -> list[float]:
if arm not in ARM_JOINT_NAMES:
raise ValueError("arm must be left or right")
return [
float(self.data.qpos[self._qpos_addresses[arm][name]])
for name in ARM_JOINT_NAMES[arm]
]
```
- [ ] **步骤 5:运行 MuJoCo 模型测试并确认通过**
运行步骤 2 的同一命令。
预期:全部 PASS;本机输出会使用 MuJoCo 3.10.0,模型为 `nq=14`、`nv=14`。
- [ ] **步骤 6:提交新包基础**
```bash
git add \
src/xr_rm_mujoco/package.xml \
src/xr_rm_mujoco/setup.py \
src/xr_rm_mujoco/setup.cfg \
src/xr_rm_mujoco/resource/xr_rm_mujoco \
src/xr_rm_mujoco/xr_rm_mujoco/__init__.py \
src/xr_rm_mujoco/xr_rm_mujoco/dual_arm_simulator.py \
src/xr_rm_mujoco/test/test_dual_arm_simulator.py
git commit -m "feat: 添加双臂 MuJoCo 运动学模型"
```
### 任务二:发布左右关节反馈与限速目标
**文件:**
- 修改:`xr_rm_teleop/package.xml`
- 修改:`xr_rm_teleop/xr_rm_teleop/placo_ik_solver.py`
- 修改:`xr_rm_teleop/xr_rm_teleop/single_arm_velocity_teleop.py`
- 修改:`xr_rm_teleop/test/test_joint_control.py`
- [ ] **步骤 1:先增加关节消息失败测试和测试发布器**
在 `xr_rm_teleop/test/test_joint_control.py` 导入:
```python
from builtin_interfaces.msg import Time as TimeMsg
```
给现有 `FakeTime` 增加:
```python
def to_msg(self):
return TimeMsg()
```
在文件前部增加:
```python
class FakePublisher:
def __init__(self) -> None:
self.messages = []
def publish(self, message) -> None:
self.messages.append(message)
def _joint_publishing_teleop() -> SingleArmVelocityTeleop:
names = [f"omnipic_joint_{index}" for index in range(1, 8)]
teleop = object.__new__(SingleArmVelocityTeleop)
teleop._ik_solver = SimpleNamespace(
joint_names=names,
update_joint_state=lambda joints: np.eye(4),
)
teleop._joint_state_pub = FakePublisher()
teleop._joint_target_pub = FakePublisher()
teleop._active = False
teleop._last_valid_joint_target = None
teleop.get_clock = lambda: SimpleNamespace(now=lambda: FakeTime())
return teleop
def test_reset_joint_state_publishes_named_feedback() -> None:
teleop = _joint_publishing_teleop()
positions = [0.1 * index for index in range(7)]
snapshot = JointStateSnapshot(positions, time.monotonic())
teleop._reset_joint_state(snapshot)
message = teleop._joint_state_pub.messages[-1]
assert message.name == teleop._ik_solver.joint_names
assert message.position == pytest.approx(positions)
def test_sync_joint_feedback_publishes_each_sample() -> None:
teleop = _joint_publishing_teleop()
positions = [0.2] * 7
teleop._sync_joint_feedback(
JointStateSnapshot(positions, time.monotonic())
)
assert len(teleop._joint_state_pub.messages) == 1
assert teleop._joint_state_pub.messages[0].position == pytest.approx(positions)
```
给现有 `_timeout_teleop()` 补充一个 `FakePublisher`,防止后续成功发送目标时测试对象
缺少新属性:
```python
teleop._joint_state_pub = FakePublisher()
teleop._joint_target_pub = FakePublisher()
teleop._ik_solver.joint_names = [
f"omnipic_joint_{index}" for index in range(1, 8)
]
teleop.get_clock = lambda: SimpleNamespace(now=lambda: FakeTime())
```
增加限速后目标发布测试:
```python
def test_send_joint_target_publishes_limited_command() -> None:
sent = []
teleop = _joint_publishing_teleop()
teleop._adapter = SimpleNamespace(
send_joint_target=lambda joints, follow: sent.append((list(joints), follow))
)
teleop._follow = False
teleop._latest_joint_positions = [0.0] * 7
teleop._last_joint_command_target = [0.0] * 7
teleop._last_joint_command_velocity = [0.0] * 7
teleop._joint_command_max_speed = 1.0
teleop._joint_command_max_acceleration = 100.0
teleop._dt = 0.1
assert teleop._send_joint_target([0.5] * 7)
assert len(sent) == 1
assert sent[0][0] == pytest.approx([0.1] * 7)
assert sent[0][1] is False
message = teleop._joint_target_pub.messages[-1]
assert message.name == teleop._ik_solver.joint_names
assert message.position == pytest.approx(teleop._last_joint_command_target)
```
现有以下两个测试也会经过新的反馈发布路径,分别补齐
`joint_names`、`_joint_state_pub` 和可生成 ROS 时间戳的 fake clock:
```python
# test_startup_joint_query_initializes_qp_and_command_history
teleop._ik_solver = SimpleNamespace(
joint_names=[f"omnipic_joint_{index}" for index in range(1, 8)],
update_joint_state=lambda joints: pose,
)
teleop._joint_state_pub = FakePublisher()
teleop.get_clock = lambda: SimpleNamespace(now=lambda: FakeTime())
# test_first_feedback_initializes_last_valid_target_without_solving
teleop._ik_solver.joint_names = [
f"omnipic_joint_{index}" for index in range(1, 8)
]
teleop._joint_state_pub = FakePublisher()
teleop.get_clock = lambda: SimpleNamespace(now=lambda: FakeTime())
```
`test_feedback_fault_blocks_grip_until_release` 的测试对象会调用 `_control_tick()` 并
进入 `_sync_joint_feedback()`,同样增加:
```python
teleop._ik_solver.joint_names = [
f"omnipic_joint_{index}" for index in range(1, 8)
]
teleop._joint_state_pub = FakePublisher()
```
- [ ] **步骤 2:运行新增测试并确认因尚未发布消息而失败**
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
pytest \
src/xr_rm_teleop/test/test_joint_control.py::test_reset_joint_state_publishes_named_feedback \
src/xr_rm_teleop/test/test_joint_control.py::test_sync_joint_feedback_publishes_each_sample \
src/xr_rm_teleop/test/test_joint_control.py::test_send_joint_target_publishes_limited_command \
-v
```
预期:FAIL,反馈或目标发布器的消息列表仍为空。
- [ ] **步骤 3:暴露求解器当前侧关节名称**
在 `PlacoIkSolver` 的 `base_configuration` 属性前增加:
```python
@property
def joint_names(self) -> list[str]:
return list(self._joint_names)
```
返回副本,调用者不能修改求解器内部关节顺序。
- [ ] **步骤 4:创建发布器和标准 JointState 消息**
在 `single_arm_velocity_teleop.py` 导入:
```python
from sensor_msgs.msg import JointState
```
在创建 `PlacoIkSolver` 后、连接适配器前创建两个发布器,使启动首帧可以立即发布:
```python
debug_ns = f"{self._debug_topic_prefix}/{self._arm_name}"
self._joint_state_pub = self.create_publisher(
JointState,
f"{debug_ns}/joint_states",
10,
)
self._joint_target_pub = self.create_publisher(
JointState,
f"{debug_ns}/joint_target",
10,
)
```
保留后续五个现有调试发布器,并复用已经计算的 `debug_ns`,不要重复赋值。
在 `_reset_joint_state()` 前增加:
```python
def _publish_joint_positions(self, publisher, positions: list[float]) -> None:
message = JointState()
message.header.stamp = self.get_clock().now().to_msg()
message.name = self._ik_solver.joint_names
message.position = [float(value) for value in positions]
publisher.publish(message)
```
在 `_reset_joint_state()` 设置完状态后、返回前增加:
```python
self._publish_joint_positions(self._joint_state_pub, positions)
```
在 `_sync_joint_feedback()` 设置完 `_last_current_pose` 后增加:
```python
self._publish_joint_positions(
self._joint_state_pub,
list(snapshot.positions),
)
```
在 `_send_joint_target()` 成功更新 `_last_joint_command_velocity` 后增加:
```python
self._publish_joint_positions(
self._joint_target_pub,
limited_target,
)
```
这样 `joint_target` 是经过关节速度/加速度限制后真正交给当前适配器的目标,而不是
限速前的 QP 原始结果。
- [ ] **步骤 5:声明 ROS 标准消息依赖**
在 `xr_rm_teleop/package.xml` 的 `rclpy` 依赖后增加:
```xml
sensor_msgs
```
- [ ] **步骤 6:运行关节控制测试并确认通过**
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
pytest src/xr_rm_teleop/test/test_joint_control.py -v
```
预期:全部 PASS,包括启动首帧、90 Hz 反馈同步路径和限速后目标发布。
- [ ] **步骤 7:提交关节状态接口**
```bash
git add \
src/xr_rm_teleop/package.xml \
src/xr_rm_teleop/xr_rm_teleop/placo_ik_solver.py \
src/xr_rm_teleop/xr_rm_teleop/single_arm_velocity_teleop.py \
src/xr_rm_teleop/test/test_joint_control.py
git commit -m "feat: 发布双臂关节状态与目标"
```
### 任务三:让 Mock Reset 后立即重新锚定
**文件:**
- 修改:`xr_rm_teleop/xr_rm_teleop/single_arm_velocity_teleop.py`
- 修改:`xr_rm_teleop/test/test_joint_control.py`
- [ ] **步骤 1:写 Mock 与真机 Reset 差异的失败测试**
把现有 `_primary_button_teleop` 改为接收适配器模式:
```python
def _primary_button_teleop(*, use_mock=False, move_error=None):
# 保留现有函数体,并在 teleop 初始化处增加:
teleop._use_mock = use_mock
```
保留现有 `test_primary_button_rising_edge_moves_once_and_resyncs()` 对真机语义的
`assert teleop._grip_rearm_required`,并新增:
```python
def test_mock_primary_reset_can_reanchor_without_grip_release() -> None:
teleop, events, _, snapshot = _primary_button_teleop(use_mock=True)
teleop._on_controller(SimpleNamespace(primary=False))
teleop._on_controller(SimpleNamespace(primary=True))
assert events == [
("stop", True),
"move",
"read",
("sync", snapshot),
]
assert not teleop._grip_rearm_required
def test_failed_mock_primary_reset_still_requires_grip_release() -> None:
failure = RuntimeError("mock reset failed")
teleop, _, _, _ = _primary_button_teleop(
use_mock=True,
move_error=failure,
)
teleop._on_controller(SimpleNamespace(primary=False))
teleop._on_controller(SimpleNamespace(primary=True))
assert teleop._grip_rearm_required
```
- [ ] **步骤 2:运行新增测试并确认自动重新锚定测试失败**
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
pytest \
src/xr_rm_teleop/test/test_joint_control.py::test_mock_primary_reset_can_reanchor_without_grip_release \
src/xr_rm_teleop/test/test_joint_control.py::test_failed_mock_primary_reset_still_requires_grip_release \
-v
```
预期:第一项 FAIL,因为当前成功 Reset 后仍设置 `_grip_rearm_required=True`;失败
路径测试 PASS。
- [ ] **步骤 3:缓存 use_mock 并只在成功 Mock Reset 后解除重新使能要求**
在参数读取区域保存:
```python
self._use_mock = self._bool_parameter("use_mock")
```
把 `_make_adapter()` 中的判断改为:
```python
if self._use_mock:
return MockRealManAdapter(initial_joint_pose)
```
在 `_handle_initial_pose_button()` 中保持 Reset 前先锁存:
```python
self._grip_rearm_required = True
```
并只在 `move_to_initial_pose()`、`read_joint_state()` 和 `_reset_joint_state()` 全部成功
后增加:
```python
if self._use_mock:
self._grip_rearm_required = False
```
不要更改异常分支。`_safe_stop(reset_active=True)` 已清除旧手柄/机械臂基准;下一次
90 Hz 控制周期看到 Grip 仍按下时,会走现有 `_enter_active_control()`,以 Reset 后
状态自动建立新基准。真机仍保留 `_grip_rearm_required=True`。
- [ ] **步骤 4:运行完整关节与姿态控制测试**
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
pytest src/xr_rm_teleop/test/test_joint_control.py -v
pytest src/xr_rm_teleop/test/test_orientation_control.py -v
```
预期:全部 PASS;Mock 成功 Reset 可立即重锚,真机和失败路径仍需 Grip 松开。
- [ ] **步骤 5:提交 Reset 行为**
```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 "feat: 支持 MuJoCo 双臂即时复位"
```
### 任务四:增加 ROS MuJoCo viewer 节点和独立配置
**文件:**
- 修改:`xr_rm_mujoco/setup.py`
- 修改:`xr_rm_mujoco/xr_rm_mujoco/dual_arm_simulator.py`
- 修改:`xr_rm_mujoco/test/test_dual_arm_simulator.py`
- 新建:`xr_rm_bringup/config/dual_arm_mujoco.yaml`
- [ ] **步骤 1:写独立 MuJoCo 配置的失败测试**
在 `test_dual_arm_simulator.py` 增加:
```python
MUJOCO_CONFIG_PATH = (
SRC_DIR / "xr_rm_bringup" / "config" / "dual_arm_mujoco.yaml"
)
def test_mujoco_config_contains_only_render_parameters() -> None:
with MUJOCO_CONFIG_PATH.open(encoding="utf-8") as stream:
parameters = yaml.safe_load(stream)["dual_arm_simulator"]["ros__parameters"]
assert parameters == {"render_rate_hz": 60.0}
```
- [ ] **步骤 2:运行配置测试并确认文件不存在**
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
PYTHONPATH=src/xr_rm_mujoco \
/home/robot/miniconda3/envs/xr/bin/python -m pytest \
src/xr_rm_mujoco/test/test_dual_arm_simulator.py::test_mujoco_config_contains_only_render_parameters \
-v
```
预期:FAIL,提示 `dual_arm_mujoco.yaml` 不存在。
- [ ] **步骤 3:创建 MuJoCo 专属配置**
创建 `xr_rm_bringup/config/dual_arm_mujoco.yaml`:
```yaml
# 双 RM75 MuJoCo 运动学显示参数。初始姿态和控制限制仍由 dual_arm_rm75.yaml 管理。
dual_arm_simulator:
ros__parameters:
render_rate_hz: 60.0
```
- [ ] **步骤 4:在同一生产文件中增加最小 ROS wrapper**
在 `dual_arm_simulator.py` 增加导入:
```python
from typing import Callable
import mujoco.viewer
import rclpy
from rclpy.node import Node
from sensor_msgs.msg import JointState
```
在 `DualArmKinematicModel` 后增加:
```python
STATE_TOPICS = {
"left": "/xr_rm/left_rm75/joint_states",
"right": "/xr_rm/right_rm75/joint_states",
}
class DualArmSimulator(Node):
"""订阅左右关节反馈并刷新一个 MuJoCo 双臂 viewer。"""
def __init__(
self,
viewer_factory: Callable = mujoco.viewer.launch_passive,
) -> None:
super().__init__("dual_arm_simulator")
self.declare_parameter("robot_urdf_path", "")
self.declare_parameter("render_rate_hz", 60.0)
render_rate_hz = float(self.get_parameter("render_rate_hz").value)
if not math.isfinite(render_rate_hz) or render_rate_hz <= 0.0:
raise ValueError("render_rate_hz must be finite and > 0")
self._kinematics = DualArmKinematicModel(
str(self.get_parameter("robot_urdf_path").value)
)
self._viewer_factory = viewer_factory
self._viewer = None
self._subscriptions = [
self.create_subscription(
JointState,
topic,
lambda message, selected_arm=arm: self._on_joint_state(
selected_arm,
message,
),
10,
)
for arm, topic in STATE_TOPICS.items()
]
self.create_timer(1.0 / render_rate_hz, self._render)
self.get_logger().info(
"MuJoCo 双臂节点已启动,等待左右关节状态,"
f"render_rate_hz={render_rate_hz:.1f}"
)
def _on_joint_state(self, arm: str, message: JointState) -> None:
try:
if self._viewer is None:
self._kinematics.apply_arm_state(
arm,
list(message.name),
list(message.position),
)
else:
with self._viewer.lock():
self._kinematics.apply_arm_state(
arm,
list(message.name),
list(message.position),
)
except (RuntimeError, ValueError) as exc:
self.get_logger().warn(
f"拒绝 {arm} 关节状态:{exc}",
throttle_duration_sec=1.0,
)
def _render(self) -> None:
for topic in STATE_TOPICS.values():
publisher_count = self.count_publishers(topic)
if publisher_count > 1:
self.get_logger().warn(
f"关节状态话题存在多个发布者:{topic}, count={publisher_count}",
throttle_duration_sec=5.0,
)
if not self._kinematics.ready:
return
if self._viewer is None:
self._viewer = self._viewer_factory(
self._kinematics.model,
self._kinematics.data,
)
if not self._viewer.is_running():
self.get_logger().info("MuJoCo viewer 已关闭。")
rclpy.shutdown()
return
self._viewer.sync()
def close_viewer(self) -> None:
if self._viewer is not None:
self._viewer.close()
self._viewer = None
def main(args=None) -> None:
rclpy.init(args=args)
node = None
try:
node = DualArmSimulator()
rclpy.spin(node)
finally:
if node is not None:
node.close_viewer()
node.destroy_node()
if rclpy.ok():
rclpy.shutdown()
if __name__ == "__main__":
main()
```
保留单线程 `rclpy.spin()`;ROS 回调和 render timer 不会并行修改 qpos,viewer 已启动
后通过官方 `viewer.lock()` 保护写入。
- [ ] **步骤 5:安装 ROS 可执行入口**
在 `xr_rm_mujoco/setup.py` 的 `tests_require` 后增加:
```python
entry_points={
"console_scripts": [
"dual_arm_simulator = xr_rm_mujoco.dual_arm_simulator:main",
],
},
```
- [ ] **步骤 6:运行 MuJoCo 测试并确认通过**
运行:
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
PYTHONPATH=src/xr_rm_mujoco \
/home/robot/miniconda3/envs/xr/bin/python -m pytest \
src/xr_rm_mujoco/test/test_dual_arm_simulator.py -v
```
预期:全部 PASS;测试只加载模型,不打开 viewer。
- [ ] **步骤 7:提交 ROS viewer 与配置**
```bash
git add \
src/xr_rm_mujoco/setup.py \
src/xr_rm_mujoco/xr_rm_mujoco/dual_arm_simulator.py \
src/xr_rm_mujoco/test/test_dual_arm_simulator.py \
src/xr_rm_bringup/config/dual_arm_mujoco.yaml
git commit -m "feat: 添加双臂 MuJoCo 显示节点"
```
### 任务五:接入统一 launch 并保持默认行为
**文件:**
- 新建:`xr_rm_bringup/test/test_arm_debug_launch.py`
- 修改:`xr_rm_bringup/launch/arm_debug.launch.py`
- 修改:`xr_rm_bringup/package.xml`
- [ ] **步骤 1:写 launch 参数和范围约束的失败测试**
创建 `xr_rm_bringup/test/test_arm_debug_launch.py`:
```python
import importlib.util
from pathlib import Path
import pytest
from launch import LaunchContext
from launch.actions import DeclareLaunchArgument
from launch.utilities import perform_substitutions
MODULE_PATH = Path(__file__).parents[1] / "launch" / "arm_debug.launch.py"
SPEC = importlib.util.spec_from_file_location("arm_debug_launch", MODULE_PATH)
arm_debug_launch = importlib.util.module_from_spec(SPEC)
assert SPEC.loader is not None
SPEC.loader.exec_module(arm_debug_launch)
def test_launch_declares_mujoco_disabled_by_default() -> None:
description = arm_debug_launch.generate_launch_description()
arguments = {
entity.name: entity
for entity in description.entities
if isinstance(entity, DeclareLaunchArgument)
}
assert "use_mujoco" in arguments
assert perform_substitutions(
LaunchContext(),
arguments["use_mujoco"].default_value,
) == "false"
def test_mujoco_mode_requires_both_arms() -> None:
arm_debug_launch._validate_mujoco_mode("both", True)
arm_debug_launch._validate_mujoco_mode("left", False)
with pytest.raises(ValueError, match="arm:=both"):
arm_debug_launch._validate_mujoco_mode("left", True)
```
- [ ] **步骤 2:运行 launch 测试并确认缺少参数和校验函数**
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
pytest src/xr_rm_bringup/test/test_arm_debug_launch.py -v
```
预期:FAIL,提示缺少 `use_mujoco` 或 `_validate_mujoco_mode`。
- [ ] **步骤 3:增加 MuJoCo 节点构造与模式校验**
在 `arm_debug.launch.py` 的 `_udp_receiver_node()` 后增加:
```python
def _mujoco_node() -> Node:
"""启动只读双臂 MuJoCo 运动学显示节点。"""
return Node(
package="xr_rm_mujoco",
executable="dual_arm_simulator",
name="dual_arm_simulator",
output="screen",
prefix=[XR_PYTHON],
parameters=[
_config_file("dual_arm_mujoco.yaml"),
{"robot_urdf_path": _dual_rm75_urdf()},
],
)
def _validate_mujoco_mode(arm: str, use_mujoco: bool) -> None:
if use_mujoco and arm != "both":
raise ValueError("use_mujoco:=true requires arm:=both")
```
在 `_launch_setup()` 读取:
```python
use_mujoco = _as_bool(
LaunchConfiguration("use_mujoco").perform(context)
)
```
在已有 `arm` 校验后调用:
```python
_validate_mujoco_mode(arm, use_mujoco)
```
在左右遥操作节点加入完成后追加:
```python
if use_mujoco:
nodes.append(_mujoco_node())
```
在 `generate_launch_description()` 中 `use_mock` 后增加:
```python
# true 时额外启动只读 MuJoCo 双臂显示,默认不改变现有启动行为。
DeclareLaunchArgument("use_mujoco", default_value="false"),
```
- [ ] **步骤 4:声明 bringup 对新包的运行依赖**
在 `xr_rm_bringup/package.xml` 的 `xr_rm_input` 后增加:
```xml
xr_rm_mujoco
```
- [ ] **步骤 5:运行 launch 测试并确认通过**
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
pytest src/xr_rm_bringup/test/test_arm_debug_launch.py -v
```
预期:全部 PASS;`use_mujoco` 默认关闭,只有 `arm:=both` 可以启用。
- [ ] **步骤 6:提交 launch 集成**
```bash
git add \
src/xr_rm_bringup/package.xml \
src/xr_rm_bringup/launch/arm_debug.launch.py \
src/xr_rm_bringup/test/test_arm_debug_launch.py
git commit -m "feat: 接入双臂 MuJoCo 启动模式"
```
### 任务六:更新文档并完成全量 Mock 验收
**文件:**
- 修改:`README.md`
- [ ] **步骤 1:更新 README 的范围、结构与运行说明**
在 README 中做以下明确修改:
1. 在“已完成”加入 MuJoCo 运动学显示;
2. 在结构树加入 `xr_rm_mujoco` 和 `dual_arm_mujoco.yaml`;
3. 在 launch 参数加入 `use_mujoco`;
4. 增加下面两条命令,并明确第二条会连接真机:
```bash
# 无真机:Mock 状态驱动 MuJoCo
ros2 launch xr_rm_bringup arm_debug.launch.py \
arm:=both use_mock:=true use_mujoco:=true
# 真机:实际反馈同步到 MuJoCo
ros2 launch xr_rm_bringup arm_debug.launch.py \
arm:=both use_mock:=false use_mujoco:=true
```
5. 记录关节反馈话题、目标话题、60 Hz viewer、90 Hz MuJoCo 状态输入和真机原始
200 Hz 反馈;
6. 记录左 X/右 A Reset:Mock 立即复位且 Grip 保持时自动重锚,真机仍需松开 Grip;
7. 明确 `move_to_initial_pose_on_connect` 继续保持 `false`。
- [ ] **步骤 2:运行局部测试**
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
PYTHONPATH=src/xr_rm_mujoco \
/home/robot/miniconda3/envs/xr/bin/python -m pytest \
src/xr_rm_mujoco/test/test_dual_arm_simulator.py -v
pytest src/xr_rm_teleop/test/test_joint_control.py -v
pytest src/xr_rm_teleop/test/test_orientation_control.py -v
pytest src/xr_rm_bringup/test/test_arm_debug_launch.py -v
```
预期:全部 PASS,不允许把跳过或收集失败报告为通过。
- [ ] **步骤 3:构建整个工作空间并检查安装资源**
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
colcon build --symlink-install
source install/setup.bash
ros2 pkg executables xr_rm_mujoco
test -f install/xr_rm_bringup/share/xr_rm_bringup/config/dual_arm_mujoco.yaml
```
预期:构建成功;`ros2 pkg executables` 显示
`xr_rm_mujoco dual_arm_simulator`;配置文件检查返回 0。
- [ ] **步骤 4:验证原有 Mock 默认路径不启动 MuJoCo**
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
source install/setup.bash
timeout 15s ros2 launch xr_rm_bringup arm_debug.launch.py \
arm:=both use_mock:=true use_mujoco:=false
```
预期:左右 `left_arm_teleop`、`right_arm_teleop` 和 UDP receiver 正常启动,没有
`dual_arm_simulator`;`timeout` 返回 124 属于预期。
- [ ] **步骤 5:在有图形桌面的终端验证 MuJoCo Mock 链路**
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
source install/setup.bash
ros2 launch xr_rm_bringup arm_debug.launch.py \
arm:=both use_mock:=true use_mujoco:=true
```
另开终端启动 PICO bridge 或现有 sample sender。确认:
- viewer 收齐左右首帧后以 `dual_arm_rm75.yaml` 初始姿态打开;
- 左右 Grip 分别驱动对应机械臂,不串臂;
- 左 X、右 A 分别立即 Reset,Grip 保持时下一周期重新锚定并可继续运动;
- `ros2 topic hz /xr_rm/left_rm75/joint_states` 和右侧话题接近 `90 Hz`;
- 关闭 viewer 只结束 MuJoCo 节点,不改变遥操作节点。
不得为完成本步骤使用 `use_mock:=false`。如果当前会话没有图形桌面,记录
“未执行 viewer 人工检查:无 DISPLAY”,但模型测试、构建和非 viewer Mock 启动仍
必须完成。
- [ ] **步骤 6:检查安全配置未被改变**
```bash
cd /home/robot/WS_xr
rg -n \
"configure_safety_limits: true|move_to_initial_pose_on_connect: false|control_rate_hz: 90.0" \
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 Hz 控制频率;本任务只
新增 `dual_arm_mujoco.yaml`,不改这些值。
- [ ] **步骤 7:提交 README**
```bash
git add src/README.md
git commit -m "docs: 补充双臂 MuJoCo 使用说明"
```
## 完成标准
- `Dual_arm.urdf` 是 MuJoCo 和 Placo 唯一双臂模型源;
- `use_mujoco` 默认关闭,现有 mock/真机命令行为不变;
- Mock 和真机模式的 MuJoCo 输入均为当前适配器反馈,名义发布频率 90 Hz;
- 真机原始反馈保持 5 ms 周期,MuJoCo viewer 为 60 Hz;
- Mock A/X Reset 立即回 YAML 初始姿态并可在 Grip 保持时重新锚定;
- 真机 Reset、安全限位、超时、停止和单 RealMan 连接行为不变;
- 所有指定测试、colcon 构建和安装资源检查通过;
- 未执行任何真机运动、夹爪操作、远程提交或推送。