Solve: advanced options

This manual covers advanced option management for the solve function: how option routing works, how to disambiguate shared options with route_to, how to pass unknown options with bypass, and how to use introspection tools.

For basic usage, see Solve a problem.

Option routing system

When you call solve with keyword arguments, OptimalControl.jl automatically routes each option to the appropriate strategy (discretizer, modeler, or solver).

using OptimalControl
using NLPModelsIpopt

t0 = 0
tf = 1
x0 = [-1, 0]

ocp = @def begin
    t ∈ [ t0, tf ], time
    x = (q, v) ∈ R², state
    u ∈ R, control
    x(t0) == x0
    x(tf) == [0, 0]
    ẋ(t)  == [v(t), u(t)]
    0.5∫( u(t)^2 ) → min
end

# Options are automatically routed
sol = solve(ocp;
    grid_size=100,    # → Collocation (discretizer)
    show_time=true,   # → ADNLP (modeler)
    max_iter=500,     # → Ipopt (solver)
    print_level=0     # → Ipopt (solver)
)
▫ This is OptimalControl 1.3.3-beta, solving with: collocationadnlpipopt (cpu)

  📦 Configuration:
   ├─ Discretizer: collocation (grid_size = 100)
   ├─ Modeler: adnlp (show_time = true)
   └─ Solver: ipopt (max_iter = 500, print_level = 0)

gradient backend ADNLPModels.ReverseDiffADGradient: 0.421513713 seconds;
hprod    backend ADNLPModels.EmptyADbackend: 4.022e-6 seconds;
jprod    backend ADNLPModels.EmptyADbackend: 1.775e-6 seconds;
jtprod   backend ADNLPModels.EmptyADbackend: 1.2e-6 seconds;
  • Sparsity pattern detection of the Jacobian: 0.000136757 seconds.
  • Coloring of the sparse Jacobian: 1.6444e-5 seconds.
  • Allocation of the AD buffers for the sparse Jacobian: 5.46e-7 seconds.
jacobian backend ADNLPModels.SparseADJacobian: 0.615462672 seconds;
  • Sparsity pattern detection of the Hessian: 0.666796044 seconds.
  • Coloring of the sparse Hessian: 2.296e-5 seconds.
  • Allocation of the AD buffers for the sparse Hessian: 2.690529252 seconds.
hessian  backend ADNLPModels.SparseReverseADHessian: 3.665984569 seconds;
ghjvprod backend ADNLPModels.EmptyADbackend: 4.355e-6 seconds.

How routing works

Each strategy declares its available options via metadata. When you pass an option:

  1. Lookup: The system checks which strategies recognize this option name
  2. Route: If exactly one strategy family (discretizer/modeler/solver) recognizes it, the option is routed there
  3. Validate: The option value is validated against the declared type and constraints
  4. Error: If no strategy recognizes the option, or if multiple families claim it, an error is raised

You can inspect a strategy's declared options using describe:

using CUDA
describe(:exa)
Exa{CPU} (strategy)
├─ id: :exa
├─ hierarchy: ExaAbstractNLPModelerAbstractStrategy
├─ family: AbstractNLPModeler
├─ default parameter: CPU
├─ parameters: CPU, GPU
│
┌ Warning: CUDA is loaded but not functional. GPU backend may not work properly.
└ @ CTSolversCUDA ~/.julia/packages/CTSolvers/oXAcJ/ext/CTSolversCUDA.jl:29
├─ common options (1 option):
│  └─ base_type::DataType (default: Float64)
│     description: Base floating-point type used by ExaModels
│
├─ computed options for CPU:
│  └─ backend (exa_backend)::Any (default: nothing [computed])
│     description: Execution backend for ExaModels (CPU, GPU, etc.)
│
└─ computed options for GPU:
   └─ backend (exa_backend)::Union{Nothing, KernelAbstractions.Backend} (default: CUDA.CUDAKernels.CUDABackend(false, false) [computed])
      description: Execution backend for ExaModels (CPU, GPU, etc.)

