microduck_rl/docs/superpowers/plans/2026-07-24-shoot-pose-following.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

30 KiB

Tâche shoot par suivi de poses — Plan d'implémentation

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: Ajouter une tâche RL Mjlab-Shoot-Flat-MicroDuck qui apprend un geste de shoot one-shot (jambe droite) par suivi d'une trajectoire de poses à 4 keyframes (STAND → PIED_ARRIÈRE → PIED_AVANT → STAND) interpolée par la phase.

Architecture: Même moule que la tâche ground_pick de cette branche. Une commande de phase (GroundPickPhaseCommand, [cos,sin,0]) pilote une cible articulaire interpolée entre 3 poses ; des rewards gaussien + L1 récompensent le suivi ; obs 61D unifiée pour déploiement dans un slot bouton du runtime. Aucune balle simulée.

Tech Stack: Python, PyTorch, mjlab 1.3.0, MuJoCo, uv, pytest.

Global Constraints

  • Obs 61D unifiée identique aux autres policies microduck ([gyro(3), projected_gravity(3), joint_pos(14), joint_vel(14), last_action(14), command(13)], head+body command zero-paddés). Ne pas casser cette forme.
  • Résolution des joints PAR NOM (asset.find_joints([name])), jamais par index en dur.
  • 14 joints actifs (mouth exclu). Robot MICRODUCK_WALK_ROBOT_CFG.
  • Ne pas modifier le runtime Rust ni la classe de commande de façon cassante : le flag randomize_phase ajouté DOIT défaut à True pour préserver ground_pick.
  • Jambe droite frappe, gauche en appui.
  • Tests : uv run --with pytest pytest tests/ -q.
  • Convention commits : messages en français, style feat:/docs:/test:.

File Structure

  • src/mjlab_microduck/tasks/mdp.py — MODIFIER : ajouter kick_pose_target (pure), _kick_pose_error, kick_pose_track, kick_pose_track_l1 ; ajouter le flag randomize_phase à GroundPickPhaseCommand / GroundPickPhaseCommandCfg.
  • src/mjlab_microduck/tasks/microduck_shoot_env_cfg.py — CRÉER : make_microduck_shoot_env_cfg, MicroduckShootRlCfg, STAND_POSE/KICK_BACK_POSE/KICK_FWD_POSE, timings.
  • src/mjlab_microduck/tasks/__init__.py — MODIFIER : import + register_mjlab_task("Mjlab-Shoot-Flat-MicroDuck", …).
  • tests/test_shoot.py — CRÉER : tests des fonctions pures (kick_pose_target) + rewards via stub-env.
  • tests/test_shoot_cfg.py — CRÉER : test d'intégration (l'env se construit, bonne commande/rewards).

Task 1: Flag randomize_phase sur la commande de phase

Files:

  • Modify: src/mjlab_microduck/tasks/mdp.py:3618-3672 (GroundPickPhaseCommand + GroundPickPhaseCommandCfg)
  • Test: tests/test_shoot.py

Interfaces:

  • Produces: GroundPickPhaseCommandCfg(randomize_phase: bool = True, period: float = 4.0, …) ; à l'exécution reset() met φ=0 quand randomize_phase=False, sinon rand().

  • Step 1: Écrire le test qui échoue

Créer tests/test_shoot.py avec :

from mjlab_microduck.tasks.mdp import GroundPickPhaseCommandCfg


def test_phase_cmd_randomize_flag_default_true():
    cfg = GroundPickPhaseCommandCfg()
    assert cfg.randomize_phase is True


def test_phase_cmd_randomize_flag_settable_false():
    cfg = GroundPickPhaseCommandCfg(randomize_phase=False)
    assert cfg.randomize_phase is False
  • Step 2: Lancer le test, vérifier l'échec

Run: uv run --with pytest pytest tests/test_shoot.py -q Expected: FAIL — TypeError: __init__() got an unexpected keyword argument 'randomize_phase'.

  • Step 3: Ajouter le champ au cfg + threading dans la classe

Dans GroundPickPhaseCommandCfg (dataclass, ~ligne 3667) ajouter le champ :

