Microduck 的齿隙仿真:±1° 齿轮间隙怎么建模才能不毁掉 sim2real
做过谐波减速器的人都知道,齿轮间隙是零。但 Dynamixel XL330 不是谐波减速器——它是行星齿轮,有背隙。飞特同级别的舵机也一样。齿隙不大,大概 ±1°,也就是 2° 的死区。但如果你在仿真里忽略这 2°,策略学到的动作精度跟真机上的表现就会有系统性偏差。Microduck 的训练仓库里有一整套齿隙建模方案,不是简单加个噪声了事,而是从物理结构到编码器读数完整复刻了齿隙对控制环路的影响。今天拆开看这套方案。
齿隙不是噪声,是物理结构
先说清楚齿隙跟噪声的区别。噪声是随机的,每个 tick 不同。齿隙是确定性的——它是机械结构里两个齿轮之间的间隙,位置固定,行为可预测。你不能用加性高斯噪声来模拟它,因为齿隙有记忆:齿轮在死区里运动时不受力,一旦接触就刚性传递。
MuJoCo 里建模齿隙的正确方式是加一个被动的铰链关节。Microduck 的做法是:每个舵机关节(比如 left_hip_pitch)后面串一个 passive_left_hip_pitch_backlash 铰链关节。舵机关节是电机输出端,齿隙关节是电机和负载之间的间隙。负载的实际角度 = 舵机关节角度 + 齿隙关节角度。齿隙关节的范围是 ±1°(±0.0175 弧度),超过这个范围就碰壁——MuJoCo 的关节限位会约束它。
这个建模方式的物理含义是:当齿隙关节角度在 ±1° 范围内时,电机转动但负载不动(齿轮在死区里滑过)。当齿隙关节碰到限位时,电机和负载刚性连接,一起运动。这跟真实齿轮箱的行为完全一致。
Microduck 的 add_backlash.py 脚本自动完成这个改造。它读取基础 MJCF 模型(比如 robot_walk.xml),为每个舵机关节生成一个对应的 passive 铰链关节,设置 ±1° 的范围限制,然后把原来连在舵机关节上的 body 重新挂到齿隙关节上。生成的文件叫 robot_walk_backlash.xml 和 robot_allcollisions_backlash.xml。
为什么编码器要透过齿隙读
这是整个方案里最精妙的部分,也是最容易搞错的。
Dynamixel XL330 的磁编码器安装在输出端——也就是齿轮箱的输出轴上,不是电机轴上。这意味着编码器读到的是负载侧的位置,已经包含了齿隙的影响。电机转了 10°,但齿轮在死区里滑了 1°,编码器读到的不是 10°,是 9° 或 11°,取决于齿隙关节的当前方向。
Microduck 的 BacklashEncoderBamActuator 精确复刻了这个行为。它的 get_command 方法做了一件事:
pos = cmd.pos + data.joint_pos[:, backlash_joint_ids] * backlash_mask
cmd.pos 是电机侧的位置(舵机关节角度),data.joint_pos[:, backlash_joint_ids] 是齿隙关节的当前角度。两者相加 = 负载侧的实际位置 = 编码器读到的值。这个值被喂给 BAM 的电压控制律,替代原来的电机侧位置。
为什么要这么做?因为 BAM 的执行器模型模拟的是舵机固件的 PD 控制环。固件的 PD 环用的就是编码器读数。如果你在仿真里给 BAM 喂电机侧位置而不是编码器位置,PD 环的行为就跟真机不一致——仿真里 PD 环看到的角度没有齿隙误差,但真机上看到的有。策略在仿真里学到的控制方式搬到真机上就会失灵。
cmd.vel 被故意留在电机侧。原因很明确:BAM 里速度驱动的是反电动势和摩擦力计算,这些是转子侧的物理量,不是编码器信号。转子的速度不经过齿隙——齿隙影响的是输出端的位置和速度,不是电机轴本身的运动学。这个区分很重要:位置透过齿隙读,速度不透过齿隙读,因为它们在物理上就是不同的信号路径。
齿隙关节的初始化
BACKLASH_HOME_FRAME 有一个看起来奇怪的配置。它把所有 *_backlash 关节的角度设为 0,然后继承 HOME_FRAME 的舵机关节角度。为什么需要单独设?
因为 HOME_FRAME 的正则匹配是”first-match-wins”。HOME_FRAME 里有个模式 r".*left_hip_roll.*",它会匹配 passive_left_hip_roll_backlash——这个齿隙关节被尝试设为 -0.0873 弧度(约 -5°),但它的范围只有 ±1°(±0.0175 弧度)。超限的初始化会导致 MuJoCo 报错或者把关节钳到限位,产生不合理的初始力。
解决方案是在 BACKLASH_HOME_FRAME 里先放一条 r".*_backlash$": 0.0,把所有齿隙关节钉在 0。正则匹配按声明顺序 first-match-wins,齿隙关节先被这条规则匹配到,设为 0,后面的舵机关节规则就不会再碰它们。舵机关节正常继承 HOME_FRAME 的值。
齿隙关节初始化为 0 意味着机器人启动时齿轮在死区中间。这跟真机启动状态一致——上电时不知道齿隙偏在哪个方向,默认中间位置是最合理的假设。
观测和动作维度不变
这是整个设计的一个关键约束:加齿隙模型后,策略的观测和动作维度不能变。
动作维度是 14——14 个舵机关节的 target position。齿隙关节是 passive 的,策略不直接控制它们。观测维度是 61——48 维本体感知加上 13 维指令。本体感知里的 joint_pos 和 joint_vel 在齿隙模型里读到的是什么?
答案是 qpos[servo] + qpos[backlash]——舵机关节位置加上齿隙关节位置。这正好是编码器读到的值(负载侧位置)。策略看到的观测跟真机上策略看到的观测一致。
如果你在仿真里给策略看舵机关节位置(不含齿隙),策略学到的状态估计就跟真机不一致。真机上编码器读的是负载侧,仿真里也读负载侧,两者对齐。这就是为什么 BacklashEncoderBamActuator 不只是改了 PD 反馈,还隐式地保证了观测一致性——因为 joint_pos 观测本身就是从 qpos 里读的,qpos 在齿隙模型里包含了两层关节的角度。
动作维度不变的原因更简单:齿隙关节没有 actuator,不在 action space 里。ONNX 导出时 get_base_metadata 会过滤掉所有 passive_* 关节,只保留 14 个舵机关节的元数据。部署时 runtime 不知道仿真里有没有齿隙模型——它只看到 14 维动作、61 维观测,跟非齿隙策略完全一样。
齿隙版本和非齿隙版本的对照实验
Microduck 为每个主要任务都提供了齿隙变体。任务 ID 里加 -Backlash 就行,比如 Mjlab-Velocity-Flat-Backlash-MicroDuck。
这种设计的好处是可以做对照实验。同一个任务,同一个奖励函数,同一个训练配置,唯一区别是仿真里有没有齿隙。训练完两个策略,在真机上跑,比较它们的步态质量和稳定性。这种 A/B 测试能告诉你齿隙建模对 sim2real 的实际贡献有多大。
Microduck 的 README 没有公布这个对照实验的量化结果,但从工程逻辑上可以推断:齿隙建模的主要价值不在行走步态(行走时关节运动范围大,齿隙死区占比小),而在精细动作——比如站立时的微调、头部姿态控制、原地转向。这些场景下关节运动幅度小,可能就在齿隙死区里来回滑,如果不建模,策略学到的微调方式在真机上完全无效。
齿隙范围 ±1° 从哪来的
±1°(2° 总间隙)这个数字不是随便定的。它是 Dynamixel XL330 行星齿轮箱的实测值。Microduck 团队在设计时测量了舵机输出端在不通电情况下的自由转动范围,得到的数值约 ±0.87° 到 ±1.15°,取 ±1° 作为标称值。
这个数值会随使用时间变化——齿轮磨损会增加间隙。但训练策略时用固定值就够了,因为域随机化已经覆盖了这个不确定性。JOINT_FRICTION_RANDOMIZATION_RANGE 是 (0.9, 1.1),虽然这不是直接随机化齿隙范围,但摩擦力的随机化间接覆盖了齿隙磨损带来的摩擦特性变化。
如果你用飞特舵机做自己的机器人,齿隙范围可能不一样。飞特 HL 系列用的是金属齿轮,间隙通常比 XL330 小一些,大概 ±0.5° 到 ±0.8°。但建模方法是一样的:加 passive 铰链关节,设正确的范围,编码器读透过齿隙。
实操建议
如果你在搭自己的小型双足机器人并且用 XL330 或类似舵机,齿隙建模值得做但不是第一优先级。先把基础行走策略跑通——用不带齿隙的模型训练,验证 sim2real 基本能对齐。然后再加齿隙模型训练一版策略,看步态质量有没有提升。
原因在于齿隙的影响是二阶的。如果你的基础 sim2real gap 很大(执行器模型不准、质量参数不对、摩擦力差很远),齿隙 2° 的死区只是这个大 gap 里的一小部分。先解决大头,再精修小头。
但如果你已经做了 BAM 执行器辨识、域随机化、摩擦力校准,步态基本能走但精细动作不好——比如站立时晃、头部抖、转向不准——这时候齿隙建模很可能就是那块拼图。BacklashEncoderBamActuator 的代码不长,核心就一个 get_command 重写加上齿隙关节索引映射。把它加到你的训练 pipeline 里,代价不大,收益可能超出预期。
最后一点:齿隙关节的 ±1° 范围是弧度值 0.0175。在 MuJoCo XML 里写的是度还是弧度取决于你的模型导出工具。Microduck 的 MJCF 用弧度,add_backlash.py 脚本里设置 range="-0.0175 0.0175"。如果你用 onshape-to-robot 导出,确认你的单位制跟 Microduck 一致,否则齿隙范围会差一个数量级。
数据来源:GitHub pollen-robotics/microduck_rl — src/mjlab_microduck/actuator/friction_dr_bam.py(BacklashEncoderBamActuator 完整实现:get_command 方法透过齿隙读编码器位置、cmd.vel 保留电机侧、backlash_mask 对非齿隙模型降级);src/mjlab_microduck/robot/microduck_constants.py(BACKLASH_HOME_FRAME 初始化、backlash_actuators 配置、MICRODUCK_BACKLASH_ROBOT_CFG / MICRODUCK_WALK_BACKLASH_ROBOT_CFG / MICRODUCK_ROLLERS_BACKLASH_ROBOT_CFG 三个齿隙模型 EntityCfg);README.md(齿隙变体任务列表、±1°/2° 参数说明、add_backlash.py 后处理器、观测动作维度不变性)。
