跳转至

第 5 章 运行时与数值卫生

模型编译完成仅得到静态描述,本章进入运行时:mj_step 的一拍之内引擎执行了哪些阶段,mjData 又如何在「状态、派生、统计」三层中容纳这些结果。

1. mj_step 的阶段划分

文档给出的阶段清单,正好可以当作运行时的目录:

"顶层函数 mj_step 调用下面 1-26 的完整阶段序列。" "mj_forward 仅调用阶段 2-23,计算连续时间正向动力学,以加速度 mjData.qacc 结束。" "mj_step1 调用阶段 1-19,而 mj_step2 调用阶段 20-26……请注意,mj_step1 → mj_step2 管线不支持龙格-库塔(Runge Kutta)积分器。"

关键阶段(沿用文档命名):mj_checkPos、mj_checkVel → 正向运动学 mj_kinematics、mj_camlight → "构建活跃接触列表。这包括粗相(broad-phase)和近相(near-phase)碰撞检测:mj_collision" → "构建约束雅可比矩阵(Jacobian),计算约束残差,构建岛:mj_makeConstraint、mj_island(尚未在 API 中暴露)" → mj_passive、mj_rne → mj_fwdActuation、mj_fwdAcceleration → "使用选定的求解器计算约束力……mj_fwdConstraint" → mj_sensorAcc、mj_checkAcc → "使用选定的积分器将仿真状态推进一个时间步……mj_Euler、mj_RungeKutta、mj_implicit 中的一种"。

作为流水线看,三个入口的切分点一目了然——上排是 mj_step1 覆盖的前半拍(ctrl 之前),下排是写完 ctrl 后由 mj_step2 接的后半拍;mj_forward 则到 qacc 为止(阶段 2–23):

flowchart LR
    ck["检查 qpos / qvel<br/>checkPos / checkVel"] --> kin["运动学<br/>kinematics / camlight"]
    kin --> col["碰撞检测<br/>mj_collision"]
    col --> con["约束与岛<br/>makeConstraint / island"]
    con --> pas["被动力与逆动力学<br/>mj_passive / mj_rne"]
flowchart LR
    act["执行器与加速度<br/>fwdActuation / fwdAcceleration"] --> sol["求解约束力<br/>mj_fwdConstraint"]
    sol -->|"mj_forward 到 qacc 止(阶段 2–23)"| sen["传感器与检查<br/>sensorAcc / checkAcc"]
    sen --> itg["积分推进<br/>mj_Euler / mj_implicit"]

两个直接可用的工程结论:

  • 传感器数据在推进之前算完(mj_sensorAcc 在积分之前)⇒ 你的读数对应的是本步开始时的状态,不是结束时的。做闭环时序分析时这是起点定义问题(第 6 章)。
  • mj_step1/mj_step2 与 RK4 不兼容,是文档级的硬约束,不是实现细节。

2. mj_forward 与 mj_step

两个入口共用同一条前向管线,差别只在最后一环。mj_step 跑完整 26 个阶段,末尾用积分器把 qpos/qvel 推进一格、time += timestep;mj_forward 只调阶段 2–23,以 qacc 结束,状态与时钟完全不动。文档给 mj_forward 的两个定位:加载或重置之后让整个 mjData 进入有效状态;采样与有限差分这类乱序计算(APIfunctions)。

写 mjData 与读派生量之间必须隔着一次 mj_forward 或 mj_step——这条铁律可以直接复核:单 hinge 盒模型上把 qpos[0] 改成 0.7,不调 forward 时 xmat[0][0] 仍是旧值 1.0000(cos 0.7 = 0.7648 才对),xmat[1][0] 仍是 0(sin 0.7 = 0.6442);forward 一次后两者分别刷新成 0.7648 与 0.6442,而 time 恒为 0、qpos 逐位不变。mj_resetData 并不自动补这一步——重置后 xmat 全零,直到第一次 forward 才有效,"加载/重置后先 forward"是文档级的用法约定,不是可选修饰。

mj_step1/mj_step2 的拆分为控制服务:文档明确 step1 覆盖"需要 ctrl 之前"的阶段,因此 step1 之后、step2 之前设置的 ctrl 本步即生效(step1 → ctrl=0.5 → step2 得到非零 qpos)。Euler 下 step1+step2 与完整 mj_step 十步 qpos 逐位一致(RK4 的例外见上一节)。step2 复用 step1 的碰撞与约束结果——连续跑 5 次 step2,qpos 与 time 照常逐格推进,但接触拓扑不更新。

