Saturation and windup
What a controller should do when the actuator has nothing left to give.
Do this lesson in the simulatorPrint it: worksheet (PDF) · answers (PDF) · as a web page
Guide: PWM and motor control explained
A controller computes a number. The actuator can only deliver what it can deliver. Everywhere those two disagree, something has to be decided, and controllers that ignore the question fail in ways that look like mysteries.
Where this robot saturates
- At the top.
drive()clips at 100. Ask for 250 and you get 100. - At the bottom. Below about 15 percent, nothing moves. Ask for 6 and you get zero.
- Sideways. The lateral axis is slower than the forward one, so a diagonal saturates on one axis before the other, and the robot travels in the wrong direction.
The symptom
A controller with an integral term and a command limit of 55, asked to stop 12 cm short of a blocker 60 cm away:
from bugbot import *
connect()
KP, KI = 1.5, 0.7
TARGET = 12.0 # cm short of the blocker
DT = 0.1
integral = 0.0
for tick in range(300):
error = distance() - TARGET
integral += error * DT # no protection at all
plot("error", error)
plot("i term", KI * integral)
drive(max(-55, min(55, KP * error + KI * integral)), 0, 0)
wait(DT)
stop()
The command sits at its limit of 55 for the whole approach, so the robot goes no faster, but the i term line climbs anyway, to about 91 percent by the time the robot reaches 12 cm. Nothing is gained by any of it, and all of it has to be unwound. The robot drives straight through its target into the blocker and stays pressed against it for about 16 s, while the negative error slowly drains the integral. Only then does it back off, and it is not settled at 12 cm until about 25 s.
The fix
Stop accumulating while the actuator is saturated:
want = KP * error + KI * integral
cmd = max(-55, min(55, want))
if abs(want - cmd) < 0.01: # the actuator can still deliver what we asked for
integral += error * DT
drive(cmd, 0, 0)
Three lines. The integral is frozen while the command is pinned, and resumes the moment the controller has authority again. This is conditional integration, and it is the cheapest correct answer.
Put it in the cell above and the i term peaks at about 38 instead of 91. The robot still touches the blocker, because once the command drops below 55, about 36 cm out, the integral gathers again on the rest of the way in. But it is pressed there for about 7 s instead of 16, and settled at 12 cm by about 16 s instead of 25. To stop the touch as well, add a clamp (20 on the integral is enough here) or integrate only near the target, as in U5.4.
The other standard answer is back-calculation: integral -= (want - cmd) / Kt, which unwinds the integral in proportion to how much was clipped. Slightly gentler, one more constant to choose.
Where else this bites
- A dead band at the bottom has the same effect. The controller thinks it is pushing; the plant does nothing. An integral term is the standard treatment, and it also needs a limit so it cannot grow without bound while a dead band is eating everything.
- Handover. Switch from manual to automatic with an integral that has been accumulating meanwhile, and the robot jumps. The fix is to initialise the integral so that the controller's first output matches what was already being sent. That is called bumpless transfer, and any system with modes needs it.
Task: anti-windup
The same blocker, the same 12 cm. Run a PI controller with anti-windup, keep KI at 0.7, plot error and i term, and be settled 12 cm from the blocker, within 3 cm, from 21 seconds onwards. Without protection the integral winds up on the way in and the robot is not settled until about 25 seconds; with it, about 15.
from bugbot import *
connect()
KP, KI = 1.5, 0.7
TARGET = 12.0
DT = 0.1
integral = 0.0
Challenges
- Run it with and without the anti-windup and put the two
i termcharts side by side. - Implement back-calculation instead and compare the recovery.
- Add a lower limit: refuse to send a command below the dead band, and send zero instead. Does the loop behave better or worse?