User guide#
drto has one idea: your Pyomo model already contains everything a dynamic optimization needs, so instead of rebuilding it in a modeling layer, you declare which components play which role and let transformations rewrite the model. This page walks the four workflows those declarations drive — an infinite-horizon controller, a forward simulation, the closed loop that runs the two against each other, and the steady-state branch — then the two how-tos that come up on real models: scaling and IDAES flowsheets.
The registry#
Every declaration records itself in the model’s registry, and every
transformation reads the registry rather than re-scanning the model.
drto.info(m) returns it; displaying it renders the model in its own
terms — the problem’s size at the top, components grouped by role,
indexed constraints in compact symbolic form (one equation per family,
not the per-index expansion pprint produces), units where the model
carries them, and the ordered log of applied transformations:
<drto registry>
states: 1, controls: 1
declarations:
horizon: t (ContinuousSet, 11 points)
states: z (free)
dynamics: dzdt[t] == - z[t] + u[t] for t in t
controls: u (piecewise_constant, free)
...
transformations: (none)
The size line counts declared states and controls as members at one time point, so it reads the model’s dimension rather than its grid’s: a 129-state flowsheet says so on the first line whether the horizon holds two samples or two hundred.
The registry is stored outside the component tree and survives clone()
(and therefore every create_using) with its references remapped, so a
transformed clone knows its own declarations.
The declarations#
The control-side surface, in the order a model usually declares them:
drto.horizon(t)— the timeContinuousSet, declared before discretization so the registry keeps the sample grid.drto.state(z, ...)— the differential states. A state may be a whole time-indexed Var or a member subset of an indexed one (see the IDAES how-to below).drto.dynamics(ode, ...)— the constraint families that are the differential equations. Everything not declared here is algebra.drto.control(u, ..., profile=...)— the manipulated inputs;profilenames the pyomo-cvp parameterization (e.g."piecewise_constant") applied when a transform parameterizes the controls.drto.disturbance(w, ...)— declared zero-mean inputs a simulation or the terminal segment can hold at given values.drto.tracking_stage_cost(stage)/drto.economic_stage_cost(econ)/drto.tracking_terminal_cost(term)— cost constraints: equalities defining cost variables in the model’s own units. A tracking cost covers every declared state (the stage cost the controls too) and contains nothing else besides Params; a gap or a foreign variable is a declaration-time error.drto.move_suppression(move)— a cost constraint pricing the control moves, each member referencing controls at its own sample and the one before, the first member against previous-action Params. The steady reduction drops it, and the terminal segment keeps it off the tail.drto.initial_condition(ic, ...)— constraints pinning states at the first point to mutable Params, which a receding-horizon loop overwrites with each measurement.drto.steady_state(z, z_ss)/drto.steady_state_control(u, u_ss)— pair each state and control with the mutable Param holding its target. The transforms and initializers read these as the setpoint.drto.terminal_constraint(con)— an explicit endpoint condition, when one is wanted.
The estimation-side surface (drto.measurement,
drto.estimated_parameter, drto.estimation_stage_cost,
drto.estimation_terminal_cost, drto.arrival_cost) mirrors this for
moving-horizon estimation.
Declarations are checked as they are made, and a bad one raises a descriptive error at declaration time, not at solve time.
Checking the DAE index#
Declare first, then check: drto.check_index(m) tests whether the
declared model is an index-one DAE — whether every algebraic variable
is determined, pointwise, by the algebraic constraints. A model that
fails is higher-index, and its optimization symptoms are indirect;
the report names the variables no algebraic constraint determines and
the constraints left unmatched, states the structural index, and
estimates the condition of the algebra’s Jacobian at the current
values. The gallery notebook walks the
pendulum’s index ladder and a flowsheet posed both ways.
report = drto.check_index(m)
print(report)
Workflow: an infinite-horizon controller#
The controller workflow runs declare → discretize → terminal segment → initialize → assemble → solve:
# 1. declare (as above), then discretize the horizon
pyo.TransformationFactory("dae.collocation").apply_to(
m, wrt=m.t, nfe=10, ncp=3, scheme="LAGRANGE-RADAU")
# 2. append the infinite-horizon terminal segment
pyo.TransformationFactory("drto.infinite_horizon").apply_to(m)
# 3. initialize: states on a profile to the targets, algebra solved
drto.cold_start_dynamic(m)
# 4. parameterize the controls and assemble the objective
pyo.TransformationFactory("drto.dynamic_optimization").apply_to(m)
# 5. solve and read back
from pyomo.contrib.solver.common.factory import SolverFactory
SolverFactory("ipopt").solve(m)
drto.plot_states(m)
drto.plot_controls(m)
From a function that builds the model rather than a model, steps 1, 2, and 4 are one call:
m = drto.dynamic_optimization(build, N=10, infinite_horizon=True)
drto.cold_start_dynamic(m) # step 3 is still yours
SolverFactory("ipopt").solve(m)
build returns a declared, undiscretized model, its first two parameters
are N and h, and every parameter has a default (feature 006). A
constant input is fixed at the declared sample points, which is all an
undiscretized model has, and the call fixes the members dae.collocation
adds to it. Reach for the four calls above when the workflow has to touch
the model between them, and for the one call when it does not.
The terminal segment (drto.infinite_horizon) maps the tail
t in [tN, inf) onto tau in (0, 1] through tau = tanh(gamma*(t - tN))
and appends it as a block: copies of the states, controls, and algebra,
the dilated dynamics, the stage cost integrated with explicit quadrature
weights, and a soft pin holding the endpoint at the declared targets.
Options: nfe/ncp size the segment mesh, gamma overrides the mesh
rule tanh(gamma*dt) = tau_11, beta > 1 sets the tail’s overestimation
weight, terminal="soft"|"none" selects the endpoint pin, mu its
penalty weight, and disturbances holds declared disturbances at given
constants across the tail. beta and gamma are mutable Params on the
segment, so they retune with set_value and no re-apply.
Cold start (drto.cold_start_dynamic) initializes a model whose
initial condition sits away from the steady state, with no prior solution
and no equilibrium solve: each state runs from its declared initial
condition to its declared target, derivatives hold the profile’s slope,
controls hold their targets, the tail rests on the targets, and, with
pyomo-pounce installed, the algebraic variables are solved pointwise from
every equation except the declared dynamics. The default profile is a
straight line; profile="exponential" runs a normalized decay that lands
exactly on the target at the horizon’s end, with time_constant in the
horizon’s own units (default a third of the horizon). The guess then
satisfies every equation except the dynamics themselves, which carry the
mismatch the optimizer resolves. On the IDAES CSTR example the
exponential start roughly halves the first solve’s iteration count.
drto.initialize_steady_state(m, controls={...}) is the solve-based
alternative: reduce a throwaway clone to its steady state, solve the
equilibrium with the pyomo-pounce pipeline, and broadcast it flat across
the horizon. Use it when the initial condition is at (or near) a steady
state; use cold start when it is not.
Assembly: drto.dynamic_optimization applies the declared control
profiles (through drto.parameterize and pyomo-cvp) and builds the
objective from every live cost term — the finite-horizon stage cost, the
tail’s quadrature group, and the pin penalties — via
drto.build_objective. Deactivating a cost constraint removes its term; there
are no coupling options.
The advanced-step correction (drto.advanced_step_controller)
turns the solved horizon into a between-samples controller: solve at a
predicted state with pounce (the assembly declared the initial-condition
Params as sensitivity parameters, inert metadata for every other
solver), write the measured state into those Params when it arrives,
and ask for the
corrected solution — a backsolve on the kept factorization, not a
re-solve. The returned map holds every variable’s corrected value; the
model itself is untouched, so the prediction stays in place as the next
solve’s warm start. gradient=True returns the controls’ sensitivities
to those Params instead.
Warm start (drto.warm_start_dynamic) is the closed loop’s
initializer: every variable takes the value the previous solution had
one sampling time later, copied where the grids line up (which on a
uniform grid with the mesh rule is almost everywhere, the tail’s first
point landing exactly one sample past the horizon’s end), interpolated
in the small uncovered sliver, moves shifting as the step functions
they are. Past the end of a tailless horizon, states and controls take
their targets and derivatives zero. Values only, nothing solved, and
the loop sets the initial condition from the measurement.
Workflow: a forward simulation#
drto.dynamic_simulation turns the same declared model into an
integration: the declared control profiles are applied, the controls are
fixed at given profiles (or the values they hold), declared disturbances
are fixed at given constants or sequences, the objective is zero, and the
square system integrates forward with any NLP solver. The registry
records what was fixed and the report names it. This is the plant in the
closed loop below: the same declarations, simulated one step at a time.
From a function that builds the model rather than a model, the build, the discretization, and the transformation are one call:
plant = drto.dynamic_simulation(build, N=1, controls={"u": 0.3})
build follows the same contract the infinite-horizon workflow above
states, and the call completes its held inputs the same way.
Workflow: the closed loop#
drto.ideal_nmpc runs the receding-horizon loop the declarations
describe, in one call on the declared, discretized model — terminal
segment applied or not, before drto.dynamic_optimization:
history = drto.ideal_nmpc(
m,
steps=50,
initial_condition={"z": 0.2},
disturbances={"w": 0.05},
seed=0,
)
drto.plot_states(history)
drto.plot_controls(history)
The loop builds both sides of the control problem from the one model.
The input itself becomes the controller through
drto.dynamic_optimization (a dynamic_optimization mapping passes
options through). A clone becomes the plant: its controls are fixed at
the declared targets, drto.dynamic_simulation makes it square, and
everything past the first sampling time is removed, so the plant is the
one-sample integration the loop actually solves regardless of the
declared horizon. Ideal means the solve is treated as instantaneous —
measurement, solve, and implementation at the same instant. Each step
solves the controller at the current state, writes the first control
moves into the plant, fixes the plant’s disturbances at the step’s
realization, simulates one sample, and feeds the end state back as both
models’ initial condition.
initial_condition maps declared state names to the values written into
the initial-condition Params before the first step; omitted, the Params’
current values are the first state.
Each declared disturbance’s disturbances entry is either a sequence,
the per-step realization as given, or a number, the standard deviation of
independent zero-mean normal draws, reproducible through seed; a
disturbance with no entry is zero. The disturbance enters the plant only
— the controller solves at zero disturbance, the optimization mode’s
convention.
initialize picks the first solve’s initialization: "cold" (the
default) runs drto.cold_start_dynamic on controller and plant alike, a
mapping passing through as its options; "steady" runs
drto.initialize_steady_state on the input before the sides are built,
so both inherit the broadcast; False skips initialization. Every later
solve is warm-started with drto.warm_start_dynamic.
solver names the solver for every solve, "pounce" the default. Under
"pounce" or "ipopt", warm-started controller solves run with a warm
start recipe — warm_start_init_point=yes, mu_init=1e-6, and 1e-9
bound and multiplier pushes — and a warm_start mapping lays user
options over it, so warm_start={"mu_init": 1e-4} retunes one knob
without restating the rest. Under another solver the loop warm starts on
the shifted values alone and a given warm_start mapping passes to the
solves as is. The first solve runs at solver defaults. A solve that
fails stops the loop with an error naming the step.
tee=True streams every solve’s output as the loop runs and keeps it:
history.logs holds one (step, side, text) entry per solve in loop
order. The default is quiet, nothing streamed, nothing kept.
scale takes a drto.scale source, "point", "bounds", or a
mapping of unit names to magnitudes, and writes each side’s factors
with it once, after that side’s assembly and cold start; every solve
receives them, and the history lands in the model’s own units. The
default writes nothing and honors a scaling_factor suffix the caller
wrote.
The returned NmpcHistory holds the actual closed-loop trajectory —
times, each declared state’s values, the implemented moves, and the
disturbance realizations, under the declared names and targets — and
drto.plot_states and drto.plot_controls accept it in place of a
model, drawing the moves as the staircase they physically are.
Workflow: the steady-state branch#
The steady reduction and the dynamic transforms are sibling branches of the same declarations — the reduction applies to the declared model, before any dynamic transform:
drto.dynamic_to_steady_statecollapses the horizon to a single point: accumulations pinned at zero, time-indexed Blocks collapsed to their steady member, References re-pointed, Ports intact.drto.steady_state_simulationis the reduction plus held controls: a square equilibrium at given inputs (itscreate_usingform leaves the dynamic model untouched, anddrto.steady_state_simulation(build, ...)builds the model instead of taking one). The CSTR example computes its setpoint this way.drto.steady_state_optimizationis the reduction plus the declared economic cost: the RTO problem.drto.steady_state_optimization(build)builds the model instead of taking one.
How-to: scaling#
Badly scaled models (an energy holdup at 1e8 J next to mole fractions)
are the rule in process systems. drto.scale assigns a factor per
variable group from a source you choose, and a factor per large
constraint from the Jacobian at the model’s current point, so it runs
after an initializer has filled the values. source="point", the
default, reads the values themselves, right when the model sits near
its operating magnitudes, the initialized steady broadcast. "bounds"
reads declared operating limits, the controls in practice. A mapping
states the process’s magnitudes once per physical dimension, which
covers a quantity whose value says nothing, a duty sitting at its zero
target. drto.scaled_solve assigns the factors and solves:
drto.initialize_steady_state(m)
res = drto.scaled_solve(m) # values as the source
res = drto.scaled_solve(m, source={"J": 1e7, "W": 1e6}) # stated magnitudes
The factors reach the solver through the standard scaling_factor
suffix, and no second model is built: pounce and legacy ipopt read it
under nlp_scaling_method=user-scaling, and ipopt_v2’s NL writer
scales the problem as it writes the file. The model, its declarations,
and its Params never leave physical units. The loops take the same
assignment as an option: scale on drto.ideal_nmpc,
drto.approximate_nmpc_data, and drto.approximate_nmpc_closed_loop
forwards a source to drto.scale once per solved model, after its
cold start, and holds the factors for every solve. A hand-written suffix composes the same way:
write it and the solves receive it.
How-to: IDAES flowsheets#
drto’s transforms are built to leave a flowsheet as IDAES wrote it. Three idioms cover most models:
States as member subsets. An IDAES material holdup is one indexed Var over components, but only some members are true states — the water holdup of the saponification CSTR is fixed by the property package’s closure. Declare exactly the true states as slices:
drto.state(cv.material_holdup[:, "Liq", "NaOH"],
cv.material_holdup[:, "Liq", "EthylAcetate"],
cv.energy_holdup)
Each slice wraps as an attached Reference; the undeclared members stay algebraic, their balances close them pointwise, and the declared surface matches the true state dimension.
Controls on Ports. A feed flow lives on the inlet Port as a time-indexed Reference; declare it directly:
fin = m.fs.cstr.inlet.flow_vol
drto.control(cv.heat, fin, profile="piecewise_constant")
Time-indexed Blocks. Property and reaction blocks (Block(t)
families) ride through every transform: the steady reduction collapses
them to the surviving member, and the terminal segment replicates their
variables and equations onto the tail. No flattening, no rewriting of
the flowsheet.
The IDAES CSTR examples run all three on an unmodified
idaes.models.unit_models.CSTR.
Reports#
Every initializer returns a printable report of what it did — what was
set, what was solved, what was skipped and why — and every drto error
names the component and the declaration to fix. When something behaves
unexpectedly, look at drto.info(m) first, then the report.