Parameter Management in Acados OCPs

This tutorial walks through AcadosParameterManager — the class that controls how numerical parameters flow into an acados Optimal Control Problem (OCP) and, optionally, into a learning loop via AcadosDiffMpcTorch.

You register each parameter once with manager.register_parameter(...), which returns a CasADi symbol you drop straight into your dynamics, cost, and constraint expressions. At solve time you pass a dict of named parameter values to AcadosDiffMpcTorch; the manager routes each value to the right place (per-stage p or shared p_global) internally.

Parameter interfaces at a glance

Every parameter has one of two interfaces, selected by the differentiable flag:

differentiable

Interface

Symbolic in OCP

Changeable after solver creation

Exposed to learning

False

"non-differentiable"

Yes (p, per-stage)

Yes

No

True

"differentiable"

Yes (p_global, shared)

Yes

Yes (gradients / sensitivities)

A value that never changes (e.g. the time step dt) does not need to be a parameter at all — use a plain Python constant. There is no separate “fixed” interface.

Stage-varying differentiable parameters (e.g. an electricity price with two blocks) are declared with the splits argument, covered in the “Stage-varying parameters and splits” section below.

Minimal example

The scenario below is an N_horizon-step temperature-control problem with a non-differentiable outdoor-temperature parameter, a differentiable comfort setpoint, and a stage-varying differentiable price.

Step 1 — Register parameters

register_parameter returns a real CasADi symbol (not a placeholder) that you can use immediately.

import casadi as ca
import numpy as np
import torch
from acados_template import AcadosOcp

from leap_c.torch import AcadosDiffMpcTorch
from leap_c.parameters import AcadosParameterManager

N_horizon = 20  # stages 0 .. N_horizon (inclusive)

manager = AcadosParameterManager(N_horizon=N_horizon)

# Non-differentiable: ambient temperature, changed per call, no gradient.
outdoor_temp = manager.register_parameter(
    name="outdoor_temp",
    default=np.array([20.0]),   # ambient temperature [degC]
    differentiable=False,
)

# Differentiable, constant across stages: a single scalar the learner can adjust.
comfort_ref = manager.register_parameter(
    name="comfort_setpoint",
    default=np.array([21.0]),
    differentiable=True,
)

# Differentiable and stage-varying: two price blocks.
# With splits=[4, N_horizon]: block 0 covers stages 0-4, block 1 covers stages 5-N_horizon.
price = manager.register_parameter(
    name="price",
    default=np.array([0.15]),   # electricity price [EUR/kWh]
    differentiable=True,
    splits=[4, N_horizon],
)

Step 2 — Build the OCP with the returned symbols

Use the symbols returned by register_parameter directly. (You can also retrieve any registered symbol later with manager.get(name), which is handy when parameter registration and OCP construction live in separate functions.)

R_THERMAL, C_THERMAL = 2.0, 1.5  # thermal resistance, capacitance
dt = 0.25                        # 15-minute time step [h] — a plain constant, not a parameter

ocp = AcadosOcp()
ocp.model.name = "temp_ctrl"

# State: room temperature [degC]
T = ca.SX.sym("T")
ocp.model.x = T

# Control: heating power [kW]
q = ca.SX.sym("q")
ocp.model.u = q

# Discrete-time RC dynamics using the registered symbols.
ocp.model.disc_dyn_expr = T + dt * ((outdoor_temp - T) / (R_THERMAL * C_THERMAL) + q / C_THERMAL)

# Stage cost: comfort deviation + price-weighted energy; terminal cost: comfort deviation only.
ocp.cost.cost_type = "EXTERNAL"
ocp.cost.cost_type_e = "EXTERNAL"
ocp.model.cost_expr_ext_cost = (T - comfort_ref) ** 2 + price * q
ocp.model.cost_expr_ext_cost_e = (T - comfort_ref) ** 2

# Provide a nominal x0 so acados allocates lbx/ubx at stage 0; the actual value is supplied per solve.
ocp.constraints.x0 = np.array([20.0])
ocp.constraints.lbu = np.array([-1.0])
ocp.constraints.ubu = np.array([1.0])
ocp.constraints.idxbu = np.array([0])

ocp.solver_options.tf = N_horizon * dt
ocp.solver_options.N_horizon = N_horizon
ocp.solver_options.qp_solver = "PARTIAL_CONDENSING_HPIPM"
ocp.solver_options.hessian_approx = "EXACT"
ocp.solver_options.integrator_type = "DISCRETE"

