MuJoCo 地面差速小车单侧驱动失效检测与差速容错补偿

一侧轮毂拖滞的差速小车,两个轮子都还在转,车却慢慢往一边偏。这个模块用位姿与轮速序列预测 未来 1.0 s 内会不会偏出 0.5 m,在偏出去之前把左右轮速差拉开去顶回来。仿真里做了三档拖滞量, 把"不干预""位姿门限纠正"和"神经网预测后补偿"三组放在同样的工况下跑,比跑偏率与残余偏差。
1. 课题定位
仓库里已经有 mujoco_diff_drive_slip_compensation(打滑补偿,处理轮子空转)、
mujoco_car_rollover_guard(侧倾风险,处理车身横滚)、lateral_control_benchmark(横向跟踪精度),
但这些模块的前提都是"两个轮子都能正常出力"。
执行器本身坏掉一侧的情况没有模块覆盖:轮毂轴承发涩、制动卡钳拖滞、单侧电机退磁,表现都是 一侧轮子转得"吃力",车被另一侧推着走。本模块就做这一件事:
| 模块 | 故障对象 | 判据 | 输出 |
|---|---|---|---|
mujoco_diff_drive_slip_compensation |
轮胎与地面之间的滑移 | 轮速与车速不一致 | 补偿后的轮速 |
mujoco_car_rollover_guard |
车身横滚 | 横滚角与角速度 | 限速后的指令 |
| 本模块 | 单侧驱动阻力矩 | 偏航率与横向偏移的时序 | 风险概率 + 差速补偿量 |
和前两个模块的区别在于故障在传感器上几乎是隐形的:<velocity> 伺服会自己补力矩把轮速拉回指令值,
左右轮速差的实测均值只有 0.08 rad/s 量级(见 8.3),拿轮速差做阈值查不出来,只能从"车开始偏"
这个位姿后果往前推。
2. 运行环境
- Ubuntu 22.04 + ROS 2 Humble(虚拟机复核用这套),Windows 10/11 + Python 3.10 也能直接跑仿真与出图
- Python 依赖:
mujoco>=3.0.0、numpy、matplotlib、imageio、pillow(见requirements.txt) - ROS 2 侧只依赖
rclpy与sensor_msgs/nav_msgs/geometry_msgs/std_msgs - 实测版本:Windows 侧 Python 3.10.11 / MuJoCo 3.16.0,虚拟机上 MuJoCo 3.15.0 / numpy 1.26.4
pip install -r requirements.txt
3. 车辆与故障注入
底盘沿用仓库里已跑通的差速小车:4 kg 车身、0.05 m 轮半径、轮距 0.18 m、前后支撑球轮、
<velocity> 轮速伺服(kv=10,ctrlrange=±60,forcerange=±8),控制 50 Hz、物理步长 2 ms。
故障注入只改一处——给单侧驱动轮的 hinge 关节加 damping,模拟轮毂轴承或制动卡钳的拖滞:
<joint name="wheel_l_joint" type="hinge" axis="0 1 0" pos="0 0.09 0"
damping="0.10" armature="0.005" range="-1e9 1e9"/>
三档拖滞量 models/car_scene_fault_{light,mid,severe}.xml 分别为 0.08 / 0.10 / 0.12(健康基线 0.05),
其余结构、颜色、初始位姿完全一致,所以三档可以直接横比。场景另加了两条车道边界
edge_l / edge_r(y=±1.4 m)和四个轮端传感器:
<sensor>
<jointpos joint="wheel_l_joint" name="wheel_l_pos"/>
<jointpos joint="wheel_r_joint" name="wheel_r_pos"/>
<jointvel joint="wheel_l_joint" name="wheel_l_vel"/>
<jointvel joint="wheel_r_joint" name="wheel_r_vel"/>
</sensor>
拖滞量不是随便定的。0.20 / 0.50 / 0.90 会让车直接原地打转(偏航角 90°~155°),已经不是"跑偏" 而是"失控";0.07 / 0.12 / 0.18 这一组又整段落在饱和打转区。最后在健康基线 0.05 之上取 0.08 / 0.10 / 0.12:两侧完全对称、不补偿都会跑偏、补偿后都能压到 0.10~0.25 m,才是本模块能处理的区间。
4. 原理
4.1 拖滞为什么会让车跑偏
单侧关节加阻尼后,该轮多出一个与转速反向的阻力矩 。<velocity> 伺服想把轮速维持在
指令值,会自动加大输出力矩去抵消它,力矩上限 forcerange=±8:
三档拖滞产生的阻力矩都远小于 8 N·m,所以伺服基本都能补上,代价是两轮实际角速度几乎相等:
(8.3 表格里"不干预"组实测 rad/s,和健康车的噪声一个量级。) 力矩补上了,但两侧牵引力不再平衡,车身获得一个持续的偏航力矩,偏航率积分成航向角,航向角再积分成 横向偏移:
所以故障的可观测量不是轮速差,而是偏航率 与横向偏移 。这也是本模块用"位姿时序前瞻" 而不是"轮速差阈值"的原因。
4.2 差速运动学
归一化指令 (steer, throttle) 换算成左右轮目标角速度:
steer>0 左转(左轮减速)。补偿量直接叠加在 steer 上,再走同一条换算。
4.3 补偿控制
横向容错控制器是 PD + 积分:
系数 、、、,积分限幅 ±0.6,总补偿限幅 。积分项的作用是吃掉恒定拖滞造成的稳态偏置——纯 PD 会留下一个固定残差。
符号试过两种: 相当于顺着跑偏方向再打方向,三个工况全线跑偏 1.05~1.08 m;
只有 才是往回顶。这一点在 test_guard.py 里用左右两个方向各钉了一条用例。
4.4 风险网络
9 维输入,全部来自 IMU、里程计与轮端传感器:
(横向偏移、航向角、偏航率、侧向加速度、前向车速、左右轮实际角速度、左右轮指令角速度)。
按物理量程 [1.2, 1.2, 4, 15, 4, 60, 60, 60, 60] 归一化后裁到 ±3,尺度在训练前就固定,
不用数据统计量,换场景不用重算。
网络是两层全连接:9 → 16(tanh)→ 1(sigmoid),177 个参数,纯 numpy 实现(前向、反向、Adam 都手写),
不引入 PyTorch。损失用带类别权重的二元交叉熵,正样本只占 32.1%,权重 pos_weight=2.08。
标签用事件窗口:以横向偏移首次超过 0.5 m 的时刻为中心,向前 1.0 s、向后 1.0 s 都标 1(共 99 个 控制周期),其余标 0。试过只标"越界前 1.0 s"(网络学到的是"马上要越界"而不是"已经在越界", 松手过早),也试过"越界后整段标 1"(正样本 50.5%、评测 AUC 反而到 1.000,但训练分布被回程段主导), 最后选双向窗口:既能提前预警,也能在回程段继续压住。
5. 算法流程
采集:3 档拖滞 × 2 个失效侧 × 2 档车速(0.6 / 1.2 m/s)= 12 个工况,不干预跑到 6.5 s 或出界
↓ 每 20 ms 记录 9 维特征 + 是否落在跑偏事件窗口内
训练:按工况切分训练/验证(9 / 3),加权 BCE + Adam,220 轮
↓ 保存 drive_fault_risk.npz
评测:换 0.9 m/s(训练没见过),3 档拖滞 × 2 侧 × 3 策略 = 18 次
↓ none / rule / nn 的跑偏率、最大偏差、首次介入时刻
门控:风险 EMA 平滑后 > 0.60 触发,触发即给满权限;> 0.85 同时减速
6. 源码结构
mujoco_car_drive_fault/
├── main.py # 统一入口:--collect / --train / --eval / --demo / --test / --launch
├── mujoco_car_drive_fault/
│ ├── config.py # 全部参数与路径(路径由 __file__ 推导,无绝对路径)
│ ├── kinematics.py # (steer, throttle) -> 左右轮角速度
│ ├── car_env.py # 场景加载、步进、IMU/轮速读取、横向偏移与跑偏判定
│ ├── features.py # 9 维特征构造与定尺度归一化
│ ├── model.py # RiskMLP(两层 MLP + 加权 BCE + Adam)+ AUC
│ ├── guard.py # 补偿控制器 + 风险门控(pass/assist/slow)与位姿门限
│ ├── scenario.py # 工况执行器、标签生成、速度保持
│ ├── sim_bridge.py # ROS 2 桥:/imu、/odom、/wheel_speed、/chassis_state ← /cmd_vel
│ └── guard_node.py # ROS 2 门控节点:/fault_risk、/guard_state → /cmd_vel
├── models/ # 三档拖滞场景
├── config/params.yaml # ROS 参数
├── launch/main.launch.py # 双节点一键启动
├── results/ # 数据集、权重、指标 json、曲线
├── test/ # 49 项单元测试
└── tools/check_scene.py # 场景结构自检
几个关键函数:
car_env.CarEnv暴露lateral_offset()、yaw()、wheel_speeds()、imu(),场景 XML 与阻尼量在reset(fault_side=..., fault_damping=...)里注入;scenario.hold_throttle(v, v_target, cfg)用"目标车速前馈 + 速度误差比例",前馈项是v_target/v_max而不是常数,健康车 6 s 内横向偏移恒为 0;guard.comp_steer(y, yaw, yaw_rate, cfg, gain, integ)输出补偿转向,guard.FaultGuard.decide(...)返回(gain, action),action ∈ {pass, assist, slow};scenario.label_deviate(feat, deviate, t_deviate, cfg)生成事件窗口标签。
7. 运行步骤
# 1. 采集(12 个工况,约 1 min)
python3 main.py --collect
# 2. 训练(约 5 s)
python3 main.py --train
# 3. 评测并出图(18 次工况,约 3 min)
python3 main.py --eval
# 4. 录制演示 gif
python3 main.py --demo
# 5. 自检:49 项单测 + 三场景冒烟
python3 main.py --test
python3 main.py --test --logic-only # 未装 MuJoCo 也能跑纯逻辑单测
Windows 上把 python3 换成 python,或直接 main.bat --eval。
7.1 ROS 2 闭环
colcon build --packages-select mujoco_car_drive_fault --symlink-install
source install/setup.bash
ros2 launch mujoco_car_drive_fault main.launch.py # 默认 mid 0.10 / 左轮
ros2 launch mujoco_car_drive_fault main.launch.py scene:=car_scene_fault_severe.xml fault_side:=right
在 Ubuntu 22.04 + Humble 虚拟机上(无图形界面,ROS_LOCALHOST_ONLY=1、unset DISPLAY)实测:
| 场景 | 故障侧 / 车速 | 结果 | 最大偏差 | 末端偏差 |
|---|---|---|---|---|
| mid 0.10(默认) | left / 0.9 m/s | 跑满 6.02 s 未跑偏 | 0.31 m | +0.11 m |
| severe 0.12 | right / 0.9 m/s | 跑满 6.02 s 未跑偏 | 0.40 m | +0.23 m |
两行都是门控全开(mode=nn)的结果,单次运行、未做多次取均。闭环里指令经话题传递、
在下一仿真步生效,和离线逐帧推进有时序差,数值不要求和第 8 节完全一致。
节点侧有三处工程处理,不在网络本身:
- 网络输出在临界附近会在相邻帧间跳到
0.00/1.00,门控取最近 3 帧风险的最大值; - 触发后直接给满补偿权限(
gain=1.0),而不是按风险比例给——按比例给时comp_limit×gain顶不住拖滞,实测 6/6 仍然跑偏、残余 0.65 m; - 松手要同时满足"风险回落到 0.60 以下""横向偏移 < 0.10 m""航向 < 1.5°"三个条件, 否则一次过冲就会把补偿松掉,车接着往外漂。
节点拓扑:
sim_bridge ──/imu、/odom、/wheel_speed、/chassis_state──▶ guard_node
▲ │
└────────────────────/cmd_vel────────────────────────────┘
guard_node 自己产生"直行"的名义指令(不含转向任务),所以补偿量就是它对名义指令的唯一改动;
sim_bridge 把指令写进 MuJoCo 执行器并按 50 Hz 发布传感器数据。桥接节点跑完当前工况退出,launch 随之关闭。
7.2 参数
config/params.yaml 里改场景、失效侧、目标车速和运行时长,不用改源码也不用重编:
/mujoco_car_drive_fault_sim_bridge:
ros__parameters:
scene: car_scene_fault_mid.xml
fault_side: left
fault_damping: 0.0 # 0 表示取场景自带的拖滞量
run_seconds: 6.0
control_hz: 50.0
/mujoco_car_drive_fault_guard:
ros__parameters:
v_target: 0.9
mode: nn
model_path: ""
8. 多场景性能评价
8.1 训练结果
12 个工况共 3612 个控制周期样本,正样本 1160 个(32.1%),按工况切分后训练 9 个、验证 3 个:
| 指标 | 训练集 | 验证集 |
|---|---|---|
| AUC | 1.000 | 0.992 |
| 准确率(阈值 0.5) | 0.989 | 0.966 |
| 样本数 | 2709 | 903 |
220 轮后训练损失 0.0244,177 个参数。

