【搬运】初学者的PID教程 by Brett Beauregard-Arduino中文社区 - Powered by Discuz! Archiver

SeanM 发表于 2021-5-1 14:07

【搬运】初学者的PID教程 by Brett Beauregard

本帖最后由 SeanM 于 2021-5-1 17:05 编辑

在网上看到一个写的很详细的PID教程,一步一步地讲解Arduino PID库的原理,非常棒。帖子的作者是Brett Beauregard,网址是Improving the Beginner’s PID(http://brettbeauregard.com/blog/2011/04/improving-the-beginners-pid-introduction/)。习惯看英文的朋友建议直接去看。


抱着边看边学的想法,我准备把这一系列的帖子翻译过来。一方面是督促自己学习,一方面也是向大家介绍PID详细的知识。

要说明的是原文是2011年4月写的,到现在已经十年时间了,他所分析的arduino PID库应该已经更新,不一定能完全兼容现在的库。



SeanM 发表于 2021-5-1 14:38

第一篇:初学者的PID-简介 by Brett Beauregard

本帖最后由 SeanM 于 2021-5-5 15:26 编辑

为了配合新发布的 Arduino PID Library,我决定来写这个系列的帖子。上一版本的库,虽然很稳定,但是没有代码解释,所以这次我打算巨详细地解释一下代码的原理,希望能帮助下面两类盆友:

[*]希望了解Arduino PID Library本身的朋友可以得到巨详细的解释
[*]希望自己写PID算法的朋友也能有所借鉴

这个主题本身十分硬核,但是我也在尝试用不那么痛苦的方式来解释我的代码,我会从“新手的PID”开始,然后一步一步地把它变成一个高效的,健壮的PID算法。


新手的PID

大家以前学到的PID公式是这样的:

其中e(t)=设定点 - 输入
这个公式让大家可以编写出以下的代码:
(译者注:在实际使用中一般使用PID的离散形式:,后面的代码其实是按这个写的)


/*声名变量*/

unsigned long lastTime;
double Input, Output, SetPoint;
double errSum, lastErr;
double kp, ki, kd;

void Compute()
{
    /*计算上次计算到本次计算间隔的时间*/
    unsigned long now=millis();
    double timeChange=now-lastTime;
   
    /*计算所需要的所有工作变量*/
    double error=SetPoint-Input;
    errSum+=(error*timeChange);
    double dErr=(err-lastErr)/timeChange;
   
    /*计算PID输出*/
    Output=kp * error + ki * errSum + kd * dErr;
   
    /*记录下次要使用的变量*/
    lastErr=error;
    lastTime=now;
}

voidSetTunings(double Kp, double Ki, doubleKd)
{
    kp=Kp;
    ki=Ki;
    kd=Kd;
}


Compute()这个函数可以被定期调用或者随时调用,他都可以正常使用。但是,这个系列的帖子不会仅仅满足于工作得还好。如果我们想要把它改得和工业用的PID控制器一样出色,那么我们应该注意以下几个问题:

[*]采样时间 — 如果PID算法的采样间隔是固定的,那么PID的效果最好。如果算法知道采样间隔,我们可以简化一些数学计算;
[*]微分冲击 — 这个不是什么大问题,很好处理,我们后面再说;
[*]即时调整 — 好的PID算法可以在调整参数时对控制过程不产生太大的影响;
[*]缓解积分饱和 — 我们将要学习什么是积分饱和并且找到一个解决办法,这个解决办法还带了一些额外的好处;
[*]On/Off(自动/手动) — 在大多数的应用中,经常会想要关掉PID控制器,手动控制输出;
[*]初始化 — 在刚打开PID控制器的时候,我们希望是“无扰切换”,即我们不希望输出值忽然变成一个新的值;
[*]控制器的方向 — 这个名词不是“鲁棒性”的另一种说法,它是为了确保用户能输入正确的调优参数而设计的;
[*]New-比例测量 — 加上这个新特性可以使某些过程的控制更容易。


一旦我们解决了这些问题,我们将有一个对PID算法深刻的了解。我们还会拥有最新的Arduino PID控制库。所以不管你是想写出自己的PID算法还是想去了解PID算法里到底发生了什么,我希望这些都能帮上你。现在我们开始旅程吧。

更新:在所有的代码示例中,我都使用double数据类型。在Arduino上double其实和float(单精度)一样。真正的双精度对于PID有点大材小用了,如果你用的语言支持真正的双精度数据类型,建议你把他们都改成float。


SeanM 发表于 2021-5-1 17:16

第二篇 初学者的PID-采样时间

问题:
初学者的PID被设计成可以被不按固定的时间间隔调用,这就导致两个问题:

[*]你不能得到稳定的PID输出,因为它可能有时候调用的很频繁,有时候又不是;
[*]你要在代码中计算积分项和微分项,因为这两项都依赖于采样的时间差;


解决办法:

li0713 发表于 2021-6-17 15:29

mark!
改天实践验证一下

SeanM 发表于 2022-6-20 17:43

本帖最后由 SeanM 于 2022-6-21 17:15 编辑

第二篇 初学者的PID-采样时间(Sample Time)

问题:
初学者的PID被设计成可以被不按固定的时间间隔调用,这就导致两个问题:

[*]你不能得到稳定的PID输出,因为它可能有时候调用的很频繁,有时候又不是;
[*]你要在代码中计算积分项和微分项,因为这两项都依赖于采样的时间差;


解决办法:
保证按固定的时间间隔计算PID。我的方法是按照预先设定的采样时间(Sample Time),在每个周期调用一次PID计算函数,并让PID算法决定是要重算还是立即返回。
一旦我们决定按照固定的时间间隔计算PID,PID微分项和积分项的计算就大大简化了。哈!

代码:
/*working variables*/
unsigned long lastTime;
double Input, Output, Setpoint;
double errSum, lastErr;
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 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;
   }
}在第10行和第11行,算法会自行决定是否需要重算。同时,由于我们已经知道了两次采样的时间间隔,因此在计算积分项和微分项的时候,不用再乘(或除)时间间隔了。是需要相应调整ki和kd就好了,这样在数学上结果是一样的,但是效率更高(31和32行)。
这样做有一点要注意。如果用户在PID运行过程中要想修改采样时间,那么需要同时修改ki和kd(39和40行)

