Skip to content

Optimal control

Flows.Flow accepts an optimal control problem — a CTModels.Models.Model — and builds the flow of the associated Hamiltonian system directly from the problem structure. The result is an OptimalControlFlow: point calls behave like a HamiltonianFlow, while trajectory calls return a full CTModels.Solutions.Solution.


Control-free problems

The no-law constructor Flow(ocp) dispatches on the problem's CTBase.Traits.ControlDependence trait:

  • Control-free (ControlFree): the pseudo-Hamiltonian reduces to       (with    and   for :min,    for :max). The state equation    is computed exactly — no AD — and only    uses automatic differentiation.

  • With control (WithControl): a PreconditionError is thrown. Use Flow(ocp, law) to pass a control law — see Control laws.


Building the flow

Define a control-free problem with the CTModels building API (the exponential dynamics   with a Mayer cost):

julia
λ = 2.0

pre = CTModels.Building.PreModel()
CTModels.Building.time_dependence!(pre; autonomous=true)
CTModels.Building.time!(pre; t0=0.0, tf=1.0)
CTModels.Building.state!(pre, 1)
CTModels.Building.dynamics!(pre, (r, t, x, u, v) -> (r[1] = λ * x[1]; nothing))
CTModels.Building.objective!(pre, :min; mayer=(x0, xf, v) -> xf[1])
ocp = CTModels.Building.build(pre)

Then hand it to Flows.Flow:

julia
f = Flows.Flow(ocp; reltol=1e-10, abstol=1e-10)
OptimalControlFlow
├─ system: HamiltonianSystem
          ├─ time_dependence: Autonomous
          ├─ variable_dependence: Fixed
          ├─ Hamiltonian: autonomous, fixed (no variable)
    natural call: h(x, p)
    uniform call: h(t, x, p, v)
          └─ backend: DifferentiationInterface(ad_backend=AutoForwardDiff())
└─ integrator: SciML (abstol = 1.0e-10, reltol = 1.0e-10)

Keyword options are routed exactly as for Flow(h::Data.AbstractHamiltonian): integrator options (reltol, abstol, alg, …) go to the SciML strategy, AD options (ad_backend) to the DifferentiationInterface strategy.


Point calls — Hamiltonian semantics

Point evaluation delegates to the inner HamiltonianFlow and returns the final state–costate pair. For   the costate satisfies   :

julia
julia> x0, p0 = 1.0, 1.0;

julia> xf, pf = f(0.0, x0, p0, 1.0);

julia> xf   # ≈ exp(λ)
7.389056098963725

julia> pf   # ≈ exp(-λ)
0.1353352832375321

The variable, variable_costate and unsafe keywords work as for any Hamiltonian flow — see Integrating.


Free times

Flow(ocp) builds an OptimalControlFlow around an inner HamiltonianFlow, so the same free-time shooting technique described in Integrating § Variable costate applies directly: a free or is passed as (a component of) the variable, and t1 in f(t0, x0, p0, t1; variable=v) is the evaluation time — independent of v, even when v represents the free endpoint being shot on.

Because the augmented costate is initialised at  , the mitigated transversality residuals can be evaluated from the OCP's own Hamiltonian — H = Systems.hamiltonian(f) — with an opposite sign convention at each end:

See test/suite/flows/test_variable_costate_free_time.jl for worked examples that exercise this mechanism (free , free , and both at once), and Integrating § Free times for the derivation. This settles issues #231 and #183. (The Goddard tests close their free final time with the classical   condition instead — not the adjoint.)


Trajectory calls — a CTModels.Solution

A trajectory call integrates the Hamiltonian system and assembles a complete CTModels.Solutions.Solution: state and costate interpolants, an empty control (the problem is control-free), and the objective value (Mayer, Lagrange, or Bolza — a Lagrange cost is integrated with the same integrator).

julia
sol = f((0.0, 1.0), x0, p0)
Solution  ✓ successful
Objective : 7.389056098963725
Status : Success
  └─ Message : Solution computed by CTFlows OCP flow

Use the standard CTModels accessors on the result:

julia
julia> CTModels.Components.objective(sol)    # ≈ exp(λ)
7.389056098963725

julia> CTModels.Components.state(sol)(0.5)   # x(0.5) ≈ exp(λ/2)
2.7182818284893298

julia> CTModels.Components.costate(sol)(0.5) # p(0.5) ≈ exp(-λ/2)
0.36787944116841415

Because the result is a CTModels.Solution, it plots directly once Plots is loaded — state and costate on a shared time axis (the control panel is empty, the problem being control-free):

julia
plot(sol)


Basic flow — no costate (direct shooting)

For a control-free OCP, Flow(ocp) also exposes a state-only call, with no costate — the direct-shooting use case (#230). Same f object, dispatched on arity (3 positional arguments instead of 4):

julia
julia> xf_basic = f(0.0, x0, 1.0)
7.389056098956887

julia> xf_basic  xf
true

A trajectory call returns a StateFlowTrajectory with law = nothing: state and objective are available, but the flow carries neither a control nor a costate, so control/costate raise a PreconditionError:

julia
sol_basic = f((0.0, 1.0), x0)
StateFlowTrajectory
├─ tspan: (0.0, 1.0)
├─ time points: 62
├─ final state: [7.389056098956887]
└─ objective: 7.389056098956886
julia
julia> using CTFlows.Trajectories

julia> Trajectories.state(sol_basic)(0.5)
2.718281828464669

julia> Trajectories.objective(sol_basic)   # ≈ exp(λ)
7.389056098956886

Flow(ocp, law) has no such basic call when law is DynClosedLoop: its dynamics depend on the costate p(t), so f(t0, x0, tf) raises a PreconditionError suggesting f(t0, x0, p0, tf) instead. For an OpenLoop/ClosedLoop law, the state-only equivalent already exists as the ControlledFlow returned by Flow(ocp, law) — see Control laws.


Inspecting the wrapper

OptimalControlFlow is a thin AbstractFlow wrapper around the inner HamiltonianFlow; it exists solely so the trajectory call can rebuild a CTModels.Solution from the problem:

julia
julia> Flows.system(f) isa CTFlows.Systems.HamiltonianSystem
true

julia> Traits.time_dependence(f)
CTBase.Traits.Autonomous

See also