两个由此衍生的用法:只要碰撞结论不动状态,一次 mj_step1 即可读 ncon/contact;MPC 式推演(假设推演窗口内接触不变)一次 step1 后反复改 ctrl 跑 step2——收益真实,但接触失配时结论失真,是近似不是等价。开销上 step1 占大头:碰撞检测与约束构建都在这一步,step2 只复用其结果做剩余阶段;两次调用之和不会低于一次完整 mj_step,具体比例随模型而变。

同族函数各自定位:mj_inverse 从加速度反求 qfrc_inverse,调用前必须设好 qacc,且 API 注明正向与逆动力学数值上并不严格互逆(约束求解器提前终止);mj_Euler/mj_implicit 是 step2 末尾被调用的积分器本体(「mj_step 的阶段划分」)。

坑:写完 mjData 不 forward 就读派生量

qpos 改完而没 mj_forward,xmat、site_xpos 这些派生量还是旧值;mj_resetData 也不自动补这一步,重置后派生量全零直到第一次 forward。

3. MjData 的输入与输出字段

引擎把 MjData 的字段分成“用户写的输入”与“积分状态”两层,这条分界就是排查树的根。

文档把 MjData 的字段分成两层(programming/simulation 的状态与控制一节):用户输入层 mjSTATE_USER 由用户设置、影响仿真但引擎不会改它;积分状态 mjSTATE_INTEGRATION 是前者的超集,构成正向动力学的完整输入,其余所有字段都只是它的函数。逆向动力学时另有一条:qacc 也要当输入看。

把头文件里这几行的注释逐字对一下,输入/输出的分界就很直观(APItypes §mjData):

字段 头文件注释 层
qpos position (nq x 1) 积分状态
qvel velocity (nv x 1) 积分状态
act actuator activation (na x 1) 积分状态
ctrl control (nu x 1) 用户输入(仿真器不写它)
qacc acceleration (nv x 1) 正向动力学的输出;逆向时算输入
qfrc_bias C(qpos,qvel) 引擎算出的量
qfrc_actuator actuator force in joint space 引擎算出的量
qfrc_constraint constraint force 求解器的输出
sensordata sensor data array (nsensordata x 1) 传感器读数,只读旁路

⇒ 定位"关节为什么不动"时,这条输入/输出分界就是排查树的根:ctrl(用户)→ qfrc_actuator(执行器出力)→ qfrc_constraint(约束抢的力)→ qacc(净结果)→ qpos/qvel(积分后)。第 5、6 章各占其中一段。

另有一条常被误传的事实:没有 mjData.touch 字段。触觉是传感器:mjSENS_TOUCH = 0, // scalar contact normal forces summed over sensor zone,数据落在 sensordata 里。

4. 引擎的自检与告警

结论是:单个客户端的缺陷就能使整台机器人呈现"看似正常但完全不动"的状态,而任何表面读数都不会变红。

引擎自带的三个检查函数分工明确(APIfunctions):mj_checkPos(m, d) 检查 qpos,任一元素过大或为 NaN 就重置;mj_checkVel、mj_checkAcc 同式,分别管 qvel 与 qacc。它们默认不跑,因为检查位置和速度的计算代价高昂;跑起来以后若检测到指示发散的无效值或过大实数值,状态会自动重置并引发对应警告(programming/simulation 的错误、警告与日志记录一节)。同一节还有一条对照规定:错误是致命的、默认终止程序,警告只计数不中断。

警告枚举 mjtWarning 一共 7 条:mjWARN_INERTIA、mjWARN_CONTACTFULL、mjWARN_CNSTRFULL、mjWARN_BADQPOS、mjWARN_BADQVEL、mjWARN_BADQACC、mjWARN_BADCTRL——与本章直接相关的是后四条。没有 mj_checkControl 这个函数——命令的合法性检查内置在 mj_step 的钳位环节里;材料里出现这个函数名就说明它过时了。

命令非有限时,引擎把这条警告打到 stderr,并给 mjWARN_BADCTRL 的计数加一。两个细节值得先知道(运行环境 MuJoCo 3.11):

  • 告警文本里的 ACTUATOR n 是执行器索引,不是关节索引。在同一关节上挂两路执行器(a0、a1 都指 j0),把坏值分别灌进第 0 路与第 1 路,告警里的 n 跟着变成 0 与 1,而关节始终是 j0。
  • 同一次实验里 stderr 只写了 1 行告警,计数却涨了 2000(每步一次)。所以盯 stderr 会严重低估,必须读 d.warning[mjWARN_BADCTRL.value].number;而且状态被复位时这个计数还会被清零,判读时要同时记"过程中的峰值"与"结束时的计数"。

