翻译自Arduino PID 库作者的系列博客文章:Improving the Beginner’s PID。作者的结论应该经过了不少人的实际检验,而且文章讲得平易近人,不需要太多的理论基础。
开篇 - INtroduction
随着新的Arduino PID 库的发布,我决定发布这一系列文章。上一个库虽然稳定,但并没有附带任何代码解释。这一次,我计划详细解释代码实现为什么写成那样,希望能在某些方面帮到读者:
- 直接对Arduino PID 库内部发生的事情感兴趣的人将获得详细的解释;
- 想自己编写PID 算法的人都可以看看我是怎么做,然后借鉴他们喜欢的任何东西;
这将是一次艰难的旅程,但我想我找到了一种不太痛苦的方式来解释我的代码。我将从我所谓的“初学者PID ”开始。然后逐步改进它,直到我们得到一个高效、稳健的PID 算法。
注:后面还会介绍同作者的PID 的自动调参库 Arduino PID Autotune。另外,这些算法并不依赖具体的单片机或别的硬件,可以轻易放在别的地方用。
初学者级别的PID 实现 - THe Beginner’s PID
任何人第一次接触PID 算法时,首先都会看到下面的方程:
注:e 是误差;set point 是调定点,或者说是输入R;Input 在这里指的是来自反馈环的输入,也就是被控系统的输出C;Output 是PID 控制器的输出,传递给被控系统,如下图,环节G 是被控系统:
基本上任何人的第一反应都是瞬间写出下面这样的实现代码:
// 工作变量
unsigned long lastTime;
double Input, Output, Setpoint;
double errSum, lastErr;
double kp, ki, kd;
// 计算PID 输出
void Compute()
{
// 两次PID 计算的时间间隔
unsigned long now = millis();
double timeChange = (double)(now - lastTime);
// 计算工作变量
double error = Setpoint - Input;
errSum += (error * timeChange);
double dErr = (error - lastErr) / timeChange;
// 计算PID 输出
Output = kp * error + ki * errSum + kd * dErr;
// 记录部分数据
lastErr = error;
lastTime = now;
}
// 设置PID 参数
void SetTunings(double Kp, double Ki, double Kd)
{
kp = Kp;
ki = Ki;
kd = Kd;
}
Compute()
函数定期或不定期的被调用,计算PID 输出。这个简单实现基本上也能用,不过这系列文章不能到此就满足。如果我们想让它能够进入工业级PID 控制器的行列,必须先解决一些问题:
- 采样时间:计算周期固定时,PID 算法表现最好。如果算法知道运行周期的具体值,我们还可以简化一些内部的计算;
- 微分冲击(Derivative Kick);
- 运行时参数变更:一个好的PID 算法应该能容许运行中的参数变化,不会因而导致运行出现颠簸;
- 积分饱和;
- 启动:当控制器启动时,我们希望输出不会发生突然跳跃;
- 自动 / 手动模式:大多数应用中,偶尔会需要手动设置控制器的输出,此时PID 控制器不能造成干扰;
- 控制器方向;
- 比例反馈(Proportional on Measurement):这可以让某些类型系统的控制变得容易;
一旦这些问题被解决,我们就实现了一种鲁棒 [doge] 的PID 算法,这也是最新版Arduino PID 库的实现。所以,无论你是想自己实现一套算法,还是想理解PID 库的内部原理,希望这段旅程都能帮到你。
补充:所有的示例代码都使用了double
作为浮点类型。在Arduino 上,double
和float
是相同的,都是32 位单精度,双精度浮点对PID 而言太奢侈了。如果你使用代码的环境中,double
表示双精度,我推荐你把所有douible
浮点改成float
。
注:基于8位AVR 单片机的经典Arduino 上double 等于float,比如Uno,Nano,Mega2560,基于ESP32、STM32 之类的32 位平台的Arduino 则不一定。如果单片机没有硬件浮点单元(FPU),算浮点都是库函数软件硬算的,比较慢。在8 位AVR单片机上,PID 库函数计算一次大概需要一百微秒左右。有个FastPID 库把内部的浮点改成了定点数,差不多能减少一半的运算时间。
此外,软件计算浮点或定点数时,选择的参数不同,计算时间也是不一样的,为0 的参数越多,计算耗时就越短。
采样时间 - SAmple Time
问题
初学者PID 实现的设计支持不固定的计算周期,这导致了两个问题:
- PID 的行为不是完全一致的,因为计算频率时高时低;
- 计算积分和微分时,需要做额外的浮点运算,因为这两种运算都依赖于时间的变化量;
解决方案
需要保证PID 计算周期是固定的。方法是提前设置一个采样周期参数,PID 函数被调用时,函数内部根据时间,决定本次调用是否要执行计算。一旦我们知道了PID 计算的固定周期,还有额外的好处,能简化积分和微分的计算。
代码
unsigned long lastTime;
double Input, Output, Setpoint;
double errSum, lastErr;
double kp, ki, kd;
int SampleTime = 1000; //1 秒 ++++++++
void Compute()
{
unsigned long now = millis();
int timeChange = (now - lastTime); // +++++++++++
if(timeChange>=SampleTime) // +++++++++++
{
/*Compute all the working error variables*/
double error = Setpoint - Input;
errSum += error; // +++++++++++
double dErr = (error - lastErr); // +++++++++++
/*Compute PID Output*/
Output = kp * error + ki * errSum + kd * dErr;
/*Remember some variables for next time*/
lastErr = error;
lastTime = now;
}
}
void SetTunings(double Kp, double Ki, double Kd)
{
double SampleTimeInSec = ((double)SampleTime)/1000; // +++++++++++
kp = Kp;
ki = Ki * SampleTimeInSec; // +++++++++++
kd = Kd / SampleTimeInSec; // +++++++++++
}
// ++++
void SetSampleTime(int NewSampleTime)
{
if (NewSampleTime > 0)
{
double ratio = (double)NewSampleTime
/ (double)SampleTime;
ki *= ratio;
kd /= ratio;
SampleTime = (unsigned long)NewSampleTime;
}
}
注:上面代码中用+++++ 注释表示这次的代码变更
在第11 和12 行,算法现在自己判断有没有到计算的时间。又因为现在已知计算周期,没必要在计算中引入时间间隔。我们可以适当的修正积分和微分项的计算,修改后和原来的代码在数学上等效,但是开销更小。
还有一点特殊处理,用来应对运行时改变采样周期:SetSampleTime
函数内部Ki
和Kd
参数会根据采样时间做出调整,从而抵消采样时间变化对计算的影响。
另外,在第30 行,我把采样时间转换成以秒为单位。严格来说,这是不必要的,只是可以让用户设置Ki
和Kd
参数时以1/S 或S 为单位,而不是毫秒。
结果
上述变更为我们做了三件事:
- 无论调用
Compute()
的频率如何,PID 算法都将定期进行计算; - 因为对时间做了减法以获取间隔,当
millis()
返回到 0 时就不会有问题了。虽然这种情况每 55 天才会发生一次,但我们的算法要考虑的足够周全; - 我们不再需要乘除时间变化。由于它是一个常数,我们可以将它从计算代码中移出。从数学角度看,计算结果是一样的,但省去了多余的乘除运算;
注:对时间做减法指的是
int timeChange = (now - lastTime)
,这样处理能避免在millis()
时间戳溢出时导致BUG,详情参考:Arduino 单片机程序中处理时间戳、时间溢出和延时问题。
关于中断的说明
如果是在单片机里使用PID,有一些好的理由去使用定时中断,用SetSampleTme
设置中断频率,然后更精确的定时计算。如果你想,你可以在你自己的实现中使用中断,但先继续阅读这系列的文章,后面还会介绍一些改善措施,你或许能得到一些助益。
我有三个 难以拒绝的 不使用定时中断的理由:
- 这系列文章的目标读者并不都会用中断;
- 如果你还想让多个PID 控制器同时工作,事情就会变得比较复杂;
- 老实说,我并没有想到这个问题,是Jimmie Rodgers 帮我校对时提出了这个建议。我可能会在PID 库未来的版本中采用中断;
注:用中断还有个关键问题,就是函数是否可重入。PID 计算函数乍看起来很简单,但由于使用了浮点,编译后,内部其实调用了一些别的库函数。如果中断函数以外的地方也用到了浮点,就要求浮点库函数必须是可重入的,否则浮点计算被中断时可能导致问题。当然还有计算耗时的问题,一堆浮点运算是比较花时间的,要考虑定时中断执行时间太长会不会有问题。
注:目前这个实现依靠轮询系统时间戳来保证计算周期固定,实际精度不高,只是能让计算发生的间隔大于预设的采样时间,不保证最坏情况能大多少。如果调用计算函数的主循环同时还要做别的耗时工作,计算间隔就会变得不规则。不过,只要采样周期足够大,实际计算周期几十、几百微秒的变化就不会造成显著影响。
微分冲击 - DErivative Kick
问题
这项改善要调整一下微分项,目标是消除一种被称为“微分冲击”的现象。
注:懒得翻译这些图表里的英文,如果某位善人有这个闲心,可以在评论把翻译过的图片发给我
上图中展示了微分冲击的表现。由于误差e = SetPoint - Input
,SetPoint 的突变会让误差突变,导致误差的微分理论上变成无穷大。实际中,dt
等于采样周期,并不是无穷小,所以误差的微分只是会变成一个较大的值。然后,误差的微分参与到PID 计算方程中,结果是让PID 输出一个尖峰,并可能导致问题。幸好有一种简单方法可以清除这个问题。
注:这里的尖峰只是在PID 的输出上,而不是被控系统的输出。实际上,参考最上面的系统结构图,因为被控系统G 总会有些惯性之类的积分作用,尖峰会被过滤。从上图中的绿色线就能看出来,系统的输出并不会跟着尖峰发生大的突变。被控系统的速度越慢,对这种微分冲击就越不敏感。
解决方案
注:这里意思是,误差的微分等于SetPoint 和Input 分别微分之差,当SetPoint 不变,则其微分为0,误差微分的形式变成负的Input 微分。
可以发现,误差的微分其实等于负的Input 微分,只要SetPoint 保持不变。这能导出一个完美的解决方案:将微分项从(Kd * dError)
变成-(Kd * dInput)
,也就是把对误差取微分变成对Input 取微分。这被称作“测量端微分(Derivative on Measurement)”。
代码
/*working variables*/
unsigned long lastTime;
double Input, Output, Setpoint;
double errSum, lastInput; // ++++++++++++++
double kp, ki, kd;
int SampleTime = 1000; //1 sec
void Compute()
{
unsigned long now = millis();
int timeChange = (now - lastTime);
if(timeChange>=SampleTime)
{
/*Compute all the working error variables*/
double error = Setpoint - Input;
errSum += error;
double dInput = (Input - lastInput); // ++++++++++++++
/*Compute PID Output*/
Output = kp * error + ki * errSum - kd * dInput; // ++++++++++++++
/*Remember some variables for next time*/
lastInput = Input; // ++++++++++++++
lastTime = now;
}
}
void SetTunings(double Kp, double Ki, double Kd)
{
double SampleTimeInSec = ((double)SampleTime)/1000;
kp = Kp;
ki = Ki * SampleTimeInSec;
kd = Kd / SampleTimeInSec;
}
void SetSampleTime(int NewSampleTime)
{
if (NewSampleTime > 0)
{
double ratio = (double)NewSampleTime
/ (double)SampleTime;
ki *= ratio;
kd /= ratio;
SampleTime = (unsigned long)NewSampleTime;
}
}
这么修改很小,只是把+ dError
变成了- dInput
。现在不记录上一次的误差lastError
,取而代之的是上一次的Input lastInput
。
注:测量端微分
这样确实可以消除SetPoint 突变对PID 输出的影响,毕竟完全把SetPoint 的微分消掉了。但系统的性质已经因而发生了改变。这里的“测量端微分”其实就是测速反馈,改造之后的系统框图如下:
这就是PID 里的D 去掉了,变成PI 控制器和测速反馈串联。长话短说,如果PID 参数都取1 ,则改造以后,系统的开环传递函数是:
G k ′ = ( 1 + s ) ⋅ G s ( 1 + G s ) G_k' = \frac{(1 + s) \cdot G}{s (1 + G s)} Gk′=s(1+Gs)(1+s)⋅G
而参考最前面的PID 系统图,原来的开环传递函数是:
G k = ( 1 + s + s 2 ) ⋅ G s G_k = \frac{(1 + s + s^2) \cdot G}{s} Gk=s(1+s+s2)⋅G
显然,改造成测速反馈之后,系统少了一个开环零点,多了一个开环极点。粗略的分析,这么整就失去了微分环节“预测”误差变化的能力,可能对动态性能不利,就是系统会慢一点。不过因为微分环节的副作用,实际应用中很多人好像都不用微分,所以搞成测速反馈应该也没什么影响,增加点儿阻尼,减少超调。
结果
上图是修改后的结果。注意到Input 曲线看起来差不多。因此,改动前后的性能相近,但不会在每次SetPoint 变化时都发出巨大的Output 尖峰。
这可能是个大问题,也可能不是。这完全取决于你的实际应用对输出尖峰的敏感程度。在我看来,既然消除尖峰不会增加更多的工作量,何乐而不为呢?
评论摘录
原文底下评论区里作者和几个人的评论有几个有趣的地方,也摘录过来。
【Coding Badly】:
“显然,这对某些(大多数?)应用来说是正确的,但并不是放之四海而皆准。我曾开发过一个电加热器控制器,它需要一个相当 "激进 "的微分。输出的剧烈变化不仅是可以接受的,而且是必须的。
我见过两种获得大致相同效果的方法:1.输出变化率箝位;2.输出滤波。其优点是用户可以消除所有尖峰、允许一些 "尖峰 "或允许所有尖峰。”
【作者回复】:
“这个观点很有道理。这也解释了为什么所有制造商都允许你选择 “误差微分”,即使它们默认设置成 “测量端微分”。我说 "测量端微分 "是正确的方法,有些言过其实了。
我也见过那些输出滤波器,它们有点超出本系列的范围,但在合适的人手中却非常有效。它们已被列入 "我以后会尝试讨论的东西 "清单。”
【Jason Short】:
我发现,如果 PID 以较高的速率(200 Hz)运行,而测量变化率的传感器以较低的速率(例如 10 Hz)运行,那么如果 D 项较高,就会出现尖峰。我们必须在微分项上使用滑动平均滤波器来控制这种情况,否则四旋翼飞行器引擎的声音就会很难听,或产生其他更多负面影响。
【作者回复】:
首先感谢您来到这里。我是 diydrones 项目的忠实粉丝。
根据我的经验(几乎完全是大型工业 PID),运行 PID 的速度超过输入信号变化的速度并不会带来任何好处。
在工作中,我很少使用滤波器。一般来说,我发现直接去掉微分项会比过滤信号更好。不过,这需要对信号进行很好的调整,这需要一些时间来掌握诀窍。
但正如我所说,我在机器人/电机控制 PID 方面经验很少,所以可能有我不知道的优点。
注:在微分项后面再串联滤波器,可能相当于增加了额外的积分环节。
参数变化 - Tuning Changes
问题
运行中调节参数的功能对任何好的PID 算法都是必须的。
如上图,新手级PID 运行中遇到参数改变时会产生异常的输出,来看看原因是什么。下面是参数改变前后PID 的状态表:
我们立即就找到了罪魁祸首,是积分项(ITerm),在参数改变后,只有它发生了大幅突变。为什么会这样?先看看新手级算法使用的积分方式:
这个算法没什么问题,直到Ki 发生变化。于是,突然间,你把这个新的Ki 值和整个误差积分errSum
乘起来了。注:就是errSum 没变,Ki 从0.6 突变成0.3,于是积分项的值瞬间变成原来的一半,导致PID 输出突变。这不是我们想要的,我们只想控制之后的发展。
解决方案
我知道有几种方法可以解决这个问题。我在上一个库中使用的方法是重新缩放 errSum
:Ki 加倍了?那就将errSum
减半。不过那样有点笨拙,我想出了更优雅的方案。(我不可能是第一个想到这一点的人,但我确实自己想到了。这算该死!)
注:就是乘法分配律。
与其把Ki 放在积分外面,我们可以把它拿进去。看起来好像没什么区别,但实际中会发生很大的变化。现在我们算出单次的误差,然后把它和随便什么Ki 即时乘起来,再将结果求和。Ki 的变化不会和已经求和过的误差积分叠加,所以积分项不会突变。不需要额外的计算,我们就能得到平滑的转变过程。这可能让我显得像个geek,但是我觉得这种算法非常sexy。
注:用公式来说明就是,
( K i + Δ K i ) ∗ ∑ n = 0 N E r r n ≠ ∑ n = 0 N − 1 K i ⋅ E r r n + ( K i + Δ K i ) E r r N (Ki + \Delta Ki) * \sum_{n = 0}^{N}{Err_{n}} \ne \sum^{N - 1}_{n = 0}{Ki \cdot Err_{n}} + (Ki + \Delta Ki) Err_N (Ki+ΔKi)∗n=0∑NErrn=n=0∑N−1Ki⋅Errn+(Ki+ΔKi)ErrN
代码
/*working variables*/
unsigned long lastTime;
double Input, Output, Setpoint;
double ITerm, lastInput; // +++++++++++++++++++++
double kp, ki, kd;
int SampleTime = 1000; //1 sec
void Compute()
{
unsigned long now = millis();
int timeChange = (now - lastTime);
if(timeChange>=SampleTime)
{
/*Compute all the working error variables*/
double error = Setpoint - Input;
ITerm += (ki * error); // +++++++++++++++++++++
double dInput = (Input - lastInput);
/*Compute PID Output*/
Output = kp * error + ITerm - kd * dInput; // +++++++++++++++++++++
/*Remember some variables for next time*/
lastInput = Input;
lastTime = now;
}
}
void SetTunings(double Kp, double Ki, double Kd)
{
double SampleTimeInSec = ((double)SampleTime)/1000;
kp = Kp;
ki = Ki * SampleTimeInSec;
kd = Kd / SampleTimeInSec;
}
void SetSampleTime(int NewSampleTime)
{
if (NewSampleTime > 0)
{
double ratio = (double)NewSampleTime
/ (double)SampleTime;
ki *= ratio;
kd /= ratio;
SampleTime = (unsigned long)NewSampleTime;
}
}
现在我们用积分项变量ITerm
取代了误差积分errSum
,ITem
是Ki * err
的求和。由于Ki
的计算结果被包进ITerm
里了,PID 算式中就去掉了Ki
。
结果
于是这个问题就解决了,新的Ki 只会影响后续发生的事,正是我们想要的。
积分饱和 / 积分退饱和 - REset Windup
问题
积分饱和是一个陷阱,可能比任何其他陷阱都更容易让新手中招。当 PID 认为自己能做一些不能做的事情时,就会出现这种情况。例如,Arduino 上的 PWM 输出可接受 0 ~ 255 的占空比参数。默认情况下,PID 并不知道这一点。如果它认为把输出提高到三、五百可以控制的更好,那么它就会尝试这些值,追求它期望的结果。而实际上,输出被限制在 255,继续提高不会有任何效果,如同被某种阻力挡住了,所以它只能不断尝试更高的数值。
这个问题表现为一种奇怪的滞后。在上图中,我们可以看到输出被 “卷起”,远远超过外部限值。当设定点下降时,输出只能从超过限值的地方逐步下降,直到输出重新回到限值范围内,PWM 占空比才从255 开始下降。
注:如果系统的误差不能归零,始终有一段偏差,但PID 输出已经到达限值,不能推动系统继续缩小误差,那么误差积分就会累计,使PID 输出超过限值还继续上升。就像车驼了重物之后上坡只能跑到25Km/s,调定点放在了35Km/s,当PID 控制车跑到25Km 时到达限值。假设此时PID 输出255,由于PID 的输出不能让车跑的更快了,误差持续累积,导致PID 输出继续上升,比如一直到500。然后突然到了下坡,车速瞬间上升,超过了35Km,误差方向改变,PID 输出从500 开始逐渐下降。积分项下降需要一段时间,当输出下降到255 之后,车速才继续受到PID 的控制。所以表现出来,积分饱和就是在误差方向改变时,让被控系统的反应出现很大的滞后。
注:除了上述的不可为而为之的情况,积分饱和还可能发生在积分速度过快时。如果被控系统的响应速度比积分速度慢很多,误差缩小的过程中,积分就饱和了。之后就算误差归零,积分项也不会下降,PID 输出维持在高于限值的状态,然后就是相似的,误差反向时响应滞后。
解决方案 - 第一步
有几种方法可以减轻这个问题,我选择的方法是:告诉 PID 输出限制在在哪里。在下面的代码中,你会看到有一个 SetOuputLimits
函数。一旦达到任一限制,PID 就会停止求和(积分),它知道没有什么可以做了;由于输出不会持续上升,所以当设定点下降到系统可以采取措施的范围时,我们会立即得到响应。
解决方案 - 第二步
观察上面的曲线,虽然我们摆脱了滞后的问题,但还没有完全清除这个问题。PID 的输出依然超过了限值,这是为什么呢?原因是比例项和微分项(程度较小)。
尽管积分项已被安全的钳位,但由于误差存在,P 和 D 不会变为0,导致结果高于输出限值。在我看来,这是不可接受的。如果用户调用一个名为 "SetOutputLimits "的函数,他们就会认为这意味着 “输出将保持在这些值的范围内”。因此,在第二步,除了积分项,我们还钳位了输出值,使其保持在我们预期的范围内。
你可能会问,为什么要同时钳位这两个值?如果我们无论如何都要钳位输出,为什么还要单独钳位积分呢?如果我们只夹住输出,积分项就会继续增长。虽然在上升过程中输出看起来不错,但在下降时中我们就会看到明显的滞后现象。
注:这个地方是把积分项单独钳位在255,当积分项到达255 时停止继续积分,但由于P 和D 不是0,所以输出仍然高于255。后面会有进一步解释和可能的优化方案。
代码
/*working variables*/
unsigned long lastTime;
double Input, Output, Setpoint;
double ITerm, lastInput;
double kp, ki, kd;
int SampleTime = 1000; //1 sec
double outMin, outMax;
void Compute()
{
unsigned long now = millis();
int timeChange = (now - lastTime);
if(timeChange>=SampleTime)
{
/*Compute all the working error variables*/
double error = Setpoint - Input;
ITerm+= (ki * error);
if(ITerm> outMax) ITerm= outMax; // +++++++++++++++++++
else if(ITerm< outMin) ITerm= outMin; // +++++++++++++++++++
double dInput = (Input - lastInput);
/*Compute PID Output*/
Output = kp * error + ITerm- kd * dInput;
if(Output > outMax) Output = outMax; // +++++++++++++++++++
else if(Output < outMin) Output = outMin; // +++++++++++++++++++
/*Remember some variables for next time*/
lastInput = Input;
lastTime = now;
}
}
void SetTunings(double Kp, double Ki, double Kd)
{
double SampleTimeInSec = ((double)SampleTime)/1000;
kp = Kp;
ki = Ki * SampleTimeInSec;
kd = Kd / SampleTimeInSec;
}
void SetSampleTime(int NewSampleTime)
{
if (NewSampleTime > 0)
{
double ratio = (double)NewSampleTime
/ (double)SampleTime;
ki *= ratio;
kd /= ratio;
SampleTime = (unsigned long)NewSampleTime;
}
}
// +++++++++++++++
void SetOutputLimits(double Min, double Max)
{
if(Min > Max) return;
outMin = Min;
outMax = Max;
if(Output > outMax) Output = outMax;
else if(Output < outMin) Output = outMin;
if(ITerm> outMax) ITerm= outMax;
else if(ITerm< outMin) ITerm= outMin;
}
添加了新的函数SetOutputLimits
,允许用户设置输出限值,这些限值会同时钳位输出和积分项。
结果
可以看到,饱和的问题被消除了,而且输出也待在该在的位置。
评论摘录
【Jan Bert】:
“感谢您的精彩解释!您觉得我的建议如何?
与其单独钳位积分项,不如早一点,当输出超过限值时,积分项就不必增加了。”
【作者回复】:
“你当然可以这么做。我选择不走这条路,因为如果输出被 P 和 D 猛冲了一下,可能会错误地影响 I 项的求和。”
【Will】:
“另一种相似的处理方法是:在计算输出后,钳位输出之前,钳位积分项。算法类似这样:
iTerm += ki * error;
output = pTerm + iTerm + dTerm;
if (output > maxLimit) {
iTerm -= output – maxLimit;
output = maxLimit;
}
else if (output < minLimit) {
iTerm += minLimit – output;
output = minLimit;
}
差异很小,但却能将饱和减少到0,消除了单独钳位积分项而不钳位输出时小的超限值。”
注:在别的文章里看到过这种算法,名字叫“反计算抗饱和法”,就是拿输出超限的部分去调整积分项。
【作者回复】:
“完美的技巧!下一次升级PID 库时我会采用这种算法。”
自动 / 手动模式 - ON / Off
问题
有个PID 控制器很好,但有时你不想听它怎么说。
你在程序的某个位置可能想强制PID 输出为某个值,比如0,你可能会这么做:
void loop() {
Compute();
Output = 0;
}
这样,无论 PID 说了什么,你都直接把值覆盖掉。在实际操作中这是个糟糕的想法,PID 会变得非常混乱: “我一直在增加输出,但什么都没发生!怎么回事?让我再增加一些”。因此,当你停止覆盖输出并切换回 PID 时,输出值很可能会立即发生巨大变化。
解决方案
解决方案是添加一个关闭和开启 PID 的方法。这两种状态的通用术语是 “手动”(我将手动调整数值)和 “自动”(PID 将自动调整输出)。让我们看看如何在代码中实现这一点:
代码
/*working variables*/
unsigned long lastTime;
double Input, Output, Setpoint;
double ITerm, lastInput;
double kp, ki, kd;
int SampleTime = 1000; //1 sec
double outMin, outMax;
bool inAuto = false; // +++++++++++++
#define MANUAL 0 // +++++++++++++
#define AUTOMATIC 1 // +++++++++++++
void Compute()
{
if(!inAuto) return; // +++++++++++++
unsigned long now = millis();
int timeChange = (now - lastTime);
if(timeChange>=SampleTime)
{
/*Compute all the working error variables*/
double error = Setpoint - Input;
ITerm+= (ki * error);
if(ITerm> outMax) ITerm= outMax;
else if(ITerm< outMin) ITerm= outMin;
double dInput = (Input - lastInput);
/*Compute PID Output*/
Output = kp * error + ITerm- kd * dInput;
if(Output > outMax) Output = outMax;
else if(Output < outMin) Output = outMin;
/*Remember some variables for next time*/
lastInput = Input;
lastTime = now;
}
}
void SetTunings(double Kp, double Ki, double Kd)
{
double SampleTimeInSec = ((double)SampleTime)/1000;
kp = Kp;
ki = Ki * SampleTimeInSec;
kd = Kd / SampleTimeInSec;
}
void SetSampleTime(int NewSampleTime)
{
if (NewSampleTime > 0)
{
double ratio = (double)NewSampleTime
/ (double)SampleTime;
ki *= ratio;
kd /= ratio;
SampleTime = (unsigned long)NewSampleTime;
}
}
void SetOutputLimits(double Min, double Max)
{
if(Min > Max) return;
outMin = Min;
outMax = Max;
if(Output > outMax) Output = outMax;
else if(Output < outMin) Output = outMin;
if(ITerm> outMax) ITerm= outMax;
else if(ITerm< outMin) ITerm= outMin;
}
// +++++++++++++
void SetMode(int Mode)
{
inAuto = (Mode == AUTOMATIC);
}
相当简单的修改。如果在手动模式,调用Compute
会立即返回,不做计算。
结果
的确,你只要不调用Compute
就能达到相同的效果。但这种解决方案把控制机制封装在了PID 函数内部,而这正是我们所需要的。把开关放在内部,我们可以跟踪当前所处的模式,更重要的是,它可以让我们知道用户何时改变了模式。这就引出了下一个问题…
启动 - Initialization
问题
在上一节,我们实现了开关PID 的功能。当我们关掉它后,来看看重新打开时会发生什么:
哎呀!PID 会跳回到关闭时的输出值,然后从那里开始调整。这将导致我们不希望出现的波动。
解决方案
这个问题很容易解决。既然现在我们知道PID 是什么时候启动的,我们可以直接把PID 的状态初始化到某个值,也就是调整那两个存储下来的变量(积分项ITerm
和上一次输入lastInput
),从而实现平滑的过度。
代码
/*working variables*/
unsigned long lastTime;
double Input, Output, Setpoint;
double ITerm, lastInput;
double kp, ki, kd;
int SampleTime = 1000; //1 sec
double outMin, outMax;
bool inAuto = false;
#define MANUAL 0
#define AUTOMATIC 1
void Compute()
{
if(!inAuto) return;
unsigned long now = millis();
int timeChange = (now - lastTime);
if(timeChange>=SampleTime)
{
/*Compute all the working error variables*/
double error = Setpoint - Input;
ITerm+= (ki * error);
if(ITerm> outMax) ITerm= outMax;
else if(ITerm< outMin) ITerm= outMin;
double dInput = (Input - lastInput);
/*Compute PID Output*/
Output = kp * error + ITerm- kd * dInput;
if(Output> outMax) Output = outMax;
else if(Output < outMin) Output = outMin;
/*Remember some variables for next time*/
lastInput = Input;
lastTime = now;
}
}
void SetTunings(double Kp, double Ki, double Kd)
{
double SampleTimeInSec = ((double)SampleTime)/1000;
kp = Kp;
ki = Ki * SampleTimeInSec;
kd = Kd / SampleTimeInSec;
}
void SetSampleTime(int NewSampleTime)
{
if (NewSampleTime > 0)
{
double ratio = (double)NewSampleTime
/ (double)SampleTime;
ki *= ratio;
kd /= ratio;
SampleTime = (unsigned long)NewSampleTime;
}
}
void SetOutputLimits(double Min, double Max)
{
if(Min > Max) return;
outMin = Min;
outMax = Max;
if(Output > outMax) Output = outMax;
else if(Output < outMin) Output = outMin;
if(ITerm> outMax) ITerm= outMax;
else if(ITerm< outMin) ITerm= outMin;
}
void SetMode(int Mode)
{
bool newAuto = (Mode == AUTOMATIC); // ++++++++++++++++++
if(newAuto && !inAuto) // ++++++++++++++++++
{ /*we just went from manual to auto*/ // ++++++++++++++++++
Initialize(); // ++++++++++++++++++
} // ++++++++++++++++++
inAuto = newAuto; // ++++++++++++++++++
}
void Initialize() // ++++++++++++++++++
{ // ++++++++++++++++++
lastInput = Input; // ++++++++++++++++++
ITerm = Output; // ++++++++++++++++++
if(ITerm> outMax) ITerm= outMax; // ++++++++++++++++++
else if(ITerm< outMin) ITerm= outMin; // ++++++++++++++++++
} // ++++++++++++++++++
我们修改了 SetMode
函数,用来检测从手动到自动模式的转换。还添加了初始化函数,它设置 ITerm=Output
来处理积分项,并设置 lastInput = Input
来防止微分项出现尖峰。比例项不依赖于过去的任何信息,因此不需要任何初始化。
结果
从上图中我们可以看出,正确的初始化可以实现从手动到自动的顺滑转换,这正是我们所追求的。
为什么不把积分项初始化为0
最近我收到很多问题,问我为什么不在初始化时设置 ITerm=0
。作为回答,我想请你考虑以下情况: PID 处于手动状态,用户将输出设置为 50。一段时间后,系统稳定下来,Input 为 75.2。用户将调定点设为 75.2,并打开 PID,此时会发生什么情况?
我认为在切换到自动状态后,输出值应保持在 50。但由于由于 P 和 D 项为零,只有将积分项初始化为输出值时才能实现这种效果。如果需要将输出初始化为零,则无需修改上述代码。只需在将 PID 从手动转为自动之前,设置 Output=0。
注:意思是,因为初始化时把
lastInput
设为了当前Input,所以微分项为零,又因为调定点等于Input,所以比例项也是零。如果再把积分归零,那么转到自动的瞬间,PID 输出会从50 跳到0,
方向 - DIrection
问题
PID 连接的被控系统分为两类:同向动作和反向动作。到目前为止,我所展示的所有示例都假设是同向动作。也就是说,PID 输出的增加会导致输入的增加。反向作用过程则相反。例如,在冰箱中,制冷量增加会导致温度下降。为了PID 代码能够控制反向系统,Kp、Ki 和 Kd 的符号都必须为负。
这本身不是个问题,但用户必须选择正确的符号,并确保所有参数的符号相同。
解决方案
为了让代码简单,我要求P,I,D 三个参数都大于0。如果被控系统是反向动作的,用户可以单独用SetControllerDirection
函数设置。这保证了所有参数都有相同的符号,并且或许能让程序看起来更符合直觉。
代码
/*working variables*/
unsigned long lastTime;
double Input, Output, Setpoint;
double ITerm, lastInput;
double kp, ki, kd;
int SampleTime = 1000; //1 sec
double outMin, outMax;
bool inAuto = false;
#define MANUAL 0
#define AUTOMATIC 1
#define DIRECT 0 // +++++++++++++++++++
#define REVERSE 1 // +++++++++++++++++++
int controllerDirection = DIRECT; // +++++++++++++++++++
void Compute()
{
if(!inAuto) return;
unsigned long now = millis();
int timeChange = (now - lastTime);
if(timeChange>=SampleTime)
{
/*Compute all the working error variables*/
double error = Setpoint - Input;
ITerm+= (ki * error);
if(ITerm > outMax) ITerm= outMax;
else if(ITerm < outMin) ITerm= outMin;
double dInput = (Input - lastInput);
/*Compute PID Output*/
Output = kp * error + ITerm- kd * dInput;
if(Output > outMax) Output = outMax;
else if(Output < outMin) Output = outMin;
/*Remember some variables for next time*/
lastInput = Input;
lastTime = now;
}
}
void SetTunings(double Kp, double Ki, double Kd)
{
if (Kp<0 || Ki<0|| Kd<0) return; // +++++++++++++++++++
double SampleTimeInSec = ((double)SampleTime)/1000;
kp = Kp;
ki = Ki * SampleTimeInSec;
kd = Kd / SampleTimeInSec;
if(controllerDirection ==REVERSE) // +++++++++++++++++++
{ // +++++++++++++++++++
kp = (0 - kp); // +++++++++++++++++++
ki = (0 - ki); // +++++++++++++++++++
kd = (0 - kd); // +++++++++++++++++++
} // +++++++++++++++++++
}
void SetSampleTime(int NewSampleTime)
{
if (NewSampleTime > 0)
{
double ratio = (double)NewSampleTime
/ (double)SampleTime;
ki *= ratio;
kd /= ratio;
SampleTime = (unsigned long)NewSampleTime;
}
}
void SetOutputLimits(double Min, double Max)
{
if(Min > Max) return;
outMin = Min;
outMax = Max;
if(Output > outMax) Output = outMax;
else if(Output < outMin) Output = outMin;
if(ITerm > outMax) ITerm= outMax;
else if(ITerm < outMin) ITerm= outMin;
}
void SetMode(int Mode)
{
bool newAuto = (Mode == AUTOMATIC);
if(newAuto == !inAuto)
{ /*we just went from manual to auto*/
Initialize();
}
inAuto = newAuto;
}
void Initialize()
{
lastInput = Input;
ITerm = Output;
if(ITerm > outMax) ITerm= outMax;
else if(ITerm < outMin) ITerm= outMin;
}
void SetControllerDirection(int Direction) // +++++++++++++++++++
{ // +++++++++++++++++++
controllerDirection = Direction; // +++++++++++++++++++
} // +++++++++++++++++++
PID 完成
至此,一切都结束了。我们已经将 "初级 PID "变成了目前我所知道的最稳健的控制器。对于那些正在寻找 PID 库详细解释的读者,我希望你们能如愿以偿。对于那些正在编写自己的 PID 的读者,我希望您能从中获得一些思路,从而在今后的工作中节省一些时间。
最后两点说明:
- 如果本系列中有些地方看起来不对,请告诉我。我可能遗漏了什么,或者只是需要更清楚地解释。无论如何,我都想知道。
- 这只是一个基本的 PID。为了简单起见,我还故意忽略了许多其他问题。我想到的有:前馈、tieback、整数数学、不同的 PID 形式、使用增量式而不是位置式。如果有兴趣让我探讨这些问题,请告诉我。
评论摘录
【wwwoholic】:
“直接把输出取反不是更方便吗?”
“从数学上讲,取反所有PID 参数与取反输出完全相同:
Output = - (Kp * Err + Ki * errSum - kd * dInput)
当然,除非你在写 kp = (0 - kp) 时有其他意思。不过,既然你没有改变输出限制,如果直接取反输出,输出限值就失效了,那么我猜你是假定输入、输出和调定点始终为正或零。
在这种情况下,改变方向的方法通常是
Output = outMax - Output
而不是改变系数的符号。”
【作者回复】:
“我现在明白你的意思了。这在数学上肯定是等价的。我想我的风格是让Compute
函数尽可能简洁,并将方向代码分隔开来。当然,你也可以在Compute
函数中加入 if 结构,以决定是否需要乘以-1。只要你觉得合适就行。
这个系列的最终目标是让人们熟悉库的内部原理,以至于他们可以说:“好吧,我打算这样做”。看起来我成功了! 😉”
测量端比例 - Proportional On Measurement
介绍
好久不见,我终于更新了 Arduino PID 库。我添加的是一个几乎无人知晓的功能,但我认为这将是业余爱好者的福音。它被称为 “测量端比例”(简称 PonM)。
为什么你应该了解
有些系统被称为 “积分系统”。这些系统由 PID 的输出控制系统输出的变化率。在工业领域,这类系统只占所有系统的一小部分,但在业余爱好领域,这类系统却随处可见,比如3D 打印机挤出机的温度。
这些系统令人沮丧的地方在于,使用传统的 PI 或 PID 控制时,它们会超调,而且不是有时会超调,而是始终:
如果你不了解这一点,就会感到很头疼。你可以一直调整参数直到永远,但过冲现象依然存在,这是由底层数学原理造成的。“测量端比例”从底层改变了控制规律,可以找到不会出现过冲的参数集:
超调仍会发生,但不是不可避免。只要有PonM 和正确的调参,系统可以被正好控制在调定点上。
注:这应该说的是加热器之类滞后比较大的系统,PID 产生输出,系统要积累能量,逐渐往上跟。超调可能和系统自身的惯性以及积分饱和有关,只留下比例环节,比例系数很大,随着误差缩小而逐渐降低比例系数,这也是一种方法。在靠近调定点时再启动积分,用来克服稳态误差。
那么森么是测量端比例
与测量端微分类似,PonM 改变了比例项,用Input 和比例项运算,而不是误差。
误差端比例:
测量端比例:
与 "测量端微分 "不同,"测量端比例 "对性能的影响非常大。改用测量端微分,微分项仍然在做同样的工作:抵抗突然的变化,从而给P 和I 驱动的震荡增加阻尼。而"测量端比例 "从根本上改变了比例项的作用。它不再是 I 这样的驱动力,而变成了 D 这样的阻力。这意味着,有了PonM,更大的比例系数Kp 将使控制器更保守。
很好,但它是如何消除超调的?
要了解问题所在并加以解决,最好先看看三个环节分别对整个 PID 输出的贡献。下面是一个使用传统 PID 的控制积分系统的例子,查看调定点变化时的响应:
有两件事值得注意:
- 当我们处于调定点时,积分项是整个输出的唯一贡献者。
- 即使开始和结束时的设定点不同,输出也会返回到相同的值。这个值通常被称为 “平衡点”:不会导致Input 变化的值。对于加热器来说,这个点刚好有足够的加热功率来补偿散失到周围环境中的热量。
在这里,我们可以看到为什么会一直发生过冲。当设定点首次发生变化时,存在的误差会导致积分项增长。为了使被控系统稳定在新的设定点,输出需要回到平衡点。要做到这一点,必须让积分项缩小。唯一的办法就是出现负误差,而这只有在高于设定点时才会发生。
注:就是说,对于积分系统,系统的输出是输入累计的结果。比如加热器,PID 总要在温度上升时增大功率,在维持温度时减少功率到平衡点,所以和积分饱和类似,积分项会让功率下降滞后,维持一段时间的超调。最简单的方法可能是干脆去掉积分项,改成PD 控制,被控系统本身的积分作用够强了,对微分冲击不敏感,反而能提高快速性。
PonM 改变了规律
下面是同样的系统用PonM 控制的效果:
在这里你应该注意到:
- P 项现在提供的是一个阻力。输入越高,负值越大。
- 以前,P 项在到达调定点时为零,现在它继续保持一个负值。
关键在于比例项没有回到 0。这意味着积分项不需要自己返回平衡点。P 和 I 可以一起将输出返回到平衡点,而不需要 I 项缩小。因为不需要收缩,所以也就不需要超调。
注:也能看到,Input 上升的速度明显变慢了。因为这时候,PID 完全是靠积分项推动的,I 和D 都起反作用。
注:比例反馈 / 测量点比例
还是再画一下改造后的系统结构图,假设Input 初值是0。
所有参数设为1,开环传递函数变成了:
G k = G s ( 1 + G + G s ) G_k = \frac{G}{s (1 + G + Gs)} Gk=s(1+G+Gs)G
好嘛,一个零点都没了。不太感兴趣,还有一节讲代码实现的,省略,想看的自己去:Proportional on Measurement – The Code
PID 自动调参
终于,我发布了一个自动调参库来补充 Arduino PID 库。当我发布当前版本的 PID 库时,我写了一系列内容丰富的文章,让人们熟悉库的内部设计。虽然没有那么深入,但这也是本篇文章的目标。我将解释 Autotune 库要实现的目标,以及它是如何运行的。
起因
几年以来,我一直想做一个自动调参库,但由于和我的雇主的协议,我无法编写。不过,当我发现William Spinelli 的 AutotunerPID ToolKit 时,我觉得我可以开始了;我的公司对我移植和扩充现有的开源项目没有意见。
我将源代码从matlab 转换过来,对峰值识别代码做了一些调整,并将其从标准形式(Kc、Ti、Td)转换为理想形式(Kp、Ki、Kd)。除了这些,其他都是Spinelli 先生的创作。
理论
PID 控制器的最佳参数(Kp、Ki、Kd)取决于具体的被控设备。烤面包机的最佳参数与蒸煮锅的最佳参数是不同的。
自动调参器试图测试出被控系统的性质,然后据此反推控制器参数。有多种方法可以做到这一点,但大多数都是先以某种方式改变 PID 输出,然后观察Input 的变化,也就是被控系统的响应。库中使用的方法被称为继电器法,其工作原理如下:
从稳定状态开始(输入和输出均为稳定状态),输出向一个方向阶跃,阶跃距离为 D。当输入越过触发线时,输出向另一个方向变化,变化距离为 D。
通过分析输入的峰值之间的距离以及峰值与输出变化之间的关系,自动调参器可以区分出被控系统的类别。因此,不同的系统将获得定制的参数:
实现
这个方法理论上可以工作,但真实世界的数据并不是那么配合;输入信号总是充满噪声,这导致了两个主要问题:
问题1、什么时候阶跃
由于噪声信号是不稳定的,因此当输入信号经过触发线时,触发线很可能会被多次穿越。这可能会导致输出抖动,严重时甚至会损坏设备:
我选择的解决方法是让用户指定一个噪声带。这会产生上下两条触发线。由于它们之间的距离等于噪声幅度(如果设置得当),因此由于信号抖动而发生多次交叉的可能性较小。
注:就是类似比较器的迟滞特性,用回差避免噪声干扰。但噪声带选的较宽时,信号穿越噪声带的时间和穿越实际触发线的时间差较大,可能导致最后计算不精确。
问题2、峰值检测
在仿真的世界,识别峰值是简单的:只要Input 信号的增减方向改变,就代表一个峰值点。然而在充满噪声的真实世界,这个方法不可用:
每一个噪声点都是一个方向变化。为了解决这个问题,我添加了一个 "回视时间 "参数。这个名字很糟糕。如果你能想到更好的名字,请告诉我。
用户可以任意定义一个时间窗口,比如 10 秒。然后,自动调参库会将当前点与过去 10 秒的数据进行比较。如果它是最小值或最大值,就会被标记为可能的峰值。
当新的疑似尖峰点的类别发生转变,前一个标记点就被确认是尖峰点,比如,若之前一串疑似点都是最大值点,表示Input 信号正在上升,新的标记点突然变成了最小值点,表示进入曲线下降沿,那么前一个标记点就是正尖峰点,反之,则是负尖峰点。
注:所以就需要缓存最近的十个采样点,以及之前的若干个标记点。还可以在标记点上再做滤波处理,正方向标记点必须持续出现多少次才会被识别为上升沿,否则,即使标记点发生转变,也会被过滤掉。
你还要知道……
- 施加的循环次数会在 3 到 10 次之间。算法会等到最后 3 个最大值相差在 5%以内。这样做是为了确保我们已经达到了一个稳定的振荡状态,并且没有外部异常情况发生。这让我想到…
- 我不是自动调参的忠实粉丝。我经常说,而且现在仍然相信,一个训练有素的人每天都能打败自动调参器。在算法对实情了解不足的情况下,可能出错的地方实在太多了。尽管如此,自动调参仍是帮助新手进入正轨的重要工具。