← Back to Write-ups

Double Pendulum • Part III

Real-Time Nonlinear MPC in the Browser

By David Nash · · 12 min read (2,490 words)
Total Sparks: 0
Enjoyed this write-up? Charge it with sparks

In Part I, I ran an optimizer once offline which found the swing-up trajectory and it took about a minute to find the best overall path to follow. Then a conventional control system followed that plan, and like magic, it worked every time. The "magic" though is a highly controlled simulation environment so your plan is always within reach.

But if you've done anything in the real world, you know you don't have the luxury of such magic. So, what happens if you ditch having plans altogether? There is a precedent for this, and its called model predictive control. It'll solve the trajectory problem in real time, up to two hundred times a second, to control the behavior of the pendulum. Let's try it out.

In making this write-up, I found that the control theory was the easy part. The algorithm is maybe fifteen lines of cost function appended to the solver from Part I. The pain in the ass was getting it to actually run fast enough and correctly, once the theory was implemented.

The Problem With Pre-Computing

Following a pre-computed trajectory works great until a disturbance knocks the system off course. A tracking controller like only knows how to pull the machine back towards the original computed path. If a bump pushes the pendulum far away, the controller tries to correct the full error right away, which can saturate the motor and causes the swing to fail.

What I'd like is a controller that can abandon the old plan and calculate a fresh one on the fly to adapt to the current conditions better. If you are driving and all of the sudden your planned route is blocked due to construction, you don't try to force yourself onto that route. You re-calculate a new route based on that information and carry on. That is what I want my controller to do.

F**k It, We'll Do it Live

is what I'll be using to try to get this adaptive behavior. Instead of tracking a pre-baked trajectory, the controller finds a brand new trajectory every tick, starting from wherever its at. It only applies the first step of that new plan, throws the rest away, and does it again. NMPC is more akin to brute force than an elegantly architected plan.

Despite its brutish nature, NMPC can be surprisingly effective. You can see it in action in the video below, where an online NMPC controller stabilizes and steers a dynamic quadruped robot in real time. It's not far-fetched to think this was controlled using a machine learning policy but it's not. Pretty cool, eh?

Nonlinear MPC for Quadrupedal Locomotion
Real-time Nonlinear MPC applied to dynamic quadrupedal locomotion, re-solving the trajectory problem online to react to impacts and disturbances without pre-planned footsteps.

NMPC requires no training and there is no neural net. It uses a physics model and a cost function to run an optimization every tick to plan its next move. A trained RL policy is cheap to run and expensive to produce, often needing hours or days of simulated training before it ever touches a real machine. NMPC is cheap to produce (as long as you can model your physics well) but it runs on every tick, which makes it pretty expensive to run. For it to perform well you need a very fast control loop. I aimed for 200 Hz, but the faster you can run it, the better.

NMPC gives you something RL can't, which is an explainable solution. When the solver converges, you have transparency into everything that influenced its decisions and final result. With a trained RL policy, an action is spit out of a black box. If it suddenly does something strange, you can't just read the weights to find out why.

Real-Time Computation Headaches

Getting the NMPC code to run two hundred times a second is where I struggled a bit. Part I's offline solver took about 2 seconds to compute a single full path. Doing that inside a 5ms budget, in JavaScript no less, requires some careful coding.

But I have a few tricks up my sleeve. For starters, I don't need to compute the entire trajectory every time, I only need to compute a segment of it which is determined by the horizon. Second, from the previous solve I already have an answer that, unless something crazy happened, will be a pretty good guess for the current moment as well. So instead of just throwing away ALL that old data, I just throw away the part that I already used (the first step) and I start the new optimization from the previous one's solution. This warm start strategy gets a "good enough" answer in a single iteration so there is no need to iterate to full convergence. This is called .

Here's a live demo of NMPC in action compared to the TVLQR tracker from Part I. In this example the NMPC is also tracking the pre-computed trajectory so its not fully independent yet. If you add a disturbance to the system you can see how they vary in their response. TVLQR tries to erase the whole error immediately and it doesn't work very well. The NMPC plans a longer way back with less corrective force. NMPC doesn't always catch either, but it wins more often than the TVLQR tracker does. The horizon and rate dials connect to the NMPC behavior directly. The starts to cause problems around 30Hz as the correction force is too out of sync with reality.

Fig. 1 NMPC vs. TVLQR Tracking Duel
Solver / Budget
0.0 / 10.0 ms
t = 0.00s
TVLQR Tracking
tracking
Receding Horizon NMPC
tracking
If you play with the dials here you can see how NMPC succeeds in scenarios where TVLQR fails.

Swinging Up From Scratch

Tracking Part I's plan works great, but the promise of this article is that we do everything live. If we keep using the pre-computed trajectory it kinda sucks the fun out of everything.

