Skip to content

How the PID Controller Works

This page explains how Pid turns an error into a command, one term at a time: proportional, then derivative, then integral. Two refinements a flight controller cannot do without come after that: filtering the derivative, and anti-windup.

Every animation plays a trace recorded from the controller’s own C++, and the scenario is linked under each player. The three widgets with sliders rerun that C++, compiled to WebAssembly, in your browser. The plants the controller drives are simple models, the same ones its closed-loop tests use. The controls and keys are described on the Trace Player page.

The first three sections fly a height loop. The plant is a double integrator, z¨=u+d\ddot z = u + d. The command uu is an acceleration, and d=−3d = -3 m/s² is an offset the controller does not know about: the vehicle is heavier than the controller believes. The controller sees the height zz, and its setpoint is r=1r = 1 m. It runs at 1 kHz.

The controller computes

u=Kp e+Ki∫e dt−Kd z˙,e=r−zu = K_p\,e + K_i \int e\,dt - K_d\,\dot z, \qquad e = r - z

and the three runs below add one term at a time.

1. Proportional: push in proportion to the error

Section titled “1. Proportional: push in proportion to the error”

The P term pushes toward the setpoint with a force proportional to the distance from it. That is a spring.

Before you play: a spring with nothing to slow it down. Does the height settle at 1 m?

What does a proportional controller alone do to a height? 1× real time
Height [m] P setpoint r + d/k_p
Command [m/s²] P
t = 0.000 s / 10.000 s

Keys (with the player focused): Space play/pause · ← → one sample (Shift: ten) · Home/End · [ ] speed

The vehicle starts at rest at 0 m, and the setpoint is 1 m. With only the P term (k_p = 16 /s²), the command is a spring force. The height overshoots and swings between 0 and 1.63 m with a period of 1.6 s, and keeps swinging: nothing removes energy. The swing is centred below the setpoint, at 0.81 m (dashed), because the P term can only hold up the vehicle's extra weight with an error: at 0.19 m below the setpoint it pushes 16 × 0.19 = 3 m/s².

  1. setpoint 1 m; the vehicle is 3 m/s² heavier than the controller believes
  • ✓ passBB-V1 pd_height_error_vs_closed_form_offset = 1.22e-14 m (<= 0.001), computed by the scenario harness
  • ✓ passBB-T1 pid_steady_state_error = 2.22e-4 m (<= 0.001), computed by the scenario harness

Two problems show. The loop does not settle, and it is centred in the wrong place. The next two terms fix one each.

The D term pushes against the vehicle’s speed, like a shock absorber. Pid takes the rate of the measurement, −Kd z˙-K_d\,\dot z, rather than of the error. While the setpoint is constant the two are equal, but a jump in the setpoint does not make the D term jump. The command still jumps, through the P term, but without a derivative kick on top. This is called derivative on measurement.

Before you play: with damping, the height settles. Where?

What does damping add, and what does it leave? 1× real time
Height [m] P PD setpoint
Command [m/s²] P PD
t = 0.000 s / 10.000 s

Keys (with the player focused): Space play/pause · ← → one sample (Shift: ten) · Home/End · [ ] speed

The same start, with a D term added (k_d = 8 /s). The P-only run swings as before. The PD run rises without overshoot and settles within about 2 s. It settles at 0.81 m, the same offset the P-only run swings about: damping removes the swing, not the offset. The check under the player compares the PD height at 10 s with the closed-form offset r + d/k_p.

  1. setpoint 1 m; the vehicle is 3 m/s² heavier than the controller believes
  • ✓ passBB-V1 pd_height_error_vs_closed_form_offset = 1.22e-14 m (<= 0.001), computed by the scenario harness
  • ✓ passBB-T1 pid_steady_state_error = 2.22e-4 m (<= 0.001), computed by the scenario harness

The gains here give a critically damped loop: s2+Kds+Kps^2 + K_d s + K_p with Kd2=4KpK_d^2 = 4K_p. A smaller KdK_d lets it overshoot, a larger one makes it slower.

