控制器在纸上很努力
前面十四章都假设了一件事:控制器说要多大输出,就能拿到多大输出。而现实里阀门只能开到 100%,机器只能开这么多台,油门踩到底就是踩到底。当控制器要的东西超过物理上限时,会发生一件非常具体、非常常见、也非常好修的坏事。
一台加热设备,阀门全开时输出最多到 1.2。有人把目标设成了 1.6——一个物理上到不了的值。控制器是 PI,它老老实实工作了 150 秒(阀门当然一直全开)。
然后有人发现设错了,把目标改回 1.0——一个完全够得着的值。
问:从改回目标那一刻算起,多久能真正回到 1.0 并稳住?
一个天真的假设,一直没被拆穿
翻回第 3 章那张框图。控制器的输出直接连到对象——这个连法暗含一个假设:控制器要多少,对象就吃多少。
这个假设在数学上很方便,在现实里从来不成立:
- 阀门开度在 0% 到 100% 之间;
- 加热功率不能为负(除非你有制冷);
- 机器数量是正整数,而且有配额上限;
- 油门踩到底就是踩到底;
- 你一天只有 24 小时。
这件事叫执行器饱和(actuator saturation)。它本身不可怕——阀门全开就全开,系统只是暂时慢一点,最终该到哪还是到哪。
可怕的是它和积分项的组合。
积分器的工作是「只要还有误差,就一直往上加」。它看不见阀门已经全开了——它只看得见误差。
于是在阀门顶死的那段时间里,积分器持续地、毫无意义地把自己越攒越大。它攒的那些数一分钱作用都没有(阀门已经开不动了),但它们全都记在账上。
等到目标终于降下来、误差变成负的,积分器必须先把这笔巨额的账一点一点还清,才能重新回到能干活的区间。而在还清之前,它的输出一直是「阀门全开」。
这个现象叫积分饱和(integral windup),windup 的字面意思就是「越绕越紧」。
还债比欠债慢一倍
为什么这笔债特别难还?因为欠债的速度和还债的速度不对称,而且这个不对称是可以算出来的。
# 积分器的累积速度 = Ki × 当前误差 # 【欠债阶段】目标 1.6,而系统最多到 1.2 误差 = 1.6 − 1.2 = 0.4 欠债速度 = Ki × 0.4 = 0.12 × 0.4 = 0.0480 /秒 # 【还债阶段】目标改回 1.0,系统被顶在 1.2 误差 = 1.0 − 1.2 = −0.2 ← 注意:只有 −0.2! 还债速度 = Ki × 0.2 = 0.12 × 0.2 = 0.0240 /秒 ★ 欠债速度是还债速度的 2.0 倍。 # 为什么不对称?因为误差的两个方向**幅度不一样**: # 往上的误差 = 你要的 − 它最多能给的 (可以要多少有多少) # 往下的误差 = 它最多能给的 − 你要的 (被 1.2 这个上限卡死了) # # 你可以把目标设成 100,欠债速度就是 Ki×98.8; # 但还债速度永远不会超过 Ki×(1.2 − 目标)。 ★ 上限卡住的是还债,不是欠债 —— 所以这笔账天然是高利贷。
把开关拨一拨,看两种做法的差别:
| 做法 | 阀门卡在全开 | 回到目标用了 |
|---|---|---|
| 什么都不做 | 306.2 s | 340.9 s |
| 抗饱和(顶死时停止积分) | 0.0 s | 41.1 s |
| 相差 | — | 8.3 倍 |
请注意那个 306.2 秒:在目标已经改回来之后,阀门还傻乎乎地全开了五分多钟。在这五分钟里,这个控制器完全失去了反馈能力——你可以给它任何目标,它的输出都是「全开」,因为积分器里那笔账压倒了一切。
而它在纸面上是完全「正常」的:控制器在运行,没有报错,误差在正确的方向上被计算,积分在正确的方向上被累加。每一步都对,合起来是灾难。
怎么修
好消息是:这大概是这本书里最容易修的一个问题。三种做法,一种比一种讲究:
① 钳位法(conditional integration)
最简单,也最常用:如果输出已经饱和,而且误差还在往同一个方向推,那就别积了。
// 在积分那一行加一个判断
const uRaw = kp * e + I + kd * dTerm;
const uSat = clamp(uRaw, uMin, uMax);
// 只在「没饱和」或者「误差方向能把它拉回来」时才积分
const pushingFurther =
(uRaw > uMax && e > 0) || (uRaw < uMin && e < 0);
if (!pushingFurther) {
I += ki * e * dt;
}
五行代码,效果就是上表那个 8.3 倍。如果你在生产环境里写过任何一个带积分的控制逻辑而没写这几行,现在就去加上。
② 反算法(back-calculation)
更讲究一点:不是简单地停止积分,而是按「要了多少 vs 实际给了多少」的差额,把积分器往回拉:
I += Ki·e·dt + (1/Tt)·(u_sat − u_raw)·dt
└──────────┬──────────┘
饱和了多少,就往回退多少
# Tt 是「跟踪时间常数」,通常取和积分时间同一个量级。
# 好处:从饱和状态退出时更平滑,不像钳位法那样有个硬拐点。
③ 从根上避免:别给它够不着的目标
最治本,也最常被忽略:在目标进入控制器之前,先把它限制在物理可达的范围内。这件事叫目标值限幅(setpoint clamping)或者更一般的参考轨迹整形——与其让控制器去追一个到不了的地方,不如给它一条它追得上的路。
这也是为什么好的系统里,「目标」往往不是用户直接设的那个数,而是经过一层处理的:限幅、限速率、平滑。这一层非常便宜,能挡掉大量麻烦。
「饱和」在中文技术圈常被用来形容负载——「CPU 饱和了」「链路饱和了」,意思是「用满了」。
控制论里的饱和是一个更狭窄、也更精确的意思:一个信号被硬性地限制在某个范围内,超出部分被直接砍掉。它描述的是一个非线性函数(在范围内是 y = x,在范围外是常数),不是一种资源状态。
为什么这个区分重要?因为「非线性」意味着前面十四章的所有工具都不适用了。特征根、传递函数、增益裕度、相位裕度——它们全都建立在线性假设上。一旦系统进入饱和,它就不再是那个你分析过的系统了。
这是一条贯穿卷 IV 的暗线:你分析的那个模型,只在某个工作区间内有效。而事故最爱发生在区间外。
空调制冷。盛夏把空调设成 16°C,而它满负荷也只能把房间降到 22°C。如果这台空调的控制器有积分项且没做抗饱和,那么等你晚上把温度调回 26°C 时,它会继续全功率制冷很久——因为它还在还白天攒下的账。你会觉得「这空调好像有点傻」,而它确实有点傻,就傻在这五行代码上。
自动扩容撞到配额。流量暴涨,扩容器想开 200 台,而云账号配额只有 50 台。如果扩容逻辑里有积分项(很多「按累计偏差调整」的实现都有),它会在配额墙上一直攒。等流量退去,它还会继续维持满配额很久——账单照付。
人也会积分饱和。「这个月落后太多了,下个月要拼命补」——如果落后的原因是目标本身不可能完成(比如人手根本不够),那这笔「欠账感」会持续累积。而等目标终于被调整到合理水平时,人往往还会继续过度投入很长一段时间,因为那个内在的积分器还没排空。抗饱和在这里的对应做法是:明确宣布「这笔账一笔勾销」——不是安慰,是在重置积分器。
再看一遍那个算式:欠债速度 Ki × 0.4,还债速度 Ki × 0.2,比值 0.4/0.2 = 2.0——Ki 消失了。这个比值只由「目标超出多少」和「上限还剩多少余地」决定,和你的整定参数毫无关系。
所以调小 Ki 对积分饱和是无效的(而且会赔上正常工况下的性能)。这个问题必须用结构手段解决,不能用参数手段解决——这是本书反复出现的一个模式:
- 稳态误差:调大增益是参数手段(无效),加积分项是结构手段(有效)——第 5、6 章。
- 延迟:调小增益是参数手段(是交易),缩短延迟是结构手段(是净收益)——第 9、11 章。
- 积分饱和:调小 Ki 是参数手段(无效),抗饱和是结构手段(五行代码,8.3 倍)。
遇到问题先问一句:这是参数问题还是结构问题?参数问题在旋钮上解决,结构问题在旋钮上永远解决不了——你只会在几个都不好的选项里来回挑。
C大约 340 秒——精确地说 340.9 秒,其中前 306.2 秒阀门一直卡在全开。作为对照,加了抗饱和只要 41.1 秒,相差 8.3 倍。
原因是欠债速度(0.0480/秒)是还债速度(0.0240/秒)的 2.0 倍:往上的误差可以要多大有多大,往下的误差被执行器上限卡死在 0.2。这笔账天然是高利贷。
A 「误差立刻变负,马上会收油」——比例项确实立刻变负了,但它的贡献(1.2 × (−0.2) = −0.24)在积分器攒下的那个大数面前微不足道。控制器的输出是几项之和,而其中一项已经大到淹没了其他所有项。
B 「攒了 150 秒就花 150 秒排掉」——这个直觉抓住了「要花时间排」,很好。它漏掉的是不对称:排的速度只有攒的一半,所以要花两倍多的时间。这也是为什么答案是 340 而不是 150。
D 「永远回不去,得重启」——它最终会回去(还债速度虽然慢但是正的)。不过这个答案抓住了一个真实的现场经验:很多人遇到积分饱和时的实际做法,就是重启控制器——而重启之所以有效,正是因为它把积分器清零了。如果「重启一下就好了」在你的系统里反复出现,值得去找一找那个积分器在哪。
这一章的一句话
积分器看不见执行器的上限,所以它会在那堵墙上一直攒账——而这笔账还起来比欠下时慢一倍,那五行防止它的代码是这本书里性价比最高的东西。
下一章讲另一种非线性,而它不是缺陷,是设计:当执行器只有「开」和「关」两档时,这个环永远不可能到位,它只能在目标附近来回。你家冰箱、空调、电熨斗全是这样。而这个来回的周期有闭式解——闭式算出 3.5140 秒,真跑一遍 3.5180 秒,差 0.114%。