Robot Motion
Chapter 19PART VDynamics, Trajectories, and ConstraintsDifficulty: AdvancedEstimated reading time: 75 min

Trajectory Optimization and MPC

Once a trajectory is a vector of numbers, planning becomes descending a cost. Pontryagin's minimum principle and Choset's transcription are the root; CHOMP's covariant gradient on a distance field, STOMP's seeded rollouts, TrajOpt's sequential convexification, iLQR's backward value recursion and the receding-horizon loop are five modern answers — all local, all honest about it.

Since nonlinear optimization uses local gradient information, it will converge to a locally optimal solution. For some problems, the control parameter space will be littered with many local optima. Therefore, the solution achieved will depend heavily on the initial guess.
Howie Choset, Kevin Lynch, Seth Hutchinson, George Kantor, Wolfram Burgard, Lydia Kavraki, and Sebastian ThrunPrinciples of Robot Motion (2005), §11.3.2

In this chapter

Chapter 18 fixed the path and optimized the clock. This chapter lets the path move too, and discovers that once a trajectory is a vector of numbers, "planning" becomes "descend a cost". Choset gave this two pages of optimal control and three of nonlinear programming, with the warning quoted above. Twenty years later that warning is the whole discipline: CHOMP, STOMP, TrajOpt, iLQR and model predictive control are five answers to which descent, in which metric, with obstacles as cost or as constraint.

The idea the chapter unpacks is this. A trajectory optimizer is a local planner with a global vocabulary. It polishes the homotopy class it is handed — brilliantly: smoother, shorter, dynamically feasible, closed-loop — and it never changes its mind. Hand it the other side of the obstacle and it will polish that instead. So the pipeline Chapter 23 ships keeps Part III's sampling planners for the global decision and uses this chapter's machinery to make that decision fast, smooth and safe.

Every method here is local, and the prose never says "optimal" without "locally". CHOMP's obstacle term is a cost, so a collision can survive convergence, and the widget shows it when it happens. Our sco is a recipe in TrajOpt's shape, not TrajOpt's QP-based solver, and says so. iLQR needs a regularization and a line search, and the defaults are stated. MPC's stability through a terminal Lyapunov cost is a pointer, not a theorem proved.

The problem: a jagged path, polished

Take a Chapter 12 RRT path for Reach on the Workbench, seeded, fourteen nodes from the home pose to the far side of the block. Shortcut it with Chapter 13's greedy shortcutter — three waypoints survive — resample to twenty-four, and run forty CHOMP steps against the Chapter 7 distance field of the block. Then hand both the original and the polished path to Chapter 18's time scaler on a horizontal Workbench with the same motors.

t=0
Figure Left: the RRT path, time-scaled, 1.803 s. Right: after shortcutting and forty CHOMP iterations, time-scaled, 0.992 s. Same arm, same start and goal, same |τ| ≤ (20, 12) N·m; the smoothed path keeps 0.146 m of clearance from the block and executes in 55% of the time, because the time scaler is only as fast as the path's curvature lets it be.

Nothing about the planner's global decision changed: both paths go around the block the same way. What changed is everything the sampling planner never cared about — curvature, clearance, and therefore the clock. The question of this chapter is how to make that change deliberately, and what "deliberately" cannot do.

Building intuition

A rubber band in a force field

Think of the discretized trajectory ξ=(q1,…,qN−1)\xi = (q_1, \dots, q_{N-1}) as a chain of beads on a rubber band with its ends pinned at q0q_0 and qNq_N. Smoothness pulls each bead toward the midpoint of its neighbours. The obstacle term is a force field: zero beyond a margin ϵ\epsilon from the nearest obstacle, growing as a bead approaches, pushing straight out along ∇D\nabla D. CHOMP is what the band does when you let go — with one twist that makes it work on robots: a push on one bead is spread along the whole band by A−1A^{-1}, so the band translates and bends instead of growing a kink where it was touched.

Three things to notice, each of which becomes a theorem below.

The step size has a stability bound, and it grows with the path's length. Slide η\eta down and the band oscillates — on the micro-example preset it flips between the disc's centre and the edge of the margin. The bound is η>12(1+(λ/ϵ)/λmin⁡(A))\eta > \tfrac12(1 + (\lambda/\epsilon)/\lambda_{\min}(A)): for one free waypoint and ϵ=12\epsilon = \tfrac12 it is (1+λ)/2(1 + \lambda)/2; for twenty waypoints it is about 9090, because A−1A^{-1} amplifies a low-frequency push by (m+1)2/π2(m+1)^2/\pi^2. Above the bound, the step that is right for a short path crawls on a long one.

The answer depends on the initial path. The two initial paths on the Workbench start in different homotopy classes around the block. CHOMP lowers the cost of both and they end 1.981.98 rad apart at mid-path. From the straight chart line it converges — gradient at machine precision — with a body point still inside the block, at D=−0.183D = -0.183 m, and the badge says converged in collision. That is not a bug in the optimizer. The obstacle term is a cost; the smoothness term is a cost; the sum has a stationary point there, and a gradient method finds stationary points.

Clearance is bought with λ\lambda and ϵ\epsilon, not asked for. On the micro-example preset the single waypoint settles where 2y=λ(2−2y)2y = \lambda(2 - 2y): at y∗=λ/(1+λ)y^* = \lambda/(1 + \lambda), clearance D∗=(λ−1)/(2(1+λ))D^* = (\lambda - 1)/(2(1 + \lambda)) — zero at λ=1\lambda = 1, 1/61/6 at 22, 0.30.3 at 44. Raise λ\lambda and the band stands further off; it never stands off by exactly what you need. The sequential-convex recipe later in the chapter is the method that does.

Notation used in this chapter
SymbolMeaning
x=(q,q˙),  x˙=f(x,u)x = (q, \dot q),\; \dot x = f(x, u)State and state equation (Choset eq. 11.24).
J=∫0tfℓ(x,u,t) dtJ = \int_0^{t_f} \ell(x, u, t)\,dtObjective; ℓ is the running cost — Choset's optimal-control Lagrangian, renamed to avoid the dynamics L.
H=ℓ+λTf,  λ(t)H = \ell + \lambda\T f,\; \lambda(t)Hamiltonian and costate (Lagrange multipliers for the state equation), eq. 11.26.
XXFinite parameter vector of the transcribed program (eq. 11.40).
ξ=(q1,…,qN−1)\xi = (q_1, \dots, q_{N-1})Discretized trajectory with fixed endpoints q₀, q_N.
E,  A=ETE,  Fsmooth=12∥Eξ+e∥2\mathsf{E},\; A = \mathsf{E}\T\mathsf{E},\; \mathcal{F}_{smooth} = \tfrac12\|\mathsf{E}\xi + e\|^2Finite-difference operator; the smoothness metric A = tridiag(−1, 2, −1); the smoothness functional.
c(d),  ϵ,  Fobs,  λobsc(d),\; \epsilon,\; \mathcal{F}_{obs},\; \lambda_{obs}Obstacle cost of a distance, its margin, the obstacle functional, its weight.
∇ˉF=A−1∇F,  η\bar\nabla\mathcal{F} = A^{-1}\nabla\mathcal{F},\; \etaCovariant gradient; the step is 1/η.
xk,uk;  fx,fu;  ℓx,ℓxx,…x_k, u_k;\; f_x, f_u;\; \ell_x, \ell_{xx}, \dotsDiscrete-time trajectory and its local derivatives.
Vk(x),  Vx,Vxx;  Qx,Qu,Qxx,Quu,QuxV_k(x),\; V_x, V_{xx};\; Q_x, Q_u, Q_{xx}, Q_{uu}, Q_{ux}Value function and its quadratic model; the action-value expansion at one stage.
kk,  Kk,  μ,  αk_k,\; \mathbf{K}_k,\; \mu,\; \alphaFeedforward and feedback gains; regularization on Q_uu; line-search step.
sd(⋅),  n^,  Δ,  μpen;  Nhsd(\cdot),\; \hat n,\; \Delta,\; \mu_{pen};\; N_hSigned distance, contact normal, trust radius, penalty weight (TrajOpt); the MPC horizon.

The mathematics

Definitions

Direct trajectory planning (Choset §11.3) searches the full state space for (x(t),u(t))(x(t), u(t)) jointly, rather than a path first and a clock second.

An extremal control satisfies ∂H/∂u=0\partial H/\partial u = 0 along the trajectory — necessary, not sufficient; it is locally optimal if also ∂2H/∂u2>0\partial^2 H/\partial u^2 > 0 (the Legendre–Clebsch condition, eq. 11.29). A singular optimal control is extremal with ∂2H/∂u2\partial^2 H/\partial u^2 only semidefinite: the min-time double integrator, and Chapter 18's zero-inertia arcs.

Transcription replaces (x(t),u(t))(x(t), u(t)) by a finite XX and the continuous constraints by finitely many checks at jΔtj\Delta t (eqs. 11.40–11.45).

