Plot
plot/plot! extend Plots.jl to draw a Solution directly — the same call works on a Flow-produced trajectory too (last section below).
RecipesBase.plot Method
plot(
sol::CTModels.Solutions.Solution,
description::Symbol...;
kwargs...
) -> AnyPlot the components of an optimal control CTModels.Solution.
Generates a set of subplots showing the state, control, costate, path constraints and dual variables over time, depending on the problem and the given description.
Arguments
sol: the optimal control solution to visualise.description: symbols selecting which groups to include; any of:state,:costate,:control,:path(path constraints),:dual(their multipliers). If none is given, a default set is used based on the problem.
Keyword arguments
layout::Symbol = :split::split(one subplot per component) or:group(group each signal into a single subplot with a legend).control::Symbol = :components::components(a curve per control component),:norm(the Euclidean norm‖u(t)‖) or:all(both).time::Symbol = :default::default(real time) or:normalize/:normalise([0, 1]).color: colour applied to every curve.size: figure size; defaults to a heuristic based on the layout.
Style options
Each *_style keyword is a NamedTuple of plotting attributes, or :none to hide the group/decoration: state_style, costate_style, control_style, path_style, dual_style, time_style (initial/final time markers), and the bounds decorations state_bounds_style, control_bounds_style, path_bounds_style.
Returns
A Plots.Plot. All layout and rendering is delegated to CTBase.Plotting.
Example
julia> plot(sol)
julia> plot(sol, :state, :control; layout=:group, control=:all)
julia> plot(sol; state_style=(color=:blue,), costate_style=:none)RecipesBase.plot! Method
plot!(
sol::CTModels.Solutions.Solution,
description::Symbol...;
kwargs...
) -> Plots.PlotOverlay the optimal control solution sol onto the current plot (Plots.current()).
RecipesBase.plot! Method
plot!(
p::Plots.Plot,
sol::CTModels.Solutions.Solution,
description::Symbol...;
kwargs...
) -> Plots.PlotOverlay the optimal control solution sol onto the existing plot p. Same behaviour and keyword arguments as Plots.plot(::CTModels.Solution); an empty p is filled as if by plot.
Getting started
using OptimalControl
using NLPModelsIpopt
t0 = 0
tf = 1
x0 = [-1, 0]
xf = [0, 0]
ocp = @def begin
t ∈ [t0, tf], time
x ∈ R², state
u ∈ R, control
x(t0) == x0
x(tf) == xf
ẋ(t) == [x₂(t), u(t)]
∫(0.5u(t)^2) → min
end
sol = solve(ocp; display=false)plot on a solution is an extension — nothing happens until Plots itself is loaded:
julia> using Plots
julia> plot(sol)
IncorrectArgument → top-level scope, REPL[2]:2
│
│ a VBox needs at least one child
│
│ Got 0 children
│ Expected ≥ 1 child
└─Wait — that's not a Plots-missing error, it's real: CTModels.jl#392, a genuine upstream bug hit while writing this page. A bare plot(sol) currently fails whenever the default subplot selection includes the costate column under the default :split layout — which is nearly always. Explicit selectors or layout=:group both avoid it:
plot(sol, :state, :costate, :control)
Every example below uses one of those two forms for that reason, not by preference. Loading Plots before plot(sol) at all — the actual extension gate — throws a clean ExtensionError instead:
julia> using OptimalControl
julia> plot(sol)
ERROR: ExtensionError: missing dependencies to plot solutions
Missing Plots
Hint Run: using PlotsWhat gets drawn by default
With every group shown, the layout is a grid: state trajectories on the left, costate on the right, control along the bottom.
plot(sol, :state, :costate, :control; size=(700, 450), legend=:bottomright, grid=false, linewidth=2)
state_style, costate_style, and control_style set series attributes per group (any Plots.jl attribute, as a NamedTuple):
plot(sol, :state, :costate, :control;
state_style=(color=:blue,),
costate_style=(color=:black, linestyle=:dash),
control_style=(color=:red, linewidth=2),
)
Vertical markers at the initial/final times are controlled by time_style. Any *_style also accepts :none to hide that group entirely:
plot(sol, :state, :costate, :control;
state_style=:none,
costate_style=:none,
control_style=(color=:red,),
time_style=(color=:green,),
)
Choosing what to draw
Positional symbols select which groups appear — :state, :costate, :control, and (with a path constraint present) :path, :dual:
plot(sol, :state) # only the state
plot(sol, :costate) # only the costate
plot(sol, :control) # only the controlCombine freely:
plot(sol, :state, :control)
Layout
layout=:group puts each family (state, costate, control) in one subplot instead of one per component — and, as shown above, is one of the two ways to sidestep CTModels.jl#392 on a bare call:
plot(sol; layout=:group)
:split (the default) is the per-component grid used everywhere above.
The control
control=:norm plots the Euclidean norm of the control instead of its components; control=:all plots both:
plot(sol; control=:norm, layout=:group, size=(800, 300))
plot(sol; control=:components, layout=:group, size=(800, 300)) # the default
plot(sol; control=:all, layout=:group)
Styling
Covered above (state_style/costate_style/control_style/time_style) — repeated here for the outline: every one of them is a NamedTuple of Plots.jl attributes, or :none to hide the group. Use plotattr("attribute") (from Plots) to look up any attribute's aliases and description once using Plots is loaded.
Normalised time
Solve the same problem for several final times and compare them on a normalised time axis time=:normalize (or the British spelling, :normalise — both work):
function lqr(tf)
ocp = @def begin
t ∈ [0, tf], time
x ∈ R², state
u ∈ R, control
x(0) == [0, 1]
ẋ(t) == [x₂(t), -x₁(t) + u(t)]
∫(0.5(x₁(t)^2 + x₂(t)^2 + u(t)^2)) → min
end
return ocp
end
tfs = [3, 5, 30]
solutions = [solve(lqr(tf); display=false) for tf in tfs]
plt = plot()
for (tf, sol) in zip(tfs, solutions)
plot!(plt, sol, :state, :control; time=:normalize, label="tf = $tf", xlabel="s")
end
using Plots.PlotMeasures
px1 = plot(plt[1]; legend=false) # x₁
px2 = plot(plt[2]; legend=true) # x₂
pu = plot(plt[3]; legend=false) # u
plot(px1, px2, pu; layout=(1, 3), size=(800, 300), leftmargin=5mm, bottommargin=5mm)
Constraints
A problem with a box control constraint and a nonlinear path constraint:
ocp_c = @def begin
tf ∈ R, variable
t ∈ [0, tf], time
x = (q, v) ∈ R², state
u ∈ R, control
tf ≥ 0
-1 ≤ u(t) ≤ 1
q(0) == -1
v(0) == 0
q(tf) == 0
v(tf) == 0
1 ≤ v(t) + 1 ≤ 1.8, (c1)
ẋ(t) == [v(t), u(t)]
tf → min
end
sol_c = solve(ocp_c; display=false)
plot(sol_c, :state, :costate, :control, :path, :dual)
The path constraint's bounds are drawn alongside it, with its dual variable in its own panel. Style keywords for these two extra groups: path_style, dual_style, and the bounds decorations state_bounds_style, control_bounds_style, path_bounds_style:
plot(sol_c, :state, :costate, :control, :path, :dual;
state_bounds_style=(linestyle=:dash,),
control_bounds_style=(linestyle=:dash,),
path_style=(color=:green,),
path_bounds_style=(linestyle=:dash,),
dual_style=(color=:red,),
time_style=:none,
)
Adding to an existing plot
plot! overlays a second solution — same state/costate/control dimensions required:
ocp2 = @def begin
t ∈ [t0, tf], time
x ∈ R², state
u ∈ R, control
x(t0) == [-0.5, -0.5]
x(tf) == xf
ẋ(t) == [x₂(t), u(t)]
∫(0.5u(t)^2) → min
end
sol2 = solve(ocp2; display=false)
plt = plot(sol, :state, :costate, :control; label="sol1", size=(700, 500))
plot!(plt, sol2, :state, :costate, :control; label="sol2", linestyle=:dash)
Custom subplots
Extract state, control, costate as plain functions to build your own figure:
using LinearAlgebra
t = time_grid(sol)
u = control(sol)
plot(t, norm ∘ u; label="‖u‖", xlabel="t")
Or reach into an existing plot(sol, ...)'s subplots directly — order follows the :state, :costate, :control, :path, :dual grouping, in the order requested:
plt = plot(sol, :state, :costate, :control)
plot(plt[1]) # x₁
plot(plt[5]) # u
Plotting a flow trajectory
The same plot call works on a trajectory produced by Flow — see Flows for how to build one; here's the plotting side:
using OrdinaryDiffEqTsit5
p = costate(sol)
p0 = p(t0)
f = Flow(ocp, (x, p) -> p[2]) # flow from an ocp and a feedback control law
sol_flow = f((t0, tf), x0, p0)
plot(sol_flow)
The default grid can be sparse — the
time_grid(sol_flow)5-element Vector{Float64}:
0.0
0.00694633729453733
0.07787622341383321
0.5858181009164434
1.0For a denser plot, pass saveat when constructing the flow, not on the call — the call itself only accepts variable/unsafe (and variable_costate, for costate augmentation). dense=false is required alongside saveat, since dense output and saveat conflict at the integrator level:
fine_grid = range(t0, tf, 100)
f2 = Flow(ocp, (x, p) -> p[2]; saveat=fine_grid, dense=false)
sol_flow2 = f2((t0, tf), x0, p0)
plot(sol_flow2)
See also
Solution object — the accessors this page draws.
Save and load — persist a solution instead of just plotting it.
Flows — building the
Flowused in the last section.