PID 算法实现的细节问题处理 - 从新手级别开始逐步完善

翻译自Arduino PID 库作者的系列博客文章:Improving the Beginner’s PID。作者的结论应该经过了不少人的实际检验,而且文章讲得平易近人,不需要太多的理论基础。


开篇 - INtroduction

随着新的Arduino PID 库的发布,我决定发布这一系列文章。上一个库虽然稳定,但并没有附带任何代码解释。这一次,我计划详细解释代码实现为什么写成那样,希望能在某些方面帮到读者:

  1. 直接对Arduino PID 库内部发生的事情感兴趣的人将获得详细的解释;
  2. 想自己编写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 控制器的行列,必须先解决一些问题:

  1. 采样时间:计算周期固定时,PID 算法表现最好。如果算法知道运行周期的具体值,我们还可以简化一些内部的计算;
  2. 微分冲击(Derivative Kick)
  3. 运行时参数变更:一个好的PID 算法应该能容许运行中的参数变化,不会因而导致运行出现颠簸;
  4. 积分饱和
  5. 启动:当控制器启动时,我们希望输出不会发生突然跳跃;
  6. 自动 / 手动模式:大多数应用中,偶尔会需要手动设置控制器的输出,此时PID 控制器不能造成干扰;
  7. 控制器方向
  8. 比例反馈(Proportional on Measurement):这可以让某些类型系统的控制变得容易;

一旦这些问题被解决,我们就实现了一种鲁棒 [doge] 的PID 算法,这也是最新版Arduino PID 库的实现。所以,无论你是想自己实现一套算法,还是想理解PID 库的内部原理,希望这段旅程都能帮到你。

补充:所有的示例代码都使用了double 作为浮点类型。在Arduino 上,doublefloat 是相同的,都是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 实现的设计支持不固定的计算周期,这导致了两个问题:

  1. PID 的行为不是完全一致的,因为计算频率时高时低;
  2. 计算积分和微分时,需要做额外的浮点运算,因为这两种运算都依赖于时间的变化量;

解决方案

需要保证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 函数内部KiKd 参数会根据采样时间做出调整,从而抵消采样时间变化对计算的影响。

另外,在第30 行,我把采样时间转换成以秒为单位。严格来说,这是不必要的,只是可以让用户设置KiKd参数时以1/S 或S 为单位,而不是毫秒。

结果

上述变更为我们做了三件事:

  1. 无论调用 Compute() 的频率如何,PID 算法都将定期进行计算;
  2. 因为对时间做了减法以获取间隔,当 millis() 返回到 0 时就不会有问题了。虽然这种情况每 55 天才会发生一次,但我们的算法要考虑的足够周全;
  3. 我们不再需要乘除时间变化。由于它是一个常数,我们可以将它从计算代码中移出。从数学角度看,计算结果是一样的,但省去了多余的乘除运算;

注:对时间做减法指的是int timeChange = (now - lastTime),这样处理能避免在millis() 时间戳溢出时导致BUG,详情参考:Arduino 单片机程序中处理时间戳、时间溢出和延时问题

关于中断的说明

如果是在单片机里使用PID,有一些好的理由去使用定时中断,用SetSampleTme 设置中断频率,然后更精确的定时计算。如果你想,你可以在你自己的实现中使用中断,但先继续阅读这系列的文章,后面还会介绍一些改善措施,你或许能得到一些助益。

