PID Control in Python: Simulation and Tuning

Implement PID control in Python, simulate with a first-order system, compare P/PI/PID responses, and explore Ziegler-Nichols tuning.

Introduction

PID control is the most widely used feedback control method in industry. It appears in temperature control, motor control, process control, and countless other applications.

For the theoretical foundations of PID control, see Fundamentals of PID Control and the Role of Each Component. In this article, we implement a PID controller in Python, simulate its behavior with a first-order system, compare P/PI/PID step responses, and introduce the classical Ziegler-Nichols tuning method.

Implementing a Discrete PID Controller

The continuous-time PID control law is expressed as:

\[ u(t) = K_P e(t) + K_I \int_0^t e(\tau)d\tau + K_D \frac{de(t)}{dt} \]

where \(e(t) = r(t) - y(t)\) is the error between the setpoint and the output. For computer implementation, we approximate the integral with a cumulative sum and the derivative with a backward difference, yielding the discrete PID control law:

\[ u[k] = K_P e[k] + K_I \Delta t \sum_{i=0}^{k} e[i] + K_D \frac{e[k] - e[k-1]}{\Delta t} \tag{1} \]

where \(\Delta t\) is the sampling period.

The following Python implementation includes output limiting and anti-windup.

class PIDController:
    def __init__(self, kp, ki, kd, dt, output_limits=None):
        self.kp = kp
        self.ki = ki
        self.kd = kd
        self.dt = dt
        self.output_limits = output_limits
        self.integral = 0.0
        self.prev_error = 0.0

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

        # Proportional term
        p_term = self.kp * error

        # Integral term (cumulative sum approximation)
        self.integral += error * self.dt
        i_term = self.ki * self.integral

        # Derivative term (backward difference approximation)
        derivative = (error - self.prev_error) / self.dt
        d_term = self.kd * derivative
        self.prev_error = error

        output = p_term + i_term + d_term

        # Output limiting (anti-windup)
        if self.output_limits is not None:
            lo, hi = self.output_limits
            if output > hi:
                output = hi
                self.integral -= error * self.dt
            elif output < lo:
                output = lo
                self.integral -= error * self.dt

        return output

    def reset(self):
        self.integral = 0.0
        self.prev_error = 0.0

The anti-windup mechanism stops the integral term from accumulating when the output reaches its limits. Without it, the integral value would grow unboundedly during saturation, causing a large overshoot when the constraint is released.

Plant Model

We use a first-order system as the plant for simulation. Its transfer function is:

\[ G(s) = \frac{K}{1 + Ts} \tag{2} \]

where \(K\) is the process gain and \(T\) is the time constant. Discretizing this differential equation gives the following update rule:

\[ y[k+1] = y[k] + \frac{\Delta t}{T}(K \cdot u[k] - y[k]) \tag{3} \]

The Python implementation is as follows.

class FirstOrderSystem:
    def __init__(self, gain, time_constant, dt):
        self.gain = gain
        self.time_constant = time_constant
        self.dt = dt
        self.y = 0.0

    def update(self, u):
        self.y += self.dt / self.time_constant * (self.gain * u - self.y)
        return self.y

    def reset(self):
        self.y = 0.0

Comparing P, PI, and PID Step Responses

We compare the step responses of three controller types. The plant has \(K=1.0\) and \(T=1.0\).

import numpy as np
import matplotlib.pyplot as plt

dt = 0.01
t_end = 10.0
t = np.arange(0, t_end, dt)
setpoint = np.ones_like(t)  # Step input

plant_params = {'gain': 1.0, 'time_constant': 1.0, 'dt': dt}

configs = [
    ('P control (Kp=2.0)', {'kp': 2.0, 'ki': 0.0, 'kd': 0.0}),
    ('PI control (Kp=2.0, Ki=1.0)', {'kp': 2.0, 'ki': 1.0, 'kd': 0.0}),
    ('PID control (Kp=2.0, Ki=1.0, Kd=0.5)', {'kp': 2.0, 'ki': 1.0, 'kd': 0.5}),
]

fig, axes = plt.subplots(2, 1, figsize=(10, 8), sharex=True)

for label, params in configs:
    pid = PIDController(**params, dt=dt)
    plant = FirstOrderSystem(**plant_params)
    y_hist = []
    u_hist = []

    for sp in setpoint:
        u = pid.update(sp, plant.y)
        y = plant.update(u)
        y_hist.append(y)
        u_hist.append(u)

    axes[0].plot(t, y_hist, label=label)
    axes[1].plot(t, u_hist, label=label)

axes[0].axhline(y=1.0, color='k', linestyle='--', alpha=0.5, label='Setpoint')
axes[0].set_ylabel('Output y(t)')
axes[0].set_title('Step Response Comparison')
axes[0].legend()
axes[0].grid(True, alpha=0.3)

axes[1].set_ylabel('Control input u(t)')
axes[1].set_xlabel('Time [s]')
axes[1].legend()
axes[1].grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

From the simulation results, the following characteristics can be observed:

  • P control: The response is fast, but a steady-state error remains. With \(K_P = 2.0\), the theoretical steady-state error is \(\frac{1}{1 + K \cdot K_P} = \frac{1}{3} \approx 0.33\).
  • PI control: The integral term eliminates the steady-state error. However, overshoot tends to increase.
  • PID control: The derivative term suppresses overshoot, achieving both the fast response of P control and the steady-state accuracy of PI control.