The covariant gradient is steepest descent in the metric AA: ∇ˉF=A−1∇F\bar\nabla\mathcal{F} = A^{-1}\nabla\mathcal{F}, the direction that most decreases F\mathcal{F} per unit of 12 δξTA δξ\tfrac12\,\delta\xi\T A\,\delta\xi — a perturbation measured by how much it changes the whole path's smoothness, not by how far one bead moves.

Receding horizon (MPC). Each tick, solve a horizon-NhN_h problem from the measured state, apply the first control, discard the rest, repeat.

Pontryagin and the min-effort double integrator

DerivationThe minimum principle on the simplest system

Step 1 — the Hamiltonian. With x=(q,q˙)x = (q, \dot q), f=(x2,u)f = (x_2, u) and ℓ=u2\ell = u^2: H=u2+λ1x2+λ2uH = u^2 + \lambda_1 x_2 + \lambda_2 u (eq. 11.26).

Step 2 — extremality. ∂H/∂u=2u+λ2=0\partial H/\partial u = 2u + \lambda_2 = 0, so u=−λ2/2u = -\lambda_2/2 (eq. 11.31).

Step 3 — the adjoint equation. λ˙=−∂H/∂x\dot\lambda = -\partial H/\partial x gives λ˙1=0\dot\lambda_1 = 0 and λ˙2=−λ1\dot\lambda_2 = -\lambda_1 (eq. 11.32): λ1\lambda_1 is constant, λ2\lambda_2 is affine in tt, and therefore so is u(t)=c0+c1tu(t) = c_0 + c_1 t.

Step 4 — the terminal conditions. x2(tf)=∫0tfu dt=0x_2(t_f) = \int_0^{t_f} u\,dt = 0 gives c0tf+c1tf2/2=0c_0 t_f + c_1 t_f^2/2 = 0; x1(tf)=∫0tf ⁣∫0tu=dx_1(t_f) = \int_0^{t_f}\!\int_0^t u = d gives c0tf2/2+c1tf3/6=dc_0 t_f^2/2 + c_1 t_f^3/6 = d. Solving: c0=6d/tf2c_0 = 6d/t_f^2, c1=−12d/tf3c_1 = -12d/t_f^3.

Step 5 — convexity. ∂2H/∂u2=2>0\partial^2 H/\partial u^2 = 2 > 0: the extremal is a local minimum. The position is the cubic q(t)=3d(t/tf)2−2d(t/tf)3q(t) = 3d(t/t_f)^2 - 2d(t/t_f)^3 — the minimum-acceleration-energy profile every spline library rediscovers.

Discretized and solved numerically. With N=100N = 100 stages of Δt=0.01\Delta t = 0.01, ℓk=uk2Δt\ell_k = u_k^2\Delta t and a terminal weight of 10610^6 on ∥xN−(1,0)∥2\|x_N - (1, 0)\|^2, iLQR's first and only iteration returns u0=5.9405u_0 = 5.9405 against Pontryagin's 6−12⋅0.005=5.946 - 12 \cdot 0.005 = 5.94, with max⁡k∣uk−(6−12 tk+1/2)∣=5.1×10−4\max_k |u_k - (6 - 12\,t_{k+1/2})| = 5.1 \times 10^{-4} and xN=(1,0)x_N = (1, 0) to 10−410^{-4}. One iteration, because the problem is linear-quadratic — the next derivation but one says why.

Minimum time is bang-bang, by the minimum principle

DerivationWhere the control set's boundary does the work

Step 1 — no uu in ∂H/∂u\partial H/\partial u. H=1+λ1x2+λ2uH = 1 + \lambda_1 x_2 + \lambda_2 u is affine in uu; ∂H/∂u=λ2\partial H/\partial u = \lambda_2 says nothing about uu, and ∂2H/∂u2=0\partial^2 H/\partial u^2 = 0, so Legendre–Clebsch is silent. The extremality condition 11.28 is useless here.

Step 2 — use the inequality instead. The minimum principle 11.27 says H∗≤HH^* \le H for every feasible uu: λ2u∗≤λ2u\lambda_2 u^* \le \lambda_2 u for all ∣u∣≤umax⁡|u| \le u_{\max}.

Step 3 — so u∗u^* is at a bound. If λ2<0\lambda_2 < 0 the inequality forces u∗=umax⁡u^* = u_{\max}; if λ2>0\lambda_2 > 0, u∗=−umax⁡u^* = -u_{\max}. The control lives on the boundary of its set — bang-bang.

Step 4 — at most one switch. λ2\lambda_2 is affine in tt (same adjoint equation), so it changes sign at most once. Rest to rest over dd: accelerate for tst_s, brake for tst_s, with umax⁡ts2=du_{\max}t_s^2 = d, so ts=d/umax⁡t_s = \sqrt{d/u_{\max}} — the 0.39630.3963 s of Chapter 18's micro-example read off a Hamiltonian instead of a phase plane.

Shooting. When the extremality conditions cannot be solved by hand, guess λ(0)\lambda(0), integrate state and adjoint with uu from ∂H/∂u=0\partial H/\partial u = 0, compare x(tf)x(t_f) with the target, and correct the guess — typically by a Gauss–Newton step on the terminal miss. This is where the "initial guess" problem is first explicit, and optim::lm below is the corrector.

Transcription

DerivationThree ways to make a trajectory a vector

Step 1 — choose a basis. Choset's list: polynomial coefficients, a truncated Fourier series, spline coefficients, wavelets, piecewise-constant accelerations or forces. The basis decides whether one parameter moves the whole trajectory or a finite support, which decides the conditioning of everything downstream.

Step 2 — sample the constraints. Torque limits 11.42 and obstacle constraints 11.43 are checked at jΔtj\Delta t, j=0…kj = 0 \dots k; endpoint conditions 11.44–11.45 are equalities on XX.

Step 3 — hand it to a solver. Sequential quadratic programming needs JJ, gg, hh and their gradients, usually a Hessian approximation too, and all of it at least C1C^1 in XX. Choset's two warnings are exactly the two the modern methods still trip on: smoothness in XX, and local optima.

The modern names. (a) is state collocation: CHOMP's ξ\xi is a sampled q(t)q(t), and its smoothness functional is the acceleration energy the dynamics would charge. (b) is direct shooting: iLQR parameterizes uku_k and integrates ff to get xkx_k. (c) is full collocation: TrajOpt carries both and enforces the dynamics as equalities. Choset's penalty variant — constraints folded into the objective — is CHOMP's obstacle term and TrajOpt's outer loop.

CHOMP's covariant functional gradient

DerivationSteepest descent in the smoothness metric

Step 1 — the smoothness functional is a quadratic. Fsmooth=12∑k∥qk+1−qk∥2=12∥Eξ+e∥2\mathcal{F}_{smooth} = \tfrac12\sum_k\|q_{k+1} - q_k\|^2 = \tfrac12\|\mathsf{E}\xi + e\|^2 with E\mathsf{E} the finite-difference operator on the free waypoints and ee carrying the fixed ends. Its gradient is Aξ+bA\xi + b with A=ETE=tridiag(−1,2,−1)⊗1A = \mathsf{E}\T\mathsf{E} = \mathrm{tridiag}(-1, 2, -1) \otimes \mathbb{1}, i.e. 2qk−qk−1−qk+12q_k - q_{k-1} - q_{k+1} at each free waypoint.

Step 2 — the obstacle functional is an arc-length integral. Fobs=∑β∫01c(D(xβ(q(s)))) ∥xβ′∥ ds\mathcal{F}_{obs} = \sum_\beta \int_0^1 c(D(x_\beta(q(s))))\,\|x_\beta'\|\,ds: cost per metre travelled by each body point, so retiming the path does not change it, and a longer path through cost costs more.

Step 3 — differentiate under the integral. The variation of c(D(x))∥x′∥c(D(x))\|x'\| with respect to the body point's path gives the Euler–Lagrange expression ∥x′∥[(1−x^′x^′T)∇c−c κ]\|x'\|\big[(\mathbb{1} - \hat x'\hat x'\T)\nabla c - c\,\kappa\big], with κ=(1−x^′x^′T)x′′/∥x′∥2\kappa = (\mathbb{1} - \hat x'\hat x'\T)x''/\|x'\|^2 the path's curvature. The projection (1−x^′x^′T)(\mathbb{1} - \hat x'\hat x'\T) discards the component of ∇c\nabla c along the path — pushing a bead along the band does nothing but retime it — and the curvature term says that where the cost is positive, straightening the path shortens the exposure.

Step 4 — pull back to joint space. A workspace force ff at body point β\beta is the joint torque JβTfJ_\beta\T f — Chapter 7's §4.7 lift, Chapter 17's eq. 10.12, the same virtual-work identity every time. Summing over body points gives ∇qFobs\nabla_q\mathcal{F}_{obs} at each waypoint. The library uses Chapter 7's controlPointJacobian for JβJ_\beta unchanged.

