Control systems and PID
Closed-loop control from first principles, with a tuner you can play with.
Day 8 · 50 min
After this lesson
- You can explain what each PID term does and how to tune by hand.
- You can implement a discrete PID with anti-windup and derivative on measurement.
- You can diagnose oscillation and steady-state error.
Closing the loop
Open-loop control sets an output and hopes: 60% duty for "about half speed". Hope fails fast. A 2S pack sags from 8.4 V to about 6.4 V over a run, a 24% drop in motor voltage; carpet adds load; one gearbox has more friction than the other, so the robot curves. Closed-loop control measures what actually happened and corrects it, recomputing the error e = r − y (setpoint minus measurement) every cycle.
P, I and D
- Proportional, u = Kp·e: push harder the further away you are. On a plant that needs a steady input to hold its output, such as a wheel at constant speed, P alone always leaves an offset of 1/(1 + K·Kp) of the setpoint, where K is the plant's gain. With Kp = 2 on the tuner's unit-gain plant, a third of the setpoint is missing. More Kp shrinks the offset but, once lag is present, starts oscillation.
- Integral adds Ki·∫e dt. It keeps growing while any error remains, so it removes the offset. Too much of it overshoots and rings, because the accumulated error can only be unwound by passing the setpoint.
- Derivative adds Kd·de/dt, reacting to how fast things change, which brakes an approach before it overshoots. It helps plants with inertia, such as heading or position, amplifies noise, and does little on a first-order speed loop. That is why wheel-speed loops are usually PI.
Discrete PID, done properly
On a microcontroller the controller runs every dt seconds: u = Kp·e + Ki·Σe·dt − Kd·(y − y_prev)/dt. Four details separate a PID that works from one that bites:
- Constant, known sample time. dt appears in the I and D terms, so a jittery loop is a noisy controller. Run it from a timer or vTaskDelayUntil().
- Derivative on measurement. The derivative of the error spikes when the setpoint steps (derivative kick); the derivative of the measurement does not, and it damps just the same otherwise. Filter it, or use a sensor that measures the rate directly, such as a gyro.
- Output clamping. A motor driver accepts −100% to +100% duty, so clamp to that.
- Anti-windup. While the output is clamped, stop integrating in the direction that pushes it further. Otherwise a robot held against a wall winds up a huge integral and lunges when released.
The tuner below runs exactly this controller on a model of the course robot's wheel speed.
Tuning by hand and by Ziegler-Nichols
A reliable manual recipe: set Ki = Kd = 0 and raise Kp until the response is brisk with a little overshoot or the first sign of ringing, then back off by a third to a half. Add Ki until the offset disappears fast enough, stopping before overshoot grows. Add Kd only if a plant with inertia still overshoots. Change one gain at a time by about 1.5×, and test with both setpoint steps and disturbances.
Ziegler and Nichols (1942) offered a formula. With Ki and Kd at zero, raise Kp until the loop oscillates steadily; call that gain Ku and the period Tu. Then Kp = 0.6·Ku, Ki = 1.2·Ku/Tu and Kd = 0.075·Ku·Tu. The result overshoots about 25%, so treat it as a starting point, and remember that the test deliberately drives real hardware to the edge of instability.
Why loops oscillate: lag
Every delay in the loop (sensor filtering, sample time, communication, actuator response) makes the controller act on old information. At some frequency the total lag reaches 180°, and if the loop gain there is 1 or more, each correction arrives just in time to make things worse: a sustained oscillation. That is why high gains that settle at 100 Hz oscillate at 10 Hz in the tuner. Remove lag first, then raise gain.
Cascades and feedforward
Real robots nest loops. The course robot's heading loop outputs a turn-rate command, which becomes left and right wheel-speed setpoints (v ∓ ω·L/2 for a wheel base L); per-wheel PI loops at 100 Hz turn those into PWM. Each inner loop runs 5-10 times faster than the one above it, so the outer loop sees an almost ideal actuator, and the wheel loops fix a carpet edge before the heading even moves.
Feedforward adds what you already know: duty = kS·sign(v) + kV·v, measured by stepping the motor at a few duty cycles. Feedforward then supplies most of the output and the PID only trims the rest, so gains stay low and the response is fast without overshoot.
Wheel speed and heading in practice
Speed comes from encoder counts per cycle, which quantizes it: at 0.2 m/s and 100 Hz, ×1 decoding gives about 5 ticks per cycle, so one tick is a 20% speed step; ×4 decoding makes it 5%. Heading comes from integrating the gyro's yaw rate or from the wheel-speed difference. Wrap the heading error into ±180° before using it, or the robot will turn the long way round.
Interactive: tune the wheel-speed loop
PID tuner: wheel-speed step response
The plant is the course robot's wheel speed, modelled as first order (τ = 0.3 s), asked to go from 0 to 50% of top speed. The controller is simulatePid: output clamped to ±100% duty, anti-windup, derivative on measurement.
Use the left and right arrow keys to move a cursor along the response and hear its values.
- Overshoot
- 0.0%
- Settling time (±2%)
- never settles
- Steady-state error
- 33.3% of setpoint
- After the load bump
- add the bump to test
Start with the P-only preset and add the load bump: the offset grows from 33% to 53% of the setpoint. Add Ki and the integral pulls it back. Then try the slow-loop preset: the same kind of gains at 10 Hz limit-cycle between full forward and full reverse duty. For a full-screen version, open the PID lab.
In practice
Practice
Tune to under 10% overshoot and 1 s settling
In the tuner, at 100 Hz, find gains that give less than 10% overshoot, settle within ±2% in under 1 s, leave no steady-state error, and recover from the load bump. Change one gain at a time and note each result.
| Step | Kp | Ki | Kd | Overshoot | Settling (±2%) | Steady-state error |
|---|---|---|---|---|---|---|
| P only | 2 | 0 | 0 | 0% | never | 33% |
| More P | 5 | 0 | 0 | 0% | never | 17% |
| Add I | 2 | 5 | 0 | 0% | 1.20 s | 0% |
| More I | 2 | 10 | 0 | 2.8% | 0.73 s | 0% |
| Too much I | 1 | 20 | 0 | 21.7% | 0.97 s | 0% |
| Final | 2 | 8 | 0 | 0.5% | 0.47 s | 0% |
Answer. Kp = 2, Ki = 8, Kd = 0 gives 0.5% overshoot and 0.47 s settling, with no offset, and returns within 2% about 0.85 s after the load bump; Kp = 3, Ki = 15 also passes (0.3%, 0.39 s). Adding Kd = 0.1 to Kp = 2, Ki = 10 raises the overshoot from 2.8% to 6.9%: derivative action does not help this plant.
Practice
Write the PID in C++
Implement the controller for the ESP32 with derivative on measurement, clamping and anti-windup, then wire it into the 100 Hz loop from the previous lesson.
// Discrete PID: derivative on measurement, output clamping, anti-windup.
// The same algorithm as simulatePid() behind the tuner, plus an optional derivative filter.
struct Pid {
float kp, ki, kd;
float outMin = -1.0f, outMax = 1.0f; // motor duty limits
float dAlpha = 1.0f; // derivative EMA: 1 = unfiltered, 0.2 = heavily filtered
float integral = 0.0f;
float prevMeas = 0.0f;
float dFilt = 0.0f;
bool primed = false;
// A real constructor: the default member initialisers above stop Pid being an aggregate
// before C++14, and the Arduino-ESP32 2.x core compiles as gnu++11.
Pid(float p, float i, float d) : kp(p), ki(i), kd(d) {}
float update(float setpoint, float meas, float dt) {
if (!primed) { prevMeas = meas; primed = true; } // no derivative spike on the first call
float error = setpoint - meas;
float dRaw = -(meas - prevMeas) / dt; // derivative of the measurement: no kick
prevMeas = meas;
dFilt += dAlpha * (dRaw - dFilt);
float newIntegral = integral + error * dt;
float out = kp * error + ki * newIntegral + kd * dFilt;
if (out > outMax) {
out = outMax;
if (error < 0) integral = newIntegral; // only integrate if it pulls the output back
} else if (out < outMin) {
out = outMin;
if (error > 0) integral = newIntegral;
} else {
integral = newIntegral;
}
return out;
}
void reset(float meas) { // call when enabling: bumpless start
integral = 0.0f;
prevMeas = meas;
dFilt = 0.0f;
primed = true;
}
};
// The tuner works in fractions of top speed. On the robot, speeds are in mm/s,
// so divide its gains by the top speed (about 230 mm/s for 100 rpm motors on 44 mm wheels).
constexpr float TOP_SPEED = 230.0f;
Pid pidLeft(2.0f / TOP_SPEED, 8.0f / TOP_SPEED, 0.0f);
Pid pidRight(2.0f / TOP_SPEED, 8.0f / TOP_SPEED, 0.0f);
// These are the pidLeft / pidRight that controlStep() in the firmware lesson's 100 Hz loop calls:
// float left = pidLeft.update(targetLeft, speedLeft(), dt);
// float right = pidRight.update(targetRight, speedRight(), dt);Real-world scenario
Real-world scenario
Heading hold weaves ±10° down the hallway
A robot holds its heading with a P controller on a gyro-integrated yaw angle: wheel speed difference = Kp × heading error. To fix a slow drift, the team doubled Kp. They had also set the MPU-6050's digital low-pass filter to its 5 Hz setting to quiet motor vibration, and the heading controller runs in the 20 Hz behavior loop rather than the 100 Hz control loop. Now the robot weaves left and right about ±10° as it drives straight.
Symptoms
- The weave has a steady period of about 0.7 s and does not die out.
- Halving Kp stops the weaving but brings back the slow drift.
- The weave gets worse at higher drive speeds and after the low-pass filter was set to 5 Hz.
- A log of heading error and commanded turn rate shows the command peaking well after the error peaks.
Interview questions
screenWhat does each term of a PID controller do?
screenWhat is integral windup and how do you prevent it?
deepWhy compute the derivative on the measurement instead of the error, and what else do you do to the D term?
deepA robot's heading oscillates by about ±10°. How do you diagnose and fix it?
screenHow does Ziegler-Nichols tuning work, and what are its limits?
deepExplain cascade control and feedforward for a wheeled robot.
Quiz
References and further reading
Where the ideas in this lesson come from, and what to read next. Books are the standard texts; papers are the original sources; standards are what employers work to.
- Paper
J. G. Ziegler and N. B. Nichols (1942). Optimum Settings for Automatic Controllers. Transactions of the ASME 64, 759-768.
Read: The original tuning rules.
- Book
Karl J. Åström and Tore Hägglund (1995). PID Controllers: Theory, Design, and Tuning. ISA, 2nd edition.
Read: Anti-windup and derivative filtering.
- Book
Gene F. Franklin, J. David Powell and Abbas Emami-Naeini (2019). Feedback Control of Dynamic Systems. Pearson, 8th edition.
Read: The PID and digital-control chapters.
- Bookfree online
Read: The PID chapter, free online.