Integral action is one of the great practical hacks in feedback control.

If the output sits below the target, keep adding corrective pressure. If the output sits above the target, keep subtracting. A proportional controller can leave a steady offset; an integrator keeps a ledger.

The problem is that actuators have limits.

Once a valve is fully open, a motor drive is pegged, or a service has hit its rate cap, asking for more no longer changes the plant input. The controller’s internal integral term may still keep charging. When the target later becomes reachable again, the actuator can remain pinned while the old integral debt unwinds.

That is integrator windup.

The Three Signals

Write a simple PI controller as

\[v(t) = K_p e(t) + I(t),\]

where \(e(t)=r(t)-y(t)\) is tracking error, \(v(t)\) is the controller’s desired command, and \(I(t)\) is the integral contribution.

The plant does not receive \(v(t)\). It receives the actuator output

\[u(t) = \operatorname{sat}(v(t), -U, U).\]

When \(v(t)=u(t)\), the controller’s mental model and the actuator agree. When they differ, the controller is asking for something the plant cannot receive.

Ziegler and Nichols’ 1942 paper named and organized the familiar proportional, automatic-reset, and pre-act control effects for practical controller tuning.1 Modern PID controllers still carry those terms in more mathematical clothes. The anti-windup question is not whether integral action is useful. It is what the integral term should do while the actuator is lying at a hard limit.

Three Update Rules

The lab below compares three PI variants on the same first-order plant:

\[\dot y(t) = {u(t-d)-y(t) \over \tau}.\]

The default parameters are:

  • plant time constant tau = 3;
  • input delay d = 0.6;
  • actuator limit U = 1;
  • high target 1.5, which is unreachable under that limit;
  • later target 0.45, which is reachable.

All three controllers use the same proportional and integral gains. Only the integral update changes.

No anti-windup:

\[\dot I = K_i e.\]

This is clean in a linear model and dangerous under saturation.

Conditional integration:

\[\dot I = \begin{cases} 0, & \text{if saturated and error pushes farther into saturation},\\ K_i e, & \text{otherwise}. \end{cases}\]

MIT’s feedback-control notes describe this intuition directly: once the input saturates, stop integrating the error because the actuator cannot do much more with it.2

Back-calculation:

\[\dot I = K_i e + K_b(u-v).\]

The extra term feeds back the gap between what the controller wanted and what the actuator actually delivered. Astrom and Rundqwist’s 1989 anti-windup paper unified several practical windup-protection ideas around this kind of tracking view.3 MathWorks’ PID documentation describes back-calculation as an internal loop that unwinds the integrator during saturation, and clamping as conditional integration.4

Lab: Saturate, Then Drop

The experiment is intentionally unfair to the plain PI controller. The target steps from 0 to 1.5 at t = 4s, even though the actuator can only supply +1.0. At t = 28s, the target drops to 0.45, which is reachable.

The interesting question is what happens immediately after the drop.

No anti-windup Conditional integration Back-calculation Actuator limit

The high request is 1.50 with actuator limit +/-1.00. After the drop, plain PI takes 21.2s to leave saturation; back-calculation takes 0.0s.

What The Default Run Measures

On the default run, the high target is impossible to hold. The controller can ask for more than +1, but the plant only receives +1.

After the target drops to 0.45, the audit reports:

Mode Time to leave saturation after drop Settling time after drop Error area after drop Max absolute integral
no anti-windup 21.2s >32s 14.45 7.02
conditional integration 0.0s 12.5s 1.67 0.45
back-calculation 0.0s 10.3s 1.20 0.62

The plain integrator is not “more determined.” It is stale. Its hidden state was charged during a period when the plant input could not increase. When the target falls, the raw command remains above the actuator limit for another 21.2s.

The two anti-windup variants disagree in detail. Conditional integration freezes the integral when the error is pushing farther into saturation. Back-calculation actively tracks the actuator by feeding back \(u-v\). In this default scenario, back-calculation has the smaller post-drop error area, while clamping keeps a smaller maximum integral. That is a real tradeoff, not a universal ranking.

Why The Integrator Is A State Variable

The tempting wrong explanation is:

The proportional term is too high.

Sometimes it is. But in this experiment, changing only the integrator update rule changes the recovery behavior dramatically. The plant, proportional gain, integral gain, actuator limit, delay, and setpoint schedule all stay fixed.

