Introduction

A PID controller is the workhorse of automatic control: it looks at the error between where a system is and where it should be, and reacts with three terms — Proportional (react to the error now), Integral (react to the error accumulated over time), and Derivative (react to how fast the error is changing).

The integral term is what erases stubborn, lingering error — the kind a purely proportional controller can never quite close. But it has a blind spot: it keeps summing the error forever, with no idea that the valve, motor or heater it commands can only push so hard.

When the controller asks for more than the actuator can deliver, the actuator saturates — it's already at 100% and can't go higher — while the integral term, oblivious, keeps climbing. That silent buildup is called integral windup, and it is one of the most common ways a real PID loop goes wrong.

See It Live

Below is a simple plant (think: a heater or a motor) driven by a PID controller whose actuator is capped between 0% and 100%. Press Run step to command a big jump in setpoint and watch the response.

<p class="hint">A PID controller drives a lagging plant (like a heater) toward a setpoint, but the actuator can only output between 0% and 100%. Toggle anti-windup and press Run to compare the response.</p>
<div class="controls">
  <label class="toggle">
    <input type="checkbox" id="awToggle" checked>
    <span>Anti-windup (integral clamping)</span>
  </label>
  <button id="run" type="button">Run step</button>
  <button id="reset" type="button" class="ghost">Clear chart</button>
</div>
<canvas id="chart" width="640" height="300" aria-label="Chart of setpoint, plant output and actuator command over time"></canvas>
<div class="legend">
  <span><i class="sw sp"></i>Setpoint</span>
  <span><i class="sw pv"></i>Plant output</span>
  <span><i class="sw ac"></i>Actuator command</span>
