microduck_rl/docs/superpowers/specs/2026-08-04-spin-env-design.md
Upstream Snapshot 47372443ff Import upstream snapshot d424a0c899f6b33cbd3daeb279913134349c0b63
Upstream: https://github.com/pollen-robotics/microduck_rl
Upstream-Commit: d424a0c899f6b33cbd3daeb279913134349c0b63
Upstream-Branch: develop
2026-08-28 15:41:56 +08:00

418 lines
23 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 36 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`.