The difference lives in a hidden state.

During saturation, the plant sees

\[u(t)=U,\]

even if the raw controller command is

\[v(t)=7U.\]

The extra 6U is not extra actuation. It is only an internal promise. If the integrator keeps accumulating error while that promise cannot be cashed, the controller must later work off the promise before it can respond normally.

Anti-windup is therefore not a cosmetic clamp on the final output. It is a rule for keeping the controller’s internal state close to the actuator reality.

Engineering Notes

The lab is deliberately simple. It uses a first-order plant, fixed time step Euler integration, a small input delay implemented as a queue, and symmetric input saturation. The exported audit checks finite samples, actuator bounds, post-drop error area, integral growth, and settlement for the two anti-windup modes across several parameter sets. The parameter sweep below reports 90 named checks: 18 invariants for each of five saturated step/drop scenarios.

To reproduce that parameter sweep from the repository root:

node - <<'NODE'
const lab = require("./assets/js/pid-windup-lab.js");
const EXPECTED_CASE_SHAPE = [
  "1:high=1.50:limit=1.00:kp=1.40:ki=0.45:kb=0.70:14.45/1.67/1.20:21.2/0.0/0.0:7.02/0.45/0.62:12.5/10.3:18/18",
  "2:high=1.70:limit=1.00:kp=1.40:ki=0.45:kb=0.70:17.57/2.16/1.40:29.9/0.0/0.0:9.18/0.45/0.47:13.7/11.6:18/18",
  "3:high=1.50:limit=0.80:kp=1.20:ki=0.35:kb=0.60:11.21/2.03/1.14:>32/0.0/0.0:6.89/0.45/0.45:15.7/12.7:18/18",
  "4:high=1.20:limit=0.90:kp=1.80:ki=0.55:kb=0.90:9.58/1.18/0.94:16.3/0.0/0.0:5.74/0.45/0.54:10.5/8.9:18/18",
  "5:high=1.60:limit=1.10:kp=1.00:ki=0.60:kb=1.20:15.52/1.81/1.68:20.1/0.0/0.0:9.58/0.60/0.85:9.8/10.0:18/18"
];
const EXPECTED_CHECK_NAMES = [
  "none sample count",
  "none finite samples",
  "none actuator limit respected",
  "none finite IAE",
  "clamp sample count",
  "clamp finite samples",
  "clamp actuator limit respected",
  "clamp finite IAE",
  "backcalc sample count",
  "backcalc finite samples",
  "backcalc actuator limit respected",
  "backcalc finite IAE",
  "windup grows larger integral",
  "back calculation lowers post-drop error area",
  "plain integrator stays saturated longer",
  "back calculation settles by end",
  "clamping lowers post-drop error area",
  "clamping settles by end"
];
const EXPECTED_CASES = EXPECTED_CASE_SHAPE.length;
const EXPECTED_CHECKS = EXPECTED_CASES * EXPECTED_CHECK_NAMES.length;
const EXPECTED_CHECK_ROWS = Array.from({ length: EXPECTED_CASES }, (_, index) =>
  `${index + 1}:` + EXPECTED_CHECK_NAMES.map((name) => `${name}=true`).join("|")
);

function sameList(actual, expected) {
  return actual.length === expected.length &&
    actual.every((value, index) => value === expected[index]);
}

function seconds(value) {
  return value == null ? ">32" : value.toFixed(1);
}

function caseShape(row) {
  return `${row.caseId}:high=${row.high.toFixed(2)}:limit=${row.limit.toFixed(2)}:` +
    `kp=${row.kp.toFixed(2)}:ki=${row.ki.toFixed(2)}:kb=${row.kb.toFixed(2)}:` +
    `${row.noneIaeAfterDrop.toFixed(2)}/${row.clampIaeAfterDrop.toFixed(2)}/${row.backcalcIaeAfterDrop.toFixed(2)}:` +
    `${seconds(row.noneReentryTime)}/${seconds(row.clampReentryTime)}/${seconds(row.backcalcReentryTime)}:` +
    `${row.noneMaxAbsIntegral.toFixed(2)}/${row.clampMaxAbsIntegral.toFixed(2)}/${row.backcalcMaxAbsIntegral.toFixed(2)}:` +
    `${seconds(row.clampSettlingTime)}/${seconds(row.backcalcSettlingTime)}:` +
    `${row.passedChecks}/${row.totalChecks}`;
}

