Skip to content

Displaying models and solutions ​

The CTModels.Display module provides Base.show extensions that render Model and PreModel objects in a human-readable mathematical format. It also hosts a RecipesBase.plot stub that is specialised by the CTModelsPlots extension when Plots.jl is loaded.

Setup ​

Displaying a built Model ​

When you print a Model in the REPL (or any IO context with MIME"text/plain"), CTModels renders the OCP in standard mathematical notation — objective, dynamics, constraints, and variable spaces:

julia
pre = CTModels.PreModel()

CTModels.variable!(pre, 0)
CTModels.time!(pre; t0=0.0, tf=1.0)
CTModels.state!(pre, 2)
CTModels.control!(pre, 1)

function dynamics!(r, t, x, u, v)
    r[1] = x[2]
    r[2] = u[1]
    return nothing
end
CTModels.dynamics!(pre, dynamics!)

CTModels.objective!(pre, :min; lagrange=(t, x, u, v) -> u[1]^2)

function boundary!(r, x0, xf, v)
    r[1] = x0[1]
    r[2] = x0[2] - 1
    r[3] = xf[1]
    r[4] = xf[2] + 1
    return nothing
end
CTModels.constraint!(pre, :boundary; f=boundary!, lb=zeros(4), ub=zeros(4), label=:bc)
CTModels.constraint!(pre, :state;   rg=1:1, lb=[0.0],   ub=[0.1],  label=:x1_box)
CTModels.constraint!(pre, :control; rg=1:1, lb=[-10.0], ub=[10.0], label=:u_box)

CTModels.time_dependence!(pre; autonomous=true)
ocp = CTModels.build(pre)

ocp  # displays the model
The (autonomous) optimal control problem is of the form:

    minimize  J(x, u) = ∫ f⁰(x(t), u(t)) dt, over [0.0, 1.0]

    subject to

        ẋ(t) = f(x(t), u(t)), t in [0.0, 1.0] a.e.,

        ϕ₋ ≤ ϕ(x(0.0), x(1.0)) ≤ ϕ₊, 
        x₋ ≤ x(t) ≤ x₊, 
        u₋ ≤ u(t) ≤ u₊, 

    where x(t) ∈ R² and u(t) ∈ R.

The output includes:

  • An (autonomous) or (non autonomous) qualifier.

  • The objective J(x, u) = … with Mayer and/or Lagrange terms.

  • The dynamics ẋ(t) = f(t, x(t), u(t)).

  • Constraint lines for path, boundary, and box constraints (only those that are present).

  • A where clause listing the state, control, and variable spaces.

Displaying a PreModel ​

A PreModel can be displayed at any stage of construction. If the problem is not yet consistent (missing components, incomplete declarations), only the abstract definition — if any — is shown. Once the pre-model is consistent, the full mathematical formulation is rendered:

julia
pre2 = CTModels.PreModel()
CTModels.variable!(pre2, 0)
CTModels.time!(pre2; t0=0.0, tf=1.0)
CTModels.state!(pre2, 1)
CTModels.control!(pre2, 1)
CTModels.dynamics!(pre2, (r, t, x, u, v) -> (r[1] = u[1]; return nothing))
CTModels.objective!(pre2, :min; lagrange=(t, x, u, v) -> u[1]^2)
CTModels.time_dependence!(pre2; autonomous=true)

pre2  # consistent PreModel displays the mathematical form
The (autonomous) optimal control problem is of the form:

    minimize  J(x, u) = ∫ f⁰(x(t), u(t)) dt, over [0.0, 1.0]

    subject to

        ẋ(t) = f(x(t), u(t)), t in [0.0, 1.0] a.e.,

    where x(t) ∈ R and u(t) ∈ R.

An empty PreModel produces no output:

julia
CTModels.PreModel()  # nothing is printed

Abstract (symbolic) definitions ​

If a Definition has been attached to the model via definition!, its symbolic expression is printed under an "Abstract definition:" header before the mathematical formulation. This is useful when the OCP originates from a macro-based DSL (e.g. OptimalControl.jl) that stores the original user code.

When no definition is set (EmptyDefinition), this section is skipped silently.

Displaying component types ​

The same display conventions are used by the concrete component types that make up a model. The types are normally inspected through their public accessors, while show provides a compact visual summary of their structure:

julia> state_model = CTModels.Components.StateModel("x", ["x₁", "x₂"])
StateModel
├─ name: x
├─ dimension: 2
└─ components: x₁, x₂

julia> CTModels.Components.name(state_model)
"x"

julia> CTModels.Components.components(state_model)
2-element Vector{String}:
 "x₁"
 "x₂"

