The Klatt-Engell CSTR: the infinite-horizon problem

The Klatt-Engell CSTR: the infinite-horizon problem#

The NMPC problem of Lueken, Brandner & Lucia (2023, doi:10.1016/j.ifacol.2023.10.545) with the terminal cost replaced by drto’s terminal segment: one solve from an off-setpoint state, and a training point read off the converged factorization.

models/klatt_engell.py declares the reactor: four states, two controls, and a tracking stage cost over all six with Param weights. The steady solve below runs with the paper’s weights (the two concentrations at one, the rest at zero); after it writes the equilibrium into the targets, every weight is set to one, so the horizon problem tracks the computed equilibrium in every state and control and the tail integrand vanishes there.

from functools import partial

import pyomo.environ as pyo
from pyomo.contrib.solver.common.factory import SolverFactory
import pyomo_pounce  # registers the pounce solver with Pyomo

import drto
from models.klatt_engell import klatt_engell

m = klatt_engell(N=1, h=0.005, move_penalty=False)
drto.info(m)
drto registry
states: 4, controls: 2
horizont (ContinuousSet, 2 points)
statesc_a (free), c_b (free), T_R (free), T_K (free)
dynamicsdc_a[t] == F[t]*(c_in - c_a[t]) - k0_1*exp(- e1/(T_R[t] + 273.15))*c_a[t] - k0_3*exp(- e3/(T_R[t] + 273.15))*c_a[t]**2 + w_ca[t] for t in t
dynamicsdc_b[t] == - F[t]*c_b[t] + k0_1*exp(- e1/(T_R[t] + 273.15))*c_a[t] - k0_2*exp(- e2/(T_R[t] + 273.15))*c_b[t] + w_cb[t] for t in t
dynamicsdT_R[t] == F[t]*(t_in - T_R[t]) + k_w*a_r/(rho*cp_r*v_r)*(T_K[t] - T_R[t]) - (k0_1*exp(- e1/(T_R[t] + 273.15))*c_a[t]*dh1 + k0_2*exp(- e2/(T_R[t] + 273.15))*c_b[t]*dh2 + k0_3*exp(- e3/(T_R[t] + 273.15))*c_a[t]**2*dh3)/(rho*cp_r) + w_tr[t] for t in t
dynamicsdT_K[t] == (Q_dot[t] + k_w*a_r*(T_R[t] - T_K[t]))/(m_k*cp_k) + w_tk[t] for t in t
controlsF (piecewise_constant, free), Q_dot (piecewise_constant, free)
tracking stage costcost[t] == q_ca*(c_a[t] - ca_sp)**2 + q_cb*(c_b[t] - cb_sp)**2 + q_tr*(T_R[t] - tr_sp)**2 + q_tk*(T_K[t] - tk_sp)**2 + r_f*(F[t] - f_sp)**2 + r_q*(Q_dot[t] - q_sp)**2 for t in sorted(t)[:-1]
terminal costterm == q_ca*(c_a[0.005] - ca_sp)**2 + q_cb*(c_b[0.005] - cb_sp)**2 + q_tr*(T_R[0.005] - tr_sp)**2 + q_tk*(T_K[0.005] - tk_sp)**2
initial conditionsc_a[0.0] == ca_hat
initial conditionsc_b[0.0] == cb_hat
initial conditionsT_R[0.0] == tr_hat
initial conditionsT_K[0.0] == tk_hat
steady-state targetsca_sp (of c_a), cb_sp (of c_b), tr_sp (of T_R), tk_sp (of T_K)
steady-state control targetsf_sp (of F), q_sp (of Q_dot)
disturbancesw_ca (piecewise_constant), w_cb (piecewise_constant), w_tr (piecewise_constant), w_tk (piecewise_constant)
transformations (none)

The setpoint#