你们可能注意到了,我在第29行把采样时间改成了以秒为单位。严格来讲,可以不用这么做。但是这种方法可以让用户输入ki和kd时以1/秒(1/sec)或秒(s)为单位,而不用以毫秒为单位。

结果:
上面的修改实现了3个改进
1.无论Compute( )调用得有多频繁,PID算法都会按照固定的时间间隔计算(11行)
2.因为使用时间相减,所以及时millis()绕回到0时也不会有问题(这种情况55天会发生一次)
3.我们不需要计算PID时再乘或除时间间隔。因为时间间隔是常数,所以我们可以把它从第15和16行中移除。仅仅需要用他们乘以固定的ki或kd(31和32行)。这样做在数学上没有差异,但是避免了在每一次计算PID时都做乘法和除法计算。

注释:使用中断
在MCU中使用PID时,可以用SetSampleTime来设置中断的频率,然后就可以按照这个频率调用Compute( )。这样的话,就不需要9-12、23和24行代码了。我在这个库里没有这么做,主要原因有三个:
1.并不是所有人都会用中断
2.当在MCU中用多个中断时会有一些麻烦
3.以后的版本用

SeanM 发表于 2022-6-21 11:52

第三篇 初学者的PID-微分冲击(Derivative Kick)

本帖最后由 SeanM 于 2022-6-22 12:44 编辑

问题:
本篇的目的是解决“微分冲击”的问题


上面的图片展示了微分冲击现象。因为error=Setpoint-Input,任何对Setpoint的修改,都会导致error的大幅变化。error的微分理论上会达到无穷大(实际上,因为dt不是0,所以微分只会达到一个很大的值)。这个值代入到pid计算公式中,会导致输出值有一个尖峰。还好,这个问题不难解决。

