From 3860205c156619f504220409d922c708f790cf11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Legat?= Date: Thu, 13 Aug 2026 08:30:09 +0000 Subject: [PATCH 1/4] Use MOI.Nonlinear.QPBlockData Replace the objective union field, the four vectors of affine and quadratic constraints, and their bespoke evaluation helpers (_fill_gradient, _fill_jacobian, _fill_result) with a single MOI.Nonlinear.QPBlockData, which also answers the per-constraint-type MOI attribute queries. The equality and inequality callbacks evaluate the block once and scatter the rows into NLopt's separate equality and inequality blocks. --- ext/NLoptMathOptInterfaceExt.jl | 282 +++++++++----------------------- 1 file changed, 74 insertions(+), 208 deletions(-) diff --git a/ext/NLoptMathOptInterfaceExt.jl b/ext/NLoptMathOptInterfaceExt.jl index 050b86c..198ac62 100644 --- a/ext/NLoptMathOptInterfaceExt.jl +++ b/ext/NLoptMathOptInterfaceExt.jl @@ -16,11 +16,6 @@ function __init__() return end -mutable struct _ConstraintInfo{F,S} - func::F - set::S -end - """ Optimizer() @@ -34,33 +29,11 @@ mutable struct Optimizer <: MOI.AbstractOptimizer nlp_model::Union{Nothing,MOI.Nonlinear.Model} ad_backend::MOI.Nonlinear.AbstractAutomaticDifferentiation sense::Union{Nothing,MOI.OptimizationSense} - objective::Union{ - MOI.VariableIndex, - MOI.ScalarAffineFunction{Float64}, - MOI.ScalarQuadraticFunction{Float64}, - Nothing, - } - linear_le_constraints::Vector{ - _ConstraintInfo{ - MOI.ScalarAffineFunction{Float64}, - MOI.LessThan{Float64}, - }, - } - linear_eq_constraints::Vector{ - _ConstraintInfo{MOI.ScalarAffineFunction{Float64},MOI.EqualTo{Float64}}, - } - quadratic_le_constraints::Vector{ - _ConstraintInfo{ - MOI.ScalarQuadraticFunction{Float64}, - MOI.LessThan{Float64}, - }, - } - quadratic_eq_constraints::Vector{ - _ConstraintInfo{ - MOI.ScalarQuadraticFunction{Float64}, - MOI.EqualTo{Float64}, - }, - } + # The affine and quadratic objective and constraints. `has_objective` + # tracks whether an objective was explicitly set, because `qp_data` + # defaults to a zero objective. + has_objective::Bool + qp_data::MOI.Nonlinear.QPBlockData{Float64} # Parameters. silent::Bool options::Dict{String,Any} @@ -79,23 +52,8 @@ mutable struct Optimizer <: MOI.AbstractOptimizer nothing, MOI.Nonlinear.SparseReverseMode(), nothing, - nothing, - _ConstraintInfo{ - MOI.ScalarAffineFunction{Float64}, - MOI.LessThan{Float64}, - }[], - _ConstraintInfo{ - MOI.ScalarAffineFunction{Float64}, - MOI.EqualTo{Float64}, - }[], - _ConstraintInfo{ - MOI.ScalarQuadraticFunction{Float64}, - MOI.LessThan{Float64}, - }[], - _ConstraintInfo{ - MOI.ScalarQuadraticFunction{Float64}, - MOI.EqualTo{Float64}, - }[], + false, + MOI.Nonlinear.QPBlockData{Float64}(), false, copy(_DEFAULT_OPTIONS), NaN, @@ -121,11 +79,8 @@ function MOI.empty!(model::Optimizer) model.nlp_data = MOI.NLPBlockData([], _EmptyNLPEvaluator(), false) model.nlp_model = nothing model.sense = nothing - model.objective = nothing - empty!(model.linear_le_constraints) - empty!(model.linear_eq_constraints) - empty!(model.quadratic_le_constraints) - empty!(model.quadratic_eq_constraints) + model.has_objective = false + model.qp_data = MOI.Nonlinear.QPBlockData{Float64}() model.status = :NOT_CALLED return end @@ -136,10 +91,8 @@ function MOI.is_empty(model::Optimizer) model.nlp_data.evaluator isa _EmptyNLPEvaluator && model.nlp_model === nothing && model.sense == nothing && - isempty(model.linear_le_constraints) && - isempty(model.linear_eq_constraints) && - isempty(model.quadratic_le_constraints) && - isempty(model.quadratic_eq_constraints) + !model.has_objective && + length(model.qp_data) == 0 end function MOI.get(model::Optimizer, ::MOI.ListOfModelAttributesSet) @@ -147,7 +100,7 @@ function MOI.get(model::Optimizer, ::MOI.ListOfModelAttributesSet) if model.sense !== nothing push!(ret, MOI.ObjectiveSense()) end - if model.objective !== nothing + if model.has_objective F = MOI.get(model, MOI.ObjectiveFunctionType()) push!(ret, MOI.ObjectiveFunction{F}()) end @@ -164,38 +117,6 @@ MOI.get(::Optimizer, ::MOI.SolverName) = "NLopt" MOI.get(::Optimizer, ::MOI.SolverVersion) = "$(NLopt.version())" -function _constraints( - model, - ::Type{<:MOI.ScalarAffineFunction}, - ::Type{<:MOI.LessThan}, -) - return model.linear_le_constraints -end - -function _constraints( - model, - ::Type{<:MOI.ScalarAffineFunction}, - ::Type{<:MOI.EqualTo}, -) - return model.linear_eq_constraints -end - -function _constraints( - model, - ::Type{<:MOI.ScalarQuadraticFunction}, - ::Type{<:MOI.LessThan}, -) - return model.quadratic_le_constraints -end - -function _constraints( - model, - ::Type{<:MOI.ScalarQuadraticFunction}, - ::Type{<:MOI.EqualTo}, -) - return model.quadratic_eq_constraints -end - function MOI.supports_constraint( ::Optimizer, ::Type{ @@ -219,20 +140,12 @@ function MOI.get( }, S<:Union{MOI.LessThan{Float64},MOI.EqualTo{Float64}}, } - return length(_constraints(model, F, S)) + return MOI.get(model.qp_data, MOI.NumberOfConstraints{F,S}()) end function MOI.get(model::Optimizer, attr::MOI.ListOfConstraintTypesPresent) constraints = MOI.get(model.variables, attr) - function _check(model, F, S) - if !isempty(_constraints(model, F, S)) - push!(constraints, (F, S)) - end - end - _check(model, MOI.ScalarAffineFunction{Float64}, MOI.LessThan{Float64}) - _check(model, MOI.ScalarAffineFunction{Float64}, MOI.EqualTo{Float64}) - _check(model, MOI.ScalarQuadraticFunction{Float64}, MOI.LessThan{Float64}) - _check(model, MOI.ScalarQuadraticFunction{Float64}, MOI.EqualTo{Float64}) + append!(constraints, MOI.get(model.qp_data, attr)) return constraints end @@ -246,7 +159,7 @@ function MOI.get( }, S<:Union{MOI.LessThan{Float64},MOI.EqualTo{Float64}}, } - return MOI.ConstraintIndex{F,S}.(eachindex(_constraints(model, F, S))) + return MOI.get(model.qp_data, MOI.ListOfConstraintIndices{F,S}()) end function MOI.get( @@ -260,7 +173,7 @@ function MOI.get( }, S<:Union{MOI.LessThan{Float64},MOI.EqualTo{Float64}}, } - return copy(_constraints(model, F, S)[c.value].func) + return MOI.get(model.qp_data, MOI.ConstraintFunction(), c) end function MOI.get( @@ -274,7 +187,7 @@ function MOI.get( }, S<:Union{MOI.LessThan{Float64},MOI.EqualTo{Float64}}, } - return _constraints(model, F, S)[c.value].set + return MOI.get(model.qp_data, MOI.ConstraintSet(), c) end # ObjectiveSense @@ -462,7 +375,7 @@ function MOI.is_valid( }, S<:Union{MOI.LessThan{Float64},MOI.EqualTo{Float64}}, } - return 1 <= ci.value <= length(_constraints(model, F, S)) + return MOI.is_valid(model.qp_data, ci) end function _check_inbounds(model, f::MOI.VariableIndex) @@ -499,9 +412,7 @@ function MOI.add_constraint( S<:Union{MOI.LessThan{Float64},MOI.EqualTo{Float64}}, } _check_inbounds(model, func) - constraints = _constraints(model, F, S) - push!(constraints, _ConstraintInfo(func, set)) - return MOI.ConstraintIndex{F,S}(length(constraints)) + return MOI.add_constraint(model.qp_data, func, set) end # MOI.VariablePrimalStart @@ -564,12 +475,14 @@ end function MOI.get(model::Optimizer, ::MOI.ObjectiveFunctionType) if model.nlp_model !== nothing && model.nlp_model.objective !== nothing return MOI.ScalarNonlinearFunction + elseif !model.has_objective + return Nothing end - return typeof(model.objective) + return MOI.get(model.qp_data, MOI.ObjectiveFunctionType()) end -function MOI.get(model::Optimizer, ::MOI.ObjectiveFunction{F}) where {F} - return convert(F, model.objective)::F +function MOI.get(model::Optimizer, attr::MOI.ObjectiveFunction{F}) where {F} + return MOI.get(model.qp_data, attr)::F end function MOI.set( @@ -584,7 +497,8 @@ function MOI.set( }, } _check_inbounds(model, func) - model.objective = func + model.has_objective = true + MOI.set(model.qp_data, MOI.ObjectiveFunction{F}(), func) if model.nlp_model !== nothing MOI.Nonlinear.set_objective(model.nlp_model, nothing) end @@ -709,78 +623,6 @@ end # optimize! -function _fill_gradient(grad, x, f::MOI.VariableIndex) - grad[f.value] = 1.0 - return -end - -function _fill_gradient(grad, x, f::MOI.ScalarAffineFunction{Float64}) - for term in f.terms - grad[term.variable.value] += term.coefficient - end - return -end - -function _fill_gradient(grad, x, f::MOI.ScalarQuadraticFunction{Float64}) - for term in f.affine_terms - grad[term.variable.value] += term.coefficient - end - for term in f.quadratic_terms - i, j = term.variable_1.value, term.variable_2.value - grad[i] += term.coefficient * x[j] - if i != j - grad[j] += term.coefficient * x[i] - end - end - return -end - -function _fill_result(result::Vector, x, offset, constraints::Vector) - for (i, constraint) in enumerate(constraints) - lhs = MOI.Utilities.eval_variables(vi -> x[vi.value], constraint.func) - result[offset+i] = lhs - MOI.constant(constraint.set) - end - return -end - -function _fill_jacobian(jac, x, offset, term::MOI.ScalarAffineTerm) - jac[term.variable.value, offset] += term.coefficient - return -end - -function _fill_jacobian(jac, x, offset, term::MOI.ScalarQuadraticTerm) - i, j = term.variable_1.value, term.variable_2.value - jac[i, offset] += term.coefficient * x[j] - if i != j - jac[j, offset] += term.coefficient * x[i] - end - return -end - -function _fill_jacobian(jac, x, offset, f::MOI.ScalarAffineFunction) - for term in f.terms - _fill_jacobian(jac, x, offset, term) - end - return -end - -function _fill_jacobian(jac, x, offset, f::MOI.ScalarQuadraticFunction) - for term in f.affine_terms - _fill_jacobian(jac, x, offset, term) - end - for q_term in f.quadratic_terms - _fill_jacobian(jac, x, offset, q_term) - end - return -end - -function _fill_jacobian(jac, x, offset, constraints::Vector) - for (i, constraint) in enumerate(constraints) - _fill_jacobian(jac, x, offset + i, constraint.func) - end - return -end - function objective_fn(model::Optimizer, x::Vector, grad::Vector) # The order of the conditions is important. NLP objectives override regular # objectives. @@ -790,16 +632,16 @@ function objective_fn(model::Optimizer, x::Vector, grad::Vector) # nothing elseif model.nlp_data.has_objective MOI.eval_objective_gradient(model.nlp_data.evaluator, grad, x) - elseif model.objective !== nothing - _fill_gradient(grad, x, model.objective) + elseif model.has_objective + MOI.eval_objective_gradient(model.qp_data, grad, x) end end if model.sense == MOI.FEASIBILITY_SENSE return 0.0 elseif model.nlp_data.has_objective return MOI.eval_objective(model.nlp_data.evaluator, x) - elseif model.objective !== nothing - return MOI.Utilities.eval_variables(vi -> x[vi.value], model.objective) + elseif model.has_objective + return MOI.eval_objective(model.qp_data, x) end # No ObjectiveFunction is set, but ObjectiveSense is? return 0.0 @@ -898,6 +740,22 @@ function MOI.optimize!(model::Optimizer) end Jac_val = zeros(length(Jac_IJ)) g_vec = zeros(num_nlpblock_constraints) + # The affine and quadratic constraints, in the row order of `qp_data`. + # `LessThan` rows have a lower bound of -Inf, so a row is an equality + # constraint if and only if the bounds are equal. + qp_eq_pos, qp_le_pos = zeros(Int, length(model.qp_data)), zeros(Int, length(model.qp_data)) + for row in 1:length(model.qp_data) + if model.qp_data.g_L[row] == model.qp_data.g_U[row] + qp_eq_pos[row] = count(!iszero, qp_eq_pos) + 1 + else + qp_le_pos[row] = count(!iszero, qp_le_pos) + 1 + end + end + num_qp_eq = count(!iszero, qp_eq_pos) + num_qp_le = count(!iszero, qp_le_pos) + qp_Jac_IJ = MOI.jacobian_structure(model.qp_data) + qp_Jac_val = zeros(length(qp_Jac_IJ)) + qp_g = zeros(length(model.qp_data)) function equality_constraint_fn(result::Vector, x::Vector, jac::Matrix) if length(jac) > 0 fill!(jac, 0.0) @@ -909,9 +767,12 @@ function MOI.optimize!(model::Optimizer) end end offset = length(nonlinear_equality_indices) - _fill_jacobian(jac, x, offset, model.linear_eq_constraints) - offset += length(model.linear_eq_constraints) - _fill_jacobian(jac, x, offset, model.quadratic_eq_constraints) + MOI.eval_constraint_jacobian(model.qp_data, qp_Jac_val, x) + for ((row, col), val) in zip(qp_Jac_IJ, qp_Jac_val) + if qp_eq_pos[row] > 0 + jac[col, offset+qp_eq_pos[row]] += val + end + end end MOI.eval_constraint(model.nlp_data.evaluator, g_vec, x) for (i, index) in enumerate(nonlinear_equality_indices) @@ -919,15 +780,16 @@ function MOI.optimize!(model::Optimizer) result[i] = g_vec[index] - bounds.upper end offset = length(nonlinear_equality_indices) - _fill_result(result, x, offset, model.linear_eq_constraints) - offset += length(model.linear_eq_constraints) - _fill_result(result, x, offset, model.quadratic_eq_constraints) + MOI.eval_constraint(model.qp_data, qp_g, x) + for row in 1:length(model.qp_data) + if qp_eq_pos[row] > 0 + result[offset+qp_eq_pos[row]] = + qp_g[row] - model.qp_data.g_U[row] + end + end return end - num_equality_constraints = - length(nonlinear_equality_indices) + - length(model.linear_eq_constraints) + - length(model.quadratic_eq_constraints) + num_equality_constraints = length(nonlinear_equality_indices) + num_qp_eq if num_equality_constraints > 0 NLopt.equality_constraint!( model.inner, @@ -957,9 +819,12 @@ function MOI.optimize!(model::Optimizer) end end offset = num_nlpblock_inequalities - _fill_jacobian(jac, x, offset, model.linear_le_constraints) - offset += length(model.linear_le_constraints) - _fill_jacobian(jac, x, offset, model.quadratic_le_constraints) + MOI.eval_constraint_jacobian(model.qp_data, qp_Jac_val, x) + for ((row, col), val) in zip(qp_Jac_IJ, qp_Jac_val) + if qp_le_pos[row] > 0 + jac[col, offset+qp_le_pos[row]] += val + end + end end # Fill in the result. The first entries are from NLPBlock, and the value # of g(x) is placed in g_vec. @@ -979,15 +844,16 @@ function MOI.optimize!(model::Optimizer) end end offset = num_nlpblock_inequalities - _fill_result(result, x, offset, model.linear_le_constraints) - offset += length(model.linear_le_constraints) - _fill_result(result, x, offset, model.quadratic_le_constraints) + MOI.eval_constraint(model.qp_data, qp_g, x) + for row in 1:length(model.qp_data) + if qp_le_pos[row] > 0 + result[offset+qp_le_pos[row]] = + qp_g[row] - model.qp_data.g_U[row] + end + end return end - num_inequality_constraints = - num_nlpblock_inequalities + - length(model.linear_le_constraints) + - length(model.quadratic_le_constraints) + num_inequality_constraints = num_nlpblock_inequalities + num_qp_le if num_inequality_constraints > 0 NLopt.inequality_constraint!( model.inner, From 741c46979380fa7fae368d7948742a3c339dc49b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Legat?= Date: Thu, 13 Aug 2026 12:23:45 +0200 Subject: [PATCH 2/4] MOi --- .github/workflows/ci.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9cf2cf2..70878b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,13 @@ jobs: version: ${{ matrix.version }} arch: ${{ matrix.arch }} - uses: julia-actions/cache@v1 + - name: MOI + shell: julia --project=@. {0} + run: | + using Pkg + Pkg.add([ + PackageSpec(name="MathOptInterface", rev="bl/qp_block_data"), + ]) - uses: julia-actions/julia-buildpkg@v1 - uses: julia-actions/julia-runtest@v1 with: From 68b290a4fbe7efc758fe8ff646444ba546b83570 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Legat?= Date: Fri, 21 Aug 2026 07:20:06 +0200 Subject: [PATCH 3/4] Use MOI.Nonlinear.ModelWithQuad instead of QPBlockData (#276) * Use MOI.Nonlinear.ModelWithQuad instead of QPBlockData Store the affine and quadratic objective and constraints in a MOI.Nonlinear.ModelWithQuad whose inner model is the nonlinear model, and build a single MOI.Nonlinear.EvaluatorWithQuad in optimize! that stacks the quadratic rows before the NLPBlock rows. The equality/inequality classification and the Jacobian assembly are now uniform over all rows, which removes the separate QP scatter machinery (qp_eq_pos/qp_le_pos) and the direct use of the private product functions of QPBlockData. * MOI * Set the quadratic objective with Nonlinear.set_objective MOI.Nonlinear.ModelWithQuad no longer implements MOI.set for the objective; Nonlinear.set_objective is the single way to set it. * Merge the variables and the nonlinear model into one field MOI.Nonlinear.ModelWithQuad now owns the variables (with indices guaranteed to be 1:n), so the variables, quad_data and nlp_model fields collapse into a single model field. A uses_nlp_block flag replaces the nlp_model !== nothing test to tell the legacy MOI.NLPBlock API apart, because optimize! overwrites nlp_data; the NLPBlock is rebuilt on every solve unless that flag is set, so that a stale objective cannot survive an objective switch. --- .github/workflows/ci.yml | 2 +- ext/NLoptMathOptInterfaceExt.jl | 286 ++++++++++++++------------------ 2 files changed, 124 insertions(+), 164 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 70878b4..fa6e6ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,7 +37,7 @@ jobs: run: | using Pkg Pkg.add([ - PackageSpec(name="MathOptInterface", rev="bl/qp_block_data"), + PackageSpec(name="MathOptInterface", rev="bl/model_with_quad"), ]) - uses: julia-actions/julia-buildpkg@v1 - uses: julia-actions/julia-runtest@v1 diff --git a/ext/NLoptMathOptInterfaceExt.jl b/ext/NLoptMathOptInterfaceExt.jl index 198ac62..7047bcf 100644 --- a/ext/NLoptMathOptInterfaceExt.jl +++ b/ext/NLoptMathOptInterfaceExt.jl @@ -23,17 +23,19 @@ Create a new Optimizer object. """ mutable struct Optimizer <: MOI.AbstractOptimizer inner::Union{NLopt.Opt,Nothing} - variables::MOI.Utilities.VariablesContainer{Float64} + # The variables, the affine and quadratic objective and constraints, and + # the inner nonlinear model. + model::MOI.Nonlinear.ModelWithQuad{Float64,MOI.Nonlinear.Model} starting_values::Vector{Union{Nothing,Float64}} nlp_data::MOI.NLPBlockData - nlp_model::Union{Nothing,MOI.Nonlinear.Model} + # Whether `nlp_data` was set through the legacy `MOI.NLPBlock` API, in + # which case it must not be rebuilt from the inner nonlinear model. + uses_nlp_block::Bool ad_backend::MOI.Nonlinear.AbstractAutomaticDifferentiation sense::Union{Nothing,MOI.OptimizationSense} - # The affine and quadratic objective and constraints. `has_objective` - # tracks whether an objective was explicitly set, because `qp_data` - # defaults to a zero objective. + # Whether an objective was explicitly set, because the QP block defaults + # to a zero objective. has_objective::Bool - qp_data::MOI.Nonlinear.QPBlockData{Float64} # Parameters. silent::Bool options::Dict{String,Any} @@ -46,14 +48,13 @@ mutable struct Optimizer <: MOI.AbstractOptimizer function Optimizer() return new( nothing, - MOI.Utilities.VariablesContainer{Float64}(), + MOI.Nonlinear.ModelWithQuad(MOI.Nonlinear.Model()), Union{Nothing,Float64}[], MOI.NLPBlockData([], _EmptyNLPEvaluator(), false), - nothing, + false, MOI.Nonlinear.SparseReverseMode(), nothing, false, - MOI.Nonlinear.QPBlockData{Float64}(), false, copy(_DEFAULT_OPTIONS), NaN, @@ -68,31 +69,34 @@ struct _EmptyNLPEvaluator <: MOI.AbstractNLPEvaluator end MOI.initialize(::_EmptyNLPEvaluator, ::Vector{Symbol}) = nothing +MOI.features_available(::_EmptyNLPEvaluator) = [:Grad, :Jac] + +MOI.jacobian_structure(::_EmptyNLPEvaluator) = Tuple{Int64,Int64}[] + MOI.eval_constraint(::_EmptyNLPEvaluator, g, x) = nothing MOI.eval_constraint_jacobian(::_EmptyNLPEvaluator, J, x) = nothing function MOI.empty!(model::Optimizer) model.inner = nothing - MOI.empty!(model.variables) + model.model = MOI.Nonlinear.ModelWithQuad(MOI.Nonlinear.Model()) empty!(model.starting_values) model.nlp_data = MOI.NLPBlockData([], _EmptyNLPEvaluator(), false) - model.nlp_model = nothing + model.uses_nlp_block = false model.sense = nothing model.has_objective = false - model.qp_data = MOI.Nonlinear.QPBlockData{Float64}() model.status = :NOT_CALLED return end function MOI.is_empty(model::Optimizer) - return MOI.is_empty(model.variables) && + return MOI.is_empty(model.model.variables) && isempty(model.starting_values) && - model.nlp_data.evaluator isa _EmptyNLPEvaluator && - model.nlp_model === nothing && + !model.uses_nlp_block && + MOI.is_empty(model.model.inner) && model.sense == nothing && !model.has_objective && - length(model.qp_data) == 0 + length(model.model) == 0 end function MOI.get(model::Optimizer, ::MOI.ListOfModelAttributesSet) @@ -140,12 +144,12 @@ function MOI.get( }, S<:Union{MOI.LessThan{Float64},MOI.EqualTo{Float64}}, } - return MOI.get(model.qp_data, MOI.NumberOfConstraints{F,S}()) + return MOI.get(model.model, MOI.NumberOfConstraints{F,S}()) end function MOI.get(model::Optimizer, attr::MOI.ListOfConstraintTypesPresent) - constraints = MOI.get(model.variables, attr) - append!(constraints, MOI.get(model.qp_data, attr)) + constraints = MOI.get(model.model.variables, attr) + append!(constraints, MOI.get(model.model, attr)) return constraints end @@ -159,7 +163,7 @@ function MOI.get( }, S<:Union{MOI.LessThan{Float64},MOI.EqualTo{Float64}}, } - return MOI.get(model.qp_data, MOI.ListOfConstraintIndices{F,S}()) + return MOI.get(model.model, MOI.ListOfConstraintIndices{F,S}()) end function MOI.get( @@ -173,7 +177,7 @@ function MOI.get( }, S<:Union{MOI.LessThan{Float64},MOI.EqualTo{Float64}}, } - return MOI.get(model.qp_data, MOI.ConstraintFunction(), c) + return MOI.get(model.model, MOI.ConstraintFunction(), c) end function MOI.get( @@ -187,7 +191,7 @@ function MOI.get( }, S<:Union{MOI.LessThan{Float64},MOI.EqualTo{Float64}}, } - return MOI.get(model.qp_data, MOI.ConstraintSet(), c) + return MOI.get(model.model, MOI.ConstraintSet(), c) end # ObjectiveSense @@ -285,7 +289,7 @@ end function MOI.add_variable(model::Optimizer) push!(model.starting_values, nothing) - return MOI.add_variable(model.variables) + return MOI.add_variable(model.model.variables) end function MOI.supports_constraint( @@ -312,7 +316,7 @@ function MOI.get( MOI.ListOfConstraintIndices{MOI.VariableIndex}, }, ) - return MOI.get(model.variables, attr) + return MOI.get(model.model.variables, attr) end function MOI.get( @@ -320,14 +324,14 @@ function MOI.get( attr::Union{MOI.ConstraintFunction,MOI.ConstraintSet}, ci::MOI.ConstraintIndex{MOI.VariableIndex}, ) - return MOI.get(model.variables, attr, ci) + return MOI.get(model.model.variables, attr, ci) end function MOI.is_valid( model::Optimizer, index::Union{MOI.VariableIndex,MOI.ConstraintIndex{MOI.VariableIndex}}, ) - return MOI.is_valid(model.variables, index) + return MOI.is_valid(model.model.variables, index) end function MOI.add_constraint( @@ -340,7 +344,7 @@ function MOI.add_constraint( MOI.Interval{Float64}, }, ) - return MOI.add_constraint(model.variables, vi, set) + return MOI.add_constraint(model.model.variables, vi, set) end function MOI.set( @@ -349,14 +353,14 @@ function MOI.set( ci::MOI.ConstraintIndex{MOI.VariableIndex,S}, set::S, ) where {S} - return MOI.set(model.variables, attr, ci, set) + return MOI.set(model.model.variables, attr, ci, set) end function MOI.delete( model::Optimizer, ci::MOI.ConstraintIndex{MOI.VariableIndex}, ) - return MOI.delete(model.variables, ci) + return MOI.delete(model.model.variables, ci) end # constraints @@ -375,7 +379,7 @@ function MOI.is_valid( }, S<:Union{MOI.LessThan{Float64},MOI.EqualTo{Float64}}, } - return MOI.is_valid(model.qp_data, ci) + return MOI.is_valid(model.model, ci) end function _check_inbounds(model, f::MOI.VariableIndex) @@ -412,7 +416,7 @@ function MOI.add_constraint( S<:Union{MOI.LessThan{Float64},MOI.EqualTo{Float64}}, } _check_inbounds(model, func) - return MOI.add_constraint(model.qp_data, func, set) + return MOI.add_constraint(model.model, func, set) end # MOI.VariablePrimalStart @@ -450,10 +454,11 @@ end MOI.supports(::Optimizer, ::MOI.NLPBlock) = true function MOI.set(model::Optimizer, ::MOI.NLPBlock, nlp_data::MOI.NLPBlockData) - if model.nlp_model !== nothing + if !MOI.is_empty(model.model.inner) error("Cannot mix the new and legacy nonlinear APIs") end model.nlp_data = nlp_data + model.uses_nlp_block = !(nlp_data.evaluator isa _EmptyNLPEvaluator) return end @@ -473,16 +478,16 @@ function MOI.supports( end function MOI.get(model::Optimizer, ::MOI.ObjectiveFunctionType) - if model.nlp_model !== nothing && model.nlp_model.objective !== nothing + if model.model.inner.objective !== nothing return MOI.ScalarNonlinearFunction elseif !model.has_objective return Nothing end - return MOI.get(model.qp_data, MOI.ObjectiveFunctionType()) + return MOI.get(model.model, MOI.ObjectiveFunctionType()) end function MOI.get(model::Optimizer, attr::MOI.ObjectiveFunction{F}) where {F} - return MOI.get(model.qp_data, attr)::F + return MOI.get(model.model, attr)::F end function MOI.set( @@ -498,21 +503,17 @@ function MOI.set( } _check_inbounds(model, func) model.has_objective = true - MOI.set(model.qp_data, MOI.ObjectiveFunction{F}(), func) - if model.nlp_model !== nothing - MOI.Nonlinear.set_objective(model.nlp_model, nothing) - end + MOI.Nonlinear.set_objective(model.model, func) return end # ScalarNonlinearFunction -function _init_nlp_model(model) - if model.nlp_model === nothing - if !(model.nlp_data.evaluator isa _EmptyNLPEvaluator) - error("Cannot mix the new and legacy nonlinear APIs") - end - model.nlp_model = MOI.Nonlinear.Model() +# The nonlinear model is `model.model.inner` and always exists; this guard +# only rejects mixing it with the legacy `MOI.NLPBlock` API. +function _check_no_nlp_block(model) + if model.uses_nlp_block + error("Cannot mix the new and legacy nonlinear APIs") end return end @@ -528,11 +529,8 @@ function MOI.is_valid( MOI.Interval{Float64}, }, } - if model.nlp_model === nothing - return false - end index = MOI.Nonlinear.ConstraintIndex(ci.value) - return MOI.is_valid(model.nlp_model, index) + return MOI.is_valid(model.model.inner, index) end function MOI.supports_constraint( @@ -560,8 +558,8 @@ function MOI.add_constraint( MOI.Interval{Float64}, }, ) - _init_nlp_model(model) - index = MOI.Nonlinear.add_constraint(model.nlp_model, f, set) + _check_no_nlp_block(model) + index = MOI.Nonlinear.add_constraint(model.model.inner, f, set) return MOI.ConstraintIndex{typeof(f),typeof(set)}(index.value) end @@ -577,8 +575,8 @@ function MOI.set( attr::MOI.ObjectiveFunction{MOI.ScalarNonlinearFunction}, func::MOI.ScalarNonlinearFunction, ) - _init_nlp_model(model) - MOI.Nonlinear.set_objective(model.nlp_model, func) + _check_no_nlp_block(model) + MOI.Nonlinear.set_objective(model.model, func) return end @@ -604,9 +602,9 @@ end MOI.supports(model::Optimizer, ::MOI.UserDefinedFunction) = true function MOI.set(model::Optimizer, attr::MOI.UserDefinedFunction, args) - _init_nlp_model(model) + _check_no_nlp_block(model) MOI.Nonlinear.register_operator( - model.nlp_model, + model.model.inner, attr.name, attr.arity, args..., @@ -617,34 +615,34 @@ end ### MOI.ListOfSupportedNonlinearOperators function MOI.get(model::Optimizer, attr::MOI.ListOfSupportedNonlinearOperators) - _init_nlp_model(model) - return MOI.get(model.nlp_model, attr) + _check_no_nlp_block(model) + return MOI.get(model.model.inner, attr) end # optimize! -function objective_fn(model::Optimizer, x::Vector, grad::Vector) - # The order of the conditions is important. NLP objectives override regular - # objectives. +function objective_fn(model::Optimizer, evaluator, x::Vector, grad::Vector) + # The objective sink of `evaluator` routes between the QP block and the + # inner nonlinear model, and evaluates to zero if no objective is set. A + # legacy NLPBlock objective is evaluated directly, because the sink does + # not know about it. + legacy_nlp = model.uses_nlp_block && model.nlp_data.has_objective if length(grad) > 0 fill!(grad, 0.0) if model.sense == MOI.FEASIBILITY_SENSE # nothing - elseif model.nlp_data.has_objective + elseif legacy_nlp MOI.eval_objective_gradient(model.nlp_data.evaluator, grad, x) - elseif model.has_objective - MOI.eval_objective_gradient(model.qp_data, grad, x) + else + MOI.eval_objective_gradient(evaluator, grad, x) end end if model.sense == MOI.FEASIBILITY_SENSE return 0.0 - elseif model.nlp_data.has_objective + elseif legacy_nlp return MOI.eval_objective(model.nlp_data.evaluator, x) - elseif model.has_objective - return MOI.eval_objective(model.qp_data, x) end - # No ObjectiveFunction is set, but ObjectiveSense is? - return 0.0 + return MOI.eval_objective(evaluator, x) end function _initialize_options!(model::Optimizer) @@ -689,111 +687,91 @@ function MOI.optimize!(model::Optimizer) num_variables = length(model.starting_values) model.inner = NLopt.Opt(model.options["algorithm"], num_variables) _initialize_options!(model) - if model.nlp_model !== nothing - vars = MOI.VariableIndex.(1:num_variables) + vars = MOI.VariableIndex.(1:num_variables) + # Rebuild even when the inner model is empty: a previous solve may have + # left a stale `nlp_data` (for example, a nonlinear objective replaced by + # a quadratic one). + if !model.uses_nlp_block model.nlp_data = MOI.NLPBlockData( - MOI.Nonlinear.Evaluator(model.nlp_model, model.ad_backend, vars), + MOI.Nonlinear.Evaluator(model.model.inner, model.ad_backend, vars), ) end - NLopt.lower_bounds!(model.inner, model.variables.lower) - NLopt.upper_bounds!(model.inner, model.variables.upper) - nonlinear_equality_indices = findall( - bound -> bound.lower == bound.upper, - model.nlp_data.constraint_bounds, - ) - nonlinear_inequality_indices = findall( - bound -> bound.lower != bound.upper, + # One evaluator for the whole model: the rows of the QP block come first, + # followed by the rows of `model.nlp_data`. + evaluator = + MOI.Nonlinear.EvaluatorWithQuad(model.model, model.nlp_data.evaluator) + constraint_bounds = vcat( + MOI.NLPBoundsPair.(model.model.qp.g_L, model.model.qp.g_U), model.nlp_data.constraint_bounds, ) - num_nlpblock_constraints = length(model.nlp_data.constraint_bounds) + num_constraints = length(constraint_bounds) + NLopt.lower_bounds!(model.inner, model.model.variables.lower) + NLopt.upper_bounds!(model.inner, model.model.variables.upper) + equality_indices = + findall(bound -> bound.lower == bound.upper, constraint_bounds) + inequality_indices = + findall(bound -> bound.lower != bound.upper, constraint_bounds) # map from eqidx/ineqidx to index in equalities/inequalities - constrmap = zeros(Int, num_nlpblock_constraints) - for (i, k) in enumerate(nonlinear_equality_indices) + constrmap = zeros(Int, num_constraints) + for (i, k) in enumerate(equality_indices) constrmap[k] = i end - num_nlpblock_inequalities = 0 - for (i, k) in enumerate(nonlinear_inequality_indices) - num_nlpblock_inequalities += 1 - constrmap[k] = num_nlpblock_inequalities - bounds = model.nlp_data.constraint_bounds[k] + num_inequalities = 0 + for k in inequality_indices + num_inequalities += 1 + constrmap[k] = num_inequalities + bounds = constraint_bounds[k] if !isinf(bounds.lower) && !isinf(bounds.upper) # constraint has bounds on both sides, keep room for it - num_nlpblock_inequalities += 1 + num_inequalities += 1 end end if string(model.options["algorithm"])[2] == 'N' # Derivative free optimizer chosen - MOI.initialize(model.nlp_data.evaluator, Symbol[]) - elseif num_nlpblock_constraints > 0 - MOI.initialize(model.nlp_data.evaluator, [:Grad, :Jac]) + MOI.initialize(evaluator, Symbol[]) + elseif num_constraints > 0 + MOI.initialize(evaluator, [:Grad, :Jac]) else - MOI.initialize(model.nlp_data.evaluator, [:Grad]) + MOI.initialize(evaluator, [:Grad]) end if model.sense == MOI.MAX_SENSE - NLopt.max_objective!(model.inner, (x, g) -> objective_fn(model, x, g)) + NLopt.max_objective!( + model.inner, + (x, g) -> objective_fn(model, evaluator, x, g), + ) else - NLopt.min_objective!(model.inner, (x, g) -> objective_fn(model, x, g)) + NLopt.min_objective!( + model.inner, + (x, g) -> objective_fn(model, evaluator, x, g), + ) end Jac_IJ = Tuple{Int,Int}[] - if num_nlpblock_constraints > 0 - append!(Jac_IJ, MOI.jacobian_structure(model.nlp_data.evaluator)) + if num_constraints > 0 + append!(Jac_IJ, MOI.jacobian_structure(evaluator)) end Jac_val = zeros(length(Jac_IJ)) - g_vec = zeros(num_nlpblock_constraints) - # The affine and quadratic constraints, in the row order of `qp_data`. - # `LessThan` rows have a lower bound of -Inf, so a row is an equality - # constraint if and only if the bounds are equal. - qp_eq_pos, qp_le_pos = zeros(Int, length(model.qp_data)), zeros(Int, length(model.qp_data)) - for row in 1:length(model.qp_data) - if model.qp_data.g_L[row] == model.qp_data.g_U[row] - qp_eq_pos[row] = count(!iszero, qp_eq_pos) + 1 - else - qp_le_pos[row] = count(!iszero, qp_le_pos) + 1 - end - end - num_qp_eq = count(!iszero, qp_eq_pos) - num_qp_le = count(!iszero, qp_le_pos) - qp_Jac_IJ = MOI.jacobian_structure(model.qp_data) - qp_Jac_val = zeros(length(qp_Jac_IJ)) - qp_g = zeros(length(model.qp_data)) + g_vec = zeros(num_constraints) function equality_constraint_fn(result::Vector, x::Vector, jac::Matrix) if length(jac) > 0 fill!(jac, 0.0) - MOI.eval_constraint_jacobian(model.nlp_data.evaluator, Jac_val, x) + MOI.eval_constraint_jacobian(evaluator, Jac_val, x) for ((row, col), val) in zip(Jac_IJ, Jac_val) - bounds = model.nlp_data.constraint_bounds[row] + bounds = constraint_bounds[row] if bounds.lower == bounds.upper jac[col, constrmap[row]] += val end end - offset = length(nonlinear_equality_indices) - MOI.eval_constraint_jacobian(model.qp_data, qp_Jac_val, x) - for ((row, col), val) in zip(qp_Jac_IJ, qp_Jac_val) - if qp_eq_pos[row] > 0 - jac[col, offset+qp_eq_pos[row]] += val - end - end end - MOI.eval_constraint(model.nlp_data.evaluator, g_vec, x) - for (i, index) in enumerate(nonlinear_equality_indices) - bounds = model.nlp_data.constraint_bounds[index] - result[i] = g_vec[index] - bounds.upper - end - offset = length(nonlinear_equality_indices) - MOI.eval_constraint(model.qp_data, qp_g, x) - for row in 1:length(model.qp_data) - if qp_eq_pos[row] > 0 - result[offset+qp_eq_pos[row]] = - qp_g[row] - model.qp_data.g_U[row] - end + MOI.eval_constraint(evaluator, g_vec, x) + for (i, index) in enumerate(equality_indices) + result[i] = g_vec[index] - constraint_bounds[index].upper end return end - num_equality_constraints = length(nonlinear_equality_indices) + num_qp_eq - if num_equality_constraints > 0 + if length(equality_indices) > 0 NLopt.equality_constraint!( model.inner, - num_equality_constraints, + length(equality_indices), equality_constraint_fn, model.options["constrtol_abs"], ) @@ -804,9 +782,9 @@ function MOI.optimize!(model::Optimizer) function inequality_constraint_fn(result::Vector, x::Vector, jac::Matrix) if length(jac) > 0 fill!(jac, 0.0) - MOI.eval_constraint_jacobian(model.nlp_data.evaluator, Jac_val, x) + MOI.eval_constraint_jacobian(evaluator, Jac_val, x) for ((row, col), val) in zip(Jac_IJ, Jac_val) - bounds = model.nlp_data.constraint_bounds[row] + bounds = constraint_bounds[row] if bounds.lower == bounds.upper continue # This is an equality constraint elseif isinf(bounds.lower) # upper bound @@ -818,20 +796,11 @@ function MOI.optimize!(model::Optimizer) jac[col, constrmap[row]+1] -= val end end - offset = num_nlpblock_inequalities - MOI.eval_constraint_jacobian(model.qp_data, qp_Jac_val, x) - for ((row, col), val) in zip(qp_Jac_IJ, qp_Jac_val) - if qp_le_pos[row] > 0 - jac[col, offset+qp_le_pos[row]] += val - end - end end - # Fill in the result. The first entries are from NLPBlock, and the value - # of g(x) is placed in g_vec. - MOI.eval_constraint(model.nlp_data.evaluator, g_vec, x) - for row in 1:num_nlpblock_constraints + MOI.eval_constraint(evaluator, g_vec, x) + for row in 1:num_constraints index = constrmap[row] - bounds = model.nlp_data.constraint_bounds[row] + bounds = constraint_bounds[row] if bounds.lower == bounds.upper continue # This is an equality constraint elseif isinf(bounds.lower) # g(x) <= u --> g(x) - u <= 0 @@ -843,21 +812,12 @@ function MOI.optimize!(model::Optimizer) result[index+1] = bounds.lower - g_vec[row] end end - offset = num_nlpblock_inequalities - MOI.eval_constraint(model.qp_data, qp_g, x) - for row in 1:length(model.qp_data) - if qp_le_pos[row] > 0 - result[offset+qp_le_pos[row]] = - qp_g[row] - model.qp_data.g_U[row] - end - end return end - num_inequality_constraints = num_nlpblock_inequalities + num_qp_le - if num_inequality_constraints > 0 + if num_inequalities > 0 NLopt.inequality_constraint!( model.inner, - num_inequality_constraints, + num_inequalities, inequality_constraint_fn, model.options["constrtol_abs"], ) @@ -865,7 +825,7 @@ function MOI.optimize!(model::Optimizer) # Set MOI.VariablePrimalStart, clamping to bound nearest 0 if not given. model.solution = something.( model.starting_values, - clamp.(0.0, model.variables.lower, model.variables.upper), + clamp.(0.0, model.model.variables.lower, model.model.variables.upper), ) start_time = time() model.objective_value, _, model.status = From 288b939c528da7660387a9d32b645479ce99e5d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Legat?= Date: Fri, 21 Aug 2026 07:32:56 +0200 Subject: [PATCH 4/4] MOI --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa6e6ae..70878b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,7 +37,7 @@ jobs: run: | using Pkg Pkg.add([ - PackageSpec(name="MathOptInterface", rev="bl/model_with_quad"), + PackageSpec(name="MathOptInterface", rev="bl/qp_block_data"), ]) - uses: julia-actions/julia-buildpkg@v1 - uses: julia-actions/julia-runtest@v1