5. 事故复盘:NaN 命令下的停摆

现象(来自一个真实服务):running 仍 True、fps 仍 30、sim_s 每秒推进 1.0 s、physics_ms 正常、ctrl 挂着非零目标,而所有关节恒为静止值。

以下用一条最小模型复现该现象。需要预先明确:表面读数正常与状态不推进是两件相互独立的事,mj_step 的返回值不反映物理是否有效,d.time 也不反映状态是否变化。

单自由度滑移关节 + <position kp=1000 ctrlrange="-1 1">,预热后把 ctrl[0] 设成 NaN,跑 2000 步(4.000 s 仿真时间):

场景(gravity / forcerange / autoreset) qpos 末值 等于 qpos0 有限 time time 倒退 BADCTRL BADQACC
disable / 无 / 开 0.000000 True True 4.000 0 2000 0
disable / 无 / 禁 0.000000 True True 4.000 0 2000 0
disable / 有 / 开 0.000000 True True 4.000 0 2000 0
disable / 有 / 禁 0.000000 True True 4.000 0 2000 0
enable / 无 / 开 −0.008869 False True 4.000 0 2000 0
enable / 有 / 禁 −0.008869 False True 4.000 0 2000 0

(完整 8 行对照里,gravity=enable 的行里 qpos 在下落,那是重力在起作用,不是执行器。)

三条必须照抄下来的判读:

  1. NaN 的执行器等价于“这一路不出力”:qpos 精确停在 qpos0,而 ctrl 读数仍是 NaN、time 照常推进、mj_step 无返回值异常。这就是「事故复盘:NaN 命令下的停摆」那句“所有关节恒为静止值”的机理。
  2. 识别特征是 qpos 恒等于 qpos0 + warning[mjWARN_BADCTRL].number 每步 +1,而不是"time 倒退"。本表 8 行里 time 倒退次数全为 0。
  3. forcerange 与 autoreset 都不改变结果:钳位不消除 NaN(mjWARN_BADQACC 全程为 0,说明 NaN 没传播到加速度), 而这张表里 autoreset 的开与关两组数据逐字段相同——它之所以"看不出差别",是因为状态本来就停在 qpos0,没有可复位的余地(下一小节就把这层差别显出来)。

5.1 状态先离开 qpos0 时:复位事件才现形

同一台两路模型先预热 200 步(ctrl=0.5,qpos 走到 0.4974),再把其中一路换成 NaN、跑 2000 步:

量 灌 NaN 后 2000 步
qpos 末值 [0, 0](被拉回 qpos0)
ctrl 末值 [0.5, nan](用户输入层,引擎不回写)
time 4.400 s,倒退 0 次
状态复位事件 3 次(按"位姿突然回到 qpos0"检测)
mjWARN_BADCTRL 峰值 = 末值 = 2000
mjWARN_BADQACC / 非有限状态 0 / 无

两个结论:其一,回弹确实存在,但触发条件是状态先离开 qpos0——上一张表看不出它,正因为那台模型从头到尾就停在 qpos0。其二,这条复位与 autoreset 开关无关:enable 与 disable 两组在上表所有字段上完全一致(各 64 组参数扫描同样如此),它复位的是位姿与速度,保留 time 与 ctrl,所以时间读数一切正常、只有位姿被悄悄抹回起点——这正是"表面全绿而机器人不动"的机制。

参数扫描(384 组:四类积分器 × 三种 dyntype × 四组 kp/kv × 两种 armature × 有无 forcerange × autoreset 开关)里,触发类型只有一种:状态复位。没有出现 QACC/QPOS 级告警、没有出现 qpos/qvel 非有限、也没有 time 倒退;kp 越大复位次数越多(kp=1000 kv=0 时 7–9 次,kp=30 kv=1 时 1 次),其余维度都不改变结论。

5.2 真让状态带病跑:直接写坏 qpos