So let's take the training wheels off. All the controller will be given is a goal of trying to get the energy of the pendulum equal to that of the energy in the pendulum when it's standing upright at equilibrium.

That may seem strange at first. I don't tell it anything about where or how it should do that. It works out on its own that pumping back and forth is the only thing it can do to add the energy it needs as it searches over sequences of forces and finds which actions score best.

However, I now have a problem since I ditched the offline plan to warm-start from. The controller has to discover the whole maneuver on its own, needing 15 to 20 iterations every tick instead of just one. Getting that to actually work turned into a series of headaches.

Getting It to Move at All

When I first tested the from-scratch controller, it didn't move at all. The optimizer was not throwing errors, but the cart just sat there hanging. The problem was how the angle was measured at the hanging starting position. I made a mistake with my function.

In Part I I had a bug where a link would spin around 360° because I didn't wrap the angle values so the error was showing as greater than it actually was. So, in Part I I wrapped the values between −π and π and that solved my problem. For NMPC, the terminal cost has a and I'd implemented it by wrapping the joint angle into (−π, π] like I did before. Worked before, so should work this time, right? Well... no. The simulation always starts hanging where θ = π, which is where the (−π, π] wrapping function has a discontinuity.

The solver builds its derivatives by finite differencing (nudge left, nudge right, subtract the value). Doing that at the start when θ = π meant that one side of the equation was near +π and the other was near −π. This caused the second derivative to be very very negative, which the solver treats as something going terribly wrong. To protect itself, it injects heavy damping to force the curvature back positive. Because motor commands are computed by dividing the correction by that damped curvature, dividing by such a massive damping term made the commanded force effectively zero.

The fix is dead simple, just use a different wrapping function. 1 − cos θ has the same bowl I wanted, without the discontinuity right at the resting position. Lesson learned. Picking a wrapping function with a break in it was the wrong call for something that touches every angle, since sooner or later the system lands exactly on the break, and here "sooner" was literally frame one.

Fig. 2 Comparing Wrapping Functions
wrap(θ) then square (buggy) 1 − cos θ (fixed)
00.260.530.791.050π/2π3π/22πθ = πcatch-bowl cost (normalized)θ (rad)
Hover for values

You can see how the cos curve is smooth and continuous while the pi-wrapped curve has a corner at θ = π. The solver was computing the curve across that corner which caused the issue.

Both curves plotted from θ = 0 to 2π, normalized to the same peak height so shape is what's being compared. The hanging start state sits at θ = π, marked by the dashed line.

Getting It Fast Enough

With the solver no longer blowing up, the next problem was speed. A from-scratch swing-up needs around twenty iterations a tick. My initial solve took around 25ms per tick, which blew way past my 5ms budget. The solver was struggling with heavy math and constant garbage collection.

My first implementation calculated the the lazy (as in easier to implement) way by running fourteen separate physics evaluations at every trajectory knot. I tried replacing the lazy approach with a standard textbook formula. Accelerations come from , where is the vector of applied forces. Differentiating that requires differentiating the inverse mass matrix:

The textbook matrix calculus identity for differentiating an inverse matrix.

Funny enough, this benchmarked even slower than my original code. Profiling showed that creating the 3×3 matrices in JavaScript cost more than the fourteen physics rollouts it removed.

Luckily I can simplify the math by reusing work from earlier. Multiplying the textbook formula by the force vector lets us substitute the acceleration vector directly into the right side:

Because M⁻¹r is already the acceleration vector a, we avoid matrix-matrix multiplication entirely.

With this approach every intermediate calculation is now just a plain scalar number. The Jacobian dropped from 5.5 microseconds down to about 1 microsecond per knot. I wrote a verification script testing this formula against my old finite-difference code across 500 random states to confirm no degradation happened. All came out clean.

The rest of the time savings came from reducing memory allocations. The backward pass calculates several matrices and vectors at every knot. My naive code built all of them out of fresh nested arrays of numbers (number[][]). A single 6×6 matrix created an outer array holding six inner arrays on the heap. Vectors were fresh number[] arrays. The line search also allocated new arrays of state vectors (State[]) for each candidate step size. That created over 100,000 short-lived array objects in a single solve and triggered repeated garbage collection pauses.

BEFORE naive-ilqr.ts
Heap Churn (number[][])
// 6x6 matrix: 7 heap arrays, slow nested dereference Vxx[r][c]
const Vxx: number[][] = Array.from(
  { length: 6 },
  () => new Array(6).fill(0)
);
Vxx[r][c] = cost;
 
// Physics step: allocates fresh 6-element State array every call
function step(s: State): State {
  return [s[0] + dt * s[1], s[1] + dt * a[0], ...];
}
 
