Skip to content

第 28 章 网球击球控制与全身协调

本章定位:Ch26 建立了网球场的物理底座(坐标系、球体模型、弹道空气动力学),Ch27 建立了感知与轨迹预测管线(EKF、GRU 残差预测、延迟补偿、PredictorOutput 接口)。本章进入综合项目的最终层——击球控制。击球控制不是"让机械臂碰到球",而是一个高时效的接触控制问题:机器人必须在有限时间内判断球是否可打、移动底盘到合适位置、调整球拍姿态、在正确时刻以正确速度和法向击中球、控制出球方向——同时保持自身稳定。这比 cube lifting 更难(目标在高速运动),比 velocity tracking 更难(reward 极稀疏),比普通 reach task 更难(需要"带速度和姿态约束的定时接触"而非简单到点)。

前置依赖:Ch26(court-local 坐标 / 八维发球命令 / BallAerodynamics / 接触物理 / 机器人+球拍建模路线)、Ch27(PredictorOutput 接口 / time_to_impact / uncertainty / EKF + GRU predictor)、Ch06(Reward 设计)、Ch08(Domain Randomization)、Ch09(Teacher-Student 特权学习)、Ch25(训练诊断与调参)、mjlab action 架构(DifferentialIKActionCfg)

关键参考:✅ KungfuBot(NeurIPS'25,arXiv:2506.12851,github.com/TeleHuman/PBHC,自适应 curriculum + asymmetric AC)· ✅ WoCoCo(CoRL'24,arXiv:2406.06005,github.com/LeCAR-Lab/wococo,sequential contacts 分解 + Butterworth 滤波)· ✅ LATENT(arXiv:2603.12686,人形网球,latent action space + wrist correction)· ✅ HITTER(arXiv:2508.21043,人形乒乓球,hierarchical planner + RL controller)· 📄 ETH 羽毛球(Science Robotics, 2025)· 📄 Phybot 羽毛球(arXiv:2511.11218,三阶段 curriculum)· 📄 PACE(arXiv:2509.21690,dual predictor)


前置自测

📋 答不出 ≥ 3 题 → 先回 Ch26-Ch27 复习

  1. [Ch26] 当前 Mjlab-Tennis-Launcheractions 字典内容是什么?为什么不能直接在这个 task 上训练击球?
  2. [Ch26] Ch26 规划的机器人+球拍引入阶段有几个 Stage?为什么不能直接跳到最后一个 Stage?
  3. [Ch27] PredictorOutput 的四个核心字段是什么?它们分别给控制器什么信息?
  4. [Ch27] time_to_impact 为什么比 predicted_position 更接近控制需求?
  5. [RL] 什么是 reward shaping?为什么纯稀疏 reward 很难让 PPO 学到东西?

本章目标

学完本章后,你应该能够:

  1. 设计 击球 MDP 的完整五元组(state / action / transition / reward / termination),包含分层状态空间和 task-space action
  2. 实现 五类 staged reward(approach / timing / contact / outcome / regularization)的完整代码,解释乘法结构的设计理由
  3. 设计 从静态球到高速发球的 8 阶段 curriculum,包含成功率门控和安全回退
  4. 配置 Domain Randomization 的五类 gap 覆盖策略,并通过消融实验验证每类 DR 的贡献
  5. 实现 Lagrangian PPO 安全约束的训练循环代码
  6. 分析 网球 Sim2Real 的五类特殊挑战及其工程对策

28.1 击球控制的本质:带速度和姿态约束的定时接触 ⭐⭐

这一节解决什么问题:建立对击球控制问题复杂度的正确认知,避免"把球拍移到球的位置"的天真假设。

动机:五个让天真方案失败的原因

一个天真的控制方案是:从 Ch27 predictor 得到未来球的位置,让机械臂用 IK 把球拍中心移到那个点,如果接触就给 reward。这个方案会失败,原因有五个。

原因一:IK 到点不等于击球。 球拍必须在正确时间到达(太早等在那里球还没来,太晚球已经飞过去了),还要有正确的速度(静止的球拍碰到高速球只会被弹开或产生微弱反弹),面法向也必须正确(偏了出球方向不可控)。这就好比棒球击球——站在好球带里不挥棒也算"到位了",但球不会自己飞向外场。

原因二:只控制手臂可能不可达。 高速横向来球可能落在 arm workspace 之外。如果球预计到达机器人右侧 1.5 m 处,固定底座的机械臂根本够不到。移动底盘需要提前开始——但底盘移动会改变 arm base frame,又会改变 IK 目标。base-arm 协调是耦合问题,不能拆成两个独立模块。

原因三:接触瞬间极短。 真实网球拍-球接触约 3-5 ms。Ch26 确认了这一点——LATENT 用 2000 Hz 仿真才能准确解算。reward 如果只看 episode 结束时的落点,策略很难知道是哪一步做对了——credit assignment 问题被极端放大。