8.2 未参与训练的工况上的风险预测
评测换用 0.9 m/s(训练只见过 0.6 / 1.2 m/s),在"不干预"组上逐样本比较预测与标签:
| 指标 | 数值 |
|---|---|
| AUC | 0.998 |
| 阈值 0.60 下准确率 | 0.962 |
| 样本数 / 正样本数 | 1806 / 600 |

上图是 mid 0.10、左轮故障、0.9 m/s 的一次真实跑偏:2.2 s 之后风险持续爬到 1.0 并保持到 4.7 s, 横向偏移在 3.36 s 越过 0.5 m。风险曲线在越界前 1 s 出头就已经接近 1,这是 1.0 s 前瞻的直接体现; 越界后曲线没有立刻掉下去,是因为事件窗口把回程段也标成了正样本,网络不会"过线就松手"。
8.3 十八次工况对比
三组策略在同样的 18 次工况(3 档 × 2 侧 × 3 车速档里的 0.9 m/s)上的汇总:
| 策略 | 跑偏次数 | 跑偏率 | 平均最大横向偏移 | 平均最大航向 | 首次介入 | 补偿帧 |
|---|---|---|---|---|---|---|
| none(不干预) | 6 / 6 | 100% | 1.12 m | 67.9° | — | 0 |
| rule(位姿门限) | 0 / 6 | 0% | 0.19 m | 13.9° | 1.34 s | 200 |
| nn(1.0 s 预测后补偿) | 0 / 6 | 0% | 0.40 m | 22.6° | 2.44 s | 135 |