@_dataclass(kw_only=True)
class GroundPickPhaseCommandCfg(UniformVelocityCommandCfg):
    class_type: type = GroundPickPhaseCommand
    period: float = 4.0  # cycle length in seconds; sitstand uses 8.0
    randomize_phase: bool = True  # False -> chaque épisode démarre à φ=0 (STAND)

    def build(self, env: ManagerBasedRlEnv) -> "GroundPickPhaseCommand":
        return GroundPickPhaseCommand(self, env)

Dans GroundPickPhaseCommand.__init__ (~ligne 3634) lire le flag :

    def __init__(self, cfg, env: ManagerBasedRlEnv):
        super().__init__(cfg, env)
        self._gp_phase = torch.zeros(self.num_envs, device=self.device)
        self._period = float(getattr(cfg, "period", self.PERIOD))
        self._randomize_phase = bool(getattr(cfg, "randomize_phase", True))

Dans GroundPickPhaseCommand.reset (~ligne 3649) respecter le flag :

    def reset(self, env_ids: torch.Tensor | None) -> dict:
        if env_ids is not None and len(env_ids) > 0:
            if self._randomize_phase:
                self._gp_phase[env_ids] = torch.rand(len(env_ids), device=self.device)
            else:
                self._gp_phase[env_ids] = 0.0
        return {}
  • Step 4: Lancer le test, vérifier le succès

Run: uv run --with pytest pytest tests/test_shoot.py -q Expected: PASS (2 tests).

  • Step 5: Commit
git add src/mjlab_microduck/tasks/mdp.py tests/test_shoot.py
git commit -m "feat: flag randomize_phase sur GroundPickPhaseCommand (défaut True)"

Task 2: Fonction pure kick_pose_target

Files:

  • Modify: src/mjlab_microduck/tasks/mdp.py (ajouter près de phase_pose_blend, ~ligne 2062)
  • Test: tests/test_shoot.py

Interfaces:

  • Produces: kick_pose_target(phase: Tensor(B,), stand, back, forward, windup_end: float, kick_end: float, return_end: float) -> Tensor(B,k). stand/back/forward sont des tenseurs (k,) ou (1,k). Segments : [0,windup_end) STAND→BACK, [windup_end,kick_end) BACK→FORWARD, [kick_end,return_end) FORWARD→STAND, [return_end,1) STAND.

  • Step 1: Écrire les tests qui échouent

Ajouter à tests/test_shoot.py :

import torch
from mjlab_microduck.tasks.mdp import kick_pose_target

W, K, R = 0.35, 0.45, 0.75  # windup_end, kick_end, return_end
STAND = torch.tensor([0.0, 0.0])
BACK = torch.tensor([1.0, -1.0])
FWD = torch.tensor([-1.0, 2.0])


def _t(phase):
    return kick_pose_target(torch.tensor([phase]), STAND, BACK, FWD, W, K, R)[0]


def test_kick_target_keypoints():
    assert torch.allclose(_t(0.0), STAND)          # début: STAND
    assert torch.allclose(_t(W), BACK)             # fin armement: BACK
    assert torch.allclose(_t(K), FWD)              # fin frappe: FORWARD
    assert torch.allclose(_t(R), STAND)            # fin retour: STAND
    assert torch.allclose(_t(0.9), STAND)          # repos: STAND


def test_kick_target_midsegments():
    assert torch.allclose(_t(W / 2), 0.5 * BACK)                    # mi-armement
    assert torch.allclose(_t((W + K) / 2), 0.5 * (BACK + FWD))      # mi-frappe
    assert torch.allclose(_t((K + R) / 2), 0.5 * FWD)              # mi-retour


def test_kick_target_batch_shape():
    phase = torch.linspace(0.0, 1.0, 50)
    out = kick_pose_target(phase, STAND, BACK, FWD, W, K, R)
    assert out.shape == (50, 2)
    # chaque composante reste dans l'enveloppe des 3 poses
    lo = torch.minimum(torch.minimum(STAND, BACK), FWD)
    hi = torch.maximum(torch.maximum(STAND, BACK), FWD)
    assert (out >= lo - 1e-6).all() and (out <= hi + 1e-6).all()
  • Step 2: Lancer, vérifier l'échec

Run: uv run --with pytest pytest tests/test_shoot.py -q Expected: FAIL — ImportError: cannot import name 'kick_pose_target'.

  • Step 3: Implémenter la fonction pure

Ajouter dans mdp.py juste après phase_pose_blend (~ligne 2062) :

