Import upstream snapshot d424a0c899f6b33cbd3daeb279913134349c0b63

Upstream: https://github.com/pollen-robotics/microduck_rl
Upstream-Commit: d424a0c899f6b33cbd3daeb279913134349c0b63
Upstream-Branch: develop
This commit is contained in:
Upstream Snapshot
2026-08-28 15:41:56 +08:00
commit 47372443ff
222 changed files with 38327 additions and 0 deletions
@@ -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).
@@ -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 <onnx>` + 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 <onnx>` 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).
@@ -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.
@@ -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).
@@ -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 <match>
```
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 <N>
```
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).
@@ -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=<head command obs>, 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`.
@@ -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`.
@@ -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`.