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 symptom

A controller with an integral term, told to do something it cannot:

from bugbot import *
connect()

KP, KI = 1.5, 0.7
TARGET = 12.0          # closer than the blocker allows
DT = 0.1
integral = 0.0

for tick in range(150):
    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 i term line climbs and climbs for as long as the robot is stuck. Nothing is gained by any of it, because the command has been at its limit the whole time, and all of it has to be unwound later.

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.

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 robot is asked for a gap it cannot achieve: a blocker sits in the way. Run a PI controller with anti-windup, plot error and i term, and end up settled against the blocker rather than charging about.

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?