Effect of Gain Parameters

The setting of each gain parameter significantly affects control performance.

Excessive Proportional Gain \(K_P\)

Setting \(K_P\) too large makes the response oscillatory and eventually unstable. For example, with P control at \(K_P = 10.0\), the output oscillates around the setpoint.

Integral Gain \(K_I\) and Windup

When \(K_I\) is large or the control output is constrained, the integral term can accumulate excessively, a phenomenon called “windup.” While the output is saturated, the integral value keeps growing, causing a large overshoot after the constraint is released. The anti-windup mechanism described earlier mitigates this problem.

Derivative Gain \(K_D\) and Noise Sensitivity

Since the derivative term uses the rate of change of the error, it is sensitive to measurement noise. Setting a large \(K_D\) on a noisy signal causes rapid fluctuations in the control output. In practice, a low-pass filter on the derivative input, known as “filtered derivative,” is commonly used.

Parameter Tuning Guidelines

ParameterEffect of IncreasingCaution
\(K_P\)Faster response, reduced steady-state errorOscillation and instability if too large
\(K_I\)Eliminates steady-state errorIncreased overshoot, windup
\(K_D\)Suppresses overshoot, improves responseSensitive to noise

Ziegler-Nichols Tuning Method

The Ziegler-Nichols method is a classical approach for empirically determining PID gains from the plant’s step response characteristics. Here we introduce the step response method (open-loop method).

Step Response Method

A step input is applied to the plant, and the following three parameters are read from the response curve:

  • \(K\): Process gain (steady-state value / input value)
  • \(L\): Dead time (delay before the response begins)
  • \(T\): Time constant (time from the inflection point tangent to reaching the steady-state value)

Based on these parameters, PID gains are calculated using the following table.

Controller\(K_P\)\(T_I\)\(T_D\)
P\(\frac{T}{KL}\)--
PI\(\frac{0.9T}{KL}\)\(\frac{L}{0.3}\)-
PID\(\frac{1.2T}{KL}\)\(2L\)\(0.5L\)

Here, \(T_I\) is the integral time and \(T_D\) is the derivative time, with the relationships \(K_I = K_P / T_I\) and \(K_D = K_P \cdot T_D\).

The Ziegler-Nichols method tends to produce oscillatory responses as its starting point, so the resulting gains often lead to significant overshoot. In practice, these values are used as initial settings and then fine-tuned manually.

Disturbance Rejection Simulation

In real control systems, disturbances (unexpected external inputs) affect control performance. Here we add a step disturbance midway through the simulation and compare the disturbance rejection of P control and PID control.

import numpy as np
import matplotlib.pyplot as plt

dt = 0.01
t_end = 20.0
t = np.arange(0, t_end, dt)
setpoint = np.ones_like(t)

# Disturbance: step of magnitude 0.5 at t=10s
disturbance = np.zeros_like(t)
disturbance[t >= 10.0] = 0.5

plant_params = {'gain': 1.0, 'time_constant': 1.0, 'dt': dt}

configs = [
    ('P control (Kp=2.0)', {'kp': 2.0, 'ki': 0.0, 'kd': 0.0}),
    ('PID control (Kp=2.0, Ki=1.0, Kd=0.5)', {'kp': 2.0, 'ki': 1.0, 'kd': 0.5}),
]

fig, axes = plt.subplots(2, 1, figsize=(10, 8), sharex=True)

for label, params in configs:
    pid = PIDController(**params, dt=dt)
    plant = FirstOrderSystem(**plant_params)
    y_hist = []
    u_hist = []

    for i, sp in enumerate(setpoint):
        u = pid.update(sp, plant.y)
        # Add disturbance to plant input
        y = plant.update(u + disturbance[i])
        y_hist.append(y)
        u_hist.append(u)

    axes[0].plot(t, y_hist, label=label)
    axes[1].plot(t, u_hist, label=label)

axes[0].axhline(y=1.0, color='k', linestyle='--', alpha=0.5, label='Setpoint')
axes[0].axvline(x=10.0, color='r', linestyle=':', alpha=0.5, label='Disturbance onset')
axes[0].set_ylabel('Output y(t)')
axes[0].set_title('Disturbance Rejection Comparison')
axes[0].legend()
axes[0].grid(True, alpha=0.3)

axes[1].set_ylabel('Control input u(t)')
axes[1].set_xlabel('Time [s]')
axes[1].legend()
axes[1].grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

The simulation results show the following:

  • P control: It provides some disturbance suppression, but a steady-state error due to the disturbance remains.
  • PID control: Thanks to the integral term, the output eventually returns to the setpoint even after the disturbance is applied. The derivative term also improves the initial response to the disturbance.

Summary

In this article, we implemented a PID controller in Python and verified its behavior through simulation with a first-order system. By comparing P, PI, and PID step responses, we experimentally confirmed the role and effect of each component.

PID control is simple in structure yet achieves high control performance with proper tuning. However, for systems with strong nonlinearity or multiple inputs and outputs, more advanced control methods become necessary.

References

  • Astrom, K. J., & Murray, R. M. (2021). Feedback Systems: An Introduction for Scientists and Engineers (2nd ed.). Princeton University Press.
  • Ziegler, J. G., & Nichols, N. B. (1942). “Optimum settings for automatic controllers”. Transactions of the ASME, 64(11), 759-768.