WEEK 8

Real-Time Perception II — The Self-Driving Car

Last week you built a stage that turns pixels into events. This week you close the loop: the event steers a vehicle, and the vehicle does not stop moving while your code thinks. A controller that works perfectly on fresh, clean measurements will oscillate itself off the road on measurements that are 150 ms old — and nothing about the controller changed.

Project P2 · your intelligence module goes live Simulator: rtsim, fixed step, seeded, headless Week 9 breaks this loop on purpose
Part 1

1What is a closed loop?

Sense, decide, act — and then sense the consequence of what you just did. Everything you have built so far this course was a pipeline. This is the first week where your output changes your input.

The vehicle is a kinematic bicycle: two state variables you steer with and two you integrate. No tyre forces, no slip, no mass. Below about 15 m/s on a wide lane that model is close enough that the control problem is honest, which is the only part this week is about.

# rtsim/model.py — the whole vehicle
x'     = v cos(theta)
y'     = v sin(theta)
theta' = v / L * tan(steer)        # L = 2.7 m wheelbase
v'     = accel
# |steer| <= 30 deg, |accel| <= 3 m/s^2

Two sign conventions carry everything else: cross_track > 0 means the car is to the left of the centreline, and steer > 0 turns the car left. So a lane-keeping controller is negative feedback on cross_track. Get that sign backwards and the car leaves the road in half a second, which is at least a fast, honest failure.

The simulator is deliberately fixed-step: one step() is exactly dt of simulated time no matter how long your controller took to think. That separation is the whole point of this week and next — the world does not wait for your code, so you model the lateness explicitly instead of pretending your wall clock is the world's clock.

# the loop, and the two knobs that make it real
sim = Sim(Track.oval(), Car(v=8.0, y=1.0), dt=0.02, seed=0,
          noise=Noise(cross_track=0.05),      # the sensor lies a little
          latency=Latency(sensor=7))          # ...and 7 steps late = 140 ms

def controller(obs):                        # obs is ALL you may look at
    return -(0.55 * obs.cross_track + 1.1 * obs.heading_err), 0.0

res = sim.run(controller, n_steps=2000, control_every=1)
print(res.summary())   # rms_cte_m, max_cte_m, laps, collided

What the controller may look at

One Obs per step. Ground truth exists in the simulator and is not in this object — you score against the truth, you steer on the observation.

fieldunitswhat it is
cross_trackmsigned lane offset, + = left
heading_errradtheta − path heading
speedm/scurrent forward speed
curvature1/mthe road's bend at the car
lookaheadm, ma point ahead, in the car's frame
rangesmobstacle ray fan
Two corruptions, both injectable, both seeded. Noise is gaussian, per channel. Latency is dead time in simulator steps, applied separately to sensing and to actuation. Dead time is the one that kills you.
dt
20 ms
= loop rate
50 Hz
2000 steps
40 s

Widget 1 · perception → planning → control, with a stopwatch on each stage

Click a stage to see what it owns and how it fails. Then spend the 20 ms period: everything the loop does between one sensor read and the next command has to fit, and whatever does not fit becomes dead time in the next widget.

loop total
19.8 ms
period
20.0 ms
dead time
1 step
Latency and period are different things. The period is how often you decide; the latency is how old your information is when you decide. You can run a 50 Hz loop on 140 ms-old perception, and it will feel like driving with your eyes closed for an eighth of a second at a time — because it is.
Part 2

2How does it work? Drive the thing

Two controllers, one track, one score. PID is negative feedback on the error you can see; pure pursuit is geometry — aim at a point on the path ahead and turn along the arc that reaches it.

They fail differently, and that is why you learn both. A PID lane-keeper with a large proportional gain is fast and twitchy; it fights the road's curvature because a constant curve is a constant error it can only reduce, never remove. Pure pursuit is smooth and inherently feed-forward — it aims where the road goes — but its one knob, the lookahead distance, is a direct trade: short is precise and unstable, long is stable and cuts corners.

Widget 2 · the 2D driving simulator

Fixed step, seeded, and identical on every run: same gains, same numbers, bit for bit. Tune the controller, then read the score — RMS cross-track error against the true centreline, never against what the sensor said.

