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 |
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 |
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 |
None
|
Returns:
| Type | Description |
|---|---|
tuple[Array, Array]
|
|