解决办法:



上面的公式证明,当设定的目标(Setpoint)不变时,误差值(Error)的微分等于输入值的(Input)微分。于是可以得出一个很完美的解决方案:使用(-kd*输入的微分)代替(kd*误差的微分)。这种方法就是使用了“对测量值的微分”(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;
   }
}修改的内容(4、16、19和20行)很容易理解。我们把+dError替换成-dInput。同时,我们可以不用在记录lastError,改为记录lastInput。

结果:


上面就是修改代码后的结果。可以看到,输入没有改变,但是输出已经没有尖峰了。这是一个很大的改进。
如果你的应用对输出的尖峰不敏感,那么这个改进其实可有可无。但是这么做既不麻烦,又能避免冲击,何乐而不为呢?










SeanM 发表于 2022-6-21 12:56

本帖最后由 SeanM 于 2022-6-21 12:57 编辑

第四篇 初学者的PID-改变参数(Tuning Changes)


问题:
在系统运行中改变PID的参数,对于所有的PID算法都是必须考虑的。


如果使用我们目前的算法,在系统运行时改变参数就会造成很大的问题。来看看为什么,下表是使用目前的算法时,参数改变前和改变后的情况对比。


可以很容易看到,由于积分项(Integral Term)的变化引起输出的凸起(bump)。为什么积分参数的变化,会导致输出的大幅变化呢?这是由于目前的算法中,积分项的计算方法带来的问题。


在Ki不变时,这种算法没有什么问题。但是当ki改变时,算法将新的ki与整个error sum相乘。由于error sum时从开始时一直累计下来的,所以造成了积分项的大幅变化。这不是我们想要的。我们想要的时ki的变化仅影响未来的项目。

解决方案:
解决这个问题的办法有很多种。我原先用的办法是按比例调整errSum。当ki翻倍时,将errSum减半。这个办法可以避免积分项凸起(bumping)。这个办法有点笨,后来想想了个更好的办法。
新的办法要有一点代数知识:


我们把Ki挪到积分号里面来。看着好像没有什么变化,用起来你就会看到差别。
我们先用error乘以目前的ki,然后把各个error*ki加在一起。当ki变化时,旧的ki就不再用了,因此积分项就不会变化很大。新旧ki就得以平稳过度。