The paper’s setpoint pair is not an equilibrium of this reactor: k_1 and k_2 share their pre-exponential and activation energy, so holding c_A = 0.7 and c_B = 0.6 at steady state forces a temperature far above the 150 C bound, and a solve with both concentrations fixed converges to local infeasibility. The steady point is instead chosen by the same tracking cost: a clone is reduced, the controls left free, and the reduced stage cost minimized over the steady states within bounds.

Every steady-state target takes the solution’s values, the concentration setpoints included: the tracking cost then vanishes at an attainable equilibrium, which is the point the terminal segment pins. The paper’s 0.7 and 0.6 remain in the problem as the criterion that selected it.

sm = pyo.TransformationFactory("drto.steady_state_optimization").create_using(m)
print(drto.initialize_steady_state(sm))
res = SolverFactory("pounce").solve(sm, tee=True)
print("steady solve:", res.termination_condition.name)
print("steady cost:", pyo.value(sm.drto_objective))
print()

# each steady_state pairing names a component and its target Param; the
# reduced model's records line up by declaration order. The values are
# kept by target name, since the horizon model is built fresh below
reg, reg_s = drto.info(m), drto.info(sm)
setpoint = {}
for kind in ("steady_state", "steady_state_control"):
    for here, there in zip(reg.declarations(kind), reg_s.declarations(kind)):
        v = there["of"]
        value = pyo.value(next(iter(v.values())) if v.is_indexed() else v)
        setpoint[here["component"].local_name] = value
        print(f"{here['component'].name} = {value:.6g}")
pyomo-pounce initialize (fill -> repair -> block-solve)
  decisions held: 2
  values filled : 2
  projection    : optimal
  pyomo-pounce block_initialize
    decisions fixed   : 0
    system square     : True
    blocks solved     : 2 (1 by Newton 1x1, 1 subsystem solves)
    vars initialized  : 5
    left untouched    : 0 underdetermined, 0 overdetermined
********************************************************************************

                    ####    ###   /   # /#   #/  ####  #####
                    #   #  #   # /#   #/ ##  /  #      #
                    ####   #   #/ #   /  # #/#  #      ####
                    #      #   /  #  /#  # /##  #      #
                    #       ##/    #/#   #/  #   ####  #####

********************************************************************************
This program contains POUNCE, a pure-Rust interior-point optimization solver
for nonlinear, conic, and global problems (its NLP core is ported from Ipopt).
Released under the Eclipse Public License (EPL) — drop-in compatible with Ipopt.
         For more information visit https://github.com/jkitchin/pounce
********************************************************************************

This is POUNCE version 0.11.0, running with linear solver FERAL.

Reading C:\Users\Devin\AppData\Local\Temp\tmp23z_7wuu\unknowndws_irsb.nl...
Parsed 7 vars, 5 cons, jac_nnz=18, h_nnz=8 in 0.01s
Problem class: NLP. Selected solver: NLP filter line-search interior-point (pounce-nlp) [solver_selection=auto].

Number of nonzeros in equality constraint Jacobian...:       18
Number of nonzeros in inequality constraint Jacobian.:        0
Number of nonzeros in Lagrangian Hessian.............:        8

Total number of variables............................:        7
                     variables with only lower bounds:        0
                variables with lower and upper bounds:        6
                     variables with only upper bounds:        0
Total number of equality constraints.................:        5
Total number of inequality constraints...............:        0
        inequality constraints with only lower bounds:        0
   inequality constraints with lower and upper bounds:        0
        inequality constraints with only upper bounds:        0