def kick_pose_target(
    phase: torch.Tensor,
    stand: torch.Tensor,
    back: torch.Tensor,
    forward: torch.Tensor,
    windup_end: float,
    kick_end: float,
    return_end: float,
) -> torch.Tensor:
    """Cible articulaire interpolée d'un geste de shoot à 4 keyframes.

    phase (B,) ∈ [0,1). stand/back/forward (k,) ou (1,k). Retour (B,k).

    [0, windup_end)        STAND   -> BACK     (armement)
    [windup_end, kick_end) BACK    -> FORWARD  (frappe sèche)
    [kick_end, return_end) FORWARD -> STAND    (retour)
    [return_end, 1.0)      STAND             (repos)
    """
    p = phase.unsqueeze(-1)  # (B,1)

    def interp(a, b, s):
        return a + s * (b - a)

    s1 = (p / windup_end).clamp(0.0, 1.0)
    s2 = ((p - windup_end) / (kick_end - windup_end)).clamp(0.0, 1.0)
    s3 = ((p - kick_end) / (return_end - kick_end)).clamp(0.0, 1.0)

    seg1 = interp(stand, back, s1)
    seg2 = interp(back, forward, s2)
    seg3 = interp(forward, stand, s3)  # à s3=1 (phase>=return_end) => STAND

    out = seg1
    out = torch.where(p >= windup_end, seg2, out)
    out = torch.where(p >= kick_end, seg3, out)
    return out
  • Step 4: Lancer, vérifier le succès

Run: uv run --with pytest pytest tests/test_shoot.py -q Expected: PASS (tous les tests kick_target).

  • Step 5: Commit
git add src/mjlab_microduck/tasks/mdp.py tests/test_shoot.py
git commit -m "feat: kick_pose_target — cible interpolée du geste de shoot (4 keyframes)"

Task 3: Rewards de suivi kick_pose_track / kick_pose_track_l1

Files:

  • Modify: src/mjlab_microduck/tasks/mdp.py (ajouter après kick_pose_target)
  • Test: tests/test_shoot.py

Interfaces:

  • Consumes: kick_pose_target (Task 2).

  • Produces:

    • kick_pose_track(env, command_name="twist", stand_pose=None, back_pose=None, forward_pose=None, std=0.4, windup_end=0.35, kick_end=0.45, return_end=0.75, asset_cfg=_DEFAULT_ASSET_CFG) -> Tensor(B,) — gaussienne exp(-((q-cible)/std)²).mean.
    • kick_pose_track_l1(env, …mêmes args sauf std) -> Tensor(B,)-(|q-cible|).mean.
    • Helper _kick_pose_error(env, asset_cfg, command_name, stand_pose, back_pose, forward_pose, windup_end, kick_end, return_end) -> (cur, target).
  • Step 1: Écrire le test qui échoue (stub-env)

Ajouter à tests/test_shoot.py :

from mjlab_microduck.tasks.mdp import kick_pose_track, kick_pose_track_l1

STAND_D = {"a": 0.0, "b": 0.0}
BACK_D = {"a": 1.0, "b": -1.0}
FWD_D = {"a": -1.0, "b": 2.0}
_IDX = {"a": 0, "b": 1}


class _FakeData:
    def __init__(self, joint_pos):
        self.joint_pos = joint_pos
        self.default_joint_pos = torch.zeros_like(joint_pos)


class _FakeAsset:
    def __init__(self, joint_pos):
        self.data = _FakeData(joint_pos)

    def find_joints(self, names):
        return ([_IDX[names[0]]], names)


class _FakeScene:
    def __init__(self, asset):
        self._a = asset

    def __getitem__(self, name):
        return self._a


class _FakeCmdMgr:
    def __init__(self, cmd):
        self._cmd = cmd

    def get_command(self, name):
        return self._cmd


class _FakeEnv:
    def __init__(self, joint_pos, phase):
        self.scene = _FakeScene(_FakeAsset(joint_pos))
        # cmd = [cos, sin, 0]
        cmd = torch.stack(
            [torch.cos(2 * torch.pi * phase), torch.sin(2 * torch.pi * phase),
             torch.zeros_like(phase)], dim=-1)
        self.command_manager = _FakeCmdMgr(cmd)
        self.device = "cpu"
        self.num_envs = joint_pos.shape[0]


