How to plot a solution

In this tutorial, we explain the different options for plotting the solution of an optimal control problem using the plot and plot! functions, which are extensions of the Plots.jl package. Use plot to create a new plot object, and plot! to add to an existing one:

plot(args...; kw...)           # creates a new Plot, and set it to be the `current`
plot!(args...; kw...)          # modifies Plot `current()`
plot!(plt, args...; kw...)     # modifies Plot `plt`

More precisely, the signature of plot, to plot a solution, is as follows.

RecipesBase.plotMethod
plot(
    sol::Solution,
    description::Symbol...;
    layout,
    control,
    time,
    state_style,
    state_bounds_style,
    control_style,
    control_bounds_style,
    costate_style,
    time_style,
    path_style,
    path_bounds_style,
    dual_style,
    size,
    kwargs...
) -> Plots.Plot

Plot the components of an optimal control solution.

This is the main user-facing function to visualise the solution of an optimal control problem solved with the control-toolbox ecosystem.

It generates a set of subplots showing the evolution of the state, control, costate, path constraints, and dual variables over time, depending on the problem and the user’s choices.

Arguments

  • sol::CTModels.Solution: The optimal control solution to visualise.
  • description::Symbol...: A variable number of symbols indicating which components to include in the plot. Common values include:
    • :state – plot the state.
    • :costate – plot the costate (adjoint).
    • :control – plot the control.
    • :path – plot the path constraints.
    • :dual – plot the dual variables (or Lagrange multipliers) associated with path constraints.

If no symbols are provided, a default set is used based on the problem and styles.

Keyword Arguments (Optional)

  • layout::Symbol = :group: Specifies how to arrange plots.

    • :group: Fewer plots, grouping similar variables together (e.g., all states in one subplot).
    • :split: One plot per variable component, stacked in a layout.
  • control::Symbol = :components: Defines how to represent control inputs.

    • :components: One curve per control component.
    • :norm: Single curve showing the Euclidean norm ‖u(t)‖.
    • :all: Plot both components and norm.
  • time::Symbol = :default: Time normalisation for plots.

    • :default: Real time scale.
    • :normalize or :normalise: Normalised to the interval [0, 1].

Style Options (Optional)

All style-related keyword arguments can be either a NamedTuple of plotting attributes or the Symbol :none referring to not plot the associated element. These allow you to customise color, line style, markers, etc.

  • time_style: Style for vertical lines at initial and final times.
  • state_style: Style for state components.
  • costate_style: Style for costate components.
  • control_style: Style for control components.
  • path_style: Style for path constraint values.
  • dual_style: Style for dual variables.

Bounds Decorations (Optional)

Use these options to customise bounds on the plots if applicable and defined in the model. Set to :none to hide.

  • state_bounds_style: Style for state bounds.
  • control_bounds_style: Style for control bounds.
  • path_bounds_style: Style for path constraint bounds.

Returns

  • A Plots.Plot object, which can be displayed, saved, or further customised.

Example

# basic plot
julia> plot(sol)

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

# customise layout and styles, no costate
julia> plot(sol;
       layout = :group,
       control = :all,
       state_style = (color=:blue, linestyle=:solid),
       control_style = (color=:red, linestyle=:dash),
       costate_style = :none)       
source
RecipesBase.plot!Method
plot!(
    sol::Solution,
    description::Symbol...;
    layout,
    control,
    time,
    state_style,
    state_bounds_style,
    control_style,
    control_bounds_style,
    costate_style,
    time_style,
    path_style,
    path_bounds_style,
    dual_style,
    kwargs...
) -> Any

Modify Plot current() with the optimal control solution sol.

See plot for full behavior and keyword arguments.

source
RecipesBase.plot!Method
plot!(
    p::Plots.Plot,
    sol::Solution,
    description::Symbol...;
    layout,
    control,
    time,
    state_style,
    state_bounds_style,
    control_style,
    control_bounds_style,
    costate_style,
    time_style,
    path_style,
    path_bounds_style,
    dual_style,
    kwargs...
) -> Plots.Plot