t = 0.00 s
RMS after 3 s
—
peak |cte|
—
laps
—
steer effort
—
outcome
—
Two numbers, deliberately. RMS after 3 s is the tracking score: the first seconds are the initial offset being removed, and that transient saturates the steering, so including it would hide every difference between gains. Peak is the worst excursion, which on a clean run is just that offset. Score against the truth. Result.cte is the true cross-track error; Result.cte_obs is what the controller was shown. Grading yourself on the observation is how a filter that smooths away the error — without ever reducing it — gets called a success.

Now corrupt the sensor. Two knobs, two completely different diseases: noise makes the command jitter around the right answer, and a well-damped controller mostly averages it out. Dead time makes the command correct for a world that has moved on, so the controller over-corrects, then over-corrects the over-correction. That is not noise; that is a phase lag, and it turns negative feedback into positive feedback.

Widget 3 · noise and latency injector

Same track, same gains, same seed — run twice. The clean lane is the run you tuned; the corrupted lane is the run you will actually ship. Watch which knob costs you accuracy and which one costs you stability.

clean RMS
—
corrupted RMS
—
× worse
—
steer reversals / s
—
dead time
0 steps
outcome
—
clean sensor corrupted sensor lane edge ±1.75 m
The fix is not a bigger gain. Dead time punishes gain: the higher the gain, the more violently you act on stale information. Under lag you either lower the gain, or predict forward through the known delay and control on the prediction — which is exactly what week 9's filter is for.
Part 3

3Why does it matter? Latency has a stopping distance

In a trading loop, latency costs you a fill. Here it costs you metres — and metres are measurable, unforgiving and easy to reason about, which is why this week teaches the lesson better than the market did.

Reaction time is not one number, it is the sum of the whole loop: the frame you were unlucky enough to be between, perception, estimation, planning, the command going out, and the actuator moving. Every millisecond in that chain becomes metres travelled before anything begins to change, and it is added to the physical stopping distance, which you cannot negotiate at all.

Widget 4 · obstacle at range — brake, swerve, or hit it

One obstacle appears in the lane. The only things you control are the reaction time your loop achieves and the policy it runs. Everything else is arithmetic.

travelled thinking
—
braking distance
—
total needed
—
margin
—
impact speed
—
outcome
—
Swerving is not free. A lane change needs lateral distance and the time to make it: at 14 m/s you need roughly a second of clear road to move 3.5 m at a comfortable lateral rate, and that second is 14 m of forward travel you have to have. Braking scales with the square of speed; reaction distance scales linearly. That is why halving your loop latency matters most at low speed, and why nothing but braking capability saves you at high speed.

The same loop you already run

Your bot's on_tick and this car's controller are the same object with different units. That is the point of putting them in one course.

 trading botvehicle
senseorder-book tickcamera frame + odometry
estimatemid, imbalance, volcross-track, heading, curvature
decidesignal → sizesteer → accel
actorder to the exchangecommand to the wheel
deadlinethe next tickthe next 20 ms step
failurestale quote, bad fillstale pose, off the road
P2 is due this week. Whichever module you chose — surveillance detector, cross-venue signal, or the perception stage — it now lives inside a loop with a budget, and it is graded on the design and the measurement, not on the P&L.

Tuning, in the order that works

  • Zero everything, then raise Kd first. Damping on heading error is what keeps the car pointing along the road; without it, any Kp oscillates.
  • Raise Kp until it is quick, then back off 30 %. The last 30 % of speed is where all the fragility lives.
  • Add Ki only for a standing error — a constant curve, a wheel-alignment bias. It is the gain most likely to wind up and throw you off the road.
  • Re-tune at the speed you will drive. Every gain here is speed-dependent: theta' is proportional to v, so a gain tuned at 8 m/s is a different controller at 16.
  • Then, and only then, add the sensor. Tune on clean measurements, verify under noise and lag, and record both numbers. A controller with one number has not been measured.
Part 4

4Five ways a closed loop goes open

Each of these produces a car that drives beautifully in the demo you rehearsed. Open them.

Tuning on a perfect sensor, shipping on a real one