The I term adds up the error over time. While any error remains, the integral keeps growing, so the only place the loop can come to rest is where the error is zero. There, the integral alone supplies the 3 m/s² the P term had to hold up with an error.

Before you play: how large does the integral grow?

How does the integral remove the offset? 1× real time

Predict, then try it: set k_d to 0. What happens to the PID run? Then set k_d back to 8 and make k_i large, say 30. For the ideal continuous loop, the rule is that k_d·k_p must exceed k_i.

Showing the recorded run. Moving a slider reruns the same C++ in your browser (WebAssembly).
Height [m] PD PID setpoint
Integral term (PID) [m/s²] I −d
Command [m/s²] PD PID
t = 0.000 s / 10.000 s

Keys (with the player focused): Space play/pause · ← → one sample (Shift: ten) · Home/End · [ ] speed

The same start, with the full PID (k_i = 8 /s³). At first the PID run follows the PD run, and then the integral pulls it on up to the setpoint. It overshoots by 6 %, then settles: within 2 % of the setpoint after 4.1 s, and within 0.3 mm at 10 s. The integral rises to the 3 m/s² the extra weight takes (dashed) and stays there. The P term then contributes nothing, because the error is zero.

  1. setpoint 1 m; the vehicle is 3 m/s² heavier than the controller believes
  • ✓ passBB-V1 pd_height_error_vs_closed_form_offset = 1.22e-14 m (<= 0.001), computed by the scenario harness
  • ✓ passBB-T1 pid_steady_state_error = 2.22e-4 m (<= 0.001), computed by the scenario harness

The integral makes the loop slower to settle and adds overshoot, the price of removing the offset. It is also what can make the loop unstable. For the ideal loop, continuous and with no derivative filter, the characteristic polynomial is s3+Kds2+Kps+Kis^3 + K_d s^2 + K_p s + K_i, which is stable only while KdKp>KiK_d K_p > K_i. The widget’s loop runs at 1 kHz with a 5 ms filter, which moves that boundary a little, so treat the rule as a guide near its edge. With Kd=0K_d = 0 the PID run swings wider and wider. At Ki=30K_i = 30 with Kd=1K_d = 1 it swings too, between −0.9 and 2.2 m, with its command hitting the ±20 m/s² limit, while the PD run settles. The checks under the player then fail, which is the point: the gains are outside what the loop tolerates.

A real sensor is noisy, and the D term differentiates the noise. Height noise of ±1 mm, read at 1 kHz, is a rate of up to ±2 m/s from one sample to the next. Times Kd=8K_d = 8, that is a command that jumps by up to 16 m/s² every millisecond.

Pid filters the measured rate with a first-order low-pass of time constant τ\tau (derivative_tau). The rate is then smooth, but late by about τ\tau.

Before you play: the filtered run uses τ = 5 ms. How much smoother is its command, and how much later does it settle?

How much should the derivative be filtered? 1× real time

Predict, then try it: make τ larger. Up to about 300 ms the step response hardly changes. Past that, watch the overshoot.

Showing the recorded run. Moving a slider reruns the same C++ in your browser (WebAssembly).
Command [m/s²] raw (τ = 0) filtered
Height [m] raw filtered setpoint
Measured rate [m/s] raw filtered
t = 0.000 s / 4.000 s

Keys (with the player focused): Space play/pause · ← → one sample (Shift: ten) · Home/End · [ ] speed

Two copies of the height loop hold 1 m, with ±1 mm of sensor noise from a seeded generator, the same noise in both. At 1 s the setpoint steps to 1.2 m. The raw run (τ = 0) differentiates every sample of noise: its command has an RMS of 6.8 m/s², with peaks of 16 m/s². The filtered run (τ = 5 ms) has a command eight times smaller in RMS, while its height traces the same path. Both overshoot the step by 17 %, from the integral, not the noise. At the setpoint step, each command jumps by 3.2 m/s²: that is the P term, k_p × 0.2 m. There is no derivative kick on top, because the D term reads the measurement, which does not jump.

  1. setpoint 1.2 m