Step 5 — the covariant step. Minimize the first-order model F(ξ)+∇FTδξ\mathcal{F}(\xi) + \nabla\mathcal{F}\T\delta\xi subject to 12 δξTA δξ≤\tfrac12\,\delta\xi\T A\,\delta\xi \le const. The Lagrange condition gives δξ∝−A−1∇F\delta\xi \propto -A^{-1}\nabla\mathcal{F}: steepest descent in the metric that measures a perturbation by the smoothness it costs. AA is block-tridiagonal, so A−1∇FA^{-1}\nabla\mathcal{F} is one Thomas sweep per coordinate, O(N)O(N); the library's sweep agrees with the dense inverse to 4×10−154 \times 10^{-15}.

The obstacle cost and its slope. c(d)=−d+ϵ/2c(d) = -d + \epsilon/2 for d<0d < 0, (ϵ−d)2/(2ϵ)(\epsilon - d)^2/(2\epsilon) on [0,ϵ][0, \epsilon], 00 above: C1C^1 at both seams, with c′(0)=−1c'(0) = -1 on both sides. The pointwise form ∇qFobs=∑βJβTc′(D)∇D\nabla_q\mathcal{F}_{obs} = \sum_\beta J_\beta\T c'(D)\nabla D drops the arc-length weighting; it is what the micro-example pins exactly, and the library checks it against finite differences of its cost to 4×10−104 \times 10^{-10}.

Why A−1A^{-1} spreads a push. A−1A^{-1} is dense and positive: its columns are tent functions peaked at the pushed waypoint and decaying linearly to the pinned ends. A force on one bead moves every bead, most where it was pushed — the band bends rather than kinks.

The step bound. Linearize the obstacle term as a Hessian of at most λ/ϵ\lambda/\epsilon per waypoint (c′′=1/ϵc'' = 1/\epsilon in the margin, ∥∇D∥≤1\|\nabla D\| \le 1). The iteration matrix is 1−1η(1+A−1H)\mathbb{1} - \tfrac1\eta(\mathbb{1} + A^{-1}H) with eigenvalues 1−(1+μi)/η1 - (1 + \mu_i)/\eta, μi∈[0,(λ/ϵ)/λmin⁡(A)]\mu_i \in [0, (\lambda/\epsilon)/\lambda_{\min}(A)] and λmin⁡(A)=2−2cos⁡(π/(m+1))\lambda_{\min}(A) = 2 - 2\cos(\pi/(m+1)). It is a contraction iff η>12(1+(λ/ϵ)/λmin⁡(A))\eta > \tfrac12(1 + (\lambda/\epsilon)/\lambda_{\min}(A)). For m=1m = 1, ϵ=12\epsilon = \tfrac12: η>(1+λ)/2\eta > (1 + \lambda)/2. For m=20m = 20, λ=2\lambda = 2, ϵ=12\epsilon = \tfrac12: η∗=90.0\eta^* = 90.0; the library runs at 2η∗2\eta^* without a single cost increase in 400400 steps and at η∗/4\eta^*/4 records 3636 increases in 100100. The bound is worst-case — a path with few waypoints inside the margin tolerates a smaller η\eta — and it is the mechanism behind the widget's headline.

STOMP's gradient-free replacement. Sample δξk∼N(0,A−1)\delta\xi_k \sim \mathcal{N}(0, A^{-1}) for KK rollouts — smooth noise, because its covariance is the smoothness metric — evaluate Sk=F(ξ+δξk)S_k = \mathcal{F}(\xi + \delta\xi_k), weight wk∝exp⁡(−h(Sk−Smin⁡)/(Smax⁡−Smin⁡))w_k \propto \exp(-h(S_k - S_{\min})/(S_{\max} - S_{\min})), update by ∑kwkδξk\sum_k w_k\delta\xi_k smoothed once more through A−1A^{-1}. No gradient is formed, so any cost that can be evaluated can be optimized. On the micro-example at λ=2\lambda = 2, seed 7, K=24K = 24, 120 iterations end at y=0.6566y = 0.6566 against CHOMP's y∗=0.6667y^* = 0.6667, with the accepted cost never rising.

The micro-example, step by step. A point robot, three waypoints with the ends fixed: q0=(0,0)q_0 = (0,0), q2=(2,0)q_2 = (2, 0), free q1=(1,0.2)q_1 = (1, 0.2); one disc at (1,0)(1, 0) of radius 12\tfrac12, so D(q1)=0.2−0.5=−0.3D(q_1) = 0.2 - 0.5 = -0.3 (inside) and ∇D=(0,1)\nabla D = (0, 1); ϵ=12\epsilon = \tfrac12, λ=1\lambda = 1, η=1\eta = 1, unit spacing, pointwise gradient. Smoothness: ∇q1Fsmooth=2q1−q0−q2=(0,0.4)\nabla_{q_1}\mathcal{F}_{smooth} = 2q_1 - q_0 - q_2 = (0, 0.4), A=2A = 2. Obstacle: D<0D < 0, so c=0.55c = 0.55 and c′=−1c' = -1, giving ∇q1Fobs=(0,−1)\nabla_{q_1}\mathcal{F}_{obs} = (0, -1). Total (0,−0.6)(0, -0.6); covariant step q1←(1,0.2)−11⋅12(0,−0.6)=(1,0.5)q_1 \leftarrow (1, 0.2) - \tfrac11\cdot\tfrac12(0, -0.6) = (1, 0.5): one step lands the waypoint exactly on the disc, D=0D = 0. Second step: ∇Fsmooth=(0,1.0)\nabla\mathcal{F}_{smooth} = (0, 1.0), c′(0)=−1c'(0) = -1 so ∇Fobs=(0,−1)\nabla\mathcal{F}_{obs} = (0, -1), total 00 — a fixed point in contact. In general, inside the margin c′=−(ϵ−D)/ϵc' = -(\epsilon - D)/\epsilon and the equilibrium is y∗=λ/(1+λ)y^* = \lambda/(1 + \lambda), clearance D∗=(λ−1)/(2(1+λ))D^* = (\lambda - 1)/(2(1 + \lambda)): 00, 1/61/6, 0.30.3 for λ=1,2,4\lambda = 1, 2, 4, which the library reaches to 10−410^{-4} in 200 iterations at η=4\eta = 4. The lesson: clearance is bought with λ\lambda and ϵ\epsilon; it is never guaranteed.

The iLQR backward pass

DerivationBellman, expanded to second order about a nominal rollout

Step 1 — Bellman. Vk(x)=min⁡u[ℓ(x,u)+Vk+1(f(x,u))]V_k(x) = \min_u\big[\ell(x, u) + V_{k+1}(f(x, u))\big], with VN=ℓfV_N = \ell_f.

Step 2 — expand about the nominal. Write x=xˉk+δxx = \bar x_k + \delta x, u=uˉk+δuu = \bar u_k + \delta u, and expand the bracket to second order: the coefficients are the QQ terms of the statement. Dropping the second derivatives of ff — the tensor terms Vx′⋅fxxV'_x\cdot f_{xx}, Vx′⋅fuuV'_x\cdot f_{uu}, Vx′⋅fuxV'_x\cdot f_{ux} — is iLQR; keeping them is DDP. The library computes them by central differences of the Jacobians when ddp: true.

Step 3 — minimize the quadratic in δu\delta u. ∂/∂δu=0\partial/\partial\delta u = 0 gives Qu+Quuδu+Quxδx=0Q_u + Q_{uu}\delta u + Q_{ux}\delta x = 0, i.e. δu=−Quu−1(Qu+Quxδx)=k+Kδx\delta u = -Q_{uu}^{-1}(Q_u + Q_{ux}\delta x) = k + \mathbf{K}\delta x. This is a policy: a feedforward correction plus a linear feedback on the deviation from the nominal.

Step 4 — substitute back. The minimized quadratic is the new value model: Vx=Qx+KTQuuk+KTQu+QuxTkV_x = Q_x + \mathbf{K}\T Q_{uu}k + \mathbf{K}\T Q_u + Q_{ux}\T k and Vxx=Qxx+KTQuuK+KTQux+QuxTKV_{xx} = Q_{xx} + \mathbf{K}\T Q_{uu}\mathbf{K} + \mathbf{K}\T Q_{ux} + Q_{ux}\T\mathbf{K}, which collapse to the statement's forms once kk and K\mathbf{K} are substituted (Tassa et al. keep the cross terms for numerical symmetry; so does the library).