iter      objective   inf_pr   inf_du lg(mu)    ||d|| lg(rg) alpha_du alpha_pr  ls
   0  1.5937952e+00 3.41e-13 5.40e-02   -1.0 0.00e+00      - 0.00e+00 0.00e+00   0
   1  1.5223143e+00 1.45e-01 1.68e-02   -1.0 1.32e+00      - 9.65e-01 1.00e+00f  1
   2  6.4083488e-01 2.30e+01 3.24e-01   -1.0 1.63e+01      - 4.76e-01 1.00e+00f  1
   3  7.6759535e-02 1.93e+01 1.49e+00   -1.0 1.06e+02      - 9.01e-01 1.00e+00h  1
   4  4.7624828e-01 8.44e+00 1.97e+00   -1.0 8.95e+00   -4.0 7.89e-01 1.00e+00h  1
   5  1.2500415e-02 1.49e+01 7.82e-01   -1.0 5.54e+01      - 1.00e+00 1.00e+00h  1
   6 -6.7244642e-02 6.44e+00 1.99e-01   -1.0 5.33e+01      - 1.00e+00 1.00e+00h  1
   7  4.6376099e-03 2.30e+00 3.34e-02   -1.0 7.80e+02      - 1.00e+00 1.00e+00h  1
   8 -6.4454072e-04 1.31e-01 1.09e-02   -1.7 8.85e+01      - 9.83e-01 1.00e+00h  1
   9 -6.1648133e-04 5.47e-02 1.85e-04   -2.5 1.15e+02      - 1.00e+00 1.00e+00h  1
iter      objective   inf_pr   inf_du lg(mu)    ||d|| lg(rg) alpha_du alpha_pr  ls
  10 -3.0274952e-05 3.45e-03 1.13e-05   -3.8 1.75e+01      - 1.00e+00 1.00e+00h  1
  11  8.3692460e-06 8.27e-05 3.35e-07   -5.7 4.06e+00      - 1.00e+00 1.00e+00h  1
  12  8.1002096e-06 4.56e-04 2.93e-05   -5.7 3.24e+02      - 1.00e+00 1.00e+00H  1
  13  8.1291877e-06 3.25e-05 3.83e-07   -5.7 3.06e+01      - 1.00e+00 1.00e+00H  1
  14  8.0461194e-06 5.23e-02 1.86e-06   -8.6 8.64e+01      - 8.81e-01 1.00e+00h  1
  15  7.6575166e-06 1.18e+00 3.61e-05   -8.6 4.29e+02      - 8.72e-01 1.00e+00h  1
  16  7.6014466e-06 2.04e-01 5.25e-06   -8.6 1.49e+02      - 1.00e+00 8.69e-01h  1
  17  7.6068348e-06 3.05e-05 8.33e-09   -8.6 1.98e+00      - 1.00e+00 1.00e+00h  1
  18  7.6067523e-06 7.03e-08 1.54e-12   -8.6 1.25e-01      - 1.00e+00 1.00e+00h  1
  19  7.6051513e-06 2.48e-05 6.25e-10   -9.0 2.33e+00      - 1.00e+00 1.00e+00h  1
iter      objective   inf_pr   inf_du lg(mu)    ||d|| lg(rg) alpha_du alpha_pr  ls
  20  7.6051526e-06 8.28e-12 4.83e-16   -9.0 7.03e-04      - 1.00e+00 1.00e+00h  1


Number of Iterations....: 20

                                   (scaled)                 (unscaled)
Objective...............:   7.6051526281730388e-06    7.6051526281730388e-06
Dual infeasibility......:   4.8299345172901641e-16    4.8299345172901641e-16
Constraint violation....:   2.0321522242738865e-12    8.2849282989627682e-12
Variable bound violation:   0.0000000000000000e+00    0.0000000000000000e+00
Complementarity.........:   9.0909092452500373e-10    9.0909092452500373e-10
Overall NLP error.......:   9.0909092452500373e-10    9.0909092452500373e-10


Number of objective function evaluations             = 25
Number of objective gradient evaluations             = 1
Number of equality constraint evaluations            = 25
Number of inequality constraint evaluations          = 25
Number of equality constraint Jacobian evaluations   = 21
Number of inequality constraint Jacobian evaluations = 21
Number of Lagrangian Hessian evaluations             = 21
Total seconds in POUNCE                              = 0.007

EXIT: Optimal Solution Found.