绕开执行器层、把 qpos[0] 直接写成 NaN(autoreset=disable):500 步后 qpos 仍是非有限,mjWARN_BADQPOS、mjWARN_BADQVEL、mjWARN_BADQACC 三个计数同时涨到 1000/998/… ——这才是状态级检出的样子。把 autoreset 打开,同一实验变成:位姿被复位、time 倒退一次(随后从 0 重新推进 1.0 s)。autoreset 真正管的是状态级复位;ctrl 层那条复位路径由坏命令处理本身触发,不由这个开关决定。 说明:这个症状早先被归因为「autoreset 反复回弹」。八组对照否掉了它作为通用解释——回弹不由 autoreset 开关触发,而由坏命令处理触发,且要求状态先离开 qpos0 才看得见(「状态先离开 qpos0 时」一节补齐了这组对照);autoreset 的实际作用体现在状态被直接写坏的那条路上(同一节的对照段)。

6. 夹取函数对 NaN 的行为

Python 侧两层夹取都拦不住 NaN——不是偶尔失效,而是比较式对 NaN 恒不成立。

这一点可以直接复核:min(max(nan, 0.0), 1.0) 返回 NaN,np.clip(nan, 0.0, 1.0) 同样返回 NaN。

机理:0.0 > nan 为假,max 就把第一个参数(nan)原样退回。任何形如"我先 clip 过了所以安全"的论证,对 NaN 都不成立。同族的还有 abs(x) > 1e3(对 NaN 恒为 False)与 np.nanmax(会静默跳过)。 执行器层的钳位还留着一个分岔,行为分界很干净:坏值能不能被 ctrlrange 拦住,取决于它是不是有限。同一台两路模型、ctrlrange="-1 1":

灌进第 2 路的值 ctrlrange 钳位 结果
普通越界 5 钳到 1 位姿停在 1.0,零告警
huge 1e20 钳到 1 同上,零告警
+Inf / -Inf 钳到 ±1 同上,零告警
NaN 钳不住(与任何数比较都为假) 该路不出力 + BADCTRL 每步 +1 + 位姿被复位

⇒ 由此能解释一类"改了单位却什么都不报"的现象:把度当弧度写、把毫米当米写,量级再离谱也会被 ctrlrange/forcerange 静默钳住,读数一切正常;只有真正的非有限值 NaN 会漏过钳位、触发告警。"没有告警"不能当作"命令合法"的证据,要单独查 ctrl 是否落在 ctrlrange 内。

⇒ 数值进物理引擎(或任何外部系统)之前必须显式 np.isfinite() 判断并拒绝。边界拒绝比上游修复更可靠——上游永远会有下一个 bug。

7. 内存分配、多线程与休眠

内存:MuJoCo 在 mj_makeData 时一次性预分配运行期全部内存,之后不再走堆分配器。mjData 的内存是两块连续区:buffer 装定长数组,arena 装变长数组——接触与约束数组从 arena 开头生长,栈数组从结尾生长,两端夹击,总量由一个数控制:<size memory>(单位字节,K/M 后缀进位;默认 -1 由编译器按"100 个接触 + 500 个标量约束"的启发式估)。arena 溢出分三种行为(programming 章):接触分配溢出 → mjWARN_CONTACTFULL 警告,本步不再新增接触,仿真继续;约束分配溢出 → mjWARN_CNSTRFULL,本步禁用约束求解器(物理会明显不同,但场景初始化时大量重叠体恰好用得上);栈溢出 → 硬错误。单球落地(nv=6)的对照:memory="16K" 时 contact/约束从 arena 头部用掉 16222 字节,栈只剩 162 字节,mj_stackAlloc 直接报错退出(错误自带 max/available/requested 与 nefc/ncon);memory="32K" 正常,ncon=1、球停在半径高度 0.0996 m。内存给小不会在加载期报错,只会在运行期第一步接触建立时爆——加载成功不等于 arena 够用。

诊断:警告之外还有两套累计计数器,都在 reset 时清零:mjData.timer 按 mjtTimer 枚举记录每个顶层 API 的耗时,duration/number 即平均单次(1 万步 mjTIMER_STEP 累计 0.0165 s ⇒ 1.65 µs/步);mjData.solver 按 mjSolverStat 记录每次约束求解的迭代数与改善量。性能调优先看 timer 分布,再动第 6 章调参清单里对应的旋钮。

