ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

无人机PID调参新思路:非线性增益调度与元启发式优化

无人机PID调参新思路:非线性增益调度与元启发式优化

做无人机飞控这件事,调PID是个绕不开的环节。我见过不少开发者在初期把一个参数一个参数地试,试到后面自己都不记得哪组组合有效,更麻烦的是,换一个飞行工况可能又得从头再来。某个项目里我接手一台悬停性能很不稳定的四旋翼,滚转通道参数按小角度阶跃调得好好的,只要操纵幅度一大,机体就开始来回晃。问题其实不在某个Kp、Ki、Kd的数值,而是增益结构始终没变——控制器无论在什么误差状态和飞行状态下,用的都是同一组参数。

这里核心思路就是标题里那两件事:第一,用非线性增益调度让PID参数随着误差状态自动变化;第二,用元启发式优化自动把这些变化规则搜出来,而不是靠人工一遍遍试凑。这篇文章会把这两件事串起来讲一遍,包括为什么固定增益PID在无人机上不够用、增益调度策略怎么选、目标函数怎么设计、MATLAB/Simulink代码怎么落地,以及我实际调试中踩过的一堆坑。适合正在做无人机姿态控制、或者想把手动调参变成系统化参数搜索的人参考,哪怕你用的是Python,里面很多逻辑同样能搬过去。

1. 为什么固定增益PID在无人机上不够用

1.1 无人机控制难在哪

无人机和普通的过程控制对象不太一样,它是一个多变量、强耦合、非线性明显的系统。四旋翼在滚转、俯仰、偏航三个通道之间存在角速度交叉耦合,大角度机动时陀螺效应会显著改变被控对象特性;固定翼在不同速度、不同攻角下气动导数差异非常大,升力系数、阻尼力矩都会随飞行状态变化。执行机构还有饱和,传感器有噪声和时延,这些因素叠加在一起,让模型始终在漂移。

标准的做法是在某个工作点附近做线性化,然后设计PID。问题是,一切参数都只对那个工作点负责。悬停时整定出来的一组参数,放到大速度前飞或者大角度跟踪任务里,被控对象已经变了,但增益并没有跟着变。结果要么是过于保守,大误差时跟踪特别慢;要么是过于激进,小误差时持续振荡。经常有人归因于“PID参数没调好”,但很多时候真不是参数数值不够好,而是固定增益结构本身已经不适合当前的工作条件。

1.2 增益调度到底在调度什么

既然对象特性会变,那就让控制增益也跟着变,这个思想就是增益调度。调度变量可以选高度、空速、攻角,也可以选误差、误差变化率。在姿态控制器里,用误差做调度变量是最直观的:误差大的时候,增益保持中档,避免驱动饱和;误差逐渐变小,适当增大Kp和Kd来获得更好的阻尼;误差接近零的时候,靠积分项慢慢清理稳态残差。

传统的做法是手动把误差分成几个区间,每个区间单独整定一组PID,再用查表或者切换逻辑切换。听起来不复杂,实际做起来会发现一个问题:区间边界和增益数值是强耦合的。你把某个误差区间边界挪了一点点,相邻几组参数可能全部失效,必须重新整个调一遍。这个多维手工试凑过程效率极低。更可复制的路线,是把区间节点位置和节点上的增益数值一起当作优化变量,让元启发式算法去自动搜索。这也是标题里“元启发式优化”存在的意义:调参这个工作,本质上可以被构造成一个数值优化问题。

2. 整体方案设计:把调参问题变成搜索问题

2.1 增益调度策略怎么定

先明确调度策略。以误差e做调度变量为例,定义误差绝对值区间:[0, e1, e2, ..., eM]。在每个区间的节点上分别有Kp_i、Ki_i、Kd_i。控制器运行时,如果误差落在[e_i, e_{i+1}]之间,增益就用相邻两个节点线性插值;超出最后一个节点,用最外侧的值保持输出。

这里有个实操经验:M取值一般在5到9之间。M太小,增益曲线的表达能力不够,插值出来基本是粗折线,控制器行为不够细腻;M太大,优化维度变成3M,收敛速度明显下降,一次评估的计算代价也上升。另外,分段点不一定等间距。我通常会把小误差区域排得密一些,因为飞控在实际飞行中,大部分时间都工作在小误差区间,这个区域的增益曲线直接决定了悬停/巡航的平稳性,值得用更多节点去刻画。

