Simulink ILC仿真工程:PD型学习律轨迹跟踪与收敛调参实战
简介:ILC_Simulink.rar 是一份面向控制理论学习者与工程实践者的迭代学习控制仿真资料,围绕 Simulink 环境下的传递函数对象建模、ILC 算法搭建与无静差跟踪验证展开,适合课程实验、算法入门及控制系统性能对比等场景。压缩包共 14 个文件,约 66KB,以 mat 数据、xml 配置、slx/slxc 模型及 m 脚本等为主,覆盖模型主体、仿真状态与辅助运行文件,结构紧凑,便于直接打开复现与二次调整。已有 3983 人学习下载,在 ILC 入门与 Simulink 实践场景中具有一定参考热度。读者可从中获得可运行的 ILC 仿真模型、误差反馈与更新规则示例,以及参数调整、输出曲线分析和结果验证的切入点,进而理解迭代学习控制从对象建模、控制输入更新到无静差跟踪的完整流程。
1. 从一条“越跑越准”的轨迹说起:这份 Simulink ILC 工程到底解决什么问题
一条做往复定位的伺服轴,第一次跑完整条轨迹,末端偏差 0.8 mm;参数一个字没改,跑到第十次,偏差掉到 0.05 mm。第一次见这个现象的人多半会怀疑是测量噪声或者数据回放出了错,但只要你看过迭代学习控制的工作方式,就知道这是它在正常工作——误差不是被“调参调没的”,而是被一次次重复作业“学没的”。
ILC_Simulink.rar 里是一个完整的 Simulink ILC 仿真工程:被控对象模型、学习律模块、迭代调度脚本、误差收敛绘图四块都有,解压、改两行参数、跑一遍,就能看到误差随迭代次数下降的曲线。它适合两类人:正在做重复轨迹跟踪(伺服往复、点胶、机械臂末端走固定路径)的开发者,以及刚看完 ILC 公式、手里缺一个能跑起来的对照例子的同学。
2. 工程结构拆解:从 .slx 模型到 .m 脚本的四层分工
拿到一个仿真压缩包,最怕的是打开一看十几个文件互相调用,不知道该从哪个入手。这套工程的结构其实很朴素,按“参数—模型—调度—后处理”切成四层,每一层只干一件事。先把这条主干理清楚,后面看代码就不会迷路。
2.1 解压之后的文件清单与职责边界
解压出来通常是这样一个布局(不同版本的文件名可能略有出入,但角色是对应的):
| 文件 | 类型 | 职责 | 改动频率 |
|---|---|---|---|
ilc_params.m | 脚本 | 集中定义采样周期、试验时长、迭代次数、学习增益 | 高,每次调参都动它 |
ilc_plant.slx | 模型 | 参考轨迹 + 被控对象 + 学习律模块 + 数据记录 | 低,换对象时才改 |
ilc_law.m | 函数 | 学习律本体,被模型里的 MATLAB Function 块引用 | 中,换算法结构时改 |
ilc_main.m | 脚本 | 迭代主循环,一次迭代调用一次sim | 低 |
plot_convergence.m | 脚本 | 误差范数曲线、单次跟踪对比图 | 低 |
data/ | 目录 | 存每一代的误差序列和控制序列 | 不手动改 |
这里有个容易忽略的设计取向:参数不写死在模型里,全部收进ilc_params.m。仿真模型最常见的问题就是参数散落在各个 Gain 块、Constant 块、Workspace 变量里,改一个采样周期要翻五六个地方,翻漏一处就是一次血泪经验。集中管理之后,模型里所有模块只引用变量名,脚本一跑,工作区就有了值。
% ilc_params.m —— 所有可调参数在这里定义,模型只引用变量名 Ts = 0.01; % 采样周期(s),必须和模型内所有离散模块一致 T_end = 5.0; % 单次试验时长(s) N = round(T_end/Ts); % 单次试验的采样点数,也是误差序列的长度 K_iter = 30; % 迭代次数上限 gamma_p = 0.6; % 比例型学习增益,控制收敛速度 gamma_d = 0.02; % 微分型学习增益,补相位滞后 q_alpha = 0.85; % Q 滤波器系数,0~1,越接近 1 越保守 u0 = zeros(N,1); % 第 1 次迭代的控制序列,通常取零Ts和N的关系要先算明白:N不是随便填的,它决定了误差序列的长度,也决定了学习律里差分运算的边界处理方式。gamma_p的物理含义是“上一轮误差有多大比例被写进下一轮输入”,量纲上要跟对象的直流增益对上,所以换对象之后这个值基本要重调,不能照抄。q_alpha是我建议新手一开始就加上的,它把每轮的更新量做一个一阶低通,牺牲一点收敛速度换稳定性,比后面看着曲线发散再回头加要省事得多。
2.2 模型分层:被控对象、学习律、迭代开关三段式
打开ilc_plant.slx,信号流大致是三条并行支路:
- 参考支路:
Repeating Sequence或From Workspace产生期望轨迹y_d(t),长度正好N。 - 对象支路:
From Workspace读入当前代的控制序列u_in,送进Transfer Fcn(或离散State-Space)得到实际输出y_k。 - 误差支路:
y_d - y_k得到e_k,一路进To Workspace记录,一路进 MATLAB Function 块算下一代输入。
关键点在于:这条回路是开环跑的。也就是说,单次试验里对象只吃u_in这一串开环输入,没有反馈控制器参与;学习律不在试验内部生效,而是跨试验生效。很多刚接触 ILC 的人会把学习律接成反馈环里的一个增益,跑出来的曲线看着像那么回事,其实算法性质已经完全变了——那不是迭代学习,那是加了个积分器。
学习律模块在模型里通常做成一个MATLAB Function块,输入e_k、u_k,输出u_next,块内用persistent变量跨调用保存上一代数据。这样做的理由是工程化的:等到要把算法搬到实机,这段逻辑可以几乎原样翻译成 C,不用重新组织数据流。如果只图仿真快,也可以把学习律整个放在 M 脚本里离线算,但那样模型里就只剩对象,日后迁移会多一道搬运工序。
另外模型里一般会有一个手动开关(Constant 块加 Switch),用来选“开学习”还是“只跑一次基线”。别小看这个开关,调参阶段你会反复在“看看不加学习的原始跟踪误差”和“看看学了几代之后的效果”之间来回切。
2.3 脚本分层:调度与后处理必须分开
ilc_main.m只做调度,不做计算,也不画图。这条纪律听起来像教条,实际是省时间的:调参的时候你会反复跑主循环,如果主循环里还嵌着画图,每跑一次弹三个窗口,几十次迭代下来窗口能堆满屏幕。把绘图拆到plot_convergence.m,等主循环跑完、e_history已经在工作区里了,再单独调一次,想换视角就再调一次,不用重跑仿真。
同理,单次试验的封装也建议单独抽一个函数,比如run_one_trial(u_in),内部负责把输入塞进工作区、调sim、把输出取出来对齐长度。主循环里只出现“算误差—存历史—判收敛—更新输入”四件事,读代码的人一眼就能看出算法骨架。这套分层在只有几十行的时候显得啰嗦,等你要加扰动、加噪声、加多对象对比的时候,就会庆幸当初拆开了。
3. 核心算法拆解:PD 型迭代学习律怎么在 Simulink 里落地
结构清楚了,接下来是真正决定收敛与否的部分。ILC 的公式在论文里往往一行写完,落到代码里有三处必须做决定:更新式用 P 型还是 PD 型、Q 滤波器放在哪、迭代怎么驱动。这三处选错,曲线要么不降,要么降着降着飞出去。
3.1 离散更新式与三个必须交代的初值条件
工程里用的最常见形式是带 Q 滤波的 PD 型学习律,写成离散递推就是:
u_{k+1}(n) = Q · u_k(n) + (1 - Q) · [ u_k(n) + γp · e_k(n) + γd · (e_k(n) - e_k(n-1)) ]其中k是迭代次数,n是单次试验内的时间步。把它拆开看,方括号里是“上一代输入 + 本代误差的比例项和微分项”,外面套一层Q与上一代输入做凸组合,本质是一个一阶低通,把高频的更新量压下去。
三个初值条件必须显式交代,否则第一代就跑不出合理结果。第一,u_0(n)取什么——常见做法是全零,或者先跑一次 PID 把稳态输出录下来当u_0,后者能让第一代误差就小很多,收敛曲线更好看,但会掩盖算法本身的收敛速度,做对比实验时建议还是从零开始。第二,e_k(0)的差分怎么算——n=1时没有n-1,用前向差分补,或者干脆令de(1)=0,两种都行,但不能让索引越界。第三,Q的取值范围,工程上一般落在 0.7 到 0.95,取 1 等于完全不更新,取 0 等于退化成纯 P 型,抗噪能力直线下降。
还有一条容易被跳过:参考轨迹的首尾要平滑。如果y_d在t=0处有一个阶跃,第一代的e(1)会非常大,学习律会把这个冲击原封不动学进u序列,后面几代都在跟这个冲击较劲。常见的处理是把参考做成 S 形起步,或者至少在首尾各加几十毫秒的斜坡。
3.2 MATLAB Function 块里的学习律实现
模型里那个学习律块,展开之后的代码大致是这样:
function u_next = ilc_law(e_k, u_k, gamma_p, gamma_d, q_alpha) %#codegen % 输入: % e_k 当前代跟踪误差序列,长度 N 的列向量 % u_k 当前代施加的控制序列,长度 N 的列向量 % gamma_p 比例学习增益 % gamma_d 微分学习增益 % q_alpha Q 滤波器系数 (0,1) % 输出: % u_next 下一代要施加的控制序列 N = numel(e_k); u_next = zeros(N,1); % 误差一阶差分,首点用前向差分补,避免索引越界 de = zeros(N,1); de(1) = e_k(2) - e_k(1); de(2:N) = e_k(2:N) - e_k(1:N-1); for n = 1:N % 未滤波的更新量:上一代输入 + PD 修正 u_raw = u_k(n) + gamma_p * e_k(n) + gamma_d * de(n); % Q 滤波:与上一代输入做凸组合,抑制高频更新 u_next(n) = q_alpha * u_k(n) + (1 - q_alpha) * u_raw; end end逐段说。%#codegen这行不是装饰,加了它之后 MATLAB 编辑器会实时提示哪些写法不支持代码生成,比如动态扩容数组、元胞数组、eval,这些都是实机移植时的拦路虎,在仿真阶段就规避掉,后面省一大截时间。
de的边界处理用了两段赋值而不是diff,原因是diff输出长度是N-1,跟u_next对不齐,后面还要补一个元素,容易在多处修改时漏掉。显式写出来虽然多两行,但长度关系一目了然。
循环体内两行对应公式里的“先算原始更新量,再做凸组合”。注意q_alpha * u_k(n)用的是上一代输入而不是u_raw,这是标准的 Q 滤波写法;写成q_alpha * u_raw就变成了对更新量的低通,性质不一样,收敛速度会明显变慢。这个细节我第一次写的时候搞反过,曲线能收敛但慢得离谱,查了两天才发现是一个下标的问题。
参数的影响可以这样估:gamma_p大约取对象直流增益倒数的 0.3 到 0.8 倍起步,太大直接发散,太小二十代都看不出变化。gamma_d一般比gamma_p小一到两个数量级,它的作用是补偿对象相位滞后,对象阶次越高越需要,但同时会把测量噪声放大,所以实机上gamma_d通常比仿真里更保守。
3.3 主循环:用 for + sim 驱动迭代,误差序列怎么存
迭代的驱动力在脚本里,不在模型里。核心就是一个 for 循环,每转一圈做一次完整试验:
% ilc_main.m —— 迭代调度主循环 ilc_params; % 载入参数,工作区得到 Ts/N/K_iter 等 e_history = zeros(N, K_iter); % 每列存一代误差,方便后面按列取范数 u_history = zeros(N, K_iter); % 每列存一代控制输入 u_in = u0; % 第 1 代输入 iter_used = K_iter; % 实际用掉的迭代次数,提前收敛时更新 for iter = 1:K_iter assignin('base', 'u_in', u_in); % 塞进基础工作区供模型读取 out = sim('ilc_plant', 'StopTime', num2str(T_end)); e_k = out.e_log(:); % 取误差序列并保证是列向量 e_k = e_k(1:N); % 截断到 N 点,防止求解器多算一步 e_history(:, iter) = e_k; u_history(:, iter) = u_in; err_norm = norm(e_k, 2); fprintf('iter %02d ||e||_2 = %.4e\n', iter, err_norm); if err_norm < 1e-3 % 收敛判据,达标就提前退出 iter_used = iter; break; end u_in = ilc_law(e_k, u_in, gamma_p, gamma_d, q_alpha); % 算下一代输入 end几个参数值得说明。assignin('base', ...)把u_in写进基础工作区,是因为模型里的From Workspace块默认就是从 base 读变量;如果模型配置成从模型工作区读,这里要改成set_param加Simulink.SimulationInput,否则就是那个经典翻车现场——跑三十次,曲线一动不动。e_k(1:N)的截断也不能省,变步长求解器在末点可能多输出一个采样,长度变成N+1,直接往e_history里塞会报维度不匹配。
norm(e_k, 2)作为收敛判据是最省事的做法,但要注意它跟误差的绝对量纲挂钩,换了对象、换了单位,阈值就得跟着改。更稳妥的写法是相对判据,用当前代范数除第一代范数,落到 1e-2 以下就算收敛,换个对象也不用重调阈值。
4. 跑起来:从解压到第一条收敛曲线的完整步骤
代码看懂了,还得真跑一遍才算数。这一节按实际操作顺序走,每一步都写清楚失败时该看哪里。
4.1 环境准备与路径设置
需要 MATLAB 和 Simulink,版本上建议 R2018b 及以上,因为再早的版本里MATLAB Function块对persistent的支持和代码生成报告不太一样,容易在小地方卡住。如果模型里用了Transfer Fcn的离散化或者lsim做对比验证,还需要 Control System Toolbox,缺了这个工具箱打开模型会提示模块未授权,不会报错但仿真结果全是零。
解压路径这一条是纯经验:全英文、无空格、层级别太深。放在桌面某个中文目录下,sim有时候能跑,但模型引用和数据存盘会出各种玄学问题,排查起来很浪费时间。解压之后先cd到该目录,用pwd确认一下当前路径,再确认.slx和几个.m在同一层——不同层的话,sim('ilc_plant')找不到模型,报错信息是“cannot find model”,看着像模型损坏,其实是路径问题。
第一件事不是跑主循环,是跑一次基线:
% 基线检查:不加学习,先看对象本身的跟踪误差有多大 ilc_params; assignin('base', 'u_in', u0); out = sim('ilc_plant', 'StopTime', num2str(T_end)); plot(out.tout, out.y_log, 'b', out.tout, out.yd_log, 'r--'); legend('实际输出 y', '期望轨迹 y_d');如果这条基线的红蓝两条线完全重合,说明参考轨迹没接进去,或者对象根本没被驱动,先别往下跑迭代,先修模型。
4.2 参数整定:采样周期、迭代次数、学习增益、Q 滤波器
四个参数有先后顺序,别一起乱调。先定Ts,它必须和模型里所有离散模块的采样时间一致,包括对象的离散化模块、参考轨迹模块、To Workspace的采样设置。检查方法是在模型里搜一遍采样时间字段,凡是填了具体数字的都要对得上,填-1的继承上游,问题不大。
Ts定的依据是对象的主导时间常数,一般取它的 1/20 到 1/50。取得太大,学习律在时间上“看不清”对象的动态,微分项基本失效;取得太小,单次试验的N变大,迭代一次慢,而且噪声占比上升。
然后定K_iter,先给 30 跑一轮看趋势,不要一上来就 200。曲线在前 10 代就明显下降的话,后面只是精修;前 10 代纹丝不动,说明不是迭代次数不够,是增益或者对象接口有问题。
gamma_p从 0.1 开始往上加,每次翻倍,看曲线的下降速度。合适的值表现为误差范数单调下降、五代之内下降一个数量级左右。加到某个值之后曲线开始出现“先降后升”,说明到边界了,退回上一档。gamma_d最后加,从 0.005 起步,它的收益通常不如gamma_p明显,但对象有纯滞后时它是必需的。
q_alpha放在最后微调,0.8 到 0.9 之间试两三个值就够。它的作用在曲线上表现为“末段的抖动变小,但收敛平台抬高一点”,是个明确的取舍。
| 参数 | 起步值 | 调整方向 | 典型症状 |
|---|---|---|---|
Ts | 对象时间常数 / 30 | 与模型内所有离散模块对齐 | 不对齐时结果与理论严重偏离 |
K_iter | 30 | 先看趋势再加大 | 前 10 代无变化说明不是次数问题 |
gamma_p | 0.1 → 翻倍 | 到“先降后升”后退一档 | 过大会发散 |
gamma_d | 0.005 | 有滞后时再往上加 | 过大会放大噪声 |
q_alpha | 0.85 | 0.8~0.9 之间微调 | 过小末段抖动,过大收敛变慢 |
4.3 看结果:收敛曲线与三个判断依据
主循环跑完,单独调plot_convergence:
% plot_convergence.m —— 收敛曲线与单次跟踪对比 err_norm = vecnorm(e_history(:, 1:iter_used), 2, 1); % 按列求 2-范数 figure; semilogy(1:iter_used, err_norm, 'o-', 'LineWidth', 1.2); grid on; xlabel('迭代次数 k'); ylabel('||e_k||_2'); title('ILC 误差收敛曲线'); figure; % 第一代与最后一代对比 plot(e_history(:,1), 'Color', [0.6 0.6 0.6]); hold on; plot(e_history(:,iter_used), 'b', 'LineWidth', 1.2); grid on; xlabel('采样点 n'); ylabel('跟踪误差'); legend('第 1 代', sprintf('第 %d 代', iter_used));用semilogy而不是plot,是因为 ILC 的收敛通常是指数型的,线性坐标下前三代就把整个纵轴占满,后面二十代挤成一条平线,看不出细节。
判读有三条。误差范数单调下降、斜率逐渐变缓,是健康状态。先降后升、甚至在中途翘起来,是增益过大或者缺少 Q 滤波,回去降gamma_p。降到某个值之后完全躺平,下降幅度小于每一代 1%,那不是“收敛了”,是碰到瓶颈了,原因多半是采样周期与对象动态不匹配,或者存在未建模的时滞,得回到 4.2 节重新看Ts。
5. 避坑与排查:ILC 跑不出收敛的五种典型翻车
仿真类工程有个共同特点:出错的时候不报错,只是结果不对。下面五条是我在这类工程上反复遇到的,按“现象 → 原因 → 解决”写,遇到对应症状直接对号入座。
5.1 数值类问题:发散、停滞与抖振
第一条:控制序列逐代放大,几十代之后变成 Inf 或者 NaN。
原因是学习增益超过了稳定边界。ILC 的收敛条件本质上要求|1 - γp·P| < 1,P是对象的直流增益,γp超过2/P就发散,取到1/P附近收敛最快。很多人凭感觉给 0.6、0.8,碰上增益只有 0.5 的对象就直接越界了。
解决办法是先算P:给对象一个单位阶跃,看稳态输出值,那就是直流增益。gamma_p取其倒数的 0.5 倍起步,同时把q_alpha设到 0.9,先求稳再求快。已经发散过的记得清一次工作区,u_in里残留的 Inf 会污染下一轮。
第二条:误差范数降到某个值就不动了,曲线像被削平了头。
常见原因是 Q 滤波器系数给得太小。q_alpha小意味着更新量被强烈低通,高频段的误差修正不进去,范数就卡在一个平台上下不来。判断方法很简单:把q_alpha从 0.85 调到 0.95 再跑一轮,如果平台明显下降,就是这个原因。
另一个原因是参考轨迹里含高频成分,而学习律的gamma_d又不足以跟上。这时候调q_alpha没用,得回头简化参考轨迹,或者加长单次试验时间让动态充分展开。
第三条:末段误差抖动明显,范数曲线呈锯齿状。
gamma_d太大,把测量噪声或者数值微分噪声放大了。仿真里如果是理想无噪声环境还抖,多半是Ts相对对象动态取得太小,差分运算放大了数值误差。解决方向是把gamma_d减半,或者对误差序列先做一次滑动平均再送进学习律——后者会引入额外相位滞后,慎用,但对付纯数值噪声很有效。
5.2 工程类问题:迭代空转、路径与版本
第四条:跑了三十次,每一代误差完全一样,e_history每一列都相同。
这是最高频的翻车点,没有之一。原因是u_in更新了但模型没读到。可能是assignin写进了基础工作区,而From Workspace块配置的是从模型工作区读;也可能是模型里有persistent变量没清,还留着上一次运行的缓存。
排查顺序:先在工作区敲u_in(1:5)看值有没有变,再看模型里那个From Workspace块的变量名拼写是不是和assignin里的一致(多一个下划线都能让你查半天),最后在循环里加一句clear ilc_law或者把persistent改成显式传参。治本的做法是换Simulink.SimulationInput对象,把输入作为仿真输入显式传进去,不依赖工作区。
第五条:换台机器就报找不到模型,或者报路径含非法字符。
中文路径、空格、以及过深的目录层级都可能触发。表现出来是sim报“cannot find model”或者模型里的数据记录文件写不出来,但模型本身打开是正常的,很容易误判成文件损坏。解决就是换一个纯英文短路径,解压到D:\work\ilc\这种层级,重新cd过去再跑。
顺带一条版本相关的:低版本 MATLAB 打开高版本保存的.slx会提示“模型由更新版本创建”,强行打开后部分模块参数丢失,仿真能跑但结果不对。这种情况别硬修,找保存者导出成低版本,或者直接用对应版本环境。
6. 二次开发:把仿真工程改成能上实验台的版本
从仿真走到实机,改动集中在三个地方,我一般按下面的顺序推,每改一处都保留上一版可回滚的.m和.slx,这是吃过亏之后养成的习惯——控制序列这种东西,一旦被写坏了又没有存档,只能从头再来一遍。
第一处是换被控对象。把Transfer Fcn换成实测辨识出来的离散State-Space,或者直接换成一组实测的频响数据拟合结果。接口保持不变:输入一路u,输出一路y,长度N。换完之后第一件事不是跑 ILC,是再跑一次 4.1 节的基线,确认对象在开环下能被正确驱动,输出量纲和参考轨迹对得上。量纲不对是最隐蔽的错,误差序列看着在降,其实降的是个比例错误的量。
第二处是给学习律加频域整形。仿真里q_alpha一阶低通够用,实机上往往要做成零相位滤波,把L和Q分开设计:L负责在对象带宽内放大误差,Q负责在带宽外快速衰减。做法是把 3.2 节里那两行更新式改成对整段序列做一次filtfilt,注意这样就不能在MATLAB Function块里逐点算,得挪到脚本层,模型里只留对象。这个改动会破坏前面说的“算法可原样移植到 C”的优点,是个明确的取舍,做之前想清楚后面到底要不要上嵌入式。
第三处是相位对齐。实机有采样抖动、有传感器延时、有执行器死区,这些在仿真里都是零。表现出来是仿真收敛、实机发散,或者实机前几代收敛得很好,到第十代左右开始缓慢振荡。验证方法很土但有效:让实机跟着一段已知的正弦指令空跑,把输入输出录下来做互相关,看主峰偏了几个采样点,偏几个就在学习律里补几个采样点的超前。
| 改造点 | 改哪个文件 | 验证方式 |
|---|---|---|
| 换被控对象 | ilc_plant.slx | 重跑开环基线,检查量纲与轨迹贴合 |
| 频域整形 L/Q | ilc_main.m+ 新建滤波器脚本 | 对比整形前后误差范数的收敛平台 |
| 相位补偿 | 学习律内加超前步数 | 实机正弦空跑的互相关主峰位置 |
| 数据留存 | data/目录 | 每代都存u_k,能按代回放 |
最后说一个具体的技巧:把每一代的u_k单独存成u_k_%03d.mat,而不是只存在内存的u_history里。实机调试时最需要后悔药,某一代跑崩了,直接load上一代的u重放,比重新学二十代快得多。那条命令我在每个实机项目里都会写,save(sprintf('data/u_%03d.mat', iter), 'u_in'),一行的事,省下的是整晚的时间。
从那以后我每次动学习增益或者换对象之前,都会强制走一遍“基线—单代—十代—全量”这个流程,绝不跳步直接跑全量迭代,因为跳过的那一步里往往藏着真正的原因。希望帮到你。
本文还有配套的精品资源,点击获取