多线程:仿真沿时间串行(上一步输出是下一步输入),但采样类应用里大量 forward/inverse 调用互相独立,可并行。MuJoCo 从设计上就是单结构状态:mjData 包含全部随时间变化的量,所以并行模板就是每线程一份 mjData、共享只读的 mjModel。3.11 还支持步内多线程(mju_threadpool):把碰撞检测与跨 island 的约束求解分发给 worker 线程(programming 章)。无论哪种方式,arena 与栈都是 per-mjData 的——这也是「健康检查的判据与成本」里"读 MjData 要与推进物理同线程"的底层原因。

休眠(Sleeping islands):静止的运动树被临时移出管线,最小休眠单位是运动树,约束相连的树一起睡成 island;默认关闭,<flag sleep="enable"/> 开启。仓库自带 model/sleep/dominos.xml(96 棵树)的开关对照:6000 步平均 228.3 vs 494.2 µs/步(2.16×),睡着后 nisland=0、ncon=0——island 内部与对静态体的接触被整体跳过,这是收益的大头。唤醒发生在 position stage,awake 树与 sleeping island 一接触即自动醒,行为与一直醒着一致;opt.sleep_tolerance 定速度容差,mjMINAWAKE=10 保证至少 10 步才能睡。

8. 健康检查的判据与成本

8.1 判据覆盖面

被控的一组读数正常,并不代表整机正常。落地模板(nu 只有一时看不出差别,54 只执行器的真机上差别就是覆盖面):

def health(m, d):
    """每步调一次;判据要看差分而不是瞬时值。"""
    # ① 命令层:NaN/Inf 或量级离谱(度当弧度写常落在这里)
    ctrl_bad = [i for i in range(m.nu)
                if not np.isfinite(d.ctrl[i]) or abs(d.ctrl[i]) > 1e3]
    return {
        "ctrl_nonfinite": len(ctrl_bad),
        # ② 状态层:引擎默认不检查(检查代价高昂),必须自己扫
        "qpos_nonfinite": int((~np.isfinite(d.qpos)).sum()),
        "qvel_nonfinite": int((~np.isfinite(d.qvel)).sum()),
        # ③ 量级而不是非有限值:发散的前兆是"大",不是"nan"
        "qvel_absmax": float(np.nanmax(np.abs(d.qvel))) if m.nv else 0.0,
        # ④ 停摆嫌疑(单独不可作判据,要和 ⑥ 配合)
        "frozen_at_qpos0": bool(np.allclose(d.qpos, m.qpos0, atol=1e-12)),
        # ⑤ 唯一不会被健康态误报的判据:它在递增还是恒为 0
        "warn_badctrl": int(d.warning[mujoco.mjtWarning.mjWARN_BADCTRL.value].number),
    }

注意最后两行的配合关系。frozen_at_qpos0 单独不能作为判据——一台完全健康的 54 执行器模型(ctrl=0、阻尼收敛后静置)也满足 qpos == qpos0。

静止的健康机器人同样满足 qpos == qpos0(一台 54 执行器的模型在 ctrl=0、阻尼收敛后静置,扫描结果为 qpos_nonfinite=0、qvel_absmax=0.0、frozen=True、warn_badctrl=0)。⇒ 真正区分"停摆"与"正常待机"的是 warn_badctrl 是否在递增:故障态下它每步 +1(2000 步 = 2000),健康态恒为 0。任何"看起来像故障"的布尔判据,都要先在已知健康的对象上跑一遍,看它会不会误报;会误报的判据必须配一个只在故障时变化的量。(同一族错误见第 7 章那个恒等于全图像素数的"非白像素"指标。)

另外 warning[...] 用的是累计计数(MjWarningStat 只有 number 与 lastinfo 两个字段,没有 lastchange),做长期监控要取增量,否则一只历史告警会一直伪装成"当前仍在告警"。

另外——frozen_at_qpos0 这条判据必须是 allclose 而不是 == 0,因为很多真机的 qpos0 本来就不是零。

8.2 健康检查项与成本

一份可直接抄成模板的排查清单,配合本节字段表即可落地:

  1. 全场 ctrl 是否有限(不是只看被控那一组):
    bad = [i for i in range(m.nu) if not np.isfinite(d.ctrl[i]) or abs(d.ctrl[i]) > 1e3]
    
    配套 qpos_nonfinite / qvel_nonfinite / qvel_absmax。
  2. opt.disableflags 是否把执行器全局禁了("mjModel.opt.disableflags 和 mjModel.opt.enableflags 中的位标志可分别用于跳过默认步骤和启用可选步骤",XML reference §option/flag)。这一项能解释"命令有值但完全没力",且不产生任何警告**。
  3. qfrc_actuator / actuator_force 是否出力,以及是否有代码每帧写入 qpos——后者通过在每个 qpos 写入点登记调用者得到直接结论。