def test_kick_track_perfect_at_stand_phase():
    # phase=0 -> cible STAND=[0,0] ; joint_pos exactement STAND -> reward ~1
    env = _FakeEnv(torch.tensor([[0.0, 0.0]]), torch.tensor([0.0]))
    r = kick_pose_track(env, stand_pose=STAND_D, back_pose=BACK_D, forward_pose=FWD_D)
    assert torch.allclose(r, torch.tensor([1.0]), atol=1e-4)


def test_kick_track_lower_when_off_target():
    # phase=0.45 (kick_end) -> cible FORWARD=[-1,2] ; joint_pos=STAND -> reward < 0.5
    env = _FakeEnv(torch.tensor([[0.0, 0.0]]), torch.tensor([0.45]))
    r = kick_pose_track(env, stand_pose=STAND_D, back_pose=BACK_D, forward_pose=FWD_D)
    assert (r < 0.5).all()


def test_kick_track_l1_zero_when_perfect():
    env = _FakeEnv(torch.tensor([[0.0, 0.0]]), torch.tensor([0.0]))
    r = kick_pose_track_l1(env, stand_pose=STAND_D, back_pose=BACK_D, forward_pose=FWD_D)
    assert torch.allclose(r, torch.tensor([0.0]), atol=1e-6)
  • Step 2: Lancer, vérifier l'échec

Run: uv run --with pytest pytest tests/test_shoot.py -q Expected: FAIL — ImportError: cannot import name 'kick_pose_track'.

  • Step 3: Implémenter helper + rewards

Ajouter dans mdp.py après kick_pose_target :

def _kick_pose_error(
    env: ManagerBasedRlEnv,
    asset_cfg: SceneEntityCfg,
    command_name: str,
    stand_pose: dict,
    back_pose: dict,
    forward_pose: dict,
    windup_end: float,
    kick_end: float,
    return_end: float,
):
    """(cur, target) pour le geste de shoot, joints résolus PAR NOM.

    Les 3 poses partagent les mêmes clés (14 joints). L'ordre des noms est
    donné par `stand_pose`.
    """
    if not stand_pose:
        raise ValueError("_kick_pose_error requires a non-empty stand_pose dict")
    asset: Entity = env.scene[asset_cfg.name]
    names = list(stand_pose.keys())
    ids = [int(asset.find_joints([n])[0][0]) for n in names]

    def vec(d):
        return torch.tensor([d[n] for n in names], device=env.device,
                            dtype=asset.data.joint_pos.dtype)

    stand_v, back_v, fwd_v = vec(stand_pose), vec(back_pose), vec(forward_pose)

    cmd = env.command_manager.get_command(command_name)
    phase = (torch.atan2(cmd[:, 1], cmd[:, 0]) / (2 * torch.pi)) % 1.0  # (B,)
    target = kick_pose_target(phase, stand_v, back_v, fwd_v,
                              windup_end, kick_end, return_end)          # (B,k)
    cur = asset.data.joint_pos[:, ids]                                   # (B,k)
    return cur, target


def kick_pose_track(
    env: ManagerBasedRlEnv,
    command_name: str = "twist",
    stand_pose: Optional[dict] = None,
    back_pose: Optional[dict] = None,
    forward_pose: Optional[dict] = None,
    std: float = 0.4,
    windup_end: float = 0.35,
    kick_end: float = 0.45,
    return_end: float = 0.75,
    asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG,
) -> torch.Tensor:
    """Gaussienne sur la pose articulaire vs cible interpolée du shoot.

    Reward directif et symétrique : chaque phase impose la config articulaire
    exacte. Résolution PAR NOM.
    """
    cur, target = _kick_pose_error(
        env, asset_cfg, command_name, stand_pose or {}, back_pose or {},
        forward_pose or {}, windup_end, kick_end, return_end,
    )
    return torch.exp(-((cur - target) / std) ** 2).mean(dim=-1)


def kick_pose_track_l1(
    env: ManagerBasedRlEnv,
    command_name: str = "twist",
    stand_pose: Optional[dict] = None,
    back_pose: Optional[dict] = None,
    forward_pose: Optional[dict] = None,
    windup_end: float = 0.35,
    kick_end: float = 0.45,
    return_end: float = 0.75,
    asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG,
) -> torch.Tensor:
    """Bootstrap L1 vers la cible interpolée (gradient constant, pénalité<=0)."""
    cur, target = _kick_pose_error(
        env, asset_cfg, command_name, stand_pose or {}, back_pose or {},
        forward_pose or {}, windup_end, kick_end, return_end,
    )
    return -(cur - target).abs().mean(dim=-1)
  • Step 4: Lancer, vérifier le succès