Modify Plot p with the optimal control solution sol.

See plot for full behavior and keyword arguments.

source

Argument Overview

The table below summarizes the main plotting arguments and links to the corresponding documentation sections for detailed explanations:

SectionRelevant Arguments
Basic conceptssize, state_style, costate_style, control_style, time_style, kwargs...
Split vs. group layoutlayout
Plotting control normcontrol
Normalised timetime
Constraintsstate_bounds_style, control_bounds_style, path_style, path_bounds_style, dual_style
What to plotdescription...

You can plot solutions obtained from the solve function or from a flow computed using an optimal control problem and a control law. See the Basic Concepts and From Flow function sections for details.

To overlay a new plot on an existing one, use the plot! function (see Add a plot).

If you prefer full control over the visualisation, you can extract the state, costate, and control to create your own plots. Refer to the Custom plot section for guidance. You can also access the subplots.

The problem and the solution

Let us start by importing the packages needed to define and solve the problem.

using OptimalControl
using NLPModelsIpopt
<< @example-block not executed in draft mode >>

We consider the simple optimal control problem from the basic example page.

t0 = 0          # initial time
tf = 1          # final time
x0 = [-1, 0]    # initial condition
xf = [ 0, 0]    # final condition

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)
nothing # hide
<< @example-block not executed in draft mode >>

Basic concepts

The simplest way to plot the solution is to use the plot function with the solution as the only argument.

Caveat

The plot function for a solution of an optimal control problem extends the plot function from Plots.jl. Therefore, you need to import this package in order to plot a solution.

using Plots
plot(sol)
<< @example-block not executed in draft mode >>

In the figure above, we have a grid of subplots: the left column displays the state component trajectories, the right column shows the costate component trajectories, and the bottom row contains the control component trajectory.

As in Plots.jl, input data is passed positionally (for example, sol in plot(sol)), and attributes are passed as keyword arguments (for example, plot(sol; color = :blue)). After executing using Plots in the REPL, you can use the plotattr() function to print a list of all available attributes for series, plots, subplots, or axes.

# Valid Operations
plotattr(:Plot)
plotattr(:Series)
plotattr(:Subplot)
plotattr(:Axis)

Once you have the list of attributes, you can either use the aliases of a specific attribute or inspect a specific attribute to display its aliases and description.

plotattr("color") # Specific Attribute Example
<< @repl-block not executed in draft mode >>
Warning

Some attributes have different default values in OptimalControl.jl compared to Plots.jl. For instance, the default figure size is 600x400 in Plots.jl, while in OptimalControl.jl, it depends on the number of states and controls.

You can also visit the Plot documentation online to get the descriptions of the attributes:

  • To pass attributes to the plot, see the attributes plot documentation. For instance, you can specify the size of the figure.
List of plot attributes.
for a in Plots.attributes(:Plot) # hide
    println(a) # hide
end # hide
<< @example-block not executed in draft mode >>
  • You can pass attributes to all subplots at once by referring to the attributes subplot documentation. For example, you can specify the location of the legends.
List of subplot attributes.
for a in Plots.attributes(:Subplot) # hide
    println(a) # hide
end # hide
<< @example-block not executed in draft mode >>
  • Similarly, you can pass axis attributes to all subplots. See the attributes axis documentation. For example, you can remove the grid from every subplot.
List of axis attributes.
for a in Plots.attributes(:Axis) # hide
    println(a) # hide
end # hide
<< @example-block not executed in draft mode >>
  • Finally, you can pass series attributes to all subplots. Refer to the attributes series documentation. For instance, you can set the width of the curves using linewidth.
List of series attributes.
for a in Plots.attributes(:Series) # hide
    println(a) # hide
end # hide
<< @example-block not executed in draft mode >>

plot(sol, size=(700, 450), label="sol", legend=:bottomright, grid=false, linewidth=2)
<< @example-block not executed in draft mode >>