The output shows:

  • Strategy ID: The symbol used to reference this strategy (:exa)
  • Family: The abstract type family (AbstractNLPModeler)
  • Default parameter: Default execution backend (CPU)
  • Parameters: Available execution backends (CPU, GPU)
  • Common options: Options shared across all parameters
    • Option name and type
    • Default value
    • Description
  • Computed options: Options that vary by parameter
    • Parameter-specific defaults
    • Whether the value is computed automatically

Ambiguous options and route_to

Ambiguity occurs when an option name exists in multiple strategies within the same method. Since a method always has exactly one discretizer, one modeler, and one solver, ambiguity only happens when strategies from different families share an option name.

For example, suppose :exa (modeler) and :madnlp (solver) both have an option called common_option_name. If you try to use it without disambiguation, you'll get an error:

# This will raise an error
solve(ocp, :exa, :madnlp; common_option_name=12)
# ERROR: IncorrectArgument: Option 'common_option_name' is ambiguous...

Using route_to for disambiguation

Use route_to to explicitly specify which strategy should receive the option:

# Explicitly route to the :exa strategy
sol = solve(ocp, :exa, :madnlp; 
    common_option_name=route_to(exa=12),
    max_iter=500,
    print_level=MadNLP.ERROR
)

The route_to function accepts keyword arguments with strategy names:

  • route_to(collocation=value) — route to the Collocation discretizer
  • route_to(adnlp=value) — route to the ADNLP modeler
  • route_to(exa=value) — route to the Exa modeler
  • route_to(ipopt=value) — route to the Ipopt solver
  • route_to(madnlp=value) — route to the MadNLP solver
  • route_to(uno=value) — route to the Uno solver
  • route_to(madncl=value) — route to the MadNCL solver
  • route_to(knitro=value) — route to the Knitro solver

You can use route_to even for non-ambiguous options, and combine routed and non-routed options:

using MadNLP
sol = solve(ocp, :madnlp;
    grid_size=50,                      # auto-routed to discretizer
    max_iter=route_to(madnlp=1000),    # explicitly routed to solver
    print_level=MadNLP.ERROR           # auto-routed to solver
)
▫ This is OptimalControl 1.3.3-beta, solving with: collocationadnlpmadnlp (cpu)

  📦 Configuration:
   ├─ Discretizer: collocation (grid_size = 50)
   ├─ Modeler: adnlp
   └─ Solver: madnlp
     linear_solver = MumpsSolver [cpu-dependent], max_iter = 1000, print_level = ERROR

The bypass mechanism

By default, solve uses strict validation: any option not recognized by a registered strategy raises an error. This prevents typos and ensures you're using valid options.

However, NLP solvers have many options, and not all of them are declared in OptimalControl's strategy metadata. For example, Ipopt has an option mumps_print_level for controlling MUMPS debug output:

mumps_print_level: Debug printing level for the linear solver MUMPS

0: no printing; 1: Error messages only; 2: Error, warning, and main statistic messages; 3: Error and warning messages and terse diagnostics; ≥4: All information.

This option is not in the Ipopt strategy metadata. If you try to use it directly, you'll get an error:

julia> sol = solve(ocp, :ipopt;
           max_iter=100,
           mumps_print_level=1)ERROR: Control Toolbox Error

❌ Error: CTBase.Exceptions.IncorrectArgument, Unknown option provided
🔍 Got: option :mumps_print_level in method (:collocation, :adnlp, :ipopt, :cpu), Expected: valid option name for one of the strategies
📂 Context: route_options - unknown option validation
💡 Suggestion: Did you mean?
  - :print_level [distance: 6]
  - :mu_strategy [distance: 11]
  - :dual_inf_tol [distance: 11]
If you're confident this option exists for a specific strategy, use bypass() to skip validation:
  custom_opt = route_to(<strategy_id>=bypass(<value>))

To pass undeclared options, combine route_to with bypass:

julia> sol = solve(ocp, :ipopt;
           max_iter=100,
           mumps_print_level=route_to(ipopt=bypass(1)))▫ This is OptimalControl 1.3.3-beta, solving with: collocationadnlpipopt (cpu)

  📦 Configuration:
   ├─ Discretizer: collocation
   ├─ Modeler: adnlp
   └─ Solver: ipopt (max_iter = 100, mumps_print_level = 1)

▫ This is Ipopt version 3.14.19, running with linear solver MUMPS 5.8.2.

Number of nonzeros in equality constraint Jacobian...:     1754
Number of nonzeros in inequality constraint Jacobian.:        0
Number of nonzeros in Lagrangian Hessian.............:      250

Total number of variables............................:      752
                     variables with only lower bounds:        0
                variables with lower and upper bounds:        0
                     variables with only upper bounds:        0
Total number of equality constraints.................:      504
Total number of inequality constraints...............:        0
        inequality constraints with only lower bounds:        0
   inequality constraints with lower and upper bounds:        0
        inequality constraints with only upper bounds:        0

iter    objective    inf_pr   inf_du lg(mu)  ||d||  lg(rg) alpha_du alpha_pr  ls
   0  5.0000000e-03 1.10e+00 2.03e-14   0.0 0.00e+00    -  0.00e+00 0.00e+00   0
   1  6.0000960e+00 2.22e-16 1.78e-15 -11.0 6.08e+00    -  1.00e+00 1.00e+00h  1

Number of Iterations....: 1

                                   (scaled)                 (unscaled)
Objective...............:   6.0000960015360247e+00    6.0000960015360247e+00
Dual infeasibility......:   1.7763568394002505e-15    1.7763568394002505e-15
Constraint violation....:   2.2204460492503131e-16    2.2204460492503131e-16
Variable bound violation:   0.0000000000000000e+00    0.0000000000000000e+00
Complementarity.........:   0.0000000000000000e+00    0.0000000000000000e+00
Overall NLP error.......:   1.7763568394002505e-15    1.7763568394002505e-15


Number of objective function evaluations             = 2
Number of objective gradient evaluations             = 2
Number of equality constraint evaluations            = 2
Number of inequality constraint evaluations          = 0
Number of equality constraint Jacobian evaluations   = 2
Number of inequality constraint Jacobian evaluations = 0
Number of Lagrangian Hessian evaluations             = 1
Total seconds in IPOPT                               = 0.004

EXIT: Optimal Solution Found.
• Solver:
  ✓ Successful  : true
  │  Status     : first_order
  │  Message    : Ipopt/generic
  │  Iterations : 1
  │  Objective  : 6.000096001536025
  └─ Constraints violation : 2.220446049250313e-16

• Boundary duals: [12.000192003072046, 6.000096001536023, -12.000192003072046, 6.000096001536035]

You must combine bypass with route_to because:

  • If the option is unknown, the system needs to know which strategy should receive it
  • bypass forces the option through without validation
Alias: force = bypass

You can use force as an alias for bypass: route_to(ipopt=force(1))

Use bypass sparingly

The bypass mechanism skips validation entirely. Use it only when:

  • You need to pass an option to the underlying solver that isn't declared in the strategy metadata
  • You're certain the option name and value are correct

Bypassed options are passed directly to the solver without type checking or validation.

Parameter token (CPU/GPU)

The 4th token in a method description specifies the execution backend: :cpu (default) or :gpu.

# Explicitly request CPU execution (this is the default)
sol = solve(ocp, :collocation, :adnlp, :ipopt, :cpu;
    grid_size=50,
    print_level=0
)
▫ This is OptimalControl 1.3.3-beta, solving with: collocationadnlpipopt (cpu)

  📦 Configuration:
   ├─ Discretizer: collocation (grid_size = 50)
   ├─ Modeler: adnlp
   └─ Solver: ipopt (print_level = 0)

The parameter token automatically changes default options for GPU-capable strategies. For example:

  • Exa{GPU} uses a CUDA backend by default
  • MadNLP{GPU} uses CUDSSSolver as the linear solver by default

For full GPU usage details, see Solve on GPU.

See also