A zero-dependency, high-performance TypeScript control, trajectory optimization, and simulation library for underactuated double inverted pendulums on a cart.
The double inverted pendulum on a cart is a classic benchmark in underactuated nonlinear robotics: one actuator (horizontal force on the cart
This toolkit provides modern control pipelines in pure TypeScript, running with zero runtime dependencies in Node.js, Bun, and modern browsers:
- Continuous Algebraic Riccati Equation (CARE) Solver: Steady-state LQR for upright balance and hanging brake.
- Iterative Linear Quadratic Regulator (iLQR / DDP): Gauss-Newton differential dynamic programming for non-linear swing-up trajectory optimization with soft state/control barriers.
- Time-Varying LQR (TVLQR): Discrete Riccati backward-pass gain scheduling along swing-up trajectories with continuous terminal handover.
- Real-Time Model Predictive Control (NMPC): Real-Time Iteration (RTI) 1-step tracking MPC and online reference-free Energy NMPC.
- Hardware Bench Simulation: Simulates physical reality including Coulomb dry friction (stiction), loop transport latency (40ms+ delay), sensor quantization, and forward state prediction.
- Interactive Browser Canvas Demo: Standalone zero-build HTML5 Canvas visualizer.
Read the complete technical deep dive and interactive essays at davidnash.dev.
- Frictionless Portability: No C++ compilation toolchains (CMake, Eigen, BLAS), no Python virtualenv/wheel management, and no native binaries.
- Universal Runtime: The exact same mathematical models and controllers run in high-throughput Node.js microservices, edge workers, and client-side browser simulations at 60+ fps.
- High Numerical Performance: Uses preallocated flat typed buffers (
Float64Array) and scalar reduction for single-actuator systems, achieving < 0.5 ms solve times for real-time MPC loops.
Run on an Apple M-series processor (single-threaded JavaScript / V8):
| Routine / Operation | Iterations | Mean Execution Time | Max Throughput |
|---|---|---|---|
| RK4 Physics Step (6D non-linear) | 100,000 | ~1.8 µs / step | > 500 kHz |
| Continuous Riccati (CARE) Solve | 100 | ~4.5 ms / solve | ~220 Hz |
| Tracking MPC (1-Step RTI Gauss-Newton) | 1,000 | ~0.35 ms / solve | > 2,800 Hz |
| From-Scratch Energy NMPC (5 iterations) | 200 | ~1.6 ms / solve | ~600 Hz |
Run the benchmark suite locally:
pnpm run benchpnpm add @buildwithnash/pendulum-toolkit
# or npm install @buildwithnash/pendulum-toolkitimport {
computeBalanceLQR,
computeBrakeLQR,
evaluateLQR,
rk4,
STATE_UPRIGHT,
} from '@buildwithnash/pendulum-toolkit';
// Solve Continuous Algebraic Riccati Equation (CARE) about upright equilibrium
const balance = computeBalanceLQR();
console.log('Balance Gains K:', balance.K); // [K_x, K_v, K_th1, K_w1, K_th2, K_w2]
// Closed-loop simulation step
let state = [0, 0, 0.08, 0, -0.05, 0]; // state: [x, v, th1, w1, th2, w2]
const dt = 0.002; // 500 Hz
for (let step = 0; step < 1000; step++) {
const u = evaluateLQR(state, balance.K, STATE_UPRIGHT);
state = rk4(state, u, dt);
}import {
ilqr,
computeTVLQR,
computeBalanceLQR,
STATE_HANGING,
type CostFunction,
} from '@buildwithnash/pendulum-toolkit';
const dt = 0.02; // 20 ms knot spacing
const N = 200; // 4.0 second horizon
// Define stage and terminal costs with track and actuator limits
const cost: CostFunction = {
run(s, u) {
return 0.5 * 0.05 * u * u + 0.5 * (s[0] ** 2 + s[2] ** 2 + s[4] ** 2);
},
term(s) {
return 500 * (s[0] ** 2 + 10 * s[2] ** 2 + 10 * s[4] ** 2);
},
runDeriv(s, u) {
/* gradients & hessians */
},
termDeriv(s) {
/* terminal gradients & hessians */
},
};
const uGuess = new Array(N).fill(0);
const trajectory = ilqr(STATE_HANGING, uGuess, dt, cost, { maxIter: 300 });
// Compute TVLQR tracking gains K(t) initialized with steady-state balance P
const balance = computeBalanceLQR();
const trackingGains = computeTVLQR(
trajectory.xs,
trajectory.us,
dt,
[10, 1, 150, 10, 150, 10],
0.1,
balance.P
);import { EnergyNMPC, rk4 } from '@buildwithnash/pendulum-toolkit';
// Reference-free swing-up NMPC with periodic catch bowl and gated LQR cost-to-go blending
const nmpc = new EnergyNMPC(1.0, 0.02); // 1.0s horizon @ 20ms steps
let state = [0, 0, Math.PI, 0, Math.PI, 0]; // hanging start
// 50 Hz control loop
setInterval(() => {
const u = nmpc.computeControl(state, 15, 1e-3); // ~1-3 ms solve time
state = rk4(state, u, 0.02);
}, 20);import {
HardwareBench,
predictForward,
computeBalanceLQR,
evaluateLQR,
STATE_UPRIGHT,
} from '@buildwithnash/pendulum-toolkit';
const bench = new HardwareBench([0, 0, 0.08, 0, -0.05, 0], {
loopDelay: 0.04, // 40 ms transport / loop latency
coulombFriction: 0.8, // 0.8 N dry Coulomb friction
encoderBits: 14, // 14-bit angular encoder quantization
});
const balance = computeBalanceLQR();
const dt = 0.002;
for (let i = 0; i < 2000; i++) {
const measured = bench.getSensorMeasurement();
// Model-based state prediction across loop delay with friction feedforward
const predicted = predictForward(measured, bench.getControlHistory(), dt, 0.04, {
coulombEstimate: 0.8 * 0.85,
});
const u = evaluateLQR(predicted, balance.K, STATE_UPRIGHT);
bench.step(u, dt);
}When studying control theory papers (Todorov, Tassa, Tedrake) and working with this codebase, here is a quick mapping of key terms and variable names:
| Mathematical Symbol | Code Variable | Concept & Intuition |
|---|---|---|
s, xs
|
State Vector: Cart position ( |
|
u, us
|
Control Input: Horizontal force in Newtons applied to the cart. | |
Q, R, Qf
|
Cost Matrices: State error penalty ( |
|
P, P_BALANCE
|
Cost-to-Go Matrix: Steady-state Riccati solution ( |
|
| $\mathbf{V}x, \mathbf{V}{xx}$ |
Vx, Vxx
|
Value Function Gradient & Hessian: Slope and curvature of total remaining cost from current state forward. |
Qx, Qu
|
Action-Value Gradient: First derivatives of total cost w.r.t. state and control. | |
| $\mathbf{Q}{xx}, \mathbf{Q}{ux}, Q_{uu}$ |
Qxx, Qux, Quu
|
Action-Value Curvature: Second derivatives of cost. Single-actuator control makes |
ks, k_t
|
Feedforward Adjustment: |
|
Ks, K_t
|
Feedback Gain Matrix: |
|
mu |
Levenberg-Marquardt Damping: Regularization added to |
|
alphas, a
|
Line Search Factor: Step size scalar along the search direction ( |
(See docs/cheatsheet.md for the complete comprehensive reference table).
The repository includes a self-contained, zero-build HTML5 Canvas visualizer:
🎮 Click here to open the Live GitHub Pages Demo
Or open examples/browser-demo/index.html locally in any browser to:
- Switch Modes:
- Auto (iLQR + TVLQR → LQR): Smooth precomputed 4.0s swing-up with linear feedback tracking and automatic steady-state balance handover.
- Real-Time Energy NMPC: From-scratch online trajectory discovery and catch without precomputed references.
- Hold Balance LQR & Hanging Brake: Steady-state CARE Riccati equilibrium regulators.
- Real-Time Horizon Prediction Ghosts: Visualizes the solver's rolling planned trajectory fanning out in translucent preview links ahead of the cart.
- Live Compute Speed & Budget Telemetry: Color-coded monitor verifying real-time loop feasibility at 100 Hz / 200 Hz.
- Interactive Tuning: Adjust control loop frequency (20–200 Hz) and horizon length (0.5–1.5s) on the fly.
- Direct Disturbance Testing: Drag the cart with mouse/touch or inject velocity perturbations to stress test controller stability.
The Lagrangian equations of motion for a cart of mass
where
The linearised continuous-time system
The steady-state solution
yielding the optimal feedback law
For non-linear discrete dynamics
For single-actuator systems (
Run any example directly using tsx:
# Offline iLQR swing-up and TVLQR gain scheduling
pnpm run example:swingup
# Balance and hanging brake LQR with basin of attraction sweep
pnpm run example:lqr
# Real-time 50 Hz / 100 Hz NMPC simulation
pnpm run example:nmpc
# Hardware bench with 40ms transport delay and Coulomb friction
pnpm run example:bench
# Run full performance benchmark suite
pnpm run benchRun test suite:
pnpm testMIT License © 2026 David Nash. See LICENSE for details.