Docs menu
MPC
Every tick, MPC plans 20 steering moves over the next second within the servo's angle and rate limits, applies the first, and plans again.
Model predictive control is one of the four steering laws in pure_pursuit_node. Each tick it plans the next second of steering: 20 moves, 50 ms apart. The plan is the one that keeps the car closest to the racing line for the least steering, subject to the servo’s angle limit and its rate limit, both treated as hard limits. The controller applies the first move, discards the rest, and plans again on the next tick from where the car now is. It is the only law that sees a bend coming precisely, and the only one that can run out of time.
Only the steering step changes. The LB check, the pose watchdogs, the safety net, overtaking and every speed limit are the same as for pure pursuit. MPC has been driven only in the simulator. The code is MpcLaw in tracking_laws.pyGitHub, and the excerpts below are from it with docstrings and input checks removed. It builds on the LQR page’s model, which is worth reading first.
Topics
MPC runs inside pure_pursuit_node, so its topics are that node’s: see Pure pursuit for the full tables. Of those inputs, MPC uses the pose, the measured speed from /odom, and the racing line’s points, curvature and speed profile. The path the dashboard draws ahead of the car, from /drive_intent, replays MPC’s planned steering sequence rather than solving again.
Looking ahead along the line
MPC uses LQR’s state, the cross-track error and the heading error , and LQR’s model of how they change. The difference is that it looks ahead. Starting from where the car is on the line, it steps forward = 20 times (mpc.horizon_steps), = 0.05 s apart (mpc.dt), and at each step samples the line’s profiled speed and curvature . The arc length advances by each step, so the horizon covers what the car will drive in the next second at the profiled speeds. Each sample gives a feedforward .
s = s0
for k in range(n):
speeds[k] = max(sample_at_arc(path.speed_profile, path.cumulative, s, path.closed),
LqrGainTable.V_FLOOR)
kappas[k] = sample_at_arc(path.curvature, path.cumulative, s, path.closed)
ff[k] = math.atan(self.limits.wheelbase * kappas[k])
if k + 1 < n:
s = s + speeds[k] * dt_pThe model now changes from step to step, because the speed and the bend change along the horizon. That is what “linear time-varying” means: linear at each step, with different numbers at each step.
has an extra factor of compared with LQR’s. The bicycle model’s turning rate depends on , and LQR approximates by a straight line around the feedforward. The slope of at is its derivative, . On a straight that is 1, so the two models agree. In a tight bend it is larger, because a little more steering turns the car noticeably more when the wheels are already turned.
The optimization problem
The unknowns are the 20 steering moves . The plan minimizes
subject to
Each part has a job:
| Term | What it charges for | Weight |
|---|---|---|
| Being off the line at each step | : mpc.q_lateral, mpc.q_heading |
|
| Whatever error is left at the end of the horizon | from LQR’s Riccati solution | |
| Steering away from what the bend needs | = 1.0, mpc.r_steer |
|
| Steering fast, for smoothness | = 0.01, mpc.r_rate |
|
| Hard limit: the steering angle | 0.33 rad on car 1, 0.349 rad on car 2 | |
| Hard limit: the servo’s rate | max_steering_rate, 1.0 rad/s |
is the command the car is already holding. The first move is a control period away, so = 0.025 s, and the rest are a prediction step apart, = 0.05 s.
The terminal cost. A one-second plan cannot see what comes after it, and a plan that ignores the future can end the horizon pointing the wrong way. LQR’s matrix is exactly the least cost still to come from any final state, if LQR drove from there on. Charging at the end therefore makes the short plan behave like an infinitely long one on a straight. is computed for the prediction step and the speed at the end of the horizon, with MPC’s own weights.
Removing the states
The states depend only on the start state and the steering moves, so they can be written in terms of the moves and removed. Stacking all the steps:
Each is a shear, a matrix of the form , and multiplying two shears adds their corner entries:
So a product of many is the shear whose corner is the running sum , with . That gives and in closed form, without multiplying 2×2 matrices in a loop, which matters because this runs inside an 8 ms budget every tick.
a = speeds * dt_p
b = speeds * dt_p / (np.cos(ff) ** 2) / wheelbase
c = np.cumsum(a)
lower = np.tril(np.ones((n, n)))
Phi = np.zeros((2 * n, 2))
Phi[0::2, 0] = 1.0
Phi[0::2, 1] = c
Phi[1::2, 1] = 1.0
Gamma = np.zeros((2 * n, n))
Gamma[0::2] = (c[:, None] - c[None, :]) * b[None, :] * lower
Gamma[1::2] = b[None, :] * lowerRows 0::2 are the rows and rows 1::2 the rows. lower keeps only moves at or before each step: a steering move can only affect the future.
Substituting the stacked states into leaves a function of the 20 moves alone, of the form
with , where holds for every step and for the last. That is a quadratic program: a quadratic cost with linear limits. Both limits fit one form, , by stacking the identity (for the angle) on a difference matrix (for the rate).
Solving it
The OSQP solver solves the quadratic program. Three things keep it fast enough to run 40 times a second on the Jetson:
- The problem shape never changes. OSQP is set up once with the pattern of non-zero entries, and each tick only the numbers are updated, not rebuilt.
- Warm start. Each solve starts from the last plan. The car moved only 25 ms since then, so the last plan is close to the new answer.
- A deadline.
mpc.deadline_sec(8 ms) is both OSQP’s own time limit and a wall-clock check after the solve.
On the Jetson, with the full simulated race stack running beside it, solves took 1.9 ms typically and 3.5 ms at the 99.9th percentile. 4 solves in 20,000 were late, none of them consecutive.
When a solve fails
A solve that is late, returns any status other than solved, contains a non-finite number, or breaks the angle limit by more than rad is a miss:
| Event | Result | State |
|---|---|---|
| One miss | Pure pursuit steers for that one tick | mpc_fallback |
3 misses in a row (mpc.max_consecutive_misses) |
The car stops until LB is released and pressed again | mpc_miss_latched |
OSQP meets its limits only to about , so the check allows rad, which is far below anything the servo can resolve. Before it is used, the plan is clamped move by move onto the exact angle and rate limits.
status_ok = result.info.status == 'solved'
if not status_ok:
self._warm_start = None
raise MpcMiss(f'status: osqp returned "{result.info.status}"')
if elapsed > self.params.deadline_sec:
self._warm_start = None
raise MpcMiss(f'deadline: solve took {elapsed * 1e3:.1f}ms ...')Like Stanley and LQR, MPC predicts the pose mpc.delay_compensation (0.05 s) ahead before planning; see the Stanley page. It does not use the smoothing filter, because the rate term and the rate limit already keep its output smooth.
Parameters
Set in pure_pursuit.yamlGitHub, read at launch only.
| Symbol | Parameter | Default | Unit | Sets |
|---|---|---|---|---|
mpc.dt |
0.05 | s | Prediction step, up to 0.2 | |
mpc.horizon_steps |
20 | Moves planned, 2 to 60 | ||
mpc.q_lateral |
4.0 | Weight on cross-track error | ||
mpc.q_heading |
1.0 | Weight on heading error | ||
mpc.r_steer |
1.0 | Weight on steering away from the feedforward | ||
mpc.r_rate |
0.01 | Weight on steering rate | ||
mpc.deadline_sec |
0.008 | s | Time allowed per solve | |
mpc.max_iter |
4000 | OSQP iteration limit | ||
mpc.max_consecutive_misses |
3 | Misses in a row that stop the car | ||
mpc.delay_compensation |
0.05 | s | How far ahead the pose is predicted |
is the preview: 1.0 s with the defaults. A longer horizon sees further but costs solve time.
Run it
MPC needs the osqp Python package. See Set up the workspace, step 5. Without it, the node refuses to start with steering_law 'mpc' needs the 'osqp' package.
ros2 launch racerbot_launch race_launch.py run_dir:=$HOME/racerbot-ws/data/<id>/racerbot_auto/<run> steering_law:=mpcThe first time it runs on a car: wheels off the ground, then the floor at about 1 m/s, then race speed.
Read the decision log
A decision line for normal driving includes the law’s own detail, for example:
mpc e=+0.015m th=-0.006rad solve=1.9ms it=25e and th are the two errors, solve is how long OSQP took, and it is how many iterations it used. While MPC runs, the node also prints a timing summary every 5 s. Solve times creeping toward 8 ms mean something else is loading the CPU.
Tune it
python3 tools/f1tenth_sim/law_sweep.py --laws mpc --seed 1000 --repeat-seeds 3 \
--law-param mpc.q_lateral=6.0In the October 19, 2026 comparison, MPC passed 15 of 15 runs with a mean cross-track error of 0.0694 m (0.6% worse than pure pursuit) and a mean lap of 145.3 s (1.5% faster). See the comparison of the four laws.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
steering_law 'mpc' needs the 'osqp' package |
osqp is not installed for this user’s Python |
Install it: Set up the workspace, step 5. |
State mpc_miss_latched, car stopped |
Three late or failed solves in a row | Release LB and press it again. If it repeats, check top for another process loading the CPU, or race with pure_pursuit. |
Frequent mpc_fallback |
Solves near the 8 ms deadline | Shorten mpc.horizon_steps, or reduce the CPU load. |
Limitations
- MPC has been driven only in the simulator.
- It plans with the kinematic bicycle model and the profiled speeds, not the car’s actual speed along the horizon, and it has no tyre slip.
- It is the only law that depends on finishing a computation in time. A loaded Jetson turns into fallbacks and, after three in a row, a stop.
Checked against
car 1 commit 0265263
and
car 2 commit b81b291, before the two workspaces merged.