调度策略确定之后,编码就很简单了:把M个Kp、M个Ki、M个Kd拼接成一个向量,作为优化个体的染色体,维数就是3M。后面所有元启发式算法,不管粒子群、灰狼、差分进化还是遗传算法,处理的都是这个一维向量。

2.2 元启发式算法选哪个更合适

元启发式算法的选择,在优化阶段其实不是最重要的决定。粒子群实现最简单,收敛速度也可以,但容易早熟;差分进化对多峰函数的全局搜索能力比较强,不过需要调F和CR两个参数;灰狼优化参数少,整体稳定性好;遗传算法做离散和约束处理方便,但编码稍微麻烦一点。我自己的习惯是,先用灰狼优化跑一遍,如果收敛曲线不理想,再换差分进化或者带自适应权重的粒子群去对比。

关键在于,只要能把参数编码成向量、能写清楚目标函数,这些算法本质上是同一个套路:初始化种群、评估适应度、按某种规则更新种群、迭代直到收敛。所以本文后续代码以灰狼优化为例,但你把那段更新逻辑换成任何其他种群类算法,整个闭环都能直接跑通。不要盲目追求“先进算法”,在飞控参数搜索这个问题上,真正决定结果上限的是目标函数设计和仿真模型可信度,而不是优化器本身的某个小改进。

2.3 优化闭环流程怎么串

整个离线优化闭环可以拆成下面几步:

  1. 初始化种群:随机生成N个体,每个个体解码后就是一组增益调度节点。
  2. 评估个体:把当前个体的增益节点写入Simulink基础工作区,运行一次仿真,记录误差和控制量的时间序列。
  3. 计算适应度:根据目标函数,把刚才的时域响应变成一个标量数值。
  4. 更新种群:按照算法机制,生成下一代个体。
  5. 重复评估和更新,直到满足最大迭代次数或适应度不再明显下降。

这里有个很关键的原则:整个过程一定是离线完成的。不要想着在飞控MCU上在线跑元启发式优化,一次适应度评估就要完整跑一遍仿真,飞行控制器实时性根本承受不住。正确的分工是,离线优化好增益调度表,飞行中控制器只做一个查表插值运算,计算量非常小。

3. 目标函数设计:让飞控朝哪个方向优化

3.1 用哪些时域指标衡量控制效果

目标函数是整个优化环节的灵魂,它决定了优化器最终找到什么样的控制行为。我常用的时域指标有这么几类:

  • ITAE(积分时间乘绝对误差):J = ∫ t·|e(t)| dt。它对长期小误差和振荡都比较敏感,而且随时间累积权重,越到后期越在意误差收敛,实际工程中抗振荡效果不错。
  • 超调量:阶跃响应中误差超过目标值的程度。超调过大意味着姿态偏离安全范围,某些任务里是绝对不能接受的。
  • 稳定时间:误差进入5%或2%容差带并且不再超出的时间。
  • 稳态误差:响应完全稳定后误差是否收敛到足够小的范围。
  • 控制能量:控制量或者控制量变化率是否过大,衡量执行机构压力和系统富余度。

这些指标往往互相矛盾。想追求快速性,超调通常就会变大;想压低超调,稳定时间可能拉长。所以不能只看单个指标,必须把它们综合起来。

3.2 多目标权重怎么配才不白调

把多个指标加权成一个标量目标函数,是最简单的做法,但坑也最多。最大的坑是不做归一化。ITAE的数值可能是几百,超调量可能只有0.1,如果你直接把两个数字加起来,超调对总目标的贡献几乎可以忽略,优化器会完全无视超调指标。所以第一步,先跑一次仿真,把各项指标的量级记下来,用基准值做除法,让它们都变成无量纲的数。

权重怎么给,可以按任务优先级来。比如要优先保证响应速度,ITAE权重给大一些;如果安全余量更重要,超调量权重要提上去。我常用的初始权重组合是:ITAE权重0.5到0.6,超调权重0.2到0.3,稳态误差权重0.1到0.2。如果执行机构对控制量变化率很敏感,再单独加一个惩罚项。惩罚项不是简单加个权重,而是用“大数”逻辑:一旦某次仿真中控制量超过设定阈值,就在适应度末尾加上一个很大的惩罚值,比如1e6,强制优化器避开这个不可行区域。

