Cheat sheet

PID tuning cheat sheet

A PID tuning recipe for robot joints, the Ziegler-Nichols rules and their limits, the discrete PID update with derivative filtering, anti-windup and setpoint weighting, and a simulation that compares them.

Last verified 14 Nov 2026· NumPy 2.5

This sheet is for anyone bringing up a joint, a wheel or a gimbal loop who wants a tuning that is good enough to build on. It gives the controller form we recommend for code, a step-by-step recipe, the Ziegler-Nichols starting points with their limits, and one simulation that shows what each fix buys. For the concepts, start with the PID control lesson and the glossary entries PID controller and step response.

The controller you should write

Form Expression Notes
Parallel (gains) u = Kp e + Ki integral(e) + Kd de/dt What most code and drives expose
Standard (times) u = Kp (e + integral(e) / Ti + Td de/dt) Ki = Kp / Ti, Kd = Kp Td. Tuning rules are written in this form
With setpoint weight u = Kp (beta r - y) + Ki integral(r - y) - Kd dy/dt beta between 0 and 1 shapes the reference response without changing load rejection. The integral must act on the full error

Discrete version at sample time Ts, the one in the code below:

e_k   = r_k - y_k
P_k   = Kp (beta r_k - y_k)
D_k   = a D_(k-1) - (1 - a) Kd (y_k - y_(k-1)) / Ts      a = Tf / (Tf + Ts),  Tf = Td / N
u_raw = P_k + I_k + D_k
u_k   = clamp(u_raw, -u_max, u_max)
I_(k+1) = I_k + Ki Ts e_k     unless u_k is saturated and e_k pushes further into saturation

Three details matter more than the gains.

  • Derivative on the measurement, not the error. A step in r then gives no derivative kick.
  • A low-pass filter on the derivative. Åström and Murray give Tf = Td / N with N between 5 and 20. The code uses N = 10.
  • Anti-windup. Clamping (conditional integration) is shown here. Back-calculation feeds the saturation error back to the integrator through a gain, which Åström and Murray suggest choosing as a multiple of Ki.

A tuning recipe for a robot joint

  1. Check the sign. Command a small positive output and confirm the joint moves in the positive direction of its encoder. A wrong sign looks like instability at any gain.
  2. Set limits first: output saturation, a position window, a current limit in the drive. Tune with the real limits in place, because saturation changes the response.
  3. Start with P only. Raise Kp until the response is fast but has visibly too much overshoot, then halve it. If you need a number to start from, use the ultimate-gain test below.
  4. Add D to damp the overshoot. Raise Td until the overshoot drops or the motor gets noisy. Noise means N is too large or the encoder is too coarse for this much D.
  5. Add I last, for load rejection. Start with a long Ti (a slow integral) and shorten it until a load step recovers fast enough. Keep anti-windup on from the first test.
  6. If the reference response now overshoots but load recovery is right, lower beta rather than the gains. Robots usually follow a smooth trajectory, so a ramped reference does the same job.
  7. Retune at the extremes: full payload, arm extended, cold and hot. Gains tuned on a light, folded arm are optimistic.

Ziegler-Nichols

The two classic rules from 1942, as tabulated by Åström and Murray (Table 11.1).

Controller Step response method: Kp, Ti, Td Ultimate gain method: Kp, Ti, Td
P 1/a 0.5 Ku
PI 0.9/a, tau/0.3 0.45 Ku, Tu/1.2
PID 1.2/a, tau/0.5, 0.5 tau 0.6 Ku, Tu/2, Tu/8

In the step response method, a and tau come from the steepest tangent of the open-loop step response: tau is the apparent delay and a/tau the steepest slope. In the ultimate gain method, you raise a P-only gain until the loop oscillates steadily. Ku is that gain and Tu the period of the oscillation.