原因四:击中但失稳也不是成功。 极端动作挥拍在仿真中可能打到球,但真实机器人会因惯性和反作用力损坏或摔倒。WoCoCo(CoRL'24)在部署时使用 4 Hz Butterworth 低通滤波器(Ch25 详述)正是为了防止策略产生硬件不安全的高频动作。

原因五:仿真接触太理想。 仿真中球拍稍微碰到球就产生可控出球,真实的线床变形、球体压缩、摩擦变化更复杂。LATENT 的消融实验直接证明了这一点——去掉 dynamics randomization 后真机成功率从 91% 骤降到 14-29%。

本质洞察:击球控制的核心不是末端到点, 而是"带速度和姿态约束的定时接触"。 只把球拍中心追到球心,会把最关键的时间和动量问题删掉。 控制器需要同时解决四个子问题:到哪(position)、什么时候(timing)、 多快(velocity)、什么角度(orientation)。

如果不做 staged reward 会怎样

反事实推理:只给 outcome reward(球落在目标区域内才给分)。大多数时间 reward 为 0——球拍追不上球、追上了没打到、打到了没过网。PPO 在这种极稀疏 reward 下几乎学不到任何东西。这就像让从未摸过球拍的人参加 Wimbledon,只告诉他"赢了才有奖金"但不教怎么挥拍——他连球都碰不到,更谈不上改进技术。staged reward 相当于沿途放一系列越来越亮的灯——至少能沿着光走。

当前事实边界

回顾 Ch26:当前 actions: dict = {} 为空,无 robot/racket/击球策略。本章是击球 MDP 和训练路线的完整设计——基于仓库已有的 manipulation(staged reward pattern)、velocity(base control + DR + curriculum pattern)、differential IK(task-space arm action)三类可复用 pattern,以及 Ch27 定义的 PredictorOutput 接口。

前沿系统的击球控制架构对比

系统 控制架构 动作空间 Reward 类型 关键创新
LATENT 分层:high-level policy + low-level tracker latent action (~16D) + wrist correction 击球成功 + 落点精度 + 自然度 latent action barrier
HITTER 分层:model-based planner + RL controller 全关节 PD targets (29D) tracking + balance + contact 解析弹道规划
WoCoCo 端到端:sequential contact stages 全关节 PD targets contact-stage-decomposed reward Butterworth 部署滤波
KungfuBot 端到端:motion tracking + adaptive curriculum 全关节 PD targets tracking error(无 AMP) bi-level adaptive tolerance
Phybot 三阶段 curriculum 全关节 PD targets footwork → swing → task 无 motion prior
ETH 羽毛球 端到端:unified RL 全关节 PD targets + 步态相位 perception-aware reward 机载视觉集成

Ch26→Ch27→Ch28 的完整数据流

本章是三章综合项目的最后一环。在进入技术细节之前,回顾从 Ch26 到 Ch28 的完整数据流——理解每个模块的输入、输出和接口:

Ch26 物理底座                    Ch27 感知管线                    Ch28 控制策略
┌──────────────────┐        ┌──────────────────┐        ┌──────────────────┐
│ tennis_court.xml │──坐标──→│ BallObsHistory   │        │ TennisStrikeEnv  │
│ BallEntity       │──状态──→│ BallEKFWithDrag  │──状态──→│ ObservationsCfg  │
│ LaunchCommand    │──参数──→│ GRUResidual      │──预测──→│ ActionsCfg       │
│ BallAerodynamics │──物理──→│ DelayCompensation│──补偿──→│ RewardsCfg       │
│ ContactPhysics   │──接触──→│                  │        │ CurriculumMgr    │
│                  │        │ PredictorOutput  │──接口──→│ LagrangianPPO    │
└──────────────────┘        └──────────────────┘        └──────────────────┘
                                                              ↓
                                                        joint_targets
                                                              ↓
                                                        DeploySafety
                                                        (Butterworth +
                                                         Action Rescaler)
                                                              ↓
                                                          真机执行

每个箭头代表一个明确的接口——坐标系约定(Ch26)、PredictorOutput 数据结构(Ch27)、StrikeEnvCfg(Ch28)。如果任何一个接口的语义不一致(比如 Ch27 的 impact_pos 用的坐标系和 Ch28 的 reward 计算用的坐标系不同),整个系统就会失效。

这就是为什么 Ch26 花了大量篇幅在坐标约定上——它是所有后续模块的"坐标语言"。

⚠️ 常见陷阱

⚠️ 思维陷阱:直接在 Phase 1 launcher 上训练击球 - actions: dict = {} 意味着 PPO 无输出维度,训练无意义 - 正确做法:新增包含 robot/racket/action 的 strike task(参见 Ch26 的阶段化引入路线)

⚠️ 概念误区:IK 到点就是击球 - 位置对了但速度为零,接触冲量不足;面法向偏了,出球方向不可控 - 正确做法:同时约束位置、速度和姿态

⚠️ 编程陷阱:wrist pose 当 racket face pose - 球拍有长度和面法向偏移(Ch26 的 euler="0 -15 0"),wrist 到位不代表 racket face 到位 - 正确做法:在 MJCF 中定义 racket_face_center site,IK target 指向该 site

练习

  1. [分析题] 解释为什么 cube lifting 的 staged reward 可以迁移到网球击球,但参数不能直接复用。从目标物体速度(0 vs 30 m/s)、接触持续时间(持续 vs 3 ms)、reward 稀疏性三个角度分析。
  2. [设计题] 对比 LATENT(latent action)和 HITTER(全关节 PD targets)的动作空间设计。哪种更适合教学项目?为什么?

建立了复杂度认知后,下一步定义 MDP 五元组——这是把"打网球"转化为 RL 可优化问题的数学语言。

28.2 击球 MDP 设计:状态、动作与转移 ⭐⭐⭐

这一节解决什么问题:定义击球任务的完整 MDP 五元组,为后续 reward 和 curriculum 设计提供框架。

状态空间分层设计

击球任务的状态天然分成三层,对应不同的信息访问权限:

包含的状态 谁能看到 维度(近似)
环境真值 ball pos/vel/ang_vel, contact state, launch cmd, court geometry 仿真内部 ~30
Teacher Actor ground truth ball + future trajectory + perfect robot state teacher policy ~60
Student Actor estimated impact pos/vel, time_to_impact, uncertainty, proprioception, prev action 可部署 policy ~50

为什么要分层? Teacher 能看到未来轨迹和精确接触状态,Student 只能看到 Ch27 predictor 输出和自身传感器。如果一开始就限制 teacher 信息,上限性能更低,蒸馏目标更弱。分层让我们先确定"信息完全时能打多好",再逐步降级到"可部署信息下能打多好"。

Student Actor 的 Observation 设计

class TennisStrikeObsCfg:
    """击球 task 的 student actor observation。"""

    class ActorCfg:
        # === 球相关(来自 Ch27 PredictorOutput)===
        ball_pos_estimate = ObsTermCfg(
            func=lambda env: env.perception.predicted_state_now[:, :3],
            enable_corruption=True,
        )  # [3] EKF 估计的当前球位置
        ball_vel_estimate = ObsTermCfg(
            func=lambda env: env.perception.predicted_state_now[:, 3:6],
        )  # [3] EKF 估计的当前球速度
        impact_pos = ObsTermCfg(
            func=lambda env: env.perception.impact_pos_c,
        )  # [3] 预测击球点
        time_to_impact = ObsTermCfg(
            func=lambda env: env.perception.time_to_impact.unsqueeze(-1),
        )  # [1] 距击球时间
        prediction_uncertainty = ObsTermCfg(
            func=lambda env: env.perception.uncertainty.unsqueeze(-1),
        )  # [1] 预测不确定性
        prediction_validity = ObsTermCfg(
            func=lambda env: env.perception.validity.float().unsqueeze(-1),
        )  # [1] 预测是否有效

        # === 机器人本体感知 ===
        joint_pos = ObsTermCfg(func=mdp.joint_pos_rel)       # [n_j]
        joint_vel = ObsTermCfg(func=mdp.joint_vel_rel)       # [n_j]
        base_lin_vel = ObsTermCfg(func=mdp.base_lin_vel)     # [3]
        base_ang_vel = ObsTermCfg(func=mdp.base_ang_vel)     # [3]
        projected_gravity = ObsTermCfg(func=mdp.projected_gravity)  # [3]

        # === 球拍状态 ===
        racket_pos = ObsTermCfg(func=racket_face_position)   # [3]
        racket_normal = ObsTermCfg(func=racket_face_normal)  # [3]
        racket_vel = ObsTermCfg(func=racket_face_velocity)   # [3]

        # === 动作历史 ===
        last_action = ObsTermCfg(func=mdp.last_action)       # [action_dim]

    class CriticCfg(ActorCfg):
        """Critic 额外看到 privileged 信息。"""
        ball_pos_perfect = ObsTermCfg(
            func=ball_state_privileged, enable_corruption=False,
        )  # [9] ground truth ball state
        ball_future_trajectory = ObsTermCfg(
            func=ball_future_positions_20step,
        )  # [60] 未来 20 步真实位置

动作空间设计

候选方案 维度 优点 缺点 推荐阶段
base_vel + racket_delta_pos 6D 简单起步 无姿态控制 Stage 1-2
base_vel + racket_delta_pose_6d 9D 有姿态控制 IK 可能不可达 Stage 2-4
全关节 joint_position_targets \(n_j\)D 最大灵活性 策略要自学运动学 Stage 4+
latent action(LATENT 方案) ~16D 自然运动 需要 motion data 高级

本课程推荐 base velocity + differential IK(9D)起步。

class TennisStrikeActionsCfg:
    """击球 task 的动作空间配置。"""

    # 方案 A(推荐起步):base velocity + task-space IK
    base_velocity = ActionTermCfg(
        class_type=BaseVelocityActionCfg,
        asset_name="robot",
        scale=(1.0, 0.5, 1.0),  # (vx, vy, wz) 缩放
    )  # 3D: 底盘速度

    racket_ik = ActionTermCfg(
        class_type=DifferentialIKActionCfg,
        asset_name="robot",
        joint_names=[".*_shoulder_.*", ".*_elbow_.*", ".*_wrist_.*"],
        body_name="racket_face_center",
        controller=DifferentialIKControllerCfg(
            command_type="pose",        # 位置 + 旋转
            use_relative_mode=True,     # delta pose
            ik_method="dls",            # damped least squares
            ik_params={"lambda_val": 0.05},
        ),
    )  # 6D: 球拍 task-space 增量

    # 总 action_dim = 3 + 6 = 9

本质洞察:移动底盘不是"让机器人更灵活"的附加功能, 而是把击球点从 arm workspace 外拉回可达域的控制变量。 忽略 base,会把许多可打来球错误地变成不可达来球。

类比理解:足球守门员如果只能站在原地伸手,大部分射门都接不到。移动脚步(底盘)是扩大防守范围的关键。这个类比的边界在于:守门员的反应时间约 0.2-0.5 秒,而网球机器人的反应时间可能只有 0.3-0.8 秒(球从远端飞到近端),两者数量级接近。

DifferentialIK 的关键参数

DifferentialIKActionCfg 把 task-space 的 delta pose 转化为 joint-space 的关节角增量,核心是 damped least squares (DLS) 伪逆:

\[\Delta \mathbf{q} = J^T (JJ^T + \lambda^2 I)^{-1} \Delta \mathbf{x}\]

其中 \(J\) 是 Jacobian 矩阵(\(6 \times n_j\)),\(\Delta \mathbf{x}\) 是 task-space 中的 pose 增量(3D 位置 + 3D 旋转),\(\lambda\) 是阻尼系数。

为什么不直接用 \(J^{-1}\)(伪逆)? 当机器人接近 workspace 边界或奇异构型(Jacobian 秩下降)时,\(J\) 的小奇异值导致伪逆 \(J^+\) 放大——等价地,上式中 \(JJ^T\) 的小特征值接近零,使逆矩阵剧增——策略输出一个微小的 task-space 动作,关节却跳变几十度。DLS 通过在 \(JJ^T\) 上添加 \(\lambda^2 I\) 来正则化——代价是精度稍有下降,但换来了数值稳定性。

参数 含义 击球任务建议值 理由
use_relative_mode action 是 delta 还是 absolute True delta 更适合 RL(action space 以零为中心)
delta_pos_scale 位置 delta 的缩放 0.05-0.1 m/step 50 Hz 控制 → 0.05 m/step → 最大速度 2.5 m/s
delta_ori_scale 旋转 delta 的缩放 0.1-0.2 rad/step 不宜过大防止球拍翻转
lambda_val (damping) DLS 阻尼 0.05-0.1 太小 → 奇异附近发散;太大 → 响应迟钝
max_dq 单步最大关节角变化 按关节速度限制 防止关节瞬间跳变

DifferentialIK 在 mjlab 中的完整使用流程

# Step 1: 在 env_cfg 中配置 IK action
class TennisStrikeActionsCfg:
    racket_ik = ActionTermCfg(
        class_type=DifferentialIKActionCfg,
        asset_name="robot",
        joint_names=["right_shoulder_pitch", "right_shoulder_roll",
                     "right_shoulder_yaw", "right_elbow", "right_wrist_yaw"],
        body_name="racket_face_center",  # IK 目标:球拍面中心
        controller=DifferentialIKControllerCfg(
            command_type="pose",
            use_relative_mode=True,
            ik_method="dls",
            ik_params={"lambda_val": 0.05},
        ),
    )

# Step 2: 策略输出 6D action → IK 转化为关节目标
# 策略: obs → [base_vx, base_vy, base_wz, dx, dy, dz, drx, dry, drz]
# DifferentialIK: [dx, dy, dz, drx, dry, drz] → Δq for arm joints

# Step 3: PD 控制器追踪关节目标
# current_joint_pos + Δq → PD target
# torque = Kp * (target - current) - Kd * joint_vel

action transform 的完整链路

策略输出 action [9D]
    ├── action[:3] = base_velocity [vx, vy, wz]
    │   → 直接发送给底盘速度控制器
    │
    └── action[3:9] = racket_delta_pose [dx, dy, dz, drx, dry, drz]
        → delta_pos_scale * action[3:6] = Δx_pos [m]
        → delta_ori_scale * action[6:9] = Δx_ori [rad]
        → DLS IK: Δq = J^T(JJ^T + λ²I)^{-1} [Δx_pos; Δx_ori]
        → clamp(Δq, -max_dq, +max_dq)
        → current_joint_pos + Δq = joint_target
        → PD controller: τ = Kp(q_target - q) - Kd * q̇
        → 施加到物理仿真

为什么推荐 Task-Space IK 而非 Joint-Space Action

反事实推理:如果不用 DifferentialIK 而直接用全关节 PD targets(\(n_j\) 维 action)——策略需要自己学习"怎么移动关节才能让球拍到达目标位置"。这是一个从 joint space 到 task space 的逆运动学映射——人类工程师用几十行代码就能精确解的问题,让神经网络用几亿个样本去学是极其低效的。

HITTER 和 Phybot 使用全关节 PD targets 的原因是它们的机器人有完整的人形身体——IK 只覆盖手臂,但腿部和躯干也参与运动。对这种情况,全关节 action + 动作先验(motion data)是一种合理选择。但对教学项目(特别是固定底座或简单移动底盘的配置),Task-Space IK 大幅降低了学习难度。

本质洞察:DifferentialIK 把运动学知识("怎么移动关节才能让末端到达目标") 编码进了 action transform。策略只需要学习"球拍应该去哪"—— 这是一个 3D/6D 的决策问题,比 \(n_j\)-D 的关节控制问题简单得多。

Isaac Lab 中的 IK Action 对照

Isaac Lab 提供了类似的 DifferentialIK action,但 API 路径不同:

# Isaac Lab 的 DifferentialIK 配置
from isaaclab.envs.mdp.actions import DifferentialInverseKinematicsActionCfg

class IsaacLabTennisActionsCfg:
    racket_ik = DifferentialInverseKinematicsActionCfg(
        asset_name="robot",
        joint_names=["panda_joint.*"],
        body_name="panda_hand",
        controller=DifferentialIKControllerCfg(
            command_type="pose",
            use_relative_mode=True,
            ik_method="dls",
            ik_params={"lambda_val": 0.05},
        ),
    )

两个框架的 IK 解算器在数学上完全等价——都是 DLS 伪逆。差异在于 Jacobian 的获取方式:mjlab 通过 MuJoCo 的 mj_jac 函数获取,Isaac Lab 通过 PhysX 的 articulation API 获取。

反事实推理:如果不用 DifferentialIK 而直接用全关节 PD targets(\(n_j\) 维 action)——策略需要自己学习"怎么移动关节才能让球拍到达目标位置"。这是一个从 joint space 到 task space 的逆运动学映射——人类工程师用几十行代码就能解的问题,让神经网络用几亿个样本去学是极其低效的。DifferentialIK 把这个已知的映射编码进 action transform,让策略只需要学习"球拍应该去哪"而非"关节应该怎么动"。

⚠️ 常见陷阱

⚠️ 思维陷阱:先练手臂再加底盘 - arm-only 策略只能打正面来球。加入底盘后策略需重新学 base-arm 协调 - 正确做法:从第一版就把 base command 放入 action space(收窄初始活动范围即可)

⚠️ 编程陷阱:DifferentialIK 的 damping 设太低 - 接近 workspace 边界或奇异构型时 IK 解发散(关节角跳变) - 正确做法:damping 从 0.1 开始逐步降低,观察 IK 残差

⚠️ 概念误区:高维 action space 一定更好 - joint-space action(\(n_j\) 维)让策略自己学运动学,样本效率极低 - 正确做法:用 task-space IK 把运动学知识编码进 action transform

练习

  1. [设计题] 设计 9D action [base_vx, base_vy, base_wz, racket_dx, racket_dy, racket_dz, racket_drx, racket_dry, racket_drz],说明每维的物理含义和建议 action scale。如果控制频率是 50 Hz,每维的 action scale 应该设为多少才能达到合理的最大速度/角速度?
  2. [手算题] 固定底座机械臂的 arm workspace 半径约 0.6-0.8 m。如果球可能到达机器人左右 2 m 范围,底盘需要至少移动多远?假设底盘最大速度 2 m/s,需要多少时间?

MDP 的状态和动作定义好后,接下来是 RL 训练中最关键的部分——reward 设计。

28.3 Reward 分解:从稀疏到可学 ⭐⭐⭐

这一节解决什么问题:设计让 PPO 能有效学习击球行为的 reward 函数。关键是把"打赢一分"拆成持续可学的子目标。

五类 reward 详解

类别 名称 时机 数学形式(示意) 作用 权重建议
接近 approach contact 前 \(\exp(-\|\mathbf{p}_r - \mathbf{p}_i\|^2 / \sigma^2)\) 引导球拍进入击球窗口 1.0
对时 timing contact 前 $\exp(- t_{\text{tti}} - t_{\text{ready}} / \tau)$
击中 contact contact 时 合法球拍-球接触的二值信号 确认物理接触发生 5.0
出球 outcome contact 后 过网 \(\cdot\) 落入目标区 最终任务目标 10.0
正则 reg 全程 action rate + joint limit + base stability 安全和平滑 -0.1 ~ -0.5

每类 reward 的工程实现

Approach Reward:引导球拍接近预测击球点

def compute_approach_reward(env, sigma=0.3):
    """球拍面中心到预测击球点的距离 reward。

    使用指数衰减:远处有信号,近处梯度最大。
    """
    racket_pos = env.get_site_position("racket_face_center")  # [N, 3]
    impact_pos = env.perception.impact_pos_c                   # [N, 3]
    validity = env.perception.validity                         # [N] bool

    distance = (racket_pos - impact_pos).norm(dim=-1)  # [N]

    # 指数衰减
    reward = torch.exp(-distance**2 / sigma**2)  # [N]

    # 只对有效预测给 reward(无效时 reward = 0)
    reward = reward * validity.float()

    return reward

为什么用指数衰减而非线性? \(\exp(-d^2/\sigma^2)\) 在近处 reward 最高,且在距离大时梯度也不为零(远处仍有学习信号);其对距离的导数大小为 \((2d/\sigma^2)\exp(-d^2/\sigma^2)\),在 \(d=0\) 处为 0、在 \(d=\sigma/\sqrt{2}\) 附近最大,因此引导信号在中等距离最强、近处趋于平缓。线性衰减 \(\max(0, 1 - d/d_{\max})\)\(d > d_{\max}\) 时梯度为零——策略远处没有方向信号。\(\sigma\) 应该和 arm workspace 尺度匹配(0.3-0.5 m),让球拍在整个工作空间内都有非零信号。

Timing Reward:在正确时间接近击球点

def compute_timing_reward(env, tau=0.05, t_ready=0.1):
    """时间协调 reward:球拍应在 time_to_impact ≈ t_ready 时到达击球点附近。

    过早到位等在那里→球还没来→击中概率低
    过晚到位→球已经飞过→完全打空
    """
    tti = env.perception.time_to_impact          # [N] 剩余时间
    distance = (env.get_site_position("racket_face_center")
                - env.perception.impact_pos_c).norm(dim=-1)  # [N]

    # 定义"准备度":距离小,且 tti 接近理想提前量 t_ready(提前 t_ready 到位开始挥拍)。
    # 用 |tti - t_ready| 而非 -tti,避免 tti<0(球已飞过)时 exp 爆炸;并 clamp 掉负 tti。
    tti_clamped = tti.clamp_min(0.0)
    readiness = torch.exp(-distance / 0.2) * torch.exp(-torch.abs(tti_clamped - t_ready) / tau)

    return readiness * env.perception.validity.float()

Contact Reward:检测到合法的球拍-球接触

def compute_contact_reward(env):
    """合法球拍-球接触的二值 reward。

    "合法"的定义:
    1. 球拍面的 geom 碰到球的 geom(不是球拍框或握柄)
    2. 球在空中(不是落地后的球)
    3. 球在可打范围内(不是已经飞过机器人的球)
    """
    # 检查 MuJoCo contact 数组中是否有 racket_face ↔ ball 接触
    contact_detected = env.contact_sensor.detect_contact(
        geom_a="racket_face",
        geom_b="ball_geom",
    )  # [N] bool

    # 额外条件
    ball_airborne = env.perception.predicted_state_now[:, 2] > 0.1  # 球在空中
    ball_in_range = env.perception.validity  # 球在可打范围

    legal_contact = contact_detected & ball_airborne & ball_in_range

    # 记录事件(用于后续 outcome reward)
    env.event_buffer.record_contact(legal_contact, env.sim_time)

    return legal_contact.float()

Outcome Reward:出球过网并落入目标区域

def compute_outcome_reward(env):
    """出球结果 reward:过网 + 落入目标区域。

    这是最终任务目标,但触发条件苛刻(需要先击中球)。
    """
    if not env.event_buffer.has_contact.any():
        return torch.zeros(env.num_envs, device=env.device)

    # 检查接触后球是否过了网
    ball_crossed_net = env.event_buffer.ball_crossed_net_after_contact  # [N]

    # 检查落点是否在目标区域:回球任务的目标是对方(far)半场(x>0),
    # 而非机器人所在的近端 x<0(后者是 Ch26 Phase 1 发球验证的落点区,语义不同)
    landing_pos = env.event_buffer.first_landing_after_contact  # [N, 3]
    in_target = (
        (landing_pos[:, 0] > 0.0) & (landing_pos[:, 0] < 6.40) &
        (landing_pos[:, 1] > -4.115) & (landing_pos[:, 1] < 4.115) &
        (landing_pos[:, 2] < 0.1)
    )

    # 过网但没落入目标区域:部分 reward
    # 过网且落入目标区域:满分
    reward = ball_crossed_net.float() * 0.5 + in_target.float() * 0.5

    return reward

Regularization Reward:安全和平滑约束

def compute_regularization(env, w_action_rate=0.01, w_joint_limit=0.1,
                            w_base_stability=0.05, w_energy=0.001):
    """正则化惩罚:确保动作安全、平滑、高效。"""

    # 动作平滑度(Ch25 action_rate penalty)
    action_rate = (env.actions - env.previous_actions).norm(dim=-1)
    action_rate_penalty = w_action_rate * action_rate

    # 关节限位惩罚
    joint_pos = env.scene["robot"].data.joint_pos
    joint_limits = env.scene["robot"].data.joint_pos_limits
    margin = 0.05  # 距限位 5% 以内开始惩罚
    lower_violation = torch.clamp(joint_limits[:, :, 0] + margin - joint_pos, min=0).sum(dim=-1)
    upper_violation = torch.clamp(joint_pos - joint_limits[:, :, 1] + margin, min=0).sum(dim=-1)
    joint_limit_penalty = w_joint_limit * (lower_violation + upper_violation)

    # 底盘稳定性(防止倾倒)
    projected_gravity = env.scene["robot"].data.projected_gravity_b
    base_tilt = (projected_gravity[:, :2]).norm(dim=-1)  # 水平分量越大越倾斜
    base_stability_penalty = w_base_stability * base_tilt

    # 能量消耗
    joint_torques = env.scene["robot"].data.applied_torque
    energy = (joint_torques.abs() * env.scene["robot"].data.joint_vel.abs()).sum(dim=-1)
    energy_penalty = w_energy * energy

    return action_rate_penalty + joint_limit_penalty + base_stability_penalty + energy_penalty

Staged Reward 的乘法结构

def compute_total_reward(env):
    """五类 reward 组合:乘法结构确保层级优先。"""
    approach = compute_approach_reward(env)
    timing = compute_timing_reward(env)
    contact = compute_contact_reward(env)
    outcome = compute_outcome_reward(env)
    reg = compute_regularization(env)

    # 乘法结构:approach 为 0 时后面再好也不贡献
    total = (
        1.0 * approach
        * (1.0 + 0.5 * timing)
        * (1.0 + 5.0 * contact)
        * (1.0 + 10.0 * outcome)
        - reg
    )

    return total

乘法结构的设计理由:这确保了层级优先。如果 approach = 0(球拍离击球点很远),无论 timing 多好、是否碰到球,总 reward 都是零——强制策略先解决接近问题。只有接近了(approach > 0),timing 的好坏才开始有意义。只有接近且时间对了,contact 才有可能发生。只有 contact 发生后,outcome 才能评估。

这与 mjlab manipulation 中 reaching * (1 + bringing) 的模式完全一致——cube lifting 中策略必须先够到 cube(reaching),才有机会搬起来(bringing)。

WandB 分项 Reward 监控

分项记录每个 reward term 是诊断训练问题的关键。如果只看 total reward,你无法判断"reward 上升是因为策略真的学会了击球,还是因为学会了刷 approach shaping"。

# 在 env step 中记录分项 reward
def step(self, actions):
    # ... 物理仿真和 reward 计算 ...

    # 分项记录到 WandB
    self.extras["log"]["Reward/approach"] = approach.mean().item()
    self.extras["log"]["Reward/timing"] = timing.mean().item()
    self.extras["log"]["Reward/contact"] = contact.mean().item()
    self.extras["log"]["Reward/outcome"] = outcome.mean().item()
    self.extras["log"]["Reward/regularization"] = reg.mean().item()
    self.extras["log"]["Reward/total"] = total.mean().item()

    # 关键指标(不是 reward 但是真正的成功度量)
    self.extras["log"]["Metric/contact_rate"] = contact.float().mean().item()
    self.extras["log"]["Metric/over_net_rate"] = self.event_buffer.ball_crossed_net_after_contact.float().mean().item()
    self.extras["log"]["Metric/target_landing_rate"] = self.event_buffer.landing_in_target.float().mean().item()

WandB 面板推荐布局(击球任务专用):

左列 中列 右列
1 Total Reward Contact Rate (最重要) Over-Net Rate
2 Approach Reward Timing Reward Contact Reward
3 Outcome Reward Regularization Episode Length
4 KL Divergence Entropy Curriculum Stage

Contact Rate 是击球任务的北极星指标——它直接衡量"策略碰到球了吗"。total reward 可能因为 approach shaping 持续上升但 contact rate 不动(说明策略学会了"靠近但不击球"的局部最优)。这种情况下需要增大 contact reward 权重或减小 approach σ。

Reward 调优的系统流程

当训练不收敛时,按以下流程诊断和调优:

Total reward 升但 contact rate = 0?
  ├── 是 → 策略在刷 approach shaping
  │   修复:减小 approach σ 或增大 contact weight
  │
  └── 否 → Contact rate 升但 over_net rate = 0?
        ├── 是 → 碰到球但力量/角度不对
        │   修复:加 orientation reward 或 racket velocity reward
        │
        └── 否 → Over_net rate 升但 landing rate = 0?
              ├── 是 → 球过网但落点不准
              │   修复:加 landing accuracy reward
              │
              └── 否 → ✅ 训练正常!逐步推进 curriculum

PACE 的 Physics Predictor 用于 Reward 构造

回顾 Ch27 的 PACE 双通道设计——physics predictor 在训练时提供精确的未来球位置用于构造 approach reward。这比用 learned predictor 构造 reward 更好,因为 physics predictor 从第一天起就准确。

def compute_approach_reward_with_physics_predictor(env, sigma=0.3):
    """使用 physics predictor(精确)而非 learned predictor 构造 approach reward。

    这是 PACE(arXiv:2509.21690)的核心设计之一。
    训练时:reward 用 physics predictor(精确) → reward 稳定
    运行时:policy obs 用 learned predictor(可部署) → 策略学会使用不精确的预测
    """
    racket_pos = env.get_site_position("racket_face_center")

    # Physics predictor:用 ground truth ball state + 物理模型
    physics_impact_pos = env.physics_predictor.compute_impact_point(
        env.scene["ball"].data.root_pos_w[:, :3] - env.scene.env_origins,
        env.scene["ball"].data.root_lin_vel_w[:, :3],
    )

    distance = (racket_pos - physics_impact_pos).norm(dim=-1)
    return torch.exp(-distance**2 / sigma**2)

为什么不用 learned predictor 构造 reward? 因为训练初期 learned predictor 还不准确,用不准确的预测构造 reward → reward 噪声大 → PPO 梯度方差大 → 训练不稳定。Physics predictor 从第一天起就相当准确(物理定律不需要"学习"),提供了稳定的 reward 信号。

与 cube lifting 不同,网球接触是瞬时事件(3-5 ms)。如果不记录接触事件,几个 env step 后就无法知道"是否曾经接触过"。

class StrikeEventBuffer:
    """记录击球事件的 buffer。"""

    def __init__(self, num_envs, device):
        self.has_contact = torch.zeros(num_envs, dtype=torch.bool, device=device)
        self.contact_time = torch.zeros(num_envs, device=device)
        self.outgoing_velocity = torch.zeros(num_envs, 3, device=device)
        self.ball_crossed_net_after_contact = torch.zeros(num_envs, dtype=torch.bool, device=device)
        self.first_landing_after_contact = torch.zeros(num_envs, 3, device=device)
        self.landing_recorded = torch.zeros(num_envs, dtype=torch.bool, device=device)

    def record_contact(self, contact_mask, sim_time):
        """记录接触事件。"""
        new_contacts = contact_mask & ~self.has_contact  # 只记录第一次接触
        self.has_contact = self.has_contact | contact_mask
        self.contact_time[new_contacts] = sim_time

    def update_post_contact(self, ball_pos_local, ball_vel):
        """接触后追踪球的状态。"""
        # 只对已接触的环境追踪
        active = self.has_contact & ~self.landing_recorded

        # 检查过网:机器人在近端(x<0)击球,成功回球应把球打回对方半场(x>0)
        crossed = active & (ball_pos_local[:, 0] > 0)  # 球越网到了对方(far)半场
        self.ball_crossed_net_after_contact = self.ball_crossed_net_after_contact | crossed

        # 检查落地
        landed = active & (ball_pos_local[:, 2] < 0.05) & (ball_vel[:, 2] < 0)
        self.first_landing_after_contact[landed] = ball_pos_local[landed]
        self.landing_recorded = self.landing_recorded | landed

    def reset(self, env_ids):
        """episode reset。"""
        self.has_contact[env_ids] = False
        self.contact_time[env_ids] = 0.0
        self.outgoing_velocity[env_ids] = 0.0
        self.ball_crossed_net_after_contact[env_ids] = False
        self.first_landing_after_contact[env_ids] = 0.0
        self.landing_recorded[env_ids] = False

⚠️ 常见陷阱

⚠️ 思维陷阱:reward 越高越好 - approach reward 权重过大时,策略学到"一直把球拍举在预测点"刷分但不挥拍 - 正确做法:分项记录每个 reward term,关注 contact rate 和 target landing rate 作为真正指标

⚠️ 编程陷阱:contact reward 在 episode 结束后才计算 - 接触发生的 step 没有立即获得 reward → credit assignment 更困难 - 正确做法:每步检查是否发生合法接触,立即给 reward

⚠️ 概念误区:approach 的 \(\sigma\) 越小越好 - \(\sigma\) 太小时远处 reward 趋零,策略没有方向信号 - 正确做法:\(\sigma\) 匹配 workspace 尺度(0.3-0.5 m)

⚠️ 编程陷阱:outcome reward 需要接触后的 episode 继续运行 - 如果在 contact 时刻立即 terminate → 无法评估出球是否过网、落点在哪 - 正确做法:contact 后继续运行到球落地或出界

练习

  1. [设计题] 写完整的 staged reward 函数代码,包含 5 类 reward 的权重和物理解释。
  2. [分析题] approach reward 稳定上升但 contact rate 不变,说明什么问题?怎么调?提示:策略可能学到"把球拍举在预测点不动"。
  3. [跨章综合题] 结合 Ch26 标准 deuce service box 坐标和 Ch27 predictor 接口,设计 outcome reward:如何判断出球过网并落入 deuce service box?写出坐标不等式。

有了 reward 函数,接下来设计 curriculum——如何从简单到困难逐步增加训练难度。

28.4 分阶段 Curriculum:从静态球到高速发球 ⭐⭐⭐

这一节解决什么问题:设计从简单到困难的训练课程,确保策略在每个阶段都有足够的学习信号。

术语说明:本节的"Curriculum 阶段 0-7"与 Ch26 第 26.8 节的"环境引入 Stage 0-6"是不同维度的概念。Ch26 的 Stage 描述的是"何时引入机器人和球拍"(环境组装顺序);本节的 Curriculum 阶段描述的是"如何逐步增加训练难度"(发球参数和噪声递进)。两者可以正交组合——例如在 Ch26 的 Stage 3(固定底座机械臂引入后)才开始 Ch28 的 Curriculum 阶段 0。

八阶段 curriculum

阶段 条件 新增难度 通过标准 对应 Ch26 LaunchCommand
0 静态球,固定位置 racket 到达球位置 (approach > 0.5) speed=0, 固定 pos
1 低速直线球 12 m/s,无 spin 运动目标 contact rate > 50% speed=12, elev=0, spin=0
2 低速抛物线,固定 elev/y 垂直维度 contact rate > 40% speed=12, elev=-5
3 增加横向偏移 base-arm 协调 contact rate > 30% y_offset ∈ [-2, 2]
4 增加速度 12-25 m/s 时间窗口缩短 contact rate > 25% speed ∈ [12, 25]
5 增加 elev/azimuth 随机 轨迹多样性 legal contact > 20% 全范围 elev/azim
6 增加 topspin/sidespin 出球复杂化 over-net rate > 15% topspin ∈ [-100, 300]
7 加入 perception latency 信息退化 性能下降 < 30% + 0-3 帧延迟

Curriculum Manager 实现

class TennisCurriculumManager:
    """网球击球任务的 curriculum 管理器。"""

    def __init__(self, env_cfg, num_envs, device):
        self.current_stage = 0
        self.stages = self._define_stages()
        self.success_history = []
        self.history_window = 200  # 最近 200 个 episode 的成功率

    def _define_stages(self):
        return [
            # Stage 0: 静态球
            {
                "speed_range": (0.0, 0.0),
                "elevation_range": (0.0, 0.0),
                "azimuth_range": (0.0, 0.0),
                "y_offset_range": (0.0, 0.0),
                "topspin_range": (0.0, 0.0),
                "sidespin_range": (0.0, 0.0),
                "obs_delay_range": (0, 0),
                "pass_threshold": 0.5,  # approach > 0.5
                "metric": "approach_rate",
            },
            # Stage 1: 低速直线球
            {
                "speed_range": (10.0, 14.0),
                "elevation_range": (-2.0, 2.0),
                "azimuth_range": (0.0, 0.0),
                "y_offset_range": (0.0, 0.0),
                "topspin_range": (0.0, 0.0),
                "sidespin_range": (0.0, 0.0),
                "obs_delay_range": (0, 0),
                "pass_threshold": 0.5,
                "metric": "contact_rate",
            },
            # Stage 2: 抛物线
            {
                "speed_range": (10.0, 14.0),
                "elevation_range": (-8.0, 2.0),
                "azimuth_range": (0.0, 0.0),
                "y_offset_range": (0.0, 0.0),
                "topspin_range": (0.0, 0.0),
                "sidespin_range": (0.0, 0.0),
                "obs_delay_range": (0, 0),
                "pass_threshold": 0.4,
                "metric": "contact_rate",
            },
            # Stage 3: 横向偏移
            {
                "speed_range": (10.0, 14.0),
                "elevation_range": (-8.0, 2.0),
                "azimuth_range": (-5.0, 5.0),
                "y_offset_range": (-2.0, 2.0),
                "topspin_range": (0.0, 0.0),
                "sidespin_range": (0.0, 0.0),
                "obs_delay_range": (0, 0),
                "pass_threshold": 0.3,
                "metric": "contact_rate",
            },
            # Stage 4: 增加速度
            {
                "speed_range": (12.0, 25.0),
                "elevation_range": (-8.0, 2.0),
                "azimuth_range": (-5.0, 5.0),
                "y_offset_range": (-2.0, 2.0),
                "topspin_range": (0.0, 0.0),
                "sidespin_range": (0.0, 0.0),
                "obs_delay_range": (0, 0),
                "pass_threshold": 0.25,
                "metric": "contact_rate",
            },
            # Stage 5: 全范围角度
            {
                "speed_range": (15.0, 35.0),
                "elevation_range": (-8.0, 2.0),
                "azimuth_range": (-10.0, 10.0),
                "y_offset_range": (-3.0, 3.0),
                "topspin_range": (0.0, 0.0),
                "sidespin_range": (0.0, 0.0),
                "obs_delay_range": (0, 0),
                "pass_threshold": 0.2,
                "metric": "legal_contact_rate",
            },
            # Stage 6: 加旋转
            {
                "speed_range": (20.0, 45.0),
                "elevation_range": (-8.0, 2.0),
                "azimuth_range": (-10.0, 10.0),
                "y_offset_range": (-3.0, 3.0),
                "topspin_range": (-100.0, 300.0),
                "sidespin_range": (-100.0, 100.0),
                "obs_delay_range": (0, 0),
                "pass_threshold": 0.15,
                "metric": "over_net_rate",
            },
            # Stage 7: 加延迟
            {
                "speed_range": (20.0, 45.0),
                "elevation_range": (-8.0, 2.0),
                "azimuth_range": (-10.0, 10.0),
                "y_offset_range": (-3.0, 3.0),
                "topspin_range": (-100.0, 300.0),
                "sidespin_range": (-100.0, 100.0),
                "obs_delay_range": (0, 3),
                "pass_threshold": 0.12,
                "metric": "over_net_rate",
            },
        ]

    def update(self, metrics):
        """根据指标决定是否推进或回退。"""
        stage = self.stages[self.current_stage]
        metric_value = metrics.get(stage["metric"], 0.0)
        self.success_history.append(metric_value)

        # 取最近 window 的平均
        if len(self.success_history) > self.history_window:
            self.success_history = self.success_history[-self.history_window:]
        avg = sum(self.success_history) / len(self.success_history)

        # 推进条件
        if avg >= stage["pass_threshold"] and self.current_stage < len(self.stages) - 1:
            self.current_stage += 1
            self.success_history = []  # 重置历史
            print(f"🎯 Curriculum advanced to Stage {self.current_stage}")

        # 回退条件(可选):如果推进后成绩持续很差
        if (len(self.success_history) > 50 and
            avg < stage["pass_threshold"] * 0.3 and
            self.current_stage > 0):
            self.current_stage -= 1
            self.success_history = []
            print(f"⚠️ Curriculum retreated to Stage {self.current_stage}")

    def get_current_params(self):
        """返回当前阶段的发球参数范围。"""
        return self.stages[self.current_stage]

KungfuBot 的自适应 Curriculum 机制

KungfuBot(NeurIPS'25)提出了一种更精细的 curriculum——bi-level adaptive tolerance。不是按阶段切换参数范围,而是动态调整 tracking 精度的容忍度:

# KungfuBot 的 bi-level adaptive tolerance(概念重构)
class AdaptiveTolerance:
    """根据当前 tracking error 动态调整容忍度。"""

    def __init__(self, initial_tolerance=0.5, min_tolerance=0.05, decay=0.999):
        self.tolerance = initial_tolerance
        self.min_tolerance = min_tolerance
        self.decay = decay

    def update(self, tracking_error):
        """
        如果 tracking error < 当前 tolerance,缩小 tolerance(增加难度)
        如果 tracking error > 当前 tolerance,保持 tolerance(给策略时间学习)
        """
        if tracking_error < self.tolerance * 0.8:
            self.tolerance = max(self.min_tolerance, self.tolerance * self.decay)

    def compute_reward(self, tracking_error):
        """容忍度内的 error 给满分,超出的线性惩罚。"""
        normalized = tracking_error / self.tolerance
        return torch.clamp(1.0 - normalized, min=0.0)

这种自适应方法的优势是不需要手动设定阶段数和切换条件——tolerance 会自动根据策略能力调整。但它只适用于 tracking 类任务;网球击球的"成功"是二值的(碰到球或没碰到),不能直接用 tracking error。

Phybot 的三阶段解耦

Phybot(arXiv:2511.11218)使用了一种概念上更清晰的三阶段设计——每个阶段训练不同的能力

Stage 1: Footwork(步法)
  - 只训练下肢
  - Reward: 移动到指定位置 + 保持平衡 + 能量最小化
  - 无球参与,纯 locomotion 任务

Stage 2: Swing(挥拍)
  - 只训练上肢(机器人位置固定)
  - Reward: 球拍面法向对准 + 球拍速度匹配 + 击中球
  - 球从固定位置投出

Stage 3: Task(全任务组合)
  - 上下肢协调
  - 加载 Stage 1/2 pretrained weights 作为初始化
  - 完整随机 launcher + reward

与八阶段 curriculum 的区别:八阶段 curriculum 在同一个 task 中逐步增加难度(环境参数变化),而 Phybot 在不同 task 之间切换(能力解耦)。两者可以组合使用——例如 Stage 1 内部也可以有从慢到快的 curriculum。

⚠️ 常见陷阱

⚠️ 思维陷阱:curriculum 越多阶段越好 - 太多阶段 = 太多超参数(每个阶段的门控条件、参数范围) - 8 阶段已覆盖静态→全随机的完整过渡

⚠️ 编程陷阱:curriculum 推进不可逆 - 如果暂时达标但质量不稳,进入更难阶段后持续失败 - 正确做法:允许回退(上面代码已实现)

⚠️ 概念误区:只按训练步数推进 - 1000 iteration 后推进到下一阶段——但如果 contact rate 还是 0%,推进毫无意义 - 正确做法:按成功率门控,不按步数

练习

  1. [设计题] 为阶段 0(静态球)设计具体的环境参数:球的位置、高度、机器人初始位置。球应该放在 arm workspace 内(距机器人 0.3-0.5 m),高度约 1.0-1.5 m。
  2. [设计题] 设计阶段 3→4 的过渡条件和安全回退条件。什么指标达标后推进?什么指标低于什么阈值时回退?

Curriculum 定义了训练难度的递进。下一步是在训练中注入感知噪声——让策略学会在不完美观测下工作。

28.5 Perception Noise Model:与 Ch27 的衔接 ⭐⭐

这一节解决什么问题:将 Ch27 的感知管线与 Ch28 的控制训练连接起来,定义训练时的噪声注入策略。

动机:训练时必须见过噪声

如果策略只在 ground truth 球状态下训练,部署时面对 EKF 估计的有噪声状态——即使噪声只有 2 cm,策略的行为也可能完全不同。回顾 Ch27 的 LATENT 消融实验:去掉 observation noise 后反手成功率从 78% → 0%。这不是理论推测——这是 2026 年最先进的人形网球系统在真机上的定量消融结果。

反事实推理:如果在完全无噪声的 ground truth 下训练到 95% contact rate,然后部署到有 2 cm 噪声的 EKF 输出上——预测球位置偏了 2 cm,策略可能完全无法调整(因为从未在训练中见过这种偏差)。但如果训练时加入了 0-5 cm 的随机噪声,策略会学到"对噪声鲁棒的挥拍策略"——即使预测不完美也能碰到球。

噪声注入的三种策略

策略 实现 适用场景 复杂度 与 Ch27 的关系
固定高斯噪声 ball_pos += N(0, σ²) 快速起步 Ch27 方案 B 的简化
DR 噪声范围 σ ~ U(σ_min, σ_max) LATENT 方案 ⭐⭐ Ch27 的 LATENT DR 配置
Perception-aware 噪声 σ = f(distance, robot_speed) ETH 方案 ⭐⭐⭐ Ch27 的 ETH 噪声模型

教学项目推荐从固定高斯噪声开始,验证后升级到 DR 噪声范围。Perception-aware 噪声需要标定真实相机的误差模型,适合高级项目。

在 mjlab 中配置噪声注入

方法 1:通过 ObsTermCfg 的 noise 参数(最简单)

class NoisyBallObsCfg:
    """直接在 ObsTermCfg 中配置噪声。"""
    ball_pos = ObsTermCfg(
        func=ball_position_court_local,
        noise=GaussianNoiseCfg(mean=0.0, std=0.02),  # 2 cm 高斯噪声
        enable_corruption=True,  # 只在训练时启用,评估时可关闭
    )
    ball_vel = ObsTermCfg(
        func=ball_velocity_world,
        noise=GaussianNoiseCfg(mean=0.0, std=0.5),  # 0.5 m/s 速度噪声
        enable_corruption=True,
    )

方法 2:通过自定义 obs function(更灵活,支持 DR 和 perception-aware)

def ball_position_with_dr_noise(env) -> torch.Tensor:
    """带 DR 噪声的球位置观测。

    噪声标准差在每个 episode 开始时从 DR 分布中采样,
    同一 episode 内噪声标准差固定但每步噪声值不同。
    """
    gt_pos = env.scene["ball"].data.root_pos_w[:, :3] - env.scene.env_origins

    # DR 噪声标准差:每个环境在 episode 开始时采样一次
    noise_std = env.dr_params["ball_obs_noise_std"]  # [N] 从 EventManager 采样
    noise = torch.randn_like(gt_pos) * noise_std.unsqueeze(-1)

    return gt_pos + noise


def ball_position_with_perception_aware_noise(env) -> torch.Tensor:
    """ETH 风格的 perception-aware 噪声。

    噪声随球距离和机器人运动速度动态变化。
    """
    gt_pos = env.scene["ball"].data.root_pos_w[:, :3] - env.scene.env_origins
    robot_pos = env.scene["robot"].data.root_pos_w[:, :3] - env.scene.env_origins
    robot_vel = env.scene["robot"].data.root_lin_vel_w[:, :3]

    # 距离依赖噪声
    distance = (gt_pos - robot_pos).norm(dim=-1, keepdim=True)
    base_noise = 0.01 + 0.04 * (distance / 10.0)  # 1cm @ 0m, 5cm @ 10m

    # 运动模糊噪声
    robot_speed = robot_vel.norm(dim=-1, keepdim=True)
    motion_factor = 1.0 + 0.3 * robot_speed

    # 球速依赖噪声(高速球在图像中更模糊)
    ball_speed = env.scene["ball"].data.root_lin_vel_w[:, :3].norm(dim=-1, keepdim=True)
    speed_factor = 1.0 + 0.1 * ball_speed / 30.0

    total_noise_std = base_noise * motion_factor * speed_factor
    noise = torch.randn_like(gt_pos) * total_noise_std

    return gt_pos + noise

方法 3:通过 Ch27 的 TennisPerceptionPipeline(最真实)

# 直接使用 Ch27 的感知管线作为 obs 来源
def ball_state_from_perception_pipeline(env) -> torch.Tensor:
    """从 Ch27 的 EKF + GRU predictor 获取球状态估计。

    这是最接近真实部署的方案——
    策略看到的就是 EKF 滤波 + GRU 预测的输出,不是 ground truth。
    """
    pred = env.perception_pipeline.step(
        raw_observation=env.scene["ball"].data.root_pos_w[:, :3] - env.scene.env_origins,
        sim_time=env.sim_time,
        launch_cmd=env.commands["launch"].launch_params,
    )
    return torch.cat([
        pred.predicted_state_now,      # [N, 6] 当前状态估计
        pred.impact_pos_c,             # [N, 3] 预测击球点
        pred.time_to_impact.unsqueeze(-1),  # [N, 1]
        pred.uncertainty.unsqueeze(-1),     # [N, 1]
        pred.validity.float().unsqueeze(-1), # [N, 1]
    ], dim=-1)  # [N, 12]

延迟注入的配置

回顾 Ch27 的 DelayedObservationBuffer——在训练中随机化延迟步数:

class PerceptionNoiseManager:
    """管理感知噪声的注入,与 Curriculum 联动。"""

    def __init__(self, num_envs, device):
        self.delay_buffer = DelayedObservationBuffer(
            num_envs=num_envs, obs_dim=3, max_delay_steps=5, device=device
        )
        self.noise_std = torch.zeros(num_envs, device=device)
        self.delay_steps = torch.zeros(num_envs, dtype=torch.int, device=device)

    def configure_for_stage(self, curriculum_stage):
        """根据 Curriculum 阶段配置噪声参数。"""
        noise_configs = {
            0: {"noise_std": 0.0, "max_delay": 0},     # 无噪声
            1: {"noise_std": 0.0, "max_delay": 0},     # 无噪声
            2: {"noise_std": 0.0, "max_delay": 0},     # 无噪声
            3: {"noise_std": 0.005, "max_delay": 0},   # 极小噪声(5mm)
            4: {"noise_std": 0.01, "max_delay": 0},    # 小噪声(1cm)
            5: {"noise_std": 0.02, "max_delay": 0},    # 标准噪声(2cm)
            6: {"noise_std": 0.03, "max_delay": 1},    # 噪声 + 1帧延迟
            7: {"noise_std": 0.03, "max_delay": 3},    # 噪声 + 0-3帧延迟
        }
        cfg = noise_configs.get(curriculum_stage, noise_configs[7])
        self.noise_std[:] = cfg["noise_std"]
        self.max_delay = cfg["max_delay"]

    def apply(self, gt_ball_pos, sim_time):
        """对 ground truth 球位置施加噪声和延迟。"""
        # 1. 添加位置噪声
        noise = torch.randn_like(gt_ball_pos) * self.noise_std.unsqueeze(-1)
        noisy_pos = gt_ball_pos + noise

        # 2. 推入延迟 buffer
        self.delay_buffer.push(noisy_pos, sim_time)

        # 3. 获取延迟后的观测
        if self.max_delay > 0:
            delay = torch.randint(0, self.max_delay + 1,
                                  (gt_ball_pos.shape[0],), device=gt_ball_pos.device)
            delayed_pos = self.delay_buffer.get_delayed(delay)
        else:
            delayed_pos = noisy_pos

        return delayed_pos

    def reset(self, env_ids):
        self.delay_buffer.reset(env_ids)

噪声与 Reward 的关系

关键工程决策:噪声应该加在 observation(策略输入) 上,而不是 reward 计算 上。Reward 应该始终用 ground truth 或 physics predictor 计算——确保 reward 信号稳定。策略从 noisy observation 做决策,但 reward 告诉它"真实世界中你做得多好"。

# ✅ 正确做法
obs["ball_pos"] = perception_noise_manager.apply(gt_ball_pos, sim_time)  # 噪声 obs
reward = compute_approach_reward_with_physics_predictor(env)  # 精确 reward

# ❌ 错误做法
obs["ball_pos"] = gt_ball_pos  # 无噪声 obs(部署时会有噪声)
reward = compute_approach_reward(env)  # 基于 noisy predictor 的 reward(不稳定)

这正是 PACE(arXiv:2509.21690)的核心设计——learned predictor 给 policy obs(可部署),physics predictor 给 reward(精确稳定)。

⚠️ 常见陷阱

⚠️ 概念误区:噪声注入应该从训练一开始就加 - 噪声让任务更难,策略还没学会在无噪声下击球时加噪声只会更学不会 - 正确做法:Curriculum Stage 0-4 用 ground truth(或极低噪声),Stage 5-7 逐步加噪声

⚠️ 编程陷阱:噪声同时加在 obs 和 reward 上 - 后果:reward 不稳定 → PPO 梯度方差大 → 训练发散 - 正确做法:obs 有噪声,reward 用 ground truth

⚠️ 编程陷阱:噪声 buffer 在 episode reset 时未清空 - 新 episode 开始时 delay buffer 中残留上个 episode 的观测 - 正确做法:在 env.reset() 中调用 noise_manager.reset(env_ids)

⚠️ 思维陷阱:认为噪声标准差越大训练越鲁棒 - 过大的噪声让任务变得不可能完成——策略学到"什么都不做" - 正确做法:噪声范围应覆盖真实部署的噪声水平 + 少量余量

练习

  1. [设计题] 为每个 Curriculum Stage(0-7)设计噪声配置:位置噪声标准差、速度噪声标准差、最大延迟帧数。解释每阶段的设计理由。
  2. [编程题] 实现 PerceptionNoiseManager,在训练循环中集成。验证:Stage 0 时 obs 和 ground truth 完全一致,Stage 7 时 obs 有明显偏差。
  3. [分析题] 如果 LATENT 的消融实验显示"去掉 obs noise 后反手成功率从 78% → 0%",说明反手比正手对噪声更敏感。从运动学角度解释为什么——反手动作的哪些特征让它对时机误差更敏感?

28.6 Domain Randomization 与 Sim2Real 风险 ⭐⭐⭐

这一节解决什么问题:识别仿真与真实之间的 gap,通过 DR 在仿真阶段提前暴露风险。

五类 Sim2Real Gap

类别 仿真 vs 真实差异 DR 策略 DR 范围建议
感知 gap RGB 太干净 随机光照/纹理/噪声 Ch27 噪声模型
动力学 gap drag/Magnus 未标定 随机 \(C_d\), \(C_L\), mass ±20-30%
接触 gap 线床/球体变形难建模 随机 friction/restitution/solref ±30%
执行 gap 关节延迟/饱和/backlash 随机 actuator delay/strength delay 5-15 ms
系统 gap 网络延迟/标定误差 随机 observation delay 0-3 帧

DR 在 mjlab EventManager 中的配置

class TennisDREventCfg:
    """网球击球任务的 Domain Randomization 事件配置。"""

    # 球质量随机化
    ball_mass_dr = EventTermCfg(
        func=mdp.randomize_rigid_body_mass,
        mode="reset",  # 每个 episode 重新采样
        params={
            "asset_cfg": SceneEntityCfg("ball"),
            "mass_distribution_params": (0.045, 0.070),  # ITF: 56-59.4g ±20%
        },
    )

    # 球场摩擦随机化
    court_friction_dr = EventTermCfg(
        func=mdp.randomize_rigid_body_material,
        mode="reset",
        params={
            "asset_cfg": SceneEntityCfg("court"),
            "static_friction_range": (0.4, 0.8),
            "dynamic_friction_range": (0.3, 0.7),
            "restitution_range": (0.6, 0.9),
        },
    )

    # 球拍-球摩擦随机化
    racket_friction_dr = EventTermCfg(
        func=mdp.randomize_rigid_body_material,
        mode="reset",
        params={
            "asset_cfg": SceneEntityCfg("racket"),
            "static_friction_range": (0.2, 0.6),
        },
    )

    # 执行器强度随机化
    actuator_strength_dr = EventTermCfg(
        func=mdp.randomize_actuator_gains,
        mode="reset",
        params={
            "asset_cfg": SceneEntityCfg("robot"),
            "stiffness_distribution_params": (0.8, 1.2),  # ±20%
            "damping_distribution_params": (0.8, 1.2),
        },
    )

    # 空气动力学参数随机化
    aero_dr = EventTermCfg(
        func=randomize_ball_aerodynamics,
        mode="reset",
        params={
            "drag_coefficient_range": (0.4, 0.7),
            "lift_coefficient_range": (0.5, 1.5),
        },
    )

逐项引入原则

不要一次加满。每加入一项 DR,记录成功率变化:

# DR 消融实验脚本
dr_configs = [
    {"name": "baseline", "dr_items": []},
    {"name": "+ball_mass", "dr_items": ["ball_mass_dr"]},
    {"name": "+court_friction", "dr_items": ["ball_mass_dr", "court_friction_dr"]},
    {"name": "+racket_friction", "dr_items": ["ball_mass_dr", "court_friction_dr", "racket_friction_dr"]},
    {"name": "+actuator", "dr_items": ["ball_mass_dr", "court_friction_dr", "racket_friction_dr", "actuator_strength_dr"]},
    {"name": "+aero", "dr_items": ["ball_mass_dr", "court_friction_dr", "racket_friction_dr", "actuator_strength_dr", "aero_dr"]},
]

for config in dr_configs:
    # 训练并记录最终 contact_rate 和 over_net_rate
    result = train_with_dr(config["dr_items"], max_iterations=5000)
    print(f"{config['name']}: contact={result['contact_rate']:.1%}, "
          f"over_net={result['over_net_rate']:.1%}")

预期结果:每加一项 DR,成功率会下降 5-15%——但最终在真机上的 zero-shot 性能更好。如果某项 DR 导致成功率断崖式下降(> 50%),说明 DR 范围太宽或 reward 对该参数过敏。

⚠️ 常见陷阱

⚠️ 思维陷阱:DR 是万能保险 - 如果接触模型结构就是错的(没建模线床变形),DR 只在错误模型的参数空间加噪声 - 正确做法:DR 覆盖参数不确定性,不替代正确物理建模

⚠️ 概念误区:DR 应该训练一开始就加 - DR 让任务更难学,策略还没学会标准环境中击球时加 DR 只会更学不会 - 正确做法:先在固定环境中训练到合理 contact rate(Curriculum Stage 0-4),再逐步引入 DR

练习

  1. [设计题] 为 ball mass 设计 DR 范围。ITF 规定 56.0-59.4 g,你的 DR 范围应该比 ITF 范围更宽还是更窄?为什么?提示:DR 需要覆盖标定误差和真实世界的变异。
  2. [实验题] 运行上述 DR 消融实验,绘制"每加一项 DR → 成功率变化"的柱状图。哪项 DR 影响最大?

Isaac Lab 中的 DR 对照

Isaac Lab 的 DR 配置方式与 mjlab 类似但使用不同的 API:

# Isaac Lab 中的 DR 配置
from isaaclab.envs.mdp import events as mdp_events

class IsaacLabDRCfg:
    randomize_ball_mass = EventTermCfg(
        func=mdp_events.randomize_rigid_body_mass,
        mode="reset",
        params={
            "asset_cfg": SceneEntityCfg("ball"),
            "mass_distribution_params": (0.045, 0.070),
            "operation": "abs",  # Isaac Lab 特有:绝对值替换
        },
    )
    randomize_friction = EventTermCfg(
        func=mdp_events.randomize_rigid_body_material,
        mode="reset",
        params={
            "asset_cfg": SceneEntityCfg("court", body_names=".*"),
            "static_friction_range": (0.4, 0.8),
            "dynamic_friction_range": (0.3, 0.7),
            "restitution_range": (0.6, 0.9),
        },
    )

跨框架 DR 的一致性验证:由于 MuJoCo 和 PhysX 的接触模型不同(Ch26),同样的 friction 值在两个引擎中的行为可能不同。建议在两个框架中分别做 COR 标定(Ch26 方法),确保 DR 范围在两个框架中都能产生合理的球弹跳行为。

DR 与 Curriculum 的联动

DR 不应该在 Curriculum 所有阶段都启用——应该随 Curriculum 推进逐步引入:

class CurriculumAwareDR:
    """DR 强度随 Curriculum 阶段自适应调整。"""

    def __init__(self):
        # 每个 DR 项在哪个 Curriculum Stage 引入
        self.dr_schedule = {
            "ball_mass_dr": {"start_stage": 4, "scale_start": 0.3, "scale_end": 1.0},
            "court_friction_dr": {"start_stage": 4, "scale_start": 0.3, "scale_end": 1.0},
            "racket_friction_dr": {"start_stage": 5, "scale_start": 0.3, "scale_end": 1.0},
            "actuator_strength_dr": {"start_stage": 5, "scale_start": 0.5, "scale_end": 1.0},
            "aero_dr": {"start_stage": 6, "scale_start": 0.5, "scale_end": 1.0},
        }

    def get_dr_scale(self, dr_name, curriculum_stage):
        """返回指定 DR 项在当前 Curriculum Stage 的缩放比例。"""
        schedule = self.dr_schedule[dr_name]
        if curriculum_stage < schedule["start_stage"]:
            return 0.0  # DR 未启用
        # 线性插值
        progress = min(1.0, (curriculum_stage - schedule["start_stage"]) / 3.0)
        scale = schedule["scale_start"] + progress * (schedule["scale_end"] - schedule["scale_start"])
        return scale

    def apply_to_event_cfg(self, event_cfg, curriculum_stage):
        """根据 Curriculum Stage 调整 DR 范围。"""
        for name, term in event_cfg.__dict__.items():
            if name in self.dr_schedule:
                scale = self.get_dr_scale(name, curriculum_stage)
                if scale == 0.0:
                    term.enabled = False
                else:
                    # 缩小 DR 范围(中心值不变,宽度按 scale 缩放)
                    for key in term.params:
                        if key.endswith("_range"):
                            low, high = term.params[key]
                            center = (low + high) / 2
                            half_width = (high - low) / 2 * scale
                            term.params[key] = (center - half_width, center + half_width)
                    term.enabled = True

这种联动设计确保了:Curriculum Stage 0-3(策略还在学基本击球)时没有 DR 干扰;Stage 4-5 时逐步引入 DR(范围较窄);Stage 6-7 时使用完整 DR 范围。这与 LATENT 的训练策略一致——先在简单条件下建立基本能力,再用 DR 增强鲁棒性。


28.7 Constrained RL 安全约束 ⭐⭐⭐

这一节解决什么问题:如何在 RL 训练中融入硬件安全约束,防止策略学到损坏机器人的动作。

动机:为什么 reward penalty 不够

用 reward penalty(r -= w * violation)来惩罚不安全动作有一个根本问题:如果不安全动作偶尔带来高 reward(比如极端挥拍碰到了球),策略可能学到"冒险挥拍"——因为 reward 的期望值仍然是正的。在仿真中这没问题,但在真机上一次极端动作就可能损坏电机。

安全约束需要的是硬限制——不是"做了会扣分"而是"根本不允许做"。

Lagrangian PPO 实现

回顾 Ch25 中的 Constrained PPO——将安全约束优化转化为 Lagrangian 对偶问题:

\[\max_\pi \min_{\lambda \geq 0} J(\pi) - \sum_k \lambda_k (C_k(\pi) - d_k)\]
class LagrangianPPOForTennis:
    """网球击球任务的 Lagrangian PPO。"""

    def __init__(self, cost_configs):
        """
        cost_configs: 安全约束列表
        每项包含 name, cost_function, limit
        """
        self.costs = cost_configs
        self.lambdas = {cfg["name"]: 0.0 for cfg in cost_configs}
        self.lambda_lr = 0.005  # 乘子学习率(远小于策略 lr)

    def compute_augmented_reward(self, reward, env):
        """计算 Lagrangian 增强 reward。"""
        augmented = reward.clone()

        for cfg in self.costs:
            cost = cfg["cost_function"](env)  # [N]
            lam = self.lambdas[cfg["name"]]
            augmented -= lam * cost

        return augmented

    def update_lambdas(self, avg_costs):
        """每个 rollout 后更新 Lagrange 乘子。"""
        for cfg in self.costs:
            violation = avg_costs[cfg["name"]] - cfg["limit"]
            self.lambdas[cfg["name"]] = max(
                0.0,
                min(10.0, self.lambdas[cfg["name"]] + self.lambda_lr * violation)
            )

# 安全约束定义
tennis_safety_constraints = [
    {
        "name": "joint_velocity_limit",
        "cost_function": lambda env: (
            env.scene["robot"].data.joint_vel.abs() >
            env.scene["robot"].data.joint_vel_limits * 0.9
        ).float().mean(dim=-1),
        "limit": 0.05,  # 不超过 5% 的时间步违规
    },
    {
        "name": "base_tilt_limit",
        "cost_function": lambda env: (
            env.scene["robot"].data.projected_gravity_b[:, :2].norm(dim=-1) > 0.5
        ).float(),
        "limit": 0.02,  # 不超过 2% 的时间步倾斜过大
    },
    {
        "name": "racket_ground_contact",
        "cost_function": lambda env: env.contact_sensor.detect_contact(
            geom_a="racket_face", geom_b="court_surface"
        ).float(),
        "limit": 0.0,  # 球拍不能碰地
    },
]

Termination 与安全设计

类别 条件 优先级 time_out 含义
安全 robot fall (body height < 0.3 m) 最高 False 机器人倒地 → 立即终止
安全 joint limit severe (> 95% of limit) 最高 False 关节接近极限 → 可能损坏
安全 self collision (检测到) 最高 False 自碰撞 → 硬件风险
安全 racket hit ground False 球拍碰地 → 球拍损坏
安全 excessive contact force (> 500 N) False 接触力过大 → 关节损坏
任务 ball missed (ball stopped, no contact) False 球停了但没碰到 → 失败
任务 ball oob after contact False 出界 → 评估完成
任务 ball landed after contact False 击球任务完成 → MDP terminal(terminated)
仿真 timeout (5 秒) True 人为时间截断 → truncation,value bootstrap

关键设计:要分清 Isaac Lab 的两类终止语义——time_out=True 表示人为时间截断(truncation),进入 truncated/timeouts 通道,RSL-RL 据此对最终状态做 value bootstrapping(\(V(s_T) \neq 0\),因为真实 MDP 本会继续);time_out=False 表示真正的 MDP 终止(terminated),\(V(s_T)=0\)ball landed after contact 是单次击球任务的真实终止(球已落地、本回合结束、无后续回报),因此应设为 time_out=False,只有 5 秒上限这种人为截断才用 time_out=True

那"策略会不会为了避免 episode 结束而学到不击球(Ch25 模式九自杀策略)"?正确的解决办法是奖励设计——成功击球落入目标区给显著正的 outcome reward,策略自然被引导去击球,而不是靠把成功终止错标成 timeout 来回避。把成功落地标成 time_out=True 反而会混淆终止统计、is_terminated 类奖励和价值目标。

Termination 的完整配置代码

class TennisStrikeTerminationsCfg:
    """击球任务的完整 termination 配置。"""

    # === 安全 termination(立即终止,不做 value bootstrapping)===
    robot_fall = TerminationTermCfg(
        func=mdp.root_height_below_threshold,
        params={"minimum_height": 0.3},
        time_out=False,
    )
    joint_limit_violation = TerminationTermCfg(
        func=mdp.joint_pos_out_of_limit,
        params={"margin": 0.05},
        time_out=False,
    )
    self_collision = TerminationTermCfg(
        func=mdp.illegal_contact,
        params={
            "threshold": 1.0,
            "sensor_cfg": SceneEntityCfg("contact_sensor", body_names=".*"),
        },
        time_out=False,
    )
    racket_ground_contact = TerminationTermCfg(
        func=detect_racket_ground_contact,
        time_out=False,
    )

    # === 任务 termination ===
    ball_missed = TerminationTermCfg(
        func=ball_stopped_no_contact,
        params={"speed_threshold": 0.5, "z_threshold": 0.05},
        time_out=False,
    )
    ball_oob = TerminationTermCfg(
        func=ball_out_of_bounds,
        params={"x_limit": 15.0, "y_limit": 8.0, "z_limit": -0.5},
        time_out=False,
    )

    # === 任务成功结束(MDP terminal,terminated,不做 timeout bootstrapping)===
    # 注意:击球任务完成(球落地)是真正的 MDP 终止,应走 terminated(time_out=False);
    # 只有"人为时间截断"才用 time_out=True(进入 truncated/timeouts 通道,RSL-RL 据此 bootstrap value)。
    ball_landed_after_contact = TerminationTermCfg(
        func=ball_landed_post_contact,
        time_out=False,
    )
    # === 时间截断(truncation,value bootstrapping)===
    timeout = TerminationTermCfg(
        func=mdp.time_out,
        params={"max_episode_length_s": 5.0},
        time_out=True,
    )

CBF 安全过滤器(推理层安全保证)

Lagrangian PPO 提供训练层的统计安全保证。但单个 episode 仍可能违反约束。部署时需要推理层的逐步安全保证——CBF(Control Barrier Function)安全过滤器。

数学基础(Ames et al. 2014-2019):定义安全集合 \(\mathcal{C} = \{x : h(x) \geq 0\}\),其中 \(h\) 是连续可微的 barrier function。\(h(x) > 0\) 表示安全,\(h(x) = 0\) 是边界,\(h(x) < 0\) 是危险区域。对仿射控制系统 \(\dot{x} = f(x) + g(x)u\),CBF 安全条件为:

\[\underbrace{\frac{\partial h}{\partial x}f(x)}_{L_f h} + \underbrace{\frac{\partial h}{\partial x}g(x)}_{L_g h} \cdot u \geq -\gamma \cdot h(x)\]

其中 \(L_f h\)\(L_g h\) 是 Lie 导数,\(\gamma > 0\) 控制保守程度。直觉:\(h(x)\) 很大时右侧 \(-\gamma h\) 是大负数,约束很弱——策略自由行动。\(h(x)\) 接近零时约束变紧——策略被迫调整。就像汽车自适应巡航:车距远时自由加减速,车距近时自动减速。

网球击球中的典型 barrier function

约束 \(h(x)\) 安全含义
关节位置 \((q_{\max} - q)(q - q_{\min})\) 关节在允许范围内
机器人最小高度 \(z_{\text{root}} - z_{\min}\) 不跌倒
接触力 \(f_{\max} - \|f_{\text{contact}}\|\) 接触力不超限
球拍高度 \(z_{\text{racket}} - z_{\text{ground}}\) 球拍不碰地

CBF 的核心思想:在策略输出后、执行前,检查动作是否可能导致状态越过安全边界。如果可能越界,用 QP 找到最近的安全动作。以下是教学简化版本(直接约束关节速度):

def cbf_joint_limit_filter(
    action: torch.Tensor,           # [N, action_dim] 策略输出
    joint_pos: torch.Tensor,        # [N, n_j] 当前关节位置
    joint_vel: torch.Tensor,        # [N, n_j] 当前关节速度
    joint_limits: torch.Tensor,     # [n_j, 2] (min, max)
    dt: float = 0.02,
    gamma: float = 2.0,
) -> torch.Tensor:
    """CBF 安全过滤器:防止关节越限。

    对每个关节 j 定义两个 barrier function:
      h_lower(q) = q_j - q_min_j  (距下限余量)
      h_upper(q) = q_max_j - q_j  (距上限余量)

    CBF 条件(简化为速度约束):
      q̇_j >= -gamma * (q_j - q_min_j)    (不能太快向下限移动)
      q̇_j <= +gamma * (q_max_j - q_j)    (不能太快向上限移动)
    """
    q_min = joint_limits[:, 0]  # [n_j]
    q_max = joint_limits[:, 1]  # [n_j]

    # 预估下一步关节位置(简化:假设 action 直接影响关节速度)
    # 实际中需要通过 IK Jacobian 映射
    estimated_vel = joint_vel + action[:, 3:] * dt  # 简化

    # CBF 速度约束
    vel_lower = -gamma * (joint_pos - q_min)
    vel_upper = gamma * (q_max - joint_pos)

    # Clamp 到安全范围
    safe_vel = torch.clamp(estimated_vel, min=vel_lower, max=vel_upper)

    # 逆映射回 action(简化)
    safe_action = action.clone()
    safe_action[:, 3:] = (safe_vel - joint_vel) / dt

    return safe_action

⚠️ 重要工程声明:上述 CBF 是教学简化版本——它直接约束关节速度而非通过 IK Jacobian 映射 task-space action 到 joint-space 约束。完整的 CBF-QP 需要知道动作到关节速度的映射(\(J^{-1}\)),计算量更大但更精确。真机部署时不应直接使用此简化版本——应使用基于 Jacobian 的完整 CBF-QP,或至少在此基础上叠加硬件层的 PD 限幅保护。对教学目的,简化版本足够演示 CBF 的核心思想——"在安全边界处最小修正策略"。

本质洞察:三层安全架构(训练层 Lagrangian + 推理层 CBF + 硬件层 PD 限幅) 就像汽车的三重保护:安全意识培训(训练)→ 自动紧急制动(CBF)→ 安全带(硬件限幅)。 每层都可能失效,但三层同时失效的概率极低。

策略的"放弃"能力

策略应该有能力决定"这个球不打了"——当 Ch27 的 PredictorOutput.validity = False 时(球不可达),机器人应该回到 ready pose 而非强行挥拍。真实系统中不可达球比危险挥拍更可接受。

def compute_give_up_reward(env):
    """当球不可达时,回到 ready pose 给 reward。"""
    should_give_up = ~env.perception.validity

    if should_give_up.any():
        current_joint_pos = env.scene["robot"].data.joint_pos
        ready_joint_pos = env.default_joint_pos
        distance_to_ready = (current_joint_pos - ready_joint_pos).norm(dim=-1)
        give_up_reward = torch.exp(-distance_to_ready / 0.5)
        give_up_reward = give_up_reward * should_give_up.float()
    else:
        give_up_reward = torch.zeros(env.num_envs, device=env.device)

    return give_up_reward

Lambda 训练轨迹诊断

Lagrange 乘子 \(\lambda\) 的训练轨迹是诊断安全约束是否合理的关键信号:

# WandB 中监控 lambda 轨迹
# Lambda/joint_velocity_limit = 0.5 → 约束活跃但不过度
# Lambda/joint_velocity_limit 持续上升 → 约束太紧,策略无法在约束内完成任务
# Lambda/joint_velocity_limit = 0 → 约束太松,可以收紧
# Lambda/joint_velocity_limit 轻微、收敛范围内振荡 → 约束活跃;
#   但持续大幅振荡通常需检查 lambda_lr 过大、cost 归一化、约束上限或 curriculum 难度(参见 PID-Lagrangian)

⚠️ 常见陷阱

⚠️ 编程陷阱:termination 吞掉 outcome 评估窗口 - 球拍碰到球就 terminate → outcome reward 无法计算 - 正确做法:contact 后继续运行到球落地或出界

⚠️ 思维陷阱:策略学到"总是放弃" - 如果 miss penalty 太弱或 approach reward 不足,策略发现"不动"比"挥拍但失败"更安全 - 修复:确保接近球的行为有持续正 reward;give_up reward 的权重不能大于 approach reward

⚠️ 编程陷阱:Lagrange 乘子 lambda_lr 设太大 - 乘子振荡导致策略在"满足约束"和"完成任务"之间反复切换 - 正确做法:lambda_lr = policy_lr / 10 ~ policy_lr / 100

⚠️ 概念误区:CBF 可以替代 reward shaping - CBF 保证安全但不优化性能。策略仍需 reward 学习好的行为 - 两者互补:reward 负责"学会做什么",CBF 负责"不允许做什么"

练习

  1. [设计题] 为网球击球任务设计完整的 termination 列表(至少 8 项),区分安全/任务/仿真三类,标注 time_out 设置和优先级。
  2. [编程题] 实现 LagrangianPPOForTennis,在标准 PPO 训练循环中加入安全约束。在 WandB 中监控 lambda 轨迹——lambda 持续上升意味着什么?需要怎么调整?
  3. [推导题] 对一维系统 \(\dot{q} = u\),推导关节位置限制 \(h(q) = (q_{max} - q)(q - q_{min})\) 的 CBF 条件 \(\dot{h} \geq -\gamma h\)。这个条件如何转化为对 \(u\) 的约束?

28.8 实时性与频率分离 ⭐⭐⭐

这一节解决什么问题:击球控制的时间约束分析和频率设计。这不是"性能优化"——而是"任务能否完成"的前提。

动机:频率决定了策略能否"看到"击球窗口

回顾 Ch26:网球拍-球接触持续约 3-5 ms。如果控制步长为 20 ms(50 Hz),一次接触只有 1 个控制步在"接触区间"内——策略在这个步之前还不知道球到了,这个步之后球已经飞走了。策略必须提前开始挥拍,而不是"看到球到了再反应"。

定量分析:球速 30 m/s,控制频率 50 Hz。

参数 数值 含义
控制步长 20 ms 策略每 20 ms 做一次决策
球单步移动 0.60 m 每个控制步球飞 60 cm
球拍有效直径 ~0.30 m 球拍面可击球的宽度
击球窗口 < 1 步 球从"可打"到"飞过"不到一个控制步!
反应时间需求 ~0.3-0.5 s 球从远端飞到近端
可用决策步数 15-25 步 反应窗口内的控制步数

关键认知:策略不是在"击球那一刻"做决策——而是在球飞来的0.3-0.5 秒内规划整个击球动作。这就像棒球击球手——不是看到球到好球带才挥棒,而是在投手投球后 0.2 秒内就决定要不要挥棒、怎么挥。

频率需求分析

模块 频率 决定因素 前沿系统参考
Predictor(Ch27) 50-100 Hz 相机帧率、EKF 推理时间 HITTER: OptiTrack 360 Hz
Policy(Ch28) 50-100 Hz env step、球速约束 LATENT: 50 Hz, HITTER: 50 Hz
IK/Controller 500-1000 Hz 关节动力学时间常数 LATENT: PD @ 2000 Hz
Physics sim 1000-2000 Hz 接触数值稳定性 LATENT: 2000 Hz, HITTER: 2000 Hz

前沿系统的频率配置对比

系统 物理频率 控制频率 感知频率 decimation 理由
LATENT 2000 Hz 50 Hz 50 Hz 40 球拍-球接触需要高频仿真
HITTER 2000 Hz 50 Hz 360 Hz 40 同上 + 高频动捕
ETH 羽毛球 1000 Hz 50 Hz 30 Hz 20 羽毛球速较慢
Phybot 1000 Hz 50 Hz 100 Hz 20 同上
当前 mjlab Tennis 500 Hz 100 Hz N/A 5 Phase 1 无接触

Phase 1 → Strike Task 的频率升级:当前 timestep=0.002(500 Hz)对纯弹道飞行足够,但对球拍-球接触不足。Ch28 的 strike task 应升级到 timestep=0.001(1000 Hz)或 timestep=0.0005(2000 Hz),对应 decimation=20decimation=40(保持 50 Hz 控制频率)。

# Strike task 的仿真配置(从 Phase 1 升级)
class TennisStrikeSimCfg:
    # Phase 1(弹道验证)
    # timestep = 0.002  # 500 Hz
    # decimation = 5    # 100 Hz 控制

    # Strike task(需要准确的接触解算)
    timestep = 0.0005   # 2000 Hz(LATENT 配置)
    decimation = 40      # 50 Hz 控制(2000/40 = 50)

    # 性能影响
    # 仿真步数翻 4 倍(500→2000 Hz)
    # 但 env step 不变(50 Hz 控制 → 每个 policy step 40 个仿真步)
    # 4096 环境 × 40 仿真步/控制步 ≈ 每个 rollout step 16 万仿真步
    # 在单 GPU 上约需 50-100 ms(可接受)

频率分离架构的完整设计

高速接触需要频率分离:高层策略低频更新击球计划,低层 controller 高频追踪轨迹。这与腿足控制中 MPC(10-100 Hz)+ WBC(500-1000 Hz)的分层架构完全一致。

class TennisFrequencySeparation:
    """网球击球的频率分离架构。"""

    def __init__(self, policy, ik_solver, predictor):
        self.policy = policy             # 50 Hz 高层策略
        self.ik_solver = ik_solver       # 内置在 action transform 中
        self.predictor = predictor       # 50 Hz 感知
        self.pd_controller = PDController()  # 2000 Hz PD 追踪

        self.current_action = None
        self.high_level_counter = 0
        self.decimation = 40  # 2000/50 = 40

    def step_physics(self, robot_state, sim_step):
        """每个仿真步(2000 Hz)调用一次。"""

        # 每 40 个仿真步更新一次高层策略
        if sim_step % self.decimation == 0:
            obs = self._build_observation(robot_state)
            pred = self.predictor.step(obs)
            self.current_action = self.policy.act(obs)

        # 每个仿真步都用 PD 控制器追踪
        if self.current_action is not None:
            # IK 把 task-space action 转为 joint targets
            joint_targets = self.ik_solver.solve(self.current_action, robot_state)
            # PD 控制
            torques = self.pd_controller.compute(
                target=joint_targets,
                current_pos=robot_state.joint_pos,
                current_vel=robot_state.joint_vel,
            )
            return torques
        else:
            return torch.zeros_like(robot_state.joint_pos)  # 零力矩

延迟预算表

从球发出到策略输出动作到机器人执行,每个环节的延迟:

环节 仿真中 真机(动捕) 真机(视觉)
感知采集 0 ms 3 ms 16-33 ms
状态估计(EKF) 0 ms < 1 ms < 1 ms
轨迹预测(GRU) 0 ms 1-2 ms 1-2 ms
策略推理 0 ms 2-5 ms 2-5 ms
IK 计算 内置 1-2 ms 1-2 ms
通信延迟 0 ms 1-3 ms 1-3 ms
执行器响应 0 ms 5-20 ms 5-20 ms
总延迟 0 ms ~15-35 ms ~30-80 ms

球速 30 m/s + 总延迟 50 ms → 延迟位置误差 1.5 m——超过球拍有效范围!这就是为什么 Ch27 的延迟补偿和 Ch28 的 perception noise model 如此重要——延迟不补偿,策略永远挥晚。

反事实推理:如果 policy 频率只有 10 Hz(env step = 0.100 s),球速 35 m/s 时一个 step 球移动 3.5 m——这比半个球场还长。策略在整个飞行过程中只有 3-5 次决策机会,根本无法精确控制挥拍时机。这就像用 3 帧动画表达一个复杂的击球动作——帧数不够信息就丢失了。

⚠️ 常见陷阱

⚠️ 思维陷阱:训练时不考虑延迟 - 策略在无延迟仿真中学到"球到面前再挥拍",部署时总挥晚 - 正确做法:Curriculum Stage 7 注入 observation delay

⚠️ 编程陷阱:提高仿真频率时忘记调整 decimation - timestep=0.0005 + decimation=5 → 控制频率 400 Hz → policy 被调用太频繁,rollout 步数不够 - 正确做法:timestep=0.0005 + decimation=40 → 50 Hz 控制

⚠️ 概念误区:更高仿真频率总是更好 - 2000 Hz → 4000 Hz 增加一倍计算量但接触精度改善有限 - 正确做法:2000 Hz 对网球已经足够(LATENT 验证过),不需要更高

练习

  1. [估算题] policy 50 Hz + 球速 35 m/s,一个 step 球移动多少?和球拍半径 0.15 m 比较。如果球从远端(x=11m)飞到近端(x=-8m),飞行约 19m。在 50 Hz 控制下策略有多少个决策步?
  2. [设计题] 设计从 Phase 1(500 Hz / 100 Hz)到 Strike Task(2000 Hz / 50 Hz)的仿真配置升级计划。列出需要修改的所有参数和预期的性能影响。
  3. [估算题] 如果真机总延迟 50 ms,球速 30 m/s,Ch27 的延迟补偿将球状态前推 50 ms。补偿后的残余误差主要来自什么?提示:前推 50 ms 使用的是近似的物理模型,不是精确模型。

28.9 网球 Sim2Real 特殊挑战 ⭐⭐⭐

这一节解决什么问题:总结网球 Sim2Real 的特有挑战和工程对策。

五类特殊挑战

挑战 网球特有的原因 对策 Ch 参考
接触模型 gap 线床变形、球体压缩、接触面积变化 DR friction/restitution + 增大 contact 碰撞体厚度 Ch26
空气动力学 gap 真实 drag/Magnus 与简化模型有偏差 DR \(C_d\)/\(C_L\) + GRU 残差(Ch27) Ch26, Ch27
感知延迟 总延迟 20-100 ms vs 球飞行 0.5-1 s 延迟补偿(Ch27)+ DR 延迟 Ch27
执行器饱和 高速挥拍时电机力矩饱和 DR actuator strength + action clamp + CBF Ch28
接触时机精度 3-5 ms 接触窗口 vs ~20 ms 控制步长 2000 Hz 仿真 + Butterworth 滤波 Ch26, Ch25

LATENT 的完整 Sim2Real 工程路线

LATENT 的 Sim2Real 路线是目前最完整的网球部署方案。以下是其工程细节(根据论文和代码库重构):

Phase 1:仿真训练(MuJoCo JAX, 2000 Hz, 8 GPU)

# LATENT 的训练配置(重构)
class LATENTTrainingConfig:
    # 仿真
    sim_frequency: int = 2000  # Hz
    control_frequency: int = 50  # Hz
    num_envs: int = 4096
    num_gpus: int = 8

    # 动力学 DR(必需——去掉后 91% → 14-29%)
    ball_mass_range: tuple = (0.045, 0.070)
    ball_restitution_range: tuple = (0.60, 0.85)
    ball_drag_range: tuple = (0.40, 0.70)
    ball_friction_range: tuple = (0.3, 0.8)

    # 观测噪声 DR(必需——去掉后反手 78% → 0%)
    ball_pos_noise_std: float = 0.02
    ball_vel_noise_std: float = 0.5
    ball_obs_delay_range: tuple = (0, 3)  # 帧

    # 机器人 DR
    joint_friction_range: tuple = (0.5, 1.5)
    motor_strength_range: tuple = (0.8, 1.2)
    action_delay_range: tuple = (0, 2)  # 帧

Phase 2:Sim2Sim 验证

在另一个仿真器中回放策略,验证关节顺序和动作缩放的一致性。LATENT 使用 MuJoCo JAX 训练、MuJoCo C 回放——两者的 joint ordering 可能不同。

def sim2sim_verification(policy_onnx, env_mjcf):
    """Sim2Sim 验证流程。"""
    # 1. 加载策略
    import onnxruntime as ort
    session = ort.InferenceSession(policy_onnx)

    # 2. 在 MuJoCo C 中运行
    model = mujoco.MjModel.from_xml_path(env_mjcf)
    data = mujoco.MjData(model)

    for step in range(1000):
        # 构造观测
        obs = build_observation(data)
        # 策略推理
        action = session.run(None, {"obs": obs.numpy()})[0]
        # 检查 joint ordering
        verify_joint_mapping(action, model)
        # 执行
        data.ctrl[:] = action[0]
        mujoco.mj_step(model, data)

    print("✅ Sim2Sim verification passed")

Phase 3:真机低速测试

# 真机部署脚本框架
class RealWorldDeployment:
    def __init__(self, policy_path, robot_interface):
        self.policy = load_onnx_policy(policy_path)
        self.robot = robot_interface
        self.filter = ButterworthFilter(order=2, cutoff=4.0, fs=50.0, num_joints=29)
        self.beta = 0.3  # 初始 action rescaler(保守)

    def run_episode(self, ball_launcher):
        """运行一个 episode。"""
        # 发球
        ball_launcher.launch(speed=15.0)  # 先用低速

        for step in range(500):
            # 获取观测
            robot_state = self.robot.get_state()
            ball_state = self.perception.get_ball_state()
            obs = build_observation(robot_state, ball_state)

            # 策略推理
            raw_action = self.policy.infer(obs)

            # 安全处理
            scaled_action = raw_action * self.beta
            filtered_action = self.filter.filter(scaled_action)

            # 安全检查
            if self.is_unsafe(filtered_action, robot_state):
                self.robot.emergency_stop()
                break

            # 执行
            self.robot.send_action(filtered_action)

            # 检查 episode 结束
            if ball_state.stopped or ball_state.oob:
                break

    def is_unsafe(self, action, state):
        """运行时安全检查。"""
        # 关节速度过大
        if (state.joint_vel.abs() > state.joint_vel_limits * 0.95).any():
            return True
        # 机器人倾斜过大
        if state.body_tilt > 0.7:  # ~40 度
            return True
        # 球拍太低(可能碰地)
        if state.racket_height < 0.1:
            return True
        return False

    def gradually_increase_difficulty(self):
        """逐步增加难度。"""
        # Step 1: 低速 + 低 β
        self.beta = 0.3
        results_low = self.run_n_episodes(20, ball_speed=15.0)
        if results_low["contact_rate"] < 0.5:
            print("⚠️ Low-speed contact rate too low, check deployment")
            return

        # Step 2: 低速 + 高 β
        self.beta = 0.8
        results_mid = self.run_n_episodes(20, ball_speed=15.0)

        # Step 3: 中速 + 高 β
        results_fast = self.run_n_episodes(20, ball_speed=25.0)

        # Step 4: 全速
        self.beta = 1.0
        results_full = self.run_n_episodes(50, ball_speed=35.0)

        print(f"Deployment results:")
        print(f"  Low speed:  contact={results_low['contact_rate']:.1%}")
        print(f"  Mid speed:  contact={results_mid['contact_rate']:.1%}")
        print(f"  High speed: contact={results_fast['contact_rate']:.1%}")
        print(f"  Full speed: contact={results_full['contact_rate']:.1%}")

WoCoCo 的部署安全措施

WoCoCo(CoRL'24)在部署时使用了两道安全措施(Ch25 已详述),这里给出在网球击球中的具体配置:

class WoCoCoDeploySafety:
    """WoCoCo 风格的部署安全包装。"""

    def __init__(self):
        # 1. Butterworth 低通滤波器
        # 截止频率 4 Hz @ 50 Hz 控制 → 滤除 > 4 Hz 的高频噪声
        self.filter = ButterworthFilter(
            order=2, cutoff=4.0, fs=50.0, num_joints=29
        )

        # 2. Action rescaler
        # 部署初期 β=0.3(动作幅度减半),逐步提高到 1.0
        self.beta = 0.3

        # 3. 速度限制
        self.max_joint_vel = 5.0  # rad/s(保守)

        # 4. 平滑过渡
        self.prev_action = None
        self.max_action_rate = 2.0  # rad/step

    def apply(self, raw_action):
        """完整的安全处理链。"""
        # Step 1: 缩放
        action = raw_action * self.beta

        # Step 2: 速率限制
        if self.prev_action is not None:
            delta = action - self.prev_action
            delta = torch.clamp(delta, -self.max_action_rate, self.max_action_rate)
            action = self.prev_action + delta

        # Step 3: 滤波
        action = self.filter.filter(action)

        # Step 4: 速度限制
        action = torch.clamp(action, -self.max_joint_vel, self.max_joint_vel)

        self.prev_action = action.clone()
        return action

从 LATENT 消融实验中学到的工程教训

LATENT 的消融实验提供了关于 Sim2Real 的三个关键工程教训:

教训 1:Dynamics DR 是不可省略的。 去掉后成功率从 91% → 14-29%。这说明仿真中的物理参数和真实世界之间存在显著的 gap——即使你认为自己标定得很好。DR 的本质不是让仿真更"真实",而是让策略对参数不确定性鲁棒

教训 2:Observation noise 对某些技能是生死攸关的。 去掉后反手成功率从 78% → 0%。反手比正手更敏感的原因是:反手动作的机械约束更多(手臂活动范围更小),需要更精确的时机控制。精确时机依赖于准确的延迟补偿——如果训练时没见过延迟/噪声,部署时延迟就会导致系统性的时机偏差。

教训 3:两者必须同时存在。 只有 dynamics DR 没有 observation noise → 50% 正手但 0% 反手。只有 observation noise 没有 dynamics DR → 效果更差。两种 DR 覆盖不同的 gap——dynamics DR 覆盖物理参数的不确定性,observation noise 覆盖感知管线的不确定性。

# LATENT 真机消融实验的定量结果(按论文 Table 5,单位 %)
# 论文 Table 5 的 w/o DR 行按 Forehand/Backhand/Forecourt/Backcourt 为 16.67/25.00/28.57/14.29,
# 此处仅取 Forehand/Backhand(早期版本曾误把 Forecourt/Backcourt 的 28.57/14.29 写成 forehand/backhand)。
latent_ablation = {
    "full_system": {"forehand": 90.90, "backhand": 77.78},
    "no_dynamics_dr": {"forehand": 16.67, "backhand": 25.00},
    "no_obs_noise": {"forehand": 50.00, "backhand": 0.00},
}

⚠️ 常见陷阱

⚠️ 思维陷阱:仿真中 contact rate 95% = 真机 contact rate 95% - Sim2Real gap 通常让成绩下降 20-50% - LATENT 正手:仿真 SR 96.52%(判据:落到目标 2.5 m 内);真机 SR 90.90%(判据放宽为落入对方球场边界)——两者指标定义不同,不能直接当作同一 metric 的 sim-to-real 降幅 - 正确做法:仿真中至少达到 80%+ contact rate 才考虑上真机

⚠️ 编程陷阱:部署时不加 Butterworth 滤波器 - 策略可能输出超过电机带宽的高频信号 → 电机过热、齿轮打牙 - 正确做法:始终在部署端加低通滤波器,截止频率 = 控制频率的 8-10%

⚠️ 思维陷阱:只做一次 Sim2Real 就结束 - 第一次部署几乎一定有问题(joint ordering、action scale、延迟不匹配等) - 正确做法:Sim2Real 是迭代过程——分析真机失败 case → 调整 DR → 重新训练 → 再部署

⚠️ 概念误区:DR 范围越宽越好 - 过宽的 DR 让训练任务过难,策略可能学到过于保守的行为("什么都不做最安全") - 正确做法:DR 范围应覆盖真实世界的不确定性 + 适量余量(通常 ±20-30%)

练习

  1. [分析题] 对比 LATENT 和 HITTER 的 Sim2Real 路线。哪个更依赖外部设备(动捕)?哪个更接近自主部署?如果目标是"不需要 OptiTrack 的自主网球机器人",需要解决什么额外挑战?
  2. [设计题] 设计一个完整的 Sim2Real 检查清单(至少 12 项),参考 Ch25 的部署前检查清单。每项标注"仿真侧检查"还是"真机侧检查"。
  3. [估算题] 如果真机的总感知延迟是 50 ms,球速 30 m/s,延迟补偿后的残余误差约为多少?这个误差对 contact rate 的影响有多大?提示:比较误差和球拍半径(15 cm)。

28.10 前沿系统总结与综合项目收尾 ⭐⭐⭐

这一节解决什么问题:总结 Part VII 三章(Ch26-28)的完整综合项目,回顾前沿系统的共性模式。

Part VII 三章的完整链路

Ch26(物理底座)          Ch27(感知管线)          Ch28(控制策略)
    ↓                        ↓                        ↓
球场坐标系              Label 设计              击球 MDP
球体物理模型            EKF 状态估计            Staged Reward
发球命令                GRU 残差预测            8 阶段 Curriculum
弹道空气动力学          延迟补偿                DR + 安全约束
接触验证                PredictorOutput         Sim2Real
机器人+球拍规划         Teacher-Student         部署安全
    ↓                        ↓                        ↓
    └────────────────→ 数据协议 ←────────────────┘

三章通过明确的接口连接:Ch26 向 Ch27 传递坐标系和物理参数;Ch27 向 Ch28 传递 PredictorOutput;Ch28 向真机传递安全包装后的关节动作。

综合项目的最终产出

产出 描述 章节
tennis_court.xml 标准网球场 MJCF Ch26
BallEKFWithDrag 含阻力的 GPU EKF Ch27
PhysicsResidualPredictor 物理+GRU 预测器 Ch27
TennisPerceptionPipeline 完整感知管线 Ch27
TennisStrikeEnvCfg 击球 task 配置 Ch28
TennisCurriculumManager 8 阶段 curriculum Ch28
LagrangianPPOForTennis 安全约束训练 Ch28
DeploySafetyWrapper 部署安全包装 Ch28

从本项目学到的通用模式

网球项目不是"一个特殊场景"——它是机器人感知控制的压力测试。以下模式可以迁移到任何高速交互任务:

模式 网球中的体现 可迁移到
环境即数据协议 坐标系、接触参数、termination 语义 任何多模块系统
分层感知 ground truth → EKF → predictor 任何有噪声传感器的系统
物理+残差预测 drag baseline + GRU residual 任何有物理模型的预测任务
Staged reward approach × timing × contact × outcome 任何稀疏 reward 任务
Curriculum 静态→低速→全速→有噪声 任何从简到难的训练
DR 逐项引入 先固定→逐项加噪→全噪声 任何需要 Sim2Real 的系统
Privileged critic critic 看 GT、actor 看 sensor 任何 teacher-student 任务
部署安全包装 Butterworth + action rescaler 任何真机部署

完整训练管线的集成

把前面所有组件组装成一个可运行的训练管线——这是本章最重要的工程产出。

# === 完整的 Tennis Strike 环境配置 ===

class TennisStrikeEnvCfg(ManagerBasedRlEnvCfg):
    """网球击球任务的完整环境配置。"""

    # === Scene 配置 ===
    scene = TennisStrikeSceneCfg(
        terrain=TerrainImporterCfg(terrain_type="plane"),
        court=EntityCfg(spawn=MjcfFileCfg(mjcf_path="tennis_court.xml")),
        ball=EntityCfg(spawn=BallSpecCfg()),
        robot=ArticulationCfg(
            spawn=MjcfFileCfg(mjcf_path="unitree_g1_with_racket.xml"),
            init_state=ArticulationCfg.InitialStateCfg(
                pos=(-8.0, 0.0, 0.0),
                joint_pos={".*": 0.0},
            ),
        ),
        num_envs=4096,
        env_spacing=30.0,
    )

    # === Observation 配置 ===
    observations = TennisStrikeObsCfg()

    # === Action 配置 ===
    actions = TennisStrikeActionsCfg()

    # === Reward 配置 ===
    rewards = {
        "approach": RewardTermCfg(
            func=compute_approach_reward, weight=1.0,
            params={"sigma": 0.3},
        ),
        "timing": RewardTermCfg(
            func=compute_timing_reward, weight=0.5,
            params={"tau": 0.05},
        ),
        "contact": RewardTermCfg(
            func=compute_contact_reward, weight=5.0,
        ),
        "outcome": RewardTermCfg(
            func=compute_outcome_reward, weight=10.0,
        ),
        "regularization": RewardTermCfg(
            func=compute_regularization, weight=-0.1,
        ),
        "give_up": RewardTermCfg(
            func=compute_give_up_reward, weight=0.2,
        ),
    }

    # === Termination 配置 ===
    terminations = TennisTerminationsCfg()

    # === Commands 配置 ===
    commands = {
        "launch": LaunchCommandCfg(
            speed_range=(20.0, 45.0),
            elevation_range=(-8.0, 2.0),
            # ... 其他参数由 Curriculum 动态设置
        ),
    }

    # === Events 配置(含 DR)===
    events = TennisDREventCfg()

    # === Simulation 配置 ===
    # 与本章 strike task 推荐(LATENT 2000 Hz)保持一致;如算力受限可降到 1000 Hz/decimation=20
    sim = SimCfg(
        timestep=0.0005,   # 2000 Hz(球拍-球接触需要高频仿真,LATENT 配置)
        render_interval=4,
        gravity=(0.0, 0.0, -9.81),
    )
    decimation = 40  # 控制频率 = 2000/40 = 50 Hz
    episode_length_s = 5.0

训练循环的完整代码

def train_tennis_strike(cfg, max_iterations=10000):
    """网球击球任务的完整训练流程。"""

    # 1. 创建环境
    env = ManagerBasedRlEnv(cfg)

    # 2. 创建感知管线(Ch27)
    perception = TennisPerceptionPipeline(
        num_envs=cfg.scene.num_envs,
        device=env.device,
        config=PerceptionConfig(),
    )

    # 3. 创建 Curriculum
    curriculum = TennisCurriculumManager(
        env_cfg=cfg,
        num_envs=cfg.scene.num_envs,
        device=env.device,
    )

    # 4. 创建 Lagrangian PPO
    lagrangian = LagrangianPPOForTennis(tennis_safety_constraints)

    # 5. 创建 PPO agent(RSL-RL)
    agent = PPOAgent(
        actor_critic_cfg=ActorCriticCfg(
            actor_hidden_dims=[256, 256, 128],
            critic_hidden_dims=[256, 256, 128],
        ),
        algorithm_cfg=PPOCfg(
            learning_rate=3e-4,
            num_learning_epochs=5,
            num_mini_batches=4,
            clip_param=0.2,
            entropy_coef=0.005,
            desired_kl=0.01,
            gamma=0.99,
            lam=0.95,
        ),
    )

    # 6. 训练循环
    for iteration in range(max_iterations):
        # Curriculum 更新发球参数
        launch_params = curriculum.get_current_params()
        env.update_command_ranges(launch_params)

        # 收集 rollout
        rollout_data = collect_rollout(env, agent, perception, lagrangian)

        # 更新策略
        train_info = agent.update(rollout_data)

        # 更新 Lagrange 乘子
        avg_costs = compute_average_costs(rollout_data)
        lagrangian.update_lambdas(avg_costs)

        # 更新 Curriculum
        metrics = compute_metrics(rollout_data)
        curriculum.update(metrics)

        # 记录到 WandB
        if iteration % 10 == 0:
            log_to_wandb(iteration, train_info, metrics, curriculum, lagrangian)

        # 保存 checkpoint
        if iteration % 500 == 0:
            agent.save(f"checkpoints/tennis_strike_iter{iteration}.pt")

    return agent


def collect_rollout(env, agent, perception, lagrangian, num_steps=24):
    """收集一个 rollout。"""
    obs_buffer = []
    action_buffer = []
    reward_buffer = []
    done_buffer = []

    for step in range(num_steps):
        # 获取观测
        obs = env.get_observations()

        # 更新感知管线
        raw_ball_obs = obs["policy"]["ball_pos_estimate"]
        pred = perception.step(
            raw_ball_obs, env.sim_time,
            env.commands["launch"].launch_params,
        )

        # 策略推理
        with torch.no_grad():
            action = agent.act(obs["policy"])

        # 环境步进
        next_obs, reward, done, info = env.step(action)

        # Lagrangian 增强 reward
        augmented_reward = lagrangian.compute_augmented_reward(reward, env)

        # 存储
        obs_buffer.append(obs)
        action_buffer.append(action)
        reward_buffer.append(augmented_reward)
        done_buffer.append(done)

        # 处理 reset
        if done.any():
            reset_ids = done.nonzero(as_tuple=True)[0]
            perception.reset(reset_ids,
                            env.scene["ball"].data.root_pos_w[reset_ids, :3]
                            - env.scene.env_origins[reset_ids])

    return RolloutData(obs_buffer, action_buffer, reward_buffer, done_buffer)

实验管理与对比

训练网球击球策略通常需要多次实验迭代。推荐使用 WandB group 来管理实验:

# 基线实验
uv run train Mjlab-Tennis-Strike \
  --num-envs 4096 --max-iterations 5000 \
  --wandb-name baseline_v1 --wandb-group tennis_strike

# 消融:去掉 timing reward
uv run train Mjlab-Tennis-Strike \
  --rewards.timing.weight 0.0 \
  --wandb-name ablation_no_timing --wandb-group tennis_strike

# 消融:去掉 DR
uv run train Mjlab-Tennis-Strike \
  --events.ball_mass_dr.enabled False \
  --events.court_friction_dr.enabled False \
  --wandb-name ablation_no_dr --wandb-group tennis_strike

# 消融:全关节 action(不用 IK)
uv run train Mjlab-Tennis-Strike \
  --actions.use_joint_space True \
  --wandb-name ablation_joint_action --wandb-group tennis_strike

部署前的完整检查清单

综合 Ch25-28 的内容,从训练完成到真机运行的检查清单:

步骤 检查内容 工具 通过标准
1 仿真 contact rate WandB > 80%
2 仿真 over_net rate WandB > 60%
3 动作平滑性 Ch25 FFT 分析 90% 能量 < 电机带宽
4 关节安全性 Ch25 check_joint_safety() 峰值 < 90% 限位
5 Lipschitz 常数 Ch25 check_lipschitz_sensitivity() < 10
6 ONNX 导出一致性 PyTorch vs ONNX max diff < 1e-5
7 Normalization 冻结 ONNX graph 检查 running_mean/var 包含
8 Joint ordering deploy.yaml vs 真机 完全匹配
9 Sim-to-sim(跨引擎) mjlab vs Isaac Lab 行为一致
10 Butterworth 滤波 频谱分析 截止频率合理
11 Action rescaler β=0.3 真机低速 无异常振动
12 Action rescaler β=0.8 真机中速 行为匹配仿真
13 全速测试 β=1.0 真机全速 contact rate 下降 < 30%

本质洞察:球类运动 RL 的核心难点不在于算法(PPO 足够), 而在于工程设计。本章的 staged reward、curriculum、DR、安全约束 都是工程设计——它们不改变 PPO 的数学,但决定了 PPO 能否学到有用的策略。 算法是引擎,工程设计是方向盘和油门——没有方向盘的引擎只会原地打转。

⚠️ 常见陷阱

⚠️ 思维陷阱:认为算法创新比工程设计更重要 - LATENT 用的就是 PPO,但通过 latent action + adaptive DR 达到了 91% 真机成功率 - 正确认知:在机器人 RL 中,reward/curriculum/DR/安全的边际收益通常大于算法创新

⚠️ 编程陷阱:训练时用 ground truth reward,评估时用 learned predictor reward - 训练和评估的 reward 语义不同 → 训练曲线和评估性能脱节 - 正确做法:reward 始终用 physics predictor(精确),policy obs 用 learned predictor

⚠️ 思维陷阱:Curriculum 全部通过 = 可以上真机 - Curriculum 通过只说明策略在仿真中可以击球,不保证 Sim2Real - 正确做法:必须经过部署检查清单的全部 13 步

练习

  1. [综合题] 回顾 Ch26-28 的完整链路,画出数据流图:从 LaunchCommand 到最终的 joint_targets,标注每个模块的输入、输出、频率和关键参数。
  2. [设计题] 如果要把本项目从网球迁移到乒乓球,哪些模块需要修改?哪些可以直接复用?球场尺寸(2.74m vs 23.77m)、球速范围(5-15 m/s vs 20-45 m/s)、机器人形态各有什么变化?
  3. [思考题] LATENT 使用 5 小时业余选手数据 + RL,达到了 91% 真机正手成功率。如果没有 motion data(像 Phybot 那样纯 RL),你预计需要多少额外的训练时间才能达到相同性能?motion prior 的核心价值是什么?

本章常见误解汇总

误解 正确理解
"IK 到点就是击球" 需要同时满足位置、速度、姿态、时机四个约束
"纯稀疏 reward 也能学" 3-5 ms 接触窗口 + 30 m/s 球速 → PPO 几乎无法探索到成功
"先练手臂再加底盘" 底盘是把不可达球拉回可达域的关键控制变量
"Curriculum 越多阶段越好" 8 阶段已覆盖完整过渡,更多阶段 = 更多超参数
"DR 应该训练一开始就加" 先在固定环境学会击球,再加 DR 增强鲁棒性
"reward penalty 可以替代安全约束" Lagrangian PPO 提供更可靠的约束满足
"仿真 95% = 真机 95%" Sim2Real gap 通常导致 20-50% 性能下降
"算法创新比工程设计重要" PPO 足够,reward/curriculum/DR/安全是真正的杠杆

本章建立的心智模型

击球控制 = 定时接触控制
    ↓
MDP 设计
    ├── State: ball_estimate + robot_proprio + predictor_output
    ├── Action: base_vel(3D) + racket_delta_pose(6D) = 9D
    └── Reward: approach × (1+timing) × (1+contact) × (1+outcome) - reg
    ↓
Curriculum
    Stage 0(静态) → 1(低速) → ... → 6(全随机) → 7(+延迟)
    ↓
DR + 安全约束
    ├── 五类 DR 逐项引入
    ├── Lagrangian PPO 安全约束
    └── Termination 安全优先
    ↓
Sim2Real
    ├── Sim2Sim 验证
    ├── 低速真机测试
    ├── Butterworth + Action Rescaler
    └── 逐步提速

本章小结

知识点总表

编号 知识点 核心要点 对应节 难度
1 击球本质 带速度+姿态约束的定时接触 28.1 ⭐⭐
2 MDP 分层状态 env真值 / teacher / student 三层 28.2 ⭐⭐⭐
3 Task-space IK action base_vel(3D) + racket_delta_pose(6D) 28.2 ⭐⭐⭐
4 五类 staged reward approach × timing × contact × outcome - reg 28.3 ⭐⭐⭐
5 乘法 reward 结构 层级优先,approach=0 时总 reward=0 28.3 ⭐⭐⭐
6 Event Buffer 瞬时接触事件的记录和追踪 28.3 ⭐⭐
7 八阶段 curriculum 静态→低速→全速→延迟,成功率门控 28.4 ⭐⭐⭐
8 KungfuBot 自适应 tolerance bi-level 动态调整精度容忍度 28.4 ⭐⭐⭐
9 Perception noise 注入 固定噪声 / DR噪声 / perception-aware 28.5 ⭐⭐
10 五类 Sim2Real gap 感知/动力学/接触/执行/系统 28.6 ⭐⭐⭐
11 DR 逐项引入 每加一项记录成功率变化 28.6 ⭐⭐⭐
12 Lagrangian PPO 安全约束的对偶优化 28.7 ⭐⭐⭐
13 Termination 安全优先 安全终止 > 任务终止 > 超时 28.7 ⭐⭐
14 频率分离 policy 50Hz + IK 500Hz + sim 2000Hz 28.8 ⭐⭐
15 LATENT Sim2Real 路线 DR+noise → sim2sim → 低速真机 → 全速 28.9 ⭐⭐⭐
16 部署安全包装 Butterworth + action rescaler β 28.9 ⭐⭐
17 工程 > 算法 PPO 足够,reward/curriculum/DR 是杠杆 28.10 ⭐⭐⭐

累积项目:本章新增模块

项目进度更新:

阶段 能力 新增于
环境构建 EntityCfg → SceneCfg → Registry Ch04, Ch15
Obs/Action 设计 五条原则 + 双框架配置 Ch05
Reward/Curriculum 四类奖励 + 渐进 curriculum Ch06
训练管线 PPO + 多后端 + 训练诊断 Ch07, Ch25
Domain Randomization EventManager + 分阶段 DR Ch08
Teacher-Student 特权学习 + 蒸馏 Ch09
球类环境底座 球场坐标 + 球物理 + 弹道模型 Ch26
感知管线 EKF + GRU + 延迟补偿 + PredictorOutput Ch27
击球控制 MDP + Staged Reward + Curriculum + DR + Safety + Deploy Ch28

本章代码产出清单

模块 类/函数名 功能
Observation TennisStrikeObsCfg 分层观测(Actor/Critic) 28.2
Action TennisStrikeActionsCfg base_vel + DifferentialIK 28.2
Approach Reward compute_approach_reward() 指数距离衰减 28.3
Timing Reward compute_timing_reward() 时间-距离协调 28.3
Contact Reward compute_contact_reward() 合法接触检测 28.3
Outcome Reward compute_outcome_reward() 过网+落点 28.3
Regularization compute_regularization() 安全平滑惩罚 28.3
Event Buffer StrikeEventBuffer 瞬时接触事件记录 28.3
Curriculum TennisCurriculumManager 8阶段+门控+回退 28.4
Noise Manager PerceptionNoiseManager 噪声+延迟注入 28.5
DR Config TennisDREventCfg 五类 DR 配置 28.6
DR-Curriculum CurriculumAwareDR DR 随 Curriculum 联动 28.6
Safety LagrangianPPOForTennis 安全约束训练 28.7
CBF Filter cbf_joint_limit_filter() 推理层安全过滤 28.7
Termination TennisStrikeTerminationsCfg 完整终止配置 28.7
Give-up compute_give_up_reward() 不可达球放弃 28.7
Freq Separation TennisFrequencySeparation 高低层频率分离 28.8
Deploy Safety WoCoCoDeploySafety Butterworth + Rescaler 28.9
Deployment RealWorldDeployment 真机部署脚本 28.9
Env Config TennisStrikeEnvCfg 完整环境配置 28.10
Train Loop train_tennis_strike() 完整训练流程 28.10

本章实验清单

实验 目的 预期结果
Approach-only 训练 验证 approach reward 有效 球拍持续接近预测击球点
Staged reward vs sparse 对比乘法 staged 和纯 outcome staged 显著更好
Curriculum 推进记录 记录每个 Stage 的达标时间 Stage 0-3 快,Stage 4-7 慢
DR 消融 逐项加入 DR 记录成绩变化 每项下降 5-15%
Lambda 轨迹 监控安全约束乘子 稳定或轻微振荡
CBF 过滤率 统计 CBF 修正动作的比例 正常训练 < 5%
频率消融 500 Hz vs 1000 Hz vs 2000 Hz sim 2000 Hz 接触最稳定
Noise 消融 有噪声 vs 无噪声训练 有噪声 Sim2Real 更好

延伸阅读

资料 地址 难度 与本章的关系
LATENT 代码库 github.com/GalaxyGeneralRobotics/LATENT ⭐⭐⭐ 最完整人形网球系统
KungfuBot 代码库 github.com/TeleHuman/PBHC ⭐⭐⭐ 自适应 curriculum + asymmetric AC
WoCoCo 代码库 github.com/LeCAR-Lab/wococo ⭐⭐⭐ sequential contacts + Butterworth
HITTER 论文 arXiv:2508.21043 ⭐⭐⭐ hierarchical planner + RL controller
PACE 论文 arXiv:2509.21690 ⭐⭐⭐ dual predictor + dense reward
Phybot 论文 arXiv:2511.11218 ⭐⭐⭐ 三阶段 curriculum,无 motion prior
ETH 羽毛球 Science Robotics, 2025 ⭐⭐⭐ perception-aware + 机载视觉
Tobin et al. 2017 arXiv:1703.06907 ⭐⭐ Domain Randomization 开山之作
Achiam et al. 2017 CPO Proceedings of ICML ⭐⭐⭐ Constrained Policy Optimization
Lee et al. 2020 Science Robotics ⭐⭐⭐ Teacher-Student + curriculum 经典

🔧 故障排查手册

症状 可能原因 排查步骤 相关节
球拍追不上球 curriculum 太难 / base 没移动 1. 降速度 2. 检查 base action 3. 检查 approach σ 28.3-28.4
碰到球但不过网 racket 速度不足 / 面法向偏 1. 记录接触时 racket vel 2. 加 orientation reward 3. 检查 IK damping 28.3
contact rate 升但机器人摔倒 safety penalty 弱 1. 增大 joint limit penalty 2. 加严 safety termination 3. 用 Lagrangian PPO 28.7
reward 升但 contact 不变 刷 approach shaping 1. 分项记录 reward 2. 增加 contact 权重 3. 减小 approach σ 28.3
固定速度成功随机失败 策略记住固定时刻挥拍 1. 检查 actor obs 含 time_to_impact 2. held-out eval 3. 加宽 curriculum 28.4
Lagrange λ 持续上升 约束太紧或任务太难 1. 放宽 cost limit 2. 检查 curriculum 难度 3. 降低 λ_lr 28.7
DR 后成绩断崖下降 DR 范围太宽 1. 缩小范围 2. 逐项排查 3. 检查 reward 对该参数的敏感性 28.6
真机比仿真差 50%+ DR 不够 / 接触模型 gap 1. 分析失败 case 2. 增大 DR 范围 3. 检查 Butterworth 滤波 28.9

给下一章的桥

本章完成了网球综合项目的最后一个模块——击球控制策略。至此,Part VII(Ch26-28)建立了一个完整的端到端系统:从球场物理建模、到感知与轨迹预测、再到全身协调击球控制。

Part VII 的三章产出总结

章节 核心产出 代码行数 关键类/函数
Ch26 物理环境底座 ~2465 行 tennis_court.xml, BallAerodynamics, LaunchCommand
Ch27 感知预测管线 ~2508 行 BallEKFWithDrag, PhysicsResidualPredictor, TennisPerceptionPipeline
Ch28 击球控制策略 ~2500 行 TennisStrikeEnvCfg, TennisCurriculumManager, LagrangianPPOForTennis

这个综合项目不是"一个网球游戏"——它是机器人感知控制的工程方法论教学载体。本章和前两章中介绍的每个设计模式(环境即数据协议、分层感知、物理+残差预测、staged reward、curriculum、DR、安全约束、部署安全包装)都可以迁移到其他高速交互任务——乒乓球、羽毛球、足球、甚至工业装配中的高速对位。

整本教材的 28 章到此完成。从 Ch01 的仿真生态全景,到 Ch14 的人形 locomotion,到 Ch20 的全身操作,再到本章的网球击球——贯穿始终的核心理念是:算法回顾(20%)→ 工程实现(80%)。机器人 RL 的挑战不在于发明新算法,而在于把已有算法正确地、鲁棒地、安全地应用到复杂的真实世界中。

本质洞察:28 章的旅程证明了一个工程事实—— PPO 已经足够好,瓶颈在于环境设计、reward 工程、感知管线、 训练诊断、部署安全这些"算法之外"的工程能力。 掌握这些能力,你就拥有了让机器人在真实世界中做有用事情的基础。