Python PID Control Systems — Deep Dive

Discrete PID Implementation

Real controllers run at fixed intervals, not in continuous time. The discrete form uses summation instead of integration and differences instead of derivatives:

class PIDController:
    def __init__(self, kp, ki, kd, dt, output_limits=(-1, 1)):
        self.kp = kp
        self.ki = ki
        self.kd = kd
        self.dt = dt
        self.output_min, self.output_max = output_limits

        self.integral = 0.0
        self.prev_error = 0.0
        self.prev_measurement = 0.0

    def update(self, setpoint, measurement):
        error = setpoint - measurement

        # Proportional
        p_term = self.kp * error

        # Integral with anti-windup (clamping)
        self.integral += error * self.dt
        i_term = self.ki * self.integral

        # Derivative on measurement (avoids derivative kick on setpoint change)
        d_measurement = (measurement - self.prev_measurement) / self.dt
        d_term = -self.kd * d_measurement

        # Total output
        output = p_term + i_term + d_term

        # Output clamping
        if output > self.output_max:
            output = self.output_max
            # Back-calculate integral to prevent windup
            self.integral = (output - p_term - d_term) / self.ki if self.ki != 0 else self.integral
        elif output < self.output_min:
            output = self.output_min
            self.integral = (output - p_term - d_term) / self.ki if self.ki != 0 else self.integral

        self.prev_error = error
        self.prev_measurement = measurement

        return output

Two critical details distinguish this from a textbook implementation:

Derivative on Measurement

The standard formula computes Kd × d(error)/dt. When the setpoint changes suddenly (step change), the error jumps, producing a massive derivative spike called “derivative kick.” Computing the derivative on the measurement signal instead avoids this because the measurement changes smoothly.

Anti-Windup

When the output saturates (hits its limit), the integral keeps accumulating. When conditions change, the inflated integral causes severe overshoot. The back-calculation method above recomputes the integral to match the clamped output, preventing accumulation during saturation.

Derivative Filtering

Raw derivatives amplify high-frequency noise. A first-order low-pass filter smooths the derivative:

class FilteredPID(PIDController):
    def __init__(self, kp, ki, kd, dt, output_limits=(-1, 1), tau_d=0.1):
        super().__init__(kp, ki, kd, dt, output_limits)
        self.tau_d = tau_d  # filter time constant
        self.filtered_derivative = 0.0

    def update(self, setpoint, measurement):
        error = setpoint - measurement

        p_term = self.kp * error
        self.integral += error * self.dt
        i_term = self.ki * self.integral

        # Filtered derivative
        raw_derivative = -(measurement - self.prev_measurement) / self.dt
        alpha = self.dt / (self.tau_d + self.dt)
        self.filtered_derivative = alpha * raw_derivative + (1 - alpha) * self.filtered_derivative
        d_term = self.kd * self.filtered_derivative

        output = p_term + i_term + d_term
        output = np.clip(output, self.output_min, self.output_max)

        self.prev_measurement = measurement
        return output

The filter time constant tau_d controls the tradeoff: larger values remove more noise but add delay to the derivative response. A common rule: set tau_d to 1/8 to 1/10 of the derivative time constant.

Simulating Plant Dynamics

To test PID controllers, you need a simulated “plant” (the system being controlled). Common models:

First-Order System (Temperature Control)

import numpy as np

class ThermalPlant:
    """First-order thermal system: tau * dT/dt = -T + K*u + T_ambient"""
    def __init__(self, tau=60, gain=50, ambient=20, noise_std=0.1):
        self.tau = tau         # time constant (seconds)
        self.gain = gain       # steady-state gain (°C per unit input)
        self.ambient = ambient
        self.noise_std = noise_std
        self.temperature = ambient

    def step(self, control_input, dt):
        dT = (-self.temperature + self.ambient + self.gain * control_input) / self.tau
        self.temperature += dT * dt
        return self.temperature + np.random.normal(0, self.noise_std)

Second-Order System (Motor Position)

class MotorPlant:
    """DC motor position control: J*theta_ddot + b*theta_dot = K*u"""
    def __init__(self, J=0.01, b=0.1, K=0.01):
        self.J = J     # moment of inertia
        self.b = b     # damping
        self.K = K     # motor constant
        self.theta = 0
        self.omega = 0

    def step(self, voltage, dt):
        alpha = (self.K * voltage - self.b * self.omega) / self.J
        self.omega += alpha * dt
        self.theta += self.omega * dt
        return self.theta

Complete Simulation Loop

import matplotlib.pyplot as plt

def simulate_pid(plant, controller, setpoint, duration, dt):
    times = np.arange(0, duration, dt)
    measurements = []
    outputs = []
    setpoints = []

    for t in times:
        sp = setpoint(t) if callable(setpoint) else setpoint
        measurement = plant.step(controller.update(sp, plant.temperature), dt)
        measurements.append(measurement)
        outputs.append(controller.update(sp, measurement))
        setpoints.append(sp)

    return times, measurements, outputs, setpoints

# Example: tune a thermal controller
plant = ThermalPlant(tau=30, gain=40, ambient=20)
pid = PIDController(kp=2.0, ki=0.1, kd=5.0, dt=0.1, output_limits=(0, 1))

times, temps, outputs, sps = simulate_pid(
    plant, pid, setpoint=60.0, duration=300, dt=0.1
)

fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 6))
ax1.plot(times, temps, label='Temperature')
ax1.axhline(y=60, color='r', linestyle='--', label='Setpoint')
ax1.set_ylabel('°C')
ax1.legend()

ax2.plot(times, outputs, label='Control Output')
ax2.set_xlabel('Time (s)')
ax2.set_ylabel('Output')
ax2.legend()
plt.tight_layout()