Step 5 — recurse, then roll out. From VN=ℓfV_N = \ell_f down to k=0k = 0. The forward pass is closed-loop: uk=uˉk+αkk+Kk(xk−xˉk)u_k = \bar u_k + \alpha k_k + \mathbf{K}_k(x_k - \bar x_k), with backtracking on α\alpha against the expected decrease ΔV(α)=α∑kkkTQu+12α2∑kkkTQuukk\Delta V(\alpha) = \alpha\sum_k k_k\T Q_u + \tfrac12\alpha^2\sum_k k_k\T Q_{uu}k_k. A step is accepted when the realized decrease is a reasonable fraction of the promise.

Regularization. QuuQ_{uu} need not be positive definite far from a minimum; the library adds μ1\mu\mathbb{1}, raises μ\mu tenfold when a Cholesky pivot fails or no α\alpha descends, and lowers it on success. Defaults: μ0=10−6\mu_0 = 10^{-6} (zero for linear-quadratic checks), α∈{1,12,…,1/64}\alpha \in \{1, \tfrac12, \dots, 1/64\}.

On a linear-quadratic problem one pass is Riccati. If f=Ax+Buf = Ax + Bu and ℓ\ell is quadratic about the origin, fx=Af_x = A, fu=Bf_u = B and the recursion for VxxV_{xx} is exactly the discrete Riccati recursion Pk=Q+ATPk+1A+ATPk+1B KkP_k = Q + A\T P_{k+1}A + A\T P_{k+1}B\,\mathbf{K}_k with Kk=−(R+BTPk+1B)−1BTPk+1A\mathbf{K}_k = -(R + B\T P_{k+1}B)^{-1}B\T P_{k+1}A. The library computes that recursion independently; on a 40-stage double-integrator problem the gains agree to 1.1×10−151.1 \times 10^{-15} and the α=1\alpha = 1 forward pass lands on the optimal cost 12x0TP0x0=10.88611263\tfrac12 x_0\T P_0 x_0 = 10.88611263 in one iteration.

The first nonlinear term that breaks this. For the unicycle, fxf_x depends on θ\theta through cos⁡θ\cos\theta, sin⁡θ\sin\theta; the rollout is no longer the linear prediction, α=1\alpha = 1 may overshoot, and the pass has to be repeated. Hitch's parking problem below takes 79 iLQR iterations, or 30 with DDP's second-order terms, and the two reach different local optima — DDP's cheaper on this instance, 56.2456.24 against 60.6260.62, both parking within 5 cm and 0.05 rad.

TrajOpt's sequential convex optimization

DerivationAn exact penalty, convexified

Step 1 — the ℓ1\ell_1 penalty is exact. For a finite μpen\mu_{pen} larger than the constraints' multipliers, the minimizer of J+μpen∑∣gi∣+J + \mu_{pen}\sum|g_i|^+ satisfies gi≤0g_i \le 0 exactly — not approximately, as a quadratic penalty would. The hinge is non-smooth, which is why the subproblem is solved as a convex program rather than by a gradient step.

Step 2 — convexify. Replace JJ by its quadratic model and each gig_i by its linearization; the hinge of an affine function is convex. For collision, g=dsafe−sd(q)g = d_{safe} - sd(q) and the linearization at the witness pair is dsafe−sd(q0)−n^TJp(q0) δqd_{safe} - sd(q_0) - \hat n\T J_p(q_0)\,\delta q — exactly the contact normal times the body-point Jacobian, Chapter 7's lift once more.

Step 3 — the trust region guarantees descent on the merit. The convex model agrees with the true merit to first order; inside a small enough box the true merit decreases when the model's does. Accept and expand Δ\Delta on success; reject and shrink on failure.

Step 4 — the penalty loop. When the inner loop converges with violations left, multiply μpen\mu_{pen} and repeat. Swept volumes between consecutive footprints are handled as convex hulls of the two shapes — a recipe this book states and does not implement.

What our sco simplifies, and says so. The convex subproblem is a QP in TrajOpt. Here it is the book's own Levenberg–Marquardt on the residual vector [Eξ+e; μ ∣glin∣+][\mathsf{E}\xi + e;\ \sqrt{\mu}\,|g_{lin}|^+], boxed by the same Δ\Delta — a recipe in TrajOpt's shape, not TrajOpt. On the twelve-waypoint disc problem it ends after 27 convexifications at μpen=105\mu_{pen} = 10^5 with every waypoint at least dsafe=0.1d_{safe} = 0.1 from the disc, the tightest at 0.10000.1000 and a violation of 6×10−76 \times 10^{-7}; CHOMP on the same scene with λ=1\lambda = 1, ϵ=0.5\epsilon = 0.5 settles at a clearance of 0.4710.471 — far more than asked, because it never saw the constraint. Cost versus constraint, in one number each.

MPC: the loop around any of them

The widget's numbers are the chapter's check. Rusty crosses the Apartment from room A to room E, Δt=0.1\Delta t = 0.1 s, vmax⁡=1v_{\max} = 1 m/s, with a disc sliding in and out of the corridor through room B's door, frozen at its measured position each tick. With the Chapter 6 wave-front cost-to-go as terminal cost, horizons Nh=6,10,24N_h = 6, 10, 24 all arrive — in 125, 127 and 139 ticks — without touching the disc or a wall, at 1.9, 3.9 and 10.0 ms of iLQR per tick. With a plain quadratic pull to the goal as terminal cost, neither Nh=10N_h = 10 nor 2424 ever arrives: the pull parks Rusty against the corridor wall it cannot see around. And the open-loop ghost at Nh=16N_h = 16, executing each plan to its end before replanning, collides for 10 ticks where MPC waits. Horizon is not what cured myopia; the terminal cost was. Stability of the closed loop via a terminal cost that is a Lyapunov function is the standard theorem (Rawlings, Mayne and Diehl); this chapter points at it and does not prove it.

The algorithm

Algorithmchomp_step(ξ, q₀, q_N, D, {J_β}, ε, λ, η)CostO(N·β) distance queries + one O(N) Thomas sweep per coordinate
In
free waypoints, fixed ends, distance field with gradient, body-point Jacobians, margin, weight, step
Out
ξ updated in place; F_smooth, F_obs, min clearance
  1. gk←2qk−qk−1−qk+1g_k \leftarrow 2q_k - q_{k-1} - q_{k+1} for each free waypoint (the smoothness gradient Aξ+bA\xi + b)
  2. for each waypoint kk and body point β\beta: x←xβ(qk)x \leftarrow x_\beta(q_k), d←D(x)d \leftarrow D(x), f←c′(d) ∇D(x)f \leftarrow c'(d)\,\nabla D(x); in the arclength form, f←∥x′∥ [(1−x^′x^′T)f−c(d) κ]f \leftarrow \|x'\|\,[(\mathbb{1} - \hat x'\hat x'\T)f - c(d)\,\kappa] with x′,x′′x', x'' by central differences along ξ\xi
  3. gk←gk+λ Jβ(qk)Tfg_k \leftarrow g_k + \lambda\,J_\beta(q_k)\T f
  4. solve A δ=gA\,\delta = g by the Thomas sweep on tridiag(−1,2,−1)\mathrm{tridiag}(-1, 2, -1), one coordinate at a time
  5. ξ←ξ−δ/η\xi \leftarrow \xi - \delta/\eta; report FsmoothF_{smooth}, FobsF_{obs}, min⁡d\min d. Stable iff η>12(1+(λ/ϵ)/λmin⁡(A))\eta > \tfrac12(1 + (\lambda/\epsilon)/\lambda_{\min}(A)).
Algorithmstomp_step(ξ, K, rng, noise, h)CostK full cost evaluations; no gradients
In
the same problem, a seeded generator, rollout count, noise scale, temperature
Out
ξ updated if the cost did not rise
  1. LLT=A−1L L\T = A^{-1} once; for k=1…Kk = 1 \dots K: δξk←noise⋅L zk\delta\xi_k \leftarrow \text{noise}\cdot L\,z_k, zk∼N(0,1)z_k \sim \mathcal{N}(0, \mathbb{1}) per coordinate
  2. Sk←F(ξ+δξk)S_k \leftarrow \mathcal{F}(\xi + \delta\xi_k); wk←exp⁡(−h(Sk−Smin⁡)/(Smax⁡−Smin⁡))w_k \leftarrow \exp(-h(S_k - S_{\min})/(S_{\max} - S_{\min})), normalized
  3. δξ←M∑kwkδξk\delta\xi \leftarrow M\sum_k w_k\delta\xi_k with M=A−1M = A^{-1} column-scaled so its largest entry is 1/m1/m
  4. if F(ξ+δξ)≤F(ξ)\mathcal{F}(\xi + \delta\xi) \le \mathcal{F}(\xi): ξ←ξ+δξ\xi \leftarrow \xi + \delta\xi
