diff --git a/.github/workflows/sim-check.yml b/.github/workflows/sim-check.yml new file mode 100644 index 0000000..187d537 --- /dev/null +++ b/.github/workflows/sim-check.yml @@ -0,0 +1,137 @@ +name: Sim Check + +# Produces a diagnostic render for each changed behavior that declares a +# registry-owned recipe. Shared runner changes exercise only a small golden +# set; full-catalog runs remain an explicit manual action. + +on: + pull_request: + branches: [main, master] + paths: + - "registry/behaviors/**" + - "registry/schema/**" + - "simulation/**" + - ".github/workflows/sim-check.yml" + workflow_dispatch: + inputs: + behavior: + description: "Behavior id to check (empty = all descriptors)" + required: false + default: "" + +permissions: + contents: read + +env: + MUJOCO_GL: egl + PYOUT: sim-results + +jobs: + detect: + name: Detect changed behaviors + runs-on: ubuntu-latest + outputs: + ids: ${{ steps.set.outputs.ids }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + fetch-depth: 0 + - id: set + run: | + requested="${{ github.event.inputs.behavior }}" + if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -n "$requested" ]; then + ids=$(jq -cn --arg id "$requested" '[$id]') + elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + ids=$(find registry/behaviors -maxdepth 1 -name '*.json' -printf '%f\n' \ + | sed 's/\.json$//' | sort | jq -R -s -c 'split("\n") | map(select(length > 0))') + else + base="${{ github.event.pull_request.base.sha }}" + changed=$(git diff --name-only "$base" HEAD) + descriptor_ids=$(printf '%s\n' "$changed" \ + | sed -n 's#registry/behaviors/\(.*\)\.json$#\1#p' \ + | sort -u | jq -R -s -c 'split("\n") | map(select(length > 0))') + shared=$(printf '%s\n' "$changed" \ + | sed -n '\#^simulation/\|^registry/schema/\|^\.github/workflows/sim-check.yml$#p') + if [ -n "$shared" ]; then + golden='["alpha-walking","jump","max-height-jump","roulade"]' + ids=$(jq -cn --argjson changed "$descriptor_ids" --argjson golden "$golden" \ + '$changed + $golden | unique') + else + ids="$descriptor_ids" + fi + fi + echo "ids=$ids" >> "$GITHUB_OUTPUT" + echo "changed behaviors: $ids" + + simulate: + name: Sim ${{ matrix.id }} + needs: detect + if: needs.detect.outputs.ids != '[]' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + id: ${{ fromJson(needs.detect.outputs.ids) }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Install GL + ffmpeg + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq libegl1 libgl1 ffmpeg + + - name: Install Python deps + run: pip install -r simulation/requirements.txt + + - name: Cache pinned sim assets + uses: actions/cache@v4 + with: + path: .simcache + key: sim-assets-${{ hashFiles('simulation/assets.lock.json') }} + + - name: Test simulation runner + run: PYTHONPATH=simulation python -m unittest discover -s simulation/tests + + - name: Run simulation check + run: | + python simulation/run_check.py \ + --behavior "${{ matrix.id }}" \ + --out "$PYOUT" \ + --keep-media + + - name: Upload sim report + render + if: always() + uses: actions/upload-artifact@v4 + with: + name: sim-${{ matrix.id }} + path: | + ${{ env.PYOUT }}/${{ matrix.id }}/report.json + ${{ env.PYOUT }}/${{ matrix.id }}/loop.mp4 + ${{ env.PYOUT }}/${{ matrix.id }}/poster.png + if-no-files-found: warn + retention-days: 14 + + - name: Job summary + if: always() + run: | + { + echo "## Sim check: ${{ matrix.id }}" + echo "Diagnostic render only — this does not validate hardware behavior or reproduce arbitrary publisher environments." + echo + echo "Download \`sim-${{ matrix.id }}\` from the artifacts on [this workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})." + echo + if [ -f "$PYOUT/${{ matrix.id }}/report.json" ]; then + echo '```json' + cat "$PYOUT/${{ matrix.id }}/report.json" + echo '```' + else + echo "No report produced (run error)." + fi + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore index 99e82dc..813fa21 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,12 @@ pnpm-debug.log* vendor/ *.ses session-*.md + +# CI simulation +.simcache/ +sim-results/ +.research/ + +# Python +__pycache__/ +*.pyc diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index aa9a7e0..f7cb834 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,6 +44,30 @@ uDuck Registry is a directory of Microduck behavior policies. A contribution is For a local media path such as `/media/my-move/loop.mp4`, include the matching file at `public/media/my-move/loop.mp4` in the pull request. +### CI simulation check + +Every pull request that adds or edits a descriptor is automatically run +through the registry's headless MuJoCo runner only when it declares an explicit +`simulation` recipe. `compatibility.robotd_slot` never selects the simulation +scenario. The workflow uploads `report.json`, `loop.mp4`, and `poster.png` for +human review; it does not publish them automatically. + +The report distinguishes a completed render from its individual observations. +Requested checks use a fixed registry vocabulary, and their results are +measured by the runner—not authored in the descriptor. A render is not hardware +verification or proof that a publisher's training environment was reproduced. +The runner rejects recipes it cannot represent before downloading the policy; +it does not silently clamp command values. + +If the policy requires custom environment code, objects, meshes, dependencies, +or a different observation/action contract, use `"runner": "external"` with +an honest reason and provide publisher-owned media instead. Do not give CI a +convenient but inaccurate command schedule just so the policy can be rendered. +Do not add per-policy executable code to this repository. + +See [`simulation/README.md`](simulation/README.md) for recipe examples, start +states, supported scenarios, exact report semantics, and CI isolation rules. + ## Descriptor shape ```json diff --git a/README.md b/README.md index 34c4ea8..bd6b8be 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,16 @@ The table below is generated from the descriptors in `registry/behaviors/`. +## Simulation CI + +Pull requests touching `registry/behaviors/` are automatically run through a +headless MuJoCo diagnostic (`Sim Check` workflow) when the descriptor declares +an explicit registry simulation recipe. The runner records exactly what it +observed and produces a standardized 512×512 review artifact; it does not claim +hardware validation or reproduce arbitrary publisher environments. +See [`simulation/README.md`](simulation/README.md) for the render standard, +scenario model, CI isolation rules, and unsupported cases. + ## Machine-readable access The generated catalog is available at: diff --git a/public/media/registry-sim/alpha-walking/loop.mp4 b/public/media/registry-sim/alpha-walking/loop.mp4 new file mode 100644 index 0000000..d44b5b1 Binary files /dev/null and b/public/media/registry-sim/alpha-walking/loop.mp4 differ diff --git a/public/media/registry-sim/alpha-walking/poster.png b/public/media/registry-sim/alpha-walking/poster.png new file mode 100644 index 0000000..15cb6e7 Binary files /dev/null and b/public/media/registry-sim/alpha-walking/poster.png differ diff --git a/public/media/registry-sim/alpha-walking/report.json b/public/media/registry-sim/alpha-walking/report.json new file mode 100644 index 0000000..1458f07 --- /dev/null +++ b/public/media/registry-sim/alpha-walking/report.json @@ -0,0 +1,79 @@ +{ + "execution": "rendered", + "checks_status": "passed", + "checks": [ + { + "check": "finite_outputs", + "passed": true, + "detail": "max |action| = 0.7221" + }, + { + "check": "bounded_drift", + "passed": true, + "detail": "displacement 0.469 m" + }, + { + "check": "no_fall", + "passed": true, + "detail": "min trunk height 0.1137 m" + }, + { + "check": "ends_upright", + "passed": true, + "detail": "final tilt 3.92 deg" + }, + { + "check": "velocity_tracking", + "passed": true, + "detail": "steady-state speed >= 30% of command (worst 41%), direction cos >= 0.8 (worst 0.98), mean |v_cmd - v_xy| = 0.189 m/s" + } + ], + "observations": { + "duration_s": 6.0, + "control_steps": 300, + "obs_dim": 61, + "command_dim": 13, + "min_trunk_height_m": 0.1137, + "max_trunk_height_m": 0.122, + "final_trunk_height_m": 0.1214, + "max_tilt_deg": 4.53, + "final_tilt_deg": 3.92, + "path_length_m": 0.721, + "displacement_m": 0.469, + "max_abs_action": 0.7221, + "all_finite": true, + "initial_foot_contact": true, + "initial_bilateral_contact": true, + "airborne_observed": false, + "takeoff_after_support": false, + "touchdown_after_takeoff": false, + "mean_tracking_error_mps": 0.1892 + }, + "behavior": "alpha-walking", + "recipe": { + "runner": "microduck-standard-v1", + "scene": "flat-v1", + "start": { + "preset": "settled_standing" + }, + "scenario": "velocity" + }, + "duration_s": 6.0, + "policy": { + "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/policies/BEST_alpha_walking.onnx", + "sha256": "e36332d383997d51401897734cd3e79cf5038406feddb18b4d57ecfb141daa6c", + "baked_normalizer": true + }, + "media": { + "loop_url": "/media/registry-sim/alpha-walking/loop.mp4", + "poster_url": "/media/registry-sim/alpha-walking/poster.png" + }, + "generated_at": "2026-09-02T04:49:04.155415+00:00", + "runtime": { + "mjcf": "robot_allcollisions.xml (pollen-robotics/microduck-simulator, pinned)", + "timestep_s": 0.005, + "decimation": 4, + "control_hz": 50, + "renderer": "mujoco EGL offscreen" + } +} diff --git a/public/media/registry-sim/ball-kick-left/loop.mp4 b/public/media/registry-sim/ball-kick-left/loop.mp4 new file mode 100644 index 0000000..7fae938 Binary files /dev/null and b/public/media/registry-sim/ball-kick-left/loop.mp4 differ diff --git a/public/media/registry-sim/ball-kick-left/poster.png b/public/media/registry-sim/ball-kick-left/poster.png new file mode 100644 index 0000000..c4b338f Binary files /dev/null and b/public/media/registry-sim/ball-kick-left/poster.png differ diff --git a/public/media/registry-sim/ball-kick-left/report.json b/public/media/registry-sim/ball-kick-left/report.json new file mode 100644 index 0000000..ec6bb0d --- /dev/null +++ b/public/media/registry-sim/ball-kick-left/report.json @@ -0,0 +1,73 @@ +{ + "execution": "rendered", + "checks_status": "passed", + "checks": [ + { + "check": "finite_outputs", + "passed": true, + "detail": "max |action| = 1.8749" + }, + { + "check": "bounded_drift", + "passed": true, + "detail": "displacement 0.023 m" + }, + { + "check": "no_fall", + "passed": true, + "detail": "min trunk height 0.1092 m" + }, + { + "check": "ends_upright", + "passed": true, + "detail": "final tilt 0.9 deg" + } + ], + "observations": { + "duration_s": 2.5, + "control_steps": 125, + "obs_dim": 61, + "command_dim": 13, + "min_trunk_height_m": 0.1092, + "max_trunk_height_m": 0.1186, + "final_trunk_height_m": 0.1143, + "max_tilt_deg": 5.52, + "final_tilt_deg": 0.9, + "path_length_m": 0.039, + "displacement_m": 0.023, + "max_abs_action": 1.8749, + "all_finite": true, + "initial_foot_contact": true, + "initial_bilateral_contact": true, + "airborne_observed": true, + "takeoff_after_support": true, + "touchdown_after_takeoff": true + }, + "behavior": "ball-kick-left", + "recipe": { + "runner": "microduck-standard-v1", + "scene": "flat-v1", + "start": { + "preset": "settled_standing" + }, + "scenario": "oneshot_zero" + }, + "duration_s": 2.5, + "policy": { + "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/policies/ball_kick_left.onnx", + "sha256": "d6928284dccd3dd61e08bf2f760effa74309fbefd97b2b31afb2a60f526d196a", + "baked_normalizer": true + }, + "media": { + "loop_url": "/media/registry-sim/ball-kick-left/loop.mp4", + "poster_url": "/media/registry-sim/ball-kick-left/poster.png" + }, + "generated_at": "2026-09-02T04:49:06.900744+00:00", + "runtime": { + "mjcf": "robot_allcollisions.xml (pollen-robotics/microduck-simulator, pinned)", + "timestep_s": 0.005, + "decimation": 4, + "control_hz": 50, + "renderer": "mujoco EGL offscreen" + } +} diff --git a/public/media/registry-sim/ball-kick-right/loop.mp4 b/public/media/registry-sim/ball-kick-right/loop.mp4 new file mode 100644 index 0000000..0ca3929 Binary files /dev/null and b/public/media/registry-sim/ball-kick-right/loop.mp4 differ diff --git a/public/media/registry-sim/ball-kick-right/poster.png b/public/media/registry-sim/ball-kick-right/poster.png new file mode 100644 index 0000000..83ef02a Binary files /dev/null and b/public/media/registry-sim/ball-kick-right/poster.png differ diff --git a/public/media/registry-sim/ball-kick-right/report.json b/public/media/registry-sim/ball-kick-right/report.json new file mode 100644 index 0000000..97ec11f --- /dev/null +++ b/public/media/registry-sim/ball-kick-right/report.json @@ -0,0 +1,73 @@ +{ + "execution": "rendered", + "checks_status": "passed", + "checks": [ + { + "check": "finite_outputs", + "passed": true, + "detail": "max |action| = 1.3731" + }, + { + "check": "bounded_drift", + "passed": true, + "detail": "displacement 0.029 m" + }, + { + "check": "no_fall", + "passed": true, + "detail": "min trunk height 0.1082 m" + }, + { + "check": "ends_upright", + "passed": true, + "detail": "final tilt 0.71 deg" + } + ], + "observations": { + "duration_s": 2.5, + "control_steps": 125, + "obs_dim": 61, + "command_dim": 13, + "min_trunk_height_m": 0.1082, + "max_trunk_height_m": 0.119, + "final_trunk_height_m": 0.1136, + "max_tilt_deg": 5.35, + "final_tilt_deg": 0.71, + "path_length_m": 0.041, + "displacement_m": 0.029, + "max_abs_action": 1.3731, + "all_finite": true, + "initial_foot_contact": true, + "initial_bilateral_contact": true, + "airborne_observed": false, + "takeoff_after_support": false, + "touchdown_after_takeoff": false + }, + "behavior": "ball-kick-right", + "recipe": { + "runner": "microduck-standard-v1", + "scene": "flat-v1", + "start": { + "preset": "settled_standing" + }, + "scenario": "oneshot_zero" + }, + "duration_s": 2.5, + "policy": { + "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/policies/ball_kick_right.onnx", + "sha256": "147a32c388c6b19111b3ac3b550a9a6dc8b8bf267118af4d8c3712522eedb5af", + "baked_normalizer": true + }, + "media": { + "loop_url": "/media/registry-sim/ball-kick-right/loop.mp4", + "poster_url": "/media/registry-sim/ball-kick-right/poster.png" + }, + "generated_at": "2026-09-02T04:49:21.010749+00:00", + "runtime": { + "mjcf": "robot_allcollisions.xml (pollen-robotics/microduck-simulator, pinned)", + "timestep_s": 0.005, + "decimation": 4, + "control_hz": 50, + "renderer": "mujoco EGL offscreen" + } +} diff --git a/public/media/registry-sim/genesis-backlash/loop.mp4 b/public/media/registry-sim/genesis-backlash/loop.mp4 new file mode 100644 index 0000000..98cb29d Binary files /dev/null and b/public/media/registry-sim/genesis-backlash/loop.mp4 differ diff --git a/public/media/registry-sim/genesis-backlash/poster.png b/public/media/registry-sim/genesis-backlash/poster.png new file mode 100644 index 0000000..d76dbef Binary files /dev/null and b/public/media/registry-sim/genesis-backlash/poster.png differ diff --git a/public/media/registry-sim/genesis-backlash/report.json b/public/media/registry-sim/genesis-backlash/report.json new file mode 100644 index 0000000..1c72c20 --- /dev/null +++ b/public/media/registry-sim/genesis-backlash/report.json @@ -0,0 +1,79 @@ +{ + "execution": "rendered", + "checks_status": "passed", + "checks": [ + { + "check": "finite_outputs", + "passed": true, + "detail": "max |action| = 0.8178" + }, + { + "check": "bounded_drift", + "passed": true, + "detail": "displacement 0.56 m" + }, + { + "check": "no_fall", + "passed": true, + "detail": "min trunk height 0.115 m" + }, + { + "check": "ends_upright", + "passed": true, + "detail": "final tilt 2.48 deg" + }, + { + "check": "velocity_tracking", + "passed": true, + "detail": "steady-state speed >= 30% of command (worst 47%), direction cos >= 0.8 (worst 0.98), mean |v_cmd - v_xy| = 0.176 m/s" + } + ], + "observations": { + "duration_s": 6.0, + "control_steps": 300, + "obs_dim": 61, + "command_dim": 13, + "min_trunk_height_m": 0.115, + "max_trunk_height_m": 0.1225, + "final_trunk_height_m": 0.1221, + "max_tilt_deg": 3.58, + "final_tilt_deg": 2.48, + "path_length_m": 0.81, + "displacement_m": 0.56, + "max_abs_action": 0.8178, + "all_finite": true, + "initial_foot_contact": true, + "initial_bilateral_contact": true, + "airborne_observed": false, + "takeoff_after_support": false, + "touchdown_after_takeoff": false, + "mean_tracking_error_mps": 0.1763 + }, + "behavior": "genesis-backlash", + "recipe": { + "runner": "microduck-standard-v1", + "scene": "flat-v1", + "start": { + "preset": "settled_standing" + }, + "scenario": "velocity" + }, + "duration_s": 6.0, + "policy": { + "url": "https://raw.githubusercontent.com/Macmachi/microduck-rl-genesis/9fa4b270023b8b9b50809fa6dc15a28996f5c724/policies/backlash.onnx", + "sha256": "3f8db8bc2c11b2e41665633c1780af21bae3fda7db229eb5035e6c2d5698c075", + "baked_normalizer": true + }, + "media": { + "loop_url": "/media/registry-sim/genesis-backlash/loop.mp4", + "poster_url": "/media/registry-sim/genesis-backlash/poster.png" + }, + "generated_at": "2026-09-02T04:49:25.440902+00:00", + "runtime": { + "mjcf": "robot_allcollisions.xml (pollen-robotics/microduck-simulator, pinned)", + "timestep_s": 0.005, + "decimation": 4, + "control_hz": 50, + "renderer": "mujoco EGL offscreen" + } +} diff --git a/public/media/registry-sim/genesis-velocity/loop.mp4 b/public/media/registry-sim/genesis-velocity/loop.mp4 new file mode 100644 index 0000000..92bedfc Binary files /dev/null and b/public/media/registry-sim/genesis-velocity/loop.mp4 differ diff --git a/public/media/registry-sim/genesis-velocity/poster.png b/public/media/registry-sim/genesis-velocity/poster.png new file mode 100644 index 0000000..98f36f6 Binary files /dev/null and b/public/media/registry-sim/genesis-velocity/poster.png differ diff --git a/public/media/registry-sim/genesis-velocity/report.json b/public/media/registry-sim/genesis-velocity/report.json new file mode 100644 index 0000000..1f58ff6 --- /dev/null +++ b/public/media/registry-sim/genesis-velocity/report.json @@ -0,0 +1,79 @@ +{ + "execution": "rendered", + "checks_status": "passed", + "checks": [ + { + "check": "finite_outputs", + "passed": true, + "detail": "max |action| = 0.6363" + }, + { + "check": "bounded_drift", + "passed": true, + "detail": "displacement 0.484 m" + }, + { + "check": "no_fall", + "passed": true, + "detail": "min trunk height 0.1147 m" + }, + { + "check": "ends_upright", + "passed": true, + "detail": "final tilt 2.93 deg" + }, + { + "check": "velocity_tracking", + "passed": true, + "detail": "steady-state speed >= 30% of command (worst 39%), direction cos >= 0.8 (worst 0.96), mean |v_cmd - v_xy| = 0.181 m/s" + } + ], + "observations": { + "duration_s": 6.0, + "control_steps": 300, + "obs_dim": 61, + "command_dim": 13, + "min_trunk_height_m": 0.1147, + "max_trunk_height_m": 0.1212, + "final_trunk_height_m": 0.1205, + "max_tilt_deg": 4.23, + "final_tilt_deg": 2.93, + "path_length_m": 0.692, + "displacement_m": 0.484, + "max_abs_action": 0.6363, + "all_finite": true, + "initial_foot_contact": true, + "initial_bilateral_contact": true, + "airborne_observed": false, + "takeoff_after_support": false, + "touchdown_after_takeoff": false, + "mean_tracking_error_mps": 0.1807 + }, + "behavior": "genesis-velocity", + "recipe": { + "runner": "microduck-standard-v1", + "scene": "flat-v1", + "start": { + "preset": "settled_standing" + }, + "scenario": "velocity" + }, + "duration_s": 6.0, + "policy": { + "url": "https://raw.githubusercontent.com/Macmachi/microduck-rl-genesis/9fa4b270023b8b9b50809fa6dc15a28996f5c724/policies/velocity.onnx", + "sha256": "c315b9159a1b6f30976c90074ed6df2a33e7e1d14ef1505aed6c2c673f59061d", + "baked_normalizer": true + }, + "media": { + "loop_url": "/media/registry-sim/genesis-velocity/loop.mp4", + "poster_url": "/media/registry-sim/genesis-velocity/poster.png" + }, + "generated_at": "2026-09-02T04:49:28.232018+00:00", + "runtime": { + "mjcf": "robot_allcollisions.xml (pollen-robotics/microduck-simulator, pinned)", + "timestep_s": 0.005, + "decimation": 4, + "control_hz": 50, + "renderer": "mujoco EGL offscreen" + } +} diff --git a/public/media/registry-sim/ground-pick/loop.mp4 b/public/media/registry-sim/ground-pick/loop.mp4 new file mode 100644 index 0000000..7176cd0 Binary files /dev/null and b/public/media/registry-sim/ground-pick/loop.mp4 differ diff --git a/public/media/registry-sim/ground-pick/poster.png b/public/media/registry-sim/ground-pick/poster.png new file mode 100644 index 0000000..2998224 Binary files /dev/null and b/public/media/registry-sim/ground-pick/poster.png differ diff --git a/public/media/registry-sim/ground-pick/report.json b/public/media/registry-sim/ground-pick/report.json new file mode 100644 index 0000000..52b8f02 --- /dev/null +++ b/public/media/registry-sim/ground-pick/report.json @@ -0,0 +1,73 @@ +{ + "execution": "rendered", + "checks_status": "passed", + "checks": [ + { + "check": "finite_outputs", + "passed": true, + "detail": "max |action| = 1.7106" + }, + { + "check": "bounded_drift", + "passed": true, + "detail": "displacement 0.007 m" + }, + { + "check": "no_fall", + "passed": true, + "detail": "min trunk height 0.0839 m" + }, + { + "check": "ends_upright", + "passed": true, + "detail": "final tilt 2.12 deg" + } + ], + "observations": { + "duration_s": 2.5, + "control_steps": 125, + "obs_dim": 61, + "command_dim": 13, + "min_trunk_height_m": 0.0839, + "max_trunk_height_m": 0.1173, + "final_trunk_height_m": 0.1169, + "max_tilt_deg": 33.73, + "final_tilt_deg": 2.12, + "path_length_m": 0.105, + "displacement_m": 0.007, + "max_abs_action": 1.7106, + "all_finite": true, + "initial_foot_contact": true, + "initial_bilateral_contact": true, + "airborne_observed": false, + "takeoff_after_support": false, + "touchdown_after_takeoff": false + }, + "behavior": "ground-pick", + "recipe": { + "runner": "microduck-standard-v1", + "scene": "flat-v1", + "start": { + "preset": "settled_standing" + }, + "scenario": "oneshot_phase" + }, + "duration_s": 2.5, + "policy": { + "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/policies/alpha_ground_pick.onnx", + "sha256": "ffbf5109982ff999b0ba53afe86b9ae731bbec679d67fb7f8ab4c52152c88872", + "baked_normalizer": true + }, + "media": { + "loop_url": "/media/registry-sim/ground-pick/loop.mp4", + "poster_url": "/media/registry-sim/ground-pick/poster.png" + }, + "generated_at": "2026-09-02T04:49:30.928931+00:00", + "runtime": { + "mjcf": "robot_allcollisions.xml (pollen-robotics/microduck-simulator, pinned)", + "timestep_s": 0.005, + "decimation": 4, + "control_hz": 50, + "renderer": "mujoco EGL offscreen" + } +} diff --git a/public/media/registry-sim/jump/loop.mp4 b/public/media/registry-sim/jump/loop.mp4 new file mode 100644 index 0000000..6b36fe7 Binary files /dev/null and b/public/media/registry-sim/jump/loop.mp4 differ diff --git a/public/media/registry-sim/jump/poster.png b/public/media/registry-sim/jump/poster.png new file mode 100644 index 0000000..db2761b Binary files /dev/null and b/public/media/registry-sim/jump/poster.png differ diff --git a/public/media/registry-sim/jump/report.json b/public/media/registry-sim/jump/report.json new file mode 100644 index 0000000..c43ff82 --- /dev/null +++ b/public/media/registry-sim/jump/report.json @@ -0,0 +1,63 @@ +{ + "execution": "rendered", + "checks_status": "passed", + "checks": [ + { + "check": "finite_outputs", + "passed": true, + "detail": "max |action| = 3.4947" + }, + { + "check": "bounded_drift", + "passed": true, + "detail": "displacement 0.38 m" + } + ], + "observations": { + "duration_s": 4.0, + "control_steps": 200, + "obs_dim": 61, + "command_dim": 13, + "min_trunk_height_m": 0.0939, + "max_trunk_height_m": 0.1677, + "final_trunk_height_m": 0.117, + "max_tilt_deg": 9.42, + "final_tilt_deg": 1.1, + "path_length_m": 0.583, + "displacement_m": 0.38, + "max_abs_action": 3.4947, + "all_finite": true, + "initial_foot_contact": true, + "initial_bilateral_contact": true, + "airborne_observed": true, + "takeoff_after_support": true, + "touchdown_after_takeoff": true + }, + "behavior": "jump", + "recipe": { + "runner": "microduck-standard-v1", + "scene": "flat-v1", + "start": { + "preset": "settled_standing" + }, + "scenario": "oneshot_trigger" + }, + "duration_s": 4.0, + "policy": { + "url": "https://raw.githubusercontent.com/Liyucheng1997/318_lab-microduck-simulator/512d4bec6fc3ba321d29c93312be72856ad21268/app/public/policies/jump.onnx", + "sha256": "0b10d7f50f2225467771c1fd11e027490e775b762c2e50c9e25f82c0f488e5c4", + "baked_normalizer": true + }, + "media": { + "loop_url": "/media/registry-sim/jump/loop.mp4", + "poster_url": "/media/registry-sim/jump/poster.png" + }, + "generated_at": "2026-09-02T04:49:33.129618+00:00", + "runtime": { + "mjcf": "robot_allcollisions.xml (pollen-robotics/microduck-simulator, pinned)", + "timestep_s": 0.005, + "decimation": 4, + "control_hz": 50, + "renderer": "mujoco EGL offscreen" + } +} diff --git a/public/media/registry-sim/max-height-jump/loop.mp4 b/public/media/registry-sim/max-height-jump/loop.mp4 new file mode 100644 index 0000000..a3746eb Binary files /dev/null and b/public/media/registry-sim/max-height-jump/loop.mp4 differ diff --git a/public/media/registry-sim/max-height-jump/poster.png b/public/media/registry-sim/max-height-jump/poster.png new file mode 100644 index 0000000..6dae058 Binary files /dev/null and b/public/media/registry-sim/max-height-jump/poster.png differ diff --git a/public/media/registry-sim/max-height-jump/report.json b/public/media/registry-sim/max-height-jump/report.json new file mode 100644 index 0000000..4dc9289 --- /dev/null +++ b/public/media/registry-sim/max-height-jump/report.json @@ -0,0 +1,63 @@ +{ + "execution": "rendered", + "checks_status": "passed", + "checks": [ + { + "check": "finite_outputs", + "passed": true, + "detail": "max |action| = 2.4675" + }, + { + "check": "bounded_drift", + "passed": true, + "detail": "displacement 0.006 m" + } + ], + "observations": { + "duration_s": 4.0, + "control_steps": 200, + "obs_dim": 61, + "command_dim": 13, + "min_trunk_height_m": 0.1155, + "max_trunk_height_m": 0.1227, + "final_trunk_height_m": 0.1227, + "max_tilt_deg": 6.26, + "final_tilt_deg": 4.76, + "path_length_m": 0.089, + "displacement_m": 0.006, + "max_abs_action": 2.4675, + "all_finite": true, + "initial_foot_contact": true, + "initial_bilateral_contact": true, + "airborne_observed": true, + "takeoff_after_support": true, + "touchdown_after_takeoff": true + }, + "behavior": "max-height-jump", + "recipe": { + "runner": "microduck-standard-v1", + "scene": "flat-v1", + "start": { + "preset": "settled_standing" + }, + "scenario": "oneshot_trigger" + }, + "duration_s": 4.0, + "policy": { + "url": "https://raw.githubusercontent.com/ThomasBurgess2000/microduck-max-height-jump/7e5dc6028900f13d145e6710847378b007a675e9/policy/max_height_jump.onnx", + "sha256": "046debd3eebd61a8c027d5595c1bca4fe32056fbb0ae63ac0b2f4e3798e1270f", + "baked_normalizer": true + }, + "media": { + "loop_url": "/media/registry-sim/max-height-jump/loop.mp4", + "poster_url": "/media/registry-sim/max-height-jump/poster.png" + }, + "generated_at": "2026-09-02T04:35:33.111444+00:00", + "runtime": { + "mjcf": "robot_allcollisions.xml (pollen-robotics/microduck-simulator, pinned)", + "timestep_s": 0.005, + "decimation": 4, + "control_hz": 50, + "renderer": "mujoco EGL offscreen" + } +} diff --git a/public/media/registry-sim/roller-crouch/loop.mp4 b/public/media/registry-sim/roller-crouch/loop.mp4 new file mode 100644 index 0000000..4943735 Binary files /dev/null and b/public/media/registry-sim/roller-crouch/loop.mp4 differ diff --git a/public/media/registry-sim/roller-crouch/poster.png b/public/media/registry-sim/roller-crouch/poster.png new file mode 100644 index 0000000..40fc30a Binary files /dev/null and b/public/media/registry-sim/roller-crouch/poster.png differ diff --git a/public/media/registry-sim/roller-crouch/report.json b/public/media/registry-sim/roller-crouch/report.json new file mode 100644 index 0000000..1559e88 --- /dev/null +++ b/public/media/registry-sim/roller-crouch/report.json @@ -0,0 +1,74 @@ +{ + "execution": "rendered", + "checks_status": "passed", + "checks": [ + { + "check": "finite_outputs", + "passed": true, + "detail": "max |action| = 2.9948" + }, + { + "check": "bounded_drift", + "passed": true, + "detail": "displacement 0.01 m" + }, + { + "check": "no_fall", + "passed": true, + "detail": "min trunk height 0.0689 m" + }, + { + "check": "ends_upright", + "passed": true, + "detail": "final tilt 2.85 deg" + } + ], + "observations": { + "duration_s": 5.0, + "control_steps": 250, + "obs_dim": 61, + "command_dim": 13, + "min_trunk_height_m": 0.0689, + "max_trunk_height_m": 0.1381, + "final_trunk_height_m": 0.1007, + "max_tilt_deg": 5.37, + "final_tilt_deg": 2.85, + "path_length_m": 0.075, + "displacement_m": 0.01, + "max_abs_action": 2.9948, + "all_finite": true, + "initial_foot_contact": true, + "initial_bilateral_contact": true, + "airborne_observed": false, + "takeoff_after_support": false, + "touchdown_after_takeoff": false + }, + "behavior": "roller-crouch", + "recipe": { + "runner": "microduck-standard-v1", + "model": "microduck-rollers", + "scene": "flat-v1", + "start": { + "preset": "settled_standing" + }, + "scenario": "oneshot_phase" + }, + "duration_s": 5.0, + "policy": { + "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/policies/BEST_roller_crouch.onnx", + "sha256": "a1a084be240469c76ac9d3fa44d4792f16d4b1da60398b3ecd3cfc5e2244d990", + "baked_normalizer": true + }, + "media": { + "loop_url": "/media/registry-sim/roller-crouch/loop.mp4", + "poster_url": "/media/registry-sim/roller-crouch/poster.png" + }, + "generated_at": "2026-09-02T05:13:28.919135+00:00", + "runtime": { + "mjcf": "robot_allcollisions_rollers.xml (pollen-robotics/microduck-simulator, pinned)", + "timestep_s": 0.005, + "decimation": 4, + "control_hz": 50, + "renderer": "mujoco EGL offscreen" + } +} diff --git a/public/media/registry-sim/roulade/loop.mp4 b/public/media/registry-sim/roulade/loop.mp4 new file mode 100644 index 0000000..5f161f8 Binary files /dev/null and b/public/media/registry-sim/roulade/loop.mp4 differ diff --git a/public/media/registry-sim/roulade/poster.png b/public/media/registry-sim/roulade/poster.png new file mode 100644 index 0000000..8732cf6 Binary files /dev/null and b/public/media/registry-sim/roulade/poster.png differ diff --git a/public/media/registry-sim/roulade/report.json b/public/media/registry-sim/roulade/report.json new file mode 100644 index 0000000..c6019c4 --- /dev/null +++ b/public/media/registry-sim/roulade/report.json @@ -0,0 +1,68 @@ +{ + "execution": "rendered", + "checks_status": "passed", + "checks": [ + { + "check": "finite_outputs", + "passed": true, + "detail": "max |action| = 3.3413" + }, + { + "check": "bounded_drift", + "passed": true, + "detail": "displacement 0.501 m" + }, + { + "check": "recover_upright", + "passed": true, + "detail": "final tilt 2.69 deg, final height 0.1148 m" + } + ], + "observations": { + "duration_s": 4.0, + "control_steps": 200, + "obs_dim": 61, + "command_dim": 13, + "min_trunk_height_m": 0.0482, + "max_trunk_height_m": 0.1888, + "final_trunk_height_m": 0.1148, + "max_tilt_deg": 166.8, + "final_tilt_deg": 2.69, + "path_length_m": 0.689, + "displacement_m": 0.501, + "max_abs_action": 3.3413, + "all_finite": true, + "initial_foot_contact": true, + "initial_bilateral_contact": true, + "airborne_observed": true, + "takeoff_after_support": true, + "touchdown_after_takeoff": true + }, + "behavior": "roulade", + "recipe": { + "runner": "microduck-standard-v1", + "scene": "flat-v1", + "start": { + "preset": "settled_standing" + }, + "scenario": "oneshot_zero" + }, + "duration_s": 4.0, + "policy": { + "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/policies/roulade.onnx", + "sha256": "3d60da08fc13f29c1b57f41977aa898132c0d60042100149d8e775affcbca32b", + "baked_normalizer": true + }, + "media": { + "loop_url": "/media/registry-sim/roulade/loop.mp4", + "poster_url": "/media/registry-sim/roulade/poster.png" + }, + "generated_at": "2026-09-02T04:50:55.429289+00:00", + "runtime": { + "mjcf": "robot_allcollisions.xml (pollen-robotics/microduck-simulator, pinned)", + "timestep_s": 0.005, + "decimation": 4, + "control_hz": 50, + "renderer": "mujoco EGL offscreen" + } +} diff --git a/public/media/registry-sim/sit-stand/loop.mp4 b/public/media/registry-sim/sit-stand/loop.mp4 new file mode 100644 index 0000000..72d0f62 Binary files /dev/null and b/public/media/registry-sim/sit-stand/loop.mp4 differ diff --git a/public/media/registry-sim/sit-stand/poster.png b/public/media/registry-sim/sit-stand/poster.png new file mode 100644 index 0000000..56f93db Binary files /dev/null and b/public/media/registry-sim/sit-stand/poster.png differ diff --git a/public/media/registry-sim/sit-stand/report.json b/public/media/registry-sim/sit-stand/report.json new file mode 100644 index 0000000..137faf4 --- /dev/null +++ b/public/media/registry-sim/sit-stand/report.json @@ -0,0 +1,68 @@ +{ + "execution": "rendered", + "checks_status": "passed", + "checks": [ + { + "check": "finite_outputs", + "passed": true, + "detail": "max |action| = 1.5597" + }, + { + "check": "bounded_drift", + "passed": true, + "detail": "displacement 0.029 m" + }, + { + "check": "recover_upright", + "passed": true, + "detail": "final tilt 3.49 deg, final height 0.116 m" + } + ], + "observations": { + "duration_s": 6.0, + "control_steps": 300, + "obs_dim": 61, + "command_dim": 13, + "min_trunk_height_m": 0.0588, + "max_trunk_height_m": 0.1166, + "final_trunk_height_m": 0.116, + "max_tilt_deg": 6.8, + "final_tilt_deg": 3.49, + "path_length_m": 0.139, + "displacement_m": 0.029, + "max_abs_action": 1.5597, + "all_finite": true, + "initial_foot_contact": true, + "initial_bilateral_contact": true, + "airborne_observed": true, + "takeoff_after_support": true, + "touchdown_after_takeoff": true + }, + "behavior": "sit-stand", + "recipe": { + "runner": "microduck-standard-v1", + "scene": "flat-v1", + "start": { + "preset": "settled_standing" + }, + "scenario": "sitstand" + }, + "duration_s": 6.0, + "policy": { + "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/policies/BEST_alpha_sitstand.onnx", + "sha256": "c6c40e35e726eabd803d633e090d112994f469921152448367953fbaf9799bc8", + "baked_normalizer": true + }, + "media": { + "loop_url": "/media/registry-sim/sit-stand/loop.mp4", + "poster_url": "/media/registry-sim/sit-stand/poster.png" + }, + "generated_at": "2026-09-02T04:51:03.388442+00:00", + "runtime": { + "mjcf": "robot_allcollisions.xml (pollen-robotics/microduck-simulator, pinned)", + "timestep_s": 0.005, + "decimation": 4, + "control_hz": 50, + "renderer": "mujoco EGL offscreen" + } +} diff --git a/public/registry.json b/public/registry.json index 8fd1835..67f4992 100644 --- a/public/registry.json +++ b/public/registry.json @@ -79,6 +79,18 @@ }, "deployment": { "robotd_toml": "[policy]\nroulade = \"/opt/robot/policies/roulade.onnx\"" + }, + "simulation": { + "runner": "microduck-standard-v1", + "scene": "flat-v1", + "start": { + "preset": "settled_standing" + }, + "scenario": "oneshot_zero", + "duration_s": 4, + "checks": [ + "recover_upright" + ] } }, { @@ -159,6 +171,40 @@ }, "deployment": { "robotd_toml": "[policy]\nwalk = \"/opt/robot/policies/BEST_alpha_walking.onnx\"" + }, + "simulation": { + "runner": "microduck-standard-v1", + "scene": "flat-v1", + "start": { + "preset": "settled_standing" + }, + "scenario": "velocity", + "duration_s": 6, + "checks": [ + "no_fall", + "ends_upright", + "velocity_tracking" + ], + "segments": [ + { + "duration_s": 1, + "vx": 0, + "vy": 0, + "wz": 0 + }, + { + "duration_s": 3, + "vx": 0.25, + "vy": 0, + "wz": 0 + }, + { + "duration_s": 2, + "vx": 0.25, + "vy": 0, + "wz": 0.5 + } + ] } }, { @@ -240,6 +286,21 @@ }, "deployment": { "robotd_toml": "[policy]\nground_pick = \"/opt/robot/policies/alpha_ground_pick.onnx\"" + }, + "simulation": { + "runner": "microduck-standard-v1", + "scene": "flat-v1", + "start": { + "preset": "settled_standing" + }, + "scenario": "oneshot_phase", + "duration_s": 2.5, + "checks": [ + "no_fall", + "ends_upright" + ], + "period_s": 4, + "end_phase": 0.7 } }, { @@ -321,6 +382,11 @@ }, "deployment": { "robotd_toml": "[policy]\nstand = \"/opt/robot/policies/BEST_alpha_stand.onnx\"" + }, + "simulation": { + "runner": "external", + "reason": "custom_environment", + "notes": "Recovery starts from publisher-specific fall states and full-body contacts that the registry runner does not reproduce." } }, { @@ -403,6 +469,19 @@ }, "deployment": { "robotd_toml": "[policy]\nkick_left = \"/opt/robot/policies/ball_kick_left.onnx\"" + }, + "simulation": { + "runner": "microduck-standard-v1", + "scene": "flat-v1", + "start": { + "preset": "settled_standing" + }, + "scenario": "oneshot_zero", + "duration_s": 2.5, + "checks": [ + "no_fall", + "ends_upright" + ] } }, { @@ -483,6 +562,19 @@ }, "deployment": { "robotd_toml": "[policy]\nkick_right = \"/opt/robot/policies/ball_kick_right.onnx\"" + }, + "simulation": { + "runner": "microduck-standard-v1", + "scene": "flat-v1", + "start": { + "preset": "settled_standing" + }, + "scenario": "oneshot_zero", + "duration_s": 2.5, + "checks": [ + "no_fall", + "ends_upright" + ] } }, { @@ -564,6 +656,22 @@ }, "deployment": { "robotd_toml": "[policy]\nmode = \"roller\"\nground_pick = \"/opt/robot/policies/BEST_roller_crouch.onnx\"" + }, + "simulation": { + "runner": "microduck-standard-v1", + "model": "microduck-rollers", + "scene": "flat-v1", + "start": { + "preset": "settled_standing" + }, + "scenario": "oneshot_phase", + "duration_s": 5, + "checks": [ + "no_fall", + "ends_upright" + ], + "period_s": 5, + "end_phase": 0.6 } }, { @@ -646,6 +754,11 @@ }, "deployment": { "robotd_toml": "[policy]\nmode = \"roller\"\nwalk = \"/opt/robot/policies/BEST_roller.onnx\"" + }, + "simulation": { + "runner": "external", + "reason": "custom_assets", + "notes": "Requires the publisher's roller-wheel model; the registry runner currently renders the standard-foot model only." } }, { @@ -726,6 +839,19 @@ }, "deployment": { "robotd_toml": "[policy]\nsitstand = \"/opt/robot/policies/BEST_alpha_sitstand.onnx\"" + }, + "simulation": { + "runner": "microduck-standard-v1", + "scene": "flat-v1", + "start": { + "preset": "settled_standing" + }, + "scenario": "sitstand", + "duration_s": 6, + "checks": [ + "recover_upright" + ], + "hold_s": 2 } }, { @@ -803,6 +929,11 @@ }, "deployment": { "robotd_toml": "[policy]\nwalk = \"/opt/robot/policies/flamingo-cycle/policy.onnx\"" + }, + "simulation": { + "runner": "external", + "reason": "custom_contract", + "notes": "Uses the publisher-defined twist = [flag, side, 0] balance command rather than the registry velocity schedule." } }, { @@ -878,6 +1009,40 @@ }, "deployment": { "robotd_toml": "[policy]\nwalk = \"/opt/robot/policies/backlash.onnx\"" + }, + "simulation": { + "runner": "microduck-standard-v1", + "scene": "flat-v1", + "start": { + "preset": "settled_standing" + }, + "scenario": "velocity", + "duration_s": 6, + "checks": [ + "no_fall", + "ends_upright", + "velocity_tracking" + ], + "segments": [ + { + "duration_s": 1, + "vx": 0, + "vy": 0, + "wz": 0 + }, + { + "duration_s": 3, + "vx": 0.25, + "vy": 0, + "wz": 0 + }, + { + "duration_s": 2, + "vx": 0.25, + "vy": 0, + "wz": 0.5 + } + ] } }, { @@ -953,6 +1118,40 @@ }, "deployment": { "robotd_toml": "[policy]\nwalk = \"/opt/robot/policies/velocity.onnx\"" + }, + "simulation": { + "runner": "microduck-standard-v1", + "scene": "flat-v1", + "start": { + "preset": "settled_standing" + }, + "scenario": "velocity", + "duration_s": 6, + "checks": [ + "no_fall", + "ends_upright", + "velocity_tracking" + ], + "segments": [ + { + "duration_s": 1, + "vx": 0, + "vy": 0, + "wz": 0 + }, + { + "duration_s": 3, + "vx": 0.25, + "vy": 0, + "wz": 0 + }, + { + "duration_s": 2, + "vx": 0.25, + "vy": 0, + "wz": 0.5 + } + ] } }, { @@ -1028,6 +1227,11 @@ }, "deployment": { "robotd_toml": "[policy]\nwalk = \"/opt/robot/policies/rough.onnx\"" + }, + "simulation": { + "runner": "external", + "reason": "custom_environment", + "notes": "Trained for rough terrain in a Genesis environment; the registry runner currently owns only a flat scene." } }, { @@ -1116,6 +1320,16 @@ }, "deployment": { "robotd_toml": "[policy]\ncustom = \"/opt/robot/policies/max_height_jump.onnx\"\n# twist-vx: 1 requests launch; return it to 0 after touchdown.\n# The preview's durable reset also needs the companion standing ONNX and the documented 0.22 s / 0.14 s runtime blend." + }, + "simulation": { + "runner": "microduck-standard-v1", + "scene": "flat-v1", + "start": { + "preset": "settled_standing" + }, + "scenario": "oneshot_trigger", + "duration_s": 4, + "trigger_s": 0.2 } }, { @@ -1194,6 +1408,11 @@ }, "deployment": { "robotd_toml": "[policy]\ncustom = \"/opt/robot/policies/courier-policy.onnx\"" + }, + "simulation": { + "runner": "external", + "reason": "custom_environment", + "notes": "Uses a publisher-specific apartment, objects, and task command semantics that the registry runner does not reproduce." } }, { @@ -1270,6 +1489,11 @@ }, "deployment": { "robotd_toml": "[policy]\nwalk = \"/opt/robot/policies/running/policy.onnx\"" + }, + "simulation": { + "runner": "external", + "reason": "custom_environment", + "notes": "The published Mjlab-Running-Flat-MicroDuck task uses a 2.2 m/s command envelope and training/runtime details outside the registry standard-v1 diagnostic runner." } }, { @@ -1349,6 +1573,11 @@ }, "deployment": { "robotd_toml": "[policy]\nwalk = \"/opt/robot/policies/rough-walk-e/policy.onnx\"" + }, + "simulation": { + "runner": "external", + "reason": "custom_environment", + "notes": "The published behavior targets rough ground, stairs, rubble, and slopes; the registry runner currently owns only a flat scene." } }, { @@ -1428,6 +1657,11 @@ }, "deployment": { "robotd_toml": "[policy]\nwalk = \"/opt/robot/policies/rough-walk-g/policy.onnx\"" + }, + "simulation": { + "runner": "external", + "reason": "custom_environment", + "notes": "The published behavior targets rough ground, stairs, rubble, and slopes; the registry runner currently owns only a flat scene." } }, { @@ -1503,6 +1737,16 @@ }, "deployment": { "robotd_toml": "[policy]\ncustom = \"/opt/robot/policies/jump.onnx\"" + }, + "simulation": { + "runner": "microduck-standard-v1", + "scene": "flat-v1", + "start": { + "preset": "settled_standing" + }, + "scenario": "oneshot_trigger", + "duration_s": 4, + "trigger_s": 0.2 } } ] diff --git a/registry/behaviors/alpha-walking.json b/registry/behaviors/alpha-walking.json index b6c4e91..ae6ab56 100644 --- a/registry/behaviors/alpha-walking.json +++ b/registry/behaviors/alpha-walking.json @@ -55,6 +55,40 @@ ], "robotd_slot": "walk" }, + "simulation": { + "runner": "microduck-standard-v1", + "scene": "flat-v1", + "start": { + "preset": "settled_standing" + }, + "scenario": "velocity", + "duration_s": 6, + "checks": [ + "no_fall", + "ends_upright", + "velocity_tracking" + ], + "segments": [ + { + "duration_s": 1, + "vx": 0, + "vy": 0, + "wz": 0 + }, + { + "duration_s": 3, + "vx": 0.25, + "vy": 0, + "wz": 0 + }, + { + "duration_s": 2, + "vx": 0.25, + "vy": 0, + "wz": 0.5 + } + ] + }, "artifacts": { "onnx": { "filename": "BEST_alpha_walking.onnx", diff --git a/registry/behaviors/ball-kick-left.json b/registry/behaviors/ball-kick-left.json index a5a349d..3ffe501 100644 --- a/registry/behaviors/ball-kick-left.json +++ b/registry/behaviors/ball-kick-left.json @@ -57,6 +57,19 @@ ], "robotd_slot": "kick_left" }, + "simulation": { + "runner": "microduck-standard-v1", + "scene": "flat-v1", + "start": { + "preset": "settled_standing" + }, + "scenario": "oneshot_zero", + "duration_s": 2.5, + "checks": [ + "no_fall", + "ends_upright" + ] + }, "artifacts": { "onnx": { "filename": "ball_kick_left.onnx", diff --git a/registry/behaviors/ball-kick-right.json b/registry/behaviors/ball-kick-right.json index 256ab9e..dd1c876 100644 --- a/registry/behaviors/ball-kick-right.json +++ b/registry/behaviors/ball-kick-right.json @@ -57,6 +57,19 @@ ], "robotd_slot": "kick_right" }, + "simulation": { + "runner": "microduck-standard-v1", + "scene": "flat-v1", + "start": { + "preset": "settled_standing" + }, + "scenario": "oneshot_zero", + "duration_s": 2.5, + "checks": [ + "no_fall", + "ends_upright" + ] + }, "artifacts": { "onnx": { "filename": "ball_kick_right.onnx", diff --git a/registry/behaviors/courier.json b/registry/behaviors/courier.json index 0be88c0..ab729d3 100644 --- a/registry/behaviors/courier.json +++ b/registry/behaviors/courier.json @@ -54,6 +54,11 @@ ], "robotd_slot": "custom" }, + "simulation": { + "runner": "external", + "reason": "custom_environment", + "notes": "Uses a publisher-specific apartment, objects, and task command semantics that the registry runner does not reproduce." + }, "artifacts": { "onnx": { "filename": "courier-policy.onnx", diff --git a/registry/behaviors/fall-recovery.json b/registry/behaviors/fall-recovery.json index dd91ee7..b7ec3b5 100644 --- a/registry/behaviors/fall-recovery.json +++ b/registry/behaviors/fall-recovery.json @@ -56,6 +56,11 @@ ], "robotd_slot": "stand" }, + "simulation": { + "runner": "external", + "reason": "custom_environment", + "notes": "Recovery starts from publisher-specific fall states and full-body contacts that the registry runner does not reproduce." + }, "artifacts": { "onnx": { "filename": "BEST_alpha_stand.onnx", diff --git a/registry/behaviors/flamingo-cycle.json b/registry/behaviors/flamingo-cycle.json index 82e571b..33a1d08 100644 --- a/registry/behaviors/flamingo-cycle.json +++ b/registry/behaviors/flamingo-cycle.json @@ -54,6 +54,11 @@ ], "robotd_slot": "walk" }, + "simulation": { + "runner": "external", + "reason": "custom_contract", + "notes": "Uses the publisher-defined twist = [flag, side, 0] balance command rather than the registry velocity schedule." + }, "artifacts": { "onnx": { "filename": "policy.onnx", diff --git a/registry/behaviors/genesis-backlash.json b/registry/behaviors/genesis-backlash.json index c61d7ba..bd35b7b 100644 --- a/registry/behaviors/genesis-backlash.json +++ b/registry/behaviors/genesis-backlash.json @@ -53,6 +53,40 @@ ], "robotd_slot": "walk" }, + "simulation": { + "runner": "microduck-standard-v1", + "scene": "flat-v1", + "start": { + "preset": "settled_standing" + }, + "scenario": "velocity", + "duration_s": 6, + "checks": [ + "no_fall", + "ends_upright", + "velocity_tracking" + ], + "segments": [ + { + "duration_s": 1, + "vx": 0, + "vy": 0, + "wz": 0 + }, + { + "duration_s": 3, + "vx": 0.25, + "vy": 0, + "wz": 0 + }, + { + "duration_s": 2, + "vx": 0.25, + "vy": 0, + "wz": 0.5 + } + ] + }, "artifacts": { "onnx": { "filename": "backlash.onnx", diff --git a/registry/behaviors/genesis-rough.json b/registry/behaviors/genesis-rough.json index d213a09..f8261b1 100644 --- a/registry/behaviors/genesis-rough.json +++ b/registry/behaviors/genesis-rough.json @@ -53,6 +53,11 @@ ], "robotd_slot": "walk" }, + "simulation": { + "runner": "external", + "reason": "custom_environment", + "notes": "Trained for rough terrain in a Genesis environment; the registry runner currently owns only a flat scene." + }, "artifacts": { "onnx": { "filename": "rough.onnx", diff --git a/registry/behaviors/genesis-velocity.json b/registry/behaviors/genesis-velocity.json index 398679f..4f8fc96 100644 --- a/registry/behaviors/genesis-velocity.json +++ b/registry/behaviors/genesis-velocity.json @@ -53,6 +53,40 @@ ], "robotd_slot": "walk" }, + "simulation": { + "runner": "microduck-standard-v1", + "scene": "flat-v1", + "start": { + "preset": "settled_standing" + }, + "scenario": "velocity", + "duration_s": 6, + "checks": [ + "no_fall", + "ends_upright", + "velocity_tracking" + ], + "segments": [ + { + "duration_s": 1, + "vx": 0, + "vy": 0, + "wz": 0 + }, + { + "duration_s": 3, + "vx": 0.25, + "vy": 0, + "wz": 0 + }, + { + "duration_s": 2, + "vx": 0.25, + "vy": 0, + "wz": 0.5 + } + ] + }, "artifacts": { "onnx": { "filename": "velocity.onnx", diff --git a/registry/behaviors/ground-pick.json b/registry/behaviors/ground-pick.json index 5cdfb22..ad39564 100644 --- a/registry/behaviors/ground-pick.json +++ b/registry/behaviors/ground-pick.json @@ -56,6 +56,21 @@ ], "robotd_slot": "ground_pick" }, + "simulation": { + "runner": "microduck-standard-v1", + "scene": "flat-v1", + "start": { + "preset": "settled_standing" + }, + "scenario": "oneshot_phase", + "duration_s": 2.5, + "checks": [ + "no_fall", + "ends_upright" + ], + "period_s": 4, + "end_phase": 0.7 + }, "artifacts": { "onnx": { "filename": "alpha_ground_pick.onnx", diff --git a/registry/behaviors/jump.json b/registry/behaviors/jump.json index a2f0dfa..b19cfb5 100644 --- a/registry/behaviors/jump.json +++ b/registry/behaviors/jump.json @@ -53,6 +53,16 @@ ], "robotd_slot": "custom" }, + "simulation": { + "runner": "microduck-standard-v1", + "scene": "flat-v1", + "start": { + "preset": "settled_standing" + }, + "scenario": "oneshot_trigger", + "duration_s": 4, + "trigger_s": 0.2 + }, "artifacts": { "onnx": { "filename": "jump.onnx", diff --git a/registry/behaviors/max-height-jump.json b/registry/behaviors/max-height-jump.json index a2921f5..07c4fbe 100644 --- a/registry/behaviors/max-height-jump.json +++ b/registry/behaviors/max-height-jump.json @@ -57,6 +57,16 @@ ], "robotd_slot": "custom" }, + "simulation": { + "runner": "microduck-standard-v1", + "scene": "flat-v1", + "start": { + "preset": "settled_standing" + }, + "scenario": "oneshot_trigger", + "duration_s": 4, + "trigger_s": 0.2 + }, "artifacts": { "onnx": { "filename": "max_height_jump.onnx", diff --git a/registry/behaviors/roller-crouch.json b/registry/behaviors/roller-crouch.json index 01748a4..456b916 100644 --- a/registry/behaviors/roller-crouch.json +++ b/registry/behaviors/roller-crouch.json @@ -58,6 +58,22 @@ ], "robotd_slot": "ground_pick" }, + "simulation": { + "runner": "microduck-standard-v1", + "model": "microduck-rollers", + "scene": "flat-v1", + "start": { + "preset": "settled_standing" + }, + "scenario": "oneshot_phase", + "duration_s": 5, + "period_s": 5, + "end_phase": 0.6, + "checks": [ + "no_fall", + "ends_upright" + ] + }, "artifacts": { "onnx": { "filename": "BEST_roller_crouch.onnx", diff --git a/registry/behaviors/roller-drive.json b/registry/behaviors/roller-drive.json index 037db99..0155f7d 100644 --- a/registry/behaviors/roller-drive.json +++ b/registry/behaviors/roller-drive.json @@ -57,6 +57,11 @@ ], "robotd_slot": "roller" }, + "simulation": { + "runner": "external", + "reason": "custom_assets", + "notes": "Requires the publisher's roller-wheel model; the registry runner currently renders the standard-foot model only." + }, "artifacts": { "onnx": { "filename": "BEST_roller.onnx", diff --git a/registry/behaviors/rough-walk-e.json b/registry/behaviors/rough-walk-e.json index b4560bf..04ba0eb 100644 --- a/registry/behaviors/rough-walk-e.json +++ b/registry/behaviors/rough-walk-e.json @@ -56,6 +56,11 @@ ], "robotd_slot": "walk" }, + "simulation": { + "runner": "external", + "reason": "custom_environment", + "notes": "The published behavior targets rough ground, stairs, rubble, and slopes; the registry runner currently owns only a flat scene." + }, "artifacts": { "onnx": { "filename": "policy.onnx", diff --git a/registry/behaviors/rough-walk-g.json b/registry/behaviors/rough-walk-g.json index 0d42b9f..7256876 100644 --- a/registry/behaviors/rough-walk-g.json +++ b/registry/behaviors/rough-walk-g.json @@ -56,6 +56,11 @@ ], "robotd_slot": "walk" }, + "simulation": { + "runner": "external", + "reason": "custom_environment", + "notes": "The published behavior targets rough ground, stairs, rubble, and slopes; the registry runner currently owns only a flat scene." + }, "artifacts": { "onnx": { "filename": "policy.onnx", diff --git a/registry/behaviors/roulade.json b/registry/behaviors/roulade.json index dc0bc6b..6fc3e58 100644 --- a/registry/behaviors/roulade.json +++ b/registry/behaviors/roulade.json @@ -55,6 +55,18 @@ ], "robotd_slot": "roulade" }, + "simulation": { + "runner": "microduck-standard-v1", + "scene": "flat-v1", + "start": { + "preset": "settled_standing" + }, + "scenario": "oneshot_zero", + "duration_s": 4, + "checks": [ + "recover_upright" + ] + }, "artifacts": { "onnx": { "filename": "roulade.onnx", diff --git a/registry/behaviors/running.json b/registry/behaviors/running.json index 21cae20..83c45fd 100644 --- a/registry/behaviors/running.json +++ b/registry/behaviors/running.json @@ -53,6 +53,11 @@ ], "robotd_slot": "walk" }, + "simulation": { + "runner": "external", + "reason": "custom_environment", + "notes": "The published Mjlab-Running-Flat-MicroDuck task uses a 2.2 m/s command envelope and training/runtime details outside the registry standard-v1 diagnostic runner." + }, "artifacts": { "onnx": { "filename": "policy.onnx", diff --git a/registry/behaviors/sit-stand.json b/registry/behaviors/sit-stand.json index 653f7be..558b3fb 100644 --- a/registry/behaviors/sit-stand.json +++ b/registry/behaviors/sit-stand.json @@ -55,6 +55,19 @@ ], "robotd_slot": "sitstand" }, + "simulation": { + "runner": "microduck-standard-v1", + "scene": "flat-v1", + "start": { + "preset": "settled_standing" + }, + "scenario": "sitstand", + "duration_s": 6, + "checks": [ + "recover_upright" + ], + "hold_s": 2 + }, "artifacts": { "onnx": { "filename": "BEST_alpha_sitstand.onnx", diff --git a/registry/schema/behavior.schema.json b/registry/schema/behavior.schema.json index 82276cb..9061661 100644 --- a/registry/schema/behavior.schema.json +++ b/registry/schema/behavior.schema.json @@ -342,6 +342,102 @@ } } }, + "simulation": { + "description": "Optional diagnostic render recipe, independent from compatibility.robotd_slot.", + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["runner", "scene", "start", "scenario", "duration_s"], + "properties": { + "runner": { "type": "string", "const": "microduck-standard-v1" }, + "model": { "type": "string", "enum": ["microduck-standard", "microduck-rollers"] }, + "scene": { "type": "string", "const": "flat-v1" }, + "start": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["preset"], + "properties": { "preset": { "type": "string", "const": "standing_pose" } } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["preset"], + "properties": { + "preset": { "type": "string", "const": "settled_standing" }, + "settle_s": { "type": "number", "minimum": 0.05, "maximum": 1 } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["preset", "trunk_height_m", "orientation"], + "properties": { + "preset": { "type": "string", "const": "airborne_drop" }, + "trunk_height_m": { "type": "number", "minimum": 0.15, "maximum": 0.5 }, + "orientation": { "type": "string", "enum": ["upright", "front", "back", "left", "right"] }, + "linear_velocity_mps": { + "type": "array", + "minItems": 3, + "maxItems": 3, + "items": { "type": "number", "minimum": -3, "maximum": 3 } + } + } + } + ] + }, + "scenario": { + "type": "string", + "enum": ["velocity", "standing", "sitstand", "oneshot_phase", "oneshot_zero", "oneshot_trigger"] + }, + "duration_s": { "type": "number", "minimum": 1, "maximum": 30 }, + "checks": { + "type": "array", + "maxItems": 8, + "items": { + "type": "string", + "enum": ["no_fall", "ends_upright", "recover_upright", "velocity_tracking", "takeoff", "touchdown_after_takeoff"] + } + }, + "trigger_s": { "type": "number", "minimum": 0, "maximum": 5 }, + "period_s": { "type": "number", "exclusiveMinimum": 0, "maximum": 30 }, + "end_phase": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 }, + "hold_s": { "type": "number", "minimum": 0, "maximum": 30 }, + "segments": { + "type": "array", + "minItems": 1, + "maxItems": 12, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["duration_s", "vx", "vy", "wz"], + "properties": { + "duration_s": { "type": "number", "exclusiveMinimum": 0, "maximum": 30 }, + "vx": { "type": "number" }, + "vy": { "type": "number" }, + "wz": { "type": "number" } + } + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["runner", "reason"], + "properties": { + "runner": { "type": "string", "const": "external" }, + "reason": { + "type": "string", + "enum": ["custom_environment", "custom_contract", "custom_assets", "publisher_only"] + }, + "notes": { "$ref": "#/$defs/nonEmptyString" } + } + } + ] + }, "sources": { "type": "object", "additionalProperties": false, diff --git a/registry/schema/behavior.ts b/registry/schema/behavior.ts index 40ece46..fe6cfc3 100644 --- a/registry/schema/behavior.ts +++ b/registry/schema/behavior.ts @@ -21,6 +21,7 @@ const MediaUrlSchema = z.string().refine(isAllowedMediaUrl, { const SemverSchema = z .string() .regex(/^\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$/, "Must follow semver"); +const BoundedSimulationVelocitySchema = z.number().min(-3).max(3); /** Verification labels describe the evidence available for each behavior. */ export const VerificationStatusSchema = z.enum([ @@ -64,6 +65,73 @@ export type RobotDSlot = z.infer; export const TerrainSchema = z.enum(["flat", "rough", "slope", "any"]); export type Terrain = z.infer; +export const SimulationCheckSchema = z.enum([ + "no_fall", + "ends_upright", + "recover_upright", + "velocity_tracking", + "takeoff", + "touchdown_after_takeoff", +]); + +const SimulationStartSchema = z.discriminatedUnion("preset", [ + strict({ + preset: z.literal("standing_pose"), + }), + strict({ + preset: z.literal("settled_standing"), + settle_s: z.number().min(0.05).max(1).optional(), + }), + strict({ + preset: z.literal("airborne_drop"), + trunk_height_m: z.number().min(0.15).max(0.5), + orientation: z.enum(["upright", "front", "back", "left", "right"]), + linear_velocity_mps: z.tuple([ + BoundedSimulationVelocitySchema, + BoundedSimulationVelocitySchema, + BoundedSimulationVelocitySchema, + ]).optional(), + }), +]); + +const RegistrySimulationSchema = strict({ + runner: z.literal("microduck-standard-v1"), + model: z.enum(["microduck-standard", "microduck-rollers"]).optional(), + scene: z.literal("flat-v1"), + start: SimulationStartSchema, + scenario: z.enum([ + "velocity", + "standing", + "sitstand", + "oneshot_phase", + "oneshot_zero", + "oneshot_trigger", + ]), + duration_s: z.number().min(1).max(30), + checks: z.array(SimulationCheckSchema).max(8).optional(), + trigger_s: z.number().min(0).max(5).optional(), + period_s: z.number().positive().max(30).optional(), + end_phase: z.number().positive().max(1).optional(), + hold_s: z.number().min(0).max(30).optional(), + segments: z.array(strict({ + duration_s: z.number().positive().max(30), + vx: z.number(), + vy: z.number(), + wz: z.number(), + })).min(1).max(12).optional(), +}); + +const ExternalSimulationSchema = strict({ + runner: z.literal("external"), + reason: z.enum([ + "custom_environment", + "custom_contract", + "custom_assets", + "publisher_only", + ]), + notes: NonEmptyStringSchema.optional(), +}); + const BehaviorInputSchema = strict({ id: z.string().regex(ID_PATTERN, "Must be a lowercase kebab-case slug"), name: z.string().min(2), @@ -153,6 +221,13 @@ const BehaviorInputSchema = strict({ deployment: strict({ robotd_toml: NonEmptyStringSchema, }), + + // Optional registry-owned diagnostic render recipe. Compatibility and + // installation slots never select or imply this scenario. + simulation: z.discriminatedUnion("runner", [ + RegistrySimulationSchema, + ExternalSimulationSchema, + ]).optional(), }); export const BehaviorSchema = BehaviorInputSchema; diff --git a/research/ci-sim-viability.md b/research/ci-sim-viability.md new file mode 100644 index 0000000..4b49c08 --- /dev/null +++ b/research/ci-sim-viability.md @@ -0,0 +1,74 @@ +# CI simulation + render check: viability assessment + +**Verdict: viable, built, and validated.** Branch `feat/ci-sim-render`. + +## What was built + +- `simulation/` — headless Microduck policy runtime (Python, MuJoCo + onnxruntime), + hash-pinned upstream assets, explicit registry recipes, measured checks, and + a deterministic 512x512 H.264 render loop + poster generator. +- `.github/workflows/sim-check.yml` — PR-gated workflow: detects changed + descriptors, runs one sim job per behavior, uploads report + render artifacts, + and writes a job summary. Requested runner checks can fail the PR; an explicit + external recipe is reported as unsupported rather than treated as a failure. +- Optional `simulation` descriptor block (JSON Schema + zod) to pin a runner, + robot model, scene, start state, scenario, and requested checks per behavior. + +## Ground truth chain + +The official Pollen simulator (HF Space `microduck-simulator`) runs the exact +stack we need: MuJoCo physics + onnxruntime-web policies at 50 Hz, decimation 4, +61D obs = gyro(3) + projected gravity(3) + joint pos rel(14) + joint vel(14) + +last action(14) + command(13). The canonical reference is +`pollen-robotics/microduck_rl` `scripts/infer_policy.py` (Rust `robotd` on the +robot mirrors the same contract). The Space's MJCF (`robot_allcollisions.xml`) +is byte-identical to the one in `microduck_rl`. + +## Validation performed + +1. **Obs-level**: our port's 61D observation at reset matches upstream + `PolicyInference.get_observations()` exactly (max abs diff 0.0, same scene, + same ONNX). +2. **Trajectory-level**: driving both the unmodified upstream reference script + and our runtime with `BEST_alpha_walking.onnx`, cmd vx=0.25, 8 s: + upstream 0.8376 m total / 0.1011 m last-second; ours 0.8133 m / 0.1040 m + (~3% float-ordering drift). Runtime is a faithful port. +3. **Golden-reference limits**: `max-height-jump` (author-documented 0.628 m/s + launch, 31.67 mm rise) does not launch under the standard profile with any + simple trigger encoding we tried (max 0.208 m/s at the XML's 125 Hz default). + The author's bespoke eval protocol is not recoverable from the descriptor + alone — this is exactly what the `simulation` block is for. Checks are + calibrated to "runs safely under the standard contract", not to reproducing + author setups. +4. **Rendering**: EGL software rendering works headless (this box has no GPU); + GH ubuntu runners support the same via `libegl1`. ffmpeg encodes the loop. + +## Findings that matter + +- The registry contract is fixed at 61 observations (including the unified 13D + command) and 14 actions. The runtime accepts a dynamic batch axis but rejects + artifacts whose feature or action dimensions do not match that contract. +- Deterministic CPU sim under-reports locomotion speed vs hardware claims + (~40-50% of commanded vx for the official walk policy with a step command). + Tracking checks verify direction + a minimum fraction, not equality. +- Policy behavior is highly sensitive to the exact command protocol (sitstand + flag, phase-encoded one-shots, kick windows). The named scenarios encode the + documented upstream semantics (from `constants.js`/`infer_policy.py`) without + making the installation slot select a render recipe. + +## China / restricted-region angle + +Sim-rendered loops are generated in CI and uploaded as workflow artifacts for +review. A maintainer may deliberately promote a reviewed result under +`public/media/registry-sim/`; the site uses it only as a fallback when publisher +media is absent and otherwise shows it as a separate diagnostic. Original +author media remains canonical and preferred where available (mirrored via the +existing `remote-cache`). CI does not publish generated renders automatically. + +## Costs + +- Per-behavior sim job: ~1-2 min on ubuntu-latest (assets cached by lock hash; + 6 s rollout ≈ 300 control steps + 150 renders). Worst case (a PR touching all + descriptors) runs the matrix in parallel. +- No GPU, no HF compute, no upstream permission needed (Apache-2.0 assets, + hash-pinned, attributed). diff --git a/simulation/README.md b/simulation/README.md new file mode 100644 index 0000000..a4dfe3c --- /dev/null +++ b/simulation/README.md @@ -0,0 +1,139 @@ +# Registry simulation (`simulation/`) + +The registry runner produces a deterministic diagnostic rollout and review +media for policies that explicitly opt into its constrained environment. A +render is not hardware verification and does not reproduce arbitrary publisher +training environments. + +## Recipe model + +Simulation is independent from `compatibility.robotd_slot`: + +```json +"simulation": { + "runner": "microduck-standard-v1", + "scene": "flat-v1", + "start": { "preset": "standing_pose" }, + "scenario": "velocity", + "duration_s": 6, + "checks": ["no_fall", "ends_upright", "velocity_tracking"], + "segments": [ + { "duration_s": 1, "vx": 0, "vy": 0, "wz": 0 }, + { "duration_s": 3, "vx": 0.25, "vy": 0, "wz": 0 }, + { "duration_s": 2, "vx": 0, "vy": 0, "wz": 0 } + ] +} +``` + +- `scene` is persistent world geometry. V1 supports only the registry-owned + `flat-v1` scene; a rough-terrain policy rendered there is only a flat-world + diagnostic. +- `model` selects the pinned robot asset variant and must match the behavior's + compatibility model. It defaults to that compatibility model; V1 supports + `microduck-standard` and the official `microduck-rollers` model. +- `start` is the robot state at time zero. V1 supports the raw + `standing_pose` (contact is not implied), `settled_standing`, and a bounded + `airborne_drop` preset. An airborne reset is reported as such and is not + counted as takeoff. +- `scenario` is the command schedule: `velocity`, `standing`, `sitstand`, + `oneshot_phase`, `oneshot_zero`, or `oneshot_trigger`. +- `checks` selects runner-defined assertions. Descriptors cannot provide check + prose or results. + +Before downloading a policy or starting MuJoCo, the runner performs a +deterministic admission check. It verifies the declared contract, model, scene, +start preset, scenario, and command schedule. Velocity schedules must be +explicit, cover the rollout exactly, and stay within the runner's supported +command range. A recipe that does not fit is rejected; command values are +never silently clipped or replaced with a default. + +If the policy requires custom assets, a different observation/action contract, +or a publisher-specific environment, declare that boundary instead of adding +code to the registry runner: + +```json +"simulation": { + "runner": "external", + "reason": "custom_environment", + "notes": "Uses the publisher's obstacle scene." +} +``` + +Having the fixed 61D/14D ONNX contract is not enough for admission: the +command protocol and environment must also be represented. Do not give CI a +convenient but inaccurate command schedule just so the policy can be rendered. +If the policy's command protocol or environment is not supported, use +`external` until it has a matching runner profile. + +Omitting `simulation` is also valid and produces an unsupported/no-recipe CI +report when that descriptor changes. + +## What the report says + +Top-level execution is one of `rendered`, `unsupported`, `rejected`, or +`failed`. A rendered report includes exact observations and individual check +outcomes. It never emits a general policy-validation or hardware-validation +claim. + +The report also records the preflight status and any runtime-fidelity warnings, +such as a descriptor declaring BAM actuator dynamics while the registry runner +uses its deterministic position-control diagnostic model. That warning does not +turn a render into a reproduction claim. + +Baseline numerical-integrity and bounded-drift checks always run. Requested +checks may additionally cover falls, final posture, velocity tracking, +supported takeoff, and bilateral touchdown after takeoff. A failing requested +check fails CI only after the report and media have been produced for review. + +## Usage and outputs + +```bash +python -m venv .venv && . .venv/bin/activate +pip install -r simulation/requirements.txt # + system: libegl1, ffmpeg +python simulation/run_check.py --behavior alpha-walking --keep-media --out sim-results +``` + +Outputs under `sim-results//`: + +| File | Meaning | +| --- | --- | +| `report.json` | Execution status, recipe, observations, checks, and provenance | +| `loop.mp4` | 512×512 H.264, 30 fps, muted diagnostic rollout | +| `poster.png` | 512×512 midpoint frame with an inset caption bar | + +Exit code 0 means rendered checks passed or the recipe is explicitly +unsupported; 1 means a requested check failed; 2 means preflight rejected the +recipe or execution failed. + +After human review, a maintainer may deliberately publish one result to the +site: + +```bash +python simulation/publish_result.py sim-results/alpha-walking +``` + +Publisher media is never replaced. Published registry renders are used as card +and hero fallbacks when publisher media is absent, and otherwise appear in a +separate **Registry simulation** section on the behavior page. + +## CI isolation + +- A changed descriptor runs only that behavior. +- Shared runner/schema/workflow changes run the fixed golden set: + `alpha-walking`, `jump`, `max-height-jump`, and `roulade`. +- A full-catalog run is manual through `workflow_dispatch`. +- Artifacts are retained for 14 days and are not automatically published. + +Fork PRs use read-only permissions, no secrets, and the `pull_request` event. +The runner does not execute contributor Python, install per-policy dependencies, +or accept contributor-provided scenes. + +## Render and runtime standard + +- MuJoCo EGL offscreen renderer, square 512×512 H.264 `yuv420p`, 30 fps; +- fixed smoothed chase camera and registry-owned visual stage; +- pinned official Microduck MJCF variant and deterministic CPU rollout; +- 50 Hz control, decimation 4, 61 observations, and 14 actions. + +The runtime is a constrained compatibility aid. Publisher footage and external +evaluation remain the source of truth for environments the runner does not own. diff --git a/simulation/assets.lock.json b/simulation/assets.lock.json new file mode 100644 index 0000000..81aadc8 --- /dev/null +++ b/simulation/assets.lock.json @@ -0,0 +1,240 @@ +{ + "source": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator (pinned 2026-09-01)", + "model_dir": "microduck-mjlab", + "model_path": "robot_allcollisions.xml", + "files": [ + { + "path": "robot_allcollisions.xml", + "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/robot/mjlab/robot_allcollisions.xml", + "sha256": "7a6fdf437f5a80c7348ad801f43f906b997a834389cb70cca5e1e8517ba38044" + }, + { + "path": "assets/ankle_left.stl", + "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/robot/mjlab/meshes/ankle_left.stl", + "sha256": "df5d7f04d63390a1e263e0138ee94e1aec2ff2b55cf63729f4ec461b25c1c1f0" + }, + { + "path": "assets/ankle_right.stl", + "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/robot/mjlab/meshes/ankle_right.stl", + "sha256": "48b8551a13d22cc8752ddf3148796b357a6cbd6e2ecbd93c3ecdfbf55e533e72" + }, + { + "path": "assets/banana_pcb_locker.stl", + "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/robot/mjlab/meshes/banana_pcb_locker.stl", + "sha256": "2e51f6e23e4dbea660a9dbadcd763bbc77d48a7e3e0a58f047eab45554e07b55" + }, + { + "path": "assets/bearing_roll.stl", + "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/robot/mjlab/meshes/bearing_roll.stl", + "sha256": "fa0da0a5eef428a41da6e9639f75120781594e600c5db7518621750221669849" + }, + { + "path": "assets/bottom_head_shell.stl", + "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/robot/mjlab/meshes/bottom_head_shell.stl", + "sha256": "331a361406e77c05256e5064197f785ac1e9c8f091079a3519c2e33f28ae5ebc" + }, + { + "path": "assets/elec_rpi_robot_hat_pcb.stl", + "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/robot/mjlab/meshes/elec_rpi_robot_hat_pcb.stl", + "sha256": "acc66b75f2ff26f42339577c38c6f348ec73c4fbb52912637ba5244eb1a78ebb" + }, + { + "path": "assets/face_part.stl", + "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/robot/mjlab/meshes/face_part.stl", + "sha256": "47ad1137a68bf72b2c93f1bf4836f18fb9a8d21a4f8f86fbe761b9871233fcce" + }, + { + "path": "assets/foot_left.stl", + "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/robot/mjlab/meshes/foot_left.stl", + "sha256": "b7749b1f4fd9e7faf8180772d7ce8254139c60bc43aee463ec76afc86212d59c" + }, + { + "path": "assets/foot_right.stl", + "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/robot/mjlab/meshes/foot_right.stl", + "sha256": "e2aba02ec1b949bc8a5a77ad3d0af9009dee80c100aa4bbfcf336f1fa5ef206f" + }, + { + "path": "assets/hip_l.stl", + "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/robot/mjlab/meshes/hip_l.stl", + "sha256": "dbdf6e7f5587385c0f6a77b407931b30e81c9a212dac7c52b4e0884156049056" + }, + { + "path": "assets/jaw.stl", + "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/robot/mjlab/meshes/jaw.stl", + "sha256": "90afa64e47ef237f0f58d6f4830dc3cd6215328ed8a639b1e1a9eb2157307f4a" + }, + { + "path": "assets/jaw_soft.stl", + "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/robot/mjlab/meshes/jaw_soft.stl", + "sha256": "6c3ebb578ea5e6d7b995baca9b42ef7c9f9858b56dedf6f0d949f5e6ea9d3ef2" + }, + { + "path": "assets/left_shell.stl", + "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/robot/mjlab/meshes/left_shell.stl", + "sha256": "c31e71e2e55c5daccfef738c80053104ba2fb3589a0836ab1b6ebb5c380a05ae" + }, + { + "path": "assets/leg.stl", + "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/robot/mjlab/meshes/leg.stl", + "sha256": "bfbc6ae5faefc56bd65d860cf3410a08e8286717063584f15b56bbd5302b20f8" + }, + { + "path": "assets/lens.stl", + "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/robot/mjlab/meshes/lens.stl", + "sha256": "b9b86fce70198040781a739e2c15d2996b549b25d73b7544005ae08a415b486e" + }, + { + "path": "assets/m12_lens_holder.stl", + "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/robot/mjlab/meshes/m12_lens_holder.stl", + "sha256": "d96825a096d5d3d5d252086cf0cdb3f5bc22d9f0baef0a3f97a85bae1617be99" + }, + { + "path": "assets/motor_support.stl", + "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/robot/mjlab/meshes/motor_support.stl", + "sha256": "d6725004f0a85a5cf0161214ea0d39fb6f14ee3563c9a96cd5aad725b5e2a716" + }, + { + "path": "assets/neck.stl", + "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/robot/mjlab/meshes/neck.stl", + "sha256": "3cb654ee9387d55c7d10d40fc84bb7cbf3cedb3243d1da499cf01c54d366079c" + }, + { + "path": "assets/neck_pitch.stl", + "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/robot/mjlab/meshes/neck_pitch.stl", + "sha256": "dbd6fcbf8b8f27a8364d5bcae2b9a537d7e384325b44a653b360139b6fa1da2f" + }, + { + "path": "assets/noenoeil.stl", + "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/robot/mjlab/meshes/noenoeil.stl", + "sha256": "55f3167627f6fbd1a19966f92396adf2ccd55795b13ded3b9f52705ba8a7ab77" + }, + { + "path": "assets/np_f970.stl", + "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/robot/mjlab/meshes/np_f970.stl", + "sha256": "01fbacc788b53ebb7648cda07090133b8c696da31d700c560f45189494977871" + }, + { + "path": "assets/pcb__raspberry_pi_zero_2_w.stl", + "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/robot/mjlab/meshes/pcb__raspberry_pi_zero_2_w.stl", + "sha256": "43d8368ced3d623eaa5ef538e8361f5878b85931b7cd469a4488eee7aa00bd91" + }, + { + "path": "assets/power_support.stl", + "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/robot/mjlab/meshes/power_support.stl", + "sha256": "4aee9ddf8c12478d334f3d8d9e65af712072bef241283a08c895606fc2e3d9e8" + }, + { + "path": "assets/right_shell.stl", + "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/robot/mjlab/meshes/right_shell.stl", + "sha256": "c346581c2e3c2c189ea49372a147dfbc0dca89db4ec02f9f230921e480e762a7" + }, + { + "path": "assets/seeed_bearing__configuration__22x16x4.stl", + "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/robot/mjlab/meshes/seeed_bearing__configuration__22x16x4.stl", + "sha256": "8551dc99cbdc012bd261c2a175f2cc766dd6aed0786781b35f8381cd3aa95b34" + }, + { + "path": "assets/seeed_bearing__configuration_default.stl", + "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/robot/mjlab/meshes/seeed_bearing__configuration_default.stl", + "sha256": "e4338e0e6c7adeb52a35e02d6fa5babe761e8752ffa0fc2e31b0ba672c18630d" + }, + { + "path": "assets/soft_mouth_top.stl", + "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/robot/mjlab/meshes/soft_mouth_top.stl", + "sha256": "451f53d30350552cb6e26f777698272f7c3fa557f389adb24f94c75a201e67cd" + }, + { + "path": "assets/sole_left.stl", + "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/robot/mjlab/meshes/sole_left.stl", + "sha256": "357f80bb46855e3ce50f817705acd80471dae90b4ee6b4a0a948614a7daeddb1" + }, + { + "path": "assets/sole_right.stl", + "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/robot/mjlab/meshes/sole_right.stl", + "sha256": "07801107a36dbc1c2b83b3b64ad98be39ba662a8a7fa0b62a29119b201f41fe6" + }, + { + "path": "assets/speaker.stl", + "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/robot/mjlab/meshes/speaker.stl", + "sha256": "0ef54df86c6093c166d73e33dc0b8364ef5643a7a1b1dacaba577be5a9c06c32" + }, + { + "path": "assets/top_head_shell.stl", + "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/robot/mjlab/meshes/top_head_shell.stl", + "sha256": "a59823b8f2ab23d530e6b071559f7fd308851ae035b7cb4d0b7baf7edef4b3e3" + }, + { + "path": "assets/trunk_base.stl", + "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/robot/mjlab/meshes/trunk_base.stl", + "sha256": "4b84ebe28041e6236ecafd4a26cebe537e21c14423e6acded3d78478c7650ed7" + }, + { + "path": "assets/upper_leg_left.stl", + "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/robot/mjlab/meshes/upper_leg_left.stl", + "sha256": "a626dafcdd0074fd4e79e604d377495d304d222f1afd27b13487b7248b35a0b8" + }, + { + "path": "assets/upper_leg_right.stl", + "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/robot/mjlab/meshes/upper_leg_right.stl", + "sha256": "97fd516a4c0e593fef9e22b1521d60ca86bd91966a4328db0834bac5ba1cabdf" + }, + { + "path": "assets/upper_leg_rigidity_plate.stl", + "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/robot/mjlab/meshes/upper_leg_rigidity_plate.stl", + "sha256": "96e50158d44ef97fc46f5d6330b6bb02fb93116790ae892a170fdd052e696aba" + }, + { + "path": "assets/xl330.stl", + "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/robot/mjlab/meshes/xl330.stl", + "sha256": "d3c7b71750397083d3dfbad395fd8ac794763f43e2ea2b5006da5840c976fc82" + }, + { + "path": "assets/yaw2roll.stl", + "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/robot/mjlab/meshes/yaw2roll.stl", + "sha256": "c20cbdd8a9d52f789ec5a4b0d4a173a225652c6bc4610d555ea309c7a0e0d559" + }, + { + "path": "assets/yaw_roll_motion.stl", + "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/robot/mjlab/meshes/yaw_roll_motion.stl", + "sha256": "ae3740fde29a261b79650a25825baedd84d538fc67b6b20b79589125e5e05592" + } + ], + "variants": { + "rollers": { + "model_dir": "microduck-mjlab-rollers", + "model_path": "robot_allcollisions_rollers.xml", + "files": [ + { + "path": "robot_allcollisions_rollers.xml", + "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/robot/mjlab/robot_allcollisions_rollers.xml", + "sha256": "c201a72ecbc4dd8e110c840cb79b1647aab59e6c76db7369a88c34a33307447e" + }, + { + "path": "assets/ankle_l_v1.stl", + "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/robot/mjlab/meshes/ankle_l_v1.stl", + "sha256": "74f9a51e15ac8fef93fc2efa35b61dc53cf832d798f75c3229700bd9b3aed4c4" + }, + { + "path": "assets/ankle_r_v1.stl", + "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/robot/mjlab/meshes/ankle_r_v1.stl", + "sha256": "c200102fb6f067cbec73f048fe2020b127e61d47fcffd7dbd84c90439767fd3f" + }, + { + "path": "assets/rim.stl", + "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/robot/mjlab/meshes/rim.stl", + "sha256": "a6d58f80ec60041e721cba1de7af5c834700166a5c10e7f68d1a2b79c7b78cb0" + }, + { + "path": "assets/roller_blade.stl", + "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/robot/mjlab/meshes/roller_blade.stl", + "sha256": "a8b0e6dbdb593eceaba1a0cc2a70ad8aac56f4c3b22676dbfb97409c1a094615" + }, + { + "path": "assets/tire.stl", + "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/robot/mjlab/meshes/tire.stl", + "sha256": "14c08d73185a4b521897aca74fb8b2a6d306a20d80e8822aa56cf1b4ace29bec" + } + ] + } + } +} diff --git a/simulation/fetch_assets.py b/simulation/fetch_assets.py new file mode 100644 index 0000000..beed23d --- /dev/null +++ b/simulation/fetch_assets.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""Download and hash-verify the pinned upstream Microduck simulation assets. + +Assets: the official `pollen-robotics/microduck-simulator` Space's +`robot_allcollisions.xml` plus its 38 mesh files. `assets.lock.json` pins +URLs and sha256 digests so CI rollouts are reproducible. + +Usage: python fetch_assets.py [--cache-dir DIR] [--variant standard|rollers] + (default: /.simcache, standard) +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import sys +from pathlib import Path + +LOCK = Path(__file__).resolve().parent / "assets.lock.json" + + +def sha256(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as fh: + for chunk in iter(lambda: fh.read(1 << 20), b""): + h.update(chunk) + return h.hexdigest() + + +def select_variant(lock: dict, variant: str) -> dict: + if variant == "standard": + return lock + + config = lock.get("variants", {}).get(variant) + if config is None: + available = ", ".join(sorted(lock.get("variants", {}))) or "standard" + raise ValueError(f"unknown simulation asset variant {variant!r}; use {available}") + + files = {entry["path"]: entry for entry in lock["files"]} + files.update({entry["path"]: entry for entry in config["files"]}) + return { + "model_dir": config["model_dir"], + "model_path": config["model_path"], + "files": list(files.values()), + } + + +def fetch(cache_dir: Path | None = None, variant: str = "standard") -> Path: + if cache_dir is None: + repo_root = Path(__file__).resolve().parent.parent + cache_dir = repo_root / ".simcache" + lock = select_variant(json.loads(LOCK.read_text()), variant) + model_dir = cache_dir / lock["model_dir"] + mesh_dir = model_dir / "assets" + mesh_dir.mkdir(parents=True, exist_ok=True) + + import urllib.request + + def get(url: str, dest: Path) -> None: + req = urllib.request.Request(url, headers={"User-Agent": "uduck-registry-ci"}) + with urllib.request.urlopen(req, timeout=120) as resp, dest.open("wb") as out: + while True: + chunk = resp.read(1 << 20) + if not chunk: + break + out.write(chunk) + + failures = [] + for entry in lock["files"]: + name = entry["path"] + dest = model_dir / name + expected = entry["sha256"] + if dest.exists() and sha256(dest) == expected: + continue + print(f"fetch {name}") + try: + get(entry["url"], dest) + except Exception as exc: # noqa: BLE001 + failures.append(f"{name}: {exc}") + continue + actual = sha256(dest) + if actual != expected: + failures.append(f"{name}: sha256 mismatch ({actual} != {expected})") + dest.unlink(missing_ok=True) + if failures: + for f in failures: + print(f"ERROR {f}", file=sys.stderr) + sys.exit(1) + resolved = model_dir / lock["model_path"] + print(f"assets ready ({variant}): {resolved}") + return resolved + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--cache-dir", default=None) + parser.add_argument("--variant", choices=("standard", "rollers"), default="standard") + args = parser.parse_args() + repo_root = Path(__file__).resolve().parent.parent + default_cache = repo_root / ".simcache" + fetch(Path(args.cache_dir) if args.cache_dir else default_cache, args.variant) diff --git a/simulation/microduck_sim/__init__.py b/simulation/microduck_sim/__init__.py new file mode 100644 index 0000000..5454830 --- /dev/null +++ b/simulation/microduck_sim/__init__.py @@ -0,0 +1,3 @@ +"""Headless Microduck policy simulation and render tooling for CI.""" + +__version__ = "0.1.0" diff --git a/simulation/microduck_sim/checks.py b/simulation/microduck_sim/checks.py new file mode 100644 index 0000000..48a6806 --- /dev/null +++ b/simulation/microduck_sim/checks.py @@ -0,0 +1,131 @@ +"""Runner-owned observations and explicitly requested diagnostic checks. + +A completed render is never a behavior or hardware validation claim. Checks +only describe what this pinned rollout measured. +""" + +from __future__ import annotations + +import numpy as np + +# A standing Microduck trunk sits near 0.09-0.125 m; below this the hull has +# essentially toppled. +FALL_HEIGHT_M = 0.06 +# Projected-gravity z beyond this at the END of the rollout = not recovered. +RECOVER_UPRIGHT_Z = -0.5 # i.e. tilt < 60 degrees +# Velocity-tracking bounds for steady-state segments. Calibrated against the +# official reference implementation (infer_policy.py) run headlessly: with an +# instant 0.25 m/s step command, BEST_alpha_walking stabilizes near ~0.10-0.14 +# m/s in deterministic CPU MuJoCo. The check therefore verifies DIRECTION and +# a minimum fraction of commanded speed rather than equality. +TRACKING_MIN_FRACTION = 0.3 +TRACKING_DIRECTION_COS = 0.8 +# Segment edges trimmed when measuring tracking (startup transients). +TRACK_TRIM_S = 0.6 +# The MJCF robots drift; CI arena sanity bound. +MAX_DRIFT_M = 2.5 + + +def evaluate(result, spec) -> dict: + """Return exact observations and pass/fail results for requested checks.""" + metrics = result.metrics() + check_results = [] + + def add(name: str, passed: bool, detail: str) -> None: + check_results.append({"check": name, "passed": bool(passed), "detail": detail}) + + # Baseline integrity checks always run and cannot be disabled by a + # descriptor. They say the rollout was numerically usable, nothing more. + add("finite_outputs", metrics["all_finite"], + f"max |action| = {metrics['max_abs_action']}") + add("bounded_drift", metrics["displacement_m"] < MAX_DRIFT_M, + f"displacement {metrics['displacement_m']} m") + + for name in spec.checks: + if name == "no_fall": + add(name, metrics["min_trunk_height_m"] > FALL_HEIGHT_M, + f"min trunk height {metrics['min_trunk_height_m']} m") + elif name == "ends_upright": + add(name, metrics["final_tilt_deg"] < 45.0, + f"final tilt {metrics['final_tilt_deg']} deg") + elif name == "recover_upright": + add(name, metrics["final_tilt_deg"] < 60.0, + f"final tilt {metrics['final_tilt_deg']} deg, final height " + f"{metrics['final_trunk_height_m']} m") + elif name == "takeoff": + add(name, metrics["takeoff_after_support"], + "foot contact was lost after a supported state" if metrics["takeoff_after_support"] + else "no contact loss after a supported state") + elif name == "touchdown_after_takeoff": + add(name, metrics["touchdown_after_takeoff"], + "bilateral contact returned after takeoff" if metrics["touchdown_after_takeoff"] + else "no bilateral touchdown observed after takeoff") + elif name == "velocity_tracking": + _add_velocity_tracking(result, spec, metrics, add) + else: + raise ValueError(f"unsupported simulation check: {name}") + + checks_status = "passed" if all(c["passed"] for c in check_results) else "failed" + return { + "execution": "rendered", + "checks_status": checks_status, + "checks": check_results, + "observations": metrics, + } + + +def _add_velocity_tracking(result, spec, metrics: dict, add) -> None: + if spec.kind != "velocity": + add("velocity_tracking", False, "velocity tracking requires a velocity scenario") + return + results = _tracking_errors(result, spec) + if not results: + add("velocity_tracking", False, "no non-zero command segments found") + return + worst_fraction = min(r["fraction"] for r in results) + worst_cos = min(r["direction_cos"] for r in results) + tracking = round(float(np.mean([r["abs_err"] for r in results])), 4) + metrics["mean_tracking_error_mps"] = tracking + ok = (worst_fraction >= TRACKING_MIN_FRACTION + and worst_cos >= TRACKING_DIRECTION_COS) + add("velocity_tracking", ok, + f"steady-state speed >= {TRACKING_MIN_FRACTION:.0%} of command " + f"(worst {worst_fraction:.0%}), direction cos >= " + f"{TRACKING_DIRECTION_COS} (worst {worst_cos:.2f}), " + f"mean |v_cmd - v_xy| = {tracking:.3f} m/s") + + +def _tracking_errors(result, spec) -> list: + """Per-nonzero-segment tracking stats from displacement over the interior. + + Displacement-based stats are robust to gait oscillation and to the + near-zero-speed startup window (direction cosine of tiny instantaneous + velocities is noise). + """ + results = [] + samples = result.samples + segments = spec.segments or [] + t0 = 0.0 + for duration, vx, vy, _wz in segments: + t1 = t0 + duration + cmd_mag = float(np.hypot(vx, vy)) + if cmd_mag > 1e-6: + lo, hi = t0 + TRACK_TRIM_S, t1 - TRACK_TRIM_S + window = [s for s in samples if lo <= s.t <= hi] + if len(window) >= 2: + d = window[-1].trunk_pos[:2] - window[0].trunk_pos[:2] + dist = float(np.linalg.norm(d)) + expected = cmd_mag * (window[-1].t - window[0].t) + frac = dist / expected if expected > 1e-9 else 0.0 + cos = float(np.dot(d, [vx, vy]) / (dist * cmd_mag)) \ + if dist > 1e-6 else 0.0 + results.append({ + "abs_err": float(np.mean([ + np.linalg.norm(s.lin_vel_world[:2] - np.array([vx, vy])) + for s in window + ])), + "fraction": frac, + "direction_cos": cos, + }) + t0 = t1 + return results diff --git a/simulation/microduck_sim/constants.py b/simulation/microduck_sim/constants.py new file mode 100644 index 0000000..b82ca9f --- /dev/null +++ b/simulation/microduck_sim/constants.py @@ -0,0 +1,77 @@ +"""Constants and helpers shared by the Microduck simulation runtime. + +Every value here is lifted from the official upstream references and must not +be changed casually: + +- DEFAULT_POSE, JOINT_NAMES, ACTION_SCALE, TIMESTEP, DECIMATION: + `pollen-robotics/microduck_rl` `scripts/infer_policy.py` and the official + `pollen-robotics/microduck-simulator` Hugging Face Space + (`app/src/game/constants.js`), which agree exactly. +- Observation layout (61D, unified command mode): + 3 base angular velocity + 3 projected gravity + 14 joint pos (relative to + the default pose) + 14 joint velocities + 14 last actions + 13 command. +- Command (13D): twist (vx, vy, wz) + head offset (4) + body pose (6). +""" + +from __future__ import annotations + +import numpy as np + +OBSERVATION_DIM = 61 +ACTION_DIM = 14 + +TIMESTEP = 0.005 # infer_policy.py overrides the MJCF's 0.002 with this. +DECIMATION = 4 # 4 * 0.005 s = 0.02 s -> 50 Hz control. +CONTROL_HZ = 50 + +JOINT_NAMES = [ + "left_hip_yaw", "left_hip_roll", "left_hip_pitch", "left_knee", "left_ankle", + "neck_pitch", "head_pitch", "head_yaw", "head_roll", + "right_hip_yaw", "right_hip_roll", "right_hip_pitch", "right_knee", "right_ankle", +] + +# STAND2 pose (matches HOME_FRAME in microduck_constants.py). +DEFAULT_POSE = np.array([ + 0.0, # left_hip_yaw + -0.0873, # left_hip_roll + -0.4579, # left_hip_pitch + -0.0049, # left_knee + 0.4530, # left_ankle + 0.3491, # neck_pitch + 0.3491, # head_pitch + 0.0, # head_yaw + 0.0, # head_roll + 0.0, # right_hip_yaw + 0.0873, # right_hip_roll + 0.4579, # right_hip_pitch + 0.0049, # right_knee + -0.4530, # right_ankle +], dtype=np.float32) + +ACTION_SCALE = 1.0 + +# Initial trunk height of the freejoint (legs variant) from infer_policy.py. +INITIAL_TRUNK_Z = 0.125 + +# Velocity command envelope used by the official runtime (legs variant). +VEL_MAX_X = 0.3 +VEL_MIN_X = -0.3 +VEL_MAX_Y = 0.2 +VEL_MIN_Y = -0.2 +VEL_MAX_ANG = 1.5 + +# Trunk freejoint name in every Microduck MJCF variant. +TRUNK_FREEJOINT = "trunk_base_freejoint" +TRUNK_BODY = "trunk_base" +IMU_GYRO_SENSOR = "imu_ang_vel" + + +def quat_rotate_inverse(quat: np.ndarray, vec: np.ndarray) -> np.ndarray: + """Rotate `vec` from world frame into the frame of `quat` (w, x, y, z).""" + w, x, y, z = quat + # Conjugate quaternion rotates world -> body. + q_conj = np.array([w, -x, -y, -z], dtype=np.float32) + # q * v * q^-1 expanded (t = 2 q_vec x v). + q_vec = q_conj[1:] + t = 2.0 * np.cross(q_vec, vec) + return vec + w * t + np.cross(q_vec, t) diff --git a/simulation/microduck_sim/preflight.py b/simulation/microduck_sim/preflight.py new file mode 100644 index 0000000..76e550a --- /dev/null +++ b/simulation/microduck_sim/preflight.py @@ -0,0 +1,235 @@ +"""Deterministic admission checks for registry-owned simulation recipes. + +These checks answer whether a descriptor can be represented by the pinned +registry runner. They do not execute the policy or make a claim about its +behavioral success. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from math import isfinite + +from .constants import ( + ACTION_DIM, + CONTROL_HZ, + DECIMATION, + OBSERVATION_DIM, + VEL_MAX_ANG, + VEL_MAX_X, + VEL_MAX_Y, + VEL_MIN_X, + VEL_MIN_Y, +) + +STANDARD_RUNNER = "microduck-standard-v1" +SUPPORTED_MODELS = {"microduck-standard", "microduck-rollers"} +SUPPORTED_SCENE = "flat-v1" +SUPPORTED_SCENARIOS = { + "velocity", + "standing", + "sitstand", + "oneshot_phase", + "oneshot_zero", + "oneshot_trigger", +} +SUPPORTED_START_PRESETS = {"standing_pose", "settled_standing", "airborne_drop"} + + +@dataclass(frozen=True) +class PreflightResult: + """The static result before any artifact or simulator work begins.""" + + errors: tuple[str, ...] = () + warnings: tuple[str, ...] = () + + @property + def valid(self) -> bool: + return not self.errors + + +class SimulationPreflightError(ValueError): + """Raised when a registry recipe cannot be represented by its runner.""" + + def __init__(self, result: PreflightResult) -> None: + self.result = result + super().__init__( + "simulation preflight rejected the descriptor:\n- " + + "\n- ".join(result.errors) + ) + + +def _is_finite_number(value: object) -> bool: + return ( + isinstance(value, (int, float)) + and not isinstance(value, bool) + and isfinite(value) + ) + + +def _velocity_errors(vx: object, vy: object, wz: object, prefix: str) -> list[str]: + errors: list[str] = [] + limits = ( + ("vx", vx, VEL_MIN_X, VEL_MAX_X), + ("vy", vy, VEL_MIN_Y, VEL_MAX_Y), + ("wz", wz, -VEL_MAX_ANG, VEL_MAX_ANG), + ) + for axis, value, minimum, maximum in limits: + if not _is_finite_number(value): + errors.append(f"{prefix}.{axis} must be a finite number") + elif value < minimum or value > maximum: + errors.append( + f"{prefix}.{axis}={value:g} exceeds {STANDARD_RUNNER}'s " + f"supported range [{minimum:g}, {maximum:g}]" + ) + return errors + + +def preflight_descriptor(descriptor: dict) -> PreflightResult: + """Check a descriptor against the capabilities of ``standard-v1``. + + External/no-recipe descriptors intentionally bypass these checks. They are + reported as unsupported by ``run_check.py`` and must provide their own + publisher-owned environment or media. + """ + + simulation = descriptor.get("simulation") + if simulation is None: + return PreflightResult() + if not isinstance(simulation, dict): + return PreflightResult(errors=("simulation must be an object",)) + if simulation.get("runner") == "external": + return PreflightResult() + + errors: list[str] = [] + warnings: list[str] = [] + contract = descriptor.get("contract", {}) + if not isinstance(contract, dict): + errors.append("contract must be an object") + contract = {} + compatibility = descriptor.get("compatibility", {}) + if not isinstance(compatibility, dict): + errors.append("compatibility must be an object") + compatibility = {} + runner = simulation.get("runner") + + if runner != STANDARD_RUNNER: + errors.append(f"unsupported simulation runner: {runner!r}") + + model = simulation.get("model", compatibility.get("robot_model")) + if model not in SUPPORTED_MODELS: + errors.append(f"{STANDARD_RUNNER} does not support robot model {model!r}") + if model != compatibility.get("robot_model"): + errors.append( + f"simulation model {model!r} does not match compatibility model " + f"{compatibility.get('robot_model')!r}" + ) + + if simulation.get("scene") != SUPPORTED_SCENE: + errors.append( + f"{STANDARD_RUNNER} supports only scene {SUPPORTED_SCENE!r}; " + f"got {simulation.get('scene')!r}" + ) + + scenario = simulation.get("scenario") + if scenario not in SUPPORTED_SCENARIOS: + errors.append(f"unsupported simulation scenario: {scenario!r}") + + start = simulation.get("start") + if not isinstance(start, dict): + errors.append("simulation.start must be an object") + start = {} + preset = start.get("preset") + if preset not in SUPPORTED_START_PRESETS: + errors.append(f"unsupported simulation start preset: {preset!r}") + elif preset == "airborne_drop": + height = start.get("trunk_height_m") + if not _is_finite_number(height): + errors.append("simulation.start.trunk_height_m must be a finite number") + elif height < 0.15 or height > 0.5: + errors.append("simulation.start.trunk_height_m must be between 0.15 and 0.5 m") + if start.get("orientation") not in {"upright", "front", "back", "left", "right"}: + errors.append("simulation.start.orientation is unsupported") + velocity = start.get("linear_velocity_mps") + if velocity is not None: + if not isinstance(velocity, (list, tuple)) or len(velocity) != 3: + errors.append("simulation.start.linear_velocity_mps must have three values") + else: + for index, value in enumerate(velocity): + if not _is_finite_number(value) or value < -3 or value > 3: + errors.append( + f"simulation.start.linear_velocity_mps[{index}] must be finite and in [-3, 3]" + ) + + duration = simulation.get("duration_s") + duration_value = duration if _is_finite_number(duration) else None + if duration_value is None: + errors.append("simulation.duration_s must be a finite number") + elif duration_value < 1 or duration_value > 30: + errors.append("simulation.duration_s must be between 1 and 30 seconds") + + if scenario == "velocity": + segments = simulation.get("segments") + if not isinstance(segments, list) or not segments: + errors.append("simulation.segments is required for the velocity scenario") + else: + total_duration = 0.0 + for index, segment in enumerate(segments): + prefix = f"simulation.segments[{index}]" + if not isinstance(segment, dict): + errors.append(f"{prefix} must be an object") + continue + segment_duration = segment.get("duration_s") + if _is_finite_number(segment_duration) and segment_duration > 0: + total_duration += float(segment_duration) + else: + errors.append(f"{prefix}.duration_s must be a positive finite number") + errors.extend(_velocity_errors( + segment.get("vx"), segment.get("vy"), segment.get("wz"), prefix + )) + if duration_value is not None and abs(total_duration - duration_value) > 1e-9: + errors.append( + f"simulation.segments cover {total_duration:g}s but " + f"simulation.duration_s={duration_value:g}s; the schedule must cover the rollout exactly" + ) + elif "segments" in simulation: + errors.append("simulation.segments is only valid with the velocity scenario") + + if contract.get("observation_dim") != OBSERVATION_DIM: + errors.append( + f"{STANDARD_RUNNER} expects {OBSERVATION_DIM} observations; " + f"descriptor declares {contract.get('observation_dim')!r}" + ) + if contract.get("action_dim") != ACTION_DIM: + errors.append( + f"{STANDARD_RUNNER} expects {ACTION_DIM} actions; " + f"descriptor declares {contract.get('action_dim')!r}" + ) + if contract.get("control_frequency_hz") != CONTROL_HZ: + errors.append( + f"{STANDARD_RUNNER} expects {CONTROL_HZ} Hz control; " + f"descriptor declares {contract.get('control_frequency_hz')!r} Hz" + ) + if contract.get("decimation") != DECIMATION: + errors.append( + f"{STANDARD_RUNNER} expects decimation {DECIMATION}; " + f"descriptor declares {contract.get('decimation')!r}" + ) + + actuator_model = str(contract.get("actuator_model", "")).lower() + if "bam" in actuator_model: + warnings.append( + "descriptor declares BAM actuator dynamics; standard-v1 uses the " + "registry's deterministic position-control diagnostic runtime" + ) + + return PreflightResult(tuple(errors), tuple(warnings)) + + +def require_valid(descriptor: dict) -> PreflightResult: + """Run preflight and raise one readable error before simulation starts.""" + + result = preflight_descriptor(descriptor) + if result.errors: + raise SimulationPreflightError(result) + return result diff --git a/simulation/microduck_sim/render.py b/simulation/microduck_sim/render.py new file mode 100644 index 0000000..753673e --- /dev/null +++ b/simulation/microduck_sim/render.py @@ -0,0 +1,108 @@ +"""Standardized offscreen rendering: deterministic camera, H.264 loop, poster. + +Output contract (the "sim render standard"): +- loop.mp4 : H.264 yuv420p, 30 fps, square 512x512, muted, ~CRF 20, + duration == rollout duration, deterministic given the rollout. +- poster.png : the middle frame, 512x512, with an inset bottom caption bar. + +The camera is a smoothed chase view (side-on, slight elevation) so every +behavior gets a comparable, stable thumbnail. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + +os.environ.setdefault("MUJOCO_GL", "egl") + +import mujoco # noqa: E402 (import after MUJOCO_GL is pinned) +import numpy as np # noqa: E402 +from PIL import Image, ImageDraw # noqa: E402 + +LOOP_SIZE = 512 +LOOP_FPS = 30 +SOURCE_FPS = 25 # 50 Hz control, sampled every other step +CAM_DISTANCE = 0.72 +CAM_ELEVATION = -12.0 +CAM_AZIMUTH = 100.0 +CAM_SMOOTH = 0.2 # per-control-step lookat lerp factor + + +class LoopRenderer: + """Collects frames during a rollout and encodes the standardized outputs.""" + + def __init__(self, model: mujoco.MjModel): + self.renderer = mujoco.Renderer(model, height=LOOP_SIZE, width=LOOP_SIZE) + self.cam = mujoco.MjvCamera() + self.cam.type = mujoco.mjtCamera.mjCAMERA_FREE + self.cam.distance = CAM_DISTANCE + self.cam.elevation = CAM_ELEVATION + self.cam.azimuth = CAM_AZIMUTH + self._lookat = None + self._frames: list[np.ndarray] = [] + + def capture(self, step_index: int, sample) -> None: + # Render every other control step: 50 Hz sim -> 25 fps source. The + # encoder declares that source rate and converts it to the 30 fps + # delivery rate, so the loop keeps the rollout's real duration. + if step_index % 2 != 0: + return + pos = np.asarray(sample.trunk_pos, dtype=float) + if self._lookat is None: + self._lookat = pos.copy() + else: + self._lookat = (1.0 - CAM_SMOOTH) * self._lookat + CAM_SMOOTH * pos + self.cam.lookat[:] = self._lookat + self.renderer.update_scene(self._data, camera=self.cam) + self._frames.append(self.renderer.render().copy()) + + def attach(self, data: mujoco.MjData) -> None: + self._data = data + + def _encode_video(self, out_path: Path) -> None: + ffmpeg = shutil.which("ffmpeg") + if ffmpeg is None: + raise RuntimeError("ffmpeg not found on PATH") + h, w = self._frames[0].shape[:2] + proc = subprocess.Popen( + [ffmpeg, "-y", "-loglevel", "error", + "-f", "rawvideo", "-pix_fmt", "rgb24", "-s", f"{w}x{h}", + "-framerate", str(SOURCE_FPS), "-i", "-", + "-r", str(LOOP_FPS), + "-an", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "20", + "-movflags", "+faststart", str(out_path)], + stdin=subprocess.PIPE) + for frame in self._frames: + proc.stdin.write(frame.tobytes()) + proc.stdin.close() + if proc.wait() != 0: + raise RuntimeError(f"ffmpeg failed encoding {out_path}") + + def _encode_poster(self, out_path: Path, caption: str) -> None: + mid = self._frames[len(self._frames) // 2] + img = Image.fromarray(mid) + bar_h = 44 + bar = Image.new("RGB", (img.width, bar_h), (17, 24, 39)) + draw = ImageDraw.Draw(bar) + draw.text((10, 15), caption, fill=(226, 232, 240)) + img.paste(bar, (0, img.height - bar_h)) + img.save(out_path) + + def finalize(self, out_dir: Path, caption: str) -> dict: + out_dir = Path(out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + loop_path = out_dir / "loop.mp4" + poster_path = out_dir / "poster.png" + self._encode_video(loop_path) + self._encode_poster(poster_path, caption) + self.renderer.close() + return { + "loop": str(loop_path), + "poster": str(poster_path), + "frames": len(self._frames), + "size": LOOP_SIZE, + "fps": LOOP_FPS, + } diff --git a/simulation/microduck_sim/robot.py b/simulation/microduck_sim/robot.py new file mode 100644 index 0000000..fa26090 --- /dev/null +++ b/simulation/microduck_sim/robot.py @@ -0,0 +1,389 @@ +"""MuJoCo Microduck runtime: faithful headless port of the upstream policy loop. + +Reference: `pollen-robotics/microduck_rl` `scripts/infer_policy.py` and the +official `pollen-robotics/microduck-simulator` Space. The physics model is the +Space's `robot_allcollisions.xml` (meshes vendored, hash-pinned in +`simulation/assets.lock.json`); a plain floor and lights are injected for CI +rendering, matching how the Space's `game.js` injects a floor. + +Deviations from hardware inference are limited to: +- no action delay / domain randomization (deterministic CI rollout); +- floor + lights appended to the MJCF (visual only, plus ground contact). +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from pathlib import Path + +import mujoco +import numpy as np +import onnxruntime as ort + +from .constants import ( + ACTION_DIM, + ACTION_SCALE, + DECIMATION, + DEFAULT_POSE, + INITIAL_TRUNK_Z, + OBSERVATION_DIM, + TIMESTEP, + TRUNK_BODY, + TRUNK_FREEJOINT, + IMU_GYRO_SENSOR, + quat_rotate_inverse, +) + +# The stage is visual styling around the unchanged robot and physical floor. +# The backdrop has collisions disabled so it cannot affect a rollout. +_SCENE_ASSET_XML = """ + + + +""" + +# Floor + lights injected into the vendored MJCF (which is robot-only). +_FLOOR_XML = """ + + + + +""" + + +def load_model(mjcf_path: str | Path) -> mujoco.MjModel: + """Load the Microduck MJCF with the runtime's timestep and CI floor.""" + mjcf_path = Path(mjcf_path).resolve() + xml = mjcf_path.read_text() + # Add the showcase stage materials to the model's existing asset section. + asset_idx = xml.index("") + xml = xml[:asset_idx] + _SCENE_ASSET_XML + xml[asset_idx:] + # Inject floor/lights right after . + idx = xml.index("") + len("") + xml = xml[:idx] + "\n" + _FLOOR_XML + xml[idx:] + # from_xml_string resolves the MJCF's relative meshdir against the process + # CWD, so scope a chdir to the model directory. + prev = os.getcwd() + os.chdir(mjcf_path.parent) + try: + model = mujoco.MjModel.from_xml_string(xml) + finally: + os.chdir(prev) + model.opt.timestep = TIMESTEP + # The decorative stage expands MuJoCo's compiled bounds, which makes a + # directional light spend its shadow-map resolution over empty space. + # Keep render statistics centered on the moving duck; this affects only + # visualization clipping and shadow quality, not physics. + model.stat.center[:] = [0.0, 0.0, 0.2] + model.stat.extent = 1.5 + model.vis.map.shadowclip = 0.75 + model.vis.quality.offsamples = 8 + # Offscreen framebuffer sized for the standardized 512x512 render loop. + model.vis.global_.offwidth = 512 + model.vis.global_.offheight = 512 + return model + + +@dataclass +class StepSample: + """One 50 Hz control step of recorded telemetry.""" + + t: float + command: np.ndarray + action: np.ndarray + trunk_height: float + trunk_pos: np.ndarray + upright_z: float # projected-gravity z (-1 = perfectly upright) + lin_vel_world: np.ndarray + left_foot_contact: bool + right_foot_contact: bool + + +@dataclass +class RolloutResult: + samples: list = field(default_factory=list) + obs_dim: int = 0 + use_13d: bool = True + control_steps: int = 0 + sim_steps: int = 0 + duration_s: float = 0.0 + initial_left_foot_contact: bool = False + initial_right_foot_contact: bool = False + + def metrics(self) -> dict: + h = np.array([s.trunk_height for s in self.samples]) + uz = np.array([s.upright_z for s in self.samples]) + xy = np.array([s.trunk_pos[:2] for s in self.samples]) + acts = np.array([s.action for s in self.samples]) + supported = np.array([ + s.left_foot_contact or s.right_foot_contact for s in self.samples + ], dtype=bool) + both_supported = np.array([ + s.left_foot_contact and s.right_foot_contact for s in self.samples + ], dtype=bool) + finite = bool(np.isfinite(acts).all() and np.isfinite(h).all()) + initially_supported = bool( + self.initial_left_foot_contact or self.initial_right_foot_contact + ) + takeoff_index = None + support_seen = initially_supported + for index, is_supported in enumerate(supported): + if support_seen and not is_supported: + takeoff_index = index + break + support_seen = support_seen or bool(is_supported) + airborne_after_support = takeoff_index is not None + touchdown_after_takeoff = False + if takeoff_index is not None: + touchdown_after_takeoff = bool(np.any(both_supported[takeoff_index + 1:])) + return { + "duration_s": round(self.duration_s, 3), + "control_steps": self.control_steps, + "obs_dim": self.obs_dim, + "command_dim": 13 if self.use_13d else 3, + "min_trunk_height_m": round(float(h.min()), 4), + "max_trunk_height_m": round(float(h.max()), 4), + "final_trunk_height_m": round(float(h[-1]), 4), + "max_tilt_deg": round(float(np.degrees(np.arccos(np.clip(-uz.max(), -1, 1)))), 2), + "final_tilt_deg": round(float(np.degrees(np.arccos(np.clip(-uz[-1], -1, 1)))), 2), + "path_length_m": round(float(np.linalg.norm(np.diff(xy, axis=0), axis=1).sum()), 3), + "displacement_m": round(float(np.linalg.norm(xy[-1] - xy[0])), 3), + "max_abs_action": round(float(np.abs(acts).max()), 4), + "all_finite": finite, + "initial_foot_contact": initially_supported, + "initial_bilateral_contact": bool( + self.initial_left_foot_contact and self.initial_right_foot_contact + ), + "airborne_observed": bool(np.any(~supported)), + "takeoff_after_support": airborne_after_support, + "touchdown_after_takeoff": touchdown_after_takeoff, + } + + +class DuckRuntime: + """Deterministic Microduck policy rollout in MuJoCo.""" + + def __init__(self, model: mujoco.MjModel, onnx_path, action_scale: float = ACTION_SCALE): + self.model = model + self.data = mujoco.MjData(model) + self.action_scale = float(action_scale) + + so = ort.SessionOptions() + so.intra_op_num_threads = 2 + self.session = ort.InferenceSession(str(onnx_path), so, + providers=["CPUExecutionProvider"]) + self.input_name = self.session.get_inputs()[0].name + self.output_name = self.session.get_outputs()[0].name + in_shape = self.session.get_inputs()[0].shape + out_shape = self.session.get_outputs()[0].shape + input_dim = in_shape[-1] if in_shape and isinstance(in_shape[-1], int) else None + output_dim = out_shape[-1] if out_shape and isinstance(out_shape[-1], int) else None + if input_dim != OBSERVATION_DIM: + raise ValueError(f"Policy expects {in_shape}; expected {OBSERVATION_DIM} obs dims") + if output_dim != ACTION_DIM: + raise ValueError(f"Policy returns {out_shape}; expected {ACTION_DIM} actions") + self.use_13d = True + self.obs_dim = OBSERVATION_DIM + + self.imu_ang_vel_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_SENSOR, + IMU_GYRO_SENSOR) + if self.imu_ang_vel_id < 0: + raise ValueError("Sensor 'imu_ang_vel' missing from MJCF") + self.trunk_base_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_BODY, TRUNK_BODY) + self.floor_geom_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_GEOM, "ci_floor") + self.left_foot_geom_ids = self._find_foot_contact_geoms( + "left_foot_collision", "ankle_l_v1" + ) + self.right_foot_geom_ids = self._find_foot_contact_geoms( + "right_foot_collision", "ankle_r_v1" + ) + trunk_jid = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_JOINT, TRUNK_FREEJOINT) + self._trunk_qpos_adr = int(model.jnt_qposadr[trunk_jid]) + self._trunk_qvel_adr = int(model.jnt_dofadr[trunk_jid]) + self.n_joints = model.nu + self.joint_qpos_indices = [ + int(model.jnt_qposadr[model.actuator_trnid[i, 0]]) for i in range(model.nu) + ] + self.joint_qvel_indices = [ + int(model.jnt_dofadr[model.actuator_trnid[i, 0]]) for i in range(model.nu) + ] + self.default_pose = DEFAULT_POSE[: self.n_joints] + self.last_action = np.zeros(self.n_joints, dtype=np.float32) + self.reset() + + def reset(self) -> None: + mujoco.mj_resetData(self.model, self.data) + adr = self._trunk_qpos_adr + self.data.qpos[adr + 0] = 0.0 + self.data.qpos[adr + 1] = 0.0 + self.data.qpos[adr + 2] = INITIAL_TRUNK_Z + self.data.qpos[adr + 3:adr + 7] = [1, 0, 0, 0] + for i, qpos_idx in enumerate(self.joint_qpos_indices): + self.data.qpos[qpos_idx] = self.default_pose[i] + self.data.ctrl[:] = self.default_pose + self.last_action = np.zeros(self.n_joints, dtype=np.float32) + mujoco.mj_forward(self.model, self.data) + + def prepare_start(self, start: dict) -> None: + """Apply one bounded, registry-owned initial-state preset.""" + self.reset() + preset = start["preset"] + if preset == "standing_pose": + return + if preset == "settled_standing": + settle_s = float(start.get("settle_s", 0.2)) + for _ in range(int(round(settle_s / self.model.opt.timestep))): + mujoco.mj_step(self.model, self.data) + self.last_action[:] = 0 + return + if preset != "airborne_drop": + raise ValueError(f"unsupported start preset: {preset}") + + adr = self._trunk_qpos_adr + velocity_adr = self._trunk_qvel_adr + self.data.qpos[adr + 2] = float(start["trunk_height_m"]) + root_half = np.sqrt(0.5) + orientations = { + "upright": [1.0, 0.0, 0.0, 0.0], + "front": [root_half, 0.0, root_half, 0.0], + "back": [root_half, 0.0, -root_half, 0.0], + "left": [root_half, root_half, 0.0, 0.0], + "right": [root_half, -root_half, 0.0, 0.0], + } + self.data.qpos[adr + 3:adr + 7] = orientations[start["orientation"]] + self.data.qvel[velocity_adr:velocity_adr + 3] = np.asarray( + start.get("linear_velocity_mps", [0.0, 0.0, 0.0]), dtype=float + ) + mujoco.mj_forward(self.model, self.data) + + def _find_foot_contact_geoms(self, geom_name: str, body_name: str) -> set[int]: + """Find the floor-contact geoms for standard feet or roller wheels.""" + geom_id = mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_GEOM, geom_name) + if geom_id >= 0: + return {int(geom_id)} + + body_id = mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_BODY, body_name) + if body_id < 0: + raise ValueError( + f"model is missing {geom_name!r} and fallback body {body_name!r}" + ) + + contact_geoms = set() + for candidate, candidate_body in enumerate(self.model.geom_bodyid): + if ( + self.model.geom_contype[candidate] == 0 + and self.model.geom_conaffinity[candidate] == 0 + ): + continue + current = int(candidate_body) + while current > 0: + if current == body_id: + contact_geoms.add(candidate) + break + current = int(self.model.body_parentid[current]) + + if not contact_geoms: + raise ValueError(f"model has no contact geoms under {body_name!r}") + return contact_geoms + + def foot_contacts(self) -> tuple[bool, bool]: + """Return whether each foot or roller wheel currently touches the floor.""" + left = right = False + for index in range(self.data.ncon): + contact = self.data.contact[index] + pair = {int(contact.geom1), int(contact.geom2)} + if self.floor_geom_id not in pair: + continue + left = left or bool(self.left_foot_geom_ids.intersection(pair)) + right = right or bool(self.right_foot_geom_ids.intersection(pair)) + return left, right + + # -- observations (exact upstream layout) -------------------------------- + def _base_ang_vel(self) -> np.ndarray: + adr = self.model.sensor_adr[self.imu_ang_vel_id] + return self.data.sensordata[adr:adr + 3].copy().astype(np.float32) + + def _projected_gravity(self) -> np.ndarray: + quat = self.data.xquat[self.trunk_base_id].copy().astype(np.float32) + world_gravity = np.array([0.0, 0.0, -1.0], dtype=np.float32) + return quat_rotate_inverse(quat, world_gravity) + + def _joint_pos_rel(self) -> np.ndarray: + pos = self.data.qpos[self.joint_qpos_indices].copy().astype(np.float32) + return pos - self.default_pose + + def _joint_vel(self) -> np.ndarray: + return self.data.qvel[self.joint_qvel_indices].copy().astype(np.float32) + + def get_observation(self, command: np.ndarray) -> np.ndarray: + obs = [ + self._base_ang_vel(), + self._projected_gravity(), + self._joint_pos_rel(), + self._joint_vel(), + self.last_action, + ] + if self.use_13d: + cmd = np.zeros(13, dtype=np.float32) + cmd[: len(command)] = command + else: + cmd = np.asarray(command, dtype=np.float32)[:3] + obs.append(cmd) + return np.concatenate(obs).astype(np.float32) + + # -- stepping ------------------------------------------------------------ + def infer(self, command: np.ndarray) -> np.ndarray: + obs = self.get_observation(command).reshape(1, -1) + action = self.session.run([self.output_name], {self.input_name: obs})[0] + action = action.squeeze(0).astype(np.float32) + self.last_action = action.copy() + return action + + def apply_action(self, action: np.ndarray) -> None: + self.data.ctrl[:] = self.default_pose + action * self.action_scale + + def step_control(self, t: float, command: np.ndarray) -> StepSample: + """One 50 Hz control step: infer, then DECIMATION physics substeps.""" + action = self.infer(command) + self.apply_action(action) + for _ in range(DECIMATION): + mujoco.mj_step(self.model, self.data) + quat = self.data.xquat[self.trunk_base_id].astype(np.float32) + uz = float(quat_rotate_inverse(quat, np.array([0, 0, -1], np.float32))[2]) + left_contact, right_contact = self.foot_contacts() + return StepSample( + t=t, + command=np.asarray(command, dtype=np.float32).copy(), + action=action.copy(), + trunk_height=float(self.data.qpos[self._trunk_qpos_adr + 2]), + trunk_pos=self.data.qpos[self._trunk_qpos_adr:self._trunk_qpos_adr + 3].copy(), + upright_z=uz, + lin_vel_world=self.data.qvel[self._trunk_qvel_adr:self._trunk_qvel_adr + 3].copy(), + left_foot_contact=left_contact, + right_foot_contact=right_contact, + ) + + def rollout(self, command_fn, duration_s: float, frame_hook=None) -> RolloutResult: + """Run a rollout; `frame_hook(k, sample)` fires after every control step.""" + result = RolloutResult(obs_dim=self.obs_dim, use_13d=self.use_13d) + initial_left, initial_right = self.foot_contacts() + result.initial_left_foot_contact = initial_left + result.initial_right_foot_contact = initial_right + n_steps = int(round(duration_s * 50)) + for k in range(n_steps): + t = k / 50.0 + command = command_fn(t) + if not self.use_13d: + command = command[:3] + sample = self.step_control(t, command) + result.samples.append(sample) + if frame_hook is not None: + frame_hook(k, sample) + result.control_steps = n_steps + result.sim_steps = n_steps * DECIMATION + result.duration_s = n_steps / 50.0 + return result diff --git a/simulation/microduck_sim/scenarios.py b/simulation/microduck_sim/scenarios.py new file mode 100644 index 0000000..7c8fff6 --- /dev/null +++ b/simulation/microduck_sim/scenarios.py @@ -0,0 +1,167 @@ +"""Named scenarios: how the 13D command evolves over a diagnostic rollout. + +A scenario is selected explicitly by a descriptor's `simulation` block. +Compatibility and robotd installation slots are intentionally not inputs. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass, field +from typing import Callable + +import numpy as np + +from .constants import VEL_MAX_ANG, VEL_MAX_X, VEL_MAX_Y, VEL_MIN_X, VEL_MIN_Y + + +@dataclass +class ScenarioSpec: + """Declarative description of a command schedule.""" + + kind: str = "velocity" + # velocity: list of (duration_s, vx, vy, wz) segments, played in order. + segments: list = field(default_factory=list) + # oneshot_phase (crouch / ground pick): period + end phase per upstream. + period_s: float = 4.0 + end_phase: float = 0.7 + # sitstand: seconds holding the sit flag before returning to stand. + hold_s: float = 2.0 + # oneshot_zero: seconds the zeroed command window lasts (kicks, roulade). + duration_s: float = 0.5 + # oneshot_trigger: binary launch request followed by the zero command + # (custom one-shot policies such as jumps). + trigger_s: float = 0.2 + # Runner-defined checks requested by the descriptor. These are assertions + # over measured telemetry, not contributor-authored validation claims. + checks: list[str] = field(default_factory=list) + # Alias of the scenario for reports. + name: str = "" + + +def validate_velocity(vx: float, vy: float, wz: float) -> tuple: + """Return a velocity command unchanged, rejecting unsupported values. + + A registry recipe is declarative input, not a user-control stream. Silently + clipping it would make the rendered rollout differ from what the descriptor + says, so out-of-range values are an explicit error. + """ + limits = ( + ("vx", vx, VEL_MIN_X, VEL_MAX_X), + ("vy", vy, VEL_MIN_Y, VEL_MAX_Y), + ("wz", wz, -VEL_MAX_ANG, VEL_MAX_ANG), + ) + for axis, value, minimum, maximum in limits: + if isinstance(value, bool) or not isinstance(value, (int, float)) or not np.isfinite(value): + raise ValueError(f"{axis} command must be finite") + if value < minimum or value > maximum: + raise ValueError( + f"{axis} command {value:g} exceeds the registry runner's " + f"supported range [{minimum:g}, {maximum:g}]" + ) + return float(vx), float(vy), float(wz) + + +def scenario_from_descriptor(sim_block: dict) -> ScenarioSpec: + """Build a scenario solely from an explicit registry simulation recipe.""" + if sim_block.get("runner") != "microduck-standard-v1": + raise ValueError("descriptor does not declare a registry simulation recipe") + + spec = ScenarioSpec() + spec.kind = sim_block["scenario"] + spec.name = spec.kind + spec.checks = list(sim_block.get("checks", [])) + spec.duration_s = float(sim_block["duration_s"]) + spec.trigger_s = float(sim_block.get("trigger_s", spec.trigger_s)) + spec.period_s = float(sim_block.get("period_s", spec.period_s)) + spec.end_phase = float(sim_block.get("end_phase", spec.end_phase)) + spec.hold_s = float(sim_block.get("hold_s", spec.hold_s)) + segments = sim_block.get("segments") + if spec.kind == "velocity": + if not isinstance(segments, list) or not segments: + raise ValueError("velocity scenario requires explicit segments") + spec.segments = [(float(s["duration_s"]), float(s["vx"]), float(s["vy"]), + float(s["wz"])) for s in segments] + elif "segments" in sim_block: + raise ValueError("simulation.segments is only valid with the velocity scenario") + return spec + + +def make_command_fn(spec: ScenarioSpec, use_13d: bool) -> Callable[[float], np.ndarray]: + """Return f(t) -> command vector for the scenario. + + `use_13d` selects the unified 13D command (twist + head + body pose); + otherwise the legacy 3D twist command is produced. + """ + def wrap(cmd: np.ndarray) -> np.ndarray: + if not use_13d: + return cmd.astype(np.float32) + return np.concatenate([cmd, np.zeros(10, dtype=np.float32)]).astype(np.float32) + + if spec.kind == "velocity": + if not spec.segments: + raise ValueError("velocity scenario requires explicit segments") + segments = spec.segments + + def vel_fn(t: float) -> np.ndarray: + remaining = t + chosen = segments[-1] + for duration, vx, vy, wz in segments: + if remaining < duration: + chosen = (duration, vx, vy, wz) + break + remaining -= duration + _, vx, vy, wz = chosen + return wrap(np.array(validate_velocity(vx, vy, wz), dtype=np.float32)) + + return vel_fn + + if spec.kind == "standing": + def stand_fn(t: float) -> np.ndarray: + return wrap(np.zeros(3, dtype=np.float32)) + return stand_fn + + if spec.kind == "sitstand": + # Posture flag in the twist-x slot: 1 = sit, 0 = stand (upstream docs). + hold = spec.hold_s + + def sitstand_fn(t: float) -> np.ndarray: + flag = 1.0 if t < hold else 0.0 + return wrap(np.array([flag, 0.0, 0.0], dtype=np.float32)) + + return sitstand_fn + + if spec.kind == "oneshot_phase": + # Phase encoding in the twist slots: [cos(2pi phi), sin(2pi phi), 0], + # phase advancing 1/period per second, cycle exits at end_phase. + period, end_phase = spec.period_s, spec.end_phase + + def phase_fn(t: float) -> np.ndarray: + if t / period >= end_phase: + cmd = np.zeros(3, dtype=np.float32) + else: + phi = 2.0 * math.pi * (t / period) + cmd = np.array([math.cos(phi), math.sin(phi), 0.0], dtype=np.float32) + return wrap(cmd) + + return phase_fn + + if spec.kind == "oneshot_zero": + # Blind one-shot window with an all-zero command (kicks, roulade). + def zero_fn(t: float) -> np.ndarray: + return wrap(np.zeros(3, dtype=np.float32)) + return zero_fn + + if spec.kind == "oneshot_trigger": + # Custom one-shot policies documented by their authors as a binary + # launch request in twist-vx, followed by the settling command. + trigger_s = spec.trigger_s + + def trigger_fn(t: float) -> np.ndarray: + cmd = (np.array([1.0, 0.0, 0.0], dtype=np.float32) + if t < trigger_s else np.zeros(3, dtype=np.float32)) + return wrap(cmd) + + return trigger_fn + + raise ValueError(f"Unknown simulation scenario kind: {spec.kind!r}") diff --git a/simulation/publish_result.py b/simulation/publish_result.py new file mode 100644 index 0000000..6cf9790 --- /dev/null +++ b/simulation/publish_result.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Promote one reviewed registry simulation artifact into the static site.""" + +from __future__ import annotations + +import argparse +import json +import re +import shutil +from pathlib import Path + +HERE = Path(__file__).resolve().parent +REPO_ROOT = HERE.parent +ID_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") + + +def publish(source: Path) -> Path: + source = source.resolve() + report_path = source / "report.json" + loop_path = source / "loop.mp4" + poster_path = source / "poster.png" + for path in (report_path, loop_path, poster_path): + if not path.is_file(): + raise ValueError(f"missing generated artifact: {path}") + + report = json.loads(report_path.read_text()) + behavior_id = report.get("behavior") + if not isinstance(behavior_id, str) or not ID_PATTERN.fullmatch(behavior_id): + raise ValueError("report has no safe behavior id") + if report.get("execution") != "rendered": + raise ValueError("only a completed diagnostic render can be published") + descriptor = REPO_ROOT / "registry" / "behaviors" / f"{behavior_id}.json" + if not descriptor.is_file(): + raise ValueError(f"no registry descriptor for {behavior_id}") + + target = REPO_ROOT / "public" / "media" / "registry-sim" / behavior_id + target.mkdir(parents=True, exist_ok=True) + shutil.copy2(loop_path, target / "loop.mp4") + shutil.copy2(poster_path, target / "poster.png") + report["media"] = { + "loop_url": f"/media/registry-sim/{behavior_id}/loop.mp4", + "poster_url": f"/media/registry-sim/{behavior_id}/poster.png", + } + (target / "report.json").write_text(json.dumps(report, indent=2) + "\n") + return target + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("source", type=Path, help="sim-results/ directory") + args = parser.parse_args() + try: + target = publish(args.source) + except Exception as exc: # noqa: BLE001 + parser.error(str(exc)) + print(f"published reviewed registry simulation to {target}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/simulation/requirements.txt b/simulation/requirements.txt new file mode 100644 index 0000000..afcf71e --- /dev/null +++ b/simulation/requirements.txt @@ -0,0 +1,4 @@ +mujoco>=3.3,<4 +onnxruntime>=1.20,<2 +numpy>=2.0,<3 +pillow>=11,<13 diff --git a/simulation/run_check.py b/simulation/run_check.py new file mode 100644 index 0000000..9aab34f --- /dev/null +++ b/simulation/run_check.py @@ -0,0 +1,235 @@ +#!/usr/bin/env python3 +"""Run the standardized simulation check for one registry behavior. + +Usage: + python -m run_check --behavior alpha-walking [--out OUT_DIR] [--keep-media] + +Reads `registry/behaviors/.json`, downloads the canonical ONNX (hosts are +restricted to the registry artifact allowlist), executes the descriptor's +explicit registry simulation recipe, runs a +deterministic MuJoCo rollout at the 50 Hz runtime contract, then writes: + + OUT//report.json execution status, exact checks, observations, provenance + OUT//loop.mp4 standardized 512x512 H.264 render loop + OUT//poster.png standardized poster (middle frame + caption bar) + +Exit code 0 = rendered/unsupported, 1 = requested check failed, +2 = preflight rejection or error (could not run at all). +""" + +from __future__ import annotations + +import argparse +import datetime as _dt +import hashlib +import json +import os +import re +import sys +import tempfile +import urllib.request +from pathlib import Path + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE)) + +os.environ.setdefault("MUJOCO_GL", "egl") + +import mujoco # noqa: E402 + +from microduck_sim import checks, render # noqa: E402 +from microduck_sim.preflight import SimulationPreflightError, require_valid # noqa: E402 +from microduck_sim.scenarios import make_command_fn, scenario_from_descriptor # noqa: E402 +from microduck_sim.robot import DuckRuntime, load_model # noqa: E402 + +REPO_ROOT = HERE.parent +ALLOWED_HOSTS = ("huggingface.co", "raw.githubusercontent.com") +MAX_ONNX_BYTES = 100 * 1024 * 1024 + + +def load_descriptor(behavior_id: str) -> dict: + path = REPO_ROOT / "registry" / "behaviors" / f"{behavior_id}.json" + if not path.exists(): + raise SystemExit(f"no descriptor at {path}") + return json.loads(path.read_text()) + + +def download_onnx(descriptor: dict, dest_dir: Path) -> Path: + url = descriptor["artifacts"]["onnx"]["url"] + host = re.match(r"https://([^/]+)/", url) + if not host or host.group(1) not in ALLOWED_HOSTS: + raise ValueError(f"artifact host not allowed: {url}") + filename = descriptor["artifacts"]["onnx"].get("filename") or url.rsplit("/", 1)[-1] + dest = dest_dir / filename + if not dest.exists(): + req = urllib.request.Request(url, headers={"User-Agent": "uduck-registry-ci"}) + with urllib.request.urlopen(req, timeout=300) as resp, dest.open("wb") as out: + size = 0 + while True: + chunk = resp.read(1 << 20) + if not chunk: + break + size += len(chunk) + if size > MAX_ONNX_BYTES: + raise ValueError("ONNX artifact exceeds 100 MB sanity bound") + out.write(chunk) + return dest + + +def run(behavior_id: str, out_dir: Path, keep_media: bool) -> int: + descriptor = load_descriptor(behavior_id) + try: + preflight = require_valid(descriptor) + except SimulationPreflightError as exc: + report = { + "behavior": behavior_id, + "execution": "rejected", + "reason": "simulation_preflight", + "preflight": { + "status": "rejected", + "errors": list(exc.result.errors), + "warnings": list(exc.result.warnings), + }, + "media": None, + "generated_at": _dt.datetime.now(_dt.timezone.utc).isoformat(), + } + report_path = write_report(out_dir, behavior_id, report) + print(f"[{behavior_id}] REJECTED by simulation preflight -> {report_path}", file=sys.stderr) + for error in exc.result.errors: + print(f" ERROR {error}", file=sys.stderr) + return 2 + sim_block = descriptor.get("simulation") + if not sim_block or sim_block.get("runner") == "external": + reason = sim_block.get("reason", "no_registry_recipe") if sim_block else \ + "no_registry_recipe" + report = { + "behavior": behavior_id, + "execution": "unsupported", + "reason": reason, + "notes": sim_block.get("notes") if sim_block else None, + "media": None, + "generated_at": _dt.datetime.now(_dt.timezone.utc).isoformat(), + } + write_report(out_dir, behavior_id, report) + print(f"[{behavior_id}] UNSUPPORTED ({reason})") + return 0 + + contract = descriptor["contract"] + robot_model = descriptor["compatibility"]["robot_model"] + simulation_model = sim_block.get("model", robot_model) + if simulation_model != robot_model: + raise ValueError( + f"simulation model {simulation_model!r} must match compatibility model " + f"{robot_model!r}" + ) + if simulation_model not in ("microduck-standard", "microduck-rollers"): + raise ValueError( + f"microduck-standard-v1 does not support the {simulation_model!r} model" + ) + if sim_block["scene"] != "flat-v1": + raise ValueError(f"unsupported registry scene: {sim_block['scene']}") + spec = scenario_from_descriptor(sim_block) + duration = float(sim_block["duration_s"]) + + with tempfile.TemporaryDirectory(prefix="uduck-sim-") as tmp: + onnx_path = download_onnx(descriptor, Path(tmp)) + onnx_sha = hashlib.sha256(onnx_path.read_bytes()).hexdigest() + + from fetch_assets import fetch + asset_variant = "rollers" if simulation_model == "microduck-rollers" else "standard" + mjcf = fetch(variant=asset_variant) + model = load_model(mjcf) + + print(f"[sim] loading runtime for {behavior_id}...", flush=True) + runtime = DuckRuntime(model, onnx_path, + action_scale=float(contract.get("action_scale", 1.0))) + runtime.prepare_start(sim_block["start"]) + command_fn = make_command_fn(spec, runtime.use_13d) + print(f"[sim] obs_dim={runtime.obs_dim} scenario={spec.name or spec.kind} " + f"duration={duration}s", flush=True) + + renderer = render.LoopRenderer(model) + renderer.attach(runtime.data) + + def hook(k, sample): + if k % 50 == 0: + print(f"[sim] step {k}/{int(duration * 50)}", flush=True) + renderer.capture(k, sample) + + result = runtime.rollout(command_fn, duration, frame_hook=hook) + report = checks.evaluate(result, spec) + + media = None + if keep_media: + caption = f"{descriptor['name']} - registry sim (flat-v1, 50 Hz)" + media = renderer.finalize(out_dir / behavior_id, caption) + + report.update({ + "behavior": behavior_id, + "recipe": { + "runner": sim_block["runner"], + "model": simulation_model, + "scene": sim_block["scene"], + "start": sim_block["start"], + "scenario": spec.name or spec.kind, + }, + "duration_s": duration, + "policy": { + "url": descriptor["artifacts"]["onnx"]["url"], + "sha256": onnx_sha, + "baked_normalizer": descriptor["artifacts"]["onnx"].get("baked_normalizer"), + }, + "media": media, + "preflight": { + "status": "passed", + "warnings": list(preflight.warnings), + }, + "generated_at": _dt.datetime.now(_dt.timezone.utc).isoformat(), + "runtime": { + "mjcf": f"{'robot_allcollisions_rollers.xml' if asset_variant == 'rollers' else 'robot_allcollisions.xml'} " + "(pollen-robotics/microduck-simulator, pinned)", + "timestep_s": 0.005, + "decimation": 4, + "control_hz": 50, + "renderer": "mujoco EGL offscreen", + }, + }) + report_path = write_report(out_dir, behavior_id, report) + + print(f"[{behavior_id}] RENDERED; CHECKS {report['checks_status'].upper()} -> {report_path}") + for c in report["checks"]: + print(f" {'PASS' if c['passed'] else 'FAIL'} {c['check']}: {c['detail']}") + return 0 if report["checks_status"] == "passed" else 1 + + +def write_report(out_dir: Path, behavior_id: str, report: dict) -> Path: + target = out_dir / behavior_id + target.mkdir(parents=True, exist_ok=True) + report_path = target / "report.json" + report_path.write_text(json.dumps(report, indent=2) + "\n") + return report_path + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--behavior", required=True) + parser.add_argument("--out", default=str(REPO_ROOT / "sim-results")) + parser.add_argument("--keep-media", action="store_true", + help="render the loop.mp4 / poster.png (slower)") + args = parser.parse_args() + try: + return run(args.behavior, Path(args.out), args.keep_media) + except Exception as exc: # noqa: BLE001 + write_report(Path(args.out), args.behavior, { + "behavior": args.behavior, + "execution": "failed", + "error": str(exc), + "media": None, + "generated_at": _dt.datetime.now(_dt.timezone.utc).isoformat(), + }) + print(f"ERROR running sim for {args.behavior}: {exc}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/simulation/tests/test_asset_variants.py b/simulation/tests/test_asset_variants.py new file mode 100644 index 0000000..86fc950 --- /dev/null +++ b/simulation/tests/test_asset_variants.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import json +import unittest +from pathlib import Path + +from fetch_assets import select_variant + + +class AssetVariantTest(unittest.TestCase): + def setUp(self) -> None: + lock = json.loads(Path("simulation/assets.lock.json").read_text()) + self.lock = lock + + def test_standard_variant_keeps_the_pinned_default(self) -> None: + selected = select_variant(self.lock, "standard") + self.assertEqual(selected["model_dir"], "microduck-mjlab") + self.assertEqual(selected["model_path"], "robot_allcollisions.xml") + + def test_roller_variant_overlays_the_official_model_and_meshes(self) -> None: + selected = select_variant(self.lock, "rollers") + paths = {entry["path"] for entry in selected["files"]} + self.assertEqual(selected["model_dir"], "microduck-mjlab-rollers") + self.assertEqual(selected["model_path"], "robot_allcollisions_rollers.xml") + self.assertTrue({ + "robot_allcollisions_rollers.xml", + "assets/roller_blade.stl", + "assets/tire.stl", + "assets/rim.stl", + } <= paths) + + +if __name__ == "__main__": + unittest.main() diff --git a/simulation/tests/test_preflight.py b/simulation/tests/test_preflight.py new file mode 100644 index 0000000..86d7dfe --- /dev/null +++ b/simulation/tests/test_preflight.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import unittest + +from microduck_sim.preflight import preflight_descriptor, require_valid +from microduck_sim.scenarios import make_command_fn, scenario_from_descriptor + + +def descriptor() -> dict: + return { + "contract": { + "observation_dim": 61, + "action_dim": 14, + "control_frequency_hz": 50, + "decimation": 4, + "actuator_model": "Dynamixel XL330 (BAM M6 voltage control law)", + }, + "compatibility": {"robot_model": "microduck-standard"}, + "simulation": { + "runner": "microduck-standard-v1", + "scene": "flat-v1", + "start": {"preset": "standing_pose"}, + "scenario": "velocity", + "duration_s": 4, + "segments": [{"duration_s": 4, "vx": 0.25, "vy": 0, "wz": 0}], + }, + } + + +class SimulationPreflightTest(unittest.TestCase): + def test_accepts_a_complete_supported_recipe(self) -> None: + result = preflight_descriptor(descriptor()) + + self.assertTrue(result.valid) + self.assertEqual(len(result.warnings), 1) + self.assertIn("BAM", result.warnings[0]) + + def test_rejects_command_outside_the_runtime_range(self) -> None: + candidate = descriptor() + candidate["simulation"]["segments"][0]["vx"] = 2.2 + + result = preflight_descriptor(candidate) + + self.assertFalse(result.valid) + self.assertIn("simulation.segments[0].vx=2.2", result.errors[0]) + + def test_rejects_an_implicit_or_partial_velocity_schedule(self) -> None: + missing = descriptor() + del missing["simulation"]["segments"] + self.assertFalse(preflight_descriptor(missing).valid) + + partial = descriptor() + partial["simulation"]["segments"][0]["duration_s"] = 3 + result = preflight_descriptor(partial) + self.assertFalse(result.valid) + self.assertTrue(any("must cover the rollout exactly" in error for error in result.errors)) + + def test_external_recipe_is_not_admitted_to_the_standard_runner(self) -> None: + candidate = descriptor() + candidate["simulation"] = { + "runner": "external", + "reason": "custom_environment", + } + + result = preflight_descriptor(candidate) + + self.assertTrue(result.valid) + self.assertEqual(result.errors, ()) + + def test_runtime_command_defense_does_not_clip(self) -> None: + candidate = descriptor() + candidate["simulation"]["segments"][0]["vx"] = 0.4 + spec = scenario_from_descriptor(candidate["simulation"]) + + with self.assertRaisesRegex(ValueError, "exceeds"): + make_command_fn(spec, use_13d=True)(0) + + with self.assertRaisesRegex(ValueError, "exceeds"): + require_valid(candidate) + + +if __name__ == "__main__": + unittest.main() diff --git a/simulation/tests/test_runtime_observations.py b/simulation/tests/test_runtime_observations.py new file mode 100644 index 0000000..440f5de --- /dev/null +++ b/simulation/tests/test_runtime_observations.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import unittest + +import numpy as np + +from microduck_sim.robot import RolloutResult, StepSample +from microduck_sim.scenarios import scenario_from_descriptor + + +def sample(t: float, left: bool, right: bool, upright_z: float = -1.0) -> StepSample: + return StepSample( + t=t, + command=np.zeros(13, dtype=np.float32), + action=np.zeros(14, dtype=np.float32), + trunk_height=0.12, + trunk_pos=np.array([0.0, 0.0, 0.12]), + upright_z=upright_z, + lin_vel_world=np.zeros(3), + left_foot_contact=left, + right_foot_contact=right, + ) + + +class RuntimeObservationsTest(unittest.TestCase): + def result(self, samples: list[StepSample]) -> RolloutResult: + return RolloutResult( + samples=samples, + obs_dim=61, + use_13d=True, + control_steps=len(samples), + duration_s=len(samples) / 50, + ) + + def test_reset_drop_is_not_takeoff(self) -> None: + metrics = self.result([ + sample(0.00, False, False), + sample(0.02, True, True), + sample(0.04, True, True), + ]).metrics() + self.assertFalse(metrics["takeoff_after_support"]) + self.assertFalse(metrics["touchdown_after_takeoff"]) + + def test_supported_contact_loss_and_return_is_takeoff_and_touchdown(self) -> None: + metrics = self.result([ + sample(0.00, False, False), + sample(0.02, True, True), + sample(0.04, False, False), + sample(0.06, False, False), + sample(0.08, True, True), + ]).metrics() + self.assertTrue(metrics["takeoff_after_support"]) + self.assertTrue(metrics["touchdown_after_takeoff"]) + + def test_initial_support_then_airborne_is_takeoff(self) -> None: + result = self.result([ + sample(0.00, False, False), + sample(0.02, True, True), + sample(0.04, False, False), + ]) + result.initial_left_foot_contact = True + result.initial_right_foot_contact = True + self.assertTrue(result.metrics()["takeoff_after_support"]) + + def test_airborne_reset_followed_by_landing_is_not_takeoff(self) -> None: + metrics = self.result([ + sample(0.00, False, False), + sample(0.02, False, False), + sample(0.04, True, True), + ]).metrics() + self.assertFalse(metrics["takeoff_after_support"]) + + def test_max_tilt_uses_the_worst_sample(self) -> None: + metrics = self.result([ + sample(0.00, True, True, upright_z=-1.0), + sample(0.02, True, True, upright_z=0.0), + sample(0.04, True, True, upright_z=-1.0), + ]).metrics() + self.assertEqual(metrics["max_tilt_deg"], 90.0) + + def test_scenario_is_selected_without_a_robotd_slot(self) -> None: + recipe = { + "runner": "microduck-standard-v1", + "scenario": "oneshot_zero", + "duration_s": 4, + "checks": ["recover_upright"], + } + spec = scenario_from_descriptor(recipe) + self.assertEqual(spec.kind, "oneshot_zero") + self.assertEqual(spec.checks, ["recover_upright"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/app/behaviors/[id]/page.tsx b/src/app/behaviors/[id]/page.tsx index 8f1542d..d75408c 100644 --- a/src/app/behaviors/[id]/page.tsx +++ b/src/app/behaviors/[id]/page.tsx @@ -9,6 +9,9 @@ import { ContractSpec } from "@/components/ContractSpec"; import { MediaPreview } from "@/components/MediaPreview"; import { getSocialCopy, getSocialImagePath } from "@/lib/social"; import { SITE_NAME } from "@/lib/site"; +import { RegistrySimulation } from "@/components/RegistrySimulation"; +import { getRegistrySimulationResult } from "@/lib/simulation-results"; +import { hasPublisherMedia, preferredMedia } from "@/lib/simulation"; interface Props { params: Promise<{ id: string }>; @@ -56,6 +59,9 @@ export default async function BehaviorDetailPage({ params }: Props) { const author = behavior.authors[0]; const authorUrl = author?.url ?? (author?.github ? `https://github.com/${author.github}` : undefined); const artifact = behavior.artifacts.onnx; + const registrySimulation = getRegistrySimulationResult(behavior); + const publisherHasMedia = hasPublisherMedia(behavior); + const heroMedia = preferredMedia(behavior, registrySimulation ?? undefined); const hasHardwareEvidence = behavior.verification.status !== "community_experimental"; const downloadCommand = `curl --fail --location --output "${artifact.filename}" "${artifact.url}"`; @@ -84,13 +90,20 @@ export default async function BehaviorDetailPage({ params }: Props) {
-
- +
+
- {behavior.media.caption &&
{behavior.media.caption}
} + {heroMedia.caption &&
{heroMedia.caption}
}
+ {registrySimulation && ( + + )} {behavior.details && (

diff --git a/src/app/globals.css b/src/app/globals.css index 40af9ff..3f42e91 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -855,6 +855,64 @@ code { .detail-card h2 svg { color: var(--orange); } .detail-card p { margin: 0; color: var(--ink-soft); font-size: 0.85rem; line-height: 1.65; } +.registry-simulation { display: grid; gap: 1.2rem; } +.registry-simulation-secondary { padding: 0; } +.registry-simulation-disclosure { min-width: 0; } +.registry-simulation-summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 1.05rem 1.25rem; + cursor: pointer; + list-style: none; +} +.registry-simulation-summary::-webkit-details-marker { display: none; } +.registry-simulation-summary-copy { display: grid; gap: 0.28rem; min-width: 0; } +.registry-simulation-summary-title { display: inline-flex; align-items: center; gap: 0.5rem; font-size: 0.95rem; font-weight: 800; } +.registry-simulation-summary-title svg { color: var(--orange); } +.registry-simulation-summary-note { color: var(--quiet); font-family: var(--font-mono); font-size: 0.6rem; letter-spacing: 0.04em; } +.registry-simulation-summary-action { display: inline-flex; flex: 0 0 auto; align-items: center; gap: 0.32rem; color: var(--orange); font-family: var(--font-mono); font-size: 0.6rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; } +.registry-simulation-summary-action svg { transition: transform 160ms ease; } +.registry-simulation-disclosure[open] .registry-simulation-summary { border-bottom: 1px solid var(--line); } +.registry-simulation-disclosure[open] .registry-simulation-summary-action svg { transform: rotate(180deg); } +.registry-simulation-content { display: grid; gap: 1.1rem; padding: 1.1rem 1.25rem 1.25rem; } +.registry-simulation-note { margin: 0; color: var(--muted); font-size: 0.78rem; line-height: 1.55; } +.registry-simulation-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; } +.registry-simulation-head h2 { margin-bottom: 0.45rem; } +.registry-simulation-head p { max-width: 44rem; } +.registry-simulation-diagnostics { border-top: 1px solid var(--line); } +.registry-simulation-diagnostics-summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding-top: 0.9rem; + cursor: pointer; + list-style: none; + color: var(--orange); + font-family: var(--font-mono); + font-size: 0.6rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} +.registry-simulation-diagnostics-summary::-webkit-details-marker { display: none; } +.registry-simulation-diagnostics-summary svg { transition: transform 160ms ease; } +.registry-simulation-diagnostics[open] .registry-simulation-diagnostics-summary svg { transform: rotate(180deg); } +.registry-simulation-diagnostics-content { padding-top: 0.7rem; } +.registry-simulation-figure { margin: 0; } +.registry-simulation-frame { width: min(100%, 34rem); aspect-ratio: 1; background: var(--bg-inset); } +.registry-simulation-grid { display: grid; grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.2fr); gap: 1.2rem; } +.registry-checks { display: grid; align-content: start; gap: 0.55rem; } +.registry-check { display: flex; align-items: flex-start; gap: 0.55rem; border-bottom: 1px dashed var(--line); padding: 0.45rem 0 0.65rem; color: var(--ink-soft); } +.registry-check:last-child { border-bottom: 0; } +.registry-check > svg { flex: 0 0 auto; margin-top: 0.12rem; color: var(--cyan); } +.registry-check:has(.lucide-circle-x) > svg { color: var(--magenta); } +.registry-check span { display: grid; gap: 0.18rem; } +.registry-check strong { font-family: var(--font-mono); font-size: 0.64rem; letter-spacing: 0.06em; text-transform: uppercase; } +.registry-check small { color: var(--quiet); font-size: 0.7rem; line-height: 1.45; } + .detail-list { margin: 0; } .detail-list div { padding: 0.55rem 0; border-bottom: 1px dashed var(--line); } .detail-list div:last-child { border-bottom: 0; } @@ -1125,6 +1183,10 @@ code { .behavior-description { font-size: 0.7rem; -webkit-line-clamp: 2; } .behavior-byline { margin-top: 0.45rem; font-size: 0.56rem; } .behavior-footer { min-width: 0; margin-top: -0.4rem; } + .registry-simulation-head { display: grid; } + .registry-simulation-summary { align-items: flex-start; } + .registry-simulation-diagnostics-summary { align-items: flex-start; } + .registry-simulation-grid { grid-template-columns: 1fr; } .share-strip-inner { display: block; } .share-strip-actions { justify-content: flex-start; margin-top: 1.2rem; } .footer-compact { grid-template-columns: 1fr; gap: 0.9rem; padding-block: 2rem 1.1rem; } diff --git a/src/app/page.tsx b/src/app/page.tsx index 8831c09..610a5f6 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -4,9 +4,10 @@ import { BehaviorCatalog } from "@/components/BehaviorCatalog"; import { CopyPromptButton } from "@/components/CopyPromptButton"; import { InteractiveDuck } from "@/components/InteractiveDuck"; import { QuackAnchor } from "@/components/QuackAction"; +import { withRegistrySimulation } from "@/lib/simulation-results"; export default function HomePage() { - const behaviors = getAllBehaviors(); + const behaviors = getAllBehaviors().map(withRegistrySimulation); const stats = getRegistryStats(); return ( diff --git a/src/components/BehaviorCard.tsx b/src/components/BehaviorCard.tsx index 1dcb182..b85cf54 100644 --- a/src/components/BehaviorCard.tsx +++ b/src/components/BehaviorCard.tsx @@ -5,17 +5,19 @@ import { ArrowUpRight } from "lucide-react"; import { VerificationBadge } from "./VerificationBadge"; import { MediaPreview } from "./MediaPreview"; import { formatAccessory, formatCategory } from "@/lib/labels"; -import type { Behavior } from "@registry/schema/behavior"; +import { preferredMedia, type BehaviorWithSimulation } from "@/lib/simulation"; interface BehaviorCardProps { - behavior: Behavior; + behavior: BehaviorWithSimulation; } export function BehaviorCard({ behavior }: BehaviorCardProps) { + const previewMedia = preferredMedia(behavior, behavior.registrySimulation); + return (
- +
diff --git a/src/components/BehaviorCatalog.tsx b/src/components/BehaviorCatalog.tsx index bdeb049..a64ba8e 100644 --- a/src/components/BehaviorCatalog.tsx +++ b/src/components/BehaviorCatalog.tsx @@ -3,13 +3,13 @@ import { useMemo, useState } from "react"; import { FilterBar } from "./FilterBar"; import { BehaviorCard } from "./BehaviorCard"; -import type { Behavior } from "@registry/schema/behavior"; +import type { BehaviorWithSimulation } from "@/lib/simulation"; import { DuckMark } from "./DuckMark"; import { QuackButton } from "./QuackAction"; import { formatRobotdSlot } from "@/lib/labels"; interface BehaviorCatalogProps { - initialBehaviors: Behavior[]; + initialBehaviors: BehaviorWithSimulation[]; } export function BehaviorCatalog({ initialBehaviors }: BehaviorCatalogProps) { diff --git a/src/components/RegistrySimulation.tsx b/src/components/RegistrySimulation.tsx new file mode 100644 index 0000000..4bd92ad --- /dev/null +++ b/src/components/RegistrySimulation.tsx @@ -0,0 +1,97 @@ +import { Activity, CheckCircle2, ChevronDown, CircleX } from "lucide-react"; +import { MediaPreview } from "./MediaPreview"; +import { simulationMedia, type RegistrySimulationResult } from "@/lib/simulation"; + +interface RegistrySimulationProps { + result: RegistrySimulationResult; + title: string; + hasPublisherMedia: boolean; +} + +function observationLabel(value: unknown): string { + if (typeof value === "boolean") return value ? "observed" : "not observed"; + if (typeof value === "number") return String(value); + return "not measured"; +} + +function SimulationFacts({ result }: { result: RegistrySimulationResult }) { + const observationCandidates: Array<[string, unknown]> = [ + ["Initial foot contact", result.observations.initial_foot_contact], + ["Takeoff after support", result.observations.takeoff_after_support], + ["Touchdown after takeoff", result.observations.touchdown_after_takeoff], + ["Maximum trunk height (m)", result.observations.max_trunk_height_m], + ["Final tilt (deg)", result.observations.final_tilt_deg], + ]; + const observations = observationCandidates.filter(([, value]) => value != null); + + return ( +
+
+ {observations.map(([label, value]) => ( +
{label}
{observationLabel(value)}
+ ))} +
+
+ {result.checks.map((check) => ( +
+ {check.passed + ?
+ ))} +
+
+ ); +} + +export function RegistrySimulation({ result, title, hasPublisherMedia }: RegistrySimulationProps) { + const simulationDescription = "Registry-owned diagnostic render; it does not validate hardware or reproduce a publisher environment."; + + if (hasPublisherMedia) { + return ( +
+
+ + + + Optional diagnostic render · {result.recipe.scene} + + Show render + +
+

{simulationDescription}

+
+
+ +
+
Generated with {result.recipe.runner}; start: {result.recipe.start.preset}; scenario: {result.recipe.scenario}.
+
+ +
+
+
+ ); + } + + return ( +
+
+
+

+

Registry diagnostic preview · not hardware validation or publisher-environment reproduction.

+
+ shown above +
+
+ + Show diagnostic details + +
+ +
+
+
+ ); +} diff --git a/src/lib/simulation-results.ts b/src/lib/simulation-results.ts new file mode 100644 index 0000000..74a3cca --- /dev/null +++ b/src/lib/simulation-results.ts @@ -0,0 +1,70 @@ +import "server-only"; +import fs from "node:fs"; +import path from "node:path"; +import { z } from "zod"; +import type { Behavior } from "@registry/schema/behavior"; +import type { BehaviorWithSimulation, RegistrySimulationResult } from "./simulation"; + +const CheckResultSchema = z.object({ + check: z.string(), + passed: z.boolean(), + detail: z.string(), +}); + +const RegistrySimulationResultSchema = z.object({ + behavior: z.string(), + execution: z.literal("rendered"), + checks_status: z.enum(["passed", "failed"]), + checks: z.array(CheckResultSchema), + observations: z.record(z.string(), z.unknown()), + recipe: z.object({ + runner: z.literal("microduck-standard-v1"), + model: z.enum(["microduck-standard", "microduck-rollers"]).optional(), + scene: z.literal("flat-v1"), + start: z.object({ preset: z.string() }).passthrough(), + scenario: z.string(), + }), + duration_s: z.number(), + generated_at: z.string(), +}); + +const RESULTS_ROOT = path.resolve(process.cwd(), "public/media/registry-sim"); + +export function getRegistrySimulationResult(behavior: Behavior): RegistrySimulationResult | null { + // A checked-in render is only meaningful while the descriptor opts into the + // same registry-owned runner. This also prevents stale media from surviving + // a later reclassification to an external/publisher environment. + if (!behavior.simulation || behavior.simulation.runner !== "microduck-standard-v1") { + return null; + } + + const id = behavior.id; + const resultDir = path.join(RESULTS_ROOT, id); + const reportPath = path.join(resultDir, "report.json"); + const loopPath = path.join(resultDir, "loop.mp4"); + const posterPath = path.join(resultDir, "poster.png"); + if (![reportPath, loopPath, posterPath].every(fs.existsSync)) return null; + + try { + const parsed = RegistrySimulationResultSchema.safeParse( + JSON.parse(fs.readFileSync(reportPath, "utf8")), + ); + if (!parsed.success || parsed.data.behavior !== id) return null; + return { + ...parsed.data, + media: { + loop_url: `/media/registry-sim/${id}/loop.mp4`, + poster_url: `/media/registry-sim/${id}/poster.png`, + }, + } as RegistrySimulationResult; + } catch { + return null; + } +} + +export function withRegistrySimulation(behavior: Behavior): BehaviorWithSimulation { + return { + ...behavior, + registrySimulation: getRegistrySimulationResult(behavior) ?? undefined, + }; +} diff --git a/src/lib/simulation.ts b/src/lib/simulation.ts new file mode 100644 index 0000000..eb0537a --- /dev/null +++ b/src/lib/simulation.ts @@ -0,0 +1,56 @@ +import type { Behavior } from "@registry/schema/behavior"; + +export interface RegistrySimulationCheck { + check: string; + passed: boolean; + detail: string; +} + +export interface RegistrySimulationResult { + behavior: string; + execution: "rendered"; + checks_status: "passed" | "failed"; + checks: RegistrySimulationCheck[]; + observations: Record; + recipe: { + runner: "microduck-standard-v1"; + model?: "microduck-standard" | "microduck-rollers"; + scene: "flat-v1"; + start: { preset: string; [key: string]: unknown }; + scenario: string; + }; + duration_s: number; + generated_at: string; + media: { + loop_url: string; + poster_url: string; + }; +} + +export type BehaviorWithSimulation = Behavior & { + registrySimulation?: RegistrySimulationResult; +}; + +export function hasPublisherMedia(behavior: Behavior): boolean { + return Boolean( + behavior.media.thumbnail_url || behavior.media.loop_url || behavior.media.video_url, + ); +} + +export function simulationMedia(result: RegistrySimulationResult): Behavior["media"] { + return { + thumbnail_url: result.media.poster_url, + loop_url: result.media.loop_url, + video_url: result.media.loop_url, + hero_type: "video", + caption: `Registry simulation — ${result.recipe.scene}, ${result.recipe.scenario}. Diagnostic render only.`, + }; +} + +export function preferredMedia( + behavior: Behavior, + result?: RegistrySimulationResult, +): Behavior["media"] { + if (hasPublisherMedia(behavior) || !result) return behavior.media; + return simulationMedia(result); +} diff --git a/tests/schema.test.ts b/tests/schema.test.ts index 1a694ef..bc8dab6 100644 --- a/tests/schema.test.ts +++ b/tests/schema.test.ts @@ -74,6 +74,7 @@ describe("behavior schema", () => { jsonSchema.properties.artifacts, jsonSchema.properties.artifacts.properties.onnx, jsonSchema.properties.media, + ...jsonSchema.properties.simulation.oneOf, jsonSchema.properties.sources, jsonSchema.properties.deployment, ]) { @@ -141,4 +142,66 @@ describe("behavior schema", () => { expect(BehaviorSchema.safeParse(badCompatibilityKey).success).toBe(false); }); + it("accepts the optional simulation block and rejects bad values", () => { + const withSim = fixture(); + withSim.simulation = { + runner: "microduck-standard-v1", + scene: "flat-v1", + start: { preset: "settled_standing", settle_s: 0.2 }, + scenario: "velocity", + duration_s: 8, + checks: ["no_fall", "velocity_tracking"], + segments: [ + { duration_s: 2, vx: 0.2, vy: 0, wz: 0 }, + { duration_s: 1.5, vx: 0.1, vy: 0, wz: 0.5 }, + ], + }; + expect(BehaviorSchema.safeParse(withSim).success).toBe(true); + + const external = fixture(); + external.simulation = { + runner: "external", + reason: "custom_environment", + notes: "Requires the publisher's obstacle scene.", + }; + expect(BehaviorSchema.safeParse(external).success).toBe(true); + + const airborne = fixture(); + airborne.simulation = { + runner: "microduck-standard-v1", + scene: "flat-v1", + start: { + preset: "airborne_drop", + trunk_height_m: 0.2, + orientation: "side", + }, + scenario: "standing", + duration_s: 4, + }; + expect(BehaviorSchema.safeParse(airborne).success).toBe(false); + airborne.simulation.start.orientation = "left"; + expect(BehaviorSchema.safeParse(airborne).success).toBe(true); + airborne.simulation.start.linear_velocity_mps = [0, 0, -4]; + expect(BehaviorSchema.safeParse(airborne).success).toBe(false); + + for (const sim of [ + { ...withSim.simulation, scenario: "teleport" }, + { ...withSim.simulation, duration_s: 0.5 }, + { ...withSim.simulation, duration_s: 60 }, + { ...withSim.simulation, end_phase: 1.5 }, + { ...withSim.simulation, trigger_s: 5.5 }, + { ...withSim.simulation, checks: ["jump_really_high"] }, + { ...withSim.simulation, segments: [{ duration_s: 0, vx: 0, vy: 0, wz: 0 }] }, + { ...withSim.simulation, segments: [{ duration_s: 1, vx: 0, vy: 0, wz: 0, boost: 1 }] }, + ]) { + const bad = fixture(); + bad.simulation = sim; + expect(BehaviorSchema.safeParse(bad).success, JSON.stringify(sim)).toBe(false); + } + + const badKey = fixture(); + badKey.simulation = { ...withSim.simulation, warp: true }; + expect(BehaviorSchema.safeParse(badKey).success).toBe(false); + }); + }); diff --git a/tests/simulation-media.test.ts b/tests/simulation-media.test.ts new file mode 100644 index 0000000..181cd2e --- /dev/null +++ b/tests/simulation-media.test.ts @@ -0,0 +1,47 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import type { Behavior } from "@registry/schema/behavior"; +import { preferredMedia, type RegistrySimulationResult } from "../src/lib/simulation"; + +const behavior = JSON.parse( + fs.readFileSync(path.resolve("registry/behaviors/alpha-walking.json"), "utf8"), +) as Behavior; + +const result: RegistrySimulationResult = { + behavior: behavior.id, + execution: "rendered", + checks_status: "passed", + checks: [], + observations: {}, + recipe: { + runner: "microduck-standard-v1", + scene: "flat-v1", + start: { preset: "standing_pose" }, + scenario: "velocity", + }, + duration_s: 6, + generated_at: "2026-09-01T00:00:00Z", + media: { + loop_url: "/media/registry-sim/alpha-walking/loop.mp4", + poster_url: "/media/registry-sim/alpha-walking/poster.png", + }, +}; + +describe("registry simulation media selection", () => { + it("never replaces publisher media", () => { + expect(preferredMedia(behavior, result)).toBe(behavior.media); + }); + + it("uses a reviewed registry render when publisher media is absent", () => { + const withoutPublisherMedia: Behavior = { + ...behavior, + media: { hero_type: "badge" }, + }; + expect(preferredMedia(withoutPublisherMedia, result)).toMatchObject({ + loop_url: result.media.loop_url, + thumbnail_url: result.media.poster_url, + hero_type: "video", + }); + }); +});