Caveats before you use them on a robot:

  • Åström and Murray name two drawbacks: the rules use too little process information, and the resulting loops lack robustness. Expect overshoot of tens of per cent, as in the simulation below.
  • Driving a real arm to sustained oscillation can damage gears and hit end stops. Do it in simulation, on a blocked joint with tiny amplitude, or with a relay test that limits the amplitude.
  • A position loop around a motor already contains an integrator. Integral action then adds overshoot to every reference step, so expect to detune I or use setpoint weighting.
  • Sampling and filtering add delay, so the measured Ku on a digital loop is lower than the continuous-time value.

Simulation

A joint angle driven through a motor lag (0.1 s) and an amplifier lag (0.02 s), so G(s) = 1 / (s (0.1 s + 1)(0.02 s + 1)). The loop runs at 1 kHz with an output limit of 20. A constant load acts from t = 1.5 s. The block finds Ku and Tu by bisection on a P-only loop, applies Ziegler-Nichols, then compares anti-windup, setpoint weighting and a hand-detuned set. Overshoot and settling (2 % band) are measured on the reference step before the load arrives.

import numpy as np

DT = 0.001                      # controller period (s), 1 kHz
T_M, T_A, K = 0.10, 0.02, 1.0   # motor and amplifier time constants (s), plant gain
U_MAX = 20.0                    # actuator limit
LOAD = 2.0                      # constant load disturbance applied from t = 1.5 s


def plant_step(x: np.ndarray, u: float, load: float, sub: int = 10) -> np.ndarray:
    """Joint angle: K / (s (T_M s + 1)(T_A s + 1)), load enters at the motor."""
    pos, vel, drive = x
    h = DT / sub
    for _ in range(sub):
        drive += h * (K * u - drive) / T_A
        vel += h * (drive - load - vel) / T_M
        pos += h * vel
    return np.array([pos, vel, drive])


def make_pid(kp: float, ki: float, kd: float, tf: float, u_max: float, anti_windup: bool = True, beta: float = 1.0):
    s = {"i": 0.0, "d": 0.0, "y_prev": None}
    alpha = tf / (tf + DT)                        # first-order low-pass on the derivative

    def step(r: float, y: float) -> float:
        e = r - y
        y_prev = y if s["y_prev"] is None else s["y_prev"]
        s["d"] = alpha * s["d"] - (1 - alpha) * kd * (y - y_prev) / DT   # derivative on measurement
        s["y_prev"] = y
        u_unsat = kp * (beta * r - y) + s["i"] + s["d"]   # setpoint weight beta on P
        u = float(np.clip(u_unsat, -u_max, u_max))
        if not anti_windup or u == u_unsat or np.sign(e) != np.sign(u_unsat):
            s["i"] += ki * e * DT                 # clamping: freeze I while saturated and pushing further
        return u

    return step


def simulate(pid, t_end: float = 3.0, r: float = 1.0, load_on: bool = True) -> np.ndarray:
    x, ys = np.zeros(3), []
    for k in range(int(t_end / DT)):
        x = plant_step(x, pid(r, x[0]), LOAD if (load_on and k * DT >= 1.5) else 0.0)
        ys.append(x[0])
    return np.array(ys)


def growing(kp: float) -> bool:
    y = simulate(make_pid(kp, 0, 0, 1e-3, 1e9), t_end=4.0, r=0.01, load_on=False) - 0.01
    return np.abs(y[2000:]).max() >= np.abs(y[1000:2000]).max()


lo, hi = 1.0, 200.0
for _ in range(30):                               # bisection on P-only gain
    mid = 0.5 * (lo + hi)
    lo, hi = (lo, mid) if growing(mid) else (mid, hi)
ku = hi
y = simulate(make_pid(ku, 0, 0, 1e-3, 1e9), t_end=4.0, r=0.01, load_on=False) - 0.01
tu = np.diff(np.where(np.diff(np.sign(y[1000:])) > 0)[0]).mean() * DT
print(f"Ku = {ku:.1f} (continuous-time analytic {(T_M + T_A) / (T_M * T_A * K):.1f}), Tu = {tu:.3f} s")


