PID Controller

The PID controller computes a control signal from three terms based on the error (setpoint minus measurement): Proportional (react to present), Integral (correct past), Derivative (anticipate future). It’s the most widely used controller in industry — from thermostats to quadcopters.

Why It Matters

PID handles ~95% of industrial control loops. You can tune it without a mathematical model of the plant. Understanding what each term does, how to tune them, and common implementation pitfalls (integral windup, derivative kick) is essential for any control application.

The PID Equation

Continuous form:

u(t) = Kp·e(t) + Ki·∫e(t)dt + Kd·de(t)/dt
TermWhat It DoesToo MuchToo Little
P (Proportional)Output proportional to current errorOscillationSlow response
I (Integral)Accumulates past error, eliminates steady-state offsetOvershoot, windupPersistent offset
D (Derivative)Reacts to rate of change, damps oscillationAmplifies noiseMore overshoot

How Each Term Affects Response

                 ┌── P alone: fast but steady-state error
Output          ┌┤── P+I: eliminates offset but overshoots
  ↑            / └── P+I+D: fast, no offset, less overshoot
  │    ╱─────────── setpoint
  │   ╱╲  ╱──
  │  ╱  ╲╱
  │ ╱
  │╱
  └���─────────────→ time

Python Simulation

import numpy as np
import matplotlib.pyplot as plt
 
def pid_simulate(setpoint, Kp, Ki, Kd, dt=0.01, steps=1000):
    y, integral, prev_error = 0.0, 0.0, 0.0
    history = []
    for _ in range(steps):
        error = setpoint - y
        integral += error * dt
        derivative = (error - prev_error) / dt
        u = Kp * error + Ki * integral + Kd * derivative
        y += (u - y) * dt  # first-order plant: tau * dy/dt = u - y
        prev_error = error
        history.append(y)
    return history
 
t = np.arange(1000) * 0.01
plt.plot(t, pid_simulate(1.0, Kp=2.0, Ki=0, Kd=0), label='P only')
plt.plot(t, pid_simulate(1.0, Kp=2.0, Ki=1.0, Kd=0), label='PI')
plt.plot(t, pid_simulate(1.0, Kp=2.0, Ki=1.0, Kd=0.5), label='PID')
plt.axhline(y=1.0, color='r', linestyle='--', label='Setpoint')
plt.xlabel('Time (s)'); plt.ylabel('Output'); plt.legend(); plt.grid(); plt.show()

Tuning Methods

Ziegler-Nichols (Ultimate Gain Method)

  1. Set Ki=0, Kd=0
  2. Increase Kp until output oscillates with constant amplitude → this is Ku (ultimate gain)
  3. Measure the oscillation period → Tu
  4. Apply:
ControllerKpKiKd
P only0.5·Ku
PI0.45·Ku1.2·Kp/Tu
PID0.6·Ku2·Kp/TuKp·Tu/8

This gives aggressive tuning — expect ~25% overshoot. Reduce gains for smoother response.

Manual Tuning (Practical Heuristic)

  1. Start with Kp small, Ki=0, Kd=0
  2. Increase Kp until response is fast but oscillates slightly
  3. Add Ki to eliminate steady-state error (start small, increase until offset disappears)
  4. Add Kd to reduce overshoot (small amounts — too much amplifies noise)

Common Implementation Problems

Integral Windup

When the actuator saturates (e.g., motor at 100%), the error keeps accumulating in the integral term. When the error reverses, the bloated integral causes massive overshoot.

Fix — anti-windup clamping:

integral += error * dt
output = Kp * error + Ki * integral + Kd * derivative
 
if output > out_max:
    output = out_max
    integral -= error * dt  # undo the integration that caused saturation
elif output < out_min:
    output = out_min
    integral -= error * dt

Derivative Kick

When the setpoint changes suddenly (step input), the error derivative spikes to infinity, causing a violent actuator kick.

Fix — differentiate the measurement, not the error:

# Instead of: derivative = (error - prev_error) / dt
derivative = -(measurement - prev_measurement) / dt  # only reacts to actual change

Derivative Noise Amplification

Derivative amplifies high-frequency noise. Fix: low-pass filter the derivative term:

derivative_raw = (error - prev_error) / dt
derivative = alpha * derivative_raw + (1 - alpha) * prev_derivative  # EMA filter

Cascaded PID

For systems with nested dynamics (e.g., drone attitude → angular rate), use two PID loops:

Setpoint → [Outer PID] → rate_setpoint → [Inner PID] → motor_command
              ↑                                ↑
          angle sensor                    gyroscope

The inner loop runs faster (1kHz) and handles fast dynamics. The outer loop (250Hz) handles slower position/angle tracking.