Skip to content

第 21 章 轮式底盘 + 双臂:Mobile Manipulation

本章定位:Ch19 用四足加臂展示了足式移动操作中稳定性与末端精度的耦合挑战,Ch20 用人形全身控制深入了上下肢协调问题。本章切换到工业和服务机器人中最常见的形态——轮式底盘搭载机械臂——来讲移动操作的另一面。轮式底盘消除了足式系统中的姿态平衡难题,但引入了非完整约束、底盘漂移和 base-arm 动力学耦合等新问题。本章的工程目标是在 mjlab 中从零搭建一个轮式底盘 + YAM 机械臂的移动抓取环境,同时展示 Isaac Lab 中的对应实现路径。

前置依赖:Ch17(YAM lift cube 固定基座操作——staged reward / JointPositionAction / DiffIK)、Ch19(四足加臂 loco-manipulation——动作空间拆分 / 量纲归一化 / arm-constrained curriculum)、Ch05(Observation 五条设计原则)、Ch06(Reward 与 Curriculum 设计)、Ch08(Domain Randomization)

关键参考:🔧 mjlab 自建(YAM + 轮式底盘)· 🔧 Isaac Lab manipulation + mobile · ✅ awesome-loco-manipulation(github.com/aCodeDog/awesome-loco-manipulation,Ridgeback-UR5 URDF)

累积项目G(轮式 + 双臂移动搬运)


前置自测

📋 答不出 \(\ge\) 3 题 → 先回 Ch17/Ch19 复习

  1. [Ch17] mjlab 的 YAM lift cube 任务使用什么 action 类型?默认 action 维度是多少(臂 + 夹爪)?
  2. [Ch19] 四足加臂任务中,为什么要把底盘和臂的动作分成不同 action group?如果混在一个无量纲向量里会怎样?
  3. [Ch06] staged reward 的乘法门控 \(r_{\text{reach}} \times (1 + r_{\text{bring}})\) 和简单加权 \(w_1 r_{\text{reach}} + w_2 r_{\text{bring}}\) 相比有什么优势?
  4. [物理] 差速驱动的两轮机器人能否横向平移?这种约束在控制理论中叫什么?
  5. [Ch08] EventManager 的四种模式(startup / reset / interval / step)分别适合随机化什么?

本章目标

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

  1. 解释轮式移动操作与足式移动操作的本质差异,说明为什么"底盘稳定"不等于"任务更简单"
  2. 设计差速驱动和全向轮两种底盘的 MJCF/USD 模型,理解非完整约束在仿真中的工程表现
  3. 配置轮速 + 臂关节的混合动作空间,实现跨量纲归一化
  4. 实现从固定底盘抓取到移动搬运的五阶段 curriculum
  5. 诊断并解决抓取时底盘漂移问题
  6. 在 mjlab 和 Isaac Lab 中完成轮式 + YAM 环境的注册、验证和短训练

21.1 轮式移动操作的工业场景与形态定位 ⭐

这一节解决什么问题:建立轮式移动操作在整个移动操作谱系中的定位,理解为什么工业界大量使用轮式而非足式移动操作。

动机:为什么要单独讲轮式

回顾 Ch19 的四足加臂系统:Go2 底盘搭载 Z1 或 Arx 机械臂,在粗糙地形上移动并抓取物体。那个场景的核心难点是底盘本身不稳定——四条腿在行走中持续产生周期性的质心扰动,手臂的运动进一步改变角动量,感知和控制必须同时应对步态稳定和末端精度两个目标。

轮式底盘把这个难题中的"姿态平衡"部分彻底移除了。四个轮子接触地面,底盘在平地上几乎不会翻倒(除非载荷极端偏移)。这意味着策略可以把全部注意力集中在导航和操作上,不需要分配计算资源来维持平衡。从工程角度看,这是一个巨大的简化——但简化不等于简单。轮式系统引入了自己独特的挑战:非完整约束限制了底盘的运动学自由度,轮地接触的摩擦特性决定了底盘在抓取时是否会漂移,底盘惯性与臂反作用力的耦合影响了搬运过程中的轨迹精度。

在工业场景中,轮式移动操作的应用范围远大于足式。仓储机器人在货架之间穿行并取放包裹,医疗辅助机器人推送药品和器材,服务机器人在办公室和家庭中搬运物品——这些场景的共同特征是地面平坦且可预测,底盘不需要适应复杂地形,但导航精度和操作可靠性的要求极高。

下表列出了当前主流的轮式移动操作研究/工业平台,帮助读者建立"轮式底盘 + 臂"的具体工程直觉:

平台 底盘类型 开源 URDF 典型任务
Clearpath Ridgeback + UR5 Mecanum 全向 UR5 (6-DOF) ✅ awesome-loco-manipulation 桌面操作、物流分拣
Clearpath Husky + Dual UR5 差速四轮 双 UR5 ✅ husky_dual_ur5_mujoco 双臂协作搬运
PAL TIAGo 差速两轮 7-DOF 臂 + 平行夹爪 ✅ ROS/Gazebo 家庭服务、开门取物
Hello Robot Stretch 差速两轮 伸缩臂 (Telescoping) ✅ stretch_body 家庭辅助、开柜取物
Fetch Robotics MM 差速两轮 7-DOF 臂 ✅ fetch_ros 仓储分拣(已商业化)
Amazon Proteus 全向 无/搭载货架 ❌ 闭源 仓储搬运(>1000 台部署)

这些平台的共同模式是:底盘质量远大于臂和载荷(典型底盘 15-80 kg,臂 5-15 kg,载荷 1-10 kg),底盘惯性提供了天然的"基座效应"——臂的快速运动对底盘的扰动相对较小。但这并不意味着可以忽略耦合——在精密操作中,即使毫米级的底盘漂移也会导致抓取失败。

从 RL 训练效率的角度看,轮式移动操作相比足式有明显的收敛优势。根据经验(不同任务差异较大),类似的移动抓取任务,轮式底盘通常在 2000-5000 iterations 内达到 50% 成功率,而足式系统需要 5000-15000 iterations——因为足式需要同时学习平衡和操作两个子技能,探索空间更大。仿真吞吐量方面,轮式底盘的每步接触点数(2-4 个轮子)远少于四足(4-8 个足端),在 4096 并行环境下,轮式系统的 steps/s 通常是四足的 1.2-1.5 倍。

Wheeled Lab(Tyler Han et al., CoRL 2025, arXiv:2502.07380)是当前 Isaac Lab 生态中轮式 RL 的标杆参考。该工作在小型 RC 车上实现了零样本 sim2real 的漂移控制、爬坡和视觉导航——其中漂移任务是文献中首个不需要在线微调的 sim2real 漂移策略。Wheeled Lab 提供的 DR 参数(轮地摩擦 \(\mu \in U(0.2, 0.8)\)、执行器增益随机化、IMU 噪声注入)为本章的轮式 sim2real 设计提供了直接的工程参考。

跨领域类比:轮式移动操作之于足式移动操作,就像卡车运输之于越野驾驶。卡车在公路上比越野车更高效、更稳定、载重更大,但它不能翻山越岭。同样,轮式底盘在平地上比四足更高效、更稳定、能耗更低,但不能上楼梯。选择轮式还是足式不是技术先进性的问题,而是任务环境匹配的问题。不过,这个类比有一个重要边界:越野车在路况好的时候可以当公路车开(只是效率低),而足式机器人在平地上并不比轮式更灵活——足式在平地上的冗余自由度反而成为训练负担。

三种移动操作形态的系统对比

维度 轮式底盘 + 臂 足式底盘 + 臂 人形全身
底盘稳定性 天然稳定(四轮/三轮接触) 需要主动平衡 需要全身协调
地形适应性 仅平地或小坡 粗糙地形、台阶 最灵活
运动学约束 非完整约束(差速不能横移) 冗余自由度(步态选择多) 高度冗余
base-arm 耦合 弱(底盘质量大,臂反作用力影响小) 中(臂运动改变质心,影响步态) 强(上肢动作直接改变躯干角动量)
典型任务 仓储取放、桌面操作、开门 户外取物、灾害救援 家庭服务、人类协作
训练难度 中(curriculum 主要在操作侧) 高(稳定性和操作双目标) 最高
工业部署量 最大(Amazon/物流/医疗) 少量研究机 极少量
本书章节 Ch21(本章) Ch19 Ch20

如果把轮式当成"简化版四足"来做会怎样

一个常见的错误是直接复制 Ch19 的四足加臂架构,只把"腿"换成"轮子"。这种做法会产生以下问题:

问题一:动作空间不匹配。 四足系统的底盘动作是 12 个腿关节角,轮式系统的底盘动作是 2 个轮速(差速)或 3 个轮速分量(全向)。两者的物理语义完全不同——关节角控制是位置级的,轮速控制是速度级的。如果用 JointPositionActionCfg 控制轮子旋转角度,轮子会来回摆动而不是连续转动。

问题二:稳定性奖励变成噪声。 四足系统需要大量稳定性相关的奖励项(base orientation、foot contact timing、body height),这些对轮式底盘要么不适用(没有脚接触时序),要么恒定为零(底盘倾角在平地上几乎不变)。保留这些项不会让训练崩溃,但会增加无意义的计算开销和 reward 噪声。

问题三:非完整约束被忽略。 差速驱动的底盘不能横向平移——它必须先旋转朝向目标方向,再前进。如果 observation 中不包含底盘朝向与目标方向的关系,策略无法学会高效的导航行为。四足系统没有这个约束——它可以侧步。

本质洞察:轮式移动操作不是四足移动操作的简化版。它是一个独立的问题类,有自己的运动学约束、动力学耦合和奖励设计模式。从四足过渡到轮式,应该从 Ch17 的固定基座操作出发向上扩展(加底盘),而不是从 Ch19 的四足加臂向下简化(去掉腿)。

⚠️ 常见陷阱

⚠️ 思维陷阱:认为"底盘稳定 = 任务更简单" - 错误想法:轮式底盘不会摔倒,所以训练一定比四足容易。 - 实际上:轮式的难点从姿态平衡转移到了导航精度和 base-arm 协调。在仓储场景中,底盘必须精确停在货架前(厘米级),臂必须从拥挤的货架中取出正确的物品。这种定位精度在四足任务中通常不是主要考量。 - 正确做法:评估任务难度时同时考虑底盘控制和操作控制的精度要求。

💡 概念误区:认为"轮式底盘不需要物理建模" - 错误想法:轮子就是圆的,直接转就行。 - 实际上:轮地接触的摩擦模型直接影响底盘是否会在抓取时漂移。MuJoCo 中轮子的 condimfrictionsolref 参数决定了侧向摩擦力的大小——如果侧向摩擦太小,臂在抓取物体时产生的反作用力会把整个底盘推走。 - 正确做法:把轮地接触参数作为关键调试项,和臂的接触参数同等重视。

非完整约束对 RL 策略的深层影响 ⭐⭐

非完整约束不仅影响底盘运动学,还深刻改变了 RL 策略的学习难度。对于全向底盘,策略可以在任意时刻向任意方向移动——动作空间到工作空间的映射是满射的。对于差速底盘,策略需要学会"时序性的运动规划"——先旋转对齐目标方向,再前进。

这种时序规划要求策略具有一定的"记忆"或"预判"能力。单步 MLP 策略在差速底盘上表现明显弱于全向底盘,因为它无法规划"先转后走"的两步序列——它每一步只能看当前状态做出动作决策。如果 observation 中包含了底盘朝向与目标方向的角度差(\(\cos\alpha\), \(\sin\alpha\)),策略可以学到"当 \(|\alpha|\) 大时先旋转、当 \(|\alpha|\) 小时前进"的条件策略。如果不包含朝向信息,策略只能通过 base-to-object 向量的变化间接推断自己朝向错了——这种间接推断需要更多训练样本。

从更抽象的角度看,非完整约束导致了状态空间的拓扑结构变化。全向底盘的可达空间是欧几里得平面 \(\mathbb{R}^2\)——任意两点之间的最短路径是直线。差速底盘的配置空间是 \(SE(2)\)(位置 + 朝向),两个配置之间的最短路径通常不是直线而是一条曲线(Dubins path 或 Reeds-Shepp path)。RL 策略在 \(SE(2)\) 上学习路径规划比在 \(\mathbb{R}^2\) 上更难,因为 value 函数需要编码更复杂的距离度量。

反事实推理:如果用全向底盘训练的策略被移植到差速底盘上会怎样? 策略在需要横移时会输出侧向速度指令,但差速底盘无法执行。结果有两种:(a) 如果侧向动作被硬件忽略,策略只能使用前向和旋转分量,有效动作空间缩小,性能下降但可能仍然能完成任务(以更低效率);(b) 如果侧向指令被映射为旋转(一种常见的工程近似),策略的行为会变得混乱——它期望横移但实际上在旋转,spatial reasoning 完全失效。

练习

  1. [对比题] 列表对比轮式和四足系统在以下五个维度上的差异:动作空间类型、稳定性约束、非完整约束、base-arm 耦合强度、典型训练 curriculum。
  2. [设计题] 一个医院走廊场景需要机器人从 A 房间取药并送到 B 房间。走廊平坦但有弯道,房间门需要推开。你会选择轮式还是足式?为什么?列出至少三个决策因素。
  3. [思考题] 为什么 RNN/LSTM 策略网络在差速底盘上比 MLP 更有优势?从非完整约束导致的时序规划需求角度分析。

上节建立了轮式移动操作的问题定位和形态对比。但"轮式"并不是一种底盘,而是一个家族——差速驱动和全向轮的运动学特性截然不同,建模方式也完全不同。这正是下节的主题。


21.2 轮式底盘建模:差速驱动与全向轮 ⭐⭐

这一节解决什么问题:如何在 MJCF 和 USD 中建模两种常见的轮式底盘,理解非完整约束对 RL 策略的影响。

动机:两种轮式构型的运动学差异

轮式底盘的运动学取决于轮子的数量和布置方式。最常见的两种构型是差速驱动(differential drive)和全向轮(omnidirectional/mecanum),它们在可达运动方面有本质区别。

差速驱动是最简单的构型:两个驱动轮分别独立控制,加上一到两个被动万向轮(castor)维持稳定。两个驱动轮速度相同时直线前进,速度不同时转弯,速度相反时原地旋转。关键约束是差速底盘不能横向平移——如果目标在底盘的正右方,它必须先原地旋转 90 度,再直线前进。这个约束在控制理论中称为非完整约束(nonholonomic constraint),数学表达为 \(\dot{y} \cos\theta - \dot{x} \sin\theta = 0\),其中 \(\theta\) 是底盘朝向角。

全向轮(如 Mecanum 轮或 Swedish 轮)通过特殊的滚轮排列消除了横向约束:三个或四个全向轮的组合可以让底盘在任意方向上平移和旋转。从运动学角度看,全向底盘有三个独立的控制量:前进速度 \(v_x\)、横移速度 \(v_y\)、旋转速度 \(\omega_z\)

