microduck_rl/docs/superpowers/plans/2026-07-22-roller-slope.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

28 KiB

Mode pente roller_slope — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Entraîner une politique dédiée où microduck (rollers) démarre sur du plat avec une impulsion, roule sur une rampe descendante, et se laisse glisser jusqu'en bas en restant debout — sans aucun pilotage.

Architecture: Nouvelle tâche isolée clonée de velocity_rollers (même robot, même obs 61D → interchangeable au runtime). Terrain custom « plat + rampe » à angle interpolé par difficulté, curriculum de raideur maison, commande neutralisée, récompenses d'équilibre + posture debout nominale. Bouton Y de bascule dans infer_policy.py.

Tech Stack: Python, mjlab 1.3.x, MuJoCo (MjSpec terrains), rsl_rl (PPO), PyTorch, onnxruntime (déploiement), pytest.

Global Constraints

  • Observation unifiée 61D : twist (3D) + head_command (4D) + body_command (6D) en zéro-padding. Ne jamais changer ce layout — la politique doit charger via --new-cmd-obs.
  • Résolution des joints par NOM, jamais par index (roues passives intercalées).
  • Vitesse d'entrée via reset_root_state_uniform (velocity_range), JAMAIS via push_by_setting_velocity en mode reset (accumule sur l'état racine → free-joint diverge → NaN). Leçon roller_crouch.
  • Angles en radians dans le code physique ; les constantes de raideur sont exprimées en degrés (RAMP_DEG_MIN=2.0, RAMP_DEG_MAX=20.0) et converties.
  • Commits simples, style du dépôt (pas de Co-authored-by).
  • Tests dans tests/, lancés avec uv run pytest.

File Structure

  • Create src/mjlab_microduck/tasks/slope_terrain.pyramp_angle_by_difficulty() + FlatRampTerrainCfg (géométrie du terrain plat+rampe). Responsabilité unique : le terrain.
  • Modify src/mjlab_microduck/tasks/mdp.py — ajouter slope_move_masks() (pur) + terrain_levels_slope() (curriculum de raideur).
  • Create src/mjlab_microduck/tasks/microduck_roller_slope_env_cfg.pymake_microduck_roller_slope_env_cfg() + MicroduckRollerSlopeRlCfg.
  • Modify src/mjlab_microduck/tasks/__init__.py — enregistrer la tâche.
  • Modify scripts/infer_policy.py — flag --slope + touche Y.
  • Create tests/test_slope_terrain.py, tests/test_slope_curriculum.py, tests/test_roller_slope_cfg.py.

Task 1 : angle de rampe par difficulté (fonction pure)

Files:

  • Create: src/mjlab_microduck/tasks/slope_terrain.py
  • Test: tests/test_slope_terrain.py

Interfaces:

  • Produces: ramp_angle_by_difficulty(difficulty: float, deg_min: float = 2.0, deg_max: float = 20.0) -> float (retourne des radians). Constantes module RAMP_DEG_MIN = 2.0, RAMP_DEG_MAX = 20.0.

  • Step 1: Écrire le test qui échoue

# tests/test_slope_terrain.py
import math
from mjlab_microduck.tasks.slope_terrain import (
    ramp_angle_by_difficulty,
    RAMP_DEG_MIN,
    RAMP_DEG_MAX,
)


def test_ramp_angle_endpoints():
    assert math.isclose(ramp_angle_by_difficulty(0.0), math.radians(RAMP_DEG_MIN), abs_tol=1e-9)
    assert math.isclose(ramp_angle_by_difficulty(1.0), math.radians(RAMP_DEG_MAX), abs_tol=1e-9)


def test_ramp_angle_midpoint():
    mid_deg = (RAMP_DEG_MIN + RAMP_DEG_MAX) / 2.0
    assert math.isclose(ramp_angle_by_difficulty(0.5), math.radians(mid_deg), abs_tol=1e-9)