Algorithmsco(ξ₀, sd, d_safe, μ₀, Δ₀) — a recipe in TrajOpt's shapeCostouter penalty loop × inner convexify–solve–accept loop; each solve a few LM iterations
In
initial waypoints, signed-distance field, clearance, initial penalty and trust radius
Out
ξ with violation below tolerance, the merit history
  1. repeat (penalty loop): repeat (inner loop):
  2. convexify: at each waypoint record sd(qk)sd(q_k) and n^k=∇sd(qk)\hat n_k = \nabla sd(q_k); model gk(δ)=dsafe−sd(qk)−n^kTδqkg_k(\delta) = d_{safe} - sd(q_k) - \hat n_k\T\delta q_k
  3. solve min⁡δ12∥E(ξ+δ)+e∥2+μ∑k∣gk(δ)∣+\min_\delta \tfrac12\|\mathsf{E}(\xi + \delta) + e\|^2 + \mu\sum_k|g_k(\delta)|^+ with ∥δ∥∞≤Δ\|\delta\|_\infty \le \Delta — here by LM on the residuals [E(ξ+δ)+e; μ ∣gk(δ)∣+][\mathsf{E}(\xi + \delta) + e;\ \sqrt\mu\,|g_k(\delta)|^+] with the box as trust region (TrajOpt: a QP)
  4. if the true merit 12∥Eξ+e∥2+μ∑k∣dsafe−sd(qk)∣+\tfrac12\|\mathsf{E}\xi + e\|^2 + \mu\sum_k|d_{safe} - sd(q_k)|^+ decreased: accept, Δ←1.5Δ\Delta \leftarrow 1.5\Delta; else Δ←Δ/2\Delta \leftarrow \Delta/2
  5. until four rejections; if the worst violation ≥\ge tol: μ←10μ\mu \leftarrow 10\mu and repeat the penalty loop
Algorithmilqr(f, ℓ, ℓ_f, x₀, ū, μ₀)CostO(N (n_x + n_u)³) per iteration
In
discrete dynamics with Jacobians, running and terminal cost with derivatives, start, initial controls, regularization
Out
(x̄, ū, {k_k}, {𝐊_k}), the cost per iteration
  1. roll uˉ\bar u out from x0x_0; J←∑ℓ+ℓfJ \leftarrow \sum\ell + \ell_f
  2. backward pass: Vx,Vxx←∇ℓf,∇2ℓfV_x, V_{xx} \leftarrow \nabla\ell_f, \nabla^2\ell_f; for k=N−1…0k = N-1 \dots 0: form Qx,Qu,Qxx,Quu,QuxQ_x, Q_u, Q_{xx}, Q_{uu}, Q_{ux} (add Vx⋅fxxV_x\cdot f_{xx} etc. for DDP); if Quu+μ1Q_{uu} + \mu\mathbb{1} is not positive definite, μ←10μ\mu \leftarrow 10\mu and restart; kk←−(Quu+μ1)−1Quk_k \leftarrow -(Q_{uu} + \mu\mathbb{1})^{-1}Q_u, Kk←−(Quu+μ1)−1Qux\mathbf{K}_k \leftarrow -(Q_{uu} + \mu\mathbb{1})^{-1}Q_{ux}; update Vx,VxxV_x, V_{xx}; accumulate ΔV=αa+α2b\Delta V = \alpha a + \alpha^2 b
  3. forward pass: for α∈{1,12,… }\alpha \in \{1, \tfrac12, \dots\}: uk←uˉk+αkk+Kk(xk−xˉk)u_k \leftarrow \bar u_k + \alpha k_k + \mathbf{K}_k(x_k - \bar x_k), xk+1←f(xk,uk)x_{k+1} \leftarrow f(x_k, u_k); accept the first α\alpha whose realized decrease is a fair fraction of −ΔV(α)-\Delta V(\alpha); μ←μ/10\mu \leftarrow \mu/10
  4. if no α\alpha descends: μ←10μ\mu \leftarrow 10\mu; repeat from 2 until the relative decrease is below tol
Algorithmmpc_tick(x_meas, t)Costone warm-started iLQR solve (a few iterations) per tick
In
measured state, tick; a horizon N_h, a cost builder seeing the obstacles at t, a warm start
Out
u₀ to apply; the plan
  1. uˉ←\bar u \leftarrow last plan shifted by one stage (its last control repeated), or zeros
  2. (xˉ,uˉ)←(\bar x, \bar u) \leftarrow ilqr(f,ℓt,ℓf,xmeas,uˉ)(f, \ell_t, \ell_f, x_{meas}, \bar u) with the obstacle frozen where it is at tt and ℓf\ell_f the cost-to-go when one is available
  3. apply uˉ0\bar u_0; keep uˉ\bar u for the next tick
Algorithmoptim::lm(r, J_r, x₀, Δ) — shared with Chapter 21Costone m×n Jacobian and one n×n solve per trial step
In
residual function (and Jacobian, else central differences), start, optional box trust radius
Out
x minimizing ½‖r(x)‖², cost history
  1. λ←10−3\lambda \leftarrow 10^{-3}; repeat:
  2. J←Jr(x)J \leftarrow J_r(x); stop if ∥JTr∥∞<\|J\T r\|_\infty < tol
  3. solve (JTJ+λ diag(JTJ)) δ=−JTr(J\T J + \lambda\,\mathrm{diag}(J\T J))\,\delta = -J\T r; clip ∥δ∥∞≤Δ\|\delta\|_\infty \le \Delta
  4. if 12∥r(x+δ)∥2<12∥r(x)∥2\tfrac12\|r(x + \delta)\|^2 < \tfrac12\|r(x)\|^2: accept, λ←0.3λ\lambda \leftarrow 0.3\lambda; else λ←10λ\lambda \leftarrow 10\lambda and retry

Implementation in Rust

The trajopt crate is introduced here and imported by Chapters 21 and 23. Its one shared piece is the optimizer; the rest are the five methods, each small enough to read beside its derivation.

crates/trajopt/src/optim.rs
use nalgebra::{DMatrix, DVector};

/// A nonlinear least-squares problem ½‖r(x)‖². `jacobian` defaults to central differences.
pub trait LeastSquares {
    fn residual(&self, x: &DVector<f64>) -> DVector<f64>;
    fn jacobian(&self, x: &DVector<f64>) -> DMatrix<f64> { finite_difference_jacobian(|y| self.residual(y), x, 1e-6) }
}

pub struct LmOptions { pub max_iter: usize, pub tol_grad: f64, pub lambda0: f64, pub trust: f64 }

/// Levenberg–Marquardt with Marquardt scaling and a box trust region:
///     (JᵀJ + λ diag(JᵀJ)) δ = −Jᵀr,   ‖δ‖∞ ≤ trust,   accept iff the true cost drops.
/// λ → 0 is Gauss–Newton, λ → ∞ a short gradient step. The box is the crude trust
/// region `sco` leans on, and Chapter 21's nonlinear steering reuses the whole thing.
pub fn levenberg_marquardt(p: &impl LeastSquares, x0: DVector<f64>, o: &LmOptions) -> LmResult {
    let (mut x, mut lambda) = (x0, o.lambda0);
    let mut r = p.residual(&x);
    let mut cost = 0.5 * r.norm_squared();
    for _ in 0..o.max_iter {
        let j = p.jacobian(&x);
        let g = j.transpose() * &r;
        if g.amax() < o.tol_grad { break; }
        let jtj = j.transpose() * &j;
        loop {
            let mut a = jtj.clone();
            for i in 0..a.nrows() { a[(i, i)] += lambda * jtj[(i, i)].max(1e-12); }
            let mut delta = a.lu().solve(&(-&g)).expect("JᵀJ + λD is positive definite");
            let step = delta.amax();
            if step > o.trust { delta *= o.trust / step; }
            let x_trial = &x + &delta;
            let r_trial = p.residual(&x_trial);
            let c_trial = 0.5 * r_trial.norm_squared();
            if c_trial < cost { x = x_trial; r = r_trial; cost = c_trial; lambda *= 0.3; break; }
            lambda *= 10.0;
            if lambda > 1e15 { return LmResult::stationary(x, cost); }
        }
    }
    LmResult::converged(x, cost)
}
crates/trajopt/src/chomp.rs
use nalgebra::{Point2, SMatrix, SVector};

/// A discretized trajectory with fixed endpoints; ξ holds only the N−1 free waypoints.
pub struct ChompProblem<'a, const N: usize> {
    pub q0: SVector<f64, N>, pub q_end: SVector<f64, N>,
    pub field: &'a dyn DistanceField,            // Chapter 7: dist(x), grad(x), signed
    /// Body points: workspace position and 2×N Jacobian at q (Reach: samples along both links).
    pub body: Vec<Box<dyn Fn(&SVector<f64, N>) -> (Point2<f64>, SMatrix<f64, 2, N>) + 'a>>,
    pub eps: f64, pub lambda_obs: f64, pub eta: f64,
    pub obstacle_form: ObstacleGradient,        // Pointwise (the micro-example) | ArcLength (full CHOMP)
}

