Skip to content

chc.mpc

Stability tier: stable.

Model predictive control: receding-horizon optimal control with warm starts.

At each step MPC solves a finite-horizon OC problem from the current measured state (against the model), applies only the first control, advances the plant, and re-solves. Planning and reality are separate objects (model vs plant), so the offline/confounded setting — plan with the learned hybrid model, act on the true system — needs no change to this loop.

mpc_control runs that loop against a simulated plant. RecedingHorizon is its online form -- the caller owns the plant and the clock -- and each of its steps is a whole chc.plan.causal_plan, certificate included. PeriodBudget caps what that loop spends per period, which no single window can see.

PeriodBudget dataclass

At most amount spent per period of period steps; a step spends weights @ u.

Periods start at t = start on the loop's clock and every period * dt after. Unspent budget does not carry over: each period starts with amount.

Raises:

Type Description
ValueError

on weights that are not a finite 1-D vector with a non-zero entry, a negative or non-finite amount, a period below one step, or a non-finite start.

RecedingHorizon dataclass

chc.plan.causal_plan from each measured state, warm-started from the last plan.

::

controller = RecedingHorizon(model, cost, dt=0.1, horizon=20, u_lo=-5.0, u_hi=5.0,
                             barrier=BarrierConstraint(h, alpha=2.0))
while running:
    plan = controller.step(measure())
    apply(plan.actions[0])

step returns the whole CausalPlan, so the certificate reaches the caller with the action it covers; read plan.safety and plan.solver_status before applying plan.actions[0]. The fields are causal_plan's arguments, with the same meaning and defaults.

Each step starts the descent from the last plan shifted one step, its final action repeated, and a held barrier's rounds from the last multipliers shifted the same way, under a penalty chosen afresh. That moves where the solves start, not what they minimise.

Compiled once. Each program compiles the first time a step needs it -- the barrier's rounds the first time the barrier binds -- and is reused after, so steps stop compiling. What compiles again is a new shape, dtype or Python function: build the barrier's function once, outside the loop, as this object does by holding it; a fresh lambda per step compiles the descent per step. Across processes, set jax_compilation_cache_dir and lower jax_persistent_cache_min_compile_time_secs to 0: at its default of one second JAX wrote none of a first step's programs on the tests' oscillator, and at 0 a second process loaded every one of them from disk.

Each step is one causal_plan, so nothing outside the horizon carries over: constraints bind each horizon afresh -- a budget row caps every window, not the run's total, and a rate limit does not reach back to the action already applied -- and steps caps each descent, of which a held barrier runs up to 1 + _BARRIER_ROUNDS a step. For the same reason a per-state x_target is refused: every step would read the same window.

A budget per period is budget, and the ledger is the caller's: step takes what the current period has spent so far, as measured. Each period the window touches gets one row over its steps in the window: what is left of the current period, pro rata to the share of its remaining steps the window holds, and a later period's amount, pro rata the same way. So a horizon of at least period sees the rest of every period it plans in, and its row is exactly what is left; a shorter one gets a flat share of it. How far past the period's end to look is the plant's to say. Against the plan made for the whole run at once, on the ledger lab's plant, whose memory is a few steps, a window of one day lost 0.4-0.8 % of the budget's value over three days and one of a sixth of a day 4.6-8.2 %; on the marketing-mix plant, whose slowest adstock keeps 37 % of a spend four weeks on, a window of two four-week periods lost 0.24-0.67 % over six and one of one period 0.45-1.25 % (docs/adr/0008-a-budget-per-period.md). No step's row lets the period overspend, except where the box forces a spend; then the row is the least the box allows, and a warning says by how much the period goes over (chc_event="budget_overrun").

One controller per control loop: step updates the warm start in place, unguarded. A new controller over the same model, cost and barrier is a cold start that reuses the compiled programs.

step(x, t=0.0, spent=None)

Plan from the measured state x, and keep the plan as the next step's start.

t is the loop's clock at x, and the window is planned from it: step k of the plan reads the model at t + k * dt. An autonomous model ignores it; a model with a time-varying term needs it, since the default plans every window as if it started at 0.

spent is what the period t falls in has spent so far, as measured, and a budgeted step needs it: the budget's rows are set from it and from where t falls in the period.

Raises:

Type Description
ValueError

on a t that is not finite; on spent without a budget, or a budget without spent; on a budgeted step whose t is not on the dt grid from the budget's start.

mpc_control(model, x0, cost, dt, horizon, u_lo, u_hi, n_steps, *, plant=None, inner_steps=40, warm_start=True)

Run closed-loop MPC for n_steps; return the realised trajectory and applied controls.

Unlike the one-shot solvers, inner_steps here is a per-decision latency budget and is deliberately far below their default: a warm start hands each replan a nearly-optimal iterate, so the truncation buys latency rather than hiding an unconverged answer. Priced rather than assumed -- against the same loop run to convergence over 25 replans of a damped oscillator, 40 steps come within 0.001% of its closed-loop cost, on the known plant and with an MLP residual, in 808 and 761 steps where the converged loops take 844 and 779. Raise it if the loop is offline; the solve stops on its own once the line search fails.

The loop keeps one clock: step k applies its action to the plant at t = k * dt, and the plan it solves there reads the model from t = k * dt on. Autonomous plants never notice; a plant with a time-varying term is planned and stepped on the time it is at.

Parameters:

Name Type Description Default
model Dynamics

dynamics used for planning (the controller's belief).

required
plant Dynamics | None

true dynamics the control is applied to (defaults to model).

None

Returns:

Type Description
tuple[Array, Array]

(xs, us) with xs of shape (n_steps + 1, n) and us of shape (n_steps, m).