Lesson 5 drew the perfect path. The wind, the slipping wheels, and the imperfect model all conspire to push the robot off it. This is the Control module — the feedback that measures the error and corrects it, closing the loop.
In Lesson 5 you did something genuinely hard. You took a robot, a start, a goal, and the laws of its motion, and you computed a trajectory — a complete recipe of where to be and what control to apply at every instant: xd(t) and ud(t). The robot was supposed to roll from the driveway to the parking spot along a smooth, beautiful curve.
Then you ran it on a real robot. And it missed.
Not by much, at first. The left wheel slipped a hair on a wet leaf. A gust of wind nudged it. Your model said the motor turns at exactly the commanded rate, but the real motor lagged by a few percent. Each of these errors is tiny. The problem is that they accumulate. The robot at second 3 is in the wrong place, so the plan it follows at second 4 — which assumed it was somewhere else — is now wrong too, and the error compounds, second after second, until the robot parks in the neighbor's hedge.
You met this exact picture in Lesson 1: the open-loop robot that drifts off versus the closed-loop robot that sticks. Here is that same idea, but now the robot is tracking a curving reference path, not just a straight line to a point. Toggle between open-loop and closed-loop and press Run.
The grey dashed curve is the planned reference trajectory. A steady cross-wind disturbance pushes the robot every step. Open-loop replays the planned commands blindly and drifts off; closed-loop measures how far it is from the reference and steers back. Crank the wind to see how much abuse each can take.
Open-loop, the robot never finds out it has drifted. It applies the command the plan prescribed for "where I should be," not "where I actually am," and the gap between those two only widens. Closed-loop, the robot does one extra thing every step: it looks at where it actually ended up, computes the difference from the reference, and bends its command to shrink that difference. Same robot, same plan, same wind. The only difference is the loop.
It's worth seeing in numbers exactly why open-loop errors don't just persist, they grow. Imagine the plan says "move +1 unit east each tick." A constant wind adds an unmodeled +0.2 units north each tick. The robot's position after each tick, open-loop, accumulates that drift:
| Tick | planned (east, north) | actual open-loop (east, north) | north error |
|---|---|---|---|
| 1 | (1, 0) | (1, 0.2) | 0.2 |
| 2 | (2, 0) | (2, 0.4) | 0.4 |
| 3 | (3, 0) | (3, 0.6) | 0.6 |
| 10 | (10, 0) | (10, 2.0) | 2.0 |
The error grows linearly with time here — and that's the optimistic case. On a curving trajectory the heading is wrong too, so the robot applies its "go east" command while actually pointed northeast, and the error grows faster than linearly. A closed-loop controller breaks the chain: at tick 1 it sees the 0.2 north error and commands a small southward correction, so tick 2 starts near zero error instead of compounding. The disturbance is rejected each step instead of integrated forever. Feedback converts a growing error into a bounded one. That single sentence is the entire reason this lesson exists.
That second line is Stanford's exact definition of a closed-loop control law (also called a feedback controller or policy): the command depends on the current measured state, not just the clock. The whole of this lesson is about how to design that function π — from the simplest possible "push back proportionally to the error," all the way to the optimal LQR controller. This is the Control module of the autonomy stack: it takes the planner's trajectory and turns it into actuator commands that survive contact with the real world.
Before we build the controllers, we have to agree on the one quantity every one of them reacts to: the error. That's Chapter 1.
A controller is a machine with one obsession: drive the error to zero. So before anything else, we have to define the error precisely, because every controller in this lesson — P, PID, feedback-linearizing, LQR — is just a different recipe for reacting to it.
Let x(t) be the robot's actual state (for a wheeled robot, its pose — recall Lesson 1's ξ = (x, y, θ)). Let xd(t) be the desired or reference state — where the plan says the robot should be at time t. The tracking error is simply the difference:
Read it carefully. e is desired minus actual. If the robot is exactly on plan, e = 0. If the robot is behind, e points "forward toward where it should be." The controller's entire job is to make e → 0 and keep it there, despite disturbances.
A trajectory tracking controller has two pieces, and naming them keeps the design clean. Stanford writes the control law as:
For most of this lesson we will study the feedback term π on its own, often by setting ud = 0 and asking the controller to drive the robot to a fixed point (the goal). That is called regulation: hold the state at a setpoint. Tracking a moving reference is regulation with a feedforward term added on top, so everything we learn about regulation transfers directly.
Here is the conceptual leap that makes control a science instead of guesswork. We do not just watch the error — we write down a differential equation for how the error changes over time, called the error dynamics. If the robot's state obeys some dynamics and we apply a control law, then the error e(t) obeys its own dynamics. Designing a controller means shaping those error dynamics so that e decays to zero.
Take the simplest possible case: a 1-D robot whose position x has velocity directly commanded, ẋ = u. We want to hold it at a fixed setpoint xd (so ẋd = 0). The error is e = xd − x, so its rate of change is:
Now suppose we choose the feedback law u = k·e (push proportionally to the error — the subject of Chapter 2). Substitute it in:
This is the most important little equation in the lesson. It says the error shrinks at a rate proportional to itself. Its solution is an exponential decay:
If k > 0, the error decays to zero, smoothly, forever — the controller is stable. If k < 0, the error grows exponentially — the robot runs away. The sign and size of the gain determine whether the error dies or explodes. Larger k means faster decay. This single fact — that a control law turns into error dynamics, and stability is a property of those dynamics — is the engine behind everything in this lesson.
Let's verify the exponential claim with actual numbers, so it isn't just a formula on the page. Start the error at e(0) = 8 with gain k = 0.5. In continuous time the prediction is e(t) = 8·e−0.5t. Sample it at one-second marks:
| time t | e−0.5t | e(t) = 8·e−0.5t | fraction of e(0) left |
|---|---|---|---|
| 0 | 1.000 | 8.00 | 100% |
| 1 | 0.607 | 4.85 | 61% |
| 2 | 0.368 | 2.94 | 37% |
| 4 | 0.135 | 1.08 | 14% |
| 6 | 0.050 | 0.40 | 5% |
Every increase of 2 seconds multiplies the error by e−1 ≈ 0.37 — it loses 63% of whatever is left. There is no point where the error suddenly arrives at zero; it approaches asymptotically, halving and halving. The quantity τ = 1/k (here 2 seconds) is the time constant — the time to fall to 37% of the current value. Double the gain and you halve the time constant: the system gets to "close enough" twice as fast. This is the dial every controller in this lesson turns.
Now flip the sign to see runaway. With k = −0.5, the same table reads e(t) = 8·e+0.5t: at t = 6 the error is 8·e3 ≈ 161, twenty times bigger than it started. A negative gain doesn't merely fail to fix the error — it actively pumps energy into it. Sign errors in feedback are the classic way to turn a stable robot into a violently unstable one, which is why Chapter 1's sign convention is not pedantry.
Watch the error decay (or explode). Move the gain slider: positive k decays the error exponentially; negative k blows it up. The dashed grey curve shows the analytic solution e(0)·e−kt — notice the simulated points land right on it.
The lesson of the widget: a controller is a way of choosing the error dynamics. Every controller we build is an answer to the question "what should ė be?" Proportional control makes it −k·e (first-order decay). PID adds an integral and a derivative term to shape it better. LQR computes the gain that makes the error decay optimally. But they all live inside this one idea: shape the error dynamics so e → 0.
One more subtlety worth nailing now: tracking a moving reference and regulating to a fixed point are the same problem in error coordinates. If xd(t) moves, then ė = ẋd − ẋ picks up the reference's own velocity ẋd as an extra term. That extra term is exactly what the feedforward ud is there to cancel: supply ud so the reference's motion is accounted for, and the feedback term is left to handle only the leftover error e — back to the clean ė = −k·e we just analyzed. So everything we derive for "hold at a setpoint" carries straight over to "follow a trajectory," with feedforward absorbing the reference's motion.
python # The error dynamics e' = -k e, simulated and checked against the analytic solution. import numpy as np k, e0, dt = 0.5, 8.0, 0.01 e = e0 for step in range(601): t = step * dt if step % 100 == 0: # print once per second analytic = e0 * np.exp(-k * t) # the closed-form decay print(f"t={t:.0f} sim={e:.2f} analytic={analytic:.2f}") e = e + dt * (-k * e) # one Euler step of e' = -k e # t=0 sim=8.00 analytic=8.00 ; t=2 sim~2.94 ; t=6 sim~0.40 -- they agree
The simplest feedback law in existence is one line: command an action proportional to the error. If you are far from the target, push hard; if you are close, push gently; if you are there, do nothing. That is proportional control, the P in PID:
Here Kp (the proportional gain) is a single positive number you choose. It is the controller's aggressiveness dial — how hard it reacts per unit of error. A thermostat with a proportional valve, a car's cruise control easing onto the gas as it slows on a hill, a person steering a shopping cart back toward the aisle center: all proportional control.
Let us actually compute what a P-controller does, step by step, with real numbers — no hand-waving. Take a discrete-time first-order system (we step in ticks of size Δt, which is how a real digital controller runs):
Pick concrete values: setpoint xd = 10, start at x0 = 0, timestep Δt = 1, and a gentle gain Kp = 0.5. Substituting Δt·Kp = 0.5, each tick moves the robot halfway-ish toward the target. Let us crank it:
| Tick t | xt | error e = 10 − xt | command u = 0.5·e | xt+1 = xt + u |
|---|---|---|---|---|
| 0 | 0.00 | 10.00 | 5.00 | 5.00 |
| 1 | 5.00 | 5.00 | 2.50 | 7.50 |
| 2 | 7.50 | 2.50 | 1.25 | 8.75 |
| 3 | 8.75 | 1.25 | 0.63 | 9.38 |
| 4 | 9.38 | 0.62 | 0.31 | 9.69 |
| 5 | 9.69 | 0.31 | 0.16 | 9.84 |
Trace it. At tick 0 the error is the whole 10 units, so the controller commands a big move of 5. Halfway there, the error has halved, so the next command halves too. Each tick closes half the remaining gap. The robot approaches 10 smoothly, never overshooting, slowing as it nears — a clean exponential approach. After 5 ticks it's at 9.84, about 98% of the way. This is the discrete cousin of the e−kt decay from Chapter 1.
Now redo it with an aggressive gain. Set Kp = 1.8 (so Δt·Kp = 1.8). Watch what overshoot looks like in numbers:
| Tick t | xt | error e = 10 − xt | command u = 1.8·e | xt+1 |
|---|---|---|---|---|
| 0 | 0.00 | 10.00 | 18.00 | 18.00 |
| 1 | 18.00 | −8.00 | −14.40 | 3.60 |
| 2 | 3.60 | 6.40 | 11.52 | 15.12 |
| 3 | 15.12 | −5.12 | −9.22 | 5.90 |
| 4 | 5.90 | 4.10 | 7.38 | 13.28 |
Disaster. The big gain commands a move of 18, blowing straight past the target to 18. Now the error is negative 8, so the controller slams it back to 3.6 — overshooting the other way. It oscillates, bouncing around the target with growing-then-shrinking swings (this one happens to decay slowly; push Kp past 2 and the swings grow and the system goes unstable). High gain means fast response but overshoot and ringing. Low gain means smooth but sluggish. That trade-off is the eternal headache of the proportional controller, and it's exactly what you'll feel in the widget below.
A robot starts at the left and must reach the green setpoint. The plot is its position over time. Drag Kp: too small and it crawls in (sluggish); just right and it glides in (critically tuned); too big and it overshoots and rings; very big and it goes unstable. Press Run after each change.
Here is a subtle thing the numbers above hid because that system had no disturbance. Suppose there is a constant load — the robot rolls on a slight downhill incline, so gravity adds a steady push of, say, +2 units every tick that the controller must fight. In equilibrium the robot stops moving only when its command exactly cancels the load: Kp·e + 2 = 0, which means the controller needs a permanent nonzero error just to produce the cancelling command:
The robot settles near the target but never at it — it parks a little downhill of the line forever, because if e were zero the controller would command zero and gravity would pull it back. This leftover gap is the steady-state error. You can shrink it by raising Kp (esteady = −2/Kp gets smaller), but you can never kill it with proportional control alone, and pushing Kp up brings back the overshoot. To truly eliminate steady-state error you need a new idea: the integral term. That's Chapter 3.
Let's put a number on it. With the load = 2 and Kp = 0.5, the robot settles at e = −2/0.5 = −4 — a full 4 units short of the target of 10, parking at 6 forever. Double the gain to Kp = 1: e = −2/1 = −2, parking at 8 (better, but still off). Push to Kp = 4: e = −0.5, parking at 9.5 (close, but we're now near the overshoot regime). The gain that would make the error truly zero is infinite — impossible. Proportional control trades steady-state accuracy against stability, and can never win both. That fundamental limitation is precisely why the integral term exists.
Notice that the smooth first table (Kp = 0.5) never overshot, while the aggressive one (Kp = 1.8) oscillated. For a pure first-order system, the boundary is sharp: as long as Δt·Kp < 1, each step closes a fraction of the gap and the approach is monotone (no overshoot). At Δt·Kp = 1 it reaches the target in a single step ("deadbeat"). Between 1 and 2 it overshoots and rings but still converges. Above 2 it diverges. We'll see in Chapter 9 that this exact bound — Δt·Kp < 2 for stability — falls straight out of the discrete error dynamics. For now, the takeaway is visceral: more gain is not always more better.
python # Proportional controller, from scratch. The entire feedback loop. def p_control_step(x, x_des, Kp): e = x_des - x # tracking error: desired minus actual u = Kp * e # command proportional to the error return u # simulate the smooth case from the table above x, x_des, Kp, dt = 0.0, 10.0, 0.5, 1.0 for t in range(6): u = p_control_step(x, x_des, Kp) print(f"t={t} x={x:.2f} e={x_des-x:.2f} u={u:.2f}") x = x + dt * u # integrate one step (Euler) # t=0 x=0.00 e=10.00 u=5.00 ... matches the worked table exactly
Proportional control reacts to the error right now. That leaves it blind in two directions: it cannot remember that the error has been stubbornly nonzero for a while (so it tolerates steady-state error), and it cannot anticipate that the error is rushing toward zero fast (so it overshoots). PID fixes both by adding two more terms. The full controller is three reactions summed:
Three terms, three time-tenses, three jobs:
Recall the downhill robot from Chapter 2: a constant load of +2 forced the P-controller to settle at esteady = −2/Kp, never zero. Add an integral term and watch the gap close. The integral term accumulates the error each tick: It+1 = It + et·Δt, and contributes Ki·I to the command. Take Kp = 0.5, Ki = 0.3, load = −2 (a steady pull), xd = 10, start near the P steady-state at x0 = 6 (where P alone would stall, since −2/0.5 = −4, settling at 6):
| Tick | xt | e = 10 − x | I (∑e) | u = 0.5e + 0.3I − 2 | xt+1 |
|---|---|---|---|---|---|
| 0 | 6.00 | 4.00 | 4.00 | 0.5(4)+0.3(4)−2 = 1.20 | 7.20 |
| 1 | 7.20 | 2.80 | 6.80 | 0.5(2.8)+0.3(6.8)−2 = 1.44 | 8.64 |
| 2 | 8.64 | 1.36 | 8.16 | 0.5(1.36)+0.3(8.16)−2 = 1.13 | 9.77 |
| 3 | 9.77 | 0.23 | 8.39 | 0.5(0.23)+0.3(8.39)−2 = 0.63 | 10.40 |
| 4 | 10.40 | −0.40 | 7.99 | 0.5(−0.4)+0.3(7.99)−2 = 0.20 | 10.60 |
Watch the integral term I do its work. With P alone the robot was frozen at 6 with a permanent error of 4. But the integral keeps accumulating that error — tick after tick the stored I grows, and Ki·I climbs until the command finally overpowers the load and pushes the robot up past 10. At equilibrium the error reaches zero, but the integral term keeps the command at exactly +2 to cancel the load. The integral provides the steady command the load needs, freeing the error to go to zero. P couldn't do that because P's command vanishes when e = 0; I's command lives on a memory of past error, so it survives.
To make windup concrete: imagine the robot is jammed against an obstacle for 10 ticks with a constant error of e = 5 and Δt = 1. The integral grows by 5 each tick — after 10 ticks it holds I = 50. With Ki = 0.3 that's a stored command of 15, far larger than anything the task needs. The instant the obstacle clears, the controller dumps that 15-unit command into the actuator and the robot rockets past the setpoint before the integral slowly bleeds back down. The fix is to simply stop accumulating while the actuator is pinned at its limit, so I never balloons in the first place — that one if is the difference between a smooth recovery and a launch.
The derivative term watches de/dt — the speed at which the error is changing. Suppose the robot is racing toward the setpoint: the error is positive but shrinking fast, so de/dt is large and negative. The D term, Kd·(de/dt), is therefore negative — it subtracts from the command, easing off the gas before the robot blows past the target. It is exactly the instinct of a driver lifting off the accelerator as the red light approaches, rather than flooring it until the last instant. D adds damping: it converts a bouncy, oscillatory approach into a smooth glide.
Watch it numerically on a small slice. A mass approaches the setpoint; here are two consecutive ticks with Kp = 8, Kd = 4, Δt = 0.1. Say the error was 0.5 last tick and 0.3 this tick (closing fast). The derivative estimate is (0.3 − 0.5)/0.1 = −2.0. The P part says push hard: 8·0.3 = +2.4. But the D part says brake: 4·(−2.0) = −8.0. The total command is 2.4 − 8.0 = −5.6 — negative, against the direction of travel, even though there's still positive error. Without D, the mass would keep accelerating into the target and overshoot; with D, it's already decelerating while still a bit short. That early braking is what converts overshoot into a clean stop.
A real robot is rarely first-order. A mass being pushed (a cart, a drone's altitude, a robot arm's joint) is second-order: the command sets acceleration, not velocity, so the system has inertia and naturally wants to overshoot and oscillate. This is where the full PID earns its keep. The widget below is a mass on a spring-damper that you push with a PID command. Tune all three gains and watch the response.
A mass (the dot) must reach the green setpoint. The command sets its acceleration, so it has inertia. Tune the three gains: raise Kp for speed (and overshoot); add Ki to erase any leftover offset; add Kd to damp the bouncing. Press Run after changes. Try Kp=8, Kd=0 (it rings), then add Kd=4 (it calms).
python # PID controller, from scratch. Stateful: it remembers the integral and last error. class PID: def __init__(self, Kp, Ki, Kd, dt): self.Kp, self.Ki, self.Kd, self.dt = Kp, Ki, Kd, dt self.integral = 0.0 # running sum of error (the "past") self.prev_e = 0.0 # last error, to estimate de/dt (the "future") def step(self, x, x_des): e = x_des - x # present error self.integral += e * self.dt # accumulate the past derivative = (e - self.prev_e) / self.dt # rate of change self.prev_e = e return self.Kp*e + self.Ki*self.integral + self.Kd*derivative
Notice the PID is stateful — unlike the pure P-controller it carries memory (integral) and history (prev_e) between calls. That memory is exactly the "past" and "future" the P-controller lacked.
So far our robots could move freely in the direction we commanded. A real wheeled robot cannot. Recall the no-slip constraint from Lesson 1: a rolling wheel cannot translate along its axle. A car can drive forward and turn, but it physically cannot slide sideways. This is the nonholonomic constraint, and it breaks naive position feedback in a way worth understanding deeply.
The simplest model is the unicycle (Stanford's Eq. 1 in the homework). Its state is the pose (x, y, θ), and its two controls are forward speed V and turn rate ω:
Read it: the robot moves at speed V in the direction it faces (hence the cosθ, sinθ), and it rotates at rate ω. There is no input that produces sideways motion. The robot only has two control knobs but lives in a three-dimensional pose space — that mismatch is the whole difficulty.
Suppose the robot is at the origin facing east (θ = 0), and the target is one meter directly to its left (north). Naive position feedback says: "the y-error is +1, so command motion in +y." But the robot cannot move in +y — with θ = 0 it can only move east or rotate. Worse, if you crudely try to map a "move north" command onto V and ω, the math can blow up: the controller asks for the impossible and the gains misbehave. A wheeled robot fundamentally cannot be smoothly stabilized to an arbitrary pose by a simple time-invariant feedback law — this is Brockett's theorem, and it is why parking a car requires that back-and-forth shuffle rather than a single smooth slide.
Stanford's elegant fix (and the one your homework Problem 3 implements) starts by giving the robot a little inertia — the dynamically extended unicycle, where you command acceleration a and turn rate ω instead of speed directly:
The magic: this system is differentially flat with flat outputs (x, y) — meaning every state and control can be recovered algebraically from x, y and their derivatives (the heart of Lesson 5). Differentiate the position twice and you find that the accelerations of x and y are a clean linear function of the real controls (a, ω):
Now define the bracketed accelerations as virtual controls (w1, w2) = (ẍ, ÿ). In these virtual coordinates the robot looks like two independent double-integrators — just like the second-order mass from Chapter 3! So we can use a plain PD-style law per axis (this is Stanford's exact tracking law, Eq. 5):
Each line is feedforward (the desired acceleration ẍd) plus PD feedback on position and velocity error — exactly the feedforward-plus-feedback split from Chapter 1. Then we invert J to recover the real commands the robot can actually execute:
Since det(J) = V, this inversion works as long as V ≠ 0 (the robot is moving). The non-linearities are exactly cancelled by the J−1 — that is what "feedback linearization" means. We turned a hard nonlinear nonholonomic tracking problem into two trivial linear ones.
Suppose the robot is moving at V = 0.5 with heading θ = 60° (cos = 0.5, sin = 0.866), and the PD laws above ask for virtual accelerations w1 = 0.3 (push x) and w2 = −0.1 (ease y). Then J = [[0.5, −0.433], [0.866, 0.25]] (using −V sinθ = −0.5·0.866 = −0.433 and V cosθ = 0.25). Its determinant is det(J) = V = 0.5, so:
Multiply through to recover the real commands [a; ω] = J−1[w1; w2]: the acceleration a = 0.5(0.3) + 0.866(−0.1) = 0.150 − 0.087 = 0.063, and the turn rate ω = −1.732(0.3) + 1.0(−0.1) = −0.520 − 0.1 = −0.62. So the controller, asked to "accelerate a bit in x and ease off in y," translates that into "speed up slightly (a = 0.06) and turn right at 0.62 rad/s" — commands the wheeled robot can actually obey. The J−1 did the translation from "where I wish I could go" to "what my wheels can do."
For the final parking phase — driving to a specific pose and stopping there — Stanford uses a change of coordinates to polar form (homework Problem 2). Measure the robot relative to the goal with three numbers: ρ (distance to goal), α (heading error toward the goal), and δ (the goal-line's angle). The Lyapunov-derived control law that drives all three to zero is:
You don't need to memorize this. The point is the shape: speed V is proportional to distance ρ (drive fast when far, slow when close), and turn rate ω corrects the heading errors α and δ. With k1, k2, k3 > 0, a Lyapunov "energy decreases" argument proves the robot is driven to the goal pose (0,0,0).
Why does Stanford bother with a Lyapunov proof instead of just eigenvalues? Because the system is nonlinear — eigenvalues only certify local stability of a linearization. A Lyapunov function V(ρ, α, δ) is an "energy" of the system: it's positive everywhere except at the goal (where it's zero), and if you can show it strictly decreases along every trajectory (V̇ < 0), then the system must slide down to the goal from anywhere — global convergence, no linearization. Stanford's choice V = ½ρ2 + ½(α2 + k3δ2) plugged into the dynamics gives V̇ = −k1ρ2cos2α − k2α2, which is strictly negative whenever k1, k2 > 0. That negativity is the proof: the energy can only go down, so the robot can only approach the goal.
Robot at origin facing east (θ = 0), goal one unit north at (0, 1). Then ρ = √(02+12) = 1, the angle to the goal is atan2(1,0) = 90°, so the heading error α = 90° − 0 = 90° (π/2). With gains k1 = 0.7, k2 = 1.2 and ignoring δ for the first instant: V = 0.7·1·cos(90°) = 0.7·1·0 = 0, and ω ≈ k2·α = 1.2·(π/2) ≈ 1.88 rad/s. Read it: because the goal is straight to the robot's side (α = 90°, cosα = 0), it commands zero forward speed and pure rotation — it turns toward the goal first, then drives. The robot never tries to slide sideways; the control law respects the nonholonomic constraint automatically. As it rotates, α shrinks, cosα grows, and V ramps up — the curving approach you'll see in the widget.
The widget lets you watch a unicycle park itself this way.
The green triangle is the goal pose; the teal one is the robot. Click anywhere to drop the robot at a new start pose, then press Run. Watch it drive a curving, nonholonomic path to the goal — never sliding sideways, always rolling along its heading — and stop facing the right way. The dotted line is the goal-pointing vector that α measures.
python # Differential-flatness trajectory tracker for the extended unicycle. # This is exactly Stanford's HW1 Problem 3, from scratch. import numpy as np class TrajectoryTracker: def __init__(self, kpx, kpy, kdx, kdy): self.kpx, self.kpy, self.kdx, self.kdy = kpx, kpy, kdx, kdy self.V_prev = 0.5 # last commanded speed; avoids the J singularity self.V_MIN = 0.1 # floor so det(J)=V never hits zero def compute_control(self, x, y, th, xd, yd, xdd_d, ydd_d, xd_dot, yd_dot, dt): # 1) virtual controls: feedforward accel + PD on position & velocity error V = max(self.V_prev, self.V_MIN) xdot, ydot = V*np.cos(th), V*np.sin(th) # current velocity from kinematics w1 = xdd_d + self.kpx*(xd - x) + self.kdx*(xd_dot - xdot) w2 = ydd_d + self.kpy*(yd - y) + self.kdy*(yd_dot - ydot) # 2) invert J to map (w1, w2) -> real controls (a, om) J = np.array([[np.cos(th), -V*np.sin(th)], [np.sin(th), V*np.cos(th)]]) a, om = np.linalg.solve(J, np.array([w1, w2])) # [a; om] = J^-1 [w1; w2] # 3) integrate the commanded acceleration into a speed command V_new = V + a*dt self.V_prev = V_new return V_new, om # the actuator commands the robot can obey
Trace the three steps against the math above: build the virtual accelerations (feedforward + PD), invert J to translate them into wheel-and-steering language, then turn the commanded acceleration into a speed. The V_prev floor is the singularity guard from the callout — without it, a noise-induced V near zero would make np.linalg.solve blow up.
We have been tuning gains by feel. To tune them by theory — and to set up LQR in Chapter 6 — we need one unifying language: the state-space model. Almost every linear control system is written:
where x is the state vector (stacked: position, velocity, …), u is the control vector, A (the system matrix) describes how the state evolves on its own, and B (the input matrix) describes how the control pushes the state. Everything — a mass, a drone, a linearized robot — fits this mold.
Take the second-order mass from Chapter 3. Its position is p and velocity is v; the command u is a force (acceleration, for unit mass). The physics: position changes at the velocity rate (ṗ = v), and velocity changes at the commanded acceleration (v̇ = u). Stack the state as x = [p ; v]. Then:
So A = [[0,1],[0,0]] (the "double integrator") and B = [0;1]. Read row by row: row 1 says ṗ = 0·p + 1·v = v. Row 2 says v̇ = 0·p + 0·v + 1·u = u. The matrix is just the physics, organized.
Now apply a linear state-feedback law: command a control that is a (negative) linear combination of the states, u = −K x, where K = [k1, k2] is the gain matrix (k1 multiplies position error, k2 multiplies velocity — exactly P and D!). Substitute into ẋ = A x + B u:
This is the key move. Feedback transforms the open-loop matrix A into the closed-loop matrix (A − BK). The robot now evolves according to a new matrix that you designed by choosing K. Control design is literally reshaping (A − BK) into something well-behaved.
When does ẋ = M x decay to zero (stable) versus blow up (unstable)? The answer is one of the cleanest facts in engineering: it depends entirely on the eigenvalues of M. An eigenvalue λ is a number for which there's a direction the system shrinks or grows along like eλt. So:
This is the rigorous version of Chapter 1's "ė = −k·e is stable when k > 0" — there, M = −k was a 1×1 matrix whose single eigenvalue is −k, negative exactly when k > 0. Same idea, now in any dimension.
Take the double integrator A = [[0,1],[0,0]], B = [0;1], gain K = [k1, k2]. Then BK = [[0,0],[k1,k2]], so:
The eigenvalues λ solve det(A−BK − λI) = 0. Compute the determinant of [[−λ, 1], [−k1, −k2−λ]]:
That is a quadratic in λ whose coefficients are exactly your gains: the constant term is k1 (the P gain) and the λ coefficient is k2 (the D gain). Suppose you want both eigenvalues at −2 (a nice fast, non-oscillatory decay). The polynomial with both roots at −2 is (λ+2)2 = λ2 + 4λ + 4. Match coefficients:
We just chose where the eigenvalues live and read off the gains that put them there. That is pole placement (eigenvalues of the system are called "poles"). It is exact and principled — no slider-wiggling. Its weakness: where should the poles go? Pole placement makes you pick by hand, and for high-dimensional systems there's no obvious "best" location. That question — where to put the poles to balance speed against control effort, optimally — is precisely what LQR answers automatically. On to Chapter 6.
Suppose instead you want a faster, slightly oscillatory response: both eigenvalues at −3 ± 3i (real part −3 for quick decay, imaginary part 3 for a little overshoot). The polynomial with those roots is (λ−(−3+3i))(λ−(−3−3i)) = (λ+3)2 + 32 = λ2 + 6λ + 18. Match it to λ2 + k2λ + k1:
Both gains are bigger than the −2,−2 case (4 and 4) — faster poles cost more gain, which means more control effort. There's the trade again, now visible in the gains themselves. Drag the widget's k1, k2 to 18 and 6 and confirm the eigenvalue markers sit at −3 ± 3i on the complex plane, with a response that overshoots once then settles.
Because robots run discrete loops, it helps to see stability in discrete form too. A discrete system steps as xt+1 = M xt, and it is stable when every eigenvalue of M has magnitude less than 1 (inside the unit circle) — not "negative real part." That's the discrete analogue: a mode multiplies by λ each tick, so |λ| < 1 shrinks it and |λ| > 1 grows it.
Tie it back to Chapter 2's P-controller. There the discrete error obeyed et+1 = (1 − Δt·Kp)·et — a 1×1 system with the single eigenvalue λ = 1 − Δt·Kp. Stability needs |1 − Δt·Kp| < 1, which rearranges to 0 < Δt·Kp < 2. That is exactly the stability bound we'll re-derive in Chapter 9 — and now you can see it's just "keep the eigenvalue inside the unit circle." Continuous left-half-plane and discrete unit-circle are the same stability story told in two clocks.
Pole placement (and LQR) only works if the control input can actually reach every mode of the system — a property called controllability. If some part of the state evolves on its own and no input can touch it, no gain matrix will tame it. For our double integrator, the input drives velocity, which drives position, so both states are reachable: the system is controllable, and we can place the poles anywhere. A formal check (the controllability matrix [B, AB] being full rank) confirms it, but the intuition is enough here: feedback can only fix what the input can influence.
python # Pole placement with a library, matching our hand result for the double integrator. import numpy as np from scipy.signal import place_poles A = np.array([[0., 1.], [0., 0.]]) B = np.array([[0.], [1.]]) desired = [-2, -2.001] # both ~ -2 (solver dislikes exact repeats) K = place_poles(A, B, desired).gain_matrix print(K) # ~ [[4, 4]] -> k1=4 (P), k2=4 (D), as derived by hand # closed-loop matrix and its eigenvalues: print(np.linalg.eigvals(A - B @ K)) # ~ [-2, -2]
Left: the complex plane showing the two closed-loop eigenvalues of (A−BK). Right: the resulting position response over time. Drag the gains k1 (=P) and k2 (=D); the eigenvalues move and the response changes. Poles deep in the left half-plane → fast decay; poles with imaginary parts → oscillation; a pole crossing into the right half → instability (response blows up).
Pole placement asks you to pick where the eigenvalues go. But that just trades one tuning problem (gains) for another (pole locations). What you actually want is to say what you care about — "track well, don't waste energy" — and have the math hand you the best gain. That is the Linear Quadratic Regulator (LQR), the workhorse optimal controller for linear systems.
LQR is defined by an objective: among all possible control histories, find the one that minimizes a quadratic cost integrated over all time:
Decode each piece carefully — this cost is the controller's value system:
The remarkable result: the control law that minimizes that infinite integral is just linear state feedback — the same u = −Kx form from Chapter 5, but now with the optimal K:
where P is a special matrix found by solving the algebraic Riccati equation — the central equation of optimal control:
That equation looks intimidating, so let's strip it to one dimension and solve it completely by hand to see there's nothing magic inside.
Take the simplest system: scalar state x, scalar control u, dynamics ẋ = u (so a = 0, b = 1). Costs q and r are just positive numbers. The Riccati equation, with A = 0, B = 1, becomes:
Solve for p. We need p > 0 (P must be positive), so:
Then the optimal gain is k = r−1·b·p = (1/r)·1·√(qr) = √(qr)/r = √(q/r):
Look how beautifully the Q/R trade-off falls out. Let's plug in numbers. If q = 1, r = 1 (care equally), then k = √1 = 1. The closed-loop dynamics become ẋ = −kx = −x, decaying like e−t — modest. Now make tracking matter 100× more: q = 100, r = 1, then k = √100 = 10 — ten times more aggressive, dynamics e−10t, snapping back fast. Conversely make control expensive, q = 1, r = 100, then k = √(1/100) = 0.1 — lazy, e−0.1t, gentle.
And the closed-loop behavior follows immediately. The optimal control closes the loop as ẋ = u = −kx = −√(q/r)·x, whose solution is x(t) = x(0)·e−√(q/r)·t. The time constant is τ = 1/k = √(r/q) — the very same exponential-decay machinery from Chapter 1, but now the decay rate is derived from your cost preferences rather than guessed. Want the state at 5% of its initial value within 1 second? You need 3τ ≤ 1, so τ ≤ 0.33, so k ≥ 3, so q/r ≥ 9. You can dial in a settling-time spec and read off the required cost ratio. That is engineering, not knob-twiddling.
For the double integrator A = [[0,1],[0,0]], B = [0;1], with Q = diag(q, 0) (penalize position error only) and R = r, the algebra (which we'll spare you) yields the optimal gain K = [√(q/r), √(2)·(q/r)1/4]. With q = 1, r = 1 that's K ≈ [1, 1.41], placing both poles at −0.71 ± 0.71i — a critically-damped-ish, no-overshoot response that pole-placement would have made you guess at. LQR found the "nice" poles for you, from a cost you actually understood. Notice the structure: the position gain is √(q/r) (the same P-like dial as the scalar case) and the velocity gain is √2·(q/r)1/4 (a D-like damping term). LQR is, in the end, computing a principled P and D gain for you.
Real controllers are discrete (Chapter 9), and there's a discrete cousin of everything above. The dynamics are xt+1 = Adxt + Bdut, the cost is a sum ∑(xtTQxt + utTRut), and the optimal gain comes from the discrete algebraic Riccati equation. The conceptual payoff is the finite-horizon version, which you can compute by a simple backward recursion — and it reveals where LQR really comes from: dynamic programming working backward from the end.
Take a tiny scalar example to feel it. System xt+1 = xt + ut (Ad = 1, Bd = 1), costs q = 1, r = 1, horizon of just 2 steps with a terminal penalty P2 = q = 1. The backward Riccati recursion Pt = q + Ad2Pt+1 − (AdBdPt+1)2/(r + Bd2Pt+1) unrolls:
| step t | Pt+1 | Pt = 1 + Pt+1 − Pt+12/(1+Pt+1) | gain kt = Pt+1/(1+Pt+1) |
|---|---|---|---|
| 1 | 1.000 | 1 + 1 − 1/2 = 1.500 | 1/2 = 0.500 |
| 0 | 1.500 | 1 + 1.5 − 2.25/2.5 = 1.600 | 1.5/2.5 = 0.600 |
The gain is different at each step — 0.60 then 0.50 — bigger early (lots of horizon left to benefit from correcting) and smaller near the end (no point spending control if the episode is ending). As the horizon grows long, Pt stops changing and settles to a fixed value (here ≈ 1.618, the golden ratio!), and the gain settles to a constant — that steady value is the infinite-horizon LQR gain. So the infinite-horizon controller we use day-to-day is just the limit of this backward recursion. The algebraic Riccati equation is the "I've converged" condition: Pt = Pt+1.
For a multi-state robot, Q and R are matrices, and their diagonal entries are how you express "I care about this state/control more than that one." A clean recipe is Bryson's rule: set each diagonal entry to 1 over the square of the largest value you'll tolerate for that variable.
Say your unicycle's pose error matters: you'll tolerate at most 0.2 m of position error and 0.1 rad of heading error, and at most 0.5 m/s of speed command and 1 rad/s of turn. Then Q = diag(1/0.22, 1/0.22, 1/0.12) = diag(25, 25, 100) — heading errors are penalized 4× harder because the tolerance is tighter. And R = diag(1/0.52, 1/12) = diag(4, 1). Each entry now has a physical meaning, so tuning becomes "what error/effort can I live with?" instead of "what number feels right?"
A scalar system ẋ = u under optimal LQR. Slide the state cost q and control cost r; the gain k = √(q/r) and the closed-loop response x(t) = x0e−kt update live. The right panel plots both the state trajectory (teal) and the control effort u = −kx (warm). Big q snaps the state to zero but spends big control; big r is gentle but slow. Watch the trade.
python # Scalar LQR solved by hand vs. the library, to prove they agree. import numpy as np from scipy.linalg import solve_continuous_are # the Riccati solver # by hand: x_dot = u, so A=0, B=1; cost weights q, r q, r = 100.0, 1.0 k_hand = np.sqrt(q / r) # = 10.0, from p = sqrt(qr), k = p/r # by library: solve A^T P + P A - P B R^-1 B^T P + Q = 0 A, B, Q, R = np.array([[0.]]), np.array([[1.]]), np.array([[q]]), np.array([[r]]) P = solve_continuous_are(A, B, Q, R) K = np.linalg.inv(R) @ B.T @ P # K = R^-1 B^T P print(k_hand, K[0,0]) # 10.0 10.0 -- identical
LQR is built on a linear model ẋ = Ax + Bu. But a robot tracking a curving trajectory is not linear — its dynamics depend on its heading θ, which changes constantly along the path. A single LQR gain, computed once, will track well on the parts of the path that look like the model used to compute it, and poorly elsewhere. The fix is gain scheduling: linearize the dynamics about each point of the trajectory and recompute the LQR gain there.
The robot's true dynamics are nonlinear, ẋ = f(x, u). At any point along the reference (xd(t), ud(t)), we approximate f by its tangent — a first-order Taylor expansion in the error coordinates δx = x − xd, δu = u − ud:
Here A(t) and B(t) are the Jacobians of f — the matrices of partial derivatives ∂f/∂x and ∂f/∂u — evaluated at the reference point at time t. Because the reference moves and curves, these Jacobians change along the trajectory. A sharp turn has a different A than a straight stretch.
Let's make "B depends on heading" concrete. The unicycle is ẋ = V cosθ, ẏ = V sinθ, θ̇ = ω, with controls (V, ω). The input matrix B is ∂f/∂u — how the two controls push the three states. Differentiating: V appears with cosθ in ẋ and sinθ in ẏ; ω appears alone in θ̇. So:
Now plug in two headings the robot passes through on a curving path. Heading east (θ = 0): cos0 = 1, sin0 = 0, so B = [[1,0],[0,0],[0,1]] — a forward command V moves x only. Heading north (θ = 90°): cos = 0, sin = 1, so B = [[0,0],[1,0],[0,1]] — the same forward command V now moves y only. The columns of B literally rotate with the robot.
A gain K computed assuming "V pushes x" (east) will, when the robot has turned north, push the wrong axis — it corrects an x-error by commanding forward motion that actually moves the robot in y. On a gentle curve the mismatch is small; on a hairpin it's catastrophic. Recomputing B(θ) and re-solving LQR at, say, every 15° of heading keeps the model honest the whole way around. That table of gains, indexed by heading, is the gain schedule.
python # Build a gain schedule along a trajectory: one LQR gain per sampled point. import numpy as np from scipy.linalg import solve_continuous_are def unicycle_B(theta, V): return np.array([[np.cos(theta), 0], [np.sin(theta), 0], [0, 1]]) A = np.zeros((3, 3)) # pose drifts only via the inputs here Q = np.diag([2., 2., 1.]); R = np.eye(2) schedule = [] for theta in np.linspace(0, 2*np.pi, 24): # sample 24 headings around a loop B = unicycle_B(theta, V=0.4) P = solve_continuous_are(A, B, Q, R) K = np.linalg.inv(R) @ B.T @ P # the LQR gain for THIS heading schedule.append((theta, K)) # at runtime: pick the K for the heading nearest the robot's current pose.
At runtime the controller applies u = ud(t) − K(t)·(x − xd(t)) — the feedforward from the plan, plus the locally-optimal feedback for wherever the robot currently is on the path. As the robot moves along the curve, the active gain K(t) is swapped for the one computed at that segment. This is exactly how aircraft autopilots have worked for decades: a table of LQR gains indexed by flight condition (airspeed, altitude), interpolated in real time.
Push gain scheduling to its limit — recompute the gain at every instant — and you get the time-varying LQR, where K(t) comes from a Riccati equation that you integrate backward in time from the end of the trajectory. This is the bridge to optimal trajectory tracking and, ultimately, to model predictive control (Chapter 9). For most robots, a modest schedule of a dozen gains around the path is plenty — you don't need the full continuum.
Why backward in time? Because the optimal action now depends on what you intend to do later — the cost-to-go from the end. You met exactly this in the discrete recursion of Chapter 6, where the gain was bigger early (lots of horizon left) and smaller near the end. Time-varying LQR is that recursion run on the linearized-along-the-trajectory model: at each instant t it has its own A(t), B(t), its own P(t), and its own K(t). Gain scheduling is the practical, sampled cousin: instead of the full continuum of K(t), you precompute a handful and interpolate.
Everything comes together here. A robot must follow a reference path (the grey loop) while disturbances try to shove it off. You choose the controller — pure P, full PID, or LQR — tune its gains, and push the robot with the disturbance button to see how each recovers. The bottom strip plots the tracking error over time so you can compare them quantitatively.
The robot (teal) tracks the grey reference loop. Pick a controller, press Run, then hit 💨 Push to inject a disturbance and watch it recover. The lower strip is the live tracking-error plot. Crank Kp toward the top and watch the error oscillate, then blow up — instability. LQR's q/r dial sets its gain automatically.
Play with it until you feel each controller's personality. Set it to P, run, and push: it lurches back but settles a little off the line, and on the curved sections it permanently lags inside the turn — that's the steady-state error and phase lag you proved in Chapter 2. Switch to PID: the offset vanishes (integral) and the recovery from a push is smooth, not bouncy (derivative). Switch to LQR and slide q up: the robot hugs the reference like it's magnetized, and the error plot snaps back to zero after each push with minimal overshoot — the optimal gain at work.
Now break it. With any controller, drag Kp (or q) toward the maximum. The recovery from a push first gets faster, then starts overshooting, then oscillating around the path, and finally the error plot diverges — the robot spirals off and the loop is unstable. You are watching the closed-loop eigenvalues from Chapter 5 march from deep in the left half-plane, toward the imaginary axis (oscillation), and across it (blow-up). High gain is not free.
If you want a checklist while you play, here is the behavior each controller should exhibit — and the chapter that explains why:
| Controller | On the curves | After a push | At high gain | Why (chapter) |
|---|---|---|---|---|
| P | lags inside the turn; a steady offset never closes | lurches back, settles slightly off | overshoots, then rings, then diverges | steady-state error (Ch 2) |
| PID | offset vanishes; hugs the path | smooth, well-damped recovery | D delays the blow-up but it still comes; watch noisy twitch | I kills offset, D damps (Ch 3) |
| LQR | tracks like it's magnetized to the path | fastest clean recovery for least thrash | raising q stiffens it toward instability too | optimal gain k=√(q/r) (Ch 6) |
The single most instructive experiment: set P, run, and stare at the curved sections of the loop. The robot consistently cuts the corner — it lags inside every turn. That lag is the moving-reference version of steady-state error: the reference keeps moving, the P-controller always trails it by a fixed amount because it needs a standing error to generate the command that keeps up. Switch to PID and the corners snap tight: the integral term builds exactly the standing command the turn needs, so the error drops to zero. You are watching, live, the same effect the downhill-robot table proved with numbers in Chapter 2.
Here is the loop the showcase runs, distilled. It is the feedforward-plus-feedback structure from Chapter 1, with the feedback term swappable between P, PID, and LQR — exactly the toggle in the widget. This is the Control module of the autonomy stack, in about thirty lines.
python # The trajectory-tracking loop: feedforward + pluggable feedback, with disturbance. import numpy as np def track(controller, ref_fn, x0, T, dt, disturbance): x = np.array(x0, dtype=float) # [px, py] errors = [] for step in range(int(T/dt)): t = step*dt xd, vd = ref_fn(t) # desired position and its velocity (feedforward) e = xd - x # tracking error (Chapter 1) u_fb = controller.feedback(e, dt) # P / PID / LQR all share this interface u = vd + u_fb # feedforward + feedback x = x + dt*u + disturbance(t) # integrate; the world pushes back errors.append(np.linalg.norm(e)) return errors class P: def __init__(self, kp): self.kp = kp def feedback(self, e, dt): return self.kp*e class PID: def __init__(self, kp, ki, kd): self.kp, self.ki, self.kd = kp, ki, kd self.I = np.zeros(2); self.prev = np.zeros(2) def feedback(self, e, dt): self.I += e*dt; d = (e - self.prev)/dt; self.prev = e return self.kp*e + self.ki*self.I + self.kd*d class LQR: # scalar-per-axis gain k = sqrt(q/r) def __init__(self, q, r): self.k = np.sqrt(q/r) def feedback(self, e, dt): return self.k*e
Notice all three controllers expose the same feedback(e, dt) interface — the tracker doesn't care which one it holds. That's the engineering payoff of thinking in terms of error: the loop is universal; only the feedback law changes. Swap P(3) for PID(3, 1, 2) for LQR(9, 1) and you change the robot's personality without touching the loop.
The controllers above were written in continuous time, with infinitely precise sensors and infinitely strong motors. Real robots violate all three assumptions. Three practical realities separate a textbook controller from one that survives on hardware.
A digital controller doesn't run continuously; it runs in a loop at some fixed sample rate (say 50 Hz, so Δt = 0.02 s). Between samples, it holds the last command constant (a "zero-order hold"). Every integral and derivative becomes a discrete sum and difference, exactly as in our worked tables:
Recall the P-controller table from Chapter 2: xt+1 = xt + Δt·Kp·(xd − xt). The discrete error obeys et+1 = (1 − Δt·Kp)·et. For this to decay, the multiplier must satisfy |1 − Δt·Kp| < 1, i.e. 0 < Δt·Kp < 2. Watch the three regimes with Kp = 1:
| Δt·Kp | multiplier (1 − Δt·Kp) | behavior |
|---|---|---|
| 0.5 | +0.5 | smooth decay (each step keeps half the error) — stable |
| 1.0 | 0.0 | deadbeat: error to zero in one step — the fastest stable case |
| 1.5 | −0.5 | decays but alternates sign each step — ringing |
| 2.5 | −1.5 | |mult| > 1 — error grows and flips sign — UNSTABLE |
The exact same gain Kp = 1 that is unconditionally stable in continuous time (ė = −e always decays) goes unstable the moment the timestep Δt exceeds 2 — the discretization, not the gain, broke it. This is the single most common surprise when a controller that worked in simulation misbehaves on a robot whose loop runs slower than the simulator. Faster sampling (smaller Δt) shrinks Δt·Kp back into the stable zone.
No motor is infinitely strong. The unicycle homework caps |V| ≤ 0.5 m/s and |ω| ≤ 1 rad/s. When the controller commands more than the actuator can deliver, the command saturates — clipped to the limit. Two consequences:
The fix is anti-windup: stop accumulating the integral whenever the actuator is saturated (or "back-calculate" the integral from the clipped output). One clean version: only add to the integral if the unsaturated command is within limits.
python # Saturation + anti-windup, the version that ships on real robots. def step(self, x, x_des, u_max): e = x_des - x deriv = (e - self.prev_e) / self.dt; self.prev_e = e u_unsat = self.Kp*e + self.Ki*self.integral + self.Kd*deriv u = max(-u_max, min(u_max, u_unsat)) # clip to actuator limit if u == u_unsat: # NOT saturated -> safe to integrate self.integral += e * self.dt # if saturated, freeze the integral: no windup return u
PID and (gain-scheduled) LQR are reactive: they respond to the current error with a fixed feedback law and have no built-in way to respect hard constraints like "don't exceed 0.5 m/s" or "don't hit that wall ahead." Model Predictive Control is the next step: at every tick it solves a small optimization — "given my model, find the command sequence over the next N steps that minimizes cost while obeying all constraints" — executes only the first command, then re-solves next tick.
The shape of one MPC tick, in pseudocode, makes the difference from LQR crisp:
python # One MPC tick: solve a constrained optimization, apply only the first command. def mpc_step(x_now, ref, N, dt): # decide the next N commands u[0..N-1] all at once... u = solve_optimization( objective = lambda u: sum(cost(x_k, u_k) for x_k, u_k in rollout(x_now, u, dt)), constraints = [ "|V_k| <= 0.5 for every k", # hard actuator limit -- LQR can't say this "x_k not in obstacle for every k", # hard safety limit -- LQR can't say this ], ) return u[0] # apply ONLY the first command, then re-solve next tick ("receding horizon")
LQR's gain is computed once, offline, and ignores constraints — it just trusts that the unconstrained optimum stays feasible. MPC re-solves every tick with the constraints in the problem, so it can refuse to command 0.6 m/s when the limit is 0.5, and can plan a curve around an obstacle it sees ahead. The price is solving an optimization at control rate, which is why MPC needs more compute than the matrix-multiply that LQR's u = −Kx boils down to.
The arc of this lesson is the arc of control engineering itself: P (react to now) → PID (add memory and anticipation) → pole placement (design the eigenvalues) → LQR (optimize the gain) → MPC (optimize over a horizon with constraints). Each step buys more capability for more computation. Pick the simplest one that meets your spec — a Roomba is happy with P; a self-driving car needs MPC.
You now hold the Control module of the autonomy stack. It is the layer that takes Lesson 5's beautiful-but-fragile open-loop trajectory and makes it survive the real world by closing the loop — the very thing that, way back in Lesson 1, separated a Waymo from a welding arm.
| Controller | What it does | Kills steady-state error? | Use when… |
|---|---|---|---|
| P | command ∝ current error | No (settles with offset under load) | simple, fast, error tolerance is loose (wall-following, basic speed hold) |
| PI | P + accumulated past error | Yes (integral) | zero offset matters but the system isn't oscillation-prone (cruise control, temperature) |
| PID | P + I + rate-of-error | Yes | second-order/inertial systems that overshoot; the universal default (drones, arms) |
| LQR | optimal u = −Kx from a Q/R cost | Yes (with integral augmentation) | multi-state systems where you can write the dynamics and want the best gain, not a guessed one |
| Gain-sched. LQR | LQR re-linearized along the path | Yes | tracking curving/fast trajectories where one linearization isn't enough |
| MPC | optimize over a horizon w/ constraints | Yes | hard actuator/safety constraints, look-ahead needed (self-driving, rockets) |
Control is the green layer — the last one before the actuators. It receives the planner's trajectory and the localizer's pose estimate, and emits the motor commands that actually move the robot. Look back at the showcase: every push you injected was a disturbance, and every recovery was feedback doing the job the welding arm could never do.