Run: uv run --with pytest pytest tests/test_shoot.py -q Expected: PASS (tous les tests, y compris les 3 nouveaux).

  • Step 5: Commit
git add src/mjlab_microduck/tasks/mdp.py tests/test_shoot.py
git commit -m "feat: rewards kick_pose_track + kick_pose_track_l1 (suivi du geste de shoot)"

Task 4: Env config microduck_shoot_env_cfg.py

Files:

  • Create: src/mjlab_microduck/tasks/microduck_shoot_env_cfg.py
  • Test: (via Task 5)

Interfaces:

  • Consumes: kick_pose_track, kick_pose_track_l1 (Task 3) ; GroundPickPhaseCommandCfg(randomize_phase=…) (Task 1) ; feet_grounded_reward, feet_flat_penalty, neck_action_rate_l2, joint_torques_l2, zero_command_padding, robot_state_is_nan, DR events (existants dans mdp.py).

  • Produces: make_microduck_shoot_env_cfg(play=False, rough=False) -> ManagerBasedRlEnvCfg ; MicroduckShootRlCfg ; constantes SHOOT_PERIOD, WINDUP_END, KICK_END, RETURN_END, STAND_POSE, KICK_BACK_POSE, KICK_FWD_POSE.

  • Step 1: Partir du fichier ground_pick comme base

cp src/mjlab_microduck/tasks/microduck_ground_pick_env_cfg.py \
   src/mjlab_microduck/tasks/microduck_shoot_env_cfg.py

Ce fichier fournit déjà TOUT le boilerplate sim2real à conserver tel quel : DR (CoM, head CoM, mass/inertia, friction BAM, armature, IMU misalignment obs-level, encoder-bias, pushes), le bloc obs 61D (del base_lin_vel actor, critic base_lin_vel, suppression foot_height/height_scan, delays/noise, head_command/body_command zero-padding), la terminaison nan_state, les events expand_bam_friction_fields / reset_action_history, le curriculum action_rate/CoM. On ne modifie que : robot cfg, capteurs, commande, et le bloc rewards.

  • Step 2: Adapter l'en-tête, le nom de fonction et les constantes

Remplacer le docstring de tête par une description shoot, et juste avant def make_microduck_ground_pick_env_cfg, ajouter les constantes + poses (placeholders — à remplacer par lecture read_pose.py). Renommer la fonction en make_microduck_shoot_env_cfg.

# ── Timings du geste (phase normalisée [0,1)) ────────────────────────────────
SHOOT_PERIOD = 2.5   # s — durée d'un cycle (doit matcher --ground-pick-period au déploiement)
WINDUP_END = 0.35    # STAND -> BACK
KICK_END = 0.45      # BACK -> FORWARD (segment court = frappe sèche)
RETURN_END = 0.75    # FORWARD -> STAND, puis repos jusqu'à 1.0

# ── Poses (rad, 14 joints, mouth exclu) ──────────────────────────────────────
# Convention: jambe droite frappe (hanche/genou droit actifs), gauche en appui.
# STAND_POSE = pose HOME du sim (HOME_FRAME / default_joint_pos) pour que φ=0
# coïncide avec la config de reset (invariant randomize_phase=False). BACK/FWD
# sont des PLACEHOLDERS jambe droite, à affiner via read_pose.py.
STAND_POSE = {
    "left_hip_yaw": 0.0, "left_hip_roll": -0.0873, "left_hip_pitch": -0.4579,
    "left_knee": -0.0049, "left_ankle": 0.4530,
    "neck_pitch": 0.3491, "head_pitch": 0.3491, "head_yaw": 0.0, "head_roll": 0.0,
    "right_hip_yaw": 0.0, "right_hip_roll": 0.0873, "right_hip_pitch": 0.4579,
    "right_knee": 0.0049, "right_ankle": -0.4530,
}
KICK_BACK_POSE = {  # armement: hanche droite en extension arrière + genou fléchi
    **STAND_POSE,
    "right_hip_pitch": -0.6,
    "right_knee": 0.8,
    "right_ankle": -0.2,
}
KICK_FWD_POSE = {  # frappe: hanche droite fléchie avant + genou tendu
    **STAND_POSE,
    "right_hip_pitch": 0.7,
    "right_knee": -0.1,
    "right_ankle": 0.1,
}

