Skip to content

Architecture ​

CTSolvers provides the resolution infrastructure of the control-toolbox ecosystem — solvers, modelers, integrators, and abstract problem types — consumed by CTDirect.jl (direct methods) and CTFlows.jl (flows for indirect methods).

This page provides the complete architectural overview. Read it before diving into any specific guide.

Module Overview ​

CTSolvers modules, loaded in dependency order:

#ModuleResponsibility
1OptimizationAbstract problem types (AbstractOptimizationProblem), BuiltModel/NoCache, build_model/build_solution
2ModelersNLP backend adapters: Modelers.ADNLP, Modelers.Exa
3DOCPAbstractDiscretizer, DiscretizedModel (an OCP paired with its discretizer), discretize stub, nlp_model/ocp_solution conveniences
4SolversNLP solver wrappers: Solvers.Ipopt, Solvers.MadNLP, Solvers.MadNCL, Solvers.Knitro, Solvers.Uno
5IntegratorsODE integrator wrapper Integrators.SciML, integration results (AbstractIntegrationResult, PiecewiseIntegrationResult)

CTSolvers relies on CTBase for its generic infrastructure (CTBase.Options, CTBase.Strategies, CTBase.Orchestration). See the CTBase documentation for details on the strategy contract, option system, and orchestration.

All access is qualified — CTSolvers does not export symbols at the top level:

julia
using CTSolvers

CTSolvers.Solvers.Ipopt(max_iter = 1000)           # ✓ qualified
CTBase.Strategies.id(CTSolvers.Solvers.Ipopt)      # ✓ qualified
CTSolvers.Optimization.build_model(docp, x0, m)    # ✓ qualified

id(CTSolvers.Solvers.Ipopt)   # ERROR: UndefVarError — not exported

Type Hierarchies ​

Strategy Branch ​

All configurable components (modelers, solvers, integrators) in CTSolvers are strategies. They share a common contract defined by AbstractStrategy:

text
AbstractStrategy
├─ id(::Type)       → Symbol
├─ metadata(::Type) → StrategyMetadata
├─ options(inst)    → StrategyOptions
│
├─► AbstractNLPModeler
│       ├─ build_model(prob, x0, modeler)           → BuiltModel
│       ├─ build_solution(built, stats, modeler)    → OCP Solution
│       ├─► Modelers.ADNLP{P<:CPU}
│       └─► Modelers.Exa{P<:Union{CPU,GPU}}
│
├─► AbstractNLPSolver
│       ├─ solve(nlp, solver; display) → ExecutionStats
│       ├─► Solvers.Ipopt{P<:CPU}
│       ├─► Solvers.MadNLP{P<:Union{CPU,GPU}}
│       ├─► Solvers.MadNCL{P<:Union{CPU,GPU}}
│       ├─► Solvers.Knitro{P<:CPU}
│       └─► Solvers.Uno{P<:CPU}
│
├─► AbstractIntegrator
│       ├─ solve(prob, integ; options, unsafe) → AbstractIntegrationResult
│       └─► AbstractSciMLIntegrator
│               └─► Integrators.SciML{P<:Union{CPU,GPU}}
│
└─► AbstractDiscretizer                     (DOCP — no concrete type in CTSolvers)
        └─ discretize(ocp, discretizer) → DiscretizedModel

Discretizers live elsewhere

CTSolvers only declares AbstractDiscretizer and the discretize stub. The concrete discretizers (e.g. Collocation, DirectShooting) are defined in CTDirect.jl, which implements discretize and the build_model/build_solution methods for its DiscretizedModels. See the Implementing a Strategy guide in CTBase.jl documentation for the strategy contract.

Optimization Branch ​

AbstractOptimizationProblem is a marker type with no required methods. The build_model / build_solution contract is satisfied by multiple dispatch: external packages (e.g. CTDirect) implement the typed methods for their own problem types.

text
AbstractOptimizationProblem                 (marker — no interface methods)
└─► DiscretizedModel  (DOCP module; its cache is populated by CTDirect)

build_model(prob, x0, modeler)    → BuiltModel   (generic stub; CTDirect provides typed methods)
build_solution(built, stats, modeler) → Solution  (generic stub; CTDirect provides typed methods)

BuiltModel{TP, TN, TC}                      (problem + NLP + cache, immutable)
├─ .problem  → TP <: AbstractOptimizationProblem
├─ .nlp      → TN  (backend NLP model, e.g. ADNLPModel)
└─ .cache    → TC <: AbstractCache
       └─► NoCache  (for backends needing no auxiliary storage)

Module Dependencies ​

The loading order is strict and acyclic:

#ModuleDepends on (inside CTSolvers)External
1Optimization—CTBase
2ModelersOptimizationCTBase
3DOCPOptimization, ModelersCTBase, CTModels
4SolversOptimization, ModelersCTBase, SolverCore
5Integrators—CTBase, CTModels (status/successful)

Each module only depends on modules loaded before it. Integrators is independent of the NLP side. This strict ordering ensures no circular dependencies and makes extensions straightforward to reason about.

Data Flow ​

The complete resolution pipeline, from user call to optimal control solution:

text
User
 │
 ▼  solve(docp, x0, modeler, solver)          ← orchestration.jl