/// c(d): −d + ε/2 below zero, (ε − d)²/(2ε) on [0, ε], 0 above — C¹ at both seams.
pub fn obstacle_cost(d: f64, eps: f64) -> f64 { if d < 0.0 { -d + eps / 2.0 } else if d < eps { (eps - d).powi(2) / (2.0 * eps) } else { 0.0 } }
pub fn obstacle_slope(d: f64, eps: f64) -> f64 { if d < 0.0 { -1.0 } else if d < eps { -(eps - d) / eps } else { 0.0 } }

/// Solve A y = r for A = tridiag(−1, 2, −1) ⊗ I by one Thomas sweep per coordinate: the
/// covariant preconditioner. A⁻¹ is dense and positive — a push on one waypoint moves them all.
pub fn solve_smoothness_metric<const N: usize>(r: &[SVector<f64, N>]) -> Vec<SVector<f64, N>> {
    let m = r.len();
    let mut bp = vec![2.0; m];
    let mut dp: Vec<SVector<f64, N>> = r.to_vec();
    for k in 1..m { let w = -1.0 / bp[k - 1]; bp[k] = 2.0 + w; dp[k] = dp[k] - w * dp[k - 1]; }
    let mut y = vec![SVector::zeros(); m];
    y[m - 1] = dp[m - 1] / bp[m - 1];
    for k in (0..m - 1).rev() { y[k] = (dp[k] + y[k + 1]) / bp[k]; }
    y
}

/// One covariant step ξ ← ξ − (1/η) A⁻¹ ∇F. Returns the statistics *before* the step.
pub fn chomp_step<const N: usize>(p: &ChompProblem<'_, N>, xi: &mut [SVector<f64, N>]) -> ChompStats {
    let full: Vec<_> = std::iter::once(p.q0).chain(xi.iter().copied()).chain(std::iter::once(p.q_end)).collect();
    let mut grad: Vec<SVector<f64, N>> = (1..full.len() - 1).map(|k| 2.0 * full[k] - full[k - 1] - full[k + 1]).collect();
    let (mut f_obs, mut min_clear) = (0.0, f64::INFINITY);
    for k in 1..full.len() - 1 {
        for b in &p.body {
            let (x, j) = b(&full[k]);
            let d = p.field.dist(&x);
            min_clear = min_clear.min(d);
            let c = obstacle_cost(d, p.eps);
            let mut f = obstacle_slope(d, p.eps) * p.field.grad(&x);          // ∇c = c′(D) ∇D
            let mut w = 1.0;
            if p.obstacle_form == ObstacleGradient::ArcLength {
                let (xm, _) = b(&full[k - 1]); let (xp, _) = b(&full[k + 1]);
                let v = 0.5 * (xp - xm); let acc = xp - 2.0 * x + xm;        // x′, x″ along the path
                if let Some(t) = v.try_normalize(1e-9) {
                    let proj = |z: nalgebra::Vector2<f64>| z - t * t.dot(&z);
                    let kappa = proj(acc.coords) / v.norm_squared();
                    f = v.norm() * (proj(f) - c * kappa);                   // ‖x′‖[(I − x̂′x̂′ᵀ)∇c − c κ]
                    w = v.norm();
                }
            }
            f_obs += c * w;
            grad[k - 1] += p.lambda_obs * (j.transpose() * f);               // Jᵀ f: Chapter 7's lift
        }
    }
    let dir = solve_smoothness_metric(&grad);
    for (q, d) in xi.iter_mut().zip(dir) { *q -= d / p.eta; }
    ChompStats { f_smooth: smoothness(&full), f_obs, min_clearance: min_clear }
}

/// Sufficient step bound: η > ½(1 + (λ/ε)/λ_min(A)), λ_min(A) = 2 − 2cos(π/(m+1)).
pub fn stable_eta(m: usize, lambda_obs: f64, eps: f64) -> f64 {
    0.5 * (1.0 + lambda_obs / eps / (2.0 - 2.0 * (std::f64::consts::PI / (m as f64 + 1.0)).cos()))
}
crates/trajopt/src/ilqr.rs
/// Discrete-time dynamics x_{k+1} = f(x_k, u_k) with Jacobians; implemented by `models::*`.
pub trait DiscreteDynamics<const NX: usize, const NU: usize> {
    fn step(&self, x: &SVector<f64, NX>, u: &SVector<f64, NU>) -> SVector<f64, NX>;
    /// (f_x, f_u). Default: central differences — override with analytic forms for speed.
    fn jacobians(&self, x: &SVector<f64, NX>, u: &SVector<f64, NU>) -> (SMatrix<f64, NX, NX>, SMatrix<f64, NX, NU>) {
        numeric_jacobians(|a, b| self.step(a, b), x, u, 1e-6)
    }
}

pub struct BackwardPass<const NX: usize, const NU: usize> {
    pub k: Vec<SVector<f64, NU>>,          // feedforward
    pub big_k: Vec<SMatrix<f64, NU, NX>>,  // feedback gains 𝐊_k — the policy, not just a plan
    pub v_xx: Vec<SMatrix<f64, NX, NX>>,   // the value model, for the widget's ellipses
    pub expected_decrease: (f64, f64),     // ΔV(α) = α·a + α²·b, for the line search
    pub ok: bool,                          // every regularized Q_uu was positive definite
}

pub fn backward_pass<const NX: usize, const NU: usize>(
    f: &impl DiscreteDynamics<NX, NU>, cost: &impl StageCost<NX, NU>,
    xs: &[SVector<f64, NX>], us: &[SVector<f64, NU>], mu: f64, ddp: bool,
) -> BackwardPass<NX, NU> {
    let n = us.len();
    let (mut v_x, mut v_xx) = cost.terminal_derivs(&xs[n]);
    let mut bp = BackwardPass::with_capacity(n);
    let (mut a, mut b) = (0.0, 0.0);
    for k in (0..n).rev() {
        let (fx, fu) = f.jacobians(&xs[k], &us[k]);
        let l = cost.running_derivs(&xs[k], &us[k], k);
        let q_x  = l.lx + fx.transpose() * v_x;
        let q_u  = l.lu + fu.transpose() * v_x;
        let mut q_xx = l.lxx + fx.transpose() * v_xx * fx;
        let mut q_uu = l.luu + fu.transpose() * v_xx * fu;
        let mut q_ux = l.lux + fu.transpose() * v_xx * fx;
        if ddp {                                   // the tensor terms V_x · f_xx etc., by differencing the Jacobians
            let so = second_order_terms(f, &xs[k], &us[k], &v_x);
            q_xx += so.xx; q_uu += so.uu; q_ux += so.ux;
        }
        let q_uu_reg = q_uu + mu * SMatrix::identity();
        let Some(chol) = q_uu_reg.cholesky() else { bp.ok = false; return bp; };
        let kk = -chol.solve(&q_u);
        let big_k = -chol.solve(&q_ux);
        a += kk.dot(&q_u);
        b += 0.5 * kk.dot(&(q_uu * kk));
        // Value update with the cross terms (Tassa, Erez & Todorov 2012, eq. 11).
        v_x  = q_x + big_k.transpose() * q_uu * kk + big_k.transpose() * q_u + q_ux.transpose() * kk;
        v_xx = q_xx + big_k.transpose() * q_uu * big_k + big_k.transpose() * q_ux + q_ux.transpose() * big_k;
        v_xx = 0.5 * (v_xx + v_xx.transpose());
        bp.push(k, kk, big_k, v_xx);
    }
    bp.expected_decrease = (a, b);
    bp
}

/// Closed-loop rollout u_k = ū_k + α k_k + 𝐊_k (x_k − x̄_k): the policy, not the plan.
pub fn forward_pass<const NX: usize, const NU: usize>(f: &impl DiscreteDynamics<NX, NU>, xs: &[SVector<f64, NX>], us: &[SVector<f64, NU>], bp: &BackwardPass<NX, NU>, alpha: f64)
    -> (Vec<SVector<f64, NX>>, Vec<SVector<f64, NU>>) {
    let mut xs_new = vec![xs[0]];
    let mut us_new = Vec::with_capacity(us.len());
    for k in 0..us.len() {
        let u = us[k] + alpha * bp.k[k] + bp.big_k[k] * (xs_new[k] - xs[k]);
        xs_new.push(f.step(&xs_new[k], &u));
        us_new.push(u);
    }
    (xs_new, us_new)
}