3.3 适应度函数代码模板

下面这段代码是我实际用的适应度函数结构,基于灰狼优化做了简化。注意Matlab版本不同,取仿真数据的方式略有差异,我这里用的是较新的logsout读取方式。

function J = pid_schedule_fitness(x) % x = [Kp(1:M) Ki(1:M) Kd(1:M)] % 结构化参数放在全局或参数结构体里,避免到处复制 M = 7; kp = x(1:M); ki = x(M+1:2*M); kd = x(2*M+1:3*M); % 写入simulink模型的基础工作区 assignin('base', 'kp_pts', kp); assignin('base', 'ki_pts', ki); assignin('base', 'kd_pts', kd); % 运行仿真 simOut = sim('uav_pid_schedule', 'StopTime', '8'); e = simOut.logsout.get('error').Values.Data; t = simOut.logsout.get('error').Values.Time; u = simOut.logsout.get('control').Values.Data; e = e(:); t = t(:); u = u(:); % 计算ITAE ITAE = trapz(t, t .* abs(e)); % 粗略超调,按单位阶跃指令做了归一化;实际用目标幅值做分母 Mp = (max(abs(e)) - abs(e(end))) / 1.0; % 稳态误差 ess = abs(e(end)); % 控制量越限惩罚 max_control = 200; u_penalty = 1e4 * (max(abs(u)) > max_control); % 加权合成,具体权重根据需求调整 J = 0.6 * ITAE / 100 + 0.3 * Mp + 0.1 * ess + u_penalty; end

这段代码有几个细节值得说明。第一,用assignin把变量直接写到基础工作区,Simulink模型参数就能直接读,简单直接,适合离线批量优化;如果后期要部署规范化工程,再换数据字典。第二,ITAE除以100是因为仿真前期跑出来的数值级大约在一百多,相当于一个归一化常数,各系统不一样,要自己实测调整。第三,超调量的分母1.0对应单位阶跃,如果你的参考输入是0.5弧度或者其他幅值,务必改掉。第四,控制量惩罚写得简单粗暴,只要最大值越限就加惩罚,实际可以写成对越限总时长的积分,更平滑。

4. MATLAB实操:从模型搭建到优化闭环

4.1 工程目录和基础模型准备

先搭好目录结构,避免优化跑起来之后文件到处飞。一个典型的工程目录可以是这样:

  • main_gwo.m:主脚本,负责定义参数、调用优化器、保存结果
  • gwo_optimizer.m:灰狼优化主循环
  • pid_schedule_fitness.m:适应度函数
  • uav_pid_schedule.slx:无人机姿态控制Simulink模型
  • results:存放每代最优个体和适应度曲线

模型方面,我强烈建议优化阶段先不要用高保真全六自由度模型。一次完整的全模型仿真,如果步长设得比较小,可能要十几秒甚至更久,优化50代、每代30个个体,光是评估就要大半天。更合理的做法是,先用一个降阶等效模型,比如姿态角二阶模型加上执行机构一阶惯性,外环姿态角误差、内环角速率、执行机构饱和都尽量包含。这个模型评估一次可能只要0.5秒,参数搜索阶段跑起来非常快。等增益调度表优化完成,再把参数搬到完整六自由度模型里做验收验证。这个“快速模型优化+高保真模型验证”的流程,是我个人认为最适合工程节奏的做法。

4.2 在Simulink里实现增益调度

增益调度的实现,最方便的是用Simulink里的1-D Lookup Table模块。Kp、Ki、Kd各接一个Lookup Table,输入是当前的误差e,XData是同一个误差分段点向量,Table data分别是对应的Kp、Ki、Kd节点数值。插值方式选线性插值,不要让增益呈阶梯状跳变;超出边界时,推荐选择“边界值保持”,不要外推,否则误差很大的瞬态可能得到一组失控增益。

一个很容易出错的地方是维度匹配。Lookup Table的Table data必须和XData对齐,如果分段点是7个点,Table data必须是7元素列向量。用MATLAB Function模块也可以直接写成:

function [kp, ki, kd] = gain_schedule(e) e_abs = abs(e); kp = interp1(params.error_pts, params.kp_pts, e_abs, 'linear', 'extrap'); ki = interp1(params.error_pts, params.ki_pts, e_abs, 'linear', 'extrap'); kd = interp1(params.error_pts, params.kd_pts, e_abs, 'linear', 'extrap'); end

