feat: 添加双臂 MuJoCo 运动学模型
This commit is contained in:
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
|
||||||
|
<package format="3">
|
||||||
|
<name>xr_rm_mujoco</name>
|
||||||
|
<version>0.1.0</version>
|
||||||
|
<description>MuJoCo kinematic visualization for the dual RM75 platform.</description>
|
||||||
|
<maintainer email="user@example.com">Yikai Fu</maintainer>
|
||||||
|
<license>Apache-2.0</license>
|
||||||
|
|
||||||
|
<buildtool_depend>ament_python</buildtool_depend>
|
||||||
|
|
||||||
|
<exec_depend>rclpy</exec_depend>
|
||||||
|
<exec_depend>sensor_msgs</exec_depend>
|
||||||
|
<exec_depend>xr_rm_teleop</exec_depend>
|
||||||
|
|
||||||
|
<test_depend>ament_lint_auto</test_depend>
|
||||||
|
<test_depend>ament_lint_common</test_depend>
|
||||||
|
<test_depend>python3-pytest</test_depend>
|
||||||
|
<test_depend>python3-yaml</test_depend>
|
||||||
|
|
||||||
|
<export>
|
||||||
|
<build_type>ament_python</build_type>
|
||||||
|
</export>
|
||||||
|
</package>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
[develop]
|
||||||
|
script_dir=$base/lib/xr_rm_mujoco
|
||||||
|
[install]
|
||||||
|
install_scripts=$base/lib/xr_rm_mujoco
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""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"],
|
||||||
|
)
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""双 RM75 MuJoCo 运动学显示包。"""
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""使用现有双 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]
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user