pub fn ilqr<const NX: usize, const NU: usize>(f: &impl DiscreteDynamics<NX, NU>, cost: &impl StageCost<NX, NU>, x0: SVector<f64, NX>, u_init: &[SVector<f64, NU>], opts: IlqrOptions) -> IlqrResult<NX, NU> {
    let mut us = u_init.to_vec();
    let mut xs = rollout(f, x0, &us);
    let mut j = total_cost(cost, &xs, &us);
    let mut mu = opts.mu0;
    for _ in 0..opts.max_iter {
        let mut bp = backward_pass(f, cost, &xs, &us, mu, opts.ddp);
        while !bp.ok { mu = (mu * 10.0).max(1e-6); bp = backward_pass(f, cost, &xs, &us, mu, opts.ddp); }
        let mut accepted = false;
        for &alpha in &[1.0, 0.5, 0.25, 0.125, 0.0625, 0.03125, 0.015625] {
            let (xs_t, us_t) = forward_pass(f, &xs, &us, &bp, alpha);
            let j_t = total_cost(cost, &xs_t, &us_t);
            let expected = -(alpha * bp.expected_decrease.0 + alpha * alpha * bp.expected_decrease.1);
            if j_t < j && (expected <= 1e-12 || (j - j_t) / expected > 1e-4) {
                let rel = (j - j_t) / j.abs().max(1e-300);
                xs = xs_t; us = us_t; j = j_t; mu = (mu * 0.1).max(opts.mu_min); accepted = true;
                if rel < opts.tol { return IlqrResult::converged(xs, us, bp); }
                break;
            }
        }
        if !accepted { mu *= 10.0; if mu > opts.mu_max { break; } }
    }
    IlqrResult::stopped(xs, us)
}
crates/trajopt/src/mpc.rs
/// Receding horizon around any solver that accepts a warm start.
pub struct Mpc<S: HorizonSolver> { pub solver: S, pub horizon: usize, warm: Option<Vec<S::Control>> }

impl<S: HorizonSolver> Mpc<S> {
    /// One tick: solve from the *measured* state with the obstacles where the sensor says they
    /// are, apply the first control, keep the rest shifted by one stage as the next warm start.
    pub fn tick(&mut self, x_meas: &S::State, t: usize) -> S::Control {
        let init = match &self.warm {
            Some(w) => { let mut u = w[1..].to_vec(); u.push(*w.last().unwrap()); u }
            None => vec![S::Control::zeros(); self.horizon],
        };
        let plan = self.solver.solve(x_meas, &init, t);        // iLQR, a few iterations
        let u0 = plan.controls[0];
        self.warm = Some(plan.controls);
        u0
    }
}

/// Chapter 6's wave-front distance to the goal on an occupancy grid, bilinearly interpolated:
/// the terminal cost that lets a short horizon see around a corner.
pub struct GridCostToGo { grid: OccupancyGrid, value: Vec<f64> }
impl GridCostToGo {
    pub fn new(world: &World, goal: Point2<f64>, cell: f64, inflation: f64) -> Self {
        let grid = grid_from_world(world, cell, inflation);
        let wf = wavefront(&grid, grid.cell_at(goal), Connectivity::Eight);   // Chapter 7's wave-front on Chapter 6's grid
        let value = wf.label.iter().map(|&l| if l >= 2 { (l - 2) as f64 * cell } else { f64::INFINITY }).collect();
        Self { grid, value }
    }
    pub fn at(&self, x: f64, y: f64) -> f64 { self.grid.bilinear(&self.value, x, y) }
}

The worked example, printed

cargo run --example chomp_three -p trajopt builds the micro-example with an analytic DiscField so the numbers are exact (the widget uses the Chapter 7 grid field) and prints

grad_smooth = (0.0000, 0.4000)    grad_obs = (0.0000, -1.0000)    c(D) = 0.5500
q1 <- (1.0000, 0.5000)            clearance = 0.0000
step 2: |grad| = 0.0000 (fixed point, in contact)
lambda_obs in {1, 2, 4}, eta = 4, 200 iterations:  D* = 0.0000  0.1667  0.3000
stability: eta* = (1 + lambda)/2;  lambda = 2: eta = 1.4 -> y = 0.4615 (oscillates), eta = 1.6 -> y = 0.6667
20 waypoints, lambda = 2, eps = 0.5:  eta* = 90.0;  2 eta*: 0 cost rises in 400 steps, clearance 0.494;  eta*/4: 36 rises in 100
STOMP seed 7, K = 24, 120 iterations, lambda = 2:  y = 0.6566  (y* = 0.6667)
sco, 12 waypoints, d_safe = 0.1:  27 convexifications, mu = 1e5, tightest clearance 0.1000, violation 5.6e-7

cargo run --example ilqr_min_effort -p trajopt discretizes q¨=u\ddot q = u at N=100N = 100, tf=1t_f = 1, d=1d = 1, cost ∑uk2Δt\sum u_k^2\Delta t plus 106∥xN−(1,0)∥210^6\|x_N - (1, 0)\|^2, and prints

u_0 = 5.9405 (Pontryagin 5.9400)   u_50 = -0.0600 (-0.0600)   max |u_k - (6 - 12 t_{k+1/2})| = 5.1e-4
alpha = 1 accepted; converged in one iteration (linear-quadratic: one pass is Riccati)
LQR check, N = 40 double integrator:  max |K - K_riccati| = 1.1e-15;  cost after one pass 10.88611263 = 1/2 x0' P0 x0
Hitch parking, N = 30:  iLQR 79 iterations 559.76 -> 60.62;  DDP 30 iterations -> 56.24  (different local optima; both park within 5 cm, 0.05 rad)
Reach on the Workbench, 24 waypoints, 8 body points, arclength, eta = 8:
  straight chart line:  F 1.1895 -> 1.0612, |grad| 5e-15, clearance -0.183  (CONVERGED IN COLLISION)
  detour via folded elbow:  F 0.9236 -> 0.3724, clearance 0.099;  mid-path separation 1.98 rad
MPC, Apartment, dt = 0.1:  cost-to-go terminal  N_h = 6: 125 ticks 1.9 ms | 10: 127 ticks 3.9 ms | 24: 139 ticks 10.0 ms, no contact
                           quadratic terminal   N_h = 10, 24: never arrives (myopic);  open-loop ghost N_h = 16: 10 collision ticks
hook pipeline:  RRT (seed 19) 14 nodes -> shortcut 3 -> 40 CHOMP steps, clearance 0.146;  t_f 1.803 s -> 0.992 s

#[test] fn reproduces_micro_example() asserts the first block to 10−410^{-4} and #[test] fn ilqr_matches_pontryagin() the second: max⁡k∣uk−(6−12 tk+1/2)∣<5×10−2\max_k |u_k - (6 - 12\,t_{k+1/2})| < 5 \times 10^{-2} and convergence in one iteration. #[test] fn argmin_agrees() runs argmin's L-BFGS on the micro-example's objective and asserts the same fixed point to 10−610^{-6} — the one place a library optimizer appears, as a cross-check and never as the implementation.

The widgets on this page run the TypeScript port in lib/trajopt/, a line-for-line translation of the Rust above. Its fifteen self-checks reproduce every number in the two printouts and add the invariants: the Thomas sweep against the dense inverse, the pointwise gradient against finite differences, the iLQR gains against an independent Riccati recursion, the car model against Chapter 2's Hitch.step, the brushfire field bracketed by the exact Euclidean distance (d/2−1.5d/\sqrt2 - 1.5 cells ≤D≤d+1.5\le D \le d + 1.5 cells at all 379 sampled points of the Workbench), and the receding-horizon loop against its open-loop ghost.

Putting it together

The Reach pipeline the capstone ships is the hook: a Chapter 12 RRT → Chapter 13 shortcut → chomp → Chapter 18 CubicSpline + time_scale → Chapter 17 replay. On the seeded instance above it takes the time-optimal execution from 1.8031.803 s to 0.9920.992 s without changing the planner's global decision. Three honesty items the pipeline makes visible.

Where optimization beats search. Smoothness, clearance and dynamics are what a sampling planner ignores and an optimizer measures. Forty covariant steps cost less than the RRT that found the path, and the time scaler rewards every bit of curvature removed. iLQR goes further than CHOMP can: it respects the dynamics by construction, and it returns a feedback policy — the gains Kk\mathbf{K}_k — which is what makes the receding-horizon loop closed-loop rather than a replanner.

Where it sticks. Homotopy and narrow passages. From the straight chart line, CHOMP on the Workbench converges with a body point inside the block, at machine-precision stationarity. No η\eta, λ\lambda or ϵ\epsilon fixes that; only a different initial path does, which is a sampling planner's job. Choset's "the solution achieved will depend heavily on the initial guess" is the chapter's epigraph for a reason.

Where the loop earns its keep. MPC's value is not foresight — a frozen-obstacle planner has none past its sensor — but correction: the obstacle moves, the plan moves with it, every tick. The Apartment check shows the horizon buying compute (1.9→10.01.9 \to 10.0 ms) while the terminal cost-to-go, not the horizon, decides whether Rusty arrives at all. The sister book's MPPI runs the same loop with sampling in place of iLQR; a side-by-side on the same model, horizon and seeds is the honest way to compare them, and anything less is not shown.