POUNCE 0.11.0: Optimal Solution Found.
Status: Solve_Succeeded
pounce: wrote C:\Users\Devin\AppData\Local\Temp\tmp23z_7wuu\unknowndws_irsb.sol
steady solve: convergenceCriteriaSatisfied
steady cost: 7.605152628173039e-06

ca_sp = 0.701575
cb_sp = 0.597736
tr_sp = 140.82
tk_sp = 139.995
f_sp = 12.9428
q_sp = -715.563

Assemble the horizon#

drto.dynamic_optimization takes the model statement itself. It calls klatt_engell, discretizes the declared grid at one finite element per step, appends the terminal segment, and assembles the optimization, so the four calls this workflow used to spell out are one. The equilibrium found above and the do-mpc example’s starting state are written into the model it returns, every tracking term carries unit weight, and the cold start runs on it. drto.dynamic_optimization declares the initial-condition Params as pounce sensitivity parameters.

m = drto.dynamic_optimization(
    partial(klatt_engell, move_penalty=False),   # the page's own option
    N=1,
    h=0.005,
    ncp=2,                                   # do-mpc's mesh
    infinite_horizon={"nfe": 3, "ncp": 5},
)

for name, value in setpoint.items():         # the equilibrium from above
    m.component(name).set_value(value)
for w in (m.q_ca, m.q_cb, m.q_tr, m.q_tk, m.r_f, m.r_q):
    w.set_value(1.0)

# the initial state the horizon starts from (the do-mpc example's)
m.ca_hat.set_value(0.75)     # mol/L
m.cb_hat.set_value(0.75)     # mol/L
m.tr_hat.set_value(120)      # C
m.tk_hat.set_value(120)      # C

drto.cold_start_dynamic(m, profile="exponential", time_constant=.3)
drto.info(m)
drto registry
states: 4, controls: 2
horizont (ContinuousSet, 3 points)
statesc_a (free), c_b (free), T_R (free), T_K (free)
dynamicsdc_a[t] == F[t]*(c_in - c_a[t]) - k0_1*exp(- e1/(T_R[t] + 273.15))*c_a[t] - k0_3*exp(- e3/(T_R[t] + 273.15))*c_a[t]**2 + w_ca[t] for t in t
dynamicsdc_b[t] == - F[t]*c_b[t] + k0_1*exp(- e1/(T_R[t] + 273.15))*c_a[t] - k0_2*exp(- e2/(T_R[t] + 273.15))*c_b[t] + w_cb[t] for t in t
dynamicsdT_R[t] == F[t]*(t_in - T_R[t]) + k_w*a_r/(rho*cp_r*v_r)*(T_K[t] - T_R[t]) - (k0_1*exp(- e1/(T_R[t] + 273.15))*c_a[t]*dh1 + k0_2*exp(- e2/(T_R[t] + 273.15))*c_b[t]*dh2 + k0_3*exp(- e3/(T_R[t] + 273.15))*c_a[t]**2*dh3)/(rho*cp_r) + w_tr[t] for t in t
dynamicsdT_K[t] == (Q_dot[t] + k_w*a_r*(T_R[t] - T_K[t]))/(m_k*cp_k) + w_tk[t] for t in t
controlsF (piecewise_constant, free), Q_dot (piecewise_constant, free)
tracking stage costcost[t] == q_ca*(c_a[t] - ca_sp)**2 + q_cb*(c_b[t] - cb_sp)**2 + q_tr*(T_R[t] - tr_sp)**2 + q_tk*(T_K[t] - tk_sp)**2 + r_f*(F[t] - f_sp)**2 + r_q*(Q_dot[t] - q_sp)**2 for t in sorted(t)[:-1]
terminal costterm == q_ca*(c_a[0.005] - ca_sp)**2 + q_cb*(c_b[0.005] - cb_sp)**2 + q_tr*(T_R[0.005] - tr_sp)**2 + q_tk*(T_K[0.005] - tk_sp)**2
initial conditionsc_a[0.0] == ca_hat
initial conditionsc_b[0.0] == cb_hat
initial conditionsT_R[0.0] == tr_hat
initial conditionsT_K[0.0] == tk_hat
steady-state targetsca_sp (of c_a), cb_sp (of c_b), tr_sp (of T_R), tk_sp (of T_K)
steady-state control targetsf_sp (of F), q_sp (of Q_dot)
disturbancesw_ca (piecewise_constant), w_cb (piecewise_constant), w_tr (piecewise_constant), w_tk (piecewise_constant)
transformations
  1. drto.infinite_horizon: segment=3 elements x 5 Legendre points, beta=1.2, gamma=3.12765494, profile=collocation, horizon=kept, infinite tail appended, disturbances=w_ca fixed at 0.0, w_cb fixed at 0.0, w_tr fixed at 0.0, w_tk fixed at 0.0, terminal_cost=terminal deactivated (the tail owns it), terminal=soft pin z(tau=1)=z_s on 4 states, mu=1000.0
  2. drto.parameterize: profiles=F (piecewise_constant), Q_dot (piecewise_constant), w_ca (piecewise_constant), w_cb (piecewise_constant), w_tr (piecewise_constant), w_tk (piecewise_constant)
  3. drto.build_objective: objective=sum of 32 weighted cost terms
  4. drto.dynamic_optimization: horizon=kept, tracking_weight=(one stage cost declared), disturbances=w_ca=0, w_cb=0, w_tr=0, w_tk=0, sensitivity=4 initial-condition Params declared