Step 3 — Wrap in AcadosDiffMpcTorch and solve

AcadosDiffMpcTorch takes the OCP and its manager. Call it with the initial state and a params dict of named overrides; any parameter you omit keeps its registered default.

diff_mpc = AcadosDiffMpcTorch(ocp, manager)

rng = np.random.default_rng(seed=0)
x0_batch = torch.tensor(rng.uniform(10.0, 30.0, size=(4, 1)))

# Stage-varying price override: shape (batch_size, n_segments); splits=[4, N_horizon] -> 2 segments.
price_tensor = torch.tensor(
    [[0.015, 0.020], [0.20, 0.25], [0.25, 0.30], [0.30, 0.35]],
    dtype=torch.float64,
    requires_grad=True,
)

ctx, u0, x, u, value = diff_mpc(x0_batch, params={"price": price_tensor})
# ctx.status == [0, 0, 0, 0] means all four solves succeeded.

Step 4 — Gradients and sensitivities

Because AcadosDiffMpcTorch is a differentiable nn.Module, standard PyTorch autograd works: back- propagate through the solution, or use torch.autograd.functional.jacobian for full sensitivities. No custom sensitivity API is needed.

# Backpropagate the summed value through the solver to the price tensor.
value.sum().backward()
print(price_tensor.grad)

# du0/dprice: how the first action changes w.r.t. the price parameter.
# Re-solving warmstarted from ctx keeps this cheap.
j_u0 = torch.autograd.functional.jacobian(
    lambda p: diff_mpc(x0_batch, params={"price": p}, ctx=ctx)[1],
    price_tensor,
)

Stage-varying parameters and splits

splits controls how a differentiable parameter varies across the horizon (it is only used for differentiable parameters). It accepts:

  • list[int]: sorted, ascending stage boundaries; the parameter takes one value per resulting segment. With N_horizon = 9 and splits=[4, 9], the parameter is constant over stages 0–4 and takes a second value over stages 5–9. The last boundary must be N_horizon or N_horizon - 1.

  • int: that many equal-sized segments.

  • "stagewise": one value per stage (equivalent to list(range(N_horizon + 1))).

  • "global" (default): a single value shared across all stages.

Internally, stage variation is implemented with a one-hot indicator vector appended to the non-differentiable parameters. The manager stores each block in p_global and manager.get("price") returns a single indicator-gated expression that evaluates to the correct block value at every stage — no conditional logic in the OCP. The indicator is set for you by combine_non_differentiable_parameters (and by AcadosDiffMpcTorch when it packs the per-stage parameters). If the indicator were left all-zero, every stage would silently evaluate to zero for the stage-varying parameters.

Setting values at runtime

The primary path is the params dict passed to AcadosDiffMpcTorch, keyed by parameter name. It accepts both differentiable and non-differentiable overrides together; the manager routes each to the right internal array:

temp_forecast = rng.uniform(5.0, 25.0, size=(4, N_horizon + 1, 1))  # per-stage, non-differentiable
comfort_values = torch.tensor([[19.0], [21.0], [23.0], [22.5]], dtype=torch.float64)

ctx, u0, x, u, value = diff_mpc(
    x0=x0_batch,
    params={
        "outdoor_temp": temp_forecast,       # non-differentiable, per-stage forecast
        "comfort_setpoint": comfort_values,  # differentiable, per-batch scalar
        "price": price_tensor,               # differentiable, stage-varying
    },
)

If you need the packed arrays directly (for inspection or a custom solve loop), two lower-level helpers build them explicitly:

  • combine_non_differentiable_parameters(batch_size=..., **overwrite) returns the per-stage array of shape (batch_size, N_horizon + 1, n_non_differentiable). Overwrites are (batch_size, N_horizon + 1, pdim) arrays; omitted parameters use their defaults, and the indicator is filled in automatically.

  • combine_differentiable_parameters_torch(batch_size=..., device=..., dtype=..., **overwrite) returns the p_global tensor of shape (batch_size, n_differentiable). For a stage-varying parameter the overwrite is (batch_size, n_segments) (one value per block).

p_stagewise = manager.combine_non_differentiable_parameters(
    batch_size=4,
    outdoor_temp=temp_forecast,
)
p_global = manager.combine_differentiable_parameters_torch(
    batch_size=4,
    device=torch.device("cpu"),
    dtype=torch.float64,
    price=price_tensor,
)

A combine_differentiable_parameters_jax counterpart is available for JAX-based workflows.