NOTE au releveur de poses : remplacer ces valeurs par des lectures read_pose.py (couple coupé, robot posé à la main dans chaque position). Garder les 14 clés identiques dans les 3 dicts.

  • Step 3: Robot cfg et import

Dans les imports, remplacer MICRODUCK_GROUND_PICK_ROBOT_CFG par MICRODUCK_WALK_ROBOT_CFG :

from mjlab_microduck.robot.microduck_constants import MICRODUCK_WALK_ROBOT_CFG

Dans la fonction, la ligne d'entités :

    cfg.scene.entities = {"robot": MICRODUCK_WALK_ROBOT_CFG}
  • Step 4: Capteurs — garder self_collision, remplacer les capteurs pied

Remplacer la définition du capteur feet_ground_contact (2 pieds) par un capteur pied gauche seul (appui), et SUPPRIMER le capteur head_impact_cfg (inutile ici). Le capteur self_collision_cfg reste.

    left_foot_ground_cfg = ContactSensorCfg(
        name="left_foot_ground_contact",
        primary=ContactMatch(
            mode="geom",
            pattern=r"^left_foot_collision$",
            entity="robot",
        ),
        secondary=ContactMatch(mode="body", pattern="terrain"),
        fields=("found", "force"),
        reduce="netforce",
        num_slots=1,
        track_air_time=True,
    )

Et la ligne des capteurs de scène :

    cfg.scene.sensors = (left_foot_ground_cfg, self_collision_cfg)

Supprimer la définition de head_impact_cfg et toute référence (le reward head_impact_penalty est retiré au Step 6).

  • Step 5: Commande de phase (randomize_phase=False, période shoot)

Remplacer le bloc commande (celui qui crée GroundPickPhaseCommandCfg) par :

    command: UniformVelocityCommandCfg = cfg.commands["twist"]
    command.rel_standing_envs = 0.0
    command.rel_heading_envs = 0.0
    cfg.commands["twist"] = microduck_mdp.GroundPickPhaseCommandCfg(
        **{**vars(command), "class_type": microduck_mdp.GroundPickPhaseCommand}
    )
    cfg.commands["twist"].period = SHOOT_PERIOD
    cfg.commands["twist"].randomize_phase = False
  • Step 6: Rewards — retirer ground_pick, ajouter shoot

Supprimer les rewards spécifiques ground_pick : mouth_ground_proximity, mouth_perpendicular_to_ground, ground_pick_return_pose_legs, ground_pick_return_pose_neck, feet_grounded (les 2 pieds), head_impact_penalty. Remplacer par le bloc shoot :

    # ── Objectif : suivi de la pose interpolée du shoot ───────────────────────
    _pose_params = {
        "command_name": "twist",
        "stand_pose": STAND_POSE,
        "back_pose": KICK_BACK_POSE,
        "forward_pose": KICK_FWD_POSE,
        "windup_end": WINDUP_END,
        "kick_end": KICK_END,
        "return_end": RETURN_END,
    }
    cfg.rewards["kick_pose_track"] = RewardTermCfg(
        func=microduck_mdp.kick_pose_track,
        weight=6.0,
        params={**_pose_params, "std": 0.4},
    )
    cfg.rewards["kick_pose_l1"] = RewardTermCfg(
        func=microduck_mdp.kick_pose_track_l1,
        weight=2.0,
        params=dict(_pose_params),
    )

    # ── Équilibre / appui (jambe unique) ──────────────────────────────────────
    cfg.rewards["upright"].params["asset_cfg"].body_names = ("trunk_base",)
    cfg.rewards["upright"].weight = 2.0
    cfg.rewards["body_ang_vel"].params["asset_cfg"].body_names = ("trunk_base",)
    cfg.rewards["body_ang_vel"].weight = -0.05

    # Pied GAUCHE planté (appui). feet_grounded_reward avec un capteur mono-pied
    # -> found ∈ {0,1} -> reward ∈ {0,0.5} ; poids 6.0 => contribution max ~3.0.
    cfg.rewards["support_foot_grounded"] = RewardTermCfg(
        func=microduck_mdp.feet_grounded_reward,
        weight=6.0,
        params={"sensor_name": left_foot_ground_cfg.name},
    )

    # Pied gauche à plat.
    cfg.rewards["feet_flat_left"] = RewardTermCfg(
        func=microduck_mdp.feet_flat_penalty,
        weight=-1.0,
        params={"asset_cfg": SceneEntityCfg("robot", site_names=("left_foot",))},
    )

    cfg.rewards["self_collisions"] = RewardTermCfg(
        func=mdp.self_collision_cost,
        weight=-1.0,
        params={"sensor_name": self_collision_cfg.name},
    )
  • Step 7: Régularisation allégée (laisser passer le snap)

