Options
Every keyword argument passed to solve ends up on exactly one strategy: the discretizer, the modeler, or the solver. This page covers how that routing works, the two escape hatches for the cases it does not handle automatically, and how to inspect where a value came from.
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
endOption routing
solve runs in strict mode: every keyword you pass must be recognized by exactly one of the three strategies in play, or it's an error — never a silent no-op.
sol = solve(ocp;
grid_size=100, # → Collocation (discretizer)
backend=:default, # → ADNLP (modeler)
max_iter=500, # → Ipopt (solver)
print_level=0, # → Ipopt (solver)
)▫ This is OptimalControl 2.2.1-beta, solving with: collocation → adnlp → ipopt (cpu)
📦 Configuration:
├─ Discretizer: collocation (grid_size = 100)
├─ Modeler: adnlp (backend = default)
└─ Solver: ipopt (max_iter = 500, print_level = 0)An option nobody recognizes is rejected with a "did you mean" suggestion:
julia> solve(ocp, :ipopt; max_iter=100, mumps_print_level=1, display=false)
┌ Warning: CUDA is loaded but not functional. GPU backend may not work properly.
└ @ CTSolversCUDA ~/.julia/packages/CTSolvers/uc2u1/ext/CTSolversCUDA.jl:31
IncorrectArgument → _route_descriptive_options, descriptive_routing.jl:259
│
│ 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
│ Hint 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>))
└─Explicit routing with route_to
If two strategies of a method declared the same option name, using it bare would be ambiguous, and solve would not guess which one you meant. With the built-in strategies, this does not happen today: no two strategies of a method share an option name. route_to names the target strategy explicitly. It is required for an ambiguous name, and allowed everywhere, for instance to be explicit:
using MadNLP
sol = solve(ocp, :madnlp;
grid_size=50, # auto-routed to the discretizer
max_iter=route_to(:madnlp, 1000), # explicitly routed
print_level=MadNLP.ERROR, # auto-routed to the solver
)▫ This is OptimalControl 2.2.1-beta, solving with: collocation → adnlp → madnlp (cpu)
📦 Configuration:
├─ Discretizer: collocation (grid_size = 50)
├─ Modeler: adnlp
└─ Solver: madnlp
linear_solver = MumpsSolver [cpu-dependent], max_iter = 1000, print_level = ERRORroute_to takes a strategy id and a value, or the same as a keyword argument, route_to(madnlp=1000). It also accepts several id/value pairs, to send the same option name to several strategies with different values at once: route_to(:id1, value1, :id2, value2).
sol = solve(ocp, :madnlp; max_iter=route_to(madnlp=1000), display=false) # keyword formUndeclared solver options
The three strategies here declare their own options, but not every option the underlying solver accepts is declared — Ipopt's mumps_print_level, for instance, isn't in the strategy metadata, so it's rejected by strict validation (shown above). Combine route_to with bypass to force it through, unvalidated:
sol = solve(ocp, :ipopt;
max_iter=100,
mumps_print_level=route_to(:ipopt, bypass(1)),
)▫ This is OptimalControl 2.2.1-beta, solving with: collocation → adnlp → ipopt (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.9.0.
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.24e-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.0000960015360381e+00 6.0000960015360381e+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.442
EXIT: Optimal Solution Found.bypass is needed in addition to route_to because route_to alone still validates against the strategy's declared options — bypass is what skips that check. force is a plain alias for bypass (force === bypass); use whichever name reads better: route_to(:ipopt, force(1)).
Use bypass sparingly
It skips type checking and validation entirely. Reach for it only when you're certain the option name and value are correct and the strategy genuinely doesn't declare it.
Where a value came from
Every option on a built strategy instance knows whether it was set by you (:user), left at its default (:default), or computed from the :cpu/:gpu parameter (:computed):
s = OptimalControl.Ipopt(max_iter=200)
println(has_option(s, :max_iter)) # true — Ipopt declares this option
println(has_option(s, :not_an_option)) # false
println(option_value(s, :max_iter)) # 200 — what will actually be used
println(option_value(s, :tol)) # 1.0e-8 — strategy default
opts = options(s)
println(is_user(opts, :max_iter)) # true
println(is_default(opts, :tol)) # true
println(is_computed(opts, :max_iter)) # falsetrue
false
200
1.0e-8
true
true
falseoption_source returns which of the three it was, as a Symbol. MadNLP's linear solver is a computed option: its default depends on the parameter (MUMPS on CPU, cuDSS on GPU):
println(option_source(s, :max_iter))
println(option_source(s, :tol))
println(option_source(OptimalControl.MadNLP(), :linear_solver))user
default
computedThe display of solve (see Solve overview) shows the :user and :computed options of each strategy, the latter tagged [cpu-dependent] or [gpu-dependent]; the :default ones are not shown.
Action options vs strategy options
Two keywords — init/initial_guess and display — are handled before routing even starts; they're never sent to a strategy. If a strategy happens to declare an option with the same name, the action option wins by default. route_to is the way around that, if you ever need to target the strategy's own option of that name explicitly instead.
See also
Choosing a method — the strategies these options are routed to.
Explicit mode — configure a strategy by passing options directly to its constructor instead of routing them through
solve.