</div>
<div class="status" id="status">Press "Run step" to command a jump in setpoint.</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
.controls { display: flex; gap: .9rem; align-items: center; flex-wrap: wrap; margin-bottom: .6rem; }
.toggle { display: flex; align-items: center; gap: .4rem; font: 600 14px system-ui, sans-serif; cursor: pointer; user-select: none; }
.toggle input { width: 16px; height: 16px; cursor: pointer; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
         background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
canvas { width: 100%; max-width: 640px; height: auto; background: #f7f9fb; border: 1px solid #d7dee5; border-radius: 8px; display: block; }
.legend { display: flex; gap: 1rem; flex-wrap: wrap; font-size: .82rem; margin: .5rem 0; color: #444; }
.legend span { display: flex; align-items: center; gap: .35rem; }
.sw { width: 14px; height: 3px; display: inline-block; border-radius: 2px; }
.sw.sp { background: #94a3b8; }
.sw.pv { background: #1d3557; }
.sw.ac { background: #e63946; }
.status { font-size: .92rem; font-weight: 600; margin: .4rem 0; min-height: 1.4em; color: #1d3557; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));

// PID + saturated actuator simulation. KP/KI/KD are fixed gains; U_MIN/U_MAX model a
// valve or motor that can only push between 0% and 100% output.
const DT = 0.1;
const STEPS = 300;
const SETPOINT = 80;
const KP = 4, KI = 2.5, KD = 0;
const U_MIN = 0, U_MAX = 100;
// First-order lagging plant: dy/dt = (-y + K_PLANT * u) / TAU (Euler-integrated).
const TAU = 3, K_PLANT = 1;

// Runs one full step response, returning the output trace, actuator trace and overshoot.
function simulate(antiwindup) {
  let y = 0, integral = 0, prevError = 0;
  let maxY = 0;
  const ys = [], us = [];
  for (let i = 0; i < STEPS; i++) {
    const error = SETPOINT - y;
    let newIntegral = integral + error * DT;
    let uUnsat = KP * error + KI * newIntegral + KD * (error - prevError) / DT;
    let uSat = Math.max(U_MIN, Math.min(U_MAX, uUnsat));

    if (antiwindup) {
      // Integral clamping: freeze the integral only when saturated AND the error would push it further into saturation.
      const wouldSaturate = uSat !== uUnsat;
      const pushingFurther = (uUnsat > U_MAX && error > 0) || (uUnsat < U_MIN && error < 0);
      if (!(wouldSaturate && pushingFurther)) integral = newIntegral;
      uUnsat = KP * error + KI * integral + KD * (error - prevError) / DT;
      uSat = Math.max(U_MIN, Math.min(U_MAX, uUnsat));
    } else {
      integral = newIntegral; // No anti-windup — keep integrating no matter what the actuator can actually do.
    }

    const dydt = (-y + K_PLANT * uSat) / TAU;
    y = y + dydt * DT;
    prevError = error;
    maxY = Math.max(maxY, y);
    ys.push(y); us.push(uSat);
  }
  const overshoot = Math.max(0, ((maxY - SETPOINT) / SETPOINT) * 100);
  return { ys, us, overshoot };
}

const canvas = document.getElementById('chart');
const ctx = canvas.getContext('2d');

function draw(ys, us) {
  const W = canvas.width, H = canvas.height;
  ctx.clearRect(0, 0, W, H);
  const padL = 34, padB = 22, padT = 10, padR = 8;
  const plotW = W - padL - padR, plotH = H - padT - padB;
  const yMax = 110;
  const xOf = i => padL + (i / (STEPS - 1)) * plotW;
  const yOf = v => padT + plotH - (v / yMax) * plotH;

  // Draw plot axes and gridlines.
  ctx.strokeStyle = '#d7dee5'; ctx.lineWidth = 1;
  ctx.beginPath();
  ctx.moveTo(padL, padT); ctx.lineTo(padL, padT + plotH); ctx.lineTo(padL + plotW, padT + plotH);
  ctx.stroke();
  ctx.fillStyle = '#8892a0'; ctx.font = '11px system-ui, sans-serif';
  ctx.fillText('100', 2, yOf(100) + 4);
  ctx.fillText('0', 18, yOf(0) + 4);

  // setpoint line
  ctx.strokeStyle = '#94a3b8'; ctx.setLineDash([5, 4]); ctx.lineWidth = 1.5;
  ctx.beginPath(); ctx.moveTo(xOf(0), yOf(SETPOINT)); ctx.lineTo(xOf(STEPS - 1), yOf(SETPOINT)); ctx.stroke();
  ctx.setLineDash([]);

  // actuator command
  ctx.strokeStyle = '#e63946'; ctx.lineWidth = 1.5;
  ctx.beginPath();
  us.forEach((v, i) => { const x = xOf(i), y = yOf(v); i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); });
  ctx.stroke();

  // plant output
  ctx.strokeStyle = '#1d3557'; ctx.lineWidth = 2.2;
  ctx.beginPath();
  ys.forEach((v, i) => { const x = xOf(i), y = yOf(v); i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); });
  ctx.stroke();
}

function run() {
  const aw = document.getElementById('awToggle').checked;
  const { ys, us, overshoot } = simulate(aw);
  draw(ys, us);
  const s = document.getElementById('status');
  if (aw) {
    s.textContent = i18nFmt('Anti-windup ON — overshoot: %pct%%. The integral stops accumulating the instant the actuator saturates.', { pct: overshoot.toFixed(1) });
  } else {
    s.textContent = i18nFmt('Anti-windup OFF — overshoot: %pct%%. The integral kept growing while the actuator was pinned at its limit.', { pct: overshoot.toFixed(1) });
  }
  console.log('Overshoot over setpoint:', overshoot.toFixed(2) + '%');
}

document.getElementById('run').addEventListener('click', run);
document.getElementById('reset').addEventListener('click', () => {
  const ctx2 = canvas.getContext('2d');
  ctx2.clearRect(0, 0, canvas.width, canvas.height);
  document.getElementById('status').textContent = 'Press "Run step" to command a jump in setpoint.';
});
run();
Result
Console

With anti-windup off, the integral term keeps accumulating while the actuator sits pinned at its limit, so the controller massively overshoots before it can even start correcting. Flip anti-windup on — using integral clamping — and the same step settles quickly, with far less overshoot, because the integral stops accumulating the moment the actuator saturates.

The Real Complexity

A textbook PID controller is designed as a linear system: output is proportional to error, its integral, and its derivative, in a clean feedback loop. Saturation breaks that assumption — it is a nonlinearity the controller's math doesn't know about.

  • Without anti-windup, the discrete integral term evolves as Ik=Ik−1+Ki ek ΔtI_{k} = I_{k-1} + K_i \, e_k \, \Delta t, completely ignoring whether the actuator could use that command. Even after the error crosses zero, IkI_k can stay enormous, so the controller keeps commanding full output long after it should be backing off — the textbook symptom of overshoot followed by a slow, oscillating recovery.
  • Integral clamping is the simplest fix: freeze the integral (stop adding to it) whenever the actuator is saturated and the error would push it further into saturation. It only takes one if statement, but it stops the runaway immediately.
  • Back-calculation is the more general fix. It feeds the saturation error — the gap between what the controller demanded and what the actuator could actually deliver, es=usat−uunsate_s = u_{\text{sat}} - u_{\text{unsat}} — back into the integral with its own gain KbK_b: Ik=Ik−1+Ki ek Δt+Kb es ΔtI_{k} = I_{k-1} + K_i \, e_k \, \Delta t + K_b \, e_s \, \Delta t. This lets the integral "unwind" smoothly as soon as saturation ends, and generalizes cleanly to controllers with more complex actuator models.
  • Both tricks are instances of a wider idea in control theory: whenever a nonlinearity like saturation, rate limiting or dead zones sits between the controller and the plant, the controller needs to be told about it explicitly, or the closed-loop behavior can drift far from what the linear design predicted. It's the same lesson as Kalman filtering: a controller is only as good as the model of the world it's built on.

Where It Matters

Any control loop built around a PID and a real, physically limited actuator can suffer from windup — which is to say, nearly every control loop in the world:

  • Process control: chemical reactors, boilers and furnaces have valves and heaters that saturate constantly during startup, and unclamped integrators there can cause dangerous overshoots in temperature or pressure.
  • Automotive and aerospace: cruise control, engine throttle and flight-surface actuators all have hard physical limits; anti-windup keeps large setpoint changes from causing lurches or oscillation.
  • Robotics and drones: motor controllers saturate under heavy loads or aggressive maneuvers, and windup there can turn a fast correction into a wobble or a crash.
  • Power electronics: current and voltage controllers in motor drives and power supplies saturate near their rated limits and need anti-windup to recover cleanly.

Almost every commercial PID implementation — from a $5 microcontroller library to a distributed control system running an oil refinery — ships with anti-windup built in, precisely because saturation is the rule, not the exception, once a controller meets the real world. See also how controllability and observability shape whether a controller can even be designed to reach a state at all.

Conclusion

Integral windup is a small, almost embarrassing bug with an outsized effect: a controller keeps "remembering" error it could never act on, and pays for it later with an overshoot the plant didn't need. The fix doesn't require abandoning the PID — it just requires telling the integral term the truth about what the actuator can do.

It's a reminder that even the simplest, most battle-tested algorithms can misbehave the moment reality adds a constraint the math didn't expect — and that often the fix is not a new algorithm, but a small, honest patch that lets the old one see the world as it actually is.

Share this article

Pick a channel — or use your device's native share sheet.

Comments

Loading comments...

https://www.kipuhub.com/en/article/pid-antiwindup/Content licensed under CC BY-NC 4.0.