Le fichier ground_pick met action_rate_l2=-2.0, neck_action_rate_l2=-1.0, joint_torques_l2=-5e-3 + un curriculum action_rate qui finit à -2.0. Pour le shoot on allège. Remplacer ces 3 blocs par :

    cfg.rewards["action_rate_l2"] = RewardTermCfg(
        func=mdp.action_rate_l2, weight=-0.5
    )
    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
    )

Et alléger le curriculum action_rate (garder la structure, viser -0.5) :

    cfg.curriculum["action_rate_weight"] = CurriculumTermCfg(
        func=microduck_mdp.reward_weight,
        params={
            "reward_name": "action_rate_l2",
            "weight_stages": [
                {"step": 0,        "weight": -0.2},
                {"step": 250 * 24, "weight": -0.4},
                {"step": 500 * 24, "weight": -0.5},
            ],
        },
    )
  • Step 8: Reset — hauteur de station debout

Garder la hauteur debout (0.12, 0.13) — c'est la valeur de l'env velocity (marche) ET de ground_pick. ⚠️ Ce n'est PAS un offset additif « station accroupie » : le pos racine par défaut de InitialStateCfg est (0,0,0), donc la hauteur de reset est z ∈ [0.12, 0.13] m absolue = debout (aucune chute). Vérifier/mettre :

    cfg.events["reset_base"].params["pose_range"]["z"] = (0.12, 0.13)

(Ne PAS injecter de vitesse d'entrée — c'est un shoot debout, pas de glisse.)

  • Step 9: Renommer la RlCfg

En bas du fichier, renommer MicroduckGroundPickRlCfg en MicroduckShootRlCfg et changer les noms d'expérience :

MicroduckShootRlCfg = RslRlOnPolicyRunnerCfg(
    # … (garder actor/critic/algorithm identiques) …
    wandb_project="mjlab_microduck",
    experiment_name="shoot",
    run_name="shoot",
    save_interval=250,
    num_steps_per_env=24,
    max_iterations=20_000,
)
  • Step 10: Vérifier que le module s'importe