julia> CTModels.Components.dimension(state_model)
2
julia
state_model
StateModel
├─ name: x
├─ dimension: 2
└─ components: x₁, x₂

A solution component indicates that its value is a callable trajectory without expanding the closure. The value and metadata remain available through accessors:

julia> state_solution = CTModels.Components.StateModelSolution("x", ["x₁"], t -> 2t)
StateModelSolution
├─ name: x
├─ dimension: 1
├─ components: x₁
└─ value: <callable>

julia> CTModels.Components.value(state_solution)(0.5)
1.0

julia> CTModels.Components.name(state_solution)
"x"

julia> CTModels.Components.dimension(state_solution)
1
julia
state_solution
StateModelSolution
├─ name: x
├─ dimension: 1
├─ components: x₁
└─ value: <callable>

Time models, objectives, and constraints follow the same pattern. Their summaries expose semantic fields while the accessors provide the programmatic interface:

julia> times_model = CTModels.Components.TimesModel(
           CTModels.Components.FixedTimeModel(0.0, "t₀"),
           CTModels.Components.FreeTimeModel(2, "tf"),
           "t",
       )
TimesModel
├─ initial: FixedTimeModel(name=t₀, time=0.0)
├─ final: FreeTimeModel(name=tf, index=2)
└─ time_name: t

julia> CTModels.Components.initial(times_model)
FixedTimeModel
├─ name: t₀
└─ time: 0.0

julia> CTModels.Components.final(times_model)
FreeTimeModel
├─ name: tf
└─ index: 2

julia> CTModels.Components.initial_time_name(times_model)
"t₀"

julia> CTModels.Components.final_time_name(times_model)
"tf"
julia
times_model
TimesModel
├─ initial: FixedTimeModel(name=t₀, time=0.0)
├─ final: FreeTimeModel(name=tf, index=2)
└─ time_name: t
julia> objective_model = CTModels.Components.MayerObjectiveModel((x0, xf, v) -> xf[1], :min)
MayerObjectiveModel
├─ criterion: min
└─ mayer: <callable>

julia> CTModels.Components.criterion(objective_model)
:min

julia> CTModels.Components.mayer(objective_model)
#14 (generic function with 1 method)
julia
objective_model
MayerObjectiveModel
├─ criterion: min
└─ mayer: <callable>

For solution support objects, the display summarizes grid sizes, optional solver metadata, and dual presence rather than printing all numerical data:

julia> grid = CTModels.UnifiedTimeGridModel(collect(range(0.0, 1.0; length=5)))
UnifiedTimeGridModel
├─ points: 5
├─ first: 0.0
└─ last: 1.0

julia> infos = CTModels.SolverInfos(12, :first_order, "converged", true, 1e-8, Dict{Symbol,Any}())
SolverInfos
├─ status: first_order
├─ successful: true
├─ iterations: 12
├─ message: converged
└─ constraints violation: 1.0e-8

julia> grid
UnifiedTimeGridModel
├─ points: 5
├─ first: 0.0
└─ last: 1.0

julia> infos
SolverInfos
├─ status: first_order
├─ successful: true
├─ iterations: 12
├─ message: converged
└─ constraints violation: 1.0e-8

The corresponding accessors can be used without relying on the stored fields:

julia
CTModels.time_grid_model(
    CTModels.build_solution(
        ocp,
        collect(range(0.0, 1.0; length=3)),
        zeros(3, 2),
        zeros(3, 1),
        Float64[],
        zeros(3, 2);
        objective=0.0,
        successful=true,
    ),
)
UnifiedTimeGridModel
├─ points: 3
├─ first: 0.0
└─ last: 1.0

Plotting solutions ​

The Display module registers a RecipesBase.plot method for AbstractSolution. Without Plots.jl loaded, calling it throws an ExtensionError:

julia
using CTModels
sol = CTModels.build_solution(
    ocp,
    collect(range(0.0, 1.0; length=10)),
    zeros(10, 2),
    zeros(10, 1),
    Float64[],
    zeros(10, 2);
    objective=0.0,
    iterations=0,
    constraints_violation=0.0,
    message="",
    status=:dummy,
    successful=true,
)

try
    CTModels.plot(sol)
catch e
    println(typeof(e))
end
UndefVarError

When Plots.jl is loaded, the CTModelsPlots extension provides full plot recipes. A Makie backend (CTModelsMakie, activated by loading CairoMakie or GLMakie) renders the same figure as a Makie.Figure through a separate Makie.plot(sol) method — not the RecipesBase.plot stub above. See Plotting for both backends.

See also ​

  • Building a model — how to assemble a PreModel and call build.

  • Plotting — plot recipes for solutions.

  • Model — API reference for the model type.

  • PreModel — API reference for the pre-model type.