def test_ramp_angle_clamps_out_of_range():
    assert math.isclose(ramp_angle_by_difficulty(-1.0), math.radians(RAMP_DEG_MIN), abs_tol=1e-9)
    assert math.isclose(ramp_angle_by_difficulty(2.0), math.radians(RAMP_DEG_MAX), abs_tol=1e-9)
  • Step 2: Lancer le test — il doit échouer

Run: uv run pytest tests/test_slope_terrain.py -v Expected: FAIL — ModuleNotFoundError: No module named 'mjlab_microduck.tasks.slope_terrain'

  • Step 3: Implémentation minimale
# src/mjlab_microduck/tasks/slope_terrain.py
"""Terrain custom « plat + rampe descendante » pour la tâche roller_slope.

Le robot spawne sur une zone plate, reçoit une impulsion vers +x, roule
jusqu'à la rampe et se laisse glisser. L'angle de la rampe est interpolé par
la difficulté (curriculum) sur [RAMP_DEG_MIN, RAMP_DEG_MAX] degrés.
"""

from __future__ import annotations

import math

import numpy as np

RAMP_DEG_MIN = 2.0
RAMP_DEG_MAX = 20.0


def ramp_angle_by_difficulty(
    difficulty: float, deg_min: float = RAMP_DEG_MIN, deg_max: float = RAMP_DEG_MAX
) -> float:
    """Angle de rampe (radians) interpolé linéairement par la difficulté [0,1]."""
    d = float(np.clip(difficulty, 0.0, 1.0))
    return math.radians(deg_min + d * (deg_max - deg_min))
  • Step 4: Lancer le test — il doit passer

Run: uv run pytest tests/test_slope_terrain.py -v Expected: PASS (3 tests)

  • Step 5: Commit
git add src/mjlab_microduck/tasks/slope_terrain.py tests/test_slope_terrain.py
git commit -m "roller-slope: angle de rampe par difficulte (fonction pure + tests)"

Task 2 : terrain custom FlatRampTerrainCfg

Files:

  • Modify: src/mjlab_microduck/tasks/slope_terrain.py
  • Test: tests/test_slope_terrain.py

Interfaces:

  • Consumes: ramp_angle_by_difficulty (Task 1), SubTerrainCfg, TerrainGeometry, TerrainOutput de mjlab.terrains.terrain_generator.
  • Produces: FlatRampTerrainCfg(SubTerrainCfg) avec champs flat_length: float = 2.0, ramp_length: float = 5.0, deg_min: float = 2.0, deg_max: float = 20.0, thickness: float = 0.5 ; méthode function(difficulty, spec, rng) -> TerrainOutput. L'origine de spawn est sur le plat.

Notes géométrie (à retenir) : la surface du plat est à z=0 local. La rampe est un box tourné autour de +y par un quaternion [cos(a/2), 0, sin(a/2), 0] — une rotation +a autour de +y abaisse le bord +x (la rampe descend quand x augmente). L'assemblage exact plat/rampe (pas de marche, pas de trou) doit être vérifié dans le viewer (Step 6) car le z du centre de la rampe est sensible.

  • Step 1: Écrire le test qui échoue
# tests/test_slope_terrain.py  (ajouter)
import mujoco
import numpy as np
from mjlab_microduck.tasks.slope_terrain import FlatRampTerrainCfg


def _empty_terrain_spec():
    spec = mujoco.MjSpec()
    spec.worldbody.add_body(name="terrain")
    return spec


def test_flat_ramp_builds_geoms_and_origin_on_flat():
    cfg = FlatRampTerrainCfg(flat_length=2.0, ramp_length=5.0)
    cfg.size = (8.0, 4.0)  # posé normalement par le générateur
    spec = _empty_terrain_spec()
    out = cfg.function(difficulty=0.5, spec=spec, rng=np.random.default_rng(0))
    # deux géométries : plat + rampe
    assert len(out.geometries) == 2
    # origine sur le plat (x dans [0, flat_length], z ~ 0)
    assert 0.0 <= out.origin[0] <= 2.0
    assert abs(out.origin[2]) < 1e-6


