神经残差动力学与学习式轨迹跟踪

模块定位

本模块建立在前一模块 MuJoCo 四旋翼仿真与 ROS2 桥接底座 之上,对应作业任务(1)运动控制与任务(4)端到端神经网络。 代码位于仓库的 src/air/uav_neural_residual_control/。

传统控制器假设"模型已知"——被控对象的动力学方程写在纸上,控制器按方程反解控制量。 真实的四旋翼上这个假设不成立:气动阻力、阵风、挂载载荷、地面效应都不在方程里。 反馈能压住一部分,但要消耗稳定裕度,而且对时变扰动束手无策。

本模块换一条路:用一个小型前馈网络学习模型误差本身,把补偿量做成前馈。 网络只学"标称模型与真实动力学差多少",不学整个动力学,因此样本需求小、 外推行为可控;再叠一层递推最小二乘在线重写最后一层,把泛化误差变成辨识误差。

              ┌─────────────── 离线训练 ────────────────┐
   状态 x ──▶ │ 特征 φ(x) ──▶ MLP(14→64→64→3) ──▶ Δa_hat │──┐
              └────────────────────────────────────────┘  │
                                                          ▼ 前馈补偿
              ┌─────────────── 在线自适应 ──────────────┐  │
   实测 Δa ──▶│ RLS(只更新最后一层,65×3)─────────────│──┘
              └────────────────────────────────────────┘
                                    ▼
   几何控制器:  f_des = m·(a_d − Δa_hat + Kp e_p + Kd e_v + g e_z)

一句话结论:在 4 种载荷 × 3 种风况 × 3 条轨迹 × 4 个控制器、共 432 次 闭环运行的对比实验中,几何控制器的稳态误差严格等于模型偏差除以增益 (挂 0.5 kg 时 0.279 m);加上离线残差网络后降到 0.075 m;再加上在线自适应, 连训练集里从未出现过的 +0.75 kg 载荷也能压到 0.026 m,与标称工况持平, 且 432 次运行无一发散。


1. 与已有模块的区别

课程仓库中已有的无人机运动控制模块:

已有 它是什么 本模块不同在哪
uav_keyboard_control 键盘 → 速度指令 → 位置环 位置环直接作用于已知模型,不估计模型误差
allocnet_teleop 神经网络做控制分配 分配矩阵是已知的,网络只解决执行器层面的分配
uav_ppo_nav 端到端强化学习导航 奖励函数里的避障,没有显式动力学模型,不保证跟踪精度
uav_mujoco_sim(本项目的底座) 仿真与桥接 提供 /uav/odom 等话题与几何控制器,不含任何学习成分

"学模型误差、而不是学控制器" 这件事在课程内是空白。它与强化学习路线正交: 残差网络输出的物理量(m/s²)可以叠在任何一个现有控制器上。


2. 问题定义

2.1 标称模型与残差

控制器所使用的标称模型(世界系 ENU, 为机体→世界旋转):

残差加速度定义为两者之差:

它全部由可观测量算出: 由里程计速度差分得到, 由下达的推力与姿态算出。不需要知道真值质量、真值阻力系数 ——这正是它作为在线学习标签的合法性所在。

2.2 为什么在机体系里学

阻力在机体系里是速度的固定函数(各向异性但姿态无关),载荷偏置也随机体一起转。 把目标量表成机体系 、输入特征也全用机体系量, 网络学到的是"速度 → 力"这一与姿态解耦的映射:

同一份权重因此能覆盖任意姿态,样本效率提高一个量级。

2.3 前馈补偿为什么不消耗裕度

几何控制器的期望推力为

标称模型对加速度是线性的,因此把网络预测的 直接从 里减掉, 等价于换了一个更准的模型:

控制器内部一行都不用动。 误差只剩网络的预测残差 ,反馈只需要处理这一小截,稳定裕度基本不受影响。

2.4 常值扰动的下界:为什么反馈不够

几何控制器没有积分项,稳态误差满足

实测与解析的对照(定点悬停、):

