Solver guide for OPF

For a route through base policies, starting values and model evidence, see Scaling, initialization and diagnostics.

BMOPFTools builds one four-wire rectangular current–voltage (IVR-EN) OPF model through JuMP. The solver is selected at the boundary: pass a JuMP optimizer to solve_opf, solve_pf, or solve_feasibility_opf. Changing the solver does not change the network physics, objective, units, or result schema; it changes how the mathematical program is searched and what solver status is reported.

This page covers three direct solver paths and one JuMP-to-NLPModels path currently tested with the engine:

PathJulia packageBest first useImportant qualification
IpoptIpopt.jlThe reference solve and the broadest nonlinear model coverageLocal nonlinear optimization; a successful solve is a local result.
MadNLPMadNLP.jlAn alternative local NLP implementation, especially when experimenting with sparse linear algebra or accelerator-backed extensionsLocal nonlinear optimization; solver options and defaults differ from Ipopt.
ExaModels with NLPModelsIpoptExaModels.jl, NLPModelsIpopt.jlCheck the engine through ExaModels' JuMP-to-ExaModel interfaceExaModels is a modeling/derivative path; NLPModelsIpopt still runs Ipopt. Only the focused IVR-EN engine case described below is verified here.
GurobiGurobi.jlCross-checks and workflows that need Gurobi, for the quadratic-compatible IVR-EN subsetCommercial software and license required; not every BMOPFTools nonlinear feature is Gurobi-compatible.

Ipopt and MadNLP are local nonlinear-programming solvers in the same broad family of interior-point methods. ExaModels translates a JuMP model to an ExaModel and passes it to an NLPModels-compatible solver; using NLPModelsIpopt does not give an independent numerical solver from Ipopt. Gurobi is the quadratic/nonconvex path through the engine: it is useful for the part of the IVR-EN model that JuMP can represent as affine and quadratic expressions, but it is not a drop-in replacement for every smooth nonlinear load or control curve.

The JuMP models guide is a useful introduction to optimizer objects and solver attributes. The PowerModels installation guide uses the same practical convention: identify the formulation, install a compatible optimizer, and make solver settings explicit when comparing runs.

Install the solvers

Install JuMP and whichever solver packages you intend to use in the same Julia environment as BMOPFTools. None of the solvers is a hard dependency — add only the ones you need:

using Pkg
Pkg.add(["JuMP", "Ipopt"])   # add "MadNLP" or "Gurobi" as needed
# For the optional ExaModels path, add both "ExaModels" and "NLPModelsIpopt".

Gurobi.jl also needs a usable Gurobi installation/license. The Julia package and a license are separate concerns; see the Gurobi.jl installation notes and the Gurobi license documentation.

Gurobi is not a test dependency

Because Gurobi is commercial and license-gated, it is deliberately absent from test/Project.toml. The suite detects it at load time and skips test/gurobi_engine_tests.jl when the package is missing, fails to load, or has no usable license, so a Gurobi-free checkout runs green. To exercise that coverage, add Gurobi to the test environment yourself:

using Pkg
Pkg.activate("test")
Pkg.add("Gurobi")
ExaModels is not a declared test dependency

Neither ExaModels nor NLPModelsIpopt is in BMOPFTools' Project.toml or test/Project.toml. The main suite skips test/examodels_engine_tests.jl unless both are available. An isolated CI job runs the engine test with dependencies declared in test/examodels/Project.toml. From a clone, run:

julia --project=test/examodels -e 'using Pkg; Pkg.develop(path=pwd()); Pkg.instantiate()'
julia --project=test/examodels test/examodels/runtests.jl

Load the solver package whose optimizer you pass:

using BMOPFTools, JuMP, Ipopt
# or: using BMOPFTools, JuMP, MadNLP
# or: using BMOPFTools, JuMP; import ExaModels, NLPModelsIpopt
# or: using BMOPFTools, JuMP, Gurobi

Loading JuMP activates BMOPFTools' OPF extension. Ipopt is the default when Ipopt is loaded, but an explicit optimizer is preferable in scripts and papers because it records the intended solver and makes a solver comparison reproducible.

The common pattern

Assume net is a BMOPF network dictionary, for example one returned by from_dss and prepared with the workflow in the end-to-end tutorial. The shortest form is:

result = solve_opf(net; optimizer = Ipopt.Optimizer)

For a controlled run, create an optimizer with attributes and pass it to the engine:

opt = optimizer_with_attributes(
    Ipopt.Optimizer,
    "tol" => 1e-8,
    "max_iter" => 3000,
    "print_level" => 0,
)
result = solve_opf(net; optimizer = opt, per_unit = true)

You can instead pass raw solver attributes through BMOPFTools:

result = solve_opf(net;
    optimizer = Ipopt.Optimizer,
    solver_options = ["tol" => 1e-8, "max_iter" => 3000],
)

solver_options is an iterable of name => value pairs. The engine applies these settings after its own defaults, so user-supplied values win at the point they are set. Attribute names are solver-specific: OutputFlag is a Gurobi parameter, while print_level is an Ipopt or MadNLP option. Do not assume that an option with a similar name has the same meaning across solvers.

One caveat: verbose = false (the default) calls JuMP.set_silent only when the optimizer supports MOI's Silent attribute. ExaModels 0.12 does not, so its underlying solver can still print output. A solver that does support Silent is free to act on it later than the raw attributes you pass. MadNLP, for instance, overwrites print_level with MadNLP.ERROR inside optimize! whenever the model is silent, so a print_level in solver_options only takes effect under verbose = true.

Inspect the result and validate the physical solution rather than relying on a status string alone:

println(result["termination_status"])
println(result["objective"])

validation = profile_solution(net, result)
render_solution(validation, stdout)

Ipopt, MadNLP, and ExaModels with NLPModelsIpopt commonly report "LOCALLY_SOLVED"; Gurobi may report "OPTIMAL" for a supported quadratic model. These statuses are not interchangeable claims about global optimality. Read the solver status together with residuals, bounds, and the independent checks in Validating the OPF.

Ipopt: the reference local NLP solve

Ipopt is the default reference solver for BMOPFTools. It has the broadest coverage of the engine's smooth nonlinear features, including voltage-dependent load laws and smoothed control curves. It is a local solver, so a successful result is a locally optimal or locally stationary solution of the nonconvex OPF, not a proof that no better feasible point exists.

Basic Ipopt example

using BMOPFTools, JuMP, Ipopt

const IPOPT_OPT = optimizer_with_attributes(
    Ipopt.Optimizer,
    "tol" => 1e-8,
    "max_iter" => 3000,
    "print_level" => 0,
)

result = solve_opf(net; optimizer = IPOPT_OPT)

The library supplies phase-aware voltage starts and defaults to per-unit coordinates. Those two choices are usually more important than immediately tightening a tolerance.

Trying adaptive barrier updates with Ipopt

For operating-point OPF with dispatch freedom on cases expected to be feasible, try Ipopt's adaptive barrier strategy through the existing options interface:

result = solve_opf(net;
    optimizer = Ipopt.Optimizer,
    s_base = 1e6,
    solver_options = ["mu_strategy" => "adaptive"],
)
validation = profile_solution(net, result)
render_solution(validation, stdout)

This is opt-in: BMOPFTools does not change its solver defaults or automatically retry with another strategy. The Ipopt options reference defines mu_strategy; the option is specific to Ipopt.

Issue #429 reports fewer iterations and lower solve times for adaptive μ across 16 priced-DER French MV/LV feeder instances with 110–1,583 buses at revision a8b52e06, using Ipopt 3.14/MUMPS on an Apple M-series machine. Objectives were close; one case reached a slightly different objective. This is reported case-specific evidence, not a general speed guarantee or an independently reproduced result in this guide. Unmodified feeders without dispatch freedom showed a smaller benefit.

Difficult cases were mixed: the reported 995-bus, 1.25× load case increased from 36.5 s to 288 s. Retain the current default as the initial baseline for feasibility screening. For bounded experiments, apply the same max_iter and max_wall_time to both strategies and retain failures and time-limited outcomes. Ipopt checks its wall-time budget during convergence checks; it is not a hard process deadline. LOCALLY_INFEASIBLE does not prove global physical infeasibility, and LOCALLY_SOLVED must still be read alongside independent solution checks.

The repository-local barrier benchmark instructions provide deterministic DER augmentation, warmed repeated comparisons, provenance, and serialized verification evidence. They include the 498-bus reproduction command and instructions for retaining the slow-case comparison. No new preset API is required. See also power-base sensitivity.

Ipopt settings worth experimenting with

  • tol controls the requested KKT accuracy. Try 1e-6, 1e-8, and 1e-10 only after checking the physical residuals; a smaller number is not always a better-conditioned solve.
  • max_iter is useful when a difficult case is making progress but needs more interior-point iterations. A time limit or external job timeout may still be more appropriate for batch studies.
  • linear_solver can have a large effect on runtime and robustness. MUMPS is the common default in Ipopt.jl; an appropriately installed HSL solver can be worth testing on larger sparse cases. The linear solver is an algorithmic choice, not a change to the OPF.
  • bound_relax_factor is useful for a diagnostic comparison. Setting it to 0.0 makes the solver respect declared bounds without its initial relaxation, but may make a difficult boundary solve less forgiving.
  • acceptable_tol can allow early termination at a looser KKT residual. Use it deliberately: for feasibility checks and published comparisons, inspect the final residuals and consider requiring the regular tol instead.

