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

23 KiB
Raw Permalink Blame History

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écroissanteretenue. 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 `
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
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

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

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é
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_over70, 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_place0,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.