| 载荷 | 解析 | 解析静差 | 实测静差 | |---|---|---|---| | 无 | 0 | 0 | 0.000 m | | +0.25 kg | 2.58 m/s² | 0.129 m | 0.129 m | | +0.50 kg | 5.16 m/s² | 0.258 m | 0.259 m | | +0.75 kg | 7.74 m/s² | 0.387 m | 0.390 m |

这个静差与扰动同量级、无法靠调增益消掉(增益翻倍会把控制回路推到 100 Hz 采样下的稳定边界外)。位置 PID 可以靠积分消掉它,代价是把阵风的低频分量 也当成"需要消除的偏差"持续追风,吃掉抗扰动的相位裕度。前馈补偿两者都不需要。


3. 未建模效应的物理建模

四类效应都在 core/disturbances.py 里,量级按实物标定。

3.1 气动阻力

机体系各向异性二次阻力(四旋翼是扁的,垂直迎风面积远大于水平):

系数按"10 m/s 时阻力约占重力 60%(对应约 33° 前倾)"标定,与实物量级一致。 若随手取"每 m/s² 几牛",10 m/s 下阻力会超过推力上限,轨迹直接发散。

3.2 阵风

平均风 + 一阶 Gauss-Markov(OU 过程,时域上与 Dryden 谱等价):

这个离散化是 OU 过程的精确解而不是欧拉近似,任意步长下稳态方差都严格等于 。默认 s,垂直分量按 0.5 缩小。风通过相对气流速度 进入阻力,因此是一个真实的物理扰动而不是外加的力。

3.3 挂载载荷

在机体下方以 XML 后处理的方式挂一个集中质量刚体。不用直接改 model.body_mass:那样会绕开 MuJoCo 的惯量重算(mj_setConst), 质量与转动惯量静默不一致——看着能跑,姿态响应完全不对。

载荷同时改变真值质量与转动惯量,是常值残差的主要来源。

3.4 地面效应

近地时同一转速产生更大推力:

它依赖高度而非速度,是检验网络是否真的学到状态依赖、而不是退化成 "学一个常值偏置"的关键项。

未建模效应

图 1:(a) 阻力随相对气流速度的二次增长,以及 35° 倾角能提供的水平推力上限; (b) 阵风的一阶 Gauss-Markov 时程;(c) 各载荷档位对应的垂直残差量级。


4. 残差网络

4.1 特征设计:把物理规律喂进去

14 维输入,全部取机体系量:

中间三项是二次阻力基:气动阻力本就正比于 ,把这一项显式喂进去, 网络不必从零拟合平方关系。

高度 与归一化推力 是为地面效应与载荷准备的:悬停时 ,因此它唯一地编码了载荷档位, 网络可以把常值载荷偏差学成一个精确的线性项。

4.2 结构

项 取值 说明
结构 14 → 64 → 64 → 3 5,315 个参数,43 KB
隐层激活 tanh 输出层线性
初始化 Glorot;输出层全零 未训练时残差恒为 0,控制器退化为纯几何控制器
输出缩放 ×2.0 m/s² 让归一化后的目标落在 O(1)
输出限幅 ±10 m/s² 见下

输出层零初始化不是可有可无:网络在训练前若给出随机残差,闭环会被直接掀翻, 连数据都采不到(--selftest 里有一项专门验证这一点)。

输出限幅的取值有个坑:限幅必须大于训练目标本身的量级。+0.5 kg 载荷 对应的垂直残差就有 5.16 m/s²,早期把限幅设成 3.0,训练集 RMSE 卡在 0.82 m/s² 再也降不下去——网络连训练集都拟合不了。取 10 m/s²(约 1 g, 标称悬停推力的 2/3)既覆盖全部档位又留了安全边界。

4.3 手写反向传播

不引 PyTorch 有三个理由:仿真侧(Python ≥3.10)与 ROS 侧(Humble 的 Python 3.8)都要能 import,纯 numpy 省掉一个 700 MB 依赖;这个规模下 torch 并不更快(9 万样本 60 轮实测 12 s);ROS 节点不需要额外安装任何东西。

反向传播按链式法则手写,--selftest 用中心差分逐点校验, 相对误差 (实测 )。

一个真金白银换来的坑

