一、公式拆解
\(PID\)公式展示:
\[u(t)=K_p(e(t)+\frac{1}{T_t } ∫_0^te(t)dt+T_D \frac {de(t)}{dt}) \]
把\(K_p\)乘进去得:
\[u(t)=K_pe(t)+\frac{K_p}{T_t } ∫_0^te(t)dt+K_pT_D \frac {de(t)}{dt} \]
令 \(K_p\)
令 \(K_i=\frac{K_p}{T_t }\)
令 \(K_d=K_pT_D\)
就变成了这个亚子:
\[u(t)=K_p e(t)+K_i ∫_0^te(t)dt+K_d\frac{de(t)}{dt} \]
对于这个式子,其实涵盖了三种控制算法,每一种都可以单独拿出来。
比例控制算法\(P\):\(u(t)_1=K_p e(t)\)
积分控制算法\(I\):\(u(t)_2=K_i ∫_0^te(t)dt\)
微分控制算法\(D\):\(u(t)_3=K_d\frac{de(t)}{dt}\)
你没有看错!PID算法其实就是三个算法的组合,而且,在数学上面就是简单的代数和! |
二、基于物理进程的解释
A.比例控制算法
在这里面,\(e(t)=目标值-当前值\),自然是离散数据,也就是说\(u(t)_1=K_p e(t)\)这个输出是根据当前值和目标值的差,乘以了一个比例系数得到的输出,
举个例子,假如我们要给一个100ml的A量筒装满水,此时A量筒里面已经有了20ml的水。而我们运水的工具是一个实际容积未知的B杯子。
我们假如B杯子是一个50ml的杯子(我们并不知道是50ml),给其划上100等分的刻度,
那么根据条件可得 \(T_1=e(t)=目标值-当前值=100-20=80\) 。因此现在我们给B杯子装入可达第80刻度线的水并“一滴不漏”倒入A量筒,由于B杯子实际容积为50ml,所以实际倒入A量筒的水体积为40ml。
可以发现,我们要给100ml的量筒装满水,通过量筒的刻度我们轻易能获知还需要倒80ml才能装满杯子,但是B杯子的容积我们并不知道。我们给未知容积的B杯子划上刻线,以获知的80这个数据来给A量筒倒水,最好的情况是我的B杯子容积为100ml,这样的话按照80的刻度,一次性就可以把水加满。由于B杯子容积只有50,所以第一次加水只加了40ml,这便是比例系数的引入。
(这之间读者务必注意你的已知和未知)
从上文举的例子,我们可以明白,我们获知量和得知该量继而调整输出之间是难以对等的,例子中都是容积,实际上做控制时,将会是跨物理单位的转换,比如你获知你的小车速度设定为3m/s,实际只有2m/s,但你不可能直接给小车送定额的速度。你只能去调整等效电压、电流的大小,或者是PWM的占空比以使得速度达到3m/s,速度和电压必然是不对等的,但这之间会存在一个未知的关系,在这里,你可以认为是比例系数。
单纯以容积的刻度作为比照,此处B杯子50ml(我们刻了100等分刻度),所以虽然我们不知道容积,但这个过程,B杯子一个刻度的水量相当于A量筒的刻度的一半,所以实际上 Kp=0.5 |
(务必注意此处的0.5是根据刻度转换得来的,也就是你心中的量度转化为实际输出的关系,我看有些文章以容积为对比,这显然错误)
让强迫症把上面物理进程讲完吧 |
加了40ml水后,A量筒水位达到20+40=60ml的刻度,\(T_2=e(t)=目标值-当前值=100-60=40\)
依照这个40,B杯子加水到40刻度,\(K_p e(t)=0.5\times40=20ml\),倒进A之后水位达到60+20=80ml,如此不断无穷循环下去,A量筒最终会被装满。
(例子举的是B容积小于A量筒的情况,各位看官可以自行假想B容积大于A量筒的情形。我们使用时肯定是会逐步修正这个系数的)
B.积分控制算法
照前面比例控制算法,量筒最终会被装满,那么为何还需要别的算法呢?客官莫急,待老朽与您娓娓道来(娓娓道来:形容谈论不倦,或说话动听,没错,我是后者)
刚才比例控制算法那段内容我给“一滴不漏”特别标成了红色,实际上我们真的能一滴不漏吗?当然不能!
回到之前举的装水问题,现在已经装了\(80ml\),现在我们多加入一个条件,那就是我们每次加水,都会漏掉10ml。
现在可得\(e(t)_2=100-80=20\),那么按照比例控制算法,我们通过B杯子加进去能加入10ml的水,但是现在又会漏掉\(10ml\),\(10ml-10ml=0ml\),哦豁,加完水还是\(80ml\),之后条件不变,意味着\(80ml\)到头了,升不上去了,可是到此为止离我们的目标100ml还差20ml呢!
现实中就是这样,你不可能想给多少那边就接收多少。
于是这里我们加入积分控制算法,
\[u(t)_2=K_i ∫_0^te(t)dt \]
\[u(t)=u(t)_1+u(t)_2=K_p e(t)+K_i ∫_0^te(t)dt \]
可以看到这个式子与误差的积分成正比,在积分系数已经定下时(时间肯定取单位时间),误差的积分越大,这个积分控制算法得到的也越大,先设它积分系数为0.2,接着上面的80ml继续计算,已知比例控制算法的输入和漏掉的相等,那么这是整个\(PI\)算法只需要看积分控制算法的输出就行了,,故积分控制输出4ml,很显然,积分控制的加入打破了之前的稳定状态。积分的来源是误差,误差的累加会增大输入,从而不让系统卡在稳定误差,事实上另一方面积分项也加快了整体控制算法的响应速度(因为\(e(t)\)的符号,让正的值更大,负的更小)。
最后到了目标值后,假如没有波动,回过去看看三种控制算法,貌似无论是比例还是微分控制,输出都是0,只有积分的值是固定输出(积分误差累加),很显然,就是为了消除之前那种稳定状态的静差。(静差:被控量的稳定值和给定值之差,一般用于衡量系统的准确性)静差很难被消除,但是通过积分控制可以尽量去减小,而且积分系数不可太大,太大静差反而更大,有网友给出就平衡车工程经验而言,一般\(K_i=\frac{K_p}{200}\),稍微来点就可以。
C.微分控制算法
这里就是对误差进行微分:
\[\frac{de(t)}{dt}=Error(now)-Error(past) \]
①当 当前值<目标值 时:一般在该调节过程中,误差是越来越小的(正实数运算),这也就可以得
\[Error(now)-Error(past)<0 \]
对于主要的比例控制而言,此时\(e(t)>0\),二者符号相反,换言之,这里微分控制起到了削减比例控制力度的作用
②当 当前值>目标值 时: 输出过大,需要减小,对于比例控制,\(e(t)<0\),反观微分控制:
\[Error(now)<0 \]
\[Error(past)<0 \]
\[|Error(now)|<|Error(past)| \]
\[-Error(now)<-Error(past) \]
\[Error(now)>Error(past) \]
\[Error(now)-Error(past)>0 \]
哦豁,符号又和比例控制相反,太显然了,这明摆着就是和主要的比例控制唱反调啊!太嚣张了!
如果愿意的话,还可以细究一些量的关系,这里就不多说了。
最终结论就是微分起到阻尼作用,减小震荡,提高稳定,减小变化趋势,但是也会降低响应速度。
总结一下,PID三个字母,大致可以说是P主管响应,I减小静差,D抑制震荡,但使用不当还会有反作用。
三、C语言代码框架和解释(单片机可用)
1、定义一个PID结构体代码
//定义一个PID结构体
typedef struct PID
{
double SetValue; //设定的目标值
double Kp; //比例系数
double Kd; //微分系数
double Ki; //积分系数
double Error1; //误差量,即为当前值和目标值的差
double Error2;//也是误差量,但是这里是Error1之后时间的当前值和目标值的差,假设Error1在前
double sum_error; //误差的总和,这个就是误差的积分
}PID;
2、初始化PID结构
//初始化PID结构,这里独立出来比较符合单片机的写法,哈哈,也可以写到主函数里面去
void PIDInit(PID*Stru) //这里就是随便弄了个结构体变量Stru,反正是传递地址,主函数里面随意
{
memset(Stru,0,sizeof(PID)); /*这里把Stru的内存块中全部替换为0,据我的经验,一开始分配好内存后都是些无意义数据或者残留数据,这里memset函数的作用实际上把Stru里面所有字节最后“sizeof(PID)”位的字节全部替换为0,因为这里位数为sizeof(PID),因此是初始化全部字节为0 */
}
//PID的内部运算函数
double PID_OP(PID*Stru , double NewValue) //NewValue是新读取到的值
{
double dError,Error; //dError即为对误差微分,由于我们这里是处理离散数据,所以待会其实就是作差
//误差计算
Error= Stru->SetValue-NewValue; //这里是目标值和当前读取值的偏差,这里吧,无论是当前值在前还是目标值在前都是无所谓的,因为对于整个PID而言,误差的比例算法、积分算法、微分算法都是统一用的。不过话说回来,我不知道使用者是打算用什么负号表示输出,姑且这么用吧,使用者用时根据自己的理解用吧,毕竟咱这里只是个大体框架。
//加入微分算法
dError=Stru->Error2-Stru->Error1; //当前微分计算(离散)注意,这里如果误差越来越小的话无疑这里微分是负值
//加入积分算法
Stru->Sum_Error+=Error; //积分运算
Stru->Error1=Stru->Error2; //新误差值传递给旧误差,如此往复传递,为下个离散时间点PID的计算做准备
Stru->Error2=Error; //这里一开始Error2应该是0,但无所谓,这里计算过程用时是循环,后面会有值堆上去。
return(Stru->Kp*Error+Stru->Ki*Stru->Sum_Error+Stru->Kd*dError); //按颜色不同,分别为比例、积分、微分。但是可以注意的是,这里并不是初始的PID系数。在这个PID框架里面,很明显,是把初始的PID数学式展开了,后面的微积分两个系数都是和比例系数发生了运算的,这不影响。
}
3、输入函数和输出函数
//输入函数和输出函数,叫输入输出系统好些
//这里输入的就是读取编码盘数据,输出就是“控制器的输出”,大致就是改变PWM啥的吧,这个之后再说也行。这里就不能具体写了,是单片机的特色部分,本来我是不打算写上去的,不过参考的罗世洲先生的代码里面提到了这个,鄙人思索之下还是加上去了,毕竟框架嘛,嚯哈哈
void Input() //采集编码盘或别的数据
{
//加油
}
void System_Out(PID_out) //注意这里不是PID输出,而且根据PID结果进而进行相应硬件输出
{
//加油
}
4、定时器初始化函数
//定时器初始化函数
//设置定时中断作为控制周期
void time_interrupt_Init(这里可以放装填初值啥的,随意)
{
这里进行一些启动定时器中断、设置工作方式、装填初值啥的骚操作
}
5、中断函数
//中断函数
void Timer0() interrupt 1 //这里我当做51来做了
{
定时器硬件的装初值或其它操作语句,具体硬件具体分析;
PID_in=Input(); //执行输入函数,读取输入
PID_out=PID_OP(&Squ,PID_in); //运行PID的运算函数,得出PID_out,好用于系统输出函数
System_Out(PID_out); //系统输出
}
6、主函数
//主函数
void main()
{
PID Squ; //Squirtle是杰尼龟的英文名,杰尼龟在咱们国家被亲切叫做憨批龟,开个玩笑,这个结构体变量就用憨批龟吧!
double PID_out,PID_in; //定义PID的数据来源和数据输出
PIDInit(&Squ); //执行PID初始化函数,其实这里就只是全部存成0,记得吧,之前说的那个memset函数。
Squ.Kp=0.5; //设置比例系数,当然三个系数和目标值不一定得就这么赤裸裸写出来,换成读取感觉更灵活,随意
Squ.Ki=0.5; //设置积分系数
Squ.Kd=0.1; //设置微分系数,值得注意的是,物理进程里面这里起到阻尼作用,无论是对动态还是静态的阻尼,这都是一个“往回扳”的作用,变化越快阻尼也越大
Squ.SetValue=100.0; //设置目标值,这里是随便弄的
void time_interrupt_Init(这里可以放装填初值啥的,随意) //初始化定时器
while(1); //程序停在这里等待中断发生
主函数里面还可以放一些存储调试数据的语句,这种语句优先等级低,在不干扰正常操作情况下存储或者发送调试数据,个人认为对调试有一定帮助(强烈推荐eeprom);
}
P.S.:我们在PID控制中不可能处理并且输出连续数据,因此这本文档中都是处理离散数据,而这些离散数据的源头是编码盘或者其它硬件,这也就意味着必须考虑采集数据的装置的采集速度、周期。采集完后又得CPU进行处理,处理又需要时间,电压或者电流的响应也需要时间。为了不让其中发生“碰撞”,这就需要我们去设置一个控制周期,调整好二者,给予二者良好配合的空间。 |
四、PID归纳
写程序不难,难的是调出一个好的参数
在实际使用中,不一定非要把P、I、D三个算法都用上
依据需要进行组合使用吧
各个系数的作用表
性能指标 | 参数 | ||
Kp增大 | Ki减小 | Kd增大 | |
偏差 | 增大 | 增大 | 减小 |
稳态误差 | 减小 | ----- | ----- |
超调量 | 增大 | 增大 | 减小 |
振荡频率 | 增大 | 增大 | 增大 |