feat(lekiwi): release V0.10.1 初步集成 LeKiwi,优化碰撞模型
集成通用机器人数值接口、本机控制桥、LeRobot 插件和统一键盘遥操作。采用离线 CoACD 全臂碰撞配方 revision 4、局部装配区切分与结构自接触,限制直接关节位姿写入并保留安全看门狗。同步版本号、变更记录、来源许可证和兼容性验证。
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
"""Keyboard composition uses the real LeRobot plugin; only the transport is fake."""
|
||||
|
||||
import math
|
||||
import runpy
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from lerobot_robot_mujoco import LeKiwiSim, LeKiwiSimConfig
|
||||
from lerobot_robot_mujoco.lekiwi_sim import ARM, BASE, PROFILE
|
||||
from mujoco_control_bridge import RobotError
|
||||
from test_plugin import FakeClient
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[3] / "examples/lekiwi/teleoperate_sim.py"
|
||||
DEMO = runpy.run_path(str(SCRIPT))
|
||||
KeyboardController = DEMO["KeyboardController"]
|
||||
ARM_KEYS = DEMO["ARM_KEYS"]
|
||||
|
||||
|
||||
class KeyboardTeleopTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
transport = patch("lerobot_robot_mujoco.lekiwi_sim.SimRobotClient", FakeClient)
|
||||
transport.start()
|
||||
self.addCleanup(transport.stop)
|
||||
self.robot = LeKiwiSim(LeKiwiSimConfig())
|
||||
self.robot.connect()
|
||||
self.addCleanup(self.robot.disconnect)
|
||||
self.controller = KeyboardController(self.robot)
|
||||
|
||||
def test_every_arm_key_moves_only_its_joint_in_degrees_or_percent(self):
|
||||
self.assertEqual(set(ARM_KEYS), set(ARM))
|
||||
all_keys = [key for pair in ARM_KEYS.values() for key in pair]
|
||||
self.assertEqual(len(set(all_keys)), 12)
|
||||
self.assertFalse(set(all_keys) & set(self.robot.teleop_keys.values()))
|
||||
for channel, pair in ARM_KEYS.items():
|
||||
for key, direction in zip(pair, (1, -1), strict=True):
|
||||
with self.subTest(channel=channel, key=key):
|
||||
self.robot.disconnect()
|
||||
self.robot.connect()
|
||||
controller = KeyboardController(self.robot, arm_speed=12, gripper_speed=30)
|
||||
initial = dict(controller.targets)
|
||||
result = controller.step([key], 0)
|
||||
for other in ARM:
|
||||
delta = direction * (1 if channel == "arm_gripper.pos" else 0.4)
|
||||
self.assertAlmostEqual(
|
||||
result[other], initial[other] + (delta if other == channel else 0)
|
||||
)
|
||||
self.assertEqual({key: result[key] for key in BASE}, dict.fromkeys(BASE, 0))
|
||||
|
||||
def test_compose_all_six_arm_targets_and_base_in_one_action(self):
|
||||
initial = dict(self.controller.targets)
|
||||
result = self.controller.step("wauiotyv", 0)
|
||||
self.assertEqual(result["x.vel"], 0.1)
|
||||
self.assertEqual(result["y.vel"], 0.1)
|
||||
self.assertEqual(result["theta.vel"], 0)
|
||||
for channel in ARM:
|
||||
speed = 50 if channel == "arm_gripper.pos" else 20
|
||||
self.assertAlmostEqual(result[channel], initial[channel] + speed / 30)
|
||||
self.assertEqual(len(self.robot._client.sent), 1)
|
||||
self.assertEqual(len(self.robot._client.sent[0]), 9)
|
||||
|
||||
def test_opposing_keys_cancel_without_resetting_arm(self):
|
||||
initial = dict(self.controller.targets)
|
||||
keys = [key for pair in ARM_KEYS.values() for key in pair] + list("wsadzx")
|
||||
result = self.controller.step(keys, 0)
|
||||
for channel in ARM:
|
||||
self.assertEqual(result[channel], initial[channel])
|
||||
self.assertEqual({key: result[key] for key in BASE}, dict.fromkeys(BASE, 0))
|
||||
|
||||
def test_expired_pulses_stop_base_and_hold_confirmed_not_measured_arm(self):
|
||||
first = self.controller.step("wu", 0)
|
||||
second = self.controller.step([], 0.1)
|
||||
self.assertGreater(second[ARM[0]], first[ARM[0]])
|
||||
expired = self.controller.step([], 0.19)
|
||||
self.assertEqual(expired["x.vel"], 0)
|
||||
for channel in ARM:
|
||||
self.assertEqual(expired[channel], second[channel])
|
||||
self.assertNotEqual(expired[ARM[0]], self.robot.get_observation()[ARM[0]])
|
||||
self.assertEqual(self.controller.step([], 100), expired) # No catch-up jump.
|
||||
self.assertFalse(self.controller.active)
|
||||
|
||||
def test_space_clears_motion_and_holds_pose_even_with_other_keys_in_batch(self):
|
||||
first = self.controller.step("wuv", 0)
|
||||
stopped = self.controller.step(" wur", 0.05)
|
||||
self.assertEqual(self.robot.speed_index, 0)
|
||||
for channel in ARM:
|
||||
self.assertEqual(stopped[channel], first[channel])
|
||||
self.assertEqual(stopped["x.vel"], 0)
|
||||
self.assertEqual(self.controller.step([], 0.1), stopped)
|
||||
restarted = self.controller.step("u", 0.12)
|
||||
self.assertGreater(restarted[ARM[0]], stopped[ARM[0]])
|
||||
|
||||
def test_confirmed_clipping_prevents_joint_and_gripper_target_windup(self):
|
||||
for i in range(300):
|
||||
limited = self.controller.step("uv", i / 30)
|
||||
self.assertAlmostEqual(limited[ARM[0]], math.degrees(PROFILE["arm"][0]["max"]))
|
||||
self.assertEqual(limited["arm_gripper.pos"], 100)
|
||||
self.controller.step(" ", 10)
|
||||
reversed_action = self.controller.step("jb", 10.01)
|
||||
self.assertAlmostEqual(reversed_action[ARM[0]], limited[ARM[0]] - 20 / 30)
|
||||
self.assertAlmostEqual(reversed_action["arm_gripper.pos"], 100 - 50 / 30)
|
||||
for i in range(600):
|
||||
limited = self.controller.step("jb", 11 + i / 30)
|
||||
self.assertAlmostEqual(limited[ARM[0]], math.degrees(PROFILE["arm"][0]["min"]))
|
||||
self.assertEqual(limited["arm_gripper.pos"], 0)
|
||||
|
||||
def test_base_speed_changes_once_per_input_event_not_each_pulse_tick(self):
|
||||
self.controller.step("r", 0)
|
||||
for now in (0.03, 0.06, 0.1, 0.17):
|
||||
self.controller.step([], now)
|
||||
self.assertEqual(self.robot.speed_index, 1)
|
||||
self.controller.step("f", 0.2)
|
||||
self.assertEqual(self.robot.speed_index, 0)
|
||||
|
||||
def test_failed_send_does_not_commit_unconfirmed_targets(self):
|
||||
before = dict(self.controller.targets)
|
||||
with (
|
||||
patch.object(self.robot, "send_action", side_effect=RobotError("TIMEOUT", "test")),
|
||||
self.assertRaises(RobotError),
|
||||
):
|
||||
self.controller.step("u", 0)
|
||||
self.assertEqual(self.controller.targets, before)
|
||||
|
||||
def test_main_always_releases_lease_and_restores_terminal(self):
|
||||
self.robot.disconnect()
|
||||
main = DEMO["main"]
|
||||
for outcome in (["q"], KeyboardInterrupt(), EOFError("test"), RobotError("STALE", "test")):
|
||||
with self.subTest(outcome=outcome):
|
||||
reader = Mock()
|
||||
if isinstance(outcome, BaseException):
|
||||
reader.side_effect = outcome
|
||||
else:
|
||||
reader.return_value = outcome
|
||||
with (
|
||||
patch.dict(main.__globals__, {"read_keys": reader}),
|
||||
patch.dict(
|
||||
sys.modules,
|
||||
{"demo_control": SimpleNamespace(make_robot=lambda _: self.robot)},
|
||||
),
|
||||
patch.object(sys, "argv", [str(SCRIPT)]),
|
||||
patch.object(sys, "stdin") as stdin,
|
||||
patch("termios.tcgetattr", return_value=["saved"]),
|
||||
patch("termios.tcsetattr") as restore,
|
||||
patch("tty.setcbreak"),
|
||||
patch("builtins.print"),
|
||||
):
|
||||
stdin.isatty.return_value = True
|
||||
stdin.fileno.return_value = 123
|
||||
if isinstance(outcome, Exception):
|
||||
with self.assertRaises(type(outcome)):
|
||||
main()
|
||||
else:
|
||||
main()
|
||||
self.assertFalse(self.robot.is_connected)
|
||||
self.assertIsNone(self.robot._client)
|
||||
restore.assert_called_once_with(123, DEMO["termios"].TCSADRAIN, ["saved"])
|
||||
|
||||
def test_input_drain_is_bounded_and_eof_is_not_an_infinite_loop(self):
|
||||
with patch("select.select", return_value=([123], [], [])):
|
||||
with patch("os.read", return_value=b"u") as read:
|
||||
self.assertEqual(DEMO["read_keys"](123), ["u"] * 64)
|
||||
self.assertEqual(read.call_count, 64)
|
||||
with patch("os.read", return_value=b""), self.assertRaises(EOFError):
|
||||
DEMO["read_keys"](123)
|
||||
|
||||
def test_invalid_speeds_rejected_before_loading_robot_or_connecting(self):
|
||||
for flag, value in (
|
||||
("--arm-speed", "nan"),
|
||||
("--arm-speed", "91"),
|
||||
("--gripper-speed", "inf"),
|
||||
("--gripper-speed", "0"),
|
||||
):
|
||||
with self.subTest(flag=flag, value=value):
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(SCRIPT), flag, value],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
self.assertEqual(result.returncode, 2)
|
||||
self.assertIn(f"{flag} 必须", result.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,201 @@
|
||||
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()
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Run the installed upstream's pure methods, without constructing a hardware robot."""
|
||||
|
||||
import ast
|
||||
import importlib.metadata
|
||||
import json
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
class UpstreamKinematicsTests(unittest.TestCase):
|
||||
def test_actual_061_methods_match_shared_wheel_fixture(self):
|
||||
self.assertEqual(importlib.metadata.version("lerobot"), "0.6.1")
|
||||
path = importlib.metadata.distribution("lerobot").locate_file(
|
||||
"lerobot/robots/lekiwi/lekiwi.py"
|
||||
)
|
||||
tree = ast.parse(path.read_text())
|
||||
robot = next(
|
||||
node for node in tree.body if isinstance(node, ast.ClassDef) and node.name == "LeKiwi"
|
||||
)
|
||||
names = {"_degps_to_raw", "_raw_to_degps", "_body_to_wheel_raw", "_wheel_raw_to_body"}
|
||||
methods = [
|
||||
node for node in robot.body if isinstance(node, ast.FunctionDef) and node.name in names
|
||||
]
|
||||
self.assertEqual({node.name for node in methods}, names)
|
||||
# Only the four audited numeric methods. No imports, constructors, buses or cameras.
|
||||
cls = ast.ClassDef(
|
||||
name="PureKinematics",
|
||||
bases=[],
|
||||
keywords=[],
|
||||
body=methods,
|
||||
decorator_list=[],
|
||||
type_params=[],
|
||||
)
|
||||
namespace = {"np": np, "Any": Any}
|
||||
module = ast.fix_missing_locations(ast.Module(body=[cls], type_ignores=[]))
|
||||
exec(compile(module, str(path), "exec"), namespace)
|
||||
pure = namespace["PureKinematics"]()
|
||||
fixture = json.loads(
|
||||
(
|
||||
Path(__file__).resolve().parents[3] / "contracts/fixtures/lekiwi-kinematics.json"
|
||||
).read_text()
|
||||
)
|
||||
for case in fixture["cases"]:
|
||||
with self.subTest(body=case["body"]):
|
||||
raw = pure._body_to_wheel_raw(*case["body"])
|
||||
self.assertEqual(list(raw), fixture["wheelOrder"])
|
||||
self.assertEqual(list(raw.values()), case["wheelRaw"])
|
||||
if max(abs(v) for v in case["wheelRaw"]) < 3000:
|
||||
body = pure._wheel_raw_to_body(*case["wheelRaw"])
|
||||
for observed, expected in zip(body.values(), case["body"], strict=True):
|
||||
self.assertAlmostEqual(observed, expected, delta=0.02)
|
||||
Reference in New Issue
Block a user