如果你担心增益突变引起执行机构抖动,可以在查表输出后面加一个Rate Limiter,限制增益单位时间内的变化幅度。这不是必须的模块,但当你发现优化出来的曲线过于锋利时,它会很有帮助。

4.3 灰狼优化主循环代码

灰狼优化的逻辑不复杂,核心是每次迭代计算alpha、beta、delta三只“头狼”的位置,其他个体向它们位置靠拢。下面是完整可运行的主循环框架:

function gwo_pid_schedule() % 参数设置 params.nPts = 7; params.dim = params.nPts * 3; params.popSize = 30; params.maxIter = 50; params.model = 'uav_pid_schedule'; % 边界:每个参数单独设置,Kp/Ki/Kd的量纲不一样 lb = [zeros(1, params.nPts), zeros(1, params.nPts), zeros(1, params.nPts)]; ub = [3*ones(1, params.nPts), 2*ones(1, params.nPts), 1*ones(1, params.nPts)]; % 初始化种群 positions = rand(params.popSize, params.dim) .* (ub - lb) + lb; fitness = inf(params.popSize, 1); alpha_pos = zeros(1, params.dim); alpha_score = inf; beta_pos = zeros(1, params.dim); beta_score = inf; delta_pos = zeros(1, params.dim); delta_score = inf; best_curve = zeros(params.maxIter, 1); for iter = 1:params.maxIter % 评估当前种群 for i = 1:params.popSize fitness(i) = pid_schedule_fitness(positions(i, :)); if fitness(i) < alpha_score alpha_score = fitness(i); alpha_pos = positions(i, :); elseif fitness(i) < beta_score beta_score = fitness(i); beta_pos = positions(i, :); elseif fitness(i) < delta_score delta_score = fitness(i); delta_pos = positions(i, :); end end best_curve(iter) = alpha_score; % 收敛参数a随迭代线性递减 a = 2 - 2 * iter / params.maxIter; % 更新位置 for i = 1:params.popSize for j = 1:params.dim r1 = rand(); r2 = rand(); A1 = 2 * a * r1 - a; C1 = 2 * r2; D_alpha = abs(C1 * alpha_pos(j) - positions(i, j)); X1 = alpha_pos(j) - A1 * D_alpha; r1 = rand(); r2 = rand(); A2 = 2 * a * r1 - a; C2 = 2 * r2; D_beta = abs(C2 * beta_pos(j) - positions(i, j)); X2 = beta_pos(j) - A2 * D_beta; r1 = rand(); r2 = rand(); A3 = 2 * a * r1 - a; C3 = 2 * r2; D_delta = abs(C3 * delta_pos(j) - positions(i, j)); X3 = delta_pos(j) - A3 * D_delta; positions(i, j) = (X1 + X2 + X3) / 3; end end % 越界处理 positions = max(positions, lb); positions = min(positions, ub); fprintf('Iter %d, best fitness: %.4f\n', iter, alpha_score); end save('results/gwo_result.mat', 'alpha_pos', 'alpha_score', 'best_curve'); end

注意几个实际会踩的坑。一,dim维度必须和3*M一致,否则解码时对不上。二,边界lb和ub要逐维度设置,不要用统一的上下界,因为Kp和Ki的合理范围很可能相差一个数量级。三,每轮迭代结束要做越界处理,否则灰狼更新会把参数推到不合理的区域,仿真模型就很容易发散。

4.4 关键参数怎么定

种群大小我一般取20到40。迭代次数看单次评估耗时,快模型可以跑到80到100代,慢模型50代已经比较艰难。边界设置建议先用人工粗调,拿一组能闭环稳定的参数,然后让优化范围在它周围展开。完全随机的大范围搜索不是不能用,但很多区域其实对应的是系统发散,优化器要在里面浪费大量评估次数。

收敛判定方面,我习惯画best fitness的下降曲线。如果连续20代基本没有明显下降,说明已经收敛,继续跑意义不大。另外,固定随机种子很重要。优化器内部有随机初始化,不固定种子的话,每次跑出来的结果都不同,而且可复现性差,不利于调试。在main脚本里加一句rng(2025)就够了。