MSE 的梯度必须除以元素总数 ,而不是样本数 —— np.mean 摊的是全部元素。只除 会让梯度整体大 3 倍。 Adam 对梯度整体缩放不敏感(所以这个 bug 训练时完全看不出来), 但换成 SGD 或调学习率就会对不上。

残差学习

图 2:(a) 训练/验证收敛曲线;(b) 残差预测的散点(机体系,三轴叠加); (c) 预测误差分布。


5. 在线自适应:最后一层的递推最小二乘

离线网络只见过 none / light / heavy 三档载荷,遇到没见过的档位只能靠泛化。 在线自适应用飞行中实测的 直接修正网络:

只更新最后一层(65 × 3 = 195 个参数),前面几层冻结。理由是在线数据 时序强相关(一条轨迹上的速度不可能跳变),对整个网络做梯度更新会沿相关方向 把隐层冲垮;而最后一层是线性回归,RLS 对相关数据的收敛性有闭式保证, 单步只要 (,实测 6 µs,100 Hz 下占用 < 0.1% CPU)。

5.1 三个必须做的防护

RLS 在这类问题上天然不稳,三个防护缺一不可:

(1)先验协方差 —— 最关键的一个。 的初值取离线网络的最后一层, 表示对它的置信度。 取 相当于认为离线解完全不可信,第一次更新就按 把 精确拟合到单个样本上。 定点悬停(回归向量几乎不变、零空间维度极高)时实测 RMSE 0.14 ~ 0.25 m, 比不用 RLS 还差; 取 后同一组工况降到 0.019 ~ 0.027 m。

(2)空转期。 先跑 100 步"只辨识、不施加",等 在真实数据上收敛 再接管最后一层。起飞段恰好是最大最脏的一个样本,直接施加会当场翻机 (实测 +0.75 kg 档 RMSE 4.2 m)。

(3)协方差上限。 回归向量长期只占一个方向时(定点悬停), 会在零空间里 按 指数增长,等阵风把 转到新方向时增益突然变大。 超限时按比例缩放协方差而不是重置成初值——重置等价于声称" 一无所知", 下一次更新照样跳,没有意义。

另一个真金白银换来的坑

RLS 是原地改写最后一层的。如果直接把外部传入的网络对象交给跟踪器, 后面每一次运行都会从上一轮被改写过的权重出发。实测同一份模型连跑 9 个工况, RMSE 出现 4.52 m 的乱跳,而单独跑同一工况只有 0.02 m——全部是权重被 前一轮污染导致的。跟踪器里必须深拷贝。


6. 算法流程