// Line search: allocates 50 new trajectory state arrays per step
const xsNew: State[] = new Array(50);
AFTER fast-ilqr.ts
Zero Allocations (Float64Array)
// 6x6 matrix: 1 flat buffer using row-major indexing: r * 6 + c
const Vxx = new Float64Array(36);
Vxx[r * 6 + c] = cost;
 
// Physics step: writes 6 states [x, v, θ1, ω1, θ2, ω2] into scratch
function stepInto(out: Float64Array, s: State): void {
  out[0] = s[0] + dt * s[1]; // cart position
  out[1] = s[1] + dt * a[0]; // cart velocity
  // ... links 1 & 2 (indices 2 through 5)
}
 
// Line search: zero-allocation pointer swap between iterations
let temp = xs;
xs = xsNew;
xsNew = temp;

Replacing those nested arrays with preallocated Float64Array scratch buffers dropped each physics evaluation from 87 nanoseconds down to 58 nanoseconds. More importantly, it killed garbage collection pauses entirely.

I originally considered rewriting the solver in WebAssembly (WASM). It turns out I did not need a faster language. I just needed to "get good". The entire solve dropped from over 25ms down to just 1.2ms per tick. That comfortably fits inside my 5ms budget.

Getting It to Actually Stay Up

With the solver now fast and correct, the pendulum could finally be caught. However, it still didn't know how to behave once it was up there.

The energy target on its own turned out to be a bad objective. It was just too simple. A pendulum whipping past at high speed can carry the exact same total energy as one balanced calmly upright. They are treated as equal so nothing encouraged a controlled catch. I also didn't add anything to control the cart, which wandered off the end of the rail while the links kept flailing.

I spent some time wrestling with the cost weights to fix this. I cranked up the penalty on motor effort so the cart chilled out a bit. I added a centering term to pull it back toward the middle of the track. I also penalized joint velocities so there was something to stop the links form whipping around out of control. Eventually I got a repeatable swing-up. Tuning was still pure whack-a-mole though. Nudging any single value past its sweet spot tended to immediately break something else.

Even "tuned", it was still failing around 25% of the time with a runaway cart. This is where I ran into the short-sightedness issue with an MPC controller. Controllers that use pre-computed plans can afford to be short-sighted, because the plan already did the long-range thinking. One without a plan cannot.

With a short time horizon it simply commits to whatever move looks best for the time that it has. With a time horizon of 0.5 seconds the optimizer can't see far enough ahead to know it'll need to come back, so it yeets itself off to the side. Counterintuitively, a harder rail limit made that worse. But if I just give it a marginally larger 1 second horizon the controller can find the pump to catch maneuver. It never even leaves the rail this way so no extra constraints required. With a long enough horizon the catch rate rose back to nearly 100%.

Fig. 3 Short horizon vs. long horizon
0.4s Horizon
running
1.0s Horizon
running
Both panels run the same controller and the same physics. Only the horizon length differs. Drag the slider to find where a short horizon stops being able to see its own catch.

One problem remained after all those fixes — the cart kept drifting after a catch, even with the pendulum balanced well. Energy is the right objective for swinging up but its a pretty useless one for balancing. I needed to split up the behavior into two parts, like in Part I, and Part I's Riccati solution already gives me the number needed here. , the total future cost of being in any given state near upright.

P comes straight out of part one's Riccati solve. g fades it in only where the linearization behind it is actually valid.

Blending that in as a terminal cost once it nears the linearization region gives the planner a way to control the balance. With this the cart settles and the drift disappears.

The Result

All of those speedbumps lead to this. The from-scratch controller, no precomputed anything, replanning its whole future from wherever it is. The faint links fanning out ahead of it are the solver's own predictions. They are for the horizon it just solved, redrawn from scratch every tick. Kick it and watch that prediction reroute in real time, in pure JS baby.

Fig. 4 The Coup de Grâces
Solver / Budget
0.00 / 10.0 ms
t = 0.00s
From-Scratch NMPC running
running
Drag the horizon or the rate dial, then hit KICK. The prediction fan is resampled straight from the solver's own output, not a separate animation layered on top.

What I find funny about this write up is how little of the actual work for me in making this was control theory. The controller that finally works is about fifteen lines of cost function and it is basically straight out of a textbook. What took the most time was silly mistakes I kept making like having a wrapping function with a discontinuity sitting on the start state, lazy programming practices, and tweaking the terminal cost. As with most things though, the ordinary business of making something actually run as intended is most of the job.

One cool thing about debugging this controller is that all of these issues are findable because I could set a breakpoint and see what the heck it is doing. A trained policy does not come with this luxury, so debugging it is a lot more like guessing.

Have feedback or thoughts?

Found a typo, have a question on the derivations, or want to discuss this topic?

Send Feedback →
NMPCControl TheoryTrajectory OptimizationiLQR