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.
rtsim, fixed step, seeded, headless
Week 9 breaks this loop on purpose
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
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.
| field | units | what it is |
|---|---|---|
| cross_track | m | signed lane offset, + = left |
| heading_err | rad | theta − path heading |
| speed | m/s | current forward speed |
| curvature | 1/m | the road's bend at the car |
| lookahead | m, m | a point ahead, in the car's frame |
| ranges | m | obstacle ray fan |
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.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.
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.
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.
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.
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.
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.
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.
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 bot | vehicle | |
|---|---|---|
| sense | order-book tick | camera frame + odometry |
| estimate | mid, imbalance, vol | cross-track, heading, curvature |
| decide | signal → size | steer → accel |
| act | order to the exchange | command to the wheel |
| deadline | the next tick | the next 20 ms step |
| failure | stale quote, bad fill | stale pose, off the road |
theta'
is proportional to v, so a gain tuned at 8 m/s is a different controller at 16.Each of these produces a car that drives beautifully in the demo you rehearsed. Open them.
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.
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.
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.
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.
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.
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?
rtsim with a PID lane-keeper; get RMS cross-track error under
10 cm on the oval.make install, and drive the simulator from your own controller.