Auto-Tuning: Relay Feedback Method

The relay feedback method automates Ziegler-Nichols tuning without manual intervention:

def relay_auto_tune(plant, relay_amplitude=1.0, duration=200, dt=0.1):
    """Relay feedback auto-tuning to find ultimate gain and period."""
    setpoint = plant.temperature + 10  # target 10° above current
    output = relay_amplitude
    measurements = []
    outputs = []
    times = np.arange(0, duration, dt)

    for t in times:
        measurement = plant.step(output, dt)
        measurements.append(measurement)

        # Relay: full positive when below setpoint, full negative when above
        error = setpoint - measurement
        output = relay_amplitude if error > 0 else -relay_amplitude
        outputs.append(output)

    # Find oscillation parameters from last half of data
    measurements = np.array(measurements)
    half = len(measurements) // 2
    recent = measurements[half:]

    # Peak-to-peak amplitude of oscillation
    amplitude = (recent.max() - recent.min()) / 2

    # Find period by counting zero crossings
    mean_val = recent.mean()
    crossings = np.where(np.diff(np.sign(recent - mean_val)))[0]
    if len(crossings) >= 4:
        periods = np.diff(crossings[::2]) * dt * 2
        Tu = np.mean(periods)
    else:
        return None  # could not determine period

    # Ultimate gain from describing function analysis
    Ku = 4 * relay_amplitude / (np.pi * amplitude)

    # Ziegler-Nichols PID tuning
    return {
        'Ku': Ku, 'Tu': Tu,
        'Kp': 0.6 * Ku,
        'Ki': 1.2 * Ku / Tu,
        'Kd': 0.075 * Ku * Tu
    }

Using the python-control Library

For transfer function analysis and advanced control design:

import control

# Define plant as transfer function: G(s) = K / (tau*s + 1)
K, tau = 40, 30
plant_tf = control.tf([K], [tau, 1])

# PID controller: C(s) = Kp + Ki/s + Kd*s
kp, ki, kd = 2.0, 0.1, 5.0
pid_tf = control.tf([kd, kp, ki], [1, 0])

# Closed-loop transfer function
closed_loop = control.feedback(pid_tf * plant_tf)

# Step response analysis
t, y = control.step_response(closed_loop, T=np.linspace(0, 300, 3000))

# Stability margins
gm, pm, wgc, wpc = control.margin(pid_tf * plant_tf)
print(f"Gain margin: {20*np.log10(gm):.1f} dB, Phase margin: {pm:.1f}°")

Bode Plot Analysis

# Bode plot of open-loop system
control.bode_plot(pid_tf * plant_tf, dB=True)

# Root locus
control.root_locus(pid_tf * plant_tf)

Cascaded PID

Complex systems use multiple PID loops. A motor position controller typically has:

  • Outer loop: Position PID (setpoint = desired angle, measurement = encoder)
  • Inner loop: Velocity PID (setpoint = outer loop output, measurement = tachometer)

The inner loop runs faster (1 kHz) than the outer loop (100 Hz). This cascade responds faster and handles disturbances better than a single position loop.

class CascadePID:
    def __init__(self, outer_gains, inner_gains, dt_outer, dt_inner):
        self.outer = PIDController(*outer_gains, dt=dt_outer)
        self.inner = PIDController(*inner_gains, dt=dt_inner)
        self.inner_ratio = int(dt_outer / dt_inner)

    def update(self, position_setpoint, position, velocity, inner_plant_step):
        velocity_setpoint = self.outer.update(position_setpoint, position)
        for _ in range(self.inner_ratio):
            output = self.inner.update(velocity_setpoint, velocity)
            velocity = inner_plant_step(output)
        return output

Performance Metrics

When comparing tuning configurations, measure:

MetricFormulaGood For
Rise timeTime from 10% to 90% of setpointSpeed
Overshoot(peak - setpoint) / setpoint × 100%Safety
Settling timeTime to stay within ±2% of setpointStability
ISE∫e² dtPenalizes large errors
ITAE∫t·|e| dtPenalizes persistent errors

Automated tuning can minimize these metrics using scipy.optimize:

from scipy.optimize import minimize

def cost_function(params):
    kp, ki, kd = params
    plant = ThermalPlant()
    pid = PIDController(kp, ki, kd, dt=0.1)
    _, temps, _, _ = simulate_pid(plant, pid, 60, 300, 0.1)
    errors = np.array(temps) - 60
    times = np.arange(len(errors)) * 0.1
    return np.sum(times * np.abs(errors) * 0.1)  # ITAE

result = minimize(cost_function, x0=[1, 0.1, 1], bounds=[(0, 10), (0, 1), (0, 20)])
print(f"Optimal gains: Kp={result.x[0]:.3f}, Ki={result.x[1]:.3f}, Kd={result.x[2]:.3f}")

One thing to remember: A production PID controller needs derivative-on-measurement to avoid kick, anti-windup to prevent integral saturation, and derivative filtering to handle sensor noise — three details that separate textbook PID from working PID.

pythonpidcontrol-systemsroboticsautomation

See Also

  • Python Behavior Trees Robotics How robots make decisions using a tree-shaped rulebook that keeps them organized, like a flowchart that tells a robot what to do in every situation.
  • Python Bluetooth Ble How Python connects to fitness trackers, smart locks, and wireless sensors using the invisible radio signals all around you.
  • Python Circuitpython Hardware Why CircuitPython makes wiring up LEDs, sensors, and motors as easy as plugging in a USB drive.
  • Python Computer Vision Autonomous How self-driving cars use cameras and Python to see the road, spot pedestrians, read signs, and understand traffic — like giving a car human eyes and a brain.
  • Python Home Assistant Automation How Python turns your home into a smart home that reacts to you automatically, like a helpful invisible butler.