本文为笔者参考了网上众多大神的解析之后加上自己的理解整合起来的,因此在内容上部分参考了其他作者,目的仅用作参考以便更好地学习。
Introduction
本文先从介绍 PID 的概念开始,由 Kp 到 Ki 再到 Kd 逐步深入讲解,并展现出了从 Kp 到 pid 三个变量组合发展过程和 Python 代码实现,适合新手入门。
应用 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.5*0.8=0.4,所以这一次加入的水量会使水位在 0.2 的基础上上升 0.4,达到 0.6.
**t=2 时刻**(第 2 次施加控制),当前水位是 0.6,所以 error 是 0.4。u=0.5*0.4=0.2,会使水位再次上升 0.2,达到 0.8。
t=3...
....
t=n...
可以看到,最终水位会达到我们需要的 1 米。
用 Python 实现这一过程,可以看到结果如下所示:
import matplotlib.pyplot as plt
class ApplicationWithKp:
def __init__(self, ex=1, current=0.2, kp=0.5):
self.ex = ex
self.current = current
self.kp = kp
self.error = self.ex - self.current
self.iterate_times = 0
self.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):
# 加水量u
u = self.kp * self.error
self.current += u
self.output_list.append(self.current)
self.error_list.append(self.error)
self.error = self.ex - self.current
if self.current >= self.ex:
self.iterate_times = i
return
self.iterate_times = epoch
def 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.50.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 plt
class ApplicationWithKpKi:
def __init__(self, ex=1, current=0.2, kp=0.5, ki=0.05):
self.ex = ex
self.current = current
self.kp = kp
self.ki = ki
self.error = self.ex - self.current
self.error_acc = self.error # error accumulation
self.iterate_times = 0
self.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):
# 加水量u
u = self.kp * self.error + self.ki * self.error_acc
self.current += u
self.output_list.append(self.current)
self.error_list.append(self.error)
self.error = self.ex - self.current
self.error_acc += self.error
# if self.current >= self.ex:
# self.iterate_times = i
# return
self.iterate_times = epoch
def 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 plt
class ApplicationWithKpKiKd:
def __init__(self, ex=1, current=0.2, kp=0.5, ki=0.05, kd=0.1):
self.ex = ex
self.current = current
self.kp = kp
self.ki = ki
self.kd = kd
self.error = self.ex - self.current
self.error_acc = self.error # error accumulation
self.delta_error = self.error # d_e = e(t) - e(t-1)
self.iterate_times = 0
self.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):
# 加水量u
u = self.kp * self.error + self.ki * self.error_acc + self.kd * self.delta_error
self.current += u
self.error = self.ex - self.current
self.error_acc += self.error
self.delta_error = self.error - self.error_list[-1]
self.output_list.append(self.current)
self.error_list.append(self.error)
self.iterate_times = epoch
def 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 PID
class WaterBoiler:
"""
Simple simulation of a water boiler which can heat up water
and where the heat dissipates slowly over time
"""
def __init__(self):
self.water_temp = 20
def update(self, boiler_power, dt):
if boiler_power > 0:
# Boiler can only produce heat, not cold
self.water_temp += 1 * boiler_power * dt
# Some heat dissipation
self.water_temp -= 0.02 * dt
return self.water_temp
if __name__ == '__main__':
boiler = WaterBoiler()
water_temp = boiler.water_temp
pid = PID(5, 0.01, 0.1, setpoint=water_temp)
pid.output_limits = (0, 100)
start_time = time.time()
last_time = start_time
# Keep track of values for plotting
setpoint, y, x = [], [], []
while time.time() - start_time < 10:
current_time = time.time()
dt = current_time - last_time
power = pid(water_temp)
water_temp = boiler.update(power, dt)
x += [current_time - start_time]
y += [water_temp]
setpoint += [pid.setpoint]
if current_time - start_time > 1:
pid.setpoint = 100
last_time = current_time
plt.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 user
plt.savefig(f"result-py{'.'.join([str(x) for x in sys.version_info[:2]])}.png")
else:
plt.show()
接着,你就会得到一条优美的弧线。
这里我想说一下,为什么和 simple-pid
相比,笔者上面绘制的曲线更折更生硬一点,主要的原因是迭代次数的问题,笔者在迭代的时候只迭代了几十次,但是在一些实际问题上,PID 算法需要基于时间去做积分,而不仅仅只是做离散的迭代,因此你在 simple-pid
的 example 中也可以看到,他的迭代是通过 time
的积分微分来构建的。
最后附上一些关于 PID 的其他观点:
如果一个系统的机理模型建立的比较准确,PID 控制也是有严格的数学依据的,可以通过严格的传递函数的闭环零极点分析得到;
微分的微分为什么一般不用呢?因为微分能够严重放大噪声信号,而实际测得的输出量肯定含有噪声,因此高次微分很难用;
一般由于 PID 控制只关心输入输出,而一般不关心系统的内部特性,所以对于建模不准确的系统 PID 是最理想的控制器之一,这也导致了 PID 控制器的参数一般采用经验法来整定。
我喜欢这样理解 pid: p 是控制现在,i 是纠正曾经,d 是管控未来!只有不忘过往,把握当前,规划未来才能让人生的轨迹按照既定的目标前进。讲真,理解了弹性阻尼系统,对 pid 的内涵会更加深刻。
那为啥不加入更进一步的,微分的微分或者微分的微分的微分呢。如果有其他因素,该如何使用 PID 呢 ——@ 荆慢慢 2.0
一般来说微分环节相当于放大了反馈信号中的高频分量,如果取得系数不好会引起高频震荡。所以大部分应用都只采用 PI(不影响稳态精度)或者双闭环 PI。至于微分的微分等一些量在物理上并没有实际意义,比如调速中转速的微分是加速度,再次微分就基本不用了。当然如果学过自控理论则可以从系统传递函数来分析需要加入什么样的控制器来保持系统稳定收敛。—— @Lyn
6