Saturation and windup

What a controller should do when the actuator has nothing left to give.

U5.6Feedback controlUniversity25 min

Do this lesson in the simulator

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 command the motors actually get against the command asked for, and a diagonal asked for at full speed050100150200250050100command asked for, %what the motors get, %one axisbelow 15: nothingyou get whatyou ask forabove 100: clipped to 100asked 250asked 6a diagonalsideways, cm/sforwards, cm/s01020asked for: 20 and 20(sideways needs 133 %)got: 15 and 208° off course
Top: one axis of drive(). Below 15 percent the motors do nothing, above 100 the command is cut to 100, and only in between do you get what you asked for. Bottom: 20 cm/s forwards and 20 sideways needs 100 percent forwards but 133 sideways, because sideways tops out at 15 cm/s. The sideways part is cut to 100 and the robot heads 8 degrees off the line it was asked for.

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()

Run this in the simulator

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 error, and the command asked for, the command sent and the i term, for the controller without protection051015202530-1002040error, cmpressed against the blocker, 14 ssettled at 25 s051015202530055100time, secondspercenti term 91the cap, 55sentasked for
Top: the error, which goes through zero at 5.1 s and on into the blocker. Bottom: the command sent sits at its cap of 55 all the way in, while the i term climbs behind it to 91 by the time the robot reaches its target. The shaded part is push asked for and never delivered, and all of it has to drain away before the robot backs off.

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 error without protection and with conditional integration051015202530-1002040time, secondserror, cmsettled at 25 sno protectionsettled at 16 sconditionalintegration
The same approach twice. With the integral frozen while the command is capped, the robot is pressed against the blocker for 6 s instead of 14, and settled within 3 cm of its target from 16 s instead of 25.

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

  1. Run it with and without the anti-windup and put the two i term charts side by side.
  2. Implement back-calculation instead and compare the recovery.
  3. Add a lower limit: refuse to send a command below the dead band, and send zero instead. Does the loop behave better or worse?