Upstream: https://github.com/pollen-robotics/microduck_rl Upstream-Commit: d424a0c899f6b33cbd3daeb279913134349c0b63 Upstream-Branch: develop
20 KiB
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) -> ManagerBasedRlEnvCfgMicroduckRollerStandUpRlCfg(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 surankle_{l,r}_v1) etself_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.
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.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 :
- l'env se construit (
play=Falseetplay=True) ; - les noms de joints aux indices
_LEG_JOINTS/_NECK_JOINTS/_WHEEL_JOINTSsont les bons (le verrou contre la fragilité des roues intercalées) ; - les récompenses de relevé attendues sont présentes, les récompenses de patinage absentes
(
wheel_speed,glide,single_support,feet_flat, …) ; fell_overabsent,nan_stateprésent ;- le curriculum
wheel_frictionest bien décroissant et finit à 0.0015 ; - le curriculum
ground_state_mix: les probabilités du dernier palier somment à 1 etface_up_probcroît de façon monotone ; - 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
- 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.
- Le bucket « dos » est le plus dur. Le
standupdocumente qu'il gelait en « ne rien faire » sur cette pose, et que la cause était les bloqueurs de mouvement (body_ang_velélevé,action_ratetrop fort). Les valeurs reprises ici sont celles de la version « se relève de partout » — ne pas les durcir sans raison. - Tête zero-paddée vs commande
head_pose. Si la policy est déployée en--standinget que quelqu'un actionne les touches de tête,infer_policyécritcmd[3:7] = head_offsetet 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. - 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 demdp.py.