Skip to main content

PID with Anti-Windup: Theory, Tuning and Experimental Validation

· 5 min read
Oséias D. Farias
ML Engineer · MSc Electrical Eng. & Applied Computing · Researcher @ UFABC & UFPA

Integral windup is one of the most common failure modes in deployed PID controllers. This post covers the problem from first principles, derives the back-calculation anti-windup scheme, shows how Synapsys implements it, and validates the design against a simulated second-order plant.

The windup problem

A PID controller with output saturation has a nasty interaction: when the actuator is saturated, the integrator keeps accumulating error even though the output is clamped. When the error sign reverses, the integrator takes a long time to "wind down" before the output leaves saturation — causing overshoot and oscillation.

Error ─► [P]──────────────► sum ─► [saturation] ─► u
[I → accumulate] ──► │
[D] ────────────► ┘ │
▲ │
└─── windup occurs ───┘
when e > 0 and u = u_max

Toy example: windup in action

from synapsys.algorithms import PID
from synapsys.api import ss, c2d
import numpy as np

plant = c2d(ss([[-1]], [[1]], [[1]], [[0]]), dt=0.01)
pid_no_aw = PID(Kp=5.0, Ki=2.0, Kd=0.0, dt=0.01,
u_min=-1.0, u_max=1.0,
anti_windup=False) # windup enabled

x = np.zeros(1)
setpoint = 5.0 # large step — will saturate the output

outputs = []
for _ in range(500): # 5 s
y = x # y = x for this plant
u = np.array([pid_no_aw.compute(setpoint, float(y[0]))])
u_sat = np.clip(u, -1.0, 1.0) # saturation outside PID
x, _ = plant.evolve(x, u_sat)
outputs.append(float(y[0]))

The output reaches setpoint but then heavily overshoots because the integrator accumulated 5×500×0.01=255 \times 500 \times 0.01 = 25 (units) of error during saturation.


Back-calculation anti-windup

The back-calculation scheme feeds the saturation error back to the integrator with gain 1/Tt1/T_t (tracking time constant):

I˙(t)=Kie(t)+1Tt[usat(t)uuns(t)]\dot{I}(t) = K_i \cdot e(t) + \frac{1}{T_t}\bigl[u_{sat}(t) - u_{uns}(t)\bigr]

When the output is not saturated, usat=uunsu_{sat} = u_{uns} so the correction term is zero — the integrator behaves normally. When saturated, the correction reduces the integrator at a rate proportional to the saturation error.

A common choice is Tt=Ti/TdT_t = \sqrt{T_i / T_d} (geometric mean), or simply Tt=TiT_t = T_i when there is no derivative term.

Synapsys implementation

from synapsys.algorithms import PID

# Anti-windup ON (default) — u_min/u_max trigger back-calculation
pid = PID(
Kp = 5.0,
Ki = 2.0,
Kd = 0.1,
dt = 0.01,
u_min = -1.0,
u_max = 1.0,
# anti_windup=True is default
)

u = pid.compute(setpoint=5.0, measurement=y)

The compute() method returns the clamped output and applies back-calculation internally — no external saturation needed.


Comparative simulation

import numpy as np
import matplotlib.pyplot as plt
from synapsys.algorithms import PID
from synapsys.api import ss, c2d

plant = c2d(ss([[-0.5]], [[1]], [[1]], [[0]]), dt=0.01)

def run_sim(anti_windup: bool, n_steps: int = 800):
pid = PID(Kp=5.0, Ki=2.0, Kd=0.05, dt=0.01,
u_min=-1.0, u_max=1.0, anti_windup=anti_windup)
x = np.zeros(1)
ys, us = [], []
for i in range(n_steps):
setpoint = 3.0 if i < 400 else -3.0 # step then negative step
y = float(x[0])
u = np.array([pid.compute(setpoint, y)])
x, _ = plant.evolve(x, u)
ys.append(y); us.append(float(u[0]))
return np.array(ys), np.array(us)

t = np.arange(800) * 0.01
y_no, u_no = run_sim(anti_windup=False)
y_aw, u_aw = run_sim(anti_windup=True)

fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(9, 6), sharex=True)
ax1.plot(t, y_no, label='No anti-windup', linestyle='--')
ax1.plot(t, y_aw, label='Anti-windup', linewidth=2)
ax1.axhline(3.0, color='gray', linestyle=':', linewidth=0.8)
ax1.axhline(-3.0, color='gray', linestyle=':', linewidth=0.8)
ax1.set_ylabel("Output"); ax1.legend(); ax1.grid(True)

ax2.plot(t, u_no, linestyle='--'); ax2.plot(t, u_aw, linewidth=2)
ax2.axhline(1.0, color='red', linestyle=':', linewidth=0.8, label='u_max')
ax2.axhline(-1.0, color='red', linestyle=':', linewidth=0.8, label='u_min')
ax2.set_ylabel("Control (u)"); ax2.set_xlabel("Time (s)")
ax2.legend(); ax2.grid(True)

plt.tight_layout(); plt.savefig("pid_antiwindup_comparison.png", dpi=150)

Tuning guidelines for research

ParameterEffectStarting point
KpK_pSpeed of responseFOPDT: Kp=0.6/(Kplantτ)K_p = 0.6 / (K_{plant} \cdot \tau)
Ki=Kp/TiK_i = K_p / T_iSteady-state error eliminationTi=2τT_i = 2\tau
Kd=KpTdK_d = K_p \cdot T_dDamping, noise amplificationTd=τ/4T_d = \tau / 4
umin,umaxu_{min}, u_{max}Actuator limitsPhysical actuator spec

For discrete implementations, always use a sampling rate at least 10× the closed-loop bandwidth to avoid discretisation artefacts in the derivative term.


Connection to the broader literature

The back-calculation scheme used in Synapsys follows Åström & Hägglund (2006) Advanced PID Control, Chapter 6. The discrete formulation uses the bilinear (Tustin) approximation for the integrator, which avoids the frequency-domain distortion of forward Euler at high gains.

@book{astrom2006advanced,
author = {Åström, Karl Johan and Hägglund, Tore},
title = {Advanced PID Control},
publisher = {ISA — The Instrumentation, Systems, and Automation Society},
year = {2006},
isbn = {978-1556175169},
}

Summary

FeatureSynapsys PID
Discrete time✓ (fixed dt)
Anti-windup✓ back-calculation (default on)
Output limitsu_min, u_max
Derivative filter✓ first-order Tustin
Resetpid.reset()

The full API reference is at synapsys.algorithms →.