Import upstream snapshot d424a0c899f6b33cbd3daeb279913134349c0b63
Upstream: https://github.com/pollen-robotics/microduck_rl Upstream-Commit: d424a0c899f6b33cbd3daeb279913134349c0b63 Upstream-Branch: develop
This commit is contained in:
@@ -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`.
|
||||
Reference in New Issue
Block a user