Buckets:
| """Synthetic, data-free tests for fpgm.robot.urdf.RobotModel. | |
| No network access and no third-party assets: the URDF and its one mesh reference | |
| are both written to ``tmp_path`` by the tests themselves, mirroring the shape of | |
| the real PointWorld Franka Panda + Robotiq 2F-85 URDF (arm joints, a ``finger_joint`` | |
| gripper drive joint, mimic joints, and a collision-only ``*_sc``-style link) without | |
| depending on it being present. | |
| """ | |
| from __future__ import annotations | |
| import math | |
| from pathlib import Path | |
| import numpy as np | |
| import pytest | |
| from fpgm.robot.urdf import MimicJoint, RobotModel | |
| _DRIVE_JOINT = "finger_joint" | |
| _MIMIC_POS_JOINT = "test_mimic_pos_joint" | |
| _MIMIC_NEG_JOINT = "test_mimic_neg_joint" | |
| # A single-triangle ASCII STL, used only to exercise mesh loading + <mesh scale=...>. | |
| # Unscaled, its vertices span x in [0, 1] and y in [0, 1]. | |
| _TRIANGLE_STL = """solid tri | |
| facet normal 0 0 1 | |
| outer loop | |
| vertex 0 0 0 | |
| vertex 1 0 0 | |
| vertex 0 1 0 | |
| endloop | |
| endfacet | |
| endsolid tri | |
| """ | |
| _URDF_TEMPLATE = f"""<?xml version="1.0"?> | |
| <robot name="synthetic_test"> | |
| <link name="base_link"/> | |
| <link name="link1"> | |
| <visual> | |
| <origin xyz="0 0 0.1" rpy="0 0 0"/> | |
| <geometry> | |
| <box size="0.05 0.05 0.05"/> | |
| </geometry> | |
| </visual> | |
| </link> | |
| <link name="link2"> | |
| <visual> | |
| <origin xyz="0.5 0 0" rpy="0 0 0"/> | |
| <geometry> | |
| <mesh filename="tri.stl" scale="2 2 2"/> | |
| </geometry> | |
| </visual> | |
| </link> | |
| <link name="gripper_drive_link"/> | |
| <link name="mimic_pos_link"/> | |
| <link name="mimic_neg_link"/> | |
| <link name="link2_sc"> | |
| <collision> | |
| <geometry> | |
| <box size="0.05 0.05 0.05"/> | |
| </geometry> | |
| </collision> | |
| </link> | |
| <joint name="joint1" type="revolute"> | |
| <parent link="base_link"/> | |
| <child link="link1"/> | |
| <origin xyz="0 0 0" rpy="0 0 0"/> | |
| <axis xyz="0 0 1"/> | |
| <limit lower="-3.14159" upper="3.14159" effort="10" velocity="1"/> | |
| </joint> | |
| <joint name="joint2" type="revolute"> | |
| <parent link="link1"/> | |
| <child link="link2"/> | |
| <origin xyz="1 0 0" rpy="0 0 0"/> | |
| <axis xyz="0 0 1"/> | |
| <limit lower="-3.14159" upper="3.14159" effort="10" velocity="1"/> | |
| </joint> | |
| <joint name="{_DRIVE_JOINT}" type="revolute"> | |
| <parent link="link2"/> | |
| <child link="gripper_drive_link"/> | |
| <origin xyz="0 0 0" rpy="0 0 0"/> | |
| <axis xyz="1 0 0"/> | |
| <limit lower="0.0" upper="0.8" effort="10" velocity="1"/> | |
| </joint> | |
| <joint name="{_MIMIC_POS_JOINT}" type="revolute"> | |
| <parent link="link2"/> | |
| <child link="mimic_pos_link"/> | |
| <origin xyz="0 0 0" rpy="0 0 0"/> | |
| <axis xyz="1 0 0"/> | |
| <limit lower="0.0" upper="0.8" effort="10" velocity="1"/> | |
| <mimic joint="{_DRIVE_JOINT}" multiplier="1" offset="0"/> | |
| </joint> | |
| <joint name="{_MIMIC_NEG_JOINT}" type="revolute"> | |
| <parent link="link2"/> | |
| <child link="mimic_neg_link"/> | |
| <origin xyz="0 0 0" rpy="0 0 0"/> | |
| <axis xyz="1 0 0"/> | |
| <limit lower="-0.8" upper="0.0" effort="10" velocity="1"/> | |
| <mimic joint="{_DRIVE_JOINT}" multiplier="-1" offset="0"/> | |
| </joint> | |
| <joint name="link2_sc_joint" type="fixed"> | |
| <parent link="link2"/> | |
| <child link="link2_sc"/> | |
| <origin xyz="0 0 0" rpy="0 0 0"/> | |
| </joint> | |
| </robot> | |
| """ | |
| def _write_urdf(tmp_path: Path) -> Path: | |
| urdf_path = tmp_path / "synthetic.urdf" | |
| urdf_path.write_text(_URDF_TEMPLATE) | |
| (tmp_path / "tri.stl").write_text(_TRIANGLE_STL) | |
| return urdf_path | |
| def _make_model(tmp_path: Path, **kwargs) -> RobotModel: | |
| urdf_path = _write_urdf(tmp_path) | |
| kwargs.setdefault("arm_joints", ["joint1", "joint2"]) | |
| kwargs.setdefault("gripper_drive_joint", _DRIVE_JOINT) | |
| kwargs.setdefault("base_link", "base_link") | |
| return RobotModel(urdf_path, **kwargs) | |
| class TestForwardKinematics: | |
| def test_link_pose_matches_hand_computed_rotation(self, tmp_path: Path): | |
| model = _make_model(tmp_path) | |
| # joint1 (base_link -> link1) rotates pi/2 about Z; joint2 (link1 -> link2) | |
| # is a fixed-at-zero offset of (1, 0, 0). A point at (1, 0, 0) rotated 90 | |
| # degrees CCW about Z lands at (0, 1, 0) -- link2's origin in base_link. | |
| poses = model.link_poses(np.array([math.pi / 2, 0.0]), gripper=0.0) | |
| assert np.allclose(poses["link2"][:3, 3], [0.0, 1.0, 0.0], atol=1e-6) | |
| # link1's own origin is unaffected by its own joint's rotation. | |
| assert np.allclose(poses["link1"][:3, 3], [0.0, 0.0, 0.0], atol=1e-6) | |
| expected_rot = np.array([[0.0, -1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]]) | |
| assert np.allclose(poses["link2"][:3, :3], expected_rot, atol=1e-6) | |
| def test_link_origins_matches_link_poses(self, tmp_path: Path): | |
| model = _make_model(tmp_path) | |
| q = np.array([0.2, -0.4]) | |
| poses = model.link_poses(q, gripper=0.3) | |
| names, origins = model.link_origins(q, gripper=0.3) | |
| assert names == list(poses) | |
| for i, name in enumerate(names): | |
| assert np.allclose(origins[i], poses[name][:3, 3]) | |
| class TestMimicResolution: | |
| def test_mimic_joints_are_exposed_from_urdf_mimic_tags(self, tmp_path: Path): | |
| model = _make_model(tmp_path) | |
| by_name = {m.name: m for m in model.mimic_joints} | |
| assert set(by_name) == {_MIMIC_POS_JOINT, _MIMIC_NEG_JOINT} | |
| assert by_name[_MIMIC_POS_JOINT] == MimicJoint( | |
| name=_MIMIC_POS_JOINT, source=_DRIVE_JOINT, multiplier=1.0, offset=0.0 | |
| ) | |
| assert by_name[_MIMIC_NEG_JOINT] == MimicJoint( | |
| name=_MIMIC_NEG_JOINT, source=_DRIVE_JOINT, multiplier=-1.0, offset=0.0 | |
| ) | |
| def test_driving_joint_value_propagates_with_correct_sign(self, tmp_path: Path): | |
| model = _make_model(tmp_path) | |
| config = model.joint_config(np.array([0.0, 0.0]), gripper=0.5) | |
| lower, upper = model.gripper_limits | |
| q_drive = lower + 0.5 * (upper - lower) | |
| assert config[_DRIVE_JOINT] == pytest.approx(q_drive) | |
| assert config[_MIMIC_POS_JOINT] == pytest.approx(q_drive) | |
| assert config[_MIMIC_NEG_JOINT] == pytest.approx(-q_drive) | |
| class TestGripperMapping: | |
| def test_gripper_zero_is_lower_limit(self, tmp_path: Path): | |
| model = _make_model(tmp_path) | |
| lower, _ = model.gripper_limits | |
| config = model.joint_config(np.array([0.0, 0.0]), gripper=0.0) | |
| assert config[_DRIVE_JOINT] == pytest.approx(lower) | |
| def test_gripper_one_is_upper_limit(self, tmp_path: Path): | |
| model = _make_model(tmp_path) | |
| _, upper = model.gripper_limits | |
| config = model.joint_config(np.array([0.0, 0.0]), gripper=1.0) | |
| assert config[_DRIVE_JOINT] == pytest.approx(upper) | |
| def test_gripper_half_is_midpoint(self, tmp_path: Path): | |
| model = _make_model(tmp_path) | |
| lower, upper = model.gripper_limits | |
| config = model.joint_config(np.array([0.0, 0.0]), gripper=0.5) | |
| assert config[_DRIVE_JOINT] == pytest.approx((lower + upper) / 2) | |
| def test_out_of_range_gripper_is_clipped(self, tmp_path: Path, gripper, expected_frac): | |
| model = _make_model(tmp_path) | |
| lower, upper = model.gripper_limits | |
| config = model.joint_config(np.array([0.0, 0.0]), gripper=gripper) | |
| assert config[_DRIVE_JOINT] == pytest.approx(lower + expected_frac * (upper - lower)) | |
| class TestValidation: | |
| def test_wrong_length_joint_positions_raises(self, tmp_path: Path): | |
| model = _make_model(tmp_path) | |
| with pytest.raises(ValueError, match="length"): | |
| model.joint_config(np.array([0.1, 0.2, 0.3]), gripper=0.0) | |
| def test_unknown_arm_joint_raises_with_available_list(self, tmp_path: Path): | |
| urdf_path = _write_urdf(tmp_path) | |
| with pytest.raises(ValueError, match="nonexistent_joint") as excinfo: | |
| RobotModel( | |
| urdf_path, | |
| base_link="base_link", | |
| arm_joints=["joint1", "nonexistent_joint"], | |
| gripper_drive_joint=_DRIVE_JOINT, | |
| ) | |
| # the error should help debugging, not just say "not found" | |
| assert "joint1" in str(excinfo.value) or _DRIVE_JOINT in str(excinfo.value) | |
| def test_unknown_gripper_drive_joint_raises(self, tmp_path: Path): | |
| urdf_path = _write_urdf(tmp_path) | |
| with pytest.raises(ValueError, match="not_a_real_joint"): | |
| RobotModel( | |
| urdf_path, | |
| base_link="base_link", | |
| arm_joints=["joint1", "joint2"], | |
| gripper_drive_joint="not_a_real_joint", | |
| ) | |
| def test_unknown_base_link_raises(self, tmp_path: Path): | |
| urdf_path = _write_urdf(tmp_path) | |
| with pytest.raises(ValueError, match="not_a_real_link"): | |
| RobotModel( | |
| urdf_path, | |
| base_link="not_a_real_link", | |
| arm_joints=["joint1", "joint2"], | |
| gripper_drive_joint=_DRIVE_JOINT, | |
| ) | |
| class TestPublicAccessors: | |
| """Accessors added so callers (e.g. fpgm.robot.kinematics/ik) never need to | |
| reach into RobotModel._urdf directly. | |
| """ | |
| def test_arm_joints_and_base_link(self, tmp_path: Path): | |
| model = _make_model(tmp_path) | |
| assert model.arm_joints == ("joint1", "joint2") | |
| assert model.base_link == "base_link" | |
| def test_arm_joint_limits_matches_urdf(self, tmp_path: Path): | |
| model = _make_model(tmp_path) | |
| limits = model.arm_joint_limits() | |
| assert limits.shape == (2, 2) | |
| assert np.allclose(limits, [[-3.14159, 3.14159], [-3.14159, 3.14159]], atol=1e-4) | |
| def test_arm_joint_velocity_limits_matches_urdf(self, tmp_path: Path): | |
| model = _make_model(tmp_path) | |
| vels = model.arm_joint_velocity_limits() | |
| assert np.allclose(vels, [1.0, 1.0]) | |
| def test_gripper_drive_velocity_limit(self, tmp_path: Path): | |
| model = _make_model(tmp_path) | |
| assert model.gripper_drive_velocity_limit == pytest.approx(1.0) | |
| def test_joint_origin_matches_urdf_xyz(self, tmp_path: Path): | |
| model = _make_model(tmp_path) | |
| origin = model.joint_origin("joint2") | |
| assert np.allclose(origin[:3, 3], [1.0, 0.0, 0.0], atol=1e-9) | |
| assert np.allclose(origin[:3, :3], np.eye(3), atol=1e-9) | |
| def test_joint_axis_matches_urdf(self, tmp_path: Path): | |
| model = _make_model(tmp_path) | |
| assert np.allclose(model.joint_axis("joint1"), [0.0, 0.0, 1.0]) | |
| assert np.allclose(model.joint_axis(_DRIVE_JOINT), [1.0, 0.0, 0.0]) | |
| def test_joint_type(self, tmp_path: Path): | |
| model = _make_model(tmp_path) | |
| assert model.joint_type("joint1") == "revolute" | |
| class TestIntrospection: | |
| def test_link_and_joint_name_listings(self, tmp_path: Path): | |
| model = _make_model(tmp_path) | |
| assert set(model.link_names) == { | |
| "base_link", | |
| "link1", | |
| "link2", | |
| "gripper_drive_link", | |
| "mimic_pos_link", | |
| "mimic_neg_link", | |
| "link2_sc", | |
| } | |
| assert set(model.actuated_joint_names) == {"joint1", "joint2", _DRIVE_JOINT} | |
| class TestVisualMeshes: | |
| def test_mesh_visual_gets_origin_and_scale_applied(self, tmp_path: Path): | |
| model = _make_model(tmp_path) | |
| meshes = model.visual_meshes() | |
| assert "link2" in meshes | |
| (mesh, transform), = meshes["link2"] | |
| # <mesh scale="2 2 2"/> on a unit-triangle-in-[0,1] should double its extent. | |
| assert np.allclose(mesh.vertices.max(axis=0)[:2], [2.0, 2.0], atol=1e-6) | |
| # <visual><origin xyz="0.5 0 0"/> should show up as the node's translation. | |
| assert np.allclose(transform[:3, 3], [0.5, 0.0, 0.0], atol=1e-6) | |
| assert np.allclose(transform[:3, :3], np.eye(3), atol=1e-6) | |
| def test_box_visual_is_returned_without_a_mesh_file(self, tmp_path: Path): | |
| model = _make_model(tmp_path) | |
| meshes = model.visual_meshes() | |
| assert "link1" in meshes | |
| (mesh, transform), = meshes["link1"] | |
| assert mesh.vertices.shape[0] > 0 | |
| assert np.allclose(transform[:3, 3], [0.0, 0.0, 0.1], atol=1e-6) | |
| def test_links_without_visuals_are_absent(self, tmp_path: Path): | |
| model = _make_model(tmp_path) | |
| meshes = model.visual_meshes() | |
| assert "base_link" not in meshes | |
| assert "gripper_drive_link" not in meshes | |
| def test_collision_only_link_contributes_no_meshes(self, tmp_path: Path): | |
| # Mirrors the real PointWorld URDF's `*_sc` self-collision proxy links: a | |
| # link that has <collision> geometry but no <visual> at all must not show up | |
| # in visual_meshes(), since rendering it would draw collision capsules over | |
| # the real arm mesh. | |
| model = _make_model(tmp_path) | |
| meshes = model.visual_meshes() | |
| assert "link2_sc" not in meshes | |
| def test_load_meshes_false_skips_external_mesh_files_but_keeps_primitives( | |
| self, tmp_path: Path | |
| ): | |
| # <box>/<cylinder>/<sphere> are generated procedurally and don't need disk | |
| # I/O, so they still show up; only the external tri.stl visual is skipped. | |
| model = _make_model(tmp_path, load_meshes=False) | |
| meshes = model.visual_meshes() | |
| assert "link1" in meshes | |
| assert "link2" not in meshes | |
Xet Storage Details
- Size:
- 13.2 kB
- Xet hash:
- d9b2197c69879f39ce3b36d7da708f9e1b7adab28b3f23578b32c07a1cbb8e75
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.