You find Kp = 1.8 on noiseless, zero-latency observations, and it is genuinely the best controller on that problem: fast, tight, RMS error a few centimetres. Then you connect last week's perception stage, which is 9 ms of inference behind a 33 ms frame, and the car starts weaving. Nothing about the controller changed — the plant it is controlling did, because dead time is part of the plant.

Fix: tune with the delay you will actually have. Report two RMS numbers, clean and corrupted, and treat the gap as the honest measure of your controller's robustness.

Raising the gain to fix a lag-induced oscillation

The car is weaving, so you assume it is not correcting hard enough. You raise Kp. It weaves harder. This is the single most common instinct and it is exactly backwards: under phase lag, higher gain means you act more violently on information that is already wrong, and the loop's own correction becomes the next disturbance.

The signature tells you which disease you have. Noise looks like fast, small, uncorrelated jitter around the right answer. Lag looks like a smooth, growing oscillation at a characteristic period — roughly four times the dead time.

Fix: measure the dead time, then either lower the gain, or predict the state forward through the delay and control on the prediction. Never both at random.

Scoring the controller on what the sensor said

rms(cte_obs) instead of rms(cte). Add a heavy filter and the observed error becomes beautifully small while the car sits half a metre off the centreline the whole time. You have optimised your own instrumentation.

The simulator separates these two arrays for exactly this reason — and on a real vehicle you buy ground truth (a survey, a better sensor on the test rig) precisely so that you can score honestly.

Fix: score against ground truth, always. Report the observation error separately, as a property of the sensor, not of the controller.

Letting the wall clock be the world's clock

dt = time.time() - last inside the loop. Now your integration step depends on how busy the machine was, your integral term winds differently every run, and the same bug is unreproducible — because it is not the same experiment twice.

The fixed-step simulator forbids this by construction: one step() is exactly dt, and slowness is modelled as control_every and Latency rather than smuggled in through a variable timestep.

Fix: fixed dt, explicit lateness. Then week 9's deterministic replay is possible at all — and without it, no post-mortem you write can be trusted.

An integral term with no anti-windup

The car sits against its steering limit through a long bend. Ki keeps accumulating an error it physically cannot reduce, so by the exit of the curve the integrator holds a huge number and the car swings hard the wrong way and leaves the road on the straight — long after the disturbance is over.

Same failure family as an unbounded queue: a state variable that only ever grows while the thing it is supposed to influence is saturated.

Fix: clamp the integrator, stop accumulating whenever the actuator is saturated, and prefer feed-forward on the known road curvature over integral action for a standing error.

Part 5

5Check yourself

Instant feedback, no grading. Pick the answer you would defend in a design review.

Q1You add 150 ms of sensing dead time and the car begins a smooth, growing weave. What is the correct response?

Q2In pure pursuit you shrink the lookahead distance from 8 m to 2 m. What should you expect?

Q3Detection range 40 m, speed 20 m/s, loop reaction 180 ms, braking 6 m/s². Roughly how much room do you need, and which term dominates?

Part 6

6Do the work

In class

  • Close the loop in rtsim with a PID lane-keeper; get RMS cross-track error under 10 cm on the oval.
  • Swap in pure pursuit behind the same interface and compare both on the same seed.
  • Feed the loop from week 7's perception stage instead of the analytic read.
  • Inject 150 ms of sensing lag and watch a stable controller oscillate. Record the period.
  • Re-tune under the lag, and report both numbers — clean and corrupted.

Before next class

  • Project P2 — your intelligence module merged and live in the season, with the design note and the measurement that justify it.
  • Log every run: seed, gains, noise, latency, and the summary. Next week you will be asked to replay one of them exactly.
  • Pick the run that went worst. That is week 9's material.
Code
Get the code
The starter repo is public. Use it as a template, clone it, make install, and drive the simulator from your own controller.
Map
Course home
The ten-week map, and where the perception block sits inside it.
Next
Week 9 · Hard Real-Time & Safety →
Deadlines, jitter, deterministic replay, and a monitor that degrades instead of failing.
← Week 7 · Real-Time Perception I Week 9 · Hard Real-Time & Safety →