PTENES
MODULE 2.3 · FUNDAMENTALS

🎯 PID control in practice

PID is the formula that makes a robot reach its target without overshooting and stay there, even with a low battery or a ramp in the way. You’ll understand each letter using a car on a track, write PID code, test it on a simulated motor without hardware, tune the gains, and finish with both rover wheels spinning at the same speed.

6
Topics
Intermediate
Level
Foundation
Type
Wokwi + rover
Hardware
0%
0 of 0 topics read · Section 1 of 6

Detailed content

1

🔁 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.

on/offPIDoscillates foreverovershoots a little, then settlestarget

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

Target
what you want
Error
target − measurement
Oscillation
back-and-forth around the target
Closed loop
the measurement corrects the output
2

🏎️ 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.

targetP only: falls shortP + I: reaches target, oscillatesP + I + D: reaches target and settlestime →

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 speedOvershootRemaining error
Kpfastermoredecreases, but doesn’t disappear
Kia little fastermoredisappears
Kdchanges littlelessdoesn’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

P
reacts to the current error
I
corrects the error that persists
D
slows the approach
Overshoot
how far it goes past the target
3

💻 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().

target −PID motorencoder errorPWMrunsmeasured speed · every 10 ms

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-simulated.ino
// 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

Fixed period
dt always the same
micros()
clock without blocking
Simulated plant
test without a motor
Serial Plotter
see the response
4

🎛️ 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

1

Set I and D to zero

Start with i0 e d0. Lift the robot so the wheels are off the ground.

2

Increase P until it almost oscillates

Double Kp until the response starts to wobble, then go back to half that value.

3

Add I gradually

Increase Ki until the remaining error disappears in less than a second, without too much wobbling.

4

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.

5

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.

ControllerKpKiKd
P0.5 · Ku00
PI0.45 · Ku0.54 · Ku / Tu0
PID0.6 · Ku1.2 · Ku / Tu0.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

Ku
gain that causes oscillation
Tu
oscillation period
P → I → D
the tuning sequence
One at a time
to see the effect
5

🧯 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.

wheel held in place:integral growswithout anti-windup: big overshootwith anti-windup: reaches the target and stays theretargettime → (the wheel is released at the end of the red section)

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.

ProblemCode line (topic 3)What it does
SaturationsaidaMin / saidaMaxclamps the output to the range the motor accepts
Windupif (erro < 0) integral = novaIntegralduring saturation, only accepts the integral if it helps bring the output out of saturation
Kick at the target-(medida - medidaAnt) / dtderivative of the measurement, not the error: changing the target doesn't cause a spike
Noise in DdFiltrada += 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

Saturation
physical output limit
Anti-windup
integral under control
D on measurement
no spike when the target changes
Low-pass filter
cuts out noise
6

⚖️ 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.

target150 RPM left PIDright PID left motorright motor left enc.right enc.

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-pid.ino
// 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

One PID per wheel
independent loops
measured RPM
pulses ÷ time
PI is enough
for wheel speed
Clear the integral
when stopping

✅ Module summary

✓
On/off control oscillates: fast systems need corrections proportional to the error.
✓
P responds, I persists, D brakes: output = Kp·error + Ki·sum + Kd·change.
✓
Fixed-time loop: use micros() and the next scheduled time, never delay() for control.
✓
Tune methodically: P, then I, then D; use Ziegler-Nichols as a starting point.
✓
A real PID: saturation, anti-windup, derivative of the filtered measurement, one loop per wheel.

To go further

Next module

2.4 · Several tasks at once: millis(), state machines, and FreeRTOS let the robot read, decide, and control without getting stuck.