f19.4The Optimizer Family MapThese are not competing algorithms; they are points in one design space.
first-order gradientsecond-order modelsamplingobstacles as costas constraintShooting (C §11.3.1)Shooting (C §11.3.1)Transcription + SQP (C §11.3.2)Transcription + SQP (C §11.3.2)CHOMP (2009, 2013)CHOMP (2009, 2013)STOMP (2011)STOMP (2011)TrajOpt (2013, 2014)TrajOpt (2013, 2014)iLQR / DDP (1970, 2004, 2012)iLQR / DDP (1970, 2004, 2012)MPC (the loop)MPC (the loop)MPPI (sister Ch. 23)MPPI (sister Ch. 23)
CHOMP (2009, 2013) — Covariant functional gradient over a distance field: ξ ← ξ − (1/η) A⁻¹∇F. Obstacles are a C¹ cost c(D); clearance is bought with λ and ε, so collisions can survive convergence. (first-order in the smoothness metric; obstacles as cost; collocation: parameterize q(t) and read u from the dynamics.)
Squares are collocation methods (the trajectory is the variable), circles are shooting methods (the controls are), the diamond is a loop around any of them. Dashed blue arrows are lineage: Pontryagin's shooting to iLQR's value recursion, transcription to TrajOpt's sequential convexification, CHOMP's smoothness metric to STOMP's noise covariance, and STOMP's rollouts to MPPI inside the MPC loop.

On to Chapter 20, where the question becomes whether the car can reach a configuration at all — and to Chapter 21, where trajopt::optim steers it there.

Exercises

  1. Foundation exerciseDifficulty 2 of 3The equilibrium and its stability

    For the three-waypoint micro-example derive y∗=λ/(1+λ)y^* = \lambda/(1 + \lambda) from ∇q1F=0\nabla_{q_1}\mathcal{F} = 0 inside the margin, then write the covariant update as y←y(1−(1+λ)/η)+λ/ηy \leftarrow y(1 - (1 + \lambda)/\eta) + \lambda/\eta and show it is a contraction iff η>(1+λ)/2\eta > (1 + \lambda)/2. The design brief for this chapter claimed stability "for every η≥1\eta \ge 1"; find the smallest λ\lambda for which η=1\eta = 1 oscillates.

    For λ = 2, the smallest stable η

  2. Foundation exerciseDifficulty 2 of 3One pass is Riccati

    Show that for xk+1=Axk+Bukx_{k+1} = Ax_k + Bu_k and ℓ=12(xTQx+uTRu)\ell = \tfrac12(x\T Qx + u\T Ru), ℓf=12xTQfx\ell_f = \tfrac12 x\T Q_f x, the iLQR backward pass with μ=0\mu = 0 produces Vxx,k=PkV_{xx,k} = P_k of the discrete Riccati recursion and Kk=−(R+BTPk+1B)−1BTPk+1A\mathbf{K}_k = -(R + B\T P_{k+1}B)^{-1}B\T P_{k+1}A, and that the α=1\alpha = 1 forward pass is the optimal trajectory in one iteration. Then exhibit the first term that breaks this for the unicycle xk+1=xk+Δt (vcos⁡θ,vsin⁡θ,ω)x_{k+1} = x_k + \Delta t\,(v\cos\theta, v\sin\theta, \omega).

  3. Conceptual exerciseDifficulty 2 of 3Predict the clearance
    Predict first

    In the CHOMP Smoother load the micro-example preset and set λ_obs = 4 (η stays above the bound). What clearance does the waypoint converge to?

  4. Conceptual exerciseDifficulty 2 of 3Horizon versus cost-to-go

    In the MPC Horizon widget, switch the terminal cost-to-go off. Find the longest NhN_h at which Rusty still fails to round the corridor into room E, and explain the failure with the Chapter 6 backward-Dijkstra cost-to-go: what does the quadratic pull believe about the wall? Switch the cost-to-go back on and verify that Nh=6N_h = 6 now succeeds. Which parameter decided the outcome, and what did the horizon buy instead?

  5. Practical exerciseDifficulty 2 of 3DDP's second-order terms by finite differences

    ilqr.rs adds Vx⋅fxxV_x\cdot f_{xx}, Vx⋅fuuV_x\cdot f_{uu} and Vx⋅fuxV_x\cdot f_{ux} by central differences of the Jacobians when ddp is set. Verify the implementation against the analytic second derivatives of the unicycle, then compare iteration counts and wall-clock time of iLQR and DDP on Hitch's parking problem over 20 seeded starts. Report, honestly, how often the two reach different local optima — the chapter's single instance has DDP finding a cheaper one in fewer iterations, and one instance is not a trend.

  6. Practical exerciseDifficulty 3 of 3STOMP against CHOMP on the two-homotopy Workbench (stretch)

    Run stomp from 20 seeds and chomp from both initial paths on the Workbench scene of the smoother widget. Report the success rate (final clearance positive), the median final cost, and the seeds that never escape the straight-line class's collision minimum. STOMP's noise can jump a waypoint across the block where a gradient cannot; it can also wander. Include the seeds that fail.

References

  1. Choset, H., Lynch, K. M., Hutchinson, S., Kantor, G., Burgard, W., Kavraki, L. E., and Thrun, S. (2005) Principles of Robot Motion: Theory, Algorithms, and Implementations. MIT Press.link to Principles of Robot Motion: Theory, Algorithms, and Implementations (opens in a new tab)

    §11.3.1 (the Hamiltonian, the minimum principle, Example 11.3.1 and the bang-bang recovery, shooting) and §11.3.2 (transcription, the three parameterizations, the two warnings) are the root of everything here; §12.5.3's remark that underactuated systems must parameterize u(t) is Chapter 21's hook. Bryson & Ho, Kirk and Pontryagin et al. are cited there as the classical texts.

  2. Zucker, M., Ratliff, N., Dragan, A. D., Pivtoraiko, M., Klingensmith, M., Dellin, C. M., Bagnell, J. A., and Srinivasa, S. S. (2013) CHOMP: Covariant Hamiltonian Optimization for Motion Planning. International Journal of Robotics Research 32(9–10).doi:10.1177/0278364913488805 (opens in a new tab)

    The journal version of CHOMP: the smoothness metric, the arc-length obstacle functional and its gradient with the velocity projection and curvature term, the C¹ obstacle cost used here, and the Hamiltonian Monte Carlo variant this chapter does not implement. The 2009 ICRA paper by Ratliff, Zucker, Bagnell and Srinivasa introduced the method.

  3. Kalakrishnan, M., Chitta, S., Theodorou, E., Pastor, P., and Schaal, S. (2011) STOMP: Stochastic Trajectory Optimization for Motion Planning. IEEE International Conference on Robotics and Automation.doi:10.1109/ICRA.2011.5980280 (opens in a new tab)

    The gradient-free sibling: noise with covariance A⁻¹, exponentiated cost weights, and the smoothing matrix M. Implemented here with a seeded generator and an accept-if-not-worse guard.

  4. Schulman, J., Duan, Y., Ho, J., Lee, A., Awwal, I., Bradlow, H., Pan, J., Patil, S., Goldberg, K., and Abbeel, P. (2014) Motion Planning with Sequential Convex Optimization and Convex Collision Checking. International Journal of Robotics Research 33(9).doi:10.1177/0278364914528132 (opens in a new tab)

    TrajOpt: the ℓ₁ penalty, the trust-region sequential convex loop, signed-distance constraints linearized at the witness, and swept-volume collision checking. Our sco keeps the loop's shape with LM in place of the QP and says so.

  5. Li, W. and Todorov, E. (2004) Iterative Linear Quadratic Regulator Design for Nonlinear Biological Movement Systems. International Conference on Informatics in Control, Automation and Robotics (ICINCO).link to Iterative Linear Quadratic Regulator Design for Nonlinear Biological Movement Systems (opens in a new tab)

    iLQR: DDP without the second-order dynamics terms. Jacobson and Mayne's Differential Dynamic Programming (1970) is the ancestor with the terms kept; Tassa, Erez and Todorov (IROS 2012) supply the regularization and line search used here.

  6. Tassa, Y., Erez, T., and Todorov, E. (2012) Synthesis and Stabilization of Complex Behaviors through Online Trajectory Optimization. IEEE/RSJ International Conference on Intelligent Robots and Systems.link to Synthesis and Stabilization of Complex Behaviors through Online Trajectory Optimization (opens in a new tab)

    The practical iLQR/DDP: Q_uu regularization, the expected-decrease line search ΔV(α) = αa + α²b, and the value update with cross terms — the exact forms backwardPass and forwardPass implement — run inside an MPC loop.

  7. Rawlings, J. B., Mayne, D. Q., and Diehl, M. M. (2017) Model Predictive Control: Theory, Computation, and Design (2nd ed.). Nob Hill Publishing.link to Model Predictive Control: Theory, Computation, and Design (2nd ed.) (opens in a new tab)

    The stability theory this chapter points at and does not prove: a terminal cost that is a control Lyapunov function makes the receding-horizon loop stable. Our cost-to-go terminal is the practical cousin.