ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

电机控制前馈补偿实战:对症PID滞后,调参顺序与代码实现

电机控制前馈补偿实战:对症PID滞后,调参顺序与代码实现 1. 为什么PID已经不够用反馈控制的滞后到底从哪来接触电机控制的人都知道PID比例、积分、微分三个环节加起来能对付绝大多数调速和位置控制问题。我最早入行的时候也是这样感觉把三组系数调到一个不震荡又不迟钝的状态整个项目就稳了。但后来做高速动态响应的设备发现一个非常扎心的问题给定速度从500转跳到1500转纯PID要跟半天才能追上等它追上的时候后面的工序已经等不及了。很多人第一反应是加大比例系数但加太猛又会震荡甚至啸叫。这个问题困扰了我挺长时间直到我认真去理解PID的本质才发现问题不在参数而在控制架构本身。1.1 误差驱动的本质你的控制器永远在追误差PID是典型的有差调节器它的输入是目标值和实际值的偏差输出是控制量。也就是说它必须看到误差才会动作。这句话看起来是废话但仔细琢磨一下就会发现一个逻辑死角——当设定值快速变化的时候PID的控制量一定是滞后的。因为控制量要靠误差来产生而误差是目标变化一段时间之后才体现出来的。举个例子你在高速公路上开定速巡航目标是120公里每小时。如果当前车速是80公里每小时你踩油门没问题。但如果前面有一辆慢车变道走了你突然把目标改为120纯反馈控制器在你踩下油门的瞬间车速还是80误差是40它只会根据这40的误差去给油门指令。可你的真实需求是在加速的整个过程中持续输出较大的扭矩而不是等到误差消失之后才慢慢收油。反映到电机系统里就是纯PID控制下转速曲线总是慢半拍起步超调之后又慢半拍回落。你把比例调大超调更明显把积分调快又容易引入震荡。总觉得增益怎么调都不对劲其实不是增益的问题是控制器的信息来源太单一了。1.2 一个简单场景方波给定和斜坡加速下纯PID会输在哪我拿一个简化模型跑过对比。电机模型用一阶惯性加纯延迟来近似时间常数大概是0.1秒给定速度是阶跃跳变。在纯PID参数已经整定得比较合理的情况下转速响应有大约两三个控制周期的延迟然后按指数曲线爬升。阶跃响应里面延迟不可避免但如果是斜坡加速也就是每次控制周期目标速度都增加一点纯PID的滞后会被持续放大因为控制器始终在追一个不断移动的目标稳态误差会一直存在而且跟目标变化速率成正比。这个滞后在很多设备上表现成“跟手性差”。比如云台转动手柄摇动角度电机应该同步转过去但纯PID控制下云台好像在水里游泳慢半拍又有点飘。还有机器人的关节电机运动轨迹一旦涉及加速和减速纯反馈就会拖后腿。前馈补偿想解决的就是这个问题。它不再等偏差出现而是直接从目标信号里提取信息提前给出一部分控制量。PID只需要处理模型不匹配和外部扰动这部分残余误差就够了。用一个开车类比就是前馈是你预判前面要上坡提前加油门而不是等到车速掉下来才踩油门。反馈是车子真的掉速了再补一脚油。两者配合既能快速响应又能稳住误差。2. 电机控制里的前馈补偿四种常见形态与选型逻辑前馈这个词听起来高大上其实本质上就是把你对系统的一些先验知识利用起来。你知道给电机加多少电压能转到多少转速你知道转子的加速需要额外多大力矩这些知识如果不用控制器就只能靠误差一点点试出来。前馈就是把这些已知信息直接折算成控制量提前输出。但在实际工程里前馈不是说写一个比例关系就完事它有不同层次针对的问题也不一样。我按自己常用的分类法给它们排一下。2.1 速度前馈让PID只管“擦屁股”的最简单手段速度前馈是最容易实现、效果也最明显的一种。思路很简单如果电机在稳态下输出控制量和转速的关系近似线性那我直接拿目标转速乘以一个系数得到一个基础控制量再加上PID根据误差修正出来的分量。这里有件事需要说清楚不同控制对象的“输出控制量”不一样。如果是PWM调速的直流电机控制量是占空比如果是伺服驱动器控制量可能是电流指令或电压指令。速度前馈的系数就是稳态增益单位是“占空比/目标转速”或者“电压/目标转速”可以用开环标定的方式测出来。我见过很多工程师对速度前馈有误解觉得它不过就是加大比例增益而已。其实不是。比例增益是放大误差误差大输出就大误差小输出就小。前馈则是在误差为零的时候也有输出。一个纯粹靠比例控制的系统永远不会精准跟踪斜坡输入但加了速度前馈之后只要前馈系数接近真实系统的稳态增益即使比例增益不变跟踪误差也会大幅缩小。2.2 加速度前馈与摩擦力前馈动态响应和低速段的补充速度前馈解决的是稳态匹配但系统做加速的时候还需要额外一部分力矩来克服惯性。这部分需求速度前馈给不出来因为在匀速工况下速度前馈的输出已经能维持转速了要再加速就需要额外增加输出这部分就靠加速度前馈来补。加速度前馈的公式长这样输出增量等于加速度前馈系数乘以目标加速度。在很多嵌入式控制代码里目标加速度不是直接给的而是通过对目标速度做差分获得的。假设控制周期是1毫秒那上一时刻目标速度和当前时刻目标速度之差除以0.001就是近似的目标加速度。摩擦力前馈是另一个容易被忽略的点。电机在启停阶段尤其是低速换向的时候静摩擦和库仑摩擦会让系统出现“死区”——你给了控制量转速纹丝不动直到控制量超过某个阈值转子才开始动。这种情况PID很难调积分作用会自动去补这部分的误差但每次都要等动态性能很差。摩擦力前馈的做法是在检测到目标速度不为零或者方向发生变化的时候额外叠加一个固定偏置直接跨过死区。2.3 前馈的边界模型误差、非线性与饱和别把前馈当成万能药前馈的局限性也得说清楚。它依赖于你对系统特性的了解程度。如果模型误差太大前馈不但没有好处还会帮倒忙。比如你的前馈系数标定是在空载下做的实际现场电机带了张大惯量负载加速度前馈系数就偏小了加速依然滞后反过来如果负载惯量比标定时小很多前馈就容易给过头系统会表现为过冲甚至短促震荡。另外前馈输出要经过控制量饱和限制。你不可能因为前馈算出200%的占空比就真的给电机输出200%的电压超出限幅的部分会被截断。这里其实有一个隐含的好处前馈被截断之后短时间内PID积分还在正常工作用来补偿剩余误差。但前提是PID和前馈的输出在代码里是分开计算的我见过有人图省事把前馈直接加到PID输出上然后一起做限幅结果实际上出现“积分饱和前馈被截断”这种叠加效果系统响应反而比纯PID更差。所以选型的时候要有一个基本判断如果你的应用主要是匀速运行速度前馈就够用如果要频繁加减速加速度前馈也需要加如果低速、换向、爬行问题明显摩擦力前馈要安排上。至于更高阶的电流前馈、力矩前馈通常用于伺服系统的电流环和速度环配合场景普通电机调速暂时用不上那个层级的东西。3. 代码实操Python仿真与C运动控制类理论讲太多容易飘下面直接上代码。我准备了两个例子一个用Python做仿真对比让你直观感受纯PID和PID前馈的差距另一个是C实现的前馈PID类结构上可以直接搬到STM32或DSP28379这类嵌入式平台。先说一点基础。无论哪种实现前馈PID的通用结构都包括三个部分对设定值计算前馈分量对设定值和测量值计算误差再走PID把前馈和PID输出加在一起做限幅。3.1 Python小实验纯PID vs PID前馈跑了才知道差距Python适合做算法验证模型可以用简单的一阶惯性环节近似。下面这个脚本模拟了一个带PWM控制直流电机关键是它把几个环节都独立出来了方便你修改参数跑实验。import time import math class PID: def __init__(self, kp, ki, kd, dt, out_min, out_max): self.kp kp self.ki ki self.kd kd self.dt dt self.out_min out_min self.out_max out_max self.integral 0.0 self.prev_error 0.0 def reset(self): self.integral 0.0 self.prev_error 0.0 def compute(self, setpoint, measurement): error setpoint - measurement self.integral error * self.dt derivative (error - self.prev_error) / self.dt output self.kp * error self.ki * self.integral self.kd * derivative self.prev_error error # 积分限幅 if output self.out_max: output self.out_max self.integral - error * self.dt # anti-windup简单回退积分项 elif output self.out_min: output self.out_min self.integral - error * self.dt return output class FeedforwardPID: def __init__(self, kp, ki, kd, dt, out_min, out_max, kff_v, kff_a): self.pid PID(kp, ki, kd, dt, out_min, out_max) self.dt dt self.kff_v kff_v # 速度前馈系数 self.kff_a kff_a # 加速度前馈系数 self.prev_setpoint 0.0 def reset(self): self.pid.reset() self.prev_setpoint 0.0 def compute(self, setpoint, measurement): # 速度前馈分量 ff_v self.kff_v * setpoint # 加速度前馈分量对设定值做差分 accel (setpoint - self.prev_setpoint) / self.dt ff_a self.kff_a * accel # PID 反馈分量 fb self.pid.compute(setpoint, measurement) self.prev_setpoint setpoint out ff_v ff_a fb # 总输出限幅 if out 1.0: out 1.0 elif out 0.0: out 0.0 return out # 一阶惯性系统模拟time_constant 是机电时间常数gain 是稳态增益 class MotorPlant: def __init__(self, time_constant0.1, gain100.0, dt0.001): self.tau time_constant self.gain gain self.dt dt self.velocity 0.0 def step(self, pwm): # 简化直流电机模型dv/dt (gain*u - v) / tau self.velocity (self.gain * pwm - self.velocity) / self.tau * self.dt return self.velocity def run_simulation(controller, plant, setpoints, dt0.001): history [] for sp in setpoints: pwm controller.compute(sp, plant.velocity) vel plant.step(pwm) history.append((sp, vel, pwm)) return history if __name__ __main__: dt 0.001 # 纯PID pid_only PID(kp5.0, ki10.0, kd0.0, dtdt, out_min0.0, out_max1.0) # 前馈PID给一组合适的前馈系数 ff_pid FeedforwardPID(kp2.0, ki8.0, kd0.0, dtdt, out_min0.0, out_max1.0, kff_v0.01, kff_a0.1) motor1 MotorPlant(time_constant0.1, gain100.0, dtdt) motor2 MotorPlant(time_constant0.1, gain100.0, dtdt) # 生成一个包含阶跃和斜坡的参考轨迹 setpoints [] for i in range(2000): if i 500: sp 0.0 elif i 1500: # 斜坡加速 sp (i - 500) * 0.05 else: sp 50.0 setpoints.append(sp) hist_pid run_simulation(pid_only, motor1, setpoints, dt) hist_ff run_simulation(ff_pid, motor2, setpoints, dt) # 打印关键对比点方便直接用数字体会差距 print(时刻 t1.0s 时) print(f目标速度: {setpoints[999]:.2f}) print(f纯PID实际速度: {hist_pid[999][1]:.2f}误差: {abs(hist_pid[999][1] - setpoints[999]):.2f}) print(f前馈PID实际速度: {hist_ff[999][1]:.2f}误差: {abs(hist_ff[999][1] - setpoints[999]):.2f}) print(f纯PID PWM输出: {hist_pid[999][2]:.2f}) print(f前馈PID PWM输出: {hist_ff[999][2]:.2f})这个脚本跑完你会看到在斜坡跟踪阶段纯PID的误差比前馈PID大不少而且前馈PID用的PWM输出明显更早跟上了目标速度的变化。这里的前馈系数我不是随便写的是依据模型稳态增益反推出来的。模型增益100意思是PWM占空比到稳态转速的倍数是100那速度前馈系数大概取1/1000.01加速度前馈则要考虑时间常数和模型增益之间的关系我这里取0.1是经过一两次试凑的。需要注意的是上面的PID实现里我做了简单的抗积分饱和也就是输出超限时回退积分项。这个在工程里非常重要尤其在加前馈之后因为前馈输出大PID输出很容易被限幅截断如果积分还在继续累加就会出现“退出限幅之后还要很久才恢复”的现象。3.2 C实现一个可以直接搬到嵌入式工程的前馈PID类嵌入式环境对代码有更严格的要求不能有动态内存分配、不能有STL依赖、要能在中断服务函数里快速执行。我给的C类就按这个标准来写初始化之后所有内存都在栈上或对象内部跑一个计算函数也就是几十条指令的事。#ifndef FEEDFORWARD_PID_H #define FEEDFORWARD_PID_H class FeedforwardPID { public: struct Config { float kp; float ki; float kd; float dt; float kff_v; // 速度前馈系数 float kff_a; // 加速度前馈系数 float out_min; float out_max; float integral_limit; }; FeedforwardPID() default; void init(const Config cfg) { cfg_ cfg; integral_ 0.0f; prev_error_ 0.0f; prev_setpoint_ 0.0f; prev_measurement_ 0.0f; output_ 0.0f; } void reset() { integral_ 0.0f; prev_error_ 0.0f; prev_setpoint_ 0.0f; prev_measurement_ 0.0f; output_ 0.0f; } // 调用一次就更新一次适合放在定时中断里 float compute(float setpoint, float measurement) { // 1. 前馈分量 float ff_v cfg_.kff_v * setpoint; float accel (setpoint - prev_setpoint_) / cfg_.dt; float ff_a cfg_.kff_a * accel; float feedforward ff_v ff_a; // 2. PID反馈分量 float error setpoint - measurement; integral_ error * cfg_.dt; // 积分限幅 if (integral_ cfg_.integral_limit) integral_ cfg_.integral_limit; if (integral_ -cfg_.integral_limit) integral_ -cfg_.integral_limit; float derivative 0.0f; if (cfg_.kd 0.0f) { // 对测量值微分避免设定值跳变带来的微分冲击 derivative (measurement - prev_measurement_) / cfg_.dt; } float fb cfg_.kp * error cfg_.ki * integral_ - cfg_.kd * derivative; // 3. 合并输出并限幅 output_ feedforward fb; if (output_ cfg_.out_max) output_ cfg_.out_max; if (output_ cfg_.out_min) output_ cfg_.out_min; prev_setpoint_ setpoint; prev_measurement_ measurement; prev_error_ error; return output_; } float get_output() const { return output_; } float get_integral() const { return integral_; } private: Config cfg_{}; float integral_ 0.0f; float prev_error_ 0.0f; float prev_setpoint_ 0.0f; float prev_measurement_ 0.0f; float output_ 0.0f; }; #endif这个类有几个设计点值得展开讲。第一我故意把微分项写成对测量值求导而不是对误差求导。经典的PID公式里微分项是kd * d(error)/dt但当设定值阶跃变化的时候误差瞬间跳变微分项会输出一个巨大的尖峰俗称“微分冲击”。电机控制里这种尖峰会直接变成电流过冲非常伤驱动器和电机。对测量值求导设定值怎么跳都不影响微分项只有实际速度真实变化时才起作用。第二积分限幅单独设了一个integral_limit而不是等输出超限才回退。我调试中发现加前馈之后PID输出被限幅的几率很高如果用“输出超限回退积分”的经典方法积分项会频繁被回退导致低速段效果变差。独立限幅更直观你先把积分限制在一个合理区间剩下的交给比例和前馈去配合。第三整个类没有虚函数没有动态内存所有方法都是inline友好的。放到STM32的定时器中断或者DSP28379的PWM中断里单次调用耗时基本在1微秒以下主频150MHz以上时完全不影响控制周期。调用方式大概是这样的FeedforwardPID::Config cfg; cfg.kp 2.5f; cfg.ki 12.0f; cfg.kd 0.05f; cfg.dt 0.001f; cfg.kff_v 0.01f; cfg.kff_a 0.08f; cfg.out_min 0.0f; cfg.out_max 1.0f; cfg.integral_limit 0.3f; FeedforwardPID controller; controller.init(cfg); // 1kHz定时中断 void timer_isr() { float target_speed get_target_speed_from_planner(); float actual_speed get_speed_from_encoder(); float pwm controller.compute(target_speed, actual_speed); set_pwm(pwm); }3.3 参数怎么传给代码前馈系数标定的代码级思路很多初学者拿到代码第一步就问kp、ki、kd、kff_v、kff_a这些系数到底怎么定。前馈系数其实比PID系数好定因为它有明确的物理意义。速度前馈系数的标定最简单给电机一个固定的PWM占空比等转速稳定之后记录转速值。比如你给50%的占空比转速稳定在1000转那kff_v就是0.5/10000.0005单位是“占空比/转速”。多测几个点取一个平均值。如果发现不同转速下的增益不一样说明系统有非线性你可以分段标定或者取工作点附近的值。加速度前馈系数稍微麻烦一点。如果你知道转子的转动惯量和电机的力矩常数可以理论计算。但实际工程里更常用的做法是在速度前馈已经调好的基础上给系统一个参考斜坡观察跟踪误差。如果误差方向是落后实际速度低于目标说明加速度前馈还不够加大kff_a如果超调明显而且设定值稳定之后转速出现回落过冲说明加速度前馈太大。这个调法跟调PID超调量非常像只不过作用机理不同。C类里的前馈系数我建议设计成可以在线修改的变量不要写成常量。这样你在现场调试的时候可以不重新编译直接通过串口或CAN下发修改。这个经验是我在设备调试中总结出来的每次改参数都要重新烧录实在是太浪费时间了。4. 参数整定与调试顺序先调前馈还是先调PID参数整定的顺序其实比参数本身更值得聊。我见过太多人一上来就同时调PID和前馈结果系统震荡了也不知道是哪个环节出了问题。比较合理的顺序是先摸清模型的底再逐步叠加前馈。4.1 开环标定用PWM和转速计把电机模型摸出来在做任何闭环控制之前先做开环阶跃实验。给电机一个固定PWM记录转速上升曲线同时记录稳态转速。这个实验能拿到两个核心参数稳态增益和时间常数。稳态增益就是前面说的PWM和稳态转速的比值。时间常数可以从转速上升到63.2%稳态值所需时间估算出来。对于直流电机来说这个时间常数主要是机电时间常数一般从几十毫秒到几百毫秒不等。我习惯用PWM从10%、20%、30%逐级往上加每次等转速稳定后再记录。这样既能确认系统的线性区间也能发现死区。比如有些电机在PWM低于5%的时候根本不转这个死区范围在后续摩擦前馈里要用到。开环标定是在让系统空转或者带正常负载的情况下做环境不同参数会有差异。最好在实际工况下进行标定尤其对于有减速器的电机负载不同会导致等效转动惯量显著变化加速度前馈系数也会跟着变。4.2 三步调试法从反馈到前馈的渐进路线第一步只调PID。你可以暂时把前馈系数全部置零用传统的Ziegler-Nichols方法或者更保守的试凑法先把系统调到一个稳定但可以接受偏保守的状态。这个阶段的目标不是追求完美动态而是确认反馈环本身没有隐患。如果连纯PID都调不稳问题多半在编码器噪声、PWM分辨率或者控制周期上先解决硬件层面的问题再谈前馈。第二步加速度前馈。保持PID参数不变把小量加速度前馈加进去观察设定值变化时的跟踪误差。如果误差明显减小没有新增震荡就继续加大直到误差不再下降或者开始出现超调为止。这个“误差不再下降”临界点很关键说明前馈已经覆盖了系统模型的大部分需求。第三步加摩擦力前馈。这个一般在低速段和换向工况下才有效果。你可以在启停测试里观察如果转速在从零爬升的过程中有停顿或者爬行现象就给摩擦力前馈一个值让它在目标速度非零时输出一个固定偏置。注意摩擦力前馈只跟方向和是否运动有关不要跟速度成正比否则就变质了。这个顺序的核心思路是每个环节的作用域都清晰反馈负责补偿误差加速度前馈负责动态摩擦力前馈负责死区。一步只调一个变量出现异常就能立刻定位是哪个环节的问题。4.3 实际负载变化时怎么办前馈系数的在线修正思路很多设备的负载不是恒定的。比如机器人的关节姿态不同重力力矩就不同再比如机床主轴切削力变化很快。这种情况纯固定前馈系数不够需要图1思路的简单扩展——在线修正。加速度前馈系数的在线修正一般依赖一个思想观测PID输出的平均大小。理想状态下前馈PID控制中PID只需要补偿扰动和模型误差它的输出在较长一段时间内的平均值应该接近于零。如果发现PID输出长期偏向一个方向比如总是正的说明前馈给小了系统需要依赖反馈来补足如果PID输出长期偏向负方向说明前馈给大了。利用这个现象可以做一个慢速自适应以极小的步长去调整前馈系数让PID输出的长期平均值回归到零附近。这里要注意两点。第一自适应速度要足够慢慢到不会和控制环产生耦合我一般会把自适应时间常数设为控制周期的几百倍以上。第二要加阈值判断只有在PID输出波动不大的时候才允许修正否则系统有震荡趋势时还在猛加前馈会直接放大震荡。这种在线修正网上资料里常叫“自整定”或“自适应前馈”工程上属于比较实用的进阶玩法。5. 常见问题与排查技巧实录前面理论、代码、调参顺序都讲完了最后把这些年在现场踩过、见过、帮别人查过的坑集中梳理一下都是很典型的实际问题。5.1 前馈一加就震荡多半是滤波没做好我见过很多工程师吐槽说前馈系数明明标定得没有问题一加进去系统就开始高频抖动。排查到最后十有八九是速度反馈信号噪声太大而加速度前馈是通过目标速度差分得到的噪声会通过差分被放大再叠加到控制量上形成抖动。差分运算的放大效应很直观1毫秒的控制周期里编码器测速如果有一两转的量化误差差分之后除以0.001就会产生每秒上千转/秒的“加速度”这个值乘以前馈系数足以让PWM输出抖起来。解决方法是给速度反馈加低通滤波或对目标加速度做滑动平均。建议先用一阶低通滤波器截止频率设为控制频率的1/5到1/10效果通常就够了。滤波太深会影响响应这个取舍要实际看波形来调整。另外就是编码器分辨率的问题。如果每转只有几百线低速段的测速分辨率会很差这时候加速度前馈系数要尽量保守或者干脆不用加速度前馈只保留速度前馈加PID。硬要上的话必须配合比较强的滤波。5.2 设定值跳变时的冲击问题前馈也要限幅前馈的初衷是“提前给量”但设定值大幅阶跃的时候目标速度差分会在一个周期里产生超大加速度前馈输出瞬间顶到限幅系统会出现猛烈的启停冲击。这种冲击对机械结构是实实在在的损伤对噪音和振动的影响也很明显。处理方法有几个。第一在规划层做斜坡或者S曲线限幅从根源上不让目标速度出现阶跃。这个从我个人的使用体验来说是最推荐的做法因为前馈补偿应该补偿的是一个合理轨迹下的需求而不是补偿一个不合理的阶跃。第二对加速度前馈输出本身做限幅不让它瞬间拉满代价是响应速度会略降。第三给加速度前馈加低通滤波但滤波会引入相位延迟调的时候要注意。我自己习惯的做法是位置环和速度环都做梯形速度规划设定值每一拍只允许变化一个最大增量这样即使外部给定是跳变的控制环内部看到的也是一个平滑的目标轨迹。前馈在这个平滑轨迹上工作既不会超调也不会冲击。5.3 代码里的隐坑积分饱和、微分冲击、时间常数匹配代码层面的坑大部分都集中在实现细节上。积分饱和前面说了这个在纯PID时代就要注意。加了前馈之后输出限幅更容易被触发所以要单独给积分限幅不要等输出超限才回退否则会发现系统大动态过后转速要很久才能恢复。微分冲击也要重点处理。PID教科书里的微分项通常是对误差求导但工程上强烈建议改成对测量值求导甚至干脆不用微分——很多电机速度控制场景PID只需要PI就够了D加不好反而放大噪声。如果一定要用D别忘了在测量值上做滤波不然编码器量化噪声经过微分会变成PWM上的高频抖动。时间常数匹配这个点容易被忽略。你的C代码里cfg.dt必须和实际控制周期严格一致。我调试中发现明明所有参数都是仿真调好的上机之后误差大很多查了半天发现控制中断实际频率是4kHz代码里dt却填了0.001。差分出来的加速度差了整整4倍前馈系数等于白给。所以建议在整个工程初始化的时候用定时器实际频率去反填dt不要写死。最后说一个调参习惯。做前馈调试的时候一定要把反馈波形和前馈波形分开记录下来。很多调试软件能把设定值、实际值、PID输出、前馈输出画在同一张图上你一眼就能看出来系统是反馈在补误差还是前馈在用劲儿。如果PID输出一直很大大概率前馈没给够如果前馈输出占大头而PID输出趋近于零说明系统处于健康状态。这个观察习惯帮我避免了很多盲目调参的弯路。
返回列表