按档拆开看最大横向偏移(同档左右两侧取较大值):
| 场景 | none | rule | nn |
|---|---|---|---|
| light 0.08 | 1.12 m | 0.16 m | 0.44 m |
| mid 0.10 | 1.13 m | 0.22 m | 0.39 m |
| severe 0.12 | 1.12 m | 0.18 m | 0.42 m |
这张表不能读成"nn 更好"。数值上 rule 介入更早(1.34 s)、残余更小(0.19 m),nn 反而保守 (2.44 s 才开始动、残余 0.40 m 但也没跑偏)。原因在于门控制度不同:rule 盯的是"已经偏了 0.15 m 或 航向已经偏 3°",一触发就按 PD 持续纠正;nn 要等预测概率稳过 0.60 才满权限介入,介入得晚、动作也 更犹豫,靠积分项慢慢把残差吃回去。本模块的卖点不是"更早更准",而是:
- 轮速差查不出故障:none 组 rad/s,rule/nn 组因为主动拉开差速才到 0.21 / 0.35 rad/s,如果拿 做阈值,故障根本没有可用的前兆信号;
- nn 在越界前 1 s 就把风险推到接近 1(8.2 的时序图),这条提前量在 rule 那边是不存在的。
8.4 差别在哪里
- 三档拖滞都落在"能补但补不干净"的区间:不补偿 3.3~3.6 s 就越过 0.5 m,补偿后残余 0.2 m 上下;
- 拖滞再大(≥0.20)进入饱和打转区,单靠差速修正顶不住,需要降速或停机,这超出本模块范围;
- 失效侧左右对称:同样档位下左右两侧的最大偏差差异 < 0.1 m,补偿器不需要知道故障在哪一侧。
9. 局限与可以往下做的方向
- 拖滞用单轮 hinge 关节的
damping表征,是"轴承/制动拖滞"的简化模型,没有建摩擦、温升与磨损; - 三档阻尼是离散标定,0.20 以上进入原地打转区,本模块的可补偿区间有硬边界;
- 只做横向容错,不做故障轮隔离、降速停车或故障侧辨识;
- 补偿权限有硬上限(
comp_limit=0.45),拖滞很大时补偿饱和,残余偏差会持续存在; - 平地直线单场景,没有坡道、侧风与弯道;车道边界固定
y=±1.4 m; - 训练数据全部来自本仿真器,跨仿真器(Gazebo、CARLA)迁移需要重新采集。