摘要
本文面向无真机、需对启元 Q1 做离线仿真与二次开发的工程师。官方随 SDK 提供三份 MuJoCo MJCF:q1_v3.xml(显示用,限位近似)、q1_v3_full_range.xml(全范围,精确限位)、q1_v3_mc.xml(控制用,含 22 电机、0.001 步长、0.004 惯量)。三文件 22 关节区间因精度不一致,混用必踩坑。文章用 cmp_mjcf.py 量化差异,给出显示/运动学/全范围选型决策表。
📌 版本声明:本文基于 PrimeBot Q1 SDK(aimdk_msgs / primebot-q1-sdk),配套官方文档 dev.primebot.com,无真机离线分析。
文章目录
-
- 摘要
- [一、为什么官方要发三份 MJCF?同一机器人三种打开方式](#一、为什么官方要发三份 MJCF?同一机器人三种打开方式)
- [二、三份文件各是什么:显示用 / 全范围 / 运动学](#二、三份文件各是什么:显示用 / 全范围 / 运动学)
-
- [2.1 q1_v3.xml:显示用,只为"好看"](#2.1 q1_v3.xml:显示用,只为"好看")
- [2.2 q1_v3_full_range.xml:全范围,精确但"光身子"](#2.2 q1_v3_full_range.xml:全范围,精确但"光身子")
- [2.3 q1_v3_mc.xml:运动学/控制用,仿真主战场](#2.3 q1_v3_mc.xml:运动学/控制用,仿真主战场)
- [三、用 cmp_mjcf.py 把差异量化出来](#三、用 cmp_mjcf.py 把差异量化出来)
-
- [3.1 load():把一个 MJCF 读成字典](#3.1 load():把一个 MJCF 读成字典)
- [3.2 main():对齐三份文件并打印 diff](#3.2 main():对齐三份文件并打印 diff)
- [3.3 跑起来:用法与预期输出](#3.3 跑起来:用法与预期输出)
- [四、22 个关节 range 的精度陷阱:显示文件为什么不能当真理](#四、22 个关节 range 的精度陷阱:显示文件为什么不能当真理)
-
- [4.1 怎么单独把"区间 + 偏差"扒出来](#4.1 怎么单独把"区间 + 偏差"扒出来)
- [4.2 偏差到底有多大:节选对比](#4.2 偏差到底有多大:节选对比)
- [4.3 偏差分布并不均匀:上肢比下肢更"受伤"](#4.3 偏差分布并不均匀:上肢比下肢更"受伤")
- [4.4 把差异固化成验收清单:防止它再次咬人](#4.4 把差异固化成验收清单:防止它再次咬人)
- [五、mc.xml 的 motors + 小步长 + 转动惯量:控制器仿真的三件套](#五、mc.xml 的 motors + 小步长 + 转动惯量:控制器仿真的三件套)
-
- [5.1 22 个电机:控制器能"使上劲"的前提](#5.1 22 个电机:控制器能"使上劲"的前提)
- [5.2 22 个 motor 如何一一映射到 22 个关节](#5.2 22 个 motor 如何一一映射到 22 个关节)
- [5.3 timestep 与 armature:让刚性接触"稳"下来的关键](#5.3 timestep 与 armature:让刚性接触"稳"下来的关键)
- [5.4 mc.xml 的关键 XML 片段:三件套长这样](#5.4 mc.xml 的关键 XML 片段:三件套长这样)
- [5.5 为什么是 0.001 而不是更小?步长与算力的权衡](#5.5 为什么是 0.001 而不是更小?步长与算力的权衡)
- [六、full_range 探索边界的价值与风险](#六、full_range 探索边界的价值与风险)
- 七、什么时候用哪份:选型决策表
- 八、适用边界与风险提示
- 九、总结
- 参考资料
一、为什么官方要发三份 MJCF?同一机器人三种打开方式
做机器人仿真的人都知道一个矛盾:一份模型文件如果既要"渲染得好看",又要"算得准",还要"能驱动控制",这三个目标其实是互相打架的。渲染在乎的是网格、材质、相机;控制仿真在乎的是步长、执行器、传感器和转动惯量;而边界探索在乎的是关节限位是否精确、能否真实反映硬件可达空间。
启元在 PrimeBot Q1 的 SDK 里没有只给一份"万能"MJCF,而是随包提供了三份变体,分工明确。这不是冗余,而是工程上"按用途裁剪"的标准做法。可惜很多开发者拿到三个文件第一反应是"哪个是对的",然后随手挑一个加载,结果在查看器里看到的范围、在控制器里跑出来的稳定性、在逆解里算出的可达空间,三者对不上,debug 半天发现是文件用错了。
本文的目标就是把这个"选型问题"一次说清楚。我会先用一个量化脚本 cmp_mjcf.py 把三份文件逐字段扒开,再逐个讲清显示用、全范围、运动学三者的职责边界,最后给你一张可以直接抄的选型决策表。读完你应该能一句话回答:"我这个任务,该用哪份文件。"
举个真实会发生的例子:你在查看器里用显示文件摆了一个抬手打招呼的 pose,看着肩部转得挺自然,于是把这个关节角写进控制器。结果一上 mc 仿真,同样的角离真值允许的边界还差一大截,动作缩手缩脚;反过来,你若以 mc 的精确边界设了"安全护栏",再回显示文件里校验,又会发现显示文件根本到不了那个角,于是你误以为硬件也到不了。这种"三份文件各说各话"的错位,根源就是限位精度不统一------而它本可以通过"只用一份文件做真理源"来彻底规避。
二、三份文件各是什么:显示用 / 全范围 / 运动学
在动手对比之前,先建立对三份文件的整体认知。它们是同一个机器人(26 个 link、25 个关节,其中 22 个可动 revolute 关节加 3 个 fixed,外加一个自由浮基 freejoint)的三种"配置视图",骨架相同,但关键参数被针对性地改写了。
2.1 q1_v3.xml:显示用,只为"好看"
这份文件是给 MuJoCo 查看器(或者任何只需要把机器人画出来的人)用的。它最大的特点是"轻":没有 <actuator> 执行器段,没有 <option timestep> 覆盖(用默认 0.002),没有 <default> 里的 armature 转动惯量,也没有 <sensor> 传感器段。joint 上只挂了一个 actuatorfrcrange(被动力限),不是真正的电机。
它另一个关键特征是关节限位用了近似值 。比如 left_hip_pitch_joint 写成 -2.95 1.47,而真实 URDF 值是 -3.0543 1.5708;left_knee_joint 写成 0.1 2.34,真实值是 0 2.4435。这些近似是为了让查看器里摆 pose 时不被奇怪的边界卡住,但代价是它不能当作硬件限位真理。
2.2 q1_v3_full_range.xml:全范围,精确但"光身子"
这份文件把关节限位换成了从 URDF 直接取来的精确值(与下面的 mc 完全一致),但它同样没有电机、没有步长、没有传感器。它的定位是"纯模型 + 真实边界":当你只关心机器人能摸到的空间有多大、做逆运动学能不能到达某个末端位姿、某个构型是否自碰撞时,用它就够了,不必背上控制的负担。
"全范围"这个名字的真实含义是"限位是完整的真实范围,而不是显示用的近似值"。它和 mc 在关节区间上完全相同,区别只在于 mc 额外加了控制所需的全部脚手架。
2.3 q1_v3_mc.xml:运动学/控制用,仿真主战场
这是三份里最"重"的一份,也是做控制器仿真(MPC、WBC、强化学习)时必须用的。它在 full_range 的精确限位基础上,补上了三件套:<option timestep="0.001"> 把步长从默认 0.002 砍半;<default class="q1"> 里给所有关节加了 armature="0.004" 等效转动惯量;<actuator> 里写了 22 个 <motor> ,腿 12 个、腰 2 个、臂 8 个,并配了 <sensor>(IMU + 22 个 jointpos + 22 个 jointvel)。
另外注意一个容易忽略的细节:mc 的 pelvis 初始高度是 0 0 0.419,而显示/全范围两份是 0 0 0.48。这 6 厘米出头的差异,是控制文件刻意把质心初始姿态放低、给动力学一个更稳的起步点。如果你把显示文件的高度当成 mc 的初始状态,仿真第一帧就会"踩空"。
下表把三者的关键维度摆到一起,先建立全局印象:
| 对比维度 | q1_v3.xml(显示用) | q1_v3_full_range.xml(全范围) | q1_v3_mc.xml(运动学控制用) |
|---|---|---|---|
| 主要用途 | 外观渲染 / 查看器 | 可达空间 / 逆解边界 | 控制器仿真 |
| 关节限位 | 近似值(如 -2.95~1.47) | 精确 URDF 值 | 精确 URDF 值 |
| 电机 actuator | 无 | 无 | 22 个 motor |
| timestep | 默认 0.002 | 默认 0.002 | 0.001 |
| armature 转动惯量 | 无 | 无 | 0.004 |
| 传感器 sensor | 无 | 无 | IMU + 44 个关节传感 |
| pelvis 初始高度 | 0.48 | 0.48 | 0.419 |
| compiler 标志 | 无 | 无 | autolimits=true |
| 文件复杂度 | 最简 | 中等 | 最复杂 |
这张表里有一个容易被当成"笔误"的细节值得单独点名:pelvis 初始高度在三份文件里并不相同。显示用和全范围都是 0 0 0.48,而 mc 是 0 0 0.419,差了约 6.1 厘米。这不是疏忽,而是控制文件的刻意设计------把机器人初始质心放低一点,相当于给动力学仿真一个更接近"微屈膝、重心下沉"的稳定起步姿态,避免第一帧就因为站得太直而失去平衡。如果你从显示文件抄了 0.48 当作 mc 的初始状态,仿真一开始机器人就会"凭空下落"一小段,不仅多算几帧不必要的接触冲击,还可能触发初始自碰撞告警。所以三份文件连"出生点"都不一样,印证了开头那句话:它们是按用途裁剪的,不能互相替代。
选型的第一步,其实不是比参数,而是先问自己"我要拿这个模型干什么"。下面这张流程图把"目标 → 文件"的映射直接画出来,后面章节再逐一解释每个分支背后的原因。
#mermaid-svg-T07XNjDly5Ie99fK{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-T07XNjDly5Ie99fK .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-T07XNjDly5Ie99fK .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-T07XNjDly5Ie99fK .error-icon{fill:#552222;}#mermaid-svg-T07XNjDly5Ie99fK .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-T07XNjDly5Ie99fK .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-T07XNjDly5Ie99fK .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-T07XNjDly5Ie99fK .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-T07XNjDly5Ie99fK .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-T07XNjDly5Ie99fK .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-T07XNjDly5Ie99fK .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-T07XNjDly5Ie99fK .marker{fill:#333333;stroke:#333333;}#mermaid-svg-T07XNjDly5Ie99fK .marker.cross{stroke:#333333;}#mermaid-svg-T07XNjDly5Ie99fK svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-T07XNjDly5Ie99fK p{margin:0;}#mermaid-svg-T07XNjDly5Ie99fK .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-T07XNjDly5Ie99fK .cluster-label text{fill:#333;}#mermaid-svg-T07XNjDly5Ie99fK .cluster-label span{color:#333;}#mermaid-svg-T07XNjDly5Ie99fK .cluster-label span p{background-color:transparent;}#mermaid-svg-T07XNjDly5Ie99fK .label text,#mermaid-svg-T07XNjDly5Ie99fK span{fill:#333;color:#333;}#mermaid-svg-T07XNjDly5Ie99fK .node rect,#mermaid-svg-T07XNjDly5Ie99fK .node circle,#mermaid-svg-T07XNjDly5Ie99fK .node ellipse,#mermaid-svg-T07XNjDly5Ie99fK .node polygon,#mermaid-svg-T07XNjDly5Ie99fK .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-T07XNjDly5Ie99fK .rough-node .label text,#mermaid-svg-T07XNjDly5Ie99fK .node .label text,#mermaid-svg-T07XNjDly5Ie99fK .image-shape .label,#mermaid-svg-T07XNjDly5Ie99fK .icon-shape .label{text-anchor:middle;}#mermaid-svg-T07XNjDly5Ie99fK .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-T07XNjDly5Ie99fK .rough-node .label,#mermaid-svg-T07XNjDly5Ie99fK .node .label,#mermaid-svg-T07XNjDly5Ie99fK .image-shape .label,#mermaid-svg-T07XNjDly5Ie99fK .icon-shape .label{text-align:center;}#mermaid-svg-T07XNjDly5Ie99fK .node.clickable{cursor:pointer;}#mermaid-svg-T07XNjDly5Ie99fK .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-T07XNjDly5Ie99fK .arrowheadPath{fill:#333333;}#mermaid-svg-T07XNjDly5Ie99fK .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-T07XNjDly5Ie99fK .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-T07XNjDly5Ie99fK .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-T07XNjDly5Ie99fK .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-T07XNjDly5Ie99fK .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-T07XNjDly5Ie99fK .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-T07XNjDly5Ie99fK .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-T07XNjDly5Ie99fK .cluster text{fill:#333;}#mermaid-svg-T07XNjDly5Ie99fK .cluster span{color:#333;}#mermaid-svg-T07XNjDly5Ie99fK div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-T07XNjDly5Ie99fK .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-T07XNjDly5Ie99fK rect.text{fill:none;stroke-width:0;}#mermaid-svg-T07XNjDly5Ie99fK .icon-shape,#mermaid-svg-T07XNjDly5Ie99fK .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-T07XNjDly5Ie99fK .icon-shape p,#mermaid-svg-T07XNjDly5Ie99fK .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-T07XNjDly5Ie99fK .icon-shape .label rect,#mermaid-svg-T07XNjDly5Ie99fK .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-T07XNjDly5Ie99fK .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-T07XNjDly5Ie99fK .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-T07XNjDly5Ie99fK :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
是
否
是
我要用 Q1 模型做什么?
只是看外观 / 渲染?
q1_v3.xml 显示用
近似限位 无电机
要跑控制器仿真?
需要力矩 / 传感器
q1_v3_mc.xml 运动学控制用
timestep 0.001 / armature 0.004 / 22 电机
只想分析可达空间
逆运动学 / 边界
q1_v3_full_range.xml 全范围
精确限位 无控制脚手架
结论: 按目标选文件 不要混用
三份文件到底是"同一套元素的不同配置"还是"完全不同的模型"?从 MJCF 的 XML 结构看,它们共享同一棵 mujoco → worldbody → body → joint/geom 的树,区别只在 option、default、actuator、sensor 这些"行为层"节点。下一节用脚本把它们逐字段拆开之前,先用一张类图把 MJCF 的元素关系固定下来,后面读代码时才不会迷路。

图1:启元 Q1 三份 MJCF 文件的职责与关键参数对比卡片
三、用 cmp_mjcf.py 把差异量化出来
光看文件肉眼对比 22 个关节 × 3 份文件 = 66 个数字,既不现实也容易看错。官方思路是写一个解析脚本,用 xml.etree.ElementTree 把每个文件的关节限位、步长、转动惯量、电机数量读出来做对齐。下面我把 cmp_mjcf.py 拆成两个核心函数讲,先讲"读",再讲"比"。
3.1 load():把一个 MJCF 读成字典
load() 的职责是把一份 MJCF 压成几个关键字段。它先跳过 floating_base_joint(自由浮基不是可动关节,不参与限位对比),再用 root.iter("joint") 遍历所有关节、用 j.get("range") 取区间;然后分别去 <option> 取 timestep、去 <default> 里找 joint 的 armature、用 root.iter("motor") 数生效的电机数。注意 ElementTree 会自动忽略 XML 注释,所以 mc 里那段被注释掉的电机块不会被统计,数出来的就是真正生效的 22 个。
python
def load(path):
root = ET.parse(path).getroot()
joints = {}
body = root.find("worldbody")
for j in root.iter("joint"):
if j.get("name") == "floating_base_joint":
continue
rng = j.get("range")
joints[j.get("name")] = tuple(float(x) for x in rng.split()) if rng else None
opt = root.find("option")
default = root.find("default")
armature = None
if default is not None:
for j in default.iter("joint"):
if j.get("armature") is not None:
armature = j.get("armature")
motors = [m for m in root.iter("motor")]
keyframes = root.findall(".//keyframe/key")
worldbodies = root.findall("worldbody")
return {
"path": path,
"joints": joints,
"timestep": opt.get("timestep") if opt is not None else "(默认 0.002)",
"armature": armature,
"motors": len(motors),
"keyframes": len(keyframes),
"worldbody_cnt": len(worldbodies),
}
这段代码的核心价值在于"把异构的 XML 归一化成可比较的结构"。joints 用关节名为键存区间元组,后面才能做按名对齐的 diff;timestep 在显示/全范围文件里取不到,脚本用 "(默认 0.002)" 占位而不是报错,这是离线分析脚本应有的容错------你永远不知道用户会拿哪个文件喂进来。同样,armature 只有 mc 的 <default> 里有,其余两份返回 None,这个 None 本身就是"有没有控制惯量"的最直接证据。
3.2 main():对齐三份文件并打印 diff
main() 接收三个文件路径,先把每份 load() 一遍,再分行打印"文件级参数"和"22 关节区间"。它用一个 diff_cnt 计数器标记区间不一致的关节数,这正是我们判断"三文件是否真的一致"的量化指标。下面的输出里,<-- 不同 标记的就是显示文件与精确文件对不上的关节。
python
def main(paths):
data = [load(p) for p in paths]
names = [p.split("/")[-1] for p in paths]
print("== 文件级参数 ==")
hdr = f"{'参数':<12}" + "".join(f"{n:<22}" for n in names)
print(hdr)
print("-" * len(hdr))
for key, label in [
("timestep", "timestep"), ("armature", "armature"),
("motors", "motor 数"), ("keyframes", "keyframe 数"),
("worldbody_cnt", "worldbody 数")]:
row = f"{label:<12}"
for d in data:
v = d[key]
row += f"{str(v):<22}"
print(row)
print()
print("== 22 个可动关节的 range 对比(rad)==")
hdr2 = f"{'joint':<28}" + "".join(f"{n.replace('.xml',''):<24}" for n in names)
print(hdr2)
print("-" * len(hdr2))
diff_cnt = 0
for jn in data[0]["joints"]:
vals = [d["joints"].get(jn) for d in data]
flag = " <-- 不同" if len(set(vals)) > 1 else ""
if flag:
diff_cnt += 1
row = f"{jn:<28}"
for v in vals:
row += f"{str(v):<24}"
print(row + flag)
print()
print(f"限位不一致的关节数:{diff_cnt} / {len(data[0]['joints'])}")
这段逻辑里最值得学的是"len(set(vals)) > 1"这个判异技巧:把三份文件同一个关节的区间放进集合,如果集合长度大于 1 就说明至少有一份不一致。它不关心"谁和谁不同",只关心"是否齐整",正好契合我们"三文件是否自洽"的提问。实际跑下来,22 个关节里有 22 个在显示文件上和精确文件不同------也就是说显示文件每一个关节的近似都和 URDF 真值有偏差,这正是第四节要展开讲的坑。
3.3 跑起来:用法与预期输出
脚本设计为三个路径作参数,也可无参(默认用同目录三文件名)。在 research 目录下执行:
bash
# 1) 进入脚本与模型所在目录
cd /path/to/research
# 2) 按固定顺序传入:显示用、运动学控制用、全范围
python cmp_mjcf.py q1_v3.xml q1_v3_mc.xml q1_v3_full_range.xml
# 3) 无参运行(脚本内部默认就是上面三个文件名)
python cmp_mjcf.py
# 4) 把输出存盘,便于后续 diff 与版本回溯
python cmp_mjcf.py q1_v3.xml q1_v3_mc.xml q1_v3_full_range.xml > mjcf_cmp.log
# 5) 在 CI 里断言 mc 必须是控制可用:timestep 0.001 且 22 电机
python -c "import cmp_mjcf; d=cmp_mjcf.load('q1_v3_mc.xml'); \
assert d['timestep']=='0.001' and d['motors']==22, 'mc 文件异常'"
# 6) 只抽取限位不一致的行,快速看哪些关节被动过
python cmp_mjcf.py q1_v3.xml q1_v3_mc.xml q1_v3_full_range.xml | grep "不同"
# 7) 对比两份不同 SDK 版本的 mc,确认官方没偷偷改限位
python cmp_mjcf.py v1/q1_v3_mc.xml v2/q1_v3_mc.xml > diff_v1_v2.log
文件级参数区的输出会呈现三份文件的"行为层"差异:显示用与全范围的 timestep 列显示 (默认 0.002)、armature 列为 None、motor 数 为 0;而 mc 的 timestep 是 0.001、armature 是 0.004、motor 数 是 22。这一行就已经回答了"谁适合控制仿真"。紧接着的 22 关节区间区,每个关节后面几乎都会挂一个 <-- 不同,最后汇总行打印"限位不一致的关节数:22 / 22"------这就是本文核心结论的机器佐证。
需要提醒:脚本依赖 xml.etree.ElementTree,是 Python 标准库,无需额外安装;但它按文件名顺序对齐,所以传参顺序要固定(显示、mc、全范围),否则表头对不上。把它接进你的离线校验流水线,每次 SDK 更新后跑一遍,能第一时间发现官方是否动了限位或电机定义。
站在 MJCF 的 XML 树角度看,脚本读到的每一个字段都挂在固定节点上。下面用类图把"模型---世界体---刚体---关节---执行器---传感器"的归属关系画清楚,它也是后面解析电机、传感器时的方法论地图。
#mermaid-svg-kXy0bDriB6mNk4vk{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-kXy0bDriB6mNk4vk .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-kXy0bDriB6mNk4vk .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-kXy0bDriB6mNk4vk .error-icon{fill:#552222;}#mermaid-svg-kXy0bDriB6mNk4vk .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-kXy0bDriB6mNk4vk .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-kXy0bDriB6mNk4vk .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-kXy0bDriB6mNk4vk .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-kXy0bDriB6mNk4vk .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-kXy0bDriB6mNk4vk .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-kXy0bDriB6mNk4vk .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-kXy0bDriB6mNk4vk .marker{fill:#333333;stroke:#333333;}#mermaid-svg-kXy0bDriB6mNk4vk .marker.cross{stroke:#333333;}#mermaid-svg-kXy0bDriB6mNk4vk svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-kXy0bDriB6mNk4vk p{margin:0;}#mermaid-svg-kXy0bDriB6mNk4vk g.classGroup text{fill:#9370DB;stroke:none;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:10px;}#mermaid-svg-kXy0bDriB6mNk4vk g.classGroup text .title{font-weight:bolder;}#mermaid-svg-kXy0bDriB6mNk4vk .cluster-label text{fill:#333;}#mermaid-svg-kXy0bDriB6mNk4vk .cluster-label span{color:#333;}#mermaid-svg-kXy0bDriB6mNk4vk .cluster-label span p{background-color:transparent;}#mermaid-svg-kXy0bDriB6mNk4vk .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-kXy0bDriB6mNk4vk .cluster text{fill:#333;}#mermaid-svg-kXy0bDriB6mNk4vk .cluster span{color:#333;}#mermaid-svg-kXy0bDriB6mNk4vk .nodeLabel,#mermaid-svg-kXy0bDriB6mNk4vk .edgeLabel{color:#131300;}#mermaid-svg-kXy0bDriB6mNk4vk .edgeLabel .label rect{fill:#ECECFF;}#mermaid-svg-kXy0bDriB6mNk4vk .label text{fill:#131300;}#mermaid-svg-kXy0bDriB6mNk4vk .labelBkg{background:#ECECFF;}#mermaid-svg-kXy0bDriB6mNk4vk .edgeLabel .label span{background:#ECECFF;}#mermaid-svg-kXy0bDriB6mNk4vk .classTitle{font-weight:bolder;}#mermaid-svg-kXy0bDriB6mNk4vk .node rect,#mermaid-svg-kXy0bDriB6mNk4vk .node circle,#mermaid-svg-kXy0bDriB6mNk4vk .node ellipse,#mermaid-svg-kXy0bDriB6mNk4vk .node polygon,#mermaid-svg-kXy0bDriB6mNk4vk .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-kXy0bDriB6mNk4vk .divider{stroke:#9370DB;stroke-width:1;}#mermaid-svg-kXy0bDriB6mNk4vk g.clickable{cursor:pointer;}#mermaid-svg-kXy0bDriB6mNk4vk g.classGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-kXy0bDriB6mNk4vk g.classGroup line{stroke:#9370DB;stroke-width:1;}#mermaid-svg-kXy0bDriB6mNk4vk .classLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-kXy0bDriB6mNk4vk .classLabel .label{fill:#9370DB;font-size:10px;}#mermaid-svg-kXy0bDriB6mNk4vk .relation{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-kXy0bDriB6mNk4vk .dashed-line{stroke-dasharray:3;}#mermaid-svg-kXy0bDriB6mNk4vk .dotted-line{stroke-dasharray:1 2;}#mermaid-svg-kXy0bDriB6mNk4vk #compositionStart,#mermaid-svg-kXy0bDriB6mNk4vk .composition{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-kXy0bDriB6mNk4vk #compositionEnd,#mermaid-svg-kXy0bDriB6mNk4vk .composition{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-kXy0bDriB6mNk4vk #dependencyStart,#mermaid-svg-kXy0bDriB6mNk4vk .dependency{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-kXy0bDriB6mNk4vk #dependencyStart,#mermaid-svg-kXy0bDriB6mNk4vk .dependency{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-kXy0bDriB6mNk4vk #extensionStart,#mermaid-svg-kXy0bDriB6mNk4vk .extension{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-kXy0bDriB6mNk4vk #extensionEnd,#mermaid-svg-kXy0bDriB6mNk4vk .extension{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-kXy0bDriB6mNk4vk #aggregationStart,#mermaid-svg-kXy0bDriB6mNk4vk .aggregation{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-kXy0bDriB6mNk4vk #aggregationEnd,#mermaid-svg-kXy0bDriB6mNk4vk .aggregation{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-kXy0bDriB6mNk4vk #lollipopStart,#mermaid-svg-kXy0bDriB6mNk4vk .lollipop{fill:#ECECFF!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-kXy0bDriB6mNk4vk #lollipopEnd,#mermaid-svg-kXy0bDriB6mNk4vk .lollipop{fill:#ECECFF!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-kXy0bDriB6mNk4vk .edgeTerminals{font-size:11px;line-height:initial;}#mermaid-svg-kXy0bDriB6mNk4vk .classTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-kXy0bDriB6mNk4vk .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-kXy0bDriB6mNk4vk .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-kXy0bDriB6mNk4vk :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 驱动
Mujoco
+compiler angle=radian
+option timestep
+default armature
Worldbody
+body pelvis
Body
+freejoint
+inertial mass
+geom mesh
+joint
Joint
+name
+range rad
+actuatorfrcrange
Actuator
+motor x22
Motor
+joint
+ctrlrange
Sensor
+imu
+jointpos x22
+jointvel x22
四、22 个关节 range 的精度陷阱:显示文件为什么不能当真理
前三节建立了"三文件结构相同、行为层不同"的认知,现在进入本文第一个硬核坑:关节限位的精度陷阱 。显示文件为了方便查看器摆 pose 用了四舍五入的近似区间,而 mc 和 full_range 用的是从 URDF 直接转换的精确值。两者不是"差不多",而是系统性地不一致------而且显示文件在所有关节上都比真值偏窄。
4.1 怎么单独把"区间 + 偏差"扒出来
load() 已经把所有关节区间读进了字典,但如果我们只关心"显示文件和真值差多少",可以写一个更聚焦的函数:给定显示文件和精确文件,逐关节算偏差并标记。下面这段是对 cmp_mjcf.py 思路的延伸演示,逻辑完全一致,只是把输出收敛到"偏差"这一个维度。
python
def range_deviation(display_path, precise_path):
"""对比显示文件与精确文件的关节限位偏差,按偏差从大到小输出。"""
d = load(display_path)["joints"]
p = load(precise_path)["joints"]
rows = []
for name in d:
dv, pv = d[name], p.get(name)
if dv is None or pv is None:
continue
lo_err = abs(dv[0] - pv[0]) # 下界偏差
hi_err = abs(dv[1] - pv[1]) # 上界偏差
worst = max(lo_err, hi_err)
rows.append((name, dv, pv, worst))
rows.sort(key=lambda r: -r[3]) # 偏差从大到小排
print(f"{'关节':<26}{'显示区间':<22}{'精确区间':<24}{'最大偏差'}")
for name, dv, pv, worst in rows:
deg = worst * 180 / 3.14159265
mark = " ⚠ 偏大" if worst > 0.1 else ""
print(f"{name:<26}{str(dv):<22}{str(pv):<24}{worst:.4f}rad({deg:.1f}°){mark}")
over = [r for r in rows if r[3] > 0.1]
print(f"\n共 {len(rows)} 个关节,其中 {len(over)} 个偏差超过 0.1 弧度(约 5.7 度)")
return rows
这段代码的巧思在 rows.sort(key=lambda r: -r[3]):它按最大偏差降序排列,让你一眼看到"哪个关节被近似得最狠"。worst * 180 / 3.14159265 把弧度偏差换算成角度,因为对大多数工程师来说"差了 6 度"比"差了 0.1 弧度"更直观。这个偏差排序的结果会直接告诉你:不要平均地怀疑所有关节,要重点盯偏差最大的那几个------比如肩 roll。
4.2 偏差到底有多大:节选对比
把脚本跑出来的偏差按关节摊开,最有代表性的是下面这几个。注意 left_shoulder_roll_joint:显示文件写成 0 ~ 1.54,精确值是 -0.087266 ~ 2.7925。也就是说,显示文件把肩部外旋的上界砍掉了超过 1.25 弧度(约 72 度)!如果你在查看器里基于显示文件规划肩部动作,会误以为机器人根本转不了那么远,实际硬件却能。相反,hip_pitch、knee 这类下肢关节,偏差集中在 0.1 弧度(约 6 度)上下,属于"看不太出来但累积起来会错位"的量级。
| 关节名 | 显示文件(近似) | 精确 URDF(mc/全范围) | 单侧最大偏差 | 约合角度 |
|---|---|---|---|---|
| left_shoulder_roll | 0 ~ 1.54 | -0.087266 ~ 2.7925 | 1.253 | 71.8° |
| left_hip_pitch | -2.95 ~ 1.47 | -3.0543 ~ 1.5708 | 0.104 | 6.0° |
| left_knee | 0.1 ~ 2.34 | 0 ~ 2.4435 | 0.104 | 6.0° |
| left_hip_roll | -0.6 ~ 1.47 | -0.69813 ~ 1.5708 | 0.100 | 5.7° |
| left_elbow | -0.8 ~ 1.57 | -0.87266 ~ 1.6581 | 0.088 | 5.0° |
| left_ankle_pitch | -0.7 ~ 0.38 | -0.7854 ~ 0.43633 | 0.085 | 4.9° |
这个表要反着读才有价值:所有显示文件的区间都比精确值偏窄 。这意味着两件事。第一,拿显示文件当限位真理,你会系统性地"低估"机器人的真实可达空间,动作设计偏保守。第二,也是更危险的------如果你在显示文件里验证过一个 pose,然后把它搬进 mc 或 full_range(它们允许更宽的范围)去仿真,你原本以为"贴着边"的动作其实离真值还远,而反过来若你以精确文件为基准设的护栏,在显示文件里可能被"假安全"地放行。一句话:限位只能有一个真理源,且必须是 mc/full_range 的精确值。
为了让你直观感受"显示文件在查看器里到底长什么样、为什么它敢用近似",下面这张图给出渲染效果的示意,重点标注它的可视化职责而非控制职责。

图2:MuJoCo 查看器中显示用模型的渲染效果与可视化标注示意
4.3 偏差分布并不均匀:上肢比下肢更"受伤"
把 22 个关节的偏差摊开看,会发现一个反直觉的分布规律:上肢的近似比下肢更离谱 。下肢的 hip_pitch、knee、hip_roll 偏差都收敛在 0.1 弧度(约 6 度)附近,属于"四舍五入级别"的小误差;而 left_shoulder_roll 的上界偏差高达 1.25 弧度(约 72 度),left_shoulder_pitch 也有 0.1 弧度量级的偏移。原因在于显示文件对上肢做了更激进的"取整+收窄"------大概是觉得查看器里手臂摆到极端角度的机会少,不如把范围卡紧点让摆 pose 更顺手。
这个不均匀性带来一个实操结论:如果你做的是上肢相关的动作设计或逆解,绝不能用显示文件的限位做参考 ,因为那里偏差最大、最会误导;而下肢虽然偏差小,但膝关节 left_knee 的真值是 0 ~ 2.4435、显示文件写成 0.1 ~ 2.34,下界凭空多了 0.1 弧度门槛,会让你以为膝盖不能完全伸直(真实可以降到 0)。所以即便下肢"只差 6 度",在贴边动作上照样会咬你一口。
既然偏差已经量化,自然要追问"这些近似到底让区间宽度损失了多少"。下面用一张 XY 图把 5 个代表关节在"显示近似"和"精确 URDF"两种口径下的区间宽度(上界减下界,单位弧度)并排画出来,差距一目了然:肩 roll 那根柱子,精确值几乎是显示值的两倍。
#mermaid-svg-MHBMjONUJdJCjL8T{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-MHBMjONUJdJCjL8T .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-MHBMjONUJdJCjL8T .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-MHBMjONUJdJCjL8T .error-icon{fill:#552222;}#mermaid-svg-MHBMjONUJdJCjL8T .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-MHBMjONUJdJCjL8T .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-MHBMjONUJdJCjL8T .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-MHBMjONUJdJCjL8T .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-MHBMjONUJdJCjL8T .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-MHBMjONUJdJCjL8T .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-MHBMjONUJdJCjL8T .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-MHBMjONUJdJCjL8T .marker{fill:#333333;stroke:#333333;}#mermaid-svg-MHBMjONUJdJCjL8T .marker.cross{stroke:#333333;}#mermaid-svg-MHBMjONUJdJCjL8T svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-MHBMjONUJdJCjL8T p{margin:0;}#mermaid-svg-MHBMjONUJdJCjL8T :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 代表关节区间宽度对比(rad):显示近似 vs 精确 URDF hip_pitch knee shoulder_roll hip_roll ankle_pitch 5 4.5 4 3.5 3 2.5 2 1.5 1 0.5 0 区间宽度 rad
4.4 把差异固化成验收清单:防止它再次咬人
偏差量化完,真正要回答的是"怎么防止它再次咬人":不是背下本文的数字,而是把差异固化成一份每次 SDK 更新都跑的验收清单。先看两个最典型的误用后果:把显示 range 当控制器 clamp ------肩 roll 上界从 2.7925 被砍到 1.54,更大的目标角被静默截断,控制器以为到位了,实际差了 72 度,上肢任务全程"抬不起手";这种截断不报错,只表现为越来越大的跟踪误差,极难 debug。把显示 range 当真机护栏------它比硬件窄,撞不了限位,但白名单与 mc 真理源不一致,同一指令在仿真里放行、在白名单里被拦,联调时全是"幽灵问题"。根因一句话:限位是模块间的接口,三份文件给了三个签名,调用方必须固定认一个。
下表把前文的量化结论浓缩成 7 条断言,可以直接落进 CI:
| # | 断言项 | 期望值 | 违反后果 |
|---|---|---|---|
| 1 | mc 的 timestep | 0.001 | 刚性接触易发散 |
| 2 | mc 的 armature | 0.004 | 高频关节数值抖动 |
| 3 | mc 的 motor 数 | 22 | 控制维度 nu 与自由度错位 |
| 4 | mc 与全范围的 22 关节 range | 逐项全等 | 两份"真理源"互相矛盾 |
| 5 | 显示 range 与精确 range | 逐项窄于或等于 | 近似写反,机器人被"放大" |
| 6 | pelvis 初始高度 | mc=0.419,其余 0.48 | 出生点张冠李戴,第一帧踩空 |
| 7 | motor ctrlrange 与 actuatorfrcrange | 逐项一致 | 力矩天花板两份文件对不上 |
落成代码就是下面这样:它复用第三节的 load() 读文件级参数与关节区间,再用 read_force_limits() 补读 ctrlrange 与 pelvis 高度------这两个字段 load() 没有覆盖,却恰是"静默变更"的高发区(想想那段 ±40/±30 的注释化石)。七条断言里最该单独记住的是第 5 条:显示文件的近似只允许比真值窄------窄了是保守,顶多浪费可达空间;宽了是危险,等于把硬件够不着的角写进白名单,真机必截停。
python
import xml.etree.ElementTree as ET
from cmp_mjcf import load # 复用第三节的 load()
def read_force_limits(path):
"""读取 motor 的 ctrlrange、joint 的 actuatorfrcrange 与 pelvis 初始高度。"""
root = ET.parse(path).getroot()
ctrl = {m.get("joint"): tuple(float(x) for x in m.get("ctrlrange").split())
for m in root.iter("motor")}
frc = {j.get("name"): tuple(float(x) for x in j.get("actuatorfrcrange").split())
for j in root.iter("joint") if j.get("actuatorfrcrange")}
pelvis = root.find(".//body[@name='pelvis']").get("pos")
return ctrl, frc, pelvis
def acceptance_check(display_path, mc_path, full_path):
"""把三文件差异固化成 CI 验收清单,全部通过才放行。"""
d, m, f = load(display_path), load(mc_path), load(full_path)
ctrl, frc, pelvis_mc = read_force_limits(mc_path)
fails = []
# ① 控制三件套:步长 / 惯量 / 电机数
if m["timestep"] != "0.001":
fails.append(f"mc timestep={m['timestep']},应为 0.001")
if m["armature"] != "0.004":
fails.append(f"mc armature={m['armature']},应为 0.004")
if m["motors"] != 22:
fails.append(f"mc motor 数={m['motors']},应为 22")
# ② 两份真理源逐项一致;显示近似只允许更窄、不允许更宽
named = {k: v for k, v in m["joints"].items() if v} # 剔除 default 里的无名模板
for jn, rng in named.items():
if f["joints"].get(jn) != rng:
fails.append(f"{jn}: mc={rng} 与全范围={f['joints'].get(jn)} 不一致")
dv = d["joints"].get(jn)
if dv and (dv[0] < rng[0] - 1e-9 or dv[1] > rng[1] + 1e-9):
fails.append(f"{jn}: 显示区间 {dv} 宽于精确区间 {rng},近似方向错误")
# ③ 力矩天花板与出生点高度对齐
for jn, cr in ctrl.items():
if jn in frc and frc[jn] != cr:
fails.append(f"{jn}: ctrlrange={cr} 与 actuatorfrcrange={frc[jn]} 不一致")
if pelvis_mc != "0 0 0.419":
fails.append(f"mc pelvis 高度={pelvis_mc},应为 0 0 0.419")
if fails:
print("验收未通过:")
for x in fails:
print(" -", x)
return False
print(f"验收通过:{len(named)} 关节区间一致,控制三件套齐全,力矩与出生点对齐")
return True
跑通的输出只有一行"验收通过";任何一条断言变红,都说明你手里的三份文件已经"各自进化",第七节选型决策表的前提不再成立------先修文件,再谈仿真。
五、mc.xml 的 motors + 小步长 + 转动惯量:控制器仿真的三件套
跳过限位陷阱,来到本文最实用的部分:如果你要做控制器仿真(不管是模型预测控制、全身力控还是强化学习),唯一正确的选择是 mc 文件。它相比另外两份多出来的东西,不是"锦上添花",而是"没有就跑不起来或跑不稳"的三件套。
5.1 22 个电机:控制器能"使上劲"的前提
mc 的 <actuator> 段写了 22 个 <motor>,每个 joint 属性指向一个真实可动关节,ctrlrange 给出控制量上下限(髋、膝与腰为 ±36,踝与臂为 ±22,与显示/全范围文件 joint 上 actuatorfrcrange 的量级一致)。没有这些 motor,你下发任何控制指令仿真器都不知道"该对哪个关节施加力矩",机器人就是一堆飘着的刚体。下面这段解析代码把 22 个电机按部位分组统计,验证"腿 12、腰 2、臂 8"的配平。
python
def parse_motors(path):
root = ET.parse(path).getroot()
motors = root.iter("motor") # 注释块已被 ElementTree 忽略
groups = {"leg": 0, "waist": 0, "arm": 0}
detail = []
for m in motors:
name = m.get("name") # 如 motor_left_hip_pitch
joint = m.get("joint")
cr = m.get("ctrlrange")
detail.append((name, joint, cr))
if "waist" in name:
groups["waist"] += 1
elif "shoulder" in name or "elbow" in name:
groups["arm"] += 1
else:
groups["leg"] += 1
print(f"电机总数: {len(detail)} -> 腿{groups['leg']} 腰{groups['waist']} 臂{groups['arm']}")
for n, j, cr in detail:
print(f" {n:<28} -> {j:<24} ctrlrange={cr}")
return detail
这段代码的判断逻辑(waist / shoulder|elbow / 其余算腿)是对 Q1 关节命名的归纳,跑出来正好是腿 12(左右各 6)、腰 2、臂 8(左右各 4),与 URDF 报告脚本的"左腿 6 + 右腿 6 + 腰 2 + 左臂 4 + 右臂 4 = 22"完全吻合。ctrlrange 的分档也值得记:髋、膝与腰是 ±36 N·m 量级,踝与上肢是 ±22,这反映了不同关节的扭矩天花板,写控制器时力矩上限别乱填。更关键的是,这里再次印证了"电机只存在于 mc"------如果你把这份解析逻辑套到显示或全范围文件上,root.iter("motor") 会返回空列表,分组计数为 0,这本身就是区分三份文件最干脆的判据。
5.2 22 个 motor 如何一一映射到 22 个关节
映射规则比想象中简单:每个 <motor> 用 joint 属性直接写下游关节名,MuJoCo 据此建立"控制量 → 广义力"的通道。mc.xml 里 22 个 motor 的命名很规整------motor_ 加上关节名去掉 _joint 后缀,如 motor_left_hip_pitch 驱动 left_hip_pitch_joint。数一遍第二节的拓扑:腿 12、腰 2、臂 8,正好覆盖全部 22 个可动关节;自由浮基与头、双腕 3 个 fixed 连接则没有 motor------浮基的 6 个位姿自由度交给仿真器整体积分,不该被力矩驱动。这种"一个不多一个不少"的配平,是控制器维度(nu=22)与自由度对齐的前提。
再说两个关键属性的含义。gear 是电机到关节的传动比,mc.xml 的 22 个 motor 都没有显式写 gear ,即用 MuJoCo 默认值 1------控制量 1:1 直接当作关节力矩(N·m)。所以 ctrlrange="-36 36" 不是读数上限,而是实打实的力矩天花板;将来若按官方减速比补上 gear,同样的 ctrl 会产生成倍的力,这是改造模型时第一个要核对的字段。按 ctrlrange 分档统计:
| ctrlrange | 覆盖关节 | 个数 |
|---|---|---|
| ±36 N·m | 髋三轴(pitch/roll/yaw)、膝、腰 roll/yaw | 10 |
| ±22 N·m | 踝(pitch/roll)、肩三轴、肘 | 12 |
这个分档与显示/全范围文件 joint 上的 actuatorfrcrange 逐项一致------后者在裸模型里不起作用,但证明三份文件同源。还有一个 diff 时要盯住的细节:mc.xml 的 actuator 段有一段被注释掉的旧版电机定义,力矩档位是 ±40/±30,生效的才是 ±36/±22。这段"化石"说明官方的力矩天花板修订过,验收清单必须断言 ctrlrange 的具体数值,只数个数会漏掉静默变更。
最后回应一个常见疑问:显示用 xml 为什么不需要 motor ?渲染场景没有 ctrl 输入源,查看器摆 pose 走的是直接设定 qpos 加运动学正解,<actuator> 建立的"控制量→力矩"通道没有对端;MuJoCo 允许 nu=0 的模型正常加载渲染。给显示文件硬加 motor,只会让它背上用不到的编译负担。
5.3 timestep 与 armature:让刚性接触"稳"下来的关键
光有电机还不够。人形机器人的脚-地接触是高度刚性的,默认 0.002 秒的步长在 mc 这种要算接触力、要反馈控制的场景里容易数值发散。mc 把 <option timestep="0.001"> 直接砍半,相当于每仿真秒要多算一倍积分步,换来的是接触与高频控制的稳定性。另一个隐藏参数是 <default class="q1"> 里的 armature="0.004"------它给每个关节叠加一个等效转动惯量。
下面这段代码专门把这两个"仿真层"参数读出来,方便你在脚本里做断言(比如 CI 里检查"控制文件必须是 0.001 步长")。
python
def read_sim_params(path):
"""读取 MJCF 的仿真层参数:timestep 与 armature 转动惯量。"""
root = ET.parse(path).getroot()
opt = root.find("option")
timestep = opt.get("timestep") if opt is not None else "默认 0.002"
default = root.find("default")
armature = None
if default is not None:
for j in default.iter("joint"): # 在 default 里找关节级 armature
if j.get("armature") is not None:
armature = j.get("armature")
print(f"文件: {path}")
print(f" timestep = {timestep} 秒")
print(f" armature = {armature} (等效关节转动惯量)")
if timestep not in ("0.001",):
print(" ⚠ 控制仿真建议 timestep=0.001,当前非控制精度")
if armature is None:
print(" ⚠ 缺少 armature,高频关节可能数值抖动")
# 返回结构化结果,便于在 CI / 离线校验中断言是否为"控制可用"
return {
"timestep": timestep,
"armature": armature,
"is_control_ready": timestep == "0.001" and armature is not None,
}
armature 在 MuJoCo 里的物理含义是"在关节惯量上额外加一个常数",它的作用是压制数值积分时微小惯量关节的高频抖动------对 Q1 这种轻臂、重躯干的拓扑特别有用。timestep=0.001 则是刚性接触仿真的经验值:步长减半,稳定性显著提升,代价是 2 倍计算量。这两个参数只在 mc 里有 ,显示/全范围都没有,所以你若拿那两份去做控制,要么发散、要么抖成筛子。脚本最后返回的 is_control_ready 布尔字段,就是给自动化校验用的"红绿灯":一旦官方哪天把 mc 的步长改了,你的流水线会立刻告警。
5.4 mc.xml 的关键 XML 片段:三件套长这样
把上面三件事的 XML 原貌摆出来,以后你自己改模型或写变体时就有模板可循。下面这段摘自 mc 文件头部,三段分别对应"步长""惯量默认值""一组电机"。
xml
<mujoco model="q1_v3">
<compiler angle="radian" meshdir="../meshes/" autolimits="true" />
<option timestep="0.001" />
<default>
<default class="q1">
<joint damping="0.0" armature="0.004" frictionloss="0.0"/>
</default>
</default>
<actuator>
<!-- 腿部 6 对电机:髋三轴 + 膝 + 踝俯仰 + 踝滚转,ctrlrange 均为 ±36 -->
<motor class="q1" name="motor_left_hip_pitch" joint="left_hip_pitch_joint" ctrlrange="-36 36"/>
<motor class="q1" name="motor_left_hip_roll" joint="left_hip_roll_joint" ctrlrange="-36 36"/>
<motor class="q1" name="motor_left_hip_yaw" joint="left_hip_yaw_joint" ctrlrange="-36 36"/>
<motor class="q1" name="motor_left_knee" joint="left_knee_joint" ctrlrange="-36 36"/>
<motor class="q1" name="motor_left_ankle_pitch" joint="left_ankle_pitch_joint" ctrlrange="-22 22"/>
<motor class="q1" name="motor_left_ankle_roll" joint="left_ankle_roll_joint" ctrlrange="-22 22"/>
<!-- 上肢 4 对电机:肩三轴 + 肘,ctrlrange 均为 ±22 -->
<motor class="q1" name="motor_left_shoulder_pitch" joint="left_shoulder_pitch_joint" ctrlrange="-22 22"/>
<motor class="q1" name="motor_left_shoulder_roll" joint="left_shoulder_roll_joint" ctrlrange="-22 22"/>
<motor class="q1" name="motor_left_shoulder_yaw" joint="left_shoulder_yaw_joint" ctrlrange="-22 22"/>
<motor class="q1" name="motor_left_elbow" joint="left_elbow_joint" ctrlrange="-22 22"/>
</actuator>
</mujoco>
这段 XML 里有两个容易忽略但重要的细节。其一是 compiler 上的 autolimits="true":它会让 MuJoCo 在加载时自动根据几何推导接触/可视边界,配合精确限位使用更安全。其二是 <default class="q1"> 用 childclass="q1" 挂到 pelvis 上后,所有子刚体继承 armature=0.004 和 damping=0.0,这就是"一处定义、全链生效"的 MJCF 默认继承机制------你不必给 22 个关节逐个写惯量。读懂这段,你就掌握了"如何用最少改动造出一份控制可用的 Q1 模型"。
5.5 为什么是 0.001 而不是更小?步长与算力的权衡
有人会问:既然小步长更稳,为什么不干脆设成 0.0001,一步到位?答案是算力和收益不成正比。MuJoCo 的积分是显式或半隐式的,步长减半,单位仿真时间的积分步数就翻倍,实时性要求下(比如要跑 1kHz 的 MPC 外环)CPU 占用直接翻倍,而 0.001 对 Q1 这种刚性接触已经足够稳。再小带来的边际稳定收益很小,却会让仿真慢到无法实时。所以 0.001 是工程师在"稳"和"快"之间取的工程甜点,不是越小越好。
同理,armature=0.004 也不是越大越保险。惯量加太多,关节对控制指令的响应会变"肉",低频力矩控制会迟钝;加太少,高频数值噪声又压不住。0.004 这个值是官方在真实 Q1 关节惯量量级上做的标定,你若另起炉灶造变体,别随手改它,先用本文的 read_sim_params 把它断言锁住,再在仿真里实测抖动和响应,按需微调。
为了把 mc 的"三件套"和显示/全范围文件的"空"做成一张随手可查的卡,下面这张图把四个核心参数并排列出,决策时一眼能对上号。

图3:运动学控制用 mc 文件仿真参数(步长/惯量/电机/限位)速查卡
六、full_range 探索边界的价值与风险
讲完显示和 mc,剩下 full_range 这份"全范围"文件。它的定位最单纯:精确限位 + 零控制脚手架。它适合做"不驱动、只分析"的工作------可达空间采样、逆运动学求解、构型可行性判断、自碰撞粗筛。因为没有电机和传感器,它加载快、依赖少,是做运动规划前"摸底"的理想对象。
但"全范围"三个字本身就是提醒:它给了你真实的完整边界,却没有任何护栏 。在 mc 里,电机 ctrlrange 和限位 range 双重约束;在 full_range 里,限位是准的,但没有任何东西阻止你把关节指令设到紧贴边界甚至(通过逆解数值误差)略微越界。硬件上真机有机械硬限位,仿真里 full_range 没有"到了就停"的保险。所以用它做探索时,必须自己叠加一份白名单(比如系列里 check_cmd.py 生成的 joint_whitelist.json),把"精确限位"变成"可执行护栏"。
换句话说,full_range 是"给你地图但不发安全带":适合离线算、不适合直接下发。如果你的下游是控制器,请回到 mc;如果只是想知道"这个末端位姿机器人够得着吗",full_range 比 mc 更轻、比显示更准,是性价比最高的选择。
举个具体用法:假设你想确认 Q1 能不能单手够到侧后方某个抓取点,正确姿势是拿 full_range 加载进逆解器,用它的精确限位作为可行域约束去求解;如果反过来用显示文件,肩部 roll 上界被砍掉 1.25 弧度,逆解器会直接判定"够不到"而放弃,让你误以为机器人能力不行。反之,当你用 full_range 解出一个贴着边界的 pose 准备下发真机时,必须再套一层 joint_whitelist.json 做越界拦截------因为 full_range 本身不会拦你,而真机有机械硬限位,越界就是硬件风险。所以 full_range 的最佳拍档,是系列里 check_cmd.py 产出的白名单:前者给"真实可达",后者给"安全护栏",两者配合才闭环。
七、什么时候用哪份:选型决策表
前面所有分析,最终收敛成一张可以贴在工位上的决策表。在给表之前,先把"选型"这件事的本质再点一遍:它不是在三个文件里挑一个"最正确"的,而是给每个任务匹配一个"最省力且不出错"的。显示文件胜在轻、mc 胜在全、full_range 胜在准而裸------没有谁碾压谁,只有"用错场景"才叫错。下面这张表把常见目标、推荐文件、理由和雷区一次性列清,你照着对号入座即可,不必每次都重新推导。
前面所有分析,最终收敛成一张可以贴在工位上的决策表。先按"目标"对号入座,再看"注意"栏避开雷区。这张表也是本文结论最凝练的表达。
| 你的目标 | 推荐文件 | 核心理由 | ⚠️ 注意 |
|---|---|---|---|
| 只看模型外观 / 做展示 | q1_v3.xml | 最轻量,加载最快 | 限位是近似,千万别当真理 |
| 控制器 / 强化学习仿真 | q1_v3_mc.xml | 22 电机 + 0.001 步长 + 惯量 + 传感器 | pelvis 初始 0.419,需自行稳住起步 |
| 可达空间 / 逆解 / 边界分析 | q1_v3_full_range.xml | 精确限位,无控制冗余 | 无护栏,需自加白名单 |
| 想用显示文件做控制 | ✗ 不可 | 没有电机和步长 | 仿真根本不会动 |
| 想用 mc 做纯渲染 | 可以但没必要 | 含传感器略重 | 直接显示文件更快 |
决策准则可以压缩成三句话:看外观用显示,做控制用 mc,探边界用全范围;限位永远以 mc/full_range 的精确值为唯一真理;pelvis 初始高度别张冠李戴。 记住这三条,三份文件就不再让人困惑,而是各司其职的工具。
为了让"选型 + 风险"做成一张能直接截图发的卡,下面这张图把"用错文件的四类后果"和"正确选择"并列,作为全文的视觉收尾。

图4:三份文件选型决策与用错文件的边界风险速查卡
八、适用边界与风险提示
本文的方法是"离线解析 + 脚本比对",不依赖真机,也不依赖 MuJoCo 能否真正渲染出网格(网格文件 ../meshes/*.STL 需随包存在,否则只有碰撞体看不到外观)。它的边界在于:脚本读的是 XML 文本层面的参数,不会验证动力学是否真的收敛、不会验证 STL 是否完整。所以当你把 mc 拿去跑真实控制器前,仍应在仿真里做一次"零指令下垂 / 标准站立"的 sanity check。
另外要提醒版本时效性:Q1 的 MJCF 随 SDK 迭代,本文基于写作时随包的三份文件。若官方更新了限位或电机命名,请用 cmp_mjcf.py 重新跑一遍,以脚本输出为准,而不是凭本文记的数字。本文的数字(22 电机、0.001 步长、0.004 惯量、各关节精确限位)均来自当前文件,可作为校验基线。
最后强调一个反直觉点:显示文件不是"错误版本",它只是"用途不同的版本"。不要因为它限位是近似值就觉得它low------它的任务就是让查看器流畅、让摆 pose 不被边界干扰。选型纠结的本质,是把"渲染便利"和"仿真真理"混为一谈。分清楚,就通了。
还有一个容易被忽略的延伸风险:这三份文件共用同一套 ../meshes/*.STL 网格 ,但 mc 额外在脚踝处挂了 4 个绿色小球标记(rgba="0.3 0.8 0.3"),用于可视化接触点。如果你只拷贝了 XML 没带网格目录,三份文件都会只剩碰撞体、看不到外观;而 mc 那 4 个小球是写在 geometry 里的,不依赖 STL,所以即使网格缺失你也能在 mc 里看到脚踝标记------这反过来提醒你,mc 和另外两份在"可视元素"上也不是完全同构的,迁移模型时连这些细枝末节都要一并核对。
补充一句关于"全范围"名字的澄清,避免误解:它叫 full_range,意味着限位是完整的真实范围,但并不等于"机器人能安全到达所有点" 。真实硬件还有电机堵转、线缆牵拉、自碰撞等显示/仿真里都未必建模的软限制。所以即便你用 full_range 算出了某个可达位姿,下发前仍要以 joint_whitelist.json 的物理护栏和真机实测为最终裁定。模型给你的是"几何可行域",工程安全边界永远要再加一层。
九、总结
回到开头的问题:启元 Q1 官方为什么给三份 MJCF,又该怎么选?答案清晰------它们不是冗余,而是按用途裁剪的三种配置视图:q1_v3.xml 显示用,限位近似、无电机无步长,只为把机器人画出来;q1_v3_full_range.xml 全范围,限位精确但无控制,适合可达空间与逆解边界分析;q1_v3_mc.xml 运动学/控制用,在精确限位上补齐 22 电机、0.001 步长、0.004 惯量与全套传感器,是控制器仿真的唯一正确入口。
我们用 cmp_mjcf.py 把 22 关节逐一对齐,量化出显示文件在每个关节都比 URDF 真值偏窄,最严重的是肩 roll 上界差 1.25 弧度(约 72 度),跨文件搬 pose 时极易错位。mc 的小步长、惯量与电机三件套,解释了控制仿真为何必须认准它。结论收敛成一句话:看外观用显示,做控制用 mc,探边界用全范围;限位只能以 mc/full_range 的精确值为唯一真理。
参考资料
- 启元机器人开发者社区:https://dev.primebot.com
- PrimeBot Q1 SDK 开源仓库:https://github.com/PrimebotTech/primebot-q1-sdk
- 启元开发者社区:https://open.agibot.com
- MuJoCo 官方文档(MJCF / actuator / option 说明):https://mujoco.readthedocs.io