microduck_rl/docs/superpowers/specs/2026-08-04-roller-standup-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

20 KiB
Raw Blame History

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.13350.1435 déjà utilisé par l'env roller.

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
_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 :

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) :

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.110.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 2842 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 36 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

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.