代码:
/*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;
   }
}
我们把errSum换成ITerm(第4行)。ITerm等于各个 Ki*error的和,而不是error的和再乘以Ki。同时,因为Ki已经包含在ITerm中,因此可以把它从PID计算公式中去除掉(19行)

结果:




来总结一下,问题时怎么解决的。当Ki变化是,它影响了整个误差的合计(sum of the error)。修改代码后,以前的误差合计保持不变,新的Ki仅仅影响以后的误差。

SeanM 发表于 2022-6-21 17:15

第五篇 初学者的PID-积分饱和(Reset Windup)

本帖最后由 SeanM 于 2022-6-21 17:24 编辑

问题


积分饱和是新手最经常遇到的问题。当PID算法不知道自己的上限在哪里的时候,这个问题最容易发生。比如说,arduino的PWM输出在0-255之间,一般来说PID并不知道这个限制。这就会导致PID的输出可能是300、400甚至是500。实际上,PWM被限制到了255以内,PID会不断输出更大的值,但实际上却到不了这个值。
积分饱和问题通常表现为奇怪的控制延迟。从上图可以看出来,PID的输出大于外部的限制。当控制目标(setpoint)被调低后,输出依然会在255的限制线以上,并持续一段时间。

解决方案-Step 1

有很多办法可以解决积分饱和的问题。我选择的解决方案是告诉PID输出的限制值是多少。下面代码中可以看到新加了一个SetOuputLimits()函数.当PID输出达到外部限制时,算法会停止积分。因为这时候再积分也没有用。由于积分不在饱和,当控制目标(setpoint)下降后到可以控制的区间后,PID的输出会立刻做出反映。

解决方案-Step 2
再仔细观察上面的图,可以发现当我们搞定了积分饱和造成的控制延迟后,事情被没有完全和我们预料的一样。由于比例项和微分项的影响,PID实际的输出与它以为的输出还是有差异。
即使积分项已经被控制了,P和D依然会导致结果超过输出上限。对于我来说,这还是不可以接受。因为调用了SetOutputLimits(),就应当保证输出不会超过上限。所以在Step 2,我们应该保证整个输出值(Output value)不高于设定值。
(你可能会想,为什么我们要同时控制输出值和积分项。只控制输出值不就好了吗?我们只控制输出值,那么积分项就会逐渐累计,越来越大。在这个时候,输出看起来没有什么以上,但是一旦调整了控制目标,你就能看到明显的延迟(因为积分项已经很大了,难以马上抵消掉))

代码
/*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;
}
新加一个函数,让用户可以指定输出的限额(52-63行)。这个限额同时限制了积分项(I-Term)(17-18行)和输出值(Output)(23-24行)

结果


结果
正如我们预料的一样,饱和现象被消灭了,输出也保持在我们要求的范围内。这意味着我们没有必要在算法外部另加限制。如果你要把输出限制在23-167的范围内,可以直接设置Output的范围。


译者注:关于积分饱和还可参考Integral (Reset) Windup, Jacketing Logic and the Velocity PI Form(https://controlguru.com/integral-reset-windup-jacketing-logic-and-the-velocity-pi-form/)

SeanM 发表于 2022-6-30 15:08

第六篇 初学者的PID-开和关(On/Off)

本帖最后由 SeanM 于 2022-6-30 15:23 编辑

问题:
有时候我们要使用PID控制器,有时候也要关掉控制器


让我们看一下,强制把PID的输出设置为固定值(例如0)会带来的问题。代码如下
void loop()
{
Compute();
Output=0;
}在这个代码里,如论PID的输出是多少,我们只要重置输出即可。但是,在实践中,这个方法很不好。PID控制器就会很困扰:“我明明不断提高输出,但是好像什么都没有发生,让我再增加输出!”。结果,停止重写输出后,PID控制器要花很长时间来调整输出的值。

解决办法:
解决这个问题的办法是设计一套机制打开和关闭PID。开和关分别对应自动(Auto)和手动(Manual)。下面是代码实现:
/*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);
}【修改了8、10、11、15、71-74行】
办法很简单。如果不在自动模式中时,立即退出Compute()函数,而不调整输出和内部的其他变量。

结果




也可以在手动模式时不调用Compute()函数。但是,通过区分运行模式,并调用Compute()的方法可以保持PID一直在运行中,使得我们能够一直跟踪PID的运行模式,更重要的时,可以进行模式切换。

SeanM 发表于 2022-7-1 12:00

第七篇 初学者的PID-初始化(Initialization)

本帖最后由 SeanM 于 2022-7-1 12:02 编辑

问题:
上一篇我们研究了怎么开关PID。我们已经关掉了PID,现在就来看一下再把它打开会发生什么。

擦!PID的输出跳回了上一次输出的数值,然后从那个数值开始逐步调整。这导致了我们不愿意看到的输入的凸起(bump)。

解决办法:
这个问题很容易解决。当我们重新打开PID的时候(从手动模式变自动模式)时,我们要初始化PID参数,保证模式的平滑切换。也就是熨平Iterm和lastInput两个变量的变化,保证输出不会跳变。

代码如下:
我们修改了SetMode(…)函数,使它可以检测PID从手动到自动的过程,并在切换过程中调用初始化函数。初始化函数令ITerm=Output来处理积分项,令lastInput=Input来保证没有微分冲击。比例项不依赖pid停止前的信息,所以不需要初始化。

[修改了73-78行,81-87行]
/*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;
}
结果:

从上面的图里可以看出,适当的初始化方法可以让手动模式和自动模式平滑切换。这正是我们想要的。
页: [1]
查看完整版本: 【搬运】初学者的PID教程 by Brett Beauregard