graph TD A["离线:纯几何控制器 + 带限随机激励
飞 81 条轨迹(3 载荷 × 3 风况 × 3 轨迹 × 3 种子)"] --> B B["记录 (状态, 指令) → 实测 Δa
丢弃起飞前 1.5 s、剔除 |Δa| > 25 m/s²"] --> C C["标准化 → Adam 小批量训练
按验证集早停"] --> D D["保存 43 KB 权重"] --> E subgraph ONLINE["在线"] E["读里程计 + 自己下达的推力"] --> F["算实测 Δa = a_meas − a_nom"] E --> G["构造机体系特征 φ"] G --> H["MLP 前向 → Δa_hat"] F --> I["RLS 更新最后一层
(前 100 步只辨识不施加)"] H --> J["a_eff = a_d − Δa_hat"] I --> J J --> K["几何控制器 → 推力/力矩"] K --> L["逆混控 → 四桨推力"] L --> M["下发(仿真或真机)"] M --> E end

采集为什么要加激励? 沿参考轨迹飞时状态几乎落在一条一维流形上, 网络在这个流形外没有任何约束(实测:无激励时训练集 RMSE 0.03 m/s², 但换一条轨迹立刻退化到 0.44 m/s²)。叠加一阶低通的带限随机加速度 ( m/s², s)把状态铺开到整个包线—— 用带限而不是全带白噪声,是因为全带信号会被姿态环滤掉,只留下推力抖动, 状态反而铺不开。

采集为什么必须用纯几何控制器? 只有控制器按照标称模型行动, 实测加速度与标称预测之差才是纯粹的模型误差。用带残差的控制器采集, 网络学到的是"自己上一步的修正",会自洽地收敛到一个错误模型。


7. 四个控制器

四者共用同一套内环(姿态几何 PD、逆混控、限倾角、饱和处理), 只差在期望加速度 怎么算。实现上把底座的几何控制器 置零,让它退化为"加速度 → 推力/力矩"的执行器。因此性能差异 可以完全归因于外环与残差补偿,不会混入实现差异。

名字 外环 抗常值扰动 抗时变扰动
pid 位置 PID(含条件积分抗饱和) 积分消掉 积分把阵风低频分量也当偏差追
geo SE(3) 几何控制 + 轨迹前馈 静差 反馈带宽内可压
geo_res 几何 + 离线残差网络前馈 前馈抵消 网络预测依赖状态,可泛化
geo_rls 几何 + 离线残差 + 在线 RLS 在线辨识 在线辨识

7.1 姿态内环增益必须重新整定

底座的姿态增益是按 0.95 kg 空机整定的。挂到 +0.75 kg 时转动惯量涨约 1.6 倍, 姿态环阻尼比从 0.41 掉到 0.32,起步的 35° 倾角阶跃会过冲到 50° 以上, 进而触发翻机(实测倾角 177°)。

对 12 组 缩放组合做了扫描,把角速率阻尼 加倍 ()后,0.95 ~ 1.7 kg 全载荷域稳定,且跟踪误差随载荷单调。

偏航力矩受混控预算约束

合推力 下可用的偏航力矩上限是 , 标称档位悬停时只有 0.14 N·m。偏航误差超过约 13° 就会吃光混控预算, 把滚转/俯仰的权限按比例压掉,无人机随即翻掉。因此所有轨迹都从轨迹 自身的初始偏航起飞;从偏航误差 90° 的地方起飞实测倾角 177°、 残差数据里出现 207 m/s² 的废样本。


8. 实验设计

维度 取值
控制器 pid / geo / geo_res / geo_rls
载荷 无 / +0.25 kg / +0.50 kg / +0.75 kg(训练集外)
风况 无风 / 1.5 m/s + σ0.9 / 3.2 m/s + σ1.8
轨迹 定点悬停 / 螺旋爬升 / 水平八字
种子 3 条独立风实现(OU 过程不同随机实现)

共 4 × 4 × 3 × 3 × 3 = 432 次闭环节运行,全部从轨迹自身的初始位姿起飞, 稳态指标只统计第 3 s 之后(剔除起飞暂态)。

+0.75 kg 刻意不进训练集,用来把两种能力分开:

  • geo_res 只能靠泛化(网络见过 heavy,外推到 extra)
  • geo_rls 可以在线辨识(几秒内直接测出来)

轨迹参数:

轨迹 参数 难点
悬停 (0, 0, 1.5) 定高 最严苛的稳态误差测试,没有前馈运动掩盖模型偏差
螺旋 r = 2 m,z: 1.5→3.5 m,周期 8 s × 2 圈 垂直方向持续有速度,直接暴露推力标定误差
八字 Gerono 双纽线 2.5 × 1.4 m,周期 12 s 过原点时速度最大而曲率变号,大加速度 + 快速姿态反转

9. 结果与分析

9.1 主结果

稳态位置 RMSE(m),螺旋与八字两条轨迹 × 3 个种子的平均(每格 6 次,2 条轨迹 × 3 个种子):

载荷 风况 pid geo geo_res geo_rls
无 无风 0.0164 0.0098 0.0305 0.0187
无 1.5 m/s 0.0268 0.0312 0.0280 0.0195
无 3.2 m/s 0.0700 0.1039 0.0609 0.0235
+0.25 kg 无风 0.0319 0.1294 0.0435 0.0195
+0.25 kg 1.5 m/s 0.0383 0.1323 0.0406 0.0194
+0.25 kg 3.2 m/s 0.0790 0.1654 0.0610 0.0219
+0.50 kg 无风 0.0609 0.2598 0.0595 0.0202
+0.50 kg 1.5 m/s 0.0644 0.2610 0.0603 0.0196
+0.50 kg 3.2 m/s 0.0976 0.2794 0.0747 0.0232
+0.75 kg 无风 0.0916 0.3903 0.1248 0.0202
+0.75 kg 1.5 m/s 0.0938 0.3909 0.1219 0.0197
+0.75 kg 3.2 m/s 0.1216 0.4037 0.1295 0.0261

三条可直接读出的结论:

  1. 几何控制器的误差严格由载荷决定,与风况几乎无关(0.2598 / 0.2610 / 0.2794)。 这正是式 的预测:常值模型偏差直接映射成常值静差。
  2. 残差网络把误差压到 1/4,但它不是免费的:在"无载荷 + 无风"这一格上 geo_res = 0.0305 m,比不用网络的 geo(0.0098 m)还差。原因很直接—— 网络自身的预测误差是 0.281 m/s²,除以 就是 0.02~0.03 m, 这就是它的误差下限;没有扰动可补时,这一项反而成了主要误差来源。
  3. 在线自适应把整个表压平:geo_rls 在 12 格里落在 0.0187 ~ 0.0261, 跨度只有 0.007 m;训练集外的 +0.75 kg 档(0.0261 m)与标称档(0.0187 m) 几乎相同。432 次运行里它的最差一次也只有 0.0305 m。

控制代价(总推力平方的时间积分)四者相差在 2.5% 以内——差别几乎全部来自 载荷本身,说明残差补偿没有引入额外的推力消耗。

跟踪对比

图 3:四个控制器在 heavy + windy 螺旋轨迹上的对照。(a) 水平轨迹; (b) 高度跟踪;(c) 位置误差时程;(d) 垂直残差真值与预测;(e) 控制代价; (f) 稳态精度对比。

9.2 误差随载荷的变化

载荷扫描

图 4:(a) 跟踪精度随挂载载荷的变化,灰色区为训练集外的 +0.75 kg; (b) 残差估计的常值偏差。

螺旋 + 3.2 m/s 风这一条切片上:

载荷 pid geo geo_res geo_rls
无 0.0536 0.0917 0.0572 0.0228
+0.25 kg 0.0651 0.1589 0.0535 0.0221
+0.50 kg 0.0863 0.2761 0.0648 0.0230
+0.75 kg 0.1121 0.4018 0.1184 0.0258

几何控制器的误差随载荷严格线性增长(0.0917 → 0.4018),斜率 0.55 m/kg 与解析值 m/kg 一致 (差值来自阻力与风扰的贡献)。位置 PID 靠积分把常值偏置消掉, 但在训练集外仍留下 0.112 m——积分对时变的风扰没有前馈能力。

geo_res 在训练集内的三个档位上表现很好(0.053 ~ 0.065 m), 但在训练集外明显退化:+0.75 kg 档的残差估计留有 1.93 m/s² 的常值偏差, 跟踪误差随之涨到 0.118 m。网络对未见过的载荷只能线性外推。

geo_rls 的曲线在四个档位上近乎水平(0.0228 → 0.0258 m,跨度 0.003 m)。 残差估计偏差(右图)给出了机制:geo_res 的偏差从 0.26 m/s² 涨到 1.93 m/s²,而 geo_rls 在四个档位上始终是 0.016 ~ 0.025 m/s²—— 在线辨识把"泛化"变成了"辨识",载荷是多少,它几秒钟内就测出来了。

9.3 抗风性能

抗风

图 5:(a) 三个风况档位下的稳态误差;(b) 气动残差的在线估计。

挂 0.5 kg、螺旋轨迹,风况从无风扫到 3.2 m/s:

风况 pid geo geo_res geo_rls
无风 0.0584 0.2594 0.0302 0.0204
1.5 m/s + σ0.9 0.0604 0.2609 0.0399 0.0198
3.2 m/s + σ1.8 0.0863 0.2761 0.0648 0.0230

几何控制器的误差几乎不随风速变化——它的误差由常值载荷主导,风只贡献 不到 7%。位置 PID 在弱风下靠积分压得很好(0.058),但风速上去后积分项 开始追风的低频分量,涨了 48%。

残差网络把风况相关的水平项(正比于相对气流速度的平方)作为状态函数 学了出来,无风时 0.030 m、3.2 m/s 时 0.065 m,涨幅被压到一倍以内; 在线层再把剩余的气动偏差也辨识掉,三个风档都稳定在 0.020 ~ 0.023 m。

9.4 在线自适应过程

RLS 自适应

图 6:(a) 训练集外 +0.75 kg 载荷下,RLS 在前 1.5 秒内把残差估计从网络外推的 −6.0 拉到真值 −7.7 m/s² 并稳定跟随,而纯离线网络(绿)全程停在 −6.0; (b) 对应的跟踪误差从 0.099 m 降到 0.020 m。 注意前 1 秒的尖峰:那时 RLS 还在空转期,尚未接管最后一层。

飞行演示

图 7:三个控制器同场飞行(俯视 + 残差补偿量)。


10. ROS2 集成

10.1 拓扑

仿真侧(Python ≥3.10,MuJoCo 3.12)与 ROS 侧(Humble 的 Python 3.8) 无法共存于同一个解释器,因此沿用底座的分进程架构:

graph LR subgraph SIMP["仿真进程 (Python ≥ 3.10)"] MU["MuJoCo 物理 + 传感器
ResidualPlant(阻力/风/载荷/地面效应)"] SRV["TCP 服务端 :41460"] MU --- SRV end subgraph ROSP["ROS2 进程 (Python 3.8)"] NODE["residual_node
控制器 + 残差网络 + RLS"] end SRV <-->|"控制 50 Hz / rotor 直通"| NODE NODE -->|"/uav/odom /tf /uav/residual"| RV["rviz / 其他节点"]

控制律跑在 ROS 侧,仿真侧以 rotor 直通模式工作、不跑自己的控制器。 因此本节点替代底座的 bridge 节点运行。

10.2 同一份代码,两侧共用

Tracker 只依赖 state() / dt / nominal_params / last_acc / nominal_accel 这几个方法。仿真侧由 ResidualPlant 提供,ROS 侧由 RemotePlant 提供—— 后者只吃里程计与自己下达的推力,不知道真值质量、真值阻力,也不需要知道。

于是"仿真里调好的增益搬到 ROS 上行为一致"是可验证的,而不是靠人肉同步 两套实现。

同样地,底座把姿态数学、混控与几何控制器和 Quadrotor 放在同一个模块里, 而后者顶层 import mujoco;本模块把纯数学部分独立成 core/attitude.py, ROS 侧可以独立导入。--selftest 里有一项把本包与底座的混控逐点比对, 最大偏差 0.00e+00,防止两边日后走偏。

10.3 话题

除底座已有的 /uav/odom、/tf、/camera/*、/lidar/points、/uav/status 外:

话题 类型 含义
/uav/reference geometry_msgs/Point 当前参考位置
/uav/tracking_error geometry_msgs/Vector3(z 分量为范数) 位置误差
/uav/residual geometry_msgs/Vector3 网络预测的世界系残差加速度
/uav/disturbance geometry_msgs/Vector3 真值残差加速度(仿真侧提供,用于对照)
/uav/wind geometry_msgs/Vector3 当前风速

运行时可通过 /uav/trajectory、/uav/controller、/uav/reset 切换轨迹、 切换控制器、复位仿真。

10.4 实测

在课程虚拟机里跑通(仿真与 ROS 节点同在虚拟机内),工况是训练集外的 +0.75 kg 载荷 + 3.2 m/s 阵风:

项 结果
colcon build 两个包装配通过
自检(VM 内 Python 3.12) 34/34
话题频率(50 Hz 控制回路,TCP 往返) /uav/odom 等 43.7 ~ 45.2 Hz
位置误差(25 s,螺旋,三次独立运行) 均值 0.030 ~ 0.036 m,最大 0.075 m

进程内跑同一工况是 0.026 m,跨 TCP + Python 3.8 之后多出 4 ~ 10 mm—— 主要来自 50 Hz(进程内是 100 Hz)的周期量化与往返延迟。 "仿真里调好的控制器搬到 ROS 上行为一致"这句话因此是可验证的,而不是估计的。

两个只有上真链路才会暴露的坑

(1)必须复位到轨迹自身的起点。 仿真默认把无人机放在场景原点, 而螺旋轨迹从 起、初始偏航 90°。直接开跑实测 1.6 s 内翻机 (见 7.1 节的偏航预算分析),而且翻过来之后无法自恢复: 推力方向朝下、混控输出被限成 0,无人机躺在地上一动不动。

(2)observe() 不能漏。 残差网络的输入要用上一步下达的加速度、 标签要用本步实测的加速度,二者之间隔着一次物理步进,所以调用顺序必须是 act → 步进 → observe → 下一次 act。第一版节点只调了 act, 结果状态里的 delta_b 恒为 [0,0,0]、在线自适应完全不起作用, 误差停在 0.10 m(等于纯离线网络的水平)而不自知。


11. 源码解析

11.1 core/disturbances.py —— 未建模效应的物理

@dataclass
class DragModel:
    k_h: float = 0.055      # 水平二次项 N/(m/s)²
    k_v: float = 0.090      # 垂直二次项 N/(m/s)²
    linear: float = 0.020   # 线性项 N/(m/s),低速时主导

    def body_force(self, v_b):
        v = np.asarray(v_b, dtype=float)
        s_h = math.hypot(v[0], v[1])
        s_v = abs(v[2])
        f = np.array([self.k_h * s_h * v[0],
                      self.k_h * s_h * v[1],
                      self.k_v * s_v * v[2]])
        return -(f + self.linear * v)

阻力在机体系定义、由调用方旋到世界系;同一组系数对任意姿态成立。 OU 过程用精确离散化 、, 自检里用 20 万步验证稳态标准差严格等于设定值 (设定 1.80,实测 1.794)。

11.2 core/plant.py —— 不重写推进循环

class ResidualPlant(Quadrotor):
    def apply_rotor_thrust(self, thrusts):
        """地面效应在此叠加:同一转速近地推力更大。"""
        t = np.asarray(thrusts, dtype=float)
        self._ground_scale = self.plant_cfg.ground_scale(float(self.qpos[2]))
        if self._ground_scale > 0.0:
            t = t * (1.0 + self._ground_scale)
        super().apply_rotor_thrust(t)

    def apply_wind(self):
        """按当前状态重算阻力并**赋值**到 xfrc_applied 的力分量。"""
        self.plant_cfg.wind.update(self.dt / self.substeps)
        R = quat_to_mat(self.qpos[3:7])
        F = self.plant_cfg.drag_force(self.qvel[0:3], R)
        self.data.xfrc_applied[self.body_id, 0:3] = F

只覆写两个钩子(apply_rotor_thrust / apply_wind),推力饱和、 自由关节读写、mj_forward 等逻辑仍走底座实现,两边不会走偏。

外力每个物理子步重算并赋值而不是累加:阻力依赖当前速度,子步之间速度会变; 累加则会在同一控制周期内翻 substeps 倍。

11.3 core/residual.py —— 特征与网络

def build_features(v_b, a_cmd_b, omega_b, z, thrust, out=None):
    v = np.asarray(v_b, dtype=float)
    s_h = math.hypot(v[0], v[1])
    s_v = abs(v[2])
    f[0:3] = v
    f[3] = v[0] * s_h          # 二次阻力基
    f[4] = v[1] * s_h
    f[5] = v[2] * s_v
    f[6:9] = a_cmd_b
    f[9:12] = omega_b
    f[12] = z
    f[13] = thrust
    return f

反向传播按层手写:

def backward(self, X, hs, dY):
    gW[-1] = hs[-1].T @ D
    gb[-1] = D.sum(axis=0)
    for i in range(len(self.W) - 2, -1, -1):
        D = (D @ self.W[i + 1].T) * (1.0 - hs[i] ** 2)   # tanh 导数
        prev = A if i == 0 else hs[i - 1]
        gW[i] = prev.T @ D
        gb[i] = D.sum(axis=0)
    return gW, gb

11.4 core/controllers.py —— 四个控制器只差两个钩子

class Tracker:
    def act(self, t: float) -> np.ndarray:
        st = self.plant.state()
        pos_d, vel_d, acc_d, yaw_d, _ = self.traj.eval(t)
        self._R = quat_to_mat(st.quat)
        a_cmd = self.desired_accel(st, pos_d, vel_d, acc_d) + self.excitation
        r_w = self.residual_world(st, a_cmd)          # 基类返回 0
        T, tau = self.geo.compute(st, pos_d, vel_d, a_cmd - r_w, yaw_d)
        return mixer(T, tau, self.params)

desired_accel 与 residual_world 两个钩子决定了四个控制器的全部差异。 ResidualTracker.residual_world 做的是:

    def residual_world(self, st, a_cmd):
        v_b = self._R.T @ st.vel
        a_b = self._R.T @ self.last_a_cmd      # 上一步的指令,避免循环依赖
        x = self.net.features(v_b, a_b, st.omega, float(st.pos[2]),
                              float(np.sum(self.last_thrust)) / self.params.mass)
        self.phi = self.net.last_layer_input(x)
        return self._R @ self.net.predict(x)

self.phi 缓存下来给 RLS 用——RLS 的回归向量正是最后一层的输入, 复用它可以省掉一次前向。

11.5 core/rls.py —— 防护比算法本身更重要

        tr = float(np.trace(self.P))
        if tr > self.trace_cap:
            self.P *= self.trace_cap / tr      # 按比例缩放,不是重置
            self.resets += 1

        Pphi = self.P @ phi
        denom = self.lam + float(phi @ Pphi)
        k = Pphi / denom
        err = y - self.theta.T @ phi
        self.theta = self.theta + np.outer(k, err)
        if self.leak > 0.0:
            self.theta -= self.leak * (self.theta - self.theta0)
        self.P = (self.P - np.outer(k, Pphi)) / self.lam
        self.P = 0.5 * (self.P + self.P.T)     # 对称化,防止 λ→1 时缓慢失对称

12. 复现步骤

# 1) 环境:仿真侧 Python ≥ 3.10
uv venv ~/uav_ws/.venv --python 3.12
uv pip install --python ~/uav_ws/.venv/bin/python "mujoco==3.12.0" numpy matplotlib pytest

# 2) 把两个包放在同一 PYTHONPATH 下
export PYTHONPATH=<仓库>/src/air/uav_mujoco_sim:<仓库>/src/air/uav_neural_residual_control

# 3) 数值自检(不需要 ROS、不需要显示器,约 4 s)
cd <仓库>/src/air/uav_neural_residual_control
python main.py --selftest          # 期望 34/34

# 4) 完整流水线(约 15 分钟,纯 CPU,不需要显卡)
python main.py --pipeline

ROS2 侧:

source /opt/ros/humble/setup.bash
colcon build --symlink-install --packages-select uav_mujoco_sim uav_neural_residual_control
source install/setup.bash
ros2 launch uav_neural_residual_control main.launch.py payload:=heavy wind:=windy
命令 作用
python main.py --selftest 34 项数值自检
python main.py --pipeline 采集 → 训练 → 实验 → 出图
python main.py --train 只用已有数据集重训
python main.py --eval 只用已有模型重跑 432 次实验
python main.py --plot 只用已有结果重出全部配图
python main.py --demo 单次运行并打印指标表
python main.py --server 带未建模效应的仿真服务端(供 ROS 用)
python main.py --node ROS2 桥接节点

13. 已知限制

  • 残差网络的外推能力有限。 训练速度包线是 0 ~ 3 m/s,超出后阻力项误差 按平方增长。生产环境应在目标包线内补采数据。
  • 在线 RLS 只改最后一层。 整网微调在本场景下不稳定(在线数据时序强相关), 且 195 个参数已足够覆盖载荷与气动偏差。若未建模效应本身与状态强非线性相关 (例如大幅变形的机体),最后一层可能不够。
  • 重载档位接近推力上限。 +0.75 kg 时起飞重量 1.7 kg、总推力 24 N, 垂直方向只剩 4.3 m/s² 的加速余量。再重就要考虑饱和了。
  • 未建模项缺少旋翼动力学。 桨叶的升力-转速非线性、电机一阶滞后都没有建模, 真机上这部分会成为额外的残差来源。
  • 全部实验在无噪声仿真里完成。 里程计噪声会直接进入 的差分估计 ( ms,1 mm 的位置噪声就是 10 m/s² 的加速度噪声), 真机上需要先做低通滤波或改用加速度计。

14. 依赖