CommonSolve.solve
 │
 ├─► build_model(docp, x0, modeler)           →  BuiltModel
 │       (CTDirect provides typed method for DiscretizedModel + modeler pair)
 │
 ├─► CommonSolve.solve(built.nlp, solver)     →  ExecutionStats
 │       (backend extension provides typed method, e.g. CTSolversIpopt)
 │
 └─► build_solution(built, stats, modeler)    →  OCP Solution
         (CTDirect provides typed method for DiscretizedModel + modeler pair)

The two levels of CommonSolve.solve on the NLP side, plus the integrator one:

LevelSignaturePurpose
Highsolve(problem, x0, modeler, solver; display)Full pipeline: build NLP → solve → build solution
Midsolve(nlp, solver; display)Solve an NLP directly; implemented by each backend extension
ODEsolve(prob, integrator; options, unsafe)Integrate an ODE problem; implemented by the integrator extension

Architectural Patterns ​

Two-Level Contract ​

Every strategy implements a two-level contract separating static metadata from dynamic configuration:

text
Type-Level (static, called on the type itself)
├─ id(::Type{MyStrategy})       → :my_strategy  (unique symbol identifier)
└─ metadata(::Type{MyStrategy}) → StrategyMetadata (option definitions, defaults)
        │
        ▼  used for: introspection, routing, validation before construction
Constructor
        │
        ▼
Instance-Level (dynamic, called on a constructed instance)
└─ options(strategy)            → StrategyOptions (actual values with provenance)
        │
        ▼  used for: backend call, options_dict extraction
Execution
  • Type-level methods (id, metadata) are called on the type — they enable introspection, routing, and validation without creating objects.

  • Instance-level methods (options) are called on instances — they provide the actual configuration with provenance tracking.

See the Implementing a Strategy guide in CTBase.jl documentation for a step-by-step tutorial.

Strategy Parameters (Overview) ​

Strategies can be parameterized to specialize behavior based on execution context (e.g., CPU vs GPU). Parameters are singleton types enabling compile-time dispatch:

text
AbstractStrategyParameter
├─► CPU  (singleton type)
└─► GPU  (singleton type)

metadata(Solvers.MadNLP{CPU})  →  CPU defaults  →  Solvers.MadNLP{CPU}(max_iter=1000)
metadata(Solvers.MadNLP{GPU})  →  GPU defaults  →  Solvers.MadNLP{GPU}(max_iter=1000)

The parameter is a type parameter of the strategy (Solvers.MadNLP{P}), not a separate argument. Solvers.MadNLP(...) resolves P from default_parameter (here CPU).

See the Strategy Parameters guide in CTBase.jl documentation for a complete guide.

NotImplemented Pattern ​

All contract methods have default implementations that throw NotImplemented with helpful error messages:

julia
# If you forget to implement `id` for your strategy:
julia> CTBase.Strategies.id(IncompleteStrategy)
# ERROR: NotImplemented
#   Strategy ID method not implemented
#   Required method: id(::Type{<:IncompleteStrategy})
#   Suggestion: Implement id(::Type{<:IncompleteStrategy}) to return
#   a unique Symbol identifier

This pattern ensures that missing implementations are detected immediately with clear guidance — no silent failures or incorrect defaults.

Tag Dispatch ​

Solvers (and integrators) use Tag Dispatch to separate type definitions (in src/Solvers/) from backend implementations (in ext/).

src/Solvers/ipopt.jl — type definition and stubs (always loaded):

julia
struct Ipopt{P<:CPU} <: AbstractNLPSolver
    options::StrategyOptions
end
struct IpoptTag <: Core.AbstractTag end

CTBase.Strategies.id(::Type{<:Solvers.Ipopt}) = :ipopt
CTBase.Strategies.default_parameter(::Type{<:Solvers.Ipopt}) = CPU
CTBase.Strategies.parameter(::Type{<:Solvers.Ipopt{P}}) where {P<:CPU} = P

# Constructor chain: resolve P, then dispatch on the tag and parameter
# TYPES
function Solvers.Ipopt(; kwargs...)
    P = CTBase.Strategies.default_parameter(Solvers.Ipopt)
    return Solvers.Ipopt{P}(; kwargs...)
end
Solvers.Ipopt{P}(; kwargs...) where {P<:CPU} =
    _build_ipopt_solver(IpoptTag, P; kwargs...)
_build_ipopt_solver(
    ::Type{<:Core.AbstractTag},
    ::Type{<:AbstractStrategyParameter};
    kwargs...,
) = throw(ExtensionError(:NLPModelsIpopt))

ext/CTSolversIpopt.jl — real implementations (loaded only with using NLPModelsIpopt):

julia
metadata(::Type{Solvers.Ipopt{P}}) where {P<:CPU} = StrategyMetadata(...)
_build_ipopt_solver(
    ::Type{Solvers.IpoptTag},
    P::Type{<:AbstractStrategyParameter};
    kwargs...,
) = Solvers.Ipopt{P}(validated_opts)
CommonSolve.solve(nlp, solver::Solvers.Ipopt; display) =
    ipopt(nlp; options_dict(solver)...)

This keeps CTSolvers lightweight — backend dependencies are optional weak deps loaded on demand.