To specify series attributes for a specific group of subplots (state, costate or control), you can use the optional keyword arguments state_style, costate_style, and control_style, which correspond to the state, costate, and control trajectories, respectively.

plot(sol; 
     state_style   = (color=:blue,),                  # style: state trajectory
     costate_style = (color=:black, linestyle=:dash), # style: costate trajectory
     control_style = (color=:red, linewidth=2))       # style: control trajectory
<< @example-block not executed in draft mode >>

Vertical axes at the initial and final times are automatically plotted. The style can me modified with the time_style keyword argument. Additionally, you can choose not to display for instance the state and the costate trajectories by setting their styles to :none. You can set to :none any style.

plot(sol; 
     state_style    = :none,             # do not plot the state
     costate_style  = :none,             # do not plot the costate
     control_style  = (color = :red,),   # plot the control in red
     time_style     = (color = :green,)) # vertical axes at initial and final times in green
<< @example-block not executed in draft mode >>

To select what to display, you can also use the description argument by providing a list of symbols such as :state, :costate, and :control.

plot(sol, :state, :control)  # plot the state and the control
<< @example-block not executed in draft mode >>
Select what to plot

For more details on how to choose what to plot, see the What to plot section.

From Flow function

The previous solution of the optimal control problem was obtained using the solve function. If you prefer using an indirect shooting method and solving shooting equations, you may also want to plot the associated solution. To do this, you need to use the Flow function to reconstruct the solution. See the manual on how to compute flows for more details. In our case, you must provide the maximizing control $(x, p) \mapsto p_2$ along with the optimal control problem. For an introduction to simple indirect shooting, see the indirect simple shooting tutorial for an example.

Interactions with an optimal control solution

Please check state, costate, control, and variable to retrieve data from the solution. The functions state, costate, and control return functions of time, while variable returns a vector.

using OrdinaryDiffEq

p  = costate(sol)                # costate as a function of time
p0 = p(t0)                       # costate solution at the initial time
f  = Flow(ocp, (x, p) -> p[2])   # flow from an ocp and a control law in feedback form

sol_flow = f((t0, tf), x0, p0)   # compute the solution
plot(sol_flow)                   # plot the solution from a flow
<< @example-block not executed in draft mode >>

We may notice that the time grid contains very few points. This is evident from the subplot of $x_2$, or by retrieving the time grid directly from the solution.

time_grid(sol_flow)
<< @example-block not executed in draft mode >>

To improve visualisation (without changing the accuracy), you can provide a finer grid.

fine_grid = range(t0, tf, 100)
sol_flow = f((t0, tf), x0, p0; saveat=fine_grid)
plot(sol_flow)
<< @example-block not executed in draft mode >>

Split vs. group layout

If you prefer to get a more compact figure, you can use the layout optional keyword argument with :group value. It will group the state, costate and control trajectories in one subplot for each.

plot(sol; layout=:group)
<< @example-block not executed in draft mode >>

The default layout value is :split which corresponds to the grid of subplots presented above.

plot(sol; layout=:split)
<< @example-block not executed in draft mode >>

Add a plot

You can plot the solution of a second optimal control problem on the same figure if it has the same number of states, costates and controls. For instance, consider the same optimal control problem but with a different initial condition.

ocp = @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(ocp; display=false)
nothing # hide
<< @example-block not executed in draft mode >>

We first plot the solution of the first optimal control problem, then, we plot the solution of the second optimal control problem on the same figure, but with dashed lines.

plt = plot(sol; label="sol1", size=(700, 500))
plot!(plt, sol2; label="sol2", linestyle=:dash)
<< @example-block not executed in draft mode >>

You can also, implicitely, use the current plot.

plot(sol; label="sol1", size=(700, 500))
plot!(sol2; label="sol2", linestyle=:dash)
<< @example-block not executed in draft mode >>

Plotting the control norm

For some problem, it is interesting to plot the (Euclidean) norm of the control. You can do it by using the control optional keyword argument with :norm value.

