3ad29356c9
集成通用机器人数值接口、本机控制桥、LeRobot 插件和统一键盘遥操作。采用离线 CoACD 全臂碰撞配方 revision 4、局部装配区切分与结构自接触,限制直接关节位姿写入并保留安全看门狗。同步版本号、变更记录、来源许可证和兼容性验证。
202 lines
7.7 KiB
Python
202 lines
7.7 KiB
Python
import copy
|
|
import importlib.metadata
|
|
import json
|
|
import math
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import unittest
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import numpy as np
|
|
import torch
|
|
from lerobot.robots.robot import Robot
|
|
from lerobot.robots.utils import make_robot_from_config
|
|
from lerobot.utils.errors import DeviceNotConnectedError
|
|
from lerobot_robot_mujoco import LeKiwiSim, LeKiwiSimConfig
|
|
from lerobot_robot_mujoco.lekiwi_sim import ARM, BASE, FEATURES, PROFILE, from_sim, to_sim
|
|
from mujoco_control_bridge import RobotError
|
|
from mujoco_control_bridge.protocol import values
|
|
|
|
|
|
def descriptor():
|
|
channels = []
|
|
for i, key in enumerate(FEATURES):
|
|
gripper = key == "arm_gripper.pos"
|
|
base = key in BASE
|
|
limit = PROFILE["baseAngularLimit"] if key == "theta.vel" else PROFILE["baseLinearLimit"]
|
|
channels.append(
|
|
{
|
|
"id": key,
|
|
"unit": "ratio"
|
|
if gripper
|
|
else "rad/s"
|
|
if key == "theta.vel"
|
|
else "m/s"
|
|
if base
|
|
else "rad",
|
|
"mode": "opening" if gripper else "velocity" if base else "position",
|
|
"min": 0 if gripper else -limit if base else PROFILE["arm"][i]["min"],
|
|
"max": 1 if gripper else limit if base else PROFILE["arm"][i]["max"],
|
|
}
|
|
)
|
|
return {
|
|
"protocolVersion": 1,
|
|
"profileId": "lekiwi-v1",
|
|
"profileVersion": 1,
|
|
"modelFingerprint": "a" * 64,
|
|
"frame": "x-forward-y-left-z-up",
|
|
"capabilities": {"reset": True, "lockstep": False, "cameras": False, "training": False},
|
|
"actionChannels": channels,
|
|
"observationChannels": copy.deepcopy(channels),
|
|
}
|
|
|
|
|
|
class FakeClient:
|
|
def __init__(self, *_):
|
|
self.is_connected = False
|
|
self.sent = []
|
|
|
|
def describe(self):
|
|
return descriptor()
|
|
|
|
def connect(self):
|
|
self.is_connected = True
|
|
return self.describe()
|
|
|
|
def get_observation(self):
|
|
return {"values": {**dict.fromkeys(FEATURES, 0.1), "arm_gripper.pos": 0.25}}
|
|
|
|
def send_action(self, value):
|
|
self.sent.append(value)
|
|
return {"values": values(value, self.describe()["actionChannels"], True)}
|
|
|
|
def disconnect(self):
|
|
self.is_connected = False
|
|
|
|
|
|
class LeRobotPluginTests(unittest.TestCase):
|
|
def test_actual_upstream_factory_and_no_hardware_calibration(self):
|
|
self.assertEqual(importlib.metadata.version("lerobot"), "0.6.1")
|
|
with patch.object(Path, "mkdir", side_effect=AssertionError("hardware calibration I/O")):
|
|
robot = make_robot_from_config(LeKiwiSimConfig())
|
|
self.assertIsInstance(robot, Robot)
|
|
self.assertIsInstance(robot, LeKiwiSim)
|
|
self.assertEqual(tuple(robot.action_features), FEATURES)
|
|
self.assertEqual(robot.observation_features, robot.action_features)
|
|
self.assertTrue(robot.is_calibrated)
|
|
self.assertFalse(robot.is_connected)
|
|
self.assertEqual(robot.cameras, {})
|
|
self.assertFalse(torch.cuda.is_initialized())
|
|
with self.assertRaises(DeviceNotConnectedError):
|
|
robot.get_observation()
|
|
|
|
def test_fresh_process_plugin_discovery(self):
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
"-c",
|
|
"""
|
|
import sys
|
|
from lerobot.utils.import_utils import register_third_party_plugins
|
|
from lerobot.robots.config import RobotConfig
|
|
from lerobot.robots.utils import make_robot_from_config
|
|
assert 'lerobot_robot_mujoco' not in sys.modules
|
|
register_third_party_plugins()
|
|
config = RobotConfig.get_choice_class('lekiwi_sim')()
|
|
robot = make_robot_from_config(config)
|
|
assert robot.name == 'lekiwi_sim'
|
|
assert len(robot.action_features) == 9
|
|
assert not any(m in sys.modules for m in ('serial','zmq','pyrealsense2','scservo_sdk'))
|
|
""",
|
|
],
|
|
env={**os.environ, "PYTHONPATH": "", "PYTHONNOUSERSITE": "1"},
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
|
|
def test_profile_resource_matches_canonical(self):
|
|
root = Path(__file__).resolve().parents[3]
|
|
self.assertEqual(PROFILE, json.loads((root / "robot_profiles/lekiwi-v1.json").read_text()))
|
|
|
|
def test_bidirectional_units_and_upstream_scalar_values(self):
|
|
for key in FEATURES:
|
|
for value in (-30.0, 0.0, 30.0):
|
|
self.assertAlmostEqual(from_sim(key, to_sim(key, value)), value)
|
|
self.assertAlmostEqual(to_sim(ARM[0], 90), math.pi / 2)
|
|
self.assertEqual(to_sim("arm_gripper.pos", 50), 0.5)
|
|
self.assertAlmostEqual(to_sim("theta.vel", 30), math.pi / 6)
|
|
self.assertAlmostEqual(to_sim(ARM[0], np.float32(10)), math.radians(10))
|
|
self.assertAlmostEqual(to_sim(ARM[0], torch.tensor(10.0)), math.radians(10))
|
|
for bad in (
|
|
True,
|
|
np.bool_(True),
|
|
"2",
|
|
[1],
|
|
np.array([1]),
|
|
torch.tensor([1.0]),
|
|
float("nan"),
|
|
float("inf"),
|
|
):
|
|
with self.subTest(value=repr(bad)), self.assertRaises(RobotError):
|
|
to_sim(ARM[0], bad)
|
|
|
|
def test_partial_action_holds_confirmed_arm_and_stops_omitted_base(self):
|
|
with patch("lerobot_robot_mujoco.lekiwi_sim.SimRobotClient", FakeClient):
|
|
robot = LeKiwiSim(LeKiwiSimConfig())
|
|
robot.connect()
|
|
first = robot.send_action({ARM[0]: 180, "arm_gripper.pos": 150, "x.vel": 0.1})
|
|
self.assertAlmostEqual(first[ARM[0]], math.degrees(PROFILE["arm"][0]["max"]))
|
|
self.assertEqual(first["arm_gripper.pos"], 100)
|
|
self.assertEqual(first["x.vel"], 0.1)
|
|
second = robot.send_action({ARM[1]: 5})
|
|
self.assertEqual(second[ARM[0]], first[ARM[0]])
|
|
self.assertEqual(second["arm_gripper.pos"], 100)
|
|
self.assertEqual(second["x.vel"], 0)
|
|
self.assertEqual(second["theta.vel"], 0)
|
|
self.assertEqual(robot.get_observation()["arm_gripper.pos"], 25)
|
|
self.assertNotEqual(robot.get_observation()[ARM[0]], first[ARM[0]])
|
|
with self.assertRaises(RobotError):
|
|
robot.send_action({"unknown": 1})
|
|
robot.stop_base()
|
|
robot.disconnect()
|
|
robot.disconnect()
|
|
|
|
def test_reject_unsupported_configuration_and_descriptor(self):
|
|
for kwargs in (
|
|
{"use_degrees": False},
|
|
{"cameras": {"camera": object()}},
|
|
{"calibration_dir": Path("/tmp/never-touch")},
|
|
{"endpoint": "http://token@localhost"},
|
|
):
|
|
with self.assertRaises(ValueError):
|
|
LeKiwiSimConfig(**kwargs)
|
|
config = LeKiwiSimConfig(token="secret-not-real")
|
|
self.assertNotIn("secret-not-real", repr(config))
|
|
robot = LeKiwiSim(config)
|
|
for field in ("profileId", "profileVersion", "unit", "mode"):
|
|
desc = descriptor()
|
|
if field == "profileVersion":
|
|
desc[field] = 2
|
|
elif field == "profileId":
|
|
desc[field] = "other"
|
|
else:
|
|
desc["actionChannels"][0][field] = "wrong"
|
|
with self.assertRaises(RobotError):
|
|
robot._validate_descriptor(desc)
|
|
|
|
def test_keyboard_helpers_match_upstream_key_semantics(self):
|
|
robot = LeKiwiSim(LeKiwiSimConfig())
|
|
self.assertEqual(
|
|
robot._from_keyboard_to_base_action(["w", "a", "z"]),
|
|
{"x.vel": 0.1, "y.vel": 0.1, "theta.vel": 30},
|
|
)
|
|
self.assertEqual(robot._from_keyboard_to_base_action(["w", "s", "r"])["x.vel"], 0)
|
|
self.assertEqual(robot._from_keyboard_to_base_action(["d"])["y.vel"], -0.2)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|