我有三个 难以拒绝的 不使用定时中断的理由:

  1. 这系列文章的目标读者并不都会用中断;
  2. 如果你还想让多个PID 控制器同时工作,事情就会变得比较复杂;
  3. 老实说,我并没有想到这个问题,是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=0NErrn=n=0N1KiErrn+(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 取代了误差积分errSumITemKi * 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 的读者,我希望您能从中获得一些思路,从而在今后的工作中节省一些时间。

最后两点说明:

  1. 如果本系列中有些地方看起来不对,请告诉我。我可能遗漏了什么,或者只是需要更清楚地解释。无论如何,我都想知道。
  2. 这只是一个基本的 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 的控制积分系统的例子,查看调定点变化时的响应:

在这里插入图片描述

有两件事值得注意:

  1. 当我们处于调定点时,积分项是整个输出的唯一贡献者。
  2. 即使开始和结束时的设定点不同,输出也会返回到相同的值。这个值通常被称为 “平衡点”:不会导致Input 变化的值。对于加热器来说,这个点刚好有足够的加热功率来补偿散失到周围环境中的热量。

在这里,我们可以看到为什么会一直发生过冲。当设定点首次发生变化时,存在的误差会导致积分项增长。为了使被控系统稳定在新的设定点,输出需要回到平衡点。要做到这一点,必须让积分项缩小。唯一的办法就是出现负误差,而这只有在高于设定点时才会发生。

注:就是说,对于积分系统,系统的输出是输入累计的结果。比如加热器,PID 总要在温度上升时增大功率,在维持温度时减少功率到平衡点,所以和积分饱和类似,积分项会让功率下降滞后,维持一段时间的超调。最简单的方法可能是干脆去掉积分项,改成PD 控制,被控系统本身的积分作用够强了,对微分冲击不敏感,反而能提高快速性。

PonM 改变了规律

下面是同样的系统用PonM 控制的效果:

在这里插入图片描述

在这里你应该注意到:

  1. P 项现在提供的是一个阻力。输入越高,负值越大。
  2. 以前,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 信号正在上升,新的标记点突然变成了最小值点,表示进入曲线下降沿,那么前一个标记点就是正尖峰点,反之,则是负尖峰点。

在这里插入图片描述

注:所以就需要缓存最近的十个采样点,以及之前的若干个标记点。还可以在标记点上再做滤波处理,正方向标记点必须持续出现多少次才会被识别为上升沿,否则,即使标记点发生转变,也会被过滤掉。

你还要知道……

  1. 施加的循环次数会在 3 到 10 次之间。算法会等到最后 3 个最大值相差在 5%以内。这样做是为了确保我们已经达到了一个稳定的振荡状态,并且没有外部异常情况发生。这让我想到…
  2. 我不是自动调参的忠实粉丝。我经常说,而且现在仍然相信,一个训练有素的人每天都能打败自动调参器。在算法对实情了解不足的情况下,可能出错的地方实在太多了。尽管如此,自动调参仍是帮助新手进入正轨的重要工具。
  • 3
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
【路径跟踪方法一:PID控制算法实现路径跟踪--飞思卡尔的方法】 PID控制算法是一种经典的控制算法,常用于实现路径跟踪。飞思卡尔是一家专注于自动控制和电子系统的全球知名厂商,他们提供了一种基于PID控制的路径跟踪方法。 该方法的基本思路是通过传感器获取机器人当前位置和目标位置之间的误差,并根据误差值来计算机器人的控制指令,使机器人能够沿着预定的路径行进。 具体实现步骤如下: 1. 设置机器人的目标路径,并初始化PID控制器的参数,包括比例增益Kp、积分增益Ki和微分增益Kd。 2. 通过传感器获取机器人当前位置信息,计算当前位置与目标位置之间的误差。 3. 根据误差值,通过PID控制算法计算出机器人的控制指令。PID控制算法的计算公式为: 控制指令 = Kp * 误差 + Ki * 积分误差 + Kd * 微分误差 其中,误差为当前位置与目标位置之间的差值,积分误差为误差累积的和,微分误差为误差变化率。 4. 将控制指令传递给机器人的执行器,控制机器人的运动。 5. 循环执行以上步骤,直到机器人到达目标位置或达到预定的终止条件。 这种路径跟踪方法基于PID控制算法,可以实现机器人沿着预定路径稳定地行进。通过不断调整PID控制器的参数,可以使机器人的路径跟踪性能更加优良,提高了路径跟踪的准确性和鲁棒性。 飞思卡尔的路径跟踪方法基于PID控制算法,在工业自动化、无人驾驶和机器人导航等领域得到了广泛的应用。其简单可靠的控制方式和良好的性能使得机器人能够准确地行进在指定路径上,为自动化系统的实际应用提供了重要的技术支持。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值