5. 常见问题与排查技巧实录

5.1 优化不收敛或者系统发散

最明显的表现是:适应度值极大,动辄几千几万,或者最佳适应度曲线长时间不动。这种情况我第一个怀疑的不是优化器,而是Simulink模型本身。先固定一组手工粗调参数,直接在模型上跑一次仿真,确认闭环是稳定的。如果模型闭环本身都发散,那无论元启发式怎么搜索,都不可能找到好结果。

第二个常见原因是仿真步长。变步长求解器在增益调度这种分段插值结构里,可能因为误差节点附近数值变化过快导致步长收缩,仿真速度极慢;或者步长过大导致高频动态被错误积分,参数明明可行但数值发散。我的处理方式是:优化阶段用固定步长求解器,步长取系统最高响应频率的1/10以下,比如姿态带宽10rad/s,步长就取0.01s以内。这样既保证可复现,也避免了步长自适应带来的评估时间不可控。

5.2 增益调度引起的抖振

有个典型场景是:优化结果在Simulink低阶模型里表现很好,误差曲线平滑收敛,但换到高保真模型或者真机上就出现高频抖振。原因通常是增益曲线在误差节点之间变化太陡。误差信号在实际系统中带噪声,会在节点附近来回穿越,增益随之剧烈变化,等于给系统注入了一个高频扰动。

解决办法有三个方向:第一,确保查表用线性插值而不是零阶保持;第二,在查表输出后面加低通滤波或者Rate Limiter,限制增益的变化率和变化速率;第三,把“相邻节点增益变化幅度”写进目标函数罚项,让优化器主动回避尖锐的增益曲线。这个第三点很多人会忽略,但它往往是最治本的,因为单纯靠后处理滤波会把控制器原本想表达的动态响应也滤掉。

5.3 仿真结果与真机表现差异大

每次做硬件在环或者真机验证前,我都会先问自己一个问题:仿真模型里该有的延迟和噪声都有了吗?传感器信号处理延迟、执行机构带宽、PWM更新周期、IMU噪声,这些都是真实飞控里绕不开的。如果优化模型太理想,得到的参数本质上是在“过度拟合理想模型”。

解决思路是在优化阶段就给模型注入高频噪声和延迟环节,这样搜出来的增益调度表在真机上才有余量。同时目标函数里保留控制能量项,不要为了压缩跟踪误差把控制量推到饱和边缘。真机验证时,从小角度悬停开始,逐步加大操纵幅度,每一步都记录响应曲线,和仿真结果对比。如果差异突然变大,优先怀疑模型里的未建模延迟和增益曲线过陡,而不是先怀疑参数数值。

5.4 问题速查表

问题表现可能原因处理建议
适应度巨大,仿真曲线发散增益边界过宽导致系统失稳先人工粗调一组稳定参数再定边界
最佳适应度曲线长时间不动种群过早收敛到局部最优增大种群,或换差分进化/PSO重跑
仿真结果好,真机抖振增益曲线过陡,噪声引发增益跳变加Rate Limiter,目标函数加平滑罚项
每次优化结果差异很大随机种子未固定,或评估函数有随机噪声固定rng,固定仿真步长和输入信号
ITAE权重过低,超调不可接受指标未归一化先实测各指标量级,统一归一化再调权重
仿真速度过慢全六自由度模型参与优化评估用降阶等效模型做优化,最后高保真验证

最后分享一点个人习惯

整个流程跑顺之后,我现在的标准操作是:先用人工粗调拿到一组能稳定工作的PID参数,把这组参数作为增益调度曲线的中心值;然后把优化边界控制在中心值上下加减50%左右;最后让元启发式在这个范围内做局部精修。这样看起来搜索范围小了,但实际上避开了大量发散区域,收敛速度快得多,优化出的参数也不会出现“完全看不懂为什么这样”的怪异结构。

另外,评估函数里一定要保证每次仿真条件完全一致。固定随机种子、固定仿真步长、固定参考输入信号,这几个细节不做好的话,适应度数值会带噪声,元启发式算法会被这些噪声带偏。我早期就因为没固定仿真模型里的某个随机扰动源,导致同一组参数两次评估结果偏差很大,优化曲线一直抖,白白浪费了很多时间。先把这些基础工作做扎实,再去调算法和权重,整个收益曲线会顺畅很多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进