diff --git a/.gitignore b/.gitignore
index 29a3009..ec9d871 100644
--- a/.gitignore
+++ b/.gitignore
@@ -36,6 +36,7 @@ checkpoints/
wandb/
sim2sim_log_*.txt
**/sim2sim_temp.xml
+**/route_check_runs/
# IDE and operating system files
.idea/
diff --git a/01_doc/version_history.md b/01_doc/version_history.md
index e5a5a06..6970060 100644
--- a/01_doc/version_history.md
+++ b/01_doc/version_history.md
@@ -12,6 +12,7 @@
| `v0.5.0` | 随机化增强 | 扩大观测、延迟和动力学随机化,加入持续外力扰动 |
| `v0.6.0` | 比赛训练架构 | 分轴奖励、自适应指令课程、障碍释放课程和比赛站姿 |
| `v0.7.0` | MuJoCo 工具 | 姿态优化、IK 扫描、动力学、MPC 和 GUI 调试工具 |
+| `v0.8.0` | 后期 Sim2Sim | ONNX 回放、IK/路线检查工具和比赛最终 Rough 策略 |
## `v0.4.0` 的模型变化
@@ -51,3 +52,12 @@
- 增加 IK/差速轮参数扫描,可导出 JSON 结果。
- 增加 Robot、Controller、Dynamics、MPCController 和 GUI 调试链路。
- 记录历史工具常量与新版 MJCF 质量、比赛默认站姿之间的参数边界,避免将分析结果直接当作已校准真机参数。
+
+## `v0.8.0` 的后期 Sim2Sim
+
+- 比赛训练任务、MJCF 和 `v0.7.0` 的 MuJoCo 工具保持不变。
+- 策略运行器增加 ONNX 加载,并允许在缺少 `pynput` 时关闭后台键盘监听继续运行。
+- MuJoCo 执行器重建同时兼容新旧 Spec 删除接口。
+- 增加 PT→ONNX 导出、IK 补偿扫描、纯 IK 绕桩和 ONNX 批量路线检查入口。
+- 归档比赛最终 Rough 策略 `model_6800.onnx`;其 SHA-256 为 `3C994BDD3434AD15770A52AC0E8D229F502F00D6511CDD42C2E2C742301AEF13`。
+- Crawl 权重、运行日志、临时 XML 和大量重复路线实验不在本阶段归档。
diff --git a/05_software/README.md b/05_software/README.md
index 3ed6053..157bf58 100644
--- a/05_software/README.md
+++ b/05_software/README.md
@@ -28,7 +28,7 @@ MJCF + mjlab task
IK real --------------------------------> 电机
```
-`rc_mjlab` 是自包含工程。训练、MJCF、MuJoCo、Sim2Sim 和策略权重通过相对路径绑定,因此保留其内部布局,没有为了目录外观拆散。第一代完整闭环见 `v0.3.0`,第一份新版 MJCF 与训练框架见 `v0.4.0`,随机化增强版见 `v0.5.0`,比赛最终训练架构见 `v0.6.0`,后期 MuJoCo 工具集见 `v0.7.0`。
+`rc_mjlab` 是自包含工程。训练、MJCF、MuJoCo、Sim2Sim 和策略权重通过相对路径绑定,因此保留其内部布局,没有为了目录外观拆散。第一代完整闭环见 `v0.3.0`,第一份新版 MJCF 与训练框架见 `v0.4.0`,随机化增强版见 `v0.5.0`,比赛最终训练架构见 `v0.6.0`,后期 MuJoCo 工具集见 `v0.7.0`,后期 Sim2Sim 与比赛 Rough 策略见 `v0.8.0`。
详细说明见:
diff --git a/05_software/train/README.md b/05_software/train/README.md
index fbb3db4..4fb7831 100644
--- a/05_software/train/README.md
+++ b/05_software/train/README.md
@@ -2,7 +2,7 @@
`rc_mjlab/` 保存 16DOF 轮足机器人的当前训练与 Sim2Sim 工程。历史快照由 Git Tag 保留,不在目录中复制 `old`、`new` 或 `final` 版本。
-当前内容对应 `v0.7.0`:训练代码保持 `v0.6.0` 的比赛架构,新增后期 MuJoCo 姿态、IK、动力学和 MPC 工具。训练过程可能先获得基模,再调整奖励、课程和环境参数继续训练;模型 checkpoint 的变化不等同于软件架构变化。
+当前内容对应 `v0.8.0`:训练代码保持 `v0.6.0` 的比赛架构,包含 `v0.7.0` 的 MuJoCo 独立工具,并新增后期 Sim2Sim、路线检查和比赛最终 Rough ONNX 策略。训练过程可能先获得基模,再调整奖励、课程和环境参数继续训练;模型 checkpoint 的变化不等同于软件架构变化。
## 内容
@@ -12,6 +12,7 @@
- `mujoco_sim`:不依赖训练循环的姿态、IK、动力学和 MPC 分析
- `mjlab`:固定版本的本地训练框架依赖
- `model_rough.pt`:本阶段 Rough 策略权重
+- `model_6800.onnx`:比赛最终使用的 Rough 策略
- `pyproject.toml`、`uv.lock`:Python 环境与依赖锁定
与 `v0.3.0` 相比,本版本更新了 MJCF 质量和惯性参数,并将 mjlab 上游基准从 `00409797` 更新到 `40f8d93e`。机械 CAD 未发生变化。
@@ -22,4 +23,6 @@
`v0.7.0` 不修改比赛训练架构,增加独立 MuJoCo 工具;入口和参数边界见 [`rc_mjlab/mujoco_sim/README.md`](rc_mjlab/mujoco_sim/README.md)。
+`v0.8.0` 继续保持训练架构和 MJCF 不变,归档后期 Sim2Sim 增量与比赛 Rough ONNX 策略;入口和归档边界见 [`rc_mjlab/sim2sim/README.md`](rc_mjlab/sim2sim/README.md)。
+
工程命令和任务说明见 [`rc_mjlab/README.md`](rc_mjlab/README.md),本地依赖来源见 [`rc_mjlab/DEPENDENCIES.md`](rc_mjlab/DEPENDENCIES.md)。
diff --git a/05_software/train/rc_mjlab/DEPENDENCIES.md b/05_software/train/rc_mjlab/DEPENDENCIES.md
index b3fb65e..768e85d 100644
--- a/05_software/train/rc_mjlab/DEPENDENCIES.md
+++ b/05_software/train/rc_mjlab/DEPENDENCIES.md
@@ -8,6 +8,7 @@
- `mjlab[cu128]`
- PyTorch CUDA 12.8 环境
- `pynput`
+- 后期 Sim2Sim 可选依赖:Pygame、ONNX Runtime
精确解析结果保存在 `uv.lock`。项目使用本地可编辑的 `mjlab`:
@@ -39,4 +40,10 @@ uv run train Robot-Flat-v0
uv run play Robot-Rough-v0
```
+根 `uv.lock` 保留比赛训练环境的历史解析结果。后期 Sim2Sim 新增依赖单独保存在 `sim2sim/requirements.txt`,运行时叠加,避免重新锁定时升级历史 MuJoCo nightly:
+
+```bash
+uv run --with-requirements sim2sim/requirements.txt python sim2sim/nav_sim2sim.py
+```
+
GPU、CUDA、MuJoCo development wheel 和驱动版本必须满足 `pyproject.toml` 与 `uv.lock` 的约束。
diff --git a/05_software/train/rc_mjlab/README.md b/05_software/train/rc_mjlab/README.md
index 283d13f..71d71b2 100644
--- a/05_software/train/rc_mjlab/README.md
+++ b/05_software/train/rc_mjlab/README.md
@@ -2,7 +2,7 @@
基于 [mjlab](https://github.com/google-deepmind/mjlab) 框架的四轮腿混合机器人强化学习训练与部署部署项目,面向机器人竞赛场景(如越障、匍匐、斜坡、台阶等复合任务)。
-> 当前目录对应 `v0.7.0`:保留 `v0.6.0` 的比赛训练架构,并加入后期 MuJoCo 独立工具集。当前目录中的 `model_rough.pt` 是早期参考权重;比赛最终使用的 `model_6800.onnx` 将随最终部署版本归档。
+> 当前目录对应 `v0.8.0`:保留 `v0.6.0` 的比赛训练架构与 `v0.7.0` 的 MuJoCo 独立工具集,加入后期 Sim2Sim 工具和比赛最终 Rough 策略 `model_6800.onnx`。`model_rough.pt` 仍作为早期参考权重保留。
---
@@ -41,6 +41,10 @@ rc_mjlab/
│ └── competition_terrains.py # 竞赛自定义地形(高墙障碍、低杆障碍)
├── sim2sim/ # Sim2Sim 物理部署与高精度交互回放工具
│ ├── nav_sim2sim.py # 主程序:2D Pygame 交互面板 + 全自动多地形导航追踪
+│ ├── nav_route_sim2sim_check.py # ONNX 策略批量路线检查
+│ ├── ik_slalom_sim2sim.py # 纯 IK、路径跟踪与绕桩验证
+│ ├── ik_compensation_sweep.py # IK 补偿参数扫描
+│ ├── export_onnx.py # PT actor 导出与 ONNX 一致性检查
│ ├── sim2sim.py # 简易版键盘调试工具
│ ├── interface/
│ │ └── mujoco_io.py # MuJoCo 输入输出与传感器、低通滤波器接口
@@ -54,7 +58,9 @@ rc_mjlab/
│ ├── scene.xml # mjlab 场景入口文件
│ └── meshes/ # STL/OBJ 碰撞与外观网格
├── mujoco_sim/ # 姿态、IK、动力学和 MPC 独立工具
-├── model_rough.pt # 本阶段用于回放和 Sim2Sim 的 Rough 策略
+├── tools/nav_tools/ # 路线安全检查公共模块
+├── model_rough.pt # 早期 Rough 参考 checkpoint
+├── model_6800.onnx # 比赛最终 Rough 策略
├── pyproject.toml # 项目依赖(uv 管理,含清华镜像源加速)
└── uv.lock # 精确依赖锁定文件
```
@@ -89,6 +95,8 @@ cd sim2sim
uv run python nav_sim2sim.py
```
+后期 Sim2Sim 的入口、模型边界和批量检查命令见 [`sim2sim/README.md`](sim2sim/README.md)。
+
---
## 🖥️ 交互式自动导航平台 (sim2sim/nav_sim2sim.py)
diff --git a/05_software/train/rc_mjlab/model_6800.onnx b/05_software/train/rc_mjlab/model_6800.onnx
new file mode 100644
index 0000000..49ea593
Binary files /dev/null and b/05_software/train/rc_mjlab/model_6800.onnx differ
diff --git a/05_software/train/rc_mjlab/sim2sim/README.md b/05_software/train/rc_mjlab/sim2sim/README.md
new file mode 100644
index 0000000..8e43e94
--- /dev/null
+++ b/05_software/train/rc_mjlab/sim2sim/README.md
@@ -0,0 +1,58 @@
+# 后期 Sim2Sim 工具
+
+本目录保存比赛训练架构之后形成的 MuJoCo 策略验证工具。`v0.8.0` 在早期 Sim2Sim 基础上增加 ONNX 策略加载、IK 参数扫描、纯 IK 绕桩验证和批量路线检查;训练任务与 MJCF 不在本阶段修改。
+
+## 主要入口
+
+- `nav_sim2sim.py`:Pygame 面板与 MuJoCo 多任务导航,Rough 策略优先加载根目录的 `model_6800.onnx`。
+- `sim2sim.py`:较轻量的键盘控制与策略回放入口,优先加载 `model_6800.onnx`,缺失时回退到早期 `model_rough.pt`。
+- `ik_slalom_sim2sim.py`:不依赖 RL 策略的 IK、差速轮、路径跟踪和绕桩测试。
+- `ik_compensation_sweep.py`:批量扫描 IK 补偿参数并输出排序结果。
+- `nav_route_sim2sim_check.py`:使用 ONNX 策略批量检查内置任务或外部航点路线。
+- `export_onnx.py`:将兼容的 PyTorch actor checkpoint 导出并核对为 ONNX。
+- `interface/mujoco_io.py`:MuJoCo 模型、传感器和执行器接口。
+- `policy/policy_runner.py`:PT/ONNX 策略加载与历史观测缓存。
+
+## 环境
+
+主训练环境继续由根目录的 `uv.lock` 管理。后期 Sim2Sim 新增的 Pygame 与 ONNX Runtime 单独记录在 `sim2sim/requirements.txt`,运行时叠加,避免重新解析时改变已归档的 MuJoCo nightly 版本:
+
+```powershell
+uv run --with-requirements .\sim2sim\requirements.txt python .\sim2sim\nav_sim2sim.py
+```
+
+训练工程提供 MuJoCo、NumPy、PyTorch、Matplotlib 和 `pynput`;专用 requirements 显式补充 Pygame 与 ONNX Runtime。下面其他命令同样使用 `--with-requirements .\sim2sim\requirements.txt`。
+
+## 常用命令
+
+```powershell
+# 比赛 Rough 策略交互回放
+uv run --with-requirements .\sim2sim\requirements.txt python .\sim2sim\nav_sim2sim.py
+
+# 轻量策略回放
+uv run --with-requirements .\sim2sim\requirements.txt python .\sim2sim\sim2sim.py
+
+# 纯 IK 绕桩验证
+uv run --with-requirements .\sim2sim\requirements.txt python .\sim2sim\ik_slalom_sim2sim.py --test slalom
+
+# IK 补偿参数扫描
+uv run --with-requirements .\sim2sim\requirements.txt python .\sim2sim\ik_compensation_sweep.py --top 12
+
+# 使用内置绕桩任务做批量 Sim2Sim 路线检查
+uv run --with-requirements .\sim2sim\requirements.txt python .\sim2sim\nav_route_sim2sim_check.py `
+ --terrain-xml .\sim2sim\terrain\scene_terrain.xml `
+ --mission slalom `
+ --onnx .\model_6800.onnx
+
+# 导出早期参考 PT 权重;也可用 --pt-path 指定其他 checkpoint
+uv run --with-requirements .\sim2sim\requirements.txt python .\sim2sim\export_onnx.py
+```
+
+## 模型与边界
+
+- `../model_6800.onnx` 是 `last_not_slalom_1050` 最终真机工程使用的比赛 Rough 策略,SHA-256 为 `3C994BDD3434AD15770A52AC0E8D229F502F00D6511CDD42C2E2C742301AEF13`。
+- `../model_rough.pt` 是较早阶段的参考 checkpoint,两者不是同一版本的权重。
+- Crawl 模型未在本阶段归档;需要 Crawl 策略的入口会查找 `model_crawl.onnx` 或 `model_crawl.pt`。
+- `nav_route_sim2sim_check.py` 依赖 `../tools/nav_tools/route_safety_check.py` 的航点和避障几何定义。
+
+运行时生成的日志、临时 XML 和 `route_check_runs/` 不纳入版本库。源目录中的大量路线试验结果也未复制;它们包含重复轨迹和本机绝对路径,不属于可复用程序源码。
diff --git a/05_software/train/rc_mjlab/sim2sim/export_onnx.py b/05_software/train/rc_mjlab/sim2sim/export_onnx.py
new file mode 100644
index 0000000..d1be128
--- /dev/null
+++ b/05_software/train/rc_mjlab/sim2sim/export_onnx.py
@@ -0,0 +1,110 @@
+import argparse
+import torch
+import torch.nn as nn
+import numpy as np
+from pathlib import Path
+import onnxruntime as ort
+
+
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
+
+class PolicyMLP(nn.Module):
+ def __init__(self, obs_dim=53, action_dim=16):
+ super().__init__()
+ self.register_buffer("obs_mean", torch.zeros(obs_dim))
+ self.register_buffer("obs_std", torch.ones(obs_dim))
+ self.net = nn.Sequential(
+ nn.Linear(obs_dim, 512), nn.ELU(),
+ nn.Linear(512, 256), nn.ELU(),
+ nn.Linear(256, 128), nn.ELU(),
+ nn.Linear(128, action_dim),
+ )
+
+ def forward(self, x):
+ x = (x - self.obs_mean) / torch.clamp(self.obs_std, min=1e-6)
+ return self.net(x)
+
+def load_policy(model_path, device):
+ ckpt = torch.load(model_path, map_location=device, weights_only=False)
+ state_dict = ckpt["actor_state_dict"]
+ weight_key = "mlp.0.weight" if "mlp.0.weight" in state_dict else "net.0.weight"
+ obs_dim = state_dict[weight_key].shape[1]
+
+ output_key = "mlp.6.weight" if "mlp.6.weight" in state_dict else "net.6.weight"
+ action_dim = state_dict[output_key].shape[0]
+
+ model = PolicyMLP(obs_dim=obs_dim, action_dim=action_dim)
+ my_sd = {}
+ for k, v in state_dict.items():
+ if k.startswith("mlp."):
+ my_sd[k.replace("mlp.", "net.")] = v
+ elif k.startswith("net."):
+ my_sd[k] = v
+ elif k == "obs_normalizer._mean":
+ my_sd["obs_mean"] = v.squeeze()
+ elif k == "obs_normalizer._var":
+ my_sd["obs_std"] = torch.sqrt(v.squeeze() + 1e-5)
+
+ model.load_state_dict(my_sd, strict=False)
+ model.eval()
+ model.to(device)
+ return model, obs_dim
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument(
+ "--pt-path",
+ "--pt_path",
+ dest="pt_path",
+ type=Path,
+ default=PROJECT_ROOT / "model_rough.pt",
+ help="PyTorch checkpoint to export (default: ../model_rough.pt).",
+ )
+ args = parser.parse_args()
+
+ pt_path = args.pt_path.expanduser().resolve()
+ if not pt_path.exists():
+ print(f"File not found: {pt_path}")
+ return
+
+ device = torch.device("cpu")
+ print(f"Loading {pt_path}...")
+ model, obs_dim = load_policy(pt_path, device)
+
+ onnx_path = pt_path.with_suffix(".onnx")
+
+ dummy_input = torch.randn(1, obs_dim, device=device)
+
+ print(f"Exporting to {onnx_path}...")
+ torch.onnx.export(
+ model,
+ dummy_input,
+ str(onnx_path),
+ export_params=True,
+ opset_version=14,
+ do_constant_folding=True,
+ input_names=["obs"],
+ output_names=["action"],
+ dynamic_axes={"obs": {0: "batch_size"}, "action": {0: "batch_size"}}
+ )
+
+ print("Verifying ONNX export...")
+ try:
+ session = ort.InferenceSession(str(onnx_path))
+ with torch.no_grad():
+ pt_out = model(dummy_input).numpy()
+ onnx_out = session.run(["action"], {"obs": dummy_input.numpy()})[0]
+
+ max_diff = np.max(np.abs(pt_out - onnx_out))
+ mean_diff = np.mean(np.abs(pt_out - onnx_out))
+ print(f"ONNX vs PyTorch - max_diff: {max_diff:.6f}, mean_diff: {mean_diff:.6f}")
+
+ if max_diff < 1e-4:
+ print("ONNX export verified OK.")
+ else:
+ print("WARNING: ONNX export has significant divergence from PyTorch model.")
+ except ImportError:
+ print("onnxruntime not installed. Skipping verification. Install with: pip install onnxruntime")
+
+if __name__ == "__main__":
+ main()
diff --git a/05_software/train/rc_mjlab/sim2sim/ik_compensation_sweep.py b/05_software/train/rc_mjlab/sim2sim/ik_compensation_sweep.py
new file mode 100644
index 0000000..f013f7b
--- /dev/null
+++ b/05_software/train/rc_mjlab/sim2sim/ik_compensation_sweep.py
@@ -0,0 +1,238 @@
+#!/usr/bin/env python3
+"""Sweep IK compensation parameters in the standalone sim2sim scene."""
+
+from __future__ import annotations
+
+import argparse
+import importlib.util
+import json
+import sys
+from pathlib import Path
+from typing import Any
+
+THIS_DIR = Path(__file__).resolve().parent
+PROJECT_ROOT = THIS_DIR.parent
+if str(THIS_DIR) not in sys.path:
+ sys.path.insert(0, str(THIS_DIR))
+
+SIM_PATH = THIS_DIR / "ik_slalom_sim2sim.py"
+spec = importlib.util.spec_from_file_location("ik_slalom_sim2sim", SIM_PATH)
+if spec is None or spec.loader is None:
+ raise RuntimeError(f"Cannot load {SIM_PATH}")
+sim = importlib.util.module_from_spec(spec)
+sys.modules["ik_slalom_sim2sim"] = sim
+spec.loader.exec_module(sim)
+
+
+TRIALS = [
+ {"name": "forward", "speed": 1.0, "yaw": 0.0, "target_vx": 1.0, "target_yaw": 0.0},
+ {"name": "yaw", "speed": 0.0, "yaw": 1.0, "target_vx": 0.0, "target_yaw": 1.0},
+ {"name": "arc", "speed": 1.0, "yaw": 1.0, "target_vx": 1.0, "target_yaw": 1.0},
+]
+
+
+def parse_float_list(text: str) -> list[float]:
+ return [float(x.strip()) for x in text.split(",") if x.strip()]
+
+
+def parse_bool_list(text: str) -> list[bool]:
+ out: list[bool] = []
+ for item in text.split(","):
+ key = item.strip().lower()
+ if not key:
+ continue
+ if key in {"1", "true", "on", "yes"}:
+ out.append(True)
+ elif key in {"0", "false", "off", "no"}:
+ out.append(False)
+ else:
+ raise argparse.ArgumentTypeError(f"Invalid bool item: {item}")
+ return out
+
+
+def make_sim_args(args: argparse.Namespace, trial: dict[str, float | str], cfg: dict[str, Any]) -> argparse.Namespace:
+ argv = [
+ "ik_slalom_sim2sim.py",
+ "--test",
+ str(trial["name"]),
+ "--duration",
+ str(args.duration),
+ "--settle",
+ str(args.settle),
+ "--speed",
+ str(trial["speed"]),
+ "--yaw-rate",
+ str(trial["yaw"]),
+ "--posture",
+ "custom",
+ "--custom-abduction",
+ str(args.custom_abduction),
+ "--custom-hip",
+ str(args.custom_hip),
+ "--custom-knee",
+ str(args.custom_knee),
+ "--wheel-model",
+ "direct",
+ "--linear-wheel-gain",
+ str(args.linear_wheel_gain),
+ "--direct-yaw-wheel-gain",
+ str(args.direct_yaw_wheel_gain),
+ "--max-wheel-speed",
+ str(args.max_wheel_speed),
+ "--wheel-accel-limit",
+ str(args.wheel_accel_limit),
+ "--yaw-rate-kp",
+ str(cfg["yaw_rate_kp"]),
+ "--encoder-posture-kp",
+ str(cfg["encoder_posture_kp"]),
+ "--encoder-posture-max",
+ str(cfg["encoder_posture_max"]),
+ "--roll-comp-gain",
+ str(cfg["roll_comp_gain"]),
+ "--pitch-comp-gain",
+ str(cfg["pitch_comp_gain"]),
+ "--no-realtime",
+ ]
+ argv.append("--imu-posture" if cfg["imu_posture"] else "--no-imu-posture")
+ argv.append("--encoder-guard" if cfg["encoder_guard"] else "--no-encoder-guard")
+ argv.append("--imu-guard" if cfg["imu_guard"] else "--no-imu-guard")
+ old_argv = sys.argv
+ try:
+ sys.argv = argv
+ return sim.parse_args()
+ finally:
+ sys.argv = old_argv
+
+
+def score_trial(out: dict[str, Any], trial: dict[str, float | str]) -> dict[str, float]:
+ vx = float(out["mean_body_vx_mps"])
+ yaw = float(out["mean_yaw_rate_rad_s"])
+ vx_err = abs(vx - float(trial["target_vx"]))
+ yaw_err = abs(yaw - float(trial["target_yaw"]))
+ return {
+ "vx": vx,
+ "yaw": yaw,
+ "imu_gyro_z": float(out["mean_imu_gyro_z_rad_s"]),
+ "vx_err": vx_err,
+ "yaw_err": yaw_err,
+ "err": vx_err + yaw_err,
+ }
+
+
+def run_sweep(args: argparse.Namespace) -> list[dict[str, Any]]:
+ rows: list[dict[str, Any]] = []
+ for imu_posture in args.imu_posture_values:
+ for encoder_guard in args.encoder_guard_values:
+ for imu_guard in args.imu_guard_values:
+ for encoder_posture_kp in args.encoder_posture_kps:
+ for encoder_posture_max in args.encoder_posture_maxs:
+ for yaw_rate_kp in args.yaw_rate_kps:
+ for roll_comp_gain in args.roll_comp_gains:
+ for pitch_comp_gain in args.pitch_comp_gains:
+ cfg = {
+ "imu_posture": imu_posture,
+ "encoder_guard": encoder_guard,
+ "imu_guard": imu_guard,
+ "encoder_posture_kp": encoder_posture_kp,
+ "encoder_posture_max": encoder_posture_max,
+ "yaw_rate_kp": yaw_rate_kp,
+ "roll_comp_gain": roll_comp_gain,
+ "pitch_comp_gain": pitch_comp_gain,
+ }
+ detail: list[dict[str, Any]] = []
+ speed_error = 0.0
+ max_tilt = 0.0
+ max_leg = 0.0
+ mean_wheel_err = 0.0
+ stable_all = True
+ for trial in TRIALS:
+ sim_args = make_sim_args(args, trial, cfg)
+ out = sim.run_one(str(trial["name"]), sim_args)
+ trial_score = score_trial(out, trial)
+ trial_score["test"] = str(trial["name"])
+ detail.append(trial_score)
+ speed_error += trial_score["err"]
+ max_tilt = max(max_tilt, float(out["max_tilt_deg"]))
+ max_leg = max(max_leg, float(out["max_leg_encoder_error_rad"]))
+ mean_wheel_err += float(out["mean_wheel_speed_error_rad_s"])
+ stable_all = stable_all and bool(out["stable"])
+
+ score = (
+ speed_error
+ + args.tilt_weight * max_tilt
+ + args.leg_error_weight * max_leg
+ + args.wheel_error_weight * (mean_wheel_err / len(TRIALS))
+ )
+ row = {
+ **cfg,
+ "score": round(score, 6),
+ "speed_error_sum": round(speed_error, 6),
+ "max_tilt_deg": round(max_tilt, 5),
+ "max_leg_encoder_error_rad": round(max_leg, 6),
+ "mean_wheel_speed_error_rad_s": round(mean_wheel_err / len(TRIALS), 6),
+ "stable_all": stable_all,
+ "detail": detail,
+ }
+ rows.append(row)
+ print(
+ "DONE "
+ + json.dumps(
+ {k: v for k, v in row.items() if k != "detail"},
+ ensure_ascii=False,
+ ),
+ flush=True,
+ )
+ rows.sort(key=lambda r: float(r["score"]))
+ return rows
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--duration", type=float, default=3.0)
+ parser.add_argument("--settle", type=float, default=1.5)
+ parser.add_argument("--custom-abduction", type=float, default=0.2)
+ parser.add_argument("--custom-hip", type=float, default=1.697)
+ parser.add_argument("--custom-knee", type=float, default=-2.650)
+ parser.add_argument("--linear-wheel-gain", type=float, default=12.5)
+ parser.add_argument("--direct-yaw-wheel-gain", type=float, default=8.0)
+ parser.add_argument("--max-wheel-speed", type=float, default=12.0)
+ parser.add_argument("--wheel-accel-limit", type=float, default=35.0)
+ parser.add_argument("--imu-posture-values", type=parse_bool_list, default=[True, False])
+ parser.add_argument("--encoder-guard-values", type=parse_bool_list, default=[True])
+ parser.add_argument("--imu-guard-values", type=parse_bool_list, default=[True])
+ parser.add_argument("--encoder-posture-kps", type=parse_float_list, default=[0.0, 0.05, 0.15, 0.30])
+ parser.add_argument("--encoder-posture-maxs", type=parse_float_list, default=[0.03])
+ parser.add_argument("--yaw-rate-kps", type=parse_float_list, default=[0.0, 0.4, 0.8])
+ parser.add_argument("--roll-comp-gains", type=parse_float_list, default=[0.35])
+ parser.add_argument("--pitch-comp-gains", type=parse_float_list, default=[0.35])
+ parser.add_argument("--tilt-weight", type=float, default=0.02)
+ parser.add_argument("--leg-error-weight", type=float, default=0.5)
+ parser.add_argument("--wheel-error-weight", type=float, default=0.0)
+ parser.add_argument("--top", type=int, default=12)
+ parser.add_argument("--json", type=Path, default=None)
+ return parser.parse_args()
+
+
+def main() -> int:
+ args = parse_args()
+ rows = run_sweep(args)
+ if args.json:
+ args.json.write_text(json.dumps(rows, indent=2), encoding="utf-8")
+
+ print("\nTop compensation parameter sets")
+ print("rank score speed_err tilt leg_err wheel_err imu enc_kp yaw_kp enc_guard imu_guard")
+ for i, row in enumerate(rows[: args.top], 1):
+ print(
+ f"{i:2d} {row['score']:7.4f} {row['speed_error_sum']:7.4f} "
+ f"{row['max_tilt_deg']:5.2f} {row['max_leg_encoder_error_rad']:7.4f} "
+ f"{row['mean_wheel_speed_error_rad_s']:7.4f} "
+ f"{int(row['imu_posture'])} {row['encoder_posture_kp']:6.3f} "
+ f"{row['yaw_rate_kp']:6.3f} {int(row['encoder_guard'])} {int(row['imu_guard'])}"
+ )
+ for d in row["detail"]:
+ print(f" {d['test']:<7} vx={d['vx']:+.3f} yaw={d['yaw']:+.3f} err={d['err']:.3f}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/05_software/train/rc_mjlab/sim2sim/ik_slalom_sim2sim.py b/05_software/train/rc_mjlab/sim2sim/ik_slalom_sim2sim.py
new file mode 100644
index 0000000..5107681
--- /dev/null
+++ b/05_software/train/rc_mjlab/sim2sim/ik_slalom_sim2sim.py
@@ -0,0 +1,1235 @@
+#!/usr/bin/env python3
+"""
+Standalone sim2sim check for wheel-leg IK navigation.
+
+This file is intentionally isolated from nav_sim2sim.py and the ROS2 runtime.
+It keeps the 12 leg joints at the RL rough-terrain default pose and drives the
+four wheel velocity actuators with a differential-drive inverse kinematics law.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import math
+import os
+import sys
+import tempfile
+import time
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Iterable
+
+import mujoco
+import mujoco.viewer
+import numpy as np
+
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
+SIM2SIM_DIR = PROJECT_ROOT / "sim2sim"
+if str(SIM2SIM_DIR) not in sys.path:
+ sys.path.insert(0, str(SIM2SIM_DIR))
+
+from tools.math_utils import get_gravity_orientation # noqa: E402
+
+
+LEG_JOINT_NAMES = [
+ "fl_hip_abduction_joint",
+ "fl_hip_pitch_joint",
+ "fl_knee_joint",
+ "fr_hip_abduction_joint",
+ "fr_hip_pitch_joint",
+ "fr_knee_joint",
+ "rl_hip_abduction_joint",
+ "rl_hip_pitch_joint",
+ "rl_knee_joint",
+ "rr_hip_abduction_joint",
+ "rr_hip_pitch_joint",
+ "rr_knee_joint",
+]
+WHEEL_JOINT_NAMES = [
+ "fl_wheel_joint",
+ "fr_wheel_joint",
+ "rl_wheel_joint",
+ "rr_wheel_joint",
+]
+ALL_JOINT_NAMES = LEG_JOINT_NAMES + WHEEL_JOINT_NAMES
+WHEEL_BODY_NAMES = [
+ "fl_wheel_Link",
+ "fr_wheel_Link",
+ "rl_wheel_Link",
+ "rr_wheel_Link",
+]
+
+# Must match src/robot/robot_cfg.py rough policy initial state.
+ROUGH_DEFAULT_DOF_POS = np.array(
+ [0.0, 0.550, -1.125] * 4 + [0.0] * 4,
+ dtype=np.float64,
+)
+
+# From mujoco_sim/config.py. These poses keep the wheel contact close to the
+# hip in sagittal X while changing body height.
+HEIGHT_TABLE = np.array(
+ [
+ (0.17, 0.914, -2.628),
+ (0.19, 0.926, -2.528),
+ (0.21, 0.924, -2.428),
+ (0.23, 0.912, -2.328),
+ (0.25, 0.892, -2.226),
+ (0.27, 0.864, -2.122),
+ (0.29, 0.834, -2.014),
+ (0.31, 0.798, -1.906),
+ (0.33, 0.758, -1.792),
+ (0.35, 0.714, -1.672),
+ (0.37, 0.666, -1.546),
+ (0.39, 0.612, -1.412),
+ (0.41, 0.552, -1.266),
+ (0.43, 0.484, -1.104),
+ (0.45, 0.404, -0.918),
+ ],
+ dtype=np.float64,
+)
+
+
+def wrap_pi(x: float) -> float:
+ return (x + math.pi) % (2.0 * math.pi) - math.pi
+
+
+def quat_to_euler_wxyz(q: np.ndarray) -> tuple[float, float, float]:
+ w, x, y, z = q
+ sinr_cosp = 2.0 * (w * x + y * z)
+ cosr_cosp = 1.0 - 2.0 * (x * x + y * y)
+ roll = math.atan2(sinr_cosp, cosr_cosp)
+
+ sinp = 2.0 * (w * y - z * x)
+ pitch = math.asin(float(np.clip(sinp, -1.0, 1.0)))
+
+ siny_cosp = 2.0 * (w * z + x * y)
+ cosy_cosp = 1.0 - 2.0 * (y * y + z * z)
+ yaw = math.atan2(siny_cosp, cosy_cosp)
+ return roll, pitch, yaw
+
+
+def yaw_to_quat_wxyz(yaw: float) -> np.ndarray:
+ half = 0.5 * yaw
+ return np.array([math.cos(half), 0.0, 0.0, math.sin(half)], dtype=np.float64)
+
+
+def quat_to_mat_wxyz(q: np.ndarray) -> np.ndarray:
+ w, x, y, z = q
+ return np.array(
+ [
+ [1.0 - 2.0 * (y * y + z * z), 2.0 * (x * y - z * w), 2.0 * (x * z + y * w)],
+ [2.0 * (x * y + z * w), 1.0 - 2.0 * (x * x + z * z), 2.0 * (y * z - x * w)],
+ [2.0 * (x * z - y * w), 2.0 * (y * z + x * w), 1.0 - 2.0 * (x * x + y * y)],
+ ],
+ dtype=np.float64,
+ )
+
+
+def table_pose_for_height(height: float) -> np.ndarray:
+ h = HEIGHT_TABLE[:, 0]
+ hip = float(np.interp(np.clip(height, h[0], h[-1]), h, HEIGHT_TABLE[:, 1]))
+ knee = float(np.interp(np.clip(height, h[0], h[-1]), h, HEIGHT_TABLE[:, 2]))
+ return np.array([0.0, hip, knee] * 4 + [0.0] * 4, dtype=np.float64)
+
+
+def nominal_dof_pos(args: argparse.Namespace) -> np.ndarray:
+ if args.posture == "custom":
+ ab = args.custom_abduction
+ return np.array(
+ [
+ ab,
+ args.custom_hip,
+ args.custom_knee,
+ -ab,
+ args.custom_hip,
+ args.custom_knee,
+ ab,
+ args.custom_hip,
+ args.custom_knee,
+ -ab,
+ args.custom_hip,
+ args.custom_knee,
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0,
+ ],
+ dtype=np.float64,
+ )
+ if args.posture == "table":
+ return table_pose_for_height(args.body_height)
+ return ROUGH_DEFAULT_DOF_POS.copy()
+
+
+def make_flat_scene_xml(robot_abs: str, tmp_dir: Path) -> Path:
+ out = tmp_dir / "ik_flat_scene.xml"
+ out.write_text(
+ f"""
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+""",
+ encoding="utf-8",
+ )
+ return out
+
+
+def make_sim_xml(terrain_xml: Path | None, robot_xml: Path, tmp_dir: Path) -> Path:
+ robot_content = robot_xml.read_text(encoding="utf-8")
+ mesh_dir = str((robot_xml.parent / "meshes").resolve()).replace("\\", "/")
+ robot_content = robot_content.replace(
+ '',
+ f'',
+ )
+ tmp_dir.mkdir(parents=True, exist_ok=True)
+ robot_out = tmp_dir / "wheelleg_abs_mesh.xml"
+ robot_out.write_text(robot_content, encoding="utf-8")
+
+ robot_abs = str(robot_out.resolve()).replace("\\", "/")
+ if terrain_xml is None:
+ return make_flat_scene_xml(robot_abs, tmp_dir)
+
+ content = terrain_xml.read_text(encoding="utf-8")
+ content = content.replace('', f'')
+ content = content.replace('', f'')
+
+ terrain_dir = terrain_xml.parent
+ replacements = {
+ "../height_field.png": str((terrain_dir / "height_field.png").resolve()).replace("\\", "/"),
+ "../unitree_hfield.png": str((terrain_dir / "unitree_hfield.png").resolve()).replace("\\", "/"),
+ }
+ for old, new in replacements.items():
+ content = content.replace(old, new)
+
+ out = tmp_dir / "ik_slalom_scene.xml"
+ out.write_text(content, encoding="utf-8")
+ return out
+
+
+def rebuild_actuators(spec: mujoco.MjSpec) -> None:
+ for actuator in list(spec.actuators):
+ spec.delete(actuator)
+
+ kp_leg = 50.0
+ kd_leg = 1.5
+ kd_wheel = 1.0
+ effort_limit = 17.0
+
+ for jname in LEG_JOINT_NAMES:
+ act = spec.add_actuator(name=jname, target=jname)
+ act.trntype = mujoco.mjtTrn.mjTRN_JOINT
+ act.dyntype = mujoco.mjtDyn.mjDYN_NONE
+ act.gaintype = mujoco.mjtGain.mjGAIN_FIXED
+ act.biastype = mujoco.mjtBias.mjBIAS_AFFINE
+ act.gainprm[0] = kp_leg
+ act.biasprm[1] = -kp_leg
+ act.biasprm[2] = -kd_leg
+ act.forcelimited = True
+ act.forcerange[:] = [-effort_limit, effort_limit]
+ act.ctrllimited = False
+
+ for jname in WHEEL_JOINT_NAMES:
+ act = spec.add_actuator(name=jname, target=jname)
+ act.trntype = mujoco.mjtTrn.mjTRN_JOINT
+ act.dyntype = mujoco.mjtDyn.mjDYN_NONE
+ act.gaintype = mujoco.mjtGain.mjGAIN_FIXED
+ act.biastype = mujoco.mjtBias.mjBIAS_AFFINE
+ act.gainprm[0] = kd_wheel
+ act.biasprm[2] = -kd_wheel
+ act.forcelimited = True
+ act.forcerange[:] = [-effort_limit, effort_limit]
+ act.ctrllimited = False
+
+
+@dataclass(frozen=True)
+class ModelIds:
+ qpos: np.ndarray
+ qvel: np.ndarray
+ ctrl: np.ndarray
+ wheel_body: np.ndarray
+ wheel_geom: np.ndarray
+
+
+@dataclass
+class SensorFrame:
+ time: float
+ base_pos: np.ndarray
+ quat_wxyz: np.ndarray
+ rpy: np.ndarray
+ gyro: np.ndarray
+ projected_gravity: np.ndarray
+ joint_pos: np.ndarray
+ joint_vel: np.ndarray
+ wheel_vel: np.ndarray
+
+
+def name_id(model: mujoco.MjModel, obj_type: mujoco.mjtObj, name: str) -> int:
+ idx = mujoco.mj_name2id(model, obj_type, name)
+ if idx < 0:
+ raise RuntimeError(f"MuJoCo object not found: {name}")
+ return idx
+
+
+def build_model(args: argparse.Namespace, tmp_dir: Path) -> tuple[mujoco.MjModel, mujoco.MjData, ModelIds]:
+ scene_xml = make_sim_xml(args.terrain_xml, args.robot_xml, tmp_dir)
+
+ old_cwd = Path.cwd()
+ os.chdir(str(args.robot_xml.parent))
+ try:
+ spec = mujoco.MjSpec.from_file(str(scene_xml))
+ rebuild_actuators(spec)
+ model = spec.compile()
+ finally:
+ os.chdir(str(old_cwd))
+
+ data = mujoco.MjData(model)
+ qpos_ids = np.array(
+ [model.jnt_qposadr[name_id(model, mujoco.mjtObj.mjOBJ_JOINT, n)] for n in ALL_JOINT_NAMES],
+ dtype=np.int32,
+ )
+ qvel_ids = np.array(
+ [model.jnt_dofadr[name_id(model, mujoco.mjtObj.mjOBJ_JOINT, n)] for n in ALL_JOINT_NAMES],
+ dtype=np.int32,
+ )
+ ctrl_ids = np.array(
+ [name_id(model, mujoco.mjtObj.mjOBJ_ACTUATOR, n) for n in ALL_JOINT_NAMES],
+ dtype=np.int32,
+ )
+ wheel_body_ids = np.array(
+ [name_id(model, mujoco.mjtObj.mjOBJ_BODY, n) for n in WHEEL_BODY_NAMES],
+ dtype=np.int32,
+ )
+
+ wheel_geom_ids: list[int] = []
+ for body_id in wheel_body_ids:
+ for geom_id in range(model.ngeom):
+ if model.geom_bodyid[geom_id] == body_id and model.geom_type[geom_id] == mujoco.mjtGeom.mjGEOM_CYLINDER:
+ wheel_geom_ids.append(geom_id)
+ break
+ else:
+ raise RuntimeError(f"No cylinder collision geom found for body id {body_id}")
+
+ return model, data, ModelIds(qpos_ids, qvel_ids, ctrl_ids, wheel_body_ids, np.array(wheel_geom_ids))
+
+
+def reset_robot(model: mujoco.MjModel, data: mujoco.MjData, ids: ModelIds, args: argparse.Namespace) -> np.ndarray:
+ default_dof_pos = nominal_dof_pos(args)
+ data.qpos[:3] = [args.start_x, args.start_y, args.start_z]
+ data.qpos[3:7] = [1.0, 0.0, 0.0, 0.0]
+ data.qpos[ids.qpos] = default_dof_pos
+ data.qvel[:] = 0.0
+ data.ctrl[ids.ctrl[:12]] = default_dof_pos[:12]
+ data.ctrl[ids.ctrl[12:]] = 0.0
+ mujoco.mj_forward(model, data)
+
+ settle_steps = int(round(args.settle / model.opt.timestep))
+ for _ in range(settle_steps):
+ data.ctrl[ids.ctrl[:12]] = default_dof_pos[:12]
+ data.ctrl[ids.ctrl[12:]] = 0.0
+ mujoco.mj_step(model, data)
+ return default_dof_pos
+
+
+def read_sensors(model: mujoco.MjModel, data: mujoco.MjData, ids: ModelIds) -> SensorFrame:
+ del model
+ quat = data.qpos[3:7].copy()
+ rpy = np.array(quat_to_euler_wxyz(quat), dtype=np.float64)
+ joint_pos = data.qpos[ids.qpos].copy()
+ joint_vel = data.qvel[ids.qvel].copy()
+ return SensorFrame(
+ time=float(data.time),
+ base_pos=data.qpos[:3].copy(),
+ quat_wxyz=quat,
+ rpy=rpy,
+ gyro=data.qvel[3:6].copy(),
+ projected_gravity=get_gravity_orientation(quat),
+ joint_pos=joint_pos,
+ joint_vel=joint_vel,
+ wheel_vel=joint_vel[12:].copy(),
+ )
+
+
+@dataclass(frozen=True)
+class WheelGeometry:
+ radius: float
+ track_width: float
+ wheel_y: np.ndarray
+
+
+def infer_wheel_geometry(model: mujoco.MjModel, data: mujoco.MjData, ids: ModelIds) -> WheelGeometry:
+ radius_values = model.geom_size[ids.wheel_geom, 0]
+ radius = float(np.mean(radius_values))
+ wheel_y = data.xipos[ids.wheel_body, 1].copy()
+ left_y = wheel_y[[0, 2]]
+ right_y = wheel_y[[1, 3]]
+ track = float(np.mean(left_y) - np.mean(right_y))
+ if radius <= 0.0 or track <= 0.0:
+ raise RuntimeError(f"Invalid wheel geometry: radius={radius}, track={track}")
+ return WheelGeometry(radius=radius, track_width=track, wheel_y=wheel_y)
+
+
+@dataclass
+class IkCommand:
+ linear_x: float
+ yaw_rate: float
+ target_index: int = -1
+ target_distance: float = 0.0
+
+
+class DifferentialWheelIk:
+ def __init__(
+ self,
+ geometry: WheelGeometry,
+ max_wheel_speed: float,
+ wheel_signs: np.ndarray,
+ yaw_wheel_gain: float,
+ args: argparse.Namespace,
+ ) -> None:
+ self.geometry = geometry
+ self.max_wheel_speed = max_wheel_speed
+ self.wheel_signs = wheel_signs.astype(np.float64)
+ self.yaw_wheel_gain = yaw_wheel_gain
+ self.args = args
+
+ def solve(self, cmd: IkCommand, sensor: SensorFrame | None = None) -> np.ndarray:
+ half_track = 0.5 * self.geometry.track_width
+ yaw_rate = cmd.yaw_rate
+ if sensor is not None:
+ yaw_rate += self.args.yaw_rate_kp * (cmd.yaw_rate - float(sensor.gyro[2]))
+
+ if self.args.wheel_model == "direct":
+ linear_wheel = self.args.linear_wheel_gain * cmd.linear_x
+ yaw_wheel = self.args.direct_yaw_wheel_gain * yaw_rate
+ else:
+ linear_wheel = cmd.linear_x / self.geometry.radius
+ yaw_wheel = self.yaw_wheel_gain * yaw_rate * half_track / self.geometry.radius
+ yaw_wheel = float(np.clip(yaw_wheel, -self.args.max_yaw_wheel_speed, self.args.max_yaw_wheel_speed))
+ left = linear_wheel - yaw_wheel
+ right = linear_wheel + yaw_wheel
+ wheel = np.array([left, right, left, right], dtype=np.float64)
+ wheel = np.clip(wheel, -self.max_wheel_speed, self.max_wheel_speed)
+ return wheel * self.wheel_signs
+
+
+class LegStanceIk:
+ """Numerical wheel-center IK for keeping the rough RL stance level."""
+
+ def __init__(self, model: mujoco.MjModel, data: mujoco.MjData, ids: ModelIds, args: argparse.Namespace) -> None:
+ self.model = model
+ self.ids = ids
+ self.args = args
+ self.shadow = mujoco.MjData(model)
+ self.jacp = np.zeros((3, model.nv), dtype=np.float64)
+ self.jacr = np.zeros((3, model.nv), dtype=np.float64)
+ self.last_target = ROUGH_DEFAULT_DOF_POS[:12].copy()
+
+ self.leg_qpos = [ids.qpos[i * 3 : i * 3 + 3] for i in range(4)]
+ self.leg_qvel = [ids.qvel[i * 3 : i * 3 + 3] for i in range(4)]
+ self.leg_joint_ids = [
+ [name_id(model, mujoco.mjtObj.mjOBJ_JOINT, LEG_JOINT_NAMES[i * 3 + j]) for j in range(3)]
+ for i in range(4)
+ ]
+ self.joint_ranges = [
+ np.array([model.jnt_range[jid].copy() for jid in leg], dtype=np.float64)
+ for leg in self.leg_joint_ids
+ ]
+
+ mujoco.mj_forward(model, data)
+ base_pos = data.qpos[:3].copy()
+ base_rot = quat_to_mat_wxyz(data.qpos[3:7])
+ self.nominal_body_wheel = (base_rot.T @ (data.xipos[ids.wheel_body] - base_pos).T).T
+
+ def compute(self, data: mujoco.MjData, sensor: SensorFrame) -> np.ndarray:
+ current_rot = quat_to_mat_wxyz(sensor.quat_wxyz)
+ level_rot = quat_to_mat_wxyz(yaw_to_quat_wxyz(float(sensor.rpy[2])))
+ base_targets = (current_rot @ self.nominal_body_wheel.T).T
+ level_targets = (level_rot @ self.nominal_body_wheel.T).T
+ target_world = sensor.base_pos + base_targets + self.args.posture_gain * (level_targets - base_targets)
+
+ self.shadow.qpos[:] = data.qpos
+ self.shadow.qvel[:] = 0.0
+ mujoco.mj_forward(self.model, self.shadow)
+
+ damping = self.args.leg_ik_damping
+ for _ in range(self.args.leg_ik_iterations):
+ for leg_idx, body_id in enumerate(self.ids.wheel_body):
+ err = target_world[leg_idx] - self.shadow.xipos[body_id]
+ if float(np.linalg.norm(err)) < 1e-4:
+ continue
+
+ self.jacp.fill(0.0)
+ self.jacr.fill(0.0)
+ mujoco.mj_jacBody(self.model, self.shadow, self.jacp, self.jacr, int(body_id))
+ cols = self.leg_qvel[leg_idx]
+ j_leg = self.jacp[:, cols]
+ lhs = j_leg @ j_leg.T + (damping * damping) * np.eye(3)
+ dq = j_leg.T @ np.linalg.solve(lhs, err)
+ dq = np.clip(dq, -self.args.leg_ik_step, self.args.leg_ik_step)
+
+ qpos_ids = self.leg_qpos[leg_idx]
+ self.shadow.qpos[qpos_ids] += dq
+ ranges = self.joint_ranges[leg_idx]
+ self.shadow.qpos[qpos_ids] = np.clip(self.shadow.qpos[qpos_ids], ranges[:, 0], ranges[:, 1])
+ mujoco.mj_forward(self.model, self.shadow)
+
+ raw_target = self.shadow.qpos[self.ids.qpos[:12]].copy()
+ offset = np.clip(
+ raw_target - ROUGH_DEFAULT_DOF_POS[:12],
+ -self.args.max_leg_offset,
+ self.args.max_leg_offset,
+ )
+ raw_target = ROUGH_DEFAULT_DOF_POS[:12] + offset
+
+ max_delta = self.args.leg_target_rate
+ filtered = self.last_target + np.clip(raw_target - self.last_target, -max_delta, max_delta)
+ alpha = self.args.leg_target_filter
+ filtered = (1.0 - alpha) * self.last_target + alpha * filtered
+ self.last_target = filtered.copy()
+ return filtered
+
+
+class TablePostureController:
+ def __init__(self, args: argparse.Namespace, initial_target: np.ndarray) -> None:
+ self.args = args
+ self.target = initial_target[:12].copy()
+
+ def compute(self, sensor: SensorFrame) -> np.ndarray:
+ if self.args.posture == "rough":
+ raw = ROUGH_DEFAULT_DOF_POS[:12].copy()
+ elif self.args.posture == "custom":
+ raw = nominal_dof_pos(self.args)[:12].copy()
+ else:
+ pose = table_pose_for_height(self.args.body_height)
+ raw = pose[:12].copy()
+
+ if self.args.imu_posture:
+ roll_corr = -self.args.roll_comp_gain * float(sensor.rpy[0])
+ pitch_corr = -self.args.pitch_comp_gain * float(sensor.rpy[1])
+ for i, side in enumerate((1.0, -1.0, 1.0, -1.0)):
+ raw[i * 3] = np.clip(raw[i * 3] + side * roll_corr, -0.5, 0.5)
+ raw[i * 3 + 1] = np.clip(raw[i * 3 + 1] + pitch_corr, -1.0, 2.5)
+ raw[i * 3 + 2] = np.clip(raw[i * 3 + 2], -2.65, -0.3)
+
+ if self.args.encoder_posture_kp > 0.0:
+ encoder_err = self.target - sensor.joint_pos[:12]
+ raw += np.clip(
+ self.args.encoder_posture_kp * encoder_err,
+ -self.args.encoder_posture_max,
+ self.args.encoder_posture_max,
+ )
+
+ max_delta = self.args.leg_target_rate
+ raw = self.target + np.clip(raw - self.target, -max_delta, max_delta)
+ alpha = self.args.leg_target_filter
+ self.target = (1.0 - alpha) * self.target + alpha * raw
+ return self.target.copy()
+
+
+def sensor_command_scale(sensor: SensorFrame, leg_target: np.ndarray, args: argparse.Namespace) -> float:
+ scale = 1.0
+ if args.encoder_guard:
+ leg_error = float(np.max(np.abs(sensor.joint_pos[:12] - leg_target)))
+ if leg_error >= args.encoder_guard_stop:
+ scale = 0.0
+ elif leg_error > args.encoder_guard_start:
+ span = max(1e-6, args.encoder_guard_stop - args.encoder_guard_start)
+ scale *= 1.0 - (leg_error - args.encoder_guard_start) / span
+
+ if args.imu_guard:
+ tilt = math.hypot(float(sensor.rpy[0]), float(sensor.rpy[1]))
+ start = math.radians(args.imu_guard_start_deg)
+ stop = math.radians(args.imu_guard_stop_deg)
+ if tilt >= stop:
+ scale = 0.0
+ elif tilt > start:
+ scale *= 1.0 - (tilt - start) / max(1e-6, stop - start)
+
+ return float(np.clip(scale, 0.0, 1.0))
+
+
+class PathFollower:
+ def __init__(
+ self,
+ waypoints: Iterable[tuple[float, float]],
+ max_speed: float,
+ max_yaw_rate: float,
+ arrive_radius: float,
+ allow_reverse: bool,
+ ) -> None:
+ self.waypoints = list(waypoints)
+ self.idx = 0
+ self.max_speed = max_speed
+ self.max_yaw_rate = max_yaw_rate
+ self.arrive_radius = arrive_radius
+ self.allow_reverse = allow_reverse
+
+ @property
+ def done(self) -> bool:
+ return self.idx >= len(self.waypoints)
+
+ def update(self, sensor: SensorFrame) -> IkCommand:
+ if self.done:
+ return IkCommand(0.0, 0.0, self.idx, 0.0)
+
+ x, y = sensor.base_pos[:2]
+ target = self.waypoints[self.idx]
+ dx = target[0] - float(x)
+ dy = target[1] - float(y)
+ dist = math.hypot(dx, dy)
+ while dist < self.arrive_radius and self.idx < len(self.waypoints) - 1:
+ self.idx += 1
+ target = self.waypoints[self.idx]
+ dx = target[0] - float(x)
+ dy = target[1] - float(y)
+ dist = math.hypot(dx, dy)
+ if dist < self.arrive_radius and self.idx == len(self.waypoints) - 1:
+ self.idx = len(self.waypoints)
+ return IkCommand(0.0, 0.0, self.idx, dist)
+
+ desired_yaw = math.atan2(dy, dx)
+ yaw_error = wrap_pi(desired_yaw - float(sensor.rpy[2]))
+ direction = 1.0
+ if self.allow_reverse and abs(yaw_error) > math.pi * 0.5:
+ desired_yaw = wrap_pi(desired_yaw + math.pi)
+ yaw_error = wrap_pi(desired_yaw - float(sensor.rpy[2]))
+ direction = -1.0
+ yaw_rate = float(np.clip(2.8 * yaw_error, -self.max_yaw_rate, self.max_yaw_rate))
+
+ speed = min(self.max_speed, 1.2 * dist)
+ speed *= max(0.15, math.cos(min(abs(yaw_error), math.pi * 0.5)))
+ speed *= direction
+ return IkCommand(speed, yaw_rate, self.idx, dist)
+
+
+class PurePursuitFollower:
+ def __init__(self, waypoints: Iterable[tuple[float, float]], args: argparse.Namespace) -> None:
+ self.points = np.array(list(waypoints), dtype=np.float64)
+ self.args = args
+ self.progress = 0
+ self.closest_distance = float("inf")
+ self.finished = False
+ if len(self.points) < 2:
+ raise ValueError("PurePursuitFollower needs at least two points")
+ seg = self.points[1:] - self.points[:-1]
+ self.seg_len = np.linalg.norm(seg, axis=1)
+ self.cum = np.concatenate([[0.0], np.cumsum(self.seg_len)])
+ self.total_len = float(self.cum[-1])
+
+ @property
+ def done(self) -> bool:
+ return self.finished
+
+ @property
+ def idx(self) -> int:
+ return int(self.progress)
+
+ def _project_s(self, p: np.ndarray) -> tuple[float, float]:
+ best_s = 0.0
+ best_d = float("inf")
+ start_i = max(0, self.progress - 1)
+ end_i = min(len(self.points) - 1, self.progress + self.args.pure_search_segments)
+ for i in range(start_i, end_i):
+ a = self.points[i]
+ b = self.points[i + 1]
+ ab = b - a
+ denom = float(np.dot(ab, ab))
+ if denom < 1e-9:
+ continue
+ t = float(np.clip(np.dot(p - a, ab) / denom, 0.0, 1.0))
+ proj = a + t * ab
+ d = float(np.linalg.norm(p - proj))
+ if d < best_d:
+ best_d = d
+ best_s = float(self.cum[i] + t * self.seg_len[i])
+ self.progress = max(self.progress, i)
+ return best_s, best_d
+
+ def _point_at_s(self, s: float) -> np.ndarray:
+ s = float(np.clip(s, 0.0, self.total_len))
+ i = int(np.searchsorted(self.cum, s, side="right") - 1)
+ i = int(np.clip(i, 0, len(self.seg_len) - 1))
+ local = 0.0 if self.seg_len[i] < 1e-9 else (s - self.cum[i]) / self.seg_len[i]
+ return self.points[i] + local * (self.points[i + 1] - self.points[i])
+
+ def update(self, sensor: SensorFrame) -> IkCommand:
+ p = sensor.base_pos[:2].astype(np.float64)
+ s, dist_path = self._project_s(p)
+ self.closest_distance = min(self.closest_distance, dist_path)
+ goal_dist = float(np.linalg.norm(self.points[-1] - p))
+ if self.total_len - s < self.args.arrive_radius and goal_dist < self.args.arrive_radius:
+ self.finished = True
+ return IkCommand(0.0, 0.0, len(self.points) - 1, goal_dist)
+
+ lookahead = np.clip(
+ self.args.lookahead_base + self.args.lookahead_time * abs(self.args.speed),
+ self.args.lookahead_min,
+ self.args.lookahead_max,
+ )
+ target = self._point_at_s(s + lookahead)
+ dx, dy = target - p
+ yaw = float(sensor.rpy[2])
+ c, sn = math.cos(yaw), math.sin(yaw)
+ x_body = c * dx + sn * dy
+ y_body = -sn * dx + c * dy
+ ld2 = max(lookahead * lookahead, 1e-6)
+ curvature = 2.0 * y_body / ld2
+
+ abs_curv = abs(curvature)
+ if abs_curv > 1e-6:
+ v_curve = math.sqrt(max(self.args.max_lat_acc, 1e-6) / abs_curv)
+ else:
+ v_curve = self.args.speed
+ speed = min(self.args.speed, v_curve)
+ speed = max(self.args.min_path_speed, speed)
+ if x_body < -0.05 and self.args.allow_reverse:
+ speed = -speed
+ yaw_rate = float(np.clip(speed * curvature, -self.args.max_yaw_rate, self.args.max_yaw_rate))
+ return IkCommand(float(speed), yaw_rate, self.progress, dist_path)
+
+
+def args_sign(x: float) -> float:
+ return -1.0 if x < -0.05 else 1.0
+
+
+def test_command(test_name: str, elapsed: float, sensor: SensorFrame, follower: PathFollower | None, args: argparse.Namespace) -> IkCommand:
+ if test_name == "forward":
+ return IkCommand(args.speed, 0.0)
+ if test_name == "yaw":
+ return IkCommand(0.0, args.yaw_rate)
+ if test_name == "arc":
+ return IkCommand(args.speed, args.yaw_rate)
+ if follower is None:
+ raise RuntimeError(f"Test {test_name} needs a path follower")
+ del elapsed
+ return follower.update(sensor)
+
+
+def waypoints_for_test(test_name: str, args: argparse.Namespace) -> list[tuple[float, float]]:
+ sx = float(args.start_x)
+ sy = float(args.start_y)
+ if test_name == "waypoint":
+ return [
+ (sx + 0.3, sy),
+ (sx + 0.65, sy),
+ (sx + 1.0, sy),
+ ]
+ if test_name == "slalom":
+ local = [
+ (0.0, 0.0),
+ (0.8, -0.5),
+ (1.9, -0.95),
+ (2.8, -1.0),
+ (2.85, -1.8),
+ (2.75, -2.05),
+ (1.05, -2.0),
+ (0.9, -2.8),
+ (1.05, -3.0),
+ (2.75, -3.0),
+ (2.75, -3.72),
+ (2.25, -3.85),
+ (1.4, -4.25),
+ (1.4, -2.55),
+ (0.45, -3.42),
+ (0.0, 0.0),
+ ]
+ return [(sx + x, sy + y) for x, y in local]
+ return []
+
+
+@dataclass
+class Metrics:
+ start_pos: np.ndarray
+ start_yaw: float
+ start_time: float = 0.0
+ max_abs_roll: float = 0.0
+ max_abs_pitch: float = 0.0
+ max_tilt: float = 0.0
+ max_leg_error: float = 0.0
+ max_wheel_air_height: float = -1e9
+ min_wheel_air_height: float = 1e9
+ wheel_speed_error_sum: float = 0.0
+ wheel_speed_error_count: int = 0
+ max_wheel_speed: float = 0.0
+ max_gyro_z: float = 0.0
+ body_vx_sum: float = 0.0
+ gyro_z_sum: float = 0.0
+ yaw_unwrapped: float = 0.0
+ velocity_count: int = 0
+ last_yaw: float = field(init=False)
+
+ def __post_init__(self) -> None:
+ self.last_yaw = float(self.start_yaw)
+
+ def update(
+ self,
+ sensor: SensorFrame,
+ model: mujoco.MjModel,
+ data: mujoco.MjData,
+ ids: ModelIds,
+ geometry: WheelGeometry,
+ wheel_target: np.ndarray,
+ leg_target: np.ndarray,
+ ) -> None:
+ roll, pitch, _ = sensor.rpy
+ self.max_abs_roll = max(self.max_abs_roll, abs(float(roll)))
+ self.max_abs_pitch = max(self.max_abs_pitch, abs(float(pitch)))
+ self.max_tilt = max(self.max_tilt, math.hypot(float(roll), float(pitch)))
+ self.max_leg_error = max(
+ self.max_leg_error,
+ float(np.max(np.abs(sensor.joint_pos[:12] - leg_target))),
+ )
+
+ wheel_center_z = data.xipos[ids.wheel_body, 2]
+ floor_air_height = wheel_center_z - geometry.radius
+ self.max_wheel_air_height = max(self.max_wheel_air_height, float(np.max(floor_air_height)))
+ self.min_wheel_air_height = min(self.min_wheel_air_height, float(np.min(floor_air_height)))
+
+ actual = sensor.wheel_vel
+ self.wheel_speed_error_sum += float(np.mean(np.abs(actual - wheel_target)))
+ self.wheel_speed_error_count += 1
+ self.max_wheel_speed = max(self.max_wheel_speed, float(np.max(np.abs(actual))))
+ self.max_gyro_z = max(self.max_gyro_z, abs(float(sensor.gyro[2])))
+ rot = quat_to_mat_wxyz(sensor.quat_wxyz)
+ body_vel = rot.T @ data.qvel[:3]
+ yaw_now = float(sensor.rpy[2])
+ self.yaw_unwrapped += wrap_pi(yaw_now - self.last_yaw)
+ self.last_yaw = yaw_now
+ self.body_vx_sum += float(body_vel[0])
+ self.gyro_z_sum += float(sensor.gyro[2])
+ self.velocity_count += 1
+ del model
+
+ def as_dict(self, sensor: SensorFrame, reached: int, total: int, reason: str, success: bool) -> dict:
+ yaw_change = wrap_pi(float(sensor.rpy[2]) - self.start_yaw)
+ delta = sensor.base_pos[:2] - self.start_pos[:2]
+ mean_wheel_err = self.wheel_speed_error_sum / max(1, self.wheel_speed_error_count)
+ mean_body_vx = self.body_vx_sum / max(1, self.velocity_count)
+ elapsed = max(1e-6, float(sensor.time) - float(self.start_time))
+ mean_yaw_rate = self.yaw_unwrapped / elapsed
+ mean_gyro_z = self.gyro_z_sum / max(1, self.velocity_count)
+ return {
+ "success": bool(success),
+ "reason": reason,
+ "sim_time": round(sensor.time, 4),
+ "start_xy": [round(float(v), 4) for v in self.start_pos[:2]],
+ "final_xy": [round(float(v), 4) for v in sensor.base_pos[:2]],
+ "delta_xy": [round(float(v), 4) for v in delta],
+ "final_z": round(float(sensor.base_pos[2]), 4),
+ "yaw_change_deg": round(math.degrees(yaw_change), 3),
+ "final_yaw_deg": round(math.degrees(float(sensor.rpy[2])), 3),
+ "max_roll_deg": round(math.degrees(self.max_abs_roll), 3),
+ "max_pitch_deg": round(math.degrees(self.max_abs_pitch), 3),
+ "max_tilt_deg": round(math.degrees(self.max_tilt), 3),
+ "max_leg_encoder_error_rad": round(self.max_leg_error, 5),
+ "mean_wheel_speed_error_rad_s": round(mean_wheel_err, 5),
+ "max_wheel_speed_rad_s": round(self.max_wheel_speed, 5),
+ "mean_body_vx_mps": round(mean_body_vx, 5),
+ "mean_yaw_rate_rad_s": round(mean_yaw_rate, 5),
+ "mean_imu_gyro_z_rad_s": round(mean_gyro_z, 5),
+ "min_wheel_air_height_m": round(self.min_wheel_air_height, 5),
+ "max_wheel_air_height_m": round(self.max_wheel_air_height, 5),
+ "max_imu_gyro_z_rad_s": round(self.max_gyro_z, 5),
+ "reached_waypoints": int(reached),
+ "total_waypoints": int(total),
+ }
+
+
+class InteractivePanel:
+ def __init__(self, args: argparse.Namespace) -> None:
+ try:
+ import tkinter as tk
+ from tkinter import ttk
+ except ImportError as exc:
+ raise RuntimeError("Interactive mode needs tkinter, but it is not available in this Python.") from exc
+
+ self.tk = tk
+ self.alive = True
+ self.last_update = 0.0
+ self.root = tk.Tk()
+ self.root.title("Wheel-Leg IK Control")
+ self.root.protocol("WM_DELETE_WINDOW", self.close)
+
+ self.vx = tk.DoubleVar(value=float(args.speed))
+ self.yaw = tk.DoubleVar(value=float(args.yaw_rate))
+ self.status_vars: dict[str, object] = {}
+
+ main = ttk.Frame(self.root, padding=10)
+ main.grid(row=0, column=0, sticky="nsew")
+ self.root.columnconfigure(0, weight=1)
+ main.columnconfigure(1, weight=1)
+
+ self._add_slider(main, 0, "linear x m/s", self.vx, args.interactive_vx_limit)
+ self._add_slider(main, 1, "yaw z rad/s", self.yaw, args.interactive_yaw_limit)
+
+ buttons = ttk.Frame(main)
+ buttons.grid(row=2, column=0, columnspan=3, sticky="ew", pady=(6, 10))
+ ttk.Button(buttons, text="Zero", command=self.zero).pack(side="left", padx=(0, 6))
+ ttk.Button(buttons, text="Quit", command=self.close).pack(side="left")
+
+ metrics = ttk.LabelFrame(main, text="State", padding=8)
+ metrics.grid(row=3, column=0, columnspan=3, sticky="nsew")
+ metrics.columnconfigure(1, weight=1)
+ for row, key in enumerate(
+ [
+ "cmd",
+ "pos",
+ "rpy",
+ "body_vel",
+ "imu",
+ "wheel",
+ "wheel_air",
+ "leg",
+ "limits",
+ ]
+ ):
+ ttk.Label(metrics, text=key).grid(row=row, column=0, sticky="w", padx=(0, 8))
+ var = tk.StringVar(value="-")
+ self.status_vars[key] = var
+ ttk.Label(metrics, textvariable=var, width=48).grid(row=row, column=1, sticky="w")
+
+ def _add_slider(self, parent: object, row: int, label: str, var: object, limit: float) -> None:
+ ttk = __import__("tkinter.ttk").ttk
+ ttk.Label(parent, text=label).grid(row=row, column=0, sticky="w")
+ scale = ttk.Scale(parent, from_=-limit, to=limit, variable=var, orient="horizontal")
+ scale.grid(row=row, column=1, sticky="ew", padx=8)
+ value = ttk.Label(parent, width=8)
+ value.grid(row=row, column=2, sticky="e")
+
+ def refresh_value(*_: object) -> None:
+ value.configure(text=f"{float(var.get()): .2f}")
+
+ var.trace_add("write", refresh_value)
+ refresh_value()
+
+ def zero(self) -> None:
+ self.vx.set(0.0)
+ self.yaw.set(0.0)
+
+ def close(self) -> None:
+ self.alive = False
+
+ def command(self) -> IkCommand:
+ return IkCommand(float(self.vx.get()), float(self.yaw.get()))
+
+ def update_events(self) -> bool:
+ if not self.alive:
+ return False
+ try:
+ self.root.update_idletasks()
+ self.root.update()
+ except self.tk.TclError:
+ self.alive = False
+ return self.alive
+
+ def update_metrics(
+ self,
+ sensor: SensorFrame,
+ data: mujoco.MjData,
+ ids: ModelIds,
+ geometry: WheelGeometry,
+ wheel_target: np.ndarray,
+ leg_target: np.ndarray,
+ metrics: Metrics,
+ args: argparse.Namespace,
+ ) -> None:
+ if sensor.time - self.last_update < args.ui_update_dt:
+ return
+ self.last_update = sensor.time
+
+ rot = quat_to_mat_wxyz(sensor.quat_wxyz)
+ body_vel = rot.T @ data.qvel[:3]
+ wheel_air = data.xipos[ids.wheel_body, 2] - geometry.radius
+ wheel_err = float(np.mean(np.abs(sensor.wheel_vel - wheel_target)))
+ leg_err = float(np.max(np.abs(sensor.joint_pos[:12] - leg_target)))
+ tilt = math.hypot(float(sensor.rpy[0]), float(sensor.rpy[1]))
+
+ values = {
+ "cmd": f"x={self.vx.get(): .2f} m/s, yaw={self.yaw.get(): .2f} rad/s",
+ "pos": f"x={sensor.base_pos[0]: .3f}, y={sensor.base_pos[1]: .3f}, z={sensor.base_pos[2]: .3f}",
+ "rpy": (
+ f"roll={math.degrees(sensor.rpy[0]): .2f}, "
+ f"pitch={math.degrees(sensor.rpy[1]): .2f}, "
+ f"yaw={math.degrees(sensor.rpy[2]): .2f} deg"
+ ),
+ "body_vel": f"vx={body_vel[0]: .3f}, vy={body_vel[1]: .3f}, vz={body_vel[2]: .3f} m/s",
+ "imu": f"gyro_z={sensor.gyro[2]: .3f} rad/s, tilt={math.degrees(tilt): .2f} deg",
+ "wheel": (
+ f"target=[{', '.join(f'{v: .1f}' for v in wheel_target)}], "
+ f"err={wheel_err: .3f} rad/s"
+ ),
+ "wheel_air": f"min={np.min(wheel_air): .4f}, max={np.max(wheel_air): .4f} m",
+ "leg": f"encoder_err={leg_err: .4f} rad, max={metrics.max_leg_error: .4f} rad",
+ "limits": (
+ f"max_tilt={math.degrees(metrics.max_tilt): .2f} deg, "
+ f"max_wheel_speed={metrics.max_wheel_speed: .2f} rad/s"
+ ),
+ }
+ for key, value in values.items():
+ self.status_vars[key].set(value)
+
+ def destroy(self) -> None:
+ try:
+ self.root.destroy()
+ except self.tk.TclError:
+ pass
+
+
+def run_one(test_name: str, args: argparse.Namespace) -> dict:
+ tmp_root = Path(args.tmp_dir) if args.tmp_dir else Path(tempfile.gettempdir())
+ with tempfile.TemporaryDirectory(prefix="ik_slalom_", dir=str(tmp_root)) as tmp:
+ model, data, ids = build_model(args, Path(tmp))
+ default_dof_pos = reset_robot(model, data, ids, args)
+ geometry = infer_wheel_geometry(model, data, ids)
+ wheel_signs = np.array(args.wheel_signs, dtype=np.float64)
+ ik = DifferentialWheelIk(geometry, args.max_wheel_speed, wheel_signs, args.yaw_wheel_gain, args)
+ leg_ik = LegStanceIk(model, data, ids, args) if args.leg_ik else None
+ posture = TablePostureController(args, default_dof_pos) if leg_ik is None else None
+ path = waypoints_for_test(test_name, args)
+ if path and args.path_follower == "pure":
+ follower = PurePursuitFollower(path, args)
+ else:
+ follower = PathFollower(path, args.speed, args.max_yaw_rate, args.arrive_radius, args.allow_reverse) if path else None
+
+ sensor0 = read_sensors(model, data, ids)
+ metrics = Metrics(
+ start_pos=sensor0.base_pos.copy(),
+ start_yaw=float(sensor0.rpy[2]),
+ start_time=float(sensor0.time),
+ )
+
+ control_steps = max(1, int(round(args.control_dt / model.opt.timestep)))
+ total_steps = int(round(args.duration / model.opt.timestep))
+ wheel_target = np.zeros(4, dtype=np.float64)
+ leg_target = default_dof_pos[:12].copy()
+ reason = "timeout"
+ success = False
+
+ viewer = mujoco.viewer.launch_passive(model, data) if args.viewer else None
+ panel = InteractivePanel(args) if args.interactive else None
+ try:
+ for step in range(total_steps):
+ if panel is not None and not panel.update_events():
+ reason = "interactive panel closed"
+ break
+ if viewer is not None and not viewer.is_running():
+ reason = "viewer closed"
+ break
+
+ if step % control_steps == 0:
+ sensor = read_sensors(model, data, ids)
+ cmd = panel.command() if panel is not None else test_command(test_name, step * model.opt.timestep, sensor, follower, args)
+ raw_wheel_target = ik.solve(cmd, sensor)
+ if leg_ik is not None:
+ leg_target = leg_ik.compute(data, sensor)
+ elif posture is not None:
+ leg_target = posture.compute(sensor)
+ raw_wheel_target *= sensor_command_scale(sensor, leg_target, args)
+ max_wheel_delta = args.wheel_accel_limit * args.control_dt
+ wheel_target = wheel_target + np.clip(raw_wheel_target - wheel_target, -max_wheel_delta, max_wheel_delta)
+ data.ctrl[ids.ctrl[:12]] = leg_target
+ data.ctrl[ids.ctrl[12:]] = wheel_target
+
+ if follower is not None and follower.done:
+ reason = "path complete"
+ success = True
+ break
+
+ mujoco.mj_step(model, data)
+ if step % control_steps == 0:
+ sensor = read_sensors(model, data, ids)
+ metrics.update(sensor, model, data, ids, geometry, wheel_target, leg_target)
+ if panel is not None:
+ panel.update_metrics(sensor, data, ids, geometry, wheel_target, leg_target, metrics, args)
+ if viewer is not None:
+ viewer.sync()
+
+ if viewer is not None and args.realtime:
+ time.sleep(model.opt.timestep * args.realtime_scale)
+ finally:
+ if panel is not None:
+ panel.destroy()
+ if viewer is not None:
+ viewer.close()
+
+ final_sensor = read_sensors(model, data, ids)
+ reached = follower.idx if follower is not None else 0
+ total = len(path)
+
+ stable = (
+ metrics.max_tilt < math.radians(args.max_tilt_deg)
+ and metrics.max_leg_error < args.max_leg_error
+ and metrics.max_wheel_air_height < args.max_wheel_air_height
+ )
+
+ if args.interactive:
+ success = stable
+ reason = reason if reason != "timeout" else "interactive duration elapsed"
+ elif test_name == "forward":
+ success = final_sensor.base_pos[0] - metrics.start_pos[0] > max(0.25, 0.35 * args.speed * args.duration) and stable
+ reason = "forward displacement ok" if success else reason
+ elif test_name == "yaw":
+ success = abs(wrap_pi(final_sensor.rpy[2] - metrics.start_yaw)) > max(0.15, 0.14 * abs(args.yaw_rate) * args.duration) and stable
+ reason = "yaw change ok" if success else reason
+ elif test_name == "arc":
+ delta = np.linalg.norm(final_sensor.base_pos[:2] - metrics.start_pos[:2])
+ success = (
+ delta > max(0.25, 0.25 * args.speed * args.duration)
+ and abs(wrap_pi(final_sensor.rpy[2] - metrics.start_yaw)) > 0.15
+ and stable
+ )
+ reason = "arc displacement and yaw ok" if success else reason
+ elif test_name in {"waypoint", "slalom"}:
+ success = follower is not None and follower.done and stable
+ reason = "path complete" if success else reason
+
+ out = metrics.as_dict(final_sensor, reached, total, reason, success)
+ out.update(
+ {
+ "test": test_name,
+ "wheel_radius_m": round(geometry.radius, 5),
+ "track_width_m": round(geometry.track_width, 5),
+ "wheel_body_y_m": [round(float(v), 5) for v in geometry.wheel_y],
+ "wheel_signs": [float(v) for v in wheel_signs],
+ "wheel_model": args.wheel_model,
+ "linear_wheel_gain": float(args.linear_wheel_gain),
+ "direct_yaw_wheel_gain": float(args.direct_yaw_wheel_gain),
+ "yaw_wheel_gain": float(args.yaw_wheel_gain),
+ "stable": bool(stable),
+ "leg_ik": bool(args.leg_ik),
+ "posture": args.posture,
+ "body_height": float(args.body_height),
+ "imu_posture": bool(args.imu_posture),
+ "yaw_rate_kp": float(args.yaw_rate_kp),
+ "encoder_posture_kp": float(args.encoder_posture_kp),
+ "encoder_guard": bool(args.encoder_guard),
+ "imu_guard": bool(args.imu_guard),
+ "posture_gain": float(args.posture_gain),
+ "path_follower": args.path_follower,
+ "default_pose": (
+ "rough_rl"
+ if args.posture == "rough"
+ else "custom"
+ if args.posture == "custom"
+ else "mujoco_sim_height_table"
+ ),
+ }
+ )
+ return out
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--test", choices=("forward", "yaw", "arc", "waypoint", "slalom", "all"), default="forward")
+ parser.add_argument("--duration", type=float, default=8.0)
+ parser.add_argument("--settle", type=float, default=1.5)
+ parser.add_argument("--control-dt", type=float, default=0.02)
+ parser.add_argument("--speed", type=float, default=0.35)
+ parser.add_argument("--yaw-rate", type=float, default=0.25)
+ parser.add_argument("--max-yaw-rate", type=float, default=0.45)
+ parser.add_argument("--max-wheel-speed", type=float, default=10.0)
+ parser.add_argument("--wheel-model", choices=("diff", "direct"), default="diff")
+ parser.add_argument("--linear-wheel-gain", type=float, default=12.5)
+ parser.add_argument("--direct-yaw-wheel-gain", type=float, default=8.0)
+ parser.add_argument("--yaw-wheel-gain", type=float, default=2.2)
+ parser.add_argument("--yaw-rate-kp", type=float, default=0.0)
+ parser.add_argument("--max-yaw-wheel-speed", type=float, default=1.65)
+ parser.add_argument("--wheel-accel-limit", type=float, default=28.0)
+ parser.add_argument("--max-tilt-deg", type=float, default=25.0)
+ parser.add_argument("--max-leg-error", type=float, default=0.65)
+ parser.add_argument("--max-wheel-air-height", type=float, default=0.08)
+ parser.add_argument("--posture", choices=("rough", "table", "custom"), default="rough")
+ parser.add_argument("--body-height", type=float, default=0.37)
+ parser.add_argument("--custom-abduction", type=float, default=0.0)
+ parser.add_argument("--custom-hip", type=float, default=0.55)
+ parser.add_argument("--custom-knee", type=float, default=-1.30)
+ parser.add_argument("--imu-posture", action=argparse.BooleanOptionalAction, default=True)
+ parser.add_argument("--roll-comp-gain", type=float, default=0.35)
+ parser.add_argument("--pitch-comp-gain", type=float, default=0.35)
+ parser.add_argument("--encoder-posture-kp", type=float, default=0.15)
+ parser.add_argument("--encoder-posture-max", type=float, default=0.03)
+ parser.add_argument("--encoder-guard", action=argparse.BooleanOptionalAction, default=True)
+ parser.add_argument("--encoder-guard-start", type=float, default=0.28)
+ parser.add_argument("--encoder-guard-stop", type=float, default=0.65)
+ parser.add_argument("--imu-guard", action=argparse.BooleanOptionalAction, default=True)
+ parser.add_argument("--imu-guard-start-deg", type=float, default=12.0)
+ parser.add_argument("--imu-guard-stop-deg", type=float, default=28.0)
+ parser.add_argument("--leg-ik", action=argparse.BooleanOptionalAction, default=False)
+ parser.add_argument("--posture-gain", type=float, default=0.65)
+ parser.add_argument("--leg-ik-iterations", type=int, default=3)
+ parser.add_argument("--leg-ik-damping", type=float, default=0.02)
+ parser.add_argument("--leg-ik-step", type=float, default=0.035)
+ parser.add_argument("--max-leg-offset", type=float, default=0.28)
+ parser.add_argument("--leg-target-rate", type=float, default=0.035)
+ parser.add_argument("--leg-target-filter", type=float, default=0.45)
+ parser.add_argument("--arrive-radius", type=float, default=0.20)
+ parser.add_argument("--allow-reverse", action=argparse.BooleanOptionalAction, default=True)
+ parser.add_argument("--path-follower", choices=("waypoint", "pure"), default="pure")
+ parser.add_argument("--lookahead-base", type=float, default=0.45)
+ parser.add_argument("--lookahead-time", type=float, default=0.35)
+ parser.add_argument("--lookahead-min", type=float, default=0.35)
+ parser.add_argument("--lookahead-max", type=float, default=0.90)
+ parser.add_argument("--max-lat-acc", type=float, default=0.45)
+ parser.add_argument("--min-path-speed", type=float, default=0.25)
+ parser.add_argument("--pure-search-segments", type=int, default=4)
+ parser.add_argument("--start-x", type=float, default=0.0)
+ parser.add_argument("--start-y", type=float, default=0.0)
+ parser.add_argument("--start-z", type=float, default=0.6)
+ parser.add_argument(
+ "--terrain-xml",
+ type=Path,
+ default=None,
+ help="Optional terrain scene XML. Omit it to use a generated flat ground scene.",
+ )
+ parser.add_argument("--robot-xml", type=Path, default=PROJECT_ROOT / "mjcf" / "wheelleg.xml")
+ parser.add_argument("--tmp-dir", type=Path, default=Path("D:/tmp") if Path("D:/tmp").exists() else None)
+ parser.add_argument("--interactive", action="store_true", help="Open a live command/metrics panel and drive manually.")
+ parser.add_argument("--interactive-vx-limit", type=float, default=1.0)
+ parser.add_argument("--interactive-yaw-limit", type=float, default=1.0)
+ parser.add_argument("--ui-update-dt", type=float, default=0.10)
+ parser.add_argument("--viewer", action="store_true", help="Open MuJoCo passive viewer while running the test.")
+ parser.add_argument("--realtime", action=argparse.BooleanOptionalAction, default=True)
+ parser.add_argument("--realtime-scale", type=float, default=1.0)
+ parser.add_argument(
+ "--wheel-signs",
+ type=float,
+ nargs=4,
+ default=[1.0, 1.0, 1.0, 1.0],
+ metavar=("FL", "FR", "RL", "RR"),
+ help="Per-wheel velocity sign multipliers in joint order.",
+ )
+ return parser.parse_args()
+
+
+def main() -> int:
+ args = parse_args()
+ if args.interactive:
+ args.viewer = True
+ args.duration = max(args.duration, 3600.0)
+ tests = ["forward", "yaw", "arc", "waypoint", "slalom"] if args.test == "all" else [args.test]
+ results = [run_one(name, args) for name in tests]
+ print(json.dumps(results[0] if len(results) == 1 else results, indent=2, ensure_ascii=False))
+ return 0 if all(r["success"] for r in results) else 2
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/05_software/train/rc_mjlab/sim2sim/interface/mujoco_io.py b/05_software/train/rc_mjlab/sim2sim/interface/mujoco_io.py
index 276aa64..a7447f7 100644
--- a/05_software/train/rc_mjlab/sim2sim/interface/mujoco_io.py
+++ b/05_software/train/rc_mjlab/sim2sim/interface/mujoco_io.py
@@ -87,9 +87,14 @@ class MuJoCoIO:
return out_xml_path
def _rebuild_actuators(self, spec):
- actuators_to_delete = list(spec.actuators)
- for act in actuators_to_delete:
- spec.delete(act)
+ if hasattr(spec, "delete"):
+ actuators_to_delete = list(spec.actuators)
+ for act in actuators_to_delete:
+ spec.delete(act)
+ else:
+ actuators_to_delete = list(spec.actuators)
+ for act in actuators_to_delete:
+ act.delete()
# Keep sim2sim aligned with the training robot config and sim2real runtime:
# leg position PD = (50.0, 1.5), wheel velocity damping = 1.0.
diff --git a/05_software/train/rc_mjlab/sim2sim/nav_route_sim2sim_check.py b/05_software/train/rc_mjlab/sim2sim/nav_route_sim2sim_check.py
new file mode 100644
index 0000000..4425482
--- /dev/null
+++ b/05_software/train/rc_mjlab/sim2sim/nav_route_sim2sim_check.py
@@ -0,0 +1,2776 @@
+#!/usr/bin/env python3
+"""Batch sim2sim route validation for nav_tools waypoints.
+
+This script runs the MuJoCo robot with an ONNX locomotion policy and a simple
+waypoint follower, then reports whether the route can be completed without
+falling or entering avoid-region clearance.
+
+Example:
+ uv run python sim2sim/nav_route_sim2sim_check.py \
+ --terrain-xml tools/nav_tools/xml/A.xml \
+ --points tools/nav_tools/points/points_20260705_174627.json \
+ --onnx model_6800.onnx \
+ --start-yaw-offset-deg -180 \
+ --heading-offset-deg 180
+"""
+
+from __future__ import annotations
+
+import argparse
+import csv
+import json
+import math
+import os
+import pkgutil
+import random
+import sys
+import time
+import xml.etree.ElementTree as ET
+from collections import deque
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+import mujoco
+import mujoco.viewer
+import numpy as np
+import onnxruntime as ort
+import torch
+
+
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
+SIM2SIM_DIR = Path(__file__).resolve().parent
+if str(SIM2SIM_DIR) not in sys.path:
+ sys.path.insert(0, str(SIM2SIM_DIR))
+if str(PROJECT_ROOT) not in sys.path:
+ sys.path.insert(0, str(PROJECT_ROOT))
+
+from interface.mujoco_io import MuJoCoIO # noqa: E402
+from policy.policy_runner import PolicyRunner # noqa: E402
+from tools.nav_tools.route_safety_check import ( # noqa: E402
+ AvoidRegion,
+ Waypoint,
+ default_lateral_footprint_radius,
+ load_regions,
+ load_waypoints,
+ point_in_polygon,
+ point_segment_distance,
+)
+
+
+ACTION_SCALE = np.array(
+ [0.125, 0.25, 0.25] * 4 + [5.0, 5.0, 5.0, 5.0],
+ dtype=np.float32,
+)
+DEPLOY_DEFAULT_DOF_POS = np.array([0.0, 0.550, -1.125] * 4 + [0.0] * 4, dtype=np.float32)
+ROBOT_BODY_LENGTH = 0.356
+ROBOT_BODY_WIDTH = 0.235
+ROBOT_BODY_CENTER_X = 0.1518
+ROBOT_ORIGIN_FROM_FRONT = 0.105
+ROBOT_BODY_CENTER_OFFSET_X = ROBOT_ORIGIN_FROM_FRONT - ROBOT_BODY_LENGTH * 0.5
+ROBOT_WHEEL_VIS_LENGTH = 0.16
+ROBOT_WHEEL_VIS_WIDTH = 0.055
+ROBOT_WHEEL_POSITIONS = {
+ "fl": ((0.32826 + 0.06389) - ROBOT_BODY_CENTER_X, 0.066172 - 0.027344, 0.1035, 0.014699, 0.04074, 0.0),
+ "fr": ((0.32826 + 0.06389) - ROBOT_BODY_CENTER_X, -0.065853 + 0.027311, -0.1035, -0.018447, -0.040735, -0.00075079),
+ "rl": ((-0.024743 - 0.06389) - ROBOT_BODY_CENTER_X, 0.066141 - 0.027309, 0.099459, 0.012475, 0.040737, 0.0),
+ "rr": ((-0.024743 - 0.06389) - ROBOT_BODY_CENTER_X, -0.065884 + 0.027341, -0.099408, -0.012435, -0.040737, -0.00075079),
+}
+
+
+@dataclass
+class SimConfig:
+ control_hz: float = 50.0
+ max_vx: float = 1.2
+ max_vy: float = 0.0
+ max_wz: float = 0.8
+ kp_dist: float = 0.8
+ kp_yaw: float = 1.8
+ yaw_stop_threshold_deg: float = 45.0
+ turn_in_place_enter_deg: float = 70.0
+ turn_in_place_exit_deg: float = 18.0
+ turn_in_place_max_wz: float = 0.8
+ final_align_max_wz: float = 0.45
+ final_align_kp_scale: float = 0.6
+ waypoint_timeout_s: float = 30.0
+ max_total_time_s: float = 420.0
+ stable_cycles: int = 2
+ lookahead_m: float = 0.45
+ min_cmd_vx: float = 0.08
+ creep_cmd_vx: float = 0.04
+ cmd_vx_scale: float = 1.0
+ stuck_timeout_s: float = 4.0
+ stuck_progress_m: float = 0.08
+ recovery_duration_s: float = 2.0
+ max_recoveries: int = 4
+ slalom_script_enabled: bool = True
+ slalom_script_start_tolerance: float = 0.10
+ slalom_script_pos_tolerance: float = 0.08
+ slalom_script_yaw_tolerance_deg: float = 5.0
+ slalom_script_stable_cycles: int = 1
+ slalom_script_rotate_steps_enabled: bool = False
+ slalom_script_final_rotate_enabled: bool = False
+ slalom_script_require_yaw_at_step: bool = False
+ slalom_script_kp_dist: float = 1.0
+ slalom_script_kp_yaw: float = 1.6
+ slalom_script_max_vx: float = 0.60
+ slalom_script_max_vy: float = 0.50
+ slalom_script_max_wz: float = 0.50
+ slalom_script_min_cmd_linear: float = 0.2
+ slalom_script_min_cmd_angular: float = 0.0
+ slalom_script_min_cmd_epsilon: float = 0.05
+ slalom_script_min_step_distance: float = 0.02
+ slalom_script_yaw_gate_deg: float = 25.0
+ slalom_script_drive_yaw_source: str = "segment"
+ slalom_script_curvature_speed_enabled: bool = True
+ slalom_script_curvature_min_scale: float = 0.55
+
+
+@dataclass(frozen=True)
+class SlalomScriptStep:
+ kind: str
+ start_index: int
+ end_index: int
+ target_x: float
+ target_y: float
+ target_yaw: float | None
+ pos_tolerance: float | None = None
+ forward: float = 0.0
+ left: float = 0.0
+
+
+@dataclass
+class SimResult:
+ success: bool
+ reason: str
+ sim_time: float
+ reached_count: int
+ waypoint_count: int
+ min_clearance: float
+ min_margin: float
+ min_clearance_region: str
+ min_clearance_wp: str
+ max_roll_deg: float
+ max_pitch_deg: float
+ max_tilt_deg: float
+ samples: int
+ wall_time: float
+ real_time_factor: float
+
+
+@dataclass
+class RouteSnapshot:
+ time: float
+ qpos: np.ndarray
+ qvel: np.ndarray
+ ctrl: np.ndarray
+ follower_index: int
+ follower_turn_in_place: bool
+ follower_stable_count: int
+ follower_wp_start_time: float
+ follower_best_dist: float | None
+ follower_last_progress_time: float | None
+ follower_recovery_until: float | None
+ follower_recovery_count: int | None
+ follower_recovery_turn_sign: float | None
+
+
+class OnnxPolicy:
+ def __init__(self, path: Path) -> None:
+ providers = ["CPUExecutionProvider"]
+ self.session = ort.InferenceSession(str(path), providers=providers)
+ self.input_name = self.session.get_inputs()[0].name
+ self.output_name = self.session.get_outputs()[0].name
+ shape = self.session.get_inputs()[0].shape
+ dim = shape[1] if len(shape) >= 2 else 53
+ self.obs_dim = 53 if isinstance(dim, str) else int(dim)
+ self.last_actions = np.zeros(16, dtype=np.float32)
+
+ def reset(self) -> None:
+ self.last_actions[:] = 0.0
+
+ def step(self, obs_53d: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
+ if self.obs_dim == obs_53d.shape[0]:
+ model_input = obs_53d
+ else:
+ raise RuntimeError(
+ f"ONNX expects obs dim {self.obs_dim}, but this checker provides {obs_53d.shape[0]}"
+ )
+ raw = self.session.run(
+ [self.output_name],
+ {self.input_name: model_input.astype(np.float32)[None, :]},
+ )[0][0].astype(np.float32)
+ raw = np.clip(raw, -100.0, 100.0)
+ self.last_actions = raw.copy()
+ return raw * ACTION_SCALE, raw
+
+
+class ExistingPolicyBackend:
+ """Adapter around sim2sim/policy/PolicyRunner to match nav_sim2sim.py."""
+
+ def __init__(self, path: Path, crawl_path: Path | None = None) -> None:
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
+ policy_path: Path | dict[str, Path]
+ if crawl_path is not None:
+ policy_path = {"rough": path, "crawl": crawl_path}
+ else:
+ policy_path = path
+ self.runner = PolicyRunner(policy_path, device)
+ self.runner.reset()
+
+ @property
+ def default_dof_pos(self) -> np.ndarray:
+ return self.runner.default_dof_pos
+
+ @property
+ def last_actions(self) -> np.ndarray:
+ return self.runner.last_actions
+
+ def reset(self) -> None:
+ self.runner.reset()
+
+ def maybe_switch_policy(self, policy_name: str | None) -> None:
+ if not policy_name:
+ return
+ if policy_name == self.runner.current_policy_name:
+ return
+ if self.runner.transition_in_progress and policy_name == self.runner.current_policy_name:
+ return
+ if policy_name in self.runner.policies:
+ self.runner.trigger_transition(policy_name)
+
+ def step(self, obs_53d: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
+ return self.runner.step(obs_53d)
+
+ def close(self) -> None:
+ listener = getattr(self.runner, "listener", None)
+ if listener is not None:
+ listener.stop()
+
+
+class RoughPolicyIkCrawlBackend(ExistingPolicyBackend):
+ def __init__(self, path: Path) -> None:
+ super().__init__(path, None)
+ self.mode = "rough"
+ self.requested_mode = "rough"
+ self.crawl_default_dof_pos = np.array(
+ [
+ 0.2, 1.697, -2.650,
+ -0.2, 1.697, -2.650,
+ 0.2, 1.697, -2.650,
+ -0.2, 1.697, -2.650,
+ 0.0, 0.0, 0.0, 0.0,
+ ],
+ dtype=np.float32,
+ )
+ self.slalom_ik_default_dof_pos = np.array(
+ [
+ 0.0, 1.697, -2.650,
+ 0.0, 1.697, -2.650,
+ 0.0, 1.697, -2.650,
+ 0.0, 1.697, -2.650,
+ 0.0, 0.0, 0.0, 0.0,
+ ],
+ dtype=np.float32,
+ )
+ self._last_actions = np.zeros(16, dtype=np.float32)
+ self.crawl_ik_wheel_linear_gain = 6.25
+ self.crawl_ik_wheel_yaw_gain = 4.0
+ self.crawl_ik_max_wheel_speed = 6.0
+ self.transition_steps = 60
+ self.transition_step = self.transition_steps
+ self.transition_start_pose = self.runner.default_dof_pos.copy()
+ self.transition_target_pose = self.runner.default_dof_pos.copy()
+
+ def reset(self) -> None:
+ self.runner.reset()
+ self.mode = "rough"
+ self.requested_mode = "rough"
+ self.transition_step = self.transition_steps
+ self.transition_start_pose = self.runner.default_dof_pos.copy()
+ self.transition_target_pose = self.runner.default_dof_pos.copy()
+ self._last_actions[:] = 0.0
+
+ @property
+ def default_dof_pos(self) -> np.ndarray:
+ if self.transition_step < self.transition_steps:
+ alpha = self.transition_step / max(1, self.transition_steps)
+ return (1.0 - alpha) * self.transition_start_pose + alpha * self.transition_target_pose
+ if self.requested_mode == "crawl":
+ return self.crawl_default_dof_pos
+ if self.requested_mode == "slalom_ik":
+ return self.slalom_ik_default_dof_pos
+ return self.runner.default_dof_pos
+
+ @property
+ def last_actions(self) -> np.ndarray:
+ if self.requested_mode in {"crawl", "slalom_ik"}:
+ return self._last_actions
+ return self.runner.last_actions
+
+ def maybe_switch_policy(self, policy_name: str | None) -> None:
+ normalized = (policy_name or "").lower()
+ if normalized in {"slalom", "slalom_ik", "slalomik"}:
+ next_mode = "slalom_ik"
+ elif normalized in {"crawl", "ik"}:
+ next_mode = "crawl"
+ else:
+ next_mode = "rough"
+ if next_mode == self.requested_mode:
+ return
+ self.transition_start_pose = self.default_dof_pos.copy()
+ if next_mode == "crawl":
+ self.transition_target_pose = self.crawl_default_dof_pos.copy()
+ elif next_mode == "slalom_ik":
+ self.transition_target_pose = self.slalom_ik_default_dof_pos.copy()
+ else:
+ self.transition_target_pose = self.runner.default_dof_pos.copy()
+ self.transition_step = 0
+ self.requested_mode = next_mode
+
+ def step(self, obs_53d: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
+ in_transition = self.transition_step < self.transition_steps
+ if in_transition:
+ self.transition_step += 1
+ if self.requested_mode not in {"crawl", "slalom_ik"}:
+ return self.runner.step(obs_53d)
+ cmd = obs_53d[6:9].astype(np.float32)
+ target = (
+ self.slalom_ik_default_dof_pos.copy()
+ if self.requested_mode == "slalom_ik"
+ else self.crawl_default_dof_pos.copy()
+ )
+ left_wheel = float(np.clip(
+ cmd[0] * self.crawl_ik_wheel_linear_gain - cmd[2] * self.crawl_ik_wheel_yaw_gain,
+ -self.crawl_ik_max_wheel_speed,
+ self.crawl_ik_max_wheel_speed,
+ ))
+ right_wheel = float(np.clip(
+ cmd[0] * self.crawl_ik_wheel_linear_gain + cmd[2] * self.crawl_ik_wheel_yaw_gain,
+ -self.crawl_ik_max_wheel_speed,
+ self.crawl_ik_max_wheel_speed,
+ ))
+ alpha = 1.0
+ if in_transition:
+ alpha = min(1.0, self.transition_step / max(1, self.transition_steps))
+ scaled = target.copy()
+ scaled[12:] = [left_wheel, right_wheel, left_wheel, right_wheel]
+ scaled[12:] *= alpha
+ raw = np.zeros(16, dtype=np.float32)
+ raw[12:] = (np.array([left_wheel, right_wheel, left_wheel, right_wheel], dtype=np.float32) * alpha) / 5.0
+ self._last_actions = raw
+ # send_actions adds default_dof_pos to scaled actions, so return leg offsets.
+ scaled[:12] = 0.0
+ return scaled, raw
+
+
+class DirectOnnxBackend:
+ """Lightweight ONNX adapter kept for isolating PolicyRunner effects."""
+
+ def __init__(self, path: Path, default_dof_pos: np.ndarray) -> None:
+ self.policy = OnnxPolicy(path)
+ self._default_dof_pos = default_dof_pos.astype(np.float32)
+
+ @property
+ def default_dof_pos(self) -> np.ndarray:
+ return self._default_dof_pos
+
+ @property
+ def last_actions(self) -> np.ndarray:
+ return self.policy.last_actions
+
+ def reset(self) -> None:
+ self.policy.reset()
+
+ def maybe_switch_policy(self, policy_name: str | None) -> None:
+ _ = policy_name
+
+ def step(self, obs_53d: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
+ return self.policy.step(obs_53d)
+
+ def close(self) -> None:
+ pass
+
+
+class WaypointFollower:
+ def __init__(self, waypoints: list[Waypoint], cfg: SimConfig) -> None:
+ self.waypoints = waypoints
+ self.cfg = cfg
+ self.index = 0
+ self.turn_in_place = False
+ self.stable_count = 0
+ self.wp_start_time = 0.0
+
+ @property
+ def active(self) -> Waypoint | None:
+ if self.index >= len(self.waypoints):
+ return None
+ return self.waypoints[self.index]
+
+ def reset_timing(self, sim_time: float) -> None:
+ self.wp_start_time = sim_time
+
+ @staticmethod
+ def normalize_angle(value: float) -> float:
+ while value > math.pi:
+ value -= 2.0 * math.pi
+ while value < -math.pi:
+ value += 2.0 * math.pi
+ return value
+
+ @staticmethod
+ def clamp(value: float, lo: float, hi: float) -> float:
+ return max(lo, min(hi, value))
+
+ def update(self, x: float, y: float, yaw: float, sim_time: float) -> tuple[np.ndarray, str | None]:
+ wp = self.active
+ if wp is None:
+ return np.zeros(3, dtype=np.float32), "complete"
+
+ if sim_time - self.wp_start_time > self.cfg.waypoint_timeout_s:
+ return np.zeros(3, dtype=np.float32), f"timeout at waypoint {wp.id}"
+
+ dx = wp.x - x
+ dy = wp.y - y
+ dist = math.hypot(dx, dy)
+ tolerance = wp.tolerance if wp.tolerance is not None else 0.15
+ if dist <= tolerance:
+ self.stable_count += 1
+ if self.stable_count >= self.cfg.stable_cycles:
+ self.index += 1
+ self.turn_in_place = False
+ self.stable_count = 0
+ self.wp_start_time = sim_time
+ if self.index >= len(self.waypoints):
+ return np.zeros(3, dtype=np.float32), "complete"
+ return np.zeros(3, dtype=np.float32), None
+ return np.zeros(3, dtype=np.float32), None
+ self.stable_count = 0
+
+ target_yaw = math.atan2(dy, dx)
+ yaw_err = self.normalize_angle(target_yaw - yaw)
+ yaw_stop_threshold = math.radians(self.cfg.yaw_stop_threshold_deg)
+
+ if self.turn_in_place:
+ if abs(yaw_err) <= math.radians(self.cfg.turn_in_place_exit_deg):
+ self.turn_in_place = False
+ elif abs(yaw_err) >= math.radians(self.cfg.turn_in_place_enter_deg):
+ self.turn_in_place = True
+
+ if self.turn_in_place:
+ return np.array(
+ [
+ 0.0,
+ 0.0,
+ self.clamp(
+ self.cfg.kp_yaw * yaw_err,
+ -self.cfg.turn_in_place_max_wz,
+ self.cfg.turn_in_place_max_wz,
+ ),
+ ],
+ dtype=np.float32,
+ ), None
+
+ speed_limit = self.cfg.max_vx if wp.speed is None else min(self.cfg.max_vx, max(0.0, wp.speed))
+ cmd = np.zeros(3, dtype=np.float32)
+ cmd[2] = self.clamp(self.cfg.kp_yaw * yaw_err, -self.cfg.max_wz, self.cfg.max_wz)
+ if abs(yaw_err) <= yaw_stop_threshold:
+ cmd[0] = self.clamp(self.cfg.kp_dist * dist * math.cos(yaw_err), 0.0, speed_limit)
+ cmd[0] *= self.cfg.cmd_vx_scale
+ return cmd, None
+
+
+class PurePursuitFollower(WaypointFollower):
+ def __init__(self, waypoints: list[Waypoint], cfg: SimConfig) -> None:
+ super().__init__(waypoints, cfg)
+ self.best_dist = float("inf")
+ self.last_progress_time = 0.0
+ self.recovery_until = -1.0
+ self.recovery_count = 0
+ self.recovery_turn_sign = 1.0
+
+ def reset_timing(self, sim_time: float) -> None:
+ super().reset_timing(sim_time)
+ self.best_dist = float("inf")
+ self.last_progress_time = sim_time
+ self.recovery_until = -1.0
+ self.recovery_count = 0
+
+ def _advance_reached(self, x: float, y: float, sim_time: float) -> str | None:
+ while self.active is not None:
+ wp = self.active
+ if wp.require_yaw:
+ break
+ tol = wp.tolerance if wp.tolerance is not None else 0.15
+ if wp.mandatory_cross:
+ tol = min(tol, 0.15 if wp.mandatory_radius is None else max(0.01, wp.mandatory_radius))
+ completion_x = wp.x if wp.mandatory_center_x is None else wp.mandatory_center_x
+ completion_y = wp.y if wp.mandatory_center_y is None else wp.mandatory_center_y
+ if math.hypot(completion_x - x, completion_y - y) > tol:
+ break
+ self.index += 1
+ self.turn_in_place = False
+ self.stable_count = 0
+ self.wp_start_time = sim_time
+ self.best_dist = float("inf")
+ self.last_progress_time = sim_time
+ if self.index >= len(self.waypoints):
+ return "complete"
+ return None
+
+ def _lookahead_target(self, x: float, y: float) -> Waypoint:
+ active = self.active
+ if active is None:
+ return self.waypoints[-1]
+ if active.exact_reach:
+ return active
+ target = active
+ for wp in self.waypoints[self.index :]:
+ target = wp
+ if wp.mandatory_cross:
+ mandatory_radius = 0.15 if wp.mandatory_radius is None else max(0.01, wp.mandatory_radius)
+ center_x = wp.x if wp.mandatory_center_x is None else wp.mandatory_center_x
+ center_y = wp.y if wp.mandatory_center_y is None else wp.mandatory_center_y
+ if math.hypot(center_x - x, center_y - y) > mandatory_radius:
+ break
+ if math.hypot(wp.x - x, wp.y - y) >= self.cfg.lookahead_m:
+ break
+ return target
+
+ def _current_speed_limit(self) -> float:
+ speed = self.cfg.max_vx
+ end = min(len(self.waypoints), self.index + 4)
+ for wp in self.waypoints[self.index : end]:
+ if wp.speed is not None:
+ speed = min(speed, max(0.0, wp.speed))
+ return speed
+
+ def update(self, x: float, y: float, yaw: float, sim_time: float) -> tuple[np.ndarray, str | None]:
+ nav_state = self._advance_reached(x, y, sim_time)
+ if nav_state is not None:
+ return np.zeros(3, dtype=np.float32), nav_state
+
+ active = self.active
+ if active is None:
+ return np.zeros(3, dtype=np.float32), "complete"
+ active_dist = math.hypot(active.x - x, active.y - y)
+ if active.require_yaw and active_dist <= (active.tolerance if active.tolerance is not None else 0.15):
+ yaw_tolerance_deg = 5.0 if active.yaw_tolerance_deg is None else max(0.0, active.yaw_tolerance_deg)
+ yaw_err = self.normalize_angle(math.radians(active.yaw_deg) - yaw)
+ if abs(yaw_err) > math.radians(yaw_tolerance_deg):
+ self.stable_count = 0
+ return np.array(
+ [
+ 0.0,
+ 0.0,
+ self.clamp(
+ self.cfg.kp_yaw * self.cfg.final_align_kp_scale * yaw_err,
+ -self.cfg.final_align_max_wz,
+ self.cfg.final_align_max_wz,
+ ),
+ ],
+ dtype=np.float32,
+ ), None
+ required_stable_cycles = (
+ self.cfg.stable_cycles if active.stable_cycles is None else max(1, active.stable_cycles)
+ )
+ self.stable_count += 1
+ if self.stable_count < required_stable_cycles:
+ return np.zeros(3, dtype=np.float32), None
+ self.index += 1
+ self.turn_in_place = False
+ self.stable_count = 0
+ self.wp_start_time = sim_time
+ self.best_dist = float("inf")
+ self.last_progress_time = sim_time
+ if self.index >= len(self.waypoints):
+ return np.zeros(3, dtype=np.float32), "complete"
+ return np.zeros(3, dtype=np.float32), None
+ if active_dist + self.cfg.stuck_progress_m < self.best_dist:
+ self.best_dist = active_dist
+ self.last_progress_time = sim_time
+
+ if self.recovery_until > sim_time:
+ return np.array(
+ [
+ -0.10 * abs(self.cfg.cmd_vx_scale),
+ 0.0,
+ 0.45 * self.recovery_turn_sign,
+ ],
+ dtype=np.float32,
+ ), None
+
+ if sim_time - self.wp_start_time > self.cfg.waypoint_timeout_s:
+ return np.zeros(3, dtype=np.float32), f"timeout at waypoint {active.id}"
+
+ if sim_time - self.last_progress_time > self.cfg.stuck_timeout_s:
+ if active.require_yaw:
+ self.last_progress_time = sim_time
+ elif self.recovery_count >= self.cfg.max_recoveries:
+ return np.zeros(3, dtype=np.float32), f"stuck near waypoint {active.id}"
+ else:
+ self.recovery_count += 1
+ self.recovery_turn_sign *= -1.0
+ self.recovery_until = sim_time + self.cfg.recovery_duration_s
+ self.last_progress_time = sim_time
+ return np.array([-0.10 * abs(self.cfg.cmd_vx_scale), 0.0, 0.45 * self.recovery_turn_sign], dtype=np.float32), None
+
+ target = self._lookahead_target(x, y)
+ dx = target.x - x
+ dy = target.y - y
+ target_dist = math.hypot(dx, dy)
+ target_yaw = math.atan2(dy, dx)
+ yaw_err = self.normalize_angle(target_yaw - yaw)
+
+ cmd = np.zeros(3, dtype=np.float32)
+ cmd[2] = self.clamp(self.cfg.kp_yaw * yaw_err, -self.cfg.max_wz, self.cfg.max_wz)
+ if abs(yaw_err) > math.radians(self.cfg.turn_in_place_enter_deg):
+ cmd[0] = 0.0
+ return cmd, None
+
+ speed_limit = self._current_speed_limit()
+ curvature_slow = max(0.35, 1.0 - abs(yaw_err) / math.radians(80.0))
+ cmd_vx = self.cfg.kp_dist * target_dist * math.cos(yaw_err)
+ cmd_vx = self.clamp(cmd_vx, self.cfg.min_cmd_vx, speed_limit * curvature_slow)
+ if abs(yaw_err) > math.radians(self.cfg.yaw_stop_threshold_deg):
+ if abs(yaw_err) < math.radians(self.cfg.turn_in_place_enter_deg):
+ cmd_vx = min(self.cfg.creep_cmd_vx, speed_limit)
+ else:
+ cmd_vx = 0.0
+ cmd[0] = cmd_vx * self.cfg.cmd_vx_scale
+ return cmd, None
+
+
+class NavGoodFollower(WaypointFollower):
+ """Follower shaped after sim2real nav_good/simple_nav_node.py.
+
+ This keeps the route as the active path, advances through near path points,
+ selects a lookahead target, then applies the same yaw gating and
+ turn-in-place structure as the ROS2 simple_nav node.
+ """
+
+ def __init__(self, waypoints: list[Waypoint], cfg: SimConfig) -> None:
+ super().__init__(waypoints, cfg)
+ self.goal_entered_tolerance = False
+ self.goal_complete_stable_count = 0
+ self.goal_exit_tolerance_margin = 0.05
+ self.final_align_kp_yaw_scale = 0.6
+ self.final_align_creep_speed = 0.03
+ self.path_reach_dist = 0.18
+ self.best_dist = float("inf")
+ self.last_progress_time = 0.0
+
+ def reset_timing(self, sim_time: float) -> None:
+ super().reset_timing(sim_time)
+ self.goal_entered_tolerance = False
+ self.goal_complete_stable_count = 0
+ self.turn_in_place = False
+ self.best_dist = float("inf")
+ self.last_progress_time = sim_time
+
+ def _current_goal(self) -> Waypoint | None:
+ return self.active
+
+ def _is_charge_segment(self, x: float, y: float, goal: Waypoint) -> bool:
+ policy = (goal.policy or "").lower()
+ if policy in {"charge", "wall", "obstacle", "climb"}:
+ return True
+ return -0.45 <= x <= 0.35 and -4.35 <= y <= -2.85
+
+ def _point_is_behind_segment(
+ self,
+ x: float,
+ y: float,
+ point: Waypoint,
+ next_point: Waypoint,
+ ) -> bool:
+ seg_x = next_point.x - point.x
+ seg_y = next_point.y - point.y
+ seg_len_sq = seg_x * seg_x + seg_y * seg_y
+ if seg_len_sq <= 1.0e-9:
+ return False
+ proj = ((x - point.x) * seg_x + (y - point.y) * seg_y) / seg_len_sq
+ return proj > 0.65 and math.hypot(next_point.x - x, next_point.y - y) < math.hypot(point.x - x, point.y - y)
+
+ def _advance_path_nodes(self, x: float, y: float, sim_time: float) -> str | None:
+ while self.index < len(self.waypoints) - 1:
+ goal = self.waypoints[self.index]
+ if goal.require_yaw:
+ break
+ next_goal = self.waypoints[self.index + 1]
+ tol = max(self.path_reach_dist, goal.tolerance if goal.tolerance is not None else 0.15)
+ dist = math.hypot(goal.x - x, goal.y - y)
+ if dist > tol and not self._point_is_behind_segment(x, y, goal, next_goal):
+ break
+ self.index += 1
+ self.wp_start_time = sim_time
+ self.turn_in_place = False
+ self.goal_entered_tolerance = False
+ self.goal_complete_stable_count = 0
+ self.best_dist = float("inf")
+ self.last_progress_time = sim_time
+ if self.index >= len(self.waypoints):
+ return "complete"
+ return None
+
+ def _path_follow_target(self, x: float, y: float, goal: Waypoint) -> Waypoint:
+ target_index = self.index
+ while target_index < len(self.waypoints) - 1:
+ candidate = self.waypoints[target_index + 1]
+ if math.hypot(candidate.x - x, candidate.y - y) > self.cfg.lookahead_m:
+ break
+ target_index += 1
+ return self.waypoints[target_index]
+
+ def update(self, x: float, y: float, yaw: float, sim_time: float) -> tuple[np.ndarray, str | None]:
+ nav_state = self._advance_path_nodes(x, y, sim_time)
+ if nav_state is not None:
+ return np.zeros(3, dtype=np.float32), nav_state
+
+ goal = self._current_goal()
+ if goal is None:
+ return np.zeros(3, dtype=np.float32), "complete"
+ if sim_time - self.wp_start_time > self.cfg.waypoint_timeout_s:
+ return np.zeros(3, dtype=np.float32), f"timeout at waypoint {goal.id}"
+
+ goal_dx = goal.x - x
+ goal_dy = goal.y - y
+ dist = math.hypot(goal_dx, goal_dy)
+ tol_enter = goal.tolerance if goal.tolerance is not None else 0.15
+ tol_exit = tol_enter + self.goal_exit_tolerance_margin
+ position_ready = dist < (tol_exit if self.goal_entered_tolerance else tol_enter)
+ max_vx = self.cfg.max_vx if goal.speed is None else min(self.cfg.max_vx, max(0.0, goal.speed))
+
+ if goal.require_yaw and position_ready:
+ self.goal_entered_tolerance = True
+ yaw_tolerance_deg = 5.0 if goal.yaw_tolerance_deg is None else max(0.0, goal.yaw_tolerance_deg)
+ yaw_err = self.normalize_angle(math.radians(goal.yaw_deg) - yaw)
+ if abs(yaw_err) > math.radians(yaw_tolerance_deg):
+ self.goal_complete_stable_count = 0
+ return np.array(
+ [
+ 0.0,
+ 0.0,
+ self.clamp(
+ self.cfg.kp_yaw * self.final_align_kp_yaw_scale * yaw_err,
+ -self.cfg.final_align_max_wz,
+ self.cfg.final_align_max_wz,
+ ),
+ ],
+ dtype=np.float32,
+ ), None
+ required_stable_cycles = (
+ self.cfg.stable_cycles if goal.stable_cycles is None else max(1, goal.stable_cycles)
+ )
+ self.goal_complete_stable_count += 1
+ if self.goal_complete_stable_count < required_stable_cycles:
+ return np.zeros(3, dtype=np.float32), None
+ self.index += 1
+ self.wp_start_time = sim_time
+ self.turn_in_place = False
+ self.goal_entered_tolerance = False
+ self.goal_complete_stable_count = 0
+ self.best_dist = float("inf")
+ self.last_progress_time = sim_time
+ if self.index >= len(self.waypoints):
+ return np.zeros(3, dtype=np.float32), "complete"
+ return np.zeros(3, dtype=np.float32), None
+
+ is_final_goal = self.index >= len(self.waypoints) - 1
+ if is_final_goal and position_ready:
+ self.goal_entered_tolerance = True
+ self.goal_complete_stable_count += 1
+ if self.goal_complete_stable_count < self.cfg.stable_cycles:
+ return np.zeros(3, dtype=np.float32), None
+ self.index += 1
+ self.wp_start_time = sim_time
+ self.turn_in_place = False
+ self.goal_entered_tolerance = False
+ self.goal_complete_stable_count = 0
+ if self.index >= len(self.waypoints):
+ return np.zeros(3, dtype=np.float32), "complete"
+ return np.zeros(3, dtype=np.float32), None
+
+ self.goal_entered_tolerance = False
+ self.goal_complete_stable_count = 0
+ if dist + self.cfg.stuck_progress_m < self.best_dist:
+ self.best_dist = dist
+ self.last_progress_time = sim_time
+ if sim_time - self.last_progress_time > self.cfg.waypoint_timeout_s:
+ return np.zeros(3, dtype=np.float32), f"no progress near waypoint {goal.id}"
+
+ target = goal if goal.require_yaw else self._path_follow_target(x, y, goal)
+ dx = target.x - x
+ dy = target.y - y
+ target_yaw = math.atan2(dy, dx)
+ yaw_err = self.normalize_angle(target_yaw - yaw)
+
+ if self.turn_in_place:
+ if abs(yaw_err) <= math.radians(self.cfg.turn_in_place_exit_deg):
+ self.turn_in_place = False
+ elif abs(yaw_err) >= math.radians(self.cfg.turn_in_place_enter_deg):
+ self.turn_in_place = True
+
+ if self.turn_in_place:
+ return np.array(
+ [
+ 0.0,
+ 0.0,
+ self.clamp(self.cfg.kp_yaw * yaw_err, -self.cfg.turn_in_place_max_wz, self.cfg.turn_in_place_max_wz),
+ ],
+ dtype=np.float32,
+ ), None
+
+ cmd = np.zeros(3, dtype=np.float32)
+ cmd[2] = self.clamp(self.cfg.kp_yaw * yaw_err, -self.cfg.max_wz, self.cfg.max_wz)
+ if abs(yaw_err) <= math.radians(self.cfg.yaw_stop_threshold_deg):
+ path_dist = math.hypot(dx, dy)
+ if self._is_charge_segment(x, y, goal):
+ cmd[0] = max_vx
+ else:
+ cmd[0] = self.clamp(self.cfg.kp_dist * path_dist * math.cos(yaw_err), 0.0, max_vx)
+ cmd[0] *= self.cfg.cmd_vx_scale
+ return cmd, None
+
+
+class NavSim2SimFollower(WaypointFollower):
+ """Follower matched to sim2sim/nav_sim2sim.py NavController.
+
+ It tracks only the current waypoint, switches at 0.15 m, stops forward
+ motion above ~45 deg yaw error, and can keep full waypoint speed through
+ obstacle-charge segments instead of scaling speed down by distance.
+ """
+
+ def __init__(self, waypoints: list[Waypoint], cfg: SimConfig) -> None:
+ super().__init__(waypoints, cfg)
+ self.reach_dist = 0.15
+
+ def _advance_reached(self, x: float, y: float, sim_time: float) -> str | None:
+ while self.active is not None:
+ wp = self.active
+ tol = wp.tolerance if wp.tolerance is not None else self.reach_dist
+ tol = max(self.reach_dist, tol)
+ if math.hypot(wp.x - x, wp.y - y) >= tol:
+ break
+ if self.index < len(self.waypoints) - 1:
+ self.index += 1
+ self.turn_in_place = False
+ self.stable_count = 0
+ self.wp_start_time = sim_time
+ return None
+ self.index += 1
+ return "complete"
+ if self.index >= len(self.waypoints):
+ return "complete"
+ return None
+
+ def _is_charge_segment(self, x: float, y: float, wp: Waypoint) -> bool:
+ policy = (wp.policy or "").lower()
+ if policy in {"charge", "wall", "obstacle", "climb"}:
+ return True
+ # A.xml has a low wall/step around x=0, y=-3.5. nav_sim2sim keeps
+ # speed through comparable obstacle sections instead of distance taper.
+ return -0.45 <= x <= 0.35 and -4.35 <= y <= -2.85
+
+ def update(self, x: float, y: float, yaw: float, sim_time: float) -> tuple[np.ndarray, str | None]:
+ nav_state = self._advance_reached(x, y, sim_time)
+ if nav_state is not None:
+ return np.zeros(3, dtype=np.float32), nav_state
+ wp = self.active
+ if wp is None:
+ return np.zeros(3, dtype=np.float32), "complete"
+ if sim_time - self.wp_start_time > self.cfg.waypoint_timeout_s:
+ return np.zeros(3, dtype=np.float32), f"timeout at waypoint {wp.id}"
+
+ dx = wp.x - x
+ dy = wp.y - y
+ dist = math.hypot(dx, dy)
+ target_yaw = math.atan2(dy, dx)
+ yaw_err = self.normalize_angle(target_yaw - yaw)
+ speed_limit = self.cfg.max_vx if wp.speed is None else min(self.cfg.max_vx, max(0.0, wp.speed))
+
+ cmd = np.zeros(3, dtype=np.float32)
+ cmd[2] = self.clamp(self.cfg.kp_yaw * yaw_err, -self.cfg.max_wz, self.cfg.max_wz)
+ if abs(yaw_err) > 0.8:
+ cmd[0] = 0.0
+ elif self._is_charge_segment(x, y, wp):
+ cmd[0] = speed_limit
+ else:
+ cmd[0] = self.clamp(self.cfg.kp_dist * dist * math.cos(yaw_err), -0.2, speed_limit)
+ cmd[0] *= self.cfg.cmd_vx_scale
+ return cmd, None
+
+
+class SlalomScriptFollower(NavGoodFollower):
+ """NavGood follower plus the sim2real slalomStraight odometry script.
+
+ The script starts only after the robot reaches the first slalomStraight
+ point, then drives consecutive slalomStraight points with body-frame
+ vx/vy/wz feedback. This mirrors simple_nav_node.py instead of the older
+ pure waypoint followers.
+ """
+
+ def __init__(self, waypoints: list[Waypoint], cfg: SimConfig) -> None:
+ super().__init__(waypoints, cfg)
+ self.slalom_script_active = False
+ self.slalom_script_start_index = 0
+ self.slalom_script_end_index = 0
+ self.slalom_script_steps: list[SlalomScriptStep] = []
+ self.slalom_script_step_index = 0
+ self.slalom_script_step_stable_count = 0
+
+ def reset_timing(self, sim_time: float) -> None:
+ super().reset_timing(sim_time)
+ self.reset_slalom_script()
+
+ def reset_slalom_script(self) -> None:
+ self.slalom_script_active = False
+ self.slalom_script_start_index = 0
+ self.slalom_script_end_index = 0
+ self.slalom_script_steps = []
+ self.slalom_script_step_index = 0
+ self.slalom_script_step_stable_count = 0
+
+ @staticmethod
+ def _is_slalom_script_waypoint(wp: Waypoint | None) -> bool:
+ return bool(wp is not None and getattr(wp, "slalom_straight", False))
+
+ def _advance_path_nodes(self, x: float, y: float, sim_time: float) -> str | None:
+ active = self.active
+ if (
+ self.cfg.slalom_script_enabled
+ and self._is_slalom_script_waypoint(active)
+ and not self.slalom_script_active
+ and self.index + 1 < len(self.waypoints)
+ and self._is_slalom_script_waypoint(self.waypoints[self.index + 1])
+ ):
+ # Do not let the normal path follower skip the script start point at
+ # the wider path tolerance; the real node gates this with
+ # nav_slalom_script_start_tolerance.
+ return None
+ return super()._advance_path_nodes(x, y, sim_time)
+
+ def get_slalom_script_bounds(self, start_index: int) -> tuple[int, int]:
+ if start_index < 0 or start_index >= len(self.waypoints):
+ return start_index, start_index
+ if not self._is_slalom_script_waypoint(self.waypoints[start_index]):
+ return start_index, start_index
+ end_index = start_index
+ while end_index + 1 < len(self.waypoints):
+ if getattr(self.waypoints[end_index], "slalom_script_break", False):
+ break
+ if not self._is_slalom_script_waypoint(self.waypoints[end_index + 1]):
+ break
+ end_index += 1
+ return start_index, end_index
+
+ def build_slalom_script_steps(self, start_index: int, end_index: int) -> list[SlalomScriptStep]:
+ if end_index <= start_index:
+ return []
+
+ steps: list[SlalomScriptStep] = []
+ for index in range(start_index, end_index):
+ goal = self.waypoints[index]
+ next_goal = self.waypoints[index + 1]
+ route_dx = next_goal.x - goal.x
+ route_dy = next_goal.y - goal.y
+ route_dist = math.hypot(route_dx, route_dy)
+ route_yaw = math.atan2(route_dy, route_dx) if route_dist > 1.0e-6 else math.radians(goal.yaw_deg)
+ yaw_source = self.cfg.slalom_script_drive_yaw_source
+ if yaw_source == "segment":
+ target_yaw = route_yaw
+ elif yaw_source == "blend":
+ target_yaw = route_yaw
+ if index + 2 <= end_index:
+ following_goal = self.waypoints[index + 2]
+ next_dx = following_goal.x - next_goal.x
+ next_dy = following_goal.y - next_goal.y
+ if math.hypot(next_dx, next_dy) > 1.0e-6:
+ next_yaw = math.atan2(next_dy, next_dx)
+ delta = self.normalize_angle(next_yaw - route_yaw)
+ target_yaw = route_yaw + 0.15 * delta
+ elif yaw_source == "next":
+ target_yaw = math.radians(next_goal.yaw_deg)
+ else:
+ target_yaw = math.radians(goal.yaw_deg)
+ target_yaw = self.normalize_angle(target_yaw)
+ if self.cfg.slalom_script_rotate_steps_enabled:
+ steps.append(
+ SlalomScriptStep(
+ kind="rotate",
+ start_index=index,
+ end_index=index,
+ target_x=goal.x,
+ target_y=goal.y,
+ target_yaw=target_yaw,
+ pos_tolerance=getattr(goal, "slalom_script_pos_tolerance", None),
+ )
+ )
+ if route_dist >= self.cfg.slalom_script_min_step_distance:
+ forward = math.cos(target_yaw) * route_dx + math.sin(target_yaw) * route_dy
+ left = -math.sin(target_yaw) * route_dx + math.cos(target_yaw) * route_dy
+ steps.append(
+ SlalomScriptStep(
+ kind="drive",
+ start_index=index,
+ end_index=index + 1,
+ target_x=next_goal.x,
+ target_y=next_goal.y,
+ target_yaw=target_yaw,
+ pos_tolerance=getattr(next_goal, "slalom_script_pos_tolerance", None),
+ forward=forward,
+ left=left,
+ )
+ )
+
+ final_goal = self.waypoints[end_index]
+ if self.cfg.slalom_script_final_rotate_enabled:
+ steps.append(
+ SlalomScriptStep(
+ kind="rotate",
+ start_index=end_index,
+ end_index=end_index,
+ target_x=final_goal.x,
+ target_y=final_goal.y,
+ target_yaw=math.radians(final_goal.yaw_deg),
+ pos_tolerance=getattr(final_goal, "slalom_script_pos_tolerance", None),
+ )
+ )
+ return steps
+
+ def maybe_start_slalom_script(self, x: float, y: float, sim_time: float) -> bool:
+ if not self.cfg.slalom_script_enabled or self.slalom_script_active:
+ return self.slalom_script_active
+ active = self.active
+ if not self._is_slalom_script_waypoint(active):
+ return False
+ if math.hypot(active.x - x, active.y - y) > self.cfg.slalom_script_start_tolerance:
+ return False
+
+ start_index, end_index = self.get_slalom_script_bounds(self.index)
+ if end_index <= start_index:
+ return False
+ steps = self.build_slalom_script_steps(start_index, end_index)
+ if not steps:
+ return False
+
+ self.slalom_script_active = True
+ self.slalom_script_start_index = start_index
+ self.slalom_script_end_index = end_index
+ self.slalom_script_steps = steps
+ self.slalom_script_step_index = 0
+ self.slalom_script_step_stable_count = 0
+ self.turn_in_place = False
+ self.goal_entered_tolerance = False
+ self.goal_complete_stable_count = 0
+ self.wp_start_time = sim_time
+ return True
+
+ def compute_slalom_script_command(
+ self,
+ step: SlalomScriptStep,
+ x: float,
+ y: float,
+ yaw: float,
+ ) -> tuple[np.ndarray, bool]:
+ cmd = np.zeros(3, dtype=np.float32)
+ target_yaw = step.target_yaw
+ yaw_tolerance = math.radians(self.cfg.slalom_script_yaw_tolerance_deg)
+
+ if step.kind == "rotate":
+ if target_yaw is None:
+ return cmd, True
+ yaw_err = self.normalize_angle(target_yaw - yaw)
+ if abs(yaw_err) <= yaw_tolerance:
+ return cmd, True
+ cmd[2] = self.clamp(
+ self.cfg.slalom_script_kp_yaw * yaw_err,
+ -self.cfg.slalom_script_max_wz,
+ self.cfg.slalom_script_max_wz,
+ )
+ cmd[2] = self.apply_min_command(
+ float(cmd[2]),
+ self.cfg.slalom_script_min_cmd_angular,
+ self.cfg.slalom_script_max_wz,
+ self.cfg.slalom_script_min_cmd_epsilon,
+ )
+ return cmd, False
+
+ dx = step.target_x - x
+ dy = step.target_y - y
+ if target_yaw is None:
+ target_yaw = math.atan2(dy, dx) if math.hypot(dx, dy) > 1.0e-6 else yaw
+ yaw_err = self.normalize_angle(target_yaw - yaw)
+ pos_err = math.hypot(dx, dy)
+ pos_tolerance = (
+ self.cfg.slalom_script_pos_tolerance
+ if step.pos_tolerance is None
+ else max(0.01, float(step.pos_tolerance))
+ )
+ complete = (
+ pos_err <= pos_tolerance
+ and (
+ not self.cfg.slalom_script_require_yaw_at_step
+ or abs(yaw_err) <= yaw_tolerance
+ )
+ )
+ if complete:
+ return cmd, True
+
+ cmd[2] = self.clamp(
+ self.cfg.slalom_script_kp_yaw * yaw_err,
+ -self.cfg.slalom_script_max_wz,
+ self.cfg.slalom_script_max_wz,
+ )
+ cmd[2] = self.apply_min_command(
+ float(cmd[2]),
+ self.cfg.slalom_script_min_cmd_angular,
+ self.cfg.slalom_script_max_wz,
+ self.cfg.slalom_script_min_cmd_epsilon,
+ )
+ if abs(yaw_err) <= math.radians(self.cfg.slalom_script_yaw_gate_deg):
+ err_forward = math.cos(yaw) * dx + math.sin(yaw) * dy
+ err_left = -math.sin(yaw) * dx + math.cos(yaw) * dy
+ yaw_scale = max(0.35, math.cos(yaw_err) ** 2)
+ curvature_scale = 1.0
+ if self.cfg.slalom_script_curvature_speed_enabled:
+ gate = max(1.0e-6, math.radians(self.cfg.slalom_script_yaw_gate_deg))
+ curvature_scale = max(
+ self.cfg.slalom_script_curvature_min_scale,
+ 1.0 - abs(yaw_err) / gate,
+ )
+ cmd[0] = self.clamp(
+ self.cfg.slalom_script_kp_dist * err_forward,
+ -self.cfg.slalom_script_max_vx * yaw_scale * curvature_scale,
+ self.cfg.slalom_script_max_vx * yaw_scale * curvature_scale,
+ )
+ cmd[1] = self.clamp(
+ self.cfg.slalom_script_kp_dist * err_left,
+ -self.cfg.slalom_script_max_vy * curvature_scale,
+ self.cfg.slalom_script_max_vy * curvature_scale,
+ )
+ cmd[0] = self.apply_min_command(
+ float(cmd[0]),
+ self.cfg.slalom_script_min_cmd_linear,
+ self.cfg.slalom_script_max_vx,
+ self.cfg.slalom_script_min_cmd_epsilon,
+ )
+ cmd[1] = self.apply_min_command(
+ float(cmd[1]),
+ self.cfg.slalom_script_min_cmd_linear,
+ self.cfg.slalom_script_max_vy,
+ self.cfg.slalom_script_min_cmd_epsilon,
+ )
+ return cmd, False
+
+ @classmethod
+ def apply_min_command(
+ cls,
+ value: float,
+ min_abs: float,
+ max_abs: float,
+ epsilon: float,
+ ) -> float:
+ if max_abs <= 0.0 or min_abs <= 0.0:
+ return value
+ abs_value = abs(value)
+ if abs_value < max(0.0, epsilon) or abs_value >= min_abs:
+ return value
+ return cls.clamp(math.copysign(min_abs, value), -max_abs, max_abs)
+
+ def finish_slalom_script(self, sim_time: float) -> tuple[np.ndarray, str | None]:
+ end_index = self.slalom_script_end_index
+ self.reset_slalom_script()
+ self.index = end_index + 1
+ self.wp_start_time = sim_time
+ self.turn_in_place = False
+ self.goal_entered_tolerance = False
+ self.goal_complete_stable_count = 0
+ self.best_dist = float("inf")
+ self.last_progress_time = sim_time
+ if self.index >= len(self.waypoints):
+ return np.zeros(3, dtype=np.float32), "complete"
+ return np.zeros(3, dtype=np.float32), None
+
+ def handle_slalom_script(
+ self,
+ x: float,
+ y: float,
+ yaw: float,
+ sim_time: float,
+ ) -> tuple[np.ndarray, str | None] | None:
+ if not self.slalom_script_active and not self.maybe_start_slalom_script(x, y, sim_time):
+ return None
+ if sim_time - self.wp_start_time > self.cfg.waypoint_timeout_s:
+ active = self.active
+ active_id = active.id if active is not None else "unknown"
+ return np.zeros(3, dtype=np.float32), (
+ f"timeout at slalom step {self.slalom_script_step_index + 1}/"
+ f"{len(self.slalom_script_steps)} near waypoint {active_id}"
+ )
+
+ while self.slalom_script_step_index < len(self.slalom_script_steps):
+ step = self.slalom_script_steps[self.slalom_script_step_index]
+ cmd, complete = self.compute_slalom_script_command(step, x, y, yaw)
+ if complete:
+ self.slalom_script_step_stable_count += 1
+ if self.slalom_script_step_stable_count >= self.cfg.slalom_script_stable_cycles:
+ self.slalom_script_step_index += 1
+ self.index = max(self.index, min(step.end_index, self.slalom_script_end_index))
+ self.wp_start_time = sim_time
+ self.slalom_script_step_stable_count = 0
+ continue
+ else:
+ self.slalom_script_step_stable_count = 0
+ return cmd, None
+
+ return self.finish_slalom_script(sim_time)
+
+ def update(self, x: float, y: float, yaw: float, sim_time: float) -> tuple[np.ndarray, str | None]:
+ script_result = self.handle_slalom_script(x, y, yaw, sim_time)
+ if script_result is not None:
+ return script_result
+ return super().update(x, y, yaw, sim_time)
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description="Run MuJoCo+ONNX route validation.")
+ parser.add_argument("--terrain-xml", type=Path, default=PROJECT_ROOT / "tools/nav_tools/xml/A.xml")
+ parser.add_argument("--points", type=Path, default=PROJECT_ROOT / "tools/nav_tools/points/points_20260705_174627.json")
+ parser.add_argument("--onnx", type=Path, default=PROJECT_ROOT / "model_6800.onnx")
+ parser.add_argument("--crawl-onnx", type=Path, default=PROJECT_ROOT / "model_crawl.onnx")
+ parser.add_argument(
+ "--policy-backend",
+ choices=("policy-runner", "onnx", "rough-ik-crawl"),
+ default="policy-runner",
+ help="policy-runner matches nav_sim2sim.py; rough-ik-crawl matches sim2real nav_good crawl_backend=ik.",
+ )
+ parser.add_argument("--enable-policy-switch", action="store_true")
+ parser.add_argument(
+ "--mission",
+ choices=("json", "slalom", "grand"),
+ default="json",
+ help="json uses --points; slalom/grand use the hardcoded nav_sim2sim coordinate frame.",
+ )
+ parser.add_argument("--robot-xml", type=Path, default=PROJECT_ROOT / "mjcf/wheelleg.xml")
+ parser.add_argument("--hfield-dir", type=Path, default=PROJECT_ROOT / "sim2sim/terrain")
+ parser.add_argument("--out-dir", type=Path, default=PROJECT_ROOT / "sim2sim/route_check_runs")
+ parser.add_argument("--avoid-margin", type=float, default=0.05)
+ parser.add_argument("--footprint-radius", type=float, default=None)
+ parser.add_argument("--max-time", type=float, default=420.0)
+ parser.add_argument("--waypoint-timeout", type=float, default=30.0)
+ parser.add_argument("--max-vx", type=float, default=1.2)
+ parser.add_argument("--max-vy", type=float, default=0.0)
+ parser.add_argument("--max-wz", type=float, default=0.8)
+ parser.add_argument("--kp-dist", type=float, default=0.8)
+ parser.add_argument("--kp-yaw", type=float, default=1.8)
+ parser.add_argument("--yaw-stop-threshold-deg", type=float, default=45.0)
+ parser.add_argument("--turn-in-place-enter-deg", type=float, default=70.0)
+ parser.add_argument("--turn-in-place-exit-deg", type=float, default=18.0)
+ parser.add_argument("--follower", choices=("waypoint", "pure-pursuit", "nav-good", "nav-sim2sim", "slalom-script", "nav-script"), default="pure-pursuit")
+ parser.add_argument("--lookahead", type=float, default=0.45)
+ parser.add_argument("--min-cmd-vx", type=float, default=0.08)
+ parser.add_argument("--creep-cmd-vx", type=float, default=0.04)
+ parser.add_argument("--cmd-vx-scale", type=float, default=1.0)
+ parser.add_argument("--stuck-timeout", type=float, default=4.0)
+ parser.add_argument("--recovery-duration", type=float, default=2.0)
+ parser.add_argument("--max-recoveries", type=int, default=4)
+ parser.add_argument("--slalom-script", action="store_true", default=True)
+ parser.add_argument("--no-slalom-script", dest="slalom_script", action="store_false")
+ parser.add_argument("--slalom-script-start-tolerance", type=float, default=0.10)
+ parser.add_argument("--slalom-script-pos-tolerance", type=float, default=0.08)
+ parser.add_argument("--slalom-script-yaw-tolerance-deg", type=float, default=5.0)
+ parser.add_argument("--slalom-script-stable-cycles", type=int, default=1)
+ parser.add_argument("--slalom-script-rotate-steps", action="store_true", default=False)
+ parser.add_argument("--slalom-script-final-rotate", action="store_true", default=False)
+ parser.add_argument("--slalom-script-require-yaw-at-step", action="store_true", default=False)
+ parser.add_argument("--slalom-script-kp-dist", type=float, default=1.0)
+ parser.add_argument("--slalom-script-kp-yaw", type=float, default=1.6)
+ parser.add_argument("--slalom-script-max-vx", type=float, default=0.60)
+ parser.add_argument("--slalom-script-max-vy", type=float, default=0.50)
+ parser.add_argument("--slalom-script-max-wz", type=float, default=0.50)
+ parser.add_argument("--slalom-script-min-cmd-linear", type=float, default=0.2)
+ parser.add_argument("--slalom-script-min-cmd-angular", type=float, default=0.0)
+ parser.add_argument("--slalom-script-min-cmd-epsilon", type=float, default=0.05)
+ parser.add_argument("--slalom-script-min-step-distance", type=float, default=0.02)
+ parser.add_argument("--slalom-script-yaw-gate-deg", type=float, default=25.0)
+ parser.add_argument("--slalom-script-curvature-speed", action="store_true", default=True)
+ parser.add_argument(
+ "--no-slalom-script-curvature-speed",
+ dest="slalom_script_curvature_speed",
+ action="store_false",
+ )
+ parser.add_argument("--slalom-script-curvature-min-scale", type=float, default=0.55)
+ parser.add_argument(
+ "--slalom-script-drive-yaw-source",
+ choices=("current", "next", "segment", "blend"),
+ default="segment",
+ help="Yaw used while driving each slalom segment.",
+ )
+ parser.add_argument("--local-safety", action="store_true", default=False)
+ parser.add_argument("--no-local-safety", dest="local_safety", action="store_false")
+ parser.add_argument("--safety-horizon", type=float, default=1.0)
+ parser.add_argument("--safety-dt", type=float, default=0.1)
+ parser.add_argument(
+ "--start-yaw-offset-deg",
+ type=float,
+ default=0.0,
+ help="Added to route yawDeg when placing the robot.",
+ )
+ parser.add_argument(
+ "--heading-offset-deg",
+ type=float,
+ default=0.0,
+ help="Added to MuJoCo base yaw before waypoint heading control.",
+ )
+ parser.add_argument("--sample-every", type=int, default=5)
+ parser.add_argument("--sim-speed", type=float, default=1.0, help="Simulation wall-clock speed factor, matching nav_sim2sim.py timing semantics.")
+ parser.add_argument("--viewer", action="store_true", help="Open MuJoCo passive viewer while the check runs.")
+ parser.add_argument("--viewer-sync-every", type=int, default=2)
+ parser.add_argument("--dashboard", action="store_true", help="Open a pygame telemetry dashboard with route, avoid regions, and trail.")
+ parser.add_argument("--dashboard-fps", type=float, default=30.0)
+ parser.add_argument("--dashboard-sim-speed", type=float, default=1.0, help="Initial dashboard simulation speed factor; can be changed in the UI up to 10x.")
+ parser.add_argument("--dashboard-width", type=int, default=1180)
+ parser.add_argument("--dashboard-height", type=int, default=760)
+ parser.add_argument("--dashboard-trail", type=int, default=6000)
+ parser.add_argument("--start-index", type=int, default=1, help="1-based waypoint index to start testing from.")
+ parser.add_argument("--end-index", type=int, default=None, help="1-based waypoint index to stop testing at.")
+ parser.add_argument("--start-id", type=str, default=None, help="Waypoint id to start testing from; overrides --start-index.")
+ parser.add_argument("--end-id", type=str, default=None, help="Waypoint id to stop testing at; overrides --end-index.")
+ parser.add_argument(
+ "--skip-initial-waypoint",
+ action="store_true",
+ help="Use the first sliced waypoint as spawn pose, then navigate to the next waypoint.",
+ )
+ parser.add_argument(
+ "--auto-skip-slice-start",
+ action="store_true",
+ default=True,
+ help="When --start-index > 1, skip the sliced start waypoint by default.",
+ )
+ parser.add_argument("--no-auto-skip-slice-start", dest="auto_skip_slice_start", action="store_false")
+ parser.add_argument("--start-z", type=float, default=0.75, help="Initial base z before settling.")
+ parser.add_argument("--start-x-offset", type=float, default=0.0, help="World x offset added to the initial spawn pose.")
+ parser.add_argument("--start-y-offset", type=float, default=0.0, help="World y offset added to the initial spawn pose.")
+ parser.add_argument("--settle-steps", type=int, default=500)
+ parser.add_argument("--min-base-z", type=float, default=0.12)
+ parser.add_argument("--stop-on-clearance-violation", action="store_true")
+ parser.add_argument("--deployment-randomization", action="store_true")
+ parser.add_argument("--random-seed", type=int, default=1)
+ parser.add_argument("--pose-noise-xy", type=float, default=0.015)
+ parser.add_argument("--pose-noise-yaw-deg", type=float, default=1.0)
+ parser.add_argument("--command-delay-ms", type=float, default=40.0)
+ parser.add_argument("--action-delay-ms", type=float, default=20.0)
+ parser.add_argument("--control-jitter-ms", type=float, default=4.0)
+ parser.add_argument("--command-dropout-prob", type=float, default=0.01)
+ parser.add_argument("--push-force", type=float, default=25.0)
+ parser.add_argument("--push-interval", type=float, default=8.0)
+ parser.add_argument("--push-duration", type=float, default=0.08)
+ parser.add_argument("--no-csv", action="store_true")
+ return parser.parse_args()
+
+
+def make_combined_terrain_xml(terrain_xml: Path, work_dir: Path) -> Path:
+ text = terrain_xml.read_text(encoding="utf-8")
+ if '' not in text and '' not in text:
+ end = text.find(">")
+ if end < 0:
+ raise ValueError(f"Invalid terrain XML: {terrain_xml}")
+ text = text[: end + 1] + '\n' + text[end + 1 :]
+ out_path = work_dir / f"{terrain_xml.stem}_with_robot_include.xml"
+ out_path.write_text(text, encoding="utf-8")
+ return out_path
+
+
+def builtin_mission_waypoints(name: str) -> list[Waypoint]:
+ slalom = [
+ (2.9, -9.5, 0.4, "rough"),
+ (1.8, -9.95, 0.4, "rough"),
+ (0.9, -10.0, 0.4, "rough"),
+ (0.8, -10.8, 0.4, "rough"),
+ (0.9, -11.0, 0.4, "rough"),
+ (2.7, -11.0, 0.4, "rough"),
+ (2.8, -11.8, 0.4, "rough"),
+ (2.7, -12.0, 0.4, "rough"),
+ (0.9, -12.0, 0.4, "rough"),
+ (0.9, -12.75, 0.4, "rough"),
+ (1.42, -12.82, 0.4, "rough"),
+ (2.3, -13.3, 0.4, "rough"),
+ (2.3, -11.5, 0.4, "rough"),
+ (3.32, -12.44, 0.4, "rough"),
+ (3.7, -9.0, 0.6, "rough"),
+ ]
+ grand_tail = [
+ (5.7, -10.2, 0.5, "rough"),
+ (5.7, -9.0, 0.3, "crawl"),
+ (5.7, -7.8, 0.3, "rough"),
+ (5.7, -7.0, 0.5, "rough"),
+ (3.7, -9.0, 0.5, "rough"),
+ (3.7, -12.5, 0.5, "rough"),
+ (4.84, -12.5, 0.4, "rough"),
+ (5.84, -12.0, 0.4, "rough"),
+ (5.84, -10.2, 0.4, "rough"),
+ (3.3, -10.2, 0.5, "rough"),
+ (3.3, -9.0, 0.5, "rough"),
+ (3.7, -9.0, 0.5, "rough"),
+ (3.3, -9.0, 0.5, "rough"),
+ (1.8, -9.0, 0.5, "rough"),
+ (1.8, -8.0, 0.4, "rough"),
+ (1.8, -7.5, 0.4, "rough"),
+ (1.8, -5.8, 1.2, "rough"),
+ (1.8, -3.5, 0.4, "rough"),
+ (1.8, -1.6, 0.4, "rough"),
+ (1.8, -1.38, 0.4, "rough"),
+ (1.8, 0.0, 0.4, "rough"),
+ (3.2, 0.0, 0.4, "rough"),
+ (4.85, 0.0, 0.4, "rough"),
+ (5.7, -0.5, 0.4, "rough"),
+ (5.7, -2.25, 0.4, "rough"),
+ (5.7, -3.5, 0.4, "rough"),
+ (5.7, -5.0, 0.4, "rough"),
+ (5.7, -7.0, 0.4, "rough"),
+ (3.3, -7.0, 0.5, "rough"),
+ (3.3, -9.0, 0.5, "rough"),
+ (3.7, -9.0, 0.6, "rough"),
+ ]
+ rows = slalom if name == "slalom" else slalom + grand_tail
+ return [
+ Waypoint(
+ index=i,
+ id=f"{name}_{i}",
+ x=x,
+ y=y,
+ yaw_deg=0.0,
+ speed=speed,
+ policy=policy,
+ tolerance=0.15,
+ )
+ for i, (x, y, speed, policy) in enumerate(rows, start=1)
+ ]
+
+
+def load_route(path: Path) -> tuple[list[Waypoint], list[AvoidRegion]]:
+ with path.open("r", encoding="utf-8") as f:
+ payload = json.load(f)
+ if not isinstance(payload, dict):
+ raise ValueError(f"Route JSON root must be object: {path}")
+ return load_waypoints(payload), load_regions(payload)
+
+
+def load_requested_route(args: argparse.Namespace) -> tuple[list[Waypoint], list[AvoidRegion]]:
+ if args.mission == "json":
+ waypoints, regions = load_route(args.points)
+ else:
+ waypoints, regions = builtin_mission_waypoints(args.mission), []
+
+ id_to_pos = {wp.id: i + 1 for i, wp in enumerate(waypoints)}
+ start = id_to_pos.get(str(args.start_id), max(1, int(args.start_index))) if args.start_id is not None else max(1, int(args.start_index))
+ end_default = int(args.end_index) if args.end_index is not None else len(waypoints)
+ end = id_to_pos.get(str(args.end_id), end_default) if args.end_id is not None else end_default
+ if start > end or start > len(waypoints):
+ raise ValueError(f"Invalid waypoint slice: start={start} end={end} total={len(waypoints)}")
+ sliced = waypoints[start - 1 : min(end, len(waypoints))]
+ if len(sliced) < 1:
+ raise ValueError("Waypoint slice is empty")
+ return sliced, regions
+
+
+def initial_pose_for_route(args: argparse.Namespace, waypoints: list[Waypoint]) -> tuple[float, float, float]:
+ if args.mission == "json":
+ start = waypoints[0]
+ return start.x, start.y, start.yaw_deg + float(args.start_yaw_offset_deg)
+ return 3.7, -9.0, 0.0
+
+
+def set_robot_pose(
+ io: MuJoCoIO,
+ x: float,
+ y: float,
+ yaw_deg: float,
+ z: float,
+ settle_steps: int,
+ default_dof_pos: np.ndarray,
+) -> None:
+ yaw = math.radians(yaw_deg)
+ io.d.qpos[0] = x
+ io.d.qpos[1] = y
+ io.d.qpos[2] = z
+ io.d.qpos[3:7] = [math.cos(yaw * 0.5), 0.0, 0.0, math.sin(yaw * 0.5)]
+ io.d.qpos[io.qpos_ids] = default_dof_pos
+ io.d.qvel[:] = 0.0
+ io.d.ctrl[io.ctrl_ids[:12]] = default_dof_pos[:12]
+ io.d.ctrl[io.ctrl_ids[12:]] = 0.0
+ mujoco.mj_forward(io.m, io.d)
+ for _ in range(max(0, int(settle_steps))):
+ mujoco.mj_step(io.m, io.d)
+
+
+def clearance_to_regions(
+ x: float,
+ y: float,
+ regions: list[AvoidRegion],
+) -> tuple[float, str]:
+ best = float("inf")
+ best_name = ""
+ for region in regions:
+ polygon = region.polygon
+ if len(polygon) < 3:
+ continue
+ if point_in_polygon(x, y, polygon):
+ return 0.0, region.name
+ dist = min(
+ point_segment_distance(x, y, ax, ay, bx, by)
+ for (ax, ay), (bx, by) in zip(polygon, polygon[1:] + polygon[:1])
+ )
+ if dist < best:
+ best = dist
+ best_name = region.name
+ return best, best_name
+
+
+def safety_filter_command(
+ desired: np.ndarray,
+ x: float,
+ y: float,
+ yaw: float,
+ target: Waypoint | None,
+ regions: list[AvoidRegion],
+ required_clearance: float,
+ cfg: SimConfig,
+ horizon_s: float,
+ dt_s: float,
+) -> np.ndarray:
+ if not regions or target is None:
+ return desired
+
+ base_vx = float(desired[0])
+ base_vy = float(desired[1])
+ base_wz = float(desired[2])
+ vx_abs = abs(base_vx)
+ vy_abs = abs(base_vy)
+ vx_sign = -1.0 if base_vx < 0.0 else 1.0
+ vy_sign = -1.0 if base_vy < 0.0 else 1.0
+ vx_samples = [0.0, 0.35 * vx_abs, 0.65 * vx_abs, vx_abs]
+ vy_samples = [0.0, 0.5 * vy_abs, vy_abs]
+ wz_span = max(0.35, min(cfg.max_wz, abs(base_wz) + 0.35))
+ wz_samples = [
+ base_wz - wz_span,
+ base_wz - 0.5 * wz_span,
+ base_wz,
+ base_wz + 0.5 * wz_span,
+ base_wz + wz_span,
+ ]
+ best_cmd = desired.copy()
+ best_score = -float("inf")
+ initial_dist = math.hypot(target.x - x, target.y - y)
+ steps = max(1, int(round(horizon_s / dt_s)))
+
+ for vx_mag in vx_samples:
+ for vy_mag in vy_samples:
+ for wz in wz_samples:
+ px, py, pyaw = x, y, yaw
+ min_margin = float("inf")
+ for _ in range(steps):
+ vx = vx_sign * vx_mag
+ vy = vy_sign * vy_mag
+ px += (vx * math.cos(pyaw) - vy * math.sin(pyaw)) * dt_s
+ py += (vx * math.sin(pyaw) + vy * math.cos(pyaw)) * dt_s
+ pyaw = WaypointFollower.normalize_angle(pyaw + wz * dt_s)
+ clearance, _ = clearance_to_regions(px, py, regions)
+ min_margin = min(min_margin, clearance - required_clearance)
+ final_dist = math.hypot(target.x - px, target.y - py)
+ progress = initial_dist - final_dist
+ yaw_cost = abs(wz - base_wz)
+ speed_cost = abs(vx_mag - vx_abs) + abs(vy_mag - vy_abs)
+ risk_penalty = 20.0 * max(0.0, -min_margin)
+ margin_reward = min(0.4, min_margin)
+ score = 3.0 * progress + margin_reward - risk_penalty - 0.12 * yaw_cost - 0.08 * speed_cost
+ if score > best_score:
+ best_score = score
+ best_cmd = np.array(
+ [
+ vx_sign * vx_mag,
+ vy_sign * vy_mag,
+ WaypointFollower.clamp(wz, -cfg.max_wz, cfg.max_wz),
+ ],
+ dtype=np.float32,
+ )
+ return best_cmd
+
+
+def robot_yaw_from_qpos(qpos: np.ndarray) -> float:
+ qw, qx, qy, qz = qpos[3:7]
+ siny_cosp = 2.0 * (qw * qz + qx * qy)
+ cosy_cosp = 1.0 - 2.0 * (qy * qy + qz * qz)
+ return math.atan2(siny_cosp, cosy_cosp)
+
+
+def quat_to_euler_wxyz(quat: np.ndarray) -> tuple[float, float, float]:
+ qw, qx, qy, qz = [float(v) for v in quat]
+ sinr_cosp = 2.0 * (qw * qx + qy * qz)
+ cosr_cosp = 1.0 - 2.0 * (qx * qx + qy * qy)
+ roll = math.atan2(sinr_cosp, cosr_cosp)
+ sinp = 2.0 * (qw * qy - qz * qx)
+ pitch = math.asin(max(-1.0, min(1.0, sinp)))
+ yaw = robot_yaw_from_qpos(np.array([0.0, 0.0, 0.0, qw, qx, qy, qz], dtype=np.float64))
+ return roll, pitch, yaw
+
+
+@dataclass(frozen=True)
+class DashboardGeom:
+ name: str
+ kind: str
+ pos: tuple[float, ...]
+ size: tuple[float, ...]
+ quat: tuple[float, float, float, float]
+ rgba: tuple[float, float, float, float]
+ collidable: bool
+
+
+def parse_dashboard_geoms(xml_path: Path) -> list[DashboardGeom]:
+ geoms: list[DashboardGeom] = []
+ if not xml_path.exists():
+ return geoms
+ try:
+ root = ET.parse(xml_path).getroot()
+ except ET.ParseError:
+ return geoms
+ for index, geom in enumerate(root.iter("geom"), start=1):
+ name = geom.get("name", f"geom_{index}")
+ if name == "floor":
+ continue
+ kind = geom.get("type", "box")
+ pos_text = geom.get("pos")
+ size_text = geom.get("size")
+ if not pos_text or not size_text:
+ continue
+ try:
+ pos = tuple(float(v) for v in pos_text.split())
+ size = tuple(float(v) for v in size_text.split())
+ quat_values = tuple(float(v) for v in geom.get("quat", "1 0 0 0").split())
+ rgba_values = tuple(float(v) for v in geom.get("rgba", "0.65 0.65 0.65 1").split())
+ except ValueError:
+ continue
+ if len(pos) < 2 or len(size) < 1 or len(quat_values) != 4:
+ continue
+ rgba = rgba_values if len(rgba_values) == 4 else (0.65, 0.65, 0.65, 1.0)
+ collidable = geom.get("contype", "1") != "0" and geom.get("conaffinity", "1") != "0"
+ geoms.append(
+ DashboardGeom(
+ name=name,
+ kind=kind,
+ pos=pos,
+ size=size,
+ quat=quat_values, # type: ignore[arg-type]
+ rgba=rgba, # type: ignore[arg-type]
+ collidable=collidable,
+ )
+ )
+ return geoms
+
+
+def quat_yaw_wxyz(quat: tuple[float, float, float, float]) -> float:
+ qw, qx, qy, qz = quat
+ siny_cosp = 2.0 * (qw * qz + qx * qy)
+ cosy_cosp = 1.0 - 2.0 * (qy * qy + qz * qz)
+ return math.atan2(siny_cosp, cosy_cosp)
+
+
+def local_rect_polygon(
+ origin_x: float,
+ origin_y: float,
+ yaw: float,
+ center_x: float,
+ center_y: float,
+ length: float,
+ width: float,
+) -> list[tuple[float, float]]:
+ c = math.cos(yaw)
+ s = math.sin(yaw)
+ hx = length * 0.5
+ hy = width * 0.5
+ points = []
+ for lx, ly in [(-hx, -hy), (hx, -hy), (hx, hy), (-hx, hy)]:
+ px = center_x + lx
+ py = center_y + ly
+ points.append((origin_x + px * c - py * s, origin_y + px * s + py * c))
+ return points
+
+
+def robot_wheel_local_points() -> list[tuple[str, float, float]]:
+ wheels = []
+ for name, (pitch_x, pitch_y, knee_y, wheel_y, wheel_geom_y, knee_x) in ROBOT_WHEEL_POSITIONS.items():
+ x = ROBOT_BODY_CENTER_OFFSET_X + pitch_x + knee_x
+ y = pitch_y + knee_y + wheel_y + wheel_geom_y
+ wheels.append((name, x, y))
+ return wheels
+
+
+class DashboardButton:
+ def __init__(self, rect: tuple[int, int, int, int], label: str, action: str) -> None:
+ self.rect_tuple = rect
+ self.label = label
+ self.action = action
+
+ def rect(self, pygame: Any) -> Any:
+ return pygame.Rect(*self.rect_tuple)
+
+
+class PygameRouteDashboard:
+ def __init__(
+ self,
+ waypoints: list[Waypoint],
+ regions: list[AvoidRegion],
+ terrain_xml: Path,
+ width: int,
+ height: int,
+ fps: float,
+ max_trail: int,
+ control_hz: float,
+ initial_sim_speed: float,
+ ) -> None:
+ os.environ.setdefault("PYGAME_HIDE_SUPPORT_PROMPT", "1")
+ # Older pkg_resources versions used by some pygame installs still
+ # reference pkgutil.ImpImporter, which was removed in Python 3.12.
+ if not hasattr(pkgutil, "ImpImporter"):
+ import zipimport
+ pkgutil.ImpImporter = zipimport.zipimporter # type: ignore[attr-defined]
+ import pygame
+
+ self.pygame = pygame
+ pygame.init()
+ pygame.font.init()
+ self.screen = pygame.display.set_mode((width, height), pygame.RESIZABLE)
+ pygame.display.set_caption("nav_route_sim2sim_check dashboard")
+ self.clock = pygame.time.Clock()
+ self.font = pygame.font.Font(None, 24)
+ self.small_font = pygame.font.Font(None, 19)
+ self.title_font = pygame.font.Font(None, 28)
+ self.waypoints = waypoints
+ self.regions = regions
+ self.geoms = parse_dashboard_geoms(terrain_xml)
+ self.width = width
+ self.height = height
+ self.panel_width = 400
+ self.fps = max(1.0, float(fps))
+ self.control_hz = max(1.0, float(control_hz))
+ self.max_trail = max(50, int(max_trail))
+ self.trail: list[tuple[float, float]] = []
+ self.zoom = 60.0
+ self.pan_x = 0.0
+ self.pan_y = 0.0
+ self.speed_scale = 1.0
+ self.sim_speed = max(0.1, min(10.0, float(initial_sim_speed)))
+ self.rewind_seconds = 0.0
+ self.paused = False
+ self.buttons: list[DashboardButton] = []
+ self._dragging = False
+ self._drag_start: tuple[int, int] | None = None
+ self._last_draw_time = 0.0
+ self._fit_view()
+
+ @property
+ def map_width(self) -> int:
+ return max(240, self.width - self.panel_width)
+
+ def _fit_view(self) -> None:
+ points: list[tuple[float, float]] = [(wp.x, wp.y) for wp in self.waypoints]
+ for region in self.regions:
+ points.extend(region.polygon)
+ for geom in self.geoms:
+ if len(geom.pos) >= 2:
+ points.append((geom.pos[0], geom.pos[1]))
+ if not points:
+ return
+ xs = [p[0] for p in points]
+ ys = [p[1] for p in points]
+ span_x = max(1.0, max(xs) - min(xs))
+ span_y = max(1.0, max(ys) - min(ys))
+ margin = 70
+ self.zoom = max(
+ 12.0,
+ min(
+ 120.0,
+ min((self.map_width - margin * 2) / span_x, (self.height - margin * 2) / span_y),
+ ),
+ )
+ center_x = (min(xs) + max(xs)) * 0.5
+ center_y = (min(ys) + max(ys)) * 0.5
+ self.pan_x = -center_x * self.zoom
+ self.pan_y = center_y * self.zoom
+
+ def _screen(self, x: float, y: float) -> tuple[int, int]:
+ sx = int(self.map_width / 2.0 + x * self.zoom + self.pan_x)
+ sy = int(self.height / 2.0 - y * self.zoom + self.pan_y)
+ return sx, sy
+
+ def _draw_text(self, text: str, x: int, y: int, color: tuple[int, int, int] = (226, 232, 240), small: bool = False) -> None:
+ font = self.small_font if small else self.font
+ self.screen.blit(font.render(text, True, color), (x, y))
+
+ def _draw_card(self, rect: tuple[int, int, int, int], title: str | None = None) -> None:
+ pygame = self.pygame
+ pygame.draw.rect(self.screen, (20, 28, 48), rect, border_radius=7)
+ pygame.draw.rect(self.screen, (47, 61, 90), rect, width=1, border_radius=7)
+ if title:
+ self._draw_text(title, rect[0] + 12, rect[1] + 9, (203, 213, 225), small=True)
+
+ def _draw_bar(self, x: int, y: int, w: int, value: float, lo: float, hi: float, color: tuple[int, int, int]) -> None:
+ pygame = self.pygame
+ frac = 0.0 if hi <= lo else max(0.0, min(1.0, (value - lo) / (hi - lo)))
+ pygame.draw.rect(self.screen, (8, 13, 25), (x, y, w, 8), border_radius=4)
+ pygame.draw.rect(self.screen, color, (x, y, int(w * frac), 8), border_radius=4)
+
+ def _rgba_to_color(self, rgba: tuple[float, float, float, float], alpha: int | None = None) -> tuple[int, int, int] | tuple[int, int, int, int]:
+ rgb = tuple(max(0, min(255, int(v * 255))) for v in rgba[:3])
+ if alpha is None:
+ return rgb
+ return (*rgb, alpha)
+
+ def _draw_grid(self) -> None:
+ pygame = self.pygame
+ bg = (9, 14, 26)
+ grid = (28, 36, 58)
+ axis = (58, 72, 106)
+ self.screen.fill(bg)
+ step = 1.0
+ left_world = -(self.map_width / 2.0 + self.pan_x) / self.zoom
+ right_world = (self.map_width / 2.0 - self.pan_x) / self.zoom
+ bottom_world = -(self.height / 2.0 - self.pan_y) / self.zoom
+ top_world = (self.height / 2.0 + self.pan_y) / self.zoom
+ gx = math.floor(left_world / step) * step
+ while gx <= right_world:
+ sx, _ = self._screen(gx, 0.0)
+ pygame.draw.line(self.screen, axis if abs(gx) < 1e-6 else grid, (sx, 0), (sx, self.height), 2 if abs(gx) < 1e-6 else 1)
+ gx += step
+ gy = math.floor(bottom_world / step) * step
+ while gy <= top_world:
+ _, sy = self._screen(0.0, gy)
+ pygame.draw.line(self.screen, axis if abs(gy) < 1e-6 else grid, (0, sy), (self.map_width, sy), 2 if abs(gy) < 1e-6 else 1)
+ gy += step
+
+ def _draw_map(self, telemetry: dict[str, Any]) -> None:
+ pygame = self.pygame
+ self._draw_grid()
+ self._draw_xml_geoms()
+ overlay = pygame.Surface((self.map_width, self.height), pygame.SRCALPHA)
+ for region in self.regions:
+ pts = [self._screen(px, py) for px, py in region.polygon]
+ if len(pts) >= 3:
+ pygame.draw.polygon(overlay, (244, 63, 94, 72), pts)
+ pygame.draw.polygon(self.screen, (251, 113, 133), pts, width=2)
+ self.screen.blit(overlay, (0, 0))
+
+ route = [self._screen(wp.x, wp.y) for wp in self.waypoints]
+ if len(route) >= 2:
+ pygame.draw.lines(self.screen, (14, 165, 233), False, route, width=2)
+ active_index = int(telemetry.get("wp_index", 0))
+ for index, wp in enumerate(self.waypoints):
+ color = (148, 163, 184)
+ radius = 4
+ if index < active_index:
+ color = (34, 197, 94)
+ elif index == active_index:
+ color = (250, 204, 21)
+ radius = 7
+ pygame.draw.circle(self.screen, color, self._screen(wp.x, wp.y), radius)
+
+ if len(self.trail) >= 2:
+ pygame.draw.lines(self.screen, (34, 197, 94), False, [self._screen(x, y) for x, y in self.trail], width=3)
+
+ x = float(telemetry.get("x", 0.0))
+ y = float(telemetry.get("y", 0.0))
+ yaw = float(telemetry.get("yaw", 0.0))
+ self._draw_robot_pose(x, y, yaw)
+
+ def _draw_robot_pose(self, x: float, y: float, yaw: float) -> None:
+ pygame = self.pygame
+ body = local_rect_polygon(
+ x,
+ y,
+ yaw,
+ ROBOT_BODY_CENTER_OFFSET_X,
+ 0.0,
+ ROBOT_BODY_LENGTH,
+ ROBOT_BODY_WIDTH,
+ )
+ body_screen = [self._screen(px, py) for px, py in body]
+ pygame.draw.polygon(self.screen, (30, 41, 59), body_screen)
+ pygame.draw.polygon(self.screen, (226, 232, 240), body_screen, width=2)
+
+ c = math.cos(yaw)
+ s = math.sin(yaw)
+ for _, lx, ly in robot_wheel_local_points():
+ wx = x + lx * c - ly * s
+ wy = y + lx * s + ly * c
+ wheel = local_rect_polygon(
+ wx,
+ wy,
+ yaw,
+ 0.0,
+ 0.0,
+ ROBOT_WHEEL_VIS_LENGTH,
+ ROBOT_WHEEL_VIS_WIDTH,
+ )
+ pygame.draw.polygon(self.screen, (2, 6, 23), [self._screen(px, py) for px, py in wheel])
+
+ origin = self._screen(x, y)
+ nose = self._screen(x + 0.32 * math.cos(yaw), y + 0.32 * math.sin(yaw))
+ radius = max(3, int(self.zoom * 0.025))
+ pygame.draw.circle(self.screen, (45, 212, 191), origin, radius)
+ pygame.draw.line(self.screen, (45, 212, 191), (origin[0] - radius - 2, origin[1]), (origin[0] + radius + 2, origin[1]), 1)
+ pygame.draw.line(self.screen, (45, 212, 191), (origin[0], origin[1] - radius - 2), (origin[0], origin[1] + radius + 2), 1)
+ pygame.draw.line(self.screen, (240, 253, 250), origin, nose, 2)
+
+ def _draw_xml_geoms(self) -> None:
+ pygame = self.pygame
+ geom_layer = pygame.Surface((self.map_width, self.height), pygame.SRCALPHA)
+ for geom in self.geoms:
+ if len(geom.pos) < 2 or not geom.size:
+ continue
+ color = self._rgba_to_color(geom.rgba, 130 if geom.collidable else 55)
+ outline = self._rgba_to_color(geom.rgba)
+ if geom.kind == "box" and len(geom.size) >= 2:
+ cx, cy = geom.pos[0], geom.pos[1]
+ sx, sy = geom.size[0], geom.size[1]
+ yaw = quat_yaw_wxyz(geom.quat)
+ points = []
+ for lx, ly in [(-sx, -sy), (sx, -sy), (sx, sy), (-sx, sy)]:
+ wx = cx + lx * math.cos(yaw) - ly * math.sin(yaw)
+ wy = cy + lx * math.sin(yaw) + ly * math.cos(yaw)
+ points.append(self._screen(wx, wy))
+ pygame.draw.polygon(geom_layer, color, points)
+ pygame.draw.polygon(self.screen, outline, points, width=1)
+ elif geom.kind == "cylinder":
+ cx, cy = self._screen(geom.pos[0], geom.pos[1])
+ radius = max(2, int(abs(geom.size[0]) * self.zoom))
+ pygame.draw.circle(geom_layer, color, (cx, cy), radius)
+ pygame.draw.circle(self.screen, outline, (cx, cy), radius, width=1)
+ elif geom.kind == "plane":
+ continue
+ self.screen.blit(geom_layer, (0, 0))
+
+ def _draw_panel(self, telemetry: dict[str, Any]) -> None:
+ pygame = self.pygame
+ x0 = self.map_width
+ pygame.draw.rect(self.screen, (12, 18, 32), (x0, 0, self.panel_width, self.height))
+ pygame.draw.line(self.screen, (55, 65, 88), (x0, 0), (x0, self.height), 2)
+ x = x0 + 16
+ y = 14
+ self.screen.blit(self.title_font.render("Route Monitor", True, (248, 250, 252)), (x, y))
+ self._draw_text("MuJoCo sim2sim telemetry", x, y + 25, (148, 163, 184), small=True)
+
+ y += 58
+ card_w = self.panel_width - 32
+ self._draw_card((x, y, card_w, 112), "state")
+ mode_color = (250, 204, 21) if str(telemetry.get("mode", "")).lower().startswith("crawl") else (45, 212, 191)
+ self._draw_text(str(telemetry.get("mode", "rough")), x + 12, y + 31, mode_color)
+ self._draw_text(f"t {float(telemetry.get('time', 0.0)):.2f}s", x + 130, y + 31, (226, 232, 240))
+ self._draw_text(
+ f"wp {telemetry.get('wp_id', '')} {int(telemetry.get('wp_index', 0))}/{int(telemetry.get('wp_count', 0))}",
+ x + 12,
+ y + 60,
+ (226, 232, 240),
+ )
+ self._draw_text(
+ f"x {float(telemetry.get('x', 0.0)):.2f} y {float(telemetry.get('y', 0.0)):.2f} yaw {math.degrees(float(telemetry.get('yaw', 0.0))):.1f}",
+ x + 12,
+ y + 86,
+ (148, 163, 184),
+ small=True,
+ )
+
+ y += 124
+ self._draw_card((x, y, card_w, 128), "command limits")
+ cmd_vx = float(telemetry.get("cmd_vx", 0.0))
+ cmd_vy = float(telemetry.get("cmd_vy", 0.0))
+ cmd_wz = float(telemetry.get("cmd_wz", 0.0))
+ for row, (name, value, color) in enumerate(
+ [
+ ("x", cmd_vx, (34, 197, 94)),
+ ("y", cmd_vy, (14, 165, 233)),
+ ("yaw", cmd_wz, (250, 204, 21)),
+ ]
+ ):
+ yy = y + 32 + row * 30
+ self._draw_text(f"{name} {value:+.3f}", x + 12, yy - 7, (226, 232, 240), small=True)
+ self._draw_bar(x + 90, yy, 245, value, -1.0, 1.0, color)
+ cx = x + 90 + 245 // 2
+ pygame.draw.line(self.screen, (71, 85, 105), (cx, yy - 3), (cx, yy + 11), 1)
+
+ y += 140
+ self._draw_card((x, y, card_w, 112), "safety")
+ margin = float(telemetry.get("margin", 0.0))
+ margin_color = (251, 113, 133) if margin < 0.0 else (34, 197, 94)
+ self._draw_text(f"margin {margin:+.3f} m", x + 12, y + 32, margin_color)
+ self._draw_text(f"clearance {float(telemetry.get('clearance', 0.0)):.3f} m", x + 190, y + 32, (226, 232, 240))
+ self._draw_text(f"region {telemetry.get('region', '')}", x + 12, y + 62, (148, 163, 184), small=True)
+ self._draw_text(
+ f"roll {float(telemetry.get('roll_deg', 0.0)):.1f} pitch {float(telemetry.get('pitch_deg', 0.0)):.1f} tilt {float(telemetry.get('max_tilt_deg', 0.0)):.1f}",
+ x + 12,
+ y + 84,
+ (148, 163, 184),
+ small=True,
+ )
+
+ y += 124
+ self._draw_controls(x, y, card_w)
+
+ def _draw_controls(self, x: int, y: int, card_w: int) -> None:
+ pygame = self.pygame
+ self._draw_card((x, y, card_w, min(245, self.height - y - 12)), "controls")
+ x += 12
+ y += 32
+ self._draw_text(f"nav {self.speed_scale:.2f}x", x, y, (226, 232, 240))
+ self._draw_text(f"sim {self.sim_speed:.1f}x", x + 132, y, (226, 232, 240))
+ self._draw_text(f"rtf {float(self._last_rtf):.1f}x" if hasattr(self, "_last_rtf") else "rtf --", x + 250, y, (148, 163, 184), small=True)
+ y += 30
+ labels = [
+ ("nav -", "speed_down"),
+ ("nav +", "speed_up"),
+ ("nav 1x", "speed_normal"),
+ ("sim -", "sim_down"),
+ ("sim +", "sim_up"),
+ ("sim 1x", "sim_normal"),
+ ("sim 5x", "sim_5"),
+ ("sim 10x", "sim_10"),
+ ("rew -1s", "rewind_1"),
+ ("rew -5s", "rewind_5"),
+ ("pause", "pause"),
+ ("fit", "fit"),
+ ]
+ self.buttons = []
+ bx = x
+ by = y
+ for i, (label, action) in enumerate(labels):
+ if i and i % 3 == 0:
+ bx = x
+ by += 36
+ button = DashboardButton((bx, by, 104, 28), label, action)
+ rect = button.rect(pygame)
+ fill = (31, 41, 55)
+ if action == "pause" and self.paused:
+ fill = (245, 158, 11)
+ elif action == "speed_normal" and abs(self.speed_scale - 1.0) < 1e-6:
+ fill = (16, 185, 129)
+ elif action == "sim_normal" and abs(self.sim_speed - 1.0) < 1e-6:
+ fill = (14, 165, 233)
+ pygame.draw.rect(self.screen, fill, rect, border_radius=5)
+ pygame.draw.rect(self.screen, (71, 85, 105), rect, width=1, border_radius=5)
+ text = self.small_font.render(label, True, (248, 250, 252))
+ self.screen.blit(text, (rect.centerx - text.get_width() // 2, rect.centery - text.get_height() // 2))
+ self.buttons.append(button)
+ bx += 112
+ by += 48
+ self._draw_text("keys: +/- nav, [/ ] sim, backspace rewind, space pause", x, by, (148, 163, 184), small=True)
+ self._draw_text("wheel zoom, right drag pan, f fit", x, by + 20, (148, 163, 184), small=True)
+
+ def pump_events(self) -> bool:
+ pygame = self.pygame
+ for event in pygame.event.get():
+ if event.type == pygame.QUIT:
+ return False
+ if event.type == pygame.KEYDOWN:
+ if event.key in (pygame.K_EQUALS, pygame.K_PLUS, pygame.K_KP_PLUS):
+ self.speed_scale = min(10.0, self.speed_scale + 0.10)
+ elif event.key in (pygame.K_MINUS, pygame.K_KP_MINUS):
+ self.speed_scale = max(0.05, self.speed_scale - 0.10)
+ elif event.key == pygame.K_0:
+ self.speed_scale = 1.0
+ elif event.key == pygame.K_RIGHTBRACKET:
+ self.sim_speed = self._next_sim_speed(1)
+ elif event.key == pygame.K_LEFTBRACKET:
+ self.sim_speed = self._next_sim_speed(-1)
+ elif event.key == pygame.K_BACKSPACE:
+ self.rewind_seconds = max(self.rewind_seconds, 1.0)
+ elif event.key == pygame.K_SPACE:
+ self.paused = not self.paused
+ elif event.key == pygame.K_f:
+ self._fit_view()
+ if event.type == pygame.VIDEORESIZE:
+ self.width = max(700, int(event.w))
+ self.height = max(480, int(event.h))
+ self.screen = pygame.display.set_mode((self.width, self.height), pygame.RESIZABLE)
+ self._fit_view()
+ if event.type == pygame.MOUSEBUTTONDOWN:
+ if event.button == 1:
+ for button in self.buttons:
+ if button.rect(pygame).collidepoint(event.pos):
+ self._handle_button(button.action)
+ break
+ elif event.button == 3:
+ self._dragging = True
+ self._drag_start = event.pos
+ elif event.button == 4 and event.pos[0] < self.map_width:
+ self.zoom = min(240.0, self.zoom * 1.12)
+ elif event.button == 5 and event.pos[0] < self.map_width:
+ self.zoom = max(8.0, self.zoom / 1.12)
+ if event.type == pygame.MOUSEBUTTONUP and event.button == 3:
+ self._dragging = False
+ self._drag_start = None
+ if event.type == pygame.MOUSEMOTION and self._dragging and self._drag_start is not None:
+ dx = event.pos[0] - self._drag_start[0]
+ dy = event.pos[1] - self._drag_start[1]
+ self.pan_x += dx
+ self.pan_y += dy
+ self._drag_start = event.pos
+ return True
+
+ def draw_if_due(self, telemetry: dict[str, Any], force: bool = False) -> None:
+ now = time.perf_counter()
+ draw_interval = 1.0 / max(1.0, self.fps)
+ if force or now - self._last_draw_time >= draw_interval:
+ self._last_rtf = float(telemetry.get("rtf", 0.0))
+ self.trail.append((float(telemetry.get("x", 0.0)), float(telemetry.get("y", 0.0))))
+ if len(self.trail) > self.max_trail:
+ self.trail = self.trail[-self.max_trail :]
+ self._draw_map(telemetry)
+ self._draw_panel(telemetry)
+ self.pygame.display.flip()
+ self._last_draw_time = now
+
+ def _handle_button(self, action: str) -> None:
+ if action == "speed_down":
+ self.speed_scale = max(0.05, self.speed_scale - 0.10)
+ elif action == "speed_up":
+ self.speed_scale = min(10.0, self.speed_scale + 0.10)
+ elif action == "speed_normal":
+ self.speed_scale = 1.0
+ elif action == "sim_down":
+ self.sim_speed = self._next_sim_speed(-1)
+ elif action == "sim_up":
+ self.sim_speed = self._next_sim_speed(1)
+ elif action == "sim_normal":
+ self.sim_speed = 1.0
+ elif action == "sim_5":
+ self.sim_speed = 5.0
+ elif action == "sim_10":
+ self.sim_speed = 10.0
+ elif action == "rewind_1":
+ self.rewind_seconds = max(self.rewind_seconds, 1.0)
+ elif action == "rewind_5":
+ self.rewind_seconds = max(self.rewind_seconds, 5.0)
+ elif action == "pause":
+ self.paused = not self.paused
+ elif action == "fit":
+ self._fit_view()
+
+ def _next_sim_speed(self, direction: int) -> float:
+ steps = [0.2, 0.5, 0.8, 1.0, 1.5, 2.0, 3.0, 5.0, 10.0]
+ current = float(self.sim_speed)
+ if direction > 0:
+ for value in steps:
+ if value > current + 1e-6:
+ return value
+ return steps[-1]
+ for value in reversed(steps):
+ if value < current - 1e-6:
+ return value
+ return steps[0]
+
+ def consume_rewind_seconds(self) -> float:
+ value = self.rewind_seconds
+ self.rewind_seconds = 0.0
+ return value
+
+ def close(self) -> None:
+ self.pygame.quit()
+
+
+def capture_route_snapshot(io: MuJoCoIO, follower: WaypointFollower) -> RouteSnapshot:
+ return RouteSnapshot(
+ time=float(io.d.time),
+ qpos=io.d.qpos.copy(),
+ qvel=io.d.qvel.copy(),
+ ctrl=io.d.ctrl.copy(),
+ follower_index=int(follower.index),
+ follower_turn_in_place=bool(follower.turn_in_place),
+ follower_stable_count=int(follower.stable_count),
+ follower_wp_start_time=float(follower.wp_start_time),
+ follower_best_dist=float(getattr(follower, "best_dist", 0.0)) if hasattr(follower, "best_dist") else None,
+ follower_last_progress_time=float(getattr(follower, "last_progress_time", 0.0)) if hasattr(follower, "last_progress_time") else None,
+ follower_recovery_until=float(getattr(follower, "recovery_until", 0.0)) if hasattr(follower, "recovery_until") else None,
+ follower_recovery_count=int(getattr(follower, "recovery_count", 0)) if hasattr(follower, "recovery_count") else None,
+ follower_recovery_turn_sign=float(getattr(follower, "recovery_turn_sign", 1.0)) if hasattr(follower, "recovery_turn_sign") else None,
+ )
+
+
+def restore_route_snapshot(io: MuJoCoIO, follower: WaypointFollower, snapshot: RouteSnapshot) -> None:
+ io.d.time = snapshot.time
+ io.d.qpos[:] = snapshot.qpos
+ io.d.qvel[:] = snapshot.qvel
+ io.d.ctrl[:] = snapshot.ctrl
+ follower.index = snapshot.follower_index
+ follower.turn_in_place = snapshot.follower_turn_in_place
+ follower.stable_count = snapshot.follower_stable_count
+ follower.wp_start_time = snapshot.follower_wp_start_time
+ if snapshot.follower_best_dist is not None and hasattr(follower, "best_dist"):
+ setattr(follower, "best_dist", snapshot.follower_best_dist)
+ if snapshot.follower_last_progress_time is not None and hasattr(follower, "last_progress_time"):
+ setattr(follower, "last_progress_time", snapshot.follower_last_progress_time)
+ if snapshot.follower_recovery_until is not None and hasattr(follower, "recovery_until"):
+ setattr(follower, "recovery_until", snapshot.follower_recovery_until)
+ if snapshot.follower_recovery_count is not None and hasattr(follower, "recovery_count"):
+ setattr(follower, "recovery_count", snapshot.follower_recovery_count)
+ if snapshot.follower_recovery_turn_sign is not None and hasattr(follower, "recovery_turn_sign"):
+ setattr(follower, "recovery_turn_sign", snapshot.follower_recovery_turn_sign)
+ mujoco.mj_forward(io.m, io.d)
+
+
+def run_sim(args: argparse.Namespace) -> tuple[SimResult, Path | None, Path]:
+ args.out_dir.mkdir(parents=True, exist_ok=True)
+ stamp = time.strftime("%Y%m%d_%H%M%S") + f"_{time.time_ns() % 1_000_000_000:09d}"
+ run_dir = args.out_dir / f"route_check_{stamp}"
+ run_dir.mkdir(parents=True, exist_ok=True)
+
+ waypoints, regions = load_requested_route(args)
+ footprint_radius = (
+ float(args.footprint_radius)
+ if args.footprint_radius is not None
+ else default_lateral_footprint_radius()
+ )
+ required_clearance = footprint_radius + float(args.avoid_margin)
+ cfg = SimConfig(
+ max_vx=float(args.max_vx),
+ max_vy=float(args.max_vy),
+ max_wz=float(args.max_wz),
+ kp_dist=float(args.kp_dist),
+ kp_yaw=float(args.kp_yaw),
+ yaw_stop_threshold_deg=float(args.yaw_stop_threshold_deg),
+ turn_in_place_enter_deg=float(args.turn_in_place_enter_deg),
+ turn_in_place_exit_deg=float(args.turn_in_place_exit_deg),
+ max_total_time_s=float(args.max_time),
+ waypoint_timeout_s=float(args.waypoint_timeout),
+ lookahead_m=float(args.lookahead),
+ min_cmd_vx=float(args.min_cmd_vx),
+ creep_cmd_vx=float(args.creep_cmd_vx),
+ cmd_vx_scale=float(args.cmd_vx_scale),
+ stuck_timeout_s=float(args.stuck_timeout),
+ recovery_duration_s=float(args.recovery_duration),
+ max_recoveries=int(args.max_recoveries),
+ slalom_script_enabled=bool(args.slalom_script),
+ slalom_script_start_tolerance=float(args.slalom_script_start_tolerance),
+ slalom_script_pos_tolerance=float(args.slalom_script_pos_tolerance),
+ slalom_script_yaw_tolerance_deg=float(args.slalom_script_yaw_tolerance_deg),
+ slalom_script_stable_cycles=int(args.slalom_script_stable_cycles),
+ slalom_script_rotate_steps_enabled=bool(args.slalom_script_rotate_steps),
+ slalom_script_final_rotate_enabled=bool(args.slalom_script_final_rotate),
+ slalom_script_require_yaw_at_step=bool(args.slalom_script_require_yaw_at_step),
+ slalom_script_kp_dist=float(args.slalom_script_kp_dist),
+ slalom_script_kp_yaw=float(args.slalom_script_kp_yaw),
+ slalom_script_max_vx=float(args.slalom_script_max_vx),
+ slalom_script_max_vy=float(args.slalom_script_max_vy),
+ slalom_script_max_wz=float(args.slalom_script_max_wz),
+ slalom_script_min_cmd_linear=float(args.slalom_script_min_cmd_linear),
+ slalom_script_min_cmd_angular=float(args.slalom_script_min_cmd_angular),
+ slalom_script_min_cmd_epsilon=float(args.slalom_script_min_cmd_epsilon),
+ slalom_script_min_step_distance=float(args.slalom_script_min_step_distance),
+ slalom_script_yaw_gate_deg=float(args.slalom_script_yaw_gate_deg),
+ slalom_script_drive_yaw_source=str(args.slalom_script_drive_yaw_source),
+ slalom_script_curvature_speed_enabled=bool(args.slalom_script_curvature_speed),
+ slalom_script_curvature_min_scale=max(
+ 0.1, min(1.0, float(args.slalom_script_curvature_min_scale))
+ ),
+ )
+
+ combined_xml = make_combined_terrain_xml(args.terrain_xml.resolve(), run_dir)
+ old_cwd = Path.cwd()
+ try:
+ io = MuJoCoIO(combined_xml, args.robot_xml.resolve(), args.hfield_dir.resolve())
+ finally:
+ os.chdir(old_cwd)
+ if args.policy_backend == "rough-ik-crawl":
+ policy = RoughPolicyIkCrawlBackend(args.onnx.resolve())
+ elif args.policy_backend == "policy-runner":
+ crawl_path = args.crawl_onnx.resolve() if args.crawl_onnx and args.crawl_onnx.exists() else None
+ policy = ExistingPolicyBackend(args.onnx.resolve(), crawl_path)
+ else:
+ policy = DirectOnnxBackend(args.onnx.resolve(), DEPLOY_DEFAULT_DOF_POS)
+ follower: WaypointFollower
+ if args.follower in {"slalom-script", "nav-script"}:
+ follower = SlalomScriptFollower(waypoints, cfg)
+ elif args.follower == "nav-good":
+ follower = NavGoodFollower(waypoints, cfg)
+ elif args.follower == "nav-sim2sim":
+ follower = NavSim2SimFollower(waypoints, cfg)
+ elif args.follower == "pure-pursuit":
+ follower = PurePursuitFollower(waypoints, cfg)
+ else:
+ follower = WaypointFollower(waypoints, cfg)
+ if (
+ args.skip_initial_waypoint
+ or (args.auto_skip_slice_start and int(args.start_index) > 1)
+ ) and len(waypoints) > 1:
+ follower.index = 1
+
+ start_x, start_y, start_yaw_deg = initial_pose_for_route(args, waypoints)
+ start_x += float(args.start_x_offset)
+ start_y += float(args.start_y_offset)
+ set_robot_pose(
+ io,
+ start_x,
+ start_y,
+ start_yaw_deg,
+ float(args.start_z),
+ int(args.settle_steps),
+ policy.default_dof_pos,
+ )
+ policy.reset()
+ follower.reset_timing(io.d.time)
+
+ sim_steps_per_control = max(1, int(round((1.0 / cfg.control_hz) / io.m.opt.timestep)))
+ csv_path = None if args.no_csv else run_dir / "trajectory.csv"
+ csv_file = None
+ writer = None
+ if csv_path is not None:
+ csv_file = csv_path.open("w", newline="", encoding="utf-8")
+ writer = csv.writer(csv_file)
+ writer.writerow(
+ [
+ "time",
+ "wp_index",
+ "wp_id",
+ "script_active",
+ "script_step",
+ "script_step_count",
+ "x",
+ "y",
+ "z",
+ "yaw_deg",
+ "roll_deg",
+ "pitch_deg",
+ "cmd_vx",
+ "cmd_vy",
+ "cmd_wz",
+ "clearance",
+ "margin",
+ "region",
+ "raw_wheel_fl",
+ "raw_wheel_fr",
+ "raw_wheel_rl",
+ "raw_wheel_rr",
+ "qvel_wheel_fl",
+ "qvel_wheel_fr",
+ "qvel_wheel_rl",
+ "qvel_wheel_rr",
+ ]
+ )
+
+ min_clearance = float("inf")
+ min_clearance_region = ""
+ min_clearance_wp = ""
+ max_roll = 0.0
+ max_pitch = 0.0
+ max_tilt = 0.0
+ samples = 0
+ reason = "max time reached"
+ success = False
+
+ viewer = None
+ if args.viewer:
+ viewer = mujoco.viewer.launch_passive(io.m, io.d)
+ viewer.cam.distance = 5.0
+ viewer.cam.elevation = -35.0
+ viewer.cam.azimuth = 135.0
+ sim_speed_factor = max(0.1, min(10.0, float(args.sim_speed)))
+ if args.dashboard:
+ sim_speed_factor = max(0.1, min(10.0, float(args.dashboard_sim_speed)))
+ dashboard = None
+ if args.dashboard:
+ dashboard = PygameRouteDashboard(
+ waypoints=waypoints,
+ regions=regions,
+ terrain_xml=args.terrain_xml.resolve(),
+ width=int(args.dashboard_width),
+ height=int(args.dashboard_height),
+ fps=float(args.dashboard_fps),
+ max_trail=int(args.dashboard_trail),
+ control_hz=cfg.control_hz,
+ initial_sim_speed=sim_speed_factor,
+ )
+ snapshots: list[RouteSnapshot] = []
+ snapshot_keep_s = 45.0
+ control_dt = 1.0 / cfg.control_hz
+ next_exec_time = time.perf_counter()
+ wall_start_time = next_exec_time
+ sim_start_time = float(io.d.time)
+ randomizer = random.Random(int(args.random_seed))
+ command_delay_steps = max(0, int(round(float(args.command_delay_ms) * 0.001 * cfg.control_hz)))
+ action_delay_steps = max(0, int(round(float(args.action_delay_ms) * 0.001 * cfg.control_hz)))
+ command_queue: deque[np.ndarray] = deque()
+ action_queue: deque[np.ndarray] = deque()
+ last_deployed_command = np.zeros(3, dtype=np.float32)
+ base_body_id = mujoco.mj_name2id(io.m, mujoco.mjtObj.mjOBJ_BODY, "base_link")
+
+ try:
+ while float(io.d.time) - sim_start_time < cfg.max_total_time_s:
+ if viewer is not None and not viewer.is_running():
+ reason = "viewer closed"
+ break
+ if dashboard is not None and not dashboard.pump_events():
+ reason = "dashboard closed"
+ break
+ if dashboard is not None:
+ sim_speed_factor = max(0.1, min(10.0, float(dashboard.sim_speed)))
+ x = float(io.d.qpos[0])
+ y = float(io.d.qpos[1])
+ z = float(io.d.qpos[2])
+ yaw = robot_yaw_from_qpos(io.d.qpos)
+ sensed_x = x
+ sensed_y = y
+ sensed_yaw = yaw
+ if args.deployment_randomization:
+ sensed_x += randomizer.gauss(0.0, max(0.0, float(args.pose_noise_xy)))
+ sensed_y += randomizer.gauss(0.0, max(0.0, float(args.pose_noise_xy)))
+ sensed_yaw += math.radians(
+ randomizer.gauss(0.0, max(0.0, float(args.pose_noise_yaw_deg)))
+ )
+ heading_yaw = follower.normalize_angle(sensed_yaw + math.radians(float(args.heading_offset_deg)))
+ roll, pitch, _ = quat_to_euler_wxyz(io.d.qpos[3:7].copy())
+ roll_deg = math.degrees(roll)
+ pitch_deg = math.degrees(pitch)
+ max_roll = max(max_roll, abs(roll_deg))
+ max_pitch = max(max_pitch, abs(pitch_deg))
+ max_tilt = max(max_tilt, math.hypot(roll_deg, pitch_deg))
+
+ active = follower.active
+ active_id = active.id if active is not None else "done"
+ clearance, region_name = clearance_to_regions(x, y, regions)
+ margin = clearance - required_clearance
+ if clearance < min_clearance:
+ min_clearance = clearance
+ min_clearance_region = region_name
+ min_clearance_wp = active_id
+
+ if z < float(args.min_base_z) or abs(roll_deg) > 70.0 or abs(pitch_deg) > 70.0:
+ reason = f"robot fell or tipped at t={io.d.time:.2f}s"
+ break
+ if args.stop_on_clearance_violation and margin < 0.0:
+ reason = (
+ f"clearance violation at t={io.d.time:.2f}s: "
+ f"clearance={clearance:.3f} required={required_clearance:.3f} region={region_name}"
+ )
+ break
+
+ command, nav_state = follower.update(sensed_x, sensed_y, heading_yaw, io.d.time)
+ if nav_state == "complete":
+ success = True
+ reason = "route complete"
+ break
+ if nav_state and nav_state.startswith("timeout"):
+ reason = nav_state
+ break
+ if nav_state:
+ reason = nav_state
+ break
+ skip_local_safety = False
+ if active is not None and (active.policy or "").lower() == "crawl":
+ skip_local_safety = True
+ if hasattr(follower, "_is_charge_segment") and active is not None:
+ skip_local_safety = bool(getattr(follower, "_is_charge_segment")(x, y, active)) or skip_local_safety
+ if args.local_safety and not skip_local_safety:
+ command = safety_filter_command(
+ command,
+ x,
+ y,
+ heading_yaw,
+ follower.active,
+ regions,
+ required_clearance,
+ cfg,
+ float(args.safety_horizon),
+ float(args.safety_dt),
+ )
+ if dashboard is not None:
+ command[0] = float(command[0]) * dashboard.speed_scale
+ if dashboard.paused:
+ command[:] = 0.0
+ command = np.clip(command, -1.0, 1.0).astype(np.float32)
+ if args.deployment_randomization:
+ if randomizer.random() < max(0.0, min(1.0, float(args.command_dropout_prob))):
+ command = last_deployed_command.copy()
+ command_queue.append(command.copy())
+ command = command_queue.popleft() if len(command_queue) > command_delay_steps else np.zeros(3, dtype=np.float32)
+ last_deployed_command = command.copy()
+
+ use_route_policy = args.enable_policy_switch or args.policy_backend == "rough-ik-crawl"
+ active_policy = active.policy if active is not None and use_route_policy else "rough"
+ policy.maybe_switch_policy(active_policy)
+ policy_mode = str(getattr(policy, "requested_mode", getattr(getattr(policy, "runner", None), "current_policy_name", active_policy)))
+ default_dof_pos = policy.default_dof_pos
+ obs = io.get_obs_53d(command, default_dof_pos, policy.last_actions)
+ scaled_actions, raw_actions = policy.step(obs)
+ if args.deployment_randomization:
+ action_queue.append(scaled_actions.copy())
+ scaled_actions = action_queue.popleft() if len(action_queue) > action_delay_steps else np.zeros_like(scaled_actions)
+ io.send_actions(scaled_actions, default_dof_pos)
+ for _ in range(sim_steps_per_control):
+ if args.deployment_randomization and base_body_id != -1:
+ io.d.xfrc_applied[base_body_id] = 0.0
+ push_phase = (float(io.d.time) - sim_start_time) % max(0.1, float(args.push_interval))
+ if push_phase < max(0.0, float(args.push_duration)):
+ angle = randomizer.uniform(-math.pi, math.pi)
+ force = max(0.0, float(args.push_force))
+ io.d.xfrc_applied[base_body_id, 0] = force * math.cos(angle)
+ io.d.xfrc_applied[base_body_id, 1] = force * math.sin(angle)
+ mujoco.mj_step(io.m, io.d)
+ pole_contact = None
+ pole_contact_robot_geom = None
+ pole_contact_robot_body = None
+ pole_contact_position = None
+ border_contact = None
+ for contact_index in range(io.d.ncon):
+ contact = io.d.contact[contact_index]
+ geom1 = mujoco.mj_id2name(io.m, mujoco.mjtObj.mjOBJ_GEOM, contact.geom1) or ""
+ geom2 = mujoco.mj_id2name(io.m, mujoco.mjtObj.mjOBJ_GEOM, contact.geom2) or ""
+ if geom1.startswith("slalom_pole_") or geom2.startswith("slalom_pole_"):
+ pole_contact = geom1 if geom1.startswith("slalom_pole_") else geom2
+ robot_geom_id = contact.geom2 if geom1.startswith("slalom_pole_") else contact.geom1
+ pole_contact_robot_geom = geom2 if geom1.startswith("slalom_pole_") else geom1
+ robot_body_id = int(io.m.geom_bodyid[robot_geom_id])
+ pole_contact_robot_body = (
+ mujoco.mj_id2name(io.m, mujoco.mjtObj.mjOBJ_BODY, robot_body_id) or "unknown"
+ )
+ pole_contact_position = tuple(float(value) for value in contact.pos)
+ break
+ if geom1.startswith("border_") or geom2.startswith("border_"):
+ border_contact = geom1 if geom1.startswith("border_") else geom2
+ break
+ if pole_contact is not None:
+ contact_xyz = ""
+ if pole_contact_position is not None:
+ contact_xyz = (
+ f" at xyz=({pole_contact_position[0]:.3f},"
+ f"{pole_contact_position[1]:.3f},{pole_contact_position[2]:.3f})"
+ )
+ reason = (
+ f"physical contact: robot body {pole_contact_robot_body or 'unknown'} "
+ f"geom {pole_contact_robot_geom or 'unnamed'} with "
+ f"{pole_contact}{contact_xyz} at t={io.d.time:.2f}s near waypoint {active_id}"
+ )
+ break
+ if border_contact is not None:
+ reason = f"physical contact with field boundary {border_contact} at t={io.d.time:.2f}s near waypoint {active_id}"
+ break
+ if pole_contact is not None or border_contact is not None:
+ break
+ if dashboard is not None:
+ snapshots.append(capture_route_snapshot(io, follower))
+ cutoff_time = float(io.d.time) - snapshot_keep_s
+ while len(snapshots) > 2 and snapshots[0].time < cutoff_time:
+ snapshots.pop(0)
+ viewer_sync_stride = max(1, int(args.viewer_sync_every), int(math.ceil(max(1.0, sim_speed_factor) * 2.0)))
+ if viewer is not None and samples % viewer_sync_stride == 0:
+ base_id = mujoco.mj_name2id(io.m, mujoco.mjtObj.mjOBJ_BODY, "base_link")
+ if base_id != -1:
+ viewer.cam.lookat[:] = io.d.xpos[base_id]
+ viewer.sync()
+
+ if dashboard is not None:
+ wall_elapsed = max(1e-6, time.perf_counter() - wall_start_time)
+ rtf = max(0.0, (float(io.d.time) - sim_start_time) / wall_elapsed)
+ telemetry = {
+ "time": float(io.d.time),
+ "rtf": rtf,
+ "status": "running",
+ "mode": policy_mode,
+ "wp_index": follower.index,
+ "wp_count": len(waypoints),
+ "wp_id": active_id,
+ "x": x,
+ "y": y,
+ "z": z,
+ "yaw": yaw,
+ "roll_deg": roll_deg,
+ "pitch_deg": pitch_deg,
+ "cmd_vx": float(command[0]),
+ "cmd_vy": float(command[1]),
+ "cmd_wz": float(command[2]),
+ "clearance": clearance,
+ "margin": margin,
+ "region": region_name,
+ "min_margin": min_clearance - required_clearance,
+ "max_tilt_deg": max_tilt,
+ }
+ dashboard.draw_if_due(telemetry)
+ rewind_seconds = dashboard.consume_rewind_seconds()
+ if rewind_seconds > 0.0 and snapshots:
+ target_time = max(0.0, float(io.d.time) - rewind_seconds)
+ rewind_snapshot = snapshots[0]
+ for snapshot in snapshots:
+ if snapshot.time <= target_time:
+ rewind_snapshot = snapshot
+ else:
+ break
+ restore_route_snapshot(io, follower, rewind_snapshot)
+ snapshots = [snapshot for snapshot in snapshots if snapshot.time <= rewind_snapshot.time]
+ if hasattr(policy, "reset"):
+ policy.reset()
+ reason = "max time reached"
+ next_exec_time = time.perf_counter()
+
+ if writer is not None and samples % max(1, int(args.sample_every)) == 0:
+ writer.writerow(
+ [
+ f"{io.d.time:.4f}",
+ follower.index,
+ active_id,
+ int(bool(getattr(follower, "slalom_script_active", False))),
+ int(getattr(follower, "slalom_script_step_index", -1)),
+ len(getattr(follower, "slalom_script_steps", [])),
+ f"{x:.5f}",
+ f"{y:.5f}",
+ f"{z:.5f}",
+ f"{math.degrees(yaw):.3f}",
+ f"{roll_deg:.3f}",
+ f"{pitch_deg:.3f}",
+ f"{float(command[0]):.4f}",
+ f"{float(command[1]):.4f}",
+ f"{float(command[2]):.4f}",
+ f"{clearance:.5f}",
+ f"{margin:.5f}",
+ region_name,
+ f"{raw_actions[12]:.5f}",
+ f"{raw_actions[13]:.5f}",
+ f"{raw_actions[14]:.5f}",
+ f"{raw_actions[15]:.5f}",
+ f"{io.d.qvel[io.qvel_ids[12]]:.5f}",
+ f"{io.d.qvel[io.qvel_ids[13]]:.5f}",
+ f"{io.d.qvel[io.qvel_ids[14]]:.5f}",
+ f"{io.d.qvel[io.qvel_ids[15]]:.5f}",
+ ]
+ )
+ samples += 1
+ jitter_s = 0.0
+ if args.deployment_randomization:
+ jitter_s = randomizer.uniform(
+ -max(0.0, float(args.control_jitter_ms)) * 0.001,
+ max(0.0, float(args.control_jitter_ms)) * 0.001,
+ )
+ dt_real = max(0.001, control_dt + jitter_s) / sim_speed_factor
+ next_exec_time += dt_real
+ now = time.perf_counter()
+ sleep_time = next_exec_time - now
+ if sleep_time > 0.0:
+ time.sleep(sleep_time)
+ elif sleep_time < -dt_real:
+ next_exec_time = now
+ finally:
+ wall_time = max(1e-6, time.perf_counter() - wall_start_time)
+ elapsed_sim_time = max(0.0, float(io.d.time) - sim_start_time)
+ real_time_factor = max(0.0, elapsed_sim_time / wall_time)
+ if csv_file is not None:
+ csv_file.close()
+ if viewer is not None:
+ viewer.close()
+ if dashboard is not None:
+ dashboard.close()
+ policy.close()
+
+ result = SimResult(
+ success=success,
+ reason=reason,
+ sim_time=elapsed_sim_time,
+ reached_count=follower.index,
+ waypoint_count=len(waypoints),
+ min_clearance=float(min_clearance),
+ min_margin=float(min_clearance - required_clearance),
+ min_clearance_region=min_clearance_region,
+ min_clearance_wp=min_clearance_wp,
+ max_roll_deg=max_roll,
+ max_pitch_deg=max_pitch,
+ max_tilt_deg=max_tilt,
+ samples=samples,
+ wall_time=wall_time,
+ real_time_factor=real_time_factor,
+ )
+ report = {
+ "success": result.success,
+ "reason": result.reason,
+ "sim_time": round(result.sim_time, 4),
+ "reached_count": result.reached_count,
+ "waypoint_count": result.waypoint_count,
+ "min_clearance": round(result.min_clearance, 6),
+ "required_clearance": round(required_clearance, 6),
+ "min_margin": round(result.min_margin, 6),
+ "min_clearance_region": result.min_clearance_region,
+ "min_clearance_wp": result.min_clearance_wp,
+ "max_roll_deg": round(result.max_roll_deg, 3),
+ "max_pitch_deg": round(result.max_pitch_deg, 3),
+ "max_tilt_deg": round(result.max_tilt_deg, 3),
+ "samples": result.samples,
+ "wall_time": round(result.wall_time, 4),
+ "real_time_factor": round(result.real_time_factor, 4),
+ "sim_speed": round(sim_speed_factor, 4),
+ "points": str(args.points.resolve()),
+ "mission": args.mission,
+ "terrain_xml": str(args.terrain_xml.resolve()),
+ "onnx": str(args.onnx.resolve()),
+ "crawl_onnx": str(args.crawl_onnx.resolve()) if args.crawl_onnx else None,
+ "policy_backend": args.policy_backend,
+ "enable_policy_switch": args.enable_policy_switch,
+ "follower": args.follower,
+ "max_vx": args.max_vx,
+ "max_vy": args.max_vy,
+ "max_wz": args.max_wz,
+ "lookahead": args.lookahead,
+ "cmd_vx_scale": args.cmd_vx_scale,
+ "slalom_script": args.slalom_script,
+ "slalom_script_start_tolerance": args.slalom_script_start_tolerance,
+ "slalom_script_pos_tolerance": args.slalom_script_pos_tolerance,
+ "slalom_script_kp_dist": args.slalom_script_kp_dist,
+ "slalom_script_kp_yaw": args.slalom_script_kp_yaw,
+ "slalom_script_max_vx": args.slalom_script_max_vx,
+ "slalom_script_max_vy": args.slalom_script_max_vy,
+ "slalom_script_max_wz": args.slalom_script_max_wz,
+ "slalom_script_min_cmd_linear": args.slalom_script_min_cmd_linear,
+ "slalom_script_min_cmd_angular": args.slalom_script_min_cmd_angular,
+ "slalom_script_min_cmd_epsilon": args.slalom_script_min_cmd_epsilon,
+ "slalom_script_yaw_gate_deg": args.slalom_script_yaw_gate_deg,
+ "slalom_script_drive_yaw_source": args.slalom_script_drive_yaw_source,
+ "local_safety": args.local_safety,
+ "safety_horizon": args.safety_horizon,
+ "start_yaw_offset_deg": args.start_yaw_offset_deg,
+ "start_x_offset": args.start_x_offset,
+ "start_y_offset": args.start_y_offset,
+ "heading_offset_deg": args.heading_offset_deg,
+ "start_z": args.start_z,
+ "settle_steps": args.settle_steps,
+ "viewer": args.viewer,
+ "start_index": args.start_index,
+ "end_index": args.end_index,
+ "skip_initial_waypoint": args.skip_initial_waypoint,
+ "auto_skip_slice_start": args.auto_skip_slice_start,
+ "trajectory_csv": str(csv_path) if csv_path else None,
+ }
+ (run_dir / "report.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
+ return result, csv_path, run_dir
+
+
+def main() -> int:
+ args = parse_args()
+ result, csv_path, run_dir = run_sim(args)
+ print("Route sim2sim check")
+ print(f" success: {result.success}")
+ print(f" reason: {result.reason}")
+ print(f" reached: {result.reached_count}/{result.waypoint_count}")
+ print(f" sim_time: {result.sim_time:.2f}s")
+ print(f" wall_time: {result.wall_time:.2f}s rtf={result.real_time_factor:.2f}x")
+ print(
+ " clearance: "
+ f"min={result.min_clearance:.3f}m margin={result.min_margin:.3f}m "
+ f"region={result.min_clearance_region} wp={result.min_clearance_wp}"
+ )
+ print(
+ " attitude: "
+ f"max_roll={result.max_roll_deg:.1f}deg "
+ f"max_pitch={result.max_pitch_deg:.1f}deg max_tilt={result.max_tilt_deg:.1f}deg"
+ )
+ print(f" report_dir: {run_dir}")
+ if csv_path:
+ print(f" trajectory: {csv_path}")
+ return 0 if result.success and result.min_margin >= 0.0 else 2
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/05_software/train/rc_mjlab/sim2sim/nav_sim2sim.py b/05_software/train/rc_mjlab/sim2sim/nav_sim2sim.py
index 9e1a740..b798dac 100644
--- a/05_software/train/rc_mjlab/sim2sim/nav_sim2sim.py
+++ b/05_software/train/rc_mjlab/sim2sim/nav_sim2sim.py
@@ -324,9 +324,11 @@ def main():
terrain_dir = Path(__file__).parent / "terrain"
terrain_xml = terrain_dir / "scene_terrain.xml"
robot_xml = project_root / "mjcf" / "wheelleg.xml"
+ rough_onnx = project_root / "model_6800.onnx"
+ crawl_onnx = project_root / "model_crawl.onnx"
policy_path = {
- "rough": project_root / "model_rough.pt",
- "crawl": project_root / "model_crawl.pt"
+ "rough": rough_onnx if rough_onnx.exists() else project_root / "model_rough.pt",
+ "crawl": crawl_onnx if crawl_onnx.exists() else project_root / "model_crawl.pt"
}
# 1. 解析 XML 地图障碍物,实现 100% 可视化精准对应
diff --git a/05_software/train/rc_mjlab/sim2sim/policy/policy_runner.py b/05_software/train/rc_mjlab/sim2sim/policy/policy_runner.py
index 3b15db4..b5a3134 100644
--- a/05_software/train/rc_mjlab/sim2sim/policy/policy_runner.py
+++ b/05_software/train/rc_mjlab/sim2sim/policy/policy_runner.py
@@ -3,7 +3,10 @@ import torch.nn as nn
import numpy as np
from collections import deque
from pathlib import Path
-from pynput import keyboard
+try:
+ from pynput import keyboard
+except ImportError:
+ keyboard = None
# ============================================================
# Policy Model
@@ -26,6 +29,28 @@ class PolicyMLP(nn.Module):
def load_policy(model_path, device):
+ if str(model_path).endswith('.onnx'):
+ import onnxruntime as ort
+ session = ort.InferenceSession(str(model_path))
+ class OnnxWrapper:
+ def __init__(self, session):
+ self.session = session
+ self.obs_dim = session.get_inputs()[0].shape[1]
+ if isinstance(self.obs_dim, str):
+ self.obs_dim = 53
+ class MockMean:
+ def __init__(self, d):
+ self.d = d
+ def numel(self):
+ return self.d
+ self.obs_mean = MockMean(self.obs_dim)
+
+ def __call__(self, x):
+ inputs = {self.session.get_inputs()[0].name: x.cpu().numpy()}
+ out = self.session.run(None, inputs)[0]
+ return torch.tensor(out, device=x.device)
+ return OnnxWrapper(session)
+
ckpt = torch.load(model_path, map_location=device, weights_only=False)
state_dict = ckpt["actor_state_dict"]
@@ -129,9 +154,13 @@ class PolicyRunner:
], dtype=np.float32)
# Background keyboard listener for seamless switcher keys ('1' and '2')
- self.listener = keyboard.Listener(on_press=self._on_press)
- self.listener.start()
- print("[PolicyRunner] Background Keyboard Switcher active: Press '1' for ROUGH, '2' for CRAWL")
+ self.listener = None
+ if keyboard is not None:
+ self.listener = keyboard.Listener(on_press=self._on_press)
+ self.listener.start()
+ print("[PolicyRunner] Background Keyboard Switcher active: Press '1' for ROUGH, '2' for CRAWL")
+ else:
+ print("[PolicyRunner] pynput not installed; background keyboard switcher disabled.")
def _on_press(self, key):
try:
diff --git a/05_software/train/rc_mjlab/sim2sim/requirements.txt b/05_software/train/rc_mjlab/sim2sim/requirements.txt
new file mode 100644
index 0000000..4da9002
--- /dev/null
+++ b/05_software/train/rc_mjlab/sim2sim/requirements.txt
@@ -0,0 +1,3 @@
+# Additional runtime dependencies for the post-training Sim2Sim tools.
+onnxruntime>=1.19.0
+pygame>=2.6.1
diff --git a/05_software/train/rc_mjlab/sim2sim/sim2sim.py b/05_software/train/rc_mjlab/sim2sim/sim2sim.py
index 91560c8..c6adf7c 100644
--- a/05_software/train/rc_mjlab/sim2sim/sim2sim.py
+++ b/05_software/train/rc_mjlab/sim2sim/sim2sim.py
@@ -41,6 +41,19 @@ class PolicyMLP(nn.Module):
def load_policy(model_path, device):
+ if str(model_path).endswith('.onnx'):
+ import onnxruntime as ort
+ session = ort.InferenceSession(str(model_path))
+ class OnnxWrapper:
+ def __init__(self, session):
+ self.session = session
+ def __call__(self, x):
+ inputs = {self.session.get_inputs()[0].name: x.cpu().numpy()}
+ out = self.session.run(None, inputs)[0]
+ return torch.tensor(out, device=x.device)
+ return OnnxWrapper(session)
+
+
ckpt = torch.load(model_path, map_location=device, weights_only=False)
state_dict = ckpt["actor_state_dict"]
model = PolicyMLP()
@@ -131,7 +144,9 @@ def main():
terrain_dir = Path(__file__).parent / "terrain"
terrain_xml = terrain_dir / "scene_terrain.xml"
robot_xml = Path(__file__).parent.parent / "mjcf" / "wheelleg.xml"
- policy_path = Path(__file__).parent.parent / "model_1700.pt"
+ policy_path = Path(__file__).parent.parent / "model_6800.onnx"
+ if not policy_path.exists():
+ policy_path = Path(__file__).parent.parent / "model_rough.pt"
hfield_dir = terrain_dir
temp_xml = project_root / "mjcf" / "sim2sim_temp.xml"
diff --git a/05_software/train/rc_mjlab/tools/nav_tools/README.md b/05_software/train/rc_mjlab/tools/nav_tools/README.md
new file mode 100644
index 0000000..2b672c1
--- /dev/null
+++ b/05_software/train/rc_mjlab/tools/nav_tools/README.md
@@ -0,0 +1,7 @@
+# 路线检查公共模块
+
+`route_safety_check.py` 提供航点、避障区域、机器人平面包络和几何距离计算,供 `sim2sim/nav_route_sim2sim_check.py` 复用。
+
+该模块只依赖 Python 标准库。原开发目录中的地图编辑器、PCD、比赛路线 JSON、备份和批量实验结果不属于本次后期 Sim2Sim 里程碑,未在这里复制。
+
+外部路线文件需要包含 `waypoints`(或 `segments[].waypoints`)以及 `regions` / `avoid_regions`。也可以不提供路线文件,直接使用 Sim2Sim 检查器的内置任务。
diff --git a/05_software/train/rc_mjlab/tools/nav_tools/route_safety_check.py b/05_software/train/rc_mjlab/tools/nav_tools/route_safety_check.py
new file mode 100644
index 0000000..1e3dca3
--- /dev/null
+++ b/05_software/train/rc_mjlab/tools/nav_tools/route_safety_check.py
@@ -0,0 +1,638 @@
+#!/usr/bin/env python3
+"""Offline route safety checker for nav_tools waypoint JSON files.
+
+The checker treats avoid regions as hard no-go polygons and validates the
+route centerline with a circular robot footprint. It is intentionally light on
+dependencies so it can run on the robot laptop without ROS, pygame, or shapely.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import math
+import sys
+import xml.etree.ElementTree as ET
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+
+ROBOT_BODY_LENGTH = 0.356
+ROBOT_BODY_WIDTH = 0.235
+ROBOT_BODY_CENTER_X = 0.1518
+ROBOT_ORIGIN_FROM_FRONT = 0.105
+ROBOT_WHEEL_VIS_LENGTH = 0.16
+ROBOT_WHEEL_VIS_WIDTH = 0.055
+ROBOT_POSE_HIP = 0.550
+ROBOT_POSE_KNEE = -1.125
+PCD_ROBOT_RADIUS = 0.18
+ROBOT_FOOTPRINT_PADDING = 0.03
+
+
+@dataclass(frozen=True)
+class Waypoint:
+ index: int
+ id: str
+ x: float
+ y: float
+ yaw_deg: float
+ speed: float | None
+ policy: str
+ tolerance: float | None
+ slalom_straight: bool = False
+ slalom_script_break: bool = False
+ slalom_script_pos_tolerance: float | None = None
+ exact_reach: bool = False
+ precision_follow: bool = False
+ require_yaw: bool = False
+ yaw_tolerance_deg: float | None = None
+ stable_cycles: int | None = None
+ mandatory_cross: bool = False
+ mandatory_radius: float | None = None
+ mandatory_center_x: float | None = None
+ mandatory_center_y: float | None = None
+
+
+@dataclass(frozen=True)
+class AvoidRegion:
+ name: str
+ kind: str
+ polygon: tuple[tuple[float, float], ...]
+
+
+@dataclass(frozen=True)
+class SegmentRisk:
+ start_id: str
+ end_id: str
+ region: str
+ clearance_m: float
+ required_m: float
+ margin_m: float
+ length_m: float
+ centerline_intersects: bool
+
+ @property
+ def status(self) -> str:
+ if self.centerline_intersects:
+ return "INTERSECT"
+ if self.margin_m < 0.0:
+ return "VIOLATION"
+ if self.margin_m < 0.05:
+ return "TIGHT"
+ return "OK"
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(
+ description="Check nav_tools waypoint routes against avoid/no-go polygons."
+ )
+ parser.add_argument(
+ "--points",
+ type=Path,
+ default=Path("tools/nav_tools/points/points_20260705_174627.json"),
+ help="Route JSON exported by nav_map_viewer.",
+ )
+ parser.add_argument(
+ "--xml",
+ type=Path,
+ default=Path("tools/nav_tools/xml/A.xml"),
+ help="Optional MuJoCo terrain XML used for metadata checks.",
+ )
+ parser.add_argument(
+ "--onnx",
+ type=Path,
+ default=Path("model_6800.onnx"),
+ help="Optional ONNX policy path used for input/output shape reporting.",
+ )
+ parser.add_argument(
+ "--footprint-radius",
+ type=float,
+ default=None,
+ help="Robot circular footprint radius in meters. Defaults to sim2real lateral footprint.",
+ )
+ parser.add_argument(
+ "--avoid-margin",
+ type=float,
+ default=0.05,
+ help="Extra clearance added outside the robot footprint.",
+ )
+ parser.add_argument(
+ "--warn-margin",
+ type=float,
+ default=0.05,
+ help="Report a TIGHT warning when spare margin is below this value.",
+ )
+ parser.add_argument(
+ "--top",
+ type=int,
+ default=20,
+ help="Number of closest segment-region pairs to print.",
+ )
+ parser.add_argument(
+ "--json-out",
+ type=Path,
+ default=None,
+ help="Optional machine-readable report path.",
+ )
+ parser.add_argument(
+ "--allow-violations",
+ action="store_true",
+ help="Exit with code 0 even when violations are detected.",
+ )
+ return parser.parse_args()
+
+
+def load_json(path: Path) -> dict[str, Any]:
+ with path.open("r", encoding="utf-8") as f:
+ data = json.load(f)
+ if not isinstance(data, dict):
+ raise ValueError(f"JSON root must be an object: {path}")
+ return data
+
+
+def _optional_float(value: Any) -> float | None:
+ if value is None:
+ return None
+ try:
+ return float(value)
+ except (TypeError, ValueError):
+ return None
+
+
+def load_waypoints(payload: dict[str, Any]) -> list[Waypoint]:
+ rows = None
+ if isinstance(payload.get("segments"), list) and payload["segments"]:
+ rows = []
+ for segment in payload["segments"]:
+ if isinstance(segment, dict) and isinstance(segment.get("waypoints"), list):
+ rows.extend(segment["waypoints"])
+ if rows is None:
+ rows = payload.get("waypoints")
+ if not isinstance(rows, list):
+ raise ValueError("Route JSON has no top-level waypoints or segments[].waypoints")
+
+ waypoints: list[Waypoint] = []
+ for index, row in enumerate(rows, start=1):
+ if not isinstance(row, dict):
+ continue
+ x = float(row.get("world_x", row.get("x", 0.0)))
+ y = float(row.get("world_y", row.get("y", 0.0)))
+ yaw = float(row.get("yawDeg", row.get("yaw_deg", row.get("yaw", 0.0))))
+ speed = row.get("speed")
+ tolerance = row.get("tolerance")
+ waypoints.append(
+ Waypoint(
+ index=index,
+ id=str(row.get("id", index)),
+ x=x,
+ y=y,
+ yaw_deg=yaw,
+ speed=float(speed) if speed is not None else None,
+ policy=str(row.get("policy", "")),
+ tolerance=float(tolerance) if tolerance is not None else None,
+ slalom_straight=bool(row.get("slalom_straight", row.get("slalomStraight", False))),
+ slalom_script_break=bool(
+ row.get("slalom_script_break", row.get("slalomScriptBreak", False))
+ ),
+ slalom_script_pos_tolerance=_optional_float(
+ row.get(
+ "slalom_script_pos_tolerance",
+ row.get("slalomScriptPosTolerance", row.get("scriptTolerance")),
+ )
+ ),
+ exact_reach=bool(row.get("exact_reach", row.get("exactReach", False))),
+ precision_follow=bool(row.get("precision_follow", row.get("precisionFollow", False))),
+ require_yaw=bool(row.get("require_yaw", row.get("requireYaw", False))),
+ yaw_tolerance_deg=_optional_float(
+ row.get("yaw_tolerance_deg", row.get("yawToleranceDeg"))
+ ),
+ stable_cycles=(
+ int(row.get("stable_cycles", row.get("stableCycles")))
+ if row.get("stable_cycles", row.get("stableCycles")) is not None
+ else None
+ ),
+ mandatory_cross=bool(row.get("mandatory_cross", row.get("mandatoryCross", False))),
+ mandatory_radius=_optional_float(
+ row.get("mandatory_radius", row.get("mandatoryRadius"))
+ ),
+ mandatory_center_x=_optional_float(
+ row.get("mandatory_center_x", row.get("mandatoryCenterX"))
+ ),
+ mandatory_center_y=_optional_float(
+ row.get("mandatory_center_y", row.get("mandatoryCenterY"))
+ ),
+ )
+ )
+ if len(waypoints) < 2:
+ raise ValueError("Route must contain at least two waypoints")
+ return waypoints
+
+
+def load_regions(payload: dict[str, Any]) -> list[AvoidRegion]:
+ rows = payload.get("regions", payload.get("avoid_regions", []))
+ if not isinstance(rows, list):
+ return []
+
+ regions: list[AvoidRegion] = []
+ for index, row in enumerate(rows, start=1):
+ if not isinstance(row, dict):
+ continue
+ polygon_rows = row.get("polygon", row.get("points", []))
+ if not isinstance(polygon_rows, list):
+ continue
+ polygon: list[tuple[float, float]] = []
+ for point in polygon_rows:
+ if isinstance(point, dict):
+ polygon.append((float(point.get("x", 0.0)), float(point.get("y", 0.0))))
+ elif isinstance(point, (list, tuple)) and len(point) >= 2:
+ polygon.append((float(point[0]), float(point[1])))
+ if len(polygon) >= 3:
+ regions.append(
+ AvoidRegion(
+ name=str(row.get("name", f"avoid_{index}")),
+ kind=str(row.get("kind", "avoid")),
+ polygon=tuple(polygon),
+ )
+ )
+ return regions
+
+
+def robot_wheel_local_points(body_center_offset_x: float) -> list[tuple[float, float]]:
+ thigh_dx = -0.25 * math.sin(ROBOT_POSE_HIP)
+ shank_dx = -0.2 * math.sin(ROBOT_POSE_HIP + ROBOT_POSE_KNEE)
+ wheel_positions = (
+ ((0.32826 + 0.06389) - ROBOT_BODY_CENTER_X, 0.066172 - 0.027344, 0.1035, 0.014699, 0.04074, 0.0),
+ ((0.32826 + 0.06389) - ROBOT_BODY_CENTER_X, -0.065853 + 0.027311, -0.1035, -0.018447, -0.040735, -0.00075079),
+ ((-0.024743 - 0.06389) - ROBOT_BODY_CENTER_X, 0.066141 - 0.027309, 0.099459, 0.012475, 0.040737, 0.0),
+ ((-0.024743 - 0.06389) - ROBOT_BODY_CENTER_X, -0.065884 + 0.027341, -0.099408, -0.012435, -0.040737, -0.00075079),
+ )
+ return [
+ (
+ body_center_offset_x + pitch_x + knee_x + thigh_dx + shank_dx,
+ pitch_y + knee_y + wheel_y + wheel_geom_y,
+ )
+ for pitch_x, pitch_y, knee_y, wheel_y, wheel_geom_y, knee_x in wheel_positions
+ ]
+
+
+def default_lateral_footprint_radius() -> float:
+ half_width = ROBOT_BODY_WIDTH * 0.5
+ radius = max(PCD_ROBOT_RADIUS, half_width)
+ body_center_offset_x = ROBOT_ORIGIN_FROM_FRONT - ROBOT_BODY_LENGTH * 0.5
+ for _, wheel_y in robot_wheel_local_points(body_center_offset_x):
+ radius = max(radius, abs(wheel_y) + ROBOT_WHEEL_VIS_WIDTH * 0.5)
+ return radius + ROBOT_FOOTPRINT_PADDING
+
+
+def point_segment_distance(
+ px: float,
+ py: float,
+ ax: float,
+ ay: float,
+ bx: float,
+ by: float,
+) -> float:
+ dx = bx - ax
+ dy = by - ay
+ length_sq = dx * dx + dy * dy
+ if length_sq <= 1.0e-12:
+ return math.hypot(px - ax, py - ay)
+ t = ((px - ax) * dx + (py - ay) * dy) / length_sq
+ t = max(0.0, min(1.0, t))
+ qx = ax + t * dx
+ qy = ay + t * dy
+ return math.hypot(px - qx, py - qy)
+
+
+def orientation(
+ ax: float,
+ ay: float,
+ bx: float,
+ by: float,
+ cx: float,
+ cy: float,
+) -> float:
+ return (bx - ax) * (cy - ay) - (by - ay) * (cx - ax)
+
+
+def on_segment(
+ ax: float,
+ ay: float,
+ bx: float,
+ by: float,
+ cx: float,
+ cy: float,
+) -> bool:
+ return (
+ min(ax, bx) - 1.0e-9 <= cx <= max(ax, bx) + 1.0e-9
+ and min(ay, by) - 1.0e-9 <= cy <= max(ay, by) + 1.0e-9
+ and abs(orientation(ax, ay, bx, by, cx, cy)) <= 1.0e-9
+ )
+
+
+def segments_intersect(
+ a: tuple[float, float],
+ b: tuple[float, float],
+ c: tuple[float, float],
+ d: tuple[float, float],
+) -> bool:
+ ax, ay = a
+ bx, by = b
+ cx, cy = c
+ dx, dy = d
+ o1 = orientation(ax, ay, bx, by, cx, cy)
+ o2 = orientation(ax, ay, bx, by, dx, dy)
+ o3 = orientation(cx, cy, dx, dy, ax, ay)
+ o4 = orientation(cx, cy, dx, dy, bx, by)
+ if o1 * o2 < 0.0 and o3 * o4 < 0.0:
+ return True
+ return (
+ on_segment(ax, ay, bx, by, cx, cy)
+ or on_segment(ax, ay, bx, by, dx, dy)
+ or on_segment(cx, cy, dx, dy, ax, ay)
+ or on_segment(cx, cy, dx, dy, bx, by)
+ )
+
+
+def point_in_polygon(x: float, y: float, polygon: tuple[tuple[float, float], ...]) -> bool:
+ inside = False
+ for index, (ax, ay) in enumerate(polygon):
+ bx, by = polygon[(index + 1) % len(polygon)]
+ if point_segment_distance(x, y, ax, ay, bx, by) <= 1.0e-9:
+ return True
+ if (ay > y) != (by > y):
+ x_cross = (bx - ax) * (y - ay) / (by - ay) + ax
+ if x < x_cross:
+ inside = not inside
+ return inside
+
+
+def segment_polygon_intersects(
+ a: tuple[float, float],
+ b: tuple[float, float],
+ polygon: tuple[tuple[float, float], ...],
+) -> bool:
+ if point_in_polygon(a[0], a[1], polygon) or point_in_polygon(b[0], b[1], polygon):
+ return True
+ return any(
+ segments_intersect(a, b, polygon[index], polygon[(index + 1) % len(polygon)])
+ for index in range(len(polygon))
+ )
+
+
+def segment_polygon_distance(
+ a: tuple[float, float],
+ b: tuple[float, float],
+ polygon: tuple[tuple[float, float], ...],
+) -> float:
+ if segment_polygon_intersects(a, b, polygon):
+ return 0.0
+ distances = [point_segment_distance(px, py, a[0], a[1], b[0], b[1]) for px, py in polygon]
+ for index, (ax, ay) in enumerate(polygon):
+ bx, by = polygon[(index + 1) % len(polygon)]
+ distances.append(point_segment_distance(a[0], a[1], ax, ay, bx, by))
+ distances.append(point_segment_distance(b[0], b[1], ax, ay, bx, by))
+ return min(distances)
+
+
+def analyze_route(
+ waypoints: list[Waypoint],
+ regions: list[AvoidRegion],
+ required_clearance: float,
+) -> list[SegmentRisk]:
+ risks: list[SegmentRisk] = []
+ for start, end in zip(waypoints, waypoints[1:]):
+ a = (start.x, start.y)
+ b = (end.x, end.y)
+ length = math.hypot(end.x - start.x, end.y - start.y)
+ for region in regions:
+ intersects = segment_polygon_intersects(a, b, region.polygon)
+ clearance = 0.0 if intersects else segment_polygon_distance(a, b, region.polygon)
+ risks.append(
+ SegmentRisk(
+ start_id=start.id,
+ end_id=end.id,
+ region=region.name,
+ clearance_m=clearance,
+ required_m=required_clearance,
+ margin_m=clearance - required_clearance,
+ length_m=length,
+ centerline_intersects=intersects,
+ )
+ )
+ risks.sort(key=lambda item: (item.margin_m, item.clearance_m))
+ return risks
+
+
+def parse_xml_summary(path: Path) -> dict[str, Any]:
+ if not path.exists():
+ return {"path": str(path), "exists": False}
+ root = ET.parse(path).getroot()
+ geoms = [geom for geom in root.iter("geom")]
+ collidable = [
+ geom for geom in geoms
+ if geom.get("name") != "floor"
+ and geom.get("contype", "1") != "0"
+ and geom.get("conaffinity", "1") != "0"
+ ]
+ return {
+ "path": str(path),
+ "exists": True,
+ "model": root.get("model", ""),
+ "geom_count": len(geoms),
+ "collidable_geom_count": len(collidable),
+ }
+
+
+def parse_onnx_summary(path: Path) -> dict[str, Any]:
+ if not path.exists():
+ return {"path": str(path), "exists": False}
+ try:
+ import onnx # type: ignore
+ except Exception as exc: # pragma: no cover - depends on local env
+ return {"path": str(path), "exists": True, "error": f"onnx import failed: {exc}"}
+
+ model = onnx.load(str(path))
+ inputs = [
+ {
+ "name": item.name,
+ "shape": [
+ dim.dim_value if dim.dim_value else dim.dim_param
+ for dim in item.type.tensor_type.shape.dim
+ ],
+ }
+ for item in model.graph.input
+ ]
+ outputs = [
+ {
+ "name": item.name,
+ "shape": [
+ dim.dim_value if dim.dim_value else dim.dim_param
+ for dim in item.type.tensor_type.shape.dim
+ ],
+ }
+ for item in model.graph.output
+ ]
+ return {
+ "path": str(path),
+ "exists": True,
+ "inputs": inputs,
+ "outputs": outputs,
+ "metadata_keys": [prop.key for prop in model.metadata_props],
+ }
+
+
+def risk_to_dict(risk: SegmentRisk) -> dict[str, Any]:
+ return {
+ "start_id": risk.start_id,
+ "end_id": risk.end_id,
+ "region": risk.region,
+ "clearance_m": round(risk.clearance_m, 6),
+ "required_m": round(risk.required_m, 6),
+ "margin_m": round(risk.margin_m, 6),
+ "length_m": round(risk.length_m, 6),
+ "centerline_intersects": risk.centerline_intersects,
+ "status": risk.status,
+ }
+
+
+def print_report(
+ points_path: Path,
+ xml_summary: dict[str, Any],
+ onnx_summary: dict[str, Any],
+ waypoints: list[Waypoint],
+ regions: list[AvoidRegion],
+ footprint_radius: float,
+ avoid_margin: float,
+ warn_margin: float,
+ risks: list[SegmentRisk],
+ top: int,
+) -> None:
+ required_clearance = footprint_radius + avoid_margin
+ violations = [risk for risk in risks if risk.margin_m < 0.0 or risk.centerline_intersects]
+ tight = [
+ risk for risk in risks
+ if risk.margin_m >= 0.0 and risk.margin_m < warn_margin
+ ]
+ route_len = sum(
+ math.hypot(b.x - a.x, b.y - a.y)
+ for a, b in zip(waypoints, waypoints[1:])
+ )
+
+ print("Route safety check")
+ print(f" points: {points_path}")
+ print(f" waypoints: {len(waypoints)}, regions: {len(regions)}, path_length: {route_len:.3f} m")
+ print(
+ " clearance: "
+ f"footprint={footprint_radius:.3f} m + avoid_margin={avoid_margin:.3f} m "
+ f"=> required={required_clearance:.3f} m"
+ )
+ if xml_summary.get("exists"):
+ print(
+ " xml: "
+ f"{xml_summary.get('path')} "
+ f"(model={xml_summary.get('model')}, geoms={xml_summary.get('geom_count')}, "
+ f"collidable={xml_summary.get('collidable_geom_count')})"
+ )
+ else:
+ print(f" xml: missing ({xml_summary.get('path')})")
+ if onnx_summary.get("exists") and not onnx_summary.get("error"):
+ print(f" onnx: {onnx_summary.get('path')}")
+ print(f" inputs: {onnx_summary.get('inputs')}")
+ print(f" outputs: {onnx_summary.get('outputs')}")
+ elif onnx_summary.get("exists"):
+ print(f" onnx: {onnx_summary.get('error')}")
+ else:
+ print(f" onnx: missing ({onnx_summary.get('path')})")
+
+ print("")
+ if violations:
+ print(f"FAIL: {len(violations)} segment-region pairs are inside required clearance.")
+ elif tight:
+ print(f"WARN: no violations, but {len(tight)} segment-region pairs are tight.")
+ else:
+ print("PASS: all segment-region pairs satisfy the requested clearance.")
+
+ print("")
+ print(f"Closest {min(top, len(risks))} segment-region pairs:")
+ print(" status wp_start->wp_end region clear req spare")
+ for risk in risks[:top]:
+ print(
+ f" {risk.status:<10} "
+ f"{risk.start_id:>4}->{risk.end_id:<4} "
+ f"{risk.region:<10} "
+ f"{risk.clearance_m:>6.3f} "
+ f"{risk.required_m:>6.3f} "
+ f"{risk.margin_m:>7.3f}"
+ )
+
+
+def main() -> int:
+ args = parse_args()
+ points_path = args.points.resolve()
+ xml_path = args.xml.resolve()
+ onnx_path = args.onnx.resolve()
+
+ payload = load_json(points_path)
+ waypoints = load_waypoints(payload)
+ regions = load_regions(payload)
+ if not regions:
+ raise ValueError(f"No avoid regions found in {points_path}")
+
+ footprint_radius = (
+ float(args.footprint_radius)
+ if args.footprint_radius is not None
+ else default_lateral_footprint_radius()
+ )
+ required_clearance = footprint_radius + float(args.avoid_margin)
+ risks = analyze_route(waypoints, regions, required_clearance)
+ xml_summary = parse_xml_summary(xml_path)
+ onnx_summary = parse_onnx_summary(onnx_path)
+
+ print_report(
+ points_path,
+ xml_summary,
+ onnx_summary,
+ waypoints,
+ regions,
+ footprint_radius,
+ float(args.avoid_margin),
+ float(args.warn_margin),
+ risks,
+ max(0, int(args.top)),
+ )
+
+ violations = [risk for risk in risks if risk.margin_m < 0.0 or risk.centerline_intersects]
+ tight = [
+ risk for risk in risks
+ if risk.margin_m >= 0.0 and risk.margin_m < float(args.warn_margin)
+ ]
+ report = {
+ "points": str(points_path),
+ "waypoint_count": len(waypoints),
+ "region_count": len(regions),
+ "footprint_radius_m": round(footprint_radius, 6),
+ "avoid_margin_m": round(float(args.avoid_margin), 6),
+ "required_clearance_m": round(required_clearance, 6),
+ "violations": [risk_to_dict(risk) for risk in violations],
+ "tight": [risk_to_dict(risk) for risk in tight],
+ "closest": [risk_to_dict(risk) for risk in risks[: max(0, int(args.top))]],
+ "xml": xml_summary,
+ "onnx": onnx_summary,
+ }
+ if args.json_out:
+ args.json_out.parent.mkdir(parents=True, exist_ok=True)
+ args.json_out.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
+
+ if violations and not args.allow_violations:
+ return 2
+ return 0
+
+
+if __name__ == "__main__":
+ try:
+ raise SystemExit(main())
+ except Exception as exc:
+ print(f"ERROR: {exc}", file=sys.stderr)
+ raise SystemExit(1)
diff --git a/README.md b/README.md
index e6b0c26..dd76f47 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
RC_WheelLeg 是山东华宇工学院 16DOF 串联轮足机器人项目。
-当前 `16dof` 分支用于整理 16DOF 机械、强化学习训练、Sim2Sim、Sim2Real、ROS 2 部署和比赛版本。机械资料、比赛训练架构和后期 MuJoCo 工具集已经完成整理。
+当前 `16dof` 分支用于整理 16DOF 机械、强化学习训练、Sim2Sim、Sim2Real、ROS 2 部署和比赛版本。机械资料、比赛训练架构、后期 MuJoCo 工具集和后期 Sim2Sim 已经完成整理。
## 平台概览
@@ -37,6 +37,7 @@ RC_WheelLeg/
- [x] 整理第二版 Sim2Real 随机化训练配置
- [x] 整理比赛最终训练代码架构
- [x] 整理后期 MuJoCo 姿态、IK、动力学和 MPC 工具
+- [x] 整理后期 Sim2Sim、路线检查与比赛 Rough ONNX 策略
- [ ] 核对比赛机械与仿真模型参数
- [ ] 整理 URDF/MJCF 机器人描述
- [ ] 整理后续统一训练、ROS 2 和比赛版本