Skip to content

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
julia
plot(
    sol::CTModels.Solutions.Solution,
    description::Symbol...;
    kwargs...
) -> Any

Plot 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
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
julia
plot!(
    sol::CTModels.Solutions.Solution,
    description::Symbol...;
    kwargs...
) -> Plots.Plot

Overlay the optimal control solution sol onto the current plot (Plots.current()).

RecipesBase.plot! Method
julia
plot!(
    p::Plots.Plot,
    sol::CTModels.Solutions.Solution,
    description::Symbol...;
    kwargs...
) -> Plots.Plot

Overlay 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

julia
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
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:

julia
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
julia> using OptimalControl
julia> plot(sol)
ERROR: ExtensionError: missing dependencies to plot solutions
Missing  Plots
Hint     Run: using Plots

What 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.

julia
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):

julia
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:

julia
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:

julia
plot(sol, :state)    # only the state
plot(sol, :costate)  # only the costate
plot(sol, :control)  # only the control

Combine freely:

julia
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:

julia
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:

julia
plot(sol; control=:norm, layout=:group, size=(800, 300))

julia
plot(sol; control=:components, layout=:group, size=(800, 300))  # the default

julia
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    , via time=:normalize (or the British spelling, :normalise — both work):

julia
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:

julia
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:

julia
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:

julia
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:

julia
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:

julia
plt = plot(sol, :state, :costate, :control)
plot(plt[1])  # x₁

julia
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:

julia
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 subplot above shows it, or read it directly:

julia
time_grid(sol_flow)
5-element Vector{Float64}:
 0.0
 0.00694633729453733
 0.07787622341383321
 0.5858181009164434
 1.0

For 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:

julia
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 Flow used in the last section.