Skip to content

Overview

Flow is one constructor that does three distinct jobs: indirect optimal control (build the Hamiltonian flow of the Pontryagin Maximum Principle, write a shooting function, solve it), simulation (integrate a controlled system under an open-loop or feedback control), and inspection (pull the Hamiltonian, the Hamiltonian vector field, or the control law back out of a flow you built). This page maps the section and recaps just enough PMP to make the rest make sense.

Why flows

The direct methods (Solve) discretize the whole problem into one large nonlinear program. Flows integrate instead: you supply a control law — from the PMP, from a feedback design, from anywhere — and get back an ODE solution. Direct methods win when you don't yet have a control law and want the solver to find one; flows win once you do, whether that law came from solving the PMP by hand, from a direct solve's costate, or from a controller you designed independently of any optimization.

The Pontryagin Maximum Principle, briefly

For an OCP with dynamics   and Lagrange cost , the pseudo-Hamiltonian is

The maximisation condition picks, at each , the control that extremises — a control law . Substituting it back gives the true Hamiltonian  , whose Hamiltonian system

is the Hamiltonian flow this whole section builds and integrates. Boundary conditions on at / (transversality) turn "integrate the flow" into "find the missing " — shooting.

From the PMP to a flow

You supply — worked out by hand, or read off a @def problem via Flow(ocp, ...) — and Flow gives you : an object callable at a point (endpoint only) or over a trajectory (the full path), integrated numerically.

Three things this section does

  1. Indirect solvingFrom an OCP, Constrained arcs, Multi-phase, Shooting.

  2. SimulationSimulating a controlled system: integrate under a given control, no optimization involved.

  3. InspectionWhat you can get back from a flow: the Hamiltonian, its vector field, the pseudo-Hamiltonian, the control law you passed in.

From Hamiltonians and vector fields covers the lower-level constructors these three jobs are all built from.

Before you start

Every flow needs an ODE integrator, and none is a hard dependency — load one, most commonly OrdinaryDiffEqTsit5, before building any flow:

julia
using OptimalControl
using OrdinaryDiffEqTsit5
using NLPModelsIpopt

t0 = 0
tf = 1
x0 = [-1, 0]

ocp = @def begin
    t  [t0, tf], time
    x = (q, v)  R², state
    u  R, control
    x(t0) == x0
    x(tf) == [0, 0]
(t) == [v(t), u(t)]
    0.5∫(u(t)^2)  min
end

f = Flow(ocp, (x, p) -> p[2])

Every page in this section opens with using OrdinaryDiffEqTsit5 — no exceptions. Forget it and Flow says so at construction time, naming the using to add:

julia
julia> f = Flow(ocp, (x, p) -> p[2])
ERROR: ExtensionError  top-level scope, REPL[6]:1

missing dependencies to access SciML options metadata

│  Missing  OrdinaryDiffEqTsit5

│  Context  Load OrdinaryDiffEqTsit5, OrdinaryDiffEq, or DifferentialEquations to activate the CTSolversSciMLIntegrator extension.
│  Hint     Run: using OrdinaryDiffEqTsit5
└─

Why that block is not executed

The documentation build loads OrdinaryDiffEq once, for the whole site, and a Julia extension stays armed for the rest of the session — so no page here can demonstrate this failure live. The transcript above comes from a session loading OptimalControl and NLPModelsIpopt and nothing else.

Choosing an integrator and its options

describe covers the indirect side too, not just the direct-solve strategies from Choosing a method: :sciml for the integrator family, :di for the automatic-differentiation backend that builds a Hamiltonian's vector field.

julia
describe(:sciml)
SciML (strategy)
├─ id: :sciml
├─ hierarchy: SciML → AbstractSciMLIntegrator → AbstractIntegrator → AbstractStrategy
├─ description: SciML ODE integrator.
│               See: https://docs.sciml.ai/DiffEqDocs
│               Solver options: https://docs.sciml.ai/DiffEqDocs/stable/basics/common_solver_opts/
├─ family: AbstractIntegrator
├─ default parameter: CPU
├─ parameters: CPU, GPU

├─ computed options for CPU: none

├─ computed options for GPU: none

