Learning Goals 5 min
Bang-bang control overshoots and oscillates. PID — Proportional + Integral + Derivative — is the smoother controller used in cruise control, drones, 3D printer heaters, balancing robots, industrial process control. The same three-term formula has been industry standard for over a century. By the end of this lesson you will:
- Explain what each of the three terms (P, I, D) contributes intuitively.
- Implement a basic PID controller in Arduino C, with non-blocking sample timing.
- Tune the three gains (Kp, Ki, Kd) using the "manual" method — and know when to reach for the PID_v1 / QuickPID libraries.
Warm-Up 10 min
No hardware. We'll think through the algorithm conceptually, then code it.
The intuition
PID looks at three things:
- P (proportional): how big is the error now? Bigger error → bigger reaction.
- I (integral): how much error has accumulated over time? Persistent small offsets eventually trigger a response.
- D (derivative): how fast is the error changing? Damps overshoot — if you're approaching the target fast, ease off.
Output = Kp·P + Ki·I + Kd·D, where each K is a tunable gain.
New Concept · PID in code 25 min
The minimal PID class
class PID {
public:
PID(float kp, float ki, float kd)
: kp_(kp), ki_(ki), kd_(kd),
integral_(0), lastError_(0), lastTime_(0) {}
float update(float setpoint, float measurement) {
unsigned long now = millis();
if (lastTime_ == 0) { lastTime_ = now; return 0; }
float dt = (now - lastTime_) / 1000.0;
if (dt <= 0) return 0;
lastTime_ = now;
float error = setpoint - measurement;
integral_ += error * dt;
float derivative = (error - lastError_) / dt;
lastError_ = error;
return kp_ * error + ki_ * integral_ + kd_ * derivative;
}
void setGains(float kp, float ki, float kd) { kp_ = kp; ki_ = ki; kd_ = kd; }
void reset() { integral_ = 0; lastError_ = 0; lastTime_ = 0; }
private:
float kp_, ki_, kd_;
float integral_, lastError_;
unsigned long lastTime_;
};One update(setpoint, measurement) call per loop iteration. Returns a control output number.
Using it for a temperature controller
PID tempPID(2.0, 0.5, 1.0); // initial gains
void loop() {
float t = readTemp();
float u = tempPID.update(80.0, t); // setpoint 80 °C
int duty = constrain((int)u, 0, 255);
analogWrite(HEATER_PIN, duty);
delay(100);
}The output u can be any number — clamp to your actuator's range. For a heater (PWM 0..255), use constrain. For a bidirectional motor, the sign of u = direction.
Tuning method 1 — manual
Standard practice:
- Start with Kp small, Ki = 0, Kd = 0.
- Increase Kp until the system oscillates. Halve.
- Add a little Kd to dampen overshoot.
- Add Ki if there's persistent steady-state error.
Tuning method 2 — Ziegler-Nichols
Engineering classic. Set Ki = Kd = 0. Increase Kp until you get sustained oscillation. Note the gain (Ku) and period (Tu). Then:
- P only: Kp = 0.5 Ku.
- PI: Kp = 0.45 Ku, Ki = 1.2 Kp / Tu.
- PID: Kp = 0.6 Ku, Ki = 2 Kp / Tu, Kd = Kp Tu / 8.
Heuristic, not optimal, but a fast first cut. Tune up from there.
Anti-windup
If the actuator is saturated (e.g. motor at 100% duty but still not reaching setpoint), the integral keeps growing indefinitely. When the error finally drops, the accumulated integral causes a long overshoot. The fix is "anti-windup": stop accumulating integral while the actuator is saturated.
if (output > MAX_OUT) { output = MAX_OUT; /* don't grow integral */ }
else if (output < MIN_OUT) { output = MIN_OUT; }
else { integral_ += error * dt; }Real libraries (PID_v1, QuickPID) handle anti-windup, output limits, and derivative-on-measurement (a refinement that ignores setpoint changes in the D term) for you.
Worked Example · PID-controlled fan speed 25 min
You want to hold a fan at a target speed (in RPM) regardless of how dusty it is. Open-loop: analogWrite a duty cycle. Closed-loop: read a tachometer pulse from the fan, measure RPM, PID controls duty.
Pseudo-build
- Fan with 4-wire connector (PWM + tach input).
- PWM out from Arduino → fan's PWM pin.
- Tach pulse counted via interrupt on D2.
- PID at 10 Hz.
Sketch outline
#include "PID.h"
const int PWM_PIN = 9;
const int TACH_PIN = 2;
volatile unsigned int pulses = 0;
PID pid(0.5, 0.2, 0.05);
void onPulse() { pulses++; }
void setup() {
Serial.begin(115200);
pinMode(PWM_PIN, OUTPUT);
pinMode(TACH_PIN, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(TACH_PIN), onPulse, RISING);
}
void loop() {
static unsigned long lastSample = 0;
unsigned long now = millis();
if (now - lastSample < 100) return;
float dt = (now - lastSample) / 1000.0;
lastSample = now;
noInterrupts();
unsigned int p = pulses;
pulses = 0;
interrupts();
// 4-wire PC fans give 2 pulses per revolution
float rpm = (p / dt) * 60.0 / 2.0;
float u = pid.update(2000.0, rpm);
int duty = constrain((int)u, 0, 255);
analogWrite(PWM_PIN, duty);
Serial.print("rpm="); Serial.print(rpm);
Serial.print(" duty="); Serial.println(duty);
}Tuning the fan
- Run with Ki = Kd = 0. Increase Kp: 0.2 → 0.5 → 1.0. Watch for the moment it oscillates around 2000 RPM.
- Halve Kp. Now smooth but probably 50 RPM off setpoint.
- Add Ki = 0.1. Steady-state error should slowly fade.
- Add Kd = 0.05. Tames overshoot when setpoint changes quickly.
Final tuning depends on the fan + sensor + sample rate. Plot the RPM vs setpoint over a few minutes to see the response.
Basic 5 min
Goal: Hand-trace the PID output for a heater starting at 22 °C with setpoint 50 °C and Kp = 5, Ki = 0, Kd = 0. Compute the first 3 outputs over dt = 1 s each (assume temperature rises 1 °C per output of 50 in 1 s — a hypothetical model).
Challenge 1 5 min
Goal: Install the PID_v1 library (Brett Beauregard's industry-standard one). Replace your hand-rolled PID class. Note the API differences.
Challenge 2 5 min
Goal: Add a Serial-tunable PID: type kp 2.0 / ki 0.5 / kd 0.1 in the monitor to adjust gains live. Plot the response with the Arduino Serial Plotter while you tune. The classic engineer's workflow.
Challenge 3 · Fix a broken PID class 10 min
A classmate typed the PID class from memory. It has three bugs. Each one makes a P, I or D term misbehave.
class PID {
public:
PID(float kp, float ki, float kd)
: kp_(kp), ki_(ki), kd_(kd),
integral_(0), lastError_(0), lastTime_(0) {}
float update(float setpoint, float measurement) {
unsigned long now = millis();
if (lastTime_ == 0) {
lastTime_ = now;
return 0;
}
float dt = now - lastTime_;
if (dt <= 0) {
return 0;
}
float error = measurement - setpoint;
integral_ += error * dt;
float derivative = (error - lastError_) / dt;
lastError_ = error;
return kp_ * error + ki_ * integral_ + kd_ * derivative;
}
private:
float kp_;
float ki_;
float kd_;
float integral_;
float lastError_;
unsigned long lastTime_;
};- Compare it with the class in §3. Find all three bugs.
- For each, say which term it breaks and what you would see.
- Fix it, and use it to run your L04-23 light-keeper with setpoint 500 and gains 0.2, 0.5, 0.
It works if the light-keeper settles at 500 in the Serial Plotter, with no steady gap left over.
Recap 5 min
PID = three terms acting on the error and its derivatives. Tune in this order: Kp, Kd, Ki. Anti-windup the integral. Smooth noisy measurements before computing D. For production, use PID_v1 / QuickPID library. PID lives in cruise control, drones, 3D printer heaters, factory floor regulators. Tomorrow we apply PID to a real moving target — line-following.
- PID
- Proportional + Integral + Derivative controller. The most widely-used closed-loop control algorithm.
- Proportional (P)
- Output proportional to current error. Gain Kp. Bigger gain = faster but more overshoot.
- Integral (I)
- Accumulates error over time. Eliminates steady-state offset. Gain Ki.
- Derivative (D)
- Reacts to how fast error is changing. Damps overshoot. Gain Kd. Amplifies sensor noise.
- Setpoint, error, output
- Setpoint = target; error = setpoint − measurement; output = controller's action signal.
- Anti-windup
- Preventing the integral term from growing while the actuator is saturated. Avoids long overshoots after disturbances.
- Ziegler-Nichols tuning
- Classic heuristic for setting Kp/Ki/Kd from an oscillation test.
- Sample period
- How often the controller runs. Should be much faster than the system's natural time constant.
- PID_v1 library
- Brett Beauregard's industry-standard Arduino PID library. Handles dt, anti-windup, modes.
Extra Mission 5 min
Part 1 — Design a PID-controlled gadget
Pick something that needs smooth, steady control, such as a fan that holds its speed. Design its PID loop on paper.
Your design must include:
- The setpoint, the sensor, and the actuator with its output range.
- How often the loop samples, in Hz.
- Your starting gains and your tuning plan.
- What you would see if Kp were too high, if Ki were missing, and if Kd were too high.
Part 2 — Make it
Build it with the fixed PID class from Challenge 3. No fan with a tachometer? Use two pots instead, one as the "measurement" and one as the "setpoint". Drive an LED's brightness. Tune the gains until it settles quickly with little overshoot.
Bring back next class: the uploaded sketch, your final gains, and a Serial Plotter screenshot. For ARD-L04-25, bring IR reflectance sensors (3 × TCRT5000 or a QTR array).