Skip to content

The build_solution contract ​

build_solution is the single entry point through which a solver (CTDirect, CTFlows, or your own) hands its numerical output to CTModels. This page states what it expects and what it checks; the other Solutions pages describe what can be read back.

Signatures ​

julia
# one grid shared by every component
build_solution(ocp, T, X, U, v, P; objective, successful, kwargs...)

# one grid per component
build_solution(ocp, T_state, T_control, T_costate, T_path, X, U, v, P;
               objective, successful, kwargs...)

The first form calls the second with T_state = T_control = T_costate = T_path = T. objective::Float64 and successful::Bool are the only required keywords.

Primal data ​

ArgumentTypeShape
T, T_state, T_control, T_costateVector{Float64}increasing (a grid out of order is sorted, with a warning)
T_pathVector{Float64} or nothingnothing reuses the state grid for the path duals
XMatrix{Float64} or Functionlength(T_state) × state_dimension(ocp)
UMatrix{Float64} or Functionlength(T_control) × control_dimension(ocp)
PMatrix{Float64} or Functionlength(T_costate) × state_dimension(ocp)
vVector{Float64}variable_dimension(ocp), Float64[] when there is no variable

Matrices have one row per time point and one column per component. Inputs are always vectors and matrices, also in dimension one; the one-dimensional is scalar rule applies to what the solution returns:

julia
using CTModels

pre = CTModels.PreModel()
CTModels.variable!(pre, 1, "tf")
CTModels.time!(pre; t0=0.0, tf=1.0)
CTModels.state!(pre, 1)
CTModels.control!(pre, 1)
CTModels.dynamics!(pre, (r, t, x, u, v) -> (r[1] = u; nothing))
CTModels.objective!(pre, :min; lagrange=(t, x, u, v) -> u^2)
CTModels.constraint!(pre, :path; f=(r, t, x, u, v) -> (r[1] = x + u; nothing),
    lb=[-1.0], ub=[1.0], label=:p)
CTModels.constraint!(pre, :boundary; f=(r, x0, xf, v) -> (r[1] = x0; nothing),
    lb=[0.0], ub=[0.0], label=:x0)
CTModels.time_dependence!(pre; autonomous=true)
ocp = CTModels.build(pre)

N = 11
T = collect(range(0.0, 1.0; length=N))
X = reshape(T, N, 1)                 # N × 1
U = ones(N, 1)                       # N × 1
P = zeros(N, 1)                      # N × 1
v = [1.0]                            # length 1

sol = CTModels.build_solution(ocp, T, X, U, v, P; objective=1.0, successful=true)
julia
julia> CTModels.state(sol)(0.5)
0.5

julia> CTModels.control(sol)(0.5)
1.0

julia> CTModels.variable(sol)
1.0

A Function t -> x(t) is accepted in place of a matrix for X, U, P and the path duals. It is not sampled: a copy of it (deepcopy, so that later changes to captured variables do not affect the solution) is stored and called directly, its result being converted to a scalar in dimension one. The grids are still needed for display, plotting and serialization.

Time grids ​

  • When all the provided grids are equal, the solution stores a single UnifiedTimeGridModel; otherwise a MultipleTimeGridModel. See Time grids.

  • A grid that is the longest grid without its last point (for instance a control grid T[1:end-1] from a direct method) is extended to the longest one, so that a unified grid can still be used; its data is not modified.

  • State and costate are interpolated linearly. The control is piecewise constant by default; pass control_interpolation=:linear for a control computed from the state and costate, as in indirect methods. Control box duals follow the control interpolation.

Dual variables ​

All duals are optional keywords; leaving one out (nothing) stores no multiplier for it.

KeywordTypeShape
path_constraints_dualMatrix{Float64} or Functionlength(T_path) × dim_path_constraints_nl(ocp), one column per constraint row
boundary_constraints_dualVector{Float64}dim_boundary_constraints_nl(ocp)
state_constraints_lb_dual, state_constraints_ub_dualMatrix{Float64}length(T_state) × state_dimension(ocp)
control_constraints_lb_dual, control_constraints_ub_dualMatrix{Float64}length(T_control) × control_dimension(ocp)
variable_constraints_lb_dual, variable_constraints_ub_dualVector{Float64}variable_dimension(ocp)

Box duals have one column per primal component, not one per declared constraint: components without a bound carry a zero multiplier, and several labels on one component share its multiplier. This is what lets dual return the multiplier of any label; see Duals & diagnostics.

Diagnostics ​

KeywordTypeDefault
objectiveFloat64required
successfulBoolrequired
iterationsIntnot provided
constraints_violationFloat64not provided
messageStringnot provided
statusSymbolnot provided
infosDict{Symbol,Any}empty

Fields that are not provided are omitted from the display of the solution, which is what a flow-based result typically needs; see the display examples in Solutions.

What is checked ​

build_solution raises an IncorrectArgument, with the received and expected sizes, when:

  • the number of columns of X, U, P, a path dual or a box dual does not match the model;

  • v, boundary_constraints_dual or a variable box dual has the wrong length;

  • control_interpolation is neither :constant nor :linear.

julia> CTModels.build_solution(ocp, T, X, U, Float64[], P; objective=1.0, successful=true)
ERROR: IncorrectArgument
│
│  variable length mismatch
│
│  Got       length=0
│  Expected  length=1 (= variable_dimension)
│
│  Context   build_solution - validating variable length
│  Hint      Provide a vector of length variable_dimension(ocp). If the data comes from an exported solution, pass the model the solution was computed from.
└─
julia> CTModels.build_solution(ocp, T, X, U, v, P; objective=1.0, successful=true,
           boundary_constraints_dual=[1.0, 2.0])
ERROR: IncorrectArgument
│
│  boundary_constraints_dual length mismatch
│
│  Got       length=2
│  Expected  length=1 (= number of boundary constraints)
│
│  Context   build_solution - validating boundary dual length
│  Hint      Provide a vector with one entry per boundary constraint. If the data comes from an exported solution, pass the model the solution was computed from.
└─