Detailed content
🔁 Why on/off isn’t enough
The simplest control is on/off: below the target, the motor runs at full power; above it, the motor shuts off. This works for an iron, which heats up slowly. In a robot, which reacts quickly, the result is an endless back-and-forth: the wheel overshoots the target, slows down, falls below it, then speeds up all over again. The line follower “dances” along the track and misses the curves. It needs a proportional: make a small correction when the error is small and a large one when it’s big.
🌱 New here?
O target (setpoint) is the value you want, such as 150 RPM. The measurement is what the sensor says is happening. The error is the target minus the measurement. The output is what the controller sends to the actuator—in this case, the motor’s PWM signal. Closed loop is the cycle in which the measurement feeds back to correct the output.
How to read this map: The amber line is the target in both graphs. On the left, the speed reaches the target and immediately overshoots because the motor only knows “all or nothing.” On the right, PID reduces the power as it gets closer: it takes a small step past the target, then settles on it.
✓ On/off works for
- ✓Slow systems: a heater, a water tank pump.
- ✓State decisions: stopping when an obstacle is detected.
✗ PID is needed for
- ✗Wheel speed, steering in a line follower.
- ✗Balance, arm position, turning exactly 90°.
Why learn this
PID is used for car speed control, in drones, 3D printers, and the arm in Trail 5. Understanding why on/off control fails means understanding the problem PID solves.
Key concepts
🏎️ P, I, and D explained with a car on the track
Imagine you’re driving and want to stay in the middle of the lane. The P (proportional) term is turning the steering wheel based on how far you are from the middle: turn a lot when you’re far away, a little when you’re close. With only P, you’ll zigzag. The D (derivative) term looks at the speed at which you’re approaching the middle and eases off the steering before you pass it: it dampens the zigzag. The I (integral) term notices that with a side wind, you’re always a little to the right, and keeps adding up that error until it compensates for it.
🌱 New here?
A integral of the error is the sum of the error over time: a small error that doesn’t go away becomes a large sum. The derivative of the error is how quickly it changes. The gains Kp, Ki, and Kd are the three numbers you adjust: how much weight to give each term. Output = Kp·error + Ki·sum + Kd·change.
How to read this map: All three curves respond to the same request: “speed up to the target.” The gray one (P only) stops short: a steady error remains. The cyan one (P + I) eliminates that error, but the accumulated sum causes it to overshoot and wobble. The blue one (PID) uses D to slow the approach and settles sooner.
| Increase… | Response speed | Overshoot | Remaining error |
|---|---|---|---|
| Kp | faster | more | decreases, but doesn’t disappear |
| Ki | a little faster | more | disappears |
| Kd | changes little | less | doesn’t change (amplifies noise) |
Why learn this
When you understand what each term does, you can tune the gains by looking at the graph: “overshot too much, add D”; “fell short, add I.” Without that understanding, you just change numbers at random.
Key concepts
💻 PID in code (fixed time loop)
In code, PID is a function called at the same interval, such as every 10 ms. A fixed interval matters: the integral sums error × time, and the derivative divides by time. If the interval varies, the gains change their effect on their own. That’s why the loop uses micros() and a scheduled “next time,” instead of delay().
How to read this map: Follow the blue arrows from left to right: target, error, PID, PWM, motor. The dashed cyan arrow shows the measurement feeding back. In the code below, “motor + encoder” is simulated in two lines so you can experiment with the gains without hardware; in topic 6, it becomes a real motor.
Goal: run a complete PID at 100 Hz against a simulated motor, with the target alternating between 120 and 220 RPM every 4 s and an “uphill” section that slows the wheel starting at 12 s. You can change the gains by typing in the Serial Monitor (e.g., p3, i5, d0.05). Runs in Wokwi with just an ESP32.
// PID against a simulated motor: understand the gains without hardware (esp32 3.x package)
struct PID {
float kp = 1.0, ki = 0.0, kd = 0.0; // start with P only
float saidaMin = 0, saidaMax = 255; // PWM limits
float integral = 0, dFiltrada = 0, medidaAnt = 0;
bool primeira = true;
float calcula(float alvo, float medida, float dt) {
float erro = alvo - medida;
if (primeira) { medidaAnt = medida; primeira = false; }
float dBruta = -(medida - medidaAnt) / dt; // derivative of the MEASUREMENT: no jolt when the target changes
medidaAnt = medida;
dFiltrada += 0.2 * (dBruta - dFiltrada); // low-pass filter on the derivative
float novaIntegral = integral + ki * erro * dt;
float saida = kp * erro + novaIntegral + kd * dFiltrada;
if (saida > saidaMax) { saida = saidaMax; if (erro < 0) integral = novaIntegral; } // anti-windup
else if (saida < saidaMin) { saida = saidaMin; if (erro > 0) integral = novaIntegral; }
else integral = novaIntegral;
return saida;
}
};
PID pid;
const unsigned long PERIODO_US = 10000; // 10 ms = 100 Hz
const float DT = PERIODO_US / 1e6;
float vel = 0; // simulated speed, in RPM
void motorSimulado(float pwm) { // first-order motor: tends toward 1.2 x PWM in ~0.3 s
float carga = (millis() > 12000) ? 40 : 0; // starting at 12 s, an uphill section slows the wheel
vel += ((1.2 * pwm - carga) - vel) * DT / 0.3;
}
void setup() {
Serial.begin(115200);
Serial.setTimeout(20);
Serial.println("Enter p, i, or d followed by the value. E.g.: p3");
}
void loop() {
static unsigned long proximo = micros();
if ((long)(micros() - proximo) < 0) return; // the next scheduled time has not arrived yet
proximo += PERIODO_US;
if (Serial.available()) {
char g = Serial.read();
float v = Serial.parseFloat();
if (g == 'p') pid.kp = v; else if (g == 'i') pid.ki = v; else if (g == 'd') pid.kd = v;
}
float alvo = ((millis() / 4000) % 2) ? 220 : 120;
float pwm = pid.calcula(alvo, vel, DT);
motorSimulado(pwm);
static int n = 0;
if (++n % 5 == 0) Serial.printf("alvo:%.0f,vel:%.1f,pwm:%.0f\n", alvo, vel, pwm);
}
How to check
- ✓In the Serial Plotter, with P = 1 only: “vel” stays well below “alvo” (remaining error).
- ✓Enter i5: “vel” reaches the target; after 12 s, the load pulls the speed down and I brings it back up.
- ✓Enter p8: the response becomes jumpy and overshoots the target. Go back to p2.
Why learn this
This PID structure is the one you'll copy for the line follower in 2.6, the turn control for the object-following robot in 3.6, and anything else that needs to “reach and stay.” Testing with a simulation first separates code errors from assembly errors.
Key concepts
🎛️ Step-by-step tuning (simplified Ziegler-Nichols, by hand)
Tuning means choosing Kp, Ki, and Kd for your robot. There is no universal number: it depends on the motor, wheel, weight, and battery. There are two complementary approaches. The Ziegler-Nichols method gives you a starting point based on a single test; manual tuning, using the Serial Plotter, fine-tunes it.
Manual tuning, in the right order
Set I and D to zero
Start with i0 e d0. Lift the robot so the wheels are off the ground.
Increase P until it almost oscillates
Double Kp until the response starts to wobble, then go back to half that value.
Add I gradually
Increase Ki until the remaining error disappears in less than a second, without too much wobbling.
Use D only if needed
If it overshoots the target by a lot, a little Kd can dampen the response. For wheel speed, PI is often enough.
Test on the floor and take notes
The robot's weight changes the response on the floor, so tune it again and note the final gains in the code.
Simplified Ziegler-Nichols: with I and D at zero, increase Kp until the speed oscillates at a steady rate, without growing or dying out. This is the critical gain Ku. Measure the time for one complete oscillation in the Serial Plotter: this is the period Tu, in seconds. The table gives you the initial gains.
| Controller | Kp | Ki | Kd |
|---|---|---|---|
| P | 0.5 · Ku | 0 | 0 |
| PI | 0.45 · Ku | 0.54 · Ku / Tu | 0 |
| PID | 0.6 · Ku | 1.2 · Ku / Tu | 0.075 · Ku · Tu |
💡 Ziegler-Nichols is aggressive
The gains in the table usually produce a fast response that overshoots the target by a lot. Use them as a starting point and reduce Kp and Ki by about 20 to 30% if the robot gets twitchy. The calculation helps you get started; it doesn't replace testing.
✓ Tuning that works
- ✓Change one gain at a time and watch the graph.
- ✓Make large changes (double it or halve it).
- ✓Tune with a fully charged battery, as in real use.
✗ Tuning that drives you crazy
- ✗Change all three gains at the same time.
- ✗Adjust “by eye,” without the Serial Plotter.
- ✗Copy gains from a robot that's different from yours.
Why learn this
A poorly tuned PID is worse than none: it oscillates, drains the battery, and heats up the motor. With a method, tuning a line follower goes from an afternoon of trial and error to a few organized tests.
Key concepts
🧯 Windup, saturation, and derivative filtering
The “textbook” PID has three real-world pitfalls, and the code in topic 3 already handles all three. Saturation: PWM can't go above 255; asking for 400 makes no difference. Windup: while the output is stuck at its maximum, the integral keeps adding up the error. When the robot finally reaches the target, that huge sum makes it overshoot by a lot. Derivative noise: D amplifies every little jump in the measurement and makes the motor vibrate.
How to read this map: In the red section, your hand is holding the wheel still: speed is zero, and the output is at its maximum. When you let go, the red curve (without protection) shoots far past the target because of the accumulated integral. The blue curve (with protection) didn't accumulate anything while saturated, so it reaches the target smoothly.
| Problem | Code line (topic 3) | What it does |
|---|---|---|
| Saturation | saidaMin / saidaMax | clamps the output to the range the motor accepts |
| Windup | if (erro < 0) integral = novaIntegral | during saturation, only accepts the integral if it helps bring the output out of saturation |
| Kick at the target | -(medida - medidaAnt) / dt | derivative of the measurement, not the error: changing the target doesn't cause a spike |
| Noise in D | dFiltrada += 0.2 * (...) | exponential moving average: smooths the signal before multiplying by Kd |
🌱 New here?
A low-pass filter lets slow changes through and cuts out fast ones, like the average in 1.4. The expression f += a * (novo - f) does this in one line: with a = 0.2, each new reading pulls the filtered value just 20% toward it. A smaller a means smoother but more delayed.
⚠️ Test windup carefully
To see the effect on a real rover, hold the wheel still using only a small motor (N20 or TT) and only for a few seconds: a stalled motor draws maximum current and heats up the motor and driver. Never do this with larger motors.
Why learn this
The difference between a PID that works in simulation and one that works on the robot comes down to these four lines. Ready-made PID libraries do the same thing; now you know what to look for in them.
Key concepts
⚖️ Match the speed of both wheels with PID
Two “identical” motors are never identical: with the same PWM, one spins faster than the other, and the rover veers to one side. The solution is to give each wheel its own PID, both with the same target RPM, and each reading its own encoder. The PID for the weaker wheel simply sends more PWM until both reach the target.
How to read this map: The same amber target feeds two loops that don’t know about each other. Each dashed cyan line is one wheel’s encoder feeding back to its own PID. If the right wheel is weaker, only the lower loop increases the PWM.
Goal: keep both wheels at the RPM you enter in the Serial Monitor (e.g., 150; 0 to stop), with one PI controller for each at 50 Hz. Use the same driver pins as in 2.1 and the same encoder pins as in 2.2.
// Two wheels at the same speed: one PID per wheel (TB6612FNG + encoders, esp32 3.x package)
const int PWMA = 25, AIN1 = 26, AIN2 = 27, PWMB = 14, BIN1 = 16, BIN2 = 17; // STBY on 3V3
const int ENC_E_A = 18, ENC_E_B = 19, ENC_D_A = 34, ENC_D_B = 35;
const float PULSOS_VOLTA = 1400; // the value measured in 2.2
const unsigned long PERIODO_US = 20000; // 20 ms = 50 Hz
const float DT = PERIODO_US / 1e6;
volatile long pulsosE = 0, pulsosD = 0;
void IRAM_ATTR isrE() { if (digitalRead(ENC_E_A) == digitalRead(ENC_E_B)) pulsosE++; else pulsosE--; }
void IRAM_ATTR isrD() { if (digitalRead(ENC_D_A) == digitalRead(ENC_D_B)) pulsosD--; else pulsosD++; }
struct PID {
float kp = 0.8, ki = 4.0, kd = 0.0, saidaMin = 0, saidaMax = 255; // starting point: tune yours
float integral = 0, dFiltrada = 0, medidaAnt = 0;
float calcula(float alvo, float medida, float dt) {
float erro = alvo - medida;
dFiltrada += 0.2 * (-(medida - medidaAnt) / dt - dFiltrada);
medidaAnt = medida;
float novaIntegral = integral + ki * erro * dt;
float saida = kp * erro + novaIntegral + kd * dFiltrada;
if (saida > saidaMax) { saida = saidaMax; if (erro < 0) integral = novaIntegral; }
else if (saida < saidaMin) { saida = saidaMin; if (erro > 0) integral = novaIntegral; }
else integral = novaIntegral;
return saida;
}
void zera() { integral = 0; dFiltrada = 0; }
};
PID pidE, pidD;
float alvoRPM = 0, rpmE = 0, rpmD = 0;
void frente(int pwm, int in1, int in2, int v) { digitalWrite(in1, v > 0); digitalWrite(in2, LOW); ledcWrite(pwm, v); }
void setup() {
Serial.begin(115200);
Serial.setTimeout(20);
int saidas[] = {AIN1, AIN2, BIN1, BIN2};
for (int p : saidas) pinMode(p, OUTPUT);
ledcAttach(PWMA, 20000, 8);
ledcAttach(PWMB, 20000, 8);
pinMode(ENC_E_A, INPUT_PULLUP); pinMode(ENC_E_B, INPUT_PULLUP);
pinMode(ENC_D_A, INPUT); pinMode(ENC_D_B, INPUT);
attachInterrupt(digitalPinToInterrupt(ENC_E_A), isrE, CHANGE);
attachInterrupt(digitalPinToInterrupt(ENC_D_A), isrD, CHANGE);
Serial.println("Enter the target in RPM (e.g., 150). 0 to stop.");
}
void loop() {
static unsigned long proximo = micros();
static long antE = 0, antD = 0;
if ((long)(micros() - proximo) < 0) return;
proximo += PERIODO_US;
if (Serial.available()) alvoRPM = constrain(Serial.parseFloat(), 0, 250);
noInterrupts(); long e = pulsosE, d = pulsosD; interrupts();
rpmE = 0.7 * rpmE + 0.3 * ((e - antE) / PULSOS_VOLTA / DT * 60); // light filtering on the measurement
rpmD = 0.7 * rpmD + 0.3 * ((d - antD) / PULSOS_VOLTA / DT * 60);
antE = e; antD = d;
if (alvoRPM < 1) { // really stop and clear the integral
frente(PWMA, AIN1, AIN2, 0); frente(PWMB, BIN1, BIN2, 0);
pidE.zera(); pidD.zera();
} else {
frente(PWMA, AIN1, AIN2, (int)pidE.calcula(alvoRPM, rpmE, DT));
frente(PWMB, BIN1, BIN2, (int)pidD.calcula(alvoRPM, rpmD, DT));
}
static int n = 0;
if (++n % 5 == 0) Serial.printf("alvo:%.0f,esq:%.1f,dir:%.1f\n", alvoRPM, rpmE, rpmD);
}
How to check
- ✓With the wheels off the ground, enter 150: in the Serial Plotter, "left" and "right" rise and meet at 150 in under a second.
- ✓Gently touch one wheel with your finger: it slows a little, then returns to 150 on its own.
- ✓On the ground, set both wheels to 150: the rover drives straight for 2 m (compare with fixed PWM).
- ✗Does one wheel shoot up to maximum and fail to come back? Its encoder is counting backward: the PID "sees" the wheel moving in reverse and speeds it up even more.
Why learn this
Driving straight seems trivial until you try it. With both wheels’ speeds controlled, the odometry in 2.2 becomes more reliable, the line follower in 2.6 has a stable base for adding steering corrections, and the ROS 2 /cmd_vel command in Track 4 becomes exactly this: a speed target for each wheel.
Key concepts
✅ Module summary
To go further
- Improving the Beginner's PID (Brett Beauregard) — the series that inspired the Arduino PID library
- Control Tutorials for MATLAB and Simulink: PID — the effect of each gain, shown in graphs (University of Michigan)
- Arduino IDE 2 Serial Plotter — how to plot multiple variables
- LEDC (PWM) in arduino-esp32 — ledcAttach and ledcWrite in 3.x
Next module
2.4 · Several tasks at once: millis(), state machines, and FreeRTOS let the robot read, decide, and control without getting stuck.