def report(name: str, y: np.ndarray) -> None:
    step = y[:1500]
    outside = np.where(np.abs(step - 1.0) > 0.02)[0]
    settle = (outside[-1] + 1) * DT if len(outside) else 0.0
    print(f"{name:30s} overshoot {max(0.0, 100 * (step.max() - 1)):5.1f} %  settling {settle:.2f} s  "
          f"error at 3 s {1 - y[-1]:+.4f} rad")



kp, ti, td = 0.6 * ku, 0.5 * tu, 0.125 * tu       # classic Ziegler-Nichols PID
report("Z-N, no anti-windup", simulate(make_pid(kp, kp / ti, kp * td, td / 10, U_MAX, False)))
y_zn = simulate(make_pid(kp, kp / ti, kp * td, td / 10, U_MAX))
report("Z-N, clamping", y_zn)
report("Z-N, clamping, beta = 0", simulate(make_pid(kp, kp / ti, kp * td, td / 10, U_MAX, beta=0.0)))
kp2, ti2, td2 = 0.5 * ku, 1.0 * tu, 0.2 * tu      # detuned by hand from the Z-N start
y_tuned = simulate(make_pid(kp2, kp2 / ti2, kp2 * td2, td2 / 10, U_MAX, beta=0.3))
report("tuned, clamping, beta = 0.3", y_tuned)
for name, y in (("Z-N", y_zn), ("tuned", y_tuned)):
    print(name, [[round(k * DT, 2), round(float(y[k - 1]), 3)] for k in range(60, 3001, 60)])

Output, first lines:

Ku = 58.7 (continuous-time analytic 60.0), Tu = 0.284 s
Z-N, no anti-windup            overshoot  83.8 %  settling 1.07 s  error at 3 s -0.0002 rad
Z-N, clamping                  overshoot  41.0 %  settling 0.87 s  error at 3 s -0.0002 rad
Z-N, clamping, beta = 0        overshoot  16.0 %  settling 0.86 s  error at 3 s -0.0002 rad
tuned, clamping, beta = 0.3    overshoot   0.0 %  settling 0.64 s  error at 3 s +0.0000 rad

The measured Ku is 58.7 against 60.0 for the continuous plant, because of the 1 ms sample delay. The output limit of 20 is below the initial proportional action (0.6 Ku times 1 rad is about 35), so the actuator saturates at the start of every step. Without anti-windup the integral keeps growing during saturation and overshoot doubles. Clamping fixes most of that. Setpoint weighting removes more without changing how the loop handles the load. The detuned set (0.5 Ku, Ti = Tu, Td = 0.2 Tu, beta = 0.3) reaches the 2 % band in 0.64 s with no overshoot and still removes the load error.

0.510.511.522.53Z-N, clampingtuned, beta = 0.3joint angle (rad)time (s)
Joint angle for a 1 rad step at t = 0 and a load step at t = 1.5 s, for the Ziegler-Nichols gains with clamping and for the hand-tuned gains with setpoint weight 0.3. Points every 60 ms, printed by the block above.

Symptoms and fixes

Symptom Likely cause Fix
Slow oscillation that grows after a large move Integral windup Anti-windup, or limit the integral state
Fast buzzing, hot motor, audible noise Too much D, or N too large, or encoder quantisation Lower Td or N, filter the measurement, raise the encoder resolution
Steady error that never goes away No I, or I frozen by a clamp, or static friction Add or check I, add friction feedforward
Small oscillation around the target Static friction with integral action (stick-slip), or backlash Deadband on I, friction compensation, reduce Ki
Overshoot only on reference steps, fine on load steps Integral acting on a step reference Setpoint weighting, or ramp the reference
Gains that worked last week now oscillate Different payload, temperature, firmware loop rate Retest at the extremes, gain schedule if needed

Further reading