Compare commits

6 Commits

Author SHA1 Message Date
chenlin fa5485049a feat(web-platform): release V0.8.3 常规界面优化
web-platform-release / Build and publish release (push) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (pull_request) Has been cancelled
web-platform-ci / Playwright E2E (pull_request) Has been cancelled
2026-09-04 15:55:36 +08:00
chenlin 63d67a645b feat(tuning): release V0.8.2 Agent 界面重构
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
2026-09-03 16:25:53 +08:00
chenlin a9b07e0abf feat(web-platform): release V0.8.1 常规地图优化
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
2026-09-03 13:18:35 +08:00
chenlin deead17a9a feat(training): release V0.8 自调参 Agent
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
2026-09-02 13:49:34 +08:00
chenlin cffac29a03 feat(training-server): release V0.7.3 整合强化学习服务器
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (pull_request) Has been cancelled
web-platform-ci / Playwright E2E (pull_request) Has been cancelled
2026-09-01 17:51:42 +08:00
chenlin 8ba83a8d0a refactor(web-platform): release V0.7.2 项目常规优化
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
2026-09-01 16:39:04 +08:00
188 changed files with 779799 additions and 2548 deletions
+6 -1
View File
@@ -16,9 +16,10 @@ __pycache__/
*.py[cod]
*.egg-info/
# Local build and cache directories
# Local build, cache, and planning directories
build/
.cache/
/plans/
# Editors and operating systems
.vscode/
@@ -28,3 +29,7 @@ build/
# Runtime logs
*.log
MUJOCO_LOG.TXT
training_server/rl/logs/
training_server/rl/wandb/
training_server/rl/outputs/
training_server/tuning-data/
+3
View File
@@ -0,0 +1,3 @@
Please always speak chinese.
python 虚拟环境路径在/home/cen/Embodied_Workspace/Mujoco_Projects/mujoco/.venv/bin/activate
系统是Ubuntu 24.04 LTS
+19
View File
@@ -2,6 +2,25 @@
本项目的重要变更记录在此文件中,版本标签沿用仓库现有的 `V主版本.次版本[.修订版本]` 格式。
## [0.7.3] - 2026-09-01
### 新增
- 将默认 `Unitree-Go2-Flat` 训练所需的 PPO 环境、奖励/观测配置、ONNX 导出逻辑和 Go2 模型资产内置到 `training_server/rl/`。
### 变更
- 训练服务默认使用仓库内置训练器,不再要求单独克隆 `unitree_rl_mjlab`;仍可通过 `--trainer-root` 覆盖。
## [0.7.2] - 2026-09-01
### 变更
- 前端改为“应用壳 + 业务域 + 共享组件”的目录结构,并补充架构与依赖边界文档。
- 拆分工程侧栏、模型控制侧栏、执行器控制和通用侧栏容器,降低单文件职责和跨域耦合。
- 将地图、控制器、强化学习、本地训练和遥测面板及测试与各自业务域共置。
- 将遥测模块从仿真调度目录独立到 `telemetry/`,保留通过显式接口与 `SimulationSession` 集成。
## [0.7.1] - 2026-09-01
### 新增
+20 -13
View File
@@ -10,12 +10,12 @@
- ROS `package://`、常见 URDF 兼容转换及 DAE 降级处理
- Three.js 模型、碰撞体、坐标系、关节轴、质心和惯量可视化
- 播放、暂停、单步、重置、变速、关节拖动与外力交互
- 内置平地、坡道、楼梯、随机障碍物及 9 类系统参数化地形(粗糙/波浪、金字塔阶梯、深坑、沟壑等)
- 工程地图包:静态 MJCF/OBJ/STL/高度场碰撞层、GLB 视觉层和机器人出生点
- V3 地图创作层:认证资产库支持点击添加、拖到画布落位和首个资产自动创建场景,并可通过表单与视口操纵器继续编辑、事务式应用及导出地图 ZIP
- 统一地图资产库:工程地图、认证资产、内置地图及 9 类系统参数化地形共用点击/拖放、场景树、轻量预览、实例变换、放弃和一次编译事务
- 工程地图包:静态 MJCF/OBJ/STL/高度场碰撞层、GLB 视觉层和机器人出生点;同源实例拥有独立位姿,物理/视觉/出生点同步变换
- V3 地图创作层:认证资产可在程序地形上自动重力落位,共用右侧属性和视口操纵器,支持源内容编辑、空白取消选择及导出地图 ZIP
- 浏览器内 Python 控制器(Pyodide)
- ONNX 强化学习策略推理(ONNX Runtime Web)
- 可选的本机 mjlab 训练桥接服务
- 内置 Go2 PPO 任务的本机 mjlab 训练桥接服务,以及 DeepSeek 驱动的奖励函数自调参、固定评估和 TensorBoard 风格独立工作台
- 响应式工作区、源码编辑、性能监控和中文诊断
## 快速开始
@@ -56,26 +56,33 @@ npm run lint:python
```text
.
├── web_platform/ React、TypeScript、Three.js 前端与测试夹具
├── training_server/ 可选的本机强化学习训练桥接服务
├── training_server/ 本机训练桥接服务及内置 Go2 PPO 训练代码/资产
├── controllers/ 可导入平台的 Python 控制器示例
├── package.json 根目录统一开发入口
└── LICENSE Apache License 2.0
```
核心前端分层:
核心前端采用“应用壳 + 业务域 + 共享组件”分层:
- `web_platform/src/app/`:工作区、工具栏、面板和对话框
- `web_platform/src/project/`:工程导入、路径规范化和 MEMFS
- `web_platform/src/simulation/`:MuJoCo WASM 会话与仿真调度
- `web_platform/src/app/`:工作区编排、侧栏外壳、工具栏和全局对话框
- `web_platform/src/components/ui/`:无业务依赖的共享 UI 基础组件
- `web_platform/src/project/`:工程导入、路径规范化、项目树和 MEMFS
- `web_platform/src/simulation/`:MuJoCo WASM 会话、仿真调度及执行器控制 UI
- `web_platform/src/viewer/`:Three.js 渲染、选择和物理交互
- `web_platform/src/controller/`:Pyodide 控制器运行时
- `web_platform/src/rl/`:ONNX 策略运行时
- `web_platform/src/map/`:地图加载、创作、校验及对应 UI
- `web_platform/src/controller/`:Pyodide 控制器运行时及控制面板
- `web_platform/src/rl/`:ONNX 策略运行时、任务绑定及策略面板
- `web_platform/src/training/`:本地训练服务客户端、类型及训练面板
- `web_platform/src/telemetry/`:遥测数据源、记录器、导出与数据面板
- `web_platform/src/stores/`:跨业务域的应用状态
详细使用说明见 [`web_platform/README.md`](web_platform/README.md),训练服务说明见 [`training_server/README.md`](training_server/README.md)。
组件和测试优先与所属业务域共置;`app/components/` 仅保留跨域编排和应用壳组件,避免业务逻辑继续集中到单一侧栏文件。
详细使用说明见 [`web_platform/README.md`](web_platform/README.md),前端依赖边界见 [`web_platform/ARCHITECTURE.md`](web_platform/ARCHITECTURE.md),训练服务说明见 [`training_server/README.md`](training_server/README.md)。
## 数据与安全边界
模型、资源、Python 控制器和 ONNX 策略默认只在当前浏览器会话中处理,不上传到服务器。训练桥接服务只监听本机回环地址,并仅执行服务端允许列表中的任务。
模型、资源、Python 控制器和 ONNX 策略默认只在当前浏览器会话中处理,不上传到服务器。训练桥接服务只监听本机回环地址,并仅执行服务端允许列表中的任务。启用自调参时,DeepSeek 只接收脱敏后的奖励参数、曲线摘要和评估数值;API key 仅存在训练服务环境中,不进入浏览器、URL、SQLite 或训练日志。
## 上游与许可证
+116
View File
@@ -0,0 +1,116 @@
# Code Context
## Files Retrieved
1. `wasm/web_platform/src/simulation/SimulationSession.ts`(lines 1-107)- 仿真步进、控制器生命周期、模型名称解析和 `mj_step` 前控制写入的核心集成点。
2. `wasm/web_platform/src/simulation/PhysicsAdapter.ts`(lines 12-110)- UI 与 session 的稳定门面;模型切换、资源释放和 Go2-W URDF 增强发生于此。
3. `wasm/web_platform/src/app/App.tsx`(lines 37-78, 100-105)- 工程导入、控制器状态、错误反馈、右侧栏 wiring。
4. `wasm/web_platform/src/app/components/SidebarPanel.tsx`(lines 19-35)- 当前“控制”tab 仅嵌入 Python 控制器,是 ONNX 面板的最小 UI 插槽。
5. `wasm/web_platform/src/controller/PythonControllerRuntime.ts`(lines 30-120)- 可复用的控制器生命周期/状态设计;同时揭示现有控制回调必须同步。
6. `wasm/web_platform/src/controller/types.ts`(lines 1-42)- 当前状态被写死为 `language:'python'`,且 step API 可读关节、sensor、body quaternion。
7. `wasm/web_platform/src/project/importer.ts`(lines 1-48, 184-223)- 所有二进制文件已原样进入 manifest,ONNX 无需进入模型 entry 发现逻辑。
8. `wasm/web_platform/src/project/types.ts`(lines 1-41)- ProjectFile/大小上限;单文件允许 128 MiB、工程 512 MiB。
9. `wasm/package.json`(scripts/dependencies)- 已有 Vitest/Playwright,尚无 `onnxruntime-web`。
10. `wasm/web_platform/vite.config.ts`(lines 1-27)- Pyodide 本地资产复制是 ORT wasm 离线资产处理的直接范例。
11. `wasm/src/go2_w_balance.py`(lines 1-约480)- 仓库已有 Go2-W 手写 200 Hz 平衡/轮速控制,可作模型命名、IMU 和安全回退参考,但不是 RL 策略。
12. `/home/cen/Embodied_Workspace/unitree_rl_mjlab/deploy/robots/go2/config/policy/velocity/v0/params/deploy.yaml`(lines 1-56)- 部署时序、动作变换及精确 observation 顺序。
13. `/home/cen/Embodied_Workspace/unitree_rl_mjlab/deploy/robots/go2/src/State_RLBase.cpp`(lines 6-31)- 部署约定为 `params/deploy.yaml` + `exported/policy.onnx`,输出经 action manager 处理后按 joint map 下发。
14. `/home/cen/Embodied_Workspace/unitree_rl_mjlab/src/tasks/velocity/config/go2/env_cfgs.py`(lines 1-约160)- flat 任务移除 height scan,Go2 base 为 `base_link`。
## Key Code
### 已有正确插入顺序
`SimulationSession.singleStep/advance` 均执行:控制器 → 外力 → `mj_step`(`SimulationSession.ts:46-57`)。ONNX 动作必须沿用该位置,而不能在 React frame snapshot 后直接写 ctrl,否则会引入一物理步延迟和不稳定的渲染帧时序。
### 参考部署契约
Go2 deploy YAML 定义:
- `step_dt: 0.02`,即策略 50 Hz;仿真步可保持 0.002 s,动作 hold 10 步。
- observation 串接顺序和维数:`base_ang_vel(3), projected_gravity(3), velocity_commands(3), gait_phase(2), joint_pos_rel(12), joint_vel_rel(12), last_action(12)`,总计 **47 float32**。
- action 为 12 维位置目标:`processed = raw * 0.25 + default_joint_pos`;默认位姿见 YAML lines 3-5/18-20。
- joint 顺序不能取 MuJoCo ID 偶然顺序;部署 `joint_ids_map=[3,4,5,0,1,2,9,10,11,6,7,8]` 表示 RL 顺序与硬件/模型顺序存在显式重排。
- ONNX 与参数是配套件:`params/deploy.yaml` + `exported/policy.onnx`(`State_RLBase.cpp:12-16`)。仅导入 `.onnx` 无法可靠解释 observation/action。
### 最小文件级实现方案(建议,不修改)
1. **新增独立策略层**:`src/policy/types.ts`, `OnnxPolicyRuntime.ts`, `Go2VelocityTask.ts`。
- `OnnxPolicyRuntime` 只负责 ORT session、输入/输出名称与 shape 校验、generation cancellation、dispose。
- `Go2VelocityTask` 固化首个 task 的 47→12 契约、命令/phase/lastAction、名称到 joint/actuator 的显式映射、PD 目标或 position actuator 输出。
- 不应把 ONNX 逻辑塞入 `PythonControllerRuntime`;其 Pyodide proxy 与同步函数假设完全不同。
2. **`SimulationSession.ts`**:将单一 `pythonController` 最小泛化为互斥 controller slot,或新增 `onnxPolicy` 并在同一个 `runController()` 中互斥调度;load/remove/reset/dispose 复用 generation 防模型切换竞态。添加只读 observation helpers(按 joint/body/sensor 名称解析),避免 UI 从 snapshot 拼向量。禁用/异常时必须 `ctrl.fill(0)`、暂停并保留错误。
3. **异步风险处理**:`onnxruntime-web` 的 `session.run()` 返回 Promise,不能直接 await 于当前同步 `advance()` while loop。最小侵入方案是“最新完成动作 hold”:到 50 Hz 时复制 observation 发起一次推理(仅允许一个 in-flight),后续物理步继续持有上次动作,完成后原子替换;首帧保持安全默认位姿而非零力矩。状态须显示 inference latency/overrun。若要求训练等价的严格每 20 ms 动作,则需把物理 loop 改为异步/worker,已非最小侵入。
4. **`PhysicsAdapter.ts`**:接口新增 `loadOnnxPolicy(policyBytes, config)`, `setPolicyEnabled`, `setVelocityCommand`, `removePolicy`;MainThread adapter 只转发,模型 `releaseCurrent()` 自动 dispose。不要把 policy 写入 MuJoCo MEMFS,ORT 可直接吃 `Uint8Array`。
5. **项目导入**:`importer.ts` 已保留任意扩展名,ZIP/目录中的 `.onnx/.yaml` 会存在 `manifest.files`;无需修改 `discoverEntries`(它只应发现模型)。在 App 中按配套目录约定寻找 `exported/policy.onnx` 及 `params/deploy.yaml`,首版更稳妥的是新增浏览器可直接解析的 JSON policy manifest;不要为 YAML 引入重量解析器后猜测任意训练配置。独立单文件导入应沿用 Python 的 upsert 流程,但 accept 改 `.onnx` 并施加专用上限。
6. **依赖/构建**:`wasm/package.json` 加 `onnxruntime-web`;`vite.config.ts` 仿照 Pyodide 将 ORT wasm 文件复制到固定相对目录并设置 `ort.env.wasm.wasmPaths`,否则离线 `base:'./'` 构建容易 404。首版使用 wasm CPU 单线程,避免 WebGPU/COOP-COEP 扩大范围。
7. **UI**:新增 `PolicyPanel.tsx`,插在 `SidebarPanel.tsx:32` 的控制 tab,与 Python 控制器并列;展示模型/config、47×12 shape、50 Hz、启停、速度 x/y/yaw、推理耗时/错误。`App.tsx:40,55,72-77,104` 按现有 controller 状态模式增加 policy 状态及 callbacks;加载另一控制器时明确移除/禁用前一个,不能二者同时写 ctrl。
8. **首个 Go2-W 任务边界**:参考仓库提供的是 **Go2 12-DOF** velocity policy,不是 Go2-W policy;Go2-W 多四个轮关节。首版可把 12 个腿关节按该 policy 控制、轮 actuator 固定阻尼/零速,仅称“Go2-W 腿式站立/速度实验”,不能宣称与训练分布一致。真正轮式平衡/速度需要 Go2-W 专属训练导出(observation/action 很可能含轮速和 16 维动作)。
## Architecture
浏览器文件 → `ProjectManifest.files`(二进制 ONNX 已保留)→ `App` 选择配套 policy/config → `PhysicsAdapter` → `SimulationSession` 拥有 policy runtime。每个 MuJoCo 子步前 task 从 `MjData` 组 observation;每 0.02 s 异步提交 ONNX,完成输出经 scale/offset、显式 joint mapping 和限幅后成为 held action;物理 loop 每步应用 held action。snapshot 仅向 UI报告状态,不作为 observation 数据源。
## 观测/动作风险与严重度
- **blocker**:参考资产无 Go2-W ONNX,且仓库参考目录下 Go2 velocity 的 `exported/policy.onnx` 实际不存在;不能验证 input/output 名或数值一致性。
- **blocker**:Go2 12-DOF policy 与 Go2-W 16 actuator 拓扑不匹配。若按 `model.njnt/nactuator` 顺序拼接会 shape 错误或静默错控。
- **high**:`projected_gravity` 必须是重力单位向量由世界系旋到 body/IMU 局部系。MuJoCo `xquat` 为 wxyz;符号、quat inverse、IMU 安装姿态任一错误都会使策略立刻摔倒。加速度计并不等价于无噪声 projected gravity。
- **high**:`base_ang_vel` 需 body/local frame;直接使用 free joint `qvel[3:6]` 或 gyro sensor前必须确认 MuJoCo sensor frame与训练定义一致。
- **high**:`joint_pos_rel = q-default`,joint_vel 和 action 都必须是 YAML 的 RL 顺序;四足 FL/FR/RL/RR 次序在当前手写 Go2-W controller 与 deploy map 中并不天然一致。
- **high**:输出是 normalized action,不是 torque;必须 scale+offset 后交给 position actuator/PD。当前 `setActuator` 只写 ctrl,URDF 自动生成的是 motor(`PhysicsAdapter.ts:68`),因此若直接写位置值会被当 N·m。需要 task 内 PD torque,或为该 task 构造 position actuator。
- **medium**:gait phase 是 0.6 s 周期的 sin/cos,但应确认零点、sin/cos 顺序以及站立零命令时是否冻结;YAML 没编码完整函数语义。
- **medium**:异步 ORT latency 若超过 20 ms 会跳过策略 tick;必须统计 dropped/late inference,且避免并发堆积。
- **medium**:reset 应同时清 lastAction、phase、in-flight generation 和 held action;否则旧 Promise 可污染新 episode。
- **medium**:导入不可信 ONNX 可能造成大内存/计算消耗;现有 128 MiB 文件上限不足以约束 tensor shape/运行时内存。
## 测试建议
1. `src/policy/Go2VelocityTask.test.ts`:固定 qpos/qvel/quaternion/command,逐元素断言 47 维顺序、dtype、projected gravity、phase、joint reorder;固定 raw action 断言 `*0.25+offset` 和 actuator 映射。
2. `src/policy/OnnxPolicyRuntime.test.ts`:mock ORT,覆盖 input/output shape/name 错误、NaN/Inf、single-flight、超时/overrun、generation cancellation、dispose;用极小 identity ONNX fixture 做一次真实 wasm smoke test。
3. 新增 `SimulationSession.test.ts`(当前该核心类无测试):mock MainModule 验证 controller 在 `mj_step` 前执行、50 Hz decimation、held action、reset/disable/error 清 ctrl、Python/ONNX 互斥、模型切换后旧推理无效。
4. `PhysicsAdapter` contract test:load/remove/dispose 转发及失败后 workspace/runtime 都释放。
5. `importer.test.ts`:ZIP/目录保留 `.onnx` 与 config、路径规范化、重复/超限拒绝;entry discovery 不把 ONNX 当模型。
6. `PolicyPanel.test.tsx` + `SidebarPanel` 测试:导入、启停、命令范围、shape/错误展示、另一控制器启用时互斥。
7. Playwright:导入 Go2-W 工程+fixture policy,启用后推进若干秒,断言页面不崩、policy 状态与命令变化;数值验收应另做离线 golden trajectory(相同初态前 N 次 observation/action 与 Python/ORT reference 对齐),容差逐元素约 `1e-5`,并检查 base height/倾角安全阈值。
8. 常规命令:`npm run typecheck:platform`, `npm run lint:platform`, `npm run test:platform`, `npm run build:platform`, 最后 `npm run test:e2e:platform`。
## Start Here
先打开 `wasm/web_platform/src/simulation/SimulationSession.ts:44-93`:这是唯一能保证 observation 采样、控制输出和 `mj_step` 顺序正确,并统一 reset/error/dispose 语义的位置。
## Residual Risks
- 未获得 Go2-W 专属 ONNX 与导出 metadata,无法完成真实 shape/name/golden 验证。
- unitree_rl_mjlab 的 YAML 说明字段顺序与缩放,但 gait phase、frame convention 等函数语义仍需用实际导出/运行器确认。
- 主线程异步 inference 的 held-action 方案是最小侵入折衷,不提供硬实时或训练环境严格等价。
```acceptance-report
{
"criteriaSatisfied": [
{
"id": "criterion-1",
"status": "satisfied",
"evidence": "已给出 SimulationSession、PhysicsAdapter、App/Sidebar、导入与测试结构的具体文件/行范围方案,并按 blocker/high/medium 列出观测动作风险。"
}
],
"changedFiles": [],
"testsAddedOrUpdated": [],
"commandsRun": [
{
"command": "find/grep/read/nl(只读仓库与 unitree_rl_mjlab 参考配置)",
"result": "passed",
"summary": "确认现有控制步序、项目导入行为、测试布局及 Go2 deploy 的 47维观测/12维动作约定。"
}
],
"validationOutput": [
"参考 deploy.yaml: step_dt=0.02,观测 3+3+3+2+12+12+12=47,动作 12,scale=0.25。",
"参考部署约定为 params/deploy.yaml + exported/policy.onnx;当前 Go2 目录未发现实际 ONNX。",
"当前仓库 package dependencies 未包含 onnxruntime-web。"
],
"residualRisks": [
"缺少 Go2-W 专属 ONNX/metadata,参考仅为 Go2 12-DOF。",
"异步浏览器 ORT 与同步物理 loop 存在时序折衷。",
"projected gravity、角速度 frame、joint mapping 必须做 golden 对齐。"
],
"noStagedFiles": true,
"diffSummary": "只读分析;未修改仓库文件,仅写入指定 context.md 报告。",
"reviewFindings": [
"blocker: /home/cen/Embodied_Workspace/unitree_rl_mjlab/deploy/robots/go2 - 未发现 Go2 velocity policy.onnx,无法验证模型 I/O。",
"blocker: deploy.yaml:1-56 - 参考策略为 12-DOF Go2,与 Go2-W 16 actuator 不匹配。",
"high: wasm/web_platform/src/simulation/SimulationSession.ts:49-57 - 同步物理 while loop 无法直接 await ORT Promise。",
"high: wasm/web_platform/src/simulation/PhysicsAdapter.ts:68-70 - URDF 自动生成 motor,normalized position action 不可直接写 ctrl。"
],
"manualNotes": "报告已写入权威路径;除 context.md 外未改动项目。"
}
```
+9 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mujoco-web-platform",
"version": "0.7.1",
"version": "0.8.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mujoco-web-platform",
"version": "0.7.1",
"version": "0.8.3",
"license": "Apache-2.0",
"dependencies": {
"@monaco-editor/react": "^4.7.0",
@@ -18,6 +18,7 @@
"pyodide": "^0.29.4",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"uplot": "^1.6.32",
"zustand": "^5.0.15"
},
"devDependencies": {
@@ -5091,6 +5092,12 @@
"browserslist": ">= 4.21.0"
}
},
"node_modules/uplot": {
"version": "1.6.32",
"resolved": "https://registry.npmjs.org/uplot/-/uplot-1.6.32.tgz",
"integrity": "sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==",
"license": "MIT"
},
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mujoco-web-platform",
"version": "0.7.1",
"version": "0.8.3",
"description": "基于 MuJoCo WebAssembly 的本地机器人仿真与控制平台",
"private": true,
"type": "module",
@@ -61,6 +61,7 @@
"pyodide": "^0.29.4",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"uplot": "^1.6.32",
"zustand": "^5.0.15"
},
"engines": {
+1 -1
View File
@@ -1,7 +1,7 @@
[tool.ruff]
target-version = "py312"
line-length = 100
extend-exclude = [".venv", "build"]
extend-exclude = [".venv", "build", "training_server/rl"]
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM"]
+1
View File
@@ -1 +1,2 @@
-r training_server/requirements.txt
ruff==0.16.5
+55 -17
View File
@@ -1,39 +1,65 @@
# 本地强化学习训练服务
该服务把 Web 平台发出的受限训练请求转换为本机 `unitree_rl_mjlab/scripts/train.py` 子进程,并提供状态轮询、停止任务和 `policy.onnx` 下载接口。服务只绑定 `127.0.0.1`,不执行前端传入的任意命令。
该服务把 Web 平台发出的受限训练请求转换为本机训练子进程,并提供状态轮询、停止任务和 `policy.onnx` 下载接口。它还提供 `Unitree-Go2-Flat` 奖励函数自调参:DeepSeek Agent 根据训练曲线与固定评估指标提出受限参数 patch,系统支持全自动或逐轮审批、successive-halving、TensorBoard scalar 查询、最佳 preset 与 ONNX 导出。服务只绑定 `127.0.0.1`,不执行前端传入的任意命令或 Agent 生成的代码。
仓库已在 [`rl/`](rl/) 内置 `Unitree-Go2-Flat` 所需的 PPO 训练代码、Go2 模型资产和 ONNX 导出逻辑,不再要求另外克隆 `unitree_rl_mjlab`。`mjlab`、PyTorch 等大型运行依赖仍需安装在本机训练环境中。
## 准备训练环境
使用仓库已有的 `.venv` 项目虚拟环境:
```bash
source .venv/bin/activate
python -m pip install -r training_server/rl/requirements.txt
python -m pip install -r training_server/requirements.txt
```
## 启动
必须使用已经安装 `mjlab`、PyTorch 和训练工程依赖的 Python 解释器:
使用已安装训练依赖的 Python 解释器启动服务:
```bash
npm run training-server -- \
--trainer-root /path/to/unitree_rl_mjlab \
--trainer-python /path/to/training-env/bin/python
```
如:
```bash
npm run training-server -- --trainer-root /home/cen/Embodied_Workspace/unitree_rl_mjlab --trainer-python /home/cen/miniconda3/envs/unitree_rl_mjlab/bin/python
--trainer-python "$PWD/.venv/bin/python"
```
服务启动时会在终端显示一个随机访问令牌。将该令牌填入前端“访问令牌”字段后再连接。令牌只保存在当前浏览器标签页的 `sessionStorage` 中。自动化启动时可固定令牌:
```bash
MUJOCO_TRAINING_TOKEN='至少十六个字符的随机令牌' npm run training-server -- \
--trainer-root /path/to/unitree_rl_mjlab
--trainer-python /path/to/training-env/bin/python
```
也可用 `UNITREE_RL_MJLAB_ROOT` 指定工程目录。默认端口是 `8765`。如果前端不是从 `localhost` 或 `127.0.0.1` 提供,可显式添加来源:
启用云端自调参 Agent 时,在**服务端环境变量**中配置 DeepSeek;不要把 key 填到浏览器、URL 或命令行参数:
```bash
export DEEPSEEK_API_KEY='你的 DeepSeek API key'
export MUJOCO_TUNING_AGENT_BASE_URL='https://api.deepseek.com' # 可省略
export MUJOCO_TUNING_AGENT_MODEL='deepseek-v4-flash' # 可省略
npm run training-server -- --trainer-python "$PWD/.venv/bin/python"
```
普通训练不要求 DeepSeek key。未配置时健康接口会把 tuning 标记为不可用;只有创建 session 时明确勾选 fallback,才允许 Agent 失败后使用 Optuna 候选,不会静默降级。
默认训练工程是仓库内的 `training_server/rl`。如需使用包含其他已注册任务的外部训练工程,仍可通过 `--trainer-root /path/to/trainer` 或 `UNITREE_RL_MJLAB_ROOT` 覆盖。默认端口是 `8765`。如果前端不是从 `localhost` 或 `127.0.0.1` 提供,可显式添加来源:
```bash
python training_server/server.py \
--trainer-root /path/to/unitree_rl_mjlab \
--trainer-python /path/to/training-env/bin/python \
--allow-origin http://192.168.1.10:5173
```
服务一次只运行一个训练任务,最多保留 20 个任务的内存状态,每个任务最多保留 200 行最近日志。停止服务或在界面点击“停止训练”会同步终止整个训练进程组。训练请求的 W&B 模式默认为 `offline`,保留本地指标但不登录;也可以在界面选择完全禁用或在线模式。所有 API 请求都必须携带启动时生成的 Bearer Token。
普通训练与自调参共享同一个计算资源锁,任何时刻只允许一个训练/评估子进程占用 GPU。普通任务最多保留 20 个内存状态和每个任务 200 行最近日志;调参 session、trial、proposal、审计和 scalar 写入 SQLite/WAL,默认保存在 `training_server/rl/logs/auto_tuning/`。服务重启后等待审批/暂停状态可恢复,正在训练或评估的 trial 标记为 interrupted,只能从已完整保存的 checkpoint 显式恢复。停止服务或点击“停止”会终止整个进程组。
普通训练的 W&B 模式默认为 `offline`;调参 trial 强制使用本地 TensorBoard writer,DeepSeek 只接收最多 12 个 trial 的脱敏数值摘要和降采样曲线,不接收源代码、机器人资产、checkpoint、服务 token 或本地路径。所有 HTTP API 请求都必须携带启动时生成的 Bearer Token。
## 自调参流程
在主工作台连接训练服务后,点击“打开自调参 Agent 工作台”。默认预算为 12 个唯一配置:所有配置先训练 300 iterations,前 4 名续训到 900,前 2 名续训到 2000;默认使用 GPU 0 和 4096 个并行环境。首次使用建议先降低为 256–512 environments 做 smoke test。
固定评估使用站立、前进/侧移、转向和组合命令以及 3 个固定 seed。最终分数不直接使用可被权重放大的总 reward,而由速度跟踪 35%、动作平滑 20%、姿态稳定 15%、减少跌倒 15%、足端滑移 10%、能耗 5% 的权重无关指标组成。跌倒率高于基线 2% 或速度误差恶化超过 5% 的 trial 不晋级。逐轮审批模式会自动运行基线,之后每条 Agent 建议都等待批准、修改后批准或拒绝反馈;运行中的 session 也可在逐轮审批和全自动之间切换,切到全自动时会批准当前待处理建议。
最佳结果保存为不可变 preset,可在普通训练面板的“奖励配置”中选择,也可导出 JSON;不会覆盖仓库里的 Python 默认奖励配置。
## 接口
@@ -41,14 +67,26 @@ python training_server/server.py \
- `POST /api/training/jobs`:发起训练;
- `GET /api/training/jobs/{id}`:状态、迭代进度和最近日志;
- `DELETE /api/training/jobs/{id}`:停止训练;
- `GET /api/training/jobs/{id}/artifacts/policy.onnx`:下载本次生成的策略。
- `GET /api/training/jobs/{id}/artifacts/policy.onnx`:下载本次生成的策略;
- `GET /api/tuning/capabilities`、`POST /api/tuning/agent/test`:检查/测试 Agent;
- `GET|POST /api/tuning/sessions`、`GET|DELETE /api/tuning/sessions/{id}`:列出、创建、查询、停止 session;
- `POST /api/tuning/sessions/{id}/pause|resume`:暂停后续调度或恢复;
- `POST /api/tuning/sessions/{id}/mode`:运行时切换 `automatic`/`approval` 模式;
- `PUT /api/tuning/sessions/{id}/constraints`:以 revision CAS 保存参数固定值/工程上下限,服务端在 Agent、fallback 与人工修改三条路径统一强制;
- `POST /api/tuning/sessions/{id}/step`:发放且只消费一个 Trial 调度令牌,完成训练与固定评估后重新暂停;
- `POST /api/tuning/sessions/{id}/rollback`:把同 Session 内已完成且通过安全门槛的 Trial 设为非破坏性后续基准,可同时验证其 checkpoint;
- `POST /api/tuning/sessions/{id}/proposals/{proposalId}/approve|reject`:审批、修改或拒绝建议;
- `GET /api/tuning/sessions/{id}/trials/{trialId}/metrics?afterStep=N`:查询降采样或增量 scalar;
- `GET /api/tuning/sessions/{id}/artifacts/best/policy.onnx`:下载最佳策略;
- `GET /api/tuning/presets`:列出可供普通训练复用的最佳奖励 preset。
任务保存在服务内存中,服务重启后历史任务状态会丢失,但 mjlab 日志和 checkpoint 仍保留在训练工程中。
普通任务状态在服务重启后丢失,但日志、checkpoint 和 ONNX 保留在 `training_server/rl/logs/rsl_rl/`;调参状态、调度令牌、参数护栏、回滚基准及产物持久化在 `logs/auto_tuning/`。候选配置数可在 1–100 间设置(包含基线配置,仍受连续无提升早停约束)。API 只接收 32 位资源 ID,不接收客户端文件路径;奖励 patch 受到名称、符号、上下界、Session 护栏、每轮最多 4 项及 `0.5×–2×` 变化率校验。
## 测试
```bash
python3 -m pip install -r requirements-dev.txt
source .venv/bin/activate
python -m pip install -r requirements-dev.txt
npm run lint:python
npm run test:training-server
```
+5
View File
@@ -0,0 +1,5 @@
# 本地自调参服务(Python 3.12)
pydantic-ai-slim[openai]==2.37.0
httpx2[socks]==2.12.0
optuna==4.9.0
tensorboard==2.21.0
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2024 The Isaac Lab Project Developers
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+28
View File
@@ -0,0 +1,28 @@
# 内置 Go2 强化学习训练器
这里包含 `training_server` 默认任务 `Unitree-Go2-Flat` 所需的训练代码与资产:
- RSL-RL/PPO 训练入口;
- Go2 速度跟踪环境、观测、奖励、终止条件和课程配置;
- Go2 MJCF 与网格资产;
- checkpoint 保存及 `policy.onnx` 导出 runner。
这是 [unitreerobotics/unitree_rl_mjlab](https://github.com/unitreerobotics/unitree_rl_mjlab) 的最小内置子集,来源提交为 `1425b15f73bd4095f0df53709d7c389c3eb9e790`,按上游 Apache License 2.0 许可证分发,见 [`LICENSE`](LICENSE)。为避免复制与默认训练任务无关的机器人、真机部署和运动模仿代码,只保留了 Go2 速度任务依赖。
## 环境
从仓库根目录使用已有的 `.venv` 项目虚拟环境:
```bash
source .venv/bin/activate
python -m pip install -r training_server/rl/requirements.txt
```
训练服务会直接从本目录启动 `scripts/train.py`,不需要安装本目录自身。训练产物写入本目录的 `logs/rsl_rl/`(已被仓库忽略)。
可以脱离服务验证任务是否成功注册:
```bash
cd training_server/rl
python scripts/train.py Unitree-Go2-Flat --help
```
+9
View File
@@ -0,0 +1,9 @@
# 与内置 unitree_rl_mjlab 子集验证一致的训练栈。
# mjlab 1.2.0 会使用 MuJoCo 3.5 的 mjENBL_MULTICCD API,不能放任
# pip 将 mujoco/warp-lang 升级到较新的不兼容版本。
mjlab==1.2.0
mujoco==3.5.0
mujoco-warp==3.5.0
warp-lang==1.15.0
# RSL-RL 5.0.1 仍向 wandb.Settings 传递 start_method,0.29 已删除该字段。
wandb==0.28.2
+222
View File
@@ -0,0 +1,222 @@
"""Deterministic, headless evaluation for Unitree Go2 velocity policies."""
from __future__ import annotations
import hashlib
import json
import os
import sys
from dataclasses import asdict, dataclass, field
from pathlib import Path
from statistics import fmean, pstdev
from typing import Literal
TRAINER_ROOT = Path(__file__).resolve().parents[1]
SERVICE_ROOT = TRAINER_ROOT.parent
for source_root in (TRAINER_ROOT, SERVICE_ROOT):
if str(source_root) not in sys.path:
sys.path.insert(0, str(source_root))
import torch
import tyro
import warp as wp
if not hasattr(wp, "context"):
from warp._src import context as warp_context
wp.context = warp_context # type: ignore[attr-defined]
from mjlab.envs import ManagerBasedRlEnv
from mjlab.rl import MjlabOnPolicyRunner, RslRlVecEnvWrapper
from mjlab.tasks.registry import list_tasks, load_env_cfg, load_rl_cfg, load_runner_cls
from mjlab.tasks.velocity.mdp import UniformVelocityCommandCfg
from mjlab.utils.torch import configure_torch_backends
from torch.utils.tensorboard import SummaryWriter
from tuning.schema import apply_reward_configuration, validate_configuration
SCENARIOS = (
(0.0, 0.0, 0.0),
(0.5, 0.0, 0.0),
(1.0, 0.0, 0.0),
(1.5, 0.0, 0.0),
(0.0, 0.5, 0.0),
(0.0, -0.5, 0.0),
(0.0, 0.0, 0.5),
(0.0, 0.0, -0.5),
(0.8, 0.25, 0.35),
)
METRIC_NAMES = (
"linear_velocity_rmse",
"angular_velocity_rmse",
"mean_action_acc",
"orientation_error",
"slip_velocity",
"mechanical_power",
)
@dataclass(frozen=True)
class EvaluateConfig:
checkpoint: str
output: str
reward_config: str | None = None
num_envs: int = 256
steps_per_seed: int = 1000
seeds: tuple[int, ...] = field(default_factory=lambda: (101, 202, 303))
device: str | None = None
gpu_ids: list[int] | Literal["all"] | None = field(default_factory=lambda: [0])
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _fixed_commands(command: torch.Tensor) -> torch.Tensor:
values = torch.as_tensor(SCENARIOS, device=command.device, dtype=command.dtype)
indexes = torch.arange(command.shape[0], device=command.device) % values.shape[0]
command[:] = values[indexes]
return command
def _evaluate_seed(task_id: str, cfg: EvaluateConfig, seed: int) -> dict[str, float]:
torch.manual_seed(seed)
env_cfg = load_env_cfg(task_id, play=False)
agent_cfg = load_rl_cfg(task_id)
env_cfg.seed = seed
env_cfg.scene.num_envs = cfg.num_envs
env_cfg.curriculum = {}
env_cfg.observations["actor"].enable_corruption = False
env_cfg.events.pop("push_robot", None)
twist_cfg = env_cfg.commands["twist"]
assert isinstance(twist_cfg, UniformVelocityCommandCfg)
twist_cfg.heading_command = False
twist_cfg.ranges.heading = None
twist_cfg.rel_heading_envs = 0.0
twist_cfg.rel_standing_envs = 0.0
twist_cfg.resampling_time_range = (1.0e9, 1.0e9)
if cfg.reward_config:
reward_path = Path(cfg.reward_config).expanduser().resolve(strict=True)
with reward_path.open(encoding="utf-8") as stream:
apply_reward_configuration(env_cfg, validate_configuration(json.load(stream)))
env = ManagerBasedRlEnv(cfg=env_cfg, device=cfg.device or "cuda:0")
wrapped = RslRlVecEnvWrapper(env, clip_actions=agent_cfg.clip_actions)
try:
runner_cls = load_runner_cls(task_id) or MjlabOnPolicyRunner
runner = runner_cls(wrapped, asdict(agent_cfg), log_dir=None, device=wrapped.device)
runner.load(
str(Path(cfg.checkpoint).expanduser().resolve(strict=True)),
load_cfg={"actor": True},
strict=True,
map_location=str(wrapped.device),
)
policy = runner.get_inference_policy(device=str(wrapped.device))
twist = wrapped.unwrapped.command_manager.get_term("twist")
_fixed_commands(twist.command)
obs = wrapped.get_observations()
sums = {name: 0.0 for name in METRIC_NAMES}
samples = 0
terminations = 0
completions = 0
with torch.inference_mode():
for _ in range(cfg.steps_per_seed):
_fixed_commands(twist.command)
obs = wrapped.get_observations()
actions = policy(obs)
obs, _rewards, _dones, _extras = wrapped.step(actions)
manager = wrapped.unwrapped.metrics_manager
for index, name in enumerate(manager.active_terms):
if name in sums:
sums[name] += float(torch.sum(manager._step_values[:, index]).item())
samples += wrapped.num_envs
terminated = wrapped.unwrapped.reset_terminated
timed_out = wrapped.unwrapped.reset_time_outs
terminations += int(torch.count_nonzero(terminated).item())
completions += int(torch.count_nonzero(terminated | timed_out).item())
result = {name: sums[name] / max(samples, 1) for name in METRIC_NAMES}
result["fall_rate"] = terminations / max(completions, wrapped.num_envs)
return result
finally:
wrapped.close()
def run_evaluation(task_id: str, cfg: EvaluateConfig) -> dict:
configure_torch_backends()
selected = cfg.gpu_ids
if selected is None:
os.environ["CUDA_VISIBLE_DEVICES"] = ""
device = "cpu"
else:
if selected == "all":
selected = [0]
os.environ["CUDA_VISIBLE_DEVICES"] = ",".join(map(str, selected))
device = cfg.device or "cuda:0"
os.environ["MUJOCO_GL"] = "egl"
cfg = EvaluateConfig(**{**asdict(cfg), "device": device})
checkpoint = Path(cfg.checkpoint).expanduser().resolve(strict=True)
output = Path(cfg.output).expanduser().resolve()
output.parent.mkdir(parents=True, exist_ok=True)
per_seed = [_evaluate_seed(task_id, cfg, seed) for seed in cfg.seeds]
metrics = {
key: fmean(seed_metrics[key] for seed_metrics in per_seed)
for key in (*METRIC_NAMES, "fall_rate")
}
deviations = {
key: pstdev(seed_metrics[key] for seed_metrics in per_seed)
for key in (*METRIC_NAMES, "fall_rate")
}
result = {
"protocolVersion": 1,
"taskId": task_id,
"checkpoint": checkpoint.name,
"checkpointSha256": _sha256(checkpoint),
"seeds": list(cfg.seeds),
"numEnvs": cfg.num_envs,
"stepsPerSeed": cfg.steps_per_seed,
"scenarios": [list(value) for value in SCENARIOS],
"metrics": metrics,
"metricStd": deviations,
"seedMetrics": [
{"seed": seed, "metrics": values}
for seed, values in zip(cfg.seeds, per_seed, strict=True)
],
}
output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
writer = SummaryWriter(log_dir=str(output.parent / "evaluation-events"))
try:
for name, value in metrics.items():
writer.add_scalar(f"Evaluation/{name}", value, 0)
finally:
writer.close()
print("MUJOCO_EVALUATION " + json.dumps({"output": str(output), "metrics": metrics}))
return result
def main() -> None:
import mjlab.tasks # noqa: F401
import src.tasks # noqa: F401
chosen_task, remaining = tyro.cli(
tyro.extras.literal_type_from_choices(list_tasks()),
add_help=False,
return_unknown_args=True,
config=mjlab.TYRO_FLAGS,
)
args = tyro.cli(
EvaluateConfig,
args=remaining,
prog=sys.argv[0] + f" {chosen_task}",
config=mjlab.TYRO_FLAGS,
)
run_evaluation(chosen_task, args)
if __name__ == "__main__":
main()
+286
View File
@@ -0,0 +1,286 @@
"""Script to train RL agent with RSL-RL."""
import json
import logging
import os
import sys
from dataclasses import asdict, dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Literal, cast
# 训练器作为仓库内置子集直接从 scripts/ 启动,不要求额外执行 pip install -e。
TRAINER_ROOT = Path(__file__).resolve().parents[1]
SERVICE_ROOT = TRAINER_ROOT.parent
for source_root in (TRAINER_ROOT, SERVICE_ROOT):
if str(source_root) not in sys.path:
sys.path.insert(0, str(source_root))
import tyro
import warp as wp
# mjlab 1.2.0 的 GPU CUDA-graph 检查仍访问旧公开路径 wp.context;
# Warp 1.15 已把实现移到 warp._src.context,但保留了相同 runtime 契约。
if not hasattr(wp, "context"):
from warp._src import context as warp_context
wp.context = warp_context # type: ignore[attr-defined]
from mjlab.envs import ManagerBasedRlEnv, ManagerBasedRlEnvCfg
from mjlab.rl import MjlabOnPolicyRunner, RslRlBaseRunnerCfg, RslRlVecEnvWrapper
from mjlab.tasks.registry import list_tasks, load_env_cfg, load_rl_cfg, load_runner_cls
from mjlab.tasks.tracking.mdp import MotionCommandCfg
from mjlab.utils.gpu import select_gpus
from mjlab.utils.os import dump_yaml, get_checkpoint_path
from mjlab.utils.torch import configure_torch_backends
from mjlab.utils.wrappers import VideoRecorder
from tuning.schema import apply_reward_configuration, validate_configuration
@dataclass(frozen=True)
class TrainConfig:
env: ManagerBasedRlEnvCfg
agent: RslRlBaseRunnerCfg
motion_file: str | None = None
video: bool = False
video_length: int = 200
video_interval: int = 2000
enable_nan_guard: bool = False
torchrunx_log_dir: str | None = None
gpu_ids: list[int] | Literal["all"] | None = field(default_factory=lambda: [0])
output_dir: str | None = None
resume_checkpoint: str | None = None
reward_config: str | None = None
reward_config_json: str | None = None
@staticmethod
def from_task(task_id: str) -> "TrainConfig":
env_cfg = load_env_cfg(task_id)
agent_cfg = load_rl_cfg(task_id)
return TrainConfig(env=env_cfg, agent=agent_cfg)
def _load_reward_config(path: str | None, inline: str | None) -> dict | None:
if path is not None and inline is not None:
raise ValueError("Use only one of reward_config and reward_config_json")
if inline is not None:
if len(inline.encode("utf-8")) > 64 * 1024:
raise ValueError("Reward configuration is larger than 64 KiB")
return validate_configuration(json.loads(inline))
if path is None:
return None
source = Path(path).expanduser().resolve(strict=True)
if source.stat().st_size > 64 * 1024:
raise ValueError("Reward configuration is larger than 64 KiB")
with source.open(encoding="utf-8") as stream:
return validate_configuration(json.load(stream))
def run_train(task_id: str, cfg: TrainConfig, log_dir: Path) -> None:
reward_config = _load_reward_config(cfg.reward_config, cfg.reward_config_json)
if reward_config is not None:
apply_reward_configuration(cfg.env, reward_config)
cuda_visible = os.environ.get("CUDA_VISIBLE_DEVICES", "")
if cuda_visible == "":
device = "cpu"
seed = cfg.agent.seed
rank = 0
else:
local_rank = int(os.environ.get("LOCAL_RANK", "0"))
rank = int(os.environ.get("RANK", "0"))
# Set EGL device to match the CUDA device.
os.environ["MUJOCO_EGL_DEVICE_ID"] = str(local_rank)
device = f"cuda:{local_rank}"
# Set seed to have diversity in different processes.
seed = cfg.agent.seed + local_rank
configure_torch_backends()
cfg.agent.seed = seed
cfg.env.seed = seed
print(f"[INFO] Training with: device={device}, seed={seed}, rank={rank}")
# Check if this is a tracking task by checking for motion command.
is_tracking_task = "motion" in cfg.env.commands and isinstance(
cfg.env.commands["motion"], MotionCommandCfg
)
if is_tracking_task:
if not cfg.motion_file:
raise ValueError("For tracking tasks, --motion-file must be set ...")
motion_path = Path(cfg.motion_file).expanduser().resolve()
if not motion_path.exists():
raise FileNotFoundError(f"Motion file not found: {motion_path}")
motion_cmd = cfg.env.commands["motion"]
assert isinstance(motion_cmd, MotionCommandCfg)
motion_cmd.motion_file = str(motion_path)
print(f"[INFO] Using motion file: {motion_cmd.motion_file}")
# Check if motion_file is already set (e.g., via CLI --env.commands.motion.motion-file).
if motion_cmd.motion_file and Path(motion_cmd.motion_file).exists():
print(f"[INFO] Using local motion file: {motion_cmd.motion_file}")
# Enable NaN guard if requested.
if cfg.enable_nan_guard:
cfg.env.sim.nan_guard.enabled = True
print(f"[INFO] NaN guard enabled, output dir: {cfg.env.sim.nan_guard.output_dir}")
if rank == 0:
print(f"[INFO] Logging experiment in directory: {log_dir}")
env = ManagerBasedRlEnv(
cfg=cfg.env, device=device, render_mode="rgb_array" if cfg.video else None
)
log_root_path = log_dir.parent # Go up from specific run dir to experiment dir.
resume_path: Path | None = None
explicit_resume = cfg.resume_checkpoint is not None
if explicit_resume:
resume_path = Path(cfg.resume_checkpoint).expanduser().resolve(strict=True)
elif cfg.agent.resume:
# Load checkpoint from local filesystem.
resume_path = get_checkpoint_path(
log_root_path, cfg.agent.load_run, cfg.agent.load_checkpoint
)
# Only record videos on rank 0 to avoid multiple workers writing to the same files.
if cfg.video and rank == 0:
env = VideoRecorder(
env,
video_folder=Path(log_dir) / "videos" / "train",
step_trigger=lambda step: step % cfg.video_interval == 0,
video_length=cfg.video_length,
disable_logger=True,
)
print("[INFO] Recording videos during training.")
env = RslRlVecEnvWrapper(env, clip_actions=cfg.agent.clip_actions)
agent_cfg = asdict(cfg.agent)
env_cfg = asdict(cfg.env)
runner_cls = load_runner_cls(task_id)
if runner_cls is None:
runner_cls = MjlabOnPolicyRunner
runner_kwargs = {}
runner = runner_cls(env, agent_cfg, str(log_dir), device, **runner_kwargs)
runner.add_git_repo_to_log(__file__)
if resume_path is not None:
print(f"[INFO]: Loading model checkpoint from: {resume_path}")
runner.load(str(resume_path), map_location=device)
if explicit_resume:
# RSL-RL stores the last completed zero-based iteration and otherwise
# repeats it after load. Explicit tuning promotion uses an absolute target.
runner.current_learning_iteration += 1
# Only write config files from rank 0 to avoid race conditions.
if rank == 0:
dump_yaml(log_dir / "params" / "env.yaml", env_cfg)
dump_yaml(log_dir / "params" / "agent.yaml", agent_cfg)
if reward_config is not None:
reward_snapshot = log_dir / "params" / "reward_config.json"
reward_snapshot.parent.mkdir(parents=True, exist_ok=True)
reward_snapshot.write_text(
json.dumps(reward_config, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
iterations = cfg.agent.max_iterations
if explicit_resume:
iterations = max(0, cfg.agent.max_iterations - runner.current_learning_iteration)
print(
f"[INFO] Learning target: current={runner.current_learning_iteration}, "
f"additional={iterations}, target={cfg.agent.max_iterations}",
flush=True,
)
runner.learn(num_learning_iterations=iterations, init_at_random_ep_len=True)
env.close()
def launch_training(task_id: str, args: TrainConfig | None = None):
args = args or TrainConfig.from_task(task_id)
# Create log directory once before launching workers.
if args.output_dir:
log_dir = Path(args.output_dir).expanduser().resolve()
else:
log_root_path = (Path("logs") / "rsl_rl" / args.agent.experiment_name).resolve()
log_dir_name = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
if args.agent.run_name:
log_dir_name += f"_{args.agent.run_name}"
log_dir = log_root_path / log_dir_name
log_dir.mkdir(parents=True, exist_ok=True)
# Select GPUs based on CUDA_VISIBLE_DEVICES and user specification.
selected_gpus, num_gpus = select_gpus(args.gpu_ids)
# Set environment variables for all modes.
if selected_gpus is None:
os.environ["CUDA_VISIBLE_DEVICES"] = ""
else:
os.environ["CUDA_VISIBLE_DEVICES"] = ",".join(map(str, selected_gpus))
os.environ["MUJOCO_GL"] = "egl"
if num_gpus <= 1:
# CPU or single GPU: run directly without torchrunx.
run_train(task_id, args, log_dir)
else:
# Multi-GPU: use torchrunx.
import torchrunx
# torchrunx redirects stdout to logging.
logging.basicConfig(level=logging.INFO)
# Configure torchrunx logging directory.
# Priority: 1) existing env var, 2) user flag, 3) default to {log_dir}/torchrunx.
if "TORCHRUNX_LOG_DIR" not in os.environ:
if args.torchrunx_log_dir is not None:
# User specified a value via flag (could be "" to disable).
os.environ["TORCHRUNX_LOG_DIR"] = args.torchrunx_log_dir
else:
# Default: put logs in training directory.
os.environ["TORCHRUNX_LOG_DIR"] = str(log_dir / "torchrunx")
print(f"[INFO] Launching training with {num_gpus} GPUs", flush=True)
torchrunx.Launcher(
hostnames=["localhost"],
workers_per_host=num_gpus,
backend=None, # Let rsl_rl handle process group initialization.
copy_env_vars=torchrunx.DEFAULT_ENV_VARS_FOR_COPY + ("MUJOCO*",),
).run(run_train, task_id, args, log_dir)
def main():
# Parse first argument to choose the task.
# Import tasks to populate the registry.
import mjlab.tasks # noqa: F401
import src.tasks
all_tasks = list_tasks()
chosen_task, remaining_args = tyro.cli(
tyro.extras.literal_type_from_choices(all_tasks),
add_help=False,
return_unknown_args=True,
config=mjlab.TYRO_FLAGS,
)
args = tyro.cli(
TrainConfig,
args=remaining_args,
default=TrainConfig.from_task(chosen_task),
prog=sys.argv[0] + f" {chosen_task}",
config=mjlab.TYRO_FLAGS,
)
del remaining_args
launch_training(task_id=chosen_task, args=args)
if __name__ == "__main__":
main()
+4
View File
@@ -0,0 +1,4 @@
from pathlib import Path
SRC_PATH: Path = Path(__file__).parent
@@ -0,0 +1,3 @@
"""仓库内置训练器支持的机器人资产。"""
from .unitree_go2.go2_constants import get_go2_robot_cfg as get_go2_robot_cfg
@@ -0,0 +1 @@
"""Unitree Go2 quadruped."""
@@ -0,0 +1,148 @@
"""Unitree Go2 constants."""
from pathlib import Path
import mujoco
from src import SRC_PATH
from mjlab.actuator import BuiltinPositionActuatorCfg
from mjlab.entity import EntityArticulationInfoCfg, EntityCfg
from mjlab.utils.actuator import ElectricActuator, reflected_inertia
from mjlab.utils.os import update_assets
from mjlab.utils.spec_config import CollisionCfg
##
# MJCF and assets.
##
GO2_XML: Path = (
SRC_PATH / "assets" / "robots" / "unitree_go2" / "xmls" / "go2.xml"
)
assert GO2_XML.exists()
def get_assets(meshdir: str) -> dict[str, bytes]:
assets: dict[str, bytes] = {}
update_assets(assets, GO2_XML.parent / "assets", meshdir)
return assets
def get_spec() -> mujoco.MjSpec:
spec = mujoco.MjSpec.from_file(str(GO2_XML))
spec.assets = get_assets(spec.meshdir)
return spec
##
# Actuator config.
##
GO2_ACTUATOR_HIP = BuiltinPositionActuatorCfg(
target_names_expr=(
".*hip_.*",
),
stiffness=20.0,
damping=1.0,
effort_limit=23.5,
armature=0.01,
)
GO2_ACTUATOR_THIGH = BuiltinPositionActuatorCfg(
target_names_expr=(
".*thigh_.*",
),
stiffness=20.0,
damping=1.0,
effort_limit=23.5,
armature=0.01,
)
GO2_ACTUATOR_CALF = BuiltinPositionActuatorCfg(
target_names_expr=(
".*calf_.*",
),
stiffness=40.0,
damping=2.0,
effort_limit=45,
armature=0.02,
)
##
# Keyframes.
##
INIT_STATE = EntityCfg.InitialStateCfg(
pos=(0.0, 0.0, 0.32),
joint_pos={
".*thigh_joint": 0.9,
".*calf_joint": -1.8,
".*R_hip_joint": 0.1,
".*L_hip_joint": -0.1,
},
joint_vel={".*": 0.0},
)
##
# Collision config.
##
_foot_regex = "^[FR][LR]_foot_collision$"
# This disables all collisions except the feet.
# Furthermore, feet self collisions are disabled.
FEET_ONLY_COLLISION = CollisionCfg(
geom_names_expr=(_foot_regex,),
contype=0,
conaffinity=1,
condim=3,
priority=1,
friction=(0.6,),
solimp=(0.9, 0.95, 0.023),
)
# This enables all collisions, excluding self collisions.
# Foot collisions are given custom condim, friction and solimp.
FULL_COLLISION = CollisionCfg(
geom_names_expr=(".*_collision",),
condim={_foot_regex: 3, ".*_collision": 1},
priority={_foot_regex: 1},
friction={_foot_regex: (0.6,)},
solimp={_foot_regex: (0.9, 0.95, 0.023)},
contype=1,
conaffinity=0,
)
##
# Final config.
##
GO2_ARTICULATION = EntityArticulationInfoCfg(
actuators=(
GO2_ACTUATOR_HIP,
GO2_ACTUATOR_THIGH,
GO2_ACTUATOR_CALF,
),
soft_joint_pos_limit_factor=0.9,
)
def get_go2_robot_cfg() -> EntityCfg:
"""Get a fresh Go2 robot configuration instance.
Returns a new EntityCfg instance each time to avoid mutation issues when
the config is shared across multiple places.
"""
return EntityCfg(
init_state=INIT_STATE,
collisions=(FULL_COLLISION,),
spec_fn=get_spec,
articulation=GO2_ARTICULATION,
)
if __name__ == "__main__":
import mujoco.viewer as viewer
from mjlab.entity.entity import Entity
robot = Entity(get_go2_robot_cfg())
viewer.launch(robot.spec.compile())
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,161 @@
<mujoco model="go2">
<compiler angle="radian" meshdir="assets" autolimits="true" />
<default>
<default class="go2">
<default class="visual">
<geom type="mesh" contype="0" conaffinity="0" density="0" group="2"/>
</default>
<default class="collision">
<geom priority="1" condim="6" group="3"/>
</default>
<site rgba="1 0 0 1" group="5"/>
</default>
</default>
<asset>
<material name="metal" rgba=".9 .95 .95 1" />
<material name="black" rgba="0 0 0 1" />
<material name="white" rgba="1 1 1 1" />
<material name="gray" rgba="0.671705 0.692426 0.774270 1" />
<mesh file="base_0.obj" />
<mesh file="base_1.obj" />
<mesh file="base_2.obj" />
<mesh file="base_3.obj" />
<mesh file="base_4.obj" />
<mesh file="hip_0.obj" />
<mesh file="hip_1.obj" />
<mesh file="thigh_0.obj" />
<mesh file="thigh_1.obj" />
<mesh file="thigh_mirror_0.obj" />
<mesh file="thigh_mirror_1.obj" />
<mesh file="calf_0.obj" />
<mesh file="calf_1.obj" />
<mesh file="calf_mirror_0.obj" />
<mesh file="calf_mirror_1.obj" />
<mesh file="foot.obj" />
</asset>
<worldbody>
<body name="base_link" pos="0 0 0.445" childclass="go2">
<inertial pos="0.021112 0 -0.005366" quat="-0.000543471 0.713435 -0.00173769 0.700719" mass="6.921" diaginertia="0.107027 0.0980771 0.0244531" />
<freejoint name="floating_base_joint"/>
<geom mesh="base_0" material="black" class="visual" />
<geom mesh="base_1" material="black" class="visual" />
<geom mesh="base_2" material="black" class="visual" />
<geom mesh="base_3" material="white" class="visual" />
<geom mesh="base_4" material="gray" class="visual" />
<geom name="base1_collision" size="0.1881 0.04675 0.057" type="box" class="collision" />
<geom name="base2_collision" size="0.05 0.045" pos="0.285 0 0.01" type="cylinder" class="collision" />
<geom name="base3_collision" size="0.047" pos="0.293 0 -0.06" type="sphere" class="collision" />
<site name="imu" pos="-0.02557 0 0.04232" group="5"/>
<body name="FL_hip" pos="0.1934 0.0465 0">
<inertial pos="-0.0054 0.00194 -0.000105" quat="0.497014 0.499245 0.505462 0.498237" mass="0.678" diaginertia="0.00088403 0.000596003 0.000479967" />
<joint name="FL_hip_joint" axis="1 0 0" range="-1.0472 1.0472"/>
<geom mesh="hip_0" material="metal" class="visual" />
<geom mesh="hip_1" material="gray" class="visual" />
<geom name="FL_hip_collision" size="0.046 0.02" pos="0 0.08 0" quat="1 1 0 0" type="cylinder" class="collision" />
<body name="FL_thigh" pos="0 0.0955 0">
<inertial pos="-0.00374 -0.0223 -0.0327" quat="0.829533 0.0847635 -0.0200632 0.551623" mass="1.152" diaginertia="0.00594973 0.00584149 0.000878787" />
<joint name="FL_thigh_joint" axis="0 1 0" range="-1.5708 3.4907"/>
<geom mesh="thigh_0" material="metal" class="visual" />
<geom mesh="thigh_1" material="gray" class="visual" />
<geom name="FL_thigh_collision" size="0.1065 0.01225 0.017" pos="0 0 -0.1065" quat="0.707107 0 0.707107 0" type="box" class="collision" />
<body name="FL_calf" pos="0 0 -0.213">
<inertial pos="0.00629595 -0.000622121 -0.141417" quat="0.710672 0.00154099 -0.00450087 0.703508" mass="0.241352" diaginertia="0.0014901 0.00146356 5.31397e-05" />
<joint name="FL_calf_joint" axis="0 1 0" range="-2.7227 -0.83776"/>
<geom mesh="calf_0" material="gray" class="visual" />
<geom mesh="calf_1" material="black" class="visual" />
<geom name="FL_calf1_collision" size="0.012 0.06" pos="0.008 0 -0.06" quat="0.994493 0 -0.104807 0" type="cylinder" class="collision" />
<geom name="FL_calf2_collision" size="0.011 0.0325" pos="0.02 0 -0.148" quat="0.999688 0 0.0249974 0" type="cylinder" class="collision" />
<geom pos="0 0 -0.213" mesh="foot" class="visual" material="black" />
<geom name="FL_foot_collision" pos="0 0 -0.213" type="sphere" size="0.022" class="collision"/>
<site name="FL" pos="0 0 -0.213" type="sphere" size="0.022"/>
</body>
</body>
</body>
<body name="FR_hip" pos="0.1934 -0.0465 0">
<inertial pos="-0.0054 -0.00194 -0.000105" quat="0.498237 0.505462 0.499245 0.497014" mass="0.678" diaginertia="0.00088403 0.000596003 0.000479967" />
<joint name="FR_hip_joint" axis="1 0 0" range="-1.0472 1.0472"/>
<geom mesh="hip_0" material="metal" class="visual" quat="4.63268e-05 1 0 0" />
<geom mesh="hip_1" material="gray" class="visual" quat="4.63268e-05 1 0 0" />
<geom name="FR_hip_collision" size="0.046 0.02" pos="0 -0.08 0" quat="0.707107 0.707107 0 0" type="cylinder" class="collision" />
<body name="FR_thigh" pos="0 -0.0955 0">
<inertial pos="-0.00374 0.0223 -0.0327" quat="0.551623 -0.0200632 0.0847635 0.829533" mass="1.152" diaginertia="0.00594973 0.00584149 0.000878787" />
<joint name="FR_thigh_joint" axis="0 1 0" range="-1.5708 3.4907"/>
<geom mesh="thigh_mirror_0" material="metal" class="visual" />
<geom mesh="thigh_mirror_1" material="gray" class="visual" />
<geom name="FR_thigh_collision" size="0.1065 0.01225 0.017" pos="0 0 -0.1065" quat="0.707107 0 0.707107 0" type="box" class="collision" />
<body name="FR_calf" pos="0 0 -0.213">
<inertial pos="0.00629595 0.000622121 -0.141417" quat="0.703508 -0.00450087 0.00154099 0.710672" mass="0.241352" diaginertia="0.0014901 0.00146356 5.31397e-05" />
<joint name="FR_calf_joint" axis="0 1 0" range="-2.7227 -0.83776"/>
<geom mesh="calf_mirror_0" material="gray" class="visual" />
<geom mesh="calf_mirror_1" material="black" class="visual" />
<geom name="FR_calf1_collision" size="0.013 0.06" pos="0.01 0 -0.06" quat="0.995004 0 -0.0998334 0" type="cylinder" class="collision" />
<geom name="FR_calf2_collision" size="0.011 0.0325" pos="0.02 0 -0.148" quat="0.999688 0 0.0249974 0" type="cylinder" class="collision" />
<geom pos="0 0 -0.213" mesh="foot" class="visual" material="black" />
<geom name="FR_foot_collision" pos="0 0 -0.213" type="sphere" size="0.022" class="collision"/>
<site name="FR" pos="0 0 -0.213" type="sphere" size="0.022"/>
</body>
</body>
</body>
<body name="RL_hip" pos="-0.1934 0.0465 0">
<inertial pos="0.0054 0.00194 -0.000105" quat="0.505462 0.498237 0.497014 0.499245" mass="0.678" diaginertia="0.00088403 0.000596003 0.000479967" />
<joint name="RL_hip_joint" axis="1 0 0" range="-1.0472 1.0472"/>
<geom mesh="hip_0" material="metal" class="visual" quat="4.63268e-05 0 1 0" />
<geom mesh="hip_1" material="gray" class="visual" quat="4.63268e-05 0 1 0" />
<geom name="RL_hip_collision" size="0.046 0.02" pos="0 0.08 0" quat="0.707107 0.707107 0 0" type="cylinder" class="collision" />
<body name="RL_thigh" pos="0 0.0955 0">
<inertial pos="-0.00374 -0.0223 -0.0327" quat="0.829533 0.0847635 -0.0200632 0.551623" mass="1.152" diaginertia="0.00594973 0.00584149 0.000878787" />
<joint name="RL_thigh_joint" axis="0 1 0" range="-0.5236 4.5379"/>
<geom mesh="thigh_0" material="metal" class="visual" />
<geom mesh="thigh_1" material="gray" class="visual" />
<geom name="RL_thigh_collision" size="0.1065 0.01225 0.017" pos="0 0 -0.1065" quat="0.707107 0 0.707107 0" type="box" class="collision" />
<body name="RL_calf" pos="0 0 -0.213">
<inertial pos="0.00629595 -0.000622121 -0.141417" quat="0.710672 0.00154099 -0.00450087 0.703508" mass="0.241352" diaginertia="0.0014901 0.00146356 5.31397e-05" />
<joint name="RL_calf_joint" axis="0 1 0" range="-2.7227 -0.83776"/>
<geom mesh="calf_0" material="gray" class="visual" />
<geom mesh="calf_1" material="black" class="visual" />
<geom name="RL_calf1_collision" size="0.013 0.06" pos="0.01 0 -0.06" quat="0.995004 0 -0.0998334 0" type="cylinder" class="collision" />
<geom name="RL_calf2_collision" size="0.011 0.0325" pos="0.02 0 -0.148" quat="0.999688 0 0.0249974 0" type="cylinder" class="collision" />
<geom pos="0 0 -0.213" mesh="foot" class="visual" material="black" />
<geom name="RL_foot_collision" pos="0 0 -0.213" type="sphere" size="0.022" class="collision"/>
<site name="RL" pos="0 0 -0.213" type="sphere" size="0.022"/>
</body>
</body>
</body>
<body name="RR_hip" pos="-0.1934 -0.0465 0">
<inertial pos="0.0054 -0.00194 -0.000105" quat="0.499245 0.497014 0.498237 0.505462" mass="0.678" diaginertia="0.00088403 0.000596003 0.000479967" />
<joint name="RR_hip_joint" axis="1 0 0" range="-1.0472 1.0472"/>
<geom mesh="hip_0" material="metal" class="visual" quat="2.14617e-09 4.63268e-05 4.63268e-05 -1" />
<geom mesh="hip_1" material="gray" class="visual" quat="2.14617e-09 4.63268e-05 4.63268e-05 -1" />
<geom name="RR_hip_collision" size="0.046 0.02" pos="0 -0.08 0" quat="0.707107 0.707107 0 0" type="cylinder" class="collision" />
<body name="RR_thigh" pos="0 -0.0955 0">
<inertial pos="-0.00374 0.0223 -0.0327" quat="0.551623 -0.0200632 0.0847635 0.829533" mass="1.152" diaginertia="0.00594973 0.00584149 0.000878787" />
<joint name="RR_thigh_joint" axis="0 1 0" range="-0.5236 4.5379"/>
<geom mesh="thigh_mirror_0" material="metal" class="visual" />
<geom mesh="thigh_mirror_1" material="gray" class="visual" />
<geom name="RR_thigh_collision" size="0.1065 0.01225 0.017" pos="0 0 -0.1065" quat="0.707107 0 0.707107 0" type="box" class="collision" />
<body name="RR_calf" pos="0 0 -0.213">
<inertial pos="0.00629595 0.000622121 -0.141417" quat="0.703508 -0.00450087 0.00154099 0.710672" mass="0.241352" diaginertia="0.0014901 0.00146356 5.31397e-05" />
<joint name="RR_calf_joint" axis="0 1 0" range="-2.7227 -0.83776"/>
<geom mesh="calf_mirror_0" material="gray" class="visual" />
<geom mesh="calf_mirror_1" material="black" class="visual" />
<geom name="RR_calf1_collision" size="0.013 0.06" pos="0.01 0 -0.06" quat="0.995004 0 -0.0998334 0" type="cylinder" class="collision" />
<geom name="RR_calf2_collision" size="0.011 0.0325" pos="0.02 0 -0.148" quat="0.999688 0 0.0249974 0" type="cylinder" class="collision" />
<geom pos="0 0 -0.213" mesh="foot" class="visual" material="black" />
<geom name="RR_foot_collision" pos="0 0 -0.213" type="sphere" size="0.022" class="collision"/>
<site name="RR" pos="0 0 -0.213" type="sphere" size="0.022"/>
</body>
</body>
</body>
</body>
</worldbody>
<sensor>
<gyro name="imu_ang_vel" site="imu"/>
<velocimeter name="imu_lin_vel" site="imu"/>
<accelerometer name="imu_lin_acc" site="imu"/>
<subtreeangmom name="root_angmom" body="base_link"/>
</sensor>
</mujoco>
@@ -0,0 +1,306 @@
<mujoco model="scene_go2">
<compiler angle="radian" meshdir="assets" autolimits="true" />
<option cone="elliptic" impratio="100" />
<default>
<default class="go2">
<geom friction="0.4" margin="0.001" condim="1"/>
<joint axis="0 1 0" damping="0.1" armature="0.01" frictionloss="0.2"/>
<motor ctrlrange="-23.7 23.7"/>
<default class="abduction">
<joint axis="1 0 0" range="-1.0472 1.0472"/>
</default>
<default class="hip">
<default class="front_hip">
<joint range="-1.5708 3.4907"/>
</default>
<default class="back_hip">
<joint range="-0.5236 4.5379"/>
</default>
</default>
<default class="knee">
<joint range="-2.7227 -0.83776"/>
<motor ctrlrange="-45.43 45.43"/>
</default>
<default class="visual">
<geom type="mesh" contype="0" conaffinity="0" group="2"/>
</default>
<default class="collision">
<geom group="3"/>
<default class="foot">
<geom size="0.022" pos="-0.002 0 -0.213" priority="1" condim="6"
friction="0.4 0.02 0.01"/>
</default>
</default>
</default>
</default>
<asset>
<material name="metal" rgba=".9 .95 .95 1" />
<material name="black" rgba="0 0 0 1" />
<material name="white" rgba="1 1 1 1" />
<material name="gray" rgba="0.671705 0.692426 0.774270 1" />
<mesh file="base_0.obj" />
<mesh file="base_1.obj" />
<mesh file="base_2.obj" />
<mesh file="base_3.obj" />
<mesh file="base_4.obj" />
<mesh file="hip_0.obj" />
<mesh file="hip_1.obj" />
<mesh file="thigh_0.obj" />
<mesh file="thigh_1.obj" />
<mesh file="thigh_mirror_0.obj" />
<mesh file="thigh_mirror_1.obj" />
<mesh file="calf_0.obj" />
<mesh file="calf_1.obj" />
<mesh file="calf_mirror_0.obj" />
<mesh file="calf_mirror_1.obj" />
<mesh file="foot.obj" />
</asset>
<worldbody>
<body name="base_link" pos="0 0 0.445" childclass="go2">
<inertial pos="0.021112 0 -0.005366" quat="-0.000543471 0.713435 -0.00173769 0.700719"
mass="6.921"
diaginertia="0.107027 0.0980771 0.0244531" />
<freejoint />
<geom mesh="base_0" material="black" class="visual" />
<geom mesh="base_1" material="black" class="visual" />
<geom mesh="base_2" material="black" class="visual" />
<geom mesh="base_3" material="white" class="visual" />
<geom mesh="base_4" material="gray" class="visual" />
<geom size="0.1881 0.04675 0.057" type="box" class="collision" />
<geom size="0.05 0.045" pos="0.285 0 0.01" type="cylinder" class="collision" />
<geom size="0.047" pos="0.293 0 -0.06" class="collision" />
<site name="imu" pos="-0.02557 0 0.04232" />
<body name="FL_hip" pos="0.1934 0.0465 0">
<inertial pos="-0.0054 0.00194 -0.000105" quat="0.497014 0.499245 0.505462 0.498237"
mass="0.678"
diaginertia="0.00088403 0.000596003 0.000479967" />
<joint name="FL_hip_joint" class="abduction" />
<geom mesh="hip_0" material="metal" class="visual" />
<geom mesh="hip_1" material="gray" class="visual" />
<geom size="0.046 0.02" pos="0 0.08 0" quat="1 1 0 0" type="cylinder" class="collision" />
<body name="FL_thigh" pos="0 0.0955 0">
<inertial pos="-0.00374 -0.0223 -0.0327" quat="0.829533 0.0847635 -0.0200632 0.551623"
mass="1.152"
diaginertia="0.00594973 0.00584149 0.000878787" />
<joint name="FL_thigh_joint" class="front_hip" />
<geom mesh="thigh_0" material="metal" class="visual" />
<geom mesh="thigh_1" material="gray" class="visual" />
<geom size="0.1065 0.01225 0.017" pos="0 0 -0.1065" quat="0.707107 0 0.707107 0"
type="box" class="collision" />
<body name="FL_calf" pos="0 0 -0.213">
<inertial pos="0.00629595 -0.000622121 -0.141417"
quat="0.710672 0.00154099 -0.00450087 0.703508"
mass="0.241352" diaginertia="0.0014901 0.00146356 5.31397e-05" />
<joint name="FL_calf_joint" class="knee" />
<geom mesh="calf_0" material="gray" class="visual" />
<geom mesh="calf_1" material="black" class="visual" />
<geom size="0.012 0.06" pos="0.008 0 -0.06" quat="0.994493 0 -0.104807 0"
type="cylinder" class="collision" />
<geom size="0.011 0.0325" pos="0.02 0 -0.148" quat="0.999688 0 0.0249974 0"
type="cylinder" class="collision" />
<geom pos="0 0 -0.213" mesh="foot" class="visual" material="black" />
<geom name="FL" class="foot" />
<body name="FL_foot" pos="0 0 -0.213" />
</body>
</body>
</body>
<body name="FR_hip" pos="0.1934 -0.0465 0">
<inertial pos="-0.0054 -0.00194 -0.000105" quat="0.498237 0.505462 0.499245 0.497014"
mass="0.678"
diaginertia="0.00088403 0.000596003 0.000479967" />
<joint name="FR_hip_joint" class="abduction" />
<geom mesh="hip_0" material="metal" class="visual" quat="4.63268e-05 1 0 0" />
<geom mesh="hip_1" material="gray" class="visual" quat="4.63268e-05 1 0 0" />
<geom size="0.046 0.02" pos="0 -0.08 0" quat="0.707107 0.707107 0 0" type="cylinder"
class="collision" />
<body name="FR_thigh" pos="0 -0.0955 0">
<inertial pos="-0.00374 0.0223 -0.0327" quat="0.551623 -0.0200632 0.0847635 0.829533"
mass="1.152"
diaginertia="0.00594973 0.00584149 0.000878787" />
<joint name="FR_thigh_joint" class="front_hip" />
<geom mesh="thigh_mirror_0" material="metal" class="visual" />
<geom mesh="thigh_mirror_1" material="gray" class="visual" />
<geom size="0.1065 0.01225 0.017" pos="0 0 -0.1065" quat="0.707107 0 0.707107 0"
type="box" class="collision" />
<body name="FR_calf" pos="0 0 -0.213">
<inertial pos="0.00629595 0.000622121 -0.141417"
quat="0.703508 -0.00450087 0.00154099 0.710672"
mass="0.241352" diaginertia="0.0014901 0.00146356 5.31397e-05" />
<joint name="FR_calf_joint" class="knee" />
<geom mesh="calf_mirror_0" material="gray" class="visual" />
<geom mesh="calf_mirror_1" material="black" class="visual" />
<geom size="0.013 0.06" pos="0.01 0 -0.06" quat="0.995004 0 -0.0998334 0"
type="cylinder" class="collision" />
<geom size="0.011 0.0325" pos="0.02 0 -0.148" quat="0.999688 0 0.0249974 0"
type="cylinder" class="collision" />
<geom pos="0 0 -0.213" mesh="foot" class="visual" material="black" />
<geom name="FR" class="foot" />
<body name="FR_foot" pos="0 0 -0.213" />
</body>
</body>
</body>
<body name="RL_hip" pos="-0.1934 0.0465 0">
<inertial pos="0.0054 0.00194 -0.000105" quat="0.505462 0.498237 0.497014 0.499245"
mass="0.678"
diaginertia="0.00088403 0.000596003 0.000479967" />
<joint name="RL_hip_joint" class="abduction" />
<geom mesh="hip_0" material="metal" class="visual" quat="4.63268e-05 0 1 0" />
<geom mesh="hip_1" material="gray" class="visual" quat="4.63268e-05 0 1 0" />
<geom size="0.046 0.02" pos="0 0.08 0" quat="0.707107 0.707107 0 0" type="cylinder"
class="collision" />
<body name="RL_thigh" pos="0 0.0955 0">
<inertial pos="-0.00374 -0.0223 -0.0327" quat="0.829533 0.0847635 -0.0200632 0.551623"
mass="1.152"
diaginertia="0.00594973 0.00584149 0.000878787" />
<joint name="RL_thigh_joint" class="back_hip" />
<geom mesh="thigh_0" material="metal" class="visual" />
<geom mesh="thigh_1" material="gray" class="visual" />
<geom size="0.1065 0.01225 0.017" pos="0 0 -0.1065" quat="0.707107 0 0.707107 0"
type="box" class="collision" />
<body name="RL_calf" pos="0 0 -0.213">
<inertial pos="0.00629595 -0.000622121 -0.141417"
quat="0.710672 0.00154099 -0.00450087 0.703508"
mass="0.241352" diaginertia="0.0014901 0.00146356 5.31397e-05" />
<joint name="RL_calf_joint" class="knee" />
<geom mesh="calf_0" material="gray" class="visual" />
<geom mesh="calf_1" material="black" class="visual" />
<geom size="0.013 0.06" pos="0.01 0 -0.06" quat="0.995004 0 -0.0998334 0"
type="cylinder" class="collision" />
<geom size="0.011 0.0325" pos="0.02 0 -0.148" quat="0.999688 0 0.0249974 0"
type="cylinder" class="collision" />
<geom pos="0 0 -0.213" mesh="foot" class="visual" material="black" />
<geom name="RL" class="foot" />
<body name="RL_foot" pos="0 0 -0.213" />
</body>
</body>
</body>
<body name="RR_hip" pos="-0.1934 -0.0465 0">
<inertial pos="0.0054 -0.00194 -0.000105" quat="0.499245 0.497014 0.498237 0.505462"
mass="0.678"
diaginertia="0.00088403 0.000596003 0.000479967" />
<joint name="RR_hip_joint" class="abduction" />
<geom mesh="hip_0" material="metal" class="visual"
quat="2.14617e-09 4.63268e-05 4.63268e-05 -1" />
<geom mesh="hip_1" material="gray" class="visual"
quat="2.14617e-09 4.63268e-05 4.63268e-05 -1" />
<geom size="0.046 0.02" pos="0 -0.08 0" quat="0.707107 0.707107 0 0" type="cylinder"
class="collision" />
<body name="RR_thigh" pos="0 -0.0955 0">
<inertial pos="-0.00374 0.0223 -0.0327" quat="0.551623 -0.0200632 0.0847635 0.829533"
mass="1.152"
diaginertia="0.00594973 0.00584149 0.000878787" />
<joint name="RR_thigh_joint" class="back_hip" />
<geom mesh="thigh_mirror_0" material="metal" class="visual" />
<geom mesh="thigh_mirror_1" material="gray" class="visual" />
<geom size="0.1065 0.01225 0.017" pos="0 0 -0.1065" quat="0.707107 0 0.707107 0"
type="box" class="collision" />
<body name="RR_calf" pos="0 0 -0.213">
<inertial pos="0.00629595 0.000622121 -0.141417"
quat="0.703508 -0.00450087 0.00154099 0.710672"
mass="0.241352" diaginertia="0.0014901 0.00146356 5.31397e-05" />
<joint name="RR_calf_joint" class="knee" />
<geom mesh="calf_mirror_0" material="gray" class="visual" />
<geom mesh="calf_mirror_1" material="black" class="visual" />
<geom size="0.013 0.06" pos="0.01 0 -0.06" quat="0.995004 0 -0.0998334 0"
type="cylinder" class="collision" />
<geom size="0.011 0.0325" pos="0.02 0 -0.148" quat="0.999688 0 0.0249974 0"
type="cylinder" class="collision" />
<geom pos="0 0 -0.213" mesh="foot" class="visual" material="black" />
<geom name="RR" class="foot" />
<body name="RR_foot" pos="0 0 -0.213" />
</body>
</body>
</body>
</body>
</worldbody>
<actuator>
<motor class="abduction" name="FR_hip" joint="FR_hip_joint" />
<motor class="hip" name="FR_thigh" joint="FR_thigh_joint" />
<motor class="knee" name="FR_calf" joint="FR_calf_joint" />
<motor class="abduction" name="FL_hip" joint="FL_hip_joint" />
<motor class="hip" name="FL_thigh" joint="FL_thigh_joint" />
<motor class="knee" name="FL_calf" joint="FL_calf_joint" />
<motor class="abduction" name="RR_hip" joint="RR_hip_joint" />
<motor class="hip" name="RR_thigh" joint="RR_thigh_joint" />
<motor class="knee" name="RR_calf" joint="RR_calf_joint" />
<motor class="abduction" name="RL_hip" joint="RL_hip_joint" />
<motor class="hip" name="RL_thigh" joint="RL_thigh_joint" />
<motor class="knee" name="RL_calf" joint="RL_calf_joint" />
</actuator>
<sensor>
<jointpos name="FR_hip_pos" joint="FR_hip_joint" />
<jointpos name="FR_thigh_pos" joint="FR_thigh_joint" />
<jointpos name="FR_calf_pos" joint="FR_calf_joint" />
<jointpos name="FL_hip_pos" joint="FL_hip_joint" />
<jointpos name="FL_thigh_pos" joint="FL_thigh_joint" />
<jointpos name="FL_calf_pos" joint="FL_calf_joint" />
<jointpos name="RR_hip_pos" joint="RR_hip_joint" />
<jointpos name="RR_thigh_pos" joint="RR_thigh_joint" />
<jointpos name="RR_calf_pos" joint="RR_calf_joint" />
<jointpos name="RL_hip_pos" joint="RL_hip_joint" />
<jointpos name="RL_thigh_pos" joint="RL_thigh_joint" />
<jointpos name="RL_calf_pos" joint="RL_calf_joint" />
<jointvel name="FR_hip_vel" joint="FR_hip_joint" />
<jointvel name="FR_thigh_vel" joint="FR_thigh_joint" />
<jointvel name="FR_calf_vel" joint="FR_calf_joint" />
<jointvel name="FL_hip_vel" joint="FL_hip_joint" />
<jointvel name="FL_thigh_vel" joint="FL_thigh_joint" />
<jointvel name="FL_calf_vel" joint="FL_calf_joint" />
<jointvel name="RR_hip_vel" joint="RR_hip_joint" />
<jointvel name="RR_thigh_vel" joint="RR_thigh_joint" />
<jointvel name="RR_calf_vel" joint="RR_calf_joint" />
<jointvel name="RL_hip_vel" joint="RL_hip_joint" />
<jointvel name="RL_thigh_vel" joint="RL_thigh_joint" />
<jointvel name="RL_calf_vel" joint="RL_calf_joint" />
<jointactuatorfrc name="FR_hip_torque" joint="FR_hip_joint" noise="0.01" />
<jointactuatorfrc name="FR_thigh_torque" joint="FR_thigh_joint" noise="0.01" />
<jointactuatorfrc name="FR_calf_torque" joint="FR_calf_joint" noise="0.01" />
<jointactuatorfrc name="FL_hip_torque" joint="FL_hip_joint" noise="0.01" />
<jointactuatorfrc name="FL_thigh_torque" joint="FL_thigh_joint" noise="0.01" />
<jointactuatorfrc name="FL_calf_torque" joint="FL_calf_joint" noise="0.01" />
<jointactuatorfrc name="RR_hip_torque" joint="RR_hip_joint" noise="0.01" />
<jointactuatorfrc name="RR_thigh_torque" joint="RR_thigh_joint" noise="0.01" />
<jointactuatorfrc name="RR_calf_torque" joint="RR_calf_joint" noise="0.01" />
<jointactuatorfrc name="RL_hip_torque" joint="RL_hip_joint" noise="0.01" />
<jointactuatorfrc name="RL_thigh_torque" joint="RL_thigh_joint" noise="0.01" />
<jointactuatorfrc name="RL_calf_torque" joint="RL_calf_joint" noise="0.01" />
<framequat name="imu_quat" objtype="site" objname="imu" />
<gyro name="imu_gyro" site="imu" />
<accelerometer name="imu_acc" site="imu" />
<framepos name="frame_pos" objtype="site" objname="imu" />
<framelinvel name="frame_vel" objtype="site" objname="imu" />
</sensor>
<keyframe>
<key name="home" qpos="0 0 0.27 1 0 0 0 0 0.9 -1.8 0 0.9 -1.8 0 0.9 -1.8 0 0.9 -1.8"
ctrl="0 0.9 -1.8 0 0.9 -1.8 0 0.9 -1.8 0 0.9 -1.8" />
</keyframe>
<!-- setup scene -->
<statistic center="1.0 1.0 1.0" extent="0.8"/>
<visual>
<headlight diffuse="0.6 0.6 0.6" ambient="0.1 0.1 0.1" specular="0.9 0.9 0.9"/>
<rgba haze="0.15 0.25 0.35 1"/>
<global azimuth="-140" elevation="-20"/>
</visual>
<asset>
<texture type="skybox" builtin="flat" rgb1="0 0 0" rgb2="0 0 0" width="512" height="3072"/>
<texture type="2d" name="groundplane" builtin="checker" mark="edge" rgb1="0.2 0.3 0.4" rgb2="0.1 0.2 0.3" markrgb="0.8 0.8 0.8" width="300" height="300"/>
<material name="groundplane" texture="groundplane" texuniform="true" texrepeat="5 5" reflectance="0.2"/>
</asset>
<worldbody>
<light pos="1 0 3.5" dir="0 0 -1" directional="true"/>
<geom name="floor" size="0 0 0.05" type="plane" material="groundplane"/>
</worldbody>
</mujoco>
+5
View File
@@ -0,0 +1,5 @@
from mjlab.utils.lab_api.tasks.importer import import_packages
_BLACKLIST_PKGS = ["utils", ".mdp"]
import_packages(__name__, _BLACKLIST_PKGS)
@@ -0,0 +1 @@
"""Velocity tracking environments for legged robots."""
@@ -0,0 +1,24 @@
from mjlab.tasks.registry import register_mjlab_task
from src.tasks.velocity.rl import VelocityOnPolicyRunner
from .env_cfgs import (
unitree_go2_flat_env_cfg,
unitree_go2_rough_env_cfg,
)
from .rl_cfg import unitree_go2_ppo_runner_cfg
register_mjlab_task(
task_id="Unitree-Go2-Rough",
env_cfg=unitree_go2_rough_env_cfg(),
play_env_cfg=unitree_go2_rough_env_cfg(play=True),
rl_cfg=unitree_go2_ppo_runner_cfg(),
runner_cls=VelocityOnPolicyRunner,
)
register_mjlab_task(
task_id="Unitree-Go2-Flat",
env_cfg=unitree_go2_flat_env_cfg(),
play_env_cfg=unitree_go2_flat_env_cfg(play=True),
rl_cfg=unitree_go2_ppo_runner_cfg(),
runner_cls=VelocityOnPolicyRunner,
)
@@ -0,0 +1,171 @@
"""Unitree Go2 velocity environment configurations."""
from typing import Literal
from src.assets.robots import (
get_go2_robot_cfg,
)
from mjlab.envs import ManagerBasedRlEnvCfg
from mjlab.envs import mdp as envs_mdp
from mjlab.envs.mdp.actions import JointPositionActionCfg
from mjlab.managers import TerminationTermCfg
from mjlab.managers.event_manager import EventTermCfg
from mjlab.sensor import ContactMatch, ContactSensorCfg, RayCastSensorCfg
from mjlab.tasks.velocity import mdp
from mjlab.tasks.velocity.mdp import UniformVelocityCommandCfg
from src.tasks.velocity.velocity_env_cfg import make_velocity_env_cfg
TerrainType = Literal["rough", "obstacles"]
def unitree_go2_rough_env_cfg(
play: bool = False,
) -> ManagerBasedRlEnvCfg:
"""Create Unitree Go2 rough terrain velocity configuration."""
cfg = make_velocity_env_cfg()
cfg.sim.mujoco.ccd_iterations = 500
cfg.sim.contact_sensor_maxmatch = 500
cfg.scene.entities = {"robot": get_go2_robot_cfg()}
# Set raycast sensor frame to Go2 base_link.
for sensor in cfg.scene.sensors or ():
if sensor.name == "terrain_scan":
assert isinstance(sensor, RayCastSensorCfg)
sensor.frame.name = "base_link"
foot_names = ("FR", "FL", "RR", "RL")
site_names = ("FR", "FL", "RR", "RL")
geom_names = tuple(f"{name}_foot_collision" for name in foot_names)
feet_ground_cfg = ContactSensorCfg(
name="feet_ground_contact",
primary=ContactMatch(mode="geom", pattern=geom_names, entity="robot"),
secondary=ContactMatch(mode="body", pattern="terrain"),
fields=("found", "force"),
reduce="netforce",
num_slots=1,
track_air_time=True,
)
nonfoot_ground_cfg = ContactSensorCfg(
name="nonfoot_ground_touch",
primary=ContactMatch(
mode="geom",
entity="robot",
# Grab all collision geoms...
pattern=r".*_collision\d*$",
# Except for the foot geoms.
exclude=tuple(geom_names),
),
secondary=ContactMatch(mode="body", pattern="terrain"),
fields=("found", "force"),
reduce="none",
num_slots=1,
history_length=4,
)
cfg.scene.sensors = (cfg.scene.sensors or ()) + (
feet_ground_cfg,
nonfoot_ground_cfg,
)
if cfg.scene.terrain is not None and cfg.scene.terrain.terrain_generator is not None:
cfg.scene.terrain.terrain_generator.curriculum = True
joint_pos_action = cfg.actions["joint_pos"]
assert isinstance(joint_pos_action, JointPositionActionCfg)
cfg.viewer.body_name = "base_link"
cfg.viewer.distance = 1.5
cfg.viewer.elevation = -10.0
cfg.observations["critic"].terms["foot_height"].params["asset_cfg"].site_names = site_names
cfg.events["foot_friction"].params["asset_cfg"].geom_names = geom_names
cfg.events["base_com"].params["asset_cfg"].body_names = ("base_link",)
cfg.rewards["pose"].params["std_standing"] = {
r".*(FR|FL|RR|RL)_hip_joint.*": 0.05,
r".*(FR|FL|RR|RL)_thigh_joint.*": 0.1,
r".*(FR|FL|RR|RL)_calf_joint.*": 0.15,
}
cfg.rewards["pose"].params["std_walking"] = {
r".*(FR|FL|RR|RL)_hip_joint.*": 0.15,
r".*(FR|FL|RR|RL)_thigh_joint.*": 0.35,
r".*(FR|FL|RR|RL)_calf_joint.*": 0.5,
}
cfg.rewards["pose"].params["std_running"] = {
r".*(FR|FL|RR|RL)_hip_joint.*": 0.15,
r".*(FR|FL|RR|RL)_thigh_joint.*": 0.35,
r".*(FR|FL|RR|RL)_calf_joint.*": 0.5,
}
cfg.rewards["foot_gait"].params["offset"] = [0.0, 0.5, 0.5, 0.0]
cfg.rewards["body_orientation_l2"].params["asset_cfg"].body_names = ("base_link",)
cfg.rewards["body_ang_vel"].params["asset_cfg"].body_names = ("base_link",)
cfg.rewards["foot_clearance"].params["asset_cfg"].site_names = site_names
cfg.rewards["foot_slip"].params["asset_cfg"].site_names = site_names
cfg.metrics["slip_velocity"].params["asset_cfg"].site_names = site_names
cfg.terminations["illegal_contact"] = TerminationTermCfg(
func=mdp.illegal_contact,
params={"sensor_name": nonfoot_ground_cfg.name, "force_threshold": 10.0},
)
# Apply play mode overrides.
if play:
# Effectively infinite episode length.
cfg.episode_length_s = int(1e9)
cfg.observations["actor"].enable_corruption = False
cfg.events.pop("push_robot", None)
cfg.curriculum = {}
cfg.events["randomize_terrain"] = EventTermCfg(
func=envs_mdp.randomize_terrain,
mode="reset",
params={},
)
if cfg.scene.terrain is not None:
if cfg.scene.terrain.terrain_generator is not None:
cfg.scene.terrain.terrain_generator.curriculum = False
cfg.scene.terrain.terrain_generator.num_cols = 5
cfg.scene.terrain.terrain_generator.num_rows = 5
cfg.scene.terrain.terrain_generator.border_width = 10.0
return cfg
def unitree_go2_flat_env_cfg(play: bool = False) -> ManagerBasedRlEnvCfg:
"""Create Unitree Go2 flat terrain velocity configuration."""
cfg = unitree_go2_rough_env_cfg(play=play)
cfg.sim.njmax = 300
cfg.sim.mujoco.ccd_iterations = 50
cfg.sim.contact_sensor_maxmatch = 64
cfg.sim.nconmax = None
# Switch to flat terrain.
assert cfg.scene.terrain is not None
cfg.scene.terrain.terrain_type = "plane"
cfg.scene.terrain.terrain_generator = None
# Remove raycast sensor and height scan (no terrain to scan).
cfg.scene.sensors = tuple(
s for s in (cfg.scene.sensors or ()) if s.name != "terrain_scan"
)
del cfg.observations["actor"].terms["height_scan"]
del cfg.observations["critic"].terms["height_scan"]
# Disable terrain curriculum (not present in play mode since rough clears all).
cfg.curriculum.pop("terrain_levels", None)
if play:
twist_cmd = cfg.commands["twist"]
assert isinstance(twist_cmd, UniformVelocityCommandCfg)
twist_cmd.ranges.lin_vel_x = (-0.5, 1.0)
twist_cmd.ranges.lin_vel_y = (-0.5, 0.5)
twist_cmd.ranges.ang_vel_z = (-0.5, 0.5)
return cfg
@@ -0,0 +1,46 @@
"""RL configuration for Unitree Go2 velocity task."""
from mjlab.rl import (
RslRlModelCfg,
RslRlOnPolicyRunnerCfg,
RslRlPpoAlgorithmCfg,
)
def unitree_go2_ppo_runner_cfg() -> RslRlOnPolicyRunnerCfg:
"""Create RL runner configuration for Unitree Go2 velocity task."""
return RslRlOnPolicyRunnerCfg(
actor=RslRlModelCfg(
hidden_dims=(512, 256, 128),
activation="elu",
obs_normalization=True,
distribution_cfg={
"class_name": "GaussianDistribution",
"init_std": 1.0,
"std_type": "scalar",
},
),
critic=RslRlModelCfg(
hidden_dims=(512, 256, 128),
activation="elu",
obs_normalization=True,
),
algorithm=RslRlPpoAlgorithmCfg(
value_loss_coef=1.0,
use_clipped_value_loss=True,
clip_param=0.2,
entropy_coef=0.01,
num_learning_epochs=5,
num_mini_batches=4,
learning_rate=1.0e-3,
schedule="adaptive",
gamma=0.99,
lam=0.95,
desired_kl=0.01,
max_grad_norm=1.0,
),
experiment_name="go2_velocity",
save_interval=100,
num_steps_per_env=24,
max_iterations=10001,
)
@@ -0,0 +1,8 @@
from mjlab.envs.mdp import * # noqa: F401, F403
from .curriculums import * # noqa: F403
from .metrics import * # noqa: F403
from .observations import * # noqa: F403
from .rewards import * # noqa: F403
from .terminations import * # noqa: F403
from .velocity_command import * # noqa: F403
@@ -0,0 +1,107 @@
from __future__ import annotations
from typing import TYPE_CHECKING, TypedDict, cast
import torch
from mjlab.entity import Entity
from mjlab.managers.scene_entity_config import SceneEntityCfg
from .velocity_command import UniformVelocityCommandCfg
if TYPE_CHECKING:
from mjlab.envs import ManagerBasedRlEnv
_DEFAULT_SCENE_CFG = SceneEntityCfg("robot")
class VelocityStage(TypedDict):
step: int
lin_vel_x: tuple[float, float] | None
lin_vel_y: tuple[float, float] | None
ang_vel_z: tuple[float, float] | None
class RewardWeightStage(TypedDict):
step: int
weight: float
def terrain_levels_vel(
env: ManagerBasedRlEnv,
env_ids: torch.Tensor,
command_name: str,
asset_cfg: SceneEntityCfg = _DEFAULT_SCENE_CFG,
) -> torch.Tensor:
asset: Entity = env.scene[asset_cfg.name]
terrain = env.scene.terrain
assert terrain is not None
terrain_generator = terrain.cfg.terrain_generator
assert terrain_generator is not None
command = env.command_manager.get_command(command_name)
assert command is not None
# Compute the distance the robot walked.
distance = torch.norm(
asset.data.root_link_pos_w[env_ids, :2] - env.scene.env_origins[env_ids, :2], dim=1
)
# Robots that walked far enough progress to harder terrains.
move_up = distance > terrain_generator.size[0] / 2
# Robots that walked less than half of their required distance go to simpler
# terrains.
move_down = (
distance < torch.norm(command[env_ids, :2], dim=1) * env.max_episode_length_s * 0.5
)
move_down *= ~move_up
# Update terrain levels.
terrain.update_env_origins(env_ids, move_up, move_down)
return torch.mean(terrain.terrain_levels.float())
def commands_vel(
env: ManagerBasedRlEnv,
env_ids: torch.Tensor,
command_name: str,
velocity_stages: list[VelocityStage],
) -> dict[str, torch.Tensor]:
del env_ids # Unused.
command_term = env.command_manager.get_term(command_name)
assert command_term is not None
cfg = cast(UniformVelocityCommandCfg, command_term.cfg)
for stage in velocity_stages:
if env.common_step_counter > stage["step"]:
if "lin_vel_x" in stage and stage["lin_vel_x"] is not None:
cfg.ranges.lin_vel_x = stage["lin_vel_x"]
if "lin_vel_y" in stage and stage["lin_vel_y"] is not None:
cfg.ranges.lin_vel_y = stage["lin_vel_y"]
if "ang_vel_z" in stage and stage["ang_vel_z"] is not None:
cfg.ranges.ang_vel_z = stage["ang_vel_z"]
return {
# "lin_vel_x_min": torch.tensor(cfg.ranges.lin_vel_x[0]),
# "lin_vel_x_max": torch.tensor(cfg.ranges.lin_vel_x[1]),
# "lin_vel_y_min": torch.tensor(cfg.ranges.lin_vel_y[0]),
# "lin_vel_y_max": torch.tensor(cfg.ranges.lin_vel_y[1]),
# "ang_vel_z_min": torch.tensor(cfg.ranges.ang_vel_z[0]),
# "ang_vel_z_max": torch.tensor(cfg.ranges.ang_vel_z[1]),
}
def reward_weight(
env: ManagerBasedRlEnv,
env_ids: torch.Tensor,
reward_name: str,
weight_stages: list[RewardWeightStage],
) -> torch.Tensor:
"""Update a reward term's weight based on training step stages."""
del env_ids # Unused.
reward_term_cfg = env.reward_manager.get_term_cfg(reward_name)
for stage in weight_stages:
if env.common_step_counter > stage["step"]:
reward_term_cfg.weight = stage["weight"]
return torch.tensor([reward_term_cfg.weight])
@@ -0,0 +1,88 @@
"""Reward-weight-independent quality metrics for velocity tasks."""
from __future__ import annotations
from typing import TYPE_CHECKING
import torch
from mjlab.entity import Entity
from mjlab.managers.scene_entity_config import SceneEntityCfg
from mjlab.sensor import ContactSensor
if TYPE_CHECKING:
from mjlab.envs import ManagerBasedRlEnv
_DEFAULT_ASSET_CFG = SceneEntityCfg("robot")
def linear_velocity_rmse(
env: ManagerBasedRlEnv,
command_name: str,
asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG,
) -> torch.Tensor:
"""Per-step commanded-vs-actual base linear velocity RMSE in body frame."""
asset: Entity = env.scene[asset_cfg.name]
command = env.command_manager.get_command(command_name)
assert command is not None
actual = asset.data.root_link_lin_vel_b
error = torch.cat((command[:, :2] - actual[:, :2], -actual[:, 2:3]), dim=1)
return torch.sqrt(torch.mean(torch.square(error), dim=1))
def angular_velocity_rmse(
env: ManagerBasedRlEnv,
command_name: str,
asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG,
) -> torch.Tensor:
"""Per-step commanded-vs-actual base angular velocity RMSE in body frame."""
asset: Entity = env.scene[asset_cfg.name]
command = env.command_manager.get_command(command_name)
assert command is not None
actual = asset.data.root_link_ang_vel_b
desired = torch.zeros_like(actual)
desired[:, 2] = command[:, 2]
return torch.sqrt(torch.mean(torch.square(desired - actual), dim=1))
def orientation_error(
env: ManagerBasedRlEnv,
asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG,
) -> torch.Tensor:
"""Magnitude of projected gravity in the base x/y plane; zero is upright."""
asset: Entity = env.scene[asset_cfg.name]
return torch.linalg.vector_norm(asset.data.projected_gravity_b[:, :2], dim=1)
def fall_indicator(env: ManagerBasedRlEnv) -> torch.Tensor:
"""One on non-timeout terminal steps, otherwise zero."""
return env.termination_manager.terminated.float()
def slip_velocity(
env: ManagerBasedRlEnv,
sensor_name: str,
asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG,
) -> torch.Tensor:
"""Mean x/y velocity of feet currently touching the ground."""
asset: Entity = env.scene[asset_cfg.name]
sensor: ContactSensor = env.scene[sensor_name]
assert sensor.data.found is not None
contact = (sensor.data.found > 0).float()
speed = torch.linalg.vector_norm(
asset.data.site_lin_vel_w[:, asset_cfg.site_ids, :2], dim=-1
)
count = torch.clamp(torch.sum(contact, dim=1), min=1.0)
return torch.sum(speed * contact, dim=1) / count
def mechanical_power(
env: ManagerBasedRlEnv,
asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG,
) -> torch.Tensor:
"""Positive actuator mechanical power in watts (regeneration is ignored)."""
asset: Entity = env.scene[asset_cfg.name]
torque = asset.data.actuator_force[:, asset_cfg.actuator_ids]
velocity = asset.data.joint_vel[:, asset_cfg.joint_ids]
count = min(torque.shape[1], velocity.shape[1])
return torch.sum(torch.clamp(torque[:, :count] * velocity[:, :count], min=0.0), dim=1)
@@ -0,0 +1,54 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import torch
from mjlab.entity import Entity
from mjlab.managers.scene_entity_config import SceneEntityCfg
from mjlab.sensor import ContactSensor
if TYPE_CHECKING:
from mjlab.envs import ManagerBasedRlEnv
_DEFAULT_ASSET_CFG = SceneEntityCfg("robot")
def foot_height(
env: ManagerBasedRlEnv, asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG
) -> torch.Tensor:
asset: Entity = env.scene[asset_cfg.name]
return asset.data.site_pos_w[:, asset_cfg.site_ids, 2] # (num_envs, num_sites)
def foot_air_time(env: ManagerBasedRlEnv, sensor_name: str) -> torch.Tensor:
sensor: ContactSensor = env.scene[sensor_name]
sensor_data = sensor.data
current_air_time = sensor_data.current_air_time
assert current_air_time is not None
return current_air_time
def foot_contact(env: ManagerBasedRlEnv, sensor_name: str) -> torch.Tensor:
sensor: ContactSensor = env.scene[sensor_name]
sensor_data = sensor.data
assert sensor_data.found is not None
return (sensor_data.found > 0).float()
def foot_contact_forces(env: ManagerBasedRlEnv, sensor_name: str) -> torch.Tensor:
sensor: ContactSensor = env.scene[sensor_name]
sensor_data = sensor.data
assert sensor_data.force is not None
forces_flat = sensor_data.force.flatten(start_dim=1) # [B, N*3]
return torch.sign(forces_flat) * torch.log1p(torch.abs(forces_flat))
def phase(env: ManagerBasedRlEnv, period: float, command_name: str) -> torch.Tensor:
global_phase = (env.episode_length_buf * env.step_dt) % period / period
phase = torch.zeros(env.num_envs, 2, device=env.device)
phase[:, 0] = torch.sin(global_phase * torch.pi * 2.0)
phase[:, 1] = torch.cos(global_phase * torch.pi * 2.0)
stand_mask = torch.linalg.norm(env.command_manager.get_command(command_name), dim=1) < 0.1
phase = torch.where(stand_mask.unsqueeze(1), torch.zeros_like(phase), phase)
return phase
@@ -0,0 +1,427 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import torch
from mjlab.entity import Entity
from mjlab.managers.reward_manager import RewardTermCfg
from mjlab.managers.scene_entity_config import SceneEntityCfg
from mjlab.sensor import BuiltinSensor, ContactSensor
from mjlab.utils.lab_api.math import quat_apply_inverse
from mjlab.utils.lab_api.string import (
resolve_matching_names_values,
)
if TYPE_CHECKING:
from mjlab.envs import ManagerBasedRlEnv
_DEFAULT_ASSET_CFG = SceneEntityCfg("robot")
def track_linear_velocity(
env: ManagerBasedRlEnv,
std: float,
command_name: str,
asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG,
) -> torch.Tensor:
"""Reward for tracking the commanded base linear velocity.
The commanded z velocity is assumed to be zero.
"""
asset: Entity = env.scene[asset_cfg.name]
command = env.command_manager.get_command(command_name)
assert command is not None, f"Command '{command_name}' not found."
actual = asset.data.root_link_lin_vel_b
xy_error = torch.sum(torch.square(command[:, :2] - actual[:, :2]), dim=1)
z_error = torch.square(actual[:, 2])
lin_vel_error = xy_error + (2 * z_error)
return torch.exp(-lin_vel_error / std**2)
def track_angular_velocity(
env: ManagerBasedRlEnv,
std: float,
command_name: str,
asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG,
) -> torch.Tensor:
"""Reward heading error for heading-controlled envs, angular velocity for others.
The commanded xy angular velocities are assumed to be zero.
"""
asset: Entity = env.scene[asset_cfg.name]
command = env.command_manager.get_command(command_name)
assert command is not None, f"Command '{command_name}' not found."
actual = asset.data.root_link_ang_vel_b
z_error = torch.square(command[:, 2] - actual[:, 2])
xy_error = torch.sum(torch.square(actual[:, :2]), dim=1)
ang_vel_error = z_error + (0.05 * xy_error)
return torch.exp(-ang_vel_error / std**2)
def body_orientation_l2(
env: ManagerBasedRlEnv,
asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG,
) -> torch.Tensor:
"""Reward flat base orientation (robot being upright).
If asset_cfg has body_ids specified, computes the projected gravity
for that specific body. Otherwise, uses the root link projected gravity.
"""
asset: Entity = env.scene[asset_cfg.name]
# If body_ids are specified, compute projected gravity for that body.
if asset_cfg.body_ids:
body_quat_w = asset.data.body_link_quat_w[:, asset_cfg.body_ids, :] # [B, N, 4]
body_quat_w = body_quat_w.squeeze(1) # [B, 4]
gravity_w = asset.data.gravity_vec_w # [3]
projected_gravity_b = quat_apply_inverse(body_quat_w, gravity_w) # [B, 3]
xy_squared = torch.sum(torch.square(projected_gravity_b[:, :2]), dim=1)
else:
# Use root link projected gravity.
xy_squared = torch.sum(torch.square(asset.data.projected_gravity_b[:, :2]), dim=1)
return xy_squared
def self_collision_cost(
env: ManagerBasedRlEnv,
sensor_name: str,
force_threshold: float = 10.0,
) -> torch.Tensor:
"""Penalize self-collisions.
When the sensor provides force history (from ``history_length > 0``),
counts substeps where any contact force exceeds *force_threshold*.
Falls back to the instantaneous ``found`` count otherwise.
"""
sensor: ContactSensor = env.scene[sensor_name]
data = sensor.data
if data.force_history is not None:
# force_history: [B, N, H, 3]
force_mag = torch.norm(data.force_history, dim=-1) # [B, N, H]
hit = (force_mag > force_threshold).any(dim=1) # [B, H]
return hit.sum(dim=-1).float() # [B]
assert data.found is not None
return data.found.squeeze(-1)
def body_angular_velocity_penalty(
env: ManagerBasedRlEnv,
asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG,
) -> torch.Tensor:
"""Penalize excessive body angular velocities."""
asset: Entity = env.scene[asset_cfg.name]
ang_vel = asset.data.body_link_ang_vel_w[:, asset_cfg.body_ids, :]
ang_vel = ang_vel.squeeze(1)
ang_vel_xy = ang_vel[:, :2] # Don't penalize z-angular velocity.
return torch.sum(torch.square(ang_vel_xy), dim=1)
def angular_momentum_penalty(
env: ManagerBasedRlEnv,
sensor_name: str,
) -> torch.Tensor:
"""Penalize whole-body angular momentum to encourage natural arm swing."""
angmom_sensor: BuiltinSensor = env.scene[sensor_name]
angmom = angmom_sensor.data
angmom_magnitude_sq = torch.sum(torch.square(angmom), dim=-1)
angmom_magnitude = torch.sqrt(angmom_magnitude_sq)
env.extras["log"]["Metrics/angular_momentum_mean"] = torch.mean(angmom_magnitude)
return angmom_magnitude_sq
def feet_air_time(
env: ManagerBasedRlEnv,
sensor_name: str,
threshold: float = 0.4,
command_name: str | None = None,
command_threshold: float = 0.1,
) -> torch.Tensor:
"""Reward feet air time."""
sensor: ContactSensor = env.scene[sensor_name]
sensor_data = sensor.data
air_time = sensor_data.current_air_time
contact_time = sensor_data.current_contact_time
in_contact = contact_time > 0.0
in_mode_time = torch.where(in_contact, contact_time, air_time)
single_stance = torch.mean(in_contact.float(), dim=1) == 0.5
mode_time = torch.min(torch.where(single_stance.unsqueeze(-1), in_mode_time, 0.0), dim=1)[0]
error = torch.abs(mode_time - threshold)
reward = torch.clamp(threshold - error, min=0.0)
if command_name is not None:
command = env.command_manager.get_command(command_name)
if command is not None:
linear_norm = torch.norm(command[:, :2], dim=1)
angular_norm = torch.abs(command[:, 2])
total_command = linear_norm + angular_norm
scale = (total_command > command_threshold).float()
reward *= scale
return reward
def feet_clearance(
env: ManagerBasedRlEnv,
target_height: float,
command_name: str | None = None,
command_threshold: float = 0.1,
asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG,
) -> torch.Tensor:
"""Penalize deviation from target clearance height, weighted by foot velocity."""
asset: Entity = env.scene[asset_cfg.name]
foot_z = asset.data.site_pos_w[:, asset_cfg.site_ids, 2] # [B, N]
foot_vel_xy = asset.data.site_lin_vel_w[:, asset_cfg.site_ids, :2] # [B, N, 2]
vel_norm = torch.norm(foot_vel_xy, dim=-1) # [B, N]
delta = torch.abs(foot_z - target_height) # [B, N]
cost = torch.sum(delta * vel_norm, dim=1) # [B]
if command_name is not None:
command = env.command_manager.get_command(command_name)
if command is not None:
linear_norm = torch.norm(command[:, :2], dim=1)
angular_norm = torch.abs(command[:, 2])
total_command = linear_norm + angular_norm
active = (total_command > command_threshold).float()
cost = cost * active
return cost
def feet_gait(
env: ManagerBasedRlEnv,
period: float,
offset: list[float],
threshold: float,
command_threshold: float,
command_name: str,
sensor_name: str,
) -> torch.Tensor:
sensor: ContactSensor = env.scene[sensor_name]
is_contact = sensor.data.current_contact_time > 0
global_phase = ((env.episode_length_buf * env.step_dt) / period).unsqueeze(1)
offsets = torch.as_tensor(offset, device=env.device, dtype=global_phase.dtype).view(1, -1)
leg_phase = (global_phase + offsets) % 1.0
is_stance = (leg_phase < threshold)
reward = (is_stance == is_contact).float().mean(dim=1)
if command_name is not None:
command = env.command_manager.get_command(command_name)
if command is not None:
linear_norm = torch.norm(command[:, :2], dim=1)
angular_norm = torch.abs(command[:, 2])
total_command = linear_norm + angular_norm
scale = (total_command > command_threshold).float()
reward *= scale
return reward
class feet_swing_height:
"""Penalize deviation from target swing height, evaluated at landing."""
def __init__(self, cfg: RewardTermCfg, env: ManagerBasedRlEnv):
self.sensor_name = cfg.params["sensor_name"]
self.site_names = cfg.params["asset_cfg"].site_names
self.peak_heights = torch.zeros(
(env.num_envs, len(self.site_names)), device=env.device, dtype=torch.float32
)
self.step_dt = env.step_dt
def __call__(
self,
env: ManagerBasedRlEnv,
sensor_name: str,
target_height: float,
command_name: str,
command_threshold: float,
asset_cfg: SceneEntityCfg,
) -> torch.Tensor:
asset: Entity = env.scene[asset_cfg.name]
contact_sensor: ContactSensor = env.scene[sensor_name]
command = env.command_manager.get_command(command_name)
assert command is not None
foot_heights = asset.data.site_pos_w[:, asset_cfg.site_ids, 2]
in_air = contact_sensor.data.found == 0
self.peak_heights = torch.where(
in_air,
torch.maximum(self.peak_heights, foot_heights),
self.peak_heights,
)
first_contact = contact_sensor.compute_first_contact(dt=self.step_dt)
linear_norm = torch.norm(command[:, :2], dim=1)
angular_norm = torch.abs(command[:, 2])
total_command = linear_norm + angular_norm
active = (total_command > command_threshold).float()
error = self.peak_heights / target_height - 1.0
cost = torch.sum(torch.square(error) * first_contact.float(), dim=1) * active
num_landings = torch.sum(first_contact.float())
peak_heights_at_landing = self.peak_heights * first_contact.float()
mean_peak_height = torch.sum(peak_heights_at_landing) / torch.clamp(
num_landings, min=1
)
env.extras["log"]["Metrics/peak_height_mean"] = mean_peak_height
self.peak_heights = torch.where(
first_contact,
torch.zeros_like(self.peak_heights),
self.peak_heights,
)
return cost
def feet_slip(
env: ManagerBasedRlEnv,
sensor_name: str,
command_name: str,
command_threshold: float = 0.01,
asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG,
) -> torch.Tensor:
"""Penalize foot sliding (xy velocity while in contact)."""
asset: Entity = env.scene[asset_cfg.name]
contact_sensor: ContactSensor = env.scene[sensor_name]
command = env.command_manager.get_command(command_name)
assert command is not None
linear_norm = torch.norm(command[:, :2], dim=1)
angular_norm = torch.abs(command[:, 2])
total_command = linear_norm + angular_norm
active = (total_command > command_threshold).float()
assert contact_sensor.data.found is not None
in_contact = (contact_sensor.data.found > 0).float() # [B, N]
foot_vel_xy = asset.data.site_lin_vel_w[:, asset_cfg.site_ids, :2] # [B, N, 2]
vel_xy_norm = torch.norm(foot_vel_xy, dim=-1) # [B, N]
vel_xy_norm_sq = torch.square(vel_xy_norm) # [B, N]
cost = torch.sum(vel_xy_norm_sq * in_contact, dim=1) * active
num_in_contact = torch.sum(in_contact)
mean_slip_vel = torch.sum(vel_xy_norm * in_contact) / torch.clamp(
num_in_contact, min=1
)
env.extras["log"]["Metrics/slip_velocity_mean"] = mean_slip_vel
return cost
def soft_landing(
env: ManagerBasedRlEnv,
sensor_name: str,
command_name: str | None = None,
command_threshold: float = 0.05,
) -> torch.Tensor:
"""Penalize high impact forces at landing to encourage soft footfalls."""
contact_sensor: ContactSensor = env.scene[sensor_name]
sensor_data = contact_sensor.data
assert sensor_data.force is not None
forces = sensor_data.force # [B, N, 3]
force_magnitude = torch.norm(forces, dim=-1) # [B, N]
first_contact = contact_sensor.compute_first_contact(dt=env.step_dt) # [B, N]
landing_impact = force_magnitude * first_contact.float() # [B, N]
cost = torch.sum(landing_impact, dim=1) # [B]
num_landings = torch.sum(first_contact.float())
mean_landing_force = torch.sum(landing_impact) / torch.clamp(num_landings, min=1)
env.extras["log"]["Metrics/landing_force_mean"] = mean_landing_force
if command_name is not None:
command = env.command_manager.get_command(command_name)
if command is not None:
linear_norm = torch.norm(command[:, :2], dim=1)
angular_norm = torch.abs(command[:, 2])
total_command = linear_norm + angular_norm
active = (total_command > command_threshold).float()
cost = cost * active
return cost
class variable_posture:
"""Penalize deviation from default pose with speed-dependent tolerance.
Uses per-joint standard deviations to control how much each joint can deviate
from default pose. Smaller std = stricter (less deviation allowed), larger
std = more forgiving. The reward is: exp(-mean(error² / std²))
Three speed regimes (based on linear + angular command velocity):
- std_standing (speed < walking_threshold): Tight tolerance for holding pose.
- std_walking (walking_threshold <= speed < running_threshold): Moderate.
- std_running (speed >= running_threshold): Loose tolerance for large motion.
Tune std values per joint based on how much motion that joint needs at each
speed. Map joint name patterns to std values, e.g. {".*knee.*": 0.35}.
"""
def __init__(self, cfg: RewardTermCfg, env: ManagerBasedRlEnv):
asset: Entity = env.scene[cfg.params["asset_cfg"].name]
default_joint_pos = asset.data.default_joint_pos
assert default_joint_pos is not None
self.default_joint_pos = default_joint_pos
_, joint_names = asset.find_joints(cfg.params["asset_cfg"].joint_names)
_, _, std_standing = resolve_matching_names_values(
data=cfg.params["std_standing"],
list_of_strings=joint_names,
)
self.std_standing = torch.tensor(
std_standing, device=env.device, dtype=torch.float32
)
_, _, std_walking = resolve_matching_names_values(
data=cfg.params["std_walking"],
list_of_strings=joint_names,
)
self.std_walking = torch.tensor(std_walking, device=env.device, dtype=torch.float32)
_, _, std_running = resolve_matching_names_values(
data=cfg.params["std_running"],
list_of_strings=joint_names,
)
self.std_running = torch.tensor(std_running, device=env.device, dtype=torch.float32)
def __call__(
self,
env: ManagerBasedRlEnv,
std_standing,
std_walking,
std_running,
asset_cfg: SceneEntityCfg,
command_name: str,
walking_threshold: float = 0.5,
running_threshold: float = 1.5,
) -> torch.Tensor:
del std_standing, std_walking, std_running # Unused.
asset: Entity = env.scene[asset_cfg.name]
command = env.command_manager.get_command(command_name)
assert command is not None
linear_speed = torch.norm(command[:, :2], dim=1)
angular_speed = torch.abs(command[:, 2])
total_speed = linear_speed + angular_speed
standing_mask = (total_speed < walking_threshold).float()
walking_mask = (
(total_speed >= walking_threshold) & (total_speed < running_threshold)
).float()
running_mask = (total_speed >= running_threshold).float()
std = (
self.std_standing * standing_mask.unsqueeze(1)
+ self.std_walking * walking_mask.unsqueeze(1)
+ self.std_running * running_mask.unsqueeze(1)
)
current_joint_pos = asset.data.joint_pos[:, asset_cfg.joint_ids]
desired_joint_pos = self.default_joint_pos[:, asset_cfg.joint_ids]
error_squared = torch.square(current_joint_pos - desired_joint_pos)
return torch.exp(-torch.mean(error_squared / (std**2), dim=1))
def stand_still(
env: ManagerBasedRlEnv,
command_name: str,
command_threshold: float = 0.1,
asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG
) -> torch.Tensor:
asset: Entity = env.scene[asset_cfg.name]
diff_angle = asset.data.joint_pos[:, asset_cfg.joint_ids] - asset.data.default_joint_pos[:, asset_cfg.joint_ids]
reward = torch.sum(torch.square(diff_angle), dim=1)
if command_name is not None:
command = env.command_manager.get_command(command_name)
if command is not None:
linear_norm = torch.norm(command[:, :2], dim=1)
angular_norm = torch.abs(command[:, 2])
total_command = linear_norm + angular_norm
scale = (total_command <= command_threshold).float()
reward *= scale
return reward
@@ -0,0 +1,25 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import torch
from mjlab.sensor import ContactSensor
if TYPE_CHECKING:
from mjlab.envs import ManagerBasedRlEnv
def illegal_contact(
env: ManagerBasedRlEnv,
sensor_name: str,
force_threshold: float = 10.0,
) -> torch.Tensor:
sensor: ContactSensor = env.scene[sensor_name]
data = sensor.data
if data.force_history is not None:
# force_history: [B, N, H, 3]
force_mag = torch.norm(data.force_history, dim=-1) # [B, N, H]
return (force_mag > force_threshold).any(dim=-1).any(dim=-1) # [B]
assert data.found is not None
return torch.any(data.found, dim=-1)
@@ -0,0 +1,282 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
import numpy as np
import torch
from mjlab.entity import Entity
from mjlab.managers.command_manager import CommandTerm, CommandTermCfg
from mjlab.utils.lab_api.math import (
matrix_from_quat,
quat_apply,
wrap_to_pi,
)
if TYPE_CHECKING:
import viser
from mjlab.envs.manager_based_rl_env import ManagerBasedRlEnv
from mjlab.viewer.debug_visualizer import DebugVisualizer
class UniformVelocityCommand(CommandTerm):
cfg: UniformVelocityCommandCfg
def __init__(self, cfg: UniformVelocityCommandCfg, env: ManagerBasedRlEnv):
super().__init__(cfg, env)
if self.cfg.heading_command and self.cfg.ranges.heading is None:
raise ValueError("heading_command=True but ranges.heading is set to None.")
if self.cfg.ranges.heading and not self.cfg.heading_command:
raise ValueError("ranges.heading is set but heading_command=False.")
self.robot: Entity = env.scene[cfg.entity_name]
self.vel_command_b = torch.zeros(self.num_envs, 3, device=self.device)
self.heading_target = torch.zeros(self.num_envs, device=self.device)
self.heading_error = torch.zeros(self.num_envs, device=self.device)
self.is_heading_env = torch.zeros(
self.num_envs, dtype=torch.bool, device=self.device
)
self.is_standing_env = torch.zeros_like(self.is_heading_env)
self.metrics["error_vel_xy"] = torch.zeros(self.num_envs, device=self.device)
self.metrics["error_vel_yaw"] = torch.zeros(self.num_envs, device=self.device)
# Set by create_gui() when the viewer is active.
self._joystick_enabled: viser.GuiCheckboxHandle | None = None
self._joystick_sliders: list[viser.GuiSliderHandle] = []
self._joystick_get_env_idx: Callable[[], int] | None = None
@property
def command(self) -> torch.Tensor:
return self.vel_command_b
def _update_metrics(self) -> None:
max_command_time = self.cfg.resampling_time_range[1]
max_command_step = max_command_time / self._env.step_dt
self.metrics["error_vel_xy"] += (
torch.norm(
self.vel_command_b[:, :2] - self.robot.data.root_link_lin_vel_b[:, :2], dim=-1
)
/ max_command_step
)
self.metrics["error_vel_yaw"] += (
torch.abs(self.vel_command_b[:, 2] - self.robot.data.root_link_ang_vel_b[:, 2])
/ max_command_step
)
def _resample_command(self, env_ids: torch.Tensor) -> None:
r = torch.empty(len(env_ids), device=self.device)
self.vel_command_b[env_ids, 0] = r.uniform_(*self.cfg.ranges.lin_vel_x)
self.vel_command_b[env_ids, 1] = r.uniform_(*self.cfg.ranges.lin_vel_y)
self.vel_command_b[env_ids, 2] = r.uniform_(*self.cfg.ranges.ang_vel_z)
self.vel_command_b[env_ids, :] *= (torch.norm(self.vel_command_b[env_ids, :], dim=1) > 0.1).unsqueeze(1)
if self.cfg.heading_command:
assert self.cfg.ranges.heading is not None
self.heading_target[env_ids] = r.uniform_(*self.cfg.ranges.heading)
self.is_heading_env[env_ids] = r.uniform_(0.0, 1.0) <= self.cfg.rel_heading_envs
self.is_standing_env[env_ids] = r.uniform_(0.0, 1.0) <= self.cfg.rel_standing_envs
init_vel_mask = r.uniform_(0.0, 1.0) < self.cfg.init_velocity_prob
init_vel_env_ids = env_ids[init_vel_mask]
if len(init_vel_env_ids) > 0:
root_pos = self.robot.data.root_link_pos_w[init_vel_env_ids]
root_quat = self.robot.data.root_link_quat_w[init_vel_env_ids]
lin_vel_b = self.robot.data.root_link_lin_vel_b[init_vel_env_ids]
lin_vel_b[:, :2] = self.vel_command_b[init_vel_env_ids, :2]
root_lin_vel_w = quat_apply(root_quat, lin_vel_b)
root_ang_vel_b = self.robot.data.root_link_ang_vel_b[init_vel_env_ids]
root_ang_vel_b[:, 2] = self.vel_command_b[init_vel_env_ids, 2]
root_state = torch.cat(
[root_pos, root_quat, root_lin_vel_w, root_ang_vel_b], dim=-1
)
self.robot.write_root_state_to_sim(root_state, init_vel_env_ids)
def _update_command(self) -> None:
if self.cfg.heading_command:
self.heading_error = wrap_to_pi(self.heading_target - self.robot.data.heading_w)
env_ids = self.is_heading_env.nonzero(as_tuple=False).flatten()
self.vel_command_b[env_ids, 2] = torch.clip(
self.cfg.heading_control_stiffness * self.heading_error[env_ids],
min=self.cfg.ranges.ang_vel_z[0],
max=self.cfg.ranges.ang_vel_z[1],
)
standing_env_ids = self.is_standing_env.nonzero(as_tuple=False).flatten()
self.vel_command_b[standing_env_ids, :] = 0.0
# GUI.
def create_gui(
self,
name: str,
server: "viser.ViserServer",
get_env_idx: Callable[[], int],
) -> None:
"""Create velocity joystick sliders in the Viser viewer."""
from viser import Icon
ranges = self.cfg.ranges
axes = [
("lin_vel_x", ranges.lin_vel_x[1]),
("lin_vel_y", ranges.lin_vel_y[1]),
("ang_vel_z", ranges.ang_vel_z[1]),
]
sliders: list = []
with server.gui.add_folder(name.capitalize()):
enabled = server.gui.add_checkbox("Enable", initial_value=False)
for label, max_val in axes:
max_input = server.gui.add_slider(
f"Max {label}",
initial_value=max_val,
step=0.1,
min=0.1,
max=10.0,
)
slider = server.gui.add_slider(
label,
min=-max_val,
max=max_val,
step=0.05,
initial_value=0.0,
)
@max_input.on_update
def _(_ev, _s=slider, _m=max_input) -> None:
_s.min = -_m.value
_s.max = _m.value
sliders.append(slider)
zero_btn = server.gui.add_button("Zero", icon=Icon.SQUARE_X)
@zero_btn.on_click
def _(_) -> None:
for s in sliders:
s.value = 0.0
# Store GUI state for compute() override.
self._joystick_enabled = enabled
self._joystick_sliders = sliders
self._joystick_get_env_idx = get_env_idx
def compute(self, dt: float) -> None:
super().compute(dt)
if self._joystick_enabled is not None and self._joystick_enabled.value:
assert self._joystick_get_env_idx is not None
idx = self._joystick_get_env_idx()
for i, s in enumerate(self._joystick_sliders):
self.vel_command_b[idx, i] = s.value
# Visualization.
def _debug_vis_impl(self, visualizer: "DebugVisualizer") -> None:
"""Draw velocity command and actual velocity arrows."""
env_indices = visualizer.get_env_indices(self.num_envs)
if not env_indices:
return
cmds = self.command.cpu().numpy()
base_pos_ws = self.robot.data.root_link_pos_w.cpu().numpy()
base_quat_w = self.robot.data.root_link_quat_w
base_mat_ws = matrix_from_quat(base_quat_w).cpu().numpy()
lin_vel_bs = self.robot.data.root_link_lin_vel_b.cpu().numpy()
ang_vel_bs = self.robot.data.root_link_ang_vel_b.cpu().numpy()
scale = self.cfg.viz.scale
z_offset = self.cfg.viz.z_offset
for batch in env_indices:
base_pos_w = base_pos_ws[batch]
base_mat_w = base_mat_ws[batch]
cmd = cmds[batch]
lin_vel_b = lin_vel_bs[batch]
ang_vel_b = ang_vel_bs[batch]
# Skip if robot appears uninitialized (at origin).
if np.linalg.norm(base_pos_w) < 1e-6:
continue
# Helper to transform local to world coordinates.
def local_to_world(
vec: np.ndarray, pos: np.ndarray = base_pos_w, mat: np.ndarray = base_mat_w
) -> np.ndarray:
return pos + mat @ vec
# Command linear velocity arrow (blue).
cmd_lin_from = local_to_world(np.array([0, 0, z_offset]) * scale)
cmd_lin_to = local_to_world(
(np.array([0, 0, z_offset]) + np.array([cmd[0], cmd[1], 0])) * scale
)
visualizer.add_arrow(
cmd_lin_from, cmd_lin_to, color=(0.2, 0.2, 0.6, 0.6), width=0.015
)
# Command angular velocity arrow (green).
cmd_ang_from = cmd_lin_from
cmd_ang_to = local_to_world(
(np.array([0, 0, z_offset]) + np.array([0, 0, cmd[2]])) * scale
)
visualizer.add_arrow(
cmd_ang_from, cmd_ang_to, color=(0.2, 0.6, 0.2, 0.6), width=0.015
)
# Actual linear velocity arrow (cyan).
act_lin_from = local_to_world(np.array([0, 0, z_offset]) * scale)
act_lin_to = local_to_world(
(np.array([0, 0, z_offset]) + np.array([lin_vel_b[0], lin_vel_b[1], 0])) * scale
)
visualizer.add_arrow(
act_lin_from, act_lin_to, color=(0.0, 0.6, 1.0, 0.7), width=0.015
)
# Actual angular velocity arrow (light green).
act_ang_from = act_lin_from
act_ang_to = local_to_world(
(np.array([0, 0, z_offset]) + np.array([0, 0, ang_vel_b[2]])) * scale
)
visualizer.add_arrow(
act_ang_from, act_ang_to, color=(0.0, 1.0, 0.4, 0.7), width=0.015
)
@dataclass(kw_only=True)
class UniformVelocityCommandCfg(CommandTermCfg):
entity_name: str
heading_command: bool = False
heading_control_stiffness: float = 1.0
rel_standing_envs: float = 0.0
rel_heading_envs: float = 1.0
init_velocity_prob: float = 0.0
@dataclass
class Ranges:
lin_vel_x: tuple[float, float]
lin_vel_y: tuple[float, float]
ang_vel_z: tuple[float, float]
heading: tuple[float, float] | None = None
ranges: Ranges
@dataclass
class VizCfg:
z_offset: float = 0.2
scale: float = 0.5
viz: VizCfg = field(default_factory=VizCfg)
def build(self, env: ManagerBasedRlEnv) -> UniformVelocityCommand:
return UniformVelocityCommand(self, env)
def __post_init__(self):
if self.heading_command and self.ranges.heading is None:
raise ValueError(
"The velocity command has heading commands active (heading_command=True) but "
"the `ranges.heading` parameter is set to None."
)
@@ -0,0 +1,3 @@
from .runner import (
VelocityOnPolicyRunner as VelocityOnPolicyRunner,
)
@@ -0,0 +1,28 @@
import os
import wandb
from mjlab.rl import RslRlVecEnvWrapper
from mjlab.rl.exporter_utils import (
attach_metadata_to_onnx,
get_base_metadata,
)
from mjlab.rl.runner import MjlabOnPolicyRunner
class VelocityOnPolicyRunner(MjlabOnPolicyRunner):
env: RslRlVecEnvWrapper
def save(self, path: str, infos=None):
super().save(path, infos)
policy_path = path.split("model")[0]
filename = "policy.onnx"
self.export_policy_to_onnx(policy_path, filename)
run_name: str = (
wandb.run.name if self.logger.logger_type == "wandb" and wandb.run else "local"
) # type: ignore[assignment]
onnx_path = os.path.join(policy_path, filename)
metadata = get_base_metadata(self.env.unwrapped, run_name)
attach_metadata_to_onnx(onnx_path, metadata)
if self.logger.logger_type in ["wandb"]:
wandb.save(policy_path + filename, base_path=os.path.dirname(policy_path))
@@ -0,0 +1,455 @@
"""Velocity task configuration.
This module provides a factory function to create a base velocity task config.
Robot-specific configurations call the factory and customize as needed.
"""
import math
from dataclasses import replace
from mjlab.envs import ManagerBasedRlEnvCfg
from mjlab.envs import mdp as envs_mdp
from mjlab.envs.mdp import dr
from mjlab.envs.mdp.actions import JointPositionActionCfg
from mjlab.managers.action_manager import ActionTermCfg
from mjlab.managers.command_manager import CommandTermCfg
from mjlab.managers.curriculum_manager import CurriculumTermCfg
from mjlab.managers.event_manager import EventTermCfg
from mjlab.managers.metrics_manager import MetricsTermCfg
from mjlab.managers.observation_manager import ObservationGroupCfg, ObservationTermCfg
from mjlab.managers.reward_manager import RewardTermCfg
from mjlab.managers.scene_entity_config import SceneEntityCfg
from mjlab.managers.termination_manager import TerminationTermCfg
from mjlab.scene import SceneCfg
from mjlab.sensor import GridPatternCfg, ObjRef, RayCastSensorCfg
from mjlab.sim import MujocoCfg, SimulationCfg
from mjlab.tasks.velocity import mdp
from mjlab.tasks.velocity.mdp import UniformVelocityCommandCfg
from mjlab.terrains import TerrainEntityCfg
from mjlab.terrains.config import ROUGH_TERRAINS_CFG
from mjlab.utils.noise import UniformNoiseCfg as Unoise
from mjlab.viewer import ViewerConfig
import src.tasks.velocity.mdp as mdp
def make_velocity_env_cfg() -> ManagerBasedRlEnvCfg:
"""Create base velocity tracking task configuration."""
##
# Sensors
##
terrain_scan = RayCastSensorCfg(
name="terrain_scan",
frame=ObjRef(type="body", name="", entity="robot"), # Set per-robot.
ray_alignment="yaw",
pattern=GridPatternCfg(size=(1.6, 1.0), resolution=0.1),
max_distance=5.0,
exclude_parent_body=True,
debug_vis=True,
viz=RayCastSensorCfg.VizCfg(show_normals=True),
)
##
# Observations
##
actor_terms = {
"base_ang_vel": ObservationTermCfg(
func=mdp.builtin_sensor,
params={"sensor_name": "robot/imu_ang_vel"},
noise=Unoise(n_min=-0.2, n_max=0.2),
),
"projected_gravity": ObservationTermCfg(
func=mdp.projected_gravity,
noise=Unoise(n_min=-0.05, n_max=0.05),
),
"command": ObservationTermCfg(
func=mdp.generated_commands,
params={"command_name": "twist"},
),
"phase": ObservationTermCfg(
func=mdp.phase,
params={"period": 0.6, "command_name": "twist"},
),
"joint_pos": ObservationTermCfg(
func=mdp.joint_pos_rel,
noise=Unoise(n_min=-0.01, n_max=0.01),
),
"joint_vel": ObservationTermCfg(
func=mdp.joint_vel_rel,
noise=Unoise(n_min=-1.5, n_max=1.5),
),
"actions": ObservationTermCfg(func=mdp.last_action),
"height_scan": ObservationTermCfg(
func=envs_mdp.height_scan,
params={"sensor_name": "terrain_scan"},
noise=Unoise(n_min=-0.1, n_max=0.1),
scale=1 / terrain_scan.max_distance,
),
}
critic_terms = {
**actor_terms,
"base_lin_vel": ObservationTermCfg(
func=mdp.builtin_sensor,
params={"sensor_name": "robot/imu_lin_vel"},
noise=Unoise(n_min=-0.5, n_max=0.5),
),
"height_scan": ObservationTermCfg(
func=envs_mdp.height_scan,
params={"sensor_name": "terrain_scan"},
scale=1 / terrain_scan.max_distance,
),
"foot_height": ObservationTermCfg(
func=mdp.foot_height,
params={"asset_cfg": SceneEntityCfg("robot", site_names=())}, # Set per-robot.
),
"foot_air_time": ObservationTermCfg(
func=mdp.foot_air_time,
params={"sensor_name": "feet_ground_contact"},
),
"foot_contact": ObservationTermCfg(
func=mdp.foot_contact,
params={"sensor_name": "feet_ground_contact"},
),
"foot_contact_forces": ObservationTermCfg(
func=mdp.foot_contact_forces,
params={"sensor_name": "feet_ground_contact"},
),
}
observations = {
"actor": ObservationGroupCfg(
terms=actor_terms,
concatenate_terms=True,
enable_corruption=True,
history_length=1,
),
"critic": ObservationGroupCfg(
terms=critic_terms,
concatenate_terms=True,
enable_corruption=False,
history_length=1,
),
}
##
# Metrics
##
metrics = {
"linear_velocity_rmse": MetricsTermCfg(
func=mdp.linear_velocity_rmse,
params={"command_name": "twist"},
),
"angular_velocity_rmse": MetricsTermCfg(
func=mdp.angular_velocity_rmse,
params={"command_name": "twist"},
),
"mean_action_acc": MetricsTermCfg(func=mdp.mean_action_acc),
"orientation_error": MetricsTermCfg(func=mdp.orientation_error),
"fall_indicator": MetricsTermCfg(func=mdp.fall_indicator),
"slip_velocity": MetricsTermCfg(
func=mdp.slip_velocity,
params={
"sensor_name": "feet_ground_contact",
"asset_cfg": SceneEntityCfg("robot", site_names=()), # Set per-robot.
},
),
"mechanical_power": MetricsTermCfg(
func=mdp.mechanical_power,
params={"asset_cfg": SceneEntityCfg("robot", joint_names=(".*",))},
),
}
##
# Actions
##
actions: dict[str, ActionTermCfg] = {
"joint_pos": JointPositionActionCfg(
entity_name="robot",
actuator_names=(".*",),
scale=0.25, # Override per-robot.
use_default_offset=True,
)
}
##
# Commands
##
commands: dict[str, CommandTermCfg] = {
"twist": UniformVelocityCommandCfg(
entity_name="robot",
resampling_time_range=(3.0, 8.0),
rel_standing_envs=0.05,
heading_command=True,
heading_control_stiffness=0.5,
debug_vis=True,
ranges=UniformVelocityCommandCfg.Ranges(
lin_vel_x=(-1.0, 2.0),
lin_vel_y=(-1.0, 1.0),
ang_vel_z=(-1.0, 1.0),
heading=(-math.pi, math.pi),
),
)
}
##
# Events
##
events = {
"reset_base": EventTermCfg(
func=mdp.reset_root_state_uniform,
mode="reset",
params={
"pose_range": {
"x": (-0.5, 0.5),
"y": (-0.5, 0.5),
"z": (0.0, 0.0),
"yaw": (-3.14, 3.14),
},
"velocity_range": {},
},
),
"reset_robot_joints": EventTermCfg(
func=mdp.reset_joints_by_offset,
mode="reset",
params={
"position_range": (-0.0, 0.0),
"velocity_range": (-0.0, 0.0),
"asset_cfg": SceneEntityCfg("robot", joint_names=(".*",)),
},
),
"push_robot": EventTermCfg(
func=mdp.push_by_setting_velocity,
mode="interval",
interval_range_s=(5.0, 6.0),
params={
"velocity_range": {
"x": (-0.5, 0.5),
"y": (-0.5, 0.5),
"z": (-0.4, 0.4),
"roll": (-0.52, 0.52),
"pitch": (-0.52, 0.52),
"yaw": (-0.78, 0.78),
},
},
),
"foot_friction": EventTermCfg(
mode="startup",
func=dr.geom_friction,
params={
"asset_cfg": SceneEntityCfg("robot", geom_names=()), # Set per-robot.
"operation": "abs",
"ranges": (0.3, 1.6),
"shared_random": True, # All foot geoms share the same friction.
},
),
"encoder_bias": EventTermCfg(
mode="startup",
func=dr.encoder_bias,
params={
"asset_cfg": SceneEntityCfg("robot"),
"bias_range": (-0.015, 0.015),
},
),
"base_com": EventTermCfg(
mode="startup",
func=dr.body_com_offset,
params={
"asset_cfg": SceneEntityCfg("robot", body_names=()), # Set per-robot.
"operation": "add",
"ranges": {
0: (-0.05, 0.05),
1: (-0.05, 0.05),
2: (-0.05, 0.05),
},
},
),
}
##
# Rewards
##
rewards = {
"track_linear_velocity": RewardTermCfg(
func=mdp.track_linear_velocity,
weight=1.0,
params={"command_name": "twist", "std": math.sqrt(0.25)},
),
"track_angular_velocity": RewardTermCfg(
func=mdp.track_angular_velocity,
weight=1.0,
params={"command_name": "twist", "std": math.sqrt(0.5)},
),
"body_orientation_l2": RewardTermCfg(
func=mdp.body_orientation_l2,
weight=-1.0,
params={"asset_cfg": SceneEntityCfg("robot", body_names=())}, # Set per-robot.
),
"pose": RewardTermCfg(
func=mdp.variable_posture,
weight=1.0,
params={
"asset_cfg": SceneEntityCfg("robot", joint_names=".*"),
"command_name": "twist",
"std_standing": {}, # Set per-robot.
"std_walking": {}, # Set per-robot.
"std_running": {}, # Set per-robot.
"walking_threshold": 0.1,
"running_threshold": 1.5,
},
),
"body_ang_vel": RewardTermCfg(
func=mdp.body_angular_velocity_penalty,
weight=-0.05, # Override per-robot
params={"asset_cfg": SceneEntityCfg("robot", body_names=())}, # Set per-robot.
),
"angular_momentum": RewardTermCfg(
func=mdp.angular_momentum_penalty,
weight=-0.025, # Override per-robot
params={"sensor_name": "robot/root_angmom"},
),
"is_terminated": RewardTermCfg(func=mdp.is_terminated, weight=-200.0),
"electrical_power": RewardTermCfg(
func=mdp.electrical_power_cost,
weight=0.0,
params={"asset_cfg": SceneEntityCfg("robot", joint_names=(".*",))},
),
"joint_acc_l2": RewardTermCfg(func=mdp.joint_acc_l2, weight=-2.5e-7),
"joint_pos_limits": RewardTermCfg(func=mdp.joint_pos_limits, weight=-10.0),
"action_rate_l2": RewardTermCfg(func=mdp.action_rate_l2, weight=-0.05),
"foot_gait": RewardTermCfg(
func=mdp.feet_gait,
weight=0.5,
params={
"period": 0.6,
"offset": [0.0, 0.5],
"threshold": 0.56,
"command_threshold": 0.1,
"command_name": "twist",
"sensor_name": "feet_ground_contact",
}
),
"foot_clearance": RewardTermCfg(
func=mdp.feet_clearance,
weight=-1.0,
params={
"target_height": 0.10,
"command_name": "twist",
"command_threshold": 0.1,
"asset_cfg": SceneEntityCfg("robot", site_names=()), # Set per-robot.
},
),
"foot_slip": RewardTermCfg(
func=mdp.feet_slip,
weight=-0.25,
params={
"sensor_name": "feet_ground_contact",
"command_name": "twist",
"command_threshold": 0.1,
"asset_cfg": SceneEntityCfg("robot", site_names=()), # Set per-robot.
},
),
"soft_landing": RewardTermCfg(
func=mdp.soft_landing,
weight=-1e-3,
params={
"sensor_name": "feet_ground_contact",
"command_name": "twist",
"command_threshold": 0.1,
},
),
"stand_still": RewardTermCfg(
func=mdp.stand_still,
weight=-1.0,
params={
"command_name": "twist",
"command_threshold": 0.1,
"asset_cfg": SceneEntityCfg("robot", joint_names=".*"),
},
),
}
##
# Terminations
##
terminations = {
"time_out": TerminationTermCfg(func=mdp.time_out, time_out=True),
"fell_over": TerminationTermCfg(
func=mdp.bad_orientation,
params={"limit_angle": math.radians(70.0)},
),
}
##
# Curriculum
##
curriculum = {
"terrain_levels": CurriculumTermCfg(
func=mdp.terrain_levels_vel,
params={"command_name": "twist"},
),
"command_vel": CurriculumTermCfg(
func=mdp.commands_vel,
params={
"command_name": "twist",
"velocity_stages": [
{"step": 0, "lin_vel_x": (-0.5, 1.0), "lin_vel_y": (-0.5, 0.5), "ang_vel_z": (-1.0, 1.0)},
{"step": 5000 * 24, "lin_vel_x": (-1.0, 2.0), "lin_vel_y": (-1.0, 1.0)},
],
},
),
}
##
# Assemble and return
##
return ManagerBasedRlEnvCfg(
scene=SceneCfg(
terrain=TerrainEntityCfg(
terrain_type="generator",
terrain_generator=replace(ROUGH_TERRAINS_CFG),
max_init_terrain_level=5,
),
sensors=(terrain_scan,),
num_envs=1,
extent=2.0,
),
observations=observations,
actions=actions,
commands=commands,
events=events,
rewards=rewards,
terminations=terminations,
curriculum=curriculum,
metrics=metrics,
viewer=ViewerConfig(
origin_type=ViewerConfig.OriginType.ASSET_BODY,
entity_name="robot",
body_name="", # Set per-robot.
distance=3.0,
elevation=-5.0,
azimuth=90.0,
),
sim=SimulationCfg(
nconmax=35,
njmax=1500,
mujoco=MujocoCfg(
timestep=0.005,
iterations=10,
ls_iterations=20,
),
),
decimation=4,
episode_length_s=20.0,
)
+201 -25
View File
@@ -23,9 +23,14 @@ from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from urllib.parse import unquote, urlsplit
from urllib.parse import parse_qs, unquote, urlsplit
VERSION = "0.2.0"
from tuning.manager import TuningError, TuningManager
from tuning.process import GpuLease, ResourceBusyError
from tuning.schema import RewardConfigError
from tuning.scoring import EvaluationError
VERSION = "0.4.0"
# 浏览器当前 ONNX 运行时只实现 Go2 的 47→12 部署契约;其他任务须由服务启动参数显式放行。
DEFAULT_TASKS = ("Unitree-Go2-Flat",)
ACTIVE_STATES = {"queued", "running"}
@@ -65,6 +70,7 @@ class TrainingConfig:
device: str
gpu_ids: list[int]
wandb_mode: str
reward_config: dict[str, Any] | None = None
@dataclass
@@ -110,6 +116,7 @@ class TrainingManager:
python: str,
tasks: tuple[str, ...],
check_environment: bool = True,
lease: GpuLease | None = None,
):
self.trainer_root = trainer_root.expanduser().resolve()
self.python = str(Path(python).expanduser()) if os.sep in python else python
@@ -118,6 +125,8 @@ class TrainingManager:
self.lock = threading.RLock()
self.check_environment = check_environment
self._environment_error: str | None | bool = False
self.lease = lease or GpuLease()
self.preset_resolver: Any = None
def readiness_error(self) -> str | None:
if not self.trainer_root.is_dir():
@@ -130,7 +139,7 @@ class TrainingManager:
if self.check_environment and self._environment_error is False:
probe = (
"import importlib.util,sys; "
"missing=[m for m in ('mjlab','torch','tyro') "
"missing=[m for m in ('mjlab','torch','tyro','wandb') "
"if importlib.util.find_spec(m) is None]; "
"print(','.join(missing)); sys.exit(bool(missing))"
)
@@ -204,6 +213,17 @@ class TrainingManager:
wandb_mode = payload.get("wandbMode", "offline")
if wandb_mode not in ("offline", "disabled", "online"):
raise ApiError(HTTPStatus.BAD_REQUEST, "wandbMode 必须是 offline、disabled 或 online")
preset_id = payload.get("rewardPresetId")
reward_config = None
if preset_id is not None:
if not isinstance(preset_id, str) or not re.fullmatch(r"[0-9a-f]{32}", preset_id):
raise ApiError(HTTPStatus.BAD_REQUEST, "rewardPresetId 格式无效")
if self.preset_resolver is None:
raise ApiError(HTTPStatus.BAD_REQUEST, "奖励 preset 服务未就绪")
try:
reward_config = self.preset_resolver(preset_id)
except KeyError as error:
raise ApiError(HTTPStatus.BAD_REQUEST, "奖励 preset 不存在") from error
return TrainingConfig(
task_id=task_id,
num_envs=integer("numEnvs", 1, 16384),
@@ -213,6 +233,7 @@ class TrainingManager:
device=device,
gpu_ids=raw_gpu_ids,
wandb_mode=wandb_mode,
reward_config=reward_config,
)
def start(self, payload: Any) -> dict[str, Any]:
@@ -232,10 +253,20 @@ class TrainingManager:
raise ApiError(HTTPStatus.CONFLICT, "训练任务历史已满,请稍后重试")
del self.jobs[completed]
job = TrainingJob(id=uuid.uuid4().hex, config=config)
owner = f"training:{job.id}"
try:
self.lease.acquire(owner)
except ResourceBusyError as error:
raise ApiError(HTTPStatus.CONFLICT, str(error)) from error
self.jobs[job.id] = job
try:
threading.Thread(
target=self._run, args=(job,), name=f"training-{job.id[:8]}", daemon=True
).start()
except Exception:
self.jobs.pop(job.id, None)
self.lease.release(owner)
raise
return job.public()
def get(self, job_id: str) -> dict[str, Any]:
@@ -305,6 +336,13 @@ class TrainingManager:
f"--agent.seed={config.seed}",
f"--agent.run-name={config.run_name}",
]
if config.reward_config is not None:
command.extend(
(
"--reward-config-json",
json.dumps(config.reward_config, ensure_ascii=False, separators=(",", ":")),
)
)
if config.device == "cpu":
command.extend(("--gpu-ids", "None"))
else:
@@ -408,13 +446,16 @@ class TrainingManager:
job.state = "cancelled" if job.cancel_requested else "failed"
job.message = f"启动训练失败:{error}"
job.logs.append(job.message)
finally:
self.lease.release(f"training:{job.id}")
class TrainingRequestHandler(BaseHTTPRequestHandler):
manager: TrainingManager
tuning_manager: TuningManager
allowed_origins: tuple[str, ...] = ()
access_token = ""
server_version = "MuJoCoLocalTraining/0.2"
server_version = "MuJoCoLocalTraining/0.4"
def log_message(self, format: str, *args: Any) -> None:
sys.stderr.write(f"[{self.log_date_time_string()}] {format % args}\n")
@@ -455,6 +496,12 @@ class TrainingRequestHandler(BaseHTTPRequestHandler):
def _error(self, error: Exception) -> None:
if isinstance(error, ApiError):
self._json(error.status, {"error": str(error)})
elif isinstance(error, KeyError):
self._json(HTTPStatus.NOT_FOUND, {"error": "调参 session、trial 或 proposal 不存在"})
elif isinstance(error, ResourceBusyError):
self._json(HTTPStatus.CONFLICT, {"error": str(error)})
elif isinstance(error, (TuningError, RewardConfigError, EvaluationError)):
self._json(HTTPStatus.BAD_REQUEST, {"error": str(error)})
else:
self._json(
HTTPStatus.INTERNAL_SERVER_ERROR, {"error": f"本地训练服务内部错误:{error}"}
@@ -490,12 +537,24 @@ class TrainingRequestHandler(BaseHTTPRequestHandler):
match = re.fullmatch(r"/api/training/jobs/([0-9a-f]{32})(/artifacts/policy\.onnx)?", path)
return (unquote(match.group(1)), bool(match.group(2))) if match else (None, False)
def _send_file(self, file_path: Path, filename: str) -> None:
size = file_path.stat().st_size
self.send_response(HTTPStatus.OK)
self._cors()
self.send_header("Content-Type", "application/octet-stream")
self.send_header("Content-Disposition", f'attachment; filename="{filename}"')
self.send_header("Content-Length", str(size))
self.send_header("Cache-Control", "no-store")
self.end_headers()
with file_path.open("rb") as source:
shutil.copyfileobj(source, self.wfile)
def do_OPTIONS(self) -> None:
try:
self._ensure_origin()
self.send_response(HTTPStatus.NO_CONTENT)
self._cors()
self.send_header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type")
self.send_header("Access-Control-Max-Age", "600")
self.end_headers()
@@ -505,25 +564,61 @@ class TrainingRequestHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None:
try:
self._ensure_request()
path = urlsplit(self.path).path
parsed = urlsplit(self.path)
path = parsed.path
if path == "/api/training/health":
self._json(HTTPStatus.OK, self.manager.health())
health = self.manager.health()
health["tuning"] = self.tuning_manager.capability()
health["resourceOwner"] = self.manager.lease.public()
self._json(HTTPStatus.OK, health)
return
if path == "/api/tuning/capabilities":
self._json(HTTPStatus.OK, self.tuning_manager.capability())
return
if path == "/api/tuning/sessions":
self._json(HTTPStatus.OK, {"sessions": self.tuning_manager.list()})
return
if path == "/api/tuning/presets":
self._json(HTTPStatus.OK, {"presets": self.tuning_manager.storage.list_presets()})
return
match = re.fullmatch(r"/api/tuning/sessions/([0-9a-f]{32})", path)
if match:
self._json(HTTPStatus.OK, self.tuning_manager.detail(match.group(1)))
return
match = re.fullmatch(
r"/api/tuning/sessions/([0-9a-f]{32})/trials/([0-9a-f]{32})/metrics", path
)
if match:
query = parse_qs(parsed.query)
tags = [tag for value in query.get("tags", []) for tag in value.split(",") if tag]
try:
max_points = int(query.get("maxPoints", ["1000"])[0])
after_raw = query.get("afterStep", [None])[0]
after_step = int(after_raw) if after_raw is not None else None
except ValueError as error:
raise TuningError("maxPoints/afterStep 必须是整数") from error
if not 10 <= max_points <= 5000:
raise TuningError("maxPoints 必须在 10–5000 之间")
if after_step is not None and after_step < -1:
raise TuningError("afterStep 不能小于 -1")
self._json(
HTTPStatus.OK,
self.tuning_manager.metrics(
match.group(1), match.group(2), tags or None, max_points, after_step
),
)
return
match = re.fullmatch(
r"/api/tuning/sessions/([0-9a-f]{32})/artifacts/best/policy\.onnx", path
)
if match:
self._send_file(self.tuning_manager.best_artifact(match.group(1)), "policy.onnx")
return
job_id, artifact = self._route(path)
if not job_id:
raise ApiError(HTTPStatus.NOT_FOUND, "接口不存在")
if artifact:
file_path = self.manager.artifact(job_id)
size = file_path.stat().st_size
self.send_response(HTTPStatus.OK)
self._cors()
self.send_header("Content-Type", "application/octet-stream")
self.send_header("Content-Disposition", 'attachment; filename="policy.onnx"')
self.send_header("Content-Length", str(size))
self.send_header("Cache-Control", "no-store")
self.end_headers()
with file_path.open("rb") as source:
shutil.copyfileobj(source, self.wfile)
self._send_file(self.manager.artifact(job_id), "policy.onnx")
else:
self._json(HTTPStatus.OK, self.manager.get(job_id))
except Exception as error:
@@ -532,16 +627,83 @@ class TrainingRequestHandler(BaseHTTPRequestHandler):
def do_POST(self) -> None:
try:
self._ensure_request()
if urlsplit(self.path).path != "/api/training/jobs":
raise ApiError(HTTPStatus.NOT_FOUND, "接口不存在")
path = urlsplit(self.path).path
if path == "/api/training/jobs":
self._json(HTTPStatus.ACCEPTED, self.manager.start(self._payload()))
return
if path == "/api/tuning/agent/test":
self._json(HTTPStatus.OK, self.tuning_manager.test_agent())
return
if path == "/api/tuning/sessions":
self._json(HTTPStatus.ACCEPTED, self.tuning_manager.create(self._payload()))
return
match = re.fullmatch(r"/api/tuning/sessions/([0-9a-f]{32})/(pause|resume)", path)
if match:
action = (
self.tuning_manager.pause
if match.group(2) == "pause"
else self.tuning_manager.resume
)
self._json(HTTPStatus.ACCEPTED, action(match.group(1)))
return
match = re.fullmatch(r"/api/tuning/sessions/([0-9a-f]{32})/(step|rollback)", path)
if match:
action = (
self.tuning_manager.step
if match.group(2) == "step"
else self.tuning_manager.rollback
)
self._json(HTTPStatus.ACCEPTED, action(match.group(1), self._payload()))
return
match = re.fullmatch(r"/api/tuning/sessions/([0-9a-f]{32})/mode", path)
if match:
self._json(
HTTPStatus.ACCEPTED,
self.tuning_manager.set_mode(match.group(1), self._payload()),
)
return
match = re.fullmatch(
r"/api/tuning/sessions/([0-9a-f]{32})/proposals/([0-9a-f]{32})/(approve|reject)",
path,
)
if match:
action = (
self.tuning_manager.approve
if match.group(3) == "approve"
else self.tuning_manager.reject
)
self._json(
HTTPStatus.ACCEPTED, action(match.group(1), match.group(2), self._payload())
)
return
raise ApiError(HTTPStatus.NOT_FOUND, "接口不存在")
except Exception as error:
self._error(error)
def do_PUT(self) -> None:
try:
self._ensure_request()
path = urlsplit(self.path).path
match = re.fullmatch(r"/api/tuning/sessions/([0-9a-f]{32})/constraints", path)
if match:
self._json(
HTTPStatus.ACCEPTED,
self.tuning_manager.set_constraints(match.group(1), self._payload()),
)
return
raise ApiError(HTTPStatus.NOT_FOUND, "接口不存在")
except Exception as error:
self._error(error)
def do_DELETE(self) -> None:
try:
self._ensure_request()
job_id, artifact = self._route(urlsplit(self.path).path)
path = urlsplit(self.path).path
match = re.fullmatch(r"/api/tuning/sessions/([0-9a-f]{32})", path)
if match:
self._json(HTTPStatus.ACCEPTED, self.tuning_manager.cancel(match.group(1)))
return
job_id, artifact = self._route(path)
if not job_id or artifact:
raise ApiError(HTTPStatus.NOT_FOUND, "接口不存在")
self._json(HTTPStatus.ACCEPTED, self.manager.cancel(job_id))
@@ -553,8 +715,7 @@ def default_trainer_root() -> Path:
configured = os.environ.get("UNITREE_RL_MJLAB_ROOT")
if configured:
return Path(configured)
repository = Path(__file__).resolve().parents[2]
return repository.parent.parent / "unitree_rl_mjlab"
return Path(__file__).resolve().parent / "rl"
def parse_args() -> argparse.Namespace:
@@ -570,11 +731,17 @@ def parse_args() -> argparse.Namespace:
"--trainer-root",
type=Path,
default=default_trainer_root(),
help="unitree_rl_mjlab 工程目录",
help="训练工程目录;默认使用仓库内置的 Go2 训练器",
)
parser.add_argument(
"--trainer-python", default=sys.executable, help="已安装 mjlab/torch 的 Python 解释器"
)
parser.add_argument(
"--tuning-data-root",
type=Path,
default=None,
help="调参 SQLite 与 trial 产物目录;默认位于训练工程 logs/auto_tuning",
)
parser.add_argument(
"--task", action="append", dest="tasks", help="允许前端启动的任务 ID;可重复"
)
@@ -594,10 +761,18 @@ def main() -> None:
token = args.token or secrets.token_urlsafe(24)
if len(token) < 16:
raise SystemExit("训练服务访问令牌至少需要 16 个字符")
lease = GpuLease()
manager = TrainingManager(
args.trainer_root, args.trainer_python, tuple(args.tasks or DEFAULT_TASKS)
args.trainer_root,
args.trainer_python,
tuple(args.tasks or DEFAULT_TASKS),
lease=lease,
)
tuning_root = args.tuning_data_root or (Path(args.trainer_root) / "logs" / "auto_tuning")
tuning_manager = TuningManager(args.trainer_root, args.trainer_python, tuning_root, lease)
manager.preset_resolver = tuning_manager.preset_config
TrainingRequestHandler.manager = manager
TrainingRequestHandler.tuning_manager = tuning_manager
TrainingRequestHandler.allowed_origins = tuple(args.allow_origin)
TrainingRequestHandler.access_token = token
server = ThreadingHTTPServer((args.host, args.port), TrainingRequestHandler)
@@ -613,6 +788,7 @@ def main() -> None:
except KeyboardInterrupt:
print("\n正在停止本地训练服务…")
finally:
tuning_manager.shutdown()
manager.shutdown()
server.server_close()
+21
View File
@@ -1,3 +1,4 @@
import json
import subprocess
import sys
import tempfile
@@ -14,6 +15,7 @@ from server import ( # noqa: E402
TrainingJob,
TrainingManager,
TrainingRequestHandler,
default_trainer_root,
termination_signal_handler,
)
@@ -57,6 +59,16 @@ out.write_bytes(b'onnx')
value.update(patch)
return value
def test_default_trainer_is_bundled_with_go2_assets(self):
trainer_root = default_trainer_root()
self.assertEqual(trainer_root, Path(__file__).resolve().parents[1] / "rl")
self.assertTrue((trainer_root / "scripts" / "train.py").is_file())
self.assertTrue(
(
trainer_root / "src" / "assets" / "robots" / "unitree_go2" / "xmls" / "go2.xml"
).is_file()
)
def test_validates_allowlist_and_limits(self):
with self.assertRaises(ApiError):
self.manager.parse_config(self.payload(taskId="shell injection"))
@@ -75,6 +87,15 @@ out.write_bytes(b'onnx')
)
self.assertEqual(command[-2:], ["--gpu-ids", "[0,2]"])
def test_resolves_reward_preset_to_inline_validated_trainer_argument(self):
preset_id = "f" * 32
reward_config = {"weights": {"pose": 1.2}, "params": {}}
self.manager.preset_resolver = lambda value: reward_config if value == preset_id else None
config = self.manager.parse_config(self.payload(rewardPresetId=preset_id))
command = self.manager.command_for(config)
index = command.index("--reward-config-json")
self.assertEqual(json.loads(command[index + 1]), reward_config)
def test_requires_local_host_origin_and_bearer_token(self):
handler = object.__new__(TrainingRequestHandler)
handler.access_token = "secret-token-1234"
+239
View File
@@ -0,0 +1,239 @@
import math
import sys
import tempfile
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from tuning.advisor import AdvisorConfig, DeepSeekAdvisor # noqa: E402
from tuning.schema import ( # noqa: E402
BASE_REWARD_CONFIGURATION,
RewardConfigError,
merge_proposal,
validate_configuration,
validate_constraints,
validate_proposal,
)
from tuning.scoring import ( # noqa: E402
DEFAULT_OBJECTIVE_WEIGHTS,
EvaluationError,
score_evaluation,
)
from tuning.storage import StorageConflict, TuningStorage # noqa: E402
class RewardSchemaTest(unittest.TestCase):
def test_baseline_is_complete_and_energy_is_disabled(self):
config = validate_configuration(BASE_REWARD_CONFIGURATION)
self.assertEqual(len(config["weights"]), 16)
self.assertEqual(config["weights"]["electrical_power"], 0.0)
def test_sparse_proposal_constraints(self):
patch = validate_proposal(
{
"weights": {"track_linear_velocity": 1.2, "foot_slip": -0.3},
"params": {"foot_gait.period": 0.65},
},
BASE_REWARD_CONFIGURATION,
)
merged = merge_proposal(BASE_REWARD_CONFIGURATION, patch)
self.assertEqual(merged["weights"]["track_linear_velocity"], 1.2)
with self.assertRaises(RewardConfigError):
validate_proposal({"weights": {"track_linear_velocity": 0}}, BASE_REWARD_CONFIGURATION)
with self.assertRaises(RewardConfigError):
validate_proposal({"weights": {"foot_slip": 0.2}}, BASE_REWARD_CONFIGURATION)
with self.assertRaises(RewardConfigError):
validate_proposal(
{"weights": {"track_linear_velocity": 3.0}}, BASE_REWARD_CONFIGURATION
)
with self.assertRaises(RewardConfigError):
validate_proposal({"params": {"foot_gait.period": math.nan}}, BASE_REWARD_CONFIGURATION)
with self.assertRaises(RewardConfigError):
validate_proposal(
{
"weights": {
"pose": 1.1,
"foot_gait": 0.6,
"foot_slip": -0.3,
"soft_landing": -0.002,
},
"params": {"foot_gait.period": 0.65},
},
BASE_REWARD_CONFIGURATION,
)
def test_cross_parameter_order(self):
with self.assertRaises(RewardConfigError):
validate_proposal(
{"params": {"pose.walking_threshold": 0.5, "pose.running_threshold": 0.4}},
BASE_REWARD_CONFIGURATION,
)
def test_session_constraints_reject_unknown_out_of_range_and_fixed_changes(self):
constraints = validate_constraints(
{
"weights.track_linear_velocity": {"kind": "fixed", "value": 1.0},
"params.foot_gait.period": {"kind": "range", "min": 0.5, "max": 0.7},
}
)
validate_proposal(
{"params": {"foot_gait.period": 0.65}},
BASE_REWARD_CONFIGURATION,
constraints,
)
with self.assertRaisesRegex(RewardConfigError, "已固定"):
validate_proposal(
{"weights": {"track_linear_velocity": 1.1}},
BASE_REWARD_CONFIGURATION,
constraints,
)
with self.assertRaisesRegex(RewardConfigError, "工程锁定范围"):
validate_proposal(
{"params": {"foot_gait.period": 0.75}},
BASE_REWARD_CONFIGURATION,
constraints,
)
with self.assertRaisesRegex(RewardConfigError, "未知参数约束"):
validate_constraints({"weights.not_allowed": {"kind": "fixed", "value": 1.0}})
class ScoringTest(unittest.TestCase):
baseline = {
"linear_velocity_rmse": 0.3,
"angular_velocity_rmse": 0.2,
"mean_action_acc": 0.1,
"orientation_error": 0.2,
"fall_rate": 0.1,
"slip_velocity": 0.2,
"mechanical_power": 100.0,
}
def test_improvement_and_safety_gate(self):
better = {key: value * 0.8 for key, value in self.baseline.items()}
scored = score_evaluation(self.baseline, better, DEFAULT_OBJECTIVE_WEIGHTS)
self.assertTrue(scored["eligible"])
self.assertGreater(scored["score"], 0)
unsafe = dict(better, fall_rate=0.2)
scored = score_evaluation(self.baseline, unsafe)
self.assertFalse(scored["eligible"])
self.assertEqual(scored["score"], -1.0)
def test_rejects_missing_and_nonfinite_metrics(self):
with self.assertRaises(EvaluationError):
score_evaluation(self.baseline, {"fall_rate": 0.1})
bad = dict(self.baseline, mechanical_power=math.inf)
with self.assertRaises(EvaluationError):
score_evaluation(self.baseline, bad)
class AdvisorTest(unittest.TestCase):
class Output:
weights = {"pose": 1.1}
params = {}
rationale = "improve posture"
expected_impact = {"posture": "better"}
confidence = 0.7
class Result:
output = None
@staticmethod
def usage():
return type("Usage", (), {"requests": 1, "input_tokens": 10, "output_tokens": 5})()
class Agent:
def __init__(self, output):
self.output = output
def run_sync(self, _prompt):
result = AdvisorTest.Result()
result.output = self.output
return result
def test_structured_result_is_revalidated_locally(self):
advisor = DeepSeekAdvisor(AdvisorConfig("fake"))
advisor._cached_agent = self.Agent(self.Output())
proposal = advisor.propose({"trials": []}, BASE_REWARD_CONFIGURATION)
self.assertEqual(proposal["patch"]["weights"]["pose"], 1.1)
self.assertEqual(proposal["usage"]["input_tokens"], 10)
def test_invalid_model_patch_is_rejected(self):
output = self.Output()
output.weights = {"track_linear_velocity": -1.0}
advisor = DeepSeekAdvisor(AdvisorConfig("fake"))
advisor._cached_agent = self.Agent(output)
with self.assertRaises(RewardConfigError):
advisor.propose({"trials": []}, BASE_REWARD_CONFIGURATION)
class StorageTest(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.storage = TuningStorage(Path(self.temporary.name) / "state.sqlite3")
def tearDown(self):
self.temporary.cleanup()
def test_persists_session_trial_proposal_and_metrics(self):
session = self.storage.create_session(
"approval", {"taskId": "Unitree-Go2-Flat"}, DEFAULT_OBJECTIVE_WEIGHTS, False
)
trial = self.storage.create_trial(
session["id"], 0, 0, 10, BASE_REWARD_CONFIGURATION, None, "trial-000-rung-0"
)
proposal = self.storage.create_proposal(
session["id"],
trial["id"],
{"weights": {"pose": 1.1}, "params": {}},
"test",
{},
0.8,
)
self.assertTrue(self.storage.decide_proposal(proposal["id"], "approved", None))
self.assertFalse(self.storage.decide_proposal(proposal["id"], "approved", None))
points = [
("Train/reward", step, float(step), 50.0 if step == 50 else float(step % 7))
for step in range(100)
]
self.storage.insert_metrics(trial["id"], points)
sampled = self.storage.metrics(trial["id"], max_points=10)[0]["points"]
self.assertEqual(len(sampled), 10)
self.assertEqual(sampled[0]["step"], 0)
self.assertEqual(sampled[-1]["step"], 99)
self.assertIn(50.0, [point["value"] for point in sampled])
control = self.storage.replace_constraints(
session["id"],
0,
{"weights.pose": {"kind": "range", "min": 0.5, "max": 1.5}},
)
self.assertEqual(control["constraintsRevision"], 1)
with self.assertRaises(StorageConflict):
self.storage.replace_constraints(session["id"], 0, {})
self.storage.grant_dispatch_token(session["id"])
with self.assertRaises(StorageConflict):
self.storage.grant_dispatch_token(session["id"])
self.assertTrue(self.storage.use_dispatch_token(session["id"]))
self.assertFalse(self.storage.use_dispatch_token(session["id"]))
incremental = self.storage.metrics(trial["id"], max_points=100, after_step=90)[0]
self.assertEqual(incremental["points"][0]["step"], 91)
reopened = TuningStorage(self.storage.path)
self.assertEqual(reopened.get_session(session["id"])["mode"], "approval")
self.assertEqual(reopened.get_control(session["id"])["constraintsRevision"], 1)
def test_recovery_marks_inflight_records(self):
session = self.storage.create_session(
"automatic", {"taskId": "Unitree-Go2-Flat"}, DEFAULT_OBJECTIVE_WEIGHTS, True
)
trial = self.storage.create_trial(
session["id"], 0, 0, 10, BASE_REWARD_CONFIGURATION, None, "trial-000-rung-0"
)
self.storage.update_session(session["id"], state="running")
self.storage.update_trial(trial["id"], state="training")
self.storage.recover_interrupted()
self.assertEqual(self.storage.get_session(session["id"])["state"], "interrupted")
self.assertEqual(self.storage.get_trial(trial["id"])["state"], "interrupted")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,350 @@
import sys
import tempfile
import time
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from tuning.manager import TuningManager # noqa: E402
from tuning.process import GpuLease # noqa: E402
from tuning.schema import BASE_REWARD_CONFIGURATION # noqa: E402
from tuning.scoring import score_evaluation # noqa: E402
from tuning.storage import now_iso # noqa: E402
BASE_METRICS = {
"linear_velocity_rmse": 0.3,
"angular_velocity_rmse": 0.2,
"mean_action_acc": 0.1,
"orientation_error": 0.2,
"fall_rate": 0.1,
"slip_velocity": 0.2,
"mechanical_power": 100.0,
}
class FakeAdvisor:
def capability(self):
return {
"configured": True,
"apiKeyConfigured": True,
"frameworkInstalled": True,
"model": "fake",
"baseUrl": "https://example.invalid",
}
def propose(self, _context, previous):
value = min(2.4, previous["weights"]["pose"] * 1.05)
return {
"patch": {"weights": {"pose": value}, "params": {}},
"rationale": "fake",
"expectedImpact": {},
"confidence": 0.8,
"promptHash": "abc",
"usage": {},
"model": "fake",
}
def test_connection(self):
return {"ok": True, "model": "fake", "outputType": "fake"}
class FakeTuningManager(TuningManager):
def _execute_trial(self, session, trial, resume_checkpoint=None):
del resume_checkpoint
factor = max(0.5, 1.0 - 0.03 * trial["number"] - 0.01 * trial["rung"])
metrics = {key: value * factor for key, value in BASE_METRICS.items()}
trials = self.storage.list_trials(session["id"])
baseline = next(
(item for item in trials if item["number"] == 0 and item["rung"] == 0), None
)
if baseline and baseline["evaluation"]:
scored = score_evaluation(
baseline["evaluation"]["metrics"], metrics, session["objectiveWeights"]
)
else:
scored = {"score": 0.0, "eligible": True, "components": {}}
root = self._session_root(session["id"])
run = root / trial["runDir"]
run.mkdir(parents=True, exist_ok=True)
(run / "model_1.pt").write_bytes(b"checkpoint")
(run / "policy.onnx").write_bytes(b"onnx")
evaluation = {"metrics": metrics, "score": scored}
self.storage.update_trial(
trial["id"],
state="completed",
started_at=now_iso(),
ended_at=now_iso(),
message="fake complete",
checkpoint_path=str((run / "model_1.pt").relative_to(root)),
policy_path=str((run / "policy.onnx").relative_to(root)),
evaluation=evaluation,
score=scored["score"],
eligible=scored["eligible"],
)
return self.storage.get_trial(trial["id"])
class TuningManagerTest(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.root = Path(self.temporary.name)
(self.root / "trainer" / "scripts").mkdir(parents=True)
(self.root / "trainer" / "scripts" / "evaluate.py").write_text("", encoding="utf-8")
self.manager = FakeTuningManager(
self.root / "trainer",
sys.executable,
self.root / "data",
GpuLease(),
advisor=FakeAdvisor(),
)
def tearDown(self):
self.manager.shutdown()
self.temporary.cleanup()
@staticmethod
def payload(mode="automatic"):
return {
"taskId": "Unitree-Go2-Flat",
"mode": mode,
"runName": "test",
"numEnvs": 16,
"gpuIds": [0],
"trialCount": 4,
"initialIterations": 1,
"middleIterations": 2,
"finalIterations": 3,
"evalNumEnvs": 8,
"evalSteps": 10,
}
def wait_terminal(self, session_id, timeout=5):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
session = self.manager.detail(session_id)
if session["state"] in {"succeeded", "failed", "cancelled"}:
return session
time.sleep(0.01)
self.fail("session did not finish")
def test_automatic_session_runs_rungs_and_persists_best_artifact(self):
session = self.manager.create(self.payload())
completed = self.wait_terminal(session["id"])
self.assertEqual(completed["state"], "succeeded", completed["message"])
self.assertGreaterEqual(len(completed["trials"]), 7)
self.assertTrue(self.manager.best_artifact(session["id"]).is_file())
self.assertEqual(len(self.manager.storage.list_presets()), 1)
def test_approval_session_waits_and_accepts_modified_patch(self):
session = self.manager.create(self.payload("approval"))
deadline = time.monotonic() + 3
while time.monotonic() < deadline:
detail = self.manager.detail(session["id"])
if detail["state"] == "awaiting_approval":
break
time.sleep(0.01)
else:
self.fail("session did not wait for approval")
proposal = detail["proposals"][-1]
patch = {"weights": {"pose": 1.1}, "params": {}}
with self.assertRaisesRegex(Exception, "feedback"):
self.manager.approve(session["id"], proposal["id"], {"feedback": {}})
approved = self.manager.approve(
session["id"], proposal["id"], {"feedback": "ok", "patch": patch}
)
self.assertEqual(approved["proposals"][-1]["state"], "approved")
self.manager.cancel(session["id"])
self.assertEqual(self.wait_terminal(session["id"])["state"], "cancelled")
def test_runtime_mode_switch_auto_approves_pending_proposal(self):
session = self.manager.create(self.payload("approval"))
deadline = time.monotonic() + 3
while time.monotonic() < deadline:
detail = self.manager.detail(session["id"])
if detail["state"] == "awaiting_approval":
break
time.sleep(0.01)
else:
self.fail("session did not wait for approval")
changed = self.manager.set_mode(session["id"], {"mode": "automatic"})
self.assertEqual(changed["mode"], "automatic")
self.assertEqual(changed["proposals"][-1]["state"], "approved")
completed = self.wait_terminal(session["id"])
self.assertEqual(completed["state"], "succeeded", completed["message"])
def test_trial_count_is_user_configurable(self):
payload = self.payload()
payload["trialCount"] = 1
_, config, _, _ = self.manager.parse_create(payload)
self.assertEqual(config["trialCount"], 1)
payload["trialCount"] = 100
_, config, _, _ = self.manager.parse_create(payload)
self.assertEqual(config["trialCount"], 100)
payload["trialCount"] = 101
with self.assertRaisesRegex(Exception, "trialCount"):
self.manager.parse_create(payload)
def test_resume_discards_only_interrupted_trial_and_continues(self):
mode, config, objective, fallback = self.manager.parse_create(self.payload())
session = self.manager.storage.create_session(mode, config, objective, fallback)
baseline = self.manager.storage.create_trial(
session["id"],
0,
0,
1,
BASE_REWARD_CONFIGURATION,
None,
"trial-000-rung-0",
)
self.manager._execute_trial(self.manager.storage.get_session(session["id"]), baseline)
interrupted = self.manager.storage.create_trial(
session["id"],
1,
0,
1,
baseline["rewardConfig"],
None,
"trial-001-rung-0",
)
self.manager.storage.update_trial(interrupted["id"], state="interrupted")
self.manager.storage.reset_step_gate(session["id"])
self.manager.storage.update_session(session["id"], state="interrupted")
self.manager.resume(session["id"])
self.assertEqual(self.manager.storage.get_control(session["id"])["runPolicy"], "continuous")
completed = self.wait_terminal(session["id"])
self.assertEqual(completed["state"], "succeeded", completed["message"])
self.assertNotIn(interrupted["id"], [trial["id"] for trial in completed["trials"]])
def test_create_validation_and_agent_capability(self):
self.assertTrue(self.manager.capability()["configured"])
with self.assertRaisesRegex(Exception, "只支持"):
self.manager.parse_create({"taskId": "Other"})
self.assertEqual(self.manager.test_agent()["model"], "fake")
def test_pause_closes_persistent_dispatch_gate_at_trial_boundary(self):
mode, config, objective, fallback = self.manager.parse_create(self.payload())
session = self.manager.storage.create_session(mode, config, objective, fallback)
self.manager.storage.update_session(session["id"], state="running")
self.manager.storage.grant_dispatch_token(session["id"])
paused = self.manager.pause(session["id"])
self.assertEqual(paused["state"], "paused")
self.assertEqual(paused["control"]["runPolicy"], "step")
self.assertEqual(paused["control"]["dispatchTokens"], 0)
def test_step_token_executes_exactly_one_trial_then_pauses(self):
session = self.manager.create(self.payload("approval"))
deadline = time.monotonic() + 3
while time.monotonic() < deadline:
detail = self.manager.detail(session["id"])
if detail["state"] == "awaiting_approval":
break
time.sleep(0.01)
else:
self.fail("session did not wait for approval")
baseline_count = len([trial for trial in detail["trials"] if trial["state"] == "completed"])
stepped = self.manager.step(session["id"], {"count": 1})
self.assertEqual(stepped["control"]["runPolicy"], "step")
self.assertEqual(stepped["control"]["dispatchTokens"], 1)
proposal = stepped["proposals"][-1]
self.manager.approve(session["id"], proposal["id"], {})
deadline = time.monotonic() + 3
while time.monotonic() < deadline:
detail = self.manager.detail(session["id"])
completed_count = len(
[trial for trial in detail["trials"] if trial["state"] == "completed"]
)
if detail["state"] == "paused" and completed_count == baseline_count + 1:
break
time.sleep(0.01)
else:
self.fail("single-step trial did not pause at the next boundary")
time.sleep(0.05)
self.assertEqual(
len(
[
trial
for trial in self.manager.detail(session["id"])["trials"]
if trial["state"] == "completed"
]
),
baseline_count + 1,
)
self.manager.cancel(session["id"])
self.assertEqual(self.wait_terminal(session["id"])["state"], "cancelled")
def test_constraints_are_revisioned_and_enforced_during_approval(self):
session = self.manager.create(self.payload("approval"))
deadline = time.monotonic() + 3
while time.monotonic() < deadline:
detail = self.manager.detail(session["id"])
if detail["state"] == "awaiting_approval":
break
time.sleep(0.01)
else:
self.fail("session did not wait for approval")
proposal = detail["proposals"][-1]
constrained = self.manager.set_constraints(
session["id"],
{
"revision": 0,
"constraints": {"weights.track_linear_velocity": {"kind": "fixed", "value": 1.0}},
},
)
self.assertEqual(constrained["control"]["constraintsRevision"], 1)
with self.assertRaisesRegex(Exception, "已固定"):
self.manager.approve(
session["id"],
proposal["id"],
{"patch": {"weights": {"track_linear_velocity": 1.1}, "params": {}}},
)
with self.assertRaisesRegex(Exception, "revision"):
self.manager.set_constraints(session["id"], {"revision": 0, "constraints": {}})
self.manager.approve(session["id"], proposal["id"], {})
self.manager.cancel(session["id"])
self.assertEqual(self.wait_terminal(session["id"])["state"], "cancelled")
def test_rollback_uses_safe_completed_trial_as_next_proposal_base(self):
session = self.manager.create(self.payload("approval"))
deadline = time.monotonic() + 3
while time.monotonic() < deadline:
detail = self.manager.detail(session["id"])
if detail["state"] == "awaiting_approval":
break
time.sleep(0.01)
else:
self.fail("session did not wait for approval")
baseline = detail["trials"][0]
old_proposal = detail["proposals"][-1]
rolled_back = self.manager.rollback(
session["id"], {"trialId": baseline["id"], "checkpoint": True}
)
self.assertEqual(rolled_back["state"], "paused")
self.assertEqual(rolled_back["control"]["activeBaseTrialId"], baseline["id"])
self.assertEqual(
next(item for item in rolled_back["proposals"] if item["id"] == old_proposal["id"])[
"state"
],
"rejected",
)
self.manager.step(session["id"], {"count": 1})
deadline = time.monotonic() + 3
while time.monotonic() < deadline:
detail = self.manager.detail(session["id"])
if (
detail["state"] == "awaiting_approval"
and detail["proposals"][-1]["id"] != old_proposal["id"]
):
break
time.sleep(0.01)
else:
self.fail("rollback base did not produce a replacement proposal")
self.assertEqual(detail["proposals"][-1]["baseTrialId"], baseline["id"])
self.manager.cancel(session["id"])
self.assertEqual(self.wait_terminal(session["id"])["state"], "cancelled")
if __name__ == "__main__":
unittest.main()
+26
View File
@@ -0,0 +1,26 @@
"""Reward auto-tuning support for the local training service."""
from .schema import (
BASE_REWARD_CONFIGURATION,
PARAMETER_SPECS,
WEIGHT_SPECS,
RewardConfigError,
apply_reward_configuration,
merge_proposal,
validate_configuration,
validate_proposal,
)
from .scoring import DEFAULT_OBJECTIVE_WEIGHTS, score_evaluation
__all__ = [
"BASE_REWARD_CONFIGURATION",
"DEFAULT_OBJECTIVE_WEIGHTS",
"PARAMETER_SPECS",
"WEIGHT_SPECS",
"RewardConfigError",
"apply_reward_configuration",
"merge_proposal",
"score_evaluation",
"validate_configuration",
"validate_proposal",
]
+134
View File
@@ -0,0 +1,134 @@
"""PydanticAI adapter for the DeepSeek reward-tuning advisor."""
from __future__ import annotations
import hashlib
import json
import os
from dataclasses import dataclass
from typing import Any
from .schema import validate_proposal
SYSTEM_PROMPT = """你是 Unitree Go2 强化学习奖励调参专家。
只根据提供的数值配置、训练曲线摘要和固定评估结果提出下一轮稀疏修改。
必须优先保持速度跟踪与跌倒安全门槛;每轮最多修改四个白名单标量,不得改变符号、函数、传感器或结构。
不要建议 Python 代码、命令、文件路径或白名单外参数。输出必须符合 RewardProposal schema。
"""
class AdvisorUnavailable(RuntimeError):
pass
@dataclass(frozen=True)
class AdvisorConfig:
api_key: str | None
base_url: str = "https://api.deepseek.com"
model: str = "deepseek-v4-flash"
@classmethod
def from_environment(cls) -> AdvisorConfig:
return cls(
api_key=os.environ.get("DEEPSEEK_API_KEY"),
base_url=os.environ.get("MUJOCO_TUNING_AGENT_BASE_URL", "https://api.deepseek.com"),
model=os.environ.get("MUJOCO_TUNING_AGENT_MODEL", "deepseek-v4-flash"),
)
class DeepSeekAdvisor:
def __init__(self, config: AdvisorConfig | None = None):
self.config = config or AdvisorConfig.from_environment()
self._cached_agent = None
def capability(self) -> dict[str, Any]:
try:
import pydantic_ai # noqa: F401
except ImportError:
installed = False
else:
installed = True
return {
"configured": bool(self.config.api_key) and installed,
"apiKeyConfigured": bool(self.config.api_key),
"frameworkInstalled": installed,
"model": self.config.model,
"baseUrl": self.config.base_url,
}
def _agent(self):
if self._cached_agent is not None:
return self._cached_agent
if not self.config.api_key:
raise AdvisorUnavailable("未配置 DEEPSEEK_API_KEY")
try:
import httpx2
from pydantic import BaseModel, Field
from pydantic_ai import Agent, PromptedOutput
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.openai import OpenAIProvider
except ImportError as error:
raise AdvisorUnavailable(
"缺少 PydanticAI,请安装 training_server/requirements.txt"
) from error
class RewardProposalOutput(BaseModel):
weights: dict[str, float] = Field(default_factory=dict)
params: dict[str, float] = Field(default_factory=dict)
rationale: str = Field(min_length=1, max_length=2000)
expected_impact: dict[str, str] = Field(default_factory=dict)
confidence: float = Field(ge=0.0, le=1.0)
proxy = os.environ.get("HTTPS_PROXY") or os.environ.get("ALL_PROXY")
if proxy and proxy.startswith("socks://"):
proxy = "socks5://" + proxy.removeprefix("socks://")
http_client = httpx2.AsyncClient(proxy=proxy, trust_env=False, timeout=60.0)
provider = OpenAIProvider(
base_url=self.config.base_url, api_key=self.config.api_key, http_client=http_client
)
model = OpenAIChatModel(self.config.model, provider=provider) # type: ignore[arg-type]
self._cached_agent = Agent(
model,
output_type=PromptedOutput(RewardProposalOutput),
system_prompt=SYSTEM_PROMPT,
retries=2,
model_settings={"temperature": 0.2},
)
return self._cached_agent
def propose(self, context: dict[str, Any], previous: dict) -> dict[str, Any]:
prompt = json.dumps(context, ensure_ascii=False, separators=(",", ":"), allow_nan=False)
result = self._agent().run_sync(prompt)
output = result.output
patch = validate_proposal(
{"weights": dict(output.weights), "params": dict(output.params)}, previous
)
try:
usage = result.usage()
usage_value = {
key: getattr(usage, key)
for key in ("requests", "input_tokens", "output_tokens", "total_tokens")
if getattr(usage, key, None) is not None
}
except (AttributeError, TypeError):
usage_value = {}
return {
"patch": patch,
"rationale": output.rationale,
"expectedImpact": dict(output.expected_impact),
"confidence": float(output.confidence),
"promptHash": hashlib.sha256(prompt.encode()).hexdigest(),
"usage": usage_value,
"model": self.config.model,
}
def test_connection(self) -> dict[str, Any]:
base = {
"weights": {"track_linear_velocity": 1.0},
"params": {},
"instruction": "仅返回一个合法示例:把 track_linear_velocity 改为 1.1。",
}
# A minimal full previous config is supplied by callers for actual proposals;
# connectivity probing only verifies the provider and structured response path.
result = self._agent().run_sync(json.dumps(base, ensure_ascii=False))
return {"ok": True, "model": self.config.model, "outputType": type(result.output).__name__}
+999
View File
@@ -0,0 +1,999 @@
"""Persistent reward tuning session orchestrator."""
from __future__ import annotations
import json
import os
import re
import shutil
import subprocess
import threading
from contextlib import suppress
from copy import deepcopy
from pathlib import Path
from typing import Any
from .advisor import AdvisorUnavailable, DeepSeekAdvisor
from .process import GpuLease, ResourceBusyError, terminate_process
from .schema import (
BASE_REWARD_CONFIGURATION,
merge_proposal,
validate_configuration_constraints,
validate_constraints,
validate_proposal,
)
from .scoring import DEFAULT_OBJECTIVE_WEIGHTS, score_evaluation, validate_objective_weights
from .storage import StorageConflict, TuningStorage, now_iso
from .study import OptunaStudies
from .tensorboard import ingest_scalars
ACTIVE_SESSION_STATES = {
"queued",
"running",
"evaluating",
"awaiting_approval",
"paused",
"interrupted",
}
RUNNING_STATES = {"queued", "running", "evaluating"}
RUN_NAME = re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
class TuningError(RuntimeError):
pass
class TuningManager:
def __init__(
self,
trainer_root: Path,
python: str,
data_root: Path,
lease: GpuLease,
advisor: Any | None = None,
):
self.trainer_root = trainer_root.expanduser().resolve()
self.python = python
self.data_root = data_root.expanduser().resolve()
self.data_root.mkdir(parents=True, exist_ok=True)
self.storage = TuningStorage(self.data_root / "tuning.sqlite3")
self.storage.recover_interrupted()
self.studies = OptunaStudies(self.data_root)
self.lease = lease
self.advisor = advisor or DeepSeekAdvisor()
self.lock = threading.RLock()
self.condition = threading.Condition(self.lock)
self.workers: dict[str, threading.Thread] = {}
self.processes: dict[str, subprocess.Popen[str]] = {}
self.cancel_events: dict[str, threading.Event] = {}
def capability(self) -> dict[str, Any]:
capability = self.advisor.capability()
capability.update({"ready": (self.trainer_root / "scripts" / "evaluate.py").is_file()})
return capability
@staticmethod
def _integer(payload: dict, name: str, default: int, minimum: int, maximum: int) -> int:
value = payload.get(name, default)
if isinstance(value, bool) or not isinstance(value, int) or not minimum <= value <= maximum:
raise TuningError(f"{name} 必须在 {minimum}–{maximum} 之间")
return value
def parse_create(self, payload: Any) -> tuple[str, dict, dict, bool]:
if not isinstance(payload, dict):
raise TuningError("请求体必须是 JSON 对象")
mode = payload.get("mode", "automatic")
if mode not in ("automatic", "approval"):
raise TuningError("mode 必须是 automatic 或 approval")
if payload.get("taskId", "Unitree-Go2-Flat") != "Unitree-Go2-Flat":
raise TuningError("第一版只支持 Unitree-Go2-Flat")
run_name = payload.get("runName", "auto-tune")
if not isinstance(run_name, str) or not RUN_NAME.fullmatch(run_name):
raise TuningError("runName 格式无效")
gpu_ids = payload.get("gpuIds", [0])
if (
not isinstance(gpu_ids, list)
or not gpu_ids
or any(
isinstance(value, bool) or not isinstance(value, int) or not 0 <= value <= 255
for value in gpu_ids
)
):
raise TuningError("gpuIds 必须是非空非负整数数组")
trial_count = self._integer(payload, "trialCount", 12, 1, 100)
rung0 = self._integer(payload, "initialIterations", 300, 1, 1000000)
rung1 = self._integer(payload, "middleIterations", 900, rung0, 1000000)
rung2 = self._integer(payload, "finalIterations", 2000, rung1, 1000000)
config = {
"taskId": "Unitree-Go2-Flat",
"numEnvs": self._integer(payload, "numEnvs", 4096, 1, 16384),
"seed": self._integer(payload, "seed", 42, 0, 2147483647),
"runName": run_name,
"gpuIds": gpu_ids,
"trialCount": trial_count,
"rungs": [rung0, rung1, rung2],
"promote": [trial_count, min(4, trial_count), min(2, trial_count)],
"evalNumEnvs": self._integer(payload, "evalNumEnvs", 256, 1, 4096),
"evalSteps": self._integer(payload, "evalSteps", 1000, 10, 100000),
"earlyStopPatience": self._integer(payload, "earlyStopPatience", 4, 1, 20),
}
objective = validate_objective_weights(
payload.get("objectiveWeights", DEFAULT_OBJECTIVE_WEIGHTS)
)
fallback = payload.get("fallbackEnabled", False)
if not isinstance(fallback, bool):
raise TuningError("fallbackEnabled 必须是布尔值")
return mode, config, objective, fallback
def create(self, payload: Any) -> dict:
mode, config, objective, fallback = self.parse_create(payload)
capability = self.capability()
if not capability["ready"]:
raise TuningError("评估入口未就绪")
if not capability["configured"] and not fallback:
raise TuningError("DeepSeek Agent 未配置;设置 DEEPSEEK_API_KEY 或显式启用 fallback")
with self.lock:
active = [
session
for session in self.storage.list_sessions()
if session["state"] in ACTIVE_SESSION_STATES
]
if active:
raise ResourceBusyError("已有调参 session 未结束")
session = self.storage.create_session(mode, config, objective, fallback)
self._start_worker(session["id"], resume=False)
return self.detail(session["id"])
def _start_worker(self, session_id: str, resume: bool) -> None:
cancel = threading.Event()
self.cancel_events[session_id] = cancel
worker = threading.Thread(
target=self._run_session,
args=(session_id, resume, cancel),
name=f"tuning-{session_id[:8]}",
daemon=True,
)
self.workers[session_id] = worker
worker.start()
def detail(self, session_id: str) -> dict:
session = self.storage.get_session(session_id)
session["trials"] = self.storage.list_trials(session_id)
session["proposals"] = self.storage.list_proposals(session_id)
session["audit"] = self.storage.audit_events(session_id)
control = self.storage.get_control(session_id)
current = next(
(trial for trial in session["trials"] if trial["id"] == session["currentTrialId"]),
None,
)
control["effectiveAfterCurrent"] = bool(
current and current["state"] in {"training", "evaluating"}
)
session["control"] = control
return session
def list(self) -> list[dict]:
return self.storage.list_sessions()
def _session_root(self, session_id: str) -> Path:
root = (self.data_root / "sessions" / session_id).resolve()
if not root.is_relative_to(self.data_root):
raise TuningError("非法 session 路径")
root.mkdir(parents=True, exist_ok=True)
return root
def _run_command(
self,
session_id: str,
command: list[str],
cwd: Path,
environment: dict[str, str],
log_path: Path,
) -> int:
owner = f"tuning:{session_id}"
self.lease.acquire(owner)
try:
process = subprocess.Popen(
command,
cwd=cwd,
env=environment,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="replace",
bufsize=1,
start_new_session=True,
)
with self.lock:
self.processes[session_id] = process
assert process.stdout is not None
with log_path.open("a", encoding="utf-8") as log:
for line in process.stdout:
log.write(line)
log.flush()
if self.cancel_events[session_id].is_set():
terminate_process(process)
break
return process.wait()
finally:
with self.lock:
self.processes.pop(session_id, None)
self.lease.release(owner)
@staticmethod
def _latest_checkpoint(run_dir: Path) -> Path | None:
values = []
for path in run_dir.glob("model_*.pt"):
match = re.fullmatch(r"model_(\d+)\.pt", path.name)
if match:
values.append((int(match.group(1)), path))
return max(values, default=(0, None), key=lambda value: value[0])[1]
def _execute_trial(
self, session: dict, trial: dict, resume_checkpoint: Path | None = None
) -> dict:
session_id, trial_id = session["id"], trial["id"]
root = self._session_root(session_id)
run_dir = (root / trial["runDir"]).resolve()
if not run_dir.is_relative_to(root):
raise TuningError("trial 目录越界")
run_dir.mkdir(parents=True, exist_ok=True)
reward_path = run_dir / "reward_config.json"
reward_path.write_text(
json.dumps(trial["rewardConfig"], ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
config = session["config"]
command = [
self.python,
"-u",
"scripts/train.py",
config["taskId"],
f"--env.scene.num-envs={config['numEnvs']}",
f"--agent.max-iterations={trial['targetIterations']}",
f"--agent.seed={config['seed']}",
f"--agent.run-name={config['runName']}-t{trial['number']}-r{trial['rung']}",
"--agent.logger=tensorboard",
"--agent.upload-model=False",
"--gpu-ids",
json.dumps(config["gpuIds"], separators=(",", ":")),
"--output-dir",
str(run_dir),
"--reward-config",
str(reward_path),
]
if resume_checkpoint is not None:
command.extend(("--resume-checkpoint", str(resume_checkpoint)))
environment = os.environ.copy()
environment["WANDB_MODE"] = "disabled"
environment["WANDB_SILENT"] = "true"
self.storage.update_trial(
trial_id, state="training", started_at=now_iso(), message="正在训练"
)
self.storage.update_session(
session_id,
state="running",
message=f"正在训练 trial {trial['number']} / rung {trial['rung']}",
)
return_code = self._run_command(
session_id, command, self.trainer_root, environment, run_dir / "train.log"
)
ingest_scalars(self.storage, trial_id, run_dir)
if self.cancel_events[session_id].is_set():
raise TuningError("session 已取消")
checkpoint = self._latest_checkpoint(run_dir)
policy = run_dir / "policy.onnx"
if return_code != 0 or checkpoint is None or not policy.is_file():
raise TuningError(f"训练失败(返回码 {return_code})或缺少 checkpoint/policy.onnx")
# 暂停只关闭后续 Trial 调度门;已经开始的 Trial 必须连同固定评估一起
# 完成,避免把一次单步令牌错误地消耗在半个 Trial 上。
eval_output = run_dir / "evaluation.json"
eval_command = [
self.python,
"-u",
"scripts/evaluate.py",
config["taskId"],
"--checkpoint",
str(checkpoint),
"--output",
str(eval_output),
"--reward-config",
str(reward_path),
f"--num-envs={config['evalNumEnvs']}",
f"--steps-per-seed={config['evalSteps']}",
"--gpu-ids",
json.dumps(config["gpuIds"], separators=(",", ":")),
]
self.storage.update_trial(trial_id, state="evaluating", message="正在固定协议评估")
self.storage.update_session(
session_id, state="evaluating", message=f"正在评估 trial {trial['number']}"
)
return_code = self._run_command(
session_id, eval_command, self.trainer_root, environment, run_dir / "evaluate.log"
)
ingest_scalars(self.storage, trial_id, run_dir / "evaluation-events")
if return_code != 0 or not eval_output.is_file():
raise TuningError(f"评估失败(返回码 {return_code})")
evaluation = json.loads(eval_output.read_text(encoding="utf-8"))
baseline_trial = self.storage.list_trials(session_id)[0]
if baseline_trial["evaluation"] is None:
scored = {
"eligible": True,
"score": 0.0,
"components": {},
"metrics": evaluation["metrics"],
}
else:
scored = score_evaluation(
baseline_trial["evaluation"]["metrics"],
evaluation["metrics"],
session["objectiveWeights"],
)
evaluation["score"] = scored
rel_checkpoint = str(checkpoint.relative_to(root))
rel_policy = str(policy.relative_to(root))
self.storage.update_trial(
trial_id,
state="completed",
ended_at=now_iso(),
message="训练与评估完成",
checkpoint_path=rel_checkpoint,
policy_path=rel_policy,
evaluation=evaluation,
score=scored["score"],
eligible=scored["eligible"],
)
best = self._best_highest_rung(session_id)
if best is not None:
self.storage.update_session(session_id, best_trial_id=best["id"])
try:
optuna_number = self.studies.record(
session_id,
trial["rewardConfig"],
scored["score"],
scored["eligible"],
trial["rung"],
)
self.storage.audit(
session_id, "optuna_trial_recorded", {"trialId": trial_id, "number": optuna_number}
)
except Exception as error:
self.storage.audit(
session_id, "optuna_record_failed", {"trialId": trial_id, "error": str(error)[:500]}
)
return self.storage.get_trial(trial_id)
def _best(self, session_id: str, rung: int | None = None) -> dict | None:
trials = [
trial
for trial in self.storage.list_trials(session_id)
if trial["state"] == "completed" and trial["eligible"]
]
if rung is not None:
trials = [trial for trial in trials if trial["rung"] == rung]
return max(
trials,
key=lambda trial: trial["score"] if trial["score"] is not None else -999,
default=None,
)
def _best_highest_rung(self, session_id: str) -> dict | None:
for rung in (2, 1, 0):
best = self._best(session_id, rung=rung)
if best is not None:
return best
return None
def _proposal_context(self, session: dict) -> dict:
trials = self.storage.list_trials(session["id"])[-12:]
rejected_feedback = [
proposal["feedback"]
for proposal in self.storage.list_proposals(session["id"])
if proposal["state"] == "rejected" and proposal["feedback"]
][-4:]
return {
"task": session["config"]["taskId"],
"objectiveWeights": session["objectiveWeights"],
"allowlist": "服务端将验证固定 schema;最多四项修改",
"parameterConstraints": self.storage.get_control(session["id"])["constraints"],
"rejectedFeedback": rejected_feedback,
"trials": [
{
"number": t["number"],
"rung": t["rung"],
"score": t["score"],
"eligible": t["eligible"],
"rewardConfig": t["rewardConfig"],
"evaluation": t["evaluation"] and t["evaluation"].get("metrics"),
}
for t in trials
],
}
def _fallback_patch(self, previous: dict, index: int) -> dict:
names = ("track_linear_velocity", "action_rate_l2", "body_orientation_l2", "foot_slip")
name = names[index % len(names)]
old = previous["weights"][name]
factor = 1.1 if index % 2 == 0 else 0.9
return validate_proposal({"weights": {name: old * factor}}, previous)
def _request_proposal(
self, session: dict, previous: dict, base_trial_id: str, index: int
) -> dict:
try:
result = self.advisor.propose(self._proposal_context(session), previous)
source = "agent"
except Exception as error:
if not session["fallbackEnabled"]:
raise AdvisorUnavailable(str(error)) from error
result = {
"patch": self._fallback_patch(previous, index),
"rationale": f"Agent 不可用,显式 fallback:{error}",
"expectedImpact": {},
"confidence": 0.2,
"promptHash": None,
"usage": {},
"model": "optuna-fallback",
}
source = "fallback"
constraints = self.storage.get_control(session["id"])["constraints"]
result["patch"] = validate_proposal(result["patch"], previous, constraints)
proposal = self.storage.create_proposal(
session["id"],
base_trial_id,
result["patch"],
result["rationale"],
result.get("expectedImpact", {}),
result["confidence"],
source,
)
self.storage.audit(
session["id"],
"proposal_created",
{
"proposalId": proposal["id"],
"source": source,
"promptHash": result.get("promptHash"),
"usage": result.get("usage", {}),
"model": result.get("model"),
},
)
return proposal
def _wait_for_approval(
self, session_id: str, proposal_id: str, cancel: threading.Event
) -> dict:
with self.condition:
while not cancel.is_set():
proposal = self.storage.get_proposal(proposal_id)
if proposal["state"] != "pending":
return proposal
self.condition.wait(timeout=1.0)
raise TuningError("session 已取消")
def _run_session(self, session_id: str, resume: bool, cancel: threading.Event) -> None:
try:
session = self.storage.get_session(session_id)
trials = self.storage.list_trials(session_id)
if resume:
root = self._session_root(session_id)
for interrupted in [trial for trial in trials if trial["state"] == "interrupted"]:
run_dir = (root / interrupted["runDir"]).resolve()
if run_dir.is_relative_to(root):
shutil.rmtree(run_dir, ignore_errors=True)
self.storage.delete_trial(interrupted["id"])
self.storage.audit(
session_id,
"session_resumed",
{
"discardedInterruptedTrials": [
t["id"] for t in trials if t["state"] == "interrupted"
]
},
)
trials = self.storage.list_trials(session_id)
if not trials:
baseline_dir = "trial-000-rung-0"
trial = self.storage.create_trial(
session_id,
0,
0,
session["config"]["rungs"][0],
deepcopy(BASE_REWARD_CONFIGURATION),
None,
baseline_dir,
)
self._execute_trial(session, trial)
session = self.storage.get_session(session_id)
completed_rung0 = [
t
for t in self.storage.list_trials(session_id)
if t["rung"] == 0 and t["state"] == "completed"
]
next_number = len({t["number"] for t in completed_rung0})
best_score = max(
(t["score"] if t["score"] is not None else float("-inf") for t in completed_rung0),
default=0.0,
)
no_improve = 0
while (
next_number < session["config"]["trialCount"]
and no_improve < session["config"]["earlyStopPatience"]
):
if cancel.is_set():
raise TuningError("session 已取消")
self._wait_for_dispatch(session_id, cancel)
control = self.storage.get_control(session_id)
base = (
self.storage.get_trial(control["activeBaseTrialId"])
if control["activeBaseTrialId"]
else self._best(session_id, rung=0) or completed_rung0[0]
)
proposal = self._request_proposal(
session, base["rewardConfig"], base["id"], next_number
)
# 模式允许在 session 运行期间切换,因此每次决策都读取最新持久化值。
current_mode = self.storage.get_session(session_id)["mode"]
if current_mode == "approval":
self.storage.update_session(
session_id, state="awaiting_approval", message="等待批准 Agent 建议"
)
proposal = self._wait_for_approval(session_id, proposal["id"], cancel)
if proposal["state"] == "rejected":
self.storage.audit(
session_id,
"proposal_rejected",
{"proposalId": proposal["id"], "feedback": proposal["feedback"]},
)
continue
else:
self.storage.decide_proposal(proposal["id"], "approved", "自动模式")
proposal = self.storage.get_proposal(proposal["id"])
self._claim_dispatch(session_id, cancel)
constraints = self.storage.get_control(session_id)["constraints"]
reward_config = merge_proposal(base["rewardConfig"], proposal["patch"], constraints)
trial = self.storage.create_trial(
session_id,
next_number,
0,
session["config"]["rungs"][0],
reward_config,
proposal["id"],
f"trial-{next_number:03d}-rung-0",
)
result = self._execute_trial(session, trial)
if control["activeBaseTrialId"]:
self.storage.set_active_base(session_id, None)
self._pause_after_step(session_id)
result_score = result["score"]
if (
result["eligible"]
and result_score is not None
and result_score > best_score + 0.01
):
best_score = result_score
no_improve = 0
else:
no_improve += 1
self.storage.update_session(session_id, consecutive_no_improve=no_improve)
next_number += 1
# Promote top configurations; each new rung resumes its own previous checkpoint.
for rung in (1, 2):
previous = [
t
for t in self.storage.list_trials(session_id)
if t["rung"] == rung - 1 and t["state"] == "completed" and t["eligible"]
]
previous.sort(
key=lambda trial: (
trial["score"] if trial["score"] is not None else float("-inf")
),
reverse=True,
)
promoted_numbers = {
trial["number"]
for trial in self.storage.list_trials(session_id)
if trial["rung"] == rung and trial["state"] == "completed"
}
for parent in previous[: session["config"]["promote"][rung]]:
if parent["number"] in promoted_numbers:
continue
if cancel.is_set():
raise TuningError("session 已取消")
self._claim_dispatch(session_id, cancel)
root = self._session_root(session_id)
checkpoint = root / parent["checkpointPath"]
trial = self.storage.create_trial(
session_id,
parent["number"],
rung,
session["config"]["rungs"][rung],
parent["rewardConfig"],
parent["proposalId"],
f"trial-{parent['number']:03d}-rung-{rung}",
)
self._execute_trial(session, trial, checkpoint)
self._pause_after_step(session_id)
best = self._best_highest_rung(session_id)
if best is None:
raise TuningError("没有通过安全门槛的 trial")
preset_name = f"{session['config']['runName']}-{session_id[:8]}"
self.storage.save_preset(preset_name, session_id, best["id"], best["rewardConfig"])
self.storage.update_session(
session_id,
state="succeeded",
best_trial_id=best["id"],
current_trial_id=None,
message="调参完成",
)
self.storage.audit(
session_id, "session_completed", {"bestTrialId": best["id"], "preset": preset_name}
)
except Exception as error:
state = self.storage.get_session(session_id)["state"]
if cancel.is_set() or state == "cancelled":
self.storage.update_session(
session_id, state="cancelled", message="调参已取消", current_trial_id=None
)
else:
self.storage.update_session(
session_id, state="failed", message=str(error), current_trial_id=None
)
self.storage.audit(session_id, "session_failed", {"error": str(error)[:1000]})
finally:
with self.lock:
self.workers.pop(session_id, None)
self.processes.pop(session_id, None)
def _wait_for_dispatch(self, session_id: str, cancel: threading.Event) -> None:
"""Wait at a scheduler boundary without consuming a one-shot token."""
with self.condition:
while not cancel.is_set():
session = self.storage.get_session(session_id)
control = self.storage.get_control(session_id)
if session["state"] == "paused":
self.condition.wait(timeout=1.0)
continue
if control["runPolicy"] == "continuous" or control["dispatchTokens"] > 0:
return
self.storage.update_session(
session_id,
state="paused",
message="单步 Trial 已完成;等待下一个调度令牌",
)
self.storage.audit(session_id, "step_gate_waiting", {})
self.condition.wait(timeout=1.0)
raise TuningError("session 已取消")
def _claim_dispatch(self, session_id: str, cancel: threading.Event) -> None:
while not cancel.is_set():
self._wait_for_dispatch(session_id, cancel)
if self.storage.use_dispatch_token(session_id):
return
raise TuningError("session 已取消")
def _pause_after_step(self, session_id: str) -> None:
control = self.storage.get_control(session_id)
if control["runPolicy"] == "step" and control["dispatchTokens"] == 0:
self.storage.update_session(
session_id,
state="paused",
message="单步 Trial 已完成;后续调度已暂停",
)
self.storage.audit(session_id, "step_trial_completed", {})
def approve(self, session_id: str, proposal_id: str, payload: Any) -> dict:
proposal = self.storage.get_proposal(proposal_id)
if proposal["sessionId"] != session_id:
raise TuningError("proposal 不属于该 session")
patch = proposal["patch"]
feedback = None
base = self.storage.get_trial(proposal["baseTrialId"])
constraints = self.storage.get_control(session_id)["constraints"]
if isinstance(payload, dict):
feedback = payload.get("feedback")
if "patch" in payload:
patch = payload["patch"]
if feedback is not None and (not isinstance(feedback, str) or len(feedback) > 2000):
raise TuningError("feedback 无效")
patch = validate_proposal(patch, base["rewardConfig"], constraints)
if not self.storage.decide_proposal(proposal_id, "approved", feedback, patch):
raise TuningError("proposal 已处理")
self.storage.audit(
session_id,
"proposal_approved",
{"proposalId": proposal_id, "modified": patch != proposal["patch"]},
)
with self.condition:
self.condition.notify_all()
return self.detail(session_id)
def reject(self, session_id: str, proposal_id: str, payload: Any) -> dict:
feedback = payload.get("feedback", "") if isinstance(payload, dict) else ""
if not isinstance(feedback, str) or len(feedback) > 2000:
raise TuningError("feedback 无效")
proposal = self.storage.get_proposal(proposal_id)
if proposal["sessionId"] != session_id:
raise TuningError("proposal 不属于该 session")
if not self.storage.decide_proposal(proposal_id, "rejected", feedback):
raise TuningError("proposal 已处理")
with self.condition:
self.condition.notify_all()
return self.detail(session_id)
def set_mode(self, session_id: str, payload: Any) -> dict:
if not isinstance(payload, dict) or payload.get("mode") not in ("automatic", "approval"):
raise TuningError("mode 必须是 automatic 或 approval")
mode = payload["mode"]
with self.condition:
session = self.storage.get_session(session_id)
if session["state"] not in RUNNING_STATES | {"awaiting_approval", "paused"}:
raise TuningError("当前状态不能切换运行模式")
previous = session["mode"]
if previous == mode:
return self.detail(session_id)
self.storage.update_session(session_id, mode=mode)
approved_ids = []
if mode == "automatic":
constraints = self.storage.get_control(session_id)["constraints"]
for proposal in self.storage.list_proposals(session_id):
if proposal["state"] != "pending":
continue
base = self.storage.get_trial(proposal["baseTrialId"])
try:
validate_proposal(proposal["patch"], base["rewardConfig"], constraints)
except Exception as error:
self.storage.decide_proposal(
proposal["id"], "rejected", f"参数护栏已变化:{error}"
)
continue
if self.storage.decide_proposal(
proposal["id"], "approved", "运行时切换为全自动模式"
):
approved_ids.append(proposal["id"])
if session["state"] == "awaiting_approval":
self.storage.update_session(
session_id, state="running", message="已切换为全自动模式,继续调参"
)
self.storage.audit(
session_id,
"session_mode_changed",
{"from": previous, "to": mode, "autoApprovedProposalIds": approved_ids},
)
self.condition.notify_all()
return self.detail(session_id)
def set_constraints(self, session_id: str, payload: Any) -> dict:
if not isinstance(payload, dict) or set(payload) != {"revision", "constraints"}:
raise TuningError("参数护栏请求必须包含 revision 与 constraints")
revision = payload["revision"]
if isinstance(revision, bool) or not isinstance(revision, int) or revision < 0:
raise TuningError("constraints revision 必须是非负整数")
constraints = validate_constraints(payload["constraints"])
session = self.storage.get_session(session_id)
if session["state"] not in ACTIVE_SESSION_STATES:
raise TuningError("终态 session 不能修改参数护栏")
control = self.storage.get_control(session_id)
base = None
if control["activeBaseTrialId"]:
base = self.storage.get_trial(control["activeBaseTrialId"])
elif session["currentTrialId"]:
base = self.storage.get_trial(session["currentTrialId"])
else:
base = self._best_highest_rung(session_id)
if base is not None:
validate_configuration_constraints(base["rewardConfig"], constraints)
try:
updated = self.storage.replace_constraints(session_id, revision, constraints)
except StorageConflict as error:
raise ResourceBusyError(str(error)) from error
rejected = []
for proposal in self.storage.list_proposals(session_id):
if proposal["state"] != "pending":
continue
proposal_base = self.storage.get_trial(proposal["baseTrialId"])
try:
validate_proposal(proposal["patch"], proposal_base["rewardConfig"], constraints)
except Exception as error:
message = f"参数护栏 revision {updated['constraintsRevision']}:{error}"
if self.storage.decide_proposal(proposal["id"], "rejected", message):
rejected.append(proposal["id"])
self.storage.audit(
session_id,
"constraints_updated",
{
"revision": updated["constraintsRevision"],
"paths": sorted(constraints),
"rejectedProposalIds": rejected,
},
)
if rejected:
with self.condition:
self.condition.notify_all()
return self.detail(session_id)
def step(self, session_id: str, payload: Any) -> dict:
if not isinstance(payload, dict) or payload.get("count", 1) != 1:
raise TuningError("单步调度一次只能发放 1 个 Trial 令牌")
session = self.storage.get_session(session_id)
if session["state"] not in {"paused", "awaiting_approval"}:
raise TuningError("请先暂停或等待 Proposal 审批,再执行单步 Trial")
control = self.storage.get_control(session_id)
if control["dispatchTokens"] > 0:
raise TuningError("已有未消费的单步 Trial 令牌")
try:
self.storage.grant_dispatch_token(session_id)
except StorageConflict as error:
raise ResourceBusyError(str(error)) from error
if session["state"] == "paused":
has_pending = any(
proposal["state"] == "pending"
for proposal in self.storage.list_proposals(session_id)
)
self.storage.update_session(
session_id,
state="awaiting_approval" if has_pending else "running",
message="单步令牌已就绪;请审批 Proposal"
if has_pending
else "已授权执行一个 Trial",
)
self.storage.audit(session_id, "step_token_granted", {"count": 1})
with self.condition:
self.condition.notify_all()
return self.detail(session_id)
def rollback(self, session_id: str, payload: Any) -> dict:
if not isinstance(payload, dict):
raise TuningError("rollback 请求体必须是对象")
session = self.storage.get_session(session_id)
if session["state"] not in {"paused", "awaiting_approval"}:
raise TuningError("回滚只能在安全暂停或等待审批时执行")
target_id = payload.get("trialId")
if payload.get("target") == "best":
target_id = session["bestTrialId"] or (self._best_highest_rung(session_id) or {}).get(
"id"
)
if not isinstance(target_id, str):
raise TuningError("rollback 必须指定 trialId 或 target=best")
target = self.storage.get_trial(target_id)
if target["sessionId"] != session_id:
raise TuningError("rollback Trial 不属于该 session")
if target["state"] != "completed" or not target["eligible"]:
raise TuningError("只能回滚到已完成且通过安全门槛的 Trial")
checkpoint = payload.get("checkpoint", False)
if not isinstance(checkpoint, bool):
raise TuningError("checkpoint 必须是布尔值")
if checkpoint:
path_value = target.get("checkpointPath")
if not path_value:
raise TuningError("目标 Trial 没有可用 checkpoint")
root = self._session_root(session_id)
path = (root / path_value).resolve()
if not path.is_relative_to(root) or not path.is_file():
raise TuningError("目标 checkpoint 不存在或路径非法")
superseded = []
for proposal in self.storage.list_proposals(session_id):
if proposal["state"] == "pending" and self.storage.decide_proposal(
proposal["id"], "rejected", f"由回滚到 Trial {target_id[:8]} 取代"
):
superseded.append(proposal["id"])
self.storage.set_active_base(session_id, target_id)
self.storage.reset_step_gate(session_id)
self.storage.update_session(
session_id,
state="paused",
message=f"已回滚到 Trial {target['number']} / Rung {target['rung']};等待单步或继续",
)
self.storage.audit(
session_id,
"rollback_selected",
{
"trialId": target_id,
"checkpoint": checkpoint,
"supersededProposalIds": superseded,
},
)
with self.condition:
self.condition.notify_all()
return self.detail(session_id)
def pause(self, session_id: str) -> dict:
session = self.storage.get_session(session_id)
if session["state"] not in RUNNING_STATES | {"awaiting_approval"}:
raise TuningError("当前状态不能暂停")
# 以持久化调度门记录暂停意图,而不是只依赖 session.state。当前 Trial
# 的训练/评估会继续完成,下一次 _claim_dispatch 必须等待显式继续或单步。
self.storage.reset_step_gate(session_id)
self.storage.update_session(
session_id, state="paused", message="已暂停后续调度;在途 Trial(如有)将完整结束"
)
return self.detail(session_id)
def resume(self, session_id: str) -> dict:
session = self.storage.get_session(session_id)
if session["state"] == "paused":
self.storage.set_run_policy(session_id, "continuous")
has_pending = any(
proposal["state"] == "pending"
for proposal in self.storage.list_proposals(session_id)
)
self.storage.update_session(
session_id,
state="awaiting_approval" if has_pending else "running",
message="请审批待处理 Proposal" if has_pending else "连续调参已恢复",
)
with self.condition:
self.condition.notify_all()
elif session["state"] == "interrupted":
self.storage.set_run_policy(session_id, "continuous")
self.storage.update_session(session_id, state="queued", message="从最近完整结果恢复")
self._start_worker(session_id, resume=True)
else:
raise TuningError("当前状态不能恢复")
return self.detail(session_id)
def cancel(self, session_id: str) -> dict:
session = self.storage.get_session(session_id)
if session["state"] not in ACTIVE_SESSION_STATES:
return self.detail(session_id)
self.storage.update_session(session_id, state="cancelled", message="正在取消")
event = self.cancel_events.get(session_id)
if event:
event.set()
process = self.processes.get(session_id)
if process:
terminate_process(process)
with self.condition:
self.condition.notify_all()
return self.detail(session_id)
def metrics(
self,
session_id: str,
trial_id: str,
tags: list[str] | None,
max_points: int,
after_step: int | None = None,
) -> dict:
trial = self.storage.get_trial(trial_id)
if trial["sessionId"] != session_id:
raise TuningError("trial 不属于该 session")
series = self.storage.metrics(trial_id, tags, max_points, after_step)
next_step = max(
(point["step"] for item in series for point in item["points"]),
default=after_step,
)
return {"trialId": trial_id, "series": series, "nextStep": next_step}
def best_artifact(self, session_id: str) -> Path:
session = self.storage.get_session(session_id)
if not session["bestTrialId"]:
raise TuningError("尚无最佳策略")
trial = self.storage.get_trial(session["bestTrialId"])
if not trial["policyPath"]:
raise TuningError("最佳策略文件不存在")
root = self._session_root(session_id)
path = (root / trial["policyPath"]).resolve()
if not path.is_relative_to(root) or not path.is_file():
raise TuningError("最佳策略文件不存在")
return path
def preset_config(self, preset_id: str) -> dict:
return self.storage.get_preset(preset_id)["rewardConfig"]
def test_agent(self) -> dict:
try:
return self.advisor.test_connection()
except Exception as error:
raise TuningError(f"Agent 连接测试失败:{error}") from error
def shutdown(self) -> None:
for session_id in list(self.workers):
with suppress(KeyError, TuningError):
self.cancel(session_id)
for worker in list(self.workers.values()):
worker.join(timeout=7)
+46
View File
@@ -0,0 +1,46 @@
"""Shared GPU lease and process-group lifecycle helpers."""
from __future__ import annotations
import os
import signal
import subprocess
import threading
from contextlib import suppress
class ResourceBusyError(RuntimeError):
pass
class GpuLease:
def __init__(self):
self.lock = threading.RLock()
self.owner: str | None = None
def acquire(self, owner: str) -> None:
with self.lock:
if self.owner is not None and self.owner != owner:
raise ResourceBusyError(f"计算资源正由 {self.owner} 使用")
self.owner = owner
def release(self, owner: str) -> None:
with self.lock:
if self.owner == owner:
self.owner = None
def public(self) -> str | None:
with self.lock:
return self.owner
def terminate_process(process: subprocess.Popen[str], grace_seconds: float = 5.0) -> None:
if process.poll() is not None:
return
with suppress(ProcessLookupError):
os.killpg(process.pid, signal.SIGTERM)
try:
process.wait(timeout=grace_seconds)
except subprocess.TimeoutExpired:
with suppress(ProcessLookupError):
os.killpg(process.pid, signal.SIGKILL)
+281
View File
@@ -0,0 +1,281 @@
"""Pure-Python reward tuning schema shared by the service and trainer."""
from __future__ import annotations
import math
from collections.abc import Mapping
from copy import deepcopy
from dataclasses import dataclass
from typing import Any
MAX_PROPOSAL_CHANGES = 4
MIN_CHANGE_RATIO = 0.5
MAX_CHANGE_RATIO = 2.0
class RewardConfigError(ValueError):
"""A reward configuration or proposal violated the allowlist."""
@dataclass(frozen=True)
class NumericSpec:
minimum: float
maximum: float
default: float
allow_zero: bool = True
WEIGHT_SPECS: dict[str, NumericSpec] = {
"track_linear_velocity": NumericSpec(0.5, 3.0, 1.0, False),
"track_angular_velocity": NumericSpec(0.25, 2.0, 1.0, False),
"body_orientation_l2": NumericSpec(-3.0, -0.1, -1.0, False),
"pose": NumericSpec(0.0, 2.5, 1.0),
"body_ang_vel": NumericSpec(-0.2, 0.0, -0.05),
"angular_momentum": NumericSpec(-0.1, 0.0, -0.025),
"is_terminated": NumericSpec(-400.0, -50.0, -200.0, False),
"joint_acc_l2": NumericSpec(-2.0e-6, 0.0, -2.5e-7),
"joint_pos_limits": NumericSpec(-30.0, -2.0, -10.0, False),
"action_rate_l2": NumericSpec(-0.2, -0.005, -0.05, False),
"foot_gait": NumericSpec(0.0, 1.5, 0.5),
"foot_clearance": NumericSpec(-3.0, 0.0, -1.0),
"foot_slip": NumericSpec(-1.0, 0.0, -0.25),
"soft_landing": NumericSpec(-5.0e-3, 0.0, -1.0e-3),
"stand_still": NumericSpec(-3.0, 0.0, -1.0),
"electrical_power": NumericSpec(-5.0e-3, 0.0, 0.0),
}
PARAMETER_SPECS: dict[str, NumericSpec] = {
"track_linear_velocity.std": NumericSpec(0.25, 1.0, math.sqrt(0.25), False),
"track_angular_velocity.std": NumericSpec(0.35, 1.2, math.sqrt(0.5), False),
"pose.std_standing_scale": NumericSpec(0.5, 2.0, 1.0, False),
"pose.std_walking_scale": NumericSpec(0.5, 2.0, 1.0, False),
"pose.std_running_scale": NumericSpec(0.5, 2.0, 1.0, False),
"pose.walking_threshold": NumericSpec(0.05, 0.5, 0.1, False),
"pose.running_threshold": NumericSpec(1.0, 2.5, 1.5, False),
"foot_gait.period": NumericSpec(0.4, 0.8, 0.6, False),
"foot_gait.threshold": NumericSpec(0.45, 0.65, 0.56, False),
"foot_gait.command_threshold": NumericSpec(0.02, 0.3, 0.1, False),
"foot_clearance.target_height": NumericSpec(0.06, 0.16, 0.1, False),
"foot_clearance.command_threshold": NumericSpec(0.02, 0.3, 0.1, False),
"foot_slip.command_threshold": NumericSpec(0.02, 0.3, 0.1, False),
"soft_landing.command_threshold": NumericSpec(0.02, 0.3, 0.1, False),
"stand_still.command_threshold": NumericSpec(0.02, 0.3, 0.1, False),
}
BASE_REWARD_CONFIGURATION: dict[str, dict[str, float]] = {
"weights": {name: spec.default for name, spec in WEIGHT_SPECS.items()},
"params": {name: spec.default for name, spec in PARAMETER_SPECS.items()},
}
def _number(name: str, value: Any, spec: NumericSpec) -> float:
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise RewardConfigError(f"{name} 必须是数值")
result = float(value)
if not math.isfinite(result):
raise RewardConfigError(f"{name} 必须是有限数值")
if result == 0.0 and not spec.allow_zero:
raise RewardConfigError(f"{name} 不允许关闭")
if result < spec.minimum or result > spec.maximum:
raise RewardConfigError(f"{name} 必须在 {spec.minimum}–{spec.maximum} 之间")
return result
def _mapping(value: Any, name: str) -> Mapping[str, Any]:
if not isinstance(value, Mapping):
raise RewardConfigError(f"{name} 必须是对象")
return value
def _cross_validate(config: Mapping[str, Mapping[str, float]]) -> None:
params = config["params"]
if params["pose.walking_threshold"] >= params["pose.running_threshold"]:
raise RewardConfigError("pose.walking_threshold 必须小于 pose.running_threshold")
def _path_spec(path: str) -> tuple[str, str, NumericSpec]:
if path.startswith("weights."):
section, name = "weights", path.removeprefix("weights.")
spec = WEIGHT_SPECS.get(name)
elif path.startswith("params."):
section, name = "params", path.removeprefix("params.")
spec = PARAMETER_SPECS.get(name)
else:
section, name, spec = "", "", None
if spec is None:
raise RewardConfigError(f"未知参数约束:{path}")
return section, name, spec
def validate_constraints(value: Any) -> dict[str, dict[str, float | str]]:
"""Validate sparse per-session range/fixed safety constraints."""
root = _mapping(value, "constraints")
if len(root) > len(WEIGHT_SPECS) + len(PARAMETER_SPECS):
raise RewardConfigError("constraints 数量超过白名单参数总数")
result: dict[str, dict[str, float | str]] = {}
for raw_path, raw_constraint in root.items():
if not isinstance(raw_path, str):
raise RewardConfigError("constraint path 必须是字符串")
_, _, spec = _path_spec(raw_path)
constraint = _mapping(raw_constraint, raw_path)
kind = constraint.get("kind")
if kind == "fixed":
if set(constraint) != {"kind", "value"}:
raise RewardConfigError(f"{raw_path} fixed 约束只能包含 kind/value")
fixed = _number(f"{raw_path}.value", constraint["value"], spec)
result[raw_path] = {"kind": "fixed", "value": fixed}
elif kind == "range":
if set(constraint) != {"kind", "min", "max"}:
raise RewardConfigError(f"{raw_path} range 约束只能包含 kind/min/max")
minimum = _number(f"{raw_path}.min", constraint["min"], spec)
maximum = _number(f"{raw_path}.max", constraint["max"], spec)
if minimum > maximum:
raise RewardConfigError(f"{raw_path} 下限不能大于上限")
result[raw_path] = {"kind": "range", "min": minimum, "max": maximum}
else:
raise RewardConfigError(f"{raw_path}.kind 必须是 range 或 fixed")
return result
def validate_configuration_constraints(value: Any, constraints: Any) -> None:
"""Ensure a complete reward configuration satisfies every session constraint."""
config = validate_configuration(value)
checked = validate_constraints(constraints)
for path, constraint in checked.items():
section, name, _ = _path_spec(path)
current = config[section][name]
if constraint["kind"] == "fixed":
if current != constraint["value"]:
raise RewardConfigError(
f"{path} 已固定为 {constraint['value']},不能设为 {current}"
)
elif current < constraint["min"] or current > constraint["max"]:
raise RewardConfigError(
f"{path}={current} 超出工程锁定范围 {constraint['min']}–{constraint['max']}"
)
def validate_configuration(value: Any) -> dict[str, dict[str, float]]:
"""Validate a complete configuration and reject missing/unknown fields."""
root = _mapping(value, "rewardConfig")
if set(root) != {"weights", "params"}:
raise RewardConfigError("rewardConfig 只能包含 weights 和 params")
raw_weights = _mapping(root["weights"], "weights")
raw_params = _mapping(root["params"], "params")
if set(raw_weights) != set(WEIGHT_SPECS):
raise RewardConfigError("weights 必须完整且不能包含未知奖励项")
if set(raw_params) != set(PARAMETER_SPECS):
raise RewardConfigError("params 必须完整且不能包含未知参数")
config = {
"weights": {
name: _number(f"weights.{name}", raw_weights[name], spec)
for name, spec in WEIGHT_SPECS.items()
},
"params": {
name: _number(f"params.{name}", raw_params[name], spec)
for name, spec in PARAMETER_SPECS.items()
},
}
_cross_validate(config)
return config
def validate_proposal(
value: Any, previous: Any, constraints: Any | None = None
) -> dict[str, dict[str, float]]:
"""Validate a sparse Agent patch relative to a complete previous config and guardrails."""
current = validate_configuration(previous)
root = _mapping(value, "proposal")
if not set(root).issubset({"weights", "params"}):
raise RewardConfigError("proposal 只能包含 weights 和 params")
raw_weights = _mapping(root.get("weights", {}), "weights")
raw_params = _mapping(root.get("params", {}), "params")
if len(raw_weights) + len(raw_params) == 0:
raise RewardConfigError("proposal 至少需要一项修改")
if len(raw_weights) + len(raw_params) > MAX_PROPOSAL_CHANGES:
raise RewardConfigError(f"proposal 每轮最多修改 {MAX_PROPOSAL_CHANGES} 项")
unknown_weights = set(raw_weights) - set(WEIGHT_SPECS)
unknown_params = set(raw_params) - set(PARAMETER_SPECS)
if unknown_weights:
raise RewardConfigError(f"未知奖励项:{', '.join(sorted(unknown_weights))}")
if unknown_params:
raise RewardConfigError(f"未知奖励参数:{', '.join(sorted(unknown_params))}")
patch: dict[str, dict[str, float]] = {"weights": {}, "params": {}}
for name, raw in raw_weights.items():
value_number = _number(f"weights.{name}", raw, WEIGHT_SPECS[name])
old = current["weights"][name]
if old != 0.0 and value_number != 0.0:
ratio = abs(value_number / old)
if ratio < MIN_CHANGE_RATIO or ratio > MAX_CHANGE_RATIO:
raise RewardConfigError(
f"weights.{name} 单轮变化必须在旧值幅度的 "
f"{MIN_CHANGE_RATIO}×–{MAX_CHANGE_RATIO}×"
)
if value_number == old:
raise RewardConfigError(f"weights.{name} 没有发生变化")
patch["weights"][name] = value_number
for name, raw in raw_params.items():
value_number = _number(f"params.{name}", raw, PARAMETER_SPECS[name])
old = current["params"][name]
ratio = abs(value_number / old)
if ratio < MIN_CHANGE_RATIO or ratio > MAX_CHANGE_RATIO:
raise RewardConfigError(
f"params.{name} 单轮变化必须在旧值的 {MIN_CHANGE_RATIO}×–{MAX_CHANGE_RATIO}×"
)
if value_number == old:
raise RewardConfigError(f"params.{name} 没有发生变化")
patch["params"][name] = value_number
candidate = deepcopy(current)
candidate["weights"].update(patch["weights"])
candidate["params"].update(patch["params"])
_cross_validate(candidate)
if constraints is not None:
validate_configuration_constraints(candidate, constraints)
return patch
def merge_proposal(
previous: Any, proposal: Any, constraints: Any | None = None
) -> dict[str, dict[str, float]]:
current = validate_configuration(previous)
patch = validate_proposal(proposal, current, constraints)
merged = deepcopy(current)
merged["weights"].update(patch["weights"])
merged["params"].update(patch["params"])
return validate_configuration(merged)
def apply_reward_configuration(env_cfg: Any, value: Any) -> None:
"""Apply a validated full config to a fresh mjlab environment config."""
config = validate_configuration(value)
for name, weight in config["weights"].items():
if name not in env_cfg.rewards:
raise RewardConfigError(f"环境缺少奖励项:{name}")
env_cfg.rewards[name].weight = weight
params = config["params"]
direct = {
"track_linear_velocity.std": ("track_linear_velocity", "std"),
"track_angular_velocity.std": ("track_angular_velocity", "std"),
"pose.walking_threshold": ("pose", "walking_threshold"),
"pose.running_threshold": ("pose", "running_threshold"),
"foot_gait.period": ("foot_gait", "period"),
"foot_gait.threshold": ("foot_gait", "threshold"),
"foot_gait.command_threshold": ("foot_gait", "command_threshold"),
"foot_clearance.target_height": ("foot_clearance", "target_height"),
"foot_clearance.command_threshold": ("foot_clearance", "command_threshold"),
"foot_slip.command_threshold": ("foot_slip", "command_threshold"),
"soft_landing.command_threshold": ("soft_landing", "command_threshold"),
"stand_still.command_threshold": ("stand_still", "command_threshold"),
}
for path, (term, parameter) in direct.items():
env_cfg.rewards[term].params[parameter] = params[path]
for regime in ("standing", "walking", "running"):
key = f"std_{regime}"
scale = params[f"pose.{key}_scale"]
baseline = env_cfg.rewards["pose"].params[key]
env_cfg.rewards["pose"].params[key] = {
pattern: float(std) * scale for pattern, std in baseline.items()
}
+118
View File
@@ -0,0 +1,118 @@
"""Stable, reward-weight-independent evaluation scoring."""
from __future__ import annotations
import math
from collections.abc import Mapping
from typing import Any
DEFAULT_OBJECTIVE_WEIGHTS = {
"velocity_tracking": 0.35,
"action_smoothness": 0.20,
"posture_stability": 0.15,
"fall_avoidance": 0.15,
"foot_slip": 0.10,
"energy": 0.05,
}
REQUIRED_METRICS = {
"linear_velocity_rmse",
"angular_velocity_rmse",
"mean_action_acc",
"orientation_error",
"fall_rate",
"slip_velocity",
"mechanical_power",
}
PHYSICAL_FLOORS = {
"linear_velocity_rmse": 0.10,
"angular_velocity_rmse": 0.10,
"mean_action_acc": 0.01,
"orientation_error": 0.05,
"fall_rate": 0.02,
"slip_velocity": 0.05,
"mechanical_power": 10.0,
}
class EvaluationError(ValueError):
pass
def validate_objective_weights(value: Any) -> dict[str, float]:
if not isinstance(value, Mapping) or set(value) != set(DEFAULT_OBJECTIVE_WEIGHTS):
raise EvaluationError("objectiveWeights 必须完整包含六个目标")
result: dict[str, float] = {}
for key in DEFAULT_OBJECTIVE_WEIGHTS:
raw = value[key]
if isinstance(raw, bool) or not isinstance(raw, (int, float)):
raise EvaluationError(f"objectiveWeights.{key} 必须是数值")
number = float(raw)
if not math.isfinite(number) or number < 0.0 or number > 1.0:
raise EvaluationError(f"objectiveWeights.{key} 必须在 0–1 之间")
result[key] = number
if not math.isclose(sum(result.values()), 1.0, abs_tol=1.0e-6):
raise EvaluationError("objectiveWeights 总和必须为 1")
return result
def validate_metrics(value: Any) -> dict[str, float]:
if not isinstance(value, Mapping):
raise EvaluationError("metrics 必须是对象")
missing = REQUIRED_METRICS - set(value)
if missing:
raise EvaluationError(f"metrics 缺少:{', '.join(sorted(missing))}")
result: dict[str, float] = {}
for key in REQUIRED_METRICS:
raw = value[key]
if isinstance(raw, bool) or not isinstance(raw, (int, float)):
raise EvaluationError(f"metrics.{key} 必须是数值")
number = float(raw)
if not math.isfinite(number) or number < 0.0:
raise EvaluationError(f"metrics.{key} 必须是非负有限数值")
result[key] = number
if result["fall_rate"] > 1.0:
raise EvaluationError("metrics.fall_rate 必须在 0–1 之间")
return result
def _improvement(baseline: Mapping[str, float], current: Mapping[str, float], key: str) -> float:
scale = max(abs(baseline[key]), PHYSICAL_FLOORS[key])
return max(-1.0, min(1.0, (baseline[key] - current[key]) / scale))
def score_evaluation(
baseline_value: Any,
current_value: Any,
objective_weights: Any = DEFAULT_OBJECTIVE_WEIGHTS,
) -> dict[str, Any]:
baseline = validate_metrics(baseline_value)
current = validate_metrics(current_value)
weights = validate_objective_weights(objective_weights)
components = {
"velocity_tracking": 0.8 * _improvement(baseline, current, "linear_velocity_rmse")
+ 0.2 * _improvement(baseline, current, "angular_velocity_rmse"),
"action_smoothness": _improvement(baseline, current, "mean_action_acc"),
"posture_stability": _improvement(baseline, current, "orientation_error"),
"fall_avoidance": _improvement(baseline, current, "fall_rate"),
"foot_slip": _improvement(baseline, current, "slip_velocity"),
"energy": _improvement(baseline, current, "mechanical_power"),
}
tracking_limit = max(
baseline["linear_velocity_rmse"] * 1.05, baseline["linear_velocity_rmse"] + 1.0e-6
)
eligible = (
current["fall_rate"] <= baseline["fall_rate"] + 0.02
and current["linear_velocity_rmse"] <= tracking_limit
)
total = sum(weights[key] * components[key] for key in weights)
if not eligible:
total = min(total, -1.0)
return {
"eligible": eligible,
"score": total,
"components": components,
"metrics": current,
"baselineMetrics": baseline,
}
+689
View File
@@ -0,0 +1,689 @@
"""SQLite persistence for tuning sessions, trials, proposals and scalar data."""
from __future__ import annotations
import json
import sqlite3
import threading
import uuid
from collections.abc import Iterator
from contextlib import contextmanager
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
SCHEMA_VERSION = 2
class StorageConflict(RuntimeError):
"""Optimistic-concurrency revision mismatch."""
def now_iso() -> str:
return datetime.now(UTC).isoformat()
def _json(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, separators=(",", ":"), allow_nan=False)
def _decode(value: str | None) -> Any:
return json.loads(value) if value else None
def _lttb(points: list[dict], threshold: int) -> list[dict]:
"""Largest-Triangle-Three-Buckets downsampling preserving peaks and endpoints."""
if threshold >= len(points) or threshold < 3:
return points[:threshold]
sampled = [points[0]]
bucket_width = (len(points) - 2) / (threshold - 2)
anchor_index = 0
for bucket in range(threshold - 2):
average_start = int((bucket + 1) * bucket_width) + 1
average_end = min(int((bucket + 2) * bucket_width) + 1, len(points))
average_bucket = points[average_start:average_end] or [points[-1]]
average_x = sum(point["step"] for point in average_bucket) / len(average_bucket)
average_y = sum(point["value"] for point in average_bucket) / len(average_bucket)
range_start = int(bucket * bucket_width) + 1
range_end = min(int((bucket + 1) * bucket_width) + 1, len(points) - 1)
anchor = points[anchor_index]
selected_index = range_start
maximum_area = -1.0
for index in range(range_start, max(range_start + 1, range_end)):
point = points[index]
area = abs(
(anchor["step"] - average_x) * (point["value"] - anchor["value"])
- (anchor["step"] - point["step"]) * (average_y - anchor["value"])
)
if area > maximum_area:
maximum_area = area
selected_index = index
sampled.append(points[selected_index])
anchor_index = selected_index
sampled.append(points[-1])
return sampled
class TuningStorage:
def __init__(self, path: Path):
self.path = path.expanduser().resolve()
self.path.parent.mkdir(parents=True, exist_ok=True)
self.local = threading.local()
self._migrate()
def connection(self) -> sqlite3.Connection:
connection = getattr(self.local, "connection", None)
if connection is None:
connection = sqlite3.connect(self.path, timeout=10, isolation_level=None)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA foreign_keys=ON")
connection.execute("PRAGMA journal_mode=WAL")
connection.execute("PRAGMA busy_timeout=10000")
self.local.connection = connection
return connection
@contextmanager
def transaction(self) -> Iterator[sqlite3.Connection]:
connection = self.connection()
connection.execute("BEGIN IMMEDIATE")
try:
yield connection
connection.execute("COMMIT")
except Exception:
connection.execute("ROLLBACK")
raise
def _migrate(self) -> None:
connection = self.connection()
connection.executescript(
"""
CREATE TABLE IF NOT EXISTS schema_migrations(version INTEGER PRIMARY KEY);
CREATE TABLE IF NOT EXISTS sessions(
id TEXT PRIMARY KEY, state TEXT NOT NULL, mode TEXT NOT NULL,
created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
config_json TEXT NOT NULL, objective_json TEXT NOT NULL,
message TEXT NOT NULL, current_trial_id TEXT, best_trial_id TEXT,
consecutive_no_improve INTEGER NOT NULL DEFAULT 0,
fallback_enabled INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS trials(
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
number INTEGER NOT NULL, state TEXT NOT NULL, rung INTEGER NOT NULL,
target_iterations INTEGER NOT NULL, reward_config_json TEXT NOT NULL,
proposal_id TEXT, run_dir TEXT NOT NULL, checkpoint_path TEXT,
policy_path TEXT, evaluation_json TEXT, score REAL, eligible INTEGER,
created_at TEXT NOT NULL, started_at TEXT, ended_at TEXT, message TEXT NOT NULL,
UNIQUE(session_id, number, rung)
);
CREATE TABLE IF NOT EXISTS proposals(
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
base_trial_id TEXT, state TEXT NOT NULL, source TEXT NOT NULL,
patch_json TEXT NOT NULL, rationale TEXT NOT NULL,
expected_json TEXT, confidence REAL NOT NULL,
created_at TEXT NOT NULL, decided_at TEXT, feedback TEXT
);
CREATE TABLE IF NOT EXISTS metric_points(
trial_id TEXT NOT NULL REFERENCES trials(id) ON DELETE CASCADE,
tag TEXT NOT NULL, step INTEGER NOT NULL,
wall_time REAL NOT NULL, value REAL NOT NULL,
PRIMARY KEY(trial_id, tag, step)
);
CREATE TABLE IF NOT EXISTS audit_events(
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
event_type TEXT NOT NULL, payload_json TEXT NOT NULL, created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS presets(
id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, session_id TEXT NOT NULL,
trial_id TEXT NOT NULL, reward_config_json TEXT NOT NULL, created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS session_controls(
session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE,
run_policy TEXT NOT NULL DEFAULT 'continuous'
CHECK(run_policy IN ('continuous','step')),
dispatch_tokens INTEGER NOT NULL DEFAULT 0 CHECK(dispatch_tokens >= 0),
active_base_trial_id TEXT,
revision INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS session_constraints(
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
path TEXT NOT NULL, kind TEXT NOT NULL CHECK(kind IN ('range','fixed')),
min_value REAL, max_value REAL, fixed_value REAL,
updated_at TEXT NOT NULL,
PRIMARY KEY(session_id, path)
);
CREATE INDEX IF NOT EXISTS idx_trials_session ON trials(session_id, number, rung);
CREATE INDEX IF NOT EXISTS idx_proposals_session ON proposals(session_id, created_at);
CREATE INDEX IF NOT EXISTS idx_metrics_trial_tag ON metric_points(trial_id, tag, step);
"""
)
connection.execute(
"INSERT OR IGNORE INTO session_controls(session_id) SELECT id FROM sessions"
)
connection.execute(
"INSERT OR IGNORE INTO schema_migrations(version) VALUES (?)", (SCHEMA_VERSION,)
)
def recover_interrupted(self) -> None:
at = now_iso()
with self.transaction() as connection:
connection.execute(
"UPDATE trials SET state='interrupted', ended_at=?, "
"message='服务重启中断,等待显式恢复' "
"WHERE state IN ('training','evaluating')",
(at,),
)
connection.execute(
"UPDATE sessions SET state='interrupted', updated_at=?, "
"message='服务重启中断,可从完整 checkpoint 恢复' "
"WHERE state IN ('running','evaluating')",
(at,),
)
def create_session(self, mode: str, config: dict, objective: dict, fallback: bool) -> dict:
session_id, at = uuid.uuid4().hex, now_iso()
with self.transaction() as connection:
connection.execute(
"INSERT INTO sessions("
"id,state,mode,created_at,updated_at,config_json,objective_json,"
"message,fallback_enabled) "
"VALUES (?, 'queued', ?, ?, ?, ?, ?, '等待基线训练', ?)",
(session_id, mode, at, at, _json(config), _json(objective), int(fallback)),
)
connection.execute("INSERT INTO session_controls(session_id) VALUES (?)", (session_id,))
connection.execute(
"INSERT INTO audit_events(session_id,event_type,payload_json,created_at) "
"VALUES (?,?,?,?)",
(session_id, "session_created", _json({"mode": mode}), at),
)
return self.get_session(session_id)
def _session(self, row: sqlite3.Row) -> dict:
return {
"id": row["id"],
"state": row["state"],
"mode": row["mode"],
"createdAt": row["created_at"],
"updatedAt": row["updated_at"],
"config": _decode(row["config_json"]),
"objectiveWeights": _decode(row["objective_json"]),
"message": row["message"],
"currentTrialId": row["current_trial_id"],
"bestTrialId": row["best_trial_id"],
"consecutiveNoImprove": row["consecutive_no_improve"],
"fallbackEnabled": bool(row["fallback_enabled"]),
}
def get_session(self, session_id: str) -> dict:
row = (
self.connection().execute("SELECT * FROM sessions WHERE id=?", (session_id,)).fetchone()
)
if row is None:
raise KeyError(session_id)
return self._session(row)
def list_sessions(self, limit: int = 50) -> list[dict]:
rows = (
self.connection()
.execute("SELECT * FROM sessions ORDER BY created_at DESC LIMIT ?", (limit,))
.fetchall()
)
return [self._session(row) for row in rows]
def update_session(self, session_id: str, **changes: Any) -> bool:
columns = {
"state": "state",
"mode": "mode",
"message": "message",
"current_trial_id": "current_trial_id",
"best_trial_id": "best_trial_id",
"consecutive_no_improve": "consecutive_no_improve",
}
values, assignments = [], []
for key, value in changes.items():
if key not in columns:
raise ValueError(key)
assignments.append(f"{columns[key]}=?")
values.append(value)
assignments.append("updated_at=?")
values.extend((now_iso(), session_id))
cursor = self.connection().execute(
f"UPDATE sessions SET {', '.join(assignments)} WHERE id=?", values
)
return cursor.rowcount == 1
def get_control(self, session_id: str) -> dict:
row = (
self.connection()
.execute("SELECT * FROM session_controls WHERE session_id=?", (session_id,))
.fetchone()
)
if row is None:
raise KeyError(session_id)
constraint_rows = (
self.connection()
.execute(
"SELECT * FROM session_constraints WHERE session_id=? ORDER BY path", (session_id,)
)
.fetchall()
)
constraints = {}
for constraint in constraint_rows:
if constraint["kind"] == "fixed":
value = {"kind": "fixed", "value": constraint["fixed_value"]}
else:
value = {
"kind": "range",
"min": constraint["min_value"],
"max": constraint["max_value"],
}
constraints[constraint["path"]] = value
return {
"runPolicy": row["run_policy"],
"dispatchTokens": row["dispatch_tokens"],
"constraintsRevision": row["revision"],
"constraints": constraints,
"activeBaseTrialId": row["active_base_trial_id"],
}
def replace_constraints(
self, session_id: str, expected_revision: int, constraints: dict
) -> dict:
at = now_iso()
with self.transaction() as connection:
row = connection.execute(
"SELECT revision FROM session_controls WHERE session_id=?", (session_id,)
).fetchone()
if row is None:
raise KeyError(session_id)
if row["revision"] != expected_revision:
raise StorageConflict(
f"参数护栏 revision 已变化(当前 {row['revision']},请求 {expected_revision})"
)
connection.execute("DELETE FROM session_constraints WHERE session_id=?", (session_id,))
for path, constraint in constraints.items():
connection.execute(
"INSERT INTO session_constraints("
"session_id,path,kind,min_value,max_value,fixed_value,updated_at) "
"VALUES (?,?,?,?,?,?,?)",
(
session_id,
path,
constraint["kind"],
constraint.get("min"),
constraint.get("max"),
constraint.get("value"),
at,
),
)
connection.execute(
"UPDATE session_controls SET revision=revision+1 WHERE session_id=?",
(session_id,),
)
return self.get_control(session_id)
def grant_dispatch_token(self, session_id: str) -> dict:
"""Atomically grant the sole outstanding one-Trial token."""
with self.transaction() as connection:
cursor = connection.execute(
"UPDATE session_controls SET run_policy='step',dispatch_tokens=1 "
"WHERE session_id=? AND dispatch_tokens=0",
(session_id,),
)
if cursor.rowcount != 1:
exists = connection.execute(
"SELECT 1 FROM session_controls WHERE session_id=?", (session_id,)
).fetchone()
if exists is None:
raise KeyError(session_id)
raise StorageConflict("已有未消费的单步 Trial 令牌")
return self.get_control(session_id)
def use_dispatch_token(self, session_id: str) -> bool:
"""Atomically consume one step token; continuous mode never needs a token."""
with self.transaction() as connection:
row = connection.execute(
"SELECT run_policy,dispatch_tokens FROM session_controls WHERE session_id=?",
(session_id,),
).fetchone()
if row is None:
raise KeyError(session_id)
if row["run_policy"] == "continuous":
return True
if row["dispatch_tokens"] <= 0:
return False
connection.execute(
"UPDATE session_controls SET dispatch_tokens=dispatch_tokens-1 WHERE session_id=?",
(session_id,),
)
return True
def set_run_policy(self, session_id: str, policy: str) -> dict:
if policy not in {"continuous", "step"}:
raise ValueError(policy)
cursor = self.connection().execute(
"UPDATE session_controls SET run_policy=?,"
"dispatch_tokens=CASE WHEN ?='continuous' THEN 0 ELSE dispatch_tokens END "
"WHERE session_id=?",
(policy, policy, session_id),
)
if cursor.rowcount != 1:
raise KeyError(session_id)
return self.get_control(session_id)
def reset_step_gate(self, session_id: str) -> dict:
cursor = self.connection().execute(
"UPDATE session_controls SET run_policy='step',dispatch_tokens=0 WHERE session_id=?",
(session_id,),
)
if cursor.rowcount != 1:
raise KeyError(session_id)
return self.get_control(session_id)
def set_active_base(self, session_id: str, trial_id: str | None) -> dict:
cursor = self.connection().execute(
"UPDATE session_controls SET active_base_trial_id=? WHERE session_id=?",
(trial_id, session_id),
)
if cursor.rowcount != 1:
raise KeyError(session_id)
return self.get_control(session_id)
def create_trial(
self,
session_id: str,
number: int,
rung: int,
target: int,
reward_config: dict,
proposal_id: str | None,
run_dir: str,
) -> dict:
trial_id, at = uuid.uuid4().hex, now_iso()
with self.transaction() as connection:
connection.execute(
"INSERT INTO trials("
"id,session_id,number,state,rung,target_iterations,reward_config_json,"
"proposal_id,run_dir,created_at,message) "
"VALUES (?,?,?,'queued',?,?,?,?,?,?,'等待训练')",
(
trial_id,
session_id,
number,
rung,
target,
_json(reward_config),
proposal_id,
run_dir,
at,
),
)
connection.execute(
"UPDATE sessions SET current_trial_id=?,updated_at=? WHERE id=?",
(trial_id, at, session_id),
)
return self.get_trial(trial_id)
def _trial(self, row: sqlite3.Row) -> dict:
return {
"id": row["id"],
"sessionId": row["session_id"],
"number": row["number"],
"state": row["state"],
"rung": row["rung"],
"targetIterations": row["target_iterations"],
"rewardConfig": _decode(row["reward_config_json"]),
"proposalId": row["proposal_id"],
"runDir": row["run_dir"],
"checkpointPath": row["checkpoint_path"],
"policyPath": row["policy_path"],
"evaluation": _decode(row["evaluation_json"]),
"score": row["score"],
"eligible": None if row["eligible"] is None else bool(row["eligible"]),
"createdAt": row["created_at"],
"startedAt": row["started_at"],
"endedAt": row["ended_at"],
"message": row["message"],
}
def get_trial(self, trial_id: str) -> dict:
row = self.connection().execute("SELECT * FROM trials WHERE id=?", (trial_id,)).fetchone()
if row is None:
raise KeyError(trial_id)
return self._trial(row)
def list_trials(self, session_id: str) -> list[dict]:
rows = (
self.connection()
.execute("SELECT * FROM trials WHERE session_id=? ORDER BY number,rung", (session_id,))
.fetchall()
)
return [self._trial(row) for row in rows]
def delete_trial(self, trial_id: str) -> bool:
cursor = self.connection().execute(
"DELETE FROM trials WHERE id=? AND state='interrupted'", (trial_id,)
)
return cursor.rowcount == 1
def update_trial(self, trial_id: str, **changes: Any) -> bool:
columns = {
"state": "state",
"message": "message",
"checkpoint_path": "checkpoint_path",
"policy_path": "policy_path",
"score": "score",
"eligible": "eligible",
"started_at": "started_at",
"ended_at": "ended_at",
"evaluation": "evaluation_json",
}
values, assignments = [], []
for key, value in changes.items():
if key not in columns:
raise ValueError(key)
if key == "evaluation":
value = _json(value)
if key == "eligible":
value = int(value)
assignments.append(f"{columns[key]}=?")
values.append(value)
values.append(trial_id)
cursor = self.connection().execute(
f"UPDATE trials SET {', '.join(assignments)} WHERE id=?", values
)
return cursor.rowcount == 1
def create_proposal(
self,
session_id: str,
base_trial_id: str | None,
patch: dict,
rationale: str,
expected: Any,
confidence: float,
source: str = "agent",
) -> dict:
proposal_id, at = uuid.uuid4().hex, now_iso()
self.connection().execute(
"INSERT INTO proposals("
"id,session_id,base_trial_id,state,source,patch_json,rationale,"
"expected_json,confidence,created_at) "
"VALUES (?,?,?,'pending',?,?,?,?,?,?)",
(
proposal_id,
session_id,
base_trial_id,
source,
_json(patch),
rationale,
_json(expected),
confidence,
at,
),
)
return self.get_proposal(proposal_id)
def _proposal(self, row: sqlite3.Row) -> dict:
return {
"id": row["id"],
"sessionId": row["session_id"],
"baseTrialId": row["base_trial_id"],
"state": row["state"],
"source": row["source"],
"patch": _decode(row["patch_json"]),
"rationale": row["rationale"],
"expectedImpact": _decode(row["expected_json"]),
"confidence": row["confidence"],
"createdAt": row["created_at"],
"decidedAt": row["decided_at"],
"feedback": row["feedback"],
}
def get_proposal(self, proposal_id: str) -> dict:
row = (
self.connection()
.execute("SELECT * FROM proposals WHERE id=?", (proposal_id,))
.fetchone()
)
if row is None:
raise KeyError(proposal_id)
return self._proposal(row)
def list_proposals(self, session_id: str) -> list[dict]:
rows = (
self.connection()
.execute(
"SELECT * FROM proposals WHERE session_id=? ORDER BY created_at", (session_id,)
)
.fetchall()
)
return [self._proposal(row) for row in rows]
def decide_proposal(
self, proposal_id: str, state: str, feedback: str | None, patch: dict | None = None
) -> bool:
at = now_iso()
assignments, values = ["state=?", "feedback=?", "decided_at=?"], [state, feedback, at]
if patch is not None:
assignments.append("patch_json=?")
values.append(_json(patch))
values.extend((proposal_id,))
cursor = self.connection().execute(
f"UPDATE proposals SET {', '.join(assignments)} WHERE id=? AND state='pending'", values
)
return cursor.rowcount == 1
def insert_metrics(self, trial_id: str, points: list[tuple[str, int, float, float]]) -> None:
self.connection().executemany(
"INSERT INTO metric_points(trial_id,tag,step,wall_time,value) "
"VALUES (?,?,?,?,?) ON CONFLICT(trial_id,tag,step) DO UPDATE SET "
"wall_time=excluded.wall_time,value=excluded.value",
[(trial_id, *point) for point in points],
)
def metrics(
self,
trial_id: str,
tags: list[str] | None = None,
max_points: int = 1000,
after_step: int | None = None,
) -> list[dict]:
parameters: list[Any] = [trial_id]
clause = "trial_id=?"
if tags:
clause += f" AND tag IN ({','.join('?' for _ in tags)})"
parameters.extend(tags)
if after_step is not None:
clause += " AND step>?"
parameters.append(after_step)
rows = (
self.connection()
.execute(
f"SELECT tag,step,wall_time,value FROM metric_points "
f"WHERE {clause} ORDER BY tag,step",
parameters,
)
.fetchall()
)
grouped: dict[str, list[dict]] = {}
for row in rows:
grouped.setdefault(row["tag"], []).append(
{"step": row["step"], "wallTime": row["wall_time"], "value": row["value"]}
)
series = []
for tag, values in grouped.items():
if len(values) > max_points:
values = _lttb(values, max_points)
series.append({"tag": tag, "points": values})
return series
def audit(self, session_id: str, event_type: str, payload: Any) -> None:
self.connection().execute(
"INSERT INTO audit_events(session_id,event_type,payload_json,created_at) "
"VALUES (?,?,?,?)",
(session_id, event_type, _json(payload), now_iso()),
)
def audit_events(self, session_id: str) -> list[dict]:
rows = (
self.connection()
.execute("SELECT * FROM audit_events WHERE session_id=? ORDER BY id", (session_id,))
.fetchall()
)
return [
{
"id": row["id"],
"type": row["event_type"],
"payload": _decode(row["payload_json"]),
"createdAt": row["created_at"],
}
for row in rows
]
def save_preset(self, name: str, session_id: str, trial_id: str, reward_config: dict) -> dict:
preset_id, at = uuid.uuid4().hex, now_iso()
self.connection().execute(
"INSERT INTO presets(id,name,session_id,trial_id,reward_config_json,created_at) "
"VALUES (?,?,?,?,?,?)",
(preset_id, name, session_id, trial_id, _json(reward_config), at),
)
return {
"id": preset_id,
"name": name,
"sessionId": session_id,
"trialId": trial_id,
"rewardConfig": reward_config,
"createdAt": at,
}
def get_preset(self, preset_id: str) -> dict:
row = self.connection().execute("SELECT * FROM presets WHERE id=?", (preset_id,)).fetchone()
if row is None:
raise KeyError(preset_id)
return {
"id": row["id"],
"name": row["name"],
"sessionId": row["session_id"],
"trialId": row["trial_id"],
"rewardConfig": _decode(row["reward_config_json"]),
"createdAt": row["created_at"],
}
def list_presets(self) -> list[dict]:
rows = (
self.connection().execute("SELECT * FROM presets ORDER BY created_at DESC").fetchall()
)
return [
{
"id": row["id"],
"name": row["name"],
"sessionId": row["session_id"],
"trialId": row["trial_id"],
"rewardConfig": _decode(row["reward_config_json"]),
"createdAt": row["created_at"],
}
for row in rows
]
+55
View File
@@ -0,0 +1,55 @@
"""Lazy Optuna study integration used for durable trial history and pruning metadata."""
from __future__ import annotations
from pathlib import Path
from typing import Any
class OptunaStudies:
def __init__(self, root: Path):
self.path = (root / "optuna.sqlite3").resolve()
self.url = f"sqlite:///{self.path}"
def _study(self, session_id: str):
import optuna
optuna.logging.set_verbosity(optuna.logging.WARNING)
return optuna.create_study(
study_name=f"reward-tuning-{session_id}",
storage=self.url,
direction="maximize",
load_if_exists=True,
pruner=optuna.pruners.SuccessiveHalvingPruner(
min_resource=300, reduction_factor=3, min_early_stopping_rate=0
),
)
def record(
self,
session_id: str,
reward_config: dict[str, Any],
score: float,
eligible: bool,
rung: int,
) -> int:
"""Record an externally proposed Agent config through Optuna ask/tell."""
import optuna
study = self._study(session_id)
trial = study.ask()
trial.set_user_attr("reward_config", reward_config)
trial.set_user_attr("eligible", eligible)
trial.set_user_attr("rung", rung)
state = optuna.trial.TrialState.COMPLETE if eligible else optuna.trial.TrialState.PRUNED
study.tell(trial, score if eligible else None, state=state)
return trial.number
def summary(self, session_id: str) -> dict[str, Any]:
study = self._study(session_id)
completed = [trial for trial in study.trials if trial.value is not None]
return {
"studyName": study.study_name,
"trialCount": len(study.trials),
"bestValue": max((trial.value for trial in completed), default=None),
}
+35
View File
@@ -0,0 +1,35 @@
"""TensorBoard scalar ingestion with optional dependency isolation."""
from __future__ import annotations
from pathlib import Path
from .storage import TuningStorage
class TensorboardUnavailable(RuntimeError):
pass
def ingest_scalars(storage: TuningStorage, trial_id: str, log_dir: Path) -> int:
"""Reload all scalar events and idempotently upsert them into SQLite."""
try:
from tensorboard.backend.event_processing.event_accumulator import EventAccumulator
except ImportError as error:
raise TensorboardUnavailable(
"缺少 tensorboard,请安装 training_server/requirements.txt"
) from error
if not log_dir.is_dir():
return 0
accumulator = EventAccumulator(str(log_dir), size_guidance={"scalars": 0})
try:
accumulator.Reload()
except (OSError, ValueError):
return 0
points: list[tuple[str, int, float, float]] = []
for tag in accumulator.Tags().get("scalars", []):
for event in accumulator.Scalars(tag):
points.append((tag, int(event.step), float(event.wall_time), float(event.value)))
if points:
storage.insert_metrics(trial_id, points)
return len(points)
+44
View File
@@ -0,0 +1,44 @@
# 前端架构
`src/` 采用“应用壳 + 业务域 + 共享基础设施”的组织方式。
## 目录职责
```text
src/
├── app/ 应用入口、跨域编排、工作区壳和全局反馈
├── components/ui/ 无业务依赖的共享 UI 基础组件
├── controller/ Python 控制器运行时、类型和面板
├── map/ 地图加载、生成、编辑和面板
├── project/ 工程导入、转换、文件树和工作区
├── rl/ ONNX 策略运行时、任务绑定、类型和面板
├── simulation/ MuJoCo 会话、物理适配器和仿真控制组件
├── telemetry/ 数据源抽象、记录器、导出和数据面板
├── training/ 本地训练/调参客户端、类型、共享连接和面板
├── tuning/ 独立 tuning.html 的 Agent dashboard 与 scalar 图表
├── viewer/ Three.js 场景、渲染和交互
├── stores/ 跨域应用状态
└── test/ 全局测试初始化
```
## 依赖规则
1. `components/ui` 不依赖任何业务域。
2. 业务组件、类型和测试与所属业务域共置。
3. `app` 负责组合业务域,不承载可复用的业务算法或运行时。
4. `simulation` 不依赖 React 应用状态;遥测通过 `TelemetrySource` 接口读取仿真数据。
5. 业务域之间通过显式类型和门面交互,避免从其他域读取内部状态。
6. 新文件优先放入已有业务域;只有跨域复用且不含业务语义的组件才进入 `components/ui`。
## 主要运行链路
```text
项目文件 → project → PhysicsAdapter → SimulationSession → MuJoCo WASM
├→ controller / rl
└→ telemetry
SimulationSession snapshot → app → viewer / 各业务面板
```
主工作台由 `index.html → src/main.tsx` 启动;自调参工作台由 Vite MPA 入口 `tuning.html → src/tuning/main.tsx` 启动,避免把 MuJoCo/Three.js 主应用依赖打入监控页面。两页仅通过训练 HTTP API和严格同源的短消息交接训练服务凭据/策略导入请求,不在 URL 中传 token。
测试文件使用 `*.test.ts(x)` 与被测模块共置;端到端测试统一保存在 `e2e/`。
+8 -7
View File
@@ -15,7 +15,7 @@
- 内置平地、坡道、楼梯、可复现随机障碍物及 9 类系统参数化地形,可配置尺寸、摩擦、难度、种子与高度场采样精度
- 导入单文件 `.py` 控制器,通过本地 Pyodide 在 `mj_step` 前按仿真时间同步执行
- 导入 mjlab 导出的 `policy.onnx`,在浏览器本地执行 Go2-W 平衡/速度策略推理
- 从图形界面向本机训练桥接服务发起 mjlab 强化学习训练、查看进度/日志、停止任务并导入训练生成的 ONNX
- 从图形界面向本机训练桥接服务发起 mjlab 强化学习训练、查看进度/日志、停止任务并导入训练生成的 ONNX;可在独立 TensorBoard 风格页面运行 DeepSeek 奖励函数自调参
- 可配置仿真遥测记录,实时查看速度、机身姿态、位置、驱动力等指标并导出 CSV/JSON
- FPS、物理耗时和主线程步进预算提示
@@ -65,7 +65,7 @@ python3 -m http.server 8080 --directory web-platform-dist
## 地图模块
模型加载后打开右侧“地图”标签,可以选择平地、坡道、楼梯、随机障碍物、系统参数化地形或工程内地图包。参数化地形包括离散障碍、沟壑、倒金字塔阶梯、深坑、金字塔阶梯、轨道、随机粗糙、踏石和波浪地形;相同参数与随机种子会确定性生成相同碰撞层。点击“应用并重新编译”后,平台会在当前入口同目录生成临时组合 MJCF;原始工程文件不会被修改。模型编译失败时保留上一个可用仿真会话。
模型加载后打开右侧“地图”标签,即可使用统一地图资产库。工程地图、认证资产、内置程序地图和系统参数化地形共用一条“点击/拖放 → 轻量预览 → 场景树管理 → 一次应用”链路;任何来源的增删或参数修改都先进入同一个场景草稿,不会隐式触发 MuJoCo 编译。参数化地形包括离散障碍、沟壑、倒金字塔阶梯、深坑、金字塔阶梯、轨道、随机粗糙、踏石和波浪地形;相同参数与随机种子会确定性生成相同碰撞层。卡片可直接拖到三维画布,落点按 0.1 m 吸附;所有内置程序地图都可在视口选择、移动和绕 Z 旋转。认证资产的“自动落位(重力)”会查询程序地图真实表面,可落到坡道、楼梯、坑底或高度场,而不是固定假设 `z=0`。最后点击“一次编译应用”才组合 MJCF;编译失败时保留上一个可用仿真会话和完整草稿,普通模型重载也不会提前提交草稿。
工程地图由 `map.json`、静态 MJCF 碰撞层和可选的自包含 GLB 视觉层组成:
@@ -94,7 +94,7 @@ maps/warehouse/
物理地图仅允许静态 `worldbody` 以及 mesh、heightfield、texture、material 等基础 asset,不允许 joint、mocap body、actuator、sensor、include 或 default class。OBJ/STL 应使用简化碰撞模型;高精度模型只放入 GLB。GLB 必须是 2.0 自包含文件,外部 URI 会被拒绝。地图统一使用米制、Z-up、+X 前向坐标系。
V3 可编辑地图使用 `schemaVersion: 2`,并增加 `"authoring": { "source": "authoring/map.scene.json" }`。创作层支持方盒、圆柱、胶囊、坡道、楼梯和出生点。“场景 · 资产库”中的认证资产可点击添加或拖到画布落位;没有可编辑地图时,首个资产会立即创建 Schema V2 场景草稿,不触发 MuJoCo 重编译。新增对象支持三种放置方式:自动贴地、沿世界 `-Z` 落到最高静态承载面的自动重力落位,以及禁止位姿编辑的锁定模式。只有点击“应用并重新编译”后才提交物理层。可以通过表单或视口 TransformControls 修改位置、绕 Z 轴旋转和原语尺寸,支持移动/旋转吸附、视口拾取、复制、删除及对齐地面;`W`/`E`/`S` 切换移动、旋转和缩放工具,`Delete` 删除,`Ctrl+Z`/`Ctrl+Y` 撤销重做。编辑只更新 Three.js 草稿预览,点击编辑器内“应用并重新编译”后才生成确定性的静态 MJCF。失败时保留旧仿真和草稿。浏览器不会直接写回原目录,可使用“导出地图 ZIP”下载当前已提交地图包。没有 `authoring.source` 的 V1/V2 地图默认只读;仅由 `box`、`cylinder`、`capsule` 构成且不含 asset、材质、碰撞过滤或隐藏姿态语义的静态 MJCF,可通过“创建可编辑副本”显式升级。转换会生成 `authoring/map.scene.json`、Schema V2 描述和确定性物理层;任何不可逆语义都会导致整体拒绝,不会静默丢失内容。
V3 可编辑地图使用 `schemaVersion: 2`,并增加 `"authoring": { "source": "authoring/map.scene.json" }`。创作层支持方盒、圆柱、胶囊、坡道、楼梯和出生点。“场景 · 资产库”中的认证资产可点击添加或拖到画布落位;没有可编辑地图时,首个资产会创建临时 Schema V2 场景,但不触发 MuJoCo 重编译,“放弃场景更改”会同时移除临时实例和三份临时工程文件。新增对象支持自动贴地、沿世界 `-Z` 自动重力落位及锁定位姿;属性面板可编辑位置、绕 Z 旋转、原语尺寸、摩擦、颜色和透明度。工程地图源与场景实例分离:同一来源可以重复放置,实例拥有独立 XY/绕 Z 位姿,物理层、GLB 视觉层和出生点同步变换;编辑 authoring 源内容会明确同步到同源实例。视口支持吸附、拾取、空白取消选择、复制、删除及对齐地面;`W`/`E`/`S` 切换移动、旋转和缩放工具,`Delete` 删除,`Ctrl+Z`/`Ctrl+Y` 撤销重做。浏览器不会直接写回原目录,可使用“导出地图 ZIP”下载当前已提交地图包。没有 `authoring.source` 的 V1/V2 地图默认只读;仅由 `box`、`cylinder`、`capsule` 构成且不含 asset、材质、碰撞过滤或隐藏姿态语义的静态 MJCF,可通过“创建可编辑副本”显式升级;任何不可逆语义都会导致整体拒绝,不会静默丢失内容。
物理地图超过 2000 个 geom 会产生性能警告,超过 10000 个会被拒绝;GLB 超过 100 万三角面会警告,超过 300 万会被拒绝。当前原生 URDF 模式不支持地图,请切换到“转换为 MJCF”。
@@ -112,17 +112,18 @@ Python 控制器是可信的单文件脚本,必须同步定义 `step(ctx, stat
## 本地强化学习训练
训练仍由本机 Python/mjlab 进程执行,但可以从右侧“控制 → 本地强化学习训练”直接发起和管理。先使用安装了 mjlab、PyTorch 及训练依赖的 Python 启动本地桥接服务:
训练仍由本机 Python/mjlab 进程执行,但可以从右侧“控制 → 本地强化学习训练”直接发起和管理。仓库已经内置默认 Go2 任务的训练代码与资产;先按 [`training_server/README.md`](../training_server/README.md) 安装训练依赖,再使用对应 Python 启动本地桥接服务:
```bash
npm run training-server -- \
--trainer-root /path/to/unitree_rl_mjlab \
--trainer-python /path/to/training-env/bin/python
```
服务启动时会在终端输出一个随机访问令牌;在界面中填写该令牌后连接。令牌仅保存在当前标签页的 `sessionStorage`。界面默认连接 `http://127.0.0.1:8765`,可选择服务端允许的任务、并行环境数、训练迭代、随机种子、CPU/GPU、GPU 编号和实验记录方式。W&B 默认为本地离线模式,无需登录或 API Key;也可完全禁用,只有明确选择在线模式时才会联网登录。训练期间页面轮询迭代进度与最近日志,可以停止任务;训练成功后点击“导入策略”,生成的 `policy.onnx` 会进入现有 ONNX 加载流程。
服务启动时会在终端输出一个随机访问令牌;在界面中填写该令牌后连接。令牌仅保存在当前标签页的 `sessionStorage`。界面默认连接 `http://127.0.0.1:8765`,可选择服务端允许的任务、并行环境数、训练迭代、随机种子、CPU/GPU、GPU 编号和实验记录方式。W&B 默认为本地离线模式,无需登录或 API Key;也可完全禁用,只有明确选择在线模式时才会联网登录。训练期间页面轮询迭代进度与最近日志,可以停止任务;训练成功后点击“导入策略”,生成的 `policy.onnx` 会进入现有 ONNX 加载流程。普通训练还可以选择自调参产生的命名 reward preset,而不会改写仓库默认配置。
桥接服务只监听本机回环地址,并检查 Host、Origin 和 Bearer Token;仅接受允许列表中的任务和经过范围校验的参数,不执行前端提供的 Shell 命令;一次只运行一个训练进程。当前任务使用 `unitree_rl_mjlab` 自带的机器人资产与环境配置,**不会自动把浏览器中临时编辑的 MJCF/URDF 作为训练环境**。自定义浏览器模型训练需要先在 mjlab 中注册对应 task。服务配置、接口和安全边界见 [`../training_server/README.md`](../training_server/README.md)。
连接服务后点击“打开自调参 Agent 工作台”会打开独立 `tuning.html`。该页面采用 Cyber-Industrial 三栏控制台:左侧展示 Session/ASHA 晋级树和 Trial 对比选择,中间使用 uPlot 叠加多 Trial 增量收敛曲线(金线标记历史最优)及六维物理评分,右侧以可折叠因果时间线展示 rationale、expected impact、置信度和评估结果。工具栏支持自动/逐轮审批切换、一次一 Trial 的调度令牌、服务端参数范围/固定值护栏、回滚历史最优或复现任意安全 Trial;Reward Merge Patch 与回滚差异由按需加载的 Monaco Diff 审查。scalar 以 1 Hz 非重入方式增量轮询,进入固定容量环形缓冲并由 `requestAnimationFrame` 合批后调用 `uPlot.setData`,不会在每次轮询时重建图表。新标签页 URL 不包含 token;同源 opener 会一次性交接凭据,直接打开页面时也可手工输入。DeepSeek key 始终由本地 Python 服务的 `DEEPSEEK_API_KEY` 环境变量读取,浏览器不会接触该 key。
桥接服务只监听本机回环地址,并检查 Host、Origin 和 Bearer Token;仅接受允许列表中的任务和经过范围校验的参数,不执行前端提供的 Shell 命令;一次只运行一个训练进程。默认任务使用仓库内置的 Go2 机器人资产与环境配置,**不会自动把浏览器中临时编辑的 MJCF/URDF 作为训练环境**。自定义浏览器模型训练需要在兼容的外部训练工程中注册 task,并通过服务的 `--trainer-root` 指定该工程。服务配置、接口和安全边界见 [`../training_server/README.md`](../training_server/README.md)。
## ONNX 强化学习策略
+337 -46
View File
@@ -77,6 +77,14 @@ const LARGE_MODEL = `
</worldbody>
</mujoco>`;
test('独立自调参工作台不需要加载 MuJoCo 主应用即可打开', async ({ page }) => {
await page.goto('/tuning.html');
await expect(page.getByRole('heading', { name: 'Go2 奖励函数自调参 Agent' })).toBeVisible();
await expect(page.getByText('新建 Unitree-Go2-Flat 调参 Session')).toBeVisible();
await expect(page.getByLabel('访问令牌(仅当前标签页)')).toBeVisible();
await expect(page.getByRole('button', { name: '启动自调参' })).toBeVisible();
});
test('显示中文平台骨架并加载单文件模型', async ({ page }) => {
page.on('console', (message) => console.log(`[browser:${message.type()}] ${message.text()}`));
page.on('pageerror', (error) => console.log(`[browser:error] ${error.message}`));
@@ -137,6 +145,7 @@ test('显示中文平台骨架并加载单文件模型', async ({ page }) => {
});
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 });
await page.getByRole('button', { name: '显示设置' }).click();
const displayDialog = page.getByRole('dialog', { name: '视图显示设置' });
await expect(displayDialog).toBeVisible();
@@ -154,16 +163,22 @@ test('显示中文平台骨架并加载单文件模型', async ({ page }) => {
await page.getByRole('button', { name: /FPS .*物理/ }).click();
await expect(page.getByRole('dialog', { name: '性能详情' })).toBeVisible();
await page.keyboard.press('Escape');
await page.getByRole('tab', { name: '控制' }).click();
await page.getByRole('button', { name: 'Actuator' }).click();
await expect(page.getByText('motor', { exact: true })).toBeVisible();
await expect(
page.getByRole('tabpanel', { name: '控制' }).getByText('slide', { exact: true }),
).toBeVisible();
await page.getByRole('tab', { name: '模型结构' }).click();
await page.getByRole('tab', { name: '控制台' }).click();
const tools = page.getByRole('tabpanel', { name: '控制台' });
await expect(page.getByRole('dialog', { name: '工作区工具' })).toHaveCount(0);
await expect(page.locator('main canvas')).toBeVisible();
await tools.getByRole('button', { name: /执行器实时控制/ }).click();
await expect(tools.getByText('motor', { exact: true })).toBeVisible();
await expect(tools.getByText('关节:slide', { exact: true })).toBeVisible();
await page.getByRole('tab', { name: '数据录制' }).click();
await expect(page.getByRole('tabpanel', { name: '数据录制' })).toContainText('仿真遥测记录');
await expect(page.locator('main canvas')).toBeVisible();
await page.getByRole('tab', { name: '检查器' }).click();
const structure = page.getByRole('navigation', { name: '模型结构树' });
await expect(structure).toBeVisible();
await structure.getByRole('treeitem', { name: /hinge/ }).hover();
const hinge = structure.getByRole('treeitem', { name: /hinge/ });
await hinge.hover();
await hinge.click();
await expect(page.getByRole('alert')).toHaveCount(0);
await expect(page.getByRole('button', { name: '重置关节' })).toBeVisible();
await page.getByRole('button', { name: '高级' }).click();
@@ -187,10 +202,10 @@ test('窄视口默认保留完整视口并可按需打开侧栏', async ({ page
await page.goto('/');
await expect(page.getByRole('main')).toBeInViewport();
await expect(page.getByRole('button', { name: '显示工程面板' })).toBeVisible();
await expect(page.getByRole('button', { name: '显示属性面板' })).toBeVisible();
await page.getByRole('button', { name: '显示属性面板' }).click();
await expect(page.getByRole('button', { name: '显示右侧面板' })).toBeVisible();
await page.getByRole('button', { name: '显示右侧面板' }).click();
await expect(
page.getByRole('complementary').filter({ hasText: '导入模型后显示属性' }),
page.getByRole('complementary').filter({ hasText: '导入模型后显示检查器' }),
).toBeVisible();
});
@@ -288,7 +303,6 @@ test('加载引用 OBJ 的 URDF 工程', async ({ page }) => {
await options.getByRole('button', { name: '转换并加载' }).click();
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await expect(page.getByText('2 个文件')).toBeVisible();
await page.getByRole('button', { name: 'URDF 处理方式' }).click();
await expect(page.getByLabel('URDF 处理方式')).toHaveValue('mjcf');
await expect(page.getByLabel('URDF 基座类型')).toHaveValue('floating');
await page.getByRole('button', { name: '通知中心' }).click();
@@ -336,8 +350,11 @@ test('URDF 自动生成的关节驱动器与摄像头可通过 MuJoCo 编译', a
await expect(notifications).toContainText('已为 1 个 hinge/slide 关节生成 motor 驱动器');
await expect(notifications).toContainText('已将 640×480 摄像头固连到 arm');
await page.keyboard.press('Escape');
await page.getByRole('tab', { name: '控制' }).click();
await page.getByRole('button', { name: 'Actuator' }).click();
await page.getByRole('tab', { name: '控制台' }).click();
await page
.getByRole('tabpanel', { name: '控制台' })
.getByRole('button', { name: /执行器实时控制/ })
.click();
await expect(page.getByText('shoulder_motor')).toBeVisible();
await expect(page.getByText('关节:shoulder')).toBeVisible();
await expect(page.getByText('N·m', { exact: true })).toBeVisible();
@@ -405,8 +422,10 @@ test('slide 关节向屏幕轴正方向拖动时 qpos 同向增加', async ({ pa
await page.mouse.down();
await page.mouse.move(x + 70, y, { steps: 8 });
await page.mouse.up();
await page.getByRole('tab', { name: '控制' }).click();
const jointSection = page.getByRole('button', { name: '关节 1' });
await page.getByRole('tab', { name: '控制台' }).click();
const jointSection = page
.getByRole('tabpanel', { name: '控制台' })
.getByRole('button', { name: /关节姿态调试/ });
if ((await jointSection.getAttribute('aria-expanded')) === 'false') await jointSection.click();
const output = page.getByText('screen_x').locator('..').locator('output');
await expect
@@ -421,7 +440,11 @@ test('可导入并启用 Python 控制器', async ({ page }) => {
.first()
.setInputFiles({ name: 'model.xml', mimeType: 'text/xml', buffer: Buffer.from(SIMPLE_MODEL) });
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await page.getByRole('tab', { name: '控制' }).click();
await page.getByRole('tab', { name: '控制台' }).click();
await page
.getByRole('tabpanel', { name: '控制台' })
.getByRole('button', { name: /Python 脚本控制/ })
.click();
const python = `NAME = "测试 PD 控制器"\nCONTROL_HZ = 100\ndef init(api):\n return {"joint": api.joint("slide"), "actuator": api.actuator("motor"), "body": api.body("box")}\ndef step(ctx, state):\n assert len(ctx.body_quat(state["body"])) == 4\n assert len(ctx.body_position(state["body"])) == 3\n ctx.set_control(state["actuator"], -ctx.qpos(state["joint"]) - 0.1 * ctx.qvel(state["joint"]))\n`;
await page
.locator('input[accept=".py,text/x-python"]')
@@ -429,7 +452,9 @@ test('可导入并启用 Python 控制器', async ({ page }) => {
await expect(page.getByText('测试 PD 控制器', { exact: true })).toBeVisible({ timeout: 30_000 });
await expect(page.getByText('Python / Pyodide')).toBeVisible();
await page.getByRole('button', { name: '启用', exact: true }).click();
await expect(page.getByText('运行中')).toBeVisible();
await expect(
page.getByRole('tabpanel', { name: '控制台' }).getByRole('button', { name: /Python 脚本控制/ }),
).toContainText('运行');
});
test('中等规模模型持续步进并可重复加载', async ({ page }) => {
@@ -471,16 +496,15 @@ test('认证资产可点击创建场景并拖到画布落位', async ({ page })
buffer: Buffer.from(SIMPLE_MODEL),
});
await expect(page.getByText('模型加载完成')).toBeVisible({ timeout: 30_000 });
await page.getByRole('tab', { name: '地图' }).click();
const library = page.getByLabel('认证资产');
await expect(library.getByText('点击添加,或按住资产拖到画布落位。')).toBeVisible();
const library = page.getByLabel('地图资产库');
await expect(library.getByText('物理几何原语', { exact: true })).toBeVisible();
await library.getByRole('button', { name: '添加基础方盒' }).click();
await expect(page.getByText('正在加载 MuJoCo 与模型…')).toHaveCount(0);
await expect(page.getByLabel('地图来源')).toHaveValue('project:maps/scene_1/map.json', {
timeout: 30_000,
});
await expect(page.getByText('V3 地图编辑器')).toBeVisible();
await expect(page.getByLabel('地图物体检查器')).toBeVisible();
await expect(page.getByLabel('地图对象列表').getByText('基础方盒 · 方盒')).toBeVisible();
await page.locator('[data-map-asset="ramp"]').dragTo(page.locator('main canvas'));
@@ -488,9 +512,9 @@ test('认证资产可点击创建场景并拖到画布落位', async ({ page })
await expect(page.getByText('地图草稿尚未应用')).toBeVisible();
await page.getByLabel('地图对象列表').getByText('基础方盒 · 方盒').click();
await page.getByLabel('对象放置方式').selectOption('locked');
await page.getByLabel('贴地检测模式').selectOption('locked');
await expect(page.getByLabel('对象位置X')).toBeDisabled();
await page.getByLabel('对象放置方式').selectOption('auto_ground');
await page.getByLabel('贴地检测模式').selectOption('auto_ground');
await expect(page.getByLabel('对象位置X')).toBeEnabled();
const gizmoLine = page.getByRole('img', { name: 'XYZ 方向指示器' }).locator('line').first();
@@ -502,6 +526,236 @@ test('认证资产可点击创建场景并拖到画布落位', async ({ page })
await page.mouse.move(canvasBox!.x + 104, canvasBox!.y + 50, { steps: 8 });
await page.mouse.up({ button: 'left' });
await expect.poll(() => gizmoLine.getAttribute('x2')).not.toBe(beforeRotation);
await expect(page.getByLabel('对象名称')).toHaveCount(0);
await expect(page.getByLabel('地图对象列表').getByText('基础方盒 · 方盒')).toBeVisible();
});
test('认证资产自动打开地图属性并与参数地形一次编译', async ({ page }) => {
await page.goto('/');
await page.setViewportSize({ width: 1440, height: 900 });
await page
.locator('input[type="file"]')
.first()
.setInputFiles({
name: 'unified-map-model.xml',
mimeType: 'text/xml',
buffer: Buffer.from(SIMPLE_MODEL),
});
await expect(page.getByText('模型加载完成')).toBeVisible({ timeout: 30_000 });
const library = page.getByLabel('地图资产库');
await library.getByRole('button', { name: '添加基础方盒' }).click();
await expect(page.getByText('Map / Object')).toBeVisible();
await expect(page.getByLabel('地图物体检查器')).toBeVisible();
await expect(page.getByText('1 项场景更改待应用')).toBeVisible();
const sceneTree = page.getByLabel('场景资产树');
await expect(sceneTree.getByText('基础方盒')).toBeVisible();
await sceneTree.getByRole('treeitem', { name: 'box', exact: true }).click();
await expect(page.getByText('Robot / Body')).toBeVisible();
await sceneTree.getByRole('treeitem', { name: /基础方盒/ }).click();
await expect(page.getByText('Map / Object')).toBeVisible();
await library.getByRole('button', { name: '添加随机粗糙地形' }).click();
await page.getByLabel('位置 X(m)').fill('4');
await expect(page.getByText('2 项场景更改待应用')).toBeVisible();
// 切换到参数地形后,尚未编译的认证资产仍保留在画布中并可直接选回。
const draftCanvasBox = await page.locator('main canvas').first().boundingBox();
expect(draftCanvasBox).not.toBeNull();
let selectedDraftAsset = false;
for (let y = 0.15; y <= 0.85 && !selectedDraftAsset; y += 0.05) {
for (let x = 0.05; x <= 0.95; x += 0.05) {
await page.mouse.click(
draftCanvasBox!.x + draftCanvasBox!.width * x,
draftCanvasBox!.y + draftCanvasBox!.height * y,
);
await page.waitForTimeout(20);
if (await page.getByLabel('对象名称').count()) {
selectedDraftAsset = true;
break;
}
}
}
expect(selectedDraftAsset).toBe(true);
await expect(page.getByLabel('地图来源')).toHaveValue('project:maps/scene_1/map.json');
await sceneTree.getByRole('treeitem', { name: /随机粗糙地形/ }).click();
await expect(page.getByLabel('地图来源')).toHaveValue('builtin');
await page.getByRole('button', { name: '应用场景' }).click();
await expect(page.getByText('2 项场景更改待应用')).toHaveCount(0, { timeout: 30_000 });
await expect(page.getByText(/已加载工程地图“场景 1”(1 个物理几何/)).toBeVisible();
await expect(page.getByText(/已加载随机粗糙地形物理地图/)).toBeVisible();
await expect(page.getByLabel('地图来源')).toHaveValue('builtin');
// 当前仍选中参数地形时,直接点已编译的认证资产也必须反查场景与对象,首次点击即挂载操纵器。
const canvasBox = await page.locator('main canvas').first().boundingBox();
expect(canvasBox).not.toBeNull();
let selectedCompiledAsset = false;
for (let y = 0.15; y <= 0.85 && !selectedCompiledAsset; y += 0.05) {
for (let x = 0.05; x <= 0.95; x += 0.05) {
await page.mouse.click(
canvasBox!.x + canvasBox!.width * x,
canvasBox!.y + canvasBox!.height * y,
);
await page.waitForTimeout(20);
if (await page.getByLabel('对象名称').count()) {
selectedCompiledAsset = true;
break;
}
}
}
expect(selectedCompiledAsset).toBe(true);
await expect(page.getByLabel('地图来源')).toHaveValue('project:maps/scene_1/map.json');
await expect(page.getByLabel('对象名称')).toHaveValue('基础方盒');
await expect(page.getByLabel('地图对象列表').getByText('基础方盒 · 方盒')).toBeVisible();
await expect(page.getByText('地图草稿尚未应用')).toHaveCount(0);
});
test('放弃首次认证资产会完整回滚临时场景和工程文件', async ({ page }) => {
await page.goto('/');
await page.setViewportSize({ width: 1440, height: 900 });
await page
.locator('input[type="file"]')
.first()
.setInputFiles({
name: 'discard-map-model.xml',
mimeType: 'text/xml',
buffer: Buffer.from(SIMPLE_MODEL),
});
await expect(page.getByText('模型加载完成')).toBeVisible({ timeout: 30_000 });
const library = page.getByLabel('地图资产库');
await library.getByRole('button', { name: '添加基础方盒' }).click();
await expect(page.getByText('1 项场景更改待应用')).toBeVisible();
await expect(page.getByLabel('场景资产树')).toContainText('场景 1');
await page.getByRole('button', { name: '放弃更改' }).click();
await expect(page.getByText('1 项场景更改待应用')).toHaveCount(0);
await expect(page.getByLabel('场景资产树')).not.toContainText('场景 1');
await expect(library.getByText('场景 1')).toHaveCount(0);
// 文件和 map id 也必须回滚;再次创建应复用 scene_1,而不是泄漏出 scene_2。
await library.getByRole('button', { name: '添加基础方盒' }).click();
await expect(page.getByLabel('场景资产树')).toContainText('场景 1');
await expect(page.getByLabel('场景资产树')).not.toContainText('场景 2');
});
test('工程地图与参数地形共享放置草稿、实例变换和回滚入口', async ({ page }) => {
const mapJson = JSON.stringify({
schemaVersion: 1,
id: 'warehouse-draft',
name: '草稿仓库',
coordinateSystem: { units: 'm', up: 'Z', forward: '+X' },
physics: { source: 'physics/world.xml' },
spawnPoints: [],
});
const project = zipSync({
'model.xml': Buffer.from(SIMPLE_MODEL),
'maps/warehouse/map.json': Buffer.from(mapJson),
'maps/warehouse/physics/world.xml': Buffer.from(
'<mujoco><worldbody><geom name="wall" type="box" pos="0 0 .5" size=".1 1 .5"/></worldbody></mujoco>',
),
});
await page.goto('/');
await page.setViewportSize({ width: 1440, height: 900 });
await page
.locator('input[type="file"]')
.first()
.setInputFiles({
name: 'unified-project-map.zip',
mimeType: 'application/zip',
buffer: Buffer.from(project),
});
await expect(page.getByText('模型加载完成')).toBeVisible({ timeout: 30_000 });
const library = page.getByLabel('地图资产库');
await library.getByRole('button', { name: '放置工程地图 草稿仓库' }).click();
await expect(page.getByText('1 项场景更改待应用')).toBeVisible();
await expect(page.getByLabel('场景资产树')).toContainText('待应用');
await expect(page.getByText('正在准备仿真')).toHaveCount(0);
await page.getByLabel('位置 X(m)').fill('3');
await page.getByLabel('位置 Y(m)').fill('-2');
await expect(page.getByText('1 项场景更改待应用')).toBeVisible();
await library.getByRole('button', { name: '添加波浪地形' }).click();
await expect(page.getByText('2 项场景更改待应用')).toBeVisible();
await page.getByRole('button', { name: '应用场景' }).click();
await expect(page.getByText('2 项场景更改待应用')).toHaveCount(0, { timeout: 30_000 });
await expect(page.getByText(/已加载工程地图“草稿仓库”/)).toBeVisible();
await expect(page.getByText(/已加载波浪地形物理地图/)).toBeVisible();
await page.getByRole('button', { name: '删除地图实例 草稿仓库' }).click();
await expect(page.getByText('1 项场景更改待应用')).toBeVisible();
await expect(page.getByLabel('场景资产树')).not.toContainText('草稿仓库');
await page.getByRole('button', { name: '放弃更改' }).click();
await expect(page.getByText('1 项场景更改待应用')).toHaveCount(0);
await expect(page.getByLabel('场景资产树')).toContainText('草稿仓库');
});
test('认证资产重力放置使用参数地形的真实承载高度', async ({ page }) => {
await page.goto('/');
await page.setViewportSize({ width: 1440, height: 900 });
await page
.locator('input[type="file"]')
.first()
.setInputFiles({
name: 'terrain-surface-model.xml',
mimeType: 'text/xml',
buffer: Buffer.from(SIMPLE_MODEL),
});
await expect(page.getByText('模型加载完成')).toBeVisible({ timeout: 30_000 });
const library = page.getByLabel('地图资产库');
await library.getByRole('button', { name: '添加深坑地形' }).click();
await page.getByLabel('地形难度(0–1)').fill('1');
await library.getByLabel('新增资产放置方式').selectOption('gravity');
await library.getByRole('button', { name: '添加基础方盒' }).click();
await expect(page.getByLabel('贴地检测模式')).toHaveValue('gravity');
await expect(page.getByLabel('对象位置Z')).toHaveValue('-0.3');
await expect(page.getByText('2 项场景更改待应用')).toBeVisible();
});
test('参数化地形可连续拖到画布并一次性编译', async ({ page }) => {
await page.goto('/');
await page.setViewportSize({ width: 1440, height: 900 });
await page
.locator('input[type="file"]')
.first()
.setInputFiles({
name: 'terrain-draft-model.xml',
mimeType: 'text/xml',
buffer: Buffer.from(SIMPLE_MODEL),
});
await expect(page.getByText('模型加载完成')).toBeVisible({ timeout: 30_000 });
await page.locator('[data-system-terrain="rough"]').dragTo(page.locator('main canvas'));
await expect(page.getByText('1 项场景更改待应用')).toBeVisible();
await expect(page.getByText('正在准备仿真')).toHaveCount(0);
await page.locator('[data-system-terrain="wave"]').dragTo(page.locator('main canvas'));
await expect(page.getByText('2 项场景更改待应用')).toBeVisible();
await expect(page.getByLabel('场景资产树')).toContainText('随机粗糙地形');
await expect(page.getByLabel('场景资产树')).toContainText('波浪地形');
await page.getByLabel('场景资产树').getByRole('treeitem', { name: 'box' }).click();
await expect(page.getByText('Robot / Body')).toBeVisible();
const canvasBox = await page.locator('main canvas').first().boundingBox();
expect(canvasBox).not.toBeNull();
await page.mouse.click(
canvasBox!.x + canvasBox!.width * 0.5,
canvasBox!.y + canvasBox!.height * 0.78,
);
await expect(page.getByText('Map / Instance')).toBeVisible();
await expect(page.getByLabel('地图视口工具')).toBeVisible();
await expect(page.getByText('地图与对象属性')).toBeVisible();
await page.getByRole('button', { name: '应用场景' }).click();
await expect(page.getByText('2 项场景更改待应用')).toHaveCount(0, { timeout: 30_000 });
await expect(page.getByText(/已加载随机粗糙地形物理地图/)).toBeVisible({
timeout: 30_000,
});
await expect(page.getByText(/已加载波浪地形物理地图/)).toBeVisible();
});
test('应用内置 MJCF 楼梯物理地图', async ({ page }) => {
@@ -516,7 +770,6 @@ test('应用内置 MJCF 楼梯物理地图', async ({ page }) => {
buffer: Buffer.from(SIMPLE_MODEL),
});
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await page.getByRole('tab', { name: '地图' }).click();
await page.getByLabel('地图来源').selectOption('builtin');
await page.getByLabel('物理地图预设').selectOption('stairs');
await page.getByLabel('台阶数量').fill('6');
@@ -548,7 +801,6 @@ test('依次应用全部系统参数化地形', async ({ page }) => {
buffer: Buffer.from(SIMPLE_MODEL),
});
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await page.getByRole('tab', { name: '地图' }).click();
await page.getByLabel('地图来源').selectOption('builtin');
for (const [preset, label] of terrains) {
await page.getByLabel('物理地图预设').selectOption(preset);
@@ -592,7 +844,6 @@ test('导入并应用分层工程地图包', async ({ page }) => {
buffer: Buffer.from(project),
});
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await page.getByRole('tab', { name: '地图' }).click();
await page.getByLabel('地图来源').selectOption({ label: '测试场景' });
await expect(page.getByLabel('地图出生点')).toHaveValue('start');
await page.getByRole('button', { name: '应用并重新编译' }).click();
@@ -627,14 +878,15 @@ test('将受支持的只读物理地图转换为可编辑副本', async ({ page
buffer: Buffer.from(project),
});
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await page.getByRole('tab', { name: '地图' }).click();
await page.getByLabel('地图来源').selectOption({ label: '旧版基础场景' });
await page.getByRole('button', { name: '应用并重新编译' }).click();
const editor = page.getByText('V3 地图编辑器').locator('..');
const editor = page.getByText('认证资产与场景对象属性').locator('..');
await expect(editor.getByText(/保持只读/)).toBeVisible({ timeout: 30_000 });
await editor.getByRole('button', { name: '创建可编辑副本' }).click();
await expect(page.getByText('已创建可编辑地图副本')).toBeVisible({ timeout: 30_000 });
await expect(editor.getByRole('button', { name: '移动工具 W' })).toBeVisible();
await expect(
page.getByLabel('地图视口工具').getByRole('button', { name: '移动工具 W' }),
).toBeVisible();
await expect(editor.getByRole('button', { name: /floor · 方盒/ })).toBeVisible();
await expect(editor.getByRole('button', { name: /wall · 方盒/ })).toBeVisible();
});
@@ -673,30 +925,56 @@ test('编辑 V3 地图对象并事务式应用', async ({ page }) => {
buffer: Buffer.from(project),
});
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await page.getByRole('tab', { name: '地图' }).click();
await page.getByLabel('地图来源').selectOption({ label: '可编辑场景' });
await page.getByRole('button', { name: '应用并重新编译' }).click();
await expect(page.getByText('V3 地图编辑器')).toBeVisible({ timeout: 30_000 });
const editor = page.getByText('V3 地图编辑器').locator('..');
await expect(editor.getByRole('button', { name: '移动工具 W' })).toHaveAttribute(
await expect(page.getByText('认证资产与场景对象属性')).toBeVisible({ timeout: 30_000 });
const mapTools = page.getByLabel('地图视口工具');
await expect(mapTools.getByRole('button', { name: '移动工具 W' })).toHaveAttribute(
'aria-pressed',
'true',
);
await page.keyboard.press('e');
await expect(editor.getByRole('button', { name: '旋转工具 E' })).toHaveAttribute(
await expect(mapTools.getByRole('button', { name: '旋转工具 E' })).toHaveAttribute(
'aria-pressed',
'true',
);
await page.keyboard.press('s');
await expect(editor.getByRole('button', { name: '缩放工具 S' })).toHaveAttribute(
await page.keyboard.press('r');
await expect(mapTools.getByRole('button', { name: '缩放工具 R' })).toHaveAttribute(
'aria-pressed',
'true',
);
await editor.getByRole('button', { name: '新增', exact: true }).click();
await editor.getByLabel('对象位置X').fill('2');
await editor.getByRole('button', { name: '应用并重新编译' }).click();
await expect(editor.getByRole('button', { name: /box · 方盒/ })).toBeVisible({ timeout: 30_000 });
await expect(editor.getByText('地图草稿尚未应用')).toHaveCount(0);
await page.getByRole('button', { name: '新增', exact: true }).click();
await page.getByLabel('对象位置X').fill('2');
await page.getByLabel('对象位置X').blur();
await page.keyboard.press('f');
const draftStatus = page.getByLabel('地图草稿状态');
await expect(draftStatus).toContainText('未保存改动');
await page.keyboard.press('Control+s');
await expect(
page.getByLabel('地图对象列表').getByRole('button', { name: /box · 方盒/ }),
).toBeVisible({
timeout: 30_000,
});
await expect(draftStatus).toContainText('地图草稿已同步');
await page
.getByLabel('地图对象列表')
.getByRole('button', { name: /box · 方盒/ })
.click();
await page.getByLabel('对象位置X').fill('3');
await expect(draftStatus).toContainText('未保存改动');
await draftStatus.getByRole('button', { name: '丢弃地图草稿' }).click();
await page
.getByLabel('地图对象列表')
.getByRole('button', { name: /box · 方盒/ })
.click();
await expect(page.getByLabel('对象位置X')).toHaveValue('2');
await expect(draftStatus).toContainText('地图草稿已同步');
await page.getByRole('button', { name: '新增', exact: true }).click();
await expect(page.getByLabel('地图对象列表').getByRole('button')).toHaveCount(2);
await page.keyboard.press('Delete');
await expect(page.getByLabel('地图对象列表').getByRole('button')).toHaveCount(1);
await expect(page.getByText('WASM 已加载')).toBeVisible();
});
@@ -727,15 +1005,28 @@ test('工程地图编译失败时保留上一仿真会话', async ({ page }) =>
buffer: Buffer.from(project),
});
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await page.getByRole('tab', { name: '地图' }).click();
await page.getByRole('button', { name: '▶ 播放' }).click();
await page.waitForTimeout(200);
const runningTime = Number(
(await page.locator('footer').innerText()).match(/时间 ([\d.]+) s/)?.[1] ?? 0,
);
await page.getByLabel('地图来源').selectOption({ label: '动态错误地图' });
await page.getByRole('button', { name: '应用并重新编译' }).click();
await expect(page.getByRole('alert')).toContainText('模型编译失败', { timeout: 30_000 });
await expect(page.getByLabel('地图来源')).toHaveValue('none');
await expect(page.getByLabel('地图来源')).toHaveValue('project:maps/invalid/map.json');
await expect(page.getByText('1 项场景更改待应用')).toBeVisible();
await expect(page.getByLabel('场景资产树')).toContainText('待应用');
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeVisible();
await page.getByRole('button', { name: '关闭错误' }).click();
await page.getByRole('button', { name: '▶ 播放' }).click();
await page.waitForTimeout(300);
await expect(page.locator('footer')).not.toContainText('时间 0.000 s');
await expect
.poll(async () =>
Number((await page.locator('footer').innerText()).match(/时间 ([\d.]+) s/)?.[1] ?? 0),
)
.toBeGreaterThan(runningTime);
await page.getByRole('button', { name: '放弃更改' }).click();
await expect(page.getByText('1 项场景更改待应用')).toHaveCount(0);
await expect(page.getByLabel('地图来源')).toHaveValue('none');
});
test('无效模型显示中文诊断且保留工程树', async ({ page }) => {
+1055 -186
View File
File diff suppressed because it is too large Load Diff
@@ -43,7 +43,7 @@ export function LayoutSettingsDialog({
onClick={() => onRightOpen(!rightOpen)}
icon={<PanelRight className="h-3.5 w-3.5" />}
>
属性面板
右侧面板
</Button>
</div>
<h3 className="mb-2 mt-4 text-xs font-semibold">布局预设</h3>
@@ -1,51 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { MAP_ASSET_DRAG_MIME, MAP_ASSET_PLACEMENT_MIME } from '../../map/editor/assetCatalog';
import { MapAssetLibrary } from './MapAssetLibrary';
const renderLibrary = (
onAdd: (type: string, placementMode: string) => void = () => {},
onSelectTerrain: (preset: string) => void = () => {},
) =>
render(
<MapAssetLibrary
disabled={false}
terrainSize={8}
onAdd={onAdd}
onSelectTerrain={onSelectTerrain}
/>,
);
describe('MapAssetLibrary', () => {
it('按所选放置方式添加认证资产', () => {
const onAdd = vi.fn();
renderLibrary(onAdd);
fireEvent.change(screen.getByLabelText('新增资产放置方式'), {
target: { value: 'gravity' },
});
fireEvent.click(screen.getByRole('button', { name: '添加基础方盒' }));
expect(onAdd).toHaveBeenCalledWith('box', 'gravity');
});
it('拖动资产时写入类型和放置方式', () => {
const values = new Map<string, string>();
const dataTransfer = {
effectAllowed: 'none',
setData: (type: string, value: string) => values.set(type, value),
} as unknown as DataTransfer;
renderLibrary();
fireEvent.change(screen.getByLabelText('新增资产放置方式'), {
target: { value: 'locked' },
});
fireEvent.dragStart(document.querySelector('[data-map-asset="ramp"]')!, { dataTransfer });
expect(values.get(MAP_ASSET_DRAG_MIME)).toBe('ramp');
expect(values.get(MAP_ASSET_PLACEMENT_MIME)).toBe('locked');
});
it('以示意图卡片选择系统参数化地形', () => {
const onSelectTerrain = vi.fn();
renderLibrary(undefined, onSelectTerrain);
expect(screen.getAllByText('8.00 × 8.00 m')).toHaveLength(9);
fireEvent.click(screen.getByRole('button', { name: '添加随机粗糙地形' }));
expect(onSelectTerrain).toHaveBeenCalledWith('rough');
});
});
@@ -1,106 +0,0 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import type { EditableMapDocument } from '../../map/editor/types';
import { MapEditorPanel } from './MapEditorPanel';
const interactionProps = {
onConvert: async () => true,
canConvert: true,
onBindInteraction: () => {},
onSelectPreview: () => {},
onTransformMode: () => {},
onSnapping: () => {},
};
const document: EditableMapDocument = {
schemaVersion: 1,
mapId: 'map',
revision: 0,
objects: [],
spawnPoints: [],
};
describe('MapEditorPanel', () => {
it('新增并编辑对象后应用递增 revision', async () => {
const apply = vi.fn(async (documentValue: EditableMapDocument) => Boolean(documentValue)),
preview = vi.fn();
render(
<MapEditorPanel
{...interactionProps}
document={document}
loading={false}
onPreview={preview}
onApply={apply}
onExport={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '新增' }));
fireEvent.change(screen.getByLabelText('对象位置X'), { target: { value: '2' } });
fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' }));
await waitFor(() => expect(apply).toHaveBeenCalled());
expect(apply.mock.calls[0][0]).toEqual(
expect.objectContaining({
revision: 1,
objects: [
expect.objectContaining({ pose: expect.objectContaining({ position: [2, 0, 0.5] }) }),
],
}),
);
});
it('锁定放置方式后禁用位姿编辑', () => {
render(
<MapEditorPanel
{...interactionProps}
document={document}
loading={false}
onPreview={() => {}}
onApply={async () => true}
onExport={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '新增' }));
fireEvent.change(screen.getByLabelText('对象放置方式'), { target: { value: 'locked' } });
expect(screen.getByLabelText('对象位置X')).toBeDisabled();
expect(screen.getByLabelText('对象绕Z旋转')).toBeDisabled();
expect(screen.getByRole('button', { name: '对齐地面' })).toBeDisabled();
});
it('编辑出生点并包含在应用文档中', async () => {
const apply = vi.fn(async (value: EditableMapDocument) => Boolean(value));
render(
<MapEditorPanel
{...interactionProps}
document={document}
loading={false}
onPreview={() => {}}
onApply={apply}
onExport={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '新增出生点' }));
const name = screen.getByLabelText(/出生点名称/);
fireEvent.change(name, { target: { value: '装卸区' } });
const yaw = screen.getByLabelText(/出生点.*朝向/);
fireEvent.change(yaw, { target: { value: '90' } });
fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' }));
await waitFor(() => expect(apply).toHaveBeenCalled());
expect(apply.mock.calls[0][0].spawnPoints[0]).toMatchObject({ name: '装卸区', yawDeg: 90 });
});
it('没有 authoring 文档时可请求创建可编辑副本', async () => {
const convert = vi.fn(async () => true);
render(
<MapEditorPanel
{...interactionProps}
onConvert={convert}
document={null}
loading={false}
onPreview={() => {}}
onApply={async () => true}
onExport={() => {}}
/>,
);
expect(screen.getByText(/保持只读/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '创建可编辑副本' }));
await waitFor(() => expect(convert).toHaveBeenCalledOnce());
});
});
@@ -1,597 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { Button, Select } from '../../components/ui';
import { MapEditSession } from '../../map/editor/MapEditSession';
import {
editableObjectGroundHeight,
MAP_OBJECT_PLACEMENT_LABELS,
type EditableMapDocument,
type EditableMapObject,
type EditableMapObjectType,
type MapEditorInteractionCallbacks,
type MapEditorTransformMode,
type MapObjectPlacementMode,
} from '../../map/editor/types';
const labels: Record<EditableMapObjectType, string> = {
box: '方盒',
cylinder: '圆柱',
capsule: '胶囊',
ramp: '坡道',
stairs: '楼梯',
};
function yawDegrees(object: EditableMapObject): number {
const [w, x, y, z] = object.pose.quaternion;
return (Math.atan2(2 * (w * z + x * y), 1 - 2 * (y * y + z * z)) * 180) / Math.PI;
}
function scaledParameters(
object: EditableMapObject,
scale: [number, number, number],
): Record<string, number> {
const [sx, sy, sz] = scale.map((value) => Math.max(0.001, Math.abs(value))) as [
number,
number,
number,
];
const p = object.parameters;
if (object.type === 'box')
return { sizeX: p.sizeX * sx, sizeY: p.sizeY * sy, sizeZ: p.sizeZ * sz };
if (object.type === 'cylinder')
return { radius: p.radius * Math.max(sx, sy), height: p.height * sz };
if (object.type === 'capsule')
return { radius: p.radius * Math.max(sx, sy), length: p.length * sz };
if (object.type === 'ramp')
return {
length: p.length * sx,
width: p.width * sy,
rise: p.rise * sz,
thickness: p.thickness * sz,
};
return {
stepDepth: p.stepDepth * sx,
width: p.width * sy,
stepHeight: p.stepHeight * sz,
count: p.count,
};
}
export function MapEditorPanel({
document,
loading,
onPreview,
onApply,
onExport,
onConvert,
canConvert,
onBindInteraction,
onSelectPreview,
onTransformMode,
onSnapping,
}: {
document: EditableMapDocument | null;
loading: boolean;
onPreview: (document: EditableMapDocument | null) => void;
onApply: (document: EditableMapDocument) => Promise<boolean>;
onExport: () => void;
onConvert: () => Promise<boolean>;
canConvert: boolean;
onBindInteraction: (callbacks: MapEditorInteractionCallbacks | null) => void;
onSelectPreview: (id: string | null) => void;
onTransformMode: (mode: MapEditorTransformMode) => void;
onSnapping: (translation: number | null, rotationDegrees: number | null) => void;
}) {
const session = useMemo(() => (document ? new MapEditSession(document) : null), [document]);
const [, render] = useState(0),
[selected, setSelected] = useState<string>(),
[selectedType, setSelectedType] = useState<EditableMapObjectType>('box'),
[transformMode, setTransformMode] = useState<MapEditorTransformMode>('translate'),
[snapping, setSnapping] = useState(true),
[converting, setConverting] = useState(false);
const refresh = () => {
render((value) => value + 1);
onPreview(session?.document ?? null);
};
useEffect(() => {
onPreview(session?.document ?? null);
return () => onPreview(null);
}, [session, onPreview]);
useEffect(() => {
if (!session) {
onBindInteraction(null);
return;
}
const callbacks: MapEditorInteractionCallbacks = {
onSelect: (id) => setSelected(id ?? undefined),
onTransform: ({ id, position, quaternion, scale }) => {
const object = session.document.objects.find((item) => item.id === id);
if (!object) return;
session.update(id, {
pose: { position, quaternion },
parameters: scaledParameters(object, scale),
});
setSelected(id);
render((value) => value + 1);
onPreview(session.document);
},
onAddAsset: (type, position, placementMode) => {
const object = session.addAsset(type, position, placementMode);
setSelected(object.id);
render((value) => value + 1);
onPreview(session.document);
onSelectPreview(object.id);
},
};
onBindInteraction(callbacks);
return () => onBindInteraction(null);
}, [session, onBindInteraction, onPreview, onSelectPreview]);
useEffect(() => {
onTransformMode(transformMode);
onSnapping(snapping ? 0.1 : null, snapping ? 5 : null);
}, [transformMode, snapping, onTransformMode, onSnapping]);
useEffect(() => {
if (!session) return;
const keydown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null;
if (target?.matches('input, textarea, select') || target?.isContentEditable || event.altKey)
return;
const key = event.key.toLowerCase();
if ((event.ctrlKey || event.metaKey) && key === 'z') {
event.preventDefault();
if (event.shiftKey) session.redo();
else session.undo();
if (selected && !session.document.objects.some((object) => object.id === selected)) {
setSelected(undefined);
onSelectPreview(null);
}
render((value) => value + 1);
onPreview(session.document);
} else if ((event.ctrlKey || event.metaKey) && key === 'y') {
event.preventDefault();
session.redo();
render((value) => value + 1);
onPreview(session.document);
} else if (!event.ctrlKey && !event.metaKey && key === 'w') setTransformMode('translate');
else if (!event.ctrlKey && !event.metaKey && key === 'e') setTransformMode('rotate');
else if (!event.ctrlKey && !event.metaKey && key === 's') setTransformMode('scale');
else if (event.key === 'Escape') {
setSelected(undefined);
onSelectPreview(null);
} else if ((event.key === 'Delete' || event.key === 'Backspace') && selected) {
event.preventDefault();
session.remove(selected);
setSelected(undefined);
render((value) => value + 1);
onPreview(session.document);
onSelectPreview(null);
}
};
window.addEventListener('keydown', keydown);
return () => window.removeEventListener('keydown', keydown);
}, [session, selected, onPreview, onSelectPreview]);
if (!session)
return (
<div className="space-y-2 rounded border border-border-subtle p-3 text-xs text-text-tertiary">
<p>当前地图没有 V3 authoring.source,保持只读。</p>
<p className="text-[10px] leading-relaxed">
仅由 box、cylinder、capsule 组成且不含资产、材质或碰撞过滤的静态 MJCF
可以安全转换;遇到无法表达的语义会整体拒绝,不会静默丢失内容。
</p>
<div className="flex flex-wrap gap-2">
<Button
variant="primary"
disabled={loading || converting || !canConvert}
onClick={() => {
setConverting(true);
void onConvert().finally(() => setConverting(false));
}}
>
{converting ? '正在转换…' : '创建可编辑副本'}
</Button>
<Button disabled={loading || converting} onClick={onExport}>
导出原地图 ZIP
</Button>
</div>
{!canConvert && <p className="text-warning">该地图没有 MJCF 物理层,无法转换。</p>}
</div>
);
const current = session.document;
const active = current.objects.find((object) => object.id === selected);
const poseLocked = active?.placementMode === 'locked';
const updatePosition = (index: number, value: number) => {
if (!active || !Number.isFinite(value)) return;
const position = [...active.pose.position] as [number, number, number];
position[index] = value;
session.update(active.id, { pose: { ...active.pose, position } });
refresh();
};
const updateParameter = (key: string, value: number) => {
if (!active || !Number.isFinite(value) || value <= 0) return;
session.update(active.id, {
parameters: { ...active.parameters, [key]: key === 'count' ? Math.round(value) : value },
});
refresh();
};
const updateSpawnPosition = (
id: string,
position: [number, number, number],
index: number,
value: number,
) => {
if (!Number.isFinite(value)) return;
const next = [...position] as [number, number, number];
next[index] = value;
session.updateSpawn(id, { position: next });
refresh();
};
return (
<div className="space-y-3">
<div className="grid grid-cols-3 gap-2 rounded border border-border-subtle p-2">
<Button
variant={transformMode === 'translate' ? 'primary' : 'secondary'}
aria-pressed={transformMode === 'translate'}
disabled={loading}
onClick={() => setTransformMode('translate')}
>
移动工具 W
</Button>
<Button
variant={transformMode === 'rotate' ? 'primary' : 'secondary'}
aria-pressed={transformMode === 'rotate'}
disabled={loading}
onClick={() => setTransformMode('rotate')}
>
旋转工具 E
</Button>
<Button
variant={transformMode === 'scale' ? 'primary' : 'secondary'}
aria-pressed={transformMode === 'scale'}
disabled={loading}
onClick={() => setTransformMode('scale')}
>
缩放工具 S
</Button>
<label className="col-span-3 flex items-center justify-between text-[11px] text-text-secondary">
<span>吸附(移动 0.1 m / 旋转 5°)</span>
<input
aria-label="地图编辑吸附"
type="checkbox"
checked={snapping}
disabled={loading}
onChange={(event) => setSnapping(event.target.checked)}
/>
</label>
<p className="col-span-3 text-[10px] text-text-tertiary">
在视口点击对象后拖动操纵轴;只允许绕世界 Z 轴旋转,缩放会写入原语尺寸。
</p>
</div>
<div className="flex gap-2">
<Select
aria-label="新增地图对象类型"
className="min-w-0 flex-1"
value={selectedType}
disabled={loading}
onChange={(event) => setSelectedType(event.target.value as EditableMapObjectType)}
>
{(Object.keys(labels) as EditableMapObjectType[]).map((type) => (
<option key={type} value={type}>
{labels[type]}
</option>
))}
</Select>
<Button
disabled={loading}
onClick={() => {
const object = session.add(selectedType);
setSelected(object.id);
refresh();
onSelectPreview(object.id);
}}
>
新增
</Button>
</div>
<div className="max-h-36 space-y-1 overflow-auto" aria-label="地图对象列表">
{current.objects.map((object) => (
<button
key={object.id}
type="button"
className={`w-full rounded border px-2 py-1 text-left text-xs ${selected === object.id ? 'border-accent bg-accent/10' : 'border-border-subtle'}`}
onClick={() => {
setSelected(object.id);
onSelectPreview(object.id);
}}
>
{object.name} · {labels[object.type]}
{object.placementMode === 'locked' ? ' · 已锁定' : ''}
</button>
))}
{!current.objects.length && <div className="text-xs text-text-tertiary">暂无对象</div>}
</div>
{active && (
<div className="space-y-2 rounded border border-border-subtle p-2">
<label className="block text-[11px] text-text-secondary">
名称
<input
aria-label="对象名称"
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
value={active.name}
onChange={(event) => {
if (!event.target.value.trim()) return;
session.update(active.id, { name: event.target.value });
refresh();
}}
/>
</label>
<label className="block text-[11px] text-text-secondary">
放置方式
<Select
aria-label="对象放置方式"
className="mt-1 w-full"
value={active.placementMode}
disabled={loading}
onChange={(event) => {
session.update(active.id, {
placementMode: event.target.value as MapObjectPlacementMode,
});
refresh();
}}
>
{(Object.keys(MAP_OBJECT_PLACEMENT_LABELS) as MapObjectPlacementMode[]).map(
(mode) => (
<option key={mode} value={mode}>
{MAP_OBJECT_PLACEMENT_LABELS[mode]}
</option>
),
)}
</Select>
<span className="mt-1 block text-[9px] leading-3.5 text-text-tertiary">
{active.placementMode === 'auto_ground'
? '始终将对象底部对齐 z=0。'
: active.placementMode === 'gravity'
? '沿世界 -Z 落到 XY 范围内最高的静态承载面。'
: '保持当前位姿,并禁用表单和视口变换。'}
</span>
</label>
<div className="grid grid-cols-3 gap-2">
{active.pose.position.map((value, index) => (
<label key={index} className="text-[11px] text-text-secondary">
{['X', 'Y', 'Z'][index]}
<input
aria-label={`对象位置${['X', 'Y', 'Z'][index]}`}
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
type="number"
step="0.1"
value={value}
disabled={loading || poseLocked || index === 2}
onChange={(event) => updatePosition(index, Number(event.target.value))}
/>
</label>
))}
</div>
<label className="block text-[11px] text-text-secondary">
绕 Z 旋转(°)
<input
aria-label="对象绕Z旋转"
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
type="number"
step="1"
value={Number(yawDegrees(active).toFixed(4))}
disabled={loading || poseLocked}
onChange={(event) => {
const value = Number(event.target.value);
if (!Number.isFinite(value)) return;
const half = (value * Math.PI) / 360;
session.update(active.id, {
pose: { ...active.pose, quaternion: [Math.cos(half), 0, 0, Math.sin(half)] },
});
refresh();
}}
/>
</label>
<div className="grid grid-cols-2 gap-2">
{Object.entries(active.parameters).map(([key, value]) => (
<label key={key} className="text-[11px] text-text-secondary">
{key}
<input
aria-label={`对象参数${key}`}
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
type="number"
min="0.001"
step={key === 'count' ? 1 : 0.1}
value={value}
onChange={(event) => updateParameter(key, Number(event.target.value))}
/>
</label>
))}
</div>
<label className="flex items-center justify-between text-[11px] text-text-secondary">
<span>启用对象</span>
<input
aria-label="启用地图对象"
type="checkbox"
checked={active.enabled}
onChange={(event) => {
session.update(active.id, { enabled: event.target.checked });
refresh();
}}
/>
</label>
<div className="flex flex-wrap gap-2">
<Button
disabled={loading || poseLocked}
onClick={() => {
session.update(active.id, {
pose: {
...active.pose,
position: [
active.pose.position[0],
active.pose.position[1],
editableObjectGroundHeight(active),
],
},
});
refresh();
}}
>
对齐地面
</Button>
<Button
disabled={loading}
onClick={() => {
const copy = session.duplicate(active.id);
setSelected(copy.id);
refresh();
onSelectPreview(copy.id);
}}
>
复制对象
</Button>
<Button
variant="danger"
disabled={loading}
onClick={() => {
session.remove(active.id);
setSelected(undefined);
refresh();
onSelectPreview(null);
}}
>
删除对象
</Button>
</div>
</div>
)}
<div className="space-y-2 rounded border border-border-subtle p-2">
<div className="flex items-center justify-between text-xs font-medium text-text-primary">
<span>出生点</span>
<Button
disabled={loading}
onClick={() => {
session.addSpawn();
refresh();
}}
>
新增出生点
</Button>
</div>
{current.spawnPoints.map((spawn) => (
<div key={spawn.id} className="space-y-2 rounded bg-panel-muted p-2">
<input
aria-label={`出生点名称 ${spawn.id}`}
className="h-7 w-full rounded border border-border-strong bg-input px-1 text-xs"
value={spawn.name}
onChange={(event) => {
if (!event.target.value.trim()) return;
session.updateSpawn(spawn.id, { name: event.target.value });
refresh();
}}
/>
<div className="grid grid-cols-4 gap-1">
{spawn.position.map((value, index) => (
<label key={index} className="text-[10px] text-text-secondary">
{['X', 'Y', 'Z'][index]}
<input
aria-label={`出生点${spawn.id}位置${['X', 'Y', 'Z'][index]}`}
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
type="number"
step="0.1"
value={value}
onChange={(event) =>
updateSpawnPosition(
spawn.id,
spawn.position,
index,
Number(event.target.value),
)
}
/>
</label>
))}
<label className="text-[10px] text-text-secondary">
Yaw°
<input
aria-label={`出生点${spawn.id}朝向`}
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
type="number"
step="1"
value={spawn.yawDeg}
onChange={(event) => {
const value = Number(event.target.value);
if (!Number.isFinite(value)) return;
session.updateSpawn(spawn.id, { yawDeg: value });
refresh();
}}
/>
</label>
</div>
<Button
variant="danger"
disabled={loading}
onClick={() => {
session.removeSpawn(spawn.id);
refresh();
}}
>
删除出生点
</Button>
</div>
))}
{!current.spawnPoints.length && (
<div className="text-[11px] text-text-tertiary">暂无出生点</div>
)}
</div>
<div className="grid grid-cols-2 gap-2">
<Button
disabled={loading || !session.canUndo}
onClick={() => {
session.undo();
refresh();
}}
>
撤销
</Button>
<Button
disabled={loading || !session.canRedo}
onClick={() => {
session.redo();
refresh();
}}
>
重做
</Button>
<Button
disabled={loading || !session.dirty}
onClick={() => {
session.discard();
setSelected(undefined);
refresh();
onSelectPreview(null);
}}
>
放弃草稿
</Button>
<Button disabled={loading} onClick={onExport}>
导出地图 ZIP
</Button>
</div>
<Button
className="w-full"
disabled={loading || !session.dirty}
onClick={async () => {
const latest = session.document;
const candidate = { ...latest, revision: latest.revision + 1 };
if (await onApply(candidate)) onPreview(null);
else onPreview(session.document);
}}
>
应用并重新编译
</Button>
{session.dirty && (
<div role="status" className="text-xs text-warning">
地图草稿尚未应用
</div>
)}
</div>
);
}
@@ -0,0 +1,99 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { MapDraftStatusOverlay, MapViewportToolbar } from './MapViewportTools';
const noop = () => {};
describe('MapViewportToolbar', () => {
it('统一联动变换、吸附与贴地检测命令', () => {
const onModeChange = vi.fn();
const onSnappingChange = vi.fn();
const onPlacementModeChange = vi.fn();
const onAlignToSurface = vi.fn();
render(
<MapViewportToolbar
visible
interactionActive
mode="translate"
snapping
placementMode="auto_ground"
hasSelectedObject
allowScale
loading={false}
onActivate={noop}
onModeChange={onModeChange}
onSnappingChange={onSnappingChange}
onPlacementModeChange={onPlacementModeChange}
onAlignToSurface={onAlignToSurface}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '旋转工具 E' }));
fireEvent.click(screen.getByRole('button', { name: '网格吸附' }));
fireEvent.change(screen.getByLabelText('贴地检测模式'), {
target: { value: 'gravity' },
});
fireEvent.click(screen.getByRole('button', { name: '立即贴合承载面' }));
expect(onModeChange).toHaveBeenCalledWith('rotate');
expect(onSnappingChange).toHaveBeenCalledWith(false);
expect(onPlacementModeChange).toHaveBeenCalledWith('gravity');
expect(onAlignToSurface).toHaveBeenCalledOnce();
});
it('参数地形或未选中对象时不提供缩放与贴合动作', () => {
render(
<MapViewportToolbar
visible
interactionActive
mode="translate"
snapping={false}
placementMode="auto_ground"
hasSelectedObject={false}
allowScale={false}
loading={false}
onActivate={noop}
onModeChange={noop}
onSnappingChange={noop}
onPlacementModeChange={noop}
onAlignToSurface={noop}
/>,
);
expect(screen.queryByRole('button', { name: '缩放工具 R' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '立即贴合承载面' })).toBeDisabled();
});
});
describe('MapDraftStatusOverlay', () => {
it('常驻显示未保存数量并提供提交与丢弃入口', () => {
const onCommit = vi.fn();
const onDiscard = vi.fn();
render(
<MapDraftStatusOverlay
visible
changeCount={3}
loading={false}
onCommit={onCommit}
onDiscard={onDiscard}
/>,
);
expect(screen.getByText('3 项未保存改动')).toBeVisible();
fireEvent.click(screen.getByRole('button', { name: '提交地图草稿' }));
fireEvent.click(screen.getByRole('button', { name: '丢弃地图草稿' }));
expect(onCommit).toHaveBeenCalledOnce();
expect(onDiscard).toHaveBeenCalledOnce();
});
it('无草稿时显示已同步并禁用动作', () => {
render(
<MapDraftStatusOverlay
visible
changeCount={0}
loading={false}
onCommit={noop}
onDiscard={noop}
/>,
);
expect(screen.getByText('地图草稿已同步')).toBeVisible();
expect(screen.getByRole('button', { name: '提交地图草稿' })).toBeDisabled();
});
});
@@ -0,0 +1,202 @@
import {
ArrowDownToLine,
Check,
Grid3X3,
LockKeyhole,
MapPinned,
Move3d,
Rotate3d,
Scaling,
Trash2,
} from 'lucide-react';
import {
Button,
IconButton,
Select,
ToolbarToggleGroup,
type ToolbarItem,
} from '../../components/ui';
import {
MAP_OBJECT_PLACEMENT_LABELS,
type MapEditorTransformMode,
type MapObjectPlacementMode,
} from '../../map/editor/types';
const TRANSFORM_TOOLS: ToolbarItem<MapEditorTransformMode>[] = [
{ value: 'translate', label: '移动工具 W', icon: Move3d },
{ value: 'rotate', label: '旋转工具 E', icon: Rotate3d },
{ value: 'scale', label: '缩放工具 R', icon: Scaling },
];
export function MapViewportToolbar({
visible,
interactionActive,
mode,
snapping,
placementMode,
hasSelectedObject,
allowScale,
loading,
onActivate,
onModeChange,
onSnappingChange,
onPlacementModeChange,
onAlignToSurface,
}: {
visible: boolean;
interactionActive: boolean;
mode: MapEditorTransformMode;
snapping: boolean;
placementMode: MapObjectPlacementMode;
hasSelectedObject: boolean;
allowScale: boolean;
loading: boolean;
onActivate: () => void;
onModeChange: (mode: MapEditorTransformMode) => void;
onSnappingChange: (value: boolean) => void;
onPlacementModeChange: (mode: MapObjectPlacementMode) => void;
onAlignToSurface: () => void;
}) {
if (!visible) return null;
return (
<div
aria-label="地图视口工具"
className="engineering-glass map-tool-enter absolute left-1/2 top-3 z-20 flex max-w-[calc(100%-24px)] -translate-x-1/2 items-center gap-1.5 rounded-xl border p-1.5"
>
<button
type="button"
aria-label="激活地图编辑"
aria-pressed={interactionActive}
disabled={loading}
onClick={onActivate}
className={`hidden h-8 items-center gap-1.5 rounded-lg px-2 text-[10px] font-semibold transition-colors sm:flex ${interactionActive ? 'bg-accent-soft text-accent' : 'bg-surface/80 text-text-secondary hover:bg-element-hover'}`}
>
<MapPinned className="h-3.5 w-3.5" aria-hidden="true" />
地图编辑
</button>
<ToolbarToggleGroup
items={TRANSFORM_TOOLS.filter((item) => allowScale || item.value !== 'scale')}
value={mode}
onChange={onModeChange}
label="地图变换模式"
/>
<IconButton
active={snapping}
tooltip={snapping ? '关闭网格吸附(当前 0.1 m / 5°)' : '开启网格吸附'}
aria-label="网格吸附"
aria-pressed={snapping}
disabled={loading}
onClick={() => onSnappingChange(!snapping)}
>
<Grid3X3 className="h-3.5 w-3.5" />
</IconButton>
<span aria-hidden="true" className="mx-0.5 h-5 w-px bg-border" />
<label className="flex items-center gap-1 text-[10px] text-text-tertiary">
<span className="hidden lg:inline">贴地</span>
<Select
aria-label="贴地检测模式"
className="w-[112px]"
value={placementMode}
disabled={loading}
onChange={(event) => onPlacementModeChange(event.target.value as MapObjectPlacementMode)}
>
{(Object.keys(MAP_OBJECT_PLACEMENT_LABELS) as MapObjectPlacementMode[]).map((value) => (
<option key={value} value={value}>
{value === 'auto_ground' ? '地面 z=0' : value === 'gravity' ? '表面检测' : '锁定位姿'}
</option>
))}
</Select>
</label>
<IconButton
tooltip={hasSelectedObject ? '重新检测并贴合承载面' : '选中地图物体后可贴合表面'}
aria-label="立即贴合承载面"
disabled={loading || !hasSelectedObject || placementMode === 'locked'}
onClick={onAlignToSurface}
>
{placementMode === 'locked' ? (
<LockKeyhole className="h-3.5 w-3.5" />
) : (
<ArrowDownToLine className="h-3.5 w-3.5" />
)}
</IconButton>
</div>
);
}
export function MapDraftStatusOverlay({
visible,
changeCount,
loading,
onCommit,
onDiscard,
}: {
visible: boolean;
changeCount: number;
loading: boolean;
onCommit: () => void;
onDiscard: () => void;
}) {
if (!visible) return null;
const dirty = changeCount > 0;
return (
<div
aria-label="地图草稿状态"
role="status"
className="engineering-glass absolute bottom-3 left-1/2 z-20 flex max-w-[calc(100%-24px)] -translate-x-1/2 items-center gap-2 rounded-xl border px-2 py-1.5"
>
<span
aria-hidden="true"
className={`h-2 w-2 shrink-0 rounded-full ${dirty ? 'draft-dirty-dot bg-warning' : 'bg-success'}`}
/>
<span className="min-w-0 whitespace-nowrap text-[10px] font-medium text-text-secondary sm:text-[11px]">
{dirty ? `${changeCount} 项未保存改动` : '地图草稿已同步'}
</span>
<Button
variant="ghost"
aria-label="丢弃地图草稿"
disabled={loading || !dirty}
icon={<Trash2 className="h-3 w-3" />}
onClick={onDiscard}
>
<span className="hidden sm:inline">丢弃</span>
</Button>
<Button
variant="primary"
aria-label="提交地图草稿"
disabled={loading || !dirty}
icon={<Check className="h-3 w-3" />}
onClick={onCommit}
>
<span className="hidden sm:inline">提交</span>
</Button>
</div>
);
}
export interface MapAssetDropTarget {
left: number;
top: number;
position: readonly [number, number, number] | null;
}
export function MapAssetDropIndicator({ target }: { target?: MapAssetDropTarget }) {
if (!target) return null;
const valid = Boolean(target.position);
return (
<div className="pointer-events-none absolute inset-0 z-30" aria-label="地图资产放置预览">
<div
className={`absolute -translate-x-1/2 -translate-y-1/2 rounded-full border-2 p-1 shadow-2xl ${valid ? 'border-accent bg-accent/25 text-accent' : 'border-danger bg-danger/20 text-danger'}`}
style={{ left: target.left, top: target.top }}
>
<span className="grid h-8 w-8 place-items-center rounded-full border border-current bg-panel/85 backdrop-blur">
<MapPinned className="h-4 w-4" />
</span>
<span className="absolute left-1/2 top-full mt-2 -translate-x-1/2 whitespace-nowrap rounded-lg border border-border-strong bg-panel/90 px-2 py-1 text-[10px] font-medium shadow-xl backdrop-blur">
{valid
? `释放放置 · ${target.position![0].toFixed(1)}, ${target.position![1].toFixed(1)}`
: '请拖到 3D 地面'}
</span>
</div>
</div>
);
}
@@ -0,0 +1,162 @@
import type { ComponentProps } from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { DEFAULT_PHYSICAL_MAP_CONFIG } from '../../map/types';
import type { SimulationSnapshot } from '../../simulation/SimulationSession';
import { ModelControlsSidebar } from './ModelControlsSidebar';
const snapshot = {
bodies: [
{ id: 0, name: 'world', parentId: 0 },
{ id: 1, name: 'base', parentId: 0 },
{ id: 2, name: 'arm', parentId: 1 },
],
joints: [
{
id: 7,
name: 'arm_joint',
bodyId: 2,
type: 3,
value: 0.25,
min: -1,
max: 1,
limitMin: -1,
limitMax: 1,
limited: true,
limitsIgnored: false,
editable: true,
axis: [0, 0, 1],
},
],
actuators: [],
model: { nbody: 3, njnt: 1, ngeom: 2, ncam: 0, nactuator: 0, nu: 0, nq: 1, nv: 1 },
} as unknown as SimulationSnapshot;
const noop = () => {};
const asyncTrue = async () => true;
function props(
patch: Partial<ComponentProps<typeof ModelControlsSidebar>> = {},
): ComponentProps<typeof ModelControlsSidebar> {
return {
snapshot,
selection: null,
viewerSelection: null,
loading: false,
urdfMode: 'mjcf',
baseMode: 'floating',
showCollision: false,
ignoreJointLimits: false,
jointAdvanced: false,
angleUnit: 'rad',
mapSelection: { kind: 'none' },
mapSceneDirty: false,
maps: [],
showVisualMap: true,
showMapCollision: false,
editorDocument: null,
workspaceTool: null,
onWorkspaceToolChange: noop,
onSelectJoint: noop,
onUrdfMode: noop,
onBaseMode: noop,
onShowCollision: noop,
onResetJoints: noop,
onToggleJointLimits: noop,
onToggleAdvanced: noop,
onToggleAngleUnit: noop,
onActuator: noop,
onActuatorParameters: noop,
onJoint: noop,
onApplyMap: noop,
onMapDraft: noop,
onEditorPreview: noop,
onEditorDraftChange: noop,
onEditorApply: asyncTrue,
onEditorExport: noop,
onEditorConvert: asyncTrue,
onEditorBindInteraction: noop,
onEditorSelect: noop,
onEditorSessionStateChange: noop,
onMapDisplay: noop,
...patch,
};
}
describe('ModelControlsSidebar', () => {
it('没有选择时显示场景摘要,并保留基础地图快速入口', () => {
render(<ModelControlsSidebar {...props()} />);
expect(screen.getByText('未选择对象')).toBeVisible();
expect(screen.getByText('模型摘要')).toBeVisible();
expect(screen.getByText('未选择地图实例')).toBeVisible();
});
it('按照统一选择自动路由 Body 与 Joint 检查器', () => {
const view = render(
<ModelControlsSidebar {...props({ selection: { kind: 'body', bodyId: 2 } })} />,
);
expect(screen.getByText('Robot / Body')).toBeVisible();
expect(screen.getByText('arm', { selector: '[title="arm"]' })).toBeVisible();
view.rerender(
<ModelControlsSidebar {...props({ selection: { kind: 'joint', jointId: 7, bodyId: 2 } })} />,
);
expect(screen.getByText('Robot / Joint')).toBeVisible();
expect(screen.getByText('Hinge · arm')).toBeVisible();
expect(screen.getByText('关联 Actuator')).toBeVisible();
});
it('控制台与数据录制在右侧面板内切换,不创建模态窗口', () => {
const onWorkspaceToolChange = vi.fn();
render(
<ModelControlsSidebar
{...props({
selection: { kind: 'body', bodyId: 2 },
workspaceTool: 'controls',
workspaceTools: (
<div role="tabpanel" aria-label="控制台">
内嵌控制工具
</div>
),
onWorkspaceToolChange,
})}
/>,
);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(screen.getByRole('tabpanel', { name: '控制台' })).toBeVisible();
expect(screen.getByText('内嵌控制工具')).toBeVisible();
const inspector = document.querySelector('[role="tabpanel"][aria-label="检查器"]');
expect(inspector).not.toBeNull();
expect(inspector).not.toBeVisible();
const controlsTab = screen.getByRole('tab', { name: '控制台' });
expect(controlsTab).toHaveAttribute('aria-selected', 'true');
fireEvent.keyDown(controlsTab, { key: 'ArrowRight' });
expect(onWorkspaceToolChange).toHaveBeenCalledWith('data');
fireEvent.click(screen.getByRole('tab', { name: '检查器' }));
expect(onWorkspaceToolChange).toHaveBeenCalledWith(null);
});
it('选择地图实例后自动展示地图检查器,并把全局工具移到右侧入口', () => {
const onWorkspaceToolChange = vi.fn();
render(
<ModelControlsSidebar
{...props({
selection: { kind: 'map', mapAssetId: 'terrain-a' },
activeMapAssetId: 'terrain-a',
activeMapAssetName: '随机粗糙地形',
mapSelection: {
kind: 'builtin',
config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'rough' },
},
onWorkspaceToolChange,
})}
/>,
);
expect(screen.getByText('Map / Instance')).toBeVisible();
expect(screen.getByText('随机粗糙地形', { selector: '[title="随机粗糙地形"]' })).toBeVisible();
fireEvent.click(screen.getByRole('tab', { name: '控制台' }));
fireEvent.click(screen.getByRole('tab', { name: '数据录制' }));
expect(onWorkspaceToolChange.mock.calls).toEqual([['controls'], ['data']]);
});
});
@@ -0,0 +1,253 @@
import type { ReactNode } from 'react';
import { Box, Database, MapPinned, PanelRight, SlidersHorizontal } from 'lucide-react';
import type { MapEntry, ModelEntry } from '../../project/types';
import type { ActuatorParameters, SimulationSnapshot } from '../../simulation/SimulationSession';
import type { UrdfBaseMode, UrdfLoadMode } from '../../simulation/PhysicsAdapter';
import type { ViewerSelection } from '../../viewer/MuJoCoViewer';
import type { MapSelection, PlacedMapSelection } from '../../map/types';
import type {
EditableMapDocument,
MapEditorInteractionCallbacks,
MapEditorSessionState,
} from '../../map/editor/types';
import { PhysicalMapPanel } from '../../map/PhysicalMapPanel';
import {
BodyInspector,
InspectorIdentity,
JointInspector,
ModelSummaryInspector,
} from '../../simulation/RobotInspector';
import type { EditorSelection } from '../editorSelection';
import { SidebarPanel } from './SidebarPanel';
import type { WorkspaceTool } from './WorkspaceToolsPanel';
import { RightSidebarTabs } from './RightSidebarTabs';
interface ModelControlsProps {
snapshot?: SimulationSnapshot;
selection: EditorSelection | null;
viewerSelection: ViewerSelection | null;
selectedFormat?: ModelEntry['format'];
loading: boolean;
visible?: boolean;
urdfMode: UrdfLoadMode;
baseMode: UrdfBaseMode;
showCollision: boolean;
ignoreJointLimits: boolean;
jointAdvanced: boolean;
angleUnit: 'rad' | 'deg';
mapSelection: MapSelection;
activeMapAssetId?: string;
activeMapAssetName?: string;
mapSceneDirty: boolean;
maps: MapEntry[];
showVisualMap: boolean;
showMapCollision: boolean;
editorDocument: EditableMapDocument | null;
editorDraftDocument?: EditableMapDocument;
workspaceTool: WorkspaceTool | null;
workspaceTools?: ReactNode;
onWorkspaceToolChange: (tool: WorkspaceTool | null) => void;
onSelectJoint: (jointId: number, bodyId: number) => void;
onUrdfMode: (value: UrdfLoadMode) => void;
onBaseMode: (value: UrdfBaseMode) => void;
onShowCollision: (value: boolean) => void;
onResetJoints: () => void;
onToggleJointLimits: () => void;
onToggleAdvanced: () => void;
onToggleAngleUnit: () => void;
onActuator: (id: number, value: number) => void;
onActuatorParameters: (id: number, parameters: ActuatorParameters) => void;
onJoint: (id: number, value: number) => void;
onApplyMap: (value: MapSelection) => void;
onMapDraft: (value: PlacedMapSelection) => void;
onEditorPreview: (document: EditableMapDocument | null) => void;
onEditorDraftChange: (
descriptorPath: string,
document: EditableMapDocument,
dirty: boolean,
) => void;
onEditorApply: (document: EditableMapDocument) => Promise<boolean>;
onEditorExport: () => void;
onEditorConvert: () => Promise<boolean>;
onEditorBindInteraction: (callbacks: MapEditorInteractionCallbacks | null) => void;
onEditorSelect: (id: string | null) => void;
onEditorSessionStateChange?: (
descriptorPath: string,
state: MapEditorSessionState | null,
) => void;
onEditorSurfaceHeight?: (position: readonly [number, number, number]) => number | null;
onMapDisplay: (visual: boolean, collision: boolean) => void;
}
export function ModelControlsSidebar(props: ModelControlsProps) {
const snapshot = props.snapshot;
const activeView = props.workspaceTool ?? 'inspector';
const title =
activeView === 'controls' ? '控制台' : activeView === 'data' ? '数据录制' : '检查器';
const icon =
activeView === 'controls' ? (
<SlidersHorizontal />
) : activeView === 'data' ? (
<Database />
) : (
<PanelRight />
);
const tabs = (
<RightSidebarTabs
value={activeView}
onChange={(value) => props.onWorkspaceToolChange(value === 'inspector' ? null : value)}
/>
);
if (!snapshot)
return (
<SidebarPanel title={title} side="right" visible={props.visible} icon={icon}>
{tabs}
{props.workspaceTool ? (
props.workspaceTools
) : (
<div className="p-4 text-sm text-text-tertiary">导入模型后显示检查器</div>
)}
</SidebarPanel>
);
const selection = props.selection;
const selectedBody =
selection?.kind === 'body'
? snapshot.bodies.find((body) => body.id === selection.bodyId)
: undefined;
const selectedJoint =
selection?.kind === 'joint'
? snapshot.joints.find((joint) => joint.id === selection.jointId)
: undefined;
const mapSelected =
(selection?.kind === 'map' || selection?.kind === 'map-object') &&
selection.mapAssetId === props.activeMapAssetId;
const selectedMapObject =
selection?.kind === 'map-object' && mapSelected
? (props.editorDraftDocument ?? props.editorDocument)?.objects.find(
(object) => object.id === selection.objectId,
)
: undefined;
const mapPanel = (
<PhysicalMapPanel
key={`${props.activeMapAssetId ?? 'none'}:${
props.mapSelection.kind === 'project'
? props.mapSelection.descriptorPath
: props.mapSelection.kind
}`}
value={props.mapSelection}
maps={props.maps}
rootBodies={snapshot.bodies
.filter(
(body) =>
body.id !== 0 && body.parentId === 0 && !body.name.startsWith('__platform_map_'),
)
.map((body) => body.name)}
loading={props.loading}
nativeUrdf={props.selectedFormat === 'urdf' && props.urdfMode === 'native'}
showVisualMap={props.showVisualMap}
showMapCollision={props.showMapCollision}
editorDocument={props.editorDocument}
editorDraftDocument={props.editorDraftDocument}
sceneDirty={props.mapSceneDirty}
onEditorPreview={props.onEditorPreview}
onEditorDraftChange={props.onEditorDraftChange}
onEditorApply={props.onEditorApply}
onEditorExport={props.onEditorExport}
onEditorConvert={props.onEditorConvert}
selectedEditorObjectId={
selection?.kind === 'map-object' && mapSelected ? selection.objectId : undefined
}
onEditorBindInteraction={props.onEditorBindInteraction}
onEditorSelect={props.onEditorSelect}
onEditorSessionStateChange={props.onEditorSessionStateChange}
onEditorSurfaceHeight={props.onEditorSurfaceHeight}
onMapDisplay={props.onMapDisplay}
onDraft={props.onMapDraft}
onApply={props.onApplyMap}
/>
);
let inspector: ReactNode;
if (selectedBody) {
inspector = (
<BodyInspector
body={selectedBody}
snapshot={snapshot}
viewerSelection={props.viewerSelection}
showCollision={props.showCollision}
onShowCollision={props.onShowCollision}
onSelectJoint={props.onSelectJoint}
/>
);
} else if (selectedJoint) {
inspector = (
<JointInspector
joint={selectedJoint}
snapshot={snapshot}
loading={props.loading}
ignoreJointLimits={props.ignoreJointLimits}
jointAdvanced={props.jointAdvanced}
angleUnit={props.angleUnit}
onResetJoints={props.onResetJoints}
onToggleJointLimits={props.onToggleJointLimits}
onToggleAdvanced={props.onToggleAdvanced}
onToggleAngleUnit={props.onToggleAngleUnit}
onJoint={props.onJoint}
onActuator={props.onActuator}
onActuatorParameters={props.onActuatorParameters}
/>
);
} else if (mapSelected) {
const mapName = selectedMapObject?.name ?? props.activeMapAssetName ?? '地图实例';
inspector = (
<>
<InspectorIdentity
icon={selectedMapObject ? <Box /> : <MapPinned />}
eyebrow={selectedMapObject ? 'Map / Object' : 'Map / Instance'}
name={mapName}
meta={
selectedMapObject
? `${selectedMapObject.type} · ${props.activeMapAssetName ?? '地图'}`
: props.mapSelection.kind === 'builtin'
? '参数化地形'
: '工程地图包实例'
}
/>
{mapPanel}
</>
);
} else {
inspector = (
<>
<ModelSummaryInspector
snapshot={snapshot}
selectedFormat={props.selectedFormat}
loading={props.loading}
urdfMode={props.urdfMode}
baseMode={props.baseMode}
showCollision={props.showCollision}
onUrdfMode={props.onUrdfMode}
onBaseMode={props.onBaseMode}
onShowCollision={props.onShowCollision}
/>
{props.mapSelection.kind === 'none' && mapPanel}
</>
);
}
return (
<SidebarPanel title={title} side="right" visible={props.visible} icon={icon}>
{tabs}
<div
hidden={props.workspaceTool !== null}
role="tabpanel"
aria-label="检查器"
className="min-h-0 flex-1 overflow-auto panel-scroll"
>
{inspector}
</div>
{props.workspaceTool && props.workspaceTools}
</SidebarPanel>
);
}
@@ -1,118 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { DEFAULT_PHYSICAL_MAP_CONFIG } from '../../map/types';
import { PhysicalMapPanel } from './PhysicalMapPanel';
const common = {
maps: [],
rootBodies: ['robot'],
loading: false,
nativeUrdf: false,
showVisualMap: true,
showMapCollision: false,
editorDocument: null,
onEditorPreview: () => {},
onEditorApply: async () => true,
onEditorExport: () => {},
onEditorConvert: async () => true,
onEditorBindInteraction: () => {},
onEditorSelect: () => {},
onEditorTransformMode: () => {},
onEditorSnapping: () => {},
onMapDisplay: () => {},
};
describe('PhysicalMapPanel', () => {
it('选择楼梯参数后请求重新编译', () => {
const onApply = vi.fn();
render(<PhysicalMapPanel {...common} value={{ kind: 'none' }} onApply={onApply} />);
fireEvent.change(screen.getByLabelText('地图来源'), { target: { value: 'builtin' } });
fireEvent.change(screen.getByLabelText('物理地图预设'), { target: { value: 'stairs' } });
fireEvent.change(screen.getByLabelText('台阶数量'), { target: { value: '6' } });
fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' }));
expect(onApply).toHaveBeenCalledWith({
kind: 'builtin',
config: expect.objectContaining({ preset: 'stairs', stairCount: 6 }),
});
});
it('在右侧参数面板调整系统地形并提交', () => {
const onApply = vi.fn();
render(<PhysicalMapPanel {...common} value={{ kind: 'none' }} onApply={onApply} />);
fireEvent.change(screen.getByLabelText('地图来源'), { target: { value: 'builtin' } });
fireEvent.change(screen.getByLabelText('物理地图预设'), { target: { value: 'rough' } });
fireEvent.change(screen.getByLabelText('地形难度(0–1)'), { target: { value: '0.8' } });
fireEvent.change(screen.getByLabelText('随机种子'), { target: { value: '9' } });
fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' }));
expect(onApply).toHaveBeenCalledWith({
kind: 'builtin',
config: expect.objectContaining({
preset: 'rough',
terrainDifficulty: 0.8,
seed: 9,
}),
});
});
it('可以移动并旋转资产地图', () => {
const onApply = vi.fn();
render(<PhysicalMapPanel {...common} value={{ kind: 'none' }} onApply={onApply} />);
fireEvent.change(screen.getByLabelText('地图来源'), { target: { value: 'builtin' } });
fireEvent.change(screen.getByLabelText('位置 X(m)'), { target: { value: '2.5' } });
fireEvent.change(screen.getByLabelText('位置 Y(m)'), { target: { value: '-1.5' } });
fireEvent.change(screen.getByLabelText('旋转 Z(°)'), { target: { value: '45' } });
fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' }));
expect(onApply).toHaveBeenCalledWith({
kind: 'builtin',
config: expect.objectContaining({ positionX: 2.5, positionY: -1.5, yawDeg: 45 }),
});
});
it('显示工程地图、出生点和根 Body', () => {
const onApply = vi.fn();
render(
<PhysicalMapPanel
{...common}
value={{ kind: 'none' }}
maps={[
{
descriptorPath: 'maps/warehouse/map.json',
schemaVersion: 1 as const,
id: 'warehouse',
name: '仓库',
spawnPoints: [{ id: 'door', name: '入口' }],
},
]}
rootBodies={['robot', 'payload']}
onApply={onApply}
/>,
);
fireEvent.change(screen.getByLabelText('地图来源'), {
target: { value: 'project:maps/warehouse/map.json' },
});
expect(screen.getByLabelText('地图出生点')).toHaveValue('door');
fireEvent.change(screen.getByLabelText('机器人根 Body'), { target: { value: 'robot' } });
fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' }));
expect(onApply).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'project',
descriptorPath: 'maps/warehouse/map.json',
spawnPointId: 'door',
robotRootBody: 'robot',
}),
);
});
it('原生 URDF 模式只允许移除地图', () => {
render(
<PhysicalMapPanel
{...common}
value={{ kind: 'builtin', config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'flat' } }}
nativeUrdf
onApply={() => {}}
/>,
);
expect(screen.getByRole('status')).toHaveTextContent('原生 URDF');
expect(screen.getByRole('button', { name: '应用并重新编译' })).toBeDisabled();
expect(screen.getByLabelText('地图来源')).not.toBeDisabled();
});
});
@@ -0,0 +1,243 @@
import { useState } from 'react';
import { FolderTree, Library, Search } from 'lucide-react';
import type { MapEntry, ModelEntry } from '../../project/types';
import {
countProjectSearchResults,
ProjectTree,
type ProjectTreeFile,
} from '../../project/ProjectTree';
import type { SimulationSnapshot } from '../../simulation/SimulationSession';
import { Button, Tabs, VerticalSplitPane } from '../../components/ui';
import {
DEFAULT_PHYSICAL_MAP_CONFIG,
type MapSelection,
type PlacedMapAsset,
type SystemTerrainPreset,
} from '../../map/types';
import type {
EditableMapDocument,
EditableMapObjectType,
MapObjectPlacementMode,
} from '../../map/editor/types';
import { MapAssetLibrary } from '../../map/MapAssetLibrary';
import type { EditorSelection } from '../editorSelection';
import { ProjectBreadcrumb } from './ProjectBreadcrumb';
import { SceneOutliner, countSceneSearchResults } from './SceneOutliner';
import { SidebarPanel } from './SidebarPanel';
import { TreeSearchField } from './TreeSearchField';
export type ProjectResourceTab = 'assets' | 'files';
export function ProjectSidebar({
projectName,
files,
entries,
selectedEntry,
snapshot,
selection,
loading,
visible = true,
nativeUrdf,
mapSelection,
maps,
placedMaps,
pendingSceneChangeCount,
pendingSceneIds,
editorDocuments,
assetPlacementMode,
activeTab,
onActiveTabChange,
onRemove,
onSelectEntry,
onSelectBody,
onSelectJoint,
onJointHover,
onAddMapAsset,
onAddProjectMap,
onSelectTerrain,
onAssetPlacementModeChange,
onApplyScene,
onDiscardScene,
onSelectMap,
onRemoveMap,
onSelectMapObject,
}: {
projectName?: string;
files: ProjectTreeFile[];
entries: ModelEntry[];
selectedEntry?: string;
snapshot?: SimulationSnapshot;
selection: EditorSelection | null;
loading: boolean;
visible?: boolean;
nativeUrdf: boolean;
mapSelection: MapSelection;
maps: MapEntry[];
placedMaps: PlacedMapAsset[];
pendingSceneChangeCount: number;
pendingSceneIds: string[];
editorDocuments: ReadonlyMap<string, EditableMapDocument>;
assetPlacementMode?: MapObjectPlacementMode;
activeTab?: ProjectResourceTab;
onActiveTabChange?: (value: ProjectResourceTab) => void;
onRemove: () => void;
onSelectEntry: (path: string) => void;
onSelectBody: (bodyId: number) => void;
onSelectJoint: (jointId: number, bodyId: number) => void;
onJointHover: (jointId: number | null) => void;
onAddMapAsset: (
type: EditableMapObjectType,
placementMode: MapObjectPlacementMode,
) => void | Promise<void>;
onAddProjectMap: (descriptorPath: string) => void;
onSelectTerrain: (preset: SystemTerrainPreset) => void;
onAssetPlacementModeChange?: (mode: MapObjectPlacementMode) => void;
onApplyScene: () => void;
onDiscardScene: () => void;
onSelectMap: (id: string) => void;
onRemoveMap: (id: string) => void;
onSelectMapObject: (mapId: string, objectId: string) => void;
}) {
const [internalTab, setInternalTab] = useState<ProjectResourceTab>('assets');
const [fileQuery, setFileQuery] = useState('');
const [sceneQuery, setSceneQuery] = useState('');
const tab = activeTab ?? internalTab;
const fileMatches = countProjectSearchResults(files, fileQuery);
const sceneMatches = countSceneSearchResults(snapshot, placedMaps, editorDocuments, sceneQuery);
return (
<SidebarPanel title="场景大纲与资产中心" side="left" visible={visible} icon={<Library />}>
{projectName ? (
<>
<div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-2.5">
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-accent" title={projectName}>
{projectName}
</div>
<div className="mt-0.5 truncate text-[10px] text-text-tertiary">
{snapshot
? `${snapshot.bodies.filter((body) => body.id > 0 && !body.name.startsWith('__platform_map_')).length} Body`
: '模型未加载'}{' '}
· {placedMaps.length} 个地图实例 · {files.length} 个文件
</div>
</div>
<Button variant="danger" onClick={onRemove} disabled={loading}>
移除
</Button>
</div>
<VerticalSplitPane
storageKey="mujoco-project-sidebar-scene-ratio"
separatorLabel="调整场景大纲与资源面板高度"
defaultRatio={0.42}
minFirstSize={160}
minSecondSize={160}
firstClassName="flex flex-col"
secondClassName="flex flex-col"
first={
<>
<div className="flex h-8 shrink-0 items-center gap-2 px-3 text-[11px] font-semibold uppercase tracking-wide text-text-tertiary">
<Search className="h-3.5 w-3.5" aria-hidden="true" />
场景大纲
</div>
<TreeSearchField
value={sceneQuery}
onChange={setSceneQuery}
resultCount={sceneMatches}
label="搜索场景对象"
placeholder="搜索 Body、关节或地图物体…"
/>
<SceneOutliner
snapshot={snapshot}
maps={placedMaps}
documents={editorDocuments}
selection={selection}
query={sceneQuery}
loading={loading}
pendingSceneChangeCount={pendingSceneChangeCount}
pendingSceneIds={pendingSceneIds}
onSelectBody={onSelectBody}
onSelectJoint={onSelectJoint}
onJointHover={onJointHover}
onSelectMap={onSelectMap}
onSelectMapObject={onSelectMapObject}
onRemoveMap={onRemoveMap}
onApplyScene={onApplyScene}
onDiscardScene={onDiscardScene}
/>
</>
}
second={
<Tabs
label="资产与工程文件"
value={tab}
onValueChange={(value) => {
setInternalTab(value);
onActiveTabChange?.(value);
}}
items={[
{
value: 'assets',
label: '资产库',
icon: <Library className="h-3.5 w-3.5" />,
disabled: !snapshot,
content: (
<MapAssetLibrary
disabled={loading || nativeUrdf}
terrainSize={
mapSelection.kind === 'builtin'
? mapSelection.config.size
: DEFAULT_PHYSICAL_MAP_CONFIG.size
}
maps={maps}
placementMode={assetPlacementMode}
onPlacementModeChange={onAssetPlacementModeChange}
onAdd={onAddMapAsset}
onAddProjectMap={onAddProjectMap}
onSelectTerrain={onSelectTerrain}
/>
),
},
{
value: 'files',
label: '工程文件',
icon: <FolderTree className="h-3.5 w-3.5" />,
content: (
<>
<ProjectBreadcrumb
projectName={projectName}
entries={entries}
selectedEntry={selectedEntry}
loading={loading}
onSelect={onSelectEntry}
/>
<TreeSearchField
value={fileQuery}
onChange={setFileQuery}
resultCount={fileMatches}
label="搜索工程文件"
placeholder="搜索文件或目录…"
/>
<div className="px-2 pb-3">
<ProjectTree
key={projectName}
files={files}
entries={entries}
selectedEntry={selectedEntry}
query={fileQuery}
/>
</div>
</>
),
},
]}
/>
}
/>
</>
) : (
<div className="p-4 text-center text-sm text-text-tertiary">导入模型后显示场景与资产</div>
)}
</SidebarPanel>
);
}
@@ -0,0 +1,65 @@
import { Database, PanelRight, SlidersHorizontal, type LucideIcon } from 'lucide-react';
export type RightSidebarView = 'inspector' | 'controls' | 'data';
const VIEWS: ReadonlyArray<{
value: RightSidebarView;
label: string;
icon: LucideIcon;
}> = [
{ value: 'inspector', label: '检查器', icon: PanelRight },
{ value: 'controls', label: '控制台', icon: SlidersHorizontal },
{ value: 'data', label: '数据录制', icon: Database },
];
export function RightSidebarTabs({
value,
onChange,
}: {
value: RightSidebarView;
onChange: (value: RightSidebarView) => void;
}) {
return (
<div
role="tablist"
aria-label="右侧工作区视图"
className="grid shrink-0 grid-cols-3 gap-1 border-b border-border bg-panel-muted/40 p-2"
>
{VIEWS.map((view, index) => {
const Icon = view.icon;
const selected = value === view.value;
return (
<button
key={view.value}
type="button"
role="tab"
aria-selected={selected}
tabIndex={selected ? 0 : -1}
className={`flex min-w-0 items-center justify-center gap-1.5 rounded-md px-1 py-2 text-[11px] font-medium transition-colors ${
selected
? 'bg-panel text-accent shadow-sm'
: 'text-text-tertiary hover:bg-element-hover hover:text-text-primary'
}`}
onClick={() => onChange(view.value)}
onKeyDown={(event) => {
let nextIndex: number | undefined;
if (event.key === 'ArrowLeft') nextIndex = (index + VIEWS.length - 1) % VIEWS.length;
else if (event.key === 'ArrowRight') nextIndex = (index + 1) % VIEWS.length;
else if (event.key === 'Home') nextIndex = 0;
else if (event.key === 'End') nextIndex = VIEWS.length - 1;
if (nextIndex === undefined) return;
event.preventDefault();
onChange(VIEWS[nextIndex].value);
const tabs =
event.currentTarget.parentElement?.querySelectorAll<HTMLElement>('[role="tab"]');
tabs?.[nextIndex]?.focus();
}}
>
<Icon className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span className="truncate">{view.label}</span>
</button>
);
})}
</div>
);
}
@@ -0,0 +1,120 @@
import { fireEvent, render, screen } from '@testing-library/react';
import type { EditableMapDocument } from '../../map/editor/types';
import type { SimulationSnapshot } from '../../simulation/SimulationSession';
import { SceneOutliner, countSceneSearchResults } from './SceneOutliner';
const snapshot = {
bodies: [
{ id: 0, name: 'world', parentId: 0 },
{ id: 1, name: 'base', parentId: 0 },
{ id: 2, name: 'arm', parentId: 1 },
],
joints: [
{
id: 7,
name: 'arm_joint',
bodyId: 2,
type: 3,
value: 0,
min: -1,
max: 1,
limitMin: -1,
limitMax: 1,
limited: true,
limitsIgnored: false,
editable: true,
axis: [0, 0, 1],
},
],
model: { nbody: 3 },
} as SimulationSnapshot;
const document: EditableMapDocument = {
schemaVersion: 1,
mapId: 'warehouse',
revision: 0,
objects: [
{
id: 'crate-1',
name: '木箱',
type: 'box',
pose: { position: [0, 0, 0.5], quaternion: [1, 0, 0, 0] },
parameters: { sizeX: 1, sizeY: 1, sizeZ: 1 },
friction: [1, 0.005, 0.0001],
rgba: [0.5, 0.6, 0.7, 1],
placementMode: 'auto_ground',
enabled: true,
},
],
spawnPoints: [],
};
const maps = [
{
id: 'map-a',
name: '仓库',
selection: { kind: 'project' as const, descriptorPath: 'maps/warehouse/map.json' },
},
];
const documents = new Map([['maps/warehouse/map.json', document]]);
function renderOutliner(overrides: Record<string, unknown> = {}) {
const props = {
snapshot,
maps,
documents,
selection: null,
query: '',
loading: false,
pendingSceneChangeCount: 0,
pendingSceneIds: [],
onSelectBody: vi.fn(),
onSelectJoint: vi.fn(),
onJointHover: vi.fn(),
onSelectMap: vi.fn(),
onSelectMapObject: vi.fn(),
onRemoveMap: vi.fn(),
onApplyScene: vi.fn(),
onDiscardScene: vi.fn(),
...overrides,
};
render(<SceneOutliner {...props} />);
return props;
}
describe('SceneOutliner', () => {
it('在一棵场景大纲中统一展示机器人、地图实例和地图物体', () => {
renderOutliner();
expect(screen.getByLabelText('场景资产树')).toHaveTextContent('机器人');
expect(screen.getByLabelText('场景资产树')).toHaveTextContent('地图与环境');
expect(screen.getByRole('treeitem', { name: /arm_joint/ })).toBeVisible();
expect(screen.getByRole('treeitem', { name: /木箱/ })).toBeVisible();
expect(countSceneSearchResults(snapshot, maps, documents, '木箱')).toBe(2);
});
it('把所有选择统一上报为稳定 id', () => {
const props = renderOutliner();
fireEvent.click(screen.getByRole('treeitem', { name: 'base' }));
fireEvent.click(screen.getByRole('treeitem', { name: /arm_joint/ }));
fireEvent.click(screen.getByRole('treeitem', { name: /^仓库/ }));
fireEvent.click(screen.getByRole('treeitem', { name: /木箱/ }));
expect(props.onSelectBody).toHaveBeenCalledWith(1);
expect(props.onSelectJoint).toHaveBeenCalledWith(7, 2);
expect(props.onSelectMap).toHaveBeenCalledWith('map-a');
expect(props.onSelectMapObject).toHaveBeenCalledWith('map-a', 'crate-1');
});
it('在大纲中集中处理场景草稿与实例删除', () => {
const props = renderOutliner({
pendingSceneChangeCount: 2,
pendingSceneIds: ['map-a'],
});
expect(screen.getByRole('status')).toHaveTextContent('2 项场景更改待应用');
fireEvent.click(screen.getByRole('button', { name: '应用场景' }));
fireEvent.click(screen.getByRole('button', { name: '放弃更改' }));
fireEvent.click(screen.getByRole('button', { name: '删除地图实例 仓库' }));
expect(props.onApplyScene).toHaveBeenCalledOnce();
expect(props.onDiscardScene).toHaveBeenCalledOnce();
expect(props.onRemoveMap).toHaveBeenCalledWith('map-a');
});
});
@@ -0,0 +1,275 @@
import { useMemo } from 'react';
import { Box, Bot, Layers3, MapPinned, Trash2, Zap } from 'lucide-react';
import { Button, EmptySearchState, SearchHighlight } from '../../components/ui';
import type { EditableMapDocument } from '../../map/editor/types';
import type { PlacedMapAsset } from '../../map/types';
import {
countModelStructureSearchResults,
ModelStructureTree,
} from '../../project/ModelStructureTree';
import type { SimulationSnapshot } from '../../simulation/SimulationSession';
import type { EditorSelection } from '../editorSelection';
interface VisibleMap {
asset: PlacedMapAsset;
objects: EditableMapDocument['objects'];
}
function mapDocument(
asset: PlacedMapAsset,
documents: ReadonlyMap<string, EditableMapDocument>,
): EditableMapDocument | undefined {
return asset.selection.kind === 'project'
? documents.get(asset.selection.descriptorPath)
: undefined;
}
// eslint-disable-next-line react-refresh/only-export-components
export function countSceneSearchResults(
snapshot: SimulationSnapshot | undefined,
maps: readonly PlacedMapAsset[],
documents: ReadonlyMap<string, EditableMapDocument>,
query: string,
): number {
const normalized = query.trim().toLocaleLowerCase();
const robotBodies =
snapshot?.bodies.filter((body) => body.id > 0 && !body.name.startsWith('__platform_map_')) ??
[];
const robotBodyIds = new Set(robotBodies.map((body) => body.id));
const robotCount = snapshot
? countModelStructureSearchResults(
robotBodies,
snapshot.joints.filter((joint) => robotBodyIds.has(joint.bodyId)),
normalized,
)
: 0;
if (!normalized)
return (
robotCount +
maps.reduce((count, map) => count + 1 + (mapDocument(map, documents)?.objects.length ?? 0), 0)
);
return (
robotCount +
maps.reduce((count, map) => {
const document = mapDocument(map, documents);
if (map.name.toLocaleLowerCase().includes(normalized))
return count + 1 + (document?.objects.length ?? 0);
const objectCount =
document?.objects.filter((object) =>
`${object.name} ${object.type}`.toLocaleLowerCase().includes(normalized),
).length ?? 0;
return count + (objectCount ? 1 + objectCount : 0);
}, 0)
);
}
export function SceneOutliner({
snapshot,
maps,
documents,
selection,
query,
loading,
pendingSceneChangeCount,
pendingSceneIds,
onSelectBody,
onSelectJoint,
onJointHover,
onSelectMap,
onSelectMapObject,
onRemoveMap,
onApplyScene,
onDiscardScene,
}: {
snapshot?: SimulationSnapshot;
maps: readonly PlacedMapAsset[];
documents: ReadonlyMap<string, EditableMapDocument>;
selection: EditorSelection | null;
query: string;
loading: boolean;
pendingSceneChangeCount: number;
pendingSceneIds: readonly string[];
onSelectBody: (bodyId: number) => void;
onSelectJoint: (jointId: number, bodyId: number) => void;
onJointHover: (jointId: number | null) => void;
onSelectMap: (mapId: string) => void;
onSelectMapObject: (mapId: string, objectId: string) => void;
onRemoveMap: (mapId: string) => void;
onApplyScene: () => void;
onDiscardScene: () => void;
}) {
const normalized = query.trim().toLocaleLowerCase();
const robotBodies = useMemo(
() =>
snapshot?.bodies.filter((body) => body.id > 0 && !body.name.startsWith('__platform_map_')) ??
[],
[snapshot?.bodies],
);
const robotBodyIds = useMemo(() => new Set(robotBodies.map((body) => body.id)), [robotBodies]);
const robotJoints = useMemo(
() => snapshot?.joints.filter((joint) => robotBodyIds.has(joint.bodyId)) ?? [],
[snapshot?.joints, robotBodyIds],
);
const visibleMaps = useMemo<VisibleMap[]>(
() =>
maps.flatMap((asset) => {
const objects = mapDocument(asset, documents)?.objects ?? [];
if (!normalized || asset.name.toLocaleLowerCase().includes(normalized))
return [{ asset, objects }];
const matches = objects.filter((object) =>
`${object.name} ${object.type}`.toLocaleLowerCase().includes(normalized),
);
return matches.length ? [{ asset, objects: matches }] : [];
}),
[documents, maps, normalized],
);
const hasRobotMatches = snapshot
? countModelStructureSearchResults(robotBodies, robotJoints, normalized) > 0
: false;
const pending = new Set(pendingSceneIds);
return (
<section
aria-label="场景资产树"
className="min-h-0 flex-1 overflow-auto px-2 pb-2 panel-scroll"
>
{pendingSceneChangeCount > 0 && (
<div
role="status"
className="mb-2 rounded-lg border border-accent/30 bg-accent-soft p-2.5 text-[10px] text-text-secondary"
>
<div className="flex items-center gap-1.5 font-medium text-accent">
<Zap className="h-3.5 w-3.5" aria-hidden="true" />
{pendingSceneChangeCount} 项场景更改待应用
</div>
<div className="mt-2 grid grid-cols-2 gap-2">
<Button variant="primary" disabled={loading} onClick={onApplyScene}>
应用场景
</Button>
<Button variant="ghost" disabled={loading} onClick={onDiscardScene}>
放弃更改
</Button>
</div>
</div>
)}
{snapshot && hasRobotMatches && (
<details open className="group/robot">
<summary className="flex cursor-pointer select-none items-center gap-2 rounded px-1.5 py-1.5 text-xs font-semibold text-text-primary hover:bg-element-hover">
<Bot className="h-3.5 w-3.5 text-accent" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate">机器人</span>
<span className="technical-value text-[9px] font-normal text-text-tertiary">
{robotBodies.length} Body
</span>
</summary>
<div className="ml-2 border-l border-border pl-1">
<ModelStructureTree
bodies={robotBodies}
joints={robotJoints}
query={query}
selectedBodyId={selection?.kind === 'body' ? selection.bodyId : undefined}
selectedJointId={selection?.kind === 'joint' ? selection.jointId : undefined}
onSelectBody={onSelectBody}
onSelectJoint={(joint) => onSelectJoint(joint.id, joint.bodyId)}
onJointHover={onJointHover}
/>
</div>
</details>
)}
{visibleMaps.length > 0 && (
<details open className="mt-1">
<summary className="flex cursor-pointer select-none items-center gap-2 rounded px-1.5 py-1.5 text-xs font-semibold text-text-primary hover:bg-element-hover">
<Layers3 className="h-3.5 w-3.5 text-accent" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate">地图与环境</span>
<span className="technical-value text-[9px] font-normal text-text-tertiary">
{maps.length} 实例
</span>
</summary>
<ul role="tree" aria-label="地图物体树" className="ml-2 border-l border-border pl-1">
{visibleMaps.map(({ asset, objects }) => {
const mapSelected =
(selection?.kind === 'map' || selection?.kind === 'map-object') &&
selection.mapAssetId === asset.id;
return (
<li key={asset.id} role="none">
<div
className={`flex items-center rounded ${
selection?.kind === 'map' && mapSelected
? 'bg-accent-soft text-accent'
: 'text-text-secondary hover:bg-element-hover'
}`}
>
<button
type="button"
role="treeitem"
aria-selected={selection?.kind === 'map' && mapSelected}
className="flex min-w-0 flex-1 items-center gap-1.5 px-1.5 py-1 text-left text-xs"
disabled={loading}
onClick={() => onSelectMap(asset.id)}
>
<MapPinned className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate">
<SearchHighlight text={asset.name} query={query} />
</span>
{pending.has(asset.id) && (
<span className="rounded bg-warning/10 px-1 text-[9px] text-warning">
待应用
</span>
)}
</button>
<button
type="button"
aria-label={`删除地图实例 ${asset.name}`}
className="mr-1 rounded p-1 text-text-tertiary hover:bg-danger/10 hover:text-danger"
disabled={loading}
onClick={() => onRemoveMap(asset.id)}
>
<Trash2 className="h-3 w-3" aria-hidden="true" />
</button>
</div>
{objects.length > 0 && (
<ul role="group" className="ml-3 border-l border-border pl-1">
{objects.map((object) => {
const selected =
selection?.kind === 'map-object' &&
selection.mapAssetId === asset.id &&
selection.objectId === object.id;
return (
<li role="none" key={object.id}>
<button
type="button"
role="treeitem"
aria-selected={selected}
disabled={loading}
className={`flex w-full items-center gap-1.5 rounded px-1.5 py-1 text-left text-[11px] ${
selected
? 'bg-accent-soft text-accent'
: 'text-text-secondary hover:bg-element-hover'
}`}
onClick={() => onSelectMapObject(asset.id, object.id)}
>
<Box className="h-3 w-3 shrink-0" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate">
<SearchHighlight text={object.name} query={query} />
</span>
<span className="text-[9px] text-text-tertiary">{object.type}</span>
</button>
</li>
);
})}
</ul>
)}
</li>
);
})}
</ul>
</details>
)}
{!hasRobotMatches && !visibleMaps.length && (
<EmptySearchState label={normalized ? '没有匹配的场景对象' : '加载模型后显示场景层级'} />
)}
</section>
);
}
@@ -1,10 +1,18 @@
import { Dialog, Kbd, Separator } from '../../components/ui';
const shortcuts = [
['Space', '播放 / 暂停'],
['R', '重置仿真'],
['R', '重置仿真(非地图编辑)'],
['1', '选择模式'],
['2', '关节拖动'],
['3', '外力施加'],
['W / E / R', '地图移动 / 旋转 / 缩放'],
['F', '聚焦当前对象'],
['Delete', '删除当前地图对象'],
['Ctrl / Cmd + S', '保存并编译地图草稿'],
['Ctrl / Cmd + Z', '撤销地图编辑'],
['Ctrl / Cmd + Y / Shift + Z', '重做地图编辑'],
['G', '切换地图网格吸附'],
['Esc', '取消选择或关闭浮层'],
];
export function ShortcutHelpDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
return (
@@ -29,7 +37,8 @@ export function ShortcutHelpDialog({ open, onClose }: { open: boolean; onClose:
<li>左键拖动:旋转相机或执行当前交互工具</li>
<li>右键拖动:平移相机</li>
<li>滚轮:缩放视口</li>
<li>选择物体后可在右侧“属性”中查看信息</li>
<li>选择物体后可在右侧 Inspector 中编辑尺寸、位姿、材质和摩擦</li>
<li>从左侧资产库拖动几何障碍物到视口地面即可放置</li>
</ul>
</section>
</Dialog>
@@ -1,83 +1,28 @@
import { useState, type ReactNode } from 'react';
import {
Box,
Database,
FolderTree,
Info,
Map as MapIcon,
Settings2,
SlidersHorizontal,
} from 'lucide-react';
import type { MapEntry, ModelEntry } from '../../project/types';
import {
countProjectSearchResults,
ProjectTree,
type ProjectTreeFile,
} from '../../project/ProjectTree';
import {
countModelStructureSearchResults,
ModelStructureTree,
} from '../../project/ModelStructureTree';
import type {
ActuatorInfo,
ActuatorParameters,
SimulationSnapshot,
} from '../../simulation/SimulationSession';
import type { UrdfBaseMode, UrdfLoadMode } from '../../simulation/PhysicsAdapter';
import type { DataRecorderConfig } from '../../simulation/DataRecorder';
import type { ViewerSelection } from '../../viewer/MuJoCoViewer';
import type { ControllerCommand, ControllerStatus } from '../../controller/types';
import type { RLCommand, RLPolicyStatus } from '../../rl/types';
import {
Badge,
Button,
CollapsibleSection,
CopyButton,
PropertyRow,
ResizablePanel,
Select,
Tabs,
} from '../../components/ui';
import { TreeSearchField } from './TreeSearchField';
import { ProjectBreadcrumb } from './ProjectBreadcrumb';
import { PythonControllerPanel } from './PythonControllerPanel';
import { RLPolicyPanel } from './RLPolicyPanel';
import { LocalTrainingPanel } from './LocalTrainingPanel';
import { DataRecordingPanel } from './DataRecordingPanel';
import { PhysicalMapPanel } from './PhysicalMapPanel';
import { MapAssetLibrary } from './MapAssetLibrary';
import {
DEFAULT_PHYSICAL_MAP_CONFIG,
type MapSelection,
type SystemTerrainPreset,
} from '../../map/types';
import type {
EditableMapDocument,
EditableMapObjectType,
MapEditorInteractionCallbacks,
MapEditorTransformMode,
MapObjectPlacementMode,
} from '../../map/editor/types';
import type { ReactNode } from 'react';
import { Settings2 } from 'lucide-react';
import { ResizablePanel } from '../../components/ui';
export function SidebarPanel({
title,
side,
children,
visible = true,
icon,
}: {
title: string;
side: 'left' | 'right';
children: ReactNode;
visible?: boolean;
icon?: ReactNode;
}) {
return (
<ResizablePanel side={side} storageKey={`mujoco-${side}-sidebar-width`} visible={visible}>
<aside
className={`flex h-full w-full min-w-0 flex-col overflow-hidden bg-panel ${side === 'left' ? 'border-r' : 'border-l'} border-border`}
>
<h2 className="flex h-10 shrink-0 items-center gap-2 border-b border-border bg-panel/95 px-3 text-sm font-semibold tracking-tight text-text-primary">
<span className="grid h-6 w-6 place-items-center rounded-md bg-accent-soft text-accent">
<Settings2 aria-hidden="true" className="h-3.5 w-3.5" />
<h2 className="flex h-10 shrink-0 items-center gap-2 border-b border-border bg-gradient-to-r from-panel via-surface/70 to-panel px-3 text-sm font-semibold tracking-tight text-text-primary shadow-[inset_0_-1px_0_rgb(255_255_255/0.025)]">
<span className="grid h-6 w-6 place-items-center rounded-md bg-accent-soft text-accent [&>svg]:h-3.5 [&>svg]:w-3.5">
{icon ?? <Settings2 aria-hidden="true" className="h-3.5 w-3.5" />}
</span>
{title}
</h2>
@@ -86,828 +31,3 @@ export function SidebarPanel({
</ResizablePanel>
);
}
export function ProjectSidebar({
projectName,
files,
entries,
selectedEntry,
snapshot,
loading,
visible = true,
nativeUrdf,
mapSelection,
editorDocument,
activeTab,
onActiveTabChange,
onRemove,
onSelectEntry,
onJointHover,
onAddMapAsset,
onSelectTerrain,
onSelectMapObject,
}: {
projectName?: string;
files: ProjectTreeFile[];
entries: ModelEntry[];
selectedEntry?: string;
snapshot?: SimulationSnapshot;
loading: boolean;
visible?: boolean;
nativeUrdf: boolean;
mapSelection: MapSelection;
editorDocument: EditableMapDocument | null;
activeTab?: 'project' | 'structure' | 'assets';
onActiveTabChange?: (value: 'project' | 'structure' | 'assets') => void;
onRemove: () => void;
onSelectEntry: (path: string) => void;
onJointHover: (jointId: number | null) => void;
onAddMapAsset: (
type: EditableMapObjectType,
placementMode: MapObjectPlacementMode,
) => void | Promise<void>;
onSelectTerrain: (preset: SystemTerrainPreset) => void;
onSelectMapObject: (id: string) => void;
}) {
const [internalTab, setInternalTab] = useState<'project' | 'structure' | 'assets'>('project'),
[fileQuery, setFileQuery] = useState(''),
[structureQuery, setStructureQuery] = useState('');
const tab = activeTab ?? internalTab,
fileMatches = countProjectSearchResults(files, fileQuery),
structureMatches = snapshot
? countModelStructureSearchResults(snapshot.bodies, snapshot.joints, structureQuery)
: 0;
return (
<SidebarPanel title="资产与结构" side="left" visible={visible}>
{projectName ? (
<>
<div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-2.5">
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-accent" title={projectName}>
{projectName}
</div>
<div className="mt-0.5 text-[10px] text-text-tertiary">{files.length} 个文件</div>
</div>
<Button variant="danger" onClick={onRemove} disabled={loading}>
移除
</Button>
</div>
<ProjectBreadcrumb
projectName={projectName}
entries={entries}
selectedEntry={selectedEntry}
loading={loading}
onSelect={onSelectEntry}
/>
<Tabs
label="工程侧栏"
value={tab}
onValueChange={(value) => {
setInternalTab(value);
onActiveTabChange?.(value);
}}
items={[
{
value: 'project',
label: '工程',
icon: <FolderTree className="h-3.5 w-3.5" />,
content: (
<>
<TreeSearchField
value={fileQuery}
onChange={setFileQuery}
resultCount={fileMatches}
label="搜索工程文件"
placeholder="搜索文件或目录…"
/>
<div className="px-2 pb-3">
<ProjectTree
key={projectName}
files={files}
entries={entries}
selectedEntry={selectedEntry}
query={fileQuery}
/>
</div>
</>
),
},
{
value: 'structure',
label: '模型结构',
icon: <Box className="h-3.5 w-3.5" />,
disabled: !snapshot,
content: snapshot ? (
<>
<TreeSearchField
value={structureQuery}
onChange={setStructureQuery}
resultCount={structureMatches}
label="搜索模型结构"
placeholder="搜索 Body 或关节…"
/>
<div className="px-2 pb-3">
<ModelStructureTree
bodies={snapshot.bodies}
joints={snapshot.joints}
onJointHover={onJointHover}
query={structureQuery}
/>
</div>
</>
) : (
<p className="p-4 text-center text-xs text-text-tertiary">加载模型后显示结构</p>
),
},
{
value: 'assets',
label: '资产',
icon: <MapIcon className="h-3.5 w-3.5" />,
disabled: !snapshot,
content: (
<MapAssetLibrary
disabled={loading || nativeUrdf}
terrainSize={
mapSelection.kind === 'builtin'
? mapSelection.config.size
: DEFAULT_PHYSICAL_MAP_CONFIG.size
}
document={editorDocument}
onAdd={onAddMapAsset}
onSelectTerrain={onSelectTerrain}
onSelectObject={onSelectMapObject}
/>
),
},
]}
/>
</>
) : (
<div className="p-4 text-center text-sm text-text-tertiary">导入模型后显示工程资源</div>
)}
</SidebarPanel>
);
}
interface ModelControlsProps {
snapshot?: SimulationSnapshot;
selection: ViewerSelection | null;
selectedFormat?: ModelEntry['format'];
loading: boolean;
visible?: boolean;
urdfMode: UrdfLoadMode;
baseMode: UrdfBaseMode;
showCollision: boolean;
ignoreJointLimits: boolean;
jointAdvanced: boolean;
angleUnit: 'rad' | 'deg';
forceScale: number;
controllerPaths: string[];
selectedControllerPath?: string;
controllerStatus?: ControllerStatus;
policyPaths: string[];
selectedPolicyPath?: string;
policyStatus?: RLPolicyStatus;
mapSelection: MapSelection;
maps: MapEntry[];
showVisualMap: boolean;
showMapCollision: boolean;
editorDocument: EditableMapDocument | null;
onUrdfMode: (value: UrdfLoadMode) => void;
onBaseMode: (value: UrdfBaseMode) => void;
onShowCollision: (value: boolean) => void;
onResetJoints: () => void;
onToggleJointLimits: () => void;
onToggleAdvanced: () => void;
onToggleAngleUnit: () => void;
onActuator: (id: number, value: number) => void;
onActuatorParameters: (id: number, parameters: ActuatorParameters) => void;
onJoint: (id: number, value: number) => void;
onForceScale: (value: number) => void;
onSelectControllerPath: (path: string) => void;
onLoadControllerPath: (path: string) => void;
onImportController: (file: File) => void;
onToggleController: (enabled: boolean) => void;
onControllerCommand: (command: ControllerCommand) => void;
onRemoveController: () => void;
onSelectPolicyPath: (path: string) => void;
onLoadPolicyPath: (path: string) => void;
onImportPolicy: (file: File) => void;
onTogglePolicy: (enabled: boolean) => void;
onPolicyCommand: (command: RLCommand) => void;
onRemovePolicy: () => void;
onApplyMap: (value: MapSelection) => void;
onEditorPreview: (document: EditableMapDocument | null) => void;
onEditorApply: (document: EditableMapDocument) => Promise<boolean>;
onEditorExport: () => void;
onEditorConvert: () => Promise<boolean>;
onEditorBindInteraction: (callbacks: MapEditorInteractionCallbacks | null) => void;
onEditorSelect: (id: string | null) => void;
onEditorTransformMode: (mode: MapEditorTransformMode) => void;
onEditorSnapping: (translation: number | null, rotationDegrees: number | null) => void;
onMapDisplay: (visual: boolean, collision: boolean) => void;
onMapTabOpen?: () => void;
onDataRecorderConfigure: (patch: Partial<DataRecorderConfig>) => void;
onDataRecordingStart: () => void;
onDataRecordingStop: () => void;
onDataRecordingClear: () => void;
onDataRecordingExport: (format: 'csv' | 'json') => void;
}
export function ModelControlsSidebar(props: ModelControlsProps) {
const [tab, setTab] = useState<'properties' | 'controls' | 'data' | 'map'>('properties'),
s = props.snapshot;
if (!s)
return (
<SidebarPanel title="属性与参数" side="right" visible={props.visible}>
<div className="p-4 text-sm text-text-tertiary">导入模型后显示属性</div>
</SidebarPanel>
);
const properties = (
<>
<CollapsibleSection title="模型信息" defaultOpen badge={<Badge>{s.model.nbody} Body</Badge>}>
<div>
<PropertyRow label="Body" value={s.model.nbody} />
<PropertyRow label="Joint" value={s.model.njnt} />
<PropertyRow label="Geom" value={s.model.ngeom} />
<PropertyRow label="Actuator" value={s.model.nactuator} />
<PropertyRow label="qpos / qvel" value={`${s.model.nq} / ${s.model.nv}`} />
</div>
</CollapsibleSection>
{props.selectedFormat === 'urdf' && (
<CollapsibleSection title="URDF 处理方式" defaultOpen={false}>
<Select
aria-label="URDF 处理方式"
className="w-full"
value={props.urdfMode}
disabled={props.loading}
onChange={(event) => props.onUrdfMode(event.target.value as UrdfLoadMode)}
>
<option value="mjcf">转换为 MJCF(推荐)</option>
<option value="native">MuJoCo 原生 URDF</option>
</Select>
<label className="mt-3 block text-xs text-text-secondary">
<span className="mb-1 block">基座类型</span>
<Select
aria-label="URDF 基座类型"
className="w-full"
value={props.baseMode}
disabled={props.loading || props.urdfMode === 'native'}
onChange={(event) => props.onBaseMode(event.target.value as UrdfBaseMode)}
>
<option value="floating">浮动基座(Free Joint)</option>
<option value="fixed">固定基座(连接世界)</option>
</Select>
</label>
<p className="mt-2 text-xs text-text-tertiary">
MJCF 模式保留 visual mesh、添加物理地面,并将模型最低点对齐到 z=0。
</p>
<Check
label="显示碰撞几何"
checked={props.showCollision}
onChange={props.onShowCollision}
/>
</CollapsibleSection>
)}
<CollapsibleSection title="当前选择" defaultOpen>
{props.selection ? (
<div className="text-xs">
<PropertyRow
label="Body"
value={props.selection.bodyName}
action={<CopyButton value={props.selection.bodyName} label="复制 Body 名称" />}
/>
<PropertyRow
label="标识"
value={`${props.selection.bodyId} / ${props.selection.geomId} / ${props.selection.geomType}`}
action={
<CopyButton
value={`body ${props.selection.bodyId}, geom ${props.selection.geomId}, type ${props.selection.geomType}`}
label="复制标识"
/>
}
/>
<PropertyRow
label="位置"
value={props.selection.position.map((value) => value.toFixed(3)).join(', ')}
action={<CopyButton value={props.selection.position.join(', ')} label="复制位置" />}
/>
</div>
) : (
<p className="flex items-center gap-2 text-xs text-text-tertiary">
<Info className="h-3.5 w-3.5" />
在视口中单击物体
</p>
)}
</CollapsibleSection>
</>
);
const controls = (
<>
<CollapsibleSection
title="ONNX 强化学习策略"
defaultOpen
badge={s.rlPolicy ? <Badge>{s.rlPolicy.enabled ? '推理' : '停止'}</Badge> : undefined}
>
<RLPolicyPanel
paths={props.policyPaths}
selectedPath={props.selectedPolicyPath}
status={props.policyStatus ?? s.rlPolicy}
loading={props.loading}
onSelectPath={props.onSelectPolicyPath}
onLoadPath={props.onLoadPolicyPath}
onImport={props.onImportPolicy}
onToggle={props.onTogglePolicy}
onCommand={props.onPolicyCommand}
onRemove={props.onRemovePolicy}
/>
</CollapsibleSection>
<CollapsibleSection title="本地强化学习训练" defaultOpen={false}>
<LocalTrainingPanel onPolicyReady={props.onImportPolicy} />
</CollapsibleSection>
<CollapsibleSection
title="Python 控制器"
defaultOpen
badge={s.controller ? <Badge>{s.controller.enabled ? '运行' : '停止'}</Badge> : undefined}
>
<PythonControllerPanel
paths={props.controllerPaths}
selectedPath={props.selectedControllerPath}
status={props.controllerStatus ?? s.controller}
loading={props.loading}
onSelectPath={props.onSelectControllerPath}
onLoadPath={props.onLoadControllerPath}
onImport={props.onImportController}
onToggle={props.onToggleController}
onCommand={props.onControllerCommand}
onRemove={props.onRemoveController}
/>
</CollapsibleSection>
<CollapsibleSection
title="Actuator"
defaultOpen={false}
badge={<Badge>{s.actuators.length}</Badge>}
>
{s.actuators.length ? (
s.actuators.map((actuator) => (
<ActuatorControl
key={actuator.id}
actuator={actuator}
onControl={(value) => props.onActuator(actuator.id, value)}
onParameters={(parameters) => props.onActuatorParameters(actuator.id, parameters)}
/>
))
) : (
<p className="text-xs text-text-tertiary">模型没有驱动器</p>
)}
</CollapsibleSection>
<CollapsibleSection title="关节" defaultOpen badge={<Badge>{s.joints.length}</Badge>}>
<div className="mb-4 grid grid-cols-2 gap-2">
<Button onClick={props.onResetJoints}>重置关节</Button>
<Button
variant={props.ignoreJointLimits ? 'primary' : 'secondary'}
aria-pressed={props.ignoreJointLimits}
onClick={props.onToggleJointLimits}
>
忽略关节限位
</Button>
<Button
variant={props.jointAdvanced ? 'primary' : 'secondary'}
aria-pressed={props.jointAdvanced}
onClick={props.onToggleAdvanced}
>
高级
</Button>
<Button
variant={props.angleUnit === 'deg' ? 'primary' : 'secondary'}
aria-pressed={props.angleUnit === 'deg'}
onClick={props.onToggleAngleUnit}
>
{props.angleUnit === 'rad' ? 'rad 弧度制' : '° 角度制'}
</Button>
</div>
{s.joints.map((joint) => {
const scale = joint.type === 3 && props.angleUnit === 'deg' ? 180 / Math.PI : 1,
unit =
joint.type === 3
? props.angleUnit === 'deg'
? '°'
: ' rad'
: joint.type === 2
? ' m'
: '';
return (
<ControlSlider
key={joint.id}
label={`${joint.name}${joint.editable ? '' : '(只读)'}`}
value={joint.value * scale}
min={joint.min * scale}
max={joint.max * scale}
unit={unit}
advanced={props.jointAdvanced}
limited={joint.limited}
limitsIgnored={joint.limitsIgnored}
limitMin={joint.limitMin * scale}
limitMax={joint.limitMax * scale}
disabled={!joint.editable}
onChange={(value) => props.onJoint(joint.id, value / scale)}
/>
);
})}
</CollapsibleSection>
<CollapsibleSection title="外力强度" defaultOpen={false}>
<ControlSlider
label={`${props.forceScale.toFixed(0)} N/屏幕单位`}
value={props.forceScale}
min={5}
max={200}
onChange={props.onForceScale}
/>
<p className="text-xs text-text-tertiary">
选择“外力施加”,在动态物体上按住拖动,松开即清零。
</p>
</CollapsibleSection>
</>
);
return (
<SidebarPanel title="属性与参数" side="right" visible={props.visible}>
<Tabs
label="模型控制侧栏"
value={tab}
onValueChange={(value) => {
setTab(value);
if (value === 'map') props.onMapTabOpen?.();
}}
items={[
{
value: 'properties',
label: '属性',
icon: <Info className="h-3.5 w-3.5" />,
content: properties,
},
{
value: 'controls',
label: '控制',
icon: <SlidersHorizontal className="h-3.5 w-3.5" />,
content: controls,
},
{
value: 'data',
label: '数据',
icon: <Database className="h-3.5 w-3.5" />,
content: (
<DataRecordingPanel
status={s.telemetry}
bodies={s.bodies}
onConfigure={props.onDataRecorderConfigure}
onStart={props.onDataRecordingStart}
onStop={props.onDataRecordingStop}
onClear={props.onDataRecordingClear}
onExport={props.onDataRecordingExport}
/>
),
},
{
value: 'map',
label: '地图',
icon: <MapIcon className="h-3.5 w-3.5" />,
content: (
<PhysicalMapPanel
key={JSON.stringify(props.mapSelection)}
value={props.mapSelection}
maps={props.maps}
rootBodies={s.bodies
.filter(
(body) =>
body.id !== 0 &&
body.parentId === 0 &&
!body.name.startsWith('__platform_map_'),
)
.map((body) => body.name)}
loading={props.loading}
nativeUrdf={props.selectedFormat === 'urdf' && props.urdfMode === 'native'}
showVisualMap={props.showVisualMap}
showMapCollision={props.showMapCollision}
editorDocument={props.editorDocument}
onEditorPreview={props.onEditorPreview}
onEditorApply={props.onEditorApply}
onEditorExport={props.onEditorExport}
onEditorConvert={props.onEditorConvert}
onEditorBindInteraction={props.onEditorBindInteraction}
onEditorSelect={props.onEditorSelect}
onEditorTransformMode={props.onEditorTransformMode}
onEditorSnapping={props.onEditorSnapping}
onMapDisplay={props.onMapDisplay}
onApply={props.onApplyMap}
/>
),
},
]}
/>
</SidebarPanel>
);
}
export function ActuatorControl({
actuator,
onControl,
onParameters,
}: {
actuator: ActuatorInfo;
onControl: (value: number) => void;
onParameters: (parameters: ActuatorParameters) => void;
}) {
const isMotor = actuator.kind === 'motor',
isPosition = actuator.kind === 'position',
editable = isMotor || isPosition,
baseTargetScale = isPosition && actuator.jointType === 3 ? 180 / Math.PI : 1,
targetScale =
isPosition && Math.abs(actuator.gear) > 1e-9
? baseTargetScale / actuator.gear
: baseTargetScale;
const clampForce = (value: number) =>
actuator.forceLimited
? Math.min(actuator.forceMax, Math.max(actuator.forceMin, value))
: value,
physicalScale = actuator.gear * actuator.gain;
const forceA = clampForce(actuator.min * actuator.gain) * actuator.gear,
forceB = clampForce(actuator.max * actuator.gain) * actuator.gear;
const targetA = actuator.min * targetScale,
targetB = actuator.max * targetScale,
outputMin = isMotor ? Math.min(forceA, forceB) : Math.min(targetA, targetB),
outputMax = isMotor ? Math.max(forceA, forceB) : Math.max(targetA, targetB);
const outputValue = isMotor
? clampForce(actuator.value * actuator.gain) * actuator.gear
: actuator.value * targetScale,
outputDisabled =
(isMotor && Math.abs(physicalScale) <= 1e-9) ||
(isPosition && Math.abs(actuator.gear) <= 1e-9);
const outputLabel = isMotor
? actuator.jointType === 3
? '输出力矩'
: '输出力'
: isPosition
? actuator.jointType === 3
? '目标角度'
: '目标位置'
: '控制输入';
const forceUnit = actuator.jointType === 3 ? 'N·m' : actuator.jointType === 2 ? 'N' : '',
jointForceA = actuator.forceMin * actuator.gear,
jointForceB = actuator.forceMax * actuator.gear,
jointForceMin = Math.min(jointForceA, jointForceB),
jointForceMax = Math.max(jointForceA, jointForceB);
const update = (patch: Partial<ActuatorParameters>) => onParameters({ ...actuator, ...patch }),
controlLabel = isPosition ? (actuator.jointType === 3 ? '角度' : '位置') : '控制',
gearSquared = actuator.gear * actuator.gear;
return (
<div className="mb-3 rounded-lg border border-border bg-surface p-2.5">
<div className="mb-2 flex min-w-0 items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-xs font-medium text-text-primary" title={actuator.name}>
{actuator.name}
</div>
<div className="mt-0.5 truncate text-[10px] text-text-tertiary">
{actuator.jointName ? `关节:${actuator.jointName}` : '未关联标量关节'}
</div>
</div>
<Badge>{actuator.unit || 'u'}</Badge>
</div>
{actuator.controlCount === 1 ? (
<ControlSlider
label={outputLabel}
value={outputValue}
min={outputMin}
max={outputMax}
disabled={outputDisabled}
unit={actuator.unit ? ` ${actuator.unit}` : ''}
onChange={(value) => {
if (outputDisabled) return;
onControl(isMotor ? value / physicalScale : value / targetScale);
}}
/>
) : (
<p className="mb-2 text-[10px] leading-4 text-text-tertiary">
该驱动器包含 {actuator.controlCount} 个控制分量,请在 MJCF 源码或专用控制器中设置。
</p>
)}
{actuator.controlCount === 1 && !actuator.ctrlLimited && (
<div className="mb-2">
<ParameterInput
label={`${controlLabel}输入(不限幅)`}
value={actuator.value * targetScale}
onCommit={(value) => onControl(value / targetScale)}
/>
</div>
)}
{editable ? (
<details className="group border-t border-border pt-2">
<summary className="cursor-pointer select-none text-xs font-medium text-text-secondary hover:text-text-primary">
常用参数
</summary>
<div className="mt-2 grid grid-cols-2 gap-2">
{isPosition ? (
<>
<ParameterInput
label={`位置增益 kp(${forceUnit}/${actuator.jointType === 3 ? 'rad' : 'm'})`}
value={actuator.kp * gearSquared}
disabled={gearSquared <= 1e-18}
onCommit={(kp) => update({ kp: kp / gearSquared })}
/>
<ParameterInput
label={`速度增益 kv(${forceUnit}·s/${actuator.jointType === 3 ? 'rad' : 'm'})`}
value={actuator.kv * gearSquared}
disabled={gearSquared <= 1e-18}
onCommit={(kv) => update({ kv: kv / gearSquared })}
/>
</>
) : (
<>
<ParameterInput
label={`kp(MJCF stiffness,${forceUnit}/${actuator.jointType === 3 ? 'rad' : 'm'})`}
value={actuator.kp}
onCommit={(kp) => update({ kp })}
/>
<ParameterInput
label={`kv(MJCF damping,${forceUnit}·s/${actuator.jointType === 3 ? 'rad' : 'm'})`}
value={actuator.kv}
onCommit={(kv) => update({ kv })}
/>
</>
)}
</div>
<ParameterToggle
label={`限制输出${actuator.jointType === 3 ? '力矩' : '力'}${forceUnit ? `(${forceUnit})` : ''}`}
checked={actuator.forceLimited}
onChange={(forceLimited) => update({ forceLimited })}
/>
<div className="mt-2 grid grid-cols-2 gap-2">
<ParameterInput
label="输出下限"
value={jointForceMin}
disabled={!actuator.forceLimited || Math.abs(actuator.gear) <= 1e-9}
onCommit={(value) =>
update(
actuator.gear >= 0
? { forceMin: value / actuator.gear }
: { forceMax: value / actuator.gear },
)
}
/>
<ParameterInput
label="输出上限"
value={jointForceMax}
disabled={!actuator.forceLimited || Math.abs(actuator.gear) <= 1e-9}
onCommit={(value) =>
update(
actuator.gear >= 0
? { forceMax: value / actuator.gear }
: { forceMin: value / actuator.gear },
)
}
/>
</div>
<p className="mt-2 text-[10px] leading-4 text-text-tertiary">
{isPosition
? 'position 伺服使用 kp 跟踪目标位置,kv 提供速度阻尼。'
: 'motor 保持力/力矩控制且控制输入不限幅。MJCF 的 motor 没有 kp/kv 属性;这里的 kp、kv 会分别保存为对应 joint 的 stiffness、damping。'}{' '}
参数修改会立即作用于当前模型,并可随 MJCF 导出。
</p>
</details>
) : (
<p className="border-t border-border pt-2 text-[10px] leading-4 text-text-tertiary">
该驱动器不是可直接编辑的 motor/position 类型,控制值按模型原始单位显示;请在 MJCF
源码中修改专用参数。
</p>
)}
</div>
);
}
function ParameterInput({
label,
value,
onCommit,
disabled = false,
}: {
label: string;
value: number;
onCommit: (value: number) => void;
disabled?: boolean;
}) {
return (
<label className="block text-[10px] text-text-tertiary">
<span className="mb-1 block truncate">{label}</span>
<input
key={value}
type="number"
step="any"
defaultValue={Number.isFinite(value) ? value : 0}
disabled={disabled}
className="field h-7 w-full px-2 text-xs text-text-primary disabled:opacity-40"
onBlur={(event) => {
const next = Number(event.currentTarget.value);
if (Number.isFinite(next) && next !== value) onCommit(next);
else event.currentTarget.value = String(value);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') event.currentTarget.blur();
}}
/>
</label>
);
}
function ParameterToggle({
label,
checked,
onChange,
}: {
label: string;
checked: boolean;
onChange: (value: boolean) => void;
}) {
return (
<label className="mt-2 flex items-center gap-2 text-[11px] text-text-secondary">
<input
type="checkbox"
className="accent-accent"
checked={checked}
onChange={(event) => onChange(event.target.checked)}
/>
{label}
</label>
);
}
function Check({
label,
checked,
onChange,
}: {
label: string;
checked: boolean;
onChange: (value: boolean) => void;
}) {
return (
<label className="mt-3 flex items-center gap-2 text-xs text-text-secondary">
<input
type="checkbox"
className="rounded accent-accent focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-1 focus-visible:ring-offset-panel"
checked={checked}
onChange={(event) => onChange(event.target.checked)}
/>
{label}
</label>
);
}
function ControlSlider({
label,
value,
min,
max,
onChange,
disabled = false,
unit = '',
advanced = false,
limited = false,
limitsIgnored = false,
limitMin = 0,
limitMax = 0,
}: {
label: string;
value: number;
min: number;
max: number;
onChange: (value: number) => void;
disabled?: boolean;
unit?: string;
advanced?: boolean;
limited?: boolean;
limitsIgnored?: boolean;
limitMin?: number;
limitMax?: number;
}) {
const sane = Number.isFinite(value) ? value : 0,
format = (number: number) => `${number.toFixed(3)}${unit}`;
return (
<label className="mb-3 block text-xs">
<span className="mb-1 flex justify-between gap-2">
<span className="truncate text-text-secondary">{label}</span>
<output className="technical-value text-text-primary">{format(sane)}</output>
</span>
<input
className="control-slider rounded focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-2 focus-visible:ring-offset-panel"
type="range"
disabled={disabled}
value={Math.min(max, Math.max(min, sane))}
min={min}
max={max}
step={(max - min) / 500 || 0.001}
onChange={(event) => onChange(Number(event.target.value))}
/>
{advanced && (
<span className="mt-1 flex justify-between text-[10px] text-text-tertiary">
<span>下限 {limited ? format(limitMin) : '无限制'}</span>
{limitsIgnored && limited && <span className="text-warning">已忽略</span>}
<span>上限 {limited ? format(limitMax) : '无限制'}</span>
</span>
)}
</label>
);
}
@@ -1,4 +1,4 @@
import { Crosshair, Hand, MousePointer2, RotateCcw } from 'lucide-react';
import { Crosshair, Hand, MapPinned, MousePointer2, RotateCcw } from 'lucide-react';
import type { InteractionMode } from '../../viewer/MuJoCoViewer';
import type { ViewerDisplayOptions } from '../../viewer/displayOptions';
import { IconButton, ToolbarToggleGroup, type ToolbarItem } from '../../components/ui';
@@ -11,12 +11,18 @@ const tools: ToolbarItem<InteractionMode>[] = [
export function ViewerToolDock({
mode,
display,
mapEditContext,
onModeChange,
onDisplayChange,
onResetCamera,
}: {
mode: InteractionMode;
display: ViewerDisplayOptions;
mapEditContext?: {
active: boolean;
label: string;
onActivate: () => void;
};
onModeChange: (mode: InteractionMode) => void;
onDisplayChange: (next: ViewerDisplayOptions) => void;
onResetCamera: () => void;
@@ -24,6 +30,18 @@ export function ViewerToolDock({
return (
<div className="flex items-center gap-1">
<ToolbarToggleGroup items={tools} value={mode} onChange={onModeChange} label="视口交互模式" />
{mapEditContext && (
<button
type="button"
aria-label={`地图编辑联动:${mapEditContext.label}`}
aria-pressed={mapEditContext.active}
onClick={mapEditContext.onActivate}
className={`hidden h-7 items-center gap-1.5 rounded-lg border px-2 text-[10px] font-semibold transition-colors md:flex ${mapEditContext.active ? 'border-accent/40 bg-accent-soft text-accent' : 'border-border bg-surface/80 text-text-tertiary hover:bg-element-hover hover:text-text-primary'}`}
>
<MapPinned className="h-3.5 w-3.5" aria-hidden="true" />
{mapEditContext.label}
</button>
)}
<ViewerDisplayPopover value={display} onChange={onDisplayChange} />
<IconButton tooltip="相机复位" aria-label="相机复位" onClick={onResetCamera}>
<RotateCcw className="h-3.5 w-3.5" />
@@ -16,11 +16,13 @@ export function ViewportHUD({
mode,
selection,
ready,
mapEditing = false,
}: {
paused: boolean;
mode: InteractionMode;
selection: ViewerSelection | null;
ready: boolean;
mapEditing?: boolean;
}) {
if (!ready) return null;
return (
@@ -45,7 +47,7 @@ export function ViewportHUD({
</div>
<div
aria-label="视口操作提示"
className="pointer-events-none absolute bottom-3 left-1/2 z-10 hidden -translate-x-1/2 items-center gap-2 whitespace-nowrap rounded-full border border-border-strong bg-panel px-3 py-1.5 text-[10px] text-text-secondary shadow-xl lg:flex"
className="pointer-events-none absolute bottom-14 left-1/2 z-10 hidden -translate-x-1/2 items-center gap-2 whitespace-nowrap rounded-full border border-border-strong bg-panel/85 px-3 py-1.5 text-[10px] text-text-secondary shadow-xl backdrop-blur lg:flex"
>
<Mouse aria-hidden="true" className="h-3 w-3 text-text-secondary" />
<span>{primaryGestures[mode]}</span>
@@ -57,7 +59,20 @@ export function ViewportHUD({
·
</span>
<span>滚轮缩放</span>
{mode !== 'select' && (
{mapEditing && mode === 'select' ? (
<>
<span aria-hidden="true" className="text-border-strong">
·
</span>
<Kbd>W</Kbd>
<Kbd>E</Kbd>
<Kbd>R</Kbd>
<span>变换</span>
<Kbd>F</Kbd>
<span>聚焦</span>
</>
) : (
mode !== 'select' && (
<>
<span aria-hidden="true" className="text-border-strong">
·
@@ -65,6 +80,7 @@ export function ViewportHUD({
<Kbd>1</Kbd>
<span>旋转视角</span>
</>
)
)}
</div>
</>
@@ -75,7 +75,7 @@ export function WorkbenchHeader({
onToggleFullscreen: () => void;
}) {
return (
<header className="relative z-40 grid h-10 shrink-0 grid-cols-[minmax(0,1fr)_auto_minmax(max-content,1fr)] items-center gap-2 border-b border-border bg-panel/95 px-2.5 shadow-[0_1px_0_rgba(255,255,255,0.02)] backdrop-blur-md">
<header className="workbench-header relative z-40 grid h-10 shrink-0 grid-cols-[minmax(0,1fr)_auto_minmax(max-content,1fr)] items-center gap-2 border-b border-border px-2.5 shadow-[0_1px_0_rgba(255,255,255,0.02)] backdrop-blur-md">
<div className="flex min-w-0 items-center gap-1">
<div className="mr-2 hidden items-center gap-2 border-r border-border pr-3 xl:flex">
<span className="grid h-7 w-7 place-items-center rounded-lg border border-accent/25 bg-accent-soft text-accent shadow-sm">
@@ -170,8 +170,8 @@ export function WorkbenchHeader({
<PanelLeft className="h-4 w-4" />
</IconButton>
<IconButton
tooltip={rightOpen ? '隐藏属性面板' : '显示属性面板'}
aria-label={rightOpen ? '隐藏属性面板' : '显示属性面板'}
tooltip={rightOpen ? '隐藏右侧面板' : '显示右侧面板'}
aria-label={rightOpen ? '隐藏右侧面板' : '显示右侧面板'}
aria-expanded={rightOpen}
onClick={onToggleRight}
>
@@ -0,0 +1,80 @@
import type { ComponentProps } from 'react';
import { render, screen } from '@testing-library/react';
import type { SimulationSnapshot } from '../../simulation/SimulationSession';
import { WorkspaceToolsPanel } from './WorkspaceToolsPanel';
const snapshot = {
actuators: [],
joints: [],
controller: { enabled: true },
rlPolicy: { enabled: false },
} as unknown as SimulationSnapshot;
const noop = () => {};
function props(): ComponentProps<typeof WorkspaceToolsPanel> {
return {
active: 'controls',
snapshot,
loading: false,
ignoreJointLimits: false,
jointAdvanced: false,
angleUnit: 'rad',
forceScale: 50,
controllerPaths: [],
controllerStatus: { enabled: true } as ComponentProps<
typeof WorkspaceToolsPanel
>['controllerStatus'],
policyPaths: [],
policyStatus: { enabled: false } as ComponentProps<typeof WorkspaceToolsPanel>['policyStatus'],
onResetJoints: noop,
onToggleJointLimits: noop,
onToggleAdvanced: noop,
onToggleAngleUnit: noop,
onActuator: noop,
onActuatorParameters: noop,
onJoint: noop,
onForceScale: noop,
onSelectControllerPath: noop,
onLoadControllerPath: noop,
onImportController: noop,
onToggleController: noop,
onControllerCommand: noop,
onRemoveController: noop,
onSelectPolicyPath: noop,
onLoadPolicyPath: noop,
onImportPolicy: noop,
onTogglePolicy: noop,
onPolicyCommand: noop,
onRemovePolicy: noop,
onDataRecorderConfigure: noop,
onDataRecordingStart: noop,
onDataRecordingStop: noop,
onDataRecordingClear: noop,
onDataRecordingExport: noop,
};
}
describe('WorkspaceToolsPanel', () => {
it('按实时控制到自动化流程排序,并默认折叠所有控制台组件', () => {
render(<WorkspaceToolsPanel {...props()} />);
const sections = screen
.getAllByRole('button')
.filter((button) => button.hasAttribute('aria-expanded'));
const expected = [
['执行器实时控制', '控制'],
['关节姿态调试', '调试'],
['外力交互参数', '交互'],
['Python 脚本控制', '控制', '运行'],
['ONNX 策略运行', '推理', '停止'],
['强化学习任务', '训练'],
];
expect(sections).toHaveLength(expected.length);
sections.forEach((section, index) => {
expect(section).toHaveAttribute('aria-expanded', 'false');
expected[index].forEach((label) => expect(section).toHaveTextContent(label));
});
});
});
@@ -0,0 +1,295 @@
import type { ControllerCommand, ControllerStatus } from '../../controller/types';
import { PythonControllerPanel } from '../../controller/PythonControllerPanel';
import type { RLCommand, RLPolicyStatus } from '../../rl/types';
import { RLPolicyPanel } from '../../rl/RLPolicyPanel';
import { ActuatorControl, ControlSlider } from '../../simulation/ActuatorControl';
import type { ActuatorParameters, SimulationSnapshot } from '../../simulation/SimulationSession';
import type { DataRecorderConfig } from '../../telemetry/DataRecorder';
import { DataRecordingPanel } from '../../telemetry/DataRecordingPanel';
import { LocalTrainingPanel } from '../../training/LocalTrainingPanel';
import { Badge, Button, CollapsibleSection } from '../../components/ui';
import type { RightSidebarView } from './RightSidebarTabs';
export type WorkspaceTool = Exclude<RightSidebarView, 'inspector'>;
function ConsoleSectionBadges({
category,
count,
state,
running = false,
}: {
category: string;
count?: number;
state?: string;
running?: boolean;
}) {
return (
<span className="flex shrink-0 items-center gap-1">
<Badge tone="accent">{category}</Badge>
{count !== undefined && <Badge>{count}</Badge>}
{state && <Badge tone={running ? 'success' : 'neutral'}>{state}</Badge>}
</span>
);
}
export function WorkspaceToolsPanel({
active,
snapshot,
loading,
ignoreJointLimits,
jointAdvanced,
angleUnit,
forceScale,
controllerPaths,
selectedControllerPath,
controllerStatus,
policyPaths,
selectedPolicyPath,
policyStatus,
onResetJoints,
onToggleJointLimits,
onToggleAdvanced,
onToggleAngleUnit,
onActuator,
onActuatorParameters,
onJoint,
onForceScale,
onSelectControllerPath,
onLoadControllerPath,
onImportController,
onToggleController,
onControllerCommand,
onRemoveController,
onSelectPolicyPath,
onLoadPolicyPath,
onImportPolicy,
onTogglePolicy,
onPolicyCommand,
onRemovePolicy,
onDataRecorderConfigure,
onDataRecordingStart,
onDataRecordingStop,
onDataRecordingClear,
onDataRecordingExport,
}: {
active: WorkspaceTool;
snapshot?: SimulationSnapshot;
loading: boolean;
ignoreJointLimits: boolean;
jointAdvanced: boolean;
angleUnit: 'rad' | 'deg';
forceScale: number;
controllerPaths: string[];
selectedControllerPath?: string;
controllerStatus?: ControllerStatus;
policyPaths: string[];
selectedPolicyPath?: string;
policyStatus?: RLPolicyStatus;
onResetJoints: () => void;
onToggleJointLimits: () => void;
onToggleAdvanced: () => void;
onToggleAngleUnit: () => void;
onActuator: (id: number, value: number) => void;
onActuatorParameters: (id: number, parameters: ActuatorParameters) => void;
onJoint: (id: number, value: number) => void;
onForceScale: (value: number) => void;
onSelectControllerPath: (path: string) => void;
onLoadControllerPath: (path: string) => void;
onImportController: (file: File) => void;
onToggleController: (enabled: boolean) => void;
onControllerCommand: (command: ControllerCommand) => void;
onRemoveController: () => void;
onSelectPolicyPath: (path: string) => void;
onLoadPolicyPath: (path: string) => void;
onImportPolicy: (file: File) => void;
onTogglePolicy: (enabled: boolean) => void;
onPolicyCommand: (command: RLCommand) => void;
onRemovePolicy: () => void;
onDataRecorderConfigure: (patch: Partial<DataRecorderConfig>) => void;
onDataRecordingStart: () => void;
onDataRecordingStop: () => void;
onDataRecordingClear: () => void;
onDataRecordingExport: (format: 'csv' | 'json') => void;
}) {
const resolvedControllerStatus = controllerStatus ?? snapshot?.controller;
const resolvedPolicyStatus = policyStatus ?? snapshot?.rlPolicy;
const controls = snapshot ? (
<>
<CollapsibleSection
title="执行器实时控制"
defaultOpen={false}
badge={<ConsoleSectionBadges category="控制" count={snapshot.actuators.length} />}
>
{snapshot.actuators.length ? (
snapshot.actuators.map((actuator) => (
<ActuatorControl
key={actuator.id}
actuator={actuator}
onControl={(value) => onActuator(actuator.id, value)}
onParameters={(parameters) => onActuatorParameters(actuator.id, parameters)}
/>
))
) : (
<p className="text-xs text-text-tertiary">模型没有驱动器</p>
)}
</CollapsibleSection>
<CollapsibleSection
title="关节姿态调试"
defaultOpen={false}
badge={<ConsoleSectionBadges category="调试" count={snapshot.joints.length} />}
>
<div className="mb-4 grid grid-cols-2 gap-2">
<Button onClick={onResetJoints}>重置关节</Button>
<Button
variant={ignoreJointLimits ? 'primary' : 'secondary'}
aria-pressed={ignoreJointLimits}
onClick={onToggleJointLimits}
>
忽略关节限位
</Button>
<Button
variant={jointAdvanced ? 'primary' : 'secondary'}
aria-pressed={jointAdvanced}
onClick={onToggleAdvanced}
>
高级
</Button>
<Button
variant={angleUnit === 'deg' ? 'primary' : 'secondary'}
aria-pressed={angleUnit === 'deg'}
onClick={onToggleAngleUnit}
>
{angleUnit === 'rad' ? 'rad 弧度制' : '° 角度制'}
</Button>
</div>
{snapshot.joints.map((joint) => {
const scale = joint.type === 3 && angleUnit === 'deg' ? 180 / Math.PI : 1;
const unit =
joint.type === 3 ? (angleUnit === 'deg' ? '°' : ' rad') : joint.type === 2 ? ' m' : '';
return (
<ControlSlider
key={joint.id}
label={`${joint.name}${joint.editable ? '' : '(只读)'}`}
value={joint.value * scale}
min={joint.min * scale}
max={joint.max * scale}
unit={unit}
advanced={jointAdvanced}
limited={joint.limited}
limitsIgnored={joint.limitsIgnored}
limitMin={joint.limitMin * scale}
limitMax={joint.limitMax * scale}
disabled={!joint.editable}
onChange={(value) => onJoint(joint.id, value / scale)}
/>
);
})}
</CollapsibleSection>
<CollapsibleSection
title="外力交互参数"
defaultOpen={false}
badge={<ConsoleSectionBadges category="交互" />}
>
<ControlSlider
label={`${forceScale.toFixed(0)} N/屏幕单位`}
value={forceScale}
min={5}
max={200}
onChange={onForceScale}
/>
<p className="text-xs text-text-tertiary">
选择“外力施加”,在动态物体上按住拖动,松开即清零。
</p>
</CollapsibleSection>
<CollapsibleSection
title="Python 脚本控制"
defaultOpen={false}
badge={
<ConsoleSectionBadges
category="控制"
state={
resolvedControllerStatus
? resolvedControllerStatus.enabled
? '运行'
: '停止'
: undefined
}
running={Boolean(resolvedControllerStatus?.enabled)}
/>
}
>
<PythonControllerPanel
paths={controllerPaths}
selectedPath={selectedControllerPath}
status={resolvedControllerStatus}
loading={loading}
onSelectPath={onSelectControllerPath}
onLoadPath={onLoadControllerPath}
onImport={onImportController}
onToggle={onToggleController}
onCommand={onControllerCommand}
onRemove={onRemoveController}
/>
</CollapsibleSection>
<CollapsibleSection
title="ONNX 策略运行"
defaultOpen={false}
badge={
<ConsoleSectionBadges
category="推理"
state={
resolvedPolicyStatus ? (resolvedPolicyStatus.enabled ? '运行' : '停止') : undefined
}
running={Boolean(resolvedPolicyStatus?.enabled)}
/>
}
>
<RLPolicyPanel
paths={policyPaths}
selectedPath={selectedPolicyPath}
status={resolvedPolicyStatus}
loading={loading}
onSelectPath={onSelectPolicyPath}
onLoadPath={onLoadPolicyPath}
onImport={onImportPolicy}
onToggle={onTogglePolicy}
onCommand={onPolicyCommand}
onRemove={onRemovePolicy}
/>
</CollapsibleSection>
<CollapsibleSection
title="强化学习任务"
defaultOpen={false}
badge={<ConsoleSectionBadges category="训练" />}
>
<LocalTrainingPanel onPolicyReady={onImportPolicy} />
</CollapsibleSection>
</>
) : (
<p className="p-4 text-sm text-text-tertiary">导入模型后显示控制工具</p>
);
return (
<div
role="tabpanel"
aria-label={active === 'controls' ? '控制台' : '数据录制'}
className="min-h-0 flex-1 overflow-y-auto panel-scroll"
>
{active === 'controls' ? (
controls
) : snapshot ? (
<DataRecordingPanel
status={snapshot.telemetry}
bodies={snapshot.bodies}
onConfigure={onDataRecorderConfigure}
onStart={onDataRecordingStart}
onStop={onDataRecordingStop}
onClear={onDataRecordingClear}
onExport={onDataRecordingExport}
/>
) : (
<p className="p-4 text-sm text-text-tertiary">导入模型后显示数据录制工具</p>
)}
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
export type EditorSelection =
| { kind: 'body'; bodyId: number }
| { kind: 'joint'; jointId: number; bodyId: number }
| { kind: 'map'; mapAssetId: string }
| { kind: 'map-object'; mapAssetId: string; objectId: string };
export function isMapEditorSelection(
selection: EditorSelection | null,
): selection is Extract<EditorSelection, { kind: 'map' | 'map-object' }> {
return selection?.kind === 'map' || selection?.kind === 'map-object';
}
@@ -0,0 +1,103 @@
import { render } from '@testing-library/react';
import { useMapEditorShortcuts, type MapEditorShortcutOptions } from './useMapEditorShortcuts';
function Harness({ options }: { options: MapEditorShortcutOptions }) {
useMapEditorShortcuts(options);
return <input aria-label="数值字段" />;
}
function press(code: string, init: KeyboardEventInit = {}): KeyboardEvent {
const event = new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
code,
key: init.key ?? code.replace('Key', '').toLowerCase(),
...init,
});
window.dispatchEvent(event);
return event;
}
function options(patch: Partial<MapEditorShortcutOptions> = {}): MapEditorShortcutOptions {
return {
enabled: true,
mapEditing: true,
dirty: true,
loading: false,
hasSelection: true,
canDelete: true,
canScale: true,
onTransformMode: vi.fn(),
onFocusSelection: vi.fn(),
onDeleteSelection: vi.fn(),
onToggleSnapping: vi.fn(),
onSave: vi.fn(),
...patch,
};
}
describe('useMapEditorShortcuts', () => {
it('处理 W/E/R、F、Delete 与 Ctrl+S', () => {
const value = options();
render(<Harness options={value} />);
press('KeyW');
press('KeyE');
press('KeyR');
press('KeyF');
press('KeyG');
press('Delete', { key: 'Delete' });
const saveEvent = press('KeyS', { key: 's', ctrlKey: true });
expect(value.onTransformMode).toHaveBeenNthCalledWith(1, 'translate');
expect(value.onTransformMode).toHaveBeenNthCalledWith(2, 'rotate');
expect(value.onTransformMode).toHaveBeenNthCalledWith(3, 'scale');
expect(value.onFocusSelection).toHaveBeenCalledOnce();
expect(value.onDeleteSelection).toHaveBeenCalledOnce();
expect(value.onToggleSnapping).toHaveBeenCalledOnce();
expect(value.onSave).toHaveBeenCalledOnce();
expect(saveEvent.defaultPrevented).toBe(true);
});
it('文本输入时避免误触变换和删除,但仍允许保存', () => {
const value = options();
const view = render(<Harness options={value} />);
const input = view.getByLabelText('数值字段');
input.dispatchEvent(
new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
key: 'Delete',
code: 'Delete',
}),
);
input.dispatchEvent(
new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
key: 's',
code: 'KeyS',
ctrlKey: true,
}),
);
expect(value.onDeleteSelection).not.toHaveBeenCalled();
expect(value.onSave).toHaveBeenCalledOnce();
});
it('非地图上下文仍支持 F 聚焦,但不劫持变换键', () => {
const value = options({ mapEditing: false });
render(<Harness options={value} />);
press('KeyF');
const transformEvent = press('KeyW');
expect(value.onFocusSelection).toHaveBeenCalledOnce();
expect(value.onTransformMode).not.toHaveBeenCalled();
expect(transformEvent.defaultPrevented).toBe(false);
});
it('缩放不可用时消费 R 但不切换模式', () => {
const value = options({ canScale: false });
render(<Harness options={value} />);
const event = press('KeyR');
expect(value.onTransformMode).not.toHaveBeenCalled();
expect(event.defaultPrevented).toBe(true);
});
});
@@ -0,0 +1,101 @@
import { useEffect } from 'react';
import type { MapEditorTransformMode } from '../../map/editor/types';
export interface MapEditorShortcutOptions {
enabled: boolean;
mapEditing: boolean;
dirty: boolean;
loading: boolean;
hasSelection: boolean;
canDelete: boolean;
canScale: boolean;
onTransformMode: (mode: MapEditorTransformMode) => void;
onFocusSelection: () => void;
onDeleteSelection: () => void;
onToggleSnapping: () => void;
onSave: () => void;
}
function isTextEditingTarget(target: EventTarget | null): boolean {
return (
target instanceof HTMLElement &&
(target.matches('input, textarea, select') || target.isContentEditable)
);
}
/**
* 地图编辑快捷键的唯一监听入口。
*
* W/E/R: 平移/旋转/缩放,F: 聚焦,Delete: 删除,Ctrl/Cmd+S: 提交草稿。
* 对话框优先持有键盘;文本输入期间只保留 Ctrl/Cmd+S,避免误删对象。
*/
export function useMapEditorShortcuts({
enabled,
mapEditing,
dirty,
loading,
hasSelection,
canDelete,
canScale,
onTransformMode,
onFocusSelection,
onDeleteSelection,
onToggleSnapping,
onSave,
}: MapEditorShortcutOptions): void {
useEffect(() => {
if (!enabled) return;
const keydown = (event: KeyboardEvent) => {
if (event.defaultPrevented || event.isComposing) return;
const target = event.target;
if (target instanceof HTMLElement && target.closest('[role="dialog"]')) return;
const save =
(mapEditing || dirty) && (event.ctrlKey || event.metaKey) && event.code === 'KeyS';
if (save) {
event.preventDefault();
event.stopImmediatePropagation();
if (!loading && dirty && !event.repeat) onSave();
return;
}
if (loading || isTextEditingTarget(target) || event.ctrlKey || event.metaKey || event.altKey)
return;
if (event.code === 'KeyF' && hasSelection) {
event.preventDefault();
event.stopImmediatePropagation();
onFocusSelection();
return;
}
if (!mapEditing) return;
let handled = true;
if (event.code === 'KeyW') onTransformMode('translate');
else if (event.code === 'KeyE') onTransformMode('rotate');
else if (event.code === 'KeyR') {
if (canScale) onTransformMode('scale');
} else if (event.code === 'KeyG') onToggleSnapping();
else if (event.key === 'Delete' && canDelete && !event.repeat) onDeleteSelection();
else handled = false;
if (handled) {
event.preventDefault();
event.stopImmediatePropagation();
}
};
window.addEventListener('keydown', keydown, true);
return () => window.removeEventListener('keydown', keydown, true);
}, [
enabled,
mapEditing,
dirty,
loading,
hasSelection,
canDelete,
canScale,
onTransformMode,
onFocusSelection,
onDeleteSelection,
onToggleSnapping,
onSave,
]);
}
+1 -1
View File
@@ -19,7 +19,7 @@ export function Badge({
return (
<span
title={title}
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium ${toneClass} ${className}`}
className={`engineering-badge inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium ${toneClass} ${className}`}
>
{children}
</span>
+1 -1
View File
@@ -30,7 +30,7 @@ export function Button({
return (
<button
type={type}
className={`inline-flex shrink-0 select-none items-center justify-center border font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 disabled:cursor-not-allowed disabled:opacity-40 ${variants[variant]} ${sizes[size]} ${className}`.trim()}
className={`inline-flex shrink-0 select-none items-center justify-center border font-medium transition-[color,background-color,border-color,transform,box-shadow] duration-150 ease-out enabled:hover:-translate-y-px enabled:active:translate-y-0 enabled:active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 disabled:cursor-not-allowed disabled:opacity-40 ${variants[variant]} ${sizes[size]} ${className}`.trim()}
{...props}
>
{icon && (
@@ -16,7 +16,7 @@ export function IconButton({
<button
type={type}
aria-pressed={active || undefined}
className={`inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 disabled:cursor-not-allowed disabled:opacity-40 ${active ? 'border-accent/40 bg-accent-soft text-accent' : 'border-transparent bg-transparent text-text-tertiary hover:bg-element-hover hover:text-text-primary'} ${className}`.trim()}
className={`inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md border transition-[color,background-color,border-color,transform,box-shadow] duration-150 ease-out enabled:active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 disabled:cursor-not-allowed disabled:opacity-40 ${active ? 'tool-active-glow border-accent/40 bg-accent-soft text-accent' : 'border-transparent bg-transparent text-text-tertiary hover:bg-element-hover hover:text-text-primary'} ${className}`.trim()}
{...props}
/>
);
@@ -106,7 +106,7 @@ export function ResizablePanel({
<button
type="button"
role="separator"
aria-label={side === 'left' ? '调整工程面板宽度' : '调整属性面板宽度'}
aria-label={side === 'left' ? '调整工程面板宽度' : '调整右侧面板宽度'}
aria-orientation="vertical"
aria-valuemin={minWidth}
aria-valuemax={Math.round(panelMaxWidth(minWidth))}
@@ -0,0 +1,45 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { ScrubbableNumberInput } from './ScrubbableNumberInput';
describe('ScrubbableNumberInput', () => {
it('保留标准数值输入行为', () => {
const onValueChange = vi.fn();
render(
<ScrubbableNumberInput label="位置 X" value={1} step={0.1} onValueChange={onValueChange} />,
);
fireEvent.change(screen.getByLabelText('位置 X'), { target: { value: '2.5' } });
expect(onValueChange).toHaveBeenCalledWith(2.5);
});
it('在标签上水平拖拽并支持 Shift 精调', () => {
const onValueChange = vi.fn();
render(
<ScrubbableNumberInput
label="摩擦力"
value={1}
step={0.1}
min={0}
onValueChange={onValueChange}
/>,
);
const handle = screen.getByRole('button', { name: '拖拽调节数值' });
fireEvent.pointerDown(handle, { button: 0, pointerId: 3, clientX: 20 });
fireEvent.pointerMove(window, { pointerId: 3, clientX: 60 });
expect(onValueChange).toHaveBeenLastCalledWith(2);
fireEvent.pointerMove(window, { pointerId: 3, clientX: 80, shiftKey: true });
expect(onValueChange).toHaveBeenLastCalledWith(1.15);
fireEvent.pointerUp(window, { pointerId: 3, clientX: 80 });
expect(document.body).not.toHaveClass('is-scrubbing-number');
});
it('通过键盘箭头进行无鼠标调节', () => {
const onValueChange = vi.fn();
render(
<ScrubbableNumberInput label="尺寸" value={2} step={0.5} onValueChange={onValueChange} />,
);
fireEvent.keyDown(screen.getByRole('button', { name: '拖拽调节数值' }), {
key: 'ArrowRight',
});
expect(onValueChange).toHaveBeenCalledWith(2.5);
});
});

Some files were not shown because too many files have changed in this diff Show More