def test_flat_ramp_steeper_at_higher_difficulty():
    # à difficulté plus haute, le bout de rampe descend plus bas
    cfg = FlatRampTerrainCfg()
    cfg.size = (8.0, 4.0)
    easy = cfg.function(0.0, _empty_terrain_spec(), np.random.default_rng(0))
    hard = cfg.function(1.0, _empty_terrain_spec(), np.random.default_rng(0))
    # la rampe (2e géométrie) est plus basse (centre z plus négatif) en difficile
    assert hard.geometries[1].geom.pos[2] < easy.geometries[1].geom.pos[2]
  • Step 2: Lancer le test — il doit échouer

Run: uv run pytest tests/test_slope_terrain.py -k flat_ramp -v Expected: FAIL — ImportError: cannot import name 'FlatRampTerrainCfg'

  • Step 3: Implémentation minimale
# src/mjlab_microduck/tasks/slope_terrain.py  (ajouter en tête)
from dataclasses import dataclass

import mujoco

from mjlab.terrains.terrain_generator import (
    SubTerrainCfg,
    TerrainGeometry,
    TerrainOutput,
)


@dataclass(kw_only=True)
class FlatRampTerrainCfg(SubTerrainCfg):
    """Zone plate de départ suivie d'une rampe descendante (angle par difficulté)."""

    flat_length: float = 2.0   # longueur du plat de départ le long de +x (m)
    ramp_length: float = 5.0   # longueur horizontale de la rampe le long de +x (m)
    deg_min: float = RAMP_DEG_MIN
    deg_max: float = RAMP_DEG_MAX
    thickness: float = 0.5     # épaisseur des box (m)

    def function(
        self, difficulty: float, spec: mujoco.MjSpec, rng
    ) -> TerrainOutput:
        del rng  # non utilisé
        body = spec.body("terrain")
        angle = ramp_angle_by_difficulty(difficulty, self.deg_min, self.deg_max)
        width = self.size[1]
        t = self.thickness

        # Plat : box dont la surface supérieure est à z=0, x dans [0, flat_length].
        flat = body.add_geom(
            type=mujoco.mjtGeom.mjGEOM_BOX,
            size=(self.flat_length / 2.0, width / 2.0, t / 2.0),
            pos=(self.flat_length / 2.0, 0.0, -t / 2.0),
        )

        # Rampe : box tourné de +angle autour de +y (le bord +x descend).
        # Longueur de surface = ramp_length / cos(angle).
        surf_len = self.ramp_length / math.cos(angle)
        ramp_cx = self.flat_length + self.ramp_length / 2.0
        # Centre z : mi-descente de la surface, moins la demi-épaisseur projetée.
        ramp_cz = -(self.ramp_length * math.tan(angle) / 2.0) - (t / 2.0) * math.cos(angle)
        half = angle / 2.0
        ramp = body.add_geom(
            type=mujoco.mjtGeom.mjGEOM_BOX,
            size=(surf_len / 2.0, width / 2.0, t / 2.0),
            pos=(ramp_cx, 0.0, ramp_cz),
            quat=(math.cos(half), 0.0, math.sin(half), 0.0),
        )

        origin = np.array([self.flat_length * 0.4, 0.0, 0.0])
        return TerrainOutput(
            origin=origin,
            geometries=[
                TerrainGeometry(geom=flat, color=(0.5, 0.5, 0.5, 1.0)),
                TerrainGeometry(geom=ramp, color=(0.45, 0.55, 0.75, 1.0)),
            ],
        )
  • Step 4: Lancer les tests — ils doivent passer

Run: uv run pytest tests/test_slope_terrain.py -v Expected: PASS (5 tests)

  • Step 5: Commit
git add src/mjlab_microduck/tasks/slope_terrain.py tests/test_slope_terrain.py
git commit -m "roller-slope: terrain custom plat+rampe (FlatRampTerrainCfg + tests)"
  • Step 6: Vérification visuelle (checkpoint humain)