For example, to expose the Ipopt log while investigating convergence:

result = solve_opf(net;
    optimizer = Ipopt.Optimizer,
    verbose = true,
    solver_options = ["tol" => 1e-8, "max_iter" => 5000],
)

verbose=true lets solver output through. It is independent of the numeric settings, so use "print_level" => 0 when you want a quiet, reproducible run.

MadNLP: an alternative local NLP solver

MadNLP is another interior-point local NLP solver with a JuMP interface. It is a useful independent implementation for solver parity checks and for experiments with alternative sparse linear solvers; the MadNLP ecosystem also provides extensions for selected HSL, Pardiso, and GPU-backed configurations.

Basic MadNLP example

using BMOPFTools, JuMP, MadNLP

const MADNLP_OPT = optimizer_with_attributes(
    MadNLP.Optimizer,
    "tol" => 1e-8,
    "max_iter" => 3000,
    "print_level" => MadNLP.ERROR,
)

result = solve_opf(net; optimizer = MADNLP_OPT)

The same call can be written with solver_options:

result = solve_opf(net;
    optimizer = MadNLP.Optimizer,
    solver_options = ["tol" => 1e-9, "bound_relax_factor" => 0.0],
)

MadNLP settings worth experimenting with

  • linear_solver chooses the KKT linear algebra backend. The default is MadNLP.MumpsSolver; on a suitable installation, comparing it with another supported backend can be more informative than changing the nonlinear tolerance.
  • tol, max_iter, acceptable_tol, and bound_relax_factor play roles similar to their Ipopt counterparts, but the defaults and implementation are not identical. Compare physical residuals and iteration counts, not just wall time.
  • hessian_approximation can be useful when exact second derivatives are expensive or numerically fragile. MadNLP's exact-Hessian path is the natural starting point; quasi-Newton choices are an experiment, not a universally safer default.
  • jacobian_constant and hessian_constant are performance hints. Set them only when the corresponding derivatives really are constant; they are not generic convergence switches and should not be enabled for a changing parameterized model without checking the formulation.
  • nlp_scaling controls MadNLP's nonlinear-program scaling. Compare it with BMOPFTools' own per_unit=true coordinate choice rather than treating the two scaling layers as substitutes.

The authoritative option names and supported linear-solver types are in the MadNLP options reference.

ExaModels: a JuMP-to-NLPModels path

ExaModels.Optimizer accepts an NLPModels-compatible solver as its first argument. Pass an optimizer factory to BMOPFTools so JuMP can construct a fresh model for each solve:

using BMOPFTools, JuMP
import ExaModels, NLPModelsIpopt

exa_optimizer = () -> ExaModels.Optimizer(NLPModelsIpopt.ipopt)
result = solve_opf(net; optimizer = exa_optimizer)

The optional engine test exercises a nonlinear constant-power IVR-EN case through this path at ExaModels 0.12.0 and NLPModelsIpopt 0.11.3, checking the termination status and a known bus-voltage solution. It also exercises the staged build_opf_model path. This is a focused compatibility check, not a claim that every BMOPFTools device, control curve, or ExaModels backend has been verified. The underlying numerical solver in this example is Ipopt; use the direct MadNLP path for an independent solver implementation comparison.

ExaModels 0.12 does not support MOI's Silent attribute. BMOPFTools leaves the optimizer's output setting alone when that attribute is unavailable, so verbose=false may still produce Ipopt output through this path.

Gurobi: the quadratic-compatible local-NLP path

Gurobi is a commercial optimizer exposed to JuMP by Gurobi.jl. In BMOPFTools it is appropriate for the quadratic-compatible IVR-EN subset: constant power/current/impedance models and other features that leave the generated JuMP model affine or quadratic. Voltage-dependent exponential laws should remain on Ipopt or MadNLP unless you have separately verified that your exact formulation is supported by your Gurobi version. Smooth piecewise-linear control curves (IBR Volt-var / Volt-watt) are supported, but only under the encoding described next.

Volt-var / Volt-watt on Gurobi requires `softplus=:swish`

The engine smooths each control-curve kink with a smooth-ReLU surrogate, and Gurobi's nonlinear interface accepts only a fixed opcode set. Of the three available encodings, exactly one lands inside it:

softplusEmitsOn Gurobi
:user_defined (default)MOI.UserDefinedFunctionrejected — MOI.UnsupportedAttribute
:builtinlog1p(exp(⋅))rejected — :log1p is not a Gurobi opcode
:swishz · logistic(z / ε)accepted — maps to GRB_OPCODE_LOGISTIC

So a case carrying a Volt-var or Volt-watt profile must select the encoding explicitly, or the solve fails at optimize!:

result = solve_opf(net;
    optimizer = Gurobi.Optimizer,
    softplus = :swish,
    solver_options = ["NonConvex" => 2, "OutputFlag" => 0],
)

Gurobi's nonlinear support needs Gurobi 12.0 or newer; older libraries expose no nonlinear opcodes at all. Swish is a genuinely different surrogate, not a re-implementation of softplus — it is non-monotone and non-convex near each hinge, so validate the resulting droop curve against the exact characteristic. See ReLU/softplus encoding for the error analysis.

Cases with no control profile are unaffected: softplus only matters once a curve is actually smoothed.

The engine's quadratic OPF constraints are generally nonconvex. Set Gurobi's NonConvex parameter explicitly:

using BMOPFTools, JuMP, Gurobi

const GUROBI_OPT = optimizer_with_attributes(
    Gurobi.Optimizer,
    "NonConvex" => 2,
    "OutputFlag" => 0,
)

result = solve_opf(net; optimizer = GUROBI_OPT)

The equivalent BMOPFTools form is:

result = solve_opf(net;
    optimizer = Gurobi.Optimizer,
    solver_options = ["NonConvex" => 2, "OutputFlag" => 0],
)

NonConvex=2 tells Gurobi to accept and solve nonconvex quadratic structure; without it, a model containing the engine's nonconvex quadratic constraints may be rejected. Gurobi's treatment of a nonconvex quadratic model is not the same algorithm as Ipopt or MadNLP, so compare the returned point and independent residuals as well as the objective.

Gurobi settings worth experimenting with

  • OutputFlag controls Gurobi logging. Keep it at 0 in batch runs and use verbose=true plus OutputFlag=1 while diagnosing the solve.
  • TimeLimit is useful for bounded batch jobs. Always record whether the result stopped due to a time limit and whether a feasible point was returned.
  • FeasibilityTol and OptimalityTol affect numerical acceptance. They are absolute solver tolerances, so interpret them in the model's working coordinates and validate the final SI result.
  • NumericFocus asks Gurobi to spend more effort on numerical reliability. It can help diagnose scaling-sensitive cases, but per-unit modeling should be tested first.
  • Threads and Presolve are useful controlled performance experiments. Fix them when comparing solver timings across machines or solver versions.

Gurobi environments and licenses deserve one additional caution. If many models are solved in one process, a shared Gurobi.Env() can avoid acquiring a new license token for every model, but Gurobi environments are not thread-safe. Do not solve models concurrently through the same environment; see the Gurobi.jl environment guidance.

Settings that apply to all three solvers

Start with the model coordinates

per_unit=true is the default and is usually the first conditioning experiment to keep. Use per_unit=false when you specifically need a raw-SI comparison, not as a general way to improve a solve. For controlled coordinate experiments, use OpfScaling and record the resulting diagnostic metadata.

pu = solve_opf(net; optimizer = Ipopt.Optimizer, per_unit = true)
si = solve_opf(net; optimizer = Ipopt.Optimizer, per_unit = false)

The two runs should represent the same physical problem. If they produce meaningfully different physical results, inspect conditioning, bounds, solver termination, and residuals before tuning solver parameters.

Use tolerances as an experiment, not a substitute for validation

The engine applies solver options after its own defaults, but it cannot make a solver-specific option portable, and it cannot stop a solver from revisiting an option during optimize! (see the set_silent caveat above). A useful comparison fixes the case, objective, coordinate system, and stopping policy, then changes one solver or one solver setting at a time:

configs = [
    ("ipopt", Ipopt.Optimizer, ["tol" => 1e-8]),
    ("madnlp", MadNLP.Optimizer, ["tol" => 1e-8]),
]

for (name, optimizer, options) in configs
    result = solve_opf(net; optimizer, solver_options = options)
    println(name, ": ", result["termination_status"])
end

Do not mix solver-specific options in a portable configuration object unless you filter them by solver. For example, print_level is meaningful to Ipopt and MadNLP, while Gurobi uses OutputFlag.

A practical solver-selection workflow

  1. Start with Ipopt in per-unit coordinates and validate the solution.
  2. Run MadNLP on the same case when you want an independent local-NLP result or want to investigate linear-solver performance.
  3. Try ExaModels with NLPModelsIpopt when you want to check the alternate JuMP-to-ExaModel modeling and derivative path on a compatible case.
  4. Try Gurobi when the model is in the quadratic-compatible subset and a Gurobi solve is useful as a cross-check or part of your deployment stack.
  5. Compare objective, termination status, physical residuals, bound activity, and runtime. Keep the solver name, package versions, options, coordinate system, and result validation with the benchmark record.

For a full discussion of what the engine does and does not model, see the OPF engine scope and status page. For the result fields and status conventions, see OPF result dictionary.