Skip to content

Commit 5f33573

Browse files
committed
docs: dynamic optimization docs
1 parent 5988c81 commit 5f33573

File tree

6 files changed

+180
-23
lines changed

6 files changed

+180
-23
lines changed
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
# Solving Dynamic Optimization Problems
2+
Systems in ModelingToolkit.jl can be directly converted to dynamic optimization or optimal control problems. In such systems, one has one or more input variables that are externally controlled to control the dynamics of the system. A dynamic optimization solves for the optimal time trajectory of the input variables in order to maximize or minimize a desired objective function. For example, a car driver might like to know how to step on the accelerator if the goal is to finish a race while using the least gas.
3+
4+
To begin, let us take a rocket launch example. The input variable here is the thrust exerted by the engine. The rocket state is described by its current height and velocity.
5+
```julia
6+
using ModelingToolkit
7+
t = ModelingToolkit.t_nounits
8+
D = ModelingToolkit.D_nounits
9+
10+
@parameters h_c m₀ h₀ g₀ D_c c Tₘ m_c
11+
@variables begin
12+
h(..)
13+
v(..)
14+
m(..) [bounds = (m_c, 1)]
15+
T(..) [input = true, bounds = (0, Tₘ)]
16+
end
17+
18+
drag(h, v) = D_c * v^2 * exp(-h_c * (h - h₀) / h₀)
19+
gravity(h) = g₀ * (h₀ / h)
20+
21+
eqs = [D(h(t)) ~ v(t),
22+
D(v(t)) ~ (T(t) - drag(h(t), v(t))) / m(t) - gravity(h(t)),
23+
D(m(t)) ~ -T(t) / c]
24+
25+
(ts, te) = (0.0, 0.2)
26+
costs = [-h(te)]
27+
cons = [T(te) ~ 0, m(te) ~ m_c]
28+
29+
@named rocket = ODESystem(eqs, t; costs, constraints = cons)
30+
rocket, input_idxs = structural_simplify(rocket, ([T(t)], []))
31+
32+
u0map = [h(t) => h₀, m(t) => m₀, v(t) => 0]
33+
pmap = [
34+
g₀ => 1, m₀ => 1.0, h_c => 500, c => 0.5 * (g₀ * h₀), D_c => 0.5 * 620 * m₀ / g₀,
35+
Tₘ => 3.5 * g₀ * m₀, T(t) => 0.0, h₀ => 1, m_c => 0.6]
36+
```
37+
What we would like to optimize here is the final height of the rocket. We do this by providing a vector of expressions corresponding to the costs. By default, the sense of the optimization is to minimize the provided cost. So to maximize the rocket height at the final time, we write `-h(te)` as the cost.
38+
39+
Now we can construct a problem and solve it. Let us use JuMP as our backend here.
40+
```julia
41+
jprob = JuMPDynamicOptProblem(rocket, u0map, (ts, te), pmap; dt = 0.001, cse = false)
42+
jsol = solve(jprob, JuMPCollocation(Ipopt.Optimizer, constructRadauIIA5()))
43+
```
44+
45+
Let's plot our final solution and the controller here:
46+
```julia
47+
```
48+
49+
###### Free final time problems
50+
There are additionally a class of dynamic optimization problems where we would like to know how to control our system to achieve something in the least time. Such problems are called free final time problems, since the final time is unknown. To model these problems in ModelingToolkit, we declare the final time as a parameter.
51+
52+
```julia
53+
@variables x(..) v(..)
54+
@variables u(..) [bounds = (-1.0, 1.0), input = true]
55+
@parameters tf
56+
57+
constr = [v(tf) ~ 0, x(tf) ~ 0]
58+
cost = [tf] # Minimize time
59+
60+
@named block = ODESystem(
61+
[D(x(t)) ~ v(t), D(v(t)) ~ u(t)], t; costs = cost, constraints = constr)
62+
63+
block, input_idxs = structural_simplify(block, ([u(t)], []))
64+
65+
u0map = [x(t) => 1.0, v(t) => 0.0]
66+
tspan = (0.0, tf)
67+
parammap = [u(t) => 0.0, tf => 1.0]
68+
```
69+
70+
Please note that, at the moment, free final time problems cannot support constraints defined at definite time values, like `x(3) ~ 2`.
71+
72+
Let's plot our final solution and the controller for the block:
73+
```julia
74+
```
75+
76+
### Solvers
77+
Currently 4 backends are exposed for solving dynamic optimization problems using collocation: JuMP, InfiniteOpt, CasADi, and Pyomo.
78+
79+
Please note that there are differences in how to construct the collocation solver for the different cases. For example, the Python based ones, CasADi and Pyomo, expect the solver to be passed in as a string (CasADi and Pyomo come pre-loaded with certain solvers, but other solvers may need to be manually installed using `pip` or `conda`), while JuMP/InfiniteOpt expect the optimizer object to be passed in directly:
80+
```
81+
JuMPCollocation(Ipopt.Optimizer, constructRK4())
82+
CasADiCollocation("ipopt", constructRK4())
83+
```
84+
85+
**JuMP** and **CasADi** collocation require an ODE tableau to be passed in. These can be constructed by calling the `constructX()` functions from DiffEqDevTools. If none is passed in, both solvers will default to using Radau second-order with five collocation points.
86+
87+
**Pyomo** and **InfiniteOpt** each have their own built-in collocation methods.
88+
1. **InfiniteOpt**: The list of InfiniteOpt collocation methods can be found [in the table on this page](https://infiniteopt.github.io/InfiniteOpt.jl/stable/guide/derivative/). If none is passed in, the solver defaults to `FiniteDifference(Backward())`, which is effectively implicit Euler.
89+
2. **Pyomo**: The list of Pyomo collocation methods can be found [here](). If none is passed in, the solver defaults to a `LagrangeRadau(3)`.
90+
91+
```@docs; canonical = false
92+
JuMPCollocation
93+
InfiniteOptCollocation
94+
CasADiCollocation
95+
PyomoCollocation
96+
solve(::AbstractDynamicOptProblem)
97+
```
98+
99+
### Problem constructors
100+
```@docs; canonical = false
101+
JuMPDynamicOptProblem
102+
InfiniteOptDynamicOptProblem
103+
CasADiDynamicOptProblem
104+
PyomoDynamicOptProblem
105+
```

ext/MTKCasADiDynamicOptExt.jl

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,20 @@ function MTK.lowered_var(m::CasADiModel, uv, i, t)
120120
t isa Union{Num, Symbolics.Symbolic} ? X.u[i, :] : X(t)[i]
121121
end
122122

123-
MTK.lowered_integral(model::CasADiModel, expr, args...) = model.tₛ * (model.U.t[2] - model.U.t[1]) * sum(expr)
123+
function MTK.lowered_integral(model::CasADiModel, expr, lo, hi)
124+
total = MX(0)
125+
dt = model.U.t[2] - model.U.t[1]
126+
for (i, t) in enumerate(model.U.t)
127+
if lo < t < hi
128+
Δt = min(dt, t - lo)
129+
total += (0.5*Δt*(expr[i] + expr[i-1]))
130+
elseif t >= hi && (t - dt < hi)
131+
Δt = hi - t + dt
132+
total += (0.5*Δt*(expr[i] + expr[i-1]))
133+
end
134+
end
135+
model.tₛ * total
136+
end
124137

125138
function add_solve_constraints!(prob::CasADiDynamicOptProblem, tableau)
126139
@unpack A, α, c = tableau
@@ -210,6 +223,7 @@ function MTK.get_t_values(model::CasADiModel)
210223
value_getter = MTK.successful_solve(model) ? CasADi.debug_value : CasADi.value
211224
ts = value_getter(model.solver_opti, model.tₛ) .* model.U.t
212225
end
226+
MTK.objective_value(model::CasADiModel) = CasADi.pyconvert(Float64, model.solver_opti.py.value(model.solver_opti.py.f))
213227

214228
function MTK.successful_solve(m::CasADiModel)
215229
isnothing(m.solver_opti) && return false

ext/MTKInfiniteOptExt.jl

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ end
9191
MTK.lowered_integral(model::InfiniteOptModel, expr, lo, hi) = model.tₛ * InfiniteOpt.(expr, model.model[:t], lo, hi)
9292
MTK.lowered_derivative(model::InfiniteOptModel, i) = (model.U[i], model.model[:t])
9393

94-
function MTK.process_integral_bounds(model, integral_span, tspan)
94+
function MTK.process_integral_bounds(model::InfiniteOptModel, integral_span, tspan)
9595
if MTK.is_free_final(model) && isequal(integral_span, tspan)
9696
integral_span = (0, 1)
9797
elseif MTK.is_free_final(model)
@@ -213,6 +213,7 @@ function MTK.get_U_values(m::InfiniteModel)
213213
U_vals = [[U_vals[i][j] for i in 1:length(U_vals)] for j in 1:nt]
214214
end
215215
MTK.get_t_values(m::InfiniteModel) = value(m[:tₛ]) * supports(m[:t])
216+
MTK.objective_value(m::InfiniteModel) = InfiniteOpt.objective_value(m)
216217

217218
function MTK.successful_solve(model::InfiniteModel)
218219
tstatus = termination_status(model)

ext/MTKPyomoDynamicOptExt.jl

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,13 @@ using Setfield
88
const MTK = ModelingToolkit
99

1010
SPECIAL_FUNCTIONS_DICT = Dict([acos => Pyomo.py_acos,
11-
log1p => Pyomo.py_log1p,
1211
acosh => Pyomo.py_acosh,
13-
log2 => Pyomo.py_log2,
1412
asin => Pyomo.py_asin,
1513
tan => Pyomo.py_tan,
1614
atanh => Pyomo.py_atanh,
1715
cos => Pyomo.py_cos,
1816
log => Pyomo.py_log,
1917
sin => Pyomo.py_sin,
20-
log10 => Pyomo.py_log10,
2118
sqrt => Pyomo.py_sqrt,
2219
exp => Pyomo.py_exp])
2320

@@ -36,10 +33,21 @@ struct PyomoDynamicOptModel
3633
function PyomoDynamicOptModel(model, U, V, tₛ, is_free_final)
3734
@variables MODEL_SYM::Symbolics.symstruct(ConcreteModel) T_SYM DUMMY_SYM
3835
model.dU = dae.DerivativeVar(U, wrt = model.t, initialize = 0)
36+
#add_time_equation!(model, MODEL_SYM, T_SYM)
3937
new(model, U, V, tₛ, is_free_final, nothing, PyomoVar(model.dU), MODEL_SYM, T_SYM, DUMMY_SYM)
4038
end
4139
end
4240

41+
function add_time_equation!(model::ConcreteModel, model_sym, t_sym)
42+
model.dtime = dae.DerivativeVar(model.time)
43+
44+
mdt = Symbolics.value(pysym_getproperty(model_sym, :dtime))
45+
mts = Symbolics.value(pysym_getproperty(model_sym, :tₛ))
46+
expr = mdt[t_sym] - mts == 0
47+
f = Pyomo.pyfunc(eval(Symbolics.build_function(expr, model_sym, t_sym)))
48+
model.time_eq = pyomo.Constraint(model.t, rule = f)
49+
end
50+
4351
struct PyomoDynamicOptProblem{uType, tType, isinplace, P, F, K} <:
4452
AbstractDynamicOptProblem{uType, tType, isinplace}
4553
f::F
@@ -72,6 +80,7 @@ MTK.generate_internal_model(m::Type{PyomoDynamicOptModel}) = ConcreteModel(pyomo
7280
function MTK.generate_time_variable!(m::ConcreteModel, tspan, tsteps)
7381
m.steps = length(tsteps)
7482
m.t = dae.ContinuousSet(initialize = tsteps, bounds = tspan)
83+
m.time = pyomo.Var(m.t)
7584
end
7685

7786
function MTK.generate_state_variable!(m::ConcreteModel, u0, ns, ts)
@@ -116,7 +125,6 @@ end
116125

117126
function MTK.set_objective!(pmodel::PyomoDynamicOptModel, expr)
118127
@unpack model, model_sym, t_sym, dummy_sym = pmodel
119-
@show expr
120128
expr = Symbolics.substitute(expr, SPECIAL_FUNCTIONS_DICT)
121129
if occursin(Symbolics.unwrap(t_sym), expr)
122130
f = eval(Symbolics.build_function(expr, model_sym, t_sym))
@@ -134,15 +142,24 @@ function MTK.add_initial_constraints!(model::PyomoDynamicOptModel, u0, u0_idxs,
134142
end
135143

136144
function MTK.lowered_integral(m::PyomoDynamicOptModel, arg, lo, hi)
137-
@unpack model, model_sym, t_sym = m
138-
arg_f = eval(Symbolics.build_function(arg, model_sym, t_sym))
139-
int_sym = Symbol(:int, hash(arg))
140-
setproperty!(model, int_sym, dae.Integral(model.t, wrt = model.t, rule=Pyomo.pyfunc(arg_f)))
141-
PyomoVar(model.tₛ * model.int_sym)
145+
@unpack model, model_sym, t_sym, dummy_sym = m
146+
total = 0
147+
dt = Pyomo.pyconvert(Float64, (model.t.at(-1) - model.t.at(1))/(model.steps - 1))
148+
f = Symbolics.build_function(arg, model_sym, t_sym, expression = false)
149+
for (i, t) in enumerate(model.t)
150+
if Bool(lo < t) && Bool(t < hi)
151+
t_p = model.t.at(i-1)
152+
Δt = min(t - lo, t - t_p)
153+
total += 0.5*Δt*(f(model, t) + f(model, t_p))
154+
elseif Bool(t >= hi) && Bool(t - dt < hi)
155+
t_p = model.t.at(i-1)
156+
Δt = hi - t + dt
157+
total += 0.5*Δt*(f(model, t) + f(model, t_p))
158+
end
159+
end
160+
PyomoVar(model.tₛ * total)
142161
end
143162

144-
MTK.process_integral_bounds(model, integral_span, tspan) = integral_span
145-
146163
function MTK.lowered_derivative(m::PyomoDynamicOptModel, i)
147164
mdU = Symbolics.value(pysym_getproperty(m.model_sym, :dU))
148165
Symbolics.unwrap(mdU[i, m.t_sym])
@@ -167,6 +184,7 @@ function MTK.prepare_and_optimize!(prob::PyomoDynamicOptProblem, collocation; ve
167184
discretizer = TransformationFactory(dm)
168185
ncp = Pyomo.is_finite_difference(dm) ? 1 : dm.np
169186
discretizer.apply_to(solver_m, wrt = solver_m.t, nfe = solver_m.steps - 1, scheme = Pyomo.scheme_string(dm))
187+
170188
solver = SolverFactory(string(collocation.solver))
171189
results = solver.solve(solver_m, tee = true)
172190
PyomoOutput(results, solver_m)
@@ -190,10 +208,11 @@ function MTK.get_t_values(output::PyomoOutput)
190208
Pyomo.pyconvert(Float64, pyomo.value(m.tₛ)) * [Pyomo.pyconvert(Float64, t) for t in m.t]
191209
end
192210

211+
MTK.objective_value(output::PyomoOutput) = Pyomo.pyconvert(Float64, pyomo.value(output.model.obj))
212+
193213
function MTK.successful_solve(output::PyomoOutput)
194214
r = output.result
195215
ss = r.solver.status
196-
Main.xx[] = ss
197216
tc = r.solver.termination_condition
198217
if Bool(ss == opt.SolverStatus.ok) && (Bool(tc == opt.TerminationCondition.optimal) || Bool(tc == opt.TerminationCondition.locallyOptimal))
199218
return true

src/systems/optimal_control_interface.jl

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,18 @@ function substitute_integral(model, exprs, tspan)
331331
exprs = map(c -> Symbolics.substitute(c, intmap), value.(exprs))
332332
end
333333

334+
function process_integral_bounds(model, integral_span, tspan)
335+
if is_free_final(model) && isequal(integral_span, tspan)
336+
integral_span = (0, 1)
337+
elseif is_free_final(model)
338+
error("Free final time problems cannot handle partial timespans.")
339+
else
340+
(lo, hi) = integral_span
341+
(lo < tspan[1] || hi > tspan[2]) && error("Integral bounds are beyond the timespan.")
342+
integral_span
343+
end
344+
end
345+
334346
"""Substitute variables like x(1.5), x(t), etc. with the corresponding model variables."""
335347
function substitute_model_vars(model, sys, exprs, tspan)
336348
x_ops = [operation(unwrap(st)) for st in unknowns(sys)]
@@ -405,6 +417,7 @@ function add_equational_constraints!(model, sys, pmap, tspan)
405417
end
406418

407419
function set_objective! end
420+
objective_value(sol::DynamicOptSolution) = objective_value(sol.model)
408421

409422
function substitute_differentials(model, sys, eqs)
410423
t = get_iv(sys)

test/extensions/dynamic_optimization.jl

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ const M = ModelingToolkit
4747
@test (csol2.sol.u, osol2.u, rtol = 0.001)
4848
pprob = PyomoDynamicOptProblem(sys, u0map, tspan, parammap, dt = 0.01)
4949
psol = solve(pprob, PyomoCollocation("ipopt", BackwardEuler()))
50-
@test all([(psol.sol(t), osol2(t), rtol = 1e-3) for t in 0.:0.01:1.])
50+
@test all([(psol.sol(t), osol2(t), rtol = 1e-2) for t in 0.:0.01:1.])
5151

5252
# With a constraint
5353
u0map = Pair[]
@@ -111,9 +111,9 @@ function is_bangbang(input_sol, lbounds, ubounds, rtol = 1e-4)
111111
end
112112

113113
function ctrl_to_spline(inputsol, splineType)
114-
us = reduce(vcat, inputsol.u)
115-
ts = reduce(vcat, inputsol.t)
116-
splineType(us, ts)
114+
us = reduce(vcat, inputsol.u)
115+
ts = reduce(vcat, inputsol.t)
116+
splineType(us, ts)
117117
end
118118

119119
@testset "Linear systems" begin
@@ -163,7 +163,8 @@ end
163163
@test is_bangbang(psol.input_sol, [-1.0], [1.0])
164164
@test (psol.sol.u[end][2], 0.25, rtol = 1e-3)
165165

166-
osol = solve(oprob, ImplicitEuler(); dt = 0.01, adaptive = false)
166+
spline = ctrl_to_spline(isol.input_sol, ConstantInterpolation)
167+
oprob = ODEProblem(block_ode, u0map, tspan, [u_interp => spline])
167168
@test (isol.sol.u, osol.u, rtol = 0.05)
168169
@test all([(psol.sol(t), osol(t), rtol = 0.05) for t in 0.:0.01:1.])
169170

@@ -250,7 +251,7 @@ end
250251
@test isol.sol[h(t)][end] > 1.012
251252

252253
pprob = PyomoDynamicOptProblem(rocket, u0map, (ts, te), pmap; dt = 0.001, cse = false)
253-
psol = solve(pprob, PyomoCollocation("ipopt", MidpointEuler()))
254+
psol = solve(pprob, PyomoCollocation("ipopt", LagrangeRadau(4)))
254255
@test psol.sol.u[end][1] > 1.012
255256

256257
# Test solution
@@ -273,7 +274,7 @@ end
273274
interpmap2 = Dict(T_interp => ctrl_to_spline(psol.input_sol, CubicSpline))
274275
oprob2 = ODEProblem(rocket_ode, u0map, (ts, te), merge(Dict(pmap), interpmap2))
275276
osol2 = solve(oprob2, RadauIIA5(); adaptive = false, dt = 0.001)
276-
@test (psol.sol.u, osol2.u, rtol = 0.01)
277+
@test all([(psol.sol(t), osol2(t), rtol = 0.01) for t in 0:0.001:0.2])
277278
end
278279

279280
@testset "Free final time problems" begin
@@ -294,18 +295,22 @@ end
294295
jprob = JuMPDynamicOptProblem(rocket, u0map, (0, tf), pmap; steps = 201)
295296
jsol = solve(jprob, JuMPCollocation(Ipopt.Optimizer, constructTsitouras5()))
296297
@test isapprox(jsol.sol.t[end], 10.0, rtol = 1e-3)
298+
@test (M.objective_value(jsol), -92.75, atol = 0.25)
297299

298300
cprob = CasADiDynamicOptProblem(rocket, u0map, (0, tf), pmap; steps = 201)
299301
csol = solve(cprob, CasADiCollocation("ipopt", constructTsitouras5()))
300302
@test isapprox(csol.sol.t[end], 10.0, rtol = 1e-3)
303+
@test (M.objective_value(csol), -92.75, atol = 0.25)
301304

302305
iprob = InfiniteOptDynamicOptProblem(rocket, u0map, (0, tf), pmap; steps = 200)
303306
isol = solve(iprob, InfiniteOptCollocation(Ipopt.Optimizer))
304307
@test isapprox(isol.sol.t[end], 10.0, rtol = 1e-3)
308+
@test (M.objective_value(isol), -92.75, atol = 0.25)
305309

306310
pprob = PyomoDynamicOptProblem(rocket, u0map, (0, tf), pmap; steps = 201)
307-
psol = solve(pprob, PyomoCollocation("ipopt"))
311+
psol = solve(pprob, PyomoCollocation("ipopt", BackwardEuler()))
308312
@test isapprox(psol.sol.t[end], 10.0, rtol = 1e-3)
313+
@test (M.objective_value(psol), -92.75, atol = 0.1)
309314

310315
@variables x(..) v(..)
311316
@variables u(..) [bounds = (-1.0, 1.0), input = true]
@@ -375,7 +380,7 @@ end
375380
@test csol.sol.u[end] [π, 0, 0, 0]
376381

377382
iprob = InfiniteOptDynamicOptProblem(cartpole, u0map, tspan, pmap; dt = 0.04)
378-
isol = solve(iprob, InfiniteOptCollocation(Ipopt.Optimizer))
383+
isol = solve(iprob, InfiniteOptCollocation(Ipopt.Optimizer, InfiniteOpt.OrthogonalCollocation(2)))
379384
@test isol.sol.u[end] [π, 0, 0, 0]
380385

381386
pprob = PyomoDynamicOptProblem(cartpole, u0map, tspan, pmap; dt = 0.04)

0 commit comments

Comments
 (0)