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
3DOCPDiscretizedModel — pairs an OCP with its discretizer (provided by CTDirect)
4SolversNLP solver wrappers: Solvers.Ipopt, Solvers.MadNLP, Solvers.MadNCL, Solvers.Knitro, Solvers.Uno
5IntegratorsODE integrator wrapper: Integrators.SciML

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
│       └─► Solvers.Uno
│
└─► AbstractIntegrator
        ├─ solve(prob, integ) → AbstractIntegrationResult
        └─► AbstractSciMLIntegrator
                └─► Integrators.SciML

External Strategy Families

Other packages in the control-toolbox ecosystem define additional strategy families: AbstractDiscretizer (in CTDirect.jl) discretizes continuous-time OCP into finite-dimensional problems (e.g., Collocation, DirectShooting). These external strategies follow the same AbstractStrategy contract. See the Implementing a Strategy guide in CTBase.jl documentation for a complete tutorial.

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  (in DOCP module, concrete implementation from 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:

text
CTBase: Options → Strategies → Orchestration
                                    │
                         ┌──────────┴──────────┐
                         ▼                     ▼
                    Optimization           Integrators
                         │
               ┌─────────┼─────────┐
               ▼         ▼         ▼
           Modelers     DOCP     Solvers

Each module only depends on modules loaded before it. 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 three levels of CommonSolve.solve:

LevelSignaturePurpose
Highsolve(problem, x0, modeler, solver)Full pipeline: build NLP → solve → build solution
MidCommonSolve.solve(nlp, solver; display)Solve an NLP directly; implemented by each backend 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
Solvers.Ipopt(; kwargs...) =
    Solvers.Ipopt{CTBase.Strategies.default_parameter(Solvers.Ipopt)}(; kwargs...)
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.

Qualified Access ​

CTSolvers does not export symbols at the top level. All access goes through qualified module paths:

julia
CTBase.Strategies.id(CTSolvers.Solvers.Ipopt)
CTSolvers.Optimization.build_model(problem, x0, modeler)

This ensures namespace clarity, avoids conflicts with other packages, and makes dependencies explicit.