function checkRow(row) {
  return `${row.caseId}:` +
    row.checks.map((check) => `${check.name}=${check.ok}`).join("|");
}

function rowDrifts(actual, expected) {
  return {
    missing: expected.filter((row) => !actual.includes(row)),
    extra: actual.filter((row) => !expected.includes(row))
  };
}

function hasRowDrift(drift) {
  return drift.missing.length > 0 || drift.extra.length > 0;
}

const audit = lab.auditGrid();
const failed = audit.cases.filter(
  (row) => !row.passed || row.passedChecks !== row.totalChecks
);
const shape = {
  cases: audit.cases.map(caseShape),
  checkRows: audit.cases.map(checkRow)
};
const checkRowDrifts = rowDrifts(shape.checkRows, EXPECTED_CHECK_ROWS);
const shapeErrors = [];
if (!sameList(shape.cases, EXPECTED_CASE_SHAPE)) {
  shapeErrors.push({ name: "cases", actual: shape.cases, expected: EXPECTED_CASE_SHAPE });
}
if (hasRowDrift(checkRowDrifts)) {
  shapeErrors.push({ name: "checkRows", drift: checkRowDrifts });
}
audit.cases.forEach((row) => {
  const names = row.checks.map((check) => check.name);
  if (!sameList(names, EXPECTED_CHECK_NAMES)) {
    shapeErrors.push({
      name: `case ${row.caseId} check names`,
      actual: names,
      expected: EXPECTED_CHECK_NAMES
    });
  }
});
const summary =
  `${audit.passed}/${audit.total} cases and ${audit.passedChecks}/${audit.totalChecks} checks passed`;
if (
  shapeErrors.length ||
  failed.length ||
  !audit.ok ||
  audit.total !== EXPECTED_CASES ||
  audit.totalChecks !== EXPECTED_CHECKS ||
  audit.passed !== audit.total ||
  audit.passedChecks !== audit.totalChecks
) {
  throw new Error(JSON.stringify({
    summary,
    shapeErrors,
    shape,
    checkRowDrifts,
    failed,
    errors: audit.errors,
    totals: {
      total: audit.total,
      passed: audit.passed,
      passedChecks: audit.passedChecks,
      totalChecks: audit.totalChecks
    }
  }, null, 2));
}
console.table(audit.cases.map((row) => ({
  case: row.caseId,
  high: row.high.toFixed(2),
  limit: row.limit.toFixed(2),
  noneIAE: row.noneIaeAfterDrop.toFixed(2),
  clampIAE: row.clampIaeAfterDrop.toFixed(2),
  backcalcIAE: row.backcalcIaeAfterDrop.toFixed(2),
  noneReentry: seconds(row.noneReentryTime),
  backcalcReentry: seconds(row.backcalcReentryTime),
  checks: `${row.passedChecks}/${row.totalChecks}`,
  passed: row.passed
})));
console.log(summary);
NODE

It does not prove stability for arbitrary nonlinear plants. It does not tune a PID controller. It does not model derivative filtering, sensor noise, quantized actuators, actuator dynamics, or cascaded loops. MathWorks’ documentation calls out that more complex industrial scenarios may require tracking the effective actuator contribution rather than assuming the PID output is the only signal feeding the actuator.4

The small lesson survives those caveats:

If the actuator is saturated, the controller output is no longer the plant input. A PI controller that ignores that fact can store old error as future delay.

The integrator is a memory. Give it truthful feedback.

Sources

  1. J. G. Ziegler and N. B. Nichols, “Optimum Settings for Automatic Controllers”, Transactions of the ASME 64, 1942. The paper identifies proportional response, automatic reset, and pre-act as the three principal control effects in practical controllers. 

  2. MIT OpenCourseWare, “16.30 Feedback Control Systems, Topic 23: Anti-windup”, Fall 2010. The notes describe integrator windup under saturation and give conditional integration and actuator-error feedback as anti-windup options. 

  3. Karl J. Astrom and Lars Rundqwist, “Integrator Windup and How to Avoid It”, Proceedings of the 1989 American Control Conference, 1989. The paper discusses practical anti-windup schemes and a unifying tracking view. 

  4. MathWorks, “Anti-Windup Control Using PID Controller Block”. The documentation describes back-calculation, clamping, and tracking-mode anti-windup behavior for saturated actuators.  2