La géométrie (surtout ramp_cz et le signe du quaternion) doit être confirmée à l'œil. Après la Task 4 (env assemblé), lancer le viewer play (voir Task 4 Step 6) et vérifier : la zone plate rejoint la rampe sans marche ni trou, et la rampe descend dans la direction +x (devant le robot). Si un décalage vertical apparaît, ajuster ramp_cz ; si la rampe monte au lieu de descendre, inverser le signe (-half) du quaternion.


Task 3 : curriculum de raideur terrain_levels_slope

Files:

  • Modify: src/mjlab_microduck/tasks/mdp.py
  • Test: tests/test_slope_curriculum.py

Interfaces:

  • Produces:

    • slope_move_masks(distance: torch.Tensor, size_x: float) -> tuple[torch.Tensor, torch.Tensor] — helper pur. move_up = distance > size_x * 0.5 (a atteint le bas → rampe plus raide) ; move_down = (distance < size_x * 0.2) & ~move_up (chute/blocage tôt → rampe plus douce). Retourne (move_up, move_down) en bool.
    • terrain_levels_slope(env, env_ids) -> torch.Tensor — signature curriculum mjlab ; calcule la distance parcourue en x depuis l'origine, applique slope_move_masks, appelle terrain.update_env_origins, retourne le niveau moyen.
  • Step 1: Écrire le test qui échoue

# tests/test_slope_curriculum.py
import torch
from mjlab_microduck.tasks.mdp import slope_move_masks


def test_move_up_when_reached_bottom():
    # distance > size_x/2 → monte en difficulté
    dist = torch.tensor([5.0, 4.1])
    up, down = slope_move_masks(dist, size_x=8.0)
    assert bool(up[0]) and bool(up[1])
    assert not bool(down[0]) and not bool(down[1])


def test_move_down_when_stuck_early():
    # distance < size_x*0.2 (=1.6) → descend en difficulté
    dist = torch.tensor([0.5, 1.0])
    up, down = slope_move_masks(dist, size_x=8.0)
    assert not bool(up[0]) and not bool(up[1])
    assert bool(down[0]) and bool(down[1])


def test_stay_in_middle_band():
    # entre 1.6 et 4.0 → ni haut ni bas
    dist = torch.tensor([2.5])
    up, down = slope_move_masks(dist, size_x=8.0)
    assert not bool(up[0]) and not bool(down[0])
  • Step 2: Lancer le test — il doit échouer

Run: uv run pytest tests/test_slope_curriculum.py -v Expected: FAIL — ImportError: cannot import name 'slope_move_masks'

  • Step 3: Implémentation minimale