└─ common options (20 options):
   ├─ maxiters (max_iters, max_iter, maxiter, max_iterations, maxit)::Integer (default: NotProvided)
   │  description: Maximum number of solver iterations.

   ├─ save_everystep::Union{Bool, Symbol} (default: auto)
   │  description: Save the solution at every solver step. Set `true`/`false` to force, or `:auto` to resolve to `false` for point integration and `true` for trajectory integration.

   ├─ save_end::Bool (default: NotProvided)
   │  description: Whether to force saving the final timepoint.

   ├─ d_discontinuities::AbstractVector{<:Real} (default: NotProvided)
   │  description: Locations of discontinuities in low-order derivatives.

   ├─ save_start::Union{Bool, Symbol} (default: auto)
   │  description: Save initial condition in solution. Set `true`/`false` to force, or `:auto` to resolve to `false` for point integration and `true` for trajectory integration.

   ├─ force_dtmin::Bool (default: NotProvided)
   │  description: Whether to continue forcing minimum dt usage.

   ├─ alg (algorithm, solver)::SciMLBase.AbstractDEAlgorithm (default: Tsit5{typeof(OrdinaryDiffEqCore.trivial_limiter!), typeof(OrdinaryDiffEqCore.trivial_limiter!), FastBroadcast.Serial}(OrdinaryDiffEqCore.trivial_limiter!, OrdinaryDiffEqCore.trivial_limiter!, FastBroadcast.Serial()))
   │  description: ODE algorithm (e.g. Tsit5(), Vern6()).

   ├─ saveat (save_at, save_times)::Union{Real, AbstractVector} (default: NotProvided)
   │  description: Times at which to save the solution (Vector or range).

   ├─ dtmin (min_dt, dt_min)::Real (default: NotProvided)
   │  description: Minimum step size for adaptive timestepping.

   ├─ progress (verbose)::Bool (default: NotProvided)
   │  description: Whether to show progress bar.

   ├─ reltol (rtol, rel_tol)::Real (default: 1.0e-8)
   │  description: Relative tolerance for the ODE solver.

   ├─ adaptive (adaptive_step, adaptive_stepping)::Bool (default: NotProvided)
   │  description: Whether to use adaptive step-size control.

   ├─ dt (dt0, timestep)::Real (default: NotProvided)
   │  description: Fixed step size (used when adaptive=false).

   ├─ save_idxs (saveindices, save_indices)::AbstractVector{<:Integer} (default: NotProvided)
   │  description: Indices of components to save (Vector of integers).

   ├─ callback (callbacks, cb)::Any (default: NotProvided)
   │  description: Callback function for event handling.

   ├─ tstops (t_stops, stop_times)::AbstractVector{<:Real} (default: NotProvided)
   │  description: Extra times the solver must step to (for discontinuities).

   ├─ internalnorm (internal_norm, norm)::Function (default: real_norm)
   │  description: Internal norm for adaptive step-size control. Defaults to `real_norm`, which extracts the primal (Float64) part of ForwardDiff dual numbers to ensure grid invariance (IND) when ForwardDiff is loaded. Set to `DiffEqBase.ODE_DEFAULT_NORM` to use the SciML default.

   ├─ abstol (atol, abs_tol)::Real (default: 1.0e-8)
   │  description: Absolute tolerance for the ODE solver.

   ├─ dense::Union{Bool, Symbol} (default: auto)
   │  description: Dense output. Set `true`/`false` to force, or `:auto` to resolve to `false` for point integration and `true` for trajectory integration.

   └─ dtmax (max_dt, dt_max)::Real (default: NotProvided)
      description: Maximum step size for adaptive timestepping.

Real option names worth knowing: alg (the ODE algorithm, default Tsit5()), reltol/ abstol (default 1e-8 each), saveat (explicit save times), dense (dense output, :auto by default — resolves to false for a point call, true for a trajectory call). Pass any of them as keywords when constructing a flow, e.g. Flow(ocp, law; reltol=1e-10, alg=Tsit5()).

julia
describe(:di)
DifferentiationInterface (strategy)
├─ id: :di
├─ hierarchy: DifferentiationInterface → AbstractADBackend → AbstractStrategy
├─ description: AD backend wrapping DifferentiationInterface.jl backends (e.g., AutoForwardDiff).
├─ family: AbstractADBackend
├─ default parameter: CPU
├─ parameters: CPU, GPU

├─ computed options for CPU:
│  └─ ad_backend (backend, ad)::ADTypes.AbstractADType (default: AutoForwardDiff() [computed])
│     description: DifferentiationInterface.jl backend (e.g. AutoForwardDiff() on CPU).

└─ computed options for GPU:
   └─ ad_backend (backend, ad)::ADTypes.AbstractADType (default: ADTypes.AutoMooncake() [computed])
      description: DifferentiationInterface.jl backend for GPU execution. Default: AutoMooncake(), validated end-to-end on CuArray including through a mutating in-place RHS. AutoForwardDiff() does not work on GPU (scalar-indexes a CuArray). AutoZygote() can be selected explicitly; it was found unreliable in some device call contexts (an unexpected KernelException was observed on a non-mutating call).

OptimalControl.get_full_strategy_registry() — internal, not re-exported — is what describe and the constructor completion machinery actually query for the indirect side: it merges the direct-solve registry with CTFlows.Flows.flow_registry(), so :sciml/:di show up alongside :collocation/:adnlp/:ipopt in the same introspection tools.

CPU and GPU

method=:cpu/:gpu — the same tokens as solve — are passed when constructing a flow, not on the call:

julia
f = Flow(ocp, law; method=:gpu)   # correct — a constructor keyword
f(t0, x0, p0, tf; method=:gpu)    # wrong — MethodError, no such call-time keyword

Where to go