commit 47372443ff702bb22451f6c41b6562733aa3c27d Author: Upstream Snapshot Date: Fri Aug 28 15:41:56 2026 +0800 Import upstream snapshot d424a0c899f6b33cbd3daeb279913134349c0b63 Upstream: https://github.com/pollen-robotics/microduck_rl Upstream-Commit: d424a0c899f6b33cbd3daeb279913134349c0b63 Upstream-Branch: develop diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7257287 --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info +.python-version +# uv.lock + + + +# Virtual environments +.venv + +# Logs and databases +wandb +logs +agents + +*.pch +nohup.out +*.onnx +beyondmimic_motions/ +artifacts/ +*.npz + +src/mjlab_microduck/robot/microduck.bak/* +src/mjlab_microduck/robot/microduck_test/* +data/ +*.pt + +claude_experiments/ + +backup_onnx/ + + +logdir/.claude/ +logdir/ +logdir/.claude/worktrees/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ddae6a8 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,245 @@ +# AGENTS.md + +RL training environments for Microduck — a ~800 g, ~25 cm tall bipedal +robot with 14 Dynamixel XL330 servos — built on [mjlab](https://github.com/mujocolab/mjlab) +(MuJoCo Warp) with PPO (rsl_rl). Policies are trained here at 50 Hz, exported to +ONNX, and deployed by the runtime in the `pollen-robotics/microduck` repo on +the real robot. Sim2real transfer +is the whole point: every convention below exists because breaking it produced a +policy that worked in the viewer and failed on hardware. + +## Commands + +```bash +uv run list-envs # live task registry +uv run train --env.scene.num-envs 4096 # train (add --hf-jobs for Hugging Face Jobs) +uv run train --env.scene.num-envs 64 --agent.max_iterations 5 # SMOKE TEST — always run first +uv run play --wandb-run-path +uv run scripts/export.py --wandb-run-path <...> # → ONNX (bakes obs normalizer — mandatory path) +uv run scripts/infer_policy.py --walking out.onnx # CPU MuJoCo deployment rehearsal +uv run --with pytest pytest tests/ +``` + +A 5-iteration smoke test at 64 envs catches ~95% of config errors for cents. +Never launch a long run without one. + +## Repo map + +- `src/mjlab_microduck/tasks/mdp.py` — ALL custom MDP functions (rewards, events, + observations, commands, curricula). Add new functions here, grouped by task. +- `src/mjlab_microduck/tasks/microduck_*_env_cfg.py` — one cfg module per task + family. `microduck_velocity_env_cfg.py` is the main walking recipe AND the + shared base (robot, DR, obs, commands) other envs build on or mirror. +- `src/mjlab_microduck/tasks/__init__.py` — task registration (base + `-Backlash-` variants). +- `src/mjlab_microduck/tasks/backlash.py` — wraps any env cfg into its backlash twin. +- `src/mjlab_microduck/robot/microduck_constants.py` — robot cfgs, HOME frame, BAM actuator cfg. +- `src/mjlab_microduck/robot/microduck/` — MJCF exports from Onshape + (onshape-to-robot, one `config_mjcf_*.json` per model) + scenes + `add_backlash.py`. +- `src/mjlab_microduck/actuator/friction_dr_bam.py` — BAM actuator + friction DR + backlash encoder. +- `scripts/` — export, infer, sim2real comparison, wandb helpers. +- `tests/` — cfg-invariant and mdp-function regression tests (CPU, no GPU needed). + +## Invariants — do not break these + +- **Obs layout is 61D (actor) and shared across the whole policy family** so + policies are hot-swappable in the runtime: 48 base proprioception + + 13D command block `[twist(3), head_pose(4), body_pose(6)]`, in that order. + An env that doesn't use a command slot ZERO-PADS it (keep the obs term, + sample tiny ranges) — never delete a slot. +- **Joint layout** (14 servos, ctrl idx = joint idx on walk/allcollisions + models): 0–4 left leg (hip_yaw, hip_roll, hip_pitch, knee, ankle), 5–8 + neck/head (neck_pitch, head_pitch, head_yaw, head_roll), 9–13 right leg. + On roller/backlash models, passive joints INTERLEAVE — never hardcode joint + indices in mdp functions; use the `_servo_joint_ids` / `_servo_joint_pos` + helpers in mdp.py (identity on plain models, correct everywhere else). +- **Unactuated joints are all named `passive_*`** (wheels, backlash hinges). + Every actuator/obs/reward selector uses `^(?!passive_).*` — keep the prefix + convention when adding joints, and new `passive_` regexes must not + accidentally match backlash joints (`^passive_.*wheel`, not `^passive_.*`). +- **Actuators are BAM** (voltage-controlled XL330 model, friction computed by + the actuator). Two consequences: any STANDALONE env cfg must register the + `expand_bam_friction_fields` startup event, and joint-friction DR must scale + the actuator's `friction_scale` — `dof_frictionloss` is zeroed under BAM, so + randomizing it is a silent no-op. +- **Obs normalization is ON** → the normalizer must be baked into the ONNX. + `scripts/export.py` does this; in-sim play hides the bug (it applies the + normalizer anyway), so never hand-convert a checkpoint. +- **Policies are UNFILTERED** (no action low-pass in training). Don't add EMA + filtering without a matched runtime flag and a transfer test — trained-with / + deployed-without (either direction) breaks transfer. +- **Domain randomization must not accumulate across resets.** mjlab 1.3.0's + `dr.*` ops with `operation="add"/"scale"` are natively non-accumulating (they + re-read compile-time defaults); custom DR functions must restore-then-apply. + An accumulating CoM randomizer once degraded every long run for months. +- If an obs is remapped to a sensor view (backlash encoder, bias), any tracking + REWARD on the same quantity must measure the same view — otherwise the policy + is punished for correcting what it sees. +- `-Backlash-` task variants must mirror their base task's robot model + (walk / allcollisions / rollers) so backlash A/B comparisons are unconfounded. + +## Building a new env — the workflow + +1. **Pick the closest template** and build on it, don't start from scratch: + locomotion → the velocity recipe; episodic trick ending in a pose → + standup; commanded two-state → sitstand; dynamic maneuver → roulade + (read its cfg docstring — it encodes a 5-run lesson arc). Building on + `make_microduck_velocity*_env_cfg` keeps DR / obs / noise / delays in sync + for free; if you build standalone from mjlab's base template, you must port + the whole DR + obs-noise + NaN-guard stack yourself (grep for what velocity + wires: `_safe` critic obs terms, `nan_state` termination with sensor_names, + `expand_bam_friction_fields`, encoder bias, IMU misalignment). +2. **Verify physics assumptions in sim BEFORE training** — this is the single + biggest time-saver: + - A target/rest pose must be a stable equilibrium: hold its ctrl for 3 s + from noisy inits and check TILT, not just height (a settle test that only + records z reports fallen states as "resting fine"). + - Measure target heights off the actual robot in sim (e.g. trunk z under a + standing policy), never carry them across model revisions. A 5 mm-wrong + STAND_Z once turned the goal into an impossible target for days. +3. **Config conventions**: `ENABLE_*` toggles + tuned constants at the top of + the cfg file; factory `make_..._env_cfg(play: bool, rough: bool)`; register + in `tasks/__init__.py` (+ the `_BACKLASH_TASKS` table if applicable); own + `RslRl...RunnerCfg` with a distinct `experiment_name`. Symmetry mirror-loss + is available (61D table in `symmetry.py`) — OFF by default, never for + asymmetric tasks. +4. **Write cfg tests** (see `tests/test_*_cfg.py`): joint indices resolve on + the actual model, reward weights have the intended sign, gates open/closed + where expected. These run on CPU and lock in the invariants. +5. **Smoke test** (64 envs, 5 iters): builds, steps NaN-free, obs is 61D, + every reward term computes, ONNX exports. +6. Train, watch the log (below), and expect 2–5 iterations of reward-hacking + whack-a-mole — that's normal, the lessons below shortcut most of it. + +## Reward design — rules that were each learned the hard way + +- **Sign convention (bit four envs):** mdp.py has two penalty styles. mjlab-base + cost functions return ≥ 0 → negative weight. Self-negating microduck functions + (`*_penalty`, `*_l1` returning ≤ 0) → POSITIVE weight. A negative weight on a + self-negating penalty double-negates into a reward for the violation, and the + policy will farm it (butt-hopping, crash-sits). **The infallible check: on + every run, every `Episode_Reward/` in wandb must be ≤ 0.** +- **RL optimizes the letter of the reward.** Every under-specified degree of + freedom will be exploited (ballistic whip instead of a roll, shoulder-roll + instead of sagittal, head-tripod instead of standing). Encode what counts as + the maneuver in hard state-based gates (support contact, orientation-axis + checks, latches), not in small penalty nudges. +- **No jackpots:** any "reach X" reward must be rate-limited or slewed. + Arriving early at a goal state that then pays per-step is a jackpot that + buys arbitrary violence. For commanded transitions, track a slewed internal + target (constant-rate blend) — being ahead of the ramp pays zero, so slow IS + the argmax. Speed-cap penalties alone integrate to a bounded cost and lose. +- **Never gate a positive reward on being in a bad state** (fallen, low) — the + policy parks in the cheapest qualifying pose and farms it. Use + potential-based shaping instead (pay Δprogress, e.g. Δcos(tilt): rising pays, + holding pays zero, unfarmable). For rest tasks, audit each positive term + against every stable flop (on back / face / side): if flopping keeps most of + the stack, the policy will flop. +- **Episodic pose-landing tasks:** single fixed target from t=0 (Gaussian + L1 + on joints and height, generous std) + |a_z| impact penalty + two-layer + upright — NOT keyframe/waypoint trajectories (the policy camps at + waypoints). The path is what RL is supposed to discover. +- **Regularizers come in two kinds.** Motion-blockers (body_ang_vel, + angular_momentum, pose std) penalize what a dynamic motion physically + requires — keep them LOW for dynamic tasks. Smoothness (action_rate, + joint_torque_rate) damps jitter without blocking slow big motions — safe to + weight, but introduce it AFTER skill discovery (curriculum from ~0): any + attempt-tax active while a hard skill is being explored makes "do nothing" + win. Slow careful tasks (reaching) want heavier smoothness than walking. +- **Compare reward mass, not weights, when copying regularizers between envs.** + PPO sees relative advantage: the same action_rate weight is 4× weaker under a + 4×-larger positive task stack. +- **Tracking Gaussian std:** ≈ the error you still care about, not the max + error — too loose has no gradient at small errors. BUT before tightening, + ask whether the error is escapable by the policy or inherent to the behavior + you want (a 38%-of-body-mass head MUST oscillate while walking; a tight + instantaneous head-tracking std taxed walking so hard the policy stood + still). Price only the escapable part — e.g. L1 on a 1 s EMA charges DC bias + and lets oscillation cancel. +- **Multiplicative composites beat additive sums at goal states:** when an + additive stack has a compromise basin (80% of every term via a lean), a + product of Gaussians collapses on any single deficient factor — but pick stds + wide enough that the CURRENT policy scores visibly, or the gradient is + invisible and nothing changes. +- **Joints parking on hard limits:** fix with a qpos-side limit-proximity + penalty on the offending joints; the stock `dof_pos_limits` only fires in the + last ~7.5% of range, and command-side penalties don't work (wide ctrlrange is + intentional — low-kp servos need overshoot). + +## Commands, observations, dead weights + +- **A command input that is never non-zero has dead weights forever.** Every + command slot keeps a small non-zero sampling range from step 0 (even at + reward weight 0) so its input neurons stay alive for later curricula. +- **Zero-command behavior must be explicitly trained** (`zero_command_prob`-style + exact-zero sampling): uniform sampling essentially never produces the all-zero + command, which is exactly the deployment idle state. +- Rare-but-important command regions need explicit buckets — e.g. turn-in-place + (`rel_turn_in_place_envs`): independent uniform sampling made spinning ~2% of + experience and it never trained. + +## Curricula + +- Steps are env steps: `iteration × 24` (`NUM_STEPS_PER_ENV = 24`). +- Use the proven split: `microduck_mdp.reward_weight` for weight schedules, a + dedicated params-curriculum for command/event ranges. `mdp.reward_weight` is + a step function, not an interpolation — discretize ramps into stages. +- Mutate term cfgs via the managers (`env.event_manager.get_term_cfg(...)`), + never `env.cfg.events[...]` — managers deepcopy their cfg at init, so writes + to `env.cfg` are silent no-ops (this also bites eval scripts that force + spawn states). +- **Phase-align every stage with what the policy has actually learned**: don't + harden spawn mixes before the current slice consolidates; don't introduce + taxes before the skill exists. When a wandb metric steps DOWN exactly at + curriculum stage boundaries, the pacing is wrong — stretch stages or move + the introduction later, never earlier. +- Reverse-curriculum spawns (starting episodes partway through the maneuver, + including nearly-done) are the reliable fix for "learns the start, never the + last mile" — the frontier otherwise gets no on-policy data. + +## Training ops & reading a run + +- wandb project `mjlab_microduck`; logs in `logs//`; resume + with `--agent.load-checkpoint model_XXXX.pt --agent.resume True`. +- Watch per-iteration: mean reward rising AND episode length behaving as the + task demands; every penalty term ≤ 0; the MAIN task term actually growing + (total reward can rise purely on regularizers while the trick never happens). + `Episode_Reward/` logs the WEIGHTED value — a term at weight 0 reads 0 + regardless of behavior, so interpret against the weight schedule. +- Budgets: simple episodic tricks ≈ 1000 iters at 4096 envs; gaits and + curriculum-heavy recovery need 4000–6000. +- **Measure before theorizing.** When a run "fails", run a headless eval of the + actual checkpoint (per-spawn-type batteries, end-state clusters, angular-rate + profiles) before changing rewards: past "failures" turned out to be early + checkpoints, a success criterion splitting one behavior cluster in half, and + a pay cap fighting measured physics. Sim metrics can pass while the video + fails the human eye — watch the video AND check which geom/axis touches. +- Report what rollouts actually show ("rolls but face-plants 1 in 3"), not + "it works!". The user decides when it's good enough. + +## Sim2real footguns (cost real debugging weeks) + +- A fresh `uv sync` is the ground truth (HF Jobs run one): anything that only + works via manually-installed local packages will die remotely. Keep + `pyproject.toml` honest. +- **Wheels are per-architecture.** On linux-`aarch64` (DGX Spark / GB10) PyPI's + torch wheel is CPU-ONLY (`2.9.1+cpu`, `torch.version.cuda is None`), so + `torch.cuda.device_count() == 0` and mjlab's `select_gpus()` indexes an empty + list → `IndexError` before iteration 0. `[tool.uv.sources]` routes torch to + the cu129 index for `aarch64` only (cu129 matches the CUDA toolkit warp + bundles; x86_64/HF Jobs stay on PyPI). Two silent break points, both locked + by `tests/test_aarch64_cuda_torch.py`: torch must stay a DIRECT dependency + (uv applies `[tool.uv.sources]` to direct deps only — deleting the + redundant-looking `torch==` pin makes the routing a no-op), and the pin must + stay `==`, since the CUDA index carries newer builds than PyPI (a `>=` + silently dragged torch 2.9.1 → 2.13.0). +- Physics-aligned limits: a 25 cm robot tumbles at 3.5–5.5 rad/s NATURALLY — + don't impose human-scale speed intuitions via caps; put anti-violence + pressure on impacts and thrash (|a_z|, action_rate, support gates), not on + rotation speed. +- IMU DR is zero-centered — it trains tolerance to misalignment magnitude, and + CANNOT compensate a systematic mounting bias (that's a runtime calibration). +- Real deployments hot-swap ONNX policies (walk / stand / trick) with a shared + obs contract — rehearse in `scripts/infer_policy.py` before touching the + robot, with the correct command-slot writes (a posture flag lives in the + twist vx slot; feeding all-zeros means "stand", which looks like "policy + ignores the button"). diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5babf66 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this + License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Pollen Robotics + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..308ff4c --- /dev/null +++ b/README.md @@ -0,0 +1,195 @@ +# Microduck RL + +image + + +RL training environments for [Microduck](https://github.com/pollen-robotics/microduck) — +a ~800 g, ~25 cm tall bipedal robot — built on +[mjlab](https://github.com/mujocolab/mjlab) (MuJoCo Warp) with PPO. +Policies are trained here at 50 Hz, exported to ONNX, and deployed on the real +robot by the runtime in [pollen-robotics/microduck](https://github.com/pollen-robotics/microduck). + + + +https://github.com/user-attachments/assets/50c3d537-8db2-4005-9d9c-3472faeec4d0 + +The repo encodes the full sim2real recipe: [BAM](https://github.com/Rhoban/bam) +actuator physics, domain randomization, backlash simulation, and the +reward-design lessons that made it work +(see [AGENTS.md](AGENTS.md) for the distilled playbook). + +## Quickstart + +Requires a CUDA GPU (training runs through MuJoCo Warp) and [uv](https://docs.astral.sh/uv/). + +> **On ARM boxes (DGX Spark / GB10, Jetson):** `uv sync` pulls ~2 GB of CUDA +> wheels on first run and uv's default 30 s HTTP timeout can abort mid-download. +> Export `UV_HTTP_TIMEOUT=600` for the first sync. + +```bash +git clone https://github.com/pollen-robotics/microduck_rl +cd microduck_rl + +# train the walking policy (uses your GPU; ~1-2 h for a usable gait at 4096 envs) +uv run train Mjlab-Velocity-Flat-MicroDuck --env.scene.num-envs 4096 + +# watch a trained policy in the viewer +uv run play Mjlab-Velocity-Flat-MicroDuck --wandb-run-path + +# export to ONNX for deployment +uv run scripts/export.py Mjlab-Velocity-Flat-MicroDuck --wandb-run-path <...> + +# drive the exported policy in CPU MuJoCo with the keyboard +uv run scripts/infer_policy.py --walking output.onnx +``` + +Resume from a checkpoint: + +```bash +uv run train Mjlab-Velocity-Flat-MicroDuck --env.scene.num-envs 4096 \ + --agent.run-name resume --agent.load-checkpoint model_29999.pt --agent.resume True +``` + +No GPU? Add `--hf-jobs` to any train command to run it on Hugging Face Jobs +instead of locally (see [scripts/hf/README.md](scripts/hf/README.md)). + +## Tasks + +`uv run list-envs` prints the live registry. Flat/Rough variants exist where noted. + + + +| Task id | Terrain | Description | +|---|---|---| +| `Mjlab-Velocity-{Flat,Rough}-MicroDuck` | flat/rough | **The main task**: walking with velocity commands + head-pose commands | +| `Mjlab-VelStand-{Flat,Rough}-MicroDuck` | flat/rough | Walking + fall recovery in one policy | +| `Mjlab-StandUp-{Flat,Rough}-MicroDuck` | flat/rough | Stand up from face-down/face-up/sitting, then hold the stand + body-pose control | +| `Mjlab-SitStand-{Flat,Rough}-MicroDuck` | flat/rough | Commanded sit ↔ stand in one policy, gently, head commandable | +| `Mjlab-GroundPick-{Flat,Rough}-MicroDuck` | flat/rough | Crouch and touch the ground with the mouth tip, return to stand | +| `Mjlab-BallKick-Flat-MicroDuck` | flat | Kick a 70 mm / 15 g ball forward (actor is ball-blind) | +| `Mjlab-Roulade-Flat-MicroDuck` | flat | Forward roll over the head, land back on the feet | +| `Mjlab-Velocity-Flat-MicroDuck-Rollers` | flat | Roller-skate velocity tracking (passive wheels under the feet) | +| `Mjlab-Velocity-Swizzle-MicroDuck` | flat | Classic symmetric swizzle skating | +| `Mjlab-RollerCrouch-Flat-MicroDuck` | flat | Crouch while gliding on rollers | +| `Mjlab-RollerSlope-Flat-MicroDuck` | slope | Glide down slopes on rollers | +| `Mjlab-RollerStandUp-Flat-MicroDuck` | flat | Stand up from the ground onto the wheels | +| `Mjlab-Spin-Flat-MicroDuck` | flat | Fast spin in place on rollers | + +At deployment the runtime hot-swaps these policies (walk / recover / trick) +behind a shared 61-dimensional observation contract, so any of them can take +over the robot at any moment. `scripts/infer_policy.py` rehearses exactly that: + +```bash +uv run scripts/infer_policy.py --walking walk.onnx --standing stand.onnx \ + --sitstand sitstand.onnx --roulade roulade.onnx --new-cmd-obs +``` + +Keyboard-driven (velocity commands, `G` ground pick, `Y` sit/stand, `R` roulade, +`K`/`L` kicks); `--debug`, `--save-csv`, `--record` support sim2real comparisons. + +### Backlash variants + +Every main task has a **Backlash** twin that trains on a model with ±1° of gear +play (2° total) in series with each of the 14 servo joints: insert `-Backlash` +before `MicroDuck` in the task id, e.g. `Mjlab-Velocity-Flat-Backlash-MicroDuck`. + +The backlash is modeled properly for sim2real: each servo gets an unactuated +`passive__backlash` hinge, and because the real encoder sits on the +output side of the play, both the firmware PD emulation +(`BacklashEncoderBamActuator`) and the `joint_pos`/`joint_vel` observations +read *through* the backlash (`qpos[servo] + qpos[backlash]`). Observation and +action dims are unchanged, so ONNX export and the runtime need no changes. +See `src/mjlab_microduck/tasks/backlash.py`. + +## Actuator model + +All tasks use the [BAM](https://github.com/Rhoban/bam) M6 actuator model for +the Dynamixel XL330 (voltage control law, back-EMF, Coulomb/Stribeck/load-dependent +friction), with per-env domain randomization on battery voltage, voltage sag +under load, command delay, and friction magnitude +(`FrictionDRBamActuator` in `src/mjlab_microduck/actuator/`). + +At this scale — tiny servos driving a ~800 g biped — actuator fidelity is most +of the sim2real gap, which is why the actuator is modeled down to its voltage +control law instead of an ideal PD. + +## Robot models + +MJCF models live in `src/mjlab_microduck/robot/microduck/` and are exported +from Onshape with [onshape-to-robot](https://github.com/Rhoban/onshape-to-robot), +one `config_mjcf_*.json` per model: + +| XML | Used by | +|---|---| +| `robot_walk.xml` | Velocity (stripped trunk/head contacts — falling is cheap) | +| `robot_allcollisions.xml` | VelStand, StandUp, SitStand, GroundPick, BallKick, Roulade (body can physically lie on the ground) | +| `robot_allcollisions_rollers.xml` | Roller tasks (passive wheels) | +| `robot_*_backlash.xml` | Backlash task variants (generated by `add_backlash.py`) | + +`scene*.xml` files wrap the robots with a floor + keyframes (STAND/SIT/FOLD) +for quick viewing and for `infer_policy.py`. + + + +## Project structure + +``` +src/mjlab_microduck/ +├── robot/ +│ ├── microduck/ # MJCF exports, export configs, scenes, add_backlash.py +│ └── microduck_constants.py # robot cfgs, HOME frame, BAM actuator cfg +├── actuator/friction_dr_bam.py # BAM + friction DR + backlash encoder feedback +├── tasks/ +│ ├── __init__.py # task registration (base + backlash variants) +│ ├── mdp.py # rewards, events, observations, custom classes +│ ├── backlash.py # make_backlash_variant() env-cfg wrapper +│ └── microduck_*_env_cfg.py # one cfg module per task family +├── train_cli.py # `train` entry point (+ --hf-jobs) +└── hf_jobs.py # Hugging Face Jobs submission +``` + +Conventions worth knowing: + +- The observation layout is shared across every policy (61-dim actor obs: + 48 proprioception + commands `[twist(3), head_pose(4), body_pose(6)]`), which + is what makes runtime policy hot-swapping possible. Envs that don't use a + command slot zero-pad it rather than dropping it. +- Unactuated joints are all named `passive_*` (roller wheels, backlash + hinges); actuators, joint observations and pose rewards select servo joints + with `^(?!passive_).*`. +- Domain-randomization toggles are `ENABLE_*` booleans at the top of each + env cfg file. +- Joint layout (14 servos): 0–4 left leg (hip_yaw, hip_roll, hip_pitch, knee, + ankle), 5–8 neck/head (neck_pitch, head_pitch, head_yaw, head_roll), + 9–13 right leg. +- The exporter bakes the observation normalizer into the ONNX graph — always + deploy ONNX produced by `scripts/export.py`, never a hand-converted + checkpoint, or the policy sees unnormalized observations at runtime. + +[AGENTS.md](AGENTS.md) documents the env-building workflow and the reward-design +rules learned across the project (also aimed at AI coding agents working in +this repo). + +## Tests + +```bash +uv run --with pytest pytest tests/ +``` + +CPU-only config-invariant and reward-function regression tests — they lock in +joint-index mappings, reward sign conventions, and NaN guards. + +## Related projects + +- [microduck](https://github.com/pollen-robotics/microduck) — the Microduck project home, including the onboard runtime that runs the exported policies +- [mjlab](https://github.com/mujocolab/mjlab) — the training framework (MuJoCo Warp + rsl_rl) +- [BAM](https://github.com/Rhoban/bam) — better actuator models, by Rhoban + +## License + +This project is licensed under the Apache 2.0 License. See the [LICENSE](LICENSE) file for details. +3D model files are licensed under Creative Commons BY-SA-NC. diff --git a/docs/roller_standup_policy_summary.md b/docs/roller_standup_policy_summary.md new file mode 100644 index 0000000..c7def69 --- /dev/null +++ b/docs/roller_standup_policy_summary.md @@ -0,0 +1,195 @@ +# Policy `roller_standup` — se relever sur rollers + +**But** : le microduck (sur rollers) part du sol — à plat ventre ou à plat dos — et se remet **debout sur ses roues**, puis **tient** la station. + +- **Tâche** : `Mjlab-RollerStandUp-Flat-MicroDuck` +- **Fichier** : `src/mjlab_microduck/tasks/microduck_roller_standup_env_cfg.py` +- **Base** : dérivée de l'env roller (`velocity_rollers`) → même robot, même physique/DR, **même observation 61D** (interchangeable au runtime, chargeable via `--new-cmd-obs`). +- **Spec** : `docs/superpowers/specs/2026-08-04-roller-standup-design.md` +- **Politique aveugle** : pas de scan de terrain ; proprioception + `projected_gravity`. + +## Hauteurs (mesurées, pas devinées) + +| pose | modèle pieds | modèle rollers | +|---|---|---| +| debout | 0.1172 → `STAND_Z=0.115` sous charge | 0.1407 → **`ROLLER_STAND_Z=0.138`** | +| à plat ventre (repos) | 0.075 | 0.075 | +| à plat dos (repos) | 0.048 | 0.048 | + +Les hauteurs de repos au sol sont identiques aux deux modèles : c'est la coque du tronc qui touche, pas les pieds. + +## ⚠️ Indices de joints — les roues sont INTERCALÉES + +``` +0-4 jambe gauche 5-6 roues gauches +7-10 cou / tête 11-15 jambe droite 16-17 roues droites +``` +`_LEG_JOINTS = [0-4, 11-15]`. Les indices du `standup` (`[0-4, 9-13]`) valent pour le modèle **sans** roues et pointeraient sur des roues ici. Verrouillé par `tests/test_roller_standup_cfg.py::test_joint_indices_match_actual_roller_model`. + +## Reset — départ au sol + +`set_random_ground_state` : ventre (`prone_z` 0.076–0.09, plancher relevé car le ventre ne décolle du sol qu'à 0.0752) / dos / **déjà debout** (`standing_z` 0.134–0.144), ± 10° de bruit en pitch/roll. Pas de bucket « assis ». Le bucket « debout » est nécessaire : sans lui la policy monte mais ne tient pas. + +**Curriculum `ground_state_mix`** (easy → hard, le dos en dernier) : + +| iter | debout | ventre | dos | +|---|---|---|---| +| 0 | 0.50 | 0.50 | 0.00 | +| 600 | 0.35 | 0.45 | 0.20 | +| 1500 | 0.25 | 0.40 | 0.35 | +| 2500 | 0.20 | 0.40 | 0.40 | + +## Récompenses + +Dix termes repris du `standup` avec leurs poids déjà réglés : `pose_stand_legs` (+8), `pose_stand_l1` (+5), `height_stand` (+4, std 0.04), `height_stand_sharp` (+4, std 0.015), `height_stand_l1` (+30), `com_upward_velocity` (+3), `gentle_rise` (−0.02), `upright_linear` (+6), `upright_sharp` (+6), `standing_composite` (+15). Plus `joint_torque_rate_l2` (−2e-3), l'anti-jitter qui n'empêche pas le retournement. + +Régularisateurs hérités : `body_ang_vel` **−0.05** (bloqueur de mouvement, à garder LÉGER), `angular_momentum` −0.02, `action_rate_l2` (rampe −0.4 → −1.0, **pas** le −2.0 du roller), `neck_action_rate_l2` −0.5, `neck_joint_pos_l2` −0.5 (tête droite), `joint_torques_l2` −1e-3, `action_over_limit` −0.5, `self_collisions` −1.0. + +Retirées : toutes les récompenses de patinage, plus `feet_flat` (les lames ne sont pas à plat pendant la montée) et `hip_roll_neutral` (se relever demande d'écarter les jambes). + +## ⚠️ Le point dur : les roues roulent + +Aucune adhérence longitudinale pour pousser sur le sol. Le **curriculum de friction de roulement est INVERSÉ** (l'env roller la fait monter, ici elle descend) : + +| iter | frictionloss | | +|---|---|---| +| 0 | 0.05 | roues quasi bloquées → se relève comme avec des pieds | +| 1000 | 0.02 | | +| 2000 | 0.008 | | +| 3000 | 0.003 | | +| 4000 | 0.0015 | la vraie valeur du roulement | + +**Surveiller `Episode_Reward/standing_composite` aux paliers.** S'il s'écroule, le geste « pieds adhérents » ne transfère pas aux roues libres → il faudra guider une technique de patineur (appui genou intermédiaire, un patin à la fois). C'est un résultat, pas un échec. + +**Surveiller AUSSI la dérive horizontale du robot en play**, à chaque palier de friction. `standing_composite` ne voit ni `root_link_pos_w[:2]` ni la vitesse horizontale : une policy qui se relève en glissant loin de son point de départ collecte exactement le même score qu'une qui se relève et s'arrête. Tant que cette dérive n'a pas été mesurée visuellement, le résultat du curriculum de friction (la question même que cet env existe pour trancher) n'est pas fiable. + +**Sim2real** : seuls les checkpoints d'après iter 4000 sont candidats au déploiement. Avant, la policy s'appuie sur une friction qui n'existe pas sur le vrai robot. + +## Commande + +Slot `twist` neutralisé : `lin_vel_x`/`lin_vel_y` ± 0.01, `ang_vel_z` **± 0.05** (5× plus large — même +choix que le `standup`). Slots `head_pose` / `body_pose` **zero-paddés** (convention roller). Déploiement visé : en `--standing` face à la policy roller en `--walking`, avec la bascule automatique sur la magnitude de la commande (`infer_policy.py:262`, seuil 0.05) ; le slot twist y est laissé à zéro (`infer_policy.py:239`). + +**Réserve** : `infer_policy.py` est le script de sim/clavier local. Le runtime robot est le binaire Rust `microduck_runtime`, absent du repo — il n'est pas vérifié qu'il expose un équivalent `--standing`. Le doc de passation du crouch ne liste que `--model`, `--ground-pick`, `--fold-policy`. À confirmer. + +## Terminaisons + +`fell_over` **supprimée** (le robot démarre tombé). `nan_state` héritée. `nan_policy="sanitize"` sur les obs actor/critic. + +## Réseau / PPO + +Actor et critic `(512, 256, 128)` elu, `obs_normalization=True`. PPO `lr=1e-3` adaptive, `desired_kl=0.01`, `gamma=0.99`, `lam=0.95`, `num_steps_per_env=24`, épisode 6 s, `max_iterations=15000`. **Symétrie OFF** (`SYMMETRY_CFG` est câblé pour le layout 51D). + +## Commandes + +```bash +uv run train Mjlab-RollerStandUp-Flat-MicroDuck --env.scene.num-envs 4096 --agent.max_iterations 15000 +uv run scripts/play_latest.py # alias md-play +uv run scripts/export_latest.py # alias md-export +uv run --with pytest pytest tests/test_roller_standup_cfg.py -q +``` + +### ⚠️ Voir les départs sur le dos au play + +Un play ne montre **jamais** de départ sur le dos par défaut : l'env de play est +reconstruit à neuf, donc `common_step_counter` repart à 0 et le curriculum applique son +palier 0, où `face_up_prob = 0`. On ne voit que 50 % ventre / 50 % debout, quelle que soit +la maturité du checkpoint chargé. Or le dos est le cas le plus dur, celui qu'on veut +justement inspecter. + +`STANDUP_PLAY_FACE_UP` force le mélange (même motif que `SLOPE_PLAY_DIFFICULTY` dans +`roller_slope`), **uniquement sur le chemin `play=True`** — l'entraînement et son +curriculum easy → hard sont intouchés : + +```bash +STANDUP_PLAY_FACE_UP=1.0 md-play # 100 % de départs sur le dos +STANDUP_PLAY_FACE_UP=0.4 md-play # le mélange du dernier palier du curriculum +STANDUP_PLAY_FACE_UP=none md-play # défaut (palier 0, pas de dos) +``` + +Le reste (`1 - face_up`) est réparti ventre:debout dans le rapport 2:1 du dernier palier, +si bien que `0.4` reproduit exactement le mélange de fin d'entraînement (0.40 / 0.20 / 0.40). + +## 🔧 Correction anti-violence (après premier test robot) + +**Symptômes** sur un checkpoint 4000+ : mouvements très brusques, la tête tape le sol, +échec du relevé depuis le dos sur le robot. **Présents en simu aussi** → ce n'était donc +ni du sim2real, ni un checkpoint trop jeune, mais la conception des récompenses. + +**Root cause : `gentle_rise` récompensait la violence.** `trunk_vertical_accel_penalty` +renvoie déjà `-|a_z|` (`mdp.py:2171`) ; multiplié par le poids **−0.02** hérité du +`standup`, ça faisait un double négatif, donc `+0.02·|a_z|` — **plus le tronc accélérait +brutalement, plus la policy était payée**. Confirmé par le log : `Episode_Reward/gentle_rise += +0.0118` sur le run `vweolw91`, seul terme de pénalité loggé positif. + +`mdp.py` mélange deux conventions de signe, et c'est le piège : + +| terme | la fonction renvoie | poids correct | +|---|---|---| +| `height_stand_l1`, `pose_stand_l1`, `gentle_rise` | `-abs(...)`, déjà négatif | **positif** | +| `joint_torques_l2`, `joint_torque_rate_l2`, `action_rate_l2`, `body_impact_cost` | magnitude positive | **négatif** | + +Verrouillé par `test_already_negative_penalties_use_positive_weights`. + +⚠️ **Le `standup` du marcheur a exactement le même bug** (même fonction, même poids −0.02). +Ça explique la série de tentatives d'amortissement infructueuses documentées dans ses +commentaires (« *violent / shaky / overshoot-tip-repeat on the real robot* ») : elles +combattaient un terme qui poussait activement dans l'autre sens. **Non corrigé ici** — c'est +un autre env, à trancher séparément. + +**Problème structurel associé.** À convergence les récompenses de tâche totalisaient **≈ +41.6** +saturées à 95–99 %, contre **≈ −1.2** pour tous les amortisseurs réunis — dont +`joint_torque_rate_l2` à **−0.0002/pas** et `joint_torques_l2` à **−0.0001/pas**, soit rien. +Rapport ~35:1 : aucune raison d'être doux. + +**État actuel des corrections :** + +| | avant | maintenant | pourquoi | +|---|---|---|---| +| `gentle_rise` | −0.02 (récompense) | **+0.02** (pénalité) | signe corrigé ; magnitude gardée PETITE exprès — `\|a_z\|` est forcément élevé pendant un retournement, un gros poids serait un bloqueur de mouvement | +| `joint_torque_rate_l2` | −2e-3 | **−0.2** | le levier SÛR : pénalise la variation de couple, pas le mouvement | +| `head_impact_penalty` | absent | **toujours absent** | essayé à −1.0, a gelé la policy — voir ci-dessous | + +### ⚠️ La pénalité d'impact tête a gelé la policy — ne pas la remettre telle quelle + +Tentative avec les valeurs de `velstand` (`body_impact_cost`, sous-arbre `neck`, −1.0, +seuil 2.0) : **la policy a convergé vers rester couchée, inerte.** Mesuré (run `d8rnko6p`) : + +| terme | avant (violent) | avec head_impact (gelé) | +|---|---|---| +| `standing_composite` | +14.32 | **+3.26** | +| `upright_sharp` | +5.76 | +1.06 | +| `head_impact_penalty` | — | **−1.01** ← plus gros terme négatif | +| `joint_torque_rate_l2` | −0.0002 | −0.255 (donc **pas** le coupable) | + +L'erreur de raisonnement : croire qu'une pénalité « ciblée » ne bride pas le mouvement. +**Faux ici — pour se relever du dos, ce robot pivote sur sa tête et ses épaules.** La tête +est le point d'appui du retournement, pas un dégât collatéral ; la pénaliser bloque le seul +mécanisme disponible, et le dos était déjà le cas qui échouait. + +**L'optimum paresseux qui rend ce gel possible** : `pose_stand_legs` restait à **+7.72 sur 8** +alors que le robot était allongé — les jambes sont à HOME en position couchée, donc cette +récompense est encaissée quasi gratuitement. C'est `height_stand_l1` (poids +30) qui doit +rendre « rester au sol » net négatif ; il ne faut pas l'affaiblir. + +**Hypothèse en cours de test** : taper la tête était un *symptôme* de la violence (le bug de +signe payait la brutalité, et une montée brutale finit sur la tête), pas un défaut séparé. +Si le slam revient maintenant que le signe est corrigé, la reprise doit être une pénalité +**gatée en hauteur** (comme `upright_sharp` l'est), qui épargne la phase de retournement au sol. + +**Leçon de méthode** : les trois corrections ont été appliquées d'un coup, donc le gel n'a pas +pu être attribué avec certitude — seul le suspect le plus probable a pu être désigné. Une +correction à la fois, à l'avenir. + +**Recalibrage si c'est encore violent** : `|Δτ|²` vaut ~0.1 à convergence, donc la +contribution de `joint_torque_rate_l2` ≈ `0.1 × |poids|`. Monter **ce** terme, **pas** +`body_ang_vel` (−0.05) ni `action_rate_l2` (rampe → −1.0) : ceux-là sont des bloqueurs de +mouvement et le `standup` documente qu'à −0.15 et −1.2 respectivement, ils **gelaient** le +relevé depuis le dos. Si au contraire le dos cesse de fonctionner, **baisser** +`joint_torque_rate_l2` en premier. + +## Hors périmètre + +Intégrer le relevé dans la policy de roulage (recette `velstand`) ; buckets de départ sur le côté ; variante rough ; pénalités d'impact tronc/tête. + +Aucune récompense ne pénalise la vitesse horizontale du tronc (`root_link_lin_vel_w[:, :2]`) : « se relever en roulant loin » est un résultat non pénalisé et qui score à plein. Décision volontaire (pas un oubli) : une récompense d'immobilité qui ne serait pas gatée en hauteur pénaliserait aussi la translation que le relevé depuis le sol exige physiquement — le mode d'échec « bloqueur de mouvement » que le `standup` documente. Candidat si le problème se confirme : une immobilité gatée en hauteur (proche de `ROLLER_STAND_Z` seulement). diff --git a/docs/superpowers/plans/2026-07-17-roller-crouch-glide.md b/docs/superpowers/plans/2026-07-17-roller-crouch-glide.md new file mode 100644 index 0000000..372f0c4 --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-roller-crouch-glide.md @@ -0,0 +1,899 @@ +# Roller Crouch-Glide Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ajouter un geste « s'accroupir en glissant puis se relever » déclenché au bouton A, sans modifier le runtime Rust, en entraînant une policy mjlab chargée dans le slot `--ground-pick`. + +**Architecture:** Nouvelle tâche mjlab entraînée sur le robot rollers, pilotée par la commande de phase `GroundPickPhaseCommand` (celle qu'envoie le slot ground-pick du runtime). Une nouvelle reward suit une cible de hauteur du tronc « en trapèze » (haut → bas → palier 1 s → haut) le long de la phase. Le même layout d'obs 61D que la policy roller → interchangeable au runtime. Export ONNX, chargé via `--ground-pick`. + +**Tech Stack:** Python, PyTorch, mjlab 1.3.0, MuJoCo, uv, ONNX. Runtime cible : `apirrone/microduck_runtime` (Rust, binaire — NON modifié). + +## Global Constraints + +- **Aucune modification du runtime Rust.** Le geste réutilise le slot `--ground-pick` existant (bouton A, one-shot). +- **Layout d'obs unifié 61D** obligatoire (`--new-cmd-obs`) : `[twist(3), head(4), body(6)]`, head/body zero-paddés. Toute nouvelle policy DOIT conserver ce layout. +- **14 joints actifs** (roues passives exclues via `SceneEntityCfg("robot", joint_names=(r"^(?!passive_).*",))`), `action.scale = 1.0`, `kp_fw = 200`. +- **Parité entraînement/déploiement (sim2real) :** au déploiement, forcer `--ground-pick-kp-ratio 1.0` (défaut 0.6), `--ground-pick-action-scale` = action_scale runtime, `--ground-pick-period 5.0`. +- **Phase encoding (imposé par le runtime) :** `command = [cos(2π·φ), sin(2π·φ), 0]`, période 4 s. Palier de glisse = 1 s → `hold_lo=0.375`, `hold_hi=0.625`. +- **Commits simples** (pas de `Co-Authored-By`). +- Lancer les tests via `uv run --with pytest pytest` (pas de dépendance pytest ajoutée au projet). +- Spec de référence : `docs/superpowers/specs/2026-07-17-roller-crouch-glide-design.md`. + +--- + +## File Structure + +| Fichier | Responsabilité | +|---|---| +| `src/mjlab_microduck/tasks/mdp.py` | **Modifier.** Ajouter 3 fonctions : `crouch_height_target` (pure), `crouch_glide_reward_from_values` (pure), `crouch_glide_height_by_phase` (wrapper env) et `forward_speed_reward`. | +| `tests/test_crouch_glide.py` | **Créer.** Tests unitaires des fonctions pures. | +| `src/mjlab_microduck/tasks/microduck_roller_crouch_env_cfg.py` | **Créer.** L'env (hybride roller + phase) + `MicroduckRollerCrouchRlCfg`. | +| `src/mjlab_microduck/tasks/__init__.py` | **Modifier.** Importer + enregistrer `Mjlab-RollerCrouch-Flat-MicroDuck`. | +| `tests/test_roller_crouch_cfg.py` | **Créer.** Smoke test : l'env se construit avec la bonne commande/rewards. | + +--- + +## Task 1: Cible de hauteur « en trapèze » (fonction pure) + +**Files:** +- Modify: `src/mjlab_microduck/tasks/mdp.py` (ajouter la fonction, après `com_height_target` vers la ligne 737) +- Test: `tests/test_crouch_glide.py` + +**Interfaces:** +- Produces: `crouch_height_target(phase: torch.Tensor, height_low: float, height_high: float, hold_lo: float = 0.375, hold_hi: float = 0.625) -> torch.Tensor` — prend la phase (B,) ∈ [0,1) et retourne la hauteur-cible (B,). + +- [ ] **Step 1: Écrire le test qui échoue** + +Créer `tests/test_crouch_glide.py` : + +```python +import math +import torch +from mjlab_microduck.tasks import mdp + + +def test_crouch_height_target_endpoints_are_high(): + # phase 0 (début) et phase ~1 (fin) → hauteur haute (debout) + phase = torch.tensor([0.0, 0.999]) + t = mdp.crouch_height_target(phase, height_low=0.075, height_high=0.11) + assert torch.allclose(t, torch.tensor([0.11, 0.11]), atol=2e-3) + + +def test_crouch_height_target_plateau_is_low(): + # tout le palier [0.375, 0.625] → hauteur basse constante + phase = torch.tensor([0.375, 0.5, 0.624]) + t = mdp.crouch_height_target(phase, height_low=0.075, height_high=0.11) + assert torch.allclose(t, torch.full((3,), 0.075), atol=1e-6) + + +def test_crouch_height_target_descent_midpoint(): + # milieu de la descente (phase = hold_lo/2 = 0.1875) → milieu des deux hauteurs + phase = torch.tensor([0.1875]) + t = mdp.crouch_height_target(phase, height_low=0.075, height_high=0.11) + assert torch.allclose(t, torch.tensor([(0.11 + 0.075) / 2]), atol=1e-6) + + +def test_crouch_height_target_rise_midpoint(): + # milieu de la remontée (phase = 0.8125) → milieu des deux hauteurs + phase = torch.tensor([0.8125]) + t = mdp.crouch_height_target(phase, height_low=0.075, height_high=0.11) + assert torch.allclose(t, torch.tensor([(0.11 + 0.075) / 2]), atol=1e-6) +``` + +- [ ] **Step 2: Lancer le test pour vérifier qu'il échoue** + +Run: `uv run --with pytest pytest tests/test_crouch_glide.py -v` +Expected: FAIL — `AttributeError: module ... has no attribute 'crouch_height_target'` + +- [ ] **Step 3: Implémenter la fonction** + +Dans `src/mjlab_microduck/tasks/mdp.py`, juste après `com_height_target` (après la ligne 737) : + +```python +def crouch_height_target( + phase: torch.Tensor, + height_low: float, + height_high: float, + hold_lo: float = 0.375, + hold_hi: float = 0.625, +) -> torch.Tensor: + """Cible de hauteur du tronc « en trapèze » le long de la phase [0,1). + + phase ∈ [0, hold_lo) : descente height_high -> height_low + phase ∈ [hold_lo, hold_hi): palier height_low (la glisse accroupie) + phase ∈ [hold_hi, 1.0) : remontée height_low -> height_high + + Args: + phase: (B,) phase par env, dans [0, 1). + height_low: hauteur du tronc accroupi (m). + height_high: hauteur du tronc debout (m). + hold_lo, hold_hi: bornes du palier bas en fraction de phase. + Returns: + (B,) hauteur-cible en mètres. + """ + descend = phase < hold_lo + hold = (phase >= hold_lo) & (phase < hold_hi) + + frac_d = phase / hold_lo + t_descend = height_high + (height_low - height_high) * frac_d + + t_hold = torch.full_like(phase, height_low) + + frac_r = (phase - hold_hi) / (1.0 - hold_hi) + t_rise = height_low + (height_high - height_low) * frac_r + + return torch.where(descend, t_descend, torch.where(hold, t_hold, t_rise)) +``` + +- [ ] **Step 4: Lancer le test pour vérifier qu'il passe** + +Run: `uv run --with pytest pytest tests/test_crouch_glide.py -v` +Expected: PASS (4 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/mjlab_microduck/tasks/mdp.py tests/test_crouch_glide.py +git commit -m "roller-crouch: cible de hauteur en trapezoide (fonction pure + tests)" +``` + +--- + +## Task 2: Rewards crouch-glide et forward-speed + +**Files:** +- Modify: `src/mjlab_microduck/tasks/mdp.py` +- Test: `tests/test_crouch_glide.py` (ajouts) + +**Interfaces:** +- Consumes: `crouch_height_target` (Task 1). +- Produces: + - `crouch_glide_reward_from_values(com_height, cmd_cos, cmd_sin, height_low, height_high, hold_lo=0.375, hold_hi=0.625, std=0.02) -> torch.Tensor` (pure). + - `crouch_glide_height_by_phase(env, command_name="twist", height_low=0.075, height_high=0.11, hold_lo=0.375, hold_hi=0.625, std=0.02, asset_cfg=_DEFAULT_ASSET_CFG) -> torch.Tensor` (wrapper env). + - `forward_speed_reward(env, vel_ref=0.2, asset_cfg=_DEFAULT_ASSET_CFG) -> torch.Tensor` — récompense la vitesse avant (élan), indépendante de la commande. + +- [ ] **Step 1: Écrire les tests qui échouent** + +Ajouter à `tests/test_crouch_glide.py` : + +```python +def test_reward_is_one_when_height_matches_target(): + # phase 0.5 (plein palier) → cible = height_low ; si com_height == height_low → reward 1 + cmd_cos = torch.tensor([math.cos(2 * math.pi * 0.5)]) # -1 + cmd_sin = torch.tensor([math.sin(2 * math.pi * 0.5)]) # ~0 + com_height = torch.tensor([0.075]) + r = mdp.crouch_glide_reward_from_values( + com_height, cmd_cos, cmd_sin, height_low=0.075, height_high=0.11, std=0.02 + ) + assert torch.allclose(r, torch.tensor([1.0]), atol=1e-3) + + +def test_reward_decays_when_off_by_one_std(): + # à height_low + std de la cible → exp(-1) ≈ 0.368 + cmd_cos = torch.tensor([math.cos(2 * math.pi * 0.5)]) + cmd_sin = torch.tensor([math.sin(2 * math.pi * 0.5)]) + com_height = torch.tensor([0.075 + 0.02]) + r = mdp.crouch_glide_reward_from_values( + com_height, cmd_cos, cmd_sin, height_low=0.075, height_high=0.11, std=0.02 + ) + assert torch.allclose(r, torch.tensor([math.exp(-1.0)]), atol=1e-3) + + +def test_reward_at_phase_zero_expects_high_stance(): + # phase 0 → cible = height_high ; rester debout est récompensé, être accroupi non + cmd_cos = torch.tensor([1.0, 1.0]) # cos(0) + cmd_sin = torch.tensor([0.0, 0.0]) # sin(0) + com_height = torch.tensor([0.11, 0.075]) # debout vs accroupi + r = mdp.crouch_glide_reward_from_values( + com_height, cmd_cos, cmd_sin, height_low=0.075, height_high=0.11, std=0.02 + ) + assert r[0] > 0.99 # debout à phase 0 → ~1 + assert r[1] < 0.2 # accroupi à phase 0 → faible +``` + +- [ ] **Step 2: Vérifier l'échec** + +Run: `uv run --with pytest pytest tests/test_crouch_glide.py -v` +Expected: FAIL — `crouch_glide_reward_from_values` n'existe pas. + +- [ ] **Step 3: Implémenter les trois fonctions** + +Dans `src/mjlab_microduck/tasks/mdp.py`, à la suite de `crouch_height_target` : + +```python +def crouch_glide_reward_from_values( + com_height: torch.Tensor, + cmd_cos: torch.Tensor, + cmd_sin: torch.Tensor, + height_low: float, + height_high: float, + hold_lo: float = 0.375, + hold_hi: float = 0.625, + std: float = 0.02, +) -> torch.Tensor: + """Récompense gaussienne du suivi de la cible de hauteur (fonction pure). + + Décode la phase depuis [cos, sin] puis compare la hauteur mesurée à la + cible-trapèze. Retourne exp(-((h - cible)/std)^2) ∈ (0, 1]. + """ + phase = (torch.atan2(cmd_sin, cmd_cos) / (2 * torch.pi)) % 1.0 + target = crouch_height_target(phase, height_low, height_high, hold_lo, hold_hi) + return torch.exp(-((com_height - target) / std) ** 2) + + +def crouch_glide_height_by_phase( + env: ManagerBasedRlEnv, + command_name: str = "twist", + height_low: float = 0.075, + height_high: float = 0.11, + hold_lo: float = 0.375, + hold_hi: float = 0.625, + std: float = 0.02, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """Reward principale : suit la cible de hauteur du tronc le long de la phase. + + La hauteur du CoM est calculée comme dans `com_height_target` (world z moins + l'origine du terrain, nan->0). La phase provient de la commande GroundPick. + """ + asset: Entity = env.scene[asset_cfg.name] + com_height = torch.nan_to_num( + asset.data.root_link_pos_w[:, 2] - env.scene.terrain.env_origins[:, 2], nan=0.0 + ) + cmd = env.command_manager.get_command(command_name) + return crouch_glide_reward_from_values( + com_height, cmd[:, 0], cmd[:, 1], + height_low, height_high, hold_lo, hold_hi, std, + ) + + +def forward_speed_reward( + env: ManagerBasedRlEnv, + vel_ref: float = 0.2, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """Récompense la vitesse avant du tronc (conserver l'élan / ne pas freiner). + + Indépendante de la commande (la commande porte la phase, pas la vitesse). + tanh(clamp(vx, 0)/vel_ref) → sature à ~1, ne récompense jamais reculer. + """ + asset: Entity = env.scene[asset_cfg.name] + vx = asset.data.root_link_lin_vel_b[:, 0] + return torch.tanh(torch.clamp(vx, min=0.0) / vel_ref) +``` + +- [ ] **Step 4: Vérifier le passage** + +Run: `uv run --with pytest pytest tests/test_crouch_glide.py -v` +Expected: PASS (7 tests au total) + +- [ ] **Step 5: Commit** + +```bash +git add src/mjlab_microduck/tasks/mdp.py tests/test_crouch_glide.py +git commit -m "roller-crouch: rewards crouch-glide-height et forward-speed" +``` + +--- + +## Task 3: L'environnement + enregistrement de la tâche + +**Files:** +- Create: `src/mjlab_microduck/tasks/microduck_roller_crouch_env_cfg.py` +- Modify: `src/mjlab_microduck/tasks/__init__.py` +- Test: `tests/test_roller_crouch_cfg.py` + +**Interfaces:** +- Consumes: `crouch_glide_height_by_phase`, `forward_speed_reward`, `ground_pick_return_pose` (Task 2 + existant), `GroundPickPhaseCommandCfg`, `GroundPickPhaseCommand`, `MICRODUCK_WALK_ROLLERS_ROBOT_CFG`. +- Produces: `make_microduck_roller_crouch_env_cfg(play: bool = False) -> ManagerBasedRlEnvCfg`, `MicroduckRollerCrouchRlCfg`, tâche `Mjlab-RollerCrouch-Flat-MicroDuck`. + +- [ ] **Step 1: Écrire le smoke test qui échoue** + +Créer `tests/test_roller_crouch_cfg.py` : + +```python +from mjlab_microduck.tasks.microduck_roller_crouch_env_cfg import ( + make_microduck_roller_crouch_env_cfg, +) +from mjlab_microduck.tasks import mdp as microduck_mdp + + +def test_cfg_uses_phase_command(): + cfg = make_microduck_roller_crouch_env_cfg() + assert isinstance( + cfg.commands["twist"], microduck_mdp.GroundPickPhaseCommandCfg + ) + assert cfg.commands["twist"].period == 4.0 + + +def test_cfg_has_crouch_and_forward_rewards(): + cfg = make_microduck_roller_crouch_env_cfg() + assert "crouch_glide_height" in cfg.rewards + assert "forward_speed" in cfg.rewards + # rewards de patinage actif retirées (pas de stride pendant le trick) + for gone in ("braking", "skating_air_time", "single_support", "glide", "wheel_speed"): + assert gone not in cfg.rewards + + +def test_cfg_has_entry_velocity_event(): + cfg = make_microduck_roller_crouch_env_cfg() + assert "entry_velocity" in cfg.events +``` + +- [ ] **Step 2: Vérifier l'échec** + +Run: `uv run --with pytest pytest tests/test_roller_crouch_cfg.py -v` +Expected: FAIL — `ModuleNotFoundError: ...microduck_roller_crouch_env_cfg` + +- [ ] **Step 3: Créer le fichier d'environnement** + +Créer `src/mjlab_microduck/tasks/microduck_roller_crouch_env_cfg.py` : + +```python +"""Microduck roller crouch-glide task. + +Geste one-shot déclenché au bouton A via le slot --ground-pick du runtime : +le robot s'accroupit et glisse sur son élan (palier ~1 s), puis se relève et +rend la main à la policy roller. + +Hybride : + - physique / robot roller ← microduck_velocity_rollers_env_cfg.py + - machinerie phase one-shot ← microduck_ground_pick_env_cfg.py + (commande GroundPickPhaseCommand : [cos(2πφ), sin(2πφ), 0], période 4 s) + +Cible de hauteur « en trapèze » (haut→bas→palier 1 s→haut) via +crouch_glide_height_by_phase. Obs 61D unifié → interchangeable au runtime. +""" + +import math +from copy import deepcopy + +ENABLE_SYMMETRY = False + +# DR — repris du roller env +ENABLE_COM_RANDOMIZATION = True +ENABLE_HEAD_COM_RANDOMIZATION = True +ENABLE_MASS_INERTIA_RANDOMIZATION = True +ENABLE_JOINT_FRICTION_RANDOMIZATION = True +ENABLE_ARMATURE_RANDOMIZATION = True +ENABLE_WHEEL_FRICTION_RANDOMIZATION = True +ENABLE_VELOCITY_PUSHES = True +ENABLE_IMU_ORIENTATION_RANDOMIZATION = True +ENABLE_ENCODER_BIAS = True + +COM_RANDOMIZATION_RANGE = 0.003 +HEAD_COM_RANDOMIZATION_RANGE = 0.003 +MASS_INERTIA_RANDOMIZATION_RANGE = (0.95, 1.05) +JOINT_FRICTION_RANDOMIZATION_RANGE = (0.9, 1.1) +ARMATURE_RANDOMIZATION_RANGE = (0.9, 1.1) +VELOCITY_PUSH_INTERVAL_S = (3.0, 6.0) +VELOCITY_PUSH_RANGE = (-0.2, 0.2) +IMU_ORIENTATION_RANDOMIZATION_ANGLE = 6.0 +ENCODER_BIAS_RANGE = (-0.015, 0.015) + +# Geste : hauteurs cibles (m) et vitesse d'entrée (élan) +CROUCH_HEIGHT_HIGH = 0.11 # tronc debout +CROUCH_HEIGHT_LOW = 0.075 # tronc accroupi (à affiner en play) +CROUCH_STD = 0.02 +ENTRY_VELOCITY_X = (0.2, 0.5) # m/s : le robot arrive en roulant + +from mjlab.envs import ManagerBasedRlEnvCfg +from mjlab.envs.mdp import dr +from mjlab.envs.mdp.actions import JointPositionActionCfg +from mjlab.managers import ( + CurriculumTermCfg, + EventTermCfg, + ObservationTermCfg, + RewardTermCfg, + TerminationTermCfg, +) +from mjlab.managers.scene_entity_config import SceneEntityCfg +from mjlab.rl import RslRlOnPolicyRunnerCfg, RslRlModelCfg +from mjlab.sensor import ContactMatch, ContactSensorCfg +from mjlab.tasks.velocity import mdp +from mjlab.tasks.velocity.mdp import UniformVelocityCommandCfg +from mjlab.tasks.velocity.velocity_env_cfg import make_velocity_env_cfg +from mjlab.utils.noise import UniformNoiseCfg as Unoise + +from mjlab_microduck.robot.microduck_constants import MICRODUCK_WALK_ROLLERS_ROBOT_CFG +from mjlab_microduck.tasks import mdp as microduck_mdp +from mjlab_microduck.tasks.microduck_velocity_env_cfg import HEAD_BODY_NAMES +from mjlab_microduck.tasks.symmetry import PpoWithSymmetryCfg, SYMMETRY_CFG + + +def make_microduck_roller_crouch_env_cfg(play: bool = False) -> ManagerBasedRlEnvCfg: + """Env crouch-glide sur rollers, piloté par la phase du slot ground-pick.""" + + feet_ground_cfg = ContactSensorCfg( + name="feet_ground_contact", + primary=ContactMatch( + mode="subtree", + pattern=r"^(roller_blade|roller_blade_2)$", + entity="robot", + ), + secondary=ContactMatch(mode="body", pattern="terrain"), + fields=("found", "force"), + reduce="netforce", + num_slots=1, + track_air_time=True, + ) + self_collision_cfg = ContactSensorCfg( + name="self_collision", + primary=ContactMatch(mode="subtree", pattern="trunk_base", entity="robot"), + secondary=ContactMatch(mode="subtree", pattern="trunk_base", entity="robot"), + fields=("found",), + reduce="none", + num_slots=1, + ) + + cfg = make_velocity_env_cfg() + cfg.scene.entities = {"robot": MICRODUCK_WALK_ROLLERS_ROBOT_CFG} + cfg.scene.sensors = (feet_ground_cfg, self_collision_cfg) + cfg.viewer.body_name = "trunk_base" + + joint_pos_action = cfg.actions["joint_pos"] + assert isinstance(joint_pos_action, JointPositionActionCfg) + joint_pos_action.scale = 1.0 + + # === REWARDS === + keep = {"upright", "body_ang_vel", "angular_momentum", "action_rate_l2"} + for name in list(cfg.rewards.keys()): + if name not in keep: + del cfg.rewards[name] + + cfg.rewards["upright"].params["asset_cfg"].body_names = ("trunk_base",) + cfg.rewards["upright"].weight = 2.0 + cfg.rewards["body_ang_vel"].params["asset_cfg"].body_names = ("trunk_base",) + cfg.rewards["body_ang_vel"].weight = -0.05 + cfg.rewards["angular_momentum"].weight = -0.02 + cfg.rewards["action_rate_l2"].weight = -1.0 + + # Reward principale : cible de hauteur trapèze le long de la phase + cfg.rewards["crouch_glide_height"] = RewardTermCfg( + func=microduck_mdp.crouch_glide_height_by_phase, + weight=4.0, + params={ + "command_name": "twist", + "height_low": CROUCH_HEIGHT_LOW, + "height_high": CROUCH_HEIGHT_HIGH, + "hold_lo": 0.375, + "hold_hi": 0.625, + "std": CROUCH_STD, + }, + ) + # Conserver l'élan (ne pas freiner) — indépendant de la commande + cfg.rewards["forward_speed"] = RewardTermCfg( + func=microduck_mdp.forward_speed_reward, + weight=2.0, + params={"vel_ref": 0.2}, + ) + # Fin de phase : converger vers la pose roller debout pour rendre la main proprement + _LEG_JOINTS = [0, 1, 2, 3, 4, 9, 10, 11, 12, 13] + _NECK_JOINTS = [5, 6, 7, 8] + cfg.rewards["return_pose_legs"] = RewardTermCfg( + func=microduck_mdp.ground_pick_return_pose, + weight=3.0, + params={"std": 0.3, "command_name": "twist", "joint_indices": _LEG_JOINTS}, + ) + cfg.rewards["return_pose_neck"] = RewardTermCfg( + func=microduck_mdp.ground_pick_return_pose, + weight=3.0, + params={"std": 0.15, "command_name": "twist", "joint_indices": _NECK_JOINTS}, + ) + # Stabilité de glisse + cfg.rewards["feet_flat"] = RewardTermCfg( + func=microduck_mdp.feet_flat_penalty, + weight=-2.0, + params={ + "asset_cfg": SceneEntityCfg("robot", site_names=("left_foot", "right_foot")), + "sensor_name": "feet_ground_contact", + }, + ) + cfg.rewards["self_collisions"] = RewardTermCfg( + func=mdp.self_collision_cost, + weight=-1.0, + params={"sensor_name": "self_collision"}, + ) + cfg.rewards["neck_action_rate_l2"] = RewardTermCfg( + func=microduck_mdp.neck_action_rate_l2, weight=-0.5 + ) + cfg.rewards["joint_torques_l2"] = RewardTermCfg( + func=microduck_mdp.joint_torques_l2, weight=-1e-3 + ) + + # === TERMINATIONS === + cfg.terminations["nan_state"] = TerminationTermCfg( + func=microduck_mdp.robot_state_is_nan, time_out=False, + ) + + # === EVENTS === + cfg.events["reset_action_history"] = EventTermCfg( + func=microduck_mdp.reset_action_history, mode="reset", + ) + del cfg.events["foot_friction"] + + # Vitesse d'entrée : le robot démarre en roulant vers l'avant (élan à conserver) + cfg.events["entry_velocity"] = EventTermCfg( + func=mdp.push_by_setting_velocity, + mode="reset", + params={ + "velocity_range": {"x": ENTRY_VELOCITY_X, "y": (0.0, 0.0)}, + "asset_cfg": SceneEntityCfg("robot"), + }, + ) + + if ENABLE_VELOCITY_PUSHES: + cfg.events["push_robot"] = EventTermCfg( + func=mdp.push_by_setting_velocity, + mode="interval", + interval_range_s=VELOCITY_PUSH_INTERVAL_S, + params={ + "velocity_range": {"x": VELOCITY_PUSH_RANGE, "y": VELOCITY_PUSH_RANGE}, + "asset_cfg": SceneEntityCfg("robot"), + }, + ) + + cfg.events["reset_base"].params["pose_range"]["z"] = (0.1335, 0.1435) + + if ENABLE_WHEEL_FRICTION_RANDOMIZATION: + cfg.events["randomize_wheel_friction"] = EventTermCfg( + func=dr.dof_frictionloss, + mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot", joint_names=(r"^passive_.*",)), + "operation": "abs", + "ranges": (0.000, 0.000), + }, + ) + if ENABLE_COM_RANDOMIZATION: + cfg.events["randomize_com"] = EventTermCfg( + func=dr.body_ipos, mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",)), + "operation": "add", + "ranges": (-COM_RANDOMIZATION_RANGE, COM_RANDOMIZATION_RANGE), + }, + ) + if ENABLE_HEAD_COM_RANDOMIZATION: + cfg.events["randomize_head_com"] = EventTermCfg( + func=dr.body_ipos, mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot", body_names=HEAD_BODY_NAMES), + "operation": "add", + "ranges": (-HEAD_COM_RANDOMIZATION_RANGE, HEAD_COM_RANDOMIZATION_RANGE), + }, + ) + if ENABLE_MASS_INERTIA_RANDOMIZATION: + _mi_lo, _mi_hi = MASS_INERTIA_RANDOMIZATION_RANGE + cfg.events["randomize_mass_inertia"] = EventTermCfg( + func=dr.pseudo_inertia, mode="startup", + params={ + "asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",)), + "alpha_range": (math.log(_mi_lo) / 2.0, math.log(_mi_hi) / 2.0), + }, + ) + if ENABLE_JOINT_FRICTION_RANDOMIZATION: + cfg.events["randomize_joint_friction"] = EventTermCfg( + func=microduck_mdp.randomize_bam_friction, mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot"), + "scale_range": JOINT_FRICTION_RANDOMIZATION_RANGE, + }, + ) + if ENABLE_ARMATURE_RANDOMIZATION: + cfg.events["randomize_armature"] = EventTermCfg( + func=dr.joint_armature, mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot", joint_names=(r"^(?!passive_).*",)), + "operation": "scale", + "ranges": ARMATURE_RANDOMIZATION_RANGE, + }, + ) + + # === OBSERVATIONS (unified 61D layout) === + del cfg.observations["actor"].terms["base_lin_vel"] + del cfg.observations["critic"].terms["foot_height"] + del cfg.observations["actor"].terms["height_scan"] + del cfg.observations["critic"].terms["height_scan"] + cfg.observations["critic"].terms["base_lin_vel"] = ObservationTermCfg( + func=mdp.base_lin_vel, scale=1.0, + ) + + gravity_term_name = "projected_gravity" + cfg.observations["actor"].terms[gravity_term_name] = deepcopy( + cfg.observations["actor"].terms[gravity_term_name] + ) + cfg.observations["actor"].terms["base_ang_vel"] = deepcopy( + cfg.observations["actor"].terms["base_ang_vel"] + ) + cfg.observations["actor"].terms["base_ang_vel"].delay_min_lag = 0 + cfg.observations["actor"].terms["base_ang_vel"].delay_max_lag = 1 + cfg.observations["actor"].terms["base_ang_vel"].delay_update_period = 64 + cfg.observations["actor"].terms[gravity_term_name].delay_min_lag = 0 + cfg.observations["actor"].terms[gravity_term_name].delay_max_lag = 1 + cfg.observations["actor"].terms[gravity_term_name].delay_update_period = 64 + cfg.observations["actor"].terms["base_ang_vel"].noise = Unoise(n_min=-0.03, n_max=0.03) + cfg.observations["actor"].terms[gravity_term_name].noise = Unoise(n_min=-0.01, n_max=0.01) + cfg.observations["actor"].terms["joint_pos"].noise = Unoise(n_min=-0.001, n_max=0.001) + cfg.observations["actor"].terms["joint_vel"].noise = Unoise(n_min=-0.25, n_max=0.25) + + if ENABLE_IMU_ORIENTATION_RANDOMIZATION: + av = cfg.observations["actor"].terms["base_ang_vel"] + av.func = microduck_mdp.base_ang_vel_imu_misaligned + av.params = {"max_angle_deg": IMU_ORIENTATION_RANDOMIZATION_ANGLE} + g = cfg.observations["actor"].terms[gravity_term_name] + g.func = microduck_mdp.projected_gravity_imu_misaligned + g.params = {"max_angle_deg": IMU_ORIENTATION_RANDOMIZATION_ANGLE} + + cfg.observations["actor"].terms["joint_vel"] = deepcopy( + cfg.observations["actor"].terms["joint_vel"] + ) + cfg.observations["actor"].terms["joint_vel"].delay_min_lag = 1 + cfg.observations["actor"].terms["joint_vel"].delay_max_lag = 1 + cfg.observations["actor"].terms["joint_vel"].delay_update_period = 0 + + passive_excluded = SceneEntityCfg("robot", joint_names=(r"^(?!passive_).*",)) + for grp in ("actor", "critic"): + for term in ("joint_pos", "joint_vel"): + cfg.observations[grp].terms[term] = deepcopy(cfg.observations[grp].terms[term]) + cfg.observations[grp].terms[term].params["asset_cfg"] = deepcopy(passive_excluded) + + if ENABLE_ENCODER_BIAS: + cfg.events["encoder_bias"].params["bias_range"] = ENCODER_BIAS_RANGE + cfg.observations["actor"].terms["joint_pos"].params["biased"] = True + cfg.observations["critic"].terms["joint_pos"].params["biased"] = False + else: + cfg.events.pop("encoder_bias", None) + + wheel_cfg = SceneEntityCfg("robot", joint_names=(r"^passive_.*",)) + cfg.observations["critic"].terms["wheel_vel"] = ObservationTermCfg( + func=mdp.joint_vel_rel, scale=1.0, params={"asset_cfg": wheel_cfg}, + ) + + for group in ("actor", "critic"): + cfg.observations[group].terms["head_command"] = ObservationTermCfg( + func=microduck_mdp.zero_command_padding, params={"dim": 4}, + ) + cfg.observations[group].terms["body_command"] = ObservationTermCfg( + func=microduck_mdp.zero_command_padding, params={"dim": 6}, + ) + + # === COMMAND: phase (comme ground_pick) === + command: UniformVelocityCommandCfg = cfg.commands["twist"] + command.rel_standing_envs = 0.0 + command.rel_heading_envs = 0.0 + cfg.commands["twist"] = microduck_mdp.GroundPickPhaseCommandCfg( + **{**vars(command), "class_type": microduck_mdp.GroundPickPhaseCommand, "period": 4.0} + ) + + cfg.scene.terrain.terrain_type = "plane" + cfg.scene.terrain.terrain_generator = None + + # === CURRICULUM === + del cfg.curriculum["terrain_levels"] + del cfg.curriculum["command_vel"] + cfg.curriculum["action_rate_weight"] = CurriculumTermCfg( + func=microduck_mdp.reward_weight, + params={ + "reward_name": "action_rate_l2", + "weight_stages": [ + {"step": 0, "weight": -0.5}, + {"step": 250 * 24, "weight": -0.8}, + {"step": 500 * 24, "weight": -1.0}, + ], + }, + ) + if ENABLE_COM_RANDOMIZATION: + cfg.curriculum["com_range"] = CurriculumTermCfg( + func=microduck_mdp.com_range_curriculum, + params={ + "event_name": "randomize_com", + "range_stages": [ + {"step": 0, "range": 0.003}, + {"step": 500 * 24, "range": 0.005}, + {"step": 1000 * 24, "range": 0.01}, + ], + }, + ) + if ENABLE_HEAD_COM_RANDOMIZATION: + cfg.curriculum["head_com_range"] = CurriculumTermCfg( + func=microduck_mdp.com_range_curriculum, + params={ + "event_name": "randomize_head_com", + "range_stages": [ + {"step": 0, "range": 0.003}, + {"step": 500 * 24, "range": 0.005}, + {"step": 1000 * 24, "range": 0.01}, + ], + }, + ) + + return cfg + + +MicroduckRollerCrouchRlCfg = RslRlOnPolicyRunnerCfg( + actor=RslRlModelCfg( + hidden_dims=(512, 256, 128), + activation="elu", + obs_normalization=True, + distribution_cfg={ + "class_name": "GaussianDistribution", + "init_std": 1.0, + "std_type": "scalar", + }, + ), + critic=RslRlModelCfg( + hidden_dims=(512, 256, 128), + activation="elu", + obs_normalization=True, + ), + algorithm=PpoWithSymmetryCfg( + value_loss_coef=1.0, + use_clipped_value_loss=True, + clip_param=0.2, + entropy_coef=0.01, + num_learning_epochs=5, + num_mini_batches=4, + learning_rate=1.0e-3, + schedule="adaptive", + gamma=0.99, + lam=0.95, + desired_kl=0.01, + max_grad_norm=1.0, + symmetry_cfg=SYMMETRY_CFG if ENABLE_SYMMETRY else None, + ), + wandb_project="mjlab_microduck", + experiment_name="roller_crouch", + run_name="roller_crouch", + save_interval=250, + num_steps_per_env=24, + max_iterations=8_000, +) +``` + +- [ ] **Step 4: Enregistrer la tâche** + +Dans `src/mjlab_microduck/tasks/__init__.py`, ajouter l'import après le bloc rollers (après la ligne 54) : + +```python +from .microduck_roller_crouch_env_cfg import ( + make_microduck_roller_crouch_env_cfg, + MicroduckRollerCrouchRlCfg, +) +``` + +et l'enregistrement après le bloc rollers (après la ligne 175) : + +```python +register_mjlab_task( + task_id="Mjlab-RollerCrouch-Flat-MicroDuck", + env_cfg=make_microduck_roller_crouch_env_cfg(), + play_env_cfg=make_microduck_roller_crouch_env_cfg(play=True), + rl_cfg=MicroduckRollerCrouchRlCfg, + runner_cls=MicroduckOnPolicyRunner, +) +print("✓ RollerCrouch task registered: Mjlab-RollerCrouch-Flat-MicroDuck") +``` + +- [ ] **Step 5: Vérifier le passage du smoke test** + +Run: `uv run --with pytest pytest tests/test_roller_crouch_cfg.py -v` +Expected: PASS (3 tests). (Ce test construit l'env — il compile le spec MuJoCo, donc il est plus lent ; c'est normal.) + +- [ ] **Step 6: Vérifier que la tâche est bien enregistrée** + +Run: `uv run python -c "import mjlab_microduck.tasks"` +Expected: la ligne `✓ RollerCrouch task registered: Mjlab-RollerCrouch-Flat-MicroDuck` s'affiche sans erreur. + +- [ ] **Step 7: Commit** + +```bash +git add src/mjlab_microduck/tasks/microduck_roller_crouch_env_cfg.py \ + src/mjlab_microduck/tasks/__init__.py tests/test_roller_crouch_cfg.py +git commit -m "roller-crouch: env crouch-glide + enregistrement de la tache" +``` + +--- + +## Task 4: Smoke run d'entraînement (vérification runtime) + +**Files:** aucun (vérification observationnelle). + +**Interfaces:** +- Consumes: la tâche `Mjlab-RollerCrouch-Flat-MicroDuck` (Task 3). + +- [ ] **Step 1: Lancer un entraînement très court** + +Run: +```bash +uv run train Mjlab-RollerCrouch-Flat-MicroDuck \ + --env.scene.num-envs 64 --agent.max_iterations 5 +``` +Expected: l'entraînement démarre, log les rewards (dont `crouch_glide_height`, `forward_speed`), 5 itérations sans crash, un checkpoint est écrit. + +- [ ] **Step 2: Vérifier l'absence d'erreur de forme d'obs** + +Inspecter le log de démarrage : l'obs actor doit être **61D** (comme les autres policies de la famille). Si la dim diffère, le padding head/body ou l'exclusion des roues est mal câblé — corriger avant de continuer. + +- [ ] **Step 3: Commit (si un fichier de conf a dû être ajusté)** + +```bash +git add -A && git commit -m "roller-crouch: ajustement post smoke-run" +``` +(S'il n'y a rien à committer, sauter cette étape.) + +--- + +## Task 5: Entraînement complet + vérification en play + +**Files:** itérations possibles sur `microduck_roller_crouch_env_cfg.py` (poids de reward, `CROUCH_HEIGHT_LOW`). + +- [ ] **Step 1: Lancer l'entraînement complet** + +Run: +```bash +uv run train Mjlab-RollerCrouch-Flat-MicroDuck \ + --env.scene.num-envs 4096 --agent.max_iterations 8000 +``` + +- [ ] **Step 2: Visualiser en play** + +Run: `uv run scripts/play_latest.py` (ou l'entrée play du projet pour cette tâche). +Observer le cycle : le robot **descend**, **glisse ~1 s** avec les roues qui continuent de tourner (il ne freine pas), puis **se relève** et la pose finale rejoint la pose roller debout. Il ne doit pas tomber. + +- [ ] **Step 3: Itérer si nécessaire** + +Réglages typiques (dans `microduck_roller_crouch_env_cfg.py`) : +- Il ne descend pas assez → baisser `CROUCH_HEIGHT_LOW` (ex. 0.07) et/ou monter le poids de `crouch_glide_height`. +- Il freine pendant l'accroupi → monter le poids de `forward_speed`. +- Il tombe en position basse → monter `upright`, baisser la vitesse d'entrée `ENTRY_VELOCITY_X`, ou raccourcir le palier (rapprocher `hold_lo`/`hold_hi`). +- La remontée est brutale → monter `return_pose_*` et/ou `action_rate_l2`. + +Après chaque changement, relancer un entraînement et re-visualiser. Committer chaque réglage retenu : +```bash +git add src/mjlab_microduck/tasks/microduck_roller_crouch_env_cfg.py +git commit -m "roller-crouch: reglage " +``` + +--- + +## Task 6: Export ONNX + déploiement sur le robot + +**Files:** aucun (manuel / matériel). + +- [ ] **Step 1: Exporter la policy en ONNX** + +Run: `uv run scripts/export_latest.py` (le normaliseur d'obs est baké dans le graphe par `scripts/export.py`). +Récupérer le fichier `.onnx`, le renommer `roller_crouch.onnx`, le copier sur le robot (ex. `~/microduck/policies/roller_crouch.onnx`). + +- [ ] **Step 2: Lancer le runtime avec le slot ground-pick** + +Sur le robot : +```bash +microduck_runtime --variant pre-alpha --new-cmd-obs --roller \ + --model output.onnx \ + --new-dxl-imu --kp 200 --action-scale 0.8 \ + --max-linear-vel 0.6 --max-linear-vel-backward 0.5 --max-angular-vel 0.0 \ + --ground-pick ~/microduck/policies/roller_crouch.onnx \ + --ground-pick-period 5.0 \ + --ground-pick-kp-ratio 1.0 \ + --ground-pick-action-scale 0.8 +``` + +**Paramètres critiques (parité sim2real) :** +- `--ground-pick-kp-ratio 1.0` — le défaut 0.6 baisserait kp à 120 alors qu'on entraîne à 200. +- `--ground-pick-action-scale 0.8` — doit matcher l'`action_scale` d'entraînement. +- `--ground-pick-period 5.0` — doit matcher la période entraînée. + +- [ ] **Step 3: Tester le geste** + +Lancer le robot à petite vitesse en avant, appuyer sur **A**. Vérifier : il s'accroupit, glisse ~1 s, se relève, et la policy roller reprend la main proprement. Si instable, revenir à la Task 5 (itérer sur les poids / la hauteur / la vitesse d'entrée). + +--- + +## Notes de vérification (self-review) + +- **Couverture spec :** cible trapèze 1 s (Task 1) ; rewards crouch + anti-freinage + return-pose (Task 2/3) ; robot rollers + phase + obs 61D + DR (Task 3) ; vitesse d'entrée (Task 3, event `entry_velocity`) ; flags de déploiement dont le piège `kp-ratio` (Task 6). ✅ +- **Piège phase vs vitesse :** `wheel_speed_reward`/`braking`/`coasting_reward` du roller env utilisent `command[:,0]` comme *vitesse* — invalide ici où `command[:,0]=cos(2πφ)`. Elles sont donc **retirées** et remplacées par `forward_speed_reward` (indépendante de la commande). Testé par `test_cfg_has_crouch_and_forward_rewards`. +- **Cohérence des noms :** `crouch_glide_height` (clé reward) vs `crouch_glide_height_by_phase` (fonction) — voulu : la clé est le nom du terme, la fonction est `func=`. +``` diff --git a/docs/superpowers/plans/2026-07-22-roller-slope.md b/docs/superpowers/plans/2026-07-22-roller-slope.md new file mode 100644 index 0000000..2862c70 --- /dev/null +++ b/docs/superpowers/plans/2026-07-22-roller-slope.md @@ -0,0 +1,671 @@ +# Mode pente `roller_slope` — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Entraîner une politique dédiée où microduck (rollers) démarre sur du plat avec une impulsion, roule sur une rampe descendante, et se laisse glisser jusqu'en bas en restant debout — sans aucun pilotage. + +**Architecture:** Nouvelle tâche isolée clonée de `velocity_rollers` (même robot, même obs 61D → interchangeable au runtime). Terrain custom « plat + rampe » à angle interpolé par difficulté, curriculum de raideur maison, commande neutralisée, récompenses d'équilibre + posture debout nominale. Bouton `Y` de bascule dans `infer_policy.py`. + +**Tech Stack:** Python, mjlab 1.3.x, MuJoCo (MjSpec terrains), rsl_rl (PPO), PyTorch, onnxruntime (déploiement), pytest. + +## Global Constraints + +- **Observation unifiée 61D** : twist (3D) + head_command (4D) + body_command (6D) en zéro-padding. Ne jamais changer ce layout — la politique doit charger via `--new-cmd-obs`. +- **Résolution des joints par NOM**, jamais par index (roues passives intercalées). +- **Vitesse d'entrée via `reset_root_state_uniform` (velocity_range)**, JAMAIS via `push_by_setting_velocity` en mode reset (accumule sur l'état racine → free-joint diverge → NaN). Leçon `roller_crouch`. +- **Angles en radians** dans le code physique ; les constantes de raideur sont exprimées en degrés (`RAMP_DEG_MIN=2.0`, `RAMP_DEG_MAX=20.0`) et converties. +- **Commits simples**, style du dépôt (pas de `Co-authored-by`). +- Tests dans `tests/`, lancés avec `uv run pytest`. + +--- + +## File Structure + +- **Create** `src/mjlab_microduck/tasks/slope_terrain.py` — `ramp_angle_by_difficulty()` + `FlatRampTerrainCfg` (géométrie du terrain plat+rampe). Responsabilité unique : le terrain. +- **Modify** `src/mjlab_microduck/tasks/mdp.py` — ajouter `slope_move_masks()` (pur) + `terrain_levels_slope()` (curriculum de raideur). +- **Create** `src/mjlab_microduck/tasks/microduck_roller_slope_env_cfg.py` — `make_microduck_roller_slope_env_cfg()` + `MicroduckRollerSlopeRlCfg`. +- **Modify** `src/mjlab_microduck/tasks/__init__.py` — enregistrer la tâche. +- **Modify** `scripts/infer_policy.py` — flag `--slope` + touche `Y`. +- **Create** `tests/test_slope_terrain.py`, `tests/test_slope_curriculum.py`, `tests/test_roller_slope_cfg.py`. + +--- + +## Task 1 : angle de rampe par difficulté (fonction pure) + +**Files:** +- Create: `src/mjlab_microduck/tasks/slope_terrain.py` +- Test: `tests/test_slope_terrain.py` + +**Interfaces:** +- Produces: `ramp_angle_by_difficulty(difficulty: float, deg_min: float = 2.0, deg_max: float = 20.0) -> float` (retourne des **radians**). Constantes module `RAMP_DEG_MIN = 2.0`, `RAMP_DEG_MAX = 20.0`. + +- [ ] **Step 1: Écrire le test qui échoue** + +```python +# tests/test_slope_terrain.py +import math +from mjlab_microduck.tasks.slope_terrain import ( + ramp_angle_by_difficulty, + RAMP_DEG_MIN, + RAMP_DEG_MAX, +) + + +def test_ramp_angle_endpoints(): + assert math.isclose(ramp_angle_by_difficulty(0.0), math.radians(RAMP_DEG_MIN), abs_tol=1e-9) + assert math.isclose(ramp_angle_by_difficulty(1.0), math.radians(RAMP_DEG_MAX), abs_tol=1e-9) + + +def test_ramp_angle_midpoint(): + mid_deg = (RAMP_DEG_MIN + RAMP_DEG_MAX) / 2.0 + assert math.isclose(ramp_angle_by_difficulty(0.5), math.radians(mid_deg), abs_tol=1e-9) + + +def test_ramp_angle_clamps_out_of_range(): + assert math.isclose(ramp_angle_by_difficulty(-1.0), math.radians(RAMP_DEG_MIN), abs_tol=1e-9) + assert math.isclose(ramp_angle_by_difficulty(2.0), math.radians(RAMP_DEG_MAX), abs_tol=1e-9) +``` + +- [ ] **Step 2: Lancer le test — il doit échouer** + +Run: `uv run pytest tests/test_slope_terrain.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'mjlab_microduck.tasks.slope_terrain'` + +- [ ] **Step 3: Implémentation minimale** + +```python +# src/mjlab_microduck/tasks/slope_terrain.py +"""Terrain custom « plat + rampe descendante » pour la tâche roller_slope. + +Le robot spawne sur une zone plate, reçoit une impulsion vers +x, roule +jusqu'à la rampe et se laisse glisser. L'angle de la rampe est interpolé par +la difficulté (curriculum) sur [RAMP_DEG_MIN, RAMP_DEG_MAX] degrés. +""" + +from __future__ import annotations + +import math + +import numpy as np + +RAMP_DEG_MIN = 2.0 +RAMP_DEG_MAX = 20.0 + + +def ramp_angle_by_difficulty( + difficulty: float, deg_min: float = RAMP_DEG_MIN, deg_max: float = RAMP_DEG_MAX +) -> float: + """Angle de rampe (radians) interpolé linéairement par la difficulté [0,1].""" + d = float(np.clip(difficulty, 0.0, 1.0)) + return math.radians(deg_min + d * (deg_max - deg_min)) +``` + +- [ ] **Step 4: Lancer le test — il doit passer** + +Run: `uv run pytest tests/test_slope_terrain.py -v` +Expected: PASS (3 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/mjlab_microduck/tasks/slope_terrain.py tests/test_slope_terrain.py +git commit -m "roller-slope: angle de rampe par difficulte (fonction pure + tests)" +``` + +--- + +## Task 2 : terrain custom `FlatRampTerrainCfg` + +**Files:** +- Modify: `src/mjlab_microduck/tasks/slope_terrain.py` +- Test: `tests/test_slope_terrain.py` + +**Interfaces:** +- Consumes: `ramp_angle_by_difficulty` (Task 1), `SubTerrainCfg`, `TerrainGeometry`, `TerrainOutput` de `mjlab.terrains.terrain_generator`. +- Produces: `FlatRampTerrainCfg(SubTerrainCfg)` avec champs `flat_length: float = 2.0`, `ramp_length: float = 5.0`, `deg_min: float = 2.0`, `deg_max: float = 20.0`, `thickness: float = 0.5` ; méthode `function(difficulty, spec, rng) -> TerrainOutput`. L'origine de spawn est sur le plat. + +**Notes géométrie (à retenir) :** la surface du plat est à `z=0` local. La rampe est un box tourné autour de `+y` par un quaternion `[cos(a/2), 0, sin(a/2), 0]` — une rotation `+a` autour de `+y` abaisse le bord `+x` (la rampe descend quand `x` augmente). L'assemblage exact plat/rampe (pas de marche, pas de trou) **doit être vérifié dans le viewer** (Step 6) car le `z` du centre de la rampe est sensible. + +- [ ] **Step 1: Écrire le test qui échoue** + +```python +# tests/test_slope_terrain.py (ajouter) +import mujoco +import numpy as np +from mjlab_microduck.tasks.slope_terrain import FlatRampTerrainCfg + + +def _empty_terrain_spec(): + spec = mujoco.MjSpec() + spec.worldbody.add_body(name="terrain") + return spec + + +def test_flat_ramp_builds_geoms_and_origin_on_flat(): + cfg = FlatRampTerrainCfg(flat_length=2.0, ramp_length=5.0) + cfg.size = (8.0, 4.0) # posé normalement par le générateur + spec = _empty_terrain_spec() + out = cfg.function(difficulty=0.5, spec=spec, rng=np.random.default_rng(0)) + # deux géométries : plat + rampe + assert len(out.geometries) == 2 + # origine sur le plat (x dans [0, flat_length], z ~ 0) + assert 0.0 <= out.origin[0] <= 2.0 + assert abs(out.origin[2]) < 1e-6 + + +def test_flat_ramp_steeper_at_higher_difficulty(): + # à difficulté plus haute, le bout de rampe descend plus bas + cfg = FlatRampTerrainCfg() + cfg.size = (8.0, 4.0) + easy = cfg.function(0.0, _empty_terrain_spec(), np.random.default_rng(0)) + hard = cfg.function(1.0, _empty_terrain_spec(), np.random.default_rng(0)) + # la rampe (2e géométrie) est plus basse (centre z plus négatif) en difficile + assert hard.geometries[1].geom.pos[2] < easy.geometries[1].geom.pos[2] +``` + +- [ ] **Step 2: Lancer le test — il doit échouer** + +Run: `uv run pytest tests/test_slope_terrain.py -k flat_ramp -v` +Expected: FAIL — `ImportError: cannot import name 'FlatRampTerrainCfg'` + +- [ ] **Step 3: Implémentation minimale** + +```python +# src/mjlab_microduck/tasks/slope_terrain.py (ajouter en tête) +from dataclasses import dataclass + +import mujoco + +from mjlab.terrains.terrain_generator import ( + SubTerrainCfg, + TerrainGeometry, + TerrainOutput, +) + + +@dataclass(kw_only=True) +class FlatRampTerrainCfg(SubTerrainCfg): + """Zone plate de départ suivie d'une rampe descendante (angle par difficulté).""" + + flat_length: float = 2.0 # longueur du plat de départ le long de +x (m) + ramp_length: float = 5.0 # longueur horizontale de la rampe le long de +x (m) + deg_min: float = RAMP_DEG_MIN + deg_max: float = RAMP_DEG_MAX + thickness: float = 0.5 # épaisseur des box (m) + + def function( + self, difficulty: float, spec: mujoco.MjSpec, rng + ) -> TerrainOutput: + del rng # non utilisé + body = spec.body("terrain") + angle = ramp_angle_by_difficulty(difficulty, self.deg_min, self.deg_max) + width = self.size[1] + t = self.thickness + + # Plat : box dont la surface supérieure est à z=0, x dans [0, flat_length]. + flat = body.add_geom( + type=mujoco.mjtGeom.mjGEOM_BOX, + size=(self.flat_length / 2.0, width / 2.0, t / 2.0), + pos=(self.flat_length / 2.0, 0.0, -t / 2.0), + ) + + # Rampe : box tourné de +angle autour de +y (le bord +x descend). + # Longueur de surface = ramp_length / cos(angle). + surf_len = self.ramp_length / math.cos(angle) + ramp_cx = self.flat_length + self.ramp_length / 2.0 + # Centre z : mi-descente de la surface, moins la demi-épaisseur projetée. + ramp_cz = -(self.ramp_length * math.tan(angle) / 2.0) - (t / 2.0) * math.cos(angle) + half = angle / 2.0 + ramp = body.add_geom( + type=mujoco.mjtGeom.mjGEOM_BOX, + size=(surf_len / 2.0, width / 2.0, t / 2.0), + pos=(ramp_cx, 0.0, ramp_cz), + quat=(math.cos(half), 0.0, math.sin(half), 0.0), + ) + + origin = np.array([self.flat_length * 0.4, 0.0, 0.0]) + return TerrainOutput( + origin=origin, + geometries=[ + TerrainGeometry(geom=flat, color=(0.5, 0.5, 0.5, 1.0)), + TerrainGeometry(geom=ramp, color=(0.45, 0.55, 0.75, 1.0)), + ], + ) +``` + +- [ ] **Step 4: Lancer les tests — ils doivent passer** + +Run: `uv run pytest tests/test_slope_terrain.py -v` +Expected: PASS (5 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/mjlab_microduck/tasks/slope_terrain.py tests/test_slope_terrain.py +git commit -m "roller-slope: terrain custom plat+rampe (FlatRampTerrainCfg + tests)" +``` + +- [ ] **Step 6: Vérification visuelle (checkpoint humain)** + +La géométrie (surtout `ramp_cz` et le signe du quaternion) doit être confirmée à l'œil. +Après la Task 4 (env assemblé), lancer le viewer play (voir Task 4 Step 6) et vérifier : +la zone plate rejoint la rampe **sans marche ni trou**, et la rampe **descend** dans +la direction `+x` (devant le robot). Si un décalage vertical apparaît, ajuster `ramp_cz` ; +si la rampe monte au lieu de descendre, inverser le signe (`-half`) du quaternion. + +--- + +## Task 3 : curriculum de raideur `terrain_levels_slope` + +**Files:** +- Modify: `src/mjlab_microduck/tasks/mdp.py` +- Test: `tests/test_slope_curriculum.py` + +**Interfaces:** +- Produces: + - `slope_move_masks(distance: torch.Tensor, size_x: float) -> tuple[torch.Tensor, torch.Tensor]` — helper pur. `move_up = distance > size_x * 0.5` (a atteint le bas → rampe plus raide) ; `move_down = (distance < size_x * 0.2) & ~move_up` (chute/blocage tôt → rampe plus douce). Retourne `(move_up, move_down)` en `bool`. + - `terrain_levels_slope(env, env_ids) -> torch.Tensor` — signature curriculum mjlab ; calcule la distance parcourue en `x` depuis l'origine, applique `slope_move_masks`, appelle `terrain.update_env_origins`, retourne le niveau moyen. + +- [ ] **Step 1: Écrire le test qui échoue** + +```python +# tests/test_slope_curriculum.py +import torch +from mjlab_microduck.tasks.mdp import slope_move_masks + + +def test_move_up_when_reached_bottom(): + # distance > size_x/2 → monte en difficulté + dist = torch.tensor([5.0, 4.1]) + up, down = slope_move_masks(dist, size_x=8.0) + assert bool(up[0]) and bool(up[1]) + assert not bool(down[0]) and not bool(down[1]) + + +def test_move_down_when_stuck_early(): + # distance < size_x*0.2 (=1.6) → descend en difficulté + dist = torch.tensor([0.5, 1.0]) + up, down = slope_move_masks(dist, size_x=8.0) + assert not bool(up[0]) and not bool(up[1]) + assert bool(down[0]) and bool(down[1]) + + +def test_stay_in_middle_band(): + # entre 1.6 et 4.0 → ni haut ni bas + dist = torch.tensor([2.5]) + up, down = slope_move_masks(dist, size_x=8.0) + assert not bool(up[0]) and not bool(down[0]) +``` + +- [ ] **Step 2: Lancer le test — il doit échouer** + +Run: `uv run pytest tests/test_slope_curriculum.py -v` +Expected: FAIL — `ImportError: cannot import name 'slope_move_masks'` + +- [ ] **Step 3: Implémentation minimale** + +Ajouter dans `src/mjlab_microduck/tasks/mdp.py` (près des autres curriculums, ex. après `com_range_curriculum`). Vérifier en tête de fichier que `torch` est importé (il l'est). + +```python +def slope_move_masks(distance: "torch.Tensor", size_x: float): + """Masques de promotion/rétrogradation du curriculum de pente. + + move_up : a parcouru plus de la moitié de la tuile → il a dévalé la rampe, + on la rend plus raide. + move_down : a à peine avancé (< 20% de la tuile) → chute/blocage précoce, + on adoucit la rampe. + """ + move_up = distance > size_x * 0.5 + move_down = (distance < size_x * 0.2) & (~move_up) + return move_up, move_down + + +def terrain_levels_slope(env, env_ids): + """Curriculum de raideur pour roller_slope (pas de vitesse commandée). + + Progression basée sur la distance en x parcourue depuis l'origine de spawn. + """ + asset = env.scene["robot"] + terrain = env.scene.terrain + assert terrain is not None + terrain_generator = terrain.cfg.terrain_generator + assert terrain_generator is not None + + distance = ( + asset.data.root_link_pos_w[env_ids, 0] - env.scene.env_origins[env_ids, 0] + ) + move_up, move_down = slope_move_masks(distance, terrain_generator.size[0]) + terrain.update_env_origins(env_ids, move_up, move_down) + return torch.mean(terrain.terrain_levels.float()) +``` + +- [ ] **Step 4: Lancer le test — il doit passer** + +Run: `uv run pytest tests/test_slope_curriculum.py -v` +Expected: PASS (3 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/mjlab_microduck/tasks/mdp.py tests/test_slope_curriculum.py +git commit -m "roller-slope: curriculum de raideur terrain_levels_slope (+ helper pur teste)" +``` + +--- + +## Task 4 : env cfg `roller_slope` + enregistrement + +**Files:** +- Create: `src/mjlab_microduck/tasks/microduck_roller_slope_env_cfg.py` +- Modify: `src/mjlab_microduck/tasks/__init__.py` +- Test: `tests/test_roller_slope_cfg.py` + +**Interfaces:** +- Consumes: `make_microduck_velocity_rollers_env_cfg` (base physique/DR/obs), `FlatRampTerrainCfg` (Task 2), `terrain_levels_slope` (Task 3), fonctions mdp existantes : `body_upright_gaussian`, `is_alive`, `pose_target_match`, `pose_l1_penalty`, `feet_flat_penalty`, `neck_action_rate_l2`, `joint_torques_l2`, `robot_state_is_nan`, `reset_action_history`, `zero_command_padding`. +- Produces: `make_microduck_roller_slope_env_cfg(play: bool = False) -> ManagerBasedRlEnvCfg` et `MicroduckRollerSlopeRlCfg` (`RslRlOnPolicyRunnerCfg`, `experiment_name="roller_slope"`). + +> Réutiliser les blocs DR/obs/reset du roller env : on **part** de `make_microduck_velocity_rollers_env_cfg()` et on ne modifie QUE terrain, commande, récompenses, terminaisons, curriculum. Ne pas réécrire la DR. + +- [ ] **Step 1: Écrire le test qui échoue** + +```python +# tests/test_roller_slope_cfg.py +from mjlab_microduck.tasks.microduck_roller_slope_env_cfg import ( + make_microduck_roller_slope_env_cfg, +) +from mjlab_microduck.tasks.slope_terrain import FlatRampTerrainCfg + + +def test_terrain_is_flat_ramp_generator(): + cfg = make_microduck_roller_slope_env_cfg() + assert cfg.scene.terrain.terrain_type == "generator" + gen = cfg.scene.terrain.terrain_generator + assert gen is not None and gen.curriculum is True + assert any(isinstance(st, FlatRampTerrainCfg) for st in gen.sub_terrains.values()) + + +def test_command_is_neutralised(): + cfg = make_microduck_roller_slope_env_cfg() + cmd = cfg.commands["twist"] + assert cmd.rel_standing_envs == 1.0 + assert cmd.rel_heading_envs == 0.0 + + +def test_entry_velocity_set_on_reset_base(): + cfg = make_microduck_roller_slope_env_cfg() + vr = cfg.events["reset_base"].params["velocity_range"] + assert vr["x"][0] > 0.0 # impulsion vers l'avant + + +def test_has_upright_and_pose_rewards(): + cfg = make_microduck_roller_slope_env_cfg() + for name in ("upright", "alive", "standing_pose", "feet_flat"): + assert name in cfg.rewards +``` + +- [ ] **Step 2: Lancer le test — il doit échouer** + +Run: `uv run pytest tests/test_roller_slope_cfg.py -v` +Expected: FAIL — `ModuleNotFoundError` (module env cfg absent) + +- [ ] **Step 3: Implémentation** + +```python +# src/mjlab_microduck/tasks/microduck_roller_slope_env_cfg.py +"""Microduck roller slope — descente passive équilibrée. + +Le robot spawne sur du plat (impulsion vers l'avant), roule sur une rampe +descendante et se laisse glisser en restant debout. Aucun pilotage : la +commande twist est neutralisée (rel_standing_envs=1.0). Terrain custom +plat+rampe (FlatRampTerrainCfg), curriculum de raideur (terrain_levels_slope). +Obs 61D unifié → interchangeable au runtime (--new-cmd-obs). +""" + +from mjlab.envs import ManagerBasedRlEnvCfg +from mjlab.managers import CurriculumTermCfg, EventTermCfg, RewardTermCfg, TerminationTermCfg +from mjlab.managers.scene_entity_config import SceneEntityCfg +from mjlab.rl import RslRlOnPolicyRunnerCfg, RslRlModelCfg +from mjlab.terrains import TerrainEntityCfg +from mjlab.terrains.terrain_generator import TerrainGeneratorCfg +from mjlab.tasks.velocity import mdp +from mjlab.envs import mdp as base_mdp + +from mjlab_microduck.tasks import mdp as microduck_mdp +from mjlab_microduck.tasks.slope_terrain import FlatRampTerrainCfg +from mjlab_microduck.tasks.microduck_velocity_rollers_env_cfg import ( + make_microduck_velocity_rollers_env_cfg, +) +from mjlab_microduck.tasks.symmetry import PpoWithSymmetryCfg + +ENTRY_VELOCITY_X = (0.2, 0.5) # impulsion vers l'avant au reset (m/s) + + +def make_microduck_roller_slope_env_cfg(play: bool = False) -> ManagerBasedRlEnvCfg: + cfg = make_microduck_velocity_rollers_env_cfg(play=play) + + # === TERRAIN : plat + rampe, curriculum de raideur === + cfg.scene.terrain = TerrainEntityCfg( + terrain_type="generator", + terrain_generator=TerrainGeneratorCfg( + size=(8.0, 4.0), + curriculum=True, + num_rows=10, # 10 niveaux de raideur + num_cols=1, + difficulty_range=(0.0, 1.0), + sub_terrains={"flat_ramp": FlatRampTerrainCfg(flat_length=2.0, ramp_length=5.0)}, + ), + max_init_terrain_level=0, # démarrer sur la rampe la plus douce + ) + + # === COMMANDE neutralisée (équilibre pur) === + command = cfg.commands["twist"] + command.rel_standing_envs = 1.0 + command.rel_heading_envs = 0.0 + command.ranges.lin_vel_x = (0.0, 0.0) + command.ranges.lin_vel_y = (0.0, 0.0) + if getattr(command.ranges, "ang_vel_z", None) is not None: + command.ranges.ang_vel_z = (0.0, 0.0) + + # === RESET : impulsion vers l'avant sur le plat === + cfg.events["reset_base"].params["velocity_range"] = {"x": ENTRY_VELOCITY_X} + + # === RÉCOMPENSES : équilibre + posture debout nominale === + keep = {"action_rate_l2"} + for name in list(cfg.rewards.keys()): + if name not in keep: + del cfg.rewards[name] + + cfg.rewards["upright"] = RewardTermCfg( + func=microduck_mdp.body_upright_gaussian, + weight=3.0, + params={"asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",)), "std": 0.2}, + ) + cfg.rewards["alive"] = RewardTermCfg(func=microduck_mdp.is_alive, weight=1.0) + # posture debout nominale (cible fixe = default_joint_pos, aucun override) + cfg.rewards["standing_pose"] = RewardTermCfg( + func=microduck_mdp.pose_target_match, weight=3.0, params={"std": 0.4}, + ) + cfg.rewards["standing_pose_l1"] = RewardTermCfg( + func=microduck_mdp.pose_l1_penalty, weight=1.0, + ) + cfg.rewards["feet_flat"] = RewardTermCfg( + func=microduck_mdp.feet_flat_penalty, + weight=-2.0, + params={ + "asset_cfg": SceneEntityCfg("robot", site_names=("left_foot", "right_foot")), + "sensor_name": "feet_ground_contact", + }, + ) + cfg.rewards["neck_action_rate_l2"] = RewardTermCfg( + func=microduck_mdp.neck_action_rate_l2, weight=-0.5, + ) + cfg.rewards["joint_torques_l2"] = RewardTermCfg( + func=microduck_mdp.joint_torques_l2, weight=-1e-3, + ) + cfg.rewards["action_rate_l2"].weight = -1.0 + + # === TERMINATIONS : chute + bas atteint === + cfg.terminations["fell_over"] = TerminationTermCfg( + func=base_mdp.bad_orientation, + params={"limit_angle": 1.0, "asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",))}, + ) + cfg.terminations["out_of_bounds"] = TerminationTermCfg(func=mdp.out_of_terrain_bounds) + cfg.terminations["nan_state"] = TerminationTermCfg( + func=microduck_mdp.robot_state_is_nan, time_out=False, + ) + + # === EVENTS === + cfg.events["reset_action_history"] = EventTermCfg( + func=microduck_mdp.reset_action_history, mode="reset", + ) + + # === CURRICULUM : raideur de la rampe === + for name in list(cfg.curriculum.keys()): + del cfg.curriculum[name] + cfg.curriculum["terrain_levels"] = CurriculumTermCfg(func=microduck_mdp.terrain_levels_slope) + + return cfg + + +MicroduckRollerSlopeRlCfg = RslRlOnPolicyRunnerCfg( + actor=RslRlModelCfg( + hidden_dims=(512, 256, 128), + activation="elu", + obs_normalization=True, + distribution_cfg={"class_name": "GaussianDistribution", "init_std": 1.0, "std_type": "scalar"}, + ), + critic=RslRlModelCfg(hidden_dims=(512, 256, 128), activation="elu", obs_normalization=True), + algorithm=PpoWithSymmetryCfg( + value_loss_coef=1.0, use_clipped_value_loss=True, clip_param=0.2, + entropy_coef=0.01, num_learning_epochs=5, num_mini_batches=4, + learning_rate=1.0e-3, schedule="adaptive", gamma=0.99, lam=0.95, + desired_kl=0.01, max_grad_norm=1.0, symmetry_cfg=None, + ), + wandb_project="mjlab_microduck", + experiment_name="roller_slope", + run_name="roller_slope", + save_interval=250, + num_steps_per_env=24, + max_iterations=8_000, +) +``` + +Puis enregistrer dans `src/mjlab_microduck/tasks/__init__.py`, en suivant EXACTEMENT le pattern d'enregistrement de `roller_crouch` déjà présent (import de `make_...` + `Microduck...RlCfg`, puis `register_mjlab_task(...)` avec un id du style `"Microduck-Roller-Slope"`). Copier le bloc `roller_crouch` et remplacer `crouch`→`slope`. + +- [ ] **Step 4: Lancer les tests — ils doivent passer** + +Run: `uv run pytest tests/test_roller_slope_cfg.py -v` +Expected: PASS (4 tests) + +- [ ] **Step 5: Vérifier l'enregistrement de la tâche + build complet** + +Run: +```bash +uv run python -c "import gymnasium as gym; import mjlab_microduck.tasks; print([e for e in gym.registry if 'Slope' in e])" +``` +Expected: la liste contient l'id `Microduck-Roller-Slope` (ou variante enregistrée). + +- [ ] **Step 6: Vérification visuelle du terrain + descente (checkpoint humain — clôt Task 2 Step 6)** + +Lancer un court entraînement puis le play (ou `scripts/play_latest.py` selon l'usage du dépôt) et observer : +1. Plat + rampe assemblés sans marche/trou ; la rampe **descend** devant le robot. +2. Le robot spawne sur le plat, part vers l'avant, atteint la rampe. +Si la géométrie est fausse, corriger `slope_terrain.py` (voir Task 2 Step 6) et re-commit. + +- [ ] **Step 7: Commit** + +```bash +git add src/mjlab_microduck/tasks/microduck_roller_slope_env_cfg.py src/mjlab_microduck/tasks/__init__.py tests/test_roller_slope_cfg.py +git commit -m "roller-slope: env descente passive (terrain plat+rampe, cmd nulle, rewards equilibre) + enregistrement" +``` + +--- + +## Task 5 : déploiement — flag `--slope` + touche `Y` + +**Files:** +- Modify: `scripts/infer_policy.py` + +**Interfaces:** +- Consumes: le `.onnx` exporté de la politique `roller_slope`. +- Produces: argument CLI `--slope ` ; attribut `self.slope_session` + flag `self.slope_mode` ; méthode `toggle_slope_mode()` ; touche `GLFW_KEY_Y = 89` câblée. + +> La politique pente tourne avec commande twist nulle (comme le mode standing). En slope mode, la bascule automatique walking/standing doit être neutralisée. + +- [ ] **Step 1: Ajouter l'argument CLI et charger la session** + +Dans `main()` (près des autres `add_argument`, ~ligne 471) : +```python + parser.add_argument("--slope", type=str, default=None, help="Path to slope policy ONNX file (press Y to toggle)") +``` +Passer `slope_onnx_path=args.slope` au constructeur du contrôleur (ajouter le paramètre `slope_onnx_path=None` à `__init__`, ~ligne 51-57, et charger comme les autres) : +```python + self.slope_session = None + self.slope_mode = False + if slope_onnx_path: + print(f"\nLoading slope policy from: {slope_onnx_path}") + self.slope_session = ort.InferenceSession(slope_onnx_path) +``` + +- [ ] **Step 2: Ajouter `toggle_slope_mode` et neutraliser la bascule auto** + +Après `toggle_body_pose_mode` (~ligne 285) : +```python + def toggle_slope_mode(self): + """Bascule vers/depuis la politique pente (descente passive).""" + if self.slope_session is None: + print("Slope unavailable: no --slope policy loaded") + return + self.slope_mode = not self.slope_mode + if self.slope_mode: + self.ort_session = self.slope_session + self.current_policy = "slope" + self.set_vel_cmd(0.0, 0.0, 0.0) # descente passive : commande nulle + print("Slope mode: ON (descente passive)") + else: + self.ort_session = self.walking_session or self.standing_session + self.current_policy = "walking" if self.walking_session else "standing" + print("Slope mode: OFF") +``` +Dans `_update_policy_session` (~ligne 250), ajouter le garde en tête (après le garde `ground_pick_mode`) : +```python + if self.slope_mode: + return # Ne pas basculer pendant le mode pente +``` + +- [ ] **Step 3: Câbler la touche `Y`** + +Ajouter le code de touche près des autres (~ligne 680) : +```python + GLFW_KEY_Y = 89 +``` +Dans `key_callback`, ajouter une branche (ex. après la branche `GLFW_KEY_B`) : +```python + elif key == GLFW_KEY_Y: + policy.toggle_slope_mode() +``` +Ajouter la ligne d'aide clavier (près des `print` ~ligne 821) : +```python + print(" Y: toggle slope mode (requires --slope, descente passive)") +``` + +- [ ] **Step 4: Vérifier que le script se charge sans erreur** + +Run: `uv run python scripts/infer_policy.py --help` +Expected: l'aide s'affiche et liste `--slope`. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/infer_policy.py +git commit -m "roller-slope: deploiement --slope + touche Y (bascule mode pente)" +``` + +--- + +## Self-Review (fait par l'auteur du plan) + +- **Couverture spec** : tâche dédiée (Task 4) ✓ ; terrain plat+rampe custom (Task 2) ✓ ; départ plat + impulsion (Task 4 reset velocity_range) ✓ ; commande nulle (Task 4) ✓ ; récompenses équilibre + pose debout + anti-écrasement (Task 4) ✓ ; terminaisons chute/bas/nan (Task 4) ✓ ; curriculum 0→20° (Task 1 angle + Task 3 promotion) ✓ ; obs 61D interchangeable (hérité du roller env, non modifié) ✓ ; bouton Y (Task 5) ✓. +- **Placeholders** : aucun « TBD/TODO » ; les deux checkpoints humains (géométrie viewer) sont des vérifications explicites, pas des trous d'implémentation. +- **Cohérence des types** : `ramp_angle_by_difficulty` (Task 1) réutilisé par `FlatRampTerrainCfg` (Task 2) ; `slope_move_masks` (Task 3) consommé par `terrain_levels_slope` (Task 3) ; noms de récompenses testés en Task 4 (`upright`, `alive`, `standing_pose`, `feet_flat`) alignés sur l'implémentation. +- **Risques signalés** : géométrie de la rampe (`ramp_cz`, signe du quaternion) à confirmer au viewer ; noms exacts d'API mjlab (`terrain.terrain_levels`, `TerrainEntityCfg`, id d'enregistrement) à valider contre le pattern `roller_crouch` existant lors de l'implémentation. diff --git a/docs/superpowers/plans/2026-07-24-ground-pick-pose-following.md b/docs/superpowers/plans/2026-07-24-ground-pick-pose-following.md new file mode 100644 index 0000000..e1a4c1e --- /dev/null +++ b/docs/superpowers/plans/2026-07-24-ground-pick-pose-following.md @@ -0,0 +1,618 @@ +# Ground-pick par suivi de pose — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Réécrire la tâche `Mjlab-GroundPick-Flat-MicroDuck` pour piloter le geste par un suivi de pose articulaire interpolé par la phase (STAND→DOWN→STAND) au lieu de l'objectif espace-tâche actuel (proximité bouche-sol + retour de pose). + +**Architecture:** On ajoute trois fonctions mdp pures/quasi-pures (`phase_pose_blend`, `phase_pose_track`, `phase_pose_track_l1`) qui calculent une cible articulaire interpolée entre HOME (STAND) et un dict `DOWN_POSE` selon un profil de phase à 4 segments, résolue **par nom**. On ajoute un flag `randomize_phase` à la commande de phase existante. On réécrit ensuite le bloc rewards de `microduck_ground_pick_env_cfg.py` en gardant tout le reste (DR, obs 61D, curricula, RlCfg). + +**Tech Stack:** Python, PyTorch, mjlab 1.3.0, MuJoCo, uv, pytest (via `uv run --with pytest`). + +## Global Constraints + +- Résolution des joints **PAR NOM** (`asset.find_joints([name])[0][0]`), jamais par index en dur. +- Obs 61D unifié **inchangé** (padding head/body zéro) → policy interchangeable dans le slot runtime. +- Task id inchangé : `Mjlab-GroundPick-Flat-MicroDuck` (+ variante `-Rough-`). +- Période de phase = **4.0 s** (défaut du slot `--ground-pick-period`). +- Profil de phase (fractions) : `DESCENT_END=0.15`, `HOLD_END=0.50`, `RISE_END=0.65`. +- `randomize_phase=False` pour la tâche ground_pick (parité déploiement bouton A à φ=0) ; défaut `True` de la cfg pour ne pas casser sit/stand. +- STAND = HOME (`asset.data.default_joint_pos`, ne pas redéfinir). DOWN = dict `DOWN_POSE` par nom. +- 14 joints actifs (mouth exclu). Robot `MICRODUCK_GROUND_PICK_ROBOT_CFG` (pas de roues → indices 0-4 jambe G, 5-8 cou/tête, 9-13 jambe D, mais on résout quand même par nom). +- Fichiers mdp : imports déjà présents (`torch`, `Optional`, `Entity`, `SceneEntityCfg`, `ManagerBasedRlEnv`, `_DEFAULT_ASSET_CFG`). + +--- + +### Task 1: Fonction `phase_pose_blend` (blend 4 segments, pure) + +**Files:** +- Modify: `src/mjlab_microduck/tasks/mdp.py` (ajout d'une fonction ; l'insérer juste avant `phase_pose_match` ~ligne 2041) +- Test: `tests/test_ground_pick_pose.py` (create) + +**Interfaces:** +- Produces: `phase_pose_blend(phase: torch.Tensor, descent_end: float, hold_end: float, rise_end: float) -> torch.Tensor` — renvoie un blend ∈ [0,1] de même shape que `phase` (0 = STAND, 1 = DOWN). + +- [ ] **Step 1: Write the failing test** + +Créer `tests/test_ground_pick_pose.py` : + +```python +import torch +from mjlab_microduck.tasks.mdp import phase_pose_blend + +DESCENT_END, HOLD_END, RISE_END = 0.15, 0.50, 0.65 + + +def test_phase_pose_blend_keypoints(): + phase = torch.tensor([0.0, 0.075, 0.15, 0.30, 0.50, 0.575, 0.65, 0.80]) + b = phase_pose_blend(phase, DESCENT_END, HOLD_END, RISE_END) + expected = torch.tensor([0.0, 0.5, 1.0, 1.0, 1.0, 0.5, 0.0, 0.0]) + assert torch.allclose(b, expected, atol=1e-6), b + + +def test_phase_pose_blend_range(): + phase = torch.linspace(0.0, 1.0, 101) + b = phase_pose_blend(phase, DESCENT_END, HOLD_END, RISE_END) + assert b.min() >= 0.0 and b.max() <= 1.0 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run --with pytest pytest tests/test_ground_pick_pose.py -q` +Expected: FAIL — `ImportError: cannot import name 'phase_pose_blend'` + +- [ ] **Step 3: Write minimal implementation** + +Dans `src/mjlab_microduck/tasks/mdp.py`, juste avant `def phase_pose_match(` (~ligne 2041) : + +```python +def phase_pose_blend( + phase: torch.Tensor, + descent_end: float, + hold_end: float, + rise_end: float, +) -> torch.Tensor: + """Blend 0..1 le long de la phase [0,1) — 0 = pose STAND, 1 = pose DOWN. + + [0, descent_end) : 0 -> 1 (se baisser) + [descent_end, hold_end): 1 (bas) + [hold_end, rise_end) : 1 -> 0 (se lever) + [rise_end, 1.0) : 0 (haut / repos) + """ + b = torch.zeros_like(phase) + descend = phase < descent_end + b = torch.where(descend, phase / descent_end, b) + low = (phase >= descent_end) & (phase < hold_end) + b = torch.where(low, torch.ones_like(phase), b) + rise = (phase >= hold_end) & (phase < rise_end) + b = torch.where(rise, 1.0 - (phase - hold_end) / (rise_end - hold_end), b) + return b +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run --with pytest pytest tests/test_ground_pick_pose.py -q` +Expected: PASS (2 passed) + +- [ ] **Step 5: Commit** + +```bash +git add tests/test_ground_pick_pose.py src/mjlab_microduck/tasks/mdp.py +git commit -m "feat(mdp): phase_pose_blend — blend 4 segments STAND<->DOWN par la phase" +``` + +--- + +### Task 2: Rewards `phase_pose_track` / `phase_pose_track_l1` (+ helper `_phase_pose_error`) + +**Files:** +- Modify: `src/mjlab_microduck/tasks/mdp.py` (ajout juste après `phase_pose_blend`) +- Test: `tests/test_ground_pick_pose.py` (append) + +**Interfaces:** +- Consumes: `phase_pose_blend` (Task 1). +- Produces: + - `_phase_pose_error(env, asset_cfg, command_name, target_pose: dict, descent_end, hold_end, rise_end, source_pose: dict | None = None) -> (cur: Tensor, target: Tensor)` — tenseurs (B, k) résolus par nom. + - `phase_pose_track(env, command_name="twist", target_pose: dict | None = None, source_pose: dict | None = None, std=0.3, descent_end=0.15, hold_end=0.50, rise_end=0.65, asset_cfg=_DEFAULT_ASSET_CFG) -> Tensor` — gaussienne `exp(-((cur-target)/std)²).mean(-1)`. + - `phase_pose_track_l1(env, command_name="twist", target_pose=None, source_pose=None, descent_end=0.15, hold_end=0.50, rise_end=0.65, asset_cfg=_DEFAULT_ASSET_CFG) -> Tensor` — `-(cur-target).abs().mean(-1)`. + +- [ ] **Step 1: Write the failing test** + +Ajouter à `tests/test_ground_pick_pose.py` un faux env léger + les assertions : + +```python +from mjlab_microduck.tasks.mdp import phase_pose_track, phase_pose_track_l1 + + +class _FakeData: + def __init__(self, joint_pos, default_pos): + self.joint_pos = joint_pos + self.default_joint_pos = default_pos + + +class _FakeAsset: + def __init__(self, names, joint_pos, default_pos): + self._ids = {n: i for i, n in enumerate(names)} + self.data = _FakeData(joint_pos, default_pos) + + def find_joints(self, query): + # mjlab renvoie (ids, names) ; on ne gère que la requête [name] + (name,) = query + return ([self._ids[name]], [name]) + + +class _FakeCmdMgr: + def __init__(self, cmd): + self._cmd = cmd + + def get_command(self, _name): + return self._cmd + + +class _FakeEnv: + def __init__(self, names, joint_pos, default_pos, phase): + import math + self.device = "cpu" + self.scene = {"robot": _FakeAsset(names, joint_pos, default_pos)} + ang = 2 * math.pi * phase + cmd = torch.tensor([[math.cos(ang), math.sin(ang), 0.0]]) + self.command_manager = _FakeCmdMgr(cmd) + + +NAMES = ["j0", "j1"] +DOWN = {"j0": 1.0, "j1": -1.0} +# HOME (STAND source) = 0 pour les deux joints +HOME = torch.tensor([[0.0, 0.0]]) + + +def _env(cur, phase): + return _FakeEnv(NAMES, torch.tensor([cur]), HOME.clone(), phase) + + +def test_phase_pose_track_perfect_at_down(): + # phase 0.30 -> blend 1 -> cible = DOWN ; cur == DOWN -> gaussienne 1, l1 0 + from mjlab.managers.scene_entity_config import SceneEntityCfg + cfg = SceneEntityCfg("robot") + env = _env([1.0, -1.0], phase=0.30) + r = phase_pose_track(env, target_pose=DOWN, asset_cfg=cfg) + assert torch.allclose(r, torch.tensor([1.0]), atol=1e-6), r + env2 = _env([1.0, -1.0], phase=0.30) + l1 = phase_pose_track_l1(env2, target_pose=DOWN, asset_cfg=cfg) + assert torch.allclose(l1, torch.tensor([0.0]), atol=1e-6), l1 + + +def test_phase_pose_track_l1_at_home_when_down_target(): + # phase 0.30 -> cible DOWN=[1,-1] ; cur=HOME=[0,0] -> l1 = -mean(|1|,|1|) = -1 + from mjlab.managers.scene_entity_config import SceneEntityCfg + cfg = SceneEntityCfg("robot") + env = _env([0.0, 0.0], phase=0.30) + l1 = phase_pose_track_l1(env, target_pose=DOWN, asset_cfg=cfg) + assert torch.allclose(l1, torch.tensor([-1.0]), atol=1e-6), l1 + + +def test_phase_pose_track_returns_to_stand(): + # phase 0.80 -> blend 0 -> cible = HOME ; cur=HOME -> gaussienne 1 + from mjlab.managers.scene_entity_config import SceneEntityCfg + cfg = SceneEntityCfg("robot") + env = _env([0.0, 0.0], phase=0.80) + r = phase_pose_track(env, target_pose=DOWN, asset_cfg=cfg) + assert torch.allclose(r, torch.tensor([1.0]), atol=1e-6), r +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run --with pytest pytest tests/test_ground_pick_pose.py -q` +Expected: FAIL — `ImportError: cannot import name 'phase_pose_track'` + +- [ ] **Step 3: Write minimal implementation** + +Dans `src/mjlab_microduck/tasks/mdp.py`, juste après `phase_pose_blend` : + +```python +def _phase_pose_error( + env: ManagerBasedRlEnv, + asset_cfg: SceneEntityCfg, + command_name: str, + target_pose: dict, + descent_end: float, + hold_end: float, + rise_end: float, + source_pose: Optional[dict] = None, +): + """(cur, target) pour la pose interpolée par la phase, résolue PAR NOM. + + Cible = source + blend(phase)·(target_pose - source), source = STAND + (`source_pose` si fourni, sinon le DEFAULT/HOME du modèle). blend ∈ [0,1] + (0 = STAND, 1 = target_pose) via `phase_pose_blend`. + """ + asset: Entity = env.scene[asset_cfg.name] + cmd = env.command_manager.get_command(command_name) + phase = (torch.atan2(cmd[:, 1], cmd[:, 0]) / (2 * torch.pi)) % 1.0 # (B,) + blend = phase_pose_blend(phase, descent_end, hold_end, rise_end) # (B,) + + names = list(target_pose.keys()) + ids = [int(asset.find_joints([n])[0][0]) for n in names] + default = asset.data.default_joint_pos[:, ids] # (B,k) + + source = default.clone() + if source_pose: + for j, n in enumerate(names): + if n in source_pose: + source[:, j] = source_pose[n] + target_vec = torch.tensor( + [target_pose[n] for n in names], device=env.device, dtype=default.dtype + ).unsqueeze(0) # (1,k) + + target = source + blend.unsqueeze(-1) * (target_vec - source) # (B,k) + cur = asset.data.joint_pos[:, ids] # (B,k) + return cur, target + + +def phase_pose_track( + env: ManagerBasedRlEnv, + command_name: str = "twist", + target_pose: Optional[dict] = None, + source_pose: Optional[dict] = None, + std: float = 0.3, + descent_end: float = 0.15, + hold_end: float = 0.50, + rise_end: float = 0.65, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """Gaussienne sur la pose articulaire vs cible interpolée STAND<->DOWN. + + Reward directif : indique la config articulaire exacte à chaque phase. Se + relever (cible → STAND) est récompensé exactement comme se baisser (cible → + DOWN) — symétrique par construction. Résolution PAR NOM. + """ + cur, target = _phase_pose_error( + env, asset_cfg, command_name, target_pose or {}, + descent_end, hold_end, rise_end, source_pose, + ) + return torch.exp(-((cur - target) / std) ** 2).mean(dim=-1) + + +def phase_pose_track_l1( + env: ManagerBasedRlEnv, + command_name: str = "twist", + target_pose: Optional[dict] = None, + source_pose: Optional[dict] = None, + descent_end: float = 0.15, + hold_end: float = 0.50, + rise_end: float = 0.65, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """Bootstrap L1 vers la cible interpolée (pénalité négative). + + Gradient constant partout — donne une direction vers la cible même quand la + gaussienne ci-dessus a saturé à ~0 loin de la cible. + """ + cur, target = _phase_pose_error( + env, asset_cfg, command_name, target_pose or {}, + descent_end, hold_end, rise_end, source_pose, + ) + return -(cur - target).abs().mean(dim=-1) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run --with pytest pytest tests/test_ground_pick_pose.py -q` +Expected: PASS (5 passed) + +- [ ] **Step 5: Commit** + +```bash +git add tests/test_ground_pick_pose.py src/mjlab_microduck/tasks/mdp.py +git commit -m "feat(mdp): phase_pose_track/_l1 — suivi de pose interpolée par la phase (par nom)" +``` + +--- + +### Task 3: Flag `randomize_phase` sur `GroundPickPhaseCommandCfg` + +**Files:** +- Modify: `src/mjlab_microduck/tasks/mdp.py` (classe `GroundPickPhaseCommand` ~3611/3626, cfg ~3644) +- Test: `tests/test_ground_pick_pose.py` (append) + +**Interfaces:** +- Produces: `GroundPickPhaseCommandCfg.randomize_phase: bool = True` ; `GroundPickPhaseCommand.reset()` met la phase à 0 quand `randomize_phase=False`, sinon `torch.rand`. + +- [ ] **Step 1: Write the failing test** + +Ajouter à `tests/test_ground_pick_pose.py` : + +```python +def test_ground_pick_cmd_cfg_has_randomize_phase_default_true(): + from mjlab_microduck.tasks.mdp import GroundPickPhaseCommandCfg + from mjlab.tasks.velocity.mdp import UniformVelocityCommandCfg + # construit une cfg minimale en copiant une cfg velocity par défaut + base = UniformVelocityCommandCfg( + asset_name="robot", resampling_time_range=(10.0, 10.0), + ranges=UniformVelocityCommandCfg.Ranges( + lin_vel_x=(0.0, 0.0), lin_vel_y=(0.0, 0.0), ang_vel_z=(0.0, 0.0), + ), + ) + cfg = GroundPickPhaseCommandCfg(**{**vars(base)}) + assert cfg.randomize_phase is True + assert cfg.period == 4.0 +``` + +Note : si la signature de `UniformVelocityCommandCfg.Ranges` diffère localement, adapter les champs — l'assertion clé est `cfg.randomize_phase is True`. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run --with pytest pytest tests/test_ground_pick_pose.py::test_ground_pick_cmd_cfg_has_randomize_phase_default_true -q` +Expected: FAIL — `AttributeError: 'GroundPickPhaseCommandCfg' object has no attribute 'randomize_phase'` + +- [ ] **Step 3: Write minimal implementation** + +Dans `src/mjlab_microduck/tasks/mdp.py`, classe `GroundPickPhaseCommand`, modifier `__init__` et `reset` : + +Remplacer (dans `__init__`, ~ligne 3614) : +```python + self._period = float(getattr(cfg, "period", self.PERIOD)) +``` +par : +```python + self._period = float(getattr(cfg, "period", self.PERIOD)) + self._randomize_phase = bool(getattr(cfg, "randomize_phase", True)) +``` + +Remplacer la méthode `reset` (~ligne 3626) : +```python + def reset(self, env_ids: torch.Tensor | None) -> dict: + if env_ids is not None and len(env_ids) > 0: + self._gp_phase[env_ids] = torch.rand(len(env_ids), device=self.device) + return {} +``` +par : +```python + def reset(self, env_ids: torch.Tensor | None) -> dict: + if env_ids is not None and len(env_ids) > 0: + if self._randomize_phase: + self._gp_phase[env_ids] = torch.rand(len(env_ids), device=self.device) + else: + self._gp_phase[env_ids] = 0.0 + return {} +``` + +Dans la cfg `GroundPickPhaseCommandCfg` (~ligne 3644), ajouter le champ après `period` : +```python +@_dataclass(kw_only=True) +class GroundPickPhaseCommandCfg(UniformVelocityCommandCfg): + class_type: type = GroundPickPhaseCommand + period: float = 4.0 # cycle length in seconds; sitstand uses 8.0 + randomize_phase: bool = True # False = chaque épisode démarre à φ=0 (parité slot bouton A) + + def build(self, env: ManagerBasedRlEnv) -> "GroundPickPhaseCommand": + return GroundPickPhaseCommand(self, env) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run --with pytest pytest tests/test_ground_pick_pose.py::test_ground_pick_cmd_cfg_has_randomize_phase_default_true -q` +Expected: PASS. Si la construction de `UniformVelocityCommandCfg` échoue pour une raison d'API locale, ajuster les champs du `base` dans le test (l'implémentation, elle, est correcte). + +- [ ] **Step 5: Commit** + +```bash +git add tests/test_ground_pick_pose.py src/mjlab_microduck/tasks/mdp.py +git commit -m "feat(mdp): flag randomize_phase sur GroundPickPhaseCommandCfg (défaut True)" +``` + +--- + +### Task 4: Réécriture du bloc rewards + poses dans l'env cfg + +**Files:** +- Modify: `src/mjlab_microduck/tasks/microduck_ground_pick_env_cfg.py` +- Test: `tests/test_ground_pick_cfg.py` (create) + +**Interfaces:** +- Consumes: `phase_pose_track`, `phase_pose_track_l1` (Task 2) ; `randomize_phase` (Task 3). +- Produces: `make_microduck_ground_pick_env_cfg(play=False, rough=False)` renvoie une cfg dont : commande `GroundPickPhaseCommand` avec `randomize_phase=False`, `period=4.0` ; rewards contiennent `phase_pose_track` (6.0) et `phase_pose_track_l1` (2.0), `mouth_ground_proximity` (1.0) ; ne contiennent plus `mouth_perpendicular_to_ground`, `ground_pick_return_pose_legs`, `ground_pick_return_pose_neck`. + +- [ ] **Step 1: Write the failing test** + +Créer `tests/test_ground_pick_cfg.py` : + +```python +from mjlab_microduck.tasks.microduck_ground_pick_env_cfg import ( + make_microduck_ground_pick_env_cfg, +) +from mjlab_microduck.tasks.mdp import GroundPickPhaseCommand + + +def test_ground_pick_cfg_builds_with_pose_rewards(): + cfg = make_microduck_ground_pick_env_cfg() + rewards = cfg.rewards + assert "phase_pose_track" in rewards + assert "phase_pose_track_l1" in rewards + assert rewards["phase_pose_track"].weight == 6.0 + assert rewards["phase_pose_track_l1"].weight == 2.0 + # filet bouche-sol conservé mais allégé + assert "mouth_ground_proximity" in rewards + assert rewards["mouth_ground_proximity"].weight == 1.0 + # anciennes mécaniques retirées + assert "mouth_perpendicular_to_ground" not in rewards + assert "ground_pick_return_pose_legs" not in rewards + assert "ground_pick_return_pose_neck" not in rewards + + +def test_ground_pick_cfg_command_is_phase_no_randomize(): + cfg = make_microduck_ground_pick_env_cfg() + cmd = cfg.commands["twist"] + assert cmd.class_type is GroundPickPhaseCommand + assert cmd.period == 4.0 + assert cmd.randomize_phase is False +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run --with pytest pytest tests/test_ground_pick_cfg.py -q` +Expected: FAIL — `assert 'phase_pose_track' in rewards` (KeyError/False). + +- [ ] **Step 3: Write minimal implementation** + +Dans `src/mjlab_microduck/tasks/microduck_ground_pick_env_cfg.py` : + +(a) Ajouter les constantes de poses/phase juste avant `def make_microduck_ground_pick_env_cfg(` : + +```python +# ── Poses cibles du geste (rad, par NOM) ────────────────────────────────────── +# STAND = HOME (default_joint_pos du modèle) — ne pas redéfinir ici : source du +# blend. DOWN = pli avant profond (bouche vers le sol), valeurs initiales tirées +# du keyframe FOLD de scene_walk.xml. ⚠️ REMPLAÇABLE par une lecture read_pose.py +# du vrai robot posé bouche-au-sol quand disponible. +DOWN_POSE = { + "left_hip_yaw": 0.0, "left_hip_roll": 0.0, "left_hip_pitch": 1.57, + "left_knee": 1.57, "left_ankle": 0.0, + "neck_pitch": 1.0, "head_pitch": 1.0, "head_yaw": 0.0, "head_roll": 0.0, + "right_hip_yaw": 0.0, "right_hip_roll": 0.0, "right_hip_pitch": -1.57, + "right_knee": -1.57, "right_ankle": 0.0, +} + +# Timing du cycle (fractions de phase), période 4 s : +# descente [0, DESCENT_END) ~0.6s / bas [DESCENT_END, HOLD_END) ~1.4s / +# remontée [HOLD_END, RISE_END) ~0.6s / repos [RISE_END, 1) ~1.4s +GP_PERIOD = 4.0 +DESCENT_END = 0.15 +HOLD_END = 0.50 +RISE_END = 0.65 +POSE_STD = 0.3 +``` + +(b) Dans la boucle de suppression des rewards (~ligne 145-155), remplacer le contenu du geste. **Retirer** les deux blocs `mouth_perpendicular_to_ground` (~176-183) et les deux `ground_pick_return_pose_*` (~189-212), et **retuner** `mouth_ground_proximity` à `weight=1.0` (~163-172, changer `weight=2.0` → `weight=1.0`). + +Concrètement : +- Éditer le bloc `cfg.rewards["mouth_ground_proximity"]` : `weight=2.0` → `weight=1.0`. +- Supprimer entièrement le bloc `cfg.rewards["mouth_perpendicular_to_ground"] = RewardTermCfg(...)`. +- Supprimer les blocs `_LEG_JOINTS = [...]` / `cfg.rewards["ground_pick_return_pose_legs"]` et `_NECK_JOINTS = [...]` / `cfg.rewards["ground_pick_return_pose_neck"]`. +- Retirer `"pose"` de la liste de suppression de rewards si présent (inchangé) — mais **retirer** aussi la ligne de commentaire `# replaced by phase-conditioned ground_pick_return_pose` devenue obsolète (optionnel). + +(c) Ajouter les deux nouveaux rewards de suivi de pose (à la place des blocs retirés, dans la section « main ground pick objectives ») : + +```python + # Suivi de pose interpolée par la phase (STAND<->DOWN<->STAND). Directif et + # symétrique : le retour debout est récompensé exactement comme la descente. + cfg.rewards["phase_pose_track"] = RewardTermCfg( + func=microduck_mdp.phase_pose_track, + weight=6.0, + params={ + "command_name": "twist", + "target_pose": DOWN_POSE, + "std": POSE_STD, + "descent_end": DESCENT_END, + "hold_end": HOLD_END, + "rise_end": RISE_END, + "asset_cfg": SceneEntityCfg("robot"), + }, + ) + cfg.rewards["phase_pose_track_l1"] = RewardTermCfg( + func=microduck_mdp.phase_pose_track_l1, + weight=2.0, + params={ + "command_name": "twist", + "target_pose": DOWN_POSE, + "descent_end": DESCENT_END, + "hold_end": HOLD_END, + "rise_end": RISE_END, + "asset_cfg": SceneEntityCfg("robot"), + }, + ) +``` + +(d) Dans le bloc « Command » (~ligne 368), passer la période et désactiver la randomisation de phase : + +Remplacer : +```python + cfg.commands["twist"] = microduck_mdp.GroundPickPhaseCommandCfg( + **{**vars(command), "class_type": microduck_mdp.GroundPickPhaseCommand} + ) +``` +par : +```python + cfg.commands["twist"] = microduck_mdp.GroundPickPhaseCommandCfg( + **{ + **vars(command), + "class_type": microduck_mdp.GroundPickPhaseCommand, + "period": GP_PERIOD, + "randomize_phase": False, + } + ) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run --with pytest pytest tests/test_ground_pick_cfg.py -q` +Expected: PASS (2 passed). + +Puis vérifier que l'ensemble de la suite passe : +Run: `uv run --with pytest pytest tests/ -q` +Expected: PASS (tous). + +- [ ] **Step 5: Commit** + +```bash +git add tests/test_ground_pick_cfg.py src/mjlab_microduck/tasks/microduck_ground_pick_env_cfg.py +git commit -m "feat(ground_pick): suivi de pose interpolée par la phase (STAND->DOWN->STAND)" +``` + +--- + +### Task 5: Vérification de bout en bout (construction runtime de la tâche) + +**Files:** +- Test: `tests/test_ground_pick_cfg.py` (append) + +**Interfaces:** +- Consumes: tout ce qui précède. + +- [ ] **Step 1: Write the failing/uncovered test** + +Ajouter à `tests/test_ground_pick_cfg.py` : + +```python +def test_ground_pick_rough_variant_builds(): + cfg = make_microduck_ground_pick_env_cfg(rough=True) + assert "phase_pose_track" in cfg.rewards + + +def test_ground_pick_play_variant_builds(): + cfg = make_microduck_ground_pick_env_cfg(play=True) + assert cfg.commands["twist"].randomize_phase is False +``` + +- [ ] **Step 2: Run to verify** + +Run: `uv run --with pytest pytest tests/test_ground_pick_cfg.py -q` +Expected: PASS. + +- [ ] **Step 3: Vérifier l'enregistrement de la tâche (import du package)** + +Run: `uv run python -c "import mjlab_microduck.tasks; print('ok')"` +Expected: affiche les lignes `✓ ... registered` dont `GroundPick`, puis `ok`, sans exception. + +- [ ] **Step 4: Commit** + +```bash +git add tests/test_ground_pick_cfg.py +git commit -m "test(ground_pick): variantes rough/play + import du package" +``` + +--- + +## Self-Review + +**1. Spec coverage :** +- §1 objectif directif par pose → Tasks 1,2,4. ✓ +- §2 poses (STAND=HOME source, DOWN=FOLD par nom) → Task 4 (a), Task 2 (`source_pose=None`→default). ✓ +- §3 profil 4 segments période 4 s + `randomize_phase=False` → Task 1, Task 3, Task 4 (a,d). ✓ +- §4 fonctions mdp `phase_pose_blend/track/_l1` par nom → Tasks 1,2. ✓ +- §5 rewards (ajouts + retraits + retune mouth 1.0) → Task 4 (b,c), test Task 4. ✓ +- §6 déploiement (période 4, kp-ratio 1.0) → documenté dans spec ; period=4 vérifié en test Task 4. ✓ +- §7 tests (fonctions pures + construction env) → Tasks 1,2,4,5. ✓ +- §9 doublon `pose_target_match` hors scope → non modifié (conforme). ✓ + +**2. Placeholder scan :** aucun TODO/TBD ; tout le code est fourni. ✓ + +**3. Type consistency :** `phase_pose_track(target_pose=..., std=..., asset_cfg=...)` et `phase_pose_track_l1(target_pose=..., asset_cfg=...)` identiques entre Task 2 (def), Task 4 (appel) et tests. `randomize_phase` cohérent entre Task 3 (def) et Task 4/tests (usage). `GroundPickPhaseCommand`/`GroundPickPhaseCommandCfg` noms inchangés. ✓ diff --git a/docs/superpowers/plans/2026-07-24-shoot-pose-following.md b/docs/superpowers/plans/2026-07-24-shoot-pose-following.md new file mode 100644 index 0000000..696600b --- /dev/null +++ b/docs/superpowers/plans/2026-07-24-shoot-pose-following.md @@ -0,0 +1,774 @@ +# Tâche shoot par suivi de poses — Plan d'implémentation + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ajouter une tâche RL `Mjlab-Shoot-Flat-MicroDuck` qui apprend un geste de shoot one-shot (jambe droite) par suivi d'une trajectoire de poses à 4 keyframes (STAND → PIED_ARRIÈRE → PIED_AVANT → STAND) interpolée par la phase. + +**Architecture:** Même moule que la tâche `ground_pick` de cette branche. Une commande de phase (`GroundPickPhaseCommand`, `[cos,sin,0]`) pilote une cible articulaire interpolée entre 3 poses ; des rewards gaussien + L1 récompensent le suivi ; obs 61D unifiée pour déploiement dans un slot bouton du runtime. Aucune balle simulée. + +**Tech Stack:** Python, PyTorch, mjlab 1.3.0, MuJoCo, uv, pytest. + +## Global Constraints + +- Obs **61D unifiée** identique aux autres policies microduck (`[gyro(3), projected_gravity(3), joint_pos(14), joint_vel(14), last_action(14), command(13)]`, head+body command zero-paddés). Ne pas casser cette forme. +- Résolution des joints **PAR NOM** (`asset.find_joints([name])`), jamais par index en dur. +- **14 joints** actifs (mouth exclu). Robot `MICRODUCK_WALK_ROBOT_CFG`. +- Ne pas modifier le runtime Rust ni la classe de commande de façon cassante : le flag `randomize_phase` ajouté DOIT défaut à `True` pour préserver `ground_pick`. +- Jambe **droite** frappe, **gauche** en appui. +- Tests : `uv run --with pytest pytest tests/ -q`. +- Convention commits : messages en français, style `feat:`/`docs:`/`test:`. + +--- + +## File Structure + +- `src/mjlab_microduck/tasks/mdp.py` — MODIFIER : ajouter `kick_pose_target` (pure), `_kick_pose_error`, `kick_pose_track`, `kick_pose_track_l1` ; ajouter le flag `randomize_phase` à `GroundPickPhaseCommand` / `GroundPickPhaseCommandCfg`. +- `src/mjlab_microduck/tasks/microduck_shoot_env_cfg.py` — CRÉER : `make_microduck_shoot_env_cfg`, `MicroduckShootRlCfg`, `STAND_POSE`/`KICK_BACK_POSE`/`KICK_FWD_POSE`, timings. +- `src/mjlab_microduck/tasks/__init__.py` — MODIFIER : import + `register_mjlab_task("Mjlab-Shoot-Flat-MicroDuck", …)`. +- `tests/test_shoot.py` — CRÉER : tests des fonctions pures (`kick_pose_target`) + rewards via stub-env. +- `tests/test_shoot_cfg.py` — CRÉER : test d'intégration (l'env se construit, bonne commande/rewards). + +--- + +### Task 1: Flag `randomize_phase` sur la commande de phase + +**Files:** +- Modify: `src/mjlab_microduck/tasks/mdp.py:3618-3672` (`GroundPickPhaseCommand` + `GroundPickPhaseCommandCfg`) +- Test: `tests/test_shoot.py` + +**Interfaces:** +- Produces: `GroundPickPhaseCommandCfg(randomize_phase: bool = True, period: float = 4.0, …)` ; à l'exécution `reset()` met φ=0 quand `randomize_phase=False`, sinon `rand()`. + +- [ ] **Step 1: Écrire le test qui échoue** + +Créer `tests/test_shoot.py` avec : + +```python +from mjlab_microduck.tasks.mdp import GroundPickPhaseCommandCfg + + +def test_phase_cmd_randomize_flag_default_true(): + cfg = GroundPickPhaseCommandCfg() + assert cfg.randomize_phase is True + + +def test_phase_cmd_randomize_flag_settable_false(): + cfg = GroundPickPhaseCommandCfg(randomize_phase=False) + assert cfg.randomize_phase is False +``` + +- [ ] **Step 2: Lancer le test, vérifier l'échec** + +Run: `uv run --with pytest pytest tests/test_shoot.py -q` +Expected: FAIL — `TypeError: __init__() got an unexpected keyword argument 'randomize_phase'`. + +- [ ] **Step 3: Ajouter le champ au cfg + threading dans la classe** + +Dans `GroundPickPhaseCommandCfg` (dataclass, ~ligne 3667) ajouter le champ : + +```python +@_dataclass(kw_only=True) +class GroundPickPhaseCommandCfg(UniformVelocityCommandCfg): + class_type: type = GroundPickPhaseCommand + period: float = 4.0 # cycle length in seconds; sitstand uses 8.0 + randomize_phase: bool = True # False -> chaque épisode démarre à φ=0 (STAND) + + def build(self, env: ManagerBasedRlEnv) -> "GroundPickPhaseCommand": + return GroundPickPhaseCommand(self, env) +``` + +Dans `GroundPickPhaseCommand.__init__` (~ligne 3634) lire le flag : + +```python + def __init__(self, cfg, env: ManagerBasedRlEnv): + super().__init__(cfg, env) + self._gp_phase = torch.zeros(self.num_envs, device=self.device) + self._period = float(getattr(cfg, "period", self.PERIOD)) + self._randomize_phase = bool(getattr(cfg, "randomize_phase", True)) +``` + +Dans `GroundPickPhaseCommand.reset` (~ligne 3649) respecter le flag : + +```python + def reset(self, env_ids: torch.Tensor | None) -> dict: + if env_ids is not None and len(env_ids) > 0: + if self._randomize_phase: + self._gp_phase[env_ids] = torch.rand(len(env_ids), device=self.device) + else: + self._gp_phase[env_ids] = 0.0 + return {} +``` + +- [ ] **Step 4: Lancer le test, vérifier le succès** + +Run: `uv run --with pytest pytest tests/test_shoot.py -q` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/mjlab_microduck/tasks/mdp.py tests/test_shoot.py +git commit -m "feat: flag randomize_phase sur GroundPickPhaseCommand (défaut True)" +``` + +--- + +### Task 2: Fonction pure `kick_pose_target` + +**Files:** +- Modify: `src/mjlab_microduck/tasks/mdp.py` (ajouter près de `phase_pose_blend`, ~ligne 2062) +- Test: `tests/test_shoot.py` + +**Interfaces:** +- Produces: `kick_pose_target(phase: Tensor(B,), stand, back, forward, windup_end: float, kick_end: float, return_end: float) -> Tensor(B,k)`. `stand/back/forward` sont des tenseurs `(k,)` ou `(1,k)`. Segments : [0,windup_end) STAND→BACK, [windup_end,kick_end) BACK→FORWARD, [kick_end,return_end) FORWARD→STAND, [return_end,1) STAND. + +- [ ] **Step 1: Écrire les tests qui échouent** + +Ajouter à `tests/test_shoot.py` : + +```python +import torch +from mjlab_microduck.tasks.mdp import kick_pose_target + +W, K, R = 0.35, 0.45, 0.75 # windup_end, kick_end, return_end +STAND = torch.tensor([0.0, 0.0]) +BACK = torch.tensor([1.0, -1.0]) +FWD = torch.tensor([-1.0, 2.0]) + + +def _t(phase): + return kick_pose_target(torch.tensor([phase]), STAND, BACK, FWD, W, K, R)[0] + + +def test_kick_target_keypoints(): + assert torch.allclose(_t(0.0), STAND) # début: STAND + assert torch.allclose(_t(W), BACK) # fin armement: BACK + assert torch.allclose(_t(K), FWD) # fin frappe: FORWARD + assert torch.allclose(_t(R), STAND) # fin retour: STAND + assert torch.allclose(_t(0.9), STAND) # repos: STAND + + +def test_kick_target_midsegments(): + assert torch.allclose(_t(W / 2), 0.5 * BACK) # mi-armement + assert torch.allclose(_t((W + K) / 2), 0.5 * (BACK + FWD)) # mi-frappe + assert torch.allclose(_t((K + R) / 2), 0.5 * FWD) # mi-retour + + +def test_kick_target_batch_shape(): + phase = torch.linspace(0.0, 1.0, 50) + out = kick_pose_target(phase, STAND, BACK, FWD, W, K, R) + assert out.shape == (50, 2) + # chaque composante reste dans l'enveloppe des 3 poses + lo = torch.minimum(torch.minimum(STAND, BACK), FWD) + hi = torch.maximum(torch.maximum(STAND, BACK), FWD) + assert (out >= lo - 1e-6).all() and (out <= hi + 1e-6).all() +``` + +- [ ] **Step 2: Lancer, vérifier l'échec** + +Run: `uv run --with pytest pytest tests/test_shoot.py -q` +Expected: FAIL — `ImportError: cannot import name 'kick_pose_target'`. + +- [ ] **Step 3: Implémenter la fonction pure** + +Ajouter dans `mdp.py` juste après `phase_pose_blend` (~ligne 2062) : + +```python +def kick_pose_target( + phase: torch.Tensor, + stand: torch.Tensor, + back: torch.Tensor, + forward: torch.Tensor, + windup_end: float, + kick_end: float, + return_end: float, +) -> torch.Tensor: + """Cible articulaire interpolée d'un geste de shoot à 4 keyframes. + + phase (B,) ∈ [0,1). stand/back/forward (k,) ou (1,k). Retour (B,k). + + [0, windup_end) STAND -> BACK (armement) + [windup_end, kick_end) BACK -> FORWARD (frappe sèche) + [kick_end, return_end) FORWARD -> STAND (retour) + [return_end, 1.0) STAND (repos) + """ + p = phase.unsqueeze(-1) # (B,1) + + def interp(a, b, s): + return a + s * (b - a) + + s1 = (p / windup_end).clamp(0.0, 1.0) + s2 = ((p - windup_end) / (kick_end - windup_end)).clamp(0.0, 1.0) + s3 = ((p - kick_end) / (return_end - kick_end)).clamp(0.0, 1.0) + + seg1 = interp(stand, back, s1) + seg2 = interp(back, forward, s2) + seg3 = interp(forward, stand, s3) # à s3=1 (phase>=return_end) => STAND + + out = seg1 + out = torch.where(p >= windup_end, seg2, out) + out = torch.where(p >= kick_end, seg3, out) + return out +``` + +- [ ] **Step 4: Lancer, vérifier le succès** + +Run: `uv run --with pytest pytest tests/test_shoot.py -q` +Expected: PASS (tous les tests kick_target). + +- [ ] **Step 5: Commit** + +```bash +git add src/mjlab_microduck/tasks/mdp.py tests/test_shoot.py +git commit -m "feat: kick_pose_target — cible interpolée du geste de shoot (4 keyframes)" +``` + +--- + +### Task 3: Rewards de suivi `kick_pose_track` / `kick_pose_track_l1` + +**Files:** +- Modify: `src/mjlab_microduck/tasks/mdp.py` (ajouter après `kick_pose_target`) +- Test: `tests/test_shoot.py` + +**Interfaces:** +- Consumes: `kick_pose_target` (Task 2). +- Produces: + - `kick_pose_track(env, command_name="twist", stand_pose=None, back_pose=None, forward_pose=None, std=0.4, windup_end=0.35, kick_end=0.45, return_end=0.75, asset_cfg=_DEFAULT_ASSET_CFG) -> Tensor(B,)` — gaussienne `exp(-((q-cible)/std)²).mean`. + - `kick_pose_track_l1(env, …mêmes args sauf std) -> Tensor(B,)` — `-(|q-cible|).mean`. + - Helper `_kick_pose_error(env, asset_cfg, command_name, stand_pose, back_pose, forward_pose, windup_end, kick_end, return_end) -> (cur, target)`. + +- [ ] **Step 1: Écrire le test qui échoue (stub-env)** + +Ajouter à `tests/test_shoot.py` : + +```python +from mjlab_microduck.tasks.mdp import kick_pose_track, kick_pose_track_l1 + +STAND_D = {"a": 0.0, "b": 0.0} +BACK_D = {"a": 1.0, "b": -1.0} +FWD_D = {"a": -1.0, "b": 2.0} +_IDX = {"a": 0, "b": 1} + + +class _FakeData: + def __init__(self, joint_pos): + self.joint_pos = joint_pos + self.default_joint_pos = torch.zeros_like(joint_pos) + + +class _FakeAsset: + def __init__(self, joint_pos): + self.data = _FakeData(joint_pos) + + def find_joints(self, names): + return ([_IDX[names[0]]], names) + + +class _FakeScene: + def __init__(self, asset): + self._a = asset + + def __getitem__(self, name): + return self._a + + +class _FakeCmdMgr: + def __init__(self, cmd): + self._cmd = cmd + + def get_command(self, name): + return self._cmd + + +class _FakeEnv: + def __init__(self, joint_pos, phase): + self.scene = _FakeScene(_FakeAsset(joint_pos)) + # cmd = [cos, sin, 0] + cmd = torch.stack( + [torch.cos(2 * torch.pi * phase), torch.sin(2 * torch.pi * phase), + torch.zeros_like(phase)], dim=-1) + self.command_manager = _FakeCmdMgr(cmd) + self.device = "cpu" + self.num_envs = joint_pos.shape[0] + + +def test_kick_track_perfect_at_stand_phase(): + # phase=0 -> cible STAND=[0,0] ; joint_pos exactement STAND -> reward ~1 + env = _FakeEnv(torch.tensor([[0.0, 0.0]]), torch.tensor([0.0])) + r = kick_pose_track(env, stand_pose=STAND_D, back_pose=BACK_D, forward_pose=FWD_D) + assert torch.allclose(r, torch.tensor([1.0]), atol=1e-4) + + +def test_kick_track_lower_when_off_target(): + # phase=0.45 (kick_end) -> cible FORWARD=[-1,2] ; joint_pos=STAND -> reward < 0.5 + env = _FakeEnv(torch.tensor([[0.0, 0.0]]), torch.tensor([0.45])) + r = kick_pose_track(env, stand_pose=STAND_D, back_pose=BACK_D, forward_pose=FWD_D) + assert (r < 0.5).all() + + +def test_kick_track_l1_zero_when_perfect(): + env = _FakeEnv(torch.tensor([[0.0, 0.0]]), torch.tensor([0.0])) + r = kick_pose_track_l1(env, stand_pose=STAND_D, back_pose=BACK_D, forward_pose=FWD_D) + assert torch.allclose(r, torch.tensor([0.0]), atol=1e-6) +``` + +- [ ] **Step 2: Lancer, vérifier l'échec** + +Run: `uv run --with pytest pytest tests/test_shoot.py -q` +Expected: FAIL — `ImportError: cannot import name 'kick_pose_track'`. + +- [ ] **Step 3: Implémenter helper + rewards** + +Ajouter dans `mdp.py` après `kick_pose_target` : + +```python +def _kick_pose_error( + env: ManagerBasedRlEnv, + asset_cfg: SceneEntityCfg, + command_name: str, + stand_pose: dict, + back_pose: dict, + forward_pose: dict, + windup_end: float, + kick_end: float, + return_end: float, +): + """(cur, target) pour le geste de shoot, joints résolus PAR NOM. + + Les 3 poses partagent les mêmes clés (14 joints). L'ordre des noms est + donné par `stand_pose`. + """ + if not stand_pose: + raise ValueError("_kick_pose_error requires a non-empty stand_pose dict") + asset: Entity = env.scene[asset_cfg.name] + names = list(stand_pose.keys()) + ids = [int(asset.find_joints([n])[0][0]) for n in names] + + def vec(d): + return torch.tensor([d[n] for n in names], device=env.device, + dtype=asset.data.joint_pos.dtype) + + stand_v, back_v, fwd_v = vec(stand_pose), vec(back_pose), vec(forward_pose) + + cmd = env.command_manager.get_command(command_name) + phase = (torch.atan2(cmd[:, 1], cmd[:, 0]) / (2 * torch.pi)) % 1.0 # (B,) + target = kick_pose_target(phase, stand_v, back_v, fwd_v, + windup_end, kick_end, return_end) # (B,k) + cur = asset.data.joint_pos[:, ids] # (B,k) + return cur, target + + +def kick_pose_track( + env: ManagerBasedRlEnv, + command_name: str = "twist", + stand_pose: Optional[dict] = None, + back_pose: Optional[dict] = None, + forward_pose: Optional[dict] = None, + std: float = 0.4, + windup_end: float = 0.35, + kick_end: float = 0.45, + return_end: float = 0.75, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """Gaussienne sur la pose articulaire vs cible interpolée du shoot. + + Reward directif et symétrique : chaque phase impose la config articulaire + exacte. Résolution PAR NOM. + """ + cur, target = _kick_pose_error( + env, asset_cfg, command_name, stand_pose or {}, back_pose or {}, + forward_pose or {}, windup_end, kick_end, return_end, + ) + return torch.exp(-((cur - target) / std) ** 2).mean(dim=-1) + + +def kick_pose_track_l1( + env: ManagerBasedRlEnv, + command_name: str = "twist", + stand_pose: Optional[dict] = None, + back_pose: Optional[dict] = None, + forward_pose: Optional[dict] = None, + windup_end: float = 0.35, + kick_end: float = 0.45, + return_end: float = 0.75, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """Bootstrap L1 vers la cible interpolée (gradient constant, pénalité<=0).""" + cur, target = _kick_pose_error( + env, asset_cfg, command_name, stand_pose or {}, back_pose or {}, + forward_pose or {}, windup_end, kick_end, return_end, + ) + return -(cur - target).abs().mean(dim=-1) +``` + +- [ ] **Step 4: Lancer, vérifier le succès** + +Run: `uv run --with pytest pytest tests/test_shoot.py -q` +Expected: PASS (tous les tests, y compris les 3 nouveaux). + +- [ ] **Step 5: Commit** + +```bash +git add src/mjlab_microduck/tasks/mdp.py tests/test_shoot.py +git commit -m "feat: rewards kick_pose_track + kick_pose_track_l1 (suivi du geste de shoot)" +``` + +--- + +### Task 4: Env config `microduck_shoot_env_cfg.py` + +**Files:** +- Create: `src/mjlab_microduck/tasks/microduck_shoot_env_cfg.py` +- Test: (via Task 5) + +**Interfaces:** +- Consumes: `kick_pose_track`, `kick_pose_track_l1` (Task 3) ; `GroundPickPhaseCommandCfg(randomize_phase=…)` (Task 1) ; `feet_grounded_reward`, `feet_flat_penalty`, `neck_action_rate_l2`, `joint_torques_l2`, `zero_command_padding`, `robot_state_is_nan`, DR events (existants dans `mdp.py`). +- Produces: `make_microduck_shoot_env_cfg(play=False, rough=False) -> ManagerBasedRlEnvCfg` ; `MicroduckShootRlCfg` ; constantes `SHOOT_PERIOD`, `WINDUP_END`, `KICK_END`, `RETURN_END`, `STAND_POSE`, `KICK_BACK_POSE`, `KICK_FWD_POSE`. + +- [ ] **Step 1: Partir du fichier ground_pick comme base** + +```bash +cp src/mjlab_microduck/tasks/microduck_ground_pick_env_cfg.py \ + src/mjlab_microduck/tasks/microduck_shoot_env_cfg.py +``` + +Ce fichier fournit déjà TOUT le boilerplate sim2real à conserver tel quel : DR (CoM, head CoM, mass/inertia, friction BAM, armature, IMU misalignment obs-level, encoder-bias, pushes), le bloc obs 61D (`del base_lin_vel` actor, critic base_lin_vel, suppression `foot_height`/`height_scan`, delays/noise, `head_command`/`body_command` zero-padding), la terminaison `nan_state`, les events `expand_bam_friction_fields` / `reset_action_history`, le curriculum action_rate/CoM. On ne modifie que : robot cfg, capteurs, commande, et le bloc rewards. + +- [ ] **Step 2: Adapter l'en-tête, le nom de fonction et les constantes** + +Remplacer le docstring de tête par une description shoot, et juste avant `def make_microduck_ground_pick_env_cfg`, ajouter les constantes + poses (placeholders — à remplacer par lecture `read_pose.py`). Renommer la fonction en `make_microduck_shoot_env_cfg`. + +```python +# ── Timings du geste (phase normalisée [0,1)) ──────────────────────────────── +SHOOT_PERIOD = 2.5 # s — durée d'un cycle (doit matcher --ground-pick-period au déploiement) +WINDUP_END = 0.35 # STAND -> BACK +KICK_END = 0.45 # BACK -> FORWARD (segment court = frappe sèche) +RETURN_END = 0.75 # FORWARD -> STAND, puis repos jusqu'à 1.0 + +# ── Poses (rad, 14 joints, mouth exclu) ────────────────────────────────────── +# Convention: jambe droite frappe (hanche/genou droit actifs), gauche en appui. +# STAND_POSE = pose HOME du sim (HOME_FRAME / default_joint_pos) pour que φ=0 +# coïncide avec la config de reset (invariant randomize_phase=False). BACK/FWD +# sont des PLACEHOLDERS jambe droite, à affiner via read_pose.py. +STAND_POSE = { + "left_hip_yaw": 0.0, "left_hip_roll": -0.0873, "left_hip_pitch": -0.4579, + "left_knee": -0.0049, "left_ankle": 0.4530, + "neck_pitch": 0.3491, "head_pitch": 0.3491, "head_yaw": 0.0, "head_roll": 0.0, + "right_hip_yaw": 0.0, "right_hip_roll": 0.0873, "right_hip_pitch": 0.4579, + "right_knee": 0.0049, "right_ankle": -0.4530, +} +KICK_BACK_POSE = { # armement: hanche droite en extension arrière + genou fléchi + **STAND_POSE, + "right_hip_pitch": -0.6, + "right_knee": 0.8, + "right_ankle": -0.2, +} +KICK_FWD_POSE = { # frappe: hanche droite fléchie avant + genou tendu + **STAND_POSE, + "right_hip_pitch": 0.7, + "right_knee": -0.1, + "right_ankle": 0.1, +} +``` + +> NOTE au releveur de poses : remplacer ces valeurs par des lectures `read_pose.py` (couple coupé, robot posé à la main dans chaque position). Garder les 14 clés identiques dans les 3 dicts. + +- [ ] **Step 3: Robot cfg et import** + +Dans les imports, remplacer `MICRODUCK_GROUND_PICK_ROBOT_CFG` par `MICRODUCK_WALK_ROBOT_CFG` : + +```python +from mjlab_microduck.robot.microduck_constants import MICRODUCK_WALK_ROBOT_CFG +``` + +Dans la fonction, la ligne d'entités : + +```python + cfg.scene.entities = {"robot": MICRODUCK_WALK_ROBOT_CFG} +``` + +- [ ] **Step 4: Capteurs — garder self_collision, remplacer les capteurs pied** + +Remplacer la définition du capteur `feet_ground_contact` (2 pieds) par un capteur **pied gauche seul** (appui), et SUPPRIMER le capteur `head_impact_cfg` (inutile ici). Le capteur `self_collision_cfg` reste. + +```python + left_foot_ground_cfg = ContactSensorCfg( + name="left_foot_ground_contact", + primary=ContactMatch( + mode="geom", + pattern=r"^left_foot_collision$", + entity="robot", + ), + secondary=ContactMatch(mode="body", pattern="terrain"), + fields=("found", "force"), + reduce="netforce", + num_slots=1, + track_air_time=True, + ) +``` + +Et la ligne des capteurs de scène : + +```python + cfg.scene.sensors = (left_foot_ground_cfg, self_collision_cfg) +``` + +Supprimer la définition de `head_impact_cfg` et toute référence (le reward `head_impact_penalty` est retiré au Step 6). + +- [ ] **Step 5: Commande de phase (randomize_phase=False, période shoot)** + +Remplacer le bloc commande (celui qui crée `GroundPickPhaseCommandCfg`) par : + +```python + command: UniformVelocityCommandCfg = cfg.commands["twist"] + command.rel_standing_envs = 0.0 + command.rel_heading_envs = 0.0 + cfg.commands["twist"] = microduck_mdp.GroundPickPhaseCommandCfg( + **{**vars(command), "class_type": microduck_mdp.GroundPickPhaseCommand} + ) + cfg.commands["twist"].period = SHOOT_PERIOD + cfg.commands["twist"].randomize_phase = False +``` + +- [ ] **Step 6: Rewards — retirer ground_pick, ajouter shoot** + +Supprimer les rewards spécifiques ground_pick : `mouth_ground_proximity`, `mouth_perpendicular_to_ground`, `ground_pick_return_pose_legs`, `ground_pick_return_pose_neck`, `feet_grounded` (les 2 pieds), `head_impact_penalty`. Remplacer par le bloc shoot : + +```python + # ── Objectif : suivi de la pose interpolée du shoot ─────────────────────── + _pose_params = { + "command_name": "twist", + "stand_pose": STAND_POSE, + "back_pose": KICK_BACK_POSE, + "forward_pose": KICK_FWD_POSE, + "windup_end": WINDUP_END, + "kick_end": KICK_END, + "return_end": RETURN_END, + } + cfg.rewards["kick_pose_track"] = RewardTermCfg( + func=microduck_mdp.kick_pose_track, + weight=6.0, + params={**_pose_params, "std": 0.4}, + ) + cfg.rewards["kick_pose_l1"] = RewardTermCfg( + func=microduck_mdp.kick_pose_track_l1, + weight=2.0, + params=dict(_pose_params), + ) + + # ── Équilibre / appui (jambe unique) ────────────────────────────────────── + cfg.rewards["upright"].params["asset_cfg"].body_names = ("trunk_base",) + cfg.rewards["upright"].weight = 2.0 + cfg.rewards["body_ang_vel"].params["asset_cfg"].body_names = ("trunk_base",) + cfg.rewards["body_ang_vel"].weight = -0.05 + + # Pied GAUCHE planté (appui). feet_grounded_reward avec un capteur mono-pied + # -> found ∈ {0,1} -> reward ∈ {0,0.5} ; poids 6.0 => contribution max ~3.0. + cfg.rewards["support_foot_grounded"] = RewardTermCfg( + func=microduck_mdp.feet_grounded_reward, + weight=6.0, + params={"sensor_name": left_foot_ground_cfg.name}, + ) + + # Pied gauche à plat. + cfg.rewards["feet_flat_left"] = RewardTermCfg( + func=microduck_mdp.feet_flat_penalty, + weight=-1.0, + params={"asset_cfg": SceneEntityCfg("robot", site_names=("left_foot",))}, + ) + + cfg.rewards["self_collisions"] = RewardTermCfg( + func=mdp.self_collision_cost, + weight=-1.0, + params={"sensor_name": self_collision_cfg.name}, + ) +``` + +- [ ] **Step 7: Régularisation allégée (laisser passer le snap)** + +Le fichier ground_pick met `action_rate_l2=-2.0`, `neck_action_rate_l2=-1.0`, `joint_torques_l2=-5e-3` + un curriculum action_rate qui finit à -2.0. Pour le shoot on allège. Remplacer ces 3 blocs par : + +```python + cfg.rewards["action_rate_l2"] = RewardTermCfg( + func=mdp.action_rate_l2, weight=-0.5 + ) + cfg.rewards["neck_action_rate_l2"] = RewardTermCfg( + func=microduck_mdp.neck_action_rate_l2, weight=-0.5 + ) + cfg.rewards["joint_torques_l2"] = RewardTermCfg( + func=microduck_mdp.joint_torques_l2, weight=-1e-3 + ) +``` + +Et alléger le curriculum action_rate (garder la structure, viser -0.5) : + +```python + cfg.curriculum["action_rate_weight"] = CurriculumTermCfg( + func=microduck_mdp.reward_weight, + params={ + "reward_name": "action_rate_l2", + "weight_stages": [ + {"step": 0, "weight": -0.2}, + {"step": 250 * 24, "weight": -0.4}, + {"step": 500 * 24, "weight": -0.5}, + ], + }, + ) +``` + +- [ ] **Step 8: Reset — hauteur de station debout** + +Garder la **hauteur debout** `(0.12, 0.13)` — c'est la valeur de l'env velocity +(marche) ET de ground_pick. ⚠️ Ce n'est PAS un offset additif « station accroupie » : +le `pos` racine par défaut de `InitialStateCfg` est (0,0,0), donc la hauteur de reset +est z ∈ [0.12, 0.13] m **absolue** = debout (aucune chute). Vérifier/mettre : + +```python + cfg.events["reset_base"].params["pose_range"]["z"] = (0.12, 0.13) +``` + +(Ne PAS injecter de vitesse d'entrée — c'est un shoot debout, pas de glisse.) + +- [ ] **Step 9: Renommer la RlCfg** + +En bas du fichier, renommer `MicroduckGroundPickRlCfg` en `MicroduckShootRlCfg` et changer les noms d'expérience : + +```python +MicroduckShootRlCfg = RslRlOnPolicyRunnerCfg( + # … (garder actor/critic/algorithm identiques) … + wandb_project="mjlab_microduck", + experiment_name="shoot", + run_name="shoot", + save_interval=250, + num_steps_per_env=24, + max_iterations=20_000, +) +``` + +- [ ] **Step 10: Vérifier que le module s'importe** + +Run: `uv run python -c "from mjlab_microduck.tasks.microduck_shoot_env_cfg import make_microduck_shoot_env_cfg, MicroduckShootRlCfg; print('ok')"` +Expected: `ok` (pas d'ImportError / NameError — en particulier plus aucune référence à `head_impact_cfg`, `MICRODUCK_GROUND_PICK_ROBOT_CFG`, ni aux rewards ground_pick supprimés). + +- [ ] **Step 11: Commit** + +```bash +git add src/mjlab_microduck/tasks/microduck_shoot_env_cfg.py +git commit -m "feat: env config Mjlab-Shoot (geste de shoot par suivi de poses)" +``` + +--- + +### Task 5: Enregistrement + test d'intégration + +**Files:** +- Modify: `src/mjlab_microduck/tasks/__init__.py` +- Test: `tests/test_shoot_cfg.py` + +**Interfaces:** +- Consumes: `make_microduck_shoot_env_cfg`, `MicroduckShootRlCfg` (Task 4). +- Produces: tâche enregistrée `Mjlab-Shoot-Flat-MicroDuck`. + +- [ ] **Step 1: Écrire le test d'intégration qui échoue** + +Créer `tests/test_shoot_cfg.py` : + +```python +from mjlab_microduck.tasks.microduck_shoot_env_cfg import ( + make_microduck_shoot_env_cfg, + STAND_POSE, KICK_BACK_POSE, KICK_FWD_POSE, SHOOT_PERIOD, +) +from mjlab_microduck.tasks import mdp as microduck_mdp + + +def test_poses_have_same_14_keys(): + assert set(STAND_POSE) == set(KICK_BACK_POSE) == set(KICK_FWD_POSE) + assert len(STAND_POSE) == 14 + assert "mouth" not in STAND_POSE + + +def test_shoot_cfg_builds_with_phase_command(): + cfg = make_microduck_shoot_env_cfg() + twist = cfg.commands["twist"] + assert isinstance(twist, microduck_mdp.GroundPickPhaseCommandCfg) + assert twist.randomize_phase is False + assert twist.period == SHOOT_PERIOD + + +def test_shoot_cfg_has_kick_rewards_and_no_walking(): + cfg = make_microduck_shoot_env_cfg() + assert "kick_pose_track" in cfg.rewards + assert "kick_pose_l1" in cfg.rewards + assert "support_foot_grounded" in cfg.rewards + for gone in ("track_linear_velocity", "track_angular_velocity", + "mouth_ground_proximity", "ground_pick_return_pose_legs"): + assert gone not in cfg.rewards +``` + +- [ ] **Step 2: Lancer, vérifier l'échec** + +Run: `uv run --with pytest pytest tests/test_shoot_cfg.py -q` +Expected: PASS possible sur les tests de poses, mais l'ensemble doit être vert seulement une fois l'env construit sans erreur ; si `make_...` lève, FAIL. (À ce stade l'import du fichier fonctionne déjà via Task 4.) + +- [ ] **Step 3: Enregistrer la tâche** + +Dans `src/mjlab_microduck/tasks/__init__.py`, après le bloc d'import ground_pick (~ligne 50), ajouter : + +```python +from .microduck_shoot_env_cfg import ( + make_microduck_shoot_env_cfg, + MicroduckShootRlCfg, +) +``` + +Après le bloc `register_mjlab_task` de GroundPick-Rough (~ligne 161), ajouter : + +```python +register_mjlab_task( + task_id="Mjlab-Shoot-Flat-MicroDuck", + env_cfg=make_microduck_shoot_env_cfg(), + play_env_cfg=make_microduck_shoot_env_cfg(play=True), + rl_cfg=MicroduckShootRlCfg, + runner_cls=MicroduckOnPolicyRunner, +) +print("✓ Shoot task registered: Mjlab-Shoot-Flat-MicroDuck") +``` + +- [ ] **Step 4: Lancer tout, vérifier le succès** + +Run: `uv run --with pytest pytest tests/ -q` +Expected: PASS (test_shoot.py + test_shoot_cfg.py + tests existants). + +- [ ] **Step 5: Vérifier l'enregistrement de la tâche** + +Run: `uv run python -c "import mjlab_microduck.tasks"` +Expected: la sortie contient `✓ Shoot task registered: Mjlab-Shoot-Flat-MicroDuck`. + +- [ ] **Step 6: Commit** + +```bash +git add src/mjlab_microduck/tasks/__init__.py tests/test_shoot_cfg.py +git commit -m "feat: enregistre Mjlab-Shoot-Flat-MicroDuck + test d'intégration" +``` + +--- + +## Après implémentation (hors plan TDD) + +1. **Relever les vraies poses** avec `read_pose.py` (STAND, PIED_ARRIÈRE, PIED_AVANT), remplacer les placeholders dans `microduck_shoot_env_cfg.py`. +2. **Entraîner** : `uv run train Mjlab-Shoot-Flat-MicroDuck --env.scene.num-envs 4096 --agent.max_iterations 8000`. Surveiller `Episode_Reward/kick_pose_track` (doit monter). +3. **Play** : script play_latest ; vérifier l'équilibre sur le pied gauche pendant la frappe. +4. **Export ONNX** + déploiement dans un slot phase (`--ground-pick shoot.onnx --ground-pick-period 2.5 --ground-pick-kp-ratio 1.0`). +5. **Réglages probables** : période/timings (snap), poids `action_rate`, et éventuel reward « vitesse pied vers l'avant » (segment frappe) si le suivi manque de punch. + +## Self-review — couverture de la spec + +- Fichier & enregistrement → Tasks 4, 5. ✅ +- Poses placeholders 14 joints → Task 4 Step 2, testé Task 5. ✅ +- Commande de phase + `randomize_phase=False` + période → Tasks 1, 4 Step 5, testé Task 5. ✅ +- `kick_pose_target` + `kick_pose_track` + `kick_pose_track_l1` → Tasks 2, 3. ✅ +- Équilibre/appui (upright, pied gauche planté, feet_flat gauche, self_collisions, body_ang_vel) → Task 4 Step 6. ✅ +- Régularisation allégée → Task 4 Step 7. ✅ +- Obs 61D parité (hérité ground_pick, conservé) → Task 4 Step 1. ✅ +- Tests pures + cfg → Tasks 2, 3, 5. ✅ diff --git a/docs/superpowers/plans/2026-07-27-swizzle-head-control.md b/docs/superpowers/plans/2026-07-27-swizzle-head-control.md new file mode 100644 index 0000000..3690f1f --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-swizzle-head-control.md @@ -0,0 +1,204 @@ +# Swizzle Head Control Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add operator head-pose control (Y button) to the swizzle roller task so the policy moves its head to commanded poses while staying balanced. + +**Architecture:** Policy-managed head via the observation command (matches the walking `--new-cmd-obs` path). The swizzle env currently zero-pads the `head_command` obs slot; we feed a real `head_pose` command into it, reward `head_pose_tracking`, remove the two reward terms that pull the neck/head to HOME (which would fight the command), and ramp the head in LATE via a curriculum so the already-working swizzle isn't disturbed. Config-only change to one file; requires retraining. + +**Tech Stack:** mjlab / mjlab_microduck task configs (Python), rsl_rl PPO. Reuses machinery already in `microduck_velocity_env_cfg.py` (`UniformPoseCommandCfg`, `head_pose_tracking`, `pose_command_range_curriculum`, `reward_weight`). + +## Global Constraints + +- Only the swizzle task changes: `src/mjlab_microduck/tasks/microduck_velocity_swizzle_env_cfg.py`. The stride, velocity, standup, roller-slope/crouch tasks and `mdp.py` are NOT modified. +- Keep the 61D obs layout `[twist(3), head(4), body(6)]`: replace the `head_command` slot's contents (zero-pad → real command) but keep `body_command` zero-padded (no body-pose control here). +- No new mdp functions — all reward/command/curriculum functions already exist in `microduck_mdp`. +- Runtime unchanged: the `microduck_runtime` Y button already drives the `head_command` obs slot. + +--- + +### Task 1: Wire head-pose control into the swizzle env + +**Files:** +- Modify: `src/mjlab_microduck/tasks/microduck_velocity_swizzle_env_cfg.py` +- Test: `tests/test_swizzle_head_cfg.py` (create) + +**Interfaces:** +- Consumes (already exist, do not redefine): + - `microduck_mdp.UniformPoseCommandCfg(resampling_time_range, ranges)` — head-pose command term. + - `mdp.generated_commands` (from `mjlab.tasks.velocity`) — obs func reading a command by name; used as `params={"command_name": "head_pose"}`. + - `microduck_mdp.head_pose_tracking` — reward `func`, params `{"command_name": "head_pose", "std": 0.5}`. + - `microduck_mdp.reward_weight` — curriculum func, params `{"reward_name", "weight_stages": [{"step","weight"}, ...]}`. + - `microduck_mdp.pose_command_range_curriculum` — curriculum func, params `{"command_name", "range_stages": [{"step","ranges"}, ...]}`. +- Produces: the swizzle env cfg with a `head_pose` command, a real `head_command` obs, a `head_pose_tracking` reward, `neck_joint_pos_l2` removed, the `pose` reward scoped to leg joints, and two head curricula. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_swizzle_head_cfg.py`: + +```python +from mjlab.tasks.velocity import mdp +from mjlab_microduck.tasks.microduck_velocity_swizzle_env_cfg import ( + make_microduck_velocity_swizzle_env_cfg, +) + + +def test_swizzle_head_control_wired(): + cfg = make_microduck_velocity_swizzle_env_cfg() + + # Head-pose command term exists. + assert "head_pose" in cfg.commands + + # head_command obs is the REAL command (not zero-padded) on both groups. + for group in ("actor", "critic"): + term = cfg.observations[group].terms["head_command"] + assert term.func is mdp.generated_commands + assert term.params["command_name"] == "head_pose" + + # head_pose_tracking reward exists. + assert "head_pose_tracking" in cfg.rewards + + # The two HOME-pullers that would fight the head command are handled: + # - neck_joint_pos_l2 removed + assert "neck_joint_pos_l2" not in cfg.rewards + # - pose reward scoped to leg joints via a negative-lookahead regex that + # excludes neck/head (and passive wheels) + pose_joints = cfg.rewards["pose"].params["asset_cfg"].joint_names + assert any( + "(?!" in j and "neck" in j and "head" in j for j in pose_joints + ), f"pose reward not scoped away from neck/head: {pose_joints}" + + # Late head curricula exist. + assert "head_pose_tracking_weight" in cfg.curriculum + assert "head_pose_range" in cfg.curriculum +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `MUJOCO_GL=egl uv run pytest tests/test_swizzle_head_cfg.py -v` +Expected: FAIL (head_pose command / head_pose_tracking reward absent; head_command obs is still `zero_command_padding`). + +- [ ] **Step 3: Add imports to the swizzle env cfg** + +In `microduck_velocity_swizzle_env_cfg.py`, extend the imports (currently `from mjlab.managers import CurriculumTermCfg, RewardTermCfg`) to add `ObservationTermCfg`, and import the velocity mdp for `generated_commands`: + +```python +from mjlab.managers import CurriculumTermCfg, ObservationTermCfg, RewardTermCfg +from mjlab.managers.scene_entity_config import SceneEntityCfg +from mjlab.tasks.velocity import mdp +``` + +- [ ] **Step 4: Add the head_pose command + real head_command obs + head_pose_tracking reward + neck reconciliation** + +Inside `make_microduck_velocity_swizzle_env_cfg`, AFTER the existing reward/heading setup and BEFORE `return cfg`, add: + +```python + # --- Head-pose control (Y button): the policy produces the head pose --------- + # Head-pose command (4D deltas from HOME: [neck_pitch, head_pitch, head_yaw, + # head_roll]). Ported from the velocity env; ranges start small (widened by the + # curriculum below). Resample every 2-5 s. + cfg.commands["head_pose"] = microduck_mdp.UniformPoseCommandCfg( + resampling_time_range=(2.0, 5.0), + ranges=( + (-0.05, 0.05), # neck_pitch + (-0.05, 0.05), # head_pitch + (-0.07, 0.07), # head_yaw + (-0.015, 0.015), # head_roll (tighter — small mechanical range) + ), + ) + + # Feed the REAL head command into the obs (replaces zero_command_padding) on + # both groups. body_command stays zero-padded (no body-pose control here). + for group in ("actor", "critic"): + cfg.observations[group].terms["head_command"] = ObservationTermCfg( + func=mdp.generated_commands, + params={"command_name": "head_pose"}, + ) + + # Reward the head tracking its command. Weight 0 here — ramped in LATE by the + # curriculum so it doesn't disturb the swizzle before it's solid. + cfg.rewards["head_pose_tracking"] = RewardTermCfg( + func=microduck_mdp.head_pose_tracking, + weight=0.0, + params={"command_name": "head_pose", "std": 0.5}, + ) + + # Reconcile the two HOME-pullers that would fight head_pose_tracking: + # 1) neck_joint_pos_l2 pulls the neck/head joints to HOME -> remove it. + if "neck_joint_pos_l2" in cfg.rewards: + del cfg.rewards["neck_joint_pos_l2"] + # 2) the pose reward includes neck/head -> scope it to LEG joints only. + cfg.rewards["pose"].params["asset_cfg"] = SceneEntityCfg( + "robot", joint_names=(r"^(?!passive_|.*neck.*|.*head.*).*",) + ) +``` + +- [ ] **Step 5: Add the late head curricula** + +Immediately after the block from Step 4 (still before `return cfg`): + +```python + # head_pose_tracking ramps 0 -> 4.0, staying 0 until ~1500 it. (swizzle solid), + # so head control is added on top of a stable swizzle. + cfg.curriculum["head_pose_tracking_weight"] = CurriculumTermCfg( + func=microduck_mdp.reward_weight, + params={ + "reward_name": "head_pose_tracking", + "weight_stages": [ + {"step": 0, "weight": 0.0}, # must match initial weight + {"step": 1500 * 24, "weight": 0.0}, # head off while swizzle solidifies + {"step": 2250 * 24, "weight": 2.0}, + {"step": 3000 * 24, "weight": 4.0}, + ], + }, + ) + # Head-command range widens over the SAME window (tiny until 1500, full by 3000), + # so the commanded head barely moves early and reaches full range once the policy + # can handle it. + cfg.curriculum["head_pose_range"] = CurriculumTermCfg( + func=microduck_mdp.pose_command_range_curriculum, + params={ + "command_name": "head_pose", + "range_stages": [ + # step, ((neck_pitch), (head_pitch), (head_yaw), (head_roll)) + {"step": 0, "ranges": ((-0.05, 0.05), (-0.05, 0.05), (-0.07, 0.07), (-0.015, 0.015))}, + {"step": 1500 * 24, "ranges": ((-0.05, 0.05), (-0.05, 0.05), (-0.07, 0.07), (-0.015, 0.015))}, + {"step": 2250 * 24, "ranges": ((-0.55, 0.55), (-0.55, 0.55), (-0.70, 0.70), (-0.15, 0.15))}, + {"step": 3000 * 24, "ranges": ((-1.10, 1.10), (-1.10, 1.10), (-1.40, 1.40), (-0.31, 0.31))}, + ], + }, + ) +``` + +- [ ] **Step 6: Run the cfg test to verify it passes** + +Run: `MUJOCO_GL=egl uv run pytest tests/test_swizzle_head_cfg.py -v` +Expected: PASS. + +- [ ] **Step 7: Smoke test the env end-to-end** + +Run: `MUJOCO_GL=egl uv run train Mjlab-Velocity-Swizzle-MicroDuck --env.scene.num-envs 16 --agent.max-iterations 2` +Expected: no error; the reward log lists `head_pose_tracking` and no longer lists `neck_joint_pos_l2`; a `Curriculum/head_pose_tracking_weight` line appears at value 0.0. + +- [ ] **Step 8: Commit** + +```bash +git add src/mjlab_microduck/tasks/microduck_velocity_swizzle_env_cfg.py tests/test_swizzle_head_cfg.py +git commit -m "swizzle: add head-pose control (Y button, policy-managed, late curriculum)" +``` + +--- + +## Notes for the full training run (not part of the task) + +Because the head curriculum only finishes at ~3000 iters, train longer than the +2500 used before: + +```bash +uv run train Mjlab-Velocity-Swizzle-MicroDuck --env.scene.num-envs 4096 --agent.max-iterations 3500 +``` + +Watch: `head_pose_tracking` rises after ~1500 it.; the swizzle fall rate does NOT +spike when it kicks in. If the head disturbs the swizzle → push the kick-in later / +widen the range more slowly. If the head doesn't follow → raise the final weight. +Deploy unchanged (`--roller --new-cmd-obs`, Y button moves the head). diff --git a/docs/superpowers/plans/2026-08-04-roller-standup.md b/docs/superpowers/plans/2026-08-04-roller-standup.md new file mode 100644 index 0000000..31b1ec0 --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-roller-standup.md @@ -0,0 +1,1233 @@ +# Roller StandUp — Plan d'implémentation + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Une policy dédiée `Mjlab-RollerStandUp-Flat-MicroDuck` qui remet le microduck debout sur ses rollers après une chute (à plat ventre ou à plat dos) et qui sait tenir la station sur roues. + +**Architecture:** Un seul fichier d'env nouveau, dérivé de `make_microduck_velocity_rollers_env_cfg()` — il hérite ainsi du robot rollers, des capteurs, de toute la domain randomization et de l'observation 61D (condition dure pour l'interchangeabilité au runtime). On retire les récompenses de patinage, on greffe les dix récompenses de relevé du `standup` (remappées sur les indices de joints du modèle rollers, où les roues passives sont intercalées), on remplace le reset par un départ au sol, et on inverse le curriculum de friction de roulement (roues freinées → libres) pour bootstrapper le geste avant d'imposer la physique réelle des roues. + +**Tech Stack:** Python 3.12, mjlab 1.3.0, MuJoCo / mujoco-warp, rsl_rl (PPO), uv, pytest. + +Spec de référence : `docs/superpowers/specs/2026-08-04-roller-standup-design.md` + +## Global Constraints + +- **Aucune modification** de `src/mjlab_microduck/tasks/mdp.py`, ni des envs `roller`, `roller_crouch`, `roller_slope`, `standup`, `velstand`. Toutes les fonctions mdp nécessaires existent déjà. +- **Parité d'observation 61D obligatoire** avec `make_microduck_velocity_rollers_env_cfg()` : `[gyro(3), projected_gravity(3), joint_pos(14), joint_vel(14), last_action(14), command(13)]`. Les slots `head_pose` (4) et `body_pose` (6) restent **zero-paddés**. Sans cette parité l'ONNX ne se charge pas dans un slot du runtime. +- **Indices de joints du modèle rollers** (roues passives intercalées ; vérifiés dans MuJoCo) : + `_LEG_JOINTS = [0, 1, 2, 3, 4, 11, 12, 13, 14, 15]`, `_NECK_JOINTS = [7, 8, 9, 10]`, `_WHEEL_JOINTS = [5, 6, 16, 17]`. + Ne **jamais** réutiliser les indices du `standup` (`[0-4, 9-13]` / `[5-8]`), qui valent pour le modèle sans roues. +- **Hauteurs mesurées** : `ROLLER_STAND_Z = 0.138`, `ROLLER_PRONE_Z = 0.075`. Ne pas les remplacer par les valeurs du `standup` (0.115 / 0.07). +- `EPISODE_LENGTH_S = 6.0`, `NUM_STEPS_PER_ENV = 24`. Les `step` des curricula s'expriment en `iters × NUM_STEPS_PER_ENV`. +- **Symétrie OFF** : `symmetry_cfg=None`. `SYMMETRY_CFG` est câblé pour l'ancien layout 51D et casse sur le 61D. +- Style du repo : commentaires en français dans les envs roller, indentation 4 espaces, `SceneEntityCfg` **reconstruit à chaque terme** (jamais un objet partagé — mjlab résout et mute ces objets en place). +- Commits simples, sans `Co-Authored-By`. +- **Pré-existant, hors périmètre** : `tests/test_wheel_glide.py` a 4 tests en échec avant ce travail (faux asset avec une regex obsolète `passive_LF_?wheel`). Ne pas les corriger, ne pas s'en alarmer. Le reste de la suite passe (46 tests). + +--- + +## Structure des fichiers + +| Fichier | Responsabilité | +|---|---| +| `src/mjlab_microduck/tasks/microduck_roller_standup_env_cfg.py` (créer) | Toute la config de l'env + `MicroduckRollerStandUpRlCfg`. Un seul fichier, comme tous les autres envs du repo. | +| `src/mjlab_microduck/tasks/__init__.py` (modifier) | Import + `register_mjlab_task` de la nouvelle tâche. | +| `tests/test_roller_standup_cfg.py` (créer) | Tests de construction de config + verrou des indices de joints. Pas de sim, pas de GPU (comme `test_roller_slope_cfg.py`). | +| `docs/roller_standup_policy_summary.md` (créer, Task 5) | Résumé de passation, sur le modèle de `docs/roller_slope_policy_summary.md`. | + +--- + +## Task 1 : Squelette de l'env — dérivation, commande neutralisée, patinage retiré, enregistrement + +**Files:** +- Create: `src/mjlab_microduck/tasks/microduck_roller_standup_env_cfg.py` +- Modify: `src/mjlab_microduck/tasks/__init__.py` +- Test: `tests/test_roller_standup_cfg.py` + +**Interfaces:** +- Consumes: `make_microduck_velocity_rollers_env_cfg(play: bool = False) -> ManagerBasedRlEnvCfg` (existant), `microduck_mdp.VelocityCommandCommandOnlyCfg` (existant). +- Produces: `make_microduck_roller_standup_env_cfg(play: bool = False) -> ManagerBasedRlEnvCfg` ; `MicroduckRollerStandUpRlCfg: RslRlOnPolicyRunnerCfg` ; les constantes de module `ROLLER_STAND_Z: float`, `ROLLER_PRONE_Z: float`, `EPISODE_LENGTH_S: float`, `NUM_STEPS_PER_ENV: int`, `_LEG_JOINTS: list[int]`, `_NECK_JOINTS: list[int]`, `_WHEEL_JOINTS: list[int]` ; la tâche enregistrée `"Mjlab-RollerStandUp-Flat-MicroDuck"`. + +- [ ] **Step 1 : Écrire les tests qui échouent** + +Créer `tests/test_roller_standup_cfg.py` : + +```python +from mjlab_microduck.tasks.microduck_roller_standup_env_cfg import ( + EPISODE_LENGTH_S, + make_microduck_roller_standup_env_cfg, +) +from mjlab_microduck.tasks.microduck_velocity_rollers_env_cfg import ( + make_microduck_velocity_rollers_env_cfg, +) + +# Récompenses de PATINAGE : elles ne doivent pas survivre dans un env de relevé. +SKATING_REWARDS = ( + "wheel_speed", + "braking", + "skating_air_time", + "glide", + "single_support", + "gait_symmetry", + "forward_lean", + "heading_hold", + "feet_flat", + "hip_roll_neutral", + "pose", + "com_height_target", + "upright", +) + + +def test_env_builds_train_and_play(): + assert make_microduck_roller_standup_env_cfg() is not None + assert make_microduck_roller_standup_env_cfg(play=True) is not None + + +def test_episode_is_short(): + # Épisode court : monter puis stabiliser, comme standup (6 s). + cfg = make_microduck_roller_standup_env_cfg() + assert cfg.episode_length_s == EPISODE_LENGTH_S == 6.0 + + +def test_no_skating_rewards_survive(): + cfg = make_microduck_roller_standup_env_cfg() + for name in SKATING_REWARDS: + assert name not in cfg.rewards, f"reward de patinage survivante : {name}" + + +def test_smoothness_regularisers_kept(): + # Gardées de l'héritage roller : le relevé a besoin de douceur sim2real, mais + # body_ang_vel doit rester LÉGER (standup documente qu'à -0.15 il gelait). + cfg = make_microduck_roller_standup_env_cfg() + for name in ( + "action_over_limit", + "self_collisions", + "body_ang_vel", + "angular_momentum", + "action_rate_l2", + "neck_action_rate_l2", + "neck_joint_pos_l2", + "joint_torques_l2", + ): + assert name in cfg.rewards, f"régularisateur perdu : {name}" + assert cfg.rewards["body_ang_vel"].weight == -0.05 + + +def test_twist_command_is_neutralised(): + # Pas de pilotage : la policy se déploie en --standing, où le runtime laisse + # le slot twist à zéro (cf. infer_policy.py:239). + cfg = make_microduck_roller_standup_env_cfg() + cmd = cfg.commands["twist"] + assert cmd.ranges.lin_vel_x == (-0.01, 0.01) + assert cmd.ranges.lin_vel_y == (-0.01, 0.01) + assert cmd.ranges.ang_vel_z == (-0.05, 0.05) + assert cmd.heading_command is False + assert cmd.ranges.heading is None + assert cmd.rel_standing_envs == 0.0 + + +def test_twist_command_is_not_heading_relative(): + # L'env roller installe un RelativeHeadingVelocityCommandCfg (cmd[2] = erreur + # de cap, calculée en interne). Ici cmd[2] doit être un vrai zéro bruité. + from mjlab_microduck.tasks import mdp as microduck_mdp + + cfg = make_microduck_roller_standup_env_cfg() + cmd = cfg.commands["twist"] + assert isinstance(cmd, microduck_mdp.VelocityCommandCommandOnlyCfg) + assert not isinstance(cmd, microduck_mdp.RelativeHeadingVelocityCommandCfg) + + +def test_obs_nan_policy_sanitize(): + # Un contact rare fait diverger le free-joint en NaN : on assainit l'obs + # plutôt que de tuer l'entraînement (même choix que roller_slope). + cfg = make_microduck_roller_standup_env_cfg() + assert cfg.observations["actor"].nan_policy == "sanitize" + assert cfg.observations["critic"].nan_policy == "sanitize" + + +def test_obs_parity_with_roller_env(): + # Parité 61D obligatoire : sinon l'ONNX ne se charge pas dans un slot runtime. + standup = make_microduck_roller_standup_env_cfg() + roller = make_microduck_velocity_rollers_env_cfg() + for grp in ("actor", "critic"): + assert list(standup.observations[grp].terms.keys()) == list( + roller.observations[grp].terms.keys() + ), f"layout d'observation divergent sur le groupe {grp}" + + +def test_terrain_is_plain_plane(): + # Hérité de l'env roller : sol plat, pas de générateur. Pas de variante rough + # pour cette v1. + cfg = make_microduck_roller_standup_env_cfg() + assert cfg.scene.terrain.terrain_type == "plane" + assert cfg.scene.terrain.terrain_generator is None + + +def test_task_is_registered(): + from mjlab.tasks.registry import list_tasks + + import mjlab_microduck.tasks # noqa: F401 (l'import déclenche l'enregistrement) + + assert "Mjlab-RollerStandUp-Flat-MicroDuck" in list_tasks() +``` + +- [ ] **Step 2 : Lancer les tests pour vérifier qu'ils échouent** + +```bash +uv run --with pytest pytest tests/test_roller_standup_cfg.py -q +``` +Attendu : erreur de collecte, `ModuleNotFoundError: No module named 'mjlab_microduck.tasks.microduck_roller_standup_env_cfg'`. + +- [ ] **Step 3 : Créer le fichier d'env** + +Créer `src/mjlab_microduck/tasks/microduck_roller_standup_env_cfg.py` : + +```python +"""Microduck roller standup — se relever sur rollers. + +Policy DÉDIÉE épisodique : le robot démarre au sol (à plat ventre, à plat dos) ou +déjà debout, et doit se remettre debout sur ses rollers puis TENIR la station. +Portage de la recette `standup` (canard marcheur) vers le modèle rollers. + +Dérive de l'env roller (`make_microduck_velocity_rollers_env_cfg`) → hérite tel +quel le robot rollers, les capteurs, toute la DR et l'observation 61D, donc +interchangeable au runtime (--new-cmd-obs). C'est le pattern de roller_slope. + +Deux différences structurelles avec `standup` : + - les roues passives sont INTERCALÉES dans l'ordre des joints → indices + remappés (_LEG_JOINTS ci-dessous), verrouillés par + tests/test_roller_standup_cfg.py ; + - pas de commande head_pose : les slots head/body restent zero-paddés + (convention de la famille roller) et la tête est tenue droite par + neck_joint_pos_l2, qui résout par NOM. + +La pièce nouvelle est le curriculum de friction de roulement, INVERSÉ (roues +freinées → libres) : les roues roulent, donc il n'y a aucune adhérence pour +pousser sur le sol. On bootstrappe avec des roues quasi bloquées puis on rampe +vers la vraie valeur. Si `standing_composite` s'écroule à un palier, le geste +« pieds adhérents » ne transfère pas et il faudra guider une technique de +patineur (appui genou, un patin à la fois). + +Déploiement visé : en `--standing` face à la policy roller en `--walking`, avec +la bascule automatique sur la magnitude de la commande de vitesse +(infer_policy.py:262, seuil 0.05) ; le slot twist y est laissé à zéro +(infer_policy.py:239). +""" + +import math + +from mjlab.envs import ManagerBasedRlEnvCfg +from mjlab.managers import ( + CurriculumTermCfg, + EventTermCfg, + RewardTermCfg, +) +from mjlab.managers.scene_entity_config import SceneEntityCfg +from mjlab.rl import RslRlModelCfg, RslRlOnPolicyRunnerCfg + +from mjlab_microduck.tasks import mdp as microduck_mdp +from mjlab_microduck.tasks.microduck_velocity_rollers_env_cfg import ( + make_microduck_velocity_rollers_env_cfg, +) +from mjlab_microduck.tasks.symmetry import PpoWithSymmetryCfg + +# ── Hauteurs de tronc (m) ───────────────────────────────────────────────────── +# Mesurées par cinématique exacte (minimum des sommets de maillage des géoms +# collidantes, pose STAND, tronc ramené au contact) sur scene_rollers.xml : +# debout 0.1407, repos à plat ventre 0.0752, repos à plat dos 0.0475. +# Contrôle : le modèle SANS roues donne 0.1172 en cinématique contre STAND_Z=0.115 +# mesuré sous charge par standup → ~2 mm d'affaissement, appliqué ici aussi. +# 0.138 tombe dans le reset_base z (0.1335–0.1435) déjà utilisé par l'env roller. +ROLLER_STAND_Z = 0.138 +ROLLER_PRONE_Z = 0.075 + +EPISODE_LENGTH_S = 6.0 # monter + stabiliser, comme standup +NUM_STEPS_PER_ENV = 24 + +# ── Indices de joints — les roues passives sont INTERCALÉES ─────────────────── +# Ordre réel du modèle rollers (18 joints après le free-joint), vérifié dans +# MuJoCo via get_walk_rollers_spec().compile() : +# 0-4 left_hip_yaw, left_hip_roll, left_hip_pitch, left_knee, left_ankle +# 5-6 passive_LF_wheel, passive_LR_wheel +# 7-10 neck_pitch, head_pitch, head_yaw, head_roll +# 11-15 right_hip_yaw, right_hip_roll, right_hip_pitch, right_knee, right_ankle +# 16-17 passive_RF_wheel, passive_RR_wheel +# Le standup utilise [0-4, 9-13] / [5-8] : ce sont les indices du modèle SANS +# roues, ils ne valent PAS ici. Verrouillé par tests/test_roller_standup_cfg.py. +# +# Seul _LEG_JOINTS est consommé (par les récompenses de pose). _NECK_JOINTS et +# _WHEEL_JOINTS servent à la documentation et au test d'indices : le cou est +# résolu par NOM (neck_joint_pos_l2 appelle find_joints(r".*(neck|head).*") à +# chaque pas) et les roues par la regex ^passive_.*. +_LEG_JOINTS = [0, 1, 2, 3, 4, 11, 12, 13, 14, 15] +_NECK_JOINTS = [7, 8, 9, 10] +_WHEEL_JOINTS = [5, 6, 16, 17] + +# Récompenses de PATINAGE de l'env roller : aucun sens quand on est par terre. +# feet_flat : les lames ne sont PAS à plat pendant la montée → combattrait le geste. +# hip_roll_neutral : se relever demande d'écarter les jambes. +# pose / com_height_target : remplacés par les cibles pose/hauteur du relevé. +# upright (gaussienne de base) : remplacée par upright_linear + upright_sharp. +_SKATING_REWARDS = ( + "wheel_speed", + "braking", + "skating_air_time", + "glide", + "single_support", + "gait_symmetry", + "forward_lean", + "heading_hold", + "feet_flat", + "hip_roll_neutral", + "pose", + "com_height_target", + "upright", +) + + +def make_microduck_roller_standup_env_cfg(play: bool = False) -> ManagerBasedRlEnvCfg: + """Env « se relever sur rollers » : départ au sol, cible = debout sur roues.""" + cfg = make_microduck_velocity_rollers_env_cfg(play=play) + + cfg.episode_length_s = EPISODE_LENGTH_S + + # ── Récompenses de patinage retirées ───────────────────────────────────── + for name in _SKATING_REWARDS: + cfg.rewards.pop(name, None) + + # ── Commande : slot twist neutralisé (≈ 0) ─────────────────────────────── + # L'env roller installe un RelativeHeadingVelocityCommandCfg (cmd[2] = erreur + # de cap calculée en interne). Ici on ne pilote rien : on repasse au + # command-only neutralisé, comme standup. Les slots head_pose (4) et + # body_pose (6) restent zero-paddés → parité d'obs 61D préservée. + command = cfg.commands["twist"] + command.rel_standing_envs = 0.0 + command.rel_heading_envs = 0.0 + command.heading_command = False + command.ranges.heading = None + command.resampling_time_range = (EPISODE_LENGTH_S, EPISODE_LENGTH_S * 2) + command.debug_vis = False + command.ranges.lin_vel_x = (-0.01, 0.01) + command.ranges.lin_vel_y = (-0.01, 0.01) + command.ranges.ang_vel_z = (-0.05, 0.05) + cfg.commands["twist"] = microduck_mdp.VelocityCommandCommandOnlyCfg(**vars(command)) + + # ── Robustesse numérique (même choix que roller_slope) ─────────────────── + # Un contact rare (~1/25M pas) fait diverger le free-joint en NaN : on + # assainit l'obs (→ 0) pour ne pas tuer l'entraînement, l'env fautif se reset + # au pas suivant. + for grp in ("actor", "critic"): + cfg.observations[grp].nan_policy = "sanitize" + + return cfg + + +# ── Config du runner RL — identique à standup ───────────────────────────────── +MicroduckRollerStandUpRlCfg = RslRlOnPolicyRunnerCfg( + actor=RslRlModelCfg( + hidden_dims=(512, 256, 128), + activation="elu", + obs_normalization=True, # le normaliseur DOIT être baké dans l'ONNX par export.py + distribution_cfg={ + "class_name": "GaussianDistribution", + "init_std": 1.0, + "std_type": "scalar", + }, + ), + critic=RslRlModelCfg( + hidden_dims=(512, 256, 128), + activation="elu", + obs_normalization=True, + ), + algorithm=PpoWithSymmetryCfg( + value_loss_coef=1.0, + use_clipped_value_loss=True, + clip_param=0.2, + entropy_coef=0.01, + num_learning_epochs=5, + num_mini_batches=4, + learning_rate=1.0e-3, + schedule="adaptive", + gamma=0.99, + lam=0.95, + desired_kl=0.01, + max_grad_norm=1.0, + # Symétrie OFF : SYMMETRY_CFG est câblé pour l'ancien layout 51D et casse + # sur le 61D (même situation que tous les envs v1.5+). + symmetry_cfg=None, + ), + wandb_project="mjlab_microduck", + experiment_name="roller_standup", + run_name="roller_standup", + save_interval=250, + num_steps_per_env=NUM_STEPS_PER_ENV, + max_iterations=15_000, +) +``` + +- [ ] **Step 4 : Enregistrer la tâche** + +Dans `src/mjlab_microduck/tasks/__init__.py`, ajouter l'import **après** le bloc d'import de `microduck_roller_slope_env_cfg` : + +```python +from .microduck_roller_standup_env_cfg import ( + make_microduck_roller_standup_env_cfg, + MicroduckRollerStandUpRlCfg, +) +``` + +Puis, tout à la fin du fichier (après l'enregistrement de `Mjlab-RollerSlope-Flat-MicroDuck`) : + +```python +# Roller STANDUP — se relever sur rollers (policy dédiée, départ au sol). +register_mjlab_task( + task_id="Mjlab-RollerStandUp-Flat-MicroDuck", + env_cfg=make_microduck_roller_standup_env_cfg(), + play_env_cfg=make_microduck_roller_standup_env_cfg(play=True), + rl_cfg=MicroduckRollerStandUpRlCfg, + runner_cls=MicroduckOnPolicyRunner, +) +print("✓ RollerStandUp task registered: Mjlab-RollerStandUp-Flat-MicroDuck") +``` + +- [ ] **Step 5 : Lancer les tests pour vérifier qu'ils passent** + +```bash +uv run --with pytest pytest tests/test_roller_standup_cfg.py -q +``` +Attendu : 10 passed. + +Si `test_obs_parity_with_roller_env` échoue, c'est que quelque chose a touché aux observations — le corriger avant de continuer, c'est la contrainte dure du projet. + +- [ ] **Step 6 : Vérifier qu'aucun autre test ne régresse** + +```bash +uv run --with pytest pytest tests/ -q +``` +Attendu : `4 failed, 56 passed` — les 4 échecs sont ceux, pré-existants, de `tests/test_wheel_glide.py` (la suite était à `4 failed, 46 passed` avant ce travail). Aucun autre échec. + +- [ ] **Step 7 : Commit** + +```bash +git add src/mjlab_microduck/tasks/microduck_roller_standup_env_cfg.py \ + src/mjlab_microduck/tasks/__init__.py \ + tests/test_roller_standup_cfg.py +git commit -m "roller-standup: squelette de l'env (dérivé roller, twist neutralisé)" +``` + +--- + +## Task 2 : Récompenses de relevé + verrou des indices de joints + +**Files:** +- Modify: `src/mjlab_microduck/tasks/microduck_roller_standup_env_cfg.py` +- Test: `tests/test_roller_standup_cfg.py` + +**Interfaces:** +- Consumes: de la Task 1 — `make_microduck_roller_standup_env_cfg`, `ROLLER_STAND_Z`, `ROLLER_PRONE_Z`, `_LEG_JOINTS`, `_NECK_JOINTS`, `_WHEEL_JOINTS`. De `mdp.py` (existant, non modifié) : `pose_target_match(target_overrides, asset_cfg, std, joint_indices)`, `pose_l1_penalty(target_overrides, asset_cfg, joint_indices)`, `height_target_gaussian(target_height, asset_cfg, std)`, `height_l1_penalty(target_height, asset_cfg)`, `com_upward_velocity(asset_cfg, max_height)`, `trunk_vertical_accel_penalty(asset_cfg)`, `body_upright_linear(asset_cfg)`, `upright_gaussian_at_height(std, height_low, height_high, asset_cfg)`, `standing_composite_score(target_height, height_std, upright_std, pose_std, joint_indices, target_overrides, asset_cfg)`, `joint_torque_rate_l2()`. +- Produces: les termes de récompense `pose_stand_legs`, `pose_stand_l1`, `height_stand`, `height_stand_sharp`, `height_stand_l1`, `com_upward_velocity`, `gentle_rise`, `upright_linear`, `upright_sharp`, `standing_composite`, `joint_torque_rate_l2` dans `cfg.rewards`. + +- [ ] **Step 1 : Écrire les tests qui échouent** + +Ajouter à la fin de `tests/test_roller_standup_cfg.py` : + +```python +def test_joint_indices_match_actual_roller_model(): + """Verrou : les roues passives sont intercalées dans l'ordre des joints. + + Réutiliser les indices du standup ([0-4, 9-13]) donnerait des récompenses + qui pointent sur des roues. Ce test compile le vrai MjSpec du robot rollers + et vérifie les noms aux indices utilisés. Pur CPU, pas de sim. + """ + import mujoco + + from mjlab_microduck.robot.microduck_constants import get_walk_rollers_spec + from mjlab_microduck.tasks.microduck_roller_standup_env_cfg import ( + _LEG_JOINTS, + _NECK_JOINTS, + _WHEEL_JOINTS, + ) + + model = get_walk_rollers_spec().compile() + articulated = [ + mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_JOINT, j) + for j in range(model.njnt) + if model.jnt_type[j] != mujoco.mjtJoint.mjJNT_FREE + ] + + assert [articulated[i] for i in _LEG_JOINTS] == [ + "left_hip_yaw", "left_hip_roll", "left_hip_pitch", "left_knee", "left_ankle", + "right_hip_yaw", "right_hip_roll", "right_hip_pitch", "right_knee", "right_ankle", + ] + assert [articulated[i] for i in _NECK_JOINTS] == [ + "neck_pitch", "head_pitch", "head_yaw", "head_roll", + ] + assert [articulated[i] for i in _WHEEL_JOINTS] == [ + "passive_LF_wheel", "passive_LR_wheel", "passive_RF_wheel", "passive_RR_wheel", + ] + # Aucun recouvrement, et les trois listes couvrent tous les joints. + assert len(set(_LEG_JOINTS) | set(_NECK_JOINTS) | set(_WHEEL_JOINTS)) == len(articulated) + + +def test_recovery_rewards_present_with_expected_weights(): + cfg = make_microduck_roller_standup_env_cfg() + expected = { + "pose_stand_legs": 8.0, + "pose_stand_l1": 5.0, + "height_stand": 4.0, + "height_stand_sharp": 4.0, + "height_stand_l1": 30.0, + "com_upward_velocity": 3.0, + "gentle_rise": -0.02, + "upright_linear": 6.0, + "upright_sharp": 6.0, + "standing_composite": 15.0, + "joint_torque_rate_l2": -2e-3, + } + for name, weight in expected.items(): + assert name in cfg.rewards, f"récompense de relevé manquante : {name}" + assert cfg.rewards[name].weight == weight, f"poids inattendu sur {name}" + + +def test_recovery_rewards_use_roller_heights_not_walker_heights(): + from mjlab_microduck.tasks.microduck_roller_standup_env_cfg import ( + ROLLER_PRONE_Z, + ROLLER_STAND_Z, + ) + + cfg = make_microduck_roller_standup_env_cfg() + assert ROLLER_STAND_Z == 0.138 # PAS le 0.115 du modèle sans roues + for name in ("height_stand", "height_stand_sharp", "height_stand_l1"): + assert cfg.rewards[name].params["target_height"] == ROLLER_STAND_Z + assert cfg.rewards["standing_composite"].params["target_height"] == ROLLER_STAND_Z + # com_upward_velocity se coupe juste AU-DESSUS de la cible (10 mm de marge), + # sinon la policy se gare à l'altitude de coupure sans finir la montée. + assert cfg.rewards["com_upward_velocity"].params["max_height"] == ROLLER_STAND_Z + 0.010 + # upright_sharp est gatée entre le repos au sol et la station debout. + assert cfg.rewards["upright_sharp"].params["height_low"] == ROLLER_PRONE_Z + assert cfg.rewards["upright_sharp"].params["height_high"] == ROLLER_STAND_Z + + +def test_pose_rewards_target_legs_only_at_roller_indices(): + from mjlab_microduck.tasks.microduck_roller_standup_env_cfg import _LEG_JOINTS + + cfg = make_microduck_roller_standup_env_cfg() + for name in ("pose_stand_legs", "pose_stand_l1", "standing_composite"): + assert cfg.rewards[name].params["joint_indices"] == _LEG_JOINTS + # target_overrides=None → la cible est HOME (default_joint_pos). + assert cfg.rewards[name].params["target_overrides"] is None + + +def test_trunk_asset_cfgs_are_distinct_objects(): + """mjlab résout et MUTE les SceneEntityCfg en place : un objet partagé entre + plusieurs termes provoque des indices périmés. Chaque terme doit avoir le sien. + """ + cfg = make_microduck_roller_standup_env_cfg() + names = ( + "height_stand", "height_stand_sharp", "height_stand_l1", + "com_upward_velocity", "gentle_rise", "upright_linear", + "upright_sharp", "standing_composite", + ) + seen = [id(cfg.rewards[n].params["asset_cfg"]) for n in names] + assert len(set(seen)) == len(seen), "asset_cfg partagé entre plusieurs termes" +``` + +- [ ] **Step 2 : Lancer les tests pour vérifier qu'ils échouent** + +```bash +uv run --with pytest pytest tests/test_roller_standup_cfg.py -q +``` +Attendu : `test_joint_indices_match_actual_roller_model` **passe** (les constantes de la Task 1 sont déjà correctes — c'est un verrou de régression, pas un test rouge) ; les 4 autres échouent avec `KeyError: 'pose_stand_legs'` ou `assert 'pose_stand_legs' in cfg.rewards`. + +- [ ] **Step 3 : Ajouter les récompenses de relevé** + +Dans `microduck_roller_standup_env_cfg.py`, insérer ce bloc **après** le bloc « Robustesse numérique » et **avant** le `return cfg` : + +```python + # ── Récompenses de relevé — transplant du standup, remappé ─────────────── + # Les poids viennent des itérations documentées dans + # microduck_standup_env_cfg.py : ne les retoucher qu'avec une raison. Seuls + # les indices de joints et les deux hauteurs changent ici. + # NB : un SceneEntityCfg NEUF par terme — mjlab les résout et les mute en + # place, un objet partagé donne des indices périmés. + + # Pose cible = HOME (target_overrides=None), JAMBES seulement : le cou et la + # tête sont tenus par neck_joint_pos_l2 (hérité), qui résout par NOM. + cfg.rewards["pose_stand_legs"] = RewardTermCfg( + func=microduck_mdp.pose_target_match, + weight=8.0, + params={ + "std": 0.5, + "joint_indices": _LEG_JOINTS, + "target_overrides": None, + }, + ) + # Bootstrap L1 : gradient constant même loin de HOME (la gaussienne sature). + cfg.rewards["pose_stand_l1"] = RewardTermCfg( + func=microduck_mdp.pose_l1_penalty, + weight=5.0, + params={ + "joint_indices": _LEG_JOINTS, + "target_overrides": None, + }, + ) + + # Hauteur en trois couches : gaussienne large (tire depuis le sol), + # gaussienne étroite (force les derniers cm, là où la large est saturée), + # et L1 fort qui rend « rester par terre » net NÉGATIF — sans lui, la policy + # se contente de l'optimum paresseux « immobile au sol ». + cfg.rewards["height_stand"] = RewardTermCfg( + func=microduck_mdp.height_target_gaussian, + weight=4.0, + params={ + "std": 0.04, + "target_height": ROLLER_STAND_Z, + "asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",)), + }, + ) + cfg.rewards["height_stand_sharp"] = RewardTermCfg( + func=microduck_mdp.height_target_gaussian, + weight=4.0, + params={ + "std": 0.015, + "target_height": ROLLER_STAND_Z, + "asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",)), + }, + ) + cfg.rewards["height_stand_l1"] = RewardTermCfg( + func=microduck_mdp.height_l1_penalty, + weight=30.0, + params={ + "target_height": ROLLER_STAND_Z, + "asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",)), + }, + ) + + # Paye le MOUVEMENT de montée, pas seulement la destination : sans ça, + # « rester assis en collectant la pose partielle » domine. La coupure est + # 10 mm AU-DESSUS de la cible, sinon la policy se gare à l'altitude de + # coupure et ne finit pas la montée. + cfg.rewards["com_upward_velocity"] = RewardTermCfg( + func=microduck_mdp.com_upward_velocity, + weight=3.0, + params={ + "asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",)), + "max_height": ROLLER_STAND_Z + 0.010, + }, + ) + # Montée douce : pénalise |a_z|. Compatible avec com_upward_velocity — une + # vitesse verticale constante collecte l'une ET a a_z = 0 → les deux + # pressions sélectionnent ensemble une montée lisse à vitesse constante. + cfg.rewards["gentle_rise"] = RewardTermCfg( + func=microduck_mdp.trunk_vertical_accel_penalty, + weight=-0.02, + params={"asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",))}, + ) + + # Tronc vertical en deux couches : cos(tilt) a un fort gradient quand on est + # couché mais s'essouffle près de la verticale ; la gaussienne serrée gatée + # en hauteur prend le relais et tue le penché-arrière (mode d'échec du + # standup : basculer en arrière en tendant les jambes). + cfg.rewards["upright_linear"] = RewardTermCfg( + func=microduck_mdp.body_upright_linear, + weight=6.0, + params={"asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",))}, + ) + cfg.rewards["upright_sharp"] = RewardTermCfg( + func=microduck_mdp.upright_gaussian_at_height, + weight=6.0, + params={ + "std": 0.3, + "height_low": ROLLER_PRONE_Z, + "height_high": ROLLER_STAND_Z, + "asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",)), + }, + ) + + # Score MULTIPLICATIF hauteur × verticalité × pose : comme les facteurs se + # multiplient, être bon sur 2 critères sur 3 ne rapporte rien → casse les + # compromis « penché à la bonne hauteur » que les récompenses additives + # laissent passer. Stds volontairement LARGES pour rester visible pendant la + # montée (des stds serrées donnaient un score ~5e-5, donc zéro gradient). + cfg.rewards["standing_composite"] = RewardTermCfg( + func=microduck_mdp.standing_composite_score, + weight=15.0, + params={ + "target_height": ROLLER_STAND_Z, + "height_std": 0.04, + "upright_std": 0.40, + "pose_std": 0.40, + "joint_indices": _LEG_JOINTS, + "target_overrides": None, + "asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",)), + }, + ) + + # Anti-jitter : pénalise la VARIATION de couple, pas son amplitude ni la + # rotation du tronc → amortit la tremblote sans bloquer le retournement. + # Le standup l'a identifié comme le seul amortisseur qui ne tue pas le + # relevé depuis le dos. + cfg.rewards["joint_torque_rate_l2"] = RewardTermCfg( + func=microduck_mdp.joint_torque_rate_l2, + weight=-2e-3, + ) +``` + +- [ ] **Step 4 : Lancer les tests pour vérifier qu'ils passent** + +```bash +uv run --with pytest pytest tests/test_roller_standup_cfg.py -q +``` +Attendu : 15 passed. + +- [ ] **Step 5 : Commit** + +```bash +git add src/mjlab_microduck/tasks/microduck_roller_standup_env_cfg.py \ + tests/test_roller_standup_cfg.py +git commit -m "roller-standup: recompenses de relevé + verrou des indices de joints" +``` + +--- + +## Task 3 : Départ au sol — reset, suppression de `fell_over`, curriculum des poses + +**Files:** +- Modify: `src/mjlab_microduck/tasks/microduck_roller_standup_env_cfg.py` +- Test: `tests/test_roller_standup_cfg.py` + +**Interfaces:** +- Consumes: `microduck_mdp.set_random_ground_state(env, env_ids, asset_cfg, face_down_prob, face_up_prob, sitting_prob, standing_prob, prone_z_min, prone_z_max, sitting_z_min, sitting_z_max, standing_z_min, standing_z_max, sitting_joint_overrides, sitting_joint_noise_std, sitting_tilt_max)` et `microduck_mdp.event_param_curriculum(env, env_ids, event_name, param_stages)` — existants, non modifiés. +- Produces: l'événement `cfg.events["set_ground_state"]` et le curriculum `cfg.curriculum["ground_state_mix"]` ; `cfg.terminations` sans `fell_over`. + +- [ ] **Step 1 : Écrire les tests qui échouent** + +Ajouter à la fin de `tests/test_roller_standup_cfg.py` : + +```python +def test_starts_from_ground_states(): + # Ventre + dos + debout. Pas de bucket "assis" : il n'existait dans standup + # que pour le hand-off depuis la policy sit, dont il n'y a pas d'équivalent + # roller — et ses sitting_joint_overrides sont des indices du modèle SANS roues. + cfg = make_microduck_roller_standup_env_cfg() + assert "set_ground_state" in cfg.events + params = cfg.events["set_ground_state"].params + assert params["sitting_prob"] == 0.0 + assert params["sitting_joint_overrides"] is None + assert params["face_down_prob"] > 0.0 + assert params["standing_prob"] > 0.0 + # face_up (le dos) démarre à 0 : introduit tard par le curriculum. + assert params["face_up_prob"] == 0.0 + + +def test_ground_state_heights_are_roller_specific(): + cfg = make_microduck_roller_standup_env_cfg() + params = cfg.events["set_ground_state"].params + # Repos au sol : géométrie identique aux deux modèles (c'est la coque du + # tronc qui touche, pas les pieds) → plages du standup réutilisées. + assert (params["prone_z_min"], params["prone_z_max"]) == (0.05, 0.09) + # [Corrigé à 0.076 après la revue finale — voir docs/superpowers/specs/2026-08-04-roller-standup-design.md] + # Debout : hauteur ROLLER (+23 mm vs le modèle sans roues, qui est à 0.11–0.12). + assert params["standing_z_min"] == 0.134 + assert params["standing_z_max"] == 0.144 + assert params["standing_z_min"] < 0.138 < params["standing_z_max"] + + +def test_ground_state_event_runs_after_base_reset(): + # set_ground_state écrase la pose posée par reset_base / reset_robot_joints : + # l'ordre des événements suit l'ordre d'insertion, il doit donc venir APRÈS. + cfg = make_microduck_roller_standup_env_cfg() + order = list(cfg.events.keys()) + assert order.index("set_ground_state") > order.index("reset_base") + assert order.index("set_ground_state") > order.index("reset_robot_joints") + + +def test_no_fall_termination(): + # Le robot DÉMARRE tombé : une terminaison sur inclinaison tuerait l'épisode + # au premier pas. nan_state (hérité) reste, lui. + cfg = make_microduck_roller_standup_env_cfg() + assert "fell_over" not in cfg.terminations + assert "nan_state" in cfg.terminations + + +def test_ground_state_curriculum_ramps_easy_to_hard(): + cfg = make_microduck_roller_standup_env_cfg() + assert "ground_state_mix" in cfg.curriculum + stages = cfg.curriculum["ground_state_mix"].params["param_stages"] + assert cfg.curriculum["ground_state_mix"].params["event_name"] == "set_ground_state" + # Les steps sont croissants et démarrent à 0. + steps = [s["step"] for s in stages] + assert steps[0] == 0 and steps == sorted(steps) and len(set(steps)) == len(steps) + # Le dos (face_up) est introduit tard puis croît de façon monotone. + face_up = [s["params"]["face_up_prob"] for s in stages] + assert face_up[0] == 0.0 + assert face_up == sorted(face_up) + assert face_up[-1] >= 0.35 + # Chaque palier est une distribution valide, et le "déjà debout" ne disparaît + # jamais (sinon la policy se relève puis retombe faute d'apprendre à tenir). + for stage in stages: + p = stage["params"] + total = ( + p["standing_prob"] + p["sitting_prob"] + + p["face_down_prob"] + p["face_up_prob"] + ) + assert abs(total - 1.0) < 1e-9 + assert p["sitting_prob"] == 0.0 + assert p["standing_prob"] > 0.0 +``` + +- [ ] **Step 2 : Lancer les tests pour vérifier qu'ils échouent** + +```bash +uv run --with pytest pytest tests/test_roller_standup_cfg.py -q +``` +Attendu : les 5 nouveaux échouent — `assert 'set_ground_state' in cfg.events` (KeyError / AssertionError), `assert 'fell_over' not in cfg.terminations`, `assert 'ground_state_mix' in cfg.curriculum`. + +- [ ] **Step 3 : Ajouter le reset au sol, la suppression de `fell_over` et le curriculum** + +Dans `microduck_roller_standup_env_cfg.py`, insérer ce bloc **après** les récompenses de relevé et **avant** le `return cfg` : + +```python + # ── Départ AU SOL : à plat ventre / à plat dos / déjà debout ───────────── + # Ajouté en DERNIER dans cfg.events : l'ordre d'exécution suit l'ordre + # d'insertion, et ce terme doit écraser la pose posée par reset_base / + # reset_robot_joints. + # Le bucket « déjà debout » n'est pas décoratif : sans lui la policy apprend + # à monter mais pas à TENIR, et elle retombe juste après s'être relevée. + # Pas de bucket « assis » → aucun sitting_joint_overrides à remapper (ceux du + # standup sont des indices du modèle SANS roues). + # Les probabilités ci-dessous = palier 0 du curriculum ground_state_mix. + cfg.events["set_ground_state"] = EventTermCfg( + func=microduck_mdp.set_random_ground_state, + mode="reset", + params={ + "face_down_prob": 0.50, # ventre (+90° de pitch) + "face_up_prob": 0.00, # dos — le plus dur, introduit tard + "sitting_prob": 0.00, + "standing_prob": 0.50, + "sitting_joint_overrides": None, + # Repos au sol : mesuré à 0.075 (ventre) / 0.048 (dos), identique aux + # deux modèles — c'est la coque du tronc qui touche, pas les pieds. + "prone_z_min": 0.05, + # [Corrigé à 0.076 après la revue finale — voir docs/superpowers/specs/2026-08-04-roller-standup-design.md] + "prone_z_max": 0.09, + # Debout sur roues : ROLLER_STAND_Z = 0.138 (contre 0.11–0.12 sans roues). + "standing_z_min": 0.134, + "standing_z_max": 0.144, + # Bruit de pitch/roll au départ. Attention : dans + # set_random_ground_state le bucket « debout » réutilise le quaternion + # du bucket « assis », donc ce bruit s'applique AUSSI aux départs + # debout — c'est voulu (pas de sur-apprentissage du parfaitement droit). + "sitting_tilt_max": math.radians(10), + }, + ) + + # Le robot DÉMARRE tombé → la terminaison sur inclinaison n'a aucun sens ici + # (elle tuerait l'épisode au premier pas). nan_state, hérité, reste. + cfg.terminations.pop("fell_over", None) + + # Curriculum des poses de départ, easy → hard. Avec un mélange plat dès le + # départ, la policy optimise la majorité facile et laisse le dos sous-entraîné + # (leçon du standup : il gelait en « ne rien faire » sur cette pose). On + # introduit donc debout+ventre d'abord, le dos tard, et on biaise vers les + # poses dures à la fin pour qu'elles reçoivent le plus d'entraînement. + cfg.curriculum["ground_state_mix"] = CurriculumTermCfg( + func=microduck_mdp.event_param_curriculum, + params={ + "event_name": "set_ground_state", + "param_stages": [ + {"step": 0, "params": { + "standing_prob": 0.50, "sitting_prob": 0.00, + "face_down_prob": 0.50, "face_up_prob": 0.00}}, + {"step": 600 * NUM_STEPS_PER_ENV, "params": { + "standing_prob": 0.35, "sitting_prob": 0.00, + "face_down_prob": 0.45, "face_up_prob": 0.20}}, + {"step": 1500 * NUM_STEPS_PER_ENV, "params": { + "standing_prob": 0.25, "sitting_prob": 0.00, + "face_down_prob": 0.40, "face_up_prob": 0.35}}, + {"step": 2500 * NUM_STEPS_PER_ENV, "params": { + "standing_prob": 0.20, "sitting_prob": 0.00, + "face_down_prob": 0.40, "face_up_prob": 0.40}}, + ], + }, + ) +``` + +- [ ] **Step 4 : Lancer les tests pour vérifier qu'ils passent** + +```bash +uv run --with pytest pytest tests/test_roller_standup_cfg.py -q +``` +Attendu : 20 passed. + +- [ ] **Step 5 : Commit** + +```bash +git add src/mjlab_microduck/tasks/microduck_roller_standup_env_cfg.py \ + tests/test_roller_standup_cfg.py +git commit -m "roller-standup: depart au sol (ventre/dos/debout) + curriculum des poses" +``` + +--- + +## Task 4 : Curricula — friction de roulement inversée, poussées, action_rate + +**Files:** +- Modify: `src/mjlab_microduck/tasks/microduck_roller_standup_env_cfg.py` +- Test: `tests/test_roller_standup_cfg.py` + +**Interfaces:** +- Consumes: `microduck_mdp.wheel_friction_curriculum(env, env_ids, event_name, ranges_stages)`, `microduck_mdp.push_curriculum(env, env_ids, event_name, push_stages)`, `microduck_mdp.reward_weight(env, env_ids, reward_name, weight_stages)` — existants, non modifiés. Événements hérités de l'env roller : `randomize_wheel_friction`, `push_robot`. +- Produces: `cfg.curriculum["wheel_friction"]` (décroissant), `cfg.curriculum["push_magnitude"]`, `cfg.curriculum["action_rate_weight"]` (remplacé). + +- [ ] **Step 1 : Écrire les tests qui échouent** + +Ajouter à la fin de `tests/test_roller_standup_cfg.py` : + +```python +def test_wheel_friction_curriculum_is_decreasing(): + """La pièce nouvelle : roues FREINÉES → LIBRES. + + Les roues roulent, donc il n'y a aucune adhérence longitudinale pour pousser + sur le sol. On bootstrappe avec des roulements quasi bloqués (le relevé se + fait comme avec des pieds) puis on rampe vers la vraie valeur. L'env roller, + lui, fait MONTER cette friction (0 → 0.0015) : le sens est bien inversé ici. + """ + cfg = make_microduck_roller_standup_env_cfg() + stages = cfg.curriculum["wheel_friction"].params["ranges_stages"] + assert cfg.curriculum["wheel_friction"].params["event_name"] == "randomize_wheel_friction" + + steps = [s["step"] for s in stages] + assert steps[0] == 0 and steps == sorted(steps) and len(set(steps)) == len(steps) + + lows = [s["ranges"][0] for s in stages] + assert lows == sorted(lows, reverse=True), "la friction doit DÉCROÎTRE" + assert lows[0] >= 0.02, "départ franchement freiné pour bootstrapper le geste" + # Arrivée sur la vraie valeur du roulement (celle de l'env roller). + assert stages[-1]["ranges"] == (0.0015, 0.0015) + for stage in stages: + assert stage["ranges"][0] == stage["ranges"][1] + + +def test_wheel_friction_event_starts_at_stage_zero(): + # Le curriculum n'est évalué qu'à partir du premier pas : sans ça les tout + # premiers resets utiliseraient la valeur (0, 0) héritée de l'env roller, + # soit des roues LIBRES pendant le bootstrap — exactement l'inverse du but. + cfg = make_microduck_roller_standup_env_cfg() + stage0 = cfg.curriculum["wheel_friction"].params["ranges_stages"][0]["ranges"] + assert cfg.events["randomize_wheel_friction"].params["ranges"] == stage0 + + +def test_action_rate_ramp_is_the_standup_one_not_the_roller_one(): + # L'env roller monte à -2.0 (gait calme) : c'est un bloqueur de mouvement, + # il ralentit l'action rapide dont le relevé depuis le dos a besoin. On + # reprend la rampe du standup, qui plafonne à -1.0. + cfg = make_microduck_roller_standup_env_cfg() + weights = [ + s["weight"] for s in cfg.curriculum["action_rate_weight"].params["weight_stages"] + ] + assert weights == [-0.4, -0.8, -1.0] + assert cfg.rewards["action_rate_l2"].weight == -0.6 + + +def test_push_curriculum_ramps_from_zero(): + # Poussées héritées (±0.2 m/s), mais rampées : une bourrade dès le pas 0 + # parasite le bootstrap du relevé. + cfg = make_microduck_roller_standup_env_cfg() + assert "push_robot" in cfg.events + stages = cfg.curriculum["push_magnitude"].params["push_stages"] + assert cfg.curriculum["push_magnitude"].params["event_name"] == "push_robot" + assert stages[0]["velocity_range"]["x"] == (0.0, 0.0) + assert stages[-1]["velocity_range"]["x"] == (-0.2, 0.2) + highs = [s["velocity_range"]["x"][1] for s in stages] + assert highs == sorted(highs), "la poussée doit CROÎTRE" + + +def test_inherited_dr_curricula_survive(): + # La DR héritée de l'env roller ne doit pas avoir été perdue en chemin. + cfg = make_microduck_roller_standup_env_cfg() + for name in ("com_range", "head_com_range"): + assert name in cfg.curriculum, f"curriculum de DR perdu : {name}" + for name in ( + "randomize_com", + "randomize_head_com", + "randomize_armature", + "randomize_joint_friction", + "randomize_mass_inertia", + "randomize_wheel_friction", + "encoder_bias", + ): + assert name in cfg.events, f"événement de DR perdu : {name}" +``` + +- [ ] **Step 2 : Lancer les tests pour vérifier qu'ils échouent** + +```bash +uv run --with pytest pytest tests/test_roller_standup_cfg.py -q +``` +Attendu : `test_wheel_friction_curriculum_is_decreasing` échoue sur `assert lows == sorted(lows, reverse=True)` (l'env roller monte 0 → 0.0015), `test_wheel_friction_event_starts_at_stage_zero` échoue, `test_action_rate_ramp_is_the_standup_one_not_the_roller_one` échoue sur `[-1.0, -1.5, -2.0] != [-0.4, -0.8, -1.0]`, `test_push_curriculum_ramps_from_zero` échoue sur `KeyError: 'push_magnitude'`. `test_inherited_dr_curricula_survive` passe déjà (vérification de non-régression). + +- [ ] **Step 3 : Remplacer les curricula** + +Dans `microduck_roller_standup_env_cfg.py`, insérer ce bloc **après** le curriculum `ground_state_mix` et **avant** le `return cfg` : + +```python + # ── Friction de roulement INVERSÉE : freinées → libres ─────────────────── + # C'est la seule pièce vraiment nouvelle de cet env, et le cœur de la + # difficulté : les roues roulent, donc il n'y a AUCUNE adhérence + # longitudinale pour pousser sur le sol. L'env roller fait MONTER cette + # friction (0 → 0.0015) ; ici on la fait DESCENDRE, pour bootstrapper le + # geste sur un problème facile (roues quasi bloquées ≈ des pieds) avant + # d'imposer la physique réelle du roulement. + # + # DIAGNOSTIC à surveiller : si Episode_Reward/standing_composite s'écroule à + # un palier, le geste « pieds adhérents » ne transfère pas aux roues libres + # → il faudra guider une technique de patineur (appui genou intermédiaire, + # un patin à la fois). C'est un résultat exploitable, pas un échec. + # + # ATTENTION sim2real : seuls les checkpoints d'APRÈS le dernier palier + # (iter 4000+) sont candidats au déploiement. Avant, la policy s'appuie sur + # une friction de roulement qui n'existe pas sur le vrai robot. + _WHEEL_FRICTION_STAGE0 = (0.0500, 0.0500) + cfg.curriculum["wheel_friction"] = CurriculumTermCfg( + func=microduck_mdp.wheel_friction_curriculum, + params={ + "event_name": "randomize_wheel_friction", + "ranges_stages": [ + {"step": 0, "ranges": _WHEEL_FRICTION_STAGE0}, + {"step": 1000 * NUM_STEPS_PER_ENV, "ranges": (0.0200, 0.0200)}, + {"step": 2000 * NUM_STEPS_PER_ENV, "ranges": (0.0080, 0.0080)}, + {"step": 3000 * NUM_STEPS_PER_ENV, "ranges": (0.0030, 0.0030)}, + {"step": 4000 * NUM_STEPS_PER_ENV, "ranges": (0.0015, 0.0015)}, + ], + }, + ) + # La valeur de DÉPART de l'événement doit matcher le palier 0 : le curriculum + # n'est évalué qu'à partir du premier pas, sinon les tout premiers resets + # utiliseraient le (0, 0) hérité de l'env roller — des roues LIBRES pendant + # le bootstrap, soit exactement l'inverse du but. + cfg.events["randomize_wheel_friction"].params["ranges"] = _WHEEL_FRICTION_STAGE0 + + # ── action_rate : la rampe du standup, pas celle du roller ─────────────── + # L'env roller monte à -2.0 pour un gait calme. C'est un bloqueur de + # mouvement : il ralentit l'action rapide dont le relevé depuis le dos a + # besoin (le standup documente qu'un action_rate trop fort tuait cette + # récupération). La douceur est portée ici par joint_torque_rate_l2. + cfg.rewards["action_rate_l2"].weight = -0.6 + cfg.curriculum["action_rate_weight"] = CurriculumTermCfg( + func=microduck_mdp.reward_weight, + params={ + "reward_name": "action_rate_l2", + "weight_stages": [ + {"step": 0, "weight": -0.4}, + {"step": 250 * NUM_STEPS_PER_ENV, "weight": -0.8}, + {"step": 500 * NUM_STEPS_PER_ENV, "weight": -1.0}, + ], + }, + ) + + # ── Poussées rampées ──────────────────────────────────────────────────── + # push_robot est hérité de l'env roller (±0.2 m/s, toutes les 3–6 s) mais + # sans curriculum. Une bourrade dès le pas 0 parasite le bootstrap du + # relevé : on la fait monter comme le standup. + cfg.curriculum["push_magnitude"] = CurriculumTermCfg( + func=microduck_mdp.push_curriculum, + params={ + "event_name": "push_robot", + "push_stages": [ + {"step": 0, "velocity_range": { + "x": (0.0, 0.0), "y": (0.0, 0.0)}}, + {"step": 500 * NUM_STEPS_PER_ENV, "velocity_range": { + "x": (-0.08, 0.08), "y": (-0.08, 0.08)}}, + {"step": 1000 * NUM_STEPS_PER_ENV, "velocity_range": { + "x": (-0.2, 0.2), "y": (-0.2, 0.2)}}, + ], + }, + ) +``` + +- [ ] **Step 4 : Lancer les tests pour vérifier qu'ils passent** + +```bash +uv run --with pytest pytest tests/test_roller_standup_cfg.py -q +``` +Attendu : 25 passed. + +- [ ] **Step 5 : Vérifier qu'aucun autre test ne régresse** + +```bash +uv run --with pytest pytest tests/ -q +``` +Attendu : `4 failed, 71 passed` — uniquement les 4 échecs pré-existants de `tests/test_wheel_glide.py`. + +- [ ] **Step 6 : Commit** + +```bash +git add src/mjlab_microduck/tasks/microduck_roller_standup_env_cfg.py \ + tests/test_roller_standup_cfg.py +git commit -m "roller-standup: curriculum de friction de roulement inverse + pousses rampees" +``` + +--- + +## Task 5 : Vérification bout-en-bout sur GPU + doc de passation + +Les tests des Tasks 1–4 sont **statiques** : ils vérifient la config, pas l'exécution. Ils ne peuvent pas attraper un `joint_indices` hors bornes, un nom de paramètre erroné passé à une fonction mdp, ou un capteur manquant. Cette tâche est le seul endroit où l'env tourne réellement. + +**Files:** +- Create: `docs/roller_standup_policy_summary.md` +- (aucune modification de code attendue si tout passe) + +**Interfaces:** +- Consumes: la tâche enregistrée `Mjlab-RollerStandUp-Flat-MicroDuck` (Task 1) et l'env complet (Tasks 2–4). +- Produces: rien de programmatique — un doc de passation et la confirmation que l'env tourne. + +- [ ] **Step 1 : Lancer un entraînement très court** + +```bash +uv run train Mjlab-RollerStandUp-Flat-MicroDuck \ + --env.scene.num-envs 64 \ + --agent.max_iterations 3 \ + --agent.logger tensorboard +``` + +`--agent.logger tensorboard` évite de polluer wandb avec un run jetable. + +Attendu : `✓ RollerStandUp task registered: Mjlab-RollerStandUp-Flat-MicroDuck`, puis 3 itérations qui s'exécutent sans exception, avec un tableau de récompenses affichant les termes `pose_stand_legs`, `height_stand`, `standing_composite`, etc. + +Erreurs plausibles et leur cause : +- `IndexError` sur `joint_pos[:, joint_indices]` → les indices de `_LEG_JOINTS` dépassent le nombre de joints ; relire Task 2. +- `TypeError: ... unexpected keyword argument` → un nom de paramètre ne correspond pas à la signature de la fonction mdp ; comparer avec le bloc **Interfaces** de la Task 2. +- `KeyError` sur un nom de capteur → une récompense retirée était la seule à utiliser un capteur, ou une récompense gardée en réclame un absent. + +- [ ] **Step 2 : Vérifier que les récompenses de relevé ne sont pas toutes nulles** + +Dans la sortie de l'étape précédente, vérifier que `Episode_Reward/standing_composite` et `Episode_Reward/height_stand` sont **non nuls**. Une valeur exactement 0.0 sur les trois itérations signale une récompense qui ne se déclenche jamais (mauvais `asset_cfg`, mauvaise hauteur cible). + +- [ ] **Step 3 : Vérifier visuellement le départ au sol** + +```bash +uv run play Mjlab-RollerStandUp-Flat-MicroDuck --env.scene.num-envs 16 +``` + +Attendu : les robots apparaissent **au sol** (à plat ventre) ou **debout sur leurs roues**, jamais en l'air ni traversant le sol. Aucun robot à plat dos à ce stade — c'est normal, `face_up_prob = 0` au palier 0 du curriculum, et en play le curriculum ne tourne pas. + +Si des robots tombent de haut, les plages `prone_z` sont mal réglées ; si un robot traverse le sol, la pose de départ le fait spawner sous le plan. + +- [ ] **Step 4 : Écrire le doc de passation** + +Créer `docs/roller_standup_policy_summary.md`, sur le modèle de `docs/roller_slope_policy_summary.md` : + +```markdown +# Policy `roller_standup` — se relever sur rollers + +**But** : le microduck (sur rollers) part du sol — à plat ventre ou à plat dos — et se remet **debout sur ses roues**, puis **tient** la station. + +- **Tâche** : `Mjlab-RollerStandUp-Flat-MicroDuck` +- **Fichier** : `src/mjlab_microduck/tasks/microduck_roller_standup_env_cfg.py` +- **Base** : dérivée de l'env roller (`velocity_rollers`) → même robot, même physique/DR, **même observation 61D** (interchangeable au runtime, chargeable via `--new-cmd-obs`). +- **Spec** : `docs/superpowers/specs/2026-08-04-roller-standup-design.md` +- **Politique aveugle** : pas de scan de terrain ; proprioception + `projected_gravity`. + +## Hauteurs (mesurées, pas devinées) + +| pose | modèle pieds | modèle rollers | +|---|---|---| +| debout | 0.1172 → `STAND_Z=0.115` sous charge | 0.1407 → **`ROLLER_STAND_Z=0.138`** | +| à plat ventre (repos) | 0.075 | 0.075 | +| à plat dos (repos) | 0.048 | 0.048 | + +Les hauteurs de repos au sol sont identiques aux deux modèles : c'est la coque du tronc qui touche, pas les pieds. + +## ⚠️ Indices de joints — les roues sont INTERCALÉES + +``` +0-4 jambe gauche 5-6 roues gauches +7-10 cou / tête 11-15 jambe droite 16-17 roues droites +``` +`_LEG_JOINTS = [0-4, 11-15]`. Les indices du `standup` (`[0-4, 9-13]`) valent pour le modèle **sans** roues et pointeraient sur des roues ici. Verrouillé par `tests/test_roller_standup_cfg.py::test_joint_indices_match_actual_roller_model`. + +## Reset — départ au sol + +`set_random_ground_state` : ventre (`prone_z` 0.05–0.09) / dos / **déjà debout** (`standing_z` 0.134–0.144), ± 10° de bruit en pitch/roll. Pas de bucket « assis ». Le bucket « debout » est nécessaire : sans lui la policy monte mais ne tient pas. + +**Curriculum `ground_state_mix`** (easy → hard, le dos en dernier) : + +| iter | debout | ventre | dos | +|---|---|---|---| +| 0 | 0.50 | 0.50 | 0.00 | +| 600 | 0.35 | 0.45 | 0.20 | +| 1500 | 0.25 | 0.40 | 0.35 | +| 2500 | 0.20 | 0.40 | 0.40 | + +## Récompenses + +Dix termes repris du `standup` avec leurs poids déjà réglés : `pose_stand_legs` (+8), `pose_stand_l1` (+5), `height_stand` (+4, std 0.04), `height_stand_sharp` (+4, std 0.015), `height_stand_l1` (+30), `com_upward_velocity` (+3), `gentle_rise` (−0.02), `upright_linear` (+6), `upright_sharp` (+6), `standing_composite` (+15). Plus `joint_torque_rate_l2` (−2e-3), l'anti-jitter qui n'empêche pas le retournement. + +Régularisateurs hérités : `body_ang_vel` **−0.05** (bloqueur de mouvement, à garder LÉGER), `angular_momentum` −0.02, `action_rate_l2` (rampe −0.4 → −1.0, **pas** le −2.0 du roller), `neck_action_rate_l2` −0.5, `neck_joint_pos_l2` −0.5 (tête droite), `joint_torques_l2` −1e-3, `action_over_limit` −0.5, `self_collisions` −1.0. + +Retirées : toutes les récompenses de patinage, plus `feet_flat` (les lames ne sont pas à plat pendant la montée) et `hip_roll_neutral` (se relever demande d'écarter les jambes). + +## ⚠️ Le point dur : les roues roulent + +Aucune adhérence longitudinale pour pousser sur le sol. Le **curriculum de friction de roulement est INVERSÉ** (l'env roller la fait monter, ici elle descend) : + +| iter | frictionloss | | +|---|---|---| +| 0 | 0.05 | roues quasi bloquées → se relève comme avec des pieds | +| 1000 | 0.02 | | +| 2000 | 0.008 | | +| 3000 | 0.003 | | +| 4000 | 0.0015 | la vraie valeur du roulement | + +**Surveiller `Episode_Reward/standing_composite` aux paliers.** S'il s'écroule, le geste « pieds adhérents » ne transfère pas aux roues libres → il faudra guider une technique de patineur (appui genou intermédiaire, un patin à la fois). C'est un résultat, pas un échec. + +**Sim2real** : seuls les checkpoints d'après iter 4000 sont candidats au déploiement. Avant, la policy s'appuie sur une friction qui n'existe pas sur le vrai robot. + +## Commande + +Slot `twist` neutralisé (± 0.01), slots `head_pose` / `body_pose` **zero-paddés** (convention roller). Déploiement visé : en `--standing` face à la policy roller en `--walking`, avec la bascule automatique sur la magnitude de la commande (`infer_policy.py:262`, seuil 0.05) ; le slot twist y est laissé à zéro (`infer_policy.py:239`). + +**Réserve** : `infer_policy.py` est le script de sim/clavier local. Le runtime robot est le binaire Rust `microduck_runtime`, absent du repo — il n'est pas vérifié qu'il expose un équivalent `--standing`. Le doc de passation du crouch ne liste que `--model`, `--ground-pick`, `--fold-policy`. À confirmer. + +## Terminaisons + +`fell_over` **supprimée** (le robot démarre tombé). `nan_state` héritée. `nan_policy="sanitize"` sur les obs actor/critic. + +## Réseau / PPO + +Actor et critic `(512, 256, 128)` elu, `obs_normalization=True`. PPO `lr=1e-3` adaptive, `desired_kl=0.01`, `gamma=0.99`, `lam=0.95`, `num_steps_per_env=24`, épisode 6 s, `max_iterations=15000`. **Symétrie OFF** (`SYMMETRY_CFG` est câblé pour le layout 51D). + +## Commandes + +```bash +uv run train Mjlab-RollerStandUp-Flat-MicroDuck --env.scene.num-envs 4096 --agent.max_iterations 15000 +uv run scripts/play_latest.py # alias md-play +uv run scripts/export_latest.py # alias md-export +uv run --with pytest pytest tests/test_roller_standup_cfg.py -q +``` + +## Hors périmètre + +Intégrer le relevé dans la policy de roulage (recette `velstand`) ; buckets de départ sur le côté ; variante rough ; pénalités d'impact tronc/tête. +``` + +- [ ] **Step 5 : Commit** + +```bash +git add docs/roller_standup_policy_summary.md +git commit -m "roller-standup: doc de passation" +``` + +--- + +## Après le plan + +Lancer un vrai entraînement : + +```bash +uv run train Mjlab-RollerStandUp-Flat-MicroDuck --env.scene.num-envs 4096 --agent.max_iterations 15000 +``` + +**Le signal à lire** : `Episode_Reward/standing_composite` doit monter, et surtout **son comportement aux iters 1000 / 2000 / 3000 / 4000** (les paliers de friction de roulement) répond à la question qui a motivé tout ce design — est-ce que se relever sur des roues libres est faisable avec le geste « pieds adhérents », ou faut-il enseigner une technique de patineur ? diff --git a/docs/superpowers/plans/2026-08-04-spin-env.md b/docs/superpowers/plans/2026-08-04-spin-env.md new file mode 100644 index 0000000..64ccd84 --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-spin-env.md @@ -0,0 +1,1564 @@ +# Spin Env Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ajouter une tâche RL `Mjlab-Spin-Flat-MicroDuck` qui apprend au microduck sur rollers à faire ~2 tours anti-horaire sur place à ~6 rad/s puis à s'arrêter, geste cyclique piloté par la phase du slot bouton du runtime. + +**Architecture :** Nouvel env cfg qui clone la structure de `microduck_roller_crouch_env_cfg.py` (robot rollers, obs 61D, DR complète, `GroundPickPhaseCommand`) mais remplace les rewards de pose par des rewards de **résultat** (suivi d'une vitesse de lacet cible en trapèze sur la phase), plus deux amorces de *shaping* décroissantes qui poussent vers le roulement différentiel. Toutes les nouvelles fonctions de reward vont dans `src/mjlab_microduck/tasks/mdp.py`, chacune découpée en une **fonction pure sur valeurs** (testable sans simulateur) + un **wrapper env** — c'est l'idiome déjà présent dans le repo (`crouch_glide_reward_from_values`). + +**Tech Stack :** Python 3.12, mjlab 1.3.0, MuJoCo / mujoco-warp, torch, rsl_rl, pytest, uv. + +**Spec de référence :** `docs/superpowers/specs/2026-08-04-spin-env-design.md` + +## Global Constraints + +- Toutes les valeurs numériques de l'enveloppe sont fixées par le spec : `SPIN_PERIOD = 4.0` s, `SPIN_RATE_MAX = 6.0` rad/s, `SPIN_ACCEL_END = 0.125`, `SPIN_HOLD_END = 0.525`, `SPIN_BRAKE_END = 0.650`. Ne pas les changer sans changer le spec. +- Sens de rotation : **anti-horaire uniquement**, ω_z cible **positive**. +- `ENABLE_SYMMETRY = False` et `symmetry_cfg=None` : l'augmentation de symétrie G/D détruirait un spin à sens unique. +- La reward `angular_momentum` (norme 3D du moment angulaire) doit être **absente** de l'env : elle combattrait le spin. +- L'obs actor doit rester à **61 dimensions** (layout identique à roller / ground_pick / crouch), sinon l'ONNX ne charge pas dans le slot du runtime. +- Les joints sont **toujours** résolus par nom / regex via `asset.find_joints(...)`, jamais par index en dur : les 4 roues passives sont intercalées dans l'ordre des joints du modèle rollers. +- La vitesse d'entrée est injectée via `cfg.events["reset_base"].params["velocity_range"]`, **jamais** via un `push_by_setting_velocity` en `mode="reset"` (régression NaN connue). +- Style du repo : commentaires en français dans les env cfg, docstrings des fonctions `mdp.py` en anglais ou français selon le voisinage, pas de `Co-Authored-By` dans les commits. +- Lancer les tests avec `uv run --with pytest pytest tests/ -q`. + +--- + +### Task 1 : Enveloppe de phase (fonctions pures) + +Les deux fonctions purement mathématiques qui portent toute la définition du geste : le profil de vitesse de lacet cible et la porte de shaping qui s'en déduit. + +**Files:** +- Modify: `src/mjlab_microduck/tasks/mdp.py` (ajouter à la fin du fichier, après `RelativeHeadingVelocityCommandCfg` et les rewards de patinage) +- Test: `tests/test_spin.py` (créer) + +**Interfaces:** +- Consumes: rien (fonctions pures sur `torch.Tensor`) +- Produces: + - `SPIN_PERIOD: float = 4.0`, `SPIN_RATE_MAX: float = 6.0`, `SPIN_ACCEL_END: float = 0.125`, `SPIN_HOLD_END: float = 0.525`, `SPIN_BRAKE_END: float = 0.650` + - `spin_rate_by_phase(phase: torch.Tensor, rate_max: float = 6.0, accel_end: float = 0.125, hold_end: float = 0.525, brake_end: float = 0.650) -> torch.Tensor` + - `spin_gate_by_phase(phase: torch.Tensor, rate_max: float = 6.0, accel_end: float = 0.125, hold_end: float = 0.525, brake_end: float = 0.650) -> torch.Tensor` + +- [ ] **Step 1 : Écrire les tests qui échouent** + +Créer `tests/test_spin.py` : + +```python +import math + +import torch + +from mjlab_microduck.tasks import mdp + +# Enveloppe du spec : accel 0.5s / régime 1.6s / freinage 0.5s / repos 1.4s sur 4s. +_ENV = dict(rate_max=6.0, accel_end=0.125, hold_end=0.525, brake_end=0.650) + + +def test_spin_rate_segment_boundaries(): + # bornes des 4 segments : 0 au départ, plein régime sur [accel_end, hold_end], + # encore plein régime au tout début du freinage, 0 dès le segment de repos. + phase = torch.tensor([0.0, 0.125, 0.30, 0.525, 0.650, 0.80, 0.999]) + w = mdp.spin_rate_by_phase(phase, **_ENV) + expected = torch.tensor([0.0, 6.0, 6.0, 6.0, 0.0, 0.0, 0.0]) + assert torch.allclose(w, expected, atol=1e-6) + + +def test_spin_rate_accel_ramp_is_increasing(): + phase = torch.linspace(0.0, 0.125, 20) + w = mdp.spin_rate_by_phase(phase, **_ENV) + assert torch.all(w[1:] >= w[:-1]) + # milieu de la rampe de lancement -> moitié de la cible + mid = mdp.spin_rate_by_phase(torch.tensor([0.0625]), **_ENV) + assert torch.allclose(mid, torch.tensor([3.0]), atol=1e-6) + + +def test_spin_rate_brake_ramp_is_decreasing(): + phase = torch.linspace(0.525, 0.6499, 20) + w = mdp.spin_rate_by_phase(phase, **_ENV) + assert torch.all(w[1:] <= w[:-1]) + # milieu du freinage -> moitié de la cible + mid = mdp.spin_rate_by_phase(torch.tensor([0.5875]), **_ENV) + assert torch.allclose(mid, torch.tensor([3.0]), atol=1e-6) + + +def test_spin_rate_integral_is_two_turns(): + # LE test qui protège la cible du spec : l'aire sous l'enveloppe sur un cycle + # de 4 s doit valoir ~4*pi rad = 2 tours. Enveloppe exacte = 12.6 rad, + # 4*pi = 12.566 -> tolérance 1 %. + n = 100_000 + phase = (torch.arange(n, dtype=torch.float64) + 0.5) / n + w = mdp.spin_rate_by_phase(phase, **_ENV) + integral = float(w.mean()) * 4.0 + assert abs(integral - 4 * math.pi) / (4 * math.pi) < 0.01 + + +def test_spin_gate_is_normalized_rate(): + phase = torch.tensor([0.0, 0.0625, 0.30, 0.5875, 0.80]) + gate = mdp.spin_gate_by_phase(phase, **_ENV) + rate = mdp.spin_rate_by_phase(phase, **_ENV) + assert torch.allclose(gate, rate / 6.0, atol=1e-6) + assert torch.all(gate >= 0.0) and torch.all(gate <= 1.0) + + +def test_spin_gate_is_zero_over_the_whole_rest_segment(): + # pendant le repos aucune amorce ne doit pousser au ciseau -> porte nulle, + # c'est ce qui donne une sortie de trick propre vers la policy roller. + phase = torch.linspace(0.650, 0.999, 50) + gate = mdp.spin_gate_by_phase(phase, **_ENV) + assert torch.allclose(gate, torch.zeros_like(gate), atol=1e-6) +``` + +- [ ] **Step 2 : Lancer les tests pour vérifier qu'ils échouent** + +Run: `uv run --with pytest pytest tests/test_spin.py -q` +Expected: FAIL avec `AttributeError: module 'mjlab_microduck.tasks.mdp' has no attribute 'spin_rate_by_phase'` + +- [ ] **Step 3 : Implémenter les deux fonctions** + +Ajouter à la fin de `src/mjlab_microduck/tasks/mdp.py` : + +```python +# --------------------------------------------------------------------------- # +# Tâche SPIN — rotation rapide sur place sur rollers # +# --------------------------------------------------------------------------- # +# Enveloppe de phase : la commande du slot bouton porte une phase, qui pilote +# une VITESSE DE LACET cible en trapèze (et non une pose comme le crouch). +# [0, accel_end) 0.5 s 0 -> rate_max (lancement) +# [accel_end, hold_end) 1.6 s rate_max (régime) +# [hold_end, brake_end) 0.5 s rate_max -> 0 (freinage) +# [brake_end, 1.0) 1.4 s 0 (repos debout) +# Aire sous l'enveloppe sur un cycle de 4 s = 12.6 rad ~ 2 tours. +SPIN_PERIOD = 4.0 +SPIN_RATE_MAX = 6.0 +SPIN_ACCEL_END = 0.125 +SPIN_HOLD_END = 0.525 +SPIN_BRAKE_END = 0.650 + + +def spin_rate_by_phase( + phase: torch.Tensor, + rate_max: float = SPIN_RATE_MAX, + accel_end: float = SPIN_ACCEL_END, + hold_end: float = SPIN_HOLD_END, + brake_end: float = SPIN_BRAKE_END, +) -> torch.Tensor: + """Vitesse de lacet cible (rad/s, positive = anti-horaire) le long de la phase.""" + w = torch.zeros_like(phase) + accel = phase < accel_end + w = torch.where(accel, rate_max * phase / accel_end, w) + hold = (phase >= accel_end) & (phase < hold_end) + w = torch.where(hold, torch.full_like(phase, rate_max), w) + brake = (phase >= hold_end) & (phase < brake_end) + w = torch.where( + brake, rate_max * (1.0 - (phase - hold_end) / (brake_end - hold_end)), w + ) + return w + + +def spin_gate_by_phase( + phase: torch.Tensor, + rate_max: float = SPIN_RATE_MAX, + accel_end: float = SPIN_ACCEL_END, + hold_end: float = SPIN_HOLD_END, + brake_end: float = SPIN_BRAKE_END, +) -> torch.Tensor: + """Porte de shaping dans [0,1] = enveloppe normalisée. + + Vaut 0 sur tout le segment de repos : les amorces (ciseau des jambes, + différentiel des roues) ne s'appliquent que pendant lancement + régime, donc + le robot revient en station neutre avant de rendre la main à la policy roller. + """ + return spin_rate_by_phase(phase, rate_max, accel_end, hold_end, brake_end) / rate_max +``` + +- [ ] **Step 4 : Lancer les tests pour vérifier qu'ils passent** + +Run: `uv run --with pytest pytest tests/test_spin.py -q` +Expected: PASS (6 tests) + +- [ ] **Step 5 : Commit** + +```bash +git add src/mjlab_microduck/tasks/mdp.py tests/test_spin.py +git commit -m "spin: enveloppe de phase (vitesse de lacet cible + porte de shaping)" +``` + +--- + +### Task 2 : Rewards de suivi de lacet et « sur place » + +L'objectif principal (`spin_rate_track`), son bootstrap L1, et la pénalité qui garde le robot sur place. Cette tâche introduit aussi le **faux env** partagé par les tests des tâches 2 à 4, ce qui permet de tester les wrappers env sans lancer MuJoCo. + +**Files:** +- Modify: `src/mjlab_microduck/tasks/mdp.py` (à la suite de la Task 1) +- Modify: `tests/test_spin.py` + +**Interfaces:** +- Consumes: `spin_rate_by_phase`, `spin_gate_by_phase`, `SPIN_*` (Task 1) +- Produces: + - `spin_phase_from_command(cmd: torch.Tensor) -> torch.Tensor` + - `spin_rate_reward_from_values(omega_z: torch.Tensor, omega_target: torch.Tensor, std: float) -> torch.Tensor` + - `spin_rate_track(env, command_name="twist", std=1.5, rate_max=..., accel_end=..., hold_end=..., brake_end=..., asset_cfg=_DEFAULT_ASSET_CFG) -> torch.Tensor` + - `spin_rate_l1(env, command_name="twist", rate_max=..., accel_end=..., hold_end=..., brake_end=..., asset_cfg=_DEFAULT_ASSET_CFG) -> torch.Tensor` + - `spin_stay_in_place(env, asset_cfg=_DEFAULT_ASSET_CFG) -> torch.Tensor` + +- [ ] **Step 1 : Écrire les tests qui échouent** + +Ajouter à `tests/test_spin.py` (le faux env sert aussi aux tâches 3 et 4) : + +```python +# ── faux env minimal : permet de tester les wrappers de reward sans MuJoCo ──── +class _FakeData: + def __init__(self, ang_vel_b=None, lin_vel_b=None, joint_pos=None, joint_vel=None): + self.root_link_ang_vel_b = ang_vel_b + self.root_link_lin_vel_b = lin_vel_b + self.joint_pos = joint_pos + self.joint_vel = joint_vel + + +class _FakeEntity: + """Entity minimale : find_joints() résout par nom depuis un dict {nom: index}.""" + + def __init__(self, data, joint_ids=None): + self.data = data + self._joint_ids = joint_ids or {} + + def find_joints(self, pattern): + import re + + names = list(self._joint_ids.keys()) + if isinstance(pattern, (list, tuple)): + matched = [n for n in names if n in pattern] + else: + matched = [n for n in names if re.fullmatch(pattern, n)] + assert matched, f"aucun joint ne matche {pattern!r} parmi {names}" + return [self._joint_ids[n] for n in matched], matched + + +class _FakeCommandManager: + def __init__(self, cmd): + self._cmd = cmd + + def get_command(self, name): + return self._cmd + + +class _FakeSensorData: + def __init__(self, current_contact_time): + self.current_contact_time = current_contact_time + + +class _FakeSensor: + def __init__(self, current_contact_time): + self.data = _FakeSensorData(current_contact_time) + + +class _FakeEnv: + def __init__(self, entity, cmd=None, sensors=None): + self.scene = {"robot": entity, **(sensors or {})} + self.command_manager = _FakeCommandManager(cmd) + self.device = "cpu" + + +def _phase_cmd(phases): + """Commande du slot telle que la voit la policy : [cos(2*pi*phi), sin(...), 0].""" + p = torch.as_tensor(phases, dtype=torch.float32) + return torch.stack( + [torch.cos(2 * math.pi * p), torch.sin(2 * math.pi * p), torch.zeros_like(p)], + dim=-1, + ) + + +# ── phase recover ──────────────────────────────────────────────────────────── +def test_spin_phase_from_command_roundtrip(): + phases = torch.tensor([0.0, 0.125, 0.4, 0.65, 0.9]) + got = mdp.spin_phase_from_command(_phase_cmd(phases)) + assert torch.allclose(got, phases, atol=1e-5) + + +# ── spin_rate_track ────────────────────────────────────────────────────────── +def test_spin_rate_reward_peaks_on_exact_match(): + w = torch.tensor([6.0, 6.0]) + target = torch.tensor([6.0, 4.5]) + r = mdp.spin_rate_reward_from_values(w, target, std=1.5) + # erreur nulle -> 1.0 ; erreur = 1 std -> exp(-1) + assert torch.allclose(r, torch.tensor([1.0, math.exp(-1.0)]), atol=1e-6) + + +def test_spin_rate_track_uses_yaw_and_phase(): + # phase 0.30 = plein régime -> cible 6 rad/s. Un robot qui tourne à 6 rad/s + # doit toucher 1.0 ; un robot immobile doit être largement en dessous. + ang = torch.tensor([[0.0, 0.0, 6.0], [0.0, 0.0, 0.0]]) + env = _FakeEnv( + _FakeEntity(_FakeData(ang_vel_b=ang)), cmd=_phase_cmd([0.30, 0.30]) + ) + r = mdp.spin_rate_track(env, std=1.5) + assert r[0] > 0.99 + assert r[1] < 0.01 + + +def test_spin_rate_track_wants_stillness_during_rest(): + # phase 0.80 = repos -> cible 0 : tourner encore est puni, être immobile payé. + ang = torch.tensor([[0.0, 0.0, 0.0], [0.0, 0.0, 6.0]]) + env = _FakeEnv( + _FakeEntity(_FakeData(ang_vel_b=ang)), cmd=_phase_cmd([0.80, 0.80]) + ) + r = mdp.spin_rate_track(env, std=1.5) + assert r[0] > 0.99 + assert r[1] < 0.01 + + +def test_spin_rate_track_penalizes_wrong_direction(): + # tourner à -6 rad/s (horaire) quand on demande +6 doit être pire qu'immobile + ang = torch.tensor([[0.0, 0.0, -6.0], [0.0, 0.0, 0.0]]) + env = _FakeEnv( + _FakeEntity(_FakeData(ang_vel_b=ang)), cmd=_phase_cmd([0.30, 0.30]) + ) + r = mdp.spin_rate_track(env, std=1.5) + assert r[0] < r[1] + + +# ── spin_rate_l1 ───────────────────────────────────────────────────────────── +def test_spin_rate_l1_is_negative_absolute_error(): + ang = torch.tensor([[0.0, 0.0, 6.0], [0.0, 0.0, 2.0]]) + env = _FakeEnv( + _FakeEntity(_FakeData(ang_vel_b=ang)), cmd=_phase_cmd([0.30, 0.30]) + ) + r = mdp.spin_rate_l1(env) + assert torch.allclose(r, torch.tensor([0.0, -4.0]), atol=1e-5) + + +# ── spin_stay_in_place ─────────────────────────────────────────────────────── +def test_spin_stay_in_place_is_squared_planar_speed(): + lin = torch.tensor([[0.0, 0.0, 0.0], [0.3, 0.4, 9.0]]) + env = _FakeEnv(_FakeEntity(_FakeData(lin_vel_b=lin))) + c = mdp.spin_stay_in_place(env) + # 0.3^2 + 0.4^2 = 0.25 ; la composante z est ignorée + assert torch.allclose(c, torch.tensor([0.0, 0.25]), atol=1e-6) +``` + +- [ ] **Step 2 : Lancer les tests pour vérifier qu'ils échouent** + +Run: `uv run --with pytest pytest tests/test_spin.py -q` +Expected: FAIL — `AttributeError: ... has no attribute 'spin_phase_from_command'` (les 6 tests de la Task 1 continuent de passer) + +- [ ] **Step 3 : Implémenter les rewards** + +Ajouter à la suite dans `src/mjlab_microduck/tasks/mdp.py` : + +```python +def spin_phase_from_command(cmd: torch.Tensor) -> torch.Tensor: + """Récupère la phase [0,1) depuis la commande [cos(2πφ), sin(2πφ), 0] du slot.""" + return (torch.atan2(cmd[:, 1], cmd[:, 0]) / (2 * torch.pi)) % 1.0 + + +def _spin_target_rate( + env: ManagerBasedRlEnv, + command_name: str, + rate_max: float, + accel_end: float, + hold_end: float, + brake_end: float, +) -> torch.Tensor: + phase = spin_phase_from_command(env.command_manager.get_command(command_name)) + return spin_rate_by_phase(phase, rate_max, accel_end, hold_end, brake_end) + + +def _spin_gate( + env: ManagerBasedRlEnv, + command_name: str, + rate_max: float, + accel_end: float, + hold_end: float, + brake_end: float, +) -> torch.Tensor: + phase = spin_phase_from_command(env.command_manager.get_command(command_name)) + return spin_gate_by_phase(phase, rate_max, accel_end, hold_end, brake_end) + + +def spin_rate_reward_from_values( + omega_z: torch.Tensor, omega_target: torch.Tensor, std: float +) -> torch.Tensor: + """Gaussienne sur l'erreur de vitesse de lacet (fonction pure, testable).""" + return torch.exp(-(((omega_z - omega_target) / std) ** 2)) + + +def spin_rate_track( + env: ManagerBasedRlEnv, + command_name: str = "twist", + std: float = 1.5, + rate_max: float = SPIN_RATE_MAX, + accel_end: float = SPIN_ACCEL_END, + hold_end: float = SPIN_HOLD_END, + brake_end: float = SPIN_BRAKE_END, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """Objectif principal du spin : suivre la vitesse de lacet cible ω*(φ). + + ω_z est pris en repère corps (c'est ce que voit le gyro de l'IMU, donc ce que + la policy observe). Une rotation dans le mauvais sens est plus punie que + l'immobilité, la gaussienne étant centrée sur une cible positive. + """ + asset: Entity = env.scene[asset_cfg.name] + omega_z = asset.data.root_link_ang_vel_b[:, 2] + target = _spin_target_rate(env, command_name, rate_max, accel_end, hold_end, brake_end) + return spin_rate_reward_from_values(omega_z, target, std) + + +def spin_rate_l1( + env: ManagerBasedRlEnv, + command_name: str = "twist", + rate_max: float = SPIN_RATE_MAX, + accel_end: float = SPIN_ACCEL_END, + hold_end: float = SPIN_HOLD_END, + brake_end: float = SPIN_BRAKE_END, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """Bootstrap L1 : gradient constant vers la cible même quand la gaussienne + de `spin_rate_track` sature loin de la cible. À utiliser avec un poids + POSITIF (la valeur retournée est déjà négative).""" + asset: Entity = env.scene[asset_cfg.name] + omega_z = asset.data.root_link_ang_vel_b[:, 2] + target = _spin_target_rate(env, command_name, rate_max, accel_end, hold_end, brake_end) + return -torch.abs(omega_z - target) + + +def spin_stay_in_place( + env: ManagerBasedRlEnv, asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG +) -> torch.Tensor: + """Coût ‖v_xy‖² du tronc : tourner SUR PLACE, et tuer l'élan d'entrée. + + Pas d'état de référence (contrairement à une dérive mesurée depuis le reset), + donc reste valide sur les 5 cycles d'un épisode. À utiliser avec un poids + NÉGATIF.""" + asset: Entity = env.scene[asset_cfg.name] + v_xy = asset.data.root_link_lin_vel_b[:, :2] + return torch.sum(torch.square(v_xy), dim=1) +``` + +- [ ] **Step 4 : Lancer les tests pour vérifier qu'ils passent** + +Run: `uv run --with pytest pytest tests/test_spin.py -q` +Expected: PASS (13 tests) + +- [ ] **Step 5 : Commit** + +```bash +git add src/mjlab_microduck/tasks/mdp.py tests/test_spin.py +git commit -m "spin: rewards de suivi de lacet (gaussienne + L1) et sur-place" +``` + +--- + +### Task 3 : Amorces du roulement différentiel + +Les deux rewards qui injectent la physique connue : les patins roulent en sens opposés (`spin_wheel_differential`) et les deux lames restent au sol (`spin_grounded`). Toutes deux portées par la porte `gate(φ)`. + +**Rappel des signes** (dérivé dans le spec) : pour une rotation anti-horaire, le patin gauche va vers l'**arrière** et le droit vers l'**avant** ; les 4 roues tournent positif en marche avant, donc **`ω_D − ω_G > 0`**. + +**Files:** +- Modify: `src/mjlab_microduck/tasks/mdp.py` (à la suite de la Task 2) +- Modify: `tests/test_spin.py` + +**Interfaces:** +- Consumes: `_spin_gate`, `SPIN_*` (Tasks 1-2), `_FakeEntity` / `_FakeSensor` / `_FakeEnv` (Task 2) +- Produces: + - `spin_wheel_differential_from_values(diff: torch.Tensor, gate: torch.Tensor, omega_scale: float) -> torch.Tensor` + - `spin_wheel_differential(env, command_name="twist", omega_scale=20.0, rate_max=..., accel_end=..., hold_end=..., brake_end=...) -> torch.Tensor` + - `spin_grounded(env, sensor_name: str, command_name="twist", rate_max=..., accel_end=..., hold_end=..., brake_end=...) -> torch.Tensor` + +- [ ] **Step 1 : Écrire les tests qui échouent** + +Ajouter à `tests/test_spin.py` : + +```python +# ── spin_wheel_differential ────────────────────────────────────────────────── +_WHEEL_IDS = { + "passive_LF_wheel": 0, + "passive_LR_wheel": 1, + "passive_RF_wheel": 2, + "passive_RR_wheel": 3, +} + + +def _wheel_env(vel_rows, phases): + vel = torch.tensor(vel_rows, dtype=torch.float32) + entity = _FakeEntity(_FakeData(joint_vel=vel), joint_ids=_WHEEL_IDS) + return _FakeEnv(entity, cmd=_phase_cmd(phases)) + + +def test_wheel_differential_rewards_counter_rolling_wheels(): + # anti-horaire : roues GAUCHE négatives (patin part en arrière), DROITE + # positives -> omega_D - omega_G > 0 -> récompensé. + env = _wheel_env( + [ + [-10.0, -10.0, 10.0, 10.0], # bon différentiel + [10.0, 10.0, 10.0, 10.0], # tout droit : différentiel nul + [10.0, 10.0, -10.0, -10.0], # différentiel inversé (horaire) + ], + [0.30, 0.30, 0.30], + ) + r = mdp.spin_wheel_differential(env, omega_scale=20.0) + assert r[0] > 0.5 + assert torch.allclose(r[1], torch.tensor(0.0), atol=1e-6) + assert torch.allclose(r[2], torch.tensor(0.0), atol=1e-6) + + +def test_wheel_differential_is_gated_off_during_rest(): + # même bon différentiel, mais en phase de repos -> porte nulle -> pas payé. + env = _wheel_env([[-10.0, -10.0, 10.0, 10.0]], [0.80]) + r = mdp.spin_wheel_differential(env, omega_scale=20.0) + assert torch.allclose(r, torch.zeros(1), atol=1e-6) + + +def test_wheel_differential_saturates(): + # tanh : au-delà de omega_scale la reward sature, pas de course à la vitesse. + env = _wheel_env( + [[-10.0, -10.0, 10.0, 10.0], [-100.0, -100.0, 100.0, 100.0]], [0.30, 0.30] + ) + r = mdp.spin_wheel_differential(env, omega_scale=20.0) + assert r[1] > r[0] + assert r[1] <= 1.0 + + +def test_wheel_differential_from_values_is_pure(): + diff = torch.tensor([20.0, 0.0, -20.0]) + gate = torch.ones(3) + r = mdp.spin_wheel_differential_from_values(diff, gate, omega_scale=20.0) + expected = torch.tensor([math.tanh(1.0), 0.0, 0.0]) + assert torch.allclose(r, expected, atol=1e-6) + + +# ── spin_grounded ──────────────────────────────────────────────────────────── +def test_spin_grounded_rewards_both_blades_down_and_is_gated(): + contact = torch.tensor([[0.2, 0.3], [0.2, 0.0], [0.0, 0.0], [0.2, 0.3]]) + entity = _FakeEntity(_FakeData()) + env = _FakeEnv( + entity, + cmd=_phase_cmd([0.30, 0.30, 0.30, 0.80]), + sensors={"feet_ground_contact": _FakeSensor(contact)}, + ) + r = mdp.spin_grounded(env, sensor_name="feet_ground_contact") + # deux lames au sol en régime -> porte 1.0 ; une seule ou zéro -> 0 ; + # deux lames au sol mais en repos -> porte 0. + assert torch.allclose(r, torch.tensor([1.0, 0.0, 0.0, 0.0]), atol=1e-6) +``` + +- [ ] **Step 2 : Lancer les tests pour vérifier qu'ils échouent** + +Run: `uv run --with pytest pytest tests/test_spin.py -q` +Expected: FAIL — `AttributeError: ... has no attribute 'spin_wheel_differential'` + +- [ ] **Step 3 : Implémenter les deux rewards** + +Ajouter à la suite dans `src/mjlab_microduck/tasks/mdp.py` : + +```python +SPIN_WHEEL_OMEGA_SCALE = 20.0 # rad/s ; voir le calibrage dans le spec + + +def spin_wheel_differential_from_values( + diff: torch.Tensor, gate: torch.Tensor, omega_scale: float +) -> torch.Tensor: + """Fonction pure : tanh du différentiel de roues, portée par gate, clampée ≥ 0.""" + return gate * torch.tanh(torch.clamp(diff, min=0.0) / omega_scale) + + +def spin_wheel_differential( + env: ManagerBasedRlEnv, + command_name: str = "twist", + omega_scale: float = SPIN_WHEEL_OMEGA_SCALE, + rate_max: float = SPIN_RATE_MAX, + accel_end: float = SPIN_ACCEL_END, + hold_end: float = SPIN_HOLD_END, + brake_end: float = SPIN_BRAKE_END, +) -> torch.Tensor: + """Récompense la rotation EN ROULEMENT (et non en patinage). + + Pour un spin anti-horaire, le patin gauche recule et le droit avance ; les 4 + roues tournant positif en marche avant, cela donne ω_D − ω_G > 0. Le tanh + sature à `omega_scale` pour éviter la course à la vitesse de roue. + """ + asset: Entity = env.scene["robot"] + lf_ids, _ = asset.find_joints("passive_LF_?wheel") + lr_ids, _ = asset.find_joints("passive_LR_?wheel") + rf_ids, _ = asset.find_joints("passive_RF_?wheel") + rr_ids, _ = asset.find_joints("passive_RR_?wheel") + + vel = asset.data.joint_vel + omega_left = (vel[:, lf_ids[0]] + vel[:, lr_ids[0]]) / 2.0 + omega_right = (vel[:, rf_ids[0]] + vel[:, rr_ids[0]]) / 2.0 + gate = _spin_gate(env, command_name, rate_max, accel_end, hold_end, brake_end) + return spin_wheel_differential_from_values( + omega_right - omega_left, gate, omega_scale + ) + + +def spin_grounded( + env: ManagerBasedRlEnv, + sensor_name: str, + command_name: str = "twist", + rate_max: float = SPIN_RATE_MAX, + accel_end: float = SPIN_ACCEL_END, + hold_end: float = SPIN_HOLD_END, + brake_end: float = SPIN_BRAKE_END, +) -> torch.Tensor: + """Les deux lames au sol pendant le spin — empêche « je saute et je vrille ». + + Variante de `grounded_reward` du swizzle, qui n'est pas réutilisable ici : + elle se pondère par cmd_x, qui vaut cos(2πφ) sur la commande de phase. + """ + from mjlab.sensor import ContactSensor + + sensor: ContactSensor = env.scene[sensor_name] + contact_time = sensor.data.current_contact_time # (num_envs, num_feet) + assert contact_time is not None + n_contact = torch.sum((contact_time > 0.0).float(), dim=1) + grounded = (n_contact >= 2).float() + gate = _spin_gate(env, command_name, rate_max, accel_end, hold_end, brake_end) + return grounded * gate +``` + +- [ ] **Step 4 : Lancer les tests pour vérifier qu'ils passent** + +Run: `uv run --with pytest pytest tests/test_spin.py -q` +Expected: PASS (18 tests) + +- [ ] **Step 5 : Mesurer la demi-voie réelle et ajuster `omega_scale`** + +Le spec fixe `omega_scale = 20.0` sur une demi-voie **estimée** à 0.03 m. Mesurer la vraie valeur sur le modèle rollers, à la pose HOME : + +```bash +uv run python -c " +import mujoco, numpy as np +m = mujoco.MjModel.from_xml_path('src/mjlab_microduck/robot/microduck/robot_allcollisions_rollers.xml') +d = mujoco.MjData(m) +mujoco.mj_forward(m, d) +ys = {} +for name in ('left_foot', 'right_foot'): + sid = mujoco.mj_name2id(m, mujoco.mjtObj.mjOBJ_SITE, name) + ys[name] = d.site_xpos[sid][1] +half = abs(ys['left_foot'] - ys['right_foot']) / 2.0 +print('sites y =', ys) +print('demi-voie =', round(half, 4), 'm') +print('differentiel attendu a 6 rad/s =', round(2 * 6.0 * half / 0.0175, 1), 'rad/s') +" +``` + +Si le différentiel attendu diffère de plus de 30 % de 20.0, mettre `SPIN_WHEEL_OMEGA_SCALE` à la valeur mesurée (arrondie à l'entier) et noter la mesure en commentaire au-dessus de la constante. Sinon laisser 20.0 et noter la mesure en commentaire. Dans les deux cas, le commentaire doit contenir la demi-voie mesurée. + +- [ ] **Step 6 : Relancer les tests** + +Run: `uv run --with pytest pytest tests/test_spin.py -q` +Expected: PASS (18 tests — les tests passent `omega_scale=20.0` explicitement, donc ils sont indépendants de la constante) + +- [ ] **Step 7 : Commit** + +```bash +git add src/mjlab_microduck/tasks/mdp.py tests/test_spin.py +git commit -m "spin: amorces du roulement differentiel (roues opposees + lames au sol)" +``` + +--- + +### Task 4 : Ciseau des jambes et tête partiellement libre + +L'amorce de pose (`leg_antisymmetry`) et l'ajustement de `neck_joint_pos_l2` pour laisser `head_yaw` libre de servir de volant d'inertie. + +**Piège de convention** : le robot a des conventions de signe **miroir** gauche/droite. Une pose *symétrique* satisfait `q_G + q_D ≈ 0` (c'est ce que mesure `leg_symmetry_reward`). Donc le **ciseau** (une jambe vers l'avant, l'autre vers l'arrière) satisfait `q_G ≈ q_D`, et se mesure par `−|q_G − q_D|`. + +**Files:** +- Modify: `src/mjlab_microduck/tasks/mdp.py` (`neck_joint_pos_l2` à la ligne ~1237, puis ajout à la suite de la Task 3) +- Modify: `tests/test_spin.py` + +**Interfaces:** +- Consumes: `_spin_gate`, `SPIN_*`, `_FakeEntity` / `_FakeEnv` +- Produces: + - `leg_antisymmetry(env, command_name="twist", asset_cfg=_DEFAULT_ASSET_CFG, joint_bases=("hip_pitch", "knee"), rate_max=..., accel_end=..., hold_end=..., brake_end=...) -> torch.Tensor` + - `neck_joint_pos_l2(env, asset_cfg=_NECK_JOINT_CFG, pattern: str = r".*(neck|head).*") -> torch.Tensor` (signature **élargie**, comportement par défaut inchangé) + +- [ ] **Step 1 : Écrire les tests qui échouent** + +Ajouter à `tests/test_spin.py` : + +```python +# ── leg_antisymmetry ───────────────────────────────────────────────────────── +_LEG_IDS = { + "left_hip_pitch": 0, + "left_knee": 1, + "right_hip_pitch": 2, + "right_knee": 3, +} + + +def _leg_env(pos_rows, phases): + pos = torch.tensor(pos_rows, dtype=torch.float32) + entity = _FakeEntity(_FakeData(joint_pos=pos), joint_ids=_LEG_IDS) + return _FakeEnv(entity, cmd=_phase_cmd(phases)) + + +def test_leg_antisymmetry_prefers_scissor_over_mirror(): + # convention miroir : q_G = -q_D est une pose SYMÉTRIQUE (mauvais ici), + # q_G = q_D est le CISEAU (bon ici). Valeur = -mean|q_G - q_D|, donc <= 0. + env = _leg_env( + [ + [0.4, 0.3, 0.4, 0.3], # ciseau parfait : q_G == q_D -> 0.0 + [0.4, 0.3, -0.4, -0.3], # miroir : écart 0.8 et 0.6 -> -0.7 + ], + [0.30, 0.30], + ) + r = mdp.leg_antisymmetry(env) + assert torch.allclose(r, torch.tensor([0.0, -0.7]), atol=1e-6) + assert r[0] > r[1] + + +def test_leg_antisymmetry_is_gated_off_during_rest(): + # en repos la porte est nulle : rien ne pousse au ciseau, station neutre libre. + env = _leg_env([[0.4, 0.3, -0.4, -0.3]], [0.80]) + r = mdp.leg_antisymmetry(env) + assert torch.allclose(r, torch.zeros(1), atol=1e-6) + + +# ── neck_joint_pos_l2 : paramètre pattern ──────────────────────────────────── +_NECK_IDS = { + "neck_pitch": 0, + "head_pitch": 1, + "head_roll": 2, + "head_yaw": 3, +} + + +def test_neck_joint_pos_l2_pattern_can_exclude_head_yaw(): + class _NeckData(_FakeData): + def __init__(self, joint_pos, default_joint_pos): + super().__init__(joint_pos=joint_pos) + self.default_joint_pos = default_joint_pos + + pos = torch.tensor([[0.0, 0.0, 0.0, 1.0]]) # seul head_yaw dévie, de 1 rad + default = torch.zeros(1, 4) + entity = _FakeEntity(_NeckData(pos, default), joint_ids=_NECK_IDS) + env = _FakeEnv(entity) + + # motif par défaut : head_yaw compté -> coût 1.0 + assert torch.allclose( + mdp.neck_joint_pos_l2(env), torch.tensor([1.0]), atol=1e-6 + ) + # motif du spin : head_yaw exclu -> coût 0.0 (tête libre en lacet) + assert torch.allclose( + mdp.neck_joint_pos_l2(env, pattern=r"^(neck_pitch|head_pitch|head_roll)$"), + torch.tensor([0.0]), + atol=1e-6, + ) +``` + +- [ ] **Step 2 : Lancer les tests pour vérifier qu'ils échouent** + +Run: `uv run --with pytest pytest tests/test_spin.py -q` +Expected: FAIL — `AttributeError: ... has no attribute 'leg_antisymmetry'` + +- [ ] **Step 3 : Ajouter le paramètre `pattern` à `neck_joint_pos_l2`** + +Dans `src/mjlab_microduck/tasks/mdp.py`, remplacer la fonction existante (vers la ligne 1237) par : + +```python +def neck_joint_pos_l2( + env: ManagerBasedRlEnv, + asset_cfg: SceneEntityCfg = _NECK_JOINT_CFG, + pattern: str = r".*(neck|head).*", +) -> torch.Tensor: + """Penalize neck/head joint position deviation from default (L2 squared). + + Uses find_joints() every call to avoid stale cached indices when the same + SceneEntityCfg singleton is reused across robots with different joint layouts + (e.g. walk robot vs rollers robot where passive wheels shift neck indices). + + ``pattern`` sélectionne les joints comptés (défaut : toute la nuque + la tête). + La tâche spin passe un motif qui EXCLUT `head_yaw`, pour laisser la tête servir + de volant d'inertie au lancement de la rotation. + """ + asset: Entity = env.scene[asset_cfg.name] + joint_ids, _ = asset.find_joints(pattern) + error = asset.data.joint_pos[:, joint_ids] - asset.data.default_joint_pos[:, joint_ids] + return torch.sum(torch.square(error), dim=1) +``` + +- [ ] **Step 4 : Implémenter `leg_antisymmetry`** + +Ajouter à la suite de la Task 3 dans `src/mjlab_microduck/tasks/mdp.py` : + +```python +def leg_antisymmetry( + env: ManagerBasedRlEnv, + command_name: str = "twist", + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, + joint_bases: tuple = ("hip_pitch", "knee"), + rate_max: float = SPIN_RATE_MAX, + accel_end: float = SPIN_ACCEL_END, + hold_end: float = SPIN_HOLD_END, + brake_end: float = SPIN_BRAKE_END, +) -> torch.Tensor: + """Amorce le CISEAU des jambes (une avant / une arrière) pendant le spin. + + Le robot a des conventions de signe MIROIR gauche/droite : une pose + symétrique satisfait q_G + q_D ≈ 0 (cf. `leg_symmetry_reward`), donc le + ciseau satisfait q_G ≈ q_D. On retourne `gate(φ) · (−mean|q_G − q_D|)` — à + utiliser avec un poids POSITIF, décroissant par curriculum : l'amorce + s'efface pour laisser la policy affiner son propre geste. + """ + asset: Entity = env.scene[asset_cfg.name] + left, right = [], [] + for base in joint_bases: + li, _ = asset.find_joints([f"left_{base}"]) + ri, _ = asset.find_joints([f"right_{base}"]) + left.append(li[0]) + right.append(ri[0]) + lids = torch.tensor(left, device=env.device) + rids = torch.tensor(right, device=env.device) + + q = asset.data.joint_pos + scissor = -torch.abs(q[:, lids] - q[:, rids]).mean(dim=-1) + gate = _spin_gate(env, command_name, rate_max, accel_end, hold_end, brake_end) + return gate * scissor +``` + +- [ ] **Step 5 : Lancer les tests pour vérifier qu'ils passent** + +Run: `uv run --with pytest pytest tests/test_spin.py -q` +Expected: PASS (21 tests) + +- [ ] **Step 6 : Vérifier la non-régression des autres tests** + +`neck_joint_pos_l2` est utilisée par les envs roller / slope / swizzle ; sa signature a changé (ajout d'un paramètre avec défaut, donc compatible). + +Run: `uv run --with pytest pytest tests/ -q` +Expected: PASS — aucun test existant cassé + +- [ ] **Step 7 : Commit** + +```bash +git add src/mjlab_microduck/tasks/mdp.py tests/test_spin.py +git commit -m "spin: amorce ciseau des jambes + head_yaw libre (pattern sur neck_joint_pos_l2)" +``` + +--- + +### Task 5 : Env cfg et enregistrement de la tâche + +Le fichier d'environnement complet et son enregistrement, avec les tests de configuration. + +**Files:** +- Create: `src/mjlab_microduck/tasks/microduck_spin_env_cfg.py` +- Modify: `src/mjlab_microduck/tasks/__init__.py` +- Test: `tests/test_spin_cfg.py` (créer) + +**Interfaces:** +- Consumes: `spin_rate_track`, `spin_rate_l1`, `spin_stay_in_place`, `spin_wheel_differential`, `spin_grounded`, `leg_antisymmetry`, `neck_joint_pos_l2(pattern=...)`, `SPIN_PERIOD`, `SPIN_RATE_MAX`, `SPIN_ACCEL_END`, `SPIN_HOLD_END`, `SPIN_BRAKE_END` (Tasks 1-4) +- Produces: + - `make_microduck_spin_env_cfg(play: bool = False) -> ManagerBasedRlEnvCfg` + - `MicroduckSpinRlCfg: RslRlOnPolicyRunnerCfg` + - task id `Mjlab-Spin-Flat-MicroDuck` + +- [ ] **Step 1 : Écrire les tests qui échouent** + +Créer `tests/test_spin_cfg.py` : + +```python +from mjlab_microduck.tasks import mdp as microduck_mdp +from mjlab_microduck.tasks.microduck_spin_env_cfg import ( + make_microduck_spin_env_cfg, + MicroduckSpinRlCfg, +) + + +def test_cfg_uses_phase_command_with_runtime_default_period(): + cfg = make_microduck_spin_env_cfg() + cmd = cfg.commands["twist"] + assert isinstance(cmd, microduck_mdp.GroundPickPhaseCommandCfg) + # 4.0 s = le défaut de --ground-pick-period : rien à passer au runtime + assert cmd.period == 4.0 + # chaque épisode démarre à phase 0 (debout), comme le bouton au déploiement + assert cmd.randomize_phase is False + + +def test_cfg_has_the_spin_rewards(): + cfg = make_microduck_spin_env_cfg() + for name in ( + "spin_rate_track", + "spin_rate_l1", + "spin_stay_in_place", + "spin_wheel_differential", + "spin_grounded", + "leg_antisymmetry", + ): + assert name in cfg.rewards, name + # objectif principal avec un poids dominant + assert cfg.rewards["spin_rate_track"].weight == 6.0 + # sur-place est un COÛT + assert cfg.rewards["spin_stay_in_place"].weight < 0.0 + # cible positive = anti-horaire (le sens est porté par l'enveloppe) + assert microduck_mdp.SPIN_RATE_MAX > 0.0 + + +def test_angular_momentum_reward_is_removed(): + # Régression : angular_momentum_penalty pénalise la NORME 3D du moment + # angulaire, elle combattrait directement le spin. Elle doit être absente. + cfg = make_microduck_spin_env_cfg() + assert "angular_momentum" not in cfg.rewards + # body_ang_vel ne pénalise que x/y -> elle reste, elle mate le ballant + assert "body_ang_vel" in cfg.rewards + + +def test_head_yaw_is_free_to_act_as_a_flywheel(): + cfg = make_microduck_spin_env_cfg() + pattern = cfg.rewards["neck_joint_pos_l2"].params["pattern"] + assert "head_yaw" not in pattern + + +def test_entry_velocity_allows_standstill_and_slow_roll(): + cfg = make_microduck_spin_env_cfg() + # jamais via un push en mode reset (régression NaN du crouch) + assert "entry_velocity" not in cfg.events + lo, hi = cfg.events["reset_base"].params["velocity_range"]["x"] + assert lo == 0.0 and hi > 0.0 + + +def test_symmetry_augmentation_is_disabled(): + # la symétrie G/D transformerait un spin à gauche en spin à droite + assert MicroduckSpinRlCfg.algorithm.symmetry_cfg is None + + +def test_leg_antisymmetry_shaping_decays(): + cfg = make_microduck_spin_env_cfg() + stages = cfg.curriculum["leg_antisym_weight"].params["weight_stages"] + weights = [s["weight"] for s in stages] + assert weights[0] == cfg.rewards["leg_antisymmetry"].weight + assert weights == sorted(weights, reverse=True) + assert weights[-1] < weights[0] + + +def test_actor_observation_keeps_the_61d_slot_layout(): + # condition pour que l'ONNX charge dans le slot du runtime. L'égalité exacte + # des dimensions avec le crouch est vérifiée en Task 6 Step 1 (il faut + # construire l'env pour compter les dims ; ici on vérifie la structure). + cfg = make_microduck_spin_env_cfg() + terms = cfg.observations["actor"].terms + assert "base_lin_vel" not in terms + assert "height_scan" not in terms + for padded in ("head_command", "body_command"): + assert padded in terms + assert terms["head_command"].params["dim"] == 4 + assert terms["body_command"].params["dim"] == 6 +``` + +- [ ] **Step 2 : Lancer les tests pour vérifier qu'ils échouent** + +Run: `uv run --with pytest pytest tests/test_spin_cfg.py -q` +Expected: FAIL avec `ModuleNotFoundError: No module named 'mjlab_microduck.tasks.microduck_spin_env_cfg'` + +- [ ] **Step 3 : Créer le fichier d'environnement** + +Créer `src/mjlab_microduck/tasks/microduck_spin_env_cfg.py` : + +```python +"""Microduck SPIN task — rotation rapide sur place, sur rollers. + +Geste cyclique déclenché au bouton A via le slot --ground-pick du runtime : +~2 tours anti-horaire à ~6 rad/s puis arrêt propre debout. + +Hybride : + - physique / robot roller ← microduck_velocity_rollers_env_cfg.py + - machinerie phase cyclique ← microduck_roller_crouch_env_cfg.py + (commande GroundPickPhaseCommand : [cos(2πφ), sin(2πφ), 0], période 4 s) + +Différence de fond avec le crouch : la phase pilote une VITESSE DE LACET cible +(objectif de résultat) et non une pose articulaire. Deux amorces décroissantes +poussent vers le roulement différentiel — le seul mécanisme physique certain sur +4 roues passives : patin gauche vers l'arrière, patin droit vers l'avant. + +Obs 61D unifié → interchangeable au runtime avec roller / ground_pick / crouch. +Voir docs/superpowers/specs/2026-08-04-spin-env-design.md. +""" + +import math +from copy import deepcopy + +# La symétrie G/D transformerait un spin à gauche en spin à droite : interdit ici. +ENABLE_SYMMETRY = False + +# DR — repris du roller env +ENABLE_COM_RANDOMIZATION = True +ENABLE_HEAD_COM_RANDOMIZATION = True +ENABLE_MASS_INERTIA_RANDOMIZATION = True +ENABLE_JOINT_FRICTION_RANDOMIZATION = True +ENABLE_ARMATURE_RANDOMIZATION = True +ENABLE_WHEEL_FRICTION_RANDOMIZATION = True +ENABLE_VELOCITY_PUSHES = True +ENABLE_IMU_ORIENTATION_RANDOMIZATION = True +ENABLE_ENCODER_BIAS = True + +COM_RANDOMIZATION_RANGE = 0.003 +HEAD_COM_RANDOMIZATION_RANGE = 0.003 +MASS_INERTIA_RANDOMIZATION_RANGE = (0.95, 1.05) +JOINT_FRICTION_RANDOMIZATION_RANGE = (0.9, 1.1) +ARMATURE_RANDOMIZATION_RANGE = (0.9, 1.1) +VELOCITY_PUSH_INTERVAL_S = (3.0, 6.0) +VELOCITY_PUSH_RANGE = (-0.2, 0.2) +IMU_ORIENTATION_RANDOMIZATION_ANGLE = 6.0 +ENCODER_BIAS_RANGE = (-0.015, 0.015) + +# Le bouton peut être pressé à l'arrêt OU en roulement lent : la policy apprend +# à tuer l'élan résiduel avant/pendant le lancement de la rotation. +ENTRY_VELOCITY_X = (0.0, 0.3) + +from mjlab.envs import ManagerBasedRlEnvCfg +from mjlab.envs.mdp import dr +from mjlab.envs.mdp.actions import JointPositionActionCfg +from mjlab.managers import ( + CurriculumTermCfg, + EventTermCfg, + ObservationTermCfg, + RewardTermCfg, + TerminationTermCfg, +) +from mjlab.managers.scene_entity_config import SceneEntityCfg +from mjlab.rl import RslRlOnPolicyRunnerCfg, RslRlModelCfg +from mjlab.sensor import ContactMatch, ContactSensorCfg +from mjlab.tasks.velocity import mdp +from mjlab.tasks.velocity.mdp import UniformVelocityCommandCfg +from mjlab.tasks.velocity.velocity_env_cfg import make_velocity_env_cfg +from mjlab.utils.noise import UniformNoiseCfg as Unoise + +from mjlab_microduck.robot.microduck_constants import MICRODUCK_WALK_ROLLERS_ROBOT_CFG +from mjlab_microduck.tasks import mdp as microduck_mdp +from mjlab_microduck.tasks.microduck_velocity_env_cfg import HEAD_BODY_NAMES +from mjlab_microduck.tasks.symmetry import PpoWithSymmetryCfg, SYMMETRY_CFG + +# Enveloppe de phase : constantes canoniques définies dans mdp.py. +SPIN_PERIOD = microduck_mdp.SPIN_PERIOD +_ENVELOPE = { + "rate_max": microduck_mdp.SPIN_RATE_MAX, + "accel_end": microduck_mdp.SPIN_ACCEL_END, + "hold_end": microduck_mdp.SPIN_HOLD_END, + "brake_end": microduck_mdp.SPIN_BRAKE_END, +} +# Nuque/tête tenues près du neutre SAUF head_yaw, laissé libre : il peut servir +# de volant d'inertie pour lancer la rotation. +NECK_PATTERN_NO_YAW = r"^(neck_pitch|head_pitch|head_roll)$" + + +def make_microduck_spin_env_cfg(play: bool = False) -> ManagerBasedRlEnvCfg: + """Env spin sur rollers, piloté par la phase du slot ground-pick.""" + + feet_ground_cfg = ContactSensorCfg( + name="feet_ground_contact", + primary=ContactMatch( + mode="subtree", + pattern=r"^(ankle_l_v1|ankle_r_v1)$", + entity="robot", + ), + secondary=ContactMatch(mode="body", pattern="terrain"), + fields=("found", "force"), + reduce="netforce", + num_slots=1, + track_air_time=True, + ) + self_collision_cfg = ContactSensorCfg( + name="self_collision", + primary=ContactMatch(mode="subtree", pattern="trunk_base", entity="robot"), + secondary=ContactMatch(mode="subtree", pattern="trunk_base", entity="robot"), + fields=("found",), + reduce="none", + num_slots=1, + ) + + cfg = make_velocity_env_cfg() + cfg.scene.entities = {"robot": MICRODUCK_WALK_ROLLERS_ROBOT_CFG} + cfg.scene.sensors = (feet_ground_cfg, self_collision_cfg) + cfg.viewer.body_name = "trunk_base" + + joint_pos_action = cfg.actions["joint_pos"] + assert isinstance(joint_pos_action, JointPositionActionCfg) + joint_pos_action.scale = 1.0 + + # === REWARDS === + # ⚠️ angular_momentum n'est PAS gardée : elle pénalise la norme 3D du moment + # angulaire, donc elle combattrait directement le spin. body_ang_vel, elle, + # ne pénalise que x/y (« Don't penalize z-angular velocity » dans mjlab) → + # gardée, elle mate le ballant roulis/tangage sans gêner la rotation. + keep = {"upright", "body_ang_vel", "action_rate_l2"} + for name in list(cfg.rewards.keys()): + if name not in keep: + del cfg.rewards[name] + + cfg.rewards["upright"].params["asset_cfg"].body_names = ("trunk_base",) + cfg.rewards["upright"].weight = 2.0 + cfg.rewards["body_ang_vel"].params["asset_cfg"].body_names = ("trunk_base",) + cfg.rewards["body_ang_vel"].weight = -0.05 + cfg.rewards["action_rate_l2"].weight = -1.0 + + # Objectif principal : suivre la vitesse de lacet cible ω*(φ) (trapèze). + cfg.rewards["spin_rate_track"] = RewardTermCfg( + func=microduck_mdp.spin_rate_track, + weight=6.0, + params={"command_name": "twist", "std": 1.5, **_ENVELOPE}, + ) + # Bootstrap L1 : gradient constant quand la gaussienne sature loin de la cible. + cfg.rewards["spin_rate_l1"] = RewardTermCfg( + func=microduck_mdp.spin_rate_l1, + weight=0.5, + params={"command_name": "twist", **_ENVELOPE}, + ) + # Tourner SUR PLACE, et tuer l'élan d'entrée. + cfg.rewards["spin_stay_in_place"] = RewardTermCfg( + func=microduck_mdp.spin_stay_in_place, + weight=-1.0, + params={}, + ) + # Amorce 1 : tourner EN ROULEMENT (patins en sens opposés), pas en patinage. + cfg.rewards["spin_wheel_differential"] = RewardTermCfg( + func=microduck_mdp.spin_wheel_differential, + weight=1.0, + params={ + "command_name": "twist", + "omega_scale": microduck_mdp.SPIN_WHEEL_OMEGA_SCALE, + **_ENVELOPE, + }, + ) + # Amorce 2 : ciseau des jambes (décroît par curriculum, voir plus bas). + cfg.rewards["leg_antisymmetry"] = RewardTermCfg( + func=microduck_mdp.leg_antisymmetry, + weight=1.0, + params={ + "command_name": "twist", + "joint_bases": ("hip_pitch", "knee"), + **_ENVELOPE, + }, + ) + # Les deux lames au sol pendant le spin (pas de vrille en l'air). + cfg.rewards["spin_grounded"] = RewardTermCfg( + func=microduck_mdp.spin_grounded, + weight=0.5, + params={ + "sensor_name": "feet_ground_contact", + "command_name": "twist", + **_ENVELOPE, + }, + ) + # Stabilité / sim2real + cfg.rewards["feet_flat"] = RewardTermCfg( + func=microduck_mdp.feet_flat_penalty, + weight=-2.0, + params={ + "asset_cfg": SceneEntityCfg("robot", site_names=("left_foot", "right_foot")), + "sensor_name": "feet_ground_contact", + }, + ) + cfg.rewards["self_collisions"] = RewardTermCfg( + func=mdp.self_collision_cost, + weight=-1.0, + params={"sensor_name": "self_collision"}, + ) + cfg.rewards["neck_action_rate_l2"] = RewardTermCfg( + func=microduck_mdp.neck_action_rate_l2, weight=-0.5 + ) + cfg.rewards["neck_joint_pos_l2"] = RewardTermCfg( + func=microduck_mdp.neck_joint_pos_l2, + weight=-0.2, + params={"pattern": NECK_PATTERN_NO_YAW}, + ) + cfg.rewards["joint_torques_l2"] = RewardTermCfg( + func=microduck_mdp.joint_torques_l2, weight=-1e-3 + ) + + # === TERMINATIONS === + cfg.terminations["nan_state"] = TerminationTermCfg( + func=microduck_mdp.robot_state_is_nan, time_out=False, + ) + + # === EVENTS === + cfg.events["reset_action_history"] = EventTermCfg( + func=microduck_mdp.reset_action_history, mode="reset", + ) + del cfg.events["foot_friction"] + + if ENABLE_VELOCITY_PUSHES: + cfg.events["push_robot"] = EventTermCfg( + func=mdp.push_by_setting_velocity, + mode="interval", + interval_range_s=VELOCITY_PUSH_INTERVAL_S, + params={ + "velocity_range": {"x": VELOCITY_PUSH_RANGE, "y": VELOCITY_PUSH_RANGE}, + "asset_cfg": SceneEntityCfg("robot"), + }, + ) + + cfg.events["reset_base"].params["pose_range"]["z"] = (0.1335, 0.1435) + # Élan d'entrée : injecté via reset_root_state_uniform (état par défaut PROPRE + # + range), et NON via push_by_setting_velocity en mode reset, qui additionne à + # une vitesse racine potentiellement divergente et fait exploser le free-joint + # de la base -> NaN. Régression connue du roller_crouch. + cfg.events["reset_base"].params["velocity_range"] = {"x": ENTRY_VELOCITY_X} + + if ENABLE_WHEEL_FRICTION_RANDOMIZATION: + cfg.events["randomize_wheel_friction"] = EventTermCfg( + func=dr.dof_frictionloss, + mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot", joint_names=(r"^passive_.*",)), + "operation": "abs", + "ranges": (0.000, 0.000), + }, + ) + if ENABLE_COM_RANDOMIZATION: + cfg.events["randomize_com"] = EventTermCfg( + func=dr.body_ipos, mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",)), + "operation": "add", + "ranges": (-COM_RANDOMIZATION_RANGE, COM_RANDOMIZATION_RANGE), + }, + ) + if ENABLE_HEAD_COM_RANDOMIZATION: + cfg.events["randomize_head_com"] = EventTermCfg( + func=dr.body_ipos, mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot", body_names=HEAD_BODY_NAMES), + "operation": "add", + "ranges": (-HEAD_COM_RANDOMIZATION_RANGE, HEAD_COM_RANDOMIZATION_RANGE), + }, + ) + if ENABLE_MASS_INERTIA_RANDOMIZATION: + _mi_lo, _mi_hi = MASS_INERTIA_RANDOMIZATION_RANGE + cfg.events["randomize_mass_inertia"] = EventTermCfg( + func=dr.pseudo_inertia, mode="startup", + params={ + "asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",)), + "alpha_range": (math.log(_mi_lo) / 2.0, math.log(_mi_hi) / 2.0), + }, + ) + if ENABLE_JOINT_FRICTION_RANDOMIZATION: + cfg.events["randomize_joint_friction"] = EventTermCfg( + func=microduck_mdp.randomize_bam_friction, mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot"), + "scale_range": JOINT_FRICTION_RANDOMIZATION_RANGE, + }, + ) + if ENABLE_ARMATURE_RANDOMIZATION: + cfg.events["randomize_armature"] = EventTermCfg( + func=dr.joint_armature, mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot", joint_names=(r"^(?!passive_).*",)), + "operation": "scale", + "ranges": ARMATURE_RANDOMIZATION_RANGE, + }, + ) + + # === OBSERVATIONS (unified 61D layout) === + del cfg.observations["actor"].terms["base_lin_vel"] + del cfg.observations["critic"].terms["foot_height"] + del cfg.observations["actor"].terms["height_scan"] + del cfg.observations["critic"].terms["height_scan"] + cfg.observations["critic"].terms["base_lin_vel"] = ObservationTermCfg( + func=mdp.base_lin_vel, scale=1.0, + ) + + gravity_term_name = "projected_gravity" + cfg.observations["actor"].terms[gravity_term_name] = deepcopy( + cfg.observations["actor"].terms[gravity_term_name] + ) + cfg.observations["actor"].terms["base_ang_vel"] = deepcopy( + cfg.observations["actor"].terms["base_ang_vel"] + ) + cfg.observations["actor"].terms["base_ang_vel"].delay_min_lag = 0 + cfg.observations["actor"].terms["base_ang_vel"].delay_max_lag = 1 + cfg.observations["actor"].terms["base_ang_vel"].delay_update_period = 64 + cfg.observations["actor"].terms[gravity_term_name].delay_min_lag = 0 + cfg.observations["actor"].terms[gravity_term_name].delay_max_lag = 1 + cfg.observations["actor"].terms[gravity_term_name].delay_update_period = 64 + cfg.observations["actor"].terms["base_ang_vel"].noise = Unoise(n_min=-0.03, n_max=0.03) + cfg.observations["actor"].terms[gravity_term_name].noise = Unoise(n_min=-0.01, n_max=0.01) + cfg.observations["actor"].terms["joint_pos"].noise = Unoise(n_min=-0.001, n_max=0.001) + cfg.observations["actor"].terms["joint_vel"].noise = Unoise(n_min=-0.25, n_max=0.25) + + if ENABLE_IMU_ORIENTATION_RANDOMIZATION: + av = cfg.observations["actor"].terms["base_ang_vel"] + av.func = microduck_mdp.base_ang_vel_imu_misaligned + av.params = {"max_angle_deg": IMU_ORIENTATION_RANDOMIZATION_ANGLE} + g = cfg.observations["actor"].terms[gravity_term_name] + g.func = microduck_mdp.projected_gravity_imu_misaligned + g.params = {"max_angle_deg": IMU_ORIENTATION_RANDOMIZATION_ANGLE} + + cfg.observations["actor"].terms["joint_vel"] = deepcopy( + cfg.observations["actor"].terms["joint_vel"] + ) + cfg.observations["actor"].terms["joint_vel"].delay_min_lag = 1 + cfg.observations["actor"].terms["joint_vel"].delay_max_lag = 1 + cfg.observations["actor"].terms["joint_vel"].delay_update_period = 0 + + passive_excluded = SceneEntityCfg("robot", joint_names=(r"^(?!passive_).*",)) + for grp in ("actor", "critic"): + for term in ("joint_pos", "joint_vel"): + cfg.observations[grp].terms[term] = deepcopy(cfg.observations[grp].terms[term]) + cfg.observations[grp].terms[term].params["asset_cfg"] = deepcopy(passive_excluded) + + if ENABLE_ENCODER_BIAS: + cfg.events["encoder_bias"].params["bias_range"] = ENCODER_BIAS_RANGE + cfg.observations["actor"].terms["joint_pos"].params["biased"] = True + cfg.observations["critic"].terms["joint_pos"].params["biased"] = False + else: + cfg.events.pop("encoder_bias", None) + + wheel_cfg = SceneEntityCfg("robot", joint_names=(r"^passive_.*",)) + cfg.observations["critic"].terms["wheel_vel"] = ObservationTermCfg( + func=mdp.joint_vel_rel, scale=1.0, params={"asset_cfg": wheel_cfg}, + ) + + for group in ("actor", "critic"): + cfg.observations[group].terms["head_command"] = ObservationTermCfg( + func=microduck_mdp.zero_command_padding, params={"dim": 4}, + ) + cfg.observations[group].terms["body_command"] = ObservationTermCfg( + func=microduck_mdp.zero_command_padding, params={"dim": 6}, + ) + + # === COMMAND: phase (comme ground_pick / roller_crouch) === + command: UniformVelocityCommandCfg = cfg.commands["twist"] + command.rel_standing_envs = 0.0 + command.rel_heading_envs = 0.0 + # period=4.0 = défaut de --ground-pick-period (rien à passer au runtime) ; + # randomize_phase=False -> chaque épisode démarre debout à phase 0, comme le + # bouton au déploiement. Épisode 20 s = 5 cycles complets du geste. + cfg.commands["twist"] = microduck_mdp.GroundPickPhaseCommandCfg( + **{ + **vars(command), + "class_type": microduck_mdp.GroundPickPhaseCommand, + "period": SPIN_PERIOD, + "randomize_phase": False, + } + ) + + cfg.scene.terrain.terrain_type = "plane" + cfg.scene.terrain.terrain_generator = None + + # === CURRICULUM === + del cfg.curriculum["terrain_levels"] + del cfg.curriculum["command_vel"] + cfg.curriculum["action_rate_weight"] = CurriculumTermCfg( + func=microduck_mdp.reward_weight, + params={ + "reward_name": "action_rate_l2", + "weight_stages": [ + {"step": 0, "weight": -0.5}, + {"step": 250 * 24, "weight": -0.8}, + {"step": 500 * 24, "weight": -1.0}, + ], + }, + ) + # L'amorce ciseau s'efface : elle lance le bon mécanisme puis laisse la policy + # affiner son propre geste (fréquence de pompage libre). + cfg.curriculum["leg_antisym_weight"] = CurriculumTermCfg( + func=microduck_mdp.reward_weight, + params={ + "reward_name": "leg_antisymmetry", + "weight_stages": [ + {"step": 0, "weight": 1.0}, + {"step": 1500 * 24, "weight": 0.5}, + {"step": 3000 * 24, "weight": 0.25}, + ], + }, + ) + if ENABLE_COM_RANDOMIZATION: + cfg.curriculum["com_range"] = CurriculumTermCfg( + func=microduck_mdp.com_range_curriculum, + params={ + "event_name": "randomize_com", + "range_stages": [ + {"step": 0, "range": 0.003}, + {"step": 500 * 24, "range": 0.005}, + {"step": 1000 * 24, "range": 0.01}, + ], + }, + ) + if ENABLE_HEAD_COM_RANDOMIZATION: + cfg.curriculum["head_com_range"] = CurriculumTermCfg( + func=microduck_mdp.com_range_curriculum, + params={ + "event_name": "randomize_head_com", + "range_stages": [ + {"step": 0, "range": 0.003}, + {"step": 500 * 24, "range": 0.005}, + {"step": 1000 * 24, "range": 0.01}, + ], + }, + ) + + return cfg + + +MicroduckSpinRlCfg = RslRlOnPolicyRunnerCfg( + actor=RslRlModelCfg( + hidden_dims=(512, 256, 128), + activation="elu", + obs_normalization=True, + distribution_cfg={ + "class_name": "GaussianDistribution", + "init_std": 1.0, + "std_type": "scalar", + }, + ), + critic=RslRlModelCfg( + hidden_dims=(512, 256, 128), + activation="elu", + obs_normalization=True, + ), + algorithm=PpoWithSymmetryCfg( + value_loss_coef=1.0, + use_clipped_value_loss=True, + clip_param=0.2, + entropy_coef=0.01, + num_learning_epochs=5, + num_mini_batches=4, + learning_rate=1.0e-3, + schedule="adaptive", + gamma=0.99, + lam=0.95, + desired_kl=0.01, + max_grad_norm=1.0, + symmetry_cfg=SYMMETRY_CFG if ENABLE_SYMMETRY else None, + ), + wandb_project="mjlab_microduck", + experiment_name="spin", + run_name="spin", + save_interval=250, + num_steps_per_env=24, + max_iterations=8_000, +) +``` + +- [ ] **Step 4 : Enregistrer la tâche** + +Dans `src/mjlab_microduck/tasks/__init__.py`, ajouter l'import après le bloc `microduck_roller_slope_env_cfg` (vers la ligne 66) : + +```python +from .microduck_spin_env_cfg import ( + make_microduck_spin_env_cfg, + MicroduckSpinRlCfg, +) +``` + +Puis l'enregistrement à la fin du fichier, après le bloc `Mjlab-RollerSlope-Flat-MicroDuck` : + +```python +register_mjlab_task( + task_id="Mjlab-Spin-Flat-MicroDuck", + env_cfg=make_microduck_spin_env_cfg(), + play_env_cfg=make_microduck_spin_env_cfg(play=True), + rl_cfg=MicroduckSpinRlCfg, + runner_cls=MicroduckOnPolicyRunner, +) +print("✓ Spin task registered: Mjlab-Spin-Flat-MicroDuck") +``` + +- [ ] **Step 5 : Lancer les tests de config** + +Run: `uv run --with pytest pytest tests/test_spin_cfg.py -q` +Expected: PASS (8 tests) + +- [ ] **Step 6 : Vérifier que la tâche est bien enregistrée** + +Run: `uv run python -c "import mjlab_microduck.tasks"` +Expected: la sortie contient `✓ Spin task registered: Mjlab-Spin-Flat-MicroDuck`, sans exception + +- [ ] **Step 7 : Vérifier la non-régression de toute la suite** + +Run: `uv run --with pytest pytest tests/ -q` +Expected: PASS + +- [ ] **Step 8 : Commit** + +```bash +git add src/mjlab_microduck/tasks/microduck_spin_env_cfg.py src/mjlab_microduck/tasks/__init__.py tests/test_spin_cfg.py +git commit -m "spin: env cfg Mjlab-Spin-Flat-MicroDuck + enregistrement" +``` + +--- + +### Task 6 : Vérification en simulation (smoke run) + +Les tests unitaires ne prouvent pas que l'env tourne réellement : les 61D de l'obs, les capteurs de contact, les résolutions de joints et l'absence de NaN ne se voient qu'en lançant le simulateur. Cette tâche est la porte de sortie du plan. + +**Files:** +- Modify: `docs/superpowers/specs/2026-08-04-spin-env-design.md` (noter la mesure de demi-voie et le résultat du smoke run) + +**Interfaces:** +- Consumes: la tâche enregistrée `Mjlab-Spin-Flat-MicroDuck` (Task 5) +- Produces: rien de code — une vérification et une note + +- [ ] **Step 1 : Vérifier la dimension réelle de l'obs actor** + +Run: +```bash +uv run python -c " +import mjlab_microduck.tasks # enregistre les tâches +from mjlab_microduck.tasks.microduck_spin_env_cfg import make_microduck_spin_env_cfg +from mjlab_microduck.tasks.microduck_roller_crouch_env_cfg import make_microduck_roller_crouch_env_cfg +spin = make_microduck_spin_env_cfg() +crouch = make_microduck_roller_crouch_env_cfg() +print('spin actor terms:', list(spin.observations['actor'].terms.keys())) +print('crouch actor terms:', list(crouch.observations['actor'].terms.keys())) +assert list(spin.observations['actor'].terms.keys()) == list(crouch.observations['actor'].terms.keys()), 'layout obs != crouch -> ONNX ne chargera pas dans le slot' +print('OK: layout obs actor identique au crouch') +" +``` +Expected: `OK: layout obs actor identique au crouch`. Si les listes diffèrent, corriger le bloc OBSERVATIONS de l'env cfg avant de continuer — c'est la condition pour que l'ONNX charge dans le slot du runtime. + +- [ ] **Step 2 : Lancer un entraînement très court avec garde NaN** + +Run: +```bash +uv run train Mjlab-Spin-Flat-MicroDuck \ + --env.scene.num-envs 64 \ + --agent.max_iterations 5 \ + --enable-nan-guard +``` +Expected: 5 itérations sans exception, sans NaN, et les logs `Episode_Reward/` listent bien `spin_rate_track`, `spin_rate_l1`, `spin_stay_in_place`, `spin_wheel_differential`, `spin_grounded`, `leg_antisymmetry`. + +Si des NaN apparaissent : les dumps sont dans `/tmp/mjlab/nan_dumps/`. Le suspect n°1 est la vitesse d'entrée — vérifier qu'elle passe bien par `reset_base.velocity_range` et pas par un push en `mode="reset"`. + +- [ ] **Step 3 : Lancer un entraînement de calibrage (500 itérations)** + +Run: +```bash +uv run train Mjlab-Spin-Flat-MicroDuck --env.scene.num-envs 4096 --agent.max_iterations 500 +``` +Expected: `Episode_Reward/spin_rate_track` **monte** sur les 500 itérations. C'est le signal que l'objectif est apprenable. Noter la valeur atteinte. + +Si la courbe reste plate, appliquer le « Plan B » du spec dans l'ordre : (1) curriculum de vitesse 3 → 6 rad/s, (2) monter `spin_wheel_differential` et retarder la décroissance de `leg_antisymmetry`, (3) élargir `std` de 1.5 à 2.5. + +- [ ] **Step 4 : Regarder le geste** + +Run: `uv run scripts/play_latest.py` +Expected: le robot tente une rotation anti-horaire sur place. À 500 itérations le geste sera brut ; ce qu'on vérifie c'est qu'il tourne **dans le bon sens**, qu'il ne part pas en translation, et qu'il ne finit pas systématiquement par tomber. + +- [ ] **Step 5 : Noter les mesures dans le spec** + +Ajouter une section `## Résultats de la vérification initiale` à la fin de `docs/superpowers/specs/2026-08-04-spin-env-design.md`, contenant : la demi-voie mesurée (Task 3 Step 5), la valeur retenue pour `omega_scale`, le résultat du smoke run 5 itérations, et la valeur de `Episode_Reward/spin_rate_track` à 500 itérations. + +- [ ] **Step 6 : Commit** + +```bash +git add docs/superpowers/specs/2026-08-04-spin-env-design.md +git commit -m "spin: notes de verification initiale (demi-voie, smoke run, 500 it.)" +``` + +--- + +## Notes pour l'implémenteur + +- **Ordre obligatoire** : Task 1 → 2 → 3 → 4 → 5 → 6. Les tâches 2 à 4 dépendent du faux env créé dans la Task 2 ; la Task 5 consomme toutes les fonctions des tâches 1 à 4. +- **Le piège n°1 de cette tâche** est la reward `angular_momentum` : si elle reste dans l'env, elle pénalise la norme 3D du moment angulaire et le spin ne décollera jamais. Le test `test_angular_momentum_reward_is_removed` la garde. +- **Le piège n°2** est la convention de signe miroir gauche/droite du robot : le ciseau se mesure par `|q_G − q_D|`, pas par `|q_G + q_D|`. Comparer avec `leg_symmetry_reward` (ligne ~3769 de `mdp.py`) en cas de doute. +- **Le piège n°3** est la parité d'obs 61D : toute divergence par rapport au layout du crouch rend l'ONNX inutilisable dans le slot du runtime. La Task 6 Step 1 la vérifie explicitement. +- Ne pas activer la symétrie PPO, quelle que soit la tentation d'accélérer l'apprentissage. +- **Hors périmètre de ce plan** : l'entraînement complet (8000 itérations), l'export ONNX et le déploiement dans le slot du runtime. Les commandes sont dans le spec ; le plan s'arrête quand l'env est vérifié apprenable (Task 6). diff --git a/docs/superpowers/specs/2026-07-17-roller-crouch-glide-design.md b/docs/superpowers/specs/2026-07-17-roller-crouch-glide-design.md new file mode 100644 index 0000000..f0757c1 --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-roller-crouch-glide-design.md @@ -0,0 +1,158 @@ +# Design — Roller Crouch-Glide (« s'accroupir en glissant » au bouton) + +**Date :** 2026-07-17 +**Statut :** conception validée, prêt pour le plan d'implémentation + +## Contexte + +Le robot microduck sait patiner (policy roller, tâche `Mjlab-Velocity-Flat-MicroDuck-Rollers`). +On veut un nouveau geste : sur un appui bouton, il **s'accroupit et continue de glisser +sur son élan** (comme un patineur en position basse), maintient ~1 s, puis **se relève** +tout seul et reprend le patinage. + +Contrainte forte de l'utilisatrice : **ne pas modifier le runtime Rust** +(`apirrone/microduck_runtime`, installé en binaire). Le geste doit donc réutiliser un +mécanisme déjà présent dans le runtime. + +**Découverte clé :** le runtime a déjà un slot « comportement one-shot déclenché au +bouton » : `--ground-pick`. Il est déclenché par le **bouton A** (front montant), +joue une policy ONNX pilotée par une **phase** pendant une durée fixe, puis revient +automatiquement à la policy principale. Surtout, il utilise **exactement le même +layout d'observation 61D** que la policy roller — les deux sont interchangeables au +runtime. C'est le véhicule idéal, sans une ligne de Rust. + +Compromis accepté : le geste est **one-shot** (durée fixe, pas de « bascule maintenue »). +La durée de l'accroupi est fixée par la période du slot. + +## Approche retenue (approche B) + +Créer une **nouvelle tâche mjlab** entraînée sur le robot rollers, qui joue +descente → glisse accroupi → remontée, piloté par la phase du slot ground-pick. +L'exporter en ONNX et la charger via `--ground-pick`. Aucune modif Rust. + +### Fichiers concernés + +| Fichier | Action | +|---|---| +| `src/mjlab_microduck/tasks/microduck_roller_crouch_env_cfg.py` | **Nouveau.** L'env, hybride roller + ground-pick. | +| `src/mjlab_microduck/tasks/mdp.py` | **Ajout** de la reward `crouch_glide_height_by_phase`. | +| `src/mjlab_microduck/tasks/__init__.py` | **Ajout** : enregistrer `Mjlab-RollerCrouch-Flat-MicroDuck`. | + +### Réutilisation (ne rien réinventer) + +- **Physique / robot roller** ← `microduck_velocity_rollers_env_cfg.py` : + `MICRODUCK_WALK_ROLLERS_ROBOT_CFG` (14 joints actifs + 4 roues passives), + capteur de contact sur les `roller_blade`, DR friction des roulements + (`randomize_wheel_friction` + curriculum), obs 14-dim (roues exclues via + `SceneEntityCfg("robot", joint_names=(r"^(?!passive_).*",))`), `action.scale=1.0`, + `kp_fw=200`. +- **Machinerie phase / one-shot** ← `microduck_ground_pick_env_cfg.py` : + commande `microduck_mdp.GroundPickPhaseCommand` **réutilisée telle quelle** + (produit le `[cos(2πφ), sin(2πφ), 0]` que le runtime enverra dans le slot twist), + padding head/body à zéro (`zero_command_padding`), terminaison `robot_state_is_nan`, + `reset_action_history`. +- **DR sim2real** ← repris du roller env sans changement (IMU misalignment obs-level, + encoder bias, masse/inertie, friction BAM, armature, pushes doux ±0.2). + +## Le cœur : cible de hauteur « en trapèze » pilotée par la phase + +Seule vraie nouveauté. Au lieu de descendre la bouche (ground-pick), on pilote la +**hauteur du tronc** (`com_height` du `trunk_base`) selon la phase, avec un palier bas : + +``` +hauteur + haute ┐ ┌── debout (rend la main à la policy roller) + │ \ / + basse│ \_______________/ accroupi + glisse (palier 1 s) + └───────────────────────► phase + 0 0.375 0.625 1 +``` + +- φ ∈ [0, 0.375] : descente vers la hauteur accroupie +- φ ∈ [0.375, 0.625] : **maintien accroupi** (= 1 s sur une période de 4 s) → glisse +- φ ∈ [0.625, 1.0] : remontée vers la pose roller debout + +**Nouvelle reward `crouch_glide_height_by_phase(env, command_name, height_low, +height_high, hold_lo=0.375, hold_hi=0.625, std=...)`** dans `mdp.py` : +lit la phase depuis la commande, calcule la hauteur-cible (interpolée haut→bas→haut, +plate sur le palier), récompense `exp(-((h_mesurée - h_cible)/std)²)`. +S'inspirer des fonctions `com_height_target` (mdp.py:694) et des +`interpolated/multistage height target` déjà présentes. + +Valeurs de départ : `height_high ≈ 0.11` m (hauteur roller debout, cf. bande +`com_height_target` roller 0.0935–0.1235), `height_low ≈ 0.075` m (accroupi ; +à affiner en play). La phase est reconstruite depuis `atan2(sin, cos)` de la commande. + +## Récompenses + +| Reward | Rôle | Origine | +|---|---|---| +| `crouch_glide_height_by_phase` | Cible principale (haut→bas→haut) | **nouveau** | +| `wheel_speed` (poids réduit ~2–3) | Garder l'élan, ne pas freiner pendant l'accroupi | roller env (`wheel_speed_reward`) | +| `upright` (≈2), `body_ang_vel` (−0.05), `angular_momentum` (−0.02) | Équilibre / stabilité | roller env | +| `return_pose` (fin de phase) | Converger vers la pose roller debout pour rendre la main proprement | adapté de `ground_pick_return_pose` | +| `feet_flat` (−2) | Lames à plat → glisse stable | roller env | +| `action_rate_l2`, `neck_action_rate_l2`, `joint_torques_l2`, `self_collisions` | Lissage / transfert sim2real | les deux envs | + +**Explicitement PAS inclus :** `braking` (on ne veut pas s'arrêter), `mouth_ground_proximity` +/ `mouth_perpendicular_to_ground` (on ne touche pas le sol), `skating_air_time` / +`single_support` / `glide` (pas de stride pendant le trick — on glisse passivement). + +## Entraînement + +- `MicroduckRollerCrouchRlCfg` = copie de `MicroduckRollersRlCfg` + (MLP 512/256/128, ELU, obs_normalization, PPO, `experiment_name="roller_crouch"`). +- Enregistrer dans `tasks/__init__.py` : + `register_mjlab_task(task_id="Mjlab-RollerCrouch-Flat-MicroDuck", ...)`. +- Lancer : + ```bash + uv run train Mjlab-RollerCrouch-Flat-MicroDuck \ + --env.scene.num-envs 4096 --agent.max_iterations 8000 + ``` +- Épisodes démarrés avec une **vitesse d'entrée réaliste** (le robot arrive en roulant), + sinon il n'aura pas d'élan à conserver pendant l'accroupi. À câbler via un event de + reset (vitesse initiale non nulle) ou un push au début d'épisode. + +## Export + déploiement (flags runtime exacts) + +Export ONNX (le normaliseur est baké par `export.py`), puis : + +```bash +microduck_runtime --variant pre-alpha --new-cmd-obs --roller \ + --model output.onnx \ + --new-dxl-imu --kp 200 --action-scale 0.8 \ + --max-linear-vel 0.6 --max-linear-vel-backward 0.5 --max-angular-vel 0.0 \ + --ground-pick roller_crouch.onnx \ + --ground-pick-period 5.0 \ + --ground-pick-kp-ratio 1.0 \ + --ground-pick-action-scale 0.8 +``` + +Bouton **A** → crouch-glide, puis retour auto à la policy roller. + +**Pièges de parité entraînement/déploiement (importants pour le sim2real) :** +- `--ground-pick-kp-ratio 1.0` : le défaut est **0.6** (baisse kp à 120 pendant le trick). + On entraîne à kp=200 → il faut forcer **1.0** pour que ça corresponde. +- `--ground-pick-action-scale` doit matcher l'`action_scale` d'entraînement (0.8 ci-dessus). +- `--ground-pick-period 5.0` doit matcher la période/longueur de mouvement entraînée + (défaut 4.0, on le garde). + +## Risques et vérification + +- **One-shot, durée fixe :** l'accroupi dure `ground-pick-period` puis remonte tout seul. + Pas de maintien libre — limite acceptée de l'approche B. +- **Élan pendant le trick :** la phase remplace la commande de vitesse → **pas de poussée + active** pendant l'accroupi. Si l'élan d'entrée est trop faible, il ralentit. D'où + l'entraînement avec vitesse d'entrée réaliste. +- **Vérification :** + 1. En sim (`play`) : il descend, garde les roues qui tournent pendant le palier, + se relève sans tomber, et la pose finale rejoint proprement la pose roller debout. + 2. Sur le vrai robot : lancer à petite vitesse, appuyer sur A, observer. + 3. Confirmer que la policy roller reprend la main proprement après le retour. + +## Questions ouvertes / à confirmer pendant l'implémentation + +- Valeur exacte de `height_low` (accroupi) — à régler en play. +- Meilleure façon d'injecter la vitesse d'entrée à l'épisode (event reset vs push initial). +- Poids relatif `wheel_speed` vs `crouch_glide_height_by_phase` (garder l'élan sans + empêcher de s'accroupir). diff --git a/docs/superpowers/specs/2026-07-22-roller-slope-design.md b/docs/superpowers/specs/2026-07-22-roller-slope-design.md new file mode 100644 index 0000000..130ebba --- /dev/null +++ b/docs/superpowers/specs/2026-07-22-roller-slope-design.md @@ -0,0 +1,145 @@ +# Mode pente — `roller_slope` (descente passive équilibrée) + +Date : 2026-07-22 +Statut : design validé, prêt pour le plan d'implémentation. + +## Objectif + +Entraîner une politique dédiée où **microduck (sur rollers) démarre sur du plat +avec une petite impulsion vers l'avant, roule jusqu'à une rampe descendante, et +se laisse glisser jusqu'en bas en restant debout et équilibré**. Aucun pilotage +pendant la descente : le seul objectif de la politique est de **ne pas tomber**. + +La politique doit gérer des rampes de raideur croissante (**~2° → ~20°**) grâce à +un curriculum de difficulté. + +## Décisions cadrées (brainstorming) + +| Sujet | Décision | +|---|---| +| Comportement | Descente passive équilibrée (la gravité fait avancer, pas de pédalage imposé) | +| Pilotage | Aucun — équilibre pur, commande `twist` forcée à zéro | +| Approche | **A** — tâche dédiée, isolée (comme `roller_crouch`) | +| Forme du terrain | **Rampe simple** : plat de départ + rampe descendante (pas de pyramide) | +| Scénario épisode | Spawn sur le plat → vitesse d'impulsion vers l'avant → glisse sur la rampe | +| Raideur | Curriculum **0/2° → 20°** | +| Déploiement | Flag `--slope ` + touche **`Y`** dans `infer_policy.py` (Y est libre) | + +## Architecture + +### 1. Nouvelle tâche + +Fichier : `src/mjlab_microduck/tasks/microduck_roller_slope_env_cfg.py`, cloné de +`microduck_velocity_rollers_env_cfg.py`. + +- Même robot rollers (`MICRODUCK_WALK_ROLLERS_ROBOT_CFG`), même physique, même + domain randomization / bruit / délais. +- **Même observation 61D** (twist + head/body en zéro-padding) → la politique + charge par le chemin `--new-cmd-obs` du runtime et reste interchangeable avec + les autres politiques rollers. +- Enregistrement dans `src/mjlab_microduck/tasks/__init__.py` via + `register_mjlab_task`, avec une config PPO `MicroduckRollerSlopeRlCfg` + (`experiment_name`/`run_name` = `roller_slope`). + +### 2. Terrain « plat + rampe » (custom) + +Les terrains inclinés fournis par mjlab sont des pyramides ; on écrit donc un +`SubTerrainCfg` dédié (p. ex. `FlatRampTerrainCfg`) dont la méthode +`function(difficulty, spec, rng)` construit : + +- une **zone plate de départ** (longueur ~1–2 m) où le robot spawne ; +- une **rampe descendante** à la suite, dont l'angle est + **interpolé par `difficulty`** sur `[~2°, ~20°]`. + +Le terrain est monté via `TerrainEntityCfg(terrain_type="generator", ...)` avec un +`TerrainGeneratorCfg` qui génère plusieurs niveaux de difficulté (donc plusieurs +angles de rampe). L'origine de chaque environnement doit tomber **sur la zone +plate**, la rampe devant lui. + +> Risque d'implémentation à traiter dans le plan : positionnement de l'origine de +> spawn sur le plat (pas au centre de la tuile), et orientation de la rampe pour +> que « devant » = « vers le bas ». + +### 3. Commande = aucune + +Slot `twist` neutralisé : `rel_standing_envs = 1.0`, ranges de vitesse à 0, +`rel_heading_envs = 0.0`. Head/body restent en zéro-padding. La politique ne +reçoit aucune consigne de déplacement. + +### 4. Reset & vitesse d'impulsion + +- `reset_base` : spawn au repos sur le plat, hauteur `z` nominale rollers + (~`0.1335–0.1435`, comme le roller env). +- **Vitesse d'entrée** injectée via le `velocity_range` de + `reset_root_state_uniform` (état propre + range), **pas** via + `push_by_setting_velocity` (qui s'additionne à l'état courant et peut faire + diverger le free-joint → NaN — leçon déjà apprise sur `roller_crouch`) : + `x ≈ (0.2, 0.5) m/s` vers l'avant. +- Pushs aléatoires légers conservés pendant l'épisode (robustesse), comme le + roller env. + +### 5. Récompenses + +Cœur « rester droit + posture naturelle », anti-optimum-paresseux (éviter qu'il +s'écrase au sol pour maximiser la stabilité) : + +- `upright` (tronc vertical) — **principale** +- `alive` (bonus de survie par pas) +- **pose debout nominale** : récompense vers la pose HOME (mécanique + d'interpolation de pose reprise de `roller_crouch`, mais cible fixe = debout), + pour garder une stance rollers normale plutôt qu'un accroupi défensif +- `feet_flat` (rollers à plat au sol) +- `body_ang_vel`, `angular_momentum` (pas de tremblement / vrille) +- `action_rate_l2`, `neck_action_rate_l2`, `joint_torques_l2`, + `self_collisions` (douceur + sim2real) + +> Pas de récompense de vitesse/frein : la descente est passive. On ne récompense +> pas « aller vite », seulement « rester droit en descendant ». + +### 6. Terminaisons + +- **Chute** : `bad_orientation` (tronc trop incliné). +- **Bas atteint** : `out_of_terrain_bounds` (le robot est arrivé en bas de la + rampe → reset). +- `nan_state`, time-out. + +### 7. Curriculum de difficulté (raideur) + +Progression **doux → raide** : commencer sur des rampes quasi plates, augmenter +l'angle vers 20° au fur et à mesure des réussites. + +> Risque d'implémentation : le curriculum standard `terrain_levels_vel` promeut +> selon la distance parcourue par rapport à la vitesse commandée. Ici la commande +> est nulle, donc **il faut un critère de promotion custom** : promouvoir si le +> robot a survécu / atteint le bas sans tomber, rétrograder s'il chute tôt. + +### 8. Déploiement — bouton `Y` + +Dans `scripts/infer_policy.py` : + +- nouveau flag `--slope ` chargeant la politique pente comme session + supplémentaire (même schéma que `--walking` / `--standing` / `--ground-pick`) ; +- `GLFW_KEY_Y = 89` (aujourd'hui **libre** — la tête est sur `H`) qui **bascule** + la session active vers/depuis la politique pente ; +- ligne d'aide clavier ajoutée. + +Aucun contrôle existant n'est cassé (contrairement à un partage de la touche `H` +du contrôle de la tête). + +## Ce qui n'est PAS dans le périmètre (YAGNI) + +- Pas de pilotage gauche/droite ni de freinage en descente. +- Pas de montée de pente ni de traversée. +- Pas de pyramide ni de terrains multi-directions. +- Pas de fine-tuning depuis les poids roller existants (entraînement from + scratch). + +## Livrables + +1. `microduck_roller_slope_env_cfg.py` (env + `FlatRampTerrainCfg` + PPO cfg). +2. Enregistrement de la tâche dans `tasks/__init__.py`. +3. Récompenses/curriculum custom nécessaires dans `tasks/mdp.py` (pose-debout, + promotion de niveau). +4. Branchement `--slope` + touche `Y` dans `scripts/infer_policy.py`. +5. Tests unitaires pour les fonctions pures (angle de rampe par difficulté, + éventuel critère de promotion). diff --git a/docs/superpowers/specs/2026-07-23-swizzle-env-design.md b/docs/superpowers/specs/2026-07-23-swizzle-env-design.md new file mode 100644 index 0000000..358f94c --- /dev/null +++ b/docs/superpowers/specs/2026-07-23-swizzle-env-design.md @@ -0,0 +1,103 @@ +# Swizzle roller environment — design + +**Date:** 2026-07-23 +**Branch:** `new_pre_alpha_rollers` + +## Goal + +A **separate** roller task that produces a **clean classic swizzle**: both blades +stay on the ground, the legs spread out and pull back in **symmetrically** +(hourglass pattern), propelling the duck forward. This is a simpler, more stable +alternative to the alternating stride (`Mjlab-Velocity-Flat-MicroDuck-Rollers`), +motivated by the stride not transferring well to the real robot. The stride env is +left untouched. + +Sim2real is a target: same robot, observations, command semantics, domain +randomization and ONNX export as the stride env, so it **deploys identically** +(`microduck_runtime ... --roller`, same flags). + +## Approach (chosen: A — remove anti-swizzle + reward symmetry) + +The base roller velocity recipe *naturally* converges to a swizzle (this is the +attractor we fought against for the stride). So the simplest way to a clean swizzle +is to **remove the anti-swizzle machinery** and **reward the swizzle's defining +features** (symmetry, feet grounded). No phase scripting. + +Rejected: B (explicit hourglass foot-pattern shaping) and C (phase-driven scripted +trajectory) — more complex, only needed if A's swizzle looks messy (rhythm/amplitude). + +## Structure + +- New file `src/mjlab_microduck/tasks/microduck_velocity_swizzle_env_cfg.py` with + `make_microduck_velocity_swizzle_env_cfg(play=False)` and `MicroduckSwizzleRlCfg`. + Built from `make_velocity_env_cfg()` + the roller robot, mirroring + `microduck_velocity_rollers_env_cfg.py`'s structure (obs, DR, command, curricula). +- Register `Mjlab-Velocity-Swizzle-MicroDuck` in `tasks/__init__.py`. +- Reuse everything sim2real from the stride env: robot cfg, 61D obs layout, command + (cmd_x push/coast/brake, straight-line: `ang_vel_z=(0,0)`, `heading_hold`), all DR + events + curricula (com, wheel_friction), `action_over_limit`, ONNX export path. + +## Reward recipe + +**Kept** (task + stability + sim2real): +`wheel_speed` (forward propulsion, the task), `braking`, `upright`, `com_height_target`, +`pose`, `forward_lean`, `heading_hold`, `action_over_limit`, `feet_flat`, +`self_collisions`, regularizers (`action_rate_l2` + curriculum, `neck_action_rate_l2`, +`neck_joint_pos_l2`, `joint_torques_l2`). + +**Removed** (stride / anti-swizzle machinery): +`single_support`, `glide`, `skating_air_time`, `gait_symmetry`, `hip_roll_neutral` +(the last would fight the swizzle's lateral out-motion). + +**Added** (pro-swizzle): +- `leg_symmetry` — reward left/right legs mirroring. The robot uses mirrored L/R + sign conventions, so a symmetric config satisfies `q_left + q_right ≈ 0` per pair. + Return `-mean_pairs |q_left + q_right|` (L1, constant gradient — same form as the + existing `bilateral_symmetry_penalty`) over the leg joint pairs (hip_yaw, hip_roll, + hip_pitch, knee, ankle); used with a positive weight so asymmetry is penalised and + the symmetric swizzle is favoured. This is the swizzle's defining feature. + (Implementation: the existing `bilateral_symmetry_penalty` takes explicit L/R + index lists; add a thin wrapper that resolves the L/R leg-joint pairs by name at + runtime so it can be configured without hard-coded indices.) +- `grounded` — reward both blades in contact (n_contact == 2) while pushing, so the + feet stay down (classic swizzle, no lifting). Small weight. New mdp function + (mirror of `single_support_reward` but rewarding double support). Gate on + `cmd_x >= 0` like the others. + +Leave `hip_roll` pose std loose (as in the stride env) so the legs can spread. + +## New mdp functions (in `tasks/mdp.py`) + +1. `leg_symmetry_reward(env, asset_cfg)` — resolve L/R leg joint pairs by name, + return `-mean_pairs |q_left + q_right|` (used with a positive weight). +2. `grounded_reward(env, sensor_name, command_name)` — reward exactly-two-blades in + contact, scaled by `clamp(cmd_x, 0)`. + +## Command / sim2real (identical to stride) + +`cmd_x` push/coast/brake, `lin_vel_y=0`, `ang_vel_z=(0,0)` (straight-line), full DR +(com, head_com, mass/inertia, joint friction, armature, wheel friction, velocity +pushes, IMU misalignment, encoder bias, obs delays), 61D obs, `vel_scale=0.3`. +Deploys with the same runtime flags as the stride roller policy. + +## PPO config + +Reuse `MicroduckRollersRlCfg`'s hyperparameters (same actor/critic 512-256-128 ELU, +PPO settings, `entropy_coef=0.03`), new `experiment_name`/`run_name` = `velocity_swizzle`. + +## Testing / verification + +- Smoke test: `uv run train Mjlab-Velocity-Swizzle-MicroDuck --env.scene.num-envs 16 + --agent.max-iterations 2` runs without error; `leg_symmetry` and `grounded` appear + in the reward log. +- Watch on a real run: `leg_symmetry` high (symmetric), `grounded` high (both feet + down), `wheel_speed` rising (moves forward). Video: symmetric hourglass swizzle, + both blades on the ground. + +## Tuning knobs (post-first-run) + +- If not symmetric enough → raise `leg_symmetry` weight. +- If it lifts feet → raise `grounded` weight. +- If it barely moves → the symmetry/grounded weights are too high vs `wheel_speed`; + lower them. +- If the swizzle looks messy (rhythm/amplitude) → escalate to Approach B. diff --git a/docs/superpowers/specs/2026-07-24-ground-pick-pose-following-design.md b/docs/superpowers/specs/2026-07-24-ground-pick-pose-following-design.md new file mode 100644 index 0000000..339857b --- /dev/null +++ b/docs/superpowers/specs/2026-07-24-ground-pick-pose-following-design.md @@ -0,0 +1,161 @@ +# Ground-pick par suivi de pose interpolée par la phase + +**Date** : 2026-07-24 +**Branche** : `new_pre_alpha_ground_pick` +**Fichier cible** : `src/mjlab_microduck/tasks/microduck_ground_pick_env_cfg.py` (réécriture en place) +**Task id** : `Mjlab-GroundPick-Flat-MicroDuck` (inchangé) + +## 1. Objectif + +Remplacer l'objectif *espace-tâche* du ground_pick actuel (récompense la bouche +qui descend au sol, puis récompense séparément le retour debout) par un objectif +**directif de suivi de pose** : on définit deux poses articulaires cibles — STAND +et DOWN — et on récompense le suivi de la **pose interpolée par la phase** +(STAND→DOWN→STAND). + +Motivation (reprise de l'approche roller_crouch, validée) : l'objectif par pose +interpolée est **symétrique par construction** — le « se relever » (cible → STAND) +est récompensé exactement comme le « se baisser » (cible → DOWN), ce qui règle le +problème d'optimum paresseux où la policy descend mais remonte mal. Le signal est +**dense à chaque phase** (cible qui bouge en continu), contrairement à une cible +fixe pondérée par `sin` qui ne donne aucun signal aux transitions. + +Le geste reste déclenché au **bouton A** via le slot `--ground-pick` du runtime +(one-shot, retour auto à la policy principale). Obs 61D unifié inchangé → +policy interchangeable dans le slot. + +## 2. Poses cibles + +Résolution des joints **PAR NOM** (`asset.find_joints([name])`) — robuste, cohérent +avec l'approche roller. 14 joints (mouth exclu). + +- **STAND_POSE** = HOME (`default_joint_pos` du modèle). Source du blend ; ne pas + la redéfinir en dur — utiliser le défaut du modèle comme source (blend=0). + Au déploiement, la policy principale reprend depuis HOME → retour propre. + +- **DOWN_POSE** = valeurs initiales issues du **keyframe FOLD** de `scene_walk.xml` + (pli avant profond, tête baissée → bouche vers le sol). Dict par nom en tête de + fichier, **commenté comme remplaçable par une lecture `read_pose.py`** du vrai + robot posé bouche-au-sol. Valeurs de départ : + + ```python + DOWN_POSE = { + "left_hip_yaw": 0.0, "left_hip_roll": 0.0, "left_hip_pitch": 1.57, + "left_knee": 1.57, "left_ankle": 0.0, + "neck_pitch": 1.0, "head_pitch": 1.0, "head_yaw": 0.0, "head_roll": 0.0, + "right_hip_yaw": 0.0, "right_hip_roll": 0.0, "right_hip_pitch": -1.57, + "right_knee": -1.57, "right_ankle": 0.0, + } + ``` + +## 3. Profil de phase (4 segments) + +Commande `GroundPickPhaseCommand` : `[cos(2πφ), sin(2πφ), 0]`, période **4.0 s** +(défaut du slot runtime → pas de flag période à changer au déploiement). + +``` +DESCENT_END=0.15 HOLD_END=0.50 RISE_END=0.65 (période 4 s) +[0, 0.15) descente STAND->DOWN ~0.6 s blend 0->1 +[0.15, 0.50) bas DOWN ~1.4 s blend 1 +[0.50, 0.65) remontée DOWN->STAND ~0.6 s blend 1->0 +[0.65, 1.0) haut STAND (repos) ~1.4 s blend 0 +``` + +`blend ∈ [0,1]` : 0 = STAND (HOME), 1 = DOWN. Cible = `stand + blend·(down - stand)`. +Bornes tunables (constantes en tête de fichier). + +**`randomize_phase=False`** : chaque épisode démarre à φ=0 (= debout), comme le +déclenchement bouton A au déploiement. Les épisodes se réinitialisant à des +instants échelonnés, les envs se décorrèlent naturellement en phase (pas besoin de +randomiser). Nécessite d'ajouter un flag `randomize_phase` à +`GroundPickPhaseCommandCfg` (défaut `True` → autres tâches sit/stand inchangées), +honoré dans `reset()`. + +## 4. Nouvelles fonctions mdp (portées de roller, adaptées, par nom) + +Dans `src/mjlab_microduck/tasks/mdp.py`. Noms distincts du `phase_pose_match` +existant (qui est la variante cible-fixe-pondérée-sin) pour éviter la confusion. + +- **`phase_pose_blend(phase, descent_end, hold_end, rise_end) -> Tensor`** — pur, + blend 4 segments 0..1 (testable en isolation). +- **`_phase_pose_error(env, asset_cfg, command_name, target_pose, descent_end, + hold_end, rise_end, source_pose=None) -> (cur, target)`** — résout les joints par + nom ; `source_pose` = HOME (`default_joint_pos`) si `None` ; calcule + `phase = atan2(sin,cos)/2π % 1`, `blend`, puis `target = source + blend·(target_pose - source)`. +- **`phase_pose_track(env, command_name, target_pose, source_pose=None, std=0.3, + descent_end, hold_end, rise_end, asset_cfg) -> Tensor`** — gaussienne + `exp(-((cur-target)/std)²).mean(-1)`. +- **`phase_pose_track_l1(env, ...même args sans std...) -> Tensor`** — bootstrap + `-(cur-target).abs().mean(-1)` (gradient constant quand la gaussienne sature). + +`target_pose` = `DOWN_POSE` (dict par nom). `source_pose=None` → HOME. + +## 5. Rewards + +Réécriture minimale par rapport à l'actuel — on remplace la mécanique de retour de +pose, on garde la stabilité/régul/sim2real. + +| Reward | Poids | Statut | Rôle | +|---|---|---|---| +| `phase_pose_track` (std 0.3) | **6.0** | **NOUVEAU** | suivi pose interpolée STAND↔DOWN | +| `phase_pose_track_l1` | **2.0** | **NOUVEAU** | bootstrap L1 | +| `mouth_ground_proximity` (std 0.10) | **1.0** | retune (était 2.0) | filet : garantit la bouche au sol si DOWN imparfaite ; gaté approche (+sin) | +| `upright` | 0.2 | gardé | tronc ~vertical (faible, le robot penche) | +| `feet_grounded` | 3.0 | gardé | 2 pieds au sol pendant tout le geste | +| `self_collisions` | -1.0 | gardé | | +| `head_impact_penalty` (seuil 2 N) | -0.5 | gardé | pas de slam tête (DOWN amène la tête bas) | +| `action_rate_l2` | -0.8→-2.0 (curric) | gardé | lissage | +| `neck_action_rate_l2` | -1.0 | gardé | | +| `joint_torques_l2` | -5e-3 | gardé | | +| `body_ang_vel` | -0.05 | gardé | | +| `angular_momentum` | -0.02 | gardé | | +| `soft_landing` | -1e-5 | gardé | | + +**Retirées** : `mouth_perpendicular_to_ground`, `ground_pick_return_pose_legs`, +`ground_pick_return_pose_neck` (remplacées par le suivi de pose). + +Tout le reste **inchangé** : bloc DR (CoM/head-CoM/mass-inertia/friction/armature/ +IMU-misalign/encoder-bias/pushes), obs 61D + padding head/body zéro, terminaisons +(`nan_state`), curricula (`action_rate_weight`, `com_range`, `head_com_range`), +RlCfg (`experiment_name="ground_pick"`). + +## 6. Déploiement (parité sim2real) + +```bash +microduck_runtime ... \ + --ground-pick ground_pick.onnx \ + --ground-pick-period 4.0 \ # = période env (défaut, rien à changer) + --ground-pick-kp-ratio 1.0 \ # entraîné kp 200 → forcer 1.0 (défaut 0.6 baisse à 120) + --ground-pick-action-scale 1.0 # = action.scale env +``` + +## 7. Tests + +`tests/` (lancer `uv run --with pytest pytest tests/ -q`) : + +- **Fonctions pures** : `phase_pose_blend` aux points clés + (φ=0→0, φ=0.075→0.5, φ=0.3→1, φ=0.575→0.5, φ=0.8→0, monotone par segment) ; + `phase_pose_track`/`_l1` : valeur max (cur==target) et signe. +- **Construction de l'env** : `make_microduck_ground_pick_env_cfg()` construit ; + commande = `GroundPickPhaseCommand` avec `randomize_phase=False`, `period=4.0` ; + rewards `phase_pose_track`/`phase_pose_track_l1` présents ; + `mouth_perpendicular_to_ground`/`ground_pick_return_pose_*` absents ; + `mouth_ground_proximity` présent poids 1.0. + +## 8. Entraînement / play / export + +```bash +uv run train Mjlab-GroundPick-Flat-MicroDuck --env.scene.num-envs 4096 --agent.max_iterations 20000 +uv run scripts/play_latest.py # md-play +uv run scripts/export_latest.py # normaliseur baké dans l'ONNX +``` +Surveiller `Episode_Reward/phase_pose_track` (doit monter). + +## 9. Hors scope / notes + +- **Doublon `pose_target_match`** (mdp.py 1577 et 1914) : latent, non traité ici. +- **Ajustement DOWN_POSE** : si la bouche ne touche pas assez le sol avec les + valeurs FOLD, ajuster le dict (idéalement lecture `read_pose.py` du vrai robot + posé bouche-au-sol) plutôt que de gonfler `mouth_ground_proximity`. +- **Transition au déploiement** : STAND=HOME = neutre de la policy principale → + pas d'à-coup au retour (contrairement au souci noté sur roller où STAND≠HOME). diff --git a/docs/superpowers/specs/2026-07-24-shoot-pose-following-design.md b/docs/superpowers/specs/2026-07-24-shoot-pose-following-design.md new file mode 100644 index 0000000..294e783 --- /dev/null +++ b/docs/superpowers/specs/2026-07-24-shoot-pose-following-design.md @@ -0,0 +1,202 @@ +# Spec — Tâche RL « shoot dans une balle » par suivi de poses + +**Date** : 2026-07-24 +**Branche** : `new_pre_alpha_ground_pick` +**Task id** : `Mjlab-Shoot-Flat-MicroDuck` + +## Objectif + +Apprendre un geste de **shoot one-shot** (frappe dans une balle) par **suivi d'une +trajectoire de poses articulaires à 4 keyframes** interpolée par la phase : + +``` +STAND → PIED_ARRIÈRE (armement) → PIED_AVANT (frappe) → STAND (repos) +``` + +- **Jambe droite** frappe, **jambe gauche** en appui. +- **Aucune balle simulée** : on apprend le *geste* par suivi de poses (comme + `ground_pick` / crouch). Si une vraie balle est devant le robot au déploiement, + elle se fait frapper. +- Obs **61D unifiée** identique aux autres policies microduck → l'ONNX exporté se + déploie tel quel dans un **slot bouton** du runtime (one-shot : joue le geste + puis rend la main à la policy principale). + +Même moule que la tâche `ground_pick` de cette branche (phase encodée `[cos, sin, 0]` +dans le slot twist, suivi de pose par phase, obs 61D, DR sim2real héritée de velocity). + +## Non-objectifs (YAGNI) + +- Pas de balle physique, pas de reward de contact/vitesse de balle. +- Pas de côté configurable (droite uniquement ; gauche = symétrisable plus tard si besoin). +- Pas de marche / récupération de chute : tous les termes de locomotion sont retirés. + +## Architecture + +### Fichier & enregistrement +- `src/mjlab_microduck/tasks/microduck_shoot_env_cfg.py` + - `make_microduck_shoot_env_cfg(play: bool = False, rough: bool = False) -> ManagerBasedRlEnvCfg` + - `MicroduckShootRlCfg` (RslRlOnPolicyRunnerCfg, `experiment_name="shoot"`) +- Enregistrement dans `src/mjlab_microduck/tasks/__init__.py` : + `Mjlab-Shoot-Flat-MicroDuck` (variante `-Rough-` optionnelle). +- Base : hérite de l'env velocity (via `make_velocity_env_cfg` comme ground_pick), + puis strip agressif de tout ce qui est locomotion. +- Robot : `MICRODUCK_WALK_ROBOT_CFG` (marche standard, 14 joints, pas de rollers). +- `action.scale = 1.0`. + +### Poses (placeholders → lues sur le vrai robot via `read_pose.py`) +Dicts `{nom_joint: rad}`, **14 joints** (mouth exclu). Sommet du fichier env. +- `STAND_POSE` : station neutre (~HOME du sim). +- `KICK_BACK_POSE` : hanche droite en **extension arrière** + genou droit fléchi + (armement) ; jambe gauche + cou ≈ HOME. +- `KICK_FWD_POSE` : hanche droite **fléchie avant** + genou droit tendu (frappe) ; + jambe gauche + cou ≈ HOME. + +Placeholders plausibles au départ (ajustables), à remplacer par les lectures réelles. + +### Commande & phase +- Réutilise `GroundPickPhaseCommand` : `command = [cos(2π·φ), sin(2π·φ), 0]` dans le + slot twist. +- **Période** : `SHOOT_PERIOD ≈ 2.5 s` (configurable via `cfg.period`). +- **Nouveau flag `randomize_phase`** sur `GroundPickPhaseCommandCfg` / + `GroundPickPhaseCommand` : + - Défaut `True` (non-cassant : `ground_pick` garde le comportement actuel). + - Shoot le met à `False` → `reset()` remet φ=0 au lieu de `rand()`. + - Raison : chaque épisode démarre au STAND (état du robot = `default_joint_pos`) + avec φ=0 = cible STAND → cohérence état/cible au reset (sinon la policy est + sommée d'être instantanément en pose « frappe » depuis une station immobile). + - **Invariant de cohérence** : `STAND_POSE` DOIT égaler la pose articulaire de + reset du sim (`HOME_FRAME` / `default_joint_pos`, non nulle : hip_pitch ±0.4579, + ankle ±0.4530, hip_roll ±0.0873, neck/head_pitch 0.3491). Vérifié par + `test_stand_pose_matches_home_standing_pose`. Les placeholders initialement à + zéro cassaient cet invariant (corrigé après revue finale). + +### Reset (hauteur debout, pas d'élan) +- `reset_base.pose_range.z = (0.12, 0.13)` — **hauteur debout absolue** (le `pos` + racine par défaut de `InitialStateCfg` est (0,0,0), donc z de reset = 0.12–0.13 m, + pas un offset additif ; valeur identique à l'env velocity qui marche). Pas de chute. +- **Pas d'injection de vitesse d'entrée** (shoot debout, contrairement au crouch-glide). + +### Rewards hérités non listés +La table ci-dessus n'est pas exhaustive : l'env hérite de velocity quelques +régularisateurs génériques de faible poids non spécifiques au shoot — +`angular_momentum` (-0.02), `dof_pos_limits` — conservés (stabilité, négligeables). +⚠️ `soft_landing` (reward de marche) est **retiré** : il lit le capteur 2-pieds +`feet_ground_contact` supprimé au profit du capteur pied gauche → KeyError au 1er step +sinon, et il est inerte pour un shoot debout. + +### Gotcha renommage capteur (⚠️) +Renommer le capteur pied (`feet_ground_contact` → `left_foot_ground_contact`) casse +tout ce que l'héritage velocity/ground_pick référence par ce nom. À traiter : +- **obs critic** `foot_air_time`/`foot_contact`/`foot_contact_forces` → repointés vers + le capteur pied gauche (le critic garde l'info d'appui ; sinon KeyError à la + construction de l'env). +- **reward** `soft_landing` → retiré (voir ci-dessus ; sinon KeyError au 1er step). +Toujours valider par une construction live + **au moins un `step()`** (le reward +manager ne tourne qu'au step), pas seulement le build de cfg ni les tests unitaires. + +### ⚠️ Transfert de poids appris (révision post-1er entraînement) +Constat : les poses BACK/FWD relevées **robot tenu à la main (appui bipède)** gardent le +CoM **centré entre les deux pieds** (~4-5 cm à l'intérieur du pied gauche) à toutes les +phases. Avec `upright` imposé, dès que le pied droit se lève le robot bascule → aucune +policy ne peut tenir (géométrique, pas du tuning). Vérifié en sim (CoM vs sites pieds). + +Fix retenu (RL apprend l'équilibre) : +- `mdp.com_over_support_foot` : reward gaussien (std 4 cm) tirant la projection du CoM + (`root_com_pos_w`) vers le pied d'appui, **gaté** par `mdp.kick_engagement` (0 au repos + STAND, 1 pendant la frappe). Poids 3.0. +- **suivi de pose scindé** (param `joint_names` sur `kick_pose_track`/`_l1`) : + GESTE = jambe droite + cou/tête (std 0.35, serré) ; APPUI = jambe gauche (std 0.9, + poids 1.0, **lâche**) → la policy peut adducter/décaler le bassin pour transférer le + poids sans que le suivi fige le bassin centré. +La table « Équilibre / appui » ci-dessus est donc étendue : `support_leg_pose` (1.0), +`com_over_support` (3.0) s'ajoutent, et `kick_pose_track`/`kick_pose_l1` ne portent plus +que sur les 9 joints du geste (droite+cou). + +### Objectif : suivi de la pose interpolée par la phase +Nouvelle fonction **pure** dans `mdp.py` : +```python +kick_pose_target(phase, stand, back, forward, windup_end, kick_end, return_end) -> Tensor +``` +Interpole entre les vecteurs de pose selon 4 segments (période normalisée [0,1)) : +``` +[0, windup_end) STAND → BACK (armement, défaut 0.35) +[windup_end, kick_end) BACK → FORWARD (frappe sèche, défaut 0.10 = "snap") +[kick_end, return_end) FORWARD → STAND (retour, défaut 0.30) +[return_end, 1.0) STAND (repos) +``` +Le « snap » vient du segment frappe court : la cible articulaire bouge vite → swing +rapide du pied. Les 3 bornes de timing sont paramétrables. + +Résolution des joints **par nom** (`asset.find_joints([name])`) — robuste à l'ordre. + +Rewards de suivi (toujours actifs, symétriques comme crouch) : +| Reward | Poids | Rôle | +|---|---|---| +| `kick_pose_tracking` | 6.0 | suivi gaussien `exp(-((q-cible)/std)²).mean`, std=0.4 | +| `kick_pose_l1` | 2.0 | bootstrap L1 (gradient constant tôt) | + +### Équilibre / appui (jambe unique = risque de bascule) +| Reward | Poids | Rôle | +|---|---|---| +| `upright` | 2.0 | tronc vertical | +| `support_foot_grounded` (pied gauche) | 6.0 | garder le pied d'appui planté (capteur mono-pied → `found∈{0,1}` → reward∈{0,0.5} après `/2`, donc poids 6.0 ≈ contribution max 3.0) | +| `feet_flat` (gauche) | -1.0 | lame gauche à plat | +| `self_collisions` | -1.0 | | +| `body_ang_vel` | -0.05 | | + +`support_foot_grounded` : réutiliser le mécanisme `feet_grounded_reward` du +ground_pick mais restreint au **pied gauche** (capteur de contact sur +`left_foot_collision`). + +### Régularisation (allégée vs ground_pick — laisser passer le snap) +| Reward | Poids | Rôle | +|---|---|---| +| `action_rate_l2` | -0.5 | léger : un poids lourd tuerait la frappe rapide | +| `neck_action_rate_l2` | -0.5 | tête stable | +| `joint_torques_l2` | -1e-3 | | + +**Retirés** (termes de marche) : `track_linear_velocity`, `track_angular_velocity`, +`air_time`, `foot_clearance`, `foot_swing_height`, `foot_slip`, `pose`. + +### Observations / déploiement (parité) +- Obs **61D identique** à ground_pick/roller : `[gyro(3), projected_gravity(3), + joint_pos(14), joint_vel(14), last_action(14), command(13)]` avec les slots + head(4)+body(6) **zero-paddés** (`zero_command_padding`). +- Même DR sim2real héritée de velocity (CoM, mass/inertia, friction BAM, armature, + IMU misalignment obs-level, encoder-bias, pushes ±0.3), termine par NaN guard. +- Export ONNX (normaliseur baké) via le script d'export existant. +- Déploiement dans un slot phase du runtime, p.ex. : + ``` + --ground-pick shoot.onnx --ground-pick-period 2.5 \ + --ground-pick-kp-ratio 1.0 --ground-pick-action-scale + ``` + Bouton → shoot → retour auto à la policy principale. + +## Tests + +- `tests/test_shoot.py` — fonctions pures : + - `kick_pose_target` aux keypoints : STAND à φ=0, BACK à `windup_end`, + FORWARD à `kick_end`, STAND dans le segment repos ; interpolation à mi-segment ; + bornes (chaque composante entre min/max des poses). + - Valeurs des rewards `kick_pose_tracking` / `kick_pose_l1` sur cas simples. +- `tests/test_shoot_cfg.py` — l'env se construit avec la bonne commande + (`GroundPickPhaseCommand`, `randomize_phase=False`, période) et les rewards + attendus présents / termes de marche absents. +- Lancer : `uv run --with pytest pytest tests/ -q`. + +## Entraînement + +```bash +uv run train Mjlab-Shoot-Flat-MicroDuck --env.scene.num-envs 4096 --agent.max_iterations +``` +Surveiller `Episode_Reward/kick_pose_tracking` (doit monter). Play : script play_latest. + +## Points ouverts / à régler à l'entraînement +- **Timings** (windup/kick/return) et **période** : défauts snap raisonnables, + à ajuster selon la vitesse de pied obtenue et la stabilité. +- **Poids `action_rate`** : tension snap vs lissage sim2real ; démarrer léger (-0.5). +- **Enrichissement optionnel (non retenu pour v1)** : petit reward de « vitesse du + pied droit vers l'avant » gaté sur le segment frappe, pour pousser la puissance + sans balle simulée. À ajouter seulement si le suivi de pose seul manque de punch. +- **Transitions au déploiement** : si `STAND_POSE` ≠ neutre de la policy principale, + léger à-coup au déclenchement/retour (comme noté pour crouch). diff --git a/docs/superpowers/specs/2026-07-27-swizzle-head-control-design.md b/docs/superpowers/specs/2026-07-27-swizzle-head-control-design.md new file mode 100644 index 0000000..31cb623 --- /dev/null +++ b/docs/superpowers/specs/2026-07-27-swizzle-head-control-design.md @@ -0,0 +1,83 @@ +# Swizzle head control (Y button) — design + +**Date:** 2026-07-27 +**Branch:** `new_pre_alpha_rollers` +**Task touched:** `Mjlab-Velocity-Swizzle-MicroDuck` (`microduck_velocity_swizzle_env_cfg.py`) + +## Goal + +Let the operator move the duck's HEAD to different poses (Y button) while it rollers, +**without the swizzle falling apart when the head moves**. The head pose is the +physical head/neck joints (look up/down/left/right) — unrelated to `heading_tracking` +(the body's travel direction, which is untouched). + +The current swizzle policy would tip over if the head moved as an external offset (it +doesn't compensate the CoM shift), so the head must be **policy-managed**: the policy +produces the head pose AND keeps its balance. This matches how the walking policy does +it in `--new-cmd-obs` mode — the head is a COMMAND injected into the observation, and +the policy produces the pose (no external "double-add"). + +## Approach (chosen: A — policy-managed head via the obs command) + +Port the head-command machinery that already exists in `microduck_velocity_env_cfg.py` +into the swizzle env: feed a real head-pose command into the (currently zero-padded) +`head_command` obs slot, reward the head tracking that command, and ramp it in LATE +via a curriculum so it doesn't disturb the swizzle. + +No external-offset option (rejected earlier: the swizzle won't stay upright if the head +moves without the policy compensating). + +## Changes (all in `make_microduck_velocity_swizzle_env_cfg`) + +1. **Head-pose command term.** Add `cfg.commands["head_pose"] = UniformPoseCommandCfg(...)`, + copied from the velocity env: 4D `[neck_pitch, head_pitch, head_yaw, head_roll]` + deltas-from-default, `resampling_time_range = (2.0, 5.0)`, per-joint ranges (head_roll + tighter, matching the small mechanical range). +2. **Real `head_command` obs.** Replace the current `zero_command_padding(dim=4)` head + slot with the real command obs `func=, params={"command_name": + "head_pose"}`, for BOTH actor and critic. (Keeps the 61D layout; body_command stays + zero-padded — no body-pose control here.) +3. **`head_pose_tracking` reward.** Add `cfg.rewards["head_pose_tracking"]` + (`microduck_mdp.head_pose_tracking`, `command_name="head_pose"`, `std=0.5`), initial + weight 0 (curriculum-ramped). +4. **Late curriculum.** A `reward_weight` curriculum ramps `head_pose_tracking` from 0 + → **4.0**, staying 0 until **~1500 iters** (swizzle solid) then climbing over the + next ~1000 iters, mirroring velstand's body-pose kick-in. Plus a head-pose command- + range curriculum: start with tight ranges (small head deltas) and widen them over + the same window, so the head barely moves early and reaches full range once the + policy can handle it. This is what makes head control "not hard to manage" — it is + added on top of an already-stable swizzle. (Values are starting points, tunable.) +5. **Reconcile the neck penalty (required).** The env currently has `neck_joint_pos_l2` + which pulls the neck/head joints toward HOME — it would FIGHT `head_pose_tracking` + (which pulls them to the command) so the head would never move. Exclude the + head-pose joints from `neck_joint_pos_l2` (or drop it), mirroring the velocity env's + handling (its comment: keeping them in both "would pull them to HOME while + head_pose_tracking pulls them to the command"). Keep `neck_action_rate_l2` (smoothness, + no conflict). + +Everything else (swizzle, backward locomotion, heading curriculum, DR, obs layout, +command) is unchanged. Requires **retraining** the swizzle task. + +## Runtime + +No runtime code change. The `microduck_runtime` **Y button** already drives the +`head_command` obs slot (new-cmd-obs mode injects the head offset as a command, "don't +double-add"). Once the swizzle policy is retrained with head control, it responds to Y. +Deploy flags unchanged (`--roller --new-cmd-obs ...`). + +## Testing / verification + +- Smoke test: `uv run train Mjlab-Velocity-Swizzle-MicroDuck --env.scene.num-envs 16 + --agent.max-iterations 2` runs; `head_pose_tracking` appears in the reward log; the + `head_pose` command and real `head_command` obs build without error. +- Real run: `head_pose_tracking` rises after the curriculum kick-in; the swizzle stays + stable (fall rate does not spike when the head curriculum turns on). In the viewer / + on the robot: moving the head command moves the head, and the roller keeps skating. + +## Tuning knobs + +- Head disrupts the swizzle when it kicks in → push the curriculum kick-in later, or + widen the head range more slowly. +- Head doesn't follow well → raise `head_pose_tracking` target weight, or check the neck + penalty still isn't fighting it. +- Head too twitchy → keep/raise `neck_action_rate_l2`. diff --git a/docs/superpowers/specs/2026-08-04-roller-standup-design.md b/docs/superpowers/specs/2026-08-04-roller-standup-design.md new file mode 100644 index 0000000..f775e4c --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-roller-standup-design.md @@ -0,0 +1,372 @@ +# Design — `roller_standup` : se relever sur rollers + +**But** : une policy dédiée qui remet le microduck **debout sur ses rollers** après une chute +(à plat ventre ou à plat dos), et qui sait ensuite **tenir** la station sur roues. + +Portage de la recette `standup` (canard marcheur) vers le modèle rollers. Aucune modification +des envs existants. + +--- + +## Décisions actées + +| Décision | Choix | Alternatives écartées | +|---|---|---| +| Forme | **Policy dédiée** épisodique | Greffer le relevé sur l'env roller (recette `velstand`) → risque réel de casser la foulée acquise | +| Poses de départ | **ventre + dos + debout** | `assis` (n'existe que pour le hand-off depuis la policy `sit`, pas d'équivalent roller) ; côtés (couverture max mais convergence bien plus dure) ; sans `debout` (la policy se relèverait puis retomberait) | +| Roues libres | **curriculum de friction de roulement inversé** | Vraie friction d'entrée (bootstrap trop dur) ; imposer une technique de patineur par récompenses (historique du repo : les récompenses de style trop directives créent des optima parasites — le swizzle, l'optimum paresseux du crouch) | +| Pose cible | **HOME + hauteur mesurée** | `STAND_POSE` du roller-crouch (signalée comme issue ouverte : ≠ du neutre roller → à-coup au retour) ; pose lue sur le vrai robot (bloque le dev) | +| Commande | **twist neutralisé** (≈ 0) | Commande de phase / slot bouton (voir « Déploiement ») ; tête pilotable | + +--- + +## Architecture + +**Nouveau fichier** : `src/mjlab_microduck/tasks/microduck_roller_standup_env_cfg.py` +- `make_microduck_roller_standup_env_cfg(play: bool = False) -> ManagerBasedRlEnvCfg` +- `MicroduckRollerStandUpRlCfg` (`experiment_name="roller_standup"`) +- Task id : `Mjlab-RollerStandUp-Flat-MicroDuck` (flat uniquement, pas de variante rough) + +**Dérivation** : `cfg = make_microduck_velocity_rollers_env_cfg()`. + +C'est le pattern de `roller_slope` (246 lignes) et non celui de `roller_crouch` (479 lignes, qui +repart de `make_velocity_env_cfg()` et recopie tous les blocs de DR). On hérite ainsi sans risque +de dérive : + +- le robot `MICRODUCK_WALK_ROLLERS_ROBOT_CFG` (14 joints actifs + 4 roues passives, BAM m6, kp_fw 200) ; +- les capteurs `feet_ground_contact` (mode subtree sur `ankle_{l,r}_v1`) et `self_collision` ; +- toute la DR : CoM tronc + tête, masse/inertie (pseudo_inertia), friction BAM, armature, + biais d'encodeur, désalignement IMU au niveau obs, friction des roulements ; +- **l'observation unifiée 61D** `[gyro(3), projected_gravity(3), joint_pos(14), joint_vel(14), + last_action(14), command(13)]` — condition dure pour l'interchangeabilité au runtime ; +- la termination `nan_state` (garde élargi : joints + free-joint + roues). + +Le modèle rollers **permet physiquement** de s'allonger : `robot_allcollisions_rollers.xml` porte des +géoms de collision sur le tronc (`np_f970`), les hanches, les jambes, les coques de tête et la mâchoire, +en plus des 4 pneus. Vérifié. + +--- + +## Constantes mesurées + +Mesurées par cinématique exacte (minimum des sommets de maillage des géoms collidantes, pose +`STAND` du keyframe, tronc ramené au contact) sur `scene_rollers.xml` vs `scene.xml` : + +| pose | modèle pieds | modèle rollers | +|---|---|---| +| debout (`STAND` = HOME) | 0.1172 | **0.1407** | +| à plat ventre (repos) | 0.0752 | 0.0752 | +| à plat dos (repos) | 0.0476 | 0.0475 | + +Contrôle de cohérence : le `standup` utilise `STAND_Z = 0.115` mesuré **sous charge** contre 0.1172 +en cinématique → ~2 mm d'affaissement. On applique la même correction, et le résultat tombe pile dans +le `reset_base z = 0.1335–0.1435` déjà utilisé par l'env roller. + +```python +ROLLER_STAND_Z = 0.138 # tronc debout sur roues, sous charge (+23 mm vs pieds) +ROLLER_PRONE_Z = 0.075 # hauteur de repos à plat ventre +EPISODE_LENGTH_S = 6.0 +``` + +Les hauteurs de repos au sol sont **identiques** aux deux modèles (c'est la coque du tronc qui touche, +pas les pieds). Cela ne veut pas dire que la plage `prone_z` du `standup` se réutilise telle quelle : +voir la note sous « Reset » — `prone_z_min` diverge (0.076 ici, pas 0.05) car une seule plage sert +deux poses (ventre, dos) dont les hauteurs de contact au reset ne sont pas les mêmes. + +La grandeur mesurée est bien celle que lisent les récompenses : `height_target_gaussian` et +`height_l1_penalty` utilisent `root_link_pos_w[:, 2]`, qui vaut exactement `xpos[trunk_base].z` +(le free-joint est sur `trunk_base`) — vérifié numériquement. + +## Indices de joints + +Les roues passives sont **intercalées** dans l'ordre des joints. Ordre réel vérifié dans MuJoCo +(`m.jnt_qposadr`, modèle rollers, 18 joints après le free-joint) : + +``` +0-4 left_hip_yaw, left_hip_roll, left_hip_pitch, left_knee, left_ankle +5-6 passive_LF_wheel, passive_LR_wheel +7-10 neck_pitch, head_pitch, head_yaw, head_roll +11-15 right_hip_yaw, right_hip_roll, right_hip_pitch, right_knee, right_ankle +16-17 passive_RF_wheel, passive_RR_wheel +``` + +```python +_LEG_JOINTS = [0, 1, 2, 3, 4, 11, 12, 13, 14, 15] # standup : [0-4, 9-13] +_NECK_JOINTS = [7, 8, 9, 10] # standup : [5-8] +_WHEEL_JOINTS = [5, 6, 16, 17] +``` + +Seul `_LEG_JOINTS` est réellement consommé (par les récompenses de pose). `_NECK_JOINTS` et +`_WHEEL_JOINTS` sont déclarés pour la documentation et pour le test d'indices : le cou est résolu +**par nom** (`neck_joint_pos_l2` appelle `find_joints(r".*(neck|head).*")` à chaque pas, précisément +pour être robuste au décalage dû aux roues) et les roues par la regex `^passive_.*`. + +Le doc de passation signale explicitement cette fragilité. Elle est verrouillée par un test qui +construit l'env et vérifie les noms de joints à ces indices (voir « Tests »). + +--- + +## Récompenses + +### Retirées de l'héritage roller + +| Retiré | Pourquoi | +|---|---| +| `wheel_speed`, `braking`, `skating_air_time`, `glide`, `single_support`, `gait_symmetry`, `forward_lean`, `heading_hold` | récompenses de foulée : aucun sens quand on est par terre | +| `feet_flat` | pendant la montée les lames ne sont pas à plat → cette pénalité combattrait le geste | +| `hip_roll_neutral` | se relever demande d'écarter les jambes | +| `pose`, `com_height_target` | remplacés par les cibles pose/hauteur ci-dessous | +| `upright` (gaussienne de base) | remplacée par `upright_linear` + `upright_sharp` | + +### Gardées de l'héritage roller + +| Reward | Poids | Rôle | +|---|---|---| +| `action_over_limit` | −0.5 | protection sim2real (sur-commande au-delà des butées), indépendante de la tâche | +| `self_collisions` | −1.0 | | +| `body_ang_vel` | **−0.05** | volontairement **léger** : le `standup` documente qu'à −0.15 il gelait le relevé (bloqueur de mouvement) | +| `angular_momentum` | −0.02 | | +| `action_rate_l2` | curriculum −0.4 → −0.8 → −1.0 | l'env roller le met à plat à −1.0 ; on reprend la rampe du `standup` (douce au début → aide le bootstrap du grand mouvement de retournement) | +| `neck_action_rate_l2` | −0.5 | tête stable | +| `neck_joint_pos_l2` | −0.5 | garder la tête droite (le choix de `roller_slope`) — **remplace** la commande `head_pose` du `standup` | +| `joint_torques_l2` | −1e-3 | | + +### Ajoutée + +| Reward | Poids | Rôle | +|---|---|---| +| `joint_torque_rate_l2` | −2e-3 | anti-jitter : le `standup` l'a identifié comme le seul amortisseur qui ne bloque pas le retournement (il pénalise la *variation* de couple, pas son amplitude ni la rotation du tronc) | + +### Récompenses de relevé (transplant du `standup`, remappé) + +Les dix termes sont copiés **avec leurs poids déjà réglés** par les itérations documentées dans +`microduck_standup_env_cfg.py`. Seuls changent les indices de joints et les deux hauteurs. +Toutes les fonctions mdp existent déjà — **rien à écrire dans `mdp.py`**. + +| Reward | Fonction mdp | Poids | Paramètres roller | Rôle | +|---|---|---|---|---| +| `pose_stand_legs` | `pose_target_match` | +8.0 | `std=0.5`, `joint_indices=_LEG_JOINTS`, `target_overrides=None` (HOME) | pose articulaire cible | +| `pose_stand_l1` | `pose_l1_penalty` | +5.0 | `joint_indices=_LEG_JOINTS`, `target_overrides=None` | bootstrap L1 : gradient constant même loin de HOME | +| `height_stand` | `height_target_gaussian` | +4.0 | `std=0.04`, `target_height=0.138` | gaussienne large → tire depuis le sol | +| `height_stand_sharp` | `height_target_gaussian` | +4.0 | `std=0.015`, `target_height=0.138` | gaussienne étroite → force les derniers cm | +| `height_stand_l1` | `height_l1_penalty` | +30.0 | `target_height=0.138` | rend « rester par terre » net négatif (sinon optimum paresseux) | +| `com_upward_velocity` | `com_upward_velocity` | +3.0 | `max_height=0.148` | paye le *mouvement* de montée (+10 mm de marge au-dessus de la cible, comme 0.125 vs 0.115 chez `standup`) | +| `gentle_rise` | `trunk_vertical_accel_penalty` | −0.02 | | pénalise `\|a_z\|` → montée lisse à vitesse constante | +| `upright_linear` | `body_upright_linear` | +6.0 | | `cos(tilt)` : fort gradient quand couché | +| `upright_sharp` | `upright_gaussian_at_height` | +6.0 | `std=0.3`, `height_low=0.075`, `height_high=0.138` | gaussienne serrée gatée en hauteur → tue le penché-arrière | +| `standing_composite` | `standing_composite_score` | +15.0 | `height_std=0.04`, `upright_std=0.40`, `pose_std=0.40`, `target_height=0.138`, `joint_indices=_LEG_JOINTS` | score multiplicatif hauteur × droit × pose | + +Tous les termes prennent `asset_cfg=SceneEntityCfg("robot", body_names=("trunk_base",))` là où le +`standup` le fait. + +**Pas de pénalités d'impact** (tronc/tête) pour cette v1 : le `standup` n'en a pas, seul `velstand` +en a. On garde le jeu minimal. + +--- + +## Observation et commande + +**Observation** : héritée intacte de l'env roller (61D). Aucune modification — c'est la raison de +dériver de cet env. + +On ajoute `nan_policy = "sanitize"` sur les groupes actor et critic, comme `roller_slope` : un contact +rare fait diverger le free-joint en NaN, l'obs est assainie (→ 0) pour ne pas tuer l'entraînement, +et l'env fautif se reset au pas suivant. + +**Commande** : le slot `twist` est neutralisé, exactement comme le `standup` : + +```python +command = cfg.commands["twist"] +command.rel_standing_envs = 0.0 +command.rel_heading_envs = 0.0 +command.heading_command = False +command.ranges.heading = None +command.resampling_time_range = (EPISODE_LENGTH_S, EPISODE_LENGTH_S * 2) +command.debug_vis = False +command.ranges.lin_vel_x = (-0.01, 0.01) +command.ranges.lin_vel_y = (-0.01, 0.01) +command.ranges.ang_vel_z = (-0.05, 0.05) +cfg.commands["twist"] = microduck_mdp.VelocityCommandCommandOnlyCfg(**vars(command)) +``` + +Les slots `head_pose` (4) et `body_pose` (6) restent **zero-paddés** — convention de la famille +roller (`roller`, `roller_crouch`, `roller_slope`). C'est un écart assumé vis-à-vis du `standup` de +la marche, qui pilote la tête via une vraie commande `head_pose` 4D (voir « Risques »). + +Justification du twist neutralisé : dans `scripts/infer_policy.py`, la policy `standup` de la marche +est chargée en `--standing` à côté de `--walking`, et la bascule est **automatique sur la magnitude +de la commande de vitesse** (`infer_policy.py:262`, seuil 0.05) ; quand `standing` est active, le +slot twist est laissé à zéro (`infer_policy.py:239`). Les slots à phase (`ground_pick`, `fold`) +servent aux tricks one-shot déclenchés au bouton, pas à un relevé. + +--- + +## Reset + +Ajout de l'événement `set_ground_state` (mode `reset`), inséré **après** `reset_base` et +`reset_robot_joints` de l'héritage (l'ordre des événements suit l'ordre d'insertion dans le dict) : + +```python +cfg.events["set_ground_state"] = EventTermCfg( + func=microduck_mdp.set_random_ground_state, + mode="reset", + params={ + "face_down_prob": 0.50, # ventre — piloté par le curriculum ci-dessous + "face_up_prob": 0.00, # dos — introduit tard (le plus dur) + "sitting_prob": 0.00, # pas de bucket assis → aucun override de joint à remapper + "standing_prob": 0.50, + "prone_z_min": 0.076, # cf. note ci-dessous — pas un simple héritage du standup + "prone_z_max": 0.09, + "standing_z_min": 0.134, # roller (contre 0.11–0.12 pour les pieds) + "standing_z_max": 0.144, + "sitting_tilt_max": math.radians(10), # ± bruit de pitch/roll ; s'applique AUSSI au bucket debout + }, +) +``` + +Note : dans `set_random_ground_state`, le bucket `standing` réutilise le quaternion du bucket +`sitting` — donc `sitting_tilt_max` bruite aussi les départs debout, ce qui est voulu. + +**Sur `prone_z_min` = 0.076 (et pas 0.05, valeur reprise à tort du `standup`)** : les poses ventre et +dos partagent une seule plage de z, mais leurs hauteurs de contact mesurées diffèrent — ventre +0.0752, dos 0.0475 — donc une plage unique ne peut pas être idéale pour les deux. Le commentaire du +`standup` justifie son plancher `0.05` par un repos mesuré à ~0.044 **après stabilisation sous +gravité** ; or ce qui compte à l'instant du reset, c'est la hauteur de contact en pose HOME, pas la +hauteur de repos une fois retombé. À 0.05, le ventre spawn avec la coque du tronc **enfoncée de +25 mm dans le sol**, un pushout que la policy paie ensuite via `gentle_rise` / +`joint_torque_rate_l2`. `prone_z_min = 0.076` élimine cette interpénétration, au prix d'un dos qui +démarre 28–42 mm au-dessus de son repos — un artefact bien plus doux qu'un pushout de contact. + +**Aucune modification de `mdp.py`** : `reset_robot_joints` de la base utilise +`joint_names=(".*",)` avec `velocity_range=(0.0, 0.0)` et `default_joint_vel` (HOME_FRAME +`joint_vel={".*": 0.0}`) → les 4 roues passives sont déjà remises à zéro à chaque reset. Vérifié. + +**Curriculum `ground_state_mix`** (`event_param_curriculum`), même logique easy → hard que le +`standup` : le dos est introduit tard et reçoit le plus d'entraînement à la fin. + +| iter | debout | ventre | dos | +|---|---|---|---| +| 0 | 0.50 | 0.50 | 0.00 | +| 600 | 0.35 | 0.45 | 0.20 | +| 1500 | 0.25 | 0.40 | 0.35 | +| 2500 | 0.20 | 0.40 | 0.40 | + +(Steps en unités de `common_step_counter` = `iter × 24`.) + +**Poussées** : `push_robot` est hérité de l'env roller (±0.2 m/s, intervalle 3–6 s). On ajoute le +curriculum montant du `standup` pour ne pas parasiter le bootstrap : 0 → ±0.08 (iter 500) → ±0.2 +(iter 1000). + +**Terminations** : suppression de `fell_over` (le robot **démarre** tombé — la termination sur +inclinaison n'a pas de sens ici). `nan_state` est hérité et conservé. + +**Terrain** : `plane`. Pas de variante rough pour cette v1 — cohérent avec l'env roller, qui n'a pas +de paramètre `rough`. + +--- + +## Curriculum de friction de roulement, inversé + +C'est la seule pièce réellement nouvelle du design, et le cœur de la question posée par la tâche : +**les roues roulent, il n'y a aucune adhérence longitudinale pour pousser sur le sol.** + +Le mécanisme existe déjà et est hérité (`randomize_wheel_friction` via `dr.dof_frictionloss` sur +`^passive_.*` + `wheel_friction_curriculum`). Dans l'env roller il **monte** 0 → 0.0015. Ici on le +fait **descendre** : + +| iter | frictionloss | effet | +|---|---|---| +| 0 | 0.05 | roues quasi bloquées → il se relève comme s'il avait des pieds | +| 1000 | 0.02 | | +| 2000 | 0.008 | | +| 3000 | 0.003 | | +| 4000 | 0.0015 | la vraie valeur du roulement (celle de l'env roller) | + +`wheel_friction_curriculum` applique simplement le dernier palier franchi +(`if env.common_step_counter > stage["step"]`) — il fonctionne aussi bien en descente qu'en montée. +**Zéro code à écrire.** + +**Ce que ce curriculum nous dit** : si `Episode_Reward/standing_composite` s'écroule quand la +friction baisse, on a la réponse nette que le geste « pieds adhérents » ne transfère pas aux roues +libres, et il faudra guider une technique de patineur (appui genou intermédiaire, un patin à la +fois). C'est un résultat exploitable, pas un échec. + +--- + +## Réseau et PPO + +Identiques au `standup` : actor et critic `(512, 256, 128)` elu, `obs_normalization=True` +(normaliseur baké dans l'ONNX par `export.py`), PPO `lr=1e-3` schedule adaptive, `desired_kl=0.01`, +`entropy_coef=0.01`, `gamma=0.99`, `lam=0.95`, `num_steps_per_env=24`, `save_interval=250`, +`max_iterations=15_000`. **Symétrie OFF** (`SYMMETRY_CFG` est câblé pour l'ancien layout 51D et casse +sur le 61D — même situation que tous les envs v1.5+). + +--- + +## Tests + +`tests/test_roller_standup_cfg.py` : + +1. l'env se construit (`play=False` et `play=True`) ; +2. **les noms de joints aux indices `_LEG_JOINTS` / `_NECK_JOINTS` / `_WHEEL_JOINTS` sont les bons** + (le verrou contre la fragilité des roues intercalées) ; +3. les récompenses de relevé attendues sont présentes, les récompenses de patinage absentes + (`wheel_speed`, `glide`, `single_support`, `feet_flat`, …) ; +4. `fell_over` absent, `nan_state` présent ; +5. le curriculum `wheel_friction` est bien **décroissant** et finit à 0.0015 ; +6. le curriculum `ground_state_mix` : les probabilités du dernier palier somment à 1 et + `face_up_prob` croît de façon monotone ; +7. **parité d'obs** : les noms et dimensions des termes actor/critic sont identiques à ceux de + `make_microduck_velocity_rollers_env_cfg()` (sinon l'ONNX ne se charge pas dans un slot). + +Lancer : `uv run --with pytest pytest tests/ -q`. + +--- + +## Entraînement et déploiement + +```bash +uv run train Mjlab-RollerStandUp-Flat-MicroDuck --env.scene.num-envs 4096 --agent.max_iterations 15000 +``` + +Surveiller `Episode_Reward/standing_composite` (doit monter), et surtout son comportement **aux +paliers de friction de roulement** (iters 1000/2000/3000/4000). + +Play : `uv run scripts/play_latest.py`. Export : `uv run scripts/export_latest.py`. + +Déploiement visé : la policy en `--standing` face à la policy roller en `--walking`, avec la bascule +automatique sur la magnitude de la commande. **Réserve** : `infer_policy.py` est le script de +sim/clavier local ; le runtime robot est le binaire Rust `microduck_runtime`, absent de ce repo — il +n'est pas vérifié ici qu'il expose un équivalent `--standing` avec la même bascule. Le doc de +passation ne liste que `--model`, `--ground-pick`, `--fold-policy`. À confirmer. Cela ne change rien +à l'entraînement : si le runtime n'a pas ce slot, la policy reste utilisable dans un slot bouton (la +commande y serait une phase au lieu de zéro — ce serait alors le seul point à revoir). + +--- + +## Risques et points de vigilance + +1. **Le relevé sur roues libres est peut-être infaisable sans technique dédiée.** C'est le risque + principal. Le curriculum de friction est conçu pour trancher cette question de façon lisible + plutôt que pour la contourner. +2. **Le bucket « dos » est le plus dur.** Le `standup` documente qu'il gelait en « ne rien faire » + sur cette pose, et que la cause était les *bloqueurs de mouvement* (`body_ang_vel` élevé, + `action_rate` trop fort). Les valeurs reprises ici sont celles de la version « se relève de + partout » — ne pas les durcir sans raison. +3. **Tête zero-paddée vs commande `head_pose`.** Si la policy est déployée en `--standing` et que + quelqu'un actionne les touches de tête, `infer_policy` écrit `cmd[3:7] = head_offset` et la policy + voit du hors-distribution. Choix assumé pour rester dans la convention roller ; à revoir si le + pilotage de tête pendant le relevé s'avère nécessaire. +4. **Frictionloss 0.05 est loin du réel.** Les paliers 0 → 2000 iters produisent une policy qui ne + transfère pas ; seuls les checkpoints d'après le dernier palier (iter 4000+) sont candidats au + déploiement. + +## Hors périmètre + +- Intégrer le relevé dans la policy de roulage (recette `velstand`) — décision reportée après + validation de la faisabilité. +- Buckets de départ sur le côté. +- Variante rough / terrain accidenté. +- Pénalités d'impact tronc/tête. +- Toute modification des envs `roller`, `roller_crouch`, `roller_slope`, `standup`, `velstand`, ou + de `mdp.py`. diff --git a/docs/superpowers/specs/2026-08-04-spin-env-design.md b/docs/superpowers/specs/2026-08-04-spin-env-design.md new file mode 100644 index 0000000..251be4b --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-spin-env-design.md @@ -0,0 +1,417 @@ +# Spec — Env « Spin » (rotation rapide sur place, sur rollers) + +Date : 2026-08-04. Branche : `new_pre_alpha_rollers`. + +> **Amendement (après le premier run)** : le premier run de calibrage (500 it.) +> a montré que le robot tombe systématiquement vers 1,16 s, bien avant le +> freinage. En réponse, la cible a été réduite de moitié — `SPIN_RATE_MAX` +> 6.0 → **3.0 rad/s**, soit **1 tour par cycle au lieu de 2** — et +> `spin_stay_in_place` renforcé à **−3.0**, **sans curriculum** de vitesse. +> Voir « Résultats de la vérification initiale » pour les preuves et la +> configuration actuellement en vigueur. + +## But + +Une nouvelle tâche RL qui apprend au microduck sur rollers à faire un **spin** : +~2 tours anti-horaire sur place à ~6 rad/s (360°/s) *(cible initiale ; ramenée +à 3 rad/s, voir l'amendement)*, puis arrêt propre debout. +Geste **cyclique piloté par une phase**, déployé dans un **slot bouton one-shot** +du runtime, comme la tâche `roller_crouch` existante. + +## Décisions cadrées + +| Question | Décision | +|---|---| +| Support | Sur rollers (`MICRODUCK_WALK_ROLLERS_ROBOT_CFG`, 4 roues passives) | +| Pilotage | Slot bouton one-shot, commande = phase `[cos(2πφ), sin(2πφ), 0]` | +| Cible | ~6 rad/s, 2 tours, puis freinage jusqu'à l'arrêt (cible initiale ; ramenée à 3 rad/s, voir l'amendement) | +| État d'entrée | À l'arrêt **ou** en roulement lent (0 → 0.3 m/s) | +| Sens | Gauche uniquement (lacet positif, anti-horaire) | +| Approche | Objectif « résultat » (suivi de ω_z) + amorce antisymétrique décroissante | + +**Contrainte runtime** : le slot n'envoie que `[cos, sin, 0]` — aucun canal libre +pour le sens de rotation. La policy tourne donc **toujours à gauche**. Une policy +miroir pourrait plus tard aller dans un autre slot (bouton B, `--fold-policy`). + +## Mécanique physique visée + +Sur 4 roues passives, la rotation sur place « propre » se fait en **roulement +différentiel** : le patin gauche part vers l'arrière, le droit vers l'avant (les +roues **roulent**, elles ne patinent pas). C'est un *swizzle antisymétrique* : les +jambes font l'inverse l'une de l'autre, au lieu du miroir du swizzle classique. + +Vérification des signes pour une rotation anti-horaire (repère : x avant, y gauche, +z haut ; ω_z > 0) : un point à gauche (+y) a pour vitesse `ω ẑ × y ŷ = −ω y x̂`, +donc **vers l'arrière**. Les 4 roues tournent positif en marche avant (vérifié par +`test_wheel_direction.py`), donc pour un spin anti-horaire : +`ω_roues_gauche < 0`, `ω_roues_droite > 0`, soit **`ω_D − ω_G > 0`**. + +## Approche retenue (C) et pourquoi + +Trois approches ont été considérées : + +- **A — objectif « résultat » pur** : on récompense la vitesse de lacet et on laisse + PPO trouver le geste. Risque documenté dans ce repo : optimum paresseux / + patinage-sautillement au lieu du roulement propre. +- **B — objectif « directif » par poses** : deux poses de ciseau interpolées par la + phase, comme `roller_crouch`. Marche vite *si* les poses sont bonnes ; or pour le + crouch elles étaient **lues sur le vrai robot**, alors qu'ici le geste est inconnu. + Il faudrait le composer à la main : cher et risqué (des poses sans couple utile + ne produisent rien). +- **C — A + amorce antisymétrique décroissante** ← **retenue**. Structure de A, plus + deux termes de *shaping* faibles qui injectent la seule connaissance physique + certaine (le roulement différentiel), et dont le poids décroît par curriculum pour + laisser la policy affiner son propre geste. La **fréquence de pompage reste libre**. + +## Architecture + +**Fichier** : `src/mjlab_microduck/tasks/microduck_spin_env_cfg.py` +- factory `make_microduck_spin_env_cfg(play: bool = False) -> ManagerBasedRlEnvCfg` +- config PPO `MicroduckSpinRlCfg` +- task id `Mjlab-Spin-Flat-MicroDuck`, enregistré dans `tasks/__init__.py` + +Clone la structure de `microduck_roller_crouch_env_cfg.py` : robot rollers, obs 61D +unifié, DR complète, `action.scale = 1.0`, terrain plat. + +**`ENABLE_SYMMETRY = False`** — obligatoire : l'augmentation de symétrie gauche/droite +transformerait un spin à gauche en spin à droite et détruirait l'apprentissage. + +**Commande** : `GroundPickPhaseCommandCfg(period=4.0, randomize_phase=False)`. +`period=4.0` est le défaut de `--ground-pick-period` → rien à passer au runtime. +`randomize_phase=False` → chaque épisode démarre à φ=0 (debout), comme au déploiement. + +## Enveloppe de phase + +La phase pilote une **vitesse de lacet cible** ω\*(φ), en trapèze sur 4 segments +(période 4 s, `SPIN_RATE_MAX = 6.0` rad/s — cible initiale ; ramenée à 3 rad/s, +voir l'amendement ; les segments et la période n'ont pas changé) : + +``` +ACCEL_END = 0.125 [0, 0.125) 0.5 s ω* : 0 → 6 rad/s (lancement, rampe linéaire) +HOLD_END = 0.525 [0.125, 0.525) 1.6 s ω* = 6 rad/s (régime) +BRAKE_END = 0.650 [0.525, 0.650) 0.5 s ω* : 6 → 0 (freinage, rampe linéaire) + 1.0 [0.650, 1.0) 1.4 s ω* = 0 (repos debout) +``` + +*(Les valeurs ω\* = 6 rad/s ci-dessus correspondent à `SPIN_RATE_MAX` = 6.0, la +cible initiale ; voir l'amendement pour la valeur en vigueur.)* + +Intégrale sur un cycle : `0.5·3 + 1.6·6 + 0.5·3 = 12.6 rad ≈ 2.0 tours`. ✅ +*(à `SPIN_RATE_MAX = 6.0`, cible initiale.)* Forme générale : l'intégrale vaut +`2.1 × SPIN_RATE_MAX` quel que soit `rate_max` (0.25 + 1.6 + 0.25 = 2.1). Avec la +cible en vigueur (3.0 rad/s) : `2.1 × 3.0 = 6.3 rad ≈ 1 tour` par cycle — voir +l'amendement. + +Épisode = 20 s = **5 cycles** : le robot répète lancement → régime → freinage → repos +cinq fois par épisode. Plus de données par épisode, et le segment « repos » entraîne +aussi la sortie propre du trick. **Note (post-run)** : ceci reste vrai +géométriquement (20 s / 4 s), mais aucun épisode du run de calibrage n'a survécu +au-delà de ~1,16 s, soit une fraction du premier cycle seulement — voir +« Résultats de la vérification initiale ». + +**Fonction pure** `spin_rate_by_phase(phase, rate_max, accel_end, hold_end, brake_end)` +dans `mdp.py`, à côté de `crouch_pose_blend`. Testable sans simulateur. + +**Porte de shaping** : `gate(φ) = spin_rate_by_phase(φ) / rate_max ∈ [0, 1]`. Vaut 0 +sur le segment repos → aucune amorce ne pousse au ciseau à ce moment-là, donc le robot +revient en station neutre. C'est ce qui donne une sortie de trick propre vers la policy +roller. + +## Rewards + +### Pièges vérifiés dans mjlab (à traiter explicitement) + +- `body_ang_vel` (`body_angular_velocity_penalty`) ne pénalise que **x/y** + (`ang_vel_xy`, commentaire « Don't penalize z-angular velocity ») → **gardée** + (poids −0.05) : elle réprime le ballant roulis/tangage sans gêner le spin. +- `angular_momentum` (`angular_momentum_penalty`) pénalise la **norme 3D** du moment + angulaire → elle combattrait directement le spin. **Supprimée.** + +### Nouvelles rewards (à écrire dans `mdp.py`) + +| Reward | Poids | Définition | +|---|---|---| +| `spin_rate_track` | 6.0 | `exp(−((ω_z − ω*(φ))/std)²)`, `std = 1.5` rad/s. ω_z = lacet du tronc en repère corps (ce que voit l'IMU). Objectif principal. | +| `spin_rate_l1` | 0.5 | `−|ω_z − ω*(φ)|` : bootstrap à gradient constant quand la gaussienne sature loin de la cible (même astuce que `crouch_glide_pose_l1`) | +| `spin_stay_in_place` | −3.0 (initialement −1.0, voir l'amendement) | `‖v_xy‖²` du tronc → « sur place », et tue l'élan d'entrée. Pas d'état de référence, donc robuste aux 5 cycles par épisode | +| `spin_wheel_differential` | 1.0 | `gate(φ) · tanh(clamp(ω_D − ω_G, min=0) / omega_scale)` avec `ω_G = (LF+LR)/2`, `ω_D = (RF+RR)/2` : récompense les patins qui roulent en sens opposés cohérents avec l'anti-horaire → tourner **en roulement**, pas en patinage. Roues résolues par nom (`passive_LF_?wheel`, …). `omega_scale = 17.0` rad/s en vigueur (voir le paragraphe de calibrage ci-dessous) | +| `leg_antisymmetry` | 1.0 → 0.25 | `gate(φ) · (−mean|q_G − q_D|)` sur `hip_pitch` et `knee`. ⚠️ convention miroir : une pose *symétrique* donne `q_G + q_D ≈ 0`, donc le **ciseau** c'est `q_G ≈ q_D`. Décroît par curriculum | +| `spin_grounded` | 0.5 | `gate(φ) · 1[n_contact ≥ 2]` : les deux lames au sol, empêche « je saute et je vrille en l'air ». La `grounded_reward` du swizzle n'est pas réutilisable telle quelle (elle se pondère par `cmd_x`, qui vaut ici `cos(2πφ)`) | + +**Calibrage de `omega_scale`** (échelle de saturation du tanh) : au régime visé, +chaque patin avance à `v = ω_z · demi_voie`, donc chaque roue tourne à +`v / r` avec `r = 0.0175` m, et le différentiel vaut `2 · ω_z · demi_voie / r`. +Les racines de jambe sont à `y = ±0.0175` m dans le modèle rollers, mais les patins +sont plus écartés (offset de cheville) : la demi-voie réelle est à **mesurer sur les +sites `left_foot` / `right_foot` dans le sim** au premier run. Avec une demi-voie +estimée à ~0.03 m et `ω_z = 6` rad/s, le différentiel attendu était ~20 rad/s — d'où +le défaut initial `omega_scale = 20.0`. **Mesure faite (Task 3) : demi-voie réelle += 0.0499 m, différentiel attendu = 34.2 rad/s, soit 71 % au-dessus de l'estimation +— au-delà du seuil de 30 % fixé par le plan.** `SPIN_WHEEL_OMEGA_SCALE` a donc été +corrigé à **34.0** (valeur intermédiaire, en vigueur tant que la cible était à +6 rad/s ; recalibrée depuis à **17.0**, voir le paragraphe « Mise à jour » +juste en dessous). Voir la section « Résultats de la vérification initiale » +ci-dessous pour le détail de la mesure de demi-voie. + +**Mise à jour (fix wave post-review)** : `SPIN_RATE_MAX` a été réduit de 6.0 à +**3.0 rad/s** (décision humaine, sans curriculum — voir plus bas). Conséquence +mécanique directe sur `omega_scale`, pas un choix indépendant : le différentiel +attendu au régime redevient `2 · 3.0 · 0.0499 / 0.0175` = **17.1 rad/s**. Laisser +`omega_scale = 34.0` plafonnerait le terme à `tanh(17.1/34) = 0.47` de son propre +maximum, ce qui affaiblirait exactement le shaping que l'on cherche à renforcer. +`SPIN_WHEEL_OMEGA_SCALE` est donc recorrigé à **17.0**, avec la même demi-voie +mesurée (0.0499 m) conservée comme référence. + +### Rewards reprises de `roller_crouch` (stabilité / sim2real) + +| Reward | Poids | +|---|---| +| `upright` (tronc vertical) | 2.0 | +| `feet_flat` (lames à plat) | −2.0 | +| `self_collisions` | −1.0 | +| `body_ang_vel` (xy seulement) | −0.05 | +| `action_rate_l2` | −1.0 (curriculum −0.5 → −1.0) | +| `neck_action_rate_l2` | −0.5 | +| `joint_torques_l2` | −1e-3 | +| `neck_joint_pos_l2` **hors `head_yaw`** | −0.2 | + +**La tête** : tangage/roulis de la nuque tenus près du neutre (sim2real), mais +`head_yaw` **exclu** du terme → libre de servir de volant d'inertie pour lancer la +rotation. Implémentation : `neck_joint_pos_l2` résout ses joints par regex +`.*(neck|head).*` en dur ; il faut donc soit ajouter un paramètre de regex à cette +fonction, soit écrire une variante `neck_joint_pos_l2_no_yaw`. Choix : **ajouter un +paramètre `pattern`** à `neck_joint_pos_l2` (défaut inchangé) pour ne pas dupliquer. + +## Reset / état d'entrée + +```python +cfg.events["reset_base"].params["pose_range"]["z"] = (0.1335, 0.1435) +cfg.events["reset_base"].params["velocity_range"] = {"x": (0.0, 0.3)} +``` + +Injection via `reset_root_state_uniform`. **Jamais** `push_by_setting_velocity` en +`mode="reset"` : c'est ce qui avait produit les NaN sur le crouch (`root_vel +=` sur +une vitesse racine potentiellement divergente → le free-joint de la base explose). + +## Domain randomization + +Identique à `roller_crouch`, sans dévier (recette sim2real validée du repo) : COM +tronc + tête, masse/inertie, friction articulaire BAM, armature, friction roues, +pushes 0.2 m/s toutes les 3–6 s, désalignement IMU 6°, biais d'encodeurs ±0.015 rad. + +## Observations + +Layout **61D à l'identique** de roller / ground_pick / crouch — condition pour que +l'ONNX charge dans le slot : +`[gyro(3), projected_gravity(3), joint_pos(14), joint_vel(14), last_action(14), command(13)]` +avec `command = [twist(3), head_pose(4), body_pose(6)]`, head/body zero-paddés. + +Donc : retrait de `base_lin_vel` de l'actor (gardé côté critic), retrait des +`height_scan` et `foot_height`, `wheel_vel` côté critic, joints passifs exclus des +termes `joint_pos`/`joint_vel`, délais et bruits identiques au crouch. + +Le gyro est dans l'obs → la policy **observe** son propre ω_z : la tâche est observable. + +## Terminations + +`time_out`, `fell_over`, `out_of_terrain_bounds` (héritées) + `nan_state` +(`microduck_mdp.robot_state_is_nan`), comme le crouch. + +## Curriculum + +| Terme | Étapes | +|---|---| +| `action_rate_weight` | −0.5 (0) → −0.8 (250 it.) → −1.0 (500 it.) | +| `leg_antisym_weight` | 1.0 (0) → 0.5 (1500 it.) → 0.25 (3000 it.) | +| `com_range` | 0.003 → 0.005 (500 it.) → 0.01 (1000 it.) | +| `head_com_range` | 0.003 → 0.005 (500 it.) → 0.01 (1000 it.) | + +(itérations × 24 pas/env, comme les autres envs) + +**Pas de curriculum sur la vitesse cible** : 6 rad/s d'emblée *(cible initiale ; +ramenée à 3 rad/s, toujours sans curriculum, voir l'amendement)*. Voir « Plan B ». + +## PPO + +`MicroduckSpinRlCfg` = copie de `MicroduckRollerCrouchRlCfg` : actor/critic +(512, 256, 128) elu, obs normalization, PPO adaptatif lr 1e-3, `desired_kl=0.01`, +`num_steps_per_env=24`, `symmetry_cfg=None`, `experiment_name="spin"`, +`run_name="spin"`, `max_iterations=8000`. + +## Tests + +`tests/test_spin.py` — fonctions pures, sans simulateur : +- `spin_rate_by_phase` : valeurs aux bornes des 4 segments (0, rate_max, rate_max, 0, 0) +- monotonie croissante sur la rampe de lancement, décroissante sur le freinage +- **intégrale sur un cycle ≈ 4π** à `rate_max = 6.0` (garantit la **forme** du + trapèze, `2.1 × rate_max` rad par cycle) — ne protège plus la cible en vigueur + depuis l'amendement, cf. bullet suivant. Valeur exacte de l'enveloppe : 12.6 rad + contre 4π = 12.566 → tolérance 1 % +- **la cible réellement expédiée** (`mdp.SPIN_RATE_MAX`) intègre bien à + `2.1 × SPIN_RATE_MAX` rad par cycle, quel que soit `rate_max` — ajouté en + 7d916aa, c'est ce test qui échoue si la cible change sans qu'on ait réfléchi au + nombre de tours. Avec la valeur en vigueur (3.0 rad/s) : 6.3 rad ≈ 1 tour +- `gate(φ) = 0` sur tout le segment repos, `∈ [0,1]` partout + +`tests/test_spin_cfg.py` — l'env se construit : +- commande = `GroundPickPhaseCommand`, `period == 4.0`, `randomize_phase is False` +- `"angular_momentum" not in cfg.rewards` (le piège de la section rewards) +- `symmetry_cfg is None` +- dimension de l'obs actor == 61 +- **parité exacte de l'ordre des termes d'observation** (actor + critic) avec + `roller_crouch`, groupe par groupe — ajouté en 7d916aa, condition stricte pour + que l'ONNX exporté charge dans le slot du runtime + +Lancer : `uv run --with pytest pytest tests/ -q` + +## Entraînement / déploiement + +```bash +uv run train Mjlab-Spin-Flat-MicroDuck --env.scene.num-envs 4096 --agent.max_iterations 8000 +# surveiller Episode_Reward/spin_rate_track (doit monter) +uv run scripts/play_latest.py # alias md-play +uv run scripts/export_latest.py # ONNX, normaliseur d'obs baké +``` + +```bash +microduck_runtime --variant pre-alpha --new-cmd-obs --roller \ + --model output.onnx --new-dxl-imu --kp 200 --action-scale 0.8 \ + --ground-pick spin.onnx \ + --ground-pick-period 4.0 \ # = SPIN_PERIOD + --ground-pick-kp-ratio 1.0 \ # défaut 0.6 -> forcer 1.0 (entraîné kp 200) + --ground-pick-action-scale 0.8 # matcher action_scale runtime +``` + +Bouton **A** → spin, puis retour auto à la policy roller. + +## Critère de succès + +En play : ~2 tours anti-horaire en ~2.6 s, dérive du tronc < ~10 cm, robot debout tout +du long, station neutre stable pendant le segment repos avant le cycle suivant. +*(Critère formulé pour la cible initiale de 6 rad/s / 2 tours ; à 3 rad/s, voir +l'amendement, ce serait ~1 tour sur la durée du régime — critère non révisé, le +robot ne tenant pas encore jusque-là.)* + +## Plan B si l'entraînement plafonne + +Dans l'ordre : +1. **Curriculum de vitesse** : `SPIN_RATE_MAX` 3 → 6 rad/s (nécessite de rendre + `rate_max` pilotable par un `CurriculumTermCfg` sur les params de reward). + **Partiellement suivi** : suite au run de calibrage, la cible a bien été + abaissée à 3 rad/s (voir l'amendement), mais **sans curriculum** — 3 rad/s + est pour l'instant une cible fixe, pas un point de départ ramping vers 6. + L'humain a choisi de voir d'abord ce que le robot parvient à faire à cette + vitesse avant d'envisager une remontée graduelle. +2. Monter `spin_wheel_differential` et retarder la décroissance de `leg_antisymmetry`. +3. Élargir `std` de `spin_rate_track` (1.5 → 2.5) pour un gradient utile plus loin. +4. En dernier recours, basculer sur l'approche B (poses de ciseau composées à la main + dans un pose editor) pour amorcer le geste, puis relâcher. + +## Hors périmètre + +- Spin à droite (policy miroir dans un autre slot) — plus tard. +- Variante à pied (sans rollers). +- Spin commandé en vitesse continue (nécessiterait un canal de commande runtime). + +## Résultats de la vérification initiale + +### Demi-voie mesurée et `omega_scale` + +La demi-voie a été mesurée sur les sites `left_foot` / `right_foot` du modèle +rollers : **0.0499 m**, contre l'estimation de 0.03 m du spec. Différentiel de +roues attendu au régime (6 rad/s) : `2 · 6.0 · 0.0499 / 0.0175` = **34.2 rad/s**, +soit 71 % au-dessus du défaut 20.0 — au-delà du seuil de 30 % fixé par le plan. +`SPIN_WHEEL_OMEGA_SCALE` a donc été changé de 20.0 à **34.0**. Les tests continuent +de passer `omega_scale=20.0` explicitement, pour rester indépendants de la +constante. + +### Smoke run (Step 2 : 5 itérations, 64 envs, garde NaN) + +Terminé sans exception. `Episode_Termination/nan_state` est resté à 0.0000 sur +toute la durée, et `/tmp/mjlab/nan_dumps/` n'a jamais été créé. Les six rewards +spin apparaissent bien dans les clés `Episode_Reward/` loggées : `spin_rate_track`, +`spin_rate_l1`, `spin_stay_in_place`, `spin_wheel_differential`, `spin_grounded`, +`leg_antisymmetry`. + +Parité d'observation (Step 1) : la liste des termes de l'obs actor de l'env spin +est **identique** à celle de `roller_crouch` — 8 termes, même ordre : +`base_ang_vel, projected_gravity, joint_pos, joint_vel, actions, command, +head_command, body_command`. C'est la condition pour que l'ONNX exporté charge +dans le slot du runtime. + +**Note d'usage à retenir** : la commande d'exemple du plan avec `--enable-nan-guard` +en flag nu est rejetée par le CLI de ce repo — il faut passer +`--enable-nan-guard True`. + +### Run de calibrage 500 itérations (Step 3) + +4096 envs, 500 itérations, ~2,32 s/itération, code de sortie 0, logger wandb (donc +`scripts/play_latest.py` / `md-play` retrouve le run). + +**Ce qui a réellement été établi** : `Mean episode length` = **57.83 pas** sur un +épisode de 1000 pas (20 s à 50 Hz), soit **~1,16 s**. `Episode_Termination/fell_over` +≈ **70**, `time_out = 0.0000`, `nan_state = 0`. Le robot **tombe à chaque épisode**, +à une phase φ ≈ 0,29 — en plein milieu du segment de régime. Il n'atteint jamais le +freinage (φ ≥ 0,525) ni le repos (φ ≥ 0,650) : **71 % du cycle n'est jamais +entraîné**. + +La longueur d'épisode est passée de 23,98 à 57,83 pas sur la durée du run : la +montée de `Episode_Reward/spin_rate_track` (0,0291 → 0,3168) reflète donc +principalement une **survie qui s'allonge**, pas un suivi qui s'améliore. Le +critère de succès de cette étape tel qu'énoncé dans le plan (« la courbe doit +monter ») **n'est pas un signal valide** pour ce terme : un robot totalement +immobile score déjà `6.0 × 0.405 = 2.43` dessus — le segment de repos paie plein +tarif pour rester debout sans bouger, donc toute policy qui survit plus longtemps +capte mécaniquement plus de ce segment-là, indépendamment de la qualité du suivi. + +### Diagnostic dérivé — estimations, pas des mesures directes + +Les valeurs ci-dessous viennent du rapport entre termes de reward dans le dernier +bloc de log, ce qui annule le facteur de normalisation inconnu appliqué par le +logger. À prendre comme des estimations, reproductibles à partir de la même +méthode : + +**Ce qui tient** : pendant les ~1,2 s où il reste debout, le robot suit la cible +d'assez près. Rapport `spin_rate_l1 / spin_rate_track` (−0,0097 / 0,3168, poids 0,5 +et 6,0, `std = 1.5`), en résolvant `e = 0.3674 · exp(−(e/1.5)²)` : erreur moyenne +absolue de suivi de vitesse de lacet ≈ **0,35 rad/s**, confirmée par deux voies +indépendantes — ce ratio `spin_rate_l1 / spin_rate_track`, et un calcul inverse à +partir de la normalisation du reward manager. Il **peut lancer** le spin ; il **ne +peut pas rester debout** en le faisant. + +**Ce qui ne tient pas** : le bloc de shaping (`spin_wheel_differential` 1,0, +`spin_grounded` 0,5, `spin_stay_in_place` −1,0) totalise ~1,0 de poids contre 6,0 +pour l'objectif principal — environ **13 %** de ce qu'une policy en patinage +renoncerait à gagner en ignorant ce bloc. Et `spin_wheel_differential` est +**invariant au centre instantané de rotation** : un spin centré à 6 rad/s et un +pivot sur le patin gauche à 6 rad/s produisent tous les deux un différentiel de +34,2 — ce terme n'encode donc **pas** le roulement centré, seul +`spin_stay_in_place` le fait. `spin_stay_in_place` ≈ −0,0069 implique +`‖v_xy‖ ≈ 0,35 m/s` : le robot est encore en translation, cohérent avec un pivot +excentré (patin comme pivot) plutôt qu'une rotation autour du centre du corps. + +### Changement de configuration décidé suite à ce diagnostic + +Cible réduite de moitié — `SPIN_RATE_MAX` 6.0 → **3.0 rad/s** — et +`spin_stay_in_place` renforcé −1.0 → **−3.0** (voir le tableau des rewards et +`SPIN_WHEEL_OMEGA_SCALE` recalibré à 17.0 plus haut). **Délibérément sans +curriculum** sur la vitesse cible : c'est un premier essai pour voir ce que le +robot parvient à faire à vitesse moitié, avant d'envisager une remontée graduelle +si besoin. + +**Atténuation du coût de dérive pendant le lancement.** Renforcer +`spin_stay_in_place` à −3.0 a rendu plus aigu un défaut relevé par la revue : ce +terme était le seul du spin à ne pas être modulé par la phase, donc il facturait à +plein tarif la translation transitoire pendant la rampe de lancement — précisément +le moment où le robot doit pousser au sol pour s'injecter du moment angulaire, et +où l'élan d'entrée (jusqu'à 0.3 m/s) doit être **converti** en rotation. Le coût +est désormais multiplié par `SPIN_LAUNCH_DRIFT_SCALE = 0.2` sur `[0, ACCEL_END)` +et vaut plein tarif ensuite. Il n'est volontairement **pas** éteint pendant le +repos, contrairement aux amorces : c'est là que l'immobilité est le vrai critère. + +L'étape 4 (regarder le geste) reste à faire, réservée à l'humain. + +⚠️ Ces quatre tests (trois nouveaux sur l'atténuation, un modifié) n'ont **pas** +été exécutés — la machine était réservée à autre chose au moment du commit. À +lancer avant tout run long : `uv run --with pytest pytest tests/test_spin.py +tests/test_spin_cfg.py -q`. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..479431f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,100 @@ +[build-system] +requires = ["uv_build>=0.8.19,<0.9.0"] +build-backend = "uv_build" + +[project] +name = "mjlab-microduck" +version = "0.1.0" +description = "RL training environments for the Microduck robot, built on mjlab" +readme = "README.md" +license = "Apache-2.0" +license-files = ["LICENSE"] +# <3.13: bam (better-actuator-models) pins requires-python <3.13, and jobs must +# run the same interpreter we test locally (3.12) — a floating upper bound let +# HF jobs pick 3.13.14 (2026-07-21). +requires-python = ">=3.12, <3.13" +dependencies = [ + "mjlab==1.3.0", + "warp-lang==1.12.0", + # BAM distribution name is `better-actuator-models`; the import stays `bam`. + "better-actuator-models", + "onnxruntime>=1.24.4", + "rustypot>=1.4.2", + "huggingface_hub>=0.27.0", + "matplotlib>=3.10.9", + # mjlab 1.3.0 imports scipy (terrains/heightfield_terrains.py) but forgets + # to declare it — without this line a fresh `uv sync` (e.g. on HF jobs) + # can't even `import mjlab_microduck` (found 2026-07-21). + "scipy>=1.16", + # Direct dep ONLY so [tool.uv.sources] can bind torch to the CUDA index on + # aarch64 (see the torch source below) — uv applies sources to DIRECT + # dependencies only, so as a purely transitive dep (mjlab, rsl_rl) the + # source entry is silently ignored. + # Pinned to the exact version uv.lock already resolved from PyPI so this + # changes only the SOURCE of the wheel on aarch64, not the version: a + # floating `>=` lets the CUDA index (which carries newer builds than the + # PyPI pin) drag torch 2.9.1 -> 2.13.0, an unvetted bump for mjlab/rsl_rl. + "torch==2.9.1", +] + +[project.entry-points."mjlab.tasks"] +mjlab_microduck = "mjlab_microduck.tasks" + +[project.scripts] +# Shadows mjlab's `train` entry point: identical behavior, plus a --hf-jobs +# flag that submits the run to Hugging Face Jobs instead (see train_cli.py). +train = "mjlab_microduck.train_cli:main" + +[tool.ruff] +src = ["src"] # Helpful for recognizing first-party imports. +indent-width = 4 +exclude = [ + "src/mjlab/third_party", + "typings", +] + +[tool.uv] +override-dependencies = [ + # NOTE: do NOT override mujoco — mjlab 1.3.0's mujoco-warp pins a compatible + # mujoco (3.10.x); an override here previously forced mujoco down to 3.4.0, + # which lacks mjDSBL_MULTICCD that mujoco-warp 3.8.1 imports → import crash. + # bam pins protobuf<4.0 (for its zmq/dynamixel messaging, which we don't + # use — we only import bam.model/bam.actuator). Without this override the + # downgrade cascades onnx down to 1.17.0, which has no wheel and fails to + # build from source. + "protobuf>=4.0,<7.0", + # Keep onnx on a version with prebuilt wheels (1.17.0 has no py3.13 wheel and + # fails to build from source). Pulling in bam otherwise nudges it downward. + "onnx>=1.20.1", + # bam depends on the PyPI stub `zmq==0.0.0`, whose prebuilt wheel is invalid + # (missing .dist-info) -> `uv sync` fails on a FRESH install (e.g. HF Jobs + # remote build), even though a warm local cache tolerated it. We only use + # bam.mjlab (no zmq/erob messaging), so drop zmq via an always-false marker. + "zmq ; python_version < '3.0'", +] + +[tool.uv.sources] +# mjlab now comes from PyPI (==1.3.0, pinned in [project.dependencies]); no git source. +# Official BAM actuator model. Switch to `main` (or a tag) once the +# mjlab-facing API lands there. +better-actuator-models = { git = "https://github.com/Rhoban/bam.git", branch = "mjlab_frictionloss" } +# For local BAM development, comment the line above and use: +# better-actuator-models = { path = "/home/antoine/Rhoban/bam", editable = true } +# On linux-aarch64 (DGX Spark / GB10) PyPI's torch wheel is CPU-ONLY: +# torch.__version__ == "2.9.1+cpu", torch.version.cuda is None, so +# torch.cuda.device_count() == 0 and mjlab's select_gpus() indexes an empty +# list -> `IndexError: list index out of range` before training even starts +# (mjlab/utils/gpu.py:70). Route torch to PyTorch's CUDA index there. +# cu129 (not cu130) matches the CUDA toolkit warp 1.12.0 bundles, so the +# zero-copy warp<->torch interop stays on one runtime major version. +# The marker keeps x86_64 (HF Jobs) on PyPI, where the wheel already bundles +# CUDA via its nvidia-*-cu12 deps — that resolution is unchanged. +torch = [ + { index = "pytorch-cu129", marker = "sys_platform == 'linux' and platform_machine == 'aarch64'" }, +] + +[[tool.uv.index]] +name = "pytorch-cu129" +url = "https://download.pytorch.org/whl/cu129" +# explicit: only packages that name this index resolve from it. +explicit = true diff --git a/scripts/crouch_pose_editor.py b/scripts/crouch_pose_editor.py new file mode 100644 index 0000000..1c8193f --- /dev/null +++ b/scripts/crouch_pose_editor.py @@ -0,0 +1,99 @@ +"""Interactive crouch pose editor (roller robot). + +Ouvre le viewer MuJoCo avec le robot rollers debout. Dans le panneau "Control" +du viewer, bouge les sliders (genoux/hanches/chevilles…) pour composer la pose +ACCROUPIE voulue. La gravité est coupée et la base est maintenue droite + +abaissée pour que le point le plus bas reste au sol (tu vois donc le tronc +descendre quand tu plies les genoux). À la fermeture de la fenêtre, la pose est +imprimée en dict CROUCH_POSE {nom_articulation: angle_rad} prêt à coller. + +Usage: + uv run python scripts/crouch_pose_editor.py +""" + +import re +import time + +import mujoco +import mujoco.viewer + +from mjlab_microduck.robot.microduck_constants import ( + get_walk_rollers_spec, + HOME_FRAME, +) + + +def home_value(joint_name: str): + for pattern, val in HOME_FRAME.joint_pos.items(): + if re.search(pattern, joint_name): + return float(val) + return 0.0 + + +# Modèle direct depuis le spec du robot (14 actionneurs dans le XML). +model = get_walk_rollers_spec().compile() +data = mujoco.MjData(model) +mujoco.mj_resetData(model, data) +model.opt.gravity[:] = [0, 0, 0] # rien ne s'effondre : seuls les sliders bougent + +has_free = model.jnt_type[0] == mujoco.mjtJoint.mjJNT_FREE + +# Articulations actionnées (hors roues passives), avec adresse qpos. +joints = [] +for i in range(model.njnt): + name = mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_JOINT, i) + if not name or "freejoint" in name or "passive_" in name: + continue + joints.append((name, model.jnt_qposadr[i])) + +# ctrl initial = pose HOME (les actionneurs position tiennent cette cible). +for a in range(model.nu): + aname = mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_ACTUATOR, a) + data.ctrl[a] = home_value(aname or "") + +if has_free: + data.qpos[0:3] = [0.0, 0.0, 0.14] + data.qpos[3:7] = [1.0, 0.0, 0.0, 0.0] + base_xy = data.qpos[0:2].copy() + base_quat = data.qpos[3:7].copy() + +robot_geoms = [g for g in range(model.ngeom) + if model.geom_type[g] != mujoco.mjtGeom.mjGEOM_PLANE] + +mujoco.mj_forward(model, data) + +print("=== Crouch Pose Editor (rollers) ===") +print(f"actionneurs: {model.nu} | base flottante: {has_free}") +print("Ouvre le panneau 'Control' du viewer et bouge les sliders pour composer") +print("la pose ACCROUPIE. Ferme la fenêtre quand c'est bon.\n") + +with mujoco.viewer.launch_passive(model, data) as viewer: + while viewer.is_running(): + if has_free: + data.qpos[0:2] = base_xy + data.qpos[3:7] = base_quat + data.qvel[0:6] = 0.0 + mujoco.mj_step(model, data) # actionneurs position -> les joints suivent ctrl + if has_free: + data.qpos[0:2] = base_xy + data.qpos[3:7] = base_quat + data.qvel[0:6] = 0.0 + mujoco.mj_forward(model, data) + try: + zmin = min(float(data.geom_xpos[g, 2] - model.geom_rbound[g]) + for g in robot_geoms) + data.qpos[2] -= zmin + mujoco.mj_forward(model, data) + except Exception: + pass + viewer.sync() + time.sleep(1.0 / 60.0) + +print("\n=== Pose accroupie capturée ===\n") +print("CROUCH_POSE = {") +for name, adr in joints: + print(f' "{name}": {float(data.qpos[adr]):.4f},') +print("}") +if has_free: + print(f"\n# hauteur de base finale (info) : z = {float(data.qpos[2]):.4f}") +print("# Colle CROUCH_POSE ici et donne-le a Claude pour cabler la reward.") diff --git a/scripts/export.py b/scripts/export.py new file mode 100644 index 0000000..90a0a13 --- /dev/null +++ b/scripts/export.py @@ -0,0 +1,279 @@ +"""Script to play RL agent with RSL-RL.""" + +import os +import re +import sys +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Literal + +import torch +import tyro +from rsl_rl.runners import OnPolicyRunner + +from mjlab.envs import ManagerBasedRlEnv +from mjlab.rl import RslRlVecEnvWrapper +from mjlab.tasks.registry import list_tasks, load_env_cfg, load_rl_cfg, load_runner_cls +from mjlab.tasks.tracking.mdp import MotionCommandCfg +from mjlab.utils.os import get_checkpoint_path, get_wandb_checkpoint_path +from mjlab.utils.torch import configure_torch_backends +from mjlab.utils.wrappers import VideoRecorder +from mjlab.viewer import NativeMujocoViewer, ViserPlayViewer + + +@dataclass(frozen=True) +class ExportConfig: + onnx_file: str = "output.onnx" + agent: Literal["zero", "random", "trained"] = "trained" + registry_name: str | None = None + wandb_run_path: str | None = None + checkpoint: int | None = None # Select checkpoint by iteration number (e.g. 3000) + checkpoint_file: str | None = None + motion_file: str | None = None + num_envs: int | None = None + device: str | None = None + video: bool = False + video_length: int = 200 + video_height: int | None = None + video_width: int | None = None + camera: int | str | None = None + viewer: Literal["auto", "native", "viser"] = "auto" + + # Internal flag used by demo script. + _demo_mode: tyro.conf.Suppress[bool] = False + + +def run_export(task_id: str, cfg: ExportConfig): + configure_torch_backends() + + device = cfg.device or ("cuda:0" if torch.cuda.is_available() else "cpu") + + env_cfg = load_env_cfg(task_id, play=True) + agent_cfg = load_rl_cfg(task_id) + + DUMMY_MODE = cfg.agent in {"zero", "random"} + TRAINED_MODE = not DUMMY_MODE + + # Check if this is a motion tracking task. + is_motion_tracking = ( + env_cfg.commands is not None + and "motion" in env_cfg.commands + and isinstance(env_cfg.commands["motion"], MotionCommandCfg) + ) + is_tracking_task = is_motion_tracking + + if is_tracking_task and cfg._demo_mode: + # Demo mode: use uniform sampling to see more diversity with num_envs > 1. + assert env_cfg.commands is not None + motion_cmd = env_cfg.commands["motion"] + assert isinstance(motion_cmd, MotionCommandCfg) + motion_cmd.sampling_mode = "uniform" + + if is_tracking_task: + assert env_cfg.commands is not None + motion_cmd = env_cfg.commands["motion"] + assert isinstance(motion_cmd, MotionCommandCfg) + + # Check if motion file is already set and exists + motion_file_already_set = ( + hasattr(motion_cmd, 'motion_file') + and motion_cmd.motion_file is not None + and Path(motion_cmd.motion_file).exists() + ) + + if DUMMY_MODE: + if not cfg.registry_name: + raise ValueError( + "Tracking tasks require `registry_name` when using dummy agents." + ) + # Check if the registry name includes alias, if not, append ":latest". + registry_name = cfg.registry_name + if ":" not in registry_name: + registry_name = registry_name + ":latest" + import wandb + + api = wandb.Api() + artifact = api.artifact(registry_name) + motion_cmd.motion_file = str(Path(artifact.download()) / "motion.npz") + else: + if cfg.motion_file is not None: + print(f"[INFO]: Using motion file from CLI: {cfg.motion_file}") + motion_cmd.motion_file = cfg.motion_file + elif motion_file_already_set: + print(f"[INFO]: Using motion file from env config: {motion_cmd.motion_file}") + else: + # Try to download from wandb artifacts + import wandb + + api = wandb.Api() + if cfg.wandb_run_path is None and cfg.checkpoint_file is not None: + raise ValueError( + "Tracking tasks require `motion_file` when using `checkpoint_file`, " + "or provide `wandb_run_path` so the motion artifact can be resolved." + ) + if cfg.wandb_run_path is not None: + wandb_run = api.run(str(cfg.wandb_run_path)) + art = next( + (a for a in wandb_run.used_artifacts() if a.type == "motions"), + None, + ) + if art is None: + raise RuntimeError("No motion artifact found in the run.") + motion_cmd.motion_file = str(Path(art.download()) / "motion.npz") + + log_dir: Path | None = None + resume_path: Path | None = None + if TRAINED_MODE: + log_root_path = (Path("logs") / "rsl_rl" / agent_cfg.experiment_name).resolve() + if cfg.checkpoint_file is not None: + resume_path = Path(cfg.checkpoint_file) + if not resume_path.exists(): + raise FileNotFoundError(f"Checkpoint file not found: {resume_path}") + print(f"[INFO]: Loading checkpoint: {resume_path.name}") + elif cfg.checkpoint is not None: + # Select a specific checkpoint iteration, from wandb or local. + checkpoint_filename = f"model_{cfg.checkpoint}.pt" + if cfg.wandb_run_path is not None: + import wandb + api = wandb.Api() + wandb_run = api.run(str(cfg.wandb_run_path)) + run_id = cfg.wandb_run_path.split("/")[-1] + download_dir = log_root_path / "wandb_checkpoints" / run_id + resume_path = download_dir / checkpoint_filename + if resume_path.exists(): + print(f"[INFO]: Loading checkpoint: {checkpoint_filename} (run: {run_id}, cached)") + else: + available = [f.name for f in wandb_run.files() if "model" in f.name] + if checkpoint_filename not in available: + raise FileNotFoundError( + f"Checkpoint '{checkpoint_filename}' not found in wandb run. " + f"Available: {sorted(available)}" + ) + wandb_run.file(checkpoint_filename).download(str(download_dir), replace=True) + print(f"[INFO]: Loading checkpoint: {checkpoint_filename} (run: {run_id}, downloaded)") + else: + resume_path = get_checkpoint_path( + log_root_path, checkpoint=re.escape(checkpoint_filename) + ) + print(f"[INFO]: Loading checkpoint: {resume_path.name}") + else: + if cfg.wandb_run_path is None: + raise ValueError( + "`wandb_run_path` is required when `checkpoint_file` is not provided." + ) + resume_path, was_cached = get_wandb_checkpoint_path( + log_root_path, Path(cfg.wandb_run_path) + ) + # Extract run_id and checkpoint name from path for display. + run_id = resume_path.parent.name + checkpoint_name = resume_path.name + cached_str = "cached" if was_cached else "downloaded" + print( + f"[INFO]: Loading checkpoint: {checkpoint_name} (run: {run_id}, {cached_str})" + ) + log_dir = resume_path.parent + + if cfg.num_envs is not None: + env_cfg.scene.num_envs = cfg.num_envs + if cfg.video_height is not None: + env_cfg.viewer.height = cfg.video_height + if cfg.video_width is not None: + env_cfg.viewer.width = cfg.video_width + + render_mode = "rgb_array" if (TRAINED_MODE and cfg.video) else None + if cfg.video and DUMMY_MODE: + print( + "[WARN] Video recording with dummy agents is disabled (no checkpoint/log_dir)." + ) + env = ManagerBasedRlEnv(cfg=env_cfg, device=device, render_mode=render_mode) + + if TRAINED_MODE and cfg.video: + print("[INFO] Recording videos during play") + assert log_dir is not None # log_dir is set in TRAINED_MODE block + env = VideoRecorder( + env, + video_folder=log_dir / "videos" / "play", + step_trigger=lambda step: step == 0, + video_length=cfg.video_length, + disable_logger=True, + ) + + env = RslRlVecEnvWrapper(env, clip_actions=agent_cfg.clip_actions) + if DUMMY_MODE: + action_shape: tuple[int, ...] = env.unwrapped.action_space.shape # type: ignore + if cfg.agent == "zero": + + class PolicyZero: + def __call__(self, obs) -> torch.Tensor: + del obs + return torch.zeros(action_shape, device=env.unwrapped.device) + + policy = PolicyZero() + else: + + class PolicyRandom: + def __call__(self, obs) -> torch.Tensor: + del obs + return 2 * torch.rand(action_shape, device=env.unwrapped.device) - 1 + + policy = PolicyRandom() + else: + runner_cls = load_runner_cls(task_id) or OnPolicyRunner + runner = runner_cls(env, asdict(agent_cfg), device=device) + runner.load(str(resume_path), map_location=device) + policy = runner.get_inference_policy(device=device) + + # mjlab 1.3.0: ONNX export + metadata moved to mjlab.rl.exporter_utils and + # the runner's built-in export_policy_to_onnx. Observation normalization is + # baked into the exported graph automatically — EmpiricalNormalization is a + # submodule of the policy's MLPModel (obs_normalization=True in RslRlModelCfg), + # so export_policy_to_onnx emits actor(normalizer(obs)). No manual normalizer + # handling needed (the old export_velocity_policy_as_onnx path is gone). + from mjlab.rl.exporter_utils import get_base_metadata, attach_metadata_to_onnx + + onnx_path = os.path.abspath(cfg.onnx_file) + path = os.path.dirname(onnx_path) + filename = os.path.basename(onnx_path) + + runner.export_policy_to_onnx(path, filename) + + metadata = get_base_metadata(runner.env.unwrapped, run_path=cfg.checkpoint_file) + attach_metadata_to_onnx(onnx_path, metadata) + + print(f"Written {onnx_path}") + + env.close() + + +def main(): + # Parse first argument to choose the task. + # Import tasks to populate the registry. + import mjlab.tasks # noqa: F401 + + all_tasks = list_tasks() + chosen_task, remaining_args = tyro.cli( + tyro.extras.literal_type_from_choices(all_tasks), + add_help=False, + return_unknown_args=True, + ) + + # Parse the rest of the arguments + allow overriding env_cfg and agent_cfg. + agent_cfg = load_rl_cfg(chosen_task) + + args = tyro.cli( + ExportConfig, + args=remaining_args, + default=ExportConfig(), + prog=sys.argv[0] + f" {chosen_task}", + config=( + tyro.conf.AvoidSubcommands, + tyro.conf.FlagConversionOff, + ), + ) + del remaining_args, agent_cfg + + run_export(chosen_task, args) + + +if __name__ == "__main__": + main() diff --git a/scripts/hf/README.md b/scripts/hf/README.md new file mode 100644 index 0000000..f77b1f2 --- /dev/null +++ b/scripts/hf/README.md @@ -0,0 +1,79 @@ +# HF Jobs training + +Train mjlab-microduck on Hugging Face's managed GPUs. Auth is the cached HF +token (`hf auth login` or `HF_TOKEN`); everything goes through the +`huggingface_hub` Python API — the standalone `hf` CLI is not required. + +## One-time setup + +```fish +hf auth login # or export HF_TOKEN (any tool that caches the token works) +wandb login # auto-detected from ~/.netrc and forwarded +``` + +## Submit a run + +Your normal train command, plus `--hf-jobs`: + +```fish +uv run train Mjlab-Kick-Flat-MicroDuck \ + --env.scene.num-envs 4096 --agent.max_iterations 4000 --hf-jobs +``` + +You'll be asked which namespace to run under — your personal account or one +of your orgs. Repos, uv-cache bucket, billing and the job itself all live in +the chosen namespace. Pass `--namespace ` to skip the prompt +(non-interactive runs default to personal). + +Without `--hf-jobs` the command behaves exactly as before (local training). +Submission flags are consumed locally; everything else is forwarded to +`uv run train` inside the job. + +Useful flags: +- `--namespace ` — account/org to run under; skips the prompt +- `--flavor l4x1` (default) / `a10g-large` / `a100-large` +- `--timeout 12h` (default) — job is killed past this +- `--detach` — submit and return immediately (default streams logs; Ctrl-C detaches without killing the job) +- `--dry-run` — build tarball, print the job spec, do not submit +- `--run-name ` — overrides the auto-generated `-` name +- `--no-uv-cache` — disable the persistent `uv` cache bucket (first-run cost on every run) +- `--no-wandb` — don't forward a wandb key + +(`uv run scripts/hf/train_hf.py ...` still works — it's a shim to the +same code, which lives in `src/mjlab_microduck/hf_jobs.py`.) + +## What happens under the hood + +1. `git ls-files` snapshots tracked + uncommitted files of the repo you run + from (worktree-aware) → `src-.tar.gz`. +2. Tarball is uploaded to private dataset `/mjlab-microduck-src`. +3. Private model repo `/` is created for checkpoints. +4. A private HF bucket `/mjlab-uv-cache` is mounted at `/uv-cache` + and used as `UV_CACHE_DIR` so wheel downloads persist across runs (first + run cold, subsequent runs fast). +5. `HfApi.run_job` launches a container that: + - installs `uv`, extracts the tarball, runs `uv sync` (warm-cached), + - starts `scripts/hf/uploader.py` in background (watches `logs/rsl_rl/**/model_*.pt`, pushes every 60s), + - runs `uv run train `, + - does a final one-shot upload on exit. +6. wandb credentials are forwarded as a secret — runs show up live in your + wandb project. + +## Browsing checkpoints + +The submitter prints `https://huggingface.co//` at +start; new `.pt` files appear there during training. + +## Managing jobs + +The job id and URL are printed at submission. From Python: + +```python +from huggingface_hub import HfApi +api = HfApi() +api.list_jobs() # or namespace="pollen-robotics" +for l in api.fetch_job_logs(job_id="...", follow=True): print(l) +api.cancel_job(job_id="...") +``` + +(or the `hf jobs ps/logs/cancel` CLI if you have it installed.) diff --git a/scripts/hf/train_hf.py b/scripts/hf/train_hf.py new file mode 100644 index 0000000..85b476e --- /dev/null +++ b/scripts/hf/train_hf.py @@ -0,0 +1,15 @@ +"""Back-compat shim: the submission logic moved to mjlab_microduck.hf_jobs. + +Prefer the integrated flag: + uv run train --hf-jobs [--namespace ] [...] + +This script keeps the old invocation working: + uv run scripts/hf/train_hf.py [submission flags] +""" + +import sys + +from mjlab_microduck.hf_jobs import submit + +if __name__ == "__main__": + sys.exit(submit(sys.argv[1:])) diff --git a/scripts/hf/uploader.py b/scripts/hf/uploader.py new file mode 100644 index 0000000..7c5f966 --- /dev/null +++ b/scripts/hf/uploader.py @@ -0,0 +1,75 @@ +"""Checkpoint uploader run inside an HF Job. + +Watches `logs/rsl_rl/**/model_*.pt` and uploads new/updated files to the +target HF Model repo. Designed to be `nohup uv run`-launched from the job +bootstrap, with auth coming from the HF_TOKEN secret injected by `hf jobs run`. +""" + +from __future__ import annotations + +import os +import sys +import time +from pathlib import Path + +from huggingface_hub import HfApi, CommitOperationAdd + + +def main() -> int: + repo_id = os.environ.get("CKPT_REPO") + if not repo_id: + print("[uploader] CKPT_REPO not set, exiting", flush=True) + return 1 + + poll_interval = float(os.environ.get("CKPT_POLL_INTERVAL", "60")) + root = Path(os.environ.get("CKPT_ROOT", "logs/rsl_rl")) + + one_shot = os.environ.get("CKPT_ONE_SHOT") == "1" + + api = HfApi() + api.create_repo(repo_id, repo_type="model", private=True, exist_ok=True) + mode = "one-shot" if one_shot else f"every {poll_interval}s" + print(f"[uploader] watching {root} -> {repo_id} ({mode})", flush=True) + + sent: dict[Path, float] = {} + while True: + try: + files = list(root.glob("**/model_*.pt")) + # also pick up the dumped configs once + files += [p for p in root.glob("**/params/*.yaml")] + files += [p for p in root.glob("**/params/*.json")] + + to_upload: list[CommitOperationAdd] = [] + for f in files: + try: + mtime = f.stat().st_mtime + except FileNotFoundError: + continue + if sent.get(f) == mtime: + continue + # use path-in-repo relative to logs/rsl_rl so the repo mirrors run dirs + rel = f.relative_to(root) + to_upload.append( + CommitOperationAdd(path_in_repo=str(rel), path_or_fileobj=str(f)) + ) + sent[f] = mtime + + if to_upload: + msg = f"upload {len(to_upload)} file(s)" + api.create_commit( + repo_id=repo_id, + repo_type="model", + operations=to_upload, + commit_message=msg, + ) + print(f"[uploader] pushed {len(to_upload)} file(s)", flush=True) + except Exception as e: + print(f"[uploader] error: {e}", flush=True) + + if one_shot: + return 0 + time.sleep(poll_interval) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/infer_policy.py b/scripts/infer_policy.py new file mode 100644 index 0000000..abbf257 --- /dev/null +++ b/scripts/infer_policy.py @@ -0,0 +1,1383 @@ +#!/usr/bin/env python3 +"""Simple script to run ONNX policy inference in MuJoCo with rendering.""" + +import argparse +import csv +import math +import os +import pickle +import queue +import select +import sys +import termios +import threading +import time +import tty +import numpy as np +import mujoco +import mujoco.viewer +import onnxruntime as ort + +MICRODUCK_XML = "src/mjlab_microduck/robot/microduck/scene.xml" +# MICRODUCK_XML = "src/mjlab_microduck/robot/microduck/scene_ramps.xml" +# MICRODUCK_XML = "src/mjlab_microduck/robot/microduck/scene_floor_objects.xml" +# MICRODUCK_XML = "src/mjlab_microduck/robot/microduck/scene_robot_walk.xml" +MICRODUCK_ROLLERS_XML = "src/mjlab_microduck/robot/microduck/scene_rollers.xml" +MICRODUCK_BALL_XML = "src/mjlab_microduck/robot/microduck/scene_ball.xml" + +# Body pose command constants (must match training constants) +BODY_CMD_MAX_Z = 0.03 # ±30 mm +BODY_CMD_MAX_XY = 0.02 # ±20 mm +BODY_CMD_MAX_ANGLE = math.radians(30) # ±30° + +# Ball placement for kick behaviors (must match microduck_ball_kick_env_cfg's +# reset_ball_in_front_of_foot params: ball center in the robot's yaw frame). +BALL_OFFSET_X = 0.09 +BALL_OFFSET_ABS_Y = 0.042 +BALL_RADIUS = 0.035 + +# Default pose used by the policy (legs flexed, standing position) +# This is the reference pose that: +# - Actions are offsets from (motor_target = DEFAULT_POSE + action * scale) +# - Joint observations are relative to (obs_joint_pos = current_pos - DEFAULT_POSE) +# STAND2 pose (matches HOME_FRAME in microduck_constants.py): trunk shifted +# ~5mm forward so the CoM sits over the ankle axis. Leg pitch chain leaned +# forward vs the old pose: hip_pitch 30°→26.24°, ankle 30°→25.95°, knee 0°→0.28°. +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) + + +class TerminalInput: + """Single-keypress reader on stdin (cbreak mode, background thread). + + Replaces the MuJoCo viewer key_callback: keypresses in the viewer window + also fire the viewer's built-in visualization shortcuts (frames, labels, + rendering toggles…), so commands are read from the terminal instead. + Arrow keys arrive as ESC [ A/B/C/D escape sequences and are translated to + symbolic names ("up"/"down"/"left"/"right"); letters are lowercased. + cbreak (not raw) mode keeps ISIG enabled, so Ctrl+C still works. + """ + + _ARROWS = {"A": "up", "B": "down", "C": "right", "D": "left"} + + def __init__(self): + self._queue = queue.Queue() + self.enabled = sys.stdin.isatty() + self._fd = sys.stdin.fileno() if self.enabled else -1 + self._old_attrs = None + self._stop = threading.Event() + + def __enter__(self): + if not self.enabled: + print("WARNING: stdin is not a TTY — keyboard control disabled") + return self + self._old_attrs = termios.tcgetattr(self._fd) + tty.setcbreak(self._fd) + threading.Thread(target=self._reader, daemon=True).start() + return self + + def __exit__(self, *exc): + self._stop.set() + if self._old_attrs is not None: + termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old_attrs) + + def _read1(self, timeout): + """Read one byte from stdin, or None on timeout. os.read (unbuffered): + buffered sys.stdin.read would swallow escape-sequence bytes past what + select reported ready.""" + r, _, _ = select.select([self._fd], [], [], timeout) + if not r: + return None + data = os.read(self._fd, 1) + return data.decode(errors="ignore") if data else None + + def _reader(self): + while not self._stop.is_set(): + ch = self._read1(0.1) + if not ch: + continue + if ch == "\x1b": # possible arrow-key escape sequence + if self._read1(0.05) == "[": + final = self._read1(0.05) + name = self._ARROWS.get(final) if final else None + if name: + self._queue.put(name) + continue # bare ESC / unknown sequence: ignore + self._queue.put(ch.lower() if ch.isalpha() else ch) + + def get_keys(self): + """Drain and return all pending keys (symbolic names / characters).""" + keys = [] + while True: + try: + keys.append(self._queue.get_nowait()) + except queue.Empty: + return keys + + +class PolicyInference: + def __init__(self, model, data, walking_onnx_path=None, action_scale=1.0, + delay_min_lag=0, delay_max_lag=0, + standing_onnx_path=None, switch_threshold=0.05, + use_projected_gravity=False, ground_pick_onnx_path=None, ground_pick_period=4.0, + sit_onnx_path=None, new_cmd_obs=False, slope_onnx_path=None, + sitstand_onnx_path=None, + kick_left_onnx_path=None, kick_right_onnx_path=None, + roulade_onnx_path=None, + kick_duration=3.0, roulade_duration=2.0): + self.model = model + self.data = data + self.action_scale = action_scale + self.use_projected_gravity = use_projected_gravity + self.delay_min_lag = delay_min_lag + self.delay_max_lag = delay_max_lag + self.switch_threshold = switch_threshold + # When True: emit the unified 13D command vector and treat head_offset / + # body_cmd as policy COMMANDS (no add to ctrl, no joint_pos correction). + # When False: legacy behaviour (3D command, head_offset added to ctrl[5:9]). + self.new_cmd_obs = new_cmd_obs + + # Load walking policy + self.walking_session = None + self.default_gait_period_from_onnx = None + if walking_onnx_path: + print(f"Loading walking policy from: {walking_onnx_path}") + self.walking_session = ort.InferenceSession(walking_onnx_path) + w_input_shape = self.walking_session.get_inputs()[0].shape + w_output_shape = self.walking_session.get_outputs()[0].shape + print(f"Walking policy input: {self.walking_session.get_inputs()[0].name}, shape: {w_input_shape}") + print(f"Walking policy output: {self.walking_session.get_outputs()[0].name}, shape: {w_output_shape}") + + # Try to read gait period from ONNX metadata + try: + model_metadata = self.walking_session.get_modelmeta() + if hasattr(model_metadata, 'custom_metadata_map') and 'gait_period' in model_metadata.custom_metadata_map: + self.default_gait_period_from_onnx = float(model_metadata.custom_metadata_map['gait_period']) + print(f"Found gait period in ONNX metadata: {self.default_gait_period_from_onnx:.4f}s") + except Exception as e: + print(f"Could not read gait period from ONNX metadata: {e}") + + # Load standing policy + self.standing_session = None + if standing_onnx_path: + print(f"\nLoading standing policy from: {standing_onnx_path}") + self.standing_session = ort.InferenceSession(standing_onnx_path) + s_input_shape = self.standing_session.get_inputs()[0].shape + s_output_shape = self.standing_session.get_outputs()[0].shape + print(f"Standing policy input: {self.standing_session.get_inputs()[0].name}, shape: {s_input_shape}") + print(f"Standing policy output: {self.standing_session.get_outputs()[0].name}, shape: {s_output_shape}") + if self.walking_session: + print(f"Policy switching threshold: {switch_threshold} (vel command magnitude)") + + # Load ground pick policy + self.ground_pick_session = None + self.ground_pick_mode = False + self.ground_pick_phase = 0.0 + self.ground_pick_period = ground_pick_period + if ground_pick_onnx_path: + print(f"\nLoading ground pick policy from: {ground_pick_onnx_path}") + self.ground_pick_session = ort.InferenceSession(ground_pick_onnx_path) + gp_input_shape = self.ground_pick_session.get_inputs()[0].shape + print(f"Ground pick policy input shape: {gp_input_shape}") + + # Load sit policy. Two flavours share the Y key and self.sit_session: + # - --sit (is_sitstand=False): the OLD one-way sit policy. Sits + # unconditionally on a zero twist command; standing back up is done + # by switching back to the standing/walking session. + # - --sitstand (is_sitstand=True): the commanded sit↔stand policy. + # twist[0] is a posture flag (0=stand, 1=sit); the SAME policy sits, + # holds, and stands back up — Y just flips the flag. + self.sit_session = None + self.sit_mode = False + self.is_sitstand = False + if sit_onnx_path and sitstand_onnx_path: + raise ValueError("Provide only one of --sit / --sitstand") + if sit_onnx_path: + print(f"\nLoading sit policy from: {sit_onnx_path}") + self.sit_session = ort.InferenceSession(sit_onnx_path) + sit_input_shape = self.sit_session.get_inputs()[0].shape + print(f"Sit policy input shape: {sit_input_shape}") + elif sitstand_onnx_path: + if not self.new_cmd_obs: + raise ValueError( + "--sitstand policies use the unified 13D command obs (61D); run with --new-cmd-obs" + ) + print(f"\nLoading sitstand policy from: {sitstand_onnx_path}") + self.sit_session = ort.InferenceSession(sitstand_onnx_path) + self.is_sitstand = True + ss_input_shape = self.sit_session.get_inputs()[0].shape + print(f"Sitstand policy input shape: {ss_input_shape}") + + # Load slope policy (passive descent, runs with zero twist command) + self.slope_session = None + self.slope_mode = False + if slope_onnx_path: + print(f"\nLoading slope policy from: {slope_onnx_path}") + self.slope_session = ort.InferenceSession(slope_onnx_path) + sl_input_shape = self.slope_session.get_inputs()[0].shape + print(f"Slope policy input shape: {sl_input_shape}") + + # Episodic behavior policies (kick left/right, roulade). All three use + # the unified 61D obs layout with an ALL-ZERO 13D command (twist forced + # ~0 in training, head/body slots zero-padded), so triggering one is a + # plain session swap; after `duration` seconds control hands back to + # walking/standing (the behavior policies end standing on their own). + self.behavior_sessions = {} + self.behavior_durations = {} + self.behavior_mode = None # name of the running behavior, or None + self.behavior_time_left = 0.0 + for name, path, duration in ( + ("kick_left", kick_left_onnx_path, kick_duration), + ("kick_right", kick_right_onnx_path, kick_duration), + ("roulade", roulade_onnx_path, roulade_duration), + ): + if not path: + continue + if not self.new_cmd_obs: + raise ValueError( + f"--{name.replace('_', '-')} policies use the unified 13D " + "command obs (61D); run with --new-cmd-obs" + ) + print(f"\nLoading {name} policy from: {path}") + self.behavior_sessions[name] = ort.InferenceSession(path) + self.behavior_durations[name] = duration + print(f"{name} policy input shape: {self.behavior_sessions[name].get_inputs()[0].shape}" + f" (auto-return after {duration:.1f}s)") + + # Validate at least one policy loaded. A sitstand policy can run alone + # (it holds the stand at flag=0), unlike the old one-way sit policy. + if not self.walking_session and not self.standing_session and not self.is_sitstand: + raise ValueError("At least one of --walking, --standing or --sitstand must be provided") + + # Determine initial active session and policy + if self.walking_session: + self.current_policy = "walking" + self.ort_session = self.walking_session + elif self.standing_session: + self.current_policy = "standing" + self.ort_session = self.standing_session + else: + # sitstand-only: start standing (posture flag 0). + self.current_policy = "sit" + self.ort_session = self.sit_session + + # Get input/output names from active session + self.input_name = self.ort_session.get_inputs()[0].name + self.output_name = self.ort_session.get_outputs()[0].name + + # Get sensor IDs and body IDs + self.imu_ang_vel_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_SENSOR, "imu_ang_vel") + self.trunk_base_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_BODY, "trunk_base") + + # Trunk freejoint qpos address (needed to place the ball in the robot's + # yaw frame) and optional ball freejoint (present in scene_ball.xml). + _trunk_jid = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_JOINT, "trunk_base_freejoint") + self._trunk_qpos_adr = int(model.jnt_qposadr[_trunk_jid]) + _ball_jid = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_JOINT, "ball_free") + if _ball_jid >= 0: + self.ball_qpos_adr = int(model.jnt_qposadr[_ball_jid]) + self.ball_qvel_adr = int(model.jnt_dofadr[_ball_jid]) + else: + self.ball_qpos_adr = None + self.ball_qvel_adr = None + + print(f"Sensors found:") + print(f" imu_ang_vel: id={self.imu_ang_vel_id}") + print(f"Body IDs:") + print(f" trunk_base: id={self.trunk_base_id}") + + # Joint information + self.n_joints = model.nu + + # For robots with passive/interspersed joints (e.g. roller skates), the actuated + # joints are not contiguous in qpos/qvel. Compute the correct indices from the + # actuator transmission joint IDs so extraction works for any joint ordering. + 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) + ] + + # Default pose for the policy (flexed legs) + self.default_pose = DEFAULT_POSE[:self.n_joints] + print(f"Number of actuators: {self.n_joints}") + print(f"Default pose: {self.default_pose}") + print(f"Action scale: {self.action_scale}") + + # Last action (for observation history) + self.last_action = np.zeros(self.n_joints, dtype=np.float32) + + # Velocity command [lin_vel_x, lin_vel_y, ang_vel_z] — controls walking / policy switching + self.vel_cmd = np.zeros(3, dtype=np.float32) + # Key-press step sizes and limits (overridden per mode in main()) + self.vel_step_x = 0.05 + self.vel_step_y = 0.05 + self.vel_step_ang = 0.3 + self.vel_max_x = 0.3 + self.vel_min_x = -0.3 + self.vel_max_y = 0.3 + self.vel_min_y = -0.3 + self.vel_max_ang = 1.5 + # Body pose command. In new_cmd_obs mode this is 6D + # [x, y, z, roll, pitch, yaw] (m, m, m, rad, rad, rad) + # In legacy mode only [z, pitch, roll] (first 3 indices reused as + # [z, pitch, roll] to keep the legacy normalization path working). + self.body_cmd = np.zeros(6 if self.new_cmd_obs else 3, dtype=np.float32) + # Obs command vector (3D in legacy mode, 13D when new_cmd_obs=True). + self.command = np.zeros(13 if self.new_cmd_obs else 3, dtype=np.float32) + + # Body pose mode (like head mode but for standing body pose control) + self.body_pose_mode = False + self.body_cmd_step_xy = 0.005 # 5 mm per keypress (4 to max) + self.body_cmd_step_z = 0.01 # 10 mm per keypress (3 to max) + self.body_cmd_step_angle = math.radians(10) # 10° per keypress (3 to max) + + # Head control mode. In legacy mode head_offset is added on top of + # ctrl[5:9]; in new_cmd_obs mode it's a *command* fed to the policy. + # Final per-joint training caps: neck/head_pitch ±1.1, head_yaw ±1.4, + # head_roll ±0.31. Slider max = widest joint cap; head_roll naturally + # gets clipped by the policy since it was never trained beyond 0.31. + self.head_mode = False + self.head_offset = np.zeros(4, dtype=np.float32) + if self.new_cmd_obs: + self.head_max = 1.4 + self.head_step = 0.1 + else: + self.head_max = 2.5 + self.head_step = 0.83 + + # Action delay buffer + self.use_delay = self.delay_max_lag > 0 + if self.use_delay: + buffer_size = self.delay_max_lag + 1 + self.action_buffer = [np.zeros(self.n_joints, dtype=np.float32) for _ in range(buffer_size)] + self.buffer_index = 0 + self.current_lag = np.random.randint(self.delay_min_lag, self.delay_max_lag + 1) + print(f"\nActuator delay enabled:") + print(f" Min lag: {self.delay_min_lag} timesteps") + print(f" Max lag: {self.delay_max_lag} timesteps") + print(f" Sampled lag: {self.current_lag} timesteps") + print(f" Buffer size: {buffer_size}") + else: + self.action_buffer = None + self.current_lag = 0 + + def _update_command(self): + """Update self.command (fed into obs) based on current policy and commands. + + Legacy mode (new_cmd_obs=False): self.command is 3D. + New mode (new_cmd_obs=True): self.command is 13D: + [vx, vy, vtheta, ← twist + neck_pitch, head_pitch, head_yaw, head_roll, ← head_pose deltas + body_x, body_y, body_z, body_roll, body_pitch, body_yaw] ← body_pose + We keep the existing keyboard mappings: head_offset (4D) drives the head + slots; body_cmd[0..2] currently mean (Δz, Δpitch, Δroll) and are routed + into body_pose slots [z, pitch, roll]; x/y/yaw stay 0 (not exposed on + keyboard yet). ground_pick still owns slots [0..2] for phase encoding. + """ + if self.new_cmd_obs: + if self.behavior_mode is not None: + # Kick/roulade were trained with an all-zero 13D command + # (twist ~0, head/body slots zero-padded) — feeding stale + # head/body commands would be out-of-distribution. + self.command = np.zeros(13, dtype=np.float32) + return + cmd = np.zeros(13, dtype=np.float32) + # twist slot (or phase encoding for ground_pick — overwritten there) + if self.current_policy == "walking": + cmd[0:3] = self.vel_cmd + elif self.current_policy == "sit" and self.is_sitstand: + # Sitstand posture flag: 1 = sit, 0 = stand. NOT zeros — the + # all-zero twist is the STAND command for this policy, which is + # why feeding it the old sit-policy zero command did nothing. + cmd[0] = 1.0 if self.sit_mode else 0.0 + # else standing/old-sit/ground_pick: leave twist 0 (ground_pick + # writes its phase encoding later) + cmd[3:7] = self.head_offset + cmd[7:13] = self.body_cmd # [x, y, z, roll, pitch, yaw] + self.command = cmd + return + + # Legacy 3D command + if self.current_policy == "walking": + self.command = self.vel_cmd.copy() + elif self.current_policy == "sit": + # Sit was trained with a near-zero twist command. + self.command = np.zeros(3, dtype=np.float32) + elif self.current_policy == "standing": + # Normalize body pose cmd to match training's body_pose_cmd_obs + self.command = np.array([ + self.body_cmd[0] / BODY_CMD_MAX_Z, + self.body_cmd[1] / BODY_CMD_MAX_ANGLE, + self.body_cmd[2] / BODY_CMD_MAX_ANGLE, + ], dtype=np.float32) + elif self.current_policy == "slope": + # Passive descent: zero command (like standing coast) + self.command = np.zeros(3, dtype=np.float32) + # ground_pick: command is set directly by update_ground_pick_phase + + def _update_policy_session(self): + """Switch between walking and standing sessions based on vel_cmd magnitude.""" + if not (self.walking_session and self.standing_session): + return # Only one policy loaded, no switching + if self.ground_pick_mode: + return # Don't switch during ground pick + if self.sit_mode: + return # Don't switch while sitting + if self.slope_mode: + return # Don't switch during slope mode + if self.behavior_mode is not None: + return # Don't switch during a kick/roulade + + magnitude = float(np.linalg.norm(self.vel_cmd)) + new_policy = "standing" if magnitude <= self.switch_threshold else "walking" + if new_policy != self.current_policy: + self.current_policy = new_policy + self.ort_session = self.standing_session if new_policy == "standing" else self.walking_session + print(f"Switched to {self.current_policy} policy (vel magnitude: {magnitude:.3f})") + self._update_command() + + def set_vel_cmd(self, lin_vel_x=0.0, lin_vel_y=0.0, ang_vel_z=0.0): + """Set velocity command (used for walking / policy switching).""" + self.vel_cmd = np.array([lin_vel_x, lin_vel_y, ang_vel_z], dtype=np.float32) + self._update_policy_session() + self._update_command() + print(f"Vel cmd: [{lin_vel_x:.2f}, {lin_vel_y:.2f}, {ang_vel_z:.2f}] [{self.current_policy}]") + + def toggle_body_pose_mode(self): + """Toggle body pose control mode on/off.""" + self.body_pose_mode = not self.body_pose_mode + if self.body_pose_mode: + print("Body pose mode: ON") + print(f" UP/DOWN: Δz ±{self.body_cmd_step_z*1000:.0f}mm (max ±{BODY_CMD_MAX_Z*1000:.0f}mm)") + print(f" LEFT/RIGHT: Δpitch ±{math.degrees(self.body_cmd_step_angle):.0f}° (max ±{math.degrees(BODY_CMD_MAX_ANGLE):.0f}°)") + print(f" A/E: Δroll ±{math.degrees(self.body_cmd_step_angle):.0f}° (max ±{math.degrees(BODY_CMD_MAX_ANGLE):.0f}°)") + if self.new_cmd_obs: + print(f" Z/S: Δyaw ±{math.degrees(self.body_cmd_step_angle):.0f}° (max ±{math.degrees(BODY_CMD_MAX_ANGLE):.0f}°)") + print(f" SPACE: reset body pose to zero") + self._print_body_cmd() + else: + print("Body pose mode: OFF") + + def toggle_slope_mode(self): + """Toggle slope policy mode on/off (passive descent, zero twist command).""" + if self.slope_session is None: + print("Slope unavailable: no --slope policy loaded") + return + if self.behavior_mode is not None: + print(f"Cannot toggle slope mode during {self.behavior_mode}") + return + self.slope_mode = not self.slope_mode + if self.slope_mode: + self.ort_session = self.slope_session + self.current_policy = "slope" + self.set_vel_cmd(0.0, 0.0, 0.0) # passive descent: zero command + print("Slope mode: ON (passive descent)") + else: + self.vel_cmd = np.zeros(3, dtype=np.float32) + if self.walking_session: + self.current_policy = "walking" + self.ort_session = self.walking_session + else: + self.current_policy = "standing" + self.ort_session = self.standing_session + self._update_command() + print("Slope mode: OFF") + + def _print_body_cmd(self): + if self.new_cmd_obs: + x, y, z, roll, pitch, yaw = self.body_cmd + print( + f"Body cmd: x={x*1000:5.1f}mm y={y*1000:5.1f}mm z={z*1000:5.1f}mm " + f"roll={math.degrees(roll):5.1f}° pitch={math.degrees(pitch):5.1f}° " + f"yaw={math.degrees(yaw):5.1f}°" + ) + else: + print( + f"Body cmd: z={self.body_cmd[0]*1000:.1f}mm " + f"pitch={math.degrees(self.body_cmd[1]):.1f}° " + f"roll={math.degrees(self.body_cmd[2]):.1f}°" + ) + + # --- body command bumpers (index differs between legacy 3D and new 6D) --- + def _body_idx(self, axis: str) -> int: + """Map an axis name to the body_cmd index, depending on the active mode.""" + if self.new_cmd_obs: + return {"x": 0, "y": 1, "z": 2, "roll": 3, "pitch": 4, "yaw": 5}[axis] + return {"z": 0, "pitch": 1, "roll": 2}[axis] + + def bump_body(self, axis: str, delta: float): + idx = self._body_idx(axis) + cap = BODY_CMD_MAX_Z if axis == "z" else BODY_CMD_MAX_XY if axis in ("x", "y") else BODY_CMD_MAX_ANGLE + self.body_cmd[idx] = float(np.clip(self.body_cmd[idx] + delta, -cap, cap)) + self._update_command() + self._print_body_cmd() + + def quat_rotate_inverse(self, quat, vec): + """Rotate a vector by the inverse of a quaternion [w, x, y, z].""" + w = quat[0] + xyz = quat[1:4] + t = np.cross(xyz, vec) * 2 + return vec - w * t + np.cross(xyz, t) + + def get_raw_accelerometer(self): + """Get raw accelerometer reading from MuJoCo sensor.""" + sensor_id = mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_SENSOR, "imu_accel") + if sensor_id < 0: + raise ValueError("Sensor 'imu_accel' not found in model") + + sensor_adr = self.model.sensor_adr[sensor_id] + accel_raw = self.data.sensordata[sensor_adr:sensor_adr+3].copy().astype(np.float32) + accel_negated = -accel_raw + mag = np.linalg.norm(accel_negated) + if mag > 0.1: + return accel_negated / mag + else: + 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 self.quat_rotate_inverse(quat, world_gravity) + + def get_projected_gravity(self): + """Get projected gravity in body frame.""" + 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 self.quat_rotate_inverse(quat, world_gravity) + + def get_base_ang_vel(self): + """Get base angular velocity from IMU gyro sensor.""" + sensor_adr = self.model.sensor_adr[self.imu_ang_vel_id] + return self.data.sensordata[sensor_adr:sensor_adr + 3].copy().astype(np.float32) + + def get_joint_pos_relative(self): + """Get joint positions relative to default pose.""" + current_pos = self.data.qpos[self.joint_qpos_indices].copy().astype(np.float32) + return current_pos - self.default_pose + + def get_joint_vel(self): + """Get joint velocities.""" + return self.data.qvel[self.joint_qvel_indices].copy().astype(np.float32) + + def get_observations(self): + """Collect observations matching policy input. + + Order for velocity/standing task: + 1. base_ang_vel (3D) + 2. raw_accelerometer OR projected_gravity (3D) + 3. joint_pos (14D) - relative to default + 4. joint_vel (14D) + 5. actions (14D) - last action + 6. command (3D) - vel cmd (walking) or normalized body pose cmd (standing) + Total: 51D + """ + obs = [] + + obs.append(self.get_base_ang_vel()) + + if self.use_projected_gravity: + obs.append(self.get_projected_gravity()) + else: + obs.append(self.get_raw_accelerometer()) + + obs.append(self.get_joint_pos_relative()) + obs.append(self.get_joint_vel()) + obs.append(self.last_action) + obs.append(self.command) + + return np.concatenate(obs).astype(np.float32) + + def trigger_ground_pick(self): + """Start one ground pick cycle. Automatically returns to walking when done.""" + if self.ground_pick_session is None: + print("Ground pick unavailable: no --ground-pick policy loaded") + return + if self.ground_pick_mode: + print("Ground pick already in progress") + return + if self.sit_mode: + print("Cannot ground pick while sitting (press Y to stand up first)") + return + if self.behavior_mode is not None: + print(f"Cannot ground pick during {self.behavior_mode}") + return + self.ground_pick_mode = True + self.ground_pick_phase = 0.0 + self.ort_session = self.ground_pick_session + self.current_policy = "ground_pick" + print(f"Ground pick: started (period={self.ground_pick_period:.1f}s)") + + def _end_ground_pick(self): + """Switch back after a ground pick cycle completes.""" + self.ground_pick_mode = False + self.vel_cmd = np.zeros(3, dtype=np.float32) + if self.walking_session: + self.current_policy = "walking" + self.ort_session = self.walking_session + else: + self.current_policy = "standing" + self.ort_session = self.standing_session + self._update_command() + print(f"Ground pick: done → back to {self.current_policy}") + + def update_ground_pick_phase(self, dt: float): + """Advance the ground pick phase; auto-exit when one full cycle completes.""" + if not self.ground_pick_mode: + return + new_phase = self.ground_pick_phase + dt / self.ground_pick_period + if new_phase >= 0.7: + self._end_ground_pick() + return + self.ground_pick_phase = new_phase + # ground_pick policies use the first 3 slots (twist) as phase encoding. + # Higher slots (head/body) stay at whatever _update_command set them to. + self.command[0] = np.cos(2 * np.pi * self.ground_pick_phase) + self.command[1] = np.sin(2 * np.pi * self.ground_pick_phase) + self.command[2] = 0.0 + + def trigger_behavior(self, name): + """Start an episodic behavior (kick_left / kick_right / roulade). + + The behavior policies were trained to run from a standing start with an + all-zero command and end standing, so triggering is a session swap; a + timer hands control back to walking/standing afterwards. + """ + session = self.behavior_sessions.get(name) + if session is None: + print(f"{name} unavailable: no --{name.replace('_', '-')} policy loaded") + return + if self.behavior_mode is not None: + print(f"Cannot start {name}: {self.behavior_mode} already in progress") + return + if self.ground_pick_mode: + print(f"Cannot start {name} during ground pick") + return + if self.sit_mode: + print(f"Cannot start {name} while sitting (press Y to stand up first)") + return + if self.slope_mode: + print(f"Cannot start {name} during slope mode") + return + if name in ("kick_left", "kick_right"): + self._place_ball(name) + self.behavior_mode = name + self.behavior_time_left = self.behavior_durations[name] + self.vel_cmd = np.zeros(3, dtype=np.float32) + self.current_policy = name + self.ort_session = session + self._update_command() + print(f"{name}: started (auto-return in {self.behavior_time_left:.1f}s)") + + def _place_ball(self, behavior): + """Teleport the ball in front of the kicking foot, matching training's + reset_ball_in_front_of_foot (offset in the robot's yaw frame).""" + if self.ball_qpos_adr is None or self.ball_qvel_adr is None: + print("No ball in scene (kick will swing at air)") + return + adr = self._trunk_qpos_adr + x, y = float(self.data.qpos[adr]), float(self.data.qpos[adr + 1]) + qw, qx, qy, qz = self.data.qpos[adr + 3:adr + 7] + yaw = math.atan2(2.0 * (qw * qz + qx * qy), 1.0 - 2.0 * (qy * qy + qz * qz)) + off_y = -BALL_OFFSET_ABS_Y if behavior == "kick_right" else BALL_OFFSET_ABS_Y + bx = x + math.cos(yaw) * BALL_OFFSET_X - math.sin(yaw) * off_y + by = y + math.sin(yaw) * BALL_OFFSET_X + math.cos(yaw) * off_y + self.data.qpos[self.ball_qpos_adr:self.ball_qpos_adr + 7] = [bx, by, BALL_RADIUS, 1, 0, 0, 0] + self.data.qvel[self.ball_qvel_adr:self.ball_qvel_adr + 6] = 0.0 + foot = behavior.split("_")[1] + print(f"Ball placed at ({bx:.3f}, {by:.3f}) in front of the {foot} foot") + + def update_behavior(self, dt: float): + """Advance the behavior timer; hand back to walking/standing when done.""" + if self.behavior_mode is None: + return + self.behavior_time_left -= dt + if self.behavior_time_left <= 0.0: + self._end_behavior() + + def _end_behavior(self): + name = self.behavior_mode + self.behavior_mode = None + self.vel_cmd = np.zeros(3, dtype=np.float32) + if self.walking_session: + self.current_policy = "walking" + self.ort_session = self.walking_session + elif self.standing_session: + self.current_policy = "standing" + self.ort_session = self.standing_session + else: + # sitstand-only setup: the sitstand policy holds the stand (flag 0). + self.current_policy = "sit" + self.ort_session = self.sit_session + self._update_command() + print(f"{name}: done → back to {self.current_policy}") + + def toggle_sit(self): + """Toggle sitting on/off (Y key). + + Old one-way sit policy (--sit): Y off switches back to the standing/ + walking session, which does the standing back up. + Sitstand policy (--sitstand): Y just flips the posture flag — the SAME + policy sits, holds the sit, and stands back up gently (trained response + to a flag flip is a ~2 s glide). The session stays active after + standing (it holds the stand); a velocity command switches back to + walking/standing as usual. + """ + if self.sit_session is None: + print("Sit unavailable: no --sit/--sitstand policy loaded") + return + if self.ground_pick_mode: + print("Cannot sit during ground pick") + return + if self.behavior_mode is not None: + print(f"Cannot sit during {self.behavior_mode}") + return + self.sit_mode = not self.sit_mode + if self.sit_mode: + self.vel_cmd = np.zeros(3, dtype=np.float32) + self.current_policy = "sit" + self.ort_session = self.sit_session + print("Sit: ON" + (" (sitstand flag=1; Y again to stand up)" if self.is_sitstand else "")) + elif self.is_sitstand: + # Stay on the sitstand session — it stands up itself (flag → 0). + # Do NOT swap to the standing policy here: it would take over + # mid-rise from a seated state it wasn't trained on. + print("Sit: OFF → sitstand policy standing up (flag=0)") + else: + if self.standing_session: + self.current_policy = "standing" + else: + self.current_policy = "walking" + self.ort_session = self.standing_session if self.current_policy == "standing" else self.walking_session + print(f"Sit: OFF → back to {self.current_policy}") + self._update_command() + + def toggle_head_mode(self): + """Toggle head control mode on/off.""" + self.head_mode = not self.head_mode + if self.head_mode: + print("Head mode: ON") + print(f" Z/S: neck_pitch | UP/DOWN: head_pitch | LEFT/RIGHT: head_yaw | A/E: head_roll | SPACE: reset (max ±{self.head_max:.2f} rad)") + else: + print("Head mode: OFF") + + def infer(self): + """Run policy inference and return action.""" + obs = self.get_observations() + obs_batch = obs.reshape(1, -1) + action = self.ort_session.run([self.output_name], {self.input_name: obs_batch})[0] + action = action.squeeze(0).astype(np.float32) + self.last_action = action.copy() + return action + + def apply_action(self, action): + """Apply action to MuJoCo controls with optional delay.""" + if self.use_delay: + self.action_buffer[self.buffer_index] = action.copy() + delayed_index = (self.buffer_index - self.current_lag) % len(self.action_buffer) + delayed_action = self.action_buffer[delayed_index] + self.buffer_index = (self.buffer_index + 1) % len(self.action_buffer) + target_positions = self.default_pose + delayed_action * self.action_scale + else: + target_positions = self.default_pose + action * self.action_scale + + self.data.ctrl[:] = target_positions + # Legacy mode: head_offset is an external perturbation added on top of + # the policy output. New mode: head_offset is a COMMAND fed into the + # policy's obs, so the policy itself produces the offset head pose. + if not self.new_cmd_obs: + self.data.ctrl[5:9] += self.head_offset + + +def main(): + parser = argparse.ArgumentParser(description="Run ONNX policy in MuJoCo") + parser.add_argument("--roller", action="store_true", help="Use roller skate robot XML (robot_walk_rollers.xml)") + parser.add_argument("--walking", type=str, default=None, help="Path to walking policy ONNX file") + parser.add_argument("--standing", "-s", type=str, default=None, help="Path to standing policy ONNX file") + parser.add_argument("--ground-pick", type=str, default=None, help="Path to ground pick policy ONNX file (press G to activate)") + parser.add_argument("--sit", type=str, default=None, help="Path to OLD one-way sitting policy ONNX file (press Y to sit, Y again switches back to standing/walking policy)") + parser.add_argument("--sitstand", type=str, default=None, help="Path to sitstand policy ONNX (commanded sit<->stand; press Y to sit, Y again the SAME policy stands back up). Requires --new-cmd-obs. Can run standalone.") + parser.add_argument("--slope", type=str, default=None, help="Path to slope policy ONNX file (press Y to toggle)") + parser.add_argument("--kick-left", type=str, default=None, help="Path to LEFT-foot ball kick policy ONNX (press K to trigger). Requires --new-cmd-obs. Loads a scene with a ball.") + parser.add_argument("--kick-right", type=str, default=None, help="Path to RIGHT-foot ball kick policy ONNX (press L to trigger). Requires --new-cmd-obs. Loads a scene with a ball.") + parser.add_argument("--roulade", type=str, default=None, help="Path to roulade (forward roll) policy ONNX (press R to trigger). Requires --new-cmd-obs.") + parser.add_argument("--kick-duration", type=float, default=3.0, help="Seconds a kick policy stays active before handing back to standing/walking (default: 3.0)") + parser.add_argument("--roulade-duration", type=float, default=2.0, help="Seconds the roulade policy stays active before handing back to standing/walking (default: 2.0, ~the roll itself; the standing/walking policy takes over for the settle)") + parser.add_argument("--lin-vel-x", type=float, default=0.0, help="Initial linear velocity X command (m/s)") + parser.add_argument("--lin-vel-y", type=float, default=0.0, help="Initial linear velocity Y command (m/s)") + parser.add_argument("--ang-vel-z", type=float, default=0.0, help="Initial angular velocity Z command (rad/s)") + parser.add_argument("--action-scale", type=float, default=1.0, help="Action scale (default: 1.0)") + parser.add_argument("--raw-accelerometer", action="store_true", help="Use raw accelerometer instead of projected gravity") + parser.add_argument("--delay", type=int, nargs='*', default=None, help="Enable actuator delay: --delay MIN MAX or --delay LAG") + parser.add_argument("--debug", action="store_true", help="Print observations and actions") + parser.add_argument("--save-csv", type=str, default=None, help="Save observations and actions to CSV file") + parser.add_argument("--record", type=str, default=None, help="Enable recording mode: save observations to pickle file on Ctrl+C") + parser.add_argument("--switch-threshold", type=float, default=0.05, help="Vel command magnitude threshold for walking/standing switch (default: 0.05)") + parser.add_argument("--ground-pick-period", type=float, default=4.0, help="Ground pick phase period in seconds (default: 4.0)") + parser.add_argument("--new-cmd-obs", action="store_true", + help="Use the unified 13D command obs layout (twist+head_pose+body_pose). " + "Required for policies trained with the new pose-command-tracking setup. " + "Old policies (51D obs, head_offset added to ctrl) need this flag OFF.") + parser.add_argument("--current-limit", type=float, default=1.75, + help="XL330 firmware current limit [A]. Actuator torque is clipped to " + "+/- current_limit * kt (kt from the bam package), matching the " + "current saturation modeled in training. <=0 disables.") + parser.add_argument("--foot-friction", type=float, default=None, + help="Override the foot sliding friction (mu) to emulate the real grippy " + "PU sole. Training used mu~1.0 (range 0.7-1.3); real PU is likely " + "~1.5-2.5. e.g. --foot-friction 2.0") + parser.add_argument("--foot-solref", type=float, default=None, + help="Soften foot contact: solref time constant (s) for the foot geoms " + "(default sim ~0.02 = stiff/rigid). Larger = softer, to emulate the " + "compliant PU sole. e.g. --foot-solref 0.04") + args = parser.parse_args() + + if not args.walking and not args.standing and not args.sitstand: + parser.error("At least one of --walking, --standing or --sitstand must be provided") + if args.sitstand and not args.new_cmd_obs: + parser.error("--sitstand policies use the unified 13D command obs (61D); add --new-cmd-obs") + if (args.kick_left or args.kick_right or args.roulade) and not args.new_cmd_obs: + parser.error("--kick-left/--kick-right/--roulade policies use the unified 13D command obs (61D); add --new-cmd-obs") + if (args.kick_left or args.kick_right or args.roulade) and args.roller: + parser.error("kick/roulade policies are trained on the walking robot, not the roller model") + + # Parse delay arguments + delay_min_lag = 0 + delay_max_lag = 0 + if args.delay is not None: + if len(args.delay) == 0: + delay_min_lag = 1 + delay_max_lag = 2 + elif len(args.delay) == 1: + delay_min_lag = args.delay[0] + delay_max_lag = args.delay[0] + elif len(args.delay) == 2: + delay_min_lag = args.delay[0] + delay_max_lag = args.delay[1] + else: + print("Error: --delay accepts 0, 1, or 2 arguments") + return + + # Load MuJoCo model. Kick policies get a scene with a ball to kick. + if args.roller: + xml_path = MICRODUCK_ROLLERS_XML + elif args.kick_left or args.kick_right: + xml_path = MICRODUCK_BALL_XML + else: + xml_path = MICRODUCK_XML + print(f"Loading MuJoCo model from: {xml_path}") + model = mujoco.MjModel.from_xml_path(xml_path) + model.opt.timestep = 0.005 + data = mujoco.MjData(model) + + # XL330 firmware current limit. The motors saturate current at ~1.75 A; since + # torque = kt * current, this caps the actuator force at +/- kt * I_max. The + # MuJoCo position actuators here are not the BAM voltage model, but clipping + # their output force reproduces the same current saturation the policy was + # trained against (see BamActuator.max_current). kt comes from the bam package. + if args.current_limit and args.current_limit > 0: + from bam.model import load_model + kt = load_model(motor_name="xl330", model="m6").kt.value + torque_limit = kt * args.current_limit + model.actuator_forcerange[:, 0] = -torque_limit + model.actuator_forcerange[:, 1] = torque_limit + model.actuator_forcelimited[:] = 1 + print(f"Current limit: {args.current_limit:.2f} A -> torque limit " + f"+/-{torque_limit:.4f} Nm (kt={kt:.4f})") + + # Foot contact override — emulate the real grippy + soft PU sole to check + # whether it reproduces the on-robot forward-fall-at-speed. Training used + # rigid feet at mu~1.0; the real sole is grippier (higher mu) and compliant + # (softer solref). Applied to the foot collision geoms only. + if args.foot_friction is not None or args.foot_solref is not None: + import re as _re + n_feet = 0 + for g in range(model.ngeom): + gname = mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_GEOM, g) + if gname and _re.match(r"^(left|right)_foot_collision$", gname): + if args.foot_friction is not None: + model.geom_friction[g, 0] = args.foot_friction # tangential mu + if args.foot_solref is not None: + model.geom_solref[g, 0] = args.foot_solref # softer contact + model.geom_solref[g, 1] = 1.0 + n_feet += 1 + print(f"Foot override on {n_feet} geoms: " + f"mu={args.foot_friction if args.foot_friction is not None else 'default'}, " + f"solref={args.foot_solref if args.foot_solref is not None else 'default'}") + + # Initialize policy + policy = PolicyInference( + model, data, + walking_onnx_path=args.walking, + action_scale=args.action_scale, + delay_min_lag=delay_min_lag, + delay_max_lag=delay_max_lag, + standing_onnx_path=args.standing, + switch_threshold=args.switch_threshold, + use_projected_gravity=not args.raw_accelerometer, + ground_pick_onnx_path=args.ground_pick, + ground_pick_period=args.ground_pick_period, + sit_onnx_path=args.sit, + new_cmd_obs=args.new_cmd_obs, + slope_onnx_path=args.slope, + sitstand_onnx_path=args.sitstand, + kick_left_onnx_path=args.kick_left, + kick_right_onnx_path=args.kick_right, + roulade_onnx_path=args.roulade, + kick_duration=args.kick_duration, + roulade_duration=args.roulade_duration, + ) + policy.set_vel_cmd(args.lin_vel_x, args.lin_vel_y, args.ang_vel_z) + + # Set realistic wheel bearing friction for roller inference (must be done + # programmatically — non-zero frictionloss in the XML breaks training) + if args.roller: + import re + for j in range(model.njnt): + name = mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_JOINT, j) + if name and re.match(r"^passive_.*", name): + dof_adr = model.jnt_dofadr[j] + model.dof_frictionloss[dof_adr] = 0.003 + + # Per-mode velocity command limits matching training ranges + if args.roller: + policy.vel_step_x = 0.05 # lin_vel_x step (range -0.5..0.6) + policy.vel_step_y = 0.0 # no lateral command for rollers + policy.vel_step_ang = 0.1 # heading error step (range ±1.0 rad) + policy.vel_max_x = 0.6 + policy.vel_min_x = -0.5 # negative = brake + policy.vel_max_y = 0.0 + policy.vel_min_y = 0.0 + policy.vel_max_ang = 1.0 # ±1.0 rad heading error + else: + policy.vel_max_x = 0.3 + policy.vel_min_x = -0.3 + policy.vel_max_y = 0.2 + policy.vel_min_y = -0.2 + policy.vel_max_ang = 1.5 + + # Set initial position to default pose + freejoint_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_JOINT, "trunk_base_freejoint") + qpos_adr = model.jnt_qposadr[freejoint_id] + data.qpos[qpos_adr + 0] = 0.0 + data.qpos[qpos_adr + 1] = 0.0 + data.qpos[qpos_adr + 2] = 0.1385 if args.roller else 0.125 # rollers add 13.5mm height + data.qpos[qpos_adr + 3:qpos_adr + 7] = [1, 0, 0, 0] + for i, qpos_idx in enumerate(policy.joint_qpos_indices): + data.qpos[qpos_idx] = policy.default_pose[i] + data.ctrl[:] = policy.default_pose + mujoco.mj_forward(model, data) + + # Verify observation size + test_obs = policy.get_observations() + cmd_dim = 13 if policy.new_cmd_obs else 3 + expected_obs_size = 3 + 3 + policy.n_joints + policy.n_joints + policy.n_joints + cmd_dim + breakdown = ( + f"3(ang_vel) + 3(proj_grav) + {policy.n_joints}(joint_pos) + " + f"{policy.n_joints}(joint_vel) + {policy.n_joints}(last_action) + {cmd_dim}(command)" + ) + + if test_obs.size != expected_obs_size: + print(f"\nWARNING: Observation size mismatch!") + print(f" Expected: {expected_obs_size}") + print(f" Got: {test_obs.size}") + print(f" Breakdown: {breakdown}") + print() + + print("\n" + "="*80) + print("MicroDuck Policy Inference") + print("="*80) + print(f"Control frequency: 50 Hz (decimation: 4)") + print(f"Simulation timestep: {model.opt.timestep}s") + print(f"Observation size: {test_obs.size} (expected: {expected_obs_size})") + if policy.walking_session: + print(f"Walking policy: loaded") + if policy.standing_session: + print(f"Standing policy: loaded (body pose: z=±{BODY_CMD_MAX_Z*1000:.0f}mm, pitch/roll=±{math.degrees(BODY_CMD_MAX_ANGLE):.0f}°)") + if policy.walking_session and policy.standing_session: + print(f" Switch threshold: {policy.switch_threshold} (vel cmd magnitude)") + if policy.ground_pick_session: + print(f"Ground pick policy: loaded (press G)") + if policy.sit_session: + kind = "Sitstand" if policy.is_sitstand else "Sit" + print(f"{kind} policy: loaded (press Y to toggle)") + if policy.slope_session: + print(f"Slope policy: loaded (press Y to toggle, passive descent)") + _behavior_keys = {"kick_left": "K", "kick_right": "L", "roulade": "R"} + for _name in policy.behavior_sessions: + print(f"{_name} policy: loaded (press {_behavior_keys[_name]}, " + f"auto-return after {policy.behavior_durations[_name]:.1f}s)") + print(f"Active policy: {policy.current_policy}") + print("Close viewer window to exit") + print() + + decimation = 4 + control_step_count = 0 + control_dt = decimation * model.opt.timestep + + # Rolling buffer of trunk world-frame xy velocity over the last 1 s, used + # to print a running average so we can compare commanded vs achieved speed. + from collections import deque + _vel_window_steps = max(1, int(round(1.0 / control_dt))) # ≈ 50 @ 50 Hz + vel_history = deque(maxlen=_vel_window_steps) + + csv_data = [] if args.save_csv else None + recorded_observations = [] if args.record else None + policy_enabled = not args.record + policy_enable_time = None + original_kp = None + if args.record: + original_kp = model.actuator_gainprm[:, 0].copy() + + # Cache the trunk freejoint qvel address so the push handler can write to + # the trunk's world-frame linear velocity directly (qvel[0..3]). + _freejoint_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_JOINT, "trunk_base_freejoint") + _trunk_qvel_adr = int(model.jnt_dofadr[_freejoint_id]) + PUSH_MAX = 1.0 # matches the final velstand push_magnitude curriculum cap + + def random_push(): + """Set the trunk's world-frame xy velocity to a random vector of + magnitude PUSH_MAX, simulating the push_by_setting_velocity training + event. Doesn't accumulate — overwrites current linear velocity.""" + import random + angle = random.uniform(0, 2 * np.pi) + vx = PUSH_MAX * np.cos(angle) + vy = PUSH_MAX * np.sin(angle) + data.qvel[_trunk_qvel_adr + 0] = vx + data.qvel[_trunk_qvel_adr + 1] = vy + print(f"PUSH applied: v=[{vx:.2f}, {vy:.2f}, 0] m/s (angle={np.degrees(angle):.0f}°)") + + # Keys come from the TERMINAL (raw stdin, see TerminalInput) — not from the + # MuJoCo viewer window, whose keypresses also fire built-in visualization + # shortcuts. `key` is a symbolic name: "up"/"down"/"left"/"right", " ", or + # a lowercase letter. + quit_requested = False + + def handle_key(key): + nonlocal policy_enabled, quit_requested + try: + if key == "up": + if policy.head_mode: + policy.head_offset[1] = np.clip(policy.head_offset[1] + policy.head_step, -policy.head_max, policy.head_max) + policy._update_command() + print(f"Head offset: neck={policy.head_offset[0]:.2f} pitch={policy.head_offset[1]:.2f} yaw={policy.head_offset[2]:.2f} roll={policy.head_offset[3]:.2f}") + elif policy.body_pose_mode: + policy.bump_body("z", policy.body_cmd_step_z) + else: + policy.set_vel_cmd(policy.vel_max_x, policy.vel_cmd[1], policy.vel_cmd[2]) + elif key == "down": + if policy.head_mode: + policy.head_offset[1] = np.clip(policy.head_offset[1] - policy.head_step, -policy.head_max, policy.head_max) + policy._update_command() + print(f"Head offset: neck={policy.head_offset[0]:.2f} pitch={policy.head_offset[1]:.2f} yaw={policy.head_offset[2]:.2f} roll={policy.head_offset[3]:.2f}") + elif policy.body_pose_mode: + policy.bump_body("z", -policy.body_cmd_step_z) + else: + policy.set_vel_cmd(policy.vel_min_x, policy.vel_cmd[1], policy.vel_cmd[2]) + elif key == "right": + if policy.head_mode: + policy.head_offset[2] = np.clip(policy.head_offset[2] - policy.head_step, -policy.head_max, policy.head_max) + policy._update_command() + print(f"Head offset: neck={policy.head_offset[0]:.2f} pitch={policy.head_offset[1]:.2f} yaw={policy.head_offset[2]:.2f} roll={policy.head_offset[3]:.2f}") + elif policy.body_pose_mode: + policy.bump_body("pitch", -policy.body_cmd_step_angle) + elif args.roller: + new_ang = np.clip(policy.vel_cmd[2] - policy.vel_step_ang, -policy.vel_max_ang, policy.vel_max_ang) + policy.set_vel_cmd(policy.vel_cmd[0], policy.vel_cmd[1], new_ang) + else: + policy.set_vel_cmd(policy.vel_cmd[0], policy.vel_min_y, policy.vel_cmd[2]) + elif key == "left": + if policy.head_mode: + policy.head_offset[2] = np.clip(policy.head_offset[2] + policy.head_step, -policy.head_max, policy.head_max) + policy._update_command() + print(f"Head offset: neck={policy.head_offset[0]:.2f} pitch={policy.head_offset[1]:.2f} yaw={policy.head_offset[2]:.2f} roll={policy.head_offset[3]:.2f}") + elif policy.body_pose_mode: + policy.bump_body("pitch", policy.body_cmd_step_angle) + elif args.roller: + new_ang = np.clip(policy.vel_cmd[2] + policy.vel_step_ang, -policy.vel_max_ang, policy.vel_max_ang) + policy.set_vel_cmd(policy.vel_cmd[0], policy.vel_cmd[1], new_ang) + else: + policy.set_vel_cmd(policy.vel_cmd[0], policy.vel_max_y, policy.vel_cmd[2]) + elif key == " ": + if policy.head_mode: + policy.head_offset[:] = 0.0 + policy._update_command() + print("Head offset reset to zero") + elif policy.body_pose_mode: + policy.body_cmd[:] = 0.0 + policy._update_command() + print("Body pose cmd reset to zero") + else: + policy.set_vel_cmd(0.0, 0.0, 0.0) + elif key == "t": + # Toggle policy inference on/off. When OFF the controller stops + # querying the ONNX policy and the motors hold the last applied + # target (no fresh ctrl writes). + policy_enabled = not policy_enabled + print(f"Policy inference: {'ON' if policy_enabled else 'OFF (paused)'}") + elif key == "g": + policy.trigger_ground_pick() + elif key == "k": + policy.trigger_behavior("kick_left") + elif key == "l": + policy.trigger_behavior("kick_right") + elif key == "r": + policy.trigger_behavior("roulade") + elif key == "q": + quit_requested = True + print("Quit requested") + elif key == "y": + # Y toggles whichever aux policy is loaded (--sit or --slope). + if policy.sit_session is not None: + policy.toggle_sit() + else: + policy.toggle_slope_mode() + elif key == "h": + policy.toggle_head_mode() + elif key == "b": + policy.toggle_body_pose_mode() + elif key == "p": + random_push() + elif key == "a": + if policy.head_mode: + policy.head_offset[3] = np.clip(policy.head_offset[3] + policy.head_step, -policy.head_max, policy.head_max) + policy._update_command() + print(f"Head offset: neck={policy.head_offset[0]:.2f} pitch={policy.head_offset[1]:.2f} yaw={policy.head_offset[2]:.2f} roll={policy.head_offset[3]:.2f}") + elif policy.body_pose_mode: + policy.bump_body("roll", policy.body_cmd_step_angle) + else: + policy.set_vel_cmd(policy.vel_cmd[0], policy.vel_cmd[1], policy.vel_max_ang) + elif key == "e": + if policy.head_mode: + policy.head_offset[3] = np.clip(policy.head_offset[3] - policy.head_step, -policy.head_max, policy.head_max) + policy._update_command() + print(f"Head offset: neck={policy.head_offset[0]:.2f} pitch={policy.head_offset[1]:.2f} yaw={policy.head_offset[2]:.2f} roll={policy.head_offset[3]:.2f}") + elif policy.body_pose_mode: + policy.bump_body("roll", -policy.body_cmd_step_angle) + else: + policy.set_vel_cmd(policy.vel_cmd[0], policy.vel_cmd[1], -policy.vel_max_ang) + elif key == "z": + if policy.head_mode: + policy.head_offset[0] = np.clip(policy.head_offset[0] + policy.head_step, -policy.head_max, policy.head_max) + policy._update_command() + print(f"Head offset: neck={policy.head_offset[0]:.2f} pitch={policy.head_offset[1]:.2f} yaw={policy.head_offset[2]:.2f} roll={policy.head_offset[3]:.2f}") + elif policy.body_pose_mode and policy.new_cmd_obs: + policy.bump_body("yaw", policy.body_cmd_step_angle) + elif key == "s": + if policy.head_mode: + policy.head_offset[0] = np.clip(policy.head_offset[0] - policy.head_step, -policy.head_max, policy.head_max) + policy._update_command() + print(f"Head offset: neck={policy.head_offset[0]:.2f} pitch={policy.head_offset[1]:.2f} yaw={policy.head_offset[2]:.2f} roll={policy.head_offset[3]:.2f}") + elif policy.body_pose_mode and policy.new_cmd_obs: + policy.bump_body("yaw", -policy.body_cmd_step_angle) + except Exception as e: + print(f"Key press error: {e}") + + print("\nKeyboard controls (type in THIS terminal — the viewer window no longer captures keys):") + print(" [ Velocity mode (default) ]") + print(" UP arrow: increase lin_vel_x (push/accelerate)") + print(" DOWN arrow: decrease lin_vel_x (0=coast, negative=brake)") + if args.roller: + print(" LEFT/RIGHT arrow: turn left/right (ang_vel_z heading error)") + print(" A / E: turn left/right (ang_vel_z, incremental)") + else: + print(" LEFT/RIGHT arrow: strafe left/right (lin_vel_y)") + print(" A / E: turn left/right (ang_vel_z)") + print(" SPACE: coast (zero all commands)") + print(" T: toggle policy inference on/off (paused = motors hold last target)") + print(" G: trigger ground pick (requires --ground-pick)") + print(" Y: toggle sit (with --sit/--sitstand) or slope mode (with --slope)") + print(" K: kick with LEFT foot (requires --kick-left)") + print(" L: kick with RIGHT foot (requires --kick-right)") + print(" R: roulade / forward roll (requires --roulade)") + print(f" P: random push (trunk vel = {PUSH_MAX:.1f} m/s in random direction)") + print(" Q: quit") + print(" [ Body pose mode — press B to toggle ]") + print(f" UP/DOWN arrow: Δz ±10mm (max ±{BODY_CMD_MAX_Z*1000:.0f}mm)") + print(f" LEFT/RIGHT arrow: Δpitch ±10° (max ±{math.degrees(BODY_CMD_MAX_ANGLE):.0f}°)") + print(f" A / E: Δroll ±10° (max ±{math.degrees(BODY_CMD_MAX_ANGLE):.0f}°)") + if args.new_cmd_obs: + print(f" Z / S: Δyaw ±10° (new_cmd_obs only, max ±{math.degrees(BODY_CMD_MAX_ANGLE):.0f}°)") + print(" SPACE: reset body pose to zero") + print(" [ Head mode — press H to toggle ]") + print(" Z / S: neck_pitch ±step") + print(" UP/DOWN arrow: head_pitch ±step") + print(" LEFT/RIGHT arrow: head_yaw ±step") + print(" A / E: head_roll ±step") + print(" SPACE: reset head offset to zero") + + with TerminalInput() as term, \ + mujoco.viewer.launch_passive(model, data, show_left_ui=False, show_right_ui=False) as viewer: + viewer.sync() + start_time = time.time() + + if args.record: + policy_enable_time = start_time + 1.0 + print("Recording mode: policy will be enabled after 1 second standby") + for i in range(model.nu): + model.actuator_gainprm[i, 0] = 2.0 + model.actuator_biasprm[i, 1] = -2.0 + print(" Standby mode: kp set to 2.0") + + try: + prev_step_time = time.time() + + while viewer.is_running() and not quit_requested: + step_start = time.time() + + for key in term.get_keys(): + handle_key(key) + + if not policy_enabled and policy_enable_time is not None: + if step_start >= policy_enable_time: + policy_enabled = True + if original_kp is not None: + for i in range(model.nu): + kp = original_kp[i] + model.actuator_gainprm[i, 0] = kp + model.actuator_biasprm[i, 1] = -kp + print("Policy inference enabled (after 1s standby)") + print(f" Restored original kp gains (range: [{original_kp.min():.2f}, {original_kp.max():.2f}])") + + actual_dt = step_start - prev_step_time + prev_step_time = step_start + + policy.update_ground_pick_phase(actual_dt) + policy.update_behavior(actual_dt) + + if policy_enabled: + action = policy.infer() + policy.apply_action(action) + else: + # Paused: keep last ctrl, don't query the policy. Motors + # hold position. Use a zero action just so downstream + # logging (csv/debug) sees something consistent. + action = np.zeros(policy.n_joints, dtype=np.float32) + + control_step_count += 1 + + # Track BODY-frame forward/lateral velocity + yaw rate, print the + # 1-second moving average once per second vs the commanded values. + # Body frame so "forward" / "turn" are directly comparable to the + # command (which is in the robot frame): lets us see if the policy + # actually achieves commanded forward speed and turn rate. + quat = data.qpos[qpos_adr + 3:qpos_adr + 7].astype(np.float32) + v_world = np.array([ + data.qvel[_trunk_qvel_adr + 0], + data.qvel[_trunk_qvel_adr + 1], + data.qvel[_trunk_qvel_adr + 2], + ], dtype=np.float32) + v_body = policy.quat_rotate_inverse(quat, v_world) + yaw_rate = float(data.qvel[_trunk_qvel_adr + 5]) # body-frame wz + vel_history.append((float(v_body[0]), float(v_body[1]), yaw_rate)) + if control_step_count % _vel_window_steps == 0 and len(vel_history) > 0: + n = len(vel_history) + avg_fwd = sum(v[0] for v in vel_history) / n + avg_lat = sum(v[1] for v in vel_history) / n + avg_yaw = sum(v[2] for v in vel_history) / n + cmd_x, cmd_y, cmd_yaw = policy.vel_cmd[0], policy.vel_cmd[1], policy.vel_cmd[2] + trunk_z = float(data.qpos[qpos_adr + 2]) + print( + f"[vel 1s avg] achieved/cmd fwd={avg_fwd:+.2f}/{cmd_x:+.2f} " + f"lat={avg_lat:+.2f}/{cmd_y:+.2f} m/s " + f"yaw={avg_yaw:+.2f}/{cmd_yaw:+.2f} rad/s " + f"trunk_z={trunk_z*1000:.1f} mm" + ) + + if csv_data is not None: + obs = policy.get_observations() + row = {'step': control_step_count, 'time': control_step_count * control_dt} + for i in range(obs.size): + row[f'obs_{i}'] = obs[i] + for i in range(action.size): + row[f'action_{i}'] = action[i] + csv_data.append(row) + + if recorded_observations is not None: + obs = policy.get_observations() + timestamp = time.time() - start_time + recorded_observations.append({'timestamp': timestamp, 'observation': obs.tolist()}) + + if args.debug: + should_print = control_step_count <= 10 or control_step_count % 50 == 0 + if should_print: + obs = policy.get_observations() + pos = data.qpos[qpos_adr:qpos_adr + 3] + quat = data.qpos[qpos_adr + 3:qpos_adr + 7] + com_height = pos[2] + + print(f"\n{'='*70}") + print(f"Step {control_step_count} DEBUG:") + print(f"{'='*70}") + print(f"Active policy: {policy.current_policy}") + print(f"Base state:") + print(f" Position: [{pos[0]:7.4f}, {pos[1]:7.4f}, {pos[2]:7.4f}]") + print(f" CoM height: {com_height:7.4f}") + print(f" Quaternion: [{quat[0]:7.4f}, {quat[1]:7.4f}, {quat[2]:7.4f}, {quat[3]:7.4f}]") + print(f"\nObservation (shape {obs.shape}, total {obs.size}):") + print(f" Ang vel [0:3]: {obs[0:3]}") + print(f" Proj grav [3:6]: {obs[3:6]}") + print(f" Joint pos [6:{6+policy.n_joints}]: {obs[6:6+policy.n_joints]}") + print(f" Joint vel [{6+policy.n_joints}:{6+2*policy.n_joints}]: {obs[6+policy.n_joints:6+2*policy.n_joints]}") + print(f" Last action [{6+2*policy.n_joints}:{6+3*policy.n_joints}]: {obs[6+2*policy.n_joints:6+3*policy.n_joints]}") + cmd_end = 6+3*policy.n_joints+3 + print(f" Command [{6+3*policy.n_joints}:{cmd_end}]: {obs[6+3*policy.n_joints:cmd_end]}") + if policy.current_policy == "standing": + print(f" Body cmd (raw): z={policy.body_cmd[0]*1000:.1f}mm pitch={math.degrees(policy.body_cmd[1]):.1f}° roll={math.degrees(policy.body_cmd[2]):.1f}°") + print(f"\nAction output:") + print(f" Raw action: {action}") + print(f" Action min/max: [{action.min():.4f}, {action.max():.4f}]") + if policy.use_delay: + print(f" Delay: {policy.current_lag} timesteps (buffered)") + print(f" Applied ctrl (first 5): {data.ctrl[:5]}") + print(f" Applied ctrl (last 5): {data.ctrl[-5:]}") + + for _ in range(decimation): + mujoco.mj_step(model, data) + + viewer.sync() + + elapsed = time.time() - step_start + sleep_time = control_dt - elapsed + if sleep_time > 0: + time.sleep(sleep_time) + + except KeyboardInterrupt: + print("\n\nKeyboardInterrupt received (Ctrl+C). Saving data...") + + print("\nInference stopped.") + + if csv_data is not None and len(csv_data) > 0: + print(f"\nSaving {len(csv_data)} steps to: {args.save_csv}") + with open(args.save_csv, 'w', newline='') as csvfile: + fieldnames = csv_data[0].keys() + writer = csv.DictWriter(csvfile, fieldnames=fieldnames) + writer.writeheader() + writer.writerows(csv_data) + print(f"CSV file saved successfully!") + print(f" Columns: {len(fieldnames)}") + print(f" Rows: {len(csv_data)}") + + if recorded_observations is not None and len(recorded_observations) > 0: + print(f"\nSaving {len(recorded_observations)} recorded observations to: {args.record}") + with open(args.record, 'wb') as f: + pickle.dump(recorded_observations, f) + print(f"Recorded observations saved to {args.record}") + print(f" Observations: {len(recorded_observations)}") + print(f" Duration: {recorded_observations[-1]['timestamp']:.2f}s") + + +if __name__ == "__main__": + main() diff --git a/scripts/play_latest.py b/scripts/play_latest.py new file mode 100644 index 0000000..841ff1d --- /dev/null +++ b/scripts/play_latest.py @@ -0,0 +1,57 @@ +"""Find the latest wandb run for a given user and launch `uv run play`. + +Par défaut : le tout dernier run (toutes tâches confondues). +Avec un flag de type, le dernier run de CE type seulement : + md-play --crouch # dernier Mjlab-RollerCrouch-... + md-play --roller # dernier Mjlab-...-Rollers + md-play --swizzle # dernier Mjlab-...-Swizzle-... + md-play --slope # dernier Mjlab-RollerSlope-... +Les arguments inconnus sont transmis tels quels à `uv run play` +(ex: md-play --crouch --action-scale 0.8). +""" + +import argparse + +from wandb_utils import resolve_run, run_command + +# flag -> sous-chaîne recherchée dans le task_id (metadata args[0]) +TYPE_SUBSTR = { + "crouch": "Crouch", # Mjlab-RollerCrouch-Flat-MicroDuck + "roller": "MicroDuck-Rollers", # Mjlab-Velocity-Flat-MicroDuck-Rollers (≠ RollerSlope/RollerCrouch) + "swizzle": "Swizzle", # Mjlab-Velocity-Swizzle-MicroDuck + "slope": "Slope", # Mjlab-RollerSlope-Flat-MicroDuck +} + + +def main(): + parser = argparse.ArgumentParser(description="Play latest wandb run for a user") + parser.add_argument( + "--user", default="coralie", + help="Filter runs by user (matched against email, default: coralie)", + ) + parser.add_argument( + "--dry-run", action="store_true", + help="Print the play command without executing it", + ) + for t in TYPE_SUBSTR: + parser.add_argument( + f"--{t}", dest="type", action="store_const", const=t, + help=f"latest '{t}' run only", + ) + # unknown args (e.g. --action-scale 0.8) are forwarded to `uv run play` + args, extra = parser.parse_known_args() + + task_substr = TYPE_SUBSTR[args.type] if getattr(args, "type", None) else None + _, info = resolve_run(args.user, task_substr) + + cmd = [ + "uv", "run", "play", + info["env_name"], + "--wandb-run-path", info["run_path"], + *extra, + ] + run_command(cmd, args.dry_run) + + +if __name__ == "__main__": + main() diff --git a/scripts/plot_observations_comparison_plotly.py b/scripts/plot_observations_comparison_plotly.py new file mode 100644 index 0000000..d3cb4b0 --- /dev/null +++ b/scripts/plot_observations_comparison_plotly.py @@ -0,0 +1,306 @@ +#!/usr/bin/env python3 +""" +Plot comparison between real and simulated observations using Plotly. +""" + +import argparse +import pickle +import numpy as np +import plotly.graph_objects as go +from plotly.subplots import make_subplots +from pathlib import Path + + +def load_observations(pkl_path: str): + """Load observations from pickle file.""" + with open(pkl_path, 'rb') as f: + data = pickle.load(f) + + if isinstance(data, dict): + if 'observations' in data and 'timestamps' in data: + observations = data['observations'] + timestamps = data['timestamps'] + else: + raise ValueError("Dictionary must contain 'observations' and 'timestamps' keys") + elif isinstance(data, list): + if len(data) == 0: + raise ValueError("Empty data list") + + if isinstance(data[0], dict) and 'timestamp' in data[0] and 'observation' in data[0]: + timestamps = [item['timestamp'] for item in data] + observations = [item['observation'] for item in data] + elif isinstance(data[0], tuple): + timestamps = [item[0] for item in data] + observations = [item[1] for item in data] + else: + observations = data + timestamps = [i * 0.02 for i in range(len(observations))] + else: + raise ValueError(f"Unsupported data format: {type(data)}") + + return np.array(observations), np.array(timestamps) + + +def plot_comparison(real_obs, real_ts, sim_obs=None, sim_ts=None): + """ + Plot comparison between real and simulated observations using Plotly. + If sim_obs is None, only plots real data. + """ + + # Joint names + joint_names = [ + 'L_hip_yaw', 'L_hip_roll', 'L_hip_pitch', 'L_knee', 'L_ankle', + 'neck_pitch', 'head_pitch', 'head_yaw', 'head_roll', + 'R_hip_yaw', 'R_hip_roll', 'R_hip_pitch', 'R_knee', 'R_ankle' + ] + + obs_dim = real_obs.shape[1] if sim_obs is None else min(real_obs.shape[1], sim_obs.shape[1]) + + # Velocity (51D): ang_vel (3) + proj_grav (3) + joint_pos (14) + joint_vel (14) + actions (14) + command (3) + base_ang_vel_start = 0 + gravity_start = 3 + joint_pos_start = 6 + joint_vel_start = 20 + action_start = 34 + + # Create subplot titles with sections + subplot_titles = [] + + # Base angular velocity (3) + subplot_titles.extend(['BASE ANG VEL
ω_x', 'ω_y', 'ω_z', '']) + + # Raw accelero (3) + subplot_titles.extend(['Raw Accelero
g_x', 'g_y', 'g_z', '']) + + # Joint positions (14 + 2 empty) + subplot_titles.append(f'JOINT POSITIONS
{joint_names[0]}') + subplot_titles.extend(joint_names[1:14]) + subplot_titles.extend(['', '']) + + # Joint velocities (14 + 2 empty) + subplot_titles.append(f'JOINT VELOCITIES
{joint_names[0]}') + subplot_titles.extend(joint_names[1:14]) + subplot_titles.extend(['', '']) + + # Actions (14 + 2 empty) + subplot_titles.append(f'ACTIONS
{joint_names[0]}') + subplot_titles.extend(joint_names[1:14]) + subplot_titles.extend(['', '']) + + num_rows = 14 + fig = make_subplots( + rows=num_rows, cols=4, + subplot_titles=subplot_titles, + vertical_spacing=0.02, + horizontal_spacing=0.05, + row_heights=[1]*num_rows, + ) + + plot_idx = 0 + + # Track data for common scaling + command_data = [] + + def add_traces(row, col, real_data, sim_data=None, y_range=None): + """Helper to add real and sim traces to a subplot.""" + fig.add_trace( + go.Scatter(x=real_ts, y=real_data, name='Real', + line=dict(color='blue', width=1.5), + showlegend=(plot_idx == 0)), + row=row, col=col + ) + if sim_data is not None: + fig.add_trace( + go.Scatter(x=sim_ts, y=sim_data, name='Sim', + line=dict(color='red', width=1.5, dash='dash'), + showlegend=(plot_idx == 0)), + row=row, col=col + ) + if y_range: + fig.update_yaxes(range=y_range, row=row, col=col) + + base_ang_vel_data = [] + gravity_data = [] + joint_pos_data = [] + joint_vel_data = [] + action_data = [] + + # 1. Base angular velocity (3 subplots) + for i in range(3): + row, col = divmod(plot_idx, 4) + row += 1 + col += 1 + base_ang_vel_data.append(real_obs[:, base_ang_vel_start+i]) + if sim_obs is not None: + base_ang_vel_data.append(sim_obs[:, base_ang_vel_start+i]) + add_traces(row, col, real_obs[:, base_ang_vel_start+i], None if sim_obs is None else sim_obs[:, base_ang_vel_start+i]) + fig.update_yaxes(title_text='rad/s', row=row, col=col) + plot_idx += 1 + + # Empty slot + plot_idx += 1 + + # 2. Raw accelero (3 subplots) + for i in range(3): + row, col = divmod(plot_idx, 4) + row += 1 + col += 1 + gravity_data.append(real_obs[:, gravity_start+i]) + if sim_obs is not None: + gravity_data.append(sim_obs[:, gravity_start+i]) + add_traces(row, col, real_obs[:, gravity_start+i], None if sim_obs is None else sim_obs[:, gravity_start+i]) + fig.update_yaxes(title_text='g', row=row, col=col) + plot_idx += 1 + + # Empty slot + plot_idx += 1 + + # 3. Joint positions (14 subplots) + for i in range(14): + row, col = divmod(plot_idx, 4) + row += 1 + col += 1 + if joint_pos_start + i < obs_dim: + joint_pos_data.append(real_obs[:, joint_pos_start+i]) + if sim_obs is not None: + joint_pos_data.append(sim_obs[:, joint_pos_start+i]) + add_traces(row, col, real_obs[:, joint_pos_start+i], None if sim_obs is None else sim_obs[:, joint_pos_start+i]) + fig.update_yaxes(title_text='rad', row=row, col=col) + plot_idx += 1 + + # Skip 2 empty slots + plot_idx += 2 + + # 4. Joint velocities (14 subplots) + for i in range(14): + row, col = divmod(plot_idx, 4) + row += 1 + col += 1 + if joint_vel_start + i < obs_dim: + joint_vel_data.append(real_obs[:, joint_vel_start+i]) + if sim_obs is not None: + joint_vel_data.append(sim_obs[:, joint_vel_start+i]) + add_traces(row, col, real_obs[:, joint_vel_start+i], None if sim_obs is None else sim_obs[:, joint_vel_start+i]) + fig.update_yaxes(title_text='rad/s', row=row, col=col) + plot_idx += 1 + + # Skip 2 empty slots + plot_idx += 2 + + # 5. Actions (14 subplots) + for i in range(14): + row, col = divmod(plot_idx, 4) + row += 1 + col += 1 + if action_start + i < obs_dim: + action_data.append(real_obs[:, action_start+i]) + if sim_obs is not None: + action_data.append(sim_obs[:, action_start+i]) + add_traces(row, col, real_obs[:, action_start+i], None if sim_obs is None else sim_obs[:, action_start+i]) + fig.update_yaxes(title_text='action', row=row, col=col) + fig.update_xaxes(title_text='Time (s)', row=row, col=col) + plot_idx += 1 + + # Set common y-ranges for each group + def compute_range(data_list): + if not data_list: + return None + all_data = np.concatenate([d.flatten() for d in data_list]) + y_min, y_max = np.min(all_data), np.max(all_data) + margin = (y_max - y_min) * 0.1 + return [y_min - margin, y_max + margin] + + base_ang_vel_range = compute_range(base_ang_vel_data) + gravity_range = compute_range(gravity_data) + joint_pos_range = compute_range(joint_pos_data) + joint_vel_range = compute_range(joint_vel_data) + action_range = compute_range(action_data) + + # Apply common ranges + plot_idx = 0 + + for i in range(3): # Base ang vel + row, col = divmod(plot_idx, 4) + fig.update_yaxes(range=base_ang_vel_range, row=row+1, col=col+1) + plot_idx += 1 + plot_idx += 1 + + for i in range(3): # Gravity + row, col = divmod(plot_idx, 4) + fig.update_yaxes(range=gravity_range, row=row+1, col=col+1) + plot_idx += 1 + plot_idx += 1 + + for i in range(14): # Joint pos + row, col = divmod(plot_idx, 4) + fig.update_yaxes(range=joint_pos_range, row=row+1, col=col+1) + plot_idx += 1 + plot_idx += 2 + + for i in range(14): # Joint vel + row, col = divmod(plot_idx, 4) + fig.update_yaxes(range=joint_vel_range, row=row+1, col=col+1) + plot_idx += 1 + plot_idx += 2 + + for i in range(14): # Actions + row, col = divmod(plot_idx, 4) + fig.update_yaxes(range=action_range, row=row+1, col=col+1) + plot_idx += 1 + + # Update layout + title = 'Real vs Simulated Observations Comparison' if sim_obs is not None else 'Real Robot Observations' + + fig.update_layout( + title_text=title, + title_font_size=24, + height=4600, + width=1600, + showlegend=True, + legend=dict(x=0.85, y=0.99, bgcolor='rgba(255,255,255,0.8)'), + hovermode='x unified' + ) + + fig.show() + + +def main(): + parser = argparse.ArgumentParser( + description="Compare real and simulated observations (Plotly version)" + ) + parser.add_argument("real_pkl", type=str, + help="Path to .pkl file with real robot observations") + parser.add_argument("sim_pkl", type=str, nargs='?', default=None, + help="Path to .pkl file with simulated observations (optional)") + args = parser.parse_args() + + # Check if files exist + if not Path(args.real_pkl).exists(): + print(f"Error: {args.real_pkl} not found") + return 1 + + # Load observations + print(f"Loading real observations from {args.real_pkl}...") + real_obs, real_ts = load_observations(args.real_pkl) + print(f"Loaded {len(real_obs)} real observations (shape: {real_obs.shape})") + + if args.sim_pkl: + if not Path(args.sim_pkl).exists(): + print(f"Error: {args.sim_pkl} not found") + return 1 + print(f"Loading simulated observations from {args.sim_pkl}...") + sim_obs, sim_ts = load_observations(args.sim_pkl) + print(f"Loaded {len(sim_obs)} simulated observations (shape: {sim_obs.shape})") + else: + print("No sim data provided, plotting real data only") + sim_obs, sim_ts = None, None + + # Plot comparison + print(f"\nGenerating interactive comparison plots...") + plot_comparison(real_obs, real_ts, sim_obs, sim_ts) + + return 0 + + +if __name__ == "__main__": + exit(main()) diff --git a/scripts/testbench_sim2real.py b/scripts/testbench_sim2real.py new file mode 100644 index 0000000..1ba3d58 --- /dev/null +++ b/scripts/testbench_sim2real.py @@ -0,0 +1,611 @@ +#!/usr/bin/env python3 +"""Sim2real validation for the XL330 test bench. + +Runs the same ONNX policy on a fixed sequence of target angles, either in +MuJoCo (with the BAM M6 actuator model) or on the real XL330 (via rustypot), +logs the joint trajectory, and plots sim vs real for comparison. + +Example workflow +---------------- + # 1) Record in sim: + uv run python scripts/testbench_sim2real.py --mode sim --onnx policy.onnx --out sim.npz + # 2) Plug the real bench via USB, then record on hardware: + uv run python scripts/testbench_sim2real.py --mode real --onnx policy.onnx --out real.npz \ + --port /dev/ttyUSB0 --motor-id 1 + # 3) Compare the two traces: + uv run python scripts/testbench_sim2real.py --compare sim.npz real.npz --out-plot comparison.png + +Observation layout (must match the training env): [joint_pos, joint_vel, last_action, command] +Action: 1-D position offset in radians, scaled by 1.0, added to default pose (0.0). +""" + +from __future__ import annotations + +import argparse +import math +import time +from pathlib import Path + +import numpy as np +import onnxruntime as ort +from mjlab_microduck.robot.testbench_constants import ( + TESTBENCH_ARM_MASS, + TESTBENCH_XML, + _set_arm_mass, +) + + +# --- Match training env --- +CONTROL_DT = 0.02 # decimation=4 × timestep=0.005 (policy rate = 50 Hz) +SIM_DT = 0.005 # (logging rate = 200 Hz — one sample per inner sim step) +LOG_DT = SIM_DT +DEFAULT_POS = 0.0 +MAX_ANGLE = math.radians(80.0) + +# XL330 present_velocity is returned by rustypot as raw ticks (i32, NOT converted). +# Each tick = 0.229 RPM (per Dynamixel XL330 spec). rad/s = ticks * 0.229 * 2π/60. +DXL_VEL_TICK_TO_RAD_S = 0.229 * 2.0 * math.pi / 60.0 # ≈ 0.02398 rad/s per tick + + +# --------------------------------------------------------------------------- +# Shared: target schedule + policy wrapper +# --------------------------------------------------------------------------- + + +def make_target_schedule( + total_time: float, + hold_time: float = 4.0, + seed: int = 0, +) -> np.ndarray: + """Return one target angle per control step.""" + rng = np.random.default_rng(seed) + n_steps = int(round(total_time / CONTROL_DT)) + steps_per_hold = int(round(hold_time / CONTROL_DT)) + targets = np.zeros(n_steps, dtype=np.float32) + i = 0 + while i < n_steps: + angle = float(rng.uniform(-MAX_ANGLE, MAX_ANGLE)) + end = min(i + steps_per_hold, n_steps) + targets[i:end] = angle + i = end + return targets + + +class PolicyRunner: + def __init__(self, onnx_path: str, action_scale: float = 1.0): + print(f"Loading policy: {onnx_path} (action_scale={action_scale})") + self.session = ort.InferenceSession(onnx_path) + self.in_name = self.session.get_inputs()[0].name + in_shape = self.session.get_inputs()[0].shape + print(f" input {self.in_name} shape={in_shape}") + self.action_scale = action_scale + self.last_action = np.zeros(1, dtype=np.float32) + + def reset(self): + self.last_action[:] = 0.0 + + def step(self, q: float, qd: float, target: float) -> float: + # Matches the testbench env's policy obs layout: + # [joint_pos_rel, joint_vel_rel, last_action, command] (4-d). + obs = np.array( + [q - DEFAULT_POS, qd, self.last_action[0], target], + dtype=np.float32, + )[None, :] + action = self.session.run(None, {self.in_name: obs})[0].reshape(-1) + self.last_action = action.astype(np.float32) + return DEFAULT_POS + float(action[0]) * self.action_scale + + +# --------------------------------------------------------------------------- +# Sim rollout (mujoco, same XL330 testbench XML as training) +# --------------------------------------------------------------------------- + + +def rollout_sim_bam(onnx_path: str, total_time: float, seed: int, action_scale: float) -> dict: + """Sim rollout using bam's MujocoController on a vanilla MuJoCo step loop. + + Pros: 200 Hz inner-step logging, no torch/mjwarp. Cons: not the exact + actuator that was trained against (uses bam upstream, not mjlab's M6). + """ + import mujoco # local import so --mode real works without mujoco + + from bam.actuators import actuators as bam_actuators + from bam.model import models as bam_models + from bam.mujoco import MujocoController + + # Load the fitted XL330 m6 params from the canonical bam bundle (identical to + # the values that used to live in mjlab_microduck.actuator.bam_params). + import json as _json + from bam.model import _resolve_json_path + with open(_resolve_json_path(None, "xl330", "m6")) as _f: + DEFAULT_XL330_M6 = _json.load(_f) + + VIN = 7.4 + KP_FW = 200.0 + ACTUATOR_NAME = "1" + + # Build BAM's M6 model + XL330 voltage-controlled actuator. The + # MujocoController below drives the joint via this model on every step, + # writing torque to data.ctrl and updating dof_frictionloss/dof_damping + # so MuJoCo's solver applies BAM's Stribeck+load+quadratic friction. + bam_model = bam_models["m6"]() + bam_model.set_actuator(bam_actuators["xl330"]()) + bam_model.actuator.kp = KP_FW + bam_model.actuator.vin = VIN + bam_model.load_parameters_from_dict(DEFAULT_XL330_M6) + + kt = bam_model.kt.value + R = bam_model.R.value + + spec = mujoco.MjSpec.from_file(str(TESTBENCH_XML)) + _set_arm_mass(spec, TESTBENCH_ARM_MASS) + + # MujocoController needs a torque-controlled motor; the XL330 entry in the + # XML is a position actuator, so convert it and set the voltage-bounded + # force range. Armature is set on the dof by MujocoController.__init__. + for act in spec.actuators: + act.set_to_motor() + act.forcelimited = False + fl = VIN * kt / R + act.forcerange = (-fl, fl) + act.gear = [1.0, 0, 0, 0, 0, 0] + for joint in spec.joints: + if joint.type == mujoco.mjtJoint.mjJNT_HINGE: + joint.damping = 0.0 + joint.frictionloss = 0.0 + + model = spec.compile() + data = mujoco.MjData(model) + model.opt.timestep = SIM_DT + + joint_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_JOINT, "1") + dof_id = int(model.jnt_dofadr[joint_id]) + qpos_id = int(model.jnt_qposadr[joint_id]) + + data.qpos[qpos_id] = 0.0 + data.qvel[dof_id] = 0.0 + mujoco.mj_forward(model, data) + + bam_ctrl = MujocoController(bam_model, ACTUATOR_NAME, model, data) + bam_ctrl.reset(data.qpos) + + runner = PolicyRunner(onnx_path, action_scale=action_scale) + policy_targets = make_target_schedule(total_time, seed=seed) + decim = int(round(CONTROL_DT / SIM_DT)) + + # Logging at SIM_DT (200 Hz): decim samples per policy step. + N_log = len(policy_targets) * decim + rec = {k: np.zeros(N_log, dtype=np.float32) + for k in ("t", "target", "q", "qd", "action", "ctrl")} + + t = 0.0 + log_i = 0 + for policy_i, target in enumerate(policy_targets): + q = float(data.qpos[qpos_id]) + qd = float(data.qvel[dof_id]) + goal = runner.step(q, qd, float(target)) + action_raw = float(runner.last_action[0]) + + for _ in range(decim): + q = float(data.qpos[qpos_id]) + dq = float(data.qvel[dof_id]) + + # ---- log at 200 Hz ---- + rec["t"][log_i] = t + rec["target"][log_i] = target + rec["q"][log_i] = q + rec["qd"][log_i] = dq + rec["action"][log_i] = action_raw + rec["ctrl"][log_i] = goal + log_i += 1 + + # BAM owns control/torque/friction: set the target, then update() + # writes torque to data.ctrl and pushes friction/damping onto the + # dof so MuJoCo's solver applies them on the next step. + bam_ctrl.set_q_target(ACTUATOR_NAME, goal) + bam_ctrl.update() + mujoco.mj_step(model, data) + t += SIM_DT + + return rec + + +def rollout_sim_mjlab(onnx_path: str, total_time: float, seed: int, action_scale: float) -> dict: + """Sim rollout via the actual mjlab testbench env (same BAM M6 the policy was trained against). + + Boots make_testbench_env_cfg() with num_envs=1, overrides the target_angle + command with our deterministic schedule each policy tick, and steps the env + with the policy action. We replicate ManagerBasedRlEnv.step's inner + decimation loop manually so we can log q/qd at SIM_DT (200 Hz) between + sub-steps, matching the bam backend's logging rate. + """ + import torch + + from mjlab.envs import ManagerBasedRlEnv + + from mjlab_microduck.tasks.testbench_env_cfg import make_testbench_env_cfg + + env_cfg = make_testbench_env_cfg(play=True) + env_cfg.scene.num_envs = 1 + # Disable auto-resampling and auto-reset so our deterministic schedule and + # initial pose hold for the entire rollout. + env_cfg.commands["target_angle"].resampling_time_range = (1e6, 1e6) + env_cfg.episode_length_s = max(total_time + 10.0, env_cfg.episode_length_s) + # Drop observation noise so the mjlab path is a fair sim2real reference + # (matches the bam path which doesn't inject noise either). + env_cfg.observations["policy"].enable_corruption = False + + device = "cuda:0" if torch.cuda.is_available() else "cpu" + env = ManagerBasedRlEnv(cfg=env_cfg, device=device) + env.reset(seed=seed) + + cmd_term = env.command_manager.get_term("target_angle") + robot = env.scene["robot"] + + runner = PolicyRunner(onnx_path, action_scale=action_scale) + policy_targets = make_target_schedule(total_time, seed=seed) + decim = env.cfg.decimation + physics_dt = env.physics_dt + N_log = len(policy_targets) * decim + rec = {k: np.zeros(N_log, dtype=np.float32) + for k in ("t", "target", "q", "qd", "action", "ctrl")} + + t = 0.0 + log_i = 0 + for target in policy_targets: + # Inject deterministic target and recompute obs so the policy sees it + # this tick (the env's TargetAngleCommand otherwise samples randomly). + cmd_term._target[0, 0] = float(target) + # update_history=True is critical: the testbench env's joint_vel obs + # has a 1-tick delay, so the history buffer must advance each policy + # tick or the policy sees stale velocity. + obs_buf = env.observation_manager.compute(update_history=True) + policy_obs = obs_buf["policy"][0].detach().cpu().numpy().astype(np.float32) + ort_out = runner.session.run(None, {runner.in_name: policy_obs[None, :]})[0].reshape(-1) + runner.last_action = ort_out.astype(np.float32) + action_raw = float(ort_out[0]) + goal = DEFAULT_POS + action_raw * action_scale + + # Manually run the decimation loop ManagerBasedRlEnv.step uses, so we + # can sample joint state at the physics rate (200 Hz). + action = torch.as_tensor(ort_out, device=device).reshape(1, -1) + env.action_manager.process_action(action) + for _ in range(decim): + # Log the pre-step state to mirror the bam backend (which records + # q/qd right before each mj_step). + rec["t"][log_i] = t + rec["target"][log_i] = float(target) + rec["q"][log_i] = float(robot.data.joint_pos[0, 0].item()) + rec["qd"][log_i] = float(robot.data.joint_vel[0, 0].item()) + rec["action"][log_i] = action_raw + rec["ctrl"][log_i] = goal + log_i += 1 + + env.action_manager.apply_action() + env.scene.write_data_to_sim() + env.sim.step() + env.scene.update(dt=physics_dt) + t += physics_dt + + env.close() + return rec + + +# --------------------------------------------------------------------------- +# Real rollout (rustypot XL330) +# --------------------------------------------------------------------------- + + +def rollout_real( + onnx_path: str, + total_time: float, + seed: int, + port: str, + motor_id: int, + baudrate: int, + kp: int, + action_scale: float, +) -> dict: + from rustypot import Xl330PyController + + ctrl = Xl330PyController(port, baudrate, 0.05) + assert ctrl.ping(motor_id), f"motor id={motor_id} not responding on {port}" + + # Match the firmware gain used in sim (BAM kp_fw=200). + ctrl.write_torque_enable(motor_id, False) + ctrl.write_operating_mode(motor_id, 3) # position control + ctrl.write_position_p_gain(motor_id, kp) + ctrl.write_position_i_gain(motor_id, 0) + ctrl.write_position_d_gain(motor_id, 0) + # Read back to confirm the gain actually landed (firmware silently clamps + # out-of-range values, so verifying catches mismatches early). + readback = ctrl.read_position_p_gain(motor_id) + if isinstance(readback, (list, tuple)): + readback = readback[0] + print(f" XL330 position P-gain: requested={kp}, readback={readback}") + ctrl.write_goal_position(motor_id, 0.0) + ctrl.write_torque_enable(motor_id, True) + time.sleep(1.0) # let it settle at zero + + runner = PolicyRunner(onnx_path, action_scale=action_scale) + policy_targets = make_target_schedule(total_time, seed=seed) + + decim = int(round(CONTROL_DT / LOG_DT)) # samples per policy tick (4 at 200 Hz / 50 Hz) + N_log = len(policy_targets) * decim + rec = {k: np.zeros(N_log, dtype=np.float32) + for k in ("t", "target", "q", "qd", "action", "ctrl")} + + def _scalar(x) -> float: + if isinstance(x, (list, tuple)): + return float(x[0]) + return float(x) + + t_start = time.perf_counter() + prev_q = 0.0 + log_i = 0 + goal = 0.0 + action_raw = 0.0 + + for policy_i, target in enumerate(policy_targets): + tick_start = time.perf_counter() + target_f = float(target) + + # Read once, run policy, write goal — all at the start of the 20 ms window. + q = _scalar(ctrl.read_present_position(motor_id)) + try: + qd = _scalar(ctrl.read_present_velocity(motor_id)) * DXL_VEL_TICK_TO_RAD_S + except Exception: + qd = (q - prev_q) / CONTROL_DT + + goal = runner.step(q, qd, target_f) + action_raw = float(runner.last_action[0]) + # ctrl.write_goal_position(motor_id, float(np.clip(goal, -MAX_ANGLE, MAX_ANGLE))) + ctrl.write_goal_position(motor_id, float(goal)) + + # First 200 Hz sample uses the values we just read (no extra USB round-trip). + rec["t"][log_i] = time.perf_counter() - t_start + rec["target"][log_i] = target_f + rec["q"][log_i] = q + rec["qd"][log_i] = qd + rec["action"][log_i] = action_raw + rec["ctrl"][log_i] = goal + prev_q = q + log_i += 1 + + # Remaining (decim-1) samples inside the policy window: read only. + for k in range(1, decim): + sample_deadline = tick_start + (k + 1) * LOG_DT + while time.perf_counter() < sample_deadline - 0.001: + time.sleep(0.0005) + q = _scalar(ctrl.read_present_position(motor_id)) + try: + qd = _scalar(ctrl.read_present_velocity(motor_id)) * DXL_VEL_TICK_TO_RAD_S + except Exception: + qd = (q - prev_q) / LOG_DT + prev_q = q + + rec["t"][log_i] = time.perf_counter() - t_start + rec["target"][log_i] = target_f + rec["q"][log_i] = q + rec["qd"][log_i] = qd + rec["action"][log_i] = action_raw + rec["ctrl"][log_i] = goal + log_i += 1 + + # Live status on every new segment plus a heartbeat. + new_segment = policy_i == 0 or policy_targets[policy_i] != policy_targets[policy_i - 1] + if new_segment or policy_i % 25 == 0: + print( + f"\r t={rec['t'][log_i-1]:6.2f}s target={math.degrees(target_f):+6.1f}° " + f"q={math.degrees(q):+6.1f}° err={math.degrees(q - target_f):+6.1f}° " + f"goal={math.degrees(goal):+6.1f}°", + end="" if not new_segment else "\n", + flush=True, + ) + + # Hold the remaining time of the policy window if we got here early. + dt_left = CONTROL_DT - (time.perf_counter() - tick_start) + if dt_left > 0: + time.sleep(dt_left) + print() + + ctrl.write_torque_enable(motor_id, False) + return rec + + +# --------------------------------------------------------------------------- +# Plotting / analytics +# --------------------------------------------------------------------------- + + +def _mae(a: np.ndarray, b: np.ndarray) -> float: + n = min(len(a), len(b)) + return float(np.mean(np.abs(a[:n] - b[:n]))) + + +def npz_to_bam_log(npz_path: str, json_path: str, *, mass: float, length: float, + kp: int, vin: float) -> None: + """Convert a rollout .npz (written by rollout_sim/rollout_real) to a BAM log json. + + BAM log format (see ~/Rhoban/bam/bam/logs.py): + top-level: mass, length, kp, vin, motor, trajectory, dt + entries: position, speed, load, input_volts, temp, goal_position, torque_enable, timestamp + Can be fed to `python -m bam.plot --logdir --actuator xl330`. + """ + import json + + d = dict(np.load(npz_path)) + t = d["t"] + # Prefer the actual recorded timestamps for dt to handle small jitter; + # fall back to the fixed control period if there are fewer than 2 samples. + dt = float(np.mean(np.diff(t))) if len(t) > 1 else CONTROL_DT + + entries = [] + for i in range(len(t)): + entries.append({ + "position": float(d["q"][i]), + "speed": float(d["qd"][i]), + "load": 0.0, + "input_volts": vin, + "temp": 25.0, + "goal_position": float(d["ctrl"][i]), + "torque_enable": True, + "timestamp": float(t[i]), + }) + + log = { + "mass": mass, + "length": length, + "kp": kp, + "vin": vin, + "motor": "xl330", + "trajectory": "rl_policy", + "dt": dt, + "entries": entries, + } + + out = Path(json_path) + out.parent.mkdir(parents=True, exist_ok=True) + with open(out, "w") as f: + json.dump(log, f) + print(f"Wrote BAM log: {out} ({len(entries)} entries, dt={dt:.4f}s, mass={mass}kg, kp={kp})") + print(f" Replay with: (cd ~/Rhoban/bam && python -m bam.plot --logdir {out.parent} --actuator xl330)") + + +def compare_and_plot(sim_file: str, real_file: str, out_path: str) -> None: + import matplotlib.pyplot as plt + + sim = dict(np.load(sim_file)) + real = dict(np.load(real_file)) + n = min(len(sim["t"]), len(real["t"])) + t = sim["t"][:n] + + err_sim = sim["q"][:n] - sim["target"][:n] + err_real = real["q"][:n] - real["target"][:n] + + print("\n=== Analytics ===") + print(f" steps compared : {n}") + print(f" MAE q (sim vs real) : {_mae(sim['q'], real['q']):.4f} rad " + f"({math.degrees(_mae(sim['q'], real['q'])):.2f}°)") + print(f" sim tracking MAE : {float(np.mean(np.abs(err_sim))):.4f} rad " + f"({math.degrees(float(np.mean(np.abs(err_sim)))):.2f}°)") + print(f" real tracking MAE : {float(np.mean(np.abs(err_real))):.4f} rad " + f"({math.degrees(float(np.mean(np.abs(err_real)))):.2f}°)") + print(f" sim qd RMS : {float(np.sqrt(np.mean(sim['qd'][:n]**2))):.3f} rad/s") + print(f" real qd RMS : {float(np.sqrt(np.mean(real['qd'][:n]**2))):.3f} rad/s") + print(f" action MAE : {_mae(sim['action'], real['action']):.4f} rad") + + fig, axes = plt.subplots(4, 1, figsize=(11, 10), sharex=True) + + axes[0].plot(t, sim["target"][:n], "k-", lw=1, label="target", alpha=0.4) + axes[0].plot(t, sim["q"][:n], "b-", lw=1.2, label="sim q") + axes[0].plot(t, real["q"][:n], "r-", lw=1.2, label="real q") + axes[0].plot(t, sim["ctrl"][:n], "b:", lw=0.8, alpha=0.6, label="sim goal (policy)") + axes[0].plot(t, real["ctrl"][:n], "r:", lw=0.8, alpha=0.6, label="real goal (policy)") + axes[0].set_ylabel("position [rad]") + axes[0].legend(loc="upper right", fontsize=8) + axes[0].grid(alpha=0.3) + axes[0].set_title(f"Testbench sim2real — MAE(sim, real) = {_mae(sim['q'], real['q']):.4f} rad") + + axes[1].plot(t, np.degrees(err_sim), "b-", lw=1, label="sim") + axes[1].plot(t, np.degrees(err_real), "r-", lw=1, label="real") + axes[1].axhline(0, color="k", lw=0.5) + axes[1].set_ylabel("tracking error [deg]") + axes[1].legend(fontsize=8) + axes[1].grid(alpha=0.3) + + axes[2].plot(t, sim["qd"][:n], "b-", lw=1, label="sim") + axes[2].plot(t, real["qd"][:n], "r-", lw=1, label="real") + axes[2].set_ylabel("velocity [rad/s]") + axes[2].legend(fontsize=8) + axes[2].grid(alpha=0.3) + + axes[3].plot(t, sim["action"][:n], "b-", lw=1, label="sim action") + axes[3].plot(t, real["action"][:n], "r-", lw=1, label="real action") + axes[3].set_ylabel("policy action [rad]") + axes[3].set_xlabel("time [s]") + axes[3].legend(fontsize=8) + axes[3].grid(alpha=0.3) + + plt.tight_layout() + plt.savefig(out_path, dpi=140) + print(f"\nSaved plot: {out_path}") + plt.show() + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--mode", choices=["sim", "real"], help="Rollout mode") + ap.add_argument("--sim-backend", choices=["bam", "mjlab"], default="bam", + help="Sim backend: 'bam' uses bam.MujocoController on a vanilla " + "mujoco loop (200 Hz log, lightweight); 'mjlab' boots the actual " + "make_testbench_env_cfg() mjlab env with its BamM6Actuator (50 Hz log).") + ap.add_argument("--onnx", type=str, help="Path to trained ONNX policy") + ap.add_argument("--out", type=str, help="Output .npz log file") + ap.add_argument("--duration", type=float, default=30.0, help="Total time [s]") + ap.add_argument("--seed", type=int, default=0, help="Target schedule seed") + # real-only + ap.add_argument("--port", type=str, default="/dev/ttyUSB0") + ap.add_argument("--motor-id", type=int, default=1) + ap.add_argument("--baudrate", type=int, default=1_000_000) + ap.add_argument("--kp", type=int, default=200, help="XL330 position P gain") + ap.add_argument("--action-scale", type=float, default=1.0, + help="Multiplier applied to the policy action before offsetting " + "by the default pose (must match training env action scale)") + # compare mode + ap.add_argument("--compare", nargs=2, metavar=("SIM_NPZ", "REAL_NPZ"), + help="Plot two logged runs side by side") + ap.add_argument("--out-plot", type=str, default="testbench_sim2real.png") + # BAM log export + ap.add_argument("--to-bam", nargs=2, metavar=("NPZ", "JSON"), + help="Convert a rollout NPZ to BAM log format " + "(run with: python -m bam.plot --logdir --actuator xl330)") + ap.add_argument("--bam-mass", type=float, default=TESTBENCH_ARM_MASS, help="Payload mass [kg]") + ap.add_argument("--bam-length", type=float, default=0.1, help="Arm length [m]") + ap.add_argument("--bam-vin", type=float, default=7.4, help="Supply voltage [V]") + + args = ap.parse_args() + + if args.compare: + compare_and_plot(args.compare[0], args.compare[1], args.out_plot) + return + + if args.to_bam: + npz_to_bam_log( + args.to_bam[0], + args.to_bam[1], + mass=args.bam_mass, + length=args.bam_length, + kp=args.kp, + vin=args.bam_vin, + ) + return + + if not (args.mode and args.onnx and args.out): + ap.error("--mode, --onnx and --out are required for a rollout") + + if args.mode == "sim": + sim_fn = rollout_sim_mjlab if args.sim_backend == "mjlab" else rollout_sim_bam + rec = sim_fn(args.onnx, args.duration, args.seed, args.action_scale) + else: + rec = rollout_real( + args.onnx, args.duration, args.seed, + args.port, args.motor_id, args.baudrate, args.kp, + args.action_scale, + ) + + out = Path(args.out) + out.parent.mkdir(parents=True, exist_ok=True) + np.savez(out, **rec) + err = rec["q"] - rec["target"] + print(f"\nSaved {len(rec['t'])} samples to {out}") + print(f" tracking MAE: {float(np.mean(np.abs(err))):.4f} rad ({math.degrees(float(np.mean(np.abs(err)))):.2f}°)") + + +if __name__ == "__main__": + main() diff --git a/scripts/validate_bam_testbench.py b/scripts/validate_bam_testbench.py new file mode 100644 index 0000000..08455e7 --- /dev/null +++ b/scripts/validate_bam_testbench.py @@ -0,0 +1,277 @@ +"""Validate the BAM M6 actuator kernel against real testbench data. + +Loads real testbench recordings, replays them in MuJoCo with the BAM M6 actuator, +and compares simulated vs real position traces. Also runs BAM's own Python simulator +as a reference. + +Usage: + uv run python3 scripts/validate_bam_testbench.py [--plot] [--max-files N] +""" + +import argparse +import json +import os +import sys +from copy import copy +from pathlib import Path + +import mujoco +import numpy as np + +# ── Paths ── +BAM_DIR = Path(os.path.expanduser("~/Rhoban/bam")) +DATA_DIR = BAM_DIR / "bam" / "data" / "processed" +PARAMS_FILE = BAM_DIR / "params" / "xl330" / "m6_new.json" +TESTBENCH_XML = ( + Path(__file__).resolve().parent.parent + / "src" + / "mjlab_microduck" + / "robot" + / "xl330_test_bench" + / "scene.xml" +) + +# ── Load M6 params ── +with open(PARAMS_FILE) as f: + M6 = json.load(f) + +# XL330 firmware constants +ERROR_GAIN = (4096 / (2 * np.pi)) / (256 * 885) +VIN = 7.4 +MAX_PWM = 1.0 + + +def bam_python_rollout(log: dict) -> list[float]: + """Reference: BAM's own Python simulator.""" + sys.path.insert(0, str(BAM_DIR)) + from bam.model import load_model + from bam.simulate import Simulator + + # BAM expects arm_mass in the log (mass of the arm itself, not the payload) + if "arm_mass" not in log: + log = dict(log) + log["arm_mass"] = 0.0 + + model = load_model(str(PARAMS_FILE)) + sim = Simulator(model) + result = sim.rollout_log(log, simulate_control=True) + return result[0] # positions + + +def compute_m6_friction(motor_torque, external_torque, dq): + """M6 friction computation matching our kernel (and BAM's model.py).""" + p = M6 + stribeck_coeff = np.exp(-(np.abs(dq / p["dtheta_stribeck"]) ** p["alpha"])) + + gearbox_torque = np.abs( + external_torque * p["load_friction_external"] + - motor_torque * p["load_friction_motor"] + ) + gearbox_torque_stribeck = np.abs( + external_torque * p["load_friction_external_stribeck"] + - motor_torque * p["load_friction_motor_stribeck"] + ) + + frictionloss = p["friction_base"] + frictionloss += gearbox_torque + frictionloss += stribeck_coeff * p["friction_stribeck"] + frictionloss += gearbox_torque_stribeck * stribeck_coeff + # quadratic (tiny, skip for clarity) + + damping = p["friction_viscous"] + friction_budget = frictionloss + damping * np.abs(dq) + return friction_budget + + +def mujoco_rollout(log: dict) -> list[float]: + """Run the testbench in MuJoCo with our BAM M6 actuator logic.""" + mass = log["mass"] + kp_fw = log["kp"] + dt = log["dt"] + entries = log["entries"] + + # Load and modify the testbench model + spec = mujoco.MjSpec.from_file(str(TESTBENCH_XML)) + + # Convert actuator to motor (same as our kernel's edit_spec) + for act in spec.actuators: + act.set_to_motor() + act.forcelimited = True + force_limit = VIN * M6["kt"] / M6["R"] + act.forcerange = (-force_limit, force_limit) + act.gear = [1.0, 0, 0, 0, 0, 0] + + # Zero out MuJoCo joint friction (we handle it) + for joint in spec.joints: + if joint.type == mujoco.mjtJoint.mjJNT_HINGE: + joint.damping = 0.0 + joint.frictionloss = 0.0 + joint.armature = M6["armature"] + + # Set the arm mass to match the BAM recording + for body in spec.bodies: + if body.name == "arm": + # Scale mass and inertia proportionally + original_mass = body.mass + scale = mass / original_mass if original_mass > 0 else 1.0 + body.mass = mass + # Scale inertia proportionally to mass + body.fullinertia = [x * scale for x in body.fullinertia] + break + + model = spec.compile() + data = mujoco.MjData(model) + model.opt.timestep = dt + + # Find joint and actuator IDs + joint_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_JOINT, "1") + dof_id = model.jnt_dofadr[joint_id] + + # Initialize state + data.qpos[dof_id] = entries[0]["position"] + data.qvel[dof_id] = entries[0].get("speed", 0.0) + mujoco.mj_forward(model, data) + + positions = [] + for entry in entries: + positions.append(float(data.qpos[dof_id])) + + if not entry["torque_enable"]: + data.ctrl[0] = 0.0 + mujoco.mj_step(model, data) + continue + + goal = entry["goal_position"] + q = data.qpos[dof_id] + dq = data.qvel[dof_id] + + # ── BAM M6 actuator logic (same as our kernel) ── + + # 1. Firmware control law + duty = (goal - q) * kp_fw * ERROR_GAIN + duty = np.clip(duty, -MAX_PWM, MAX_PWM) + voltage = VIN * duty + + # 2. DC motor torque + motor_torque = M6["kt"] * voltage / M6["R"] - M6["kt"] ** 2 * dq / M6["R"] + + # 3. External torque (from MuJoCo bias forces) + # BAM convention: bias_torque = m*g*l*sin(q) with g=-9.81 (gravity negative) + # MuJoCo convention: qfrc_bias has opposite sign + external_torque = -data.qfrc_bias[dof_id] + + # 4. M6 friction + friction_budget = compute_m6_friction(motor_torque, external_torque, dq) + + # 5. Static friction clipping + eff_inertia = 1.0 / model.dof_invweight0[dof_id] if model.dof_invweight0[dof_id] > 0 else 1e6 + net_no_friction = motor_torque + external_torque + tau_stop = (eff_inertia / dt) * dq + net_no_friction + friction_mag = min(abs(tau_stop), friction_budget) + friction_torque = -np.sign(tau_stop) * friction_mag + + # 6. Set ctrl = motor + friction (MuJoCo adds qfrc_bias) + data.ctrl[0] = motor_torque + friction_torque + + mujoco.mj_step(model, data) + + return positions + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--plot", action="store_true", help="Show plots") + parser.add_argument("--max-files", type=int, default=5) + args = parser.parse_args() + + data_files = sorted(DATA_DIR.glob("*.json")) + if args.max_files: + data_files = data_files[: args.max_files] + + print(f"Validating BAM M6 kernel against {len(data_files)} testbench recordings") + print(f"M6 params: kt={M6['kt']:.4f} R={M6['R']:.4f}") + print(f"Testbench XML: {TESTBENCH_XML}") + print() + + results = [] + for fpath in data_files: + log = json.load(open(fpath)) + name = f"{log['trajectory']}_m{log['mass']}_kp{log['kp']}" + print(f" {name}...", end=" ", flush=True) + + real_pos = [e["position"] for e in log["entries"]] + + # BAM Python reference + bam_pos = bam_python_rollout(log) + + # Our MuJoCo M6 kernel + mj_pos = mujoco_rollout(log) + + # Compute MAE + real_np = np.array(real_pos) + bam_np = np.array(bam_pos) + mj_np = np.array(mj_pos[: len(real_np)]) + + mae_bam = np.mean(np.abs(bam_np - real_np)) + mae_mj = np.mean(np.abs(mj_np - real_np)) + mae_bam_vs_mj = np.mean(np.abs(bam_np - mj_np)) + + print( + f"MAE bam_vs_real={mae_bam:.5f} mj_vs_real={mae_mj:.5f} bam_vs_mj={mae_bam_vs_mj:.5f}" + ) + + results.append( + { + "name": name, + "real": real_np, + "bam": bam_np, + "mj": mj_np, + "mae_bam": mae_bam, + "mae_mj": mae_mj, + "mae_bam_vs_mj": mae_bam_vs_mj, + } + ) + + print() + avg_bam = np.mean([r["mae_bam"] for r in results]) + avg_mj = np.mean([r["mae_mj"] for r in results]) + avg_diff = np.mean([r["mae_bam_vs_mj"] for r in results]) + print(f"Average MAE bam_vs_real={avg_bam:.5f} mj_vs_real={avg_mj:.5f} bam_vs_mj={avg_diff:.5f}") + + if avg_diff > 0.01: + print("\n⚠ BAM and MuJoCo diverge significantly — likely a kernel bug!") + elif avg_mj > avg_bam * 1.5: + print("\n⚠ MuJoCo worse than BAM — MuJoCo dynamics differ from BAM's simple integrator") + else: + print("\n✓ BAM and MuJoCo agree — kernel is correct") + + if args.plot: + try: + import matplotlib.pyplot as plt + + n = len(results) + fig, axes = plt.subplots(n, 1, figsize=(12, 3 * n), sharex=False) + if n == 1: + axes = [axes] + + for ax, r in zip(axes, results): + t = np.arange(len(r["real"])) * 0.005 + ax.plot(t, r["real"], "k-", lw=1.5, label="Real") + ax.plot(t, r["bam"], "b--", lw=1.2, label=f'BAM (MAE={r["mae_bam"]:.4f})') + ax.plot(t, r["mj"], "r:", lw=1.2, label=f'MuJoCo M6 (MAE={r["mae_mj"]:.4f})') + ax.set_title(r["name"]) + ax.set_ylabel("Position (rad)") + ax.legend(fontsize=8) + ax.grid(alpha=0.3) + + axes[-1].set_xlabel("Time (s)") + plt.tight_layout() + plt.savefig("bam_validation.png", dpi=150) + print("Saved bam_validation.png") + plt.show() + except ImportError: + print("matplotlib not available, skipping plots") + + +if __name__ == "__main__": + main() diff --git a/scripts/view_slope_terrain.py b/scripts/view_slope_terrain.py new file mode 100644 index 0000000..fef271d --- /dev/null +++ b/scripts/view_slope_terrain.py @@ -0,0 +1,101 @@ +"""Observer la rampe du mode pente (roller_slope) dans le viewer MuJoCo. + +Construit UNIQUEMENT le terrain « plat + rampe » (FlatRampTerrainCfg), sur +plusieurs rangées de difficulté croissante (raideur 2° -> 20°), et ouvre le +viewer natif MuJoCo. Aucune politique entraînée n'est nécessaire — c'est fait +pour valider à l'œil la géométrie (jointure plat/rampe, sens de descente). + +Usage : + uv run python scripts/view_slope_terrain.py + uv run python scripts/view_slope_terrain.py --rows 6 --ramp-max 8 --runout 4 + uv run python scripts/view_slope_terrain.py --build-only # test sans GUI + +Dans le viewer : molette pour zoomer, clic-gauche glisser pour orbiter, +clic-droit glisser pour translater. Chaque rangée est une rampe de plus en plus +raide (difficulté 0 -> 1), de longueur tirée au hasard dans [ramp-min, ramp-max], +et se termine par un plat de sortie. « Devant » (+x) doit descendre. +""" + +import argparse + +import mujoco +import mujoco.viewer + +from mjlab.terrains.terrain_generator import TerrainGenerator, TerrainGeneratorCfg +from mjlab_microduck.tasks.slope_terrain import ( + FlatRampTerrainCfg, + RAMP_DEG_MIN, + RAMP_DEG_MAX, +) + + +def build_model(rows, size, flat_length, ramp_range, runout, deg_min, deg_max): + """Construit le modèle MuJoCo du terrain seul (rows rampes de raideur croissante).""" + cfg = TerrainGeneratorCfg( + seed=0, + size=size, + num_rows=rows, + num_cols=1, + curriculum=True, # difficulté croissante le long des rangées + difficulty_range=(0.0, 1.0), + add_lights=True, + sub_terrains={ + "flat_ramp": FlatRampTerrainCfg( + flat_length=flat_length, + ramp_length_range=ramp_range, + runout_length=runout, + deg_min=deg_min, + deg_max=deg_max, + ) + }, + ) + generator = TerrainGenerator(cfg) + spec = mujoco.MjSpec() + generator.compile(spec) + model = spec.compile() + return model + + +def main(): + p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("--rows", type=int, default=5, help="Nb de rangées = nb de raideurs affichées (défaut 5)") + p.add_argument("--size", type=float, nargs=2, default=(15.0, 4.0), help="Taille d'une tuile (x y) en m") + p.add_argument("--flat-length", type=float, default=2.0, help="Longueur du plat de départ (m)") + p.add_argument("--ramp-min", type=float, default=3.0, help="Longueur horizontale mini de la rampe (m)") + p.add_argument("--ramp-max", type=float, default=8.0, help="Longueur horizontale maxi de la rampe (m)") + p.add_argument("--runout", type=float, default=4.0, help="Longueur du plat de sortie (m)") + p.add_argument("--deg-min", type=float, default=RAMP_DEG_MIN, help=f"Raideur min en degrés (défaut {RAMP_DEG_MIN})") + p.add_argument("--deg-max", type=float, default=RAMP_DEG_MAX, help=f"Raideur max en degrés (défaut {RAMP_DEG_MAX})") + p.add_argument("--build-only", action="store_true", help="Construit le modèle et quitte (test sans GUI)") + args = p.parse_args() + + model = build_model( + rows=args.rows, + size=tuple(args.size), + flat_length=args.flat_length, + ramp_range=(args.ramp_min, args.ramp_max), + runout=args.runout, + deg_min=args.deg_min, + deg_max=args.deg_max, + ) + data = mujoco.MjData(model) + mujoco.mj_forward(model, data) + + print( + f"Terrain construit : {args.rows} rampes, raideur {args.deg_min}°->{args.deg_max}°, " + f"longueur rampe {args.ramp_min}-{args.ramp_max}m + sortie {args.runout}m, " + f"{model.ngeom} géométries." + ) + if args.build_only: + print("--build-only : OK, pas de GUI.") + return + + print("Ouverture du viewer MuJoCo (Ctrl+C pour quitter)…") + with mujoco.viewer.launch_passive(model, data, show_left_ui=False, show_right_ui=False) as viewer: + while viewer.is_running(): + mujoco.mj_forward(model, data) + viewer.sync() + + +if __name__ == "__main__": + main() diff --git a/scripts/wandb_utils.py b/scripts/wandb_utils.py new file mode 100644 index 0000000..d55f7d4 --- /dev/null +++ b/scripts/wandb_utils.py @@ -0,0 +1,138 @@ +"""Shared wandb helpers for play_latest and export_latest scripts.""" + +import os +import subprocess +import sys +from datetime import datetime + +import wandb + +WANDB_PROJECT = "pollen-robotics/mjlab_microduck" + + +def find_latest_run(user_filter: str) -> wandb.apis.public.Run | None: + """Return the most recent run whose metadata email contains *user_filter*.""" + api = wandb.Api() + runs = api.runs(WANDB_PROJECT, per_page=100, order="-created_at") + for run in runs: + email = (run.metadata or {}).get("email", "") + if user_filter.lower() in email.lower(): + return run + return None + + +def find_latest_runs(user_filter, task_match, n): + """Return up to *n* most recent runs whose metadata email contains + *user_filter* and whose task_id (metadata args[0]) satisfies *task_match*. + + task_match: Callable[[str], bool] applied to the wandb task_id string. + Ordered most-recent first. + """ + api = wandb.Api() + runs = api.runs(WANDB_PROJECT, per_page=100, order="-created_at") + matched = [] + for run in runs: + email = (run.metadata or {}).get("email", "") + if user_filter.lower() not in email.lower(): + continue + args = (run.metadata or {}).get("args", []) + task_id = args[0] if args else "" + if not task_match(task_id): + continue + matched.append(run) + if len(matched) >= n: + break + return matched + + +def _format_duration(seconds: float) -> str: + h = int(seconds // 3600) + m = int((seconds % 3600) // 60) + return f"{h}h{m:02d}m" + + +def print_run_info(run: wandb.apis.public.Run) -> dict: + """Print run details and return dict with env_name, run_path, checkpoints.""" + meta = run.metadata + summary = run.summary + train_cfg = run.config.get("train_cfg", {}) + env_cfg = run.config.get("env_cfg", {}) + + env_name = meta.get("args", ["?"])[0] + run_path = f"{WANDB_PROJECT}/{run.id}" + duration = summary.get("_runtime", 0) + total_steps = summary.get("_step", 0) + max_iter = train_cfg.get("max_iterations", "?") + num_envs = env_cfg.get("scene", {}).get("terrain", {}).get("num_envs", "?") + mean_reward = summary.get("Train/mean_reward") + lr = summary.get("Loss/learning_rate") + + reward_items = [ + (k.removeprefix("Episode_Reward/"), v) + for k, v in summary.items() + if k.startswith("Episode_Reward/") and isinstance(v, (int, float)) + ] + reward_items.sort(key=lambda x: abs(x[1]), reverse=True) + + checkpoints = sorted( + [f.name for f in run.files() if f.name.startswith("model_") and f.name.endswith(".pt")] + ) + last_ckpt = checkpoints[-1] if checkpoints else "none" + + created = datetime.fromisoformat(run.created_at.replace("Z", "+00:00")) + date_str = created.astimezone().strftime("%Y-%m-%d %H:%M") + + print("=" * 60) + print(f" Run: {run.name}") + print(f" ID: {run.id}") + print(f" Date: {date_str}") + print(f" Status: {run.state}") + print(f" Environment: {env_name}") + print(f" Duration: {_format_duration(duration)}") + print(f" Progress: {total_steps} / {max_iter} iterations") + print(f" Num envs: {num_envs}") + print(f" Mean reward: {mean_reward:.2f}" if mean_reward else " Mean reward: ?") + print(f" Learning rate:{lr:.2e}" if lr else " Learning rate:?") + print(f" Last ckpt: {last_ckpt}") + print(f" Host: {meta.get('host', '?')}") + print(f" GPU: {meta.get('gpu', '?')}") + print(f" User: {meta.get('email', '?')}") + print() + print(" Top rewards (by magnitude):") + for name, val in reward_items[:8]: + sign = "+" if val >= 0 else "" + print(f" {name:<35s} {sign}{val:.4f}") + print("=" * 60) + + return {"env_name": env_name, "run_path": run_path, "checkpoints": checkpoints} + + +def resolve_run(user: str, task_substr: str | None = None) -> tuple[wandb.apis.public.Run, dict]: + """Find latest run for user (optionally filtered to a task-id substring), + print info, return (run, info). Exits on failure.""" + if task_substr: + print(f"Searching latest '{task_substr}' run for user '{user}'...") + runs = find_latest_runs(user, lambda t: task_substr.lower() in t.lower(), 1) + run = runs[0] if runs else None + else: + print(f"Searching latest run for user '{user}'...") + run = find_latest_run(user) + if run is None: + suffix = f" matching '{task_substr}'" if task_substr else "" + print(f"No run found for user '{user}'{suffix}", file=sys.stderr) + sys.exit(1) + info = print_run_info(run) + return run, info + + +def run_command(cmd: list[str], dry_run: bool) -> None: + """Print and optionally execute a command from the project root.""" + print() + print("Command:") + print(f" {' '.join(cmd)}") + print() + if dry_run: + print("(dry-run, not executing)") + return + project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + subprocess.run(cmd, cwd=project_root) diff --git a/src/mjlab_microduck/__init__.py b/src/mjlab_microduck/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/mjlab_microduck/actuator/__init__.py b/src/mjlab_microduck/actuator/__init__.py new file mode 100644 index 0000000..170f7c0 --- /dev/null +++ b/src/mjlab_microduck/actuator/__init__.py @@ -0,0 +1,13 @@ +from mjlab_microduck.actuator.friction_dr_bam import ( + BacklashEncoderBamActuator, + BacklashEncoderBamActuatorCfg, + FrictionDRBamActuator, + FrictionDRBamActuatorCfg, +) + +__all__ = [ + "BacklashEncoderBamActuator", + "BacklashEncoderBamActuatorCfg", + "FrictionDRBamActuator", + "FrictionDRBamActuatorCfg", +] diff --git a/src/mjlab_microduck/actuator/friction_dr_bam.py b/src/mjlab_microduck/actuator/friction_dr_bam.py new file mode 100644 index 0000000..5215017 --- /dev/null +++ b/src/mjlab_microduck/actuator/friction_dr_bam.py @@ -0,0 +1,112 @@ +"""BAM actuator with per-env friction-magnitude domain randomization. + +The canonical ``bam.mjlab.BamActuator`` exposes per-env gain scaling (kp/kd) but +no friction hook, and under BAM MuJoCo's ``dof_frictionloss`` is zeroed in +``edit_spec`` (BAM computes friction itself in ``compute()``). So the stock +``dr.dof_frictionloss`` is a no-op here. + +This thin subclass adds a per-env ``friction_scale`` that multiplies BAM's +velocity-INDEPENDENT friction budget (Coulomb + Stribeck + load-dependent) inside +``_compute_friction_budget`` — the term that carries the dominant sim2real +friction uncertainty (stiction / gearbox). The viscous (velocity-proportional) +term is left at nominal; scale it too by overriding ``compute`` if ever needed. + +Non-accumulating: ``friction_scale`` is reset to 1.0 then set to a fresh sample +each episode by the ``randomize_bam_friction`` event (see tasks/mdp.py). +""" + +from __future__ import annotations + +import dataclasses +from dataclasses import dataclass + +import torch +from bam.mjlab import BamActuator, BamActuatorCfg +from mjlab.actuator.actuator import ActuatorCmd + + +class FrictionDRBamActuator(BamActuator): + """BamActuator + per-env friction_scale on the BAM friction budget.""" + + def initialize(self, mj_model, model, data, device) -> None: + super().initialize(mj_model, model, data, device) + # kp_scale is (num_envs, 1); mirror it for a per-env friction multiplier. + self.friction_scale = torch.ones_like(self.kp_scale) + self.default_friction_scale = self.friction_scale.clone() + + def _compute_friction_budget( + self, + motor_torque: torch.Tensor, + external_torque: torch.Tensor, + stribeck_coeff: torch.Tensor, + ) -> torch.Tensor: + base = super()._compute_friction_budget( + motor_torque, external_torque, stribeck_coeff + ) + fs = getattr(self, "friction_scale", None) + return base if fs is None else base * fs # (N, J) * (N, 1) + + def set_friction_scale(self, env_ids, friction_scale: torch.Tensor) -> None: + self.friction_scale[env_ids] = friction_scale + + def reset_friction_scale(self, env_ids) -> None: + self.friction_scale[env_ids] = self.default_friction_scale[env_ids] + + +@dataclass(kw_only=True) +class FrictionDRBamActuatorCfg(BamActuatorCfg): + """Drop-in for BamActuatorCfg that builds a friction-DR-capable actuator.""" + + def build(self, entity, target_ids, target_names) -> FrictionDRBamActuator: + return FrictionDRBamActuator(self, entity, target_ids, target_names) + + +class BacklashEncoderBamActuator(FrictionDRBamActuator): + """FrictionDRBamActuator whose firmware PD reads the encoder THROUGH backlash. + + Backlash models (robot_allcollisions_backlash.xml) put an unactuated + ``passive__backlash`` hinge in series with each servo joint: the + servo joint is the motor output, the backlash joint is the play between it + and the link, and the link angle is their sum. + + On the real servo the magnetic encoder sits on the OUTPUT side of that + play, so the firmware position loop closes on main+backlash — while the + servo winds through the dead zone the measured position (and hence the PD + error) doesn't change. This subclass reproduces that: ``cmd.pos`` fed to + BAM's voltage control law becomes qpos[main] + qpos[backlash]. + + ``cmd.vel`` is left motor-side on purpose: in BAM it drives back-EMF and + friction, which are rotor physics, not an encoder-derived firmware signal. + + Degrades to a plain FrictionDRBamActuator on models without backlash + joints (per-joint mask), so it is safe to use on any microduck model. + """ + + def initialize(self, mj_model, model, data, device) -> None: + super().initialize(mj_model, model, data, device) + name_to_local = {n: i for i, n in enumerate(self.entity.joint_names)} + ids, mask = [], [] + for name in self._target_names: + bl_id = name_to_local.get(f"passive_{name}_backlash") + ids.append(0 if bl_id is None else bl_id) + mask.append(0.0 if bl_id is None else 1.0) + self._backlash_joint_ids = torch.tensor(ids, dtype=torch.long, device=device) + self._backlash_mask = torch.tensor(mask, dtype=torch.float32, device=device) + n_backlash = int(self._backlash_mask.sum().item()) + print( + f"[BacklashEncoderBamActuator] encoder-through-backlash feedback on " + f"{n_backlash}/{len(mask)} joints" + ) + + def get_command(self, data) -> ActuatorCmd: + cmd = super().get_command(data) + pos = cmd.pos + data.joint_pos[:, self._backlash_joint_ids] * self._backlash_mask + return dataclasses.replace(cmd, pos=pos) + + +@dataclass(kw_only=True) +class BacklashEncoderBamActuatorCfg(FrictionDRBamActuatorCfg): + """FrictionDRBamActuatorCfg whose PD feedback reads through backlash joints.""" + + def build(self, entity, target_ids, target_names) -> BacklashEncoderBamActuator: + return BacklashEncoderBamActuator(self, entity, target_ids, target_names) diff --git a/src/mjlab_microduck/hf_jobs.py b/src/mjlab_microduck/hf_jobs.py new file mode 100644 index 0000000..37fd23e --- /dev/null +++ b/src/mjlab_microduck/hf_jobs.py @@ -0,0 +1,480 @@ +"""Submit a mjlab-microduck training run as a Hugging Face Job. + +Invoked via the `train` wrapper (see train_cli.py): + + uv run train Mjlab-Kick-Flat-MicroDuck \ + --env.scene.num-envs 4096 --agent.max_iterations 4000 --hf-jobs + +Anything that isn't an --hf-* / submission flag is forwarded verbatim to +`uv run train` inside the job. + +Auth: the cached HF token from `hf auth login` / HF_TOKEN env. The account's +orgs are listed at submission and you pick the namespace to run under +(personal or org) — repos, uv-cache bucket and the job itself all live in +that namespace. Pass --namespace to skip the prompt (automation). + +Everything goes through the huggingface_hub Python API (Jobs API, hub >= 1.x) +— the standalone `hf` CLI is NOT required. + +Source: a snapshot of tracked files (committed or not) is uploaded to a +private HF dataset repo and mounted read-only inside the job. Checkpoints are +pushed by a watcher running alongside training (scripts/hf/uploader.py) to a +private HF model repo. +""" + +from __future__ import annotations + +import argparse +import datetime as dt +import os +import shlex +import subprocess +import sys +import tarfile +import tempfile +import time +from netrc import netrc +from pathlib import Path + +from huggingface_hub import HfApi, Volume, get_token + +DEFAULT_IMAGE = "pytorch/pytorch:2.5.1-cuda12.4-cudnn9-runtime" +DEFAULT_FLAVOR = "l4x1" +DEFAULT_TIMEOUT = "12h" + +# Bootstrap script run inside the container. `$VAR` is expanded by the +# container shell from the job's env vars. +BOOTSTRAP = r""" +set -euo pipefail +export DEBIAN_FRONTEND=noninteractive +apt-get update -qq +apt-get install -qq -y --no-install-recommends git curl ca-certificates xz-utils >/dev/null +# Pinned uv: the cache bucket persists across jobs, and a floating "latest" uv +# reading entries written by an older uv corrupts installs (seen 2026-07-21: +# bam's built-wheel cache entry from a 0.9.x-era job made 0.11.30 fail with +# "The wheel is invalid: Missing .dist-info directory"). +curl -LsSf https://astral.sh/uv/0.11.30/install.sh | sh >/dev/null +export PATH="/root/.local/bin:$PATH" +# HF Jobs met le cache uv et /work sur des FS différents -> uv ne peut pas +# hardlink et son fallback corrompt les wheels construits (bam -> "Missing +# .dist-info directory"). copy = install fiable (le remède que uv suggère). +export UV_LINK_MODE=copy + +mkdir -p /work && cd /work +echo "[bootstrap] extracting source $SRC_TARBALL" +tar -xzf "/src/$SRC_TARBALL" + +echo "[bootstrap] uv sync" +# Self-heal a poisoned persistent cache: a bad entry fails sync +# deterministically, so nuke the cache and rebuild it once before giving up. +uv sync --no-progress || { + echo "[bootstrap] uv sync failed — cleaning uv cache and retrying" + uv cache clean || true + uv sync --no-progress +} + +echo "[bootstrap] launching checkpoint uploader" +mkdir -p logs/rsl_rl +nohup uv run python scripts/hf/uploader.py > /tmp/uploader.log 2>&1 & +UPLOADER_PID=$! + +echo "[bootstrap] starting training: uv run train $TRAIN_ARGS" +set +e +uv run train $TRAIN_ARGS +TRAIN_RC=$? +set -e + +echo "[bootstrap] training exited with code $TRAIN_RC, final upload pass" +# kill watcher loop, then run one final upload pass synchronously +kill $UPLOADER_PID 2>/dev/null || true +CKPT_ONE_SHOT=1 uv run python scripts/hf/uploader.py || true + +# Auto-export the final checkpoint to daemon-ready ONNX while the env is +# still warm — a separate export job would pay the full bootstrap (image +# pull + apt + uv sync) again just to run this one command. Best-effort: +# an export failure must not mark a successful training as failed. +if [ "$TRAIN_RC" -eq 0 ] && [ "${AUTO_EXPORT:-1}" = "1" ]; then + set +e + TASK_ID=${TRAIN_ARGS%% *} + CKPT=$(ls -t logs/rsl_rl/*/model_*.pt 2>/dev/null | head -1) + if [ -n "$CKPT" ]; then + echo "[bootstrap] auto-exporting ONNX from $(basename "$CKPT")" + uv run python scripts/export.py "$TASK_ID" \ + --checkpoint-file "$(basename "$CKPT")" \ + --num-envs 1 --onnx-file /work/policy.onnx \ + && uv run python - <<'PY' +import os +from huggingface_hub import HfApi +HfApi().upload_file(path_or_fileobj="/work/policy.onnx", + path_in_repo="exported/policy.onnx", + repo_id=os.environ["CKPT_REPO"], repo_type="model") +print("[bootstrap] uploaded exported/policy.onnx") +PY + [ $? -ne 0 ] && echo "[bootstrap] auto-export failed (training still OK)" + else + echo "[bootstrap] no checkpoint found, skipping auto-export" + fi + set -e +fi + +exit $TRAIN_RC +""" + + +def _wandb_api_key() -> str | None: + """Best-effort lookup of the user's wandb API key. + + Order: WANDB_API_KEY env -> ~/.netrc (machine api.wandb.ai). + """ + if k := os.environ.get("WANDB_API_KEY"): + return k + try: + n = netrc(str(Path.home() / ".netrc")) + auth = n.authenticators("api.wandb.ai") + if auth and auth[2]: + return auth[2] + except (FileNotFoundError, OSError): + pass + return None + + +def _repo_root() -> Path: + """Repo root of the CURRENT directory — worktree-aware. + + (The old scripts/hf/train_hf.py used the script file's location; resolving + from cwd instead means running from a worktree snapshots that worktree.) + """ + out = subprocess.check_output(["git", "rev-parse", "--show-toplevel"]) + return Path(out.decode().strip()) + + +def _build_tarball(repo_root: Path, out_path: Path) -> str: + """Create a tarball of HEAD + uncommitted tracked changes. Returns short SHA.""" + sha = subprocess.check_output( + ["git", "rev-parse", "--short", "HEAD"], cwd=repo_root + ).decode().strip() + + # Use `git ls-files` so we include tracked-but-modified files (working tree + # state) but skip ignored junk (.venv, logs, *.onnx, wandb/, etc.). + files = subprocess.check_output( + ["git", "ls-files", "-co", "--exclude-standard"], cwd=repo_root + ).decode().splitlines() + + with tarfile.open(out_path, "w:gz") as tar: + for rel in files: + p = repo_root / rel + if p.exists() and p.is_file(): + tar.add(p, arcname=rel) + return sha + + +def _pick_namespace(api: HfApi, preset: str | None) -> str: + """Choose the namespace (personal account or org) the job runs under. + + Interactive prompt unless --namespace was given or there is nothing to + choose. Non-tty (scripts, CI) falls back to the personal account. + """ + info = api.whoami() + user = info.get("name") or info.get("email") + if not user: + raise RuntimeError("Could not determine HF username. Run `hf auth login` first.") + orgs = [o["name"] for o in info.get("orgs", []) if o.get("name")] + + if preset is not None: + if preset not in (user, *orgs): + raise RuntimeError( + f"--namespace {preset!r} is neither your account ({user}) " + f"nor one of your orgs ({', '.join(orgs) or 'none'})." + ) + return preset + + if not orgs: + return user + + if not sys.stdin.isatty(): + print(f"[hf] non-interactive, defaulting to personal namespace: {user}") + return user + + choices = [user, *orgs] + print("[hf] run under which namespace?") + print(f" 1) {user} (personal)") + for i, org in enumerate(orgs, start=2): + print(f" {i}) {org}") + while True: + raw = input(f"Choice [1-{len(choices)}, default 1]: ").strip() + if raw == "": + return user + if raw.isdigit() and 1 <= int(raw) <= len(choices): + return choices[int(raw) - 1] + if raw in choices: + return raw + print(f" invalid choice: {raw!r}") + + +def _await_scheduling( + api: HfApi, job_id: str, namespace: str, budget_s: float = 1200.0 +) -> tuple[str, str | None]: + """Poll until the job leaves SCHEDULING (image pull / queue / mounts). + + This phase legitimately takes minutes (the pytorch image pull alone is + ~5 min on a cold GPU node) and is also where volume-mount failures + surface, ~7 min in ("init container exhausted retries"). Returns + (stage, message) at the first non-SCHEDULING stage, or the last observed + one when the budget runs out (a long queue is not an error — the caller's + streaming loop keeps supervising). + """ + deadline = time.monotonic() + budget_s + last_note = time.monotonic() + stage, message = "", None + while time.monotonic() < deadline: + status = api.inspect_job(job_id=job_id, namespace=namespace).status + stage, message = status.stage, status.message + if stage and stage != "SCHEDULING": + return stage, message + if time.monotonic() - last_note > 60: + print("[job] still scheduling (queue / image pull / volume mounts)...") + last_note = time.monotonic() + time.sleep(10) + return stage, message + + +def submit(argv: list[str]) -> int: + """Parse submission args from ``argv`` and launch the HF job.""" + ap = argparse.ArgumentParser( + prog="train --hf-jobs", + description="Submit a microduck training run to HF Jobs.", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + ap.add_argument("task", help="mjlab task id, e.g. Mjlab-Kick-Flat-MicroDuck") + ap.add_argument("--flavor", default=DEFAULT_FLAVOR, help="HF Jobs hardware flavor") + ap.add_argument("--image", default=DEFAULT_IMAGE, help="Docker image to run in") + ap.add_argument("--timeout", default=DEFAULT_TIMEOUT, help="Job max duration") + ap.add_argument( + "--namespace", + default=None, + help="HF namespace (your username or an org) to run under; skips the prompt.", + ) + ap.add_argument( + "--run-name", + default=None, + help="Short tag for this run; defaults to task+timestamp", + ) + ap.add_argument( + "--detach", action="store_true", + help="Submit and return immediately (do not stream logs).", + ) + ap.add_argument( + "--dry-run", action="store_true", + help="Build tarball and print the job spec without submitting.", + ) + ap.add_argument( + "--src-repo", + default=None, + help="HF dataset repo for source tarballs. Defaults to /mjlab-microduck-src", + ) + ap.add_argument( + "--ckpt-repo", + default=None, + help="HF model repo for checkpoints. Defaults to /", + ) + ap.add_argument( + "--uv-cache-bucket", + default=None, + help="HF bucket used as UV_CACHE_DIR to persist wheels across runs. " + "Defaults to /mjlab-uv-cache. Requires --uv-cache.", + ) + ap.add_argument( + "--uv-cache", action="store_true", + help="Mount a persistent uv cache bucket (OFF by default: the FUSE " + "bucket mount does not support hardlinks, so `uv sync` falls back " + "to full-copying ~6 GB of unpacked packages through the network " + "mount — far slower than just re-downloading wheels from PyPI, " + "which HF's datacenter bandwidth handles in ~1 min; it also " + "poisons across uv versions: a 0.9.x-era entry made 0.11.30 die " + "with 'wheel is invalid: Missing .dist-info', 2026-07-21).", + ) + ap.add_argument( + "--no-wandb", action="store_true", + help="Do not forward a wandb API key (training will fail if wandb is enabled).", + ) + args, train_args = ap.parse_known_args(argv) + + api = HfApi() + try: + namespace = _pick_namespace(api, args.namespace) + except Exception as e: + print(f"error: {e}", file=sys.stderr) + return 1 + print(f"[hf] namespace: {namespace}") + + token = get_token() + if not token: + print("error: no cached HF token. Run `hf auth login`.", file=sys.stderr) + return 1 + + repo_root = _repo_root() + stamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S") + run_name = args.run_name or f"{args.task}-{stamp}".lower() + src_repo = args.src_repo or f"{namespace}/mjlab-microduck-src" + ckpt_repo = args.ckpt_repo or f"{namespace}/{run_name}" + + env: dict[str, str] = { + "CKPT_REPO": ckpt_repo, + "TRAIN_ARGS": " ".join(shlex.quote(a) for a in [args.task, *train_args]), + } + secrets: dict[str, str] = {"HF_TOKEN": token} + + # Forward wandb credentials (env var, then ~/.netrc) + if not args.no_wandb: + wb_key = _wandb_api_key() + if not wb_key: + print( + "[wandb] ✗ no API key found (checked $WANDB_API_KEY and ~/.netrc).\n" + " Run `wandb login` locally, or pass --no-wandb to skip.", + file=sys.stderr, + ) + return 1 + secrets["WANDB_API_KEY"] = wb_key + src = "env" if os.environ.get("WANDB_API_KEY") else "~/.netrc" + print(f"[wandb] forwarding API key from {src}") + for k in ("WANDB_PROJECT", "WANDB_ENTITY"): + if os.environ.get(k): + env[k] = os.environ[k] + + volumes = [Volume(type="dataset", source=src_repo, mount_path="/src", read_only=True)] + + # Persistent uv cache — opt-in via --uv-cache (see the flag's help text: + # cross-filesystem installs from the FUSE bucket are slower than fresh + # PyPI downloads, and a stale entry deterministically killed uv sync on + # 2026-07-21). + cache_bucket: str | None = None + if args.uv_cache: + cache_bucket = args.uv_cache_bucket or f"{namespace}/mjlab-uv-cache" + volumes.append(Volume(type="bucket", source=cache_bucket, mount_path="/uv-cache")) + env["UV_CACHE_DIR"] = "/uv-cache" + print(f"[uv-cache] using bucket {cache_bucket}") + + # 1. Build tarball + with tempfile.TemporaryDirectory() as td: + tar_path = Path(td) / f"src-{stamp}.tar.gz" + print(f"[src] building tarball -> {tar_path.name} (from {repo_root})") + sha = _build_tarball(repo_root, tar_path) + size_mb = tar_path.stat().st_size / 1e6 + print(f"[src] HEAD={sha}, {size_mb:.1f} MB") + env["SRC_TARBALL"] = tar_path.name + env["GIT_SHA"] = sha + + if args.dry_run: + print("[dry-run] would submit job:") + print(f" namespace: {namespace}") + print(f" image: {args.image}") + print(f" flavor: {args.flavor}, timeout: {args.timeout}") + print(f" volumes: {[f'{v.type}:{v.source} -> {v.mount_path}' for v in volumes]}") + print(f" env: { {k: v for k, v in env.items()} }") + print(f" secrets: { {k: '***' for k in secrets} }") + print(f" ckpt repo: https://huggingface.co/{ckpt_repo}") + return 0 + + # 2. Upload tarball + pre-create repos/bucket + api.create_repo(src_repo, repo_type="dataset", private=True, exist_ok=True) + print(f"[src] uploading to dataset {src_repo}") + api.upload_file( + path_or_fileobj=str(tar_path), + path_in_repo=tar_path.name, + repo_id=src_repo, + repo_type="dataset", + ) + api.create_repo(ckpt_repo, repo_type="model", private=True, exist_ok=True) + if cache_bucket is not None: + api.create_bucket(cache_bucket, private=True, exist_ok=True) + + # 3. Submit. GPU-node volume mounts fail transiently ("init container + # exhausted retries", surfacing ~7 min into SCHEDULING — observed + # 2026-07 while an identical probe job mounted fine at the same time). + # Supervise the scheduling phase and resubmit on a mount failure. + print(f"[ckpt] checkpoints -> https://huggingface.co/{ckpt_repo}") + print(f"[job] submitting (namespace={namespace}, flavor={args.flavor}, timeout={args.timeout})") + job = None + stage, message = "", None + for attempt in range(3): + if attempt: + print(f"[job] ✗ volume mount failed (flaky node) — resubmitting ({attempt + 1}/3)") + time.sleep(10) + try: + job = api.run_job( + image=args.image, + command=["bash", "-c", BOOTSTRAP], + env=env, + secrets=secrets, + flavor=args.flavor, + timeout=args.timeout, + volumes=volumes, + namespace=namespace, + ) + except Exception as e: + msg = str(e) + if "402" in msg or "Payment Required" in msg or "credit" in msg.lower(): + print( + "\n[job] ✗ Hugging Face Jobs billing error.\n" + f" The namespace {namespace!r} has insufficient Jobs credits.\n" + " → Add credits: https://huggingface.co/settings/billing\n" + " → Or get HF Pro: https://huggingface.co/settings/billing/subscription", + file=sys.stderr, + ) + return 2 + if "403" in msg or "Forbidden" in msg or "required permissions" in msg.lower(): + print( + "\n[job] ✗ Hugging Face Jobs permission error (403).\n" + " Your HF token authenticates fine but is NOT allowed to use the Jobs API\n" + f" for namespace {namespace!r}. This is a token-scope problem, not billing.\n" + " → Create/edit a fine-grained token WITH the Jobs permission enabled:\n" + " https://huggingface.co/settings/tokens\n" + " (fine-grained → under your user AND/OR the org, tick the 'Jobs' permissions),\n" + " then re-login locally: hf auth login\n" + " → Verify: python -c \"from huggingface_hub import HfApi; \"\n" + " \"print(list(HfApi().list_jobs(namespace='')))\" (must not 403)\n" + " (If Jobs are enabled but still blocked, the namespace may also need an HF\n" + " plan/credits that include Jobs — see the billing link above.)", + file=sys.stderr, + ) + return 3 + raise + print(f"[job] id: {job.id}") + if getattr(job, "url", None): + print(f"[job] url: {job.url}") + if args.detach: + print("[job] --detach: not supervising startup — check the URL above; " + "transient 'Volume mount failed' errors need a manual resubmit.") + return 0 + stage, message = _await_scheduling(api, job.id, namespace) + if stage == "ERROR" and "mount" in (message or "").lower(): + continue # flaky node — resubmit + break + + assert job is not None + if stage == "ERROR": + print(f"[job] ✗ failed to start: {message}", file=sys.stderr) + return 1 + + # Supervise to completion: stream logs, re-attach if the stream drops + # (it returns empty while the container is still starting), and report + # the terminal status. + print("[job] streaming logs (Ctrl-C detaches; the job keeps running)") + try: + while True: + try: + for line in api.fetch_job_logs(job_id=job.id, namespace=namespace, follow=True): + print(line) + except Exception as e: + print(f"[job] log stream dropped ({e}); re-attaching") + status = api.inspect_job(job_id=job.id, namespace=namespace).status + if status.stage == "COMPLETED": + print("[job] ✓ completed") + return 0 + if status.stage in ("ERROR", "DELETED", "CANCELED"): + print(f"[job] ✗ {status.stage}: {status.message}", file=sys.stderr) + return 1 + time.sleep(10) + except KeyboardInterrupt: + print(f"\n[job] detached. Job {job.id} is still running: {getattr(job, 'url', job.id)}") + return 0 diff --git a/src/mjlab_microduck/robot/__init__.py b/src/mjlab_microduck/robot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/mjlab_microduck/robot/microduck/add_backlash.py b/src/mjlab_microduck/robot/microduck/add_backlash.py new file mode 100644 index 0000000..aa35e16 --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/add_backlash.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Inject gearbox-backlash joints into an onshape-to-robot MJCF export. + +For every actuated servo joint (``class="chosen_actuator"``) this inserts an +unactuated hinge on the same body / same axis right after it: + + + + +The composite link rotation is main + backlash: the main joint is the servo +output (BAM drives it), the backlash joint is the play between the servo and +the link, free to wander within ±(backlash/2). + +Naming: the ``passive_`` prefix means the new joints are automatically excluded +by every existing regex in the task configs (actuators ``^(?!passive_).*``, +joint obs, pose reward). The encoder-through-backlash handling lives on the +mjlab side (BacklashEncoderBamActuatorCfg + joint_pos/vel_rel_backlash obs). + +Meant to run as the LAST post_import_command of an onshape-to-robot config +(see config_mjcf_allcollisions_backlash.json), but works standalone on any +already-exported robot xml: + + python3 add_backlash.py robot_allcollisions_backlash.xml --backlash-deg 2.0 + +``--backlash-deg`` is the TOTAL peak-to-peak play (what you measure wiggling +the horn with the servo held); the joint range is symmetric ±deg/2. +""" + +import argparse +import math +import re +import sys + +JOINT_RE = re.compile(r'^(\s*)]*/>\s*$') +ATTR_RE = re.compile(r'(\w+)="([^"]*)"') + + +def build_backlash_default(half_range_rad: float, damping: float, + armature: float, frictionloss: float, + total_deg: float) -> str: + return ( + f" \n" + f" \n" + f" \n" + f" \n" + f" \n" + f" \n" + f" \n" + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("xml", help="MJCF file to modify in place") + parser.add_argument("--backlash-deg", type=float, default=2.0, + help="TOTAL backlash play in degrees (peak-to-peak); " + "joint range is symmetric +/-deg/2 (default: 2.0)") + parser.add_argument("--damping", type=float, default=0.01, + help="backlash joint damping (default: 0.01)") + parser.add_argument("--armature", type=float, default=0.001, + help="backlash joint armature, kept small but non-zero " + "for solver conditioning (default: 0.001)") + parser.add_argument("--frictionloss", type=float, default=0.0, + help="backlash joint frictionloss (default: 0)") + parser.add_argument("--joint-class", default="chosen_actuator", + help="default class of the joints that get backlash " + "(default: chosen_actuator)") + parser.add_argument("--exclude", default=None, + help="optional regex of joint names to skip " + "(e.g. '.*(neck|head).*')") + args = parser.parse_args() + + half_range = math.radians(args.backlash_deg) / 2.0 + exclude = re.compile(args.exclude) if args.exclude else None + + with open(args.xml) as f: + lines = f.readlines() + + if any('class="backlash"' in line for line in lines): + print(f"[add_backlash] {args.xml} already contains backlash joints — aborting.") + return 1 + + out = [] + added = [] + default_inserted = False + for line in lines: + # Insert the defaults block right before . + if not default_inserted and "" in line: + out.append(build_backlash_default( + half_range, args.damping, args.armature, args.frictionloss, + args.backlash_deg)) + default_inserted = True + + out.append(line) + + m = JOINT_RE.match(line) + if m is None: + continue + attrs = dict(ATTR_RE.findall(line)) + if attrs.get("class") != args.joint_class: + continue + name = attrs.get("name") + if not name or (exclude and exclude.match(name)): + continue + indent = m.group(1) + axis = attrs.get("axis", "0 0 1") + pos = f' pos="{attrs["pos"]}"' if "pos" in attrs else "" + out.append( + f'{indent}\n' + ) + added.append(name) + + if not default_inserted: + print("[add_backlash] ERROR: no found — is this an MJCF file?") + return 1 + if not added: + print(f"[add_backlash] ERROR: no joints with class=\"{args.joint_class}\" found.") + return 1 + + with open(args.xml, "w") as f: + f.writelines(out) + + print(f"[add_backlash] added {len(added)} backlash joints " + f"(+/-{args.backlash_deg / 2:g} deg = +/-{half_range:.5f} rad) to {args.xml}: " + f"{', '.join(added)}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/mjlab_microduck/robot/microduck/additional.xml b/src/mjlab_microduck/robot/microduck/additional.xml new file mode 100644 index 0000000..25cc7d8 --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/additional.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/mjlab_microduck/robot/microduck/assets/ankle_l_v1.part b/src/mjlab_microduck/robot/microduck/assets/ankle_l_v1.part new file mode 100644 index 0000000..34173cf --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/assets/ankle_l_v1.part @@ -0,0 +1,13 @@ +{ + "configuration": "default", + "documentId": "804927696f06d877f3f1803e", + "documentMicroversion": "fc8237658b0c3e9ada6f813b", + "elementId": "a48e2e3940da29620aa227db", + "fullConfiguration": "default", + "id": "MnipPqhb15cTaf16d", + "isStandardContent": false, + "name": "ankle-l-v1 <1>", + "partId": "RbFD", + "suppressed": false, + "type": "Part" +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/microduck/assets/ankle_l_v1.stl b/src/mjlab_microduck/robot/microduck/assets/ankle_l_v1.stl new file mode 100644 index 0000000..1c6f20d Binary files /dev/null and b/src/mjlab_microduck/robot/microduck/assets/ankle_l_v1.stl differ diff --git a/src/mjlab_microduck/robot/microduck/assets/ankle_left.part b/src/mjlab_microduck/robot/microduck/assets/ankle_left.part new file mode 100644 index 0000000..bee2818 --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/assets/ankle_left.part @@ -0,0 +1,13 @@ +{ + "configuration": "default", + "documentId": "804927696f06d877f3f1803e", + "documentMicroversion": "48ccc523b8461dff097d59f9", + "elementId": "451317b8e3aacc7f88e8f8ed", + "fullConfiguration": "default", + "id": "MnipPqhb15cTaf16d", + "isStandardContent": false, + "name": "ankle_left <1>", + "partId": "RTBH", + "suppressed": false, + "type": "Part" +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/microduck/assets/ankle_left.stl b/src/mjlab_microduck/robot/microduck/assets/ankle_left.stl new file mode 100644 index 0000000..712488c Binary files /dev/null and b/src/mjlab_microduck/robot/microduck/assets/ankle_left.stl differ diff --git a/src/mjlab_microduck/robot/microduck/assets/ankle_r_v1.part b/src/mjlab_microduck/robot/microduck/assets/ankle_r_v1.part new file mode 100644 index 0000000..0208428 --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/assets/ankle_r_v1.part @@ -0,0 +1,13 @@ +{ + "configuration": "default", + "documentId": "804927696f06d877f3f1803e", + "documentMicroversion": "fc8237658b0c3e9ada6f813b", + "elementId": "a48e2e3940da29620aa227db", + "fullConfiguration": "default", + "id": "M/kz+prC94C355xoU", + "isStandardContent": false, + "name": "ankle_r_v1 <1>", + "partId": "RNHH", + "suppressed": false, + "type": "Part" +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/microduck/assets/ankle_r_v1.stl b/src/mjlab_microduck/robot/microduck/assets/ankle_r_v1.stl new file mode 100644 index 0000000..562399b Binary files /dev/null and b/src/mjlab_microduck/robot/microduck/assets/ankle_r_v1.stl differ diff --git a/src/mjlab_microduck/robot/microduck/assets/ankle_right.part b/src/mjlab_microduck/robot/microduck/assets/ankle_right.part new file mode 100644 index 0000000..f201459 --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/assets/ankle_right.part @@ -0,0 +1,13 @@ +{ + "configuration": "default", + "documentId": "804927696f06d877f3f1803e", + "documentMicroversion": "48ccc523b8461dff097d59f9", + "elementId": "451317b8e3aacc7f88e8f8ed", + "fullConfiguration": "default", + "id": "M/kz+prC94C355xoU", + "isStandardContent": false, + "name": "ankle_right <1>", + "partId": "R8CD", + "suppressed": false, + "type": "Part" +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/microduck/assets/ankle_right.stl b/src/mjlab_microduck/robot/microduck/assets/ankle_right.stl new file mode 100644 index 0000000..e60bbf2 Binary files /dev/null and b/src/mjlab_microduck/robot/microduck/assets/ankle_right.stl differ diff --git a/src/mjlab_microduck/robot/microduck/assets/banana_pcb_locker.part b/src/mjlab_microduck/robot/microduck/assets/banana_pcb_locker.part new file mode 100644 index 0000000..601be5d --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/assets/banana_pcb_locker.part @@ -0,0 +1,13 @@ +{ + "configuration": "default", + "documentId": "804927696f06d877f3f1803e", + "documentMicroversion": "fc8237658b0c3e9ada6f813b", + "elementId": "d6fcdccc8b25aaa256e7e213", + "fullConfiguration": "default", + "id": "MBeyWD9BcBiAC88kr", + "isStandardContent": false, + "name": "Banana_PCB_locker <1>", + "partId": "RfDD", + "suppressed": false, + "type": "Part" +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/microduck/assets/banana_pcb_locker.stl b/src/mjlab_microduck/robot/microduck/assets/banana_pcb_locker.stl new file mode 100644 index 0000000..b7b5215 Binary files /dev/null and b/src/mjlab_microduck/robot/microduck/assets/banana_pcb_locker.stl differ diff --git a/src/mjlab_microduck/robot/microduck/assets/bearing_roll.part b/src/mjlab_microduck/robot/microduck/assets/bearing_roll.part new file mode 100644 index 0000000..e0730bc --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/assets/bearing_roll.part @@ -0,0 +1,13 @@ +{ + "configuration": "default", + "documentId": "804927696f06d877f3f1803e", + "documentMicroversion": "fc8237658b0c3e9ada6f813b", + "elementId": "d6fcdccc8b25aaa256e7e213", + "fullConfiguration": "default", + "id": "MnzpqZwaCaK4P1f41", + "isStandardContent": false, + "name": "bearing_roll <1>", + "partId": "RKBD", + "suppressed": false, + "type": "Part" +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/microduck/assets/bearing_roll.stl b/src/mjlab_microduck/robot/microduck/assets/bearing_roll.stl new file mode 100644 index 0000000..a814312 Binary files /dev/null and b/src/mjlab_microduck/robot/microduck/assets/bearing_roll.stl differ diff --git a/src/mjlab_microduck/robot/microduck/assets/bottom_head_shell.part b/src/mjlab_microduck/robot/microduck/assets/bottom_head_shell.part new file mode 100644 index 0000000..0341ac8 --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/assets/bottom_head_shell.part @@ -0,0 +1,13 @@ +{ + "configuration": "default", + "documentId": "804927696f06d877f3f1803e", + "documentMicroversion": "fc8237658b0c3e9ada6f813b", + "elementId": "cceb83ef371fcd79b077022f", + "fullConfiguration": "default", + "id": "MlY/PdlncxN6Lc433", + "isStandardContent": false, + "name": "bottom_head_shell <2>", + "partId": "J/H", + "suppressed": false, + "type": "Part" +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/microduck/assets/bottom_head_shell.stl b/src/mjlab_microduck/robot/microduck/assets/bottom_head_shell.stl new file mode 100644 index 0000000..643f110 Binary files /dev/null and b/src/mjlab_microduck/robot/microduck/assets/bottom_head_shell.stl differ diff --git a/src/mjlab_microduck/robot/microduck/assets/elec_rpi_robot_hat_pcb.part b/src/mjlab_microduck/robot/microduck/assets/elec_rpi_robot_hat_pcb.part new file mode 100644 index 0000000..c6ff4d5 --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/assets/elec_rpi_robot_hat_pcb.part @@ -0,0 +1,13 @@ +{ + "configuration": "default", + "documentId": "804927696f06d877f3f1803e", + "documentMicroversion": "fc8237658b0c3e9ada6f813b", + "elementId": "c1085f5be782740f05cd18b2", + "fullConfiguration": "default", + "id": "MReezno9wlhkqhOa/", + "isStandardContent": false, + "name": "elec_RPI_Robot_HAT_PCB <1>", + "partId": "JFD", + "suppressed": false, + "type": "Part" +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/microduck/assets/elec_rpi_robot_hat_pcb.stl b/src/mjlab_microduck/robot/microduck/assets/elec_rpi_robot_hat_pcb.stl new file mode 100644 index 0000000..09048bc Binary files /dev/null and b/src/mjlab_microduck/robot/microduck/assets/elec_rpi_robot_hat_pcb.stl differ diff --git a/src/mjlab_microduck/robot/microduck/assets/face_part.part b/src/mjlab_microduck/robot/microduck/assets/face_part.part new file mode 100644 index 0000000..23f67eb --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/assets/face_part.part @@ -0,0 +1,13 @@ +{ + "configuration": "default", + "documentId": "804927696f06d877f3f1803e", + "documentMicroversion": "fc8237658b0c3e9ada6f813b", + "elementId": "cceb83ef371fcd79b077022f", + "fullConfiguration": "default", + "id": "M/rAeuKSeRbB6zGHy", + "isStandardContent": false, + "name": "face_part <1>", + "partId": "RQBD", + "suppressed": false, + "type": "Part" +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/microduck/assets/face_part.stl b/src/mjlab_microduck/robot/microduck/assets/face_part.stl new file mode 100644 index 0000000..0a27859 Binary files /dev/null and b/src/mjlab_microduck/robot/microduck/assets/face_part.stl differ diff --git a/src/mjlab_microduck/robot/microduck/assets/foot_left.part b/src/mjlab_microduck/robot/microduck/assets/foot_left.part new file mode 100644 index 0000000..fe3f22c --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/assets/foot_left.part @@ -0,0 +1,13 @@ +{ + "configuration": "default", + "documentId": "804927696f06d877f3f1803e", + "documentMicroversion": "48ccc523b8461dff097d59f9", + "elementId": "451317b8e3aacc7f88e8f8ed", + "fullConfiguration": "default", + "id": "MWwuFYNVBMata0ArD", + "isStandardContent": false, + "name": "foot_left <1>", + "partId": "RACD", + "suppressed": false, + "type": "Part" +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/microduck/assets/foot_left.stl b/src/mjlab_microduck/robot/microduck/assets/foot_left.stl new file mode 100644 index 0000000..43155ba Binary files /dev/null and b/src/mjlab_microduck/robot/microduck/assets/foot_left.stl differ diff --git a/src/mjlab_microduck/robot/microduck/assets/foot_right.part b/src/mjlab_microduck/robot/microduck/assets/foot_right.part new file mode 100644 index 0000000..dfeb4fd --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/assets/foot_right.part @@ -0,0 +1,13 @@ +{ + "configuration": "default", + "documentId": "804927696f06d877f3f1803e", + "documentMicroversion": "48ccc523b8461dff097d59f9", + "elementId": "451317b8e3aacc7f88e8f8ed", + "fullConfiguration": "default", + "id": "Mf+5HUbxEOCokX7m4", + "isStandardContent": false, + "name": "foot_right <1>", + "partId": "R8CL", + "suppressed": false, + "type": "Part" +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/microduck/assets/foot_right.stl b/src/mjlab_microduck/robot/microduck/assets/foot_right.stl new file mode 100644 index 0000000..6971bbc Binary files /dev/null and b/src/mjlab_microduck/robot/microduck/assets/foot_right.stl differ diff --git a/src/mjlab_microduck/robot/microduck/assets/hip_l.part b/src/mjlab_microduck/robot/microduck/assets/hip_l.part new file mode 100644 index 0000000..0b59d97 --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/assets/hip_l.part @@ -0,0 +1,13 @@ +{ + "configuration": "default", + "documentId": "804927696f06d877f3f1803e", + "documentMicroversion": "fc8237658b0c3e9ada6f813b", + "elementId": "451317b8e3aacc7f88e8f8ed", + "fullConfiguration": "default", + "id": "M+QhBpslsJGbwRNpO", + "isStandardContent": false, + "name": "hip_l <1>", + "partId": "RODD", + "suppressed": false, + "type": "Part" +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/microduck/assets/hip_l.stl b/src/mjlab_microduck/robot/microduck/assets/hip_l.stl new file mode 100644 index 0000000..5162239 Binary files /dev/null and b/src/mjlab_microduck/robot/microduck/assets/hip_l.stl differ diff --git a/src/mjlab_microduck/robot/microduck/assets/jaw.part b/src/mjlab_microduck/robot/microduck/assets/jaw.part new file mode 100644 index 0000000..f54b09e --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/assets/jaw.part @@ -0,0 +1,13 @@ +{ + "configuration": "default", + "documentId": "804927696f06d877f3f1803e", + "documentMicroversion": "fc8237658b0c3e9ada6f813b", + "elementId": "cceb83ef371fcd79b077022f", + "fullConfiguration": "default", + "id": "MaQI9NUJXlumL8U6p", + "isStandardContent": false, + "name": "jaw <2>", + "partId": "J6D", + "suppressed": false, + "type": "Part" +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/microduck/assets/jaw.stl b/src/mjlab_microduck/robot/microduck/assets/jaw.stl new file mode 100644 index 0000000..d7b0e12 Binary files /dev/null and b/src/mjlab_microduck/robot/microduck/assets/jaw.stl differ diff --git a/src/mjlab_microduck/robot/microduck/assets/jaw_soft.part b/src/mjlab_microduck/robot/microduck/assets/jaw_soft.part new file mode 100644 index 0000000..bc030cb --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/assets/jaw_soft.part @@ -0,0 +1,14 @@ +{ + "configuration": "default", + "documentId": "804927696f06d877f3f1803e", + "documentMicroversion": "9306fb07735e782de209a230", + "documentVersion": "f0b9dc2ed2b65290c37daecc", + "elementId": "cceb83ef371fcd79b077022f", + "fullConfiguration": "default", + "id": "M4M8XlOWpIPvmIr8/", + "isStandardContent": false, + "name": "jaw_soft <1>", + "partId": "RaCD", + "suppressed": false, + "type": "Part" +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/microduck/assets/jaw_soft.stl b/src/mjlab_microduck/robot/microduck/assets/jaw_soft.stl new file mode 100644 index 0000000..3c8bb13 Binary files /dev/null and b/src/mjlab_microduck/robot/microduck/assets/jaw_soft.stl differ diff --git a/src/mjlab_microduck/robot/microduck/assets/left_shell.part b/src/mjlab_microduck/robot/microduck/assets/left_shell.part new file mode 100644 index 0000000..66c25a1 --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/assets/left_shell.part @@ -0,0 +1,13 @@ +{ + "configuration": "default", + "documentId": "804927696f06d877f3f1803e", + "documentMicroversion": "fc8237658b0c3e9ada6f813b", + "elementId": "d6fcdccc8b25aaa256e7e213", + "fullConfiguration": "default", + "id": "Mx9wCEl1i0Lb7LDyx", + "isStandardContent": false, + "name": "left_shell <1>", + "partId": "RiED", + "suppressed": false, + "type": "Part" +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/microduck/assets/left_shell.stl b/src/mjlab_microduck/robot/microduck/assets/left_shell.stl new file mode 100644 index 0000000..9cb1656 Binary files /dev/null and b/src/mjlab_microduck/robot/microduck/assets/left_shell.stl differ diff --git a/src/mjlab_microduck/robot/microduck/assets/left_upper_leg.part b/src/mjlab_microduck/robot/microduck/assets/left_upper_leg.part new file mode 100644 index 0000000..3368919 --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/assets/left_upper_leg.part @@ -0,0 +1,13 @@ +{ + "configuration": "default", + "documentId": "804927696f06d877f3f1803e", + "documentMicroversion": "49900cb439825f734c36e098", + "elementId": "d6fcdccc8b25aaa256e7e213", + "fullConfiguration": "default", + "id": "MCzyiRc5ESe5zlNkJ", + "isStandardContent": false, + "name": "left_upper_leg <1>", + "partId": "RoCD", + "suppressed": false, + "type": "Part" +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/microduck/assets/left_upper_leg.stl b/src/mjlab_microduck/robot/microduck/assets/left_upper_leg.stl new file mode 100644 index 0000000..b02cccb Binary files /dev/null and b/src/mjlab_microduck/robot/microduck/assets/left_upper_leg.stl differ diff --git a/src/mjlab_microduck/robot/microduck/assets/leg.part b/src/mjlab_microduck/robot/microduck/assets/leg.part new file mode 100644 index 0000000..0cd4c37 --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/assets/leg.part @@ -0,0 +1,13 @@ +{ + "configuration": "default", + "documentId": "804927696f06d877f3f1803e", + "documentMicroversion": "fc8237658b0c3e9ada6f813b", + "elementId": "451317b8e3aacc7f88e8f8ed", + "fullConfiguration": "default", + "id": "MS3g6LBmQDJMHE7cl", + "isStandardContent": false, + "name": "leg <2>", + "partId": "R1ED", + "suppressed": false, + "type": "Part" +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/microduck/assets/leg.stl b/src/mjlab_microduck/robot/microduck/assets/leg.stl new file mode 100644 index 0000000..cc46130 Binary files /dev/null and b/src/mjlab_microduck/robot/microduck/assets/leg.stl differ diff --git a/src/mjlab_microduck/robot/microduck/assets/lens.part b/src/mjlab_microduck/robot/microduck/assets/lens.part new file mode 100644 index 0000000..f7c75e5 --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/assets/lens.part @@ -0,0 +1,13 @@ +{ + "configuration": "default", + "documentId": "804927696f06d877f3f1803e", + "documentMicroversion": "fc8237658b0c3e9ada6f813b", + "elementId": "42eb36bcd4029eaa3ed8308f", + "fullConfiguration": "default", + "id": "M/ROtwU5VWibkq8MR", + "isStandardContent": false, + "name": "Lens <1>", + "partId": "JFD", + "suppressed": false, + "type": "Part" +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/microduck/assets/lens.stl b/src/mjlab_microduck/robot/microduck/assets/lens.stl new file mode 100644 index 0000000..beff905 Binary files /dev/null and b/src/mjlab_microduck/robot/microduck/assets/lens.stl differ diff --git a/src/mjlab_microduck/robot/microduck/assets/m12_lens_holder.part b/src/mjlab_microduck/robot/microduck/assets/m12_lens_holder.part new file mode 100644 index 0000000..93a0a23 --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/assets/m12_lens_holder.part @@ -0,0 +1,13 @@ +{ + "configuration": "default", + "documentId": "804927696f06d877f3f1803e", + "documentMicroversion": "fc8237658b0c3e9ada6f813b", + "elementId": "42eb36bcd4029eaa3ed8308f", + "fullConfiguration": "default", + "id": "MqHplZVsJg1E257Bg", + "isStandardContent": false, + "name": "M12 lens holder <1>", + "partId": "JFH", + "suppressed": false, + "type": "Part" +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/microduck/assets/m12_lens_holder.stl b/src/mjlab_microduck/robot/microduck/assets/m12_lens_holder.stl new file mode 100644 index 0000000..23a0143 Binary files /dev/null and b/src/mjlab_microduck/robot/microduck/assets/m12_lens_holder.stl differ diff --git a/src/mjlab_microduck/robot/microduck/assets/motor_support.part b/src/mjlab_microduck/robot/microduck/assets/motor_support.part new file mode 100644 index 0000000..bf25f38 --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/assets/motor_support.part @@ -0,0 +1,13 @@ +{ + "configuration": "default", + "documentId": "804927696f06d877f3f1803e", + "documentMicroversion": "fc8237658b0c3e9ada6f813b", + "elementId": "cceb83ef371fcd79b077022f", + "fullConfiguration": "default", + "id": "M5y2lEokqUzNhieFD", + "isStandardContent": false, + "name": "motor_support <2>", + "partId": "RYGD", + "suppressed": false, + "type": "Part" +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/microduck/assets/motor_support.stl b/src/mjlab_microduck/robot/microduck/assets/motor_support.stl new file mode 100644 index 0000000..810f089 Binary files /dev/null and b/src/mjlab_microduck/robot/microduck/assets/motor_support.stl differ diff --git a/src/mjlab_microduck/robot/microduck/assets/neck.part b/src/mjlab_microduck/robot/microduck/assets/neck.part new file mode 100644 index 0000000..a046c8b --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/assets/neck.part @@ -0,0 +1,13 @@ +{ + "configuration": "default", + "documentId": "804927696f06d877f3f1803e", + "documentMicroversion": "fc8237658b0c3e9ada6f813b", + "elementId": "d6fcdccc8b25aaa256e7e213", + "fullConfiguration": "default", + "id": "Mp1N9lFNNnGzVouAX", + "isStandardContent": false, + "name": "neck <2>", + "partId": "R6BD", + "suppressed": false, + "type": "Part" +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/microduck/assets/neck.stl b/src/mjlab_microduck/robot/microduck/assets/neck.stl new file mode 100644 index 0000000..ce34b0b Binary files /dev/null and b/src/mjlab_microduck/robot/microduck/assets/neck.stl differ diff --git a/src/mjlab_microduck/robot/microduck/assets/neck_pitch.part b/src/mjlab_microduck/robot/microduck/assets/neck_pitch.part new file mode 100644 index 0000000..038de48 --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/assets/neck_pitch.part @@ -0,0 +1,14 @@ +{ + "configuration": "default", + "documentId": "804927696f06d877f3f1803e", + "documentMicroversion": "9306fb07735e782de209a230", + "documentVersion": "f0b9dc2ed2b65290c37daecc", + "elementId": "cceb83ef371fcd79b077022f", + "fullConfiguration": "default", + "id": "MLZ8mVman8YHTKSXj", + "isStandardContent": false, + "name": "neck_pitch <1>", + "partId": "RdDD", + "suppressed": false, + "type": "Part" +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/microduck/assets/neck_pitch.stl b/src/mjlab_microduck/robot/microduck/assets/neck_pitch.stl new file mode 100644 index 0000000..1fd0ce0 Binary files /dev/null and b/src/mjlab_microduck/robot/microduck/assets/neck_pitch.stl differ diff --git a/src/mjlab_microduck/robot/microduck/assets/noenoeil.part b/src/mjlab_microduck/robot/microduck/assets/noenoeil.part new file mode 100644 index 0000000..58af762 --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/assets/noenoeil.part @@ -0,0 +1,13 @@ +{ + "configuration": "default", + "documentId": "804927696f06d877f3f1803e", + "documentMicroversion": "fc8237658b0c3e9ada6f813b", + "elementId": "cceb83ef371fcd79b077022f", + "fullConfiguration": "default", + "id": "MYFat2pb9IuxkqfjY", + "isStandardContent": false, + "name": "noenoeil <1>", + "partId": "RALD", + "suppressed": false, + "type": "Part" +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/microduck/assets/noenoeil.stl b/src/mjlab_microduck/robot/microduck/assets/noenoeil.stl new file mode 100644 index 0000000..40ef4d0 Binary files /dev/null and b/src/mjlab_microduck/robot/microduck/assets/noenoeil.stl differ diff --git a/src/mjlab_microduck/robot/microduck/assets/np_f970.part b/src/mjlab_microduck/robot/microduck/assets/np_f970.part new file mode 100644 index 0000000..2525f4d --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/assets/np_f970.part @@ -0,0 +1,13 @@ +{ + "configuration": "default", + "documentId": "804927696f06d877f3f1803e", + "documentMicroversion": "fc8237658b0c3e9ada6f813b", + "elementId": "2e7ae1b0d9ba087c7bcd41de", + "fullConfiguration": "default", + "id": "MRjnbJSLuN6LprqOg", + "isStandardContent": false, + "name": "NP-F970 <1>", + "partId": "JFD", + "suppressed": false, + "type": "Part" +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/microduck/assets/np_f970.stl b/src/mjlab_microduck/robot/microduck/assets/np_f970.stl new file mode 100644 index 0000000..1ba95ee Binary files /dev/null and b/src/mjlab_microduck/robot/microduck/assets/np_f970.stl differ diff --git a/src/mjlab_microduck/robot/microduck/assets/pcb__raspberry_pi_zero_2_w.part b/src/mjlab_microduck/robot/microduck/assets/pcb__raspberry_pi_zero_2_w.part new file mode 100644 index 0000000..b1b137d --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/assets/pcb__raspberry_pi_zero_2_w.part @@ -0,0 +1,13 @@ +{ + "configuration": "default", + "documentId": "804927696f06d877f3f1803e", + "documentMicroversion": "fc8237658b0c3e9ada6f813b", + "elementId": "c1085f5be782740f05cd18b2", + "fullConfiguration": "default", + "id": "MrOXTXNp4AShbRVlx", + "isStandardContent": false, + "name": "PCB, Raspberry Pi Zero 2 W <1>", + "partId": "KF7G", + "suppressed": false, + "type": "Part" +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/microduck/assets/pcb__raspberry_pi_zero_2_w.stl b/src/mjlab_microduck/robot/microduck/assets/pcb__raspberry_pi_zero_2_w.stl new file mode 100644 index 0000000..83ad4e5 Binary files /dev/null and b/src/mjlab_microduck/robot/microduck/assets/pcb__raspberry_pi_zero_2_w.stl differ diff --git a/src/mjlab_microduck/robot/microduck/assets/power_support.part b/src/mjlab_microduck/robot/microduck/assets/power_support.part new file mode 100644 index 0000000..4c02566 --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/assets/power_support.part @@ -0,0 +1,13 @@ +{ + "configuration": "default", + "documentId": "804927696f06d877f3f1803e", + "documentMicroversion": "fc8237658b0c3e9ada6f813b", + "elementId": "d6fcdccc8b25aaa256e7e213", + "fullConfiguration": "default", + "id": "MKtTPacZZyojWma+b", + "isStandardContent": false, + "name": "power_support <1>", + "partId": "RDCD", + "suppressed": false, + "type": "Part" +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/microduck/assets/power_support.stl b/src/mjlab_microduck/robot/microduck/assets/power_support.stl new file mode 100644 index 0000000..981bd20 Binary files /dev/null and b/src/mjlab_microduck/robot/microduck/assets/power_support.stl differ diff --git a/src/mjlab_microduck/robot/microduck/assets/right_shell.part b/src/mjlab_microduck/robot/microduck/assets/right_shell.part new file mode 100644 index 0000000..219a997 --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/assets/right_shell.part @@ -0,0 +1,13 @@ +{ + "configuration": "default", + "documentId": "804927696f06d877f3f1803e", + "documentMicroversion": "fc8237658b0c3e9ada6f813b", + "elementId": "d6fcdccc8b25aaa256e7e213", + "fullConfiguration": "default", + "id": "M7Gm6FOo6Hf6qW5Fn", + "isStandardContent": false, + "name": "right_shell <1>", + "partId": "RaGD", + "suppressed": false, + "type": "Part" +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/microduck/assets/right_shell.stl b/src/mjlab_microduck/robot/microduck/assets/right_shell.stl new file mode 100644 index 0000000..5879c3c Binary files /dev/null and b/src/mjlab_microduck/robot/microduck/assets/right_shell.stl differ diff --git a/src/mjlab_microduck/robot/microduck/assets/right_upper_leg.part b/src/mjlab_microduck/robot/microduck/assets/right_upper_leg.part new file mode 100644 index 0000000..53202fe --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/assets/right_upper_leg.part @@ -0,0 +1,13 @@ +{ + "configuration": "default", + "documentId": "804927696f06d877f3f1803e", + "documentMicroversion": "49900cb439825f734c36e098", + "elementId": "d6fcdccc8b25aaa256e7e213", + "fullConfiguration": "default", + "id": "MtnGERvz7RcFa4xeC", + "isStandardContent": false, + "name": "right_upper_leg <1>", + "partId": "R7GD", + "suppressed": false, + "type": "Part" +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/microduck/assets/right_upper_leg.stl b/src/mjlab_microduck/robot/microduck/assets/right_upper_leg.stl new file mode 100644 index 0000000..d70b840 Binary files /dev/null and b/src/mjlab_microduck/robot/microduck/assets/right_upper_leg.stl differ diff --git a/src/mjlab_microduck/robot/microduck/assets/rim.part b/src/mjlab_microduck/robot/microduck/assets/rim.part new file mode 100644 index 0000000..e5638b6 --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/assets/rim.part @@ -0,0 +1,14 @@ +{ + "configuration": "default", + "documentId": "804927696f06d877f3f1803e", + "documentMicroversion": "f694115e325b201fc7eee9eb", + "documentVersion": "f839e201fb05eeec735c386e", + "elementId": "a48e2e3940da29620aa227db", + "fullConfiguration": "default", + "id": "M5j7f+l/mYiBJew4g", + "isStandardContent": false, + "name": "rim <1>", + "partId": "RVHD", + "suppressed": false, + "type": "Part" +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/microduck/assets/rim.stl b/src/mjlab_microduck/robot/microduck/assets/rim.stl new file mode 100644 index 0000000..9a85769 Binary files /dev/null and b/src/mjlab_microduck/robot/microduck/assets/rim.stl differ diff --git a/src/mjlab_microduck/robot/microduck/assets/roller_blade.part b/src/mjlab_microduck/robot/microduck/assets/roller_blade.part new file mode 100644 index 0000000..e51e6b2 --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/assets/roller_blade.part @@ -0,0 +1,14 @@ +{ + "configuration": "default", + "documentId": "804927696f06d877f3f1803e", + "documentMicroversion": "f694115e325b201fc7eee9eb", + "documentVersion": "f839e201fb05eeec735c386e", + "elementId": "a48e2e3940da29620aa227db", + "fullConfiguration": "default", + "id": "MlRvMQE63m7hFBxbA", + "isStandardContent": false, + "name": "roller_blade <1>", + "partId": "RbHD", + "suppressed": false, + "type": "Part" +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/microduck/assets/roller_blade.stl b/src/mjlab_microduck/robot/microduck/assets/roller_blade.stl new file mode 100644 index 0000000..12ceafe Binary files /dev/null and b/src/mjlab_microduck/robot/microduck/assets/roller_blade.stl differ diff --git a/src/mjlab_microduck/robot/microduck/assets/seeed_bearing__configuration__22x16x4.part b/src/mjlab_microduck/robot/microduck/assets/seeed_bearing__configuration__22x16x4.part new file mode 100644 index 0000000..f2e6336 --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/assets/seeed_bearing__configuration__22x16x4.part @@ -0,0 +1,13 @@ +{ + "configuration": "List_ydxZOkfPyEIdF6=_22x16x4", + "documentId": "804927696f06d877f3f1803e", + "documentMicroversion": "fc8237658b0c3e9ada6f813b", + "elementId": "92eaf48a756ec816309fc756", + "fullConfiguration": "List_ydxZOkfPyEIdF6=_22x16x4", + "id": "MQ5Bx0sg5RW0ntDAW", + "isStandardContent": false, + "name": "Seeed bearing <8>", + "partId": "JHD", + "suppressed": false, + "type": "Part" +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/microduck/assets/seeed_bearing__configuration__22x16x4.stl b/src/mjlab_microduck/robot/microduck/assets/seeed_bearing__configuration__22x16x4.stl new file mode 100644 index 0000000..56ab78a Binary files /dev/null and b/src/mjlab_microduck/robot/microduck/assets/seeed_bearing__configuration__22x16x4.stl differ diff --git a/src/mjlab_microduck/robot/microduck/assets/seeed_bearing__configuration_default.part b/src/mjlab_microduck/robot/microduck/assets/seeed_bearing__configuration_default.part new file mode 100644 index 0000000..476d8a3 --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/assets/seeed_bearing__configuration_default.part @@ -0,0 +1,13 @@ +{ + "configuration": "List_ydxZOkfPyEIdF6=Default", + "documentId": "804927696f06d877f3f1803e", + "documentMicroversion": "fc8237658b0c3e9ada6f813b", + "elementId": "92eaf48a756ec816309fc756", + "fullConfiguration": "List_ydxZOkfPyEIdF6=Default", + "id": "MHnJznQ2OhdOOo7w5", + "isStandardContent": false, + "name": "Seeed bearing <10>", + "partId": "JHD", + "suppressed": false, + "type": "Part" +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/microduck/assets/seeed_bearing__configuration_default.stl b/src/mjlab_microduck/robot/microduck/assets/seeed_bearing__configuration_default.stl new file mode 100644 index 0000000..4baeca2 Binary files /dev/null and b/src/mjlab_microduck/robot/microduck/assets/seeed_bearing__configuration_default.stl differ diff --git a/src/mjlab_microduck/robot/microduck/assets/soft_mouth_top.part b/src/mjlab_microduck/robot/microduck/assets/soft_mouth_top.part new file mode 100644 index 0000000..1bc8b32 --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/assets/soft_mouth_top.part @@ -0,0 +1,14 @@ +{ + "configuration": "default", + "documentId": "804927696f06d877f3f1803e", + "documentMicroversion": "9306fb07735e782de209a230", + "documentVersion": "f0b9dc2ed2b65290c37daecc", + "elementId": "cceb83ef371fcd79b077022f", + "fullConfiguration": "default", + "id": "MSnJgmvIMI2MH46WD", + "isStandardContent": false, + "name": "soft_mouth_top <1>", + "partId": "RHCD", + "suppressed": false, + "type": "Part" +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/microduck/assets/soft_mouth_top.stl b/src/mjlab_microduck/robot/microduck/assets/soft_mouth_top.stl new file mode 100644 index 0000000..43e9515 Binary files /dev/null and b/src/mjlab_microduck/robot/microduck/assets/soft_mouth_top.stl differ diff --git a/src/mjlab_microduck/robot/microduck/assets/sole_left.part b/src/mjlab_microduck/robot/microduck/assets/sole_left.part new file mode 100644 index 0000000..30da971 --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/assets/sole_left.part @@ -0,0 +1,13 @@ +{ + "configuration": "default", + "documentId": "804927696f06d877f3f1803e", + "documentMicroversion": "48ccc523b8461dff097d59f9", + "elementId": "451317b8e3aacc7f88e8f8ed", + "fullConfiguration": "default", + "id": "MGDwbMclRQjrME1m+", + "isStandardContent": false, + "name": "sole_left <1>", + "partId": "R0BD", + "suppressed": false, + "type": "Part" +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/microduck/assets/sole_left.stl b/src/mjlab_microduck/robot/microduck/assets/sole_left.stl new file mode 100644 index 0000000..1fe55aa Binary files /dev/null and b/src/mjlab_microduck/robot/microduck/assets/sole_left.stl differ diff --git a/src/mjlab_microduck/robot/microduck/assets/sole_right.part b/src/mjlab_microduck/robot/microduck/assets/sole_right.part new file mode 100644 index 0000000..3dae5e6 --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/assets/sole_right.part @@ -0,0 +1,13 @@ +{ + "configuration": "default", + "documentId": "804927696f06d877f3f1803e", + "documentMicroversion": "48ccc523b8461dff097d59f9", + "elementId": "451317b8e3aacc7f88e8f8ed", + "fullConfiguration": "default", + "id": "M29FL+tazLvANCmWh", + "isStandardContent": false, + "name": "sole_right <1>", + "partId": "R8CH", + "suppressed": false, + "type": "Part" +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/microduck/assets/sole_right.stl b/src/mjlab_microduck/robot/microduck/assets/sole_right.stl new file mode 100644 index 0000000..7d22f5c Binary files /dev/null and b/src/mjlab_microduck/robot/microduck/assets/sole_right.stl differ diff --git a/src/mjlab_microduck/robot/microduck/assets/speaker.part b/src/mjlab_microduck/robot/microduck/assets/speaker.part new file mode 100644 index 0000000..23d1a73 --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/assets/speaker.part @@ -0,0 +1,13 @@ +{ + "configuration": "default", + "documentId": "804927696f06d877f3f1803e", + "documentMicroversion": "fc8237658b0c3e9ada6f813b", + "elementId": "f622c3133a65333d713a0880", + "fullConfiguration": "default", + "id": "MulH+XC1B6nhcOdGg", + "isStandardContent": false, + "name": "speaker <1>", + "partId": "JHD", + "suppressed": false, + "type": "Part" +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/microduck/assets/speaker.stl b/src/mjlab_microduck/robot/microduck/assets/speaker.stl new file mode 100644 index 0000000..1f4de82 Binary files /dev/null and b/src/mjlab_microduck/robot/microduck/assets/speaker.stl differ diff --git a/src/mjlab_microduck/robot/microduck/assets/tire.part b/src/mjlab_microduck/robot/microduck/assets/tire.part new file mode 100644 index 0000000..f6c666b --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/assets/tire.part @@ -0,0 +1,14 @@ +{ + "configuration": "default", + "documentId": "804927696f06d877f3f1803e", + "documentMicroversion": "f694115e325b201fc7eee9eb", + "documentVersion": "f839e201fb05eeec735c386e", + "elementId": "a48e2e3940da29620aa227db", + "fullConfiguration": "default", + "id": "M9smhH3H3mAiXciQc", + "isStandardContent": false, + "name": "tire <1>", + "partId": "RYHD", + "suppressed": false, + "type": "Part" +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/microduck/assets/tire.stl b/src/mjlab_microduck/robot/microduck/assets/tire.stl new file mode 100644 index 0000000..628feaa Binary files /dev/null and b/src/mjlab_microduck/robot/microduck/assets/tire.stl differ diff --git a/src/mjlab_microduck/robot/microduck/assets/top_head_shell.part b/src/mjlab_microduck/robot/microduck/assets/top_head_shell.part new file mode 100644 index 0000000..7757450 --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/assets/top_head_shell.part @@ -0,0 +1,13 @@ +{ + "configuration": "default", + "documentId": "804927696f06d877f3f1803e", + "documentMicroversion": "fc8237658b0c3e9ada6f813b", + "elementId": "cceb83ef371fcd79b077022f", + "fullConfiguration": "default", + "id": "MCM/35G26Fd1GFOzR", + "isStandardContent": false, + "name": "top_head_shell <1>", + "partId": "J/D", + "suppressed": false, + "type": "Part" +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/microduck/assets/top_head_shell.stl b/src/mjlab_microduck/robot/microduck/assets/top_head_shell.stl new file mode 100644 index 0000000..f446053 Binary files /dev/null and b/src/mjlab_microduck/robot/microduck/assets/top_head_shell.stl differ diff --git a/src/mjlab_microduck/robot/microduck/assets/trunk_base.part b/src/mjlab_microduck/robot/microduck/assets/trunk_base.part new file mode 100644 index 0000000..9f96dce --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/assets/trunk_base.part @@ -0,0 +1,13 @@ +{ + "configuration": "default", + "documentId": "804927696f06d877f3f1803e", + "documentMicroversion": "fc8237658b0c3e9ada6f813b", + "elementId": "d6fcdccc8b25aaa256e7e213", + "fullConfiguration": "default", + "id": "MJcJLYFe5PHdcbIOw", + "isStandardContent": false, + "name": "trunk_base <1>", + "partId": "RlBD", + "suppressed": false, + "type": "Part" +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/microduck/assets/trunk_base.stl b/src/mjlab_microduck/robot/microduck/assets/trunk_base.stl new file mode 100644 index 0000000..1fc5ea0 Binary files /dev/null and b/src/mjlab_microduck/robot/microduck/assets/trunk_base.stl differ diff --git a/src/mjlab_microduck/robot/microduck/assets/trunk_shell_left.part b/src/mjlab_microduck/robot/microduck/assets/trunk_shell_left.part new file mode 100644 index 0000000..139f8c3 --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/assets/trunk_shell_left.part @@ -0,0 +1,13 @@ +{ + "configuration": "default", + "documentId": "804927696f06d877f3f1803e", + "documentMicroversion": "49900cb439825f734c36e098", + "elementId": "d6fcdccc8b25aaa256e7e213", + "fullConfiguration": "default", + "id": "Mx9wCEl1i0Lb7LDyx", + "isStandardContent": false, + "name": "trunk_shell_left <1>", + "partId": "RwKD", + "suppressed": false, + "type": "Part" +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/microduck/assets/trunk_shell_left.stl b/src/mjlab_microduck/robot/microduck/assets/trunk_shell_left.stl new file mode 100644 index 0000000..787f710 Binary files /dev/null and b/src/mjlab_microduck/robot/microduck/assets/trunk_shell_left.stl differ diff --git a/src/mjlab_microduck/robot/microduck/assets/trunk_shell_right.part b/src/mjlab_microduck/robot/microduck/assets/trunk_shell_right.part new file mode 100644 index 0000000..a7acc5e --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/assets/trunk_shell_right.part @@ -0,0 +1,13 @@ +{ + "configuration": "default", + "documentId": "804927696f06d877f3f1803e", + "documentMicroversion": "49900cb439825f734c36e098", + "elementId": "d6fcdccc8b25aaa256e7e213", + "fullConfiguration": "default", + "id": "M7Gm6FOo6Hf6qW5Fn", + "isStandardContent": false, + "name": "trunk_shell_right <1>", + "partId": "R+LD", + "suppressed": false, + "type": "Part" +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/microduck/assets/trunk_shell_right.stl b/src/mjlab_microduck/robot/microduck/assets/trunk_shell_right.stl new file mode 100644 index 0000000..eeac8d3 Binary files /dev/null and b/src/mjlab_microduck/robot/microduck/assets/trunk_shell_right.stl differ diff --git a/src/mjlab_microduck/robot/microduck/assets/upper_leg_left.part b/src/mjlab_microduck/robot/microduck/assets/upper_leg_left.part new file mode 100644 index 0000000..40f4153 --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/assets/upper_leg_left.part @@ -0,0 +1,13 @@ +{ + "configuration": "default", + "documentId": "804927696f06d877f3f1803e", + "documentMicroversion": "fc8237658b0c3e9ada6f813b", + "elementId": "451317b8e3aacc7f88e8f8ed", + "fullConfiguration": "default", + "id": "MCzyiRc5ESe5zlNkJ", + "isStandardContent": false, + "name": "upper_leg_left <1>", + "partId": "R+DD", + "suppressed": false, + "type": "Part" +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/microduck/assets/upper_leg_left.stl b/src/mjlab_microduck/robot/microduck/assets/upper_leg_left.stl new file mode 100644 index 0000000..b02cccb Binary files /dev/null and b/src/mjlab_microduck/robot/microduck/assets/upper_leg_left.stl differ diff --git a/src/mjlab_microduck/robot/microduck/assets/upper_leg_right.part b/src/mjlab_microduck/robot/microduck/assets/upper_leg_right.part new file mode 100644 index 0000000..69c0ae6 --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/assets/upper_leg_right.part @@ -0,0 +1,13 @@ +{ + "configuration": "default", + "documentId": "804927696f06d877f3f1803e", + "documentMicroversion": "fc8237658b0c3e9ada6f813b", + "elementId": "451317b8e3aacc7f88e8f8ed", + "fullConfiguration": "default", + "id": "MtnGERvz7RcFa4xeC", + "isStandardContent": false, + "name": "upper_leg_right <1>", + "partId": "RsED", + "suppressed": false, + "type": "Part" +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/microduck/assets/upper_leg_right.stl b/src/mjlab_microduck/robot/microduck/assets/upper_leg_right.stl new file mode 100644 index 0000000..8427f7e Binary files /dev/null and b/src/mjlab_microduck/robot/microduck/assets/upper_leg_right.stl differ diff --git a/src/mjlab_microduck/robot/microduck/assets/upper_leg_rigidity_plate.part b/src/mjlab_microduck/robot/microduck/assets/upper_leg_rigidity_plate.part new file mode 100644 index 0000000..b28b607 --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/assets/upper_leg_rigidity_plate.part @@ -0,0 +1,13 @@ +{ + "configuration": "default", + "documentId": "804927696f06d877f3f1803e", + "documentMicroversion": "fc8237658b0c3e9ada6f813b", + "elementId": "451317b8e3aacc7f88e8f8ed", + "fullConfiguration": "default", + "id": "MpYBlDWICMN4d9B39", + "isStandardContent": false, + "name": "upper_leg_rigidity_plate <1>", + "partId": "RxED", + "suppressed": false, + "type": "Part" +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/microduck/assets/upper_leg_rigidity_plate.stl b/src/mjlab_microduck/robot/microduck/assets/upper_leg_rigidity_plate.stl new file mode 100644 index 0000000..8b00e8e Binary files /dev/null and b/src/mjlab_microduck/robot/microduck/assets/upper_leg_rigidity_plate.stl differ diff --git a/src/mjlab_microduck/robot/microduck/assets/xl330.part b/src/mjlab_microduck/robot/microduck/assets/xl330.part new file mode 100644 index 0000000..8f666ea --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/assets/xl330.part @@ -0,0 +1,13 @@ +{ + "configuration": "default", + "documentId": "804927696f06d877f3f1803e", + "documentMicroversion": "fc8237658b0c3e9ada6f813b", + "elementId": "e34e27a4a091c95d26a71da8", + "fullConfiguration": "default", + "id": "MNMyVEwhc7HfSm3EX", + "isStandardContent": false, + "name": "xl330 <11>", + "partId": "JND", + "suppressed": false, + "type": "Part" +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/microduck/assets/xl330.stl b/src/mjlab_microduck/robot/microduck/assets/xl330.stl new file mode 100644 index 0000000..0ea8d4f Binary files /dev/null and b/src/mjlab_microduck/robot/microduck/assets/xl330.stl differ diff --git a/src/mjlab_microduck/robot/microduck/assets/yaw2roll.part b/src/mjlab_microduck/robot/microduck/assets/yaw2roll.part new file mode 100644 index 0000000..0b7e3e0 --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/assets/yaw2roll.part @@ -0,0 +1,13 @@ +{ + "configuration": "default", + "documentId": "804927696f06d877f3f1803e", + "documentMicroversion": "fc8237658b0c3e9ada6f813b", + "elementId": "d6fcdccc8b25aaa256e7e213", + "fullConfiguration": "default", + "id": "MO3UG60E/crGUBuqS", + "isStandardContent": false, + "name": "yaw2roll <1>", + "partId": "JgD", + "suppressed": false, + "type": "Part" +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/microduck/assets/yaw2roll.stl b/src/mjlab_microduck/robot/microduck/assets/yaw2roll.stl new file mode 100644 index 0000000..4354d0c Binary files /dev/null and b/src/mjlab_microduck/robot/microduck/assets/yaw2roll.stl differ diff --git a/src/mjlab_microduck/robot/microduck/assets/yaw_roll_motion.part b/src/mjlab_microduck/robot/microduck/assets/yaw_roll_motion.part new file mode 100644 index 0000000..e846155 --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/assets/yaw_roll_motion.part @@ -0,0 +1,13 @@ +{ + "configuration": "default", + "documentId": "804927696f06d877f3f1803e", + "documentMicroversion": "fc8237658b0c3e9ada6f813b", + "elementId": "cceb83ef371fcd79b077022f", + "fullConfiguration": "default", + "id": "M8nmO8TdXJ1yzOlXl", + "isStandardContent": false, + "name": "yaw_roll_motion <1>", + "partId": "RcBD", + "suppressed": false, + "type": "Part" +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/microduck/assets/yaw_roll_motion.stl b/src/mjlab_microduck/robot/microduck/assets/yaw_roll_motion.stl new file mode 100644 index 0000000..a69ce50 Binary files /dev/null and b/src/mjlab_microduck/robot/microduck/assets/yaw_roll_motion.stl differ diff --git a/src/mjlab_microduck/robot/microduck/ball.xml b/src/mjlab_microduck/robot/microduck/ball.xml new file mode 100644 index 0000000..42c3278 --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/ball.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + diff --git a/src/mjlab_microduck/robot/microduck/config_mjcf_allcollisions.json b/src/mjlab_microduck/robot/microduck/config_mjcf_allcollisions.json new file mode 100644 index 0000000..7e964fc --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/config_mjcf_allcollisions.json @@ -0,0 +1,48 @@ +{ + "url": "https://cad.onshape.com/documents/804927696f06d877f3f1803e/w/5b75db19292e71970de02dee/e/ef6e972847fec8d82570b35e", + "outputFormat": "mujoco", + "robot_name": "microduck", + "output_filename": "robot_allcollisions", + "simplify_stls": true, + "max_stl_size": 1.0, + "ignore": { + "*": "collision", + "!sole_left": "collision", + "!sole_right": "collision", + "!leg": "collision", + "!power_support": "collision", + + "!top_head_shell": "collision", + "!bottom_head_shell": "collision", + "!jaw": "collision", + "!left_upper_leg": "collision", + "!right_upper_leg": "collision", + "!hip_l": "collision", + "!NP-F970": "collision", + "!trunk_shell_left": "collision", + "!trunk_shell_right": "collision", + + }, + "additional_xml": [ + "joints_properties.xml", + "sensors.xml", + "additional.xml" + ], + "post_import_commands": [ + "sed -i 's/body name=\"trunk_base\" pos=\"[^\"]*\"/body name=\"trunk_base\" pos=\"0 0 0.12\"/' robot_allcollisions.xml", + "sed -i '//{n; n; s/class=\"collision\"/name=\"left_foot_collision\" class=\"collision\"/}' robot_allcollisions.xml", + "sed -i '//{n; n; s/class=\"collision\"/name=\"right_foot_collision\" class=\"collision\"/}' robot_allcollisions.xml", + "sed -i '//{n; n; s/class=\"collision\"/class=\"self_collision_only\"/}' robot_allcollisions.xml", + "sed -i 's/ inheritrange=\"1\"//g' robot_allcollisions.xml", + "sed -i 's|]*name=\"head_camera\"[^>]*pos=\"\\([^\"]*\\)\"[^>]*/>|&\\n |' robot_allcollisions.xml" + ], + "joint_properties": { + "default": { + "class": "chosen_actuator" + }, + "passive*": { + "actuated": false, + "class" : "passive_joint" + } + } +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/microduck/config_mjcf_allcollisions_backlash.json b/src/mjlab_microduck/robot/microduck/config_mjcf_allcollisions_backlash.json new file mode 100644 index 0000000..1ec800b --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/config_mjcf_allcollisions_backlash.json @@ -0,0 +1,49 @@ +{ + "url": "https://cad.onshape.com/documents/804927696f06d877f3f1803e/w/5b75db19292e71970de02dee/e/ef6e972847fec8d82570b35e", + "outputFormat": "mujoco", + "robot_name": "microduck", + "output_filename": "robot_allcollisions_backlash", + "simplify_stls": true, + "max_stl_size": 1.0, + "ignore": { + "*": "collision", + "!sole_left": "collision", + "!sole_right": "collision", + "!leg": "collision", + "!power_support": "collision", + + "!top_head_shell": "collision", + "!bottom_head_shell": "collision", + "!jaw": "collision", + "!left_upper_leg": "collision", + "!right_upper_leg": "collision", + "!hip_l": "collision", + "!NP-F970": "collision", + "!trunk_shell_left": "collision", + "!trunk_shell_right": "collision", + + }, + "additional_xml": [ + "joints_properties.xml", + "sensors.xml", + "additional.xml" + ], + "post_import_commands": [ + "sed -i 's/body name=\"trunk_base\" pos=\"[^\"]*\"/body name=\"trunk_base\" pos=\"0 0 0.12\"/' robot_allcollisions_backlash.xml", + "sed -i '//{n; n; s/class=\"collision\"/name=\"left_foot_collision\" class=\"collision\"/}' robot_allcollisions_backlash.xml", + "sed -i '//{n; n; s/class=\"collision\"/name=\"right_foot_collision\" class=\"collision\"/}' robot_allcollisions_backlash.xml", + "sed -i '//{n; n; s/class=\"collision\"/class=\"self_collision_only\"/}' robot_allcollisions_backlash.xml", + "sed -i 's/ inheritrange=\"1\"//g' robot_allcollisions_backlash.xml", + "sed -i 's|]*name=\"head_camera\"[^>]*pos=\"\\([^\"]*\\)\"[^>]*/>|&\\n |' robot_allcollisions_backlash.xml", + "python3 add_backlash.py robot_allcollisions_backlash.xml --backlash-deg 2.0" + ], + "joint_properties": { + "default": { + "class": "chosen_actuator" + }, + "passive*": { + "actuated": false, + "class" : "passive_joint" + } + } +} diff --git a/src/mjlab_microduck/robot/microduck/config_mjcf_allcollisions_rollers.json b/src/mjlab_microduck/robot/microduck/config_mjcf_allcollisions_rollers.json new file mode 100644 index 0000000..93bb0ed --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/config_mjcf_allcollisions_rollers.json @@ -0,0 +1,49 @@ +{ + "url": "https://cad.onshape.com/documents/804927696f06d877f3f1803e/w/5b75db19292e71970de02dee/e/ed34b749f5a3718f68024fd5", + "outputFormat": "mujoco", + "robot_name": "microduck", + "output_filename": "robot_allcollisions_rollers", + "simplify_stls": true, + "max_stl_size": 1.0, + "ignore": { + "*": "collision", + "!sole_left": "collision", + "!sole_right": "collision", + "!leg": "collision", + "!power_support": "collision", + + "!top_head_shell": "collision", + "!bottom_head_shell": "collision", + "!jaw": "collision", + "!left_upper_leg": "collision", + "!right_upper_leg": "collision", + "!hip_l": "collision", + "!NP-F970": "collision", + "!trunk_shell_left": "collision", + "!trunk_shell_right": "collision", + "!tire": "collision", + + }, + "additional_xml": [ + "joints_properties.xml", + "sensors.xml", + "additional.xml" + ], + "post_import_commands": [ + "sed -i 's/body name=\"trunk_base\" pos=\"[^\"]*\"/body name=\"trunk_base\" pos=\"0 0 0.12\"/' robot_allcollisions_rollers.xml", + "sed -i '//{n; n; s/class=\"collision\"/name=\"left_foot_collision\" class=\"collision\"/}' robot_allcollisions_rollers.xml", + "sed -i '//{n; n; s/class=\"collision\"/name=\"right_foot_collision\" class=\"collision\"/}' robot_allcollisions_rollers.xml", + "sed -i '//{n; n; s/class=\"collision\"/class=\"self_collision_only\"/}' robot_allcollisions_rollers.xml", + "sed -i 's/ inheritrange=\"1\"//g' robot_allcollisions_rollers.xml", + "sed -i 's|]*name=\"head_camera\"[^>]*pos=\"\\([^\"]*\\)\"[^>]*/>|&\\n |' robot_allcollisions_rollers.xml" + ], + "joint_properties": { + "default": { + "class": "chosen_actuator" + }, + "passive*": { + "actuated": false, + "class" : "passive_joint" + } + } +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/microduck/config_mjcf_allcollisions_rollers_backlash.json b/src/mjlab_microduck/robot/microduck/config_mjcf_allcollisions_rollers_backlash.json new file mode 100644 index 0000000..c67f0cd --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/config_mjcf_allcollisions_rollers_backlash.json @@ -0,0 +1,50 @@ +{ + "url": "https://cad.onshape.com/documents/804927696f06d877f3f1803e/w/5b75db19292e71970de02dee/e/ed34b749f5a3718f68024fd5", + "outputFormat": "mujoco", + "robot_name": "microduck", + "output_filename": "robot_allcollisions_rollers_backlash", + "simplify_stls": true, + "max_stl_size": 1.0, + "ignore": { + "*": "collision", + "!sole_left": "collision", + "!sole_right": "collision", + "!leg": "collision", + "!power_support": "collision", + + "!top_head_shell": "collision", + "!bottom_head_shell": "collision", + "!jaw": "collision", + "!left_upper_leg": "collision", + "!right_upper_leg": "collision", + "!hip_l": "collision", + "!NP-F970": "collision", + "!trunk_shell_left": "collision", + "!trunk_shell_right": "collision", + "!tire": "collision", + + }, + "additional_xml": [ + "joints_properties.xml", + "sensors.xml", + "additional.xml" + ], + "post_import_commands": [ + "sed -i 's/body name=\"trunk_base\" pos=\"[^\"]*\"/body name=\"trunk_base\" pos=\"0 0 0.12\"/' robot_allcollisions_rollers_backlash.xml", + "sed -i '//{n; n; s/class=\"collision\"/name=\"left_foot_collision\" class=\"collision\"/}' robot_allcollisions_rollers_backlash.xml", + "sed -i '//{n; n; s/class=\"collision\"/name=\"right_foot_collision\" class=\"collision\"/}' robot_allcollisions_rollers_backlash.xml", + "sed -i '//{n; n; s/class=\"collision\"/class=\"self_collision_only\"/}' robot_allcollisions_rollers_backlash.xml", + "sed -i 's/ inheritrange=\"1\"//g' robot_allcollisions_rollers_backlash.xml", + "sed -i 's|]*name=\"head_camera\"[^>]*pos=\"\\([^\"]*\\)\"[^>]*/>|&\\n |' robot_allcollisions_rollers_backlash.xml", + "python3 add_backlash.py robot_allcollisions_rollers_backlash.xml --backlash-deg 2.0" + ], + "joint_properties": { + "default": { + "class": "chosen_actuator" + }, + "passive*": { + "actuated": false, + "class" : "passive_joint" + } + } +} diff --git a/src/mjlab_microduck/robot/microduck/config_mjcf_walk.json b/src/mjlab_microduck/robot/microduck/config_mjcf_walk.json new file mode 100644 index 0000000..43db163 --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/config_mjcf_walk.json @@ -0,0 +1,39 @@ +{ + "url": "https://cad.onshape.com/documents/804927696f06d877f3f1803e/w/5b75db19292e71970de02dee/e/ef6e972847fec8d82570b35e", + "outputFormat": "mujoco", + "robot_name": "microduck", + "output_filename": "robot_walk", + "simplify_stls": true, + "max_stl_size": 1.0, + "ignore": { + "*": "collision", + "!sole_left": "collision", + "!sole_right": "collision", + "!leg": "collision", + "!power_support": "collision" + }, + "additional_xml": [ + "joints_properties.xml", + "sensors.xml", + "additional.xml" + ], + "post_import_commands": [ + "sed -i 's/body name=\"trunk_base\" pos=\"[^\"]*\"/body name=\"trunk_base\" pos=\"0 0 0.12\"/' robot_walk.xml", + "sed -i '//{n; n; s/class=\"collision\"/name=\"left_foot_collision\" class=\"collision\"/}' robot_walk.xml", + "sed -i '//{n; n; s/class=\"collision\"/name=\"right_foot_collision\" class=\"collision\"/}' robot_walk.xml", + "sed -i '//{n; n; s/class=\"collision\"/class=\"self_collision_only\"/}' robot_walk.xml", + "sed -i '//{n; n; s/class=\"collision\"/class=\"self_collision_only\"/}' robot_walk.xml", + "sed -i '//{n; n; s/class=\"collision\"/class=\"self_collision_only\"/}' robot_walk.xml", + "sed -i 's/ inheritrange=\"1\"//g' robot_walk.xml", + "sed -i 's|]*name=\"head_camera\"[^>]*pos=\"\\([^\"]*\\)\"[^>]*/>|&\\n |' robot_walk.xml" + ], + "joint_properties": { + "default": { + "class": "chosen_actuator" + }, + "passive*": { + "actuated": false, + "class" : "passive_joint" + } + } +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/microduck/config_mjcf_walk_backlash.json b/src/mjlab_microduck/robot/microduck/config_mjcf_walk_backlash.json new file mode 100644 index 0000000..fc1a83d --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/config_mjcf_walk_backlash.json @@ -0,0 +1,40 @@ +{ + "url": "https://cad.onshape.com/documents/804927696f06d877f3f1803e/w/5b75db19292e71970de02dee/e/ef6e972847fec8d82570b35e", + "outputFormat": "mujoco", + "robot_name": "microduck", + "output_filename": "robot_walk_backlash", + "simplify_stls": true, + "max_stl_size": 1.0, + "ignore": { + "*": "collision", + "!sole_left": "collision", + "!sole_right": "collision", + "!leg": "collision", + "!power_support": "collision" + }, + "additional_xml": [ + "joints_properties.xml", + "sensors.xml", + "additional.xml" + ], + "post_import_commands": [ + "sed -i 's/body name=\"trunk_base\" pos=\"[^\"]*\"/body name=\"trunk_base\" pos=\"0 0 0.12\"/' robot_walk_backlash.xml", + "sed -i '//{n; n; s/class=\"collision\"/name=\"left_foot_collision\" class=\"collision\"/}' robot_walk_backlash.xml", + "sed -i '//{n; n; s/class=\"collision\"/name=\"right_foot_collision\" class=\"collision\"/}' robot_walk_backlash.xml", + "sed -i '//{n; n; s/class=\"collision\"/class=\"self_collision_only\"/}' robot_walk_backlash.xml", + "sed -i '//{n; n; s/class=\"collision\"/class=\"self_collision_only\"/}' robot_walk_backlash.xml", + "sed -i '//{n; n; s/class=\"collision\"/class=\"self_collision_only\"/}' robot_walk_backlash.xml", + "sed -i 's/ inheritrange=\"1\"//g' robot_walk_backlash.xml", + "sed -i 's|]*name=\"head_camera\"[^>]*pos=\"\\([^\"]*\\)\"[^>]*/>|&\\n |' robot_walk_backlash.xml", + "python3 add_backlash.py robot_walk_backlash.xml --backlash-deg 2.0" + ], + "joint_properties": { + "default": { + "class": "chosen_actuator" + }, + "passive*": { + "actuated": false, + "class" : "passive_joint" + } + } +} diff --git a/src/mjlab_microduck/robot/microduck/joints_properties.xml b/src/mjlab_microduck/robot/microduck/joints_properties.xml new file mode 100644 index 0000000..ad2e755 --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/joints_properties.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/mjlab_microduck/robot/microduck/robot_allcollisions.xml b/src/mjlab_microduck/robot/microduck/robot_allcollisions.xml new file mode 100644 index 0000000..7f296e3 --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/robot_allcollisions.xml @@ -0,0 +1,434 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/mjlab_microduck/robot/microduck/robot_allcollisions_backlash.xml b/src/mjlab_microduck/robot/microduck/robot_allcollisions_backlash.xml new file mode 100644 index 0000000..c31684f --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/robot_allcollisions_backlash.xml @@ -0,0 +1,459 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/mjlab_microduck/robot/microduck/robot_allcollisions_rollers.xml b/src/mjlab_microduck/robot/microduck/robot_allcollisions_rollers.xml new file mode 100644 index 0000000..f0eb02c --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/robot_allcollisions_rollers.xml @@ -0,0 +1,470 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/mjlab_microduck/robot/microduck/robot_allcollisions_rollers_backlash.xml b/src/mjlab_microduck/robot/microduck/robot_allcollisions_rollers_backlash.xml new file mode 100644 index 0000000..32e6dbc --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/robot_allcollisions_rollers_backlash.xml @@ -0,0 +1,495 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/mjlab_microduck/robot/microduck/robot_walk.xml b/src/mjlab_microduck/robot/microduck/robot_walk.xml new file mode 100644 index 0000000..2ee35d4 --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/robot_walk.xml @@ -0,0 +1,428 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/mjlab_microduck/robot/microduck/robot_walk_backlash.xml b/src/mjlab_microduck/robot/microduck/robot_walk_backlash.xml new file mode 100644 index 0000000..da94d43 --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/robot_walk_backlash.xml @@ -0,0 +1,453 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/mjlab_microduck/robot/microduck/scene.xml b/src/mjlab_microduck/robot/microduck/scene.xml new file mode 100644 index 0000000..3ce5068 --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/scene.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/mjlab_microduck/robot/microduck/scene_backlash.xml b/src/mjlab_microduck/robot/microduck/scene_backlash.xml new file mode 100644 index 0000000..1254e27 --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/scene_backlash.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/mjlab_microduck/robot/microduck/scene_ball.xml b/src/mjlab_microduck/robot/microduck/scene_ball.xml new file mode 100644 index 0000000..a7207f6 --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/scene_ball.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/mjlab_microduck/robot/microduck/scene_rollers.xml b/src/mjlab_microduck/robot/microduck/scene_rollers.xml new file mode 100644 index 0000000..5f15f9d --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/scene_rollers.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/mjlab_microduck/robot/microduck/scene_walk.xml b/src/mjlab_microduck/robot/microduck/scene_walk.xml new file mode 100644 index 0000000..7aa25e5 --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/scene_walk.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/mjlab_microduck/robot/microduck/scene_walk_backlash.xml b/src/mjlab_microduck/robot/microduck/scene_walk_backlash.xml new file mode 100644 index 0000000..0fba9a5 --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/scene_walk_backlash.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/mjlab_microduck/robot/microduck/sensors.xml b/src/mjlab_microduck/robot/microduck/sensors.xml new file mode 100644 index 0000000..a2be91b --- /dev/null +++ b/src/mjlab_microduck/robot/microduck/sensors.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/mjlab_microduck/robot/microduck_constants.py b/src/mjlab_microduck/robot/microduck_constants.py new file mode 100644 index 0000000..8aa7b23 --- /dev/null +++ b/src/mjlab_microduck/robot/microduck_constants.py @@ -0,0 +1,263 @@ +import os +from pathlib import Path + +import mujoco +from mjlab.actuator import XmlActuatorCfg +from mjlab_microduck.actuator import ( + BacklashEncoderBamActuatorCfg, + FrictionDRBamActuatorCfg, +) +from mjlab.entity import EntityArticulationInfoCfg, EntityCfg +from mjlab.utils.spec_config import CollisionCfg + + +_ROBOT_DIR: Path = Path(os.path.dirname(__file__)) / "microduck" + +MICRODUCK_WALK_XML: Path = _ROBOT_DIR / "robot_walk.xml" +# Full-collision model, shared by standup / ground-pick / walk-rollers tasks. +MICRODUCK_ALLCOLLISIONS_XML: Path = _ROBOT_DIR / "robot_allcollisions.xml" +# 70mm / 15g ball prop for the BallKick task. +MICRODUCK_BALL_XML: Path = _ROBOT_DIR / "ball.xml" +# Roller-skate model: 14 actuated joints + passive wheel hinges (passive_*wheel). +MICRODUCK_ALLCOLLISIONS_ROLLERS_XML: Path = _ROBOT_DIR / "robot_allcollisions_rollers.xml" +# Backlash models: every servo joint gets an unactuated passive__backlash +# hinge in series (±1° play, 2° total). Exported via +# config_mjcf_{allcollisions,walk}_backlash.json (add_backlash.py post-processor). +MICRODUCK_ALLCOLLISIONS_BACKLASH_XML: Path = _ROBOT_DIR / "robot_allcollisions_backlash.xml" +MICRODUCK_WALK_BACKLASH_XML: Path = _ROBOT_DIR / "robot_walk_backlash.xml" +MICRODUCK_ALLCOLLISIONS_ROLLERS_BACKLASH_XML: Path = _ROBOT_DIR / "robot_allcollisions_rollers_backlash.xml" + +assert MICRODUCK_WALK_XML.exists(), f"XML not found: {MICRODUCK_WALK_XML}" +assert MICRODUCK_ALLCOLLISIONS_XML.exists(), f"XML not found: {MICRODUCK_ALLCOLLISIONS_XML}" +assert MICRODUCK_BALL_XML.exists(), f"XML not found: {MICRODUCK_BALL_XML}" +assert MICRODUCK_ALLCOLLISIONS_ROLLERS_XML.exists(), f"XML not found: {MICRODUCK_ALLCOLLISIONS_ROLLERS_XML}" +assert MICRODUCK_ALLCOLLISIONS_BACKLASH_XML.exists(), f"XML not found: {MICRODUCK_ALLCOLLISIONS_BACKLASH_XML}" +assert MICRODUCK_WALK_BACKLASH_XML.exists(), f"XML not found: {MICRODUCK_WALK_BACKLASH_XML}" +assert MICRODUCK_ALLCOLLISIONS_ROLLERS_BACKLASH_XML.exists(), f"XML not found: {MICRODUCK_ALLCOLLISIONS_ROLLERS_BACKLASH_XML}" + + +def get_walk_spec() -> mujoco.MjSpec: + return mujoco.MjSpec.from_file(str(MICRODUCK_WALK_XML)) + + +def get_standup_spec() -> mujoco.MjSpec: + return mujoco.MjSpec.from_file(str(MICRODUCK_ALLCOLLISIONS_XML)) + + +def get_ground_pick_spec() -> mujoco.MjSpec: + return mujoco.MjSpec.from_file(str(MICRODUCK_ALLCOLLISIONS_XML)) + + +def get_walk_rollers_spec() -> mujoco.MjSpec: + # NOTE: was loading robot_allcollisions.xml (no wheels) — the roller env + # silently ran on the wheel-less standup model. + return mujoco.MjSpec.from_file(str(MICRODUCK_ALLCOLLISIONS_ROLLERS_XML)) + + +def get_ball_spec() -> mujoco.MjSpec: + return mujoco.MjSpec.from_file(str(MICRODUCK_BALL_XML)) + + +def get_backlash_spec() -> mujoco.MjSpec: + return mujoco.MjSpec.from_file(str(MICRODUCK_ALLCOLLISIONS_BACKLASH_XML)) + + +def get_walk_backlash_spec() -> mujoco.MjSpec: + return mujoco.MjSpec.from_file(str(MICRODUCK_WALK_BACKLASH_XML)) + + +def get_rollers_backlash_spec() -> mujoco.MjSpec: + return mujoco.MjSpec.from_file(str(MICRODUCK_ALLCOLLISIONS_ROLLERS_BACKLASH_XML)) + + +HOME_FRAME = EntityCfg.InitialStateCfg( + joint_pos={ + # Lower body — STAND2 pose: trunk shifted ~5mm forward over the feet so + # the CoM sits over the ankle axis (was ~5mm behind it at the old HOME, + # which biased the robot backward and made the standup policy droop its + # head forward as a counterweight). Leg pitch chain leaned forward: + # hip_pitch 30°→26.24°, ankle 30°→25.95°, knee 0°→0.28°. Matches the + # STAND keyframe in scene.xml / scene_walk.xml. + r".*hip_yaw.*": 0.0, + r".*left_hip_roll.*": -0.0873, + r".*right_hip_roll.*": 0.0873, + r".*left_hip_pitch.*": -0.4579, + r".*right_hip_pitch.*": 0.4579, + r".*left_knee.*": -0.0049, + r".*right_knee.*": 0.0049, + r".*left_ankle.*": 0.4530, + r".*right_ankle.*": -0.4530, + # Head + r".*neck_pitch.*": 0.3491, + r".*head_pitch.*": 0.3491, + r".*head_yaw.*": 0.0, + r".*head_roll.*": 0.0, + }, + joint_vel={".*": 0.0}, +) + +FULL_COLLISION = CollisionCfg( + geom_names_expr=[".*_collision"], + condim={r"^(left|right)_foot_collision$": 3, ".*_collision": 1}, + priority={r"^(left|right)_foot_collision$": 1}, + friction={r"^(left|right)_foot_collision$": (1.0,)}, +) + +# -- Old actuator (XML position, MuJoCo built-in PD + friction) -- +# actuators = DelayedActuatorCfg( + # delay_min_lag=0, + # delay_max_lag=3, + # base_cfg=XmlPositionActuatorCfg(joint_names_expr=(r".*",)), +# ) + +# -- BAM M6 actuator (full voltage control + load-dependent friction) -- +# Exclude passive_* joints (jaw linkage in the new model has no XML actuator). +# Voltage domain randomization (mirrors mjlab_microban): +# - vin_range: per-env battery voltage sampled at startup (replaces fixed vin) +# - vin_drop_gain_range: load-dependent voltage sag V_drop = gain * sum(|tau|) +# - vin_min: hard floor on the effective voltage after sag +# kp_fw kept at 200 (microduck's preserved firmware stiffness; microban uses 125). +_BAM_ACTUATOR_KWARGS = dict( + motor_name="xl330", + model="m6", + target_names_expr=(r"^(?!passive_).*",), + kp_fw=200.0, # microduck's preserved firmware stiffness (microban uses 125) + # vin_range=(6.9, 7.9), + vin_range=(6.5, 8.2), + vin_drop_gain_range=(0.0, 0.2), + vin_min=6.0, + # max_current=1.75, + delay_min_lag=3, + delay_max_lag=6, +) +actuators = FrictionDRBamActuatorCfg(**_BAM_ACTUATOR_KWARGS) + +# Same BAM actuator, but the firmware position loop reads the encoder THROUGH +# the passive__backlash hinges (the real encoder is on the output side +# of the gear play). Only for the backlash model; the target regex already +# excludes the passive_* backlash joints from actuation. +backlash_actuators = BacklashEncoderBamActuatorCfg(**_BAM_ACTUATOR_KWARGS) + +# -- BAM M4 actuator +# actuators = DelayedActuatorCfg( + # delay_min_lag=0, + # delay_max_lag=3, + # base_cfg=make_bam_m4_actuator_cfg(), +# ) + +# HOME frame for the backlash model. HOME_FRAME's unanchored patterns +# (e.g. r".*left_hip_roll.*") would also match passive_left_hip_roll_backlash +# and try to initialize it at -0.0873 rad — outside its ±1° range. Pattern +# matching is first-match-wins in declaration order, so the anchored backlash +# rule placed FIRST pins every backlash joint at 0 and the servo joints fall +# through to the normal HOME values. +BACKLASH_HOME_FRAME = EntityCfg.InitialStateCfg( + joint_pos={r".*_backlash$": 0.0, **HOME_FRAME.joint_pos}, + joint_vel={".*": 0.0}, +) + +MICRODUCK_WALK_ROBOT_CFG = EntityCfg( + spec_fn=get_walk_spec, + init_state=HOME_FRAME, + collisions=(FULL_COLLISION,), + articulation=EntityArticulationInfoCfg( + actuators=(actuators,), + soft_joint_pos_limit_factor=0.9, + ), +) + +MICRODUCK_STANDUP_ROBOT_CFG = EntityCfg( + spec_fn=get_standup_spec, + init_state=HOME_FRAME, + collisions=(FULL_COLLISION,), + articulation=EntityArticulationInfoCfg( + actuators=(actuators,), + soft_joint_pos_limit_factor=0.9, + ), +) + +MICRODUCK_GROUND_PICK_ROBOT_CFG = EntityCfg( + spec_fn=get_ground_pick_spec, + init_state=HOME_FRAME, + collisions=(FULL_COLLISION,), + articulation=EntityArticulationInfoCfg( + actuators=(actuators,), + soft_joint_pos_limit_factor=0.9, + ), +) + +# Backlash robots: base model + ±1° serial backlash hinge per servo. +# Encoder reads through the backlash (BacklashEncoderBamActuator feedback + +# joint_pos/vel_rel_backlash observations — see tasks/backlash.py). +# Allcollisions variant → VelStand/StandUp backlash tasks (mirrors +# MICRODUCK_STANDUP_ROBOT_CFG); walk variant → Velocity backlash +# tasks (mirrors MICRODUCK_WALK_ROBOT_CFG, keeps backlash-vs-base comparisons +# unconfounded by the collision model). +MICRODUCK_BACKLASH_ROBOT_CFG = EntityCfg( + spec_fn=get_backlash_spec, + init_state=BACKLASH_HOME_FRAME, + collisions=(FULL_COLLISION,), + articulation=EntityArticulationInfoCfg( + actuators=(backlash_actuators,), + soft_joint_pos_limit_factor=0.9, + ), +) + +MICRODUCK_WALK_BACKLASH_ROBOT_CFG = EntityCfg( + spec_fn=get_walk_backlash_spec, + init_state=BACKLASH_HOME_FRAME, + collisions=(FULL_COLLISION,), + articulation=EntityArticulationInfoCfg( + actuators=(backlash_actuators,), + soft_joint_pos_limit_factor=0.9, + ), +) + +# Roller-skate backlash robot: wheels stay free (passive_*wheel untouched by +# add_backlash.py). collisions=() mirrors MICRODUCK_WALK_ROLLERS_ROBOT_CFG — +# roller wheel collision geoms have no explicit names; XML defaults apply. +MICRODUCK_ROLLERS_BACKLASH_ROBOT_CFG = EntityCfg( + spec_fn=get_rollers_backlash_spec, + init_state=BACKLASH_HOME_FRAME, + collisions=(), + articulation=EntityArticulationInfoCfg( + actuators=(backlash_actuators,), + soft_joint_pos_limit_factor=0.9, + ), +) + +# Free-floating, non-articulated ball prop for the BallKick task. Position is +# set each episode by the reset_ball_in_front_of_foot event; the init pos here +# only matters for the pristine pre-first-reset state. +MICRODUCK_BALL_CFG = EntityCfg( + spec_fn=get_ball_spec, + init_state=EntityCfg.InitialStateCfg(pos=(0.3, 0.0, 0.035)), +) + +# Roller skate robot: the 4 passive wheel joints (passive_*wheel) have no XML +# actuators; the BAM cfg's target regex already excludes them, so the action +# space stays 14-dimensional. Uses the SAME canonical BAM actuator as every +# other variant (was a plain XmlActuatorCfg PD — an actuator-physics mismatch +# vs the rest of the family, and joint-friction DR was impossible). +MICRODUCK_WALK_ROLLERS_ROBOT_CFG = EntityCfg( + spec_fn=get_walk_rollers_spec, + init_state=HOME_FRAME, + collisions=(), # roller wheel collision geoms have no explicit names; XML defaults apply + articulation=EntityArticulationInfoCfg( + actuators=(actuators,), + soft_joint_pos_limit_factor=0.9, + ), +) + +if __name__ == "__main__": + import mujoco.viewer as viewer + from mjlab.scene import Scene, SceneCfg + from mjlab.terrains import TerrainImporterCfg + + SCENE_CFG = SceneCfg( + terrain=TerrainImporterCfg(terrain_type="plane"), + entities={"robot": MICRODUCK_WALK_ROBOT_CFG}, + ) + + scene = Scene(SCENE_CFG, device="cuda:0") + viewer.launch(scene.compile()) diff --git a/src/mjlab_microduck/robot/testbench_constants.py b/src/mjlab_microduck/robot/testbench_constants.py new file mode 100644 index 0000000..e676e59 --- /dev/null +++ b/src/mjlab_microduck/robot/testbench_constants.py @@ -0,0 +1,67 @@ +"""XL330 testbench entity configuration for sim2real validation.""" + +import os +from pathlib import Path + +import mujoco +from mjlab.entity import EntityArticulationInfoCfg, EntityCfg + +from bam.mjlab import BamActuatorCfg + + +_TESTBENCH_DIR: Path = Path(os.path.dirname(__file__)) / "xl330_test_bench" +# Use the robot-only XML (no floor / no lights): mjlab's TerrainImporterCfg +# adds its own ground plane, so scene.xml would give a duplicated floor. +TESTBENCH_XML: Path = _TESTBENCH_DIR / "xl330_test_bench.xml" + +assert TESTBENCH_XML.exists(), f"XML not found: {TESTBENCH_XML}" + + +# Real-device payload mass (120 g) +TESTBENCH_ARM_MASS: float = 0.12 + + +def _set_arm_mass(spec: mujoco.MjSpec, mass: float) -> None: + for body in spec.bodies: + if body.name == "arm": + original = body.mass + if original > 0: + scale = mass / original + body.mass = mass + body.fullinertia = [x * scale for x in body.fullinertia] + break + + +def get_testbench_spec() -> mujoco.MjSpec: + spec = mujoco.MjSpec.from_file(str(TESTBENCH_XML)) + _set_arm_mass(spec, TESTBENCH_ARM_MASS) + return spec + + +HOME_FRAME = EntityCfg.InitialStateCfg( + joint_pos={"1": 0.0}, + joint_vel={".*": 0.0}, +) + + +# Use the BAM M6 actuator model (matches the real XL330 on the test bench). +testbench_actuators = BamActuatorCfg( + motor_name="xl330", + model="m6", + target_names_expr=(r"1",), + kp_fw=200.0, + # max_current=1.75, + delay_min_lag=0, + delay_max_lag=3, +) + + +XL330_TESTBENCH_ROBOT_CFG = EntityCfg( + spec_fn=get_testbench_spec, + init_state=HOME_FRAME, + collisions=(), + articulation=EntityArticulationInfoCfg( + actuators=(testbench_actuators,), + soft_joint_pos_limit_factor=1.0, + ), +) diff --git a/src/mjlab_microduck/robot/xl330_test_bench/assets/arm.part b/src/mjlab_microduck/robot/xl330_test_bench/assets/arm.part new file mode 100644 index 0000000..51da6d4 --- /dev/null +++ b/src/mjlab_microduck/robot/xl330_test_bench/assets/arm.part @@ -0,0 +1,13 @@ +{ + "configuration": "default", + "documentId": "804927696f06d877f3f1803e", + "documentMicroversion": "fa58a038af2d285cadcbad4c", + "elementId": "67ba2439d4126a1259fbfee7", + "fullConfiguration": "default", + "id": "Mha8Sdmb9vh+2HA7n", + "isStandardContent": false, + "name": "arm <1>", + "partId": "JPD", + "suppressed": false, + "type": "Part" +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/xl330_test_bench/assets/arm.stl b/src/mjlab_microduck/robot/xl330_test_bench/assets/arm.stl new file mode 100644 index 0000000..a8dbbf4 Binary files /dev/null and b/src/mjlab_microduck/robot/xl330_test_bench/assets/arm.stl differ diff --git a/src/mjlab_microduck/robot/xl330_test_bench/assets/axis.part b/src/mjlab_microduck/robot/xl330_test_bench/assets/axis.part new file mode 100644 index 0000000..de3f789 --- /dev/null +++ b/src/mjlab_microduck/robot/xl330_test_bench/assets/axis.part @@ -0,0 +1,13 @@ +{ + "configuration": "default", + "documentId": "804927696f06d877f3f1803e", + "documentMicroversion": "fa58a038af2d285cadcbad4c", + "elementId": "67ba2439d4126a1259fbfee7", + "fullConfiguration": "default", + "id": "MKUZcFWSjWB0/agJb", + "isStandardContent": false, + "name": "axis <1>", + "partId": "JzD", + "suppressed": false, + "type": "Part" +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/xl330_test_bench/assets/axis.stl b/src/mjlab_microduck/robot/xl330_test_bench/assets/axis.stl new file mode 100644 index 0000000..7cd475f Binary files /dev/null and b/src/mjlab_microduck/robot/xl330_test_bench/assets/axis.stl differ diff --git a/src/mjlab_microduck/robot/xl330_test_bench/assets/bench_holder.part b/src/mjlab_microduck/robot/xl330_test_bench/assets/bench_holder.part new file mode 100644 index 0000000..62d6f93 --- /dev/null +++ b/src/mjlab_microduck/robot/xl330_test_bench/assets/bench_holder.part @@ -0,0 +1,13 @@ +{ + "configuration": "default", + "documentId": "804927696f06d877f3f1803e", + "documentMicroversion": "fa58a038af2d285cadcbad4c", + "elementId": "67ba2439d4126a1259fbfee7", + "fullConfiguration": "default", + "id": "MR8A9Ho1GCMhM6H01", + "isStandardContent": false, + "name": "bench_holder <1>", + "partId": "JhD", + "suppressed": false, + "type": "Part" +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/xl330_test_bench/assets/bench_holder.stl b/src/mjlab_microduck/robot/xl330_test_bench/assets/bench_holder.stl new file mode 100644 index 0000000..32cfa18 Binary files /dev/null and b/src/mjlab_microduck/robot/xl330_test_bench/assets/bench_holder.stl differ diff --git a/src/mjlab_microduck/robot/xl330_test_bench/assets/part_1.part b/src/mjlab_microduck/robot/xl330_test_bench/assets/part_1.part new file mode 100644 index 0000000..1c0a388 --- /dev/null +++ b/src/mjlab_microduck/robot/xl330_test_bench/assets/part_1.part @@ -0,0 +1,13 @@ +{ + "configuration": "default", + "documentId": "804927696f06d877f3f1803e", + "documentMicroversion": "5175a1e16fdf84a44266c7bc", + "elementId": "67ba2439d4126a1259fbfee7", + "fullConfiguration": "default", + "id": "Mha8Sdmb9vh+2HA7n", + "isStandardContent": false, + "name": "Part 1 <1>", + "partId": "JPD", + "suppressed": false, + "type": "Part" +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/xl330_test_bench/assets/part_1.stl b/src/mjlab_microduck/robot/xl330_test_bench/assets/part_1.stl new file mode 100644 index 0000000..a8dbbf4 Binary files /dev/null and b/src/mjlab_microduck/robot/xl330_test_bench/assets/part_1.stl differ diff --git a/src/mjlab_microduck/robot/xl330_test_bench/assets/part_2.part b/src/mjlab_microduck/robot/xl330_test_bench/assets/part_2.part new file mode 100644 index 0000000..8d4f8bf --- /dev/null +++ b/src/mjlab_microduck/robot/xl330_test_bench/assets/part_2.part @@ -0,0 +1,13 @@ +{ + "configuration": "default", + "documentId": "804927696f06d877f3f1803e", + "documentMicroversion": "5175a1e16fdf84a44266c7bc", + "elementId": "67ba2439d4126a1259fbfee7", + "fullConfiguration": "default", + "id": "MLOuSlzWOUNeu49vt", + "isStandardContent": false, + "name": "Part 2 <2>", + "partId": "JYD", + "suppressed": false, + "type": "Part" +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/xl330_test_bench/assets/part_2.stl b/src/mjlab_microduck/robot/xl330_test_bench/assets/part_2.stl new file mode 100644 index 0000000..8219a16 Binary files /dev/null and b/src/mjlab_microduck/robot/xl330_test_bench/assets/part_2.stl differ diff --git a/src/mjlab_microduck/robot/xl330_test_bench/assets/part_3.part b/src/mjlab_microduck/robot/xl330_test_bench/assets/part_3.part new file mode 100644 index 0000000..bd58d22 --- /dev/null +++ b/src/mjlab_microduck/robot/xl330_test_bench/assets/part_3.part @@ -0,0 +1,13 @@ +{ + "configuration": "default", + "documentId": "804927696f06d877f3f1803e", + "documentMicroversion": "5175a1e16fdf84a44266c7bc", + "elementId": "67ba2439d4126a1259fbfee7", + "fullConfiguration": "default", + "id": "MR8A9Ho1GCMhM6H01", + "isStandardContent": false, + "name": "Part 3 <1>", + "partId": "JhD", + "suppressed": false, + "type": "Part" +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/xl330_test_bench/assets/part_3.stl b/src/mjlab_microduck/robot/xl330_test_bench/assets/part_3.stl new file mode 100644 index 0000000..32cfa18 Binary files /dev/null and b/src/mjlab_microduck/robot/xl330_test_bench/assets/part_3.stl differ diff --git a/src/mjlab_microduck/robot/xl330_test_bench/assets/part_4.part b/src/mjlab_microduck/robot/xl330_test_bench/assets/part_4.part new file mode 100644 index 0000000..db6b2c2 --- /dev/null +++ b/src/mjlab_microduck/robot/xl330_test_bench/assets/part_4.part @@ -0,0 +1,13 @@ +{ + "configuration": "default", + "documentId": "804927696f06d877f3f1803e", + "documentMicroversion": "5175a1e16fdf84a44266c7bc", + "elementId": "67ba2439d4126a1259fbfee7", + "fullConfiguration": "default", + "id": "M+gOGPrN95DwFDy28", + "isStandardContent": false, + "name": "Part 4 <1>", + "partId": "JyD", + "suppressed": false, + "type": "Part" +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/xl330_test_bench/assets/part_4.stl b/src/mjlab_microduck/robot/xl330_test_bench/assets/part_4.stl new file mode 100644 index 0000000..b559084 Binary files /dev/null and b/src/mjlab_microduck/robot/xl330_test_bench/assets/part_4.stl differ diff --git a/src/mjlab_microduck/robot/xl330_test_bench/assets/part_5.part b/src/mjlab_microduck/robot/xl330_test_bench/assets/part_5.part new file mode 100644 index 0000000..2b87f2b --- /dev/null +++ b/src/mjlab_microduck/robot/xl330_test_bench/assets/part_5.part @@ -0,0 +1,13 @@ +{ + "configuration": "default", + "documentId": "804927696f06d877f3f1803e", + "documentMicroversion": "5175a1e16fdf84a44266c7bc", + "elementId": "67ba2439d4126a1259fbfee7", + "fullConfiguration": "default", + "id": "MKUZcFWSjWB0/agJb", + "isStandardContent": false, + "name": "Part 5 <1>", + "partId": "JzD", + "suppressed": false, + "type": "Part" +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/xl330_test_bench/assets/part_5.stl b/src/mjlab_microduck/robot/xl330_test_bench/assets/part_5.stl new file mode 100644 index 0000000..7cd475f Binary files /dev/null and b/src/mjlab_microduck/robot/xl330_test_bench/assets/part_5.stl differ diff --git a/src/mjlab_microduck/robot/xl330_test_bench/assets/spacer.part b/src/mjlab_microduck/robot/xl330_test_bench/assets/spacer.part new file mode 100644 index 0000000..292fd50 --- /dev/null +++ b/src/mjlab_microduck/robot/xl330_test_bench/assets/spacer.part @@ -0,0 +1,13 @@ +{ + "configuration": "default", + "documentId": "804927696f06d877f3f1803e", + "documentMicroversion": "fa58a038af2d285cadcbad4c", + "elementId": "67ba2439d4126a1259fbfee7", + "fullConfiguration": "default", + "id": "MLOuSlzWOUNeu49vt", + "isStandardContent": false, + "name": "spacer <2>", + "partId": "JYD", + "suppressed": false, + "type": "Part" +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/xl330_test_bench/assets/spacer.stl b/src/mjlab_microduck/robot/xl330_test_bench/assets/spacer.stl new file mode 100644 index 0000000..8219a16 Binary files /dev/null and b/src/mjlab_microduck/robot/xl330_test_bench/assets/spacer.stl differ diff --git a/src/mjlab_microduck/robot/xl330_test_bench/assets/weight.part b/src/mjlab_microduck/robot/xl330_test_bench/assets/weight.part new file mode 100644 index 0000000..06dd404 --- /dev/null +++ b/src/mjlab_microduck/robot/xl330_test_bench/assets/weight.part @@ -0,0 +1,13 @@ +{ + "configuration": "default", + "documentId": "804927696f06d877f3f1803e", + "documentMicroversion": "fa58a038af2d285cadcbad4c", + "elementId": "67ba2439d4126a1259fbfee7", + "fullConfiguration": "default", + "id": "M+gOGPrN95DwFDy28", + "isStandardContent": false, + "name": "weight <1>", + "partId": "JyD", + "suppressed": false, + "type": "Part" +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/xl330_test_bench/assets/weight.stl b/src/mjlab_microduck/robot/xl330_test_bench/assets/weight.stl new file mode 100644 index 0000000..b559084 Binary files /dev/null and b/src/mjlab_microduck/robot/xl330_test_bench/assets/weight.stl differ diff --git a/src/mjlab_microduck/robot/xl330_test_bench/assets/xl330.part b/src/mjlab_microduck/robot/xl330_test_bench/assets/xl330.part new file mode 100644 index 0000000..311a5fb --- /dev/null +++ b/src/mjlab_microduck/robot/xl330_test_bench/assets/xl330.part @@ -0,0 +1,13 @@ +{ + "configuration": "default", + "documentId": "804927696f06d877f3f1803e", + "documentMicroversion": "fa58a038af2d285cadcbad4c", + "elementId": "e34e27a4a091c95d26a71da8", + "fullConfiguration": "default", + "id": "MDyAYdJMHA21176gq", + "isStandardContent": false, + "name": "xl330 <1>", + "partId": "JND", + "suppressed": false, + "type": "Part" +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/xl330_test_bench/assets/xl330.stl b/src/mjlab_microduck/robot/xl330_test_bench/assets/xl330.stl new file mode 100644 index 0000000..0ea8d4f Binary files /dev/null and b/src/mjlab_microduck/robot/xl330_test_bench/assets/xl330.stl differ diff --git a/src/mjlab_microduck/robot/xl330_test_bench/config.json b/src/mjlab_microduck/robot/xl330_test_bench/config.json new file mode 100644 index 0000000..8d44975 --- /dev/null +++ b/src/mjlab_microduck/robot/xl330_test_bench/config.json @@ -0,0 +1,19 @@ +{ + "url": "https://cad.onshape.com/documents/804927696f06d877f3f1803e/w/3df6f6a8dcea50fb5658e3c0/e/54c1186243f1b4db10e4bd59", + "outputFormat": "mujoco", + "robot_name": "xl330_test_bench", + "output_filename": "xl330_test_bench", + "simplify_stls": true, + "max_stl_size": 1.0, + "ignore": { + "*": "collision", + }, + "additional_xml": [ + "joints_properties.xml", + ], + "joint_properties": { + "default": { + "class": "chosen_actuator" + } + } +} \ No newline at end of file diff --git a/src/mjlab_microduck/robot/xl330_test_bench/joints_properties.xml b/src/mjlab_microduck/robot/xl330_test_bench/joints_properties.xml new file mode 100644 index 0000000..c0406a1 --- /dev/null +++ b/src/mjlab_microduck/robot/xl330_test_bench/joints_properties.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/mjlab_microduck/robot/xl330_test_bench/scene.xml b/src/mjlab_microduck/robot/xl330_test_bench/scene.xml new file mode 100644 index 0000000..4b9fa89 --- /dev/null +++ b/src/mjlab_microduck/robot/xl330_test_bench/scene.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/mjlab_microduck/robot/xl330_test_bench/xl330_test_bench.xml b/src/mjlab_microduck/robot/xl330_test_bench/xl330_test_bench.xml new file mode 100644 index 0000000..14132ba --- /dev/null +++ b/src/mjlab_microduck/robot/xl330_test_bench/xl330_test_bench.xml @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/mjlab_microduck/tasks/__init__.py b/src/mjlab_microduck/tasks/__init__.py new file mode 100644 index 0000000..9e69fac --- /dev/null +++ b/src/mjlab_microduck/tasks/__init__.py @@ -0,0 +1,270 @@ +from mjlab.tasks.registry import register_mjlab_task +from mjlab.tasks.velocity.rl import VelocityOnPolicyRunner + + +class MicroduckOnPolicyRunner(VelocityOnPolicyRunner): + def __init__(self, env, train_cfg: dict, log_dir=None, device="cpu", **kwargs): + super().__init__(env, train_cfg, log_dir, device, **kwargs) + # resolve_symmetry_config injects _env into train_cfg["algorithm"]["symmetry_cfg"] + # in-place, sharing the same dict object with self.alg.symmetry. Replace the + # train_cfg reference with a copy that omits _env so dump_yaml can serialize the + # config (MjSpec is not picklable), without touching the PPO's internal reference. + alg = train_cfg.get("algorithm", {}) + sym = alg.get("symmetry_cfg") if isinstance(alg, dict) else None + if isinstance(sym, dict) and "_env" in sym: + alg["symmetry_cfg"] = {k: v for k, v in sym.items() if k != "_env"} + + +from .microduck_velocity_env_cfg import ( + make_microduck_velocity_env_cfg, + MicroduckRlCfg, +) +from .microduck_standup_env_cfg import ( + make_microduck_standup_env_cfg, + MicroduckStandUpRlCfg, +) +from .microduck_velstand_env_cfg import ( + make_microduck_velstand_env_cfg, + MicroduckVelStandRlCfg, +) +from .microduck_ground_pick_env_cfg import ( + make_microduck_ground_pick_env_cfg, + MicroduckGroundPickRlCfg, +) +from .microduck_ball_kick_env_cfg import ( + make_microduck_ball_kick_env_cfg, + MicroduckBallKickRlCfg, +) +from .microduck_sitstand_env_cfg import ( + make_microduck_sitstand_env_cfg, + MicroduckSitStandRlCfg, +) +from .microduck_velocity_rollers_env_cfg import ( + make_microduck_velocity_rollers_env_cfg, + MicroduckRollersRlCfg, +) +from .microduck_velocity_swizzle_env_cfg import ( + make_microduck_velocity_swizzle_env_cfg, + MicroduckSwizzleRlCfg, +) +from .microduck_roller_crouch_env_cfg import ( + make_microduck_roller_crouch_env_cfg, + MicroduckRollerCrouchRlCfg, +) +from .microduck_roller_slope_env_cfg import ( + make_microduck_roller_slope_env_cfg, + MicroduckRollerSlopeRlCfg, +) +from .microduck_roller_standup_env_cfg import ( + make_microduck_roller_standup_env_cfg, + MicroduckRollerStandUpRlCfg, +) +from .microduck_spin_env_cfg import ( + make_microduck_spin_env_cfg, + MicroduckSpinRlCfg, +) +from .microduck_roulade_env_cfg import ( + make_microduck_roulade_env_cfg, + MicroduckRouladeRlCfg, +) +from .backlash import make_backlash_variant + +# Standard velocity task +register_mjlab_task( + task_id="Mjlab-Velocity-Flat-MicroDuck", + env_cfg=make_microduck_velocity_env_cfg(), + play_env_cfg=make_microduck_velocity_env_cfg(play=True), + rl_cfg=MicroduckRlCfg, + runner_cls=MicroduckOnPolicyRunner, +) + +register_mjlab_task( + task_id="Mjlab-Velocity-Rough-MicroDuck", + env_cfg=make_microduck_velocity_env_cfg(rough=True), + play_env_cfg=make_microduck_velocity_env_cfg(play=True, rough=True), + rl_cfg=MicroduckRlCfg, + runner_cls=MicroduckOnPolicyRunner, +) + +# VelStand — walking + fall recovery + body pose control in one policy. +register_mjlab_task( + task_id="Mjlab-VelStand-Flat-MicroDuck", + env_cfg=make_microduck_velstand_env_cfg(), + play_env_cfg=make_microduck_velstand_env_cfg(play=True), + rl_cfg=MicroduckVelStandRlCfg, + runner_cls=MicroduckOnPolicyRunner, +) + +register_mjlab_task( + task_id="Mjlab-VelStand-Rough-MicroDuck", + env_cfg=make_microduck_velstand_env_cfg(rough=True), + play_env_cfg=make_microduck_velstand_env_cfg(play=True, rough=True), + rl_cfg=MicroduckVelStandRlCfg, + runner_cls=MicroduckOnPolicyRunner, +) + +# Stand-up task — robot starts inverted (lying on back) and must stand up +register_mjlab_task( + task_id="Mjlab-StandUp-Flat-MicroDuck", + env_cfg=make_microduck_standup_env_cfg(), + play_env_cfg=make_microduck_standup_env_cfg(play=True), + rl_cfg=MicroduckStandUpRlCfg, + runner_cls=MicroduckOnPolicyRunner, +) + +register_mjlab_task( + task_id="Mjlab-StandUp-Rough-MicroDuck", + env_cfg=make_microduck_standup_env_cfg(rough=True), + play_env_cfg=make_microduck_standup_env_cfg(play=True, rough=True), + rl_cfg=MicroduckStandUpRlCfg, + runner_cls=MicroduckOnPolicyRunner, +) + +# SitStand task — commanded sit ↔ stand in one policy, gently, head commandable +register_mjlab_task( + task_id="Mjlab-SitStand-Flat-MicroDuck", + env_cfg=make_microduck_sitstand_env_cfg(), + play_env_cfg=make_microduck_sitstand_env_cfg(play=True), + rl_cfg=MicroduckSitStandRlCfg, + runner_cls=MicroduckOnPolicyRunner, +) + +register_mjlab_task( + task_id="Mjlab-SitStand-Rough-MicroDuck", + env_cfg=make_microduck_sitstand_env_cfg(rough=True), + play_env_cfg=make_microduck_sitstand_env_cfg(play=True, rough=True), + rl_cfg=MicroduckSitStandRlCfg, + runner_cls=MicroduckOnPolicyRunner, +) + +# Ground-pick task — crouch, touch the ground with the mouth tip, return to stand +register_mjlab_task( + task_id="Mjlab-GroundPick-Flat-MicroDuck", + env_cfg=make_microduck_ground_pick_env_cfg(), + play_env_cfg=make_microduck_ground_pick_env_cfg(play=True), + rl_cfg=MicroduckGroundPickRlCfg, + runner_cls=MicroduckOnPolicyRunner, +) + +# BallKick task — kick a 70mm/15g ball forward hard with the right foot from a +# standing start (flat terrain only — a ball on rough terrain is another task). +register_mjlab_task( + task_id="Mjlab-BallKick-Flat-MicroDuck", + env_cfg=make_microduck_ball_kick_env_cfg(), + play_env_cfg=make_microduck_ball_kick_env_cfg(play=True), + rl_cfg=MicroduckBallKickRlCfg, + runner_cls=MicroduckOnPolicyRunner, +) + +register_mjlab_task( + task_id="Mjlab-GroundPick-Rough-MicroDuck", + env_cfg=make_microduck_ground_pick_env_cfg(rough=True), + play_env_cfg=make_microduck_ground_pick_env_cfg(play=True, rough=True), + rl_cfg=MicroduckGroundPickRlCfg, + runner_cls=MicroduckOnPolicyRunner, +) + +# Roller skate velocity task (passive-wheel model; historical task id kept) +register_mjlab_task( + task_id="Mjlab-Velocity-Flat-MicroDuck-Rollers", + env_cfg=make_microduck_velocity_rollers_env_cfg(), + play_env_cfg=make_microduck_velocity_rollers_env_cfg(play=True), + rl_cfg=MicroduckRollersRlCfg, + runner_cls=MicroduckOnPolicyRunner, +) + +# Roller SWIZZLE task — clean classic swizzle (symmetric, feet grounded). +register_mjlab_task( + task_id="Mjlab-Velocity-Swizzle-MicroDuck", + env_cfg=make_microduck_velocity_swizzle_env_cfg(), + play_env_cfg=make_microduck_velocity_swizzle_env_cfg(play=True), + rl_cfg=MicroduckSwizzleRlCfg, + runner_cls=MicroduckOnPolicyRunner, +) + +register_mjlab_task( + task_id="Mjlab-RollerCrouch-Flat-MicroDuck", + env_cfg=make_microduck_roller_crouch_env_cfg(), + play_env_cfg=make_microduck_roller_crouch_env_cfg(play=True), + rl_cfg=MicroduckRollerCrouchRlCfg, + runner_cls=MicroduckOnPolicyRunner, +) + +register_mjlab_task( + task_id="Mjlab-RollerSlope-Flat-MicroDuck", + env_cfg=make_microduck_roller_slope_env_cfg(), + play_env_cfg=make_microduck_roller_slope_env_cfg(play=True), + rl_cfg=MicroduckRollerSlopeRlCfg, + runner_cls=MicroduckOnPolicyRunner, +) + +# Roller STANDUP — se relever sur rollers (policy dédiée, départ au sol). +register_mjlab_task( + task_id="Mjlab-RollerStandUp-Flat-MicroDuck", + env_cfg=make_microduck_roller_standup_env_cfg(), + play_env_cfg=make_microduck_roller_standup_env_cfg(play=True), + rl_cfg=MicroduckRollerStandUpRlCfg, + runner_cls=MicroduckOnPolicyRunner, +) + +# Spin task — rotation rapide sur place, sur rollers (slot ground-pick). +register_mjlab_task( + task_id="Mjlab-Spin-Flat-MicroDuck", + env_cfg=make_microduck_spin_env_cfg(), + play_env_cfg=make_microduck_spin_env_cfg(play=True), + rl_cfg=MicroduckSpinRlCfg, + runner_cls=MicroduckOnPolicyRunner, +) + +# Roulade — forward roll over the flat head top, land back on the feet. +register_mjlab_task( + task_id="Mjlab-Roulade-Flat-MicroDuck", + env_cfg=make_microduck_roulade_env_cfg(), + play_env_cfg=make_microduck_roulade_env_cfg(play=True), + rl_cfg=MicroduckRouladeRlCfg, + runner_cls=MicroduckOnPolicyRunner, +) + +# Backlash variants — ±1° serial gear play per servo + encoder-through-backlash +# actuator feedback and joint obs (see tasks/backlash.py). Each family keeps its +# base task's collision model: Velocity → robot_walk_backlash.xml, +# VelStand/StandUp → robot_allcollisions_backlash.xml. Obs/action dims are +# unchanged vs the base tasks. +from mjlab_microduck.robot.microduck_constants import ( + MICRODUCK_BACKLASH_ROBOT_CFG, + MICRODUCK_ROLLERS_BACKLASH_ROBOT_CFG, + MICRODUCK_WALK_BACKLASH_ROBOT_CFG, +) + +# (task_id, make_fn, make_kwargs, rl_cfg, backlash robot cfg). Task ids mirror +# the base ids with "-Backlash" inserted. Walk-model tasks get the walk +# backlash robot, roller tasks the wheels+backlash robot, the rest the +# allcollisions backlash robot — same model as their base task in each case. +_BL_ALLCOL = MICRODUCK_BACKLASH_ROBOT_CFG +_BL_WALK = MICRODUCK_WALK_BACKLASH_ROBOT_CFG +_BL_ROLLERS = MICRODUCK_ROLLERS_BACKLASH_ROBOT_CFG +_BACKLASH_TASKS = ( + ("Mjlab-Velocity-Flat-Backlash-MicroDuck", make_microduck_velocity_env_cfg, {}, MicroduckRlCfg, _BL_WALK), + ("Mjlab-Velocity-Rough-Backlash-MicroDuck", make_microduck_velocity_env_cfg, {"rough": True}, MicroduckRlCfg, _BL_WALK), + ("Mjlab-VelStand-Flat-Backlash-MicroDuck", make_microduck_velstand_env_cfg, {}, MicroduckVelStandRlCfg, _BL_ALLCOL), + ("Mjlab-VelStand-Rough-Backlash-MicroDuck", make_microduck_velstand_env_cfg, {"rough": True}, MicroduckVelStandRlCfg, _BL_ALLCOL), + ("Mjlab-StandUp-Flat-Backlash-MicroDuck", make_microduck_standup_env_cfg, {}, MicroduckStandUpRlCfg, _BL_ALLCOL), + ("Mjlab-StandUp-Rough-Backlash-MicroDuck", make_microduck_standup_env_cfg, {"rough": True}, MicroduckStandUpRlCfg, _BL_ALLCOL), + ("Mjlab-SitStand-Flat-Backlash-MicroDuck", make_microduck_sitstand_env_cfg, {}, MicroduckSitStandRlCfg, _BL_ALLCOL), + ("Mjlab-SitStand-Rough-Backlash-MicroDuck", make_microduck_sitstand_env_cfg, {"rough": True}, MicroduckSitStandRlCfg, _BL_ALLCOL), + ("Mjlab-GroundPick-Flat-Backlash-MicroDuck", make_microduck_ground_pick_env_cfg, {}, MicroduckGroundPickRlCfg, _BL_ALLCOL), + ("Mjlab-GroundPick-Rough-Backlash-MicroDuck", make_microduck_ground_pick_env_cfg, {"rough": True}, MicroduckGroundPickRlCfg, _BL_ALLCOL), + ("Mjlab-BallKick-Flat-Backlash-MicroDuck", make_microduck_ball_kick_env_cfg, {}, MicroduckBallKickRlCfg, _BL_ALLCOL), + ("Mjlab-Velocity-Flat-Backlash-MicroDuck-Rollers", make_microduck_velocity_rollers_env_cfg, {}, MicroduckRollersRlCfg, _BL_ROLLERS), + ("Mjlab-Velocity-Swizzle-Backlash-MicroDuck", make_microduck_velocity_swizzle_env_cfg, {}, MicroduckSwizzleRlCfg, _BL_ROLLERS), + ("Mjlab-RollerCrouch-Flat-Backlash-MicroDuck", make_microduck_roller_crouch_env_cfg, {}, MicroduckRollerCrouchRlCfg, _BL_ROLLERS), + ("Mjlab-RollerSlope-Flat-Backlash-MicroDuck", make_microduck_roller_slope_env_cfg, {}, MicroduckRollerSlopeRlCfg, _BL_ROLLERS), +) +for _task_id, _make_cfg, _kw, _rl_cfg, _robot_cfg in _BACKLASH_TASKS: + register_mjlab_task( + task_id=_task_id, + env_cfg=make_backlash_variant(_make_cfg(**_kw), _robot_cfg), + play_env_cfg=make_backlash_variant(_make_cfg(play=True, **_kw), _robot_cfg), + rl_cfg=_rl_cfg, + runner_cls=MicroduckOnPolicyRunner, + ) diff --git a/src/mjlab_microduck/tasks/backlash.py b/src/mjlab_microduck/tasks/backlash.py new file mode 100644 index 0000000..44fdbb7 --- /dev/null +++ b/src/mjlab_microduck/tasks/backlash.py @@ -0,0 +1,92 @@ +"""Backlash task variants — swap in the backlash robot model + encoder obs. + +``make_backlash_variant(cfg)`` turns any microduck env cfg into its backlash +counterpart (task ids ``Mjlab---Backlash-MicroDuck``): + +1. Robot → the matching backlash robot cfg (an unactuated + ``passive__backlash`` hinge in series with each of the 14 servo + joints, ±1° play) driven by BacklashEncoderBamActuator, whose firmware PD + closes on the encoder READING THROUGH the backlash — like the real servo, + whose encoder sits on the output side of the gear play. Pass the robot cfg + that mirrors the base task's model: MICRODUCK_WALK_BACKLASH_ROBOT_CFG for + Velocity (robot_walk_backlash.xml), the default + MICRODUCK_BACKLASH_ROBOT_CFG for VelStand/StandUp + (robot_allcollisions_backlash.xml). +2. joint_pos / joint_vel obs → joint_pos_rel_backlash / joint_vel_rel_backlash: + the policy observes qpos[servo] + qpos[backlash] (encoder view), keeping the + encoder-bias DR path (``biased`` param) intact. Obs and action dims are + unchanged (still 14 joints), so runtime/export need no changes. +3. dof_pos_limits reward is scoped to the servo joints: backlash joints spend + their life pinned against their ±1° limits (that is the point of backlash), + which would otherwise feed a permanent out-of-soft-limit penalty. + +Everything else (rewards, DR events, curricula) carries over untouched — the +``passive_`` prefix on the backlash joints means every existing +``^(?!passive_).*`` regex (actuators, pose reward, joint obs selection) +already excludes them. +""" + +from copy import deepcopy + +from mjlab.envs import ManagerBasedRlEnvCfg +from mjlab.managers.scene_entity_config import SceneEntityCfg + +from mjlab.entity import EntityCfg + +from mjlab_microduck.robot.microduck_constants import MICRODUCK_BACKLASH_ROBOT_CFG +from mjlab_microduck.tasks import mdp as microduck_mdp + +_SERVO_JOINTS_ONLY = (r"^(?!passive_).*",) + + +def make_backlash_variant( + cfg: ManagerBasedRlEnvCfg, + robot_cfg: EntityCfg = MICRODUCK_BACKLASH_ROBOT_CFG, +) -> ManagerBasedRlEnvCfg: + """Convert a microduck env cfg (velocity/velstand/standup/...) to backlash.""" + cfg.scene.entities = {**cfg.scene.entities, "robot": robot_cfg} + + for group in ("actor", "critic"): + terms = cfg.observations[group].terms + for term_name, func in ( + ("joint_pos", microduck_mdp.joint_pos_rel_backlash), + ("joint_vel", microduck_mdp.joint_vel_rel_backlash), + ): + term = terms.get(term_name) + if term is None: + continue + term.func = func + # Envs that never narrowed the selection would otherwise feed the + # backlash joints themselves into the obs (wrong dim + double count). + if "asset_cfg" not in term.params: + term.params["asset_cfg"] = SceneEntityCfg( + "robot", joint_names=_SERVO_JOINTS_ONLY + ) + + # Backlash joints legitimately ride their hard limits — exclude them from + # the soft-limit penalty (its default asset_cfg covers every joint). + dof_limits = cfg.rewards.get("dof_pos_limits") + if dof_limits is not None and "asset_cfg" not in dof_limits.params: + dof_limits.params["asset_cfg"] = SceneEntityCfg( + "robot", joint_names=_SERVO_JOINTS_ONLY + ) + + # The pose (variable_posture) reward resolves its std dicts against the + # selected joint names and ERRORS on ambiguous matches — on the backlash + # model "passive_left_hip_yaw_backlash" matches both ".*hip_yaw.*" and the + # roller envs' ".*passive_.*" std entry. Prepend a backlash exclusion to + # the selection; existing lookaheads (velocity's passive/neck/head + # exclusion) compose fine, and envs that keep wheels selected still get + # them. + pose = cfg.rewards.get("pose") + if pose is not None and "asset_cfg" in pose.params: + # Deepcopy first — base templates share SceneEntityCfg objects across + # make() calls; mutating in place would leak into the base tasks. + ac = deepcopy(pose.params["asset_cfg"]) + ac.joint_names = tuple( + p if "_backlash" in p else r"^(?!passive_.*_backlash)" + p.lstrip("^") + for p in ac.joint_names + ) + pose.params["asset_cfg"] = ac + + return cfg diff --git a/src/mjlab_microduck/tasks/mdp.py b/src/mjlab_microduck/tasks/mdp.py new file mode 100644 index 0000000..f8dfcba --- /dev/null +++ b/src/mjlab_microduck/tasks/mdp.py @@ -0,0 +1,7188 @@ +"""MDP functions for microduck tasks""" + +import math +from dataclasses import dataclass as _dataclass + +import numpy as np +import torch +from typing import TYPE_CHECKING, Optional +import mujoco + +from mjlab.envs.manager_based_rl_env import ManagerBasedRlEnv +from mjlab.managers.scene_entity_config import SceneEntityCfg +from mjlab.managers.reward_manager import RewardManager as _RewardManager +from mjlab.entity import Entity +from mjlab.tasks.velocity.mdp.velocity_command import UniformVelocityCommand, UniformVelocityCommandCfg +from mjlab.tasks.velocity.mdp import observations as _velocity_obs +from mjlab.managers.command_manager import CommandTerm +from mjlab.managers import CommandTermCfg +from mjlab.managers.event_manager import requires_model_fields +from mjlab.utils.lab_api.math import matrix_from_quat, wrap_to_pi, quat_apply, quat_from_angle_axis +from rsl_rl.algorithms.ppo import PPO as _PPO + +# --------------------------------------------------------------------------- +# Patch 1: RewardManager.compute — sanitize NaN rewards before they enter the +# PPO buffer. mjlab computes rewards BEFORE resetting environments, so any +# reward term operating on a NaN physics state returns NaN. That NaN +# propagates: NaN reward → NaN advantage → NaN loss → NaN gradient → +# NaN/negative std → crash in torch.normal on the next mini-batch. +# --------------------------------------------------------------------------- +_orig_reward_compute = _RewardManager.compute + +def _nan_safe_reward_compute(self, dt: float) -> torch.Tensor: + result = _orig_reward_compute(self, dt) + # _episode_sums is updated inside compute() before nan_to_num can act. + # Sanitize in-place so per-term metrics don't show NaN. + for key in self._episode_sums: + torch.nan_to_num_(self._episode_sums[key], nan=0.0) + return torch.nan_to_num(result, nan=0.0) + +_RewardManager.compute = _nan_safe_reward_compute + +# --------------------------------------------------------------------------- +# Patch 2: PPO.compute_returns — sanitize advantages before normalization. +# At a sudden curriculum step (e.g. reward weight ×2.5) the value function is +# badly wrong: all TD errors shift by the same amount, std(advantages) → tiny, +# and (A − mean) / (std + 1e-8) → huge. That blows up the gradient for std, +# which the optimizer then pushes below zero. Zeroing NaN/Inf advantages +# before normalization keeps them in a safe range. +# --------------------------------------------------------------------------- +_orig_compute_returns = _PPO.compute_returns + +def _safe_compute_returns(self, obs) -> None: + _orig_compute_returns(self, obs) + st = self.storage + torch.nan_to_num_(st.advantages, nan=0.0, posinf=0.0, neginf=0.0) + torch.nan_to_num_(st.returns, nan=0.0, posinf=0.0, neginf=0.0) + +_PPO.compute_returns = _safe_compute_returns + +# Patch 3 (ActorCritic._update_distribution std-clamp) was REMOVED in the mjlab +# 1.3.0 migration: rsl_rl 5.0.1 refactored the policy (no ActorCritic class; the +# distribution now lives in rsl_rl.modules.distribution). It was a defensive +# band-aid against std going negative/NaN (microban runs fine without it). If +# std-blowup recurs under 1.3.0, reinstate it against the new GaussianDistribution. + +print("[mdp] Patches 1-2 active: NaN-safe reward/advantage") + +# --------------------------------------------------------------------------- +# Patch 4: exporter_utils.get_base_metadata — the new microduck model has +# passive joints (jaw linkage closed via equality constraints) that are part +# of the articulation but have no XML actuator. The upstream exporter +# iterates robot.joint_names (16) and indexes joint_name_to_ctrl_id (14), +# crashing with KeyError on passive_*. Filter passive joints out of the +# exported metadata so policies stay consistent with the 14-dim action space. +# --------------------------------------------------------------------------- +from mjlab.rl import exporter_utils as _exporter_utils # noqa: E402 +from mjlab.envs.mdp.actions import JointPositionAction as _JointAction # noqa: E402 + +def _get_base_metadata_no_passive(env, run_path): + robot = env.scene["robot"] + joint_action = env.action_manager.get_term("joint_pos") + assert isinstance(joint_action, _JointAction) + full_names = list(robot.joint_names) + keep_idx = [i for i, n in enumerate(full_names) if not n.startswith("passive_")] + joint_names = [full_names[i] for i in keep_idx] + joint_name_to_ctrl_id = {a.target.split("/")[-1]: a.id for a in robot.spec.actuators} + ctrl_ids = [joint_name_to_ctrl_id[n] for n in joint_names] + stiffness = env.sim.mj_model.actuator_gainprm[ctrl_ids, 0] + damping = -env.sim.mj_model.actuator_biasprm[ctrl_ids, 2] + default_jp = robot.data.default_joint_pos[0].cpu().tolist() + return { + "run_path": run_path, + "joint_names": joint_names, + "joint_stiffness": stiffness.tolist(), + "joint_damping": damping.tolist(), + "default_joint_pos": [default_jp[i] for i in keep_idx], + "command_names": list(env.command_manager.active_terms), + "observation_names": env.observation_manager.active_terms["actor"], + "action_scale": joint_action._scale[0].cpu().tolist() + if isinstance(joint_action._scale, torch.Tensor) + else joint_action._scale, + } + +_exporter_utils.get_base_metadata = _get_base_metadata_no_passive +# Also patch the already-imported reference in the velocity task exporter. +try: + from mjlab.tasks.velocity.rl import exporter as _vel_exporter # noqa: E402 + if hasattr(_vel_exporter, "get_base_metadata"): + _vel_exporter.get_base_metadata = _get_base_metadata_no_passive +except Exception: + pass + +print("[mdp] Patch 4 active: ONNX export filters passive_* joints") + +if TYPE_CHECKING: + from mjlab.viewer.debug_visualizer import DebugVisualizer + + +_DEFAULT_ASSET_CFG = SceneEntityCfg("robot") + +# Name patterns matching the 4 neck/head actuated joints. Used by head_pose +# tracking reward and by UniformPoseCommand asset hookups. +_NECK_JOINT_PATTERNS = [r".*neck_pitch.*", r".*head_pitch.*", r".*head_yaw.*", r".*head_roll.*"] + + +def _servo_joint_ids(env: "ManagerBasedRlEnv", asset: Entity) -> list: + """Entity-local indices of the servo (non-``passive_``) joints, cached. + + All joint-index-based reward/event params in this module (``joint_indices``, + ``target_overrides``, qpos-column math) are written against the canonical + 14-servo layout. On models with extra unactuated joints — backlash hinges, + roller wheels, the jaw linkage, all named ``passive_*`` — the entity joint + array is wider and interleaved, so raw indices would select the wrong + joints. Index through this list to recover the servo-only view; on plain + models it is the identity. + """ + cache = env.__dict__.setdefault("_servo_joint_ids_cache", {}) + key = id(asset) + ids = cache.get(key) + if ids is None: + ids, _ = asset.find_joints(r"^(?!passive_).*") + cache[key] = ids + return ids + + +def _servo_joint_pos(env: "ManagerBasedRlEnv", asset: Entity) -> torch.Tensor: + return asset.data.joint_pos[:, _servo_joint_ids(env, asset)] + + +def _servo_joint_vel(env: "ManagerBasedRlEnv", asset: Entity) -> torch.Tensor: + return asset.data.joint_vel[:, _servo_joint_ids(env, asset)] + + +def _servo_default_joint_pos(env: "ManagerBasedRlEnv", asset: Entity) -> torch.Tensor: + return asset.data.default_joint_pos[:, _servo_joint_ids(env, asset)] + + +def reset_with_forward_velocity( + env: ManagerBasedRlEnv, + env_ids: torch.Tensor, + velocity_range: tuple[float, float] = (0.3, 0.8), + fraction_stages: list[dict] | None = None, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> None: + """Warm-start a fraction of reset environments with a random forward velocity. + + The robot spawns already moving in its body-forward direction, so it first + discovers what coasting at speed feels like. The fraction decreases over + training, forcing it to progressively earn that speed from rest. + + Args: + velocity_range: (min, max) forward speed in m/s. + fraction_stages: list of {"step": int, "fraction": float} dicts, sorted by step. + The fraction active at the current training step is used. + Example: [{"step":0,"fraction":0.8}, {"step":2000*24,"fraction":0.0}] + asset_cfg: robot entity config. + """ + if fraction_stages is None: + fraction_stages = [{"step": 0, "fraction": 0.8}] + + # Determine current fraction from training step + step = env.common_step_counter + fraction = fraction_stages[0]["fraction"] + for stage in fraction_stages: + if step >= stage["step"]: + fraction = stage["fraction"] + + if len(env_ids) == 0 or fraction <= 0.0: + return + + n_warmstart = max(1, int(len(env_ids) * fraction)) + perm = torch.randperm(len(env_ids), device=env.device)[:n_warmstart] + warmstart_ids = env_ids[perm] + + lo, hi = velocity_range + vx = lo + torch.rand(n_warmstart, device=env.device) * (hi - lo) + + # Build horizontal forward direction from yaw only — ignoring pitch/roll. + # IMPORTANT: read quaternion from qpos, NOT from root_link_quat_w. + # root_link_quat_w reads xquat which requires sim.forward() to be current. + # After reset_base writes a new yaw to qpos, xquat is still stale (old episode). + # qpos is updated immediately by write_root_pose, so it's always fresh. + asset: Entity = env.scene[asset_cfg.name] + qpos_q_adr = asset.data.indexing.free_joint_q_adr[3:7] # quat indices in qpos + q = asset.data.data.qpos[warmstart_ids][:, qpos_q_adr] # (n, 4) [w, x, y, z] + w, x, y, z = q[:, 0], q[:, 1], q[:, 2], q[:, 3] + yaw = torch.atan2(2.0 * (w * z + x * y), 1.0 - 2.0 * (y * y + z * z)) + forward_world = torch.stack([torch.cos(yaw), torch.sin(yaw), torch.zeros_like(yaw)], dim=-1) + + velocities = torch.zeros(n_warmstart, 6, device=env.device) + velocities[:, :3] = vx.unsqueeze(-1) * forward_world + + asset.write_root_link_velocity_to_sim(velocities, env_ids=warmstart_ids) + + # Spin wheels to match forward velocity — prevents instantaneous no-slip braking. + # Wheel radius = 0.0175 m (measured). + # All 4 wheels spin at +ω for forward motion (verified by test_wheel_direction.py). + _WHEEL_RADIUS = 0.0175 + all_wheel_ids, _ = asset.find_joints(r"^passive_.*") + + if all_wheel_ids: + joint_pos = asset.data.joint_pos[warmstart_ids].clone() + joint_vel = asset.data.joint_vel[warmstart_ids].clone() + omega = vx / _WHEEL_RADIUS # (n,) rad/s, positive = forward + joint_vel[:, all_wheel_ids] = omega.unsqueeze(-1).expand(-1, len(all_wheel_ids)) + asset.write_joint_state_to_sim(joint_pos, joint_vel, env_ids=warmstart_ids) + + +def reset_action_history( + env: ManagerBasedRlEnv, + env_ids: torch.Tensor, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +): + """ + Reset cached action history for environments that are being reset. + This is critical for action rate and acceleration penalty terms. + + This function should be called in the post_reset callback or at episode termination. + + Args: + env: The environment + env_ids: Indices of environments being reset + asset_cfg: Asset configuration + """ + if len(env_ids) == 0: + return + + asset: Entity = env.scene[asset_cfg.name] + + # Reset leg action rate cache + if hasattr(env, '_prev_leg_actions'): + # Set to current action (or zero if no action yet) + if hasattr(env, 'action_manager') and env.action_manager.action is not None: + leg_joint_indices = list(range(0, 5)) + list(range(9, 14)) + env._prev_leg_actions[env_ids] = env.action_manager.action[env_ids][:, leg_joint_indices] + else: + env._prev_leg_actions[env_ids] = 0.0 + + # Reset neck action rate cache + if hasattr(env, '_prev_neck_actions'): + if hasattr(env, 'action_manager') and env.action_manager.action is not None: + neck_joint_indices = list(range(5, 9)) + env._prev_neck_actions[env_ids] = env.action_manager.action[env_ids][:, neck_joint_indices] + else: + env._prev_neck_actions[env_ids] = 0.0 + + # Reset leg action acceleration cache + if hasattr(env, '_prev_leg_actions_for_acc'): + if hasattr(env, 'action_manager') and env.action_manager.action is not None: + leg_joint_indices = list(range(0, 5)) + list(range(9, 14)) + current_action = env.action_manager.action[env_ids][:, leg_joint_indices] + env._prev_leg_actions_for_acc[env_ids] = current_action + env._prev_prev_leg_actions_for_acc[env_ids] = current_action + else: + env._prev_leg_actions_for_acc[env_ids] = 0.0 + env._prev_prev_leg_actions_for_acc[env_ids] = 0.0 + + # Reset neck action acceleration cache + if hasattr(env, '_prev_neck_actions_for_acc'): + if hasattr(env, 'action_manager') and env.action_manager.action is not None: + neck_joint_indices = list(range(5, 9)) + current_action = env.action_manager.action[env_ids][:, neck_joint_indices] + env._prev_neck_actions_for_acc[env_ids] = current_action + env._prev_prev_neck_actions_for_acc[env_ids] = current_action + else: + env._prev_neck_actions_for_acc[env_ids] = 0.0 + env._prev_prev_neck_actions_for_acc[env_ids] = 0.0 + + # Reset joint velocity cache for joint accelerations + if hasattr(asset.data, '_prev_joint_vel'): + # Get current joint velocities for reset environments + joint_vel = asset.data.joint_vel[env_ids, :][:, asset_cfg.joint_ids] + asset.data._prev_joint_vel[env_ids] = joint_vel + + # Reset contact frequency tracking + if hasattr(env, '_contact_change_count'): + env._contact_change_count[env_ids] = 0.0 + if hasattr(env, '_contact_change_timer'): + env._contact_change_timer[env_ids] = 0.0 + if hasattr(env, '_prev_contacts_for_freq'): + if "feet_ground_contact" in env.scene.sensors: + contacts = env.scene.sensors["feet_ground_contact"].data.found[env_ids, :2] + env._prev_contacts_for_freq[env_ids] = contacts + + # Reset foot force smoothness tracking + if hasattr(env, '_prev_foot_forces'): + if "feet_ground_contact" in env.scene.sensors: + forces = env.scene.sensors["feet_ground_contact"].data.found[env_ids, :2].squeeze(-1) + env._prev_foot_forces[env_ids] = forces + + # Reset actuator torque rate tracking + if hasattr(env, '_prev_actuator_forces'): + env._prev_actuator_forces[env_ids] = asset.data.actuator_force[env_ids].clone() + + +def joint_accelerations_l2( + env: ManagerBasedRlEnv, asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG +) -> torch.Tensor: + """ + Penalize joint accelerations using L2 squared norm. + Joint accelerations are computed using finite differences of joint velocities. + + Args: + env: The environment + asset_cfg: Asset configuration + + Returns: + Penalty tensor of shape (num_envs,) - sum of squared joint accelerations + """ + asset: Entity = env.scene[asset_cfg.name] + + # Get current joint velocities + joint_vel = asset.data.joint_vel[:, asset_cfg.joint_ids] + + # Get previous joint velocities (stored in asset data) + # Note: This assumes the environment stores previous joint velocities + if not hasattr(asset.data, '_prev_joint_vel'): + # Initialize on first call + asset.data._prev_joint_vel = joint_vel.clone() + return torch.zeros(env.num_envs, device=env.device) + + # Compute joint accelerations using finite differences + dt = env.step_dt + joint_acc = (joint_vel - asset.data._prev_joint_vel) / dt + + # Store current velocities for next step + asset.data._prev_joint_vel = joint_vel.clone() + + # Return L2 squared norm + return torch.sum(torch.square(joint_acc), dim=1) + + +def leg_action_rate_l2( + env: ManagerBasedRlEnv, asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG +) -> torch.Tensor: + """ + Penalize the rate of change of leg actions (action_t - action_{t-1}). + Leg joints are indices 0-4 and 9-13 (10 joints total). + + Args: + env: The environment + asset_cfg: Asset configuration + + Returns: + Penalty tensor of shape (num_envs,) + """ + # Get leg joint indices + leg_joint_indices = list(range(0, 5)) + list(range(9, 14)) + + # Get current and previous actions for leg joints only + # Actions are stored in env (assuming the action is available) + if not hasattr(env, 'action_manager'): + return torch.zeros(env.num_envs, device=env.device) + + # Get the joint position action + actions = env.action_manager.action + if actions.shape[1] < 14: + return torch.zeros(env.num_envs, device=env.device) + + leg_actions = actions[:, leg_joint_indices] + + if not hasattr(env, '_prev_leg_actions'): + env._prev_leg_actions = leg_actions.clone() + return torch.zeros(env.num_envs, device=env.device) + + action_rate = leg_actions - env._prev_leg_actions + env._prev_leg_actions = leg_actions.clone() + + return torch.sum(torch.square(action_rate), dim=1) + + +def neck_action_rate_l2( + env: ManagerBasedRlEnv, asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG +) -> torch.Tensor: + """ + Penalize the rate of change of neck actions (action_t - action_{t-1}). + Neck joints are indices 5-8 (4 joints total). + + Args: + env: The environment + asset_cfg: Asset configuration + + Returns: + Penalty tensor of shape (num_envs,) + """ + # Get neck joint indices + neck_joint_indices = list(range(5, 9)) + + # Get current and previous actions for neck joints only + if not hasattr(env, 'action_manager'): + return torch.zeros(env.num_envs, device=env.device) + + actions = env.action_manager.action + if actions.shape[1] < 14: + return torch.zeros(env.num_envs, device=env.device) + + neck_actions = actions[:, neck_joint_indices] + + if not hasattr(env, '_prev_neck_actions'): + env._prev_neck_actions = neck_actions.clone() + return torch.zeros(env.num_envs, device=env.device) + + action_rate = neck_actions - env._prev_neck_actions + env._prev_neck_actions = neck_actions.clone() + + return torch.sum(torch.square(action_rate), dim=1) + + +def leg_action_acceleration_l2( + env: ManagerBasedRlEnv, asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG +) -> torch.Tensor: + """ + Penalize leg action accelerations (action_t - 2*action_{t-1} + action_{t-2}). + Leg joints are indices 0-4 and 9-13 (10 joints total). + + Args: + env: The environment + asset_cfg: Asset configuration + + Returns: + Penalty tensor of shape (num_envs,) + """ + # Get leg joint indices + leg_joint_indices = list(range(0, 5)) + list(range(9, 14)) + + if not hasattr(env, 'action_manager'): + return torch.zeros(env.num_envs, device=env.device) + + actions = env.action_manager.action + if actions.shape[1] < 14: + return torch.zeros(env.num_envs, device=env.device) + + leg_actions = actions[:, leg_joint_indices] + + if not hasattr(env, '_prev_leg_actions_for_acc'): + env._prev_leg_actions_for_acc = leg_actions.clone() + env._prev_prev_leg_actions_for_acc = leg_actions.clone() + return torch.zeros(env.num_envs, device=env.device) + + action_acc = leg_actions - 2 * env._prev_leg_actions_for_acc + env._prev_prev_leg_actions_for_acc + + env._prev_prev_leg_actions_for_acc = env._prev_leg_actions_for_acc.clone() + env._prev_leg_actions_for_acc = leg_actions.clone() + + return torch.sum(torch.square(action_acc), dim=1) + + +def neck_action_acceleration_l2( + env: ManagerBasedRlEnv, asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG +) -> torch.Tensor: + """ + Penalize neck action accelerations (action_t - 2*action_{t-1} + action_{t-2}). + Neck joints are indices 5-8 (4 joints total). + + Args: + env: The environment + asset_cfg: Asset configuration + + Returns: + Penalty tensor of shape (num_envs,) + """ + # Get neck joint indices + neck_joint_indices = list(range(5, 9)) + + if not hasattr(env, 'action_manager'): + return torch.zeros(env.num_envs, device=env.device) + + actions = env.action_manager.action + if actions.shape[1] < 14: + return torch.zeros(env.num_envs, device=env.device) + + neck_actions = actions[:, neck_joint_indices] + + if not hasattr(env, '_prev_neck_actions_for_acc'): + env._prev_neck_actions_for_acc = neck_actions.clone() + env._prev_prev_neck_actions_for_acc = neck_actions.clone() + return torch.zeros(env.num_envs, device=env.device) + + action_acc = neck_actions - 2 * env._prev_neck_actions_for_acc + env._prev_prev_neck_actions_for_acc + + env._prev_prev_neck_actions_for_acc = env._prev_neck_actions_for_acc.clone() + env._prev_neck_actions_for_acc = neck_actions.clone() + + return torch.sum(torch.square(action_acc), dim=1) + + +def _fallen_mask( + env: ManagerBasedRlEnv, + asset, + gate_z_below: float, + gate_tilt_above_deg: float, +) -> torch.Tensor: + """Per-env float mask: 1.0 where the robot counts as FALLEN — trunk height + below `gate_z_below` OR tilt beyond `gate_tilt_above_deg`. Used to gate the + recovery rewards so they only steer while actually fallen and contribute + exactly zero during clean walking (no walk tax / bounce farming).""" + z = torch.nan_to_num( + asset.data.root_link_pos_w[:, 2] - env.scene.terrain.env_origins[:, 2], nan=0.0 + ) + quat = asset.data.root_link_quat_w + # cos(tilt) = R22 = 1 - 2(qx² + qy²) + cos_tilt = 1.0 - 2.0 * (quat[:, 1] ** 2 + quat[:, 2] ** 2) + fallen = (z < gate_z_below) | (cos_tilt < math.cos(math.radians(gate_tilt_above_deg))) + return fallen.float() + + +def feet_air_time_upright( + env: ManagerBasedRlEnv, + gate_tilt_above_deg: float = 40.0, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, + **air_time_kwargs, +) -> torch.Tensor: + """velocity template feet_air_time, zeroed while FALLEN (tilt > gate). + + velstand: a robot lying on its trunk can still tap its feet rhythmically + through the air-time window — the observed "lies there shaking a leg" + exploit. Air time is only meaningful upright. + """ + from mjlab.tasks.velocity.mdp import feet_air_time as _template_air_time + reward = _template_air_time(env, **air_time_kwargs) + asset: Entity = env.scene[asset_cfg.name] + upright = 1.0 - _fallen_mask(env, asset, 0.0, gate_tilt_above_deg) + return reward * upright + + +def upright_progress( + env: ManagerBasedRlEnv, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """Potential-based upright shaping: Δcos(tilt) per step. + + Pays for PROGRESS toward upright, charges for progress toward fallen, and + pays exactly ZERO for holding any pose — so no state can farm it (the gated + state-reward it replaces was farmed from sitting, lying flat, and a + head-tripod lean across three velstand runs). Potential-based shaping is + policy-invariant (Ng et al.): it accelerates learning of recovery without + creating new optima. A full prone→stand recovery collects Δ≈+1 total + (× weight); a fall costs the same on the way down. + """ + asset: Entity = env.scene[asset_cfg.name] + quat = asset.data.root_link_quat_w + cos_tilt = torch.nan_to_num( + 1.0 - 2.0 * (quat[:, 1] ** 2 + quat[:, 2] ** 2), nan=1.0 + ) + if not hasattr(env, "_upright_potential_prev"): + env._upright_potential_prev = cos_tilt.clone() + # Freshly reset envs: no spurious delta from the previous episode's pose. + fresh = env.episode_length_buf <= 1 + env._upright_potential_prev[fresh] = cos_tilt[fresh] + delta = cos_tilt - env._upright_potential_prev + env._upright_potential_prev = cos_tilt.clone() + return delta + + +def height_progress( + env: ManagerBasedRlEnv, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, + ceiling: float = 0.115, +) -> torch.Tensor: + """Potential-based height shaping: Δ min(trunk z, ceiling) per step. + + The z-axis companion to ``upright_progress`` (velstand crouch-endpoint + lesson): the last mile of a recovery — extending the knees out of a deep + crouch — is mostly a HEIGHT change at modest tilt, exactly where the + Gaussian upright/pose rewards are flat and Δcos(tilt) is tiny. Rising pays, + falling charges, holding pays zero, so gait bobbing nets zero and nothing + can farm it. Capped at ``ceiling`` (just below full-stand trunk z ≈ 0.117) + so hopping above stance height pays nothing extra. + """ + asset: Entity = env.scene[asset_cfg.name] + z = torch.nan_to_num( + asset.data.root_link_pos_w[:, 2] - env.scene.terrain.env_origins[:, 2], nan=0.0 + ) + pot = torch.clamp(z, max=ceiling) + if not hasattr(env, "_height_potential_prev"): + env._height_potential_prev = pot.clone() + fresh = env.episode_length_buf <= 1 + env._height_potential_prev[fresh] = pot[fresh] + delta = pot - env._height_potential_prev + env._height_potential_prev = pot.clone() + return delta + + +def fallen_state_penalty( + env: ManagerBasedRlEnv, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, + gate_tilt_above_deg: float = 40.0, + release_tilt_below_deg: float | None = None, + release_z_above: float | None = None, +) -> torch.Tensor: + """1.0 while FALLEN (weight it negative): a flat per-step tax on staying + down. Without it, lying still is ~0/step while attempting recovery costs + action-rate/torque penalties — waiting for the fallen_too_long recycle was + the rational policy. (Penalties on bad states are safe; it's POSITIVE + rewards gated on bad states that get farmed.) + + With ``release_*`` set, the tax has HYSTERESIS (velstand crouch-endpoint + lesson): a fall arms it and it keeps paying until the robot is genuinely + up (tilt < release_tilt AND z > release_z), not merely under the arming + gate. Without it, a crouch just below the 40° gate is a zero-cost rest + state — recoveries learned to park there instead of finishing the stand. + Arms only on a genuine fall, so gait-cycle tilt wobble is never taxed.""" + asset: Entity = env.scene[asset_cfg.name] + fallen = _fallen_mask(env, asset, 0.0, gate_tilt_above_deg).bool() + if release_tilt_below_deg is None: + return fallen.float() + z = torch.nan_to_num( + asset.data.root_link_pos_w[:, 2] - env.scene.terrain.env_origins[:, 2], nan=0.0 + ) + quat = asset.data.root_link_quat_w + cos_tilt = 1.0 - 2.0 * (quat[:, 1] ** 2 + quat[:, 2] ** 2) + up = cos_tilt > math.cos(math.radians(release_tilt_below_deg)) + if release_z_above is not None: + up &= z > release_z_above + if not hasattr(env, "_fallen_tax_armed"): + env._fallen_tax_armed = torch.zeros( + env.num_envs, dtype=torch.bool, device=env.device + ) + fresh = env.episode_length_buf <= 1 + env._fallen_tax_armed[fresh] = False + env._fallen_tax_armed |= fallen + env._fallen_tax_armed &= ~up + return env._fallen_tax_armed.float() + + +def recovery_success( + env: ManagerBasedRlEnv, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, + fallen_tilt_deg: float = 40.0, + min_fallen_s: float = 0.5, + up_tilt_deg: float = 25.0, + up_z: float = 0.105, +) -> torch.Tensor: + """One-shot bounty on a COMPLETED recovery: fires on the frame where an env + that has been fallen (tilt > fallen_tilt for ≥ min_fallen_s) becomes + genuinely upright (tilt < up_tilt AND trunk z > up_z). Hysteresis: re-arms + only by being fallen again, so oscillating around the gate pays nothing. + Gives the sparse-but-strong endpoint gradient the dense gated terms lack. + """ + asset: Entity = env.scene[asset_cfg.name] + z = torch.nan_to_num( + asset.data.root_link_pos_w[:, 2] - env.scene.terrain.env_origins[:, 2], nan=0.0 + ) + quat = asset.data.root_link_quat_w + cos_tilt = 1.0 - 2.0 * (quat[:, 1] ** 2 + quat[:, 2] ** 2) + fallen = cos_tilt < math.cos(math.radians(fallen_tilt_deg)) + up = (cos_tilt > math.cos(math.radians(up_tilt_deg))) & (z > up_z) + if not hasattr(env, "_recovery_fallen_s"): + env._recovery_fallen_s = torch.zeros(env.num_envs, device=env.device) + env._recovery_armed = torch.zeros(env.num_envs, dtype=torch.bool, device=env.device) + fresh = env.episode_length_buf <= 1 + env._recovery_fallen_s[fresh] = 0.0 + env._recovery_armed[fresh] = False + env._recovery_fallen_s = torch.where( + fallen, env._recovery_fallen_s + env.step_dt, torch.zeros_like(env._recovery_fallen_s) + ) + env._recovery_armed |= env._recovery_fallen_s >= min_fallen_s + fired = env._recovery_armed & up + env._recovery_armed &= ~fired + return fired.float() + + +def body_upright_linear( + env: ManagerBasedRlEnv, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, + gate_z_below: float | None = None, + gate_tilt_above_deg: float = 40.0, +) -> torch.Tensor: + """Linear reward for body uprightness — provides gradient at every tilt angle. + + Returns +1 when fully upright, 0 when horizontal (prone/supine), -1 when inverted. + Unlike flat_orientation (Gaussian), this has non-zero gradient everywhere, so the + robot always has a signal to rotate toward upright even when starting from prone. + + Computed as the z-component of the body's local Z-axis expressed in world frame, + which equals R[2,2] = 1 - 2*(qx² + qy²) for quaternion [w, x, y, z]. + """ + asset: Entity = env.scene[asset_cfg.name] + quat = asset.data.root_link_quat_w # (N, 4): [w, x, y, z] + qx = quat[:, 1] + qy = quat[:, 2] + reward = 1.0 - 2.0 * (qx * qx + qy * qy) + if gate_z_below is not None: + # Recovery-gated variant (velstand): active only while fallen, exactly + # zero during clean walking so it can't dilute the tracking rewards. + reward = reward * _fallen_mask(env, asset, gate_z_below, gate_tilt_above_deg) + return reward + + +def body_upright_gaussian( + env: ManagerBasedRlEnv, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, + std: float = 0.1, +) -> torch.Tensor: + """Gaussian reward on tilt magnitude — sharp pull toward fully vertical. + + Complements ``body_upright_linear`` (which is ``cos(tilt)`` and whose + gradient ``sin(tilt)`` *vanishes* at the target). This Gaussian's + gradient is non-zero near vertical and tapers as you move away, so it + creates a strong differential pull in the regime where the linear + version is weakest. + + Uses ``2*(qx² + qy²) = 1 - cos(tilt) ≈ tilt²/2`` as a tilt-squared + proxy and applies ``exp(-tilt²/std²)``. Default std=0.1 rad ≈ 5.7°. + """ + asset: Entity = env.scene[asset_cfg.name] + quat = asset.data.root_link_quat_w + qx = quat[:, 1] + qy = quat[:, 2] + tilt_sq = 2.0 * (qx * qx + qy * qy) # ≈ 1 − cos(tilt); small-angle: tilt²/2 + return torch.exp(-tilt_sq / (std * std)) + + +def upright_gaussian_at_height( + env: ManagerBasedRlEnv, + std: float, + height_low: float, + height_high: float, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """``body_upright_gaussian`` weighted by smoothstep on trunk z. + + Full Gaussian-upright reward when ``z >= height_high``, zero when + ``z <= height_low``, smoothstep in between. Use this when the upright + incentive should only apply at the target standing height — otherwise + the policy can find a "crouch low and vertical" local optimum that + collects upright reward without ever rising. + """ + asset = env.scene[asset_cfg.name] + quat = asset.data.root_link_quat_w + qx = quat[:, 1] + qy = quat[:, 2] + tilt_sq = 2.0 * (qx * qx + qy * qy) + upright_g = torch.exp(-tilt_sq / (std * std)) + z = torch.nan_to_num( + asset.data.root_link_pos_w[:, 2] - env.scene.terrain.env_origins[:, 2], nan=0.0 + ) + t = torch.clamp((z - height_low) / max(height_high - height_low, 1e-6), 0.0, 1.0) + smooth = t * t * (3.0 - 2.0 * t) + return upright_g * smooth + + +def body_ang_vel_at_height( + env: ManagerBasedRlEnv, + height_low: float, + height_high: float, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, + tilt_full_deg: float | None = None, + tilt_zero_deg: float = 45.0, +) -> torch.Tensor: + """Trunk ``sum(ω_xy²)`` penalty gated by trunk z (and optionally tilt). + + Height-gated arrival damper: zero below ``height_low`` (ground recovery — + flips/rolls need large trunk rotation and must stay free), full above + ``height_high``. Same formula as mjlab's body_angular_velocity_penalty + (world-frame ω_xy, z-rotation free) but returns the gated POSITIVE cost; + use a negative weight. + + ``tilt_full_deg`` (optional but STRONGLY recommended): additionally gate + by tilt — full cost only when tilt ≤ tilt_full_deg, zero when + ≥ tilt_zero_deg, smoothstep between. LESSON (2026-07 run that broke + front-recovery): with a height gate alone, the final straighten of a + bent-over rise (tilt 60°→0 happening INSIDE the z gate) is itself a + large trunk rotation — taxing it builds a reward wall right before the + finish, and the policy parks bent-over below the gate instead. With the + tilt gate, the approach TO vertical is free; only residual wobble + AROUND vertical (the overshoot→tip→retry oscillation) is damped. + """ + asset = env.scene[asset_cfg.name] + ang_vel = asset.data.body_link_ang_vel_w[:, asset_cfg.body_ids, :].squeeze(1) + cost = torch.sum(torch.square(ang_vel[:, :2]), dim=1) + z = torch.nan_to_num( + asset.data.root_link_pos_w[:, 2] - env.scene.terrain.env_origins[:, 2], nan=0.0 + ) + t = torch.clamp((z - height_low) / max(height_high - height_low, 1e-6), 0.0, 1.0) + gate = t * t * (3.0 - 2.0 * t) + if tilt_full_deg is not None: + quat = asset.data.root_link_quat_w + cos_tilt = 1.0 - 2.0 * (quat[:, 1] ** 2 + quat[:, 2] ** 2) + tilt_deg = torch.rad2deg(torch.acos(cos_tilt.clamp(-1.0, 1.0))) + s = torch.clamp( + (tilt_zero_deg - tilt_deg) / max(tilt_zero_deg - tilt_full_deg, 1e-6), + 0.0, + 1.0, + ) + gate = gate * (s * s * (3.0 - 2.0 * s)) + return cost * gate + + +def standing_composite_score( + env: ManagerBasedRlEnv, + target_height: float, + height_std: float, + upright_std: float, + pose_std: float, + joint_indices: list, + target_overrides: Optional[dict] = None, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """Smooth multiplicative goal-state score (product of three Gaussians). + + Returns ``height_score * upright_score * pose_score``, each ∈ [0, 1]. + Because the factors *multiply*, a deficiency in any one term collapses + the whole reward — the policy can't claim 80% of this by being perfect + on 2-of-3. Gradient is non-zero everywhere, so the score works during + the rise (not just at the goal like a binary bonus would). + + Use to break Nash-equilibrium compromises (e.g., a "lean trunk at the + right height" basin that satisfies the additive rewards' partial sums). + """ + asset = env.scene[asset_cfg.name] + + z = torch.nan_to_num( + asset.data.root_link_pos_w[:, 2] - env.scene.terrain.env_origins[:, 2], nan=0.0 + ) + height_score = torch.exp(-((z - target_height) / height_std) ** 2) + + quat = asset.data.root_link_quat_w + qx = quat[:, 1] + qy = quat[:, 2] + tilt_sq = 2.0 * (qx * qx + qy * qy) + upright_score = torch.exp(-tilt_sq / (upright_std * upright_std)) + + target = _servo_default_joint_pos(env, asset).clone() + if target_overrides: + for idx, val in target_overrides.items(): + target[:, idx] = val + joint_pos = _servo_joint_pos(env, asset)[:, joint_indices] + target = target[:, joint_indices] + pose_err_sq = ((joint_pos - target) ** 2).mean(dim=-1) + pose_score = torch.exp(-pose_err_sq / (pose_std * pose_std)) + + return height_score * upright_score * pose_score + + +def standing_success_bonus( + env: ManagerBasedRlEnv, + target_height: float, + height_tol: float, + upright_threshold: float, + pose_tol: float, + joint_indices: list, + target_overrides: Optional[dict] = None, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """Binary bonus: 1.0 iff height, uprightness AND pose are all within tol. + + Creates a discrete goal-state attractor that gradient-based pose/upright/ + height rewards can't fully match by themselves. Surrounding compromises + (lean trunk to balance head-forward CoM, park 1cm short of target z, + etc.) collect partial gradient credit but ZERO bonus — the bonus is + available only at the true goal state, so it changes the policy's + relative preference once the rest of the rewards have brought it close. + """ + asset = env.scene[asset_cfg.name] + + z = torch.nan_to_num( + asset.data.root_link_pos_w[:, 2] - env.scene.terrain.env_origins[:, 2], nan=0.0 + ) + height_ok = (z - target_height).abs() <= height_tol + + quat = asset.data.root_link_quat_w + qx = quat[:, 1] + qy = quat[:, 2] + upright = 1.0 - 2.0 * (qx * qx + qy * qy) + upright_ok = upright >= upright_threshold + + target = _servo_default_joint_pos(env, asset).clone() + if target_overrides: + for idx, val in target_overrides.items(): + target[:, idx] = val + joint_pos = _servo_joint_pos(env, asset)[:, joint_indices] + target = target[:, joint_indices] + pose_err = (joint_pos - target).abs().max(dim=-1).values # tightest joint + pose_ok = pose_err <= pose_tol + + return (height_ok & upright_ok & pose_ok).float() + + +def com_upward_velocity( + env: ManagerBasedRlEnv, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, + max_height: float = 0.08, + gate_z_below: float | None = None, + gate_tilt_above_deg: float = 40.0, + max_vz: float | None = None, +) -> torch.Tensor: + """Reward upward CoM velocity to incentivize dynamic standup motion. + + Gated by height: only active while the CoM is below `max_height` (the + standing target). Once standing, the reward is zero so the robot has no + incentive to keep squatting to farm upward-velocity reward. + + ``max_vz`` (optional): cap the rewarded velocity. Uncapped, the reward is + proportional to vz, which pays MORE per step for an explosive launch — + a violent-rise incentive. With a cap, any rise ≥ max_vz earns the same, + so the gentlest rise that reaches the cap is optimal (the |a_z| penalty + then picks the smooth one). The bootstrap property is preserved: any + upward motion still pays immediately. + """ + asset: Entity = env.scene[asset_cfg.name] + # nan_to_num: MuJoCo can produce NaN on contact instability; treat as z=0 + com_z = torch.nan_to_num( + asset.data.root_link_pos_w[:, 2] - env.scene.terrain.env_origins[:, 2], nan=0.0 + ) + vz = torch.nan_to_num(asset.data.root_link_lin_vel_w[:, 2], nan=0.0) + below_target = (com_z < max_height).float() + reward = torch.clamp(vz, min=0.0, max=max_vz) * below_target + if gate_z_below is not None: + # Recovery-gated (velstand): without the gate this pays for dip-and-rise + # during gait whenever the trunk crosses max_height → bounce incentive. + reward = reward * _fallen_mask(env, asset, gate_z_below, gate_tilt_above_deg) + return reward + + +def fallen_too_long( + env: ManagerBasedRlEnv, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, + gate_z_below: float = 0.10, + gate_tilt_above_deg: float = 40.0, + max_duration_s: float = 5.0, +) -> torch.Tensor: + """Terminate envs that have been continuously FALLEN for `max_duration_s`. + + For envs that mix walking with fall recovery (velstand): the fell_over + termination gets disabled by curriculum so the policy can attempt recovery, + but without a backstop a failed recovery farms recovery-reward for the whole + 20 s episode, starving the walk of data (audit: ~25% walking share). This + gives every fall a fair recovery window, then recycles the env. + """ + asset: Entity = env.scene[asset_cfg.name] + fallen = _fallen_mask(env, asset, gate_z_below, gate_tilt_above_deg).bool() + if not hasattr(env, "_fallen_timer_s"): + env._fallen_timer_s = torch.zeros(env.num_envs, device=env.device) + # Freshly reset envs start with a clean timer. + env._fallen_timer_s[env.episode_length_buf <= 1] = 0.0 + env._fallen_timer_s = torch.where( + fallen, env._fallen_timer_s + env.step_dt, torch.zeros_like(env._fallen_timer_s) + ) + return env._fallen_timer_s >= max_duration_s + + +def robot_state_is_nan( + env: ManagerBasedRlEnv, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, + sensor_names: tuple[str, ...] = (), +) -> torch.Tensor: + """Terminate environments where MuJoCo produced NaN joint positions. + + MuJoCo's contact solver can overflow to NaN under extreme penetration or + impulse (e.g. robot landing at high velocity). A NaN simulation state + propagates into observations, corrupting the policy network weights. + + Terminating immediately resets the environment before the cascade spreads: + - The observation returned to the runner is from the valid reset state. + - NaN rewards are avoided on subsequent steps. + + Note: the reward at THIS terminal step may still be NaN from the simulation; + mjlab computes rewards before resetting (see manager_based_rl_env.py step()). + Our custom reward functions guard against NaN internally with nan_to_num, + but standard mjlab rewards can still be NaN here. One NaN reward is + tolerable because done=True prevents it propagating backward through GAE. + + Couvre TOUT l'état physique, pas seulement joint_pos : la divergence du + contact fait souvent exploser le FREE-JOINT de base (position/orientation/ + vitesse) ou les ROUES passives, pas les joints actionnés. Ces quantités + alimentent des termes d'obs critic (base_lin_vel, base_ang_vel, + projected_gravity, wheel_vel) ; si on ne les surveille pas, l'env ne se + reset pas et le NaN atteint l'obs → le check_nan de rsl_rl tue tout + l'entraînement. On teste la non-finitude (NaN ET inf, l'inf devenant NaN en + aval lors de la normalisation de projected_gravity). + """ + asset: Entity = env.scene[asset_cfg.name] + d = asset.data + bad = ~torch.isfinite(d.joint_pos).all(dim=1) + bad |= ~torch.isfinite(d.joint_vel).all(dim=1) + bad |= ~torch.isfinite(d.root_link_pos_w).all(dim=1) + bad |= ~torch.isfinite(d.root_link_quat_w).all(dim=1) + bad |= ~torch.isfinite(d.root_link_lin_vel_w).all(dim=1) + bad |= ~torch.isfinite(d.root_link_ang_vel_w).all(dim=1) + + # Contact FORCES can blow up a step before qpos/qvel do: MuJoCo resolves a + # degenerate contact into an inf/NaN impulse while the integrated state is + # still finite. That force feeds the critic-only `foot_contact_forces` obs + # (sign(F)*log1p(|F|)), which the state checks above do NOT cover — so the + # env was not reset and the NaN reached the runner's check_nan, killing the + # whole run (crash 2026-08-21, Velocity2-Rough-Backlash with hfield slopes). + for name in sensor_names: + if name not in env.scene.sensors: + continue + force = getattr(env.scene.sensors[name].data, "force", None) + if force is not None: + bad |= ~torch.isfinite(force).flatten(start_dim=1).all(dim=1) + return bad + + +def root_height_below( + env: ManagerBasedRlEnv, + min_height: float, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """Terminate when the trunk drops below ``min_height`` in world z. + + Utilisé par roller_slope comme « tombé dans le vide » : le terrain a un + plat de sortie au bas de la rampe, donc une descente normale ne passe + jamais sous le niveau du plat de sortie le plus bas. Choisir min_height + en dessous de ce niveau => la terminaison ne se déclenche que si le robot + quitte le solide et chute dans le vide. Indépendant de la géométrie exacte + de la rampe (longueur/pente). + """ + asset: Entity = env.scene[asset_cfg.name] + return asset.data.root_link_pos_w[:, 2] < min_height + + +def descent_speed_reward( + env: ManagerBasedRlEnv, + cap: float = 0.8, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """Récompense la vitesse d'avance vers le BAS de la pente (monde +x). + + La rampe descend en +x, donc la vitesse linéaire monde en x mesure la + progression de descente. Plafonnée à ``cap`` m/s : encourage à se laisser + glisser sans pousser à dévaler de plus en plus vite. Nulle si le robot + recule/remonte (vx < 0). Sans cette récompense, l'optimum est de rester + immobile et droit (le robot « freine » au lieu de glisser). NaN-safe. + """ + asset: Entity = env.scene[asset_cfg.name] + vx = torch.nan_to_num( + asset.data.root_link_lin_vel_w[:, 0], nan=0.0, posinf=0.0, neginf=0.0 + ) + return torch.clamp(vx, min=0.0, max=cap) + + +def reset_rolling_entry( + env: ManagerBasedRlEnv, + env_ids: torch.Tensor | None, + speed_range: tuple = (0.25, 0.45), + wheel_radius: float = 0.0175, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> None: + """Départ en ROULEMENT sans glissement (élan aux roues). + + Tire une vitesse d'avance v par env ; met la vitesse LINÉAIRE de base (x + monde) = v ET la vitesse de ROTATION des 4 roues passives = v / r, donc + ω·r = v => zéro glissement au contact. Évite l'à-coup de l'ancienne poussée + base-seule (base qui bouge, roues immobiles = patinage brutal au 1er pas). + À exécuter APRÈS reset_base (qui pose la base ; ne plus lui donner de + velocity_range). + """ + asset: Entity = env.scene[asset_cfg.name] + if env_ids is None: + env_ids = torch.arange(env.num_envs, device=env.device) + n = int(env_ids.shape[0]) + lo, hi = speed_range + v = torch.rand(n, device=env.device) * (hi - lo) + lo # (n,) vitesse avant + + # Vitesse de base (monde) : uniquement +x. + root_vel = torch.zeros(n, 6, device=env.device) + root_vel[:, 0] = v + asset.write_root_link_velocity_to_sim(root_vel, env_ids=env_ids) + + # Rotation des 4 roues passives = v / r (positif = avant, cf. wheel_speed). + wheel_ids = [] + for name in ("passive_LF_?wheel", "passive_LR_?wheel", "passive_RF_?wheel", "passive_RR_?wheel"): + ids, _ = asset.find_joints(name) + wheel_ids.append(ids[0]) + wheel_ids_t = torch.tensor(wheel_ids, device=env.device) + omega = (v / wheel_radius).unsqueeze(1).repeat(1, len(wheel_ids)) # (n, 4) + asset.write_joint_velocity_to_sim(omega, joint_ids=wheel_ids_t, env_ids=env_ids) + + +def wheel_glide_reward( + env: ManagerBasedRlEnv, + cap_speed: float = 0.35, + wheel_radius: float = 0.0175, +) -> torch.Tensor: + """Récompense le ROULEMENT des roues vers l'avant (glisse), plafonné. + + Contrairement à descent_speed (vitesse de la BASE, qu'on peut atteindre en + "courant"/poussant), on récompense la rotation des ROUES passives = vraie + glisse par roulement. Indépendant de toute commande (la tâche pente a une + commande nulle : la glisse vient de la gravité). Plafonné à ``cap_speed`` + (m/s de vitesse de roulement) -> AUCUNE incitation à accélérer au-delà ; nul + si les roues reculent (remontée). NaN-safe. + """ + asset: Entity = env.scene["robot"] + lf, _ = asset.find_joints("passive_LF_?wheel") + lr, _ = asset.find_joints("passive_LR_?wheel") + rf, _ = asset.find_joints("passive_RF_?wheel") + rr, _ = asset.find_joints("passive_RR_?wheel") + vel = asset.data.joint_vel + # Les 4 roues tournent en positif pour l'avant (cf. wheel_speed_reward). + omega = (vel[:, lf[0]] + vel[:, lr[0]] + vel[:, rf[0]] + vel[:, rr[0]]) / 4.0 + speed = torch.nan_to_num(omega * wheel_radius, nan=0.0, posinf=0.0, neginf=0.0) + return torch.clamp(speed, min=0.0, max=cap_speed) + + +def is_alive(env: ManagerBasedRlEnv) -> torch.Tensor: + """ + Reward for staying alive (not terminated) + + Args: + env: The environment + + Returns: + Reward tensor of shape (num_envs,) - ones for all envs + """ + return torch.ones(env.num_envs, device=env.device) + + +def com_height_target( + env: ManagerBasedRlEnv, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, + target_height_min: float = 0.1, + target_height_max: float = 0.15, +) -> torch.Tensor: + """ + Reward for keeping the center of mass within a target height range. + Returns positive reward when in range, negative penalty when outside. + + Args: + env: The environment + asset_cfg: Asset configuration + target_height_min: Minimum target height for CoM (meters) + target_height_max: Maximum target height for CoM (meters) + + Returns: + Reward tensor of shape (num_envs,) + """ + asset: Entity = env.scene[asset_cfg.name] + + # Height above terrain spawn origin (world z minus terrain z). + # env_origins[:, 2] is 0 for flat ground, so this is safe unconditionally. + # nan_to_num: MuJoCo can produce NaN on contact instability; treat as z=0 + # so the penalty is finite (small, since 0 is near the target range). + com_height = torch.nan_to_num( + asset.data.root_link_pos_w[:, 2] - env.scene.terrain.env_origins[:, 2], nan=0.0 + ) + + # Reward when in range, penalty when outside + # Use smooth penalty that increases quadratically with distance from range + below_min = com_height < target_height_min + above_max = com_height > target_height_max + in_range = ~(below_min | above_max) + + # Compute penalties for being outside range + penalty_below = torch.square(com_height - target_height_min) * below_min.float() + penalty_above = torch.square(com_height - target_height_max) * above_max.float() + + # Reward: +1 when in range, -squared_distance when outside + reward = in_range.float() - (penalty_below + penalty_above) + + return reward + + +def crouch_height_target( + phase: torch.Tensor, + height_low: float, + height_high: float, + hold_lo: float = 0.375, + hold_hi: float = 0.625, +) -> torch.Tensor: + """Cible de hauteur du tronc « en trapèze » le long de la phase [0,1). + + phase ∈ [0, hold_lo) : descente height_high -> height_low + phase ∈ [hold_lo, hold_hi): palier height_low (la glisse accroupie) + phase ∈ [hold_hi, 1.0) : remontée height_low -> height_high + + Args: + phase: (B,) phase par env, dans [0, 1). + height_low: hauteur du tronc accroupi (m). + height_high: hauteur du tronc debout (m). + hold_lo, hold_hi: bornes du palier bas en fraction de phase. + Returns: + (B,) hauteur-cible en mètres. + """ + descend = phase < hold_lo + hold = (phase >= hold_lo) & (phase < hold_hi) + + frac_d = phase / hold_lo + t_descend = height_high + (height_low - height_high) * frac_d + + t_hold = torch.full_like(phase, height_low) + + frac_r = (phase - hold_hi) / (1.0 - hold_hi) + t_rise = height_low + (height_high - height_low) * frac_r + + return torch.where(descend, t_descend, torch.where(hold, t_hold, t_rise)) + + +def crouch_glide_reward_from_values( + com_height: torch.Tensor, + cmd_cos: torch.Tensor, + cmd_sin: torch.Tensor, + height_low: float, + height_high: float, + hold_lo: float = 0.375, + hold_hi: float = 0.625, + std: float = 0.02, +) -> torch.Tensor: + """Récompense gaussienne du suivi de la cible de hauteur (fonction pure). + + Décode la phase depuis [cos, sin] puis compare la hauteur mesurée à la + cible-trapèze. Retourne exp(-((h - cible)/std)^2) ∈ (0, 1]. + """ + phase = (torch.atan2(cmd_sin, cmd_cos) / (2 * torch.pi)) % 1.0 + target = crouch_height_target(phase, height_low, height_high, hold_lo, hold_hi) + return torch.exp(-((com_height - target) / std) ** 2) + + +def crouch_glide_height_by_phase( + env: ManagerBasedRlEnv, + command_name: str = "twist", + height_low: float = 0.075, + height_high: float = 0.11, + hold_lo: float = 0.375, + hold_hi: float = 0.625, + std: float = 0.02, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """Reward principale : suit la cible de hauteur du tronc le long de la phase. + + La hauteur du CoM est calculée comme dans `com_height_target` (world z moins + l'origine du terrain, nan->0). La phase provient de la commande GroundPick. + """ + asset: Entity = env.scene[asset_cfg.name] + com_height = torch.nan_to_num( + asset.data.root_link_pos_w[:, 2] - env.scene.terrain.env_origins[:, 2], nan=0.0 + ) + cmd = env.command_manager.get_command(command_name) + return crouch_glide_reward_from_values( + com_height, cmd[:, 0], cmd[:, 1], + height_low, height_high, hold_lo, hold_hi, std, + ) + + +def forward_speed_reward( + env: ManagerBasedRlEnv, + vel_ref: float = 0.2, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """Récompense la vitesse avant du tronc (conserver l'élan / ne pas freiner). + + Indépendante de la commande (la commande porte la phase, pas la vitesse). + tanh(clamp(vx, 0)/vel_ref) → sature à ~1, ne récompense jamais reculer. + """ + asset: Entity = env.scene[asset_cfg.name] + vx = asset.data.root_link_lin_vel_b[:, 0] + return torch.tanh(torch.clamp(vx, min=0.0) / vel_ref) + + +def crouch_pose_blend( + phase: torch.Tensor, + descent_end: float, + hold_end: float, + rise_end: float, +) -> torch.Tensor: + """Blend 0..1 le long de la phase [0,1) — 0 = pose debout, 1 = pose accroupie. + + [0, descent_end) : 0 -> 1 (se baisser) + [descent_end, hold_end): 1 (bas / accroupi) + [hold_end, rise_end) : 1 -> 0 (se lever) + [rise_end, 1.0) : 0 (haut / debout, repos) + """ + b = torch.zeros_like(phase) + descend = phase < descent_end + b = torch.where(descend, phase / descent_end, b) + low = (phase >= descent_end) & (phase < hold_end) + b = torch.where(low, torch.ones_like(phase), b) + rise = (phase >= hold_end) & (phase < rise_end) + b = torch.where(rise, 1.0 - (phase - hold_end) / (rise_end - hold_end), b) + return b + + +def _crouch_pose_error( + env: ManagerBasedRlEnv, + asset_cfg: SceneEntityCfg, + command_name: str, + crouch_pose: dict, + descent_end: float, + hold_end: float, + rise_end: float, + stand_pose: Optional[dict] = None, +): + """(cur, target) joint tensors for the phase-interpolated crouch pose. + + Target interpolates per joint STAND <-> crouch_pose by the 4-segment blend + b(phase) in [0,1] (0 = standing, 1 = crouch). STAND is `stand_pose` where + given, else the model DEFAULT (HOME). Joints are resolved BY NAME so the + passive-wheel interspersing on the roller robot never shifts an index. + """ + asset: Entity = env.scene[asset_cfg.name] + cmd = env.command_manager.get_command(command_name) + phase = (torch.atan2(cmd[:, 1], cmd[:, 0]) / (2 * torch.pi)) % 1.0 # (B,) + blend = crouch_pose_blend(phase, descent_end, hold_end, rise_end) # (B,) 0..1 + + names = list(crouch_pose.keys()) + ids = [int(asset.find_joints([n])[0][0]) for n in names] + default = asset.data.default_joint_pos[:, ids] # (B,k) + + stand = default.clone() # source pose + if stand_pose: + for j, n in enumerate(names): + if n in stand_pose: + stand[:, j] = stand_pose[n] + crouch = torch.tensor( + [crouch_pose[n] for n in names], device=env.device, dtype=default.dtype + ).unsqueeze(0) # (1,k) + + target = stand + blend.unsqueeze(-1) * (crouch - stand) # (B,k) + cur = asset.data.joint_pos[:, ids] # (B,k) + return cur, target + + +def crouch_glide_pose_by_phase( + env: ManagerBasedRlEnv, + command_name: str = "twist", + crouch_pose: Optional[dict] = None, + stand_pose: Optional[dict] = None, + std: float = 0.4, + descent_end: float = 0.10, + hold_end: float = 0.50, + rise_end: float = 0.60, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """Gaussian match to a phase-interpolated joint pose (stand <-> crouch). + + Directive reward: tells the robot the exact joint configuration to be in at + each phase. Standing back up (target = stand_pose) is rewarded exactly like + crouching (target = crouch_pose) — symmetric by construction. + """ + cur, target = _crouch_pose_error( + env, asset_cfg, command_name, crouch_pose or {}, + descent_end, hold_end, rise_end, stand_pose, + ) + return torch.exp(-((cur - target) / std) ** 2).mean(dim=-1) + + +def crouch_glide_pose_l1( + env: ManagerBasedRlEnv, + command_name: str = "twist", + crouch_pose: Optional[dict] = None, + stand_pose: Optional[dict] = None, + descent_end: float = 0.10, + hold_end: float = 0.50, + rise_end: float = 0.60, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """L1 bootstrap toward the phase-interpolated crouch pose (negative penalty). + + Constant gradient everywhere — gives the policy a direction to the target + pose even when the Gaussian above has saturated to ~0 far from it. + """ + cur, target = _crouch_pose_error( + env, asset_cfg, command_name, crouch_pose or {}, + descent_end, hold_end, rise_end, stand_pose, + ) + return -(cur - target).abs().mean(dim=-1) + + +def crouch_forward_lean( + env: ManagerBasedRlEnv, + command_name: str = "twist", + target_pitch: float = 0.08, + std: float = 0.1, + descent_end: float = 0.10, + hold_end: float = 0.50, + rise_end: float = 0.60, + asset_cfg: SceneEntityCfg = SceneEntityCfg("robot", body_names=("trunk_base",)), +) -> torch.Tensor: + """Léger penché AVANT du tronc pendant l'accroupi (gaté par le blend crouch). + + Contre la bascule arrière induite par la flexion rapide des hanches. Proxy de + pitch = projected_gravity_b[:,0] (positif = vers l'avant, vérifié). La porte + (blend) vaut 1 pendant descente+bas, 0 debout → ne biaise QUE l'accroupi. + target_pitch petit = "de très peu". + """ + asset: Entity = env.scene[asset_cfg.name] + cmd = env.command_manager.get_command(command_name) + phase = (torch.atan2(cmd[:, 1], cmd[:, 0]) / (2 * torch.pi)) % 1.0 + gate = crouch_pose_blend(phase, descent_end, hold_end, rise_end) + lean = asset.data.projected_gravity_b[:, 0] + return gate * torch.exp(-((lean - target_pitch) ** 2) / std ** 2) + + +def neck_joint_vel_l2( + env: ManagerBasedRlEnv, asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG +) -> torch.Tensor: + """ + Penalize neck joint velocities to keep head stable. + Neck joints are indices 5-8 (4 joints total). + + Args: + env: The environment + asset_cfg: Asset configuration + + Returns: + Penalty tensor of shape (num_envs,) + """ + asset: Entity = env.scene[asset_cfg.name] + + # Get neck joint indices (neck_pitch, head_pitch, head_yaw, head_roll). + # Servo view: passive_* joints (backlash, wheels) don't shift the indices. + neck_joint_indices = list(range(5, 9)) + joint_vel = _servo_joint_vel(env, asset) + neck_joint_vel = joint_vel[:, neck_joint_indices] + + # Return L2 squared norm of neck joint velocities + return torch.sum(torch.square(neck_joint_vel), dim=1) + + +def leg_joint_vel_l2( + env: ManagerBasedRlEnv, asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG +) -> torch.Tensor: + """ + Penalize leg joint velocities to encourage smoother, less dynamic motion. + Leg joints are indices 0-4 and 9-13 (10 joints total). + + Args: + env: The environment + asset_cfg: Asset configuration + + Returns: + Penalty tensor of shape (num_envs,) + """ + asset: Entity = env.scene[asset_cfg.name] + + # Get leg joint indices (left hip-ankle: 0-4, right hip-ankle: 9-13). + # Servo view: passive_* joints (backlash, wheels) don't shift the indices. + leg_joint_indices = list(range(0, 5)) + list(range(9, 14)) + joint_vel = _servo_joint_vel(env, asset) + leg_joint_vel = joint_vel[:, leg_joint_indices] + + # Return L2 squared norm of leg joint velocities + return torch.sum(torch.square(leg_joint_vel), dim=1) + +_NECK_JOINT_CFG = SceneEntityCfg("robot", joint_names=(r"^(?!passive_).*(neck|head).*",)) +_HIP_PITCH_KNEE_CFG = SceneEntityCfg("robot", joint_names=(r"^(?!passive_).*(hip_pitch|knee).*",)) +_ROLLER_FEET_SITE_CFG = SceneEntityCfg("robot", site_names=("left_foot", "right_foot")) + + +def feet_flat_penalty( + env: ManagerBasedRlEnv, + asset_cfg: SceneEntityCfg = _ROLLER_FEET_SITE_CFG, + sensor_name: str | None = None, +) -> torch.Tensor: + """Penalize foot sites not being parallel to the ground. + + The foot site frame has Z+ pointing up when flat. We project a unit gravity + vector (pointing down) into each foot site's local frame. When flat, gravity + maps to [0,0,-1] in site frame (xy=0, penalty=0). Any tilt rotates Z away + from world-up, giving nonzero xy components. + + Max value ≈ 2.0 per foot (foot fully sideways), total ≈ 4.0. + + When ``sensor_name`` is given, each foot's penalty is GATED by that foot's own + ground contact: the airborne (swing) foot is free to tilt, only the stance + blade is asked to stay flat (so its wheels keep gripping). Without this gate + the penalty punishes the recovery-foot lift a stride needs — it is minimised + by keeping BOTH blades flat on the ground, i.e. the swizzle. Assumes the site + order (left, right) matches the sensor slot order (ankle_l_v1, + ankle_r_v1) — both left-first in this model. + + Bug note: must normalize gravity PER ENV with dim=-1. Using torch.norm() + without dim computes a scalar over all envs × 3 dims, making the vector + ~1/sqrt(num_envs) in magnitude → penalty ~num_envs times too small. + """ + from mjlab.utils.lab_api.math import quat_apply_inverse + import torch.nn.functional as F + + asset: Entity = env.scene[asset_cfg.name] + gravity_w_n = F.normalize(asset.data.gravity_vec_w, dim=-1) # (B, 3), unit vector per env + + foot_quats = asset.data.site_quat_w[:, asset_cfg.site_ids, :] # (B, N_feet, 4) + per_foot = torch.zeros(env.num_envs, foot_quats.shape[1], device=env.device) + for i in range(foot_quats.shape[1]): + proj = quat_apply_inverse(foot_quats[:, i, :], gravity_w_n) # (B, 3) + per_foot[:, i] = torch.sum(torch.square(proj[:, :2]), dim=1) # xy² only + + if sensor_name is not None: + from mjlab.sensor import ContactSensor + sensor: ContactSensor = env.scene[sensor_name] + contact_time = sensor.data.current_contact_time # (B, N_feet) + assert contact_time is not None + per_foot = per_foot * (contact_time > 0.0).float() + + return per_foot.sum(dim=1) + + +def feet_tiptoe_alignment( + env: ManagerBasedRlEnv, + asset_cfg: SceneEntityCfg = _ROLLER_FEET_SITE_CFG, + command_name: str = "twist", + command_threshold: float = 0.01, +) -> torch.Tensor: + """Reward each foot site's local x-axis pointing downward — tiptoe stance. + + When flat, foot site x points roughly forward (horizontal). Pitching the + foot forward (heel up, toe down) rotates x toward world -Z. We reward the + z-component of the foot x-axis being -1 (perfectly downward). + + Per foot: alignment ∈ [-1, 1], summed over both feet ∈ [-2, 2]. + + Gated on |vel_cmd_xy| > command_threshold so the policy isn't required to + stand on tiptoes at rest — only while walking. The companion + feet_flat_penalty is NOT used in this task; the two would fight. + """ + asset: Entity = env.scene[asset_cfg.name] + quats = asset.data.site_quat_w[:, asset_cfg.site_ids, :] # (B, N, 4) [w, x, y, z] + w, qx, qy, qz = quats[:, :, 0], quats[:, :, 1], quats[:, :, 2], quats[:, :, 3] + x_axis_z = 2.0 * (qx * qz - w * qy) # (B, N) — z-component of local x-axis in world + alignment = (-x_axis_z).sum(dim=-1) # +1 per foot when pointing straight down + + cmd = env.command_manager.get_command(command_name) + cmd_mag = torch.linalg.norm(cmd[:, :2], dim=1) + active = (cmd_mag > command_threshold).float() + return alignment * active + + +def hip_pitch_knee_vel_l2( + env: ManagerBasedRlEnv, + asset_cfg: SceneEntityCfg = _HIP_PITCH_KNEE_CFG, +) -> torch.Tensor: + """Penalize hip_pitch and knee joint velocities (L2 squared). + + Walking requires rapid oscillation of these sagittal-plane joints. + Skating uses hip_roll laterally and glides with minimal sagittal movement. + This penalizes the oscillation without preventing static balance adjustments. + """ + asset: Entity = env.scene[asset_cfg.name] + return torch.sum(torch.square(asset.data.joint_vel[:, asset_cfg.joint_ids]), dim=1) + + +def neck_joint_pos_l2( + env: ManagerBasedRlEnv, + asset_cfg: SceneEntityCfg = _NECK_JOINT_CFG, + pattern: str = r".*(neck|head).*", +) -> torch.Tensor: + """Penalize neck/head joint position deviation from default (L2 squared). + + Uses find_joints() every call to avoid stale cached indices when the same + SceneEntityCfg singleton is reused across robots with different joint layouts + (e.g. walk robot vs rollers robot where passive wheels shift neck indices). + + ``pattern`` sélectionne les joints comptés (défaut : toute la nuque + la tête). + La tâche spin passe un motif qui EXCLUT `head_yaw`, pour laisser la tête servir + de volant d'inertie au lancement de la rotation. + """ + asset: Entity = env.scene[asset_cfg.name] + # Exclude passive_* joints (backlash hinges also contain "neck"/"head"). + if not pattern.startswith(r"^(?!passive_)"): + pattern = r"^(?!passive_)" + pattern.lstrip("^") + joint_ids, _ = asset.find_joints(pattern) + error = asset.data.joint_pos[:, joint_ids] - asset.data.default_joint_pos[:, joint_ids] + return torch.sum(torch.square(error), dim=1) + + +def joint_torques_l2( + env: ManagerBasedRlEnv, asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG +) -> torch.Tensor: + """ + Penalize actuator forces (torques) to encourage energy-efficient motion. + + Args: + env: The environment + asset_cfg: Asset configuration + + Returns: + Penalty tensor of shape (num_envs,) - sum of squared actuator forces + """ + asset: Entity = env.scene[asset_cfg.name] + + # Get actuator forces (scalar actuation in actuation space) + actuator_forces = asset.data.actuator_force + + # Return L2 squared norm + return torch.sum(torch.square(actuator_forces), dim=1) + + +def joint_torque_rate_l2( + env: ManagerBasedRlEnv, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """Penalize rate of change in actuator torques (proxy for gearbox shock). + + Sudden torque spikes occur when the robot impacts the ground and actuators + resist the impulse. Penalising this rate encourages soft landings and smooth + force transitions that protect gearboxes. + + Returns the sum of squared torque differences from the previous step. + """ + asset: Entity = env.scene[asset_cfg.name] + current = asset.data.actuator_force # (num_envs, num_actuators) + + if not hasattr(env, '_prev_actuator_forces'): + env._prev_actuator_forces = current.clone() + return torch.zeros(env.num_envs, device=env.device) + + rate = current - env._prev_actuator_forces + env._prev_actuator_forces = current.clone() + return torch.sum(torch.square(rate), dim=1) + + +def feet_grounded_reward( + env: ManagerBasedRlEnv, + sensor_name: str, +) -> torch.Tensor: + """Positive reward for feet contacting the ground (0, +0.5, or +1.0). + + Uses the contact sensor's `found` field. For the feet_ground_contact sensor + which has 2 primary foot geoms, `found` has shape (num_envs, 2) with per-foot + binary contact. We sum and normalize to [0, 1]. + """ + if sensor_name not in env.scene.sensors: + return torch.zeros(env.num_envs, device=env.device) + sensor = env.scene.sensors[sensor_name] + found = sensor.data.found # (num_envs, num_feet) or (num_envs, 1) + if found.dim() > 1: + found = found.sum(dim=-1) # collapse foot dimension + return torch.clamp(found, 0.0, 2.0) / 2.0 + + +def body_impact_cost( + env: ManagerBasedRlEnv, + sensor_name: str, + threshold: float = 1.0, +) -> torch.Tensor: + """Penalize terrain contact forces above a threshold on protected body parts. + + Used to discourage slamming the trunk shell or head into the ground during + falls. The sensor should cover the relevant body or subtree with + reduce='netforce'. Forces below threshold are free; above that the penalty + grows linearly. + + Args: + sensor_name: Name of a ContactSensorCfg with fields=("force",), + reduce="netforce". + threshold: Contact force (N) below which no penalty is applied. + + Returns: + Penalty tensor (num_envs,) — N above threshold per step. + """ + if sensor_name not in env.scene.sensors: + return torch.zeros(env.num_envs, device=env.device) + + sensor = env.scene.sensors[sensor_name] + forces = sensor.data.force # (num_envs, N_bodies, 3) + total_force = forces.sum(dim=1) # sum over bodies in the subtree + force_mag = torch.norm(total_force, dim=1) + return torch.clamp(force_mag - threshold, min=0.0) + + +def wheel_speed_reward( + env: ManagerBasedRlEnv, + command_name: str, + wheel_radius: float = 0.0175, + vel_scale: float = 0.5, + bidirectional: bool = False, +) -> torch.Tensor: + """Reward wheel spin proportional to commanded push. + + All 4 wheels spin positive for forward motion (verified visually). + tanh saturation at vel_scale m/s equivalent prevents runaway. + + - ``bidirectional=False`` (default): forward only — reward forward spin for + cmd_x > 0, silent otherwise (cmd_x < 0 handled by the braking reward). + - ``bidirectional=True``: reward wheel spin in the COMMANDED direction — + forward for cmd_x > 0, backward for cmd_x < 0 — with magnitude |cmd_x|. + Lets cmd_x < 0 mean "go backward" instead of "brake". + """ + cmd_x = env.command_manager.get_command(command_name)[:, 0] # (B,) + + asset: Entity = env.scene["robot"] + lf_ids, _ = asset.find_joints("passive_LF_?wheel") + lr_ids, _ = asset.find_joints("passive_LR_?wheel") + rf_ids, _ = asset.find_joints("passive_RF_?wheel") + rr_ids, _ = asset.find_joints("passive_RR_?wheel") + + vel = asset.data.joint_vel + # All 4 wheels spin positive for forward motion (verified by test_wheel_direction.py) + forward_omega = (vel[:, lf_ids[0]] + vel[:, lr_ids[0]] + vel[:, rf_ids[0]] + vel[:, rr_ids[0]]) / 4.0 + + omega_scale = vel_scale / wheel_radius + if bidirectional: + # spin aligned with the command sign (fwd for +, back for -) + aligned = torch.sign(cmd_x) * forward_omega + return torch.abs(cmd_x) * torch.tanh(torch.clamp(aligned, min=0.0) / omega_scale) + return torch.clamp(cmd_x, min=0.0) * torch.tanh(torch.clamp(forward_omega, min=0.0) / omega_scale) + + +def coasting_reward( + env: ManagerBasedRlEnv, + command_name: str, + vel_std: float = 0.3, + stillness_std: float = 5.0, + asset_cfg: SceneEntityCfg = SceneEntityCfg("robot", joint_names=(r".*(hip|knee|ankle).*",)), +) -> torch.Tensor: + """Reward coasting: low leg-joint velocity while at target speed. + + Returns exp(-vel_error / vel_std²) × exp(-sum(joint_vel²) / stillness_std²). + Both factors must be high simultaneously — robot is rewarded for being at + target speed AND keeping its legs still (gliding), not for either alone. + + Typical values when coasting well: ~0.7–1.0. When actively stomping at + speed the joint_vel term suppresses the reward toward 0. + """ + cmd = env.command_manager.get_command(command_name) + vel_b = env.scene["robot"].data.root_link_lin_vel_b[:, :2] + vel_error = torch.sum(torch.square(cmd[:, :2] - vel_b), dim=1) + at_speed = torch.exp(-vel_error / vel_std ** 2) + + asset: Entity = env.scene[asset_cfg.name] + joint_vel_sq = torch.sum(torch.square(asset.data.joint_vel[:, asset_cfg.joint_ids]), dim=1) + stillness = torch.exp(-joint_vel_sq / stillness_std ** 2) + + return at_speed * stillness + + +def braking_reward( + env: ManagerBasedRlEnv, + command_name: str, + vel_std: float = 0.3, +) -> torch.Tensor: + """Reward coming to a stop when cmd_x < 0 (brake commanded). + + Returns clamp(-cmd_x, 0) * exp(-fwd_vel² / vel_std²). + - Silent when cmd_x ≥ 0 (coast or push). + - At cmd_x = -1 and vel = 0: reward = 1.0 (full stop achieved). + - At cmd_x = -1 and vel = vel_std: reward ≈ 0.37 (strong gradient). + vel_std=0.3 m/s gives meaningful gradient down to walking-pace speeds. + """ + cmd = env.command_manager.get_command(command_name) + cmd_x = cmd[:, 0] + braking_strength = torch.clamp(-cmd_x, min=0.0) + fwd_vel = env.scene["robot"].data.root_link_lin_vel_b[:, 0] + stopped = torch.exp(-(fwd_vel.clamp(min=0.0) ** 2) / (vel_std ** 2)) + return braking_strength * stopped + + +def contact_frequency_penalty( + env: ManagerBasedRlEnv, + sensor_name: str = "feet_ground_contact", + max_contact_changes_per_sec: float = 4.0, + command_threshold: float = 0.01, +) -> torch.Tensor: + """ + Penalize high frequency of contact changes to encourage slower stepping. + Tracks the number of contact state changes per second and penalizes when above threshold. + + Args: + env: The environment + sensor_name: Name of the contact sensor + max_contact_changes_per_sec: Maximum allowed contact changes per second + command_threshold: Minimum command magnitude to apply penalty + + Returns: + Penalty tensor of shape (num_envs,) - negative when exceeding threshold + """ + if sensor_name not in env.scene.sensors: + return torch.zeros(env.num_envs, device=env.device) + + # Check if command is above threshold + if "twist" in env.command_manager._terms: + cmd = env.command_manager.get_command("twist") + cmd_vel = cmd[:, :3] + cmd_norm = torch.linalg.norm(cmd_vel, dim=1) + active_mask = cmd_norm > command_threshold + else: + active_mask = torch.ones(env.num_envs, device=env.device, dtype=torch.bool) + + sensor = env.scene.sensors[sensor_name] + contacts = sensor.data.found[:, :2] # (num_envs, 2) + + # Initialize tracking if needed + if not hasattr(env, '_contact_change_count'): + env._contact_change_count = torch.zeros(env.num_envs, device=env.device) + env._contact_change_timer = torch.zeros(env.num_envs, device=env.device) + env._prev_contacts_for_freq = contacts.clone() + return torch.zeros(env.num_envs, device=env.device) + + # Detect any contact changes (either foot) + contact_changed = torch.any(contacts != env._prev_contacts_for_freq, dim=1) + + # Increment change counter + env._contact_change_count += contact_changed.float() + + # Update timer + env._contact_change_timer += env.step_dt + + # Calculate current frequency (changes per second) + # Avoid division by zero + freq = env._contact_change_count / torch.clamp(env._contact_change_timer, min=0.01) + + # Reset counter and timer every 1 second + reset_mask = env._contact_change_timer >= 1.0 + env._contact_change_count[reset_mask] = 0.0 + env._contact_change_timer[reset_mask] = 0.0 + + # Penalize when frequency exceeds maximum + # Use quadratic penalty for frequencies above threshold + excess_freq = torch.clamp(freq - max_contact_changes_per_sec, min=0.0) + penalty = -torch.square(excess_freq) + + # Update previous contacts + env._prev_contacts_for_freq = contacts.clone() + + # Apply command threshold mask + penalty = penalty * active_mask.float() + + return penalty + + +# ============================================================================== +# Ground Pick Rewards +# ============================================================================== + +def mouth_ground_proximity( + env: ManagerBasedRlEnv, + asset_cfg: SceneEntityCfg = SceneEntityCfg("robot", site_names=["mouth_tip"]), + std: float = 0.03, + target_height: float = 0.0, + command_name: str = "twist", +) -> torch.Tensor: + """Reward for mouth tip approaching the ground, weighted by the approach phase. + + The command for the ground pick task is [cos(2π*phase), sin(2π*phase), 0]. + The approach phase is the first half-cycle (sin > 0, phase ∈ [0, 0.5]), + smoothly weighted by max(0, sin(2π*phase)). + + Args: + std: Gaussian std on mouth_tip height (m). 0.03 m gives strong gradient. + target_height: Target z-height for the mouth tip (m). 0 = ground level. + """ + asset = env.scene[asset_cfg.name] + mouth_z = asset.data.site_pos_w[:, asset_cfg.site_ids[0], 2] # (num_envs,) + proximity = torch.exp(-((mouth_z - target_height) / std) ** 2) + + # Approach weight: max(0, sin(2π*phase)) — peaks at 1 at phase=0.25, zero at 0 and 0.5 + cmd = env.command_manager.get_command(command_name) + approach_weight = torch.clamp(cmd[:, 1], min=0.0) + + return approach_weight * proximity + + +def mouth_perpendicular_to_ground( + env: ManagerBasedRlEnv, + asset_cfg: SceneEntityCfg = SceneEntityCfg("robot", site_names=["mouth_tip"]), + command_name: str = "twist", +) -> torch.Tensor: + """Reward the mouth tip x-axis being vertical (pointing down) during the approach phase. + + A perfectly perpendicular contact gives alignment=1; horizontal gives 0; pointing up gives -1. + Weighted by max(0, sin(2π*phase)) so it only applies during the descent. + """ + asset = env.scene[asset_cfg.name] + # site_quat_w: (num_envs, num_sites, 4) as [w, x, y, z] + q = asset.data.site_quat_w[:, asset_cfg.site_ids[0], :] # (num_envs, 4) + w, qx, qy, qz = q[:, 0], q[:, 1], q[:, 2], q[:, 3] + # z-component of the site x-axis in world frame (first column of rotation matrix) + x_axis_z = 2.0 * (qx * qz - w * qy) + # dot with [0, 0, -1]: 1 = perfectly downward, -1 = upward + alignment = -x_axis_z + + cmd = env.command_manager.get_command(command_name) + approach_weight = torch.clamp(cmd[:, 1], min=0.0) + + return approach_weight * alignment + + +def sit_grounded( + env: ManagerBasedRlEnv, + sensor_name: str, + command_name: Optional[str] = None, + sin_threshold: float = 0.7, + min_progress_frac: float = 0.0, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, + upright_cos_threshold: float = 0.5, +) -> torch.Tensor: + """Positive reward for trunk-ground contact WHILE upright. + + Gated additionally on the trunk's body-frame +Z axis pointing in roughly the + world-up direction (cosine >= ``upright_cos_threshold``, default 0.5 → up to + 60° tilt accepted). Without this gate, the policy can earn the contact + bonus by tipping sideways or face-forward — the trunk hits the ground in + those weird poses, sit_grounded fires, and the policy converges to a + "fallen" mode that competes with the actual sit pose. + + When ``command_name`` is provided, the reward is gated to the sit window of + a phase command. Otherwise it's always-on, optionally gated to the late + part of the episode via ``min_progress_frac``. + """ + if sensor_name not in env.scene.sensors: + return torch.zeros(env.num_envs, device=env.device) + sensor = env.scene.sensors[sensor_name] + found = sensor.data.found + if found.dim() > 1: + found = found.sum(dim=-1) + has_contact = (found > 0).float() + + # Upright check: trunk body's +Z (world frame, third column of rotation matrix + # derived from the trunk quaternion) dot world-up = trunk's body-up · world-up. + # Equivalently: 1 - 2*(qx² + qy²) for a unit quaternion (w, x, y, z). + asset: Entity = env.scene[asset_cfg.name] + quat = asset.data.root_link_quat_w # (N, 4) = (w, x, y, z) + qx, qy = quat[:, 1], quat[:, 2] + upright_cos = 1.0 - 2.0 * (qx * qx + qy * qy) + is_upright = (upright_cos >= upright_cos_threshold).float() + + contact_upright = has_contact * is_upright + + if command_name is None: + if min_progress_frac > 0.0: + progress = env.episode_length_buf.float() / float(env.max_episode_length) + late_enough = (progress >= min_progress_frac).float() + return late_enough * contact_upright + return contact_upright + cmd = env.command_manager.get_command(command_name) + in_sit_window = (cmd[:, 1] > sin_threshold).float() + return in_sit_window * contact_upright + + +def sit_stability( + env: ManagerBasedRlEnv, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, + command_name: Optional[str] = None, + ang_vel_std: float = 0.5, + sin_threshold: float = 0.7, + min_progress_frac: float = 0.0, +) -> torch.Tensor: + """Bonus for low body angular velocity. + + Phase-gated when ``command_name`` is set (sit window of a phase command). + Always-on otherwise, optionally restricted to the late part of the episode + via ``min_progress_frac``. Encourages a stable rest pose. + """ + asset = env.scene[asset_cfg.name] + ang_vel_norm = asset.data.root_link_ang_vel_w.norm(dim=-1) + stillness = torch.exp(-((ang_vel_norm / ang_vel_std) ** 2)) + if command_name is None: + if min_progress_frac > 0.0: + progress = env.episode_length_buf.float() / float(env.max_episode_length) + late_enough = (progress >= min_progress_frac).float() + return late_enough * stillness + return stillness + cmd = env.command_manager.get_command(command_name) + in_sit_window = (cmd[:, 1] > sin_threshold).float() + return in_sit_window * stillness + + +def joint_deviation_l1( + env: ManagerBasedRlEnv, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """L1 penalty for joint positions deviating from their default (HOME). + + Returns sum of |joint_pos - default| over the selected joints. Unlike the + Gaussian `pose` reward (which saturates near 1.0 for any small deviation), + this gives a *linear* gradient at all deviation magnitudes — useful as a + focused penalty on a subset of joints (e.g. hip_yaw / hip_roll) to prevent + them drifting to wide-base stances even when other joints are near HOME. + """ + asset = env.scene[asset_cfg.name] + jnt_ids = asset_cfg.joint_ids + err = asset.data.joint_pos[:, jnt_ids] - asset.data.default_joint_pos[:, jnt_ids] + return torch.sum(torch.abs(err), dim=-1) + + +def joint_pos_limit_proximity( + env: ManagerBasedRlEnv, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, + margin: float = 0.15, +) -> torch.Tensor: + """L1 penalty for joint positions entering a ``margin`` (rad) band next to + their *hard* range limits. + + The base ``joint_pos_limits`` reward only fires past the *soft* limit + (global ``soft_joint_pos_limit_factor`` = 0.9 → roughly the last 7.5% of + range) and only by the radians-overshoot magnitude, so it's near-useless + against a joint parked on its stop. This term instead reads the *hard* + limits directly and lets each reward set its own wide margin, scoped to + specific joints. + + Motivating case: with a low-kp position servo and wide ctrlrange the policy + can command far past a joint's limit "for free" (no command-side cost) and + park the joint on its hard stop — e.g. hip_yaw slammed to ±limit so the foot + slides/pivots. The overshoot is *intended* (it's how a low-kp servo reaches + its target), so the deterrent must live on the qpos side and bite well + before the stop. + + For each selected joint with hard limits ``[lo, hi]``:: + + soft_lo = lo + margin, soft_hi = hi - margin + penalty = relu(soft_lo - q) + relu(q - soft_hi) + + summed over joints: zero in the interior, ramping linearly toward each stop. + """ + asset = env.scene[asset_cfg.name] + jnt_ids = asset_cfg.joint_ids + q = asset.data.joint_pos[:, jnt_ids] + hard = asset.data.joint_pos_limits[:, jnt_ids] # (num_envs, num_sel_joints, 2) + soft_lo = hard[..., 0] + margin + soft_hi = hard[..., 1] - margin + below = (soft_lo - q).clip(min=0.0) + above = (q - soft_hi).clip(min=0.0) + return torch.sum(below + above, dim=-1) + + +def phase_height_track( + env: ManagerBasedRlEnv, + command_name: str, + stand_z: float, + sit_z: float, + std: float = 0.02, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """Reward trunk_z tracking a sin-interpolated target between stand and sit heights. + + Used for the sitstand task instead of joint-angle matching for the sit pose — + rewards the END STATE (low trunk) without prescribing HOW the robot gets there. + The policy is free to find any motion strategy (deep squat, head-supported + descent, etc.). + + Command (from GroundPickPhaseCommand): cmd[:, 1] = sin(2π·phase). + sin = +1 at phase 0.25 (sit peak) → target = sit_z. + sin = -1 at phase 0.75 (stand peak) → target = stand_z. + sin = 0 at transitions → target = midpoint. + """ + cmd = env.command_manager.get_command(command_name) + sin_phase = cmd[:, 1] + target_z = (stand_z + sit_z) * 0.5 - (stand_z - sit_z) * 0.5 * sin_phase + asset = env.scene[asset_cfg.name] + z = torch.nan_to_num( + asset.data.root_link_pos_w[:, 2] - env.scene.terrain.env_origins[:, 2], nan=0.0 + ) + return torch.exp(-((z - target_z) / std) ** 2) + + +def pose_target_match( + env: ManagerBasedRlEnv, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, + std: float = 0.3, + joint_indices: Optional[list] = None, + target_overrides: Optional[dict] = None, +) -> torch.Tensor: + """Always-on Gaussian on joint positions vs a target pose. + + Non-phase analog of ``phase_pose_match``: useful for episodic tasks (e.g. + the sit env) where there's no cyclic command to weight the reward by, and + the target pose is constant for the whole episode. + + Args: + std: Gaussian std per joint (rad). + joint_indices: Optional subset of joints to evaluate. + target_overrides: ``{joint_index: angle_rad}``. Joints not listed default + to ``asset.data.default_joint_pos`` (the home/standing pose). + """ + asset = env.scene[asset_cfg.name] + joint_pos = _servo_joint_pos(env, asset) + target = _servo_default_joint_pos(env, asset).clone() + if target_overrides: + for idx, val in target_overrides.items(): + target[:, idx] = val + if joint_indices is not None: + joint_pos = joint_pos[:, joint_indices] + target = target[:, joint_indices] + return torch.exp(-((joint_pos - target) / std) ** 2).mean(dim=-1) + + +def interpolated_pose_target_match( + env: ManagerBasedRlEnv, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, + std: float = 0.3, + joint_indices: Optional[list] = None, + source_overrides: Optional[dict] = None, + target_overrides: Optional[dict] = None, + ramp_start_frac: float = 0.0, + ramp_end_frac: float = 1.0, +) -> torch.Tensor: + """Gaussian on joint positions vs a time-interpolated target pose. + + Tracks a target that linearly interpolates from a source pose to a target + pose over the episode, between progress fractions ``ramp_start_frac`` and + ``ramp_end_frac``. Before/after the ramp the target is clamped to source / + final target respectively. + + The point is to enforce smooth descent: snapping to the final target early + leaves the robot *off-target* relative to where the interpolated target + currently is, costing pose reward for the duration of the mismatch. + + Args: + std: Gaussian std per joint (rad). + joint_indices: Optional subset of joints to evaluate. + source_overrides: ``{joint_index: angle_rad}`` defining the source pose + (start of the ramp). ``None`` = default/HOME pose. + target_overrides: same, for the target pose (end of the ramp). + ramp_start_frac, ramp_end_frac: episode-progress window in [0, 1] over + which the target moves from source to target. + """ + asset = env.scene[asset_cfg.name] + joint_pos = _servo_joint_pos(env, asset) + source = _servo_default_joint_pos(env, asset).clone() + target = _servo_default_joint_pos(env, asset).clone() + if source_overrides: + for idx, val in source_overrides.items(): + source[:, idx] = val + if target_overrides: + for idx, val in target_overrides.items(): + target[:, idx] = val + + progress = env.episode_length_buf.float() / float(env.max_episode_length) + span = max(ramp_end_frac - ramp_start_frac, 1e-6) + tau = ((progress - ramp_start_frac) / span).clamp(0.0, 1.0).unsqueeze(-1) + interp = source * (1.0 - tau) + target * tau + + if joint_indices is not None: + joint_pos = joint_pos[:, joint_indices] + interp = interp[:, joint_indices] + return torch.exp(-((joint_pos - interp) / std) ** 2).mean(dim=-1) + + +def interpolated_pose_l1_penalty( + env: ManagerBasedRlEnv, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, + joint_indices: Optional[list] = None, + source_overrides: Optional[dict] = None, + target_overrides: Optional[dict] = None, + ramp_start_frac: float = 0.0, + ramp_end_frac: float = 1.0, +) -> torch.Tensor: + """L1 distance from a time-interpolated target pose (negative — used as penalty). + + Same interpolation schedule as ``interpolated_pose_target_match`` but + returns ``-mean(|joint_pos - interp|)`` instead of a Gaussian. The L1 + gradient is constant everywhere — useful as a bootstrap signal when the + Gaussian variant saturates to zero far from target and leaves the policy + no gradient to discover the target direction. + """ + asset = env.scene[asset_cfg.name] + joint_pos = _servo_joint_pos(env, asset) + source = _servo_default_joint_pos(env, asset).clone() + target = _servo_default_joint_pos(env, asset).clone() + if source_overrides: + for idx, val in source_overrides.items(): + source[:, idx] = val + if target_overrides: + for idx, val in target_overrides.items(): + target[:, idx] = val + + progress = env.episode_length_buf.float() / float(env.max_episode_length) + span = max(ramp_end_frac - ramp_start_frac, 1e-6) + tau = ((progress - ramp_start_frac) / span).clamp(0.0, 1.0).unsqueeze(-1) + interp = source * (1.0 - tau) + target * tau + + if joint_indices is not None: + joint_pos = joint_pos[:, joint_indices] + interp = interp[:, joint_indices] + return -torch.abs(joint_pos - interp).mean(dim=-1) + + +def interpolated_height_l1_penalty( + env: ManagerBasedRlEnv, + start_height: float, + end_height: float, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, + ramp_start_frac: float = 0.0, + ramp_end_frac: float = 1.0, +) -> torch.Tensor: + """L1 distance from a time-interpolated target height (negative — penalty). + + Same role as ``interpolated_pose_l1_penalty`` but on trunk z. Provides a + constant gradient toward the target height regardless of how far off the + current z is, complementing the Gaussian ``interpolated_height_target``. + """ + progress = env.episode_length_buf.float() / float(env.max_episode_length) + span = max(ramp_end_frac - ramp_start_frac, 1e-6) + tau = ((progress - ramp_start_frac) / span).clamp(0.0, 1.0) + target_z = start_height * (1.0 - tau) + end_height * tau + + asset = env.scene[asset_cfg.name] + z = torch.nan_to_num( + asset.data.root_link_pos_w[:, 2] - env.scene.terrain.env_origins[:, 2], nan=0.0 + ) + return -torch.abs(z - target_z) + + +def interpolated_height_target( + env: ManagerBasedRlEnv, + start_height: float, + end_height: float, + std: float = 0.02, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, + ramp_start_frac: float = 0.0, + ramp_end_frac: float = 1.0, +) -> torch.Tensor: + """Gaussian on trunk z vs a time-interpolated target height. + + Companion to ``interpolated_pose_target_match`` — same time-interpolation + logic applied to the trunk height. + """ + progress = env.episode_length_buf.float() / float(env.max_episode_length) + span = max(ramp_end_frac - ramp_start_frac, 1e-6) + tau = ((progress - ramp_start_frac) / span).clamp(0.0, 1.0) + target_z = start_height * (1.0 - tau) + end_height * tau + + asset = env.scene[asset_cfg.name] + z = torch.nan_to_num( + asset.data.root_link_pos_w[:, 2] - env.scene.terrain.env_origins[:, 2], nan=0.0 + ) + return torch.exp(-((z - target_z) / std) ** 2) + + +def bilateral_symmetry_penalty( + env: ManagerBasedRlEnv, + left_indices: list, + right_indices: list, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """L1 penalty on left/right leg asymmetry. + + For a bilaterally-symmetric robot the leg HOME and any symmetric target + (FOLD, SIT) satisfy ``q_left + q_right == 0`` on each matched joint pair + (because the left/right joints use mirrored sign conventions). This term + penalises departures from that constraint. + + Useful when ``mean()`` of pose-target rewards lets the policy get away + with one-leg-correct solutions (you collect ~half the reward for free + and the gradient toward fixing the second leg is too weak to escape that + local minimum). The penalty here has constant L1 gradient regardless of + magnitude, so any asymmetry pays a cost and the unique zero is the + fully-symmetric configuration. + + Returns ``-sum_i |q[left_i] + q[right_i]|`` averaged over the N pairs. + """ + asset: Entity = env.scene[asset_cfg.name] + pos = asset.data.joint_pos + left = pos[:, left_indices] + right = pos[:, right_indices] + return -torch.abs(left + right).mean(dim=-1) + + +def _multistage_target_pose( + env: ManagerBasedRlEnv, + asset_cfg: SceneEntityCfg, + waypoints, +) -> torch.Tensor: + """Compute the time-interpolated joint target across N waypoints. + + waypoints: ordered list of dicts {"frac": float in [0,1], + "overrides": dict[int,float] | None}. + First waypoint should have frac=0.0 (typically HOME, overrides=None). + Subsequent waypoints define milestones. Between two waypoints the target + linearly interpolates. Before the first / after the last it clamps. + + Returns a (num_envs, num_joints) tensor of target joint angles. + """ + asset = env.scene[asset_cfg.name] + default = _servo_default_joint_pos(env, asset) + + def build_pose(overrides): + pose = default.clone() + if overrides: + for idx, val in overrides.items(): + pose[:, idx] = val + return pose + + progress = env.episode_length_buf.float() / float(env.max_episode_length) + # Find which segment we're in (broadcast over envs). + out = build_pose(waypoints[0]["overrides"]) + for i in range(1, len(waypoints)): + f0 = waypoints[i - 1]["frac"] + f1 = waypoints[i]["frac"] + span = max(f1 - f0, 1e-6) + tau = ((progress - f0) / span).clamp(0.0, 1.0).unsqueeze(-1) + prev_pose = build_pose(waypoints[i - 1]["overrides"]) + next_pose = build_pose(waypoints[i]["overrides"]) + seg = prev_pose * (1.0 - tau) + next_pose * tau + # Take this segment's value when progress is in [f0, f1] or past it. + mask = (progress >= f0).float().unsqueeze(-1) + out = torch.where(mask > 0, seg, out) + return out + + +def _multistage_target_height( + env: ManagerBasedRlEnv, + waypoints, +) -> torch.Tensor: + """Same logic as _multistage_target_pose but for trunk z height. + + waypoints: [{"frac": float, "height": float}, ...]. + """ + progress = env.episode_length_buf.float() / float(env.max_episode_length) + out = torch.full_like(progress, waypoints[0]["height"]) + for i in range(1, len(waypoints)): + f0 = waypoints[i - 1]["frac"] + f1 = waypoints[i]["frac"] + span = max(f1 - f0, 1e-6) + tau = ((progress - f0) / span).clamp(0.0, 1.0) + seg = waypoints[i - 1]["height"] * (1.0 - tau) + waypoints[i]["height"] * tau + mask = (progress >= f0).float() + out = torch.where(mask > 0, seg, out) + return out + + +def multistage_pose_target_match( + env: ManagerBasedRlEnv, + waypoints: list, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, + std: float = 0.3, + joint_indices: Optional[list] = None, +) -> torch.Tensor: + """Multi-waypoint variant of interpolated_pose_target_match. + + waypoints: [{"frac": 0.0, "overrides": None}, + {"frac": 0.4, "overrides": FOLD_OVERRIDES}, + {"frac": 0.7, "overrides": SIT_OVERRIDES}] + + Use this to enforce a curriculum-style trajectory through one or more + intermediate poses (e.g. stand → fold → sit). Same per-joint Gaussian + semantics as the single-stage version. + """ + asset = env.scene[asset_cfg.name] + target = _multistage_target_pose(env, asset_cfg, waypoints) + joint_pos = _servo_joint_pos(env, asset) + if joint_indices is not None: + joint_pos = joint_pos[:, joint_indices] + target = target[:, joint_indices] + return torch.exp(-((joint_pos - target) / std) ** 2).mean(dim=-1) + + +def multistage_pose_l1_penalty( + env: ManagerBasedRlEnv, + waypoints: list, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, + joint_indices: Optional[list] = None, +) -> torch.Tensor: + """L1 companion to multistage_pose_target_match.""" + asset = env.scene[asset_cfg.name] + target = _multistage_target_pose(env, asset_cfg, waypoints) + joint_pos = _servo_joint_pos(env, asset) + if joint_indices is not None: + joint_pos = joint_pos[:, joint_indices] + target = target[:, joint_indices] + return -torch.abs(joint_pos - target).mean(dim=-1) + + +def multistage_height_target( + env: ManagerBasedRlEnv, + waypoints: list, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, + std: float = 0.03, +) -> torch.Tensor: + """Multi-waypoint Gaussian on trunk z.""" + target_z = _multistage_target_height(env, waypoints) + asset = env.scene[asset_cfg.name] + z = torch.nan_to_num( + asset.data.root_link_pos_w[:, 2] - env.scene.terrain.env_origins[:, 2], nan=0.0 + ) + return torch.exp(-((z - target_z) / std) ** 2) + + +def multistage_height_l1_penalty( + env: ManagerBasedRlEnv, + waypoints: list, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """L1 companion to multistage_height_target.""" + target_z = _multistage_target_height(env, waypoints) + asset = env.scene[asset_cfg.name] + z = torch.nan_to_num( + asset.data.root_link_pos_w[:, 2] - env.scene.terrain.env_origins[:, 2], nan=0.0 + ) + return -torch.abs(z - target_z) + + +def pose_target_match( + env: ManagerBasedRlEnv, + target_overrides: Optional[dict] = None, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, + std: float = 0.3, + joint_indices: Optional[list] = None, +) -> torch.Tensor: + """Gaussian pose-match against a single fixed target. + + target = ``default_joint_pos`` with the per-index overrides applied. No + waypoints, no episode-progress interpolation — the same target is rewarded + from t=0 to the end of the episode. + """ + asset = env.scene[asset_cfg.name] + target = _servo_default_joint_pos(env, asset).clone() + if target_overrides: + for idx, val in target_overrides.items(): + target[:, idx] = val + joint_pos = _servo_joint_pos(env, asset) + if joint_indices is not None: + joint_pos = joint_pos[:, joint_indices] + target = target[:, joint_indices] + return torch.exp(-((joint_pos - target) / std) ** 2).mean(dim=-1) + + +def pose_l1_penalty( + env: ManagerBasedRlEnv, + target_overrides: Optional[dict] = None, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, + joint_indices: Optional[list] = None, +) -> torch.Tensor: + """L1 companion to ``pose_target_match`` (constant gradient toward target).""" + asset = env.scene[asset_cfg.name] + target = _servo_default_joint_pos(env, asset).clone() + if target_overrides: + for idx, val in target_overrides.items(): + target[:, idx] = val + joint_pos = _servo_joint_pos(env, asset) + if joint_indices is not None: + joint_pos = joint_pos[:, joint_indices] + target = target[:, joint_indices] + return -torch.abs(joint_pos - target).mean(dim=-1) + + +def height_target_gaussian( + env: ManagerBasedRlEnv, + target_height: float, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, + std: float = 0.02, +) -> torch.Tensor: + """Gaussian on trunk z against a single fixed target.""" + asset = env.scene[asset_cfg.name] + z = torch.nan_to_num( + asset.data.root_link_pos_w[:, 2] - env.scene.terrain.env_origins[:, 2], nan=0.0 + ) + return torch.exp(-((z - target_height) / std) ** 2) + + +def height_l1_penalty( + env: ManagerBasedRlEnv, + target_height: float, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """L1 companion to ``height_target_gaussian``.""" + asset = env.scene[asset_cfg.name] + z = torch.nan_to_num( + asset.data.root_link_pos_w[:, 2] - env.scene.terrain.env_origins[:, 2], nan=0.0 + ) + return -torch.abs(z - target_height) + + +def trunk_vertical_accel_penalty( + env: ManagerBasedRlEnv, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """Penalty proportional to ``|a_z|`` of the trunk (finite-diff of v_z). + + Captures hard impacts (large deceleration spike on landing) AND incentivises + a smooth quasi-static descent (constant velocity → a_z ≈ 0). At rest a_z is + zero so the seated robot pays no cost. + + State is kept on the env in ``_prev_trunk_vz``; at episode reset the + accel is zeroed to avoid a transient from the previous episode's final + state leaking into the new one. + """ + asset = env.scene[asset_cfg.name] + vz = torch.nan_to_num(asset.data.root_link_lin_vel_w[:, 2], nan=0.0) + prev = getattr(env, "_prev_trunk_vz", None) + if prev is None or prev.shape[0] != vz.shape[0]: + prev = vz.detach().clone() + a_z = (vz - prev) / env.step_dt + # Zero out a_z at reset steps to suppress the cross-episode transient. + if hasattr(env, "episode_length_buf"): + reset_mask = env.episode_length_buf <= 1 + a_z = torch.where(reset_mask, torch.zeros_like(a_z), a_z) + env._prev_trunk_vz = vz.detach().clone() + return -torch.abs(a_z) + + +def trunk_downward_velocity_penalty( + env: ManagerBasedRlEnv, + max_down_vel: float = 0.05, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """Penalty on downward trunk velocity beyond ``max_down_vel``. + + Caps descent SPEED, which ``trunk_vertical_accel_penalty`` alone cannot: + a fast constant-velocity drop has a_z ≈ 0 the whole way down and pays only + one impact spike at the bottom — cheap relative to arriving at the target + pose sooner. This term makes every step of a too-fast descent cost reward, + so the gentlest descent that stays under the cap is optimal. Zero at rest + and for any motion slower than the cap (including all upward motion). + """ + asset = env.scene[asset_cfg.name] + vz = torch.nan_to_num(asset.data.root_link_lin_vel_w[:, 2], nan=0.0) + return -torch.clamp(-vz - max_down_vel, min=0.0) + + +def seated_stillness( + env: ManagerBasedRlEnv, + height_full: float = 0.06, + height_zero: float = 0.08, + vel_std: float = 0.05, + tilt_full_deg: float = 25.0, + tilt_zero_deg: float = 60.0, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """Reward trunk stillness while seated UPRIGHT: |v| Gaussian, z- and tilt-gated. + + exp(-(|v|/vel_std)²) · smoothstep(z) · smoothstep(tilt). The z gate is full + below ``height_full`` and zero above ``height_zero`` (inactive during the + descent). The tilt gate is full below ``tilt_full_deg`` and zero above + ``tilt_zero_deg`` — WITHOUT it, "lie still on your back" scores as well as + "sit still upright" (the trunk on its back is inside the seated z band and + perfectly motionless), which is exactly the exploit run 2 converged to. + Makes "rest quietly, upright, at the seated height" the only rewarded rest. + """ + asset = env.scene[asset_cfg.name] + v = torch.nan_to_num(asset.data.root_link_lin_vel_w, nan=0.0).norm(dim=-1) + z = torch.nan_to_num( + asset.data.root_link_pos_w[:, 2] - env.scene.terrain.env_origins[:, 2], nan=0.0 + ) + t = torch.clamp((height_zero - z) / max(height_zero - height_full, 1e-6), 0.0, 1.0) + z_gate = t * t * (3.0 - 2.0 * t) + quat = asset.data.root_link_quat_w + cos_tilt = 1.0 - 2.0 * (quat[:, 1] ** 2 + quat[:, 2] ** 2) + cos_full = math.cos(math.radians(tilt_full_deg)) + cos_zero = math.cos(math.radians(tilt_zero_deg)) + u = torch.clamp((cos_tilt - cos_zero) / max(cos_full - cos_zero, 1e-6), 0.0, 1.0) + tilt_gate = u * u * (3.0 - 2.0 * u) + return torch.exp(-((v / vel_std) ** 2)) * z_gate * tilt_gate + + +def upright_while_tall( + env: ManagerBasedRlEnv, + height_low: float, + height_high: float, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """Linear upright reward weighted by a smoothstep on trunk z. + + Returns ``body_upright_linear * smoothstep((z - low)/(high - low))`` so the + upright incentive is full while the robot is still standing tall, and + fades to zero once it has committed to the lower sit configuration (where + butt-on-ground orientation is fine). Prevents the policy from learning to + tip backward while still high (which would otherwise farm the descent + reward via a controlled fall). + """ + asset = env.scene[asset_cfg.name] + quat = asset.data.root_link_quat_w + qx = quat[:, 1] + qy = quat[:, 2] + upright = 1.0 - 2.0 * (qx * qx + qy * qy) + z = torch.nan_to_num( + asset.data.root_link_pos_w[:, 2] - env.scene.terrain.env_origins[:, 2], nan=0.0 + ) + t = torch.clamp((z - height_low) / max(height_high - height_low, 1e-6), 0.0, 1.0) + smooth = t * t * (3.0 - 2.0 * t) + return upright * smooth + + +def phase_pose_blend( + phase: torch.Tensor, + descent_end: float, + hold_end: float, + rise_end: float, +) -> torch.Tensor: + """Blend 0..1 le long de la phase [0,1) — 0 = pose STAND, 1 = pose DOWN. + + [0, descent_end) : 0 -> 1 (se baisser) + [descent_end, hold_end): 1 (bas) + [hold_end, rise_end) : 1 -> 0 (se lever) + [rise_end, 1.0) : 0 (haut / repos) + """ + b = torch.zeros_like(phase) + descend = phase < descent_end + b = torch.where(descend, phase / descent_end, b) + low = (phase >= descent_end) & (phase < hold_end) + b = torch.where(low, torch.ones_like(phase), b) + rise = (phase >= hold_end) & (phase < rise_end) + b = torch.where(rise, 1.0 - (phase - hold_end) / (rise_end - hold_end), b) + return b + + +def kick_pose_target( + phase: torch.Tensor, + stand: torch.Tensor, + back: torch.Tensor, + forward: torch.Tensor, + windup_end: float, + kick_end: float, + return_end: float, +) -> torch.Tensor: + """Cible articulaire interpolée d'un geste de shoot à 4 keyframes. + + phase (B,) ∈ [0,1). stand/back/forward (k,) ou (1,k). Retour (B,k). + + [0, windup_end) STAND -> BACK (armement) + [windup_end, kick_end) BACK -> FORWARD (frappe sèche) + [kick_end, return_end) FORWARD -> STAND (retour) + [return_end, 1.0) STAND (repos) + """ + p = phase.unsqueeze(-1) # (B,1) + + def interp(a, b, s): + return a + s * (b - a) + + s1 = (p / windup_end).clamp(0.0, 1.0) + s2 = ((p - windup_end) / (kick_end - windup_end)).clamp(0.0, 1.0) + s3 = ((p - kick_end) / (return_end - kick_end)).clamp(0.0, 1.0) + + seg1 = interp(stand, back, s1) + seg2 = interp(back, forward, s2) + seg3 = interp(forward, stand, s3) # à s3=1 (phase>=return_end) => STAND + + out = seg1 + out = torch.where(p >= windup_end, seg2, out) + out = torch.where(p >= kick_end, seg3, out) + return out + + +def _kick_pose_error( + env: ManagerBasedRlEnv, + asset_cfg: SceneEntityCfg, + command_name: str, + stand_pose: dict, + back_pose: dict, + forward_pose: dict, + windup_end: float, + kick_end: float, + return_end: float, + joint_names: Optional[list] = None, +): + """(cur, target) pour le geste de shoot, joints résolus PAR NOM. + + Les 3 poses partagent les mêmes clés (14 joints). L'ordre des noms est + donné par `stand_pose` (ou par `joint_names` si fourni — un sous-ensemble + des clés, ex. jambe droite + cou d'un côté, jambe gauche de l'autre, pour + appliquer des std différents au geste vs à la jambe d'appui). + """ + if not stand_pose: + raise ValueError("_kick_pose_error requires a non-empty stand_pose dict") + asset: Entity = env.scene[asset_cfg.name] + names = list(joint_names) if joint_names is not None else list(stand_pose.keys()) + ids = [int(asset.find_joints([n])[0][0]) for n in names] + + def vec(d): + return torch.tensor([d[n] for n in names], device=env.device, + dtype=asset.data.joint_pos.dtype) + + stand_v, back_v, fwd_v = vec(stand_pose), vec(back_pose), vec(forward_pose) + + cmd = env.command_manager.get_command(command_name) + phase = (torch.atan2(cmd[:, 1], cmd[:, 0]) / (2 * torch.pi)) % 1.0 # (B,) + target = kick_pose_target(phase, stand_v, back_v, fwd_v, + windup_end, kick_end, return_end) # (B,k) + cur = asset.data.joint_pos[:, ids] # (B,k) + return cur, target + + +def kick_pose_track( + env: ManagerBasedRlEnv, + command_name: str = "twist", + stand_pose: Optional[dict] = None, + back_pose: Optional[dict] = None, + forward_pose: Optional[dict] = None, + std: float = 0.4, + windup_end: float = 0.35, + kick_end: float = 0.45, + return_end: float = 0.75, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, + joint_names: Optional[list] = None, +) -> torch.Tensor: + """Gaussienne sur la pose articulaire vs cible interpolée du shoot. + + Reward directif et symétrique : chaque phase impose la config articulaire + exacte. Résolution PAR NOM. `joint_names` restreint l'évaluation à un + sous-ensemble (ex. jambe droite + cou tracés serré, jambe gauche d'appui + tracée lâche pour la laisser équilibrer). + """ + cur, target = _kick_pose_error( + env, asset_cfg, command_name, stand_pose or {}, back_pose or {}, + forward_pose or {}, windup_end, kick_end, return_end, joint_names, + ) + return torch.exp(-((cur - target) / std) ** 2).mean(dim=-1) + + +def kick_pose_track_l1( + env: ManagerBasedRlEnv, + command_name: str = "twist", + stand_pose: Optional[dict] = None, + back_pose: Optional[dict] = None, + forward_pose: Optional[dict] = None, + windup_end: float = 0.35, + kick_end: float = 0.45, + return_end: float = 0.75, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, + joint_names: Optional[list] = None, +) -> torch.Tensor: + """Bootstrap L1 vers la cible interpolée (gradient constant, pénalité<=0).""" + cur, target = _kick_pose_error( + env, asset_cfg, command_name, stand_pose or {}, back_pose or {}, + forward_pose or {}, windup_end, kick_end, return_end, joint_names, + ) + return -(cur - target).abs().mean(dim=-1) + + +def kick_engagement( + phase: torch.Tensor, + windup_end: float, + return_end: float, +) -> torch.Tensor: + """Gate d'engagement du geste ∈ [0,1] (pur) — pour pondérer les rewards + d'équilibre unipède qui ne doivent s'appliquer que hors du repos STAND. + + [0, windup_end) : 0 -> 1 (montée pendant l'armement) + [windup_end, return_end): 1 (phase de frappe = appui unipède attendu) + [return_end, 1.0) : 0 (repos STAND, appui bipède, CoM centré OK) + """ + g = torch.zeros_like(phase) + ramp = phase < windup_end + g = torch.where(ramp, phase / windup_end, g) + hold = (phase >= windup_end) & (phase < return_end) + g = torch.where(hold, torch.ones_like(phase), g) + return g + + +def com_over_support_foot( + env: ManagerBasedRlEnv, + asset_cfg: SceneEntityCfg, + command_name: str = "twist", + std: float = 0.04, + windup_end: float = 0.35, + return_end: float = 0.75, +) -> torch.Tensor: + """Reward gaussien : projection horizontale du CoM proche du pied d'appui, + gaté sur la phase de frappe (kick_engagement). + + Apprend le transfert latéral du poids sur le pied d'appui (support). Sans + ça, un geste à un pied issu de poses relevées en appui bipède garde le CoM + centré entre les deux pieds → bascule et chute dès que l'autre pied se lève. + Au repos STAND le gate est 0 (appui bipède, CoM centré autorisé). + + `asset_cfg` doit cibler le site du pied d'appui (ex. site_names=["left_foot"]). + `std` en mètres (rayon de tolérance CoM↔pied, ~taille du pied). + """ + asset: Entity = env.scene[asset_cfg.name] + com_xy = asset.data.root_com_pos_w[:, :2] + foot_id = asset_cfg.site_ids[0] + foot_xy = asset.data.site_pos_w[:, foot_id, :2] + dist2 = ((com_xy - foot_xy) ** 2).sum(dim=-1) + reward = torch.exp(-dist2 / (std ** 2)) + + cmd = env.command_manager.get_command(command_name) + phase = (torch.atan2(cmd[:, 1], cmd[:, 0]) / (2 * torch.pi)) % 1.0 + gate = kick_engagement(phase, windup_end, return_end) + return gate * reward + + +def _phase_pose_error( + env: ManagerBasedRlEnv, + asset_cfg: SceneEntityCfg, + command_name: str, + target_pose: dict, + descent_end: float, + hold_end: float, + rise_end: float, + source_pose: Optional[dict] = None, +): + """(cur, target) pour la pose interpolée par la phase, résolue PAR NOM. + + Cible = source + blend(phase)·(target_pose - source), source = STAND + (`source_pose` si fourni, sinon le DEFAULT/HOME du modèle). blend ∈ [0,1] + (0 = STAND, 1 = target_pose) via `phase_pose_blend`. + """ + if not target_pose: + raise ValueError("_phase_pose_error requires a non-empty target_pose dict") + + asset: Entity = env.scene[asset_cfg.name] + cmd = env.command_manager.get_command(command_name) + phase = (torch.atan2(cmd[:, 1], cmd[:, 0]) / (2 * torch.pi)) % 1.0 # (B,) + blend = phase_pose_blend(phase, descent_end, hold_end, rise_end) # (B,) + + names = list(target_pose.keys()) + ids = [int(asset.find_joints([n])[0][0]) for n in names] + default = asset.data.default_joint_pos[:, ids] # (B,k) + + source = default.clone() + if source_pose: + for j, n in enumerate(names): + if n in source_pose: + source[:, j] = source_pose[n] + target_vec = torch.tensor( + [target_pose[n] for n in names], device=env.device, dtype=default.dtype + ).unsqueeze(0) # (1,k) + + target = source + blend.unsqueeze(-1) * (target_vec - source) # (B,k) + cur = asset.data.joint_pos[:, ids] # (B,k) + return cur, target + + +def phase_pose_track( + env: ManagerBasedRlEnv, + command_name: str = "twist", + target_pose: Optional[dict] = None, + source_pose: Optional[dict] = None, + std: float = 0.3, + descent_end: float = 0.15, + hold_end: float = 0.50, + rise_end: float = 0.65, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """Gaussienne sur la pose articulaire vs cible interpolée STAND<->DOWN. + + Reward directif : indique la config articulaire exacte à chaque phase. Se + relever (cible → STAND) est récompensé exactement comme se baisser (cible → + DOWN) — symétrique par construction. Résolution PAR NOM. + """ + cur, target = _phase_pose_error( + env, asset_cfg, command_name, target_pose or {}, + descent_end, hold_end, rise_end, source_pose, + ) + return torch.exp(-((cur - target) / std) ** 2).mean(dim=-1) + + +def phase_pose_track_l1( + env: ManagerBasedRlEnv, + command_name: str = "twist", + target_pose: Optional[dict] = None, + source_pose: Optional[dict] = None, + descent_end: float = 0.15, + hold_end: float = 0.50, + rise_end: float = 0.65, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """Bootstrap L1 vers la cible interpolée (pénalité négative). + + Gradient constant partout — donne une direction vers la cible même quand la + gaussienne ci-dessus a saturé à ~0 loin de la cible. + """ + cur, target = _phase_pose_error( + env, asset_cfg, command_name, target_pose or {}, + descent_end, hold_end, rise_end, source_pose, + ) + return -(cur - target).abs().mean(dim=-1) + + +def phase_pose_match( + env: ManagerBasedRlEnv, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, + std: float = 0.3, + command_name: str = "twist", + joint_indices: Optional[list] = None, + target_overrides: Optional[dict] = None, + phase: str = "approach", +) -> torch.Tensor: + """Reward matching a target pose, weighted by phase-cycle command. + + Generic helper for phase-conditioned tasks (e.g. sit/stand). The command + encodes phase as [cos(2π·phase), sin(2π·phase), 0]: + - "approach" weight = max(0, sin(2π·phase)) — peaks at phase 0.25. + - "return" weight = max(0,-sin(2π·phase)) — peaks at phase 0.75. + + Args: + std: Gaussian std per joint (rad). + joint_indices: Optional subset of joints to evaluate (rest ignored). + target_overrides: {joint_index: angle_rad}. Joints not listed default + to asset.data.default_joint_pos (the home/standing pose). + phase: "approach" or "return". + """ + asset = env.scene[asset_cfg.name] + joint_pos = _servo_joint_pos(env, asset) + target = _servo_default_joint_pos(env, asset).clone() + if target_overrides: + for idx, val in target_overrides.items(): + target[:, idx] = val + if joint_indices is not None: + joint_pos = joint_pos[:, joint_indices] + target = target[:, joint_indices] + pose_reward = torch.exp(-((joint_pos - target) / std) ** 2).mean(dim=-1) + + cmd = env.command_manager.get_command(command_name) + if phase == "approach": + weight = torch.clamp(cmd[:, 1], min=0.0) + else: + weight = torch.clamp(-cmd[:, 1], min=0.0) + return weight * pose_reward + + +def ground_pick_return_pose( + env: ManagerBasedRlEnv, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, + std: float = 0.3, + command_name: str = "twist", + joint_indices: Optional[list] = None, +) -> torch.Tensor: + """Reward for returning to the standing pose after ground pick, weighted by the return phase. + + The return phase is the second half-cycle (sin < 0, phase ∈ [0.5, 1.0]), + smoothly weighted by max(0, -sin(2π*phase)). + + Args: + std: Gaussian std per joint (rad). + joint_indices: Subset of joints to evaluate. Use to apply different stds + to leg joints vs neck/head joints (call this reward twice). + """ + asset = env.scene[asset_cfg.name] + joint_pos = _servo_joint_pos(env, asset) # (num_envs, n_servo_joints) + default_pos = _servo_default_joint_pos(env, asset) + + if joint_indices is not None: + joint_pos = joint_pos[:, joint_indices] + default_pos = default_pos[:, joint_indices] + + pose_reward = torch.exp(-((joint_pos - default_pos) / std) ** 2).mean(dim=-1) + + # Return weight: max(0, -sin(2π*phase)) — peaks at 1 at phase=0.75, zero at 0.5 and 1 + cmd = env.command_manager.get_command(command_name) + return_weight = torch.clamp(-cmd[:, 1], min=0.0) + + return return_weight * pose_reward + + +def ground_pick_return_upright( + env: ManagerBasedRlEnv, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, + std: float = 0.4, + command_name: str = "twist", +) -> torch.Tensor: + """Reward trunk verticality, weighted by the RETURN phase (stand-up aid). + + Same return weighting as ``ground_pick_return_pose`` (``max(0, -sin(2π·phase))``) + so it only rewards being upright during the stand-up, never fighting the + forward lean of the approach. Verticality = ``exp(-tilt²/std²)`` with the same + tilt proxy as ``body_upright_gaussian`` (``2*(qx²+qy²) ≈ 1-cos(tilt)``). A broad + std (0.4 rad ≈ 23°) gives gradient even from a fairly tilted crouch. + """ + asset: Entity = env.scene[asset_cfg.name] + quat = asset.data.root_link_quat_w + tilt_sq = 2.0 * (quat[:, 1] ** 2 + quat[:, 2] ** 2) # qx² + qy² + upright = torch.exp(-tilt_sq / (std * std)) + cmd = env.command_manager.get_command(command_name) + return_weight = torch.clamp(-cmd[:, 1], min=0.0) + return return_weight * upright + + +# --------------------------------------------------------------------------- # +# Ground-pick : gating de phase SEGMENTÉ (durées descente/palier/remontée/repos # +# indépendantes, au lieu de la pondération sinusoïdale max(0,±sin)). # +# down-gate = phase_pose_blend(phase, descent_end, hold_end, rise_end) # +# 0 (haut) -> 1 (descente) -> 1 (palier bas) -> 0 (remontée/repos) # +# up-gate = phase_rise_gate(phase, hold_end, rise_end) # +# 0 avant la remontée -> 0..1 (remontée) -> 1 (repos debout) # +# --------------------------------------------------------------------------- # +def phase_rise_gate( + phase: torch.Tensor, hold_end: float, rise_end: float +) -> torch.Tensor: + """Gate montante pour le RETOUR : 0 avant hold_end, 0->1 sur [hold_end, + rise_end), 1 après (repos debout).""" + g = torch.zeros_like(phase) + rising = (phase >= hold_end) & (phase < rise_end) + g = torch.where(rising, (phase - hold_end) / (rise_end - hold_end), g) + g = torch.where(phase >= rise_end, torch.ones_like(phase), g) + return g + + +def _gp_phase(env: ManagerBasedRlEnv, command_name: str) -> torch.Tensor: + cmd = env.command_manager.get_command(command_name) + return (torch.atan2(cmd[:, 1], cmd[:, 0]) / (2 * torch.pi)) % 1.0 + + +def mouth_ground_proximity_phased( + env: ManagerBasedRlEnv, + asset_cfg: SceneEntityCfg = SceneEntityCfg("robot", site_names=["mouth_tip"]), + std: float = 0.10, + target_height: float = 0.0, + command_name: str = "twist", + descent_end: float = 0.25, + hold_end: float = 0.35, + rise_end: float = 0.60, +) -> torch.Tensor: + """mouth_ground_proximity gaté par la down-gate segmentée (descente+palier).""" + asset = env.scene[asset_cfg.name] + mouth_z = asset.data.site_pos_w[:, asset_cfg.site_ids[0], 2] + proximity = torch.exp(-((mouth_z - target_height) / std) ** 2) + gate = phase_pose_blend(_gp_phase(env, command_name), descent_end, hold_end, rise_end) + return gate * proximity + + +def mouth_perpendicular_phased( + env: ManagerBasedRlEnv, + asset_cfg: SceneEntityCfg = SceneEntityCfg("robot", site_names=["mouth_tip"]), + command_name: str = "twist", + descent_end: float = 0.25, + hold_end: float = 0.35, + rise_end: float = 0.60, +) -> torch.Tensor: + """mouth_perpendicular_to_ground gaté par la down-gate segmentée.""" + asset = env.scene[asset_cfg.name] + q = asset.data.site_quat_w[:, asset_cfg.site_ids[0], :] + w, qx, qy, qz = q[:, 0], q[:, 1], q[:, 2], q[:, 3] + x_axis_z = 2.0 * (qx * qz - w * qy) + alignment = -x_axis_z # 1 = bouche pointe droit vers le bas + gate = phase_pose_blend(_gp_phase(env, command_name), descent_end, hold_end, rise_end) + return gate * alignment + + +def ground_pick_return_pose_phased( + env: ManagerBasedRlEnv, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, + std: float = 0.3, + command_name: str = "twist", + joint_indices: Optional[list] = None, + hold_end: float = 0.35, + rise_end: float = 0.60, +) -> torch.Tensor: + """ground_pick_return_pose gaté par la up-gate segmentée (remontée+repos).""" + asset = env.scene[asset_cfg.name] + joint_pos = _servo_joint_pos(env, asset) + default_pos = _servo_default_joint_pos(env, asset) + if joint_indices is not None: + joint_pos = joint_pos[:, joint_indices] + default_pos = default_pos[:, joint_indices] + pose_reward = torch.exp(-((joint_pos - default_pos) / std) ** 2).mean(dim=-1) + gate = phase_rise_gate(_gp_phase(env, command_name), hold_end, rise_end) + return gate * pose_reward + + +def ground_pick_return_upright_phased( + env: ManagerBasedRlEnv, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, + std: float = 0.4, + command_name: str = "twist", + hold_end: float = 0.35, + rise_end: float = 0.60, +) -> torch.Tensor: + """ground_pick_return_upright gaté par la up-gate segmentée.""" + asset: Entity = env.scene[asset_cfg.name] + quat = asset.data.root_link_quat_w + tilt_sq = 2.0 * (quat[:, 1] ** 2 + quat[:, 2] ** 2) + upright = torch.exp(-tilt_sq / (std * std)) + gate = phase_rise_gate(_gp_phase(env, command_name), hold_end, rise_end) + return gate * upright + + +def neck_vel_descent_penalty( + env: ManagerBasedRlEnv, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, + command_name: str = "twist", + joint_indices: Optional[list] = None, + hold_end: float = 0.35, +) -> torch.Tensor: + """Pénalise la vitesse des joints du cou pendant la DESCENTE+palier (freine le + piqué de la tête). + + Coût = mean(joint_vel²) sur les joints donnés, gaté à 1 pour phase < hold_end + (descente + palier bas) et 0 ensuite (remontée + repos) -> ne gêne PAS le + relever du cou. Retourne un coût positif ; à utiliser avec un poids négatif. + """ + asset = env.scene[asset_cfg.name] + vel = _servo_joint_vel(env, asset) + if joint_indices is not None: + vel = vel[:, joint_indices] + cost = (vel ** 2).mean(dim=-1) + phase = _gp_phase(env, command_name) + gate = (phase < hold_end).to(vel.dtype) # descente + palier bas uniquement + return gate * cost + + +def sample_mouth_payload( + env: ManagerBasedRlEnv, + env_ids: torch.Tensor, + min_kg: float = 0.01, + max_kg: float = 0.04, +) -> None: + """Event de reset : tire une masse d'objet 'tenu dans la bouche' par env (kg), + stockée sur env._mouth_payload_kg. Utilisée par apply_mouth_payload_force.""" + buf = getattr(env, "_mouth_payload_kg", None) + if buf is None: + buf = torch.zeros(env.num_envs, device=env.device) + env._mouth_payload_kg = buf + if env_ids is None: + env_ids = torch.arange(env.num_envs, device=env.device) + buf[env_ids] = torch.rand(len(env_ids), device=env.device) * (max_kg - min_kg) + min_kg + + +def apply_mouth_payload_force( + env: ManagerBasedRlEnv, + asset_cfg: SceneEntityCfg = SceneEntityCfg( + "robot", body_names=["jaw_soft"], site_names=["mouth_tip"] + ), + command_name: str = "twist", + hold_end: float = 0.35, + ramp: float = 0.05, + gravity: float = 9.81, +) -> torch.Tensor: + """Hook par-step (utilisé comme reward de poids 0) : applique le POIDS de + l'objet tenu dans la bouche comme force externe verticale au mouth_tip, gaté + sur la remontée (phase >= hold_end, rampe rapide au moment du 'grab'). + + Émule une masse ponctuelle au bout de la bouche pendant le relever : la force + m·g est appliquée au CoM du corps + le couple (p_mouth - p_com) × F, ce qui + équivaut à l'appliquer au mouth_tip (bon bras de levier pour le cou). Retourne + 0 (ce n'est pas une vraie récompense — juste le hook d'application).""" + asset: Entity = env.scene[asset_cfg.name] + payload = getattr(env, "_mouth_payload_kg", None) + if payload is None: + return torch.zeros(env.num_envs, device=env.device) + phase = _gp_phase(env, command_name) + gate = ((phase - hold_end) / ramp).clamp(0.0, 1.0) # 0 avant grab -> 1 après + fz = -(gate * payload) * gravity # (N,) force verticale (bas) + + bid = int(asset_cfg.body_ids[0]) + sid = int(asset_cfg.site_ids[0]) + p_mouth = asset.data.site_pos_w[:, sid, :] # (N,3) + p_com = asset.data.body_com_pos_w[:, bid, :] # (N,3) + F = torch.zeros((env.num_envs, 3), device=env.device, dtype=p_mouth.dtype) + F[:, 2] = fz + tau = torch.cross(p_mouth - p_com, F, dim=-1) # applique F au mouth_tip + asset.write_external_wrench_to_sim( + forces=F.unsqueeze(1), torques=tau.unsqueeze(1), body_ids=[bid], + ) + return torch.zeros(env.num_envs, device=env.device) + + +# ============================================================================== +# Domain Randomization Events +# ============================================================================== + + +def randomize_delayed_actuator_gains( + env: ManagerBasedRlEnv, + env_ids: torch.Tensor, + kp_range: tuple[float, float], + kd_range: tuple[float, float], + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, + operation: str = "scale", +): + """Randomize firmware PD gains per episode (NON-accumulating). + + Under the canonical BAM actuator (``bam.mjlab.BamActuator``) gains are scaled + per-env via ``set_gains``/``reset_gains`` (the actuator owns ``kp_scale``/ + ``kd_scale``), so we never touch the MuJoCo model — no accumulation risk. The + sampled per-joint factors are averaged into a single scalar per env (the + actuator applies one scale across its joints), matching the previous behavior. + Non-BAM actuators are skipped (e.g. the roller XmlActuator, which doesn't + expose set_gains). + + Args: + env: The environment + env_ids: Environment IDs to randomize (None = all envs) + kp_range: (min, max) for kp randomization + kd_range: (min, max) for kd randomization + asset_cfg: Asset configuration + operation: unused (kept for cfg compatibility; scaling is always applied) + """ + del operation + from bam.mjlab import BamActuator + + if env_ids is None: + env_ids = torch.arange(env.num_envs, device=env.device, dtype=torch.int) + else: + env_ids = env_ids.to(env.device, dtype=torch.int) + + asset: Entity = env.scene[asset_cfg.name] + + for actuator in asset.actuators: + if not isinstance(actuator, BamActuator): + continue + n_joints = len(actuator.ctrl_ids) + kp_samples = torch.rand(len(env_ids), n_joints, device=env.device) * (kp_range[1] - kp_range[0]) + kp_range[0] + kd_samples = torch.rand(len(env_ids), n_joints, device=env.device) * (kd_range[1] - kd_range[0]) + kd_range[0] + # Restore nominal first (prevents accumulation), then apply fresh scale. + actuator.reset_gains(env_ids) + actuator.set_gains( + env_ids, + kp_scale=kp_samples.mean(dim=1, keepdim=True), + kd_scale=kd_samples.mean(dim=1, keepdim=True), + ) + + +@requires_model_fields("dof_frictionloss", "dof_damping") +def expand_bam_friction_fields( + env: ManagerBasedRlEnv, + env_ids: torch.Tensor, +): + """No-op startup event whose only purpose is the decorator above. + + bam's BamActuator (mjlab_frictionloss branch) writes a per-env friction + budget into MuJoCo's dof_frictionloss/dof_damping every step, which + requires those model fields to be expanded per world. mjlab expands + exactly the fields declared by event functions via requires_model_fields, + so every env using the BAM actuator must register this event. + """ + + +def randomize_bam_friction( + env: ManagerBasedRlEnv, + env_ids: torch.Tensor, + scale_range: tuple[float, float], + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +): + """Per-episode joint-friction randomization for the BAM actuator (NON-accumulating). + + Under BAM, MuJoCo's dof_frictionloss is zeroed (BAM computes friction in + compute()), so stock dr.dof_frictionloss is a no-op. Instead this samples a + per-env scalar in ``scale_range`` and applies it to the FrictionDRBamActuator's + ``friction_scale``, which multiplies BAM's velocity-independent friction budget + (Coulomb + Stribeck + load). Restores nominal (1.0) first to avoid accumulation. + No-op on actuators without a friction_scale hook. + """ + from mjlab_microduck.actuator.friction_dr_bam import FrictionDRBamActuator + + if env_ids is None: + env_ids = torch.arange(env.num_envs, device=env.device, dtype=torch.int) + else: + env_ids = env_ids.to(env.device, dtype=torch.int) + + asset: Entity = env.scene[asset_cfg.name] + lo, hi = scale_range + for actuator in asset.actuators: + if isinstance(actuator, FrictionDRBamActuator): + actuator.reset_friction_scale(env_ids) + samples = torch.rand(len(env_ids), 1, device=env.device) * (hi - lo) + lo + actuator.set_friction_scale(env_ids, samples) + + +def randomize_mass_and_inertia( + env: ManagerBasedRlEnv, + env_ids: torch.Tensor, + scale_range: tuple[float, float], + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +): + """Randomize body mass and inertia together with the same scaling factor. + + This maintains physical consistency - mass and inertia must scale together + to avoid creating invalid inertia tensors that cause simulation instability. + + Args: + env: The environment + env_ids: Environment IDs to randomize + scale_range: (min, max) scaling factor applied to both mass and inertia + asset_cfg: Asset configuration specifying which bodies to randomize + """ + if env_ids is None: + env_ids = torch.arange(env.num_envs, device=env.device, dtype=torch.int) + else: + env_ids = env_ids.to(env.device, dtype=torch.int) + + asset: Entity = env.scene[asset_cfg.name] + + # Get body indices + body_ids = asset_cfg.body_ids + if isinstance(body_ids, slice): + body_ids = list(range(asset.num_bodies))[body_ids] + body_indices = asset.indexing.body_ids[body_ids] + + # Sample ONE random scale per environment (applied to both mass and inertia) + num_envs = len(env_ids) + num_bodies = len(body_indices) + scales = torch.rand(num_envs, num_bodies, device=env.device) * (scale_range[1] - scale_range[0]) + scale_range[0] + + # Store original values on first call + if not hasattr(env, '_original_mass_inertia'): + env._original_mass_inertia = { + 'mass': env.sim.model.body_mass[0, body_indices].clone(), + 'inertia': env.sim.model.body_inertia[0, body_indices].clone(), + } + + # Reset to original first (to prevent accumulation) + original = env._original_mass_inertia + env.sim.model.body_mass[env_ids[:, None], body_indices] = original['mass'].unsqueeze(0).expand(num_envs, -1) + env.sim.model.body_inertia[env_ids[:, None], body_indices] = original['inertia'].unsqueeze(0).expand(num_envs, -1, -1) + + # Apply same scale to both mass and inertia + env.sim.model.body_mass[env_ids[:, None], body_indices] *= scales + env.sim.model.body_inertia[env_ids[:, None], body_indices] *= scales.unsqueeze(-1) # Scale all 3 inertia components + + +def standing_envs_curriculum( + env: ManagerBasedRlEnv, + env_ids: torch.Tensor, + command_name: str, + standing_stages: list[dict], +) -> torch.Tensor: + """Update the relative number of standing environments based on training progress. + + Args: + env: The RL environment + env_ids: Environment IDs (unused, but required by curriculum interface) + command_name: Name of the velocity command term + standing_stages: List of dicts with 'step' and 'rel_standing_envs' keys + Example: [ + {"step": 0, "rel_standing_envs": 0.02}, + {"step": 1000, "rel_standing_envs": 0.1}, + {"step": 2000, "rel_standing_envs": 0.2}, + ] + + Returns: + Current rel_standing_envs value as a tensor + """ + del env_ids # Unused + + from mjlab.tasks.velocity.mdp import UniformVelocityCommandCfg + from typing import cast + + command_term = env.command_manager.get_term(command_name) + assert command_term is not None, f"Command term '{command_name}' not found" + + cfg = cast(UniformVelocityCommandCfg, command_term.cfg) + + # Update rel_standing_envs based on current step + for stage in standing_stages: + if env.common_step_counter > stage["step"]: + cfg.rel_standing_envs = stage["rel_standing_envs"] + + return torch.tensor([cfg.rel_standing_envs]) + + +def velocity_tracking_std_curriculum( + env: ManagerBasedRlEnv, + env_ids: torch.Tensor, + reward_name: str, + std_stages: list[dict], +) -> torch.Tensor: + """Update velocity tracking std parameter based on training progress. + + Starts with loose std (easy rewards) to learn basic walking, then gradually + tightens to improve velocity tracking accuracy. + + Args: + env: The RL environment + env_ids: Environment IDs (unused, but required by curriculum interface) + reward_name: Name of the reward term (e.g., "track_linear_velocity") + std_stages: List of dicts with 'step' and 'std' keys + Example: [ + {"step": 0, "std": 0.5}, # Start loose - learn to walk + {"step": 250, "std": 0.3}, # Moderate - refine gait + {"step": 500, "std": 0.2}, # Strict - accurate tracking + ] + + Returns: + Current std value as a tensor + """ + del env_ids # Unused + + # Get reward term configuration + reward_term_cfg = env.reward_manager.get_term_cfg(reward_name) + + # Update std based on current step + current_std = std_stages[0]["std"] # Default to first stage + + for stage in std_stages: + if env.common_step_counter > stage["step"]: + current_std = stage["std"] + + # Update the reward term's std parameter + reward_term_cfg.params["std"] = current_std + + return torch.tensor([current_std]) + + +def push_curriculum( + env: ManagerBasedRlEnv, + env_ids: torch.Tensor, + event_name: str, + push_stages: list[dict], +) -> torch.Tensor: + """Update push velocity range based on training progress. + + Starts with no/small pushes to learn clean walking, then gradually increases + to build robustness without disrupting early learning. + + Args: + env: The RL environment + env_ids: Environment IDs (unused, but required by curriculum interface) + event_name: Name of the push event term (e.g., "push_robot") + push_stages: List of dicts with 'step' and 'velocity_range' keys + Example: [ + {"step": 0, "velocity_range": {"x": (0.0, 0.0), "y": (0.0, 0.0)}}, + {"step": 250, "velocity_range": {"x": (-0.15, 0.15), "y": (-0.15, 0.15)}}, + {"step": 500, "velocity_range": {"x": (-0.3, 0.3), "y": (-0.3, 0.3)}}, + ] + + Returns: + Current max push magnitude as a tensor + """ + del env_ids # Unused + + # NOTE: must update the live EventManager term_cfg, not env.cfg.events — + # EventManager.__init__ does deepcopy(cfg), so mutating env.cfg.events is a no-op. + event_cfg = env.event_manager.get_term_cfg(event_name) + + # Update velocity_range based on current step + current_range = push_stages[0]["velocity_range"] # Default to first stage + + for stage in push_stages: + if env.common_step_counter > stage["step"]: + current_range = stage["velocity_range"] + + # Update the event configuration's velocity_range parameter + event_cfg.params["velocity_range"] = current_range + + # Return max magnitude for logging + max_push = max(abs(current_range["x"][0]), abs(current_range["x"][1])) + return torch.tensor([max_push]) + + +def wheel_friction_curriculum( + env: ManagerBasedRlEnv, + env_ids: torch.Tensor, + event_name: str, + ranges_stages: list[dict], +) -> torch.Tensor: + """Update wheel friction based on training step stages.""" + del env_ids # Unused + + current_ranges = ranges_stages[0]["ranges"] + for stage in ranges_stages: + if env.common_step_counter > stage["step"]: + current_ranges = stage["ranges"] + + env.event_manager.get_term_cfg(event_name).params["ranges"] = current_ranges + return torch.tensor([current_ranges[0]]) + + +def reward_weight( + env: ManagerBasedRlEnv, + env_ids: torch.Tensor, + reward_name: str, + weight_stages: list[dict], +) -> torch.Tensor: + """Step-staged reward weight curriculum. + + mjlab 1.3.0 dropped the built-in ``mdp.reward_weight`` helper, so microduck + provides its own. ``weight_stages`` is a list of ``{"step": int, "weight": + float}`` dicts; the weight of the latest stage whose step has elapsed is + applied. Mutates the live RewardManager term cfg (not env.cfg, which is a + deepcopy at manager init). + """ + del env_ids + term_cfg = env.reward_manager.get_term_cfg(reward_name) + for stage in weight_stages: + if env.common_step_counter > stage["step"]: + term_cfg.weight = stage["weight"] + return torch.tensor([term_cfg.weight]) + + +def com_range_curriculum( + env: ManagerBasedRlEnv, + env_ids: torch.Tensor, + event_name: str, + range_stages: list[dict], +) -> torch.Tensor: + """Update CoM randomization range based on training progress. + + Gradually increases the CoM offset range so the robot first learns to walk + with a small CoM uncertainty, then progressively larger. + + Args: + env: The RL environment + env_ids: Environment IDs (unused) + event_name: Name of the CoM randomization event (e.g., "randomize_com") + range_stages: List of dicts with 'step' and 'range' keys (range in meters) + Example: [ + {"step": 0, "range": 0.003}, + {"step": 1000 * 24, "range": 0.005}, + {"step": 2000 * 24, "range": 0.008}, + ] + + Returns: + Current range value as a tensor (for logging) + """ + del env_ids + + # NOTE: must update the live EventManager term_cfg, not env.cfg.events — + # EventManager.__init__ does deepcopy(cfg), so mutating env.cfg.events is a no-op. + event_cfg = env.event_manager.get_term_cfg(event_name) + + current_range = range_stages[0]["range"] + for stage in range_stages: + if env.common_step_counter > stage["step"]: + current_range = stage["range"] + + event_cfg.params["ranges"] = (-current_range, current_range) + return torch.tensor([current_range]) + + +def slope_move_masks(distance: "torch.Tensor", size_x: float): + """Masques de promotion/rétrogradation du curriculum de pente. + + move_up : a parcouru plus de 40% de la tuile → il a dévalé la rampe, + on la rend plus raide. Aligné sur la termination + terrain_edge_reached (~3.8 m, threshold_fraction=0.95 par + défaut sur size_x=8.0), qui termine l'épisode avant le seuil + de moitié (4.0 m) — sans cet alignement un traverseur réussi + n'est jamais promu. + move_down : a à peine avancé (< 20% de la tuile) → chute/blocage précoce, + on adoucit la rampe. + """ + move_up = distance > size_x * 0.4 + move_down = (distance < size_x * 0.2) & (~move_up) + return move_up, move_down + + +def terrain_levels_slope(env: ManagerBasedRlEnv, env_ids: torch.Tensor) -> torch.Tensor: + """Curriculum de raideur pour roller_slope (pas de vitesse commandée). + + Progression basée sur la distance en x parcourue depuis l'origine de spawn. + """ + asset = env.scene["robot"] + terrain = env.scene.terrain + assert terrain is not None + terrain_generator = terrain.cfg.terrain_generator + assert terrain_generator is not None + + distance = ( + asset.data.root_link_pos_w[env_ids, 0] - env.scene.env_origins[env_ids, 0] + ) + move_up, move_down = slope_move_masks(distance, terrain_generator.size[0]) + terrain.update_env_origins(env_ids, move_up, move_down) + return torch.mean(terrain.terrain_levels.float()) + + +def velocity_command_ranges_curriculum( + env: ManagerBasedRlEnv, + env_ids: torch.Tensor, + command_name: str, + velocity_stages: list[dict], + update_lin_vel_y: bool = True, + update_ang_vel_z: bool = True, + forward_only: bool = False, +) -> torch.Tensor: + """Update velocity command ranges based on training progress. + + Gradually increases the commanded velocity ranges to allow the robot to learn + higher speeds progressively. Starts with smaller ranges for stable learning, + then expands to more challenging velocities. + + Args: + env: The RL environment + env_ids: Environment IDs (unused, but required by curriculum interface) + command_name: Name of the velocity command term (e.g., "twist") + velocity_stages: List of dicts with 'step', 'lin_vel_range', and 'ang_vel_range' keys + Example: [ + {"step": 0, "lin_vel_range": 0.3, "ang_vel_range": 1.5}, + {"step": 500 * 24, "lin_vel_range": 0.4, "ang_vel_range": 1.75}, + {"step": 1000 * 24, "lin_vel_range": 0.5, "ang_vel_range": 2.0}, + ] + + Returns: + Current max linear velocity as a tensor + """ + del env_ids # Unused + + from mjlab.tasks.velocity.mdp import UniformVelocityCommandCfg + from typing import cast + + command_term = env.command_manager.get_term(command_name) + assert command_term is not None, f"Command term '{command_name}' not found" + + cfg = cast(UniformVelocityCommandCfg, command_term.cfg) + + # Update velocity ranges based on current step + current_lin_vel = velocity_stages[0]["lin_vel_range"] + current_ang_vel = velocity_stages[0]["ang_vel_range"] + + for stage in velocity_stages: + if env.common_step_counter > stage["step"]: + current_lin_vel = stage["lin_vel_range"] + current_ang_vel = stage["ang_vel_range"] + + # Update command ranges + if forward_only: + cfg.ranges.lin_vel_x = (0.0, current_lin_vel) + else: + cfg.ranges.lin_vel_x = (-current_lin_vel, current_lin_vel) + if update_lin_vel_y: + cfg.ranges.lin_vel_y = (-current_lin_vel, current_lin_vel) + if update_ang_vel_z: + cfg.ranges.ang_vel_z = (-current_ang_vel, current_ang_vel) + + return torch.tensor([current_lin_vel]) + + +def projected_gravity( + env: ManagerBasedRlEnv, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """Projected gravity vector in body frame. + + Returns the gravity vector projected into the robot's body frame, + representing pure orientation without linear acceleration. + This is simpler than raw accelerometer and only depends on orientation. + + Returns: + torch.Tensor: Projected gravity in body frame (num_envs, 3) + """ + asset: Entity = env.scene[asset_cfg.name] + return asset.data.projected_gravity_b + + +def _imu_misalignment_quat(env: ManagerBasedRlEnv, max_angle_rad: float) -> torch.Tensor: + """Per-env constant IMU mounting-misalignment rotation (sampled once). + + Models a fixed small mounting/calibration error of the IMU on each robot. + Sampled lazily on first use and cached — constant per env for the whole run + (like a startup randomization), so it's a *systematic per-robot bias*, not + per-step noise. Replaces the old randomize_imu_orientation event, which wrote + site_quat (not per-env expanded under mjlab 1.3.0, and not read by the + projected_gravity / base_ang_vel observations anyway). + + Returns a (num_envs, 4) unit quaternion (w, x, y, z). + """ + q = getattr(env, "_imu_misalign_quat", None) + if q is None: + n = env.num_envs + axis = torch.randn(n, 3, device=env.device) + axis = axis / (torch.norm(axis, dim=-1, keepdim=True) + 1e-8) + angle = torch.rand(n, device=env.device) * max_angle_rad # [0, max] + q = quat_from_angle_axis(angle, axis) + env._imu_misalign_quat = q + return q + + +def projected_gravity_imu_misaligned( + env: ManagerBasedRlEnv, + max_angle_deg: float = 1.0, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """projected_gravity with a per-env constant IMU mounting misalignment.""" + asset: Entity = env.scene[asset_cfg.name] + q = _imu_misalignment_quat(env, math.radians(max_angle_deg)) + return quat_apply(q, asset.data.projected_gravity_b) + + +def base_ang_vel_imu_misaligned( + env: ManagerBasedRlEnv, + max_angle_deg: float = 1.0, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """base angular velocity with the SAME per-env IMU misalignment as gravity.""" + asset: Entity = env.scene[asset_cfg.name] + q = _imu_misalignment_quat(env, math.radians(max_angle_deg)) + return quat_apply(q, asset.data.root_link_ang_vel_b) + + +def raw_accelerometer( + env: ManagerBasedRlEnv, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """Raw accelerometer reading (includes gravity + linear acceleration). + + Returns normalized raw accelerometer which mimics what a real IMU measures. + This is different from pure projected_gravity which only reflects orientation. + Reads from the MuJoCo accelerometer sensor "imu_accel". + + Returns: + torch.Tensor: Normalized raw accelerometer reading (num_envs, 3) + """ + asset: Entity = env.scene[asset_cfg.name] + + # Access the model to find the sensor address + # The accelerometer sensor is the 5th sensor (index 4) in robot.xml + # Sensors: framequat, gyro, gyro, velocimeter, accelerometer, subtreeangmom + mj_model = asset.data.model + + # Get sensor address from model arrays (sensor_adr is torch tensor) + sensor_adr_array = mj_model.sensor_adr # This is a TorchArray/tensor + sensor_id = 4 # imu_accel is the 5th sensor (0-indexed) + sensor_adr = int(sensor_adr_array[sensor_id].item()) # Convert to Python int + + # Read accelerometer data (specific force measured by sensor) + # Shape: (num_envs, 3) + accel_raw = asset.data.data.sensordata[:, sensor_adr:sensor_adr+3] + + # MuJoCo accelerometer measures specific force (like real sensor) + # Negate to match convention: when at rest upright, should point down + accel_negated = -accel_raw + + # Normalize to unit vector + accel_norm = torch.norm(accel_negated, dim=-1, keepdim=True) + accel_normalized = torch.where( + accel_norm > 0.1, + accel_negated / accel_norm, + asset.data.projected_gravity_b # Fallback to projected gravity + ) + + return accel_normalized + +def randomize_imu_orientation( + env: ManagerBasedRlEnv, + env_ids: torch.Tensor, + max_angle_deg: float = 2.0, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +): + """Randomize IMU sensor mounting orientation by small angles. + + Simulates slight mounting errors or calibration offsets in the real robot. + The IMU orientation is randomized by rotating around random axes by up to max_angle_deg. + + Args: + env: The environment + env_ids: Environment IDs to randomize + max_angle_deg: Maximum rotation angle in degrees (default 2.0°) + asset_cfg: Asset configuration + """ + if env_ids is None: + env_ids = torch.arange(env.num_envs, device=env.device, dtype=torch.int) + else: + env_ids = env_ids.to(env.device, dtype=torch.int) + + asset: Entity = env.scene[asset_cfg.name] + + # IMU site is the first site (index 0) in robot.xml + # Sites: imu (0), left_foot (1), right_foot (2) + site_id = 0 + + # Store original orientation on first call + if not hasattr(env, '_original_imu_quat'): + env._original_imu_quat = env.sim.model.site_quat[0, site_id].clone() + + # Generate random rotations for each environment + num_envs = len(env_ids) + max_angle_rad = max_angle_deg * torch.pi / 180.0 + + # Random rotation angles [-max_angle, +max_angle] for each axis + angles = (torch.rand(num_envs, 3, device=env.device) * 2 - 1) * max_angle_rad + + # Convert Euler angles to quaternions (small angle approximation for efficiency) + # For small angles: quat ≈ [1, θx/2, θy/2, θz/2] + half_angles = angles / 2.0 + quats_delta = torch.zeros(num_envs, 4, device=env.device) + quats_delta[:, 0] = 1.0 # w component + quats_delta[:, 1:] = half_angles # x, y, z components + + # Normalize the quaternion + quats_delta = quats_delta / torch.norm(quats_delta, dim=1, keepdim=True) + + # Get original quaternion and apply delta rotation + original_quat = env._original_imu_quat.unsqueeze(0).expand(num_envs, -1) + + # Quaternion multiplication: q_new = q_delta * q_original + # q1 * q2 = [w1*w2 - dot(v1,v2), w1*v2 + w2*v1 + cross(v1,v2)] + w1, x1, y1, z1 = quats_delta[:, 0], quats_delta[:, 1], quats_delta[:, 2], quats_delta[:, 3] + w2, x2, y2, z2 = original_quat[:, 0], original_quat[:, 1], original_quat[:, 2], original_quat[:, 3] + + new_quat = torch.stack([ + w1*w2 - x1*x2 - y1*y2 - z1*z2, # w + w1*x2 + x1*w2 + y1*z2 - z1*y2, # x + w1*y2 - x1*z2 + y1*w2 + z1*x2, # y + w1*z2 + x1*y2 - y1*x2 + z1*w2, # z + ], dim=1) + + # Apply to the selected environments + env.sim.model.site_quat[env_ids, site_id] = new_quat + + +def standing_phase( + env: ManagerBasedRlEnv, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """Simple time-based phase for standing task. + + Returns a scalar phase value that cycles from 0 to 1 based on time. + This allows the policy to have a sense of time progression even when standing. + + Args: + env: The RL environment + asset_cfg: Not used, but kept for API consistency + + Returns: + Phase value [0, 1] as tensor of shape (num_envs, 1) + """ + # Simple time-based phase that cycles every 2 seconds + # This gives the policy a time-varying signal + phase_period = 2.0 # seconds + time = env.episode_length_buf * env.step_dt + phase = (time % phase_period) / phase_period + + return phase.unsqueeze(-1) # Shape: (num_envs, 1) + + +def air_time_adaptive( + env: ManagerBasedRlEnv, + sensor_name: str, + command_name: str = "twist", + command_threshold: float = 0.01, # below this: no reward (standing) + running_threshold: float = 0.5, # above this: use running air-time window + walk_threshold_min: float = 0.10, + walk_threshold_max: float = 0.25, + run_threshold_min: float = 0.05, + run_threshold_max: float = 0.25, +) -> torch.Tensor: + """Air-time reward with separate swing-time windows for walking vs running. + + - command < command_threshold → 0 (standing, no reward) + - command_threshold–running_threshold → walk window [walk_min, walk_max] + - command > running_threshold → run window [run_min, run_max] + + This lets the walking gait keep its deliberate 100–250 ms swing while + running can use a faster 50–250 ms cadence. + """ + sensor = env.scene.sensors[sensor_name] + current_air_time = sensor.data.current_air_time # (num_envs, num_feet) + assert current_air_time is not None + + command = env.command_manager.get_command(command_name) + total_speed = torch.norm(command[:, :2], dim=1) + torch.abs(command[:, 2]) + + is_walking = ((total_speed >= command_threshold) & (total_speed < running_threshold)).float() # (num_envs,) + is_running = (total_speed >= running_threshold).float() + + # Per-env thresholds broadcast over feet + tmin = (is_walking * walk_threshold_min + is_running * run_threshold_min).unsqueeze(1) + tmax = (is_walking * walk_threshold_max + is_running * run_threshold_max).unsqueeze(1) + + in_range = (current_air_time > tmin) & (current_air_time < tmax) + reward = torch.sum(in_range.float(), dim=1) # sum over feet + + # Zero reward when standing + active = (total_speed >= command_threshold).float() + return reward * active + + +def stillness_at_zero_command( + env: ManagerBasedRlEnv, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, + command_name: str = "twist", + command_threshold: float = 0.01, + vel_std: float = 0.1, +) -> torch.Tensor: + """Reward staying still when command is near zero. + + Returns exp(-body_vel² / vel_std²) when command < threshold, else 0. + This is monotonically decreasing with body speed — moving faster is always + less rewarding. There is no threshold the robot can cross to 'escape' it, + unlike gate-based stepping penalties. + """ + asset: Entity = env.scene[asset_cfg.name] + + command = env.command_manager.get_command(command_name) + total_speed = torch.norm(command[:, :2], dim=1) + torch.abs(command[:, 2]) + is_standing_cmd = (total_speed < command_threshold).float() + + body_vel = torch.norm(asset.data.root_link_vel_w[:, :2], dim=1) + stillness = torch.exp(-body_vel ** 2 / vel_std ** 2) + + return is_standing_cmd * stillness + + +def joint_vel_l2_when_standing( + env: ManagerBasedRlEnv, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, + command_name: str = "twist", + command_threshold: float = 0.01, +) -> torch.Tensor: + """Penalise leg joint velocities only when command is near zero. + + Targets the standing-shake problem: the policy makes rapid oscillating + corrections around the home pose when standing. Gated on command so it + does not affect the walking gait at all. + """ + asset: Entity = env.scene[asset_cfg.name] + + command = env.command_manager.get_command(command_name) + total_speed = torch.norm(command[:, :2], dim=1) + torch.abs(command[:, 2]) + is_standing_cmd = (total_speed < command_threshold).float() + + leg_indices = list(range(0, 5)) + list(range(9, 14)) + joint_vel = asset.data.joint_vel[:, leg_indices] + vel_sq = torch.sum(joint_vel ** 2, dim=-1) + + return is_standing_cmd * vel_sq + + +def foot_step_penalty_when_standing( + env: ManagerBasedRlEnv, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, + command_name: str = "twist", + command_threshold: float = 0.01, + body_vel_threshold: float = 0.2, + air_time_threshold: float = 0.05, +) -> torch.Tensor: + """Penalise stepping when at zero command and the body is not being pushed. + + Symmetric counterpart to the air_time reward: + - air_time gives +reward for stepping when command > threshold (walk) + - this gives -reward for stepping when command < threshold (stand) + + The body-velocity gate prevents penalising recovery steps after a push: + if the robot is already moving fast (pushed), no penalty is applied so it + can still take steps to catch itself. + + Returns a value in [0, 1] (use a negative weight in the config). + """ + asset: Entity = env.scene[asset_cfg.name] + contact_sensor = env.scene.sensors["feet_ground_contact"] + + # Was either foot recently lifted? (last completed air phase > threshold) + air_time = contact_sensor.data.last_air_time[:, :2] # (num_envs, 2) + any_foot_stepped = (air_time > air_time_threshold).any(dim=1).float() + + # Are we in standing mode? (command near zero) + command = env.command_manager.get_command(command_name) + total_speed = torch.norm(command[:, :2], dim=1) + torch.abs(command[:, 2]) + is_standing = (total_speed < command_threshold).float() + + # Is the body still? (not being pushed) + body_vel = torch.norm(asset.data.root_link_vel_w[:, :2], dim=1) + is_still = (body_vel < body_vel_threshold).float() + + return any_foot_stepped * is_standing * is_still + + +def recovery_stepping_reward( + env: ManagerBasedRlEnv, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, + command_name: str = "twist", + command_threshold: float = 0.01, + velocity_threshold: float = 0.3, + air_time_threshold: float = 0.05, +) -> torch.Tensor: + """Reward foot air time only when at zero command AND robot has high velocity (recovering from push). + + This encourages the robot to take steps to recover balance when pushed, + but does NOT fire during normal walking (command > threshold). + + Args: + env: The RL environment + asset_cfg: Asset configuration (unused but kept for API consistency) + command_name: Name of the velocity command in the command manager + command_threshold: Speed below which the robot is considered to be in standing mode + velocity_threshold: Linear velocity threshold to activate stepping reward (m/s) + air_time_threshold: Minimum air time to count as a step (seconds) + + Returns: + Reward tensor of shape (num_envs,) + """ + asset: Entity = env.scene[asset_cfg.name] + + # Only fire for standing envs (command near zero) + command = env.command_manager.get_command(command_name) + total_speed = torch.norm(command[:, :2], dim=1) + torch.abs(command[:, 2]) + is_standing_cmd = (total_speed < command_threshold).float() + + # Get base linear velocity magnitude + base_lin_vel = asset.data.root_link_vel_w[:, :3] # (num_envs, 3) + vel_magnitude = torch.norm(base_lin_vel[:, :2], dim=1) # Only XY plane + + # Only reward stepping when velocity is high (being pushed) + should_step = vel_magnitude > velocity_threshold + + # Get foot air time from contact sensor + contact_sensor = env.scene.sensors["feet_ground_contact"] + air_time = contact_sensor.data.last_air_time[:, :2] # (num_envs, 2) - left and right foot + + # Reward if either foot has been in air recently + foot_in_air = (air_time > air_time_threshold).any(dim=1) # (num_envs,) + + # Only give reward when: standing command AND high body velocity AND foot stepped + reward = is_standing_cmd * should_step.float() * foot_in_air.float() + + return reward + + +def adaptive_pose_weight( + env: ManagerBasedRlEnv, + base_pose_reward: torch.Tensor, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, + velocity_threshold: float = 0.3, + min_weight: float = 0.3, +) -> torch.Tensor: + """Reduce pose tracking weight when robot has high velocity (recovering from push). + + This gives the robot freedom to deviate from the standing pose when taking + recovery steps, while maintaining strict pose tracking when standing still. + + Args: + env: The RL environment + base_pose_reward: The original pose reward (before weighting) + asset_cfg: Asset configuration (unused but kept for API consistency) + velocity_threshold: Linear velocity threshold to start reducing weight (m/s) + min_weight: Minimum weight multiplier (0-1) at high velocities + + Returns: + Weighted reward tensor of shape (num_envs,) + """ + asset: Entity = env.scene[asset_cfg.name] + + # Get base linear velocity magnitude + base_lin_vel = asset.data.root_link_vel_w[:, :3] # (num_envs, 3) + vel_magnitude = torch.norm(base_lin_vel[:, :2], dim=1) # Only XY plane + + # Compute weight: 1.0 when stationary, min_weight at high velocity + # Use smooth transition via sigmoid-like function + weight = min_weight + (1.0 - min_weight) * torch.exp( + -((vel_magnitude - velocity_threshold) / velocity_threshold).clamp(min=0.0) ** 2 + ) + + return base_pose_reward * weight + + +def randomize_base_orientation( + env: ManagerBasedRlEnv, + env_ids: torch.Tensor, + max_pitch_deg: float = 10.0, + max_roll_deg: float = 5.0, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +): + """Randomize base orientation at episode start to force reactive behavior. + + Adds random pitch and roll to the robot's base orientation at the start of + each episode. This prevents the policy from memorizing a single initial state + and forces it to use feedback to adapt to different orientations. + + Args: + env: The environment + env_ids: Environment IDs to randomize + max_pitch_deg: Maximum pitch angle in degrees (forward/backward tilt) + max_roll_deg: Maximum roll angle in degrees (side-to-side tilt) + asset_cfg: Asset configuration + """ + if env_ids is None: + env_ids = torch.arange(env.num_envs, device=env.device, dtype=torch.int) + else: + env_ids = env_ids.to(env.device, dtype=torch.int) + + asset: Entity = env.scene[asset_cfg.name] + num_envs = len(env_ids) + + # Generate random pitch and roll angles + max_pitch_rad = max_pitch_deg * torch.pi / 180.0 + max_roll_rad = max_roll_deg * torch.pi / 180.0 + + pitch = (torch.rand(num_envs, device=env.device) * 2 - 1) * max_pitch_rad + roll = (torch.rand(num_envs, device=env.device) * 2 - 1) * max_roll_rad + yaw = torch.zeros(num_envs, device=env.device) # Keep yaw at 0 + + # Convert Euler angles (roll, pitch, yaw) to quaternion + # Using the standard aerospace sequence (ZYX) + cy = torch.cos(yaw * 0.5) + sy = torch.sin(yaw * 0.5) + cp = torch.cos(pitch * 0.5) + sp = torch.sin(pitch * 0.5) + cr = torch.cos(roll * 0.5) + sr = torch.sin(roll * 0.5) + + quat_w = cr * cp * cy + sr * sp * sy + quat_x = sr * cp * cy - cr * sp * sy + quat_y = cr * sp * cy + sr * cp * sy + quat_z = cr * cp * sy - sr * sp * cy + + new_quat = torch.stack([quat_w, quat_x, quat_y, quat_z], dim=1) + + # Normalize quaternion + new_quat = new_quat / torch.norm(new_quat, dim=1, keepdim=True) + + # Get root position index (freejoint starts at qpos index 0) + # Freejoint: [x, y, z, qw, qx, qy, qz] + root_quat_idx = 3 # Quaternion starts at index 3 + + # Apply the randomized orientation to selected environments + env.sim.data.qpos[env_ids, root_quat_idx:root_quat_idx+4] = new_quat + + +def set_face_down_orientation( + env: ManagerBasedRlEnv, + env_ids: torch.Tensor, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +): + """Set the robot to a prone (belly-down) orientation for stand-up training. + + Rotates the robot 90° forward around the pitch axis (Y) so the front/belly + faces the ground and legs point upward. Combined with a random yaw. + + Quaternion derivation: + quat_pitch90 = [s, 0, s, 0] where s = sqrt(2)/2 (90° around Y) + quat_yaw = [cy, 0, 0, sy] + combined = quat_yaw * quat_pitch90 = [s*cy, -s*sy, s*cy, s*sy] + """ + if env_ids is None or len(env_ids) == 0: + return + env_ids = env_ids.to(env.device, dtype=torch.int) + num = len(env_ids) + + yaw = torch.rand(num, device=env.device) * 2 * np.pi - np.pi + cy = torch.cos(yaw * 0.5) + sy = torch.sin(yaw * 0.5) + s = 2.0 ** -0.5 # sqrt(2)/2 + + new_quat = torch.stack( + [ + s * cy, # w + -s * sy, # x + s * cy, # y + s * sy, # z + ], + dim=1, + ) + + # Freejoint qpos: [x, y, z, qw, qx, qy, qz, ...] + env.sim.data.qpos[env_ids, 3:7] = new_quat + env.sim.data.qvel[env_ids, :6] = 0.0 + + +def set_random_prone_orientation( + env: ManagerBasedRlEnv, + env_ids: torch.Tensor, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, + face_down_prob: float = 0.5, +): + """Randomly initialize each env as face-down (belly) or face-up (back), with random yaw. + + Face-down: +90° pitch → quat = [s*cy, -s*sy, s*cy, s*sy] + Face-up: -90° pitch → quat = [s*cy, s*sy, -s*cy, s*sy] + + Args: + face_down_prob: probability of sampling face-down (vs face-up). A curriculum + can ramp this from a high initial value (easier task) toward 0.5. + """ + if env_ids is None or len(env_ids) == 0: + return + env_ids = env_ids.to(env.device, dtype=torch.int) + num = len(env_ids) + + yaw = torch.rand(num, device=env.device) * 2 * np.pi - np.pi + cy = torch.cos(yaw * 0.5) + sy = torch.sin(yaw * 0.5) + s = 2.0 ** -0.5 # sqrt(2)/2 + + face_down = torch.stack([ s * cy, -s * sy, s * cy, s * sy], dim=1) + face_up = torch.stack([ s * cy, s * sy, -s * cy, s * sy], dim=1) + + mask = torch.rand(num, device=env.device) < face_down_prob # True → face-down + new_quat = torch.where(mask.unsqueeze(1), face_down, face_up) + + env.sim.data.qpos[env_ids, 3:7] = new_quat + env.sim.data.qvel[env_ids, :6] = 0.0 + + +def set_random_ground_state( + env: ManagerBasedRlEnv, + env_ids: torch.Tensor, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, + face_down_prob: float = 0.4, + face_up_prob: float = 0.4, + sitting_prob: float = 0.2, + standing_prob: float = 0.0, + prone_z_min: float = 0.20, + prone_z_max: float = 0.25, + sitting_z_min: float = 0.07, + sitting_z_max: float = 0.09, + standing_z_min: float = 0.11, + standing_z_max: float = 0.12, + sitting_joint_overrides: Optional[dict] = None, + sitting_joint_noise_std: float = 0.0, + sitting_tilt_max: float = 0.0, + face_up_roll_max: float = 0.0, +): + """Reset to a random ground state: face-down, face-up, sitting, or standing. + + Broader than ``set_random_prone_orientation`` — used by the stand-up env so + the policy learns to recover from any plausible pose, including the sitting + keyframe (rest state of the sit policy) and an already-standing pose (so it + also learns to *hold* a stand, not only to rise). + + Modes (probabilities are normalized; they need not sum to 1.0): + - face-down (belly to floor): +90° pitch, random yaw, z in [prone_z_min, prone_z_max]. + - face-up (back to floor): -90° pitch, random yaw, z in [prone_z_min, prone_z_max]. + - sitting: upright (±sitting_tilt_max), random yaw, z low, + joints set to ``sitting_joint_overrides``. + - standing: upright (±sitting_tilt_max), random yaw, z in + [standing_z_min, standing_z_max], joints left at + HOME (whatever ``reset_robot_joints`` set). + + Args: + sitting_joint_overrides: ``{qpos_joint_index: angle_rad}`` to write into + ``qpos[7+idx]`` for envs sampled into the sitting bucket. ``None`` + keeps joints at whatever ``reset_robot_joints`` already set. + """ + if env_ids is None or len(env_ids) == 0: + return + env_ids = env_ids.to(env.device, dtype=torch.int) + num = len(env_ids) + + total = face_down_prob + face_up_prob + sitting_prob + standing_prob + p_fd = face_down_prob / total + p_fu = (face_down_prob + face_up_prob) / total + p_sit = (face_down_prob + face_up_prob + sitting_prob) / total + + yaw = torch.rand(num, device=env.device) * 2 * np.pi - np.pi + cy = torch.cos(yaw * 0.5) + sy = torch.sin(yaw * 0.5) + s = 2.0 ** -0.5 # sqrt(2)/2 + + face_down = torch.stack([ s * cy, -s * sy, s * cy, s * sy], dim=1) + face_up = torch.stack([ s * cy, s * sy, -s * cy, s * sy], dim=1) + # Upright sitting: yaw-only by default, with optional ±sitting_tilt_max + # pitch/roll noise so the policy doesn't overfit to perfectly-upright starts. + if sitting_tilt_max > 0.0: + pitch = (torch.rand(num, device=env.device) * 2 - 1) * sitting_tilt_max + roll = (torch.rand(num, device=env.device) * 2 - 1) * sitting_tilt_max + cp = torch.cos(pitch * 0.5); sp = torch.sin(pitch * 0.5) + cr = torch.cos(roll * 0.5); sr = torch.sin(roll * 0.5) + # ZYX intrinsic Euler → quaternion (yaw * pitch * roll). + sit_w = cr * cp * cy + sr * sp * sy + sit_x = sr * cp * cy - cr * sp * sy + sit_y = cr * sp * cy + sr * cp * sy + sit_z = cr * cp * sy - sr * sp * cy + sitting = torch.stack([sit_w, sit_x, sit_y, sit_z], dim=1) + else: + sitting = torch.stack([cy, torch.zeros_like(cy), torch.zeros_like(cy), sy], dim=1) + + u = torch.rand(num, device=env.device) + is_fd = u < p_fd + is_fu = (u >= p_fd) & (u < p_fu) + is_sit = (u >= p_fu) & (u < p_sit) + is_stand = u >= p_sit + + # Face-up partial-roll noise: rotate the supine pose about the body's long + # axis by uniform ±face_up_roll_max. WHY (2026-07, back-recovery was + # seed-lucky): the reward landscape between supine and prone is FLAT — + # upright_linear (cos tilt) is ≈0 through the whole roll, height doesn't + # change — so rolling off the back only pays via the front-rise path that + # follows, a long-horizon dependency that noisy exploration rarely finds + # from a perfectly flat supine start. With roll noise, a fraction of + # face-up spawns start near-on-side (partway along the roll): the policy + # learns roll-completion from easy starts and generalizes back to flat + # supine — a built-in reverse curriculum. Uniform sampling keeps every + # difficulty represented (flat back |roll|<15° ≈ 17% at ±90°), so no + # annealing schedule is needed, and varied post-fall poses are realistic + # DR for deployment anyway. + if face_up_roll_max > 0.0: + theta = (torch.rand(num, device=env.device) * 2 - 1) * face_up_roll_max + ct = torch.cos(theta * 0.5) + st = torch.sin(theta * 0.5) + # Log-roll = rotation about the body's LONG axis, which is body z (the + # spine: trunk z is up when standing → horizontal when lying). NOT body + # x — supine leaves body x pointing skyward, so an x-roll would only + # spin the robot in place like the yaw noise already does. + # Body-frame rotation → right-multiply: q_fu ⊗ [ct, 0, 0, st]. + w, x, y, z = face_up[:, 0], face_up[:, 1], face_up[:, 2], face_up[:, 3] + face_up = torch.stack( + [ + w * ct - z * st, + x * ct + y * st, + y * ct - x * st, + w * st + z * ct, + ], + dim=1, + ) + + # Sitting and standing share the same upright orientation (identity + optional + # ±sitting_tilt_max); they differ only in trunk height and joint pose. + new_quat = face_down.clone() + new_quat[is_fu] = face_up[is_fu] + new_quat[is_sit] = sitting[is_sit] + new_quat[is_stand] = sitting[is_stand] + + # Random z per env: prone heights for face-down/up, low for sit, ~standing for stand. + z_prone = torch.rand(num, device=env.device) * (prone_z_max - prone_z_min) + prone_z_min + z_sit = torch.rand(num, device=env.device) * (sitting_z_max - sitting_z_min) + sitting_z_min + z_stand = torch.rand(num, device=env.device) * (standing_z_max - standing_z_min) + standing_z_min + new_z = z_prone.clone() + new_z = torch.where(is_sit, z_sit, new_z) + new_z = torch.where(is_stand, z_stand, new_z) + + env.sim.data.qpos[env_ids, 2] = new_z + env.sim.data.qpos[env_ids, 3:7] = new_quat + env.sim.data.qvel[env_ids, :6] = 0.0 + + # Sitting-bucket joint overrides (e.g. knee/ankle bent to keyframe). + # Override keys are SERVO indices (14-joint layout); translate to entity + # joint indices so models with interleaved passive_* joints (backlash) + # write the intended joints. qpos column = 7 + entity joint index + # (robot free joint first, all hinges 1-dof). + asset: Entity = env.scene[asset_cfg.name] + servo_ids = _servo_joint_ids(env, asset) + if sitting_joint_overrides: + sit_env_ids = env_ids[is_sit] + if len(sit_env_ids) > 0: + for jnt_idx, angle in sitting_joint_overrides.items(): + env.sim.data.qpos[sit_env_ids, 7 + servo_ids[jnt_idx]] = angle + + # Joint noise for sitting envs: Gaussian noise on every actuated joint + # so the policy sees a distribution of plausible "sit" starts rather than + # a single canonical pose. Captures real-world transfer where the robot's + # joint angles won't match the SIT keyframe exactly when the standup + # policy takes over from the sit policy. + if sitting_joint_noise_std > 0.0: + sit_env_ids = env_ids[is_sit] + if len(sit_env_ids) > 0: + # Servo joints only: passive_* joints (backlash hinges) have tiny + # ranges and must stay at 0 on reset. + n_sit = len(sit_env_ids) + cols = torch.tensor([7 + j for j in servo_ids], device=env.device, dtype=torch.long) + noise = torch.randn(n_sit, len(cols), device=env.device) * sitting_joint_noise_std + env.sim.data.qpos[sit_env_ids.unsqueeze(1).long(), cols.unsqueeze(0)] += noise + + +# Deep-crouch anchor pose (velstand run-5): the "stuck" mid-recovery basin — +# knees folded under the body, trunk pitched forward, feet flat. Values chosen +# by extending the HOME zig-zag (hip fwd / knee back / ankle fwd, sign +# conventions per the SIT keyframe fold directions) to deep flexion, inside +# the ±1.57 joint limits. hip_yaw/hip_roll/neck stay at HOME. +_CROUCH_ANCHOR_BY_NAME = { + "left_hip_pitch": -1.15, + "left_knee": 1.25, + "left_ankle": 1.05, + "right_hip_pitch": 1.15, + "right_knee": -1.25, + "right_ankle": -1.05, +} + + +def set_random_crouch_state( + env: ManagerBasedRlEnv, + env_ids: torch.Tensor, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, + depth_min: float = 0.35, + depth_max: float = 1.0, + pitch_max_deg: float = 55.0, + joint_noise: float = 0.12, + z_stand: float = 0.115, + z_deep: float = 0.06, +): + """Reset selected envs into a random mid-recovery crouch. + + Reverse curriculum for the recovery last mile (velstand run-5 lesson): + prone-init episodes spend most of their fallen budget getting TO the deep + crouch and are recycled shortly after reaching it, so the crouch→stand + mile gets almost no on-policy data — the policy converged to parking + there. Seeding resets ACROSS that mile (depth λ ∈ [depth_min, depth_max] + between standing and the deep-crouch anchor, trunk pitch and z scaled + with λ) makes the frontier dense from step 0 of the episode. + """ + if env_ids is None or len(env_ids) == 0: + return + env_ids = env_ids.to(env.device, dtype=torch.long) + num = len(env_ids) + asset: Entity = env.scene[asset_cfg.name] + + lam = torch.rand(num, device=env.device) * (depth_max - depth_min) + depth_min + + # Joints: lerp HOME → anchor on the leg pitch chain, uniform noise on the + # servo joints only (passive_* backlash hinges have ±1° ranges — noise + # there would spawn them pinned outside their limits). + joints = asset.data.default_joint_pos[env_ids].clone() + for name, anchor in _CROUCH_ANCHOR_BY_NAME.items(): + ids, _ = asset.find_joints(f"^{name}$") + j = ids[0] + joints[:, j] = joints[:, j] + lam * (anchor - joints[:, j]) + noise_mask = torch.zeros(joints.shape[1], device=joints.device) + noise_mask[_servo_joint_ids(env, asset)] = 1.0 + joints += (torch.rand_like(joints) * 2 - 1) * joint_noise * noise_mask + + # Base orientation: forward pitch scaled with depth (the stuck basin is a + # forward crouch from both fall directions), random yaw, small roll noise. + pitch = lam * math.radians(pitch_max_deg) \ + + (torch.rand(num, device=env.device) * 2 - 1) * math.radians(10.0) + pitch = torch.clamp(pitch, min=math.radians(5.0)) + roll = (torch.rand(num, device=env.device) * 2 - 1) * math.radians(8.0) + yaw = torch.rand(num, device=env.device) * 2 * np.pi - np.pi + cy = torch.cos(yaw * 0.5); sy = torch.sin(yaw * 0.5) + cp = torch.cos(pitch * 0.5); sp = torch.sin(pitch * 0.5) + cr = torch.cos(roll * 0.5); sr = torch.sin(roll * 0.5) + # ZYX intrinsic Euler → quaternion (yaw * pitch * roll), as in + # set_random_ground_state's sitting branch. + qw = cr * cp * cy + sr * sp * sy + qx = sr * cp * cy - cr * sp * sy + qy = cr * sp * cy + sr * cp * sy + qz = cr * cp * sy - sr * sp * cy + quat = torch.stack([qw, qx, qy, qz], dim=1) + + # Trunk height scaled with depth, small upward margin to settle cleanly. + z = z_stand + lam * (z_deep - z_stand) \ + + torch.rand(num, device=env.device) * 0.01 + + env.sim.data.qpos[env_ids, 2] = z + env.sim.data.qpos[env_ids, 3:7] = quat + env.sim.data.qpos[env_ids, 7:] = joints + env.sim.data.qvel[env_ids, :] = 0.0 + + +def maybe_set_random_prone_orientation( + env: ManagerBasedRlEnv, + env_ids: torch.Tensor, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, + prone_prob: float = 0.0, + face_down_prob: float = 0.5, + prone_z_min: float = 0.20, + prone_z_max: float = 0.25, + crouch_prob: float = 0.0, +): + """Reset event that overrides orientation to prone with probability `prone_prob`. + + With prob `prone_prob`, replaces the upright orientation (already set by + reset_base) with a prone orientation; otherwise leaves it upright. Among the + overridden envs, `face_down_prob` picks face-down (belly) vs face-up (back). + + Also lifts z to [prone_z_min, prone_z_max] for the overridden envs so the + head/neck clearance is sufficient — the vel-env reset z (~0.125) would + clip the head through the ground at 90° pitch. + + At prone_prob=2/3 and face_down_prob=0.5 you get a balanced 33/33/33 split + of upright/face-down/face-up resets, which is the standard mixture for + learning fall recovery alongside normal upright start. + + With ``crouch_prob`` > 0, an additional exclusive slice of envs is reset + into a random mid-recovery crouch via ``set_random_crouch_state`` (reverse + curriculum for the recovery last mile — see its docstring). + """ + if prone_prob <= 0.0 and crouch_prob <= 0.0: + return + # env_ids=None means "all envs" (the initial global reset passes None — + # the old early-return silently skipped prone init there). + if env_ids is None: + env_ids = torch.arange(env.num_envs, device=env.device) + if len(env_ids) == 0: + return + env_ids_t = env_ids.to(env.device, dtype=torch.long) if isinstance(env_ids, torch.Tensor) else torch.tensor(env_ids, device=env.device, dtype=torch.long) + # One draw partitions envs into exclusive prone / crouch / untouched slices. + u = torch.rand(len(env_ids_t), device=env.device) + selected = env_ids_t[u < prone_prob] + crouch_selected = env_ids_t[(u >= prone_prob) & (u < prone_prob + crouch_prob)] + if len(selected) > 0: + set_random_prone_orientation( + env, selected, asset_cfg=asset_cfg, face_down_prob=face_down_prob + ) + # Override z so the prone body has head/neck clearance when settling. + z = torch.rand(len(selected), device=env.device) * (prone_z_max - prone_z_min) + prone_z_min + env.sim.data.qpos[selected, 2] = z + if len(crouch_selected) > 0: + set_random_crouch_state(env, crouch_selected, asset_cfg=asset_cfg) + + +def event_param_curriculum( + env: ManagerBasedRlEnv, + env_ids: torch.Tensor, + event_name: str, + param_stages: list[dict], +) -> torch.Tensor: + """Mutate an event term's params at scheduled steps. + + Mirror of termination_param_curriculum but for events. Uses the live + EventManager term cfg via get_term_cfg, since env.cfg.events is a deepcopy. + param_stages: list of {step: int, params: dict}. Shallow-merged into the + live event term's params at the latest matching stage. + """ + del env_ids + event_cfg = env.event_manager.get_term_cfg(event_name) + current = param_stages[0]["params"] + for stage in param_stages: + if env.common_step_counter >= stage["step"]: + current = stage["params"] + event_cfg.params.update(current) + first_val = next(iter(current.values())) + return torch.tensor(float(first_val) if isinstance(first_val, (int, float)) else 0.0) + + +def face_down_prob_curriculum( + env: ManagerBasedRlEnv, + env_ids: torch.Tensor, + event_name: str, + prob_stages: list[dict], +) -> torch.Tensor: + """Ramp face_down_prob on a reset event over training. + + Args: + event_name: name of the event term using set_random_prone_orientation + prob_stages: list of {step: int, prob: float}. Higher prob = more + face-down resets (easier task); ramp toward 0.5 as training proceeds. + """ + del env_ids + + # NOTE: must update the live EventManager term_cfg, not env.cfg.events — + # EventManager.__init__ does deepcopy(cfg), so mutating env.cfg.events is a no-op. + event_cfg = env.event_manager.get_term_cfg(event_name) + + current_prob = prob_stages[0]["prob"] + for stage in prob_stages: + if env.common_step_counter > stage["step"]: + current_prob = stage["prob"] + + event_cfg.params["face_down_prob"] = current_prob + return torch.tensor([current_prob]) + + +class VelocityCommandCommandOnly(UniformVelocityCommand): + """Like UniformVelocityCommand but only draws the command arrows (no actual velocity arrows).""" + + def _resample_command(self, env_ids: torch.Tensor) -> None: + super()._resample_command(env_ids) + # Turn-in-place practice: for a fraction of envs, zero the linear velocity + # and force a meaningful (away-from-zero) yaw command. Independent uniform + # sampling almost never produces "lin≈0, |ang| large" (~2% of samples), so + # spinning on the spot was effectively untrained → slow/unstable real-robot + # turning. Mirrors the base rel_forward_envs mechanism. + p = getattr(self.cfg, "rel_turn_in_place_envs", 0.0) + if p <= 0.0: + return + r = torch.empty(len(env_ids), device=self.device) + turn_ids = env_ids[r.uniform_(0.0, 1.0) < p] + if len(turn_ids) == 0: + return + self.vel_command_b[turn_ids, 0] = 0.0 + self.vel_command_b[turn_ids, 1] = 0.0 + lo, hi = self.cfg.ranges.ang_vel_z + maxr = max(abs(lo), abs(hi)) + rr = torch.empty(len(turn_ids), device=self.device) + sign = torch.where(rr.uniform_(0.0, 1.0) < 0.5, -1.0, 1.0) + mag = torch.empty(len(turn_ids), device=self.device).uniform_(0.4 * maxr, maxr) + self.vel_command_b[turn_ids, 2] = sign * mag + # These envs must actually turn — un-mark them as standing (which would + # zero the command) and refresh the world-frame reference copy. + self.is_standing_env[turn_ids] = False + self.vel_command_w[turn_ids] = self.vel_command_b[turn_ids] + + def _debug_vis_impl(self, visualizer: "DebugVisualizer") -> None: + batch = visualizer.env_idx + if batch >= self.num_envs: + return + + cmds = self.command.cpu().numpy() + base_pos_ws = self.robot.data.root_link_pos_w.cpu().numpy() + base_quat_w = self.robot.data.root_link_quat_w + base_mat_ws = matrix_from_quat(base_quat_w).cpu().numpy() + + base_pos_w = base_pos_ws[batch] + base_mat_w = base_mat_ws[batch] + cmd = cmds[batch] + + if np.linalg.norm(base_pos_w) < 1e-6: + return + + def local_to_world(vec: np.ndarray) -> np.ndarray: + return base_pos_w + base_mat_w @ vec + + scale = self.cfg.viz.scale * 2.0 + z_offset = self.cfg.viz.z_offset + + # Command linear velocity arrow (blue). + cmd_lin_from = local_to_world(np.array([0, 0, z_offset]) * scale) + cmd_lin_to = local_to_world( + (np.array([0, 0, z_offset]) + np.array([cmd[0], cmd[1], 0])) * scale + ) + visualizer.add_arrow(cmd_lin_from, cmd_lin_to, color=(0.2, 0.2, 0.6, 0.6), width=0.015) + + +@_dataclass(kw_only=True) +class VelocityCommandCommandOnlyCfg(UniformVelocityCommandCfg): + # Fraction of envs commanded to turn in place (lin=0, |ang| forced to + # [0.4·max, max]) each resample. 0 = disabled (base uniform sampling only). + rel_turn_in_place_envs: float = 0.0 + + def build(self, env: ManagerBasedRlEnv) -> "VelocityCommandCommandOnly": + return VelocityCommandCommandOnly(self, env) + + +class RelativeHeadingVelocityCommand(VelocityCommandCommandOnly): + """Velocity command where cmd[2] is the heading error in the robot's body frame. + + cmd[0] = lin_vel_x (throttle: 0=coast, +push, -brake) + cmd[1] = lin_vel_y (unused, 0) + cmd[2] = heading_error (+ = target is to the right/CW, - = to the left/CCW) + 0 → go straight, ±max = target is max_angle rad to the right/left + + During training: a random world-frame heading is sampled at each episode reset. + At every step, cmd[2] = clamp(wrap(current_yaw - target_yaw), ±max_angle). + Positive when the robot is pointing CCW (left) of the target → needs to turn right. + + At inference: the user feeds cmd[2] directly. Holding cmd[2] = constant gives + a proportional heading correction = approximately constant turn rate. + + Set heading_command=False and rel_heading_envs=0.0 in the cfg (we handle + heading internally). ang_vel_z range in cfg is used as the clip limit for cmd[2]. + """ + + def __init__(self, cfg, env: ManagerBasedRlEnv): + super().__init__(cfg, env) + # Sampled target heading per env, world frame (rad) + self._target_heading_w = torch.zeros(self.num_envs, device=self.device) + # Clip limit for cmd[2]: use ang_vel_z[1] from cfg (the positive bound) + ang_rng = cfg.ranges.ang_vel_z + self._heading_max = float(ang_rng[1]) if ang_rng else 1.0 + + def _resample_command(self, env_ids: torch.Tensor) -> None: + super()._resample_command(env_ids) + n = len(env_ids) + # Sample random world-frame target heading uniformly in [-π, π] + self._target_heading_w[env_ids] = ( + torch.rand(n, device=self.device) * 2.0 * math.pi - math.pi + ) + # Zero ang_vel slot; _update_command will fill it each step + self.vel_command_b[env_ids, 2] = 0.0 + + def _update_command(self) -> None: + # Do NOT call super()._update_command() — it would run the heading + # proportional controller and overwrite cmd[2] with a yaw rate. + # Instead recompute heading error from scratch each step. + quat = self.robot.data.root_link_quat_w # (N, 4) [w, x, y, z] + w, x, y, z = quat[:, 0], quat[:, 1], quat[:, 2], quat[:, 3] + current_yaw = torch.atan2(2.0 * (w * z + x * y), 1.0 - 2.0 * (y * y + z * z)) + # Positive = target is CCW (left) of robot → turn left. Standard convention. + delta = self._target_heading_w - current_yaw + heading_error = torch.atan2(torch.sin(delta), torch.cos(delta)) + self.vel_command_b[:, 2] = heading_error.clamp(-self._heading_max, self._heading_max) + + def _update_metrics(self) -> None: + pass # No velocity tracking metrics for heading command + + +class RelativeHeadingVelocityCommandCfg(UniformVelocityCommandCfg): + def build(self, env: ManagerBasedRlEnv) -> "RelativeHeadingVelocityCommand": + return RelativeHeadingVelocityCommand(self, env) + + +def heading_tracking_reward( + env: ManagerBasedRlEnv, + command_name: str, + std: float = 0.5, +) -> torch.Tensor: + """Reward for reducing heading error when cmd[2] encodes heading error. + + Returns exp(-cmd[2]² / std²). + - At error = 0 (on heading): reward = 1.0. + - At error = std: reward ≈ 0.37 (strong gradient). + - At error = 1.0 rad with std=0.5: reward ≈ 0.018 (nearly zero). + + std=0.5 rad (≈28°) gives a meaningful gradient across the expected range. + """ + cmd = env.command_manager.get_command(command_name) + heading_error = cmd[:, 2] + return torch.exp(-(heading_error ** 2) / (std ** 2)) + + +def skating_air_time_reward( + env: ManagerBasedRlEnv, + sensor_name: str, + command_name: str, + threshold_min: float = 0.05, + threshold_max: float = 0.4, + vel_gate_ref: float = 0.0, +) -> torch.Tensor: + """Reward feet air time only when pushing (cmd_x > 0). + + Encourages the robot to lift each foot during the recovery phase of the + skating stroke rather than dragging it on the ground. + Scaled by cmd_x so the incentive grows with push intensity. + + When ``vel_gate_ref`` > 0 the reward is also multiplied by a forward-speed + gate so lifting feet without propelling the body (tap-dancing on the spot) + earns nothing. ``threshold_min`` sets the shortest swing that counts — raise + it to forbid a frantic high-cadence flutter. + """ + from mjlab.sensor import ContactSensor + sensor: ContactSensor = env.scene[sensor_name] + current_air_time = sensor.data.current_air_time + assert current_air_time is not None + + in_range = (current_air_time > threshold_min) & (current_air_time < threshold_max) + reward = torch.sum(in_range.float(), dim=1) + + cmd_x = env.command_manager.get_command(command_name)[:, 0] + reward = reward * torch.clamp(cmd_x, min=0.0) + gate = _forward_progress_gate(env, vel_gate_ref) + if gate is not None: + reward = reward * gate + return reward + + +def _forward_progress_gate(env: ManagerBasedRlEnv, v_ref: float) -> torch.Tensor | None: + """0→1 ramp in body forward speed: 0 when standing still, 1 at/above v_ref. + + Used to gate stride-shaping rewards so that stepping which does NOT propel + the body (e.g. tap-dancing on the spot) earns nothing — the reward for the + FORM of a stride is only paid when the stride actually does its JOB (moving + forward). Returns None when disabled (v_ref <= 0).""" + if v_ref <= 0.0: + return None + v_fwd = env.scene["robot"].data.root_link_lin_vel_b[:, 0] + return (v_fwd.clamp(min=0.0) / v_ref).clamp(max=1.0) + + +def single_support_reward( + env: ManagerBasedRlEnv, + sensor_name: str, + command_name: str, + vel_gate_ref: float = 0.0, + double_penalty: float = 0.25, +) -> torch.Tensor: + """Reward single-support (a skating stride), mildly discourage the swizzle. + + Real skating is a STRIDE: push off one blade while the other swings, i.e. + single support that alternates left/right. A symmetric swizzle keeps BOTH + blades grounded the whole time and still spins the wheels, so wheel_speed + alone converges to it. + + Per step, counting blades in contact: + - exactly 1 blade down (stride) → + clamp(cmd_x,0) · gate + - 2 blades down (double supp) → − double_penalty · clamp(cmd_x,0) + - 0 blades down (flight/hop) → 0 + + The POSITIVE single-support reward is gated by forward speed (``vel_gate_ref``) + so stepping in place (no propulsion) earns nothing — kills the tap-dance hack. + The double-support penalty is small and UNGATED: brief double support during + weight transfer / push-off is NORMAL skating, so we only lightly discourage + PERMANENT double support (the swizzle) rather than forbid it. The real + anti-swizzle signal is skating_air_time — the swizzle never lifts a foot. + """ + from mjlab.sensor import ContactSensor + sensor: ContactSensor = env.scene[sensor_name] + contact_time = sensor.data.current_contact_time # (num_envs, num_feet) + assert contact_time is not None + + n_contact = torch.sum((contact_time > 0.0).float(), dim=1) # (num_envs,) + single = (n_contact == 1).float() + double = (n_contact >= 2).float() + + cmd_x = torch.clamp(env.command_manager.get_command(command_name)[:, 0], min=0.0) + single_r = single * cmd_x + gate = _forward_progress_gate(env, vel_gate_ref) + if gate is not None: + single_r = single_r * gate + return single_r - double_penalty * double * cmd_x + + +def glide_reward( + env: ManagerBasedRlEnv, + sensor_name: str, + command_name: str, + vel_ref: float = 0.2, + stillness_std: float = 5.0, + asset_cfg: SceneEntityCfg = SceneEntityCfg( + "robot", joint_names=(r".*(hip|knee|ankle).*",) + ), +) -> torch.Tensor: + """Reward the GLIDE phase of a stride: coast on ONE blade with quiet legs. + + Nothing else rewards gliding — skating_air_time pays each swing, so the policy + maximises swing FREQUENCY (frantic kicking). This term pays staying on one + foot and coasting, giving the policy a reason to slow down and commit to each + stroke: + + reward = single_support · forward_gate · stillness · (cmd_x >= 0) + + - single_support: exactly ONE blade in contact. REQUIRED — this is the fix vs + the earlier broken glide, which omitted it and let a two-blade swizzle-coast + farm the reward and regress the gait. + - forward_gate = clamp(v_fwd,0,vel_ref)/vel_ref → 0 when not moving forward. + - stillness = exp(-Σ leg_joint_vel² / stillness_std²) → high only when legs + are quiet; a kick (fast joint motion) gets ~0, so only a real glide pays. + - active on push/coast only (cmd_x >= 0); silent on brake. + """ + from mjlab.sensor import ContactSensor + sensor: ContactSensor = env.scene[sensor_name] + contact_time = sensor.data.current_contact_time # (num_envs, num_feet) + assert contact_time is not None + single = (torch.sum((contact_time > 0.0).float(), dim=1) == 1).float() + + forward_gate = _forward_progress_gate(env, vel_ref) + if forward_gate is None: + forward_gate = torch.ones(env.num_envs, device=env.device) + + asset: Entity = env.scene[asset_cfg.name] + joint_vel_sq = torch.sum( + torch.square(asset.data.joint_vel[:, asset_cfg.joint_ids]), dim=1 + ) + stillness = torch.exp(-joint_vel_sq / stillness_std ** 2) + + cmd_x = env.command_manager.get_command(command_name)[:, 0] + active = (cmd_x >= 0.0).float() + return single * forward_gate * stillness * active + + +def leg_symmetry_reward( + env: ManagerBasedRlEnv, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, + joint_bases: tuple = ("hip_yaw", "hip_roll", "hip_pitch", "knee", "ankle"), +) -> torch.Tensor: + """Reward left/right legs mirroring — the swizzle's defining symmetry. + + The robot uses mirrored L/R sign conventions, so a bilaterally-symmetric config + satisfies q_left + q_right ≈ 0 per matched joint pair. Returns + ``-mean_pairs |q_left + q_right|`` (L1, constant gradient); use with a POSITIVE + weight so asymmetry is penalised and the symmetric swizzle is favoured. L/R index + pairs are resolved once by name and cached on env. + """ + asset: Entity = env.scene[asset_cfg.name] + if not hasattr(env, "_leg_sym_ids"): + left, right = [], [] + for base in joint_bases: + li, _ = asset.find_joints([f"left_{base}"]) + ri, _ = asset.find_joints([f"right_{base}"]) + left.append(li[0]) + right.append(ri[0]) + env._leg_sym_ids = ( + torch.tensor(left, device=env.device), + torch.tensor(right, device=env.device), + ) + lids, rids = env._leg_sym_ids + q = asset.data.joint_pos + return -torch.abs(q[:, lids] + q[:, rids]).mean(dim=-1) + + +def grounded_reward( + env: ManagerBasedRlEnv, + sensor_name: str, + command_name: str, +) -> torch.Tensor: + """Reward BOTH blades in contact — a classic swizzle stays grounded (no lifting). + + Mirror of single_support_reward but rewarding double support (n_contact >= 2), + scaled by |cmd_x| so it shapes the push phase in EITHER direction (forward or + backward — the swizzle env drives cmd_x < 0 as "go backward"). + """ + from mjlab.sensor import ContactSensor + sensor: ContactSensor = env.scene[sensor_name] + contact_time = sensor.data.current_contact_time # (num_envs, num_feet) + assert contact_time is not None + n_contact = torch.sum((contact_time > 0.0).float(), dim=1) + grounded = (n_contact >= 2).float() + cmd_x = torch.abs(env.command_manager.get_command(command_name)[:, 0]) + return grounded * cmd_x + + +def gait_symmetry_penalty( + env: ManagerBasedRlEnv, + sensor_name: str, +) -> torch.Tensor: + """Penalize lopsided left/right foot usage (one blade doing most of the work). + + With symmetry augmentation OFF, nothing stops the policy learning an asymmetric + stride that pushes mostly with one leg — which veers and destabilises (esp. at + launch). Accumulates per-foot swing time over the episode and penalises the + normalised imbalance |L - R| / (L + R): + - balanced alternating stride -> ~0 (no penalty) + - one foot swinging much more -> ~1 (max penalty) + Only the CUMULATIVE imbalance is penalised — the instantaneous single-support + asymmetry of a real stride (one foot swinging now) is fine. + """ + from mjlab.sensor import ContactSensor + sensor: ContactSensor = env.scene[sensor_name] + air = sensor.data.current_air_time # (N, num_feet) + assert air is not None + + if not hasattr(env, "_swing_accum") or env._swing_accum.shape[0] != env.num_envs: + env._swing_accum = torch.zeros(env.num_envs, air.shape[1], device=env.device) + reset = env.episode_length_buf <= 1 + env._swing_accum[reset] = 0.0 + env._swing_accum += (air > 0.0).float() * env.step_dt + + L = env._swing_accum[:, 0] + R = env._swing_accum[:, 1] + return torch.abs(L - R) / (L + R + 1e-3) + + +def heading_hold_reward( + env: ManagerBasedRlEnv, + std: float = 0.4, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """Reward holding the SPAWN heading (go straight) — corrective, angle-based. + + Rewards the yaw ANGLE staying near the heading captured at reset: + reward = exp(-wrap(yaw - yaw_spawn)² / std²) + + This is the RIGHT way to go straight (vs penalising yaw-RATE, which just tells + the policy 'never turn' → it can't steer back and drifts open-loop). Here a + drift lowers the reward, and the policy is free to yaw back to recover it. + + The spawn heading is captured per-env on the first step(s) after reset + (episode_length_buf <= 1), when the robot is still ~at its spawn pose. Reads + root_link_quat_w, which is fresh at reward time (post physics step). Heading- + invariant: the reference is each env's own random spawn yaw, so it works with + the full-circle yaw randomisation at reset. + """ + asset: Entity = env.scene[asset_cfg.name] + quat = asset.data.root_link_quat_w # (N, 4) [w, x, y, z] + w, x, y, z = quat[:, 0], quat[:, 1], quat[:, 2], quat[:, 3] + yaw = torch.atan2(2.0 * (w * z + x * y), 1.0 - 2.0 * (y * y + z * z)) + + if not hasattr(env, "_heading_ref") or env._heading_ref.shape[0] != env.num_envs: + env._heading_ref = yaw.clone() + just_reset = env.episode_length_buf <= 1 + env._heading_ref = torch.where(just_reset, yaw, env._heading_ref) + + err = yaw - env._heading_ref + err = torch.atan2(torch.sin(err), torch.cos(err)) # wrap to [-π, π] + return torch.exp(-(err ** 2) / std ** 2) + + +def action_over_limit_penalty( + env: ManagerBasedRlEnv, + action_name: str = "joint_pos", + overshoot: float = 0.3, +) -> torch.Tensor: + """Penalise commanding a joint target beyond its hard limit (+ overshoot). + + Policy-side deterrent against over-driving a joint onto its mechanical stop: + e.g. hip_roll has a ±0.38 rad limit but a ±10 rad ctrlrange, so the low-kp + servo can be commanded far past the stop to slam it with max torque — a + fragile sim-only trick that will not transfer. + + Reads the commanded target (raw_action · scale + offset) and penalises only + the part BEYOND (hard_limit + overshoot): + + penalty = Σ relu(target - (hi + overshoot)) + relu((lo - overshoot) - target) + + Unlike a qpos-limit penalty, this fires on the COMMAND, not the joint + position — so the joint may still reach its full range (command ≈ limit) and + no usable amplitude is stolen. Because it constrains the policy's OUTPUT, the + learned behaviour is baked into the network and transfers to deployment + WITHOUT any env-side action clip (which would only exist in sim → mismatch). + ``overshoot`` gives the low-kp servo the headroom to reach near-limit targets + under load; only the wild over-drive past that is penalised. + """ + term = env.action_manager.get_term(action_name) + target = term.raw_action * term.scale + term.offset # (B, action_dim) abs targets + jnt_ids = term.target_ids + hard = env.scene["robot"].data.joint_pos_limits[:, jnt_ids] # (B, action_dim, 2) + lo = hard[..., 0] - overshoot + hi = hard[..., 1] + overshoot + over = (target - hi).clip(min=0.0) + (lo - target).clip(min=0.0) + return torch.sum(over, dim=-1) + + +def forward_lean_reward( + env: ManagerBasedRlEnv, + command_name: str, + target_pitch: float = 0.08, + std: float = 0.08, + asset_cfg: SceneEntityCfg = SceneEntityCfg("robot", body_names=("trunk_base",)), +) -> torch.Tensor: + """Reward leaning slightly forward when pushing, to counteract the backward + torque from skating strokes. + + Uses projected_gravity_b x-component as a pitch proxy: + forward_lean = -gravity_b[:, 0] (positive when leaning forward) + + Only fires when cmd_x > 0. Peaks at target_pitch radians of forward lean. + """ + asset: Entity = env.scene[asset_cfg.name] + cmd_x = env.command_manager.get_command(command_name)[:, 0] + forward_lean = asset.data.projected_gravity_b[:, 0] + push = torch.clamp(cmd_x, min=0.0) + return push * torch.exp(-((forward_lean - target_pitch) ** 2) / (std ** 2)) + + +class GroundPickPhaseCommand(UniformVelocityCommand): + """Phase-encoding command for the ground pick / sit-stand tasks. + + Replaces the velocity command with a cyclic phase signal: + command = [cos(2π*phase), sin(2π*phase), 0] + + Phase ∈ [0, 0.5]: approach (go down). + Phase ∈ [0.5, 1.0]: return (come back up). + + Phase is randomized per environment on episode reset to decorrelate envs. + Period defaults to 4s; override via the cfg.period field (sitstand uses 8s + for a slower, gentler sit-down). + """ + + PERIOD: float = 4.0 # default; cfg.period overrides + + def __init__(self, cfg, env: ManagerBasedRlEnv): + super().__init__(cfg, env) + self._gp_phase = torch.zeros(self.num_envs, device=self.device) + self._period = float(getattr(cfg, "period", self.PERIOD)) + # When False, each episode starts at phase 0 (standing) instead of a + # random phase. Matches the runtime, where the button starts the cycle + # at phase 0 from standing. Default True keeps the historical ground_pick + # behavior (random phase to decorrelate envs). + self._randomize_phase = bool(getattr(cfg, "randomize_phase", True)) + + @property + def command(self) -> torch.Tensor: + return self.vel_command_b + + def compute(self, dt: float) -> None: + self._gp_phase = (self._gp_phase + dt / self._period) % 1.0 + self.vel_command_b[:, 0] = torch.cos(2 * torch.pi * self._gp_phase) + self.vel_command_b[:, 1] = torch.sin(2 * torch.pi * self._gp_phase) + self.vel_command_b[:, 2] = 0.0 + + def reset(self, env_ids: torch.Tensor | None) -> dict: + if env_ids is not None and len(env_ids) > 0: + if self._randomize_phase: + self._gp_phase[env_ids] = torch.rand(len(env_ids), device=self.device) + else: + self._gp_phase[env_ids] = 0.0 + return {} + + def _resample_command(self, env_ids: torch.Tensor) -> None: + pass # Phase is continuous; no resampling needed + + def _update_command(self) -> None: + pass # Updated in compute() + + def _update_metrics(self) -> None: + pass # No velocity tracking metrics for ground pick + + +from dataclasses import dataclass as _dataclass + +@_dataclass(kw_only=True) +class GroundPickPhaseCommandCfg(UniformVelocityCommandCfg): + class_type: type = GroundPickPhaseCommand + period: float = 4.0 # cycle length in seconds; sitstand uses 8.0 + randomize_phase: bool = True # False -> each episode starts at phase 0 (standing) + + def build(self, env: ManagerBasedRlEnv) -> "GroundPickPhaseCommand": + return GroundPickPhaseCommand(self, env) + + +# --------------------------------------------------------------------------- # +# Unified pose command machinery # +# --------------------------------------------------------------------------- # +# +# Background: we deprecated the old NeckOffsetJointPositionAction + +# disturbance-randomization approach (where head/body movement was an external +# perturbation the policy was supposed to be robust to). That trained a weak, +# indirect signal — see `project_neck_offset_decoupling.md` for the +# post-mortem. +# +# Replacement: head and body pose are now *commands* — direct, dense policy +# inputs with tracking rewards. At deployment, the runtime feeds those slots +# with whatever pose the user requests; at training, they're sampled uniformly +# from per-dim ranges (kept non-zero from step 0 so input neurons stay alive) +# and ramped via curriculum. +# +# Layout, unified across all microduck policies for runtime obs compatibility: +# command vector (13D) = [vx, vy, vtheta, ← "twist" (velocity) +# neck_pitch, head_pitch, ← "head_pose" (deltas) +# head_yaw, head_roll, +# body_x, body_y, body_z, ← "body_pose" (deltas) +# body_roll, body_pitch, body_yaw] +# Total policy obs becomes 61D (51 - 3 + 13). +# --------------------------------------------------------------------------- # + + +from dataclasses import dataclass, field + + +class UniformPoseCommand(CommandTerm): + """Generic N-dim uniform pose command. + + Samples each dim independently uniform in cfg.ranges[i] = (lo, hi) and holds + the value between resamples. No metrics, no debug viz — keep it lightweight + since we have many of these. + """ + + cfg: "UniformPoseCommandCfg" + + def __init__(self, cfg: "UniformPoseCommandCfg", env: ManagerBasedRlEnv): + super().__init__(cfg, env) + self.dim = len(cfg.ranges) + self._command = torch.zeros(self.num_envs, self.dim, device=self.device) + + @property + def command(self) -> torch.Tensor: + return self._command + + def _update_metrics(self) -> None: + pass + + def _update_command(self) -> None: + pass + + def _resample_command(self, env_ids: torch.Tensor) -> None: + n = len(env_ids) + if n == 0: + return + r = torch.empty(n, device=self.device) + for i, (lo, hi) in enumerate(self.cfg.ranges): + self._command[env_ids, i] = r.uniform_(lo, hi) + # Explicit zero-command bucket. Uniform sampling essentially never + # produces the all-zero command, so the deployment idle case ("hold the + # nominal pose") would otherwise be absent from training (velocity + # body-control run-1 lesson: the policy only stood still when a command + # was present). + if self.cfg.zero_command_prob > 0.0: + zero_mask = torch.rand(n, device=self.device) < self.cfg.zero_command_prob + self._command[env_ids[zero_mask]] = 0.0 + + +@dataclass(kw_only=True) +class UniformPoseCommandCfg(CommandTermCfg): + """Per-dim uniform ranges; builds a UniformPoseCommand.""" + # Tuple of (lo, hi) per dim. Length defines the command dim. + ranges: tuple[tuple[float, float], ...] = () + # Probability that a resample yields the exact all-zero command. + zero_command_prob: float = 0.0 + + def build(self, env: ManagerBasedRlEnv) -> "UniformPoseCommand": + return UniformPoseCommand(self, env) + + +def zero_command_padding( + env: ManagerBasedRlEnv, + dim: int, +) -> torch.Tensor: + """Constant-zero obs term of width `dim`. + + Used by envs that don't actively track head/body commands (e.g. sitstand, + ground_pick) but still need the unified 61D obs shape so the runtime can + feed all policies with the same buffer layout. + """ + return torch.zeros(env.num_envs, dim, device=env.device) + + +def head_pose_tracking( + env: ManagerBasedRlEnv, + command_name: str = "head_pose", + std: float = 0.5, + fine_std: float | None = None, + fine_weight: float = 0.5, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """Per-joint Gaussian reward for matching commanded neck/head deltas. + + Mean over the 4 neck/head joints of exp(-(err/std)^2). Result is (N,) in + [0, 1]. Mean form (vs sum-of-squares) keeps gradient alive when only one + joint is off — vs SOS where a single big error kills the whole reward. + + `std` is the per-joint tolerance: at err=std the per-joint reward is 1/e + (~0.37). Pick std on the order of the command range so the gradient + doesn't die as the curriculum widens. + + `fine_std` (optional) blends in a second, narrow Gaussian: + (1-fine_weight)·exp(-(err/std)²) + fine_weight·exp(-(err/fine_std)²). + Rationale: a single wide std (0.5 rad ≈ 29°) makes small errors nearly + free — a 10° gravity sag on the heavy head costs ~0.03 reward, so the + policy lets it droop. The narrow component (~0.1 rad) prices those small + errors while the wide one keeps gradient alive at far commands during + curriculum widening. + + cmd has shape (N, 4) = deltas from default joint positions in the order + [neck_pitch, head_pitch, head_yaw, head_roll]. + + On backlash models the measured angle is qpos[servo] + qpos[backlash] — + the OUTPUT link, which is also what the encoder obs + (joint_pos_rel_backlash) reports. Measuring the servo alone would let the + head droop the backlash play reward-free AND penalize the policy for + compensating it (servo biased up = servo-side "error"). On models without + passive_*_backlash joints the mask is 0 and this reduces to the servo. + """ + asset: Entity = env.scene[asset_cfg.name] + cmd = env.command_manager.get_command(command_name) # (N, 4) + + if not hasattr(env, "_head_pose_neck_ids"): + ids, names = asset.find_joints_by_actuator_names(_NECK_JOINT_PATTERNS) + env._head_pose_neck_ids = torch.tensor(ids, device=env.device, dtype=torch.long) + name_to_id = {n: i for i, n in enumerate(asset.joint_names)} + bl = [name_to_id.get(f"passive_{n}_backlash") for n in names] + env._head_pose_bl_ids = torch.tensor( + [0 if b is None else b for b in bl], device=env.device, dtype=torch.long + ) + env._head_pose_bl_mask = torch.tensor( + [0.0 if b is None else 1.0 for b in bl], device=env.device + ) + + neck_ids = env._head_pose_neck_ids + joint_pos = asset.data.joint_pos + measured = ( + joint_pos[:, neck_ids] + + joint_pos[:, env._head_pose_bl_ids] * env._head_pose_bl_mask + ) + actual = measured - asset.data.default_joint_pos[:, neck_ids] + err = actual - cmd + per_joint = torch.exp(-(err / std) ** 2) + if fine_std is not None: + per_joint = (1.0 - fine_weight) * per_joint + fine_weight * torch.exp( + -(err / fine_std) ** 2 + ) + return per_joint.mean(dim=-1) + + +# ───────────────────────────────────────────────────────────────────────────── +# NaN-safe wrappers for the sensor-derived critic observations. +# +# `robot_state_is_nan` covers joint + root state, so every obs derived from +# those is protected by the reset it triggers. The three terms below are NOT: +# they read sensor data (raycast heights, contact air-time, contact forces), +# which MuJoCo can return non-finite for while the integrated robot state is +# still clean. They are critic-only, so a single sanitized step costs the +# policy nothing, whereas letting the value through kills the entire run via +# rsl_rl's check_nan. Sanitizing here does not hide real physics blowups — +# those still terminate through nan_state and show up as +# Episode_Termination/nan_state in wandb. +# ───────────────────────────────────────────────────────────────────────────── + + +def _finite(x: torch.Tensor) -> torch.Tensor: + return torch.nan_to_num(x, nan=0.0, posinf=0.0, neginf=0.0) + + +def foot_contact_forces_safe(env: ManagerBasedRlEnv, sensor_name: str) -> torch.Tensor: + """NaN-safe `foot_contact_forces` (see note above).""" + return _finite(_velocity_obs.foot_contact_forces(env, sensor_name)) + + +def foot_height_safe(env: ManagerBasedRlEnv, sensor_name: str) -> torch.Tensor: + """NaN-safe `foot_height` (see note above).""" + return _finite(_velocity_obs.foot_height(env, sensor_name)) + + +def foot_air_time_safe(env: ManagerBasedRlEnv, sensor_name: str) -> torch.Tensor: + """NaN-safe `foot_air_time` (see note above).""" + return _finite(_velocity_obs.foot_air_time(env, sensor_name)) + + +def head_pose_bias_penalty( + env: ManagerBasedRlEnv, + command_name: str = "head_pose", + tau_s: float = 1.0, + gate_height_low: float | None = None, + gate_height_high: float = 0.11, + gate_tilt_full_deg: float = 20.0, + gate_tilt_zero_deg: float = 45.0, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """Penalize the time-averaged (DC) neck/head tracking error: -mean(|EMA(err)|). + + Companion to ``head_pose_tracking``, which scores the INSTANTANEOUS error. + Why a separate DC term instead of just tightening that Gaussian's std: + walking unavoidably shakes a head that is 38% of the robot's mass, so an + instantaneous tight-tolerance term is a permanent tax on walking that no + policy can escape — measured at ~0.77/step against an air_time reward of + ~1.01/step, which is exactly what made velocity run 2026-08-20 abandon + stepping altogether (wandb 5yay13u4). The steady-state droop IS escapable: + the policy can bias its neck command up to cancel gravity sag. Averaging + over ``tau_s`` lets the oscillation cancel and prices only the bias. + + L1 (not Gaussian) on purpose: the gradient stays constant at large bias, + where a tight Gaussian would be flat and dead. + + On backlash models the measured angle reads through the play, matching + head_pose_tracking and the encoder obs. + + ``gate_height_low`` (optional): upright gate for recovery envs (standup / + velstand), same smoothstep shape and semantics as body_ang_vel_at_height — + zero below gate_height_low or above gate_tilt_zero_deg tilt, full above + gate_height_high and below gate_tilt_full_deg. The gate multiplies the + ERROR feeding the EMA (not just the output): while fallen/rising the EMA + sees zero and decays, so arriving upright starts the bias clock from ~0 + instead of charging the whole ground phase's accumulated error at the + finish line — that would be a reward wall right before recovery completes, + the exact failure mode of the retired head_impact_penalty. The output is + gated too, so a fresh fall stops the charge immediately. + """ + asset: Entity = env.scene[asset_cfg.name] + cmd = env.command_manager.get_command(command_name) # (N, 4) + + if not hasattr(env, "_head_pose_neck_ids"): + # Share the id cache with head_pose_tracking (either may run first). + head_pose_tracking(env, command_name=command_name, asset_cfg=asset_cfg) + + neck_ids = env._head_pose_neck_ids + joint_pos = asset.data.joint_pos + measured = ( + joint_pos[:, neck_ids] + + joint_pos[:, env._head_pose_bl_ids] * env._head_pose_bl_mask + ) + err = (measured - asset.data.default_joint_pos[:, neck_ids]) - cmd + + if gate_height_low is not None: + z = torch.nan_to_num( + asset.data.root_link_pos_w[:, 2] - env.scene.terrain.env_origins[:, 2], + nan=0.0, + ) + t = torch.clamp( + (z - gate_height_low) / max(gate_height_high - gate_height_low, 1e-6), + 0.0, 1.0, + ) + gate = t * t * (3.0 - 2.0 * t) + quat = asset.data.root_link_quat_w + cos_tilt = 1.0 - 2.0 * (quat[:, 1] ** 2 + quat[:, 2] ** 2) + tilt_deg = torch.rad2deg(torch.acos(cos_tilt.clamp(-1.0, 1.0))) + st = torch.clamp( + (gate_tilt_zero_deg - tilt_deg) + / max(gate_tilt_zero_deg - gate_tilt_full_deg, 1e-6), + 0.0, 1.0, + ) + gate = gate * (st * st * (3.0 - 2.0 * st)) + err = err * gate.unsqueeze(-1) + else: + gate = None + + if not hasattr(env, "_head_bias_ema"): + env._head_bias_ema = torch.zeros_like(err) + # Freshly reset envs: drop the previous episode's accumulated bias. + fresh = env.episode_length_buf <= 1 + env._head_bias_ema[fresh] = 0.0 + + alpha = min(1.0, float(env.step_dt) / max(tau_s, 1e-6)) + env._head_bias_ema = (1.0 - alpha) * env._head_bias_ema + alpha * err + out = -env._head_bias_ema.abs().mean(dim=-1) + if gate is not None: + out = out * gate + return out + + +def body_pose_tracking_6d( + env: ManagerBasedRlEnv, + command_name: str = "body_pose", + nominal_height: float = 0.095, + xy_std: float = 0.02, + z_std: float = 0.01, + angle_std: float = math.radians(8), + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """Mean of 6 per-axis Gaussian rewards for tracking commanded body pose. + + cmd has shape (N, 6) = [x, y, z, roll, pitch, yaw] all as deltas from the + nominal standing pose (xy delta from spawn origin, z delta from + nominal_height, angles delta from upright = 0). + """ + asset: Entity = env.scene[asset_cfg.name] + cmd = env.command_manager.get_command(command_name) # (N, 6) + dx, dy, dz = cmd[:, 0], cmd[:, 1], cmd[:, 2] + droll, dpitch, dyaw = cmd[:, 3], cmd[:, 4], cmd[:, 5] + + # Position relative to env spawn origin. nan_to_num because MuJoCo can + # produce NaN on contact instability and we don't want to taint the reward. + pos_w = asset.data.root_link_pos_w + origin = env.scene.terrain.env_origins + rel = torch.nan_to_num(pos_w - origin, nan=0.0) + x_err = rel[:, 0] - dx + y_err = rel[:, 1] - dy + z_err = rel[:, 2] - (nominal_height + dz) + + # ZYX Euler from quat. + quat = asset.data.root_link_quat_w + qw, qx, qy, qz = quat[:, 0], quat[:, 1], quat[:, 2], quat[:, 3] + roll = torch.atan2(2.0 * (qw * qx + qy * qz), 1.0 - 2.0 * (qx * qx + qy * qy)) + pitch = torch.asin(torch.clamp(2.0 * (qw * qy - qz * qx), -1.0, 1.0)) + yaw = torch.atan2(2.0 * (qw * qz + qx * qy), 1.0 - 2.0 * (qy * qy + qz * qz)) + + roll_err = roll - droll + pitch_err = pitch - dpitch + yaw_err = wrap_to_pi(yaw - dyaw) + + r_x = torch.exp(-(x_err / xy_std) ** 2) + r_y = torch.exp(-(y_err / xy_std) ** 2) + r_z = torch.exp(-(z_err / z_std) ** 2) + r_r = torch.exp(-(roll_err / angle_std) ** 2) + r_p = torch.exp(-(pitch_err / angle_std) ** 2) + r_w = torch.exp(-(yaw_err / angle_std) ** 2) + + return (r_x + r_y + r_z + r_r + r_p + r_w) / 6.0 + + +def termination_param_curriculum( + env: ManagerBasedRlEnv, + env_ids: torch.Tensor, + term_name: str, + param_stages: list[dict], +) -> torch.Tensor: + """Mutate a termination term's params at scheduled steps. + + TerminationManager keeps its own deepcopy of the cfg dict, so the live + term_cfgs list must be edited directly — env.cfg.terminations is a no-op. + Useful for disabling a termination later in training (e.g. set + bad_orientation's limit_angle to pi at iter N so the robot can fall over + without ending the episode and learn to recover). + + param_stages: list of {step: int, params: dict}. The dict is shallow-merged + into the live term_cfg.params at the latest matching stage. + """ + del env_ids + tm = env.termination_manager + if term_name not in tm._term_names: + # Term was removed (e.g. play mode disables fell_over entirely). + return torch.tensor(0.0) + idx = tm._term_names.index(term_name) + term_cfg = tm._term_cfgs[idx] + + current = param_stages[0]["params"] + for stage in param_stages: + if env.common_step_counter >= stage["step"]: + current = stage["params"] + term_cfg.params.update(current) + + first_val = next(iter(current.values())) + return torch.tensor(float(first_val) if isinstance(first_val, (int, float)) else 0.0) + + +def body_pose_tracking_locomotion( + env: ManagerBasedRlEnv, + command_name: str = "body_pose", + nominal_height: float = 0.105, + xy_std: float = 0.02, + z_std: float = 0.03, + angle_std: float = math.radians(30), + axis_weights: tuple[float, float, float, float, float, float] = (1.0, 1.0, 1.0, 1.0, 1.0, 1.0), + vel_gate_command_name: str | None = None, + vel_gate_std: float = 0.1, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, + feet_cfg: SceneEntityCfg = SceneEntityCfg("robot", site_names=("left_foot", "right_foot")), +) -> torch.Tensor: + """Locomotion-aware 6D body pose tracking. + + Same shape as body_pose_tracking_6d (6D cmd, mean of 6 Gaussians), but + x/y/yaw are measured *relative to the feet support polygon*, not the spawn + origin. This makes the reward meaningful while the robot walks (or stands): + + x, y : trunk position − feet-centroid, rotated into trunk body frame. + dx = +0.02 means "lean trunk 2 cm forward of foot centroid." + z : trunk world height (− nominal_height) — locomotion-neutral. + roll : trunk world roll — locomotion-neutral. + pitch : trunk world pitch — locomotion-neutral. + yaw : trunk world yaw − circular-mean(feet site yaws). dyaw = +0.3 rad + means "twist the trunk 17° relative to where the feet point." + + The body_pose_tracking_6d reward measures x/y/yaw vs spawn origin / world + yaw, which kills the gradient as soon as the robot translates or turns. This + version stays meaningful regardless of where in the world the robot is. + """ + asset: Entity = env.scene[asset_cfg.name] + cmd = env.command_manager.get_command(command_name) # (N, 6) + dx, dy, dz = cmd[:, 0], cmd[:, 1], cmd[:, 2] + droll, dpitch, dyaw = cmd[:, 3], cmd[:, 4], cmd[:, 5] + + pos_w = asset.data.root_link_pos_w + quat = asset.data.root_link_quat_w + qw, qx, qy, qz = quat[:, 0], quat[:, 1], quat[:, 2], quat[:, 3] + trunk_yaw = torch.atan2(2.0 * (qw * qz + qx * qy), 1.0 - 2.0 * (qy * qy + qz * qz)) + roll = torch.atan2(2.0 * (qw * qx + qy * qz), 1.0 - 2.0 * (qx * qx + qy * qy)) + pitch = torch.asin(torch.clamp(2.0 * (qw * qy - qz * qx), -1.0, 1.0)) + + # Feet centroid in world frame. + foot_pos = asset.data.site_pos_w[:, feet_cfg.site_ids] # (N, 2, 3) + foot_quat = asset.data.site_quat_w[:, feet_cfg.site_ids] # (N, 2, 4) + feet_centroid = foot_pos.mean(dim=1) # (N, 3) + + # Trunk xy in body frame relative to feet centroid (rotate world Δxy by −yaw). + dx_w = pos_w[:, 0] - feet_centroid[:, 0] + dy_w = pos_w[:, 1] - feet_centroid[:, 1] + cos_y = torch.cos(trunk_yaw) + sin_y = torch.sin(trunk_yaw) + x_body = cos_y * dx_w + sin_y * dy_w + y_body = -sin_y * dx_w + cos_y * dy_w + + # Z relative to spawn-origin terrain height (still in world). + origin = env.scene.terrain.env_origins + z_world = torch.nan_to_num(pos_w[:, 2] - origin[:, 2], nan=0.0) + + # Feet yaws → circular mean. NOTE: this depends on the site orientation + # matching the foot pointing direction; if the site frame is rotated, this + # yaw reference may have an offset (constant per-env, so dyaw=0 still maps + # to "feet-aligned"). + fqw, fqx, fqy, fqz = foot_quat[..., 0], foot_quat[..., 1], foot_quat[..., 2], foot_quat[..., 3] + foot_yaws = torch.atan2(2.0 * (fqw * fqz + fqx * fqy), 1.0 - 2.0 * (fqy * fqy + fqz * fqz)) # (N, 2) + mean_foot_yaw = torch.atan2(torch.sin(foot_yaws).mean(dim=1), torch.cos(foot_yaws).mean(dim=1)) + + x_err = x_body - dx + y_err = y_body - dy + z_err = z_world - (nominal_height + dz) + roll_err = roll - droll + pitch_err = pitch - dpitch + yaw_err = wrap_to_pi(trunk_yaw - mean_foot_yaw - dyaw) + + r_x = torch.exp(-(x_err / xy_std) ** 2) + r_y = torch.exp(-(y_err / xy_std) ** 2) + r_z = torch.exp(-(z_err / z_std) ** 2) + r_r = torch.exp(-(roll_err / angle_std) ** 2) + r_p = torch.exp(-(pitch_err / angle_std) ** 2) + r_w = torch.exp(-(yaw_err / angle_std) ** 2) + + # Per-axis weighted mean. Pass axis_weights=(0,0,1,1,1,1) to disable xy + # tracking — useful when xy lean is mechanically coupled to pitch/roll on + # the robot, making independent xy commands a noise source rather than a + # learnable objective. + wx, wy, wz, wr, wp, wyaw = axis_weights + total_w = wx + wy + wz + wr + wp + wyaw + reward = (wx*r_x + wy*r_y + wz*r_z + wr*r_r + wp*r_p + wyaw*r_w) / max(total_w, 1e-6) + + # Optional gate: when vel_gate_command_name is set, scale the reward by a + # Gaussian on the velocity command's magnitude. With vel_gate_std ≈ 0.1, + # the gate is ~1 when commanded velocity is 0 and decays to ~exp(-9)≈0 + # by |vel_cmd|≥0.3 — body tracking only meaningfully contributes when the + # robot is supposed to be standing still. Avoids the tracking vs walking + # conflict that prevented the previous run from learning either well. + if vel_gate_command_name is not None: + # Gate on commanded LINEAR velocity only (xy) — turning in place still + # leaves body pose meaningful, but walking forward/sideways doesn't. + vel_cmd = env.command_manager.get_command(vel_gate_command_name) # (N, 3) + vel_mag = torch.linalg.vector_norm(vel_cmd[:, :2], dim=-1) + gate = torch.exp(-(vel_mag / vel_gate_std) ** 2) + reward = reward * gate + + return reward + + +def pose_command_range_curriculum( + env: ManagerBasedRlEnv, + env_ids: torch.Tensor, + command_name: str, + range_stages: list[dict], +) -> torch.Tensor: + """Ramp a UniformPoseCommand's per-dim ranges over training. + + range_stages: list of {step: int, ranges: tuple[(lo, hi), ...]}. + The first stage applies before its step; latest passed stage wins. + Always uses the live CommandManager term cfg (NOT env.cfg.commands) so + updates take effect — CommandManager keeps its own term refs and reads + `term.cfg.ranges` each resample. + """ + del env_ids + + term = env.command_manager.get_term(command_name) + assert term is not None, f"Command term '{command_name}' not found" + cfg = term.cfg # type: ignore[assignment] + + current = range_stages[0]["ranges"] + for stage in range_stages: + if env.common_step_counter >= stage["step"]: + current = stage["ranges"] + + cfg.ranges = tuple(current) + # Return the max abs range as a scalar for wandb visibility. + max_abs = max((max(abs(lo), abs(hi)) for lo, hi in current), default=0.0) + return torch.tensor(max_abs) + + +# ───────────────────────────────────────────────────────────────────────────── +# Gait-shaping penalties ported from mjlab_microban (microban velocity recipe). +# ───────────────────────────────────────────────────────────────────────────── +def no_stepping_penalty( + env: ManagerBasedRlEnv, + sensor_name: str, + command_name: str = "twist", + command_threshold: float = 0.01, +) -> torch.Tensor: + """Penalize feet in the air when the commanded speed is below threshold. + + Discourages marching in place when the robot should stand still. Returns the + count of airborne feet per environment (use with a negative weight). + Ported from mjlab_microban. + """ + command = env.command_manager.get_command(command_name) # (N, 3) + cmd_speed = torch.norm(command[:, :2], dim=-1) + torch.abs(command[:, 2]) + below_threshold = cmd_speed < command_threshold + + sensor = env.scene.sensors[sensor_name] + found = sensor.data.found # (N, num_feet) or (N, num_feet, num_slots) + if found.dim() == 3: + found = found.any(dim=-1) # (N, num_feet) + in_air = ~found.bool() + + return in_air.float().sum(dim=-1) * below_threshold.float() + + +def feet_distance_penalty( + env: ManagerBasedRlEnv, + min_dist: float, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """Penalize the feet getting too close to each other in the horizontal plane. + + Returns ``clamp(min_dist - d, min=0)`` per env (use with a negative weight), + where ``d`` is the horizontal (xy) distance between the two foot sites. + Ported from mjlab_microban. Not wired into velocity yet — pinned for later. + """ + asset: Entity = env.scene[asset_cfg.name] + foot_pos_xy = asset.data.site_pos_w[:, asset_cfg.site_ids, :2] # (N, 2, 2) + dist = torch.norm(foot_pos_xy[:, 0] - foot_pos_xy[:, 1], dim=-1) # (N,) + return torch.clamp(min_dist - dist, min=0.0) + + +# ───────────────────────────────────────────────────────────────────────────── +# Non-accumulating domain randomization (restore-nominal-then-apply). +# +# The stock mdp.randomize_field with operation="add"/"scale" + mode="reset" +# reads the CURRENT model value and applies the op to it, with no restore to +# nominal — so on every episode reset the perturbation STACKS on the previous +# one and the parameter random-walks away from nominal over training. For +# body_ipos (CoM) this was the long-standing microduck instability: the CoM +# drifted centimeters off-center over hundreds of resets → progressively +# unbalanced robot → falls more → reward/episode-length collapse after the early +# peak. These functions mirror randomize_mass_and_inertia: cache the nominal +# once, restore it before each draw, then apply a freshly-sampled perturbation — +# so it is re-sampled per episode but never accumulates. +# ───────────────────────────────────────────────────────────────────────────── +def randomize_com( + env: ManagerBasedRlEnv, + env_ids: torch.Tensor, + ranges: tuple[float, float], + field: str = "body_ipos", + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """Randomize body CoM (body_ipos) per episode WITHOUT accumulating. + + Drop-in replacement for the buggy mdp.randomize_field(add, body_ipos, reset). + ``ranges`` is (lo, hi) applied to all 3 CoM axes; the com_range curriculum + updates this same ``ranges`` param. ``field`` is declared so the event can run + with ``domain_randomization=True`` (mjlab reads params["field"] to expand that + model field per-env). + """ + if env_ids is None: + env_ids = torch.arange(env.num_envs, device=env.device, dtype=torch.int) + else: + env_ids = env_ids.to(env.device, dtype=torch.int) + + asset: Entity = env.scene[asset_cfg.name] + body_ids = asset_cfg.body_ids + if isinstance(body_ids, slice): + body_ids = list(range(asset.num_bodies))[body_ids] + body_indices = asset.indexing.body_ids[body_ids] + + mf = getattr(env.sim.model, field) + # Key the cache by (field, body set): multiple randomize_com events can share + # the same field (e.g. trunk + head both randomize body_ipos) and must NOT + # collide on a single _original_body_ipos attr — their body counts differ. + _bidx = body_indices.tolist() if hasattr(body_indices, "tolist") else list(body_indices) + cache_attr = f"_original_{field}_" + "_".join(str(int(i)) for i in _bidx) + # Cache nominal on first call (model[0] is still nominal at that point). + if not hasattr(env, cache_attr): + setattr(env, cache_attr, mf[0, body_indices].clone()) + nominal = getattr(env, cache_attr) + + num_envs = len(env_ids) + num_bodies = len(body_indices) + + # Restore nominal first (prevents accumulation), then add a fresh offset. + mf[env_ids[:, None], body_indices] = nominal.unsqueeze(0).expand(num_envs, -1, -1) + lo, hi = ranges + offsets = torch.rand(num_envs, num_bodies, 3, device=env.device) * (hi - lo) + lo + mf[env_ids[:, None], body_indices] += offsets + return torch.tensor(float(hi)) + + +def randomize_dof_field_scaled( + env: ManagerBasedRlEnv, + env_ids: torch.Tensor, + field: str, + scale_range: tuple[float, float], + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """Scale a per-dof model field (e.g. dof_frictionloss/dof_damping) per episode + WITHOUT accumulating: restore nominal, then apply a fresh scale. + + ``field`` doubles as the domain_randomization field name. NOTE: under the BAM + actuator, dof_frictionloss and dof_damping are zeroed in edit_spec (BAM models + friction itself), so scaling them is a no-op — these only matter with the XML + position actuator. Kept correct to avoid the accumulation footgun if re-enabled. + """ + if env_ids is None: + env_ids = torch.arange(env.num_envs, device=env.device, dtype=torch.int) + else: + env_ids = env_ids.to(env.device, dtype=torch.int) + + asset: Entity = env.scene[asset_cfg.name] + joint_ids = asset_cfg.joint_ids + if isinstance(joint_ids, slice): + joint_ids = list(range(len(asset.indexing.joint_ids)))[joint_ids] + dof_indices = asset.indexing.joint_v_adr[joint_ids] + + mf = getattr(env.sim.model, field) + cache_attr = f"_original_{field}" + if not hasattr(env, cache_attr): + setattr(env, cache_attr, mf[0, dof_indices].clone()) + nominal = getattr(env, cache_attr) + + num_envs = len(env_ids) + num_dofs = len(dof_indices) + + mf[env_ids[:, None], dof_indices] = nominal.unsqueeze(0).expand(num_envs, -1) + lo, hi = scale_range + scales = torch.rand(num_envs, num_dofs, device=env.device) * (hi - lo) + lo + mf[env_ids[:, None], dof_indices] *= scales + return torch.tensor(float(hi)) + + +# ============================================================================= +# BallKick task — ball reset event, kick rewards, critic-only ball observations +# ============================================================================= + + +def _ball_kick_dir(env: ManagerBasedRlEnv) -> torch.Tensor: + """Per-env world-frame kick direction (XY unit vector), lazily allocated. + + Set by ``reset_ball_in_front_of_foot`` to the robot's forward direction at + episode reset. Frozen for the episode so the policy can't redefine "forward" + by turning after the kick. + """ + if not hasattr(env, "_ball_kick_dir_w"): + env._ball_kick_dir_w = torch.zeros(env.num_envs, 2, device=env.device) + env._ball_kick_dir_w[:, 0] = 1.0 + return env._ball_kick_dir_w + + +def reset_ball_in_front_of_foot( + env: ManagerBasedRlEnv, + env_ids: torch.Tensor, + offset: tuple = (0.09, -0.042), + noise_xy: float = 0.015, + ball_radius: float = 0.035, + asset_name: str = "ball", +): + """Place the ball in front of the (right) foot; store the kick direction. + + ``offset`` is the nominal ball-center position in the robot's yaw frame: + at HOME the right foot is centered at (0, -0.042) with the toe tip at + x≈0.034, so (0.08, -0.042) puts a 35mm-radius ball ~1cm in front of the + toe. ``noise_xy`` (uniform ± per axis) is the placement DR: the policy is + BLIND to the ball, so this is what forces a swing that works across the + real-world placement error. + + Reads the robot root from qpos directly (root_link_pos_w lags until the + next forward()); must be registered AFTER reset_base / set_ground_state + (events run in dict insertion order) so the robot pose is final. + """ + if env_ids is None or len(env_ids) == 0: + return + env_ids = env_ids.to(env.device) + robot: Entity = env.scene["robot"] + ball: Entity = env.scene[asset_name] + + root = env.sim.data.qpos[env_ids][:, robot.indexing.free_joint_q_adr] + qw, qx, qy, qz = root[:, 3], root[:, 4], root[:, 5], root[:, 6] + yaw = torch.atan2(2.0 * (qw * qz + qx * qy), 1.0 - 2.0 * (qy * qy + qz * qz)) + cos_y, sin_y = torch.cos(yaw), torch.sin(yaw) + + n = len(env_ids) + off = torch.tensor(offset, device=env.device, dtype=torch.float).repeat(n, 1) + off += (torch.rand(n, 2, device=env.device) * 2.0 - 1.0) * noise_xy + + pose = torch.zeros(n, 7, device=env.device) + pose[:, 0] = root[:, 0] + cos_y * off[:, 0] - sin_y * off[:, 1] + pose[:, 1] = root[:, 1] + sin_y * off[:, 0] + cos_y * off[:, 1] + pose[:, 2] = env.scene.terrain.env_origins[env_ids, 2] + ball_radius + pose[:, 3] = 1.0 # identity quat + ball.write_root_link_pose_to_sim(pose, env_ids) + ball.write_root_link_velocity_to_sim( + torch.zeros(n, 6, device=env.device), env_ids + ) + + kick_dir = _ball_kick_dir(env) + kick_dir[env_ids, 0] = cos_y + kick_dir[env_ids, 1] = sin_y + + +def ball_forward_velocity( + env: ManagerBasedRlEnv, + asset_name: str = "ball", + max_speed: float = 5.0, +) -> torch.Tensor: + """Ball XY velocity along the per-env kick direction, clamped to [0, max]. + + Dense and linear-in-speed up to ``max_speed``: every extra bit of forward + ball speed pays more every step the ball keeps rolling, so exploration + nudges bootstrap the kick with no peak-detection machinery. Backward / + lateral ball motion earns 0 rather than a penalty — a mis-hit shouldn't + scare the policy away from contacting the ball at all. + + With ``max_speed`` set to a TARGET speed (rather than a large cap), pair + with ``ball_speed_overshoot_penalty``: the reward saturating at the target + alone does NOT remove "harder is better" — a harder kick keeps the ball + at/above the cap for more steps, so the rolling-time integral still grows + with strike speed. The overshoot penalty is what makes the target the + actual optimum. + """ + ball: Entity = env.scene[asset_name] + vel_xy = ball.data.root_link_lin_vel_w[:, :2] + fwd = (vel_xy * _ball_kick_dir(env)).sum(dim=1) + return torch.nan_to_num(fwd, nan=0.0).clamp(0.0, max_speed) + + +def ball_speed_overshoot_penalty( + env: ManagerBasedRlEnv, + asset_name: str = "ball", + target_speed: float = 1.0, + max_penalty: float = 5.0, +) -> torch.Tensor: + """Ball forward speed in excess of ``target_speed`` (linear, ≥ 0). + + Companion to ``ball_forward_velocity`` for a target-speed kick: below the + target this is 0 (the capped linear reward provides the upward gradient); + above it, each m/s of overshoot costs linearly every step it persists. + Keep this term's |weight| BELOW the capped reward's weight so the combined + landscape peaks at the target with a gentler slope on the overshoot side — + erring slightly hard must stay cheaper than not kicking at all. + """ + ball: Entity = env.scene[asset_name] + vel_xy = ball.data.root_link_lin_vel_w[:, :2] + fwd = (vel_xy * _ball_kick_dir(env)).sum(dim=1) + over = torch.nan_to_num(fwd, nan=0.0) - target_speed + return over.clamp(0.0, max_penalty) + + +def single_foot_grounded_reward( + env: ManagerBasedRlEnv, + sensor_name: str, +) -> torch.Tensor: + """Binary reward: 1 while the sensed foot touches the terrain. + + Single-foot variant of ``feet_grounded_reward`` — used to pin the SUPPORT + foot during the kick (anti-hop): swinging the right leg is free, lifting + the left foot costs this reward every step. + """ + if sensor_name not in env.scene.sensors: + return torch.zeros(env.num_envs, device=env.device) + found = env.scene.sensors[sensor_name].data.found + if found.dim() > 1: + found = found.sum(dim=-1) + return torch.clamp(found, 0.0, 1.0) + + +def ball_pos_in_base( + env: ManagerBasedRlEnv, + asset_name: str = "ball", +) -> torch.Tensor: + """Ball position relative to the robot root, in the robot's base frame. + + CRITIC-ONLY observation (asymmetric actor-critic): the deployed policy has + no ball sensing, so the actor must stay blind to the ball — the critic can + still use it to predict the kick payoff. + """ + robot: Entity = env.scene["robot"] + ball: Entity = env.scene[asset_name] + rel = ball.data.root_link_pos_w - robot.data.root_link_pos_w + rot = matrix_from_quat(robot.data.root_link_quat_w) + return torch.bmm(rot.transpose(1, 2), rel.unsqueeze(-1)).squeeze(-1) + + +def ball_vel_in_base( + env: ManagerBasedRlEnv, + asset_name: str = "ball", +) -> torch.Tensor: + """Ball linear velocity in the robot's base frame. CRITIC-ONLY (see above).""" + robot: Entity = env.scene["robot"] + ball: Entity = env.scene[asset_name] + rot = matrix_from_quat(robot.data.root_link_quat_w) + vel = ball.data.root_link_lin_vel_w + return torch.bmm(rot.transpose(1, 2), vel.unsqueeze(-1)).squeeze(-1) + + +# --------------------------------------------------------------------------- # +# Tâche SPIN — rotation rapide sur place sur rollers # +# --------------------------------------------------------------------------- # +# Enveloppe de phase : la commande du slot bouton porte une phase, qui pilote +# une VITESSE DE LACET cible en trapèze (et non une pose comme le crouch). +# [0, accel_end) 0.5 s 0 -> rate_max (lancement) +# [accel_end, hold_end) 1.6 s rate_max (régime) +# [hold_end, brake_end) 0.5 s rate_max -> 0 (freinage) +# [brake_end, 1.0) 1.4 s 0 (repos debout) +# Aire sous l'enveloppe sur un cycle = 2.1 * SPIN_RATE_MAX rad. À 3.0 rad/s : +# 2.1 * 3.0 = 6.3 rad ~ 1 tour (et non ~2, comme avec l'ancienne cible 6.0). +SPIN_PERIOD = 4.0 +SPIN_RATE_MAX = 3.0 +SPIN_ACCEL_END = 0.125 +SPIN_HOLD_END = 0.525 +SPIN_BRAKE_END = 0.650 + + +def spin_rate_by_phase( + phase: torch.Tensor, + rate_max: float = SPIN_RATE_MAX, + accel_end: float = SPIN_ACCEL_END, + hold_end: float = SPIN_HOLD_END, + brake_end: float = SPIN_BRAKE_END, +) -> torch.Tensor: + """Vitesse de lacet cible (rad/s, positive = anti-horaire) le long de la phase.""" + w = torch.zeros_like(phase) + accel = phase < accel_end + w = torch.where(accel, rate_max * phase / accel_end, w) + hold = (phase >= accel_end) & (phase < hold_end) + w = torch.where(hold, torch.full_like(phase, rate_max), w) + brake = (phase >= hold_end) & (phase < brake_end) + w = torch.where( + brake, rate_max * (1.0 - (phase - hold_end) / (brake_end - hold_end)), w + ) + return w + + +def spin_gate_by_phase( + phase: torch.Tensor, + rate_max: float = SPIN_RATE_MAX, + accel_end: float = SPIN_ACCEL_END, + hold_end: float = SPIN_HOLD_END, + brake_end: float = SPIN_BRAKE_END, +) -> torch.Tensor: + """Porte de shaping dans [0,1] = enveloppe normalisée. + + Vaut 0 sur tout le segment de repos : les amorces (ciseau des jambes, + différentiel des roues) ne s'appliquent que pendant lancement + régime, donc + le robot revient en station neutre avant de rendre la main à la policy roller. + """ + return spin_rate_by_phase(phase, rate_max, accel_end, hold_end, brake_end) / rate_max + + +def spin_phase_from_command(cmd: torch.Tensor) -> torch.Tensor: + """Récupère la phase [0,1) depuis la commande [cos(2πφ), sin(2πφ), 0] du slot.""" + return (torch.atan2(cmd[:, 1], cmd[:, 0]) / (2 * torch.pi)) % 1.0 + + +def _spin_target_rate( + env: ManagerBasedRlEnv, + command_name: str, + rate_max: float, + accel_end: float, + hold_end: float, + brake_end: float, +) -> torch.Tensor: + phase = spin_phase_from_command(env.command_manager.get_command(command_name)) + return spin_rate_by_phase(phase, rate_max, accel_end, hold_end, brake_end) + + +def _spin_gate( + env: ManagerBasedRlEnv, + command_name: str, + rate_max: float, + accel_end: float, + hold_end: float, + brake_end: float, +) -> torch.Tensor: + phase = spin_phase_from_command(env.command_manager.get_command(command_name)) + return spin_gate_by_phase(phase, rate_max, accel_end, hold_end, brake_end) + + +def spin_rate_reward_from_values( + omega_z: torch.Tensor, omega_target: torch.Tensor, std: float +) -> torch.Tensor: + """Gaussienne sur l'erreur de vitesse de lacet (fonction pure, testable).""" + return torch.exp(-(((omega_z - omega_target) / std) ** 2)) + + +def spin_rate_track( + env: ManagerBasedRlEnv, + command_name: str = "twist", + std: float = 1.5, + rate_max: float = SPIN_RATE_MAX, + accel_end: float = SPIN_ACCEL_END, + hold_end: float = SPIN_HOLD_END, + brake_end: float = SPIN_BRAKE_END, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """Objectif principal du spin : suivre la vitesse de lacet cible ω*(φ). + + ω_z est pris en repère corps (c'est ce que voit le gyro de l'IMU, donc ce que + la policy observe). Une rotation dans le mauvais sens est plus punie que + l'immobilité, la gaussienne étant centrée sur une cible positive. + """ + asset: Entity = env.scene[asset_cfg.name] + omega_z = asset.data.root_link_ang_vel_b[:, 2] + target = _spin_target_rate(env, command_name, rate_max, accel_end, hold_end, brake_end) + return spin_rate_reward_from_values(omega_z, target, std) + + +def spin_rate_l1( + env: ManagerBasedRlEnv, + command_name: str = "twist", + rate_max: float = SPIN_RATE_MAX, + accel_end: float = SPIN_ACCEL_END, + hold_end: float = SPIN_HOLD_END, + brake_end: float = SPIN_BRAKE_END, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """Bootstrap L1 : gradient constant vers la cible même quand la gaussienne + de `spin_rate_track` sature loin de la cible. À utiliser avec un poids + POSITIF (la valeur retournée est déjà négative).""" + asset: Entity = env.scene[asset_cfg.name] + omega_z = asset.data.root_link_ang_vel_b[:, 2] + target = _spin_target_rate(env, command_name, rate_max, accel_end, hold_end, brake_end) + return -torch.abs(omega_z - target) + + +SPIN_LAUNCH_DRIFT_SCALE = 0.2 # atténuation du coût de dérive pendant le lancement + + +def spin_stay_in_place( + env: ManagerBasedRlEnv, + command_name: str = "twist", + launch_scale: float = SPIN_LAUNCH_DRIFT_SCALE, + accel_end: float = SPIN_ACCEL_END, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """Coût ‖v_xy‖² du tronc : tourner SUR PLACE, et tuer l'élan d'entrée. + + Pas d'état de référence (contrairement à une dérive mesurée depuis le reset), + donc reste valide sur les 5 cycles d'un épisode. À utiliser avec un poids + NÉGATIF. + + ATTÉNUÉ PENDANT LE LANCEMENT : sur `[0, accel_end)` le robot doit pousser au + sol pour s'injecter du moment angulaire, et l'état d'entrée lui donne jusqu'à + 0.3 m/s qu'il est censé CONVERTIR en rotation. Facturer la translation à plein + tarif à cet instant s'oppose donc directement à l'objectif. Le coût est + multiplié par `launch_scale` sur ce seul segment, et vaut plein tarif ensuite + (régime, freinage, repos) où « sur place » est le vrai critère. + + Contrairement aux autres amorces du spin, ce terme n'est PAS éteint par + `spin_gate_by_phase` : pendant le repos on veut justement qu'il reste plein, + puisque c'est là que le robot doit être immobile. + """ + asset: Entity = env.scene[asset_cfg.name] + v_xy = asset.data.root_link_lin_vel_b[:, :2] + cost = torch.sum(torch.square(v_xy), dim=1) + + phase = spin_phase_from_command(env.command_manager.get_command(command_name)) + scale = torch.where( + phase < accel_end, + torch.full_like(cost, launch_scale), + torch.ones_like(cost), + ) + return cost * scale + + +# Demi-voie mesurée sur le modèle rollers (pose HOME, sites left_foot/right_foot) : +# 0.0499 m, contre 0.03 m estimé au spec. Conséquence mécanique de SPIN_RATE_MAX +# (A1) : différentiel attendu = 2*SPIN_RATE_MAX*demi_voie/r, r = 0.0175 m. +# À l'ancienne cible 6.0 rad/s : 2*6.0*0.0499/0.0175 = 34.2 rad/s (retenu comme +# 34.0, soit +71% par rapport aux 20.0 estimés au spec -> seuil de 30% dépassé). +# À la nouvelle cible 3.0 rad/s : 2*3.0*0.0499/0.0175 = 17.1 rad/s. Laisser 34.0 +# ici plafonnerait le terme à tanh(17.1/34) = 0.47 de son propre maximum, ce qui +# affaiblirait exactement le shaping qu'on veut renforcer (cf. spin_stay_in_place). +SPIN_WHEEL_OMEGA_SCALE = 17.0 # rad/s ; recalibré sur la demi-voie mesurée et SPIN_RATE_MAX = 3.0 + + +def spin_wheel_differential_from_values( + diff: torch.Tensor, gate: torch.Tensor, omega_scale: float +) -> torch.Tensor: + """Fonction pure : tanh du différentiel de roues, portée par gate, clampée ≥ 0.""" + return gate * torch.tanh(torch.clamp(diff, min=0.0) / omega_scale) + + +def spin_wheel_differential( + env: ManagerBasedRlEnv, + command_name: str = "twist", + omega_scale: float = SPIN_WHEEL_OMEGA_SCALE, + rate_max: float = SPIN_RATE_MAX, + accel_end: float = SPIN_ACCEL_END, + hold_end: float = SPIN_HOLD_END, + brake_end: float = SPIN_BRAKE_END, +) -> torch.Tensor: + """Récompense la rotation EN ROULEMENT (et non en patinage). + + Pour un spin anti-horaire, le patin gauche recule et le droit avance ; les 4 + roues tournant positif en marche avant, cela donne ω_D − ω_G > 0. Le tanh + sature à `omega_scale` pour éviter la course à la vitesse de roue. + """ + asset: Entity = env.scene["robot"] + lf_ids, _ = asset.find_joints("passive_LF_?wheel") + lr_ids, _ = asset.find_joints("passive_LR_?wheel") + rf_ids, _ = asset.find_joints("passive_RF_?wheel") + rr_ids, _ = asset.find_joints("passive_RR_?wheel") + + vel = asset.data.joint_vel + omega_left = (vel[:, lf_ids[0]] + vel[:, lr_ids[0]]) / 2.0 + omega_right = (vel[:, rf_ids[0]] + vel[:, rr_ids[0]]) / 2.0 + gate = _spin_gate(env, command_name, rate_max, accel_end, hold_end, brake_end) + return spin_wheel_differential_from_values( + omega_right - omega_left, gate, omega_scale + ) + + +def spin_grounded( + env: ManagerBasedRlEnv, + sensor_name: str, + command_name: str = "twist", + rate_max: float = SPIN_RATE_MAX, + accel_end: float = SPIN_ACCEL_END, + hold_end: float = SPIN_HOLD_END, + brake_end: float = SPIN_BRAKE_END, +) -> torch.Tensor: + """Les deux lames au sol pendant le spin — empêche « je saute et je vrille ». + + Variante de `grounded_reward` du swizzle, qui n'est pas réutilisable ici : + elle se pondère par cmd_x, qui vaut cos(2πφ) sur la commande de phase. + """ + from mjlab.sensor import ContactSensor + + sensor: ContactSensor = env.scene[sensor_name] + contact_time = sensor.data.current_contact_time # (num_envs, num_feet) + assert contact_time is not None + n_contact = torch.sum((contact_time > 0.0).float(), dim=1) + grounded = (n_contact >= 2).float() + gate = _spin_gate(env, command_name, rate_max, accel_end, hold_end, brake_end) + return grounded * gate + + +def leg_antisymmetry( + env: ManagerBasedRlEnv, + command_name: str = "twist", + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, + joint_bases: tuple = ("hip_pitch", "knee"), + rate_max: float = SPIN_RATE_MAX, + accel_end: float = SPIN_ACCEL_END, + hold_end: float = SPIN_HOLD_END, + brake_end: float = SPIN_BRAKE_END, +) -> torch.Tensor: + """Amorce le CISEAU des jambes (une avant / une arrière) pendant le spin. + + Le robot a des conventions de signe MIROIR gauche/droite : une pose + symétrique satisfait q_G + q_D ≈ 0 (cf. `leg_symmetry_reward`), donc le + ciseau satisfait q_G ≈ q_D. On retourne `gate(φ) · (−mean|q_G − q_D|)` — à + utiliser avec un poids POSITIF, décroissant par curriculum : l'amorce + s'efface pour laisser la policy affiner son propre geste. + """ + asset: Entity = env.scene[asset_cfg.name] + left, right = [], [] + for base in joint_bases: + li, _ = asset.find_joints([f"left_{base}"]) + ri, _ = asset.find_joints([f"right_{base}"]) + left.append(li[0]) + right.append(ri[0]) + lids = torch.tensor(left, device=env.device) + rids = torch.tensor(right, device=env.device) + + q = asset.data.joint_pos + scissor = -torch.abs(q[:, lids] - q[:, rids]).mean(dim=-1) + gate = _spin_gate(env, command_name, rate_max, accel_end, hold_end, brake_end) + return gate * scissor + + +# ============================================================================= +# Backlash model — encoder-through-backlash joint observations +# ============================================================================= +# The backlash model (robot_allcollisions_backlash.xml) puts an unactuated +# ``passive__backlash`` hinge in series with each servo joint. The link +# angle is qpos[servo] + qpos[backlash], and the real encoder sits on the +# OUTPUT side of the play — it reads the sum. These obs replace joint_pos_rel / +# joint_vel_rel in backlash tasks (see tasks/backlash.py) so the policy sees +# exactly what the runtime will feed it. The asset_cfg regex is expected to +# select only the servo joints (the usual ``^(?!passive_).*``). + + +def _backlash_encoder_ids( + env: "ManagerBasedRlEnv", + asset: Entity, + asset_cfg: SceneEntityCfg, +) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + """(main_ids, backlash_ids, mask) — cached per (entity, joint selection). + + mask is 1.0 where a matching passive__backlash joint exists, so the + same obs functions run unchanged on models without backlash joints. + """ + key = (asset_cfg.name, str(asset_cfg.joint_ids)) + cache = env.__dict__.setdefault("_backlash_encoder_cache", {}) + hit = cache.get(key) + if hit is not None: + return hit + + names = asset.joint_names + jnt_ids = asset_cfg.joint_ids + if isinstance(jnt_ids, slice): + main_ids = list(range(len(names)))[jnt_ids] + else: + main_ids = [int(i) for i in jnt_ids] + name_to_id = {n: i for i, n in enumerate(names)} + bl_ids, mask = [], [] + for i in main_ids: + bl = name_to_id.get(f"passive_{names[i]}_backlash") + bl_ids.append(0 if bl is None else bl) + mask.append(0.0 if bl is None else 1.0) + + device = asset.data.joint_pos.device + out = ( + torch.tensor(main_ids, dtype=torch.long, device=device), + torch.tensor(bl_ids, dtype=torch.long, device=device), + torch.tensor(mask, dtype=torch.float32, device=device), + ) + cache[key] = out + return out + + +def joint_pos_rel_backlash( + env: "ManagerBasedRlEnv", + biased: bool = False, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """joint_pos_rel where the encoder reads through the backlash hinge. + + Returns (qpos[servo] + qpos[backlash]) - default[servo]. With biased=True + the per-env encoder-calibration bias is applied to the servo reading (one + encoder per servo → one bias per joint; the backlash summand stays raw). + """ + asset: Entity = env.scene[asset_cfg.name] + main_ids, bl_ids, mask = _backlash_encoder_ids(env, asset, asset_cfg) + joint_pos = asset.data.joint_pos_biased if biased else asset.data.joint_pos + pos = joint_pos[:, main_ids] + asset.data.joint_pos[:, bl_ids] * mask + default_joint_pos = asset.data.default_joint_pos + assert default_joint_pos is not None + return pos - default_joint_pos[:, main_ids] + + +def joint_vel_rel_backlash( + env: "ManagerBasedRlEnv", + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """joint_vel_rel where the encoder reads through the backlash hinge. + + The firmware derives present_velocity from encoder positions, so it also + sees the backlash motion: qvel[servo] + qvel[backlash]. + """ + asset: Entity = env.scene[asset_cfg.name] + main_ids, bl_ids, mask = _backlash_encoder_ids(env, asset, asset_cfg) + vel = asset.data.joint_vel[:, main_ids] + asset.data.joint_vel[:, bl_ids] * mask + default_joint_vel = asset.data.default_joint_vel + assert default_joint_vel is not None + return vel - default_joint_vel[:, main_ids] + + +# ───────────────────────────────────────────────────────────────────────────── +# Sit↔Stand posture command + posture-conditioned rewards (sitstand env). +# +# One policy, both directions: the command is a single sit/stand flag carried +# in the twist slot (cmd = [sit_flag, 0, 0], so "stand" is the all-zero +# command — same deployment idle as every other policy). All task rewards +# below select their target (SIT keyframe + SIT_Z vs HOME + STAND_Z) from the +# live command, per env, so the same reward stack drives the descent, the +# seated rest, the rise and the standing rest. Uses the _servo_* helpers → +# backlash-model compatible. +# ───────────────────────────────────────────────────────────────────────────── + + +class SitStandCommand(UniformVelocityCommand): + """Posture command: cmd = [sit_flag, 0, 0] with dwell-time resampling and a + SLEWED internal target blend. + + sit_flag ∈ {0.0, 1.0}. Resampled by the command manager on the cfg's + resampling_time_range (the dwell time in each posture) and on episode + reset. cfg.sit_prob is the probability a resample commands SIT; with the + reset-state mix this trains all four (start-state × command) combinations, + including "hold what you're already doing". + + ``alpha`` (0 = STAND target, 1 = SIT target) slews toward the flag at a + constant rate (full transition in cfg.ramp_s seconds) and is what the + posture_* rewards track. THE anti-crash mechanism: with a binary target, + arriving early pays the full goal-state jackpot for every step saved, + while the linear speed-cap penalties integrate to a bounded excess- + distance cost — an instant drop beat a 1 s descent by ~7×. With the + slewed target, being AHEAD of the ramp scores ~0 on the height/composite + stack (z far from the commanded height), so tracking the slow setpoint IS + the argmax; the caps remain as backstops for overshoot/bounce. The OBS + stays the raw binary flag (deployment: runtime writes 0/1; the trained + response to a flip is the ~ramp_s glide). + + On episode reset, alpha is initialised from the robot's ACTUAL trunk + height, not the flag — a seated spawn must not be dragged upward by a + stand-initialised ramp (and vice versa). + """ + + def __init__(self, cfg, env: ManagerBasedRlEnv): + super().__init__(cfg, env) + self._sit_prob = float(getattr(cfg, "sit_prob", 0.5)) + self._ramp_s = float(getattr(cfg, "ramp_s", 2.0)) + self._sit_z = float(getattr(cfg, "sit_z", 0.060)) + self._stand_z = float(getattr(cfg, "stand_z", 0.115)) + self._env_ref = env + self._alpha = torch.zeros(self.num_envs, device=self.device) + + @property + def command(self) -> torch.Tensor: + return self.vel_command_b + + @property + def alpha(self) -> torch.Tensor: + """Slewed target blend: 0 = STAND target, 1 = SIT target.""" + return self._alpha + + def _resample_command(self, env_ids: torch.Tensor) -> None: + n = len(env_ids) + if n == 0: + return + sit = (torch.rand(n, device=self.device) < self._sit_prob).float() + self.vel_command_b[env_ids] = 0.0 + self.vel_command_b[env_ids, 0] = sit + + def _alpha_from_height(self) -> torch.Tensor: + z = torch.nan_to_num( + self.robot.data.root_link_pos_w[:, 2] + - self._env_ref.scene.terrain.env_origins[:, 2], + nan=self._stand_z, + ) + return torch.clamp( + (self._stand_z - z) / max(self._stand_z - self._sit_z, 1e-6), 0.0, 1.0 + ) + + def compute(self, dt: float) -> None: + super().compute(dt) + # Episode-start re-init of the blend from the ACTUAL trunk height. + # Done here (not in reset()) because the command manager resets BEFORE + # the set_ground_state event teleports the robot, so reset() would read + # the pre-teleport height. On the first compute of an episode the spawn + # state is in place. + fresh = self._env_ref.episode_length_buf <= 1 + if fresh.any(): + self._alpha = torch.where(fresh, self._alpha_from_height(), self._alpha) + # Constant-rate slew of the target blend toward the commanded flag. + step = dt / max(self._ramp_s, 1e-6) + delta = self.vel_command_b[:, 0] - self._alpha + self._alpha += torch.clamp(delta, -step, step) + + def _update_command(self) -> None: + pass # No heading controller / standing-env machinery. + + def _update_metrics(self) -> None: + pass # No velocity-tracking metrics for a posture flag. + + +@_dataclass(kw_only=True) +class SitStandCommandCfg(UniformVelocityCommandCfg): + class_type: type = SitStandCommand + # Probability that a resample commands SIT (vs STAND). + sit_prob: float = 0.5 + # Seconds for the internal target blend to traverse STAND↔SIT in full. + ramp_s: float = 2.0 + # Rest heights, used to initialise the blend from the spawn state. + sit_z: float = 0.060 + stand_z: float = 0.115 + + def build(self, env: ManagerBasedRlEnv) -> "SitStandCommand": + return SitStandCommand(self, env) + + +def _posture_blend(env: ManagerBasedRlEnv, command_name: str) -> torch.Tensor: + """Target blend ∈ [0, 1] (0 = STAND, 1 = SIT) for the posture rewards. + + Uses the SitStandCommand's slewed ``alpha`` (the moving setpoint) when the + term exposes it; falls back to the raw binary flag otherwise. + """ + term = env.command_manager.get_term(command_name) + alpha = getattr(term, "alpha", None) + if alpha is not None: + return alpha + return env.command_manager.get_command(command_name)[:, 0] + + +def _posture_targets( + env: ManagerBasedRlEnv, + asset: Entity, + command_name: str, + sit_overrides: dict, +) -> tuple[torch.Tensor, torch.Tensor]: + """(target blend, per-env joint target) for the commanded posture. + + STAND target = default_joint_pos (HOME); SIT target = HOME with the + keyframe overrides applied; the SLEWED blend interpolates between them, + so mid-ramp the rewarded pose folds in sync with the descending height. + """ + blend = _posture_blend(env, command_name) + stand_target = _servo_default_joint_pos(env, asset) + sit_target = stand_target.clone() + for idx, val in sit_overrides.items(): + sit_target[:, idx] = val + target = stand_target + blend.unsqueeze(-1) * (sit_target - stand_target) + return blend, target + + +def _posture_height( + env: ManagerBasedRlEnv, + command_name: str, + sit_z: float, + stand_z: float, +) -> tuple[torch.Tensor, torch.Tensor]: + """(slewed target trunk z, actual trunk z) per env.""" + blend = _posture_blend(env, command_name) + target_z = stand_z + blend * (sit_z - stand_z) + asset = env.scene["robot"] + z = torch.nan_to_num( + asset.data.root_link_pos_w[:, 2] - env.scene.terrain.env_origins[:, 2], nan=0.0 + ) + return target_z, z + + +def posture_pose_match( + env: ManagerBasedRlEnv, + command_name: str, + sit_overrides: dict, + joint_indices: list, + std: float = 0.5, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """Gaussian pose-match against the commanded posture's target pose.""" + asset = env.scene[asset_cfg.name] + _, target = _posture_targets(env, asset, command_name, sit_overrides) + joint_pos = _servo_joint_pos(env, asset)[:, joint_indices] + target = target[:, joint_indices] + return torch.exp(-((joint_pos - target) / std) ** 2).mean(dim=-1) + + +def posture_pose_l1( + env: ManagerBasedRlEnv, + command_name: str, + sit_overrides: dict, + joint_indices: list, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """L1 companion to ``posture_pose_match`` (constant gradient to target).""" + asset = env.scene[asset_cfg.name] + _, target = _posture_targets(env, asset, command_name, sit_overrides) + joint_pos = _servo_joint_pos(env, asset)[:, joint_indices] + target = target[:, joint_indices] + return -torch.abs(joint_pos - target).mean(dim=-1) + + +def posture_height_gaussian( + env: ManagerBasedRlEnv, + command_name: str, + sit_z: float, + stand_z: float, + std: float = 0.02, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """Gaussian on trunk z against the commanded posture's target height.""" + del asset_cfg # trunk z read via _posture_height + target_z, z = _posture_height(env, command_name, sit_z, stand_z) + return torch.exp(-((z - target_z) / std) ** 2) + + +def posture_height_l1( + env: ManagerBasedRlEnv, + command_name: str, + sit_z: float, + stand_z: float, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """L1 companion to ``posture_height_gaussian`` — the transition driver. + + While the robot rests in the *wrong* posture this charges a constant + per-step cost (~|Δz| = 55 mm), which is what makes "ignore the command" + a net-negative strategy in both directions. + """ + del asset_cfg + target_z, z = _posture_height(env, command_name, sit_z, stand_z) + return -torch.abs(z - target_z) + + +def posture_composite( + env: ManagerBasedRlEnv, + command_name: str, + sit_overrides: dict, + joint_indices: list, + sit_z: float, + stand_z: float, + height_std: float = 0.03, + upright_std: float = 0.40, + pose_std: float = 0.40, + head_std: float | None = None, + head_command_name: str = "head_pose", + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """Multiplicative goal score vs the commanded posture (height·upright·pose + [·head]). + + The posture-conditioned version of ``standing_composite_score``: a + deficiency in any factor collapses the whole term, so partial-sum + compromises (plank, flop, lean) never pay. Both rest states demand an + upright trunk, so the upright factor is posture-independent. + + ``head_std`` (optional): adds a fourth factor on the neck/head joints vs + the ``head_pose`` command (same error convention as head_pose_tracking). + Without it the goal state is head-blind: the trained policy rested with + the head dangling to the floor — trunk upright, legs in pose, z on target + all held while the head hung, costing only the light tracking term. With + the factor, "arrived" REQUIRES the head at its commanded pose, so head + assist stays free mid-transition (composite is ≈0 there anyway) but must + be retracted to collect the goal reward. + """ + asset = env.scene[asset_cfg.name] + _, target = _posture_targets(env, asset, command_name, sit_overrides) + target_z, z = _posture_height(env, command_name, sit_z, stand_z) + + height_score = torch.exp(-((z - target_z) / height_std) ** 2) + + quat = asset.data.root_link_quat_w + tilt_sq = 2.0 * (quat[:, 1] ** 2 + quat[:, 2] ** 2) + upright_score = torch.exp(-tilt_sq / (upright_std * upright_std)) + + joint_pos = _servo_joint_pos(env, asset)[:, joint_indices] + pose_err_sq = ((joint_pos - target[:, joint_indices]) ** 2).mean(dim=-1) + pose_score = torch.exp(-pose_err_sq / (pose_std * pose_std)) + + score = height_score * upright_score * pose_score + + if head_std is not None: + if not hasattr(env, "_head_pose_neck_ids"): + ids, _ = asset.find_joints_by_actuator_names(_NECK_JOINT_PATTERNS) + env._head_pose_neck_ids = torch.tensor(ids, device=env.device, dtype=torch.long) + neck_ids = env._head_pose_neck_ids + head_cmd = env.command_manager.get_command(head_command_name) + actual = asset.data.joint_pos[:, neck_ids] - asset.data.default_joint_pos[:, neck_ids] + head_err_sq = ((actual - head_cmd) ** 2).mean(dim=-1) + score = score * torch.exp(-head_err_sq / (head_std * head_std)) + + return score + + +def posture_stillness( + env: ManagerBasedRlEnv, + command_name: str, + sit_z: float, + stand_z: float, + band_full: float = 0.012, + band_zero: float = 0.03, + vel_std: float = 0.05, + tilt_full_deg: float = 25.0, + tilt_zero_deg: float = 60.0, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """Reward trunk stillness while AT the commanded posture, upright. + + Generalizes ``seated_stillness`` to both rest states: exp(-(|v|/std)²) + gated by a smoothstep on |z − commanded z| (full inside ``band_full``, + zero beyond ``band_zero`` → inactive during transitions) and by trunk + tilt (a tilted rest — back/face/side — earns nothing). Additionally gated + on the target ramp being COMPLETE (|flag − alpha| small), so stillness + never pays mid-transition. Makes "rest quietly, upright, at the commanded + height" the peak of the stack. + """ + asset = env.scene[asset_cfg.name] + target_z, z = _posture_height(env, command_name, sit_z, stand_z) + v = torch.nan_to_num(asset.data.root_link_lin_vel_w, nan=0.0).norm(dim=-1) + + flag = env.command_manager.get_command(command_name)[:, 0] + blend = _posture_blend(env, command_name) + ramp_done = ((flag - blend).abs() < 0.02).float() + + err = torch.abs(z - target_z) + t = torch.clamp((band_zero - err) / max(band_zero - band_full, 1e-6), 0.0, 1.0) + z_gate = t * t * (3.0 - 2.0 * t) + + quat = asset.data.root_link_quat_w + cos_tilt = 1.0 - 2.0 * (quat[:, 1] ** 2 + quat[:, 2] ** 2) + cos_full = math.cos(math.radians(tilt_full_deg)) + cos_zero = math.cos(math.radians(tilt_zero_deg)) + u = torch.clamp((cos_tilt - cos_zero) / max(cos_full - cos_zero, 1e-6), 0.0, 1.0) + tilt_gate = u * u * (3.0 - 2.0 * u) + + return torch.exp(-((v / vel_std) ** 2)) * z_gate * tilt_gate * ramp_done + + +def posture_rise_bootstrap( + env: ManagerBasedRlEnv, + command_name: str, + max_height: float, + max_vz: float | None = None, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """Upward-vz reward, active only when STAND is commanded and z < max_height. + + The standup-env lesson: destination-only rewards have zero gradient at + zero motion, so "stay seated and eat the L1" is a local optimum — paying + for the rise *motion* itself makes any attempt immediately positive. + Gated off above ``max_height`` (set just ABOVE the stand target so the + final cm still pays; gating at exactly STAND_Z parks the policy short). + Zero whenever SIT is commanded, so it can never fight the descent. + ``max_vz`` caps the rewarded speed (any rise ≥ the cap earns the same, so + an explosive launch can't out-earn a gentle one). + """ + asset = env.scene[asset_cfg.name] + sit = env.command_manager.get_command(command_name)[:, 0] + z = torch.nan_to_num( + asset.data.root_link_pos_w[:, 2] - env.scene.terrain.env_origins[:, 2], nan=0.0 + ) + vz = torch.nan_to_num(asset.data.root_link_lin_vel_w[:, 2], nan=0.0) + return torch.clamp(vz, min=0.0, max=max_vz) * (z < max_height).float() * (1.0 - sit) + + +def trunk_upward_velocity_penalty( + env: ManagerBasedRlEnv, + max_up_vel: float = 0.08, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """Penalty on upward trunk velocity beyond ``max_up_vel``. + + Mirror of ``trunk_downward_velocity_penalty`` for the rise: charges every + step of a too-fast (violent) stand-up, so the explosive rise can't be + amortised against arriving-standing reward. Zero at rest, for any rise + slower than the cap, and for all downward motion. Introduce via + curriculum AFTER the rise is discovered (attempt-tax lesson). + """ + asset = env.scene[asset_cfg.name] + vz = torch.nan_to_num(asset.data.root_link_lin_vel_w[:, 2], nan=0.0) + return -torch.clamp(vz - max_up_vel, min=0.0) +# ============================================================================== +# Roulade (forward roll) task — episodic dynamic maneuver +# ============================================================================== +# +# Third attempt at the roulade. What the first two taught us: +# • origin/roulade (phase-clock + time-windowed reward stages): plateaued +# face-down at ~90° — time windows are keyframes-in-time, campable local +# optima (the sit/standup lesson exactly). Also integrated -ω_y as forward +# progress, which by this codebase's own convention (face-down = +90° pitch +# = rotation about +y, see set_random_ground_state) is the WRONG SIGN — the +# progress reward paid for backward rotation. +# • origin/roulade later commits (keyframe imitation): same waypoint-camping +# family, dropped per feedback-episodic-pose-landing. +# +# This design uses the proven episodic recipe instead: +# • ONE dense progress signal: paid INCREMENTS of the max-so-far cumulative +# forward rotation (potential-based — a camping policy earns zero/step, a +# full roll earns exactly 2π worth no matter the path or speed). +# • Landing rewards (composite product, upright, height, rise velocity) are +# gated on ROLL COMPLETION (max rotation ≥ threshold) — state-based gates, +# not clock-based. "Do nothing" earns nothing; standing at spawn earns +# nothing; only rolling opens the standing-attractor annuity. +# • Reverse curriculum via mid-roll spawns (the face-up partial-roll trick +# that fixed back-recovery): a slice of episodes starts pitched 50°–185° +# into the roll, tucked, optionally with forward angular momentum, and the +# rotation accumulator is initialized to the spawn angle so the progress +# accounting stays consistent. +# +# RUN-1 LESSON (2026-08): with unsupported rotation counting and uncapped +# paid rate, the optimal policy is a violent ballistic whip ("breakdance") — +# same 2π, finishes sooner, more discounted annuity. Doesn't transfer. Fixes: +# • SUPPORT GATE: the accumulator only integrates while some robot geom +# touches the terrain (robot_ground_contact sensor) — a real roulade never +# leaves the ground; airborne rotation now earns nothing and cannot open +# the completion gate. +# • HEAD LATCH: the landing annuity additionally requires head-ground +# contact to have occurred while accum was in the first-quadrant window — +# "went over the head" is a requirement, not a 0.5-weight suggestion. +# • PAID-RATE CAP: progress increments are capped at max_paid_rate; rotation +# faster than the cap FORFEITS the excess (not deferred), so speed no +# longer pays. An explicit overspeed penalty backs this up. +# +# Per-env state on the env object (created lazily, reset by +# reset_roulade_state): +# env._roulade_accum — supported-only integral of forward pitch rate (rad) +# env._roulade_max — max(accum) so far this episode (progress frontier) +# env._roulade_paid — frontier already paid out by roulade_progress +# env._roulade_head_latch — True once the head touched ground mid-first-quadrant + +# Forward-roll sign: face-down is +90° pitch = rotation about body +y +# (set_random_ground_state convention), so forward roll = POSITIVE body-frame +# ω_y. Verified empirically (see claude_experiments smoke test): a positive +# qvel about +y pitches the robot nose-down/forward and drives accum upward. +_ROULADE_FWD_SIGN = 1.0 + +# Sensor names read by the accumulator update (must match the env cfg). +_ROULADE_SUPPORT_SENSOR = "robot_ground_contact" +_ROULADE_HEAD_SENSOR = "head_ground_contact" + +# Head-latch window: head-ground contact while accum is inside this window +# marks the episode as a genuine over-the-head roll. In a real roulade the +# head plants at ~60–120° of body rotation; the window is generous around it. +_HEAD_LATCH_LO = math.radians(20.0) +_HEAD_LATCH_HI = math.radians(170.0) + +# Head-top axis in jaw_soft's LOCAL frame (measured empirically 2026-08-13: +# world-up expressed in jaw_soft's frame with the robot settled at HOME). +# The latch requires this axis to point DOWN at contact — "the flat top of +# the head on the floor", not the face or the side of the shell (run-5 fix: +# the run-4 policy rolled over the shoulder, which still touched jaw_soft). +_HEAD_TOP_AXIS = (0.882, 0.0, 0.471) +# dot(top_axis_world, -z) threshold. Measured landmarks (trunk pitched 110°): +# passive face-plant (neck at HOME) reads +0.6, full chin-tuck (neck_pitch −1, +# head_pitch +1) reads −0.99 — 0.3 accepts partial tucks while staying far +# from any face/side contact. +_HEAD_TOP_DOWN_MIN = 0.3 + +# Sagittal flatness gate on the accumulator (run-5): in a clean forward roll +# the body's LATERAL axis stays horizontal the whole way — its world-z +# component is 2(q_y·q_z + q_w·q_x) ≈ 0 for ANY amount of pure pitch, and +# grows toward ±1 as the roll goes over the shoulder instead. Full rotation +# credit while the lateral axis is within ~30° of horizontal, zero beyond +# ~60°: a side roll does not count as rotation, earns no progress, and never +# opens the landing gate. +_FLAT_FULL = 0.5 # |lateral_axis_z| = sin(30°): full credit below +_FLAT_ZERO = 0.866 # sin(60°): zero credit above + + +def _lateral_axis_z(quat: torch.Tensor) -> torch.Tensor: + """World-z component of the body's lateral (y) axis. 0 = flat/sagittal.""" + return 2.0 * (quat[:, 2] * quat[:, 3] + quat[:, 0] * quat[:, 1]) + + +def _head_top_down(env: ManagerBasedRlEnv, asset: Entity) -> torch.Tensor: + """True where the head-top axis points at the floor (dot with -z > min).""" + if not hasattr(env, "_roulade_head_body_id"): + ids, _ = asset.find_bodies("jaw_soft") + env._roulade_head_body_id = ids[0] + q = asset.data.body_link_quat_w[:, env._roulade_head_body_id] + w, x, y, z = q[:, 0], q[:, 1], q[:, 2], q[:, 3] + a, b, c = _HEAD_TOP_AXIS + # z-component of R(q) @ axis_local + axis_world_z = ( + 2.0 * (x * z - w * y) * a + 2.0 * (y * z + w * x) * b + (1.0 - 2.0 * (x * x + y * y)) * c + ) + return axis_world_z < -_HEAD_TOP_DOWN_MIN + + +def _sensor_any_contact(env: ManagerBasedRlEnv, name: str) -> torch.Tensor | None: + if name not in env.scene.sensors: + return None + found = env.scene.sensors[name].data.found + return (found.view(found.shape[0], -1) > 0).any(dim=-1) + + +def _roulade_state(env: ManagerBasedRlEnv) -> tuple: + if not hasattr(env, "_roulade_accum"): + z = torch.zeros(env.num_envs, device=env.device) + env._roulade_accum = z.clone() + env._roulade_max = z.clone() + env._roulade_paid = z.clone() + env._roulade_head_latch = torch.zeros(env.num_envs, dtype=torch.bool, device=env.device) + env._roulade_last_update_step = -1 + return env._roulade_accum, env._roulade_max, env._roulade_paid + + +def _update_roulade_accum(env: ManagerBasedRlEnv, asset: Entity) -> None: + """Integrate forward pitch rate into the per-env rotation accumulator. + + Step-guarded so that multiple reward terms reading the accumulator in the + same control step don't double-integrate. The frontier (max) only moves + forward; backward rocking (wind-up) neither pays nor un-pays. + + SUPPORT GATE (run-1 fix): rotation is integrated only while the robot + touches the terrain — a roulade is a supported motion; ballistic flips + accumulate nothing, so they neither get paid nor open the completion gate. + + Also latches env._roulade_head_latch when the head touches the ground + while accum is inside the first-quadrant window — the landing annuity + requires this, making "over the head" a hard requirement of the task. + """ + _roulade_state(env) + step = int(env.common_step_counter) + if step != env._roulade_last_update_step: + omega_fwd = _ROULADE_FWD_SIGN * asset.data.root_link_ang_vel_b[:, 1] + delta = torch.nan_to_num(omega_fwd, nan=0.0) * env.step_dt + supported = _sensor_any_contact(env, _ROULADE_SUPPORT_SENSOR) + if supported is not None: + delta = delta * supported.float() + # Sagittal flatness gate (run-5): side/shoulder rolls don't count. + y_z = torch.nan_to_num(_lateral_axis_z(asset.data.root_link_quat_w), nan=1.0).abs() + t = torch.clamp((_FLAT_ZERO - y_z) / (_FLAT_ZERO - _FLAT_FULL), 0.0, 1.0) + delta = delta * (t * t * (3.0 - 2.0 * t)) + env._roulade_accum = env._roulade_accum + delta + env._roulade_max = torch.maximum(env._roulade_max, env._roulade_accum) + + head_contact = _sensor_any_contact(env, _ROULADE_HEAD_SENSOR) + if head_contact is not None: + in_window = (env._roulade_accum > _HEAD_LATCH_LO) & ( + env._roulade_accum < _HEAD_LATCH_HI + ) + # Run-5: contact must be with the FLAT TOP of the head (top axis + # pointing at the floor) — face/side shell contacts don't latch. + env._roulade_head_latch = env._roulade_head_latch | ( + head_contact & in_window & _head_top_down(env, asset) + ) + env._roulade_last_update_step = step + + +def _roulade_completion_gate( + env: ManagerBasedRlEnv, + gate_lo: float, + gate_hi: float, + require_head: bool = False, +) -> torch.Tensor: + """Smoothstep on the progress frontier: 0 below gate_lo rad, 1 above gate_hi. + + State-based replacement for the old phase-clock landing window — it can + only be opened by actually rotating (while SUPPORTED — the accumulator is + contact-gated), so neither pre-roll standing nor a ballistic flip collects. + With require_head=True the gate additionally requires the head latch — + the episode must have rolled over the head to unlock the landing annuity. + """ + _, max_accum, _ = _roulade_state(env) + t = torch.clamp((max_accum - gate_lo) / max(gate_hi - gate_lo, 1e-6), 0.0, 1.0) + gate = t * t * (3.0 - 2.0 * t) + if require_head: + gate = gate * env._roulade_head_latch.float() + return gate + + +def reset_roulade_state( + env: ManagerBasedRlEnv, + env_ids: torch.Tensor, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, + standing_prob: float = 0.5, + midroll_prob: float = 0.5, + standing_z_min: float = 0.11, + standing_z_max: float = 0.12, + standing_tilt_max: float = 0.0, + forward_vel_range: tuple = (0.0, 0.0), + midroll_pitch_min: float = math.radians(50.0), + midroll_pitch_max: float = math.radians(185.0), + midroll_z_min: float = 0.05, + midroll_z_max: float = 0.10, + midroll_omega_range: tuple = (0.0, 0.0), + tuck_overrides: Optional[dict] = None, + tuck_factor_range: tuple = (0.3, 1.0), + joint_noise_std: float = 0.0, +): + """Reset to a standing start or a mid-roll state (reverse curriculum). + + Standing bucket: upright (±standing_tilt_max pitch/roll noise), random yaw, + HOME joints (left from reset_robot_joints), z in [standing_z_min, _max]. + ``forward_vel_range`` is the élan hook: a per-env forward base velocity + (body x, mapped to world through the spawn yaw) sampled uniformly — 0 for + a standstill roll, widen it later to train rolls out of a walk. + + Mid-roll bucket: pitched ``midroll_pitch_min..max`` into the roll (90° = + on the head, 180° = on the back), random yaw, legs lerped HOME→tuck by a + per-env factor in ``tuck_factor_range``, z in [midroll_z_min, _max], + optional forward angular momentum from ``midroll_omega_range``. The + rotation accumulator is initialized to the spawn pitch so progress + accounting (and the completion gates) stay consistent: a 170° spawn only + gets paid for the remaining ~190°. + """ + if env_ids is None or len(env_ids) == 0: + return + env_ids = env_ids.to(env.device, dtype=torch.long) + num = len(env_ids) + asset: Entity = env.scene[asset_cfg.name] + accum, max_accum, paid = _roulade_state(env) + + total = standing_prob + midroll_prob + is_mid = torch.rand(num, device=env.device) < (midroll_prob / max(total, 1e-6)) + + yaw = torch.rand(num, device=env.device) * 2 * np.pi - np.pi + cy = torch.cos(yaw * 0.5) + sy = torch.sin(yaw * 0.5) + + # Pitch per bucket: small noise for standing, mid-roll angle otherwise. + pitch = (torch.rand(num, device=env.device) * 2 - 1) * standing_tilt_max + mid_pitch = ( + torch.rand(num, device=env.device) * (midroll_pitch_max - midroll_pitch_min) + + midroll_pitch_min + ) + pitch = torch.where(is_mid, mid_pitch, pitch) + roll = (torch.rand(num, device=env.device) * 2 - 1) * max(standing_tilt_max, math.radians(5.0)) + + cp = torch.cos(pitch * 0.5); sp = torch.sin(pitch * 0.5) + cr = torch.cos(roll * 0.5); sr = torch.sin(roll * 0.5) + # ZYX intrinsic Euler → quaternion (yaw * pitch * roll), as in + # set_random_ground_state. + qw = cr * cp * cy + sr * sp * sy + qx = sr * cp * cy - cr * sp * sy + qy = cr * sp * cy + sr * cp * sy + qz = cr * cp * sy - sr * sp * cy + quat = torch.stack([qw, qx, qy, qz], dim=1) + + z_stand = torch.rand(num, device=env.device) * (standing_z_max - standing_z_min) + standing_z_min + z_mid = torch.rand(num, device=env.device) * (midroll_z_max - midroll_z_min) + midroll_z_min + new_z = torch.where(is_mid, z_mid, z_stand) + + env.sim.data.qpos[env_ids, 2] = new_z + env.sim.data.qpos[env_ids, 3:7] = quat + env.sim.data.qvel[env_ids, :6] = 0.0 + + servo_ids = _servo_joint_ids(env, asset) + + # Mid-roll joints: lerp HOME → tuck on the overridden joints, noise on all + # servo joints (passive_* backlash hinges must stay at 0). + mid_env_ids = env_ids[is_mid] + if len(mid_env_ids) > 0 and tuck_overrides: + u = ( + torch.rand(len(mid_env_ids), device=env.device) + * (tuck_factor_range[1] - tuck_factor_range[0]) + + tuck_factor_range[0] + ) + for jnt_idx, angle in tuck_overrides.items(): + col = 7 + servo_ids[jnt_idx] + home = env.sim.data.qpos[mid_env_ids, col] + env.sim.data.qpos[mid_env_ids, col] = home + u * (angle - home) + if len(mid_env_ids) > 0 and joint_noise_std > 0.0: + cols = torch.tensor([7 + j for j in servo_ids], device=env.device, dtype=torch.long) + noise = torch.randn(len(mid_env_ids), len(cols), device=env.device) * joint_noise_std + env.sim.data.qpos[mid_env_ids.unsqueeze(1), cols.unsqueeze(0)] += noise + + # Mid-roll forward angular momentum: rotation about body +y. MuJoCo free + # joint qvel[3:6] is the angular velocity in the BODY frame, so [0, ω, 0] + # is the forward-roll axis regardless of spawn yaw (verified in the smoke + # test — a yawed spawn still rolls straight ahead in its own frame). + if len(mid_env_ids) > 0 and midroll_omega_range[1] > 0.0: + omega = ( + torch.rand(len(mid_env_ids), device=env.device) + * (midroll_omega_range[1] - midroll_omega_range[0]) + + midroll_omega_range[0] + ) + env.sim.data.qvel[mid_env_ids, 4] = _ROULADE_FWD_SIGN * omega + + # Élan hook: forward base velocity for STANDING spawns, body x → world xy + # through the spawn yaw. (0, 0) = standstill start, disabled. + stand_env_ids = env_ids[~is_mid] + if len(stand_env_ids) > 0 and forward_vel_range[1] > 0.0: + vx = ( + torch.rand(len(stand_env_ids), device=env.device) + * (forward_vel_range[1] - forward_vel_range[0]) + + forward_vel_range[0] + ) + yaw_s = yaw[~is_mid] + env.sim.data.qvel[stand_env_ids, 0] = vx * torch.cos(yaw_s) + env.sim.data.qvel[stand_env_ids, 1] = vx * torch.sin(yaw_s) + + # Progress accounting: standing starts at 0, mid-roll at the spawn pitch. + spawn_angle = torch.where(is_mid, mid_pitch, torch.zeros_like(mid_pitch)) + accum[env_ids] = spawn_angle + max_accum[env_ids] = spawn_angle + paid[env_ids] = spawn_angle + # Head latch: mid-roll spawns are considered already past the head phase + # (the reverse curriculum teaches roll COMPLETION; requiring a latch they + # never had the chance to earn would keep their landing gate shut forever). + # Standing spawns must earn it by actually rolling over the head. + env._roulade_head_latch[env_ids] = is_mid + + +def roulade_progress( + env: ManagerBasedRlEnv, + target_angle: float = 2 * math.pi, + max_paid_rate: float = 3.0, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """Pay increments of the progress frontier, up to one full roll. + + reward = Δ(min(max_accum, target)) / (step_dt · target), CAPPED at + max_paid_rate rad/s of paid rotation. Nothing to farm by camping + face-down (0/step), rocking below the frontier (0/step), or spinning past + 2π (clamped). The accumulator is support-gated, so airborne rotation pays + nothing either. + + max_paid_rate (run-1 fix): rotation faster than the cap FORFEITS the + excess — the paid pointer still jumps to the frontier, it just pays the + capped amount. A violent whip therefore collects LESS total progress + reward than a controlled ≤cap roll, instead of the same total sooner. + """ + asset: Entity = env.scene[asset_cfg.name] + _update_roulade_accum(env, asset) + _, max_accum, paid = _roulade_state(env) + new_paid = torch.clamp(max_accum, max=target_angle) + delta = torch.clamp(new_paid - torch.clamp(paid, max=target_angle), min=0.0) + delta = torch.clamp(delta, max=max_paid_rate * env.step_dt) + env._roulade_paid = torch.maximum(paid, new_paid) + return delta / (env.step_dt * target_angle) + + +def roulade_head_pivot( + env: ManagerBasedRlEnv, + sensor_name: str = "head_ground_contact", + angle_lo: float = math.radians(30.0), + angle_hi: float = math.radians(240.0), + rate_norm: float = 2.0, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """Reward head-ground contact while rotating forward mid-roll. + + contact × window(accum ∈ [angle_lo, angle_hi]) × clamp(ω_fwd/rate_norm, 0, 1) + × (0.3 + 0.7·top_down). + The rate factor is the anti-camping guard: a face-planted robot resting its + head on the floor has ω_fwd ≈ 0 and earns nothing — the term only pays for + pivoting OVER the head. The top_down factor (run-5) aligns this dense + shaping with the latch: any head contact mid-roll pays 30%, contact on the + FLAT TOP (chin tucked) pays full — the gradient that teaches the tuck. + """ + asset: Entity = env.scene[asset_cfg.name] + _update_roulade_accum(env, asset) + accum, _, _ = _roulade_state(env) + + if sensor_name not in env.scene.sensors: + return torch.zeros(env.num_envs, device=env.device) + found = env.scene.sensors[sensor_name].data.found + contact = (found.view(found.shape[0], -1) > 0).any(dim=-1).float() + + in_window = ((accum > angle_lo) & (accum < angle_hi)).float() + omega_fwd = _ROULADE_FWD_SIGN * asset.data.root_link_ang_vel_b[:, 1] + rate = torch.clamp(torch.nan_to_num(omega_fwd, nan=0.0) / rate_norm, 0.0, 1.0) + top = 0.3 + 0.7 * _head_top_down(env, asset).float() + return contact * in_window * rate * top + + +def roulade_landing_composite( + env: ManagerBasedRlEnv, + target_height: float, + height_std: float, + upright_std: float, + pose_std: float, + joint_indices: list, + gate_lo: float = math.radians(260.0), + gate_hi: float = math.radians(330.0), + target_overrides: Optional[dict] = None, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """standing_composite_score × completion gate. + + The big annuity: once the roll is (nearly) complete, every step spent + standing at HOME pose pays — finishing on the feet and staying there + dominates every partial outcome. Zero before gate_lo of rotation, so the + standing spawn cannot farm it by doing nothing. + """ + asset: Entity = env.scene[asset_cfg.name] + _update_roulade_accum(env, asset) + score = standing_composite_score( + env, + target_height=target_height, + height_std=height_std, + upright_std=upright_std, + pose_std=pose_std, + joint_indices=joint_indices, + target_overrides=target_overrides, + asset_cfg=asset_cfg, + ) + return score * _roulade_completion_gate(env, gate_lo, gate_hi, require_head=True) + + +def roulade_upright_after_roll( + env: ManagerBasedRlEnv, + gate_lo: float = math.radians(260.0), + gate_hi: float = math.radians(330.0), + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """Linear cos(tilt) × completion gate — bootstrap pull toward vertical. + + Gradient from ANY orientation (the composite is near-zero far from the + goal), but only after the roll: before gate_lo it is exactly zero, so it + cannot oppose the flip the way the old always-on upright term did. + """ + asset: Entity = env.scene[asset_cfg.name] + _update_roulade_accum(env, asset) + quat = asset.data.root_link_quat_w + upright = 1.0 - 2.0 * (quat[:, 1].pow(2) + quat[:, 2].pow(2)) + return torch.clamp(upright, min=0.0) * _roulade_completion_gate( + env, gate_lo, gate_hi, require_head=True + ) + + +def roulade_height_after_roll( + env: ManagerBasedRlEnv, + target_height: float, + std: float = 0.04, + gate_lo: float = math.radians(260.0), + gate_hi: float = math.radians(330.0), + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """Broad height Gaussian × completion gate — pull up to standing height.""" + asset: Entity = env.scene[asset_cfg.name] + _update_roulade_accum(env, asset) + z = torch.nan_to_num( + asset.data.root_link_pos_w[:, 2] - env.scene.terrain.env_origins[:, 2], nan=0.0 + ) + g = torch.exp(-((z - target_height) / std) ** 2) + return g * _roulade_completion_gate(env, gate_lo, gate_hi, require_head=True) + + +def roulade_landing_sharp( + env: ManagerBasedRlEnv, + target_height: float, + height_std: float = 0.015, + upright_std: float = 0.3, + gate_lo: float = math.radians(260.0), + gate_hi: float = math.radians(330.0), + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """Tight-std upright × height Gaussians × completion gate — the last mile. + + Run-4 fix for the 27°-lean / 1-cm-crouch end basin: the broad landing + composite (upright_std 0.40) scores ~0.5 at that pose, so the policy + parks there. This is standup's two-layer lesson — the broad layers reach, + the sharp layers finish. At 27° tilt this term scores ~0.1 (real + gradient); at vertical it pays ~1. + """ + asset: Entity = env.scene[asset_cfg.name] + _update_roulade_accum(env, asset) + quat = asset.data.root_link_quat_w + tilt_sq = 2.0 * (quat[:, 1].pow(2) + quat[:, 2].pow(2)) + upright_g = torch.exp(-tilt_sq / (upright_std * upright_std)) + z = torch.nan_to_num( + asset.data.root_link_pos_w[:, 2] - env.scene.terrain.env_origins[:, 2], nan=0.0 + ) + height_g = torch.exp(-((z - target_height) / height_std) ** 2) + gate = _roulade_completion_gate(env, gate_lo, gate_hi, require_head=True) + return upright_g * height_g * gate + + +def roulade_stand_tax( + env: ManagerBasedRlEnv, + target_height: float, + gate_lo: float = math.radians(260.0), + gate_hi: float = math.radians(330.0), + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """SELF-NEGATING height L1 below target, active only after roll completion. + + Returns −max(0, target − z) × completion_gate — use a POSITIVE weight + (penalty sign convention). The run-3 fix for post-roll crumple-camping: + the gated landing rewards made standing better than lying in a heap, but + the heap itself was FREE — with only positive gated terms, "stay crumpled" + collects ≈0/step, a comfortable basin (the standup static-sit lesson: + the basin must be net NEGATIVE to force the rise). The gate keeps the + roll itself untaxed, and requires the head latch so a no-roll episode + can't be punished into weird avoidance behaviors. + """ + asset: Entity = env.scene[asset_cfg.name] + _update_roulade_accum(env, asset) + z = torch.nan_to_num( + asset.data.root_link_pos_w[:, 2] - env.scene.terrain.env_origins[:, 2], nan=0.0 + ) + shortfall = torch.clamp(target_height - z, min=0.0) + return -shortfall * _roulade_completion_gate(env, gate_lo, gate_hi, require_head=True) + + +def roulade_rise_velocity( + env: ManagerBasedRlEnv, + max_height: float = 0.125, + gate_lo: float = math.radians(180.0), + gate_hi: float = math.radians(260.0), + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """com_upward_velocity × late-roll gate — bootstrap the exit rise. + + The second half of a roulade (supine → sitting-up → standing) is the + face-up recovery problem, and the standup env proved end-state rewards + alone have zero gradient at zero motion there: pay for rising vz directly. + Gated to open from ~180° (on the back) so pre-roll bobbing earns nothing, + and gated off above max_height so it can't be farmed by hopping. + """ + asset: Entity = env.scene[asset_cfg.name] + _update_roulade_accum(env, asset) + z = torch.nan_to_num( + asset.data.root_link_pos_w[:, 2] - env.scene.terrain.env_origins[:, 2], nan=0.0 + ) + vz = torch.nan_to_num(asset.data.root_link_lin_vel_w[:, 2], nan=0.0) + reward = torch.clamp(vz, min=0.0) * (z < max_height).float() + return reward * _roulade_completion_gate(env, gate_lo, gate_hi, require_head=True) + + +def roulade_overspeed_penalty( + env: ManagerBasedRlEnv, + omega_max: float = 4.0, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """max(0, |ω_y| − omega_max)² — quadratic tax on whip-speed rotation. + + Positive quantity; use a negative weight. Complements the paid-rate cap + in roulade_progress: the cap removes the INCENTIVE to rotate faster than + ~3 rad/s, this adds an explicit COST above omega_max, so "violent" is + strictly worse than "controlled" rather than merely not-better. A + controlled full roll (~2–3 rad/s average) never touches it. + """ + asset: Entity = env.scene[asset_cfg.name] + omega_y = torch.nan_to_num(asset.data.root_link_ang_vel_b[:, 1], nan=0.0) + excess = torch.clamp(omega_y.abs() - omega_max, min=0.0) + return excess.pow(2) + + +def roulade_flatness_penalty( + env: ManagerBasedRlEnv, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """(lateral-axis world-z)² — dense gradient toward a sagittal roll. + + Positive quantity; use a negative weight. Zero when standing, zero + through an arbitrarily deep CLEAN forward roll (pure pitch keeps the + lateral axis horizontal), up to 1 when tipped fully onto a shoulder. + The accumulator's flatness gate makes side rolls unprofitable; this term + adds the per-step gradient that steers back toward the plane. + """ + asset: Entity = env.scene[asset_cfg.name] + return torch.nan_to_num(_lateral_axis_z(asset.data.root_link_quat_w), nan=0.0).pow(2) + + +def roulade_sagittal_penalty( + env: ManagerBasedRlEnv, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """Rotation out of the sagittal plane: body-frame ω_x² + ω_z² (positive; + use a negative weight). ω_y is the roll axis and stays free.""" + asset: Entity = env.scene[asset_cfg.name] + omega_b = asset.data.root_link_ang_vel_b + return torch.nan_to_num(omega_b[:, 0].pow(2) + omega_b[:, 2].pow(2), nan=0.0) + + +def roulade_lateral_velocity_penalty( + env: ManagerBasedRlEnv, + asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, +) -> torch.Tensor: + """Body-frame lateral (y) linear velocity² — keeps the roll straight.""" + asset: Entity = env.scene[asset_cfg.name] + return torch.nan_to_num(asset.data.root_link_lin_vel_b[:, 1].pow(2), nan=0.0) diff --git a/src/mjlab_microduck/tasks/microduck_ball_kick_env_cfg.py b/src/mjlab_microduck/tasks/microduck_ball_kick_env_cfg.py new file mode 100644 index 0000000..34ea20c --- /dev/null +++ b/src/mjlab_microduck/tasks/microduck_ball_kick_env_cfg.py @@ -0,0 +1,661 @@ +"""Microduck BallKick task — kick a ball forward with one foot (KICK_FOOT flag). + +Episodic policy: the robot starts STANDING (HOME pose + noise) with a 70mm / +15g ball sitting just in front of its kicking foot (KICK_FOOT below — train a +right-footed and a left-footed policy as two separate runs). The goal is to +kick the ball forward (robot's heading at reset) at BALL_TARGET_SPEED while +keeping balance and staying robust to external pushes, then settle back into +a clean stand. + +Key design decisions: + - The policy is BLIND to the ball (no ball obs in the actor): the real robot + has no ball sensing — the operator aims the robot at the ball. Robustness + to placement error comes from ±2cm ball-position DR at reset instead. The + CRITIC does see ball pos/vel (asymmetric actor-critic) so the value + function can anticipate the kick payoff. + - No phase command: the kick reward is available from t=0 and an earlier + kick collects more ball-rolling reward, so the policy kicks immediately. + At deployment: hard ONNX swap to this policy (à la jump/ground-pick), it + kicks, then auto-swap back after ~2s. + - Right-foot kick is enforced geometrically + economically: the ball spawns + at the right toe, and an always-on LEFT-foot-grounded reward makes the + left leg the support leg (lifting it costs reward every step; anti-hop). + - Kick reward is LINEAR in ball forward speed (clamped at 5 m/s), not a + saturating tanh — "as hard as possible" needs gradient at high speeds. + - Obs layout is the unified 61D actor layout (twist + zero-padded head/body + command slots) so the runtime can hard-swap ONNX files with one buffer. + +DR / noise / regularization: velocity-parity, copied from the standup env +(which is itself matched to velocity — the recipe with proven transfer). +Task reward mass ~10 ≈ velocity's ~11, so the shared regularizer weights act +at the same relative strength. +""" + +import math +from copy import deepcopy + +# ── Kicking foot: "right" or "left" ─────────────────────────────────────────── +# Flips the ball spawn side and the support-foot (anti-hop) sensor. Everything +# else is left/right symmetric (HOME pose has mirrored signs). Train the two +# policies as separate runs — wandb experiment/run name follows this flag. +KICK_FOOT = "right" +assert KICK_FOOT in ("right", "left") + +# Symmetry — must stay OFF: the kick task is inherently one-footed. +ENABLE_SYMMETRY = False + +# ── Domain randomisation (matched to velocity / standup) ───────────────────── +ENABLE_COM_RANDOMIZATION = True +ENABLE_HEAD_COM_RANDOMIZATION = True +ENABLE_KP_RANDOMIZATION = False +ENABLE_KD_RANDOMIZATION = False +ENABLE_MASS_INERTIA_RANDOMIZATION = True +ENABLE_JOINT_FRICTION_RANDOMIZATION = True +ENABLE_ARMATURE_RANDOMIZATION = True +ENABLE_VELOCITY_PUSHES = True +ENABLE_IMU_ORIENTATION_RANDOMIZATION = True +ENABLE_ENCODER_BIAS = True + +# ── Ranges (matched to velocity / standup) ─────────────────────────────────── +COM_RANDOMIZATION_RANGE = 0.003 # ramped to 0.015 via curriculum +HEAD_COM_RANDOMIZATION_RANGE = 0.003 # ramped to 0.01 via curriculum +MASS_INERTIA_RANDOMIZATION_RANGE = (0.95, 1.05) +ARMATURE_RANDOMIZATION_RANGE = (0.9, 1.1) +JOINT_FRICTION_RANDOMIZATION_RANGE = (0.9, 1.1) +ENCODER_BIAS_RANGE = (-0.015, 0.015) +KP_RANDOMIZATION_RANGE = (0.85, 1.15) # unused (kp DR off) +KD_RANDOMIZATION_RANGE = (0.9, 1.1) # unused (kd DR off) +VELOCITY_PUSH_INTERVAL_S = (3.0, 6.0) +VELOCITY_PUSH_RANGE = (-0.3, 0.3) # ramped in via push curriculum +IMU_ORIENTATION_RANDOMIZATION_ANGLE = 6.0 + +# ── Task constants ──────────────────────────────────────────────────────────── +# Long enough for kick + several seconds of ball-rolling reward + settle-back. +EPISODE_LENGTH_S = 5.0 + +# 70mm-diameter / 15g ball (see ball.xml). +BALL_RADIUS = 0.035 +# Nominal ball-center offset in the robot's yaw frame. Measured at HOME: foot +# centers at (0, ±0.042), toe tip x≈0.034. With radius 0.035 and ±0.015 noise +# the ball's rear surface is at worst x=0.040 → always ≥6mm clear of the toe. +# (0.08 ± 0.02 allowed spawn-penetration with the toe: the solver ejected the +# ball at reset — free "kick" reward with no kick.) +# The lateral sign follows the kicking foot (right = -y, left = +y). +BALL_OFFSET_X = 0.09 +BALL_OFFSET_ABS_Y = 0.042 +# Uniform ± placement noise per axis. This is the DR that makes the BLIND +# policy's swing robust to real-world aiming error. +BALL_POS_NOISE_XY = 0.015 + +# Target kick speed (m/s). The first trained policy (linear reward capped at +# 5 m/s) kicked much harder than needed — this tames the kick to a gentle, +# controlled tap. NOTE: the kick reward weights below are scaled to keep the +# at-target payoff ≈ +3/step regardless of this value (weight ≈ 3/target for +# the capped term) — if you change the target, rescale the weights with it. +BALL_TARGET_SPEED = 1.0 + +# Trunk standing height (measured natural equilibrium at HOME — see standup env). +STAND_Z = 0.115 + +_LEG_JOINTS = [0, 1, 2, 3, 4, 9, 10, 11, 12, 13] +_NECK_JOINTS = [5, 6, 7, 8] + +from mjlab.envs import ManagerBasedRlEnvCfg +from mjlab.envs.mdp import dr +from mjlab.envs.mdp.actions import JointPositionActionCfg +from mjlab.managers import ( + CurriculumTermCfg, + EventTermCfg, + ObservationTermCfg, + RewardTermCfg, + TerminationTermCfg, +) +from mjlab.managers.scene_entity_config import SceneEntityCfg +from mjlab.rl import ( + RslRlOnPolicyRunnerCfg, + RslRlModelCfg, +) +from mjlab.sensor import ContactMatch, ContactSensorCfg +from mjlab.tasks.velocity import mdp +from mjlab.tasks.velocity.velocity_env_cfg import make_velocity_env_cfg +from mjlab.utils.noise import UniformNoiseCfg as Unoise + +from mjlab_microduck.robot.microduck_constants import ( + MICRODUCK_BALL_CFG, + MICRODUCK_STANDUP_ROBOT_CFG, +) +from mjlab_microduck.tasks import mdp as microduck_mdp +from mjlab_microduck.tasks.microduck_velocity_env_cfg import HEAD_BODY_NAMES +from mjlab_microduck.tasks.symmetry import PpoWithSymmetryCfg, SYMMETRY_CFG + + +def make_microduck_ball_kick_env_cfg( + play: bool = False, + kick_foot: str | None = None, +) -> ManagerBasedRlEnvCfg: + """Create the Microduck BallKick environment configuration. + + ``kick_foot`` overrides the module-level KICK_FOOT flag (used by tests); + normal training just sets the flag at the top of this file. + """ + kick_foot = kick_foot or KICK_FOOT + assert kick_foot in ("right", "left") + support_foot = "left" if kick_foot == "right" else "right" + + feet_ground_cfg = ContactSensorCfg( + name="feet_ground_contact", + primary=ContactMatch( + mode="geom", + pattern=r"^(left_foot_collision|right_foot_collision)$", + entity="robot", + ), + secondary=ContactMatch(mode="body", pattern="terrain"), + fields=("found", "force"), + reduce="netforce", + num_slots=1, + track_air_time=True, + ) + + # Support-foot sensor: the non-kicking foot must stay planted through the kick. + support_foot_ground_cfg = ContactSensorCfg( + name="support_foot_ground_contact", + primary=ContactMatch( + mode="geom", + pattern=rf"^{support_foot}_foot_collision$", + entity="robot", + ), + secondary=ContactMatch(mode="body", pattern="terrain"), + fields=("found",), + reduce="netforce", + num_slots=1, + ) + + self_collision_cfg = ContactSensorCfg( + name="self_collision", + primary=ContactMatch(mode="subtree", pattern="trunk_base", entity="robot"), + secondary=ContactMatch(mode="subtree", pattern="trunk_base", entity="robot"), + fields=("found",), + reduce="none", + num_slots=1, + ) + + foot_frictions_geom_names = ("left_foot_collision", "right_foot_collision") + + # ── Base config ─────────────────────────────────────────────────────────── + cfg = make_velocity_env_cfg() + + # Full-collision robot (same spec as standup/ground-pick): the ball must be + # able to contact the whole leg, not just the foot pads of the walk model. + # Robot MUST stay the first entity (set_random_ground_state and the base + # reset events write robot root state at qpos[:, 0:7]). + cfg.scene.entities = { + "robot": MICRODUCK_STANDUP_ROBOT_CFG, + "ball": MICRODUCK_BALL_CFG, + } + cfg.scene.sensors = (feet_ground_cfg, support_foot_ground_cfg, self_collision_cfg) + cfg.viewer.body_name = "trunk_base" + + cfg.episode_length_s = EPISODE_LENGTH_S + + # Extra contact headroom for the ball (ball-terrain + ball-robot contacts + # on top of the full-collision robot's budget). + cfg.sim.nconmax = 50 + + # ── Actions ─────────────────────────────────────────────────────────────── + joint_pos_action = cfg.actions["joint_pos"] + assert isinstance(joint_pos_action, JointPositionActionCfg) + joint_pos_action.scale = 1.0 + + # ── Rewards: drop walking-specific terms ────────────────────────────────── + for name in [ + "track_linear_velocity", + "track_angular_velocity", + "air_time", + "foot_clearance", + "foot_swing_height", + "foot_slip", + "pose", # gait-conditioned; replaced by pose_target_match below + "soft_landing", # velocity removes it + ]: + if name in cfg.rewards: + del cfg.rewards[name] + + # ── Rewards: kick objective — TARGET speed, not max speed ──────────────── + # Two-sided landscape peaking at BALL_TARGET_SPEED (0.25 m/s — a gentle tap): + # • ball_forward_velocity, linear and CAPPED at the target: dense + # bootstrap gradient from the first touch. Weight 12.0 = 3.0/target so + # the at-target payoff stays ≈ +3/step (with the old weight 3.0 the + # payoff would be 0.75/step — too weak vs the ~7/step standing stack to + # justify the swing's transient pose/upright cost). + # • ball_speed_overshoot_penalty (weight -4.0): each m/s above target + # costs -4/step while it persists. Needed because the cap alone does + # NOT tame the kick — a harder kick keeps the ball at the cap for more + # steps, so total (per-step × rolling time) reward still grows with + # strike speed. + # Slopes stay asymmetric (+12/(m/s) below, -4/(m/s) above): the optimum + # sits at the target, but erring hard stays much cheaper than not kicking + # (net reward only hits 0 at ~1.0 m/s, 4× the target). + cfg.rewards["ball_forward_velocity"] = RewardTermCfg( + func=microduck_mdp.ball_forward_velocity, + weight=12.0, + params={"asset_name": "ball", "max_speed": BALL_TARGET_SPEED}, + ) + cfg.rewards["ball_speed_overshoot"] = RewardTermCfg( + func=microduck_mdp.ball_speed_overshoot_penalty, + weight=-4.0, + params={"asset_name": "ball", "target_speed": BALL_TARGET_SPEED}, + ) + + # Support foot: binary +1 while the non-kicking foot touches the ground. + # Always-on anti-hop — swinging the kicking leg is free, lifting the + # support foot costs this every step. Also suppresses walking/dribbling + # exploits (any gait loses this reward half the time). + cfg.rewards["support_foot_grounded"] = RewardTermCfg( + func=microduck_mdp.single_foot_grounded_reward, + weight=2.0, + params={"sensor_name": support_foot_ground_cfg.name}, + ) + + # ── Rewards: stand cleanly before/after the kick ────────────────────────── + # Legs at HOME. std=0.5 is deliberately loose: the kick itself is a big + # transient leg deviation and must stay affordable. + cfg.rewards["pose_stand_legs"] = RewardTermCfg( + func=microduck_mdp.pose_target_match, + weight=2.0, + params={ + "std": 0.5, + "joint_indices": _LEG_JOINTS, + "target_overrides": None, # HOME = standing + }, + ) + + # Neck/head at HOME (no head command in this task; tighter std — the head + # takes no part in the kick). + cfg.rewards["pose_stand_neck"] = RewardTermCfg( + func=microduck_mdp.pose_target_match, + weight=1.0, + params={ + "std": 0.3, + "joint_indices": _NECK_JOINTS, + "target_overrides": None, + }, + ) + + # Upright — velocity's exact recipe (weight 2.0, std²=0.05). + cfg.rewards["upright"].params["asset_cfg"].body_names = ("trunk_base",) + cfg.rewards["upright"].weight = 2.0 + cfg.rewards["upright"].params["std"] = math.sqrt(0.05) + + # Trunk at standing height — discourages crouching/squatting as a kick prep. + cfg.rewards["height_stand"] = RewardTermCfg( + func=microduck_mdp.height_target_gaussian, + weight=1.0, + params={ + "std": 0.04, + "target_height": STAND_Z, + "asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",)), + }, + ) + + # ── Sim2real regularisers — velocity parity (see standup env rationale) ── + cfg.rewards["action_rate_l2"].weight = -0.1 # stage-0; curriculum ramps to -1.0 + cfg.rewards["body_ang_vel"].params["asset_cfg"].body_names = ("trunk_base",) + cfg.rewards["body_ang_vel"].weight = -0.05 + cfg.rewards["angular_momentum"].weight = -0.02 + + cfg.rewards["self_collisions"] = RewardTermCfg( + func=mdp.self_collision_cost, + weight=-1.0, + params={"sensor_name": self_collision_cfg.name}, + ) + + # ── Observations (unified 61D actor layout, ball-blind) ─────────────────── + del cfg.observations["actor"].terms["base_lin_vel"] + + cfg.observations["critic"].terms["base_lin_vel"] = ObservationTermCfg( + func=mdp.base_lin_vel, scale=1.0, + ) + # No terrain-height sensor in this env (flat only) — drop the base + # template's sensor-backed terms, like standup/ground-pick do. + del cfg.observations["critic"].terms["foot_height"] + del cfg.observations["actor"].terms["height_scan"] + del cfg.observations["critic"].terms["height_scan"] + + gravity_term_name = "projected_gravity" + cfg.observations["actor"].terms[gravity_term_name] = deepcopy( + cfg.observations["actor"].terms[gravity_term_name] + ) + cfg.observations["actor"].terms["base_ang_vel"] = deepcopy( + cfg.observations["actor"].terms["base_ang_vel"] + ) + + # IMU obs delay — match velocity's 2026-07 audit values. + cfg.observations["actor"].terms["base_ang_vel"].delay_min_lag = 0 + cfg.observations["actor"].terms["base_ang_vel"].delay_max_lag = 1 + cfg.observations["actor"].terms["base_ang_vel"].delay_update_period = 64 + cfg.observations["actor"].terms[gravity_term_name].delay_min_lag = 0 + cfg.observations["actor"].terms[gravity_term_name].delay_max_lag = 1 + cfg.observations["actor"].terms[gravity_term_name].delay_update_period = 64 + + # Obs noise — matched to the velocity env. + cfg.observations["actor"].terms["base_ang_vel"].noise = Unoise(n_min=-0.03, n_max=0.03) + cfg.observations["actor"].terms[gravity_term_name].noise = Unoise(n_min=-0.01, n_max=0.01) + cfg.observations["actor"].terms["joint_pos"].noise = Unoise(n_min=-0.001, n_max=0.001) + cfg.observations["actor"].terms["joint_vel"].noise = Unoise(n_min=-0.25, n_max=0.25) + + # IMU mounting-misalignment DR (obs-level, actor only). + if ENABLE_IMU_ORIENTATION_RANDOMIZATION: + av = cfg.observations["actor"].terms["base_ang_vel"] + av.func = microduck_mdp.base_ang_vel_imu_misaligned + av.params = {"max_angle_deg": IMU_ORIENTATION_RANDOMIZATION_ANGLE} + g = cfg.observations["actor"].terms[gravity_term_name] + g.func = microduck_mdp.projected_gravity_imu_misaligned + g.params = {"max_angle_deg": IMU_ORIENTATION_RANDOMIZATION_ANGLE} + + # 1-ctrl-step lag on joint_vel (Dynamixel moving-average, see velocity env). + cfg.observations["actor"].terms["joint_vel"] = deepcopy( + cfg.observations["actor"].terms["joint_vel"] + ) + cfg.observations["actor"].terms["joint_vel"].delay_min_lag = 1 + cfg.observations["actor"].terms["joint_vel"].delay_max_lag = 1 + cfg.observations["actor"].terms["joint_vel"].delay_update_period = 0 + + # Deepcopy joint_pos/joint_vel per group so the encoder-bias `biased` flag + # below applies to the actor only. + passive_excluded = SceneEntityCfg("robot", joint_names=(r"^(?!passive_).*",)) + for grp in ("actor", "critic"): + for term in ("joint_pos", "joint_vel"): + cfg.observations[grp].terms[term] = deepcopy(cfg.observations[grp].terms[term]) + cfg.observations[grp].terms[term].params["asset_cfg"] = deepcopy(passive_excluded) + + if ENABLE_ENCODER_BIAS: + cfg.events["encoder_bias"].params["bias_range"] = ENCODER_BIAS_RANGE + cfg.observations["actor"].terms["joint_pos"].params["biased"] = True + cfg.observations["critic"].terms["joint_pos"].params["biased"] = False + else: + cfg.events.pop("encoder_bias", None) + + # Command obs slots — unified layout parity: [twist(3), head(4), body(6)], + # head/body zero-padded (no head/body pose control in this task). + for group in ("actor", "critic"): + cfg.observations[group].terms["head_command"] = ObservationTermCfg( + func=microduck_mdp.zero_command_padding, params={"dim": 4}, + ) + cfg.observations[group].terms["body_command"] = ObservationTermCfg( + func=microduck_mdp.zero_command_padding, params={"dim": 6}, + ) + + # CRITIC-ONLY ball state (asymmetric actor-critic): the actor stays blind + # to the ball (no ball sensing on the real robot), the critic uses it to + # predict the kick payoff. + cfg.observations["critic"].terms["ball_position"] = ObservationTermCfg( + func=microduck_mdp.ball_pos_in_base, params={"asset_name": "ball"}, + ) + cfg.observations["critic"].terms["ball_velocity"] = ObservationTermCfg( + func=microduck_mdp.ball_vel_in_base, params={"asset_name": "ball"}, + ) + + # ── Command: tiny noise around zero (obs-shape parity only) ─────────────── + command = cfg.commands["twist"] + command.rel_standing_envs = 0.0 + command.rel_heading_envs = 0.0 + command.heading_command = False + command.ranges.heading = None + command.resampling_time_range = (EPISODE_LENGTH_S, EPISODE_LENGTH_S * 2) + command.debug_vis = False + command.ranges.lin_vel_x = (-0.01, 0.01) + command.ranges.lin_vel_y = (-0.01, 0.01) + command.ranges.ang_vel_z = (-0.05, 0.05) + cfg.commands["twist"] = microduck_mdp.VelocityCommandCommandOnlyCfg(**vars(command)) + + # ── Terminations ────────────────────────────────────────────────────────── + # fell_over KEPT (robot starts standing and must stay up through the kick). + cfg.terminations["nan_state"] = TerminationTermCfg( + func=microduck_mdp.robot_state_is_nan, + time_out=False, + ) + + # ── Events ──────────────────────────────────────────────────────────────── + cfg.events["expand_bam_friction_fields"] = EventTermCfg( + func=microduck_mdp.expand_bam_friction_fields, + mode="startup", + ) + + cfg.events["reset_action_history"] = EventTermCfg( + func=microduck_mdp.reset_action_history, + mode="reset", + ) + cfg.events["foot_friction"].params["asset_cfg"].geom_names = foot_frictions_geom_names + cfg.events["foot_friction"].params["ranges"] = (0.7, 1.3) # match velocity + + # Joint noise on the standing start: deployment hands off from the walk / + # velstand policy, whose settled stand won't match HOME exactly. + cfg.events["reset_robot_joints"].params["position_range"] = (-0.05, 0.05) + + # Standing-only start (reuses the standup env's ground-state machinery for + # the noisy upright spawn: random yaw ± tilt noise, z near equilibrium). + cfg.events["set_ground_state"] = EventTermCfg( + func=microduck_mdp.set_random_ground_state, + mode="reset", + params={ + "face_down_prob": 0.0, + "face_up_prob": 0.0, + "sitting_prob": 0.0, + "standing_prob": 1.0, + "sitting_tilt_max": math.radians(5), # ±5° pitch/roll on the stand + "standing_z_min": 0.11, + "standing_z_max": 0.12, + }, + ) + + # Ball placement — MUST come after set_ground_state (events run in dict + # insertion order; the ball position derives from the final robot pose). + # Also stores the per-env kick direction (robot heading at reset). + ball_offset_y = -BALL_OFFSET_ABS_Y if kick_foot == "right" else BALL_OFFSET_ABS_Y + cfg.events["reset_ball"] = EventTermCfg( + func=microduck_mdp.reset_ball_in_front_of_foot, + mode="reset", + params={ + "offset": (BALL_OFFSET_X, ball_offset_y), + "noise_xy": BALL_POS_NOISE_XY, + "ball_radius": BALL_RADIUS, + "asset_name": "ball", + }, + ) + + if ENABLE_VELOCITY_PUSHES: + interval = (0.5, 1.0) if play else VELOCITY_PUSH_INTERVAL_S + cfg.events["push_robot"] = EventTermCfg( + func=mdp.push_by_setting_velocity, + mode="interval", + interval_range_s=interval, + params={ + "velocity_range": { + "x": VELOCITY_PUSH_RANGE, + "y": VELOCITY_PUSH_RANGE, + }, + "asset_cfg": SceneEntityCfg("robot"), + }, + ) + + if ENABLE_COM_RANDOMIZATION: + cfg.events["randomize_com"] = EventTermCfg( + func=dr.body_ipos, + mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",)), + "operation": "add", + "ranges": (-COM_RANDOMIZATION_RANGE, COM_RANDOMIZATION_RANGE), + }, + ) + + if ENABLE_HEAD_COM_RANDOMIZATION: + cfg.events["randomize_head_com"] = EventTermCfg( + func=dr.body_ipos, + mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot", body_names=HEAD_BODY_NAMES), + "operation": "add", + "ranges": (-HEAD_COM_RANDOMIZATION_RANGE, HEAD_COM_RANDOMIZATION_RANGE), + }, + ) + + if ENABLE_ARMATURE_RANDOMIZATION: + cfg.events["randomize_armature"] = EventTermCfg( + func=dr.joint_armature, + mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot", joint_names=(r".*",)), + "operation": "scale", + "ranges": ARMATURE_RANDOMIZATION_RANGE, + }, + ) + + if ENABLE_KP_RANDOMIZATION or ENABLE_KD_RANDOMIZATION: + kp_range = KP_RANDOMIZATION_RANGE if ENABLE_KP_RANDOMIZATION else (1.0, 1.0) + kd_range = KD_RANDOMIZATION_RANGE if ENABLE_KD_RANDOMIZATION else (1.0, 1.0) + cfg.events["randomize_motor_gains"] = EventTermCfg( + func=microduck_mdp.randomize_delayed_actuator_gains, + mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot"), + "operation": "scale", + "kp_range": kp_range, + "kd_range": kd_range, + }, + ) + + if ENABLE_MASS_INERTIA_RANDOMIZATION: + _mi_lo, _mi_hi = MASS_INERTIA_RANDOMIZATION_RANGE + cfg.events["randomize_mass_inertia"] = EventTermCfg( + func=dr.pseudo_inertia, + mode="startup", + params={ + "asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",)), + "alpha_range": (math.log(_mi_lo) / 2.0, math.log(_mi_hi) / 2.0), + }, + ) + + if ENABLE_JOINT_FRICTION_RANDOMIZATION: + cfg.events["randomize_joint_friction"] = EventTermCfg( + func=microduck_mdp.randomize_bam_friction, + mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot"), + "scale_range": JOINT_FRICTION_RANDOMIZATION_RANGE, + }, + ) + + # ── Terrain: flat only (a ball on rough terrain is a different task) ────── + cfg.scene.terrain.terrain_type = "plane" + cfg.scene.terrain.terrain_generator = None + + # ── Curriculum ──────────────────────────────────────────────────────────── + del cfg.curriculum["terrain_levels"] + del cfg.curriculum["command_vel"] + + # action_rate ramp — velocity's exact stages (-0.1 → -1.0 by iter 1500). + # NOTE: the kick is a fast one-shot swing; if the converged kick is too + # weak, softening the ramp end (-1.0 → -0.6) is the first knob to try + # (motion-blocker vs dynamic-task tradeoff, see standup regularization notes). + cfg.curriculum["action_rate_weight"] = CurriculumTermCfg( + func=microduck_mdp.reward_weight, + params={ + "reward_name": "action_rate_l2", + "weight_stages": [ + {"step": 0, "weight": -0.1}, + {"step": 500 * 24, "weight": -0.2}, + {"step": 750 * 24, "weight": -0.4}, + {"step": 1000 * 24, "weight": -0.6}, + {"step": 1250 * 24, "weight": -0.8}, + {"step": 1500 * 24, "weight": -1.0}, + ], + }, + ) + + if ENABLE_COM_RANDOMIZATION: + cfg.curriculum["com_range"] = CurriculumTermCfg( + func=microduck_mdp.com_range_curriculum, + params={ + "event_name": "randomize_com", + "range_stages": [ + {"step": 0, "range": 0.003}, + {"step": 500 * 24, "range": 0.005}, + {"step": 1000 * 24, "range": 0.01}, + {"step": 1500 * 24, "range": 0.015}, + ], + }, + ) + + if ENABLE_HEAD_COM_RANDOMIZATION: + cfg.curriculum["head_com_range"] = CurriculumTermCfg( + func=microduck_mdp.com_range_curriculum, + params={ + "event_name": "randomize_head_com", + "range_stages": [ + {"step": 0, "range": 0.003}, + {"step": 500 * 24, "range": 0.005}, + {"step": 1000 * 24, "range": 0.01}, + ], + }, + ) + + if ENABLE_VELOCITY_PUSHES: + # Ramp pushes in AFTER the kick skill starts forming: a full-strength + # shove during the one-legged strike phase at iter 0 would tax the + # discovery of the swing itself (same timing lesson as standup). + cfg.curriculum["push_magnitude"] = CurriculumTermCfg( + func=microduck_mdp.push_curriculum, + params={ + "event_name": "push_robot", + "push_stages": [ + {"step": 0, "velocity_range": {"x": (0.0, 0.0), "y": (0.0, 0.0)}}, + {"step": 500 * 24, "velocity_range": {"x": (-0.08, 0.08), "y": (-0.08, 0.08)}}, + {"step": 1000 * 24, "velocity_range": {"x": VELOCITY_PUSH_RANGE, "y": VELOCITY_PUSH_RANGE}}, + ], + }, + ) + + return cfg + + +# ── RL runner config ────────────────────────────────────────────────────────── + +MicroduckBallKickRlCfg = RslRlOnPolicyRunnerCfg( + actor=RslRlModelCfg( + hidden_dims=(512, 256, 128), + activation="elu", + obs_normalization=True, # normalizer MUST be baked into ONNX by export.py + distribution_cfg={ + "class_name": "GaussianDistribution", + "init_std": 1.0, + "std_type": "scalar", + }, + ), + critic=RslRlModelCfg( + hidden_dims=(512, 256, 128), + activation="elu", + obs_normalization=True, + ), + algorithm=PpoWithSymmetryCfg( + value_loss_coef=1.0, + use_clipped_value_loss=True, + clip_param=0.2, + entropy_coef=0.01, + num_learning_epochs=5, + num_mini_batches=4, + learning_rate=1.0e-3, + schedule="adaptive", + gamma=0.99, + lam=0.95, + desired_kl=0.01, + max_grad_norm=1.0, + symmetry_cfg=SYMMETRY_CFG if ENABLE_SYMMETRY else None, + ), + wandb_project="mjlab_microduck", + experiment_name=f"ball_kick_{KICK_FOOT}", + run_name=f"ball_kick_{KICK_FOOT}", + save_interval=250, + num_steps_per_env=24, + max_iterations=10_000, +) diff --git a/src/mjlab_microduck/tasks/microduck_ground_pick_env_cfg.py b/src/mjlab_microduck/tasks/microduck_ground_pick_env_cfg.py new file mode 100644 index 0000000..94df8b5 --- /dev/null +++ b/src/mjlab_microduck/tasks/microduck_ground_pick_env_cfg.py @@ -0,0 +1,706 @@ +"""Microduck ground pick task. + +Episodic policy that crouches to bring its mouth tip AS CLOSE AS POSSIBLE to the +ground WITHOUT touching it (correctly oriented, mouth pointing down), then +returns to a clean standing pose — all while remaining stable and robust to +pushes. The obs/action spaces are identical to the walking policy so the two +can be switched at runtime with a single key-press. + +Objectif espace-tâche (pas de pose DOWN) : mouth_ground_proximity tire la bouche +vers le sol, head_impact_penalty (fort) interdit le contact -> équilibre = bouche +juste au-dessus ; mouth_perpendicular_to_ground l'oriente vers le bas. + +Phase encoding (in the command slot, 3-D): + command = [cos(2π·phase), sin(2π·phase), 0] + phase ∈ [0, 0.5] → approach (reward mouth going down) + phase ∈ [0.5, 1] → return (reward returning to standing pose) + +Phase is randomised per env on episode reset to de-correlate environments and +avoid synchronised oscillations. PERIOD = 4 s (2 s down + 2 s up). + +── mjlab 1.3.0 + canonical BAM ──────────────────────────────────────────────── +Migrated to match the velocity env's sim2real machinery: fixed (non-accumulating) +CoM / head-CoM / mass-inertia / friction / armature DR, obs-level IMU misalignment, +encoder-bias, obs normalization. The task-specific REGULARIZATION is deliberately +kept HEAVIER than velocity's (slow careful reaching wants more damping than +walking) — see the regularisation block. +""" + +import math +from copy import deepcopy + +# Symmetry — disabled for v1.5: SYMMETRY_CFG's _OBS_PERM is hardcoded for the +# old 51D obs layout and breaks on the new 61D obs (which includes the +# head_command/body_command padding). All v1.5 envs run with symmetry off +# until SYMMETRY_CFG gets rewritten for the new obs structure. +ENABLE_SYMMETRY = False + +# ── Domain randomisation toggles (matched to the velocity env) ──────────────── +ENABLE_COM_RANDOMIZATION = True +ENABLE_HEAD_COM_RANDOMIZATION = True +ENABLE_KP_RANDOMIZATION = False # off, like velocity +ENABLE_KD_RANDOMIZATION = False +ENABLE_MASS_INERTIA_RANDOMIZATION = True +ENABLE_JOINT_FRICTION_RANDOMIZATION = True # scales BAM friction budget per-env +ENABLE_JOINT_DAMPING_RANDOMIZATION = False +ENABLE_ARMATURE_RANDOMIZATION = True # reflected rotor inertia (affects BAM) +ENABLE_VELOCITY_PUSHES = True +ENABLE_IMU_ORIENTATION_RANDOMIZATION = True # applied at obs level (per-env rotation) +ENABLE_ENCODER_BIAS = True # actor obs sees joint_pos + per-env bias +ENABLE_BASE_ORIENTATION_RANDOMIZATION = False +ENABLE_NECK_OFFSET_RANDOMIZATION = False # disabled — head is used for the task + +# ── Ranges (matched to the velocity env) ────────────────────────────────────── +COM_RANDOMIZATION_RANGE = 0.003 # ±3mm initial, ramped via curriculum +HEAD_COM_RANDOMIZATION_RANGE = 0.003 # ±3mm initial, ramped via curriculum +MASS_INERTIA_RANDOMIZATION_RANGE = (0.95, 1.05) +KP_RANDOMIZATION_RANGE = (0.85, 1.15) +KD_RANDOMIZATION_RANGE = (0.9, 1.1) +JOINT_FRICTION_RANDOMIZATION_RANGE = (0.9, 1.1) +ARMATURE_RANDOMIZATION_RANGE = (0.9, 1.1) +VELOCITY_PUSH_INTERVAL_S = (3.0, 6.0) +VELOCITY_PUSH_RANGE = (-0.15, 0.15) # geste quasi-statique -> pushes doux (±0.3 le faisait tomber même droit) +IMU_ORIENTATION_RANDOMIZATION_ANGLE = 6.0 # match velocity (was 1.0) +ENCODER_BIAS_RANGE = (-0.015, 0.015) + +from mjlab.envs import ManagerBasedRlEnvCfg +from mjlab.envs.mdp import dr +from mjlab.envs.mdp.actions import JointPositionActionCfg +from mjlab.managers import ( + CurriculumTermCfg, + EventTermCfg, + ObservationTermCfg, + RewardTermCfg, + TerminationTermCfg, +) +from mjlab.managers.scene_entity_config import SceneEntityCfg +from mjlab.rl import ( + RslRlOnPolicyRunnerCfg, + RslRlModelCfg, +) +from mjlab.sensor import ContactMatch, ContactSensorCfg +from mjlab.tasks.velocity import mdp +from mjlab.tasks.velocity.mdp import UniformVelocityCommandCfg +from mjlab.tasks.velocity.velocity_env_cfg import make_velocity_env_cfg +from mjlab.utils.noise import UniformNoiseCfg as Unoise + +from mjlab_microduck.robot.microduck_constants import MICRODUCK_GROUND_PICK_ROBOT_CFG +from mjlab_microduck.tasks import mdp as microduck_mdp +from mjlab_microduck.tasks.microduck_velocity_env_cfg import ( + MICRODUCK_ROUGH_TERRAINS_CFG, + HEAD_BODY_NAMES, +) +from mjlab_microduck.tasks.symmetry import PpoWithSymmetryCfg, SYMMETRY_CFG + + +# ── Profil de phase SEGMENTÉ (durées indépendantes) ────────────────────────── +# Au lieu de la pondération sinusoïdale (qui couple descente/palier/remontée), +# on gate les rewards par un profil à 4 segments : descente et remontée LENTES, +# palier bas COURT, repos debout long. +# Durées à GP_PERIOD = 4 s : +# descente [0, DESCENT_END) 1.5 s transition STAND->bas +# palier bas [DESCENT_END, HOLD_END) 0.2 s effleure (court) +# remontée [HOLD_END, RISE_END) 1.5 s transition bas->STAND +# repos [RISE_END, 1) 0.8 s debout +# ⚠️ RISE_END=0.80 > coupure φ=0.7 du script infer_policy : la remontée n'est +# complète que si le slot joue jusqu'à φ~1.0 (toute la période). Vérifier la +# fenêtre réelle du runtime. ⚠️ --ground-pick-period au déploiement = 4.0. +GP_PERIOD = 4.0 +DESCENT_END = 0.375 +HOLD_END = 0.425 +RISE_END = 0.80 + + +def make_microduck_ground_pick_env_cfg(play: bool = False, rough: bool = False) -> ManagerBasedRlEnvCfg: + """Create Microduck ground pick environment configuration.""" + + feet_ground_cfg = ContactSensorCfg( + name="feet_ground_contact", + primary=ContactMatch( + mode="geom", + pattern=r"^(left_foot_collision|right_foot_collision)$", + entity="robot", + ), + secondary=ContactMatch(mode="body", pattern="terrain"), + fields=("found", "force"), + reduce="netforce", + num_slots=1, + track_air_time=True, + ) + + self_collision_cfg = ContactSensorCfg( + name="self_collision", + primary=ContactMatch(mode="subtree", pattern="trunk_base", entity="robot"), + secondary=ContactMatch(mode="subtree", pattern="trunk_base", entity="robot"), + fields=("found",), + reduce="none", + num_slots=1, + ) + + # Head-on-ground impact sensor — covers the neck subtree (head_plate, + # head_shell, etc). Used by the head_impact_penalty reward to discourage + # the policy from slamming the head into the ground during the approach. + head_impact_cfg = ContactSensorCfg( + name="head_impact_contact", + primary=ContactMatch(mode="subtree", pattern="neck", entity="robot"), + secondary=ContactMatch(mode="body", pattern="terrain"), + fields=("force",), + reduce="netforce", + num_slots=1, + ) + + foot_frictions_geom_names = ("left_foot_collision", "right_foot_collision") + + # ── Base config ─────────────────────────────────────────────────────────── + cfg = make_velocity_env_cfg() + + cfg.scene.entities = {"robot": MICRODUCK_GROUND_PICK_ROBOT_CFG} + cfg.scene.sensors = (feet_ground_cfg, self_collision_cfg, head_impact_cfg) + cfg.viewer.body_name = "trunk_base" + + # ── Actions ─────────────────────────────────────────────────────────────── + joint_pos_action = cfg.actions["joint_pos"] + assert isinstance(joint_pos_action, JointPositionActionCfg) + joint_pos_action.scale = 1.0 + # No NeckOffsetJointPositionAction — head joints are part of the task motion + + # ── Rewards: remove walking-specific terms ──────────────────────────────── + for name in [ + "track_linear_velocity", + "track_angular_velocity", + "air_time", + "foot_clearance", + "foot_swing_height", + "foot_slip", + "pose", # replaced by phase-conditioned ground_pick_return_pose + ]: + if name in cfg.rewards: + del cfg.rewards[name] + + # ── Rewards: main ground pick objectives ────────────────────────────────── + + # Approach phase: reward mouth tip getting AS CLOSE AS POSSIBLE to the ground. + # target_height=0 tire la bouche vers le sol ; std=0.10 donne du gradient dès + # ~20 cm (depuis la station debout). Le "SANS TOUCHER" est assuré par + # head_impact_penalty (fort) plus bas -> l'équilibre est la bouche juste + # au-dessus du sol. Poids monté 2.0 -> 3.0 pour tirer plus près. + cfg.rewards["mouth_ground_proximity"] = RewardTermCfg( + func=microduck_mdp.mouth_ground_proximity_phased, + weight=3.0, + params={ + "asset_cfg": SceneEntityCfg("robot", site_names=["mouth_tip"]), + "std": 0.10, + "target_height": 0.0, + "command_name": "twist", + "descent_end": DESCENT_END, + "hold_end": HOLD_END, + "rise_end": RISE_END, + }, + ) + + # Approach phase: reward mouth tip x-axis pointing downward (perpendicular to ground). + # alignment ∈ [-1, 1]: 1 = x-axis perfectly vertical, 0 = horizontal, -1 = pointing up. + # Orientation : axe bouche vers le bas (perpendiculaire au sol). Poids monté + # 1.0 -> 2.0 -> "orienter correctement" est un objectif explicite. + cfg.rewards["mouth_perpendicular_to_ground"] = RewardTermCfg( + func=microduck_mdp.mouth_perpendicular_phased, + weight=2.0, + params={ + "asset_cfg": SceneEntityCfg("robot", site_names=["mouth_tip"]), + "command_name": "twist", + "descent_end": DESCENT_END, + "hold_end": HOLD_END, + "rise_end": RISE_END, + }, + ) + + # Return phase — legs. Under mjlab 1.3.0 + canonical BAM the passive jaw + # joints are no longer part of the articulation, so joint_pos is the clean + # 14-joint layout: 0-4 left leg, 5-8 neck/head, 9-13 right leg. (Was the old + # 16-joint layout [0-4, 11-15] with passive_1/passive_2 at 9,10.) + _LEG_JOINTS = [0, 1, 2, 3, 4, 9, 10, 11, 12, 13] + cfg.rewards["ground_pick_return_pose_legs"] = RewardTermCfg( + func=microduck_mdp.ground_pick_return_pose_phased, + weight=6.0, # 4->6 : renforce l'extension des jambes au relever + params={ + "std": 0.3, + "command_name": "twist", + "joint_indices": _LEG_JOINTS, + "hold_end": HOLD_END, + "rise_end": RISE_END, + }, + ) + + # Return phase — neck/head (joints 5-8): tight std to prevent backward overshoot + # and head-body collision (head geoms have no collision mesh, so self_collisions + # can't catch it — the pose reward is the only guard). + _NECK_JOINTS = [5, 6, 7, 8] + cfg.rewards["ground_pick_return_pose_neck"] = RewardTermCfg( + func=microduck_mdp.ground_pick_return_pose_phased, + weight=6.0, + params={ + "std": 0.15, + "command_name": "twist", + "joint_indices": _NECK_JOINTS, + "hold_end": HOLD_END, + "rise_end": RISE_END, + }, + ) + + # Aide au RELEVER : tronc vertical récompensé UNIQUEMENT pendant la remontée + # (pondéré max(0,-sin) comme le retour de pose). Le retour de pose seul ne + # garantit pas l'équilibre dynamique en se relevant ; ce terme pousse le tronc + # à rester vertical pendant l'extension. Gaté sur le retour -> ne gêne PAS le + # penché avant de l'approche (upright always-on reste faible, 0.2). + cfg.rewards["return_upright"] = RewardTermCfg( + func=microduck_mdp.ground_pick_return_upright_phased, + weight=4.0, # 2->4 : aide plus fort l'équilibre du tronc au relever + params={ + "asset_cfg": SceneEntityCfg("robot"), + "std": 0.4, + "command_name": "twist", + "hold_end": HOLD_END, + "rise_end": RISE_END, + }, + ) + + # Anti-piqué : pénalise la vitesse du cou pendant la descente+palier + # (gate=0 à la remontée -> ne bride PAS le relever). Freine le plongeon de + # la tête sans l'empêcher de revenir. + cfg.rewards["neck_vel_descent"] = RewardTermCfg( + func=microduck_mdp.neck_vel_descent_penalty, + weight=-0.1, + params={ + "command_name": "twist", + "joint_indices": _NECK_JOINTS, + "hold_end": HOLD_END, + }, + ) + + # Poids aléatoire "dans la bouche" au relever (objet soulevé, 10-40 g/épisode). + # Reward de poids 0 : sert de hook par-step qui applique le POIDS de l'objet + # comme force externe au mouth_tip, gaté sur la remontée (phase >= HOLD_END). + # Le payload lui-même est tiré au reset par l'event sample_mouth_payload. + cfg.rewards["mouth_payload_force"] = RewardTermCfg( + func=microduck_mdp.apply_mouth_payload_force, + weight=0.0, + params={ + "asset_cfg": SceneEntityCfg( + "robot", body_names=["jaw_soft"], site_names=["mouth_tip"] + ), + "command_name": "twist", + "hold_end": HOLD_END, + }, + ) + + # ── Rewards: stability (kept from velocity env, weights tuned for this task) + + # Upright: reduced weight — the robot needs to lean forward during approach. + cfg.rewards["upright"].params["asset_cfg"].body_names = ("trunk_base",) + cfg.rewards["upright"].weight = 0.2 + + cfg.rewards["body_ang_vel"].params["asset_cfg"].body_names = ("trunk_base",) + cfg.rewards["body_ang_vel"].weight = -0.05 + + cfg.rewards["angular_momentum"].weight = -0.02 + + cfg.rewards["soft_landing"].weight = -1e-5 + + # Keep BOTH feet in contact throughout the pick (les pieds ne décollent pas). + # NB: c'est le CONTACT seulement ; la bascule du pied sur la cheville est gérée + # par feet_flat ci-dessous (pas par ce terme). + cfg.rewards["feet_grounded"] = RewardTermCfg( + func=microduck_mdp.feet_grounded_reward, + weight=3.0, + params={"sensor_name": feet_ground_cfg.name}, + ) + + # Pieds À PLAT. feet_grounded ne voit que le CONTACT (found par pied) : un pied + # qui PIVOTE sur la cheville (bascule sur la tranche/pointe) en gardant un point + # de contact passe au travers -> "il se retourne le pied". feet_flat_penalty + # projette la gravité dans le repère du site pied : à plat le site Z est + # vertical (xy²≈0) ; toute bascule -> xy²>0. Interdit donc le retournement du + # pied sur l'axe cheville. + cfg.rewards["feet_flat"] = RewardTermCfg( + func=microduck_mdp.feet_flat_penalty, + weight=-2.0, + params={ + "asset_cfg": SceneEntityCfg("robot", site_names=["left_foot", "right_foot"]), + }, + ) + + # ── Rewards: regularisation (HEAVIER than velocity — slow careful reaching) ─ + # Deliberately kept heavier than the velocity env: the ground-pick motion is + # slow and precise, so strong smoothness aids transfer (unlike the dynamic + # standup recovery, where heavy regularisation blocked the motion). + + # Action smoothness — flat heavy weight (ramped in via the curriculum below, + # which ends at -2.0 rather than velocity's -1.0). + cfg.rewards["action_rate_l2"] = RewardTermCfg( + func=mdp.action_rate_l2, weight=-2.0 + ) + + # Neck/head smoothness — higher weight because head is heavily used. + cfg.rewards["neck_action_rate_l2"] = RewardTermCfg( + func=microduck_mdp.neck_action_rate_l2, weight=-1.0 + ) + + # Joint torque penalty — increased to further penalise fast/forceful moves. + cfg.rewards["joint_torques_l2"] = RewardTermCfg( + func=microduck_mdp.joint_torques_l2, weight=-5e-3 + ) + + # Self-collision — head and neck could clip the legs during deep crouch. + cfg.rewards["self_collisions"] = RewardTermCfg( + func=mdp.self_collision_cost, + weight=-1.0, + params={"sensor_name": self_collision_cfg.name}, + ) + + # No-touch enforcement : on ne VEUT PAS de contact (la bouche doit rester juste + # au-dessus). Pénalité forte et seuil bas -> tout contact au sol coûte cher. + # C'est ce terme qui, contre mouth_ground_proximity, fixe l'équilibre "au plus + # près sans toucher". + cfg.rewards["head_impact_penalty"] = RewardTermCfg( + func=microduck_mdp.body_impact_cost, + weight=-2.0, + params={"sensor_name": head_impact_cfg.name, "threshold": 1.0}, + ) + + # ── Observations (identical 61D layout to walking policy) ────────────────── + del cfg.observations["actor"].terms["base_lin_vel"] + + cfg.observations["critic"].terms["base_lin_vel"] = ObservationTermCfg( + func=mdp.base_lin_vel, scale=1.0, + ) + # mjlab 1.3.0 base template adds sensor-based foot_height + height_scan obs. + # Ground-pick has no terrain-height sensor (and drops the walking foot + # rewards), so remove these terms. + del cfg.observations["critic"].terms["foot_height"] + del cfg.observations["actor"].terms["height_scan"] + del cfg.observations["critic"].terms["height_scan"] + + gravity_term_name = "projected_gravity" + cfg.observations["actor"].terms[gravity_term_name] = deepcopy( + cfg.observations["actor"].terms[gravity_term_name] + ) + cfg.observations["actor"].terms["base_ang_vel"] = deepcopy( + cfg.observations["actor"].terms["base_ang_vel"] + ) + + # Sensor delay — matches velocity env + cfg.observations["actor"].terms["base_ang_vel"].delay_min_lag = 0 + cfg.observations["actor"].terms["base_ang_vel"].delay_max_lag = 3 + cfg.observations["actor"].terms["base_ang_vel"].delay_update_period = 64 + cfg.observations["actor"].terms[gravity_term_name].delay_min_lag = 0 + cfg.observations["actor"].terms[gravity_term_name].delay_max_lag = 3 + cfg.observations["actor"].terms[gravity_term_name].delay_update_period = 64 + + # Observation noise — matches velocity env + cfg.observations["actor"].terms["base_ang_vel"].noise = Unoise(n_min=-0.03, n_max=0.03) + cfg.observations["actor"].terms[gravity_term_name].noise = Unoise(n_min=-0.01, n_max=0.01) + cfg.observations["actor"].terms["joint_pos"].noise = Unoise(n_min=-0.001, n_max=0.001) + cfg.observations["actor"].terms["joint_vel"].noise = Unoise(n_min=-0.25, n_max=0.25) + + # IMU mounting-misalignment DR (match velocity): per-env constant rotation of + # the IMU-derived actor obs; critic keeps the true values. Replaces the old + # event-based randomize_imu_orientation (site_quat write — a no-op under 1.3.0). + if ENABLE_IMU_ORIENTATION_RANDOMIZATION: + av = cfg.observations["actor"].terms["base_ang_vel"] + av.func = microduck_mdp.base_ang_vel_imu_misaligned + av.params = {"max_angle_deg": IMU_ORIENTATION_RANDOMIZATION_ANGLE} + g = cfg.observations["actor"].terms[gravity_term_name] + g.func = microduck_mdp.projected_gravity_imu_misaligned + g.params = {"max_angle_deg": IMU_ORIENTATION_RANDOMIZATION_ANGLE} + + # 1-ctrl-step lag on joint_vel: the Dynamixel firmware computes + # present_velocity via a moving-average over the previous position-sample + # window, so the value the policy actually reads is ~1 control period old. + cfg.observations["actor"].terms["joint_vel"] = deepcopy( + cfg.observations["actor"].terms["joint_vel"] + ) + cfg.observations["actor"].terms["joint_vel"].delay_min_lag = 1 + cfg.observations["actor"].terms["joint_vel"].delay_max_lag = 1 + cfg.observations["actor"].terms["joint_vel"].delay_update_period = 0 + + # Deepcopy joint_pos/joint_vel per group (they share base-template objects) so + # the encoder-bias `biased` flag below applies to the actor only. The + # passive-exclusion regex is a harmless no-op now (no passive joints in the + # articulation) but kept for parity with the other envs. + passive_excluded = SceneEntityCfg("robot", joint_names=(r"^(?!passive_).*",)) + for grp in ("actor", "critic"): + for term in ("joint_pos", "joint_vel"): + cfg.observations[grp].terms[term] = deepcopy(cfg.observations[grp].terms[term]) + cfg.observations[grp].terms[term].params["asset_cfg"] = deepcopy(passive_excluded) + + # Encoder-bias DR (match velocity): actor sees joint_pos + per-env bias; critic + # keeps the true joint pos. Requires the base-template encoder_bias event. + if ENABLE_ENCODER_BIAS: + cfg.events["encoder_bias"].params["bias_range"] = ENCODER_BIAS_RANGE + cfg.observations["actor"].terms["joint_pos"].params["biased"] = True + cfg.observations["critic"].terms["joint_pos"].params["biased"] = False + else: + cfg.events.pop("encoder_bias", None) + + # ── Pad command vector to the unified 13D layout ────────────────────────── + # Ground-pick doesn't use head/body pose commands (the head is driven by the + # task's phase motion), but all microduck policies share the same 61D obs + # shape so the runtime can feed a single command buffer. The 10 trailing + # slots (head 4 + body 6) are constant zero. + for group in ("actor", "critic"): + cfg.observations[group].terms["head_command"] = ObservationTermCfg( + func=microduck_mdp.zero_command_padding, params={"dim": 4}, + ) + cfg.observations[group].terms["body_command"] = ObservationTermCfg( + func=microduck_mdp.zero_command_padding, params={"dim": 6}, + ) + + # ── Command: cyclic phase encoding ──────────────────────────────────────── + command: UniformVelocityCommandCfg = cfg.commands["twist"] + command.rel_standing_envs = 0.0 + command.rel_heading_envs = 0.0 + # Période = GP_PERIOD (6 s). Le profil segmenté (constantes en tête de fichier) + # découple descente/palier/remontée/repos : descente & remontée ~1.5 s + # (lentes -> pas de déséquilibre), palier bas ~0.6 s (court), repos ~2.4 s. + cfg.commands["twist"] = microduck_mdp.GroundPickPhaseCommandCfg( + **{**vars(command), "class_type": microduck_mdp.GroundPickPhaseCommand, "period": GP_PERIOD} + ) + + # ── Terminations ────────────────────────────────────────────────────────── + # Terminate on NaN physics (extreme contact impulses) before it corrupts obs. + cfg.terminations["nan_state"] = TerminationTermCfg( + func=microduck_mdp.robot_state_is_nan, + time_out=False, + ) + + # ── Events ──────────────────────────────────────────────────────────────── + # BAM (mjlab_frictionloss branch) writes per-env dof_frictionloss/dof_damping + # every step; this no-op event registers those fields for per-world expansion. + cfg.events["expand_bam_friction_fields"] = EventTermCfg( + func=microduck_mdp.expand_bam_friction_fields, + mode="startup", + ) + + cfg.events["reset_action_history"] = EventTermCfg( + func=microduck_mdp.reset_action_history, + mode="reset", + ) + + # Poids aléatoire "dans la bouche" : tiré par épisode (10-40 g), appliqué au + # relever par le hook mouth_payload_force. Imagine que le robot soulève un objet. + cfg.events["sample_mouth_payload"] = EventTermCfg( + func=microduck_mdp.sample_mouth_payload, + mode="reset", + params={"min_kg": 0.01, "max_kg": 0.04}, + ) + cfg.events["foot_friction"].params["asset_cfg"].geom_names = foot_frictions_geom_names + cfg.events["foot_friction"].params["ranges"] = (0.7, 1.3) # match velocity + cfg.events["reset_base"].params["pose_range"]["z"] = (0.12, 0.13) + + if ENABLE_VELOCITY_PUSHES: + # Play : intervalle espacé (2-4 s) pour juger le geste sur un comportement + # réaliste, pas sous mitraille (0.5-1 s était un stress-test agressif qui + # faisait "tomber même droit"). + interval = (2.0, 4.0) if play else VELOCITY_PUSH_INTERVAL_S + cfg.events["push_robot"] = EventTermCfg( + func=mdp.push_by_setting_velocity, + mode="interval", + interval_range_s=interval, + params={ + "velocity_range": { + "x": VELOCITY_PUSH_RANGE, + "y": VELOCITY_PUSH_RANGE, + }, + "asset_cfg": SceneEntityCfg("robot"), + }, + ) + + if ENABLE_COM_RANDOMIZATION: + # mjlab 1.3.0: stock dr.body_ipos (operation="add") reads the compile-time + # default each reset → non-accumulating natively. Replaces the old + # mdp.randomize_field/body_ipos path (a no-op under 1.3.0). + cfg.events["randomize_com"] = EventTermCfg( + func=dr.body_ipos, + mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",)), + "operation": "add", + "ranges": (-COM_RANDOMIZATION_RANGE, COM_RANDOMIZATION_RANGE), + }, + ) + + if ENABLE_HEAD_COM_RANDOMIZATION: + # Match velocity: randomize the CoM of the head-assembly bodies. + cfg.events["randomize_head_com"] = EventTermCfg( + func=dr.body_ipos, + mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot", body_names=HEAD_BODY_NAMES), + "operation": "add", + "ranges": (-HEAD_COM_RANDOMIZATION_RANGE, HEAD_COM_RANDOMIZATION_RANGE), + }, + ) + + if ENABLE_KP_RANDOMIZATION or ENABLE_KD_RANDOMIZATION: + # Dormant (KP/KD off, like velocity). NOTE: randomize_delayed_actuator_gains + # predates canonical BAM; only enable after porting it to BamActuator.set_gains. + kp_range = KP_RANDOMIZATION_RANGE if ENABLE_KP_RANDOMIZATION else (1.0, 1.0) + kd_range = KD_RANDOMIZATION_RANGE if ENABLE_KD_RANDOMIZATION else (1.0, 1.0) + cfg.events["randomize_motor_gains"] = EventTermCfg( + func=microduck_mdp.randomize_delayed_actuator_gains, + mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot"), + "operation": "scale", + "kp_range": kp_range, + "kd_range": kd_range, + }, + ) + + if ENABLE_MASS_INERTIA_RANDOMIZATION: + # Match velocity: physics-consistent mass+inertia via pseudo_inertia + # (alpha scales both by e^(2α), CoM untouched). Startup mode. The old + # custom randomize_mass_and_inertia was a no-op under mjlab 1.3.0. + _mi_lo, _mi_hi = MASS_INERTIA_RANDOMIZATION_RANGE + cfg.events["randomize_mass_inertia"] = EventTermCfg( + func=dr.pseudo_inertia, + mode="startup", + params={ + "asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",)), + "alpha_range": (math.log(_mi_lo) / 2.0, math.log(_mi_hi) / 2.0), + }, + ) + + if ENABLE_JOINT_FRICTION_RANDOMIZATION: + # Match velocity: scale BAM's friction budget per-env via the + # FrictionDRBamActuator hook (dof_frictionloss is zeroed under BAM). + cfg.events["randomize_joint_friction"] = EventTermCfg( + func=microduck_mdp.randomize_bam_friction, + mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot"), + "scale_range": JOINT_FRICTION_RANDOMIZATION_RANGE, + }, + ) + + if ENABLE_ARMATURE_RANDOMIZATION: + # Match velocity: reflected rotor inertia (non-accumulating, affects BAM). + cfg.events["randomize_armature"] = EventTermCfg( + func=dr.joint_armature, + mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot", joint_names=(r".*",)), + "operation": "scale", + "ranges": ARMATURE_RANDOMIZATION_RANGE, + }, + ) + + # NOTE: IMU mounting-misalignment is applied at the OBSERVATION level above + # (matching velocity) — the old event-based randomize_imu_orientation wrote + # site_quat, a no-op under mjlab 1.3.0. + + # ── Terrain ─────────────────────────────────────────────────────────────── + if not rough: + cfg.scene.terrain.terrain_type = "plane" + cfg.scene.terrain.terrain_generator = None + else: + cfg.scene.terrain.terrain_type = "generator" + cfg.scene.terrain.terrain_generator = MICRODUCK_ROUGH_TERRAINS_CFG + if play: + cfg.scene.terrain.terrain_generator.curriculum = False + cfg.scene.terrain.terrain_generator.num_cols = 5 + cfg.scene.terrain.terrain_generator.num_rows = 5 + + # ── Curriculum ──────────────────────────────────────────────────────────── + # Remove base curriculum terms not applicable here + if not rough: + del cfg.curriculum["terrain_levels"] + del cfg.curriculum["command_vel"] + + # Action-rate curriculum: warm up light so the gross reaching motion can form, + # then clamp down HARD (-2.0, heavier than velocity's -1.0) for smoothness. + cfg.curriculum["action_rate_weight"] = CurriculumTermCfg( + func=microduck_mdp.reward_weight, + params={ + "reward_name": "action_rate_l2", + "weight_stages": [ + {"step": 0, "weight": -0.8}, + {"step": 250 * 24, "weight": -1.5}, + {"step": 500 * 24, "weight": -2.0}, + ], + }, + ) + + # CoM-randomization range curricula — match velocity (ramp 0.003 → 0.02 trunk, + # 0.003 → 0.01 head). + if ENABLE_COM_RANDOMIZATION: + cfg.curriculum["com_range"] = CurriculumTermCfg( + func=microduck_mdp.com_range_curriculum, + params={ + "event_name": "randomize_com", + "range_stages": [ + {"step": 0, "range": 0.003}, + {"step": 500 * 24, "range": 0.005}, + {"step": 1000 * 24, "range": 0.01}, + {"step": 1500 * 24, "range": 0.015}, + {"step": 2000 * 24, "range": 0.02}, + ], + }, + ) + + if ENABLE_HEAD_COM_RANDOMIZATION: + cfg.curriculum["head_com_range"] = CurriculumTermCfg( + func=microduck_mdp.com_range_curriculum, + params={ + "event_name": "randomize_head_com", + "range_stages": [ + {"step": 0, "range": 0.003}, + {"step": 500 * 24, "range": 0.005}, + {"step": 1000 * 24, "range": 0.01}, + ], + }, + ) + + return cfg + + +# ── RL runner config ────────────────────────────────────────────────────────── + +MicroduckGroundPickRlCfg = RslRlOnPolicyRunnerCfg( + actor=RslRlModelCfg( + hidden_dims=(512, 256, 128), + activation="elu", + obs_normalization=True, # matches velocity; normalizer baked into ONNX by export.py + distribution_cfg={ + "class_name": "GaussianDistribution", + "init_std": 1.0, + "std_type": "scalar", + }, + ), + critic=RslRlModelCfg( + hidden_dims=(512, 256, 128), + activation="elu", + obs_normalization=True, + ), + algorithm=PpoWithSymmetryCfg( + value_loss_coef=1.0, + use_clipped_value_loss=True, + clip_param=0.2, + entropy_coef=0.01, + num_learning_epochs=5, + num_mini_batches=4, + learning_rate=1.0e-3, + schedule="adaptive", + gamma=0.99, + lam=0.95, + desired_kl=0.01, + max_grad_norm=1.0, + symmetry_cfg=SYMMETRY_CFG if ENABLE_SYMMETRY else None, + ), + wandb_project="mjlab_microduck", + experiment_name="ground_pick", + run_name="ground_pick", + save_interval=250, + num_steps_per_env=24, + max_iterations=20_000, +) diff --git a/src/mjlab_microduck/tasks/microduck_roller_crouch_env_cfg.py b/src/mjlab_microduck/tasks/microduck_roller_crouch_env_cfg.py new file mode 100644 index 0000000..c37507d --- /dev/null +++ b/src/mjlab_microduck/tasks/microduck_roller_crouch_env_cfg.py @@ -0,0 +1,479 @@ +"""Microduck roller crouch-glide task. + +Geste one-shot déclenché au bouton A via le slot --ground-pick du runtime : +le robot s'accroupit et glisse sur son élan (palier ~1 s), puis se relève et +rend la main à la policy roller. + +Hybride : + - physique / robot roller ← microduck_velocity_rollers_env_cfg.py + - machinerie phase one-shot ← microduck_ground_pick_env_cfg.py + (commande GroundPickPhaseCommand : [cos(2πφ), sin(2πφ), 0], période 4 s) + +Cible de hauteur « en trapèze » (haut→bas→palier 1 s→haut) via +crouch_glide_height_by_phase. Obs 61D unifié → interchangeable au runtime. +""" + +import math +from copy import deepcopy + +ENABLE_SYMMETRY = False + +# DR — repris du roller env +ENABLE_COM_RANDOMIZATION = True +ENABLE_HEAD_COM_RANDOMIZATION = True +ENABLE_MASS_INERTIA_RANDOMIZATION = True +ENABLE_JOINT_FRICTION_RANDOMIZATION = True +ENABLE_ARMATURE_RANDOMIZATION = True +ENABLE_WHEEL_FRICTION_RANDOMIZATION = True +ENABLE_VELOCITY_PUSHES = True +ENABLE_IMU_ORIENTATION_RANDOMIZATION = True +ENABLE_ENCODER_BIAS = True + +COM_RANDOMIZATION_RANGE = 0.003 +HEAD_COM_RANDOMIZATION_RANGE = 0.003 +MASS_INERTIA_RANDOMIZATION_RANGE = (0.95, 1.05) +JOINT_FRICTION_RANDOMIZATION_RANGE = (0.9, 1.1) +ARMATURE_RANDOMIZATION_RANGE = (0.9, 1.1) +VELOCITY_PUSH_INTERVAL_S = (3.0, 6.0) +VELOCITY_PUSH_RANGE = (-0.2, 0.2) +IMU_ORIENTATION_RANDOMIZATION_ANGLE = 6.0 +ENCODER_BIAS_RANGE = (-0.015, 0.015) + +ENTRY_VELOCITY_X = (0.2, 0.5) # m/s : le robot arrive en roulant + +# Timing du cycle (phase), 4 segments sur une période de 5 s : +# descente [0, DESCENT_END] = 0.10*5 = 0.5 s (se baisser) +# bas/accroupi [DESCENT_END, HOLD_END] = 0.40*5 = 2.0 s (glisse accroupie) +# remontée [HOLD_END, RISE_END] = 0.10*5 = 0.5 s (se lever) +# haut/debout [RISE_END, 1.0] = 0.40*5 = 2.0 s (repos debout) +# NB: la période DOIT matcher --ground-pick-period au déploiement (5.0). +CROUCH_PERIOD = 5.0 +DESCENT_END = 0.10 +HOLD_END = 0.50 +RISE_END = 0.60 + +# Pose ACCROUPI cible (rad, par NOM d'articulation) — composée dans +# scripts/crouch_pose_editor.py. La reward interpole DEBOUT(HOME) <-> cette pose +# selon la phase. Résolution par nom -> robuste aux roues intercalées. +# Pose DEBOUT (départ/fin du trick). Défaut = HOME du sim (convention validée +# égale à la lecture robot). Remplace ces valeurs par une lecture read_pose.py +# du robot debout si tu veux une autre station debout. +# ⚠️ au déploiement, à la fin du trick le runtime rend la main à la policy roller +# qui repart de HOME — garde STAND_POSE proche de HOME pour un retour propre. +STAND_POSE = { + # Lue sur le VRAI robot (read_pose.py) — station debout voulue pour le trick. + "left_hip_yaw": -0.0476, "left_hip_roll": -0.0629, "left_hip_pitch": -0.2869, + "left_knee": 0.9618, "left_ankle": 1.1674, + "neck_pitch": 0.6029, "head_pitch": 0.543, "head_yaw": -0.069, "head_roll": -0.0414, + "right_hip_yaw": -0.0337, "right_hip_roll": -0.0061, "right_hip_pitch": 0.1534, + "right_knee": -0.9725, "right_ankle": -1.0646, +} + +CROUCH_POSE = { + # Lue sur le VRAI robot (Dynamixel XL330, read_pose.py) — pose tenable. + "left_hip_yaw": -0.0184, + "left_hip_roll": 0.0307, + "left_hip_pitch": 1.4082, + "left_knee": 1.5248, + "left_ankle": -0.0675, + "neck_pitch": 1.0937, + "head_pitch": 1.2149, + "head_yaw": -0.0184, + "head_roll": -0.0368, + "right_hip_yaw": 0.0184, + "right_hip_roll": -0.0169, + "right_hip_pitch": -1.4757, + "right_knee": -1.5907, + "right_ankle": 0.0568, +} +CROUCH_POSE_STD = 0.4 # tolérance gaussienne par joint (rad) +CROUCH_LEAN_PITCH = 0.08 # léger penché avant pendant l'accroupi (rad ≈ 4.6°) + +from mjlab.envs import ManagerBasedRlEnvCfg +from mjlab.envs.mdp import dr +from mjlab.envs.mdp.actions import JointPositionActionCfg +from mjlab.managers import ( + CurriculumTermCfg, + EventTermCfg, + ObservationTermCfg, + RewardTermCfg, + TerminationTermCfg, +) +from mjlab.managers.scene_entity_config import SceneEntityCfg +from mjlab.rl import RslRlOnPolicyRunnerCfg, RslRlModelCfg +from mjlab.sensor import ContactMatch, ContactSensorCfg +from mjlab.tasks.velocity import mdp +from mjlab.tasks.velocity.mdp import UniformVelocityCommandCfg +from mjlab.tasks.velocity.velocity_env_cfg import make_velocity_env_cfg +from mjlab.utils.noise import UniformNoiseCfg as Unoise + +from mjlab_microduck.robot.microduck_constants import MICRODUCK_WALK_ROLLERS_ROBOT_CFG +from mjlab_microduck.tasks import mdp as microduck_mdp +from mjlab_microduck.tasks.microduck_velocity_env_cfg import HEAD_BODY_NAMES +from mjlab_microduck.tasks.symmetry import PpoWithSymmetryCfg, SYMMETRY_CFG + + +def make_microduck_roller_crouch_env_cfg(play: bool = False) -> ManagerBasedRlEnvCfg: + """Env crouch-glide sur rollers, piloté par la phase du slot ground-pick.""" + + feet_ground_cfg = ContactSensorCfg( + name="feet_ground_contact", + primary=ContactMatch( + mode="subtree", + pattern=r"^(ankle_l_v1|ankle_r_v1)$", + entity="robot", + ), + secondary=ContactMatch(mode="body", pattern="terrain"), + fields=("found", "force"), + reduce="netforce", + num_slots=1, + track_air_time=True, + ) + self_collision_cfg = ContactSensorCfg( + name="self_collision", + primary=ContactMatch(mode="subtree", pattern="trunk_base", entity="robot"), + secondary=ContactMatch(mode="subtree", pattern="trunk_base", entity="robot"), + fields=("found",), + reduce="none", + num_slots=1, + ) + + cfg = make_velocity_env_cfg() + cfg.scene.entities = {"robot": MICRODUCK_WALK_ROLLERS_ROBOT_CFG} + cfg.scene.sensors = (feet_ground_cfg, self_collision_cfg) + cfg.viewer.body_name = "trunk_base" + + joint_pos_action = cfg.actions["joint_pos"] + assert isinstance(joint_pos_action, JointPositionActionCfg) + joint_pos_action.scale = 1.0 + + # === REWARDS === + keep = {"upright", "body_ang_vel", "angular_momentum", "action_rate_l2"} + for name in list(cfg.rewards.keys()): + if name not in keep: + del cfg.rewards[name] + + cfg.rewards["upright"].params["asset_cfg"].body_names = ("trunk_base",) + cfg.rewards["upright"].weight = 2.0 + cfg.rewards["body_ang_vel"].params["asset_cfg"].body_names = ("trunk_base",) + cfg.rewards["body_ang_vel"].weight = -0.05 + cfg.rewards["angular_momentum"].weight = -0.02 + cfg.rewards["action_rate_l2"].weight = -1.0 + + # Reward principale : POSE interpolée par la phase (DEBOUT <-> ACCROUPI). + # Directive : dit au robot la configuration articulaire exacte à chaque + # instant. « Se relever » (phase->1, cible = HOME) est récompensé EXACTEMENT + # comme « s'accroupir » (palier, cible = CROUCH_POSE) — symétrique. + _pose_params = { + "command_name": "twist", + "crouch_pose": CROUCH_POSE, + "stand_pose": STAND_POSE, + "descent_end": DESCENT_END, + "hold_end": HOLD_END, + "rise_end": RISE_END, + } + cfg.rewards["crouch_glide_pose"] = RewardTermCfg( + func=microduck_mdp.crouch_glide_pose_by_phase, + weight=6.0, + params={**_pose_params, "std": CROUCH_POSE_STD}, + ) + # Bootstrap L1 : gradient constant vers la cible même quand la gaussienne + # sature loin de la pose. + cfg.rewards["crouch_glide_pose_l1"] = RewardTermCfg( + func=microduck_mdp.crouch_glide_pose_l1, + weight=2.0, + params=_pose_params, + ) + # Conserver l'élan (ne pas freiner) — indépendant de la commande. + cfg.rewards["forward_speed"] = RewardTermCfg( + func=microduck_mdp.forward_speed_reward, + weight=1.0, + params={"vel_ref": 0.2}, + ) + # Léger penché avant pendant l'accroupi -> contre la bascule arrière observée + # sur le vrai robot lors de la descente rapide. Gaté par le blend (crouch only). + cfg.rewards["crouch_forward_lean"] = RewardTermCfg( + func=microduck_mdp.crouch_forward_lean, + weight=1.0, + params={ + "command_name": "twist", + "target_pitch": CROUCH_LEAN_PITCH, + "std": 0.1, + "descent_end": DESCENT_END, + "hold_end": HOLD_END, + "rise_end": RISE_END, + }, + ) + # Stabilité de glisse + cfg.rewards["feet_flat"] = RewardTermCfg( + func=microduck_mdp.feet_flat_penalty, + weight=-2.0, + params={ + "asset_cfg": SceneEntityCfg("robot", site_names=("left_foot", "right_foot")), + "sensor_name": "feet_ground_contact", + }, + ) + cfg.rewards["self_collisions"] = RewardTermCfg( + func=mdp.self_collision_cost, + weight=-1.0, + params={"sensor_name": "self_collision"}, + ) + cfg.rewards["neck_action_rate_l2"] = RewardTermCfg( + func=microduck_mdp.neck_action_rate_l2, weight=-0.5 + ) + cfg.rewards["joint_torques_l2"] = RewardTermCfg( + func=microduck_mdp.joint_torques_l2, weight=-1e-3 + ) + + # === TERMINATIONS === + cfg.terminations["nan_state"] = TerminationTermCfg( + func=microduck_mdp.robot_state_is_nan, time_out=False, + ) + + # === EVENTS === + cfg.events["reset_action_history"] = EventTermCfg( + func=microduck_mdp.reset_action_history, mode="reset", + ) + del cfg.events["foot_friction"] + + if ENABLE_VELOCITY_PUSHES: + cfg.events["push_robot"] = EventTermCfg( + func=mdp.push_by_setting_velocity, + mode="interval", + interval_range_s=VELOCITY_PUSH_INTERVAL_S, + params={ + "velocity_range": {"x": VELOCITY_PUSH_RANGE, "y": VELOCITY_PUSH_RANGE}, + "asset_cfg": SceneEntityCfg("robot"), + }, + ) + + cfg.events["reset_base"].params["pose_range"]["z"] = (0.1335, 0.1435) + # Vitesse d'entrée : le robot démarre en roulant vers l'avant (élan à conserver + # pendant l'accroupi). Injectée via reset_root_state_uniform (état par défaut + # PROPRE + range), et NON via push_by_setting_velocity en mode reset qui, lui, + # additionne à la vitesse racine courante (potentiellement divergente) et fait + # exploser le free-joint de la base -> NaN. Voir le commentaire ENTRY_VELOCITY_X. + cfg.events["reset_base"].params["velocity_range"] = {"x": ENTRY_VELOCITY_X} + + if ENABLE_WHEEL_FRICTION_RANDOMIZATION: + cfg.events["randomize_wheel_friction"] = EventTermCfg( + func=dr.dof_frictionloss, + mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot", joint_names=(r"^passive_.*wheel",)), + "operation": "abs", + "ranges": (0.000, 0.000), + }, + ) + if ENABLE_COM_RANDOMIZATION: + cfg.events["randomize_com"] = EventTermCfg( + func=dr.body_ipos, mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",)), + "operation": "add", + "ranges": (-COM_RANDOMIZATION_RANGE, COM_RANDOMIZATION_RANGE), + }, + ) + if ENABLE_HEAD_COM_RANDOMIZATION: + cfg.events["randomize_head_com"] = EventTermCfg( + func=dr.body_ipos, mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot", body_names=HEAD_BODY_NAMES), + "operation": "add", + "ranges": (-HEAD_COM_RANDOMIZATION_RANGE, HEAD_COM_RANDOMIZATION_RANGE), + }, + ) + if ENABLE_MASS_INERTIA_RANDOMIZATION: + _mi_lo, _mi_hi = MASS_INERTIA_RANDOMIZATION_RANGE + cfg.events["randomize_mass_inertia"] = EventTermCfg( + func=dr.pseudo_inertia, mode="startup", + params={ + "asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",)), + "alpha_range": (math.log(_mi_lo) / 2.0, math.log(_mi_hi) / 2.0), + }, + ) + if ENABLE_JOINT_FRICTION_RANDOMIZATION: + cfg.events["randomize_joint_friction"] = EventTermCfg( + func=microduck_mdp.randomize_bam_friction, mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot"), + "scale_range": JOINT_FRICTION_RANDOMIZATION_RANGE, + }, + ) + if ENABLE_ARMATURE_RANDOMIZATION: + cfg.events["randomize_armature"] = EventTermCfg( + func=dr.joint_armature, mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot", joint_names=(r"^(?!passive_).*",)), + "operation": "scale", + "ranges": ARMATURE_RANDOMIZATION_RANGE, + }, + ) + + # === OBSERVATIONS (unified 61D layout) === + del cfg.observations["actor"].terms["base_lin_vel"] + del cfg.observations["critic"].terms["foot_height"] + del cfg.observations["actor"].terms["height_scan"] + del cfg.observations["critic"].terms["height_scan"] + cfg.observations["critic"].terms["base_lin_vel"] = ObservationTermCfg( + func=mdp.base_lin_vel, scale=1.0, + ) + + gravity_term_name = "projected_gravity" + cfg.observations["actor"].terms[gravity_term_name] = deepcopy( + cfg.observations["actor"].terms[gravity_term_name] + ) + cfg.observations["actor"].terms["base_ang_vel"] = deepcopy( + cfg.observations["actor"].terms["base_ang_vel"] + ) + cfg.observations["actor"].terms["base_ang_vel"].delay_min_lag = 0 + cfg.observations["actor"].terms["base_ang_vel"].delay_max_lag = 1 + cfg.observations["actor"].terms["base_ang_vel"].delay_update_period = 64 + cfg.observations["actor"].terms[gravity_term_name].delay_min_lag = 0 + cfg.observations["actor"].terms[gravity_term_name].delay_max_lag = 1 + cfg.observations["actor"].terms[gravity_term_name].delay_update_period = 64 + cfg.observations["actor"].terms["base_ang_vel"].noise = Unoise(n_min=-0.03, n_max=0.03) + cfg.observations["actor"].terms[gravity_term_name].noise = Unoise(n_min=-0.01, n_max=0.01) + cfg.observations["actor"].terms["joint_pos"].noise = Unoise(n_min=-0.001, n_max=0.001) + cfg.observations["actor"].terms["joint_vel"].noise = Unoise(n_min=-0.25, n_max=0.25) + + if ENABLE_IMU_ORIENTATION_RANDOMIZATION: + av = cfg.observations["actor"].terms["base_ang_vel"] + av.func = microduck_mdp.base_ang_vel_imu_misaligned + av.params = {"max_angle_deg": IMU_ORIENTATION_RANDOMIZATION_ANGLE} + g = cfg.observations["actor"].terms[gravity_term_name] + g.func = microduck_mdp.projected_gravity_imu_misaligned + g.params = {"max_angle_deg": IMU_ORIENTATION_RANDOMIZATION_ANGLE} + + cfg.observations["actor"].terms["joint_vel"] = deepcopy( + cfg.observations["actor"].terms["joint_vel"] + ) + cfg.observations["actor"].terms["joint_vel"].delay_min_lag = 1 + cfg.observations["actor"].terms["joint_vel"].delay_max_lag = 1 + cfg.observations["actor"].terms["joint_vel"].delay_update_period = 0 + + passive_excluded = SceneEntityCfg("robot", joint_names=(r"^(?!passive_).*",)) + for grp in ("actor", "critic"): + for term in ("joint_pos", "joint_vel"): + cfg.observations[grp].terms[term] = deepcopy(cfg.observations[grp].terms[term]) + cfg.observations[grp].terms[term].params["asset_cfg"] = deepcopy(passive_excluded) + + if ENABLE_ENCODER_BIAS: + cfg.events["encoder_bias"].params["bias_range"] = ENCODER_BIAS_RANGE + cfg.observations["actor"].terms["joint_pos"].params["biased"] = True + cfg.observations["critic"].terms["joint_pos"].params["biased"] = False + else: + cfg.events.pop("encoder_bias", None) + + wheel_cfg = SceneEntityCfg("robot", joint_names=(r"^passive_.*wheel",)) + cfg.observations["critic"].terms["wheel_vel"] = ObservationTermCfg( + func=mdp.joint_vel_rel, scale=1.0, params={"asset_cfg": wheel_cfg}, + ) + + for group in ("actor", "critic"): + cfg.observations[group].terms["head_command"] = ObservationTermCfg( + func=microduck_mdp.zero_command_padding, params={"dim": 4}, + ) + cfg.observations[group].terms["body_command"] = ObservationTermCfg( + func=microduck_mdp.zero_command_padding, params={"dim": 6}, + ) + + # === COMMAND: phase (comme ground_pick) === + command: UniformVelocityCommandCfg = cfg.commands["twist"] + command.rel_standing_envs = 0.0 + command.rel_heading_envs = 0.0 + # period=CROUCH_PERIOD (descente plus lente) ; randomize_phase=False -> chaque + # épisode démarre debout (phase 0), comme au déploiement (le bouton lance le + # cycle à phase 0). Évite d'apprendre "reste bas" depuis des départs déjà bas. + cfg.commands["twist"] = microduck_mdp.GroundPickPhaseCommandCfg( + **{ + **vars(command), + "class_type": microduck_mdp.GroundPickPhaseCommand, + "period": CROUCH_PERIOD, + "randomize_phase": False, + } + ) + + cfg.scene.terrain.terrain_type = "plane" + cfg.scene.terrain.terrain_generator = None + + # === CURRICULUM === + del cfg.curriculum["terrain_levels"] + del cfg.curriculum["command_vel"] + cfg.curriculum["action_rate_weight"] = CurriculumTermCfg( + func=microduck_mdp.reward_weight, + params={ + "reward_name": "action_rate_l2", + "weight_stages": [ + {"step": 0, "weight": -0.5}, + {"step": 250 * 24, "weight": -0.8}, + {"step": 500 * 24, "weight": -1.0}, + ], + }, + ) + if ENABLE_COM_RANDOMIZATION: + cfg.curriculum["com_range"] = CurriculumTermCfg( + func=microduck_mdp.com_range_curriculum, + params={ + "event_name": "randomize_com", + "range_stages": [ + {"step": 0, "range": 0.003}, + {"step": 500 * 24, "range": 0.005}, + {"step": 1000 * 24, "range": 0.01}, + ], + }, + ) + if ENABLE_HEAD_COM_RANDOMIZATION: + cfg.curriculum["head_com_range"] = CurriculumTermCfg( + func=microduck_mdp.com_range_curriculum, + params={ + "event_name": "randomize_head_com", + "range_stages": [ + {"step": 0, "range": 0.003}, + {"step": 500 * 24, "range": 0.005}, + {"step": 1000 * 24, "range": 0.01}, + ], + }, + ) + + return cfg + + +MicroduckRollerCrouchRlCfg = RslRlOnPolicyRunnerCfg( + actor=RslRlModelCfg( + hidden_dims=(512, 256, 128), + activation="elu", + obs_normalization=True, + distribution_cfg={ + "class_name": "GaussianDistribution", + "init_std": 1.0, + "std_type": "scalar", + }, + ), + critic=RslRlModelCfg( + hidden_dims=(512, 256, 128), + activation="elu", + obs_normalization=True, + ), + algorithm=PpoWithSymmetryCfg( + value_loss_coef=1.0, + use_clipped_value_loss=True, + clip_param=0.2, + entropy_coef=0.01, + num_learning_epochs=5, + num_mini_batches=4, + learning_rate=1.0e-3, + schedule="adaptive", + gamma=0.99, + lam=0.95, + desired_kl=0.01, + max_grad_norm=1.0, + symmetry_cfg=SYMMETRY_CFG if ENABLE_SYMMETRY else None, + ), + wandb_project="mjlab_microduck", + experiment_name="roller_crouch", + run_name="roller_crouch", + save_interval=250, + num_steps_per_env=24, + max_iterations=8_000, +) diff --git a/src/mjlab_microduck/tasks/microduck_roller_slope_env_cfg.py b/src/mjlab_microduck/tasks/microduck_roller_slope_env_cfg.py new file mode 100644 index 0000000..a39b601 --- /dev/null +++ b/src/mjlab_microduck/tasks/microduck_roller_slope_env_cfg.py @@ -0,0 +1,246 @@ +"""Microduck roller slope — descente passive équilibrée. + +Le robot spawne sur du plat (impulsion vers l'avant), roule sur une rampe +descendante et se laisse glisser en restant debout. Aucun pilotage : la +commande twist est neutralisée (rel_standing_envs=1.0). Terrain custom +plat+rampe (FlatRampTerrainCfg), curriculum de raideur (terrain_levels_slope). +Obs 61D unifié → interchangeable au runtime (--new-cmd-obs) — hérité tel quel +de make_microduck_velocity_rollers_env_cfg (DR/obs/reset non touchés ici). +""" + +import math +import os + +from mjlab.envs import ManagerBasedRlEnvCfg +from mjlab.envs import mdp as base_mdp +from mjlab.managers import CurriculumTermCfg, EventTermCfg, RewardTermCfg, TerminationTermCfg +from mjlab.managers.scene_entity_config import SceneEntityCfg +from mjlab.rl import RslRlOnPolicyRunnerCfg, RslRlModelCfg +from mjlab.terrains import TerrainEntityCfg +from mjlab.terrains.terrain_generator import TerrainGeneratorCfg + +from mjlab_microduck.tasks import mdp as microduck_mdp +from mjlab_microduck.tasks.slope_terrain import FlatRampTerrainCfg, RAMP_DEG_MAX +from mjlab_microduck.tasks.microduck_velocity_rollers_env_cfg import ( + make_microduck_velocity_rollers_env_cfg, +) +from mjlab_microduck.tasks.symmetry import PpoWithSymmetryCfg + +# Géométrie du terrain plat+rampe+sortie. +FLAT_LENGTH = 2.0 +RAMP_LENGTH_RANGE = (3.0, 8.0) # longueur horizontale de la rampe, tirée au hasard par tuile +RUNOUT_LENGTH = 4.0 # plat de sortie en bas +SPAWN_ON_RAMP = 0.3 # spawn ce nb de m sur la rampe (gravité -> roulement, pas de patinage) +ENTRY_VELOCITY_X = (0.25, 0.45) # petit élan initial vers l'avant/descente (m/s) +TILE_SIZE = (15.0, 4.0) # >= flat + ramp_max + runout (= 14) + marge +SPAWN_YAW = (0.0, 0.0) # face à la descente (+x), fixe + +# Raideur au PLAY : None = aléatoire (comme à l'entraînement). Mettre une valeur +# 0..1 pour forcer une pente précise (1.0 = la plus raide ~20°, 0.5 = moyenne). +# Surchargeable sans éditer le code via la variable d'env SLOPE_PLAY_DIFFICULTY +# (ex: SLOPE_PLAY_DIFFICULTY=1.0 uv run play ... ; "none"/"random" = aléatoire). +PLAY_DIFFICULTY = None + + +def _resolve_play_difficulty(): + """Difficulté de play : env SLOPE_PLAY_DIFFICULTY sinon la constante.""" + raw = os.environ.get("SLOPE_PLAY_DIFFICULTY") + if raw is None: + return PLAY_DIFFICULTY + raw = raw.strip().lower() + if raw in ("", "none", "random"): + return None + try: + return max(0.0, min(1.0, float(raw))) + except ValueError: + print(f"[roller_slope] SLOPE_PLAY_DIFFICULTY='{raw}' invalide -> défaut {PLAY_DIFFICULTY}") + return PLAY_DIFFICULTY + +# Terminaison « tombé dans le vide » : sous le plat de sortie le plus bas +# (rampe la plus raide et la plus longue), avec marge => ne se déclenche jamais +# pendant une descente normale, seulement si le robot quitte le solide. +_MAX_DROP = RAMP_LENGTH_RANGE[1] * math.tan(math.radians(RAMP_DEG_MAX)) +VOID_FLOOR = -_MAX_DROP - 0.5 + + +def make_microduck_roller_slope_env_cfg(play: bool = False) -> ManagerBasedRlEnvCfg: + cfg = make_microduck_velocity_rollers_env_cfg(play=play) + + # === TERRAIN : plat + rampe (longueur aléatoire) + plat de sortie === + cfg.scene.terrain = TerrainEntityCfg( + terrain_type="generator", + terrain_generator=TerrainGeneratorCfg( + size=TILE_SIZE, + curriculum=True, + num_rows=10, # 10 niveaux de raideur + num_cols=1, + difficulty_range=(0.0, 1.0), + sub_terrains={ + "flat_ramp": FlatRampTerrainCfg( + flat_length=FLAT_LENGTH, + ramp_length_range=RAMP_LENGTH_RANGE, + runout_length=RUNOUT_LENGTH, + spawn_on_ramp=SPAWN_ON_RAMP, + ) + }, + ), + max_init_terrain_level=0, # curriculum : démarrer sur la rampe la plus douce + ) + + # Au play : montrer des pentes variées. difficulté None -> raideurs aléatoires + # (niveau tiré sur toutes les rangées) ; une valeur 0..1 force une raideur + # précise (1.0 = la plus raide). Pilotable via SLOPE_PLAY_DIFFICULTY. + if play: + play_difficulty = _resolve_play_difficulty() + if play_difficulty is not None: + cfg.scene.terrain.terrain_generator.difficulty_range = (play_difficulty, play_difficulty) + else: + cfg.scene.terrain.max_init_terrain_level = None + + # === COMMANDE neutralisée (équilibre pur) === + command = cfg.commands["twist"] + command.rel_standing_envs = 1.0 + command.rel_heading_envs = 0.0 + command.ranges.lin_vel_x = (0.0, 0.0) + command.ranges.lin_vel_y = (0.0, 0.0) + if getattr(command.ranges, "ang_vel_z", None) is not None: + command.ranges.ang_vel_z = (0.0, 0.0) + + # === RESET : toujours face à la descente (+x), PAS de poussée de base === + # Le yaw hérité est aléatoire (-180°/+180°) -> on le fixe à 0 (face au bas de + # la pente). Aucune vitesse de base injectée : le robot spawne sur la rampe + # (voir spawn_on_ramp), la gravité fait rouler les roues (élan aux roues, + # sans glissement). L'ancienne poussée de base (base rapide, roues immobiles) + # patinait -> pic de contact -> divergence NaN, et le robot "marchait pour + # s'arrêter" au lieu de rouler. + cfg.events["reset_base"].params["pose_range"]["yaw"] = SPAWN_YAW + # PAS de poussée de base ici (base qui bouge + roues immobiles = à-coup de + # patinage au 1er pas). L'élan initial est donné en ROULEMENT cohérent + # (base + roues, ω·r = v) par reset_rolling_entry ci-dessous -> départ propre. + cfg.events["reset_base"].params["velocity_range"] = {} + + # === RÉCOMPENSES : équilibre LIBRE (il place son centre de gravité lui-même) === + # PAS de récompense de pose fixe : on ne lui dicte plus la posture debout du + # plat (qui l'empêchait de fléchir/pencher). Il est libre de bouger son CoM + # (hanches/genoux, inclinaison) pour tenir la pente. On récompense juste : + # rester debout, vivre, glisser, aller droit — et ne pas tomber (terminaisons). + keep = {"action_rate_l2"} + for name in list(cfg.rewards.keys()): + if name not in keep: + del cfg.rewards[name] + + cfg.rewards["upright"] = RewardTermCfg( + func=microduck_mdp.body_upright_gaussian, + weight=3.0, + params={"asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",)), "std": 0.2}, + ) + cfg.rewards["alive"] = RewardTermCfg(func=microduck_mdp.is_alive, weight=1.0) + # Se LAISSER GLISSER (rouler), PAS accélérer/courir : récompense le ROULEMENT + # des roues vers le bas, plafonné à cap_speed. Plafonné => pas d'incitation à + # pousser plus vite ; basé sur les roues => "courir" (pousser la base sans + # rouler) ne rapporte pas. Sans récompense de glisse, l'optimum serait de + # rester immobile ; avec, il se laisse rouler tant qu'il tient l'équilibre. + cfg.rewards["wheel_glide"] = RewardTermCfg( + func=microduck_mdp.wheel_glide_reward, weight=2.0, params={"cap_speed": 0.35}, + ) + # ALLER DROIT : maintenir le yaw de spawn (= 0 = face à la descente). Corrigeant + # (le robot peut se rattraper), c'est la bonne façon d'aller tout droit. NB: la + # symétrie PPO (SYMMETRY_CFG) est codée pour l'ancien obs 51D -> inutilisable ici. + cfg.rewards["heading_hold"] = RewardTermCfg( + func=microduck_mdp.heading_hold_reward, weight=1.5, params={"std": 0.4}, + ) + cfg.rewards["feet_flat"] = RewardTermCfg( + func=microduck_mdp.feet_flat_penalty, + weight=-2.0, + params={ + "asset_cfg": SceneEntityCfg("robot", site_names=("left_foot", "right_foot")), + "sensor_name": "feet_ground_contact", + }, + ) + cfg.rewards["neck_action_rate_l2"] = RewardTermCfg( + func=microduck_mdp.neck_action_rate_l2, weight=-0.5, + ) + # GARDER LA TÊTE DROITE : pénalise la déviation des joints cou/tête par rapport + # à la position home. On a retiré la pose fixe des JAMBES (pour l'équilibre + # libre), mais rien ne tenait la tête -> elle partait n'importe où. Ceci ne + # contraint QUE la tête/cou, pas les jambes. + cfg.rewards["neck_joint_pos_l2"] = RewardTermCfg( + func=microduck_mdp.neck_joint_pos_l2, weight=-0.75, + ) + cfg.rewards["joint_torques_l2"] = RewardTermCfg( + func=microduck_mdp.joint_torques_l2, weight=-1e-3, + ) + cfg.rewards["action_rate_l2"].weight = -1.0 + + # === TERMINATIONS : chute + tombé dans le vide === + # Le plat de sortie donne du solide au bas de la rampe, donc plus besoin de + # terminer « au bord » (terrain_edge_reached coupait trop tôt les rampes + # longues). On garde : chute (bad_orientation), NaN, et « tombé dans le vide » + # (trunk sous le plat de sortie le plus bas) au cas où le robot quitte le solide. + cfg.terminations["fell_over"] = TerminationTermCfg( + func=base_mdp.bad_orientation, + params={"limit_angle": 1.0, "asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",))}, + ) + if "out_of_terrain_bounds" in cfg.terminations: + del cfg.terminations["out_of_terrain_bounds"] + cfg.terminations["fell_into_void"] = TerminationTermCfg( + func=microduck_mdp.root_height_below, + params={"min_height": VOID_FLOOR, "asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",))}, + ) + cfg.terminations["nan_state"] = TerminationTermCfg( + func=microduck_mdp.robot_state_is_nan, time_out=False, + ) + + # === OBS : assainir les NaN/Inf (robustesse aux divergences de contact rares) === + # Un contact rare (~1/25M pas-env) fait diverger le free-joint en NaN. À cause + # du décalage d'un sous-pas, la terminaison nan_state ne l'attrape qu'AU PAS + # SUIVANT (reset), mais le NaN atteint déjà l'obs du pas courant -> check_nan de + # rsl_rl tue l'entraînement. nan_policy="sanitize" remplace NaN/Inf par 0 dans + # l'obs renvoyée (pas de crash) ; nan_state reset ensuite l'env fautif. + for grp in ("actor", "critic"): + cfg.observations[grp].nan_policy = "sanitize" + + # === EVENTS === + cfg.events["reset_action_history"] = EventTermCfg( + func=microduck_mdp.reset_action_history, mode="reset", + ) + # Départ en roulement (élan aux roues, sans patinage). APRÈS reset_base. + cfg.events["reset_rolling_entry"] = EventTermCfg( + func=microduck_mdp.reset_rolling_entry, mode="reset", + params={"speed_range": ENTRY_VELOCITY_X}, + ) + + # === CURRICULUM : raideur doux -> raide === + # Démarre sur la pente la plus douce (2°) et promeut vers plus raide (jusqu'à + # 20°) quand le robot a descendu assez loin (terrain_levels_slope, basé sur la + # distance parcourue). Viable maintenant que descent_speed le fait AVANCER + # (avant il restait immobile -> jamais promu). Il apprend l'équilibre + # progressivement au lieu d'être jeté d'emblée sur du 20° (où il pique du nez). + for name in list(cfg.curriculum.keys()): + del cfg.curriculum[name] + cfg.curriculum["terrain_levels"] = CurriculumTermCfg(func=microduck_mdp.terrain_levels_slope) + + return cfg + + +MicroduckRollerSlopeRlCfg = RslRlOnPolicyRunnerCfg( + actor=RslRlModelCfg( + hidden_dims=(512, 256, 128), + activation="elu", + obs_normalization=True, + distribution_cfg={"class_name": "GaussianDistribution", "init_std": 1.0, "std_type": "scalar"}, + ), + critic=RslRlModelCfg(hidden_dims=(512, 256, 128), activation="elu", obs_normalization=True), + algorithm=PpoWithSymmetryCfg( + value_loss_coef=1.0, use_clipped_value_loss=True, clip_param=0.2, + entropy_coef=0.01, num_learning_epochs=5, num_mini_batches=4, + learning_rate=1.0e-3, schedule="adaptive", gamma=0.99, lam=0.95, + desired_kl=0.01, max_grad_norm=1.0, symmetry_cfg=None, + ), + wandb_project="mjlab_microduck", + experiment_name="roller_slope", + run_name="roller_slope", + save_interval=250, + num_steps_per_env=24, + max_iterations=8_000, +) diff --git a/src/mjlab_microduck/tasks/microduck_roller_standup_env_cfg.py b/src/mjlab_microduck/tasks/microduck_roller_standup_env_cfg.py new file mode 100644 index 0000000..872ad8c --- /dev/null +++ b/src/mjlab_microduck/tasks/microduck_roller_standup_env_cfg.py @@ -0,0 +1,551 @@ +"""Microduck roller standup — se relever sur rollers. + +Policy DÉDIÉE épisodique : le robot démarre au sol (à plat ventre, à plat dos) ou +déjà debout, et doit se remettre debout sur ses rollers puis TENIR la station. +Portage de la recette `standup` (canard marcheur) vers le modèle rollers. + +Dérive de l'env roller (`make_microduck_velocity_rollers_env_cfg`) → hérite tel +quel le robot rollers, les capteurs, toute la DR et l'observation 61D, donc +interchangeable au runtime (--new-cmd-obs). C'est le pattern de roller_slope. + +Deux différences structurelles avec `standup` : + - les roues passives sont INTERCALÉES dans l'ordre des joints → indices + remappés (_LEG_JOINTS ci-dessous), verrouillés par + tests/test_roller_standup_cfg.py ; + - pas de commande head_pose : les slots head/body restent zero-paddés + (convention de la famille roller) et la tête est tenue droite par + neck_joint_pos_l2, qui résout par NOM. + +La pièce nouvelle est le curriculum de friction de roulement, INVERSÉ (roues +freinées → libres) : les roues roulent, donc il n'y a aucune adhérence pour +pousser sur le sol. On bootstrappe avec des roues quasi bloquées puis on rampe +vers la vraie valeur. Si `standing_composite` s'écroule à un palier, le geste +« pieds adhérents » ne transfère pas et il faudra guider une technique de +patineur (appui genou, un patin à la fois). + +Déploiement visé : en `--standing` face à la policy roller en `--walking`, avec +la bascule automatique sur la magnitude de la commande de vitesse +(infer_policy.py:262, seuil 0.05) ; le slot twist y est laissé à zéro +(infer_policy.py:239). +""" + +import math +import os + +from mjlab.envs import ManagerBasedRlEnvCfg +from mjlab.managers import ( + CurriculumTermCfg, + EventTermCfg, + RewardTermCfg, +) +from mjlab.managers.scene_entity_config import SceneEntityCfg +from mjlab.rl import RslRlModelCfg, RslRlOnPolicyRunnerCfg + +from mjlab_microduck.tasks import mdp as microduck_mdp +from mjlab_microduck.tasks.microduck_velocity_rollers_env_cfg import ( + make_microduck_velocity_rollers_env_cfg, +) +from mjlab_microduck.tasks.symmetry import PpoWithSymmetryCfg + +# ── Hauteurs de tronc (m) ───────────────────────────────────────────────────── +# Mesurées par cinématique exacte (minimum des sommets de maillage des géoms +# collidantes, pose STAND, tronc ramené au contact) sur scene_rollers.xml : +# debout 0.1407, repos à plat ventre 0.0752, repos à plat dos 0.0475. +# Contrôle : le modèle SANS roues donne 0.1172 en cinématique contre STAND_Z=0.115 +# mesuré sous charge par standup → ~2 mm d'affaissement, appliqué ici aussi. +# 0.138 tombe dans le reset_base z (0.1335–0.1435) déjà utilisé par l'env roller. +ROLLER_STAND_Z = 0.138 +ROLLER_PRONE_Z = 0.075 + +EPISODE_LENGTH_S = 6.0 # monter + stabiliser, comme standup +NUM_STEPS_PER_ENV = 24 + +# ── Override de play : forcer la proportion de départs SUR LE DOS ───────────── +# Au play, l'env est reconstruit à neuf : common_step_counter repart à 0, donc le +# curriculum ground_state_mix applique son palier 0, où face_up_prob = 0. On ne +# voit donc JAMAIS de départ sur le dos au play — or c'est le cas le plus dur, +# celui qu'on veut inspecter à l'œil. Cette variable le force. +# STANDUP_PLAY_FACE_UP=1.0 -> 100 % de départs sur le dos +# STANDUP_PLAY_FACE_UP=0.4 -> le mélange du dernier palier du curriculum +# non définie / "none" / "random" -> comportement par défaut (palier 0) +# N'a d'effet QUE sur play=True. Même motif que SLOPE_PLAY_DIFFICULTY dans +# roller_slope. +PLAY_FACE_UP = None +# Rapport ventre:debout du DERNIER palier du curriculum (0.40 / 0.20 = 2:1). Le +# reste (1 - face_up) est réparti dans ce rapport, si bien que 0.4 reproduit +# exactement le mélange de fin d'entraînement. +_PLAY_FACE_DOWN_SHARE = 2.0 / 3.0 + + +def _resolve_play_face_up(): + """Proportion de départs sur le dos au play : env STANDUP_PLAY_FACE_UP sinon la constante.""" + raw = os.environ.get("STANDUP_PLAY_FACE_UP") + if raw is None: + return PLAY_FACE_UP + raw = raw.strip().lower() + if raw in ("", "none", "random"): + return None + try: + return max(0.0, min(1.0, float(raw))) + except ValueError: + print(f"[roller_standup] STANDUP_PLAY_FACE_UP='{raw}' invalide -> défaut {PLAY_FACE_UP}") + return PLAY_FACE_UP + +# ── Indices de joints — les roues passives sont INTERCALÉES ─────────────────── +# Ordre réel du modèle rollers (18 joints après le free-joint), vérifié dans +# MuJoCo via get_walk_rollers_spec().compile() : +# 0-4 left_hip_yaw, left_hip_roll, left_hip_pitch, left_knee, left_ankle +# 5-6 passive_LF_wheel, passive_LR_wheel +# 7-10 neck_pitch, head_pitch, head_yaw, head_roll +# 11-15 right_hip_yaw, right_hip_roll, right_hip_pitch, right_knee, right_ankle +# 16-17 passive_RF_wheel, passive_RR_wheel +# Le standup utilise [0-4, 9-13] / [5-8] : ce sont les indices du modèle SANS +# roues, ils ne valent PAS ici. Verrouillé par tests/test_roller_standup_cfg.py. +# +# Seul _LEG_JOINTS est consommé (par les récompenses de pose). _NECK_JOINTS et +# _WHEEL_JOINTS servent à la documentation et au test d'indices : le cou est +# résolu par NOM (neck_joint_pos_l2 appelle find_joints(r".*(neck|head).*") à +# chaque pas) et les roues par la regex ^passive_.*. +_LEG_JOINTS = [0, 1, 2, 3, 4, 11, 12, 13, 14, 15] +_NECK_JOINTS = [7, 8, 9, 10] +_WHEEL_JOINTS = [5, 6, 16, 17] + +# Récompenses de PATINAGE de l'env roller : aucun sens quand on est par terre. +# feet_flat : les lames ne sont PAS à plat pendant la montée → combattrait le geste. +# hip_roll_neutral : se relever demande d'écarter les jambes. +# pose / com_height_target : remplacés par les cibles pose/hauteur du relevé. +# upright (gaussienne de base) : remplacée par upright_linear + upright_sharp. +_SKATING_REWARDS = ( + "wheel_speed", + "braking", + "skating_air_time", + "glide", + "single_support", + "gait_symmetry", + "forward_lean", + "heading_hold", + "feet_flat", + "hip_roll_neutral", + "pose", + "com_height_target", + "upright", +) + + +def make_microduck_roller_standup_env_cfg(play: bool = False) -> ManagerBasedRlEnvCfg: + """Env « se relever sur rollers » : départ au sol, cible = debout sur roues.""" + cfg = make_microduck_velocity_rollers_env_cfg(play=play) + + cfg.episode_length_s = EPISODE_LENGTH_S + + # ── Récompenses de patinage retirées ───────────────────────────────────── + for name in _SKATING_REWARDS: + cfg.rewards.pop(name, None) + + # ── Commande : slot twist neutralisé (≈ 0) ─────────────────────────────── + # L'env roller installe un RelativeHeadingVelocityCommandCfg (cmd[2] = erreur + # de cap calculée en interne). Ici on ne pilote rien : on repasse au + # command-only neutralisé, comme standup. Les slots head_pose (4) et + # body_pose (6) restent zero-paddés → parité d'obs 61D préservée. + command = cfg.commands["twist"] + command.rel_standing_envs = 0.0 + command.rel_heading_envs = 0.0 + command.heading_command = False + command.ranges.heading = None + command.resampling_time_range = (EPISODE_LENGTH_S, EPISODE_LENGTH_S * 2) + command.debug_vis = False + command.ranges.lin_vel_x = (-0.01, 0.01) + command.ranges.lin_vel_y = (-0.01, 0.01) + command.ranges.ang_vel_z = (-0.05, 0.05) + cfg.commands["twist"] = microduck_mdp.VelocityCommandCommandOnlyCfg(**vars(command)) + + # ── Robustesse numérique (même choix que roller_slope) ─────────────────── + # Un contact rare (~1/25M pas) fait diverger le free-joint en NaN : on + # assainit l'obs (→ 0) pour ne pas tuer l'entraînement, l'env fautif se reset + # au pas suivant. + for grp in ("actor", "critic"): + cfg.observations[grp].nan_policy = "sanitize" + + # ── Récompenses de relevé — transplant du standup, remappé ─────────────── + # Les poids viennent des itérations documentées dans + # microduck_standup_env_cfg.py : ne les retoucher qu'avec une raison. Seuls + # les indices de joints et les deux hauteurs changent ici. + # NB : un SceneEntityCfg NEUF par terme — mjlab les résout et les mute en + # place, un objet partagé donne des indices périmés. + + # Pose cible = HOME (target_overrides=None), JAMBES seulement : le cou et la + # tête sont tenus par neck_joint_pos_l2 (hérité), qui résout par NOM. + cfg.rewards["pose_stand_legs"] = RewardTermCfg( + func=microduck_mdp.pose_target_match, + weight=8.0, + params={ + "std": 0.5, + "joint_indices": _LEG_JOINTS, + "target_overrides": None, + }, + ) + # Bootstrap L1 : gradient constant même loin de HOME (la gaussienne sature). + cfg.rewards["pose_stand_l1"] = RewardTermCfg( + func=microduck_mdp.pose_l1_penalty, + weight=5.0, + params={ + "joint_indices": _LEG_JOINTS, + "target_overrides": None, + }, + ) + + # Hauteur en trois couches : gaussienne large (tire depuis le sol), + # gaussienne étroite (force les derniers cm, là où la large est saturée), + # et L1 fort qui rend « rester par terre » net NÉGATIF — sans lui, la policy + # se contente de l'optimum paresseux « immobile au sol ». + cfg.rewards["height_stand"] = RewardTermCfg( + func=microduck_mdp.height_target_gaussian, + weight=4.0, + params={ + "std": 0.04, + "target_height": ROLLER_STAND_Z, + "asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",)), + }, + ) + cfg.rewards["height_stand_sharp"] = RewardTermCfg( + func=microduck_mdp.height_target_gaussian, + weight=4.0, + params={ + "std": 0.015, + "target_height": ROLLER_STAND_Z, + "asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",)), + }, + ) + cfg.rewards["height_stand_l1"] = RewardTermCfg( + func=microduck_mdp.height_l1_penalty, + weight=30.0, + params={ + "target_height": ROLLER_STAND_Z, + "asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",)), + }, + ) + + # Paye le MOUVEMENT de montée, pas seulement la destination : sans ça, + # « rester assis en collectant la pose partielle » domine. La coupure est + # 10 mm AU-DESSUS de la cible, sinon la policy se gare à l'altitude de + # coupure et ne finit pas la montée. + cfg.rewards["com_upward_velocity"] = RewardTermCfg( + func=microduck_mdp.com_upward_velocity, + weight=3.0, + params={ + "asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",)), + "max_height": ROLLER_STAND_Z + 0.010, + }, + ) + # Montée douce : pénalise |a_z|. Compatible avec com_upward_velocity — une + # vitesse verticale constante collecte l'une ET a a_z = 0 → les deux + # pressions sélectionnent ensemble une montée lisse à vitesse constante. + # + # ⚠️ POIDS POSITIF, et ce n'est pas une faute de frappe. mdp.py mélange deux + # conventions de signe : trunk_vertical_accel_penalty renvoie déjà -|a_z| + # (mdp.py:2171), comme height_l1_penalty et pose_l1_penalty — qui sont d'ailleurs + # employées ici avec des poids +30 et +5. Le -0.02 hérité du standup formait donc + # un double négatif et RÉCOMPENSAIT l'accélération verticale : mesuré à + # Episode_Reward/gentle_rise = +0.0118 (seul terme de pénalité loggé positif) sur + # le run vweolw91. C'est la cause du « très violent », et elle explique aussi les + # tentatives d'amortissement infructueuses documentées dans le standup, qui + # combattaient un terme poussant activement dans l'autre sens. + # + # On garde la magnitude 0.02 (celle voulue à l'origine) DÉLIBÉRÉMENT petite : + # |a_z| est forcément élevé pendant un retournement depuis le dos, donc un gros + # poids ici serait un bloqueur de mouvement. L'amortissement réel est porté par + # joint_torque_rate_l2, qui pénalise la VARIATION de couple et pas le mouvement. + cfg.rewards["gentle_rise"] = RewardTermCfg( + func=microduck_mdp.trunk_vertical_accel_penalty, + weight=+0.02, + params={"asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",))}, + ) + + # Tronc vertical en deux couches : cos(tilt) a un fort gradient quand on est + # couché mais s'essouffle près de la verticale ; la gaussienne serrée gatée + # en hauteur prend le relais et tue le penché-arrière (mode d'échec du + # standup : basculer en arrière en tendant les jambes). + cfg.rewards["upright_linear"] = RewardTermCfg( + func=microduck_mdp.body_upright_linear, + weight=6.0, + params={"asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",))}, + ) + cfg.rewards["upright_sharp"] = RewardTermCfg( + func=microduck_mdp.upright_gaussian_at_height, + weight=6.0, + params={ + "std": 0.3, + "height_low": ROLLER_PRONE_Z, + "height_high": ROLLER_STAND_Z, + "asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",)), + }, + ) + + # Score MULTIPLICATIF hauteur × verticalité × pose : comme les facteurs se + # multiplient, être bon sur 2 critères sur 3 ne rapporte rien → casse les + # compromis « penché à la bonne hauteur » que les récompenses additives + # laissent passer. Stds volontairement LARGES pour rester visible pendant la + # montée (des stds serrées donnaient un score ~5e-5, donc zéro gradient). + cfg.rewards["standing_composite"] = RewardTermCfg( + func=microduck_mdp.standing_composite_score, + weight=15.0, + params={ + "target_height": ROLLER_STAND_Z, + "height_std": 0.04, + "upright_std": 0.40, + "pose_std": 0.40, + "joint_indices": _LEG_JOINTS, + "target_overrides": None, + "asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",)), + }, + ) + + # Anti-jitter : pénalise la VARIATION de couple, pas son amplitude ni la + # rotation du tronc → amortit la tremblote sans bloquer le retournement. + # Le standup l'a identifié comme le seul amortisseur qui ne tue pas le + # relevé depuis le dos, donc c'est LE levier sûr à remonter. + # + # -2e-3 (valeur héritée du standup) ne contribuait que -0.0002/pas face à + # ~+41.6 de récompense de tâche saturée à 95-99 % — soit rien du tout. Tous + # amortisseurs confondus le rapport était de ~35:1 en faveur de la tâche, donc + # aucune raison d'être doux. Mesuré sur le run vweolw91 à l'itération 7500. + # + # Recalibrage : la valeur brute de |Δτ|² vaut ~0.1 à convergence, donc + # contribution ≈ 0.1 × |poids|. Mesuré à -0.255/pas avec un poids -2.0 (run + # d8rnko6p) — donc PAS la cause du gel, mais on redescend à -0.2 pour dégager + # le budget d'amortissement le temps d'isoler l'effet du seul bug de signe. + # Si c'est encore violent, monter CE terme (formule ci-dessus) plutôt que + # body_ang_vel ou action_rate, qui sont des bloqueurs de mouvement et gelaient + # le relevé depuis le dos. + cfg.rewards["joint_torque_rate_l2"] = RewardTermCfg( + func=microduck_mdp.joint_torque_rate_l2, + weight=-0.2, + ) + + # PAS de pénalité d'impact tête. Essayée avec les valeurs de velstand + # (body_impact_cost, sous-arbre `neck`, poids -1.0, seuil 2.0) : la policy a + # convergé vers rester couchée, INERTE. Mesuré (run d8rnko6p) : + # head_impact_penalty -1.01/pas, le plus gros terme négatif du tableau, pendant + # que standing_composite s'effondrait de +14.3 à +3.3. + # + # L'erreur de raisonnement était de croire qu'une pénalité « ciblée » ne bride + # pas le mouvement. Faux ici : pour se relever du dos, ce robot PIVOTE sur sa + # tête et ses épaules. La tête est le point d'appui du retournement, pas un + # dégât collatéral — la pénaliser bloque le seul mécanisme disponible, et le + # dos était déjà le cas qui échouait. + # + # Hypothèse en cours de test : taper la tête était un SYMPTÔME de la violence + # (le bug de signe de gentle_rise payait la brutalité, et une montée brutale + # finit sur la tête), pas un défaut séparé. Si le slam revient une fois le signe + # corrigé, la reprise doit être une pénalité GATÉE EN HAUTEUR — comme + # upright_sharp l'est — pour épargner la phase de retournement au sol. + # + # ⚠️ Attention à l'optimum paresseux qui rend ce gel possible : pose_stand_legs + # restait à +7.72 sur 8 alors que le robot était allongé (jambes à HOME en + # position couchée → récompense encaissée quasi gratuitement). C'est + # height_stand_l1 (poids +30) qui doit rendre « rester au sol » net négatif. + + # ── Départ AU SOL : à plat ventre / à plat dos / déjà debout ───────────── + # Ajouté en DERNIER dans cfg.events : l'ordre d'exécution suit l'ordre + # d'insertion, et ce terme doit écraser la pose posée par reset_base / + # reset_robot_joints. + # Le bucket « déjà debout » n'est pas décoratif : sans lui la policy apprend + # à monter mais pas à TENIR, et elle retombe juste après s'être relevée. + # Pas de bucket « assis » → aucun sitting_joint_overrides à remapper (ceux du + # standup sont des indices du modèle SANS roues). + # Les probabilités ci-dessous = palier 0 du curriculum ground_state_mix. + cfg.events["set_ground_state"] = EventTermCfg( + func=microduck_mdp.set_random_ground_state, + mode="reset", + params={ + "face_down_prob": 0.50, # ventre (+90° de pitch) + "face_up_prob": 0.00, # dos — le plus dur, introduit tard + "sitting_prob": 0.00, + "standing_prob": 0.50, + "sitting_joint_overrides": None, + # Les deux poses de départ (ventre/dos) partagent une SEULE plage de z, + # or leurs contacts n'ont rien de commun : le ventre ne décolle du sol + # qu'à partir de 0.0752, le dos repose à 0.0475. Un plancher unique ne + # peut donc pas être idéal pour les deux. On choisit 0.076 pour éliminer + # toute interpénétration côté ventre (mesuré : à 0.05, +25 mm dans le + # sol), au prix d'un dos qui démarre 28–42 mm au-dessus de son repos — + # un artefact bien plus doux qu'un pushout de contact. + "prone_z_min": 0.076, + "prone_z_max": 0.09, + # Debout sur roues : ROLLER_STAND_Z = 0.138 (contre 0.11–0.12 sans roues). + "standing_z_min": 0.134, + "standing_z_max": 0.144, + # Bruit de pitch/roll au départ. Attention : dans + # set_random_ground_state le bucket « debout » réutilise le quaternion + # du bucket « assis », donc ce bruit s'applique AUSSI aux départs + # debout — c'est voulu (pas de sur-apprentissage du parfaitement droit). + "sitting_tilt_max": math.radians(10), + }, + ) + + # Le robot DÉMARRE tombé → la terminaison sur inclinaison n'a aucun sens ici + # (elle tuerait l'épisode au premier pas). nan_state, hérité, reste. + cfg.terminations.pop("fell_over", None) + + # Curriculum des poses de départ, easy → hard. Avec un mélange plat dès le + # départ, la policy optimise la majorité facile et laisse le dos sous-entraîné + # (leçon du standup : il gelait en « ne rien faire » sur cette pose). On + # introduit donc debout+ventre d'abord, le dos tard, et on biaise vers les + # poses dures à la fin pour qu'elles reçoivent le plus d'entraînement. + cfg.curriculum["ground_state_mix"] = CurriculumTermCfg( + func=microduck_mdp.event_param_curriculum, + params={ + "event_name": "set_ground_state", + "param_stages": [ + {"step": 0, "params": { + "standing_prob": 0.50, "sitting_prob": 0.00, + "face_down_prob": 0.50, "face_up_prob": 0.00}}, + {"step": 600 * NUM_STEPS_PER_ENV, "params": { + "standing_prob": 0.35, "sitting_prob": 0.00, + "face_down_prob": 0.45, "face_up_prob": 0.20}}, + {"step": 1500 * NUM_STEPS_PER_ENV, "params": { + "standing_prob": 0.25, "sitting_prob": 0.00, + "face_down_prob": 0.40, "face_up_prob": 0.35}}, + {"step": 2500 * NUM_STEPS_PER_ENV, "params": { + "standing_prob": 0.20, "sitting_prob": 0.00, + "face_down_prob": 0.40, "face_up_prob": 0.40}}, + ], + }, + ) + + # Override de play : forcer les départs sur le dos pour pouvoir les inspecter. + # On écrit les probabilités dans l'événement ET on retire le curriculum : sans + # ça, event_param_curriculum (qui tourne AVANT les événements de reset) les + # réécrirait avec son palier 0 dès le premier reset. Uniquement en play, donc + # l'entraînement et son curriculum easy → hard sont intouchés. + if play: + play_face_up = _resolve_play_face_up() + if play_face_up is not None: + remainder = 1.0 - play_face_up + cfg.events["set_ground_state"].params.update({ + "face_up_prob": play_face_up, + "face_down_prob": remainder * _PLAY_FACE_DOWN_SHARE, + "standing_prob": remainder * (1.0 - _PLAY_FACE_DOWN_SHARE), + "sitting_prob": 0.00, + }) + del cfg.curriculum["ground_state_mix"] + + # ── Friction de roulement INVERSÉE : freinées → libres ─────────────────── + # C'est la seule pièce vraiment nouvelle de cet env, et le cœur de la + # difficulté : les roues roulent, donc il n'y a AUCUNE adhérence + # longitudinale pour pousser sur le sol. L'env roller fait MONTER cette + # friction (0 → 0.0015) ; ici on la fait DESCENDRE, pour bootstrapper le + # geste sur un problème facile (roues quasi bloquées ≈ des pieds) avant + # d'imposer la physique réelle du roulement. + # + # DIAGNOSTIC à surveiller : si Episode_Reward/standing_composite s'écroule à + # un palier, le geste « pieds adhérents » ne transfère pas aux roues libres + # → il faudra guider une technique de patineur (appui genou intermédiaire, + # un patin à la fois). C'est un résultat exploitable, pas un échec. + # + # ATTENTION sim2real : seuls les checkpoints d'APRÈS le dernier palier + # (iter 4000+) sont candidats au déploiement. Avant, la policy s'appuie sur + # une friction de roulement qui n'existe pas sur le vrai robot. + _WHEEL_FRICTION_STAGE0 = (0.0500, 0.0500) + cfg.curriculum["wheel_friction"] = CurriculumTermCfg( + func=microduck_mdp.wheel_friction_curriculum, + params={ + "event_name": "randomize_wheel_friction", + "ranges_stages": [ + {"step": 0, "ranges": _WHEEL_FRICTION_STAGE0}, + {"step": 1000 * NUM_STEPS_PER_ENV, "ranges": (0.0200, 0.0200)}, + {"step": 2000 * NUM_STEPS_PER_ENV, "ranges": (0.0080, 0.0080)}, + {"step": 3000 * NUM_STEPS_PER_ENV, "ranges": (0.0030, 0.0030)}, + {"step": 4000 * NUM_STEPS_PER_ENV, "ranges": (0.0015, 0.0015)}, + ], + }, + ) + # Redondance défensive : le curriculum manager tourne AVANT les événements de + # reset à chaque reset (y compris le tout premier), et wheel_friction_curriculum + # défaut lui-même sur le palier 0 — donc cette ligne n'est jamais nécessaire en + # pratique. Elle garde juste la valeur PAR DÉFAUT de l'événement cohérente avec + # le palier 0 du curriculum, au cas où quelqu'un retire le curriculum plus tard + # en laissant l'événement en place. + cfg.events["randomize_wheel_friction"].params["ranges"] = _WHEEL_FRICTION_STAGE0 + + # ── action_rate : la rampe du standup, pas celle du roller ─────────────── + # L'env roller monte à -2.0 pour un gait calme. C'est un bloqueur de + # mouvement : il ralentit l'action rapide dont le relevé depuis le dos a + # besoin (le standup documente qu'un action_rate trop fort tuait cette + # récupération). La douceur est portée ici par joint_torque_rate_l2. + cfg.rewards["action_rate_l2"].weight = -0.6 + cfg.curriculum["action_rate_weight"] = CurriculumTermCfg( + func=microduck_mdp.reward_weight, + params={ + "reward_name": "action_rate_l2", + "weight_stages": [ + {"step": 0, "weight": -0.4}, + {"step": 250 * NUM_STEPS_PER_ENV, "weight": -0.8}, + {"step": 500 * NUM_STEPS_PER_ENV, "weight": -1.0}, + ], + }, + ) + + # ── Poussées rampées ──────────────────────────────────────────────────── + # push_robot est hérité de l'env roller (±0.2 m/s, toutes les 3–6 s) mais + # sans curriculum. Une bourrade dès le pas 0 parasite le bootstrap du + # relevé : on la fait monter comme le standup. + cfg.curriculum["push_magnitude"] = CurriculumTermCfg( + func=microduck_mdp.push_curriculum, + params={ + "event_name": "push_robot", + "push_stages": [ + {"step": 0, "velocity_range": { + "x": (0.0, 0.0), "y": (0.0, 0.0)}}, + {"step": 500 * NUM_STEPS_PER_ENV, "velocity_range": { + "x": (-0.08, 0.08), "y": (-0.08, 0.08)}}, + {"step": 1000 * NUM_STEPS_PER_ENV, "velocity_range": { + "x": (-0.2, 0.2), "y": (-0.2, 0.2)}}, + ], + }, + ) + + return cfg + + +# ── Config du runner RL — identique à standup ───────────────────────────────── +MicroduckRollerStandUpRlCfg = RslRlOnPolicyRunnerCfg( + actor=RslRlModelCfg( + hidden_dims=(512, 256, 128), + activation="elu", + obs_normalization=True, # le normaliseur DOIT être baké dans l'ONNX par export.py + distribution_cfg={ + "class_name": "GaussianDistribution", + "init_std": 1.0, + "std_type": "scalar", + }, + ), + critic=RslRlModelCfg( + hidden_dims=(512, 256, 128), + activation="elu", + obs_normalization=True, + ), + algorithm=PpoWithSymmetryCfg( + value_loss_coef=1.0, + use_clipped_value_loss=True, + clip_param=0.2, + entropy_coef=0.01, + num_learning_epochs=5, + num_mini_batches=4, + learning_rate=1.0e-3, + schedule="adaptive", + gamma=0.99, + lam=0.95, + desired_kl=0.01, + max_grad_norm=1.0, + # Symétrie OFF : SYMMETRY_CFG est câblé pour l'ancien layout 51D et casse + # sur le 61D (même situation que tous les envs v1.5+). + symmetry_cfg=None, + ), + wandb_project="mjlab_microduck", + experiment_name="roller_standup", + run_name="roller_standup", + save_interval=250, + num_steps_per_env=NUM_STEPS_PER_ENV, + max_iterations=15_000, +) diff --git a/src/mjlab_microduck/tasks/microduck_roulade_env_cfg.py b/src/mjlab_microduck/tasks/microduck_roulade_env_cfg.py new file mode 100644 index 0000000..7ce9c51 --- /dev/null +++ b/src/mjlab_microduck/tasks/microduck_roulade_env_cfg.py @@ -0,0 +1,772 @@ +"""Microduck forward-roll (roulade) task — attempt 3, run 2. + +Episodic policy: robot starts standing, rolls forward over the flat top of +its head, and lands back on its feet. Triggered at deployment like sit/standup +(policy switch = roll starts immediately; no phase clock, no reference motion). + +RUN-2 REWORK (run 1 learned a violent ballistic "breakdance" whip — optimal +under the run-1 rewards: same 2π, sooner, no cost): rotation now only counts +while the robot touches the ground (support-gated accumulator — a roulade +never leaves the floor), the landing annuity requires an over-the-head +contact latch, paid progress rate is capped at 3 rad/s (faster forfeits the +excess), an overspeed penalty taxes |ω| > 4 rad/s, and the impact/smoothness +penalties are active from step 0 (discovery in this env is easy; style is +the scarce resource, not exploration). + +Design (see the roulade section of mdp.py for the full history): + • ONE dense progress signal — paid increments of the max-so-far cumulative + forward rotation (potential-based: full roll pays 2π worth total, camping + anywhere pays zero per step). + • Landing rewards gated on ROLL COMPLETION (rotation frontier ≥ ~260°), not + on a clock — "do nothing" earns nothing, the standing spawn cannot farm + them, and no upright/height pressure ever opposes the flip. + • Reverse curriculum via mid-roll spawns (the trick that fixed face-up + recovery in standup): a slice of episodes starts 50°–185° into the roll, + tucked, with forward angular momentum, accumulator pre-set to the spawn + angle. The second half of a roulade IS the face-up recovery problem, which + we know is learnable. + • Élan hook for later: reset_roulade_state.forward_vel_range gives standing + spawns an initial forward base velocity — set ROULADE_FORWARD_VEL_RANGE + to e.g. (0.0, 0.3) to train rolls out of a walk. (0, 0) = standstill-only. + +DR / obs / regularisers mirror the standup env (velocity sim2real parity), +with the motion-blockers (body_ang_vel, |a_z|, arrival damping) kept near zero +during discovery and introduced late by curriculum — the roll IS a large +angular-velocity, large-impact event; taxing attempts prevents discovery +(proven twice on standup). +""" + +import math +from copy import deepcopy + +# Symmetry — the roll is sagittal / left-right symmetric; the mirror loss +# directly fights the sideways-collapse failure seen in run 2. Enabled after +# migrating symmetry.py to the 61-dim layout (2026-08-13, includes the +# "policy" → "actor" output-key fix; roulade is the first env to use it). +ENABLE_SYMMETRY = True + +# ── Domain randomisation (matched to standup/velocity for sim2real parity) ─── +ENABLE_COM_RANDOMIZATION = True +ENABLE_HEAD_COM_RANDOMIZATION = True +ENABLE_KP_RANDOMIZATION = False # match velocity (OFF) +ENABLE_KD_RANDOMIZATION = False # match velocity (OFF) +ENABLE_MASS_INERTIA_RANDOMIZATION = True +ENABLE_JOINT_FRICTION_RANDOMIZATION = True +ENABLE_ARMATURE_RANDOMIZATION = True +ENABLE_VELOCITY_PUSHES = False # a push mid-roll is incoherent +ENABLE_IMU_ORIENTATION_RANDOMIZATION = True +ENABLE_ENCODER_BIAS = True + +# ── Ranges (matched to the standup env) ─────────────────────────────────────── +COM_RANDOMIZATION_RANGE = 0.003 # ramped to 0.015 via curriculum +HEAD_COM_RANDOMIZATION_RANGE = 0.003 # ramped to 0.01 via curriculum +MASS_INERTIA_RANDOMIZATION_RANGE = (0.95, 1.05) +ARMATURE_RANDOMIZATION_RANGE = (0.9, 1.1) +JOINT_FRICTION_RANDOMIZATION_RANGE = (0.9, 1.1) +ENCODER_BIAS_RANGE = (-0.015, 0.015) +KP_RANDOMIZATION_RANGE = (0.85, 1.15) # unused (kp DR off) +KD_RANDOMIZATION_RANGE = (0.9, 1.1) # unused (kd DR off) +IMU_ORIENTATION_RANDOMIZATION_ANGLE = 6.0 + +# Episode: a CONTROLLED roll takes ~2 s + rise ~1.5 s + settle. Run-3: 4 → 5 s +# (4 s left no room for the rise after a paced roll). +EPISODE_LENGTH_S = 5.0 + +# Empirically-measured standing trunk height (standup lesson: don't guess). +STAND_Z = 0.115 + +# ── Élan (run-up) hook ──────────────────────────────────────────────────────── +# (0, 0) = roll from a standstill (run 1). Widen to e.g. (0.0, 0.3) to train +# rolls entered with forward momentum — standing spawns then get a random +# initial forward base velocity, approximating a hand-off from the walking +# policy without simulating the walk itself. +ROULADE_FORWARD_VEL_RANGE = (0.0, 0.0) + +# ── Mid-roll spawn (reverse curriculum) ─────────────────────────────────────── +# 90° = balanced on the head, 180° = on the back, 270° = supine, ~340° = seated +# leaning back, >260° opens the landing gate. Run-3 change: MAX widened +# 185° → 340° — run-2 wandb showed the second half of the roll (supine → +# seated → rise) was never spawned and never learned; spawns past ~300° open +# the landing gate at birth, giving dense on-policy data on the crouch→stand +# last mile (the velstand run-5 crouch-basin lesson). +MIDROLL_PITCH_MIN = math.radians(50.0) +MIDROLL_PITCH_MAX = math.radians(340.0) +MIDROLL_OMEGA_RANGE = (0.0, 3.0) # rad/s forward momentum at spawn +# Tuck anchor: legs folded (crouch-anchor values from the velstand crouch +# reset) + CHIN TUCK (run-5: neck_pitch −1 / head_pitch +1 puts the flat head +# top squarely on the floor — measured axis_z −0.99 vs +0.6 for the passive +# face-plant; the head-top latch requires this, so mid-roll spawns must +# demonstrate the tucked configuration). Servo-index keyed; mid-roll spawns +# lerp HOME→tuck by a per-env factor. +TUCK_OVERRIDES = { + 2: -1.15, # left hip_pitch + 3: 1.25, # left knee + 4: 1.05, # left ankle + 5: -1.0, # neck_pitch (chin tuck) + 6: 1.0, # head_pitch (chin tuck) + 11: 1.15, # right hip_pitch + 12: -1.25, # right knee + 13: -1.05, # right ankle +} + +# Rotation thresholds (rad) for the state-based gates. +LANDING_GATE_LO = math.radians(260.0) +LANDING_GATE_HI = math.radians(330.0) +RISE_GATE_LO = math.radians(180.0) +RISE_GATE_HI = math.radians(260.0) + +_LEG_JOINTS = [0, 1, 2, 3, 4, 9, 10, 11, 12, 13] +_NECK_JOINTS = [5, 6, 7, 8] + +from mjlab.envs import ManagerBasedRlEnvCfg +from mjlab.envs.mdp import dr +from mjlab.envs.mdp.actions import JointPositionActionCfg +from mjlab.managers import ( + CurriculumTermCfg, + EventTermCfg, + ObservationTermCfg, + RewardTermCfg, + TerminationTermCfg, +) +from mjlab.managers.scene_entity_config import SceneEntityCfg +from mjlab.rl import ( + RslRlOnPolicyRunnerCfg, + RslRlModelCfg, +) +from mjlab.sensor import ContactMatch, ContactSensorCfg +from mjlab.tasks.velocity import mdp +from mjlab.tasks.velocity.velocity_env_cfg import make_velocity_env_cfg +from mjlab.utils.noise import UniformNoiseCfg as Unoise + +from mjlab_microduck.robot.microduck_constants import MICRODUCK_STANDUP_ROBOT_CFG +from mjlab_microduck.tasks import mdp as microduck_mdp +from mjlab_microduck.tasks.microduck_velocity_env_cfg import HEAD_BODY_NAMES +from mjlab_microduck.tasks.symmetry import PpoWithSymmetryCfg, SYMMETRY_CFG + + +def make_microduck_roulade_env_cfg(play: bool = False) -> ManagerBasedRlEnvCfg: + """Create Microduck forward-roll environment configuration.""" + + feet_ground_cfg = ContactSensorCfg( + name="feet_ground_contact", + primary=ContactMatch( + mode="geom", + pattern=r"^(left_foot_collision|right_foot_collision)$", + entity="robot", + ), + secondary=ContactMatch(mode="body", pattern="terrain"), + fields=("found", "force"), + reduce="netforce", + num_slots=1, + track_air_time=True, + ) + + self_collision_cfg = ContactSensorCfg( + name="self_collision", + primary=ContactMatch(mode="subtree", pattern="trunk_base", entity="robot"), + secondary=ContactMatch(mode="subtree", pattern="trunk_base", entity="robot"), + fields=("found",), + reduce="none", + num_slots=1, + ) + + # Head-ground contact — the roll's pivot signal. jaw_soft is the body that + # carries the head collision geoms (top_head_shell = the flat top, jaw, + # bottom_head_shell) in robot_allcollisions.xml. NAME IS LOAD-BEARING: + # _update_roulade_accum reads it for the over-the-head latch. + head_ground_cfg = ContactSensorCfg( + name="head_ground_contact", + primary=ContactMatch(mode="body", pattern="jaw_soft", entity="robot"), + secondary=ContactMatch(mode="body", pattern="terrain"), + fields=("found",), + reduce="none", + num_slots=1, + ) + + # Whole-robot ground contact — the SUPPORT GATE (run-2 fix): the rotation + # accumulator only integrates while some robot geom touches the terrain, + # so ballistic flips ("breakdance") earn no progress and never complete. + # NAME IS LOAD-BEARING: _update_roulade_accum reads it. + robot_ground_cfg = ContactSensorCfg( + name="robot_ground_contact", + primary=ContactMatch(mode="subtree", pattern="trunk_base", entity="robot"), + secondary=ContactMatch(mode="body", pattern="terrain"), + fields=("found",), + reduce="none", + num_slots=1, + ) + + foot_frictions_geom_names = ("left_foot_collision", "right_foot_collision") + + # ── Base config ─────────────────────────────────────────────────────────── + cfg = make_velocity_env_cfg() + + cfg.scene.entities = {"robot": MICRODUCK_STANDUP_ROBOT_CFG} + cfg.scene.sensors = (feet_ground_cfg, self_collision_cfg, head_ground_cfg, robot_ground_cfg) + cfg.viewer.body_name = "trunk_base" + + cfg.episode_length_s = EPISODE_LENGTH_S + + # ── Actions ─────────────────────────────────────────────────────────────── + joint_pos_action = cfg.actions["joint_pos"] + assert isinstance(joint_pos_action, JointPositionActionCfg) + joint_pos_action.scale = 1.0 + + # ── Rewards: drop walking-specific terms ────────────────────────────────── + for name in [ + "track_linear_velocity", + "track_angular_velocity", + "air_time", + "foot_clearance", + "foot_swing_height", + "foot_slip", + "pose", + ]: + if name in cfg.rewards: + del cfg.rewards[name] + + # ── Rewards: roulade task set ───────────────────────────────────────────── + # Progress increments — the one dense task signal during the roll. During + # a 1.5 s roll it averages ~0.7/step; total payout per full roll from a + # standing spawn ≈ weight × (episode steps it took) × mean ≈ weight × 50. + cfg.rewards["roulade_progress"] = RewardTermCfg( + func=microduck_mdp.roulade_progress, + weight=8.0, + # max_paid_rate: run-4 raised 3 → 5 rad/s. Measured physics (run-3 + # checkpoint eval): the over-the-top transit runs at 3.5–5.5 rad/s — + # this robot is 10 cm tall, its natural tumble timescale is fast, and + # the 3 rad/s cap was forfeiting most of the physically-necessary + # rotation. Style pressure lives in |a_z| / action_rate / the support + # gate, not in fighting gravity's clock. + params={"target_angle": 2 * math.pi, "max_paid_rate": 5.0}, + ) + + # Whip-speed tax — run-4 threshold 4 → 7 rad/s (above the measured p90 + # transit speed of ~5.5): taxes genuine whips, not the natural tumble. + cfg.rewards["roulade_overspeed"] = RewardTermCfg( + func=microduck_mdp.roulade_overspeed_penalty, + weight=-0.1, + params={"omega_max": 7.0}, + ) + + # Head-as-pivot shaping: contact × mid-roll window × forward-rate factor + # (the rate factor kills the "rest face-down with head on floor" farm). + cfg.rewards["roulade_head_pivot"] = RewardTermCfg( + func=microduck_mdp.roulade_head_pivot, + weight=0.5, + params={ + "sensor_name": head_ground_cfg.name, + "angle_lo": math.radians(30.0), + "angle_hi": math.radians(240.0), + "rate_norm": 2.0, + }, + ) + + # Completion-gated standing annuity — the dominant attractor. Broad stds + # (standup composite lesson: partial landing must score visibly, ~0.2+). + cfg.rewards["roulade_landing_composite"] = RewardTermCfg( + func=microduck_mdp.roulade_landing_composite, + weight=4.0, + params={ + "target_height": STAND_Z, + "height_std": 0.04, + "upright_std": 0.40, + "pose_std": 0.40, + "joint_indices": _LEG_JOINTS, + "gate_lo": LANDING_GATE_LO, + "gate_hi": LANDING_GATE_HI, + "target_overrides": None, + }, + ) + + # Completion-gated bootstrap layers (gradient far from the goal, where the + # composite product is ≈0): linear upright + broad height Gaussian. + cfg.rewards["roulade_upright_after_roll"] = RewardTermCfg( + func=microduck_mdp.roulade_upright_after_roll, + weight=1.5, + params={"gate_lo": LANDING_GATE_LO, "gate_hi": LANDING_GATE_HI}, + ) + cfg.rewards["roulade_height_after_roll"] = RewardTermCfg( + func=microduck_mdp.roulade_height_after_roll, + weight=1.0, + params={ + "target_height": STAND_Z, + "std": 0.04, + "gate_lo": LANDING_GATE_LO, + "gate_hi": LANDING_GATE_HI, + }, + ) + + # Sharp landing layer (run-4): tight-std upright × height product on top + # of the broad composite. Run-3 eval showed EVERY completed episode + # parking at the same z≈0.105 / 27°-lean pose — the broad stds score ~0.5 + # there, no gradient to finish. Sharp layer: ~0.1 at the basin, ~1.0 + # upright — 10× differential across the last mile. + cfg.rewards["roulade_landing_sharp"] = RewardTermCfg( + func=microduck_mdp.roulade_landing_sharp, + weight=2.0, + params={ + "target_height": STAND_Z, + "height_std": 0.015, + "upright_std": 0.3, + "gate_lo": LANDING_GATE_LO, + "gate_hi": LANDING_GATE_HI, + }, + ) + + # Completion-gated stand tax (run-3, THE standup lesson): once the + # rotation is done, every step spent below STAND_Z costs — "crumple in a + # heap after the roll" flips from free to net-negative, the same fix that + # broke standup's static-sit basin (its height L1 at ÷4-scaled weight + # 7.5). Gate closed during the roll, so the roll itself is never taxed; + # mid/late-roll spawns are born with it active, which is the point. + cfg.rewards["roulade_stand_tax"] = RewardTermCfg( + func=microduck_mdp.roulade_stand_tax, + weight=5.0, + params={ + "target_height": STAND_Z, + "gate_lo": LANDING_GATE_LO, + "gate_hi": LANDING_GATE_HI, + }, + ) + + # Exit-rise bootstrap: upward CoM velocity, gated to the late-roll region + # (supine → up is the face-up-recovery problem; end-state rewards have zero + # gradient at zero motion there — standup lesson #2). + cfg.rewards["roulade_rise_velocity"] = RewardTermCfg( + func=microduck_mdp.roulade_rise_velocity, + weight=0.75, + params={ + "max_height": STAND_Z + 0.01, + "gate_lo": RISE_GATE_LO, + "gate_hi": RISE_GATE_HI, + }, + ) + + # Straightness — run-5: the run-4 policy rolled over the SHOULDER (lower + # energy path than straight over the head — it avoids the fully-inverted + # configuration, same cheat human beginners default to). The structural + # fix is the flatness gate on the accumulator + the head-top latch (side + # rolls no longer count as rotation at all); these penalties provide the + # dense per-step gradient back toward the plane, weights raised 5× from + # the run-2 values that were noise against progress@8. + cfg.rewards["roulade_sagittal"] = RewardTermCfg( + func=microduck_mdp.roulade_sagittal_penalty, + weight=-0.1, + ) + cfg.rewards["roulade_lateral_vel"] = RewardTermCfg( + func=microduck_mdp.roulade_lateral_velocity_penalty, + weight=-0.5, + ) + cfg.rewards["roulade_flatness"] = RewardTermCfg( + func=microduck_mdp.roulade_flatness_penalty, + weight=-0.5, + ) + + # ── Sim2real regularisers ───────────────────────────────────────────────── + # Motion-blockers stay near zero during discovery (the roll IS a large + # angular-velocity + impact event); the settle/polish pressure comes from + # the LATE-introduced gated terms below (arrival_damping, |a_z|, torque + # rate) — the standup timing lesson. + cfg.rewards["action_rate_l2"] = RewardTermCfg(func=mdp.action_rate_l2, weight=-0.1) + cfg.rewards["joint_torque_rate_l2"] = RewardTermCfg( + func=microduck_mdp.joint_torque_rate_l2, weight=0.0 + ) + + cfg.rewards["body_ang_vel"].params["asset_cfg"].body_names = ("trunk_base",) + cfg.rewards["body_ang_vel"].weight = -0.002 # must stay ≈0: the roll is ω + cfg.rewards["angular_momentum"].weight = -0.001 + cfg.rewards.pop("soft_landing", None) + + # Arrival damper — trunk ω_xy² gated on standing height AND low tilt, so + # the roll itself is never taxed; introduced at 0 and ramped by curriculum. + cfg.rewards["arrival_damping"] = RewardTermCfg( + func=microduck_mdp.body_ang_vel_at_height, + weight=0.0, + params={ + "height_low": 0.09, + "height_high": 0.11, + "tilt_full_deg": 20.0, + "tilt_zero_deg": 45.0, + "asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",)), + }, + ) + + # |a_z| impact shaping — active from step 0 (run-2 change: run 1 + # discovered a violent solution under zero impact cost and locked it in; + # discovery is easy in this env, so shaping the style from the start is + # the priority). Curriculum ramps it further. + # NOTE: trunk_vertical_accel_penalty is SELF-NEGATING (returns -|a_z|) → + # POSITIVE weight (penalty sign convention; a negative weight here would + # reward violence — caught in the run-2 smoke test, sum was positive). + cfg.rewards["gentle_landing"] = RewardTermCfg( + func=microduck_mdp.trunk_vertical_accel_penalty, + weight=0.002, + params={"asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",))}, + ) + + # Self-collision — LIGHT: a tucked roll needs body-on-body contact + # (knees against trunk); standup's -1.0 would fight the tuck. + cfg.rewards["self_collisions"] = RewardTermCfg( + func=mdp.self_collision_cost, + weight=-0.1, + params={"sensor_name": self_collision_cfg.name}, + ) + + # Always-on upright would oppose the flip (the old attempt's core failure); + # landing uprightness is handled by the completion-gated terms above. + if "upright" in cfg.rewards: + del cfg.rewards["upright"] + + # ── Observations (identical layout to walking / standup policies) ───────── + del cfg.observations["actor"].terms["base_lin_vel"] + + cfg.observations["critic"].terms["base_lin_vel"] = ObservationTermCfg( + func=mdp.base_lin_vel, scale=1.0, + ) + del cfg.observations["critic"].terms["foot_height"] + del cfg.observations["actor"].terms["height_scan"] + del cfg.observations["critic"].terms["height_scan"] + + gravity_term_name = "projected_gravity" + cfg.observations["actor"].terms[gravity_term_name] = deepcopy( + cfg.observations["actor"].terms[gravity_term_name] + ) + cfg.observations["actor"].terms["base_ang_vel"] = deepcopy( + cfg.observations["actor"].terms["base_ang_vel"] + ) + + cfg.observations["actor"].terms["base_ang_vel"].delay_min_lag = 0 + cfg.observations["actor"].terms["base_ang_vel"].delay_max_lag = 1 + cfg.observations["actor"].terms["base_ang_vel"].delay_update_period = 64 + cfg.observations["actor"].terms[gravity_term_name].delay_min_lag = 0 + cfg.observations["actor"].terms[gravity_term_name].delay_max_lag = 1 + cfg.observations["actor"].terms[gravity_term_name].delay_update_period = 64 + + cfg.observations["actor"].terms["base_ang_vel"].noise = Unoise(n_min=-0.03, n_max=0.03) + cfg.observations["actor"].terms[gravity_term_name].noise = Unoise(n_min=-0.01, n_max=0.01) + cfg.observations["actor"].terms["joint_pos"].noise = Unoise(n_min=-0.001, n_max=0.001) + cfg.observations["actor"].terms["joint_vel"].noise = Unoise(n_min=-0.25, n_max=0.25) + + if ENABLE_IMU_ORIENTATION_RANDOMIZATION: + av = cfg.observations["actor"].terms["base_ang_vel"] + av.func = microduck_mdp.base_ang_vel_imu_misaligned + av.params = {"max_angle_deg": IMU_ORIENTATION_RANDOMIZATION_ANGLE} + g = cfg.observations["actor"].terms[gravity_term_name] + g.func = microduck_mdp.projected_gravity_imu_misaligned + g.params = {"max_angle_deg": IMU_ORIENTATION_RANDOMIZATION_ANGLE} + + cfg.observations["actor"].terms["joint_vel"] = deepcopy( + cfg.observations["actor"].terms["joint_vel"] + ) + cfg.observations["actor"].terms["joint_vel"].delay_min_lag = 1 + cfg.observations["actor"].terms["joint_vel"].delay_max_lag = 1 + cfg.observations["actor"].terms["joint_vel"].delay_update_period = 0 + + passive_excluded = SceneEntityCfg("robot", joint_names=(r"^(?!passive_).*",)) + for grp in ("actor", "critic"): + for term in ("joint_pos", "joint_vel"): + cfg.observations[grp].terms[term] = deepcopy(cfg.observations[grp].terms[term]) + cfg.observations[grp].terms[term].params["asset_cfg"] = deepcopy(passive_excluded) + + if ENABLE_ENCODER_BIAS: + cfg.events["encoder_bias"].params["bias_range"] = ENCODER_BIAS_RANGE + cfg.observations["actor"].terms["joint_pos"].params["biased"] = True + cfg.observations["critic"].terms["joint_pos"].params["biased"] = False + else: + cfg.events.pop("encoder_bias", None) + + # Command obs slots: zero padding for BOTH head (4) and body (6) — the head + # is part of the task (it's the pivot), so no head_pose command here, but + # the 61D obs layout parity with velocity/standup is kept so the runtime + # stack works unchanged (send zeros). + for group in ("actor", "critic"): + cfg.observations[group].terms["head_command"] = ObservationTermCfg( + func=microduck_mdp.zero_command_padding, params={"dim": 4}, + ) + cfg.observations[group].terms["body_command"] = ObservationTermCfg( + func=microduck_mdp.zero_command_padding, params={"dim": 6}, + ) + + # ── Command: tiny noise around zero (kept for obs-shape parity) ────────── + command = cfg.commands["twist"] + command.rel_standing_envs = 0.0 + command.rel_heading_envs = 0.0 + command.heading_command = False + command.ranges.heading = None + command.resampling_time_range = (EPISODE_LENGTH_S, EPISODE_LENGTH_S * 2) + command.debug_vis = False + command.ranges.lin_vel_x = (-0.01, 0.01) + command.ranges.lin_vel_y = (-0.01, 0.01) + command.ranges.ang_vel_z = (-0.05, 0.05) + cfg.commands["twist"] = microduck_mdp.VelocityCommandCommandOnlyCfg(**vars(command)) + + # ── Terminations ────────────────────────────────────────────────────────── + # Falling over is the task — keep only the NaN guard + timeout. + if "fell_over" in cfg.terminations: + del cfg.terminations["fell_over"] + cfg.terminations["nan_state"] = TerminationTermCfg( + func=microduck_mdp.robot_state_is_nan, + time_out=False, + ) + + # ── Events ──────────────────────────────────────────────────────────────── + cfg.events["expand_bam_friction_fields"] = EventTermCfg( + func=microduck_mdp.expand_bam_friction_fields, + mode="startup", + ) + cfg.events["reset_action_history"] = EventTermCfg( + func=microduck_mdp.reset_action_history, + mode="reset", + ) + cfg.events["foot_friction"].params["asset_cfg"].geom_names = foot_frictions_geom_names + cfg.events["foot_friction"].params["ranges"] = (0.7, 1.3) + + # Standing start + mid-roll reverse-curriculum spawns; also resets the + # rotation accumulator (must run after reset_robot_joints — dict insertion + # order — since mid-roll tuck lerps FROM the HOME pose it wrote). + cfg.events["set_roulade_state"] = EventTermCfg( + func=microduck_mdp.reset_roulade_state, + mode="reset", + params={ + "standing_prob": 0.5, + "midroll_prob": 0.5, + "standing_z_min": 0.11, + "standing_z_max": 0.12, + "standing_tilt_max": math.radians(5.0), + "forward_vel_range": ROULADE_FORWARD_VEL_RANGE, + "midroll_pitch_min": MIDROLL_PITCH_MIN, + "midroll_pitch_max": MIDROLL_PITCH_MAX, + "midroll_z_min": 0.05, + "midroll_z_max": 0.10, + "midroll_omega_range": MIDROLL_OMEGA_RANGE, + "tuck_overrides": TUCK_OVERRIDES, + "tuck_factor_range": (0.3, 1.0), + "joint_noise_std": 0.08, + }, + ) + + if "push_robot" in cfg.events: + del cfg.events["push_robot"] + + if ENABLE_COM_RANDOMIZATION: + cfg.events["randomize_com"] = EventTermCfg( + func=dr.body_ipos, + mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",)), + "operation": "add", + "ranges": (-COM_RANDOMIZATION_RANGE, COM_RANDOMIZATION_RANGE), + }, + ) + + if ENABLE_HEAD_COM_RANDOMIZATION: + cfg.events["randomize_head_com"] = EventTermCfg( + func=dr.body_ipos, + mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot", body_names=HEAD_BODY_NAMES), + "operation": "add", + "ranges": (-HEAD_COM_RANDOMIZATION_RANGE, HEAD_COM_RANDOMIZATION_RANGE), + }, + ) + + if ENABLE_ARMATURE_RANDOMIZATION: + cfg.events["randomize_armature"] = EventTermCfg( + func=dr.joint_armature, + mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot", joint_names=(r".*",)), + "operation": "scale", + "ranges": ARMATURE_RANDOMIZATION_RANGE, + }, + ) + + if ENABLE_KP_RANDOMIZATION or ENABLE_KD_RANDOMIZATION: + kp_range = KP_RANDOMIZATION_RANGE if ENABLE_KP_RANDOMIZATION else (1.0, 1.0) + kd_range = KD_RANDOMIZATION_RANGE if ENABLE_KD_RANDOMIZATION else (1.0, 1.0) + cfg.events["randomize_motor_gains"] = EventTermCfg( + func=microduck_mdp.randomize_delayed_actuator_gains, + mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot"), + "operation": "scale", + "kp_range": kp_range, + "kd_range": kd_range, + }, + ) + + if ENABLE_MASS_INERTIA_RANDOMIZATION: + _mi_lo, _mi_hi = MASS_INERTIA_RANDOMIZATION_RANGE + cfg.events["randomize_mass_inertia"] = EventTermCfg( + func=dr.pseudo_inertia, + mode="startup", + params={ + "asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",)), + "alpha_range": (math.log(_mi_lo) / 2.0, math.log(_mi_hi) / 2.0), + }, + ) + + if ENABLE_JOINT_FRICTION_RANDOMIZATION: + cfg.events["randomize_joint_friction"] = EventTermCfg( + func=microduck_mdp.randomize_bam_friction, + mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot"), + "scale_range": JOINT_FRICTION_RANDOMIZATION_RANGE, + }, + ) + + # ── Terrain ─────────────────────────────────────────────────────────────── + cfg.scene.terrain.terrain_type = "plane" + cfg.scene.terrain.terrain_generator = None + + # ── Curriculum ──────────────────────────────────────────────────────────── + if "terrain_levels" in cfg.curriculum: + del cfg.curriculum["terrain_levels"] + del cfg.curriculum["command_vel"] + + # Reverse-curriculum mix: heavy mid-roll early (the completion sub-task is + # learnable from day 0 — it overlaps face-up recovery), shift toward + # standing starts as the full roll gets discovered. Mid-roll never goes to + # zero: it keeps the second half practiced and is realistic DR anyway. + # Run-3: stages pushed 1500/3000 → 3000/6000 — run 2 shifted away from + # mid-roll BEFORE standing-spawn rolls were mastered (progress episode-sum + # was ~20% of a full roll at iter 1876; curriculum-pacing failure, same + # family as the 2026-07-28 standup regression). + cfg.curriculum["roulade_spawn_mix"] = CurriculumTermCfg( + func=microduck_mdp.event_param_curriculum, + params={ + "event_name": "set_roulade_state", + "param_stages": [ + {"step": 0, "params": {"standing_prob": 0.50, "midroll_prob": 0.50}}, + {"step": 3000 * 24, "params": {"standing_prob": 0.65, "midroll_prob": 0.35}}, + {"step": 6000 * 24, "params": {"standing_prob": 0.80, "midroll_prob": 0.20}}, + ], + }, + ) + + if ENABLE_COM_RANDOMIZATION: + cfg.curriculum["com_range"] = CurriculumTermCfg( + func=microduck_mdp.com_range_curriculum, + params={ + "event_name": "randomize_com", + "range_stages": [ + {"step": 0, "range": 0.003}, + {"step": 500 * 24, "range": 0.005}, + {"step": 1000 * 24, "range": 0.01}, + {"step": 1500 * 24, "range": 0.015}, + ], + }, + ) + + if ENABLE_HEAD_COM_RANDOMIZATION: + cfg.curriculum["head_com_range"] = CurriculumTermCfg( + func=microduck_mdp.com_range_curriculum, + params={ + "event_name": "randomize_head_com", + "range_stages": [ + {"step": 0, "range": 0.003}, + {"step": 500 * 24, "range": 0.005}, + {"step": 1000 * 24, "range": 0.01}, + ], + }, + ) + + # action_rate ramp — run-4: ceiling softened -0.6 → -0.4 and the -0.4 + # stage pushed 2000 → 3000. Run-3's landing metrics peaked at ~iter 2700 + # then declined, tracking the -0.4/-0.6 stages — the tightening was + # squeezing the rise. (Run-2 note still holds: -0.1 minimum from step 0, + # run 1 bred violence under near-zero smoothing.) + cfg.curriculum["action_rate_weight"] = CurriculumTermCfg( + func=microduck_mdp.reward_weight, + params={ + "reward_name": "action_rate_l2", + "weight_stages": [ + {"step": 0, "weight": -0.1}, + {"step": 1500 * 24, "weight": -0.2}, + {"step": 3000 * 24, "weight": -0.4}, + ], + }, + ) + + # Smoothness polish — introduced only after the roll skill exists (standup + # timing lesson: any attempt-tax active during discovery prevents the + # maneuver from being found at all; fix is timing, not magnitude). + cfg.curriculum["arrival_damping_weight"] = CurriculumTermCfg( + func=microduck_mdp.reward_weight, + params={ + "reward_name": "arrival_damping", + "weight_stages": [ + {"step": 0, "weight": 0.0}, + {"step": 2500 * 24, "weight": -0.025}, + {"step": 3500 * 24, "weight": -0.05}, + ], + }, + ) + cfg.curriculum["torque_rate_weight"] = CurriculumTermCfg( + func=microduck_mdp.reward_weight, + params={ + "reward_name": "joint_torque_rate_l2", + "weight_stages": [ + {"step": 0, "weight": 0.0}, + {"step": 2500 * 24, "weight": -5e-4}, + {"step": 3500 * 24, "weight": -1e-3}, + ], + }, + ) + cfg.curriculum["gentle_landing_weight"] = CurriculumTermCfg( + func=microduck_mdp.reward_weight, + params={ + # POSITIVE weights: the func is self-negating (returns -|a_z|). + "reward_name": "gentle_landing", + "weight_stages": [ + {"step": 0, "weight": 0.002}, + {"step": 2500 * 24, "weight": 0.005}, + ], + }, + ) + + return cfg + + +# ── RL runner config ────────────────────────────────────────────────────────── + +MicroduckRouladeRlCfg = RslRlOnPolicyRunnerCfg( + actor=RslRlModelCfg( + hidden_dims=(512, 256, 128), + activation="elu", + obs_normalization=True, # normalizer MUST be baked into ONNX by export.py + distribution_cfg={ + "class_name": "GaussianDistribution", + "init_std": 1.0, + "std_type": "scalar", + }, + ), + critic=RslRlModelCfg( + hidden_dims=(512, 256, 128), + activation="elu", + obs_normalization=True, + ), + algorithm=PpoWithSymmetryCfg( + value_loss_coef=1.0, + use_clipped_value_loss=True, + clip_param=0.2, + entropy_coef=0.01, + num_learning_epochs=5, + num_mini_batches=4, + learning_rate=1.0e-3, + schedule="adaptive", + gamma=0.99, + lam=0.95, + desired_kl=0.01, + max_grad_norm=1.0, + symmetry_cfg=SYMMETRY_CFG if ENABLE_SYMMETRY else None, + ), + wandb_project="mjlab_microduck", + experiment_name="microduck_roulade", + run_name="microduck_roulade", + save_interval=250, + num_steps_per_env=24, + max_iterations=10_000, +) diff --git a/src/mjlab_microduck/tasks/microduck_sitstand_env_cfg.py b/src/mjlab_microduck/tasks/microduck_sitstand_env_cfg.py new file mode 100644 index 0000000..fb8f8c6 --- /dev/null +++ b/src/mjlab_microduck/tasks/microduck_sitstand_env_cfg.py @@ -0,0 +1,929 @@ +"""Microduck *sitstand* task (v1.5, mjlab 1.3.0) — commanded sit ↔ stand, GENTLY. + +One policy, both directions, driven by a posture command: + cmd (twist slot) = [sit_flag, 0, 0] sit_flag ∈ {0 = STAND, 1 = SIT} +"Stand" is the all-zero command — the same deployment idle as every other +policy. The command flips mid-episode with a dwell time of a few seconds, so +each episode trains descents, seated rest, rises and standing rest, plus +"hold what you're already doing" (reset state × command are independent). + +2026-08 rebuild from scratch (the old phase-cycle env predates the 1.3.0 +migration and every sit/standup lesson). Design synthesis: + - Posture-conditioned single-target rewards (mdp posture_*): the sit env's + minimum-viable "organic discovery" stack, but the target (SIT keyframe + + SIT_Z vs HOME + STAND_Z) is selected per env from the live command. No + trajectory, no waypoints, no phase timing — the policy discovers its own + transition path, in as many steps as it likes (knee-down first, head + assist, etc. are all allowed: full-collision model, no head-ground + penalty, no fall termination). + - Gentleness both ways: descent-speed cap (sit env's proven recipe, -10 + from step 0) AND a mirrored rise-speed cap (introduced by curriculum + AFTER the rise is discovered — the standup attempt-tax lesson), plus the + |a_z| shock penalty throughout. + - Rest quality: posture_stillness (velocity-Gaussian at the commanded + height, tilt-gated) + posture_composite (multiplicative height·upright· + pose vs the commanded target — partial-sum exploits like plank/flop/lean + collapse to ~0). + - Head commandable in BOTH postures (head_pose command + tracking, exactly + like velocity/standup), body_command slot zero-padded → 61D obs parity. + - Sim2real: velocity-parity DR / obs noise / delays / regularisers (the + transferring recipe), sit env's contact-solver hardening (nconmax=200, + iters 30/50 — seated contact NaN fix), delayed push ramp (pushes early + made the sit env unlearn sitting). + +Keyframes (stability-verified, keep in sync with sit/standup envs): + SIT = knee ±1.35, hip_pitch ∓0.4079, ankle/hip_roll 0, trunk z 0.060 + (swept 2026-07-27 — the old keyframe tipped over; verify TILT in sim + before changing this pose). + STAND = HOME joints, trunk z 0.115 (measured standing equilibrium). + +Joint layout (14 actuated joints): + 0-4 : left leg (hip_yaw, hip_roll, hip_pitch, knee, ankle) + 5-8 : neck/head (neck_pitch, head_pitch, head_yaw, head_roll) + 9-13: right leg (hip_yaw, hip_roll, hip_pitch, knee, ankle) +""" + +import math +from copy import deepcopy + +# Symmetry +ENABLE_SYMMETRY = False + +# ── Domain randomisation (matched to the velocity env for sim2real parity) ──── +ENABLE_COM_RANDOMIZATION = True +ENABLE_HEAD_COM_RANDOMIZATION = True # match velocity: randomize head-assembly CoM +ENABLE_KP_RANDOMIZATION = False # match velocity (OFF) +ENABLE_KD_RANDOMIZATION = False # match velocity (OFF) +ENABLE_MASS_INERTIA_RANDOMIZATION = True # match velocity: dr.pseudo_inertia (mass+inertia) +ENABLE_JOINT_FRICTION_RANDOMIZATION = True # match velocity: FrictionDRBamActuator.friction_scale +ENABLE_ARMATURE_RANDOMIZATION = True # match velocity: reflected rotor inertia +ENABLE_VELOCITY_PUSHES = True +ENABLE_IMU_ORIENTATION_RANDOMIZATION = True # match velocity: obs-level per-env misalignment +ENABLE_ENCODER_BIAS = True # match velocity: per-env joint encoder offset (actor obs) + +# ── Ranges (matched to the velocity env) ────────────────────────────────────── +COM_RANDOMIZATION_RANGE = 0.003 # ramped to 0.015 via com_range curriculum +HEAD_COM_RANDOMIZATION_RANGE = 0.003 # ramped to 0.01 via head_com_range curriculum +MASS_INERTIA_RANDOMIZATION_RANGE = (0.95, 1.05) +ARMATURE_RANDOMIZATION_RANGE = (0.9, 1.1) +JOINT_FRICTION_RANDOMIZATION_RANGE = (0.9, 1.1) +ENCODER_BIAS_RANGE = (-0.015, 0.015) +KP_RANDOMIZATION_RANGE = (0.85, 1.15) # unused (kp DR off) +KD_RANDOMIZATION_RANGE = (0.9, 1.1) # unused (kd DR off) +VELOCITY_PUSH_INTERVAL_S = (3.0, 6.0) +# Final magnitude matches velocity's ±0.3 but the ramp is DELAYED (see the +# push_magnitude curriculum): the sit env's lesson — pushes mid-descent before +# the transition motions have consolidated make the policy unlearn them and +# converge to "just stand doing nothing". +VELOCITY_PUSH_RANGE = (-0.3, 0.3) +IMU_ORIENTATION_RANDOMIZATION_ANGLE = 6.0 # match velocity (obs-level, zero-centered random axis) + +# Episode length: room for 2-3 posture segments (dwell 3.5-6.5 s each), i.e. +# at least one full sit → rest → rise → rest cycle per episode. +EPISODE_LENGTH_S = 12.0 +# Dwell time in each commanded posture before a resample may flip it. The +# lower bound must comfortably exceed a gentle transition (~1.5 s) plus some +# rest, so "arrive, then hold still" is always trained. +POSTURE_DWELL_S = (3.5, 6.5) +# Probability a resample commands SIT (vs STAND). 0.5 → all four combinations +# of (reset state × command) get equal coverage, including both holds. +SIT_PROB = 0.5 + +# ── SIT keyframe (joint_pos index → angle in rad). Single fixed target. ───── +# STABILITY-VERIFIED 2026-07-27 (sit env, scratchpad sweep_sit_pose2.py): +# knee ±1.35, hip_pitch = HOME ∓ 0.05 lean, ankle 0, hip_roll 0 settles at +# 3-5° tilt for 95-100% of noisy resets. The old keyframe (knee ±1.0472, +# hip_pitch HOME) is NOT statically stable — it tips to ~88° in 1 s and +# silently drove the sit env's whole hop/back-flop/plank exploit chain. +# If the robot or keyframe changes, RE-RUN THE SWEEP — verify tilt, not z. +# Keep in sync with microduck_sit_env_cfg.SITTING_TARGET_OVERRIDES and +# microduck_standup_env_cfg.SITTING_JOINT_OVERRIDES. +SITTING_TARGET_OVERRIDES = { + 1: 0.0, # left hip_roll (HOME -0.0873) + 2: -0.4079, # left hip_pitch (HOME -0.4579; +0.05 = slight fwd lean) + 3: 1.35, # left knee (HOME -0.0049) + 4: 0.0, # left ankle (HOME +0.4530) + # neck/head intentionally omitted → steered by the head_pose command. + 10: 0.0, # right hip_roll (HOME +0.0873) + 11: 0.4079, # right hip_pitch (HOME +0.4579) + 12: -1.35, # right knee (HOME +0.0049) + 13: 0.0, # right ankle (HOME -0.4530) +} + +_LEG_JOINTS = [0, 1, 2, 3, 4, 9, 10, 11, 12, 13] +_NECK_JOINTS = [5, 6, 7, 8] + +# Trunk height targets (m) — both MEASURED in sim, never carried across robot +# or keyframe changes (sit run-1 / standup lessons). +STAND_Z = 0.115 +SIT_Z = 0.060 + +# Upright gating window for ``upright_while_tall``: full upright incentive +# above STAND_UPRIGHT_Z, fades to 0 at SIT_UPRIGHT_Z (committed to the sit). +# Blocks the "tip backward while still high" descent exploit; the always-on +# upright_linear floor covers the seated regime. +STAND_UPRIGHT_Z = 0.10 +SIT_UPRIGHT_Z = 0.075 + +# Target-ramp duration (s): the command term slews an internal target blend +# STAND↔SIT over this time, and the posture rewards track the MOVING target. +# THE anti-crash mechanism (run-1 failure: near-instant transitions). With a +# binary target, arriving early pays the full goal jackpot (~7/step) for +# every step saved, while the linear speed caps integrate to a bounded +# excess-distance cost (~50 total for an instant drop) — crashing won ~7×. +# With the ramp, being AHEAD of the setpoint zeroes the height/composite +# stack for the ramp remainder, so tracking the slow setpoint is the argmax. +# 55 mm over 2 s ≈ 0.028 m/s, comfortably under both caps below. +POSTURE_RAMP_S = 2.0 + +# Vertical-speed caps (m/s) — now BACKSTOPS for overshoot/bounce around the +# slewed target (see POSTURE_RAMP_S), not the primary gentleness mechanism. +# The rise cap is looser (rising against gravity needs some momentum to get +# over the heels) and is introduced by curriculum only after the rise motion +# has been discovered — see the rise_speed_weight curriculum. +MAX_DESCENT_SPEED = 0.05 +MAX_RISE_SPEED = 0.08 + +from mjlab.envs import ManagerBasedRlEnvCfg +from mjlab.envs.mdp import dr +from mjlab.envs.mdp.actions import JointPositionActionCfg +from mjlab.managers import ( + CurriculumTermCfg, + EventTermCfg, + ObservationTermCfg, + RewardTermCfg, + TerminationTermCfg, +) +from mjlab.managers.scene_entity_config import SceneEntityCfg +from mjlab.rl import ( + RslRlOnPolicyRunnerCfg, + RslRlModelCfg, +) +from mjlab.sensor import ContactMatch, ContactSensorCfg +from mjlab.tasks.velocity import mdp +from mjlab.tasks.velocity.velocity_env_cfg import make_velocity_env_cfg +from mjlab.utils.noise import UniformNoiseCfg as Unoise + +from mjlab_microduck.robot.microduck_constants import MICRODUCK_STANDUP_ROBOT_CFG +from mjlab_microduck.tasks import mdp as microduck_mdp +from mjlab_microduck.tasks.microduck_velocity_env_cfg import ( + MICRODUCK_ROUGH_TERRAINS_CFG, + HEAD_BODY_NAMES, + HEAD_POSE_CMD_RESAMPLE_S, +) +from mjlab_microduck.tasks.symmetry import PpoWithSymmetryCfg, SYMMETRY_CFG + + +def make_microduck_sitstand_env_cfg( + play: bool = False, + rough: bool = False, +) -> ManagerBasedRlEnvCfg: + """Create Microduck sitstand environment configuration.""" + + feet_ground_cfg = ContactSensorCfg( + name="feet_ground_contact", + primary=ContactMatch( + mode="geom", + pattern=r"^(left_foot_collision|right_foot_collision)$", + entity="robot", + ), + secondary=ContactMatch(mode="body", pattern="terrain"), + fields=("found", "force"), + reduce="netforce", + num_slots=1, + track_air_time=True, + ) + + self_collision_cfg = ContactSensorCfg( + name="self_collision", + primary=ContactMatch(mode="subtree", pattern="trunk_base", entity="robot"), + secondary=ContactMatch(mode="subtree", pattern="trunk_base", entity="robot"), + fields=("found",), + reduce="none", + num_slots=1, + ) + + # NOTE: no head-ground contact penalty here (unlike the sit env). Using the + # head as a third support point during transitions is explicitly allowed — + # the plank-as-terminal-rest exploit is anti-selected by posture_composite + # + posture_stillness instead (both ≈0 at plank tilt/height). + + foot_frictions_geom_names = ("left_foot_collision", "right_foot_collision") + + # ── Base config ─────────────────────────────────────────────────────────── + cfg = make_velocity_env_cfg() + + # Standup robot variant: full collision meshes — the body must physically + # rest on the ground while seated, and knees/head may touch mid-transition. + cfg.scene.entities = {"robot": MICRODUCK_STANDUP_ROBOT_CFG} + cfg.scene.sensors = (feet_ground_cfg, self_collision_cfg) + cfg.viewer.body_name = "trunk_base" + + cfg.episode_length_s = EPISODE_LENGTH_S + + # ── Actions ─────────────────────────────────────────────────────────────── + joint_pos_action = cfg.actions["joint_pos"] + assert isinstance(joint_pos_action, JointPositionActionCfg) + joint_pos_action.scale = 1.0 + + # ── Rewards: drop walking-specific terms ────────────────────────────────── + for name in [ + "track_linear_velocity", + "track_angular_velocity", + "air_time", + "foot_clearance", + "foot_swing_height", + "foot_slip", + "pose", + ]: + if name in cfg.rewards: + del cfg.rewards[name] + + # ── Rewards: posture-conditioned single-target stack ────────────────────── + # Every task term below reads the commanded posture and selects its target + # (SIT keyframe + SIT_Z vs HOME + STAND_Z) per env. Weights mirror the sit + # env's proven stack (positive task mass ≈ velocity scale, so the shared + # sim2real regularisers act at the same RELATIVE strength — the standup + # transfer lesson). + + # Pose target — legs only (head is command-steered). Generous std keeps + # gradient alive from either end (~1.35 rad knee delta). + cfg.rewards["posture_pose_legs"] = RewardTermCfg( + func=microduck_mdp.posture_pose_match, + weight=4.0, + params={ + "command_name": "twist", + "std": 0.5, + "joint_indices": _LEG_JOINTS, + "sit_overrides": SITTING_TARGET_OVERRIDES, + }, + ) + + # Head pose tracking (commandable head control, like velocity/standup) — + # active in BOTH postures. Weight kept light so a transient head-assist + # during a transition only pays a small tracking cost. + cfg.rewards["head_pose_tracking"] = RewardTermCfg( + func=microduck_mdp.head_pose_tracking, + weight=0.75, + params={"command_name": "head_pose", "std": 0.5}, + ) + + # L1 bootstrap — constant gradient toward the commanded pose. + cfg.rewards["posture_pose_l1"] = RewardTermCfg( + func=microduck_mdp.posture_pose_l1, + weight=1.0, + params={ + "command_name": "twist", + "joint_indices": _LEG_JOINTS, + "sit_overrides": SITTING_TARGET_OVERRIDES, + }, + ) + + # Trunk height — two-layer Gaussian (standup recipe: wide layer for the + # bootstrap pull across the 55 mm travel, sharp layer so the final cm has + # real gradient instead of a saturated plateau) + L1 transition driver. + cfg.rewards["posture_height"] = RewardTermCfg( + func=microduck_mdp.posture_height_gaussian, + weight=1.0, + params={ + "command_name": "twist", + "sit_z": SIT_Z, + "stand_z": STAND_Z, + "std": 0.04, + }, + ) + cfg.rewards["posture_height_sharp"] = RewardTermCfg( + func=microduck_mdp.posture_height_gaussian, + weight=1.0, + params={ + "command_name": "twist", + "sit_z": SIT_Z, + "stand_z": STAND_Z, + "std": 0.015, + }, + ) + # L1 weight 6.0: between sit's 5.0 and standup's 7.5 — resting in the + # WRONG posture must be clearly net-negative in both directions (staying + # seated under a stand command was the standup env's stall mode at low L1). + cfg.rewards["posture_height_l1"] = RewardTermCfg( + func=microduck_mdp.posture_height_l1, + weight=6.0, + params={ + "command_name": "twist", + "sit_z": SIT_Z, + "stand_z": STAND_Z, + }, + ) + + # Rise bootstrap — pays for upward motion itself when STAND is commanded + # and the trunk is below 0.125 (just ABOVE the target so the final cm + # still pays). Destination-only rewards have zero gradient at zero motion; + # without this the standup env parked seated. Zero under a SIT command. + cfg.rewards["rise_bootstrap"] = RewardTermCfg( + func=microduck_mdp.posture_rise_bootstrap, + weight=0.75, + params={ + "command_name": "twist", + "max_height": 0.125, + "max_vz": MAX_RISE_SPEED, # explosive launch can't out-earn a gentle rise + }, + ) + + # ── Gentleness (the point of this env) — three complementary signals ───── + # - ``descent_speed``: per-step penalty on downward vz beyond 0.05 m/s. + # THE anti-brutality term for the sit: a fast drop pays on every step + # of the fall so it can't be amortised. -10 from step 0 (sit lesson: + # at -5 a crash-sit was net-positive), tightened to -20 by curriculum. + # - ``rise_speed``: the mirror cap for the stand-up (0.08 m/s). Starts at + # weight 0 and is introduced at iter 750 by curriculum — the standup + # lesson: a motion-tax active while the skill is being DISCOVERED makes + # exploratory attempts net-negative and the skill is never found. The + # sit-keyframe start is easy (no prone flips), so 750 is late enough. + # - ``gentle_motion``: |a_z| shock penalty, both directions, always on. + # + # ⚠️ POSITIVE weights, deliberately: these three functions ALREADY return + # negative values (-clamp(...), -|a_z|), same convention as the *_l1_penalty + # helpers (used with +1/+6 here). Run 7ev90yd9 (2026-08-12) had them at + # negative weights — the double negative made them REWARDS for violence + # (wandb: Episode_Reward/descent_speed +4.6, rise_speed +2.1, gentle_motion + # +0.57, the three biggest positive terms) and trained a butt-hopping, + # crash-sitting policy. Same bug class roller_standup found in gentle_rise. + # After any reward change, check wandb Episode_Reward/ stays ≤ 0. + cfg.rewards["descent_speed"] = RewardTermCfg( + func=microduck_mdp.trunk_downward_velocity_penalty, + weight=10.0, + params={ + "max_down_vel": MAX_DESCENT_SPEED, + "asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",)), + }, + ) + cfg.rewards["rise_speed"] = RewardTermCfg( + func=microduck_mdp.trunk_upward_velocity_penalty, + weight=0.0, + params={ + "max_up_vel": MAX_RISE_SPEED, + "asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",)), + }, + ) + cfg.rewards["gentle_motion"] = RewardTermCfg( + func=microduck_mdp.trunk_vertical_accel_penalty, + weight=0.05, + params={"asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",))}, + ) + + # Two-layer upright pressure (sit env values — the anti-flop calibration): + # - always-on linear floor: holds the trunk vertical at BOTH rests; at 2.5 + # "lie on your back" trails upright rest by ~4.5/step (sit run-2 fix). + # - height-gated booster: blocks the "tip backward while tall" descent + # exploit; during the rise it doubles as an arrival-uprightness pull. + cfg.rewards["upright_linear"] = RewardTermCfg( + func=microduck_mdp.body_upright_linear, + weight=2.5, + params={"asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",))}, + ) + cfg.rewards["upright_while_tall"] = RewardTermCfg( + func=microduck_mdp.upright_while_tall, + weight=1.5, + params={ + "height_low": SIT_UPRIGHT_Z, + "height_high": STAND_UPRIGHT_Z, + "asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",)), + }, + ) + + # Stillness at the commanded posture — "arrive, then rest QUIETLY, UPRIGHT" + # as an explicit positive peak. The z gate is a band around the commanded + # height (inactive during transitions); the tilt gate pays nothing for a + # tilted rest (back/face/side flops earn zero — the sit run-2 exploit). + cfg.rewards["posture_stillness"] = RewardTermCfg( + func=microduck_mdp.posture_stillness, + weight=2.0, + params={ + "command_name": "twist", + "sit_z": SIT_Z, + "stand_z": STAND_Z, + "band_full": 0.012, + "band_zero": 0.03, + "vel_std": 0.05, + "tilt_full_deg": 25.0, + "tilt_zero_deg": 60.0, + }, + ) + + # Multiplicative goal score vs the COMMANDED target — kills partial-sum + # farming in both postures (plank, flop, lean, park-1cm-short). Broad stds + # keep gradient visible far from the goal (standup's proven calibration). + # head_std adds the neck/head-at-command factor: the first sign-fixed run + # rested with the head DANGLING to the floor (trunk/legs/z all on target → + # full composite, only the 0.75 tracking term lost, and the hanging head + # adds passive stability). With the head factor, the goal state itself + # requires the head up at its commanded pose; transient head assist + # mid-transition stays free (composite ≈0 there anyway). + cfg.rewards["posture_composite"] = RewardTermCfg( + func=microduck_mdp.posture_composite, + weight=3.0, + params={ + "command_name": "twist", + "sit_overrides": SITTING_TARGET_OVERRIDES, + "joint_indices": _LEG_JOINTS, + "sit_z": SIT_Z, + "stand_z": STAND_Z, + "height_std": 0.03, + "upright_std": 0.40, # ≈ 23° effective — plank (~70°+) scores ~0 + "pose_std": 0.40, + "head_std": 0.40, # head fully dropped (~1.2 rad) → factor ~0.01 + }, + ) + + # ── Sim2real regularisers — MATCHED to velocity ───────────────────────── + # velocity's exact set and absolute weights: + # • action_rate_l2: -0.1 at stage 0, ramped -0.1 → -1.0 by iter 1500 + # • body_ang_vel -0.05, angular_momentum -0.02 + # • soft_landing dropped; joint_torques_l2 / neck_action_rate_l2 not added + # Plus joint_torque_rate_l2 (anti-jitter), phased in once the transition + # motions exist. Both caps + |a_z| already push toward slow-careful motion; + # per the regularizer-type lesson these smoothness terms damp jitter + # WITHOUT blocking a slow big motion, so heavier-than-velocity would also + # be defensible — start at parity, tighten only if the real robot shakes. + cfg.rewards["action_rate_l2"] = RewardTermCfg(func=mdp.action_rate_l2, weight=-0.1) + cfg.rewards["joint_torque_rate_l2"] = RewardTermCfg( + func=microduck_mdp.joint_torque_rate_l2, weight=0.0 + ) + + cfg.rewards["body_ang_vel"].params["asset_cfg"].body_names = ("trunk_base",) + cfg.rewards["body_ang_vel"].weight = -0.05 # velocity value + cfg.rewards["angular_momentum"].weight = -0.02 # velocity value + cfg.rewards.pop("soft_landing", None) # velocity removes it + + cfg.rewards["self_collisions"] = RewardTermCfg( + func=mdp.self_collision_cost, + weight=-1.0, + params={"sensor_name": self_collision_cfg.name}, + ) + + # Drop the base "upright" Gaussian — replaced by the two-layer upright above. + if "upright" in cfg.rewards: + del cfg.rewards["upright"] + + # ── Observations (identical layout to walking / sit / standup policies) ─── + del cfg.observations["actor"].terms["base_lin_vel"] + + cfg.observations["critic"].terms["base_lin_vel"] = ObservationTermCfg( + func=mdp.base_lin_vel, scale=1.0, + ) + # mjlab 1.3.0 base template adds sensor-based foot_height + height_scan obs. + # Sitstand has no terrain-height sensor (and drops the walking foot rewards), + # so remove these terms. foot_air_time/foot_contact(_forces) use the + # feet_ground_contact sensor, which sitstand does define, so they stay. + del cfg.observations["critic"].terms["foot_height"] + del cfg.observations["actor"].terms["height_scan"] + del cfg.observations["critic"].terms["height_scan"] + + gravity_term_name = "projected_gravity" + cfg.observations["actor"].terms[gravity_term_name] = deepcopy( + cfg.observations["actor"].terms[gravity_term_name] + ) + cfg.observations["actor"].terms["base_ang_vel"] = deepcopy( + cfg.observations["actor"].terms["base_ang_vel"] + ) + + # IMU obs delay: max_lag 1 — velocity's 2026-07 audit value (real dxl IMU + # path is fast, ±20 ms envelope). + cfg.observations["actor"].terms["base_ang_vel"].delay_min_lag = 0 + cfg.observations["actor"].terms["base_ang_vel"].delay_max_lag = 1 + cfg.observations["actor"].terms["base_ang_vel"].delay_update_period = 64 + cfg.observations["actor"].terms[gravity_term_name].delay_min_lag = 0 + cfg.observations["actor"].terms[gravity_term_name].delay_max_lag = 1 + cfg.observations["actor"].terms[gravity_term_name].delay_update_period = 64 + + # Obs noise matched to the velocity env. + cfg.observations["actor"].terms["base_ang_vel"].noise = Unoise(n_min=-0.03, n_max=0.03) + cfg.observations["actor"].terms[gravity_term_name].noise = Unoise(n_min=-0.01, n_max=0.01) + cfg.observations["actor"].terms["joint_pos"].noise = Unoise(n_min=-0.001, n_max=0.001) + cfg.observations["actor"].terms["joint_vel"].noise = Unoise(n_min=-0.25, n_max=0.25) + + # IMU mounting-misalignment DR (match velocity): per-env constant rotation of + # the IMU-derived actor obs; critic keeps the true values. + if ENABLE_IMU_ORIENTATION_RANDOMIZATION: + av = cfg.observations["actor"].terms["base_ang_vel"] + av.func = microduck_mdp.base_ang_vel_imu_misaligned + av.params = {"max_angle_deg": IMU_ORIENTATION_RANDOMIZATION_ANGLE} + g = cfg.observations["actor"].terms[gravity_term_name] + g.func = microduck_mdp.projected_gravity_imu_misaligned + g.params = {"max_angle_deg": IMU_ORIENTATION_RANDOMIZATION_ANGLE} + + # 1-ctrl-step lag on joint_vel (Dynamixel present_velocity is ~1 period old). + cfg.observations["actor"].terms["joint_vel"] = deepcopy( + cfg.observations["actor"].terms["joint_vel"] + ) + cfg.observations["actor"].terms["joint_vel"].delay_min_lag = 1 + cfg.observations["actor"].terms["joint_vel"].delay_max_lag = 1 + cfg.observations["actor"].terms["joint_vel"].delay_update_period = 0 + + # Deepcopy joint_pos/joint_vel per group (they share base-template objects) so + # the encoder-bias `biased` flag below applies to the actor only. + passive_excluded = SceneEntityCfg("robot", joint_names=(r"^(?!passive_).*",)) + for grp in ("actor", "critic"): + for term in ("joint_pos", "joint_vel"): + cfg.observations[grp].terms[term] = deepcopy(cfg.observations[grp].terms[term]) + cfg.observations[grp].terms[term].params["asset_cfg"] = deepcopy(passive_excluded) + + # Encoder-bias DR (match velocity): actor sees joint_pos + per-env bias; + # critic keeps the true joint pos. + if ENABLE_ENCODER_BIAS: + cfg.events["encoder_bias"].params["bias_range"] = ENCODER_BIAS_RANGE + cfg.observations["actor"].terms["joint_pos"].params["biased"] = True + cfg.observations["critic"].terms["joint_pos"].params["biased"] = False + else: + cfg.events.pop("encoder_bias", None) + + # ── Head pose command (commandable head control, like velocity/standup) ── + cfg.commands["head_pose"] = microduck_mdp.UniformPoseCommandCfg( + resampling_time_range=HEAD_POSE_CMD_RESAMPLE_S, + ranges=( + (-0.05, 0.05), # neck_pitch + (-0.05, 0.05), # head_pitch + (-0.07, 0.07), # head_yaw + (-0.015, 0.015), # head_roll + ), + ) + + # Command obs slots. head_command is the real head_pose command; + # body_command stays zero-padded (body control not used here). + # Layout parity with velocity/standup: [twist(3), head_pose(4), body_pose(6)]. + for group in ("actor", "critic"): + cfg.observations[group].terms["head_command"] = ObservationTermCfg( + func=mdp.generated_commands, params={"command_name": "head_pose"}, + ) + cfg.observations[group].terms["body_command"] = ObservationTermCfg( + func=microduck_mdp.zero_command_padding, params={"dim": 6}, + ) + + # ── Command: sit/stand posture flag in the twist slot ──────────────────── + # cmd = [sit_flag, 0, 0]; dwell-time resampling flips the posture mid- + # episode. "Stand" is the all-zero command (deployment idle parity). The + # runtime drives this by writing 0/1 into the vx slot of the command + # buffer. Internally the term slews a target blend over POSTURE_RAMP_S + # that the posture rewards track (see the constant's comment); the OBS + # stays the raw binary flag. + command = cfg.commands["twist"] + command.rel_standing_envs = 0.0 + command.rel_heading_envs = 0.0 + command.heading_command = False + command.ranges.heading = None + command.resampling_time_range = POSTURE_DWELL_S + command.debug_vis = False + cfg.commands["twist"] = microduck_mdp.SitStandCommandCfg( + **{ + **vars(command), + "sit_prob": SIT_PROB, + "ramp_s": POSTURE_RAMP_S, + "sit_z": SIT_Z, + "stand_z": STAND_Z, + } + ) + + # ── Terminations ────────────────────────────────────────────────────────── + # No fall termination: wobbles/tips during transitions must play out so the + # policy experiences the impact/upright costs instead of a truncated episode. + if "fell_over" in cfg.terminations: + del cfg.terminations["fell_over"] + cfg.terminations["nan_state"] = TerminationTermCfg( + func=microduck_mdp.robot_state_is_nan, + time_out=False, + ) + + # ── Events ──────────────────────────────────────────────────────────────── + # BAM (mjlab_frictionloss branch) writes per-env dof_frictionloss/dof_damping + # every step; this no-op event registers those fields for per-world expansion. + cfg.events["expand_bam_friction_fields"] = EventTermCfg( + func=microduck_mdp.expand_bam_friction_fields, + mode="startup", + ) + + cfg.events["reset_action_history"] = EventTermCfg( + func=microduck_mdp.reset_action_history, + mode="reset", + ) + cfg.events["foot_friction"].params["asset_cfg"].geom_names = foot_frictions_geom_names + cfg.events["foot_friction"].params["ranges"] = (0.7, 1.3) # match velocity + + # Base reset: standing, just above the measured equilibrium (STAND_Z=0.115). + cfg.events["reset_base"].params["pose_range"]["z"] = (0.11, 0.12) + + # Reset-state mix: 50% standing / 50% already seated (SIT keyframe with + # joint/tilt noise). Combined with the independent 50/50 posture command + # this trains all four cases — sit-from-stand, rise-from-sit, hold-stand, + # hold-sit — and hands the policy both goal states' values directly (the + # sit env's discovery-bootstrap lesson, extended to both ends). + cfg.events["set_ground_state"] = EventTermCfg( + func=microduck_mdp.set_random_ground_state, + mode="reset", + params={ + "face_down_prob": 0.0, + "face_up_prob": 0.0, + "sitting_prob": 0.5, + "standing_prob": 0.5, + "sitting_joint_overrides": SITTING_TARGET_OVERRIDES, + "sitting_joint_noise_std": 0.10, # ≈ 6° per joint + "sitting_tilt_max": math.radians(8), + "sitting_z_min": 0.06, # settles to the 0.060 rest + "sitting_z_max": 0.075, + "standing_z_min": 0.11, + "standing_z_max": 0.12, + }, + ) + + # MuJoCo physics robustness (sit env's contact NaN fix). The standup XML + # has full collisions on every body; the seated pose puts trunk + folded + # legs + head all in close ground/self contact. Default nconmax=35 and + # solver iters=10 overflow the contact solver on sit attempts → NaN → + # nan_state terminations that punish the descent itself ("learn then + # unlearn by iter 500" pattern). + cfg.sim.nconmax = 200 + cfg.sim.mujoco.iterations = 30 + cfg.sim.mujoco.ls_iterations = 50 + + if ENABLE_VELOCITY_PUSHES: + interval = (0.5, 1.0) if play else VELOCITY_PUSH_INTERVAL_S + cfg.events["push_robot"] = EventTermCfg( + func=mdp.push_by_setting_velocity, + mode="interval", + interval_range_s=interval, + params={ + "velocity_range": { + "x": VELOCITY_PUSH_RANGE, + "y": VELOCITY_PUSH_RANGE, + }, + "asset_cfg": SceneEntityCfg("robot"), + }, + ) + + if ENABLE_COM_RANDOMIZATION: + # mjlab 1.3.0: stock dr.body_ipos (operation="add") reads the compile-time + # default each reset → non-accumulating natively. + cfg.events["randomize_com"] = EventTermCfg( + func=dr.body_ipos, + mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",)), + "operation": "add", + "ranges": (-COM_RANDOMIZATION_RANGE, COM_RANDOMIZATION_RANGE), + }, + ) + + if ENABLE_HEAD_COM_RANDOMIZATION: + cfg.events["randomize_head_com"] = EventTermCfg( + func=dr.body_ipos, + mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot", body_names=HEAD_BODY_NAMES), + "operation": "add", + "ranges": (-HEAD_COM_RANDOMIZATION_RANGE, HEAD_COM_RANDOMIZATION_RANGE), + }, + ) + + if ENABLE_ARMATURE_RANDOMIZATION: + cfg.events["randomize_armature"] = EventTermCfg( + func=dr.joint_armature, + mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot", joint_names=(r".*",)), + "operation": "scale", + "ranges": ARMATURE_RANDOMIZATION_RANGE, + }, + ) + + if ENABLE_KP_RANDOMIZATION or ENABLE_KD_RANDOMIZATION: + kp_range = KP_RANDOMIZATION_RANGE if ENABLE_KP_RANDOMIZATION else (1.0, 1.0) + kd_range = KD_RANDOMIZATION_RANGE if ENABLE_KD_RANDOMIZATION else (1.0, 1.0) + cfg.events["randomize_motor_gains"] = EventTermCfg( + func=microduck_mdp.randomize_delayed_actuator_gains, + mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot"), + "operation": "scale", + "kp_range": kp_range, + "kd_range": kd_range, + }, + ) + + if ENABLE_MASS_INERTIA_RANDOMIZATION: + # match velocity: physics-consistent mass+inertia via pseudo_inertia + # (alpha scales both by e^(2α), CoM untouched). Startup mode. + _mi_lo, _mi_hi = MASS_INERTIA_RANDOMIZATION_RANGE + cfg.events["randomize_mass_inertia"] = EventTermCfg( + func=dr.pseudo_inertia, + mode="startup", + params={ + "asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",)), + "alpha_range": (math.log(_mi_lo) / 2.0, math.log(_mi_hi) / 2.0), + }, + ) + + if ENABLE_JOINT_FRICTION_RANDOMIZATION: + # match velocity: scale BAM's friction budget per-env via the + # FrictionDRBamActuator hook (dof_frictionloss is zeroed under BAM). + cfg.events["randomize_joint_friction"] = EventTermCfg( + func=microduck_mdp.randomize_bam_friction, + mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot"), + "scale_range": JOINT_FRICTION_RANDOMIZATION_RANGE, + }, + ) + + # NOTE: IMU mounting-misalignment is applied at the OBSERVATION level above + # (matching velocity) — the old event-based randomize_imu_orientation wrote + # site_quat, which under mjlab 1.3.0 is neither per-env nor read by the obs. + + # ── Terrain ─────────────────────────────────────────────────────────────── + if not rough: + cfg.scene.terrain.terrain_type = "plane" + cfg.scene.terrain.terrain_generator = None + else: + cfg.scene.terrain.terrain_type = "generator" + cfg.scene.terrain.terrain_generator = MICRODUCK_ROUGH_TERRAINS_CFG + if play: + cfg.scene.terrain.terrain_generator.curriculum = False + cfg.scene.terrain.terrain_generator.num_cols = 5 + cfg.scene.terrain.terrain_generator.num_rows = 5 + + # ── Curriculum ──────────────────────────────────────────────────────────── + if not rough: + del cfg.curriculum["terrain_levels"] + del cfg.curriculum["command_vel"] + + # Head pose command range curriculum — same per-joint widening as the + # velocity/standup envs (5% → 100% of each joint's reachable delta). + cfg.curriculum["head_pose_range"] = CurriculumTermCfg( + func=microduck_mdp.pose_command_range_curriculum, + params={ + "command_name": "head_pose", + "range_stages": [ + {"step": 0, "ranges": ((-0.05, 0.05), (-0.05, 0.05), (-0.07, 0.07), (-0.015, 0.015))}, + {"step": 500 * 24, "ranges": ((-0.17, 0.17), (-0.17, 0.17), (-0.21, 0.21), (-0.047, 0.047))}, + {"step": 1000 * 24, "ranges": ((-0.39, 0.39), (-0.39, 0.39), (-0.49, 0.49), (-0.11, 0.11))}, + {"step": 1500 * 24, "ranges": ((-0.72, 0.72), (-0.72, 0.72), (-0.91, 0.91), (-0.20, 0.20))}, + {"step": 2000 * 24, "ranges": ((-1.10, 1.10), (-1.10, 1.10), (-1.40, 1.40), (-0.31, 0.31))}, + ], + }, + ) + + # CoM-randomization range curricula — match velocity (trunk capped at ±15 mm, + # head at ±10 mm, per the 2026-07 audit). + if ENABLE_COM_RANDOMIZATION: + cfg.curriculum["com_range"] = CurriculumTermCfg( + func=microduck_mdp.com_range_curriculum, + params={ + "event_name": "randomize_com", + "range_stages": [ + {"step": 0, "range": 0.003}, + {"step": 500 * 24, "range": 0.005}, + {"step": 1000 * 24, "range": 0.01}, + {"step": 1500 * 24, "range": 0.015}, + ], + }, + ) + + if ENABLE_HEAD_COM_RANDOMIZATION: + cfg.curriculum["head_com_range"] = CurriculumTermCfg( + func=microduck_mdp.com_range_curriculum, + params={ + "event_name": "randomize_head_com", + "range_stages": [ + {"step": 0, "range": 0.003}, + {"step": 500 * 24, "range": 0.005}, + {"step": 1000 * 24, "range": 0.01}, + ], + }, + ) + + # Push curriculum — delayed significantly (sit env lesson): a push + # mid-transition tips the robot into configurations it can't recover from + # before the motions have consolidated; early pushes made the sit policy + # unlearn sitting and converge to "just stand doing nothing". + if ENABLE_VELOCITY_PUSHES: + cfg.curriculum["push_magnitude"] = CurriculumTermCfg( + func=microduck_mdp.push_curriculum, + params={ + "event_name": "push_robot", + "push_stages": [ + {"step": 0, "velocity_range": {"x": (0.0, 0.0), "y": (0.0, 0.0)}}, + {"step": 1000 * 24, "velocity_range": {"x": (-0.05, 0.05), "y": (-0.05, 0.05)}}, + {"step": 1500 * 24, "velocity_range": {"x": (-0.10, 0.10), "y": (-0.10, 0.10)}}, + {"step": 2000 * 24, "velocity_range": {"x": (-0.20, 0.20), "y": (-0.20, 0.20)}}, + {"step": 2500 * 24, "velocity_range": {"x": VELOCITY_PUSH_RANGE, "y": VELOCITY_PUSH_RANGE}}, + ], + }, + ) + + # action_rate curriculum — velocity's exact ramp (-0.1 → -1.0 by iter 1500). + cfg.curriculum["action_rate_weight"] = CurriculumTermCfg( + func=microduck_mdp.reward_weight, + params={ + "reward_name": "action_rate_l2", + "weight_stages": [ + {"step": 0, "weight": -0.1}, + {"step": 500 * 24, "weight": -0.2}, + {"step": 750 * 24, "weight": -0.4}, + {"step": 1000 * 24, "weight": -0.6}, + {"step": 1250 * 24, "weight": -0.8}, + {"step": 1500 * 24, "weight": -1.0}, + ], + }, + ) + + # Descent-speed cap tightening: discover the sit under magnitude 10 + # (crash-sit already net-negative), then tighten to 20. POSITIVE weights — + # the function is self-negating (see the sign-convention warning at the + # reward definitions). + cfg.curriculum["descent_speed_weight"] = CurriculumTermCfg( + func=microduck_mdp.reward_weight, + params={ + "reward_name": "descent_speed", + "weight_stages": [ + {"step": 0, "weight": 10.0}, + {"step": 500 * 24, "weight": 20.0}, + ], + }, + ) + + # Rise-speed cap — introduced only AFTER the rise motion exists (the + # standup attempt-tax lesson: any motion-tax during discovery makes + # exploratory attempts net-negative and the skill is never found). + # Pushed 750/1250 → 1500/2500: the rise needs a brief dynamic burst to + # rock over the heels (vz > 0.08 for a few steps), and the first + # sign-fixed run stalled in a head-down forward fold — a half-finished + # rise — consistent with the cap taxing the final weight shift while it + # was still being consolidated. Sit-direction gentleness doesn't depend + # on this cap (descent_speed covers it), so late is cheap. If the rise + # degrades when this kicks in, soften the final stage — never earlier. + cfg.curriculum["rise_speed_weight"] = CurriculumTermCfg( + func=microduck_mdp.reward_weight, + params={ + "reward_name": "rise_speed", + "weight_stages": [ + {"step": 0, "weight": 0.0}, + {"step": 1500 * 24, "weight": 5.0}, + {"step": 2500 * 24, "weight": 10.0}, + ], + }, + ) + + # Torque-rate anti-jitter — phased in once both transition motions exist. + cfg.curriculum["torque_rate_weight"] = CurriculumTermCfg( + func=microduck_mdp.reward_weight, + params={ + "reward_name": "joint_torque_rate_l2", + "weight_stages": [ + {"step": 0, "weight": 0.0}, + {"step": 750 * 24, "weight": -5e-4}, + {"step": 1250 * 24, "weight": -1e-3}, + ], + }, + ) + + return cfg + + +# ── RL runner config ────────────────────────────────────────────────────────── + +MicroduckSitStandRlCfg = RslRlOnPolicyRunnerCfg( + actor=RslRlModelCfg( + hidden_dims=(512, 256, 128), + activation="elu", + obs_normalization=True, # matches velocity; normalizer MUST be baked into ONNX by export.py + distribution_cfg={ + "class_name": "GaussianDistribution", + "init_std": 1.0, + "std_type": "scalar", + }, + ), + critic=RslRlModelCfg( + hidden_dims=(512, 256, 128), + activation="elu", + obs_normalization=True, + ), + algorithm=PpoWithSymmetryCfg( + value_loss_coef=1.0, + use_clipped_value_loss=True, + clip_param=0.2, + entropy_coef=0.01, + num_learning_epochs=5, + num_mini_batches=4, + learning_rate=1.0e-3, + schedule="adaptive", + gamma=0.99, + lam=0.95, + desired_kl=0.01, + max_grad_norm=1.0, + symmetry_cfg=SYMMETRY_CFG if ENABLE_SYMMETRY else None, + ), + wandb_project="mjlab_microduck", + experiment_name="microduck_sitstand", + run_name="microduck_sitstand", + save_interval=250, + num_steps_per_env=24, + max_iterations=15_000, +) diff --git a/src/mjlab_microduck/tasks/microduck_spin_env_cfg.py b/src/mjlab_microduck/tasks/microduck_spin_env_cfg.py new file mode 100644 index 0000000..60cf32e --- /dev/null +++ b/src/mjlab_microduck/tasks/microduck_spin_env_cfg.py @@ -0,0 +1,485 @@ +"""Microduck SPIN task — rotation rapide sur place, sur rollers. + +Geste cyclique déclenché au bouton A via le slot --ground-pick du runtime : +~1 tour anti-horaire à ~3 rad/s puis arrêt propre debout. + +Hybride : + - physique / robot roller ← microduck_velocity_rollers_env_cfg.py + - machinerie phase cyclique ← microduck_roller_crouch_env_cfg.py + (commande GroundPickPhaseCommand : [cos(2πφ), sin(2πφ), 0], période 4 s) + +Différence de fond avec le crouch : la phase pilote une VITESSE DE LACET cible +(objectif de résultat) et non une pose articulaire. Deux amorces décroissantes +poussent vers le roulement différentiel — le seul mécanisme physique certain sur +4 roues passives : patin gauche vers l'arrière, patin droit vers l'avant. + +Obs 61D unifié → interchangeable au runtime avec roller / ground_pick / crouch. +Voir docs/superpowers/specs/2026-08-04-spin-env-design.md. +""" + +import math +from copy import deepcopy + +# La symétrie G/D transformerait un spin à gauche en spin à droite : interdit ici. +ENABLE_SYMMETRY = False + +# DR — repris du roller env +ENABLE_COM_RANDOMIZATION = True +ENABLE_HEAD_COM_RANDOMIZATION = True +ENABLE_MASS_INERTIA_RANDOMIZATION = True +ENABLE_JOINT_FRICTION_RANDOMIZATION = True +ENABLE_ARMATURE_RANDOMIZATION = True +ENABLE_WHEEL_FRICTION_RANDOMIZATION = True +ENABLE_VELOCITY_PUSHES = True +ENABLE_IMU_ORIENTATION_RANDOMIZATION = True +ENABLE_ENCODER_BIAS = True + +COM_RANDOMIZATION_RANGE = 0.003 +HEAD_COM_RANDOMIZATION_RANGE = 0.003 +MASS_INERTIA_RANDOMIZATION_RANGE = (0.95, 1.05) +JOINT_FRICTION_RANDOMIZATION_RANGE = (0.9, 1.1) +ARMATURE_RANDOMIZATION_RANGE = (0.9, 1.1) +VELOCITY_PUSH_INTERVAL_S = (3.0, 6.0) +VELOCITY_PUSH_RANGE = (-0.2, 0.2) +IMU_ORIENTATION_RANDOMIZATION_ANGLE = 6.0 +ENCODER_BIAS_RANGE = (-0.015, 0.015) + +# Le bouton peut être pressé à l'arrêt OU en roulement lent : la policy apprend +# à tuer l'élan résiduel avant/pendant le lancement de la rotation. +ENTRY_VELOCITY_X = (0.0, 0.3) + +from mjlab.envs import ManagerBasedRlEnvCfg +from mjlab.envs.mdp import dr +from mjlab.envs.mdp.actions import JointPositionActionCfg +from mjlab.managers import ( + CurriculumTermCfg, + EventTermCfg, + ObservationTermCfg, + RewardTermCfg, + TerminationTermCfg, +) +from mjlab.managers.scene_entity_config import SceneEntityCfg +from mjlab.rl import RslRlOnPolicyRunnerCfg, RslRlModelCfg +from mjlab.sensor import ContactMatch, ContactSensorCfg +from mjlab.tasks.velocity import mdp +from mjlab.tasks.velocity.mdp import UniformVelocityCommandCfg +from mjlab.tasks.velocity.velocity_env_cfg import make_velocity_env_cfg +from mjlab.utils.noise import UniformNoiseCfg as Unoise + +from mjlab_microduck.robot.microduck_constants import MICRODUCK_WALK_ROLLERS_ROBOT_CFG +from mjlab_microduck.tasks import mdp as microduck_mdp +from mjlab_microduck.tasks.microduck_velocity_env_cfg import HEAD_BODY_NAMES +from mjlab_microduck.tasks.symmetry import PpoWithSymmetryCfg, SYMMETRY_CFG + +# Enveloppe de phase : constantes canoniques définies dans mdp.py. +SPIN_PERIOD = microduck_mdp.SPIN_PERIOD +_ENVELOPE = { + "rate_max": microduck_mdp.SPIN_RATE_MAX, + "accel_end": microduck_mdp.SPIN_ACCEL_END, + "hold_end": microduck_mdp.SPIN_HOLD_END, + "brake_end": microduck_mdp.SPIN_BRAKE_END, +} +# Nuque/tête tenues près du neutre SAUF head_yaw, laissé libre : il peut servir +# de volant d'inertie pour lancer la rotation. +NECK_PATTERN_NO_YAW = r"^(neck_pitch|head_pitch|head_roll)$" + + +def make_microduck_spin_env_cfg(play: bool = False) -> ManagerBasedRlEnvCfg: + """Env spin sur rollers, piloté par la phase du slot ground-pick.""" + + feet_ground_cfg = ContactSensorCfg( + name="feet_ground_contact", + primary=ContactMatch( + mode="subtree", + pattern=r"^(ankle_l_v1|ankle_r_v1)$", + entity="robot", + ), + secondary=ContactMatch(mode="body", pattern="terrain"), + fields=("found", "force"), + reduce="netforce", + num_slots=1, + track_air_time=True, + ) + self_collision_cfg = ContactSensorCfg( + name="self_collision", + primary=ContactMatch(mode="subtree", pattern="trunk_base", entity="robot"), + secondary=ContactMatch(mode="subtree", pattern="trunk_base", entity="robot"), + fields=("found",), + reduce="none", + num_slots=1, + ) + + cfg = make_velocity_env_cfg() + cfg.scene.entities = {"robot": MICRODUCK_WALK_ROLLERS_ROBOT_CFG} + cfg.scene.sensors = (feet_ground_cfg, self_collision_cfg) + cfg.viewer.body_name = "trunk_base" + + joint_pos_action = cfg.actions["joint_pos"] + assert isinstance(joint_pos_action, JointPositionActionCfg) + joint_pos_action.scale = 1.0 + + # === REWARDS === + # ⚠️ angular_momentum n'est PAS gardée : elle pénalise la norme 3D du moment + # angulaire, donc elle combattrait directement le spin. body_ang_vel, elle, + # ne pénalise que x/y (« Don't penalize z-angular velocity » dans mjlab) → + # gardée, elle mate le ballant roulis/tangage sans gêner la rotation. + keep = {"upright", "body_ang_vel", "action_rate_l2"} + for name in list(cfg.rewards.keys()): + if name not in keep: + del cfg.rewards[name] + + cfg.rewards["upright"].params["asset_cfg"].body_names = ("trunk_base",) + cfg.rewards["upright"].weight = 2.0 + cfg.rewards["body_ang_vel"].params["asset_cfg"].body_names = ("trunk_base",) + cfg.rewards["body_ang_vel"].weight = -0.05 + cfg.rewards["action_rate_l2"].weight = -1.0 + + # Objectif principal : suivre la vitesse de lacet cible ω*(φ) (trapèze). + cfg.rewards["spin_rate_track"] = RewardTermCfg( + func=microduck_mdp.spin_rate_track, + weight=6.0, + params={"command_name": "twist", "std": 1.5, **_ENVELOPE}, + ) + # Bootstrap L1 : gradient constant quand la gaussienne sature loin de la cible. + cfg.rewards["spin_rate_l1"] = RewardTermCfg( + func=microduck_mdp.spin_rate_l1, + weight=0.5, + params={"command_name": "twist", **_ENVELOPE}, + ) + # Tourner SUR PLACE, et tuer l'élan d'entrée. Renforcé -1.0 -> -3.0 : au run de + # calibrage à 500 it. le tronc translatait à ~0.35 m/s (~ω·demi-voie), signature + # d'un pivot sur un seul patin plutôt qu'un spin centré sur le corps — c'est le + # seul terme qui distingue un spin centré d'un pivot excentré. + # Atténué pendant la rampe de lancement [0, ACCEL_END) : c'est le moment où le + # robot doit pousser au sol pour s'injecter du moment angulaire, et où l'élan + # d'entrée (jusqu'à 0.3 m/s) doit être CONVERTI en rotation — le facturer plein + # tarif là s'opposerait au lancement. Plein tarif sur régime/freinage/repos. + cfg.rewards["spin_stay_in_place"] = RewardTermCfg( + func=microduck_mdp.spin_stay_in_place, + weight=-3.0, + params={ + "command_name": "twist", + "launch_scale": microduck_mdp.SPIN_LAUNCH_DRIFT_SCALE, + "accel_end": microduck_mdp.SPIN_ACCEL_END, + }, + ) + # Amorce 1 : tourner EN ROULEMENT (patins en sens opposés), pas en patinage. + cfg.rewards["spin_wheel_differential"] = RewardTermCfg( + func=microduck_mdp.spin_wheel_differential, + weight=1.0, + params={ + "command_name": "twist", + "omega_scale": microduck_mdp.SPIN_WHEEL_OMEGA_SCALE, + **_ENVELOPE, + }, + ) + # Amorce 2 : ciseau des jambes (décroît par curriculum, voir plus bas). + cfg.rewards["leg_antisymmetry"] = RewardTermCfg( + func=microduck_mdp.leg_antisymmetry, + weight=1.0, + params={ + "command_name": "twist", + "joint_bases": ("hip_pitch", "knee"), + **_ENVELOPE, + }, + ) + # Les deux lames au sol pendant le spin (pas de vrille en l'air). + cfg.rewards["spin_grounded"] = RewardTermCfg( + func=microduck_mdp.spin_grounded, + weight=0.5, + params={ + "sensor_name": "feet_ground_contact", + "command_name": "twist", + **_ENVELOPE, + }, + ) + # Stabilité / sim2real + cfg.rewards["feet_flat"] = RewardTermCfg( + func=microduck_mdp.feet_flat_penalty, + weight=-2.0, + params={ + "asset_cfg": SceneEntityCfg("robot", site_names=("left_foot", "right_foot")), + "sensor_name": "feet_ground_contact", + }, + ) + cfg.rewards["self_collisions"] = RewardTermCfg( + func=mdp.self_collision_cost, + weight=-1.0, + params={"sensor_name": "self_collision"}, + ) + cfg.rewards["neck_action_rate_l2"] = RewardTermCfg( + func=microduck_mdp.neck_action_rate_l2, weight=-0.5 + ) + cfg.rewards["neck_joint_pos_l2"] = RewardTermCfg( + func=microduck_mdp.neck_joint_pos_l2, + weight=-0.2, + params={"pattern": NECK_PATTERN_NO_YAW}, + ) + cfg.rewards["joint_torques_l2"] = RewardTermCfg( + func=microduck_mdp.joint_torques_l2, weight=-1e-3 + ) + + # === TERMINATIONS === + cfg.terminations["nan_state"] = TerminationTermCfg( + func=microduck_mdp.robot_state_is_nan, time_out=False, + ) + + # === EVENTS === + cfg.events["reset_action_history"] = EventTermCfg( + func=microduck_mdp.reset_action_history, mode="reset", + ) + del cfg.events["foot_friction"] + + if ENABLE_VELOCITY_PUSHES: + cfg.events["push_robot"] = EventTermCfg( + func=mdp.push_by_setting_velocity, + mode="interval", + interval_range_s=VELOCITY_PUSH_INTERVAL_S, + params={ + "velocity_range": {"x": VELOCITY_PUSH_RANGE, "y": VELOCITY_PUSH_RANGE}, + "asset_cfg": SceneEntityCfg("robot"), + }, + ) + + cfg.events["reset_base"].params["pose_range"]["z"] = (0.1335, 0.1435) + # Élan d'entrée : injecté via reset_root_state_uniform (état par défaut PROPRE + # + range), et NON via push_by_setting_velocity en mode reset, qui additionne à + # une vitesse racine potentiellement divergente et fait exploser le free-joint + # de la base -> NaN. Régression connue du roller_crouch. + cfg.events["reset_base"].params["velocity_range"] = {"x": ENTRY_VELOCITY_X} + + if ENABLE_WHEEL_FRICTION_RANDOMIZATION: + cfg.events["randomize_wheel_friction"] = EventTermCfg( + func=dr.dof_frictionloss, + mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot", joint_names=(r"^passive_.*",)), + "operation": "abs", + "ranges": (0.000, 0.000), + }, + ) + if ENABLE_COM_RANDOMIZATION: + cfg.events["randomize_com"] = EventTermCfg( + func=dr.body_ipos, mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",)), + "operation": "add", + "ranges": (-COM_RANDOMIZATION_RANGE, COM_RANDOMIZATION_RANGE), + }, + ) + if ENABLE_HEAD_COM_RANDOMIZATION: + cfg.events["randomize_head_com"] = EventTermCfg( + func=dr.body_ipos, mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot", body_names=HEAD_BODY_NAMES), + "operation": "add", + "ranges": (-HEAD_COM_RANDOMIZATION_RANGE, HEAD_COM_RANDOMIZATION_RANGE), + }, + ) + if ENABLE_MASS_INERTIA_RANDOMIZATION: + _mi_lo, _mi_hi = MASS_INERTIA_RANDOMIZATION_RANGE + cfg.events["randomize_mass_inertia"] = EventTermCfg( + func=dr.pseudo_inertia, mode="startup", + params={ + "asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",)), + "alpha_range": (math.log(_mi_lo) / 2.0, math.log(_mi_hi) / 2.0), + }, + ) + if ENABLE_JOINT_FRICTION_RANDOMIZATION: + cfg.events["randomize_joint_friction"] = EventTermCfg( + func=microduck_mdp.randomize_bam_friction, mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot"), + "scale_range": JOINT_FRICTION_RANDOMIZATION_RANGE, + }, + ) + if ENABLE_ARMATURE_RANDOMIZATION: + cfg.events["randomize_armature"] = EventTermCfg( + func=dr.joint_armature, mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot", joint_names=(r"^(?!passive_).*",)), + "operation": "scale", + "ranges": ARMATURE_RANDOMIZATION_RANGE, + }, + ) + + # === OBSERVATIONS (unified 61D layout) === + del cfg.observations["actor"].terms["base_lin_vel"] + del cfg.observations["critic"].terms["foot_height"] + del cfg.observations["actor"].terms["height_scan"] + del cfg.observations["critic"].terms["height_scan"] + cfg.observations["critic"].terms["base_lin_vel"] = ObservationTermCfg( + func=mdp.base_lin_vel, scale=1.0, + ) + + gravity_term_name = "projected_gravity" + cfg.observations["actor"].terms[gravity_term_name] = deepcopy( + cfg.observations["actor"].terms[gravity_term_name] + ) + cfg.observations["actor"].terms["base_ang_vel"] = deepcopy( + cfg.observations["actor"].terms["base_ang_vel"] + ) + cfg.observations["actor"].terms["base_ang_vel"].delay_min_lag = 0 + cfg.observations["actor"].terms["base_ang_vel"].delay_max_lag = 1 + cfg.observations["actor"].terms["base_ang_vel"].delay_update_period = 64 + cfg.observations["actor"].terms[gravity_term_name].delay_min_lag = 0 + cfg.observations["actor"].terms[gravity_term_name].delay_max_lag = 1 + cfg.observations["actor"].terms[gravity_term_name].delay_update_period = 64 + cfg.observations["actor"].terms["base_ang_vel"].noise = Unoise(n_min=-0.03, n_max=0.03) + cfg.observations["actor"].terms[gravity_term_name].noise = Unoise(n_min=-0.01, n_max=0.01) + cfg.observations["actor"].terms["joint_pos"].noise = Unoise(n_min=-0.001, n_max=0.001) + cfg.observations["actor"].terms["joint_vel"].noise = Unoise(n_min=-0.25, n_max=0.25) + + if ENABLE_IMU_ORIENTATION_RANDOMIZATION: + av = cfg.observations["actor"].terms["base_ang_vel"] + av.func = microduck_mdp.base_ang_vel_imu_misaligned + av.params = {"max_angle_deg": IMU_ORIENTATION_RANDOMIZATION_ANGLE} + g = cfg.observations["actor"].terms[gravity_term_name] + g.func = microduck_mdp.projected_gravity_imu_misaligned + g.params = {"max_angle_deg": IMU_ORIENTATION_RANDOMIZATION_ANGLE} + + cfg.observations["actor"].terms["joint_vel"] = deepcopy( + cfg.observations["actor"].terms["joint_vel"] + ) + cfg.observations["actor"].terms["joint_vel"].delay_min_lag = 1 + cfg.observations["actor"].terms["joint_vel"].delay_max_lag = 1 + cfg.observations["actor"].terms["joint_vel"].delay_update_period = 0 + + passive_excluded = SceneEntityCfg("robot", joint_names=(r"^(?!passive_).*",)) + for grp in ("actor", "critic"): + for term in ("joint_pos", "joint_vel"): + cfg.observations[grp].terms[term] = deepcopy(cfg.observations[grp].terms[term]) + cfg.observations[grp].terms[term].params["asset_cfg"] = deepcopy(passive_excluded) + + if ENABLE_ENCODER_BIAS: + cfg.events["encoder_bias"].params["bias_range"] = ENCODER_BIAS_RANGE + cfg.observations["actor"].terms["joint_pos"].params["biased"] = True + cfg.observations["critic"].terms["joint_pos"].params["biased"] = False + else: + cfg.events.pop("encoder_bias", None) + + wheel_cfg = SceneEntityCfg("robot", joint_names=(r"^passive_.*",)) + cfg.observations["critic"].terms["wheel_vel"] = ObservationTermCfg( + func=mdp.joint_vel_rel, scale=1.0, params={"asset_cfg": wheel_cfg}, + ) + + for group in ("actor", "critic"): + cfg.observations[group].terms["head_command"] = ObservationTermCfg( + func=microduck_mdp.zero_command_padding, params={"dim": 4}, + ) + cfg.observations[group].terms["body_command"] = ObservationTermCfg( + func=microduck_mdp.zero_command_padding, params={"dim": 6}, + ) + + # === COMMAND: phase (comme ground_pick / roller_crouch) === + command: UniformVelocityCommandCfg = cfg.commands["twist"] + command.rel_standing_envs = 0.0 + command.rel_heading_envs = 0.0 + # period=4.0 = défaut de --ground-pick-period (rien à passer au runtime) ; + # randomize_phase=False -> chaque épisode démarre debout à phase 0, comme le + # bouton au déploiement. Épisode 20 s = 5 cycles complets du geste. + cfg.commands["twist"] = microduck_mdp.GroundPickPhaseCommandCfg( + **{ + **vars(command), + "class_type": microduck_mdp.GroundPickPhaseCommand, + "period": SPIN_PERIOD, + "randomize_phase": False, + } + ) + + cfg.scene.terrain.terrain_type = "plane" + cfg.scene.terrain.terrain_generator = None + + # === CURRICULUM === + del cfg.curriculum["terrain_levels"] + del cfg.curriculum["command_vel"] + cfg.curriculum["action_rate_weight"] = CurriculumTermCfg( + func=microduck_mdp.reward_weight, + params={ + "reward_name": "action_rate_l2", + "weight_stages": [ + {"step": 0, "weight": -0.5}, + {"step": 250 * 24, "weight": -0.8}, + {"step": 500 * 24, "weight": -1.0}, + ], + }, + ) + # L'amorce ciseau s'efface : elle lance le bon mécanisme puis laisse la policy + # affiner son propre geste (fréquence de pompage libre). + cfg.curriculum["leg_antisym_weight"] = CurriculumTermCfg( + func=microduck_mdp.reward_weight, + params={ + "reward_name": "leg_antisymmetry", + "weight_stages": [ + {"step": 0, "weight": 1.0}, + {"step": 1500 * 24, "weight": 0.5}, + {"step": 3000 * 24, "weight": 0.25}, + ], + }, + ) + if ENABLE_COM_RANDOMIZATION: + cfg.curriculum["com_range"] = CurriculumTermCfg( + func=microduck_mdp.com_range_curriculum, + params={ + "event_name": "randomize_com", + "range_stages": [ + {"step": 0, "range": 0.003}, + {"step": 500 * 24, "range": 0.005}, + {"step": 1000 * 24, "range": 0.01}, + ], + }, + ) + if ENABLE_HEAD_COM_RANDOMIZATION: + cfg.curriculum["head_com_range"] = CurriculumTermCfg( + func=microduck_mdp.com_range_curriculum, + params={ + "event_name": "randomize_head_com", + "range_stages": [ + {"step": 0, "range": 0.003}, + {"step": 500 * 24, "range": 0.005}, + {"step": 1000 * 24, "range": 0.01}, + ], + }, + ) + + return cfg + + +MicroduckSpinRlCfg = RslRlOnPolicyRunnerCfg( + actor=RslRlModelCfg( + hidden_dims=(512, 256, 128), + activation="elu", + obs_normalization=True, + distribution_cfg={ + "class_name": "GaussianDistribution", + "init_std": 1.0, + "std_type": "scalar", + }, + ), + critic=RslRlModelCfg( + hidden_dims=(512, 256, 128), + activation="elu", + obs_normalization=True, + ), + algorithm=PpoWithSymmetryCfg( + value_loss_coef=1.0, + use_clipped_value_loss=True, + clip_param=0.2, + entropy_coef=0.01, + num_learning_epochs=5, + num_mini_batches=4, + learning_rate=1.0e-3, + schedule="adaptive", + gamma=0.99, + lam=0.95, + desired_kl=0.01, + max_grad_norm=1.0, + symmetry_cfg=SYMMETRY_CFG if ENABLE_SYMMETRY else None, + ), + wandb_project="mjlab_microduck", + experiment_name="spin", + run_name="spin", + save_interval=250, + num_steps_per_env=24, + max_iterations=8_000, +) diff --git a/src/mjlab_microduck/tasks/microduck_standup_env_cfg.py b/src/mjlab_microduck/tasks/microduck_standup_env_cfg.py new file mode 100644 index 0000000..358b567 --- /dev/null +++ b/src/mjlab_microduck/tasks/microduck_standup_env_cfg.py @@ -0,0 +1,1159 @@ +"""Microduck *stand* task (v1.5) — specialized: sitting pose → standing. + +Episodic policy that gently rises from the sitting keyframe to the standing +keyframe. Companion to the sit env — together they form a clean sit↔stand +pair, each policy doing one direction. + +Reset: sitting keyframe (trunk z ≈ 0.07, knees/ankles bent, head at HOME). +Target: standing keyframe (trunk z ≈ 0.12, HOME joints). +Reward design (mirror of sit env): a single fixed target is rewarded from +t=0 to end of episode; gentleness is enforced via |a_z| only; smoothness is +enforced by the usual sim2real regularisers. No trajectory waypoints, no +episode-progress gating — the policy is free to discover its own rise path. + +Body control (reintroduced 2026-07-29): once standing, the policy tracks a +commanded trunk delta [z, roll, pitch] from the nominal stand (the real +body_pose command in the previously zero-padded 6D obs slot). Kicks in at +iter 2500 via the body-control curricula at the bottom of this file, after +the ground_state_mix recovery curriculum has finished ramping. +""" + +import math +from copy import deepcopy + +# Symmetry +ENABLE_SYMMETRY = False + +# ── Domain randomisation (matched to the velocity env for sim2real parity) ──── +ENABLE_COM_RANDOMIZATION = True +ENABLE_HEAD_COM_RANDOMIZATION = True # match velocity: randomize head-assembly CoM +ENABLE_KP_RANDOMIZATION = False # match velocity (OFF) +ENABLE_KD_RANDOMIZATION = False # match velocity (OFF) +ENABLE_MASS_INERTIA_RANDOMIZATION = True # match velocity: dr.pseudo_inertia (mass+inertia) +ENABLE_JOINT_FRICTION_RANDOMIZATION = True # match velocity: FrictionDRBamActuator.friction_scale +ENABLE_ARMATURE_RANDOMIZATION = True # match velocity: reflected rotor inertia +ENABLE_VELOCITY_PUSHES = True +ENABLE_IMU_ORIENTATION_RANDOMIZATION = True # match velocity: obs-level per-env misalignment +ENABLE_ENCODER_BIAS = True # match velocity: per-env joint encoder offset (actor obs) + +# ── Ranges (matched to the velocity env) ────────────────────────────────────── +COM_RANDOMIZATION_RANGE = 0.003 # ramped to 0.015 via com_range curriculum (velocity's 2026-07 audit cap; was 0.02 here) +HEAD_COM_RANDOMIZATION_RANGE = 0.003 # ramped to 0.01 via head_com_range curriculum +MASS_INERTIA_RANDOMIZATION_RANGE = (0.95, 1.05) +ARMATURE_RANDOMIZATION_RANGE = (0.9, 1.1) +JOINT_FRICTION_RANDOMIZATION_RANGE = (0.9, 1.1) +ENCODER_BIAS_RANGE = (-0.015, 0.015) +KP_RANDOMIZATION_RANGE = (0.85, 1.15) # unused (kp DR off) +KD_RANDOMIZATION_RANGE = (0.9, 1.1) # unused (kd DR off) +VELOCITY_PUSH_INTERVAL_S = (3.0, 6.0) +# Match velocity's ±0.3 (velocity was itself softened from ±0.5 in the 2026-07 +# audit). The push curriculum below still ramps 0 → ±0.08 → this final value so +# the sit-rise bootstrap isn't shoved around from step 0 (velocity pushes at +# full strength from step 0, but it starts standing, not seated/prone). +VELOCITY_PUSH_RANGE = (-0.3, 0.3) +IMU_ORIENTATION_RANDOMIZATION_ANGLE = 6.0 # match velocity (was 2.0 — pre-audit value; real IMU has ~5° systematic pitch error + estimator drift, 2° trained too narrow a band) + +# Episode length: long enough for a gentle rise + brief stabilisation. +EPISODE_LENGTH_S = 6.0 + +# ── Sitting source pose (asset.data.joint_pos index → angle in rad) ─────────── +# Must match the *actual end-state* of the sit policy. Mirrors the sit env's +# SITTING_TARGET_OVERRIDES (microduck_sit_env_cfg.py) — the swept stable +# equilibrium pose (knee ±1.35 ≈ 77°, hip_pitch ∓0.4079 = slight fwd lean, +# ankles 0). Keep the two in sync: this reset IS the sit→stand hand-off. +# Neck/head intentionally omitted → reset stays at HOME so the standup policy +# starts from exactly where the sit policy converges. +# Articulation joint indices under mjlab 1.3.0 + canonical BAM. The passive jaw +# joints are NO LONGER part of the articulation (excluded from qpos), so the +# layout is the clean 14-joint order: 0-4 left leg, 5-8 neck/head, 9-13 right leg. +# (Previously passive_1/passive_2 sat at 9,10 and shifted the right leg to 11-15.) +SITTING_JOINT_OVERRIDES = { + 1: 0.0, # left hip_roll (HOME -0.0873) + 2: -0.4079, # left hip_pitch (HOME -0.4579; +0.05 = slight fwd lean) + 3: 1.35, # left knee (HOME -0.0049) + 4: 0.0, # left ankle (HOME +0.4530) + 10: 0.0, # right hip_roll (HOME +0.0873) + 11: 0.4079, # right hip_pitch (HOME +0.4579) + 12: -1.35, # right knee (HOME +0.0049) + 13: 0.0, # right ankle (HOME -0.4530) +} + +_LEG_JOINTS = [0, 1, 2, 3, 4, 9, 10, 11, 12, 13] +_NECK_JOINTS = [5, 6, 7, 8] + +# Trunk height targets (m). +# SIT_Z matches the sit env's measured seated equilibrium (trunk z at rest in +# the swept stable pose above). Was 0.07 (old robot); keep in sync with +# microduck_sit_env_cfg.py. +SIT_Z = 0.060 +# STAND_Z = empirically-measured trunk z at the natural standing equilibrium +# (HOME joint pose, vertical trunk). Previously was 0.120 — 5 mm above +# what's mechanically reachable at HOME — which forced the policy into a +# back-lean compromise to satisfy the impossible height target. Measured +# via the velocity policy holding the robot still at zero command: 115 mm. +STAND_Z = 0.115 + +# ── Body pose command (reintroduced 2026-07-29) ─────────────────────────────── +# Master toggle. OFF restores the previous env exactly: no body_pose command, +# zero-padded body_command obs slot (obs stays 61D either way), no tracking +# reward, no body-control curricula (including the conflict-relax stages on +# height_stand_sharp / upright_sharp / standing_composite). +ENABLE_BODY_CONTROL = True +# 6D command slot [x, y, z, roll, pitch, yaw] for obs parity with velocity/ +# velstand, but only z/roll/pitch are tracked (axis_weights below) — the same +# 3 axes as the original standup body control and the runtime interface. +# x/y/yaw stay at a tiny "alive" range forever: the policy learns to ignore +# them (they're reward-uncorrelated noise) instead of leaving dead weights. +# z range is ASYMMETRIC: STAND_Z is the natural equilibrium at HOME, so there +# is plenty of crouch below it but only ~1 cm of leg extension above it. +# Angles capped at ±15°: velocity body-control run 1 showed ±20° trains +# twitchy/overdriven tilting. +BODY_CMD_MAX_Z_DOWN = 0.04 # m, crouch below STAND_Z +BODY_CMD_MAX_Z_UP = 0.030 # m, extend above STAND_Z +BODY_CMD_MAX_ANGLE = math.radians(15) # rad, trunk pitch/roll +BODY_CMD_ALIVE_XY = 0.005 # m, permanent x/y noise range +BODY_CMD_ALIVE_ANGLE = 0.05 # rad, stage-0 / permanent-yaw range +# Exact-zero command probability at resample: keeps the deployment idle case +# ("stand at nominal, no command") trained (velocity run-1 lesson — uniform +# sampling never produces the all-zero command). +BODY_CMD_ZERO_PROB = 0.3 + +from mjlab.envs import ManagerBasedRlEnvCfg +from mjlab.envs.mdp import dr +from mjlab.envs.mdp.actions import JointPositionActionCfg +from mjlab.managers import ( + CurriculumTermCfg, + EventTermCfg, + ObservationTermCfg, + RewardTermCfg, + TerminationTermCfg, +) +from mjlab.managers.scene_entity_config import SceneEntityCfg +from mjlab.rl import ( + RslRlOnPolicyRunnerCfg, + RslRlModelCfg, +) +from mjlab.sensor import ContactMatch, ContactSensorCfg +from mjlab.tasks.velocity import mdp +from mjlab.tasks.velocity.velocity_env_cfg import make_velocity_env_cfg +from mjlab.utils.noise import UniformNoiseCfg as Unoise + +from mjlab_microduck.robot.microduck_constants import MICRODUCK_STANDUP_ROBOT_CFG +from mjlab_microduck.tasks import mdp as microduck_mdp +from mjlab_microduck.tasks.microduck_velocity_env_cfg import ( + MICRODUCK_ROUGH_TERRAINS_CFG, + HEAD_BODY_NAMES, + HEAD_POSE_CMD_RESAMPLE_S, + BODY_POSE_CMD_RESAMPLE_S, +) +from mjlab_microduck.tasks.symmetry import PpoWithSymmetryCfg, SYMMETRY_CFG + + +def make_microduck_standup_env_cfg( + play: bool = False, + rough: bool = False, +) -> ManagerBasedRlEnvCfg: + """Create Microduck stand environment configuration (sit-keyframe start).""" + + site_names = ["left_foot", "right_foot"] + + feet_ground_cfg = ContactSensorCfg( + name="feet_ground_contact", + primary=ContactMatch( + mode="geom", + pattern=r"^(left_foot_collision|right_foot_collision)$", + entity="robot", + ), + secondary=ContactMatch(mode="body", pattern="terrain"), + fields=("found", "force"), + reduce="netforce", + num_slots=1, + track_air_time=True, + ) + + self_collision_cfg = ContactSensorCfg( + name="self_collision", + primary=ContactMatch(mode="subtree", pattern="trunk_base", entity="robot"), + secondary=ContactMatch(mode="subtree", pattern="trunk_base", entity="robot"), + fields=("found",), + reduce="none", + num_slots=1, + ) + + foot_frictions_geom_names = ("left_foot_collision", "right_foot_collision") + + # ── Base config ─────────────────────────────────────────────────────────── + cfg = make_velocity_env_cfg() + + cfg.scene.entities = {"robot": MICRODUCK_STANDUP_ROBOT_CFG} + cfg.scene.sensors = (feet_ground_cfg, self_collision_cfg) + cfg.viewer.body_name = "trunk_base" + + cfg.episode_length_s = EPISODE_LENGTH_S + + # ── Actions ─────────────────────────────────────────────────────────────── + joint_pos_action = cfg.actions["joint_pos"] + assert isinstance(joint_pos_action, JointPositionActionCfg) + joint_pos_action.scale = 1.0 + + # ── Rewards: drop walking-specific terms ────────────────────────────────── + for name in [ + "track_linear_velocity", + "track_angular_velocity", + "air_time", + "foot_clearance", + "foot_swing_height", + "foot_slip", + "pose", + ]: + if name in cfg.rewards: + del cfg.rewards[name] + + # ── Rewards: minimum-viable set for an organic standup policy ──────────── + # Single fixed target (STAND = HOME pose + STAND_Z), active from t=0. No + # trajectory, no waypoints, no episode-progress gating. The policy is free + # to discover any rise path that satisfies: + # (1) end-state matches the HOME pose + STAND_Z + # (2) rise is gentle (low |a_z| throughout) + # (3) trunk stays upright throughout (failure mode: tip backward while + # extending legs; no "low z is safe" regime as in sit) + # (4) joint/action motion stays smooth (sim2real regularisers) + # + # 2026-07 TRANSFER FIX (violent/shaky on the real robot): ALL task weights + # below divided by 4 (8→2, 30→7.5, 15→3.75, …) so the total task mass + # (~12) matches velocity's (~11) and the shared sim2real regularisers act + # at the same RELATIVE strength as in the well-transferring velocity env. + # Previously the task mass was ~49, so nominally-identical regulariser + # weights were effectively ~4× weaker here → jitter/limit-cycle around the + # standing point was nearly free. Internal ratios between task terms are + # unchanged (uniform scaling), so the per-term rationale comments below + # still hold — just read their absolute reward numbers ×4. PPO normalises + # advantages, so the global scale itself doesn't matter; only the + # task↔regulariser ratio does. + + # Pose target — legs+hips+knees+ankles. target_overrides=None → HOME. + cfg.rewards["pose_stand_legs"] = RewardTermCfg( + func=microduck_mdp.pose_target_match, + weight=2.0, + params={ + "std": 0.5, + "joint_indices": _LEG_JOINTS, + "target_overrides": None, # HOME = standing + }, + ) + + # Head pose tracking (commandable head control, like the velocity env). + # Replaces the old pose_stand_neck reward (which pinned the neck/head to HOME) + # — the neck/head are now steered by the head_pose command instead. Removed + # from pose_stand_l1 / standing_composite below for the same reason, so no + # reward fights head_pose_tracking's gradient. + cfg.rewards["head_pose_tracking"] = RewardTermCfg( + func=microduck_mdp.head_pose_tracking, + weight=0.75, + params={"command_name": "head_pose", "std": 0.5}, + ) + + # Head DC-droop penalty (velocity's fix, standup-adapted). L1 on a 1 s EMA + # of the head tracking error — prices only the sustained gravity sag the + # policy can cancel by biasing the neck command up; transient motion + # averages out. TWO standup-specific safeties, both mandatory here: + # - UPRIGHT GATE (same values as arrival_damping): the gate multiplies the + # error feeding the EMA, so the ground/rising phase accumulates NOTHING + # — no reward wall at the finish line, no tax on the head-pivot flip + # (the retired head_impact_penalty froze the policy exactly that way). + # - STARTS AT 0, introduced at iter 3000 by the curriculum below — same + # discovery-vs-refinement timing as arrival_damping/torque_rate. + cfg.rewards["head_pose_bias"] = RewardTermCfg( + func=microduck_mdp.head_pose_bias_penalty, + weight=0.0, # ramped by head_pose_bias_weight curriculum + params={ + "command_name": "head_pose", + "tau_s": 1.0, + "gate_height_low": 0.09, + "gate_height_high": 0.11, + "gate_tilt_full_deg": 20.0, + "gate_tilt_zero_deg": 45.0, + }, + ) + + # L1 bootstrap — constant gradient even when far from HOME. + # Bumped 2 → 5: at convergence the policy parks ~0.18 rad off-HOME (mostly + # bent knees) costing only -0.35/step at weight 2 — cheap enough to ignore. + # At weight 5 that error costs -0.9/step, forcing the policy to actually + # close the gap on the remaining joints. + cfg.rewards["pose_stand_l1"] = RewardTermCfg( + func=microduck_mdp.pose_l1_penalty, + weight=1.25, + params={ + # Legs only — neck/head are steered by head_pose_tracking. + "joint_indices": _LEG_JOINTS, + "target_overrides": None, + }, + ) + + # Trunk height target — two-layer Gaussian to get both bootstrap reach + # AND a sharp peak at STAND_Z. + # - ``height_stand``: wide std (0.04), for the bootstrap pull from sit. + # - ``height_stand_sharp``: narrow std (0.015), creates a strong gradient + # in the final cm. Earlier runs converged at z ≈ 0.109 because the + # wide-std Gaussian was already saturated (0.93/1.0) — no gradient to + # pull the last cm. The sharp layer adds 0.36→1.0 reward jump in that + # same range, ~3× the marginal pull. + cfg.rewards["height_stand"] = RewardTermCfg( + func=microduck_mdp.height_target_gaussian, + weight=1.0, + params={ + "std": 0.04, + "target_height": STAND_Z, + "asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",)), + }, + ) + cfg.rewards["height_stand_sharp"] = RewardTermCfg( + func=microduck_mdp.height_target_gaussian, + weight=1.0, + params={ + "std": 0.015, + "target_height": STAND_Z, + "asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",)), + }, + ) + # L1 bumped 10 → 30: previous run plateaued sitting still because the + # static-sit basin (-0.5 reward from L1 + everything else positive) was + # net positive. At weight 30, sitting still costs -1.5/step — net cost + # of "stay sitting" forces exploration. + cfg.rewards["height_stand_l1"] = RewardTermCfg( + func=microduck_mdp.height_l1_penalty, + weight=7.5, + params={ + "target_height": STAND_Z, + "asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",)), + }, + ) + + # Reward upward CoM velocity below STAND_Z — pays for the *motion* of + # rising, not just for the destination. Critical bootstrap: with only + # destination rewards, "stay sitting upright collecting most-of-pose + + # upright" was the dominant local optimum. Rewarding vz > 0 directly + # makes any rise attempt immediately positive. Gates off above + # max_height so the policy can't farm it by bobbing. + # max_height set just above STAND_Z (0.12 → 0.125) so the reward stays + # active through the final cm of rise. Earlier 0.11 caused the policy to + # park at ~0.108 (gate-off altitude) and never finish the climb. + # NO max_vz cap (reverted 2026-07-24, second broken run): capping the + # rewarded rise speed — even at a generous 0.30 — shrinks the payoff of + # noisy recovery ATTEMPTS during the discovery phase, and face-up/face-down + # recovery never got learned. Both broken runs shared the same wandb + # signature regardless of cap value (0.15 or 0.30) and gate tuning: + # standing metrics drop at the ground_state_mix stages (1500/2500) instead + # of recovering like the reference run. Smoothing is now done by the + # LATE-phased penalty curricula below instead (see arrival_damping / + # smoothness_polish comments). + cfg.rewards["com_upward_velocity"] = RewardTermCfg( + func=microduck_mdp.com_upward_velocity, + weight=0.75, + params={ + "asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",)), + "max_height": 0.125, + }, + ) + + # Gentle rise — penalty on |a_z|. Compatible with com_upward_velocity: + # constant positive vz collects upward-velocity reward AND has a_z = 0, + # so the two pressures together select for smooth constant-velocity rise. + # NOTE this term is GLOBAL (not phase-gated): prone flips pay it in full + # (impacts + push-off are |a_z| spikes). The 2026-07-24 attempt to double + # it to -0.01 contributed to the face-up freeze; -0.005 is the ceiling + # unless it gets a height/tilt gate like arrival_damping. + # ⚠️ POSITIVE weight: trunk_vertical_accel_penalty ALREADY returns -|a_z|. + # The previous -0.005 double-negated into a (small) reward for vertical + # shocks — the same sign bug roller_standup found and fixed in its + # gentle_rise, confirmed again on the sitstand run 7ev90yd9 (its + # Episode_Reward/gentle_motion logged POSITIVE). Keep magnitude small: + # |a_z| is unavoidable during prone flips, a big weight is a motion-blocker. + cfg.rewards["gentle_rise"] = RewardTermCfg( + func=microduck_mdp.trunk_vertical_accel_penalty, + weight=0.005, + params={"asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",))}, + ) + + # Arrival damper — trunk ω_xy², gated on height AND tilt (zero above 45° + # tilt / below 0.09 m, full below 20° tilt / above 0.11 m). Targets the + # real-robot failure loop: rise → overshoot vertical → tip → retry. + # + # STARTS AT WEIGHT 0 — introduced at iter 3000 by the arrival_damping + # curriculum below. Two broken runs (2026-07-24) proved that ANY + # attempt-tax active during the recovery DISCOVERY phase (ground_state_mix + # ramps face-down/face-up until iter 2500) prevents the flip from being + # found at all: exploration of the hard poses is noisy thrash, taxing it + # makes attempts net-negative, "do nothing" wins. Gate refinement (tilt + # gating, halved weight, generous vz cap) did NOT change the failure + # signature — the fix is timing, not magnitude. From iter 3000 the skills + # already exist and keep being exercised by prone resets, so the damping + # fine-tunes their execution instead of blocking their discovery. + cfg.rewards["arrival_damping"] = RewardTermCfg( + func=microduck_mdp.body_ang_vel_at_height, + weight=0.0, + params={ + "height_low": 0.09, + "height_high": 0.11, + "tilt_full_deg": 20.0, + "tilt_zero_deg": 45.0, + "asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",)), + }, + ) + + # Upright — two-layer like the height reward. + # - ``upright_linear``: cos(tilt). Strong gradient at high tilt (e.g., + # while inverted at the start of a recovery), weak near vertical. + # Provides bootstrap pull from any orientation. + # - ``upright_sharp``: exp(-tilt²/std²) with std ≈ 6°. Gradient is + # STRONGEST in the near-vertical regime where the linear version + # runs out of steam. Previous run converged at ~37° back-lean because + # the linear pull at small tilt becomes weak; this term punishes that + # exact regime. + cfg.rewards["upright_linear"] = RewardTermCfg( + func=microduck_mdp.body_upright_linear, + weight=1.5, + params={"asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",))}, + ) + # Sharp Gaussian upright, gated by trunk z. Pays only when the robot is + # actually at the standing height — prevents the "crouch low and vertical" + # exploit. Broadened std 0.1 → 0.3 (≈17°): too sharp before, scored + # near-zero at the lean basin (no gradient). With 0.3, the lean basin + # at z=0.111 (smoothstep ~0.91) and tilt 37° (gaussian ~0.11) scores + # ~0.1 = visible gradient that pulls toward vertical. + cfg.rewards["upright_sharp"] = RewardTermCfg( + func=microduck_mdp.upright_gaussian_at_height, + weight=1.5, + params={ + "std": 0.3, + "height_low": SIT_Z, + "height_high": STAND_Z, + "asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",)), + }, + ) + + # Smooth multiplicative goal-state score (broad stds). + # The previous tight stds (height=0.015, upright=0.15, pose=0.20) had + # the composite at ~5e-5 at the lean basin — invisible to the policy, + # zero gradient. Broadening so the lean basin scores ~0.2 (visible + # gradient) while the goal still scores ~1.0 (clear attractor). + cfg.rewards["standing_composite"] = RewardTermCfg( + func=microduck_mdp.standing_composite_score, + weight=3.75, + params={ + "target_height": STAND_Z, + "height_std": 0.04, # 4cm — broad, covers the climb + "upright_std": 0.40, # ≈ 23° — lean basin scores ~0.3 + "pose_std": 0.40, # joint-RMS, broad enough for partial pose + "joint_indices": _LEG_JOINTS, # neck/head steered by head_pose_tracking + "target_overrides": None, + "asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",)), + }, + ) + + # Body pose tracking — z/roll/pitch only (axis_weights), the runtime + # body-control axes. Locomotion variant (not body_pose_tracking_6d) so the + # unused x/y axes wouldn't reference the spawn origin, which the robot + # leaves during prone flips. Weight starts at 0; body_pose_tracking_weight + # ramps it in from iter 2500 (after ground_state_mix finishes) so recovery + # discovery is untouched. While prone/rising the reward is ≈0 on all + # tracked axes, so before the robot stands it is just another standing + # attractor — unlike motion penalties, it can't tax flip/rise attempts. + # Tight stds on purpose (standup phase-2 lesson): at 1 cm z error with + # z_std=0.01 the axis reward drops to 0.37 (real gradient); 0.02 → 0.78. + if ENABLE_BODY_CONTROL: + cfg.rewards["body_pose_tracking"] = RewardTermCfg( + func=microduck_mdp.body_pose_tracking_locomotion, + weight=0.0, + params={ + "command_name": "body_pose", + "nominal_height": STAND_Z, + "z_std": 0.01, + "angle_std": math.radians(5), + "axis_weights": (0.0, 0.0, 1.0, 1.0, 1.0, 0.0), + "vel_gate_command_name": None, + }, + ) + + # ── Sim2real regularisers — MATCHED to velocity (2026-07) ─────────────── + # velocity's exact set and absolute weights: + # • action_rate_l2: -0.1 at stage 0, ramped -0.1 → -1.0 by iter 1500 + # (action_rate_weight curriculum below, velocity's exact stages) + # • body_ang_vel -0.05, angular_momentum -0.02 + # • microduck-only extras DROPPED, like velocity drops them: + # neck_action_rate_l2, joint_torques_l2, joint_torque_rate_l2, soft_landing + # Parity is made REAL by the ÷4 task-stack scaling above — previously the + # same absolute weights were ~4× weaker relative to the ~49 task mass. + # + # HISTORY / RISK: at the OLD task scale, raising body_ang_vel to -0.15 and + # the action_rate end to -1.2 killed back-recovery (both are motion-blockers + # for the flip). At the new ÷4 scale, body_ang_vel -0.05 ≈ -0.2 old-units — + # WATCH face-down/face-up recovery as ground_state_mix ramps them in + # (iters 600–2500). If recovery freezes: halve body_ang_vel to -0.025 + # first, then soften the action_rate curriculum end to -0.6. + # + # 2026-07 smoothness polish (rise violent + overshoot-retry loop on the + # real robot after the ÷4 rescale): joint_torque_rate_l2 (anti-jitter: + # penalizes torque CHANGE, not magnitude/rotation) + arrival_damping + # (rewards block above). BOTH start at weight 0 and are introduced at iter + # 3000 by the smoothness-polish curricula below — the reward set is + # IDENTICAL to the working 2026-07-23 run until then. See the + # arrival_damping comment for why timing (discovery vs fine-tuning), not + # magnitude, is what decides whether these terms break recovery. + cfg.rewards["action_rate_l2"] = RewardTermCfg(func=mdp.action_rate_l2, weight=-0.1) + cfg.rewards["joint_torque_rate_l2"] = RewardTermCfg( + func=microduck_mdp.joint_torque_rate_l2, weight=0.0 + ) + + cfg.rewards["body_ang_vel"].params["asset_cfg"].body_names = ("trunk_base",) + cfg.rewards["body_ang_vel"].weight = -0.05 # motion-blocker: kept LIGHT (velocity value) + cfg.rewards["angular_momentum"].weight = -0.02 # velocity value + cfg.rewards.pop("soft_landing", None) # velocity removes it + + cfg.rewards["self_collisions"] = RewardTermCfg( + func=mdp.self_collision_cost, + weight=-1.0, + params={"sensor_name": self_collision_cfg.name}, + ) + + # Drop only the base "upright" Gaussian — standup uses its own + # upright_linear/upright_sharp instead. (angular_momentum kept above to match + # velocity; soft_landing/hip_yaw_roll_deviation dropped to match velocity.) + if "upright" in cfg.rewards: + del cfg.rewards["upright"] + + # ── Observations (identical layout to walking / sit policies) ───────────── + del cfg.observations["actor"].terms["base_lin_vel"] + + cfg.observations["critic"].terms["base_lin_vel"] = ObservationTermCfg( + func=mdp.base_lin_vel, scale=1.0, + ) + # mjlab 1.3.0 base template adds sensor-based foot_height + height_scan obs. + # Standup has no terrain-height sensor (and drops the walking foot rewards), + # so remove these terms. foot_air_time/foot_contact(_forces) use the + # feet_ground_contact sensor, which standup does define, so they stay. + del cfg.observations["critic"].terms["foot_height"] + del cfg.observations["actor"].terms["height_scan"] + del cfg.observations["critic"].terms["height_scan"] + # The retained sensor-derived critic terms get the NaN-safe wrappers: a + # non-finite contact force slips past robot_state_is_nan (it checks joint + + # root state only) and a single NaN here kills the run via rsl_rl's + # check_nan — the 2026-08-21 Velocity2-Rough-Backlash crash. Standup lands + # and flips constantly, so degenerate contacts are MORE likely here. + for _term, _safe in ( + ("foot_contact_forces", microduck_mdp.foot_contact_forces_safe), + ("foot_air_time", microduck_mdp.foot_air_time_safe), + ): + if _term in cfg.observations["critic"].terms: + cfg.observations["critic"].terms[_term].func = _safe + + gravity_term_name = "projected_gravity" + cfg.observations["actor"].terms[gravity_term_name] = deepcopy( + cfg.observations["actor"].terms[gravity_term_name] + ) + cfg.observations["actor"].terms["base_ang_vel"] = deepcopy( + cfg.observations["actor"].terms["base_ang_vel"] + ) + + # IMU obs delay: max_lag 1 (was 3 = 60 ms worst case) — match velocity's + # 2026-07 audit value; the real dxl IMU path is fast (±20 ms envelope). + cfg.observations["actor"].terms["base_ang_vel"].delay_min_lag = 0 + cfg.observations["actor"].terms["base_ang_vel"].delay_max_lag = 1 + cfg.observations["actor"].terms["base_ang_vel"].delay_update_period = 64 + cfg.observations["actor"].terms[gravity_term_name].delay_min_lag = 0 + cfg.observations["actor"].terms[gravity_term_name].delay_max_lag = 1 + cfg.observations["actor"].terms[gravity_term_name].delay_update_period = 64 + + # Obs noise matched to the velocity env. + cfg.observations["actor"].terms["base_ang_vel"].noise = Unoise(n_min=-0.03, n_max=0.03) + cfg.observations["actor"].terms[gravity_term_name].noise = Unoise(n_min=-0.01, n_max=0.01) + cfg.observations["actor"].terms["joint_pos"].noise = Unoise(n_min=-0.001, n_max=0.001) + cfg.observations["actor"].terms["joint_vel"].noise = Unoise(n_min=-0.25, n_max=0.25) + + # IMU mounting-misalignment DR (match velocity): per-env constant rotation of + # the IMU-derived actor obs; critic keeps the true values. + if ENABLE_IMU_ORIENTATION_RANDOMIZATION: + av = cfg.observations["actor"].terms["base_ang_vel"] + av.func = microduck_mdp.base_ang_vel_imu_misaligned + av.params = {"max_angle_deg": IMU_ORIENTATION_RANDOMIZATION_ANGLE} + g = cfg.observations["actor"].terms[gravity_term_name] + g.func = microduck_mdp.projected_gravity_imu_misaligned + g.params = {"max_angle_deg": IMU_ORIENTATION_RANDOMIZATION_ANGLE} + + cfg.observations["actor"].terms["joint_vel"] = deepcopy( + cfg.observations["actor"].terms["joint_vel"] + ) + cfg.observations["actor"].terms["joint_vel"].delay_min_lag = 1 + cfg.observations["actor"].terms["joint_vel"].delay_max_lag = 1 + cfg.observations["actor"].terms["joint_vel"].delay_update_period = 0 + + # Deepcopy joint_pos/joint_vel per group (they share base-template objects) so + # the encoder-bias `biased` flag below applies to the actor only. + passive_excluded = SceneEntityCfg("robot", joint_names=(r"^(?!passive_).*",)) + for grp in ("actor", "critic"): + for term in ("joint_pos", "joint_vel"): + cfg.observations[grp].terms[term] = deepcopy(cfg.observations[grp].terms[term]) + cfg.observations[grp].terms[term].params["asset_cfg"] = deepcopy(passive_excluded) + + # Encoder-bias DR (match velocity): actor sees joint_pos + per-env bias; critic + # keeps the true joint pos. Requires the base-template encoder_bias event. + if ENABLE_ENCODER_BIAS: + cfg.events["encoder_bias"].params["bias_range"] = ENCODER_BIAS_RANGE + cfg.observations["actor"].terms["joint_pos"].params["biased"] = True + cfg.observations["critic"].terms["joint_pos"].params["biased"] = False + else: + cfg.events.pop("encoder_bias", None) + + # ── Head pose command (commandable head control, like the velocity env) ─── + # 4D deltas-from-HOME on neck/head joints: [neck_pitch, head_pitch, head_yaw, + # head_roll]. Tracked by head_pose_tracking below; ranges widened by the + # head_pose_range curriculum. Same per-joint caps as the velocity env. + cfg.commands["head_pose"] = microduck_mdp.UniformPoseCommandCfg( + resampling_time_range=HEAD_POSE_CMD_RESAMPLE_S, + ranges=( + (-0.05, 0.05), # neck_pitch + (-0.05, 0.05), # head_pitch + (-0.07, 0.07), # head_yaw + (-0.015, 0.015), # head_roll + ), + ) + + # ── Body pose command (6D delta from nominal standing) ─────────────────── + # [x, y, z, roll, pitch, yaw]. Only z/roll/pitch are tracked (see + # body_pose_tracking below); x/y/yaw are permanent alive-range noise. + # Ranges start tiny; the body_pose_range curriculum widens z/roll/pitch + # once the recovery skills exist (ground_state_mix final at 2500). + if ENABLE_BODY_CONTROL: + cfg.commands["body_pose"] = microduck_mdp.UniformPoseCommandCfg( + resampling_time_range=BODY_POSE_CMD_RESAMPLE_S, + zero_command_prob=BODY_CMD_ZERO_PROB, + ranges=( + (-BODY_CMD_ALIVE_XY, BODY_CMD_ALIVE_XY), # x (m) + (-BODY_CMD_ALIVE_XY, BODY_CMD_ALIVE_XY), # y (m) + (-0.005, 0.005), # z (m) + (-BODY_CMD_ALIVE_ANGLE, BODY_CMD_ALIVE_ANGLE), # roll + (-BODY_CMD_ALIVE_ANGLE, BODY_CMD_ALIVE_ANGLE), # pitch + (-BODY_CMD_ALIVE_ANGLE, BODY_CMD_ALIVE_ANGLE), # yaw + ), + ) + + # Command obs slots. head_command is the real head_pose command; the + # body_command slot carries the real body_pose command when body control is + # enabled, and zero padding otherwise (obs shape identical either way). + # Layout parity with velocity/velstand: [twist(3), head_pose(4), body_pose(6)]. + for group in ("actor", "critic"): + cfg.observations[group].terms["head_command"] = ObservationTermCfg( + func=mdp.generated_commands, params={"command_name": "head_pose"}, + ) + if ENABLE_BODY_CONTROL: + cfg.observations[group].terms["body_command"] = ObservationTermCfg( + func=mdp.generated_commands, params={"command_name": "body_pose"}, + ) + else: + cfg.observations[group].terms["body_command"] = ObservationTermCfg( + func=microduck_mdp.zero_command_padding, params={"dim": 6}, + ) + + # ── Command: tiny noise around zero (kept for obs-shape parity) ────────── + command = cfg.commands["twist"] + command.rel_standing_envs = 0.0 + command.rel_heading_envs = 0.0 + command.heading_command = False + command.ranges.heading = None + command.resampling_time_range = (EPISODE_LENGTH_S, EPISODE_LENGTH_S * 2) + command.debug_vis = False + command.ranges.lin_vel_x = (-0.01, 0.01) + command.ranges.lin_vel_y = (-0.01, 0.01) + command.ranges.ang_vel_z = (-0.05, 0.05) + cfg.commands["twist"] = microduck_mdp.VelocityCommandCommandOnlyCfg(**vars(command)) + + # ── Terminations ────────────────────────────────────────────────────────── + # Robot starts seated — tilt-based fall termination doesn't apply here. + if "fell_over" in cfg.terminations: + del cfg.terminations["fell_over"] + cfg.terminations["nan_state"] = TerminationTermCfg( + func=microduck_mdp.robot_state_is_nan, + time_out=False, + params={"sensor_names": ("feet_ground_contact",)}, + ) + + # ── Events ──────────────────────────────────────────────────────────────── + # BAM (mjlab_frictionloss branch) writes per-env dof_frictionloss/dof_damping + # every step; this no-op event registers those fields for per-world expansion. + cfg.events["expand_bam_friction_fields"] = EventTermCfg( + func=microduck_mdp.expand_bam_friction_fields, + mode="startup", + ) + + cfg.events["reset_action_history"] = EventTermCfg( + func=microduck_mdp.reset_action_history, + mode="reset", + ) + cfg.events["foot_friction"].params["asset_cfg"].geom_names = foot_frictions_geom_names + cfg.events["foot_friction"].params["ranges"] = (0.7, 1.3) # match velocity + + # Start in the sitting keyframe with noise on joints + trunk tilt. Real + # deployment hand-off from the sit policy won't reproduce the SIT + # keyframe exactly — the standup policy must be robust to a band of + # plausible "sit-ish" starts. Without noise the policy was overfitting + # to the exact canonical SIT pose. + cfg.events["set_ground_state"] = EventTermCfg( + func=microduck_mdp.set_random_ground_state, + mode="reset", + params={ + # Initialize from any pose, 25% each: front (face-down), back + # (face-up), sitting keyframe, and already-standing (so the policy + # also learns to *hold* a stand, not only to rise). + # Initial mix = curriculum stage 0 (easy); the ground_state_mix + # curriculum ramps these easy→hard over training. Face-up (back) starts + # at 0 and is introduced late (hardest recovery). + "face_down_prob": 0.20, # belly to floor (+90° pitch) + "face_up_prob": 0.00, # back to floor (-90° pitch) — introduced late + "sitting_prob": 0.40, # sit keyframe (deployment hand-off) + "standing_prob": 0.40, # already upright at standing height + # Prone reset height: trunk rests at ~0.044 m face-down (measured), so + # spawn just above the ground rather than the 0.20–0.25 default (which + # would free-fall ~15 cm before landing). + "prone_z_min": 0.05, + "prone_z_max": 0.09, + # Partial-roll noise on face-up spawns (±90° about the body long + # axis): back-recovery was seed-lucky (1 success / 3 failures with + # equivalent rewards) because the reward landscape from flat + # supine to prone is flat — no gradient until the roll completes. + # Near-on-side spawns put starts partway along the roll → built-in + # reverse curriculum. See set_random_ground_state in mdp.py. + "face_up_roll_max": math.radians(90), + "sitting_joint_overrides": SITTING_JOINT_OVERRIDES, + "sitting_joint_noise_std": 0.12, # ≈ 7° per joint + "sitting_tilt_max": math.radians(10), # ±10° pitch/roll + # Seated equilibrium is SIT_Z=0.060 — band is −1cm/+3cm around it + # (same spread as when equilibrium was 0.07 with 0.06–0.10). + "sitting_z_min": 0.05, + "sitting_z_max": 0.09, + # Standing init: trunk just above the measured equilibrium (STAND_Z=0.115). + "standing_z_min": 0.11, + "standing_z_max": 0.12, + }, + ) + + if ENABLE_VELOCITY_PUSHES: + interval = (0.5, 1.0) if play else VELOCITY_PUSH_INTERVAL_S + cfg.events["push_robot"] = EventTermCfg( + func=mdp.push_by_setting_velocity, + mode="interval", + interval_range_s=interval, + params={ + "velocity_range": { + "x": VELOCITY_PUSH_RANGE, + "y": VELOCITY_PUSH_RANGE, + }, + "asset_cfg": SceneEntityCfg("robot"), + }, + ) + + if ENABLE_COM_RANDOMIZATION: + # mjlab 1.3.0: stock dr.body_ipos (operation="add") reads the compile-time + # default each reset → non-accumulating natively. + cfg.events["randomize_com"] = EventTermCfg( + func=dr.body_ipos, + mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",)), + "operation": "add", + "ranges": (-COM_RANDOMIZATION_RANGE, COM_RANDOMIZATION_RANGE), + }, + ) + + if ENABLE_HEAD_COM_RANDOMIZATION: + # Match velocity: randomize the CoM of the head-assembly bodies. + cfg.events["randomize_head_com"] = EventTermCfg( + func=dr.body_ipos, + mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot", body_names=HEAD_BODY_NAMES), + "operation": "add", + "ranges": (-HEAD_COM_RANDOMIZATION_RANGE, HEAD_COM_RANDOMIZATION_RANGE), + }, + ) + + if ENABLE_ARMATURE_RANDOMIZATION: + # Match velocity: reflected rotor inertia (non-accumulating, affects BAM). + cfg.events["randomize_armature"] = EventTermCfg( + func=dr.joint_armature, + mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot", joint_names=(r".*",)), + "operation": "scale", + "ranges": ARMATURE_RANDOMIZATION_RANGE, + }, + ) + + if ENABLE_KP_RANDOMIZATION or ENABLE_KD_RANDOMIZATION: + kp_range = KP_RANDOMIZATION_RANGE if ENABLE_KP_RANDOMIZATION else (1.0, 1.0) + kd_range = KD_RANDOMIZATION_RANGE if ENABLE_KD_RANDOMIZATION else (1.0, 1.0) + cfg.events["randomize_motor_gains"] = EventTermCfg( + func=microduck_mdp.randomize_delayed_actuator_gains, + mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot"), + "operation": "scale", + "kp_range": kp_range, + "kd_range": kd_range, + }, + ) + + if ENABLE_MASS_INERTIA_RANDOMIZATION: + # match velocity: physics-consistent mass+inertia via pseudo_inertia + # (alpha scales both by e^(2α), CoM untouched). Startup mode. The old + # custom randomize_mass_and_inertia was a no-op under mjlab 1.3.0. + _mi_lo, _mi_hi = MASS_INERTIA_RANDOMIZATION_RANGE + cfg.events["randomize_mass_inertia"] = EventTermCfg( + func=dr.pseudo_inertia, + mode="startup", + params={ + "asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",)), + "alpha_range": (math.log(_mi_lo) / 2.0, math.log(_mi_hi) / 2.0), + }, + ) + + if ENABLE_JOINT_FRICTION_RANDOMIZATION: + # match velocity: scale BAM's friction budget per-env via the + # FrictionDRBamActuator hook (dof_frictionloss is zeroed under BAM). + cfg.events["randomize_joint_friction"] = EventTermCfg( + func=microduck_mdp.randomize_bam_friction, + mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot"), + "scale_range": JOINT_FRICTION_RANDOMIZATION_RANGE, + }, + ) + + # NOTE: IMU mounting-misalignment is applied at the OBSERVATION level below + # (matching velocity) — the old event-based randomize_imu_orientation wrote + # site_quat, which under mjlab 1.3.0 is neither per-env nor read by the obs. + + # ── Terrain ─────────────────────────────────────────────────────────────── + if not rough: + cfg.scene.terrain.terrain_type = "plane" + cfg.scene.terrain.terrain_generator = None + else: + cfg.scene.terrain.terrain_type = "generator" + cfg.scene.terrain.terrain_generator = MICRODUCK_ROUGH_TERRAINS_CFG + if play: + cfg.scene.terrain.terrain_generator.curriculum = False + cfg.scene.terrain.terrain_generator.num_cols = 5 + cfg.scene.terrain.terrain_generator.num_rows = 5 + + # ── Curriculum ──────────────────────────────────────────────────────────── + if not rough: + del cfg.curriculum["terrain_levels"] + del cfg.curriculum["command_vel"] + + # Init-pose curriculum: ramp the set_ground_state mix from EASY → HARD instead + # of a flat 25/25/25/25 from step 0. With the flat split the policy optimized + # the easy majority (hold-stand + sit-rise) and left the hard poses under- + # trained — front only partially rose and face-up (back) froze into "do + # nothing". This introduces standing/sitting first, then face-down, then + # face-up last, and biases toward the hard poses late so they get the most + # practice. (event_param_curriculum shallow-merges these keys into the live + # set_ground_state event; the z-ranges / joint overrides are left untouched.) + cfg.curriculum["ground_state_mix"] = CurriculumTermCfg( + func=microduck_mdp.event_param_curriculum, + params={ + "event_name": "set_ground_state", + "param_stages": [ + # step, standing, sitting, face_down(front), face_up(back) + {"step": 0, "params": {"standing_prob": 0.40, "sitting_prob": 0.40, "face_down_prob": 0.20, "face_up_prob": 0.00}}, + {"step": 600 * 24, "params": {"standing_prob": 0.25, "sitting_prob": 0.30, "face_down_prob": 0.35, "face_up_prob": 0.10}}, + {"step": 1500 * 24, "params": {"standing_prob": 0.20, "sitting_prob": 0.25, "face_down_prob": 0.30, "face_up_prob": 0.25}}, + {"step": 2500 * 24, "params": {"standing_prob": 0.15, "sitting_prob": 0.20, "face_down_prob": 0.30, "face_up_prob": 0.35}}, + ], + }, + ) + + # Head pose command range curriculum — same per-joint widening as the velocity + # env (5% → 100% of each joint's reachable delta from HOME over ~2000 iters). + cfg.curriculum["head_pose_range"] = CurriculumTermCfg( + func=microduck_mdp.pose_command_range_curriculum, + params={ + "command_name": "head_pose", + "range_stages": [ + {"step": 0, "ranges": ((-0.05, 0.05), (-0.05, 0.05), (-0.07, 0.07), (-0.015, 0.015))}, + {"step": 500 * 24, "ranges": ((-0.17, 0.17), (-0.17, 0.17), (-0.21, 0.21), (-0.047, 0.047))}, + {"step": 1000 * 24, "ranges": ((-0.39, 0.39), (-0.39, 0.39), (-0.49, 0.49), (-0.11, 0.11))}, + {"step": 1500 * 24, "ranges": ((-0.72, 0.72), (-0.72, 0.72), (-0.91, 0.91), (-0.20, 0.20))}, + {"step": 2000 * 24, "ranges": ((-1.10, 1.10), (-1.10, 1.10), (-1.40, 1.40), (-0.31, 0.31))}, + ], + }, + ) + + # NOTE: the earlier head_pose_std / head_pose_weight curricula (band-aids for + # the head-droop) were removed — the droop was a backward-CoM balance crutch, + # fixed at the source by the STAND2 forward-shifted standing pose. head_pose + # tracking stays at its baseline (weight 3.0, std 0.5) + head_pose_range. + + # CoM-randomization range curricula — match velocity (ramp 0.003 → 0.015 trunk, + # 0.003 → 0.01 head over the first ~1500 / ~1000 iters). Trunk capped at + # ±15 mm per velocity's 2026-07 audit: beyond that the randomized CoM can + # leave the foot support polygon entirely, which trains hyper-reactive + # correction. (The old 0.02 final stage here exceeded that cap.) + if ENABLE_COM_RANDOMIZATION: + cfg.curriculum["com_range"] = CurriculumTermCfg( + func=microduck_mdp.com_range_curriculum, + params={ + "event_name": "randomize_com", + "range_stages": [ + {"step": 0, "range": 0.003}, + {"step": 500 * 24, "range": 0.005}, + {"step": 1000 * 24, "range": 0.01}, + {"step": 1500 * 24, "range": 0.015}, + ], + }, + ) + + if ENABLE_HEAD_COM_RANDOMIZATION: + cfg.curriculum["head_com_range"] = CurriculumTermCfg( + func=microduck_mdp.com_range_curriculum, + params={ + "event_name": "randomize_head_com", + "range_stages": [ + {"step": 0, "range": 0.003}, + {"step": 500 * 24, "range": 0.005}, + {"step": 1000 * 24, "range": 0.01}, + ], + }, + ) + + if ENABLE_VELOCITY_PUSHES: + cfg.curriculum["push_magnitude"] = CurriculumTermCfg( + func=microduck_mdp.push_curriculum, + params={ + "event_name": "push_robot", + "push_stages": [ + {"step": 0, "velocity_range": {"x": (0.0, 0.0), "y": (0.0, 0.0)}}, + {"step": 500 * 24, "velocity_range": {"x": (-0.08, 0.08), "y": (-0.08, 0.08)}}, + {"step": 1000 * 24, "velocity_range": {"x": VELOCITY_PUSH_RANGE, "y": VELOCITY_PUSH_RANGE}}, + ], + }, + ) + + # action_rate curriculum — velocity's exact ramp (-0.1 → -1.0 by iter 1500). + # Gentler early stages than the old -0.4/-0.8/-1.0-by-500 ramp: the rise + # skill gets discovered under light smoothing, then damping tightens. + # (Old note, still relevant: a -1.2 end once blocked back-recovery; -1.0 is + # the ceiling. With the ÷4 task scale this -1.0 now actually bites.) + cfg.curriculum["action_rate_weight"] = CurriculumTermCfg( + func=microduck_mdp.reward_weight, + params={ + "reward_name": "action_rate_l2", + "weight_stages": [ + {"step": 0, "weight": -0.1}, + {"step": 500 * 24, "weight": -0.2}, + {"step": 750 * 24, "weight": -0.4}, + {"step": 1000 * 24, "weight": -0.6}, + {"step": 1250 * 24, "weight": -0.8}, + {"step": 1500 * 24, "weight": -1.0}, + ], + }, + ) + + # Smoothness-polish curricula — introduce the anti-violence terms only + # AFTER the recovery skills exist. ground_state_mix finishes ramping the + # hard poses at iter 2500; from 3000 on, prone resets keep exercising the + # learned flips while these penalties fine-tune their execution (brake at + # arrival, less jitter). Two runs proved the same weights active from + # step 0 prevent the flips from ever being DISCOVERED (attempt-tax on + # exploration). If recovery degrades after 3000, soften the last stage, + # do NOT move the introduction earlier. + cfg.curriculum["arrival_damping_weight"] = CurriculumTermCfg( + func=microduck_mdp.reward_weight, + params={ + "reward_name": "arrival_damping", + "weight_stages": [ + {"step": 0, "weight": 0.0}, + {"step": 3000 * 24, "weight": -0.025}, + {"step": 4000 * 24, "weight": -0.05}, + ], + }, + ) + # head_pose_bias: same introduction timing as arrival_damping (see its + # comment — timing, not magnitude, is what protects recovery discovery). + # Dosage: standup runs head_pose_tracking at 0.75 vs velocity's 2.0 (task + # weights ÷4 rebalance), so the bias lands at 1.5 vs velocity's 3.0. At + # 1.5 a 15° standing droop costs 0.39/step, 5° costs 0.13/step. If the + # standing head is still down after a run, raise the last stage — do NOT + # move the introduction earlier. + cfg.curriculum["head_pose_bias_weight"] = CurriculumTermCfg( + func=microduck_mdp.reward_weight, + params={ + "reward_name": "head_pose_bias", + "weight_stages": [ + {"step": 0, "weight": 0.0}, + {"step": 3000 * 24, "weight": 0.5}, + {"step": 4000 * 24, "weight": 1.5}, + ], + }, + ) + cfg.curriculum["torque_rate_weight"] = CurriculumTermCfg( + func=microduck_mdp.reward_weight, + params={ + "reward_name": "joint_torque_rate_l2", + "weight_stages": [ + {"step": 0, "weight": 0.0}, + {"step": 3000 * 24, "weight": -1e-3}, + ], + }, + ) + + # ── Body-control curricula ──────────────────────────────────────────────── + # Everything below is body-control only — NOTE the early return; add any + # unrelated cfg above this line. + if not ENABLE_BODY_CONTROL: + return cfg + + # Tracking weight ramps in at 2500 — exactly when ground_state_mix reaches + # its final (hardest) mix, so the recovery-discovery phase trains without + # any body-command pressure. Final weight 4.0: at full command the fixed- + # stand terms oppose tracking by ~2/step AFTER the relax stages below, and + # tracking's marginal gain is ~0.65/step per unit weight → 4.0 wins with + # margin. (Without the relax stages the opposition is ~4.3/step and even + # the old design's weight 5 loses — the phase-2 lesson.) + cfg.curriculum["body_pose_tracking_weight"] = CurriculumTermCfg( + func=microduck_mdp.reward_weight, + params={ + "reward_name": "body_pose_tracking", + "weight_stages": [ + {"step": 0, "weight": 0.0}, + {"step": 2500 * 24, "weight": 1.5}, + {"step": 3000 * 24, "weight": 3.0}, + {"step": 4000 * 24, "weight": 4.0}, + ], + }, + ) + + # Command range widening, synced to the weight ramp. x/y/yaw stay at their + # alive ranges (untracked); only z/roll/pitch widen. z asymmetric — see the + # BODY_CMD constants block. + _alive_xy = (-BODY_CMD_ALIVE_XY, BODY_CMD_ALIVE_XY) + _alive_ang = (-BODY_CMD_ALIVE_ANGLE, BODY_CMD_ALIVE_ANGLE) + cfg.curriculum["body_pose_range"] = CurriculumTermCfg( + func=microduck_mdp.pose_command_range_curriculum, + params={ + "command_name": "body_pose", + "range_stages": [ + # ranges = (x, y, z, roll, pitch, yaw) + {"step": 0, "ranges": ( + _alive_xy, _alive_xy, (-0.005, 0.005), + _alive_ang, _alive_ang, _alive_ang, + )}, + {"step": 2500 * 24, "ranges": ( + _alive_xy, _alive_xy, (-0.010, 0.005), + (-math.radians(8), math.radians(8)), + (-math.radians(8), math.radians(8)), + _alive_ang, + )}, + {"step": 3000 * 24, "ranges": ( + _alive_xy, _alive_xy, (-0.018, 0.008), + (-math.radians(12), math.radians(12)), + (-math.radians(12), math.radians(12)), + _alive_ang, + )}, + {"step": 4000 * 24, "ranges": ( + _alive_xy, _alive_xy, + (-BODY_CMD_MAX_Z_DOWN, BODY_CMD_MAX_Z_UP), + (-BODY_CMD_MAX_ANGLE, BODY_CMD_MAX_ANGLE), + (-BODY_CMD_MAX_ANGLE, BODY_CMD_MAX_ANGLE), + _alive_ang, + )}, + ], + }, + ) + + # Conflict relax — the standup phase-2 lesson applied to THIS reward set: + # the sharp fixed-stand attractors directly out-bid commanded deviations + # (at Δz=−2cm/15° tilt: height_stand_sharp −0.83, upright_sharp −0.79, + # standing_composite −1.9 per step). Their bootstrap/polish job is done by + # 3000; body_pose_tracking at cmd=0 (30% of resamples) takes over the + # "sharp peak at nominal stand" role with even tighter stds. The broad + # bootstrap layers (height_stand, upright_linear, height_stand_l1, + # pose_stand_*) are left untouched — they're what recovery leans on, and + # their opposition at full command is mild (~0.9/step total). Standing- + # attractor mass is roughly conserved: 6.25 before → 2.2 + tracking 4.0. + cfg.curriculum["height_stand_sharp_weight"] = CurriculumTermCfg( + func=microduck_mdp.reward_weight, + params={ + "reward_name": "height_stand_sharp", + "weight_stages": [ + {"step": 0, "weight": 1.0}, + {"step": 3000 * 24, "weight": 0.5}, + {"step": 4000 * 24, "weight": 0.2}, + ], + }, + ) + cfg.curriculum["upright_sharp_weight"] = CurriculumTermCfg( + func=microduck_mdp.reward_weight, + params={ + "reward_name": "upright_sharp", + "weight_stages": [ + {"step": 0, "weight": 1.5}, + {"step": 3000 * 24, "weight": 1.0}, + {"step": 4000 * 24, "weight": 0.5}, + ], + }, + ) + cfg.curriculum["standing_composite_weight"] = CurriculumTermCfg( + func=microduck_mdp.reward_weight, + params={ + "reward_name": "standing_composite", + "weight_stages": [ + {"step": 0, "weight": 3.75}, + {"step": 3000 * 24, "weight": 2.5}, + {"step": 4000 * 24, "weight": 1.5}, + ], + }, + ) + + return cfg + + +# ── RL runner config ────────────────────────────────────────────────────────── + +MicroduckStandUpRlCfg = RslRlOnPolicyRunnerCfg( + actor=RslRlModelCfg( + hidden_dims=(512, 256, 128), + activation="elu", + obs_normalization=True, # matches velocity; normalizer MUST be baked into ONNX by export.py + distribution_cfg={ + "class_name": "GaussianDistribution", + "init_std": 1.0, + "std_type": "scalar", + }, + ), + critic=RslRlModelCfg( + hidden_dims=(512, 256, 128), + activation="elu", + obs_normalization=True, + ), + algorithm=PpoWithSymmetryCfg( + value_loss_coef=1.0, + use_clipped_value_loss=True, + clip_param=0.2, + entropy_coef=0.01, + num_learning_epochs=5, + num_mini_batches=4, + learning_rate=1.0e-3, + schedule="adaptive", + gamma=0.99, + lam=0.95, + desired_kl=0.01, + max_grad_norm=1.0, + symmetry_cfg=SYMMETRY_CFG if ENABLE_SYMMETRY else None, + ), + wandb_project="mjlab_microduck", + experiment_name="microduck_stand", + run_name="microduck_stand", + save_interval=250, + num_steps_per_env=24, + max_iterations=15_000, +) diff --git a/src/mjlab_microduck/tasks/microduck_velocity_env_cfg.py b/src/mjlab_microduck/tasks/microduck_velocity_env_cfg.py new file mode 100644 index 0000000..45a1f6d --- /dev/null +++ b/src/mjlab_microduck/tasks/microduck_velocity_env_cfg.py @@ -0,0 +1,949 @@ +"""Microduck velocity (walking) environment. + +The main locomotion task: velocity-command tracking + head-pose commands. +The reward/regularization recipe is locomotion-focused (lean tracking + +gait/feet terms, curriculum-ramped action-rate smoothing), with: + + - foot_slip kept at -0.1 (deliberately weak — stronger was too restrictive + for this robot's pivot-heavy turning) + - fixed, modest command ranges (ang ±1.0 makes turning learnable) instead of + a widening curriculum that outpaced the robot's capability + - turn-in-place: 15% of envs get lin=0 + |ang| ∈ [0.4, 1.0] (2026-07 audit: + independent uniform sampling makes spin-on-the-spot ~2% of data → untrained) + - head_pose_tracking as a primary objective, plus an EMA-based head_pose_bias + penalty that prices only the escapable DC head droop (see below) + - body_pose tracking infra kept intact but DISABLED (weight 0) so the obs + slot stays alive for envs that use it +""" + +import math +from copy import deepcopy + +NUM_STEPS_PER_ENV = 24 + +# Fraction of envs commanded to spin on the spot (lin=0, |ang| ∈ [0.4·max, max]). +TURN_IN_PLACE_FRACTION = 0.15 + +# Symmetry +ENABLE_SYMMETRY = False + +# Domain randomization toggles +ENABLE_COM_RANDOMIZATION = True +ENABLE_HEAD_COM_RANDOMIZATION = True # Randomize CoM of the head assembly bodies +ENABLE_KP_RANDOMIZATION = False # Was True +ENABLE_KD_RANDOMIZATION = False # Was True +ENABLE_MASS_INERTIA_RANDOMIZATION = True # Can enable once walking is stable +ENABLE_JOINT_FRICTION_RANDOMIZATION = True # Scales BAM's friction budget per-env via FrictionDRBamActuator.friction_scale +ENABLE_JOINT_DAMPING_RANDOMIZATION = False +ENABLE_ARMATURE_RANDOMIZATION = True # Reflected rotor inertia (microban-style). DOES affect BAM (armature is set, not zeroed). +ENABLE_VELOCITY_PUSHES = True # Velocity-based pushes for robustness training +ENABLE_IMU_ORIENTATION_RANDOMIZATION = True # Simulates mounting errors +ENABLE_ENCODER_BIAS = True # Per-env joint encoder calibration offset (actor obs sees joint_pos + bias) +ENABLE_BASE_ORIENTATION_RANDOMIZATION = False # Randomize initial tilt to force reactive behavior + +# Head/body pose command tracking (replaces the old neck-offset disturbance scheme). +# Head pose: 4D deltas-from-HOME on neck/head joints; vel env tracks these as a +# primary objective. Body pose: 6D delta in [x, y, z, roll, pitch, yaw]; vel env +# samples small ranges + tiny reward weight so input neurons stay alive but +# tracking isn't the priority (standup env raises the weight). +HEAD_POSE_CMD_RESAMPLE_S = (2.0, 5.0) +BODY_POSE_CMD_RESAMPLE_S = (2.0, 5.0) + +# Observation configuration +USE_PROJECTED_GRAVITY = True # If True, use projected gravity instead of raw accelerometer + +# Domain randomization ranges (adjust as needed) +# Conservative ranges proven to be stable - can increase gradually if needed +COM_RANDOMIZATION_RANGE = 0.003 # ±3mm initial, ramped to ±8mm via curriculum +# Head CoM randomization: applied per-episode to every body of the head assembly +# (neck → neck_pitch → yaw_roll_motion → head-roll body). Same non-accumulating +# mechanism as the trunk CoM randomization above. The head-roll body is named +# bottom_head_shell in the walk model and jaw_soft in the 2026-07 roller model, +# hence the alternation. NOTE: bearing_roll is NOT a head body — in both models +# it is the right-hip-yaw link (child of trunk_base); it has always been listed +# here by mistake and is kept only to preserve existing DR behavior. +HEAD_COM_RANDOMIZATION_RANGE = 0.003 # ±3mm initial, ramped via curriculum +HEAD_BODY_NAMES = ( + "neck", + "neck_pitch", + "yaw_roll_motion", + "(bottom_head_shell|jaw_soft)", + "bearing_roll", +) +MASS_INERTIA_RANDOMIZATION_RANGE = (0.95, 1.05) # ±5% applied to BOTH mass and inertia together. +KP_RANDOMIZATION_RANGE = (0.85, 1.15) # ±15% +KD_RANDOMIZATION_RANGE = (0.9, 1.1) # ±10% (can increase to 0.8-1.2) +JOINT_FRICTION_RANDOMIZATION_RANGE = (0.9, 1.1) +JOINT_DAMPING_RANDOMIZATION_RANGE = (0.9, 1.1) +ARMATURE_RANDOMIZATION_RANGE = (0.9, 1.1) # ±10% reflected rotor inertia (microban: dr.joint_armature, same range) +VELOCITY_PUSH_INTERVAL_S = (3.0, 6.0) # Apply pushes every 3-6 seconds +VELOCITY_PUSH_RANGE = (-0.3, 0.3) # Velocity change range in m/s. Was ±0.5 — an +# ADDITIVE kick larger than max walk speed (0.4) every 3-6 s trains a permanently +# nervous fall-recovery gait (2026-07 audit). ±0.3 keeps push robustness while +# letting a calmer gait be optimal. +IMU_ORIENTATION_RANDOMIZATION_ANGLE = 6.0 # up-to-6° random-axis IMU mounting error. NOTE: zero-centered (random axis) — trains tolerance to misalignment *magnitude*, NOT a pitch bias. The real board's systematic ~5° pitch offset is corrected at the source in the runtime (imu-pitch-offset), not here. +ENCODER_BIAS_RANGE = (-0.015, 0.015) # ±0.86° per-joint encoder offset (constant per env) +BASE_ORIENTATION_MAX_PITCH_DEG = 10.0 # ±10° forward/backward tilt at episode start +BASE_ORIENTATION_MAX_ROLL_DEG = 5.0 # ±5° side-to-side tilt at episode start + +import mujoco as _mujoco +import mjlab.terrains as terrain_gen +from mjlab.terrains.terrain_generator import TerrainGeneratorCfg + +from mjlab.envs import ManagerBasedRlEnvCfg +from mjlab.envs.mdp import dr +from mjlab.envs.mdp.actions import JointPositionActionCfg +from mjlab.managers import ( + CurriculumTermCfg, + EventTermCfg, + ObservationTermCfg, + RewardTermCfg, + TerminationTermCfg, +) +from mjlab.managers.scene_entity_config import SceneEntityCfg +from mjlab.rl import ( + RslRlOnPolicyRunnerCfg, + RslRlModelCfg, +) +from mjlab.sensor import ( + ContactMatch, + ContactSensorCfg, + ObjRef, + RingPatternCfg, + TerrainHeightSensorCfg, +) +from mjlab.tasks.velocity import mdp +from mjlab.tasks.velocity.mdp import UniformVelocityCommandCfg +from mjlab.tasks.velocity.velocity_env_cfg import make_velocity_env_cfg +from mjlab.utils.noise import UniformNoiseCfg as Unoise + +from mjlab_microduck.robot.microduck_constants import MICRODUCK_WALK_ROBOT_CFG +from mjlab_microduck.tasks import mdp as microduck_mdp +from mjlab_microduck.tasks.symmetry import PpoWithSymmetryCfg, SYMMETRY_CFG + + +# Microduck-specific rough terrain: much gentler than the default ROUGH_TERRAINS_CFG. +# The robot can only lift its feet ~1-2 cm, so steps are capped at 1.5 cm. +MICRODUCK_ROUGH_TERRAINS_CFG = TerrainGeneratorCfg( + size=(8.0, 8.0), + border_width=20.0, + num_rows=10, + num_cols=20, + sub_terrains={ + "flat": terrain_gen.BoxFlatTerrainCfg(proportion=0.25), + "pyramid_stairs": terrain_gen.BoxPyramidStairsTerrainCfg( + proportion=0.25, + step_height_range=(0.0, 0.015), # max 1.5 cm (vs 10 cm default) + step_width=0.15, + platform_width=2.0, + border_width=1.0, + ), + # NOTE: BoxInvertedPyramidStairsTerrainCfg removed — it sets env_origin_z to the pit + # bottom (negative), causing resets at root_z = 0.12 + env_origin_z ≈ −0.10 m which + # places the robot below the pit floor and makes it fall through the ground. + # Uneven cobblestone-like ground: random per-cell height offsets. + # grid_width=0.12 on an 8m patch = 66×66 = 4 356 boxes/patch → ~261 K total → OOM. + # 0.45 m gives 17×17 = 289 boxes/patch → ~17 K total (border = 0.35 m ✓). + # Must not divide evenly into terrain size (8.0 m): 0.45 × 17 = 7.65 ✓ + "random_grid": terrain_gen.BoxRandomGridTerrainCfg( + proportion=0.30, + grid_width=0.45, + grid_height_range=(0.0, 0.010), # max 1 cm + platform_width=1.5, + ), + # Gentle slopes (heightfield pyramid, platform on TOP — robot spawns on + # the flat platform and walks down/up/across the slope as commands + # resample). slope_range is rise/run: 0.03→0.10 ≈ 1.7°→5.7° by + # difficulty — small robot, small slopes. NOT inverted (see the + # inverted-pyramid env_origin note above — same pit-spawn risk class). + # vertical_scale=0.001 keeps quantization steps at 1 mm so a gentle + # slope is smooth instead of a staircase of 5 mm ledges. + "pyramid_slope": terrain_gen.HfPyramidSlopedTerrainCfg( + proportion=0.20, + slope_range=(0.03, 0.10), + platform_width=2.0, + vertical_scale=0.001, + ), + }, + add_lights=False, +) + + +def _soften_terrain_contacts(spec: _mujoco.MjSpec) -> None: + """Soften terrain box geom contacts to reduce edge-contact NaN instability. + + Box terrains place adjacent geoms at different heights. The hard edges where + heights change cause contact normal instability when feet land on them, which + can produce impulsive NaN forces in the MuJoCo solver. + + Doubling the solref time constant (0.02 → 0.04 s) makes contact springs + 2× softer — enough to damp the instability without noticeably changing the + macro-level walking physics. Applied to all geoms in the "terrain" body, + which contains every box generated by TerrainGenerator. + """ + body = spec.body("terrain") + count = 0 + for geom in body.geoms: + geom.solref = [0.04, 1.0] # 2× softer time constant (default: 0.02) + geom.solimp = [0.85, 0.95, 0.001, 0.5, 2.0] # slightly softer impedance + count += 1 + print(f"[rough terrain] spec_fn: softened {count} terrain geoms (solref=0.04)") + + +def make_microduck_velocity_env_cfg( + play: bool = False, + rough: bool = False, +) -> ManagerBasedRlEnvCfg: + """Create Microduck velocity tracking environment configuration.""" + + std_standing = { + # Lower body — tighter to keep the robot in home pose when standing + r".*hip_yaw.*": 0.1, + r".*hip_roll.*": 0.05, # 0.1→0.06→0.05 — hold the 5°-inward stance (sole sits flat), stop leg splay + r".*hip_pitch.*": 0.15, + r".*knee.*": 0.15, + r".*ankle.*": 0.1, + } + + std_walking = { + # Lower body + r".*hip_yaw.*": 0.3, + r".*hip_roll.*": 0.05, # 0.1→0.06→0.05 — hold the 5°-inward stance, stop the leg splay to vertical + r".*hip_pitch.*": 0.4, + r".*knee.*": 0.4, + r".*ankle.*": 0.25, # was 0.15 + } + + site_names = ["left_foot", "right_foot"] + + # Contact sensor for feet - LEFT, RIGHT order + feet_ground_cfg = ContactSensorCfg( + name="feet_ground_contact", + primary=ContactMatch( + mode="geom", + pattern=r"^(left_foot_collision|right_foot_collision)$", # LEFT foot first, RIGHT foot second + entity="robot", + ), + secondary=ContactMatch(mode="body", pattern="terrain"), + fields=("found", "force"), + reduce="netforce", + num_slots=1, + track_air_time=True, + ) + + self_collision_cfg = ContactSensorCfg( + name="self_collision", + primary=ContactMatch(mode="subtree", pattern="trunk_base", entity="robot"), + secondary=ContactMatch(mode="subtree", pattern="trunk_base", entity="robot"), + fields=("found",), + reduce="none", + num_slots=1, + ) + + # mjlab 1.3.0: foot_height obs + foot_clearance/foot_swing_height rewards are + # now driven by a per-foot terrain-height ray sensor (was site_pos based). + # Mirrors microban's foot_height_scan. + foot_height_scan_cfg = TerrainHeightSensorCfg( + name="foot_height_scan", + frame=tuple(ObjRef(type="site", name=s, entity="robot") for s in site_names), + pattern=RingPatternCfg.single_ring(radius=0.04, num_samples=2), + ray_alignment="yaw", + max_distance=1.0, + exclude_parent_body=True, + include_geom_groups=(0,), + debug_vis=False, + ) + + foot_frictions_geom_names = ( + "left_foot_collision", + "right_foot_collision", + ) + + # Base configuration + cfg = make_velocity_env_cfg() + + # Robot setup + cfg.scene.entities = {"robot": MICRODUCK_WALK_ROBOT_CFG} + cfg.scene.sensors = (feet_ground_cfg, self_collision_cfg, foot_height_scan_cfg) + cfg.viewer.body_name = "trunk_base" + + # Action configuration + joint_pos_action = cfg.actions["joint_pos"] + assert isinstance(joint_pos_action, JointPositionActionCfg) + joint_pos_action.scale = 1.0 + + # === REWARDS === + # Pose reward configuration + cfg.rewards["pose"].params["std_standing"] = std_standing # tight when command=0 + cfg.rewards["pose"].params["std_walking"] = std_walking + cfg.rewards["pose"].params["std_running"] = std_walking + # Pose reward operates on LEG joints only. Head/neck are command-driven + # (head_pose_tracking) — if they were in this reward too, it would pull + # them to HOME while head_pose_tracking pulls them to the command, and the + # policy converges to "ignore the command" because pose reward dominates + # once head_pose_tracking's gradient dies at large commands. + cfg.rewards["pose"].params["asset_cfg"] = SceneEntityCfg( + "robot", joint_names=(r"^(?!passive_|.*neck.*|.*head.*).*",) + ) + cfg.rewards["pose"].params["walking_threshold"] = 0.01 + cfg.rewards["pose"].weight = 1.0 + + # Body-specific reward configurations + cfg.rewards["upright"].params["asset_cfg"].body_names = ("trunk_base",) + # upright: deliberately strong (2.0 / std²=0.05, was 1.0 / std²=0.1). + # 2026-07 pitch-vs-speed eval: the policy walks with a +2-4° steady forward + # lean (p90 ~6-8°) and ~2/3 of push-induced falls at speed are FORWARD. At + # weight 1.0 / std²=0.1 a 4° lean cost ~0.05/step — effectively free. At + # 2.0 / std²=0.05 it costs ~0.19/step: enough gradient to hold the trunk + # level in steady gait while transient lean (push recovery, accel) stays + # affordable. + cfg.rewards["upright"].weight = 2.0 + cfg.rewards["upright"].params["std"] = math.sqrt(0.05) + + # Foot-specific configurations. In mjlab 1.3.0 foot_swing_height is fully + # sensor-driven (no asset_cfg); only foot_clearance/foot_slip still carry an + # asset_cfg whose site_names select the feet. + for reward_name in ["foot_clearance", "foot_slip"]: + cfg.rewards[reward_name].params["asset_cfg"].site_names = site_names + + # Body-specific configurations + cfg.rewards["body_ang_vel"].params["asset_cfg"].body_names = ("trunk_base",) + + # foot_slip deliberately weak (-0.1, not -1.0): -1.0 was too restrictive + # for this robot's pivot-heavy turning. + cfg.rewards["foot_slip"].weight = -0.1 + cfg.rewards["foot_slip"].params["command_threshold"] = 0.01 + + cfg.rewards.pop("soft_landing", None) + + # Self-collision penalty: discourages legs from crashing into the trunk + # battery holder (the self_collision_only-classed geoms on leg, leg_2, + # battery_holder). With proper joint-range limits the policy can't actually + # reach the body, but a positive signal here keeps it well clear. + cfg.rewards["self_collisions"] = RewardTermCfg( + func=mdp.self_collision_cost, + weight=-1.0, + params={"sensor_name": self_collision_cfg.name}, + ) + + + # air_time window [0.125, 0.300] s. NOTE: standing still at zero command is + # taught by the standing_envs curriculum (→25% standing envs by ~iter 2000), + # not by an explicit stillness/no-stepping term. + cfg.rewards["air_time"].weight = 3.0 + cfg.rewards["air_time"].params["command_threshold"] = 0.01 + cfg.rewards["air_time"].params["threshold_min"] = 0.125 + cfg.rewards["air_time"].params["threshold_max"] = 0.300 + + cfg.rewards["body_ang_vel"].weight = -0.05 + cfg.rewards["angular_momentum"].weight = -0.02 + + # Velocity tracking rewards + cfg.rewards["track_linear_velocity"].weight = 2.0 + cfg.rewards["track_linear_velocity"].params["std"] = math.sqrt(0.1) + cfg.rewards["track_angular_velocity"].weight = 2.0 + cfg.rewards["track_angular_velocity"].params["std"] = math.sqrt(0.5) + + # Action smoothness: stage-0 value; the action_rate_weight curriculum below + # ramps it -0.1 → -1.0 by iter 1500. + cfg.rewards["action_rate_l2"].weight = -0.1 + + cfg.rewards["foot_clearance"].params["command_threshold"] = 0.01 + cfg.rewards["foot_clearance"].params["target_height"] = 0.02 # Increased from 0.01 to penalize dragging + + cfg.rewards["foot_swing_height"].params["command_threshold"] = 0.01 + cfg.rewards["foot_swing_height"].params["target_height"] = 0.02 # Increased from 0.01 to force foot lifting + + # NOTE: no neck-only action-rate term — the shared action_rate_l2 sums over + # ALL action dims (neck included), and head_pose_tracking below gives the + # 4 neck/head DOFs a position objective, so the neck is fully shaped. + + # Events + # BAM (mjlab_frictionloss branch) writes per-env dof_frictionloss/dof_damping + # every step; this no-op event registers those fields for per-world expansion. + cfg.events["expand_bam_friction_fields"] = EventTermCfg( + func=microduck_mdp.expand_bam_friction_fields, + mode="startup", + ) + + cfg.events["reset_action_history"] = EventTermCfg( + func=microduck_mdp.reset_action_history, + mode="reset", + ) + + cfg.events["foot_friction"].params[ + "asset_cfg" + ].geom_names = foot_frictions_geom_names + cfg.events["foot_friction"].params["ranges"] = (0.7, 1.3) # Grippier footpad — narrowed from (0.3, 1.2) + # Terminate environments that have gone numerically unstable (NaN physics). + # MuJoCo can produce NaN joint positions on extreme contact impulses. + # Terminating immediately resets to a valid state before NaN propagates + # into the observation buffer and corrupts network weights. + cfg.terminations["nan_state"] = TerminationTermCfg( + func=microduck_mdp.robot_state_is_nan, + time_out=False, + params={"sensor_names": (feet_ground_cfg.name,)}, + ) + + cfg.events["reset_base"].params["pose_range"]["z"] = (0.12, 0.13) + + # Velocity-based pushes for robustness training + if ENABLE_VELOCITY_PUSHES: + # In play mode, use shorter interval for better visibility + interval = (0.5, 1.0) if play else VELOCITY_PUSH_INTERVAL_S + + cfg.events["push_robot"] = EventTermCfg( + func=mdp.push_by_setting_velocity, + mode="interval", + interval_range_s=interval, + params={ + "velocity_range": { + "x": VELOCITY_PUSH_RANGE, + "y": VELOCITY_PUSH_RANGE, + }, + "asset_cfg": SceneEntityCfg("robot"), + }, + ) + + # Domain randomization — re-sampled per episode at reset. In mjlab 1.3.0 the + # stock dr.* ops with operation="add"/"scale" read from the compile-time + # default field each reset (Operation.uses_defaults=True), so they are + # NON-accumulating natively — this upstream behavior replaces microduck's old + # custom restore-then-add functions that worked around the accumulation footgun. + if ENABLE_COM_RANDOMIZATION: + cfg.events["randomize_com"] = EventTermCfg( + func=dr.body_ipos, + mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",)), + "operation": "add", + "ranges": (-COM_RANDOMIZATION_RANGE, COM_RANDOMIZATION_RANGE), + }, + ) + + if ENABLE_HEAD_COM_RANDOMIZATION: + # Randomize the CoM of the head assembly bodies (per-body fresh offset each reset). + cfg.events["randomize_head_com"] = EventTermCfg( + func=dr.body_ipos, + mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot", body_names=HEAD_BODY_NAMES), + "operation": "add", + "ranges": (-HEAD_COM_RANDOMIZATION_RANGE, HEAD_COM_RANDOMIZATION_RANGE), + }, + ) + + if ENABLE_KP_RANDOMIZATION or ENABLE_KD_RANDOMIZATION: + # Randomize motor PD gains + # Uses custom function that handles DelayedActuator + kp_range = KP_RANDOMIZATION_RANGE if ENABLE_KP_RANDOMIZATION else (1.0, 1.0) + kd_range = KD_RANDOMIZATION_RANGE if ENABLE_KD_RANDOMIZATION else (1.0, 1.0) + cfg.events["randomize_motor_gains"] = EventTermCfg( + func=microduck_mdp.randomize_delayed_actuator_gains, + mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot"), + "operation": "scale", + "kp_range": kp_range, + "kd_range": kd_range, + }, + ) + + if ENABLE_MASS_INERTIA_RANDOMIZATION: + # Physics-consistent mass + inertia randomization via mjlab's pseudo_inertia: + # alpha scales BOTH mass and inertia by e^(2*alpha) with the CoM unchanged + # (so it does NOT conflict with randomize_com). alpha_range is derived from + # the ±5% mass scale range: e^(2*alpha) ∈ [0.95, 1.05]. + # Replaces the old custom randomize_mass_and_inertia, which was a silent + # no-op under mjlab 1.3.0 (direct per-env body_mass/body_inertia writes are + # not expanded and collapse to a single shared value). Startup mode = fixed + # per env for the whole run (standard for mass DR; no accumulation). + _mi_lo, _mi_hi = MASS_INERTIA_RANDOMIZATION_RANGE + cfg.events["randomize_mass_inertia"] = EventTermCfg( + func=dr.pseudo_inertia, + mode="startup", + params={ + "asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",)), + "alpha_range": (math.log(_mi_lo) / 2.0, math.log(_mi_hi) / 2.0), + }, + ) + + if ENABLE_JOINT_FRICTION_RANDOMIZATION: + # Joint-friction DR under BAM: scales BAM's velocity-independent friction + # budget (Coulomb + Stribeck + load) per-env via the FrictionDRBamActuator + # friction_scale hook. MuJoCo's dof_frictionloss is zeroed under BAM, so the + # stock dr.dof_frictionloss is a no-op — this is the BAM-native path. + cfg.events["randomize_joint_friction"] = EventTermCfg( + func=microduck_mdp.randomize_bam_friction, + mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot"), + "scale_range": JOINT_FRICTION_RANDOMIZATION_RANGE, + }, + ) + + if ENABLE_JOINT_DAMPING_RANDOMIZATION: + # Randomize joint damping (lubrication, temperature effects). + # Custom non-accumulating scaler. NOTE: no-op under BAM (dof_damping + # zeroed in edit_spec); only affects the XML position actuator. + cfg.events["randomize_joint_damping"] = EventTermCfg( + func=microduck_mdp.randomize_dof_field_scaled, + mode="reset", + domain_randomization=True, + params={ + "asset_cfg": SceneEntityCfg("robot", joint_names=(r".*",)), + "field": "dof_damping", # required by domain_randomization=True + "scale_range": JOINT_DAMPING_RANDOMIZATION_RANGE, + }, + ) + + if ENABLE_ARMATURE_RANDOMIZATION: + # Randomize reflected rotor inertia (armature), microban-exact + # (dr.joint_armature, scale, ±10%). Non-accumulating (uses_defaults). DOES + # affect the BAM actuator — BAM sets dof_armature (~0.0018), it isn't zeroed. + cfg.events["randomize_armature"] = EventTermCfg( + func=dr.joint_armature, + mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot", joint_names=(r".*",)), + "operation": "scale", + "ranges": ARMATURE_RANDOMIZATION_RANGE, + }, + ) + + # IMU orientation randomization (mounting error) is applied at the OBSERVATION + # level below (per-env constant rotation of projected_gravity + base_ang_vel). + # The old event-based randomize_imu_orientation wrote site_quat, which under + # mjlab 1.3.0 is neither per-env expanded nor read by these obs — a no-op. + + # Base orientation randomization (forces reactive behavior) + if ENABLE_BASE_ORIENTATION_RANDOMIZATION: + cfg.events["randomize_base_orientation"] = EventTermCfg( + func=microduck_mdp.randomize_base_orientation, + mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot"), + "max_pitch_deg": BASE_ORIENTATION_MAX_PITCH_DEG, + "max_roll_deg": BASE_ORIENTATION_MAX_ROLL_DEG, + }, + ) + + # Observations + del cfg.observations["actor"].terms["base_lin_vel"] + # mjlab 1.3.0 adds a height_scan term (terrain ray scan) to both groups by + # default. The microduck has no such body-mounted terrain sensor for the + # policy, so drop it from both (mirrors microban). + del cfg.observations["actor"].terms["height_scan"] + del cfg.observations["critic"].terms["height_scan"] + + # Add base_lin_vel to critic only (privileged information) + cfg.observations["critic"].terms["base_lin_vel"] = ObservationTermCfg( + func=mdp.base_lin_vel, + scale=1.0, + ) + + # Determine gravity/accelerometer term name based on flag + gravity_term_name = "projected_gravity" if USE_PROJECTED_GRAVITY else "raw_accelerometer" + + # Replace projected_gravity with raw_accelerometer if flag is False + if not USE_PROJECTED_GRAVITY: + # Remove projected_gravity and add raw_accelerometer + del cfg.observations["actor"].terms["projected_gravity"] + cfg.observations["actor"].terms["raw_accelerometer"] = ObservationTermCfg( + func=microduck_mdp.raw_accelerometer, + scale=1.0, + ) + + cfg.observations["actor"].terms[gravity_term_name] = deepcopy( + cfg.observations["actor"].terms[gravity_term_name] + ) + cfg.observations["actor"].terms["base_ang_vel"] = deepcopy( + cfg.observations["actor"].terms["base_ang_vel"] + ) + + cfg.observations["actor"].terms["base_ang_vel"].delay_min_lag = 0 + cfg.observations["actor"].terms["base_ang_vel"].delay_max_lag = 1 # was 3 (=60 ms worst case); real dxl IMU path is fast — ±20 ms envelope (2026-07 audit) + cfg.observations["actor"].terms["base_ang_vel"].delay_update_period = 64 + + cfg.observations["actor"].terms[gravity_term_name].delay_min_lag = 0 + cfg.observations["actor"].terms[gravity_term_name].delay_max_lag = 1 # was 3 (=60 ms worst case); real dxl IMU path is fast — ±20 ms envelope (2026-07 audit) + cfg.observations["actor"].terms[gravity_term_name].delay_update_period = 64 + + # The critic's sensor-derived terms are the one obs path `nan_state` cannot + # protect (it checks joint + root state; these read raycast/contact sensor + # data, which MuJoCo can return non-finite for while the state is still + # clean). A single NaN here kills the whole run via rsl_rl's check_nan — + # that is the 2026-08-21 Velocity2-Rough-Backlash crash. Critic-only, so + # sanitizing costs the policy nothing. + for _term, _safe in ( + ("foot_contact_forces", microduck_mdp.foot_contact_forces_safe), + ("foot_height", microduck_mdp.foot_height_safe), + ("foot_air_time", microduck_mdp.foot_air_time_safe), + ): + if _term in cfg.observations["critic"].terms: + cfg.observations["critic"].terms[_term].func = _safe + + # Observation noise configuration (edit these values as needed) + cfg.observations["actor"].terms["base_ang_vel"].noise = Unoise(n_min=-0.03, n_max=0.03) # was 0.2 + cfg.observations["actor"].terms[gravity_term_name].noise = Unoise(n_min=-0.01, n_max=0.01) # was 0.15 + cfg.observations["actor"].terms["joint_pos"].noise = Unoise(n_min=-0.001, n_max=0.001) # was 0.05 + cfg.observations["actor"].terms["joint_vel"].noise = Unoise(n_min=-0.25, n_max=0.25) # was 2.0 + + # IMU mounting-misalignment DR (per-env constant rotation of the IMU-derived + # observations). Applied to the ACTOR only (the policy sees a slightly rotated + # IMU frame, like a real mounting error); the critic keeps the true values. + if ENABLE_IMU_ORIENTATION_RANDOMIZATION: + av = cfg.observations["actor"].terms["base_ang_vel"] + av.func = microduck_mdp.base_ang_vel_imu_misaligned + av.params = {"max_angle_deg": IMU_ORIENTATION_RANDOMIZATION_ANGLE} + if USE_PROJECTED_GRAVITY: + g = cfg.observations["actor"].terms[gravity_term_name] + g.func = microduck_mdp.projected_gravity_imu_misaligned + g.params = {"max_angle_deg": IMU_ORIENTATION_RANDOMIZATION_ANGLE} + + # 1-ctrl-step lag on joint_vel: the Dynamixel firmware computes + # present_velocity via a moving-average over the previous position-sample + # window, so the value the policy actually reads is ~1 control period old. + # Matches reality and stops the policy relying on instantaneous qdot feedback. + cfg.observations["actor"].terms["joint_vel"] = deepcopy( + cfg.observations["actor"].terms["joint_vel"] + ) + cfg.observations["actor"].terms["joint_vel"].delay_min_lag = 1 + cfg.observations["actor"].terms["joint_vel"].delay_max_lag = 1 + cfg.observations["actor"].terms["joint_vel"].delay_update_period = 0 + + # Exclude passive_* joints (jaw linkage) from joint_pos/vel obs so the + # observation dim matches the action dim (14) instead of the raw articulation (16). + # Deepcopy each joint_pos/joint_vel term first — actor and critic share the + # same term objects/params dicts from the base template, so mutating one would + # leak into the other (e.g. the encoder-bias `biased` flag below). + passive_excluded = SceneEntityCfg("robot", joint_names=(r"^(?!passive_).*",)) + for grp in ("actor", "critic"): + for term in ("joint_pos", "joint_vel"): + cfg.observations[grp].terms[term] = deepcopy(cfg.observations[grp].terms[term]) + cfg.observations[grp].terms[term].params["asset_cfg"] = deepcopy(passive_excluded) + + # Encoder-bias DR: the base template samples a per-env constant joint-encoder + # offset (startup event "encoder_bias"), but joint_pos_rel ignores it unless + # biased=True. Feed the biased joint pos to the ACTOR only (what the real + # encoders report); the critic keeps the true joint pos (privileged). + if ENABLE_ENCODER_BIAS: + cfg.events["encoder_bias"].params["bias_range"] = ENCODER_BIAS_RANGE + cfg.observations["actor"].terms["joint_pos"].params["biased"] = True + cfg.observations["critic"].terms["joint_pos"].params["biased"] = False + else: + cfg.events.pop("encoder_bias", None) + + # Commands — deepcopy to avoid shared-state corruption from other env cfgs + # (make_velocity_env_cfg() returns objects with shared mutable references; + # standup/ground_pick envs mutate commands["twist"] in place, zeroing ranges) + command: UniformVelocityCommandCfg = deepcopy(cfg.commands["twist"]) + cfg.commands["twist"] = command + command.rel_standing_envs = 0.02 # small but non-zero from the start, ramped up by curriculum + command.rel_heading_envs = 0.0 + # Modest, FIXED command ranges (no widening curriculum): a ramp to + # lin ±0.4 / ang ±2.0 outpaced the robot's capability and tracked a + # post-iter-1000 reward/episode-length decline. ang ±1.0 is the big + # change — it makes turning learnable. + command.ranges.lin_vel_x = (-0.4, 0.4) + command.ranges.lin_vel_y = (-0.3, 0.3) + command.ranges.ang_vel_z = (-1.0, 1.0) + command.viz.z_offset = 0.5 + cfg.commands["twist"] = microduck_mdp.VelocityCommandCommandOnlyCfg(**vars(command)) + # Explicit turn-in-place bucket (see TURN_IN_PLACE_FRACTION above). + cfg.commands["twist"].rel_turn_in_place_envs = TURN_IN_PLACE_FRACTION + + # Head pose command (4D deltas from HOME, in joint order: + # neck_pitch, head_pitch, head_yaw, head_roll). Tracked as a primary + # reward — see "head_pose_tracking" added below. Initial ranges are small + # non-zero so input neurons stay alive from step 0; curriculum widens them. + # Per-joint final caps reflect each joint's mechanically reachable delta + # from HOME (XML limits minus HOME offset, with ~10% safety margin): + # neck_pitch / head_pitch: ±1.10 rad (limit ±π/2 with HOME=±20°) + # head_yaw : ±1.40 rad (limit ±π/2 with HOME=0) + # head_roll : ±0.31 rad (limit ±20°) + # Initial ranges are small non-zero so input neurons stay alive from step 0. + cfg.commands["head_pose"] = microduck_mdp.UniformPoseCommandCfg( + resampling_time_range=HEAD_POSE_CMD_RESAMPLE_S, + ranges=( + (-0.05, 0.05), # neck_pitch + (-0.05, 0.05), # head_pitch + (-0.07, 0.07), # head_yaw + (-0.015, 0.015), # head_roll (tighter — much smaller mechanical range) + ), + ) + # Body pose command (6D delta from nominal standing: [x, y, z, roll, pitch, yaw]). + # Vel env carries this slot for runtime obs-shape parity; tracked at a tiny + # weight to keep the input neurons alive but not steer the policy. The + # standup env raises the weight + widens the ranges. + cfg.commands["body_pose"] = microduck_mdp.UniformPoseCommandCfg( + resampling_time_range=BODY_POSE_CMD_RESAMPLE_S, + ranges=( + (-0.005, 0.005), # x (m) + (-0.005, 0.005), # y (m) + (-0.005, 0.005), # z (m) + (-0.05, 0.05), # roll (rad) + (-0.05, 0.05), # pitch (rad) + (-0.05, 0.05), # yaw (rad) + ), + ) + + # Append head + body command obs terms to both policy and critic groups. + # Order matters for the runtime obs layout: [twist(3), head_pose(4), body_pose(6)]. + for group in ("actor", "critic"): + cfg.observations[group].terms["head_command"] = ObservationTermCfg( + func=mdp.generated_commands, + params={"command_name": "head_pose"}, + ) + cfg.observations[group].terms["body_command"] = ObservationTermCfg( + func=mdp.generated_commands, + params={"command_name": "body_pose"}, + ) + + # === Pose tracking rewards === + # head_pose: primary objective in vel env — the whole point of the rewrite. + # std=0.5 with per-joint Gaussian (see head_pose_tracking in mdp.py): at the + # full ±1.0 rad command, a non-tracking policy still sees per-joint reward + # exp(-(1/0.5)²)=exp(-4)≈0.018 — a small but non-zero gradient — so the + # curriculum widening doesn't kill the signal. Final reward is the mean + # over 4 joints, so partial tracking is partial reward (no all-or-nothing). + cfg.rewards["head_pose_tracking"] = RewardTermCfg( + func=microduck_mdp.head_pose_tracking, + weight=2.0, + params={"command_name": "head_pose", "std": 0.5}, + ) + # body_pose: infra kept intact but DISABLED (weight 0) — the obs slot and + # command stay alive for envs that raise the weight (standup). + cfg.rewards["body_pose_tracking"] = RewardTermCfg( + func=microduck_mdp.body_pose_tracking_6d, + weight=0.0, + params={ + "command_name": "body_pose", + "nominal_height": 0.095, + "xy_std": 0.05, + "z_std": 0.02, + "angle_std": math.radians(15), + }, + ) + + # Head droop fix (2026-08-20). The head walks pitched ~15° down (measured: + # run ww1g2198 head_pose_tracking 1.544/2.0 → 14.6° mean joint error). + # DO NOT fix this by tightening head_pose_tracking's std: run 5yay13u4 tried + # fine_std=0.1 and the policy stopped walking entirely by iter 300 (air_time + # 1.01 → 0.02, peak foot height 15 mm → 2 mm, entropy collapsed 10.9 → 1.9). + # An instantaneous tight tolerance taxes walking 0.77/step — 76% of the whole + # air_time reward — and is UNESCAPABLE, since a 280 g head (38% of robot + # mass) must oscillate while stepping. Standing still scored higher, so it + # stood still. + # The DC bias, unlike the oscillation, IS escapable (bias the neck command up + # to cancel gravity sag), so price only that: L1 on a 1 s EMA of the error. + # At the optimum this costs a walking policy nothing. + cfg.rewards["head_pose_bias"] = RewardTermCfg( + func=microduck_mdp.head_pose_bias_penalty, + weight=0.0, # ramped by the head_pose_bias_weight curriculum below + params={"command_name": "head_pose", "tau_s": 1.0}, + ) + + # Terrain + if not rough: + cfg.scene.terrain.terrain_type = "plane" + cfg.scene.terrain.terrain_generator = None + else: + cfg.scene.terrain.terrain_type = "generator" + cfg.scene.terrain.terrain_generator = MICRODUCK_ROUGH_TERRAINS_CFG + + # Soften terrain box contacts: adjacent boxes at different heights create + # hard edges that destabilise the contact solver and produce NaN forces. + cfg.scene.spec_fn = _soften_terrain_contacts + + # The velocity env default nconmax=35 is tight for rough terrain: when the + # robot falls and multiple body links hit multiple boxes simultaneously, + # contacts overflow → some are silently dropped → sudden decompression → NaN. + cfg.sim.nconmax = 200 # was 35 + + # The velocity env uses only 10 solver iterations (vs the default 100), + # which is too few to resolve edge contacts on rough box terrain. + # Tripling iterations significantly reduces contact resolution failures + # with a modest compute cost on GPU (MJWarp parallelises across envs). + cfg.sim.mujoco.iterations = 30 # was 10 + cfg.sim.mujoco.ls_iterations = 50 # was 20 + + if play: + cfg.scene.terrain.terrain_generator.curriculum = False + cfg.scene.terrain.terrain_generator.num_cols = 5 + cfg.scene.terrain.terrain_generator.num_rows = 5 + + # action_rate weight ramp: gentle smoothing while the gait bootstraps, then + # tighten to -1.0 by iter 1500. + cfg.curriculum["action_rate_weight"] = CurriculumTermCfg( + func=microduck_mdp.reward_weight, + params={ + "reward_name": "action_rate_l2", + "weight_stages": [ + {"step": 0, "weight": -0.1}, + {"step": 500 * NUM_STEPS_PER_ENV, "weight": -0.2}, + {"step": 750 * NUM_STEPS_PER_ENV, "weight": -0.4}, + {"step": 1000 * NUM_STEPS_PER_ENV, "weight": -0.6}, + {"step": 1250 * NUM_STEPS_PER_ENV, "weight": -0.8}, + {"step": 1500 * NUM_STEPS_PER_ENV, "weight": -1.0}, + ], + }, + ) + + # Gradually increase standing env fraction after walking is established + cfg.curriculum["standing_envs"] = CurriculumTermCfg( + func=microduck_mdp.standing_envs_curriculum, + params={ + "command_name": "twist", + "standing_stages": [ + {"step": 0, "rel_standing_envs": 0.02}, + {"step": 500 * 24, "rel_standing_envs": 0.05}, + {"step": 750 * 24, "rel_standing_envs": 0.1}, + {"step": 1000 * 24, "rel_standing_envs": 0.15}, + {"step": 1500 * 24, "rel_standing_envs": 0.2}, + {"step": 2000 * 24, "rel_standing_envs": 0.25}, + ], + }, + ) + + # NOTE: no velocity-command-range curriculum — ranges are fixed (see the + # command section above). + + # Head pose command range curriculum — per-joint, scaled to each joint's + # reachable delta from HOME (with ~10% margin from XML limits). Same 5-stage + # shape as before (5% → 15% → 35% → 65% → 100% of each joint's final cap). + # neck/head pitch final ±1.10 rad, head_yaw ±1.40, head_roll ±0.31. + cfg.curriculum["head_pose_range"] = CurriculumTermCfg( + func=microduck_mdp.pose_command_range_curriculum, + params={ + "command_name": "head_pose", + "range_stages": [ + # step, ranges = ((neck_pitch), (head_pitch), (head_yaw), (head_roll)) + {"step": 0, "ranges": ((-0.05, 0.05), (-0.05, 0.05), (-0.07, 0.07), (-0.015, 0.015))}, + {"step": 500 * 24, "ranges": ((-0.17, 0.17), (-0.17, 0.17), (-0.21, 0.21), (-0.047, 0.047))}, + {"step": 1000 * 24, "ranges": ((-0.39, 0.39), (-0.39, 0.39), (-0.49, 0.49), (-0.11, 0.11))}, + {"step": 1500 * 24, "ranges": ((-0.72, 0.72), (-0.72, 0.72), (-0.91, 0.91), (-0.20, 0.20))}, + {"step": 2000 * 24, "ranges": ((-1.10, 1.10), (-1.10, 1.10), (-1.40, 1.40), (-0.31, 0.31))}, + ], + }, + ) + + # Body pose command range curriculum: stay small in vel env. Standup env + # overrides this curriculum with wide ranges + heavy reward weight. + cfg.curriculum["body_pose_range"] = CurriculumTermCfg( + func=microduck_mdp.pose_command_range_curriculum, + params={ + "command_name": "body_pose", + "range_stages": [ + {"step": 0, "ranges": ( + (-0.005, 0.005), # x (m) + (-0.005, 0.005), # y (m) + (-0.005, 0.005), # z (m) + (-0.05, 0.05), # roll + (-0.05, 0.05), # pitch + (-0.05, 0.05), # yaw + )}, + ], + }, + ) + + # CoM randomization range curriculum - start small, ramp up + if ENABLE_COM_RANDOMIZATION: + cfg.curriculum["com_range"] = CurriculumTermCfg( + func=microduck_mdp.com_range_curriculum, + params={ + "event_name": "randomize_com", + "range_stages": [ + # Capped at ±15 mm (2026-07 audit): the previous ramp to ±30 mm + # exceeded the foot support polygon (heel is only 20 mm behind + # the ankle) — the randomized CoM could sit entirely outside + # support, forcing a wide/fast hyper-reactive gait and making + # BACKWARD balance untrainable. Regression timeline matched the + # ramp increases: 0.015 → 0.02 → 0.03 as policies got worse. + {"step": 0, "range": 0.003}, + {"step": 500 * 24, "range": 0.005}, + {"step": 1000 * 24, "range": 0.01}, + {"step": 1500 * 24, "range": 0.015}, + ], + }, + ) + + # Head CoM randomization range curriculum - start small, ramp up + if ENABLE_HEAD_COM_RANDOMIZATION: + cfg.curriculum["head_com_range"] = CurriculumTermCfg( + func=microduck_mdp.com_range_curriculum, + params={ + "event_name": "randomize_head_com", + "range_stages": [ + # Capped at ±10 mm (2026-07 audit — same over-conservatism + # concern as trunk CoM; head is a large lever arm). + {"step": 0, "range": 0.003}, + {"step": 500 * 24, "range": 0.005}, + {"step": 1000 * 24, "range": 0.01}, + ], + }, + ) + + # Disable default curriculum + if not rough: + del cfg.curriculum["terrain_levels"] + del cfg.curriculum["command_vel"] + + # head_pose_bias ramp: OFF until iter 600, then 1.0 → 3.0 by iter 1500. + # Held at 0 early because a posture-precision term is a distraction before + # a gait exists. At weight 3.0 a 15° residual bias costs 0.79/step and a + # 2° bias costs 0.10/step. + cfg.curriculum["head_pose_bias_weight"] = CurriculumTermCfg( + func=microduck_mdp.reward_weight, + params={ + "reward_name": "head_pose_bias", + "weight_stages": [ + {"step": 0, "weight": 0.0}, + {"step": 600 * NUM_STEPS_PER_ENV, "weight": 1.0}, + {"step": 1000 * NUM_STEPS_PER_ENV, "weight": 2.0}, + {"step": 1500 * NUM_STEPS_PER_ENV, "weight": 3.0}, + ], + }, + ) + + return cfg + + +MicroduckRlCfg = RslRlOnPolicyRunnerCfg( + actor=RslRlModelCfg( + hidden_dims=(512, 256, 128), + activation="elu", + obs_normalization=True, + distribution_cfg={ + "class_name": "GaussianDistribution", + "init_std": 1.0, + "std_type": "scalar", + }, + ), + critic=RslRlModelCfg( + hidden_dims=(512, 256, 128), + activation="elu", + obs_normalization=True, + ), + algorithm=PpoWithSymmetryCfg( + value_loss_coef=1.0, + use_clipped_value_loss=True, + clip_param=0.2, + entropy_coef=0.01, + num_learning_epochs=5, + num_mini_batches=4, + learning_rate=1.0e-3, + schedule="adaptive", + gamma=0.99, + lam=0.95, + desired_kl=0.01, + max_grad_norm=1.0, + symmetry_cfg=SYMMETRY_CFG if ENABLE_SYMMETRY else None, + ), + wandb_project="mjlab_microduck", + experiment_name="velocity", # Directory name + run_name="velocity", # Appended to datetime in wandb: _velocity + save_interval=250, + num_steps_per_env=24, + max_iterations=50_000, +) diff --git a/src/mjlab_microduck/tasks/microduck_velocity_rollers_env_cfg.py b/src/mjlab_microduck/tasks/microduck_velocity_rollers_env_cfg.py new file mode 100644 index 0000000..6967db9 --- /dev/null +++ b/src/mjlab_microduck/tasks/microduck_velocity_rollers_env_cfg.py @@ -0,0 +1,669 @@ +"""Microduck velocity environment — roller skate variant. + +MIGRATED to mjlab 1.3.0 + canonical BAM (2026-07), matching the velocity env's +sim2real machinery, and updated for the NEW roller model: + + - `get_walk_rollers_spec` now loads `robot_allcollisions_rollers.xml` + (it silently loaded the wheel-less standup model before): 14 actuated + joints + 4 passive wheels (passive_{L,R}{F,R}wheel), two per blade, + INTERSPERSED in the joint order (after each ankle) — everything resolves + joints by NAME, never by index. + - Legs run the canonical BAM actuator like every other variant (was a plain + XML PD — an actuator-physics mismatch, and no joint-friction DR). + - Obs migrated to the unified 61D layout (twist + zero-padded head/body + command slots) so roller policies load through the runtime's + --new-cmd-obs path. Symmetry OFF (SYMMETRY_CFG is hardcoded for the old + 51D layout). + - DR/noise/delays matched to the velocity env's FIXED (non-accumulating, + per-env-verified) versions; wheel-bearing frictionloss DR kept + (dr.dof_frictionloss on the passive wheels + existing curriculum). + +Task design (unchanged — the roller recipe): + cmd_x semantics: 0 = coast, >0 = push to accelerate, <0 = brake. + cmd[2] = heading error via RelativeHeadingVelocityCommand. + Sole positive task reward is wheel_speed — the robot must actually spin its + wheels; braking/skating_air_time/forward_lean/heading_tracking shape the + skating style. +""" + +import math +from copy import deepcopy + +# Symmetry — OFF: SYMMETRY_CFG's obs permutation is hardcoded for the old 51D +# layout and breaks on the 61D obs (same situation as all other v1.5+ envs). +ENABLE_SYMMETRY = False + +# ── Domain randomisation toggles (matched to the velocity env) ──────────────── +ENABLE_COM_RANDOMIZATION = True +ENABLE_HEAD_COM_RANDOMIZATION = True +ENABLE_MASS_INERTIA_RANDOMIZATION = True +ENABLE_JOINT_FRICTION_RANDOMIZATION = True # BAM friction budget per-env (legs) +ENABLE_ARMATURE_RANDOMIZATION = True # legs only — NOT the wheel bearings +ENABLE_WHEEL_FRICTION_RANDOMIZATION = True # bearing frictionloss on passive wheels +ENABLE_VELOCITY_PUSHES = True +ENABLE_IMU_ORIENTATION_RANDOMIZATION = True # obs-level per-env rotation +ENABLE_ENCODER_BIAS = True + +# ── Ranges (matched to the velocity env unless roller-specific) ─────────────── +COM_RANDOMIZATION_RANGE = 0.003 # ±3mm initial, ramped via curriculum +HEAD_COM_RANDOMIZATION_RANGE = 0.003 +MASS_INERTIA_RANDOMIZATION_RANGE = (0.95, 1.05) +JOINT_FRICTION_RANDOMIZATION_RANGE = (0.9, 1.1) +ARMATURE_RANDOMIZATION_RANGE = (0.9, 1.1) +VELOCITY_PUSH_INTERVAL_S = (3.0, 6.0) +VELOCITY_PUSH_RANGE = (-0.2, 0.2) # roller-specific: gentler than walk ±0.3 +IMU_ORIENTATION_RANDOMIZATION_ANGLE = 6.0 +ENCODER_BIAS_RANGE = (-0.015, 0.015) + +from mjlab.envs import ManagerBasedRlEnvCfg +from mjlab.envs.mdp import dr +from mjlab.envs.mdp.actions import JointPositionActionCfg +from mjlab.managers import ( + CurriculumTermCfg, + EventTermCfg, + ObservationTermCfg, + RewardTermCfg, + TerminationTermCfg, +) +from mjlab.managers.scene_entity_config import SceneEntityCfg +from mjlab.rl import ( + RslRlOnPolicyRunnerCfg, + RslRlModelCfg, +) +from mjlab.sensor import ContactMatch, ContactSensorCfg +from mjlab.tasks.velocity import mdp +from mjlab.tasks.velocity.mdp import UniformVelocityCommandCfg +from mjlab.tasks.velocity.velocity_env_cfg import make_velocity_env_cfg +from mjlab.utils.noise import UniformNoiseCfg as Unoise + +from mjlab_microduck.robot.microduck_constants import MICRODUCK_WALK_ROLLERS_ROBOT_CFG +from mjlab_microduck.tasks import mdp as microduck_mdp +from mjlab_microduck.tasks.microduck_velocity_env_cfg import HEAD_BODY_NAMES +from mjlab_microduck.tasks.symmetry import PpoWithSymmetryCfg, SYMMETRY_CFG + + +def make_microduck_velocity_rollers_env_cfg( + play: bool = False, +) -> ManagerBasedRlEnvCfg: + """Create Microduck roller skate velocity tracking environment configuration.""" + + # passive_.*: 999.0 → passive wheel joints are matched but effectively ignored + std_standing = { + r".*hip_yaw.*": 0.05, + r".*hip_roll.*": 0.05, + r".*hip_pitch.*": 0.05, + r".*knee.*": 0.05, + r".*ankle.*": 0.05, + r".*neck.*": 0.05, + r".*head.*": 0.05, + r".*passive_.*": 999.0, + } + + std_walking = { + r".*hip_yaw.*": 0.3, + r".*hip_roll.*": 0.6, # loosened: skating requires wide lateral push + r".*hip_pitch.*": 0.4, + r".*knee.*": 0.4, + r".*ankle.*": 0.25, + r".*neck.*": 0.05, + r".*head.*": 0.05, + r".*passive_.*": 999.0, + } + + std_running = { + r".*hip_yaw.*": 0.5, + r".*hip_roll.*": 0.8, # loosened: skating requires wide lateral push + r".*hip_pitch.*": 0.8, + r".*knee.*": 0.8, + r".*ankle.*": 0.5, + r".*neck.*": 0.05, + r".*head.*": 0.05, + r".*passive_.*": 999.0, + } + + # 2026-07 model: the roller_blade bodies were merged into the ankles (blade + # mesh is now a visual geom on ankle_{l,r}_v1); the tires hang directly off + # the ankles. Each ankle subtree's only collision geoms are its two tires, + # so this keeps the old per-foot semantics: 2 slots, left first. + feet_ground_cfg = ContactSensorCfg( + name="feet_ground_contact", + primary=ContactMatch( + mode="subtree", + pattern=r"^(ankle_l_v1|ankle_r_v1)$", + entity="robot", + ), + secondary=ContactMatch(mode="body", pattern="terrain"), + fields=("found", "force"), + reduce="netforce", + num_slots=1, + track_air_time=True, + ) + + self_collision_cfg = ContactSensorCfg( + name="self_collision", + primary=ContactMatch(mode="subtree", pattern="trunk_base", entity="robot"), + secondary=ContactMatch(mode="subtree", pattern="trunk_base", entity="robot"), + fields=("found",), + reduce="none", + num_slots=1, + ) + + cfg = make_velocity_env_cfg() + + # Robot setup + cfg.scene.entities = {"robot": MICRODUCK_WALK_ROLLERS_ROBOT_CFG} + cfg.scene.sensors = (feet_ground_cfg, self_collision_cfg) + cfg.viewer.body_name = "trunk_base" + + # Action configuration + joint_pos_action = cfg.actions["joint_pos"] + assert isinstance(joint_pos_action, JointPositionActionCfg) + joint_pos_action.scale = 1.0 + # NOTE: an env-side action clip was tried here to bound the target, but the + # deployment pipeline (infer_policy.py) does NOT clip → the clip would only + # exist in sim, a train/deploy mismatch. The over-command deterrent lives + # policy-side instead (action_over_limit reward below), baked into the network + # so it transfers with the ONNX. + + # === REWARDS === + keep = {"pose", "upright", "body_ang_vel", "angular_momentum", "action_rate_l2"} + for name in list(cfg.rewards.keys()): + if name not in keep: + del cfg.rewards[name] + + cfg.rewards["pose"].params["std_standing"] = std_standing + cfg.rewards["pose"].params["std_walking"] = std_walking + cfg.rewards["pose"].params["std_running"] = std_running + cfg.rewards["pose"].params["walking_threshold"] = 0.01 + cfg.rewards["pose"].params["running_threshold"] = 0.5 + cfg.rewards["pose"].weight = 2.0 + + cfg.rewards["upright"].params["asset_cfg"].body_names = ("trunk_base",) + cfg.rewards["upright"].weight = 2.0 + + cfg.rewards["body_ang_vel"].params["asset_cfg"].body_names = ("trunk_base",) + cfg.rewards["body_ang_vel"].weight = -0.05 + cfg.rewards["angular_momentum"].weight = -0.02 + cfg.rewards["action_rate_l2"].weight = -1.0 + + cfg.rewards["com_height_target"] = RewardTermCfg( + func=microduck_mdp.com_height_target, + weight=2.0, + params={"target_height_min": 0.0935, "target_height_max": 0.1235}, + ) + cfg.rewards["self_collisions"] = RewardTermCfg( + func=mdp.self_collision_cost, + weight=-1.0, + params={"sensor_name": "self_collision"}, + ) + # Gated to the STANCE foot only (sensor_name) so lifting the swing foot is no + # longer punished — the old ungated -5.0 was minimised by keeping both blades + # flat on the ground (the swizzle) and actively fought the stride. Weight also + # softened -5.0 -> -2.0 to leave room for a slightly angled push. + cfg.rewards["feet_flat"] = RewardTermCfg( + func=microduck_mdp.feet_flat_penalty, + weight=-2.0, + params={ + "asset_cfg": SceneEntityCfg("robot", site_names=("left_foot", "right_foot")), + "sensor_name": "feet_ground_contact", + }, + ) + cfg.rewards["neck_action_rate_l2"] = RewardTermCfg( + func=microduck_mdp.neck_action_rate_l2, weight=-0.5 + ) + cfg.rewards["neck_joint_pos_l2"] = RewardTermCfg( + func=microduck_mdp.neck_joint_pos_l2, weight=-0.5 + ) + cfg.rewards["joint_torques_l2"] = RewardTermCfg( + func=microduck_mdp.joint_torques_l2, weight=-1e-3 + ) + # Deter OVER-COMMANDING a joint past its hard stop (policy-side, transfers via + # the ONNX). hip_roll's ±0.38 rad limit vs the ±10 rad ctrlrange let the low-kp + # servo be commanded far past the stop and slam it with max torque — a fragile + # sim-only trick. This penalises only the COMMAND beyond (limit + 0.3 overshoot), + # so the joint keeps its full reachable range (a qpos penalty stole that range + # and broke the gait) while the wild over-drive is discouraged. + cfg.rewards["action_over_limit"] = RewardTermCfg( + func=microduck_mdp.action_over_limit_penalty, + weight=-0.5, + params={"action_name": "joint_pos", "overshoot": 0.3}, + ) + # Pull hip_roll back toward neutral so the stance stops resting splayed on the + # hip_roll limits. L1 = constant gradient: it gently closes the legs AT REST, + # but the strong stride rewards (wheel_speed, single_support, air_time) easily + # overpower it during an active push → closes the posture WITHOUT preventing + # the lateral push stroke. Tune: raise if still splayed, lower if it flattens + # the stride. (Physics caveat: if the soft hip_roll servo can't hold a narrow + # stance under body weight, the policy will bend knees / lower CoM to unload + # it — or, if no stable narrow stance exists, it stays partly splayed.) + cfg.rewards["hip_roll_neutral"] = RewardTermCfg( + func=microduck_mdp.joint_deviation_l1, + weight=-2.0, # -1.0 -> -2.0: stronger centring pull. Sim already keeps hip_roll + # narrow, but a stronger corrective may help the REAL robot resist + # whatever spreads the legs (deployment/disturbance). Lower if it + # flattens the push. + params={"asset_cfg": SceneEntityCfg("robot", joint_names=(r".*hip_roll.*",))}, + ) + # Sole positive task reward — robot must spin wheels to get anything + # vel_scale 0.5 -> 0.3: the tanh target speed. Measured on a trained ckpt, the + # policy only reaches ~0.33 m/s at max push, so a 0.5 target sat on the + # un-saturated tanh slope and kept pushing it to go faster than it can (over- + # reach -> launch instability). 0.3 saturates near the achievable speed, so it + # is 'content' there instead of over-driving. + cfg.rewards["wheel_speed"] = RewardTermCfg( + func=microduck_mdp.wheel_speed_reward, + weight=10.0, + params={"command_name": "twist", "vel_scale": 0.3}, + ) + # Brake: reward stopping when cmd_x < 0. Silent at cmd_x >= 0 (coast/push). + cfg.rewards["braking"] = RewardTermCfg( + func=microduck_mdp.braking_reward, + weight=1.0, + params={"command_name": "twist", "vel_std": 0.3}, + ) + # Air time during push: pay the recovery-foot lift, but ONLY when the body is + # actually moving forward (vel_gate_ref) — otherwise a fast in-place flutter + # farmed this. threshold_min raised 0.15 → 0.25 to forbid ultra-short swings + # (caps the frantic kick cadence); glide below rewards the slow phase. + # air_time rewards each swing → drives swing FREQUENCY; glide rewards staying + # on one blade → drives commitment. Balance tilted toward glide (3.0) over + # air_time (2.0) because the cadence was still too fast. air_time kept high + # enough (2.0) that lifting the foot stays worthwhile. + # Calm gait: the aggressive [0.40, 1.00] window forced big long swings -> + # violent kicks that tipped the real robot. Back to a gentle [0.15, 0.45] + # (small swings allowed, none forced long) and weight 2.0 -> 1.5 so swinging + # is less incentivised (lower cadence). glide (below) rewards the coast, so it + # pushes only occasionally. + cfg.rewards["skating_air_time"] = RewardTermCfg( + func=microduck_mdp.skating_air_time_reward, + weight=1.5, + params={ + "sensor_name": "feet_ground_contact", + "command_name": "twist", + "threshold_min": 0.15, + "threshold_max": 0.45, + "vel_gate_ref": 0.2, + }, + ) + # Glide phase (single-support REQUIRED, unlike the earlier broken attempt): + # reward coasting on one blade with quiet legs so the policy commits to each + # stroke instead of kicking frantically. Weight raised 1.5 → 3.0 to actually + # out-weigh the swing-frequency pull of air_time. + cfg.rewards["glide"] = RewardTermCfg( + func=microduck_mdp.glide_reward, + weight=4.0, + params={ + "sensor_name": "feet_ground_contact", + "command_name": "twist", + "vel_ref": 0.2, + }, + ) + # NOTE: a recover_pose reward (reward default leg pose + quiet + coasting during + # the pause) was tried to get "stroke -> recover-to-neutral -> stroke", but + # rewarding the SYMMETRIC default posture + dropping single_support's double + # penalty re-opened the symmetric swizzle -> reverted. A proper retry must be + # PHASE-GATED (reward the neutral only briefly right after a stroke, not + # continuously) and keep the double-support penalty. + # Single-support stride vs double-support swizzle. Rewards exactly-one-blade- + # down and penalises both-down while pushing — the core anti-swizzle signal. + # Gated on forward speed too, so stepping that doesn't propel earns nothing. + cfg.rewards["single_support"] = RewardTermCfg( + func=microduck_mdp.single_support_reward, + weight=3.0, + params={ + "sensor_name": "feet_ground_contact", + "command_name": "twist", + "vel_gate_ref": 0.2, + }, + ) + # Balance left/right leg usage. With symmetry augmentation OFF nothing stops a + # lopsided stride (pushing mostly with one leg) that veers and destabilises, + # esp. at launch. Penalises the cumulative swing-time imbalance |L-R|/(L+R); + # the instantaneous one-foot-swinging asymmetry of a real stride is fine. + cfg.rewards["gait_symmetry"] = RewardTermCfg( + func=microduck_mdp.gait_symmetry_penalty, + weight=-1.0, + params={"sensor_name": "feet_ground_contact"}, + ) + # NOTE: a contact_frequency penalty was tried here to slow the cadence, but it + # penalises contact CHANGES — minimised by never lifting a foot (the swizzle), + # so it pushes toward exactly the gait we fought to leave. Reverted; the + # widened air-time window above is the safe cadence-slower (it forbids short + # swings without rewarding not-stepping). + # Encourage slight forward lean when pushing to counteract backward torque. + cfg.rewards["forward_lean"] = RewardTermCfg( + func=microduck_mdp.forward_lean_reward, + weight=1.5, + params={"command_name": "twist", "target_pitch": 0.262, "std": 0.1}, + ) + # Heading command DISABLED (straight-line focus), but we hold the heading so it + # doesn't drift: heading_hold rewards the yaw ANGLE staying near the spawn + # heading. Corrective (allows yaw to steer back) — unlike a yaw-RATE penalty, + # which froze the yaw and made drift WORSE (tried and reverted). Re-add real + # heading_tracking (turning) once the stride is solid. + cfg.rewards["heading_hold"] = RewardTermCfg( + func=microduck_mdp.heading_hold_reward, + weight=1.0, + params={"std": 0.4, "asset_cfg": SceneEntityCfg("robot")}, + ) + + # === TERMINATIONS === + cfg.terminations["nan_state"] = TerminationTermCfg( + func=microduck_mdp.robot_state_is_nan, + time_out=False, + ) + + # === EVENTS === + # BAM (mjlab_frictionloss branch) writes per-env dof_frictionloss/dof_damping + # every step; this no-op event registers those fields for per-world expansion. + cfg.events["expand_bam_friction_fields"] = EventTermCfg( + func=microduck_mdp.expand_bam_friction_fields, + mode="startup", + ) + + cfg.events["reset_action_history"] = EventTermCfg( + func=microduck_mdp.reset_action_history, + mode="reset", + ) + + del cfg.events["foot_friction"] # wheels roll; ground friction lives in the XML + + if ENABLE_VELOCITY_PUSHES: + cfg.events["push_robot"] = EventTermCfg( + func=mdp.push_by_setting_velocity, + mode="interval", + interval_range_s=VELOCITY_PUSH_INTERVAL_S, + params={ + "velocity_range": {"x": VELOCITY_PUSH_RANGE, "y": VELOCITY_PUSH_RANGE}, + "asset_cfg": SceneEntityCfg("robot"), + }, + ) + + cfg.events["reset_base"].params["pose_range"]["z"] = (0.1335, 0.1435) + + # Wheel-bearing friction DR: real bearings have a little drag; the XML keeps + # frictionloss=0 for trainability and the curriculum ramps it in. mjlab 1.3.0 + # stock dr op (operation="abs" writes the value directly; non-accumulating). + if ENABLE_WHEEL_FRICTION_RANDOMIZATION: + cfg.events["randomize_wheel_friction"] = EventTermCfg( + func=dr.dof_frictionloss, + mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot", joint_names=(r"^passive_.*wheel",)), + "operation": "abs", + "ranges": (0.000, 0.000), # ramped up by wheel_friction_curriculum + }, + ) + + # ── DR matched to the velocity env's FIXED versions ─────────────────────── + if ENABLE_COM_RANDOMIZATION: + cfg.events["randomize_com"] = EventTermCfg( + func=dr.body_ipos, + mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",)), + "operation": "add", + "ranges": (-COM_RANDOMIZATION_RANGE, COM_RANDOMIZATION_RANGE), + }, + ) + + if ENABLE_HEAD_COM_RANDOMIZATION: + cfg.events["randomize_head_com"] = EventTermCfg( + func=dr.body_ipos, + mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot", body_names=HEAD_BODY_NAMES), + "operation": "add", + "ranges": (-HEAD_COM_RANDOMIZATION_RANGE, HEAD_COM_RANDOMIZATION_RANGE), + }, + ) + + if ENABLE_MASS_INERTIA_RANDOMIZATION: + _mi_lo, _mi_hi = MASS_INERTIA_RANDOMIZATION_RANGE + cfg.events["randomize_mass_inertia"] = EventTermCfg( + func=dr.pseudo_inertia, + mode="startup", + params={ + "asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",)), + "alpha_range": (math.log(_mi_lo) / 2.0, math.log(_mi_hi) / 2.0), + }, + ) + + if ENABLE_JOINT_FRICTION_RANDOMIZATION: + cfg.events["randomize_joint_friction"] = EventTermCfg( + func=microduck_mdp.randomize_bam_friction, + mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot"), + "scale_range": JOINT_FRICTION_RANDOMIZATION_RANGE, + }, + ) + + if ENABLE_ARMATURE_RANDOMIZATION: + # Legs/head only — the wheel bearings' tiny armature is excluded (its DR + # is the frictionloss event above). + cfg.events["randomize_armature"] = EventTermCfg( + func=dr.joint_armature, + mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot", joint_names=(r"^(?!passive_).*",)), + "operation": "scale", + "ranges": ARMATURE_RANDOMIZATION_RANGE, + }, + ) + + # === OBSERVATIONS (unified 61D layout) === + del cfg.observations["actor"].terms["base_lin_vel"] + # 1.3.0 base template adds sensor-based foot_height + height_scan; the roller + # env has no terrain-height sensor. + del cfg.observations["critic"].terms["foot_height"] + del cfg.observations["actor"].terms["height_scan"] + del cfg.observations["critic"].terms["height_scan"] + + cfg.observations["critic"].terms["base_lin_vel"] = ObservationTermCfg( + func=mdp.base_lin_vel, + scale=1.0, + ) + + gravity_term_name = "projected_gravity" + cfg.observations["actor"].terms[gravity_term_name] = deepcopy( + cfg.observations["actor"].terms[gravity_term_name] + ) + cfg.observations["actor"].terms["base_ang_vel"] = deepcopy( + cfg.observations["actor"].terms["base_ang_vel"] + ) + # IMU delay 0-1 control steps (matches velocity: the real dxl IMU path is fast) + cfg.observations["actor"].terms["base_ang_vel"].delay_min_lag = 0 + cfg.observations["actor"].terms["base_ang_vel"].delay_max_lag = 1 + cfg.observations["actor"].terms["base_ang_vel"].delay_update_period = 64 + cfg.observations["actor"].terms[gravity_term_name].delay_min_lag = 0 + cfg.observations["actor"].terms[gravity_term_name].delay_max_lag = 1 + cfg.observations["actor"].terms[gravity_term_name].delay_update_period = 64 + + # Observation noise — matched to the velocity env + cfg.observations["actor"].terms["base_ang_vel"].noise = Unoise(n_min=-0.03, n_max=0.03) + cfg.observations["actor"].terms[gravity_term_name].noise = Unoise(n_min=-0.01, n_max=0.01) + cfg.observations["actor"].terms["joint_pos"].noise = Unoise(n_min=-0.001, n_max=0.001) + cfg.observations["actor"].terms["joint_vel"].noise = Unoise(n_min=-0.25, n_max=0.25) + + # IMU mounting-misalignment DR (obs-level, actor only — matches velocity) + if ENABLE_IMU_ORIENTATION_RANDOMIZATION: + av = cfg.observations["actor"].terms["base_ang_vel"] + av.func = microduck_mdp.base_ang_vel_imu_misaligned + av.params = {"max_angle_deg": IMU_ORIENTATION_RANDOMIZATION_ANGLE} + g = cfg.observations["actor"].terms[gravity_term_name] + g.func = microduck_mdp.projected_gravity_imu_misaligned + g.params = {"max_angle_deg": IMU_ORIENTATION_RANDOMIZATION_ANGLE} + + # 1-ctrl-step lag on joint_vel (Dynamixel present_velocity moving average) + cfg.observations["actor"].terms["joint_vel"] = deepcopy( + cfg.observations["actor"].terms["joint_vel"] + ) + cfg.observations["actor"].terms["joint_vel"].delay_min_lag = 1 + cfg.observations["actor"].terms["joint_vel"].delay_max_lag = 1 + cfg.observations["actor"].terms["joint_vel"].delay_update_period = 0 + + # Exclude the passive wheel joints from joint_pos/vel obs (obs dim 14, matches + # the action space). Deepcopy per group so the encoder-bias `biased` flag + # below applies to the actor only. + passive_excluded = SceneEntityCfg("robot", joint_names=(r"^(?!passive_).*",)) + for grp in ("actor", "critic"): + for term in ("joint_pos", "joint_vel"): + cfg.observations[grp].terms[term] = deepcopy(cfg.observations[grp].terms[term]) + cfg.observations[grp].terms[term].params["asset_cfg"] = deepcopy(passive_excluded) + + if ENABLE_ENCODER_BIAS: + cfg.events["encoder_bias"].params["bias_range"] = ENCODER_BIAS_RANGE + cfg.observations["actor"].terms["joint_pos"].params["biased"] = True + cfg.observations["critic"].terms["joint_pos"].params["biased"] = False + else: + cfg.events.pop("encoder_bias", None) + + # Privileged wheel speeds for the critic (4 wheels in the new model). + wheel_cfg = SceneEntityCfg("robot", joint_names=(r"^passive_.*wheel",)) + cfg.observations["critic"].terms["wheel_vel"] = ObservationTermCfg( + func=mdp.joint_vel_rel, + scale=1.0, + params={"asset_cfg": wheel_cfg}, + ) + + # Command obs parity with the 61D family layout: head/body slots zero-padded + # (the roller task drives heading through the twist slot instead). + for group in ("actor", "critic"): + cfg.observations[group].terms["head_command"] = ObservationTermCfg( + func=microduck_mdp.zero_command_padding, params={"dim": 4}, + ) + cfg.observations[group].terms["body_command"] = ObservationTermCfg( + func=microduck_mdp.zero_command_padding, params={"dim": 6}, + ) + + # === COMMANDS === + command: UniformVelocityCommandCfg = cfg.commands["twist"] + command.rel_standing_envs = 0.0 + command.rel_heading_envs = 0.0 + command.heading_command = False # RelativeHeadingVelocityCommand handles heading internally + command.ranges.heading = None # must be None when heading_command=False + # cmd_x semantics: 0=coast, >0=push to accelerate, <0=brake to stop + command.ranges.lin_vel_x = (-0.5, 0.6) + command.ranges.lin_vel_y = (0.0, 0.0) + # ang_vel_z range is the clip limit for cmd[2] = heading error (rad). + # Set to 0 → cmd[2] is always 0 → no turning demand (straight-line focus). + command.ranges.ang_vel_z = (0.0, 0.0) + command.viz.z_offset = 0.5 + cfg.commands["twist"] = microduck_mdp.RelativeHeadingVelocityCommandCfg(**vars(command)) + + cfg.scene.terrain.terrain_type = "plane" + cfg.scene.terrain.terrain_generator = None + + # === CURRICULUM === + del cfg.curriculum["terrain_levels"] + del cfg.curriculum["command_vel"] + + # action_rate penalty raised (-0.5/-0.8/-1.0 -> -1.0/-1.5/-2.0) for a CALMER + # gait: this is the main "less movement" lever — it penalises fast/large action + # changes, so motions become smaller, smoother AND less frequent (rapid + # alternation = big action change = penalised). Dial back if it gets sluggish + # / can't push enough to move. + cfg.curriculum["action_rate_weight"] = CurriculumTermCfg( + func=microduck_mdp.reward_weight, + params={ + "reward_name": "action_rate_l2", + "weight_stages": [ + {"step": 0, "weight": -1.0}, + {"step": 250 * 24, "weight": -1.5}, + {"step": 500 * 24, "weight": -2.0}, + ], + }, + ) + + if ENABLE_WHEEL_FRICTION_RANDOMIZATION: + # Delayed + softened ramp: the previous schedule started adding bearing + # drag at iter 750 — right when wheel_speed peaked — and reached 0.003, + # which (with the heading ramp below) pushed the policy off skating into + # a heading-farming local optimum. Keep the wheels free until skating is + # robust, then add gentle, realistic drag. + cfg.curriculum["wheel_friction"] = CurriculumTermCfg( + func=microduck_mdp.wheel_friction_curriculum, + params={ + "event_name": "randomize_wheel_friction", + "ranges_stages": [ + {"step": 0 * 24, "ranges": (0.0000, 0.0000)}, + {"step": 2000 * 24, "ranges": (0.0005, 0.0005)}, + {"step": 3500 * 24, "ranges": (0.0010, 0.0010)}, + {"step": 5000 * 24, "ranges": (0.0015, 0.0015)}, + ], + }, + ) + + # (heading_tracking_weight curriculum removed — heading is disabled while we + # focus on straight-line skating. Re-add together with the reward above.) + + # CoM randomization curricula — velocity's ramp, capped lower for the + # balance-sensitive skating task (audit lesson: ±30 mm forced a nervous + # gait on the walker; skates are even less forgiving). + if ENABLE_COM_RANDOMIZATION: + cfg.curriculum["com_range"] = CurriculumTermCfg( + func=microduck_mdp.com_range_curriculum, + params={ + "event_name": "randomize_com", + "range_stages": [ + {"step": 0, "range": 0.003}, + {"step": 500 * 24, "range": 0.005}, + {"step": 1000 * 24, "range": 0.01}, + ], + }, + ) + if ENABLE_HEAD_COM_RANDOMIZATION: + cfg.curriculum["head_com_range"] = CurriculumTermCfg( + func=microduck_mdp.com_range_curriculum, + params={ + "event_name": "randomize_head_com", + "range_stages": [ + {"step": 0, "range": 0.003}, + {"step": 500 * 24, "range": 0.005}, + {"step": 1000 * 24, "range": 0.01}, + ], + }, + ) + + return cfg + + +MicroduckRollersRlCfg = RslRlOnPolicyRunnerCfg( + actor=RslRlModelCfg( + hidden_dims=(512, 256, 128), + activation="elu", + obs_normalization=True, # matches the family; normalizer baked into ONNX by export.py + distribution_cfg={ + "class_name": "GaussianDistribution", + "init_std": 1.0, + "std_type": "scalar", + }, + ), + critic=RslRlModelCfg( + hidden_dims=(512, 256, 128), + activation="elu", + obs_normalization=True, + ), + algorithm=PpoWithSymmetryCfg( + value_loss_coef=1.0, + use_clipped_value_loss=True, + clip_param=0.2, + entropy_coef=0.03, # roller-specific: higher exploration than the walk envs + num_learning_epochs=5, + num_mini_batches=4, + learning_rate=1.0e-3, + schedule="adaptive", + gamma=0.99, + lam=0.95, + desired_kl=0.01, + max_grad_norm=1.0, + symmetry_cfg=SYMMETRY_CFG if ENABLE_SYMMETRY else None, + ), + wandb_project="mjlab_microduck", + experiment_name="velocity_rollers", + run_name="velocity_rollers", + save_interval=250, + num_steps_per_env=24, + max_iterations=50_000, +) diff --git a/src/mjlab_microduck/tasks/microduck_velocity_swizzle_env_cfg.py b/src/mjlab_microduck/tasks/microduck_velocity_swizzle_env_cfg.py new file mode 100644 index 0000000..104dfa1 --- /dev/null +++ b/src/mjlab_microduck/tasks/microduck_velocity_swizzle_env_cfg.py @@ -0,0 +1,198 @@ +"""Microduck roller SWIZZLE environment — clean classic swizzle. + +A separate roller task producing a CLASSIC SWIZZLE: both blades stay on the ground, +the legs spread out and pull back in SYMMETRICALLY (hourglass pattern), propelling +the duck forward. Simpler / more stable alternative to the alternating stride +(`Mjlab-Velocity-Flat-MicroDuck-Rollers`), which does not transfer well to the real +robot. The stride env is left untouched. + +Approach A (see docs/superpowers/specs/2026-07-23-swizzle-env-design.md): the base +roller recipe NATURALLY converges to a swizzle, so we reuse the stride env wholesale +(robot, 61D obs, command, full DR, curricula, sim2real — deploys identically with +`--roller`) and only swap the reward recipe: + - REMOVE the anti-swizzle / stride terms. + - ADD leg_symmetry (legs mirror) + grounded (both blades down). +""" + +import dataclasses + +from mjlab.envs import ManagerBasedRlEnvCfg +from mjlab.managers import CurriculumTermCfg, ObservationTermCfg, RewardTermCfg +from mjlab.managers.scene_entity_config import SceneEntityCfg +from mjlab.tasks.velocity import mdp + +from mjlab_microduck.tasks import mdp as microduck_mdp +from mjlab_microduck.tasks.microduck_velocity_rollers_env_cfg import ( + MicroduckRollersRlCfg, + make_microduck_velocity_rollers_env_cfg, +) + +# Stride / anti-swizzle rewards to drop for the swizzle task. +_ANTI_SWIZZLE = ("single_support", "glide", "skating_air_time", "gait_symmetry", "hip_roll_neutral") + + +def make_microduck_velocity_swizzle_env_cfg(play: bool = False) -> ManagerBasedRlEnvCfg: + """Roller swizzle env: the stride env minus its anti-swizzle terms, plus symmetry + and grounded rewards. Everything else (robot, obs, command, DR) is identical.""" + cfg = make_microduck_velocity_rollers_env_cfg(play=play) + + for name in _ANTI_SWIZZLE: + if name in cfg.rewards: + del cfg.rewards[name] + + # Legs mirror each other (the swizzle's defining symmetry). + cfg.rewards["leg_symmetry"] = RewardTermCfg( + func=microduck_mdp.leg_symmetry_reward, + weight=2.0, + params={"asset_cfg": SceneEntityCfg("robot")}, + ) + # Keep both blades on the ground (classic swizzle: no lifting). + cfg.rewards["grounded"] = RewardTermCfg( + func=microduck_mdp.grounded_reward, + weight=1.0, + params={"sensor_name": "feet_ground_contact", "command_name": "twist"}, + ) + + # --- Backward locomotion (option A): cmd_x < 0 means GO BACKWARD (not brake) --- + # wheel_speed rewards wheel spin in the COMMANDED direction (fwd for +, back for + # -); the braking reward is dropped (negative no longer means "stop"); command + # range symmetrised so forward and backward get equal push range. To stop, command + # cmd_x ~ 0 (coast). grounded uses |cmd_x| so it holds the blades down both ways. + cfg.rewards["wheel_speed"].params["bidirectional"] = True + if "braking" in cfg.rewards: + del cfg.rewards["braking"] + cfg.commands["twist"].ranges.lin_vel_x = (-0.6, 0.6) + + # --- Heading curriculum: go STRAIGHT first, then FOLLOW a commanded direction --- + # The stride env disabled heading (ang_vel_z=(0,0), heading_hold, no heading_tracking). + # Re-enable the heading command so cmd[2] carries the heading error to a sampled + # target, and add heading_tracking (starts at 0). A curriculum then swaps the two: + # phase 1 (straight): heading_hold dominant, heading_tracking off + # phase 2 (follow): heading_hold -> 0, heading_tracking -> up + # cmd[2] = heading error clip. Reduced ±1.0 -> ±0.5: bounds the OBSERVED heading + # error, so the turn-correction rate is gentler (a ±1.0-trained policy turned too + # violently — had to run --max-angular-vel 0.3 to tame it). It can still reach any + # heading (the error just saturates at 0.5), so it turns fully but smoothly, and + # the heading_tracking weight stays 3.0 so it still follows direction well. + cfg.commands["twist"].ranges.ang_vel_z = (-0.5, 0.5) + + cfg.rewards["heading_tracking"] = RewardTermCfg( + func=microduck_mdp.heading_tracking_reward, + weight=0.0, # ramped up by the curriculum below (must match its step-0 value) + params={"command_name": "twist", "std": 0.5}, + ) + + cfg.curriculum["heading_hold_weight"] = CurriculumTermCfg( + func=microduck_mdp.reward_weight, + params={ + "reward_name": "heading_hold", + "weight_stages": [ + {"step": 0, "weight": 1.0}, # must match heading_hold's initial weight + {"step": 1000 * 24, "weight": 1.0}, # hold straight while the swizzle solidifies + {"step": 1750 * 24, "weight": 0.5}, + {"step": 2500 * 24, "weight": 0.0}, + ], + }, + ) + cfg.curriculum["heading_tracking_weight"] = CurriculumTermCfg( + func=microduck_mdp.reward_weight, + params={ + "reward_name": "heading_tracking", + "weight_stages": [ + {"step": 0, "weight": 0.0}, + {"step": 1000 * 24, "weight": 0.0}, # straight-only until here + {"step": 1750 * 24, "weight": 1.5}, + {"step": 2500 * 24, "weight": 3.0}, + ], + }, + ) + + # --- Head-pose control (Y button): the policy produces the head pose --------- + # Head-pose command (4D deltas from HOME: [neck_pitch, head_pitch, head_yaw, + # head_roll]). Ported from the velocity env; ranges start small (widened by the + # curriculum below). Resample every 2-5 s. + cfg.commands["head_pose"] = microduck_mdp.UniformPoseCommandCfg( + resampling_time_range=(2.0, 5.0), + ranges=( + (-0.05, 0.05), # neck_pitch + (-0.05, 0.05), # head_pitch + (-0.07, 0.07), # head_yaw + (-0.015, 0.015), # head_roll (tighter — small mechanical range) + ), + ) + + # Feed the REAL head command into the obs (replaces zero_command_padding) on + # both groups. body_command stays zero-padded (no body-pose control here). + for group in ("actor", "critic"): + cfg.observations[group].terms["head_command"] = ObservationTermCfg( + func=mdp.generated_commands, + params={"command_name": "head_pose"}, + ) + + # Reward the head tracking its command. Weight 0 here — ramped in LATE by the + # curriculum so it doesn't disturb the swizzle before it's solid. + cfg.rewards["head_pose_tracking"] = RewardTermCfg( + func=microduck_mdp.head_pose_tracking, + weight=0.0, + params={"command_name": "head_pose", "std": 0.5}, + ) + + # Reconcile the two HOME-pullers that would fight head_pose_tracking: + # 1) neck_joint_pos_l2 pulls the neck/head joints to HOME -> remove it. + if "neck_joint_pos_l2" in cfg.rewards: + del cfg.rewards["neck_joint_pos_l2"] + # 2) the pose reward includes neck/head -> scope it to LEG joints only. + # Remove neck/head/passive patterns from std dicts to match scoped asset_cfg. + for std_key in ["std_standing", "std_walking", "std_running"]: + if std_key in cfg.rewards["pose"].params: + std_dict = cfg.rewards["pose"].params[std_key] + # Keep only leg joint patterns (filter out neck, head, passive) + cfg.rewards["pose"].params[std_key] = { + k: v for k, v in std_dict.items() + if "neck" not in k and "head" not in k and "passive" not in k + } + # Scope asset_cfg to LEG joints only (excludes neck, head, passive wheels) + cfg.rewards["pose"].params["asset_cfg"] = SceneEntityCfg( + "robot", joint_names=(r"^(?!passive_|.*neck.*|.*head.*).*",) + ) + + # head_pose_tracking ramps 0 -> 4.0, staying 0 until ~1500 it. (swizzle solid), + # so head control is added on top of a stable swizzle. + cfg.curriculum["head_pose_tracking_weight"] = CurriculumTermCfg( + func=microduck_mdp.reward_weight, + params={ + "reward_name": "head_pose_tracking", + "weight_stages": [ + {"step": 0, "weight": 0.0}, # must match initial weight + {"step": 1500 * 24, "weight": 0.0}, # head off while swizzle solidifies + {"step": 2250 * 24, "weight": 2.0}, + {"step": 3000 * 24, "weight": 4.0}, + ], + }, + ) + # Head-command range widens over the SAME window (tiny until 1500, full by 3000), + # so the commanded head barely moves early and reaches full range once the policy + # can handle it. + cfg.curriculum["head_pose_range"] = CurriculumTermCfg( + func=microduck_mdp.pose_command_range_curriculum, + params={ + "command_name": "head_pose", + "range_stages": [ + # step, ((neck_pitch), (head_pitch), (head_yaw), (head_roll)) + {"step": 0, "ranges": ((-0.05, 0.05), (-0.05, 0.05), (-0.07, 0.07), (-0.015, 0.015))}, + {"step": 1500 * 24, "ranges": ((-0.05, 0.05), (-0.05, 0.05), (-0.07, 0.07), (-0.015, 0.015))}, + {"step": 2250 * 24, "ranges": ((-0.55, 0.55), (-0.55, 0.55), (-0.70, 0.70), (-0.15, 0.15))}, + {"step": 3000 * 24, "ranges": ((-1.10, 1.10), (-1.10, 1.10), (-1.40, 1.40), (-0.31, 0.31))}, + ], + }, + ) + + return cfg + + +# Same PPO hyperparameters as the stride roller task, new experiment/run name. +MicroduckSwizzleRlCfg = dataclasses.replace( + MicroduckRollersRlCfg, + experiment_name="velocity_swizzle", + run_name="velocity_swizzle", +) diff --git a/src/mjlab_microduck/tasks/microduck_velstand_env_cfg.py b/src/mjlab_microduck/tasks/microduck_velstand_env_cfg.py new file mode 100644 index 0000000..3e16312 --- /dev/null +++ b/src/mjlab_microduck/tasks/microduck_velstand_env_cfg.py @@ -0,0 +1,416 @@ +"""Microduck VelStand environment: walking + fall recovery, one policy. + +REBASED (2026-07, audit follow-up) on the velocity recipe — the proven +walker — instead of the abandoned older recipe the old velstand used. +The 2026-07 audit found the old design starved the walk: only ~25% of +experience was clean commanded walking (2/3 prone resets + fallen envs farming +recovery reward for full 20 s episodes), the recovery rewards taxed the gait +(always-on posture double-counting, a bounce incentive from com_upward_velocity +below walk height), and the prone init dropped the robot from 0.20–0.25 m +(function defaults — a violent uncontrolled impact opening most episodes). + +Design now: + - Walk layer = make_microduck_velocity_env_cfg, verbatim. Everything the + good walker has (tracking weights, air_time, turn-in-place bucket, fixed + command ranges, DR/noise/obs) flows in by construction. + - Robot = all-collision standup XML (body can physically lie down). + - Recovery = a small reward layer GATED on actually-being-fallen + (trunk z < 0.10 m OR tilt > 40°): contributes exactly zero during clean + walking, steers only when down. upright_linear gives an orientation + gradient everywhere; com_upward_velocity pays for rising. (The old + com_height_recovery was dropped: flat/no-gradient inside its band and + redundant with the two above — audit finding 3.) + - Impact penalties (trunk/head) discourage hard landings, ungated. + - joint_torque_rate_l2 (standup's proven anti-jitter) for transfer + smoothness — penalizes torque CHANGE, never blocks the recovery flip. + +Run-5 lesson (crouch endpoint): recoveries walked nicely but parked in a deep +crouch just past the 40° gates — every dense recovery term stops paying there, +and the recovery_success bounty demanded z > 0.105, above the policy's real +standing envelope (0.084–0.096), so it never fired. Fixes: (1) shared +"recovery complete" definition (tilt < 25° AND z > 0.09 — reachable) for the +bounty and (2) a fallen_tax hysteresis that keeps taxing after a fall until +that definition is met, and (3) height_progress — a potential-based Δz term +giving the crouch→stand last mile the dense gradient nothing else provides. + +Run-6 lesson (still parked at 4k): fixing the economics wasn't enough — the +bounty fired (rising recovery_success curve) but stayed exploration-rare, +because the last mile got almost no on-policy DATA: a prone episode spends +most of its 5 s fallen budget getting TO the crouch, then fallen_too_long +recycles it right at the frontier. The old velstand learned recovery fast +precisely because 2/3 prone resets + 20 s episodes made fallen-state data +abundant (at the cost of the walk). Run-6 recovers that data density without +the starvation: (1) crouch_prob reverse-curriculum slice — reset directly +into random mid-recovery crouches, dense last-mile data from step 0; (2) +fallen timeout 5 → 8 s; (3) economics at 800 (walk is stable by ~750) and +the whole prone ramp pulled ~500 iters earlier. + +Run-7 lesson (headless eval of run 6 vs run 5 @4k, 2026-07-21): the crouch +slice WORKED — run 6 stands truly vertical (tilt ≈1°, z ≈0.117) and recovers +94–97% from crouch inits — but prone recovery collapsed to 0% (run 5: gets up +from prone but parks at ~30°). Cause: run 6 turned on tax + bounty + prone + +crouch ALL at iter 800, deleting the tax-free natural-fall window (500→1200 in +run 5) where prone-flip exploration was cheap and the dense progress terms +alone taught it — run 5's recovery_success was already firing the moment its +weight turned on at 1200. With the tax live from 800 and hopeless prone +episodes bleeding -0.5/step for the full 8 s timeout, the run-3 avoidance/ +freeze mechanism re-emerged for prone states while PPO capacity went to the +easy crouch-slice reward. Run-7: keep the crouch slice (validated) + 8 s +timeout, restore econ to 1200 and prone to the run-5 ramp (1500+), crouch +slice alone from 800 (harmless pre-econ: it just adds stand-tall data). + +Phases (as before, but with a recovery backstop): + Phase 1 (0 → 500 iters): `fell_over` termination active (70°) → clean + walking first. + Phase 2 (500+): fell_over disabled (limit → π) so falls become recovery + opportunities — but `fallen_too_long` (5 s continuously down) recycles + failed recoveries instead of letting them farm the full 20 s episode. + Phase 3 (1500+): prone-init ramp: face-down first (easier), face-up mixed + in later, capped at 45% prone so the walking data share stays ≥ ~55% + (was 2/3 prone → ~25% walking share). +""" + +import math + +from mjlab.envs import ManagerBasedRlEnvCfg +from mjlab.managers import ( + CurriculumTermCfg, + EventTermCfg, + RewardTermCfg, + TerminationTermCfg, +) +from mjlab.managers.scene_entity_config import SceneEntityCfg +from mjlab.rl import ( + RslRlOnPolicyRunnerCfg, + RslRlModelCfg, +) + +from mjlab_microduck.robot.microduck_constants import MICRODUCK_STANDUP_ROBOT_CFG +from mjlab_microduck.tasks import mdp as microduck_mdp +from mjlab_microduck.tasks.microduck_velocity_env_cfg import ( + make_microduck_velocity_env_cfg, +) +from mjlab_microduck.tasks.symmetry import PpoWithSymmetryCfg + +# Phase boundaries (PPO iterations; env step counter scales by num_steps_per_env=24) +FELL_OVER_DISABLE_ITER = 500 +NUM_STEPS_PER_ENV = 24 + +# Fallen gates. LESSON (first rebase training run): the recovery REWARDS must +# gate on TILT ONLY. Gating them on low height too made SITTING (z≈0.07, trunk +# upright) open the gate → the policy learned to sit and farm upright_linear +# while bobbing for com_upward_velocity and shaking its legs through the +# air_time window. Gating a positive reward on a bad state rewards entering +# the state. Tilt>40° can't be farmed from a comfortable pose — you're +# genuinely toppled. The TERMINATION keeps the z-condition so sitters and +# stuck-low envs get recycled (terminated) rather than paid. +REWARD_GATE_TILT_DEG = 40.0 # recovery rewards: fallen = tilt > 40° ONLY +# TERM z-gate at 0.08, NOT 0.10 (run-3 lesson): a normally wobbling upright +# robot dips to z=0.084-0.096 — 0.10 sits inside the early-learning envelope +# and recycled crouch-walking explorers every 5 s. 0.08 still catches sitting +# (z≈0.07) and prone (z≈0.05). +TERM_GATE_Z = 0.08 # fallen_too_long: z < 0.08 OR tilt > 40° +TERM_GATE_TILT_DEG = 40.0 + +# "Recovery COMPLETE" definition — shared by the recovery_success bounty and +# the fallen_tax release (run-5 crouch-endpoint lesson). z threshold must sit +# INSIDE the policy's real standing envelope: run 3 measured a normally +# wobbling upright robot at z ≈ 0.084–0.096, and the full STAND keyframe +# settles at ≈ 0.117. The old up_z=0.105 demanded standing TALLER than the +# policy ever is in practice → the bounty never fired → recoveries converged +# to a deep crouch just past the 40° gates (where every dense recovery term +# stops paying) instead of finishing the stand. 0.09 is reachable every stand +# yet still 2 cm above sitting (z ≈ 0.07) and 4 cm above prone (z ≈ 0.05). +RECOVERED_UP_TILT_DEG = 25.0 +RECOVERED_UP_Z = 0.09 + +# The tax and bounty exist FOR THE RECOVERY PHASE. Run-3 lesson: fallen_tax +# active from step 0 (dense, -0.5) taught "avoid tilt at all costs" within ~25 +# iters → crouch-freeze local optimum before walking could bootstrap (ep_len +# pinned at the 5 s recycle, air_time never grew). Run-6 tried 800 ("walk is +# stable by ~750") and prone recovery never bootstrapped — 1200 was never +# about the walk; it bought a TAX-FREE window (fell_over off at 500 → econ on +# at 1200) where natural-fall get-up attempts cost nothing and the dense +# progress terms alone could teach them. Run-7 restores it. +RECOVERY_ECON_KICKIN_ITER = 1200 + +# Failed-recovery backstop: continuously fallen this long → terminate/reset. +# Run-6: 5 s → 8 s. At 5 s a face-down recovery spent most of its budget +# getting TO the deep crouch and was recycled right at the frontier — almost +# no on-policy data for the crouch→stand last mile. +FALLEN_TIMEOUT_S = 8.0 + +# Prone + crouch init ramp (phase 3). Prone capped at 45% (was 2/3 — starved +# the walk); face-down first (easier recovery), face-up mixed in later. +# Run-6: crouch_prob adds a REVERSE-CURRICULUM slice — envs reset directly +# into random mid-recovery crouches (see set_random_crouch_state) so the +# last mile gets dense data instead of only being reached at the tail of rare +# good rollouts. Run-7: back to the run-5 prone schedule (prone AFTER econ, +# which is AFTER a tax-free natural-fall window — see econ note above); run 6 +# started prone+econ together at 800 and prone recovery never bootstrapped. +# Crouch slice alone starts at 800: near-upright states, tax-free until econ, +# and it doubles as full-stand posture data (run 6 stood truly vertical). +PRONE_RAMP_STAGES = [ + {"step": 0, "params": {"prone_prob": 0.00, "face_down_prob": 1.0, "crouch_prob": 0.00}}, + {"step": 800 * NUM_STEPS_PER_ENV, "params": {"prone_prob": 0.00, "face_down_prob": 1.0, "crouch_prob": 0.15}}, + {"step": 1500 * NUM_STEPS_PER_ENV, "params": {"prone_prob": 0.15, "face_down_prob": 0.80, "crouch_prob": 0.15}}, + {"step": 2000 * NUM_STEPS_PER_ENV, "params": {"prone_prob": 0.30, "face_down_prob": 0.65, "crouch_prob": 0.15}}, + {"step": 2500 * NUM_STEPS_PER_ENV, "params": {"prone_prob": 0.45, "face_down_prob": 0.50, "crouch_prob": 0.15}}, +] + + +def make_microduck_velstand_env_cfg(play: bool = False, rough: bool = False) -> ManagerBasedRlEnvCfg: + # Walk layer: the PROVEN velocity recipe, verbatim. + cfg = make_microduck_velocity_env_cfg(play=play, rough=rough) + + # In play mode the curriculum doesn't run, so the fall-termination disable + # below never fires — just delete the termination outright. + if play: + cfg.terminations.pop("fell_over", None) + + # Full-collision standup XML: trunk/head shells keep their contacts so the + # robot can physically lie on the ground and push off it. + cfg.scene.entities = {"robot": MICRODUCK_STANDUP_ROBOT_CFG} + + # velocity env's head_pose_bias flows in UNGATED (fine on a walk-only env — + # fell_over terminates fallen episodes there). Velstand episodes SURVIVE + # falls, so the ungated EMA would charge head "droop" all through the + # ground phase — a flat tax on being fallen that the recovery economics + # (runs 1-7) never priced in. Add the upright gate: error stops feeding the + # EMA below z=0.09 / beyond 40° tilt (matching REWARD_GATE_TILT_DEG), so + # the term prices exactly what it does in the velocity env — sustained droop while + # actually standing/walking — and nothing during recovery. + cfg.rewards["head_pose_bias"].params.update({ + "gate_height_low": 0.09, + "gate_height_high": 0.11, + "gate_tilt_full_deg": 20.0, + "gate_tilt_zero_deg": REWARD_GATE_TILT_DEG, + }) + + # ── Recovery reward layer ───────────────────────────────────────────────── + # LESSON (runs 1/2/4 — sitting, lying, head-tripod): ANY positive reward for + # BEING in a fallen-ish state gets farmed from some comfortable pose. The + # orientation reward is therefore POTENTIAL-BASED (Δcos tilt): rising pays, + # falling costs, holding anything pays zero. Unfarmable, ungated, and also + # rewards catching a stumble while walking. (Run 4 specifically: removing + # the head-impact penalty unlocked a head-tripod at ~55° farming the gated + # +2·cos(tilt) — run 2 had only been protected from it by that penalty.) + cfg.rewards["upright_progress"] = RewardTermCfg( + func=microduck_mdp.upright_progress, + weight=5.0, + params={ + "asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",)), + }, + ) + # z-axis companion to upright_progress (run-5 crouch-endpoint lesson): the + # crouch→stand last mile is mostly a HEIGHT change at modest tilt — where + # Δcos(tilt) is tiny and the Gaussian upright/pose rewards are flat. Same + # potential-based construction: unfarmable (holding/bobbing nets zero), + # ungated, charges falls symmetrically. Full prone→stand rise (0.05 → + # 0.115 m) collects Δ≈+0.065 × 30 ≈ +2; the crouch→stand mile ≈ +1. + cfg.rewards["height_progress"] = RewardTermCfg( + func=microduck_mdp.height_progress, + weight=30.0, + params={ + "asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",)), + "ceiling": 0.115, + }, + ) + cfg.rewards["com_upward_velocity"] = RewardTermCfg( + func=microduck_mdp.com_upward_velocity, + weight=0.0, # recovery term — ramped in at RECOVERY_ECON_KICKIN_ITER + params={ + "asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",)), + # Height gate slightly above standing (standup uses 0.125) so the + # rising reward keeps paying until fully up; the fallen gate is + # what prevents gait-bounce farming, not this ceiling. + "max_height": 0.125, + # tilt-only gate: z=0.0 never triggers (see LESSON above) + "gate_z_below": 0.0, + "gate_tilt_above_deg": REWARD_GATE_TILT_DEG, + }, + ) + # NO impact penalties (first run lesson #2): the standup SPECIALIST has + # none — the duck's recovery pushes off with head/trunk, and the head + # penalty (-1.0 @ 2 N) taxed exactly that strategy. Falls stayed cheaper + # than getting up. joint_torque_rate_l2 below covers landing harshness. + # Standup's proven anti-jitter term: penalizes torque CHANGE (not magnitude + # or rotation) → smooths transfer without blocking the recovery flip. + cfg.rewards["joint_torque_rate_l2"] = RewardTermCfg( + func=microduck_mdp.joint_torque_rate_l2, + weight=-2e-3, + ) + + # ── Recovery economics (first-run lessons #3-#5) ────────────────────────── + # air_time zeroed while fallen: a robot lying on its trunk can rhythmically + # tap its feet through the swing window — the observed "shaking a leg" farm. + at = cfg.rewards["air_time"] + at_params = dict(at.params) + cfg.rewards["air_time"] = RewardTermCfg( + func=microduck_mdp.feet_air_time_upright, + weight=at.weight, + params={**at_params, "gate_tilt_above_deg": REWARD_GATE_TILT_DEG}, + ) + # Flat tax while fallen: lying still must be strictly worse than trying. + # (Without it, waiting 5 s for the fallen_too_long recycle was rational — + # recovery attempts cost action-rate/torque penalties, waiting cost 0.) + cfg.rewards["fallen_tax"] = RewardTermCfg( + func=microduck_mdp.fallen_state_penalty, + weight=0.0, # ramped to -0.5 at RECOVERY_ECON_KICKIN_ITER (see curriculum) + params={ + "asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",)), + "gate_tilt_above_deg": REWARD_GATE_TILT_DEG, + # Hysteresis (run-5 crouch-endpoint lesson): recoveries parked in a + # deep crouch just under the 40° gate — past every recovery term's + # gate, but short of standing. With release conditions matching the + # recovery_success bounty (below), a fall keeps taxing until the + # stand is actually FINISHED; the sub-40° crouch is no longer a + # zero-cost rest state. Arms only on tilt > 40°, so normal gait is + # never taxed. + "release_tilt_below_deg": RECOVERED_UP_TILT_DEG, + "release_z_above": RECOVERED_UP_Z, + }, + ) + # One-shot bounty on a COMPLETED recovery (fallen ≥0.5 s → genuinely up), + # with hysteresis so gate-oscillation pays nothing. The strong endpoint + # signal the dense gated terms lack. + cfg.rewards["recovery_success"] = RewardTermCfg( + func=microduck_mdp.recovery_success, + weight=0.0, # ramped to +10 at RECOVERY_ECON_KICKIN_ITER (see curriculum) + params={ + "asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",)), + "fallen_tilt_deg": REWARD_GATE_TILT_DEG, + "min_fallen_s": 0.5, + "up_tilt_deg": RECOVERED_UP_TILT_DEG, + "up_z": RECOVERED_UP_Z, # was 0.105 — unreachable, see constant note + }, + ) + + # ── Events: prone init ──────────────────────────────────────────────────── + # z fix (audit BUG): the function defaults were 0.20–0.25 m — a 15–20 cm + # free-fall opening every prone episode. Face-down trunk rests at ~0.044 m; + # spawn just above the ground instead. + cfg.events["random_prone_init"] = EventTermCfg( + func=microduck_mdp.maybe_set_random_prone_orientation, + mode="reset", + params={ + "prone_prob": 0.0, # ramped by the prone_init_prob curriculum + "face_down_prob": 1.0, + "prone_z_min": 0.05, + "prone_z_max": 0.09, + "crouch_prob": 0.0, # ramped by the prone_init_prob curriculum + }, + ) + + # ── Terminations ────────────────────────────────────────────────────────── + # Failed-recovery backstop (see module docstring, Phase 2). + cfg.terminations["fallen_too_long"] = TerminationTermCfg( + func=microduck_mdp.fallen_too_long, + time_out=False, + params={ + "gate_z_below": TERM_GATE_Z, + "gate_tilt_above_deg": TERM_GATE_TILT_DEG, + "max_duration_s": FALLEN_TIMEOUT_S, + }, + ) + + # ── Curricula ───────────────────────────────────────────────────────────── + # Phase 1 → 2: disable fell_over at iter 500 (limit 70° → 180°) so falls + # become recovery training instead of episode ends. + if not play: + cfg.curriculum["fell_over_disable"] = CurriculumTermCfg( + func=microduck_mdp.termination_param_curriculum, + params={ + "term_name": "fell_over", + "param_stages": [ + {"step": 0, + "params": {"limit_angle": math.radians(70.0)}}, + {"step": FELL_OVER_DISABLE_ITER * NUM_STEPS_PER_ENV, + "params": {"limit_angle": math.pi}}, + ], + }, + ) + + # Phase 3: prone-init ramp (face-down first, face-up later, capped 45%). + cfg.curriculum["prone_init_prob"] = CurriculumTermCfg( + func=microduck_mdp.event_param_curriculum, + params={ + "event_name": "random_prone_init", + "param_stages": PRONE_RAMP_STAGES, + }, + ) + + # Recovery economics ramp: tax + bounty OFF until the walk is established + # (see RECOVERY_ECON_KICKIN_ITER note above — run-3 crouch-freeze lesson). + cfg.curriculum["fallen_tax_weight"] = CurriculumTermCfg( + func=microduck_mdp.reward_weight, + params={ + "reward_name": "fallen_tax", + "weight_stages": [ + {"step": 0, "weight": 0.0}, + {"step": RECOVERY_ECON_KICKIN_ITER * NUM_STEPS_PER_ENV, "weight": -0.5}, + ], + }, + ) + cfg.curriculum["recovery_success_weight"] = CurriculumTermCfg( + func=microduck_mdp.reward_weight, + params={ + "reward_name": "recovery_success", + "weight_stages": [ + {"step": 0, "weight": 0.0}, + {"step": RECOVERY_ECON_KICKIN_ITER * NUM_STEPS_PER_ENV, "weight": 10.0}, + ], + }, + ) + cfg.curriculum["com_upward_weight"] = CurriculumTermCfg( + func=microduck_mdp.reward_weight, + params={ + "reward_name": "com_upward_velocity", + "weight_stages": [ + {"step": 0, "weight": 0.0}, + {"step": RECOVERY_ECON_KICKIN_ITER * NUM_STEPS_PER_ENV, "weight": 2.0}, + ], + }, + ) + + return cfg + + +MicroduckVelStandRlCfg = RslRlOnPolicyRunnerCfg( + actor=RslRlModelCfg( + hidden_dims=(512, 256, 128), + activation="elu", + obs_normalization=True, + distribution_cfg={ + "class_name": "GaussianDistribution", + "init_std": 1.0, + "std_type": "scalar", + }, + ), + critic=RslRlModelCfg( + hidden_dims=(512, 256, 128), + activation="elu", + obs_normalization=True, + ), + algorithm=PpoWithSymmetryCfg( + value_loss_coef=1.0, + use_clipped_value_loss=True, + clip_param=0.2, + entropy_coef=0.01, + num_learning_epochs=5, + num_mini_batches=4, + learning_rate=1.0e-3, + schedule="adaptive", + gamma=0.99, + lam=0.95, + desired_kl=0.01, + max_grad_norm=1.0, + symmetry_cfg=None, + ), + wandb_project="mjlab_microduck", + experiment_name="velstand", + run_name="velstand", + save_interval=250, + num_steps_per_env=24, + max_iterations=20_000, +) diff --git a/src/mjlab_microduck/tasks/slope_terrain.py b/src/mjlab_microduck/tasks/slope_terrain.py new file mode 100644 index 0000000..e4270fc --- /dev/null +++ b/src/mjlab_microduck/tasks/slope_terrain.py @@ -0,0 +1,115 @@ +"""Terrain custom « plat + rampe descendante » pour la tâche roller_slope. + +Le robot spawne sur une zone plate, reçoit une impulsion vers +x, roule +jusqu'à la rampe et se laisse glisser. L'angle de la rampe est interpolé par +la difficulté (curriculum) sur [RAMP_DEG_MIN, RAMP_DEG_MAX] degrés. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass + +import mujoco +import numpy as np + +from mjlab.terrains.terrain_generator import ( + SubTerrainCfg, + TerrainGeometry, + TerrainOutput, +) + +RAMP_DEG_MIN = 2.0 +RAMP_DEG_MAX = 20.0 + + +def ramp_angle_by_difficulty( + difficulty: float, deg_min: float = RAMP_DEG_MIN, deg_max: float = RAMP_DEG_MAX +) -> float: + """Angle de rampe (radians) interpolé linéairement par la difficulté [0,1].""" + d = float(np.clip(difficulty, 0.0, 1.0)) + return math.radians(deg_min + d * (deg_max - deg_min)) + + +@dataclass(kw_only=True) +class FlatRampTerrainCfg(SubTerrainCfg): + """Plat de départ → rampe descendante → plat de sortie. + + Trois box alignés le long de +x : + 1. plat de départ (surface à z=0) où le robot spawne ; + 2. rampe descendante, angle interpolé par la difficulté, longueur + HORIZONTALE tirée au hasard dans ``ramp_length_range`` (une valeur par + tuile, fixée à la génération) ; + 3. plat de sortie au niveau du bas de la rampe, pour que le robot + atterrisse sur du solide au lieu du vide. + """ + + flat_length: float = 2.0 # plat de départ (m) + ramp_length_range: tuple = (3.0, 8.0) # longueur horizontale rampe (m), tirée au hasard + runout_length: float = 4.0 # plat de sortie en bas (m) + spawn_on_ramp: float = 0.3 # spawn ce nb de m SUR la rampe (gravité => roulement) + deg_min: float = RAMP_DEG_MIN + deg_max: float = RAMP_DEG_MAX + thickness: float = 0.5 # épaisseur des box (m) + + def function( + self, difficulty: float, spec: mujoco.MjSpec, rng + ) -> TerrainOutput: + total_max = self.flat_length + self.ramp_length_range[1] + self.runout_length + assert total_max <= self.size[0], ( + f"flat+ramp_max+runout ({total_max}) must fit in size[0] ({self.size[0]})" + ) + body = spec.body("terrain") + angle = ramp_angle_by_difficulty(difficulty, self.deg_min, self.deg_max) + width = self.size[1] + t = self.thickness + # Longueur de rampe tirée au hasard (déterministe pour un rng donné). + ramp_length = float(rng.uniform(self.ramp_length_range[0], self.ramp_length_range[1])) + drop = ramp_length * math.tan(angle) # dénivelé (m), positif + + # 1) Plat de départ : surface à z=0, x dans [0, flat_length]. + flat = body.add_geom( + type=mujoco.mjtGeom.mjGEOM_BOX, + size=(self.flat_length / 2.0, width / 2.0, t / 2.0), + pos=(self.flat_length / 2.0, 0.0, -t / 2.0), + ) + + # 2) Rampe : box tourné de +angle autour de +y (le bord +x descend). + # Décalage -(t/2)·sin(angle) en x : sans lui, le bord HAUT de la surface + # inclinée tombe à x=flat_length+(t/2)sin(a) -> petit trou entre la + # plateforme plate (finit à flat_length) et la rampe. Avec, le haut de la + # rampe touche pile le bord de la plateforme (raccord net), et le bas + # touche pile le plat de sortie. + surf_len = ramp_length / math.cos(angle) + ramp_cx = self.flat_length + ramp_length / 2.0 - (t / 2.0) * math.sin(angle) + ramp_cz = -(drop / 2.0) - (t / 2.0) * math.cos(angle) + half = angle / 2.0 + ramp = body.add_geom( + type=mujoco.mjtGeom.mjGEOM_BOX, + size=(surf_len / 2.0, width / 2.0, t / 2.0), + pos=(ramp_cx, 0.0, ramp_cz), + quat=(math.cos(half), 0.0, math.sin(half), 0.0), + ) + + # 3) Plat de sortie : surface au niveau du bas de la rampe (z = -drop). + runout_cx = self.flat_length + ramp_length + self.runout_length / 2.0 + runout = body.add_geom( + type=mujoco.mjtGeom.mjGEOM_BOX, + size=(self.runout_length / 2.0, width / 2.0, t / 2.0), + pos=(runout_cx, 0.0, -drop - t / 2.0), + ) + + # Spawn un peu SUR la rampe : la gravité fait rouler les roues tout de + # suite (élan AUX ROUES, pas de poussée de base qui patinerait), et le + # robot est déjà sur la pente. z sur la surface inclinée à cette distance. + spawn_x = self.flat_length + self.spawn_on_ramp + spawn_z = -self.spawn_on_ramp * math.tan(angle) + origin = np.array([spawn_x, 0.0, spawn_z]) + return TerrainOutput( + origin=origin, + geometries=[ + TerrainGeometry(geom=flat, color=(0.5, 0.5, 0.5, 1.0)), + TerrainGeometry(geom=ramp, color=(0.45, 0.55, 0.75, 1.0)), + TerrainGeometry(geom=runout, color=(0.5, 0.5, 0.5, 1.0)), + ], + ) diff --git a/src/mjlab_microduck/tasks/symmetry.py b/src/mjlab_microduck/tasks/symmetry.py new file mode 100644 index 0000000..e950421 --- /dev/null +++ b/src/mjlab_microduck/tasks/symmetry.py @@ -0,0 +1,169 @@ +"""Bilateral (left-right) symmetry augmentation for the microduck 61-D envs. + +Migrated 2026-08-13 from the old 51-D layout to the current 61-D family +(velocity/velstand/standup/roulade — twist + head_command + body_command obs +slots), and the augmented-obs output key fixed "policy" → "actor" (mjlab +1.3.0 group naming; the old key would KeyError in rsl_rl 5.0.1's mirror-loss +path — dead code until now since no env had symmetry enabled). + +Actor observation layout (61-dim flat tensor, concatenated in term insertion order): + [0:3] base_ang_vel (roll, pitch, yaw — body-frame IMU) + [3:6] projected_gravity (gx, gy, gz — body-frame) + [6:20] joint_pos_rel (14 joints, relative to default pose) + [20:34] joint_vel_rel (14 joints) + [34:48] last_action (14 joints) + [48:51] twist command (lin_vel_x, lin_vel_y, ang_vel_z) + [51:55] head command (neck_pitch, head_pitch, head_yaw, head_roll deltas) + [55:61] body command (x, y, z, roll, pitch, yaw deltas) + +Joint ordering within each 14-dim block (from robot_walk.xml body tree): + 0: left_hip_yaw 5: neck_pitch 9: right_hip_yaw + 1: left_hip_roll 6: head_pitch 10: right_hip_roll + 2: left_hip_pitch 7: head_yaw 11: right_hip_pitch + 3: left_knee 8: head_roll 12: right_knee + 4: left_ankle 13: right_ankle + +Mirroring rules (left-right reflection about the sagittal plane): +- Swap left legs (0-4) with right legs (9-13); midline joints (5-8) stay. +- Negate after swap: + - hip_yaw, hip_roll: yaw/roll axes reverse under L-R reflection + - hip_pitch, knee, ankle: home frame uses opposite-sign conventions for + left vs right (e.g., left_hip_pitch = +0.6, right_hip_pitch = -0.6), + so relative deviations also negate + - head_yaw, head_roll: same yaw/roll reasoning + - neck_pitch, head_pitch: sagittal-plane joints, no sign change +- base_ang_vel: negate roll ([0]) and yaw ([2]); pitch stays +- projected_gravity: negate gy ([4]); gx and gz stay +- twist command: negate lin_vel_y ([49]) and ang_vel_z ([50]); lin_vel_x stays +- head command: negate head_yaw ([53]) and head_roll ([54]); pitches stay +- body command: negate y ([56]), roll ([58]), yaw ([60]); x, z, pitch stay +""" + +from dataclasses import dataclass + +import torch +from tensordict import TensorDict +from mjlab.rl import RslRlPpoAlgorithmCfg + + +@dataclass +class PpoWithSymmetryCfg(RslRlPpoAlgorithmCfg): + """PPO algorithm config extended with an optional symmetry_cfg field.""" + + symmetry_cfg: dict | None = None + + +SYMMETRY_CFG = { + "use_data_augmentation": False, + "use_mirror_loss": True, + "mirror_loss_coeff": 0.5, + "data_augmentation_func": "mjlab_microduck.tasks.symmetry.microduck_vel_symmetry", +} + +# --------------------------------------------------------------------------- +# Permutation and sign tables +# --------------------------------------------------------------------------- + +# Within a 14-joint block: left (0-4) <-> right (9-13), midline (5-8) fixed +_JOINT_PERM: list[int] = [9, 10, 11, 12, 13, 5, 6, 7, 8, 0, 1, 2, 3, 4] + +# Signs applied AFTER permutation for each joint position +_JOINT_SIGN: list[float] = [-1, -1, -1, -1, -1, 1, 1, -1, -1, -1, -1, -1, -1, -1] + +# Full 61-dim actor obs permutation (all command slots mirror in place) +_OBS_PERM: list[int] = ( + [0, 1, 2] # base_ang_vel (indices unchanged) + + [3, 4, 5] # projected_gravity + + [6 + j for j in _JOINT_PERM] # joint_pos + + [20 + j for j in _JOINT_PERM] # joint_vel + + [34 + j for j in _JOINT_PERM] # last_action + + [48, 49, 50] # twist command + + [51, 52, 53, 54] # head command + + [55, 56, 57, 58, 59, 60] # body command +) + +# Full 61-dim sign vector +_OBS_SIGN: list[float] = ( + [-1.0, 1.0, -1.0] # base_ang_vel: negate roll, yaw + + [1.0, -1.0, 1.0] # projected_gravity: negate gy + + _JOINT_SIGN # joint_pos + + _JOINT_SIGN # joint_vel + + _JOINT_SIGN # last_action + + [1.0, -1.0, -1.0] # twist: negate lin_vel_y, ang_vel_z + + [1.0, 1.0, -1.0, -1.0] # head: negate head_yaw, head_roll + + [1.0, -1.0, 1.0, -1.0, 1.0, -1.0] # body: negate y, roll, yaw +) + +# Cache tensors per device to avoid reallocating on every call +_cache: dict[torch.device, tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]] = {} + + +def _get_tensors( + device: torch.device, +) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]: + if device not in _cache: + obs_perm = torch.tensor(_OBS_PERM, dtype=torch.long, device=device) + obs_sign = torch.tensor(_OBS_SIGN, dtype=torch.float32, device=device) + act_perm = torch.tensor(_JOINT_PERM, dtype=torch.long, device=device) + act_sign = torch.tensor(_JOINT_SIGN, dtype=torch.float32, device=device) + _cache[device] = (obs_perm, obs_sign, act_perm, act_sign) + return _cache[device] + + +# --------------------------------------------------------------------------- +# Public augmentation function +# --------------------------------------------------------------------------- + + +def microduck_vel_symmetry( + env, + obs: TensorDict | None, + actions: torch.Tensor | None, +) -> tuple[TensorDict | None, torch.Tensor | None]: + """Bilateral symmetry augmentation / mirror function for the microduck vel env. + + Returns [original, mirrored] concatenated along the batch dimension. + Compatible with the rsl_rl PPO ``symmetry_cfg`` interface (use_data_augmentation + and/or use_mirror_loss). + + Args: + env: The vectorised environment (unused, present for interface compatibility). + obs: TensorDict with keys ``"policy"`` and ``"critic"``, shape ``[B, obs_dim]``. + Pass ``None`` when only actions need to be mirrored. + actions: Float tensor of shape ``[B, 14]``. + Pass ``None`` when only obs need to be mirrored. + + Returns: + Tuple ``(aug_obs, aug_actions)`` where each non-None input is doubled + along the batch axis as ``[original; mirrored]``. + """ + aug_obs: TensorDict | None = None + aug_actions: torch.Tensor | None = None + + if obs is not None: + actor_orig: torch.Tensor = obs["actor"] # [B, 51] + obs_perm, obs_sign, _, _ = _get_tensors(actor_orig.device) + actor_sym = actor_orig[:, obs_perm] * obs_sign + + critic_orig: torch.Tensor = obs["critic"] + # Critic obs mirroring is not implemented (not needed for use_mirror_loss). + # For use_data_augmentation the critic sees a repeated unmirrored obs, + # which is a harmless approximation since the critic uses privileged info + # not present in the actor obs. + critic_repeated = torch.cat([critic_orig, critic_orig], dim=0) + + aug_obs = TensorDict( + { + "actor": torch.cat([actor_orig, actor_sym], dim=0), + "critic": critic_repeated, + }, + batch_size=[actor_orig.shape[0] * 2], + device=actor_orig.device, + ) + + if actions is not None: + _, _, act_perm, act_sign = _get_tensors(actions.device) + actions_sym = actions[:, act_perm] * act_sign + aug_actions = torch.cat([actions, actions_sym], dim=0) + + return aug_obs, aug_actions diff --git a/src/mjlab_microduck/tasks/testbench_env_cfg.py b/src/mjlab_microduck/tasks/testbench_env_cfg.py new file mode 100644 index 0000000..7b6ebef --- /dev/null +++ b/src/mjlab_microduck/tasks/testbench_env_cfg.py @@ -0,0 +1,298 @@ +"""XL330 test-bench RL environment. + +Single-DOF fixed-base joint tracking task for sim2real validation. Starts at 0 +and must reach a target angle uniformly sampled in [-80°, 80°]. Uses the same +observation noise and action-smoothness regularization as the microduck velocity +env, with NO domain randomization so the learned policy can be transferred +directly to the real XL330 testbench. +""" + +from __future__ import annotations + +import math +import os +from dataclasses import dataclass, field + +import torch + +from mjlab.entity import Entity +from mjlab.envs import ManagerBasedRlEnvCfg +from mjlab.envs.manager_based_rl_env import ManagerBasedRlEnv +from mjlab.envs.mdp.actions import JointPositionActionCfg +from mjlab.envs import mdp as base_mdp +from mjlab.managers.command_manager import CommandTerm +from mjlab.managers import ( + CommandTermCfg, + EventTermCfg, + ObservationGroupCfg, + ObservationTermCfg, + RewardTermCfg, + TerminationTermCfg, +) +from mjlab.managers.scene_entity_config import SceneEntityCfg +from mjlab.rl import ( + RslRlOnPolicyRunnerCfg, + RslRlPpoActorCriticCfg, + RslRlPpoAlgorithmCfg, +) +from mjlab.scene import SceneCfg +from mjlab.sim import MujocoCfg, SimulationCfg +from mjlab.terrains import TerrainImporterCfg +from mjlab.utils.noise import UniformNoiseCfg as Unoise +from mjlab.viewer import ViewerConfig + +from mjlab_microduck.robot.testbench_constants import XL330_TESTBENCH_ROBOT_CFG + + +# ---------------------------------------------------------------------------- +# Target angle command (single joint) +# ---------------------------------------------------------------------------- + +TESTBENCH_MAX_ANGLE_RAD = math.radians(80.0) + + +class TargetAngleCommand(CommandTerm): + """Uniform single-scalar target angle command.""" + + cfg: "TargetAngleCommandCfg" + + def __init__(self, cfg: "TargetAngleCommandCfg", env: ManagerBasedRlEnv): + super().__init__(cfg, env) + self.robot: Entity = env.scene[cfg.asset_name] + self._target = torch.zeros(self.num_envs, 1, device=self.device) + self.metrics["error"] = torch.zeros(self.num_envs, device=self.device) + joint_ids, _ = self.robot.find_joints([cfg.joint_name]) + self._joint_id = int(joint_ids[0]) + + @property + def command(self) -> torch.Tensor: + return self._target + + def _resample_command(self, env_ids: torch.Tensor) -> None: + lo, hi = self.cfg.range + self._target[env_ids, 0] = ( + torch.rand(len(env_ids), device=self.device) * (hi - lo) + lo + ) + + def _update_command(self) -> None: + pass + + def _update_metrics(self) -> None: + q = self.robot.data.joint_pos[:, self._joint_id] + self.metrics["error"] = torch.abs(q - self._target[:, 0]) + + +@dataclass(kw_only=True) +class TargetAngleCommandCfg(CommandTermCfg): + class_type: type[CommandTerm] = TargetAngleCommand + asset_name: str = "robot" + joint_name: str = "1" + range: tuple[float, float] = (-TESTBENCH_MAX_ANGLE_RAD, TESTBENCH_MAX_ANGLE_RAD) + resampling_time_range: tuple[float, float] = (4.0, 4.0) + + +# ---------------------------------------------------------------------------- +# Rewards +# ---------------------------------------------------------------------------- + + +def target_angle_tracking( + env: ManagerBasedRlEnv, + command_name: str, + std: float, + asset_cfg: SceneEntityCfg, +) -> torch.Tensor: + """exp(-error^2 / std^2) reward for single-joint position tracking.""" + target = env.command_manager.get_command(command_name)[:, 0] + asset: Entity = env.scene[asset_cfg.name] + joint_ids = asset_cfg.joint_ids + q = asset.data.joint_pos[:, joint_ids[0] if isinstance(joint_ids, list) else joint_ids] + if q.dim() > 1: + q = q[:, 0] + err = q - target + return torch.exp(-(err ** 2) / (std ** 2)) + + +# ---------------------------------------------------------------------------- +# Env factory +# ---------------------------------------------------------------------------- + + +def make_testbench_env_cfg(play: bool = False) -> ManagerBasedRlEnvCfg: + asset_cfg_full = SceneEntityCfg("robot", joint_names=("1",)) + + # Observations (base noise copied from microduck velocity env; joint_vel + # noise here is 10× larger to mirror the noisy XL330 firmware velocity read). + joint_pos_term = ObservationTermCfg( + func=base_mdp.joint_pos_rel, + noise=Unoise(n_min=-0.0006, n_max=0.0006), + ) + joint_vel_term = ObservationTermCfg( + func=base_mdp.joint_vel_rel, + # 10× the microduck velocity env's joint_vel noise (0.024 → 0.24) — the + # XL330 firmware velocity read is much noisier than MuJoCo's instantaneous + # qdot, so we inject more observation corruption to force robustness. + noise=Unoise(n_min=-0.24, n_max=0.24), + delay_min_lag=1, + delay_max_lag=1, + delay_update_period=0, + ) + actions_term = ObservationTermCfg(func=base_mdp.last_action) + command_term = ObservationTermCfg( + func=base_mdp.generated_commands, + params={"command_name": "target_angle"}, + ) + + policy_terms = { + "joint_pos": joint_pos_term, + "joint_vel": joint_vel_term, + "actions": actions_term, + "command": command_term, + } + critic_terms = dict(policy_terms) + + observations = { + "policy": ObservationGroupCfg( + terms=policy_terms, + concatenate_terms=True, + enable_corruption=True, + ), + "critic": ObservationGroupCfg( + terms=critic_terms, + concatenate_terms=True, + enable_corruption=False, + ), + } + + # Actions. `TESTBENCH_ACTION_SCALE` env var lets you sweep the action scale + # from the command line without editing this file, e.g. + # TESTBENCH_ACTION_SCALE=0.5 uv run python -m mjlab.scripts.train ... + action_scale = float(os.environ.get("TESTBENCH_ACTION_SCALE", "1.0")) + actions = { + "joint_pos": JointPositionActionCfg( + asset_name="robot", + actuator_names=("1",), + scale=action_scale, + use_default_offset=True, + ), + } + + # Commands + commands = { + "target_angle": TargetAngleCommandCfg( + asset_name="robot", + joint_name="1", + range=(-TESTBENCH_MAX_ANGLE_RAD, TESTBENCH_MAX_ANGLE_RAD), + resampling_time_range=(4.0, 4.0), + debug_vis=False, + ), + } + + # Events + events = { + "reset_joint": EventTermCfg( + func=base_mdp.reset_joints_by_offset, + mode="reset", + params={ + "position_range": (0.0, 0.0), + "velocity_range": (0.0, 0.0), + "asset_cfg": asset_cfg_full, + }, + ), + } + + # Rewards (same regularization recipe as the microduck velocity env) + rewards = { + "track_target": RewardTermCfg( + func=target_angle_tracking, + weight=3.0, + params={ + "command_name": "target_angle", + "std": math.sqrt(0.15), + "asset_cfg": asset_cfg_full, + }, + ), + "dof_pos_limits": RewardTermCfg( + func=base_mdp.joint_pos_limits, + weight=-1.0, + ), + "action_rate_l2": RewardTermCfg( + func=base_mdp.action_rate_l2, + weight=-0.6, + ), + "joint_torques_l2": RewardTermCfg( + func=base_mdp.joint_torques_l2, + weight=-1e-3, + ), + "joint_vel_l2": RewardTermCfg( + func=base_mdp.joint_vel_l2, + weight=-1e-3, + ), + } + + terminations = { + "time_out": TerminationTermCfg(func=base_mdp.time_out, time_out=True), + } + + return ManagerBasedRlEnvCfg( + scene=SceneCfg( + terrain=TerrainImporterCfg(terrain_type="plane"), + entities={"robot": XL330_TESTBENCH_ROBOT_CFG}, + num_envs=1, + extent=2.0, + ), + observations=observations, + actions=actions, + commands=commands, + events=events, + rewards=rewards, + terminations=terminations, + curriculum={}, + viewer=ViewerConfig( + origin_type=ViewerConfig.OriginType.ASSET_BODY, + asset_name="robot", + body_name="arm", + distance=0.8, + elevation=-15.0, + azimuth=90.0, + ), + sim=SimulationCfg( + nconmax=10, + njmax=50, + mujoco=MujocoCfg(timestep=0.005, iterations=10, ls_iterations=20), + ), + decimation=4, + episode_length_s=8.0, + ) + + +MicroduckTestbenchRlCfg = RslRlOnPolicyRunnerCfg( + policy=RslRlPpoActorCriticCfg( + init_noise_std=1.0, + actor_obs_normalization=False, + critic_obs_normalization=False, + actor_hidden_dims=(256, 128, 64), + critic_hidden_dims=(256, 128, 64), + activation="elu", + ), + algorithm=RslRlPpoAlgorithmCfg( + value_loss_coef=1.0, + use_clipped_value_loss=True, + clip_param=0.2, + entropy_coef=0.01, + num_learning_epochs=5, + num_mini_batches=4, + learning_rate=1.0e-3, + schedule="adaptive", + gamma=0.99, + lam=0.95, + desired_kl=0.01, + max_grad_norm=1.0, + ), + wandb_project="mjlab_microduck", + experiment_name="testbench", + run_name="testbench", + save_interval=200, + num_steps_per_env=24, + max_iterations=2000, +) diff --git a/src/mjlab_microduck/train_cli.py b/src/mjlab_microduck/train_cli.py new file mode 100644 index 0000000..0a6fd36 --- /dev/null +++ b/src/mjlab_microduck/train_cli.py @@ -0,0 +1,35 @@ +"""`train` entry point: mjlab's trainer, plus `--hf-jobs` remote submission. + +This project's [project.scripts] `train` shadows mjlab's so the everyday +command grows one flag: + + uv run train Mjlab-Kick-Flat-MicroDuck --env.scene.num-envs 4096 \ + --agent.max_iterations 4000 # local, exactly as before + uv run train Mjlab-Kick-Flat-MicroDuck --env.scene.num-envs 4096 \ + --agent.max_iterations 4000 --hf-jobs # same run, on HF Jobs + +Without --hf-jobs, argv is passed to mjlab.scripts.train untouched. With it, +the submission flags (--flavor, --namespace, --detach, ... see hf_jobs.py) +are consumed here and everything else is forwarded to `uv run train` inside +the job. +""" + +from __future__ import annotations + +import sys + + +def main() -> int | None: + argv = sys.argv[1:] + if "--hf-jobs" in argv: + from mjlab_microduck.hf_jobs import submit + + return submit([a for a in argv if a != "--hf-jobs"]) + + from mjlab.scripts.train import main as mjlab_train_main + + return mjlab_train_main() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_aarch64_cuda_torch.py b/tests/test_aarch64_cuda_torch.py new file mode 100644 index 0000000..ebcda61 --- /dev/null +++ b/tests/test_aarch64_cuda_torch.py @@ -0,0 +1,139 @@ +"""On linux-aarch64 (DGX Spark / GB10) PyPI's torch wheel is CPU-ONLY: +torch.version.cuda is None -> torch.cuda.device_count() == 0 -> mjlab's +select_gpus() indexes an empty list and dies with +`IndexError: list index out of range` BEFORE the first training step +(mjlab/utils/gpu.py:70). + +The fix (pyproject.toml) routes torch to PyTorch's CUDA index, on aarch64 +only. It has two SILENT break points, locked in by these tests — in both +cases `uv sync` succeeds and you only find out when you launch a run: + +1. `torch` must stay a DIRECT dependency: uv applies [tool.uv.sources] to + direct dependencies only, so deleting the `torch==...` line (which looks + redundant, since torch already comes in via mjlab/rsl_rl) makes the + source binding a no-op without any warning. +2. The x86_64 resolution must stay on PyPI, otherwise HF Jobs silently + switch wheels. +""" + +import platform +import shutil +import subprocess +import sys +import tomllib +from pathlib import Path + +import pytest + +_ROOT = Path(__file__).resolve().parents[1] +_CUDA_INDEX = "https://download.pytorch.org/whl/cu" + + +def _packages(name): + lock = tomllib.loads((_ROOT / "uv.lock").read_text()) + return [p for p in lock["package"] if p["name"] == name] + + +def _registry(pkg): + return pkg.get("source", {}).get("registry", "") + + +def _markers(pkg): + return " ".join(pkg.get("resolution-markers", [])) + + +def _aarch64_entry(pkgs): + """The entry whose resolution-markers SELECT linux-aarch64.""" + hits = [ + p + for p in pkgs + if "platform_machine == 'aarch64'" in _markers(p) + and "sys_platform == 'linux'" in _markers(p) + ] + assert len(hits) == 1, f"expected 1 aarch64 entry, found {len(hits)}" + return hits[0] + + +def test_torch_is_a_direct_dependency(): + """Without this, [tool.uv.sources] for torch is a silent no-op.""" + pyproject = tomllib.loads((_ROOT / "pyproject.toml").read_text()) + deps = pyproject["project"]["dependencies"] + assert any(d.split("=")[0].split("[")[0].strip() == "torch" for d in deps), ( + "torch must stay in [project.dependencies]: uv applies " + "[tool.uv.sources] to DIRECT dependencies only. Removing it silently " + "drops aarch64 back onto PyPI's CPU-only wheel." + ) + + +def test_torch_source_is_pinned_to_a_cuda_index_on_aarch64(): + pyproject = tomllib.loads((_ROOT / "pyproject.toml").read_text()) + uv_cfg = pyproject["tool"]["uv"] + assert "torch" in uv_cfg.get("sources", {}), ( + "[tool.uv.sources] no longer has a torch entry -> aarch64 falls back " + "to PyPI's CPU wheel and `train` dies with IndexError in select_gpus()." + ) + sources = uv_cfg["sources"]["torch"] + indexes = {p["name"]: p["url"] for p in uv_cfg.get("index", [])} + for src in sources: + assert "aarch64" in src["marker"], "the torch source must stay aarch64-scoped" + assert indexes[src["index"]].startswith(_CUDA_INDEX), ( + f"index {src['index']} is not a PyTorch CUDA index" + ) + + +def test_lockfile_routes_aarch64_torch_to_cuda_wheels(): + torch_pkgs = _packages("torch") + aarch64 = _aarch64_entry(torch_pkgs) + assert _registry(aarch64).startswith(_CUDA_INDEX), ( + f"torch on aarch64 comes from {_registry(aarch64)!r} — a CPU wheel. " + "Re-run `uv lock` after checking [tool.uv.sources]." + ) + wheels = " ".join(w["url"] for w in aarch64["wheels"]) + assert "aarch64" in wheels, "no aarch64 wheel in the aarch64 torch entry" + assert "%2Bcu" in wheels or "+cu" in wheels, ( + "the aarch64 wheel has no +cuXXX local version -> CPU build" + ) + + +def test_x86_64_resolution_stays_on_pypi(): + """HF Jobs run on x86_64: their resolution must not move.""" + others = [ + p + for p in _packages("torch") + if "platform_machine == 'aarch64'" not in _markers(p) + ] + assert others, "no non-aarch64 torch entry found" + for pkg in others: + assert _registry(pkg) == "https://pypi.org/simple", ( + f"x86_64 torch moved to {_registry(pkg)!r} — HF Jobs would switch " + "wheels." + ) + assert "+cu" not in pkg["version"], "x86_64 torch must not be CUDA-pinned" + + +def test_torch_version_identical_across_platforms(): + """The fix changes only the wheel's SOURCE, not its version: the CUDA + index carries newer builds than the PyPI pin, so a `>=` drags torch + 2.9.1 -> 2.13.0 with nothing having validated that bump.""" + versions = {p["version"].split("+")[0] for p in _packages("torch")} + assert len(versions) == 1, f"torch versions diverge across platforms: {versions}" + + +def _on_spark(): + return ( + sys.platform == "linux" + and platform.machine() == "aarch64" + and shutil.which("nvidia-smi") is not None + and subprocess.run(["nvidia-smi"], capture_output=True).returncode == 0 + ) + + +@pytest.mark.skipif(not _on_spark(), reason="not a linux-aarch64 machine with a GPU") +def test_installed_torch_actually_sees_the_gpu(): + """Direct reproduction of the crash: this is exactly what select_gpus() reads.""" + import torch + + assert torch.cuda.device_count() > 0, ( + f"torch {torch.__version__} (cuda={torch.version.cuda}) sees no GPU " + "although nvidia-smi reports one -> select_gpus() will raise IndexError." + ) diff --git a/tests/test_crouch_glide.py b/tests/test_crouch_glide.py new file mode 100644 index 0000000..af81d71 --- /dev/null +++ b/tests/test_crouch_glide.py @@ -0,0 +1,90 @@ +import math +import torch +from mjlab_microduck.tasks import mdp + + +def test_crouch_height_target_endpoints_are_high(): + # phase 0 (début) et phase ~1 (fin) → hauteur haute (debout) + phase = torch.tensor([0.0, 0.999]) + t = mdp.crouch_height_target(phase, height_low=0.075, height_high=0.11) + assert torch.allclose(t, torch.tensor([0.11, 0.11]), atol=2e-3) + + +def test_crouch_height_target_plateau_is_low(): + # tout le palier [0.375, 0.625] → hauteur basse constante + phase = torch.tensor([0.375, 0.5, 0.624]) + t = mdp.crouch_height_target(phase, height_low=0.075, height_high=0.11) + assert torch.allclose(t, torch.full((3,), 0.075), atol=1e-6) + + +def test_crouch_height_target_descent_midpoint(): + # milieu de la descente (phase = hold_lo/2 = 0.1875) → milieu des deux hauteurs + phase = torch.tensor([0.1875]) + t = mdp.crouch_height_target(phase, height_low=0.075, height_high=0.11) + assert torch.allclose(t, torch.tensor([(0.11 + 0.075) / 2]), atol=1e-6) + + +def test_crouch_height_target_rise_midpoint(): + # milieu de la remontée (phase = 0.8125) → milieu des deux hauteurs + phase = torch.tensor([0.8125]) + t = mdp.crouch_height_target(phase, height_low=0.075, height_high=0.11) + assert torch.allclose(t, torch.tensor([(0.11 + 0.075) / 2]), atol=1e-6) + + +# ── crouch_pose_blend : 4 segments (descente / bas / montée / debout) ───────── +# breakpoints de test : descente [0,0.1), bas [0.1,0.5), montée [0.5,0.6), +# debout [0.6,1.0). +_BLEND = dict(descent_end=0.10, hold_end=0.50, rise_end=0.60) + + +def test_blend_zero_standing_at_start_and_top_hold(): + phase = torch.tensor([0.0, 0.6, 0.8, 0.999]) # début + palier haut + b = mdp.crouch_pose_blend(phase, **_BLEND) + assert torch.allclose(b, torch.zeros(4), atol=1e-6) + + +def test_blend_one_on_low_hold(): + phase = torch.tensor([0.10, 0.3, 0.499]) # palier bas + b = mdp.crouch_pose_blend(phase, **_BLEND) + assert torch.allclose(b, torch.ones(3), atol=1e-6) + + +def test_blend_descent_and_rise_midpoints(): + # milieu descente (0.05 sur [0,0.1)) → 0.5 ; milieu montée (0.55 sur [0.5,0.6)) → 0.5 + phase = torch.tensor([0.05, 0.55]) + b = mdp.crouch_pose_blend(phase, **_BLEND) + assert torch.allclose(b, torch.tensor([0.5, 0.5]), atol=1e-6) + + +def test_reward_is_one_when_height_matches_target(): + # phase 0.5 (plein palier) → cible = height_low ; si com_height == height_low → reward 1 + cmd_cos = torch.tensor([math.cos(2 * math.pi * 0.5)]) # -1 + cmd_sin = torch.tensor([math.sin(2 * math.pi * 0.5)]) # ~0 + com_height = torch.tensor([0.075]) + r = mdp.crouch_glide_reward_from_values( + com_height, cmd_cos, cmd_sin, height_low=0.075, height_high=0.11, std=0.02 + ) + assert torch.allclose(r, torch.tensor([1.0]), atol=1e-3) + + +def test_reward_decays_when_off_by_one_std(): + # à height_low + std de la cible → exp(-1) ≈ 0.368 + cmd_cos = torch.tensor([math.cos(2 * math.pi * 0.5)]) + cmd_sin = torch.tensor([math.sin(2 * math.pi * 0.5)]) + com_height = torch.tensor([0.075 + 0.02]) + r = mdp.crouch_glide_reward_from_values( + com_height, cmd_cos, cmd_sin, height_low=0.075, height_high=0.11, std=0.02 + ) + assert torch.allclose(r, torch.tensor([math.exp(-1.0)]), atol=1e-3) + + +def test_reward_at_phase_zero_expects_high_stance(): + # phase 0 → cible = height_high ; rester debout est récompensé, être accroupi non + cmd_cos = torch.tensor([1.0, 1.0]) # cos(0) + cmd_sin = torch.tensor([0.0, 0.0]) # sin(0) + com_height = torch.tensor([0.11, 0.075]) # debout vs accroupi + r = mdp.crouch_glide_reward_from_values( + com_height, cmd_cos, cmd_sin, height_low=0.075, height_high=0.11, std=0.02 + ) + assert r[0] > 0.99 # debout à phase 0 → ~1 + assert r[1] < 0.2 # accroupi à phase 0 → faible diff --git a/tests/test_descent_speed.py b/tests/test_descent_speed.py new file mode 100644 index 0000000..cf97f1f --- /dev/null +++ b/tests/test_descent_speed.py @@ -0,0 +1,48 @@ +"""descent_speed_reward : récompense la vitesse d'avance vers le bas de la pente +(monde +x), plafonnée à `cap`, nulle si le robot recule/remonte, NaN-safe. +""" + +import torch + +from mjlab_microduck.tasks.mdp import descent_speed_reward + + +class _Data: + def __init__(self, vx): + self.root_link_lin_vel_w = torch.tensor(vx, dtype=torch.float32).reshape(-1, 1).repeat(1, 3) + # seule la colonne 0 (x) est lue ; on met vx en x + self.root_link_lin_vel_w[:, 0] = torch.tensor(vx, dtype=torch.float32) + + +class _Asset: + def __init__(self, data): + self.data = data + + +class _Env: + def __init__(self, vx): + self._a = _Asset(_Data(vx)) + self.scene = self + + def __getitem__(self, _k): + return self._a + + +def test_rewards_forward_speed_up_to_cap(): + out = descent_speed_reward(_Env([0.5]), cap=0.8) + assert abs(float(out[0]) - 0.5) < 1e-6 + + +def test_caps_high_speed(): + out = descent_speed_reward(_Env([1.5]), cap=0.8) + assert abs(float(out[0]) - 0.8) < 1e-6 + + +def test_zero_for_backward_or_uphill(): + out = descent_speed_reward(_Env([-0.4]), cap=0.8) + assert float(out[0]) == 0.0 + + +def test_nan_safe(): + out = descent_speed_reward(_Env([float("nan")]), cap=0.8) + assert float(out[0]) == 0.0 diff --git a/tests/test_ground_pick_cfg.py b/tests/test_ground_pick_cfg.py new file mode 100644 index 0000000..fe9c9ca --- /dev/null +++ b/tests/test_ground_pick_cfg.py @@ -0,0 +1,57 @@ +from mjlab_microduck.tasks.microduck_ground_pick_env_cfg import ( + make_microduck_ground_pick_env_cfg, +) +from mjlab_microduck.tasks.mdp import GroundPickPhaseCommand + + +def test_ground_pick_cfg_task_space_rewards(): + """Objectif espace-tâche : bouche près du sol (sans toucher) + orientée.""" + cfg = make_microduck_ground_pick_env_cfg() + r = cfg.rewards + # proximité bouche->sol (tire vers le bas) + assert "mouth_ground_proximity" in r + assert r["mouth_ground_proximity"].weight == 3.0 + assert r["mouth_ground_proximity"].params["target_height"] == 0.0 + # orientation bouche vers le bas + assert "mouth_perpendicular_to_ground" in r + assert r["mouth_perpendicular_to_ground"].weight == 2.0 + # no-touch : pénalité de contact forte + seuil bas + assert "head_impact_penalty" in r + assert r["head_impact_penalty"].weight == -2.0 + assert r["head_impact_penalty"].params["threshold"] == 1.0 + # pieds au sol ET à plat (anti-bascule sur la cheville) + assert "feet_grounded" in r and r["feet_grounded"].weight == 3.0 + assert "feet_flat" in r and r["feet_flat"].weight == -2.0 + # retour debout + aide au relever (upright gaté sur la remontée) + assert "ground_pick_return_pose_legs" in r + assert "ground_pick_return_pose_neck" in r + assert "return_upright" in r and r["return_upright"].weight == 4.0 + # plus d'approche par pose interpolée + assert "phase_pose_track_head" not in r + assert "phase_pose_track_legs" not in r + + +def test_ground_pick_mouth_payload_wired(): + cfg = make_microduck_ground_pick_env_cfg() + # hook d'application (poids 0) + event de tirage du payload + assert "mouth_payload_force" in cfg.rewards + assert cfg.rewards["mouth_payload_force"].weight == 0.0 + assert "sample_mouth_payload" in cfg.events + assert cfg.events["sample_mouth_payload"].params["min_kg"] == 0.01 + assert cfg.events["sample_mouth_payload"].params["max_kg"] == 0.04 + + +def test_ground_pick_cfg_command_is_phase(): + cfg = make_microduck_ground_pick_env_cfg() + cmd = cfg.commands["twist"] + assert cmd.class_type is GroundPickPhaseCommand + + +def test_ground_pick_rough_variant_builds(): + cfg = make_microduck_ground_pick_env_cfg(rough=True) + assert "mouth_ground_proximity" in cfg.rewards + + +def test_ground_pick_play_variant_builds(): + cfg = make_microduck_ground_pick_env_cfg(play=True) + assert "mouth_ground_proximity" in cfg.rewards diff --git a/tests/test_ground_pick_pose.py b/tests/test_ground_pick_pose.py new file mode 100644 index 0000000..23d23c8 --- /dev/null +++ b/tests/test_ground_pick_pose.py @@ -0,0 +1,123 @@ +import torch +from mjlab_microduck.tasks.mdp import phase_pose_blend + +DESCENT_END, HOLD_END, RISE_END = 0.15, 0.50, 0.65 + + +def test_phase_pose_blend_keypoints(): + phase = torch.tensor([0.0, 0.075, 0.15, 0.30, 0.50, 0.575, 0.65, 0.80]) + b = phase_pose_blend(phase, DESCENT_END, HOLD_END, RISE_END) + expected = torch.tensor([0.0, 0.5, 1.0, 1.0, 1.0, 0.5, 0.0, 0.0]) + assert torch.allclose(b, expected, atol=1e-6), b + + +def test_phase_pose_blend_range(): + phase = torch.linspace(0.0, 1.0, 101) + b = phase_pose_blend(phase, DESCENT_END, HOLD_END, RISE_END) + assert b.min() >= 0.0 and b.max() <= 1.0 + + +from mjlab_microduck.tasks.mdp import phase_pose_track, phase_pose_track_l1 + + +class _FakeData: + def __init__(self, joint_pos, default_pos): + self.joint_pos = joint_pos + self.default_joint_pos = default_pos + + +class _FakeAsset: + def __init__(self, names, joint_pos, default_pos): + self._ids = {n: i for i, n in enumerate(names)} + self.data = _FakeData(joint_pos, default_pos) + + def find_joints(self, query): + # mjlab renvoie (ids, names) ; on ne gère que la requête [name] + (name,) = query + return ([self._ids[name]], [name]) + + +class _FakeCmdMgr: + def __init__(self, cmd): + self._cmd = cmd + + def get_command(self, _name): + return self._cmd + + +class _FakeEnv: + def __init__(self, names, joint_pos, default_pos, phase): + import math + self.device = "cpu" + self.scene = {"robot": _FakeAsset(names, joint_pos, default_pos)} + ang = 2 * math.pi * phase + cmd = torch.tensor([[math.cos(ang), math.sin(ang), 0.0]]) + self.command_manager = _FakeCmdMgr(cmd) + + +NAMES = ["j0", "j1"] +DOWN = {"j0": 1.0, "j1": -1.0} +# HOME (STAND source) = 0 pour les deux joints +HOME = torch.tensor([[0.0, 0.0]]) + + +def _env(cur, phase): + return _FakeEnv(NAMES, torch.tensor([cur]), HOME.clone(), phase) + + +def test_phase_pose_track_perfect_at_down(): + # phase 0.30 -> blend 1 -> cible = DOWN ; cur == DOWN -> gaussienne 1, l1 0 + from mjlab.managers.scene_entity_config import SceneEntityCfg + cfg = SceneEntityCfg("robot") + env = _env([1.0, -1.0], phase=0.30) + r = phase_pose_track(env, target_pose=DOWN, asset_cfg=cfg) + assert torch.allclose(r, torch.tensor([1.0]), atol=1e-6), r + env2 = _env([1.0, -1.0], phase=0.30) + l1 = phase_pose_track_l1(env2, target_pose=DOWN, asset_cfg=cfg) + assert torch.allclose(l1, torch.tensor([0.0]), atol=1e-6), l1 + + +def test_phase_pose_track_l1_at_home_when_down_target(): + # phase 0.30 -> cible DOWN=[1,-1] ; cur=HOME=[0,0] -> l1 = -mean(|1|,|1|) = -1 + from mjlab.managers.scene_entity_config import SceneEntityCfg + cfg = SceneEntityCfg("robot") + env = _env([0.0, 0.0], phase=0.30) + l1 = phase_pose_track_l1(env, target_pose=DOWN, asset_cfg=cfg) + assert torch.allclose(l1, torch.tensor([-1.0]), atol=1e-6), l1 + + +def test_phase_pose_track_returns_to_stand(): + # phase 0.80 -> blend 0 -> cible = HOME ; cur=HOME -> gaussienne 1 + from mjlab.managers.scene_entity_config import SceneEntityCfg + cfg = SceneEntityCfg("robot") + env = _env([0.0, 0.0], phase=0.80) + r = phase_pose_track(env, target_pose=DOWN, asset_cfg=cfg) + assert torch.allclose(r, torch.tensor([1.0]), atol=1e-6), r + + +def test_phase_pose_track_affine_interpolation_nonzero_home(): + # HOME (source) nonzero, blend 0.5 at phase 0.075: + # target = home + 0.5*(down-home) = [0.4,-0.4] + 0.5*([1,-1]-[0.4,-0.4]) = [0.7,-0.7] + from mjlab.managers.scene_entity_config import SceneEntityCfg + cfg = SceneEntityCfg("robot") + home = torch.tensor([[0.4, -0.4]]) + env = _FakeEnv(NAMES, torch.tensor([[0.7, -0.7]]), home.clone(), phase=0.075) + r = phase_pose_track(env, target_pose=DOWN, asset_cfg=cfg) + assert torch.allclose(r, torch.tensor([1.0]), atol=1e-6), r + + +def test_ground_pick_cmd_cfg_has_randomize_phase_default_true(): + from mjlab_microduck.tasks.mdp import GroundPickPhaseCommandCfg + from mjlab.tasks.velocity.mdp import UniformVelocityCommandCfg + # construit une cfg minimale en copiant une cfg velocity par défaut + # NOTE: adapté de `asset_name` (brief) -> `entity_name` (API locale de + # UniformVelocityCommandCfg, qui n'a pas de champ `asset_name`). + base = UniformVelocityCommandCfg( + entity_name="robot", resampling_time_range=(10.0, 10.0), + ranges=UniformVelocityCommandCfg.Ranges( + lin_vel_x=(0.0, 0.0), lin_vel_y=(0.0, 0.0), ang_vel_z=(0.0, 0.0), + ), + ) + cfg = GroundPickPhaseCommandCfg(**{**vars(base)}) + assert cfg.randomize_phase is True + assert cfg.period == 4.0 diff --git a/tests/test_head_pose_bias.py b/tests/test_head_pose_bias.py new file mode 100644 index 0000000..49334bb --- /dev/null +++ b/tests/test_head_pose_bias.py @@ -0,0 +1,176 @@ +"""head_pose_bias_penalty: prices sustained standing droop, never the recovery. + +The velocity-env lesson (run 5yay13u4): instantaneous posture precision is an +unescapable tax on motion. The standup lesson (retired head_impact_penalty): +any head cost active during the ground phase blocks the head-pivot flip. So +this term must (a) charge only the DC bias, (b) accumulate NOTHING while +fallen, and (c) start the clock from ~zero on arrival upright. +""" + +import math + +import torch + +from mjlab_microduck.tasks import mdp as microduck_mdp + + +class _Data: + def __init__(self, n): + self.joint_pos = torch.zeros(n, 6) + self.default_joint_pos = torch.zeros(n, 6) + self.root_link_pos_w = torch.zeros(n, 3) + self.root_link_quat_w = torch.zeros(n, 4) + self.root_link_quat_w[:, 0] = 1.0 # upright + + +class _Asset: + def __init__(self, data): + self.data = data + + +class _Terrain: + def __init__(self, n): + self.env_origins = torch.zeros(n, 3) + + +class _Scene: + def __init__(self, asset, n): + self._asset = asset + self.terrain = _Terrain(n) + + def __getitem__(self, _): + return self._asset + + +class _Cmd: + def __init__(self, n, dim=4): + self.cmd = torch.zeros(n, dim) + + def get_command(self, _): + return self.cmd + + +class _Env: + def __init__(self, n): + self.num_envs = n + self.device = "cpu" + self.step_dt = 0.02 + self.episode_length_buf = torch.full((n,), 10, dtype=torch.long) + self.scene = _Scene(_Asset(_Data(n)), n) + self.command_manager = _Cmd(n) + # Pre-seed the neck-id cache (normally built from the real asset). + self._head_pose_neck_ids = torch.tensor([0, 1, 2, 3]) + self._head_pose_bl_ids = torch.tensor([0, 0, 0, 0]) + self._head_pose_bl_mask = torch.zeros(4) + + +GATE = dict( + gate_height_low=0.09, gate_height_high=0.11, + gate_tilt_full_deg=20.0, gate_tilt_zero_deg=45.0, +) + + +def _set_pose(env, z, pitch_deg): + env.scene._asset.data.root_link_pos_w[:, 2] = z + half = math.radians(pitch_deg) / 2 + q = torch.tensor([math.cos(half), 0.0, math.sin(half), 0.0]) + env.scene._asset.data.root_link_quat_w[:] = q + + +def _run(env, steps, **kw): + out = None + for _ in range(steps): + out = microduck_mdp.head_pose_bias_penalty(env, tau_s=1.0, **kw) + return out + + +def test_prone_accumulates_nothing(): + env = _Env(2) + _set_pose(env, z=0.05, pitch_deg=90.0) # face-down on the ground + env.scene._asset.data.joint_pos[:, :4] = 0.5 # huge head "error" (28°) + out = _run(env, 200, **GATE) # 4 s of prone thrash + assert torch.allclose(out, torch.zeros(2), atol=1e-9), out + + +def test_arrival_starts_from_zero_then_charges_true_bias(): + env = _Env(2) + _set_pose(env, z=0.05, pitch_deg=90.0) + env.scene._asset.data.joint_pos[:, :4] = 0.5 + _run(env, 200, **GATE) # ground phase: EMA stays 0 + _set_pose(env, z=0.117, pitch_deg=0.0) # recovery completes + env.scene._asset.data.joint_pos[:, :4] = math.radians(15) # 15° droop + first = _run(env, 1, **GATE) + assert first.abs().max() < 0.01, f"finish-line wall: {first}" # no arrival spike + settled = _run(env, 300, **GATE) # 6 s standing + assert abs(-settled[0].item() - math.radians(15)) < 0.01 # charges the droop + + +def test_fall_stops_the_charge_immediately(): + env = _Env(2) + _set_pose(env, z=0.117, pitch_deg=0.0) + env.scene._asset.data.joint_pos[:, :4] = math.radians(15) + _run(env, 300, **GATE) + _set_pose(env, z=0.05, pitch_deg=90.0) # falls + out = _run(env, 1, **GATE) + assert torch.allclose(out, torch.zeros(2), atol=1e-9), out + + +def test_ungated_matches_velocity_env_behavior(): + # No gate params -> plain EMA of the raw error (the velocity-env term). + env = _Env(2) + _set_pose(env, z=0.05, pitch_deg=90.0) # pose must be irrelevant + env.scene._asset.data.joint_pos[:, :4] = math.radians(15) + out = _run(env, 300) + assert abs(-out[0].item() - math.radians(15)) < 0.01 + + +def test_reset_clears_the_ema(): + env = _Env(2) + _set_pose(env, z=0.117, pitch_deg=0.0) + env.scene._asset.data.joint_pos[:, :4] = math.radians(15) + _run(env, 300, **GATE) + env.episode_length_buf[0] = 1 # env 0 just reset + out = _run(env, 1, **GATE) + # One step after reset the EMA holds exactly alpha*err (~0.005), while the + # non-reset env still carries the full settled bias (~0.26). + assert out[0].abs() < 0.01 and out[1].abs() > 0.2 + + +def test_standup_cfg_wiring(): + from mjlab_microduck.tasks.microduck_standup_env_cfg import ( + make_microduck_standup_env_cfg, + ) + + cfg = make_microduck_standup_env_cfg() + term = cfg.rewards["head_pose_bias"] + assert term.weight == 0.0 # discovery phase untouched + assert term.params["gate_height_low"] is not None + # Gate values identical to arrival_damping so "standing" means one thing. + ad = cfg.rewards["arrival_damping"].params + assert term.params["gate_height_low"] == ad["height_low"] + assert term.params["gate_tilt_full_deg"] == ad["tilt_full_deg"] + stages = cfg.curriculum["head_pose_bias_weight"].params["weight_stages"] + assert stages[0]["weight"] == 0.0 + assert min(s["step"] for s in stages if s["weight"] > 0) >= 3000 * 24 + + +def test_velocity_cfg_unchanged_no_gate(): + from mjlab_microduck.tasks.microduck_velocity_env_cfg import ( + make_microduck_velocity_env_cfg, + ) + + cfg = make_microduck_velocity_env_cfg() + assert "gate_height_low" not in cfg.rewards["head_pose_bias"].params + + +def test_velstand_inherited_term_is_gated(): + # Velstand episodes survive falls — the inherited velocity-env term must not + # charge the ground phase. + from mjlab_microduck.tasks.microduck_velstand_env_cfg import ( + make_microduck_velstand_env_cfg, + ) + + cfg = make_microduck_velstand_env_cfg() + params = cfg.rewards["head_pose_bias"].params + assert params.get("gate_height_low") is not None + assert params["gate_tilt_zero_deg"] == 40.0 # REWARD_GATE_TILT_DEG diff --git a/tests/test_nan_guard.py b/tests/test_nan_guard.py new file mode 100644 index 0000000..77ea793 --- /dev/null +++ b/tests/test_nan_guard.py @@ -0,0 +1,66 @@ +"""robot_state_is_nan doit attraper un état non-fini n'importe où (joints OU base +OU roues), pas seulement dans joint_pos — sinon un free-joint qui diverge en NaN +échappe au reset et corrompt l'obs critic (base_lin_vel/wheel_vel), ce qui tue +l'entraînement via le check_nan global de rsl_rl. +""" + +import torch + +from mjlab_microduck.tasks.mdp import robot_state_is_nan + + +class _Data: + def __init__(self, n): + self.joint_pos = torch.zeros(n, 4) + self.joint_vel = torch.zeros(n, 4) + self.root_link_pos_w = torch.zeros(n, 3) + self.root_link_quat_w = torch.zeros(n, 4) + self.root_link_lin_vel_w = torch.zeros(n, 3) + self.root_link_ang_vel_w = torch.zeros(n, 3) + + +class _Asset: + def __init__(self, data): + self.data = data + + +class _Scene: + def __init__(self, asset): + self._a = asset + + def __getitem__(self, _key): + return self._a + + +class _Env: + def __init__(self, data): + self.scene = _Scene(_Asset(data)) + + +def test_catches_base_linear_velocity_nan(): + # env 1 : vitesse de base NaN (free-joint divergé) — joint_pos reste fini. + d = _Data(3) + d.root_link_lin_vel_w[1, 0] = float("nan") + out = robot_state_is_nan(_Env(d)) + assert out.tolist() == [False, True, False] + + +def test_catches_base_velocity_inf(): + # inf dans la vitesse angulaire de base (avant qu'il ne devienne NaN). + d = _Data(2) + d.root_link_ang_vel_w[0, 2] = float("inf") + out = robot_state_is_nan(_Env(d)) + assert out.tolist() == [True, False] + + +def test_still_catches_joint_pos_nan(): + # comportement historique préservé. + d = _Data(2) + d.joint_pos[0, 1] = float("nan") + out = robot_state_is_nan(_Env(d)) + assert out.tolist() == [True, False] + + +def test_clean_state_is_not_flagged(): + out = robot_state_is_nan(_Env(_Data(4))) + assert out.tolist() == [False, False, False, False] diff --git a/tests/test_obs_nan_guard.py b/tests/test_obs_nan_guard.py new file mode 100644 index 0000000..12c7d12 --- /dev/null +++ b/tests/test_obs_nan_guard.py @@ -0,0 +1,137 @@ +"""The critic obs must survive a non-finite sensor reading. + +Regression for the 2026-08-21 crash: rsl_rl's check_nan killed a +Velocity2-Rough-Backlash run with "observation group 'critic' contains NaN". +`nan_state` (robot_state_is_nan) only covered joint + root state, but the +critic also carries three SENSOR-derived terms (raycast heights, contact +air-time, contact forces). MuJoCo can return a non-finite contact force while +the integrated robot state is still clean, so the env was never reset and the +NaN reached the runner. +""" + +import torch + +from mjlab_microduck.tasks import mdp as microduck_mdp + + +class _SensorData: + def __init__(self, force=None, heights=None): + self.force = force + self.heights = heights + + +class _Sensor: + def __init__(self, data): + self.data = data + + +class _Scene: + def __init__(self, sensors, asset): + self.sensors = sensors + self._asset = asset + + def __getitem__(self, key): + return self.sensors[key] if key in self.sensors else self._asset + + +class _AssetData: + def __init__(self, n): + self.joint_pos = torch.zeros(n, 4) + self.joint_vel = torch.zeros(n, 4) + self.root_link_pos_w = torch.zeros(n, 3) + self.root_link_quat_w = torch.zeros(n, 4) + self.root_link_lin_vel_w = torch.zeros(n, 3) + self.root_link_ang_vel_w = torch.zeros(n, 3) + + +class _Asset: + def __init__(self, data): + self.data = data + + +class _Env: + def __init__(self, n, force): + self.num_envs = n + self.device = "cpu" + asset = _Asset(_AssetData(n)) + self.scene = _Scene({"feet": _Sensor(_SensorData(force=force))}, asset) + + +def _force(n, bad_env=None, value=float("nan")): + f = torch.ones(n, 2, 3) + if bad_env is not None: + f[bad_env, 0, 0] = value + return f + + +def test_state_only_check_misses_bad_contact_force(): + # This is the gap that killed the run: robot state is clean, force is not. + env = _Env(3, _force(3, bad_env=1)) + assert not microduck_mdp.robot_state_is_nan(env).any() + + +def test_termination_catches_nan_contact_force(): + env = _Env(3, _force(3, bad_env=1)) + out = microduck_mdp.robot_state_is_nan(env, sensor_names=("feet",)) + assert out.tolist() == [False, True, False] + + +def test_termination_catches_inf_contact_force(): + env = _Env(3, _force(3, bad_env=2, value=float("inf"))) + out = microduck_mdp.robot_state_is_nan(env, sensor_names=("feet",)) + assert out.tolist() == [False, False, True] + + +def test_termination_ignores_missing_sensor(): + env = _Env(2, _force(2)) + assert not microduck_mdp.robot_state_is_nan(env, sensor_names=("nope",)).any() + + +def test_finite_helper_sanitizes_nan_and_inf(): + x = torch.tensor([[1.0, float("nan"), float("inf"), float("-inf")]]) + out = microduck_mdp._finite(x) + assert torch.isfinite(out).all() + assert out[0, 0] == 1.0 + + +def test_safe_obs_wrappers_are_wired_into_the_critic(): + # Guards must actually be installed on the env cfg, not just exist. + from mjlab_microduck.tasks.microduck_velocity_env_cfg import ( + make_microduck_velocity_env_cfg, + ) + + cfg = make_microduck_velocity_env_cfg(rough=True) + terms = cfg.observations["critic"].terms + for name in ("foot_contact_forces", "foot_height", "foot_air_time"): + assert terms[name].func.__name__.endswith("_safe"), ( + f"critic/{name} lost its NaN guard" + ) + + +def test_nan_state_termination_watches_the_contact_sensor(): + from mjlab_microduck.tasks.microduck_velocity_env_cfg import ( + make_microduck_velocity_env_cfg, + ) + + cfg = make_microduck_velocity_env_cfg(rough=True) + params = cfg.terminations["nan_state"].params + assert params.get("sensor_names"), "nan_state no longer watches contact forces" + + +def test_standup_env_is_also_guarded(): + # The deployed standing policy trains on StandUp, which builds on mjlab's + # base env (NOT the microduck velocity env) and therefore does not inherit + # the guards wired there. + from mjlab_microduck.tasks.microduck_standup_env_cfg import ( + make_microduck_standup_env_cfg, + ) + + cfg = make_microduck_standup_env_cfg() + terms = cfg.observations["critic"].terms + for name in ("foot_contact_forces", "foot_air_time"): + assert terms[name].func.__name__.endswith("_safe"), ( + f"standup critic/{name} lost its NaN guard" + ) + assert cfg.terminations["nan_state"].params.get("sensor_names"), ( + "standup nan_state no longer watches contact forces" + ) diff --git a/tests/test_roller_crouch_cfg.py b/tests/test_roller_crouch_cfg.py new file mode 100644 index 0000000..25a7e86 --- /dev/null +++ b/tests/test_roller_crouch_cfg.py @@ -0,0 +1,48 @@ +from mjlab_microduck.tasks.microduck_roller_crouch_env_cfg import ( + make_microduck_roller_crouch_env_cfg, +) +from mjlab_microduck.tasks import mdp as microduck_mdp + + +def test_cfg_uses_phase_command(): + cfg = make_microduck_roller_crouch_env_cfg() + cmd = cfg.commands["twist"] + assert isinstance(cmd, microduck_mdp.GroundPickPhaseCommandCfg) + # period must match --ground-pick-period at deploy + # (0.5s down + 2s low + 0.5s up + 2s standing = 5s) + assert cmd.period == 5.0 + # each episode starts standing (phase 0), matching the runtime trigger + assert cmd.randomize_phase is False + + +def test_cfg_has_crouch_and_forward_rewards(): + cfg = make_microduck_roller_crouch_env_cfg() + # pose-based objective (standing<->crouch) + L1 bootstrap + assert "crouch_glide_pose" in cfg.rewards + assert "crouch_glide_pose_l1" in cfg.rewards + assert "forward_speed" in cfg.rewards + # léger penché avant pendant l'accroupi (cible positive = vers l'avant) + assert "crouch_forward_lean" in cfg.rewards + assert cfg.rewards["crouch_forward_lean"].params["target_pitch"] > 0.0 + # the crouch pose is carried by-name and includes the leg fold + cp = cfg.rewards["crouch_glide_pose"].params["crouch_pose"] + assert "left_knee" in cp and "right_knee" in cp + # rewards de patinage actif retirées (pas de stride pendant le trick) + for gone in ("braking", "skating_air_time", "single_support", "glide", "wheel_speed"): + assert gone not in cfg.rewards + + +def test_entry_velocity_applied_safely_via_reset_base(): + # Regression: entry momentum must be injected through reset_base's + # velocity_range (reset_root_state_uniform sets it from the clean default + # state), NOT via a mode="reset" push_by_setting_velocity event, which adds + # to the current (possibly divergent) root velocity and blows the base + # free-joint up to NaN. See the env cfg comment on ENTRY_VELOCITY_X. + cfg = make_microduck_roller_crouch_env_cfg() + # the buggy reset-push event must NOT exist + assert "entry_velocity" not in cfg.events + # forward entry velocity must be carried by reset_base, with a positive range + vr = cfg.events["reset_base"].params.get("velocity_range") + assert vr and "x" in vr + lo, hi = vr["x"] + assert lo > 0.0 and hi >= lo diff --git a/tests/test_roller_slope_cfg.py b/tests/test_roller_slope_cfg.py new file mode 100644 index 0000000..3a438e3 --- /dev/null +++ b/tests/test_roller_slope_cfg.py @@ -0,0 +1,105 @@ +from mjlab_microduck.tasks.microduck_roller_slope_env_cfg import ( + make_microduck_roller_slope_env_cfg, +) +from mjlab_microduck.tasks.slope_terrain import FlatRampTerrainCfg + + +def test_terrain_is_flat_ramp_generator(): + cfg = make_microduck_roller_slope_env_cfg() + assert cfg.scene.terrain.terrain_type == "generator" + gen = cfg.scene.terrain.terrain_generator + assert gen is not None and gen.curriculum is True + assert any(isinstance(st, FlatRampTerrainCfg) for st in gen.sub_terrains.values()) + + +def test_command_is_neutralised(): + cfg = make_microduck_roller_slope_env_cfg() + cmd = cfg.commands["twist"] + assert cmd.rel_standing_envs == 1.0 + assert cmd.rel_heading_envs == 0.0 + assert cmd.ranges.lin_vel_x == (0.0, 0.0) + assert cmd.ranges.lin_vel_y == (0.0, 0.0) + if getattr(cmd.ranges, "ang_vel_z", None) is not None: + assert cmd.ranges.ang_vel_z == (0.0, 0.0) + + +def test_rolling_entry_no_base_push(): + # élan donné en ROULEMENT (reset_rolling_entry), pas en poussée de base + # (base seule + roues immobiles = à-coup de patinage). Donc reset_base ne + # met aucune vitesse de base. + cfg = make_microduck_roller_slope_env_cfg() + assert cfg.events["reset_base"].params["velocity_range"] == {} + assert "reset_rolling_entry" in cfg.events + lo, hi = cfg.events["reset_rolling_entry"].params["speed_range"] + assert 0.0 < lo <= hi <= 0.6 + + +def test_has_heading_hold_reward(): + # aller droit : maintien du yaw de spawn + cfg = make_microduck_roller_slope_env_cfg() + assert "heading_hold" in cfg.rewards + assert cfg.rewards["heading_hold"].weight > 0.0 + + +def test_balance_rewards_no_fixed_pose(): + # équilibre libre : upright/alive/glisse présents, mais PAS de pose fixe + # imposée (il doit pouvoir bouger son centre de gravité pour tenir la pente). + cfg = make_microduck_roller_slope_env_cfg() + for name in ("upright", "alive", "feet_flat", "wheel_glide", "neck_joint_pos_l2"): + assert name in cfg.rewards + assert "standing_pose" not in cfg.rewards + assert "standing_pose_l1" not in cfg.rewards + + +def test_has_wheel_glide_reward_not_base_speed(): + # "se laisser glisser" = rouler (roues), pas récompenser la vitesse de base + # (qu'il atteignait en courant). wheel_glide présent, descent_speed absent. + cfg = make_microduck_roller_slope_env_cfg() + assert "wheel_glide" in cfg.rewards + assert cfg.rewards["wheel_glide"].weight > 0.0 + assert "descent_speed" not in cfg.rewards + + +def test_no_roller_skating_rewards_survive(): + # les rewards de PATINAGE du roller ne doivent pas survivre (heading_hold est + # ré-ajouté volontairement pour aller droit, donc pas dans cette liste). + cfg = make_microduck_roller_slope_env_cfg() + for name in ("wheel_speed", "braking", "skating_air_time", "glide", "forward_lean"): + assert name not in cfg.rewards + + +def test_spawn_yaw_faces_downhill(): + # yaw fixe à 0 : toujours face au bas de la pente (+x), pas le -pi/+pi hérité + cfg = make_microduck_roller_slope_env_cfg() + assert cfg.events["reset_base"].params["pose_range"]["yaw"] == (0.0, 0.0) + + +def test_void_termination_present_no_edge_termination(): + cfg = make_microduck_roller_slope_env_cfg() + assert "fell_into_void" in cfg.terminations + assert "fell_over" in cfg.terminations + # plus de terminaison « bord de terrain » (remplacée par le plat de sortie) + assert "reached_bottom" not in cfg.terminations + assert "out_of_terrain_bounds" not in cfg.terminations + + +def test_obs_nan_policy_sanitize(): + # obs assainies : un NaN de contact rare ne doit pas tuer l'entraînement + cfg = make_microduck_roller_slope_env_cfg() + assert cfg.observations["actor"].nan_policy == "sanitize" + assert cfg.observations["critic"].nan_policy == "sanitize" + + +def test_curriculum_present_and_starts_gentle(): + # curriculum doux->raide : démarre sur la rampe la plus douce, promotion active + cfg = make_microduck_roller_slope_env_cfg() # play=False (entraînement) + assert "terrain_levels" in cfg.curriculum + assert cfg.scene.terrain.max_init_terrain_level == 0 + + +def test_terrain_tile_fits_geometry(): + # la tuile doit contenir plat + rampe_max + sortie + cfg = make_microduck_roller_slope_env_cfg() + gen = cfg.scene.terrain.terrain_generator + st = next(iter(gen.sub_terrains.values())) + assert st.flat_length + st.ramp_length_range[1] + st.runout_length <= gen.size[0] diff --git a/tests/test_roller_standup_cfg.py b/tests/test_roller_standup_cfg.py new file mode 100644 index 0000000..9d8d558 --- /dev/null +++ b/tests/test_roller_standup_cfg.py @@ -0,0 +1,539 @@ +import pytest + +from mjlab_microduck.tasks.microduck_roller_standup_env_cfg import ( + EPISODE_LENGTH_S, + make_microduck_roller_standup_env_cfg, +) +from mjlab_microduck.tasks.microduck_velocity_rollers_env_cfg import ( + make_microduck_velocity_rollers_env_cfg, +) + +# Récompenses de PATINAGE : elles ne doivent pas survivre dans un env de relevé. +SKATING_REWARDS = ( + "wheel_speed", + "braking", + "skating_air_time", + "glide", + "single_support", + "gait_symmetry", + "forward_lean", + "heading_hold", + "feet_flat", + "hip_roll_neutral", + "pose", + "com_height_target", + "upright", +) + + +def test_env_builds_train_and_play(): + assert make_microduck_roller_standup_env_cfg() is not None + assert make_microduck_roller_standup_env_cfg(play=True) is not None + + +def test_episode_is_short(): + # Épisode court : monter puis stabiliser, comme standup (6 s). + cfg = make_microduck_roller_standup_env_cfg() + assert cfg.episode_length_s == EPISODE_LENGTH_S == 6.0 + + +def test_no_skating_rewards_survive(): + cfg = make_microduck_roller_standup_env_cfg() + for name in SKATING_REWARDS: + assert name not in cfg.rewards, f"reward de patinage survivante : {name}" + + +def test_smoothness_regularisers_kept(): + # Gardées de l'héritage roller : le relevé a besoin de douceur sim2real, mais + # body_ang_vel doit rester LÉGER (standup documente qu'à -0.15 il gelait). + cfg = make_microduck_roller_standup_env_cfg() + for name in ( + "action_over_limit", + "self_collisions", + "body_ang_vel", + "angular_momentum", + "action_rate_l2", + "neck_action_rate_l2", + "neck_joint_pos_l2", + "joint_torques_l2", + ): + assert name in cfg.rewards, f"régularisateur perdu : {name}" + assert cfg.rewards["body_ang_vel"].weight == -0.05 + + +def test_twist_command_is_neutralised(): + # Pas de pilotage : la policy se déploie en --standing, où le runtime laisse + # le slot twist à zéro (cf. infer_policy.py:239). + cfg = make_microduck_roller_standup_env_cfg() + cmd = cfg.commands["twist"] + assert cmd.ranges.lin_vel_x == (-0.01, 0.01) + assert cmd.ranges.lin_vel_y == (-0.01, 0.01) + assert cmd.ranges.ang_vel_z == (-0.05, 0.05) + assert cmd.heading_command is False + assert cmd.ranges.heading is None + assert cmd.rel_standing_envs == 0.0 + + +def test_twist_command_is_not_heading_relative(): + # L'env roller installe un RelativeHeadingVelocityCommandCfg (cmd[2] = erreur + # de cap, calculée en interne). Ici cmd[2] doit être un vrai zéro bruité. + from mjlab_microduck.tasks import mdp as microduck_mdp + + cfg = make_microduck_roller_standup_env_cfg() + cmd = cfg.commands["twist"] + assert isinstance(cmd, microduck_mdp.VelocityCommandCommandOnlyCfg) + assert not isinstance(cmd, microduck_mdp.RelativeHeadingVelocityCommandCfg) + + +def test_obs_nan_policy_sanitize(): + # Un contact rare fait diverger le free-joint en NaN : on assainit l'obs + # plutôt que de tuer l'entraînement (même choix que roller_slope). + cfg = make_microduck_roller_standup_env_cfg() + assert cfg.observations["actor"].nan_policy == "sanitize" + assert cfg.observations["critic"].nan_policy == "sanitize" + + +def test_obs_parity_with_roller_env(): + # Parité 61D obligatoire : sinon l'ONNX ne se charge pas dans un slot runtime. + standup = make_microduck_roller_standup_env_cfg() + roller = make_microduck_velocity_rollers_env_cfg() + for grp in ("actor", "critic"): + assert list(standup.observations[grp].terms.keys()) == list( + roller.observations[grp].terms.keys() + ), f"layout d'observation divergent sur le groupe {grp}" + + +def test_terrain_is_plain_plane(): + # Hérité de l'env roller : sol plat, pas de générateur. Pas de variante rough + # pour cette v1. + cfg = make_microduck_roller_standup_env_cfg() + assert cfg.scene.terrain.terrain_type == "plane" + assert cfg.scene.terrain.terrain_generator is None + + +def test_task_is_registered(): + from mjlab.tasks.registry import list_tasks + + import mjlab_microduck.tasks # noqa: F401 (l'import déclenche l'enregistrement) + + assert "Mjlab-RollerStandUp-Flat-MicroDuck" in list_tasks() + + +def test_joint_indices_match_actual_roller_model(): + """Verrou : les roues passives sont intercalées dans l'ordre des joints. + + Réutiliser les indices du standup ([0-4, 9-13]) donnerait des récompenses + qui pointent sur des roues. Ce test compile le vrai MjSpec du robot rollers + et vérifie les noms aux indices utilisés. Pur CPU, pas de sim. + """ + import mujoco + + from mjlab_microduck.robot.microduck_constants import get_walk_rollers_spec + from mjlab_microduck.tasks.microduck_roller_standup_env_cfg import ( + _LEG_JOINTS, + _NECK_JOINTS, + _WHEEL_JOINTS, + ) + + model = get_walk_rollers_spec().compile() + articulated = [ + mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_JOINT, j) + for j in range(model.njnt) + if model.jnt_type[j] != mujoco.mjtJoint.mjJNT_FREE + ] + + assert [articulated[i] for i in _LEG_JOINTS] == [ + "left_hip_yaw", "left_hip_roll", "left_hip_pitch", "left_knee", "left_ankle", + "right_hip_yaw", "right_hip_roll", "right_hip_pitch", "right_knee", "right_ankle", + ] + assert [articulated[i] for i in _NECK_JOINTS] == [ + "neck_pitch", "head_pitch", "head_yaw", "head_roll", + ] + assert [articulated[i] for i in _WHEEL_JOINTS] == [ + "passive_LF_wheel", "passive_LR_wheel", "passive_RF_wheel", "passive_RR_wheel", + ] + # Aucun recouvrement, et les trois listes couvrent tous les joints. + assert len(set(_LEG_JOINTS) | set(_NECK_JOINTS) | set(_WHEEL_JOINTS)) == len(articulated) + + +def test_recovery_rewards_present_with_expected_weights(): + cfg = make_microduck_roller_standup_env_cfg() + expected = { + "pose_stand_legs": 8.0, + "pose_stand_l1": 5.0, + "height_stand": 4.0, + "height_stand_sharp": 4.0, + "height_stand_l1": 30.0, + "com_upward_velocity": 3.0, + # gentle_rise : poids POSITIF. trunk_vertical_accel_penalty renvoie déjà + # -|a_z|, donc un poids négatif en faisait une RÉCOMPENSE de la violence + # (bug mesuré : Episode_Reward/gentle_rise loggée à +0.0118). + "gentle_rise": +0.02, + "upright_linear": 6.0, + "upright_sharp": 6.0, + "standing_composite": 15.0, + # -2e-3 ne contribuait que -0.0002/pas face à +41.6 de tâche : nul. + # -2.0 a mesuré -0.255/pas (run d8rnko6p) — pas le gel, mais on redescend + # à -0.2 pour dégager le budget d'amortissement pendant qu'on isole. + "joint_torque_rate_l2": -0.2, + } + for name, weight in expected.items(): + assert name in cfg.rewards, f"récompense de relevé manquante : {name}" + assert cfg.rewards[name].weight == weight, f"poids inattendu sur {name}" + + +def test_recovery_rewards_use_roller_heights_not_walker_heights(): + from mjlab_microduck.tasks.microduck_roller_standup_env_cfg import ( + ROLLER_PRONE_Z, + ROLLER_STAND_Z, + ) + + cfg = make_microduck_roller_standup_env_cfg() + assert ROLLER_STAND_Z == 0.138 # PAS le 0.115 du modèle sans roues + for name in ("height_stand", "height_stand_sharp", "height_stand_l1"): + assert cfg.rewards[name].params["target_height"] == ROLLER_STAND_Z + assert cfg.rewards["standing_composite"].params["target_height"] == ROLLER_STAND_Z + # com_upward_velocity se coupe juste AU-DESSUS de la cible (10 mm de marge), + # sinon la policy se gare à l'altitude de coupure sans finir la montée. + assert cfg.rewards["com_upward_velocity"].params["max_height"] == ROLLER_STAND_Z + 0.010 + # upright_sharp est gatée entre le repos au sol et la station debout. + assert cfg.rewards["upright_sharp"].params["height_low"] == ROLLER_PRONE_Z + assert cfg.rewards["upright_sharp"].params["height_high"] == ROLLER_STAND_Z + + +def test_pose_rewards_target_legs_only_at_roller_indices(): + from mjlab_microduck.tasks.microduck_roller_standup_env_cfg import _LEG_JOINTS + + cfg = make_microduck_roller_standup_env_cfg() + for name in ("pose_stand_legs", "pose_stand_l1", "standing_composite"): + assert cfg.rewards[name].params["joint_indices"] == _LEG_JOINTS + # target_overrides=None → la cible est HOME (default_joint_pos). + assert cfg.rewards[name].params["target_overrides"] is None + + +def test_trunk_asset_cfgs_are_distinct_objects(): + """mjlab résout et MUTE les SceneEntityCfg en place : un objet partagé entre + plusieurs termes provoque des indices périmés. Chaque terme doit avoir le sien. + """ + cfg = make_microduck_roller_standup_env_cfg() + names = ( + "height_stand", "height_stand_sharp", "height_stand_l1", + "com_upward_velocity", "gentle_rise", "upright_linear", + "upright_sharp", "standing_composite", + ) + seen = [id(cfg.rewards[n].params["asset_cfg"]) for n in names] + assert len(set(seen)) == len(seen), "asset_cfg partagé entre plusieurs termes" + + +def test_starts_from_ground_states(): + # Ventre + dos + debout. Pas de bucket "assis" : il n'existait dans standup + # que pour le hand-off depuis la policy sit, dont il n'y a pas d'équivalent + # roller — et ses sitting_joint_overrides sont des indices du modèle SANS roues. + cfg = make_microduck_roller_standup_env_cfg() + assert "set_ground_state" in cfg.events + params = cfg.events["set_ground_state"].params + assert params["sitting_prob"] == 0.0 + assert params["sitting_joint_overrides"] is None + assert params["face_down_prob"] > 0.0 + assert params["standing_prob"] > 0.0 + # face_up (le dos) démarre à 0 : introduit tard par le curriculum. + assert params["face_up_prob"] == 0.0 + + +def test_ground_state_heights_are_roller_specific(): + cfg = make_microduck_roller_standup_env_cfg() + params = cfg.events["set_ground_state"].params + # Ventre et dos partagent une seule plage de z, mais leurs contacts diffèrent : + # le ventre ne décolle du sol qu'à partir de 0.0752, le dos repose à 0.0475. + # prone_z_min = 0.076 pour éliminer toute interpénétration côté ventre. + assert (params["prone_z_min"], params["prone_z_max"]) == (0.076, 0.09) + # Sous 0.0752 (contact mesuré, pose HOME), le départ ventre commence DANS le + # sol — un pushout de contact que la policy paierait via gentle_rise / + # joint_torque_rate_l2. prone_z_min doit rester au-dessus. + assert params["prone_z_min"] >= 0.0752 + # Debout : hauteur ROLLER (+23 mm vs le modèle sans roues, qui est à 0.11–0.12). + assert params["standing_z_min"] == 0.134 + assert params["standing_z_max"] == 0.144 + assert params["standing_z_min"] < 0.138 < params["standing_z_max"] + + +def test_ground_state_event_runs_after_base_reset(): + # set_ground_state écrase la pose posée par reset_base / reset_robot_joints : + # l'ordre des événements suit l'ordre d'insertion, il doit donc venir APRÈS. + cfg = make_microduck_roller_standup_env_cfg() + order = list(cfg.events.keys()) + assert order.index("set_ground_state") > order.index("reset_base") + assert order.index("set_ground_state") > order.index("reset_robot_joints") + + +def test_no_fall_termination(): + # Le robot DÉMARRE tombé : une terminaison sur inclinaison tuerait l'épisode + # au premier pas. nan_state (hérité) reste, lui. + cfg = make_microduck_roller_standup_env_cfg() + assert "fell_over" not in cfg.terminations + assert "nan_state" in cfg.terminations + + +def test_ground_state_curriculum_ramps_easy_to_hard(): + cfg = make_microduck_roller_standup_env_cfg() + assert "ground_state_mix" in cfg.curriculum + stages = cfg.curriculum["ground_state_mix"].params["param_stages"] + assert cfg.curriculum["ground_state_mix"].params["event_name"] == "set_ground_state" + # Les steps sont croissants et démarrent à 0. + steps = [s["step"] for s in stages] + assert steps[0] == 0 and steps == sorted(steps) and len(set(steps)) == len(steps) + # Le dos (face_up) est introduit tard puis croît de façon monotone. + face_up = [s["params"]["face_up_prob"] for s in stages] + assert face_up[0] == 0.0 + assert face_up == sorted(face_up) + assert face_up[-1] >= 0.35 + # Chaque palier est une distribution valide, et le "déjà debout" ne disparaît + # jamais (sinon la policy se relève puis retombe faute d'apprendre à tenir). + for stage in stages: + p = stage["params"] + total = ( + p["standing_prob"] + p["sitting_prob"] + + p["face_down_prob"] + p["face_up_prob"] + ) + assert abs(total - 1.0) < 1e-9 + assert p["sitting_prob"] == 0.0 + assert p["standing_prob"] > 0.0 + + +def test_wheel_friction_curriculum_is_decreasing(): + """La pièce nouvelle : roues FREINÉES → LIBRES. + + Les roues roulent, donc il n'y a aucune adhérence longitudinale pour pousser + sur le sol. On bootstrappe avec des roulements quasi bloqués (le relevé se + fait comme avec des pieds) puis on rampe vers la vraie valeur. L'env roller, + lui, fait MONTER cette friction (0 → 0.0015) : le sens est bien inversé ici. + """ + cfg = make_microduck_roller_standup_env_cfg() + stages = cfg.curriculum["wheel_friction"].params["ranges_stages"] + assert cfg.curriculum["wheel_friction"].params["event_name"] == "randomize_wheel_friction" + + steps = [s["step"] for s in stages] + assert steps[0] == 0 and steps == sorted(steps) and len(set(steps)) == len(steps) + + lows = [s["ranges"][0] for s in stages] + assert lows == sorted(lows, reverse=True), "la friction doit DÉCROÎTRE" + assert lows[0] >= 0.02, "départ franchement freiné pour bootstrapper le geste" + # Arrivée sur la vraie valeur du roulement (celle de l'env roller). + assert stages[-1]["ranges"] == (0.0015, 0.0015) + for stage in stages: + assert stage["ranges"][0] == stage["ranges"][1] + + +def test_wheel_friction_event_default_matches_stage_zero(): + # Le curriculum manager tourne AVANT les événements de reset à chaque reset + # (y compris le tout premier), et wheel_friction_curriculum défaut lui-même + # sur le palier 0 : cette valeur par défaut de l'événement n'est donc jamais + # lue en pratique. On vérifie juste qu'elle reste cohérente avec le palier 0 + # du curriculum — redondance défensive utile si le curriculum disparaît un + # jour en laissant l'événement en place. + cfg = make_microduck_roller_standup_env_cfg() + stage0 = cfg.curriculum["wheel_friction"].params["ranges_stages"][0]["ranges"] + assert cfg.events["randomize_wheel_friction"].params["ranges"] == stage0 + + +def test_action_rate_ramp_is_the_standup_one_not_the_roller_one(): + # L'env roller monte à -2.0 (gait calme) : c'est un bloqueur de mouvement, + # il ralentit l'action rapide dont le relevé depuis le dos a besoin. On + # reprend la rampe du standup, qui plafonne à -1.0. + cfg = make_microduck_roller_standup_env_cfg() + weights = [ + s["weight"] for s in cfg.curriculum["action_rate_weight"].params["weight_stages"] + ] + assert weights == [-0.4, -0.8, -1.0] + assert cfg.rewards["action_rate_l2"].weight == -0.6 + + +def test_push_curriculum_ramps_from_zero(): + # Poussées héritées (±0.2 m/s), mais rampées : une bourrade dès le pas 0 + # parasite le bootstrap du relevé. + cfg = make_microduck_roller_standup_env_cfg() + assert "push_robot" in cfg.events + stages = cfg.curriculum["push_magnitude"].params["push_stages"] + assert cfg.curriculum["push_magnitude"].params["event_name"] == "push_robot" + assert stages[0]["velocity_range"]["x"] == (0.0, 0.0) + assert stages[-1]["velocity_range"]["x"] == (-0.2, 0.2) + highs = [s["velocity_range"]["x"][1] for s in stages] + assert highs == sorted(highs), "la poussée doit CROÎTRE" + + +def test_inherited_dr_curricula_survive(): + # La DR héritée de l'env roller ne doit pas avoir été perdue en chemin. + cfg = make_microduck_roller_standup_env_cfg() + for name in ("com_range", "head_com_range"): + assert name in cfg.curriculum, f"curriculum de DR perdu : {name}" + for name in ( + "randomize_com", + "randomize_head_com", + "randomize_armature", + "randomize_joint_friction", + "randomize_mass_inertia", + "randomize_wheel_friction", + "encoder_bias", + ): + assert name in cfg.events, f"événement de DR perdu : {name}" + + +# ── Override de play : forcer les départs sur le dos ────────────────────────── +# Sans override, un play ne montre JAMAIS de départ sur le dos : l'env de play est +# reconstruit à neuf, donc common_step_counter repart à 0 et le curriculum applique +# son palier 0, où face_up_prob = 0. Or c'est justement le cas le plus dur, celui +# qu'on veut inspecter à l'œil. STANDUP_PLAY_FACE_UP force le mélange, sur le +# modèle de SLOPE_PLAY_DIFFICULTY dans roller_slope. + + +def test_play_face_up_override_forces_back_starts(monkeypatch): + monkeypatch.setenv("STANDUP_PLAY_FACE_UP", "1.0") + cfg = make_microduck_roller_standup_env_cfg(play=True) + params = cfg.events["set_ground_state"].params + assert params["face_up_prob"] == 1.0 + assert params["face_down_prob"] == 0.0 + assert params["standing_prob"] == 0.0 + # Sans ça, le curriculum réécrirait les probabilités dès le premier reset + # (event_param_curriculum tourne AVANT les événements de reset). + assert "ground_state_mix" not in cfg.curriculum + + +def test_play_face_up_override_splits_remainder_like_final_stage(monkeypatch): + # 0.4 doit reproduire le DERNIER palier du curriculum (0.40 ventre / 0.20 + # debout / 0.40 dos) : le reste est réparti dans le rapport 2:1 de ce palier. + monkeypatch.setenv("STANDUP_PLAY_FACE_UP", "0.4") + params = make_microduck_roller_standup_env_cfg(play=True).events["set_ground_state"].params + assert params["face_up_prob"] == pytest.approx(0.40) + assert params["face_down_prob"] == pytest.approx(0.40) + assert params["standing_prob"] == pytest.approx(0.20) + total = params["face_up_prob"] + params["face_down_prob"] + params["standing_prob"] + assert total == pytest.approx(1.0) + + +def test_play_face_up_override_is_clamped(monkeypatch): + monkeypatch.setenv("STANDUP_PLAY_FACE_UP", "3.0") + params = make_microduck_roller_standup_env_cfg(play=True).events["set_ground_state"].params + assert params["face_up_prob"] == 1.0 + + +def test_play_face_up_override_ignored_during_training(monkeypatch): + # Garde-fou : la variable ne doit JAMAIS toucher l'entraînement, sinon on + # casserait le curriculum easy->hard sans s'en apercevoir. + monkeypatch.setenv("STANDUP_PLAY_FACE_UP", "1.0") + cfg = make_microduck_roller_standup_env_cfg(play=False) + assert cfg.events["set_ground_state"].params["face_up_prob"] == 0.00 + assert "ground_state_mix" in cfg.curriculum + + +def test_play_without_override_keeps_curriculum_mix(monkeypatch): + # Comportement par défaut inchangé : palier 0, pas de départ sur le dos. + monkeypatch.delenv("STANDUP_PLAY_FACE_UP", raising=False) + cfg = make_microduck_roller_standup_env_cfg(play=True) + assert cfg.events["set_ground_state"].params["face_up_prob"] == 0.00 + assert "ground_state_mix" in cfg.curriculum + + +def test_play_face_up_override_invalid_value_falls_back(monkeypatch): + monkeypatch.setenv("STANDUP_PLAY_FACE_UP", "pouet") + cfg = make_microduck_roller_standup_env_cfg(play=True) + assert cfg.events["set_ground_state"].params["face_up_prob"] == 0.00 + assert "ground_state_mix" in cfg.curriculum + + +def test_play_face_up_override_none_keyword_disables(monkeypatch): + monkeypatch.setenv("STANDUP_PLAY_FACE_UP", "none") + cfg = make_microduck_roller_standup_env_cfg(play=True) + assert cfg.events["set_ground_state"].params["face_up_prob"] == 0.00 + assert "ground_state_mix" in cfg.curriculum + + +# ── Anti-violence : corrections après test sur le robot ─────────────────────── +# Symptômes observés (checkpoint 4000+, EN SIMU AUSSI donc pas du sim2real) : +# mouvements très brusques, la tête tape le sol, échec du relevé depuis le dos +# sur le vrai robot. Diagnostic mesuré dans wandb (run vweolw91, iter 7500). + + +def test_already_negative_penalties_use_positive_weights(): + """Verrou sur la classe de bug qui rendait la policy violente. + + mdp.py mélange DEUX conventions de signe : certaines fonctions de pénalité + renvoient une magnitude positive (à multiplier par un poids négatif), d'autres + renvoient déjà une valeur négative (à multiplier par un poids POSITIF). + trunk_vertical_accel_penalty renvoie -|a_z| : avec le poids -0.02 hérité du + standup, le double négatif RÉCOMPENSAIT l'accélération verticale — mesuré à + Episode_Reward/gentle_rise = +0.0118, seul terme de pénalité loggé positif. + """ + cfg = make_microduck_roller_standup_env_cfg() + # Ces trois termes appellent des fonctions qui renvoient déjà du négatif + # (height_l1_penalty, pose_l1_penalty, trunk_vertical_accel_penalty). + for name in ("height_stand_l1", "pose_stand_l1", "gentle_rise"): + assert cfg.rewards[name].weight > 0, ( + f"{name} appelle une fonction qui renvoie déjà du négatif : " + f"un poids négatif en ferait une récompense" + ) + # Et ces termes renvoient une magnitude positive → poids négatif. + for name in ("joint_torques_l2", "joint_torque_rate_l2", "action_rate_l2"): + assert cfg.rewards[name].weight < 0, f"{name} attend un poids négatif" + + +def test_no_ungated_head_impact_penalty(): + """PAS de pénalité d'impact tête non gatée — elle gelait la policy. + + Essayée à -1.0 (valeurs de velstand) : la policy a convergé vers rester + couchée, inerte. Mesuré sur le run d8rnko6p : head_impact_penalty -1.01/pas, + le plus gros terme négatif, pendant que standing_composite s'effondrait de + +14.3 à +3.3. + + L'erreur de raisonnement était de croire qu'une pénalité « ciblée » ne bride + pas le mouvement. Faux ici : pour se relever du dos, ce robot PIVOTE sur sa + tête et ses épaules. La tête est le point d'appui du retournement, pas un + dégât collatéral — la pénaliser, c'est pénaliser le seul mécanisme disponible. + + Si le slam revient une fois le signe de gentle_rise corrigé, la reprise doit + être une pénalité GATÉE EN HAUTEUR (comme upright_sharp l'est), qui épargne la + phase de retournement au sol. Pas celle-ci. + """ + cfg = make_microduck_roller_standup_env_cfg() + assert "head_impact_penalty" not in cfg.rewards + assert "head_impact_contact" not in [s.name for s in cfg.scene.sensors] + + +def test_inherited_sensors_intact(): + # Les capteurs hérités de l'env roller sont utilisés par des récompenses + # gardées (self_collisions) et par les observations. + cfg = make_microduck_roller_standup_env_cfg() + names = [s.name for s in cfg.scene.sensors] + assert "feet_ground_contact" in names + assert "self_collision" in names + + +def test_lazy_prone_optimum_is_documented_risk(): + """Le gel vient d'un optimum paresseux : couché, jambes à HOME, ça paye. + + pose_stand_legs restait à +7.72 sur 8 alors que le robot était allongé — les + jambes sont à HOME en position couchée, donc la récompense de pose est encaissée + quasi gratuitement. C'est le contrepoids qui rend « ne rien faire » viable dès + qu'on ajoute un coût au mouvement. height_stand_l1 (poids +30) est le terme + censé rendre « rester au sol » net négatif : il doit rester fort. + """ + cfg = make_microduck_roller_standup_env_cfg() + assert cfg.rewards["height_stand_l1"].weight >= 30.0 + assert cfg.rewards["com_upward_velocity"].weight > 0.0 + + +def test_damping_terms_are_not_numerically_negligible(): + """Les amortisseurs dédiés ne pesaient littéralement rien. + + Mesuré à convergence : joint_torque_rate_l2 -0.0002/pas et joint_torques_l2 + -0.0001/pas, face à ~+41.6 de récompense de tâche (rapport ~35:1 pour tous + les amortisseurs réunis). joint_torque_rate_l2 est le levier SÛR à remonter : + il pénalise la VARIATION de couple, pas le mouvement, donc il n'agit pas comme + bloqueur de mouvement — le standup documente que body_ang_vel et action_rate, + eux, gelaient le relevé depuis le dos. + """ + cfg = make_microduck_roller_standup_env_cfg() + assert abs(cfg.rewards["joint_torque_rate_l2"].weight) >= 0.1 + # Les bloqueurs de mouvement restent à leurs valeurs « se relève de partout ». + assert cfg.rewards["body_ang_vel"].weight == -0.05 + weights = [s["weight"] for s in cfg.curriculum["action_rate_weight"].params["weight_stages"]] + assert min(weights) >= -1.0, "action_rate au-delà de -1.0 gelait le relevé (standup)" diff --git a/tests/test_slope_curriculum.py b/tests/test_slope_curriculum.py new file mode 100644 index 0000000..b55f4a0 --- /dev/null +++ b/tests/test_slope_curriculum.py @@ -0,0 +1,39 @@ +import torch +from mjlab_microduck.tasks.mdp import slope_move_masks + + +def test_move_up_when_reached_bottom(): + # distance > size_x*0.4 (=3.2) → monte en difficulté + dist = torch.tensor([5.0, 4.1]) + up, down = slope_move_masks(dist, size_x=8.0) + assert bool(up[0]) and bool(up[1]) + assert not bool(down[0]) and not bool(down[1]) + + +def test_move_down_when_stuck_early(): + # distance < size_x*0.2 (=1.6) → descend en difficulté + dist = torch.tensor([0.5, 1.0]) + up, down = slope_move_masks(dist, size_x=8.0) + assert not bool(up[0]) and not bool(up[1]) + assert bool(down[0]) and bool(down[1]) + + +def test_stay_in_middle_band(): + # entre 1.6 et 3.2 → ni haut ni bas + dist = torch.tensor([2.5]) + up, down = slope_move_masks(dist, size_x=8.0) + assert not bool(up[0]) and not bool(down[0]) + + +def test_move_up_boundary_at_04(): + # promotion dès qu'on a descendu > 0.4*size_x (le robot a parcouru une bonne + # partie de la rampe avant d'atteindre le plat de sortie). + dist = torch.tensor([3.3]) + up, down = slope_move_masks(dist, size_x=8.0) + assert bool(up[0]) + assert not bool(down[0]) + + # 3.0 reste dans la bande médiane (3.0 < 3.2 et 3.0 > 1.6) + dist_mid = torch.tensor([3.0]) + up_mid, down_mid = slope_move_masks(dist_mid, size_x=8.0) + assert not bool(up_mid[0]) and not bool(down_mid[0]) diff --git a/tests/test_slope_terrain.py b/tests/test_slope_terrain.py new file mode 100644 index 0000000..7bf32d1 --- /dev/null +++ b/tests/test_slope_terrain.py @@ -0,0 +1,96 @@ +import math + +import mujoco +import numpy as np + +from mjlab_microduck.tasks.slope_terrain import ( + ramp_angle_by_difficulty, + RAMP_DEG_MIN, + RAMP_DEG_MAX, + FlatRampTerrainCfg, +) + + +def test_ramp_angle_endpoints(): + assert math.isclose(ramp_angle_by_difficulty(0.0), math.radians(RAMP_DEG_MIN), abs_tol=1e-9) + assert math.isclose(ramp_angle_by_difficulty(1.0), math.radians(RAMP_DEG_MAX), abs_tol=1e-9) + + +def test_ramp_angle_midpoint(): + mid_deg = (RAMP_DEG_MIN + RAMP_DEG_MAX) / 2.0 + assert math.isclose(ramp_angle_by_difficulty(0.5), math.radians(mid_deg), abs_tol=1e-9) + + +def test_ramp_angle_clamps_out_of_range(): + assert math.isclose(ramp_angle_by_difficulty(-1.0), math.radians(RAMP_DEG_MIN), abs_tol=1e-9) + assert math.isclose(ramp_angle_by_difficulty(2.0), math.radians(RAMP_DEG_MAX), abs_tol=1e-9) + + +def _empty_terrain_spec(): + spec = mujoco.MjSpec() + spec.worldbody.add_body(name="terrain") + return spec + + +def test_flat_ramp_builds_geoms_and_origin_on_flat(): + cfg = FlatRampTerrainCfg(flat_length=2.0) + cfg.size = (15.0, 4.0) # posé normalement par le générateur + spec = _empty_terrain_spec() + out = cfg.function(difficulty=0.5, spec=spec, rng=np.random.default_rng(0)) + # trois géométries : plat de départ + rampe + plat de sortie + assert len(out.geometries) == 3 + # origine SUR la rampe (au-delà du plat), donc x > flat_length et z < 0 + assert out.origin[0] == cfg.flat_length + cfg.spawn_on_ramp + assert out.origin[2] < 0.0 + # z = surface inclinée à spawn_on_ramp du bord (drop = d * tan(angle)) + angle = ramp_angle_by_difficulty(0.5, cfg.deg_min, cfg.deg_max) + assert abs(out.origin[2] - (-cfg.spawn_on_ramp * math.tan(angle))) < 1e-9 + + +def test_flat_ramp_steeper_at_higher_difficulty(): + # à difficulté plus haute, le bout de rampe descend plus bas + cfg = FlatRampTerrainCfg() + cfg.size = (15.0, 4.0) + easy = cfg.function(0.0, _empty_terrain_spec(), np.random.default_rng(0)) + hard = cfg.function(1.0, _empty_terrain_spec(), np.random.default_rng(0)) + # la rampe (2e géométrie) est plus basse (centre z plus négatif) en difficile + # (même rng -> même longueur tirée -> seule la pente change) + assert hard.geometries[1].geom.pos[2] < easy.geometries[1].geom.pos[2] + + +def test_ramp_joins_flat_platform_no_gap(): + # le haut de la rampe doit toucher le bord de la plateforme plate (x=flat_length) : + # centre rampe décalé de -(t/2)*sin(angle) en x. + cfg = FlatRampTerrainCfg() + cfg.size = (15.0, 4.0) + out = cfg.function(0.5, _empty_terrain_spec(), np.random.default_rng(0)) + ramp = out.geometries[1].geom + angle = ramp_angle_by_difficulty(0.5, cfg.deg_min, cfg.deg_max) + surf_half = ramp.size[0] + ramp_len = surf_half * 2.0 * math.cos(angle) + expected_cx = cfg.flat_length + ramp_len / 2.0 - (cfg.thickness / 2.0) * math.sin(angle) + assert abs(ramp.pos[0] - expected_cx) < 1e-6 + + +def test_flat_ramp_runout_at_ramp_bottom(): + # le plat de sortie (3e géométrie) est au niveau du bas de la rampe (z<0), + # et sa surface est plate (box non tourné : quat identité). + cfg = FlatRampTerrainCfg() + cfg.size = (15.0, 4.0) + out = cfg.function(1.0, _empty_terrain_spec(), np.random.default_rng(0)) + runout = out.geometries[2].geom + assert runout.pos[2] < 0.0 # descendu sous le plat de départ + # quaternion identité (plat, pas incliné) + assert math.isclose(runout.quat[0], 1.0, abs_tol=1e-9) + + +def test_ramp_length_within_range(): + cfg = FlatRampTerrainCfg(ramp_length_range=(3.0, 8.0)) + cfg.size = (15.0, 4.0) + # surface de rampe = ramp_length / cos(angle) ; à difficulté 0, angle=2°, + # donc surf_len ~= ramp_length. On vérifie sur plusieurs tirages. + for seed in range(20): + out = cfg.function(0.0, _empty_terrain_spec(), np.random.default_rng(seed)) + surf_half = out.geometries[1].geom.size[0] + ramp_len = surf_half * 2.0 * math.cos(math.radians(2.0)) + assert 3.0 - 1e-6 <= ramp_len <= 8.0 + 1e-6 diff --git a/tests/test_spin.py b/tests/test_spin.py new file mode 100644 index 0000000..73ca47a --- /dev/null +++ b/tests/test_spin.py @@ -0,0 +1,387 @@ +import math + +import torch + +from mjlab_microduck.tasks import mdp + +# Enveloppe du spec : accel 0.5s / régime 1.6s / freinage 0.5s / repos 1.4s sur 4s. +_ENV = dict(rate_max=6.0, accel_end=0.125, hold_end=0.525, brake_end=0.650) + + +def test_spin_rate_segment_boundaries(): + # bornes des 4 segments : 0 au départ, plein régime sur [accel_end, hold_end], + # encore plein régime au tout début du freinage, 0 dès le segment de repos. + phase = torch.tensor([0.0, 0.125, 0.30, 0.525, 0.650, 0.80, 0.999]) + w = mdp.spin_rate_by_phase(phase, **_ENV) + expected = torch.tensor([0.0, 6.0, 6.0, 6.0, 0.0, 0.0, 0.0]) + assert torch.allclose(w, expected, atol=1e-6) + + +def test_spin_rate_accel_ramp_is_increasing(): + phase = torch.linspace(0.0, 0.125, 20) + w = mdp.spin_rate_by_phase(phase, **_ENV) + assert torch.all(w[1:] >= w[:-1]) + # milieu de la rampe de lancement -> moitié de la cible + mid = mdp.spin_rate_by_phase(torch.tensor([0.0625]), **_ENV) + assert torch.allclose(mid, torch.tensor([3.0]), atol=1e-6) + + +def test_spin_rate_brake_ramp_is_decreasing(): + phase = torch.linspace(0.525, 0.6499, 20) + w = mdp.spin_rate_by_phase(phase, **_ENV) + assert torch.all(w[1:] <= w[:-1]) + # milieu du freinage -> moitié de la cible + mid = mdp.spin_rate_by_phase(torch.tensor([0.5875]), **_ENV) + assert torch.allclose(mid, torch.tensor([3.0]), atol=1e-6) + + +def test_spin_rate_integral_matches_trapezoid_shape_at_rate_max_6(): + # Ce test protège la FORME du trapèze (2.1 * rate_max rad par cycle), pas la + # cible réellement expédiée : à rate_max=6.0 (hypothétique, cf. _ENV ci-dessus) + # ça vaut ~4*pi rad = 2 tours. Enveloppe exacte = 12.6 rad, 4*pi = 12.566 -> + # tolérance 1 %. La cible EN VIGUEUR est couverte par le test suivant. + n = 100_000 + phase = (torch.arange(n, dtype=torch.float64) + 0.5) / n + w = mdp.spin_rate_by_phase(phase, **_ENV) + integral = float(w.mean()) * 4.0 + assert abs(integral - 4 * math.pi) / (4 * math.pi) < 0.01 + + +def test_spin_rate_max_integrates_to_2_1_times_itself_per_cycle(): + # LE test qui protège la cible EXPÉDIÉE (mdp.SPIN_RATE_MAX), par opposition au + # test ci-dessus qui ne teste que la forme à rate_max=6.0. L'aire sous + # l'enveloppe sur un cycle vaut 2.1 * rate_max rad, quel que soit rate_max + # (0.25 + 1.6 + 0.25 = 2.1, cf. le commentaire au-dessus des constantes dans + # mdp.py). Avec le réglage actuel (SPIN_RATE_MAX = 3.0) ça donne 6.3 rad, + # soit ~1 tour -- pas 2. Ce test échoue bruyamment si quelqu'un change la + # cible sans réfléchir au nombre de tours que ça implique. + n = 100_000 + phase = (torch.arange(n, dtype=torch.float64) + 0.5) / n + w = mdp.spin_rate_by_phase( + phase, + rate_max=mdp.SPIN_RATE_MAX, + accel_end=mdp.SPIN_ACCEL_END, + hold_end=mdp.SPIN_HOLD_END, + brake_end=mdp.SPIN_BRAKE_END, + ) + integral = float(w.mean()) * mdp.SPIN_PERIOD + expected = 2.1 * mdp.SPIN_RATE_MAX + assert abs(integral - expected) / expected < 0.01 + + +def test_spin_gate_is_normalized_rate(): + phase = torch.tensor([0.0, 0.0625, 0.30, 0.5875, 0.80]) + gate = mdp.spin_gate_by_phase(phase, **_ENV) + rate = mdp.spin_rate_by_phase(phase, **_ENV) + assert torch.allclose(gate, rate / 6.0, atol=1e-6) + assert torch.all(gate >= 0.0) and torch.all(gate <= 1.0) + + +def test_spin_gate_is_zero_over_the_whole_rest_segment(): + # pendant le repos aucune amorce ne doit pousser au ciseau -> porte nulle, + # c'est ce qui donne une sortie de trick propre vers la policy roller. + phase = torch.linspace(0.650, 0.999, 50) + gate = mdp.spin_gate_by_phase(phase, **_ENV) + assert torch.allclose(gate, torch.zeros_like(gate), atol=1e-6) + + +# ── faux env minimal : permet de tester les wrappers de reward sans MuJoCo ──── +class _FakeData: + def __init__(self, ang_vel_b=None, lin_vel_b=None, joint_pos=None, joint_vel=None): + self.root_link_ang_vel_b = ang_vel_b + self.root_link_lin_vel_b = lin_vel_b + self.joint_pos = joint_pos + self.joint_vel = joint_vel + + +class _FakeEntity: + """Entity minimale : find_joints() résout par nom depuis un dict {nom: index}.""" + + def __init__(self, data, joint_ids=None): + self.data = data + self._joint_ids = joint_ids or {} + + def find_joints(self, pattern): + import re + + names = list(self._joint_ids.keys()) + if isinstance(pattern, (list, tuple)): + matched = [n for n in names if n in pattern] + else: + matched = [n for n in names if re.fullmatch(pattern, n)] + assert matched, f"aucun joint ne matche {pattern!r} parmi {names}" + return [self._joint_ids[n] for n in matched], matched + + +class _FakeCommandManager: + def __init__(self, cmd): + self._cmd = cmd + + def get_command(self, name): + return self._cmd + + +class _FakeSensorData: + def __init__(self, current_contact_time): + self.current_contact_time = current_contact_time + + +class _FakeSensor: + def __init__(self, current_contact_time): + self.data = _FakeSensorData(current_contact_time) + + +class _FakeEnv: + def __init__(self, entity, cmd=None, sensors=None): + self.scene = {"robot": entity, **(sensors or {})} + self.command_manager = _FakeCommandManager(cmd) + self.device = "cpu" + + +def _phase_cmd(phases): + """Commande du slot telle que la voit la policy : [cos(2*pi*phi), sin(...), 0].""" + p = torch.as_tensor(phases, dtype=torch.float32) + return torch.stack( + [torch.cos(2 * math.pi * p), torch.sin(2 * math.pi * p), torch.zeros_like(p)], + dim=-1, + ) + + +# ── phase recover ──────────────────────────────────────────────────────────── +def test_spin_phase_from_command_roundtrip(): + phases = torch.tensor([0.0, 0.125, 0.4, 0.65, 0.9]) + got = mdp.spin_phase_from_command(_phase_cmd(phases)) + assert torch.allclose(got, phases, atol=1e-5) + + +# ── spin_rate_track ────────────────────────────────────────────────────────── +def test_spin_rate_reward_peaks_on_exact_match(): + w = torch.tensor([6.0, 6.0]) + target = torch.tensor([6.0, 4.5]) + r = mdp.spin_rate_reward_from_values(w, target, std=1.5) + # erreur nulle -> 1.0 ; erreur = 1 std -> exp(-1) + assert torch.allclose(r, torch.tensor([1.0, math.exp(-1.0)]), atol=1e-6) + + +def test_spin_rate_track_uses_yaw_and_phase(): + # phase 0.30 = plein régime -> cible SPIN_RATE_MAX (3.0 rad/s, défaut appelé + # ici implicitement). Un robot qui tourne à la cible doit toucher 1.0 ; un + # robot immobile doit être largement en dessous (exp(-(3/1.5)^2) = 0.018 au + # réglage courant : std=1.5 reste bien calibré à cette cible, cf. mdp.py). + ang = torch.tensor([[0.0, 0.0, mdp.SPIN_RATE_MAX], [0.0, 0.0, 0.0]]) + env = _FakeEnv( + _FakeEntity(_FakeData(ang_vel_b=ang)), cmd=_phase_cmd([0.30, 0.30]) + ) + r = mdp.spin_rate_track(env, std=1.5) + assert r[0] > 0.99 + assert r[1] < 0.05 + + +def test_spin_rate_track_wants_stillness_during_rest(): + # phase 0.80 = repos -> cible 0 : tourner encore est puni, être immobile payé. + ang = torch.tensor([[0.0, 0.0, 0.0], [0.0, 0.0, 6.0]]) + env = _FakeEnv( + _FakeEntity(_FakeData(ang_vel_b=ang)), cmd=_phase_cmd([0.80, 0.80]) + ) + r = mdp.spin_rate_track(env, std=1.5) + assert r[0] > 0.99 + assert r[1] < 0.01 + + +def test_spin_rate_track_penalizes_wrong_direction(): + # tourner à -SPIN_RATE_MAX (horaire) quand on demande +SPIN_RATE_MAX doit + # être pire qu'immobile. + ang = torch.tensor([[0.0, 0.0, -mdp.SPIN_RATE_MAX], [0.0, 0.0, 0.0]]) + env = _FakeEnv( + _FakeEntity(_FakeData(ang_vel_b=ang)), cmd=_phase_cmd([0.30, 0.30]) + ) + r = mdp.spin_rate_track(env, std=1.5) + assert r[0] < r[1] + + +# ── spin_rate_l1 ───────────────────────────────────────────────────────────── +def test_spin_rate_l1_is_negative_absolute_error(): + # phase 0.30 = plein régime -> cible SPIN_RATE_MAX (3.0 rad/s, défaut). + ang = torch.tensor([[0.0, 0.0, mdp.SPIN_RATE_MAX], [0.0, 0.0, 1.0]]) + env = _FakeEnv( + _FakeEntity(_FakeData(ang_vel_b=ang)), cmd=_phase_cmd([0.30, 0.30]) + ) + r = mdp.spin_rate_l1(env) + expected = torch.tensor([0.0, -(mdp.SPIN_RATE_MAX - 1.0)]) + assert torch.allclose(r, expected, atol=1e-5) + + +# ── spin_stay_in_place ─────────────────────────────────────────────────────── +def test_spin_stay_in_place_is_squared_planar_speed(): + # phase 0.30 = plein régime -> coût plein tarif + lin = torch.tensor([[0.0, 0.0, 0.0], [0.3, 0.4, 9.0]]) + env = _FakeEnv( + _FakeEntity(_FakeData(lin_vel_b=lin)), cmd=_phase_cmd([0.30, 0.30]) + ) + c = mdp.spin_stay_in_place(env) + # 0.3^2 + 0.4^2 = 0.25 ; la composante z est ignorée + assert torch.allclose(c, torch.tensor([0.0, 0.25]), atol=1e-6) + + +def test_spin_stay_in_place_is_attenuated_during_the_launch_ramp(): + # Même vitesse, deux phases : dans la rampe de lancement (0.05 < accel_end) le + # coût est multiplié par launch_scale, en régime (0.30) il est plein tarif. + # C'est ce qui empêche ce terme de s'opposer à l'injection de moment angulaire. + lin = torch.tensor([[0.3, 0.4, 0.0], [0.3, 0.4, 0.0]]) + env = _FakeEnv( + _FakeEntity(_FakeData(lin_vel_b=lin)), cmd=_phase_cmd([0.05, 0.30]) + ) + c = mdp.spin_stay_in_place(env, launch_scale=0.2, accel_end=0.125) + # 0.25 * 0.2 = 0.05 + assert torch.allclose(c, torch.tensor([0.05, 0.25]), atol=1e-6) + assert c[0] < c[1] + + +def test_spin_stay_in_place_is_full_price_during_rest(): + # Pendant le repos on veut le robot IMMOBILE : ce terme ne doit PAS être éteint, + # contrairement aux amorces (spin_wheel_differential, spin_grounded, ciseau). + lin = torch.tensor([[0.3, 0.4, 0.0]]) + env = _FakeEnv(_FakeEntity(_FakeData(lin_vel_b=lin)), cmd=_phase_cmd([0.80])) + c = mdp.spin_stay_in_place(env) + assert torch.allclose(c, torch.tensor([0.25]), atol=1e-6) + + +# ── spin_wheel_differential ────────────────────────────────────────────────── +_WHEEL_IDS = { + "passive_LF_wheel": 0, + "passive_LR_wheel": 1, + "passive_RF_wheel": 2, + "passive_RR_wheel": 3, +} + + +def _wheel_env(vel_rows, phases): + vel = torch.tensor(vel_rows, dtype=torch.float32) + entity = _FakeEntity(_FakeData(joint_vel=vel), joint_ids=_WHEEL_IDS) + return _FakeEnv(entity, cmd=_phase_cmd(phases)) + + +def test_wheel_differential_rewards_counter_rolling_wheels(): + # anti-horaire : roues GAUCHE négatives (patin part en arrière), DROITE + # positives -> omega_D - omega_G > 0 -> récompensé. + env = _wheel_env( + [ + [-10.0, -10.0, 10.0, 10.0], # bon différentiel + [10.0, 10.0, 10.0, 10.0], # tout droit : différentiel nul + [10.0, 10.0, -10.0, -10.0], # différentiel inversé (horaire) + ], + [0.30, 0.30, 0.30], + ) + r = mdp.spin_wheel_differential(env, omega_scale=20.0) + assert r[0] > 0.5 + assert torch.allclose(r[1], torch.tensor(0.0), atol=1e-6) + assert torch.allclose(r[2], torch.tensor(0.0), atol=1e-6) + + +def test_wheel_differential_is_gated_off_during_rest(): + # même bon différentiel, mais en phase de repos -> porte nulle -> pas payé. + env = _wheel_env([[-10.0, -10.0, 10.0, 10.0]], [0.80]) + r = mdp.spin_wheel_differential(env, omega_scale=20.0) + assert torch.allclose(r, torch.zeros(1), atol=1e-6) + + +def test_wheel_differential_saturates(): + # tanh : au-delà de omega_scale la reward sature, pas de course à la vitesse. + env = _wheel_env( + [[-10.0, -10.0, 10.0, 10.0], [-100.0, -100.0, 100.0, 100.0]], [0.30, 0.30] + ) + r = mdp.spin_wheel_differential(env, omega_scale=20.0) + assert r[1] > r[0] + assert r[1] <= 1.0 + + +def test_wheel_differential_from_values_is_pure(): + diff = torch.tensor([20.0, 0.0, -20.0]) + gate = torch.ones(3) + r = mdp.spin_wheel_differential_from_values(diff, gate, omega_scale=20.0) + expected = torch.tensor([math.tanh(1.0), 0.0, 0.0]) + assert torch.allclose(r, expected, atol=1e-6) + + +# ── spin_grounded ──────────────────────────────────────────────────────────── +def test_spin_grounded_rewards_both_blades_down_and_is_gated(): + contact = torch.tensor([[0.2, 0.3], [0.2, 0.0], [0.0, 0.0], [0.2, 0.3]]) + entity = _FakeEntity(_FakeData()) + env = _FakeEnv( + entity, + cmd=_phase_cmd([0.30, 0.30, 0.30, 0.80]), + sensors={"feet_ground_contact": _FakeSensor(contact)}, + ) + r = mdp.spin_grounded(env, sensor_name="feet_ground_contact") + # deux lames au sol en régime -> porte 1.0 ; une seule ou zéro -> 0 ; + # deux lames au sol mais en repos -> porte 0. + assert torch.allclose(r, torch.tensor([1.0, 0.0, 0.0, 0.0]), atol=1e-6) + + +# ── leg_antisymmetry ───────────────────────────────────────────────────────── +_LEG_IDS = { + "left_hip_pitch": 0, + "left_knee": 1, + "right_hip_pitch": 2, + "right_knee": 3, +} + + +def _leg_env(pos_rows, phases): + pos = torch.tensor(pos_rows, dtype=torch.float32) + entity = _FakeEntity(_FakeData(joint_pos=pos), joint_ids=_LEG_IDS) + return _FakeEnv(entity, cmd=_phase_cmd(phases)) + + +def test_leg_antisymmetry_prefers_scissor_over_mirror(): + # convention miroir : q_G = -q_D est une pose SYMÉTRIQUE (mauvais ici), + # q_G = q_D est le CISEAU (bon ici). Valeur = -mean|q_G - q_D|, donc <= 0. + env = _leg_env( + [ + [0.4, 0.3, 0.4, 0.3], # ciseau parfait : q_G == q_D -> 0.0 + [0.4, 0.3, -0.4, -0.3], # miroir : écart 0.8 et 0.6 -> -0.7 + ], + [0.30, 0.30], + ) + r = mdp.leg_antisymmetry(env) + assert torch.allclose(r, torch.tensor([0.0, -0.7]), atol=1e-6) + assert r[0] > r[1] + + +def test_leg_antisymmetry_is_gated_off_during_rest(): + # en repos la porte est nulle : rien ne pousse au ciseau, station neutre libre. + env = _leg_env([[0.4, 0.3, -0.4, -0.3]], [0.80]) + r = mdp.leg_antisymmetry(env) + assert torch.allclose(r, torch.zeros(1), atol=1e-6) + + +# ── neck_joint_pos_l2 : paramètre pattern ──────────────────────────────────── +_NECK_IDS = { + "neck_pitch": 0, + "head_pitch": 1, + "head_roll": 2, + "head_yaw": 3, +} + + +def test_neck_joint_pos_l2_pattern_can_exclude_head_yaw(): + class _NeckData(_FakeData): + def __init__(self, joint_pos, default_joint_pos): + super().__init__(joint_pos=joint_pos) + self.default_joint_pos = default_joint_pos + + pos = torch.tensor([[0.0, 0.0, 0.0, 1.0]]) # seul head_yaw dévie, de 1 rad + default = torch.zeros(1, 4) + entity = _FakeEntity(_NeckData(pos, default), joint_ids=_NECK_IDS) + env = _FakeEnv(entity) + + # motif par défaut : head_yaw compté -> coût 1.0 + assert torch.allclose( + mdp.neck_joint_pos_l2(env), torch.tensor([1.0]), atol=1e-6 + ) + # motif du spin : head_yaw exclu -> coût 0.0 (tête libre en lacet) + assert torch.allclose( + mdp.neck_joint_pos_l2(env, pattern=r"^(neck_pitch|head_pitch|head_roll)$"), + torch.tensor([0.0]), + atol=1e-6, + ) diff --git a/tests/test_spin_cfg.py b/tests/test_spin_cfg.py new file mode 100644 index 0000000..888e939 --- /dev/null +++ b/tests/test_spin_cfg.py @@ -0,0 +1,110 @@ +from mjlab_microduck.tasks import mdp as microduck_mdp +from mjlab_microduck.tasks.microduck_spin_env_cfg import ( + make_microduck_spin_env_cfg, + MicroduckSpinRlCfg, +) + + +def test_cfg_uses_phase_command_with_runtime_default_period(): + cfg = make_microduck_spin_env_cfg() + cmd = cfg.commands["twist"] + assert isinstance(cmd, microduck_mdp.GroundPickPhaseCommandCfg) + # 4.0 s = le défaut de --ground-pick-period : rien à passer au runtime + assert cmd.period == 4.0 + # chaque épisode démarre à phase 0 (debout), comme le bouton au déploiement + assert cmd.randomize_phase is False + + +def test_cfg_has_the_spin_rewards(): + cfg = make_microduck_spin_env_cfg() + for name in ( + "spin_rate_track", + "spin_rate_l1", + "spin_stay_in_place", + "spin_wheel_differential", + "spin_grounded", + "leg_antisymmetry", + ): + assert name in cfg.rewards, name + # objectif principal avec un poids dominant + assert cfg.rewards["spin_rate_track"].weight == 6.0 + # sur-place est un COÛT + assert cfg.rewards["spin_stay_in_place"].weight < 0.0 + + +def test_stay_in_place_is_attenuated_during_the_launch_ramp(): + # Renforcé à -3.0, ce terme s'opposerait à l'injection de moment angulaire s'il + # était plein tarif pendant la rampe de lancement : il doit y être atténué. + cfg = make_microduck_spin_env_cfg() + params = cfg.rewards["spin_stay_in_place"].params + assert 0.0 < params["launch_scale"] < 1.0 + assert params["accel_end"] == microduck_mdp.SPIN_ACCEL_END + # cible positive = anti-horaire (le sens est porté par l'enveloppe) + assert microduck_mdp.SPIN_RATE_MAX > 0.0 + + +def test_angular_momentum_reward_is_removed(): + # Régression : angular_momentum_penalty pénalise la NORME 3D du moment + # angulaire, elle combattrait directement le spin. Elle doit être absente. + cfg = make_microduck_spin_env_cfg() + assert "angular_momentum" not in cfg.rewards + # body_ang_vel ne pénalise que x/y -> elle reste, elle mate le ballant + assert "body_ang_vel" in cfg.rewards + + +def test_head_yaw_is_free_to_act_as_a_flywheel(): + cfg = make_microduck_spin_env_cfg() + pattern = cfg.rewards["neck_joint_pos_l2"].params["pattern"] + assert "head_yaw" not in pattern + + +def test_entry_velocity_allows_standstill_and_slow_roll(): + cfg = make_microduck_spin_env_cfg() + # jamais via un push en mode reset (régression NaN du crouch) + assert "entry_velocity" not in cfg.events + lo, hi = cfg.events["reset_base"].params["velocity_range"]["x"] + assert lo == 0.0 and hi > 0.0 + + +def test_symmetry_augmentation_is_disabled(): + # la symétrie G/D transformerait un spin à gauche en spin à droite + assert MicroduckSpinRlCfg.algorithm.symmetry_cfg is None + + +def test_leg_antisymmetry_shaping_decays(): + cfg = make_microduck_spin_env_cfg() + stages = cfg.curriculum["leg_antisym_weight"].params["weight_stages"] + weights = [s["weight"] for s in stages] + assert weights[0] == cfg.rewards["leg_antisymmetry"].weight + assert weights == sorted(weights, reverse=True) + assert weights[-1] < weights[0] + + +def test_actor_observation_keeps_the_61d_slot_layout(): + # condition pour que l'ONNX charge dans le slot du runtime. L'égalité exacte + # des dimensions avec le crouch est vérifiée par test_obs_parity_with_roller_crouch + # ci-dessous ; ici on vérifie la structure. + cfg = make_microduck_spin_env_cfg() + terms = cfg.observations["actor"].terms + assert "base_lin_vel" not in terms + assert "height_scan" not in terms + for padded in ("head_command", "body_command"): + assert padded in terms + assert terms["head_command"].params["dim"] == 4 + assert terms["body_command"].params["dim"] == 6 + + +def test_obs_parity_with_roller_crouch(): + # Parité de layout obligatoire : sinon l'ONNX exporté ne charge pas dans le + # slot du runtime. Contrairement au test de structure ci-dessus, celui-ci + # compare l'ordre EXACT des termes, groupe par groupe. + from mjlab_microduck.tasks.microduck_roller_crouch_env_cfg import ( + make_microduck_roller_crouch_env_cfg, + ) + + spin = make_microduck_spin_env_cfg() + crouch = make_microduck_roller_crouch_env_cfg() + for grp in ("actor", "critic"): + assert list(spin.observations[grp].terms.keys()) == list( + crouch.observations[grp].terms.keys() + ), f"layout d'observation divergent sur le groupe {grp}" diff --git a/tests/test_swizzle_head_cfg.py b/tests/test_swizzle_head_cfg.py new file mode 100644 index 0000000..52b31f3 --- /dev/null +++ b/tests/test_swizzle_head_cfg.py @@ -0,0 +1,42 @@ +from mjlab.tasks.velocity import mdp +from mjlab_microduck.tasks.microduck_velocity_swizzle_env_cfg import ( + make_microduck_velocity_swizzle_env_cfg, +) +from mjlab_microduck.tasks.microduck_velocity_rollers_env_cfg import ( + make_microduck_velocity_rollers_env_cfg, +) + + +def test_swizzle_head_control_wired(): + cfg = make_microduck_velocity_swizzle_env_cfg() + roller_cfg = make_microduck_velocity_rollers_env_cfg() + + # Head-pose command term exists. + assert "head_pose" in cfg.commands + + # head_command obs is the REAL command (not zero-padded) on both groups. + for group in ("actor", "critic"): + term = cfg.observations[group].terms["head_command"] + assert term.func is mdp.generated_commands + assert term.params["command_name"] == "head_pose" + + # head_pose_tracking reward exists. + assert "head_pose_tracking" in cfg.rewards + + # The two HOME-pullers that would fight the head command are handled: + # - neck_joint_pos_l2 removed + assert "neck_joint_pos_l2" not in cfg.rewards + # - pose reward scoped to leg joints via a negative-lookahead regex that + # excludes neck/head (and passive wheels) + pose_joints = cfg.rewards["pose"].params["asset_cfg"].joint_names + assert any( + "(?!" in j and "neck" in j and "head" in j for j in pose_joints + ), f"pose reward not scoped away from neck/head: {pose_joints}" + + # Pose reward function unchanged (not swapped to a different function) + assert cfg.rewards["pose"].func is roller_cfg.rewards["pose"].func, \ + "pose reward function was swapped (should only scope asset_cfg)" + + # Late head curricula exist. + assert "head_pose_tracking_weight" in cfg.curriculum + assert "head_pose_range" in cfg.curriculum diff --git a/tests/test_wheel_glide.py b/tests/test_wheel_glide.py new file mode 100644 index 0000000..2e51c76 --- /dev/null +++ b/tests/test_wheel_glide.py @@ -0,0 +1,65 @@ +"""wheel_glide_reward : récompense le ROULEMENT des roues vers l'avant (glisse +par gravité), plafonné à cap_speed, nul si les roues reculent, NaN-safe. +Indépendant de toute commande (la tâche pente a une commande nulle). +""" + +import re + +import torch + +from mjlab_microduck.tasks.mdp import wheel_glide_reward + +# Current model joint names (post 2026-07 re-export: underscore spelling). +_WHEELS = {"passive_LF_wheel": 0, "passive_LR_wheel": 1, "passive_RF_wheel": 2, "passive_RR_wheel": 3} + + +class _Data: + def __init__(self, omegas): + # 4 roues, colonnes 0..3 dans l'ordre LF,LR,RF,RR + self.joint_vel = torch.tensor([omegas], dtype=torch.float32) + + +class _Asset: + def __init__(self, data): + self.data = data + + def find_joints(self, pattern): + # Regex resolution like the real Entity.find_joints (mdp queries use + # spelling-tolerant patterns such as "passive_LF_?wheel"). + ids = [i for name, i in _WHEELS.items() if re.fullmatch(pattern, name)] + assert ids, pattern + return ids, None + + +class _Env: + def __init__(self, omegas): + self._a = _Asset(_Data(omegas)) + + def __getitem__(self, _k): + return self._a + + @property + def scene(self): + return self + + +def test_rewards_forward_roll_below_cap(): + # omega=10 rad/s sur les 4 -> vitesse = 10*0.0175 = 0.175 m/s (< cap 0.35) + out = wheel_glide_reward(_Env([10.0, 10.0, 10.0, 10.0]), cap_speed=0.35) + assert abs(float(out[0]) - 0.175) < 1e-6 + + +def test_caps_fast_roll(): + # omega=40 -> 0.7 m/s -> plafonné à 0.35 + out = wheel_glide_reward(_Env([40.0, 40.0, 40.0, 40.0]), cap_speed=0.35) + assert abs(float(out[0]) - 0.35) < 1e-6 + + +def test_zero_when_wheels_roll_backward(): + out = wheel_glide_reward(_Env([-10.0, -10.0, -10.0, -10.0]), cap_speed=0.35) + assert float(out[0]) == 0.0 + + +def test_nan_safe(): + out = wheel_glide_reward(_Env([float("nan"), 10.0, 10.0, 10.0]), cap_speed=0.35) + assert float(out[0]) == 0.0 diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..2d20aec --- /dev/null +++ b/uv.lock @@ -0,0 +1,2252 @@ +version = 1 +revision = 3 +requires-python = "==3.12.*" +resolution-markers = [ + "platform_machine == 'aarch64' and sys_platform == 'linux'", + "platform_machine != 'aarch64' or sys_platform != 'linux'", +] + +[manifest] +overrides = [ + { name = "onnx", specifier = ">=1.20.1" }, + { name = "protobuf", specifier = ">=4.0,<7.0" }, + { name = "zmq", marker = "python_full_version < '3'" }, +] + +[[package]] +name = "absl-py" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/10/2a/c93173ffa1b39c1d0395b7e842bbdc62e556ca9d8d3b5572926f3e4ca752/absl_py-2.3.1.tar.gz", hash = "sha256:a97820526f7fbfd2ec1bce83f3f25e3a14840dac0d8e02a0b71cd75db3f77fc9", size = 116588, upload-time = "2025-07-03T09:31:44.05Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/aa/ba0014cc4659328dc818a28827be78e6d97312ab0cb98105a770924dc11e/absl_py-2.3.1-py3-none-any.whl", hash = "sha256:eeecf07f0c2a93ace0772c92e596ace6d3d3996c042b2128459aaae2a76de11d", size = 135811, upload-time = "2025-07-03T09:31:42.253Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "asttokens" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/a5/8e3f9b6771b0b408517c82d97aed8f2036509bc247d46114925e32fe33f0/asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7", size = 62308, upload-time = "2025-11-15T16:43:48.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" }, +] + +[[package]] +name = "bcrypt" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" }, + { url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" }, + { url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" }, + { url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" }, + { url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" }, + { url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" }, + { url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" }, + { url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" }, + { url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" }, + { url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" }, + { url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" }, + { url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" }, + { url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" }, + { url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" }, + { url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" }, + { url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" }, +] + +[[package]] +name = "better-actuator-models" +version = "1.0.1" +source = { git = "https://github.com/Rhoban/bam.git?branch=mjlab_frictionloss#62bd8ce12154340be97e06f7f41a0ca8f116d967" } +dependencies = [ + { name = "colorama" }, + { name = "numpy" }, +] + +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "cloudpickle" +version = "3.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/27/fb/576f067976d320f5f0114a8d9fa1215425441bb35627b1993e5afd8111e5/cloudpickle-3.1.2.tar.gz", hash = "sha256:7fda9eb655c9c230dab534f1983763de5835249750e85fbcef43aaa30a9a2414", size = 22330, upload-time = "2025-11-03T09:25:26.604Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl", hash = "sha256:9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a", size = 22228, upload-time = "2025-11-03T09:25:25.534Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "contourpy" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, + { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, + { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, + { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, + { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, + { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, + { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, +] + +[[package]] +name = "cryptography" +version = "46.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" }, + { url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" }, + { url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" }, + { url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" }, + { url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" }, + { url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" }, + { url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" }, + { url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" }, + { url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" }, + { url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" }, + { url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" }, + { url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" }, + { url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" }, + { url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" }, + { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" }, + { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" }, + { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" }, + { url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" }, + { url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" }, + { url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" }, + { url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" }, + { url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" }, + { url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" }, +] + +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, +] + +[[package]] +name = "decorator" +version = "5.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, +] + +[[package]] +name = "deprecated" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, +] + +[[package]] +name = "docstring-parser" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, +] + +[[package]] +name = "etils" +version = "1.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/a0/522bbff0f3cdd37968f90dd7f26c7aa801ed87f5ba335f156de7f2b88a48/etils-1.13.0.tar.gz", hash = "sha256:a5b60c71f95bcd2d43d4e9fb3dc3879120c1f60472bb5ce19f7a860b1d44f607", size = 106368, upload-time = "2025-07-15T10:29:10.563Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/98/87b5946356095738cb90a6df7b35ff69ac5750f6e783d5fbcc5cb3b6cbd7/etils-1.13.0-py3-none-any.whl", hash = "sha256:d9cd4f40fbe77ad6613b7348a18132cc511237b6c076dbb89105c0b520a4c6bb", size = 170603, upload-time = "2025-07-15T10:29:09.076Z" }, +] + +[package.optional-dependencies] +epath = [ + { name = "fsspec" }, + { name = "importlib-resources" }, + { name = "typing-extensions" }, + { name = "zipp" }, +] + +[[package]] +name = "executing" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, +] + +[[package]] +name = "fabric" +version = "3.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "decorator" }, + { name = "deprecated" }, + { name = "invoke" }, + { name = "paramiko" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/3f/337f278b70ba339c618a490f6b8033b7006c583bd197a897f12fbc468c51/fabric-3.2.2.tar.gz", hash = "sha256:8783ca42e3b0076f08b26901aac6b9d9b1f19c410074e7accfab902c184ff4a3", size = 183215, upload-time = "2023-08-31T01:42:05.55Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/1f/e99e23ee01847147fa194e8d41cfcf2535a2dbfcb51414c541cadb15c5d7/fabric-3.2.2-py3-none-any.whl", hash = "sha256:91c47c0be68b14936c88b34da8a1f55e5710fd28397dac5d4ff2e21558113a6f", size = 59417, upload-time = "2023-08-31T01:42:03.917Z" }, +] + +[[package]] +name = "filelock" +version = "3.20.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485, upload-time = "2026-01-09T17:55:05.421Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" }, +] + +[[package]] +name = "flatbuffers" +version = "25.12.19" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" }, +] + +[[package]] +name = "fonttools" +version = "4.63.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/69/c97f2c18e0db87d2c7b15da1974dace76ae938f1cfa22e2727a648b7ed43/fonttools-4.63.0.tar.gz", hash = "sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0", size = 3597189, upload-time = "2026-05-14T12:04:30.958Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/ef/b3c6b9b5be2f82416d73fe2ed2e96e2793cd80e7510bd6a17ca79cdd88ec/fonttools-4.63.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:37dd23e621e3b0aef1baa70a303b80aaf38449632cfc8fd2a55fb285bbccfc02", size = 2881131, upload-time = "2026-05-14T12:03:13.386Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/c815bea63117fa63e4e1c01f8a1110d2112fa003f838e6467094ec2432ce/fonttools-4.63.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a9faff9e0c1f76f9fd55899d2ce785832efebab37eb8ae13995853aef178bef0", size = 2426704, upload-time = "2026-05-14T12:03:15.801Z" }, + { url = "https://files.pythonhosted.org/packages/44/04/0b91d8e916e92ad1fac9e4624760baf0fd5ff2ead614c2f68fb21373f03f/fonttools-4.63.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef3048ef05dbb552b89817713d9cac912e00d0fde4a3105c00d29e52e10c89af", size = 5044298, upload-time = "2026-05-14T12:03:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/77/c7/2342da9830e3e9d4870305ca5d2091d2a83284f2953079b7bdd3b5e029d8/fonttools-4.63.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58dc6bb86a78d782f00f9190ca02c119cf5bbe2807536e361e18d42019f877d8", size = 4999800, upload-time = "2026-05-14T12:03:20.161Z" }, + { url = "https://files.pythonhosted.org/packages/e6/6d/67fe16c48d7ce050979b33f47e0d28a318f02da030602e944c34f7a16ef3/fonttools-4.63.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee08ebfa58f6e1aeff5697ab9582105bb620008c1caafb681e4c557e7483027b", size = 4982666, upload-time = "2026-05-14T12:03:22.87Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/3bbab338c07c71fa56269953845e92c951a61457bbbb0f1022551ea266d9/fonttools-4.63.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:27fdc65af8da6f88b9c6121c47a464cbe359fcfff7ff6fc2d37a1f395d755b78", size = 5133598, upload-time = "2026-05-14T12:03:25.168Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/aa27c7f98db5b064883dadcc5283947e81e034de42e22a33675878d98b54/fonttools-4.63.0-cp312-cp312-win32.whl", hash = "sha256:af2fd1664d00a397d75f806985ddb36282091c2131a73a6485c23b4a34722263", size = 2292575, upload-time = "2026-05-14T12:03:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/87/36/cccb9bc2a6ab63d1b2980374f0dca72ce95ae267c9b4cfe77455bb70d0d4/fonttools-4.63.0-cp312-cp312-win_amd64.whl", hash = "sha256:59ac449f8cca9b4ffa08d2e7bbadad87ce710d69d1eda5c3c1ce579baa987272", size = 2343211, upload-time = "2026-05-14T12:03:30.057Z" }, + { url = "https://files.pythonhosted.org/packages/2c/47/c99d5268f354002ce80f8d029cd9d7d872969da1de8b93d32de4dc56d6f4/fonttools-4.63.0-py3-none-any.whl", hash = "sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d", size = 1164562, upload-time = "2026-05-14T12:04:29.092Z" }, +] + +[[package]] +name = "fsspec" +version = "2026.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/7d/5df2650c57d47c57232af5ef4b4fdbff182070421e405e0d62c6cdbfaa87/fsspec-2026.1.0.tar.gz", hash = "sha256:e987cb0496a0d81bba3a9d1cee62922fb395e7d4c3b575e57f547953334fe07b", size = 310496, upload-time = "2026-01-09T15:21:35.562Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/c9/97cc5aae1648dcb851958a3ddf73ccd7dbe5650d95203ecb4d7720b4cdbf/fsspec-2026.1.0-py3-none-any.whl", hash = "sha256:cb76aa913c2285a3b49bdd5fc55b1d7c708d7208126b60f2eb8194fe1b4cbdcc", size = 201838, upload-time = "2026-01-09T15:21:34.041Z" }, +] + +[[package]] +name = "gitdb" +version = "4.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "smmap" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, +] + +[[package]] +name = "gitpython" +version = "3.1.46" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitdb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/b5/59d16470a1f0dfe8c793f9ef56fd3826093fc52b3bd96d6b9d6c26c7e27b/gitpython-3.1.46.tar.gz", hash = "sha256:400124c7d0ef4ea03f7310ac2fbf7151e09ff97f2a3288d64a440c584a29c37f", size = 215371, upload-time = "2026-01-01T15:37:32.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/09/e21df6aef1e1ffc0c816f0522ddc3f6dcded766c3261813131c78a704470/gitpython-3.1.46-py3-none-any.whl", hash = "sha256:79812ed143d9d25b6d176a10bb511de0f9c67b1fa641d82097b0ab90398a2058", size = 208620, upload-time = "2026-01-01T15:37:30.574Z" }, +] + +[[package]] +name = "glfw" +version = "2.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/72/642d4f12f61816ac96777f7360d413e3977a7dd08237d196f02da681b186/glfw-2.10.0.tar.gz", hash = "sha256:801e55d8581b34df9aa2cfea43feb06ff617576e2a8cc5dac23ee75b26d10abe", size = 31475, upload-time = "2025-09-12T08:54:38.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/1f/a9ce08b1173b0ab625ee92f0c47a5278b3e76fd367699880d8ee7d56c338/glfw-2.10.0-py2.py27.py3.py30.py31.py32.py33.py34.py35.py36.py37.py38.p39.p310.p311.p312.p313-none-macosx_10_6_intel.whl", hash = "sha256:5f365a8c94bcea71ec91327e7c16e7cf739128479a18b8c1241b004b40acc412", size = 105329, upload-time = "2025-09-12T08:54:27.938Z" }, + { url = "https://files.pythonhosted.org/packages/7c/96/5a2220abcbd027eebcf8bedd28207a2de168899e51be13ba01ebdd4147a1/glfw-2.10.0-py2.py27.py3.py30.py31.py32.py33.py34.py35.py36.py37.py38.p39.p310.p311.p312.p313-none-macosx_11_0_arm64.whl", hash = "sha256:5328db1a92d07abd988730517ec02aa8390d3e6ef7ce98c8b57ecba2f43a39ba", size = 102179, upload-time = "2025-09-12T08:54:29.163Z" }, + { url = "https://files.pythonhosted.org/packages/9d/41/a5bd1d9e1808f400102bd7d328c4ac17b65fb2fc8014014ec6f23d02f662/glfw-2.10.0-py2.py27.py3.py30.py31.py32.py33.py34.py35.py36.py37.py38.p39.p310.p311.p312.p313-none-manylinux2014_aarch64.whl", hash = "sha256:312c4c1dd5509613ed6bc1e95a8dbb75a36b6dcc4120f50dc3892b40172e9053", size = 230039, upload-time = "2025-09-12T08:54:30.201Z" }, + { url = "https://files.pythonhosted.org/packages/80/aa/3b503c448609dee6cb4e7138b4109338f0e65b97be107ab85562269d378d/glfw-2.10.0-py2.py27.py3.py30.py31.py32.py33.py34.py35.py36.py37.py38.p39.p310.p311.p312.p313-none-manylinux2014_x86_64.whl", hash = "sha256:59c53387dc08c62e8bed86bbe3a8d53ab1b27161281ffa0e7f27b64284e2627c", size = 241984, upload-time = "2025-09-12T08:54:31.347Z" }, + { url = "https://files.pythonhosted.org/packages/ac/2d/bfe39a42cad8e80b02bf5f7cae19ba67832c1810bbd3624a8e83153d74a4/glfw-2.10.0-py2.py27.py3.py30.py31.py32.py33.py34.py35.py36.py37.py38.p39.p310.p311.p312.p313-none-manylinux_2_28_aarch64.whl", hash = "sha256:c6f292fdaf3f9a99e598ede6582d21c523a6f51f8f5e66213849101a6bcdc699", size = 231052, upload-time = "2025-09-12T08:54:32.859Z" }, + { url = "https://files.pythonhosted.org/packages/f7/02/6e639e90f181dc9127046e00d0528f9f7ad12d428972e3a5378b9aefdb0b/glfw-2.10.0-py2.py27.py3.py30.py31.py32.py33.py34.py35.py36.py37.py38.p39.p310.p311.p312.p313-none-manylinux_2_28_x86_64.whl", hash = "sha256:7916034efa867927892635733a3b6af8cd95ceb10566fd7f1e0d2763c2ee8b12", size = 243525, upload-time = "2025-09-12T08:54:34.006Z" }, + { url = "https://files.pythonhosted.org/packages/84/06/cb588ca65561defe0fc48d1df4c2ac12569b81231ae4f2b52ab37007d0bd/glfw-2.10.0-py2.py27.py3.py30.py31.py32.py33.py34.py35.py36.py37.py38.p39.p310.p311.p312.p313-none-win32.whl", hash = "sha256:6c9549da71b93e367b4d71438798daae1da2592039fd14204a80a1a2348ae127", size = 552685, upload-time = "2025-09-12T08:54:35.723Z" }, + { url = "https://files.pythonhosted.org/packages/86/27/00c9c96af18ac0a5eac2ff61cbe306551a2d770d7173f396d0792ee1a59e/glfw-2.10.0-py2.py27.py3.py30.py31.py32.py33.py34.py35.py36.py37.py38.p39.p310.p311.p312.p313-none-win_amd64.whl", hash = "sha256:6292d5d6634d668cd23d337e6089491d3945a9aa4ac6e1667b0003520d7caa51", size = 559466, upload-time = "2025-09-12T08:54:37.661Z" }, + { url = "https://files.pythonhosted.org/packages/b3/87/de0b33f6f00687499ca1371f22aa73396341b85bf88f1a284f9da8842493/glfw-2.10.0-py2.py27.py3.py30.py31.py32.py33.py34.py35.py36.py37.py38.py39.py310.py311.py312.py313.py314-none-macosx_10_6_intel.whl", hash = "sha256:2aab89d2d9535635ba011fc7303390685169a1aa6731ad580d08d043524b8899", size = 105326, upload-time = "2026-01-28T05:57:56.083Z" }, + { url = "https://files.pythonhosted.org/packages/b6/a6/6ea2f73ad4474896d9e38b3ffbe6ffd5a802c738392269e99e8c6621a461/glfw-2.10.0-py2.py27.py3.py30.py31.py32.py33.py34.py35.py36.py37.py38.py39.py310.py311.py312.py313.py314-none-macosx_11_0_arm64.whl", hash = "sha256:23936202a107039b5372f0b88ae1d11080746aa1c78910a45d4a0c4cf408cfaa", size = 102180, upload-time = "2026-01-28T05:57:57.787Z" }, + { url = "https://files.pythonhosted.org/packages/58/19/d81b19e8261b9cb51b81d1402167791fef81088dfe91f0c4e9d136fdc5ca/glfw-2.10.0-py2.py27.py3.py30.py31.py32.py33.py34.py35.py36.py37.py38.py39.py310.py311.py312.py313.py314-none-manylinux2014_aarch64.whl", hash = "sha256:7be06d0838f61df67bd54cb6266a6193d54083acb3624ff3c3812a6358406fa4", size = 230038, upload-time = "2026-01-28T05:57:59.105Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fa/b035636cd82198b97b51a93efe9cfc4343d6b15cefbd336a3f2be871d848/glfw-2.10.0-py2.py27.py3.py30.py31.py32.py33.py34.py35.py36.py37.py38.py39.py310.py311.py312.py313.py314-none-manylinux2014_x86_64.whl", hash = "sha256:91d36b3582a766512eff8e3b5dcc2d3ffcbf10b7cf448551085a08a10f1b8244", size = 241983, upload-time = "2026-01-28T05:58:00.352Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b4/f7b6cc022dd7c68b6c702d19da5d591f978f89c958b9bd3090615db0c739/glfw-2.10.0-py2.py27.py3.py30.py31.py32.py33.py34.py35.py36.py37.py38.py39.py310.py311.py312.py313.py314-none-manylinux_2_28_aarch64.whl", hash = "sha256:27c9e9a2d5e1dc3c9e3996171d844d9df9a5a101e797cb94cce217b7afcf8fd9", size = 231053, upload-time = "2026-01-28T05:58:01.683Z" }, + { url = "https://files.pythonhosted.org/packages/5a/3f/efeb7c6801c46e11bd666a5180f0d615f74f72264212f74f39586c6fda9d/glfw-2.10.0-py2.py27.py3.py30.py31.py32.py33.py34.py35.py36.py37.py38.py39.py310.py311.py312.py313.py314-none-manylinux_2_28_x86_64.whl", hash = "sha256:ce6724bb7cb3d0543dcba17206dce909f94176e68220b8eafee72e9f92bcf542", size = 243522, upload-time = "2026-01-28T05:58:03.517Z" }, + { url = "https://files.pythonhosted.org/packages/cf/b9/b04c3aa0aad2870cfe799f32f8b59789c98e1816bbce9e83f4823c5b840b/glfw-2.10.0-py2.py27.py3.py30.py31.py32.py33.py34.py35.py36.py37.py38.py39.py310.py311.py312.py313.py314-none-win32.whl", hash = "sha256:fca724a21a372731edb290841edd28a9fb1ee490f833392752844ac807c0086a", size = 552682, upload-time = "2026-01-28T05:58:05.649Z" }, + { url = "https://files.pythonhosted.org/packages/bd/e1/6d6816b296a529ac9b897ad228b1e084eb1f92319e96371880eebdc874a6/glfw-2.10.0-py2.py27.py3.py30.py31.py32.py33.py34.py35.py36.py37.py38.py39.py310.py311.py312.py313.py314-none-win_amd64.whl", hash = "sha256:823c0bd7770977d4b10e0ed0aef2f3682276b7c88b8b65cfc540afce5951392f", size = 559464, upload-time = "2026-01-28T05:58:07.261Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a8/d4dab8a58fc2e6981fc7a58c4e56ba9d777fb24931cec6a22152edbb3540/glfw-2.10.0-py2.py3-none-macosx_10_6_intel.whl", hash = "sha256:a0d1f29f206219cc291edfb6cace663a86da2470632551c998e3db82d48ea177", size = 105288, upload-time = "2026-03-10T17:21:19.929Z" }, + { url = "https://files.pythonhosted.org/packages/14/61/68d35e001872a7705112418da236fa2418d4f2e5419f8b2837f9b81bb3da/glfw-2.10.0-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:d28d6f3ef217e64e35dc6fd0a7acb4cec9bfe7cd14dd9b35a7228a87002de154", size = 102139, upload-time = "2026-03-10T17:21:21.645Z" }, + { url = "https://files.pythonhosted.org/packages/4e/e1/ca5984081aaae07c9d371cb11dc4e4ff603510678ed9b73e58b6c351fe63/glfw-2.10.0-py2.py3-none-manylinux2014_aarch64.whl", hash = "sha256:f968b522bb6a0e04aaf4dcac30a476d7229308bb2bac406a60587debb5a61e29", size = 229998, upload-time = "2026-03-10T17:21:23.549Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c4/82ac75fdcfba2896da7a573c0fc7f8ceb8f77ead6866d500d06c32f1c464/glfw-2.10.0-py2.py3-none-manylinux2014_x86_64.whl", hash = "sha256:68cf3752bdadb6f4bc0a876247c28c88c7251ac39f8af076ed938fdfd71e72dd", size = 241944, upload-time = "2026-03-10T17:21:26.102Z" }, + { url = "https://files.pythonhosted.org/packages/e3/96/9f691823cca5eb6a08f346bd0ff03b78032db9370b509a1e9c8976fb20a5/glfw-2.10.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:44d98de5dbf8f727e0cb29f9b29d29528ea7570f2e6f42f8430a69df05f12b48", size = 231009, upload-time = "2026-03-10T17:21:28.481Z" }, + { url = "https://files.pythonhosted.org/packages/3f/93/977b9e679e356871d428ae7a1139ec767dd5177bed58a6344b4d2199e00f/glfw-2.10.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:cca5158d62189e08792b1ae54f92307a282921a0e7783315b467e21b0a381c88", size = 243480, upload-time = "2026-03-10T17:21:30.538Z" }, + { url = "https://files.pythonhosted.org/packages/f9/bd/cea9569c8f2188b0a104472951420434a3e1f5cf26f5836ef9d7227a1a30/glfw-2.10.0-py2.py3-none-win32.whl", hash = "sha256:5e024509989740e8e7b86cc4aab508195495f79879072b0e1f68bd036a2916ad", size = 552641, upload-time = "2026-03-10T17:21:32.653Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9b/4366ad3e1c0688146c70aa6143584d6a8d88583b9390f106250e25a3d5cd/glfw-2.10.0-py2.py3-none-win_amd64.whl", hash = "sha256:7f787ee8645781f10e8800438ce4357ab38c573ffb191aba380c1e72eba6311c", size = 559423, upload-time = "2026-03-10T17:21:34.766Z" }, +] + +[[package]] +name = "grpcio" +version = "1.76.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b6/e0/318c1ce3ae5a17894d5791e87aea147587c9e702f24122cc7a5c8bbaeeb1/grpcio-1.76.0.tar.gz", hash = "sha256:7be78388d6da1a25c0d5ec506523db58b18be22d9c37d8d3a32c08be4987bd73", size = 12785182, upload-time = "2025-10-21T16:23:12.106Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/05/8e29121994b8d959ffa0afd28996d452f291b48cfc0875619de0bde2c50c/grpcio-1.76.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:81fd9652b37b36f16138611c7e884eb82e0cec137c40d3ef7c3f9b3ed00f6ed8", size = 5799718, upload-time = "2025-10-21T16:21:17.939Z" }, + { url = "https://files.pythonhosted.org/packages/d9/75/11d0e66b3cdf998c996489581bdad8900db79ebd83513e45c19548f1cba4/grpcio-1.76.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:04bbe1bfe3a68bbfd4e52402ab7d4eb59d72d02647ae2042204326cf4bbad280", size = 11825627, upload-time = "2025-10-21T16:21:20.466Z" }, + { url = "https://files.pythonhosted.org/packages/28/50/2f0aa0498bc188048f5d9504dcc5c2c24f2eb1a9337cd0fa09a61a2e75f0/grpcio-1.76.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d388087771c837cdb6515539f43b9d4bf0b0f23593a24054ac16f7a960be16f4", size = 6359167, upload-time = "2025-10-21T16:21:23.122Z" }, + { url = "https://files.pythonhosted.org/packages/66/e5/bbf0bb97d29ede1d59d6588af40018cfc345b17ce979b7b45424628dc8bb/grpcio-1.76.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:9f8f757bebaaea112c00dba718fc0d3260052ce714e25804a03f93f5d1c6cc11", size = 7044267, upload-time = "2025-10-21T16:21:25.995Z" }, + { url = "https://files.pythonhosted.org/packages/f5/86/f6ec2164f743d9609691115ae8ece098c76b894ebe4f7c94a655c6b03e98/grpcio-1.76.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:980a846182ce88c4f2f7e2c22c56aefd515daeb36149d1c897f83cf57999e0b6", size = 6573963, upload-time = "2025-10-21T16:21:28.631Z" }, + { url = "https://files.pythonhosted.org/packages/60/bc/8d9d0d8505feccfdf38a766d262c71e73639c165b311c9457208b56d92ae/grpcio-1.76.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f92f88e6c033db65a5ae3d97905c8fea9c725b63e28d5a75cb73b49bda5024d8", size = 7164484, upload-time = "2025-10-21T16:21:30.837Z" }, + { url = "https://files.pythonhosted.org/packages/67/e6/5d6c2fc10b95edf6df9b8f19cf10a34263b7fd48493936fffd5085521292/grpcio-1.76.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4baf3cbe2f0be3289eb68ac8ae771156971848bb8aaff60bad42005539431980", size = 8127777, upload-time = "2025-10-21T16:21:33.577Z" }, + { url = "https://files.pythonhosted.org/packages/3f/c8/dce8ff21c86abe025efe304d9e31fdb0deaaa3b502b6a78141080f206da0/grpcio-1.76.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:615ba64c208aaceb5ec83bfdce7728b80bfeb8be97562944836a7a0a9647d882", size = 7594014, upload-time = "2025-10-21T16:21:41.882Z" }, + { url = "https://files.pythonhosted.org/packages/e0/42/ad28191ebf983a5d0ecef90bab66baa5a6b18f2bfdef9d0a63b1973d9f75/grpcio-1.76.0-cp312-cp312-win32.whl", hash = "sha256:45d59a649a82df5718fd9527ce775fd66d1af35e6d31abdcdc906a49c6822958", size = 3984750, upload-time = "2025-10-21T16:21:44.006Z" }, + { url = "https://files.pythonhosted.org/packages/9e/00/7bd478cbb851c04a48baccaa49b75abaa8e4122f7d86da797500cccdd771/grpcio-1.76.0-cp312-cp312-win_amd64.whl", hash = "sha256:c088e7a90b6017307f423efbb9d1ba97a22aa2170876223f9709e9d1de0b5347", size = 4704003, upload-time = "2025-10-21T16:21:46.244Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "hf-xet" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/74/d8/5c06fc76461418326a7decf8367480c35be11a41fd938633929c60a9ec6b/hf_xet-1.5.0.tar.gz", hash = "sha256:e0fb0a34d9f406eed88233e829a67ec016bec5af19e480eac65a233ea289a948", size = 837196, upload-time = "2026-05-06T06:18:15.583Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/fb/69ff198a82cae7eb1a69fb84d93b3a3e4816564d76817fe541ddc96874eb/hf_xet-1.5.0-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:dad0dc84e941b8ba3c860659fe1fdc35c049d47cce293f003287757e971a8f56", size = 4030814, upload-time = "2026-05-06T06:17:57.933Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ff/edcc2b40162bef3ff78e14ab637e5f3b89243d6aee72f5949d3bb6a5af83/hf_xet-1.5.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:fd6e5a9b0fdac4ed03ed45ef79254a655b1aaab514a02202617fbf643f5fdf7a", size = 3798444, upload-time = "2026-05-06T06:17:55.79Z" }, + { url = "https://files.pythonhosted.org/packages/49/4d/103f76b04310e5e57656696cc184690d20c466af0bca3ca88f8c8ea5d4f3/hf_xet-1.5.0-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3531b1823a0e6d77d80f9ed15ca0e00f0d115094f8ac033d5cae88f4564cc949", size = 4465986, upload-time = "2026-05-06T06:17:44.886Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a2/546f47f464737b3edbab6f8ddb57f2599b93d2cbb66f06abb475ccb48651/hf_xet-1.5.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9a0ee58cd18d5ea799f7ed11290bbccbe56bdd8b1d97ca74b9cc49a3945d7a3b", size = 4259865, upload-time = "2026-05-06T06:17:42.639Z" }, + { url = "https://files.pythonhosted.org/packages/95/7f/1be593c1f28613be2e196473481cd81bfc5910795e30a34e8f744f6cac4f/hf_xet-1.5.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e60df5a42e9bed8628b6416af2cba4cba57ae9f02de226a06b020d98e1aab18", size = 4459835, upload-time = "2026-05-06T06:18:08.026Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b2/703569fc881f3284487e68cda7b42179978480da3c438042a6bbbb4a671c/hf_xet-1.5.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4b35549ce62601b84da4ff9b24d970032ace3d4430f52d91bcbb26c901d6c690", size = 4672414, upload-time = "2026-05-06T06:18:09.864Z" }, + { url = "https://files.pythonhosted.org/packages/af/37/1b6def445c567286b50aa3b33828158e135b1be44938dde59f11382a500c/hf_xet-1.5.0-cp37-abi3-win_amd64.whl", hash = "sha256:2806c7c17b4d23f8d88f7c4814f838c3b6150773fe339c20af23e1cfaf2797e4", size = 3977238, upload-time = "2026-05-06T06:18:23.621Z" }, + { url = "https://files.pythonhosted.org/packages/62/94/3b66b148778ee100dcfd69c2ca22b57b41b44d3063ceec934f209e9184ce/hf_xet-1.5.0-cp37-abi3-win_arm64.whl", hash = "sha256:b6c9df403040248c76d808d3e047d64db2d923bae593eb244c41e425cf6cd7be", size = 3806916, upload-time = "2026-05-06T06:18:21.7Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "huggingface-hub" +version = "1.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "fsspec" }, + { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "tqdm" }, + { name = "typer" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/b6/e22bd20a25299c34b8c5922c1545a6320825b13906eb0f7298edfd034a0b/huggingface_hub-1.15.0.tar.gz", hash = "sha256:28abfdddda3927fd4de6a63cf26ab012498a2c24dae52baf150c5c6edf98a1d5", size = 784100, upload-time = "2026-05-15T11:42:52.149Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/11/0b64cc9024329b76d7547c19a67604a61d21d3ba678a69d1b220c29d5112/huggingface_hub-1.15.0-py3-none-any.whl", hash = "sha256:a4a59af04cbc41a3fe3fec429b171ef994ef8c971eda10136746f408dd4e3744", size = 663602, upload-time = "2026-05-15T11:42:50.487Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "imageio" +version = "2.37.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/6f/606be632e37bf8d05b253e8626c2291d74c691ddc7bcdf7d6aaf33b32f6a/imageio-2.37.2.tar.gz", hash = "sha256:0212ef2727ac9caa5ca4b2c75ae89454312f440a756fcfc8ef1993e718f50f8a", size = 389600, upload-time = "2025-11-04T14:29:39.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/fe/301e0936b79bcab4cacc7548bf2853fc28dced0a578bab1f7ef53c9aa75b/imageio-2.37.2-py3-none-any.whl", hash = "sha256:ad9adfb20335d718c03de457358ed69f141021a333c40a53e57273d8a5bd0b9b", size = 317646, upload-time = "2025-11-04T14:29:37.948Z" }, +] + +[[package]] +name = "imageio-ffmpeg" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/44/bd/c3343c721f2a1b0c9fc71c1aebf1966a3b7f08c2eea8ed5437a2865611d6/imageio_ffmpeg-0.6.0.tar.gz", hash = "sha256:e2556bed8e005564a9f925bb7afa4002d82770d6b08825078b7697ab88ba1755", size = 25210, upload-time = "2025-01-16T21:34:32.747Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/58/87ef68ac83f4c7690961bce288fd8e382bc5f1513860fc7f90a9c1c1c6bf/imageio_ffmpeg-0.6.0-py3-none-macosx_10_9_intel.macosx_10_9_x86_64.whl", hash = "sha256:9d2baaf867088508d4a3458e61eeb30e945c4ad8016025545f66c4b5aaef0a61", size = 24932969, upload-time = "2025-01-16T21:34:20.464Z" }, + { url = "https://files.pythonhosted.org/packages/40/5c/f3d8a657d362cc93b81aab8feda487317da5b5d31c0e1fdfd5e986e55d17/imageio_ffmpeg-0.6.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b1ae3173414b5fc5f538a726c4e48ea97edc0d2cdc11f103afee655c463fa742", size = 21113891, upload-time = "2025-01-16T21:34:00.277Z" }, + { url = "https://files.pythonhosted.org/packages/33/e7/1925bfbc563c39c1d2e82501d8372734a5c725e53ac3b31b4c2d081e895b/imageio_ffmpeg-0.6.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:1d47bebd83d2c5fc770720d211855f208af8a596c82d17730aa51e815cdee6dc", size = 25632706, upload-time = "2025-01-16T21:33:53.475Z" }, + { url = "https://files.pythonhosted.org/packages/a0/2d/43c8522a2038e9d0e7dbdf3a61195ecc31ca576fb1527a528c877e87d973/imageio_ffmpeg-0.6.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:c7e46fcec401dd990405049d2e2f475e2b397779df2519b544b8aab515195282", size = 29498237, upload-time = "2025-01-16T21:34:13.726Z" }, + { url = "https://files.pythonhosted.org/packages/a0/13/59da54728351883c3c1d9fca1710ab8eee82c7beba585df8f25ca925f08f/imageio_ffmpeg-0.6.0-py3-none-win32.whl", hash = "sha256:196faa79366b4a82f95c0f4053191d2013f4714a715780f0ad2a68ff37483cc2", size = 19652251, upload-time = "2025-01-16T21:34:06.812Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c6/fa760e12a2483469e2bf5058c5faff664acf66cadb4df2ad6205b016a73d/imageio_ffmpeg-0.6.0-py3-none-win_amd64.whl", hash = "sha256:02fa47c83703c37df6bfe4896aab339013f62bf02c5ebf2dce6da56af04ffc0a", size = 31246824, upload-time = "2025-01-16T21:34:28.6Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, +] + +[[package]] +name = "importlib-resources" +version = "6.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/8c/f834fbf984f691b4f7ff60f50b514cc3de5cc08abfc3295564dd89c5e2e7/importlib_resources-6.5.2.tar.gz", hash = "sha256:185f87adef5bcc288449d98fb4fba07cea78bc036455dd44c5fc4a2fe78fed2c", size = 44693, upload-time = "2025-01-03T18:51:56.698Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/ed/1f1afb2e9e7f38a545d628f864d562a5ae64fe6f7a10e28ffb9b185b4e89/importlib_resources-6.5.2-py3-none-any.whl", hash = "sha256:789cfdc3ed28c78b67a06acb8126751ced69a3d5f79c095a98298cd8a760ccec", size = 37461, upload-time = "2025-01-03T18:51:54.306Z" }, +] + +[[package]] +name = "invoke" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/bd/b461d3424a24c80490313fd77feeb666ca4f6a28c7e72713e3d9095719b4/invoke-2.2.1.tar.gz", hash = "sha256:515bf49b4a48932b79b024590348da22f39c4942dff991ad1fb8b8baea1be707", size = 304762, upload-time = "2025-10-11T00:36:35.172Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/4b/b99e37f88336009971405cbb7630610322ed6fbfa31e1d7ab3fbf3049a2d/invoke-2.2.1-py3-none-any.whl", hash = "sha256:2413bc441b376e5cd3f55bb5d364f973ad8bdd7bf87e53c79de3c11bf3feecc8", size = 160287, upload-time = "2025-10-11T00:36:33.703Z" }, +] + +[[package]] +name = "ipython" +version = "9.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "decorator" }, + { name = "ipython-pygments-lexers" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "psutil", marker = "sys_platform != 'cygwin' and sys_platform != 'emscripten'" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/59/165d3b4d75cc34add3122c4417ecb229085140ac573103c223cd01dde96f/ipython-9.15.0.tar.gz", hash = "sha256:da2819ce2aa83135257df830660b1176d986c3d2876db24df01974fa955b2756", size = 4442580, upload-time = "2026-06-26T11:03:35.913Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/3a/948263ca3b9d65bb2b1b0c521b3a49fad5d59ada58724bd87d2bd5ff3f36/ipython-9.15.0-py3-none-any.whl", hash = "sha256:515ad9c3cdf0c932a5a9f6245419e8aba706b7bd03c3e1d3a1c83d9351d6aa6e", size = 630895, upload-time = "2026-06-26T11:03:33.809Z" }, +] + +[[package]] +name = "ipython-pygments-lexers" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, +] + +[[package]] +name = "jedi" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parso" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/b7/a3635f6a2d7cf5b5dd98064fc1d5fbbafcb25477bcea204a3a92145d158b/jedi-0.20.0.tar.gz", hash = "sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011", size = 3119416, upload-time = "2026-05-01T23:38:47.814Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl", hash = "sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67", size = 4884812, upload-time = "2026-05-01T23:38:43.919Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "kiwisolver" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/b2/818b74ebea34dabe6d0c51cb1c572e046730e64844da6ed646d5298c40ce/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4e9750bc21b886308024f8a54ccb9a2cc38ac9fa813bf4348434e3d54f337ff9", size = 123158, upload-time = "2026-03-09T13:13:23.127Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d9/405320f8077e8e1c5c4bd6adc45e1e6edf6d727b6da7f2e2533cf58bff71/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72ec46b7eba5b395e0a7b63025490d3214c11013f4aacb4f5e8d6c3041829588", size = 66388, upload-time = "2026-03-09T13:13:24.765Z" }, + { url = "https://files.pythonhosted.org/packages/99/9f/795fedf35634f746151ca8839d05681ceb6287fbed6cc1c9bf235f7887c2/kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819", size = 64068, upload-time = "2026-03-09T13:13:25.878Z" }, + { url = "https://files.pythonhosted.org/packages/c4/13/680c54afe3e65767bed7ec1a15571e1a2f1257128733851ade24abcefbcc/kiwisolver-1.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f", size = 1477934, upload-time = "2026-03-09T13:13:27.166Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2f/cebfcdb60fd6a9b0f6b47a9337198bcbad6fbe15e68189b7011fd914911f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2af221f268f5af85e776a73d62b0845fc8baf8ef0abfae79d29c77d0e776aaf", size = 1278537, upload-time = "2026-03-09T13:13:28.707Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0d/9b782923aada3fafb1d6b84e13121954515c669b18af0c26e7d21f579855/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b0f172dc8ffaccb8522d7c5d899de00133f2f1ca7b0a49b7da98e901de87bf2d", size = 1296685, upload-time = "2026-03-09T13:13:30.528Z" }, + { url = "https://files.pythonhosted.org/packages/27/70/83241b6634b04fe44e892688d5208332bde130f38e610c0418f9ede47ded/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ab8ba9152203feec73758dad83af9a0bbe05001eb4639e547207c40cfb52083", size = 1346024, upload-time = "2026-03-09T13:13:32.818Z" }, + { url = "https://files.pythonhosted.org/packages/e4/db/30ed226fb271ae1a6431fc0fe0edffb2efe23cadb01e798caeb9f2ceae8f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:cdee07c4d7f6d72008d3f73b9bf027f4e11550224c7c50d8df1ae4a37c1402a6", size = 987241, upload-time = "2026-03-09T13:13:34.435Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bd/c314595208e4c9587652d50959ead9e461995389664e490f4dce7ff0f782/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7c60d3c9b06fb23bd9c6139281ccbdc384297579ae037f08ae90c69f6845c0b1", size = 2227742, upload-time = "2026-03-09T13:13:36.4Z" }, + { url = "https://files.pythonhosted.org/packages/c1/43/0499cec932d935229b5543d073c2b87c9c22846aab48881e9d8d6e742a2d/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e315e5ec90d88e140f57696ff85b484ff68bb311e36f2c414aa4286293e6dee0", size = 2323966, upload-time = "2026-03-09T13:13:38.204Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/79b0d760907965acfd9d61826a3d41f8f093c538f55cd2633d3f0db269f6/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1465387ac63576c3e125e5337a6892b9e99e0627d52317f3ca79e6930d889d15", size = 1977417, upload-time = "2026-03-09T13:13:39.966Z" }, + { url = "https://files.pythonhosted.org/packages/ab/31/01d0537c41cb75a551a438c3c7a80d0c60d60b81f694dac83dd436aec0d0/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:530a3fd64c87cffa844d4b6b9768774763d9caa299e9b75d8eca6a4423b31314", size = 2491238, upload-time = "2026-03-09T13:13:41.698Z" }, + { url = "https://files.pythonhosted.org/packages/e4/34/8aefdd0be9cfd00a44509251ba864f5caf2991e36772e61c408007e7f417/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9", size = 2294947, upload-time = "2026-03-09T13:13:43.343Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/0348374369ca588f8fe9c338fae49fa4e16eeb10ffb3d012f23a54578a9e/kiwisolver-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384", size = 73569, upload-time = "2026-03-09T13:13:45.792Z" }, + { url = "https://files.pythonhosted.org/packages/28/26/192b26196e2316e2bd29deef67e37cdf9870d9af8e085e521afff0fed526/kiwisolver-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7", size = 64997, upload-time = "2026-03-09T13:13:46.878Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fa/2910df836372d8761bb6eff7d8bdcb1613b5c2e03f260efe7abe34d388a7/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797", size = 130262, upload-time = "2026-03-09T13:15:35.629Z" }, + { url = "https://files.pythonhosted.org/packages/0f/41/c5f71f9f00aabcc71fee8b7475e3f64747282580c2fe748961ba29b18385/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203", size = 138036, upload-time = "2026-03-09T13:15:36.894Z" }, + { url = "https://files.pythonhosted.org/packages/fa/06/7399a607f434119c6e1fdc8ec89a8d51ccccadf3341dee4ead6bd14caaf5/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7", size = 194295, upload-time = "2026-03-09T13:15:38.22Z" }, + { url = "https://files.pythonhosted.org/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57", size = 75987, upload-time = "2026-03-09T13:15:39.65Z" }, +] + +[[package]] +name = "markdown" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/7dd27d9d863b3376fcf23a5a13cb5d024aed1db46f963f1b5735ae43b3be/markdown-3.10.tar.gz", hash = "sha256:37062d4f2aa4b2b6b32aefb80faa300f82cc790cb949a35b8caede34f2b68c0e", size = 364931, upload-time = "2025-11-03T19:51:15.007Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/81/54e3ce63502cd085a0c556652a4e1b919c45a446bd1e5300e10c44c8c521/markdown-3.10-py3-none-any.whl", hash = "sha256:b5b99d6951e2e4948d939255596523444c0e677c669700b1d17aa4a8a464cb7c", size = 107678, upload-time = "2025-11-03T19:51:13.887Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, +] + +[[package]] +name = "matplotlib" +version = "3.10.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/1b/4be5be87d43d327a0cf4de1a56e86f7f84c89312452406cf122efe2839e6/matplotlib-3.10.9.tar.gz", hash = "sha256:fd66508e8c6877d98e586654b608a0456db8d7e8a546eb1e2600efd957302358", size = 34811233, upload-time = "2026-04-24T00:14:13.539Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/c6/5581e26c72233ebb2a2a6fed2d24fb7c66b4700120b813f51b0555acf0b6/matplotlib-3.10.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f0c3c28d9fbcc1fe7a03be236d73430cf6409c41fb2383a7ac52fe932b072cb1", size = 8319908, upload-time = "2026-04-24T00:12:21.323Z" }, + { url = "https://files.pythonhosted.org/packages/b7/18/4880dd762e40cd360c1bf06e890c5a97b997e91cb324602b1a19950ad5ce/matplotlib-3.10.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cb28c2bd769aa3e98322c6ab09854cbcc52ab69d2759d681bba3e327b2b320", size = 8216016, upload-time = "2026-04-24T00:12:23.4Z" }, + { url = "https://files.pythonhosted.org/packages/32/91/d024616abdba99e83120e07a20658976f6a343646710760c4a51df126029/matplotlib-3.10.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ae20801130378b82d647ff5047c07316295b68dc054ca6b3c13519d0ea624285", size = 8789336, upload-time = "2026-04-24T00:12:26.096Z" }, + { url = "https://files.pythonhosted.org/packages/5c/04/030a2f61ef2158f5e4c259487a92ac877732499fb33d871585d89e03c42d/matplotlib-3.10.9-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c63ebcd8b4b169eb2f5c200552ae6b8be8999a005b6b507ed76fb8d7d674fe2", size = 9604602, upload-time = "2026-04-24T00:12:29.052Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c2/541e4d09d87bb6b5830fc28b4c887a9a8cf4e1c6cee698a8c05552ae2003/matplotlib-3.10.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d75d11c949914165976c621b2324f9ef162af7ebf4b057ddf95dd1dba7e5edcf", size = 9670966, upload-time = "2026-04-24T00:12:32.131Z" }, + { url = "https://files.pythonhosted.org/packages/04/a1/4571fc46e7702de8d0c2dc54ad1b2f8e29328dea3ee90831181f7353d93c/matplotlib-3.10.9-cp312-cp312-win_amd64.whl", hash = "sha256:d091f9d758b34aaaaa6331d13574bf01891d903b3dec59bfff458ef7551de5d6", size = 8217462, upload-time = "2026-04-24T00:12:35.226Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d0/2269edb12aa30c13c8bcc9382892e39943ce1d28aab4ec296e0381798e81/matplotlib-3.10.9-cp312-cp312-win_arm64.whl", hash = "sha256:10cc5ce06d10231c36f40e875f3c7e8050362a4ee8f0ee5d29a6b3277d57bb42", size = 8136688, upload-time = "2026-04-24T00:12:37.442Z" }, +] + +[[package]] +name = "matplotlib-inline" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/c0/9f7c9a46090390368a4d7bcb76bb87a4a36c421e4c0792cdb53486ffac7a/matplotlib_inline-0.2.2.tar.gz", hash = "sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79", size = 8150, upload-time = "2026-05-08T17:33:33.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534, upload-time = "2026-05-08T17:33:32.055Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mediapy" +version = "1.2.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ipython" }, + { name = "matplotlib" }, + { name = "numpy" }, + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/eb/8a0499fb1a2f373f97e2b4df91797507c3971c42c59f1610bed090c57ddc/mediapy-1.2.6.tar.gz", hash = "sha256:2c866cfa0a170213f771b1dd5584a2e82d8d0dc0fa94982f83e29aae27e49c83", size = 28143, upload-time = "2026-02-03T10:29:31.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/8c/52f0299f1675cdfa1ab39a6028a2e5adf9032ae1118c9895c84b08af162b/mediapy-1.2.6-py3-none-any.whl", hash = "sha256:0a0ea00eb0da83c3c54d588b49c49a41ba456174aa33e530ffe13e17269c9072", size = 27494, upload-time = "2026-02-03T10:29:30.245Z" }, +] + +[[package]] +name = "mjlab" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "imageio-ffmpeg" }, + { name = "mediapy" }, + { name = "mjviser" }, + { name = "mujoco" }, + { name = "mujoco-warp" }, + { name = "onnxscript" }, + { name = "prettytable" }, + { name = "rsl-rl-lib" }, + { name = "tensorboard" }, + { name = "tensordict" }, + { name = "torch", version = "2.9.1", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "torch", version = "2.9.1+cu129", source = { registry = "https://download.pytorch.org/whl/cu129" }, marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, + { name = "torchrunx" }, + { name = "tqdm" }, + { name = "trimesh" }, + { name = "tyro" }, + { name = "viser" }, + { name = "wandb" }, + { name = "warp-lang" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/b4/8dc349ffdc26a5497ceb9a95b978abf77e54aaa8ea4e26fe18c3b4715c68/mjlab-1.3.0.tar.gz", hash = "sha256:7514f6703a801978f4b04ddee6e915531e020b4993b26939be91c2cc37ceed99", size = 14066827, upload-time = "2026-04-14T20:16:44.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/de/8b796d06d6e1543782179d608077b32c4e258fb0dfbc62fed516197fe179/mjlab-1.3.0-py3-none-any.whl", hash = "sha256:e3b544bf3e7b69d6de0641c0f98fa3c9bb9d8ff662951ad83dcaba4db4fe47cf", size = 14158107, upload-time = "2026-04-14T20:16:47.197Z" }, +] + +[[package]] +name = "mjlab-microduck" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "better-actuator-models" }, + { name = "huggingface-hub" }, + { name = "matplotlib" }, + { name = "mjlab" }, + { name = "onnxruntime" }, + { name = "rustypot" }, + { name = "scipy" }, + { name = "torch", version = "2.9.1", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "torch", version = "2.9.1+cu129", source = { registry = "https://download.pytorch.org/whl/cu129" }, marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, + { name = "warp-lang" }, +] + +[package.metadata] +requires-dist = [ + { name = "better-actuator-models", git = "https://github.com/Rhoban/bam.git?branch=mjlab_frictionloss" }, + { name = "huggingface-hub", specifier = ">=0.27.0" }, + { name = "matplotlib", specifier = ">=3.10.9" }, + { name = "mjlab", specifier = "==1.3.0" }, + { name = "onnxruntime", specifier = ">=1.24.4" }, + { name = "rustypot", specifier = ">=1.4.2" }, + { name = "scipy", specifier = ">=1.16" }, + { name = "torch", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'", specifier = "==2.9.1" }, + { name = "torch", marker = "platform_machine == 'aarch64' and sys_platform == 'linux'", specifier = "==2.9.1", index = "https://download.pytorch.org/whl/cu129" }, + { name = "warp-lang", specifier = "==1.12.0" }, +] + +[[package]] +name = "mjviser" +version = "0.0.14" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mujoco" }, + { name = "numpy" }, + { name = "pillow" }, + { name = "trimesh" }, + { name = "viser" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/e4/eef89b279fb1811b5f120a99ac9284c32ff2ca4fad5e6f5c93035f72ba9a/mjviser-0.0.14.tar.gz", hash = "sha256:ebde2203dab89959a13ae549b4d3e5e5cf9eb69de11a1a2fd759cbe8f8c641f3", size = 29576, upload-time = "2026-05-07T03:35:13.128Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/c2/4534d678ad1b3f7dee6fd83110112800287be21ba007d988abb9ddc8e0ac/mjviser-0.0.14-py3-none-any.whl", hash = "sha256:4b09f8e90506fc4a71d76fc628872147157947e9292428213ab78cfe137c9a26", size = 32296, upload-time = "2026-05-07T03:35:14.252Z" }, +] + +[[package]] +name = "ml-dtypes" +version = "0.5.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/4a/c27b42ed9b1c7d13d9ba8b6905dece787d6259152f2309338aed29b2447b/ml_dtypes-0.5.4.tar.gz", hash = "sha256:8ab06a50fb9bf9666dd0fe5dfb4676fa2b0ac0f31ecff72a6c3af8e22c063453", size = 692314, upload-time = "2025-11-17T22:32:31.031Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/b8/3c70881695e056f8a32f8b941126cf78775d9a4d7feba8abcb52cb7b04f2/ml_dtypes-0.5.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a174837a64f5b16cab6f368171a1a03a27936b31699d167684073ff1c4237dac", size = 676927, upload-time = "2025-11-17T22:31:48.182Z" }, + { url = "https://files.pythonhosted.org/packages/54/0f/428ef6881782e5ebb7eca459689448c0394fa0a80bea3aa9262cba5445ea/ml_dtypes-0.5.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a7f7c643e8b1320fd958bf098aa7ecf70623a42ec5154e3be3be673f4c34d900", size = 5028464, upload-time = "2025-11-17T22:31:50.135Z" }, + { url = "https://files.pythonhosted.org/packages/3a/cb/28ce52eb94390dda42599c98ea0204d74799e4d8047a0eb559b6fd648056/ml_dtypes-0.5.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ad459e99793fa6e13bd5b7e6792c8f9190b4e5a1b45c63aba14a4d0a7f1d5ff", size = 5009002, upload-time = "2025-11-17T22:31:52.001Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f0/0cfadd537c5470378b1b32bd859cf2824972174b51b873c9d95cfd7475a5/ml_dtypes-0.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:c1a953995cccb9e25a4ae19e34316671e4e2edaebe4cf538229b1fc7109087b7", size = 212222, upload-time = "2025-11-17T22:31:53.742Z" }, + { url = "https://files.pythonhosted.org/packages/16/2e/9acc86985bfad8f2c2d30291b27cd2bb4c74cea08695bd540906ed744249/ml_dtypes-0.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:9bad06436568442575beb2d03389aa7456c690a5b05892c471215bfd8cf39460", size = 160793, upload-time = "2025-11-17T22:31:55.358Z" }, +] + +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, +] + +[[package]] +name = "msgspec" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/9c/bfbd12955a49180cbd234c5d29ec6f74fe641698f0cd9df154a854fc8a15/msgspec-0.20.0.tar.gz", hash = "sha256:692349e588fde322875f8d3025ac01689fead5901e7fb18d6870a44519d62a29", size = 317862, upload-time = "2025-11-24T03:56:28.934Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/6f/1e25eee957e58e3afb2a44b94fa95e06cebc4c236193ed0de3012fff1e19/msgspec-0.20.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2aba22e2e302e9231e85edc24f27ba1f524d43c223ef5765bd8624c7df9ec0a5", size = 196391, upload-time = "2025-11-24T03:55:32.677Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ee/af51d090ada641d4b264992a486435ba3ef5b5634bc27e6eb002f71cef7d/msgspec-0.20.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:716284f898ab2547fedd72a93bb940375de9fbfe77538f05779632dc34afdfde", size = 188644, upload-time = "2025-11-24T03:55:33.934Z" }, + { url = "https://files.pythonhosted.org/packages/49/d6/9709ee093b7742362c2934bfb1bbe791a1e09bed3ea5d8a18ce552fbfd73/msgspec-0.20.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:558ed73315efa51b1538fa8f1d3b22c8c5ff6d9a2a62eff87d25829b94fc5054", size = 218852, upload-time = "2025-11-24T03:55:35.575Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a2/488517a43ccf5a4b6b6eca6dd4ede0bd82b043d1539dd6bb908a19f8efd3/msgspec-0.20.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:509ac1362a1d53aa66798c9b9fd76872d7faa30fcf89b2fba3bcbfd559d56eb0", size = 224937, upload-time = "2025-11-24T03:55:36.859Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e8/49b832808aa23b85d4f090d1d2e48a4e3834871415031ed7c5fe48723156/msgspec-0.20.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1353c2c93423602e7dea1aa4c92f3391fdfc25ff40e0bacf81d34dbc68adb870", size = 222858, upload-time = "2025-11-24T03:55:38.187Z" }, + { url = "https://files.pythonhosted.org/packages/9f/56/1dc2fa53685dca9c3f243a6cbecd34e856858354e455b77f47ebd76cf5bf/msgspec-0.20.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cb33b5eb5adb3c33d749684471c6a165468395d7aa02d8867c15103b81e1da3e", size = 227248, upload-time = "2025-11-24T03:55:39.496Z" }, + { url = "https://files.pythonhosted.org/packages/5a/51/aba940212c23b32eedce752896205912c2668472ed5b205fc33da28a6509/msgspec-0.20.0-cp312-cp312-win_amd64.whl", hash = "sha256:fb1d934e435dd3a2b8cf4bbf47a8757100b4a1cfdc2afdf227541199885cdacb", size = 190024, upload-time = "2025-11-24T03:55:40.829Z" }, + { url = "https://files.pythonhosted.org/packages/41/ad/3b9f259d94f183daa9764fef33fdc7010f7ecffc29af977044fa47440a83/msgspec-0.20.0-cp312-cp312-win_arm64.whl", hash = "sha256:00648b1e19cf01b2be45444ba9dc961bd4c056ffb15706651e64e5d6ec6197b7", size = 175390, upload-time = "2025-11-24T03:55:42.05Z" }, +] + +[[package]] +name = "mujoco" +version = "3.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "absl-py" }, + { name = "etils", extra = ["epath"] }, + { name = "glfw" }, + { name = "numpy" }, + { name = "pyopengl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/3b/c76837b7fdb007f7605ff783689a0bd23a5a49b065928bec2f1fa7ea3d67/mujoco-3.10.0.tar.gz", hash = "sha256:c9e8d5d87d82204ed5bccc87d843c0a53e75aaf381de2938ec46d04f1ac6e24e", size = 1094987, upload-time = "2026-06-22T17:40:59.904Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/a2/4dd9f4cec6ce92f836a8b2de1cc799c4458af1467d7a044ef8014217bdb4/mujoco-3.10.0-cp312-cp312-macosx_10_16_x86_64.whl", hash = "sha256:47d4a22b7667c60e24e7ef6acb027c13abe9abba9acf17cc8db6fb250ba275ea", size = 7772567, upload-time = "2026-06-22T17:40:16.539Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7d/ebe5342c136de27e0c430ba781f829df2cd66c00ed22627c1964fbd5d7fe/mujoco-3.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a4d35e9d0b13ff9ad3196294a7dac363f1d0cdaa988832d0b687d42d98f4ee29", size = 19380823, upload-time = "2026-06-22T17:40:19.211Z" }, + { url = "https://files.pythonhosted.org/packages/d8/5d/43d1b2b9fe97676e5af03020e132ac497b45a0333a4c61de657d0d52170a/mujoco-3.10.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb7f0d7c148a588f3633020807fc0ec3f3a9aff1f647406e3e0ffe96b05dfd57", size = 19705628, upload-time = "2026-06-22T17:40:22.549Z" }, + { url = "https://files.pythonhosted.org/packages/c3/11/c69199e4123935f98068ab6ab6b35955b4de0f6a91d3f9883805a5789394/mujoco-3.10.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:966d12f88e77e2b188e7530667b519d6963b9b906cff83bab534e5e4279325a0", size = 20904309, upload-time = "2026-06-22T17:40:25.861Z" }, + { url = "https://files.pythonhosted.org/packages/47/13/07bf2550c7dcd69ee8c7fd1f5c400a4ba2e4ede0a29a463ad3ac4cc9da90/mujoco-3.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:708edb5aceee96f2767b1072641523060043b2c67000e39e6e9797addf073696", size = 17865123, upload-time = "2026-06-22T17:40:28.996Z" }, +] + +[[package]] +name = "mujoco-warp" +version = "3.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "absl-py" }, + { name = "etils", extra = ["epath"] }, + { name = "mujoco" }, + { name = "numpy" }, + { name = "warp-lang" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/a1/13004d9a034e9361675c14454b0790ab2bfbdc7695388fa1a1ff16691caa/mujoco_warp-3.8.1.tar.gz", hash = "sha256:d7a66c7e5f69b10de67d31cbfd9464b49e06b2ca142a061dd9cd79c84bc21462", size = 1938856, upload-time = "2026-05-11T14:38:39.976Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/52/82ebe6ffde3bcaa341109d9fcf80dfc7c12a15706188b60b67d4be60b2d7/mujoco_warp-3.8.1-py3-none-any.whl", hash = "sha256:6aef7339ba3a8bf8e69659172d2d770169096e3a341f6b5b8a909b383c2581a2", size = 2019871, upload-time = "2026-05-11T14:38:37.757Z" }, +] + +[[package]] +name = "networkx" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/62/ae72ff66c0f1fd959925b4c11f8c2dea61f47f6acaea75a08512cdfe3fed/numpy-2.4.1.tar.gz", hash = "sha256:a1ceafc5042451a858231588a104093474c6a5c57dcc724841f5c888d237d690", size = 20721320, upload-time = "2026-01-10T06:44:59.619Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/7f/ec53e32bf10c813604edf07a3682616bd931d026fcde7b6d13195dfb684a/numpy-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d3703409aac693fa82c0aee023a1ae06a6e9d065dba10f5e8e80f642f1e9d0a2", size = 16656888, upload-time = "2026-01-10T06:42:40.913Z" }, + { url = "https://files.pythonhosted.org/packages/b8/e0/1f9585d7dae8f14864e948fd7fa86c6cb72dee2676ca2748e63b1c5acfe0/numpy-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7211b95ca365519d3596a1d8688a95874cc94219d417504d9ecb2df99fa7bfa8", size = 12373956, upload-time = "2026-01-10T06:42:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/8e/43/9762e88909ff2326f5e7536fa8cb3c49fb03a7d92705f23e6e7f553d9cb3/numpy-2.4.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:5adf01965456a664fc727ed69cc71848f28d063217c63e1a0e200a118d5eec9a", size = 5202567, upload-time = "2026-01-10T06:42:45.107Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ee/34b7930eb61e79feb4478800a4b95b46566969d837546aa7c034c742ef98/numpy-2.4.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:26f0bcd9c79a00e339565b303badc74d3ea2bd6d52191eeca5f95936cad107d0", size = 6549459, upload-time = "2026-01-10T06:42:48.152Z" }, + { url = "https://files.pythonhosted.org/packages/79/e3/5f115fae982565771be994867c89bcd8d7208dbfe9469185497d70de5ddf/numpy-2.4.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0093e85df2960d7e4049664b26afc58b03236e967fb942354deef3208857a04c", size = 14404859, upload-time = "2026-01-10T06:42:49.947Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7d/9c8a781c88933725445a859cac5d01b5871588a15969ee6aeb618ba99eee/numpy-2.4.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ad270f438cbdd402c364980317fb6b117d9ec5e226fff5b4148dd9aa9fc6e02", size = 16371419, upload-time = "2026-01-10T06:42:52.409Z" }, + { url = "https://files.pythonhosted.org/packages/a6/d2/8aa084818554543f17cf4162c42f162acbd3bb42688aefdba6628a859f77/numpy-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:297c72b1b98100c2e8f873d5d35fb551fce7040ade83d67dd51d38c8d42a2162", size = 16182131, upload-time = "2026-01-10T06:42:54.694Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/0425216684297c58a8df35f3284ef56ec4a043e6d283f8a59c53562caf1b/numpy-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cf6470d91d34bf669f61d515499859fa7a4c2f7c36434afb70e82df7217933f9", size = 18295342, upload-time = "2026-01-10T06:42:56.991Z" }, + { url = "https://files.pythonhosted.org/packages/31/4c/14cb9d86240bd8c386c881bafbe43f001284b7cce3bc01623ac9475da163/numpy-2.4.1-cp312-cp312-win32.whl", hash = "sha256:b6bcf39112e956594b3331316d90c90c90fb961e39696bda97b89462f5f3943f", size = 5959015, upload-time = "2026-01-10T06:42:59.631Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/52a703dbeb0c65807540d29699fef5fda073434ff61846a564d5c296420f/numpy-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:e1a27bb1b2dee45a2a53f5ca6ff2d1a7f135287883a1689e930d44d1ff296c87", size = 12310730, upload-time = "2026-01-10T06:43:01.627Z" }, + { url = "https://files.pythonhosted.org/packages/69/80/a828b2d0ade5e74a9fe0f4e0a17c30fdc26232ad2bc8c9f8b3197cf7cf18/numpy-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:0e6e8f9d9ecf95399982019c01223dc130542960a12edfa8edd1122dfa66a8a8", size = 10312166, upload-time = "2026-01-10T06:43:03.673Z" }, +] + +[[package]] +name = "nvidia-cublas-cu12" +version = "12.8.4.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "platform_machine != 'aarch64' or sys_platform != 'linux'", +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/61/e24b560ab2e2eaeb3c839129175fb330dfcfc29e5203196e5541a4c44682/nvidia_cublas_cu12-12.8.4.1-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:8ac4e771d5a348c551b2a426eda6193c19aa630236b418086020df5ba9667142", size = 594346921, upload-time = "2025-03-07T01:44:31.254Z" }, +] + +[[package]] +name = "nvidia-cublas-cu12" +version = "12.9.1.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "platform_machine == 'aarch64' and sys_platform == 'linux'", +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/6c/90d3f532f608a03a13c1d6c16c266ffa3828e8011b1549d3b61db2ad59f5/nvidia_cublas_cu12-12.9.1.4-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:7a950dae01add3b415a5a5cdc4ec818fb5858263e9cca59004bb99fdbbd3a5d6", size = 575006342, upload-time = "2025-06-05T20:04:16.902Z" }, +] + +[[package]] +name = "nvidia-cuda-cupti-cu12" +version = "12.8.90" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "platform_machine != 'aarch64' or sys_platform != 'linux'", +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/02/2adcaa145158bf1a8295d83591d22e4103dbfd821bcaf6f3f53151ca4ffa/nvidia_cuda_cupti_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ea0cb07ebda26bb9b29ba82cda34849e73c166c18162d3913575b0c9db9a6182", size = 10248621, upload-time = "2025-03-07T01:40:21.213Z" }, +] + +[[package]] +name = "nvidia-cuda-cupti-cu12" +version = "12.9.79" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "platform_machine == 'aarch64' and sys_platform == 'linux'", +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/78/351b5c8cdbd9a6b4fb0d6ee73fb176dcdc1b6b6ad47c2ffff5ae8ca4a1f7/nvidia_cuda_cupti_cu12-12.9.79-py3-none-manylinux_2_25_aarch64.whl", hash = "sha256:791853b030602c6a11d08b5578edfb957cadea06e9d3b26adbf8d036135a4afe", size = 10077166, upload-time = "2025-06-05T20:01:01.385Z" }, +] + +[[package]] +name = "nvidia-cuda-nvrtc-cu12" +version = "12.8.93" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "platform_machine != 'aarch64' or sys_platform != 'linux'", +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/6b/32f747947df2da6994e999492ab306a903659555dddc0fbdeb9d71f75e52/nvidia_cuda_nvrtc_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:a7756528852ef889772a84c6cd89d41dfa74667e24cca16bb31f8f061e3e9994", size = 88040029, upload-time = "2025-03-07T01:42:13.562Z" }, +] + +[[package]] +name = "nvidia-cuda-nvrtc-cu12" +version = "12.9.86" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "platform_machine == 'aarch64' and sys_platform == 'linux'", +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/eb/c2295044b8f3b3b08860e2f6a912b702fc92568a167259df5dddb78f325e/nvidia_cuda_nvrtc_cu12-12.9.86-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:096d4de6bda726415dfaf3198d4f5c522b8e70139c97feef5cd2ca6d4cd9cead", size = 44528905, upload-time = "2025-06-05T20:02:29.754Z" }, +] + +[[package]] +name = "nvidia-cuda-runtime-cu12" +version = "12.8.90" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "platform_machine != 'aarch64' or sys_platform != 'linux'", +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/9b/a997b638fcd068ad6e4d53b8551a7d30fe8b404d6f1804abf1df69838932/nvidia_cuda_runtime_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:adade8dcbd0edf427b7204d480d6066d33902cab2a4707dcfc48a2d0fd44ab90", size = 954765, upload-time = "2025-03-07T01:40:01.615Z" }, +] + +[[package]] +name = "nvidia-cuda-runtime-cu12" +version = "12.9.79" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "platform_machine == 'aarch64' and sys_platform == 'linux'", +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/e0/0279bd94539fda525e0c8538db29b72a5a8495b0c12173113471d28bce78/nvidia_cuda_runtime_cu12-12.9.79-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:83469a846206f2a733db0c42e223589ab62fd2fabac4432d2f8802de4bded0a4", size = 3515012, upload-time = "2025-06-05T20:00:35.519Z" }, +] + +[[package]] +name = "nvidia-cudnn-cu12" +version = "9.10.2.21" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas-cu12", version = "12.8.4.1", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "nvidia-cublas-cu12", version = "12.9.1.4", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/41/e79269ce215c857c935fd86bcfe91a451a584dfc27f1e068f568b9ad1ab7/nvidia_cudnn_cu12-9.10.2.21-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:c9132cc3f8958447b4910a1720036d9eff5928cc3179b0a51fb6d167c6cc87d8", size = 705026878, upload-time = "2025-06-06T21:52:51.348Z" }, + { url = "https://files.pythonhosted.org/packages/ba/51/e123d997aa098c61d029f76663dedbfb9bc8dcf8c60cbd6adbe42f76d049/nvidia_cudnn_cu12-9.10.2.21-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:949452be657fa16687d0930933f032835951ef0892b37d2d53824d1a84dc97a8", size = 706758467, upload-time = "2025-06-06T21:54:08.597Z" }, +] + +[[package]] +name = "nvidia-cufft-cu12" +version = "11.3.3.83" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "platform_machine != 'aarch64' or sys_platform != 'linux'", +] +dependencies = [ + { name = "nvidia-nvjitlink-cu12", version = "12.8.93", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/13/ee4e00f30e676b66ae65b4f08cb5bcbb8392c03f54f2d5413ea99a5d1c80/nvidia_cufft_cu12-11.3.3.83-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d2dd21ec0b88cf61b62e6b43564355e5222e4a3fb394cac0db101f2dd0d4f74", size = 193118695, upload-time = "2025-03-07T01:45:27.821Z" }, +] + +[[package]] +name = "nvidia-cufft-cu12" +version = "11.4.1.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "platform_machine == 'aarch64' and sys_platform == 'linux'", +] +dependencies = [ + { name = "nvidia-nvjitlink-cu12", version = "12.9.86", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/2b/76445b0af890da61b501fde30650a1a4bd910607261b209cccb5235d3daa/nvidia_cufft_cu12-11.4.1.4-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1a28c9b12260a1aa7a8fd12f5ebd82d027963d635ba82ff39a1acfa7c4c0fbcf", size = 200822453, upload-time = "2025-06-05T20:05:27.889Z" }, +] + +[[package]] +name = "nvidia-cufile-cu12" +version = "1.13.1.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "platform_machine != 'aarch64' or sys_platform != 'linux'", +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/fe/1bcba1dfbfb8d01be8d93f07bfc502c93fa23afa6fd5ab3fc7c1df71038a/nvidia_cufile_cu12-1.13.1.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1d069003be650e131b21c932ec3d8969c1715379251f8d23a1860554b1cb24fc", size = 1197834, upload-time = "2025-03-07T01:45:50.723Z" }, +] + +[[package]] +name = "nvidia-cufile-cu12" +version = "1.14.1.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "platform_machine == 'aarch64' and sys_platform == 'linux'", +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/d2/110af3a1f77999d5eebf6ffae5d2305ab839e53c76eec3696640cc25b35d/nvidia_cufile_cu12-1.14.1.1-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:8dea77590761e02cb6dd955a57cb6414c58aa3cb1b7adbf9919869a11509cf65", size = 1135994, upload-time = "2025-06-05T20:06:03.952Z" }, +] + +[[package]] +name = "nvidia-curand-cu12" +version = "10.3.9.90" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "platform_machine != 'aarch64' or sys_platform != 'linux'", +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/aa/6584b56dc84ebe9cf93226a5cde4d99080c8e90ab40f0c27bda7a0f29aa1/nvidia_curand_cu12-10.3.9.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:b32331d4f4df5d6eefa0554c565b626c7216f87a06a4f56fab27c3b68a830ec9", size = 63619976, upload-time = "2025-03-07T01:46:23.323Z" }, +] + +[[package]] +name = "nvidia-curand-cu12" +version = "10.3.10.19" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "platform_machine == 'aarch64' and sys_platform == 'linux'", +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1c/2a45afc614d99558d4a773fa740d8bb5471c8398eeed925fc0fcba020173/nvidia_curand_cu12-10.3.10.19-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:de663377feb1697e1d30ed587b07d5721fdd6d2015c738d7528a6002a6134d37", size = 68292066, upload-time = "2025-05-01T19:39:13.595Z" }, +] + +[[package]] +name = "nvidia-cusolver-cu12" +version = "11.7.3.90" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "platform_machine != 'aarch64' or sys_platform != 'linux'", +] +dependencies = [ + { name = "nvidia-cublas-cu12", version = "12.8.4.1", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "nvidia-cusparse-cu12", version = "12.5.8.93", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "nvidia-nvjitlink-cu12", version = "12.8.93", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/48/9a13d2975803e8cf2777d5ed57b87a0b6ca2cc795f9a4f59796a910bfb80/nvidia_cusolver_cu12-11.7.3.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:4376c11ad263152bd50ea295c05370360776f8c3427b30991df774f9fb26c450", size = 267506905, upload-time = "2025-03-07T01:47:16.273Z" }, +] + +[[package]] +name = "nvidia-cusolver-cu12" +version = "11.7.5.82" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "platform_machine == 'aarch64' and sys_platform == 'linux'", +] +dependencies = [ + { name = "nvidia-cublas-cu12", version = "12.9.1.4", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, + { name = "nvidia-cusparse-cu12", version = "12.5.10.65", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, + { name = "nvidia-nvjitlink-cu12", version = "12.9.86", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/99/686ff9bf3a82a531c62b1a5c614476e8dfa24a9d89067aeedf3592ee4538/nvidia_cusolver_cu12-11.7.5.82-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:62efa83e4ace59a4c734d052bb72158e888aa7b770e1a5f601682f16fe5b4fd2", size = 337869834, upload-time = "2025-06-05T20:06:53.125Z" }, +] + +[[package]] +name = "nvidia-cusparse-cu12" +version = "12.5.8.93" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "platform_machine != 'aarch64' or sys_platform != 'linux'", +] +dependencies = [ + { name = "nvidia-nvjitlink-cu12", version = "12.8.93", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/f5/e1854cb2f2bcd4280c44736c93550cc300ff4b8c95ebe370d0aa7d2b473d/nvidia_cusparse_cu12-12.5.8.93-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ec05d76bbbd8b61b06a80e1eaf8cf4959c3d4ce8e711b65ebd0443bb0ebb13b", size = 288216466, upload-time = "2025-03-07T01:48:13.779Z" }, +] + +[[package]] +name = "nvidia-cusparse-cu12" +version = "12.5.10.65" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "platform_machine == 'aarch64' and sys_platform == 'linux'", +] +dependencies = [ + { name = "nvidia-nvjitlink-cu12", version = "12.9.86", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/6f/8710fbd17cdd1d0fc3fea7d36d5b65ce1933611c31e1861da330206b253a/nvidia_cusparse_cu12-12.5.10.65-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:221c73e7482dd93eda44e65ce567c031c07e2f93f6fa0ecd3ba876a195023e83", size = 366359408, upload-time = "2025-06-05T20:07:42.501Z" }, +] + +[[package]] +name = "nvidia-cusparselt-cu12" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/b9/598f6ff36faaece4b3c50d26f50e38661499ff34346f00e057760b35cc9d/nvidia_cusparselt_cu12-0.7.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:8878dce784d0fac90131b6817b607e803c36e629ba34dc5b433471382196b6a5", size = 283835557, upload-time = "2025-02-26T00:16:54.265Z" }, + { url = "https://files.pythonhosted.org/packages/56/79/12978b96bd44274fe38b5dde5cfb660b1d114f70a65ef962bcbbed99b549/nvidia_cusparselt_cu12-0.7.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:f1bb701d6b930d5a7cea44c19ceb973311500847f81b634d802b7b539dc55623", size = 287193691, upload-time = "2025-02-26T00:15:44.104Z" }, +] + +[[package]] +name = "nvidia-nccl-cu12" +version = "2.27.5" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/1c/857979db0ef194ca5e21478a0612bcdbbe59458d7694361882279947b349/nvidia_nccl_cu12-2.27.5-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:31432ad4d1fb1004eb0c56203dc9bc2178a1ba69d1d9e02d64a6938ab5e40e7a", size = 322400625, upload-time = "2025-06-26T04:11:04.496Z" }, + { url = "https://files.pythonhosted.org/packages/6e/89/f7a07dc961b60645dbbf42e80f2bc85ade7feb9a491b11a1e973aa00071f/nvidia_nccl_cu12-2.27.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ad730cf15cb5d25fe849c6e6ca9eb5b76db16a80f13f425ac68d8e2e55624457", size = 322348229, upload-time = "2025-06-26T04:11:28.385Z" }, +] + +[[package]] +name = "nvidia-nvjitlink-cu12" +version = "12.8.93" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "platform_machine != 'aarch64' or sys_platform != 'linux'", +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/74/86a07f1d0f42998ca31312f998bd3b9a7eff7f52378f4f270c8679c77fb9/nvidia_nvjitlink_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:81ff63371a7ebd6e6451970684f916be2eab07321b73c9d244dc2b4da7f73b88", size = 39254836, upload-time = "2025-03-07T01:49:55.661Z" }, +] + +[[package]] +name = "nvidia-nvjitlink-cu12" +version = "12.9.86" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "platform_machine == 'aarch64' and sys_platform == 'linux'", +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/bc/2dcba8e70cf3115b400fef54f213bcd6715a3195eba000f8330f11e40c45/nvidia_nvjitlink_cu12-12.9.86-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:994a05ef08ef4b0b299829cde613a424382aff7efb08a7172c1fa616cc3af2ca", size = 39514880, upload-time = "2025-06-05T20:10:04.89Z" }, +] + +[[package]] +name = "nvidia-nvshmem-cu12" +version = "3.3.20" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/9d/3dd98852568fb845ec1f7902c90a22b240fe1cbabda411ccedf2fd737b7b/nvidia_nvshmem_cu12-3.3.20-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0b0b960da3842212758e4fa4696b94f129090b30e5122fea3c5345916545cff0", size = 124484616, upload-time = "2025-08-04T20:24:59.172Z" }, + { url = "https://files.pythonhosted.org/packages/3b/6c/99acb2f9eb85c29fc6f3a7ac4dccfd992e22666dd08a642b303311326a97/nvidia_nvshmem_cu12-3.3.20-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d00f26d3f9b2e3c3065be895e3059d6479ea5c638a3f38c9fec49b1b9dd7c1e5", size = 124657145, upload-time = "2025-08-04T20:25:19.995Z" }, +] + +[[package]] +name = "nvidia-nvtx-cu12" +version = "12.8.90" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "platform_machine != 'aarch64' or sys_platform != 'linux'", +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/eb/86626c1bbc2edb86323022371c39aa48df6fd8b0a1647bc274577f72e90b/nvidia_nvtx_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5b17e2001cc0d751a5bc2c6ec6d26ad95913324a4adb86788c944f8ce9ba441f", size = 89954, upload-time = "2025-03-07T01:42:44.131Z" }, +] + +[[package]] +name = "nvidia-nvtx-cu12" +version = "12.9.79" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "platform_machine == 'aarch64' and sys_platform == 'linux'", +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/e4/82155e4aaedb41621087ba219c95e99c5e417f37a7649b4fb6ec32dcb14d/nvidia_nvtx_cu12-12.9.79-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d1f258e752294acdb4f61c3d31fee87bd0f60e459f1e2f624376369b524cd15d", size = 86120, upload-time = "2025-06-05T20:02:51.838Z" }, +] + +[[package]] +name = "onnx" +version = "1.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ml-dtypes" }, + { name = "numpy" }, + { name = "protobuf" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/19/8ea73a64b368b75fe339771a20a02bc61ea1f551484c9e3d9d0bfbd0450f/onnx-1.22.0.tar.gz", hash = "sha256:ef40c0aaf0b643857ea9306fc7eddce17eaf9fb0407e4801f1fc5758443a38e0", size = 12024721, upload-time = "2026-06-15T12:50:05.354Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/6a/481561f1093834376ed493e4ca42a73e5be0d50031f2969c86593bdc7c96/onnx-1.22.0-cp312-abi3-macosx_12_0_universal2.whl", hash = "sha256:596fbf0490947533c1c1045ba860851dc9fb77471023dac9a71ba5b42ceab103", size = 20167081, upload-time = "2026-06-15T12:49:32.078Z" }, + { url = "https://files.pythonhosted.org/packages/84/55/b34fc2aa30aa54b4a775402d24c4082242c720283a274fe976ac8eb94480/onnx-1.22.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae5a563f281cd9d2845622cecf6c092a57e4ee1b138f66fdbbdd4200567a5e16", size = 18889249, upload-time = "2026-06-15T12:49:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/09/a6/bd32357e6cc1ecb473afd78193d7231724f284435d2db25696ecfaaa1503/onnx-1.22.0-cp312-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:955e02e1f6d385b53d52f9cd7b9cdf5caf417c300bcfe3c64c6d542be763845b", size = 19106514, upload-time = "2026-06-15T12:49:37.424Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9d/3af461ac6c714b8b369cb71499659932f4f12cfb066250b62f7567c3d530/onnx-1.22.0-cp312-abi3-pyemscripten_2025_0_wasm32.whl", hash = "sha256:82e9f27fc1223cb06d68a56bed6f9d3caf3d0dad1b61bce45006d529b15bd94c", size = 16966387, upload-time = "2026-06-15T12:49:40.918Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f0/68195b5e5a53e333faf2660f5352ee43738d0e42fc5216cc6b1871a9fbfb/onnx-1.22.0-cp312-abi3-win32.whl", hash = "sha256:cc8b66b312f8f03a53e268afb67180a2d97dd12cc79e2b61361c6c0073448016", size = 17081568, upload-time = "2026-06-15T12:49:43.398Z" }, + { url = "https://files.pythonhosted.org/packages/13/a8/734725bb703c5fabb687f79c79e51249475212b3eb37771ac4a4ac9b487f/onnx-1.22.0-cp312-abi3-win_amd64.whl", hash = "sha256:72ccebab3bac07215c204ce8848d42e78eaaa666badbf72d25cd359b9f269e3a", size = 17213290, upload-time = "2026-06-15T12:49:45.933Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/8ce48d8ae26a8761ad4e5dc771961b155c5c3c7c8540ec7f2f2d71b69af0/onnx-1.22.0-cp312-abi3-win_arm64.whl", hash = "sha256:f3c120dcdb70ad738f3c061b32798f408ea299eb69f84dd69ab4a6bf3c2ec01f", size = 17207030, upload-time = "2026-06-15T12:49:48.635Z" }, +] + +[[package]] +name = "onnx-ir" +version = "0.1.14" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ml-dtypes" }, + { name = "numpy" }, + { name = "onnx" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a5/5b/ebd083a5c3d25ce9f95b34a11b3a492cdcf7831bf127c0f64429a4e83961/onnx_ir-0.1.14.tar.gz", hash = "sha256:bd69e3b5821046d5d7c9d0fdd023f8e1d0cc9a62cbee986fa0e5ab2b1602d7ae", size = 120732, upload-time = "2026-01-07T01:19:47.777Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/d1/bd9a5007448b4599a80143b0b5ccc78e9c46176e5e1bee81f6d3da68d217/onnx_ir-0.1.14-py3-none-any.whl", hash = "sha256:89b212fa7840981c5db5dc478190f1b7369536297c3c6eae68fb1c2237dd2554", size = 139128, upload-time = "2026-01-07T01:19:46.403Z" }, +] + +[[package]] +name = "onnxruntime" +version = "1.24.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flatbuffers" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "protobuf" }, + { name = "sympy" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/38/31db1b232b4ba960065a90c1506ad7a56995cd8482033184e97fadca17cc/onnxruntime-1.24.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cad1c2b3f455c55678ab2a8caa51fb420c25e6e3cf10f4c23653cdabedc8de78", size = 17341875, upload-time = "2026-03-17T22:05:51.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/60/c4d1c8043eb42f8a9aa9e931c8c293d289c48ff463267130eca97d13357f/onnxruntime-1.24.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a5c5a544b22f90859c88617ecb30e161ee3349fcc73878854f43d77f00558b5", size = 15172485, upload-time = "2026-03-17T22:03:32.182Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ab/5b68110e0460d73fad814d5bd11c7b1ddcce5c37b10177eb264d6a36e331/onnxruntime-1.24.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d640eb9f3782689b55cfa715094474cd5662f2f137be6a6f847a594b6e9705c", size = 17244912, upload-time = "2026-03-17T22:04:37.251Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f4/6b89e297b93704345f0f3f8c62229bee323ef25682a3f9b4f89a39324950/onnxruntime-1.24.4-cp312-cp312-win_amd64.whl", hash = "sha256:535b29475ca42b593c45fbb2152fbf1cdf3f287315bf650e6a724a0a1d065cdb", size = 12596856, upload-time = "2026-03-17T22:05:41.224Z" }, + { url = "https://files.pythonhosted.org/packages/43/06/8b8ec6e9e6a474fcd5d772453f627ad4549dfe3ab8c0bf70af5afcde551b/onnxruntime-1.24.4-cp312-cp312-win_arm64.whl", hash = "sha256:e6214096e14b7b52e3bee1903dc12dc7ca09cb65e26664668a4620cc5e6f9a90", size = 12270275, upload-time = "2026-03-17T22:05:31.132Z" }, +] + +[[package]] +name = "onnxscript" +version = "0.5.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ml-dtypes" }, + { name = "numpy" }, + { name = "onnx" }, + { name = "onnx-ir" }, + { name = "packaging" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/f8/358a7d982ea51bc1b0c264f29c08adf096c62ba9f258ba13c954b41c46f5/onnxscript-0.5.7.tar.gz", hash = "sha256:480d572451bc233ed7f742b5005cb0c899594b2fdc28e15167dab26f7fd777ad", size = 596306, upload-time = "2025-12-16T20:47:15.762Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/ec/1656ea93be1e50baf429c20603dce249fa3571f3a180407cee79b1afa013/onnxscript-0.5.7-py3-none-any.whl", hash = "sha256:f94a66059c56d13b44908e9b7fd9dae4b4faa6681c784f3fd4c29cfa863e454e", size = 693353, upload-time = "2025-12-16T20:47:17.897Z" }, +] + +[[package]] +name = "orjson" +version = "3.11.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/b8/333fdb27840f3bf04022d21b654a35f58e15407183aeb16f3b41aa053446/orjson-3.11.5.tar.gz", hash = "sha256:82393ab47b4fe44ffd0a7659fa9cfaacc717eb617c93cde83795f14af5c2e9d5", size = 5972347, upload-time = "2025-12-06T15:55:39.458Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a4/8052a029029b096a78955eadd68ab594ce2197e24ec50e6b6d2ab3f4e33b/orjson-3.11.5-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:334e5b4bff9ad101237c2d799d9fd45737752929753bf4faf4b207335a416b7d", size = 245347, upload-time = "2025-12-06T15:54:22.061Z" }, + { url = "https://files.pythonhosted.org/packages/64/67/574a7732bd9d9d79ac620c8790b4cfe0717a3d5a6eb2b539e6e8995e24a0/orjson-3.11.5-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:ff770589960a86eae279f5d8aa536196ebda8273a2a07db2a54e82b93bc86626", size = 129435, upload-time = "2025-12-06T15:54:23.615Z" }, + { url = "https://files.pythonhosted.org/packages/52/8d/544e77d7a29d90cf4d9eecd0ae801c688e7f3d1adfa2ebae5e1e94d38ab9/orjson-3.11.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed24250e55efbcb0b35bed7caaec8cedf858ab2f9f2201f17b8938c618c8ca6f", size = 132074, upload-time = "2025-12-06T15:54:24.694Z" }, + { url = "https://files.pythonhosted.org/packages/6e/57/b9f5b5b6fbff9c26f77e785baf56ae8460ef74acdb3eae4931c25b8f5ba9/orjson-3.11.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a66d7769e98a08a12a139049aac2f0ca3adae989817f8c43337455fbc7669b85", size = 130520, upload-time = "2025-12-06T15:54:26.185Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6d/d34970bf9eb33f9ec7c979a262cad86076814859e54eb9a059a52f6dc13d/orjson-3.11.5-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:86cfc555bfd5794d24c6a1903e558b50644e5e68e6471d66502ce5cb5fdef3f9", size = 136209, upload-time = "2025-12-06T15:54:27.264Z" }, + { url = "https://files.pythonhosted.org/packages/e7/39/bc373b63cc0e117a105ea12e57280f83ae52fdee426890d57412432d63b3/orjson-3.11.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a230065027bc2a025e944f9d4714976a81e7ecfa940923283bca7bbc1f10f626", size = 139837, upload-time = "2025-12-06T15:54:28.75Z" }, + { url = "https://files.pythonhosted.org/packages/cb/aa/7c4818c8d7d324da220f4f1af55c343956003aa4d1ce1857bdc1d396ba69/orjson-3.11.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b29d36b60e606df01959c4b982729c8845c69d1963f88686608be9ced96dbfaa", size = 137307, upload-time = "2025-12-06T15:54:29.856Z" }, + { url = "https://files.pythonhosted.org/packages/46/bf/0993b5a056759ba65145effe3a79dd5a939d4a070eaa5da2ee3180fbb13f/orjson-3.11.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c74099c6b230d4261fdc3169d50efc09abf38ace1a42ea2f9994b1d79153d477", size = 139020, upload-time = "2025-12-06T15:54:31.024Z" }, + { url = "https://files.pythonhosted.org/packages/65/e8/83a6c95db3039e504eda60fc388f9faedbb4f6472f5aba7084e06552d9aa/orjson-3.11.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e697d06ad57dd0c7a737771d470eedc18e68dfdefcdd3b7de7f33dfda5b6212e", size = 141099, upload-time = "2025-12-06T15:54:32.196Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b4/24fdc024abfce31c2f6812973b0a693688037ece5dc64b7a60c1ce69e2f2/orjson-3.11.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e08ca8a6c851e95aaecc32bc44a5aa75d0ad26af8cdac7c77e4ed93acf3d5b69", size = 413540, upload-time = "2025-12-06T15:54:33.361Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/01c0ec95d55ed0c11e4cae3e10427e479bba40c77312b63e1f9665e0737d/orjson-3.11.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e8b5f96c05fce7d0218df3fdfeb962d6b8cfff7e3e20264306b46dd8b217c0f3", size = 151530, upload-time = "2025-12-06T15:54:34.6Z" }, + { url = "https://files.pythonhosted.org/packages/f9/d4/f9ebc57182705bb4bbe63f5bbe14af43722a2533135e1d2fb7affa0c355d/orjson-3.11.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ddbfdb5099b3e6ba6d6ea818f61997bb66de14b411357d24c4612cf1ebad08ca", size = 141863, upload-time = "2025-12-06T15:54:35.801Z" }, + { url = "https://files.pythonhosted.org/packages/0d/04/02102b8d19fdcb009d72d622bb5781e8f3fae1646bf3e18c53d1bc8115b5/orjson-3.11.5-cp312-cp312-win32.whl", hash = "sha256:9172578c4eb09dbfcf1657d43198de59b6cef4054de385365060ed50c458ac98", size = 135255, upload-time = "2025-12-06T15:54:37.209Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fb/f05646c43d5450492cb387de5549f6de90a71001682c17882d9f66476af5/orjson-3.11.5-cp312-cp312-win_amd64.whl", hash = "sha256:2b91126e7b470ff2e75746f6f6ee32b9ab67b7a93c8ba1d15d3a0caaf16ec875", size = 133252, upload-time = "2025-12-06T15:54:38.401Z" }, + { url = "https://files.pythonhosted.org/packages/dc/a6/7b8c0b26ba18c793533ac1cd145e131e46fcf43952aa94c109b5b913c1f0/orjson-3.11.5-cp312-cp312-win_arm64.whl", hash = "sha256:acbc5fac7e06777555b0722b8ad5f574739e99ffe99467ed63da98f97f9ca0fe", size = 126777, upload-time = "2025-12-06T15:54:39.515Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "paramiko" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bcrypt" }, + { name = "cryptography" }, + { name = "invoke" }, + { name = "pynacl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/e7/81fdcbc7f190cdb058cffc9431587eb289833bdd633e2002455ca9bb13d4/paramiko-4.0.0.tar.gz", hash = "sha256:6a25f07b380cc9c9a88d2b920ad37167ac4667f8d9886ccebd8f90f654b5d69f", size = 1630743, upload-time = "2025-08-04T01:02:03.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/90/a744336f5af32c433bd09af7854599682a383b37cfd78f7de263de6ad6cb/paramiko-4.0.0-py3-none-any.whl", hash = "sha256:0e20e00ac666503bf0b4eda3b6d833465a2b7aff2e2b3d79a8bba5ef144ee3b9", size = 223932, upload-time = "2025-08-04T01:02:02.029Z" }, +] + +[[package]] +name = "parso" +version = "0.8.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/4b/90c937815137d43ce71ba043cd3566221e9df6b9c805f24b5d138c9d40a7/parso-0.8.7.tar.gz", hash = "sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1", size = 401824, upload-time = "2026-05-01T23:13:02.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c", size = 107025, upload-time = "2026-05-01T23:12:58.867Z" }, +] + +[[package]] +name = "pexpect" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, +] + +[[package]] +name = "pillow" +version = "12.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, + { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, + { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, + { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, + { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, + { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, +] + +[[package]] +name = "prettytable" +version = "3.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/45/b0847d88d6cfeb4413566738c8bbf1e1995fad3d42515327ff32cc1eb578/prettytable-3.17.0.tar.gz", hash = "sha256:59f2590776527f3c9e8cf9fe7b66dd215837cca96a9c39567414cbc632e8ddb0", size = 67892, upload-time = "2025-11-14T17:33:20.212Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/8c/83087ebc47ab0396ce092363001fa37c17153119ee282700c0713a195853/prettytable-3.17.0-py3-none-any.whl", hash = "sha256:aad69b294ddbe3e1f95ef8886a060ed1666a0b83018bbf56295f6f226c43d287", size = 34433, upload-time = "2025-11-14T17:33:19.093Z" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + +[[package]] +name = "protobuf" +version = "6.33.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "ptyprocess" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, +] + +[[package]] +name = "pure-eval" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, +] + +[[package]] +name = "pycparser" +version = "2.23" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pynacl" +version = "1.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/9a/4019b524b03a13438637b11538c82781a5eda427394380381af8f04f467a/pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c", size = 3511692, upload-time = "2026-01-01T17:48:10.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/7b/4845bbf88e94586ec47a432da4e9107e3fc3ce37eb412b1398630a37f7dd/pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465", size = 388458, upload-time = "2026-01-01T17:32:16.829Z" }, + { url = "https://files.pythonhosted.org/packages/1e/b4/e927e0653ba63b02a4ca5b4d852a8d1d678afbf69b3dbf9c4d0785ac905c/pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0", size = 800020, upload-time = "2026-01-01T17:32:18.34Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/d60984052df5c97b1d24365bc1e30024379b42c4edcd79d2436b1b9806f2/pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4", size = 1399174, upload-time = "2026-01-01T17:32:20.239Z" }, + { url = "https://files.pythonhosted.org/packages/68/f7/322f2f9915c4ef27d140101dd0ed26b479f7e6f5f183590fd32dfc48c4d3/pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87", size = 835085, upload-time = "2026-01-01T17:32:22.24Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d0/f301f83ac8dbe53442c5a43f6a39016f94f754d7a9815a875b65e218a307/pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c", size = 1437614, upload-time = "2026-01-01T17:32:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/c4/58/fc6e649762b029315325ace1a8c6be66125e42f67416d3dbd47b69563d61/pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130", size = 818251, upload-time = "2026-01-01T17:32:25.69Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a8/b917096b1accc9acd878819a49d3d84875731a41eb665f6ebc826b1af99e/pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6", size = 1402859, upload-time = "2026-01-01T17:32:27.215Z" }, + { url = "https://files.pythonhosted.org/packages/85/42/fe60b5f4473e12c72f977548e4028156f4d340b884c635ec6b063fe7e9a5/pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e", size = 791926, upload-time = "2026-01-01T17:32:29.314Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f9/e40e318c604259301cc091a2a63f237d9e7b424c4851cafaea4ea7c4834e/pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577", size = 1363101, upload-time = "2026-01-01T17:32:31.263Z" }, + { url = "https://files.pythonhosted.org/packages/48/47/e761c254f410c023a469284a9bc210933e18588ca87706ae93002c05114c/pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa", size = 227421, upload-time = "2026-01-01T17:32:33.076Z" }, + { url = "https://files.pythonhosted.org/packages/41/ad/334600e8cacc7d86587fe5f565480fde569dfb487389c8e1be56ac21d8ac/pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0", size = 239754, upload-time = "2026-01-01T17:32:34.557Z" }, + { url = "https://files.pythonhosted.org/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801, upload-time = "2026-01-01T17:32:36.309Z" }, +] + +[[package]] +name = "pyopengl" +version = "3.1.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/16/912b7225d56284859cd9a672827f18be43f8012f8b7b932bc4bd959a298e/pyopengl-3.1.10.tar.gz", hash = "sha256:c4a02d6866b54eb119c8e9b3fb04fa835a95ab802dd96607ab4cdb0012df8335", size = 1915580, upload-time = "2025-08-18T02:33:01.76Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/e4/1ba6f44e491c4eece978685230dde56b14d51a0365bc1b774ddaa94d14cd/pyopengl-3.1.10-py3-none-any.whl", hash = "sha256:794a943daced39300879e4e47bd94525280685f42dbb5a998d336cfff151d74f", size = 3194996, upload-time = "2025-08-18T02:32:59.902Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "pyvers" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/39/c5432f541e6ea1d616dfd6ef42ce02792f7eb42dd44f5ed4439dbe17a58b/pyvers-0.1.0-py3-none-any.whl", hash = "sha256:065249805ae537ddf9a2d1a8dffc6d0a12474a347d2eaa2f35ebdae92c0c8199", size = 10092, upload-time = "2025-06-08T23:46:46.219Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "rich" +version = "14.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, +] + +[[package]] +name = "rsl-rl-lib" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitpython" }, + { name = "numpy" }, + { name = "onnx" }, + { name = "onnxscript" }, + { name = "tensordict" }, + { name = "torch", version = "2.9.1", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "torch", version = "2.9.1+cu129", source = { registry = "https://download.pytorch.org/whl/cu129" }, marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, + { name = "torchvision" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/23/dae404688e571e73662b7754ab2a8bddcc48f625c338b7f55576ad528653/rsl_rl_lib-5.0.1.tar.gz", hash = "sha256:b6e1fce8f4481c6118d53c7b03b80c8070d0a1edd3df3efbec5e5e6ff7c92132", size = 58623, upload-time = "2026-03-04T08:19:49.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/25/293a8ffd929d40accab30057b962084601133bd8a6779c156c2f58eb93bc/rsl_rl_lib-5.0.1-py3-none-any.whl", hash = "sha256:2f71a4f753537faf7826d0b74815160e37c409b39ab80a4581524b7ef7bd8539", size = 84202, upload-time = "2026-03-04T08:19:48.044Z" }, +] + +[[package]] +name = "rustypot" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/5d/dec9f5fc87bc0dc10f851e8d26ad27dc7680a69d1b69024f8091ddc4ebb3/rustypot-1.5.0.tar.gz", hash = "sha256:8e2a49c6d5562c8e5fb05b032a48f11afcf20fd244d42c956294d3b4bbdd6c91", size = 61436, upload-time = "2026-05-27T08:43:43.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/d3/5462bda708c9c53ca472a5add4a0f3bf73f986081031077e277344ff2c42/rustypot-1.5.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:e975ccfd0023db621ad5d828ba55ccdd0e3f36dc480ffefbc98bc1cbe2ddfed3", size = 1778207, upload-time = "2026-05-27T08:43:09.762Z" }, + { url = "https://files.pythonhosted.org/packages/09/18/fc8d16fa7aca3167edbf866f84eb45f1896a4a7235d8f3861bf05be1236e/rustypot-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5a799242f606d7635e4bf9668e1a149dede6ff16f4534ee3909bcb6ae9edf704", size = 1624935, upload-time = "2026-05-27T08:43:01.293Z" }, + { url = "https://files.pythonhosted.org/packages/2c/34/f3f353a52564bb38c064ff0bab23c0f0f854d38d6f2298fffb31ade34200/rustypot-1.5.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37238a2c939686fc0a81ab95f881b8b21b638348862ece25a8bed4c3098883de", size = 1782882, upload-time = "2026-05-27T08:42:31.004Z" }, + { url = "https://files.pythonhosted.org/packages/b6/fe/8804268ad80ab9d07a921dbb2635743541f9ddce432dc24a3ec428b1d55e/rustypot-1.5.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1f1392d122d43cc74662c3ecef0e0a77a24210bc8870f115a5eeeae25491f099", size = 1807283, upload-time = "2026-05-27T08:42:38.305Z" }, + { url = "https://files.pythonhosted.org/packages/33/05/bf8da9131bf450b00105e20ff382faa3fc6f4adce6c845d873354fbabba4/rustypot-1.5.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ed7d0183e9b32c8f17deeef6ea631612289624bfec1e55ceaf9b8d570462ae3", size = 1888155, upload-time = "2026-05-27T08:42:53.787Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3a/ae8cfc67e4cb3768dd624ef611e47aeec526c38440052138a9cfa2827a97/rustypot-1.5.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:933a584ac28ab4312d0923fe41ee32db7ea675e7c0c5ec8e0c4f4b9a8ba2ff39", size = 1865288, upload-time = "2026-05-27T08:42:46.038Z" }, + { url = "https://files.pythonhosted.org/packages/29/1e/08e0b3cf5c31129f244de9fe76b4f2d26fba85027735afa9bd2283aa5ade/rustypot-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6a8b0d601122b0ad27dba97c94c3c6877c708e5b8948a4e79a275d288caf1237", size = 1959107, upload-time = "2026-05-27T08:43:18.15Z" }, + { url = "https://files.pythonhosted.org/packages/12/19/c4a04c1a46f64be7d3a573ebbd9bb6ecd770e8eb24f014c0f35aa71b9238/rustypot-1.5.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:66dee66038792849959897b43668cff07509efbc62db7d6c4f1b1edc79ede284", size = 2084102, upload-time = "2026-05-27T08:43:25.481Z" }, + { url = "https://files.pythonhosted.org/packages/27/ee/66161eb1ca80cc89235ef4cc569ce0c20d6e0e009c3424c0393ba8640a31/rustypot-1.5.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b6c1992034b1bec6fdec48f67f744ba67ad9f8d725a94d84c32c983a6e1019aa", size = 2009318, upload-time = "2026-05-27T08:43:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/cc/e1/6ac262a96d50d99313a84b4753cf3dd80e8f92c0ccbec874037a2d7c8a3b/rustypot-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed43ef7697f12a47e6dd2c5c53a6d362958f95e6b1fdf6708ad735553ac0935f", size = 2106960, upload-time = "2026-05-27T08:43:39.894Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c1/31956b8dfe4742b245e70a53dd6bcad9d5d57f02bcb181f96313ebcd4c55/rustypot-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:f82e63076abbf1ac86a46d6f56630eff2875d03e566e6440b7060cfc5645ce5c", size = 1840806, upload-time = "2026-05-27T08:43:47.771Z" }, +] + +[[package]] +name = "scipy" +version = "1.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/19/ca10ead60b0acc80b2b833c2c4a4f2ff753d0f58b811f70d911c7e94a25c/scipy-1.18.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:7bd21faaf5a1a3b2eff922d02db5f191b99a6518db9078a8fb23169f6d22259a", size = 31056519, upload-time = "2026-06-19T14:59:45.203Z" }, + { url = "https://files.pythonhosted.org/packages/96/72/1e6442a00cd2924d361aa1b642ab6373ec35c6fabf311a760be9f76e0f13/scipy-1.18.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:265915e79107de9f946b855e50d7470d5893ec3f54b342e1aa6201cbdcd8bb6b", size = 28681889, upload-time = "2026-06-19T14:59:48.103Z" }, + { url = "https://files.pythonhosted.org/packages/9b/2d/11dd93d21e147a73ba22bd75c0b9208d3a2e0ec76d53170ce7d9029b1015/scipy-1.18.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9ab7b758be6940954a713ee466e2043e9f6e2ed965c1fce5c91039f4be3d90a9", size = 20423580, upload-time = "2026-06-19T14:59:50.665Z" }, + { url = "https://files.pythonhosted.org/packages/9c/01/93552f75e0d2a7dd115a45e59209c51e8d514daff02fc887d2623be06fe1/scipy-1.18.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:97b6cddaaee0a779ef6b5ca83c9604b27cc16b2b8fc22c142652df8793319fb8", size = 23054441, upload-time = "2026-06-19T14:59:53.564Z" }, + { url = "https://files.pythonhosted.org/packages/3c/23/21f5e703643d66f21faa6b4c73195bfcad70c55efcb4f1ab327cd7c4101a/scipy-1.18.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:52a96e21517c7292375c0e27dd796a811f03fcea5fd4d108fdfea8145dcf17ab", size = 33968720, upload-time = "2026-06-19T14:59:56.415Z" }, + { url = "https://files.pythonhosted.org/packages/dd/aa/1b939f6c67ed68635bb538e6752d3dacc02f66535182e939a89581a44e9c/scipy-1.18.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f55797419e16e7f30cf88ffb3113ce0467f00cfe3f70d5c281730b21769bfc2", size = 35287115, upload-time = "2026-06-19T14:59:59.411Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ff/eec46be7e9234208f801062b53e1983085eddebd693f6c9bfb03b459830d/scipy-1.18.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ad033410e2e0672ffdc1042110cef20e1c46f8fd0616cee1d44d8d58fad8fc11", size = 35577989, upload-time = "2026-06-19T15:00:02.235Z" }, + { url = "https://files.pythonhosted.org/packages/84/ca/210d4759c7210bb7d269437421959b39a33434e2776b60c5cb8a763bb30a/scipy-1.18.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4a55985d54c769c872e64b7f4c8a81cc30ef700cc04296abbbf3705439c126de", size = 37421717, upload-time = "2026-06-19T15:00:05.102Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/9a9edb45345bd6744da5ddfb6628e5d5185920494c6a67ec45b6381004cb/scipy-1.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:71ccc8faa2dd16ac310233203474a8b5cb67f10dedd54a3116d34943f4b19132", size = 36597428, upload-time = "2026-06-19T15:00:08.112Z" }, + { url = "https://files.pythonhosted.org/packages/99/0e/33f32a2a58987e26aec0f7df252cbbad1e90ae77bdbc76f40dd4ed0cf0ea/scipy-1.18.0-cp312-cp312-win_arm64.whl", hash = "sha256:d88363fd9d8fbd3511bd273f1a49efb2a540773ddf92a91d57498ce7dd7f3e76", size = 24351481, upload-time = "2026-06-19T15:00:11.103Z" }, +] + +[[package]] +name = "sentry-sdk" +version = "2.49.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/94/23ac26616a883f492428d9ee9ad6eee391612125326b784dbfc30e1e7bab/sentry_sdk-2.49.0.tar.gz", hash = "sha256:c1878599cde410d481c04ef50ee3aedd4f600e4d0d253f4763041e468b332c30", size = 387228, upload-time = "2026-01-08T09:56:25.642Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/43/1c586f9f413765201234541857cb82fda076f4b0f7bad4a0ec248da39cf3/sentry_sdk-2.49.0-py2.py3-none-any.whl", hash = "sha256:6ea78499133874445a20fe9c826c9e960070abeb7ae0cdf930314ab16bb97aa0", size = 415693, upload-time = "2026-01-08T09:56:21.872Z" }, +] + +[[package]] +name = "setuptools" +version = "80.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "smmap" +version = "5.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/44/cd/a040c4b3119bbe532e5b0732286f805445375489fceaec1f48306068ee3b/smmap-5.0.2.tar.gz", hash = "sha256:26ea65a03958fa0c8a1c7e8c7a58fdc77221b8910f6be2131affade476898ad5", size = 22329, upload-time = "2025-01-02T07:14:40.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e", size = 24303, upload-time = "2025-01-02T07:14:38.724Z" }, +] + +[[package]] +name = "stack-data" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "executing" }, + { name = "pure-eval" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, +] + +[[package]] +name = "sympy" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, +] + +[[package]] +name = "tensorboard" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "absl-py" }, + { name = "grpcio" }, + { name = "markdown" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "protobuf" }, + { name = "setuptools" }, + { name = "tensorboard-data-server" }, + { name = "werkzeug" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/d9/a5db55f88f258ac669a92858b70a714bbbd5acd993820b41ec4a96a4d77f/tensorboard-2.20.0-py3-none-any.whl", hash = "sha256:9dc9f978cb84c0723acf9a345d96c184f0293d18f166bb8d59ee098e6cfaaba6", size = 5525680, upload-time = "2025-07-17T19:20:49.638Z" }, +] + +[[package]] +name = "tensorboard-data-server" +version = "0.7.2" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/13/e503968fefabd4c6b2650af21e110aa8466fe21432cd7c43a84577a89438/tensorboard_data_server-0.7.2-py3-none-any.whl", hash = "sha256:7e0610d205889588983836ec05dc098e80f97b7e7bbff7e994ebb78f578d0ddb", size = 2356, upload-time = "2023-10-23T21:23:32.16Z" }, + { url = "https://files.pythonhosted.org/packages/b7/85/dabeaf902892922777492e1d253bb7e1264cadce3cea932f7ff599e53fea/tensorboard_data_server-0.7.2-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:9fe5d24221b29625dbc7328b0436ca7fc1c23de4acf4d272f1180856e32f9f60", size = 4823598, upload-time = "2023-10-23T21:23:33.714Z" }, + { url = "https://files.pythonhosted.org/packages/73/c6/825dab04195756cf8ff2e12698f22513b3db2f64925bdd41671bfb33aaa5/tensorboard_data_server-0.7.2-py3-none-manylinux_2_31_x86_64.whl", hash = "sha256:ef687163c24185ae9754ed5650eb5bc4d84ff257aabdc33f0cc6f74d8ba54530", size = 6590363, upload-time = "2023-10-23T21:23:35.583Z" }, +] + +[[package]] +name = "tensordict" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cloudpickle" }, + { name = "importlib-metadata" }, + { name = "numpy" }, + { name = "orjson" }, + { name = "packaging" }, + { name = "pyvers" }, + { name = "torch", version = "2.9.1", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "torch", version = "2.9.1+cu129", source = { registry = "https://download.pytorch.org/whl/cu129" }, marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/89/2914b6d2796bdbe64ba8d42b568bf02c25673f187079e8795fc668c609fa/tensordict-0.10.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:f52321ddec5da5acb3b90785c68b4fd4cce805aab6eb700ec59352e9f9e6214f", size = 801519, upload-time = "2025-09-08T10:07:18.954Z" }, + { url = "https://files.pythonhosted.org/packages/9e/88/2c1bf6c1abdc4d0bfcbdda2d1a5b19c7a9540f67ff6d20fe08d328d78305/tensordict-0.10.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:6a6ba462cc4c04299eb3746e8994df4a625706e60e2dfb88cc5f9513b6cbad2f", size = 445427, upload-time = "2025-09-08T10:07:20.148Z" }, + { url = "https://files.pythonhosted.org/packages/5e/05/6e7d130c5e9af947fad25fb7d40a3aa2fd9ef9d37c9c7ddc94ba11853d23/tensordict-0.10.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:6f0a52524c7c46778bf250444f1cd508f055735667b8d596a1a7e2fb38824e8c", size = 449961, upload-time = "2025-09-08T10:07:21.977Z" }, + { url = "https://files.pythonhosted.org/packages/f4/40/877fd0453c9c79a14063ecd21102a23460d033e3760e83eae7fd6c09b3ef/tensordict-0.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:76e7c1d6604addd08e026141c3943fa15fbe36db537f9ff311af5d2caee25daf", size = 494502, upload-time = "2025-09-08T10:07:23.2Z" }, +] + +[[package]] +name = "torch" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "platform_machine != 'aarch64' or sys_platform != 'linux'", +] +dependencies = [ + { name = "filelock", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "fsspec", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "jinja2", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "networkx", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "nvidia-cublas-cu12", version = "12.8.4.1", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cuda-cupti-cu12", version = "12.8.90", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cuda-nvrtc-cu12", version = "12.8.93", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cuda-runtime-cu12", version = "12.8.90", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cudnn-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cufft-cu12", version = "11.3.3.83", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cufile-cu12", version = "1.13.1.3", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-curand-cu12", version = "10.3.9.90", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cusolver-cu12", version = "11.7.3.90", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cusparse-cu12", version = "12.5.8.93", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cusparselt-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-nccl-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-nvjitlink-cu12", version = "12.8.93", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-nvshmem-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-nvtx-cu12", version = "12.8.90", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "setuptools", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "sympy", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "triton", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "typing-extensions", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/27/07c645c7673e73e53ded71705045d6cb5bae94c4b021b03aa8d03eee90ab/torch-2.9.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:da5f6f4d7f4940a173e5572791af238cb0b9e21b1aab592bd8b26da4c99f1cd6", size = 104126592, upload-time = "2025-11-12T15:20:41.62Z" }, + { url = "https://files.pythonhosted.org/packages/19/17/e377a460603132b00760511299fceba4102bd95db1a0ee788da21298ccff/torch-2.9.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:27331cd902fb4322252657f3902adf1c4f6acad9dcad81d8df3ae14c7c4f07c4", size = 899742281, upload-time = "2025-11-12T15:22:17.602Z" }, + { url = "https://files.pythonhosted.org/packages/b1/1a/64f5769025db846a82567fa5b7d21dba4558a7234ee631712ee4771c436c/torch-2.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:81a285002d7b8cfd3fdf1b98aa8df138d41f1a8334fd9ea37511517cedf43083", size = 110940568, upload-time = "2025-11-12T15:21:18.689Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ab/07739fd776618e5882661d04c43f5b5586323e2f6a2d7d84aac20d8f20bd/torch-2.9.1-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:c0d25d1d8e531b8343bea0ed811d5d528958f1dcbd37e7245bc686273177ad7e", size = 74479191, upload-time = "2025-11-12T15:21:25.816Z" }, +] + +[[package]] +name = "torch" +version = "2.9.1+cu129" +source = { registry = "https://download.pytorch.org/whl/cu129" } +resolution-markers = [ + "platform_machine == 'aarch64' and sys_platform == 'linux'", +] +dependencies = [ + { name = "filelock", marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, + { name = "fsspec", marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, + { name = "jinja2", marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, + { name = "networkx", marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, + { name = "nvidia-cublas-cu12", version = "12.9.1.4", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, + { name = "nvidia-cuda-cupti-cu12", version = "12.9.79", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, + { name = "nvidia-cuda-nvrtc-cu12", version = "12.9.86", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, + { name = "nvidia-cuda-runtime-cu12", version = "12.9.79", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, + { name = "nvidia-cudnn-cu12", marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, + { name = "nvidia-cufft-cu12", version = "11.4.1.4", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, + { name = "nvidia-cufile-cu12", version = "1.14.1.1", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, + { name = "nvidia-curand-cu12", version = "10.3.10.19", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, + { name = "nvidia-cusolver-cu12", version = "11.7.5.82", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, + { name = "nvidia-cusparse-cu12", version = "12.5.10.65", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, + { name = "nvidia-cusparselt-cu12", marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, + { name = "nvidia-nccl-cu12", marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, + { name = "nvidia-nvjitlink-cu12", version = "12.9.86", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, + { name = "nvidia-nvshmem-cu12", marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, + { name = "nvidia-nvtx-cu12", version = "12.9.79", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, + { name = "setuptools", marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, + { name = "sympy", marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, + { name = "triton", marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, + { name = "typing-extensions", marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, +] +wheels = [ + { url = "https://download-r2.pytorch.org/whl/cu129/torch-2.9.1%2Bcu129-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:c501c66fe5b0e2fc70f9d8a18e17a265f92ad1d1009dba03f5938d2f15a9066f", upload-time = "2026-01-26T17:26:29Z" }, +] + +[[package]] +name = "torchrunx" +version = "0.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cloudpickle" }, + { name = "fabric" }, + { name = "numpy" }, + { name = "torch", version = "2.9.1", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "torch", version = "2.9.1+cu129", source = { registry = "https://download.pytorch.org/whl/cu129" }, marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/65/60/ce6ccaf618e56775905e75e3fe7f2c8adfb61916d2946e854850c1d19a0d/torchrunx-0.3.4.tar.gz", hash = "sha256:6f2333fa17f7ef1f43f6c65d2b008b8479b29d972a8ed209da613d830dffdc45", size = 41312, upload-time = "2025-11-19T02:36:13.54Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/0d/9f5e24043f2562fd4dc15d04614cb5f3b4a1ffe327cb040b9f95b03fa84d/torchrunx-0.3.4-py3-none-any.whl", hash = "sha256:a157ec139f5a0bdfaa5ece50d987ff0a3212a4657d791367f11fdd383ccdbd5b", size = 34172, upload-time = "2025-11-19T02:36:12.354Z" }, +] + +[[package]] +name = "torchvision" +version = "0.24.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "pillow" }, + { name = "torch", version = "2.9.1", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "torch", version = "2.9.1+cu129", source = { registry = "https://download.pytorch.org/whl/cu129" }, marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/af/18e2c6b9538a045f60718a0c5a058908ccb24f88fde8e6f0fc12d5ff7bd3/torchvision-0.24.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e48bf6a8ec95872eb45763f06499f87bd2fb246b9b96cb00aae260fda2f96193", size = 1891433, upload-time = "2025-11-12T15:25:03.232Z" }, + { url = "https://files.pythonhosted.org/packages/9d/43/600e5cfb0643d10d633124f5982d7abc2170dfd7ce985584ff16edab3e76/torchvision-0.24.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:7fb7590c737ebe3e1c077ad60c0e5e2e56bb26e7bccc3b9d04dbfc34fd09f050", size = 2386737, upload-time = "2025-11-12T15:25:08.288Z" }, + { url = "https://files.pythonhosted.org/packages/93/b1/db2941526ecddd84884132e2742a55c9311296a6a38627f9e2627f5ac889/torchvision-0.24.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:66a98471fc18cad9064123106d810a75f57f0838eee20edc56233fd8484b0cc7", size = 8049868, upload-time = "2025-11-12T15:25:13.058Z" }, + { url = "https://files.pythonhosted.org/packages/69/98/16e583f59f86cd59949f59d52bfa8fc286f86341a229a9d15cbe7a694f0c/torchvision-0.24.1-cp312-cp312-win_amd64.whl", hash = "sha256:4aa6cb806eb8541e92c9b313e96192c6b826e9eb0042720e2fa250d021079952", size = 4302006, upload-time = "2025-11-12T15:25:16.184Z" }, +] + +[[package]] +name = "tqdm" +version = "4.67.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, +] + +[[package]] +name = "traitlets" +version = "5.15.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/a9/a2584b8313b89f94869ddb3c4074617a691de1812a614d2d50e32ca5a7a6/traitlets-5.15.1.tar.gz", hash = "sha256:7b1c07854fe25acb39e009bae49f11b79ff6cbb2f27999104e9110e7a6b53722", size = 163344, upload-time = "2026-06-03T12:26:06.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/8d/1080ee4c231f361b6ce4470d556c8c435b67c7e0753aaa641497ee92f88b/traitlets-5.15.1-py3-none-any.whl", hash = "sha256:770a53705f84b81ac107e83a1b3328ff2dae16094d8fc3cfc004e4b22dfd8e92", size = 85858, upload-time = "2026-06-03T12:26:04.395Z" }, +] + +[[package]] +name = "trimesh" +version = "4.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4c/6e/e905f84b31e6f3a7957f26a91c6d2c6337def5a888e55ac89fe2b790241b/trimesh-4.11.1.tar.gz", hash = "sha256:9a10040ca5d1c4438e0b7af94433edf6b043f5204393fc97bb85c9159a8bf21e", size = 835062, upload-time = "2026-01-17T16:38:02.67Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/be/69fffc97b78f52a5d1372e5d56a0599291e96996a47089fb6312bc023d88/trimesh-4.11.1-py3-none-any.whl", hash = "sha256:bcc082ced94610ecd2c09b031431d0f3ad74352525e23a41b5688a2897b3e3e0", size = 740352, upload-time = "2026-01-17T16:38:00.716Z" }, +] + +[[package]] +name = "triton" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/53/2bcc46879910991f09c063eea07627baef2bc62fe725302ba8f46a2c1ae5/triton-3.5.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:275a045b6ed670dd1bd005c3e6c2d61846c74c66f4512d6f33cc027b11de8fd4", size = 159940689, upload-time = "2025-11-11T17:51:55.938Z" }, + { url = "https://files.pythonhosted.org/packages/f2/50/9a8358d3ef58162c0a415d173cfb45b67de60176e1024f71fbc4d24c0b6d/triton-3.5.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d2c6b915a03888ab931a9fd3e55ba36785e1fe70cbea0b40c6ef93b20fc85232", size = 170470207, upload-time = "2025-11-11T17:41:00.253Z" }, +] + +[[package]] +name = "typeguard" +version = "4.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/68/71c1a15b5f65f40e91b65da23b8224dad41349894535a97f63a52e462196/typeguard-4.4.4.tar.gz", hash = "sha256:3a7fd2dffb705d4d0efaed4306a704c89b9dee850b688f060a8b1615a79e5f74", size = 75203, upload-time = "2025-06-18T09:56:07.624Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/a9/e3aee762739c1d7528da1c3e06d518503f8b6c439c35549b53735ba52ead/typeguard-4.4.4-py3-none-any.whl", hash = "sha256:b5f562281b6bfa1f5492470464730ef001646128b180769880468bd84b68b09e", size = 34874, upload-time = "2025-06-18T09:56:05.999Z" }, +] + +[[package]] +name = "typer" +version = "0.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/51/9aed62104cea109b820bbd6c14245af756112017d309da813ef107d42e7e/typer-0.25.1.tar.gz", hash = "sha256:9616eb8853a09ffeabab1698952f33c6f29ffdbceb4eaeecf571880e8d7664cc", size = 122276, upload-time = "2026-04-30T19:32:16.964Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89", size = 58409, upload-time = "2026-04-30T19:32:18.271Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tyro" +version = "1.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docstring-parser" }, + { name = "typeguard" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8c/34/1e306bacd2917f694fef5aa21ccd9d39dfa6747ddbe7a0f5c9612c734259/tyro-1.0.5.tar.gz", hash = "sha256:5b28c23cc8e844f284286b1043d06be0e62f7acff6f2b1b5b5db75466c6802fc", size = 453403, upload-time = "2026-01-13T20:50:51.016Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/86/eef272685bc6c15a8f79178aa8c6e07d956f0f91d97fab5a4d8af707b21b/tyro-1.0.5-py3-none-any.whl", hash = "sha256:87fcba42a1136cdabef8776a456bb4a05c0d52b81ccc2529e65c75da059ff3cd", size = 181241, upload-time = "2026-01-13T20:50:49.597Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "viser" +version = "1.0.30" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "imageio" }, + { name = "msgspec" }, + { name = "numpy" }, + { name = "requests" }, + { name = "rich" }, + { name = "tqdm" }, + { name = "trimesh" }, + { name = "typing-extensions" }, + { name = "websockets" }, + { name = "zstandard" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/9a/85f9a82eac4e067fb1bc34cf4f7865b0d914a3ce91f5b0da47dcef549930/viser-1.0.30.tar.gz", hash = "sha256:6a849a9ef141c97d4ac7bd7d970b81265e6d0130ee8ef1c2e18c0b46aa6b6283", size = 4924612, upload-time = "2026-06-03T16:02:38.552Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/c9/9a4ca25b505303d5557c98786ac0bf7081c76a7262e1b6c87b899bccc7b8/viser-1.0.30-py3-none-any.whl", hash = "sha256:43089f50acdd45c19b23101c9a2cf8edcc52d052bf7f53ed0b2b6a932c71b5d1", size = 5027789, upload-time = "2026-06-03T16:02:40.331Z" }, +] + +[[package]] +name = "wandb" +version = "0.24.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "gitpython" }, + { name = "packaging" }, + { name = "platformdirs" }, + { name = "protobuf" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "sentry-sdk" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/27/7e/aad6e943012ea4d88f3a037f1a5a7c6898263c60fbef8c9cdb95a8ff9fd9/wandb-0.24.0.tar.gz", hash = "sha256:4715a243b3d460b6434b9562e935dfd9dfdf5d6e428cfb4c3e7ce4fd44460ab3", size = 44197947, upload-time = "2026-01-13T22:59:59.767Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/8a/efec186dcc5dcf3c806040e3f33e58997878b2d30b87aa02b26f046858b6/wandb-0.24.0-py3-none-macosx_12_0_arm64.whl", hash = "sha256:aa9777398ff4b0f04c41359f7d1b95b5d656cb12c37c63903666799212e50299", size = 21464901, upload-time = "2026-01-13T22:59:31.86Z" }, + { url = "https://files.pythonhosted.org/packages/ed/84/fadf0d5f1d86c3ba662d2b33a15d2b1f08ff1e4e196c77e455f028b0fda2/wandb-0.24.0-py3-none-macosx_12_0_x86_64.whl", hash = "sha256:0423fbd58c3926949724feae8aab89d20c68846f9f4f596b80f9ffe1fc298130", size = 22697817, upload-time = "2026-01-13T22:59:35.267Z" }, + { url = "https://files.pythonhosted.org/packages/6e/5f/e3124e68d02b30c62856175ce714e07904730be06eecb00f66bb1a59aacf/wandb-0.24.0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:2b25fc0c123daac97ed32912ac55642c65013cc6e3a898e88ca2d917fc8eadc0", size = 21118798, upload-time = "2026-01-13T22:59:38.453Z" }, + { url = "https://files.pythonhosted.org/packages/22/a1/8d68a914c030e897c306c876d47c73aa5d9ca72be608971290d3a5749570/wandb-0.24.0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:9485344b4667944b5b77294185bae8469cfa4074869bec0e74f54f8492234cc2", size = 22849954, upload-time = "2026-01-13T22:59:41.265Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f8/3e68841a4282a4fb6a8935534e6064acc6c9708e8fb76953ec73bbc72a5e/wandb-0.24.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:51b2b9a9d7d6b35640f12a46a48814fd4516807ad44f586b819ed6560f8de1fd", size = 21160339, upload-time = "2026-01-13T22:59:43.967Z" }, + { url = "https://files.pythonhosted.org/packages/16/e5/d851868ce5b4b437a7cc90405979cd83809790e4e2a2f1e454f63f116e52/wandb-0.24.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:11f7e7841f31eff82c82a677988889ad3aa684c6de61ff82145333b5214ec860", size = 22936978, upload-time = "2026-01-13T22:59:46.911Z" }, + { url = "https://files.pythonhosted.org/packages/d2/34/43b7f18870051047ce6fe18e7eb24ba7ebdc71663a8f1c58e31e855eb8ac/wandb-0.24.0-py3-none-win32.whl", hash = "sha256:42af348998b00d4309ae790c5374040ac6cc353ab21567f4e29c98c9376dee8e", size = 22118243, upload-time = "2026-01-13T22:59:49.555Z" }, + { url = "https://files.pythonhosted.org/packages/a1/92/909c81173cf1399111f57f9ca5399a8f165607b024e406e080178c878f70/wandb-0.24.0-py3-none-win_amd64.whl", hash = "sha256:32604eddcd362e1ed4a2e2ce5f3a239369c4a193af223f3e66603481ac91f336", size = 22118246, upload-time = "2026-01-13T22:59:52.126Z" }, + { url = "https://files.pythonhosted.org/packages/87/85/a845aefd9c2285f98261fa6ffa0a14466366c1ac106d35bc84b654c0ad7f/wandb-0.24.0-py3-none-win_arm64.whl", hash = "sha256:e0f2367552abfca21b0f3a03405fbf48f1e14de9846e70f73c6af5da57afd8ef", size = 20077678, upload-time = "2026-01-13T22:59:56.112Z" }, +] + +[[package]] +name = "warp-lang" +version = "1.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/15/fadf3e3ba5c1c907530c20c98402aaef792da74bbbe382c848cef6e5affe/warp_lang-1.12.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c78c3701d5cad86c30ef5017410d294ec46a396bb0d502ee1c98743494f3a62f", size = 24168341, upload-time = "2026-03-06T19:42:16.333Z" }, + { url = "https://files.pythonhosted.org/packages/98/13/deab9dbae5c6aa753ac8ea1d3b1f85d20c5bab7bdebd8916ce242fbe1f0b/warp_lang-1.12.0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:a1436f60a1881cd94f787e751a83fc0987626be2d3e2b4e74c64a6947c6d1266", size = 136485344, upload-time = "2026-03-06T19:43:02.427Z" }, + { url = "https://files.pythonhosted.org/packages/45/ce/9f5c57cac849edaba2f3335cb649b7019b09195b3af02221258482254559/warp_lang-1.12.0-py3-none-manylinux_2_34_aarch64.whl", hash = "sha256:a2d6decba693aba5b828573c4414fd6a3f4c4a934db9c322736ef2b3fa99fe76", size = 137735580, upload-time = "2026-03-06T19:44:22.279Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3f/1ddc888fe769447ae33915a9567a9dd7467e1fc7fc8010d39e01b339667f/warp_lang-1.12.0-py3-none-win_amd64.whl", hash = "sha256:697248edd2f1e2952f50e3db33b214af76173641a8894aacc467bed6dc247f8a", size = 119793582, upload-time = "2026-03-06T19:45:37.288Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.2.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/30/6b0809f4510673dc723187aeaf24c7f5459922d01e2f794277a3dfb90345/wcwidth-0.2.14.tar.gz", hash = "sha256:4d478375d31bc5395a3c55c40ccdf3354688364cd61c4f6adacaa9215d0b3605", size = 102293, upload-time = "2025-09-22T16:29:53.023Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286, upload-time = "2025-09-22T16:29:51.641Z" }, +] + +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +] + +[[package]] +name = "werkzeug" +version = "3.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/70/1469ef1d3542ae7c2c7b72bd5e3a4e6ee69d7978fa8a3af05a38eca5becf/werkzeug-3.1.5.tar.gz", hash = "sha256:6a548b0e88955dd07ccb25539d7d0cc97417ee9e179677d22c7041c8f078ce67", size = 864754, upload-time = "2026-01-08T17:49:23.247Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/e4/8d97cca767bcc1be76d16fb76951608305561c6e056811587f36cb1316a8/werkzeug-3.1.5-py3-none-any.whl", hash = "sha256:5111e36e91086ece91f93268bb39b4a35c1e6f1feac762c9c822ded0a4e322dc", size = 225025, upload-time = "2026-01-08T17:49:21.859Z" }, +] + +[[package]] +name = "wrapt" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2a/6de8a50cb435b7f42c46126cf1a54b2aab81784e74c8595c8e025e8f36d3/wrapt-2.0.1.tar.gz", hash = "sha256:9c9c635e78497cacb81e84f8b11b23e0aacac7a136e73b8e5b2109a1d9fc468f", size = 82040, upload-time = "2025-11-07T00:45:33.312Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/73/8cb252858dc8254baa0ce58ce382858e3a1cf616acebc497cb13374c95c6/wrapt-2.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1fdbb34da15450f2b1d735a0e969c24bdb8d8924892380126e2a293d9902078c", size = 78129, upload-time = "2025-11-07T00:43:48.852Z" }, + { url = "https://files.pythonhosted.org/packages/19/42/44a0db2108526ee6e17a5ab72478061158f34b08b793df251d9fbb9a7eb4/wrapt-2.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3d32794fe940b7000f0519904e247f902f0149edbe6316c710a8562fb6738841", size = 61205, upload-time = "2025-11-07T00:43:50.402Z" }, + { url = "https://files.pythonhosted.org/packages/4d/8a/5b4b1e44b791c22046e90d9b175f9a7581a8cc7a0debbb930f81e6ae8e25/wrapt-2.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:386fb54d9cd903ee0012c09291336469eb7b244f7183d40dc3e86a16a4bace62", size = 61692, upload-time = "2025-11-07T00:43:51.678Z" }, + { url = "https://files.pythonhosted.org/packages/11/53/3e794346c39f462bcf1f58ac0487ff9bdad02f9b6d5ee2dc84c72e0243b2/wrapt-2.0.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7b219cb2182f230676308cdcacd428fa837987b89e4b7c5c9025088b8a6c9faf", size = 121492, upload-time = "2025-11-07T00:43:55.017Z" }, + { url = "https://files.pythonhosted.org/packages/c6/7e/10b7b0e8841e684c8ca76b462a9091c45d62e8f2de9c4b1390b690eadf16/wrapt-2.0.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:641e94e789b5f6b4822bb8d8ebbdfc10f4e4eae7756d648b717d980f657a9eb9", size = 123064, upload-time = "2025-11-07T00:43:56.323Z" }, + { url = "https://files.pythonhosted.org/packages/0e/d1/3c1e4321fc2f5ee7fd866b2d822aa89b84495f28676fd976c47327c5b6aa/wrapt-2.0.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe21b118b9f58859b5ebaa4b130dee18669df4bd111daad082b7beb8799ad16b", size = 117403, upload-time = "2025-11-07T00:43:53.258Z" }, + { url = "https://files.pythonhosted.org/packages/a4/b0/d2f0a413cf201c8c2466de08414a15420a25aa83f53e647b7255cc2fab5d/wrapt-2.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:17fb85fa4abc26a5184d93b3efd2dcc14deb4b09edcdb3535a536ad34f0b4dba", size = 121500, upload-time = "2025-11-07T00:43:57.468Z" }, + { url = "https://files.pythonhosted.org/packages/bd/45/bddb11d28ca39970a41ed48a26d210505120f925918592283369219f83cc/wrapt-2.0.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:b89ef9223d665ab255ae42cc282d27d69704d94be0deffc8b9d919179a609684", size = 116299, upload-time = "2025-11-07T00:43:58.877Z" }, + { url = "https://files.pythonhosted.org/packages/81/af/34ba6dd570ef7a534e7eec0c25e2615c355602c52aba59413411c025a0cb/wrapt-2.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a453257f19c31b31ba593c30d997d6e5be39e3b5ad9148c2af5a7314061c63eb", size = 120622, upload-time = "2025-11-07T00:43:59.962Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3e/693a13b4146646fb03254636f8bafd20c621955d27d65b15de07ab886187/wrapt-2.0.1-cp312-cp312-win32.whl", hash = "sha256:3e271346f01e9c8b1130a6a3b0e11908049fe5be2d365a5f402778049147e7e9", size = 58246, upload-time = "2025-11-07T00:44:03.169Z" }, + { url = "https://files.pythonhosted.org/packages/a7/36/715ec5076f925a6be95f37917b66ebbeaa1372d1862c2ccd7a751574b068/wrapt-2.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:2da620b31a90cdefa9cd0c2b661882329e2e19d1d7b9b920189956b76c564d75", size = 60492, upload-time = "2025-11-07T00:44:01.027Z" }, + { url = "https://files.pythonhosted.org/packages/ef/3e/62451cd7d80f65cc125f2b426b25fbb6c514bf6f7011a0c3904fc8c8df90/wrapt-2.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:aea9c7224c302bc8bfc892b908537f56c430802560e827b75ecbde81b604598b", size = 58987, upload-time = "2025-11-07T00:44:02.095Z" }, + { url = "https://files.pythonhosted.org/packages/15/d1/b51471c11592ff9c012bd3e2f7334a6ff2f42a7aed2caffcf0bdddc9cb89/wrapt-2.0.1-py3-none-any.whl", hash = "sha256:4d2ce1bf1a48c5277d7969259232b57645aae5686dba1eaeade39442277afbca", size = 44046, upload-time = "2025-11-07T00:45:32.116Z" }, +] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +] + +[[package]] +name = "zstandard" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" }, + { url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" }, + { url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" }, + { url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" }, + { url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" }, + { url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" }, + { url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" }, + { url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" }, + { url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" }, + { url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" }, + { url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" }, +]