plot(sol; control=:norm, size=(800, 300), layout=:group)
<< @example-block not executed in draft mode >>

The default value is :components.

plot(sol; control=:components, size=(800, 300), layout=:group)
<< @example-block not executed in draft mode >>

You can also plot the control and is norm.

plot(sol; control=:all, layout=:group)
<< @example-block not executed in draft mode >>

Custom plot and subplots

You can, of course, create your own plots by extracting the state, costate, and control from the optimal control solution. For instance, let us plot the norm of the control.

using LinearAlgebra
t = time_grid(sol)
u = control(sol)
plot(t, norm∘u; label="‖u‖", xlabel="t") 
<< @example-block not executed in draft mode >>

You can also get access to the subplots. The order is as follows: state, costate, control, path constraints (if any) and their dual variables.

plt = plot(sol)
plot(plt[1]) # x₁
<< @example-block not executed in draft mode >>
plt = plot(sol)
plot(plt[2]) # x₂
<< @example-block not executed in draft mode >>
plt = plot(sol)
plot(plt[3]) # p₁
<< @example-block not executed in draft mode >>
plot(plt[4]) # p₂
<< @example-block not executed in draft mode >>
plot(plt[5]) # u
<< @example-block not executed in draft mode >>

Normalised time

We consider a LQR example and solve the problem for different values of the final time tf. Then, we plot the solutions on the same figure using a normalised time $s = (t - t_0) / (t_f - t_0)$, enabled by the keyword argument time = :normalize (or :normalise) in the plot function.

# definition of the problem, parameterised by the final time
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

# solve the problems and store them
solutions = []
tfs = [3, 5, 30]
for tf ∈ tfs
    solution = solve(lqr(tf); display=false)
    push!(solutions, solution)
end

# create plots
plt = plot()
for (tf, sol) ∈ zip(tfs, solutions)
    plot!(plt, sol; time=:normalize, label="tf = $tf", xlabel="s")
end

# make a custom plot: keep only state and control
px1 = plot(plt[1]; legend=false) # x₁
px2 = plot(plt[2]; legend=true)  # x₂
pu  = plot(plt[5]; legend=false) # u    

using Plots.PlotMeasures # for leftmargin, bottommargin
plot(px1, px2, pu; layout=(1, 3), size=(800, 300), leftmargin=5mm, bottommargin=5mm)
<< @example-block not executed in draft mode >>

Constraints

We define an optimal control problem with constraints, solve it and plot the solution.

ocp = @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, (1)
    ẋ(t) == [v(t), u(t)]
    tf → min
end
sol = solve(ocp)
plot(sol)
<< @example-block not executed in draft mode >>

On the plot, you can see the lower and upper bounds of the path constraint. Additionally, the dual variable associated with the path constraint is displayed alongside it.

You can customise the plot styles. For style options related to the state, costate, and control, refer to the Basic Concepts section.

plot(sol; 
     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, # do not plot axes at t0 and tf
)
<< @example-block not executed in draft mode >>

What to plot

You can choose what to plot using the description argument. To plot only one subgroup:

plot(sol, :state)   # plot only the state
plot(sol, :costate) # plot only the costate
plot(sol, :control) # plot only the control
plot(sol, :path)    # plot only the path constraint
plot(sol, :dual)    # plot only the path constraint dual variable

You can combine elements to plot exactly what you need:

plot(sol, :state, :control, :path)
<< @example-block not executed in draft mode >>

Similarly, you can choose what not to plot passing :none to the corresponding style.

plot(sol; state_style=:none)   # do not plot the state
plot(sol; costate_style=:none) # do not plot the costate
plot(sol; control_style=:none) # do not plot the control
plot(sol; path_style=:none)    # do not plot the path constraint
plot(sol; dual_style=:none)    # do not plot the path constraint dual variable

For instance, let's plot everything except the dual variable associated with the path constraint.

plot(sol; dual_style=:none)
<< @example-block not executed in draft mode >>