Ajouter dans src/mjlab_microduck/tasks/mdp.py (près des autres curriculums, ex. après com_range_curriculum). Vérifier en tête de fichier que torch est importé (il l'est).

def slope_move_masks(distance: "torch.Tensor", size_x: float):
    """Masques de promotion/rétrogradation du curriculum de pente.

    move_up   : a parcouru plus de la moitié de la tuile → il a dévalé la rampe,
                on la rend plus raide.
    move_down : a à peine avancé (< 20% de la tuile) → chute/blocage précoce,
                on adoucit la rampe.
    """
    move_up = distance > size_x * 0.5
    move_down = (distance < size_x * 0.2) & (~move_up)
    return move_up, move_down


def terrain_levels_slope(env, env_ids):
    """Curriculum de raideur pour roller_slope (pas de vitesse commandée).

    Progression basée sur la distance en x parcourue depuis l'origine de spawn.
    """
    asset = env.scene["robot"]
    terrain = env.scene.terrain
    assert terrain is not None
    terrain_generator = terrain.cfg.terrain_generator
    assert terrain_generator is not None

    distance = (
        asset.data.root_link_pos_w[env_ids, 0] - env.scene.env_origins[env_ids, 0]
    )
    move_up, move_down = slope_move_masks(distance, terrain_generator.size[0])
    terrain.update_env_origins(env_ids, move_up, move_down)
    return torch.mean(terrain.terrain_levels.float())
  • Step 4: Lancer le test — il doit passer

Run: uv run pytest tests/test_slope_curriculum.py -v Expected: PASS (3 tests)

  • Step 5: Commit
git add src/mjlab_microduck/tasks/mdp.py tests/test_slope_curriculum.py
git commit -m "roller-slope: curriculum de raideur terrain_levels_slope (+ helper pur teste)"

Task 4 : env cfg roller_slope + enregistrement

Files:

  • Create: src/mjlab_microduck/tasks/microduck_roller_slope_env_cfg.py
  • Modify: src/mjlab_microduck/tasks/__init__.py
  • Test: tests/test_roller_slope_cfg.py

Interfaces:

  • Consumes: make_microduck_velocity_rollers_env_cfg (base physique/DR/obs), FlatRampTerrainCfg (Task 2), terrain_levels_slope (Task 3), fonctions mdp existantes : body_upright_gaussian, is_alive, pose_target_match, pose_l1_penalty, feet_flat_penalty, neck_action_rate_l2, joint_torques_l2, robot_state_is_nan, reset_action_history, zero_command_padding.
  • Produces: make_microduck_roller_slope_env_cfg(play: bool = False) -> ManagerBasedRlEnvCfg et MicroduckRollerSlopeRlCfg (RslRlOnPolicyRunnerCfg, experiment_name="roller_slope").

Réutiliser les blocs DR/obs/reset du roller env : on part de make_microduck_velocity_rollers_env_cfg() et on ne modifie QUE terrain, commande, récompenses, terminaisons, curriculum. Ne pas réécrire la DR.

  • Step 1: Écrire le test qui échoue
# tests/test_roller_slope_cfg.py
from mjlab_microduck.tasks.microduck_roller_slope_env_cfg import (
    make_microduck_roller_slope_env_cfg,
)
from mjlab_microduck.tasks.slope_terrain import FlatRampTerrainCfg


def test_terrain_is_flat_ramp_generator():
    cfg = make_microduck_roller_slope_env_cfg()
    assert cfg.scene.terrain.terrain_type == "generator"
    gen = cfg.scene.terrain.terrain_generator
    assert gen is not None and gen.curriculum is True
    assert any(isinstance(st, FlatRampTerrainCfg) for st in gen.sub_terrains.values())


def test_command_is_neutralised():
    cfg = make_microduck_roller_slope_env_cfg()
    cmd = cfg.commands["twist"]
    assert cmd.rel_standing_envs == 1.0
    assert cmd.rel_heading_envs == 0.0


def test_entry_velocity_set_on_reset_base():
    cfg = make_microduck_roller_slope_env_cfg()
    vr = cfg.events["reset_base"].params["velocity_range"]
    assert vr["x"][0] > 0.0  # impulsion vers l'avant


def test_has_upright_and_pose_rewards():
    cfg = make_microduck_roller_slope_env_cfg()
    for name in ("upright", "alive", "standing_pose", "feet_flat"):
        assert name in cfg.rewards
  • Step 2: Lancer le test — il doit échouer

Run: uv run pytest tests/test_roller_slope_cfg.py -v Expected: FAIL — ModuleNotFoundError (module env cfg absent)

  • Step 3: Implémentation
# src/mjlab_microduck/tasks/microduck_roller_slope_env_cfg.py
"""Microduck roller slope — descente passive équilibrée.

Le robot spawne sur du plat (impulsion vers l'avant), roule sur une rampe
descendante et se laisse glisser en restant debout. Aucun pilotage : la
commande twist est neutralisée (rel_standing_envs=1.0). Terrain custom
plat+rampe (FlatRampTerrainCfg), curriculum de raideur (terrain_levels_slope).
Obs 61D unifié → interchangeable au runtime (--new-cmd-obs).
"""

from mjlab.envs import ManagerBasedRlEnvCfg
from mjlab.managers import CurriculumTermCfg, EventTermCfg, RewardTermCfg, TerminationTermCfg
from mjlab.managers.scene_entity_config import SceneEntityCfg
from mjlab.rl import RslRlOnPolicyRunnerCfg, RslRlModelCfg
from mjlab.terrains import TerrainEntityCfg
from mjlab.terrains.terrain_generator import TerrainGeneratorCfg
from mjlab.tasks.velocity import mdp
from mjlab.envs import mdp as base_mdp

from mjlab_microduck.tasks import mdp as microduck_mdp
from mjlab_microduck.tasks.slope_terrain import FlatRampTerrainCfg
from mjlab_microduck.tasks.microduck_velocity_rollers_env_cfg import (
    make_microduck_velocity_rollers_env_cfg,
)
from mjlab_microduck.tasks.symmetry import PpoWithSymmetryCfg

ENTRY_VELOCITY_X = (0.2, 0.5)  # impulsion vers l'avant au reset (m/s)


def make_microduck_roller_slope_env_cfg(play: bool = False) -> ManagerBasedRlEnvCfg:
    cfg = make_microduck_velocity_rollers_env_cfg(play=play)

    # === TERRAIN : plat + rampe, curriculum de raideur ===
    cfg.scene.terrain = TerrainEntityCfg(
        terrain_type="generator",
        terrain_generator=TerrainGeneratorCfg(
            size=(8.0, 4.0),
            curriculum=True,
            num_rows=10,          # 10 niveaux de raideur
            num_cols=1,
            difficulty_range=(0.0, 1.0),
            sub_terrains={"flat_ramp": FlatRampTerrainCfg(flat_length=2.0, ramp_length=5.0)},
        ),
        max_init_terrain_level=0,  # démarrer sur la rampe la plus douce
    )

    # === COMMANDE neutralisée (équilibre pur) ===
    command = cfg.commands["twist"]
    command.rel_standing_envs = 1.0
    command.rel_heading_envs = 0.0
    command.ranges.lin_vel_x = (0.0, 0.0)
    command.ranges.lin_vel_y = (0.0, 0.0)
    if getattr(command.ranges, "ang_vel_z", None) is not None:
        command.ranges.ang_vel_z = (0.0, 0.0)

    # === RESET : impulsion vers l'avant sur le plat ===
    cfg.events["reset_base"].params["velocity_range"] = {"x": ENTRY_VELOCITY_X}

    # === RÉCOMPENSES : équilibre + posture debout nominale ===
    keep = {"action_rate_l2"}
    for name in list(cfg.rewards.keys()):
        if name not in keep:
            del cfg.rewards[name]

    cfg.rewards["upright"] = RewardTermCfg(
        func=microduck_mdp.body_upright_gaussian,
        weight=3.0,
        params={"asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",)), "std": 0.2},
    )
    cfg.rewards["alive"] = RewardTermCfg(func=microduck_mdp.is_alive, weight=1.0)
    # posture debout nominale (cible fixe = default_joint_pos, aucun override)
    cfg.rewards["standing_pose"] = RewardTermCfg(
        func=microduck_mdp.pose_target_match, weight=3.0, params={"std": 0.4},
    )
    cfg.rewards["standing_pose_l1"] = RewardTermCfg(
        func=microduck_mdp.pose_l1_penalty, weight=1.0,
    )
    cfg.rewards["feet_flat"] = RewardTermCfg(
        func=microduck_mdp.feet_flat_penalty,
        weight=-2.0,
        params={
            "asset_cfg": SceneEntityCfg("robot", site_names=("left_foot", "right_foot")),
            "sensor_name": "feet_ground_contact",
        },
    )
    cfg.rewards["neck_action_rate_l2"] = RewardTermCfg(
        func=microduck_mdp.neck_action_rate_l2, weight=-0.5,
    )
    cfg.rewards["joint_torques_l2"] = RewardTermCfg(
        func=microduck_mdp.joint_torques_l2, weight=-1e-3,
    )
    cfg.rewards["action_rate_l2"].weight = -1.0

    # === TERMINATIONS : chute + bas atteint ===
    cfg.terminations["fell_over"] = TerminationTermCfg(
        func=base_mdp.bad_orientation,
        params={"limit_angle": 1.0, "asset_cfg": SceneEntityCfg("robot", body_names=("trunk_base",))},
    )
    cfg.terminations["out_of_bounds"] = TerminationTermCfg(func=mdp.out_of_terrain_bounds)
    cfg.terminations["nan_state"] = TerminationTermCfg(
        func=microduck_mdp.robot_state_is_nan, time_out=False,
    )

    # === EVENTS ===
    cfg.events["reset_action_history"] = EventTermCfg(
        func=microduck_mdp.reset_action_history, mode="reset",
    )

    # === CURRICULUM : raideur de la rampe ===
    for name in list(cfg.curriculum.keys()):
        del cfg.curriculum[name]
    cfg.curriculum["terrain_levels"] = CurriculumTermCfg(func=microduck_mdp.terrain_levels_slope)

    return cfg