成本 = 单次耗时 × 每拍次数 × 客户端数。在同一份模型上测一次全场扫描的开销:

模型规模 health() 单次 mj_step 单次 比值
nu=nq=nv=3 47.53 µs 2.99 µs 15.9×
nu=nq=nv=54 122.28 µs 10.95 µs 11.2×

⇒ 一次全场扫描约 0.12 ms,单次几乎免费,但按消费者数量乘出来就不免费了:30 Hz 下每拍扫一次,8 个客户端就把它变成 29.35 ms/s 的占用,而同期物理步进只花 0.33 ms/s。所以:

  • 走单独一条按需拉取的调试路由,不挂到高频遥测里;
  • 并且读 MjData 的路由必须与推进物理的同线程(Python 侧 async def 里同步读,或加锁 + sync()),否则会读到半更新状态。
  • 容量要按最长单次耗时定,不按字段数估:这里 nu 从 3 涨到 54,单次耗时只从 47.5 涨到 122.3 µs(Python 侧属性访问的固定开销占了大头)。

9. 要点

  • 26 个阶段、mj_step1/2 的分界(19/20)与 RK4 的不兼容都是文档明写的。
  • mj_forward 刷新派生量不推进状态;写 mjData 与读派生量之间必须隔着一次 forward 或 step;mj_resetData 不自动 forward(重置后派生量全零)。
  • 坏值分两类:非有限 NaN 漏过 ctrlrange 钳位并触发 mjWARN_BADCTRL;huge 与 ±Inf 被静默钳到边界,零告警。
  • 位姿被抹回 qpos0 而 time、ctrl 不变,来自坏命令处理,与 autoreset 无关;autoreset 管的是状态被直接写坏那条路(复位会把 time 也退一次)。
  • arena 两端夹击:contact/约束从开头、栈从结尾;溢出三行为(警告继续/警告禁求解/硬错误),加载成功不等于 arena 够用。
  • 多线程 = 每线程一份 mjData、共享只读 mjModel;timer/solver 统计是警告之外的另外两套诊断计数器。
  • 休眠 island:<flag sleep="enable"/> 开启;dominos 模型开关对照 2.16×,睡着后接触整体消失;唤醒在 position stage 瞬时生效。
  • 输入/输出分界(mjSTATE_USER / mjSTATE_INTEGRATION)就是排查树的根。
  • 传感器在积分前算 ⇒ 读数的时间归属要显式定义(拿 mj_step1/mj_step2 手动拆开一步,在两种时机各读一次 sensordata 就能看见差异)。
  • 面板数值(time/fps/ctrl)与状态推进是两回事;disableflags 不报警告(设 <flag actuator="disable"/> 就能复制这种“全绿假死”)。
  • 健康检查按需拉取,且与物理同线程读。

  • NaN 进 ctrl ⇒ 该路执行器不出力,状态钉在 qpos0,而 time/fps/ctrl 读数全部正常。

  • 判据:qpos == qpos0 + BADCTRL.number 递增;全场扫,不看被控那一组。
  • 夹取不清 NaN;isfinite 是唯一可靠的拒绝点,且在边界做。
  • 没有 mj_checkControl;mjData.touch 也不存在(触觉是传感器)。
  • 顶穿 range 的回弹由坏命令处理触发、与 autoreset 无关(而 autoreset 真正管的是直接写坏 qpos 那条路),这正面上线与参数条件一并写清。

这些机制可拆开来看:用 mj_step1/mj_step2 将一步拆开,可见 integrator="RK4" 与 mj_step1 组合时引擎的反应;给一个正常模型设 <flag actuator="disable"/>,即呈现 qpos 不动、ctrl 有值、warning 全 0 的“无警告假死”;把「健康检查项与成本」那三项包成一个 health() 函数挂到一条独立的 HTTP 路由上,其单次耗时要计入预算——成本在常规脚本里看不见,上到 30 Hz 才暴露。

参考文献与延伸阅读

  • Todorov, E., Erez, T., & Tassa, Y. MuJoCo: A Physics Engine for Model-Based Control. IROS, 2012. DOI
  • MuJoCo. 文档入口(运行时与数值选项). 文档
  • MuJoCo. 源码仓库. 仓库