Run: uv run python -c "from mjlab_microduck.tasks.microduck_shoot_env_cfg import make_microduck_shoot_env_cfg, MicroduckShootRlCfg; print('ok')" Expected: ok (pas d'ImportError / NameError — en particulier plus aucune référence à head_impact_cfg, MICRODUCK_GROUND_PICK_ROBOT_CFG, ni aux rewards ground_pick supprimés).

  • Step 11: Commit
git add src/mjlab_microduck/tasks/microduck_shoot_env_cfg.py
git commit -m "feat: env config Mjlab-Shoot (geste de shoot par suivi de poses)"

Task 5: Enregistrement + test d'intégration

Files:

  • Modify: src/mjlab_microduck/tasks/__init__.py
  • Test: tests/test_shoot_cfg.py

Interfaces:

  • Consumes: make_microduck_shoot_env_cfg, MicroduckShootRlCfg (Task 4).

  • Produces: tâche enregistrée Mjlab-Shoot-Flat-MicroDuck.

  • Step 1: Écrire le test d'intégration qui échoue

Créer tests/test_shoot_cfg.py :

from mjlab_microduck.tasks.microduck_shoot_env_cfg import (
    make_microduck_shoot_env_cfg,
    STAND_POSE, KICK_BACK_POSE, KICK_FWD_POSE, SHOOT_PERIOD,
)
from mjlab_microduck.tasks import mdp as microduck_mdp


def test_poses_have_same_14_keys():
    assert set(STAND_POSE) == set(KICK_BACK_POSE) == set(KICK_FWD_POSE)
    assert len(STAND_POSE) == 14
    assert "mouth" not in STAND_POSE


def test_shoot_cfg_builds_with_phase_command():
    cfg = make_microduck_shoot_env_cfg()
    twist = cfg.commands["twist"]
    assert isinstance(twist, microduck_mdp.GroundPickPhaseCommandCfg)
    assert twist.randomize_phase is False
    assert twist.period == SHOOT_PERIOD


def test_shoot_cfg_has_kick_rewards_and_no_walking():
    cfg = make_microduck_shoot_env_cfg()
    assert "kick_pose_track" in cfg.rewards
    assert "kick_pose_l1" in cfg.rewards
    assert "support_foot_grounded" in cfg.rewards
    for gone in ("track_linear_velocity", "track_angular_velocity",
                 "mouth_ground_proximity", "ground_pick_return_pose_legs"):
        assert gone not in cfg.rewards
  • Step 2: Lancer, vérifier l'échec

Run: uv run --with pytest pytest tests/test_shoot_cfg.py -q Expected: PASS possible sur les tests de poses, mais l'ensemble doit être vert seulement une fois l'env construit sans erreur ; si make_... lève, FAIL. (À ce stade l'import du fichier fonctionne déjà via Task 4.)

  • Step 3: Enregistrer la tâche

Dans src/mjlab_microduck/tasks/__init__.py, après le bloc d'import ground_pick (~ligne 50), ajouter :

from .microduck_shoot_env_cfg import (
    make_microduck_shoot_env_cfg,
    MicroduckShootRlCfg,
)

Après le bloc register_mjlab_task de GroundPick-Rough (~ligne 161), ajouter :

register_mjlab_task(
    task_id="Mjlab-Shoot-Flat-MicroDuck",
    env_cfg=make_microduck_shoot_env_cfg(),
    play_env_cfg=make_microduck_shoot_env_cfg(play=True),
    rl_cfg=MicroduckShootRlCfg,
    runner_cls=MicroduckOnPolicyRunner,
)
print("✓ Shoot task registered: Mjlab-Shoot-Flat-MicroDuck")
  • Step 4: Lancer tout, vérifier le succès

Run: uv run --with pytest pytest tests/ -q Expected: PASS (test_shoot.py + test_shoot_cfg.py + tests existants).

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

Run: uv run python -c "import mjlab_microduck.tasks" Expected: la sortie contient ✓ Shoot task registered: Mjlab-Shoot-Flat-MicroDuck.

  • Step 6: Commit
git add src/mjlab_microduck/tasks/__init__.py tests/test_shoot_cfg.py
git commit -m "feat: enregistre Mjlab-Shoot-Flat-MicroDuck + test d'intégration"

Après implémentation (hors plan TDD)

  1. Relever les vraies poses avec read_pose.py (STAND, PIED_ARRIÈRE, PIED_AVANT), remplacer les placeholders dans microduck_shoot_env_cfg.py.
  2. Entraîner : uv run train Mjlab-Shoot-Flat-MicroDuck --env.scene.num-envs 4096 --agent.max_iterations 8000. Surveiller Episode_Reward/kick_pose_track (doit monter).
  3. Play : script play_latest ; vérifier l'équilibre sur le pied gauche pendant la frappe.
  4. Export ONNX + déploiement dans un slot phase (--ground-pick shoot.onnx --ground-pick-period 2.5 --ground-pick-kp-ratio 1.0).
  5. Réglages probables : période/timings (snap), poids action_rate, et éventuel reward « vitesse pied vers l'avant » (segment frappe) si le suivi manque de punch.

Self-review — couverture de la spec

  • Fichier & enregistrement → Tasks 4, 5.
  • Poses placeholders 14 joints → Task 4 Step 2, testé Task 5.
  • Commande de phase + randomize_phase=False + période → Tasks 1, 4 Step 5, testé Task 5.
  • kick_pose_target + kick_pose_track + kick_pose_track_l1 → Tasks 2, 3.
  • Équilibre/appui (upright, pied gauche planté, feet_flat gauche, self_collisions, body_ang_vel) → Task 4 Step 6.
  • Régularisation allégée → Task 4 Step 7.
  • Obs 61D parité (hérité ground_pick, conservé) → Task 4 Step 1.
  • Tests pures + cfg → Tasks 2, 3, 5.