Solve#

From the do-mpc example’s initial state, written into the _hat Params at assembly, toward the paper’s setpoints c_A = 0.7, c_B = 0.6 mol/L.

res = SolverFactory("pounce").solve(m, tee=True)
print(res.termination_condition.name)
print("objective", pyo.value(m.drto_objective))
********************************************************************************

                    ####    ###   /   # /#   #/  ####  #####
                    #   #  #   # /#   #/ ##  /  #      #
                    ####   #   #/ #   /  # #/#  #      ####
                    #      #   /  #  /#  # /##  #      #
                    #       ##/    #/#   #/  #   ####  #####

********************************************************************************
This program contains POUNCE, a pure-Rust interior-point optimization solver
for nonlinear, conic, and global problems (its NLP core is ported from Ipopt).
Released under the Eclipse Public License (EPL) — drop-in compatible with Ipopt.
         For more information visit https://github.com/jkitchin/pounce
********************************************************************************

This is POUNCE version 0.11.0, running with linear solver FERAL.

Number of nonzeros in equality constraint Jacobian...:      909
Number of nonzeros in inequality constraint Jacobian.:       40
Number of nonzeros in Lagrangian Hessian.............:      198

Total number of variables............................:      201
                     variables with only lower bounds:        8
                variables with lower and upper bounds:      120
                     variables with only upper bounds:        0
Total number of equality constraints.................:      165
Total number of inequality constraints...............:        8
        inequality constraints with only lower bounds:        0
   inequality constraints with lower and upper bounds:        8
        inequality constraints with only upper bounds:        0

