feat(lekiwi): release V0.10.1 初步集成 LeKiwi,优化碰撞模型
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
lekiwi-compatibility / cpu-compatibility (push) Has been cancelled

集成通用机器人数值接口、本机控制桥、LeRobot 插件和统一键盘遥操作。采用离线 CoACD 全臂碰撞配方 revision 4、局部装配区切分与结构自接触,限制直接关节位姿写入并保留安全看门狗。同步版本号、变更记录、来源许可证和兼容性验证。
This commit is contained in:
2026-09-20 14:42:30 +08:00
parent da4d59c31a
commit 3ad29356c9
103 changed files with 23055 additions and 280 deletions
@@ -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()
+201
View File
@@ -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)