diff --git a/.github/workflows/license.yml b/.github/workflows/license.yml
index e235670..0d4f1f7 100644
--- a/.github/workflows/license.yml
+++ b/.github/workflows/license.yml
@@ -53,6 +53,10 @@ jobs:
"LICENSES/crossterm-MIT.txt"
"LICENSES/rerun-Apache-2.0.txt"
"LICENSES/rerun-MIT.txt"
+ "LICENSES/OFL-1.1.txt"
+ "LICENSES/Ubuntu-font-1.0.txt"
+ "LICENSES/epaint_default_fonts-OFL-1.1.txt"
+ "LICENSES/epaint_default_fonts-Ubuntu-font-1.0.txt"
"LICENSES/unitree_sdk2-BSD-3-Clause.txt"
"LICENSES/nvidia-open-model-license.txt"
)
diff --git a/AGENTS.md b/AGENTS.md
index 5d75a72..374795a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,171 +1,92 @@
# AGENTS.md
-This file defines the default operating playbook for coding agents working in this repository.
-Its scope is the entire repo tree rooted at this directory.
+Repository-wide instructions for coding agents working in this tree.
-## 0) First-read policy (mandatory)
+## Read First
-Before running any command, read in this order:
+Before commands or edits, read:
-1. `AGENTS.md` (this file)
-2. `CLAUDE.md`
-3. `Cargo.toml` (workspace root) — workspace members, dependencies
-4. `docs/founding-document.md` — project research, design decisions, architecture
+1. `AGENTS.md`
+2. `Cargo.toml`
+3. `docs/founding-document.md`
+4. `docs/agents/domain.md`
-If instructions conflict, priority is:
-**system/developer/user prompt > AGENTS.md > CLAUDE.md > inferred defaults**.
+Claude Code agents should also read `CLAUDE.md` for Claude-specific deltas.
----
+Instruction priority is:
+system/developer/user prompt > `AGENTS.md` > tool-specific files > inferred defaults.
-## Agent skills
+## Project Context
-### Issue tracker
+RoboWBC is a Linux-first Rust workspace for humanoid whole-body-control policy
+inference, with PyO3/Python surfaces for users who do not own the Rust host
+loop directly. Preserve explicit diagnostics, runtime safety, reproducible
+commands, and the registry-driven `WbcPolicy` architecture.
-Issues and PRDs are tracked in GitHub Issues for `MiaoDX/robowbc` using GitHub MCP tools. See `docs/agents/issue-tracker.md`.
+Use these orientation surfaces:
-### Triage labels
+- Human overview: `README.md`, `ARCHITECTURE.md`, `STATUS.md`, `docs/human/`
+- Domain and design context: `docs/founding-document.md`, `docs/architecture.md`
+- Agent runbooks: `docs/agents/README.md`
-Use the default five-label triage vocabulary. See `docs/agents/triage-labels.md`.
+## Verification
-### Domain docs
+Do not run tests first in a fresh environment. Complete the Rust preflight in
+`docs/agents/rust-workflow.md`: toolchain, build, then check.
-Single-context repo; read the root project docs as the canonical domain context. See `docs/agents/domain.md`.
-
----
-
-## 1) Environment preflight (mandatory before tests)
-
-Do not run tests immediately on a fresh environment.
-Always complete dependency preflight first.
-
-### 1.1 Rust toolchain
-
-```bash
-rustc --version # expect 1.75+
-cargo --version
-```
-
-If the Rust toolchain is not installed:
-
-```bash
-curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
-source "$HOME/.cargo/env"
-```
-
-### 1.2 Build verification
-
-```bash
-cargo build 2>&1
-```
-
-If build fails due to missing system dependencies (e.g., CUDA, protobuf), report the exact blocker.
-
-### 1.3 Fast sanity check before tests
-
-```bash
-cargo check 2>&1
-```
-
----
-
-## 2) Standard test workflow
-
-### 2.1 Full tests (default)
-
-```bash
-cargo test
-```
-
-### 2.2 Focused debugging loop
+For Rust changes, normally run:
```bash
-cargo test -p robowbc-core -- test_name
+make test
+make clippy
+make fmt-check
```
-### 2.3 Lint and format
-
-```bash
-cargo clippy -- -D warnings
-cargo fmt --check
-```
-
-Before finishing work, run full `cargo test` + `cargo clippy` at least once.
-
----
-
-## 3) Lint/format checks for code changes
+Run `make rust-doc` when public Rust APIs or doc links change. For Python,
+site, benchmark, MuJoCo, or SDK changes, use the focused commands in
+`docs/agents/testing.md`.
-Run when Rust code changes are made:
+Do not claim a command passed unless it ran in the current environment. If a
+check is blocked by CUDA, MuJoCo, protobuf, model downloads, display/OpenGL, or
+other system dependencies, report the exact blocker and the skipped command.
-```bash
-cargo clippy -- -D warnings # lint
-cargo fmt --check # format check
-cargo doc --no-deps 2>&1 # doc generation (catch broken doc links)
-```
-
-If a check cannot run because of environment limits, report the exact blocker.
+## Protected Demo Path
----
+`make demo-keyboard` is the "git clone and see it work" path. Do not weaken the
+GR00T scene wrapper, support band, engage guard, init-pose settle window, or
+viewer lighting without equivalent verification. See
+`docs/agents/keyboard-demo.md`.
-## 4) Operational best practices
+## GitHub And Commits
-1. **Fail fast with clear diagnostics**: prefer explicit errors over silent fallbacks.
-2. **No hidden dependency assumptions**: always derive requirements from `Cargo.toml`.
-3. **Reproducible command logs**: report exact commands and outcomes.
-4. **Small, verifiable steps**: build → check → test → lint.
-5. **No dependency drift in fixes**: avoid ad-hoc single-crate installs unless doing triage.
+Use GitHub MCP tools for issues, PRDs, PRs, comments, and labels. Do not assume
+the `gh` CLI is installed.
-### 4.1 Keyboard demo guardrail
+This repository uses rebase-only merges. Keep commits scoped and descriptive.
+Codex-authored commits must include:
-`make demo-keyboard` is the "git clone and see it work" path. Keep
-`configs/demo/gear_sonic_keyboard_mujoco.toml` on the GR00T scene wrapper and
-the explicit `[sim.elastic_band]` support band anchored from the initialized
-pose unless replacing them with an equally verified upstream-style startup
-path. Keep `[runtime].require_engage = true` and `[runtime].init_pose_secs`
-enabled so `]` engages policy only after the robot settles. Keep the demo
-scene visibly lit enough to inspect foot contact in the MuJoCo viewer. For
-MuJoCo demo changes, run the targeted stability test:
-
-```bash
-MUJOCO_DOWNLOAD_DIR="$(pwd)/.cache/mujoco" \
-MUJOCO_DYNAMIC_LINK_DIR="$(pwd)/.cache/mujoco/mujoco-3.6.0/lib" \
-LD_LIBRARY_PATH="$(pwd)/.cache/mujoco/mujoco-3.6.0/lib:${LD_LIBRARY_PATH:-}" \
-cargo test -p robowbc-sim --features mujoco-auto-download \
- gear_sonic_demo_model_holds_default_pose_for_startup_window
+```text
+Co-authored-by: Codex
```
----
-
-## 5) Quick command checklist (copy/paste)
+Read `docs/agents/github-workflow.md` before PR review, CI triage, or commit
+work.
-```bash
-# 1) Verify Rust toolchain
-rustc --version && cargo --version
-
-# 2) Build
-cargo build
-
-# 3) Run all tests
-cargo test
-
-# 4) Lint + format
-cargo clippy -- -D warnings && cargo fmt --check
-```
+## Working Tree Safety
----
+The worktree may contain user changes. Do not revert changes you did not make.
+If existing changes affect your task, inspect them and work with them; ask only
+when they make the task impossible.
-## 6) Commit hygiene
+Do not add generated models, MuJoCo downloads, benchmark output, site bundles,
+or caches unless the repository already tracks that exact artifact type.
-- Keep commits scoped and descriptive (`docs: ...`, `fix: ...`, etc.).
-- This repository only allows **rebase merge** — squash and merge commits are disabled.
-- When changing workflow/docs, ensure instructions match actual repo configuration.
-- Do not claim UT success unless the relevant test command (for example
- `cargo test` or `pytest -q`) has been run in the current environment.
-- If a commit is created by Codex, include the Git trailer
- `Co-authored-by: Codex ` in the commit message.
-- If a commit is created by another AI coding agent, include a corresponding
- co-author trailer so agent usage can be tracked later.
-- If you maintain a dedicated bot/user account, prefer that account's verified
- noreply email for the relevant agent trailer.
+## Preferred Skill Routing
----
+- `$intuitive-init` refreshes agent guidance.
+- `$intuitive-doc` maintains the human documentation surface.
+- `$intuitive-layout` proposes bounded repository layout improvements.
+- `$intuitive-tests` improves test organization and behavior quality.
+- `$intuitive-flow` turns fuzzy ideas into planned execution.
+- `$intuitive-refactor` sets a bounded refactor goal before broad cleanup.
+- `$intuitive-squash` cleans local agent commit history before handoff.
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
new file mode 100644
index 0000000..34dbd0a
--- /dev/null
+++ b/ARCHITECTURE.md
@@ -0,0 +1,108 @@
+# Architecture
+
+RoboWBC is an embedded runtime for humanoid whole-body-control policy inference.
+The codebase keeps the policy contract small, then lets configs choose the
+policy backend, robot model, transport, teleop mode, reporting, and visualization
+path.
+
+For deeper implementation detail, read `docs/architecture.md`.
+
+## Runtime Shape
+
+```text
+TOML config or Python SDK
+ |
+ v
+robowbc-config + robowbc-registry
+ |
+ v
+WbcPolicy implementation
+ |
+ v
+Observation -> predict -> JointPositionTargets
+ |
+ v
+hardware, MuJoCo, synthetic transport, JSON report, Rerun trace, static site
+```
+
+The same core policy contract is used by the CLI, Python SDK, MuJoCo examples,
+benchmark helpers, and generated HTML policy reports.
+
+## Core Contracts
+
+- `Observation`: joint positions, joint velocities, gravity vector, command,
+ and timestamp.
+- `WbcCommand`: velocity, motion tokens, joint targets, kinematic pose, and
+ related command shapes supported by individual policies.
+- `JointPositionTargets`: policy output sent toward PD-controlled robot joints.
+- `WbcPolicy`: the trait implemented by every runtime policy.
+- `PolicyCapabilities`: the caller-facing contract for supported commands and
+ policy limits.
+- `RobotConfig`: joint names, default pose, gains, and limits for a robot
+ embodiment.
+
+Unsupported commands should fail explicitly instead of falling back silently.
+
+## Crate Map
+
+| Crate | Responsibility |
+|-------|----------------|
+| `robowbc-core` | Core policy, observation, command, target, robot, capability, and validator types |
+| `robowbc-config` | Typed TOML config loading, defaults, path resolution, and validation |
+| `robowbc-registry` | Inventory-based policy registration and config-driven construction |
+| `robowbc-ort` | ONNX Runtime backend plus first-party policy wrappers |
+| `robowbc-pyo3` | Python-backed runtime policy backend for user-supplied modules |
+| `robowbc-py` | Standalone Python SDK built with maturin |
+| `robowbc-runtime` | Runtime finite-state orchestration around policy, validator, teleop, and transport |
+| `robowbc-comm` | Communication-oriented control-loop helpers and Unitree G1 wiring |
+| `robowbc-transport` | Pluggable transport traits and CycloneDDS/in-memory backends |
+| `robowbc-sim` | MuJoCo transport for hardware-free execution |
+| `robowbc-teleop` | Keyboard teleop and configurable keymaps |
+| `robowbc-vis` | Rerun visualization and robot scene logging |
+| `robowbc-cli` | `robowbc` binary, config-driven runs, reports, and policy helpers |
+| `unitree-hg-idl` | Unitree HG message serialization helpers |
+
+## Policy Backends
+
+Live public-asset policies:
+
+- `gear_sonic`
+- `decoupled_wbc`
+- `wbc_agile`
+- `bfm_zero`
+
+Available but asset-limited wrappers:
+
+- `hover`: wrapper exists, but public upstream does not ship a pretrained
+ checkpoint.
+- `wholebody_vla`: contract wrapper exists, but public upstream does not expose
+ a runnable inference release.
+- `py_model`: user-supplied Python or PyTorch backend through `robowbc-pyo3`.
+
+## Execution Surfaces
+
+- CLI: `cargo run --bin robowbc -- run --config `.
+- Python SDK: `Registry`, `Observation`, `Policy`, command classes, and
+ `MujocoSession`.
+- Makefile: stable repo-level commands for build, validation, site generation,
+ SDK verification, benchmark generation, and keyboard demo.
+- Static reports: scripts generate JSON, Rerun, proof-pack, and HTML site
+ artifacts consumed by the published policy report.
+
+## Safety And Proof Boundaries
+
+- Runtime outputs remain joint position targets, not direct torque commands.
+- Validators and robot configs own dimension, limit, and safety checks.
+- Linux is the verified runtime target.
+- `make demo-keyboard` is the protected clone-and-see-it-work path.
+- Public report artifacts are evidence, not a replacement for model or hardware
+ validation.
+
+## Deliberately Out Of Scope For The Public Surface
+
+- A new server or daemon API.
+- A public ROS 2 or zenoh customer API.
+- Training workflows.
+- Real-time world-model control.
+- Additional model families without runnable public assets or explicit
+ user-supplied checkpoints.
diff --git a/CLAUDE.md b/CLAUDE.md
index 000898e..7db210a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,120 +1,27 @@
-# RoboWBC
+# CLAUDE.md
-Unified inference runtime for humanoid whole-body control policies. Rust core with Python bindings (PyO3).
+Claude-specific instructions for RoboWBC. Repository-wide rules live in
+`AGENTS.md`; read and follow that first.
-## Build & test
+## Claude Workflow
-```bash
-cargo build # build all crates
-cargo test # run all tests
-cargo clippy -- -D warnings # lint
-cargo fmt --check # format check
-cargo bench # run benchmarks
-cargo doc --no-deps --open # generate and open docs
-```
+- Use subagents only for independent work that can safely run in parallel with
+ the main task.
+- Keep the main context focused on decisions, integration, and verification.
+- Match model strength to task complexity when the host offers model choice.
-## Code style
+## PR Review
-- `rustfmt` enforces formatting — do not duplicate formatter rules here
-- `clippy` enforces lints — treat warnings as errors (`-D warnings`)
-- Type annotations on all public APIs; doc comments (`///`) on public items
-- Error handling: use `thiserror` for library errors, `anyhow` for binary/CLI errors
+When reviewing a PR, push fixes directly to the PR source branch unless the
+user asks otherwise. Read the actual CI logs before diagnosing failures; do not
+stop at status summaries.
-## Architecture
+Detailed workflow: `docs/agents/github-workflow.md`.
-```
-crates/
-├── robowbc-core/ — WbcPolicy trait, Observation, WbcCommand, JointPositionTargets, RobotConfig
-├── robowbc-ort/ — ONNX Runtime inference backend (ort crate, CUDA/TensorRT)
-├── robowbc-pyo3/ — PyO3 Python inference backend (PyTorch models)
-├── robowbc-registry/ — inventory-based policy registration and factory
-├── robowbc-comm/ — zenoh communication layer (robot hardware I/O)
-└── robowbc-cli/ — CLI entry point (config loading → control loop)
-```
+## Repo Pointers
-Key pattern: `WbcPolicy` is a trait (`Send + Sync`). New policies implement it and register via `inventory::submit!`. Config-driven instantiation via `WbcRegistry::build(name, config)`.
-
-## Git workflow
-
-- Branch from `main`
-- Commit messages: `type: description` (feat, fix, ci, docs, refactor)
-- Merge method: **rebase only** — squash and merge commits are disabled on this repository
-- CI runs on all PRs: `cargo check` + `cargo clippy` + `cargo fmt --check` + `cargo test`
-
-### PR review strategy
-
-When reviewing a PR, **push fixes directly to the PR's source branch** instead of creating a new branch or a separate PR. This keeps the workflow simple — one PR, one place to review, one merge.
-
-1. Fetch and check out the PR's source branch
-2. Make fixes, run tests/lint, commit
-3. Push to the same branch
-4. The new commit appears in the existing PR
-
-Do NOT create a new branch or a new PR for review fixes.
-
-## CI failure investigation
-
-When a CI check fails, **always read the actual logs/error messages** before diagnosing. Do not stop at the status summary. Specifically:
-
-1. Identify which checks failed
-2. Read the full error output
-3. Only after reading the actual error messages, diagnose and fix
-
-## Gotchas
-
-- ONNX models require matching `ort` version + ONNX opset version. Pin `ort` version in `Cargo.toml`.
-- CUDA/TensorRT execution providers require matching CUDA toolkit version on the host.
-- `zenoh` communication requires matching protocol version between peers.
-- `inventory` crate requires all registered types to be in crates linked into the final binary (no dynamic loading).
-- PyO3 backend requires Python 3.10+ and GIL-aware thread management.
-- GEAR-SONIC has three ONNX models (`model_encoder.onnx`, `model_decoder.onnx`, `planner_sonic.onnx`) that must be loaded together.
-
-## Tools & environment
-
-- IMPORTANT: GitHub MCP tools are available (prefixed `mcp__github__`). Use them for all GitHub interactions (issues, PRs, comments). Do NOT assume `gh` CLI is available.
-- Rust toolchain: stable (1.75+)
-- Key crates: `ort`, `zenoh`, `pyo3`, `inventory`, `serde`, `toml`
-
-## Agent skills
-
-### Issue tracker
-
-Issues and PRDs are tracked in GitHub Issues for `MiaoDX/robowbc` using GitHub MCP tools. See `docs/agents/issue-tracker.md`.
-
-### Triage labels
-
-Use the default five-label triage vocabulary. See `docs/agents/triage-labels.md`.
-
-### Domain docs
-
-Single-context repo; read the root project docs as the canonical domain context. See `docs/agents/domain.md`.
-
-## Subagent strategy
-
-- **Maximize parallelism.** Run independent tasks as concurrent subagents.
-- **Protect the main context window.** Delegate non-trivial work to subagents.
-- **Match model to task.** Opus for architecture decisions, complex refactors. Sonnet for grep/glob, straightforward edits, running tests.
-
-## Testing philosophy
-
-- **Test-driven development.** Write tests first, then implement.
-- **Real tests, not stub theater.** Unit tests must correlate with actual scenarios. Minimize mocks.
-- **Zero false positives.** Every assertion must verify a specific expected value.
-- **Benchmark critical paths.** Inference latency, control loop frequency, communication latency — measure with `criterion`.
-- After each significant change, verify related tests still pass before moving on.
-
-## Core principles
-
-| Principle | Practice |
-|-----------|----------|
-| **Simplicity First** | Minimal changes; no premature abstractions; three similar lines > one bad abstraction |
-| **Root Cause** | Fix causes, not symptoms; no workarounds; be thorough |
-| **Chesterton's Fence** | Understand why code exists before changing it |
-| **Fail Fast** | Minimize catch-all error handling; explicit errors > silent failures |
-| **Verification Before Done** | Never mark a task complete without proving it works |
-
-## Collaboration
-
-- Question assumptions; push back on technical debt or inconsistent requirements.
-- Treat instructions as intent, not literal commands. Use `AskUserQuestion` when unclear.
-- After any correction from the user, internalize the pattern to avoid repeating the same mistake.
+- Domain context: `docs/agents/domain.md`
+- Architecture crib sheet: `docs/agents/architecture.md`
+- Rust workflow: `docs/agents/rust-workflow.md`
+- Testing guidance: `docs/agents/testing.md`
+- Keyboard demo guardrail: `docs/agents/keyboard-demo.md`
diff --git a/Cargo.lock b/Cargo.lock
index 22d1033..166b70c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2905,6 +2905,16 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
+[[package]]
+name = "ecolor"
+version = "0.33.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "71ddb8ac7643d1dba1bb02110e804406dd459a838efcb14011ced10556711a8e"
+dependencies = [
+ "bytemuck",
+ "emath 0.33.3",
+]
+
[[package]]
name = "ecolor"
version = "0.34.1"
@@ -2913,7 +2923,7 @@ checksum = "137c0ce4ce4152ff7e223a7ce22ee1057cdff61fce0a45c32459c3ccec64868d"
dependencies = [
"bytemuck",
"color-hex",
- "emath",
+ "emath 0.34.1",
"serde",
]
@@ -2932,10 +2942,10 @@ dependencies = [
"ahash",
"bytemuck",
"document-features",
- "egui",
+ "egui 0.34.1",
"egui-wgpu",
- "egui-winit",
- "egui_glow",
+ "egui-winit 0.34.1",
+ "egui_glow 0.34.1",
"glutin",
"glutin-winit",
"home",
@@ -2962,6 +2972,23 @@ dependencies = [
"winit",
]
+[[package]]
+name = "egui"
+version = "0.33.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a9b567d356674e9a5121ed3fedfb0a7c31e059fe71f6972b691bcd0bfc284e3"
+dependencies = [
+ "ahash",
+ "bitflags 2.11.0",
+ "emath 0.33.3",
+ "epaint 0.33.3",
+ "log",
+ "nohash-hasher",
+ "profiling",
+ "smallvec",
+ "unicode-segmentation",
+]
+
[[package]]
name = "egui"
version = "0.34.1"
@@ -2972,8 +2999,8 @@ dependencies = [
"ahash",
"backtrace",
"bitflags 2.11.0",
- "emath",
- "epaint",
+ "emath 0.34.1",
+ "epaint 0.34.1",
"log",
"nohash-hasher",
"profiling",
@@ -2992,8 +3019,8 @@ dependencies = [
"ahash",
"bytemuck",
"document-features",
- "egui",
- "epaint",
+ "egui 0.34.1",
+ "epaint 0.34.1",
"log",
"profiling",
"thiserror 2.0.18",
@@ -3003,6 +3030,24 @@ dependencies = [
"winit",
]
+[[package]]
+name = "egui-winit"
+version = "0.33.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec6687e5bb551702f4ad10ac428bab12acf9d53047ebb1082d4a0ed8c6251a29"
+dependencies = [
+ "bytemuck",
+ "egui 0.33.3",
+ "log",
+ "objc2 0.5.2",
+ "objc2-foundation 0.2.2",
+ "objc2-ui-kit 0.2.2",
+ "profiling",
+ "raw-window-handle",
+ "web-time",
+ "winit",
+]
+
[[package]]
name = "egui-winit"
version = "0.34.1"
@@ -3012,7 +3057,7 @@ dependencies = [
"accesskit_winit",
"arboard",
"bytemuck",
- "egui",
+ "egui 0.34.1",
"log",
"objc2 0.6.4",
"objc2-foundation 0.3.2",
@@ -3032,7 +3077,7 @@ version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd9bc6d586df44e01b90715eec1eb8d73a61c3c3f554edffb01eb0894a8107ef"
dependencies = [
- "egui",
+ "egui 0.34.1",
"hello_egui_utils",
"simple-easing",
]
@@ -3043,7 +3088,7 @@ version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55df531ff51161b3c6212e0ee2166b370f150254bf4448a8a15c3d26fec87958"
dependencies = [
- "egui",
+ "egui 0.34.1",
"egui_commonmark_backend",
"egui_extras",
"pulldown-cmark",
@@ -3055,7 +3100,7 @@ version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe04399ca5a2196965833a2918e50400449721fd9350e31ae7d84d6690859437"
dependencies = [
- "egui",
+ "egui 0.34.1",
"egui_extras",
"pulldown-cmark",
]
@@ -3066,7 +3111,7 @@ version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51a348b3fdbc048c4241aaa2865255e1fdebbc0099324ded8c5b534e598e600c"
dependencies = [
- "egui",
+ "egui 0.34.1",
"egui_animation",
"simple-easing",
"web-time",
@@ -3079,7 +3124,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62bfc6870c68d3f254e33aca8200095d422e09edacb0f365f79fe23a5ba10963"
dependencies = [
"ahash",
- "egui",
+ "egui 0.34.1",
"ehttp",
"enum-map",
"image",
@@ -3090,6 +3135,24 @@ dependencies = [
"serde",
]
+[[package]]
+name = "egui_glow"
+version = "0.33.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6420863ea1d90e750f75075231a260030ad8a9f30a7cef82cdc966492dc4c4eb"
+dependencies = [
+ "bytemuck",
+ "egui 0.33.3",
+ "egui-winit 0.33.3",
+ "glow 0.16.0",
+ "log",
+ "memoffset",
+ "profiling",
+ "wasm-bindgen",
+ "web-sys",
+ "winit",
+]
+
[[package]]
name = "egui_glow"
version = "0.34.1"
@@ -3097,8 +3160,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3b28d39ab6c0cac238190e6cb1e8c9047d02cb470ab942a7a3302e4cb3a8e74"
dependencies = [
"bytemuck",
- "egui",
- "glow",
+ "egui 0.34.1",
+ "glow 0.17.0",
"log",
"memoffset",
"profiling",
@@ -3114,8 +3177,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7bd66213736bf9a9a53dc4888570b9194fc0db906507517a7fcc787e888ac47"
dependencies = [
"ahash",
- "egui",
- "emath",
+ "egui 0.34.1",
+ "emath 0.34.1",
]
[[package]]
@@ -3124,7 +3187,7 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8512decdd471a2b6106d0b42cc0662f0e94b0ca8f21bc1b0075f455f58901010"
dependencies = [
- "egui",
+ "egui 0.34.1",
"serde",
"vec1",
]
@@ -3136,7 +3199,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08e570b77f6cce3292eba4aee9b9c08cf11dfc68430f4dc9613d939628498647"
dependencies = [
"ahash",
- "egui",
+ "egui 0.34.1",
"itertools 0.14.0",
"log",
"serde",
@@ -3167,6 +3230,15 @@ version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
+[[package]]
+name = "emath"
+version = "0.33.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "491bdf728bf25ddd9ad60d4cf1c48588fa82c013a2440b91aa7fc43e34a07c32"
+dependencies = [
+ "bytemuck",
+]
+
[[package]]
name = "emath"
version = "0.34.1"
@@ -3285,6 +3357,24 @@ dependencies = [
"log",
]
+[[package]]
+name = "epaint"
+version = "0.33.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "009d0dd3c2163823a0abdb899451ecbc78798dec545ee91b43aff1fa790bab62"
+dependencies = [
+ "ab_glyph",
+ "ahash",
+ "bytemuck",
+ "ecolor 0.33.3",
+ "emath 0.33.3",
+ "epaint_default_fonts 0.33.3",
+ "log",
+ "nohash-hasher",
+ "parking_lot",
+ "profiling",
+]
+
[[package]]
name = "epaint"
version = "0.34.1"
@@ -3293,9 +3383,9 @@ checksum = "04f3017dd67f147a697ee0c8484fb568fd9553e2a0c114be5020dbbc11962841"
dependencies = [
"ahash",
"bytemuck",
- "ecolor",
- "emath",
- "epaint_default_fonts",
+ "ecolor 0.34.1",
+ "emath 0.34.1",
+ "epaint_default_fonts 0.34.1",
"font-types",
"log",
"nohash-hasher",
@@ -3309,6 +3399,12 @@ dependencies = [
"vello_cpu",
]
+[[package]]
+name = "epaint_default_fonts"
+version = "0.33.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c4fbe202b6578d3d56428fa185cdf114a05e49da05f477b3c7f0fbb221f1862"
+
[[package]]
name = "epaint_default_fonts"
version = "0.34.1"
@@ -3826,6 +3922,18 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
+[[package]]
+name = "glow"
+version = "0.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08"
+dependencies = [
+ "js-sys",
+ "slotmap",
+ "wasm-bindgen",
+ "web-sys",
+]
+
[[package]]
name = "glow"
version = "0.17.0"
@@ -4071,7 +4179,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c34bfd8bff6f6df43b0b73ed7949a7aff0c98c2c1bd4c2f2771f5f2f6d98ced0"
dependencies = [
"concat-idents",
- "egui",
+ "egui 0.34.1",
]
[[package]]
@@ -5345,6 +5453,9 @@ checksum = "82c4e2adc7cf4b96a8b4e53290a206f40304d72c805df3034beae1b3f1f9eff3"
dependencies = [
"bitflags 2.11.0",
"bytemuck",
+ "egui 0.33.3",
+ "egui-winit 0.33.3",
+ "egui_glow 0.33.3",
"flate2",
"glutin",
"glutin-winit",
@@ -7279,7 +7390,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71cde26c1f0ba8ea7ce2fd678efded0acdd6cf13bfd306930fc234d0ed8d5802"
dependencies = [
"arrow",
- "egui",
+ "egui 0.34.1",
"jiff",
"re_arrow_util",
"re_format",
@@ -7364,7 +7475,7 @@ version = "0.31.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2227563203d318c3029039a5a657464ce186d2f7b7db0d8738294dedbf1dcd78"
dependencies = [
- "egui",
+ "egui 0.34.1",
"egui_tiles",
"itertools 0.14.0",
"re_context_menu",
@@ -7412,7 +7523,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d92144303a1b0753c8734cab0ab2a03841f424b0e0e68e82c9ec7b7e80eff75"
dependencies = [
"arrow",
- "ecolor",
+ "ecolor 0.34.1",
"glam",
"half",
"parking_lot",
@@ -7427,7 +7538,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b200b033df92d7ad028182d2549df6f48742fae07c3c4efba3818f5638567223"
dependencies = [
"document-features",
- "egui",
+ "egui 0.34.1",
"re_log",
"static_assertions",
]
@@ -7510,7 +7621,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bc6d008fc16994e3cdd54d3a22b0ec6aae8fb9fdb9116612a9ea9de18c58b9f"
dependencies = [
"arrow",
- "egui",
+ "egui 0.34.1",
"egui_extras",
"itertools 0.14.0",
"re_arrow_ui",
@@ -7542,7 +7653,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "548546f57ca4d276255fe346edc8f7b4f343d26e4032288e63b159b0e847078b"
dependencies = [
"arrow",
- "egui",
+ "egui 0.34.1",
"egui_dnd",
"egui_extras",
"egui_plot",
@@ -7565,7 +7676,7 @@ version = "0.31.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1e184e6eba759242cdc88dea6e4d8774e05ae942243411e993c71a7fe76bedf"
dependencies = [
- "egui",
+ "egui 0.34.1",
"egui_tiles",
"itertools 0.14.0",
"nohash-hasher",
@@ -7675,7 +7786,7 @@ dependencies = [
"ahash",
"anyhow",
"bytemuck",
- "egui",
+ "egui 0.34.1",
"egui_extras",
"egui_plot",
"itertools 0.14.0",
@@ -7739,7 +7850,7 @@ dependencies = [
"cfg-if",
"crossbeam",
"datafusion",
- "egui",
+ "egui 0.34.1",
"egui_dnd",
"egui_table",
"futures",
@@ -7809,7 +7920,7 @@ dependencies = [
"ahash",
"arrow",
"document-features",
- "emath",
+ "emath 0.34.1",
"indexmap 2.13.1",
"itertools 0.14.0",
"nohash-hasher",
@@ -8114,7 +8225,7 @@ version = "0.31.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fec2fad201702ad66ae4b594e7326371b5b2d508df5e64fe6d9aafa9439c184"
dependencies = [
- "egui",
+ "egui 0.34.1",
"re_byte_size",
"re_format",
]
@@ -8278,7 +8389,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f8114b0dbe8a3844fb26ad93d7ba0ca945040054afdabf106fd449a083c49c7"
dependencies = [
"ahash",
- "egui",
+ "egui 0.34.1",
"itertools 0.14.0",
"re_data_ui",
"re_entity_db",
@@ -8303,7 +8414,7 @@ dependencies = [
"cfg-if",
"crossbeam",
"datafusion",
- "egui",
+ "egui 0.34.1",
"egui_extras",
"futures",
"itertools 0.14.0",
@@ -8381,7 +8492,7 @@ dependencies = [
"crossbeam",
"dae-parser",
"document-features",
- "ecolor",
+ "ecolor 0.34.1",
"enumset",
"getrandom 0.3.4",
"glam",
@@ -8491,9 +8602,9 @@ dependencies = [
"arrow",
"bytemuck",
"document-features",
- "ecolor",
+ "ecolor 0.34.1",
"egui_plot",
- "emath",
+ "emath 0.34.1",
"glam",
"half",
"image",
@@ -8528,7 +8639,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e9a59c20ccb81f1117780498853a83ae822d4fe6862986809da5c8bc70dd286"
dependencies = [
"arrow",
- "egui",
+ "egui 0.34.1",
"egui_tiles",
"itertools 0.14.0",
"nohash-hasher",
@@ -8632,7 +8743,7 @@ version = "0.31.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d44c6ad1ce043303b7ec74087d4122013276a0d9c6471dd5424433aeac3a1a38"
dependencies = [
- "egui",
+ "egui 0.34.1",
"itertools 0.14.0",
"nohash-hasher",
"re_chunk",
@@ -8716,7 +8827,7 @@ dependencies = [
"ahash",
"anyhow",
"eframe",
- "egui",
+ "egui 0.34.1",
"egui_commonmark",
"egui_extras",
"egui_tiles",
@@ -8805,7 +8916,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b16e0186f7eca8244ada2adb345ea36ab0a29a6ed9984725656174a837357d12"
dependencies = [
"ahash",
- "egui",
+ "egui 0.34.1",
"glam",
"itertools 0.14.0",
"nohash-hasher",
@@ -8832,7 +8943,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1488d35cd9a19d906499cd675249b07b49d2472a4e79e929622667236e9e865"
dependencies = [
"arrow",
- "egui",
+ "egui 0.34.1",
"egui_plot",
"re_chunk_store",
"re_entity_db",
@@ -8853,7 +8964,7 @@ checksum = "00f0c6b70b5c50fac7daf69f1201f69cad982930c1f500c1e9fa579ec5838d46"
dependencies = [
"anyhow",
"arrow",
- "egui",
+ "egui 0.34.1",
"egui_dnd",
"egui_table",
"itertools 0.14.0",
@@ -8880,7 +8991,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e32c971e88b8ea6a17938d9ff64e62b71b41e5dbaea8fa4c8c8f3cbed8578f6d"
dependencies = [
"ahash",
- "egui",
+ "egui 0.34.1",
"fjadra",
"itertools 0.14.0",
"nohash-hasher",
@@ -8906,7 +9017,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45b01528c861bbb3bd9ccb14d5b3be981c2ce9f91b7eb68f5d12291d0e3a1cb2"
dependencies = [
"bytemuck",
- "egui",
+ "egui 0.34.1",
"glam",
"itertools 0.14.0",
"macaw",
@@ -8936,7 +9047,7 @@ dependencies = [
"arrow",
"bitflags 2.11.0",
"bytemuck",
- "egui",
+ "egui 0.34.1",
"glam",
"hexasphere",
"image",
@@ -8980,7 +9091,7 @@ checksum = "9e010883859e5ca8003ef9b450fd74d07a83827cfb4c987594c9e8fd451a2f0e"
dependencies = [
"anyhow",
"bytemuck",
- "egui",
+ "egui 0.34.1",
"half",
"ndarray 0.16.1",
"re_chunk_store",
@@ -9004,7 +9115,7 @@ version = "0.31.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "761ba77c9b421ae4da0fb5cb79b70941c083d72620b60c3f96538acbf7b3fd47"
dependencies = [
- "egui",
+ "egui 0.34.1",
"egui_commonmark",
"re_chunk_store",
"re_sdk_types",
@@ -9020,7 +9131,7 @@ version = "0.31.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f06f5f706af7e63cd9a2f8b646ba7e38fbc8ffced456df5a88216861ce6a9ec2"
dependencies = [
- "egui",
+ "egui 0.34.1",
"egui_extras",
"itertools 0.14.0",
"re_chunk_store",
@@ -9044,7 +9155,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77aba3707d959445ae4ea931440100aef7d7520e020c2ad333d8db1cd83da78d"
dependencies = [
"arrayvec",
- "egui",
+ "egui 0.34.1",
"egui_plot",
"itertools 0.14.0",
"nohash-hasher",
@@ -9079,11 +9190,11 @@ dependencies = [
"cfg-if",
"crossbeam",
"eframe",
- "egui",
+ "egui 0.34.1",
"egui-wgpu",
"egui_plot",
"ehttp",
- "emath",
+ "emath 0.34.1",
"glam",
"image",
"itertools 0.14.0",
@@ -9179,10 +9290,10 @@ dependencies = [
"crossbeam",
"datafusion",
"directories",
- "egui",
+ "egui 0.34.1",
"egui-wgpu",
"egui_tiles",
- "emath",
+ "emath 0.34.1",
"glam",
"half",
"home",
@@ -9247,7 +9358,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82754c9097bdd26830fdea4a99ca3cc28053ec5236d35d77c1e5a99bcb6f14e3"
dependencies = [
"ahash",
- "egui",
+ "egui 0.34.1",
"egui_tiles",
"nohash-hasher",
"rayon",
@@ -9273,7 +9384,7 @@ checksum = "e8659b1f204e158e3854ccd5424c8c423f87eb01bc8aa209079d5ccb502fe5fb"
dependencies = [
"ahash",
"arrow",
- "egui",
+ "egui 0.34.1",
"egui_tiles",
"itertools 0.14.0",
"nohash-hasher",
@@ -11921,7 +12032,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c81bab51dc24106d35edce41958ed16e3a0c1c0b45f40685ffd3f4b5691490c"
dependencies = [
"bytes",
- "egui",
+ "egui 0.34.1",
"egui_extras",
"futures",
"geo-types",
@@ -12386,7 +12497,7 @@ dependencies = [
"bytemuck",
"cfg-if",
"cfg_aliases",
- "glow",
+ "glow 0.17.0",
"glutin_wgl_sys",
"gpu-allocator",
"gpu-descriptor",
diff --git a/LICENSES/OFL-1.1.txt b/LICENSES/OFL-1.1.txt
new file mode 100644
index 0000000..d952d62
--- /dev/null
+++ b/LICENSES/OFL-1.1.txt
@@ -0,0 +1,92 @@
+This Font Software is licensed under the SIL Open Font License,
+Version 1.1.
+
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font
+creation efforts of academic and linguistic communities, and to
+provide a free and open framework in which fonts may be shared and
+improved in partnership with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply to
+any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software
+components as distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to,
+deleting, or substituting -- in part or in whole -- any of the
+components of the Original Version, by changing formats or by porting
+the Font Software to a new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed,
+modify, redistribute, and sell modified and unmodified copies of the
+Font Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components, in
+Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the
+corresponding Copyright Holder. This restriction only applies to the
+primary font name as presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created using
+the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/LICENSES/README.md b/LICENSES/README.md
index df1b246..d15fe40 100644
--- a/LICENSES/README.md
+++ b/LICENSES/README.md
@@ -8,9 +8,10 @@ runtime.
The layout follows SPDX / [REUSE](https://reuse.software/) convention:
one file per `-.txt`, plus shared canonical
license texts (`Apache-2.0.txt`, `MIT.txt`, `BSD-3-Clause.txt`,
-`EPL-2.0.txt`) that the per-component files reference. Per-component
-files document the upstream URL, the consuming robowbc crate, and the
-SPDX identifier for that component.
+`EPL-2.0.txt`, `OFL-1.1.txt`, `Ubuntu-font-1.0.txt`) that the
+per-component files reference. Per-component files document the upstream
+URL, the consuming robowbc crate, and the SPDX identifier for that
+component.
## Index
@@ -25,6 +26,8 @@ SPDX identifier for that component.
| crossterm | [`crossterm-MIT.txt`](crossterm-MIT.txt) | https://github.com/crossterm-rs/crossterm | Cross-platform terminal input, MIT. |
| rerun (Apache) | [`rerun-Apache-2.0.txt`](rerun-Apache-2.0.txt) | https://github.com/rerun-io/rerun | Visualization SDK + viewer, dual licensed `Apache-2.0 OR MIT`. |
| rerun (MIT) | [`rerun-MIT.txt`](rerun-MIT.txt) | https://github.com/rerun-io/rerun | MIT branch of the dual license. |
+| epaint default fonts (OFL) | [`epaint_default_fonts-OFL-1.1.txt`](epaint_default_fonts-OFL-1.1.txt) | https://github.com/emilk/egui/tree/main/crates/epaint_default_fonts | Default egui viewer font assets pulled transitively through mujoco-rs. |
+| epaint default fonts (Ubuntu) | [`epaint_default_fonts-Ubuntu-font-1.0.txt`](epaint_default_fonts-Ubuntu-font-1.0.txt) | https://github.com/emilk/egui/tree/main/crates/epaint_default_fonts | Ubuntu Light font asset pulled transitively through mujoco-rs. |
| unitree_sdk2 IDL types | [`unitree_sdk2-BSD-3-Clause.txt`](unitree_sdk2-BSD-3-Clause.txt) | https://github.com/unitreerobotics/unitree_sdk2 | Source of the IDL message shapes ported into `crates/unitree-hg-idl/`. |
| NVIDIA Open Model License| [`nvidia-open-model-license.txt`](nvidia-open-model-license.txt) | https://developer.download.nvidia.com/licenses/ | Governs the GEAR-SONIC weights — fetched at runtime from HuggingFace, **never bundled in this repo**. |
@@ -43,7 +46,7 @@ When you add a third-party dependency in a PR:
3. If the SPDX expression is not already represented (no
`LICENSES/.txt`), add the canonical text for it.
4. Add a row to the index table above.
-5. Update the allowlist in `.github/workflows/license.yml` if the new
+5. Update the allowlist in [`../deny.toml`](../deny.toml) if the new
SPDX identifier is not already accepted.
6. If the new dependency is **strong-copyleft** (GPL-3.0-only,
AGPL-3.0-only, etc.), pause and consult a human reviewer — robowbc
@@ -57,6 +60,8 @@ When you add a third-party dependency in a PR:
* Weak copyleft (EPL-2.0, MPL-2.0, LGPL-3.0): accepted with attribution.
CycloneDDS is the most prominent example; dual-licensed under
`EPL-2.0 OR BSD-3-Clause`, redistributors may pick the BSD branch.
+* Font licenses (OFL-1.1, Ubuntu-font-1.0): accepted for bundled UI font
+ assets with attribution and license text preservation.
* Strong copyleft (GPL, AGPL): rejected by CI.
* Model licenses (NVIDIA Open Model License, etc.): governed
per-bundle; weights are fetched at runtime, not redistributed.
diff --git a/LICENSES/Ubuntu-font-1.0.txt b/LICENSES/Ubuntu-font-1.0.txt
new file mode 100644
index 0000000..ae78a8f
--- /dev/null
+++ b/LICENSES/Ubuntu-font-1.0.txt
@@ -0,0 +1,96 @@
+-------------------------------
+UBUNTU FONT LICENCE Version 1.0
+-------------------------------
+
+PREAMBLE
+This licence allows the licensed fonts to be used, studied, modified and
+redistributed freely. The fonts, including any derivative works, can be
+bundled, embedded, and redistributed provided the terms of this licence
+are met. The fonts and derivatives, however, cannot be released under
+any other licence. The requirement for fonts to remain under this
+licence does not require any document created using the fonts or their
+derivatives to be published under this licence, as long as the primary
+purpose of the document is not to be a vehicle for the distribution of
+the fonts.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this licence and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Original Version" refers to the collection of Font Software components
+as received under this licence.
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to
+a new environment.
+
+"Copyright Holder(s)" refers to all individuals and companies who have a
+copyright ownership of the Font Software.
+
+"Substantially Changed" refers to Modified Versions which can be easily
+identified as dissimilar to the Font Software by users of the Font
+Software comparing the Original Version with the Modified Version.
+
+To "Propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification and with or without charging
+a redistribution fee), making available to the public, and in some
+countries other activities as well.
+
+PERMISSION & CONDITIONS
+This licence does not grant any rights under trademark law and all such
+rights are reserved.
+
+Permission is hereby granted, free of charge, to any person obtaining a
+copy of the Font Software, to propagate the Font Software, subject to
+the below conditions:
+
+1) Each copy of the Font Software must contain the above copyright
+notice and this licence. These can be included either as stand-alone
+text files, human-readable headers or in the appropriate machine-
+readable metadata fields within text or binary files as long as those
+fields can be easily viewed by the user.
+
+2) The font name complies with the following:
+(a) The Original Version must retain its name, unmodified.
+(b) Modified Versions which are Substantially Changed must be renamed to
+avoid use of the name of the Original Version or similar names entirely.
+(c) Modified Versions which are not Substantially Changed must be
+renamed to both (i) retain the name of the Original Version and (ii) add
+additional naming elements to distinguish the Modified Version from the
+Original Version. The name of such Modified Versions must be the name of
+the Original Version, with "derivative X" where X represents the name of
+the new work, appended to that name.
+
+3) The name(s) of the Copyright Holder(s) and any contributor to the
+Font Software shall not be used to promote, endorse or advertise any
+Modified Version, except (i) as required by this licence, (ii) to
+acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with
+their explicit written permission.
+
+4) The Font Software, modified or unmodified, in part or in whole, must
+be distributed entirely under this licence, and must not be distributed
+under any other licence. The requirement for fonts to remain under this
+licence does not affect any document created using the Font Software,
+except any version of the Font Software extracted from a document
+created using the Font Software may only be distributed under this
+licence.
+
+TERMINATION
+This licence becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
+COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
+DEALINGS IN THE FONT SOFTWARE.
diff --git a/LICENSES/epaint_default_fonts-OFL-1.1.txt b/LICENSES/epaint_default_fonts-OFL-1.1.txt
new file mode 100644
index 0000000..2e34c18
--- /dev/null
+++ b/LICENSES/epaint_default_fonts-OFL-1.1.txt
@@ -0,0 +1,12 @@
+Component: epaint_default_fonts - default egui font assets
+Upstream: https://github.com/emilk/egui/tree/main/crates/epaint_default_fonts
+SPDX-License-Identifier: OFL-1.1
+Used by: robowbc-sim through mujoco-rs / egui viewer dependencies.
+
+epaint_default_fonts bundles NotoEmoji-Regular.ttf under the SIL Open Font
+License 1.1. The full canonical OFL-1.1 text is in ./OFL-1.1.txt in this
+directory.
+
+Copyright notice from upstream font metadata:
+
+ Copyright 2013 Google Inc. All Rights Reserved.
diff --git a/LICENSES/epaint_default_fonts-Ubuntu-font-1.0.txt b/LICENSES/epaint_default_fonts-Ubuntu-font-1.0.txt
new file mode 100644
index 0000000..d79f26d
--- /dev/null
+++ b/LICENSES/epaint_default_fonts-Ubuntu-font-1.0.txt
@@ -0,0 +1,12 @@
+Component: epaint_default_fonts - default egui font assets
+Upstream: https://github.com/emilk/egui/tree/main/crates/epaint_default_fonts
+SPDX-License-Identifier: Ubuntu-font-1.0
+Used by: robowbc-sim through mujoco-rs / egui viewer dependencies.
+
+epaint_default_fonts bundles Ubuntu-Light.ttf under the Ubuntu Font Licence
+1.0. The full canonical Ubuntu Font Licence 1.0 text is in
+./Ubuntu-font-1.0.txt in this directory.
+
+Copyright notice from upstream font metadata:
+
+ Copyright 2011 Canonical Ltd.
diff --git a/Makefile b/Makefile
index 31df75d..a186350 100644
--- a/Makefile
+++ b/Makefile
@@ -25,7 +25,7 @@ PYTHON_SDK_TARGET_DIR ?= $(CURDIR)/target/python-sdk-wheel
.DEFAULT_GOAL := help
-.PHONY: help toolchain build build-release check test sim-feature-test clippy fmt fmt-check rust-doc mdbook-install docs-book docs verify smoke demo-keyboard models-public site-python-deps site-render-check benchmark-robowbc benchmark-official benchmark-summary benchmark-nvidia site showcase-verify site-smoke site-browser-smoke site-serve-check site-serve python-sdk-deps python-sdk-build python-sdk-install python-sdk-smoke python-sdk-verify ci
+.PHONY: help toolchain build build-release check test python-test sim-feature-test clippy fmt fmt-check rust-doc mdbook-install docs-book docs verify smoke demo-keyboard models-public site-python-deps site-render-check benchmark-robowbc benchmark-official benchmark-summary benchmark-nvidia site showcase-verify site-smoke site-browser-smoke site-serve-check site-serve python-sdk-deps python-sdk-build python-sdk-install python-sdk-smoke python-sdk-verify ci
help: ## Show available targets and useful variables.
@awk 'BEGIN {FS = ":.*## "; print "Targets:"} /^[a-zA-Z0-9_.-]+:.*## / {printf " %-20s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
@@ -61,9 +61,12 @@ check: ## Run cargo check across the workspace and all targets.
test: ## Run cargo test across the workspace and all targets.
$(CARGO) test --workspace --all-targets
+python-test: ## Run Python contract and integration tests.
+ $(PYTHON) -m pytest tests
+
sim-feature-test: ## Run the feature-enabled MuJoCo sim transport tests with the runtime/plugin env wired.
download_dir="$(abspath $(MUJOCO_DOWNLOAD_DIR))"; \
- "$(PYTHON)" scripts/ensure_mujoco_runtime.py --download-dir "$$download_dir" >/dev/null; \
+ "$(PYTHON)" scripts/mujoco/ensure_mujoco_runtime.py --download-dir "$$download_dir" >/dev/null; \
link_dir="$(MUJOCO_DYNAMIC_LINK_DIR)"; \
runtime_ld_library_path="$$link_dir:$${LD_LIBRARY_PATH:-}"; \
if [[ -f "$$link_dir/libmujoco.so" || -f "$$link_dir/libmujoco.dylib" || -f "$$link_dir/mujoco.lib" ]]; then \
@@ -107,9 +110,9 @@ smoke: ## Run the no-download local decoupled_wbc smoke config.
$(CARGO) run --bin robowbc -- run --config configs/decoupled_smoke.toml
demo-keyboard: ## Run the interactive GEAR-Sonic + MuJoCo keyboard demo.
- bash scripts/download_gear_sonic_models.sh models/gear-sonic
+ bash scripts/models/download_gear_sonic_models.sh models/gear-sonic
download_dir="$(abspath $(MUJOCO_DOWNLOAD_DIR))"; \
- "$(PYTHON)" scripts/ensure_mujoco_runtime.py --download-dir "$$download_dir" >/dev/null; \
+ "$(PYTHON)" scripts/mujoco/ensure_mujoco_runtime.py --download-dir "$$download_dir" >/dev/null; \
link_dir="$(MUJOCO_DYNAMIC_LINK_DIR)"; \
MUJOCO_DOWNLOAD_DIR="$$download_dir" \
MUJOCO_DYNAMIC_LINK_DIR="$$link_dir" \
@@ -119,10 +122,10 @@ demo-keyboard: ## Run the interactive GEAR-Sonic + MuJoCo keyboard demo.
-- run --config configs/demo/gear_sonic_keyboard_mujoco.toml --teleop keyboard
models-public: ## Download the public policy checkpoints used by the site and benchmarks.
- bash scripts/download_gear_sonic_models.sh models/gear-sonic
- bash scripts/download_decoupled_wbc_models.sh models/decoupled-wbc
- bash scripts/download_wbc_agile_models.sh models/wbc-agile
- bash scripts/download_bfm_zero_models.sh models/bfm_zero
+ bash scripts/models/download_gear_sonic_models.sh models/gear-sonic
+ bash scripts/models/download_decoupled_wbc_models.sh models/decoupled-wbc
+ bash scripts/models/download_wbc_agile_models.sh models/wbc-agile
+ bash scripts/models/download_bfm_zero_models.sh models/bfm_zero
site-python-deps: ## Install Python dependencies required for site generation and proof-pack capture.
$(PYTHON) -m pip install $(SITE_PYTHON_DEPS)
@@ -130,17 +133,17 @@ site-python-deps: ## Install Python dependencies required for site generation an
site-render-check: ## Verify the MuJoCo offscreen renderer works before building screenshot-bearing proof packs.
MUJOCO_GL="$(SHOWCASE_MUJOCO_GL)" \
PYOPENGL_PLATFORM="$(SHOWCASE_PYOPENGL_PLATFORM)" \
- $(PYTHON) scripts/check_mujoco_headless.py
+ $(PYTHON) scripts/mujoco/check_mujoco_headless.py
benchmark-robowbc: ## Regenerate normalized RoboWBC benchmark artifacts.
MUJOCO_DOWNLOAD_DIR="$(abspath $(MUJOCO_DOWNLOAD_DIR))" \
- $(PYTHON) scripts/bench_robowbc_compare.py --all
+ $(PYTHON) scripts/benchmarks/bench_robowbc_compare.py --all
benchmark-official: ## Regenerate normalized official NVIDIA benchmark artifacts.
- $(PYTHON) scripts/bench_nvidia_official.py --all
+ $(PYTHON) scripts/benchmarks/bench_nvidia_official.py --all
benchmark-summary: ## Render benchmark Markdown and HTML summaries from the normalized artifacts.
- $(PYTHON) scripts/render_nvidia_benchmark_summary.py \
+ $(PYTHON) scripts/benchmarks/render_nvidia_benchmark_summary.py \
--root artifacts/benchmarks/nvidia \
--output artifacts/benchmarks/nvidia/SUMMARY.md \
--html-output artifacts/benchmarks/nvidia/index.html
@@ -151,7 +154,7 @@ site: ## Build the full static site bundle with policy pages, proof packs, and b
MUJOCO_GL="$(SHOWCASE_MUJOCO_GL)" \
PYOPENGL_PLATFORM="$(SHOWCASE_PYOPENGL_PLATFORM)" \
MUJOCO_DOWNLOAD_DIR="$(MUJOCO_DOWNLOAD_DIR)" \
- $(PYTHON) scripts/build_site.py \
+ $(PYTHON) scripts/site/build_site.py \
--repo-root . \
--robowbc-binary "$(ROBOWBC_BINARY)" \
--output-dir "$(SITE_OUTPUT_DIR)"
@@ -164,10 +167,10 @@ showcase-verify: ## Run the same showcase build + bundle validation path used in
$(MAKE) site-smoke
site-smoke: ## Validate the generated site bundle layout and embedded playback paths.
- $(PYTHON) scripts/validate_site_bundle.py --root "$(SITE_OUTPUT_DIR)"
+ $(PYTHON) scripts/site/validate_site_bundle.py --root "$(SITE_OUTPUT_DIR)"
site-browser-smoke: site-smoke ## Run the optional headless browser lag-selector smoke test for one policy page.
- $(PYTHON) scripts/site_browser_smoke.py \
+ $(PYTHON) scripts/site/site_browser_smoke.py \
--root "$(SITE_OUTPUT_DIR)" \
--policy "$(SITE_BROWSER_POLICY)" \
--bind "$(SITE_BIND)"
@@ -188,7 +191,7 @@ site-serve: ## Serve the generated site bundle locally. Set SITE_OPEN=1 to open
if [[ "$(SITE_OPEN)" == "1" || "$(SITE_OPEN)" == "true" ]]; then \
extra_args="--open"; \
fi; \
- $(PYTHON) scripts/serve_showcase.py \
+ $(PYTHON) scripts/site/serve_showcase.py \
--dir "$(SITE_OUTPUT_DIR)" \
--bind "$(SITE_BIND)" \
--port "$(SITE_PORT)" \
@@ -210,7 +213,7 @@ python-sdk-install: python-sdk-build ## Install the freshly built local RoboWBC
$(PYTHON) -m pip install --force-reinstall "$$wheel"
python-sdk-smoke: ## Run the installed RoboWBC Python SDK smoke test.
- $(PYTHON) scripts/python_sdk_smoke.py
+ $(PYTHON) scripts/sdk/python_sdk_smoke.py
python-sdk-verify: ## Build, install, and smoke-test the RoboWBC Python SDK locally.
$(MAKE) python-sdk-install
diff --git a/README.md b/README.md
index 6ffe427..82a5a48 100644
--- a/README.md
+++ b/README.md
@@ -4,119 +4,21 @@
-Linux-only embedded runtime for humanoid whole-body-control policy inference.
-
-
- Open Live Policy Reports
- ·
- Getting Started
- ·
- Architecture
- ·
- Python SDK
- ·
- Founding Document
-
-
-RoboWBC is an embedded runtime for loading multiple WBC policies through one
-customer-facing contract. The primary adoption path is the Python SDK
-(`Registry`, `Observation`, `Policy`, and `MujocoSession`). Embedded Rust is
-the secondary path for teams that want to own the host loop directly. The CLI
-stays in the repo as the verification, benchmarking, and reporting surface for
-the same runtime.
-
-RoboWBC is a Linux-only project. The runtime backends fail fast on non-Linux
-targets instead of carrying partial or unverified platform fallbacks.
-
-Run `make help` to see the repo-level commands for build, validation,
-benchmarks, site generation, and local serving.
-
-## Embedded Runtime Surface
-
-Python is the primary embedded runtime seam:
-
-```python
-from robowbc import Observation, Registry, VelocityCommand
-
-policy = Registry.build("decoupled_wbc", "configs/decoupled_smoke.toml")
-print(policy.capabilities().supported_commands)
-
-obs = Observation(
- joint_positions=[0.0] * 4,
- joint_velocities=[0.0] * 4,
- gravity_vector=[0.0, 0.0, -1.0],
- command=VelocityCommand(linear=[0.2, 0.0, 0.0], angular=[0.0, 0.0, 0.1]),
-)
-targets = policy.predict(obs)
-print(targets.positions)
-```
-
-Embedded Rust is the secondary path:
-
-```rust
-use robowbc_core::WbcCommandKind;
-use robowbc_registry::WbcRegistry;
-
-let policy = WbcRegistry::build("my_policy", &policy_cfg)?;
-let capabilities = policy.capabilities();
-assert!(capabilities.supports(WbcCommandKind::Velocity));
-```
-
-Phase 1 deliberately keeps the surface narrow:
-
-- Python SDK first, embedded runtime second
-- no `server/daemon` surface
-- no `ROS2` or `zenoh` customer API
-- no public `EndEffectorPoses` surface
-- no new wrapper families beyond the shipped policy wrappers
-
-## System Architecture
-
-
-
-The hero image gives the repo shape at a glance. The SVG is the exact system
-map: current configs, registry names, core contract fields, ORT/PyO3 backends,
-transports, and report artifacts.
-
-## Published HTML reports
-
-These pages are generated by the `showcase` job on `main` and published
-directly from the CI artifact bundle:
+Linux-first embedded runtime for humanoid whole-body-control policy inference.
-| Page | Link | Purpose |
-|------|------|---------|
-| Site home | | Comparison-first overview across all generated policy cards |
-| NVIDIA benchmarks | | Normalized RoboWBC-vs-official benchmark comparison page |
-| GEAR-SONIC detail | | Planner-driven velocity showcase, logs, JSON, `.rrd`, and proof-pack assets |
-| Decoupled WBC detail | | GR00T WholeBodyControl locomotion showcase with the same staged command profile |
-| WBC-AGILE detail | | Published G1 recurrent checkpoint detail page and raw artifacts |
-| BFM-Zero detail | | Prompt-conditioned tracking showcase with context-bundle artifacts |
+Reports · Getting Started · Architecture · Status · Python SDK · Founding Document
-## What ships today
+RoboWBC loads multiple WBC policies through one caller-facing contract:
+observations and commands in, joint position targets out. The Python SDK is the
+primary user surface through `Registry`, `Observation`, `Policy`, command
+classes, and `MujocoSession`. Embedded Rust is available for teams that own the
+host loop directly, while the CLI remains the reproducible validation,
+benchmarking, and reporting surface for the same runtime.
-| Area | Status |
-|------|--------|
-| Runtime | Rust workspace with registry-driven policy loading, ONNX Runtime and PyO3 backends, MuJoCo and communication transports, plus JSON and Rerun reporting |
-| Live public-policy paths | `gear_sonic`, `decoupled_wbc`, `wbc_agile`, `bfm_zero` |
-| Honest blocked wrappers | `hover` needs a user-exported checkpoint, `wholebody_vla` still lacks a runnable public upstream release |
-| Published visual report | The `main` workflow is wired to build the same HTML report in CI and publish it to the live report link above |
+RoboWBC is Linux-only. Runtime backends fail fast on unsupported platforms
+instead of carrying partial or unverified fallbacks.
-## Policy status
-
-| Policy | Status | Public assets | Example config | Notes |
-|--------|--------|---------------|----------------|-------|
-| `gear_sonic` | Live | Yes | [configs/sonic_g1.toml](configs/sonic_g1.toml) | Uses the published `planner_sonic.onnx` velocity path by default; supports `cpu`, `cuda`, and `tensor_rt` when the host ORT/NVIDIA runtime matches, but the shipped config stays on CPU until you opt in |
-| `decoupled_wbc` | Live | Yes | [configs/decoupled_g1.toml](configs/decoupled_g1.toml) | Public G1 balance and walk checkpoints; [configs/decoupled_smoke.toml](configs/decoupled_smoke.toml) stays as the no-download smoke path |
-| `wbc_agile` | Live | Yes | [configs/wbc_agile_g1.toml](configs/wbc_agile_g1.toml) | Published G1 recurrent checkpoint is wired; the T1 path still expects a user export |
-| `bfm_zero` | Live | Yes | [configs/bfm_zero_g1.toml](configs/bfm_zero_g1.toml) | Public ONNX plus tracking context bundle is normalized by `scripts/download_bfm_zero_models.sh` |
-| `hover` | Blocked | No | [configs/hover_h1.toml](configs/hover_h1.toml) | Wrapper exists, but the public upstream repo does not ship a pretrained checkpoint |
-| `wholebody_vla` | Experimental | No | [configs/wholebody_vla_x2.toml](configs/wholebody_vla_x2.toml) | Contract wrapper only; the public upstream repo does not yet expose a runnable inference release |
-| `py_model` | User supplied | N/A | user TOML | Loads Python modules or PyTorch checkpoints through `robowbc-pyo3` |
-
-The generated HTML report includes every currently working public-asset policy:
-`gear_sonic`, `decoupled_wbc`, `wbc_agile`, and `bfm_zero`.
-
-## Quick start
+## First Run
```bash
make toolchain
@@ -125,12 +27,12 @@ make smoke
make ci
```
-`configs/decoupled_smoke.toml` uses the checked-in dynamic identity ONNX
-fixture, so it is the intended no-download local smoke path. `make ci` runs
-the same repo entry points that GitHub CI uses for Rust validation, docs,
-Python SDK verification, and the generated HTML site bundle.
+`make help` lists the repo-level commands for build, validation, benchmarks,
+site generation, and local serving. `configs/decoupled_smoke.toml` uses a
+checked-in dynamic identity ONNX fixture, so `make smoke` is the no-download
+local path.
-### I Just Want To See It Move
+### See It Move
On Linux with a display and OpenGL available:
@@ -140,118 +42,82 @@ cd robowbc
make demo-keyboard
```
-The target downloads the public GEAR-Sonic ONNX files and the MuJoCo runtime
-on first run, starts the local MuJoCo transport with a live viewer, and runs
-`robowbc` with keyboard teleop. The checked-in demo uses the GR00T scene
-wrapper plus a neutral-height virtual support band so the robot stays
-recoverable without being lifted off the ground. Keep the terminal focused for
-input and watch the MuJoCo window: `]` engages the policy after the init pose
-settles, `WASD` changes linear velocity, `QE` changes yaw, `Space` zeroes
-velocity, `9` toggles the support band, `O` sends a zero-velocity emergency-stop
-tick, and `Esc` quits. The underlying manual command is:
+`make demo-keyboard` is the protected clone-and-see-it-work path. It downloads
+the public GEAR-Sonic ONNX files and MuJoCo runtime on first run, starts the
+local MuJoCo viewer, and runs keyboard teleop. Keep the terminal focused:
+`]` engages after init-pose settle. Then press `9` promptly in either the
+terminal or MuJoCo viewer if the support band is holding the robot high so it
+drops to foot contact. `WASD` changes linear velocity, `QE` changes yaw,
+`Space` zeroes velocity, `O` sends a zero-velocity emergency-stop tick, and
+`Esc` quits. Preserve the demo guardrails in `docs/agents/keyboard-demo.md`
+when changing this path.
-```bash
-MUJOCO_DOWNLOAD_DIR="$(pwd)/.cache/mujoco" \
-LD_LIBRARY_PATH="$(pwd)/.cache/mujoco/mujoco-3.6.0/lib:${LD_LIBRARY_PATH:-}" \
-cargo run --release -p robowbc-cli \
- --features robowbc-cli/sim-auto-download,robowbc-cli/sim-viewer \
- -- run --config configs/demo/gear_sonic_keyboard_mujoco.toml --teleop keyboard
+## Runtime Surfaces
+
+Python uses `Registry`, `Observation`, command classes, and `Policy.predict`:
+
+```python
+policy = Registry.build("decoupled_wbc", "configs/decoupled_smoke.toml")
+targets = policy.predict(obs)
```
-
-Run the live public policies
+Rust uses the same registry and `WbcPolicy` contract:
-```bash
-bash scripts/download_gear_sonic_models.sh
-cargo run --release --bin robowbc -- run --config configs/sonic_g1.toml
+```rust
+let policy = WbcRegistry::build("my_policy", &policy_cfg)?;
+```
-bash scripts/download_decoupled_wbc_models.sh
-cargo run --release --bin robowbc -- run --config configs/decoupled_g1.toml
+## Architecture
-bash scripts/download_wbc_agile_models.sh
-cargo run --release --bin robowbc -- run --config configs/wbc_agile_g1.toml
+
-bash scripts/download_bfm_zero_models.sh
-cargo run --release --bin robowbc -- run --config configs/bfm_zero_g1.toml
+```text
+TOML config or Python SDK
+ -> robowbc-config + robowbc-registry
+ -> WbcPolicy implementation
+ -> Observation -> predict -> JointPositionTargets
+ -> hardware, MuJoCo, synthetic transport, JSON report, Rerun trace, static site
```
-`gear_sonic` defaults to the published `planner_sonic.onnx` velocity path. To
-exercise the narrower encoder+decoder standing-placeholder path instead, set
-`standing_placeholder_tracking = true` in `configs/sonic_g1.toml`. That path
-does not execute `planner_sonic.onnx` on the tick and is not a generic
-motion-reference streaming interface. GEAR-Sonic runtime configs support
-`cpu`, `cuda`, and `tensor_rt`, but the checked-in config remains CPU by
-default until you run on a machine with matching ONNX Runtime and NVIDIA
-runtime dependencies. `bfm_zero` fetches the public ONNX plus tracking bundle
-and converts the context into the runtime layout used by both the CLI and CI.
-
-
-
-Open or generate the visual report
+Core contracts are `Observation`, `WbcCommand`, `JointPositionTargets`,
+`WbcPolicy`, `PolicyCapabilities`, and `RobotConfig`. Unsupported commands fail
+explicitly instead of falling back silently. Read `ARCHITECTURE.md` and
+`docs/architecture.md` for the crate map and extension points.
-The same site builder powers both the local static bundle and the published
-GitHub Pages site.
+## Policy Status
-```bash
-make site
-make showcase-verify
-make site-serve SITE_OPEN=1
-```
+| Policy | State | Config | Notes |
+|--------|-------|--------|-------|
+| `gear_sonic` | Live | [configs/sonic_g1.toml](configs/sonic_g1.toml) | Published planner velocity path; CPU by default, CUDA/TensorRT opt-in |
+| `decoupled_wbc` | Live | [configs/decoupled_g1.toml](configs/decoupled_g1.toml) | Public G1 balance/walk checkpoints; smoke config needs no download |
+| `wbc_agile` | Live | [configs/wbc_agile_g1.toml](configs/wbc_agile_g1.toml) | Published G1 recurrent checkpoint; T1 path expects user export |
+| `bfm_zero` | Live | [configs/bfm_zero_g1.toml](configs/bfm_zero_g1.toml) | Public ONNX plus tracking context bundle |
+| `hover` | Blocked | [configs/hover_h1.toml](configs/hover_h1.toml) | Wrapper exists; no public pretrained checkpoint |
+| `wholebody_vla` | Experimental | [configs/wholebody_vla_x2.toml](configs/wholebody_vla_x2.toml) | Contract wrapper only; no runnable public upstream release |
+| `py_model` | User supplied | user TOML | Loads Python modules or PyTorch checkpoints through `robowbc-pyo3` |
-`make site` wraps `scripts/build_site.py` and now owns the full local/CI site
-build. It picks `./.cache/mujoco` by default, downloads MuJoCo there when
-needed, rebuilds the `robowbc` binary with
-`robowbc-cli/sim-auto-download,robowbc-cli/vis`, runs the benchmark
-generators, forces the same `MUJOCO_GL=egl` / `PYOPENGL_PLATFORM=egl`
-offscreen path that the GitHub showcase job uses, and assembles the final
-static bundle into `/tmp/robowbc-site`.
-Set `MUJOCO_DOWNLOAD_DIR=/your/cache make site` if you want a different cache
-location, or override `SITE_OUTPUT_DIR=/your/output make site` if you want the
-site somewhere else. `make site-smoke` validates the generated bundle layout
-without serving it, and `make site-serve-check` does a short start/stop probe
-of the local HTTP server.
-
-`make showcase-verify` is the closest local equivalent to the GitHub `showcase`
-job. It installs the site Python deps, runs the same headless MuJoCo EGL render
-smoke check that CI now relies on, downloads the public checkpoints, builds the
-site bundle, and fails if any MuJoCo-backed policy page ships a proof-pack
-manifest without real screenshots. On Ubuntu, install the headless EGL runtime
-first if that render smoke check fails:
-`sudo apt-get install -y libegl1 libegl-mesa0 libgles2 libgl1-mesa-dri libgbm1`.
-
-The output folder contains `index.html`, `manifest.json`, `policies//`
-folders with per-policy HTML plus raw run artifacts, `benchmarks/nvidia/` with
-the NVIDIA comparison page, and `assets/rerun-web-viewer/` for embedded Rerun
-playback. Pull requests keep the downloadable `robowbc-site` artifact, and
-`main` publishes the generated site to the live report links above.
-
-
-
-Manual real-model verification
+## Public Reports
-```bash
-bash scripts/download_gear_sonic_models.sh
-cargo test -p robowbc-ort -- --ignored gear_sonic_real_model_inference
+The `showcase` job on `main` publishes generated HTML policy cards, proof-pack
+links, benchmark pages, JSON, `.rrd`, and raw artifacts:
-bash scripts/download_decoupled_wbc_models.sh
-cargo test -p robowbc-ort -- --ignored decoupled_wbc_real_model_inference
+- Site home:
+- NVIDIA benchmark comparison:
+- Policy pages: [`gear_sonic`](https://miaodx.com/robowbc/policies/gear_sonic/), [`decoupled_wbc`](https://miaodx.com/robowbc/policies/decoupled_wbc/), [`wbc_agile`](https://miaodx.com/robowbc/policies/wbc_agile/), and [`bfm_zero`](https://miaodx.com/robowbc/policies/bfm_zero/)
-bash scripts/download_wbc_agile_models.sh
-cargo test -p robowbc-ort -- --ignored wbc_agile_real_model_inference
+Local report commands:
-bash scripts/download_bfm_zero_models.sh
-BFM_ZERO_MODEL_PATH=models/bfm_zero/bfm_zero_g1.onnx \
-BFM_ZERO_CONTEXT_PATH=models/bfm_zero/zs_walking.npy \
-cargo test -p robowbc-ort bfm_zero_real_model_inference -- --ignored --nocapture
+```bash
+make site
+make showcase-verify
+make site-serve SITE_OPEN=1
```
-`hover` still requires a user-trained exported checkpoint, and `wholebody_vla`
-still requires a compatible private or local model because no runnable public
-release exists upstream today.
-
+`make showcase-verify` downloads public checkpoints and requires a working
+headless MuJoCo EGL environment. Use `MUJOCO_DOWNLOAD_DIR` and
+`SITE_OUTPUT_DIR` to override local cache and output paths.
-
-Python SDK
+## Python SDK
```bash
pip install "maturin>=1.9.4,<2.0"
@@ -259,30 +125,9 @@ maturin develop
python -c "from robowbc import Registry; print(Registry.list_policies())"
```
-The standalone Python package lives in `crates/robowbc-py`, while
-`robowbc-pyo3` provides the runtime backend for user-supplied Python or
-PyTorch policies. First-party embedded examples live at:
-
-- `crates/robowbc-py/examples/lerobot_adapter.py` for velocity-driven locomotion
-- `crates/robowbc-py/examples/manipulation_adapter.py` for named-link `kinematic_pose`
-- `examples/python/mujoco_kinematic_pose_session.py` for live `MujocoSession.step({"kinematic_pose": ...})`
-
-
-
-Workspace layout
-
-| Path | Purpose |
-|------|---------|
-| `crates/robowbc-core` | `WbcPolicy`, `Observation`, `WbcCommand`, `JointPositionTargets`, `RobotConfig` |
-| `crates/robowbc-registry` | `inventory`-based policy registration and factory |
-| `crates/robowbc-ort` | ONNX Runtime backends and policy wrappers |
-| `crates/robowbc-pyo3` | Python-backed runtime policy loading |
-| `crates/robowbc-comm` | Control-loop plumbing and robot transports |
-| `crates/robowbc-sim` | MuJoCo transport for hardware-free execution |
-| `crates/robowbc-vis` | Rerun visualization and `.rrd` recording |
-| `crates/robowbc-cli` | `robowbc` CLI binary |
-| `crates/robowbc-py` | Standalone `maturin` package for the Python SDK |
-
+The standalone Python package lives in `crates/robowbc-py`; `robowbc-pyo3`
+provides the runtime backend for user-supplied Python or PyTorch policies.
+Examples live under `crates/robowbc-py/examples/` and `examples/python/`.
## Documentation
@@ -290,21 +135,18 @@ PyTorch policies. First-party embedded examples live at:
- [Configuration Reference](docs/configuration.md)
- [Adding a New Policy](docs/adding-a-model.md)
- [Adding a New Robot](docs/adding-a-robot.md)
-- [Architecture](docs/architecture.md)
+- [Architecture](ARCHITECTURE.md)
+- [Current status](STATUS.md)
+- [Full docs index](docs/README.md)
- [Founding document](docs/founding-document.md)
- [Q2 2026 roadmap](docs/roadmap-2026-q2.md)
-## Related projects
+## Related Projects
- [roboharness](https://github.com/MiaoDX/roboharness), companion visual testing and browser-report project
- [LeRobot](https://github.com/huggingface/lerobot), upstream robotics stack that can consume a WBC backend
## License
-robowbc itself is **MIT-licensed** — see [`LICENSE`](LICENSE).
-Third-party dependencies and runtime-fetched policy weights retain
-their original licenses; the per-component breakdown lives in
-[`LICENSES/`](LICENSES/) and the user-facing summary in
-[`docs/third-party-notices.md`](docs/third-party-notices.md).
-[`CONTRIBUTING.md`](CONTRIBUTING.md) documents the rule for adding a
-new dependency.
+robowbc is MIT-licensed; see [`LICENSE`](LICENSE). Third-party dependencies and runtime-fetched policy weights retain their original licenses.
+See [`LICENSES/`](LICENSES/), [`docs/third-party-notices.md`](docs/third-party-notices.md), and [`CONTRIBUTING.md`](CONTRIBUTING.md) for dependency and notice rules.
diff --git a/STATUS.md b/STATUS.md
new file mode 100644
index 0000000..3d2ffbc
--- /dev/null
+++ b/STATUS.md
@@ -0,0 +1,75 @@
+# Status
+
+Updated: 2026-05-14
+
+RoboWBC is in the v0.2 line. The current repo is focused on making the public
+runtime credible for Python-first robotics users while preserving the Rust
+control core.
+
+## Current State
+
+| Area | Status |
+|------|--------|
+| Runtime | Rust workspace with registry-driven policy loading, ONNX Runtime and PyO3 backends, MuJoCo, transport crates, validation, JSON reports, and Rerun visualization |
+| Python SDK | Primary customer-facing embedded surface through `Registry`, `Observation`, `Policy`, command classes, and `MujocoSession` |
+| Live public policies | `gear_sonic`, `decoupled_wbc`, `wbc_agile`, `bfm_zero` |
+| Blocked or experimental policies | `hover` needs a user-exported checkpoint; `wholebody_vla` has no runnable public upstream release |
+| Public report | GitHub Pages publishes generated policy cards, proof-pack links, benchmark pages, and raw artifacts |
+| Protected demo | `make demo-keyboard` runs the GEAR-Sonic MuJoCo keyboard path |
+
+## Can Run Now
+
+Use these as entry points, then choose narrower commands from `Makefile`:
+
+```bash
+make help
+make build
+make smoke
+make verify
+```
+
+For the local Python SDK:
+
+```bash
+make python-sdk-verify
+```
+
+For the full policy showcase path:
+
+```bash
+make showcase-verify
+```
+
+`make showcase-verify` downloads public checkpoints and requires a working
+headless MuJoCo EGL environment.
+
+## Active Queue
+
+The active human roadmap is `ROADMAP.md`.
+
+Current order:
+
+1. WBC-AGILE official-runtime parity, issue #93.
+2. BFM-Zero upstream parity, issue #94.
+3. WBC-AGILE 35-DOF MuJoCo truth decision, issue #97.
+4. LeRobot adapter milestone, issue #41.
+5. Unitree G1 hardware proof issue.
+
+## Important Constraints
+
+- Linux is the verified runtime target.
+- Do not claim a validation command passed unless it ran in the current
+ environment.
+- Public live-policy claims should stay tied to reproducible configs, reports,
+ and artifacts.
+- Hardware-facing or MuJoCo demo changes must preserve the keyboard demo
+ stability guardrails in `docs/agents/keyboard-demo.md`.
+
+## Where To Look Next
+
+- `README.md`: first-run overview and policy status.
+- `ARCHITECTURE.md`: high-level system map.
+- `docs/README.md`: full documentation index.
+- `ROADMAP.md`: ordered product and credibility work.
+- `.planning/STATE.md`: historical agent planning state and completed phase
+ evidence.
diff --git a/configs/README.md b/configs/README.md
new file mode 100644
index 0000000..c1c2512
--- /dev/null
+++ b/configs/README.md
@@ -0,0 +1,23 @@
+# Configs
+
+Configs are grouped by runtime purpose.
+
+## Policy Entry Points
+
+Top-level TOML files such as `sonic_g1.toml`, `decoupled_g1.toml`,
+`wbc_agile_g1.toml`, and `bfm_zero_g1.toml` are public policy run configs.
+
+`decoupled_smoke.toml` is the no-download smoke path. It uses checked-in ONNX
+fixtures and is the safest local config for quick validation.
+
+## Subfolders
+
+- `robots/` contains robot embodiment configs: joint names, default pose, gains,
+ and limits.
+- `showcase/` contains configs used by generated policy report pages.
+- `demo/` contains protected interactive demo configs, including
+ `demo/gear_sonic_keyboard_mujoco.toml`.
+- `teleop/` contains keyboard and input mapping config.
+
+When changing demo configs, read `docs/agents/keyboard-demo.md` and run the
+targeted MuJoCo stability check when the environment supports it.
diff --git a/configs/demo/gear_sonic_keyboard_mujoco.toml b/configs/demo/gear_sonic_keyboard_mujoco.toml
index 8c9a3dd..b742c58 100644
--- a/configs/demo/gear_sonic_keyboard_mujoco.toml
+++ b/configs/demo/gear_sonic_keyboard_mujoco.toml
@@ -14,7 +14,10 @@
# -- run --config configs/demo/gear_sonic_keyboard_mujoco.toml --teleop keyboard
#
# Keep your terminal focused for keyboard input and watch the MuJoCo window:
-# `]` engages policy, WASD/QE drive, `9` toggles the support band.
+# `]` engages policy, WASD/QE drive, and the first `9` press after policy
+# engagement toggles the MuJoCo support band off so the robot drops to foot
+# contact. The `9` support toggle works from both the terminal and the MuJoCo
+# viewer.
[policy]
name = "gear_sonic"
@@ -44,16 +47,21 @@ config_path = "configs/robots/unitree_g1_gear_sonic.toml"
model_path = "assets/robots/groot_g1_gear_sonic/scene_29dof.xml"
timestep = 0.002
substeps = 10
-gain_profile = "simulation_pd"
+gain_profile = "default_pd"
+# Match the official GR00T MuJoCo bridge: policy q-targets and the deployment
+# kp/kd values go directly into the simulator PD loop without the hardware-side
+# target slew limiter.
+enforce_target_velocity_limits = false
viewer = true
[sim.elastic_band]
-# GR00T-style MuJoCo teleop support band. We anchor it to the initialized
-# pelvis pose so it catches a fall without lifting the feet off the ground.
+# GR00T-style MuJoCo teleop support band: fixed point [0, 0, 1] with the
+# published spring-damper gains. The first `9` press toggles it off, matching
+# the official "drop robot to ground" sim flow.
enabled = true
body_name = "pelvis"
anchor = [0.0, 0.0, 1.0]
-anchor_from_initial_pose = true
+anchor_from_initial_pose = false
length = 0.0
kp_pos = 10000.0
kd_pos = 1000.0
@@ -69,9 +77,13 @@ backend = "ort"
device = "cpu"
[runtime]
-# Keyboard teleop starts in an init pose. Press `]` to engage the policy, then
-# use WASD/QE to drive. Press `9` to toggle the elastic support band. Live
-# teleop runs until Esc or Ctrl-C.
+# Keyboard teleop starts in an init pose. Press `]` to queue policy engagement
+# after the 3.0s settle window. If the support band is holding the robot high,
+# press `9` promptly after engagement to drop to foot contact, then use WASD/QE
+# to drive. Live teleop runs until Esc or Ctrl-C. A zero command after
+# engagement runs the official idle planner/controller path; keep the `9` drop
+# prompt and first velocity command close to engagement, matching upstream sim
+# teleop practice.
velocity = [0.0, 0.0, 0.0]
init_pose_secs = 3.0
require_engage = true
diff --git a/configs/robots/unitree_g1_gear_sonic.toml b/configs/robots/unitree_g1_gear_sonic.toml
index b68307c..30402aa 100644
--- a/configs/robots/unitree_g1_gear_sonic.toml
+++ b/configs/robots/unitree_g1_gear_sonic.toml
@@ -76,10 +76,12 @@ pd_gains = [
{ kp = 16.778327, kd = 1.068142 }, # right_wrist_yaw
]
-# MuJoCo/raw-torque gains from the official GR00T G1 sim config
-# (`MOTOR_KP` / `MOTOR_KD` in g1_29dof_gear_wbc.yaml). These are intentionally
-# much stiffer than the hardware-side q_target gains above and are only used by
-# the simulation transport.
+# Alternate MuJoCo/raw-torque gains from the GR00T G1 sim config
+# (`MOTOR_KP` / `MOTOR_KD` in g1_29dof_gear_wbc.yaml). The official
+# GEAR-Sonic deploy-to-MuJoCo path sends the deployment `kps` / `kds` values
+# above in each low command, so the keyboard demo uses `gain_profile =
+# "default_pd"` for parity. These stiffer gains remain available for generic
+# simulation experiments that explicitly select `simulation_pd`.
sim_pd_gains = [
{ kp = 150.0, kd = 2.0 }, # left_hip_pitch
{ kp = 150.0, kd = 2.0 }, # left_hip_roll
diff --git a/crates/robowbc-cli/src/main.rs b/crates/robowbc-cli/src/main.rs
index dc817bb..db8b0c7 100644
--- a/crates/robowbc-cli/src/main.rs
+++ b/crates/robowbc-cli/src/main.rs
@@ -758,12 +758,35 @@ struct ReportBasePose {
rotation_xyzw: [f32; 4],
}
+#[derive(Debug, Clone, Serialize)]
+struct ReportPlannerStatus {
+ mode_id: i64,
+ mode_name: String,
+ target_vel_mps: Option,
+ planar_speed_mps: f32,
+}
+
#[derive(Debug, Clone, Serialize)]
struct VelocityTrackingMetrics {
sample_count: usize,
+ sim_elapsed_secs: f64,
+ wall_elapsed_secs: f64,
+ real_time_factor: f64,
+ commanded_vx_mean_mps: f64,
+ commanded_vy_mean_mps: f64,
+ commanded_yaw_rate_mean_rad_s: f64,
+ actual_vx_mean_mps: f64,
+ actual_vy_mean_mps: f64,
+ actual_yaw_rate_mean_rad_s: f64,
+ actual_wall_vx_mean_mps: f64,
+ actual_wall_vy_mean_mps: f64,
+ actual_wall_yaw_rate_mean_rad_s: f64,
vx_rmse_mps: f64,
vy_rmse_mps: f64,
yaw_rate_rmse_rad_s: f64,
+ wall_vx_rmse_mps: f64,
+ wall_vy_rmse_mps: f64,
+ wall_yaw_rate_rmse_rad_s: f64,
vx_mean_abs_error_mps: f64,
vy_mean_abs_error_mps: f64,
yaw_rate_mean_abs_error_rad_s: f64,
@@ -778,11 +801,16 @@ struct VelocityTrackingMetrics {
#[derive(Debug, Clone, Serialize)]
struct ReportFrame {
tick: usize,
+ wall_time_secs: f64,
command_data: Vec,
#[serde(skip_serializing_if = "Option::is_none")]
phase_name: Option,
#[serde(skip_serializing_if = "Option::is_none")]
base_pose: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ elastic_band_enabled: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ planner_status: Option,
actual_positions: Vec,
actual_velocities: Vec,
target_positions: Vec,
@@ -793,11 +821,16 @@ struct ReportFrame {
struct ReplayFrame {
tick: usize,
sim_time_secs: f64,
+ wall_time_secs: f64,
command_data: Vec,
#[serde(skip_serializing_if = "Option::is_none")]
phase_name: Option,
#[serde(skip_serializing_if = "Option::is_none")]
base_pose: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ elastic_band_enabled: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ planner_status: Option,
actual_positions: Vec,
actual_velocities: Vec,
target_positions: Vec,
@@ -812,9 +845,12 @@ impl From<&ReplayFrame> for ReportFrame {
fn from(frame: &ReplayFrame) -> Self {
Self {
tick: frame.tick,
+ wall_time_secs: frame.wall_time_secs,
command_data: frame.command_data.clone(),
phase_name: frame.phase_name.clone(),
base_pose: frame.base_pose,
+ elastic_band_enabled: frame.elastic_band_enabled,
+ planner_status: frame.planner_status.clone(),
actual_positions: frame.actual_positions.clone(),
actual_velocities: frame.actual_velocities.clone(),
target_positions: frame.target_positions.clone(),
@@ -932,6 +968,12 @@ enum TeleopControlRequest {
ToggleElasticBand,
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum PolicyEngageRequestOutcome {
+ Immediate,
+ QueuedUntilSettled,
+}
+
#[derive(Debug, Clone, PartialEq)]
struct TeleopPollOutcome {
velocity: [f32; 3],
@@ -982,6 +1024,41 @@ fn apply_teleop_events(
}
}
+fn apply_policy_engage_request(
+ startup_config: RuntimeStartupConfig,
+ policy_engaged: &mut bool,
+ pending_policy_engage: &mut bool,
+ init_pose_start: &mut Option<(Instant, Vec)>,
+) -> PolicyEngageRequestOutcome {
+ if startup_config.require_engage && startup_config.init_pose_secs > 0.0 {
+ *pending_policy_engage = true;
+ PolicyEngageRequestOutcome::QueuedUntilSettled
+ } else {
+ *policy_engaged = true;
+ *pending_policy_engage = false;
+ *init_pose_start = None;
+ PolicyEngageRequestOutcome::Immediate
+ }
+}
+
+fn pending_policy_engage_is_ready(
+ startup_config: RuntimeStartupConfig,
+ pending_policy_engage: bool,
+ elapsed: Duration,
+) -> bool {
+ startup_config.require_engage
+ && pending_policy_engage
+ && elapsed.as_secs_f32() >= startup_config.init_pose_secs
+}
+
+fn startup_settle_remaining_secs(
+ startup_config: RuntimeStartupConfig,
+ elapsed: Option,
+) -> f32 {
+ let elapsed_secs = elapsed.map_or(0.0, |duration| duration.as_secs_f32());
+ (startup_config.init_pose_secs - elapsed_secs).max(0.0)
+}
+
struct LiveTeleop {
source: KeyboardTeleop,
current_velocity: [f32; 3],
@@ -994,7 +1071,7 @@ impl LiveTeleop {
.enable()
.map_err(|err| format!("failed to enable keyboard teleop: {err}"))?;
println!(
- "keyboard teleop active: ] engages policy, WASD move, QE yaw, Space zeroes velocity, 9 toggles support band, O e-stops, Esc quits"
+ "keyboard teleop active: press ] to engage policy after settle; first 9 press in the terminal or MuJoCo viewer drops support band toward foot contact, WASD/QE update velocity, Space zeroes, O e-stops, Esc/Ctrl-C quits"
);
Ok(Self {
source,
@@ -1320,6 +1397,10 @@ trait ReportTelemetryProvider {
fn report_mujoco_state(&self) -> Option {
None
}
+
+ fn report_elastic_band_enabled(&self) -> Option {
+ None
+ }
}
impl ReportTelemetryProvider for SyntheticTransport {}
@@ -1346,14 +1427,91 @@ impl ReportTelemetryProvider for MujocoTransport {
qvel: self.qvel_snapshot(),
})
}
+
+ fn report_elastic_band_enabled(&self) -> Option {
+ self.elastic_band_enabled()
+ }
}
-#[derive(Default)]
+const VELOCITY_MONITOR_PRINT_INTERVAL_SECS: f64 = 1.0;
+const NONZERO_PLANAR_COMMAND_EPS_MPS: f64 = 0.05;
+const GEAR_SONIC_PLANNER_MODE_IDLE: i64 = 0;
+const GEAR_SONIC_PLANNER_MODE_SLOW_WALK: i64 = 1;
+const GEAR_SONIC_PLANNER_MODE_WALK: i64 = 2;
+const GEAR_SONIC_PLANNER_MODE_RUN: i64 = 3;
+const GEAR_SONIC_MIN_MOVING_SPEED_MPS: f32 = 0.2;
+const GEAR_SONIC_WALK_COMMAND_MIN_MPS: f32 = 0.8;
+const GEAR_SONIC_RUN_COMMAND_MIN_MPS: f32 = 1.5;
+const GEAR_SONIC_RUN_COMMAND_MAX_MPS: f32 = 3.0;
+
+#[derive(Debug, Clone)]
+struct VelocityTrackingFrame {
+ sim_time_secs: f64,
+ wall_time_secs: f64,
+ command: [f32; 3],
+ base_pose: Option,
+ elastic_band_enabled: Option,
+ planner_status: Option,
+}
+
+impl VelocityTrackingFrame {
+ fn from_replay_frame(frame: &ReplayFrame) -> Option {
+ if frame.command_data.len() < 3 {
+ return None;
+ }
+
+ Some(Self {
+ sim_time_secs: frame.sim_time_secs,
+ wall_time_secs: frame.wall_time_secs,
+ command: [
+ frame.command_data[0],
+ frame.command_data[1],
+ frame.command_data[2],
+ ],
+ base_pose: frame.base_pose,
+ elastic_band_enabled: frame.elastic_band_enabled,
+ planner_status: frame.planner_status.clone(),
+ })
+ }
+}
+
+#[derive(Debug, Clone)]
+struct VelocityTrackingSample {
+ command: [f32; 3],
+ actual_vx_mps: f64,
+ actual_vy_mps: f64,
+ actual_yaw_rate_rad_s: f64,
+ actual_wall_vx_mps: f64,
+ actual_wall_vy_mps: f64,
+ actual_wall_yaw_rate_rad_s: f64,
+ sim_dt_secs: f64,
+ wall_dt_secs: f64,
+ body_displacement: [f32; 2],
+ yaw_delta_rad: f32,
+ elastic_band_enabled: Option,
+ planner_status: Option,
+}
+
+#[derive(Default, Clone)]
struct VelocityTrackingAccumulator {
sample_count: usize,
+ sim_elapsed_secs: f64,
+ wall_elapsed_secs: f64,
+ commanded_vx_sum: f64,
+ commanded_vy_sum: f64,
+ commanded_yaw_sum: f64,
+ actual_vx_sum: f64,
+ actual_vy_sum: f64,
+ actual_yaw_sum: f64,
+ actual_wall_vx_sum: f64,
+ actual_wall_vy_sum: f64,
+ actual_wall_yaw_sum: f64,
vx_sq_error_sum: f64,
vy_sq_error_sum: f64,
yaw_sq_error_sum: f64,
+ wall_vx_sq_error_sum: f64,
+ wall_vy_sq_error_sum: f64,
+ wall_yaw_sq_error_sum: f64,
vx_abs_error_sum: f64,
vy_abs_error_sum: f64,
yaw_abs_error_sum: f64,
@@ -1366,49 +1524,81 @@ struct VelocityTrackingAccumulator {
}
impl VelocityTrackingAccumulator {
- fn update(
- &mut self,
- command: [f32; 3],
- actual_body_displacement: [f32; 2],
- actual_yaw_delta_rad: f32,
- dt_secs: f64,
- ) {
- let actual_forward_velocity = f64::from(actual_body_displacement[0]) / dt_secs;
- let actual_lateral_velocity = f64::from(actual_body_displacement[1]) / dt_secs;
- let actual_yaw_rate = f64::from(actual_yaw_delta_rad) / dt_secs;
-
- let forward_velocity_error = actual_forward_velocity - f64::from(command[0]);
- let lateral_velocity_error = actual_lateral_velocity - f64::from(command[1]);
- let yaw_error = actual_yaw_rate - f64::from(command[2]);
+ #[allow(clippy::similar_names)]
+ fn update(&mut self, sample: &VelocityTrackingSample) {
+ let command_vx = f64::from(sample.command[0]);
+ let command_vy = f64::from(sample.command[1]);
+ let command_yaw = f64::from(sample.command[2]);
+
+ let forward_velocity_error = sample.actual_vx_mps - command_vx;
+ let lateral_velocity_error = sample.actual_vy_mps - command_vy;
+ let yaw_error = sample.actual_yaw_rate_rad_s - command_yaw;
+ let wall_forward_velocity_error = sample.actual_wall_vx_mps - command_vx;
+ let wall_lateral_velocity_error = sample.actual_wall_vy_mps - command_vy;
+ let wall_yaw_error = sample.actual_wall_yaw_rate_rad_s - command_yaw;
self.sample_count = self.sample_count.saturating_add(1);
+ self.sim_elapsed_secs += sample.sim_dt_secs;
+ self.wall_elapsed_secs += sample.wall_dt_secs;
+ self.commanded_vx_sum += command_vx;
+ self.commanded_vy_sum += command_vy;
+ self.commanded_yaw_sum += command_yaw;
+ self.actual_vx_sum += sample.actual_vx_mps;
+ self.actual_vy_sum += sample.actual_vy_mps;
+ self.actual_yaw_sum += sample.actual_yaw_rate_rad_s;
+ self.actual_wall_vx_sum += sample.actual_wall_vx_mps;
+ self.actual_wall_vy_sum += sample.actual_wall_vy_mps;
+ self.actual_wall_yaw_sum += sample.actual_wall_yaw_rate_rad_s;
self.vx_sq_error_sum += forward_velocity_error * forward_velocity_error;
self.vy_sq_error_sum += lateral_velocity_error * lateral_velocity_error;
self.yaw_sq_error_sum += yaw_error * yaw_error;
+ self.wall_vx_sq_error_sum += wall_forward_velocity_error * wall_forward_velocity_error;
+ self.wall_vy_sq_error_sum += wall_lateral_velocity_error * wall_lateral_velocity_error;
+ self.wall_yaw_sq_error_sum += wall_yaw_error * wall_yaw_error;
self.vx_abs_error_sum += forward_velocity_error.abs();
self.vy_abs_error_sum += lateral_velocity_error.abs();
self.yaw_abs_error_sum += yaw_error.abs();
self.vx_peak_abs_error = self.vx_peak_abs_error.max(forward_velocity_error.abs());
self.vy_peak_abs_error = self.vy_peak_abs_error.max(lateral_velocity_error.abs());
self.yaw_peak_abs_error = self.yaw_peak_abs_error.max(yaw_error.abs());
- self.forward_distance_m += f64::from(actual_body_displacement[0]);
- self.lateral_distance_m += f64::from(actual_body_displacement[1]);
- self.heading_change_rad += f64::from(actual_yaw_delta_rad);
+ self.forward_distance_m += f64::from(sample.body_displacement[0]);
+ self.lateral_distance_m += f64::from(sample.body_displacement[1]);
+ self.heading_change_rad += f64::from(sample.yaw_delta_rad);
}
- fn finish(self) -> Option {
+ fn finish(&self) -> Option {
if self.sample_count == 0 {
return None;
}
#[allow(clippy::cast_precision_loss)]
let sample_count = self.sample_count as f64;
+ let real_time_factor = if self.wall_elapsed_secs > f64::EPSILON {
+ self.sim_elapsed_secs / self.wall_elapsed_secs
+ } else {
+ 0.0
+ };
Some(VelocityTrackingMetrics {
sample_count: self.sample_count,
+ sim_elapsed_secs: self.sim_elapsed_secs,
+ wall_elapsed_secs: self.wall_elapsed_secs,
+ real_time_factor,
+ commanded_vx_mean_mps: self.commanded_vx_sum / sample_count,
+ commanded_vy_mean_mps: self.commanded_vy_sum / sample_count,
+ commanded_yaw_rate_mean_rad_s: self.commanded_yaw_sum / sample_count,
+ actual_vx_mean_mps: self.actual_vx_sum / sample_count,
+ actual_vy_mean_mps: self.actual_vy_sum / sample_count,
+ actual_yaw_rate_mean_rad_s: self.actual_yaw_sum / sample_count,
+ actual_wall_vx_mean_mps: self.actual_wall_vx_sum / sample_count,
+ actual_wall_vy_mean_mps: self.actual_wall_vy_sum / sample_count,
+ actual_wall_yaw_rate_mean_rad_s: self.actual_wall_yaw_sum / sample_count,
vx_rmse_mps: (self.vx_sq_error_sum / sample_count).sqrt(),
vy_rmse_mps: (self.vy_sq_error_sum / sample_count).sqrt(),
yaw_rate_rmse_rad_s: (self.yaw_sq_error_sum / sample_count).sqrt(),
+ wall_vx_rmse_mps: (self.wall_vx_sq_error_sum / sample_count).sqrt(),
+ wall_vy_rmse_mps: (self.wall_vy_sq_error_sum / sample_count).sqrt(),
+ wall_yaw_rate_rmse_rad_s: (self.wall_yaw_sq_error_sum / sample_count).sqrt(),
vx_mean_abs_error_mps: self.vx_abs_error_sum / sample_count,
vy_mean_abs_error_mps: self.vy_abs_error_sum / sample_count,
yaw_rate_mean_abs_error_rad_s: self.yaw_abs_error_sum / sample_count,
@@ -1422,10 +1612,136 @@ impl VelocityTrackingAccumulator {
}
}
+struct VelocityTrackingMonitor {
+ enabled: bool,
+ previous_frame: Option,
+ accumulator: VelocityTrackingAccumulator,
+ next_print_wall_time_secs: f64,
+ support_band_hint_printed: bool,
+}
+
+struct VelocityMonitorSnapshot {
+ sample: VelocityTrackingSample,
+ metrics: VelocityTrackingMetrics,
+ support_band_hint: bool,
+}
+
+impl VelocityTrackingMonitor {
+ fn new(runtime_command: &ParsedRuntimeCommand) -> Self {
+ Self {
+ enabled: matches!(
+ runtime_command,
+ ParsedRuntimeCommand::Velocity(_) | ParsedRuntimeCommand::VelocitySchedule(_)
+ ),
+ previous_frame: None,
+ accumulator: VelocityTrackingAccumulator::default(),
+ next_print_wall_time_secs: VELOCITY_MONITOR_PRINT_INTERVAL_SECS,
+ support_band_hint_printed: false,
+ }
+ }
+
+ fn observe(&mut self, frame: &ReplayFrame) -> Option {
+ if !self.enabled {
+ return None;
+ }
+
+ let current = VelocityTrackingFrame::from_replay_frame(frame)?;
+ let sample = self
+ .previous_frame
+ .take()
+ .and_then(|previous| velocity_tracking_sample(previous, ¤t));
+ self.previous_frame = Some(current);
+
+ let sample = sample?;
+ self.accumulator.update(&sample);
+ let metrics = self.accumulator.finish()?;
+ let support_band_hint = !self.support_band_hint_printed
+ && sample.elastic_band_enabled == Some(true)
+ && planar_command_speed_mps(sample.command) > NONZERO_PLANAR_COMMAND_EPS_MPS;
+ if support_band_hint {
+ self.support_band_hint_printed = true;
+ }
+
+ if support_band_hint || frame.wall_time_secs >= self.next_print_wall_time_secs {
+ self.next_print_wall_time_secs =
+ frame.wall_time_secs + VELOCITY_MONITOR_PRINT_INTERVAL_SECS;
+ Some(VelocityMonitorSnapshot {
+ sample,
+ metrics,
+ support_band_hint,
+ })
+ } else {
+ None
+ }
+ }
+
+ fn finish(&self) -> Option {
+ self.accumulator.finish()
+ }
+}
+
+fn planar_command_speed_mps(command: [f32; 3]) -> f64 {
+ f64::from(command[0]).hypot(f64::from(command[1]))
+}
+
+#[allow(clippy::similar_names)]
+fn velocity_tracking_sample(
+ previous: VelocityTrackingFrame,
+ current: &VelocityTrackingFrame,
+) -> Option {
+ let sim_dt_secs = current.sim_time_secs - previous.sim_time_secs;
+ if sim_dt_secs <= f64::EPSILON {
+ return None;
+ }
+
+ let wall_dt_secs = match current.wall_time_secs - previous.wall_time_secs {
+ dt if dt > f64::EPSILON => dt,
+ _ => sim_dt_secs,
+ };
+
+ let (Some(previous_pose), Some(current_pose)) = (previous.base_pose, current.base_pose) else {
+ return None;
+ };
+
+ let body_displacement = world_to_body_planar_delta(
+ previous_pose.position_world,
+ previous_pose.rotation_xyzw,
+ current_pose.position_world,
+ );
+ let yaw_delta_rad = wrap_angle_rad(
+ yaw_from_rotation_xyzw(current_pose.rotation_xyzw)
+ - yaw_from_rotation_xyzw(previous_pose.rotation_xyzw),
+ );
+
+ let actual_vx_mps = f64::from(body_displacement[0]) / sim_dt_secs;
+ let actual_vy_mps = f64::from(body_displacement[1]) / sim_dt_secs;
+ let actual_yaw_rate_rad_s = f64::from(yaw_delta_rad) / sim_dt_secs;
+ let actual_wall_vx_mps = f64::from(body_displacement[0]) / wall_dt_secs;
+ let actual_wall_vy_mps = f64::from(body_displacement[1]) / wall_dt_secs;
+ let actual_wall_yaw_rate_rad_s = f64::from(yaw_delta_rad) / wall_dt_secs;
+
+ Some(VelocityTrackingSample {
+ command: previous.command,
+ actual_vx_mps,
+ actual_vy_mps,
+ actual_yaw_rate_rad_s,
+ actual_wall_vx_mps,
+ actual_wall_vy_mps,
+ actual_wall_yaw_rate_rad_s,
+ sim_dt_secs,
+ wall_dt_secs,
+ body_displacement,
+ yaw_delta_rad,
+ elastic_band_enabled: previous.elastic_band_enabled,
+ planner_status: previous.planner_status,
+ })
+}
+
+#[cfg(test)]
fn compute_velocity_tracking_metrics(
frames: &[ReplayFrame],
runtime_command: &ParsedRuntimeCommand,
- frequency_hz: u32,
+ _frequency_hz: u32,
) -> Option {
if !matches!(
runtime_command,
@@ -1434,46 +1750,116 @@ fn compute_velocity_tracking_metrics(
return None;
}
- if frequency_hz == 0 || frames.len() < 2 {
+ if frames.len() < 2 {
return None;
}
- let dt_secs = 1.0 / f64::from(frequency_hz);
let mut accumulator = VelocityTrackingAccumulator::default();
for pair in frames.windows(2) {
- let previous = &pair[0];
- let current = &pair[1];
-
- if previous.command_data.len() < 3 {
+ let Some(previous) = VelocityTrackingFrame::from_replay_frame(&pair[0]) else {
continue;
- }
-
- let (Some(previous_pose), Some(current_pose)) = (previous.base_pose, current.base_pose)
- else {
+ };
+ let Some(current) = VelocityTrackingFrame::from_replay_frame(&pair[1]) else {
continue;
};
+ if let Some(sample) = velocity_tracking_sample(previous, ¤t) {
+ accumulator.update(&sample);
+ }
+ }
- let command = [
- previous.command_data[0],
- previous.command_data[1],
- previous.command_data[2],
- ];
+ accumulator.finish()
+}
- let body_displacement = world_to_body_planar_delta(
- previous_pose.position_world,
- previous_pose.rotation_xyzw,
- current_pose.position_world,
- );
- let yaw_delta = wrap_angle_rad(
- yaw_from_rotation_xyzw(current_pose.rotation_xyzw)
- - yaw_from_rotation_xyzw(previous_pose.rotation_xyzw),
+fn gear_sonic_mode_name(mode_id: i64) -> &'static str {
+ match mode_id {
+ GEAR_SONIC_PLANNER_MODE_IDLE => "idle",
+ GEAR_SONIC_PLANNER_MODE_SLOW_WALK => "slow_walk",
+ GEAR_SONIC_PLANNER_MODE_WALK => "walk",
+ GEAR_SONIC_PLANNER_MODE_RUN => "run",
+ _ => "unknown",
+ }
+}
+
+fn gear_sonic_planner_status_from_command(command_data: &[f32]) -> Option {
+ if command_data.len() < 2 {
+ return None;
+ }
+
+ let planar_speed_mps = command_data[0].hypot(command_data[1]);
+ let (mode_id, target_vel_mps) = if planar_speed_mps <= 0.01 {
+ (GEAR_SONIC_PLANNER_MODE_IDLE, None)
+ } else {
+ let target_vel_mps = planar_speed_mps.clamp(
+ GEAR_SONIC_MIN_MOVING_SPEED_MPS,
+ GEAR_SONIC_RUN_COMMAND_MAX_MPS,
);
+ let mode_id = if target_vel_mps < GEAR_SONIC_WALK_COMMAND_MIN_MPS {
+ GEAR_SONIC_PLANNER_MODE_SLOW_WALK
+ } else if target_vel_mps < GEAR_SONIC_RUN_COMMAND_MIN_MPS {
+ GEAR_SONIC_PLANNER_MODE_WALK
+ } else {
+ GEAR_SONIC_PLANNER_MODE_RUN
+ };
+ (mode_id, Some(target_vel_mps))
+ };
- accumulator.update(command, body_displacement, yaw_delta, dt_secs);
+ Some(ReportPlannerStatus {
+ mode_id,
+ mode_name: gear_sonic_mode_name(mode_id).to_owned(),
+ target_vel_mps,
+ planar_speed_mps,
+ })
+}
+
+fn planner_status_for_command(
+ policy_name: &str,
+ runtime_command: &ParsedRuntimeCommand,
+ command_data: &[f32],
+) -> Option {
+ if policy_name == "gear_sonic"
+ && matches!(
+ runtime_command,
+ ParsedRuntimeCommand::Velocity(_) | ParsedRuntimeCommand::VelocitySchedule(_)
+ )
+ {
+ gear_sonic_planner_status_from_command(command_data)
+ } else {
+ None
}
+}
- accumulator.finish()
+fn print_velocity_monitor(snapshot: &VelocityMonitorSnapshot) {
+ let band_state = match snapshot.sample.elastic_band_enabled {
+ Some(true) => "enabled",
+ Some(false) => "disabled",
+ None => "n/a",
+ };
+ let planner_mode = snapshot
+ .sample
+ .planner_status
+ .as_ref()
+ .map_or("n/a", |status| status.mode_name.as_str());
+ let planner_target = snapshot
+ .sample
+ .planner_status
+ .as_ref()
+ .and_then(|status| status.target_vel_mps)
+ .map_or_else(|| "n/a".to_owned(), |target| format!("{target:.2}"));
+ println!(
+ "velocity monitor: planner_mode={planner_mode}, planner_target={planner_target} m/s, cmd_vx={:.2} m/s, actual_vx_sim={:.2} m/s, actual_vx_wall={:.2} m/s, rt={:.2}x, vx_rmse={:.2} m/s, wall_vx_rmse={:.2} m/s, support_band={band_state}",
+ snapshot.sample.command[0],
+ snapshot.sample.actual_vx_mps,
+ snapshot.sample.actual_wall_vx_mps,
+ snapshot.metrics.real_time_factor,
+ snapshot.metrics.vx_rmse_mps,
+ snapshot.metrics.wall_vx_rmse_mps,
+ );
+ if snapshot.support_band_hint {
+ println!(
+ "velocity monitor: support band is enabled while a nonzero planar velocity is commanded; press 9 before judging walking speed"
+ );
+ }
}
fn yaw_from_rotation_xyzw(rotation_xyzw: [f32; 4]) -> f32 {
@@ -1516,6 +1902,7 @@ fn world_to_body_planar_delta(
fn run_control_loop_inner(
transport: &mut T,
policy: &dyn WbcPolicy,
+ policy_name: &str,
robot: &RobotConfig,
comm: &CommConfig,
runtime_command: &ParsedRuntimeCommand,
@@ -1527,17 +1914,23 @@ fn run_control_loop_inner(
report_frames: &mut Vec,
report_max_frames: Option,
#[cfg(feature = "vis")] visualizer: &mut Option,
-) -> Result<(usize, usize, Duration), String> {
+) -> Result<(usize, usize, Duration, Option), String> {
let period = Duration::from_secs_f64(1.0 / f64::from(comm.frequency_hz));
+ let loop_started_at = Instant::now();
let mut ticks: usize = 0;
let mut dropped_frames: usize = 0;
let mut inference_total = Duration::ZERO;
let mut policy_engaged = !startup_config.enabled();
+ let mut pending_policy_engage = false;
let mut init_pose_start: Option<(Instant, Vec)> = None;
+ let mut velocity_monitor = VelocityTrackingMonitor::new(runtime_command);
if startup_config.require_engage {
- println!("startup init pose active: press ] to engage policy after the robot settles");
+ println!(
+ "startup init pose active: settle window {:.1}s; press ] any time to queue policy engagement; WASD/QE commands are held until engagement",
+ startup_config.init_pose_secs
+ );
}
while running.load(Ordering::SeqCst) {
@@ -1557,14 +1950,32 @@ fn run_control_loop_inner(
TeleopControlRequest::Reset => {
policy.reset();
policy_engaged = false;
+ pending_policy_engage = false;
init_pose_start = None;
println!("startup init pose reset: press ] to re-engage policy");
}
TeleopControlRequest::Engage => {
- policy.reset();
- policy_engaged = true;
- init_pose_start = None;
- println!("policy engaged");
+ match apply_policy_engage_request(
+ startup_config,
+ &mut policy_engaged,
+ &mut pending_policy_engage,
+ &mut init_pose_start,
+ ) {
+ PolicyEngageRequestOutcome::Immediate => {
+ policy.reset();
+ println!("policy engaged");
+ }
+ PolicyEngageRequestOutcome::QueuedUntilSettled => {
+ let elapsed = init_pose_start
+ .as_ref()
+ .map(|(start_time, _)| start_time.elapsed());
+ let remaining_secs =
+ startup_settle_remaining_secs(startup_config, elapsed);
+ println!(
+ "policy engagement queued; waiting for startup settle (~{remaining_secs:.1}s remaining)"
+ );
+ }
+ }
}
TeleopControlRequest::ToggleElasticBand => {
match transport.toggle_elastic_band() {
@@ -1599,7 +2010,9 @@ fn run_control_loop_inner(
};
let base_pose = transport.report_base_pose();
let sim_time_secs = transport.report_sim_time_secs(tick_index, comm.frequency_hz);
+ let wall_time_secs = loop_started_at.elapsed().as_secs_f64();
let replay_state = transport.report_mujoco_state();
+ let elastic_band_enabled = transport.report_elastic_band_enabled();
let mut captured_tick: Option = None;
run_control_tick(transport, command.clone(), |obs| {
@@ -1610,9 +2023,14 @@ fn run_control_loop_inner(
let (start_time, start_positions) = init_pose_start
.get_or_insert_with(|| (obs.timestamp, obs.joint_positions.clone()));
let elapsed = obs.timestamp.saturating_duration_since(*start_time);
- if !startup_config.require_engage
- && elapsed.as_secs_f32() >= startup_config.init_pose_secs
- {
+ let startup_settled = elapsed.as_secs_f32() >= startup_config.init_pose_secs;
+ if pending_policy_engage_is_ready(startup_config, pending_policy_engage, elapsed) {
+ policy.reset();
+ policy_engaged = true;
+ pending_policy_engage = false;
+ println!("policy engaged");
+ policy.predict(&obs)
+ } else if !startup_config.require_engage && startup_settled {
policy_engaged = true;
policy.predict(&obs)
} else {
@@ -1633,9 +2051,16 @@ fn run_control_loop_inner(
captured_tick = Some(ReplayFrame {
tick: tick_index,
sim_time_secs,
+ wall_time_secs,
command_data: tick_command_data.clone(),
phase_name: runtime_command.phase_name_for_tick(tick_index, comm.frequency_hz),
base_pose,
+ elastic_band_enabled,
+ planner_status: planner_status_for_command(
+ policy_name,
+ runtime_command,
+ &tick_command_data,
+ ),
actual_positions: obs.joint_positions.clone(),
actual_velocities: obs.joint_velocities.clone(),
target_positions: output
@@ -1654,6 +2079,10 @@ fn run_control_loop_inner(
let cycle_elapsed = cycle_start.elapsed();
if let Some(frame) = captured_tick {
+ if let Some(snapshot) = velocity_monitor.observe(&frame) {
+ print_velocity_monitor(&snapshot);
+ }
+
#[cfg(feature = "vis")]
if let Some(vis) = visualizer.as_mut() {
vis.advance_frame();
@@ -1702,7 +2131,12 @@ fn run_control_loop_inner(
}
}
- Ok((ticks, dropped_frames, inference_total))
+ Ok((
+ ticks,
+ dropped_frames,
+ inference_total,
+ velocity_monitor.finish(),
+ ))
}
#[allow(clippy::too_many_lines, clippy::too_many_arguments)]
@@ -1751,15 +2185,16 @@ fn run_control_loop(
// Transport priority: hardware → sim (if feature enabled) → synthetic.
#[cfg(feature = "sim")]
- let (ticks, dropped_frames, inference_total, sent_count, replay_transport) = {
+ let (ticks, dropped_frames, inference_total, velocity_tracking, sent_count, replay_transport) = {
if let Some(hw_cfg) = hardware {
let mut transport =
UnitreeG1Transport::connect(hw_cfg, robot.clone(), comm.frequency_hz)
.map_err(|e| format!("hardware transport connect failed: {e}"))?;
println!("unitree g1 hardware transport active");
- let (ticks, dropped, inf) = run_control_loop_inner(
+ let (ticks, dropped, inf, velocity_tracking) = run_control_loop_inner(
&mut transport,
policy,
+ policy_name,
robot,
comm,
runtime_command,
@@ -1777,6 +2212,7 @@ fn run_control_loop(
ticks,
dropped,
inf,
+ velocity_tracking,
ticks,
ReplayTransportMetadata::hardware("unitree_g1"),
)
@@ -1792,9 +2228,10 @@ fn run_control_loop(
transport.model_variant(),
transport.uses_meshless_public_fallback()
);
- let (ticks, dropped, inf) = run_control_loop_inner(
+ let (ticks, dropped, inf, velocity_tracking) = run_control_loop_inner(
&mut transport,
policy,
+ policy_name,
robot,
comm,
runtime_command,
@@ -1812,6 +2249,7 @@ fn run_control_loop(
ticks,
dropped,
inf,
+ velocity_tracking,
ticks,
ReplayTransportMetadata {
kind: "mujoco".to_owned(),
@@ -1826,9 +2264,10 @@ fn run_control_loop(
)
} else {
let mut transport = SyntheticTransport::new(robot.default_pose.clone());
- let (ticks, dropped, inf) = run_control_loop_inner(
+ let (ticks, dropped, inf, velocity_tracking) = run_control_loop_inner(
&mut transport,
policy,
+ policy_name,
robot,
comm,
runtime_command,
@@ -1846,6 +2285,7 @@ fn run_control_loop(
ticks,
dropped,
inf,
+ velocity_tracking,
transport.sent_commands(),
ReplayTransportMetadata::synthetic(),
)
@@ -1853,15 +2293,16 @@ fn run_control_loop(
};
#[cfg(not(feature = "sim"))]
- let (ticks, dropped_frames, inference_total, sent_count, replay_transport) = {
+ let (ticks, dropped_frames, inference_total, velocity_tracking, sent_count, replay_transport) = {
if let Some(hw_cfg) = hardware {
let mut transport =
UnitreeG1Transport::connect(hw_cfg, robot.clone(), comm.frequency_hz)
.map_err(|e| format!("hardware transport connect failed: {e}"))?;
println!("unitree g1 hardware transport active");
- let (ticks, dropped, inf) = run_control_loop_inner(
+ let (ticks, dropped, inf, velocity_tracking) = run_control_loop_inner(
&mut transport,
policy,
+ policy_name,
robot,
comm,
runtime_command,
@@ -1879,14 +2320,16 @@ fn run_control_loop(
ticks,
dropped,
inf,
+ velocity_tracking,
ticks,
ReplayTransportMetadata::hardware("unitree_g1"),
)
} else {
let mut transport = SyntheticTransport::new(robot.default_pose.clone());
- let (ticks, dropped, inf) = run_control_loop_inner(
+ let (ticks, dropped, inf, velocity_tracking) = run_control_loop_inner(
&mut transport,
policy,
+ policy_name,
robot,
comm,
runtime_command,
@@ -1904,6 +2347,7 @@ fn run_control_loop(
ticks,
dropped,
inf,
+ velocity_tracking,
transport.sent_commands(),
ReplayTransportMetadata::synthetic(),
)
@@ -1923,18 +2367,17 @@ fn run_control_loop(
let achieved_frequency_hz = (ticks as f64) / run_time_secs;
#[allow(clippy::cast_precision_loss)]
let average_inference_ms = (inference_total.as_secs_f64() * 1_000.0) / (ticks as f64);
- let velocity_tracking =
- compute_velocity_tracking_metrics(&replay_frames, runtime_command, comm.frequency_hz);
-
println!(
"runtime metrics: ticks={ticks}, sent_commands={sent_count}, avg_inference_ms={average_inference_ms:.3}, achieved_hz={achieved_frequency_hz:.2}, dropped_frames={dropped_frames}",
);
if let Some(tracking) = &velocity_tracking {
println!(
- "velocity tracking: vx_rmse={:.3} m/s, vy_rmse={:.3} m/s, yaw_rmse={:.3} rad/s, forward_distance={:.3} m, heading_change={:.1} deg",
+ "velocity tracking: vx_rmse={:.3} m/s, wall_vx_rmse={:.3} m/s, actual_vx_mean={:.3} m/s, actual_vx_wall_mean={:.3} m/s, real_time_factor={:.2}x, forward_distance={:.3} m, heading_change={:.1} deg",
tracking.vx_rmse_mps,
- tracking.vy_rmse_mps,
- tracking.yaw_rate_rmse_rad_s,
+ tracking.wall_vx_rmse_mps,
+ tracking.actual_vx_mean_mps,
+ tracking.actual_wall_vx_mean_mps,
+ tracking.real_time_factor,
tracking.forward_distance_m,
tracking.heading_change_deg,
);
@@ -2187,9 +2630,12 @@ mod tests {
ReplayFrame {
tick,
sim_time_secs: f64::from(tick_u32) * 0.02,
+ wall_time_secs: f64::from(tick_u32) * 0.02,
command_data,
phase_name: None,
base_pose,
+ elastic_band_enabled: None,
+ planner_status: None,
actual_positions: vec![],
actual_velocities: vec![],
target_positions: vec![],
@@ -2344,6 +2790,15 @@ mod tests {
sim.model_path,
PathBuf::from("assets/robots/groot_g1_gear_sonic/scene_29dof.xml")
);
+ assert_eq!(
+ sim.gain_profile.as_str(),
+ "default_pd",
+ "keyboard demo must use the official low-command kp/kd gains"
+ );
+ assert!(
+ !sim.enforce_target_velocity_limits,
+ "official MuJoCo bridge sends q-targets directly without the hardware target slew limiter"
+ );
let band = sim
.elastic_band
@@ -2351,7 +2806,7 @@ mod tests {
assert!(band.enabled);
assert_eq!(band.body_name, "pelvis");
assert_eq!(band.anchor, [0.0, 0.0, 1.0]);
- assert!(band.anchor_from_initial_pose);
+ assert!(!band.anchor_from_initial_pose);
assert_eq!(band.length, 0.0);
assert_eq!(band.kp_pos, 10_000.0);
assert_eq!(band.kd_pos, 1_000.0);
@@ -2422,6 +2877,81 @@ mod tests {
assert!(!outcome.stop_after_tick);
}
+ #[test]
+ fn engage_request_queues_until_startup_settles_when_required() {
+ let mut policy_engaged = false;
+ let mut pending_policy_engage = false;
+ let mut init_pose_start = None;
+ let startup_config = RuntimeStartupConfig {
+ init_pose_secs: 3.0,
+ require_engage: true,
+ };
+
+ let outcome = apply_policy_engage_request(
+ startup_config,
+ &mut policy_engaged,
+ &mut pending_policy_engage,
+ &mut init_pose_start,
+ );
+
+ assert_eq!(outcome, PolicyEngageRequestOutcome::QueuedUntilSettled);
+ assert!(!policy_engaged);
+ assert!(pending_policy_engage);
+ assert!(!pending_policy_engage_is_ready(
+ startup_config,
+ pending_policy_engage,
+ Duration::from_millis(2_900)
+ ));
+ assert!(pending_policy_engage_is_ready(
+ startup_config,
+ pending_policy_engage,
+ Duration::from_secs(3)
+ ));
+ }
+
+ #[test]
+ fn startup_settle_remaining_reports_configured_wait() {
+ let startup_config = RuntimeStartupConfig {
+ init_pose_secs: 3.0,
+ require_engage: true,
+ };
+
+ assert!((startup_settle_remaining_secs(startup_config, None) - 3.0).abs() < 1e-6);
+ assert!(
+ (startup_settle_remaining_secs(startup_config, Some(Duration::from_millis(1_250)))
+ - 1.75)
+ .abs()
+ < 1e-6
+ );
+ assert!(
+ startup_settle_remaining_secs(startup_config, Some(Duration::from_secs(5))).abs()
+ < 1e-6
+ );
+ }
+
+ #[test]
+ fn engage_request_is_immediate_without_required_startup_gate() {
+ let mut policy_engaged = false;
+ let mut pending_policy_engage = true;
+ let mut init_pose_start = Some((Instant::now(), vec![1.0, 2.0]));
+ let startup_config = RuntimeStartupConfig {
+ init_pose_secs: 3.0,
+ require_engage: false,
+ };
+
+ let outcome = apply_policy_engage_request(
+ startup_config,
+ &mut policy_engaged,
+ &mut pending_policy_engage,
+ &mut init_pose_start,
+ );
+
+ assert_eq!(outcome, PolicyEngageRequestOutcome::Immediate);
+ assert!(policy_engaged);
+ assert!(!pending_policy_engage);
+ assert!(init_pose_start.is_none());
+ }
+
#[test]
fn startup_init_pose_interpolates_before_policy_runs() {
let robot = startup_test_robot();
@@ -2450,6 +2980,7 @@ mod tests {
let metrics = run_control_loop_inner(
&mut transport,
&policy,
+ "test_policy",
&robot,
&comm,
&runtime_command,
@@ -2505,6 +3036,7 @@ mod tests {
let metrics = run_control_loop_inner(
&mut transport,
&policy,
+ "test_policy",
&robot,
&comm,
&runtime_command,
@@ -3176,9 +3708,12 @@ standing_placeholder_tracking = true
},
frames: vec![ReportFrame {
tick: 0,
+ wall_time_secs: 0.0,
command_data: vec![0.2, 0.0, 0.1],
phase_name: None,
base_pose: None,
+ elastic_band_enabled: None,
+ planner_status: None,
actual_positions: vec![0.1],
actual_velocities: vec![0.0],
target_positions: vec![0.1],
@@ -3303,6 +3838,132 @@ standing_placeholder_tracking = true
assert_f64_approx_eq(metrics.heading_change_deg, 0.0);
}
+ #[test]
+ fn compute_velocity_tracking_metrics_uses_recorded_sim_time() {
+ let runtime_command = ParsedRuntimeCommand::Velocity([1.0, 0.0, 0.0]);
+ let mut frames = vec![
+ replay_frame(
+ 0,
+ vec![1.0, 0.0, 0.0],
+ Some(ReportBasePose {
+ position_world: [0.0, 0.0, 0.0],
+ rotation_xyzw: [0.0, 0.0, 0.0, 1.0],
+ }),
+ ),
+ replay_frame(
+ 1,
+ vec![1.0, 0.0, 0.0],
+ Some(ReportBasePose {
+ position_world: [0.01, 0.0, 0.0],
+ rotation_xyzw: [0.0, 0.0, 0.0, 1.0],
+ }),
+ ),
+ replay_frame(
+ 2,
+ vec![1.0, 0.0, 0.0],
+ Some(ReportBasePose {
+ position_world: [0.02, 0.0, 0.0],
+ rotation_xyzw: [0.0, 0.0, 0.0, 1.0],
+ }),
+ ),
+ ];
+ frames[0].sim_time_secs = 0.0;
+ frames[1].sim_time_secs = 0.01;
+ frames[2].sim_time_secs = 0.02;
+
+ let metrics = compute_velocity_tracking_metrics(&frames, &runtime_command, 50)
+ .expect("forward-motion metrics should be computed");
+ assert_eq!(metrics.sample_count, 2);
+ assert_f64_approx_eq(metrics.vx_rmse_mps, 0.0);
+ assert_f64_approx_eq(metrics.forward_distance_m, 0.02);
+ }
+
+ #[test]
+ fn gear_sonic_planner_status_reports_mode_bands() {
+ let idle = gear_sonic_planner_status_from_command(&[0.0, 0.0, 0.0])
+ .expect("idle velocity should have planner status");
+ assert_eq!(idle.mode_id, GEAR_SONIC_PLANNER_MODE_IDLE);
+ assert_eq!(idle.mode_name, "idle");
+ assert_eq!(idle.target_vel_mps, None);
+
+ let slow_walk = gear_sonic_planner_status_from_command(&[0.6, 0.0, 0.0])
+ .expect("slow walk velocity should have planner status");
+ assert_eq!(slow_walk.mode_id, GEAR_SONIC_PLANNER_MODE_SLOW_WALK);
+ assert_eq!(slow_walk.mode_name, "slow_walk");
+ assert_eq!(slow_walk.target_vel_mps, Some(0.6));
+
+ let walk = gear_sonic_planner_status_from_command(&[1.0, 0.0, 0.0])
+ .expect("walk velocity should have planner status");
+ assert_eq!(walk.mode_id, GEAR_SONIC_PLANNER_MODE_WALK);
+ assert_eq!(walk.mode_name, "walk");
+ assert_eq!(walk.target_vel_mps, Some(1.0));
+
+ let run = gear_sonic_planner_status_from_command(&[1.6, 0.0, 0.0])
+ .expect("run velocity should have planner status");
+ assert_eq!(run.mode_id, GEAR_SONIC_PLANNER_MODE_RUN);
+ assert_eq!(run.mode_name, "run");
+ assert_eq!(run.target_vel_mps, Some(1.6));
+ }
+
+ #[test]
+ fn planner_status_is_only_reported_for_gear_sonic_velocity_commands() {
+ let velocity_command = ParsedRuntimeCommand::Velocity([1.0, 0.0, 0.0]);
+ assert!(
+ planner_status_for_command("gear_sonic", &velocity_command, &[1.0, 0.0, 0.0]).is_some()
+ );
+ assert!(
+ planner_status_for_command("decoupled_wbc", &velocity_command, &[1.0, 0.0, 0.0])
+ .is_none()
+ );
+
+ let motion_tokens = ParsedRuntimeCommand::MotionTokens(vec![0.0]);
+ assert!(
+ planner_status_for_command("gear_sonic", &motion_tokens, &[1.0, 0.0, 0.0]).is_none()
+ );
+ }
+
+ #[test]
+ fn velocity_monitor_collects_without_report_capture() {
+ let runtime_command = ParsedRuntimeCommand::Velocity([1.0, 0.0, 0.0]);
+ let mut monitor = VelocityTrackingMonitor::new(&runtime_command);
+ let mut previous = replay_frame(
+ 0,
+ vec![1.0, 0.0, 0.0],
+ Some(ReportBasePose {
+ position_world: [0.0, 0.0, 0.0],
+ rotation_xyzw: [0.0, 0.0, 0.0, 1.0],
+ }),
+ );
+ previous.elastic_band_enabled = Some(true);
+ let mut current = replay_frame(
+ 1,
+ vec![1.0, 0.0, 0.0],
+ Some(ReportBasePose {
+ position_world: [0.02, 0.0, 0.0],
+ rotation_xyzw: [0.0, 0.0, 0.0, 1.0],
+ }),
+ );
+ current.sim_time_secs = 0.02;
+ current.wall_time_secs = 0.04;
+ current.elastic_band_enabled = Some(true);
+
+ assert!(monitor.observe(&previous).is_none());
+ let snapshot = monitor
+ .observe(¤t)
+ .expect("support-band hint should force a live monitor sample");
+ assert!(snapshot.support_band_hint);
+
+ let metrics = monitor
+ .finish()
+ .expect("monitor should compute metrics without replay capture");
+ assert_eq!(metrics.sample_count, 1);
+ assert_f64_approx_eq(metrics.vx_rmse_mps, 0.0);
+ assert_f64_approx_eq(metrics.wall_vx_rmse_mps, 0.5);
+ assert_f64_approx_eq(metrics.real_time_factor, 0.5);
+ assert_f64_approx_eq(metrics.actual_vx_mean_mps, 1.0);
+ assert_f64_approx_eq(metrics.actual_wall_vx_mean_mps, 0.5);
+ }
+
#[test]
fn compute_velocity_tracking_metrics_matches_perfect_turn_rate() {
let runtime_command = ParsedRuntimeCommand::Velocity([0.0, 0.0, 1.0]);
diff --git a/crates/robowbc-ort/src/decoupled.rs b/crates/robowbc-ort/src/decoupled.rs
index 01e073a..ecb4911 100644
--- a/crates/robowbc-ort/src/decoupled.rs
+++ b/crates/robowbc-ort/src/decoupled.rs
@@ -933,11 +933,11 @@ mod tests {
///
/// To run once weights are available:
/// ```bash
- /// bash scripts/download_decoupled_wbc_models.sh
+ /// bash scripts/models/download_decoupled_wbc_models.sh
/// cargo test -p robowbc-ort -- --ignored decoupled_wbc_real_model_inference
/// ```
#[test]
- #[ignore = "requires real GR00T WholeBodyControl ONNX weights; run scripts/download_decoupled_wbc_models.sh first"]
+ #[ignore = "requires real GR00T WholeBodyControl ONNX weights; run scripts/models/download_decoupled_wbc_models.sh first"]
fn decoupled_wbc_real_model_inference() {
let walk_model = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../models/decoupled-wbc/GR00T-WholeBodyControl-Walk.onnx");
diff --git a/crates/robowbc-ort/src/lib.rs b/crates/robowbc-ort/src/lib.rs
index e82a833..ea994b9 100644
--- a/crates/robowbc-ort/src/lib.rs
+++ b/crates/robowbc-ort/src/lib.rs
@@ -786,10 +786,19 @@ const GEAR_SONIC_DEFAULT_MODE_IDLE: i64 = 0;
const GEAR_SONIC_DEFAULT_MODE_SLOW_WALK: i64 = 1;
const GEAR_SONIC_DEFAULT_MODE_WALK: i64 = 2;
const GEAR_SONIC_DEFAULT_MODE_RUN: i64 = 3;
+const GEAR_SONIC_DEFAULT_RANDOM_SEED: i64 = 1234;
const GEAR_SONIC_CONTROL_DT_SECS: f32 = 1.0 / 50.0;
+const GEAR_SONIC_MIN_MOVING_SPEED_MPS: f32 = 0.2;
+const GEAR_SONIC_WALK_COMMAND_MIN_MPS: f32 = 0.8;
+const GEAR_SONIC_RUN_COMMAND_MIN_MPS: f32 = 1.5;
+const GEAR_SONIC_RUN_COMMAND_MAX_MPS: f32 = 3.0;
const GEAR_SONIC_PLANNER_THREAD_INTERVAL_TICKS: usize = 5;
const GEAR_SONIC_PLANNER_LOOK_AHEAD_STEPS: usize = 2;
const GEAR_SONIC_PLANNER_BLEND_FRAMES: usize = 8;
+const GEAR_SONIC_IDLE_READAPT_BLEND_WEIGHT: f32 = 0.02;
+const GEAR_SONIC_IDLE_READAPT_ADAPT_TRIGGER_RAD: f32 = 0.10;
+const GEAR_SONIC_IDLE_READAPT_ADAPT_STOP_RAD: f32 = 0.05;
+const GEAR_SONIC_IDLE_READAPT_RECOVER_TRIGGER_RAD: f32 = 0.045;
const GEAR_SONIC_ENCODER_DIM: usize = 64;
const GEAR_SONIC_ENCODER_OBS_DICT_DIM: usize = 1762;
const GEAR_SONIC_DECODER_OBS_DICT_DIM: usize = 994;
@@ -801,18 +810,31 @@ const GEAR_SONIC_ENCODER_MOTION_JOINT_POSITIONS_OFFSET: usize = 4;
const GEAR_SONIC_ENCODER_MOTION_JOINT_VELOCITIES_OFFSET: usize = 294;
const GEAR_SONIC_ENCODER_MOTION_ANCHOR_ORIENTATION_OFFSET: usize = 601;
-/// `IsaacLab` to `MuJoCo` joint index remapping for G1 29-DOF.
+/// Effective `MuJoCo` to `IsaacLab` joint index remapping for G1 29-DOF.
///
-/// Despite the upstream name, this table is used as the effective
-/// `MuJoCo -> IsaacLab` remap throughout the published GEAR-SONIC runtime.
-/// Planner outputs and live observations arrive in `MuJoCo` order, and
-/// decoder outputs are consumed by looking up the `IsaacLab` index for each
-/// `MuJoCo` joint.
-const GEAR_SONIC_ISAACLAB_TO_MUJOCO: [usize; 29] = [
+/// Upstream names this array `isaaclab_to_mujoco`, but its use in
+/// `CreatePolicyCommand()` is `raw_actions[isaaclab_to_mujoco[mujoco_idx]]`.
+/// It therefore maps a `MuJoCo`/hardware joint index to the policy's
+/// `IsaacLab` action/observation index.
+const GEAR_SONIC_MUJOCO_TO_ISAACLAB: [usize; 29] = [
0, 3, 6, 9, 13, 17, 1, 4, 7, 10, 14, 18, 2, 5, 8, 11, 15, 19, 21, 23, 25, 27, 12, 16, 20, 22,
24, 26, 28,
];
+/// Effective `IsaacLab` to `MuJoCo` joint index remapping for G1 29-DOF.
+///
+/// Upstream names this array `mujoco_to_isaaclab` and uses it when converting
+/// planner `mujoco_qpos` output into the policy-order motion sequence.
+const GEAR_SONIC_ISAACLAB_TO_MUJOCO: [usize; 29] = [
+ 0, 6, 12, 1, 7, 13, 2, 8, 14, 3, 9, 15, 22, 4, 10, 16, 23, 5, 11, 17, 24, 18, 25, 19, 26, 20,
+ 27, 21, 28,
+];
+
+/// Lower-body IsaacLab-order joint indexes used by upstream idle readaptation.
+const GEAR_SONIC_IDLE_READAPT_LOWER_BODY_ISAACLAB_INDICES: [usize; 12] =
+ [0, 1, 3, 4, 6, 7, 9, 10, 13, 14, 17, 18];
+const GEAR_SONIC_IDLE_READAPT_LOWER_BODY_COUNT_F32: f32 = 12.0;
+
/// Per-joint action scale for G1 in `MuJoCo` order.
///
/// Computed from the GEAR-SONIC C++ deployment code as
@@ -863,6 +885,16 @@ struct GearSonicPlannerState {
init_ref_root_quat_wxyz: Option<[f32; 4]>,
last_command: Option,
pending_replan: Option,
+ idle_readapt_original_targets_isaaclab: [f32; 29],
+ idle_readapt_stored: bool,
+ idle_readapt_state: GearSonicIdleReadaptState,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum GearSonicIdleReadaptState {
+ Idle,
+ Adapting,
+ Recovering,
}
#[derive(Debug, Clone, Copy, PartialEq)]
@@ -901,6 +933,9 @@ impl GearSonicPlannerState {
init_ref_root_quat_wxyz: None,
last_command: None,
pending_replan: None,
+ idle_readapt_original_targets_isaaclab: [0.0; 29],
+ idle_readapt_stored: false,
+ idle_readapt_state: GearSonicIdleReadaptState::Idle,
}
}
}
@@ -1141,7 +1176,7 @@ fn load_csv_matrix_f32(path: &Path) -> CoreResult<(usize, usize, Vec)> {
if header.starts_with("version https://git-lfs.github.com/spec/v1") {
return Err(robowbc_core::WbcError::InferenceFailed(format!(
- "reference motion file {} is still a Git LFS pointer; run scripts/download_gear_sonic_reference_motions.sh to materialize the official clip payloads",
+ "reference motion file {} is still a Git LFS pointer; run scripts/models/download_gear_sonic_reference_motions.sh to materialize the official clip payloads",
path.display()
)));
}
@@ -1521,7 +1556,7 @@ impl GearSonicPolicy {
wrapped
}
- fn bin_planner_angle_to_8_directions(angle: f32) -> (f32, f32) {
+ fn bin_planner_angle_to_8_directions(angle: f32) -> f32 {
const BIN_SIZE: f32 = std::f32::consts::FRAC_PI_4;
const HALF_BIN_SIZE: f32 = BIN_SIZE * 0.5;
@@ -1546,15 +1581,7 @@ impl GearSonicPolicy {
4
};
- let slow_walk_speed = match bin_index {
- -1..=1 => 0.3,
- -2 | 2 => 0.35,
- -3 | 3 => 0.25,
- -4 | 4 => 0.2,
- _ => unreachable!("planner direction bin should stay within [-4, 4]"),
- };
-
- let binned_angle = match bin_index {
+ match bin_index {
-4 => -4.0 * BIN_SIZE,
-3 => -3.0 * BIN_SIZE,
-2 => -2.0 * BIN_SIZE,
@@ -1565,9 +1592,7 @@ impl GearSonicPolicy {
3 => 3.0 * BIN_SIZE,
4 => 4.0 * BIN_SIZE,
_ => unreachable!("planner direction bin should stay within [-4, 4]"),
- };
-
- (binned_angle, slow_walk_speed)
+ }
}
fn vec3_distance(a: [f32; 3], b: [f32; 3]) -> f32 {
@@ -1605,14 +1630,17 @@ impl GearSonicPolicy {
let local_movement_angle = twist.linear[1].atan2(twist.linear[0]);
let movement_angle =
Self::wrap_angle_rad(planner_state.facing_yaw_rad + local_movement_angle);
- let (movement_angle, slow_walk_speed) =
- Self::bin_planner_angle_to_8_directions(movement_angle);
- let (mode, target_vel) = if cmd_norm < 0.8 {
- (GEAR_SONIC_DEFAULT_MODE_SLOW_WALK, slow_walk_speed)
- } else if cmd_norm < 2.5 {
- (GEAR_SONIC_DEFAULT_MODE_WALK, -1.0)
+ let movement_angle = Self::bin_planner_angle_to_8_directions(movement_angle);
+ let target_vel = cmd_norm.clamp(
+ GEAR_SONIC_MIN_MOVING_SPEED_MPS,
+ GEAR_SONIC_RUN_COMMAND_MAX_MPS,
+ );
+ let mode = if target_vel < GEAR_SONIC_WALK_COMMAND_MIN_MPS {
+ GEAR_SONIC_DEFAULT_MODE_SLOW_WALK
+ } else if target_vel < GEAR_SONIC_RUN_COMMAND_MIN_MPS {
+ GEAR_SONIC_DEFAULT_MODE_WALK
} else {
- (GEAR_SONIC_DEFAULT_MODE_RUN, -1.0)
+ GEAR_SONIC_DEFAULT_MODE_RUN
};
GearSonicPlannerCommand {
mode,
@@ -1635,19 +1663,19 @@ impl GearSonicPolicy {
fn planner_joint_positions_isaaclab(frame: &[f32]) -> Vec {
let mut positions = vec![0.0_f32; GEAR_SONIC_ISAACLAB_TO_MUJOCO.len()];
- for (mujoco_idx, &isaaclab_idx) in GEAR_SONIC_ISAACLAB_TO_MUJOCO.iter().enumerate() {
+ for (isaaclab_idx, &mujoco_idx) in GEAR_SONIC_ISAACLAB_TO_MUJOCO.iter().enumerate() {
positions[isaaclab_idx] = frame[GEAR_SONIC_PLANNER_JOINT_OFFSET + mujoco_idx];
}
positions
}
fn joint_positions_mujoco_to_isaaclab(joint_positions: &[f32]) -> Vec {
- if joint_positions.len() != GEAR_SONIC_ISAACLAB_TO_MUJOCO.len() {
+ if joint_positions.len() != GEAR_SONIC_MUJOCO_TO_ISAACLAB.len() {
return joint_positions.to_vec();
}
let mut positions = vec![0.0_f32; joint_positions.len()];
- for (mujoco_idx, &isaaclab_idx) in GEAR_SONIC_ISAACLAB_TO_MUJOCO.iter().enumerate() {
+ for (mujoco_idx, &isaaclab_idx) in GEAR_SONIC_MUJOCO_TO_ISAACLAB.iter().enumerate() {
positions[isaaclab_idx] = joint_positions[mujoco_idx];
}
positions
@@ -1657,8 +1685,8 @@ impl GearSonicPolicy {
robot: &RobotConfig,
obs: &Observation,
) -> Vec {
- if obs.joint_positions.len() != GEAR_SONIC_ISAACLAB_TO_MUJOCO.len()
- || robot.default_pose.len() != GEAR_SONIC_ISAACLAB_TO_MUJOCO.len()
+ if obs.joint_positions.len() != GEAR_SONIC_MUJOCO_TO_ISAACLAB.len()
+ || robot.default_pose.len() != GEAR_SONIC_MUJOCO_TO_ISAACLAB.len()
{
return obs
.joint_positions
@@ -1669,7 +1697,7 @@ impl GearSonicPolicy {
}
let mut positions = vec![0.0_f32; obs.joint_positions.len()];
- for (mujoco_idx, &isaaclab_idx) in GEAR_SONIC_ISAACLAB_TO_MUJOCO.iter().enumerate() {
+ for (mujoco_idx, &isaaclab_idx) in GEAR_SONIC_MUJOCO_TO_ISAACLAB.iter().enumerate() {
positions[isaaclab_idx] =
obs.joint_positions[mujoco_idx] - robot.default_pose[mujoco_idx];
}
@@ -1677,12 +1705,12 @@ impl GearSonicPolicy {
}
fn observation_joint_velocities_isaaclab(obs: &Observation) -> Vec {
- if obs.joint_velocities.len() != GEAR_SONIC_ISAACLAB_TO_MUJOCO.len() {
+ if obs.joint_velocities.len() != GEAR_SONIC_MUJOCO_TO_ISAACLAB.len() {
return obs.joint_velocities.clone();
}
let mut velocities = vec![0.0_f32; obs.joint_velocities.len()];
- for (mujoco_idx, &isaaclab_idx) in GEAR_SONIC_ISAACLAB_TO_MUJOCO.iter().enumerate() {
+ for (mujoco_idx, &isaaclab_idx) in GEAR_SONIC_MUJOCO_TO_ISAACLAB.iter().enumerate() {
velocities[isaaclab_idx] = obs.joint_velocities[mujoco_idx];
}
velocities
@@ -1943,6 +1971,16 @@ impl GearSonicPolicy {
}
}
+ fn planner_periodic_replan_due(
+ command: GearSonicPlannerCommand,
+ steps_since_plan: usize,
+ ) -> bool {
+ if command.mode == GEAR_SONIC_DEFAULT_MODE_IDLE {
+ return false;
+ }
+ steps_since_plan >= Self::planner_replan_interval_ticks(command)
+ }
+
fn build_velocity_encoder_obs_dict(
obs: &Observation,
planner_state: &GearSonicPlannerState,
@@ -2027,6 +2065,106 @@ impl GearSonicPolicy {
.min(planner_state.motion_qpos_50hz.len() - 1);
}
+ fn planner_qpos_isaaclab_joint(frame: &[f32], isaaclab_idx: usize) -> f32 {
+ let mujoco_idx = GEAR_SONIC_ISAACLAB_TO_MUJOCO[isaaclab_idx];
+ frame[GEAR_SONIC_PLANNER_JOINT_OFFSET + mujoco_idx]
+ }
+
+ fn set_planner_qpos_isaaclab_joint(frame: &mut [f32], isaaclab_idx: usize, value: f32) {
+ let mujoco_idx = GEAR_SONIC_ISAACLAB_TO_MUJOCO[isaaclab_idx];
+ frame[GEAR_SONIC_PLANNER_JOINT_OFFSET + mujoco_idx] = value;
+ }
+
+ fn maybe_apply_idle_readaptation(
+ planner_state: &mut GearSonicPlannerState,
+ obs: &Observation,
+ command: GearSonicPlannerCommand,
+ ) {
+ if command.mode != GEAR_SONIC_DEFAULT_MODE_IDLE
+ || planner_state.motion_qpos_50hz.is_empty()
+ || obs.joint_positions.len() != GEAR_SONIC_ISAACLAB_TO_MUJOCO.len()
+ {
+ return;
+ }
+
+ let last_frame_idx = planner_state.motion_qpos_50hz.len() - 1;
+ if planner_state.current_motion_frame < last_frame_idx {
+ return;
+ }
+
+ if !planner_state.idle_readapt_stored {
+ let frame = &planner_state.motion_qpos_50hz[last_frame_idx];
+ for &isaaclab_idx in &GEAR_SONIC_IDLE_READAPT_LOWER_BODY_ISAACLAB_INDICES {
+ planner_state.idle_readapt_original_targets_isaaclab[isaaclab_idx] =
+ Self::planner_qpos_isaaclab_joint(frame, isaaclab_idx);
+ }
+ planner_state.idle_readapt_stored = true;
+ planner_state.idle_readapt_state = GearSonicIdleReadaptState::Idle;
+ }
+
+ let frame = &planner_state.motion_qpos_50hz[last_frame_idx];
+ let mut total_error = 0.0_f32;
+ for &isaaclab_idx in &GEAR_SONIC_IDLE_READAPT_LOWER_BODY_ISAACLAB_INDICES {
+ let mujoco_idx = GEAR_SONIC_ISAACLAB_TO_MUJOCO[isaaclab_idx];
+ total_error += (Self::planner_qpos_isaaclab_joint(frame, isaaclab_idx)
+ - obs.joint_positions[mujoco_idx])
+ .abs();
+ }
+ let avg_error = total_error / GEAR_SONIC_IDLE_READAPT_LOWER_BODY_COUNT_F32;
+
+ match planner_state.idle_readapt_state {
+ GearSonicIdleReadaptState::Idle => {
+ if avg_error > GEAR_SONIC_IDLE_READAPT_ADAPT_TRIGGER_RAD {
+ planner_state.idle_readapt_state = GearSonicIdleReadaptState::Adapting;
+ } else if avg_error < GEAR_SONIC_IDLE_READAPT_RECOVER_TRIGGER_RAD {
+ planner_state.idle_readapt_state = GearSonicIdleReadaptState::Recovering;
+ }
+ }
+ GearSonicIdleReadaptState::Adapting => {
+ if avg_error < GEAR_SONIC_IDLE_READAPT_ADAPT_STOP_RAD {
+ planner_state.idle_readapt_state = GearSonicIdleReadaptState::Idle;
+ }
+ }
+ GearSonicIdleReadaptState::Recovering => {
+ if avg_error > GEAR_SONIC_IDLE_READAPT_ADAPT_TRIGGER_RAD {
+ planner_state.idle_readapt_state = GearSonicIdleReadaptState::Adapting;
+ }
+ }
+ }
+
+ match planner_state.idle_readapt_state {
+ GearSonicIdleReadaptState::Adapting => {
+ let frame = &mut planner_state.motion_qpos_50hz[last_frame_idx];
+ for &isaaclab_idx in &GEAR_SONIC_IDLE_READAPT_LOWER_BODY_ISAACLAB_INDICES {
+ let mujoco_idx = GEAR_SONIC_ISAACLAB_TO_MUJOCO[isaaclab_idx];
+ let current = Self::planner_qpos_isaaclab_joint(frame, isaaclab_idx);
+ let actual = obs.joint_positions[mujoco_idx];
+ Self::set_planner_qpos_isaaclab_joint(
+ frame,
+ isaaclab_idx,
+ (1.0 - GEAR_SONIC_IDLE_READAPT_BLEND_WEIGHT) * current
+ + GEAR_SONIC_IDLE_READAPT_BLEND_WEIGHT * actual,
+ );
+ }
+ }
+ GearSonicIdleReadaptState::Recovering => {
+ let frame = &mut planner_state.motion_qpos_50hz[last_frame_idx];
+ for &isaaclab_idx in &GEAR_SONIC_IDLE_READAPT_LOWER_BODY_ISAACLAB_INDICES {
+ let current = Self::planner_qpos_isaaclab_joint(frame, isaaclab_idx);
+ let original =
+ planner_state.idle_readapt_original_targets_isaaclab[isaaclab_idx];
+ Self::set_planner_qpos_isaaclab_joint(
+ frame,
+ isaaclab_idx,
+ (1.0 - GEAR_SONIC_IDLE_READAPT_BLEND_WEIGHT) * current
+ + GEAR_SONIC_IDLE_READAPT_BLEND_WEIGHT * original,
+ );
+ }
+ }
+ GearSonicIdleReadaptState::Idle => {}
+ }
+ }
+
fn run_fixture_motion_tokens(
&self,
obs: &Observation,
@@ -2090,7 +2228,7 @@ impl GearSonicPolicy {
let target_vel = [command.target_vel];
let mode = [command.mode];
let height = [command.height];
- let random_seed = [0_i64];
+ let random_seed = [GEAR_SONIC_DEFAULT_RANDOM_SEED];
let has_specific_target = [0_i64];
let specific_target_positions = vec![0.0_f32; 4 * 3];
let specific_target_headings = vec![0.0_f32; 4];
@@ -2271,6 +2409,8 @@ impl GearSonicPolicy {
planner_state.motion_joint_velocities_isaaclab =
Self::compute_motion_joint_velocities_isaaclab(&planner_state.motion_qpos_50hz);
planner_state.current_motion_frame = 0;
+ planner_state.idle_readapt_stored = false;
+ planner_state.idle_readapt_state = GearSonicIdleReadaptState::Idle;
planner_state.init_ref_root_quat_wxyz = planner_state
.motion_qpos_50hz
.first()
@@ -2366,11 +2506,6 @@ impl GearSonicPolicy {
})?;
let command_speed =
(twist.linear[0] * twist.linear[0] + twist.linear[1] * twist.linear[1]).sqrt();
- if planner_state.motion_qpos_50hz.is_empty() && command_speed <= 0.01 {
- drop(planner_state);
- let encoder_obs = Self::build_placeholder_encoder_obs_dict(&self.robot);
- return self.run_tracking_contract_from_encoder_obs(obs, &encoder_obs);
- }
if planner_state.motion_qpos_50hz.is_empty() {
planner_state.context = Self::initialize_planner_context(&self.robot, obs);
planner_state.last_context_frame = planner_state
@@ -2385,13 +2520,12 @@ impl GearSonicPolicy {
let initializing_planner = planner_state.motion_qpos_50hz.is_empty();
let planner_command =
Self::planner_command_for_velocity_bootstrap(&planner_state, command, command_speed);
- let replan_interval_ticks = Self::planner_replan_interval_ticks(command);
let planner_tick_due = initializing_planner
|| planner_state.steps_since_planner_tick >= GEAR_SONIC_PLANNER_THREAD_INTERVAL_TICKS;
let needs_replan = initializing_planner
|| (planner_tick_due
&& (Self::planner_command_changed(planner_state.last_command, command)
- || planner_state.steps_since_plan >= replan_interval_ticks));
+ || Self::planner_periodic_replan_due(command, planner_state.steps_since_plan)));
if needs_replan {
if !planner_state.motion_qpos_50hz.is_empty() {
@@ -2449,6 +2583,7 @@ impl GearSonicPolicy {
robowbc_core::WbcError::InferenceFailed("planner state mutex poisoned".to_owned())
})?;
Self::advance_planner_motion_frame(&mut planner_state);
+ Self::maybe_apply_idle_readaptation(&mut planner_state, obs, command);
drop(planner_state);
Ok(targets)
@@ -2666,7 +2801,7 @@ impl GearSonicPolicy {
}
let mut positions = vec![0.0_f32; self.robot.joint_count];
- for (mujoco_idx, &isaaclab_idx) in GEAR_SONIC_ISAACLAB_TO_MUJOCO.iter().enumerate() {
+ for (mujoco_idx, &isaaclab_idx) in GEAR_SONIC_MUJOCO_TO_ISAACLAB.iter().enumerate() {
let action = raw_actions[isaaclab_idx];
let scaled = action * GEAR_SONIC_G1_ACTION_SCALE[mujoco_idx];
positions[mujoco_idx] = self.robot.default_pose[mujoco_idx] + scaled;
@@ -2959,8 +3094,8 @@ mod tests {
}
fn joint_velocities_isaaclab_to_mujoco(isaaclab_velocities: &[f32]) -> Vec {
- let mut mujoco_velocities = vec![0.0_f32; GEAR_SONIC_ISAACLAB_TO_MUJOCO.len()];
- for (mujoco_idx, &isaaclab_idx) in GEAR_SONIC_ISAACLAB_TO_MUJOCO.iter().enumerate() {
+ let mut mujoco_velocities = vec![0.0_f32; GEAR_SONIC_MUJOCO_TO_ISAACLAB.len()];
+ for (mujoco_idx, &isaaclab_idx) in GEAR_SONIC_MUJOCO_TO_ISAACLAB.iter().enumerate() {
mujoco_velocities[mujoco_idx] = isaaclab_velocities[isaaclab_idx];
}
mujoco_velocities
@@ -2978,7 +3113,7 @@ mod tests {
let joint_velocities = motion_joint_velocities_isaaclab
.get(clamped_idx)
.map_or_else(
- || vec![0.0_f32; GEAR_SONIC_ISAACLAB_TO_MUJOCO.len()],
+ || vec![0.0_f32; GEAR_SONIC_MUJOCO_TO_ISAACLAB.len()],
|velocities| joint_velocities_isaaclab_to_mujoco(velocities),
);
@@ -3453,6 +3588,85 @@ mod tests {
assert!(matches!(err, robowbc_core::WbcError::UnsupportedCommand(_)));
}
+ /// Regression for the keyboard demo: pressing `]` engages the velocity
+ /// policy while the command is still zero. That must start the real idle
+ /// planner/decoder path so the support band can be dropped while the
+ /// controller is already active.
+ #[test]
+ #[ignore = "requires real GEAR-SONIC ONNX models; run scripts/models/download_gear_sonic_models.sh first"]
+ fn gear_sonic_zero_velocity_bootstraps_idle_planner_motion() {
+ let model_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../models/gear-sonic");
+ let encoder_path = model_dir.join("model_encoder.onnx");
+ let decoder_path = model_dir.join("model_decoder.onnx");
+ let planner_path = model_dir.join("planner_sonic.onnx");
+ for path in [&encoder_path, &decoder_path, &planner_path] {
+ assert!(
+ path.exists(),
+ "missing real GEAR-Sonic model at {}; run scripts/models/download_gear_sonic_models.sh first",
+ path.display()
+ );
+ }
+
+ let robot_config_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
+ .join("../../configs/robots/unitree_g1_gear_sonic.toml");
+ let robot =
+ RobotConfig::from_toml_file(&robot_config_path).expect("robot config should load");
+ let policy = GearSonicPolicy::new(GearSonicConfig {
+ encoder: OrtConfig {
+ model_path: encoder_path,
+ optimization_level: OptimizationLevel::Extended,
+ num_threads: 1,
+ execution_provider: ExecutionProvider::Cpu,
+ },
+ decoder: OrtConfig {
+ model_path: decoder_path,
+ optimization_level: OptimizationLevel::Extended,
+ num_threads: 1,
+ execution_provider: ExecutionProvider::Cpu,
+ },
+ planner: OrtConfig {
+ model_path: planner_path,
+ optimization_level: OptimizationLevel::Extended,
+ num_threads: 1,
+ execution_provider: ExecutionProvider::Cpu,
+ },
+ reference_motion: None,
+ robot: robot.clone(),
+ })
+ .expect("real GEAR-Sonic policy should build");
+
+ let obs = Observation {
+ joint_positions: robot.default_pose.clone(),
+ joint_velocities: vec![0.0; robot.joint_count],
+ gravity_vector: [0.0, 0.0, -1.0],
+ angular_velocity: [0.0, 0.0, 0.0],
+ base_pose: None,
+ command: WbcCommand::Velocity(robowbc_core::Twist {
+ linear: [0.0, 0.0, 0.0],
+ angular: [0.0, 0.0, 0.0],
+ }),
+ timestamp: Instant::now(),
+ };
+
+ let targets = policy
+ .predict(&obs)
+ .expect("zero-velocity prediction should run idle planner");
+
+ assert_eq!(targets.positions.len(), robot.joint_count);
+ let planner_state = policy
+ .planner_state
+ .lock()
+ .expect("planner state mutex should not be poisoned");
+ assert!(
+ !planner_state.motion_qpos_50hz.is_empty(),
+ "engaged zero-velocity mode must bootstrap an idle planner motion"
+ );
+ assert_eq!(
+ planner_state.last_command,
+ Some(GearSonicPolicy::idle_planner_command())
+ );
+ }
+
#[test]
fn gear_sonic_planner_context_frame_prefers_observed_base_pose() {
let obs = Observation {
@@ -3484,10 +3698,10 @@ mod tests {
}
#[test]
- fn gear_sonic_planner_command_uses_slow_walk_speed_for_low_velocity() {
+ fn gear_sonic_planner_command_uses_slow_walk_speed_for_default_velocity() {
let mut planner_state = GearSonicPlannerState::new(&test_robot_config(29));
let forward = robowbc_core::Twist {
- linear: [0.6, 0.0, 0.0],
+ linear: [0.3, 0.0, 0.0],
angular: [0.0, 0.0, 0.0],
};
let command = GearSonicPolicy::derive_planner_command(&mut planner_state, &forward);
@@ -3498,6 +3712,57 @@ mod tests {
assert_vec3_approx_eq(command.facing_direction, [1.0, 0.0, 0.0]);
}
+ #[test]
+ fn gear_sonic_planner_command_uses_slow_walk_target_velocity_for_mid_velocity() {
+ let mut planner_state = GearSonicPlannerState::new(&test_robot_config(29));
+ let forward = robowbc_core::Twist {
+ linear: [0.6, 0.0, 0.0],
+ angular: [0.0, 0.0, 0.0],
+ };
+ let command = GearSonicPolicy::derive_planner_command(&mut planner_state, &forward);
+
+ assert_eq!(command.mode, GEAR_SONIC_DEFAULT_MODE_SLOW_WALK);
+ assert!((command.target_vel - 0.6).abs() < 1e-6);
+ assert_vec3_approx_eq(command.movement_direction, [1.0, 0.0, 0.0]);
+ assert_vec3_approx_eq(command.facing_direction, [1.0, 0.0, 0.0]);
+ }
+
+ #[test]
+ fn gear_sonic_planner_command_clamps_tiny_velocity_to_minimum_moving_speed() {
+ let mut planner_state = GearSonicPlannerState::new(&test_robot_config(29));
+ let forward = robowbc_core::Twist {
+ linear: [0.05, 0.0, 0.0],
+ angular: [0.0, 0.0, 0.0],
+ };
+ let command = GearSonicPolicy::derive_planner_command(&mut planner_state, &forward);
+
+ assert_eq!(command.mode, GEAR_SONIC_DEFAULT_MODE_SLOW_WALK);
+ assert!((command.target_vel - GEAR_SONIC_MIN_MOVING_SPEED_MPS).abs() < 1e-6);
+ assert_vec3_approx_eq(command.movement_direction, [1.0, 0.0, 0.0]);
+ }
+
+ #[test]
+ fn gear_sonic_planner_command_uses_walk_and_run_ranges() {
+ let mut planner_state = GearSonicPlannerState::new(&test_robot_config(29));
+ let walk = robowbc_core::Twist {
+ linear: [1.0, 0.0, 0.0],
+ angular: [0.0, 0.0, 0.0],
+ };
+ let walk_command = GearSonicPolicy::derive_planner_command(&mut planner_state, &walk);
+
+ assert_eq!(walk_command.mode, GEAR_SONIC_DEFAULT_MODE_WALK);
+ assert!((walk_command.target_vel - 1.0).abs() < 1e-6);
+
+ let run = robowbc_core::Twist {
+ linear: [1.6, 0.0, 0.0],
+ angular: [0.0, 0.0, 0.0],
+ };
+ let run_command = GearSonicPolicy::derive_planner_command(&mut planner_state, &run);
+
+ assert_eq!(run_command.mode, GEAR_SONIC_DEFAULT_MODE_RUN);
+ assert!((run_command.target_vel - 1.6).abs() < 1e-6);
+ }
+
#[test]
fn gear_sonic_planner_command_uses_lateral_slow_walk_speed_bin() {
let mut planner_state = GearSonicPlannerState::new(&test_robot_config(29));
@@ -3508,7 +3773,7 @@ mod tests {
let command = GearSonicPolicy::derive_planner_command(&mut planner_state, &left);
assert_eq!(command.mode, GEAR_SONIC_DEFAULT_MODE_SLOW_WALK);
- assert!((command.target_vel - 0.35).abs() < 1e-6);
+ assert!((command.target_vel - 0.4).abs() < 1e-6);
assert_vec3_approx_eq(command.movement_direction, [0.0, 1.0, 0.0]);
}
@@ -3532,7 +3797,7 @@ mod tests {
let command = GearSonicPolicy::derive_planner_command(&mut planner_state, &forward);
assert_eq!(command.mode, GEAR_SONIC_DEFAULT_MODE_WALK);
- assert_eq!(command.target_vel, -1.0);
+ assert!((command.target_vel - 1.0).abs() < 1e-6);
assert_vec3_approx_eq(command.movement_direction, [0.0, -1.0, 0.0]);
assert_vec3_approx_eq(command.facing_direction, [0.0, -1.0, 0.0]);
}
@@ -3614,6 +3879,130 @@ mod tests {
assert_eq!(planner_command, GearSonicPolicy::idle_planner_command());
}
+ #[test]
+ fn gear_sonic_idle_command_does_not_periodically_replan() {
+ assert!(!GearSonicPolicy::planner_periodic_replan_due(
+ GearSonicPolicy::idle_planner_command(),
+ GEAR_SONIC_PLANNER_REPLAN_INTERVAL_TICKS_DEFAULT
+ ));
+ }
+
+ #[test]
+ fn gear_sonic_idle_tail_readaptation_blends_lower_body_toward_actual_joints() {
+ let robot = test_robot_config(29);
+ let mut planner_state = GearSonicPlannerState::new(&robot);
+ planner_state.motion_qpos_50hz = vec![
+ GearSonicPolicy::make_standing_qpos(&robot),
+ GearSonicPolicy::make_standing_qpos(&robot),
+ ];
+ planner_state.current_motion_frame = planner_state.motion_qpos_50hz.len() - 1;
+
+ {
+ let tail_frame = planner_state
+ .motion_qpos_50hz
+ .last_mut()
+ .expect("tail frame should exist");
+ for &isaaclab_idx in &GEAR_SONIC_IDLE_READAPT_LOWER_BODY_ISAACLAB_INDICES {
+ GearSonicPolicy::set_planner_qpos_isaaclab_joint(tail_frame, isaaclab_idx, 0.5);
+ }
+ }
+
+ let obs = Observation {
+ joint_positions: vec![0.0; 29],
+ joint_velocities: vec![0.0; 29],
+ gravity_vector: [0.0, 0.0, -1.0],
+ angular_velocity: [0.0, 0.0, 0.0],
+ base_pose: None,
+ command: WbcCommand::Velocity(robowbc_core::Twist {
+ linear: [0.0, 0.0, 0.0],
+ angular: [0.0, 0.0, 0.0],
+ }),
+ timestamp: Instant::now(),
+ };
+
+ GearSonicPolicy::maybe_apply_idle_readaptation(
+ &mut planner_state,
+ &obs,
+ GearSonicPolicy::idle_planner_command(),
+ );
+
+ assert!(planner_state.idle_readapt_stored);
+ assert_eq!(
+ planner_state.idle_readapt_state,
+ GearSonicIdleReadaptState::Adapting
+ );
+ let tail_frame = planner_state
+ .motion_qpos_50hz
+ .last()
+ .expect("tail frame should exist");
+ for &isaaclab_idx in &GEAR_SONIC_IDLE_READAPT_LOWER_BODY_ISAACLAB_INDICES {
+ let target = GearSonicPolicy::planner_qpos_isaaclab_joint(tail_frame, isaaclab_idx);
+ assert!((target - 0.49).abs() < 1e-6);
+ assert!(
+ (planner_state.idle_readapt_original_targets_isaaclab[isaaclab_idx] - 0.5).abs()
+ < 1e-6
+ );
+ }
+ }
+
+ #[test]
+ fn gear_sonic_idle_tail_readaptation_recovers_toward_original_target() {
+ let robot = test_robot_config(29);
+ let mut planner_state = GearSonicPlannerState::new(&robot);
+ planner_state.motion_qpos_50hz = vec![GearSonicPolicy::make_standing_qpos(&robot)];
+ planner_state.current_motion_frame = 0;
+ planner_state.idle_readapt_stored = true;
+ planner_state.idle_readapt_state = GearSonicIdleReadaptState::Recovering;
+
+ for &isaaclab_idx in &GEAR_SONIC_IDLE_READAPT_LOWER_BODY_ISAACLAB_INDICES {
+ planner_state.idle_readapt_original_targets_isaaclab[isaaclab_idx] = 0.5;
+ }
+
+ let obs = Observation {
+ joint_positions: vec![0.0; 29],
+ joint_velocities: vec![0.0; 29],
+ gravity_vector: [0.0, 0.0, -1.0],
+ angular_velocity: [0.0, 0.0, 0.0],
+ base_pose: None,
+ command: WbcCommand::Velocity(robowbc_core::Twist {
+ linear: [0.0, 0.0, 0.0],
+ angular: [0.0, 0.0, 0.0],
+ }),
+ timestamp: Instant::now(),
+ };
+
+ GearSonicPolicy::maybe_apply_idle_readaptation(
+ &mut planner_state,
+ &obs,
+ GearSonicPolicy::idle_planner_command(),
+ );
+
+ let tail_frame = planner_state
+ .motion_qpos_50hz
+ .last()
+ .expect("tail frame should exist");
+ for &isaaclab_idx in &GEAR_SONIC_IDLE_READAPT_LOWER_BODY_ISAACLAB_INDICES {
+ let target = GearSonicPolicy::planner_qpos_isaaclab_joint(tail_frame, isaaclab_idx);
+ assert!((target - 0.01).abs() < 1e-6);
+ }
+ }
+
+ #[test]
+ fn gear_sonic_walk_command_periodically_replans() {
+ let command = GearSonicPlannerCommand {
+ mode: GEAR_SONIC_DEFAULT_MODE_WALK,
+ target_vel: -1.0,
+ height: GEAR_SONIC_DEFAULT_HEIGHT_SENTINEL,
+ movement_direction: [1.0, 0.0, 0.0],
+ facing_direction: [1.0, 0.0, 0.0],
+ };
+
+ assert!(GearSonicPolicy::planner_periodic_replan_due(
+ command,
+ GEAR_SONIC_PLANNER_REPLAN_INTERVAL_TICKS_DEFAULT
+ ));
+ }
+
#[test]
fn gear_sonic_planner_allowed_pred_mask_matches_upstream_default() {
assert_eq!(
@@ -3627,6 +4016,11 @@ mod tests {
assert!((GEAR_SONIC_DEFAULT_HEIGHT_METERS - 0.788_74).abs() < 1e-6);
}
+ #[test]
+ fn gear_sonic_planner_random_seed_matches_upstream_default() {
+ assert_eq!(GEAR_SONIC_DEFAULT_RANDOM_SEED, 1234);
+ }
+
#[test]
fn gear_sonic_reset_clears_tracking_state() {
if !has_test_model() {
@@ -3698,7 +4092,7 @@ mod tests {
let offsets = GearSonicPolicy::observation_joint_positions_isaaclab_offsets(&robot, &obs);
let velocities = GearSonicPolicy::observation_joint_velocities_isaaclab(&obs);
- for (mujoco_idx, &isaaclab_idx) in GEAR_SONIC_ISAACLAB_TO_MUJOCO.iter().enumerate() {
+ for (mujoco_idx, &isaaclab_idx) in GEAR_SONIC_MUJOCO_TO_ISAACLAB.iter().enumerate() {
let expected_offset = joint_positions[mujoco_idx] - robot.default_pose[mujoco_idx];
assert!(
(offsets[isaaclab_idx] - expected_offset).abs() < 1e-6,
@@ -3803,6 +4197,8 @@ mod tests {
let decoder_obs = GearSonicPolicy::build_decoder_obs_dict(&tokens, &history);
let mut expected = tokens.clone();
+ // Upstream release observations call StateLogger::GetLatest with the
+ // default newest_first=false, so each history block is oldest-to-newest.
for angular_velocity in &history.angular_velocity {
expected.extend_from_slice(angular_velocity);
}
@@ -3824,7 +4220,7 @@ mod tests {
}
#[test]
- #[ignore = "requires real GEAR-SONIC ONNX models; run scripts/download_gear_sonic_models.sh first"]
+ #[ignore = "requires real GEAR-SONIC ONNX models; run scripts/models/download_gear_sonic_models.sh first"]
fn gear_sonic_dump_model_meta() {
let model_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../models/gear-sonic");
let encoder = OrtBackend::from_file(model_dir.join("model_encoder.onnx")).unwrap();
@@ -3863,7 +4259,7 @@ mod tests {
}
#[test]
- #[ignore = "requires real GEAR-SONIC ONNX models; run scripts/download_gear_sonic_models.sh first"]
+ #[ignore = "requires real GEAR-SONIC ONNX models; run scripts/models/download_gear_sonic_models.sh first"]
fn gear_sonic_dump_tracking_placeholder_tensors() {
let model_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../models/gear-sonic");
let encoder_path = model_dir.join("model_encoder.onnx");
@@ -3875,7 +4271,7 @@ mod tests {
for path in [&encoder_path, &decoder_path, &planner_path] {
assert!(
path.exists(),
- "model not found: {path:?} — run scripts/download_gear_sonic_models.sh"
+ "model not found: {path:?} — run scripts/models/download_gear_sonic_models.sh"
);
}
@@ -3971,7 +4367,7 @@ mod tests {
}
#[test]
- #[ignore = "requires real GEAR-Sonic ONNX models; run scripts/download_gear_sonic_models.sh first"]
+ #[ignore = "requires real GEAR-Sonic ONNX models; run scripts/models/download_gear_sonic_models.sh first"]
#[allow(clippy::too_many_lines)]
fn gear_sonic_dump_velocity_first_live_replan_tensors() {
let model_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../models/gear-sonic");
@@ -3984,7 +4380,7 @@ mod tests {
for path in [&encoder_path, &decoder_path, &planner_path] {
assert!(
path.exists(),
- "model not found: {path:?} — run scripts/download_gear_sonic_models.sh"
+ "model not found: {path:?} — run scripts/models/download_gear_sonic_models.sh"
);
}
@@ -4188,7 +4584,7 @@ mod tests {
}
#[test]
- #[ignore = "requires real GEAR-Sonic ONNX models; run scripts/download_gear_sonic_models.sh first"]
+ #[ignore = "requires real GEAR-Sonic ONNX models; run scripts/models/download_gear_sonic_models.sh first"]
#[allow(clippy::too_many_lines)]
fn gear_sonic_dump_velocity_later_motion_tensors() {
let model_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../models/gear-sonic");
@@ -4201,7 +4597,7 @@ mod tests {
for path in [&encoder_path, &decoder_path, &planner_path] {
assert!(
path.exists(),
- "model not found: {path:?} — run scripts/download_gear_sonic_models.sh"
+ "model not found: {path:?} — run scripts/models/download_gear_sonic_models.sh"
);
}
@@ -4456,11 +4852,11 @@ mod tests {
///
/// Run with:
/// ```
- /// bash scripts/download_gear_sonic_models.sh
+ /// bash scripts/models/download_gear_sonic_models.sh
/// cargo test -p robowbc-ort -- --ignored gear_sonic_real_model_inference
/// ```
#[test]
- #[ignore = "requires real GEAR-SONIC ONNX models; run scripts/download_gear_sonic_models.sh first"]
+ #[ignore = "requires real GEAR-SONIC ONNX models; run scripts/models/download_gear_sonic_models.sh first"]
#[allow(clippy::too_many_lines)]
fn gear_sonic_real_model_inference() {
use robowbc_core::WbcPolicy;
@@ -4476,7 +4872,7 @@ mod tests {
for path in [&encoder_path, &decoder_path, &planner_path] {
assert!(
path.exists(),
- "model not found: {path:?} — run scripts/download_gear_sonic_models.sh"
+ "model not found: {path:?} — run scripts/models/download_gear_sonic_models.sh"
);
}
diff --git a/crates/robowbc-ort/src/wbc_agile.rs b/crates/robowbc-ort/src/wbc_agile.rs
index 28a9530..f4ee5e8 100644
--- a/crates/robowbc-ort/src/wbc_agile.rs
+++ b/crates/robowbc-ort/src/wbc_agile.rs
@@ -983,11 +983,11 @@ mod tests {
///
/// To run once weights are available:
/// ```bash
- /// bash scripts/download_wbc_agile_models.sh
+ /// bash scripts/models/download_wbc_agile_models.sh
/// cargo test -p robowbc-ort -- --ignored wbc_agile_real_model_inference
/// ```
#[test]
- #[ignore = "requires real WBC-AGILE G1 ONNX weights; run scripts/download_wbc_agile_models.sh first"]
+ #[ignore = "requires real WBC-AGILE G1 ONNX weights; run scripts/models/download_wbc_agile_models.sh first"]
fn wbc_agile_real_model_inference() {
let model_path = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../models/wbc-agile/unitree_g1_velocity_e2e.onnx");
diff --git a/crates/robowbc-sim/Cargo.toml b/crates/robowbc-sim/Cargo.toml
index 2de2ebd..746c82e 100644
--- a/crates/robowbc-sim/Cargo.toml
+++ b/crates/robowbc-sim/Cargo.toml
@@ -12,7 +12,7 @@ path = "src/lib.rs"
[features]
default = []
mujoco = ["dep:mujoco-rs", "mujoco-rs/renderer"]
-mujoco-viewer = ["mujoco", "mujoco-rs/viewer"]
+mujoco-viewer = ["mujoco", "mujoco-rs/viewer-ui"]
mujoco-auto-download = ["mujoco", "mujoco-rs/auto-download-mujoco"]
[dependencies]
diff --git a/crates/robowbc-sim/src/lib.rs b/crates/robowbc-sim/src/lib.rs
index c7c4f48..c58c73b 100644
--- a/crates/robowbc-sim/src/lib.rs
+++ b/crates/robowbc-sim/src/lib.rs
@@ -69,6 +69,14 @@ pub struct MujocoConfig {
/// when present.
#[serde(default = "default_gain_profile")]
pub gain_profile: MujocoGainProfile,
+ /// Apply [`robowbc_core::RobotConfig::joint_velocity_limits`] as a
+ /// software q-target slew limit before computing PD torques.
+ ///
+ /// The default preserves the generic hardware-style safety behavior. Set
+ /// this to `false` for official simulator parity when the upstream stack
+ /// sends each policy q-target directly to the `MuJoCo` PD loop.
+ #[serde(default = "default_enforce_target_velocity_limits")]
+ pub enforce_target_velocity_limits: bool,
/// Open a live `MuJoCo` viewer and sync it after each control tick.
///
/// Requires building with the `mujoco-viewer` feature. This is intended
@@ -93,6 +101,10 @@ const fn default_gain_profile() -> MujocoGainProfile {
MujocoGainProfile::SimulationPd
}
+const fn default_enforce_target_velocity_limits() -> bool {
+ true
+}
+
/// Upstream-style `MuJoCo` virtual support band.
///
/// GR00T's G1 `MuJoCo` simulator enables this for the teleop path to keep the
@@ -112,8 +124,9 @@ pub struct MujocoElasticBandConfig {
pub anchor: [f64; 3],
/// Replace `anchor` with the selected body's initialized world position.
///
- /// This preserves the standing height for local demos while still letting
- /// the band catch the humanoid if it starts to fall.
+ /// Leave this `false` for official GR00T-style demos that use the fixed
+ /// `[0, 0, 1]` support point. Set it only for custom demos that need the
+ /// band anchored at the model's initialized body position.
#[serde(default)]
pub anchor_from_initial_pose: bool,
/// Additional vertical rest length added to the spring error.
@@ -184,6 +197,7 @@ impl Default for MujocoConfig {
timestep: default_timestep(),
substeps: default_substeps(),
gain_profile: default_gain_profile(),
+ enforce_target_velocity_limits: default_enforce_target_velocity_limits(),
viewer: false,
elastic_band: None,
}
@@ -282,6 +296,7 @@ mod tests {
assert!((cfg.timestep - 0.002).abs() < f64::EPSILON);
assert_eq!(cfg.substeps, 10);
assert_eq!(cfg.gain_profile, MujocoGainProfile::SimulationPd);
+ assert!(cfg.enforce_target_velocity_limits);
}
#[test]
@@ -291,6 +306,7 @@ mod tests {
timestep: 0.001,
substeps: 20,
gain_profile: MujocoGainProfile::DefaultPd,
+ enforce_target_velocity_limits: false,
viewer: true,
elastic_band: Some(MujocoElasticBandConfig::default()),
};
diff --git a/crates/robowbc-sim/src/transport.rs b/crates/robowbc-sim/src/transport.rs
index 0d97668..434b8ca 100644
--- a/crates/robowbc-sim/src/transport.rs
+++ b/crates/robowbc-sim/src/transport.rs
@@ -18,6 +18,10 @@ use std::env;
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
+#[cfg(feature = "mujoco-viewer")]
+use std::sync::atomic::{AtomicBool, Ordering};
+#[cfg(feature = "mujoco-viewer")]
+use std::sync::Arc;
use std::sync::OnceLock;
use std::time::{Instant, SystemTime, UNIX_EPOCH};
@@ -115,6 +119,8 @@ pub struct MujocoTransport {
prev_positions: Vec,
#[cfg(feature = "mujoco-viewer")]
viewer: Option,
+ #[cfg(feature = "mujoco-viewer")]
+ viewer_elastic_band_toggle_requested: Option>,
}
impl MujocoTransport {
@@ -181,15 +187,25 @@ impl MujocoTransport {
build_elastic_band(&data, elastic_band_body_id, config.elastic_band.clone());
let prev_positions = robot_config.default_pose.clone();
#[cfg(feature = "mujoco-viewer")]
+ let viewer_elastic_band_toggle_requested =
+ config.viewer.then(|| Arc::new(AtomicBool::new(false)));
+ #[cfg(feature = "mujoco-viewer")]
let viewer = if config.viewer {
- Some(
- MjViewer::builder()
- .window_name("RoboWBC MuJoCo keyboard demo")
- .build_passive(data.model())
- .map_err(|error| SimError::ViewerFailed {
- reason: error.to_string(),
- })?,
- )
+ let mut viewer = MjViewer::builder()
+ .window_name("RoboWBC MuJoCo keyboard demo")
+ .build_passive(data.model())
+ .map_err(|error| SimError::ViewerFailed {
+ reason: error.to_string(),
+ })?;
+ if let Some(toggle_requested) = &viewer_elastic_band_toggle_requested {
+ let toggle_requested = Arc::clone(toggle_requested);
+ viewer.add_ui_callback_detached(move |ctx| {
+ if ctx.input(|input| input.key_pressed(mujoco_rs::viewer::egui::Key::Num9)) {
+ toggle_requested.store(true, Ordering::Relaxed);
+ }
+ });
+ }
+ Some(viewer)
} else {
None
};
@@ -214,6 +230,8 @@ impl MujocoTransport {
prev_positions,
#[cfg(feature = "mujoco-viewer")]
viewer,
+ #[cfg(feature = "mujoco-viewer")]
+ viewer_elastic_band_toggle_requested,
})
}
@@ -417,16 +435,31 @@ impl MujocoTransport {
#[cfg(feature = "mujoco-viewer")]
fn render_viewer_if_enabled(&mut self) -> Result<(), SimError> {
- let Some(viewer) = self.viewer.as_mut() else {
- return Ok(());
- };
- if !viewer.running() {
- return Ok(());
+ {
+ let Some(viewer) = self.viewer.as_mut() else {
+ return Ok(());
+ };
+ if !viewer.running() {
+ return Ok(());
+ }
+ viewer.sync_data(&mut self.data);
+ viewer.render().map_err(|error| SimError::ViewerFailed {
+ reason: error.to_string(),
+ })?;
}
- viewer.sync_data(&mut self.data);
- viewer.render().map_err(|error| SimError::ViewerFailed {
- reason: error.to_string(),
- })
+ if self
+ .viewer_elastic_band_toggle_requested
+ .as_ref()
+ .is_some_and(|requested| requested.swap(false, Ordering::Relaxed))
+ {
+ if let Some(enabled) = self.toggle_elastic_band_enabled() {
+ println!(
+ "elastic support band {}",
+ if enabled { "enabled" } else { "disabled" }
+ );
+ }
+ }
+ Ok(())
}
fn joint_state_vectors(&self) -> (Vec, Vec) {
@@ -596,13 +629,17 @@ impl RobotTransport for MujocoTransport {
let after_pos =
clamp_position_targets(targets, self.robot_config.simulation_joint_limits());
let control_frequency_hz = control_frequency_hz(&self.config);
- let safe_targets = if let Some(ref vel_limits) = self.robot_config.joint_velocity_limits {
- clamp_velocity_targets(
- &after_pos,
- &self.prev_positions,
- vel_limits,
- control_frequency_hz,
- )
+ let safe_targets = if self.config.enforce_target_velocity_limits {
+ if let Some(ref vel_limits) = self.robot_config.joint_velocity_limits {
+ clamp_velocity_targets(
+ &after_pos,
+ &self.prev_positions,
+ vel_limits,
+ control_frequency_hz,
+ )
+ } else {
+ after_pos
+ }
} else {
after_pos
};
@@ -1596,6 +1633,7 @@ mod tests {
timestep: 0.002,
substeps: 1,
gain_profile: MujocoGainProfile::DefaultPd,
+ enforce_target_velocity_limits: true,
viewer: false,
elastic_band: None,
},
@@ -1754,6 +1792,18 @@ mod tests {
assert!((gravity[2] + 1.0).abs() < 1e-6);
}
+ #[test]
+ fn gravity_from_free_joint_quaternion_matches_official_projected_gravity_signs() {
+ let quarter_turn = std::f64::consts::FRAC_PI_4;
+ let pitch_up_gravity =
+ gravity_from_free_joint_quaternion([quarter_turn.cos(), 0.0, quarter_turn.sin(), 0.0]);
+ assert_vec3_approx_eq(pitch_up_gravity, [1.0, 0.0, 0.0]);
+
+ let roll_left_gravity =
+ gravity_from_free_joint_quaternion([quarter_turn.cos(), quarter_turn.sin(), 0.0, 0.0]);
+ assert_vec3_approx_eq(roll_left_gravity, [0.0, -1.0, 0.0]);
+ }
+
#[test]
fn recv_imu_uses_floating_base_state_when_available() {
let robot = load_robot_config("configs/robots/unitree_g1.toml");
@@ -1859,6 +1909,34 @@ mod tests {
assert_eq!(transport.prev_positions, expected.positions);
}
+ #[test]
+ fn send_joint_targets_can_disable_target_velocity_limit_for_official_sim() {
+ let robot = load_robot_config("configs/robots/unitree_g1.toml");
+ let mut transport = MujocoTransport::new(
+ MujocoConfig {
+ model_path: g1_model_path(),
+ timestep: 0.002,
+ substeps: 10,
+ enforce_target_velocity_limits: false,
+ ..MujocoConfig::default()
+ },
+ robot.clone(),
+ )
+ .expect("transport should initialize");
+
+ let unsafe_targets = JointPositionTargets {
+ positions: vec![100.0; robot.joint_count],
+ timestamp: Instant::now(),
+ };
+ let after_pos = clamp_position_targets(&unsafe_targets, &robot.joint_limits);
+
+ transport
+ .send_joint_targets(&unsafe_targets)
+ .expect("send should succeed");
+
+ assert_eq!(transport.prev_positions, after_pos.positions);
+ }
+
#[test]
fn send_joint_targets_prefers_simulation_joint_limits_for_mujoco_clamp() {
let mut robot = load_robot_config("configs/robots/unitree_g1_35dof_wbc_agile.toml");
diff --git a/crates/robowbc-teleop/src/keyboard.rs b/crates/robowbc-teleop/src/keyboard.rs
index fc54f67..b2c446b 100644
--- a/crates/robowbc-teleop/src/keyboard.rs
+++ b/crates/robowbc-teleop/src/keyboard.rs
@@ -17,7 +17,7 @@
use crate::keymap::{KeymapConfig, TeleopAction};
use crate::{TeleopError, TeleopEvent, TeleopSource};
-use crossterm::event::{poll, read, Event, KeyEvent, KeyEventKind};
+use crossterm::event::{poll, read, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
use std::time::Duration;
@@ -110,6 +110,9 @@ impl KeyboardTeleop {
if !matches!(event.kind, KeyEventKind::Press) {
return None;
}
+ if is_ctrl_c(event) {
+ return Some(TeleopEvent::Quit);
+ }
let action = self.keymap.lookup(event)?;
Some(self.apply_action(action))
}
@@ -249,16 +252,25 @@ fn clamp_step(value: f32, clamp: f32) -> f32 {
value.clamp(-abs_clamp, abs_clamp)
}
+fn is_ctrl_c(event: KeyEvent) -> bool {
+ matches!(event.code, KeyCode::Char('c' | 'C'))
+ && event.modifiers.contains(KeyModifiers::CONTROL)
+}
+
#[cfg(test)]
mod tests {
use super::*;
- use crossterm::event::{KeyCode, KeyEventKind, KeyEventState, KeyModifiers};
+ use crossterm::event::{KeyEventKind, KeyEventState};
use std::time::Instant;
fn key_press(code: KeyCode) -> KeyEvent {
+ key_press_with_modifiers(code, KeyModifiers::NONE)
+ }
+
+ fn key_press_with_modifiers(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent {
KeyEvent {
code,
- modifiers: KeyModifiers::NONE,
+ modifiers,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
}
@@ -358,6 +370,18 @@ mod tests {
assert_eq!(event, TeleopEvent::Quit);
}
+ #[test]
+ fn ctrl_c_emits_quit_in_raw_mode() {
+ let mut teleop = KeyboardTeleop::new();
+ let event = teleop
+ .handle_key(key_press_with_modifiers(
+ KeyCode::Char('c'),
+ KeyModifiers::CONTROL,
+ ))
+ .unwrap();
+ assert_eq!(event, TeleopEvent::Quit);
+ }
+
#[test]
fn unbound_keys_are_dropped() {
let mut teleop = KeyboardTeleop::new();
diff --git a/deny.toml b/deny.toml
index 6bf84a3..ac22103 100644
--- a/deny.toml
+++ b/deny.toml
@@ -67,6 +67,9 @@ allow = [
"CDLA-Permissive-2.0",
"BSL-1.0",
"OpenSSL",
+ # Font licenses pulled by egui's default viewer fonts through mujoco-rs.
+ "OFL-1.1",
+ "Ubuntu-font-1.0",
]
confidence-threshold = 0.8
exceptions = []
diff --git a/docs/README.md b/docs/README.md
index 9e20df9..f30325d 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -35,12 +35,16 @@ transports.
## Start here
+- `README.md`, understand what the project is and how to try it
+- `STATUS.md`, see what works now and what is next
+- `ARCHITECTURE.md`, get the high-level system map
- [Getting Started](getting-started.md), build the workspace and run the smoke config
- [G1 MJCF Review (2026-04-24)](g1-mjcf-review-2026-04-24.md), compare the reviewed upstream G1 MuJoCo models and the RoboWBC config decisions
- [Configuration Reference](configuration.md), understand the TOML surface
+- [Maintainer Scripts](scripts.md), find the canonical script folders and edit checks
- [Adding a New Policy](adding-a-model.md), wire a new model into the registry
- [Adding a New Robot](adding-a-robot.md), add a new hardware target
-- [Architecture](architecture.md), understand the crate split and runtime flow
+- [Detailed Architecture](architecture.md), understand the crate split and runtime flow
## License
diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md
index fb67f5e..e878406 100644
--- a/docs/SUMMARY.md
+++ b/docs/SUMMARY.md
@@ -1,29 +1,31 @@
# Summary
-[Introduction](README.md)
-
----
-
-# User Guide
+# Start Here
+- [Introduction](README.md)
- [Getting Started](getting-started.md)
- [Python SDK](python-sdk.md)
- [Configuration Reference](configuration.md)
+- [Human Documentation Map](human/README.md)
+
+# Architecture And Runtime
-# Tutorials
+- [Architecture](architecture.md)
+- [Roboharness Integration](roboharness-integration.md)
+- [G1 MJCF Review](g1-mjcf-review-2026-04-24.md)
+
+# Extending RoboWBC
- [Adding a New Policy](adding-a-model.md)
- [Adding a New Robot](adding-a-robot.md)
-# Reference
+# Operations And Evidence
-- [Architecture](architecture.md)
-- [Roboharness Integration](roboharness-integration.md)
- [Benchmarks](benchmarks/README.md)
+- [Maintainer Scripts](scripts.md)
+- [Third-Party Notices](third-party-notices.md)
----
-
-# Project
+# Project Direction
- [Roadmap](roadmap-2026-q2.md)
- [Ecosystem Strategy](ecosystem-strategy.md)
diff --git a/docs/agents/README.md b/docs/agents/README.md
new file mode 100644
index 0000000..6d75745
--- /dev/null
+++ b/docs/agents/README.md
@@ -0,0 +1,15 @@
+# Agent Runbooks
+
+These docs hold repo-specific agent procedures that are too detailed for the
+root startup files.
+
+Read the runbook that matches the task:
+
+- `domain.md` - canonical domain docs and vocabulary.
+- `architecture.md` - crate map, runtime contracts, policy registry, and gotchas.
+- `rust-workflow.md` - toolchain, build, check, test, lint, format, and docs.
+- `testing.md` - focused verification choices for Rust, Python, site, SDK, and MuJoCo work.
+- `keyboard-demo.md` - protected `make demo-keyboard` behavior and stability test.
+- `github-workflow.md` - GitHub MCP usage, PR review, CI log triage, and commit rules.
+- `issue-tracker.md` - GitHub issue handling.
+- `triage-labels.md` - issue label vocabulary.
diff --git a/docs/agents/architecture.md b/docs/agents/architecture.md
new file mode 100644
index 0000000..366dc7e
--- /dev/null
+++ b/docs/agents/architecture.md
@@ -0,0 +1,54 @@
+# Architecture Notes For Agents
+
+For human-facing architecture, read `docs/architecture.md` first. This file is a
+compact agent crib sheet for implementation work.
+
+## Crate Map
+
+| Crate | Role |
+|-------|------|
+| `robowbc-core` | `WbcPolicy`, `Observation`, `WbcCommand`, `JointPositionTargets`, `RobotConfig`, validation |
+| `robowbc-config` | typed config loading and validation |
+| `robowbc-registry` | inventory-based policy registration and `WbcRegistry::build` |
+| `robowbc-ort` | ONNX Runtime backends and first-party policy wrappers |
+| `robowbc-pyo3` | Python-backed runtime policy loading |
+| `robowbc-py` | standalone maturin package for the Python SDK |
+| `robowbc-runtime` | reusable runtime loop pieces |
+| `robowbc-comm` | communication-oriented control-loop plumbing |
+| `robowbc-transport` | transport abstractions and backends |
+| `robowbc-sim` | MuJoCo transport for hardware-free execution |
+| `robowbc-teleop` | keyboard teleop and keymap handling |
+| `robowbc-vis` | Rerun visualization and `.rrd` recording |
+| `robowbc-cli` | `robowbc` binary and config-driven command surface |
+| `unitree-hg-idl` | Unitree HG message serialization helpers |
+
+## Core Contract
+
+Policies implement `WbcPolicy` and return joint position targets. They should
+advertise capabilities, reject unsupported commands explicitly, and preserve
+the unified observation and output contracts from `robowbc-core`.
+
+Model switching is config-driven through registry names such as `gear_sonic`,
+`decoupled_wbc`, `wbc_agile`, `bfm_zero`, `hover`, `wholebody_vla`, and
+`py_model`.
+
+## Integration Gotchas
+
+- ONNX models must match the pinned `ort` version and supported opset.
+- CUDA and TensorRT providers require host NVIDIA runtime compatibility.
+- `inventory` registration only discovers crates linked into the final binary.
+- PyO3 policies must be GIL-aware.
+- GEAR-Sonic uses `planner_sonic.onnx` for the live velocity path; the
+ encoder/decoder standing-placeholder path is narrower.
+- `hover` is blocked on user-supplied exported checkpoints.
+- `wholebody_vla` is experimental because no runnable public upstream release
+ exists yet.
+
+## Protected Public Surfaces
+
+- Python SDK types: `Registry`, `Observation`, policy wrappers, and
+ `MujocoSession`.
+- Config files under `configs/`.
+- CLI entry point: `cargo run --bin robowbc -- run --config ...`.
+- Generated JSON, Rerun, proof-pack, and site bundle contracts consumed by
+ `scripts/site/generate_policy_showcase.py` and `scripts/site/validate_site_bundle.py`.
diff --git a/docs/agents/domain.md b/docs/agents/domain.md
index b9e83fe..0e02dc5 100644
--- a/docs/agents/domain.md
+++ b/docs/agents/domain.md
@@ -4,8 +4,11 @@ This is a single-context repo.
## Before exploring, read these
+- `README.md` — project overview and first-run paths
+- `STATUS.md` — current state, active queue, and blocked work
+- `ARCHITECTURE.md` — high-level current architecture
- `docs/founding-document.md` — project goals, landscape, design decisions, roadmap
-- `docs/architecture.md` — current architecture
+- `docs/architecture.md` — detailed architecture
- Relevant topic docs under `docs/`, such as `docs/adding-a-model.md`, `docs/adding-a-robot.md`, and `docs/configuration.md`
If `CONTEXT.md`, `CONTEXT-MAP.md`, or `docs/adr/` are added later, read them as higher-specificity domain context.
@@ -16,4 +19,4 @@ Use the project's existing domain language: WBC policies, inference runtime, `Wb
## Flag conflicts
-If a proposal contradicts `docs/founding-document.md`, `docs/architecture.md`, or a future ADR, surface the conflict explicitly.
+If a proposal contradicts `docs/founding-document.md`, `ARCHITECTURE.md`, `docs/architecture.md`, or a future ADR, surface the conflict explicitly.
diff --git a/docs/agents/github-workflow.md b/docs/agents/github-workflow.md
new file mode 100644
index 0000000..379ac3b
--- /dev/null
+++ b/docs/agents/github-workflow.md
@@ -0,0 +1,63 @@
+# GitHub Workflow
+
+Use GitHub MCP tools for GitHub operations. Do not assume the `gh` CLI is
+installed or authenticated.
+
+## Issues And PRDs
+
+- Repository: `MiaoDX/robowbc`.
+- Read issue bodies, comments, and labels before triage or implementation.
+- Use `docs/agents/triage-labels.md` for the five-label vocabulary.
+- When a skill says to publish to the issue tracker, create a GitHub issue.
+
+## PR Review And Fixes
+
+When reviewing a PR:
+
+1. Fetch and check out the PR source branch.
+2. Read the diff and failing CI logs before diagnosing.
+3. Apply fixes directly to the PR source branch unless the user asks for a new
+ branch.
+4. Run the focused and standard verification that covers the change.
+5. Commit and push to the same branch so the existing PR updates.
+
+Do not create a separate PR for review fixes unless the user explicitly wants
+that workflow.
+
+## CI Failure Triage
+
+Always read the actual log output before diagnosing a failure.
+
+Minimum sequence:
+
+1. Identify the failed job and step.
+2. Read the full error output around the failure.
+3. Map the failure to the command in `Makefile` or the workflow file.
+4. Reproduce locally when feasible.
+5. Fix root cause, not only the symptom.
+
+The main workflow uses:
+
+- `make check`
+- `make test`
+- `make sim-feature-test`
+- `make clippy`
+- `make fmt-check`
+- `make rust-doc`
+- `make docs-book`
+- `make showcase-verify`
+- `make python-sdk-verify`
+
+## Commits
+
+This repository uses rebase-only merges. Keep commits scoped and descriptive:
+`docs: ...`, `fix: ...`, `refactor: ...`, and similar.
+
+Codex-authored commits must include:
+
+```text
+Co-authored-by: Codex
+```
+
+If another AI coding agent authors a commit, include the matching co-author
+trailer so agent usage remains auditable.
diff --git a/docs/agents/keyboard-demo.md b/docs/agents/keyboard-demo.md
new file mode 100644
index 0000000..ec5b450
--- /dev/null
+++ b/docs/agents/keyboard-demo.md
@@ -0,0 +1,64 @@
+# Keyboard Demo Guardrail
+
+`make demo-keyboard` is the "git clone and see it work" path. Treat changes to
+that path as user-facing runtime changes.
+
+## Preserve These Behaviors
+
+- `configs/demo/gear_sonic_keyboard_mujoco.toml` uses the GR00T scene wrapper.
+- The demo keeps an explicit `[sim.elastic_band]` support band with the official
+ GR00T point `[0.0, 0.0, 1.0]` and spring-damper gains.
+- `[runtime].require_engage = true` stays enabled.
+- `[runtime].init_pose_secs` stays enabled so `]` engages policy only after the
+ robot settles.
+- The `9` key remains the upstream-style support-band toggle from the terminal
+ and the MuJoCo viewer; the first press after engagement drops the robot toward
+ foot contact.
+- The scene remains visibly lit enough to inspect foot contact in the MuJoCo
+ viewer.
+
+## Targeted Stability Test
+
+For MuJoCo demo changes, run:
+
+```bash
+MUJOCO_DOWNLOAD_DIR="$(pwd)/.cache/mujoco" \
+MUJOCO_DYNAMIC_LINK_DIR="$(pwd)/.cache/mujoco/mujoco-3.6.0/lib" \
+LD_LIBRARY_PATH="$(pwd)/.cache/mujoco/mujoco-3.6.0/lib:${LD_LIBRARY_PATH:-}" \
+cargo test -p robowbc-sim --features mujoco-auto-download \
+ gear_sonic_demo_model_holds_default_pose_for_startup_window
+```
+
+Report the exact blocker if MuJoCo download, dynamic linking, EGL/OpenGL, or
+display access prevents this test from running.
+
+## Speed Monitor
+
+The CLI prints a live `velocity monitor` line for velocity-command runs. The
+line includes `planner_mode` and `planner_target` for GEAR-Sonic, then the
+commanded-vs-actual velocity metrics. Read `actual_vx_sim` as policy tracking
+in simulated time and `actual_vx_wall` plus `rt` as what a human sees in real
+time. If `rt` is well below `1.0x`, the control/viewer loop is running slower
+than real time even if simulated-time tracking is correct.
+
+If `support_band=enabled` while a nonzero planar velocity is commanded, press
+`9` before judging walking speed. The protected demo intentionally starts with
+the fixed GR00T support band enabled, and that band can resist translational
+motion until it is toggled off.
+
+GEAR-Sonic still uses one controller stack for these commands. The live planner
+is conditioned by mode plus `target_vel`, movement direction, and facing
+direction. RoboWBC maps `0.2..0.8 m/s` to slow-walk mode, `0.8..1.5 m/s` to
+walk mode, and `1.5..3.0 m/s` to run mode while passing the requested planar
+speed as `target_vel`.
+
+## Manual Demo Command
+
+The stable public entry point is:
+
+```bash
+make demo-keyboard
+```
+
+It downloads GEAR-Sonic assets, prepares the MuJoCo runtime, starts the viewer,
+and runs keyboard teleop with the checked-in config.
diff --git a/docs/agents/rust-workflow.md b/docs/agents/rust-workflow.md
new file mode 100644
index 0000000..f737602
--- /dev/null
+++ b/docs/agents/rust-workflow.md
@@ -0,0 +1,72 @@
+# Rust Workflow
+
+Use the Makefile when possible; it mirrors the CI entry points and keeps command
+names stable.
+
+## Fresh Environment Preflight
+
+Do not start with tests on a fresh machine. Run:
+
+```bash
+make toolchain
+make build
+make check
+```
+
+Expected Rust version: stable Rust 1.75 or newer.
+
+If the toolchain is missing, install Rust with rustup, then re-run the same
+preflight:
+
+```bash
+curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
+source "$HOME/.cargo/env"
+```
+
+If build or check fails because of system dependencies such as CUDA, MuJoCo,
+protobuf, Python, or OpenGL/EGL libraries, report the exact error and do not
+claim later tests are representative.
+
+## Standard Rust Gates
+
+For Rust changes, run:
+
+```bash
+make test
+make clippy
+make fmt-check
+```
+
+Run `make rust-doc` when public Rust APIs, doc comments, or intra-doc links
+changed.
+
+`make verify` is the combined local Rust gate:
+
+```bash
+make verify
+```
+
+It runs `make check`, `make test`, `make clippy`, and `make fmt-check`.
+
+## Focused Debugging
+
+Use package and test-name filters while debugging:
+
+```bash
+cargo test -p robowbc-core -- validator
+cargo test -p robowbc-ort latency
+```
+
+After focused debugging, run the broader gate that covers the changed surface.
+
+## Documentation Build
+
+Use:
+
+```bash
+make rust-doc
+make docs-book
+```
+
+`make docs` runs both. The mdBook target downloads the pinned mdBook binary into
+`.cache/bin` when needed.
diff --git a/docs/agents/testing.md b/docs/agents/testing.md
new file mode 100644
index 0000000..df434e3
--- /dev/null
+++ b/docs/agents/testing.md
@@ -0,0 +1,85 @@
+# Testing Guidance
+
+Prefer tests that prove caller-visible behavior through public interfaces.
+Avoid tests that only duplicate constants, assert language mechanics, or depend
+on private helper call order.
+
+## Rust
+
+Default Rust gate for code changes:
+
+```bash
+make test
+make clippy
+make fmt-check
+```
+
+Use focused cargo filters while debugging, then run the broader gate:
+
+```bash
+cargo test -p robowbc-core -- test_name
+```
+
+Run `make rust-doc` when public APIs or doc links change.
+
+## Python Scripts And Site Tests
+
+The repository has pytest tests for benchmark and site helper scripts. Run them
+when touching `scripts/`, `tests/`, site generation, benchmark normalization, or
+RoboHarness report code:
+
+```bash
+make python-test
+```
+
+If a Python dependency is missing, report it and prefer the documented Makefile
+target for that workflow before installing ad hoc packages.
+
+## Site And Showcase
+
+For static bundle or showcase changes:
+
+```bash
+make site-smoke
+```
+
+For CI-equivalent showcase verification:
+
+```bash
+make showcase-verify
+```
+
+`make showcase-verify` downloads public checkpoints and requires a working
+headless MuJoCo EGL stack.
+
+## Python SDK
+
+For `crates/robowbc-py`, `crates/robowbc-pyo3`, or SDK examples:
+
+```bash
+make python-sdk-verify
+```
+
+This builds, installs, and smoke-tests the local wheel.
+
+## MuJoCo
+
+For MuJoCo transport or demo startup changes:
+
+```bash
+make sim-feature-test
+```
+
+For keyboard demo stability specifically, use `docs/agents/keyboard-demo.md`.
+
+## Benchmarks
+
+Run benchmark commands only when latency, benchmark generation, or comparison
+artifacts changed:
+
+```bash
+make benchmark-nvidia
+```
+
+Do not add generated benchmark output unless it is an intentionally tracked
+artifact.
diff --git a/docs/architecture.md b/docs/architecture.md
index b1467fb..5419831 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -1,165 +1,189 @@
# Architecture
+This document is the detailed human-facing architecture map. For a shorter root
+overview, read `../ARCHITECTURE.md`.
+
## Overview

-The diagram below reflects the current crate split, policy backends,
-transport loop, and reporting path used by the CLI and the published HTML
-report.
+RoboWBC centers on one policy contract:
+```text
+Observation + WbcCommand
+ |
+ v
+WbcPolicy::predict
+ |
+ v
+JointPositionTargets
```
-┌─────────────────────────────────────────────────┐
-│ robowbc-cli — config loading → control loop │
-└────────────────────────┬────────────────────────┘
- │ WbcPolicy::predict()
- ┌─────────────┼─────────────┐
- │ │ │
- ┌───────▼──────┐ ┌────▼────┐ ┌─────▼──────┐
- │robowbc-ort │ │robowbc- │ │ (future) │
- │ONNX Runtime │ │pyo3 │ │ burn / … │
- │CUDA/TensorRT │ │PyTorch │ │ │
- └──────────────┘ └─────────┘ └────────────┘
- │
- ┌───────▼───────┐ ┌──────────────────────┐
- │robowbc-core │ │ robowbc-registry │
- │WbcPolicy trait│ │ inventory factory │
- │Observation │ │ WbcRegistry::build() │
- │WbcCommand │ └──────────────────────┘
- │JointPositions │
- └───────────────┘
- │
- ┌───────▼───────┐
- │robowbc-comm │
- │zenoh transport│
- │control loop │
- └───────────────┘
+
+Configs and SDK calls decide which policy, robot, transport, visualization, and
+reporting path surrounds that contract. The CLI and Python SDK both exercise
+the same Rust policy implementations.
+
+## Runtime Data Flow
+
+```text
+TOML config
+ |
+ v
+robowbc-config
+ |
+ v
+robowbc-registry
+ |
+ +--> robowbc-ort policy wrappers
+ | gear_sonic, decoupled_wbc, wbc_agile, bfm_zero, hover, wholebody_vla
+ |
+ +--> robowbc-pyo3 py_model wrapper
+ |
+ v
+robowbc-runtime / robowbc-cli
+ |
+ +--> hardware transport through robowbc-comm or robowbc-transport
+ +--> MuJoCo transport through robowbc-sim
+ +--> synthetic transport for smoke and reporting
+ |
+ v
+JSON report, replay trace, Rerun recording, generated static site
```
-## Crate responsibilities
+Transport priority in the CLI is hardware when configured, then MuJoCo when the
+feature and config are present, then synthetic execution.
+
+## Crate Responsibilities
| Crate | Responsibility |
-|-------|---------------|
-| `robowbc-core` | `WbcPolicy` trait, `Observation`, `WbcCommand`, `JointPositionTargets`, `RobotConfig` |
-| `robowbc-ort` | ONNX Runtime inference backend (ort crate, CUDA/TensorRT support) |
-| `robowbc-pyo3` | PyTorch inference backend via PyO3 (Python GIL-aware) |
-| `robowbc-registry` | `inventory`-based compile-time policy registration and factory |
-| `robowbc-comm` | zenoh communication layer, control loop tick runner |
-| `robowbc-cli` | CLI entry point: config parsing, backend selection, control loop |
-| `robowbc-sim` | MuJoCo simulation transport for hardware-free testing |
-| `robowbc-vis` | Rerun visualization integration |
-
-## The `WbcPolicy` trait
-
-```rust
-pub trait WbcPolicy: Send + Sync {
- fn predict(&self, obs: &Observation) -> Result;
- fn reset(&self) {}
- fn control_frequency_hz(&self) -> u32;
- fn supported_robots(&self) -> &[RobotConfig];
-}
-```
+|-------|----------------|
+| `robowbc-core` | Core types: `WbcPolicy`, `Observation`, `WbcCommand`, `JointPositionTargets`, `RobotConfig`, `PolicyCapabilities`, and target validation |
+| `robowbc-config` | TOML config structures, defaults, robot/policy path resolution, and config validation |
+| `robowbc-registry` | `inventory`-based policy registration and construction from config |
+| `robowbc-ort` | ONNX Runtime backend, execution-provider config, and first-party policy wrappers |
+| `robowbc-pyo3` | Runtime backend for user-supplied Python or PyTorch policy modules |
+| `robowbc-py` | Standalone Python SDK exposing registry, policy, observation, command, target, and MuJoCo session types |
+| `robowbc-runtime` | Finite-state runtime coordination around policy execution, validator decisions, teleop requests, and control outputs |
+| `robowbc-comm` | Communication-oriented control-loop helpers, wire helpers, zenoh helpers, and Unitree G1 wiring |
+| `robowbc-transport` | Pluggable transport trait plus in-memory and CycloneDDS-oriented backends |
+| `robowbc-sim` | MuJoCo config and transport for hardware-free execution |
+| `robowbc-teleop` | Keyboard teleop source, keymap config, and teleop events |
+| `robowbc-vis` | Rerun visualizer and robot scene logging |
+| `robowbc-cli` | `robowbc` binary, config validation, policy runs, policy helpers, reports, teleop, and visualization wiring |
+| `unitree-hg-idl` | CDR serialization and CRC helpers for Unitree HG message families |
+
+## Core Policy Contract
+
+`WbcPolicy` is implemented by every policy backend. Policies are `Send + Sync`
+so they can be called from runtime loops. Implementations are responsible for
+serializing access to any backend state that is not internally thread-safe.
+
+Important caller-facing types:
+
+- `Observation`: proprioception, gravity vector, command, and timestamp.
+- `WbcCommand`: velocity, motion tokens, joint targets, kinematic pose, and
+ other command variants.
+- `JointPositionTargets`: output joint targets plus timestamp.
+- `PolicyCapabilities`: supported command kinds and declared limits.
+- `RobotConfig`: joint names, default pose, gains, and limits.
+
+Policies should reject unsupported command variants explicitly. A wrapper that
+cannot serve a public checkpoint should say so clearly instead of pretending to
+be runnable.
+
+## Registry And Model Switching
+
+Policies register themselves at compile time through `inventory`. Runtime model
+selection is config-driven:
-`Send + Sync` is required because the control loop runs the policy from a
-dedicated thread. `predict` takes a shared reference — concurrency protection
-(typically a `Mutex` around the ONNX session) lives inside the implementation.
-
-## Observation and command types
-
-```rust
-pub struct Observation {
- pub joint_positions: Vec, // radians
- pub joint_velocities: Vec, // rad/s
- pub gravity_vector: [f32; 3], // in robot body frame
- pub command: WbcCommand,
- pub timestamp: Instant,
-}
-
-pub enum WbcCommand {
- Velocity(Twist), // vx, vy, yaw_rate
- EndEffectorPoses(Vec), // hand/wrist SE3 targets
- MotionTokens(Vec), // GEAR-SONIC style tokens
- JointTargets(Vec), // direct joint targets
- KinematicPose(BodyPose), // full-body kinematic pose
-}
+```toml
+[policy]
+name = "decoupled_wbc"
```
-Policies declare which `WbcCommand` variant they accept and return
-`WbcError::UnsupportedCommand` for anything else.
+The registry name is the public switch. Current registry names include:
-## Policy registry
+| Name | State |
+|------|-------|
+| `gear_sonic` | Live public G1 planner velocity path and narrower tracking paths |
+| `decoupled_wbc` | Live public G1 balance and walk paths |
+| `wbc_agile` | Live public G1 recurrent checkpoint path |
+| `bfm_zero` | Live public G1 policy plus tracking context bundle |
+| `hover` | Wrapper present, blocked on user-exported checkpoint |
+| `wholebody_vla` | Experimental contract wrapper, no runnable public release |
+| `py_model` | User-supplied Python or PyTorch policy |
-Policies register themselves at compile time using the `inventory` crate:
+All registered policy crates must be linked into the final binary. Dynamic
+loading does not make `inventory` registrations appear.
-```rust
-inventory::submit! {
- WbcRegistration::new::("my_policy")
-}
-```
+## Configuration Layers
-The CLI then builds any registered policy by name:
+Configs are split by purpose:
-```rust
-let policy = WbcRegistry::build("my_policy", &config_toml_value)?;
-```
+- top-level policy configs under `configs/*.toml`
+- robot configs under `configs/robots/*.toml`
+- showcase configs under `configs/showcase/*.toml`
+- demo and teleop configs under `configs/demo/` and `configs/teleop/`
-`WbcRegistry::build` iterates `inventory::iter::()` at
-runtime and calls the matching `RegistryPolicy::from_config` constructor.
-The policy name is the same string used in `[policy] name = "..."` in the
-TOML config.
+The smoke config, `configs/decoupled_smoke.toml`, uses a checked-in dynamic
+identity ONNX fixture and is the no-download local path.
-> **Gotcha**: All registered types must be in crates linked into the final
-> binary. `inventory` uses linker sections — dynamic loading is not supported.
+## Inference Backends
-## Config-driven instantiation
+### ONNX Runtime
-The CLI reads a TOML file, extracts `[policy.config]`, and passes it to the
-registry. Switching models means changing `policy.name` in the TOML:
+`robowbc-ort` wraps ONNX Runtime and supports CPU, CUDA, and TensorRT execution
+providers when the host runtime matches. The checked-in public configs default
+to CPU unless the user opts into accelerator-specific settings.
-```toml
-[policy]
-name = "decoupled_wbc" # change this to switch policies
+### PyO3 / Python
-[policy.config.rl_model]
-model_path = "models/decoupled-wbc/GR00T-WholeBodyControl-Walk.onnx"
-execution_provider = { type = "cpu" }
-```
+`robowbc-pyo3` loads user-supplied Python modules and calls them through PyO3.
+`robowbc-py` is the user-facing Python SDK package and exposes Rust-backed
+policies, observations, commands, targets, capabilities, and `MujocoSession`.
-## Inference backends
+## Runtime, Transport, And Teleop
-### ONNX Runtime (`robowbc-ort`)
+The runtime builds observations from transport state, applies the selected
+command source, calls the policy, validates targets, and sends the result toward
+the active transport.
-Wraps the [`ort`](https://github.com/pykeio/ort) crate. Each policy holds a
-`Mutex` to serialize session execution across threads.
+Supported execution modes include:
-Supported execution providers:
-- `{ type = "cpu" }` — always available
-- `{ type = "cuda", device_id = 0 }` — NVIDIA GPU
-- `{ type = "tensor_rt", device_id = 0 }` — NVIDIA TensorRT (requires matching toolkit)
+- hardware-oriented Unitree G1 communication paths
+- CycloneDDS-oriented transport work
+- MuJoCo-backed transport for local simulation
+- synthetic transport for smoke runs and report generation
+- keyboard teleop for the protected demo path
-### PyO3 / PyTorch (`robowbc-pyo3`)
+`make demo-keyboard` is the public interactive MuJoCo path and has additional
+guardrails in `docs/agents/keyboard-demo.md`.
-Loads a Python module containing a `predict(obs) -> targets` function. The
-GIL is acquired per-call. Intended for development and non-exported models.
+## Reports And Showcase
-## Control loop
+The CLI can write runtime reports, replay traces, and Rerun recordings. Python
+scripts under `scripts/` normalize benchmark output, generate policy showcase
+pages, validate static site bundles, and build the published GitHub Pages site.
-```
-tick N:
- 1. recv joint_state + imu (zenoh / MuJoCo / synthetic)
- 2. build Observation
- 3. policy.predict(&obs) ← your model runs here
- 4. send JointPositionTargets
- 5. sleep until next tick
-```
+The public site and proof-pack artifacts are evidence surfaces. They should
+remain tied to reproducible configs and generated machine-readable output.
+
+## Error Handling
+
+- Library crates use typed errors where callers need to distinguish failure
+ modes.
+- CLI paths add user-facing context to failed config, model, transport, and
+ report operations.
+- Missing model assets, unsupported commands, unsupported execution providers,
+ and platform limitations should fail explicitly.
+
+## Extension Points
-`run_control_tick` in `robowbc-comm` handles steps 1, 2, 4, and 5. Step 3
-is the policy call. The loop runs at `control_frequency_hz` (typically 50 Hz).
+To add a policy, implement the core policy contract, register the wrapper, add a
+config, and provide a smoke or blocked-state proof. See `docs/adding-a-model.md`.
-## Error handling
+To add a robot, define the robot config, joint order, limits, gains, and any
+transport or visualization mapping. See `docs/adding-a-robot.md`.
-- Library crates (`robowbc-core`, `robowbc-ort`, …) use `thiserror`.
-- The CLI (`robowbc-cli`) uses `anyhow` for rich error context.
-- `WbcPolicy::predict` returns `Result`.
- A control loop receiving `WbcError` should log it and continue rather than
- crash — hardware safety layers (PD controllers) handle the gap.
+To add a public report path, keep the JSON/report contract machine-readable and
+update the site validation tests that consume it.
diff --git a/docs/benchmarks/README.md b/docs/benchmarks/README.md
index 5c012e0..2fb1603 100644
--- a/docs/benchmarks/README.md
+++ b/docs/benchmarks/README.md
@@ -10,8 +10,8 @@ The source of truth lives under:
- `artifacts/benchmarks/nvidia/SUMMARY.md`
- HTML summary generated either locally at `artifacts/benchmarks/nvidia/index.html`
or in CI / Pages at `benchmarks/nvidia/index.html`
-- `scripts/bench_robowbc_compare.py`
-- `scripts/bench_nvidia_official.py`
+- `scripts/benchmarks/bench_robowbc_compare.py`
+- `scripts/benchmarks/bench_nvidia_official.py`
The GEAR-Sonic package now answers three distinct latency questions instead of
hiding them behind the old ambiguous `replan_tick` split:
@@ -92,8 +92,8 @@ Blocked GPU rows are expected and honest when any of the following is missing:
```bash
git submodule update --init --recursive third_party/GR00T-WholeBodyControl
-bash scripts/download_gear_sonic_models.sh
-bash scripts/download_decoupled_wbc_models.sh
+bash scripts/models/download_gear_sonic_models.sh
+bash scripts/models/download_decoupled_wbc_models.sh
```
The comparison uses a tracked git submodule checkout of
@@ -105,7 +105,7 @@ beside the downloaded assets so later artifact runs can record provenance.
```bash
for provider in cpu cuda tensor_rt; do
- python3 scripts/bench_robowbc_compare.py --all --provider "$provider"
+ python3 scripts/benchmarks/bench_robowbc_compare.py --all --provider "$provider"
done
```
@@ -123,7 +123,7 @@ matches a CUDA or TensorRT request.
```bash
for provider in cpu cuda tensor_rt; do
- python3 scripts/bench_nvidia_official.py --all --provider "$provider"
+ python3 scripts/benchmarks/bench_nvidia_official.py --all --provider "$provider"
done
```
@@ -138,13 +138,13 @@ a blocked artifact with the exact missing-EP or initialization error.
### 4. Render the summary
```bash
-python3 scripts/render_nvidia_benchmark_summary.py --output artifacts/benchmarks/nvidia/SUMMARY.md
+python3 scripts/benchmarks/render_nvidia_benchmark_summary.py --output artifacts/benchmarks/nvidia/SUMMARY.md
```
In CI, the showcase job also renders the static HTML page:
```bash
-python3 scripts/render_nvidia_benchmark_summary.py \
+python3 scripts/benchmarks/render_nvidia_benchmark_summary.py \
--root /tmp/policy-showcase/benchmarks/nvidia \
--output /tmp/policy-showcase/benchmarks/nvidia/SUMMARY.md \
--html-output /tmp/policy-showcase/benchmarks/nvidia/index.html
diff --git a/docs/community/groot-wbc-integration.md b/docs/community/groot-wbc-integration.md
index 55bd0b1..4473698 100644
--- a/docs/community/groot-wbc-integration.md
+++ b/docs/community/groot-wbc-integration.md
@@ -118,7 +118,7 @@ It is not an NVIDIA product.
```bash
git clone https://github.com/MiaoDX/robowbc
cd robowbc
-bash scripts/download_gear_sonic_models.sh
+bash scripts/models/download_gear_sonic_models.sh
# Downloads model_encoder.onnx, model_decoder.onnx, planner_sonic.onnx
# into models/gear-sonic/
```
@@ -177,9 +177,9 @@ Run the artifact-backed comparison suite:
```bash
git submodule update --init --recursive third_party/GR00T-WholeBodyControl
-python3 scripts/bench_robowbc_compare.py --all
-python3 scripts/bench_nvidia_official.py --all
-python3 scripts/render_nvidia_benchmark_summary.py --output artifacts/benchmarks/nvidia/SUMMARY.md
+python3 scripts/benchmarks/bench_robowbc_compare.py --all
+python3 scripts/benchmarks/bench_nvidia_official.py --all
+python3 scripts/benchmarks/render_nvidia_benchmark_summary.py --output artifacts/benchmarks/nvidia/SUMMARY.md
```
See the benchmark registry in `artifacts/benchmarks/nvidia/cases.json` and the
diff --git a/docs/configuration.md b/docs/configuration.md
index 49f96e3..e94fc91 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -250,7 +250,7 @@ GR00T's G1 simulator behavior.
enabled = true
body_name = "pelvis"
anchor = [0.0, 0.0, 1.0]
-anchor_from_initial_pose = true
+anchor_from_initial_pose = false
kp_pos = 10000.0
kd_pos = 1000.0
kp_ang = 1000.0
@@ -258,9 +258,10 @@ kd_ang = 10.0
```
Omit the table to run without the support band. Set
-`anchor_from_initial_pose = true` for local teleop demos where the support band
-should catch a falling humanoid without lifting it away from its initialized
-standing height.
+`anchor_from_initial_pose = false` with `anchor = [0.0, 0.0, 1.0]` to match the
+official GR00T MuJoCo support-band setup. `anchor_from_initial_pose = true` is
+available for custom local demos that need the band anchored at the model's
+initialized body position instead.
### `[report]`
diff --git a/docs/getting-started.md b/docs/getting-started.md
index 4d8b0bf..33e358f 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -66,12 +66,17 @@ robowbc run --config configs/demo/gear_sonic_keyboard_mujoco.toml --teleop keybo
```
Keep the terminal focused for keyboard input and watch the MuJoCo window:
-`]` engages the policy after the init pose settles, `WASD` changes linear
-velocity, `QE` changes yaw, `Space` zeroes velocity, `9` toggles the support
-band, `O` sends a zero-velocity emergency-stop tick, and `Esc` quits. The demo
-config uses the GR00T scene wrapper and a neutral-height virtual support band
-so the robot stays recoverable without being lifted off the ground. It also
-uses `[sim].viewer = true`, so it requires a Linux desktop/OpenGL session. For
+`]` queues policy engagement after the 3.0s init-pose settle window, `WASD`
+changes linear velocity, `QE` changes yaw, `Space` zeroes velocity, and
+`O` sends a zero-velocity emergency-stop tick. After engagement, press `9`
+promptly in either the terminal or MuJoCo viewer if the support band is holding
+the robot high so it drops to foot contact; pressing `9` again toggles the band
+back on. `Esc`/`Ctrl-C` quits. WASD/QE velocity commands are held until
+engagement; if the command is still zero after engagement, the demo keeps
+holding the default standing pose until the first nonzero velocity command. The
+demo config uses the GR00T scene wrapper and the official MuJoCo support-band
+point/gains. It also uses
+`[sim].viewer = true`, so it requires a Linux desktop/OpenGL session. For
headless machines, use `make site` or `make showcase-verify` instead.
## Generate a local policy showcase
@@ -129,7 +134,7 @@ artifacts and on the `main`-branch GitHub Pages site.
### Step 1 — download the models
```bash
-bash scripts/download_gear_sonic_models.sh
+bash scripts/models/download_gear_sonic_models.sh
# Downloads model_encoder.onnx, model_decoder.onnx, planner_sonic.onnx
# into models/gear-sonic/ and reuses cached files when already present.
```
@@ -156,7 +161,7 @@ a machine with matching ONNX Runtime and NVIDIA runtime dependencies.
### Step 1 — download and normalize the assets
```bash
-bash scripts/download_bfm_zero_models.sh
+bash scripts/models/download_bfm_zero_models.sh
# Fetches the public ONNX + tracking context bundle into models/bfm_zero/
# and converts zs_walking.pkl into zs_walking.npy for the Rust runtime.
```
diff --git a/docs/human/README.md b/docs/human/README.md
new file mode 100644
index 0000000..db9f9ea
--- /dev/null
+++ b/docs/human/README.md
@@ -0,0 +1,14 @@
+# Human Documentation
+
+This folder is the extension point for curated human-facing docs. The root files
+remain the primary entry points:
+
+- `README.md` - what RoboWBC is and how to try it.
+- `STATUS.md` - what works now, what is blocked, and what is next.
+- `ARCHITECTURE.md` - high-level system map.
+- `ROADMAP.md` - active work ordering.
+- `docs/README.md` - full documentation index.
+
+Generated plans, historical evidence, and agent working notes belong in
+`.planning/`, `docs/agents/`, or other process-specific folders rather than in
+this human surface.
diff --git a/docs/plans/refactor-book.md b/docs/plans/refactor-book.md
new file mode 100644
index 0000000..e5beb47
--- /dev/null
+++ b/docs/plans/refactor-book.md
@@ -0,0 +1,55 @@
+---
+refactor_scope: book
+status: DONE
+accepted_severities:
+ - P1
+last_verified: 2026-05-14
+---
+
+# Refactor Scope: Book
+
+## Status
+
+DONE
+
+## Target
+
+Make the mdBook navigation match the current human documentation surface without
+promoting agent runbooks, planning notes, or generated evidence into the book.
+
+## Accepted Severities
+
+P1 only. The active issue is a source-of-truth/navigation gap: useful human
+pages existed under `docs/` but were omitted or grouped around file history
+rather than reader tasks.
+
+## Accepted P0/P1 Checklist
+
+- [x] Group `docs/SUMMARY.md` by reader task: start, architecture/runtime,
+ extension, operations/evidence, direction, research, and community.
+- [x] Include the human documentation map in the book.
+- [x] Include existing durable reference pages that were omitted from the book.
+- [x] Keep agent-only docs and process state out of mdBook navigation.
+
+## Parked P2 / Future Ideas
+
+- Decide later whether root `STATUS.md` should have a book-local companion page
+ or remain a root orientation surface only.
+- Decide later whether community drafts should move behind a separate community
+ index once those pages grow.
+
+## Evidence Ladder
+
+- L0: Build the mdBook with `make docs-book`.
+- L0: Inspect the changed book navigation and confirm it only references files
+ under `docs/`.
+
+## Stop Condition
+
+Stop when `docs/SUMMARY.md` is organized by reader task, the omitted durable
+human pages are listed, and `make docs-book` passes.
+
+## Execution Log
+
+- 2026-05-14: Refactored `docs/SUMMARY.md` into task-oriented sections and
+ recorded this gate. Verified with `make docs-book`.
diff --git a/docs/plans/refactor-keyboard-speed-monitor.md b/docs/plans/refactor-keyboard-speed-monitor.md
new file mode 100644
index 0000000..d4a84d4
--- /dev/null
+++ b/docs/plans/refactor-keyboard-speed-monitor.md
@@ -0,0 +1,100 @@
+---
+refactor_scope: keyboard-speed-monitor
+status: DONE
+accepted_severities:
+ - P0
+ - P1
+ - P2
+last_verified: 2026-05-15
+---
+
+# Refactor Scope: Keyboard Speed Monitor
+
+## Status
+
+DONE
+
+## Target
+
+Keyboard-demo command-to-MuJoCo speed observability in `robowbc-cli`, with the
+small MuJoCo telemetry hook in `robowbc-sim` needed to expose support-band state.
+
+## Accepted Severities
+
+P0/P1/P2 inside this target.
+
+## Accepted Cleanup Checklist
+
+- [x] P1: Velocity tracking must be computed for normal `make demo-keyboard`
+ style runs without requiring `[report]` frame capture.
+- [x] P1: Metrics must distinguish simulated-time tracking from wall-time
+ playback speed so slow visual movement can be separated from policy
+ under-tracking or control-loop overruns.
+- [x] P1: The live monitor and report/replay frames must show the inferred
+ GEAR-Sonic planner mode for velocity-command runs.
+- [x] P1: The monitor must surface elastic support band state because the
+ protected demo starts with a fixed support band that can resist nonzero
+ translational commands until toggled off.
+- [x] P1: A 0.6 m/s GEAR-Sonic command must not be flattened to the fixed
+ 0.3 m/s slow-walk planner target.
+- [x] P2: Add focused tests for the no-report monitor path and timebase math.
+- [x] P2: Preserve the protected demo guardrails: GR00T scene wrapper, support
+ band, engage guard, init-pose settle window, and viewer lighting.
+
+## Parked Cross-Seam / Future Ideas
+
+- Policy tuning or changing the official commanded speed ranges.
+- Changing the protected demo support-band default.
+- Broader MuJoCo/viewer threading architecture changes.
+- Hardware-facing Unitree timing metrics.
+
+## Evidence Ladder
+
+- L0: `make toolchain`, `make build`, `make check`, `make fmt-check`.
+- L1: focused Rust unit tests for CLI velocity monitor behavior.
+- L4: `docs/agents/keyboard-demo.md` targeted MuJoCo startup guardrail when the
+ local MuJoCo runtime and dynamic linker paths are available.
+
+## Stop Condition
+
+Stop when the accepted checklist is complete, the focused tests pass, standard
+Rust gates that can run in this environment have results recorded here, and any
+MuJoCo/OpenGL/model-download gate that cannot run has an exact blocker recorded.
+
+## Execution Log
+
+- 2026-05-15: Scope opened from manual report that keyboard-driven G1 movement
+ appears slow even at 0.6 m/s. Initial code reading found existing velocity
+ tracking depends on replay frames, but replay frames are only collected when
+ `[report]` is configured, so the protected interactive demo has no normal
+ commanded-vs-achieved speed signal.
+- 2026-05-15: Added streaming velocity monitor output in `robowbc-cli` with
+ simulated-time velocity, wall-time velocity, real-time factor, RMSE, and
+ MuJoCo support-band state. Added focused tests for recorded simulator time
+ and no-report monitor accumulation.
+- 2026-05-15: Reproduced protected-scene slow behavior. With support band
+ enabled, a 0.6 m/s command stayed near zero actual forward speed while
+ real-time factor stayed near 1.0x. With support band disabled before the
+ planner fix, the same 0.6 m/s command averaged about 0.17 m/s over 500 ticks.
+- 2026-05-15: Root cause found in GEAR-Sonic command mapping: nonzero commands
+ below 0.8 m/s entered slow-walk mode, but RoboWBC replaced the requested
+ speed with a fixed direction-bin slow-walk target such as 0.3 m/s for forward
+ motion. Aligned with NVIDIA's planner contract by keeping 0.6 m/s in
+ slow-walk mode while passing `target_vel = 0.6`, and by mapping 0.8..1.5 m/s
+ to walk mode and 1.5..3.0 m/s to run mode. This supersedes the earlier local
+ experiment that lowered the slow-walk cutoff.
+- 2026-05-15: Current evidence passed: `make toolchain`, `make build`,
+ `make check`, `cargo test -p robowbc-ort gear_sonic_planner_command`,
+ `make test`, `make clippy`, and `make fmt-check`.
+- 2026-05-15: Added GEAR-Sonic planner status to live velocity monitor output
+ and report/replay frames. The status is mode-id/name plus planner target
+ velocity for velocity-command runs; velocity tracking remains a separate
+ measured behavior signal.
+- 2026-05-15: Local MuJoCo speed samples on the protected GEAR-Sonic scene:
+ with support band disabled, a 0.6 m/s command over 500 ticks produced
+ `actual_vx_mean=0.511 m/s`, `actual_vx_wall_mean=0.508 m/s`, and
+ `real_time_factor=0.99x`; with support band enabled, a 0.6 m/s command over
+ 200 ticks produced `actual_vx_mean=0.002 m/s`,
+ `actual_vx_wall_mean=0.002 m/s`, and `real_time_factor=0.98x`.
+- 2026-05-15: Earlier protected MuJoCo startup guardrail passed:
+ `MUJOCO_DOWNLOAD_DIR="$(pwd)/.cache/mujoco" MUJOCO_DYNAMIC_LINK_DIR="$(pwd)/.cache/mujoco/mujoco-3.6.0/lib" LD_LIBRARY_PATH="$(pwd)/.cache/mujoco/mujoco-3.6.0/lib:${LD_LIBRARY_PATH:-}" cargo test -p robowbc-sim --features mujoco-auto-download gear_sonic_demo_model_holds_default_pose_for_startup_window`.
diff --git a/docs/plans/refactor-readme.md b/docs/plans/refactor-readme.md
new file mode 100644
index 0000000..6c483ef
--- /dev/null
+++ b/docs/plans/refactor-readme.md
@@ -0,0 +1,63 @@
+---
+refactor_scope: readme
+status: DONE
+accepted_severities:
+ - P1
+last_verified: 2026-05-14
+---
+
+# Refactor Scope: README
+
+## Status
+
+DONE
+
+## Target
+
+Root `README.md` first-run overview and navigation surface.
+
+## Accepted Severities
+
+P1 only. The root README is a primary source-of-truth and onboarding surface, so
+this pass treats excessive length and buried first-run paths as a documentation
+usability gap. Lower-priority documentation architecture ideas are parked.
+
+## Accepted P0/P1 Checklist
+
+- Reduce `README.md` to 150 lines or fewer.
+- Preserve the project purpose: Linux-first humanoid WBC policy inference with
+ Python SDK and Rust runtime surfaces.
+- Preserve the protected `make demo-keyboard` clone-and-see-it-work path.
+- Preserve quick local validation commands and point detailed validation to docs.
+- Preserve current live/blocked policy status.
+- Preserve public report links, architecture pointers, documentation index, and
+ license/third-party notice pointers.
+
+## Parked P2 / Future Ideas
+
+- Rework the full documentation information architecture.
+- Split each workflow into shorter task-specific pages.
+- Add a generated README table from package metadata or policy manifests.
+
+## Evidence Ladder
+
+Minimum required confidence: L0 static.
+
+- `wc -l README.md` must report 150 lines or fewer.
+- A manual content scan must confirm the accepted checklist remains visible.
+
+## Stop Condition
+
+Stop when `README.md` is 150 lines or fewer, accepted P0/P1 checklist items are
+present, and this gate is updated to `DONE`. Park P2 ideas instead of expanding
+this cleanup.
+
+## Execution Log
+
+- 2026-05-14: Opened scope gate for README line-count cleanup.
+- 2026-05-14: Condensed `README.md` from 314 lines to 150 lines while
+ preserving project purpose, first-run commands, protected demo path, policy
+ status, public reports, Python/Rust entry points, docs links, and license
+ pointers.
+- 2026-05-14: Verified L0 evidence with `wc -l README.md` and required-anchor
+ scan using `rg`.
diff --git a/docs/plans/refactor-scripts.md b/docs/plans/refactor-scripts.md
new file mode 100644
index 0000000..750137b
--- /dev/null
+++ b/docs/plans/refactor-scripts.md
@@ -0,0 +1,61 @@
+---
+refactor_scope: scripts
+status: DONE
+accepted_severities:
+ - P1
+ - P2
+last_verified: 2026-05-14
+---
+
+# Refactor Scope: Scripts
+
+## Status
+
+DONE
+
+## Target
+
+Make the post-move script layout easier to maintain and discover while keeping
+all public root-level script paths working.
+
+## Accepted Severities
+
+- P1: Preserve documented script paths and Makefile targets after the workflow
+ folder split.
+- P2: Reduce wrapper duplication and make the canonical script folders easier
+ for humans and agents to navigate.
+
+## Accepted P0/P1 Checklist
+
+- [x] Keep existing root-level script entrypoints as compatibility wrappers.
+- [x] Document canonical workflow folders and wrapper policy.
+- [x] Add the scripts map to the mdBook navigation.
+
+## Parked P2 / Future Ideas
+
+- Add per-folder script READMEs if individual workflow folders grow beyond the
+ current single-screen map.
+- Consider a dedicated script contract test if root compatibility wrappers gain
+ argument rewriting or environment setup logic later.
+
+## Evidence Ladder
+
+- L0: Compile Python scripts with `python3 -m py_compile`.
+- L0: Syntax-check shell scripts with `bash -n`.
+- L0: Build mdBook with `make docs-book`.
+- L1/L2: Run `make python-test` because integration tests exercise script
+ contracts and report outputs.
+
+## Stop Condition
+
+Stop when compatibility wrappers still dispatch to canonical workflow folders,
+the script layout is documented in both the repo script index and mdBook, and
+the focused script/docs verification commands pass.
+
+## Execution Log
+
+- 2026-05-14: Added shared Python compatibility dispatch, updated root Python
+ wrappers, documented script workflow folders, and added the mdBook script map.
+ Verified with `python3 -m py_compile scripts/*.py scripts/*/*.py`,
+ `bash -n scripts/*.sh scripts/*/*.sh`, `make docs-book`,
+ `make python-test`, and representative root wrapper `--help` smoke checks.
diff --git a/docs/roboharness-integration.md b/docs/roboharness-integration.md
index 50fbdc6..eabee19 100644
--- a/docs/roboharness-integration.md
+++ b/docs/roboharness-integration.md
@@ -40,7 +40,7 @@ motion.
## Running a proof pack
```bash
-python3 scripts/roboharness_report.py \
+python3 scripts/reports/roboharness_report.py \
--robowbc-binary target/release/robowbc \
--config configs/sonic_g1.toml \
--output-dir artifacts/roboharness-reports/sonic_g1 \
@@ -128,10 +128,10 @@ The authority chain is intentionally one-way:
semantic phases once in the authored config
2. the Rust CLI emits that data as authoritative `phase_timeline` metadata in
the run artifacts
-3. `scripts/roboharness_report.py` copies the contract into
+3. `scripts/reports/roboharness_report.py` copies the contract into
`proof_pack_manifest.json`
-4. `scripts/generate_policy_showcase.py` and
- `scripts/validate_site_bundle.py` consume only the manifest contract
+4. `scripts/site/generate_policy_showcase.py` and
+ `scripts/site/validate_site_bundle.py` consume only the manifest contract
When `phase_review.enabled = true`, the manifest adds these fields:
@@ -231,7 +231,7 @@ outside `0..5`.
## Replay behavior
-`scripts/roboharness_report.py` now prefers the canonical replay trace when it
+`scripts/reports/roboharness_report.py` now prefers the canonical replay trace when it
exists and only falls back to `run_report.json.frames` for backward
compatibility with older CLI artifacts.
@@ -248,7 +248,7 @@ For MuJoCo replay:
The showcase and benchmark pages remain overview surfaces. Proof packs are the
drill-down layer.
-`scripts/generate_policy_showcase.py` can render optional proof-pack links when
+`scripts/site/generate_policy_showcase.py` can render optional proof-pack links when
a proof-pack artifact is co-located beside the showcase output. The simplest
convention is to copy the proof-pack directory next to the showcase as:
diff --git a/docs/scripts.md b/docs/scripts.md
new file mode 100644
index 0000000..6f661a1
--- /dev/null
+++ b/docs/scripts.md
@@ -0,0 +1,52 @@
+# Maintainer Scripts
+
+Prefer Makefile targets for stable workflows. Use direct script calls when you
+are changing a script, debugging its arguments, or reproducing a lower-level CI
+step.
+
+## Layout
+
+Canonical script implementations live in workflow folders under `scripts/`.
+Root-level scripts remain compatibility entrypoints for older commands and
+public references.
+
+| Folder | Purpose | Stable Make targets |
+|--------|---------|---------------------|
+| `scripts/models/` | Download and normalize public policy assets | `make models-public` |
+| `scripts/mujoco/` | Ensure and validate the MuJoCo runtime | `make sim-feature-test`, `make site-render-check`, `make demo-keyboard` |
+| `scripts/benchmarks/` | Generate and render NVIDIA comparison artifacts | `make benchmark-nvidia` |
+| `scripts/site/` | Build, validate, serve, and smoke-test the static policy site | `make site`, `make site-smoke`, `make site-serve` |
+| `scripts/reports/` | Produce RoboWBC proof-pack report contracts | Direct script calls while debugging reports |
+| `scripts/sdk/` | Smoke-test the installed Python SDK | `make python-sdk-verify` |
+
+## Compatibility Policy
+
+- New implementation logic belongs in the workflow folders.
+- Existing root-level `scripts/*.py` and `scripts/*.sh` paths stay as
+ compatibility entrypoints.
+- Documentation and Makefile recipes should prefer the workflow folders unless
+ they are intentionally showing a legacy-compatible command.
+- Downloaded model files, benchmark output, site bundles, MuJoCo runtimes, and
+ Python caches should stay untracked.
+
+## Common Direct Calls
+
+```bash
+python3 scripts/site/build_site.py --repo-root . --output-dir /tmp/robowbc-site
+python3 scripts/site/validate_site_bundle.py --root /tmp/robowbc-site
+python3 scripts/benchmarks/bench_robowbc_compare.py --all
+python3 scripts/benchmarks/render_nvidia_benchmark_summary.py \
+ --root artifacts/benchmarks/nvidia \
+ --output artifacts/benchmarks/nvidia/SUMMARY.md
+bash scripts/models/download_gear_sonic_models.sh models/gear-sonic
+```
+
+## Edit Checks
+
+Run the focused script checks before committing script changes:
+
+```bash
+python3 -m py_compile scripts/*.py scripts/*/*.py
+bash -n scripts/*.sh scripts/*/*.sh
+make python-test
+```
diff --git a/docs/third-party-notices.md b/docs/third-party-notices.md
index abb6e4c..f6eed45 100644
--- a/docs/third-party-notices.md
+++ b/docs/third-party-notices.md
@@ -54,7 +54,18 @@ licensed code is fine — the obligation only attaches to changed files.
source-disclosure obligation is satisfied by linking back to
upstream.
-### 3. Model licenses
+### 3. Bundled UI font licenses
+
+SIL Open Font License 1.1 and Ubuntu Font Licence 1.0 apply to default
+font assets pulled in by egui through the MuJoCo viewer stack. These
+licenses allow bundling and redistribution with robowbc binaries as long
+as the license text and notices are preserved.
+
+* **epaint_default_fonts** (`OFL-1.1`, `Ubuntu-font-1.0`, plus
+ `MIT OR Apache-2.0`) — default egui fonts used by the viewer path
+ behind `mujoco-rs`.
+
+### 4. Model licenses
These govern *trained model weights*, not source code. They are
distinct from any of robowbc's software licenses and have their own
@@ -63,7 +74,7 @@ restrictions.
* **NVIDIA Open Model License** — covers GEAR-SONIC weights
(`model_encoder.onnx`, `model_decoder.onnx`, `planner_sonic.onnx`).
robowbc **never bundles these weights**. Users fetch them on first
- run via `scripts/download_gear_sonic_models.sh` from HuggingFace and
+ run via `scripts/models/download_gear_sonic_models.sh` from HuggingFace and
accept the license at fetch time. If you build a derived
distribution that bundles the weights, you take on the redistribution
obligations of the NVIDIA Open Model License — see
diff --git a/examples/README.md b/examples/README.md
new file mode 100644
index 0000000..6ebb088
--- /dev/null
+++ b/examples/README.md
@@ -0,0 +1,21 @@
+# Examples
+
+Examples are runnable entry points. Keep existing paths stable because docs and
+users may call them directly.
+
+## Shell Examples
+
+- `shell/list_and_switch_policies.sh` lists registry policies and shows config-driven
+ switching.
+- `shell/run_gear_sonic.sh` runs the GEAR-Sonic policy path.
+- `shell/run_decoupled_wbc.sh` runs the Decoupled WBC policy path.
+
+## Python Examples
+
+- `python/gear_sonic_inference.py` demonstrates Python SDK policy inference.
+- `python/mujoco_kinematic_pose_session.py` demonstrates `MujocoSession` with a
+ kinematic-pose command.
+- `python/roboharness_backend.py` demonstrates the visual testing adapter path.
+
+Additional SDK adapter examples live in `crates/robowbc-py/examples/` because
+they are packaged with the Python project.
diff --git a/examples/list_and_switch_policies.sh b/examples/list_and_switch_policies.sh
old mode 100644
new mode 100755
index ecc4432..9074441
--- a/examples/list_and_switch_policies.sh
+++ b/examples/list_and_switch_policies.sh
@@ -1,41 +1,4 @@
#!/usr/bin/env bash
-# Example 3: List registered policies and switch between them via config
-#
-# This example demonstrates robowbc's core value proposition: switching WBC
-# models by changing a single TOML field, without recompiling.
-#
-# Usage: bash examples/list_and_switch_policies.sh
-
set -euo pipefail
-
-SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
-cd "$REPO_ROOT"
-
-echo "=== RoboWBC policy switching demo ==="
-echo ""
-
-# Validate both configs to show they differ only in policy.name.
-echo "--- Config 1: GEAR-SONIC (sonic_g1.toml) ---"
-cargo run --bin robowbc -- validate --config configs/sonic_g1.toml
-echo "Policy: gear_sonic ✓"
-echo ""
-
-echo "--- Config 2: Decoupled WBC (decoupled_g1.toml) ---"
-cargo run --bin robowbc -- validate --config configs/decoupled_g1.toml
-echo "Policy: decoupled_wbc ✓"
-echo ""
-
-# Show the diff between the two configs.
-echo "--- Diff between the two configs ---"
-diff configs/sonic_g1.toml configs/decoupled_g1.toml || true
-echo ""
-
-echo "Both policies share the same runtime interface: Observation in, JointPositionTargets out."
-echo ""
-echo "Running Decoupled WBC (1 tick, instant exit)..."
-cargo run --bin robowbc -- run --config configs/decoupled_g1.toml
-echo ""
-
-echo "To add a new policy, see: docs/adding-a-model.md"
-echo "To run GEAR-SONIC: bash examples/run_gear_sonic.sh"
+SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+exec bash "${SCRIPT_DIR}/shell/list_and_switch_policies.sh" "$@"
diff --git a/examples/run_decoupled_wbc.sh b/examples/run_decoupled_wbc.sh
old mode 100644
new mode 100755
index 053295a..5e3bbcb
--- a/examples/run_decoupled_wbc.sh
+++ b/examples/run_decoupled_wbc.sh
@@ -1,35 +1,4 @@
#!/usr/bin/env bash
-# Example 1: Run Decoupled WBC policy (no downloads required)
-#
-# Decoupled WBC combines an RL locomotion model (lower body) with an
-# analytical IK baseline (upper body). This example uses a small test
-# fixture bundled in the repository, so it runs without any external models.
-#
-# Usage: bash examples/run_decoupled_wbc.sh [--release]
-
set -euo pipefail
-
-SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
-cd "$REPO_ROOT"
-
-BUILD_MODE="${1:-}"
-CARGO_FLAGS=""
-if [[ "$BUILD_MODE" == "--release" ]]; then
- CARGO_FLAGS="--release"
-fi
-
-echo "Building robowbc..."
-cargo build $CARGO_FLAGS --bin robowbc 2>&1
-
-echo ""
-echo "Running Decoupled WBC control loop (velocity command [0.2, 0.0, 0.1])..."
-echo "Press Ctrl-C to stop early (the config sets max_ticks = 1 for quick demo)."
-echo ""
-
-cargo run $CARGO_FLAGS --bin robowbc -- run --config configs/decoupled_g1.toml
-
-echo ""
-echo "Done. Joint targets were printed above."
-echo ""
-echo "To run continuously, remove the 'max_ticks' limit from configs/decoupled_g1.toml."
+SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+exec bash "${SCRIPT_DIR}/shell/run_decoupled_wbc.sh" "$@"
diff --git a/examples/run_gear_sonic.sh b/examples/run_gear_sonic.sh
old mode 100644
new mode 100755
index 8d10d23..dfa0201
--- a/examples/run_gear_sonic.sh
+++ b/examples/run_gear_sonic.sh
@@ -1,51 +1,4 @@
#!/usr/bin/env bash
-# Example 2: Run GEAR-SONIC policy with real NVIDIA checkpoints
-#
-# GEAR-SONIC is NVIDIA's universal whole-body controller for Unitree G1.
-# It uses three ONNX models (encoder, planner, decoder) in a pipeline.
-#
-# Prerequisites:
-# - ONNX Runtime installed (see README for installation)
-# - ~500 MB free disk space for model checkpoints
-#
-# Usage: bash examples/run_gear_sonic.sh [--release]
-
set -euo pipefail
-
-SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
-cd "$REPO_ROOT"
-
-BUILD_MODE="${1:-}"
-CARGO_FLAGS=""
-if [[ "$BUILD_MODE" == "--release" ]]; then
- CARGO_FLAGS="--release"
-fi
-
-# Step 1: Download GEAR-SONIC checkpoints if not already present.
-MODELS_DIR="models/gear-sonic"
-if [[ ! -f "$MODELS_DIR/model_encoder.onnx" ]]; then
- echo "GEAR-SONIC models not found. Downloading from HuggingFace..."
- bash scripts/download_gear_sonic_models.sh
-else
- echo "GEAR-SONIC models already present in $MODELS_DIR/."
-fi
-
-# Step 2: Validate the config before loading models.
-echo ""
-echo "Validating config..."
-cargo run $CARGO_FLAGS --bin robowbc -- validate --config configs/sonic_g1.toml
-
-# Step 3: Run inference.
-echo ""
-echo "Running GEAR-SONIC control loop (motion tokens [0.05, -0.1, 0.2, 0.0])..."
-echo "Press Ctrl-C to stop early."
-echo ""
-
-cargo run $CARGO_FLAGS --bin robowbc -- run --config configs/sonic_g1.toml
-
-echo ""
-echo "Done. 50 Hz joint target inference complete."
-echo ""
-echo "To benchmark inference latency, run:"
-echo " cargo bench -p robowbc-ort"
+SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+exec bash "${SCRIPT_DIR}/shell/run_gear_sonic.sh" "$@"
diff --git a/examples/shell/list_and_switch_policies.sh b/examples/shell/list_and_switch_policies.sh
new file mode 100755
index 0000000..8342a5d
--- /dev/null
+++ b/examples/shell/list_and_switch_policies.sh
@@ -0,0 +1,41 @@
+#!/usr/bin/env bash
+# Example 3: List registered policies and switch between them via config
+#
+# This example demonstrates robowbc's core value proposition: switching WBC
+# models by changing a single TOML field, without recompiling.
+#
+# Usage: bash examples/shell/list_and_switch_policies.sh
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
+cd "$REPO_ROOT"
+
+echo "=== RoboWBC policy switching demo ==="
+echo ""
+
+# Validate both configs to show they differ only in policy.name.
+echo "--- Config 1: GEAR-SONIC (sonic_g1.toml) ---"
+cargo run --bin robowbc -- validate --config configs/sonic_g1.toml
+echo "Policy: gear_sonic ✓"
+echo ""
+
+echo "--- Config 2: Decoupled WBC (decoupled_g1.toml) ---"
+cargo run --bin robowbc -- validate --config configs/decoupled_g1.toml
+echo "Policy: decoupled_wbc ✓"
+echo ""
+
+# Show the diff between the two configs.
+echo "--- Diff between the two configs ---"
+diff configs/sonic_g1.toml configs/decoupled_g1.toml || true
+echo ""
+
+echo "Both policies share the same runtime interface: Observation in, JointPositionTargets out."
+echo ""
+echo "Running Decoupled WBC (1 tick, instant exit)..."
+cargo run --bin robowbc -- run --config configs/decoupled_g1.toml
+echo ""
+
+echo "To add a new policy, see: docs/adding-a-model.md"
+echo "To run GEAR-SONIC: bash examples/shell/run_gear_sonic.sh"
diff --git a/examples/shell/run_decoupled_wbc.sh b/examples/shell/run_decoupled_wbc.sh
new file mode 100755
index 0000000..b72ab80
--- /dev/null
+++ b/examples/shell/run_decoupled_wbc.sh
@@ -0,0 +1,35 @@
+#!/usr/bin/env bash
+# Example 1: Run Decoupled WBC policy (no downloads required)
+#
+# Decoupled WBC combines an RL locomotion model (lower body) with an
+# analytical IK baseline (upper body). This example uses a small test
+# fixture bundled in the repository, so it runs without any external models.
+#
+# Usage: bash examples/shell/run_decoupled_wbc.sh [--release]
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
+cd "$REPO_ROOT"
+
+BUILD_MODE="${1:-}"
+CARGO_FLAGS=""
+if [[ "$BUILD_MODE" == "--release" ]]; then
+ CARGO_FLAGS="--release"
+fi
+
+echo "Building robowbc..."
+cargo build $CARGO_FLAGS --bin robowbc 2>&1
+
+echo ""
+echo "Running Decoupled WBC control loop (velocity command [0.2, 0.0, 0.1])..."
+echo "Press Ctrl-C to stop early (the config sets max_ticks = 1 for quick demo)."
+echo ""
+
+cargo run $CARGO_FLAGS --bin robowbc -- run --config configs/decoupled_g1.toml
+
+echo ""
+echo "Done. Joint targets were printed above."
+echo ""
+echo "To run continuously, remove the 'max_ticks' limit from configs/decoupled_g1.toml."
diff --git a/examples/shell/run_gear_sonic.sh b/examples/shell/run_gear_sonic.sh
new file mode 100755
index 0000000..c429f6d
--- /dev/null
+++ b/examples/shell/run_gear_sonic.sh
@@ -0,0 +1,51 @@
+#!/usr/bin/env bash
+# Example 2: Run GEAR-SONIC policy with real NVIDIA checkpoints
+#
+# GEAR-SONIC is NVIDIA's universal whole-body controller for Unitree G1.
+# It uses three ONNX models (encoder, planner, decoder) in a pipeline.
+#
+# Prerequisites:
+# - ONNX Runtime installed (see README for installation)
+# - ~500 MB free disk space for model checkpoints
+#
+# Usage: bash examples/shell/run_gear_sonic.sh [--release]
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
+cd "$REPO_ROOT"
+
+BUILD_MODE="${1:-}"
+CARGO_FLAGS=""
+if [[ "$BUILD_MODE" == "--release" ]]; then
+ CARGO_FLAGS="--release"
+fi
+
+# Step 1: Download GEAR-SONIC checkpoints if not already present.
+MODELS_DIR="models/gear-sonic"
+if [[ ! -f "$MODELS_DIR/model_encoder.onnx" ]]; then
+ echo "GEAR-SONIC models not found. Downloading from HuggingFace..."
+ bash scripts/models/download_gear_sonic_models.sh
+else
+ echo "GEAR-SONIC models already present in $MODELS_DIR/."
+fi
+
+# Step 2: Validate the config before loading models.
+echo ""
+echo "Validating config..."
+cargo run $CARGO_FLAGS --bin robowbc -- validate --config configs/sonic_g1.toml
+
+# Step 3: Run inference.
+echo ""
+echo "Running GEAR-SONIC control loop (motion tokens [0.05, -0.1, 0.2, 0.0])..."
+echo "Press Ctrl-C to stop early."
+echo ""
+
+cargo run $CARGO_FLAGS --bin robowbc -- run --config configs/sonic_g1.toml
+
+echo ""
+echo "Done. 50 Hz joint target inference complete."
+echo ""
+echo "To benchmark inference latency, run:"
+echo " cargo bench -p robowbc-ort"
diff --git a/pyproject.toml b/pyproject.toml
index 7c53ac8..1525b18 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -25,5 +25,14 @@ manifest-path = "crates/robowbc-py/Cargo.toml"
module-name = "robowbc"
[tool.pytest.ini_options]
+addopts = "--strict-markers"
testpaths = ["tests"]
norecursedirs = ["third_party", "target", ".cache"]
+markers = [
+ "unit: fast isolated behavior tests",
+ "contract: public script, schema, generated artifact, and report compatibility tests",
+ "integration: subprocess, repository, simulator, external CLI, or process-boundary tests",
+ "regression: known-bug or artifact-shape regression tests",
+ "local: requires local GPU, simulator, gateway, credentials, display, or other host-specific resource",
+ "slow: CI-safe but expensive tests excluded from tight loops",
+]
diff --git a/scripts/README.md b/scripts/README.md
new file mode 100644
index 0000000..0d0d991
--- /dev/null
+++ b/scripts/README.md
@@ -0,0 +1,82 @@
+# Scripts
+
+Prefer Makefile targets for stable workflows. Call scripts directly when you
+are changing or debugging that script.
+
+Canonical script implementations live in workflow folders. Root-level
+`scripts/*.py`, `scripts/*.sh`, and `scripts/bench_nvidia_gear_sonic_official.cpp`
+remain compatibility entrypoints for older commands, docs, and local user
+habits.
+
+## Workflow Folders
+
+| Folder | Purpose | Prefer |
+|--------|---------|--------|
+| `models/` | Download and normalize public policy assets | `make models-public` or the model-specific scripts |
+| `mujoco/` | Ensure and validate the local MuJoCo runtime | `make sim-feature-test`, `make site-render-check`, or `make demo-keyboard` |
+| `benchmarks/` | Generate and render NVIDIA comparison artifacts | `make benchmark-nvidia` |
+| `site/` | Build, validate, serve, and browser-smoke the static policy site | `make site`, `make site-smoke`, `make site-serve` |
+| `reports/` | Convert RoboWBC outputs into proof-pack report contracts | Direct script use while debugging reports |
+| `sdk/` | Smoke-test the installed Python SDK | `make python-sdk-verify` |
+
+## Compatibility Entrypoints
+
+Root-level Python wrappers use `_compat.py` to dispatch to the canonical
+workflow folders. Keep these wrappers small and keep new implementation logic
+inside the workflow folders above.
+
+Shell download wrappers remain at the root for public command compatibility and
+delegate to `scripts/models/`.
+
+## Model Assets
+
+- `models/download_gear_sonic_models.sh`
+- `models/download_gear_sonic_reference_motions.sh`
+- `models/download_decoupled_wbc_models.sh`
+- `models/download_wbc_agile_models.sh`
+- `models/download_bfm_zero_models.sh`
+- `models/prepare_bfm_zero_assets.py`
+
+Downloaded models belong under local model/cache folders and should not be
+committed unless the repo already tracks that exact fixture type.
+
+## MuJoCo And Showcase
+
+- `mujoco/ensure_mujoco_runtime.py`
+- `mujoco/check_mujoco_headless.py`
+- `site/build_site.py`
+- `site/generate_policy_showcase.py`
+- `site/validate_site_bundle.py`
+- `site/serve_showcase.py`
+- `site/site_browser_smoke.py`
+
+Use `make site-smoke` for bundle validation and `make showcase-verify` for the
+CI-like path that downloads public checkpoints and renders proof packs.
+
+## Benchmarks
+
+- `benchmarks/bench_robowbc_compare.py`
+- `benchmarks/bench_nvidia_official.py`
+- `benchmarks/bench_nvidia_decoupled_official.py`
+- `benchmarks/bench_nvidia_gear_sonic_official.cpp`
+- `benchmarks/normalize_nvidia_benchmarks.py`
+- `benchmarks/render_nvidia_benchmark_summary.py`
+
+Use `make benchmark-nvidia` for the full comparison package.
+
+## Reports And SDK
+
+- `reports/roboharness_report.py`
+- `sdk/python_sdk_smoke.py`
+
+Use `make python-sdk-verify` for the local SDK build/install/smoke path.
+
+## Script Edit Checks
+
+Use these focused checks after script changes:
+
+```bash
+python3 -m py_compile scripts/*.py scripts/*/*.py
+bash -n scripts/*.sh scripts/*/*.sh
+make python-test
+```
diff --git a/scripts/_compat.py b/scripts/_compat.py
new file mode 100644
index 0000000..9b92095
--- /dev/null
+++ b/scripts/_compat.py
@@ -0,0 +1,12 @@
+#!/usr/bin/env python3
+"""Compatibility helpers for legacy root-level script entrypoints."""
+
+from __future__ import annotations
+
+import runpy
+from pathlib import Path
+
+
+def run_legacy_script(relative_path: str) -> None:
+ scripts_dir = Path(__file__).resolve().parent
+ runpy.run_path(str(scripts_dir / relative_path), run_name="__main__")
diff --git a/scripts/bench_nvidia_decoupled_official.py b/scripts/bench_nvidia_decoupled_official.py
old mode 100644
new mode 100755
index 2f62aa7..fbae318
--- a/scripts/bench_nvidia_decoupled_official.py
+++ b/scripts/bench_nvidia_decoupled_official.py
@@ -1,276 +1,4 @@
#!/usr/bin/env python3
-"""Headless benchmark harness for the upstream Decoupled WBC policy."""
+from _compat import run_legacy_script
-from __future__ import annotations
-
-import argparse
-import collections
-import contextlib
-import json
-import sys
-import time
-from pathlib import Path
-from types import ModuleType
-from typing import Any
-
-import numpy as np
-import tomllib
-
-
-def install_torch_shim_if_needed() -> str:
- try:
- import torch # noqa: F401
- except ModuleNotFoundError:
- pass
- else:
- return "torch"
-
- class Tensor(np.ndarray):
- __array_priority__ = 1000
-
- def __new__(cls, value: Any, dtype: Any | None = None) -> "Tensor":
- return np.asarray(value, dtype=dtype).view(cls)
-
- def __array_finalize__(self, obj: Any) -> None: # pragma: no cover - ndarray protocol
- del obj
-
- def unsqueeze(self, dim: int) -> "Tensor":
- return np.expand_dims(self, axis=dim).view(Tensor)
-
- def detach(self) -> "Tensor":
- return self
-
- def cpu(self) -> "Tensor":
- return self
-
- def numpy(self) -> np.ndarray:
- return np.asarray(self)
-
- class NoGrad(contextlib.AbstractContextManager[None]):
- def __exit__(self, exc_type, exc, tb) -> None:
- del exc_type, exc, tb
- return None
-
- torch_mod = ModuleType("torch")
- torch_mod.Tensor = Tensor
- torch_mod.float32 = np.float32
- torch_mod.tensor = lambda value, device=None, dtype=None: Tensor(value, dtype=dtype)
- torch_mod.zeros = lambda shape, dtype=np.float32: Tensor(np.zeros(shape, dtype=dtype))
- torch_mod.full_like = lambda tensor, fill_value: Tensor(
- np.full_like(np.asarray(tensor), fill_value)
- )
- torch_mod.remainder = lambda x, y: Tensor(np.remainder(np.asarray(x), y))
- torch_mod.cat = lambda tensors, dim=0: Tensor(
- np.concatenate([np.asarray(t) for t in tensors], axis=dim)
- )
- torch_mod.stack = lambda tensors, dim=0: Tensor(
- np.stack([np.asarray(t) for t in tensors], axis=dim)
- )
- torch_mod.sin = lambda tensor: Tensor(np.sin(np.asarray(tensor)))
- torch_mod.from_numpy = lambda array: Tensor(array)
- torch_mod.no_grad = NoGrad
- sys.modules["torch"] = torch_mod
- return "torch_shim"
-
-
-class UnitreeG1RobotModelStub:
- def __init__(self) -> None:
- self.groups = {
- "body": list(range(29)),
- "lower_body": list(range(15)),
- }
-
- def get_joint_group_indices(self, group: str) -> list[int]:
- if group not in self.groups:
- raise KeyError(f"unknown joint group: {group}")
- return self.groups[group]
-
-
-def load_robot_default_pose(path: Path) -> np.ndarray:
- with path.open("rb") as handle:
- parsed = tomllib.load(handle)
- return np.array(parsed["default_pose"], dtype=np.float32)
-
-
-def resolve_model_paths(model_dir: Path) -> tuple[Path, Path]:
- balance_src = (model_dir / "GR00T-WholeBodyControl-Balance.onnx").resolve()
- walk_src = (model_dir / "GR00T-WholeBodyControl-Walk.onnx").resolve()
- if not balance_src.is_file() or not walk_src.is_file():
- raise FileNotFoundError(f"expected balance and walk ONNX models under {model_dir}")
- return balance_src, walk_src
-
-
-@contextlib.contextmanager
-def patched_absolute_model_loading(policy_cls: type[Any]):
- original = getattr(policy_cls, "load_onnx_policy", None)
- if original is None:
- yield
- return
-
- def patched(self: Any, model_path: str):
- resolved_path = Path(model_path)
- if not resolved_path.is_file():
- marker = "/resources/robots/g1/"
- if marker in model_path:
- suffix = model_path.rsplit(marker, 1)[-1]
- suffix_path = Path(suffix)
- if suffix_path.is_file():
- model_path = str(suffix_path)
- return original(self, model_path)
-
- setattr(policy_cls, "load_onnx_policy", patched)
- try:
- yield
- finally:
- setattr(policy_cls, "load_onnx_policy", original)
-
-
-def build_observation(default_pose: np.ndarray) -> dict[str, np.ndarray]:
- return {
- "q": default_pose.copy(),
- "dq": np.zeros_like(default_pose),
- "floating_base_pose": np.array([0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0], dtype=np.float32),
- "floating_base_vel": np.zeros(6, dtype=np.float32),
- }
-
-
-def reset_policy_state(policy: Any) -> None:
- policy.obs_history = collections.deque(maxlen=policy.config["obs_history_len"])
- policy.obs_buffer = np.zeros(policy.config["num_obs"], dtype=np.float32)
- policy.counter = 0
- policy.action = np.zeros(policy.config["num_actions"], dtype=np.float32)
- policy.target_dof_pos = policy.config["default_angles"].copy()
- policy.cmd = policy.config["cmd_init"].copy()
- policy.height_cmd = float(policy.config["height_cmd"])
- policy.freq_cmd = float(policy.config["freq_cmd"])
- if "rpy_cmd" in policy.config:
- policy.roll_cmd = float(policy.config["rpy_cmd"][0])
- policy.pitch_cmd = float(policy.config["rpy_cmd"][1])
- policy.yaw_cmd = float(policy.config["rpy_cmd"][2])
- else:
- policy.roll_cmd = float(policy.config.get("roll_cmd", 0.0))
- policy.pitch_cmd = float(policy.config.get("pitch_cmd", 0.0))
- policy.yaw_cmd = float(policy.config.get("yaw_cmd", 0.0))
- policy.gait_indices = sys.modules["torch"].zeros((1,), dtype=sys.modules["torch"].float32)
- policy.obs_tensor = None
- policy.use_policy_action = True
- policy.set_use_teleop_policy_cmd(True)
-
-
-def single_tick(policy: Any, obs: dict[str, np.ndarray], command: np.ndarray) -> None:
- policy.cmd = command.astype(np.float32, copy=True)
- policy.set_observation(obs)
- policy.get_action(time=time.monotonic())
-
-
-def run_microbench(policy: Any, obs: dict[str, np.ndarray], command: np.ndarray, samples: int) -> list[int]:
- timings: list[int] = []
- for _ in range(samples):
- reset_policy_state(policy)
- start = time.perf_counter_ns()
- single_tick(policy, obs, command)
- timings.append(time.perf_counter_ns() - start)
- return timings
-
-
-def run_end_to_end_loop(
- policy: Any,
- obs: dict[str, np.ndarray],
- command: np.ndarray,
- ticks: int,
- control_frequency_hz: int,
-) -> tuple[list[int], float]:
- timings: list[int] = []
- period_ns = round(1_000_000_000 / control_frequency_hz)
- reset_policy_state(policy)
- wall_start = time.perf_counter_ns()
- for _ in range(ticks):
- tick_start = time.perf_counter_ns()
- single_tick(policy, obs, command)
- tick_end = time.perf_counter_ns()
- timings.append(tick_end - tick_start)
- remaining_ns = period_ns - (tick_end - tick_start)
- if remaining_ns > 0:
- time.sleep(remaining_ns / 1_000_000_000)
- wall_end = time.perf_counter_ns()
- elapsed_s = (wall_end - wall_start) / 1_000_000_000
- achieved_hz = ticks / elapsed_s if elapsed_s > 0 else 0.0
- return timings, achieved_hz
-
-
-def parse_command(case_id: str) -> np.ndarray:
- if case_id == "decoupled_wbc/walk_predict" or case_id == "decoupled_wbc/end_to_end_cli_loop":
- return np.array([0.25, 0.0, 0.05], dtype=np.float32)
- if case_id == "decoupled_wbc/balance_predict":
- return np.array([0.0, 0.0, 0.0], dtype=np.float32)
- raise ValueError(f"unsupported Decoupled case_id: {case_id}")
-
-
-def load_policy(repo_dir: Path, model_dir: Path) -> tuple[Any, str]:
- torch_backend = install_torch_shim_if_needed()
- balance_model, walk_model = resolve_model_paths(model_dir)
-
- sys.path.insert(0, str(repo_dir))
- from decoupled_wbc.control.policy import g1_gear_wbc_policy as policy_module # type: ignore
-
- robot_model = UnitreeG1RobotModelStub()
- config_path = (
- repo_dir
- / "decoupled_wbc"
- / "sim2mujoco"
- / "resources"
- / "robots"
- / "g1"
- / "g1_gear_wbc.yaml"
- )
- model_path = f"{balance_model},{walk_model}"
- policy_cls = policy_module.G1GearWbcPolicy
- with patched_absolute_model_loading(policy_cls):
- policy = policy_cls(robot_model=robot_model, config=str(config_path), model_path=model_path)
- return policy, torch_backend
-
-
-def main() -> int:
- parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument("--case-id", required=True)
- parser.add_argument("--repo-dir", type=Path, required=True)
- parser.add_argument("--model-dir", type=Path, required=True)
- parser.add_argument("--robot-config", type=Path, default=Path("configs/robots/unitree_g1.toml"))
- parser.add_argument("--samples", type=int, default=100)
- parser.add_argument("--ticks", type=int, default=200)
- parser.add_argument("--control-frequency-hz", type=int, default=50)
- parser.add_argument("--output", type=Path, required=True)
- args = parser.parse_args()
-
- if not args.repo_dir.is_dir():
- raise FileNotFoundError(f"repo-dir does not exist: {args.repo_dir}")
-
- default_pose = load_robot_default_pose(args.robot_config)
- observation = build_observation(default_pose)
- command = parse_command(args.case_id)
- policy, torch_backend = load_policy(args.repo_dir, args.model_dir)
-
- if args.case_id == "decoupled_wbc/end_to_end_cli_loop":
- samples_ns, hz = run_end_to_end_loop(
- policy, observation, command, args.ticks, args.control_frequency_hz
- )
- else:
- samples_ns = run_microbench(policy, observation, command, args.samples)
- hz = None
-
- payload = {
- "case_id": args.case_id,
- "samples_ns": samples_ns,
- "hz": hz,
- "notes": (
- "Measured via upstream decoupled_wbc.control.policy.g1_gear_wbc_policy.G1GearWbcPolicy "
- f"with {torch_backend}."
- ),
- }
- args.output.parent.mkdir(parents=True, exist_ok=True)
- args.output.write_text(json.dumps(payload, indent=2), encoding="utf-8")
- return 0
-
-
-if __name__ == "__main__":
- raise SystemExit(main())
+run_legacy_script("benchmarks/bench_nvidia_decoupled_official.py")
diff --git a/scripts/bench_nvidia_gear_sonic_official.cpp b/scripts/bench_nvidia_gear_sonic_official.cpp
index 433ff20..d001bfe 100644
--- a/scripts/bench_nvidia_gear_sonic_official.cpp
+++ b/scripts/bench_nvidia_gear_sonic_official.cpp
@@ -1,2200 +1 @@
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-
-#include
-
-#include "math_utils.hpp"
-#include "policy_parameters.hpp"
-#include "robot_parameters.hpp"
-
-namespace fs = std::filesystem;
-
-namespace {
-
-constexpr std::size_t kPlannerQposDim = 36;
-constexpr std::size_t kPlannerJointOffset = 7;
-constexpr std::size_t kPlannerContextLen = 4;
-constexpr std::size_t kReplanIntervalTicksDefault = 50;
-constexpr std::size_t kReplanIntervalTicksRunning = 5;
-constexpr std::size_t kPlannerThreadIntervalTicks = 5;
-constexpr std::size_t kPlannerLookAheadSteps = 2;
-constexpr std::size_t kPlannerBlendFrames = 8;
-constexpr std::size_t kAllowedPredNumTokens = 11;
-constexpr std::array kAllowedPredNumTokensMask = {
- 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0,
-};
-constexpr float kDefaultHeightMeters = 0.74f;
-constexpr float kOfficialDefaultHeightMeters = 0.788740f;
-constexpr float kDefaultHeightSentinel = -1.0f;
-constexpr std::int64_t kDefaultModeWalk = 2;
-constexpr std::int64_t kDefaultModeRun = 3;
-constexpr std::int64_t kDefaultModeIdle = 0;
-constexpr std::int64_t kDefaultModeSlowWalk = 1;
-constexpr std::size_t kReferenceFutureFrames = 10;
-constexpr std::size_t kReferenceFrameStep = 5;
-constexpr float kControlDtSeconds = 1.0f / 50.0f;
-constexpr std::size_t kEncoderDim = 64;
-constexpr std::size_t kEncoderObsDictDim = 1762;
-constexpr std::size_t kDecoderObsDictDim = 994;
-constexpr std::size_t kDecoderHistoryLen = 10;
-constexpr std::size_t kLaterMotionProbeTick = 25;
-constexpr std::size_t kEncoderModeOffset = 0;
-constexpr std::size_t kEncoderMotionJointPositionsOffset = 4;
-constexpr std::size_t kEncoderMotionJointVelocitiesOffset = 294;
-constexpr std::size_t kEncoderMotionAnchorOrientationOffset = 601;
-
-volatile float g_sink = 0.0f;
-
-enum class ProviderKind {
- Cpu,
- Cuda,
- TensorRt,
-};
-
-struct Options {
- std::string case_id;
- ProviderKind provider = ProviderKind::Cpu;
- fs::path model_dir;
- fs::path output;
- std::optional dump_dir;
- int samples = 100;
- int ticks = 200;
- int control_frequency_hz = 50;
-};
-
-struct Observation {
- std::vector joint_positions;
- std::vector joint_velocities;
- std::array gravity_vector{};
- std::array angular_velocity{};
- std::array base_quat_wxyz{1.0, 0.0, 0.0, 0.0};
-};
-
-struct Twist {
- std::array linear{};
- std::array angular{};
-};
-
-struct PlannerCommand {
- std::int64_t mode = kDefaultModeIdle;
- float target_vel = kDefaultHeightSentinel;
- float height = kDefaultHeightSentinel;
- std::array movement_direction{0.0f, 0.0f, 0.0f};
- std::array facing_direction{1.0f, 0.0f, 0.0f};
-};
-
-float wrap_angle_rad(float angle) {
- while (angle > static_cast(M_PI)) {
- angle -= 2.0f * static_cast(M_PI);
- }
- while (angle < -static_cast(M_PI)) {
- angle += 2.0f * static_cast(M_PI);
- }
- return angle;
-}
-
-std::pair bin_angle_to_8_directions(float angle) {
- constexpr float kBinSize = static_cast(M_PI / 4.0);
-
- const float normalized = wrap_angle_rad(angle);
- int bin_index = static_cast(std::lround(normalized / kBinSize));
- if (bin_index > 4) {
- bin_index -= 8;
- }
- if (bin_index < -4) {
- bin_index += 8;
- }
-
- float slow_walk_speed = 0.2f;
- switch (bin_index) {
- case 0:
- case 1:
- case -1:
- slow_walk_speed = 0.3f;
- break;
- case 2:
- case -2:
- slow_walk_speed = 0.35f;
- break;
- case 3:
- case -3:
- slow_walk_speed = 0.25f;
- break;
- case 4:
- case -4:
- default:
- slow_walk_speed = 0.2f;
- break;
- }
-
- return {static_cast(bin_index) * kBinSize, slow_walk_speed};
-}
-
-PlannerCommand idle_planner_command() {
- return PlannerCommand{};
-}
-
-PlannerCommand derive_planner_command(float& facing_yaw_rad, const Twist& twist) {
- facing_yaw_rad = wrap_angle_rad(facing_yaw_rad + twist.angular[2] * kControlDtSeconds);
- const std::array facing_direction = {
- std::cos(facing_yaw_rad),
- std::sin(facing_yaw_rad),
- 0.0f,
- };
- const float command_norm = std::sqrt(
- twist.linear[0] * twist.linear[0] + twist.linear[1] * twist.linear[1]
- );
-
- if (command_norm <= 0.01f) {
- return PlannerCommand{
- kDefaultModeIdle,
- kDefaultHeightSentinel,
- kDefaultHeightSentinel,
- {0.0f, 0.0f, 0.0f},
- facing_direction,
- };
- }
-
- const float local_movement_angle = std::atan2(twist.linear[1], twist.linear[0]);
- const auto [movement_angle, slow_walk_speed] =
- bin_angle_to_8_directions(facing_yaw_rad + local_movement_angle);
- const auto [mode, target_vel] =
- command_norm < 0.8f
- ? std::pair{kDefaultModeSlowWalk, slow_walk_speed}
- : (command_norm < 2.5f
- ? std::pair{kDefaultModeWalk, -1.0f}
- : std::pair{kDefaultModeRun, -1.0f});
-
- return PlannerCommand{
- mode,
- target_vel,
- kDefaultHeightSentinel,
- {std::cos(movement_angle), std::sin(movement_angle), 0.0f},
- facing_direction,
- };
-}
-
-bool planner_command_changed(
- const std::optional& previous,
- const PlannerCommand& next
-) {
- const auto vec3_distance = [](const std::array& a, const std::array& b) {
- const float dx = a[0] - b[0];
- const float dy = a[1] - b[1];
- const float dz = a[2] - b[2];
- return std::sqrt(dx * dx + dy * dy + dz * dz);
- };
-
- if (!previous.has_value()) {
- return true;
- }
-
- return previous->mode != next.mode
- || std::abs(previous->target_vel - next.target_vel) > 0.05f
- || std::abs(previous->height - next.height) > 1e-3f
- || vec3_distance(previous->movement_direction, next.movement_direction) > 0.1f
- || vec3_distance(previous->facing_direction, next.facing_direction) > 0.1f;
-}
-
-std::size_t planner_replan_interval_ticks(const PlannerCommand& command) {
- return command.mode == kDefaultModeRun
- ? kReplanIntervalTicksRunning
- : kReplanIntervalTicksDefault;
-}
-
-std::vector default_pose() {
- std::vector pose;
- pose.reserve(G1_NUM_MOTOR);
- for (double angle : default_angles) {
- pose.push_back(static_cast(angle));
- }
- return pose;
-}
-
-std::vector mujoco_to_isaaclab_values(const std::vector& values) {
- if (values.size() != G1_NUM_MOTOR) {
- throw std::runtime_error("values must match G1_NUM_MOTOR");
- }
-
- std::vector remapped(G1_NUM_MOTOR, 0.0f);
- for (std::size_t mujoco_index = 0; mujoco_index < G1_NUM_MOTOR; ++mujoco_index) {
- remapped[static_cast(isaaclab_to_mujoco[mujoco_index])] =
- values[mujoco_index];
- }
- return remapped;
-}
-
-std::vector mujoco_to_isaaclab_positions(const std::vector& joint_positions) {
- return mujoco_to_isaaclab_values(joint_positions);
-}
-
-std::vector mujoco_to_isaaclab_joint_offsets(const std::vector& joint_positions) {
- auto remapped = mujoco_to_isaaclab_values(joint_positions);
- for (std::size_t mujoco_index = 0; mujoco_index < G1_NUM_MOTOR; ++mujoco_index) {
- remapped[static_cast(isaaclab_to_mujoco[mujoco_index])] -=
- static_cast(default_angles[mujoco_index]);
- }
- return remapped;
-}
-
-std::vector isaaclab_to_mujoco_values(const std::vector& values) {
- if (values.size() != G1_NUM_MOTOR) {
- throw std::runtime_error("values must match G1_NUM_MOTOR");
- }
-
- std::vector remapped(G1_NUM_MOTOR, 0.0f);
- for (std::size_t mujoco_index = 0; mujoco_index < G1_NUM_MOTOR; ++mujoco_index) {
- remapped[mujoco_index] = values[static_cast(isaaclab_to_mujoco[mujoco_index])];
- }
- return remapped;
-}
-
-Observation zero_tracking_observation() {
- Observation obs;
- obs.joint_positions.assign(G1_NUM_MOTOR, 0.0f);
- obs.joint_velocities.assign(G1_NUM_MOTOR, 0.0f);
- obs.gravity_vector = {0.0f, 0.0f, -1.0f};
- obs.angular_velocity = {0.0f, 0.0f, 0.0f};
- return obs;
-}
-
-Observation standing_velocity_observation() {
- Observation obs;
- obs.joint_positions = default_pose();
- obs.joint_velocities.assign(G1_NUM_MOTOR, 0.0f);
- obs.gravity_vector = {0.0f, 0.0f, -1.0f};
- obs.angular_velocity = {0.0f, 0.0f, 0.0f};
- return obs;
-}
-
-std::string json_escape(const std::string& input) {
- std::string escaped;
- escaped.reserve(input.size());
- for (char ch : input) {
- switch (ch) {
- case '\\':
- escaped += "\\\\";
- break;
- case '"':
- escaped += "\\\"";
- break;
- case '\n':
- escaped += "\\n";
- break;
- default:
- escaped.push_back(ch);
- break;
- }
- }
- return escaped;
-}
-
-std::vector session_names(Ort::Session& session, bool inputs) {
- Ort::AllocatorWithDefaultOptions allocator;
- const std::size_t count = inputs ? session.GetInputCount() : session.GetOutputCount();
- std::vector names;
- names.reserve(count);
- for (std::size_t index = 0; index < count; ++index) {
- auto name = inputs ? session.GetInputNameAllocated(index, allocator)
- : session.GetOutputNameAllocated(index, allocator);
- names.emplace_back(name.get());
- }
- return names;
-}
-
-void require_name(const std::vector& names, std::string_view needle, std::string_view kind) {
- if (std::find(names.begin(), names.end(), needle) == names.end()) {
- throw std::runtime_error(
- "missing required " + std::string(kind) + " tensor '" + std::string(needle) + "'"
- );
- }
-}
-
-std::string provider_label(ProviderKind provider) {
- switch (provider) {
- case ProviderKind::Cpu:
- return "cpu";
- case ProviderKind::Cuda:
- return "cuda";
- case ProviderKind::TensorRt:
- return "tensor_rt";
- }
- throw std::runtime_error("unreachable provider label state");
-}
-
-std::string provider_identifier(ProviderKind provider) {
- switch (provider) {
- case ProviderKind::Cpu:
- return "CPUExecutionProvider";
- case ProviderKind::Cuda:
- return "CUDAExecutionProvider";
- case ProviderKind::TensorRt:
- return "TensorrtExecutionProvider";
- }
- throw std::runtime_error("unreachable provider identifier state");
-}
-
-std::vector available_providers() {
- return Ort::GetAvailableProviders();
-}
-
-std::string format_provider_list(const std::vector& providers) {
- if (providers.empty()) {
- return "(none)";
- }
- std::string joined;
- for (std::size_t index = 0; index < providers.size(); ++index) {
- if (index > 0) {
- joined += ", ";
- }
- joined += providers[index];
- }
- return joined;
-}
-
-constexpr const char* kTensorRtExcludedOpTypes = "Cast";
-
-void configure_provider(Ort::SessionOptions& options, ProviderKind provider) {
- if (provider == ProviderKind::Cpu) {
- return;
- }
-
- try {
- if (provider == ProviderKind::Cuda) {
- Ort::CUDAProviderOptions cuda_options;
- cuda_options.Update({{"device_id", "0"}});
- options.AppendExecutionProvider_CUDA_V2(*cuda_options);
- return;
- }
-
- Ort::TensorRTProviderOptions tensorrt_options;
- // GEAR-Sonic's planner graph includes Float->Int64 Cast paths that
- // TensorRT cannot compile inside a fused subgraph. Excluding Cast keeps
- // those nodes on CUDA/CPU while still exercising TensorRT for the rest
- // of the graph.
- tensorrt_options.Update({
- {"device_id", "0"},
- {"trt_op_types_to_exclude", kTensorRtExcludedOpTypes},
- });
- options.AppendExecutionProvider_TensorRT_V2(*tensorrt_options);
- Ort::CUDAProviderOptions cuda_options;
- cuda_options.Update({{"device_id", "0"}});
- options.AppendExecutionProvider_CUDA_V2(*cuda_options);
- } catch (const Ort::Exception& error) {
- throw std::runtime_error(
- std::string("failed to configure provider `") + provider_label(provider)
- + "`: " + error.what()
- );
- }
-}
-
-Ort::Session make_session(Ort::Env& env, const fs::path& model_path, ProviderKind provider) {
- if (!fs::is_regular_file(model_path)) {
- throw std::runtime_error("model file does not exist: " + model_path.string());
- }
-
- Ort::SessionOptions options;
- options.SetIntraOpNumThreads(1);
- options.SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_EXTENDED);
- configure_provider(options, provider);
- const std::string model_path_str = model_path.string();
- try {
- return Ort::Session(env, model_path_str.c_str(), options);
- } catch (const Ort::Exception& error) {
- const auto providers = available_providers();
- throw std::runtime_error(
- std::string("failed to initialize provider `") + provider_label(provider)
- + "` for model " + model_path.string() + ": " + error.what()
- + " (advertised providers: " + format_provider_list(providers) + ")"
- );
- }
-}
-
-struct PlannerState {
- std::deque> context;
- std::size_t steps_since_plan = kReplanIntervalTicksDefault;
- std::vector last_context_frame;
- std::vector> motion_qpos_50hz;
- std::vector> motion_joint_velocities_isaaclab;
- std::size_t current_motion_frame = 0;
- float facing_yaw_rad = 0.0f;
- std::optional> init_base_quat_wxyz;
- std::optional> init_ref_root_quat_wxyz;
- std::optional last_command;
-
- struct PendingPlannerReplan {
- std::size_t request_motion_frame = 0;
- PlannerCommand command{};
- std::future>> future;
- };
-
- std::optional pending_replan;
-
- PlannerState()
- : context(), last_context_frame()
- {
- reset();
- }
-
- void reset() {
- const auto standing = make_standing_qpos();
- context.clear();
- for (std::size_t index = 0; index < kPlannerContextLen; ++index) {
- context.push_back(standing);
- }
- steps_since_plan = kReplanIntervalTicksDefault;
- last_context_frame = standing;
- motion_qpos_50hz.clear();
- motion_joint_velocities_isaaclab.clear();
- current_motion_frame = 0;
- facing_yaw_rad = 0.0f;
- init_base_quat_wxyz.reset();
- init_ref_root_quat_wxyz.reset();
- last_command.reset();
- pending_replan.reset();
- }
-
- static std::vector make_standing_qpos() {
- std::vector qpos(kPlannerQposDim, 0.0f);
- qpos[2] = kDefaultHeightMeters;
- qpos[3] = 1.0f;
- const auto pose = default_pose();
- std::copy(pose.begin(), pose.end(), qpos.begin() + static_cast(kPlannerJointOffset));
- return qpos;
- }
-};
-
-struct TrackingState {
- std::deque> gravity;
- std::deque> angular_velocity;
- std::deque> joint_positions;
- std::deque> joint_velocities;
- std::deque> last_actions;
-
- TrackingState()
- : gravity(), angular_velocity(), joint_positions(), joint_velocities(), last_actions()
- {
- reset();
- }
-
- void reset() {
- gravity.clear();
- angular_velocity.clear();
- joint_positions.clear();
- joint_velocities.clear();
- last_actions.clear();
- for (std::size_t index = 0; index < kDecoderHistoryLen; ++index) {
- gravity.push_back({0.0f, 0.0f, 1.0f});
- angular_velocity.push_back({0.0f, 0.0f, 0.0f});
- joint_positions.emplace_back(G1_NUM_MOTOR, 0.0f);
- joint_velocities.emplace_back(G1_NUM_MOTOR, 0.0f);
- last_actions.emplace_back(G1_NUM_MOTOR, 0.0f);
- }
- }
-
- void push(const Observation& obs, const std::vector& actions) {
- if (gravity.size() >= kDecoderHistoryLen) {
- gravity.pop_front();
- angular_velocity.pop_front();
- joint_positions.pop_front();
- joint_velocities.pop_front();
- last_actions.pop_front();
- }
- gravity.push_back(obs.gravity_vector);
- angular_velocity.push_back(obs.angular_velocity);
- joint_positions.push_back(mujoco_to_isaaclab_joint_offsets(obs.joint_positions));
- joint_velocities.push_back(mujoco_to_isaaclab_values(obs.joint_velocities));
- last_actions.push_back(actions);
- }
-};
-
-class GearSonicOfficialHarness {
-public:
- explicit GearSonicOfficialHarness(
- const fs::path& model_dir,
- ProviderKind provider,
- std::optional dump_dir = std::nullopt
- )
- : env_(ORT_LOGGING_LEVEL_WARNING, "gear_sonic_official"),
- memory_info_(Ort::MemoryInfo::CreateCpu(OrtArenaAllocator, OrtMemTypeDefault)),
- encoder_(make_session(env_, model_dir / "model_encoder.onnx", provider)),
- decoder_(make_session(env_, model_dir / "model_decoder.onnx", provider)),
- planner_(make_session(env_, model_dir / "planner_sonic.onnx", provider)),
- planner_state_(),
- tracking_state_(),
- velocity_obs_(standing_velocity_observation()),
- tracking_obs_(zero_tracking_observation()),
- latest_action_(G1_NUM_MOTOR, 0.0f),
- dump_dir_(std::move(dump_dir))
- {
- validate_contracts();
- }
-
- void reset() {
- planner_state_.reset();
- tracking_state_.reset();
- velocity_obs_ = standing_velocity_observation();
- tracking_obs_ = zero_tracking_observation();
- latest_action_.assign(G1_NUM_MOTOR, 0.0f);
- dumped_tracking_tensors_ = false;
- }
-
- std::vector velocity_tick() {
- maybe_apply_pending_planner_replan();
-
- const Twist twist = velocity_command();
- const float command_speed = std::sqrt(
- twist.linear[0] * twist.linear[0] + twist.linear[1] * twist.linear[1]
- );
- const auto live_command = derive_planner_command(planner_state_.facing_yaw_rad, twist);
- const bool initializing_planner = planner_state_.motion_qpos_50hz.empty();
- const auto planner_command =
- initializing_planner && command_speed <= 0.01f
- ? idle_planner_command()
- : live_command;
- const bool needs_replan =
- initializing_planner
- || (planner_state_.pending_replan.has_value()
- ? false
- : (planner_command_changed(planner_state_.last_command, live_command)
- || planner_state_.steps_since_plan
- >= planner_replan_interval_ticks(live_command)));
-
- if (needs_replan) {
- if (!planner_state_.motion_qpos_50hz.empty()) {
- planner_state_.context = rebuild_planner_context_from_motion(
- planner_state_.motion_qpos_50hz,
- planner_state_.current_motion_frame
- );
- if (!planner_state_.context.empty()) {
- planner_state_.last_context_frame = planner_state_.context.back();
- }
- }
- if (initializing_planner) {
- commit_planner_motion(
- 0,
- planner_command,
- resample_planner_trajectory_to_50hz(
- run_planner_command(planner_state_.context, planner_command)
- )
- );
- } else {
- start_async_planner_replan(planner_command);
- }
- planner_state_.steps_since_plan = 0;
- }
-
- planner_state_.steps_since_plan += 1;
- if (planner_state_.motion_qpos_50hz.empty()) {
- throw std::runtime_error("planner motion buffer is empty after velocity bootstrap");
- }
-
- const auto obs = motion_observation_from_planner_frame(
- planner_state_.motion_qpos_50hz,
- planner_state_.motion_joint_velocities_isaaclab,
- planner_state_.current_motion_frame
- );
- const auto init_base_quat =
- planner_state_.init_base_quat_wxyz.value_or(obs.base_quat_wxyz);
- const auto init_ref_root_quat =
- planner_state_.init_ref_root_quat_wxyz.value_or(
- planner_frame_root_quaternion(planner_state_.motion_qpos_50hz.front())
- );
- const auto encoder_obs = build_velocity_encoder_obs_dict(
- planner_state_.motion_qpos_50hz,
- planner_state_.motion_joint_velocities_isaaclab,
- planner_state_.current_motion_frame,
- obs.base_quat_wxyz,
- init_base_quat,
- init_ref_root_quat
- );
- const auto tokens = run_single_f32(
- encoder_,
- "obs_dict",
- "encoded_tokens",
- encoder_obs,
- kEncoderObsDictDim
- );
- if (tokens.size() != kEncoderDim) {
- throw std::runtime_error(
- "encoder output dimension mismatch: expected " + std::to_string(kEncoderDim) +
- ", got " + std::to_string(tokens.size())
- );
- }
-
- tracking_state_.push(obs, latest_action_);
- const auto decoder_obs = build_decoder_obs_dict(tokens);
- const auto raw_actions = run_single_f32(
- decoder_,
- "obs_dict",
- "action",
- decoder_obs,
- kDecoderObsDictDim
- );
- if (raw_actions.size() != G1_NUM_MOTOR) {
- throw std::runtime_error(
- "decoder output dimension mismatch: expected " + std::to_string(G1_NUM_MOTOR) +
- ", got " + std::to_string(raw_actions.size())
- );
- }
-
- std::vector positions(G1_NUM_MOTOR, 0.0f);
- for (int mujoco_index = 0; mujoco_index < G1_NUM_MOTOR; ++mujoco_index) {
- const int isaaclab_index = isaaclab_to_mujoco[static_cast(mujoco_index)];
- const float action = raw_actions[static_cast(isaaclab_index)];
- const float scaled = action * static_cast(g1_action_scale[static_cast(mujoco_index)]);
- positions[static_cast(mujoco_index)] =
- static_cast(default_angles[static_cast(mujoco_index)]) + scaled;
- }
-
- latest_action_ = raw_actions;
- advance_planner_motion_frame();
- return positions;
- }
-
- std::vector tracking_tick() {
- const auto encoder_obs = build_encoder_obs_dict();
- const auto tokens = run_single_f32(encoder_, "obs_dict", "encoded_tokens", encoder_obs, kEncoderObsDictDim);
- if (tokens.size() != kEncoderDim) {
- throw std::runtime_error(
- "encoder output dimension mismatch: expected " + std::to_string(kEncoderDim) +
- ", got " + std::to_string(tokens.size())
- );
- }
-
- tracking_state_.push(tracking_obs_, latest_action_);
- const auto decoder_obs = build_decoder_obs_dict(tokens);
- const auto raw_actions = run_single_f32(decoder_, "obs_dict", "action", decoder_obs, kDecoderObsDictDim);
- if (raw_actions.size() != G1_NUM_MOTOR) {
- throw std::runtime_error(
- "decoder output dimension mismatch: expected " + std::to_string(G1_NUM_MOTOR) +
- ", got " + std::to_string(raw_actions.size())
- );
- }
- maybe_dump_tracking_tensors(encoder_obs, tokens, decoder_obs, raw_actions);
-
- std::vector positions(G1_NUM_MOTOR, 0.0f);
- for (int mujoco_index = 0; mujoco_index < G1_NUM_MOTOR; ++mujoco_index) {
- const int isaaclab_index = isaaclab_to_mujoco[static_cast(mujoco_index)];
- const float action = raw_actions[static_cast(isaaclab_index)];
- const float scaled = action * static_cast(g1_action_scale[static_cast(mujoco_index)]);
- positions[static_cast(mujoco_index)] =
- static_cast(default_angles[static_cast(mujoco_index)]) + scaled;
- }
-
- latest_action_ = raw_actions;
- return positions;
- }
-
- std::vector planner_only_tick() {
- std::deque> context;
- const auto standing = PlannerState::make_standing_qpos();
- for (std::size_t index = 0; index < kPlannerContextLen; ++index) {
- context.push_back(standing);
- }
-
- float facing_yaw_rad = 0.0f;
- const auto planner_command = derive_planner_command(facing_yaw_rad, velocity_command());
- const auto trajectory = run_planner_command(context, planner_command);
- if (trajectory.empty()) {
- throw std::runtime_error("planner-only benchmark produced an empty trajectory");
- }
- return trajectory.front();
- }
-
- std::vector velocity_first_live_replan_dump() {
- if (!dump_dir_.has_value()) {
- throw std::runtime_error("--dump-dir is required for gear_sonic_velocity/first_live_replan_dump");
- }
-
- reset();
- tracking_state_.reset();
- latest_action_.assign(G1_NUM_MOTOR, 0.0f);
-
- std::deque> context;
- const auto standing = make_official_standing_qpos();
- for (std::size_t index = 0; index < kPlannerContextLen; ++index) {
- context.push_back(standing);
- }
-
- const auto idle_planned_30hz = run_planner_command(context, idle_planner_command());
- const auto bootstrap_motion_50hz = resample_planner_trajectory_to_50hz(idle_planned_30hz);
-
- constexpr std::size_t kFirstLiveReplanTick = kPlannerThreadIntervalTicks;
- const auto planner_context =
- rebuild_planner_context_from_motion(bootstrap_motion_50hz, kFirstLiveReplanTick);
-
- float facing_yaw_rad = 0.0f;
- Twist twist;
- twist.linear = {0.6f, 0.0f, 0.0f};
- twist.angular = {0.0f, 0.0f, 0.0f};
- const auto live_command = derive_planner_command(facing_yaw_rad, twist);
- const auto live_planned_30hz = run_planner_command(planner_context, live_command);
- const auto live_planned_50hz = resample_planner_trajectory_to_50hz(live_planned_30hz);
- const auto committed_motion_50hz = blend_planner_motion(
- bootstrap_motion_50hz,
- kFirstLiveReplanTick,
- kFirstLiveReplanTick,
- live_planned_50hz
- );
- const auto committed_joint_velocities =
- compute_motion_joint_velocities_isaaclab(committed_motion_50hz);
-
- const std::array init_base_quat_wxyz = {1.0, 0.0, 0.0, 0.0};
- const auto init_ref_root_quat_wxyz =
- planner_frame_root_quaternion(committed_motion_50hz.front());
- const auto encoder_obs = build_velocity_encoder_obs_dict(
- committed_motion_50hz,
- committed_joint_velocities,
- 0,
- init_base_quat_wxyz,
- init_base_quat_wxyz,
- init_ref_root_quat_wxyz
- );
- const auto tokens = run_single_f32(
- encoder_,
- "obs_dict",
- "encoded_tokens",
- encoder_obs,
- kEncoderObsDictDim
- );
- if (tokens.size() != kEncoderDim) {
- throw std::runtime_error(
- "encoder output dimension mismatch: expected " + std::to_string(kEncoderDim) +
- ", got " + std::to_string(tokens.size())
- );
- }
-
- tracking_state_.push(tracking_obs_, latest_action_);
- const auto decoder_obs = build_decoder_obs_dict(tokens);
- const auto raw_actions = run_single_f32(
- decoder_,
- "obs_dict",
- "action",
- decoder_obs,
- kDecoderObsDictDim
- );
- if (raw_actions.size() != G1_NUM_MOTOR) {
- throw std::runtime_error(
- "decoder output dimension mismatch: expected " + std::to_string(G1_NUM_MOTOR) +
- ", got " + std::to_string(raw_actions.size())
- );
- }
-
- const std::vector planner_command = {
- static_cast(live_command.mode),
- live_command.target_vel,
- live_command.height,
- live_command.movement_direction[0],
- live_command.movement_direction[1],
- live_command.movement_direction[2],
- live_command.facing_direction[0],
- live_command.facing_direction[1],
- live_command.facing_direction[2],
- };
-
- write_matrix_json(*dump_dir_ / "bootstrap_motion_50hz.json", bootstrap_motion_50hz);
- write_matrix_json(*dump_dir_ / "planner_context.json", deque_to_rows(planner_context));
- write_vector_json(*dump_dir_ / "planner_command.json", planner_command);
- write_matrix_json(*dump_dir_ / "planner_motion_30hz.json", live_planned_30hz);
- write_matrix_json(*dump_dir_ / "planner_motion_50hz.json", live_planned_50hz);
- write_matrix_json(
- *dump_dir_ / "planner_motion_50hz_committed.json",
- committed_motion_50hz
- );
- write_matrix_json(
- *dump_dir_ / "planner_joint_velocities_50hz.json",
- committed_joint_velocities
- );
- write_vector_json(*dump_dir_ / "velocity_encoder_obs.json", encoder_obs);
- write_vector_json(*dump_dir_ / "velocity_tokens.json", tokens);
- write_vector_json(*dump_dir_ / "velocity_decoder_obs.json", decoder_obs);
- write_vector_json(*dump_dir_ / "velocity_raw_actions.json", raw_actions);
-
- std::vector positions(G1_NUM_MOTOR, 0.0f);
- for (int mujoco_index = 0; mujoco_index < G1_NUM_MOTOR; ++mujoco_index) {
- const int isaaclab_index = isaaclab_to_mujoco[static_cast(mujoco_index)];
- const float action = raw_actions[static_cast(isaaclab_index)];
- const float scaled =
- action * static_cast(g1_action_scale[static_cast(mujoco_index)]);
- positions[static_cast(mujoco_index)] =
- static_cast(default_angles[static_cast(mujoco_index)]) + scaled;
- }
-
- latest_action_ = raw_actions;
- return positions;
- }
-
- std::vector velocity_later_motion_dump() {
- if (!dump_dir_.has_value()) {
- throw std::runtime_error("--dump-dir is required for gear_sonic_velocity/later_motion_dump");
- }
-
- reset();
- tracking_state_.reset();
- latest_action_.assign(G1_NUM_MOTOR, 0.0f);
-
- std::deque> context;
- const auto standing = make_official_standing_qpos();
- for (std::size_t index = 0; index < kPlannerContextLen; ++index) {
- context.push_back(standing);
- }
-
- const auto idle_planned_30hz = run_planner_command(context, idle_planner_command());
- const auto bootstrap_motion_50hz = resample_planner_trajectory_to_50hz(idle_planned_30hz);
-
- constexpr std::size_t kFirstLiveReplanTick = kPlannerThreadIntervalTicks;
- const auto planner_context =
- rebuild_planner_context_from_motion(bootstrap_motion_50hz, kFirstLiveReplanTick);
-
- float facing_yaw_rad = 0.0f;
- Twist twist;
- twist.linear = {0.6f, 0.0f, 0.0f};
- twist.angular = {0.0f, 0.0f, 0.0f};
- const auto live_command = derive_planner_command(facing_yaw_rad, twist);
- const auto live_planned_30hz = run_planner_command(planner_context, live_command);
- const auto live_planned_50hz = resample_planner_trajectory_to_50hz(live_planned_30hz);
- const auto committed_motion_50hz = blend_planner_motion(
- bootstrap_motion_50hz,
- kFirstLiveReplanTick,
- kFirstLiveReplanTick,
- live_planned_50hz
- );
- const auto committed_joint_velocities =
- compute_motion_joint_velocities_isaaclab(committed_motion_50hz);
- if (committed_motion_50hz.size() <= kLaterMotionProbeTick) {
- throw std::runtime_error("later motion probe tick exceeds committed motion length");
- }
-
- const std::array init_base_quat_wxyz = {1.0, 0.0, 0.0, 0.0};
- const auto init_ref_root_quat_wxyz =
- planner_frame_root_quaternion(committed_motion_50hz.front());
-
- Observation probe_obs;
- std::vector encoder_obs;
- std::vector tokens;
- std::vector decoder_obs;
- std::vector raw_actions;
- for (std::size_t tick = 0; tick <= kLaterMotionProbeTick; ++tick) {
- probe_obs = motion_observation_from_planner_frame(
- committed_motion_50hz,
- committed_joint_velocities,
- tick
- );
- encoder_obs = build_velocity_encoder_obs_dict(
- committed_motion_50hz,
- committed_joint_velocities,
- tick,
- probe_obs.base_quat_wxyz,
- init_base_quat_wxyz,
- init_ref_root_quat_wxyz
- );
- tokens = run_single_f32(
- encoder_,
- "obs_dict",
- "encoded_tokens",
- encoder_obs,
- kEncoderObsDictDim
- );
- if (tokens.size() != kEncoderDim) {
- throw std::runtime_error(
- "encoder output dimension mismatch: expected " + std::to_string(kEncoderDim) +
- ", got " + std::to_string(tokens.size())
- );
- }
-
- tracking_state_.push(probe_obs, latest_action_);
- decoder_obs = build_decoder_obs_dict(tokens);
- raw_actions = run_single_f32(
- decoder_,
- "obs_dict",
- "action",
- decoder_obs,
- kDecoderObsDictDim
- );
- if (raw_actions.size() != G1_NUM_MOTOR) {
- throw std::runtime_error(
- "decoder output dimension mismatch: expected " + std::to_string(G1_NUM_MOTOR) +
- ", got " + std::to_string(raw_actions.size())
- );
- }
- latest_action_ = raw_actions;
- }
-
- write_vector_json(
- *dump_dir_ / "velocity_probe_tick.json",
- std::vector{static_cast(kLaterMotionProbeTick)}
- );
- write_vector_json(*dump_dir_ / "current_joint_positions_mujoco.json", probe_obs.joint_positions);
- write_vector_json(*dump_dir_ / "current_joint_velocities_mujoco.json", probe_obs.joint_velocities);
- write_vector_json(
- *dump_dir_ / "current_base_quat_wxyz.json",
- std::vector{
- static_cast(probe_obs.base_quat_wxyz[0]),
- static_cast(probe_obs.base_quat_wxyz[1]),
- static_cast(probe_obs.base_quat_wxyz[2]),
- static_cast(probe_obs.base_quat_wxyz[3]),
- }
- );
- write_vector_json(
- *dump_dir_ / "current_gravity.json",
- std::vector{
- probe_obs.gravity_vector[0],
- probe_obs.gravity_vector[1],
- probe_obs.gravity_vector[2],
- }
- );
- write_vector_json(
- *dump_dir_ / "current_angular_velocity.json",
- std::vector{
- probe_obs.angular_velocity[0],
- probe_obs.angular_velocity[1],
- probe_obs.angular_velocity[2],
- }
- );
- write_matrix_json(
- *dump_dir_ / "history_joint_positions_isaaclab_offsets.json",
- deque_to_rows(tracking_state_.joint_positions)
- );
- write_matrix_json(
- *dump_dir_ / "history_joint_velocities_isaaclab.json",
- deque_to_rows(tracking_state_.joint_velocities)
- );
- write_matrix_json(
- *dump_dir_ / "history_last_actions.json",
- deque_to_rows(tracking_state_.last_actions)
- );
- write_matrix_json(
- *dump_dir_ / "history_gravity.json",
- deque_to_rows(tracking_state_.gravity)
- );
- write_matrix_json(
- *dump_dir_ / "history_angular_velocity.json",
- deque_to_rows(tracking_state_.angular_velocity)
- );
- write_vector_json(*dump_dir_ / "velocity_encoder_obs.json", encoder_obs);
- write_vector_json(*dump_dir_ / "velocity_tokens.json", tokens);
- write_vector_json(*dump_dir_ / "velocity_decoder_obs.json", decoder_obs);
- write_vector_json(*dump_dir_ / "velocity_raw_actions.json", raw_actions);
-
- std::vector positions(G1_NUM_MOTOR, 0.0f);
- for (int mujoco_index = 0; mujoco_index < G1_NUM_MOTOR; ++mujoco_index) {
- const int isaaclab_index = isaaclab_to_mujoco[static_cast(mujoco_index)];
- const float action = raw_actions[static_cast(isaaclab_index)];
- const float scaled =
- action * static_cast(g1_action_scale[static_cast(mujoco_index)]);
- positions[static_cast(mujoco_index)] =
- static_cast(default_angles[static_cast(mujoco_index)]) + scaled;
- }
-
- return positions;
- }
-
-private:
- Ort::Env env_;
- Ort::MemoryInfo memory_info_;
- Ort::Session encoder_;
- Ort::Session decoder_;
- Ort::Session planner_;
- PlannerState planner_state_;
- TrackingState tracking_state_;
- Observation velocity_obs_;
- Observation tracking_obs_;
- std::vector latest_action_;
- std::optional dump_dir_;
- bool dumped_tracking_tensors_ = false;
-
- void validate_contracts() {
- const auto planner_inputs = session_names(planner_, true);
- const auto planner_outputs = session_names(planner_, false);
- for (const auto* name : {
- "context_mujoco_qpos",
- "target_vel",
- "mode",
- "movement_direction",
- "facing_direction",
- "random_seed",
- "has_specific_target",
- "specific_target_positions",
- "specific_target_headings",
- "allowed_pred_num_tokens",
- "height",
- }) {
- require_name(planner_inputs, name, "planner input");
- }
- require_name(planner_outputs, "mujoco_qpos", "planner output");
- require_name(planner_outputs, "num_pred_frames", "planner output");
-
- const auto encoder_inputs = session_names(encoder_, true);
- const auto encoder_outputs = session_names(encoder_, false);
- require_name(encoder_inputs, "obs_dict", "encoder input");
- require_name(encoder_outputs, "encoded_tokens", "encoder output");
-
- const auto decoder_inputs = session_names(decoder_, true);
- const auto decoder_outputs = session_names(decoder_, false);
- require_name(decoder_inputs, "obs_dict", "decoder input");
- require_name(decoder_outputs, "action", "decoder output");
- }
-
- std::vector planner_context_frame(
- const std::vector& template_frame,
- const Observation& obs
- ) const {
- std::vector frame =
- template_frame.size() == kPlannerQposDim ? template_frame : std::vector(kPlannerQposDim, 0.0f);
- if (frame[2] == 0.0f) {
- frame[2] = kDefaultHeightMeters;
- }
- frame[3] = static_cast(obs.base_quat_wxyz[0]);
- frame[4] = static_cast(obs.base_quat_wxyz[1]);
- frame[5] = static_cast(obs.base_quat_wxyz[2]);
- frame[6] = static_cast(obs.base_quat_wxyz[3]);
- std::copy(obs.joint_positions.begin(), obs.joint_positions.end(), frame.begin() + static_cast(kPlannerJointOffset));
- return frame;
- }
-
- Twist velocity_command() const {
- Twist twist;
- twist.linear = {0.3f, 0.0f, 0.0f};
- twist.angular = {0.0f, 0.0f, 0.0f};
- return twist;
- }
-
- static std::vector make_official_standing_qpos() {
- std::vector qpos(kPlannerQposDim, 0.0f);
- qpos[2] = kOfficialDefaultHeightMeters;
- qpos[3] = 1.0f;
- const auto pose = default_pose();
- std::copy(
- pose.begin(),
- pose.end(),
- qpos.begin() + static_cast(kPlannerJointOffset)
- );
- return qpos;
- }
-
- void commit_planner_motion(
- std::size_t request_motion_frame,
- const PlannerCommand& planner_command,
- const std::vector>& planned_50hz
- ) {
- if (planned_50hz.empty()) {
- throw std::runtime_error("planner produced an empty 50Hz motion sequence");
- }
-
- planner_state_.motion_qpos_50hz =
- planner_state_.motion_qpos_50hz.empty()
- ? planned_50hz
- : blend_planner_motion(
- planner_state_.motion_qpos_50hz,
- planner_state_.current_motion_frame,
- request_motion_frame,
- planned_50hz
- );
- planner_state_.motion_joint_velocities_isaaclab =
- compute_motion_joint_velocities_isaaclab(planner_state_.motion_qpos_50hz);
- planner_state_.current_motion_frame = 0;
- if (planner_state_.motion_qpos_50hz.empty()) {
- planner_state_.init_ref_root_quat_wxyz.reset();
- } else {
- planner_state_.init_ref_root_quat_wxyz =
- planner_frame_root_quaternion(planner_state_.motion_qpos_50hz.front());
- }
- if (!planner_state_.init_base_quat_wxyz.has_value()) {
- planner_state_.init_base_quat_wxyz = velocity_obs_.base_quat_wxyz;
- }
- planner_state_.last_command = planner_command;
- }
-
- void maybe_apply_pending_planner_replan() {
- if (!planner_state_.pending_replan.has_value()) {
- return;
- }
-
- auto& pending = planner_state_.pending_replan.value();
- if (pending.future.wait_for(std::chrono::seconds(0)) != std::future_status::ready) {
- return;
- }
-
- auto ready = std::move(pending);
- planner_state_.pending_replan.reset();
- commit_planner_motion(
- ready.request_motion_frame,
- ready.command,
- ready.future.get()
- );
- }
-
- void start_async_planner_replan(const PlannerCommand& planner_command) {
- PlannerState::PendingPlannerReplan pending;
- pending.request_motion_frame = planner_state_.current_motion_frame;
- pending.command = planner_command;
- const auto context = planner_state_.context;
- pending.future = std::async(
- std::launch::async,
- [this, context, planner_command]() {
- return resample_planner_trajectory_to_50hz(
- run_planner_command(context, planner_command)
- );
- }
- );
- planner_state_.last_command = planner_command;
- planner_state_.pending_replan = std::move(pending);
- }
-
- void advance_planner_motion_frame() {
- if (planner_state_.motion_qpos_50hz.empty()) {
- return;
- }
- planner_state_.current_motion_frame = std::min(
- planner_state_.current_motion_frame + 1,
- planner_state_.motion_qpos_50hz.size() - 1
- );
- }
-
- std::vector> run_planner(
- const std::deque>& context,
- const Twist& twist
- ) {
- const float cmd_norm =
- std::sqrt(twist.linear[0] * twist.linear[0] + twist.linear[1] * twist.linear[1]);
- const std::array movement_direction =
- cmd_norm > 1e-6f
- ? std::array{
- twist.linear[0] / cmd_norm,
- twist.linear[1] / cmd_norm,
- 0.0f,
- }
- : std::array{1.0f, 0.0f, 0.0f};
- const float yaw = twist.angular[2];
- const std::array facing_direction = {
- std::cos(yaw),
- std::sin(yaw),
- 0.0f,
- };
- return run_planner_command(
- context,
- PlannerCommand{
- kDefaultModeWalk,
- cmd_norm,
- kDefaultHeightMeters,
- movement_direction,
- facing_direction,
- }
- );
- }
-
- std::vector> run_planner_command(
- const std::deque>& context,
- const PlannerCommand& command
- ) {
- std::vector context_data;
- context_data.reserve(context.size() * kPlannerQposDim);
- for (const auto& frame : context) {
- context_data.insert(context_data.end(), frame.begin(), frame.end());
- }
-
- const std::array target_vel = {command.target_vel};
- const std::array mode = {command.mode};
- const std::array height = {command.height};
- const std::array random_seed = {0};
- const std::array has_specific_target = {0};
- const std::vector specific_target_positions(12, 0.0f);
- const std::vector specific_target_headings(4, 0.0f);
- const auto allowed_pred_num_tokens = kAllowedPredNumTokensMask;
-
- const std::array context_shape = {
- 1,
- static_cast(kPlannerContextLen),
- static_cast(kPlannerQposDim),
- };
- const std::array vec3_shape = {1, 3};
- const std::array