Tutorial · Robotics · Control Theory · JavaScript
📅 July 2026 ⏱ ≈ 20 min 🎯 Intermediate

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):

error(t) = setpoint(t) − measurement(t) output(t) = Kp·error(t) + Ki·∫₀ᵗ error(τ)dτ + Kd·d(error)/dt

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:

P(t) = Kp · error(t)

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:

I(t) = Ki · ∫₀ᵗ error(τ) dτ

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:

D(t) = Kd · d(error(t)) / dt

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);
}
Derivative-on-measurement: the code above differentiates the measurement, not the error. If you differentiate the error instead, a step change in setpoint (e.g. the user drags a target slider) causes a huge instantaneous derivative spike — the "derivative kick" covered in section 7.

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.

Alternative — integral clamp: a simpler (if slightly less precise) technique is to directly clamp 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

  1. Set Ki = 0 and Kd = 0. Increase Kp until the system responds quickly but starts to oscillate visibly, then back it off to roughly 50-60% of that value.
  2. Increase Ki from zero until steady-state error is eliminated within an acceptable time, watching for growing oscillation — reduce it if the response starts to ring.
  3. Increase Kd to 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:

Classic PID: Kp = 0.6 · Ku Ki = 2 · Kp / Tu Kd = Kp · Tu / 8 "No overshoot" variant: Kp = 0.2 · Ku Ki = 0.4 · Ku / Tu Kd = 0.066 · Ku · Tu
Practical warning: Ziegler-Nichols tuning deliberately drives the real system into sustained oscillation. Do this in simulation first, or on hardware with generous physical and software limits, never on a loaded robot arm or a system that could be damaged by aggressive oscillation.

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:

Outer loop (position → velocity setpoint), runs at ~50 Hz: velocitySetpoint = positionPID.update(targetPos, currentPos, dt) Inner loop (velocity → motor command), runs at ~500-1000 Hz: motorCommand = velocityPID.update(velocitySetpoint, currentVel, dt)

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