The filter costs nothing while τ\tau is small against the loop’s own time scale, about 1/Kd1/K_d = 0.13 s here. At τ = 0.5 s the damping arrives so late that the overshoot doubles, to 34 %. The cascade’s rate loop, which runs much faster, uses τ = 2 ms, a tenth of the motor’s time constant.

Every actuator has a limit. When the controller asks for more than the actuator can give, the error stays large, and the integral keeps adding it up. When the setpoint finally comes back within reach, the wound-up integral holds the command at the limit until it has unwound. This is integrator windup.

Pid prevents it by conditional integration. When the output is at a limit and the error pushes further into it, the integral is frozen for that step. When the error pulls back, it integrates as usual.

This section flies a different loop: a first-order plant, Tx˙=−x+uT\dot x = -x + u with T = 0.1 s, such as a body rate behind a motor lag. Its actuator saturates at ∣u∣≤1|u| \le 1, so it can hold at most x=1x = 1. Two PI controllers fly two copies of it. One has its output limits set to the actuator’s, so its anti-windup engages. The other has its limits set wide open, so its integral is never stopped, which is what a controller without anti-windup does.

Before you play: the setpoint is 1.5 for 2 s, beyond the plant’s reach, and then 0.5. How long does each run take to get to 0.5?

Why does a saturated integral make the loop slow to recover? 1× real time

Predict, then try it: make the hold time longer. Does the anti-windup run take longer to recover? Does the other one?

Showing the recorded run. Moving a slider reruns the same C++ in your browser (WebAssembly).
Plant output anti-windup no anti-windup setpoint
Controller command anti-windup no anti-windup actuator limit
Controller command near the limit (zoomed) anti-windup no anti-windup actuator limit
Integral term anti-windup no anti-windup
t = 0.000 s / 8.000 s

Keys (with the player focused): Space play/pause · ← → one sample (Shift: ten) · Home/End · [ ] speed

Both runs climb to x = 1, the most the actuator can hold, and sit there. The run without anti-windup keeps integrating the 0.5 error, and its integral reaches 11 by 2 s. Its command, far past the actuator's limit of 1 (dashed), means nothing to the plant. The anti-windup run's integral stops at 0.49, where P and I together just reach the limit. At 2 s the setpoint drops to 0.5. The anti-windup run's command leaves the limit at once, and the output comes within 0.02 of 0.5 after 0.09 s. It dips to 0.43 and is within 0.01 for good by 0.5 s: that is the PI loop's own response, with its low k_p. The other run's command has to unwind from 11.5 to below 1 first, so it stays at the limit and comes within 0.02 of 0.5 only after 2.40 s.

  1. setpoint 1.5, beyond the actuator's reach
  2. setpoint 0.5, within reach
  3. anti-windup: within 0.02 of 0.5, 0.09 s after the drop
  4. no anti-windup: within 0.02 of 0.5, 2.40 s after the drop
  • ✓ passBB-T1 recovery_time_to_within_0.02 = 0.088 s (<= 0.2), computed by the scenario harness

The wound-up integral grows with the time spent at the limit, and so does the recovery: about 1 s after a 0.5 s hold, and 3.5 s after a 3 s hold. The anti-windup run comes within 0.02 of 0.5 in 0.09 s whatever the hold. The check under the player is the closed-loop test’s criterion: within 0.02 of 0.5 no later than 0.2 s after the drop.

A stage after the controller can also cut its command, as the mixer does when it runs out of authority. Pid then needs to be told, or its integral winds up behind that cut. The deployment guide shows it.

  • The developer guide has the control law, the contracts, and the API.
  • The V&V plan lists what is verified, and how.
  • The deployment guide covers choosing limits and the filter, wiring external saturation, and timing.