节选自本人博客:https://www.blog.zeeland.cn/archives/pid-learning
本文为笔者参考了网上众多大神的解析之后加上自己的理解整合起来的,因此在内容上部分参考了其他作者,目的仅用作参考以便更好地学习,如有侵犯,可联系笔者修改。
Introduction
本文先从介绍PID的概念开始,由Kp到Ki再到Kd逐步深入讲解,并展现出了从Kp到pid三个变量组合发展过程和Python代码实现,适合新手入门。
应用PID控制的前提是系统一定要是一个闭环系统,什么是闭环系统?就是一定要有反馈回路,要能及时反馈我们最终控制的那个量的状态,给到控制器。也就是说,PID控制是根据被控系统的状态来进行控制的,我们需要知道这个状态才能决定控制器下一步应该怎么做。
总的来说,PID控制的用途分为两种,
- 一种是使某个物理量“保持稳定",即便出现外界干扰也能很快回到原始的稳定状态;
- 另一种是使物理量稳定地“跟踪”给定的信号,稳定地随着给定信号变化。
PID公式如下所示:
具体地,被控系统输出c(t)与给定量r(t)进行比较,得到偏差e(t),控制器对偏差值进行比例P、积分I、微分D三种运算合成,得到对应的控制器输出u(t),反馈给被控系统,进一步调节作动器的行为(例如阀门开度、电机转速、力矩输出等等)从而使偏差趋近于零,进而使被控对象的行为趋近于给定的指令信号。
- 比例环节P可以提高系统响应的快速性,但单独使用比例环节并不能使系统性能稳定在一个理想的状态,当有余差出现,较大的比例系数会引起较大的控制器输出,导致超调过大,系统产生振荡,使系统稳定性变差;
- 积分调节I可以在比例调节的基础上减小余差,提升系统的稳态性能;
- 微分环节D属于超前调节,可以提升系统的动态性能,使系统超调量减小、稳定性增加。
PID中三个参数,大体对应于控制系统的三个最重要的方面:
P对应“稳”,即稳定性,放大控制作用;I对应“准”,消除稳态误差;D对应“快”,对误差进行预判、做出快速反应。
Example
接下来我们从一些生活中的例子开始逐步地讲解PID三者。
比例控制算法
我们先说PID中最简单的比例控制,抛开其他两个不谈,还是用一个经典的例子吧。假设我有一个水缸,最终的控制目的是要保证水缸里的水位永远的维持在1米的高度。假设初始时刻,水缸里的水位是0.2米,那么当前时刻的水位和目标水位之间是存在一个误差的error,且error为0.8.这个时候,假设旁边站着一个人,这个人通过往缸里加水的方式来控制水位。如果单纯的用比例控制算法(kp),就是指加入的水量u和误差error是成正比的。即u = kp * error
假设现在kp = 0.5
,则有:
t=1时(表示第1次加水,也就是第一次对系统施加控制),那么u=0.50.8=0.4,所以这一次加入的水量会使水位在0.2的基础上上升0.4,达到0.6.
t=2时刻(第2次施加控制),当前水位是0.6,所以error是0.4。u=0.50.4=0.2,会使水位再次上升0.2,达到0.8。
t=3…
…
t=n…
可以看到,最终水位会达到我们需要的1米。
用Python实现这一过程,可以看到结果如下所示:
import matplotlib.pyplot as pltclass ApplicationWithKp:def __init__(self, ex=1, current=0.2, kp=0.5):self.ex = exself.current = currentself.kp = kpself.error = self.ex - self.currentself.iterate_times = 0self.output_list = [self.current]self.error_list = [self.error]def run(self):self.iterate(100)self.plot()print(self.output_list)print("[info] iterate times: ", self.iterate_times)def iterate(self, epoch):""" iterate to update current, error """for i in range(epoch):# 加水量uu = self.kp * self.errorself.current += uself.output_list.append(self.current)self.error_list.append(self.error)self.error = self.ex - self.currentif self.current >= self.ex:self.iterate_times = ireturnself.iterate_times = epochdef plot(self):l1, = plt.plot(list(range(len(self.output_list))), self.output_list, label='output')l2, = plt.plot(list(range(len(self.error_list))), self.error_list, label='error', linestyle='--', color='r')l3 = plt.plot(list(range(len(self.output_list))), [1] * len(self.output_list), linestyle='--', color='g' )plt.xlabel('times / s')plt.ylabel('water volume / (m^3)')plt.legend(handles=[l1, l2],labels = ['output', 'error'])plt.show()app = ApplicationWithKp()
app.run()
可以看到,当kp=0.5
时,需要迭代53次才能形成稳态,接下来我们把kp设为0.9,看看要迭代多少次。
app_2 = ApplicationWithKp(kp=0.9)
app_2.run()
可以看到,kp=0.9
时迭代16次就收敛了,可以看出kp起着一个放大控制作用。
像上述的例子,根据kp取值不同,系统最后都会达到1米,只不过kp大了到达的快,kp小了到达的慢一些。不会有稳态误差。但是,考虑另外一种情况,假设这个水缸在加水的过程中,存在漏水的情况,假设每次加水的过程,都会漏掉0.1米高度的水。仍然假设kp取0.5,那么会存在着某种情况,假设经过几次加水,水缸中的水位到0.8时,水位将不会再变换。因为水位为0.8,则误差error=0.2. 所以每次往水缸中加水的量为u=0.5*0.2=0.1.同时,每次加水,缸里又会流出去0.1米的水。加入的水和流出的水相抵消,水位将不再变化。
我们仅需要把刚才iterate
函数中current += u
的代码改成current += u - 0.1
,就能得到以下结果。
也就是说,我的目标是1米,但是最后系统达到0.8米的水位就不再变化了,且系统已经达到稳定。由此产生的误差就是稳态误差了。
在实际情况中,这种类似水缸漏水的情况往往更加常见,比如控制汽车运动,摩擦阻力就相当于是“漏水”,控制机械臂、无人机的飞行,各类阻力和消耗都可以理解为本例中的“漏水”。所以,单独的比例控制,在很多时候并不能满足要求。
积分控制算法
通过上面的例子我们可以发现,**如果仅仅使用比例控制算法,就会存在稳态误差的问题。**因此,当前我们再引入一个分量,该分量与误差的积分是正比关系,加入积分控制,公式变为:u = kp * error + ki * ∫error
还是用上面的例子来说明,第一次的误差error是0.8,第二次的误差是0.4,至此,误差的积分(离散情况下积分其实就是做累加),∫error=0.8+0.4=1.2. 这个时候的控制量,除了比例的那一部分,还有一部分就是一个系数ki乘以这个积分项。由于这个积分项会将前面若干次的误差进行累计,所以可以很好的消除稳态误差(假设在仅有比例项的情况下,系统卡在稳态误差了,即上例中的0.8,由于加入了积分项的存在,会让输入增大,从而使得水缸的水位可以大于0.8,渐渐到达目标的1.0.)这就是积分项的作用。
KI的Python实现,我们只需要在刚才ApplicationWithKp
类的基础上稍作修改,就可以得到ApplicationWithKpKi
的实现,代码如下:
import matplotlib.pyplot as pltclass ApplicationWithKpKi:def __init__(self, ex=1, current=0.2, kp=0.5, ki=0.05):self.ex = exself.current = currentself.kp = kpself.ki = kiself.error = self.ex - self.currentself.error_acc = self.error # error accumulationself.iterate_times = 0self.output_list = [self.current]self.error_list = [self.error]def run(self):self.iterate(50)self.plot()print(self.output_list)print("[info] iterate times: ", self.iterate_times)def iterate(self, epoch):""" iterate to update current, error """for i in range(epoch):# 加水量uu = self.kp * self.error + self.ki * self.error_accself.current += uself.output_list.append(self.current)self.error_list.append(self.error)self.error = self.ex - self.currentself.error_acc += self.error# if self.current >= self.ex:# self.iterate_times = i# returnself.iterate_times = epochdef plot(self):l1, = plt.plot(list(range(len(self.output_list))), self.output_list, label='output')l2, = plt.plot(list(range(len(self.error_list))), self.error_list, label='error', linestyle='--', color='r')l3 = plt.plot(list(range(len(self.output_list))), [1] * len(self.output_list), linestyle='--', color='g' )plt.xlabel('times / s')plt.ylabel('water volume / (m^3)')plt.legend(handles=[l1, l2],labels = ['output', 'error'])plt.show()if __name__ == '__main__':app = ApplicationWithKpKi(kp=0.5, ki=0.05)app.run()
运行结果如下所示:
我们可以看到,事实上,因为error
的积累导致的超调,即在装水的过程中装了比1L还多的水!当然,在当前这个例子中,水桶中的水大于1L的情况是不可能存在的,但是在其他场景下允许该中情况的出现,因此,关于Ki我们就需要调整到一个合适的值。
微分控制算法
换一个另外的例子,考虑刹车情况。平稳的驾驶车辆,当发现前面有红灯时,为了使得行车平稳,基本上提前几十米就放松油门并踩刹车了。当车辆离停车线非常近的时候,则使劲踩刹车,使车辆停下来。整个过程可以看做一个加入微分的控制策略。
微分,说白了在离散情况下,就是error的差值,就是t时刻和t-1时刻error的差,即u=kd*(error(t)-error(t-1)),其中的kd是一个系数项。可以看到,在刹车过程中,因为error是越来越小的,所以这个微分控制项一定是负数,在控制中加入一个负数项,他存在的作用就是为了防止汽车由于刹车不及时而闯过了线。从常识上可以理解,越是靠近停车线,越是应该注意踩刹车,不能让车过线,所以这个微分项的作用,就可以理解为刹车,当车离停车线很近并且车速还很快时,这个微分项的绝对值(实际上是一个负数)就会很大,从而表示应该用力踩刹车才能让车停下来。
切换到上面给水缸加水的例子,就是当发现水缸里的水快要接近1的时候,加入微分项,可以防止给水缸里的水加到超过1米的高度,说白了就是减少控制过程中的震荡。
有前面两次的代码迭代,我们最后要实现kd的累加也很简单,只需要再之前的代码的基础上记录下两次error的差值就可以了,即记录下e(t) - e(t-1)
import matplotlib.pyplot as pltclass ApplicationWithKpKiKd:def __init__(self, ex=1, current=0.2, kp=0.5, ki=0.05, kd=0.1):self.ex = exself.current = currentself.kp = kpself.ki = kiself.kd = kdself.error = self.ex - self.currentself.error_acc = self.error # error accumulationself.delta_error = self.error # d_e = e(t) - e(t-1)self.iterate_times = 0self.output_list = [self.current]self.error_list = [self.error]def run(self):self.iterate(50)self.plot()print(self.output_list)print("[info] iterate times: ", self.iterate_times)def iterate(self, epoch):""" iterate to update current, error """for i in range(epoch):# 加水量uu = self.kp * self.error + self.ki * self.error_acc + self.kd * self.delta_errorself.current += uself.error = self.ex - self.currentself.error_acc += self.errorself.delta_error = self.error - self.error_list[-1]self.output_list.append(self.current)self.error_list.append(self.error)self.iterate_times = epochdef plot(self):l1, = plt.plot(list(range(len(self.output_list))), self.output_list, label='output')l2, = plt.plot(list(range(len(self.error_list))), self.error_list, label='error', linestyle='--', color='r')l3 = plt.plot(list(range(len(self.output_list))), [1] * len(self.output_list), linestyle='--', color='g' )plt.xlabel('times / s')plt.ylabel('water volume / (m^3)')plt.legend(handles=[l1, l2],labels = ['output', 'error'])plt.show()if __name__ == '__main__':app = ApplicationWithKpKiKd(kp=0.5, ki=0.1, kd=0.3)app.run()
我们可以看到,相对于只有kp和ki,加了kd之后收敛速度会更快一些,但事实上,如果kd调节地过大,一些噪音信号会被严重放大造成震荡,因此选择合适的PID参数特别重要!下图为kd过大的结果。
在真正的工程实践中,最难的是如果确定三个项的系数,这就需要大量的实验以及经验来决定了。通过不断的尝试和正确的思考,就能选取合适的系数,实现优良的控制器。
快速上手使用PID
事实上,如果你想要Python快速上手PID,你完全没必要自己写一个PID实现,因为已经有人把PID给封装好了,你只需要传入一些简单的参数,就能很好的实现PID!这个库就是simple-pid,你可以使用pip install simple-pid
来安装该库,现在我们借由simple-pid中的example来快速上手一次。
import os
import sys
import time
import matplotlib.pyplot as plt
from simple_pid import PIDclass WaterBoiler:"""本示例模拟了一个锅炉烧水的过程,而随着时间的推移,热量会慢慢消散过程描述:1. (0,1)秒的时候,因为目标水温setpoint=当前水温,因此表示未开始加热,水温不会发生改变。2. (1,10)秒的时候将目标水温设置为100度,pid开始更新当前温度以达到目标水温"""def __init__(self):self.water_temp = 20def update(self, boiler_power, dt):"""随着时间的变化更新温度,即dt时间内锅炉收到boiler_power的热量后的温度变化Attention:1. 需要注意的是,这里也考虑了一些热量耗散的因素2. 在本示例中,boiler_power对温度的影响需要成一个系数k,即传入a的能量会升高a*k*dt的温度,在这里k=1"""if boiler_power > 0:# 锅炉只能产生热,不能变冷self.water_temp += 1 * boiler_power * dt# 一些热量耗散因素(即上面例子中的边加水边排水)self.water_temp -= 0.02 * dtreturn self.water_tempif __name__ == '__main__':boiler = WaterBoiler()water_temp = boiler.water_temp# 设置pid,setpoint为目标值,即希望水温调整到多少度# 初始化的时候,先让目标水温=当前水温pid = PID(5, 0.01, 0.1, setpoint=water_temp)# 设置该参数的边界范围,在这个demo中,标准大气压下水的温度为[0, 100]pid.output_limits = (0, 100)start_time = time.time()last_time = start_time# 记录温度(y)和时间(x)变化setpoint, y, x = [], [], []# 记录10秒内的温度变化while time.time() - start_time < 10:current_time = time.time()dt = current_time - last_time# power为pid预测的dt时间内的改变量power = pid(water_temp)# 更新水温water_temp = boiler.update(power, dt)x += [current_time - start_time]y += [water_temp]setpoint += [pid.setpoint]# 一秒后将目标水温修改为100度if current_time - start_time > 1:pid.setpoint = 100last_time = current_timeplt.plot(x, y, label='measured')plt.plot(x, setpoint, label='target')plt.xlabel('time')plt.ylabel('temperature')plt.legend()if os.getenv('NO_DISPLAY'):# If run in CI the plot is saved to file instead of shown to the userplt.savefig(f"result-py{'.'.join([str(x) for x in sys.version_info[:2]])}.png")else:plt.show()
接着,你就会得到一条优美的弧线。
如果想要进一步使用
simple-pid
,推荐阅读阅读源码的注释。
这里我想说一下,为什么和simple-pid
相比,笔者上面绘制的曲线更折更生硬一点,主要的原因是迭代次数的问题,笔者在迭代的时候只迭代了几十次,但是在一些实际问题上,PID算法需要基于时间去做积分,而不仅仅只是做离散的迭代,因此你在simple-pid
的example中也可以看到,他的迭代是通过time
的积分微分来构建的,但事实上,在代码时间中dt再小你也可以认为是一个离散的值。
在先前本人的例子中,迭代了50次可以理解为在50秒内一秒更新了一次,而在simple-pid
的示例中,更新的频率变得更快了,因为dt是一个很小的值,所以step(步幅)变小了,即水温在一秒内更新了多次。
最后附上一些关于PID的其他观点:
如果一个系统的机理模型建立的比较准确,PID控制也是有严格的数学依据的,可以通过严格的传递函数的闭环零极点分析得到;
微分的微分为什么一般不用呢?因为微分能够严重放大噪声信号,而实际测得的输出量肯定含有噪声,因此高次微分很难用;
一般由于PID控制只关心输入输出,而一般不关心系统的内部特性,所以对于建模不准确的系统PID是最理想的控制器之一,这也导致了PID控制器的参数一般采用经验法来整定。
我喜欢这样理解pid: p是控制现在,i是纠正曾经,d是管控未来!只有不忘过往,把握当前,规划未来才能让人生的轨迹按照既定的目标前进。讲真,理解了弹性阻尼系统,对pid的内涵会更加深刻。
那为啥不加入更进一步的,微分的微分或者微分的微分的微分呢。如果有其他因素,该如何使用PID呢 ——@荆慢慢2.0
一般来说微分环节相当于放大了反馈信号中的高频分量,如果取得系数不好会引起高频震荡。所以大部分应用都只采用PI(不影响稳态精度)或者双闭环PI。至于微分的微分等一些量在物理上并没有实际意义,比如调速中转速的微分是加速度,再次微分就基本不用了。当然如果学过自控理论则可以从系统传递函数来分析需要加入什么样的控制器来保持系统稳定收敛。—— @Lyn
References
- PID模拟仿真实验 -知乎
- PID控制算法原理(抛弃公式,从本质上真正理解PID控制) -知乎
- https://github.com/m-lundberg/simple-pid -python -github