iter      objective   inf_pr   inf_du lg(mu)    ||d|| lg(rg) alpha_du alpha_pr  ls
   0  9.3523418e+02 4.14e+03 1.01e+02   -1.0 0.00e+00      - 0.00e+00 0.00e+00   0
   1  8.7398172e+02 4.00e+03 9.85e+01   -1.0 4.26e+03      - 1.49e-02 3.21e-02h  1
   2  1.1778223e+04 3.67e+02 1.10e+02   -1.0 4.00e+03      - 4.90e-02 1.00e+00h  1
   3  1.0886344e+04 3.12e+02 7.87e+01   -1.0 3.73e+02      - 5.53e-01 1.48e-01f  1
   4  9.9405280e+03 2.01e+02 4.89e+01   -1.0 3.17e+02      - 6.08e-01 3.56e-01f  1
   5  8.8452141e+03 4.13e+00 1.78e+01   -1.0 2.15e+02      - 9.43e-01 1.00e+00f  1
   6  8.8909464e+03 3.23e-01 1.98e-01   -1.0 2.46e+01      - 9.98e-01 1.00e+00h  1
   7  8.8859825e+03 5.43e-04 3.10e-02   -1.7 1.30e+00      - 1.00e+00 1.00e+00h  1
   8  8.8846366e+03 9.49e-05 9.65e-03   -2.5 1.91e-01      - 1.00e+00 1.00e+00f  1
   9  8.8844070e+03 1.38e-05 1.02e-03   -3.8 3.11e-02      - 1.00e+00 1.00e+00f  1
iter      objective   inf_pr   inf_du lg(mu)    ||d|| lg(rg) alpha_du alpha_pr  ls
  10  8.8843950e+03 2.26e-07 1.33e-05   -5.7 3.90e-03      - 1.00e+00 1.00e+00h  1
  11  8.8843949e+03 5.88e-11 3.31e-09   -8.6 6.29e-05      - 1.00e+00 1.00e+00h  1


Number of Iterations....: 11

                                   (scaled)                 (unscaled)
Objective...............:   8.8843948948013679e+02    8.8843948948013676e+03
Dual infeasibility......:   3.3109783520705633e-09    3.3109783520705633e-08
Constraint violation....:   2.0176488827219406e-11    5.8804516811505891e-11
Variable bound violation:   0.0000000000000000e+00    0.0000000000000000e+00
Complementarity.........:   5.5600286731909703e-09    5.5600286731909702e-08
Overall NLP error.......:   5.5600286731909703e-09    5.5600286731909702e-08


Number of objective function evaluations             = 12
Number of objective gradient evaluations             = 12
Number of equality constraint evaluations            = 12
Number of inequality constraint evaluations          = 12
Number of equality constraint Jacobian evaluations   = 12
Number of inequality constraint Jacobian evaluations = 12
Number of Lagrangian Hessian evaluations             = 12
Total seconds in POUNCE                              = 0.015

EXIT: Optimal Solution Found.

POUNCE 0.11.0: Optimal Solution Found.
convergenceCriteriaSatisfied
objective 8884.394894801368

One training point#

The label is the first control action, and the objective is the cost-to-go from the _hat state; the gradient half is the action’s derivative with respect to the initial state, read from the solve’s converged factorization through drto.advanced_step_controller(m, gradient=True).

t0 = m.t.first()
grads = drto.advanced_step_controller(m, gradient=True)
for u in (m.F, m.Q_dot):
    print(f"{u.name}[0] = {pyo.value(u[t0]):.6g}")
    for p, g in grads[u].items():
        print(f"  d{u.name}[0]/d{p.name} = {g[u[t0], p]:.6g}")
F[0] = 33.9561
  dF[0]/dca_hat = -0.772549
  dF[0]/dcb_hat = -1.80919
  dF[0]/dtr_hat = -1.12145
  dF[0]/dtk_hat = -0.200229
Q_dot[0] = -715.505
  dQ_dot[0]/dca_hat = -0.00161395
  dQ_dot[0]/dcb_hat = -0.000469408
  dQ_dot[0]/dtr_hat = -0.000949226
  dQ_dot[0]/dtk_hat = -0.000751104
axes = drto.plot_states(m)
../_images/c095c35e8b8ac870f46ee28b5ceeb53685f28bf870c2eab44e1cbfd6d436833d.png
axes = drto.plot_controls(m)
../_images/3d00ee4041a4632c56c2bb667a7d36d2661674d9a54581c9eaa497f561dea66f.png
axes = drto.plot_stage_cost(m)
../_images/a3f726d440df825aa4aa02a1eca56f5cbf3cc139e1c16ec6b832e868412b0078.png