MicroduckRollerSlopeRlCfg = RslRlOnPolicyRunnerCfg(
    actor=RslRlModelCfg(
        hidden_dims=(512, 256, 128),
        activation="elu",
        obs_normalization=True,
        distribution_cfg={"class_name": "GaussianDistribution", "init_std": 1.0, "std_type": "scalar"},
    ),
    critic=RslRlModelCfg(hidden_dims=(512, 256, 128), activation="elu", obs_normalization=True),
    algorithm=PpoWithSymmetryCfg(
        value_loss_coef=1.0, use_clipped_value_loss=True, clip_param=0.2,
        entropy_coef=0.01, num_learning_epochs=5, num_mini_batches=4,
        learning_rate=1.0e-3, schedule="adaptive", gamma=0.99, lam=0.95,
        desired_kl=0.01, max_grad_norm=1.0, symmetry_cfg=None,
    ),
    wandb_project="mjlab_microduck",
    experiment_name="roller_slope",
    run_name="roller_slope",
    save_interval=250,
    num_steps_per_env=24,
    max_iterations=8_000,
)

Puis enregistrer dans src/mjlab_microduck/tasks/__init__.py, en suivant EXACTEMENT le pattern d'enregistrement de roller_crouch déjà présent (import de make_... + Microduck...RlCfg, puis register_mjlab_task(...) avec un id du style "Microduck-Roller-Slope"). Copier le bloc roller_crouch et remplacer crouchslope.

  • Step 4: Lancer les tests — ils doivent passer