构型 驱动轮数 控制维度 非完整约束 典型平台
差速驱动 2 2(\(v\), \(\omega\) 有(不能横移) TurtleBot, Fetch, Toyota HSR
全向 3 轮 3 3(\(v_x\), \(v_y\), \(\omega\) Kuka youBot base
Mecanum 4 轮 4 3(\(v_x\), \(v_y\), \(\omega\) Clearpath Ridgeback

如果不正确建模非完整约束会怎样

假设你在仿真中给差速底盘的所有方向都赋予了自由度——比如在 MJCF 中使用 freejoint 让整个底盘自由移动。策略会学到"直接横向平移到目标旁边"的行为,因为这在仿真中是被允许的。但部署到真机时,差速底盘物理上不能横移,策略输出的横向速度会被硬件忽略或产生异常行为。

反事实推理:如果在全向底盘上训练的策略被部署到差速底盘上,策略每次尝试横移时都会失败。更糟糕的是,策略可能依赖横移来完成抓取前的精确对齐——这个能力在差速底盘上不存在,导致抓取成功率归零。这种仿真-真实差距不是通过 Domain Randomization 能弥补的,因为它不是参数不确定性,而是运动学结构的差异。

MJCF 中的差速驱动建模 ⭐⭐

在 MuJoCo 中建模差速底盘,核心决策是选择关节类型和 actuator 类型。

方案 A:显式轮子关节。底盘 body 使用 freejoint(6 DOF 自由移动),两个驱动轮各使用一个 hinge joint 连接到底盘,被动万向轮使用 ball joint 或简化为摩擦接触。actuator 用 velocity 类型控制轮子转速,地面摩擦自然约束底盘运动。

<!-- 差速驱动底盘 MJCF 骨架 -->
<body name="base" pos="0 0 0.15">
  <freejoint name="base_joint"/>
  <geom name="chassis" type="box" size="0.3 0.25 0.08" mass="15.0"
        condim="1" contype="0" conaffinity="0"/>

  <!-- 左驱动轮 -->
  <body name="left_wheel" pos="0 0.28 -0.07">
    <joint name="left_wheel_joint" type="hinge" axis="0 1 0"
           damping="0.5"/>
    <geom name="left_wheel_geom" type="cylinder"
          size="0.08 0.025" mass="0.5"
          condim="4" friction="1.0 0.005 0.001"
          contype="1" conaffinity="1"/>
  </body>

  <!-- 右驱动轮(对称) -->
  <body name="right_wheel" pos="0 -0.28 -0.07">
    <joint name="right_wheel_joint" type="hinge" axis="0 1 0"
           damping="0.5"/>
    <geom name="right_wheel_geom" type="cylinder"
          size="0.08 0.025" mass="0.5"
          condim="4" friction="1.0 0.005 0.001"
          contype="1" conaffinity="1"/>
  </body>

  <!-- 前被动万向轮(简化为球体) -->
  <body name="castor" pos="0.25 0 -0.10">
    <joint name="castor_yaw" type="hinge" axis="0 0 1" damping="0.01"/>
    <joint name="castor_roll" type="hinge" axis="1 0 0" damping="0.01"/>
    <geom name="castor_geom" type="sphere" size="0.04" mass="0.1"
          condim="3" friction="0.3 0.001 0.0001"
          contype="1" conaffinity="1"/>
  </body>
</body>
<!-- Actuator 定义 -->
<actuator>
  <velocity name="left_wheel_vel" joint="left_wheel_joint"
            kv="10.0" ctrlrange="-10 10"/>
  <velocity name="right_wheel_vel" joint="right_wheel_joint"
            kv="10.0" ctrlrange="-10 10"/>
</actuator>

关键参数解释

condim="4" 表示接触启用法向 + 切向(滑动)+ 扭转摩擦(注意:condim=4 并不启用滚动摩擦——滚动摩擦需要 condim=6),这对差速驱动已经足够——如果只用 condim="1"(只有法向力),轮子在地面上没有切向摩擦,底盘无法移动。friction="1.0 0.005 0.001" 的三个值分别是滑动、扭转、滚动摩擦系数,但具体哪些通道真正参与约束由 condim 决定:在 condim=4 下第三个(滚动)系数不会生效。滑动摩擦决定了底盘的抓地力,扭转摩擦影响原地旋转的能耗;若确实需要建模滚动阻力,应改用 condim=6

velocity 类型的 actuator 直接控制轮子的角速度(rad/s),而不是位置或力矩。这符合轮式底盘的控制惯例:在真实的差速底盘上,电机控制器通常接受速度指令,内部有 PID 闭环跟踪。在 RL 策略中,action 输出两个轮速,经过 action_scale 缩放后直接作为 velocity actuator 的目标值。

方案 B:抽象底盘模型。如果不关心轮子接触的物理细节,可以跳过显式轮子,直接用外力驱动底盘:

# 在 event 中施加控制力(简化,不推荐作为最终方案)
base_force = action[:, :2]  # [Fx, torque_z]
sim.apply_external_wrench(
    body_name="base",
    force=torch.cat([base_force[:, 0:1], zeros, zeros], dim=-1),
    torque=torch.cat([zeros, zeros, base_force[:, 1:2]], dim=-1)
)

方案 B 训练更快(没有轮子接触计算),但失去了底盘漂移的物理反馈——策略不会学到"在抓取时锁住轮子"的行为,因为底盘被力直接驱动而非通过轮地摩擦间接驱动。

双重解读:底盘建模的精细程度可以从两个角度理解。 角度一:仿真精度。显式轮子接触产生更真实的底盘动力学,有助于 Sim2Real。 角度二:训练效率。抽象底盘减少接触计算量,加速训练。 正确的工程选择取决于任务:如果底盘漂移是核心挑战(如精密定位),用显式轮子;如果底盘只是"搬运工具"且精度要求低,抽象模型即可。

Isaac Lab 中的轮式底盘建模 ⭐⭐

Isaac Lab 使用 USD 格式定义机器人资产。轮式底盘的建模思路与 MJCF 类似,但 API 和参数名不同。

# Isaac Lab 中的差速底盘 ArticulationCfg 骨架
from isaaclab.assets import ArticulationCfg
from isaaclab.actuators import ImplicitActuatorCfg

WHEELED_BASE_CFG = ArticulationCfg(
    spawn=UsdFileCfg(usd_path="path/to/wheeled_base.usd"),
    init_state=ArticulationCfg.InitialStateCfg(
        pos=(0.0, 0.0, 0.15),
        joint_pos={".*": 0.0},
        joint_vel={"left_wheel_joint": 0.0, "right_wheel_joint": 0.0},
    ),
    actuators={
        "wheels": ImplicitActuatorCfg(
            joint_names_expr=["left_wheel_joint", "right_wheel_joint"],
            velocity_limit=10.0,
            effort_limit=50.0,
            stiffness=0.0,   # 纯速度控制,无位置刚度
            damping=10.0,    # 阻尼 = kv
        ),
    },
)

Isaac Lab 中 stiffness=0.0 + damping>0 的配置等价于 MuJoCo 中的 velocity actuator:当 stiffness 为零时,actuator 不追踪位置,只追踪速度(通过阻尼项产生力矩 \(\tau = D \cdot (v_{\text{target}} - v_{\text{current}})\))。

双框架轮式底盘 API 对照

概念 mjlab (MJCF) Isaac Lab (USD + PhysX)
轮子关节类型 hinge joint revolute joint (USD)
速度控制 <velocity> actuator ImplicitActuatorCfg(stiffness=0)
摩擦模型 condim + friction PhysX material friction
底盘自由度 freejoint (6 DOF) floating root link
非完整约束来源 轮地摩擦自然约束 轮地摩擦自然约束
全向轮建模 需自定义 plugin 或多轮组合 PhysX wheel constraint

Isaac Lab 中轮式仿真的工程限制 ⭐⭐

在 Isaac Lab 中做轮式 RL 时,一个经常被忽视但影响严重的问题是 PhysX GPU pipeline 对 cylinder 碰撞体的近似。PhysX 的 GPU 碰撞检测不原生支持精确的 cylinder-plane 接触——它将 cylinder 近似为一个 n-gon(默认约 18 面的多面体)。这意味着轮子每转过一个面的角度,接触法线方向会发生一次离散跳变,产生周期性的接触力扰动。

对于缓慢滚动的轮子,这个近似足够好。但对于高速旋转(如 10 rad/s 的轮速),n-gon 的离散接触频率可能与策略的控制频率产生干涉——策略可能学到利用这些离散接触模式(例如在特定旋转角度施加更大的推力),这些模式在真机上不存在,导致 sim2real 时底盘行为异常。

两种工程解决方案

方案 A:替代碰撞几何体。 用球体(sphere)或胶囊体(capsule)替代 cylinder 作为轮子的碰撞体。球体-平面接触在 PhysX 中是精确解析的,没有 n-gon 问题。代价是视觉上不像轮子,且球体在侧向上没有方向性——但如果轮地摩擦已经通过摩擦参数建模,这个代价可以接受。

# Isaac Lab 中用 sphere 替代 cylinder 作为轮子碰撞体
# 在 USD 资产中修改轮子的 collision shape:
# 视觉 mesh 保持 cylinder,碰撞 mesh 替换为 sphere
# radius = wheel_radius,center 在轮子质心

方案 B:虚拟臂抽象(Virtual-Arm Abstraction)。 完全跳过轮子物理仿真。把底盘建模为一个在平面上的虚拟 2-DOF 或 3-DOF "臂"——动作空间是 \((v_x, v_y, \omega)\)(全向)或 \((v, \omega)\)(差速),底盘的运动直接通过外力/外力矩施加到 root body 上,不经过轮地接触。这是 Isaac Lab 社区中处理轮式底盘的推荐做法(参考 Isaac Lab Discussion #1043),适用于任务重点是操作而非底盘动力学的场景。

# 虚拟臂抽象:直接施加外力驱动底盘(Isaac Lab)
class VirtualBaseActionCfg(ActionTermCfg):
    """直接用力/力矩驱动底盘,跳过轮子物理。"""
    asset_name: str = "robot"
    body_name: str = "base_link"

    def apply(self, env, action):
        # action: [B, 3] -> [v_x_cmd, v_y_cmd, omega_cmd]
        # 用 PD 控制器将速度命令转换为力/力矩
        base_vel = env.robot.data.root_link_lin_vel_b[:, :2]
        base_angvel = env.robot.data.root_link_ang_vel_b[:, 2:3]
        vel_target = action[:, :2] * self.scale_lin
        angvel_target = action[:, 2:3] * self.scale_ang
        force_xy = self.kp_lin * (vel_target - base_vel)
        torque_z = self.kp_ang * (angvel_target - base_angvel)
        # 施加到 root body
        env.robot.set_external_force_and_torque(
            force=torch.cat([force_xy, torch.zeros_like(torque_z)], dim=-1),
            torque=torch.cat([torch.zeros_like(force_xy), torque_z], dim=-1),
        )

方案选择决策:如果你的任务需要研究底盘动力学本身(如漂移控制、打滑恢复、精确停车),必须用显式轮子建模(MuJoCo 是更好的选择,因为没有 n-gon 问题)。如果任务的核心挑战在操作侧(如抓取精度、搬运路径规划),虚拟臂抽象更高效且避免了仿真伪影。本章的教学实战采用 MuJoCo 的显式轮子建模(在 mjlab 框架中),同时提供 Isaac Lab 的虚拟臂方案作为替代。

反事实推理:如果在 Isaac Lab 中直接用 cylinder 碰撞体训练轮式策略,然后部署到真机会怎样? 策略在仿真中学到的底盘控制可能包含对 n-gon 离散接触的隐式适应——例如在某些旋转角度减速避免"颠簸"。部署到真机后,轮子的接触是连续且光滑的,这些隐式适应变成了无意义的周期性速度波动。如果 DR 的范围足够大,可能掩盖这个问题;但对于精确停车等任务,这种仿真伪影会直接影响厘米级的定位精度。

差速驱动运动学推导 ⭐⭐

在设计 observation 和 reward 之前,必须理解差速底盘的运动学关系——它决定了轮速和底盘速度之间的映射。

设左轮角速度 \(\omega_L\),右轮角速度 \(\omega_R\),轮半径 \(r\),轮距(两轮中心距离)\(d\)。左右轮的线速度分别是 \(v_L = r\omega_L\)\(v_R = r\omega_R\)

底盘中心的线速度和角速度:

\[v = \frac{v_L + v_R}{2} = \frac{r(\omega_L + \omega_R)}{2}\]
\[\omega = \frac{v_R - v_L}{d} = \frac{r(\omega_R - \omega_L)}{d}\]

反过来,给定目标线速度 \(v\) 和角速度 \(\omega\)

\[\omega_L = \frac{v - \omega d / 2}{r}, \quad \omega_R = \frac{v + \omega d / 2}{r}\]

在 RL 策略中,网络可以直接输出两个轮速 \((\omega_L, \omega_R)\)(低级控制),也可以输出 \((v, \omega)\) 然后通过上式转换为轮速(高级控制)。前者给了策略更大的灵活性但学习更难,后者更符合人的直觉但限制了策略的动作表达。

推荐做法:在训练初期使用 \((v, \omega)\) 参数化(降低学习难度),在高级阶段切换为 \((\omega_L, \omega_R)\) 参数化(更大灵活性)。或者始终使用 \((\omega_L, \omega_R)\),但在 observation 中提供 \((v, \omega)\) 的估计值帮助策略理解底盘运动。

awesome-loco-manipulation 的 Ridgeback-UR5 参考 ⭐⭐

awesome-loco-manipulationgithub.com/aCodeDog/awesome-loco-manipulation)提供了 Clearpath Ridgeback + UR5 的 URDF,这是一个真实的全向底盘 + 6-DOF 工业臂组合。虽然 mjlab 当前没有内置轮式资产,但 Ridgeback-UR5 的 URDF 结构可以作为参考模板。

Ridgeback 的关键特征:四个 Mecanum 轮提供全向移动,底盘质量约 120 kg,UR5 臂有效载荷 5 kg。URDF 中的结构是:base_link → 四个 wheel_link(各一个 continuous joint)→ arm_base_link(固定 joint 连接到底盘顶部)→ UR5 六关节链。

Ridgeback-UR5 的 URDF 读取清单(在你开始自建之前):

检查项 在 URDF 中的位置 为什么重要
轮子 joint 类型 <joint type="continuous"> continuous = 无限旋转,正确
臂挂载方式 <joint type="fixed"> arm_base_link→base_link 刚性连接,无相对自由度
碰撞 mesh <collision><geometry><mesh> 过精细的 mesh 会拖慢仿真
惯性参数 <inertial> 在每个 link 中 Ridgeback base_link ~120 kg
轮子接触 URDF 中无 MuJoCo 特有参数 转换到 MJCF 后需手动添加 condim/friction

如果要在 mjlab 中使用这个 URDF,需要经过 Ch11 描述的转换流程:URDF → MuJoCo 加载 → 调优接触参数和 actuator 类型。URDF 中的 <transmission><gazebo> 标签会被 MuJoCo 忽略,需要在 MJCF 中手动添加对应的 actuator 和 sensor 定义。

对于本章的教学目的,我们使用更简单的自建底盘(差速两轮)+ YAM 臂的组合,避免引入 Mecanum 轮的复杂接触模型。在 Ch22 的 DIY 实战中,读者可以选择使用 Ridgeback-UR5 作为自定义项目的起点。

⚠️ 常见陷阱

⚠️ 编程陷阱:轮子 condim 设置错误 - 错误做法:把驱动轮的 condim 设为 1(只有法向力)或 3(无扭转摩擦) - 后果:轮子在地面上无法产生足够的切向力,底盘无法移动或无法原地旋转 - 正确做法:驱动轮使用 condim="4"(法向 + 滑动 + 扭转;如需滚动阻力再用 condim="6"),被动轮可以用 condim="3"

⚠️ 概念误区:用 JointPositionActionCfg 控制轮子 - 错误想法:和臂关节一样用位置控制 - 后果:位置目标会让轮子来回摆动到特定角度而非连续旋转。如果目标是 \(\pi\) rad,轮子转半圈就停;下一步目标是 \(2\pi\),又转半圈 - 正确做法:轮子用 JointVelocityActionCfg(mjlab)或 ImplicitActuatorCfg(stiffness=0)(Isaac Lab)

⚠️ 思维陷阱:全向底盘"更好"所以总应该用全向 - 错误想法:全向轮消除了非完整约束,RL 更容易学 - 实际上:全向轮的建模和仿真更复杂(Mecanum 轮的接触力模型在大多数物理引擎中不精确),而且真实 Mecanum 轮在高负载下有打滑问题。差速底盘物理更简单、仿真更准确、真机更便宜 - 正确做法:除非任务明确需要横移能力(如狭窄走廊中的侧向停靠),优先选择差速底盘

💡 编程陷阱:被动万向轮的阻尼太大 - 后果:万向轮转向时产生巨大阻力,底盘旋转困难,策略学到"尽量少转弯"的保守行为 - 正确做法:万向轮的阻尼应远小于驱动轮的阻尼(至少小一个数量级)

练习

  1. [建模题] 写一个完整的差速底盘 MJCF 文件(两个驱动轮 + 一个被动万向轮),加载到 MuJoCo viewer 中验证:(a) 两轮同速时直线前进,(b) 两轮反速时原地旋转,(c) 底盘不能横向平移。
  2. [估算题] 一个差速底盘轮距 0.56 m,轮子半径 0.08 m。如果左轮角速度 5 rad/s、右轮角速度 3 rad/s,底盘的线速度和角速度各是多少?(提示:\(v = r(\omega_L + \omega_R)/2\)\(\omega = r(\omega_R - \omega_L)/d\)
  3. [跨框架题] 分别在 MJCF 和 USD 中设置一个纯速度控制的轮子 actuator。对比两个框架中"stiffness=0, damping=D" 的物理效果。

底盘建模解决了"底盘能动"的问题,但轮式移动操作的核心挑战是让底盘和臂协同工作。臂关节使用位置控制(rad),轮子使用速度控制(m/s),两者的量纲和动态范围完全不同——这正是下节混合动作空间设计的主题。


21.3 混合动作空间设计 ⭐⭐⭐

这一节解决什么问题:如何在一个 RL 策略中同时输出轮速和臂关节动作,解决量纲不一致和梯度不平衡问题。

动机:为什么不能把所有动作拼成一个向量

回顾 Ch05 的 action 设计原则:动作空间的每一维应该有相近的"任务影响力"。如果某一维的 scale 远大于其他维,PPO 的梯度会被该维主导,其他维的学习被抑制。

轮式 + YAM 的动作空间有三种量纲:轮速(rad/s,范围 \(\pm\)10),臂关节增量(rad,范围 \(\pm\)0.1),夹爪开合(m,范围 0-0.04)。如果直接拼接,网络输出一个 9 维向量(2 轮 + 6 臂 + 1 夹爪),但这些维度的 scale 差了两个数量级。PPO 的 action std 是全局共享的(或按维度独立的),如果用一个全局 std,对轮速合理的 std 对臂来说太大(臂会剧烈抖动),反之对轮速太小(底盘几乎不动)。

反事实推理:如果不做量纲归一化会怎样? 考虑一个极端情况:策略输出 \([-1, 1]\) 范围的 action,轮速的 scale 是 10(mapping 到 \(\pm\)10 rad/s),臂关节的 scale 是 0.05(mapping 到 \(\pm\)0.05 rad 增量)。在训练初期,策略输出接近标准正态分布。轮速维度的 action 在 \(\pm\)10 rad/s 之间随机跳动,底盘疯狂来回冲刺。同时臂关节的 action 只在 \(\pm\)0.05 rad 范围内微调,基本不动。策略很快发现"不动臂但大幅移动底盘"能偶尔让 base-to-object 距离减小(纯靠运气),于是梯度集中在轮速维度——臂的学习被饿死。

归一化方案:每维 \([-1, 1]\) 产生相近的任务后果

正确的做法是让网络输出统一的 \([-1, 1]\) 范围,然后在 action manager 中分别缩放:

# mjlab 中的混合 action 配置伪代码
actions = {
    "base_velocity": JointVelocityActionCfg(
        asset_name="robot",
        joint_names=["left_wheel_joint", "right_wheel_joint"],
        scale=2.0,  # [-1,1] → [-2, 2] rad/s
    ),
    "arm_position": JointPositionActionCfg(
        asset_name="robot",
        joint_names=["joint1", "joint2", "joint3",
                     "joint4", "joint5", "joint6"],
        scale=0.05,  # [-1,1] → [-0.05, 0.05] rad 增量
        use_default_offset=True,  # 以 home keyframe 为中心
    ),
    "gripper": JointPositionActionCfg(
        asset_name="robot",
        joint_names=["left_finger_joint"],
        scale=0.02,  # [-1,1] → [-0.02, 0.02] m 增量
    ),
}

scale 的选择原则:每一维的 action 从 0 变到 1 时,对任务进度的贡献应该大致相等。轮速 scale 2.0 意味着满 action 时底盘速度约 0.16 m/s(轮半径 0.08 m \(\times\) 2 rad/s),一步(0.01 s)移动约 1.6 mm。臂关节 scale 0.05 意味着满 action 时末端移动大约 1-3 mm(取决于 Jacobian)。两者在同一数量级,策略不会偏好某一组。

Action Scale 的系统化选择方法 ⭐⭐

选择 action scale 不应该靠猜测。以下是一个系统化的方法:

Step 1:估算每维的物理效果。 对每个 action 维度,计算 action=1.0 时产生的物理位移/速度。对轮子:位移 = scale \(\times\) \(r\) \(\times\) \(dt\) = 2.0 \(\times\) 0.08 \(\times\) 0.01 = 1.6 mm/step。对臂末端:通过 Jacobian 矩阵估算,典型值 1-5 mm/step。对夹爪:scale \(\times\) \(dt\) = 0.02 \(\times\) 1 = 0.02 m/step(夹爪不需要乘 \(dt\),因为是位置增量不是速度)。

Step 2:对齐数量级。 如果轮子一步移动 1.6 mm 而臂末端一步移动 50 mm,策略会优先学臂(因为臂的 action 更"有效")。调整 scale 使各维的物理效果在同一数量级(0.5-5 mm/step 是合理范围)。

Step 3:运行 random agent 验证。 用随机策略播放 100 步,观察各关节的运动幅度。如果某个关节几乎不动(scale 太小),或者某个关节剧烈抽搐到限位(scale 太大),调整对应的 scale。

# random agent 验证脚本
import torch
env = make_env("Mjlab-Mobile-Lift-Cube-Wheeled-Yam", num_envs=1)
obs = env.reset()
for step in range(100):
    action = torch.randn(1, env.action_space.shape[0])
    obs, reward, done, info = env.step(action)
    if step % 20 == 0:
        print(f"Step {step}:")
        print(f"  base pos: {env.robot.data.root_link_pos_w[0, :2]}")
        print(f"  ee pos:   {env.robot.data.site_pos_w[0, 'grasp_site']}")
        print(f"  gripper:  {env.robot.data.joint_pos[0, -1]}")

网络架构对混合动作空间的影响 ⭐⭐

混合动作空间中底盘和臂的学习难度不同:底盘导航是粗粒度的空间推理,臂操作是细粒度的精确控制。这种异构性暗示网络架构也需要考虑——虽然最简单的方法是用一个 MLP 同时输出所有 action 维度,但以下替代方案值得了解:

架构 描述 优势 劣势
单 MLP 一个网络输出所有 action 简单、标准 底盘和臂共享梯度
双头 MLP 共享 trunk + 底盘 head + 臂 head 解耦输出层 需要手动划分维度
双策略 底盘和臂各自独立的 MLP 完全解耦 协调只能通过 obs
RNN + MLP LSTM 编码时序 + MLP 输出 处理非完整约束的时序需求 训练更慢

在教学实验中推荐单 MLP(最简单),在研究项目中可以探索双头或 RNN 架构。RSL-RL 默认使用 MLP;切换到 LSTM 需使用 Isaac Lab 的 RslRlRNNModelCfg(其中 rnn_type 为必填字段,取 "lstm""gru"),并确认 runner/model cfg 与当前 Isaac Lab 版本匹配。

如果想要实验双头架构,可以在 RSL-RL 的 ActorCritic 类上做简单修改:

class DualHeadActor(nn.Module):
    """双头 actor:共享 trunk + 独立底盘/臂输出。"""
    def __init__(self, obs_dim, base_dim=2, arm_dim=7):
        super().__init__()
        self.trunk = nn.Sequential(
            nn.Linear(obs_dim, 256), nn.ELU(),
            nn.Linear(256, 128), nn.ELU(),
        )
        self.base_head = nn.Sequential(
            nn.Linear(128, 32), nn.ELU(),
            nn.Linear(32, base_dim),
        )
        self.arm_head = nn.Sequential(
            nn.Linear(128, 64), nn.ELU(),
            nn.Linear(64, arm_dim),
        )

    def forward(self, obs):
        features = self.trunk(obs)
        base_action = self.base_head(features)
        arm_action = self.arm_head(features)
        return torch.cat([base_action, arm_action], dim=-1)

注意这个修改需要同时调整 RSL-RL 中 log_std 的维度划分——底盘和臂的 action noise 量级通常也不同(底盘探索范围大、臂探索范围小),双头架构让它们各自维护独立的 log_std 参数变得自然。

Isaac Lab 中的 multi-action-group 配置

Isaac Lab 支持在同一个环境中定义多个 action term,每个 term 有独立的 scale 和 actuator 类型:

# Isaac Lab 中的混合 action 配置
from isaaclab.envs.mdp.actions import (
    JointVelocityActionCfg,
    JointPositionActionCfg,
)

class ActionsCfg:
    base_velocity = JointVelocityActionCfg(
        asset_name="robot",
        joint_names=["left_wheel_joint", "right_wheel_joint"],
        scale=2.0,
    )
    arm_position = JointPositionActionCfg(
        asset_name="robot",
        joint_names=["joint_[1-6]"],
        scale=0.05,
        use_default_offset=True,
    )
    gripper = JointPositionActionCfg(
        asset_name="robot",
        joint_names=["left_finger_joint"],
        scale=0.02,
    )

两个框架的 action manager 都会在 step() 中按 group 分别处理:先把网络输出的连续向量按维度切分,然后分别施加 scale、offset 和 clip,最后写入对应的 actuator 目标值。这种分组机制确保了轮速和臂关节使用不同的物理接口(velocity vs position),而策略网络只需要输出一个统一的连续向量。

双框架混合动作 API 对照

概念 mjlab Isaac Lab
多动作组 actions dict 中多个 key ActionsCfg 中多个 field
速度控制 JointVelocityActionCfg JointVelocityActionCfg
位置控制 JointPositionActionCfg JointPositionActionCfg
归一化 scale 参数 scale 参数
默认偏移 use_default_offset=True use_default_offset=True
Action 总维度 各 group 维度之和 各 group 维度之和
PPO 输出 连续向量 [B, dim_total] 连续向量 [B, dim_total]

⚠️ 常见陷阱

⚠️ 编程陷阱:忘记给 arm 设置 use_default_offset=True - 后果:action=0 时臂关节目标为 0 rad(可能超出关节限位),而非 home position - 症状:zero agent 播放时臂疯狂抽搐到关节极限 - 正确做法:臂关节始终使用 use_default_offset=True,让 action=0 对应初始姿态

⚠️ 思维陷阱:只调 action scale 不调 observation scale - 错误想法:归一化 action 就够了 - 实际上:observation 中的轮速反馈(rad/s,量级 ~10)和臂关节角度(rad,量级 ~1)也有量纲差异。如果 obs 不归一化,网络输入的某些维度持续很大,梯度会被这些维度主导 - 正确做法:使用 RSL-RL 的 running mean/std 观测归一化,或者手动设定 obs clip 范围

⚠️ 编程陷阱:Isaac Lab 中 action group 顺序错误 - 错误做法:假设 action 向量的前两维一定是轮速。实际上 Isaac Lab ActionManager 按配置项的声明/插入顺序(dataclass field 声明顺序)切分 action,而不是按字段名字母序 - 后果:若顺序假设错误,底盘可能收到臂的 action、臂收到底盘的 action——底盘不动,臂剧烈旋转 - 正确做法:打印 env.action_manager(或 active_terms/action_term_dim)确认每个 term 占据的维度范围

Action Delay Buffer:Sim2Real 的关键组件 ⭐⭐

真实底盘的电机控制器从接收速度指令到轮子达到目标转速,通常有 20-80 ms 的通信和执行延迟。如果仿真中不模拟这个延迟,策略会学到"指令即时生效"的假设——部署到真机后,底盘总是比策略预期慢一拍,导致过冲和振荡。

在 mjlab 中,action delay 通过 EventTerm 的 step mode 实现——每一步在施加动作前,用 N 步前的动作替换当前动作:

class ActionDelayBuffer:
    """环形缓冲区实现动作延迟。"""
    def __init__(self, num_envs, action_dim, max_delay_steps=3, device="cuda"):
        self.buffer = torch.zeros(
            max_delay_steps + 1, num_envs, action_dim, device=device
        )
        self.ptr = 0
        self.max_delay = max_delay_steps

    def push(self, action):
        """存入当前动作。"""
        self.buffer[self.ptr] = action
        self.ptr = (self.ptr + 1) % (self.max_delay + 1)

    def get_delayed(self, delay_steps):
        """获取 delay_steps 步前的动作。

        delay_steps: [B] tensor, 每个 env 的延迟步数(整数)
        """
        idx = (self.ptr - 1 - delay_steps) % (self.max_delay + 1)
        # 从 buffer 中按 env 索引取对应延迟的动作
        return self.buffer[idx, torch.arange(len(delay_steps))]

在训练中,每个 env 的 delay 在 episode reset 时随机采样:

# EventTerm 配置
action_delay = EventTermCfg(
    func=randomize_action_delay,
    mode="reset",
    params={"delay_range": (0, 3)},  # 0-3 个 sim step
)

def randomize_action_delay(env, delay_range):
    """每个 env 的执行延迟在 reset 时随机化。"""
    env.action_delay_steps = torch.randint(
        delay_range[0], delay_range[1] + 1,
        (env.num_envs,), device=env.device
    )

⚠️ 注意:上面的 reset term 只随机化了延迟步数,并不会真正产生延迟。还必须在每个 step 真正下发动作之前,调用 buffer.push(action) 存入当前动作、再用 buffer.get_delayed(env.action_delay_steps) 取出对应延迟的旧动作来替换——这一步需要放在自定义 ActionTerm / action wrapper(或 step-mode 钩子)里。只配 reset term 而不做每步替换,读者照抄不会产生任何延迟。

在 Isaac Lab 中,可以使用 DelayedPDActuatorCfgmin_delay/max_delay)直接在 actuator 层面引入延迟,无需手动管理 buffer——这是 Isaac Lab 对 sim2real 的内置支持。

练习

  1. [计算题] 轮式 + YAM(6臂+1夹爪)+ 差速底盘(2轮)的总 action 维度是多少?如果换成全向底盘(3 控制量),总维度变为多少?
  2. [实验题] 在 mjlab 中用两种 action scale 配置分别运行 random agent 20 步:(a) 全部 scale=1.0,(b) 按上述推荐 scale。对比底盘和臂的运动幅度,解释为什么 (a) 中臂几乎不动。
  3. [设计题] 如果你要加入 DiffIK 作为臂的动作抽象(取代 joint position),action 维度和 scale 应该如何修改?DiffIK 的 3D 末端增量(m)和轮速(rad/s)的量纲差异如何处理?

动作空间设计解决了"策略怎么控制机器人",但策略还需要知道"世界是什么样子"。轮式移动操作的 observation 与固定基座操作有显著差异——底盘的位姿、朝向和轮速反馈都是新增项。这正是下节的内容。


21.4 Observation 设计:底盘里程计 + 臂状态 + 目标关系 ⭐⭐

这一节解决什么问题:设计完整的 observation 方案,确保策略拥有足够的信息来协调底盘导航和臂操作。

动机:移动操作的 observation 比固定操作多了什么

回顾 Ch17 的 YAM lift cube observation:臂关节位置/速度(13 维)+ 末端到物体向量(3 维)+ 物体到目标向量(3 维)+ 上一步动作(7 维)= 总计 26 维左右。底盘是固定的,不需要观测底盘状态。

移动操作中,底盘状态成为必要输入。策略需要知道底盘在哪里、朝向什么方向、移动多快,才能规划导航行为。同时,物体和目标的位置必须从 world frame 转换到 base frame——否则策略看到的坐标会随底盘移动而剧烈变化,违反了 observation 的"低方差"原则。

完整 observation 规格

字段 维度 Frame 来源 部署可用
底盘线速度 \((v_x, v_y, v_z)\) 3 base frame IMU + 轮速里程计
底盘角速度 \((\omega_x, \omega_y, \omega_z)\) 3 base frame IMU
投影重力 \((g_x, g_y, g_z)\) 3 base frame IMU
臂关节位置 \(q_{\text{arm}}\) 6 joint space 编码器
夹爪位置 \(q_{\text{gripper}}\) 1 joint space 编码器
臂关节速度 \(\dot{q}_{\text{arm}}\) 6 joint space 编码器差分
夹爪速度 \(\dot{q}_{\text{gripper}}\) 1 joint space 编码器差分
底盘到物体 \((x, y, z)_{\text{base→obj}}\) 3 base frame 感知系统 条件性
末端到物体 \((x, y, z)_{\text{ee→obj}}\) 3 world frame 正运动学+感知 条件性
物体到目标 \((x, y, z)_{\text{obj→goal}}\) 3 world frame 感知+command 条件性
底盘到目标 \((x, y, z)_{\text{base→goal}}\) 3 base frame command
底盘朝向 vs 目标方向 \((\cos\alpha, \sin\alpha)\) 2 base frame 计算
轮速反馈 \((\omega_L, \omega_R)\) 2 joint space 编码器
上一步动作 \(a_{t-1}\) 9 缓存
Actor 总计 基线 42 / 含两个条件性目标项(obj→goal、base→goal)共 48

关键设计决策

决策 1:坐标 frame 选择。 底盘到物体和底盘到目标的相对向量必须在 base frame 中表达。如果在 world frame 中表达,这些向量会随底盘的全局位置变化而变化——同一个"目标在底盘前方 1 米"的情况,在世界坐标 (0,0) 和 (10,10) 处的 observation 完全不同。使用 base frame 消除了底盘全局位移的影响,策略只需要关注"目标相对于我在哪里"。

回顾 Ch05 的 observation 设计原则之一:observation 应该对无关变换保持不变。底盘的全局坐标对任务没有意义(在房间的左边和右边执行同样的抓取动作应该一样),因此不应该出现在 actor observation 中。但 critic 可以使用全局坐标作为 privileged 信息,帮助估计 value。

frame 转换的工程实现需要注意:底盘的朝向用四元数 \(q_{\text{base}}\) 表示,world frame 中的向量 \(\mathbf{v}_w\) 转换到 base frame 需要旋转矩阵 \(R(q_{\text{base}})\) 的转置:\(\mathbf{v}_b = R(q_{\text{base}})^T \mathbf{v}_w\)。在 mjlab 中,这个转换通常由 quat_rotate_inverse 函数完成:

def base_to_object_b(env):
    """计算 base frame 中的底盘到物体向量。"""
    # world frame 中的相对位置
    obj_pos_w = env.cube.data.root_link_pos_w  # [B, 3]
    base_pos_w = env.robot.data.root_link_pos_w  # [B, 3]
    rel_pos_w = obj_pos_w - base_pos_w  # [B, 3]

    # 转换到 base frame
    base_quat = env.robot.data.root_link_quat_w  # [B, 4]
    rel_pos_b = quat_rotate_inverse(base_quat, rel_pos_w)  # [B, 3]
    return rel_pos_b

决策 2:底盘朝向编码。 差速底盘的非完整约束使得朝向信息对导航决策至关重要。如果物体在底盘的正右方,差速底盘需要先右转再前进,全向底盘可以直接横移。策略怎么知道"物体在右边"?

最直接的方法是把底盘到物体的相对向量放在 base frame 中——这个向量的 \(y\) 分量(横向)会告诉策略物体在左边还是右边。但为了让策略更容易学到"先转向再前进"的行为,可以额外提供底盘朝向和目标方向的角度差 \(\alpha\),用 \((\cos\alpha, \sin\alpha)\) 编码(避免角度跳变)。

为什么用 \((\cos\alpha, \sin\alpha)\) 而不是 \(\alpha\) 本身?因为角度 \(\alpha\)\(-\pi\)\(\pi\) 之间有一个 \(2\pi\) 的不连续跳变:从 \(3.14\)\(-3.14\) 只差了 0.001 弧度的真实旋转,但数值差了 6.28。这个不连续性对网络来说极难处理——在跳变附近,loss landscape 有一个尖锐的悬崖。\((\cos\alpha, \sin\alpha)\) 编码天然连续且一一对应,消除了这个问题。

决策 3:轮速反馈。 轮速反馈不是冗余的——它和底盘速度之间的差值反映了轮子是否打滑。在光滑地面上,实际速度可能小于轮速推算的速度;在抓取时,臂的反作用力可能让轮子空转。策略如果能感知到打滑,可以学到"加大轮速补偿"或"锁住轮子防漂移"的行为。

决策 4:上一步动作的必要性。 为什么要把上一步动作 \(a_{t-1}\) 放进 observation?回顾 Ch05:在存在 action delay 或 actuator 动力学的系统中,当前物理状态不仅取决于当前观测,还取决于刚才施加了什么控制。\(a_{t-1}\) 打破了 action delay 造成的部分可观测性问题。在轮式移动操作中,底盘的惯性较大(15+ kg),轮速命令和实际速度之间有延迟,\(a_{t-1}\) 帮助策略预测底盘的即时动态。

完整 observation 计算函数 ⭐⭐

将上述所有 obs term 组合成一个完整的计算函数。在 mjlab 中,每个 obs term 是一个独立函数,由 ObservationGroupCfg 组合:

# === mjlab observation terms ===

def base_lin_vel_b(env) -> torch.Tensor:
    """底盘线速度(base frame)。[B, 3]"""
    return env.robot.data.root_link_lin_vel_b

def base_ang_vel_b(env) -> torch.Tensor:
    """底盘角速度(base frame)。[B, 3]"""
    return env.robot.data.root_link_ang_vel_b

def projected_gravity(env) -> torch.Tensor:
    """投影重力向量(base frame)。[B, 3]"""
    return env.robot.data.projected_gravity_b

def arm_joint_pos_normalized(env) -> torch.Tensor:
    """臂关节位置,归一化到 [-1, 1]。[B, 7]"""
    arm_idx = env.arm_joint_ids  # 预计算的索引
    pos = env.robot.data.joint_pos[:, arm_idx]
    lo = env.robot.data.soft_joint_pos_limits[:, arm_idx, 0]
    hi = env.robot.data.soft_joint_pos_limits[:, arm_idx, 1]
    return 2.0 * (pos - lo) / (hi - lo + 1e-8) - 1.0

def arm_joint_vel(env) -> torch.Tensor:
    """臂关节速度。[B, 7]"""
    return env.robot.data.joint_vel[:, env.arm_joint_ids]

def wheel_vel(env) -> torch.Tensor:
    """轮子角速度。[B, 2]"""
    return env.robot.data.joint_vel[:, env.wheel_joint_ids]

def base_to_object_b(env) -> torch.Tensor:
    """底盘到物体向量(base frame)。[B, 3]"""
    obj_pos_w = env.cube.data.root_link_pos_w
    base_pos_w = env.robot.data.root_link_pos_w
    rel_w = obj_pos_w - base_pos_w
    base_quat = env.robot.data.root_link_quat_w
    return quat_rotate_inverse(base_quat, rel_w)

def ee_to_object_w(env) -> torch.Tensor:
    """末端到物体向量(world frame)。[B, 3]"""
    ee_pos = env.robot.data.body_link_pos_w[:, env.ee_body_idx]
    obj_pos = env.cube.data.root_link_pos_w
    return obj_pos - ee_pos

def heading_to_object(env) -> torch.Tensor:
    """底盘朝向与物体方向的 cos/sin 编码。[B, 2]"""
    rel_b = base_to_object_b(env)[:, :2]  # 取 xy 分量
    angle = torch.atan2(rel_b[:, 1], rel_b[:, 0])  # base frame 中的方位角
    return torch.stack([torch.cos(angle), torch.sin(angle)], dim=-1)

def last_action(env) -> torch.Tensor:
    """上一步动作。[B, action_dim]"""
    return env.action_manager.prev_action

将这些 term 注册到 ObservationGroupCfg

class ObservationsCfg:
    """轮式 + YAM 的 observation 配置。"""
    class PolicyCfg(ObservationGroupCfg):
        """Actor observation(部署时可用)。"""
        base_lin_vel = ObsTerm(func=base_lin_vel_b)      # 3
        base_ang_vel = ObsTerm(func=base_ang_vel_b)      # 3
        proj_gravity = ObsTerm(func=projected_gravity)    # 3
        arm_pos = ObsTerm(func=arm_joint_pos_normalized)  # 7
        arm_vel = ObsTerm(func=arm_joint_vel)             # 7
        wheel_vel_obs = ObsTerm(func=wheel_vel)           # 2
        b2o = ObsTerm(func=base_to_object_b)              # 3
        ee2o = ObsTerm(func=ee_to_object_w)               # 3
        heading = ObsTerm(func=heading_to_object)          # 2
        prev_act = ObsTerm(func=last_action)               # 9
        # Actor 总维度: 3+3+3+7+7+2+3+3+2+9 = 42

    class CriticCfg(ObservationGroupCfg):
        """Critic observation(训练时可用的 privileged 信息)。"""
        # 继承 actor 的所有 obs
        base_lin_vel = ObsTerm(func=base_lin_vel_b)
        # ... 同上 ...
        # 额外的 privileged obs
        obj_global_pose = ObsTerm(func=object_global_pose)  # 7
        base_global_pose = ObsTerm(func=base_global_pose)   # 7
        wheel_friction = ObsTerm(func=wheel_friction_coeff) # 2
        obj_mass = ObsTerm(func=object_mass)                # 1

Observation 调试三板斧 ⭐⭐

训练不起效时,obs 问题是最常见的根因之一——但也是最难发现的,因为 obs 错误通常不会导致报错,只会导致策略"学不到东西"。以下三个调试步骤覆盖了 90% 的 obs 问题:

第一斧:打印 obs 维度和数值范围。env.reset() 后立即打印一帧 obs,确认维度正确且数值在合理范围内。

# obs 维度和数值范围检查
obs, _ = env.reset()
print(f"Obs shape: {obs['policy'].shape}")  # 应为 [B, 42]
print(f"Obs min: {obs['policy'].min(dim=0).values}")
print(f"Obs max: {obs['policy'].max(dim=0).values}")
# 检查是否有 NaN 或 Inf
assert not torch.isnan(obs['policy']).any(), "NaN in obs!"
assert not torch.isinf(obs['policy']).any(), "Inf in obs!"

如果某维数值异常大(如 >100),通常是 frame 转换错误或漏减 env_origins

第二斧:可视化 base-to-object 向量。 在 Viser viewer 中画一条从底盘指向物体的线,确认 frame 转换正确。如果线指向错误方向,说明 quat_rotate_inverse 的参数顺序搞反了。

# 在 Viser 中可视化相对向量(调试用)
def debug_visualize_obs(env, viewer):
    base_pos = env.robot.data.root_link_pos_w[0].cpu().numpy()
    obj_pos = env.cube.data.root_link_pos_w[0].cpu().numpy()
    # 画 world frame 中的向量(红色)
    viewer.add_line("base_to_obj_w", base_pos, obj_pos, color=(1, 0, 0))
    # 画 base frame 中的向量(蓝色,从原点出发)
    rel_b = base_to_object_b(env)[0].cpu().numpy()
    viewer.add_line("base_to_obj_b", [0, 0, 0], rel_b, color=(0, 0, 1))

第三斧:跨 env_id 对比 obs。 在多环境并行训练中,不同 env 的物理配置相同时(如 Phase 0 固定物体位置),它们的 obs 应该也相同。如果不同 env_id 的 obs 差异很大,说明 env_origins 没有被正确减去。

# 跨 env_id obs 一致性检查
obs1 = obs['policy'][0]  # env 0
obs2 = obs['policy'][1]  # env 1
diff = (obs1 - obs2).abs()
# 底盘状态应该几乎相同(小量差异来自随机初始化)
print(f"Obs diff (should be small): {diff.mean():.4f}")
# 如果 base-to-object 项差异 > 1.0,大概率是 env_origins 问题
b2o_diff = diff[env.b2o_slice]  # 预定义的 slice 范围
if b2o_diff.max() > 1.0:
    print("WARNING: base-to-object differs across envs!")
    print("Check if env_origins is subtracted.")

Privileged observation(仅 critic)

字段 维度 用途
物体全局位姿 7 (pos+quat) 精确空间推理
底盘全局位姿 7 导航上下文
轮地摩擦系数 2 预测漂移
物体质量 1 预测抓取难度
接触力 6 确认抓取稳定

跨领域类比:actor/critic 的信息不对称就像驾校学车。学员(actor)只能通过后视镜和仪表盘获取信息,教练(critic)坐在副驾能看到盲区、知道路面湿滑程度、清楚目的地精确坐标。教练的额外信息帮助他更准确地评估学员的驾驶质量,但学员最终必须只靠自己能看到的信息来驾驶。这个类比的边界在于:教练能直接告诉学员"左转",而 critic 只能通过 value 估计间接影响 policy gradient。

双框架 Observation API 对照

概念 mjlab Isaac Lab
Obs 定义 ObservationGroupCfg 内多个 ObsTerm ObservationsCfg 内多个 ObsTermCfg
Frame 转换 手动调用 quat_rotate_inverse 手动调用 math_utils.quat_rotate_inverse
Obs 归一化 RSL-RL running_mean_std / 手动 clip 同左(共用 RSL-RL)
Privileged obs 单独的 ObservationGroupCfg enable_corruption=False 单独的 ObsGroupcritic
Obs 调试 env.obs_buf 打印 env.observation_manager.compute() 返回 dict

观测归一化的工程细节:RSL-RL 的 EmpiricalNormalization 类维护每个 obs 维度的 running mean 和 std。在训练初期,这些统计量还不稳定,所以需要一个 warm-up 期(默认 ~1000 step)。一个常见错误是在 warm-up 期间就开始评估策略效果——此时 obs 归一化还没有收敛,策略表现不代表真实水平。另外,保存 checkpoint 时必须同时保存 running mean/std 的状态,否则加载后 obs 归一化从零开始,性能骤降。

⚠️ 常见陷阱

⚠️ 编程陷阱:把绝对 world 位置或跨 frame 量混入相对向量 - 澄清:对同一个 env 的两个 world 位置,object_pos_w - base_pos_w 中的 env origin 会代数抵消(obj+origin) − (base+origin) = obj − base),相对向量本身已与 env origin 无关——本节上面的 base_to_object_b 直接相减就是对的,无需先减 env_origins。 - 真正的错误:把绝对 world position 直接放进 actor obs(它随 env origin 漂移),或把 world pose 与一个未加 origin 的局部 command/随机目标混在一起比较(两者不在同一 frame/origin)。 - 正确做法:相对向量用两个 world position 直接相减即可;与 command/local target 比较时,必须保证双方处于同一 frame/origin。

⚠️ 思维陷阱:用欧拉角表示底盘朝向 - 错误想法:底盘朝向是一个角度 \(\theta\),直接放进 obs - 后果:\(\theta\)\(\pm\pi\) 处有 \(2\pi\) 跳变,网络难以学习跳变附近的行为 - 正确做法:用 \((\cos\theta, \sin\theta)\) 或投影重力向量

💡 概念误区:认为"投影重力对轮式底盘没用" - 实际上:虽然轮式底盘在平地上几乎不倾斜,投影重力仍然提供有用信息。当底盘在斜坡上、或者臂伸到一侧导致微小倾斜时,投影重力能告诉策略"我正在倾斜"。更重要的是,投影重力是跨形态的通用 obs term——保留它能让策略更容易迁移到不同底盘 - 正确做法:保留投影重力项,即使在平地上它几乎是常数

练习

  1. [计算题] 按上表计算完整的 actor observation 维度和 critic observation 维度(actor + privileged)。
  2. [设计题] 如果要加入深度相机(\(64\times64\) 分辨率)作为视觉输入,actor observation 的维度如何变化?哪些状态项应该从视觉版的 actor obs 中移除?为什么?(提示:参考 Ch18 的 teacher-student 信息退化阶梯)

有了动作空间和观测空间的设计,下一步是设计训练课程。轮式移动操作的 curriculum 有一个独特的结构:它必须先验证操作子系统在静止底盘上能否工作,然后才逐步引入底盘移动——这正是下节的主题。


21.5 Curriculum 设计:从固定底盘到移动搬运 ⭐⭐⭐

这一节解决什么问题:设计一个五阶段的训练课程,让策略从最简单的固定底盘抓取逐步学会完整的移动搬运任务。

动机:为什么不从一开始就训练完整任务

完整的移动搬运任务要求策略同时学会:导航到物体附近、精确对齐末端、闭合夹爪、提起物体、搬运到目标位置。这五个子技能中,后面的技能以前面的为前提——如果还没学会抓取,搬运的奖励信号为零。在训练初期,PPO 面临的是一个近乎稀疏的奖励景观:策略随机输出底盘和臂的动作,底盘乱跑、臂乱挥,几乎不可能偶然完成完整的抓取-搬运序列。

这就像让一个从未开过车的人直接参加拉力赛——不仅要会开车,还要会导航、会修车、会应对路况变化。更合理的方式是先在停车场学会前进后退转弯,再上公路,再上山路。

反事实推理:如果从一开始就训练完整任务会怎样? reward 长期为零(没碰到物体 → 没抓住 → 没搬运),PPO 的 value 估计退化为零函数,策略梯度接近随机噪声。经过数百万步后,底盘可能偶尔移动到物体附近(因为 base-to-object 导航奖励还有信号),但从来不抓取。这种行为被称为"奖励寄生"——策略只优化容易获得的奖励子项,忽略需要精确操作才能获得的子项。

五阶段 curriculum ⭐⭐⭐

阶段 名称 底盘状态 新增难度 通过标准 训练重点
0 Fixed-base reach 锁定不动 末端到达物体 < 5 cm 臂的运动学
1 Fixed-base grasp 锁定不动 夹爪闭合+提升 grasp success > 60% 夹持接触
2 Base reposition 可移动,但物体在固定位置 导航 base-to-object < 30 cm 且 heading aligned 底盘导航
3 Mobile reach + grasp 可移动,物体位置随机 移动中抓取 grasp success > 40% base-arm 协调
4 Pick-and-carry 可移动,物体和目标都随机 搬运 carry success > 20% 全流程协调

阶段 0:Fixed-base reach。 锁定底盘(底盘关节不在 action 中),只训练臂的 reaching 行为。这和 Ch17 的 YAM reach 子任务几乎相同,验证臂的运动学和 reward 配置是否正确。物体放在臂的可达范围内的随机位置。

# 阶段 0 的 action 配置:只有臂
actions_phase0 = {
    "arm_position": JointPositionActionCfg(
        asset_name="robot",
        joint_names=["joint1", ..., "joint6"],
        scale=0.05,
        use_default_offset=True,
    ),
    "gripper": JointPositionActionCfg(
        asset_name="robot",
        joint_names=["left_finger_joint"],
        scale=0.02,
    ),
}
# 底盘 action 不包含在内 → 底盘保持静止

阶段 1:Fixed-base grasp。 在阶段 0 的基础上加入 staged reward,让策略不仅接近物体,还要闭合夹爪并提起。这一步验证了抓取接触参数(夹爪摩擦、物体质量)是否合理。如果这一步都不能学会抓取,加底盘只会更糟。

阶段 2:Base reposition。 放开底盘动作,物体位置固定在离初始位置有一段距离的地方。策略需要先把底盘移动到物体附近,但不需要在移动中抓取。这一步单独训练导航能力。

# 阶段 2 的 action 配置:底盘 + 臂
actions_phase2 = {
    "base_velocity": JointVelocityActionCfg(
        asset_name="robot",
        joint_names=["left_wheel_joint", "right_wheel_joint"],
        scale=2.0,
    ),
    "arm_position": JointPositionActionCfg(...),
    "gripper": JointPositionActionCfg(...),
}

阶段 3:Mobile reach + grasp。 物体位置随机化,策略需要在移动后完成抓取。这是 base-arm 协调的关键阶段——策略必须学会"移动到合适位置后停下来、伸手抓取"。

阶段 4:Pick-and-carry。 物体和目标位置都随机化,策略需要完成完整的移动-抓取-搬运-放置序列。这是最终目标。

Curriculum 推进机制

阶段推进不应只按训练步数,而应按性能门控:

# CurriculumManager 伪代码
class MobileManipCurriculum:
    def update(self, metrics):
        if self.phase == 0 and metrics["reach_success"] > 0.8:
            self.phase = 1
            self.update_env_config()  # 启用 staged reward
        elif self.phase == 1 and metrics["grasp_success"] > 0.6:
            self.phase = 2
            self.update_env_config()  # 启用底盘 action
        elif self.phase == 2 and metrics["nav_success"] > 0.7:
            self.phase = 3
            self.update_env_config()  # 物体位置随机化
        elif self.phase == 3 and metrics["grasp_success"] > 0.4:
            self.phase = 4
            self.update_env_config()  # 目标位置随机化

本质洞察:Curriculum 的本质不是"把任务变简单",而是"在策略当前能力的边缘制造足够多的成功经验"。每个阶段的通过标准不需要很高(60%、40%、20%),因为进入下一阶段后策略会在新任务的压力下继续改进之前阶段的技能。Curriculum 的真正价值是避免"冷启动"——让策略在每个阶段都有足够的正奖励信号来驱动学习。

阶段推进时的 Warm-Start 策略 ⭐⭐

阶段推进时最危险的时刻是加入新的 action 维度(如从阶段 1 进入阶段 2 时加入底盘 action)。如果新维度的网络权重随机初始化,之前学会的臂控制能力可能被破坏——因为 PPO 的策略更新会同时修改所有网络权重,新维度的随机梯度可能干扰旧维度的已有权重。

方法 A:冻结旧权重,只训练新权重。 这保护了已有能力,但限制了 base-arm 协调的联合优化。适合阶段 1→2 的过渡(先让底盘学会基本移动,再解冻所有权重联合优化)。

方法 B:降低学习率。 进入新阶段时把 learning rate 降低到之前的 1/3-1/5,让旧能力缓慢适应而不是被瞬间破坏。这是最常用的方法。

方法 C:双策略网络。 底盘和臂使用独立的策略网络,各自独立训练。协调通过 observation 中的相互信息实现。这种方法在 Ch19 的四足加臂中也被使用过——locomotion policy 和 arm policy 独立但通过 shared observation 隐式协调。

# 方法 B 的伪代码:阶段推进时降低 lr
if curriculum.phase_just_advanced:
    for param_group in optimizer.param_groups:
        param_group['lr'] *= 0.3  # 降低到 30%
    # 经过 N 个 epoch 后逐步恢复
    lr_scheduler = LinearLRWarmup(
        optimizer, warmup_epochs=50,
        target_lr=original_lr
    )

每阶段的详细 episode 设计

阶段 episode 长度 物体初始范围 目标初始范围 新增 reward 项 新增 termination
0 3 s (300 step) 臂可达 (0.2-0.5 m) reach only arm self-collision
1 5 s (500 step) 同上 固定高度 0.3 m + staged(grasp+lift) + object drop
2 8 s (800 step) 底盘前方 1-2 m + nav + heading + base oob
3 10 s (1000 step) 360° 随机 1-3 m 固定 同上 同上
4 15 s (1500 step) 同上 随机 1-3 m + carry + carry timeout

Episode 长度随阶段增长——移动搬运需要更多步数来完成完整的导航-抓取-搬运序列。但过长的 episode 会降低训练效率(PPO 的 GAE 在长 episode 中方差更大),所以在早期阶段使用短 episode 是合理的。

mjlab 中的 Curriculum 完整实现 ⭐⭐

mjlab 的 CurriculumManager 支持在训练过程中动态修改环境配置。以下是一个完整的五阶段 curriculum 配置:

class CurriculumCfg:
    """五阶段 curriculum 配置。"""
    phase_advance = CurriculumTermCfg(
        func=advance_phase,
        params={
            "thresholds": {
                0: {"metric": "reach_success", "value": 0.5, "min_episodes": 200},
                1: {"metric": "grasp_success", "value": 0.4, "min_episodes": 300},
                2: {"metric": "nav_success", "value": 0.3, "min_episodes": 200},
                3: {"metric": "grasp_success", "value": 0.3, "min_episodes": 500},
            },
            "max_phase": 4,
        },
    )
    phase_retreat = CurriculumTermCfg(
        func=retreat_phase,
        params={
            "retreat_threshold": 0.1,  # 如果 success rate < 10%,回退
            "retreat_patience": 50,     # 连续 50 个 epoch
        },
    )

def advance_phase(env, thresholds, max_phase):
    """检查是否满足推进条件并推进阶段。"""
    current = env.curriculum_phase
    if current >= max_phase:
        return
    cond = thresholds.get(current, None)
    if cond is None:
        return
    metric_val = env.metrics.get(cond["metric"], 0.0)
    episodes = env.metrics.get("total_episodes", 0)
    if metric_val >= cond["value"] and episodes >= cond["min_episodes"]:
        env.curriculum_phase = current + 1
        env.metrics["total_episodes"] = 0  # 重置计数
        print(f"🎉 Curriculum advanced: Phase {current} → {current+1}")
        # 应用新阶段的配置修改
        apply_phase_config(env, current + 1)

训练过程 Curriculum 监控 ⭐

在 WandB 中追踪 curriculum 进度是诊断训练问题的重要手段——如果策略在某个阶段卡住超过预期的 iteration 数,通常意味着该阶段的难度跳跃太大或 reward 信号不足。

def log_curriculum_stats(env, logger, step):
    """在每个 log interval 记录 curriculum 统计量。"""
    logger.log({
        "curriculum/phase": env.curriculum_phase,
        "curriculum/phase_0_reach_sr": env.metrics.get("reach_success", 0),
        "curriculum/phase_1_grasp_sr": env.metrics.get("grasp_success", 0),
        "curriculum/phase_2_nav_sr": env.metrics.get("nav_success", 0),
        "curriculum/episode_length_mean": env.metrics.get("ep_len_mean", 0),
    }, step=step)

在 WandB Dashboard 中,创建一个 "Curriculum Progress" panel,x 轴为 training iteration,y 轴同时显示 phase(阶梯线)和各阶段的 success rate(曲线)。健康的训练模式应该是:每个 phase 的 success rate 先上升 → 触发推进 → 新 phase 的 success rate 从低值开始重新上升。如果某个 phase 的 success rate 长时间(>500 iterations)不上升,应检查该阶段的 reward 是否有梯度信号。

配置项 阶段 0-1 阶段 2 阶段 3-4
actions 只有 arm+gripper 加入 base_velocity 同阶段 2
物体初始位置范围 臂可达范围内 离底盘 1-2 m 更大范围
目标位置范围 场景内随机
Reward 项 reach + grasp + nav_reward + carry_reward
Episode 长度 短(3 s) 中(5 s) 长(10 s)

Isaac Lab 中的 Curriculum 对比

Isaac Lab 的 curriculum 通过 CurriculumTermCfg 实现,触发机制可以基于 episode 统计量。两个框架的核心思路一致,区别在于配置语法:

# Isaac Lab curriculum term 示例
class CurriculumCfg:
    increase_object_range = CurriculumTermCfg(
        func=increase_spawn_range,
        params={"step_size": 0.1, "max_range": 2.0},
        trigger=CurriculumTermCfg.TriggerCfg(
            metric_name="grasp_success",
            threshold=0.5,
            min_episodes=100,
        ),
    )

⚠️ 常见陷阱

⚠️ 编程陷阱:阶段推进时没有 warm-start - 错误做法:进入阶段 2(加底盘 action)时从随机权重开始训练 - 后果:之前学会的臂控制能力丢失,需要重新学 - 正确做法:各阶段共享 policy 网络,新增的 action 维度的权重随机初始化但其余权重继承

⚠️ 思维陷阱:Curriculum 阶段越多越好 - 错误想法:把 5 个阶段拆成 10 个,过渡更平滑 - 实际上:每个阶段都需要单独的通过标准、配置修改和 debug。阶段太多 = 超参数太多 - 正确做法:5 个阶段足够覆盖从静态到完整任务的过渡。如果某个阶段跳跃太大,才考虑插入中间阶段

⚠️ 编程陷阱:阶段推进不可逆 - 错误做法:达标后进入下一阶段,即使新阶段性能崩溃也不回退 - 后果:如果阶段 3 的达标是暂时的(刚好某个 batch 超过阈值),进入阶段 4 后持续失败 - 正确做法:设置回退条件——如果连续 N 个 epoch 性能低于回退阈值,退回上一阶段

练习

  1. [设计题] 为阶段 2(base reposition)设计完整的 reward 配置:哪些奖励项在这个阶段激活?各项的权重和 sigma 如何选择?
  2. [思考题] 如果跳过阶段 0-1 直接从阶段 2 开始训练(底盘可动但不单独验证抓取),会有什么风险?如何通过实验确认跳过是否可行?
  3. [跨章综合题] 结合 Ch06 的 Curriculum 设计和 Ch08 的 Domain Randomization,设计一个扩展阶段 5:在阶段 4 基础上加入物体质量随机化、地面摩擦随机化和观测噪声。给出每项 DR 的参数范围和引入顺序。

Curriculum 保证了训练有序推进,但即使在阶段 3-4 中策略学会了抓取后开始搬运,一个新问题随之出现:抓取物体时臂的反作用力让底盘漂移偏离了目标位置。这个问题在固定底盘上不存在,是移动操作独有的——这正是下节要解决的。


21.6 抓取时底盘漂移问题 ⭐⭐⭐

这一节解决什么问题:诊断并解决轮式底盘在抓取阶段发生非预期滑动的工程问题。

动机:为什么底盘会漂移

当臂向下按压物体或向侧面施力时,根据牛顿第三定律,底盘受到等大反向的反作用力。如果轮地摩擦不足以抵消这个力,底盘就会被推走。这在固定基座操作中不存在(底盘螺栓固定在桌面上),是轮式移动操作独有的现象。

考虑一个具体场景:YAM 臂伸到前方 0.4 m 处抓取一个 0.5 kg 的方块。抓取时夹爪施加法向力 \(F_n = 5\) N(两侧各 2.5 N),同时末端有一个向下的接触力 \(F_z \approx 0.5 \times 9.81 \approx 5\) N 用于支撑方块重量。这个向下的力在臂的 base joint 处产生一个力矩 \(\tau = F_z \times L_{\text{arm}} \approx 5 \times 0.4 = 2\) N·m,反作用到底盘上表现为一个向前推的力。如果底盘质量 15 kg、轮地静摩擦系数 \(\mu = 0.6\),最大静摩擦力 \(f_s = \mu m g = 0.6 \times 15 \times 9.81 \approx 88\) N。2 N·m 的力矩产生的推力远小于这个值——在理想情况下不应该漂移。

但实际中有几个加剧因素:(1) 臂做快速运动时产生的惯性力可能瞬时很大;(2) 轮子的动摩擦系数小于静摩擦系数,一旦开始滑动就不容易停下;(3) 策略在学习过程中可能输出剧烈的底盘动作来"追"物体,和臂的反作用力叠加。

定量漂移风险评估:在设计环境之前,可以用以下公式快速判断漂移是否可能发生——如果 \(\text{drift\_ratio} > 0.3\),说明漂移风险较高,需要在 reward 或 action 中加入漂移对策:

def estimate_drift_risk(
    arm_reach: float = 0.4,    # 臂伸出距离 (m)
    payload_mass: float = 0.5, # 载荷质量 (kg)
    base_mass: float = 15.0,   # 底盘质量 (kg)
    wheel_friction: float = 0.6, # 轮地摩擦系数
    arm_max_vel: float = 2.0,  # 臂最大关节速度 (rad/s)
    arm_inertia: float = 0.5,  # 臂等效转动惯量 (kg·m²)
):
    """评估抓取时底盘漂移的风险。"""
    g = 9.81
    # 1) 静态反作用力(支撑载荷重量)
    F_static = payload_mass * g * arm_reach / 0.3  # 简化杠杆
    # 2) 动态惯性力(臂急停时的冲击)
    F_dynamic = arm_inertia * arm_max_vel / 0.02  # 假设 20ms 急停
    # 3) 最大静摩擦力
    F_friction_max = wheel_friction * base_mass * g
    # 4) 漂移比 = 总推力 / 最大摩擦力
    drift_ratio = (F_static + F_dynamic) / F_friction_max
    print(f"静态反作用力: {F_static:.1f} N")
    print(f"动态惯性力: {F_dynamic:.1f} N")
    print(f"最大摩擦力: {F_friction_max:.1f} N")
    print(f"漂移比: {drift_ratio:.2f} "
          f"({'⚠️ 高风险' if drift_ratio > 0.3 else '✅ 低风险'})")
    return drift_ratio

# 示例:YAM + 轮式底盘
estimate_drift_risk()
# 输出:
# 静态反作用力: 6.5 N
# 动态惯性力: 50.0 N
# 最大摩擦力: 88.3 N
# 漂移比: 0.64 ⚠️ 高风险

注意动态惯性力(臂急停时的冲击)才是漂移的主要来源——它比载荷的静态重量大一个数量级。这就是为什么即使载荷很轻(0.5 kg),臂的快速运动仍然可以推动 15 kg 的底盘。策略在训练中经常输出高频的臂动作抖动(exploration noise + 未收敛的策略),这些抖动产生的惯性力远超稳态抓取力。

跨领域类比:底盘漂移问题就像你站在光滑地板上用力推墙——不是墙被你推动了,而是你自己被推走了。差别在于人有主动的脚掌抓地能力(可以弯曲脚趾增加摩擦),而轮式底盘只能依靠轮地摩擦和策略的主动控制来"站稳"。不过,这个类比有一个不同点:人可以调整站姿来抵抗推力,而差速底盘不能改变轮子的位置——它只能通过轮速来产生抵抗力。

三种解决方案

方案 A:Stop reward(奖励方案)。 在抓取阶段(物体被持握后),增加一个惩罚底盘速度的 reward 项:

\[r_{\text{stop}} = -w_{\text{stop}} \cdot \|\mathbf{v}_{\text{base}}\|^2 \cdot \mathbb{1}[\text{grasping}]\]

其中 \(\mathbb{1}[\text{grasping}]\) 是一个指示函数,只有在检测到夹爪接触力大于阈值时才激活。如果不加指示函数,底盘在导航阶段也被惩罚速度——策略会学到"完全不移动"的保守行为。

# stop reward 伪代码
def base_stop_during_grasp(env, threshold=1.0):
    base_vel = env.robot.data.root_link_lin_vel_b[:, :2]  # [B, 2]
    grasp_force = env.robot.data.contact_forces["left_finger"]  # [B, 3]
    is_grasping = grasp_force.norm(dim=-1) > threshold  # [B]
    vel_penalty = base_vel.norm(dim=-1) ** 2  # [B]
    return -vel_penalty * is_grasping.float()

方案 B:Brake action(动作方案)。 在 action 中加入一个"刹车"维度。当策略输出刹车值 > 0 时,轮子的速度目标被强制设为零,并且增大轮子的阻尼以抵抗外力。完整实现如下:

class BrakeAwareWheelAction(ActionTerm):
    """带刹车功能的轮速 action term。

    Action 输出: [B, 3] = [left_wheel, right_wheel, brake]
    - brake ∈ [-1, 1],经过 sigmoid 映射到 [0, 1]
    - brake > 0.5 时锁死轮子
    """
    def __init__(self, cfg):
        super().__init__(cfg)
        self.normal_damping = 10.0   # 正常阻尼
        self.brake_damping = 200.0   # 刹车阻尼(高阻尼 = 难以转动)
        self.wheel_ids = None        # 在 reset 时初始化

    def apply(self, env, action):
        wheel_cmd = action[:, :2] * self.cfg.scale  # [B, 2]
        brake_raw = action[:, 2:3]                   # [B, 1]
        brake_prob = torch.sigmoid(brake_raw)        # [0, 1]
        is_braking = (brake_prob > 0.5).float()      # [B, 1]

        # 刹车时:轮速目标 = 0,阻尼增大
        effective_vel = wheel_cmd * (1.0 - is_braking)
        effective_damping = (
            self.normal_damping * (1.0 - is_braking)
            + self.brake_damping * is_braking
        )

        # 设置 actuator 目标
        env.robot.set_joint_velocity_target(
            effective_vel, joint_ids=self.wheel_ids
        )
        # 动态修改阻尼(MuJoCo 原生支持)
        env.robot.write_joint_damping_to_sim(
            effective_damping, joint_ids=self.wheel_ids
        )

这个方案比 stop reward 更"物理"——策略不是被间接惩罚"速度太快",而是直接获得了一个"锁轮"的工具。代价是 action 维度增加 1(从 9 变成 10),但实验中发现策略通常在 500 iterations 内学会在抓取阶段激活 brake。

本质洞察:brake action 和 stop reward 体现了 RL 中一个更深层的设计选择——把约束放在 action space 还是 reward space。Action space 约束(brake)给策略更多的控制力,但增加了探索空间;Reward space 约束(stop penalty)不改变探索空间,但依赖权重调节才能生效。对于底盘漂移这种"物理直觉清晰"的问题(锁轮 = 不漂移),action space 方案通常更高效。

方案 C:物理方案(增大轮地摩擦)。 在 MJCF 中增大轮子的 friction 参数,使底盘在正常操作力下不会滑动。这是最简单的方案,但可能过度约束底盘——如果摩擦太大,底盘旋转也变困难。

方案 优点 缺点 适用场景
Stop reward 实现简单、不改动作空间 需要调权重、接触检测可能延迟 一般场景
Brake action 策略主动决策、物理直觉清晰 增加一个 action 维度 需要精确定位的场景
增大摩擦 零代码修改 底盘旋转变困难、不够灵活 简单验证阶段

漂移诊断方法论 ⭐⭐

底盘漂移可能不容易在 viewer 中直接观察到——缓慢的漂移在短时间内几乎不可见。以下是系统化的诊断方法:

Step 1:记录底盘位移曲线。 在 episode 中记录底盘 \(x, y\) 坐标的时间序列。如果在抓取阶段(检测到夹爪接触力后)底盘位置持续单调移动,说明存在漂移。

# 漂移诊断工具
def diagnose_drift(env, episode_data):
    grasp_start = episode_data["first_grasp_step"]
    base_pos = episode_data["base_pos_history"]  # [T, 2]
    drift = base_pos[grasp_start:] - base_pos[grasp_start]  # [T', 2]
    total_drift = drift[-1].norm()
    print(f"抓取后底盘漂移: {total_drift:.4f} m")
    if total_drift > 0.05:
        print("⚠️ 漂移超过 5 cm,需要处理")

Step 2:区分被动漂移和主动漂移。 被动漂移是臂反作用力推动底盘(策略没有主动输出底盘速度),主动漂移是策略在抓取时仍然输出底盘速度指令(可能是为了"追"一个运动中的物体)。检查抓取阶段的 wheel action 值:如果接近零但底盘仍在移动,是被动漂移(需要增大摩擦或加 brake);如果 wheel action 非零,是主动漂移(需要 stop reward)。

Step 3:测量临界摩擦系数。 用不同的轮地摩擦系数运行同一个已训练策略,记录各摩擦下的漂移量。绘制 drift vs friction 曲线,找到漂移趋近零的临界摩擦值。如果临界值在 0.5-0.8 范围内(合理的橡胶-混凝土摩擦),说明物理模型合理;如果需要 2.0+ 的摩擦才能防止漂移,说明臂的反作用力过大——可能是 action scale 太大导致臂运动太激烈。

⚠️ 常见陷阱

⚠️ 编程陷阱:stop reward 的指示函数阈值设错 - 阈值太低:导航阶段臂和地面的微小接触也被误判为 grasping,底盘提前停下 - 阈值太高:抓取初期接触力还没达到阈值时底盘已经漂走了 - 正确做法:先在 zero agent 中记录正常抓取和非抓取状态的接触力分布,选择两者之间的阈值

⚠️ 思维陷阱:认为"策略会自己学到不漂移" - 错误想法:不加任何约束,策略在训练中自然学到稳定行为 - 实际上:漂移在训练中可能不影响 reward(因为物体被抓住后即使底盘移动,物体也跟着移动,搬运 reward 不受影响)。策略没有动机去抑制漂移 - 正确做法:必须显式添加 stop reward 或 brake action

练习

  1. [估算题] YAM 臂最大负载 2 kg,臂长 0.5 m。在臂水平伸出并持握负载时,底盘受到的水平推力是多少?如果底盘质量 15 kg、轮地摩擦系数 0.5,底盘是否会滑动?
  2. [实验题] 在 mjlab 中分别用三种方案训练 500 iterations,记录底盘漂移距离(episode 中底盘位移的标准差)。哪种方案最有效?

底盘漂移问题的解决让我们有了完整的移动搬运环境。但策略的学习质量最终取决于奖励函数的设计——一个好的奖励函数必须引导策略完成导航、接近、抓取和搬运的完整序列。这正是下节的内容。


21.7 奖励设计:从导航到搬运的多阶段信号 ⭐⭐⭐

这一节解决什么问题:设计覆盖移动搬运全流程的奖励函数,处理多阶段奖励之间的权重平衡。

动机:移动操作的奖励比固定操作多了什么

回顾 Ch17 的 YAM lift cube staged reward:\(r_{\text{staged}} = r_{\text{reach}} \times (1 + r_{\text{bring}})\)。这个公式假设底盘固定,只有末端到物体和物体到目标两个距离。移动操作中,奖励需要额外覆盖底盘导航、朝向对齐和搬运稳定性。

完整奖励函数 ⭐⭐

\[r_t = \underbrace{w_1 r_{\text{nav}} + w_2 r_{\text{heading}}}_{\text{导航}} + \underbrace{w_3 r_{\text{reach}} \times (1 + w_4 r_{\text{grasp}} + w_5 r_{\text{carry}})}_{\text{操作(staged)}} + \underbrace{w_6 r_{\text{stop}} + w_7 r_{\text{smooth}} + w_8 r_{\text{energy}}}_{\text{正则化}}\]

各项定义:

奖励项 公式 物理含义 典型权重
\(r_{\text{nav}}\) \(\exp(-\|\mathbf{p}_{\text{base}} - \mathbf{p}_{\text{obj}}\|^2 / \sigma_{\text{nav}}^2)\) 底盘接近物体 1.0
\(r_{\text{heading}}\) \(\cos(\alpha_{\text{base→obj}})\) 底盘朝向物体 0.5
\(r_{\text{reach}}\) \(\exp(-\|\mathbf{p}_{\text{ee}} - \mathbf{p}_{\text{obj}}\|^2 / \sigma_{\text{reach}}^2)\) 末端接近物体 2.0
\(r_{\text{grasp}}\) \(\mathbb{1}[\text{grasping}] \cdot \exp(-\Delta h / \sigma_h)\) 抓住并提起 3.0
\(r_{\text{carry}}\) \(\exp(-\|\mathbf{p}_{\text{obj}} - \mathbf{p}_{\text{goal}}\|^2 / \sigma_{\text{carry}}^2)\) 物体接近目标 5.0
\(r_{\text{stop}}\) \(-\|\mathbf{v}_{\text{base}}\|^2 \cdot \mathbb{1}[\text{grasping}]\) 抓取时不漂移 0.5
\(r_{\text{smooth}}\) \(-\|\mathbf{a}_t - \mathbf{a}_{t-1}\|^2\) 动作平滑 0.01
\(r_{\text{energy}}\) \(-\|\boldsymbol{\tau}\|^2\) 能耗惩罚 0.001

乘法门控的逻辑\(r_{\text{reach}} \times (1 + r_{\text{grasp}} + r_{\text{carry}})\) 确保了学习的因果顺序。当 \(r_{\text{reach}} \approx 0\)(末端远离物体),无论 grasp 和 carry 的值如何,乘积都接近零——策略不会被"虚假的 carry 信号"误导。只有当 \(r_{\text{reach}}\) 变大(末端接近物体),grasp 和 carry 项才开始产生梯度。

\(r_{\text{nav}}\)\(r_{\text{reach}}\) 的关系:两者看似都在减小底盘/末端到物体的距离,但服务于不同阶段。\(r_{\text{nav}}\)\(\sigma_{\text{nav}}\) 较大(~1.0 m),提供远距离导航信号。\(r_{\text{reach}}\)\(\sigma_{\text{reach}}\) 较小(~0.1 m),提供近距离精确对齐信号。在训练初期,\(r_{\text{nav}}\) 驱动底盘移动;当底盘到达物体附近后,\(r_{\text{reach}}\) 驱动末端精确对齐。

本质洞察:移动操作的奖励设计本质上是在构建一个隐式的任务状态机。每个奖励项的 \(\sigma\) 和门控条件定义了一个状态转移区域——当策略从 \(r_{\text{nav}}\) 信号主导的行为过渡到 \(r_{\text{reach}}\) 信号主导的行为时,它实际上在穿越一个由 \(\sigma\) 参数定义的"注意力切换边界"。如果两个 \(\sigma\) 区间没有重叠(比如 \(\sigma_{\text{nav}} = 0.3, \sigma_{\text{reach}} = 0.05\)),底盘到达 0.3 m 后 nav 信号饱和但 reach 信号还没有显著梯度,策略会陷入"奖励真空带"。这就是为什么 \(\sigma\) 的选择不能独立进行——必须确保相邻阶段的信号在空间上有足够的重叠。

Staged Reward 工程实现

def staged_mobile_reward(env, reach_sigma=0.1, carry_sigma=0.3):
    """移动操作的乘法门控 staged reward。"""
    # 1) reach: 末端到物体距离
    ee_pos = env.robot.data.body_link_pos_w[:, env.ee_idx]
    obj_pos = env.cube.data.root_link_pos_w
    reach_dist = torch.norm(ee_pos - obj_pos, dim=-1)
    r_reach = torch.exp(-reach_dist**2 / reach_sigma**2)

    # 2) grasp: 夹爪闭合 + 物体离地
    finger_gap = env.robot.data.joint_pos[:, env.finger_idx]
    obj_height = obj_pos[:, 2] - env.table_height
    is_grasping = (finger_gap < 0.02) & (obj_height > 0.04)
    r_grasp = is_grasping.float() * torch.clamp(obj_height / 0.1, 0, 1)

    # 3) carry: 物体到目标距离(仅在 grasping 时激活)
    goal_pos = env.goal_pos_w
    carry_dist = torch.norm(obj_pos - goal_pos, dim=-1)
    r_carry = is_grasping.float() * torch.exp(-carry_dist**2 / carry_sigma**2)

    # 乘法门控:reach 是前置条件
    return r_reach * (1.0 + 3.0 * r_grasp + 5.0 * r_carry)

Heading Alignment Reward 实现\(r_{\text{heading}}\) 需要计算底盘朝向与目标方向之间的对齐程度。一个直觉的方法是计算角度差然后取 cos,但直接计算角度会引入 atan2 的象限问题。更稳健的做法是利用 base frame 中 base-to-object 向量的方向性:

def heading_alignment_reward(env) -> torch.Tensor:
    """底盘朝向与物体方向的对齐奖励。

    在 base frame 中,如果物体在正前方,base_to_obj_b 的 x 分量为正,
    y 分量接近零。cos(angle) = x / norm(xy) 在对齐时接近 1。
    """
    rel_b = base_to_object_b(env)  # [B, 3],base frame
    xy = rel_b[:, :2]  # 只看水平面
    dist_xy = xy.norm(dim=-1, keepdim=True).clamp(min=0.01)
    # cos(heading error) = x_component / xy_distance
    cos_heading = xy[:, 0:1] / dist_xy  # [B, 1]
    # 远离物体时 heading 不重要,乘以距离衰减
    dist_to_obj = rel_b.norm(dim=-1, keepdim=True)
    close_mask = torch.exp(-dist_to_obj / 1.5)  # 靠近时才强调 heading
    return (cos_heading * close_mask).squeeze(-1)

这个实现有两个巧妙之处:(1) 避免了 atan2\(\pm\pi\) 跳变,用 \(\cos = x/\|xy\|\) 直接得到连续的对齐度量;(2) 乘以距离衰减 close_mask,在底盘远离物体时不强调 heading(远处先移动过去再调整方向),在靠近物体时才要求精确对齐。

Sigma 敏感性分析与验证:如何为 \(\sigma_{\text{nav}}\)\(\sigma_{\text{reach}}\) 选择合理的值?核心原则是确保两个 sigma 的有效范围有足够重叠——避免"奖励真空带"。以下代码可以快速验证 sigma 选择是否合理:

import numpy as np
import matplotlib.pyplot as plt

def verify_sigma_overlap(sigma_nav=1.0, sigma_reach=0.1):
    """验证 nav 和 reach 信号的空间重叠。"""
    d = np.linspace(0, 3, 300)
    r_nav = np.exp(-d**2 / sigma_nav**2)
    r_reach = np.exp(-d**2 / sigma_reach**2)
    combined = r_nav + 2.0 * r_reach  # 加权和

    plt.figure(figsize=(8, 4))
    plt.plot(d, r_nav, label=f'r_nav (σ={sigma_nav})')
    plt.plot(d, r_reach, label=f'r_reach (σ={sigma_reach})')
    plt.plot(d, combined, '--', label='Combined signal')
    plt.axhline(y=0.1, color='r', linestyle=':', label='Minimum gradient threshold')
    plt.xlabel('Distance to object (m)')
    plt.ylabel('Reward value')
    plt.legend()
    plt.title('Sigma Overlap Verification')
    plt.grid(True, alpha=0.3)

    # 检查是否存在奖励真空带
    gradient = np.abs(np.gradient(combined, d))
    vacuum_zone = np.where(gradient < 0.01)[0]
    if len(vacuum_zone) > 0:
        d_vacuum = d[vacuum_zone]
        if d_vacuum.min() > 0.05 and d_vacuum.max() < 2.5:
            print(f"⚠️ 奖励真空带检测到: d ∈ [{d_vacuum.min():.2f}, "
                  f"{d_vacuum.max():.2f}] m")
            print("建议:增大 sigma_reach 或减小 sigma_nav")
    else:
        print("✅ 无奖励真空带,sigma 选择合理")

# 推荐配置
verify_sigma_overlap(sigma_nav=1.0, sigma_reach=0.1)
# ✅ 无奖励真空带

# 错误配置示例
verify_sigma_overlap(sigma_nav=0.3, sigma_reach=0.05)
# ⚠️ 奖励真空带检测到: d ∈ [0.25, 0.50] m

经验法则:\(\sigma_{\text{nav}} / \sigma_{\text{reach}} \approx 5\text{-}15\) 通常效果好。如果这个比值小于 3,两个信号几乎完全重叠(nav 没有存在的必要);如果大于 20,中间会出现明显的梯度真空带。

原则 说明 例子
后续阶段权重 \(\ge\) 前序 防止策略"卡在接近不抓取" \(w_{\text{carry}} > w_{\text{reach}} > w_{\text{nav}}\)
正则项远小于任务项 正则是约束,不是目标 \(w_{\text{smooth}} \ll w_{\text{nav}}\)
stop reward 适中 太大底盘不敢动,太小不起作用 先设 0.5,看漂移再调
\(\sigma\) 和空间尺度匹配 \(\sigma\) 太小信号太尖锐 \(\sigma_{\text{nav}}\) \(\approx\) 底盘移动范围的 1/3

双框架 reward 配置对比

# mjlab reward 配置伪代码
rewards = {
    "nav": RewardTermCfg(func=base_to_object, weight=1.0,
                         params={"sigma": 1.0}),
    "heading": RewardTermCfg(func=heading_alignment, weight=0.5),
    "staged": RewardTermCfg(func=staged_mobile_reward, weight=2.0,
                            params={"reach_sigma": 0.1,
                                    "grasp_weight": 3.0,
                                    "carry_sigma": 0.3,
                                    "carry_weight": 5.0}),
    "stop": RewardTermCfg(func=base_stop_during_grasp, weight=0.5),
    "action_rate": RewardTermCfg(func=action_rate_penalty, weight=0.01),
}
# Isaac Lab 中的对应配置
class RewardsCfg:
    nav = RewTerm(func=mdp.base_to_object_distance,
                  weight=1.0, params={"sigma": 1.0})
    heading = RewTerm(func=mdp.heading_alignment, weight=0.5)
    staged = RewTerm(func=mdp.staged_mobile_reward, weight=2.0,
                     params={"reach_sigma": 0.1})
    stop = RewTerm(func=mdp.base_stop_during_grasp, weight=0.5)
    action_rate = RewTerm(func=mdp.action_rate_l2, weight=-0.01)

⚠️ 常见陷阱

⚠️ 编程陷阱:carry reward 没有被 grasp 门控 - 错误做法:carry reward 始终激活 - 后果:策略发现"用底盘把物体推到目标附近"比"抓起来搬"更容易获得 carry reward——学到了推而不是抓 - 正确做法:\(r_{\text{carry}}\) 必须被 \(\mathbb{1}[\text{grasping}]\)\(r_{\text{grasp}}\) 门控

⚠️ 思维陷阱:只看总 reward 调权重 - 错误做法:总 reward 曲线上升就认为一切正常 - 后果:\(r_{\text{nav}}\) 快速上升掩盖了 \(r_{\text{grasp}}\)\(r_{\text{carry}}\) 的停滞 - 正确做法:在 WandB 中分别记录每个 reward 子项,按时间顺序检查是否依次上升

⚠️ 编程陷阱:heading reward 的角度计算使用 atan2 但没有处理象限 - 后果:在某些朝向下角度差突然跳变,reward 产生虚假的尖峰 - 正确做法:用 base frame 中的 base-to-object 向量的方向分量,避免显式计算角度

练习

  1. [推导题] 证明当 \(r_{\text{reach}} = 0\) 时,整个 staged 项对 policy parameter 的梯度为零。这如何保证了学习的因果顺序?
  2. [实验题] 固定所有其他参数,分别用 \(\sigma_{\text{nav}} = 0.3\)\(\sigma_{\text{nav}} = 3.0\) 训练 300 iterations。观察底盘行为的差异,解释 \(\sigma\) 过小和过大各自的问题。
  3. [跨章综合题] 结合 Ch08 的 EventManager 和本节的 reward 设计,如果在训练的阶段 4 加入物体质量 DR(\(\pm\)30%),应该同时调整哪些 reward 参数?为什么?(提示:物体更重时 lift reward 的门槛需要降低)

Domain Randomization 考量 ⭐⭐

回顾 Ch08 的 DR 分阶段策略:先无 DR 训练 baseline,再逐步引入。轮式移动操作有几类特别重要的 DR 参数:

DR 参数 随机化范围 影响 引入阶段
轮地摩擦 0.4-1.2 底盘打滑和漂移 Phase 4+
物体质量 0.05-1.0 kg 抓取力需求和搬运惯性 Phase 3+
底盘质量 12-18 kg 加速性能和惯性 Phase 4+
电机增益 \(\times\)0.7-1.3 轮速跟踪精度 Phase 4+
观测噪声 \(\pm\)2 cm 位置 感知精度 Phase 4+
执行延迟 0-20 ms 底盘响应滞后 Phase 5

Wheeled Lab 的 DR 参数参考:Wheeled Lab(CoRL 2025)是目前轮式 sim2real 中 DR 参数最系统化的参考。他们在 RC 车漂移控制任务中使用了以下 DR 范围(可按比例缩放到全尺寸底盘):

参数 Wheeled Lab 范围 本章建议范围 说明
轮地摩擦 \(\mu\) \(U(0.2, 0.8)\) \(U(0.4, 1.2)\) 全尺寸底盘更重,摩擦上限更高
底盘质量 \(\times U(0.85, 1.15)\) 按比例随机化
电机增益 自定义范围 \(\times U(0.7, 1.3)\) 模拟电机老化和电压波动
IMU 加速度噪声 \(\sigma \approx 0.02\) m/s² \(\sigma \approx 0.02\) m/s² 与 Wheeled Lab 一致
IMU 陀螺仪噪声 \(\sigma \approx 0.001\) rad/s \(\sigma \approx 0.001\) rad/s 与 Wheeled Lab 一致

Wheeled Lab 的核心经验是 DR 范围需要通过 Sim2Sim 循环迭代来确定——先用宽范围训练,在 sim2sim(MuJoCo 回放)中测试,根据表现收窄范围,反复几轮直到 sim2sim 表现稳定。

每加入一项 DR,记录三个指标:grasp success rate、carry success rate、base drift。如果某项 DR 导致 success rate 下降超过 30%,说明范围过宽或策略对该参数过于敏感——应该先缩小范围,等策略适应后再逐步扩大。

反事实推理:如果不对轮地摩擦做 DR 会怎样? 策略在固定摩擦 0.8 的仿真中训练,学到的底盘控制策略完全依赖"0.8 摩擦下的特定加速度-速度关系"。部署到真机时,如果地面是瓷砖(摩擦 0.3-0.5),底盘加速时打滑、制动时滑行,之前学到的精确停车行为全部失效。这种失效不会在 reward 曲线上显现(仿真中一切正常),只有在 sim2real 时才暴露。

奖励函数 Debug 工作流 ⭐⭐

当训练不符合预期时,按以下顺序排查 reward:

步骤 检查内容 工具 典型发现
1 分项 reward 曲线 WandB / TensorBoard nav 涨但 reach 不涨
2 reward 数值范围 打印 min/max/mean 某项 reward 数值太大压制其他项
3 reward 与行为对应 Viser 可视化 + reward 标注 reward 涨但行为不对(reward hacking)
4 门控是否生效 打印 grasping 指示函数 门控阈值错误
5 sigma 是否合理 \(\exp(-d^2/\sigma^2)\) 曲线 sigma 太小导致只在极近距离有信号

有了完整的 MDP 设计(动作、观测、奖励、课程),下一步是将所有组件组装成可运行的环境并验证。这正是下节实战的内容。


21.8 实战:从零搭建轮式 + YAM 环境 ⭐⭐⭐

这一节解决什么问题:在 mjlab 中从零构建轮式底盘 + YAM 机械臂的完整环境,完成从 smoke test 到短训练的全流程验证,同时展示 Isaac Lab 中的对应实现路径。

从 Ch17 的 YAM lift cube 出发

轮式移动操作环境不是从零开始——它复用 Ch17 中 YAM 的大量组件。下表整理了可复用和需新建的部分:

组件 Ch17 YAM lift cube Ch21 轮式+YAM 差异
臂 MJCF i2rt_yam/xmls/yam.xml 复用
方块 Entity cube_spec 复用
底盘 MJCF 新建 差速两轮
组合 Scene 无底盘 新建 臂挂载到底盘上
Action JointPositionActionCfg (7D) 修改 JointVelocityActionCfg (2D)
Observation 无底盘状态 扩展 加底盘速度/朝向/轮速
Reward staged_position_reward 扩展 加 nav/heading/stop
Command LiftingCommand 修改 目标范围扩大
Termination illegal_contact + timeout 扩展 加底盘出界/倾斜
Curriculum 新建 五阶段

文件级别的具体改动清单:对于从 Ch17 lift cube 任务扩展到 Ch21 的读者,以下清单精确列出每个配置文件的改动内容:

文件 Ch17 原版 Ch21 改动 改动类型
robot_cfg.py YamCfg(单臂) 新建 WheeledYamCfg,增加底盘 entity + wheel actuator 新建
scene_cfg.py 固定 YAM + cube + table 增加 ground_plane(供底盘行驶),保留 table 作为放置方块的平台(cube 初始 z=0.82 即依赖桌面高度),加大 env_spacing=5.0 修改
actions_cfg.py JointPositionActionCfg(7D) JointVelocityActionCfg(2D) for wheels,总维度 7→9 修改
obs_cfg.py 26D actor obs 42D actor obs(+base_vel+wheel_vel+heading+b2o) 扩展
rewards_cfg.py staged_position_reward 单项 拆为 8 个 RewardTerm(nav/heading/staged/stop/smooth/energy) 重写
terminations_cfg.py timeout + illegal_contact base_out_of_bounds + base_tilt + object_drop 扩展
curriculum_cfg.py 新建 5 阶段 curriculum 新建
events_cfg.py 基础 DR(物体位置随机化) 加轮地摩擦/底盘质量/电机增益/观测噪声 DR 扩展
commands_cfg.py LiftingCommand(固定范围) 扩展范围到 1-3 m,加 navigation command 修改

这个改动清单的核心模式是增量式扩展——不重写整个环境,而是在 Ch17 的基础上添加底盘相关的 manager terms。这正是 mjlab 和 Isaac Lab 的 manager-based 架构的设计目标:每个 manager term 是独立的模块,增删不影响其他 term。

在 mjlab 中,复合机器人的 MJCF 通过 Python spec 组合实现——底盘的 spec 作为 root,臂的 spec attach 到底盘的 mount site 上:

import mujoco

def get_wheeled_yam_spec():
    """组合轮式底盘和 YAM 臂的 spec。"""
    # 加载底盘 spec
    base_spec = mujoco.MjSpec.from_file("wheeled_base.xml")

    # 加载 YAM 臂 spec
    arm_spec = mujoco.MjSpec.from_file(
        "src/mjlab/asset_zoo/robots/i2rt_yam/xmls/yam.xml"
    )

    # 在底盘顶部创建 mount site
    base_body = base_spec.find_body("base")
    mount_site = base_body.add_site(
        name="arm_mount",
        pos=[0.0, 0.0, 0.08],  # 底盘顶面
        type="sphere",
        size=[0.01],
    )

    # 将 YAM 臂 attach 到 mount site
    # 注意:attach 后 arm 的 root body 变成 base 的子 body
    arm_spec.attach_to(base_spec, mount_site)

    return base_spec

关键注意点:attach 后,YAM 臂的所有 body 成为底盘 body 的后代,共享同一个 kinematic tree。这意味着底盘移动时臂自动跟随(不需要额外的同步逻辑),但也意味着臂的运动会影响底盘的动力学(反作用力通过 kinematic tree 传递到底盘)。

名字冲突处理:如果底盘和臂的 MJCF 中有同名的 joint、body 或 geom(例如两个文件都定义了 joint_1),attach 后 MuJoCo 编译会报错或更危险地——静默覆盖。推荐的做法是在 attach 时使用 prefix 参数:

def get_wheeled_yam_spec_safe():
    """带名字冲突保护的 spec 组合。"""
    base_spec = mujoco.MjSpec.from_file("wheeled_base.xml")
    arm_spec = mujoco.MjSpec.from_file("yam.xml")

    base_body = base_spec.find_body("base")
    mount_site = base_body.add_site(
        name="arm_mount", pos=[0.0, 0.0, 0.08],
    )

    # 使用 prefix 避免名字冲突
    # attach 后,arm 的 "joint_1" 变成 "yam/joint_1"
    arm_frame = arm_spec.attach_to(
        base_spec, mount_site, prefix="yam/"
    )

    # 编译并验证
    model = base_spec.compile()
    data = mujoco.MjData(model)

    # 验证:列出所有 joint 名,确认无冲突
    joint_names = [
        model.joint(i).name for i in range(model.njnt)
    ]
    print(f"Total joints: {model.njnt}")
    print(f"Joint names: {joint_names}")

    # 验证:检查 actuator 数量
    print(f"Total actuators: {model.nu}")

    # 验证:运行一步物理,确认无报错
    mujoco.mj_step(model, data)
    assert not any(
        np.isnan(data.qpos)
    ), "NaN in qpos after mj_step!"

    return model, data

三个常见的 spec.attach() 陷阱

(1)Keyframe 丢失:底盘的 home keyframe 和臂的 home keyframe 在 attach 后不会自动合并。你需要在组合后的 spec 上手动定义一个新的 keyframe,包含底盘和臂的所有初始关节角。

(2)Actuator 引用断裂:如果 YAM 的 MJCF 中 actuator 通过 joint="joint_1" 引用关节,attach 后关节名变成了 yam/joint_1,但 actuator 引用仍然是旧名——编译失败。解决方法:确保 actuator 在 attach 之后重新绑定到新的 joint 名。

(3)初始碰撞:attach 后臂的初始位姿可能与底盘几何体重叠(尤其是 mount site 的 pos 设置不准确时)。使用 mj_step + 检查 data.ncon(接触数)来验证初始状态无自碰撞。

步骤 2:EntityCfg → SceneCfg

# mjlab 场景配置伪代码
class WheeledYamSceneCfg(SceneCfg):
    # 地形
    terrain = TerrainCfg(type="plane")

    # 复合机器人
    robot = EntityCfg(
        spec_fn=get_wheeled_yam_spec,
        init_state=EntityCfg.InitialStateCfg(
            pos=(0.0, 0.0, 0.15),
            joint_pos={
                "left_wheel_joint": 0.0,
                "right_wheel_joint": 0.0,
                "joint1": 0.0, "joint2": -0.5, "joint3": 0.5,
                "joint4": 0.0, "joint5": 0.5, "joint6": 0.0,
                "left_finger_joint": 0.04,
            },
        ),
    )

    # 方块(和 Ch17 相同)
    cube = EntityCfg(
        spec_fn=get_cube_spec,
        init_state=EntityCfg.InitialStateCfg(
            pos=(1.0, 0.0, 0.82),  # 离底盘 1m 处
        ),
    )

    # 桌子(可选,放置方块的平台)
    table = EntityCfg(
        spec_fn=get_table_spec,
        init_state=EntityCfg.InitialStateCfg(
            pos=(1.0, 0.0, 0.0),
        ),
    )

    # 配置
    num_envs = 128
    env_spacing = 5.0  # 底盘移动范围大,需要更大间距

步骤 3:ManagerBasedRlEnvCfg

class WheeledYamLiftCubeEnvCfg(ManagerBasedRlEnvCfg):
    scene = WheeledYamSceneCfg()

    actions = {
        "base_velocity": JointVelocityActionCfg(
            asset_name="robot",
            joint_names=["left_wheel_joint", "right_wheel_joint"],
            scale=2.0,
        ),
        "arm_position": JointPositionActionCfg(
            asset_name="robot",
            joint_names=["joint1", "joint2", "joint3",
                         "joint4", "joint5", "joint6"],
            scale=0.05,
            use_default_offset=True,
        ),
        "gripper": JointPositionActionCfg(
            asset_name="robot",
            joint_names=["left_finger_joint"],
            scale=0.02,
        ),
    }

    observations = {
        "actor": ObservationGroupCfg(
            terms={
                "base_lin_vel": ObsTerm(func=base_lin_vel_b),
                "base_ang_vel": ObsTerm(func=base_ang_vel_b),
                "projected_gravity": ObsTerm(func=projected_gravity),
                "arm_joint_pos": ObsTerm(func=arm_joint_pos),
                "arm_joint_vel": ObsTerm(func=arm_joint_vel),
                "gripper_pos": ObsTerm(func=gripper_pos),
                "wheel_vel": ObsTerm(func=wheel_vel),
                "base_to_object": ObsTerm(func=base_to_object_b),
                "ee_to_object": ObsTerm(func=ee_to_object),
                "object_to_goal": ObsTerm(func=object_to_goal),
                "base_to_goal": ObsTerm(func=base_to_goal_b),
                "heading_to_object": ObsTerm(func=heading_to_object),
                "last_action": ObsTerm(func=last_action),
            },
            enable_corruption=False,
        ),
    }

    rewards = {
        "nav": RewardTermCfg(func=base_to_object_reward,
                             weight=1.0, params={"sigma": 1.0}),
        "heading": RewardTermCfg(func=heading_alignment,
                                 weight=0.5),
        "staged": RewardTermCfg(func=staged_mobile_reward,
                                weight=2.0),
        "stop": RewardTermCfg(func=base_stop_during_grasp,
                              weight=0.5),
        "action_rate": RewardTermCfg(func=action_rate_penalty,
                                     weight=0.01),
    }

    terminations = {
        "timeout": TerminationTermCfg(func=time_out,
                                      time_out=True),
        "base_oob": TerminationTermCfg(
            func=base_out_of_bounds,
            params={"x_range": (-3, 3), "y_range": (-3, 3)}),
        "illegal_contact": TerminationTermCfg(
            func=illegal_arm_contact),
    }

    # Simulation 参数
    sim = SimCfg(timestep=0.002, decimation=5)
    episode = EpisodeCfg(length_s=10.0)

步骤 4:Termination 设计

轮式移动操作的 termination 比固定基座多了几类和底盘相关的终止条件:

Termination 触发条件 类型 time_out
timeout episode 长度达上限 仿真安全 True
base_oob 底盘 $ x >3$ 或 $
base_tilt 底盘倾角 > 30° 安全 False
illegal_contact 臂 link_6 碰地面 任务语义 False
object_drop 物体从夹爪掉落(被持握后 \(z\) 骤降) 任务语义 False

time_out=True 的含义回顾 Ch06:当 time_out=True 时,PPO 会对最后一步做 value bootstrapping(认为 episode 因为时间截断而非真正失败),不会把 termination 当作 absorbing state。只有超时终止应设为 True,其他(碰撞、出界、掉落)设为 False——这些是真正的任务失败。

base_tilt 是一个保守的安全终止。虽然轮式底盘在平地上几乎不倾斜,但如果臂伸到极远处且负载较重,质心偏移可能导致底盘在一侧抬起。30° 的阈值足够宽松(正常操作中倾角 < 5°),只有在严重倾斜时才触发——这种情况在真机上意味着翻车风险。

步骤 5:注册并验证

# 1. 注册验证
uv run list-envs
# 通过标准:包含 Mjlab-Mobile-Lift-Cube-Wheeled-Yam(或你选择的 task id)

# 2. Zero agent 播放
uv run play Mjlab-Mobile-Lift-Cube-Wheeled-Yam \
  --agent zero --num-envs 4 --viewer viser
# 通过标准:底盘和臂可见,zero agent 底盘不动、臂保持 home 姿态

# 3. Random agent 检查
uv run play Mjlab-Mobile-Lift-Cube-Wheeled-Yam \
  --agent random --num-envs 4 --viewer viser
# 通过标准:底盘移动但不爆飞,臂运动但不超限

# 4. Shape 验证
uv run train Mjlab-Mobile-Lift-Cube-Wheeled-Yam \
  --env.scene.num-envs 4 \
  --agent.max-iterations 1 \
  --agent.num-steps-per-env 4 \
  --gpu-ids None 2>&1 | head -20
# 通过标准:打印出 obs shape 和 action shape,维度与设计一致

# 5. Smoke test 训练
uv run train Mjlab-Mobile-Lift-Cube-Wheeled-Yam \
  --env.scene.num-envs 64 \
  --agent.max-iterations 10 \
  --agent.logger tensorboard \
  --gpu-ids None
# 通过标准:无 shape error,无 NaN,完成 10 iterations

逐阶段验证 checklist

在完整训练之前,按 curriculum 阶段逐个验证环境配置的正确性:

阶段 验证命令 通过标准
0 用 Ch17 的 YAM lift cube 策略在无底盘环境中 play 抓取成功
1 在固定底盘 + staged reward 环境中训练 500 iter grasp_success > 50%
2 在 nav-only 环境中训练 300 iter base-to-object < 30 cm
3 放开全环境训练 1000 iter grasp_success > 20%
4 加入 carry reward 训练 2000 iter carry_success > 5%

如果任何阶段不通过,在该阶段排查后再进入下一阶段。不要跳过——跳过的阶段会把 bug 带入后续阶段,定位难度指数级增长。

Isaac Lab 中的对应实现路径

Isaac Lab 中的轮式移动操作环境遵循类似的结构,但使用 USD 格式和 Isaac Lab 的 manager API:

# Isaac Lab 中的对应结构(概要)
from isaaclab.envs import ManagerBasedRLEnvCfg

class IsaacWheeledYamEnvCfg(ManagerBasedRLEnvCfg):
    scene = WheeledYamSceneCfg()  # InteractiveSceneCfg
    actions = ActionsCfg()         # 同 21.3 节的配置
    observations = ObservationsCfg()
    rewards = RewardsCfg()
    terminations = TerminationsCfg()
    curriculum = CurriculumCfg()

# 注册
gymnasium.register(
    id="Isaac-Mobile-Lift-Cube-Wheeled-Yam-v0",
    entry_point="isaaclab.envs:ManagerBasedRLEnv",
    kwargs={"env_cfg_entry_point": IsaacWheeledYamEnvCfg},
)

两个框架的核心差异在资产格式(MJCF vs USD)和物理引擎(MuJoCo Warp vs PhysX),但 manager 层的设计模式几乎一一对应。这是双框架并重教学策略的工程基础——掌握一个框架的 manager 架构后,迁移到另一个框架的主要工作是参数映射而非逻辑重构。

双框架环境搭建对照表

步骤 mjlab Isaac Lab
1. 资产定义 MJCF XML + spec_fn USD + UsdFileCfg
2. 复合组装 MjSpec.attach_to() USD scene composition
3. Scene 配置 SceneCfg(entities=...) InteractiveSceneCfg(articulations=...)
4. Action 配置 actions dict ActionsCfg dataclass
5. Obs 配置 observations dict ObservationsCfg dataclass
6. Reward 配置 rewards dict RewardsCfg dataclass
7. 环境注册 register_mjlab_task(...) gymnasium.register(...)
8. 验证 uv run play / train python scripts/rsl_rl/train.py

迁移过程中最容易出错的步骤是 Step 4(action 配置),因为两个框架中 action group 的排序规则不同。mjlab 按 dict key 的插入顺序排列,Isaac Lab 按 dataclass field 的声明顺序排列。如果排序不一致,网络输出的前几维被发给了错误的 actuator——底盘收到臂的信号、臂收到底盘的信号。

性能考量与优化策略

轮式移动操作比固定基座操作的计算成本更高,主要来自三个方面:

开销来源 影响 缓解策略
轮地接触 每个轮子每步都有接触求解 减少被动轮数量
更大的场景 env_spacing 增大,GPU 内存占用增加 合理设置 spacing(刚好不重叠)
更长的 episode 移动+抓取+搬运需要更多步数 阶段性训练,前期用短 episode
更多 action 维度 PPO rollout 存储增加 不影响吞吐,但影响内存
更多 obs 维度 网络输入增大 影响较小,MLP 吞吐对维度不太敏感

在 4090 GPU 上,128 个并行环境的轮式+YAM 训练,预期 steps/s 约为固定 YAM 的 60-80%。如果添加 camera sensor,预期进一步下降到 30-50%。

优化清单(按收益排序):

优化项 预期收益 实现难度 何时做
减少被动轮(用 1 个球代替 2 个万向轮) 15-20% 建模阶段
使用 condim=3 替代 condim=4(如果不需要扭转摩擦) 5-10% 调参阶段
episode 前期用短时长 20-30% curriculum 设计
减少 obs 中的冗余项 5% 训练稳定后
增大 decimation(如 5→10) 30-50% 需重新调参

跨领域类比:性能优化和代码优化类似——先 profile 确认瓶颈在哪,再针对性优化。不要一开始就在"轮子接触太慢"上花时间——也许瓶颈其实在 reward 计算的 Python 侧。用 torch.profilermj_step 计时来确认。

Sim2Real 准备度检查清单 ⭐⭐

虽然 Sim2Real 部署是 Ch23 的主题,但轮式移动操作有几个 sim2real 相关的问题应该在环境搭建阶段就考虑,否则后续修复成本很高:

检查项 仿真中如何验证 忽略的后果
底盘惯性参数 对比 MJCF 中的质量/惯量与真机 spec sheet 加速/减速行为不匹配,导航精度下降
轮径和轮距 mj_step 验证直线行驶 1m 是否精确 里程计积分漂移,底盘走弧线
执行延迟 在 obs 中加 action_delay DR(0-20ms) 真机上底盘刹不住车,过冲严重
关节摩擦 臂在 zero torque 下是否自然下垂 仿真中臂能悬停,真机上臂因重力下垂
夹爪力 抓取时接触力是否在真机夹爪的力矩范围内 仿真能抓住但真机夹不紧

最关键的一项是底盘执行延迟。真实底盘从接收速度指令到轮子达到目标转速,通常有 20-80 ms 的延迟(取决于电机控制器和通信协议)。如果仿真中不模拟这个延迟,策略会学到"发出指令后底盘立即响应"的假设。部署到真机时,这个假设破裂——底盘总是比策略预期慢一拍,导致过冲和振荡。延迟应在 actuator 层建模,而不是写成自动 step event(mdp.randomize_action_delay 不是框架内置事件,mode="step" 也不会每步自动触发):

# mjlab:在对应 ActuatorCfg 上配置 delay(字段名以目标版本 actuator 文档为准),自动作用于 command 目标
# Isaac Lab:使用 DelayedPDActuatorCfg(min_delay=..., max_delay=...) 在 actuator 层注入延迟
# 若课程自定义了 randomize_action_delay,需说明它是本教程自定义函数 + 配套 action wrapper,
# 并在 action 真正下发前从缓冲区取出延迟动作(仅在 reset 时随机化 delay 步数是不够的)。

⚠️ 常见陷阱

⚠️ 编程陷阱:env_spacing 太小 - 错误做法:用 Ch17 的 env_spacing=2.0 - 后果:底盘移动范围超出 env 边界,撞到相邻 env 的物体 - 正确做法:env_spacing \(\ge\) 底盘最大移动范围的直径 \(\times\) 1.5(推荐 5.0+)

⚠️ 编程陷阱:attach 后 joint 名字冲突 - 错误做法:底盘和臂的 MJCF 中有同名的 joint - 后果:MuJoCo 编译报错或 action manager 把 action 发给错误的 joint - 正确做法:确保底盘和臂的所有 body/joint/site/geom 名字不冲突

⚠️ 思维陷阱:先训练完整任务再 debug - 错误做法:直接在完整的移动搬运任务上训练,reward 不涨后开始调参 - 后果:无法区分是物理建模、动作空间、观测还是奖励的问题 - 正确做法:严格遵循 21.5 节的五阶段 curriculum,每个阶段验证通过后再进入下一个

💡 编程陷阱:Isaac Lab 中 USD 挂载方向错误 - 错误做法:在 USD 中把臂挂载到底盘时旋转方向搞错了 - 后果:臂朝下或朝后,末端无法到达工作区域 - 正确做法:先在 Isaac Sim viewer 中可视化 USD,确认臂的朝向正确

练习

  1. [实践题] 按上述步骤在 mjlab 中搭建轮式+YAM 环境,完成 smoke test 四步验证。记录每一步的通过情况。
  2. [源码桥接题] 画三列表:Ch17 源码 → 可复用组件 → Ch21 新增组件。覆盖 action、command、reward、observation、termination 五个 manager。
  3. [跨章综合题] 结合 Ch08 的 Domain Randomization 和 Ch09 的 Teacher-Student,为轮式移动操作设计完整的 Phase 5 训练方案:(a) 列出至少 6 项 DR 参数和范围,(b) 设计 critic privileged observation(teacher 信号),(c) 说明如何验证 student(去掉 privileged 后)的性能下降在可接受范围内。

本章小结

知识点 核心要点 与前后章节的关系
轮式形态定位 底盘稳定但有非完整约束 Ch19 足式对比、Ch22 自定义扩展
差速 vs 全向 差速不能横移,全向可以 MJCF/USD 建模基础
非完整约束建模 通过轮地摩擦自然约束 影响 obs 和 reward 设计
混合动作空间 轮速(velocity) + 臂(position),分组 scale Ch05 action 设计原则
量纲归一化 每维 \([-1,1]\) 产生相近任务后果 Ch19 同类问题
Observation 设计 base frame 相对向量 + 朝向编码 Ch05 马尔可夫性原则
五阶段 curriculum fixed reach → grasp → nav → mobile grasp → carry Ch06 curriculum 设计
底盘漂移 臂反作用力 + 轮地摩擦不足 轮式独有问题
Stop reward 抓取阶段惩罚底盘速度 21.6 节核心方案
Staged reward 乘法门控保证因果顺序 Ch17 staged reward 扩展
环境搭建 spec attach → scene → env cfg → register → verify Ch22 DIY 流程对照

本章的核心工程经验可以浓缩为一句话:移动操作的每一个子系统(底盘、臂、夹爪)都可以独立验证,但集成后涌现出的问题(漂移、量纲不匹配、奖励真空带)才是真正的工程挑战。这就是为什么本章的实战流程强调逐阶段验证而非一次性集成——先在固定底盘上确认抓取能力,再引入导航,最后组合全流程。这种"分治-集成-验证"的方法论不仅适用于轮式移动操作,也适用于 Ch22 中任何你自己设计的机器人系统。

累积项目:本章新增模块

累积项目 G 进度:本章完成了轮式底盘 + YAM 机械臂的完整移动搬运环境设计。新增模块包括差速底盘 MJCF、spec attach 组合机器人、混合 action 配置(velocity + position)、导航和搬运扩展 reward、五阶段 curriculum 和底盘漂移对策。

项目链条:Ch17(固定基座 YAM lift cube)→ Ch21(轮式 + YAM 移动搬运) → Ch22(DIY 自定义,可选轮式双臂变体)→ Ch23(Sim2Real 部署检查)。

项目 G 交付物清单

# 交付物 文件/代码位置 验收标准
G1 差速底盘 MJCF assets/wheeled_base.xml viewer 中可执行直行、原地旋转、不能横移
G2 底盘+YAM 组合 MJCF assets/wheeled_yam.xml(spec attach 生成) mj.mj_step 无报错,joint 名不冲突
G3 混合 Action 配置 envs/.../actions_cfg.py zero agent 下臂不抽搐、底盘不动
G4 Observation 配置 envs/.../obs_cfg.py 打印 obs 维度匹配表 21.4 规格
G5 五阶段 Curriculum envs/.../curriculum_cfg.py Phase 0 reach success > 50% 才推进到 Phase 1(grasp);与 21.5 阶段定义一致
G6 Staged + Stop Reward envs/.../reward_cfg.py WandB 中各子项曲线依次上升
G7 短训练 checkpoint logs/wheeled_yam/ Phase 2+ grasp success > 30%(~2000 iterations)

后续扩展方向:加入深度相机视觉输入(参考 Ch18 视觉管线)、加入多物体搬运(参考 Ch17 multi-cube)、升级为轮式双臂(两个 YAM 协作)。

从单臂到双臂的扩展路径

本章的"双臂"标题指的是轮式底盘搭载双臂的系统形态,但教学实战中使用的是单臂(一个 YAM)。从单臂扩展到双臂涉及以下工程变化:

维度 单臂(本章实现) 双臂(扩展)
Action 维度 2(base) + 6(arm) + 1(gripper) = 9 2 + 6\(\times\)2 + 1\(\times\)2 = 16
Observation 新增 双臂末端间距、左右臂对称性 obs
Reward 新增 双臂协调奖励(两末端距离匹配)
核心挑战 底盘-臂耦合 底盘-左臂-右臂三方耦合 + 双臂碰撞避免
典型任务 单物体抓取搬运 大物体双手抬起、开盒盖、倒水

双臂系统的关键工程决策是两只臂是否共享一个策略。方案一:完全对称——一个策略网络输出 16 维动作,两只臂通过 obs 中的对称编码(左右互换)实现协调。方案二:主从架构——一只臂的策略生成目标轨迹,另一只臂的策略跟踪。方案一更简单但对称性假设限制了非对称任务;方案二更灵活但需要设计 inter-arm communication 的 obs 通道。

如果读者有兴趣挑战双臂扩展,建议在 Ch22 的 DIY 项目中实现,以本章的单臂环境为起点,先添加第二只臂的 MJCF(通过 spec attach 挂载到底盘的另一侧 site),再扩展 action 和 obs 配置,最后设计双臂协调的 reward。

延伸阅读

资源 内容 难度
awesome-loco-manipulation(✅)github.com/aCodeDog/awesome-loco-manipulation 移动操作 URDF 集合(Ridgeback-UR5、Go2-Arx 等) ⭐⭐
MuJoCo XML Reference:actuator/velocity velocity actuator 的参数详解
Isaac Lab Manager-Based RL Env Tutorial 从零创建 ManagerBasedRLEnv 的官方教程 ⭐⭐
Gu et al. 2017 "Deep RL for Robotic Manipulation" 操作中 RL 的早期系统性工作 ⭐⭐⭐
Lynch & Park "Modern Robotics" Ch13 非完整约束和轮式运动学的经典教材推导 ⭐⭐⭐
Khatib 1987 "A Unified Approach for Motion and Force Control" 操作空间控制的理论基础,DiffIK 的学术根源 ⭐⭐⭐⭐
Dubins 1957 "On Curves of Minimal Length" 非完整系统最短路径的数学基础,帮助理解差速底盘规划的本质难度 ⭐⭐⭐⭐
MuJoCo spec.attach() 文档 组合多个 MJCF 模型的官方 API 参考 ⭐⭐
RSL-RL EmpiricalNormalization 源码 理解 running mean/std 观测归一化的实现细节 ⭐⭐

🔧 故障排查手册

以下手册覆盖了轮式移动操作中最常见的 10 个故障模式。遇到问题时,先定位症状所在行,然后按排查步骤逐一检查。大多数问题可以在 5 分钟内通过打印关键变量定位到根因。

使用提示:如果同时出现多个症状(比如"底盘不动"+"reward 不涨"),先解决物理层的问题(底盘不动),再排查奖励层。物理建模错误会导致所有上层机制失效。

症状 可能原因 排查步骤 相关节
底盘不动 轮子 condim 设错 / velocity actuator 配置错 1. 检查轮子 condim \(\ge\) 3 2. 检查 actuator 类型是 velocity 3. 用 mj_step + 非零 ctrl 手动测试 21.2
臂在 zero agent 下抽搐 use_default_offset 未设置 / 关节限位不含 0 1. 检查 action cfg 的 use_default_offset 2. 检查 MJCF 中关节 range 3. 打印 home keyframe 值 21.3
底盘在抓取时滑走 轮地摩擦不足 / 无 stop reward 1. 打印轮子接触力 2. 增大轮子 friction 3. 添加 stop reward 4. 检查臂力矩峰值 21.6
reward 只有 nav 在涨 末端无法到达物体 / reach sigma 太小 1. 检查臂可达范围是否覆盖物体位置 2. 增大 reach sigma 3. 检查 cube 高度 vs 臂 workspace 21.7
Isaac Lab action 维度不匹配 action group 排序错误 1. 打印 action_manager.action_term_dim 2. 确认 field 名排序 3. 比较网络输出维度 21.3
多环境训练时 obs 异常 绝对 world 位置混入 obs / 跨 frame 混用(注意相对向量相减本身已抵消 env origin) 1. 打印相对向量 2. 比较不同 env_id 的值 3. 检查 base_to_object 函数与 command 的 frame 是否一致 21.4
attach 后 joint 名字冲突 底盘和臂有同名 joint 1. 列出所有 joint name 2. 重命名冲突项 3. 重新 compile 21.8
Curriculum 卡在 Phase 0 不推进 reach success 阈值太高 / 物体超出臂可达范围 1. 打印 success rate 曲线 2. 降低推进阈值到 30% 3. 检查物体 spawn 范围 vs 臂 workspace 21.5
底盘原地旋转但不前进 两轮反向等速 / 底盘朝向 obs 错误 1. 打印左右轮速 2. 检查 heading reward 计算 3. 用 viewer 确认底盘朝向 21.2, 21.7
策略推物体而不抓取 carry reward 未被 grasp 门控 1. 检查 carry reward 是否乘以 is_grasping 2. 查看 viewer 中末端是否尝试闭合 3. 增大 grasp reward 权重 21.7

下一章预告:Ch22 将把本章学到的环境搭建方法论推广到任意机器人——你将从零开始设计自己的 RL 环境,无论是轮式双臂、飞行抓取还是你自己的创意机器人。本章的"spec attach → scene → env → register → verify"五步法将是 Ch22 的起点。