PID Controller: Theory and Tuning
The Proportional-Integral-Derivative (PID) controller is the single most widely deployed control algorithm on the planet — it drives drone flight stabilizers, robot arm joints, 3D-printer hotends, cruise control, and industrial process loops. This guide builds one from first principles: the math behind each term, a robust discrete-time implementation in JavaScript, the failure modes that bite beginners, and the tuning methods engineers actually use.
1. The Feedback Loop
A PID controller drives a system (the plant) toward a desired setpoint by continuously measuring the error — the difference between where the system is and where it should be — and computing a correcting control output (voltage, motor torque, throttle angle, PWM duty cycle):
Each term answers a different question: the proportional term asks "how wrong am I right now?", the integral term asks "how wrong have I been over time?", and the derivative term asks "how fast is the error changing?". Combined, they let a controller react immediately, eliminate steady-state offset, and damp oscillation — three things no single term can do alone.
2. Proportional Term — Immediate Reaction
The proportional term produces an output directly proportional to the current error. A drone 2 m below its target altitude gets twice the corrective thrust of one 1 m below:
A larger Kp reacts faster but pushes the system toward
instability and overshoot. A pure P controller almost always leaves
a steady-state error: for a quadcopter fighting
gravity, the hover thrust required to cancel gravity exactly
corresponds to zero error, so a pure P term settles slightly below
setpoint — there has to be some residual error to produce
the exact thrust needed to hold position. This gap is exactly what
the integral term exists to close.
3. Integral Term — Eliminating Steady-State Error
The integral term accumulates error over time, growing the output as long as any error persists — even a tiny one — until it is driven to zero:
Adding integral action removes steady-state error entirely, but it
also adds phase lag: the controller is now reacting to the
history of error, not just its present value, which
increases overshoot and can destabilize the loop if
Ki is too large. It also introduces the single most
common real-world PID bug — integral windup,
covered in section 6.
4. Derivative Term — Damping and Prediction
The derivative term reacts to the rate of change of the error, effectively predicting where the error is heading and opposing fast motion toward (or past) the setpoint. It's the term that keeps a system from ringing:
A well-tuned Kd acts as a brake: as the system
approaches the setpoint quickly, the derivative term produces a
negative correction that slows the approach, reducing overshoot and
settling time. Too much Kd, however, amplifies
measurement noise — a drone's noisy IMU sample-to-sample jitter gets
differentiated straight into jittery motor commands.
5. Discrete-Time Implementation
Real controllers run in a fixed-timestep loop, so the continuous integral becomes a running sum and the derivative becomes a finite difference. Here is a complete, production-shaped PID class:
class PIDController {
constructor({ kp, ki, kd, outMin = -Infinity, outMax = Infinity }) {
this.kp = kp;
this.ki = ki;
this.kd = kd;
this.outMin = outMin;
this.outMax = outMax;
this.integral = 0;
this.prevMeasurement = null; // for derivative-on-measurement
}
// setpoint, measurement: current target and sensor reading
// dt: seconds since last call
update(setpoint, measurement, dt) {
const error = setpoint - measurement;
// --- Proportional ---
const pTerm = this.kp * error;
// --- Integral (trapezoidal accumulation) ---
this.integral += error * dt;
let iTerm = this.ki * this.integral;
// --- Derivative on measurement (avoids "derivative kick") ---
const measDeriv = this.prevMeasurement === null
? 0
: (measurement - this.prevMeasurement) / dt;
const dTerm = -this.kd * measDeriv;
this.prevMeasurement = measurement;
// --- Sum and clamp to actuator limits ---
let output = pTerm + iTerm + dTerm;
const clamped = Math.min(this.outMax, Math.max(this.outMin, output));
// --- Clamping (back-calculation) anti-windup ---
if (clamped !== output) {
this.integral -= error * dt; // undo the integration that caused saturation
}
return clamped;
}
reset() {
this.integral = 0;
this.prevMeasurement = null;
}
}
// Usage: stabilize a simulated drone's altitude at 10 m
const altPID = new PIDController({ kp: 2.0, ki: 0.5, kd: 0.8, outMin: 0, outMax: 1 });
function simStep(dt) {
const thrust = altPID.update(10.0, drone.altitude, dt);
drone.applyThrust(thrust);
}
6. Integral Windup and Anti-Windup
Every real actuator saturates — a motor can't exceed 100% duty cycle, a robot arm servo has a torque limit. If the error stays large for a while (e.g. a robot arm physically blocked from reaching its target), the integral term keeps accumulating even though the extra output has no effect, since the actuator is already maxed out. When the obstruction clears, that huge accumulated integral fires all at once, causing a massive overshoot. This is integral windup.
The fix implemented above is clamping anti-windup (also called back-calculation): after clamping the output to the actuator's physical range, undo the integral accumulation that pushed it past the limit. This stops the integral term from growing once the actuator is saturated, so recovery is immediate rather than delayed.
this.integral itself to a fixed range, e.g.
[-50, 50], right after the accumulation step. It's one
line of code and works well when you don't need bit-perfect
back-calculation.
7. Derivative Kick and Filtering
If you differentiate the error directly, a step change in the setpoint produces an instantaneous, enormous jump in error over one timestep — the derivative term spikes to a huge value for a single frame, jerking the actuator. This is the derivative kick. Differentiating the measurement instead (as in the implementation above) fixes this cleanly, because sensor readings don't jump discontinuously the way setpoints can.
The second problem is sensor noise amplification:
differentiation is a high-pass operation, so any high-frequency
noise in the measurement gets amplified by Kd / dt.
The standard fix is a first-order low-pass filter on the derivative
term:
// N = filter coefficient (typical: 8-20); higher N = less filtering
const rawDeriv = (measurement - prevMeasurement) / dt;
const alpha = dt / (dt + 1 / N);
filteredDeriv = filteredDeriv + alpha * (rawDeriv - filteredDeriv);
const dTerm = -kd * filteredDeriv;
8. Manual and Ziegler-Nichols Tuning
Manual tuning
-
Set
Ki = 0andKd = 0. IncreaseKpuntil the system responds quickly but starts to oscillate visibly, then back it off to roughly 50-60% of that value. -
Increase
Kifrom zero until steady-state error is eliminated within an acceptable time, watching for growing oscillation — reduce it if the response starts to ring. -
Increase
Kdto damp overshoot and reduce settling time, backing off if it introduces high-frequency jitter (a sign you're amplifying noise — apply derivative filtering first).
Ziegler-Nichols closed-loop method
A classic, systematic starting point: set Ki = Kd = 0,
then raise Kp until the system oscillates with a
sustained, constant-amplitude oscillation. Record this
gain as the ultimate gain Ku and the
oscillation period as Tu. Then apply:
9. Cascade Control and Robotics Applications
A single PID loop is rarely the whole story in robotics. Most flight controllers and robot arms use cascaded PID loops: an outer, slower loop controls position and outputs a target velocity, which feeds an inner, faster loop that controls velocity (or torque) directly:
This structure appears throughout robotics: a quadcopter's attitude controller cascades angle → angular rate → motor mixing; an industrial robot arm cascades joint position → joint velocity → current/torque. Running the inner loop faster gives it tighter control authority over fast disturbances while the outer loop handles the slower, larger-scale trajectory.
10. Common Pitfalls
-
Variable timestep without using it: if
dtisn't measured and fed into the integral/ derivative math (using a fixed assumed value instead), gains that were tuned at 60 fps will misbehave the moment the frame rate drops. -
Angle wraparound: for heading/yaw control,
naively subtracting angles gives huge errors crossing the ±180°
boundary. Always normalize the error:
error = ((target - current + 540) % 360) - 180. - No output clamping: feeding an unclamped PID output straight to a motor driver risks commanding physically impossible values and worsens windup — always clamp to the actuator's real range.
-
Forgetting to reset state: when a controller is
disabled and re-enabled (e.g. a drone lands and takes off again),
call
reset()to clear the integral term and previous measurement, or the fresh run inherits stale accumulated state. - Tuning at the wrong operating point: a PID tuned for a robot arm carrying no payload will behave differently once loaded — the plant's dynamics (effective inertia, friction) changed, so gains that were stable become underdamped or oscillatory.