Run: uv run pytest tests/test_roller_slope_cfg.py -v Expected: PASS (4 tests)

  • Step 5: Vérifier l'enregistrement de la tâche + build complet

Run:

uv run python -c "import gymnasium as gym; import mjlab_microduck.tasks; print([e for e in gym.registry if 'Slope' in e])"

Expected: la liste contient l'id Microduck-Roller-Slope (ou variante enregistrée).

  • Step 6: Vérification visuelle du terrain + descente (checkpoint humain — clôt Task 2 Step 6)

Lancer un court entraînement puis le play (ou scripts/play_latest.py selon l'usage du dépôt) et observer :

  1. Plat + rampe assemblés sans marche/trou ; la rampe descend devant le robot.
  2. Le robot spawne sur le plat, part vers l'avant, atteint la rampe. Si la géométrie est fausse, corriger slope_terrain.py (voir Task 2 Step 6) et re-commit.
  • Step 7: Commit
git add src/mjlab_microduck/tasks/microduck_roller_slope_env_cfg.py src/mjlab_microduck/tasks/__init__.py tests/test_roller_slope_cfg.py
git commit -m "roller-slope: env descente passive (terrain plat+rampe, cmd nulle, rewards equilibre) + enregistrement"

Task 5 : déploiement — flag --slope + touche Y

Files:

  • Modify: scripts/infer_policy.py

Interfaces:

  • Consumes: le .onnx exporté de la politique roller_slope.
  • Produces: argument CLI --slope <path> ; attribut self.slope_session + flag self.slope_mode ; méthode toggle_slope_mode() ; touche GLFW_KEY_Y = 89 câblée.

La politique pente tourne avec commande twist nulle (comme le mode standing). En slope mode, la bascule automatique walking/standing doit être neutralisée.

  • Step 1: Ajouter l'argument CLI et charger la session

Dans main() (près des autres add_argument, ~ligne 471) :

    parser.add_argument("--slope", type=str, default=None, help="Path to slope policy ONNX file (press Y to toggle)")

Passer slope_onnx_path=args.slope au constructeur du contrôleur (ajouter le paramètre slope_onnx_path=None à __init__, ~ligne 51-57, et charger comme les autres) :

        self.slope_session = None
        self.slope_mode = False
        if slope_onnx_path:
            print(f"\nLoading slope policy from: {slope_onnx_path}")
            self.slope_session = ort.InferenceSession(slope_onnx_path)
  • Step 2: Ajouter toggle_slope_mode et neutraliser la bascule auto

Après toggle_body_pose_mode (~ligne 285) :

    def toggle_slope_mode(self):
        """Bascule vers/depuis la politique pente (descente passive)."""
        if self.slope_session is None:
            print("Slope unavailable: no --slope policy loaded")
            return
        self.slope_mode = not self.slope_mode
        if self.slope_mode:
            self.ort_session = self.slope_session
            self.current_policy = "slope"
            self.set_vel_cmd(0.0, 0.0, 0.0)  # descente passive : commande nulle
            print("Slope mode: ON (descente passive)")
        else:
            self.ort_session = self.walking_session or self.standing_session
            self.current_policy = "walking" if self.walking_session else "standing"
            print("Slope mode: OFF")

Dans _update_policy_session (~ligne 250), ajouter le garde en tête (après le garde ground_pick_mode) :

        if self.slope_mode:
            return  # Ne pas basculer pendant le mode pente
  • Step 3: Câbler la touche Y

Ajouter le code de touche près des autres (~ligne 680) :

    GLFW_KEY_Y = 89

Dans key_callback, ajouter une branche (ex. après la branche GLFW_KEY_B) :

            elif key == GLFW_KEY_Y:
                policy.toggle_slope_mode()

Ajouter la ligne d'aide clavier (près des print ~ligne 821) :

    print("  Y:                toggle slope mode (requires --slope, descente passive)")
  • Step 4: Vérifier que le script se charge sans erreur

Run: uv run python scripts/infer_policy.py --help Expected: l'aide s'affiche et liste --slope.

  • Step 5: Commit
git add scripts/infer_policy.py
git commit -m "roller-slope: deploiement --slope + touche Y (bascule mode pente)"

Self-Review (fait par l'auteur du plan)

  • Couverture spec : tâche dédiée (Task 4) ✓ ; terrain plat+rampe custom (Task 2) ✓ ; départ plat + impulsion (Task 4 reset velocity_range) ✓ ; commande nulle (Task 4) ✓ ; récompenses équilibre + pose debout + anti-écrasement (Task 4) ✓ ; terminaisons chute/bas/nan (Task 4) ✓ ; curriculum 0→20° (Task 1 angle + Task 3 promotion) ✓ ; obs 61D interchangeable (hérité du roller env, non modifié) ✓ ; bouton Y (Task 5) ✓.
  • Placeholders : aucun « TBD/TODO » ; les deux checkpoints humains (géométrie viewer) sont des vérifications explicites, pas des trous d'implémentation.
  • Cohérence des types : ramp_angle_by_difficulty (Task 1) réutilisé par FlatRampTerrainCfg (Task 2) ; slope_move_masks (Task 3) consommé par terrain_levels_slope (Task 3) ; noms de récompenses testés en Task 4 (upright, alive, standing_pose, feet_flat) alignés sur l'implémentation.
  • Risques signalés : géométrie de la rampe (ramp_cz, signe du quaternion) à confirmer au viewer ; noms exacts d'API mjlab (terrain.terrain_levels, TerrainEntityCfg, id d'enregistrement) à valider contre le pattern roller_crouch existant lors de l'implémentation.