feat(lekiwi): release V0.10.1 初步集成 LeKiwi,优化碰撞模型
集成通用机器人数值接口、本机控制桥、LeRobot 插件和统一键盘遥操作。采用离线 CoACD 全臂碰撞配方 revision 4、局部装配区切分与结构自接触,限制直接关节位姿写入并保留安全看门狗。同步版本号、变更记录、来源许可证和兼容性验证。
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,13 @@
|
||||
# 归属与兼容范围
|
||||
|
||||
本包是独立仿真适配器,不是上游 LeRobot 的 fork 或硬件 `LeKiwiClient`/ZMQ 服务器。
|
||||
|
||||
- LeRobot 0.6.1:[huggingface/lerobot](https://github.com/huggingface/lerobot),Apache-2.0。
|
||||
LeKiwi 运动学、键盘辅助和循环约定参考其 `lerobot/robots/lekiwi/lekiwi_client.py`。
|
||||
保留上游声明:Copyright 2024 The HuggingFace Inc. team. All rights reserved.
|
||||
- LeKiwi:[SIGRobotics-UIUC/LeKiwi](https://github.com/SIGRobotics-UIUC/LeKiwi),Apache-2.0,固定 revision `efa608d7ee5a495a4803b1d28cd0c955b4f1e033`。
|
||||
模型资源不放入本 wheel;显式准备资源时复制其 `LICENSE.txt` 和 `CITATION.cff`。
|
||||
- 本项目修改:移除硬件 I/O/校准持久化;增加认证本机仿真传输、SI/上游单位映射、部分动作/确认目标语义以及 profile 校验。
|
||||
简化的质量、惯量、轮几何、碰撞、限位和伺服是仿真估计,不是硬件标定。
|
||||
|
||||
许可证正文见 `LICENSE`。本包不授予额外的商标或硬件认证权利。
|
||||
@@ -0,0 +1,35 @@
|
||||
# LeRobot 仿真插件
|
||||
|
||||
真实 LeRobot **0.6.1** 的 `Robot` 子类与 `RobotConfig`,注册名 `lekiwi_sim`,发行包名 **`lerobot_robot_mujoco`**。上游按发行包名前缀发现插件,因此不能随意改成连字符名称或只提供自定义 entry point。
|
||||
|
||||
```bash
|
||||
source .venv/bin/activate # Python 3.12;只用它创建隔离环境
|
||||
python examples/lekiwi/setup_lerobot.py
|
||||
# 不要在原训练环境直接 pip install lerobot
|
||||
build/venvs/lerobot/bin/python -m unittest discover -s integrations/lerobot/tests -v
|
||||
```
|
||||
|
||||
安装脚本仅修改 `build/venvs/lerobot/`,先装官方 CPU torch/torchvision,再装 [固定兼容集](requirements-cpu.txt),最后 editable 安装桥接和本插件并执行 `pip check`。版本记录在 `build/lerobot-environment.json`;不会改上游或原 `.venv` 的训练依赖。
|
||||
|
||||
先按 [示例](../../examples/lekiwi/README.md) 准备模型、连接桥接并在浏览器授权。相同 token 放在 Python 的 `MUJOCO_CONTROL_TOKEN` 环境变量中:
|
||||
|
||||
```python
|
||||
from lerobot.robots.config import RobotConfig
|
||||
from lerobot.robots.utils import make_robot_from_config
|
||||
from lerobot.utils.import_utils import register_third_party_plugins
|
||||
|
||||
register_third_party_plugins()
|
||||
config_type = RobotConfig.get_choice_class('lekiwi_sim')
|
||||
robot = make_robot_from_config(config_type(endpoint='http://127.0.0.1:8766', id='sim-demo'))
|
||||
robot.connect()
|
||||
try:
|
||||
measured = robot.get_observation()
|
||||
accepted = robot.send_action({'x.vel': 0.05})
|
||||
diagnostic = robot.get_sim_observation()
|
||||
finally:
|
||||
robot.disconnect()
|
||||
```
|
||||
|
||||
这是单步 API 演示;实际控制须持续刷新(示例为 30 Hz),不能在两条动作之间阻塞超过 500 ms。`send_action` 返回确认目标,`get_observation` 才是实测反馈。五个臂通道为度,夹爪 0–100,底盘平移 m/s、转动 deg/s。部分动作保持最后确认的臂/夹爪目标,省略的底盘速度为零。
|
||||
|
||||
仅接受 `use_degrees=True`、`cameras={}`;拒绝物理校准目录和校准文件 I/O。`is_calibrated=True` 表示固定仿真 profile 可用,不代表硬件标定。提供 `stop_base`、键盘辅助函数和完整 SI 诊断,但没有实体 leader、串口、ZMQ、相机、dataset 或训练实现。协议/来源/升级流程见 [机器人接口](../../docs/robot-interface.md),许可证和上游归属见 [NOTICE](NOTICE.md)。
|
||||
@@ -0,0 +1,19 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=77,<82"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
# Upstream 0.6.1 discovers the literal distribution Name prefix, not entry points.
|
||||
name = "lerobot_robot_mujoco"
|
||||
version = "0.1.0"
|
||||
description = "Simulation-only LeKiwi adapter for LeRobot 0.6.1 and MuJoCo Web"
|
||||
requires-python = ">=3.12"
|
||||
license = "Apache-2.0"
|
||||
license-files = ["LICENSE", "NOTICE.md"]
|
||||
dependencies = ["lerobot==0.6.1", "mujoco-control-bridge==0.1.0"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
lerobot_robot_mujoco = ["lekiwi-v1.json"]
|
||||
@@ -0,0 +1,52 @@
|
||||
# Ubuntu 24.04 x86_64 / Python 3.12;固定兼容集,不含原训练环境。
|
||||
# 先从官方 CPU 索引安装 torch/torchvision,再安装本文件;见 setup_lerobot.py。
|
||||
Farama-Notifications==0.0.6
|
||||
Jinja2==3.1.6
|
||||
MarkupSafe==3.0.3
|
||||
PyYAML==6.0.3
|
||||
aiohappyeyeballs==2.7.1
|
||||
aiohttp==3.14.3
|
||||
aiosignal==1.4.0
|
||||
anyio==4.15.1
|
||||
attrs==26.1.0
|
||||
certifi==2026.7.22
|
||||
charset-normalizer==3.5.1
|
||||
click==8.5.0
|
||||
cloudpickle==3.1.2
|
||||
cmake==4.1.3
|
||||
draccus==0.11.6
|
||||
einops==0.8.2
|
||||
filelock==3.32.3
|
||||
frozenlist==1.8.0
|
||||
fsspec==2026.7.0
|
||||
gymnasium==1.3.0
|
||||
h11==0.16.0
|
||||
hf-xet==1.6.0
|
||||
httpcore==1.0.9
|
||||
httpx==0.28.1
|
||||
huggingface_hub==1.32.0
|
||||
idna==3.20
|
||||
lerobot==0.6.1
|
||||
mergedeep==1.3.4
|
||||
mpmath==1.3.0
|
||||
multidict==6.8.0
|
||||
mypy_extensions==1.1.0
|
||||
networkx==3.6.1
|
||||
numpy==2.2.6
|
||||
opencv-python-headless==4.13.0.92
|
||||
packaging==25.0
|
||||
pillow==12.3.0
|
||||
propcache==0.5.4
|
||||
requests==2.34.2
|
||||
safetensors==0.8.0
|
||||
setuptools==78.1.0
|
||||
sympy==1.14.0
|
||||
termcolor==3.3.0
|
||||
toml==0.10.2
|
||||
torch==2.11.0+cpu
|
||||
torchvision==0.26.0+cpu
|
||||
tqdm==4.70.1
|
||||
typing-inspect==0.9.0
|
||||
typing_extensions==4.16.0
|
||||
urllib3==2.8.0
|
||||
yarl==1.25.1
|
||||
@@ -0,0 +1,4 @@
|
||||
from .config_lekiwi_sim import LeKiwiSimConfig
|
||||
from .lekiwi_sim import LeKiwiSim
|
||||
|
||||
__all__ = ["LeKiwiSim", "LeKiwiSimConfig"]
|
||||
@@ -0,0 +1,48 @@
|
||||
from dataclasses import dataclass, field
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from lerobot.robots.config import RobotConfig
|
||||
|
||||
|
||||
@RobotConfig.register_subclass("lekiwi_sim")
|
||||
@dataclass
|
||||
class LeKiwiSimConfig(RobotConfig):
|
||||
endpoint: str = "http://127.0.0.1:8766"
|
||||
# Prefer MUJOCO_CONTROL_TOKEN. Never include this value in repr/diagnostics.
|
||||
token: str | None = field(default=None, repr=False)
|
||||
use_degrees: bool = True
|
||||
cameras: dict = field(default_factory=dict)
|
||||
expected_model_fingerprint: str | None = None
|
||||
teleop_keys: dict[str, str] = field(
|
||||
default_factory=lambda: {
|
||||
"forward": "w",
|
||||
"backward": "s",
|
||||
"left": "a",
|
||||
"right": "d",
|
||||
"rotate_left": "z",
|
||||
"rotate_right": "x",
|
||||
"speed_up": "r",
|
||||
"speed_down": "f",
|
||||
"quit": "q",
|
||||
}
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
if self.use_degrees is not True:
|
||||
raise ValueError("V1 仅支持 use_degrees=True,不支持归一化臂位置")
|
||||
if self.cameras:
|
||||
raise ValueError("V1 不支持相机;请使用 cameras={}")
|
||||
if self.calibration_dir is not None:
|
||||
raise ValueError("模拟器使用版本化 profile,不接受实体校准目录")
|
||||
url = urlparse(self.endpoint)
|
||||
if (
|
||||
url.scheme != "http"
|
||||
or url.hostname not in {"127.0.0.1", "localhost"}
|
||||
or url.username
|
||||
or url.password
|
||||
or url.query
|
||||
or url.fragment
|
||||
or url.path not in {"", "/"}
|
||||
):
|
||||
raise ValueError("仅支持不含凭据/查询参数的本机 HTTP 桥接地址")
|
||||
super().__post_init__()
|
||||
@@ -0,0 +1 @@
|
||||
../../../../robot_profiles/lekiwi-v1.json
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Real LeRobot Robot implementation, with no hardware bus/camera/ZMQ backend."""
|
||||
|
||||
import json
|
||||
import math
|
||||
from importlib.resources import files
|
||||
from numbers import Real
|
||||
|
||||
from lerobot.robots.robot import Robot
|
||||
from lerobot.utils.decorators import check_if_already_connected, check_if_not_connected
|
||||
from mujoco_control_bridge import RobotError, SimRobotClient
|
||||
|
||||
from .config_lekiwi_sim import LeKiwiSimConfig
|
||||
|
||||
PROFILE = json.loads(files(__package__).joinpath("lekiwi-v1.json").read_text())
|
||||
ARM = tuple(f"{spec['joint']}.pos" for spec in PROFILE["arm"])
|
||||
BASE = ("x.vel", "y.vel", "theta.vel")
|
||||
FEATURES = (*ARM, *BASE)
|
||||
|
||||
|
||||
def scalar(value):
|
||||
"""Accept upstream numpy/torch scalar actions, but never bool/strings/vectors."""
|
||||
if (
|
||||
not isinstance(value, Real)
|
||||
and getattr(value, "ndim", None) == 0
|
||||
and callable(getattr(value, "item", None))
|
||||
):
|
||||
value = value.item()
|
||||
if isinstance(value, bool) or not isinstance(value, Real):
|
||||
raise RobotError("INVALID_MESSAGE", "动作必须是有限标量,不能是 bool/字符串/数组")
|
||||
value = float(value)
|
||||
if not math.isfinite(value):
|
||||
raise RobotError("INVALID_MESSAGE", "动作不允许 NaN/Infinity")
|
||||
return value
|
||||
|
||||
|
||||
def to_sim(key, value):
|
||||
value = scalar(value)
|
||||
if key == "arm_gripper.pos":
|
||||
return value / 100
|
||||
if key in ARM:
|
||||
i = ARM.index(key)
|
||||
return (
|
||||
PROFILE["mapping"]["armSigns"][i] * math.radians(value)
|
||||
+ PROFILE["mapping"]["armOffsetsRad"][i]
|
||||
)
|
||||
return math.radians(value) if key == "theta.vel" else value
|
||||
|
||||
|
||||
def from_sim(key, value):
|
||||
if key == "arm_gripper.pos":
|
||||
return value * 100
|
||||
if key in ARM:
|
||||
i = ARM.index(key)
|
||||
return math.degrees(
|
||||
(value - PROFILE["mapping"]["armOffsetsRad"][i]) / PROFILE["mapping"]["armSigns"][i]
|
||||
)
|
||||
return math.degrees(value) if key == "theta.vel" else value
|
||||
|
||||
|
||||
class LeKiwiSim(Robot):
|
||||
config_class = LeKiwiSimConfig
|
||||
name = "lekiwi_sim"
|
||||
|
||||
def __init__(self, config: LeKiwiSimConfig):
|
||||
# Robot.__init__ reads/creates physical calibration files. This simulation
|
||||
# implements the same base metadata, deliberately without those side effects.
|
||||
self.config = config
|
||||
self.id, self.robot_type = config.id, self.name
|
||||
self.calibration = {}
|
||||
self.calibration_dir = self.calibration_fpath = None
|
||||
self.cameras = {}
|
||||
self._client = None
|
||||
self._targets = {}
|
||||
self.teleop_keys = dict(config.teleop_keys)
|
||||
self.speed_levels = [
|
||||
{"xy": 0.1, "theta": 30},
|
||||
{"xy": 0.2, "theta": 60},
|
||||
{"xy": 0.3, "theta": 90},
|
||||
]
|
||||
self.speed_index = 0
|
||||
|
||||
@property
|
||||
def action_features(self):
|
||||
return dict.fromkeys(FEATURES, float)
|
||||
|
||||
@property
|
||||
def observation_features(self):
|
||||
return self.action_features
|
||||
|
||||
@property
|
||||
def is_connected(self):
|
||||
return self._client is not None and self._client.is_connected
|
||||
|
||||
@property
|
||||
def is_calibrated(self):
|
||||
return True # Means the fixed SIMULATION profile is in use, not hardware calibration.
|
||||
|
||||
def calibrate(self):
|
||||
pass
|
||||
|
||||
def configure(self):
|
||||
pass
|
||||
|
||||
def _load_calibration(self, fpath=None):
|
||||
raise RobotError("UNSUPPORTED", "模拟 profile 不读取硬件校准文件")
|
||||
|
||||
def _save_calibration(self, fpath=None):
|
||||
raise RobotError("UNSUPPORTED", "模拟 profile 不生成硬件校准文件")
|
||||
|
||||
def _validate_descriptor(self, descriptor):
|
||||
if (
|
||||
descriptor["profileId"] != PROFILE["id"]
|
||||
or descriptor["profileVersion"] != PROFILE["version"]
|
||||
):
|
||||
raise RobotError("INCOMPATIBLE_MODEL", "需要 lekiwi-v1 profile v1")
|
||||
if (
|
||||
self.config.expected_model_fingerprint
|
||||
and descriptor["modelFingerprint"] != self.config.expected_model_fingerprint
|
||||
):
|
||||
raise RobotError("INCOMPATIBLE_MODEL", "模型指纹不符合固定配置")
|
||||
channels = descriptor["actionChannels"]
|
||||
if [c["id"] for c in channels] != list(FEATURES):
|
||||
raise RobotError("INCOMPATIBLE_MODEL", "LeKiwi 通道集合/顺序不匹配")
|
||||
for channel in channels:
|
||||
key = channel["id"]
|
||||
unit = (
|
||||
"ratio"
|
||||
if key == "arm_gripper.pos"
|
||||
else "rad"
|
||||
if key in ARM
|
||||
else "rad/s"
|
||||
if key == "theta.vel"
|
||||
else "m/s"
|
||||
)
|
||||
mode = (
|
||||
"opening" if key == "arm_gripper.pos" else "position" if key in ARM else "velocity"
|
||||
)
|
||||
if channel["unit"] != unit or channel["mode"] != mode:
|
||||
raise RobotError("INCOMPATIBLE_MODEL", f"LeKiwi 通道单位/模式不匹配:{key}")
|
||||
observations = {c["id"]: c for c in descriptor["observationChannels"]}
|
||||
if any(key not in observations for key in FEATURES) or any(
|
||||
observations[c["id"]]["unit"] != c["unit"] for c in channels
|
||||
):
|
||||
raise RobotError("INCOMPATIBLE_MODEL", "LeKiwi 观测通道/单位不匹配")
|
||||
|
||||
@check_if_already_connected
|
||||
def connect(self, calibrate=True):
|
||||
self.disconnect() # Clear an expired client, without reusing its old lease.
|
||||
client = SimRobotClient(self.config.endpoint, self.config.token)
|
||||
self._validate_descriptor(client.describe())
|
||||
try:
|
||||
self._validate_descriptor(
|
||||
client.connect()
|
||||
) # Recheck after the transactional handshake.
|
||||
measured = client.get_observation()["values"]
|
||||
self._targets = {key: measured[key] for key in ARM}
|
||||
self._client = client
|
||||
except BaseException:
|
||||
client.disconnect()
|
||||
raise
|
||||
|
||||
@check_if_not_connected
|
||||
def get_observation(self):
|
||||
# Base velocities are measured-wheel odometry, matching upstream LeKiwi.
|
||||
# Actual body twist remains available through the generic SDK, not extra ML features.
|
||||
values = self._client.get_observation()["values"]
|
||||
return {key: float(from_sim(key, values[key])) for key in FEATURES}
|
||||
|
||||
@check_if_not_connected
|
||||
def get_sim_observation(self):
|
||||
"""SI diagnostics and epoch metadata, deliberately not LeRobot training features."""
|
||||
return self._client.get_observation()
|
||||
|
||||
@check_if_not_connected
|
||||
def send_action(self, action):
|
||||
if not isinstance(action, dict) or not set(action).issubset(FEATURES):
|
||||
raise RobotError("INVALID_MESSAGE", "动作包含未知通道")
|
||||
values = {**self._targets, **dict.fromkeys(BASE, 0.0)}
|
||||
values.update({key: to_sim(key, value) for key, value in action.items()})
|
||||
accepted = self._client.send_action(values)["values"]
|
||||
# Only confirmed targets become the next partial-action hold state.
|
||||
self._targets = {key: accepted[key] for key in ARM}
|
||||
return {key: float(from_sim(key, accepted[key])) for key in FEATURES}
|
||||
|
||||
@check_if_not_connected
|
||||
def stop_base(self):
|
||||
return self.send_action({})
|
||||
|
||||
def _from_keyboard_to_base_action(self, pressed_keys):
|
||||
"""Same w/s/a/d/z/x and r/f semantics as the upstream 0.6.1 example."""
|
||||
keys = set(pressed_keys)
|
||||
self.speed_index = min(
|
||||
2,
|
||||
max(
|
||||
0,
|
||||
self.speed_index
|
||||
+ int(self.teleop_keys["speed_up"] in keys)
|
||||
- int(self.teleop_keys["speed_down"] in keys),
|
||||
),
|
||||
)
|
||||
speed = self.speed_levels[self.speed_index]
|
||||
pairs = [
|
||||
("x.vel", "forward", "backward", speed["xy"]),
|
||||
("y.vel", "left", "right", speed["xy"]),
|
||||
("theta.vel", "rotate_left", "rotate_right", speed["theta"]),
|
||||
]
|
||||
return {
|
||||
channel: amount
|
||||
* (int(self.teleop_keys[positive] in keys) - int(self.teleop_keys[negative] in keys))
|
||||
for channel, positive, negative, amount in pairs
|
||||
}
|
||||
|
||||
def disconnect(self):
|
||||
if self._client is not None:
|
||||
self._client.disconnect()
|
||||
self._client = None
|
||||
self._targets = {}
|
||||
@@ -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