From f3955690ad4e3302627c7aaf47a3a566ed42e6e5 Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Sun, 22 Mar 2026 20:39:03 -0400 Subject: [PATCH 01/59] inital split --- src/variables.jl | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/variables.jl b/src/variables.jl index f96cfe02..e1d2d2b3 100644 --- a/src/variables.jl +++ b/src/variables.jl @@ -424,8 +424,7 @@ function _interrogate_variables(interrogator::Function, nlp::JuMP.GenericNonline for arg in nlp.args _interrogate_variables(interrogator, arg) end - # TODO avoid recursion. See InfiniteOpt.jl for alternate method that avoids stackoverflow errors with deeply nested expressions: - # https://github.com/infiniteopt/InfiniteOpt.jl/blob/cb6dd6ae40fe0144b1dd75da0739ea6e305d5357/src/expressions.jl#L520-L534 + return end From ed2eb87d71d741d643cf594cff140fc5d0b29117 Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Mon, 23 Mar 2026 09:09:23 -0400 Subject: [PATCH 02/59] . --- src/variables.jl | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/variables.jl b/src/variables.jl index e1d2d2b3..f96cfe02 100644 --- a/src/variables.jl +++ b/src/variables.jl @@ -424,7 +424,8 @@ function _interrogate_variables(interrogator::Function, nlp::JuMP.GenericNonline for arg in nlp.args _interrogate_variables(interrogator, arg) end - + # TODO avoid recursion. See InfiniteOpt.jl for alternate method that avoids stackoverflow errors with deeply nested expressions: + # https://github.com/infiniteopt/InfiniteOpt.jl/blob/cb6dd6ae40fe0144b1dd75da0739ea6e305d5357/src/expressions.jl#L520-L534 return end From 9e7211e755da8aa58b554c0cc4e7313c093af4a8 Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Mon, 23 Mar 2026 09:28:15 -0400 Subject: [PATCH 03/59] . --- src/variables.jl | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/variables.jl b/src/variables.jl index f96cfe02..e1d2d2b3 100644 --- a/src/variables.jl +++ b/src/variables.jl @@ -424,8 +424,7 @@ function _interrogate_variables(interrogator::Function, nlp::JuMP.GenericNonline for arg in nlp.args _interrogate_variables(interrogator, arg) end - # TODO avoid recursion. See InfiniteOpt.jl for alternate method that avoids stackoverflow errors with deeply nested expressions: - # https://github.com/infiniteopt/InfiniteOpt.jl/blob/cb6dd6ae40fe0144b1dd75da0739ea6e305d5357/src/expressions.jl#L520-L534 + return end From 616b7faef8dd7745f4c51d325ba46c6d3c049c26 Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Mon, 23 Mar 2026 09:34:01 -0400 Subject: [PATCH 04/59] . --- src/variables.jl | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/variables.jl b/src/variables.jl index e1d2d2b3..f96cfe02 100644 --- a/src/variables.jl +++ b/src/variables.jl @@ -424,7 +424,8 @@ function _interrogate_variables(interrogator::Function, nlp::JuMP.GenericNonline for arg in nlp.args _interrogate_variables(interrogator, arg) end - + # TODO avoid recursion. See InfiniteOpt.jl for alternate method that avoids stackoverflow errors with deeply nested expressions: + # https://github.com/infiniteopt/InfiniteOpt.jl/blob/cb6dd6ae40fe0144b1dd75da0739ea6e305d5357/src/expressions.jl#L520-L534 return end From 6f04419352987f195a6b31ccf7e5b1cd80f670f2 Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Sun, 22 Mar 2026 20:39:03 -0400 Subject: [PATCH 05/59] inital split --- src/utilities.jl | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/utilities.jl b/src/utilities.jl index b0203d70..69d3f4ca 100644 --- a/src/utilities.jl +++ b/src/utilities.jl @@ -129,6 +129,19 @@ function get_constant(expr::JuMP.AbstractVariableRef) return zero(JuMP.value_type(typeof(JuMP.owner_model(expr)))) end +################################################################################ +# ZERO EXPRESSION CONSTRUCTORS +################################################################################ +# Create a type-correct zero affine expression for the model. +_zero_aff(model::JuMP.AbstractModel) = zero( + JuMP.GenericAffExpr{JuMP.value_type(typeof(model)), + JuMP.variable_ref_type(model)}) + +# Create a type-correct zero quadratic expression for the model. +_zero_quad(model::JuMP.AbstractModel) = zero( + JuMP.GenericQuadExpr{JuMP.value_type(typeof(model)), + JuMP.variable_ref_type(model)}) + ################################################################################ # MODEL COPYING ################################################################################ From 2a7b894a4c1c4cb2e9416e717d8261a1a2a62c21 Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Mon, 23 Mar 2026 00:01:36 -0400 Subject: [PATCH 06/59] inital split --- Project.toml | 15 +++++++++------ src/extension_api.jl | 10 +++++----- test/solve.jl | 12 ++++++++++-- 3 files changed, 24 insertions(+), 13 deletions(-) diff --git a/Project.toml b/Project.toml index a5320aa7..3346659a 100644 --- a/Project.toml +++ b/Project.toml @@ -9,25 +9,28 @@ Reexport = "189a3867-3050-52da-a836-e630ba90ab69" [weakdeps] InfiniteOpt = "20393b10-9daf-11e9-18c9-8db751c92c57" +Interpolations = "a98d9a8b-a2ab-59e6-89dd-64a1c18fca59" [extensions] -InfiniteDisjunctiveProgramming = "InfiniteOpt" +InfiniteDisjunctiveProgramming = ["InfiniteOpt", "Interpolations"] [compat] Aqua = "0.8" +InfiniteOpt = "0.6" +Interpolations = "0.16.2" +Ipopt = "1.9.0" JuMP = "1.18" +Juniper = "0.9.3" Reexport = "1" julia = "1.10" -Juniper = "0.9.3" -Ipopt = "1.9.0" -InfiniteOpt = "0.6" [extras] Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" HiGHS = "87dc4568-4c63-4d18-b0c0-bb2238e4078b" -Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" +Interpolations = "a98d9a8b-a2ab-59e6-89dd-64a1c18fca59" Ipopt = "b6b21f68-93f8-5de0-b562-5493be1d77c9" Juniper = "2ddba703-00a4-53a7-87a5-e8b9971dde84" +Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [targets] -test = ["Aqua", "HiGHS", "Test", "Juniper", "Ipopt", "InfiniteOpt"] +test = ["Aqua", "HiGHS", "Test", "Juniper", "Ipopt", "Interpolations"] diff --git a/src/extension_api.jl b/src/extension_api.jl index ad3566f6..6d046450 100644 --- a/src/extension_api.jl +++ b/src/extension_api.jl @@ -1,8 +1,8 @@ """ InfiniteGDPModel(args...; kwargs...) -Creates an `InfiniteOpt.InfiniteModel` that is compatible with the -capabiltiies provided by DisjunctiveProgramming.jl. This requires +Creates an `InfiniteOpt.InfiniteModel` that is compatible with the +capabiltiies provided by DisjunctiveProgramming.jl. This requires that InfiniteOpt be imported first. **Example** @@ -18,9 +18,9 @@ function InfiniteGDPModel end """ InfiniteLogical(prefs...) -Allows users to create infinite logical variables. This is a tag -for the `@variable` macro that is a combination of `InfiniteOpt.Infinite` -and `DisjunctiveProgramming.Logical`. This requires that InfiniteOpt be +Allows users to create infinite logical variables. This is a tag +for the `@variable` macro that is a combination of `InfiniteOpt.Infinite` +and `DisjunctiveProgramming.Logical`. This requires that InfiniteOpt be first imported. **Example** diff --git a/test/solve.jl b/test/solve.jl index c3ca9441..3f9717b3 100644 --- a/test/solve.jl +++ b/test/solve.jl @@ -7,7 +7,7 @@ function test_linear_gdp_example(m, use_complements = false) @variable(m, Y2, Logical, logical_complement = Y1) Y = [Y1, Y2] else - @variable(m, Y[1:2], Logical) + @variable(m, Y[1:3], Logical) end @variable(m, W[1:2], Logical) @objective(m, Max, sum(x)) @@ -16,7 +16,13 @@ function test_linear_gdp_example(m, use_complements = false) @constraint(m, w2[i=1:2], [2,4][i] ≤ x[i] ≤ [3,5][i], Disjunct(W[2])) @constraint(m, y2[i=1:2], [8,1][i] ≤ x[i] ≤ [9,2][i], Disjunct(Y[2])) @disjunction(m, inner, [W[1], W[2]], Disjunct(Y[1])) - @disjunction(m, outer, [Y[1], Y[2]]) + if use_complements + @disjunction(m, outer, [Y[1], Y[2]]) + else + #Infeasible disjunct + @constraint(m, y3[i=1:2], x[i] ≤ [-44,44][i], Disjunct(Y[3])) + @disjunction(m, outer, [Y[1], Y[2], Y[3]]) + end @test optimize!(m, gdp_method = BigM()) isa Nothing @test termination_status(m) == MOI.OPTIMAL @@ -98,6 +104,8 @@ function test_quadratic_gdp_example(use_complements = false) #psplit does not wo @objective(m, Max, sum(x)) @constraint(m, y1_quad, x[1]^2 + x[2]^2 ≤ 16, Disjunct(Y[1])) + # This constraint is always satisfied + @constraint(m, y1_global, x[1] + x[2] ≤ 20, Disjunct(Y[1])) @constraint(m, w1[i=1:2], [1, 2][i] ≤ x[i] ≤ [3, 4][i], Disjunct(W[1])) @constraint(m, w1_quad, x[1]^2 ≥ 2, Disjunct(W[1])) From eab521cf6c60c1c9805bee069bab8785db54576b Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Mon, 20 Apr 2026 17:33:50 -0400 Subject: [PATCH 07/59] . --- test/solve.jl | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/test/solve.jl b/test/solve.jl index 3f9717b3..c3ca9441 100644 --- a/test/solve.jl +++ b/test/solve.jl @@ -7,7 +7,7 @@ function test_linear_gdp_example(m, use_complements = false) @variable(m, Y2, Logical, logical_complement = Y1) Y = [Y1, Y2] else - @variable(m, Y[1:3], Logical) + @variable(m, Y[1:2], Logical) end @variable(m, W[1:2], Logical) @objective(m, Max, sum(x)) @@ -16,13 +16,7 @@ function test_linear_gdp_example(m, use_complements = false) @constraint(m, w2[i=1:2], [2,4][i] ≤ x[i] ≤ [3,5][i], Disjunct(W[2])) @constraint(m, y2[i=1:2], [8,1][i] ≤ x[i] ≤ [9,2][i], Disjunct(Y[2])) @disjunction(m, inner, [W[1], W[2]], Disjunct(Y[1])) - if use_complements - @disjunction(m, outer, [Y[1], Y[2]]) - else - #Infeasible disjunct - @constraint(m, y3[i=1:2], x[i] ≤ [-44,44][i], Disjunct(Y[3])) - @disjunction(m, outer, [Y[1], Y[2], Y[3]]) - end + @disjunction(m, outer, [Y[1], Y[2]]) @test optimize!(m, gdp_method = BigM()) isa Nothing @test termination_status(m) == MOI.OPTIMAL @@ -104,8 +98,6 @@ function test_quadratic_gdp_example(use_complements = false) #psplit does not wo @objective(m, Max, sum(x)) @constraint(m, y1_quad, x[1]^2 + x[2]^2 ≤ 16, Disjunct(Y[1])) - # This constraint is always satisfied - @constraint(m, y1_global, x[1] + x[2] ≤ 20, Disjunct(Y[1])) @constraint(m, w1[i=1:2], [1, 2][i] ≤ x[i] ≤ [3, 4][i], Disjunct(W[1])) @constraint(m, w1_quad, x[1]^2 ≥ 2, Disjunct(W[1])) From a7ad3fd7283e90f33bcda87b6edc13103df986dc Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Mon, 20 Apr 2026 18:33:40 -0400 Subject: [PATCH 08/59] . --- ext/InfiniteDisjunctiveProgramming.jl | 501 +++++++++++++++++++++++--- 1 file changed, 457 insertions(+), 44 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 426f7ac6..831820f7 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -1,24 +1,21 @@ module InfiniteDisjunctiveProgramming import JuMP.MOI as _MOI -import InfiniteOpt, JuMP +import InfiniteOpt, JuMP, Interpolations import DisjunctiveProgramming as DP ################################################################################ # MODEL ################################################################################ function DP.InfiniteGDPModel(args...; kwargs...) - return DP.GDPModel{ - InfiniteOpt.InfiniteModel, - InfiniteOpt.GeneralVariableRef, - InfiniteOpt.InfOptConstraintRef - }(args...; kwargs...) + return DP.GDPModel{InfiniteOpt.InfiniteModel, + InfiniteOpt.GeneralVariableRef, + InfiniteOpt.InfOptConstraintRef}(args...; kwargs...) end function DP.collect_all_vars(model::InfiniteOpt.InfiniteModel) vars = JuMP.all_variables(model) - derivs = InfiniteOpt.all_derivatives(model) - return append!(vars, derivs) + return append!(vars, InfiniteOpt.all_derivatives(model)) end ################################################################################ @@ -26,7 +23,7 @@ end ################################################################################ DP.InfiniteLogical(prefs...) = DP.Logical(InfiniteOpt.Infinite(prefs...)) -_is_parameter(vref::InfiniteOpt.GeneralVariableRef) = +_is_parameter(vref::InfiniteOpt.GeneralVariableRef) = _is_parameter(InfiniteOpt.dispatch_variable_ref(vref)) _is_parameter(::InfiniteOpt.DependentParameterRef) = true _is_parameter(::InfiniteOpt.IndependentParameterRef) = true @@ -41,20 +38,19 @@ end function DP.VariableProperties(vref::InfiniteOpt.GeneralVariableRef) info = DP.get_variable_info(vref) name = JuMP.name(vref) - set = nothing prefs = InfiniteOpt.parameter_refs(vref) var_type = !isempty(prefs) ? InfiniteOpt.Infinite(prefs...) : nothing - return DP.VariableProperties(info, name, set, var_type) + return DP.VariableProperties(info, name, nothing, var_type) end -# Extract parameter refs from expression and return VariableProperties with Infinite type +# Extract parameter refs from expression, return VariableProperties with +# Infinite type. function DP.VariableProperties( expr::Union{ JuMP.GenericAffExpr{C, InfiniteOpt.GeneralVariableRef}, JuMP.GenericQuadExpr{C, InfiniteOpt.GeneralVariableRef}, - JuMP.GenericNonlinearExpr{InfiniteOpt.GeneralVariableRef} - } -) where C + JuMP.GenericNonlinearExpr{InfiniteOpt.GeneralVariableRef}} + ) where C prefs = InfiniteOpt.parameter_refs(expr) info = DP._free_variable_info() var_type = !isempty(prefs) ? InfiniteOpt.Infinite(prefs...) : nothing @@ -66,9 +62,8 @@ function DP.VariableProperties( InfiniteOpt.GeneralVariableRef, JuMP.GenericAffExpr{<:Any, InfiniteOpt.GeneralVariableRef}, JuMP.GenericQuadExpr{<:Any, InfiniteOpt.GeneralVariableRef}, - JuMP.GenericNonlinearExpr{InfiniteOpt.GeneralVariableRef} - }} -) + JuMP.GenericNonlinearExpr{InfiniteOpt.GeneralVariableRef}}} + ) all_prefs = Set{InfiniteOpt.GeneralVariableRef}() for expr in exprs for pref in InfiniteOpt.parameter_refs(expr) @@ -90,9 +85,8 @@ end ################################################################################ function JuMP.add_constraint( model::InfiniteOpt.InfiniteModel, - c::JuMP.VectorConstraint{F, S}, - name::String = "" -) where {F, S <: DP.AbstractCardinalitySet} + c::JuMP.VectorConstraint{F, S}, name::String = "" + ) where {F, S <: DP.AbstractCardinalitySet} return DP._add_cardinality_constraint(model, c, name) end @@ -100,7 +94,7 @@ function JuMP.add_constraint( model::M, c::JuMP.ScalarConstraint{DP._LogicalExpr{M}, S}, name::String = "" -) where {S, M <: InfiniteOpt.InfiniteModel} + ) where {S, M <: InfiniteOpt.InfiniteModel} return DP._add_logical_constraint(model, c, name) end @@ -108,28 +102,29 @@ function JuMP.add_constraint( model::M, c::JuMP.ScalarConstraint{DP.LogicalVariableRef{M}, S}, name::String = "" -) where {M <: InfiniteOpt.InfiniteModel, S} - error("Cannot define constraint on single logical variable, use `fix` instead.") + ) where {M <: InfiniteOpt.InfiniteModel, S} + error("Cannot define constraint on single logical variable, " * + "use `fix` instead.") end function JuMP.add_constraint( model::M, c::JuMP.ScalarConstraint{ - JuMP.GenericAffExpr{C, DP.LogicalVariableRef{M}}, S - }, + JuMP.GenericAffExpr{C, DP.LogicalVariableRef{M}}, S}, name::String = "" -) where {M <: InfiniteOpt.InfiniteModel, S, C} - error("Cannot add, subtract, or multiply with logical variables.") + ) where {M <: InfiniteOpt.InfiniteModel, S, C} + error("Cannot add, subtract, or multiply with " * + "logical variables.") end function JuMP.add_constraint( model::M, c::JuMP.ScalarConstraint{ - JuMP.GenericQuadExpr{C, DP.LogicalVariableRef{M}}, S - }, + JuMP.GenericQuadExpr{C, DP.LogicalVariableRef{M}}, S}, name::String = "" -) where {M <: InfiniteOpt.InfiniteModel, S, C} - error("Cannot add, subtract, or multiply with logical variables.") + ) where {M <: InfiniteOpt.InfiniteModel, S, C} + error("Cannot add, subtract, or multiply with " * + "logical variables.") end ################################################################################ @@ -137,7 +132,7 @@ end ################################################################################ function DP.get_constant( expr::JuMP.GenericAffExpr{T, InfiniteOpt.GeneralVariableRef} -) where {T} + ) where {T} constant = JuMP.constant(expr) param_expr = zero(typeof(expr)) for (var, coeff) in expr.terms @@ -149,16 +144,16 @@ function DP.get_constant( end function DP.disaggregate_expression( - model::M, - aff::JuMP.GenericAffExpr, + model::M, aff::JuMP.GenericAffExpr, bvref::Union{JuMP.AbstractVariableRef, JuMP.GenericAffExpr}, method::DP._Hull -) where {M <: InfiniteOpt.InfiniteModel} + ) where {M <: InfiniteOpt.InfiniteModel} terms = Any[aff.constant * bvref] for (vref, coeff) in aff.terms if JuMP.is_binary(vref) push!(terms, coeff * vref) - elseif vref isa InfiniteOpt.GeneralVariableRef && _is_parameter(vref) + elseif vref isa InfiniteOpt.GeneralVariableRef && + _is_parameter(vref) push!(terms, coeff * vref * bvref) elseif !haskey(method.disjunct_variables, (vref, bvref)) push!(terms, coeff * vref) @@ -170,18 +165,436 @@ function DP.disaggregate_expression( return JuMP.@expression(model, sum(terms)) end +# Quadratic expression: handle parameter x parameter, parameter x variable, +# and variable x variable terms. +function DP.disaggregate_expression( + model::M, quad::JuMP.GenericQuadExpr, + bvref::Union{JuMP.AbstractVariableRef, JuMP.GenericAffExpr}, + method::DP._Hull + ) where {M <: InfiniteOpt.InfiniteModel} + # Affine part (uses InfiniteOpt override above) + new_expr = DP.disaggregate_expression(model, quad.aff, bvref, method) + ϵ = method.value + for (pair, coeff) in quad.terms + a_param = pair.a isa InfiniteOpt.GeneralVariableRef && + _is_parameter(pair.a) + b_param = pair.b isa InfiniteOpt.GeneralVariableRef && + _is_parameter(pair.b) + if a_param && b_param + # param × param: constant, scale by y + new_expr += coeff * pair.a * pair.b * bvref + elseif a_param + # param × var: perspective cancels y + db = method.disjunct_variables[pair.b, bvref] + new_expr += coeff * pair.a * db + elseif b_param + # var × param: perspective cancels y + da = method.disjunct_variables[pair.a, bvref] + new_expr += coeff * da * pair.b + else + # var × var: standard perspective + da = method.disjunct_variables[pair.a, bvref] + db = method.disjunct_variables[pair.b, bvref] + new_expr += coeff * da * db / ((1 - ϵ) * bvref + ϵ) + end + end + return new_expr +end + ################################################################################ -# ERROR MESSAGES +# MBM FOR INFINITEMODEL ################################################################################ -function DP.reformulate_model(::InfiniteOpt.InfiniteModel, ::DP.MBM) - error("The `MBM` method is not supported for `InfiniteModel`." * - "Please use `BigM`, `Hull`, `Indicator`, or `PSplit` instead.") +# Reuses the finite MBM infrastructure by overriding: +# copy_model_with_constraints (build mini InfiniteModel + +# transcribe to flat JuMP model), prepare_max_M_objective +# (expand infinite constraint into K flat objectives via +# _build_flat_map), and aggregate_M_values (interpolate flat +# values to parameter function). + +# Collect all parameter function refs from all disjunct constraints in +# the model. +function _all_param_functions( + model::InfiniteOpt.InfiniteModel + ) + pf_set = Set{InfiniteOpt.GeneralVariableRef}() + for (_, crefs) in DP._indicator_to_constraints(model) + for cref in crefs + cref isa DP.DisjunctConstraintRef || continue + con = JuMP.constraint_object(cref) + for v in InfiniteOpt.all_expression_variables( + con.func) + dv = InfiniteOpt.dispatch_variable_ref(v) + if dv isa InfiniteOpt.ParameterFunctionRef + push!(pf_set, v) + end + end + end + end + return pf_set +end + +# Build a flat map for support point k. Maps decision variables to their +# flat JuMP.VariableRef at support k (handling multi-parameter indexing) +# and evaluates parameter functions to their numerical values. pf_set is +# precomputed by the caller to avoid rescanning all disjunct constraints +# on every support point. +function _build_flat_map( + sub::DP.GDPSubmodel, k::Int, + prefs::Vector{InfiniteOpt.GeneralVariableRef}, + supports::Dict{InfiniteOpt.GeneralVariableRef,Vector{Float64}}, + full_shape::Tuple, + pf_set::Set{InfiniteOpt.GeneralVariableRef} + ) + ci = CartesianIndices(full_shape)[k] + + # Decision variables: map each to its variable-local index + flat_map = Dict{InfiniteOpt.GeneralVariableRef, Any}() + for (v, ws) in sub.fwd_map + if length(ws) == 1 + flat_map[v] = ws[1] + else + vp = InfiniteOpt.parameter_refs(v) + shape = Tuple(length(supports[p]) for p in vp) + idx = Tuple(ci[findfirst(==(p), prefs)] for p in vp) + flat_map[v] = ws[LinearIndices(shape)[idx...]] + end + end + + # Parameter functions: evaluate at support point k + sup_vals = Dict( + prefs[i] => supports[prefs[i]][ci[i]] + for i in 1:length(prefs)) + for pf in pf_set + fn = InfiniteOpt.raw_function(pf) + pf_prefs = InfiniteOpt.parameter_refs(pf) + pf_vals = Tuple(sup_vals[p] for p in pf_prefs) + flat_map[pf] = fn(pf_vals...) + end + return flat_map end -function DP.reformulate_model(::InfiniteOpt.InfiniteModel, ::DP.CuttingPlanes) - error("The `CuttingPlanes` method is not supported for `InfiniteModel`." * - "Please use `BigM`, `Hull`, `Indicator`, or `PSplit` instead.") +# Build mini InfiniteModel with only the given disjunct constraints, +# transcribe to flat JuMP model, return GDPSubmodel with forward map. +function DP.copy_model_with_constraints( + model::InfiniteOpt.InfiniteModel, + constraints::Vector{<:DP.DisjunctConstraintRef}, + method::DP._MBM + ) + mini = InfiniteOpt.InfiniteModel() + ref_map = Dict{InfiniteOpt.GeneralVariableRef,InfiniteOpt.GeneralVariableRef}() + + # 1. Copy infinite parameters with their supports + for p in InfiniteOpt.all_parameters(model) + domain = InfiniteOpt.infinite_domain(p) + sups = Float64.(InfiniteOpt.supports(p)) + param = InfiniteOpt.build_parameter(error, domain; supports = sups) + new_p = InfiniteOpt.add_parameter(mini, param, JuMP.name(p)) + ref_map[p] = new_p + end + + # 2. Copy decision variables with bounds (skip parameters) + for v in JuMP.all_variables(model) + _is_parameter(v) && continue + prefs = InfiniteOpt.parameter_refs(v) + var_type = isempty(prefs) ? nothing : + InfiniteOpt.Infinite(Tuple(ref_map[p] for p in prefs)...) + props = DP.VariableProperties(DP.get_variable_info(v),"", nothing, var_type) + ref_map[v] = DP.create_variable(mini, props) + end + + # 3. Copy derivatives with their bounds + for d in InfiniteOpt.all_derivatives(model) + vref = InfiniteOpt.derivative_argument(d) + pref = InfiniteOpt.operator_parameter(d) + new_d = InfiniteOpt.deriv(ref_map[vref], ref_map[pref]) + info = DP.get_variable_info(d) + info.has_lb && JuMP.set_lower_bound(new_d, info.lower_bound) + info.has_ub && JuMP.set_upper_bound(new_d, info.upper_bound) + ref_map[d] = new_d + end + + # 4. Copy parameter functions from ALL disjuncts (needed for + # constraint transcription) + pf_set = _all_param_functions(model) + for pf in pf_set + fn = InfiniteOpt.raw_function(pf) + prefs = InfiniteOpt.parameter_refs(pf) + mapped_prefs = Tuple(ref_map[p] for p in prefs) + new_pf = _make_parameter_function(mini, fn, mapped_prefs...) + ref_map[pf] = new_pf + end + + # 5. Add disjunct constraints using existing ref_map + for cref in constraints + cref isa DP.DisjunctConstraintRef || continue + con = JuMP.constraint_object(cref) + new_func = DP._replace_variables_in_constraint(con.func, ref_map) + T = one(JuMP.value_type(typeof(mini))) + JuMP.@constraint(mini, new_func * T in con.set) + end + + # 6. Transcribe mini InfiniteModel to flat JuMP model + flat, tr_fwd = transcribe_to_flat(mini) + + # 7. Remap fwd_map: original model var -> flat JuMP VarRef + fwd_map = Dict{InfiniteOpt.GeneralVariableRef, Vector{JuMP.VariableRef}}() + for (orig, mapped) in ref_map + _is_parameter(orig) && continue + haskey(tr_fwd, mapped) || continue + fwd_map[orig] = tr_fwd[mapped] + end + + decision_vars = collect(keys(fwd_map)) + JuMP.set_optimizer(flat, method.optimizer) + JuMP.set_silent(flat) + return DP.GDPSubmodel(flat, decision_vars, fwd_map) end +# Prepare objectives for all support points. Expands an infinite +# constraint into K flat objectives via _build_flat_map with +# multi-parameter indexing and parameter function evaluation. +function DP.prepare_max_M_objective( + model::InfiniteOpt.InfiniteModel, + obj::JuMP.ScalarConstraint{T, S}, + sub::DP.GDPSubmodel + ) where {T, S <: _MOI.LessThan} + prefs, supports = _collect_parameters(model) + full_shape = Tuple(length(supports[p]) for p in prefs) + K = prod(full_shape) + pf_set = _all_param_functions(model) + objectives = Vector{JuMP.AbstractJuMPScalar}(undef, K) + for k in 1:K + flat_map = _build_flat_map(sub, k, prefs, supports, full_shape, pf_set) + objectives[k] = -obj.set.upper + + DP._replace_variables_in_constraint(obj.func, flat_map) + end + return objectives +end +function DP.prepare_max_M_objective( + model::InfiniteOpt.InfiniteModel, + obj::JuMP.ScalarConstraint{T, S}, + sub::DP.GDPSubmodel + ) where {T, S <: _MOI.GreaterThan} + prefs, supports = _collect_parameters(model) + full_shape = Tuple(length(supports[p]) for p in prefs) + K = prod(full_shape) + pf_set = _all_param_functions(model) + objectives = Vector{JuMP.AbstractJuMPScalar}(undef, K) + for k in 1:K + flat_map = _build_flat_map(sub, k, prefs, supports,full_shape, pf_set) + objectives[k] = obj.set.lower - + DP._replace_variables_in_constraint(obj.func, flat_map) + end + return objectives end +# Solve the submodel for a vector of objectives (one per +# support point). Returns aggregated result or nothing. +function DP._raw_M( + sub::DP.GDPSubmodel, + objectives::Vector{<:JuMP.AbstractJuMPScalar}, + method::DP._MBM + ) + M_vals = typeof(method.default_M)[] + for obj_expr in objectives + JuMP.set_start_value.(JuMP.all_variables(sub.model), nothing) + JuMP.@objective(sub.model, Max, obj_expr) + JuMP.optimize!(sub.model) + if JuMP.is_solved_and_feasible(sub.model) + push!(M_vals, max( + JuMP.objective_value(sub.model), + zero(method.default_M))) + elseif JuMP.termination_status(sub.model) == + JuMP.MOI.INFEASIBLE + return nothing + else + push!(M_vals, method.default_M) + end + end + model = JuMP.owner_model( + first(keys(sub.fwd_map))) + return aggregate_M_values(model, M_vals) +end + +# Condense flat per-support values to final form (MBM path). +function aggregate_M_values( + model::InfiniteOpt.InfiniteModel, + vals::AbstractVector{<:Real} + ) + if all(==(vals[1]), vals) + return vals[1] + end + prefs, supports = _collect_parameters(model) + return condense_to_pf(model, vals, prefs, supports) +end + +# Interpolate flat per-support values into a parameter function. Computes +# grids/shape from supports, reshapes, interpolates, and registers on +# the model. +function condense_to_pf( + model::InfiniteOpt.InfiniteModel, + vals::AbstractVector{<:Real}, + prefs::Union{Vector{InfiniteOpt.GeneralVariableRef}, + Tuple{Vararg{InfiniteOpt.GeneralVariableRef}}}, + supports::Dict{InfiniteOpt.GeneralVariableRef, + Vector{Float64}} + ) + grids = Tuple(supports[p] for p in prefs) + shape = Tuple(length(supports[p]) for p in prefs) + nd = reshape(vals, shape) + fn = Interpolations.linear_interpolation(grids, nd, + extrapolation_bc = Interpolations.Line()) + return _make_parameter_function(model, fn, prefs...) +end + +################################################################################ +# TRANSCRIPTION HELPERS +################################################################################ + +# Create a parameter function programmatically. Uses +# build_parameter_function + add_parameter_function (the lower-level +# API behind @parameter_function) since the macro doesn't support +# programmatic use. The closure wrapper handles non-Function callables +# like Interpolations.Extrapolation. Accepts any number of prefs via +# varargs: _make_parameter_function(m, f, t) for 1D, (m, f, t, x) for 2D. +function _make_parameter_function( + model::InfiniteOpt.InfiniteModel, fn, + prefs::InfiniteOpt.GeneralVariableRef... + ) + f = fn isa Function ? fn : ((args...) -> fn(args...)) + pref_arg = length(prefs) == 1 ? prefs[1] : prefs + pfunc = InfiniteOpt.build_parameter_function(error, f, pref_arg) + return InfiniteOpt.add_parameter_function(model, pfunc) +end + +# Collect all infinite parameters and their supports from the model. +function _collect_parameters(model::InfiniteOpt.InfiniteModel) + params = collect(InfiniteOpt.all_parameters(model)) + if isempty(params) + error("Model has no infinite parameters.") + end + prefs = InfiniteOpt.GeneralVariableRef[p for p in params] + supports = Dict{InfiniteOpt.GeneralVariableRef, Vector{Float64}}( + p => Float64.(InfiniteOpt.supports(p)) for p in prefs) + return prefs, supports +end + +# Transcribe an InfiniteModel to a flat JuMP.Model with forward variable +# map. Shared by MBM and CP paths. +function transcribe_to_flat(model::InfiniteOpt.InfiniteModel) + InfiniteOpt.build_transformation_backend!(model) + flat = InfiniteOpt.transformation_model(model) + fwd_map = Dict{InfiniteOpt.GeneralVariableRef, Vector{JuMP.VariableRef}}() + for v in DP.collect_all_vars(model) + tv = InfiniteOpt.transformation_variable(v) + vprefs = InfiniteOpt.parameter_refs(v) + fwd_map[v] = isempty(vprefs) ? [tv] : vec(tv) + end + return flat, fwd_map +end + +################################################################################ +# CUTTING PLANES FOR INFINITEMODEL +################################################################################ + +# Build CP subproblem: reformulate the InfiniteModel, transcribe to a flat +# JuMP copy, and wrap in GDPSubmodel with forward variable map. +function DP.copy_and_reformulate( + model::InfiniteOpt.InfiniteModel, + decision_vars::Vector{InfiniteOpt.GeneralVariableRef}, + reform_method::DP.AbstractReformulationMethod, + method::DP.CuttingPlanes + ) + DP.reformulate_model(model, reform_method) + flat, tr_fwd = transcribe_to_flat(model) + sub_copy, copy_map = JuMP.copy_model(flat) + fwd_map = Dict{InfiniteOpt.GeneralVariableRef, Vector{JuMP.VariableRef}}() + for v in decision_vars + haskey(tr_fwd, v) || continue + fwd_map[v] = [copy_map[tv] for tv in tr_fwd[v]] + end + sub = DP.GDPSubmodel(sub_copy, decision_vars, fwd_map) + JuMP.set_optimizer(sub.model, method.optimizer) + JuMP.set_silent(sub.model) + return sub +end + +# Full CP loop for InfiniteModel: cannot solve in-place, so both SEP +# and rBM are separate transcribed flat copies. +function DP.reformulate_model( + model::InfiniteOpt.InfiniteModel, + method::DP.CuttingPlanes + ) + decision_vars = DP.collect_cutting_planes_vars(model) + separation = DP.copy_and_reformulate( + model, decision_vars, DP.Hull(), method) + JuMP.relax_integrality(separation.model) + rBM = DP.copy_and_reformulate( + model, decision_vars, DP.BigM(method.M_value), method) + JuMP.relax_integrality(rBM.model) + for iter in 1:method.max_iter + JuMP.optimize!(rBM.model, ignore_optimize_hook = true) + rBM_sol = DP.extract_solution(rBM) + sep_obj, sep_sol = DP._solve_separation(separation, rBM_sol) + sep_obj <= method.seperation_tolerance && break + _add_infinite_cut(rBM, model, rBM_sol, sep_sol) + end + DP._set_solution_method(model, method) + DP._set_ready_to_optimize(model, true) + return +end + +# Add cut to both the flat rBM model and the original InfiniteModel. +function _add_infinite_cut( + rBM::DP.GDPSubmodel{<:Any, <:InfiniteOpt.GeneralVariableRef, <:Any}, + model::InfiniteOpt.InfiniteModel, + rBM_sol::Dict{<:JuMP.AbstractVariableRef, <:Vector{<:Number}}, + sep_sol::Dict{<:JuMP.AbstractVariableRef, <:Vector{<:Number}} + ) + cut_expr = zero(JuMP.GenericAffExpr{ + JuMP.value_type(typeof(rBM.model)), + JuMP.variable_ref_type(rBM.model)}) + for var in rBM.decision_vars + sub_vars = rBM.fwd_map[var] + rbm_vals = rBM_sol[var] + sep_vals = sep_sol[var] + for k in 1:length(sub_vars) + xi = 2 * (sep_vals[k] - rbm_vals[k]) + JuMP.add_to_expression!(cut_expr, xi, sub_vars[k]) + JuMP.add_to_expression!(cut_expr, -xi * sep_vals[k]) + end + end + JuMP.@constraint(rBM.model, cut_expr >= 0) + prefs, sups = _collect_parameters(model) + inf_terms = Any[] + cut_scalar = zero(JuMP.GenericAffExpr{ + JuMP.value_type(typeof(model)), + JuMP.variable_ref_type(model)}) + for var in rBM.decision_vars + haskey(rBM_sol, var) || continue + haskey(sep_sol, var) || continue + vprefs = InfiniteOpt.parameter_refs(var) + if isempty(vprefs) + xi = 2 * (sep_sol[var][1] - rBM_sol[var][1]) + sp = sep_sol[var][1] + cut_scalar += xi * (var - sp) + else + xi_vals = 2 .* (sep_sol[var] .- rBM_sol[var]) + sp_vals = sep_sol[var] + xi_pf = condense_to_pf(model, xi_vals, vprefs, sups) + sp_pf = condense_to_pf(model, sp_vals, vprefs, sups) + push!(inf_terms, xi_pf * var - xi_pf * sp_pf) + end + end + if !isempty(inf_terms) + inf_expr = JuMP.@expression(model, sum(inf_terms)) + for p in prefs + inf_expr = InfiniteOpt.integral(inf_expr, p) + end + cut_scalar += inf_expr + end + JuMP.@constraint(model, cut_scalar >= 0) + return +end + +end From 8fa5f3e184525665f05ba468ab6dcfe5fec576ef Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Mon, 20 Apr 2026 20:24:12 -0400 Subject: [PATCH 09/59] . --- ext/InfiniteDisjunctiveProgramming.jl | 272 +++++++------- src/mbm.jl | 22 +- test/constraints/mbm.jl | 14 +- .../InfiniteDisjunctiveProgramming.jl | 334 +++++++++++++++++- 4 files changed, 480 insertions(+), 162 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 831820f7..8f5cd1e0 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -8,14 +8,17 @@ import DisjunctiveProgramming as DP # MODEL ################################################################################ function DP.InfiniteGDPModel(args...; kwargs...) - return DP.GDPModel{InfiniteOpt.InfiniteModel, + return DP.GDPModel{ + InfiniteOpt.InfiniteModel, InfiniteOpt.GeneralVariableRef, - InfiniteOpt.InfOptConstraintRef}(args...; kwargs...) + InfiniteOpt.InfOptConstraintRef + }(args...; kwargs...) end function DP.collect_all_vars(model::InfiniteOpt.InfiniteModel) vars = JuMP.all_variables(model) - return append!(vars, InfiniteOpt.all_derivatives(model)) + derivs = InfiniteOpt.all_derivatives(model) + return append!(vars, derivs) end ################################################################################ @@ -38,19 +41,20 @@ end function DP.VariableProperties(vref::InfiniteOpt.GeneralVariableRef) info = DP.get_variable_info(vref) name = JuMP.name(vref) + set = nothing prefs = InfiniteOpt.parameter_refs(vref) var_type = !isempty(prefs) ? InfiniteOpt.Infinite(prefs...) : nothing - return DP.VariableProperties(info, name, nothing, var_type) + return DP.VariableProperties(info, name, set, var_type) end -# Extract parameter refs from expression, return VariableProperties with -# Infinite type. +# Extract parameter refs from expression and return VariableProperties with Infinite type function DP.VariableProperties( expr::Union{ JuMP.GenericAffExpr{C, InfiniteOpt.GeneralVariableRef}, JuMP.GenericQuadExpr{C, InfiniteOpt.GeneralVariableRef}, - JuMP.GenericNonlinearExpr{InfiniteOpt.GeneralVariableRef}} - ) where C + JuMP.GenericNonlinearExpr{InfiniteOpt.GeneralVariableRef} + } +) where C prefs = InfiniteOpt.parameter_refs(expr) info = DP._free_variable_info() var_type = !isempty(prefs) ? InfiniteOpt.Infinite(prefs...) : nothing @@ -62,8 +66,9 @@ function DP.VariableProperties( InfiniteOpt.GeneralVariableRef, JuMP.GenericAffExpr{<:Any, InfiniteOpt.GeneralVariableRef}, JuMP.GenericQuadExpr{<:Any, InfiniteOpt.GeneralVariableRef}, - JuMP.GenericNonlinearExpr{InfiniteOpt.GeneralVariableRef}}} - ) + JuMP.GenericNonlinearExpr{InfiniteOpt.GeneralVariableRef} + }} +) all_prefs = Set{InfiniteOpt.GeneralVariableRef}() for expr in exprs for pref in InfiniteOpt.parameter_refs(expr) @@ -85,8 +90,9 @@ end ################################################################################ function JuMP.add_constraint( model::InfiniteOpt.InfiniteModel, - c::JuMP.VectorConstraint{F, S}, name::String = "" - ) where {F, S <: DP.AbstractCardinalitySet} + c::JuMP.VectorConstraint{F, S}, + name::String = "" +) where {F, S <: DP.AbstractCardinalitySet} return DP._add_cardinality_constraint(model, c, name) end @@ -94,7 +100,7 @@ function JuMP.add_constraint( model::M, c::JuMP.ScalarConstraint{DP._LogicalExpr{M}, S}, name::String = "" - ) where {S, M <: InfiniteOpt.InfiniteModel} +) where {S, M <: InfiniteOpt.InfiniteModel} return DP._add_logical_constraint(model, c, name) end @@ -102,29 +108,28 @@ function JuMP.add_constraint( model::M, c::JuMP.ScalarConstraint{DP.LogicalVariableRef{M}, S}, name::String = "" - ) where {M <: InfiniteOpt.InfiniteModel, S} - error("Cannot define constraint on single logical variable, " * - "use `fix` instead.") +) where {M <: InfiniteOpt.InfiniteModel, S} + error("Cannot define constraint on single logical variable, use `fix` instead.") end function JuMP.add_constraint( model::M, c::JuMP.ScalarConstraint{ - JuMP.GenericAffExpr{C, DP.LogicalVariableRef{M}}, S}, + JuMP.GenericAffExpr{C, DP.LogicalVariableRef{M}}, S + }, name::String = "" - ) where {M <: InfiniteOpt.InfiniteModel, S, C} - error("Cannot add, subtract, or multiply with " * - "logical variables.") +) where {M <: InfiniteOpt.InfiniteModel, S, C} + error("Cannot add, subtract, or multiply with logical variables.") end function JuMP.add_constraint( model::M, c::JuMP.ScalarConstraint{ - JuMP.GenericQuadExpr{C, DP.LogicalVariableRef{M}}, S}, + JuMP.GenericQuadExpr{C, DP.LogicalVariableRef{M}}, S + }, name::String = "" - ) where {M <: InfiniteOpt.InfiniteModel, S, C} - error("Cannot add, subtract, or multiply with " * - "logical variables.") +) where {M <: InfiniteOpt.InfiniteModel, S, C} + error("Cannot add, subtract, or multiply with logical variables.") end ################################################################################ @@ -132,7 +137,7 @@ end ################################################################################ function DP.get_constant( expr::JuMP.GenericAffExpr{T, InfiniteOpt.GeneralVariableRef} - ) where {T} +) where {T} constant = JuMP.constant(expr) param_expr = zero(typeof(expr)) for (var, coeff) in expr.terms @@ -144,16 +149,16 @@ function DP.get_constant( end function DP.disaggregate_expression( - model::M, aff::JuMP.GenericAffExpr, + model::M, + aff::JuMP.GenericAffExpr, bvref::Union{JuMP.AbstractVariableRef, JuMP.GenericAffExpr}, method::DP._Hull - ) where {M <: InfiniteOpt.InfiniteModel} +) where {M <: InfiniteOpt.InfiniteModel} terms = Any[aff.constant * bvref] for (vref, coeff) in aff.terms if JuMP.is_binary(vref) push!(terms, coeff * vref) - elseif vref isa InfiniteOpt.GeneralVariableRef && - _is_parameter(vref) + elseif vref isa InfiniteOpt.GeneralVariableRef && _is_parameter(vref) push!(terms, coeff * vref * bvref) elseif !haskey(method.disjunct_variables, (vref, bvref)) push!(terms, coeff * vref) @@ -208,8 +213,8 @@ end # copy_model_with_constraints (build mini InfiniteModel + # transcribe to flat JuMP model), prepare_max_M_objective # (expand infinite constraint into K flat objectives via -# _build_flat_map), and aggregate_M_values (interpolate flat -# values to parameter function). +# _build_flat_map), and raw_M (vector dispatch aggregates +# K per-support M values into a parameter function). # Collect all parameter function refs from all disjunct constraints in # the model. @@ -221,8 +226,7 @@ function _all_param_functions( for cref in crefs cref isa DP.DisjunctConstraintRef || continue con = JuMP.constraint_object(cref) - for v in InfiniteOpt.all_expression_variables( - con.func) + for v in InfiniteOpt.all_expression_variables(con.func) dv = InfiniteOpt.dispatch_variable_ref(v) if dv isa InfiniteOpt.ParameterFunctionRef push!(pf_set, v) @@ -241,7 +245,7 @@ end function _build_flat_map( sub::DP.GDPSubmodel, k::Int, prefs::Vector{InfiniteOpt.GeneralVariableRef}, - supports::Dict{InfiniteOpt.GeneralVariableRef,Vector{Float64}}, + supports::Dict{InfiniteOpt.GeneralVariableRef, Vector{Float64}}, full_shape::Tuple, pf_set::Set{InfiniteOpt.GeneralVariableRef} ) @@ -281,7 +285,8 @@ function DP.copy_model_with_constraints( method::DP._MBM ) mini = InfiniteOpt.InfiniteModel() - ref_map = Dict{InfiniteOpt.GeneralVariableRef,InfiniteOpt.GeneralVariableRef}() + ref_map = Dict{InfiniteOpt.GeneralVariableRef, + InfiniteOpt.GeneralVariableRef}() # 1. Copy infinite parameters with their supports for p in InfiniteOpt.all_parameters(model) @@ -298,7 +303,8 @@ function DP.copy_model_with_constraints( prefs = InfiniteOpt.parameter_refs(v) var_type = isempty(prefs) ? nothing : InfiniteOpt.Infinite(Tuple(ref_map[p] for p in prefs)...) - props = DP.VariableProperties(DP.get_variable_info(v),"", nothing, var_type) + props = DP.VariableProperties( + DP.get_variable_info(v), "", nothing, var_type) ref_map[v] = DP.create_variable(mini, props) end @@ -334,10 +340,19 @@ function DP.copy_model_with_constraints( end # 6. Transcribe mini InfiniteModel to flat JuMP model - flat, tr_fwd = transcribe_to_flat(mini) + InfiniteOpt.build_transformation_backend!(mini) + flat = InfiniteOpt.transformation_model(mini) + tr_fwd = Dict{InfiniteOpt.GeneralVariableRef, + Vector{JuMP.VariableRef}}() + for v in DP.collect_all_vars(mini) + tv = InfiniteOpt.transformation_variable(v) + vprefs = InfiniteOpt.parameter_refs(v) + tr_fwd[v] = isempty(vprefs) ? [tv] : vec(tv) + end # 7. Remap fwd_map: original model var -> flat JuMP VarRef - fwd_map = Dict{InfiniteOpt.GeneralVariableRef, Vector{JuMP.VariableRef}}() + fwd_map = Dict{InfiniteOpt.GeneralVariableRef, + Vector{JuMP.VariableRef}}() for (orig, mapped) in ref_map _is_parameter(orig) && continue haskey(tr_fwd, mapped) || continue @@ -370,6 +385,7 @@ function DP.prepare_max_M_objective( end return objectives end + function DP.prepare_max_M_objective( model::InfiniteOpt.InfiniteModel, obj::JuMP.ScalarConstraint{T, S}, @@ -381,16 +397,16 @@ function DP.prepare_max_M_objective( pf_set = _all_param_functions(model) objectives = Vector{JuMP.AbstractJuMPScalar}(undef, K) for k in 1:K - flat_map = _build_flat_map(sub, k, prefs, supports,full_shape, pf_set) + flat_map = _build_flat_map(sub, k, prefs, supports, full_shape, pf_set) objectives[k] = obj.set.lower - DP._replace_variables_in_constraint(obj.func, flat_map) end return objectives end -# Solve the submodel for a vector of objectives (one per -# support point). Returns aggregated result or nothing. -function DP._raw_M( +# Solve the submodel for a vector of objectives (one per support point). +# Returns aggregated M value (scalar or parameter function) or nothing. +function DP.raw_M( sub::DP.GDPSubmodel, objectives::Vector{<:JuMP.AbstractJuMPScalar}, method::DP._MBM @@ -411,40 +427,8 @@ function DP._raw_M( push!(M_vals, method.default_M) end end - model = JuMP.owner_model( - first(keys(sub.fwd_map))) - return aggregate_M_values(model, M_vals) -end - -# Condense flat per-support values to final form (MBM path). -function aggregate_M_values( - model::InfiniteOpt.InfiniteModel, - vals::AbstractVector{<:Real} - ) - if all(==(vals[1]), vals) - return vals[1] - end - prefs, supports = _collect_parameters(model) - return condense_to_pf(model, vals, prefs, supports) -end - -# Interpolate flat per-support values into a parameter function. Computes -# grids/shape from supports, reshapes, interpolates, and registers on -# the model. -function condense_to_pf( - model::InfiniteOpt.InfiniteModel, - vals::AbstractVector{<:Real}, - prefs::Union{Vector{InfiniteOpt.GeneralVariableRef}, - Tuple{Vararg{InfiniteOpt.GeneralVariableRef}}}, - supports::Dict{InfiniteOpt.GeneralVariableRef, - Vector{Float64}} - ) - grids = Tuple(supports[p] for p in prefs) - shape = Tuple(length(supports[p]) for p in prefs) - nd = reshape(vals, shape) - fn = Interpolations.linear_interpolation(grids, nd, - extrapolation_bc = Interpolations.Line()) - return _make_parameter_function(model, fn, prefs...) + model = JuMP.owner_model(first(keys(sub.fwd_map))) + return _aggregate_M_values(model, M_vals) end ################################################################################ @@ -479,26 +463,49 @@ function _collect_parameters(model::InfiniteOpt.InfiniteModel) return prefs, supports end -# Transcribe an InfiniteModel to a flat JuMP.Model with forward variable -# map. Shared by MBM and CP paths. -function transcribe_to_flat(model::InfiniteOpt.InfiniteModel) - InfiniteOpt.build_transformation_backend!(model) - flat = InfiniteOpt.transformation_model(model) - fwd_map = Dict{InfiniteOpt.GeneralVariableRef, Vector{JuMP.VariableRef}}() - for v in DP.collect_all_vars(model) - tv = InfiniteOpt.transformation_variable(v) - vprefs = InfiniteOpt.parameter_refs(v) - fwd_map[v] = isempty(vprefs) ? [tv] : vec(tv) +# Condense flat per-support M values into a scalar or parameter function. +function _aggregate_M_values( + model::InfiniteOpt.InfiniteModel, + vals::AbstractVector{<:Real} + ) + if all(==(vals[1]), vals) + return vals[1] end - return flat, fwd_map + prefs, supports = _collect_parameters(model) + return values_to_parameter_function(model, vals, prefs, supports) +end + +""" + values_to_parameter_function( + model, vals, prefs, supports + ) + +Interpolate flat per-support values into an InfiniteOpt parameter +function registered on `model`. Builds a grid from `supports`, +reshapes `vals`, fits a linear interpolation, and returns the +registered parameter function ref. +""" +function values_to_parameter_function( + model::InfiniteOpt.InfiniteModel, + vals::AbstractVector{<:Real}, + prefs::Union{Vector{InfiniteOpt.GeneralVariableRef}, + Tuple{Vararg{InfiniteOpt.GeneralVariableRef}}}, + supports::Dict{InfiniteOpt.GeneralVariableRef, Vector{Float64}} + ) + grids = Tuple(supports[p] for p in prefs) + shape = Tuple(length(supports[p]) for p in prefs) + nd = reshape(vals, shape) + fn = Interpolations.linear_interpolation(grids, nd, + extrapolation_bc = Interpolations.Line()) + return _make_parameter_function(model, fn, prefs...) end ################################################################################ # CUTTING PLANES FOR INFINITEMODEL ################################################################################ -# Build CP subproblem: reformulate the InfiniteModel, transcribe to a flat -# JuMP copy, and wrap in GDPSubmodel with forward variable map. +# Build CP subproblem: reformulate the InfiniteModel in-place, transcribe +# to a flat JuMP copy, and wrap in GDPSubmodel with forward variable map. function DP.copy_and_reformulate( model::InfiniteOpt.InfiniteModel, decision_vars::Vector{InfiniteOpt.GeneralVariableRef}, @@ -506,9 +513,18 @@ function DP.copy_and_reformulate( method::DP.CuttingPlanes ) DP.reformulate_model(model, reform_method) - flat, tr_fwd = transcribe_to_flat(model) + InfiniteOpt.build_transformation_backend!(model) + flat = InfiniteOpt.transformation_model(model) + tr_fwd = Dict{InfiniteOpt.GeneralVariableRef, + Vector{JuMP.VariableRef}}() + for v in DP.collect_all_vars(model) + tv = InfiniteOpt.transformation_variable(v) + vprefs = InfiniteOpt.parameter_refs(v) + tr_fwd[v] = isempty(vprefs) ? [tv] : vec(tv) + end sub_copy, copy_map = JuMP.copy_model(flat) - fwd_map = Dict{InfiniteOpt.GeneralVariableRef, Vector{JuMP.VariableRef}}() + fwd_map = Dict{InfiniteOpt.GeneralVariableRef, + Vector{JuMP.VariableRef}}() for v in decision_vars haskey(tr_fwd, v) || continue fwd_map[v] = [copy_map[tv] for tv in tr_fwd[v]] @@ -519,70 +535,47 @@ function DP.copy_and_reformulate( return sub end -# Full CP loop for InfiniteModel: cannot solve in-place, so both SEP -# and rBM are separate transcribed flat copies. -function DP.reformulate_model( - model::InfiniteOpt.InfiniteModel, - method::DP.CuttingPlanes - ) - decision_vars = DP.collect_cutting_planes_vars(model) - separation = DP.copy_and_reformulate( - model, decision_vars, DP.Hull(), method) - JuMP.relax_integrality(separation.model) - rBM = DP.copy_and_reformulate( - model, decision_vars, DP.BigM(method.M_value), method) - JuMP.relax_integrality(rBM.model) - for iter in 1:method.max_iter - JuMP.optimize!(rBM.model, ignore_optimize_hook = true) - rBM_sol = DP.extract_solution(rBM) - sep_obj, sep_sol = DP._solve_separation(separation, rBM_sol) - sep_obj <= method.seperation_tolerance && break - _add_infinite_cut(rBM, model, rBM_sol, sep_sol) +# Extract per-support-point solutions from the InfiniteOpt transformation +# backend after optimize!(model, ignore_optimize_hook=true). +function DP.extract_solution(model::InfiniteOpt.InfiniteModel) + dvars = DP.collect_cutting_planes_vars(model) + V = eltype(dvars) + T = JuMP.value_type(typeof(model)) + sol = Dict{V, Vector{T}}() + for v in dvars + tv = InfiniteOpt.transformation_variable(v) + vprefs = InfiniteOpt.parameter_refs(v) + sol[v] = isempty(vprefs) ? [JuMP.value(tv)] : JuMP.value.(vec(tv)) end - DP._set_solution_method(model, method) - DP._set_ready_to_optimize(model, true) - return + return sol end -# Add cut to both the flat rBM model and the original InfiniteModel. -function _add_infinite_cut( - rBM::DP.GDPSubmodel{<:Any, <:InfiniteOpt.GeneralVariableRef, <:Any}, +# Add an infinite-form cut to the InfiniteModel. Infinite variables get +# interpolated parameter function coefficients wrapped in integrals; +# finite variables contribute scalar terms. +function DP.add_cut( model::InfiniteOpt.InfiniteModel, + decision_vars::Vector{InfiniteOpt.GeneralVariableRef}, rBM_sol::Dict{<:JuMP.AbstractVariableRef, <:Vector{<:Number}}, sep_sol::Dict{<:JuMP.AbstractVariableRef, <:Vector{<:Number}} ) - cut_expr = zero(JuMP.GenericAffExpr{ - JuMP.value_type(typeof(rBM.model)), - JuMP.variable_ref_type(rBM.model)}) - for var in rBM.decision_vars - sub_vars = rBM.fwd_map[var] - rbm_vals = rBM_sol[var] - sep_vals = sep_sol[var] - for k in 1:length(sub_vars) - xi = 2 * (sep_vals[k] - rbm_vals[k]) - JuMP.add_to_expression!(cut_expr, xi, sub_vars[k]) - JuMP.add_to_expression!(cut_expr, -xi * sep_vals[k]) - end - end - JuMP.@constraint(rBM.model, cut_expr >= 0) prefs, sups = _collect_parameters(model) inf_terms = Any[] - cut_scalar = zero(JuMP.GenericAffExpr{ - JuMP.value_type(typeof(model)), - JuMP.variable_ref_type(model)}) - for var in rBM.decision_vars + cut_scalar = DP._zero_aff(model) + for var in decision_vars haskey(rBM_sol, var) || continue haskey(sep_sol, var) || continue vprefs = InfiniteOpt.parameter_refs(var) if isempty(vprefs) xi = 2 * (sep_sol[var][1] - rBM_sol[var][1]) - sp = sep_sol[var][1] - cut_scalar += xi * (var - sp) + cut_scalar += xi * (var - sep_sol[var][1]) else xi_vals = 2 .* (sep_sol[var] .- rBM_sol[var]) sp_vals = sep_sol[var] - xi_pf = condense_to_pf(model, xi_vals, vprefs, sups) - sp_pf = condense_to_pf(model, sp_vals, vprefs, sups) + xi_pf = values_to_parameter_function( + model, xi_vals, vprefs, sups) + sp_pf = values_to_parameter_function( + model, sp_vals, vprefs, sups) push!(inf_terms, xi_pf * var - xi_pf * sp_pf) end end @@ -593,7 +586,8 @@ function _add_infinite_cut( end cut_scalar += inf_expr end - JuMP.@constraint(model, cut_scalar >= 0) + cref = JuMP.@constraint(model, cut_scalar >= 0) + push!(DP._reformulation_constraints(model), cref) return end diff --git a/src/mbm.jl b/src/mbm.jl index 973c0519..8d8fc1cf 100644 --- a/src/mbm.jl +++ b/src/mbm.jl @@ -242,7 +242,7 @@ end prepare_max_M_objective(model, obj::ScalarConstraint, sub::GDPSubmodel) Convert a constraint into an objective expression for M-value -maximization. Returns a single JuMP expression to pass to `_raw_M`. +maximization. Returns a single JuMP expression to pass to `raw_M`. """ function prepare_max_M_objective( ::JuMP.AbstractModel, @@ -266,7 +266,7 @@ end # Solve the submodel for a single objective expression. # Returns a scalar M value, or nothing if infeasible. -function _raw_M( +function raw_M( sub::GDPSubmodel, objective::JuMP.AbstractJuMPScalar, method::_MBM @@ -291,7 +291,7 @@ function _maximize_M( method::_MBM ) where {T, S <: Union{_MOI.LessThan, _MOI.GreaterThan}} sub = _get_submodel(model, constraints, method) - return _raw_M(sub, + return raw_M(sub, prepare_max_M_objective(model, objective, sub), method) end @@ -321,8 +321,8 @@ function _maximize_M( set_value = objective.set.value ge_obj = JuMP.ScalarConstraint(objective.func, MOI.GreaterThan(set_value)) le_obj = JuMP.ScalarConstraint(objective.func, MOI.LessThan(set_value)) - raw_lower = _raw_M(sub, prepare_max_M_objective(model, ge_obj, sub), method) - raw_upper = _raw_M(sub, prepare_max_M_objective(model, le_obj, sub), method) + raw_lower = raw_M(sub, prepare_max_M_objective(model, ge_obj, sub), method) + raw_upper = raw_M(sub, prepare_max_M_objective(model, le_obj, sub), method) (raw_lower === nothing || raw_upper === nothing) && return nothing return [raw_lower, raw_upper] @@ -341,8 +341,8 @@ function _maximize_M( MOI.GreaterThan(set_values[1])) le_obj = JuMP.ScalarConstraint(objective.func, MOI.LessThan(set_values[2])) - raw_lower = _raw_M(sub, prepare_max_M_objective(model, ge_obj, sub), method) - raw_upper = _raw_M(sub, prepare_max_M_objective(model, le_obj, sub), method) + raw_lower = raw_M(sub, prepare_max_M_objective(model, ge_obj, sub), method) + raw_upper = raw_M(sub, prepare_max_M_objective(model, le_obj, sub), method) (raw_lower === nothing || raw_upper === nothing) && return nothing return [raw_lower, raw_upper] @@ -361,7 +361,7 @@ function _maximize_M( for i in 1:objective.set.dimension le_obj = JuMP.ScalarConstraint( objective.func[i], MOI.LessThan(zero(val_type))) - raw = _raw_M(sub, + raw = raw_M(sub, prepare_max_M_objective(model, le_obj, sub), method) raw === nothing && return nothing @@ -384,7 +384,7 @@ function _maximize_M( ge_obj = JuMP.ScalarConstraint( objective.func[i], MOI.GreaterThan(zero(val_type))) - raw = _raw_M(sub, + raw = raw_M(sub, prepare_max_M_objective(model, ge_obj, sub), method) raw === nothing && return nothing @@ -410,10 +410,10 @@ function _maximize_M( le_obj = JuMP.ScalarConstraint( objective.func[i], MOI.LessThan(zero(val_type))) - raw_ge = _raw_M(sub, + raw_ge = raw_M(sub, prepare_max_M_objective(model, ge_obj, sub), method) - raw_le = _raw_M(sub, + raw_le = raw_M(sub, prepare_max_M_objective(model, le_obj, sub), method) (raw_ge === nothing || raw_le === nothing) && diff --git a/test/constraints/mbm.jl b/test/constraints/mbm.jl index d181c082..80f9a27f 100644 --- a/test/constraints/mbm.jl +++ b/test/constraints/mbm.jl @@ -134,19 +134,19 @@ function test_raw_M() DisjunctConstraintRef[con2], mbm) obj = DP.prepare_max_M_objective(model, constraint_object(con), sub) - @test DP._raw_M(sub, obj, mbm) == 0.0 + @test DP.raw_M(sub, obj, mbm) == 0.0 set_upper_bound(x, 1) sub2 = DP.copy_model_with_constraints(model, DisjunctConstraintRef[con], mbm) obj2 = DP.prepare_max_M_objective(model, constraint_object(con2), sub2) - @test DP._raw_M(sub2, obj2, mbm) == 15 + @test DP.raw_M(sub2, obj2, mbm) == 15 set_integer(y) @constraint(model, con3, y*x == 15, Disjunct(Y[1])) obj3 = DP.prepare_max_M_objective(model, constraint_object(con2), sub2) - @test DP._raw_M(sub2, obj3, mbm) == 15 + @test DP.raw_M(sub2, obj3, mbm) == 15 # Fresh _MBM after changing bounds JuMP.fix(y, 5; force=true) mbm2 = DP._MBM( @@ -155,7 +155,7 @@ function test_raw_M() DisjunctConstraintRef[con], mbm2) obj4 = DP.prepare_max_M_objective(model, constraint_object(con2), sub3) - @test DP._raw_M(sub3, obj4, mbm2) == 10 + @test DP.raw_M(sub3, obj4, mbm2) == 10 # Infeasible region → nothing delete_lower_bound(x) mbm3 = DP._MBM( @@ -164,7 +164,7 @@ function test_raw_M() DisjunctConstraintRef[con2], mbm3) obj5 = DP.prepare_max_M_objective(model, constraint_object(con2), sub4) - @test DP._raw_M(sub4, obj5, mbm3) == nothing + @test DP.raw_M(sub4, obj5, mbm3) == nothing # infeasible (x >= 100 but x <= 1) set_upper_bound(x, 1) @@ -175,7 +175,7 @@ function test_raw_M() mbm4) obj6 = DP.prepare_max_M_objective(model, constraint_object(con), sub5) - @test DP._raw_M(sub5, obj6, mbm4) == nothing + @test DP.raw_M(sub5, obj6, mbm4) == nothing # Unbounded subproblem → default_M fallback. # No lower bound on x means max(5 - x) s.t. x <= 3 @@ -191,7 +191,7 @@ function test_raw_M() DisjunctConstraintRef[ub_con1], mbm_ub) obj_ub = DP.prepare_max_M_objective(model_ub, constraint_object(ub_con2), sub_ub) - @test DP._raw_M(sub_ub, obj_ub, mbm_ub) == mbm_ub.default_M + @test DP.raw_M(sub_ub, obj_ub, mbm_ub) == mbm_ub.default_M end function test_maximize_M() diff --git a/test/extensions/InfiniteDisjunctiveProgramming.jl b/test/extensions/InfiniteDisjunctiveProgramming.jl index 8ba1e5c2..e2dda56c 100644 --- a/test/extensions/InfiniteDisjunctiveProgramming.jl +++ b/test/extensions/InfiniteDisjunctiveProgramming.jl @@ -1,4 +1,4 @@ -using InfiniteOpt, HiGHS, Ipopt, Juniper +using InfiniteOpt, HiGHS, Ipopt, Juniper, Interpolations import DisjunctiveProgramming as DP # Helper to access internal function @@ -77,6 +77,24 @@ function test__is_parameter() @test IDP._is_parameter(y) == false end +# _is_parameter on unwrapped concrete dispatch types. Covers +# ext lines 28-32 (DependentParameterRef, IndependentParameterRef, +# FiniteParameterRef, ParameterFunctionRef, Any fallback). +function test__is_parameter_concrete_dispatches() + model = InfiniteGDPModel() + @infinite_parameter(model, t ∈ [0, 1]) + @infinite_parameter(model, s[1:2] ∈ [0, 1], independent = true) + @finite_parameter(model, p == 1.0) + @variable(model, x, Infinite(t)) + @parameter_function(model, pf == t -> 2*t) + dvr = InfiniteOpt.dispatch_variable_ref + @test IDP._is_parameter(dvr(t)) == true # Dependent + @test IDP._is_parameter(dvr(s[1])) == true # Independent + @test IDP._is_parameter(dvr(p)) == true # Finite + @test IDP._is_parameter(dvr(pf)) == true # ParamFunc + @test IDP._is_parameter(dvr(x)) == false # Any +end + function test_requires_disaggregation() model = InfiniteGDPModel() @infinite_parameter(model, t ∈ [0, 1]) @@ -169,6 +187,43 @@ function test_disaggregate_expression_infiniteopt() @test haskey(result_not_disagg.terms, y) end +function test_disaggregate_quad_expression_infiniteopt() + model = InfiniteGDPModel() + @infinite_parameter(model, t ∈ [0, 1]) + @variable(model, 0 <= x <= 10, Infinite(t)) + @variable(model, 0 <= y <= 5, Infinite(t)) + @variable(model, z, InfiniteLogical(t)) + + bvrefs = DP._indicator_to_binary(model) + bvref = bvrefs[z] + + vrefs = Set([x, y]) + DP._variable_bounds(model)[x] = DP.set_variable_bound_info(x, Hull()) + DP._variable_bounds(model)[y] = DP.set_variable_bound_info(y, Hull()) + method = DP._Hull(Hull(1e-3), vrefs) + DP._disaggregate_variables(model, z, vrefs, method) + + # var × var → nonlinear (perspective divides by y) + quad_vv = @expression(model, x * y) + result_vv = DP.disaggregate_expression(model, quad_vv, bvref, method) + @test result_vv isa JuMP.GenericNonlinearExpr + + # param × var → quadratic (param * disaggregated) + quad_pv = @expression(model, t * x) + result_pv = DP.disaggregate_expression(model, quad_pv, bvref, method) + @test result_pv isa JuMP.GenericQuadExpr + + # var × param → quadratic (disaggregated * param) + quad_vp = @expression(model, x * t) + result_vp = DP.disaggregate_expression(model, quad_vp, bvref, method) + @test result_vp isa JuMP.GenericQuadExpr + + # param × param → cubic (t * t * bvref) + quad_pp = @expression(model, t * t) + result_pp = DP.disaggregate_expression(model, quad_pp, bvref, method) + @test result_pp isa JuMP.GenericNonlinearExpr +end + function test_variable_properties_infiniteopt() model = InfiniteGDPModel() @infinite_parameter(model, t ∈ [0, 1]) @@ -336,10 +391,110 @@ function test_logical_value() @test eltype(val) == Bool end -function test_unsupported_methods_error() +# _collect_parameters on model with no infinite parameters. +# Covers ext line 508. +function test__collect_parameters_no_params() + model = InfiniteGDPModel() + @test_throws ErrorException IDP._collect_parameters(model) +end + +# MBM with finite + integer variables in InfiniteModel. Covers +# copy_model_with_constraints (finite var, set_integer), +# and _build_flat_map line 252 (finite var path). +function test_mbm_finite_and_integer_var() model = InfiniteGDPModel(HiGHS.Optimizer) - @test_throws ErrorException DP.reformulate_model(model, MBM(HiGHS.Optimizer)) - @test_throws ErrorException DP.reformulate_model(model, CuttingPlanes(HiGHS.Optimizer)) + set_silent(model) + @infinite_parameter(model, t ∈ [0, 1], num_supports = 10) + @variable(model, 0 <= x <= 10, Infinite(t)) + @variable(model, 0 <= w <= 5, Int) + @variable(model, Y[1:2], InfiniteLogical(t)) + @constraint(model, x + w >= 5, Disjunct(Y[1])) + @constraint(model, x + w <= 3, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Min, ∫(x, t) + w) + @test optimize!(model, + gdp_method = MBM(HiGHS.Optimizer)) isa Nothing + @test termination_status(model) in + [MOI.OPTIMAL, MOI.LOCALLY_SOLVED] +end + +function test_mbm_infinite_simple() + model = InfiniteGDPModel(HiGHS.Optimizer) + set_silent(model) + + @infinite_parameter(model, t ∈ [0, 1], num_supports = 10) + @variable(model, 0 <= x <= 10, Infinite(t)) + @variable(model, Y[1:2], InfiniteLogical(t)) + + @constraint(model, x >= 5, Disjunct(Y[1])) + @constraint(model, x <= 3, Disjunct(Y[2])) + @disjunction(model, Y) + + @objective(model, Min, ∫(x, t)) + + @test optimize!(model, gdp_method = MBM(HiGHS.Optimizer)) isa Nothing + @test termination_status(model) in + [MOI.OPTIMAL, MOI.LOCALLY_SOLVED] + # x=0 with disjunct 2 active (x <= 3) gives min + @test objective_value(model) ≈ 0.0 atol = 0.1 +end + +function test_mbm_infinite_param_dependent() + model = InfiniteGDPModel(HiGHS.Optimizer) + set_silent(model) + + @infinite_parameter(model, t ∈ [0, 1], num_supports = 20) + @variable(model, -10 <= x <= 10, Infinite(t)) + @variable(model, Y[1:2], InfiniteLogical(t)) + + # Parameter-dependent constraints: + # Disjunct 1: x(t) <= 2*t + # Disjunct 2: x(t) >= 1 - t + @parameter_function(model, f1 == t -> 2*t) + @parameter_function(model, f2 == t -> 1 - t) + @constraint(model, x <= f1, Disjunct(Y[1])) + @constraint(model, x >= f2, Disjunct(Y[2])) + @disjunction(model, Y) + + @objective(model, Min, ∫(x, t)) + + @test optimize!(model, gdp_method = MBM(HiGHS.Optimizer)) isa Nothing + @test termination_status(model) in + [MOI.OPTIMAL, MOI.LOCALLY_SOLVED] +end + +function test_mbm_vs_bigm_infinite() + # Compare MBM and BigM: should give same + # feasible set and optimal value. + for method_pair in [ + (BigM(100), MBM(HiGHS.Optimizer)) + ] + model1 = InfiniteGDPModel(HiGHS.Optimizer) + set_silent(model1) + @infinite_parameter(model1, t ∈ [0, 1], num_supports = 10) + @variable(model1, 0 <= x1 <= 10, Infinite(t)) + @variable(model1, Y1[1:2], InfiniteLogical(t)) + @constraint(model1, x1 >= 5, Disjunct(Y1[1])) + @constraint(model1, x1 <= 3, Disjunct(Y1[2])) + @disjunction(model1, Y1) + @objective(model1, Min, ∫(x1, t)) + optimize!(model1, gdp_method = method_pair[1]) + obj1 = objective_value(model1) + + model2 = InfiniteGDPModel(HiGHS.Optimizer) + set_silent(model2) + @infinite_parameter(model2, t2 ∈ [0, 1], num_supports = 10) + @variable(model2, 0 <= x2 <= 10, Infinite(t2)) + @variable(model2, Y2[1:2], InfiniteLogical(t2)) + @constraint(model2, x2 >= 5, Disjunct(Y2[1])) + @constraint(model2, x2 <= 3, Disjunct(Y2[2])) + @disjunction(model2, Y2) + @objective(model2, Min, ∫(x2, t2)) + optimize!(model2, gdp_method = method_pair[2]) + obj2 = objective_value(model2) + + @test obj1 ≈ obj2 atol = 0.5 + end end function test_methods() @@ -393,6 +548,154 @@ function test_methods() @test value(z) ≈ expected_z atol=tol end +function test_mbm_with_derivatives() + model = InfiniteGDPModel(HiGHS.Optimizer) + set_silent(model) + + @infinite_parameter(model, t ∈ [0, 1], num_supports = 10) + @variable(model, -5 <= x <= 5, Infinite(t)) + @variable(model, Y[1:2], InfiniteLogical(t)) + + @constraint(model, ∂(x, t) >= 1, Disjunct(Y[1])) + @constraint(model, ∂(x, t) <= -1, Disjunct(Y[2])) + @disjunction(model, Y) + + set_upper_bound(∂(x, t), 10) + set_lower_bound(∂(x, t), -10) + + @objective(model, Min, ∫(x^2, t)) + + juniper = JuMP.optimizer_with_attributes( + Juniper.Optimizer, + "nl_solver" => JuMP.optimizer_with_attributes( + Ipopt.Optimizer, "print_level" => 0), + "log_levels" => [] + ) + set_optimizer(model, juniper) + @test optimize!(model, gdp_method = MBM(juniper)) isa Nothing + @test termination_status(model) in + [MOI.OPTIMAL, MOI.LOCALLY_SOLVED, + MOI.ALMOST_LOCALLY_SOLVED] +end + +function test_CuttingPlanes_infinite_simple() + model = InfiniteGDPModel(HiGHS.Optimizer) + set_silent(model) + + @infinite_parameter(model, t ∈ [0, 1], num_supports = 10) + @variable(model, 0 <= x <= 10, Infinite(t)) + @variable(model, Y[1:2], InfiniteLogical(t)) + + @constraint(model, x >= 5, Disjunct(Y[1])) + @constraint(model, x <= 3, Disjunct(Y[2])) + @disjunction(model, Y) + + @objective(model, Min, ∫(x, t)) + + # Should not throw + @test optimize!(model, + gdp_method = CuttingPlanes( + HiGHS.Optimizer; max_iter = 5) + ) isa Nothing + @test termination_status(model) in + [MOI.OPTIMAL, MOI.LOCALLY_SOLVED] +end + +function test_CuttingPlanes_infinite_two_disj() + model = InfiniteGDPModel(HiGHS.Optimizer) + set_silent(model) + + @infinite_parameter(model, t ∈ [0, 1], num_supports = 10) + @variable(model, 0 <= x[1:2] <= 10, Infinite(t)) + @variable(model, W1[1:2], InfiniteLogical(t)) + @variable(model, W2[1:2], InfiniteLogical(t)) + + @constraint(model, x[1] >= 2, Disjunct(W1[1])) + @constraint(model, x[1] <= 1, Disjunct(W1[2])) + @disjunction(model, W1) + + @constraint(model, x[2] >= 3, Disjunct(W2[1])) + @constraint(model, x[2] <= 2, Disjunct(W2[2])) + @disjunction(model, W2) + + @objective(model, Min, ∫(x[1] + x[2], t)) + + # Compare cutting planes vs BigM + optimize!(model, + gdp_method = CuttingPlanes( + HiGHS.Optimizer; max_iter = 10) + ) + cp_obj = objective_value(model) + + model2 = InfiniteGDPModel(HiGHS.Optimizer) + set_silent(model2) + @infinite_parameter(model2, t2 ∈ [0, 1], num_supports = 10) + @variable(model2, 0 <= x2[1:2] <= 10, Infinite(t2)) + @variable(model2, V1[1:2], InfiniteLogical(t2)) + @variable(model2, V2[1:2], InfiniteLogical(t2)) + @constraint(model2, x2[1] >= 2, Disjunct(V1[1])) + @constraint(model2, x2[1] <= 1, Disjunct(V1[2])) + @disjunction(model2, V1) + @constraint(model2, x2[2] >= 3, Disjunct(V2[1])) + @constraint(model2, x2[2] <= 2, Disjunct(V2[2])) + @disjunction(model2, V2) + @objective(model2, Min, ∫(x2[1] + x2[2], t2)) + optimize!(model2, gdp_method = BigM()) + bigm_obj = objective_value(model2) + + @test cp_obj ≈ bigm_obj atol = 1.0 +end + + + +function test_CuttingPlanes_with_cuts() + # Maximization with single-constraint disjuncts where Hull + # is strictly tighter than BigM. BigM allows x+y up to + # variable bounds (20), Hull limits to max(5,8)=8. This + # forces cuts. Finite var w exercises isempty(vprefs) + # branch in add_original_model_cut (line 779). + model = InfiniteGDPModel(HiGHS.Optimizer) + set_silent(model) + @infinite_parameter(model, t ∈ [0, 1], num_supports = 10) + @variable(model, 0 <= x <= 10, Infinite(t)) + @variable(model, 0 <= y <= 10, Infinite(t)) + @variable(model, 0 <= w <= 10) + @variable(model, Y[1:2], InfiniteLogical(t)) + @constraint(model, x + y <= 5, Disjunct(Y[1])) + @constraint(model, x + y <= 8, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Max, ∫(x + y, t) + w) + cutting_planes = CuttingPlanes(HiGHS.Optimizer; + max_iter = 30, seperation_tolerance = 1e-6) + @test optimize!(model, gdp_method = cutting_planes) isa Nothing + @test termination_status(model) in + [MOI.OPTIMAL, MOI.LOCALLY_SOLVED] +end + +function test_CuttingPlanes_multiparameter() + model = InfiniteGDPModel(HiGHS.Optimizer) + set_silent(model) + + @infinite_parameter(model, t ∈ [0, 1], num_supports = 5) + @infinite_parameter(model, s ∈ [0, 2], num_supports = 4) + @variable(model, 0 <= x <= 10, Infinite(t, s)) + @variable(model, Y[1:2], InfiniteLogical(t, s)) + + @constraint(model, x >= 5, Disjunct(Y[1])) + @constraint(model, x <= 3, Disjunct(Y[2])) + @disjunction(model, Y) + + @objective(model, Min, ∫(∫(x, t), s)) + + # Should not throw + @test optimize!(model, + gdp_method = CuttingPlanes( + HiGHS.Optimizer; max_iter = 5) + ) isa Nothing + @test termination_status(model) in + [MOI.OPTIMAL, MOI.LOCALLY_SOLVED] +end + @testset "InfiniteDisjunctiveProgramming" begin @testset "Model" begin @@ -406,6 +709,7 @@ end @testset "Variables" begin test_infinite_logical() test__is_parameter() + test__is_parameter_concrete_dispatches() test_requires_disaggregation() test_variable_properties_infiniteopt() test_variable_properties_from_expr() @@ -429,7 +733,19 @@ end @testset "Methods" begin test_get_constant() test_disaggregate_expression_infiniteopt() - test_unsupported_methods_error() + test_disaggregate_quad_expression_infiniteopt() + end + + @testset "Internal Helpers" begin + test__collect_parameters_no_params() + end + + @testset "MBM" begin + test_mbm_finite_and_integer_var() + test_mbm_infinite_simple() + test_mbm_infinite_param_dependent() + test_mbm_vs_bigm_infinite() + test_mbm_with_derivatives() end @testset "Integration" begin @@ -437,4 +753,12 @@ end test_methods() end + @testset "Cutting Planes" begin + test_CuttingPlanes_infinite_simple() + test_CuttingPlanes_infinite_two_disj() + test_CuttingPlanes_with_cuts() + test_CuttingPlanes_multiparameter() + end + + end \ No newline at end of file From ab3370bb2be7310ba20a020a61f8880f8bd92d6d Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Mon, 20 Apr 2026 21:55:45 -0400 Subject: [PATCH 10/59] . --- Project.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Project.toml b/Project.toml index 3346659a..ae0dad94 100644 --- a/Project.toml +++ b/Project.toml @@ -27,10 +27,11 @@ julia = "1.10" [extras] Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" HiGHS = "87dc4568-4c63-4d18-b0c0-bb2238e4078b" +InfiniteOpt = "20393b10-9daf-11e9-18c9-8db751c92c57" Interpolations = "a98d9a8b-a2ab-59e6-89dd-64a1c18fca59" Ipopt = "b6b21f68-93f8-5de0-b562-5493be1d77c9" Juniper = "2ddba703-00a4-53a7-87a5-e8b9971dde84" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [targets] -test = ["Aqua", "HiGHS", "Test", "Juniper", "Ipopt", "Interpolations"] +test = ["Aqua", "HiGHS", "InfiniteOpt", "Test", "Juniper", "Ipopt", "Interpolations"] From 65e743f4e25aba7a27f6c6e266a6ae7562947c9e Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Tue, 21 Apr 2026 10:23:19 -0400 Subject: [PATCH 11/59] . --- ext/InfiniteDisjunctiveProgramming.jl | 9 +++++---- src/extension_api.jl | 10 +++++----- src/utilities.jl | 13 ------------- test/extensions/InfiniteDisjunctiveProgramming.jl | 3 +-- 4 files changed, 11 insertions(+), 24 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 8f5cd1e0..157ebddc 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -297,7 +297,7 @@ function DP.copy_model_with_constraints( ref_map[p] = new_p end - # 2. Copy decision variables with bounds (skip parameters) + # 2. Copy decision variables with bounds for v in JuMP.all_variables(model) _is_parameter(v) && continue prefs = InfiniteOpt.parameter_refs(v) @@ -351,8 +351,7 @@ function DP.copy_model_with_constraints( end # 7. Remap fwd_map: original model var -> flat JuMP VarRef - fwd_map = Dict{InfiniteOpt.GeneralVariableRef, - Vector{JuMP.VariableRef}}() + fwd_map = Dict{InfiniteOpt.GeneralVariableRef, Vector{JuMP.VariableRef}}() for (orig, mapped) in ref_map _is_parameter(orig) && continue haskey(tr_fwd, mapped) || continue @@ -561,7 +560,9 @@ function DP.add_cut( ) prefs, sups = _collect_parameters(model) inf_terms = Any[] - cut_scalar = DP._zero_aff(model) + cut_scalar = zero(JuMP.GenericAffExpr{ + JuMP.value_type(typeof(model)), + InfiniteOpt.GeneralVariableRef}) for var in decision_vars haskey(rBM_sol, var) || continue haskey(sep_sol, var) || continue diff --git a/src/extension_api.jl b/src/extension_api.jl index 6d046450..ad3566f6 100644 --- a/src/extension_api.jl +++ b/src/extension_api.jl @@ -1,8 +1,8 @@ """ InfiniteGDPModel(args...; kwargs...) -Creates an `InfiniteOpt.InfiniteModel` that is compatible with the -capabiltiies provided by DisjunctiveProgramming.jl. This requires +Creates an `InfiniteOpt.InfiniteModel` that is compatible with the +capabiltiies provided by DisjunctiveProgramming.jl. This requires that InfiniteOpt be imported first. **Example** @@ -18,9 +18,9 @@ function InfiniteGDPModel end """ InfiniteLogical(prefs...) -Allows users to create infinite logical variables. This is a tag -for the `@variable` macro that is a combination of `InfiniteOpt.Infinite` -and `DisjunctiveProgramming.Logical`. This requires that InfiniteOpt be +Allows users to create infinite logical variables. This is a tag +for the `@variable` macro that is a combination of `InfiniteOpt.Infinite` +and `DisjunctiveProgramming.Logical`. This requires that InfiniteOpt be first imported. **Example** diff --git a/src/utilities.jl b/src/utilities.jl index 69d3f4ca..b0203d70 100644 --- a/src/utilities.jl +++ b/src/utilities.jl @@ -129,19 +129,6 @@ function get_constant(expr::JuMP.AbstractVariableRef) return zero(JuMP.value_type(typeof(JuMP.owner_model(expr)))) end -################################################################################ -# ZERO EXPRESSION CONSTRUCTORS -################################################################################ -# Create a type-correct zero affine expression for the model. -_zero_aff(model::JuMP.AbstractModel) = zero( - JuMP.GenericAffExpr{JuMP.value_type(typeof(model)), - JuMP.variable_ref_type(model)}) - -# Create a type-correct zero quadratic expression for the model. -_zero_quad(model::JuMP.AbstractModel) = zero( - JuMP.GenericQuadExpr{JuMP.value_type(typeof(model)), - JuMP.variable_ref_type(model)}) - ################################################################################ # MODEL COPYING ################################################################################ diff --git a/test/extensions/InfiniteDisjunctiveProgramming.jl b/test/extensions/InfiniteDisjunctiveProgramming.jl index e2dda56c..22f870a9 100644 --- a/test/extensions/InfiniteDisjunctiveProgramming.jl +++ b/test/extensions/InfiniteDisjunctiveProgramming.jl @@ -760,5 +760,4 @@ end test_CuttingPlanes_multiparameter() end - -end \ No newline at end of file +end From 882390e4abad398f35532f5ecdaef2839446ea37 Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Tue, 21 Apr 2026 12:20:27 -0400 Subject: [PATCH 12/59] . --- ext/InfiniteDisjunctiveProgramming.jl | 108 ++++++++------------------ 1 file changed, 34 insertions(+), 74 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 157ebddc..7ad421c7 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -255,7 +255,7 @@ function _build_flat_map( flat_map = Dict{InfiniteOpt.GeneralVariableRef, Any}() for (v, ws) in sub.fwd_map if length(ws) == 1 - flat_map[v] = ws[1] + flat_map[v] = only(ws) else vp = InfiniteOpt.parameter_refs(v) shape = Tuple(length(supports[p]) for p in vp) @@ -427,25 +427,31 @@ function DP.raw_M( end end model = JuMP.owner_model(first(keys(sub.fwd_map))) - return _aggregate_M_values(model, M_vals) + # Condense flat per-support values: scalar if uniform, else pf. + all(==(M_vals[1]), M_vals) && return M_vals[1] + prefs, supports = _collect_parameters(model) + grids = Tuple(supports[p] for p in prefs) + shape = Tuple(length(supports[p]) for p in prefs) + fn = Interpolations.linear_interpolation( + grids, reshape(M_vals, shape), + extrapolation_bc = Interpolations.Line()) + return _make_parameter_function(model, fn, prefs...) end ################################################################################ # TRANSCRIPTION HELPERS ################################################################################ -# Create a parameter function programmatically. Uses -# build_parameter_function + add_parameter_function (the lower-level -# API behind @parameter_function) since the macro doesn't support -# programmatic use. The closure wrapper handles non-Function callables -# like Interpolations.Extrapolation. Accepts any number of prefs via -# varargs: _make_parameter_function(m, f, t) for 1D, (m, f, t, x) for 2D. +# Replacement for @parameter_function in the case of using an interpolation. +# Example (1D interpolation): +# fn = Interpolations.linear_interpolation(grids, vals) +# pf = _make_parameter_function(model, fn, t) # returns a pf ref function _make_parameter_function( model::InfiniteOpt.InfiniteModel, fn, prefs::InfiniteOpt.GeneralVariableRef... ) f = fn isa Function ? fn : ((args...) -> fn(args...)) - pref_arg = length(prefs) == 1 ? prefs[1] : prefs + pref_arg = length(prefs) == 1 ? only(prefs) : prefs pfunc = InfiniteOpt.build_parameter_function(error, f, pref_arg) return InfiniteOpt.add_parameter_function(model, pfunc) end @@ -462,42 +468,6 @@ function _collect_parameters(model::InfiniteOpt.InfiniteModel) return prefs, supports end -# Condense flat per-support M values into a scalar or parameter function. -function _aggregate_M_values( - model::InfiniteOpt.InfiniteModel, - vals::AbstractVector{<:Real} - ) - if all(==(vals[1]), vals) - return vals[1] - end - prefs, supports = _collect_parameters(model) - return values_to_parameter_function(model, vals, prefs, supports) -end - -""" - values_to_parameter_function( - model, vals, prefs, supports - ) - -Interpolate flat per-support values into an InfiniteOpt parameter -function registered on `model`. Builds a grid from `supports`, -reshapes `vals`, fits a linear interpolation, and returns the -registered parameter function ref. -""" -function values_to_parameter_function( - model::InfiniteOpt.InfiniteModel, - vals::AbstractVector{<:Real}, - prefs::Union{Vector{InfiniteOpt.GeneralVariableRef}, - Tuple{Vararg{InfiniteOpt.GeneralVariableRef}}}, - supports::Dict{InfiniteOpt.GeneralVariableRef, Vector{Float64}} - ) - grids = Tuple(supports[p] for p in prefs) - shape = Tuple(length(supports[p]) for p in prefs) - nd = reshape(vals, shape) - fn = Interpolations.linear_interpolation(grids, nd, - extrapolation_bc = Interpolations.Line()) - return _make_parameter_function(model, fn, prefs...) -end ################################################################################ # CUTTING PLANES FOR INFINITEMODEL @@ -549,46 +519,36 @@ function DP.extract_solution(model::InfiniteOpt.InfiniteModel) return sol end -# Add an infinite-form cut to the InfiniteModel. Infinite variables get -# interpolated parameter function coefficients wrapped in integrals; -# finite variables contribute scalar terms. +# Add a flat-sum cut directly to the transformation backend, matching +# the SEP's unweighted Euclidean norm (Trespalacios & Grossmann 2016 +# Eq. 11 applied in the joint transcribed variable space). Then mark +# the backend as ready so the next optimize! reuses the cut-enhanced +# flat model without re-transcribing (which would wipe the cut). function DP.add_cut( model::InfiniteOpt.InfiniteModel, decision_vars::Vector{InfiniteOpt.GeneralVariableRef}, rBM_sol::Dict{<:JuMP.AbstractVariableRef, <:Vector{<:Number}}, sep_sol::Dict{<:JuMP.AbstractVariableRef, <:Vector{<:Number}} ) - prefs, sups = _collect_parameters(model) - inf_terms = Any[] - cut_scalar = zero(JuMP.GenericAffExpr{ - JuMP.value_type(typeof(model)), - InfiniteOpt.GeneralVariableRef}) + flat = InfiniteOpt.transformation_model(model) + cut_expr = zero(JuMP.GenericAffExpr{ + JuMP.value_type(typeof(flat)), + JuMP.variable_ref_type(flat)}) for var in decision_vars haskey(rBM_sol, var) || continue haskey(sep_sol, var) || continue - vprefs = InfiniteOpt.parameter_refs(var) - if isempty(vprefs) - xi = 2 * (sep_sol[var][1] - rBM_sol[var][1]) - cut_scalar += xi * (var - sep_sol[var][1]) - else - xi_vals = 2 .* (sep_sol[var] .- rBM_sol[var]) - sp_vals = sep_sol[var] - xi_pf = values_to_parameter_function( - model, xi_vals, vprefs, sups) - sp_pf = values_to_parameter_function( - model, sp_vals, vprefs, sups) - push!(inf_terms, xi_pf * var - xi_pf * sp_pf) - end - end - if !isempty(inf_terms) - inf_expr = JuMP.@expression(model, sum(inf_terms)) - for p in prefs - inf_expr = InfiniteOpt.integral(inf_expr, p) + rbm_vals = rBM_sol[var] + sep_vals = sep_sol[var] + tv = InfiniteOpt.transformation_variable(var) + flat_vars = tv isa AbstractArray ? vec(tv) : [tv] + for k in eachindex(flat_vars) + xi = 2 * (sep_vals[k] - rbm_vals[k]) + JuMP.add_to_expression!(cut_expr, xi, flat_vars[k]) + JuMP.add_to_expression!(cut_expr, -xi * sep_vals[k]) end - cut_scalar += inf_expr end - cref = JuMP.@constraint(model, cut_scalar >= 0) - push!(DP._reformulation_constraints(model), cref) + JuMP.@constraint(flat, cut_expr >= 0) + InfiniteOpt.set_transformation_backend_ready(model, true) return end From 778b598a4c4cc49dd85e385821bce9e79ca69636 Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Tue, 21 Apr 2026 16:31:41 -0400 Subject: [PATCH 13/59] . --- ext/InfiniteDisjunctiveProgramming.jl | 244 ++++++------------ src/mbm.jl | 10 +- .../InfiniteDisjunctiveProgramming.jl | 160 ++++++++---- 3 files changed, 194 insertions(+), 220 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 7ad421c7..72a38518 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -170,111 +170,34 @@ function DP.disaggregate_expression( return JuMP.@expression(model, sum(terms)) end -# Quadratic expression: handle parameter x parameter, parameter x variable, -# and variable x variable terms. -function DP.disaggregate_expression( - model::M, quad::JuMP.GenericQuadExpr, - bvref::Union{JuMP.AbstractVariableRef, JuMP.GenericAffExpr}, - method::DP._Hull - ) where {M <: InfiniteOpt.InfiniteModel} - # Affine part (uses InfiniteOpt override above) - new_expr = DP.disaggregate_expression(model, quad.aff, bvref, method) - ϵ = method.value - for (pair, coeff) in quad.terms - a_param = pair.a isa InfiniteOpt.GeneralVariableRef && - _is_parameter(pair.a) - b_param = pair.b isa InfiniteOpt.GeneralVariableRef && - _is_parameter(pair.b) - if a_param && b_param - # param × param: constant, scale by y - new_expr += coeff * pair.a * pair.b * bvref - elseif a_param - # param × var: perspective cancels y - db = method.disjunct_variables[pair.b, bvref] - new_expr += coeff * pair.a * db - elseif b_param - # var × param: perspective cancels y - da = method.disjunct_variables[pair.a, bvref] - new_expr += coeff * da * pair.b - else - # var × var: standard perspective - da = method.disjunct_variables[pair.a, bvref] - db = method.disjunct_variables[pair.b, bvref] - new_expr += coeff * da * db / ((1 - ϵ) * bvref + ϵ) - end - end - return new_expr -end - ################################################################################ # MBM FOR INFINITEMODEL ################################################################################ # Reuses the finite MBM infrastructure by overriding: -# copy_model_with_constraints (build mini InfiniteModel + -# transcribe to flat JuMP model), prepare_max_M_objective -# (expand infinite constraint into K flat objectives via -# _build_flat_map), and raw_M (vector dispatch aggregates -# K per-support M values into a parameter function). +# copy_model_with_constraints (build mini InfiniteModel, transcribe to +# flat JuMP, stash mini + main->mini ref_map in sub.model.ext), +# prepare_max_M_objective (translate main-model slack expr to mini-level +# then call InfiniteOpt.transformation_expression to get K flat +# objectives), and raw_M (vector dispatch aggregates K per-support M +# values into a parameter function). # Collect all parameter function refs from all disjunct constraints in # the model. -function _all_param_functions( - model::InfiniteOpt.InfiniteModel - ) - pf_set = Set{InfiniteOpt.GeneralVariableRef}() +function _all_param_functions(model::InfiniteOpt.InfiniteModel) + param_funcs = Set{InfiniteOpt.GeneralVariableRef}() for (_, crefs) in DP._indicator_to_constraints(model) for cref in crefs cref isa DP.DisjunctConstraintRef || continue con = JuMP.constraint_object(cref) for v in InfiniteOpt.all_expression_variables(con.func) - dv = InfiniteOpt.dispatch_variable_ref(v) - if dv isa InfiniteOpt.ParameterFunctionRef - push!(pf_set, v) + dispatch_var = InfiniteOpt.dispatch_variable_ref(v) + if dispatch_var isa InfiniteOpt.ParameterFunctionRef + push!(param_funcs, v) end end end end - return pf_set -end - -# Build a flat map for support point k. Maps decision variables to their -# flat JuMP.VariableRef at support k (handling multi-parameter indexing) -# and evaluates parameter functions to their numerical values. pf_set is -# precomputed by the caller to avoid rescanning all disjunct constraints -# on every support point. -function _build_flat_map( - sub::DP.GDPSubmodel, k::Int, - prefs::Vector{InfiniteOpt.GeneralVariableRef}, - supports::Dict{InfiniteOpt.GeneralVariableRef, Vector{Float64}}, - full_shape::Tuple, - pf_set::Set{InfiniteOpt.GeneralVariableRef} - ) - ci = CartesianIndices(full_shape)[k] - - # Decision variables: map each to its variable-local index - flat_map = Dict{InfiniteOpt.GeneralVariableRef, Any}() - for (v, ws) in sub.fwd_map - if length(ws) == 1 - flat_map[v] = only(ws) - else - vp = InfiniteOpt.parameter_refs(v) - shape = Tuple(length(supports[p]) for p in vp) - idx = Tuple(ci[findfirst(==(p), prefs)] for p in vp) - flat_map[v] = ws[LinearIndices(shape)[idx...]] - end - end - - # Parameter functions: evaluate at support point k - sup_vals = Dict( - prefs[i] => supports[prefs[i]][ci[i]] - for i in 1:length(prefs)) - for pf in pf_set - fn = InfiniteOpt.raw_function(pf) - pf_prefs = InfiniteOpt.parameter_refs(pf) - pf_vals = Tuple(sup_vals[p] for p in pf_prefs) - flat_map[pf] = fn(pf_vals...) - end - return flat_map + return param_funcs end # Build mini InfiniteModel with only the given disjunct constraints, @@ -291,10 +214,10 @@ function DP.copy_model_with_constraints( # 1. Copy infinite parameters with their supports for p in InfiniteOpt.all_parameters(model) domain = InfiniteOpt.infinite_domain(p) - sups = Float64.(InfiniteOpt.supports(p)) - param = InfiniteOpt.build_parameter(error, domain; supports = sups) - new_p = InfiniteOpt.add_parameter(mini, param, JuMP.name(p)) - ref_map[p] = new_p + supports = Float64.(InfiniteOpt.supports(p)) + param = InfiniteOpt.build_parameter(error, domain; supports = supports) + new_param = InfiniteOpt.add_parameter(mini, param, JuMP.name(p)) + ref_map[p] = new_param end # 2. Copy decision variables with bounds @@ -321,13 +244,13 @@ function DP.copy_model_with_constraints( # 4. Copy parameter functions from ALL disjuncts (needed for # constraint transcription) - pf_set = _all_param_functions(model) - for pf in pf_set - fn = InfiniteOpt.raw_function(pf) - prefs = InfiniteOpt.parameter_refs(pf) + param_funcs = _all_param_functions(model) + for pfunc in param_funcs + func = InfiniteOpt.raw_function(pfunc) + prefs = InfiniteOpt.parameter_refs(pfunc) mapped_prefs = Tuple(ref_map[p] for p in prefs) - new_pf = _make_parameter_function(mini, fn, mapped_prefs...) - ref_map[pf] = new_pf + new_pfunc = _make_parameter_function(mini, func, mapped_prefs...) + ref_map[pfunc] = new_pfunc end # 5. Add disjunct constraints using existing ref_map @@ -342,72 +265,51 @@ function DP.copy_model_with_constraints( # 6. Transcribe mini InfiniteModel to flat JuMP model InfiniteOpt.build_transformation_backend!(mini) flat = InfiniteOpt.transformation_model(mini) - tr_fwd = Dict{InfiniteOpt.GeneralVariableRef, - Vector{JuMP.VariableRef}}() - for v in DP.collect_all_vars(mini) - tv = InfiniteOpt.transformation_variable(v) - vprefs = InfiniteOpt.parameter_refs(v) - tr_fwd[v] = isempty(vprefs) ? [tv] : vec(tv) - end - - # 7. Remap fwd_map: original model var -> flat JuMP VarRef - fwd_map = Dict{InfiniteOpt.GeneralVariableRef, Vector{JuMP.VariableRef}}() - for (orig, mapped) in ref_map - _is_parameter(orig) && continue - haskey(tr_fwd, mapped) || continue - fwd_map[orig] = tr_fwd[mapped] - end - - decision_vars = collect(keys(fwd_map)) JuMP.set_optimizer(flat, method.optimizer) JuMP.set_silent(flat) - return DP.GDPSubmodel(flat, decision_vars, fwd_map) + # Stash main + ref_map so prepare_max_M_objective can translate + # main-model expressions and let InfiniteOpt transcribe them via + # mini's backend. Also stash main so raw_M can return a parameter + # function on main (where it will be used in BigM constraints). + flat.ext[:inf_mbm_main] = model + flat.ext[:inf_mbm_ref_map] = ref_map + # fwd_map / decision_vars are CP-shaped fields on GDPSubmodel that + # the MBM path through our overrides does not consult; pass empty + # containers of the right types. + return DP.GDPSubmodel(flat, InfiniteOpt.GeneralVariableRef[], + Dict{InfiniteOpt.GeneralVariableRef, Vector{JuMP.VariableRef}}()) end -# Prepare objectives for all support points. Expands an infinite -# constraint into K flat objectives via _build_flat_map with -# multi-parameter indexing and parameter function evaluation. +# Translate the constraint slack to the mini InfiniteModel via ref_map, +# then use InfiniteOpt.transformation_expression to get one JuMP scalar +# (or plain Real, for pure-parameter slacks) per support point. function DP.prepare_max_M_objective( - model::InfiniteOpt.InfiniteModel, + ::InfiniteOpt.InfiniteModel, obj::JuMP.ScalarConstraint{T, S}, sub::DP.GDPSubmodel ) where {T, S <: _MOI.LessThan} - prefs, supports = _collect_parameters(model) - full_shape = Tuple(length(supports[p]) for p in prefs) - K = prod(full_shape) - pf_set = _all_param_functions(model) - objectives = Vector{JuMP.AbstractJuMPScalar}(undef, K) - for k in 1:K - flat_map = _build_flat_map(sub, k, prefs, supports, full_shape, pf_set) - objectives[k] = -obj.set.upper + - DP._replace_variables_in_constraint(obj.func, flat_map) - end - return objectives + ref_map = sub.model.ext[:inf_mbm_ref_map] + mini_expr = DP._replace_variables_in_constraint(obj.func, ref_map) + return InfiniteOpt.transformation_expression(mini_expr - obj.set.upper) end function DP.prepare_max_M_objective( - model::InfiniteOpt.InfiniteModel, + ::InfiniteOpt.InfiniteModel, obj::JuMP.ScalarConstraint{T, S}, sub::DP.GDPSubmodel ) where {T, S <: _MOI.GreaterThan} - prefs, supports = _collect_parameters(model) - full_shape = Tuple(length(supports[p]) for p in prefs) - K = prod(full_shape) - pf_set = _all_param_functions(model) - objectives = Vector{JuMP.AbstractJuMPScalar}(undef, K) - for k in 1:K - flat_map = _build_flat_map(sub, k, prefs, supports, full_shape, pf_set) - objectives[k] = obj.set.lower - - DP._replace_variables_in_constraint(obj.func, flat_map) - end - return objectives + ref_map = sub.model.ext[:inf_mbm_ref_map] + mini_expr = DP._replace_variables_in_constraint(obj.func, ref_map) + return InfiniteOpt.transformation_expression(obj.set.lower - mini_expr) end # Solve the submodel for a vector of objectives (one per support point). -# Returns aggregated M value (scalar or parameter function) or nothing. +# Elements may be Real when the slack is pure-parameter at that support; +# JuMP's @objective accepts Real and the solver treats it as a constant +# objective, which gives the correct M value for that slice. function DP.raw_M( sub::DP.GDPSubmodel, - objectives::Vector{<:JuMP.AbstractJuMPScalar}, + objectives::Vector{<:Union{Real, JuMP.AbstractJuMPScalar}}, method::DP._MBM ) M_vals = typeof(method.default_M)[] @@ -426,16 +328,15 @@ function DP.raw_M( push!(M_vals, method.default_M) end end - model = JuMP.owner_model(first(keys(sub.fwd_map))) - # Condense flat per-support values: scalar if uniform, else pf. + model = sub.model.ext[:inf_mbm_main] + # Condense per-support values: scalar if uniform, else pfunc. all(==(M_vals[1]), M_vals) && return M_vals[1] prefs, supports = _collect_parameters(model) grids = Tuple(supports[p] for p in prefs) shape = Tuple(length(supports[p]) for p in prefs) - fn = Interpolations.linear_interpolation( - grids, reshape(M_vals, shape), + func = Interpolations.linear_interpolation(grids, reshape(M_vals, shape), extrapolation_bc = Interpolations.Line()) - return _make_parameter_function(model, fn, prefs...) + return _make_parameter_function(model, func, prefs...) end ################################################################################ @@ -444,16 +345,17 @@ end # Replacement for @parameter_function in the case of using an interpolation. # Example (1D interpolation): -# fn = Interpolations.linear_interpolation(grids, vals) -# pf = _make_parameter_function(model, fn, t) # returns a pf ref +# func = Interpolations.linear_interpolation(grids, vals) +# pfunc = _make_parameter_function(model, func, t) # returns a pfunc ref function _make_parameter_function( - model::InfiniteOpt.InfiniteModel, fn, + model::InfiniteOpt.InfiniteModel, func, prefs::InfiniteOpt.GeneralVariableRef... ) - f = fn isa Function ? fn : ((args...) -> fn(args...)) + wrapped_func = func isa Function ? func : ((args...) -> func(args...)) pref_arg = length(prefs) == 1 ? only(prefs) : prefs - pfunc = InfiniteOpt.build_parameter_function(error, f, pref_arg) - return InfiniteOpt.add_parameter_function(model, pfunc) + builder = InfiniteOpt.build_parameter_function( + error, wrapped_func, pref_arg) + return InfiniteOpt.add_parameter_function(model, builder) end # Collect all infinite parameters and their supports from the model. @@ -484,19 +386,19 @@ function DP.copy_and_reformulate( DP.reformulate_model(model, reform_method) InfiniteOpt.build_transformation_backend!(model) flat = InfiniteOpt.transformation_model(model) - tr_fwd = Dict{InfiniteOpt.GeneralVariableRef, + transcription_fwd = Dict{InfiniteOpt.GeneralVariableRef, Vector{JuMP.VariableRef}}() for v in DP.collect_all_vars(model) - tv = InfiniteOpt.transformation_variable(v) - vprefs = InfiniteOpt.parameter_refs(v) - tr_fwd[v] = isempty(vprefs) ? [tv] : vec(tv) + transcription_var = InfiniteOpt.transformation_variable(v) + var_prefs = InfiniteOpt.parameter_refs(v) + transcription_fwd[v] = isempty(var_prefs) ? + [transcription_var] : vec(transcription_var) end sub_copy, copy_map = JuMP.copy_model(flat) - fwd_map = Dict{InfiniteOpt.GeneralVariableRef, - Vector{JuMP.VariableRef}}() + fwd_map = Dict{InfiniteOpt.GeneralVariableRef, Vector{JuMP.VariableRef}}() for v in decision_vars - haskey(tr_fwd, v) || continue - fwd_map[v] = [copy_map[tv] for tv in tr_fwd[v]] + haskey(transcription_fwd, v) || continue + fwd_map[v] = [copy_map[flat_var] for flat_var in transcription_fwd[v]] end sub = DP.GDPSubmodel(sub_copy, decision_vars, fwd_map) JuMP.set_optimizer(sub.model, method.optimizer) @@ -512,9 +414,10 @@ function DP.extract_solution(model::InfiniteOpt.InfiniteModel) T = JuMP.value_type(typeof(model)) sol = Dict{V, Vector{T}}() for v in dvars - tv = InfiniteOpt.transformation_variable(v) - vprefs = InfiniteOpt.parameter_refs(v) - sol[v] = isempty(vprefs) ? [JuMP.value(tv)] : JuMP.value.(vec(tv)) + transcription_var = InfiniteOpt.transformation_variable(v) + var_prefs = InfiniteOpt.parameter_refs(v) + sol[v] = isempty(var_prefs) ? [JuMP.value(transcription_var)] : + JuMP.value.(vec(transcription_var)) end return sol end @@ -539,8 +442,9 @@ function DP.add_cut( haskey(sep_sol, var) || continue rbm_vals = rBM_sol[var] sep_vals = sep_sol[var] - tv = InfiniteOpt.transformation_variable(var) - flat_vars = tv isa AbstractArray ? vec(tv) : [tv] + transcription_var = InfiniteOpt.transformation_variable(var) + flat_vars = transcription_var isa AbstractArray ? + vec(transcription_var) : [transcription_var] for k in eachindex(flat_vars) xi = 2 * (sep_vals[k] - rbm_vals[k]) JuMP.add_to_expression!(cut_expr, xi, flat_vars[k]) diff --git a/src/mbm.jl b/src/mbm.jl index 8d8fc1cf..4fd844c3 100644 --- a/src/mbm.jl +++ b/src/mbm.jl @@ -264,8 +264,14 @@ function prepare_max_M_objective( return expr end -# Solve the submodel for a single objective expression. -# Returns a scalar M value, or nothing if infeasible. +""" + raw_M(sub::GDPSubmodel, objective, method::_MBM) + +Maximize `objective` over `sub` to obtain one raw M value for MBM. +Returns `max(obj_value, 0)` on optimal, `nothing` on infeasible +(signals the constraint is redundant in the combined region), or +`method.default_M` otherwise (unbounded, numerical failure, etc). +""" function raw_M( sub::GDPSubmodel, objective::JuMP.AbstractJuMPScalar, diff --git a/test/extensions/InfiniteDisjunctiveProgramming.jl b/test/extensions/InfiniteDisjunctiveProgramming.jl index 22f870a9..7f8495ea 100644 --- a/test/extensions/InfiniteDisjunctiveProgramming.jl +++ b/test/extensions/InfiniteDisjunctiveProgramming.jl @@ -77,9 +77,9 @@ function test__is_parameter() @test IDP._is_parameter(y) == false end -# _is_parameter on unwrapped concrete dispatch types. Covers -# ext lines 28-32 (DependentParameterRef, IndependentParameterRef, -# FiniteParameterRef, ParameterFunctionRef, Any fallback). +# _is_parameter on unwrapped concrete dispatch types +# (DependentParameterRef, IndependentParameterRef, FiniteParameterRef, +# ParameterFunctionRef, Any fallback). function test__is_parameter_concrete_dispatches() model = InfiniteGDPModel() @infinite_parameter(model, t ∈ [0, 1]) @@ -187,43 +187,6 @@ function test_disaggregate_expression_infiniteopt() @test haskey(result_not_disagg.terms, y) end -function test_disaggregate_quad_expression_infiniteopt() - model = InfiniteGDPModel() - @infinite_parameter(model, t ∈ [0, 1]) - @variable(model, 0 <= x <= 10, Infinite(t)) - @variable(model, 0 <= y <= 5, Infinite(t)) - @variable(model, z, InfiniteLogical(t)) - - bvrefs = DP._indicator_to_binary(model) - bvref = bvrefs[z] - - vrefs = Set([x, y]) - DP._variable_bounds(model)[x] = DP.set_variable_bound_info(x, Hull()) - DP._variable_bounds(model)[y] = DP.set_variable_bound_info(y, Hull()) - method = DP._Hull(Hull(1e-3), vrefs) - DP._disaggregate_variables(model, z, vrefs, method) - - # var × var → nonlinear (perspective divides by y) - quad_vv = @expression(model, x * y) - result_vv = DP.disaggregate_expression(model, quad_vv, bvref, method) - @test result_vv isa JuMP.GenericNonlinearExpr - - # param × var → quadratic (param * disaggregated) - quad_pv = @expression(model, t * x) - result_pv = DP.disaggregate_expression(model, quad_pv, bvref, method) - @test result_pv isa JuMP.GenericQuadExpr - - # var × param → quadratic (disaggregated * param) - quad_vp = @expression(model, x * t) - result_vp = DP.disaggregate_expression(model, quad_vp, bvref, method) - @test result_vp isa JuMP.GenericQuadExpr - - # param × param → cubic (t * t * bvref) - quad_pp = @expression(model, t * t) - result_pp = DP.disaggregate_expression(model, quad_pp, bvref, method) - @test result_pp isa JuMP.GenericNonlinearExpr -end - function test_variable_properties_infiniteopt() model = InfiniteGDPModel() @infinite_parameter(model, t ∈ [0, 1]) @@ -392,15 +355,113 @@ function test_logical_value() end # _collect_parameters on model with no infinite parameters. -# Covers ext line 508. function test__collect_parameters_no_params() model = InfiniteGDPModel() @test_throws ErrorException IDP._collect_parameters(model) end -# MBM with finite + integer variables in InfiniteModel. Covers -# copy_model_with_constraints (finite var, set_integer), -# and _build_flat_map line 252 (finite var path). +# raw_M against an InfiniteModel where M is constant across supports. +# Setup: x(t) ∈ [0, 10], disj1: x ≥ 5, disj2: x ≤ 3. +# For disj1 slack r(x) = 5 - x maximized over disj2's region x ∈ [0, 3]: +# max(5 - x) = 5 at x = 0. Same at every support ⇒ scalar M = 5. +function test_raw_M_infinite_scalar() + model = InfiniteGDPModel() + @infinite_parameter(model, t ∈ [0, 1], num_supports = 5) + @variable(model, 0 <= x <= 10, Infinite(t)) + @variable(model, Y[1:2], InfiniteLogical(t)) + @constraint(model, con, x >= 5, Disjunct(Y[1])) + @constraint(model, con2, x <= 3, Disjunct(Y[2])) + @disjunction(model, Y) + mbm = DP._MBM(MBM(HiGHS.Optimizer), model) + sub = DP.copy_model_with_constraints( + model, DP.DisjunctConstraintRef[con2], mbm) + obj = DP.prepare_max_M_objective( + model, JuMP.constraint_object(con), sub) + @test length(obj) == 5 # K support points + @test DP.raw_M(sub, obj, mbm) == 5.0 +end + +# raw_M with a support-varying M. Setup: x(t) ∈ [0, 10], disj1: x ≤ 2t, +# disj2: x ≥ 0.5. Slack r(x) = x - 2t maximized over x ∈ [0.5, 10]: +# max(x - 2t) = 10 - 2t. Varies with t ⇒ raw_M returns a pfunc; the +# underlying function should evaluate to 10 - 2t at each support. +function test_raw_M_infinite_param_function() + model = InfiniteGDPModel() + supports = [0.0, 0.25, 0.5, 0.75, 1.0] + @infinite_parameter(model, t ∈ [0, 1], supports = supports) + @variable(model, 0 <= x <= 10, Infinite(t)) + @variable(model, Y[1:2], InfiniteLogical(t)) + @parameter_function(model, f == t -> 2*t) + @constraint(model, con, x <= f, Disjunct(Y[1])) + @constraint(model, con2, x >= 0.5, Disjunct(Y[2])) + @disjunction(model, Y) + mbm = DP._MBM(MBM(HiGHS.Optimizer), model) + sub = DP.copy_model_with_constraints( + model, DP.DisjunctConstraintRef[con2], mbm) + obj = DP.prepare_max_M_objective( + model, JuMP.constraint_object(con), sub) + M = DP.raw_M(sub, obj, mbm) + @test M isa InfiniteOpt.GeneralVariableRef + raw_fn = InfiniteOpt.raw_function(M) + for t_val in supports + @test raw_fn(t_val) ≈ 10.0 - 2*t_val atol=1e-6 + end +end + +# extract_solution returns per-support values from the transformation +# backend. Setup: force disj 2 active (x ≤ 3), BigM-reformulate, solve +# min ∫x ⇒ x = 0 at every support. +function test_extract_solution_infinite() + model = InfiniteGDPModel(HiGHS.Optimizer) + set_silent(model) + K = 4 + @infinite_parameter(model, t ∈ [0, 1], num_supports = K) + @variable(model, 0 <= x <= 10, Infinite(t)) + @variable(model, Y[1:2], InfiniteLogical(t)) + @constraint(model, x >= 5, Disjunct(Y[1])) + @constraint(model, x <= 3, Disjunct(Y[2])) + @disjunction(model, Y) + JuMP.fix(Y[2], true) # force disj 2 active + @objective(model, Min, ∫(x, t)) + DP.reformulate_model(model, BigM(10.0)) + set_optimizer(model, HiGHS.Optimizer) + set_silent(model) + optimize!(model, ignore_optimize_hook = true) + sol = DP.extract_solution(model) + @test haskey(sol, x) + @test length(sol[x]) == K + @test all(v -> isapprox(v, 0.0; atol=1e-6), sol[x]) +end + +# add_cut adds one flat-sum cut to the transformation backend and marks +# the backend ready so the next optimize! does NOT re-transcribe. +function test_add_cut_infinite() + model = InfiniteGDPModel(HiGHS.Optimizer) + set_silent(model) + K = 3 + @infinite_parameter(model, t ∈ [0, 1], num_supports = K) + @variable(model, 0 <= x <= 10, Infinite(t)) + @variable(model, Y[1:2], InfiniteLogical(t)) + @constraint(model, x >= 5, Disjunct(Y[1])) + @constraint(model, x <= 3, Disjunct(Y[2])) + @disjunction(model, Y) + DP.reformulate_model(model, BigM(10.0)) + InfiniteOpt.build_transformation_backend!(model) + flat = InfiniteOpt.transformation_model(model) + n_before = JuMP.num_constraints(flat; + count_variable_in_set_constraints = false) + rBM_sol = Dict(x => [1.0, 2.0, 3.0]) + sep_sol = Dict(x => [0.5, 1.5, 2.5]) + DP.add_cut(model, [x], rBM_sol, sep_sol) + n_after = JuMP.num_constraints(flat; + count_variable_in_set_constraints = false) + @test n_after == n_before + 1 + # set_transformation_backend_ready(true) — next optimize! should + # reuse without re-transcribing (otherwise our cut would be lost) + @test InfiniteOpt.transformation_backend_ready(model) +end + +# MBM with finite + integer variables in an InfiniteModel. function test_mbm_finite_and_integer_var() model = InfiniteGDPModel(HiGHS.Optimizer) set_silent(model) @@ -651,9 +712,9 @@ end function test_CuttingPlanes_with_cuts() # Maximization with single-constraint disjuncts where Hull # is strictly tighter than BigM. BigM allows x+y up to - # variable bounds (20), Hull limits to max(5,8)=8. This - # forces cuts. Finite var w exercises isempty(vprefs) - # branch in add_original_model_cut (line 779). + # variable bounds (20), Hull limits to max(5,8)=8 — this + # forces cuts to tighten the relaxation. Finite var w exercises + # the isempty(var_prefs) branch in add_cut. model = InfiniteGDPModel(HiGHS.Optimizer) set_silent(model) @infinite_parameter(model, t ∈ [0, 1], num_supports = 10) @@ -733,7 +794,6 @@ end @testset "Methods" begin test_get_constant() test_disaggregate_expression_infiniteopt() - test_disaggregate_quad_expression_infiniteopt() end @testset "Internal Helpers" begin @@ -741,6 +801,8 @@ end end @testset "MBM" begin + test_raw_M_infinite_scalar() + test_raw_M_infinite_param_function() test_mbm_finite_and_integer_var() test_mbm_infinite_simple() test_mbm_infinite_param_dependent() @@ -754,6 +816,8 @@ end end @testset "Cutting Planes" begin + test_extract_solution_infinite() + test_add_cut_infinite() test_CuttingPlanes_infinite_simple() test_CuttingPlanes_infinite_two_disj() test_CuttingPlanes_with_cuts() From 2c0071a36805afc52f36de975de0e3881247b71d Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Tue, 21 Apr 2026 18:35:47 -0400 Subject: [PATCH 14/59] . --- ext/InfiniteDisjunctiveProgramming.jl | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 72a38518..7982b192 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -303,10 +303,15 @@ function DP.prepare_max_M_objective( return InfiniteOpt.transformation_expression(obj.set.lower - mini_expr) end +# Real dispatch: pure-parameter slacks collapse to a constant at that +# support. Mirrors the max(value, 0) semantics of the scalar base. +DP.raw_M(::DP.GDPSubmodel, obj::Real, method::DP._MBM) = + max(obj, zero(method.default_M)) + # Solve the submodel for a vector of objectives (one per support point). -# Elements may be Real when the slack is pure-parameter at that support; -# JuMP's @objective accepts Real and the solver treats it as a constant -# objective, which gives the correct M value for that slice. +# Clears start values before each solve (Gurobi refuses NaN warmstarts +# that can linger from a prior unbounded solve) and delegates each +# element to the scalar base `raw_M` above. function DP.raw_M( sub::DP.GDPSubmodel, objectives::Vector{<:Union{Real, JuMP.AbstractJuMPScalar}}, @@ -315,18 +320,9 @@ function DP.raw_M( M_vals = typeof(method.default_M)[] for obj_expr in objectives JuMP.set_start_value.(JuMP.all_variables(sub.model), nothing) - JuMP.@objective(sub.model, Max, obj_expr) - JuMP.optimize!(sub.model) - if JuMP.is_solved_and_feasible(sub.model) - push!(M_vals, max( - JuMP.objective_value(sub.model), - zero(method.default_M))) - elseif JuMP.termination_status(sub.model) == - JuMP.MOI.INFEASIBLE - return nothing - else - push!(M_vals, method.default_M) - end + m = DP.raw_M(sub, obj_expr, method) + m === nothing && return nothing + push!(M_vals, m) end model = sub.model.ext[:inf_mbm_main] # Condense per-support values: scalar if uniform, else pfunc. From a77b98833af2fa3476998da3d6fb56c8e3558732 Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Tue, 21 Apr 2026 20:59:25 -0400 Subject: [PATCH 15/59] . --- ext/InfiniteDisjunctiveProgramming.jl | 19 ++++++----- .../InfiniteDisjunctiveProgramming.jl | 32 ++++++++++++------- 2 files changed, 30 insertions(+), 21 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 7982b192..631875bb 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -282,7 +282,9 @@ end # Translate the constraint slack to the mini InfiniteModel via ref_map, # then use InfiniteOpt.transformation_expression to get one JuMP scalar -# (or plain Real, for pure-parameter slacks) per support point. +# per support point. Narrows the declared Vector{Union{Real, …}} return +# to Vector{AbstractJuMPScalar}; errors loudly if a support gives a +# pure-constant slack (would require a degenerate disjunct constraint). function DP.prepare_max_M_objective( ::InfiniteOpt.InfiniteModel, obj::JuMP.ScalarConstraint{T, S}, @@ -290,7 +292,8 @@ function DP.prepare_max_M_objective( ) where {T, S <: _MOI.LessThan} ref_map = sub.model.ext[:inf_mbm_ref_map] mini_expr = DP._replace_variables_in_constraint(obj.func, ref_map) - return InfiniteOpt.transformation_expression(mini_expr - obj.set.upper) + return Vector{JuMP.AbstractJuMPScalar}( + InfiniteOpt.transformation_expression(mini_expr - obj.set.upper)) end function DP.prepare_max_M_objective( @@ -300,21 +303,17 @@ function DP.prepare_max_M_objective( ) where {T, S <: _MOI.GreaterThan} ref_map = sub.model.ext[:inf_mbm_ref_map] mini_expr = DP._replace_variables_in_constraint(obj.func, ref_map) - return InfiniteOpt.transformation_expression(obj.set.lower - mini_expr) + return Vector{JuMP.AbstractJuMPScalar}( + InfiniteOpt.transformation_expression(obj.set.lower - mini_expr)) end -# Real dispatch: pure-parameter slacks collapse to a constant at that -# support. Mirrors the max(value, 0) semantics of the scalar base. -DP.raw_M(::DP.GDPSubmodel, obj::Real, method::DP._MBM) = - max(obj, zero(method.default_M)) - # Solve the submodel for a vector of objectives (one per support point). # Clears start values before each solve (Gurobi refuses NaN warmstarts # that can linger from a prior unbounded solve) and delegates each -# element to the scalar base `raw_M` above. +# element to the scalar base `raw_M`. function DP.raw_M( sub::DP.GDPSubmodel, - objectives::Vector{<:Union{Real, JuMP.AbstractJuMPScalar}}, + objectives::Vector{<:JuMP.AbstractJuMPScalar}, method::DP._MBM ) M_vals = typeof(method.default_M)[] diff --git a/test/extensions/InfiniteDisjunctiveProgramming.jl b/test/extensions/InfiniteDisjunctiveProgramming.jl index 7f8495ea..e7fed04d 100644 --- a/test/extensions/InfiniteDisjunctiveProgramming.jl +++ b/test/extensions/InfiniteDisjunctiveProgramming.jl @@ -55,7 +55,7 @@ function test_infinite_logical() @test binary_variable(y) isa InfiniteOpt.GeneralVariableRef end -function test__is_parameter() +function test_is_parameter() model = InfiniteGDPModel() @infinite_parameter(model, t ∈ [0, 1]) @infinite_parameter(model, s[1:2] ∈ [0, 1], independent = true) @@ -80,19 +80,29 @@ end # _is_parameter on unwrapped concrete dispatch types # (DependentParameterRef, IndependentParameterRef, FiniteParameterRef, # ParameterFunctionRef, Any fallback). -function test__is_parameter_concrete_dispatches() +function test_is_parameter_concrete_dispatches() model = InfiniteGDPModel() + # Scalar + `independent = true` array both give IndependentParameterRef; + # a default array parameter gives DependentParameterRef. @infinite_parameter(model, t ∈ [0, 1]) @infinite_parameter(model, s[1:2] ∈ [0, 1], independent = true) + @infinite_parameter(model, q[1:2] ∈ [0, 1]) @finite_parameter(model, p == 1.0) @variable(model, x, Infinite(t)) @parameter_function(model, pf == t -> 2*t) dvr = InfiniteOpt.dispatch_variable_ref - @test IDP._is_parameter(dvr(t)) == true # Dependent - @test IDP._is_parameter(dvr(s[1])) == true # Independent - @test IDP._is_parameter(dvr(p)) == true # Finite - @test IDP._is_parameter(dvr(pf)) == true # ParamFunc - @test IDP._is_parameter(dvr(x)) == false # Any + # Verify each ref hits the intended dispatch. + @test dvr(t) isa InfiniteOpt.IndependentParameterRef + @test dvr(s[1]) isa InfiniteOpt.IndependentParameterRef + @test dvr(q[1]) isa InfiniteOpt.DependentParameterRef + @test dvr(p) isa InfiniteOpt.FiniteParameterRef + @test dvr(pf) isa InfiniteOpt.ParameterFunctionRef + @test IDP._is_parameter(dvr(t)) == true + @test IDP._is_parameter(dvr(s[1])) == true + @test IDP._is_parameter(dvr(q[1])) == true + @test IDP._is_parameter(dvr(p)) == true + @test IDP._is_parameter(dvr(pf)) == true + @test IDP._is_parameter(dvr(x)) == false # Any fallback end function test_requires_disaggregation() @@ -355,7 +365,7 @@ function test_logical_value() end # _collect_parameters on model with no infinite parameters. -function test__collect_parameters_no_params() +function test_collect_parameters_no_params() model = InfiniteGDPModel() @test_throws ErrorException IDP._collect_parameters(model) end @@ -769,8 +779,8 @@ end @testset "Variables" begin test_infinite_logical() - test__is_parameter() - test__is_parameter_concrete_dispatches() + test_is_parameter() + test_is_parameter_concrete_dispatches() test_requires_disaggregation() test_variable_properties_infiniteopt() test_variable_properties_from_expr() @@ -797,7 +807,7 @@ end end @testset "Internal Helpers" begin - test__collect_parameters_no_params() + test_collect_parameters_no_params() end @testset "MBM" begin From 1ec83f4f8a789f9a5afd22aa399c4834278afe75 Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Tue, 21 Apr 2026 22:46:02 -0400 Subject: [PATCH 16/59] . --- ext/InfiniteDisjunctiveProgramming.jl | 124 +++++------------- .../InfiniteDisjunctiveProgramming.jl | 20 +-- 2 files changed, 39 insertions(+), 105 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 631875bb..4a7f795a 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -173,35 +173,9 @@ end ################################################################################ # MBM FOR INFINITEMODEL ################################################################################ -# Reuses the finite MBM infrastructure by overriding: -# copy_model_with_constraints (build mini InfiniteModel, transcribe to -# flat JuMP, stash mini + main->mini ref_map in sub.model.ext), -# prepare_max_M_objective (translate main-model slack expr to mini-level -# then call InfiniteOpt.transformation_expression to get K flat -# objectives), and raw_M (vector dispatch aggregates K per-support M -# values into a parameter function). - -# Collect all parameter function refs from all disjunct constraints in -# the model. -function _all_param_functions(model::InfiniteOpt.InfiniteModel) - param_funcs = Set{InfiniteOpt.GeneralVariableRef}() - for (_, crefs) in DP._indicator_to_constraints(model) - for cref in crefs - cref isa DP.DisjunctConstraintRef || continue - con = JuMP.constraint_object(cref) - for v in InfiniteOpt.all_expression_variables(con.func) - dispatch_var = InfiniteOpt.dispatch_variable_ref(v) - if dispatch_var isa InfiniteOpt.ParameterFunctionRef - push!(param_funcs, v) - end - end - end - end - return param_funcs -end -# Build mini InfiniteModel with only the given disjunct constraints, -# transcribe to flat JuMP model, return GDPSubmodel with forward map. +# Build a mini InfiniteModel holding only the given disjunct constraints, +# transcribe it, and return as a GDPSubmodel. function DP.copy_model_with_constraints( model::InfiniteOpt.InfiniteModel, constraints::Vector{<:DP.DisjunctConstraintRef}, @@ -242,10 +216,8 @@ function DP.copy_model_with_constraints( ref_map[d] = new_d end - # 4. Copy parameter functions from ALL disjuncts (needed for - # constraint transcription) - param_funcs = _all_param_functions(model) - for pfunc in param_funcs + # 4. Copy parameter functions (needed by ref_map substitution) + for pfunc in InfiniteOpt.all_parameter_functions(model) func = InfiniteOpt.raw_function(pfunc) prefs = InfiniteOpt.parameter_refs(pfunc) mapped_prefs = Tuple(ref_map[p] for p in prefs) @@ -262,29 +234,20 @@ function DP.copy_model_with_constraints( JuMP.@constraint(mini, new_func * T in con.set) end - # 6. Transcribe mini InfiniteModel to flat JuMP model + # 6. Transcribe mini InfiniteModel InfiniteOpt.build_transformation_backend!(mini) - flat = InfiniteOpt.transformation_model(mini) - JuMP.set_optimizer(flat, method.optimizer) - JuMP.set_silent(flat) - # Stash main + ref_map so prepare_max_M_objective can translate - # main-model expressions and let InfiniteOpt transcribe them via - # mini's backend. Also stash main so raw_M can return a parameter - # function on main (where it will be used in BigM constraints). - flat.ext[:inf_mbm_main] = model - flat.ext[:inf_mbm_ref_map] = ref_map - # fwd_map / decision_vars are CP-shaped fields on GDPSubmodel that - # the MBM path through our overrides does not consult; pass empty - # containers of the right types. - return DP.GDPSubmodel(flat, InfiniteOpt.GeneralVariableRef[], + transcribed = InfiniteOpt.transformation_model(mini) + JuMP.set_optimizer(transcribed, method.optimizer) + JuMP.set_silent(transcribed) + # Stash for prepare_max_M_objective / raw_M. + transcribed.ext[:inf_mbm_main] = model + transcribed.ext[:inf_mbm_ref_map] = ref_map + # GDPSubmodel's fwd_map / decision_vars are CP-only; unused here. + return DP.GDPSubmodel(transcribed, InfiniteOpt.GeneralVariableRef[], Dict{InfiniteOpt.GeneralVariableRef, Vector{JuMP.VariableRef}}()) end -# Translate the constraint slack to the mini InfiniteModel via ref_map, -# then use InfiniteOpt.transformation_expression to get one JuMP scalar -# per support point. Narrows the declared Vector{Union{Real, …}} return -# to Vector{AbstractJuMPScalar}; errors loudly if a support gives a -# pure-constant slack (would require a degenerate disjunct constraint). +# Return one pointwise slack per support. function DP.prepare_max_M_objective( ::InfiniteOpt.InfiniteModel, obj::JuMP.ScalarConstraint{T, S}, @@ -307,10 +270,8 @@ function DP.prepare_max_M_objective( InfiniteOpt.transformation_expression(obj.set.lower - mini_expr)) end -# Solve the submodel for a vector of objectives (one per support point). -# Clears start values before each solve (Gurobi refuses NaN warmstarts -# that can linger from a prior unbounded solve) and delegates each -# element to the scalar base `raw_M`. +# Per-support solve, delegating to scalar base raw_M. Aggregated to a +# scalar if uniform, else to a parameter function. function DP.raw_M( sub::DP.GDPSubmodel, objectives::Vector{<:JuMP.AbstractJuMPScalar}, @@ -326,9 +287,9 @@ function DP.raw_M( model = sub.model.ext[:inf_mbm_main] # Condense per-support values: scalar if uniform, else pfunc. all(==(M_vals[1]), M_vals) && return M_vals[1] - prefs, supports = _collect_parameters(model) - grids = Tuple(supports[p] for p in prefs) - shape = Tuple(length(supports[p]) for p in prefs) + prefs = InfiniteOpt.all_parameters(model) + grids = Tuple(Float64.(InfiniteOpt.supports(p)) for p in prefs) + shape = Tuple(length.(grids)) func = Interpolations.linear_interpolation(grids, reshape(M_vals, shape), extrapolation_bc = Interpolations.Line()) return _make_parameter_function(model, func, prefs...) @@ -353,25 +314,12 @@ function _make_parameter_function( return InfiniteOpt.add_parameter_function(model, builder) end -# Collect all infinite parameters and their supports from the model. -function _collect_parameters(model::InfiniteOpt.InfiniteModel) - params = collect(InfiniteOpt.all_parameters(model)) - if isempty(params) - error("Model has no infinite parameters.") - end - prefs = InfiniteOpt.GeneralVariableRef[p for p in params] - supports = Dict{InfiniteOpt.GeneralVariableRef, Vector{Float64}}( - p => Float64.(InfiniteOpt.supports(p)) for p in prefs) - return prefs, supports -end - - ################################################################################ # CUTTING PLANES FOR INFINITEMODEL ################################################################################ -# Build CP subproblem: reformulate the InfiniteModel in-place, transcribe -# to a flat JuMP copy, and wrap in GDPSubmodel with forward variable map. +# Build CP subproblem: reformulate the InfiniteModel in-place, transcribe, +# copy, and wrap in GDPSubmodel with forward variable map. function DP.copy_and_reformulate( model::InfiniteOpt.InfiniteModel, decision_vars::Vector{InfiniteOpt.GeneralVariableRef}, @@ -380,7 +328,7 @@ function DP.copy_and_reformulate( ) DP.reformulate_model(model, reform_method) InfiniteOpt.build_transformation_backend!(model) - flat = InfiniteOpt.transformation_model(model) + transcribed = InfiniteOpt.transformation_model(model) transcription_fwd = Dict{InfiniteOpt.GeneralVariableRef, Vector{JuMP.VariableRef}}() for v in DP.collect_all_vars(model) @@ -389,11 +337,11 @@ function DP.copy_and_reformulate( transcription_fwd[v] = isempty(var_prefs) ? [transcription_var] : vec(transcription_var) end - sub_copy, copy_map = JuMP.copy_model(flat) + sub_copy, copy_map = JuMP.copy_model(transcribed) fwd_map = Dict{InfiniteOpt.GeneralVariableRef, Vector{JuMP.VariableRef}}() for v in decision_vars haskey(transcription_fwd, v) || continue - fwd_map[v] = [copy_map[flat_var] for flat_var in transcription_fwd[v]] + fwd_map[v] = [copy_map[transcribed_var] for transcribed_var in transcription_fwd[v]] end sub = DP.GDPSubmodel(sub_copy, decision_vars, fwd_map) JuMP.set_optimizer(sub.model, method.optimizer) @@ -401,8 +349,7 @@ function DP.copy_and_reformulate( return sub end -# Extract per-support-point solutions from the InfiniteOpt transformation -# backend after optimize!(model, ignore_optimize_hook=true). +# Read per-support values from the transformation backend. function DP.extract_solution(model::InfiniteOpt.InfiniteModel) dvars = DP.collect_cutting_planes_vars(model) V = eltype(dvars) @@ -417,36 +364,33 @@ function DP.extract_solution(model::InfiniteOpt.InfiniteModel) return sol end -# Add a flat-sum cut directly to the transformation backend, matching -# the SEP's unweighted Euclidean norm (Trespalacios & Grossmann 2016 -# Eq. 11 applied in the joint transcribed variable space). Then mark -# the backend as ready so the next optimize! reuses the cut-enhanced -# flat model without re-transcribing (which would wipe the cut). +# Add a pointwise-sum cut directly to the transformation backend and mark +# it ready so the next optimize! doesn't re-transcribe and wipe the cut. function DP.add_cut( model::InfiniteOpt.InfiniteModel, decision_vars::Vector{InfiniteOpt.GeneralVariableRef}, rBM_sol::Dict{<:JuMP.AbstractVariableRef, <:Vector{<:Number}}, sep_sol::Dict{<:JuMP.AbstractVariableRef, <:Vector{<:Number}} ) - flat = InfiniteOpt.transformation_model(model) + transcribed = InfiniteOpt.transformation_model(model) cut_expr = zero(JuMP.GenericAffExpr{ - JuMP.value_type(typeof(flat)), - JuMP.variable_ref_type(flat)}) + JuMP.value_type(typeof(transcribed)), + JuMP.variable_ref_type(transcribed)}) for var in decision_vars haskey(rBM_sol, var) || continue haskey(sep_sol, var) || continue rbm_vals = rBM_sol[var] sep_vals = sep_sol[var] transcription_var = InfiniteOpt.transformation_variable(var) - flat_vars = transcription_var isa AbstractArray ? + transcribed_vars = transcription_var isa AbstractArray ? vec(transcription_var) : [transcription_var] - for k in eachindex(flat_vars) + for k in eachindex(transcribed_vars) xi = 2 * (sep_vals[k] - rbm_vals[k]) - JuMP.add_to_expression!(cut_expr, xi, flat_vars[k]) + JuMP.add_to_expression!(cut_expr, xi, transcribed_vars[k]) JuMP.add_to_expression!(cut_expr, -xi * sep_vals[k]) end end - JuMP.@constraint(flat, cut_expr >= 0) + JuMP.@constraint(transcribed, cut_expr >= 0) InfiniteOpt.set_transformation_backend_ready(model, true) return end diff --git a/test/extensions/InfiniteDisjunctiveProgramming.jl b/test/extensions/InfiniteDisjunctiveProgramming.jl index e7fed04d..42ac0914 100644 --- a/test/extensions/InfiniteDisjunctiveProgramming.jl +++ b/test/extensions/InfiniteDisjunctiveProgramming.jl @@ -364,12 +364,6 @@ function test_logical_value() @test eltype(val) == Bool end -# _collect_parameters on model with no infinite parameters. -function test_collect_parameters_no_params() - model = InfiniteGDPModel() - @test_throws ErrorException IDP._collect_parameters(model) -end - # raw_M against an InfiniteModel where M is constant across supports. # Setup: x(t) ∈ [0, 10], disj1: x ≥ 5, disj2: x ≤ 3. # For disj1 slack r(x) = 5 - x maximized over disj2's region x ∈ [0, 3]: @@ -443,8 +437,8 @@ function test_extract_solution_infinite() @test all(v -> isapprox(v, 0.0; atol=1e-6), sol[x]) end -# add_cut adds one flat-sum cut to the transformation backend and marks -# the backend ready so the next optimize! does NOT re-transcribe. +# add_cut adds one pointwise-sum cut to the transformation backend and +# marks the backend ready so the next optimize! does NOT re-transcribe. function test_add_cut_infinite() model = InfiniteGDPModel(HiGHS.Optimizer) set_silent(model) @@ -457,13 +451,13 @@ function test_add_cut_infinite() @disjunction(model, Y) DP.reformulate_model(model, BigM(10.0)) InfiniteOpt.build_transformation_backend!(model) - flat = InfiniteOpt.transformation_model(model) - n_before = JuMP.num_constraints(flat; + transcribed = InfiniteOpt.transformation_model(model) + n_before = JuMP.num_constraints(transcribed; count_variable_in_set_constraints = false) rBM_sol = Dict(x => [1.0, 2.0, 3.0]) sep_sol = Dict(x => [0.5, 1.5, 2.5]) DP.add_cut(model, [x], rBM_sol, sep_sol) - n_after = JuMP.num_constraints(flat; + n_after = JuMP.num_constraints(transcribed; count_variable_in_set_constraints = false) @test n_after == n_before + 1 # set_transformation_backend_ready(true) — next optimize! should @@ -806,10 +800,6 @@ end test_disaggregate_expression_infiniteopt() end - @testset "Internal Helpers" begin - test_collect_parameters_no_params() - end - @testset "MBM" begin test_raw_M_infinite_scalar() test_raw_M_infinite_param_function() From f863f15622e669945cbdcb648cfeab2227eabff6 Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Wed, 22 Apr 2026 09:08:08 -0400 Subject: [PATCH 17/59] . --- ext/InfiniteDisjunctiveProgramming.jl | 1 - 1 file changed, 1 deletion(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 4a7f795a..1804f763 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -247,7 +247,6 @@ function DP.copy_model_with_constraints( Dict{InfiniteOpt.GeneralVariableRef, Vector{JuMP.VariableRef}}()) end -# Return one pointwise slack per support. function DP.prepare_max_M_objective( ::InfiniteOpt.InfiniteModel, obj::JuMP.ScalarConstraint{T, S}, From 350fda3ea0761eeee4392ac71b6d7271507892e8 Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Fri, 24 Apr 2026 19:28:14 -0400 Subject: [PATCH 18/59] . --- ext/InfiniteDisjunctiveProgramming.jl | 69 +++++++++++---------------- 1 file changed, 28 insertions(+), 41 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 1804f763..c09be8a4 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -221,8 +221,12 @@ function DP.copy_model_with_constraints( func = InfiniteOpt.raw_function(pfunc) prefs = InfiniteOpt.parameter_refs(pfunc) mapped_prefs = Tuple(ref_map[p] for p in prefs) - new_pfunc = _make_parameter_function(mini, func, mapped_prefs...) - ref_map[pfunc] = new_pfunc + pref_arg = length(mapped_prefs) == 1 ? + only(mapped_prefs) : mapped_prefs + param_func = InfiniteOpt.build_parameter_function( + error, func, pref_arg) + ref_map[pfunc] = InfiniteOpt.add_parameter_function( + mini, param_func) end # 5. Add disjunct constraints using existing ref_map @@ -253,9 +257,10 @@ function DP.prepare_max_M_objective( sub::DP.GDPSubmodel ) where {T, S <: _MOI.LessThan} ref_map = sub.model.ext[:inf_mbm_ref_map] - mini_expr = DP._replace_variables_in_constraint(obj.func, ref_map) - return Vector{JuMP.AbstractJuMPScalar}( - InfiniteOpt.transformation_expression(mini_expr - obj.set.upper)) + mini_expr = DP._replace_variables_in_constraint( + obj.func, ref_map) - obj.set.upper + sub.model.ext[:inf_mbm_obj_expr] = obj.func + return InfiniteOpt.transformation_expression(mini_expr) end function DP.prepare_max_M_objective( @@ -264,53 +269,35 @@ function DP.prepare_max_M_objective( sub::DP.GDPSubmodel ) where {T, S <: _MOI.GreaterThan} ref_map = sub.model.ext[:inf_mbm_ref_map] - mini_expr = DP._replace_variables_in_constraint(obj.func, ref_map) - return Vector{JuMP.AbstractJuMPScalar}( - InfiniteOpt.transformation_expression(obj.set.lower - mini_expr)) + mini_expr = obj.set.lower - DP._replace_variables_in_constraint( + obj.func, ref_map) + sub.model.ext[:inf_mbm_obj_expr] = obj.func + return InfiniteOpt.transformation_expression(mini_expr) end # Per-support solve, delegating to scalar base raw_M. Aggregated to a # scalar if uniform, else to a parameter function. function DP.raw_M( sub::DP.GDPSubmodel, - objectives::Vector{<:JuMP.AbstractJuMPScalar}, + objectives::AbstractArray{<:Union{JuMP.AbstractJuMPScalar, Real}}, method::DP._MBM ) - M_vals = typeof(method.default_M)[] - for obj_expr in objectives + M_vals = similar(objectives, typeof(method.default_M)) + for I in eachindex(objectives) JuMP.set_start_value.(JuMP.all_variables(sub.model), nothing) - m = DP.raw_M(sub, obj_expr, method) + m = DP.raw_M(sub, objectives[I], method) m === nothing && return nothing - push!(M_vals, m) + M_vals[I] = m end - model = sub.model.ext[:inf_mbm_main] - # Condense per-support values: scalar if uniform, else pfunc. - all(==(M_vals[1]), M_vals) && return M_vals[1] - prefs = InfiniteOpt.all_parameters(model) - grids = Tuple(Float64.(InfiniteOpt.supports(p)) for p in prefs) - shape = Tuple(length.(grids)) - func = Interpolations.linear_interpolation(grids, reshape(M_vals, shape), - extrapolation_bc = Interpolations.Line()) - return _make_parameter_function(model, func, prefs...) -end - -################################################################################ -# TRANSCRIPTION HELPERS -################################################################################ - -# Replacement for @parameter_function in the case of using an interpolation. -# Example (1D interpolation): -# func = Interpolations.linear_interpolation(grids, vals) -# pfunc = _make_parameter_function(model, func, t) # returns a pfunc ref -function _make_parameter_function( - model::InfiniteOpt.InfiniteModel, func, - prefs::InfiniteOpt.GeneralVariableRef... - ) - wrapped_func = func isa Function ? func : ((args...) -> func(args...)) - pref_arg = length(prefs) == 1 ? only(prefs) : prefs - builder = InfiniteOpt.build_parameter_function( - error, wrapped_func, pref_arg) - return InfiniteOpt.add_parameter_function(model, builder) + all(==(first(M_vals)), M_vals) && return first(M_vals) + main = sub.model.ext[:inf_mbm_main] + expr = sub.model.ext[:inf_mbm_obj_expr] + prefs = InfiniteOpt.parameter_refs(expr) + grids = Tuple(InfiniteOpt.supports(p) for p in prefs) + interp = Interpolations.linear_interpolation(grids, M_vals) + param_func = InfiniteOpt.build_parameter_function( + error, (args...) -> interp(args...), prefs) + return InfiniteOpt.add_parameter_function(main, param_func) end ################################################################################ From 523529452a3be39cf0022c39f404fc38fc5eaf1a Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Sun, 26 Apr 2026 21:25:49 -0400 Subject: [PATCH 19/59] . --- ext/InfiniteDisjunctiveProgramming.jl | 6 +++--- src/mbm.jl | 24 ++++++++++++------------ src/utilities.jl | 10 +++++----- test/constraints/mbm.jl | 24 ++++++++++++------------ 4 files changed, 32 insertions(+), 32 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index c09be8a4..5a409203 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -233,7 +233,7 @@ function DP.copy_model_with_constraints( for cref in constraints cref isa DP.DisjunctConstraintRef || continue con = JuMP.constraint_object(cref) - new_func = DP._replace_variables_in_constraint(con.func, ref_map) + new_func = DP.replace_variables_in_constraint(con.func, ref_map) T = one(JuMP.value_type(typeof(mini))) JuMP.@constraint(mini, new_func * T in con.set) end @@ -257,7 +257,7 @@ function DP.prepare_max_M_objective( sub::DP.GDPSubmodel ) where {T, S <: _MOI.LessThan} ref_map = sub.model.ext[:inf_mbm_ref_map] - mini_expr = DP._replace_variables_in_constraint( + mini_expr = DP.replace_variables_in_constraint( obj.func, ref_map) - obj.set.upper sub.model.ext[:inf_mbm_obj_expr] = obj.func return InfiniteOpt.transformation_expression(mini_expr) @@ -269,7 +269,7 @@ function DP.prepare_max_M_objective( sub::DP.GDPSubmodel ) where {T, S <: _MOI.GreaterThan} ref_map = sub.model.ext[:inf_mbm_ref_map] - mini_expr = obj.set.lower - DP._replace_variables_in_constraint( + mini_expr = obj.set.lower - DP.replace_variables_in_constraint( obj.func, ref_map) sub.model.ext[:inf_mbm_obj_expr] = obj.func return InfiniteOpt.transformation_expression(mini_expr) diff --git a/src/mbm.jl b/src/mbm.jl index 4fd844c3..76ca939f 100644 --- a/src/mbm.jl +++ b/src/mbm.jl @@ -250,7 +250,7 @@ function prepare_max_M_objective( sub::GDPSubmodel ) where {T, S <: _MOI.LessThan} flat_map = Dict(v => ws[1] for (v, ws) in sub.fwd_map) - expr = -obj.set.upper + _replace_variables_in_constraint(obj.func, flat_map) + expr = -obj.set.upper + replace_variables_in_constraint(obj.func, flat_map) return expr end @@ -260,7 +260,7 @@ function prepare_max_M_objective( sub::GDPSubmodel ) where {T, S <: _MOI.GreaterThan} flat_map = Dict(v => ws[1] for (v, ws) in sub.fwd_map) - expr = obj.set.lower - _replace_variables_in_constraint(obj.func, flat_map) + expr = obj.set.lower - replace_variables_in_constraint(obj.func, flat_map) return expr end @@ -462,7 +462,7 @@ function copy_model_with_constraints( for cref in constraints con = JuMP.constraint_object(cref) flat_map = Dict(v => only(ws) for (v, ws) in fwd_map) - expr = _replace_variables_in_constraint(con.func, flat_map) + expr = replace_variables_in_constraint(con.func, flat_map) T = one(JuMP.value_type(typeof(sub_model))) JuMP.@constraint(sub_model, expr * T in con.set) end @@ -480,7 +480,7 @@ end # Replace variable refs in an expression using a map. Uses AbstractDict # because the InfiniteModel MBM path maps decision vars to VariableRefs # and parameter functions to Numbers in the same dict (via _build_flat_map). -function _replace_variables_in_constraint( +function replace_variables_in_constraint( fun::JuMP.AbstractVariableRef, var_map::AbstractDict ) @@ -501,7 +501,7 @@ function _var_ref_type( return V end -function _replace_variables_in_constraint( +function replace_variables_in_constraint( fun::T, var_map::AbstractDict ) where {T <: JuMP.GenericAffExpr} C = JuMP.value_type(T) @@ -514,7 +514,7 @@ function _replace_variables_in_constraint( return new_aff end -function _replace_variables_in_constraint( +function replace_variables_in_constraint( fun::T, var_map::AbstractDict ) where {T <: JuMP.GenericQuadExpr} C = JuMP.value_type(T) @@ -524,23 +524,23 @@ function _replace_variables_in_constraint( JuMP.add_to_expression!(new_quad, coef * var_map[vars.a] * var_map[vars.b]) end - new_aff = _replace_variables_in_constraint(fun.aff, var_map) + new_aff = replace_variables_in_constraint(fun.aff, var_map) JuMP.add_to_expression!(new_quad, new_aff) return new_quad end -function _replace_variables_in_constraint(fun::Number, var_map::AbstractDict) +function replace_variables_in_constraint(fun::Number, var_map::AbstractDict) return fun end -function _replace_variables_in_constraint(fun::T, +function replace_variables_in_constraint(fun::T, var_map::AbstractDict) where {T <: JuMP.GenericNonlinearExpr} - new_args = Any[_replace_variables_in_constraint( + new_args = Any[replace_variables_in_constraint( arg, var_map) for arg in fun.args] return T(fun.head, new_args) end -function _replace_variables_in_constraint(fun::Vector, var_map::AbstractDict) - return [_replace_variables_in_constraint(expr, +function replace_variables_in_constraint(fun::Vector, var_map::AbstractDict) + return [replace_variables_in_constraint(expr, var_map) for expr in fun] end diff --git a/src/utilities.jl b/src/utilities.jl index b0203d70..bca1b748 100644 --- a/src/utilities.jl +++ b/src/utilities.jl @@ -30,7 +30,7 @@ function copy_and_reformulate( orig_to_copy = Dict{V, V}( v => ref_map[v] for v in decision_vars) JuMP.@objective(copy, sense, - _replace_variables_in_constraint(obj, orig_to_copy) + replace_variables_in_constraint(obj, orig_to_copy) ) fwd_map = Dict{V, Vector{V}}(v => [ref_map[v]] for v in decision_vars) sub = GDPSubmodel(copy, decision_vars, fwd_map) @@ -221,7 +221,7 @@ function copy_gdp_data( old_con_ref = LogicalConstraintRef(model, idx) new_con_ref = LogicalConstraintRef(new_model, idx) c = lc_data.constraint - expr = _replace_variables_in_constraint(c.func, lv_map) + expr = replace_variables_in_constraint(c.func, lv_map) new_con = JuMP.build_constraint(error, expr, c.set) JuMP.add_constraint(new_model, new_con, lc_data.name) lc_map[old_con_ref] = new_con_ref @@ -233,7 +233,7 @@ function copy_gdp_data( old_dc_ref = DisjunctConstraintRef(model, idx) old_indicator = old_gdp.constraint_to_indicator[old_dc_ref] new_indicator = lv_map[old_indicator] - new_expr = _replace_variables_in_constraint(old_constraint.func, + new_expr = replace_variables_in_constraint(old_constraint.func, var_map ) # Update to new_gdp.disjunct_constraints @@ -247,7 +247,7 @@ function copy_gdp_data( # Copying disjunctions for (idx, disj_data) in old_gdp.disjunctions old_disj = disj_data.constraint - new_indicators = [_replace_variables_in_constraint(indicator, lv_map) + new_indicators = [replace_variables_in_constraint(indicator, lv_map) for indicator in old_disj.indicators ] new_disj = Disjunction(new_indicators, old_disj.nested) @@ -383,7 +383,7 @@ function _remap_indicator_to_binary( bref::JuMP.GenericAffExpr, var_map::Dict{V, V} ) where {V <: JuMP.AbstractVariableRef} - return _replace_variables_in_constraint(bref, var_map) + return replace_variables_in_constraint(bref, var_map) end function _remap_constraint_to_indicator( diff --git a/test/constraints/mbm.jl b/test/constraints/mbm.jl index 80f9a27f..a6854bdc 100644 --- a/test/constraints/mbm.jl +++ b/test/constraints/mbm.jl @@ -27,7 +27,7 @@ function test__var_ref_type_numeric_map() @test DP._var_ref_type(typeof(aff), var_map) == VariableRef end -# _replace_variables_in_constraint with QuadExpr where var_map +# replace_variables_in_constraint with QuadExpr where var_map # maps some vars to Numbers. Covers lines 569, 571, 574. function test__replace_variables_quad_numeric_map() model = Model() @@ -38,21 +38,21 @@ function test__replace_variables_quad_numeric_map() # both Number (line 569) map1 = Dict{VariableRef, Any}(x[1] => 2.0, x[2] => 3.0) - result1 = DP._replace_variables_in_constraint(quad1, map1) + result1 = DP.replace_variables_in_constraint(quad1, map1) @test result1.aff.constant ≈ 6.0 # ra Number, rb VariableRef (line 571) map2 = Dict{VariableRef, Any}(x[1] => 2.0, x[2] => y) - result2 = DP._replace_variables_in_constraint(quad1, map2) + result2 = DP.replace_variables_in_constraint(quad1, map2) @test result2.aff.terms[y] ≈ 2.0 # rb Number, ra VariableRef (line 574) map3 = Dict{VariableRef, Any}(x[1] => y, x[2] => 3.0) - result3 = DP._replace_variables_in_constraint(quad1, map3) + result3 = DP.replace_variables_in_constraint(quad1, map3) @test result3.aff.terms[y] ≈ 3.0 end -function test_replace_variables_in_constraint() +function testreplace_variables_in_constraint() model = Model() sub_model = Model() @variable(model, x[1:3]) @@ -64,14 +64,14 @@ function test_replace_variables_in_constraint() #Test GenericVariableRef new_vars = Dict{AbstractVariableRef, AbstractVariableRef}() [new_vars[x[i]] = @variable(sub_model) for i in 1:3] - varref = DP._replace_variables_in_constraint(x[1], new_vars) - expr1 = DP._replace_variables_in_constraint( + varref = DP.replace_variables_in_constraint(x[1], new_vars) + expr1 = DP.replace_variables_in_constraint( constraint_object(con1).func, new_vars) - expr2 = DP._replace_variables_in_constraint( + expr2 = DP.replace_variables_in_constraint( constraint_object(con2).func, new_vars) - expr3 = DP._replace_variables_in_constraint( + expr3 = DP.replace_variables_in_constraint( constraint_object(con3).func, new_vars) - expr4 = DP._replace_variables_in_constraint( + expr4 = DP.replace_variables_in_constraint( constraint_object(con4).func, new_vars) @test expr1 == JuMP.@expression(sub_model, new_vars[x[1]] + 1 - 1) @test expr2 == JuMP.@expression(sub_model, new_vars[x[2]]*new_vars[x[1]]) @@ -80,7 +80,7 @@ function test_replace_variables_in_constraint() expected = JuMP.@expression(sub_model, sin(new_vars[x[3]]) - 0.0) @test JuMP.isequal_canonical(expr3, expected) @test expr4 == [new_vars[x[i]] for i in 1:3] - @test_throws MethodError DP._replace_variables_in_constraint( + @test_throws MethodError DP.replace_variables_in_constraint( "String", new_vars) end @@ -794,7 +794,7 @@ end test_mbm() test__var_ref_type_numeric_map() test__replace_variables_quad_numeric_map() - test_replace_variables_in_constraint() + testreplace_variables_in_constraint() test_prepare_max_M_objective() test_raw_M() test_maximize_M() From 32ccde59f594212f31654b1f9c2376a79e1e0ef5 Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Sun, 26 Apr 2026 21:33:34 -0400 Subject: [PATCH 20/59] . --- test/constraints/mbm.jl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/constraints/mbm.jl b/test/constraints/mbm.jl index a6854bdc..0c96dbbb 100644 --- a/test/constraints/mbm.jl +++ b/test/constraints/mbm.jl @@ -52,7 +52,7 @@ function test__replace_variables_quad_numeric_map() @test result3.aff.terms[y] ≈ 3.0 end -function testreplace_variables_in_constraint() +function test_replace_variables_in_constraint() model = Model() sub_model = Model() @variable(model, x[1:3]) @@ -794,7 +794,7 @@ end test_mbm() test__var_ref_type_numeric_map() test__replace_variables_quad_numeric_map() - testreplace_variables_in_constraint() + test_replace_variables_in_constraint() test_prepare_max_M_objective() test_raw_M() test_maximize_M() From bb03b3bd248ca508650d4a7a7266c70f9a3420cc Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Sun, 26 Apr 2026 21:59:53 -0400 Subject: [PATCH 21/59] . --- ext/InfiniteDisjunctiveProgramming.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 5a409203..efd80d40 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -282,7 +282,7 @@ function DP.raw_M( objectives::AbstractArray{<:Union{JuMP.AbstractJuMPScalar, Real}}, method::DP._MBM ) - M_vals = similar(objectives, typeof(method.default_M)) + M_vals = Array{typeof(method.default_M)}(undef, size(objectives)) for I in eachindex(objectives) JuMP.set_start_value.(JuMP.all_variables(sub.model), nothing) m = DP.raw_M(sub, objectives[I], method) From f2387f5437097a99825c1c9c708d1ba5af33876b Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Sun, 26 Apr 2026 23:29:14 -0400 Subject: [PATCH 22/59] . --- src/cuttingplanes.jl | 31 +++++++++++++++---------------- src/utilities.jl | 13 +++++++++++++ test/constraints/cuttingplanes.jl | 4 ++-- 3 files changed, 30 insertions(+), 18 deletions(-) diff --git a/src/cuttingplanes.jl b/src/cuttingplanes.jl index dbc818e5..603dbb4c 100644 --- a/src/cuttingplanes.jl +++ b/src/cuttingplanes.jl @@ -8,25 +8,17 @@ function collect_cutting_planes_vars(model::JuMP.AbstractModel) return collect_all_vars(model) end -# Extract solution from a solved model (in-place). Extensions -# override for models where values live on a backend. +# Read primal values from a solved model. Returns a scalar-valued +# `Dict{var, value}`, skipping fixed vars. CP callers wrap to +# per-support `Vector` shape via `_cp_per_support`. The InfiniteOpt +# extension overrides this dispatch to give per-support `Vector` +# values directly. function extract_solution(model::JuMP.AbstractModel) dvars = collect_cutting_planes_vars(model) V = eltype(dvars) T = JuMP.value_type(typeof(model)) - return Dict{V, Vector{T}}( - v => [JuMP.value(v)] for v in dvars) -end - -# Extract solution from a GDPSubmodel (SEP path). -function extract_solution(sub::GDPSubmodel) - V = eltype(sub.decision_vars) - T = JuMP.value_type(typeof(sub.model)) - sol = Dict{V, Vector{T}}() - for var in sub.decision_vars - sol[var] = JuMP.value.(sub.fwd_map[var]) - end - return sol + return Dict{V, T}( + v => JuMP.value(v) for v in dvars if !JuMP.is_fixed(v)) end # Set quadratic separation objective: min Σ (x_k - rBM_k)². @@ -113,7 +105,7 @@ function reformulate_model( # Cutting plane loop: rBM <-> SEP until convergence for iter in 1:method.max_iter JuMP.optimize!(model, ignore_optimize_hook = true) - rBM_sol = extract_solution(model) + rBM_sol = _cp_per_support(extract_solution(model)) separation_obj, separation_sol = _solve_separation(separation, rBM_sol) if separation_obj <= method.seperation_tolerance break @@ -127,6 +119,13 @@ function reformulate_model( return end +# Wrap scalar `extract_solution(model)` values into 1-element +# `Vector`s — uniform per-support shape that the CP loop expects. +# `Vector` values (from the InfiniteOpt extension's per-support read) +# pass through unchanged. +_cp_per_support(point::AbstractDict) = + Dict(v => val isa AbstractVector ? val : [val] for (v, val) in point) + ################################################################################ # ERROR MESSAGES ################################################################################ diff --git a/src/utilities.jl b/src/utilities.jl index bca1b748..da090eac 100644 --- a/src/utilities.jl +++ b/src/utilities.jl @@ -8,6 +8,19 @@ function _copy_model( return M() end +""" + extract_solution(sub::GDPSubmodel) + +Read the primal solution of `sub.model` after a solve, keyed by the +parent-model decision variables via `sub.fwd_map`. Shape follows +`fwd_map` values: `Vector`-valued fwd_maps (CP/MBM) yield per-support +`Vector`s; scalar fwd_maps (LOA feas) yield scalars. +""" +function extract_solution(sub::GDPSubmodel) + return Dict( + var => JuMP.value.(sub.fwd_map[var]) for var in sub.decision_vars) +end + """ copy_and_reformulate(model, decision_vars, reform_method, method) diff --git a/test/constraints/cuttingplanes.jl b/test/constraints/cuttingplanes.jl index 9f3a7931..e15976dd 100644 --- a/test/constraints/cuttingplanes.jl +++ b/test/constraints/cuttingplanes.jl @@ -146,7 +146,7 @@ function test_cp_cut_generation() JuMP.set_silent(model) relaxed = DP.relax_logical_vars(model) optimize!(model, ignore_optimize_hook = true) - rBM_sol = DP.extract_solution(model) + rBM_sol = DP._cp_per_support(DP.extract_solution(model)) # Solve SEP DP._set_separation_objective(separation, rBM_sol) @@ -168,7 +168,7 @@ function test_cp_cut_generation() # Re-solve with cut → should tighten optimize!(model, ignore_optimize_hook = true) rBM_sol2 = DP.extract_solution(model) - @test rBM_sol2[x][1] ≈ 4.0 atol = 0.1 + @test rBM_sol2[x] ≈ 4.0 atol = 0.1 DP.unrelax_logical_vars(relaxed) end From fbdafa0c9e888b2e37f8006ada24eecccf1860c2 Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Sun, 26 Apr 2026 23:44:18 -0400 Subject: [PATCH 23/59] . --- src/cuttingplanes.jl | 23 ++++++++--------------- test/constraints/cuttingplanes.jl | 4 ++-- 2 files changed, 10 insertions(+), 17 deletions(-) diff --git a/src/cuttingplanes.jl b/src/cuttingplanes.jl index 603dbb4c..63692258 100644 --- a/src/cuttingplanes.jl +++ b/src/cuttingplanes.jl @@ -8,17 +8,17 @@ function collect_cutting_planes_vars(model::JuMP.AbstractModel) return collect_all_vars(model) end -# Read primal values from a solved model. Returns a scalar-valued -# `Dict{var, value}`, skipping fixed vars. CP callers wrap to -# per-support `Vector` shape via `_cp_per_support`. The InfiniteOpt -# extension overrides this dispatch to give per-support `Vector` -# values directly. +# Read primal values from a solved model. Returns +# `Dict{var, Vector{value}}` — per-support shape uniformly: finite +# models trivially have one "support" (length-1 Vector), the +# InfiniteOpt extension overrides this dispatch to populate +# multi-support Vectors. Skips fixed vars. function extract_solution(model::JuMP.AbstractModel) dvars = collect_cutting_planes_vars(model) V = eltype(dvars) T = JuMP.value_type(typeof(model)) - return Dict{V, T}( - v => JuMP.value(v) for v in dvars if !JuMP.is_fixed(v)) + return Dict{V, Vector{T}}( + v => [JuMP.value(v)] for v in dvars if !JuMP.is_fixed(v)) end # Set quadratic separation objective: min Σ (x_k - rBM_k)². @@ -105,7 +105,7 @@ function reformulate_model( # Cutting plane loop: rBM <-> SEP until convergence for iter in 1:method.max_iter JuMP.optimize!(model, ignore_optimize_hook = true) - rBM_sol = _cp_per_support(extract_solution(model)) + rBM_sol = extract_solution(model) separation_obj, separation_sol = _solve_separation(separation, rBM_sol) if separation_obj <= method.seperation_tolerance break @@ -119,13 +119,6 @@ function reformulate_model( return end -# Wrap scalar `extract_solution(model)` values into 1-element -# `Vector`s — uniform per-support shape that the CP loop expects. -# `Vector` values (from the InfiniteOpt extension's per-support read) -# pass through unchanged. -_cp_per_support(point::AbstractDict) = - Dict(v => val isa AbstractVector ? val : [val] for (v, val) in point) - ################################################################################ # ERROR MESSAGES ################################################################################ diff --git a/test/constraints/cuttingplanes.jl b/test/constraints/cuttingplanes.jl index e15976dd..9f3a7931 100644 --- a/test/constraints/cuttingplanes.jl +++ b/test/constraints/cuttingplanes.jl @@ -146,7 +146,7 @@ function test_cp_cut_generation() JuMP.set_silent(model) relaxed = DP.relax_logical_vars(model) optimize!(model, ignore_optimize_hook = true) - rBM_sol = DP._cp_per_support(DP.extract_solution(model)) + rBM_sol = DP.extract_solution(model) # Solve SEP DP._set_separation_objective(separation, rBM_sol) @@ -168,7 +168,7 @@ function test_cp_cut_generation() # Re-solve with cut → should tighten optimize!(model, ignore_optimize_hook = true) rBM_sol2 = DP.extract_solution(model) - @test rBM_sol2[x] ≈ 4.0 atol = 0.1 + @test rBM_sol2[x][1] ≈ 4.0 atol = 0.1 DP.unrelax_logical_vars(relaxed) end From 153c1e796f013d006347845397cb74dc77a32e4e Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Mon, 27 Apr 2026 10:04:22 -0400 Subject: [PATCH 24/59] . --- src/cuttingplanes.jl | 20 ++++++++++++++------ src/utilities.jl | 13 ------------- 2 files changed, 14 insertions(+), 19 deletions(-) diff --git a/src/cuttingplanes.jl b/src/cuttingplanes.jl index 63692258..dbc818e5 100644 --- a/src/cuttingplanes.jl +++ b/src/cuttingplanes.jl @@ -8,17 +8,25 @@ function collect_cutting_planes_vars(model::JuMP.AbstractModel) return collect_all_vars(model) end -# Read primal values from a solved model. Returns -# `Dict{var, Vector{value}}` — per-support shape uniformly: finite -# models trivially have one "support" (length-1 Vector), the -# InfiniteOpt extension overrides this dispatch to populate -# multi-support Vectors. Skips fixed vars. +# Extract solution from a solved model (in-place). Extensions +# override for models where values live on a backend. function extract_solution(model::JuMP.AbstractModel) dvars = collect_cutting_planes_vars(model) V = eltype(dvars) T = JuMP.value_type(typeof(model)) return Dict{V, Vector{T}}( - v => [JuMP.value(v)] for v in dvars if !JuMP.is_fixed(v)) + v => [JuMP.value(v)] for v in dvars) +end + +# Extract solution from a GDPSubmodel (SEP path). +function extract_solution(sub::GDPSubmodel) + V = eltype(sub.decision_vars) + T = JuMP.value_type(typeof(sub.model)) + sol = Dict{V, Vector{T}}() + for var in sub.decision_vars + sol[var] = JuMP.value.(sub.fwd_map[var]) + end + return sol end # Set quadratic separation objective: min Σ (x_k - rBM_k)². diff --git a/src/utilities.jl b/src/utilities.jl index da090eac..bca1b748 100644 --- a/src/utilities.jl +++ b/src/utilities.jl @@ -8,19 +8,6 @@ function _copy_model( return M() end -""" - extract_solution(sub::GDPSubmodel) - -Read the primal solution of `sub.model` after a solve, keyed by the -parent-model decision variables via `sub.fwd_map`. Shape follows -`fwd_map` values: `Vector`-valued fwd_maps (CP/MBM) yield per-support -`Vector`s; scalar fwd_maps (LOA feas) yield scalars. -""" -function extract_solution(sub::GDPSubmodel) - return Dict( - var => JuMP.value.(sub.fwd_map[var]) for var in sub.decision_vars) -end - """ copy_and_reformulate(model, decision_vars, reform_method, method) From d053c0700b513663d0a4618a3b4dfb8cff2f628e Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Mon, 27 Apr 2026 10:49:18 -0400 Subject: [PATCH 25/59] . --- ext/InfiniteDisjunctiveProgramming.jl | 1 - 1 file changed, 1 deletion(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index efd80d40..66d745f0 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -196,7 +196,6 @@ function DP.copy_model_with_constraints( # 2. Copy decision variables with bounds for v in JuMP.all_variables(model) - _is_parameter(v) && continue prefs = InfiniteOpt.parameter_refs(v) var_type = isempty(prefs) ? nothing : InfiniteOpt.Infinite(Tuple(ref_map[p] for p in prefs)...) From b0184fc4ef43ec13fb4d3b9be08aa9d7a62f4cae Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Fri, 1 May 2026 00:01:42 -0400 Subject: [PATCH 26/59] Update to get_variable_info, copy_model_with_constraints, prepare_max_M_objective and raw_M in InfGDP --- ext/InfiniteDisjunctiveProgramming.jl | 57 ++++++++++--------- src/mbm.jl | 2 +- src/variables.jl | 4 +- test/constraints/mbm.jl | 15 +++++ .../InfiniteDisjunctiveProgramming.jl | 2 +- 5 files changed, 51 insertions(+), 29 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 66d745f0..693845bd 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -237,17 +237,20 @@ function DP.copy_model_with_constraints( JuMP.@constraint(mini, new_func * T in con.set) end - # 6. Transcribe mini InfiniteModel + # 6. Build the transformation backend so transcribed exists and + # configure its solver. We hold mini in sub.model and recover + # transcribed lazily via transformation_model(mini). InfiniteOpt.build_transformation_backend!(mini) transcribed = InfiniteOpt.transformation_model(mini) JuMP.set_optimizer(transcribed, method.optimizer) JuMP.set_silent(transcribed) - # Stash for prepare_max_M_objective / raw_M. - transcribed.ext[:inf_mbm_main] = model - transcribed.ext[:inf_mbm_ref_map] = ref_map - # GDPSubmodel's fwd_map / decision_vars are CP-only; unused here. - return DP.GDPSubmodel(transcribed, InfiniteOpt.GeneralVariableRef[], - Dict{InfiniteOpt.GeneralVariableRef, Vector{JuMP.VariableRef}}()) + # 7. Wrap ref_map as fwd_map (singleton vectors) so + # prepare_max_M_objective can use the standard flat_map idiom. + fwd_map = Dict{InfiniteOpt.GeneralVariableRef, + Vector{InfiniteOpt.GeneralVariableRef}}( + v => [w] for (v, w) in ref_map) + return DP.GDPSubmodel( + mini, DP.collect_all_vars(model), fwd_map) end function DP.prepare_max_M_objective( @@ -255,11 +258,9 @@ function DP.prepare_max_M_objective( obj::JuMP.ScalarConstraint{T, S}, sub::DP.GDPSubmodel ) where {T, S <: _MOI.LessThan} - ref_map = sub.model.ext[:inf_mbm_ref_map] - mini_expr = DP.replace_variables_in_constraint( - obj.func, ref_map) - obj.set.upper - sub.model.ext[:inf_mbm_obj_expr] = obj.func - return InfiniteOpt.transformation_expression(mini_expr) + flat_map = Dict(v => ws[1] for (v, ws) in sub.fwd_map) + obj_func = DP.replace_variables_in_constraint(obj.func, flat_map) + return obj_func - obj.set.upper end function DP.prepare_max_M_objective( @@ -267,31 +268,35 @@ function DP.prepare_max_M_objective( obj::JuMP.ScalarConstraint{T, S}, sub::DP.GDPSubmodel ) where {T, S <: _MOI.GreaterThan} - ref_map = sub.model.ext[:inf_mbm_ref_map] - mini_expr = obj.set.lower - DP.replace_variables_in_constraint( - obj.func, ref_map) - sub.model.ext[:inf_mbm_obj_expr] = obj.func - return InfiniteOpt.transformation_expression(mini_expr) + flat_map = Dict(v => ws[1] for (v, ws) in sub.fwd_map) + obj_func = DP.replace_variables_in_constraint(obj.func, flat_map) + return obj.set.lower - obj_func end -# Per-support solve, delegating to scalar base raw_M. Aggregated to a -# scalar if uniform, else to a parameter function. +# Transcribe mini_expr, solve per support on the transcribed JuMP +# model, and aggregate to a scalar if uniform, else to a parameter +# function on main. function DP.raw_M( - sub::DP.GDPSubmodel, - objectives::AbstractArray{<:Union{JuMP.AbstractJuMPScalar, Real}}, + sub::DP.GDPSubmodel{<:InfiniteOpt.InfiniteModel}, + mini_expr::JuMP.AbstractJuMPScalar, method::DP._MBM ) + objectives = InfiniteOpt.transformation_expression(mini_expr) + transcribed = InfiniteOpt.transformation_model(sub.model) + inner_sub = DP.GDPSubmodel(transcribed,JuMP.VariableRef[], + Dict{JuMP.VariableRef, Vector{JuMP.VariableRef}}() + ) M_vals = Array{typeof(method.default_M)}(undef, size(objectives)) for I in eachindex(objectives) - JuMP.set_start_value.(JuMP.all_variables(sub.model), nothing) - m = DP.raw_M(sub, objectives[I], method) + m = DP.raw_M(inner_sub, objectives[I], method) m === nothing && return nothing M_vals[I] = m end all(==(first(M_vals)), M_vals) && return first(M_vals) - main = sub.model.ext[:inf_mbm_main] - expr = sub.model.ext[:inf_mbm_obj_expr] - prefs = InfiniteOpt.parameter_refs(expr) + mini_prefs = InfiniteOpt.parameter_refs(mini_expr) + reverse_map = Dict(ws[1] => v for (v, ws) in sub.fwd_map) + prefs = Tuple(reverse_map[p] for p in mini_prefs) + main = JuMP.owner_model(first(prefs)) grids = Tuple(InfiniteOpt.supports(p) for p in prefs) interp = Interpolations.linear_interpolation(grids, M_vals) param_func = InfiniteOpt.build_parameter_function( diff --git a/src/mbm.jl b/src/mbm.jl index 76ca939f..23c5096e 100644 --- a/src/mbm.jl +++ b/src/mbm.jl @@ -273,7 +273,7 @@ Returns `max(obj_value, 0)` on optimal, `nothing` on infeasible `method.default_M` otherwise (unbounded, numerical failure, etc). """ function raw_M( - sub::GDPSubmodel, + sub::GDPSubmodel{<:JuMP.AbstractModel}, objective::JuMP.AbstractJuMPScalar, method::_MBM ) diff --git a/src/variables.jl b/src/variables.jl index f96cfe02..e3369db2 100644 --- a/src/variables.jl +++ b/src/variables.jl @@ -547,7 +547,9 @@ function get_variable_info(vref::JuMP.AbstractVariableRef; has_lb::Bool = JuMP.has_lower_bound(vref), has_ub::Bool = JuMP.has_upper_bound(vref), has_fix::Bool = JuMP.is_fixed(vref), - has_start::Bool = JuMP.has_start_value(vref), + has_start::Bool = JuMP.has_start_value(vref) && + !(JuMP.start_value(vref) isa Number && + isnan(JuMP.start_value(vref))), has_binary::Bool = JuMP.is_binary(vref), has_integer::Bool = JuMP.is_integer(vref), lower_bound::Union{Number, Function} = has_lb ? JuMP.lower_bound(vref) : 0, diff --git a/test/constraints/mbm.jl b/test/constraints/mbm.jl index 0c96dbbb..11f164da 100644 --- a/test/constraints/mbm.jl +++ b/test/constraints/mbm.jl @@ -783,6 +783,20 @@ function test_get_variable_info() @test info_custom.has_ub == false end +# A NaN-valued start is treated as no start so the NaN doesn't +# propagate to copies or to solver inputs. +function test_get_variable_info_nan_start() + model = GDPModel() + @variable(model, x) + JuMP.set_start_value(x, NaN) + @test JuMP.has_start_value(x) == true + @test isnan(JuMP.start_value(x)) + + info = DP.get_variable_info(x) + @test info.has_start == false + @test info.start == 0 +end + @testset "MBM" begin test__copy_model() test_variable_properties() @@ -791,6 +805,7 @@ end test_variable_copy() test__copy_model_with_constraints() test_get_variable_info() + test_get_variable_info_nan_start() test_mbm() test__var_ref_type_numeric_map() test__replace_variables_quad_numeric_map() diff --git a/test/extensions/InfiniteDisjunctiveProgramming.jl b/test/extensions/InfiniteDisjunctiveProgramming.jl index 42ac0914..c000ebc7 100644 --- a/test/extensions/InfiniteDisjunctiveProgramming.jl +++ b/test/extensions/InfiniteDisjunctiveProgramming.jl @@ -381,7 +381,7 @@ function test_raw_M_infinite_scalar() model, DP.DisjunctConstraintRef[con2], mbm) obj = DP.prepare_max_M_objective( model, JuMP.constraint_object(con), sub) - @test length(obj) == 5 # K support points + @test length(InfiniteOpt.parameter_refs(obj)) == 1 @test DP.raw_M(sub, obj, mbm) == 5.0 end From 2909c8416925a7b74eff727263ff83caa97a5fa5 Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Fri, 1 May 2026 11:59:38 -0400 Subject: [PATCH 27/59] Internal constant interpolation --- Project.toml | 7 +--- ext/InfiniteDisjunctiveProgramming.jl | 30 ++++++++++++-- .../InfiniteDisjunctiveProgramming.jl | 39 +++++++++++++++++-- 3 files changed, 64 insertions(+), 12 deletions(-) diff --git a/Project.toml b/Project.toml index ae0dad94..7b4a2728 100644 --- a/Project.toml +++ b/Project.toml @@ -9,15 +9,13 @@ Reexport = "189a3867-3050-52da-a836-e630ba90ab69" [weakdeps] InfiniteOpt = "20393b10-9daf-11e9-18c9-8db751c92c57" -Interpolations = "a98d9a8b-a2ab-59e6-89dd-64a1c18fca59" [extensions] -InfiniteDisjunctiveProgramming = ["InfiniteOpt", "Interpolations"] +InfiniteDisjunctiveProgramming = "InfiniteOpt" [compat] Aqua = "0.8" InfiniteOpt = "0.6" -Interpolations = "0.16.2" Ipopt = "1.9.0" JuMP = "1.18" Juniper = "0.9.3" @@ -28,10 +26,9 @@ julia = "1.10" Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" HiGHS = "87dc4568-4c63-4d18-b0c0-bb2238e4078b" InfiniteOpt = "20393b10-9daf-11e9-18c9-8db751c92c57" -Interpolations = "a98d9a8b-a2ab-59e6-89dd-64a1c18fca59" Ipopt = "b6b21f68-93f8-5de0-b562-5493be1d77c9" Juniper = "2ddba703-00a4-53a7-87a5-e8b9971dde84" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [targets] -test = ["Aqua", "HiGHS", "InfiniteOpt", "Test", "Juniper", "Ipopt", "Interpolations"] +test = ["Aqua", "HiGHS", "InfiniteOpt", "Test", "Juniper", "Ipopt"] diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 693845bd..05b7219e 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -1,7 +1,7 @@ module InfiniteDisjunctiveProgramming import JuMP.MOI as _MOI -import InfiniteOpt, JuMP, Interpolations +import InfiniteOpt, JuMP import DisjunctiveProgramming as DP ################################################################################ @@ -273,6 +273,31 @@ function DP.prepare_max_M_objective( return obj.set.lower - obj_func end +# Constant interpolation +function _interpolate( + grids::NTuple{N, AbstractVector{<:Real}}, + values::AbstractArray{<:Real, N} + ) where {N} + # mimic the call form of Interpolations.jl's interpolation + return (args...) -> _interpolate_at(grids, values, args) +end + +function _interpolate_at( + grids::NTuple{N, AbstractVector{<:Real}}, + values::AbstractArray{<:Real, N}, + args::NTuple{N, <:Real} + ) where {N} + # lower-corner cell index per dimension + idx_lo = ntuple(d -> + clamp(searchsortedlast(grids[d], args[d]),1, length(grids[d]) - 1), N + ) + # max over the 2^N corners; bit d of k picks lower or upper + return maximum( + values[ntuple(d -> idx_lo[d] +((k >> (d - 1)) & 1), N)...] + for k in 0:(2^N - 1) + ) +end + # Transcribe mini_expr, solve per support on the transcribed JuMP # model, and aggregate to a scalar if uniform, else to a parameter # function on main. @@ -298,9 +323,8 @@ function DP.raw_M( prefs = Tuple(reverse_map[p] for p in mini_prefs) main = JuMP.owner_model(first(prefs)) grids = Tuple(InfiniteOpt.supports(p) for p in prefs) - interp = Interpolations.linear_interpolation(grids, M_vals) param_func = InfiniteOpt.build_parameter_function( - error, (args...) -> interp(args...), prefs) + error, _interpolate(grids, M_vals), prefs) return InfiniteOpt.add_parameter_function(main, param_func) end diff --git a/test/extensions/InfiniteDisjunctiveProgramming.jl b/test/extensions/InfiniteDisjunctiveProgramming.jl index c000ebc7..47bb884b 100644 --- a/test/extensions/InfiniteDisjunctiveProgramming.jl +++ b/test/extensions/InfiniteDisjunctiveProgramming.jl @@ -1,4 +1,4 @@ -using InfiniteOpt, HiGHS, Ipopt, Juniper, Interpolations +using InfiniteOpt, HiGHS, Ipopt, Juniper import DisjunctiveProgramming as DP # Helper to access internal function @@ -387,8 +387,8 @@ end # raw_M with a support-varying M. Setup: x(t) ∈ [0, 10], disj1: x ≤ 2t, # disj2: x ≥ 0.5. Slack r(x) = x - 2t maximized over x ∈ [0.5, 10]: -# max(x - 2t) = 10 - 2t. Varies with t ⇒ raw_M returns a pfunc; the -# underlying function should evaluate to 10 - 2t at each support. +# max(x - 2t) = 10 - 2t. Varies with t ⇒ raw_M returns a pfunc whose +# raw values at supports are max-of-cell upper bounds for 10 - 2t. function test_raw_M_infinite_param_function() model = InfiniteGDPModel() supports = [0.0, 0.25, 0.5, 0.75, 1.0] @@ -407,11 +407,41 @@ function test_raw_M_infinite_param_function() M = DP.raw_M(sub, obj, mbm) @test M isa InfiniteOpt.GeneralVariableRef raw_fn = InfiniteOpt.raw_function(M) + # max-of-corners is conservative: raw_fn(t) ≥ 10 - 2t at supports. for t_val in supports - @test raw_fn(t_val) ≈ 10.0 - 2*t_val atol=1e-6 + @test raw_fn(t_val) >= 10.0 - 2*t_val - 1e-6 end end +# Piecewise-constant max-of-corners: returns the maximum value over +# the 2^n corners of the cell containing the query. +function test_interpolate() + grid1 = [0.0, 1.0, 2.0, 3.0] + vals1 = [10.0, 20.0, 40.0, 50.0] + f = IDP._interpolate((grid1,), vals1) + # At grid points: max over the cell to the right (or last cell). + @test f(0.0) == 20.0 # max(vals[1], vals[2]) + @test f(1.0) == 40.0 # max(vals[2], vals[3]) + @test f(3.0) == 50.0 # last cell: max(vals[3], vals[4]) + # Between grid points: max of the surrounding two values. + @test f(0.5) == 20.0 + @test f(1.5) == 40.0 + @test f(2.25) == 50.0 + # Out-of-range clamps to the boundary cell. + @test f(-1.0) == 20.0 + @test f(4.0) == 50.0 + + # 2D: max over the 4 surrounding corners. + gx = [0.0, 1.0, 2.0] + gy = [0.0, 10.0] + vals2 = [x * y for x in gx, y in gy] # 3x2 matrix + g = IDP._interpolate((gx, gy), vals2) + @test g(0.0, 0.0) == 10.0 # corners (0,0)=0, (1,0)=0, (0,10)=0, (1,10)=10 + @test g(2.0, 10.0) == 20.0 # last cell, max corner is (2,10)=20 + @test g(0.5, 5.0) == 10.0 # corners 0,0,0,10 -> 10 + @test g(1.5, 5.0) == 20.0 # corners 0,0,10,20 -> 20 +end + # extract_solution returns per-support values from the transformation # backend. Setup: force disj 2 active (x ≤ 3), BigM-reformulate, solve # min ∫x ⇒ x = 0 at every support. @@ -801,6 +831,7 @@ end end @testset "MBM" begin + test_interpolate() test_raw_M_infinite_scalar() test_raw_M_infinite_param_function() test_mbm_finite_and_integer_var() From bb769992125f1122afef5eefb1c6f18f2cd657a9 Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Wed, 13 May 2026 22:55:28 -0400 Subject: [PATCH 28/59] Change to copy_model_with_constraints to use copy_model(::InfiniteModel) --- ext/InfiniteDisjunctiveProgramming.jl | 84 ++++++++------------------- 1 file changed, 25 insertions(+), 59 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 05b7219e..6399c9c1 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -174,83 +174,49 @@ end # MBM FOR INFINITEMODEL ################################################################################ -# Build a mini InfiniteModel holding only the given disjunct constraints, -# transcribe it, and return as a GDPSubmodel. +# Copy the InfiniteModel, strip everything but VariableInfo bounds, +# add back the selected disjunct constraints, transcribe, and return +# only the other disjunct's constraints plus variable bounds. function DP.copy_model_with_constraints( model::InfiniteOpt.InfiniteModel, constraints::Vector{<:DP.DisjunctConstraintRef}, method::DP._MBM ) - mini = InfiniteOpt.InfiniteModel() - ref_map = Dict{InfiniteOpt.GeneralVariableRef, - InfiniteOpt.GeneralVariableRef}() + mini, ref_map = JuMP.copy_model(model) - # 1. Copy infinite parameters with their supports - for p in InfiniteOpt.all_parameters(model) - domain = InfiniteOpt.infinite_domain(p) - supports = Float64.(InfiniteOpt.supports(p)) - param = InfiniteOpt.build_parameter(error, domain; supports = supports) - new_param = InfiniteOpt.add_parameter(mini, param, JuMP.name(p)) - ref_map[p] = new_param - end - - # 2. Copy decision variables with bounds - for v in JuMP.all_variables(model) - prefs = InfiniteOpt.parameter_refs(v) - var_type = isempty(prefs) ? nothing : - InfiniteOpt.Infinite(Tuple(ref_map[p] for p in prefs)...) - props = DP.VariableProperties( - DP.get_variable_info(v), "", nothing, var_type) - ref_map[v] = DP.create_variable(mini, props) - end - - # 3. Copy derivatives with their bounds - for d in InfiniteOpt.all_derivatives(model) - vref = InfiniteOpt.derivative_argument(d) - pref = InfiniteOpt.operator_parameter(d) - new_d = InfiniteOpt.deriv(ref_map[vref], ref_map[pref]) - info = DP.get_variable_info(d) - info.has_lb && JuMP.set_lower_bound(new_d, info.lower_bound) - info.has_ub && JuMP.set_upper_bound(new_d, info.upper_bound) - ref_map[d] = new_d - end - - # 4. Copy parameter functions (needed by ref_map substitution) - for pfunc in InfiniteOpt.all_parameter_functions(model) - func = InfiniteOpt.raw_function(pfunc) - prefs = InfiniteOpt.parameter_refs(pfunc) - mapped_prefs = Tuple(ref_map[p] for p in prefs) - pref_arg = length(mapped_prefs) == 1 ? - only(mapped_prefs) : mapped_prefs - param_func = InfiniteOpt.build_parameter_function( - error, func, pref_arg) - ref_map[pfunc] = InfiniteOpt.add_parameter_function( - mini, param_func) + # Drop global constraints. + for cref in JuMP.all_constraints(mini) + JuMP.delete(mini, cref) end - # 5. Add disjunct constraints using existing ref_map for cref in constraints - cref isa DP.DisjunctConstraintRef || continue con = JuMP.constraint_object(cref) - new_func = DP.replace_variables_in_constraint(con.func, ref_map) T = one(JuMP.value_type(typeof(mini))) - JuMP.@constraint(mini, new_func * T in con.set) + JuMP.@constraint(mini, ref_map[con.func] * T in con.set) end - # 6. Build the transformation backend so transcribed exists and - # configure its solver. We hold mini in sub.model and recover - # transcribed lazily via transformation_model(mini). InfiniteOpt.build_transformation_backend!(mini) transcribed = InfiniteOpt.transformation_model(mini) JuMP.set_optimizer(transcribed, method.optimizer) JuMP.set_silent(transcribed) - # 7. Wrap ref_map as fwd_map (singleton vectors) so - # prepare_max_M_objective can use the standard flat_map idiom. + + # fwd_map needs every ref reachable from disjunct constraints — + # decision vars + parameters + parameter functions so the + # objective substitution in `prepare_max_M_objective` can look up + # any term it sees. + decision_vars = DP.collect_all_vars(model) fwd_map = Dict{InfiniteOpt.GeneralVariableRef, - Vector{InfiniteOpt.GeneralVariableRef}}( - v => [w] for (v, w) in ref_map) - return DP.GDPSubmodel( - mini, DP.collect_all_vars(model), fwd_map) + Vector{InfiniteOpt.GeneralVariableRef}}() + for v in decision_vars + fwd_map[v] = [ref_map[v]] + end + for p in InfiniteOpt.all_parameters(model) + fwd_map[p] = [ref_map[p]] + end + for pf in InfiniteOpt.all_parameter_functions(model) + fwd_map[pf] = [ref_map[pf]] + end + return DP.GDPSubmodel(mini, decision_vars, fwd_map) end function DP.prepare_max_M_objective( From fb5f381282e518323c5a95834301cab619a02dd8 Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Wed, 13 May 2026 23:07:27 -0400 Subject: [PATCH 29/59] Project.toml revert --- Project.toml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Project.toml b/Project.toml index 7b4a2728..3ca54b77 100644 --- a/Project.toml +++ b/Project.toml @@ -15,20 +15,20 @@ InfiniteDisjunctiveProgramming = "InfiniteOpt" [compat] Aqua = "0.8" -InfiniteOpt = "0.6" -Ipopt = "1.9.0" JuMP = "1.18" -Juniper = "0.9.3" Reexport = "1" julia = "1.10" +Juniper = "0.9.3" +Ipopt = "1.9.0" +InfiniteOpt = "0.6" [extras] Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" HiGHS = "87dc4568-4c63-4d18-b0c0-bb2238e4078b" -InfiniteOpt = "20393b10-9daf-11e9-18c9-8db751c92c57" +Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" Ipopt = "b6b21f68-93f8-5de0-b562-5493be1d77c9" Juniper = "2ddba703-00a4-53a7-87a5-e8b9971dde84" -Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" +InfiniteOpt = "20393b10-9daf-11e9-18c9-8db751c92c57" [targets] -test = ["Aqua", "HiGHS", "InfiniteOpt", "Test", "Juniper", "Ipopt"] +test = ["Aqua", "HiGHS", "Test", "Juniper", "Ipopt", "InfiniteOpt"] From e420f982aa88ed61348f4dc66a667211b0d85b55 Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Wed, 13 May 2026 23:23:27 -0400 Subject: [PATCH 30/59] Using master version of InfiniteOpt --- .github/workflows/CI.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index acae65fc..8d852299 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -25,6 +25,8 @@ jobs: with: version: ${{ matrix.version }} arch: ${{ matrix.arch }} + - name: Use InfiniteOpt master + run: julia --color=yes --project=. -e 'using Pkg; Pkg.develop(url="https://github.com/infiniteopt/InfiniteOpt.jl")' - uses: julia-actions/julia-runtest@latest - uses: julia-actions/julia-processcoverage@v1 - uses: codecov/codecov-action@v4 From c0ca8fe8b8b6e8535751785eb5cb94ad49e91afd Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Wed, 13 May 2026 23:27:44 -0400 Subject: [PATCH 31/59] Reverting CI change --- .github/workflows/CI.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 8d852299..acae65fc 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -25,8 +25,6 @@ jobs: with: version: ${{ matrix.version }} arch: ${{ matrix.arch }} - - name: Use InfiniteOpt master - run: julia --color=yes --project=. -e 'using Pkg; Pkg.develop(url="https://github.com/infiniteopt/InfiniteOpt.jl")' - uses: julia-actions/julia-runtest@latest - uses: julia-actions/julia-processcoverage@v1 - uses: codecov/codecov-action@v4 From 404c7d678b0398df6d18a4ca91896e94f3cc8486 Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Wed, 22 Apr 2026 13:17:06 -0400 Subject: [PATCH 32/59] loa_two_models squashed --- ext/InfiniteDisjunctiveProgramming.jl | 206 ++++++++ src/DisjunctiveProgramming.jl | 1 + src/loa.jl | 656 ++++++++++++++++++++++++++ src/utilities.jl | 82 ++++ test/constraints/loa.jl | 225 +++++++++ test/runtests.jl | 1 + test/solve.jl | 27 +- 7 files changed, 1193 insertions(+), 5 deletions(-) create mode 100644 src/loa.jl create mode 100644 test/constraints/loa.jl diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 6399c9c1..20fca5a5 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -374,5 +374,211 @@ function DP.add_cut( InfiniteOpt.set_transformation_backend_ready(model, true) return end +################################################################################ +# LOA FOR INFINITEMODEL +################################################################################ +# Dispatch overrides for InfiniteModel. The base LOA algorithm in src/loa.jl +# is written for finite (scalar) models; these overrides handle transcription +# to a flat master and per-support binary/variable indexing. + +# Helper: map an InfiniteOpt var to its flat master var(s) via transcription + +# copy_map. Returns a vector for infinite vars, scalar for finite. +function _tv_map(v, copy_map) + tv = InfiniteOpt.transformation_variable(v) + return tv isa AbstractArray ? [copy_map[fv] for fv in vec(tv)] : copy_map[tv] +end + +# Convert InfiniteModel x_values to flat-var x_values (for objective OA cut). +function _flat_xk(model::InfiniteOpt.InfiniteModel, x_values) + fxk = Dict{JuMP.VariableRef, Float64}() + for (v, val) in x_values + tv = InfiniteOpt.transformation_variable(v) + if tv isa AbstractArray + vals = val isa AbstractVector ? val : fill(Float64(val), length(tv)) + for (i, fv) in enumerate(vec(tv)) + fxk[fv] = vals[i] + end + else + fxk[tv] = val isa Number ? Float64(val) : Float64(first(val)) + end + end + return fxk +end + +#per-support dispatch methods: the base DP.jl LOA code calls scalar-form +#helpers; these array methods let the same code path handle vector-valued +#bin_map / var_map / x_values / active entries without any extra branches +#in the base file. + +DP.fix_fv(bvs::AbstractArray, val::Bool) = + (for bv in bvs; DP.fix_fv(bv, val); end; return) +DP.fix_fv(bvs::AbstractArray, val::AbstractArray) = + (for (bv, v) in zip(bvs, val); DP.fix_fv(bv, v); end; return) +DP.unfix_fv(bvs::AbstractArray) = + (for bv in bvs; DP.unfix_fv(bv); end; return) + +DP.any_active(v::AbstractVector{Bool}) = any(v) + +#combo extraction: round per-support binary values to a Vector{Bool} +DP.combo_val(bvs::AbstractArray) = Bool.(round.(JuMP.value.(bvs))) + +#no-good cut: fold one scalar term per (binary, active) pair. The scalar- +#active method handles the set-covering phase where combos are Bool-valued. +DP.add_ng_terms(cut, bvs::AbstractArray, active::Bool) = + DP.add_ng_terms(cut, bvs, fill(active, length(bvs))) +function DP.add_ng_terms(cut, bvs::AbstractArray, actives::AbstractArray) + for (bv, a) in zip(bvs, actives) + DP.add_ng_terms(cut, bv, a) + end + return +end + +#OA cut sites: one site per active support, with per-support restrictions +#of `x_values` and `var_map` +DP.cut_sites(bvs::AbstractArray, active::Bool, x_values, var_map, d) = + DP.cut_sites(bvs, fill(active, length(bvs)), x_values, var_map, d) +function DP.cut_sites( + bvs::AbstractArray, actives::AbstractArray, + x_values, var_map, d + ) + sites = Any[] + for k in 1:length(bvs) + actives[k] || continue + smap_k = Dict{Any, Any}( + v => (mv isa AbstractVector ? mv[k] : mv) + for (v, mv) in var_map) + x_k = Dict{Any, Any}( + v => (xv isa AbstractVector ? xv[k] : xv) + for (v, xv) in x_values) + d_k = d isa AbstractVector ? d[k] : d + push!(sites, (bvs[k], x_k, smap_k, d_k)) + end + return sites +end + +#detect number of supports from a bin_map; used below in `build_loa_master` +function _detect_K(bin_map) + for (_, bvs) in bin_map + bvs isa AbstractVector && return length(bvs) + end + return 1 +end + +#transcribe the BigM'd InfiniteModel to flat, copy it, create alpha_oa. +#Number of supports K is stashed in `master.model.ext[:_loa_K]` for the +#per-support OA cut and combo overrides below. +function DP.build_loa_master(model::InfiniteOpt.InfiniteModel, method::DP.LOA) + InfiniteOpt.build_transformation_backend!(model) + flat = InfiniteOpt.transformation_model(model) + orig_obj = JuMP.objective_function(flat) + master, copy_map = JuMP.copy_model(flat) + JuMP.set_optimizer(master, method.mip_optimizer) + JuMP.set_silent(master) + bin_map = Dict{DP.LogicalVariableRef, Any}() + for (ind, bv) in DP._indicator_to_binary(model) + bin_map[ind] = _tv_map(bv, copy_map) + end + var_map = Dict{InfiniteOpt.GeneralVariableRef, Any}() + for v in DP.collect_all_vars(model) + var_map[v] = _tv_map(v, copy_map) + end + #also store a flat→master copy_map for objective OA cuts + flat_copy_map = Dict{JuMP.VariableRef, JuMP.VariableRef}() + for v in JuMP.all_variables(flat) + flat_copy_map[v] = copy_map[v] + end + obj_sense = JuMP.objective_sense(master) + alpha_oa = JuMP.@variable(master, base_name = "alpha_oa") + JuMP.@objective(master, obj_sense, alpha_oa) + m = DP._LOAMaster(master, bin_map, var_map, obj_sense, orig_obj, + alpha_oa, flat_copy_map) + master.ext[:_loa_K] = _detect_K(bin_map) + master.ext[:_loa_flat_copy_map] = flat_copy_map + return m +end + +#fix per-support via point equality constraints +function DP.fix_combo_binaries(model::InfiniteOpt.InfiniteModel, combo) + crefs = InfiniteOpt.InfOptConstraintRef[] + for (ind, val) in combo + bv = DP._indicator_to_binary(model)[ind] + if val isa Bool + JuMP.fix(bv, val ? 1.0 : 0.0; force = true) + else + sups = InfiniteOpt.supports(first(InfiniteOpt.parameter_refs(bv))) + for (k, s) in enumerate(vec(sups)) + push!(crefs, + JuMP.@constraint(model, bv(s) == (val[k] ? 1.0 : 0.0))) + end + end + end + model.ext[:_loa_fix_crefs] = crefs +end + +function DP.unfix_combo_binaries(model::InfiniteOpt.InfiniteModel, combo) + if haskey(model.ext, :_loa_fix_crefs) + for c in model.ext[:_loa_fix_crefs] + JuMP.is_valid(model, c) && JuMP.delete(model, c) + end + delete!(model.ext, :_loa_fix_crefs) + end + for (ind, val) in combo + val isa Bool || continue + bv = DP._indicator_to_binary(model)[ind] + JuMP.is_fixed(bv) && JuMP.unfix(bv) + end +end + +#Transcribe the BigM'd InfiniteModel, then hand off to the base +#`copy_model_with_constraints` on the flat model. fwd_map is rekeyed to +#InfiniteModel vars for use by `_extract_*_x_values`; obj_ref_map stays +#keyed by flat vars (the flat-level objective OA cut lives there). +function DP.copy_model_with_constraints( + model::InfiniteOpt.InfiniteModel, method::DP.LOA + ) + InfiniteOpt.build_transformation_backend!(model) + flat = InfiniteOpt.transformation_model(model) + base = DP.copy_model_with_constraints(flat, method) + fwd_map = Dict{InfiniteOpt.GeneralVariableRef, Any}() + for v in DP.collect_all_vars(model) + fwd_map[v] = _tv_map(v, base.fwd_map) + end + return DP._LOAFeasSubmodel(base.model, fwd_map, base.fwd_map) +end + +#read per-support JuMP values from either a vector of flat vars or a +#single scalar. Dispatch handles both transformation_variable (may be +#N-dim) and fwd_map (already flat) shapes. +_read_values(v::AbstractArray) = Float64[JuMP.value(fv) for fv in vec(v)] +_read_values(v) = Float64(JuMP.value(v)) + +#extract per-support x-values from the InfiniteModel NLP; objective +#x-values are keyed by the flat transcription vars for the objective cut +function DP.extract_primary_x_values(model::InfiniteOpt.InfiniteModel) + x_vals = Dict{JuMP.AbstractVariableRef, Any}() + for v in DP.collect_all_vars(model) + JuMP.is_fixed(v) && continue + x_vals[v] = _read_values(InfiniteOpt.transformation_variable(v)) + end + return x_vals, _flat_xk(model, x_vals) +end + +#extract per-support x-values from the flat feas submodel. x_vals keys are +#InfiniteModel vars (via fwd_map); obj_x_values keys are flat vars. +function DP.extract_feas_x_values( + model::InfiniteOpt.InfiniteModel, feas::DP._LOAFeasSubmodel + ) + x_vals = Dict{JuMP.AbstractVariableRef, Any}() + for v in DP.collect_all_vars(model) + JuMP.is_fixed(v) && continue + haskey(feas.fwd_map, v) || continue + x_vals[v] = _read_values(feas.fwd_map[v]) + end + obj_xv = Dict{JuMP.VariableRef, Float64}() + for (flat_v, feas_v) in feas.obj_ref_map + obj_xv[flat_v] = Float64(JuMP.value(feas_v)) + end + return x_vals, obj_xv +end end diff --git a/src/DisjunctiveProgramming.jl b/src/DisjunctiveProgramming.jl index 85f03455..6f1537e7 100644 --- a/src/DisjunctiveProgramming.jl +++ b/src/DisjunctiveProgramming.jl @@ -28,6 +28,7 @@ include("print.jl") include("extension_api.jl") include("utilities.jl") include("psplit.jl") +include("loa.jl") # Define additional stuff that should not be exported const _EXCLUDE_SYMBOLS = [Symbol(@__MODULE__), :eval, :include] diff --git a/src/loa.jl b/src/loa.jl new file mode 100644 index 00000000..3f750fb3 --- /dev/null +++ b/src/loa.jl @@ -0,0 +1,656 @@ +################################################################################ +# LOGIC-BASED OUTER APPROXIMATION (LOA) +################################################################################ +# Türkay & Grossmann (1996), Comp. & Chem. Eng. 20(8), 959-978 +# With augmented-penalty OA master from: +# Viswanathan & Grossmann (1990), Comp. & Chem. Eng. 14(7), 769-782 +################################################################################ + +""" + LOA{O, P} <: AbstractReformulationMethod + +Logic-based Outer Approximation solver for GDP models. Uses two models: the +original (BigM-reformulated, binaries fixed per iteration as an NLP) and a +master MILP copy that accumulates OA and no-good cuts. +""" +struct LOA{O, P} <: AbstractReformulationMethod + nlp_optimizer::O + mip_optimizer::P + max_iter::Int + atol::Float64 + rtol::Float64 + M_value::Float64 + max_slack::Float64 + OA_penalty_factor::Float64 + function LOA( + nlp_optimizer::O; + mip_optimizer::P = nlp_optimizer, + max_iter::Int = 10, atol::Float64 = 1e-6, + rtol::Float64 = 1e-4, M_value::Float64 = 1e9, + max_slack::Float64 = 1000.0, OA_penalty_factor::Float64 = 1000.0 + ) where {O, P} + new{O, P}(nlp_optimizer, mip_optimizer, max_iter, atol, rtol, + M_value, max_slack, OA_penalty_factor) + end +end + +################################################################################ +# DATA STRUCTURES +################################################################################ +# Master MILP state. orig_obj is the original objective (for OA cuts); +# alpha_oa replaces it (Türkay & Grossmann 1996). obj_ref_map maps the +# variables in orig_obj to master variables. +mutable struct _LOAMaster{M <: JuMP.AbstractModel, B, V} + model::M + bin_map::B + var_map::V + obj_sense::_MOI.OptimizationSense + orig_obj::Any + alpha_oa::Any + obj_ref_map::Any +end + +# Feasibility-restoration submodel (Viswanathan & Grossmann 1990, NLPF). +# Standalone model with a shared scalar slack `u` embedded into every JuMP +# constraint and `min u` as the objective. When the primary NLP is +# infeasible, binaries are fixed here and the submodel is solved to +# produce a least-infeasible point for OA cut generation. Keeping it +# separate leaves the original NLP model clean (no `u`, no slackened +# constraints). +# +# fwd_map: original-model var -> feas var, used for binary fixing and +# value extraction. +# obj_ref_map: original-objective var -> feas var, used to linearize the +# original objective at the feas point. +struct _LOAFeasSubmodel{M <: JuMP.AbstractModel} + model::M + fwd_map::Any + obj_ref_map::Any +end + +################################################################################ +# MAIN ALGORITHM +################################################################################ +function reformulate_model(model::JuMP.AbstractModel, method::LOA) + _clear_reformulations(model) + combos = _set_covering_combos(model) + reformulate_model(model, BigM(method.M_value)) + + #build the feasibility-restoration submodel as a separate copy, then + #embed the shared scalar slack `u` in every constraint (V&G 1990, + #NLPF). The original model stays clean: no slacks, no integrality + #relaxation, no objective change. + feas = copy_model_with_constraints(model, method) + _embed_feas_slack(feas) + + master = build_loa_master(model, method) + reform_map = _build_reform_map(model) + JuMP.relax_integrality(model) + JuMP.set_optimizer(model, method.nlp_optimizer) + JuMP.set_silent(model) + + sense_token = Val(JuMP.objective_sense(model)) + best_obj = _worst_obj(sense_token) + is_better(o) = _is_better(sense_token, o, best_obj) + best_result = nothing + for combo in combos + result = _solve_nlp(model, combo, method, reform_map, feas) + _add_no_good_cut(model, master, combo) + _add_oa_cuts(model, master, result, method) + if result.feasible && is_better(result.objective) + best_obj = result.objective + best_result = result + end + end + + master_bound = _worst_obj(_flip_sense(sense_token)) + for iter in 1:method.max_iter + JuMP.optimize!(master.model) + JuMP.is_solved_and_feasible(master.model) || break + master_bound = JuMP.objective_value(master.model) + _loa_converged(best_obj, master_bound, sense_token, method) && break + combo = _extract_combo(model, master) + result = _solve_nlp(model, combo, method, reform_map, feas) + _add_no_good_cut(model, master, combo) + _add_oa_cuts(model, master, result, method) + if result.feasible && is_better(result.objective) + best_obj = result.objective + best_result = result + end + end + + _finalize_model(model, best_result) + return +end + +#fix best combo permanently and set start values +function _finalize_model(model, best_result) + best_result === nothing && return + fix_combo_binaries(model, best_result.combo) + for (var, v) in best_result.x_values + JuMP.is_valid(model, var) || continue + v isa Number || continue + JuMP.set_start_value(var, v) + end +end + +################################################################################ +# EXTENSION POINTS +################################################################################ +#build master MILP from the BigM-reformulated original +function build_loa_master(model::JuMP.AbstractModel, method::LOA) + orig_obj = JuMP.objective_function(model) + master, copy_map = JuMP.copy_model(model) + JuMP.set_optimizer(master, method.mip_optimizer) + JuMP.set_silent(master) + bin_map = Dict{LogicalVariableRef, Any}() + for (ind, bv) in _indicator_to_binary(model) + bin_map[ind] = copy_map[bv] + end + #replace objective with alpha_oa; OA cuts are added each iteration + obj_sense = JuMP.objective_sense(master) + alpha_oa = JuMP.@variable(master, base_name = "alpha_oa") + JuMP.@objective(master, obj_sense, alpha_oa) + return _LOAMaster( + master, bin_map, copy_map, obj_sense, orig_obj, alpha_oa, copy_map) +end + +""" + copy_model_with_constraints(model, method::LOA) + +Build an LOA feas submodel: a fresh deep copy of `model` with every +non-bound constraint copied over, wired up with `method.nlp_optimizer`. +The returned submodel is a raw copy; slack embedding and the `min u` +objective are applied separately via [`_embed_feas_slack`]. Shares the +dispatch entry point with MBM's [`copy_model_with_constraints`](@ref), +which instead takes an explicit subset of disjunct constraints. +""" +function copy_model_with_constraints( + model::JuMP.AbstractModel, method::LOA + ) + var_type = JuMP.variable_ref_type(model) + sub_model = _copy_model(model) + fwd_map = Dict{var_type, var_type}() + for var in collect_all_vars(model) + fwd_map[var] = variable_copy(sub_model, var) + end + VT = JuMP.variable_ref_type(typeof(model)) + for (F, S) in JuMP.list_of_constraint_types(model) + F === VT && continue + for cref in JuMP.all_constraints(model, F, S) + con = JuMP.constraint_object(cref) + expr = _replace_variables_in_constraint(con.func, fwd_map) + JuMP.@constraint(sub_model, expr in con.set) + end + end + JuMP.set_optimizer(sub_model, method.nlp_optimizer) + JuMP.set_silent(sub_model) + return _LOAFeasSubmodel(sub_model, fwd_map, fwd_map) +end + +#embed shared scalar slack `u` into every constraint of the feas +#submodel, relax integrality, and set `min u` as the objective. Converts +#the raw copy produced by `copy_model_with_constraints` into a V&G 1990 +#NLPF feasibility-restoration problem. +function _embed_feas_slack(feas::_LOAFeasSubmodel) + u = JuMP.@variable(feas.model, base_name = "_loa_u", lower_bound = 0.0) + _slacken_model_constraints(feas.model, u) + JuMP.relax_integrality(feas.model) + JuMP.@objective(feas.model, Min, u) + return +end + +#replace every JuMP constraint in `model` (excluding variable bounds) with +#its slackened counterpart via `_slacken`. Shared by finite and InfiniteOpt +#feas submodel builders. +function _slacken_model_constraints(model, u) + VT = JuMP.variable_ref_type(typeof(model)) + to_slacken = Any[] + for (F, S) in JuMP.list_of_constraint_types(model) + F === VT && continue + for cref in JuMP.all_constraints(model, F, S) + push!(to_slacken, cref) + end + end + for cref in to_slacken + JuMP.is_valid(model, cref) || continue + con = JuMP.constraint_object(cref) + for (sf, ss) in _slacken(con.func, con.set, u) + JuMP.@constraint(model, sf in ss) + end + JuMP.delete(model, cref) + end + return +end + +#fix/unfix indicator binaries on the original model +function fix_combo_binaries(model, combo) + for (ind, active) in combo + bv = _indicator_to_binary(model)[ind] + JuMP.fix(bv, active ? 1.0 : 0.0; force = true) + end +end +function unfix_combo_binaries(model, combo) + for (ind, _) in combo + bv = _indicator_to_binary(model)[ind] + JuMP.is_fixed(bv) && JuMP.unfix(bv) + end +end + +################################################################################ +# SET COVERING INITIALIZATION +################################################################################ +function _set_covering_combos(model::JuMP.AbstractModel) + M = typeof(model) + LVR = LogicalVariableRef{M} + per_disj = Vector{Tuple{DisjunctionIndex, LVR}}[] + for (idx, disj_data) in _disjunctions(model) + disj_data.constraint.nested && continue + push!(per_disj, [(idx, ind) for ind in disj_data.constraint.indicators]) + end + isempty(per_disj) && return Dict{LVR, Bool}[] + all_combos = [Tuple{DisjunctionIndex, LVR}[c...] + for c in Iterators.product(per_disj...)] + uncovered = Set{LVR}() + for group in per_disj + for (_, ind) in group + push!(uncovered, ind) + end + end + selected = Dict{LVR, Bool}[] + while !isempty(uncovered) + best_combo = nothing + best_count = 0 + for combo in all_combos + cnt = sum(ind in uncovered for (_, ind) in combo) + if cnt > best_count + best_count = cnt + best_combo = combo + end + end + best_combo === nothing && break + combo_dict = Dict{LVR, Bool}() + for (disj_idx, active_ind) in best_combo + disj_data = _disjunctions(model)[disj_idx] + for ind in disj_data.constraint.indicators + combo_dict[ind] = (ind == active_ind) + end + end + push!(selected, combo_dict) + for (_, active_ind) in best_combo + delete!(uncovered, active_ind) + end + end + return selected +end + +################################################################################ +# NLP SUBPROBLEM +################################################################################ +# Termination tokens. `_solve_nlp` and `_run_feas_restoration` dispatch on +# these to build the result tuple without inspecting the solver status at +# multiple call sites. +struct _Feasible end +struct _Infeasible end +_nlp_status(model) = JuMP.is_solved_and_feasible(model) ? + _Feasible() : _Infeasible() + +#solve primary NLP for a fixed combo; on infeasibility, dispatch to the +#feasibility-restoration submodel (min u) to get a least-infeasible point +#for OA cut generation (Viswanathan & Grossmann 1990) +function _solve_nlp( + model::M, combo, method::LOA, reform_map, feas::_LOAFeasSubmodel + ) where {M <: JuMP.AbstractModel} + fix_combo_binaries(model, combo) + JuMP.optimize!(model, ignore_optimize_hook = true) + result = _nlp_primary_result( + _nlp_status(model), model, combo, reform_map, feas) + unfix_combo_binaries(model, combo) + return result +end + +#feasible primary NLP: pack up x-values, duals, objective +function _nlp_primary_result( + ::_Feasible, model::M, combo, reform_map, feas + ) where {M <: JuMP.AbstractModel} + x_vals, obj_xv = extract_primary_x_values(model) + duals = _collect_nlp_duals(model, combo, reform_map) + return (combo = combo, x_values = x_vals, duals = duals, + objective = JuMP.objective_value(model), feasible = true, + obj_x_values = obj_xv) +end + +#infeasible primary NLP: hand off to the feas-restoration submodel +function _nlp_primary_result( + ::_Infeasible, model, combo, reform_map, feas + ) + return _run_feas_restoration(model, feas, combo) +end + +#fix binaries on the feas submodel, solve min u, and dispatch on outcome +function _run_feas_restoration( + model::M, feas::_LOAFeasSubmodel, combo + ) where {M <: JuMP.AbstractModel} + _fix_feas_combo_binaries(feas, model, combo) + JuMP.optimize!(feas.model) + result = _nlp_feas_result(_nlp_status(feas.model), model, feas, combo) + _unfix_feas_combo_binaries(feas, model, combo) + return result +end + +#feas submodel solved: return the least-infeasible point for OA cuts +function _nlp_feas_result( + ::_Feasible, model::M, feas, combo + ) where {M <: JuMP.AbstractModel} + x_vals, obj_xv = extract_feas_x_values(model, feas) + return (combo = combo, x_values = x_vals, + duals = Dict{DisjunctConstraintRef{M}, Any}(), + objective = Inf, feasible = false, obj_x_values = obj_xv) +end + +#feas submodel also infeasible: return empty result (no OA cut) +function _nlp_feas_result( + ::_Infeasible, model::M, feas, combo + ) where {M <: JuMP.AbstractModel} + empty = Dict{JuMP.AbstractVariableRef, Any}() + return (combo = combo, x_values = empty, + duals = Dict{DisjunctConstraintRef{M}, Any}(), + objective = Inf, feasible = false, obj_x_values = empty) +end + +#collect duals of reformulated disjunct constraints for active disjuncts +function _collect_nlp_duals( + model::M, combo, reform_map + ) where {M <: JuMP.AbstractModel} + duals = Dict{DisjunctConstraintRef{M}, Any}() + JuMP.has_duals(model) || return duals + for (ind, active) in combo + active || continue + haskey(_indicator_to_constraints(model), ind) || continue + for cref in _indicator_to_constraints(model)[ind] + cref isa DisjunctConstraintRef || continue + duals[cref] = _sum_duals(reform_map, cref) + end + end + return duals +end + +#extract x-values from the primary NLP. Returns (x_vals, obj_x_values) +#where obj_x_values is keyed by whatever variables the original objective +#uses (same as x_vals for finite; flat-transcription vars for infinite). +function extract_primary_x_values(model::JuMP.AbstractModel) + x_vals = Dict{JuMP.AbstractVariableRef, Any}() + for v in JuMP.all_variables(model) + JuMP.is_fixed(v) && continue + x_vals[v] = JuMP.value(v) + end + return x_vals, x_vals +end + +#extract x-values from the feasibility submodel, keyed by original-model +#variables via `feas.fwd_map`. Same return contract as the primary path. +function extract_feas_x_values( + model::JuMP.AbstractModel, feas::_LOAFeasSubmodel + ) + x_vals = Dict{JuMP.AbstractVariableRef, Any}() + for v in JuMP.all_variables(model) + JuMP.is_fixed(v) && continue + haskey(feas.fwd_map, v) || continue + x_vals[v] = JuMP.value(feas.fwd_map[v]) + end + return x_vals, x_vals +end + +#fix/unfix indicator binaries on the feas submodel via fwd_map. Dispatches +#through `fix_fv`/`unfix_fv` to handle scalar feas vars (finite) and +#per-support vectors (InfiniteOpt flat transcription). +function _fix_feas_combo_binaries(feas::_LOAFeasSubmodel, model, combo) + for (ind, val) in combo + orig_bv = _indicator_to_binary(model)[ind] + haskey(feas.fwd_map, orig_bv) || continue + fix_fv(feas.fwd_map[orig_bv], val) + end +end +function _unfix_feas_combo_binaries(feas::_LOAFeasSubmodel, model, combo) + for (ind, _) in combo + orig_bv = _indicator_to_binary(model)[ind] + haskey(feas.fwd_map, orig_bv) || continue + unfix_fv(feas.fwd_map[orig_bv]) + end +end + +#fix/unfix a feas-submodel binary variable at a scalar Bool value +fix_fv(bv, val::Bool) = JuMP.fix(bv, val ? 1.0 : 0.0; force = true) +function unfix_fv(bv) + JuMP.is_fixed(bv) && JuMP.unfix(bv) + return +end + +################################################################################ +# REFORM MAP (BigM constraint index) +################################################################################ +function _build_reform_map(model::M) where {M <: JuMP.AbstractModel} + ref_cons = _reformulation_constraints(model) + isempty(ref_cons) && return Dict{DisjunctConstraintRef{M}, Vector{Any}}() + CRT = eltype(ref_cons) + rmap = Dict{DisjunctConstraintRef{M}, Vector{CRT}}() + idx = 1 + for (_, disj_data) in _disjunctions(model) + disj_data.constraint.nested && continue + for ind in disj_data.constraint.indicators + haskey(_indicator_to_constraints(model), ind) || continue + for cref in _indicator_to_constraints(model)[ind] + cref isa DisjunctConstraintRef || continue + con = _disjunct_constraints(model)[JuMP.index(cref)].constraint + n = _num_reform_cons(con) + idx + n - 1 <= length(ref_cons) || break + rmap[cref] = collect(ref_cons[idx:idx + n - 1]) + idx += n + end + end + end + return rmap +end + +_num_reform_cons(::JuMP.ScalarConstraint{T, S}) where { + T <: Union{Number, JuMP.AbstractJuMPScalar}, + S <: Union{_MOI.EqualTo, _MOI.Interval}} = 2 +_num_reform_cons(::JuMP.ScalarConstraint) = 1 +_num_reform_cons(con::JuMP.VectorConstraint{T, S}) where { + T <: Union{Number, JuMP.AbstractJuMPScalar}, + S <: _MOI.Zeros} = 2 * _MOI.dimension(con.set) +_num_reform_cons(con::JuMP.VectorConstraint) = _MOI.dimension(con.set) + +function _sum_duals(reform_map, cref) + haskey(reform_map, cref) || return 0.0 + rcs = reform_map[cref] + isempty(rcs) && return 0.0 + total = JuMP.dual(rcs[1]) + for i in 2:length(rcs) + total = total .+ JuMP.dual(rcs[i]) + end + return total +end + +################################################################################ +# COMBO EXTRACTION + CUTS +################################################################################ +#extract combo from master solution. `combo_val` dispatches on scalar vs +#array bin_map values (extension adds the array method for per-support). +function _extract_combo( + model::M, master::_LOAMaster + ) where {M <: JuMP.AbstractModel} + combo = Dict{LogicalVariableRef{M}, Any}() + for (_, disj_data) in _disjunctions(model) + disj_data.constraint.nested && continue + for ind in disj_data.constraint.indicators + haskey(master.bin_map, ind) || continue + combo[ind] = combo_val(master.bin_map[ind]) + end + end + return combo +end +combo_val(bv) = round(JuMP.value(bv)) > 0.5 + +#no-good cut on the master excluding the current combo. `add_ng_terms` +#dispatches on scalar vs array bin_map / active values. +function _add_no_good_cut(model, master::_LOAMaster, combo) + cut_expr = JuMP.AffExpr(0.0) + for (ind, active) in combo + haskey(master.bin_map, ind) || continue + add_ng_terms(cut_expr, master.bin_map[ind], active) + end + JuMP.@constraint(master.model, cut_expr >= 1.0) +end +function add_ng_terms(cut, bv, active::Bool) + if active + JuMP.add_to_expression!(cut, -1.0, bv) + JuMP.add_to_expression!(cut, 1.0) + else + JuMP.add_to_expression!(cut, 1.0, bv) + end + return +end + +#predicate used by OA cut generation: is there any active indicator in +#this combo entry? Scalar Bool case; extension adds `AbstractVector{Bool}` +any_active(active::Bool) = active + +################################################################################ +# LINEARIZATION HELPERS +################################################################################ +function _linearize_at(var::JuMP.AbstractVariableRef, xk, ref_map) + return JuMP.AffExpr(0.0, ref_map[var] => 1.0) +end +function _linearize_at(func::JuMP.GenericAffExpr, xk, ref_map) + result = JuMP.AffExpr(func.constant) + for (var, coef) in func.terms + JuMP.add_to_expression!(result, coef, ref_map[var]) + end + return result +end +################################################################################ +# SLACK HELPERS +################################################################################ +_slacken(f, set::_MOI.LessThan, u) = [(f - u, set)] +_slacken(f, set::_MOI.GreaterThan, u) = [(f + u, set)] +function _slacken(f, set::_MOI.EqualTo, u) + b = _MOI.constant(set) + return [(f - u, _MOI.LessThan(b)), (f + u, _MOI.GreaterThan(b))] +end +_slacken(f, set, u) = [(f, set)] + +################################################################################ +# OA CUT GENERATION +################################################################################ +#sense-dependent coefficients dispatched on Val(obj_sense). See usage in +#`_add_objective_oa_cut` and `_add_disjunct_oa_cuts`. +_disjunct_cut_coeffs(::Val{_MOI.MIN_SENSE}) = (-1, 1) +_disjunct_cut_coeffs(::Val{_MOI.MAX_SENSE}) = (1, -1) +_worst_obj(::Val{_MOI.MIN_SENSE}) = Inf +_worst_obj(::Val{_MOI.MAX_SENSE}) = -Inf +_is_better(::Val{_MOI.MIN_SENSE}, new, best) = new < best +_is_better(::Val{_MOI.MAX_SENSE}, new, best) = new > best +_gap(::Val{_MOI.MIN_SENSE}, best, bound) = best - bound +_gap(::Val{_MOI.MAX_SENSE}, best, bound) = bound - best +_flip_sense(::Val{_MOI.MIN_SENSE}) = Val(_MOI.MAX_SENSE) +_flip_sense(::Val{_MOI.MAX_SENSE}) = Val(_MOI.MIN_SENSE) + +function _add_oa_cuts(model, master::_LOAMaster, result, method::LOA) + isempty(result.x_values) && return + _add_objective_oa_cut(master, result) + _add_disjunct_oa_cuts(model, master, result, method) + return +end + +#linearize the original objective at `result.obj_x_values` and add it as +#a cut on `alpha_oa` with direction determined by `master.obj_sense` +function _add_objective_oa_cut(master::_LOAMaster, result) + lin = _linearize_at( + master.orig_obj, result.obj_x_values, master.obj_ref_map) + _add_obj_cut(Val(master.obj_sense), master, lin) + return +end +_add_obj_cut(::Val{_MOI.MIN_SENSE}, master, lin) = + JuMP.@constraint(master.model, lin <= master.alpha_oa) +_add_obj_cut(::Val{_MOI.MAX_SENSE}, master, lin) = + JuMP.@constraint(master.model, lin >= master.alpha_oa) + +#add V&G 1990 augmented-penalty OA cuts for each active disjunct's +#nonlinear constraints. `cut_sites` yields the per-cut tuples: 1 site +#for finite, K sites for the InfiniteOpt case (extension override). +function _add_disjunct_oa_cuts( + model, master::_LOAMaster, result, method::LOA + ) + sgn, pen = _disjunct_cut_coeffs(Val(master.obj_sense)) + for (ind, active) in result.combo + any_active(active) || continue + haskey(master.bin_map, ind) || continue + haskey(_indicator_to_constraints(model), ind) || continue + for orig_cref in _indicator_to_constraints(model)[ind] + orig_cref isa DisjunctConstraintRef || continue + con = _disjunct_constraints(model)[ + JuMP.index(orig_cref)].constraint + con.func isa JuMP.GenericAffExpr && continue + d = get(result.duals, orig_cref, nothing) + d === nothing && continue + for (bv, xk, smap, d_k) in cut_sites( + master.bin_map[ind], active, + result.x_values, master.var_map, d) + dv = _dv(d_k) + s = sign(sgn * dv) + s == 0 && continue + _add_one_disjunct_oa_cut( + master, con, method, bv, xk, smap, s, pen) + end + end + end +end + +#one OA cut site: (binary, x_values, var_map, dual). Scalar case yields +#a single-element tuple collection; extension's array dispatch yields +#K per-support sites with sliced x_values and var_map. +cut_sites(bv, active::Bool, x_values, var_map, d) = + ((bv, x_values, var_map, d),) + +#extract a scalar dual value from a dual "cell" that may be a vector +#(from a vector-valued reform constraint in the infinite case) +_dv(d::Number) = d +_dv(d) = sum(d) + +#add a single disjunct OA cut with augmented-penalty slack +function _add_one_disjunct_oa_cut( + master::_LOAMaster, con, method, bv, x_values, var_map, s, pen + ) + lin_expr = _linearize_at(con.func, x_values, var_map) + rhs = _set_rhs(con.set) + slack = JuMP.@variable(master.model, + lower_bound = 0.0, upper_bound = method.max_slack) + JuMP.set_objective_function(master.model, + JuMP.objective_function(master.model) + + pen * method.OA_penalty_factor * slack) + JuMP.@constraint(master.model, + s * (lin_expr - rhs) - slack <= + method.M_value * (1 - bv)) + return +end + +################################################################################ +# CONVERGENCE CHECK +################################################################################ +function _loa_converged(best_obj, master_bound, sense_token, method::LOA) + (isinf(best_obj) || isinf(master_bound)) && return false + gap = _gap(sense_token, best_obj, master_bound) + gap <= method.atol && return true + abs(best_obj) > 1e-10 && gap / abs(best_obj) <= method.rtol && return true + return false +end +_loa_converged(z_upper, z_lower, method::LOA) = + _loa_converged(z_upper, z_lower, Val(_MOI.MIN_SENSE), method) + +################################################################################ +# ERROR FALLBACK +################################################################################ +function reformulate_model(::M, ::LOA) where {M} + error("reformulate_model not implemented for model type `$(M)` with LOA.") +end diff --git a/src/utilities.jl b/src/utilities.jl index bca1b748..431c539a 100644 --- a/src/utilities.jl +++ b/src/utilities.jl @@ -401,3 +401,85 @@ function _remap_constraint_to_indicator( ) where {M <: JuMP.AbstractModel} return disj_map[con_ref] end +################################################################################ +# LINEARIZATION & EXPRESSION CONVERSION +################################################################################ +# First-order Taylor approximation and MOI expression building +# for outer approximation methods (LOA, future OA variants). +################################################################################ + +################################################################################ +# MOI NONLINEAR EXPRESSION CONVERSION +################################################################################ +# Convert JuMP expression trees to Julia Expr with +# MOI.VariableIndex leaves for MOI.Nonlinear evaluation. +function _to_nlp_expr(expr::JuMP.GenericNonlinearExpr, idx::Dict) + args = Any[_to_nlp_expr(a, idx) for a in expr.args] + return Expr(:call, expr.head, args...) +end +function _to_nlp_expr(expr::JuMP.GenericAffExpr, idx::Dict) + parts = Any[expr.constant] + for (var, coef) in expr.terms + push!(parts, Expr(:call, :*, coef, _MOI.VariableIndex(idx[var]))) + end + length(parts) == 1 && return parts[1] + return Expr(:call, :+, parts...) +end +function _to_nlp_expr(expr::JuMP.GenericQuadExpr, idx::Dict) + parts = Any[_to_nlp_expr(expr.aff, idx)] + for (pair, coef) in expr.terms + push!(parts, Expr(:call, :*, coef, + _MOI.VariableIndex(idx[pair.a]), + _MOI.VariableIndex(idx[pair.b]))) + end + length(parts) == 1 && return parts[1] + return Expr(:call, :+, parts...) +end +function _to_nlp_expr(var::JuMP.AbstractVariableRef, idx::Dict) + return _MOI.VariableIndex(idx[var]) +end +_to_nlp_expr(x::Number, ::Dict) = x + +# First-order Taylor linearization of a quadratic or nonlinear +# expression at point xk via MOI.Nonlinear reverse-mode AD. +function _linearize_at( + func::Union{JuMP.GenericQuadExpr, JuMP.GenericNonlinearExpr}, + xk::Dict, ref_map + ) + vars = JuMP.AbstractVariableRef[] + _interrogate_variables(v -> push!(vars, v), func) + unique!(vars) + isempty(vars) && return JuMP.AffExpr(JuMP.value(v -> 0.0, func)) + + n = length(vars) + T = JuMP.value_type(typeof(JuMP.owner_model(vars[1]))) + idx = Dict(vars[i] => i for i in 1:n) + nlp = _MOI.Nonlinear.Model() + _MOI.Nonlinear.set_objective(nlp, _to_nlp_expr(func, idx)) + ord = [_MOI.VariableIndex(i) for i in 1:n] + evaluator = _MOI.Nonlinear.Evaluator( + nlp, _MOI.Nonlinear.SparseReverseMode(), ord) + _MOI.initialize(evaluator, [:Grad]) + + xk_vec = [get(xk, v, zero(T)) for v in vars] + f_xk = _MOI.eval_objective(evaluator, xk_vec) + grad = zeros(T, n) + _MOI.eval_objective_gradient(evaluator, grad, xk_vec) + + constant = T(f_xk) + for i in 1:n + constant -= grad[i] * xk_vec[i] + end + V = typeof(ref_map[vars[1]]) + result = JuMP.GenericAffExpr{T, V}(constant) + for i in 1:n + iszero(grad[i]) && continue + JuMP.add_to_expression!(result, grad[i], ref_map[vars[i]]) + end + return result +end + +# Extract RHS from an MOI set. +_set_rhs(s::Union{_MOI.LessThan, _MOI.GreaterThan, _MOI.EqualTo}) = + _MOI.constant(s) +_set_rhs(::Any) = 0.0 diff --git a/test/constraints/loa.jl b/test/constraints/loa.jl new file mode 100644 index 00000000..3b92cd5b --- /dev/null +++ b/test/constraints/loa.jl @@ -0,0 +1,225 @@ +using HiGHS + +function test_loa_datatype() + method = LOA(HiGHS.Optimizer) + @test method.nlp_optimizer == HiGHS.Optimizer + @test method.mip_optimizer == HiGHS.Optimizer + @test method.max_iter == 10 + @test method.atol == 1e-6 + @test method.rtol == 1e-4 + @test method.M_value == 1e9 + @test method.max_slack == 1000.0 + @test method.OA_penalty_factor == 1000.0 + + method = LOA(HiGHS.Optimizer; max_iter = 50, atol = 1e-8, + rtol = 1e-6, M_value = 1e6, max_slack = 500.0, + OA_penalty_factor = 200.0) + @test method.max_iter == 50 + @test method.atol == 1e-8 + @test method.rtol == 1e-6 + @test method.M_value == 1e6 + @test method.max_slack == 500.0 + @test method.OA_penalty_factor == 200.0 +end + +function test_set_covering_combos() + model = GDPModel() + @variable(model, x) + @variable(model, Y[1:2], Logical) + @constraint(model, x <= 3, Disjunct(Y[1])) + @constraint(model, x >= 5, Disjunct(Y[2])) + @disjunction(model, Y) + + combos = DP._set_covering_combos(model) + + # Should cover both Y[1] and Y[2] + all_active = Set() + for combo in combos + for (ind, active) in combo + active && push!(all_active, ind) + end + end + @test length(all_active) == 2 +end + +function test_no_good_cut() + model = GDPModel() + @variable(model, x) + @variable(model, Y[1:2], Logical) + @constraint(model, x <= 3, Disjunct(Y[1])) + @constraint(model, x >= 5, Disjunct(Y[2])) + @disjunction(model, Y) + + DP.reformulate_model(model, BigM(1e9)) + master = DP._build_loa_master( + model, LOA(HiGHS.Optimizer)) + master_model = master.model + + combo = Dict(Y[1] => true, Y[2] => false) + + num_cons_before = length(JuMP.all_constraints( + master_model; + include_variable_in_set_constraints = false)) + DP._add_no_good_cut(model, master, combo) + num_cons_after = length(JuMP.all_constraints( + master_model; + include_variable_in_set_constraints = false)) + + @test num_cons_after == num_cons_before + 1 +end + +function test_loa_convergence_check() + method = LOA(HiGHS.Optimizer; atol = 1e-6, rtol = 1e-4) + + @test DP._loa_converged(1.0, 1.0, method) == true + @test DP._loa_converged(1.0, 0.9999, method) == true + @test DP._loa_converged(1.0, 0.5, method) == false + @test DP._loa_converged(1e-8, 0.0, method) == true +end + +function test_loa_reformulate_simple() + model = GDPModel(HiGHS.Optimizer) + @variable(model, 0 <= x <= 10) + @variable(model, Y[1:2], Logical) + @constraint(model, x <= 3, Disjunct(Y[1])) + @constraint(model, x <= 7, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Max, x) + + method = LOA(HiGHS.Optimizer) + DP.reformulate_model(model, method) + + @test DP._ready_to_optimize(model) +end + +function test_loa_solve_simple() + # Simple GDP: max x s.t. (x <= 3) OR (x <= 7), 0 <= x <= 10 + # Optimal: x = 7 (select second disjunct) + model = GDPModel(HiGHS.Optimizer) + set_silent(model) + @variable(model, 0 <= x <= 10) + @variable(model, Y[1:2], Logical) + @constraint(model, x <= 3, Disjunct(Y[1])) + @constraint(model, x <= 7, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Max, x) + + optimize!(model, gdp_method = LOA(HiGHS.Optimizer)) + @test termination_status(model) == MOI.OPTIMAL + @test objective_value(model) ≈ 7.0 atol=1e-4 +end + +function test_loa_solve_two_disjunctions() + # Two disjunctions: max x + z + # D1: (x <= 3) OR (x <= 7) + # D2: (z <= 2) OR (z <= 5) + # Optimal: x = 7, z = 5 + model = GDPModel(HiGHS.Optimizer) + set_silent(model) + @variable(model, 0 <= x <= 10) + @variable(model, 0 <= z <= 10) + @variable(model, Y[1:2], Logical) + @variable(model, W[1:2], Logical) + @constraint(model, x <= 3, Disjunct(Y[1])) + @constraint(model, x <= 7, Disjunct(Y[2])) + @disjunction(model, Y) + @constraint(model, z <= 2, Disjunct(W[1])) + @constraint(model, z <= 5, Disjunct(W[2])) + @disjunction(model, W) + @objective(model, Max, x + z) + + optimize!(model, gdp_method = LOA(HiGHS.Optimizer)) + @test termination_status(model) == MOI.OPTIMAL + @test objective_value(model) ≈ 12.0 atol=1e-4 +end + +function test_loa_error_fallback() + method = LOA(HiGHS.Optimizer) + @test_throws ErrorException DP.reformulate_model(42, method) +end + +function test_linearize_nonlinear_exp() + # exp(x) + y at (1, 2): + # f = e + 2, ∇f = [e, 1] + # linear: e*(x-1) + 1*(y-2) + (e+2) = e*x + y + model = GDPModel() + @variable(model, x) + @variable(model, y) + func = @expression(model, exp(x) + y) + xk = Dict{JuMP.AbstractVariableRef, Float64}( + x => 1.0, y => 2.0) + id_map = Dict(x => x, y => y) + lin = DP._linearize_at(func, xk, id_map) + @test JuMP.constant(lin) ≈ 0.0 atol = 1e-8 + @test JuMP.coefficient(lin, x) ≈ exp(1.0) atol = 1e-8 + @test JuMP.coefficient(lin, y) ≈ 1.0 atol = 1e-8 +end + +function test_linearize_nonlinear_sin() + # sin(x) at x = π/6: + # f = 0.5, f' = cos(π/6) = √3/2 + # linear: 0.5 + (√3/2)(x - π/6) + model = GDPModel() + @variable(model, x) + func = @expression(model, sin(x)) + xk = Dict{JuMP.AbstractVariableRef, Float64}( + x => π / 6) + id_map = Dict(x => x) + lin = DP._linearize_at(func, xk, id_map) + expected_const = 0.5 - (√3 / 2) * (π / 6) + @test JuMP.constant(lin) ≈ expected_const atol = 1e-8 + @test JuMP.coefficient(lin, x) ≈ √3 / 2 atol = 1e-8 +end + +function test_linearize_nonlinear_multivar() + # exp(x) * sin(y) at (1, π/2): + # f = e*1 = e, ∂f/∂x = e*sin(π/2) = e, ∂f/∂y = e*cos(π/2) = 0 + # linear: e + e*(x-1) + 0*(y-π/2) = e*x + model = GDPModel() + @variable(model, x) + @variable(model, y) + func = @expression(model, exp(x) * sin(y)) + xk = Dict{JuMP.AbstractVariableRef, Float64}( + x => 1.0, y => π / 2) + id_map = Dict(x => x, y => y) + lin = DP._linearize_at(func, xk, id_map) + @test JuMP.constant(lin) ≈ 0.0 atol = 1e-8 + @test JuMP.coefficient(lin, x) ≈ exp(1.0) atol = 1e-8 + @test JuMP.coefficient(lin, y) ≈ 0.0 atol = 1e-8 +end + +function test_to_nlp_expr() + model = GDPModel() + @variable(model, x) + @variable(model, y) + idx = Dict(x => 1, y => 2) + + # NonlinearExpr + nl = @expression(model, exp(x)) + e = DP._to_nlp_expr(nl, idx) + @test e == Expr(:call, :exp, MOI.VariableIndex(1)) + + # AffExpr + aff = @expression(model, 2x + 3y + 1) + e = DP._to_nlp_expr(aff, idx) + @test e isa Expr + @test e.head == :call && e.args[1] == :+ + + # Number + @test DP._to_nlp_expr(42, idx) == 42 +end + +@testset "LOA" begin + test_loa_datatype() + test_set_covering_combos() + test_no_good_cut() + test_loa_convergence_check() + test_loa_reformulate_simple() + test_loa_solve_simple() + test_loa_solve_two_disjunctions() + test_loa_error_fallback() + test_linearize_nonlinear_exp() + test_linearize_nonlinear_sin() + test_linearize_nonlinear_multivar() + test_to_nlp_expr() +end diff --git a/test/runtests.jl b/test/runtests.jl index 06e8813d..ca34f816 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -18,6 +18,7 @@ include("constraints/mbm.jl") include("constraints/bigm.jl") include("constraints/psplit.jl") include("constraints/cuttingplanes.jl") +include("constraints/loa.jl") include("constraints/hull.jl") include("constraints/fallback.jl") include("constraints/disjunction.jl") diff --git a/test/solve.jl b/test/solve.jl index c3ca9441..c53cf04a 100644 --- a/test/solve.jl +++ b/test/solve.jl @@ -62,7 +62,15 @@ function test_linear_gdp_example(m, use_complements = false) @test value(Y[2]) @test !value(W[1]) @test !value(W[2]) - + + @test optimize!(m, gdp_method = LOA(HiGHS.Optimizer)) isa Nothing + @test termination_status(m) == MOI.OPTIMAL + @test objective_value(m) ≈ 11 atol=1e-3 + @test value.(x) ≈ [9,2] atol=1e-3 + @test !value(Y[1]) + @test value(Y[2]) + @test !value(W[1]) + @test !value(W[2]) m_copy, ref_map = JuMP.copy_model(m) lv_map = DP.copy_gdp_data(m, m_copy, ref_map) @@ -130,11 +138,20 @@ function test_quadratic_gdp_example(use_complements = false) #psplit does not wo @test optimize!(m, gdp_method = PSplit(2,m)) isa Nothing @test termination_status(m) in [MOI.OPTIMAL, MOI.LOCALLY_SOLVED] - @test objective_value(m) ≈ 6.1237 atol=1e-3 - @test value.(x) ≈ [4.0825, 2.0412] atol=1e-3 - @test !value(Y[1]) + @test objective_value(m) ≈ 6.1237 atol=1e-3 + @test value.(x) ≈ [4.0825, 2.0412] atol=1e-3 + @test !value(Y[1]) @test value(Y[2]) - @test !value(W[1]) + @test !value(W[1]) + @test !value(W[2]) + + @test optimize!(m, gdp_method = LOA(optimizer)) isa Nothing + @test termination_status(m) in [MOI.OPTIMAL, MOI.LOCALLY_SOLVED] + @test objective_value(m) ≈ 6.1237 atol=1e-3 + @test value.(x) ≈ [4.0825, 2.0412] atol=1e-3 + @test !value(Y[1]) + @test value(Y[2]) + @test !value(W[1]) @test !value(W[2]) end From 025140b3da633c745f664a194c5a74d9311762b6 Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Fri, 24 Apr 2026 13:39:53 -0400 Subject: [PATCH 33/59] . Co-Authored-By: Claude Opus 4.7 (1M context) --- ext/InfiniteDisjunctiveProgramming.jl | 50 +-- src/cuttingplanes.jl | 11 - src/datatypes.jl | 23 +- src/loa.jl | 489 ++++++++++++++------------ src/utilities.jl | 13 + test/constraints/loa.jl | 2 +- 6 files changed, 310 insertions(+), 278 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 20fca5a5..a72ede75 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -435,9 +435,9 @@ end #OA cut sites: one site per active support, with per-support restrictions #of `x_values` and `var_map` -DP.cut_sites(bvs::AbstractArray, active::Bool, x_values, var_map, d) = - DP.cut_sites(bvs, fill(active, length(bvs)), x_values, var_map, d) -function DP.cut_sites( +DP.cut_info(bvs::AbstractArray, active::Bool, x_values, var_map, d) = + DP.cut_info(bvs, fill(active, length(bvs)), x_values, var_map, d) +function DP.cut_info( bvs::AbstractArray, actives::AbstractArray, x_values, var_map, d ) @@ -490,8 +490,9 @@ function DP.build_loa_master(model::InfiniteOpt.InfiniteModel, method::DP.LOA) obj_sense = JuMP.objective_sense(master) alpha_oa = JuMP.@variable(master, base_name = "alpha_oa") JuMP.@objective(master, obj_sense, alpha_oa) - m = DP._LOAMaster(master, bin_map, var_map, obj_sense, orig_obj, - alpha_oa, flat_copy_map) + m = (model = master, bin_map = bin_map, var_map = var_map, + obj_sense = obj_sense, orig_obj = orig_obj, + alpha_oa = alpha_oa, obj_ref_map = flat_copy_map) master.ext[:_loa_K] = _detect_K(bin_map) master.ext[:_loa_flat_copy_map] = flat_copy_map return m @@ -530,20 +531,30 @@ function DP.unfix_combo_binaries(model::InfiniteOpt.InfiniteModel, combo) end #Transcribe the BigM'd InfiniteModel, then hand off to the base -#`copy_model_with_constraints` on the flat model. fwd_map is rekeyed to -#InfiniteModel vars for use by `_extract_*_x_values`; obj_ref_map stays -#keyed by flat vars (the flat-level objective OA cut lives there). +#`copy_model_with_constraints` on the flat model. `disjunct_crefs` +#are InfiniteModel-level refs; for the InfiniteOpt path we pass an +#empty Vector and the base includes every non-bound flat constraint +#(flat has no GDPData so no reformulation-constraint set to skip), +#giving us the whole transcribed+BigM'd model. Per-combo flat pruning +#is deferred. fwd_map is rekeyed to InfiniteModel vars for use by +#`read_*_solution`; obj_ref_map stays keyed by flat vars (the +#flat-level objective OA cut lives there). function DP.copy_model_with_constraints( - model::InfiniteOpt.InfiniteModel, method::DP.LOA + model::InfiniteOpt.InfiniteModel, + disjunct_crefs::Vector{<:DP.DisjunctConstraintRef}, + method::DP.LOA ) InfiniteOpt.build_transformation_backend!(model) flat = InfiniteOpt.transformation_model(model) - base = DP.copy_model_with_constraints(flat, method) + flat_crefs = DP.DisjunctConstraintRef{typeof(flat)}[] + base = DP.copy_model_with_constraints(flat, flat_crefs, method) fwd_map = Dict{InfiniteOpt.GeneralVariableRef, Any}() - for v in DP.collect_all_vars(model) - fwd_map[v] = _tv_map(v, base.fwd_map) + decision_vars = DP.collect_all_vars(model) + for v in decision_vars + fwd_map[v] = _tv_map(v, base.sub.fwd_map) end - return DP._LOAFeasSubmodel(base.model, fwd_map, base.fwd_map) + return (sub = DP.GDPSubmodel(base.sub.model, decision_vars, fwd_map), + obj_ref_map = base.sub.fwd_map) end #read per-support JuMP values from either a vector of flat vars or a @@ -554,7 +565,7 @@ _read_values(v) = Float64(JuMP.value(v)) #extract per-support x-values from the InfiniteModel NLP; objective #x-values are keyed by the flat transcription vars for the objective cut -function DP.extract_primary_x_values(model::InfiniteOpt.InfiniteModel) +function DP.read_primary_solution(model::InfiniteOpt.InfiniteModel) x_vals = Dict{JuMP.AbstractVariableRef, Any}() for v in DP.collect_all_vars(model) JuMP.is_fixed(v) && continue @@ -565,15 +576,10 @@ end #extract per-support x-values from the flat feas submodel. x_vals keys are #InfiniteModel vars (via fwd_map); obj_x_values keys are flat vars. -function DP.extract_feas_x_values( - model::InfiniteOpt.InfiniteModel, feas::DP._LOAFeasSubmodel +function DP.read_feas_solution( + model::InfiniteOpt.InfiniteModel, feas ) - x_vals = Dict{JuMP.AbstractVariableRef, Any}() - for v in DP.collect_all_vars(model) - JuMP.is_fixed(v) && continue - haskey(feas.fwd_map, v) || continue - x_vals[v] = _read_values(feas.fwd_map[v]) - end + x_vals = DP.extract_solution(feas.sub) obj_xv = Dict{JuMP.VariableRef, Float64}() for (flat_v, feas_v) in feas.obj_ref_map obj_xv[flat_v] = Float64(JuMP.value(feas_v)) diff --git a/src/cuttingplanes.jl b/src/cuttingplanes.jl index dbc818e5..ddb3710d 100644 --- a/src/cuttingplanes.jl +++ b/src/cuttingplanes.jl @@ -18,17 +18,6 @@ function extract_solution(model::JuMP.AbstractModel) v => [JuMP.value(v)] for v in dvars) end -# Extract solution from a GDPSubmodel (SEP path). -function extract_solution(sub::GDPSubmodel) - V = eltype(sub.decision_vars) - T = JuMP.value_type(typeof(sub.model)) - sol = Dict{V, Vector{T}}() - for var in sub.decision_vars - sol[var] = JuMP.value.(sub.fwd_map[var]) - end - return sol -end - # Set quadratic separation objective: min Σ (x_k - rBM_k)². function _set_separation_objective( sub::GDPSubmodel, diff --git a/src/datatypes.jl b/src/datatypes.jl index bdcd4c2e..6abf23c0 100644 --- a/src/datatypes.jl +++ b/src/datatypes.jl @@ -473,25 +473,24 @@ end """ GDPSubmodel{M, V, W} -A unified submodel wrapper used by MBM and cutting plane -reformulations. It encapsulates a flat JuMP optimization -submodel built from a single disjunct's feasible region, -along with mappings back to the original model's variables. +A JuMP submodel paired with the parent model's decision variables +and a map from each parent variable to its submodel representation. ## Fields -- `model::M`: The JuMP submodel representing a disjunct's - feasible region (constraints and variable bounds). -- `decision_vars::Vector{V}`: Ordered decision variables in - the submodel, matching the original model's ordering. -- `fwd_map::Dict{V, Vector{W}}`: Forward map from original - model variables to their submodel counterparts. +- `model::M`: The JuMP submodel. +- `decision_vars::Vector{V}`: Parent-model decision variables in + the order used by the submodel. +- `fwd_map::Dict{V, W}`: Map from each parent-model variable to + its submodel counterpart. `W` can be a single variable + reference (1:1 copy) or a collection of references (e.g. one + per transcription support). """ struct GDPSubmodel{M <: JuMP.AbstractModel, V <: JuMP.AbstractVariableRef, - W <: JuMP.AbstractVariableRef} + W} model::M decision_vars::Vector{V} - fwd_map::Dict{V, Vector{W}} + fwd_map::Dict{V, W} end """ diff --git a/src/loa.jl b/src/loa.jl index 3f750fb3..81cbd3e9 100644 --- a/src/loa.jl +++ b/src/loa.jl @@ -34,40 +34,6 @@ struct LOA{O, P} <: AbstractReformulationMethod end end -################################################################################ -# DATA STRUCTURES -################################################################################ -# Master MILP state. orig_obj is the original objective (for OA cuts); -# alpha_oa replaces it (Türkay & Grossmann 1996). obj_ref_map maps the -# variables in orig_obj to master variables. -mutable struct _LOAMaster{M <: JuMP.AbstractModel, B, V} - model::M - bin_map::B - var_map::V - obj_sense::_MOI.OptimizationSense - orig_obj::Any - alpha_oa::Any - obj_ref_map::Any -end - -# Feasibility-restoration submodel (Viswanathan & Grossmann 1990, NLPF). -# Standalone model with a shared scalar slack `u` embedded into every JuMP -# constraint and `min u` as the objective. When the primary NLP is -# infeasible, binaries are fixed here and the submodel is solved to -# produce a least-infeasible point for OA cut generation. Keeping it -# separate leaves the original NLP model clean (no `u`, no slackened -# constraints). -# -# fwd_map: original-model var -> feas var, used for binary fixing and -# value extraction. -# obj_ref_map: original-objective var -> feas var, used to linearize the -# original objective at the feas point. -struct _LOAFeasSubmodel{M <: JuMP.AbstractModel} - model::M - fwd_map::Any - obj_ref_map::Any -end - ################################################################################ # MAIN ALGORITHM ################################################################################ @@ -76,16 +42,8 @@ function reformulate_model(model::JuMP.AbstractModel, method::LOA) combos = _set_covering_combos(model) reformulate_model(model, BigM(method.M_value)) - #build the feasibility-restoration submodel as a separate copy, then - #embed the shared scalar slack `u` in every constraint (V&G 1990, - #NLPF). The original model stays clean: no slacks, no integrality - #relaxation, no objective change. - feas = copy_model_with_constraints(model, method) - _embed_feas_slack(feas) - master = build_loa_master(model, method) reform_map = _build_reform_map(model) - JuMP.relax_integrality(model) JuMP.set_optimizer(model, method.nlp_optimizer) JuMP.set_silent(model) @@ -93,8 +51,12 @@ function reformulate_model(model::JuMP.AbstractModel, method::LOA) best_obj = _worst_obj(sense_token) is_better(o) = _is_better(sense_token, o, best_obj) best_result = nothing + + #Initialization Procedure (Türkay & Grossmann 1996 §2.2): solve the + #set-covering NLPs to seed the master with at least one OA cut per + #disjunct before the main iteration. for combo in combos - result = _solve_nlp(model, combo, method, reform_map, feas) + result = _solve_nlp(model, combo, method, reform_map) _add_no_good_cut(model, master, combo) _add_oa_cuts(model, master, result, method) if result.feasible && is_better(result.objective) @@ -110,7 +72,7 @@ function reformulate_model(model::JuMP.AbstractModel, method::LOA) master_bound = JuMP.objective_value(master.model) _loa_converged(best_obj, master_bound, sense_token, method) && break combo = _extract_combo(model, master) - result = _solve_nlp(model, combo, method, reform_map, feas) + result = _solve_nlp(model, combo, method, reform_map) _add_no_good_cut(model, master, combo) _add_oa_cuts(model, master, result, method) if result.feasible && is_better(result.objective) @@ -120,6 +82,8 @@ function reformulate_model(model::JuMP.AbstractModel, method::LOA) end _finalize_model(model, best_result) + _set_solution_method(model, method) + _set_ready_to_optimize(model, true) return end @@ -137,7 +101,20 @@ end ################################################################################ # EXTENSION POINTS ################################################################################ -#build master MILP from the BigM-reformulated original +""" + build_loa_master(model::JuMP.AbstractModel, method::LOA) + +Build the LOA master MILP as a deep copy of the BigM-reformulated +`model`, install the `alpha_oa` objective auxiliary, and wire up +`method.mip_optimizer`. OA and no-good cuts are added to the returned +master each iteration of the main LOA loop. + +## Returns +- `NamedTuple` with `model` (master MILP), `bin_map` + (indicator→binary), `var_map` (original→master var), + `obj_sense`, `orig_obj`, `alpha_oa`, and `obj_ref_map` + (objective-side map for linearization). +""" function build_loa_master(model::JuMP.AbstractModel, method::LOA) orig_obj = JuMP.objective_function(model) master, copy_map = JuMP.copy_model(model) @@ -151,101 +128,132 @@ function build_loa_master(model::JuMP.AbstractModel, method::LOA) obj_sense = JuMP.objective_sense(master) alpha_oa = JuMP.@variable(master, base_name = "alpha_oa") JuMP.@objective(master, obj_sense, alpha_oa) - return _LOAMaster( - master, bin_map, copy_map, obj_sense, orig_obj, alpha_oa, copy_map) + return (model = master, bin_map = bin_map, var_map = copy_map, + obj_sense = obj_sense, orig_obj = orig_obj, + alpha_oa = alpha_oa, obj_ref_map = copy_map) end """ - copy_model_with_constraints(model, method::LOA) - -Build an LOA feas submodel: a fresh deep copy of `model` with every -non-bound constraint copied over, wired up with `method.nlp_optimizer`. -The returned submodel is a raw copy; slack embedding and the `min u` -objective are applied separately via [`_embed_feas_slack`]. Shares the -dispatch entry point with MBM's [`copy_model_with_constraints`](@ref), -which instead takes an explicit subset of disjunct constraints. + copy_model_with_constraints( + model::JuMP.AbstractModel, + disjunct_crefs::Vector{<:DisjunctConstraintRef}, + method::LOA + ) + +Build a raw feasibility-restoration submodel by deep-copying `model`'s +decision variables, then including only the problem's global +constraints (those not added by BigM reformulation) and the original +pre-BigM constraints of `disjunct_crefs`. The shared slack `u` and +`min u` objective are applied separately by [`_embed_feas_slack`](@ref). +Mirrors MBM's subset-taking [`copy_model_with_constraints`](@ref). + +## Returns +- `NamedTuple` with `sub::GDPSubmodel` (copied model and + original→copy forward map) and `obj_ref_map` (objective-side + reference map for linearizing the original objective at the feas + point). """ function copy_model_with_constraints( - model::JuMP.AbstractModel, method::LOA + model::JuMP.AbstractModel, + disjunct_crefs::Vector{<:DisjunctConstraintRef}, + method::LOA ) - var_type = JuMP.variable_ref_type(model) + V = JuMP.variable_ref_type(model) sub_model = _copy_model(model) - fwd_map = Dict{var_type, var_type}() - for var in collect_all_vars(model) + decision_vars = collect_all_vars(model) + fwd_map = Dict{V, V}() + for var in decision_vars fwd_map[var] = variable_copy(sub_model, var) end VT = JuMP.variable_ref_type(typeof(model)) + reform_set = is_gdp_model(model) ? + Set(_reformulation_constraints(model)) : Set() for (F, S) in JuMP.list_of_constraint_types(model) F === VT && continue for cref in JuMP.all_constraints(model, F, S) + cref in reform_set && continue con = JuMP.constraint_object(cref) expr = _replace_variables_in_constraint(con.func, fwd_map) JuMP.@constraint(sub_model, expr in con.set) end end + for cref in disjunct_crefs + con = JuMP.constraint_object(cref) + expr = _replace_variables_in_constraint(con.func, fwd_map) + JuMP.@constraint(sub_model, expr in con.set) + end JuMP.set_optimizer(sub_model, method.nlp_optimizer) JuMP.set_silent(sub_model) - return _LOAFeasSubmodel(sub_model, fwd_map, fwd_map) + return (sub = GDPSubmodel(sub_model, decision_vars, fwd_map), + obj_ref_map = fwd_map) end -#embed shared scalar slack `u` into every constraint of the feas -#submodel, relax integrality, and set `min u` as the objective. Converts -#the raw copy produced by `copy_model_with_constraints` into a V&G 1990 -#NLPF feasibility-restoration problem. -function _embed_feas_slack(feas::_LOAFeasSubmodel) - u = JuMP.@variable(feas.model, base_name = "_loa_u", lower_bound = 0.0) - _slacken_model_constraints(feas.model, u) - JuMP.relax_integrality(feas.model) - JuMP.@objective(feas.model, Min, u) - return -end - -#replace every JuMP constraint in `model` (excluding variable bounds) with -#its slackened counterpart via `_slacken`. Shared by finite and InfiniteOpt -#feas submodel builders. -function _slacken_model_constraints(model, u) - VT = JuMP.variable_ref_type(typeof(model)) +# Convert the raw copy from `copy_model_with_constraints` into a V&G 1990 +# NLPF problem: shared slack `u` embedded in every constraint via +# `_slacken`, integrality relaxed, `min u` as the objective. +function _embed_feas_slack(feas) + m = feas.sub.model + u = JuMP.@variable(m, base_name = "_loa_u", lower_bound = 0.0) + VT = JuMP.variable_ref_type(typeof(m)) to_slacken = Any[] - for (F, S) in JuMP.list_of_constraint_types(model) + for (F, S) in JuMP.list_of_constraint_types(m) F === VT && continue - for cref in JuMP.all_constraints(model, F, S) + for cref in JuMP.all_constraints(m, F, S) push!(to_slacken, cref) end end for cref in to_slacken - JuMP.is_valid(model, cref) || continue + JuMP.is_valid(m, cref) || continue con = JuMP.constraint_object(cref) for (sf, ss) in _slacken(con.func, con.set, u) - JuMP.@constraint(model, sf in ss) + JuMP.@constraint(m, sf in ss) end - JuMP.delete(model, cref) + JuMP.delete(m, cref) end + JuMP.relax_integrality(m) + JuMP.@objective(m, Min, u) return end -#fix/unfix indicator binaries on the original model +""" + fix_combo_binaries(model::JuMP.AbstractModel, combo)::Nothing + +Fix every indicator binary in `combo` to its active / inactive value +on `model`. Complement indicators (stored as `1 - other_bv`) are +handled by fixing the underlying variable to the complement value via +[`fix_fv`](@ref). +""" function fix_combo_binaries(model, combo) for (ind, active) in combo - bv = _indicator_to_binary(model)[ind] - JuMP.fix(bv, active ? 1.0 : 0.0; force = true) + fix_fv(_indicator_to_binary(model)[ind], active) end end + +""" + unfix_combo_binaries(model::JuMP.AbstractModel, combo)::Nothing + +Undo the effect of [`fix_combo_binaries`](@ref): unfix every indicator +binary in `combo` on `model`. +""" function unfix_combo_binaries(model, combo) for (ind, _) in combo - bv = _indicator_to_binary(model)[ind] - JuMP.is_fixed(bv) && JuMP.unfix(bv) + unfix_fv(_indicator_to_binary(model)[ind]) end end ################################################################################ # SET COVERING INITIALIZATION ################################################################################ +#Türkay & Grossmann (1996) §2.2: pick a minimal set of combos that +#activates every indicator at least once, so the master starts with an +#OA cut for each disjunct. We enumerate nested disjunctions alongside +#top-level ones — inconsistent combos (nested active under inactive +#parent) are handled by feasibility restoration at NLP-solve time. function _set_covering_combos(model::JuMP.AbstractModel) M = typeof(model) LVR = LogicalVariableRef{M} per_disj = Vector{Tuple{DisjunctionIndex, LVR}}[] for (idx, disj_data) in _disjunctions(model) - disj_data.constraint.nested && continue push!(per_disj, [(idx, ind) for ind in disj_data.constraint.indicators]) end isempty(per_disj) && return Dict{LVR, Bool}[] @@ -287,78 +295,65 @@ end ################################################################################ # NLP SUBPROBLEM ################################################################################ -# Termination tokens. `_solve_nlp` and `_run_feas_restoration` dispatch on -# these to build the result tuple without inspecting the solver status at -# multiple call sites. -struct _Feasible end -struct _Infeasible end -_nlp_status(model) = JuMP.is_solved_and_feasible(model) ? - _Feasible() : _Infeasible() - -#solve primary NLP for a fixed combo; on infeasibility, dispatch to the -#feasibility-restoration submodel (min u) to get a least-infeasible point -#for OA cut generation (Viswanathan & Grossmann 1990) +# Solve the primary NLP for a fixed combo. If infeasible, build a fresh +# V&G 1990 NLPF submodel (globals + active-disjunct originals, with +# shared slack `u` and `min u` objective), solve it for a least- +# infeasible point to generate OA cuts from, then discard it. Returns +# a named tuple with +# `combo / x_values / duals / objective / feasible / obj_x_values`. function _solve_nlp( - model::M, combo, method::LOA, reform_map, feas::_LOAFeasSubmodel + model::M, combo, method::LOA, reform_map ) where {M <: JuMP.AbstractModel} + DCRef = DisjunctConstraintRef{M} + empty_duals = Dict{DCRef, Any}() fix_combo_binaries(model, combo) JuMP.optimize!(model, ignore_optimize_hook = true) - result = _nlp_primary_result( - _nlp_status(model), model, combo, reform_map, feas) + if JuMP.is_solved_and_feasible(model) + x_vals, obj_xv = read_primary_solution(model) + duals = _collect_nlp_duals(model, combo, reform_map) + obj_val = JuMP.objective_value(model) + unfix_combo_binaries(model, combo) + return (combo = combo, x_values = x_vals, duals = duals, + objective = obj_val, feasible = true, obj_x_values = obj_xv) + end unfix_combo_binaries(model, combo) - return result -end - -#feasible primary NLP: pack up x-values, duals, objective -function _nlp_primary_result( - ::_Feasible, model::M, combo, reform_map, feas - ) where {M <: JuMP.AbstractModel} - x_vals, obj_xv = extract_primary_x_values(model) - duals = _collect_nlp_duals(model, combo, reform_map) - return (combo = combo, x_values = x_vals, duals = duals, - objective = JuMP.objective_value(model), feasible = true, - obj_x_values = obj_xv) -end - -#infeasible primary NLP: hand off to the feas-restoration submodel -function _nlp_primary_result( - ::_Infeasible, model, combo, reform_map, feas - ) - return _run_feas_restoration(model, feas, combo) -end - -#fix binaries on the feas submodel, solve min u, and dispatch on outcome -function _run_feas_restoration( - model::M, feas::_LOAFeasSubmodel, combo - ) where {M <: JuMP.AbstractModel} - _fix_feas_combo_binaries(feas, model, combo) - JuMP.optimize!(feas.model) - result = _nlp_feas_result(_nlp_status(feas.model), model, feas, combo) - _unfix_feas_combo_binaries(feas, model, combo) - return result -end - -#feas submodel solved: return the least-infeasible point for OA cuts -function _nlp_feas_result( - ::_Feasible, model::M, feas, combo - ) where {M <: JuMP.AbstractModel} - x_vals, obj_xv = extract_feas_x_values(model, feas) - return (combo = combo, x_values = x_vals, - duals = Dict{DisjunctConstraintRef{M}, Any}(), - objective = Inf, feasible = false, obj_x_values = obj_xv) -end - -#feas submodel also infeasible: return empty result (no OA cut) -function _nlp_feas_result( - ::_Infeasible, model::M, feas, combo - ) where {M <: JuMP.AbstractModel} - empty = Dict{JuMP.AbstractVariableRef, Any}() - return (combo = combo, x_values = empty, - duals = Dict{DisjunctConstraintRef{M}, Any}(), - objective = Inf, feasible = false, obj_x_values = empty) + # Build a fresh per-combo NLPF: only the active-disjunct originals + # + globals, slackened with a shared `u`. Discarded after solve. + active_crefs = _active_disjunct_crefs(model, combo) + feas = copy_model_with_constraints(model, active_crefs, method) + _embed_feas_slack(feas) + feas_fwd = feas.sub.fwd_map + for (ind, val) in combo + orig_bv = _indicator_to_binary(model)[ind] + haskey(feas_fwd, orig_bv) || continue + fix_fv(feas_fwd[orig_bv], val) + end + JuMP.optimize!(feas.sub.model) + feas_ok = JuMP.is_solved_and_feasible(feas.sub.model) + x_vals = feas_ok ? first(read_feas_solution(model, feas)) : + Dict{JuMP.AbstractVariableRef, Any}() + return (combo = combo, x_values = x_vals, duals = empty_duals, + objective = Inf, feasible = false, obj_x_values = x_vals) +end + +# Collect the `DisjunctConstraintRef`s of every active indicator in +# `combo`. Used to feed `copy_model_with_constraints` the minimal +# per-combo subset for NLPF construction. +function _active_disjunct_crefs(model::M, combo) where {M} + crefs = DisjunctConstraintRef{M}[] + for (ind, active) in combo + any_active(active) || continue + haskey(_indicator_to_constraints(model), ind) || continue + for cref in _indicator_to_constraints(model)[ind] + cref isa DisjunctConstraintRef || continue + push!(crefs, cref) + end + end + return crefs end -#collect duals of reformulated disjunct constraints for active disjuncts +# Sum the duals of BigM-reformulated constraints for each active +# disjunct's original constraint ref. Used for OA cut generation. function _collect_nlp_duals( model::M, combo, reform_map ) where {M <: JuMP.AbstractModel} @@ -375,10 +370,16 @@ function _collect_nlp_duals( return duals end -#extract x-values from the primary NLP. Returns (x_vals, obj_x_values) -#where obj_x_values is keyed by whatever variables the original objective -#uses (same as x_vals for finite; flat-transcription vars for infinite). -function extract_primary_x_values(model::JuMP.AbstractModel) +""" + read_primary_solution(model::JuMP.AbstractModel)::Tuple{Dict, Dict} + +Read the primal solution of the primary NLP after a feasible solve. +Returns `(x_values, obj_x_values)` where both dicts are keyed by +variable reference; `obj_x_values` matches `x_values` for finite +models. The InfiniteOpt extension overrides this to return the +flat-transcription dict as the second element. +""" +function read_primary_solution(model::JuMP.AbstractModel) x_vals = Dict{JuMP.AbstractVariableRef, Any}() for v in JuMP.all_variables(model) JuMP.is_fixed(v) && continue @@ -387,44 +388,52 @@ function extract_primary_x_values(model::JuMP.AbstractModel) return x_vals, x_vals end -#extract x-values from the feasibility submodel, keyed by original-model -#variables via `feas.fwd_map`. Same return contract as the primary path. -function extract_feas_x_values( - model::JuMP.AbstractModel, feas::_LOAFeasSubmodel - ) - x_vals = Dict{JuMP.AbstractVariableRef, Any}() - for v in JuMP.all_variables(model) - JuMP.is_fixed(v) && continue - haskey(feas.fwd_map, v) || continue - x_vals[v] = JuMP.value(feas.fwd_map[v]) - end +""" + read_feas_solution( + model::JuMP.AbstractModel, feas + )::Tuple{Dict, Dict} + +Read the primal solution of the feasibility-restoration NLPF after a +feasible solve, keyed by original-model variables via `extract_solution` +on `feas.sub`. Same return contract as [`read_primary_solution`](@ref). +""" +function read_feas_solution(model::JuMP.AbstractModel, feas) + x_vals = extract_solution(feas.sub) return x_vals, x_vals end -#fix/unfix indicator binaries on the feas submodel via fwd_map. Dispatches -#through `fix_fv`/`unfix_fv` to handle scalar feas vars (finite) and -#per-support vectors (InfiniteOpt flat transcription). -function _fix_feas_combo_binaries(feas::_LOAFeasSubmodel, model, combo) - for (ind, val) in combo - orig_bv = _indicator_to_binary(model)[ind] - haskey(feas.fwd_map, orig_bv) || continue - fix_fv(feas.fwd_map[orig_bv], val) - end -end -function _unfix_feas_combo_binaries(feas::_LOAFeasSubmodel, model, combo) - for (ind, _) in combo - orig_bv = _indicator_to_binary(model)[ind] - haskey(feas.fwd_map, orig_bv) || continue - unfix_fv(feas.fwd_map[orig_bv]) - end -end +""" + fix_fv(bv, val::Bool)::Nothing + +Fix a binary indicator reference `bv` to `val`. Dispatches on: +- `AbstractVariableRef`: calls `JuMP.fix(bv, val ? 1.0 : 0.0; force = true)`. +- `GenericAffExpr`: the complement-indicator form `1 - other_bv`; fix + `other_bv` to the complement of `val`. -#fix/unfix a feas-submodel binary variable at a scalar Bool value +The InfiniteOpt extension adds an `AbstractArray` dispatch for +per-support indicator vectors. +""" fix_fv(bv, val::Bool) = JuMP.fix(bv, val ? 1.0 : 0.0; force = true) +function fix_fv(bv::JuMP.GenericAffExpr, val::Bool) + under, coeff = only(bv.terms) + JuMP.fix(under, val ? 0.0 : 1.0; force = true) +end + +""" + unfix_fv(bv)::Nothing + +Undo [`fix_fv`](@ref) on `bv`. No-op if `bv` is not currently fixed. +For complement AffExprs, unfixes the underlying variable. +""" function unfix_fv(bv) JuMP.is_fixed(bv) && JuMP.unfix(bv) return end +function unfix_fv(bv::JuMP.GenericAffExpr) + under = only(keys(bv.terms)) + JuMP.is_fixed(under) && JuMP.unfix(under) + return +end ################################################################################ # REFORM MAP (BigM constraint index) @@ -436,7 +445,6 @@ function _build_reform_map(model::M) where {M <: JuMP.AbstractModel} rmap = Dict{DisjunctConstraintRef{M}, Vector{CRT}}() idx = 1 for (_, disj_data) in _disjunctions(model) - disj_data.constraint.nested && continue for ind in disj_data.constraint.indicators haskey(_indicator_to_constraints(model), ind) || continue for cref in _indicator_to_constraints(model)[ind] @@ -478,11 +486,10 @@ end #extract combo from master solution. `combo_val` dispatches on scalar vs #array bin_map values (extension adds the array method for per-support). function _extract_combo( - model::M, master::_LOAMaster + model::M, master ) where {M <: JuMP.AbstractModel} combo = Dict{LogicalVariableRef{M}, Any}() for (_, disj_data) in _disjunctions(model) - disj_data.constraint.nested && continue for ind in disj_data.constraint.indicators haskey(master.bin_map, ind) || continue combo[ind] = combo_val(master.bin_map[ind]) @@ -490,11 +497,16 @@ function _extract_combo( end return combo end +""" + combo_val(bv)::Bool + +Round the master's current binary solution for indicator ref `bv` to a +`Bool`. The InfiniteOpt extension adds an `AbstractArray` dispatch +that returns a `Vector{Bool}` per support. +""" combo_val(bv) = round(JuMP.value(bv)) > 0.5 -#no-good cut on the master excluding the current combo. `add_ng_terms` -#dispatches on scalar vs array bin_map / active values. -function _add_no_good_cut(model, master::_LOAMaster, combo) +function _add_no_good_cut(model, master, combo) cut_expr = JuMP.AffExpr(0.0) for (ind, active) in combo haskey(master.bin_map, ind) || continue @@ -502,6 +514,16 @@ function _add_no_good_cut(model, master::_LOAMaster, combo) end JuMP.@constraint(master.model, cut_expr >= 1.0) end + +""" + add_ng_terms(cut, bv, active::Bool)::Nothing + +Assemble one indicator's contribution to the no-good cut `cut_expr >= +1`: add `1 - y_j` if the indicator was active in the excluded combo, +or `y_j` otherwise. The InfiniteOpt extension adds an +`AbstractArray`/`AbstractArray{Bool}` dispatch that folds per-support +contributions. +""" function add_ng_terms(cut, bv, active::Bool) if active JuMP.add_to_expression!(cut, -1.0, bv) @@ -512,8 +534,13 @@ function add_ng_terms(cut, bv, active::Bool) return end -#predicate used by OA cut generation: is there any active indicator in -#this combo entry? Scalar Bool case; extension adds `AbstractVector{Bool}` +""" + any_active(active)::Bool + +Return `true` if any indicator value in `active` is truthy. The base +dispatch is the trivial scalar `Bool` case; the InfiniteOpt extension +adds an `AbstractVector{Bool}` dispatch for per-support indicators. +""" any_active(active::Bool) = active ################################################################################ @@ -543,8 +570,9 @@ _slacken(f, set, u) = [(f, set)] ################################################################################ # OA CUT GENERATION ################################################################################ -#sense-dependent coefficients dispatched on Val(obj_sense). See usage in -#`_add_objective_oa_cut` and `_add_disjunct_oa_cuts`. +# Sense-dependent coefficients for OA and convergence calls. Dispatched +# on `Val(master.obj_sense)` so downstream call sites can read like +# regular Julia without branching on the sense. _disjunct_cut_coeffs(::Val{_MOI.MIN_SENSE}) = (-1, 1) _disjunct_cut_coeffs(::Val{_MOI.MAX_SENSE}) = (1, -1) _worst_obj(::Val{_MOI.MIN_SENSE}) = Inf @@ -556,16 +584,16 @@ _gap(::Val{_MOI.MAX_SENSE}, best, bound) = bound - best _flip_sense(::Val{_MOI.MIN_SENSE}) = Val(_MOI.MAX_SENSE) _flip_sense(::Val{_MOI.MAX_SENSE}) = Val(_MOI.MIN_SENSE) -function _add_oa_cuts(model, master::_LOAMaster, result, method::LOA) +function _add_oa_cuts(model, master, result, method::LOA) isempty(result.x_values) && return _add_objective_oa_cut(master, result) _add_disjunct_oa_cuts(model, master, result, method) return end -#linearize the original objective at `result.obj_x_values` and add it as -#a cut on `alpha_oa` with direction determined by `master.obj_sense` -function _add_objective_oa_cut(master::_LOAMaster, result) +# Linearize the original objective at the NLP's solution and add the +# bounding cut `lin ≤ α` (min) or `lin ≥ α` (max) on the master. +function _add_objective_oa_cut(master, result) lin = _linearize_at( master.orig_obj, result.obj_x_values, master.obj_ref_map) _add_obj_cut(Val(master.obj_sense), master, lin) @@ -576,11 +604,11 @@ _add_obj_cut(::Val{_MOI.MIN_SENSE}, master, lin) = _add_obj_cut(::Val{_MOI.MAX_SENSE}, master, lin) = JuMP.@constraint(master.model, lin >= master.alpha_oa) -#add V&G 1990 augmented-penalty OA cuts for each active disjunct's -#nonlinear constraints. `cut_sites` yields the per-cut tuples: 1 site -#for finite, K sites for the InfiniteOpt case (extension override). +# Add V&G 1990 augmented-penalty OA cuts for each active disjunct's +# nonlinear constraints: fresh per-cut slack `σ_ik` with a penalty term +# in the master objective, and cut body `s (lin - rhs) - σ ≤ M(1 - y)`. function _add_disjunct_oa_cuts( - model, master::_LOAMaster, result, method::LOA + model, master, result, method::LOA ) sgn, pen = _disjunct_cut_coeffs(Val(master.obj_sense)) for (ind, active) in result.combo @@ -594,47 +622,44 @@ function _add_disjunct_oa_cuts( con.func isa JuMP.GenericAffExpr && continue d = get(result.duals, orig_cref, nothing) d === nothing && continue - for (bv, xk, smap, d_k) in cut_sites( + rhs = _set_rhs(con.set) + for (bv, xk, smap, d_k) in cut_info( master.bin_map[ind], active, result.x_values, master.var_map, d) - dv = _dv(d_k) - s = sign(sgn * dv) + s = sign(sgn * _dv(d_k)) s == 0 && continue - _add_one_disjunct_oa_cut( - master, con, method, bv, xk, smap, s, pen) + lin_expr = _linearize_at(con.func, xk, smap) + slack = JuMP.@variable(master.model, + lower_bound = 0.0, upper_bound = method.max_slack) + JuMP.set_objective_function(master.model, + JuMP.objective_function(master.model) + + pen * method.OA_penalty_factor * slack) + JuMP.@constraint(master.model, + s * (lin_expr - rhs) - slack <= + method.M_value * (1 - bv)) end end end end -#one OA cut site: (binary, x_values, var_map, dual). Scalar case yields -#a single-element tuple collection; extension's array dispatch yields -#K per-support sites with sliced x_values and var_map. -cut_sites(bv, active::Bool, x_values, var_map, d) = +""" + cut_info(bv, active, x_values, var_map, d) + +Yield the inputs `(bv, xk, smap, d_k)` needed to emit each OA cut for +one active disjunct constraint. The scalar base returns one tuple +(one cut per constraint). The InfiniteOpt extension adds an +`AbstractArray` dispatch that yields K tuples with per-support-sliced +`x_values`, `var_map`, and dual (one cut per active support). +""" +cut_info(bv, active::Bool, x_values, var_map, d) = ((bv, x_values, var_map, d),) -#extract a scalar dual value from a dual "cell" that may be a vector -#(from a vector-valued reform constraint in the infinite case) +# Collapse a dual value (scalar for a single reformulated constraint, +# vector for reform constraints that produced multiple JuMP constraints +# like Interval / EqualTo / Zeros) to a scalar sign-carrier. _dv(d::Number) = d _dv(d) = sum(d) -#add a single disjunct OA cut with augmented-penalty slack -function _add_one_disjunct_oa_cut( - master::_LOAMaster, con, method, bv, x_values, var_map, s, pen - ) - lin_expr = _linearize_at(con.func, x_values, var_map) - rhs = _set_rhs(con.set) - slack = JuMP.@variable(master.model, - lower_bound = 0.0, upper_bound = method.max_slack) - JuMP.set_objective_function(master.model, - JuMP.objective_function(master.model) + - pen * method.OA_penalty_factor * slack) - JuMP.@constraint(master.model, - s * (lin_expr - rhs) - slack <= - method.M_value * (1 - bv)) - return -end - ################################################################################ # CONVERGENCE CHECK ################################################################################ diff --git a/src/utilities.jl b/src/utilities.jl index 431c539a..9dd168e2 100644 --- a/src/utilities.jl +++ b/src/utilities.jl @@ -62,6 +62,19 @@ function reformulate_and_relax( return sub, undo_relax end +""" + extract_solution(sub::GDPSubmodel) + +Read the primal solution of `sub.model` after a solve, keyed by the +parent-model decision variables via `sub.fwd_map`. Shape follows +`fwd_map` values: `Vector`-valued fwd_maps (MBM/CP) yield per-support +`Vector`s; scalar fwd_maps (LOA feas) yield scalars. +""" +function extract_solution(sub::GDPSubmodel) + return Dict( + var => JuMP.value.(sub.fwd_map[var]) for var in sub.decision_vars) +end + ################################################################################ # LOGICAL VARIABLE RELAXATION ################################################################################ diff --git a/test/constraints/loa.jl b/test/constraints/loa.jl index 3b92cd5b..8009ece0 100644 --- a/test/constraints/loa.jl +++ b/test/constraints/loa.jl @@ -51,7 +51,7 @@ function test_no_good_cut() @disjunction(model, Y) DP.reformulate_model(model, BigM(1e9)) - master = DP._build_loa_master( + master = DP.build_loa_master( model, LOA(HiGHS.Optimizer)) master_model = master.model From eb65da6d506b0f6566b54befe57b20c446912151 Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Sat, 25 Apr 2026 13:34:18 -0400 Subject: [PATCH 34/59] . --- ext/InfiniteDisjunctiveProgramming.jl | 51 --------------------- src/loa.jl | 64 ++++++++++++++++++++------- 2 files changed, 49 insertions(+), 66 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index a72ede75..ce86ba8f 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -405,57 +405,6 @@ function _flat_xk(model::InfiniteOpt.InfiniteModel, x_values) return fxk end -#per-support dispatch methods: the base DP.jl LOA code calls scalar-form -#helpers; these array methods let the same code path handle vector-valued -#bin_map / var_map / x_values / active entries without any extra branches -#in the base file. - -DP.fix_fv(bvs::AbstractArray, val::Bool) = - (for bv in bvs; DP.fix_fv(bv, val); end; return) -DP.fix_fv(bvs::AbstractArray, val::AbstractArray) = - (for (bv, v) in zip(bvs, val); DP.fix_fv(bv, v); end; return) -DP.unfix_fv(bvs::AbstractArray) = - (for bv in bvs; DP.unfix_fv(bv); end; return) - -DP.any_active(v::AbstractVector{Bool}) = any(v) - -#combo extraction: round per-support binary values to a Vector{Bool} -DP.combo_val(bvs::AbstractArray) = Bool.(round.(JuMP.value.(bvs))) - -#no-good cut: fold one scalar term per (binary, active) pair. The scalar- -#active method handles the set-covering phase where combos are Bool-valued. -DP.add_ng_terms(cut, bvs::AbstractArray, active::Bool) = - DP.add_ng_terms(cut, bvs, fill(active, length(bvs))) -function DP.add_ng_terms(cut, bvs::AbstractArray, actives::AbstractArray) - for (bv, a) in zip(bvs, actives) - DP.add_ng_terms(cut, bv, a) - end - return -end - -#OA cut sites: one site per active support, with per-support restrictions -#of `x_values` and `var_map` -DP.cut_info(bvs::AbstractArray, active::Bool, x_values, var_map, d) = - DP.cut_info(bvs, fill(active, length(bvs)), x_values, var_map, d) -function DP.cut_info( - bvs::AbstractArray, actives::AbstractArray, - x_values, var_map, d - ) - sites = Any[] - for k in 1:length(bvs) - actives[k] || continue - smap_k = Dict{Any, Any}( - v => (mv isa AbstractVector ? mv[k] : mv) - for (v, mv) in var_map) - x_k = Dict{Any, Any}( - v => (xv isa AbstractVector ? xv[k] : xv) - for (v, xv) in x_values) - d_k = d isa AbstractVector ? d[k] : d - push!(sites, (bvs[k], x_k, smap_k, d_k)) - end - return sites -end - #detect number of supports from a bin_map; used below in `build_loa_master` function _detect_K(bin_map) for (_, bvs) in bin_map diff --git a/src/loa.jl b/src/loa.jl index 81cbd3e9..0c4fdc65 100644 --- a/src/loa.jl +++ b/src/loa.jl @@ -409,15 +409,18 @@ Fix a binary indicator reference `bv` to `val`. Dispatches on: - `AbstractVariableRef`: calls `JuMP.fix(bv, val ? 1.0 : 0.0; force = true)`. - `GenericAffExpr`: the complement-indicator form `1 - other_bv`; fix `other_bv` to the complement of `val`. - -The InfiniteOpt extension adds an `AbstractArray` dispatch for -per-support indicator vectors. +- `AbstractArray`: iterate elementwise over `bvs` (and `vals` if also + array). Used by per-support InfiniteOpt indicators. """ fix_fv(bv, val::Bool) = JuMP.fix(bv, val ? 1.0 : 0.0; force = true) function fix_fv(bv::JuMP.GenericAffExpr, val::Bool) under, coeff = only(bv.terms) JuMP.fix(under, val ? 0.0 : 1.0; force = true) end +fix_fv(bvs::AbstractArray, val::Bool) = + (for bv in bvs; fix_fv(bv, val); end; return) +fix_fv(bvs::AbstractArray, vals::AbstractArray) = + (for (bv, v) in zip(bvs, vals); fix_fv(bv, v); end; return) """ unfix_fv(bv)::Nothing @@ -434,6 +437,8 @@ function unfix_fv(bv::JuMP.GenericAffExpr) JuMP.is_fixed(under) && JuMP.unfix(under) return end +unfix_fv(bvs::AbstractArray) = + (for bv in bvs; unfix_fv(bv); end; return) ################################################################################ # REFORM MAP (BigM constraint index) @@ -501,10 +506,11 @@ end combo_val(bv)::Bool Round the master's current binary solution for indicator ref `bv` to a -`Bool`. The InfiniteOpt extension adds an `AbstractArray` dispatch -that returns a `Vector{Bool}` per support. +`Bool`. Scalar form returns `Bool`; `AbstractArray` form returns +`Vector{Bool}` per support for InfiniteOpt indicators. """ combo_val(bv) = round(JuMP.value(bv)) > 0.5 +combo_val(bvs::AbstractArray) = Bool.(round.(JuMP.value.(bvs))) function _add_no_good_cut(model, master, combo) cut_expr = JuMP.AffExpr(0.0) @@ -520,9 +526,8 @@ end Assemble one indicator's contribution to the no-good cut `cut_expr >= 1`: add `1 - y_j` if the indicator was active in the excluded combo, -or `y_j` otherwise. The InfiniteOpt extension adds an -`AbstractArray`/`AbstractArray{Bool}` dispatch that folds per-support -contributions. +or `y_j` otherwise. `AbstractArray` forms (with Bool or per-support +Vector) fold per-support contributions for InfiniteOpt indicators. """ function add_ng_terms(cut, bv, active::Bool) if active @@ -533,15 +538,24 @@ function add_ng_terms(cut, bv, active::Bool) end return end +add_ng_terms(cut, bvs::AbstractArray, active::Bool) = + add_ng_terms(cut, bvs, fill(active, length(bvs))) +function add_ng_terms(cut, bvs::AbstractArray, actives::AbstractArray) + for (bv, a) in zip(bvs, actives) + add_ng_terms(cut, bv, a) + end + return +end """ any_active(active)::Bool -Return `true` if any indicator value in `active` is truthy. The base -dispatch is the trivial scalar `Bool` case; the InfiniteOpt extension -adds an `AbstractVector{Bool}` dispatch for per-support indicators. +Return `true` if any indicator value in `active` is truthy. Scalar +`Bool` returns itself; `AbstractVector{Bool}` reduces with `any` for +per-support InfiniteOpt indicators. """ any_active(active::Bool) = active +any_active(actives::AbstractVector{Bool}) = any(actives) ################################################################################ # LINEARIZATION HELPERS @@ -646,13 +660,33 @@ end cut_info(bv, active, x_values, var_map, d) Yield the inputs `(bv, xk, smap, d_k)` needed to emit each OA cut for -one active disjunct constraint. The scalar base returns one tuple -(one cut per constraint). The InfiniteOpt extension adds an -`AbstractArray` dispatch that yields K tuples with per-support-sliced -`x_values`, `var_map`, and dual (one cut per active support). +one active disjunct constraint. The scalar form returns one tuple +(one cut per constraint). The `AbstractArray` form yields K tuples +with per-support-sliced `x_values`, `var_map`, and dual (one cut per +active support, used for InfiniteOpt indicators). """ cut_info(bv, active::Bool, x_values, var_map, d) = ((bv, x_values, var_map, d),) +cut_info(bvs::AbstractArray, active::Bool, x_values, var_map, d) = + cut_info(bvs, fill(active, length(bvs)), x_values, var_map, d) +function cut_info( + bvs::AbstractArray, actives::AbstractArray, + x_values, var_map, d + ) + sites = Any[] + for k in 1:length(bvs) + actives[k] || continue + smap_k = Dict{Any, Any}( + v => (mv isa AbstractVector ? mv[k] : mv) + for (v, mv) in var_map) + x_k = Dict{Any, Any}( + v => (xv isa AbstractVector ? xv[k] : xv) + for (v, xv) in x_values) + d_k = d isa AbstractVector ? d[k] : d + push!(sites, (bvs[k], x_k, smap_k, d_k)) + end + return sites +end # Collapse a dual value (scalar for a single reformulated constraint, # vector for reform constraints that produced multiple JuMP constraints From 3bf94fa1f27d119b38f8d3e5e41dd18f1eec12cd Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Sun, 26 Apr 2026 21:21:59 -0400 Subject: [PATCH 35/59] . --- ext/InfiniteDisjunctiveProgramming.jl | 764 +++++++++++++++---- src/loa.jl | 1012 ++++++++++++------------- src/mbm.jl | 7 +- src/utilities.jl | 145 ++++ test/constraints/loa.jl | 13 +- test/constraints/mbm.jl | 4 +- 6 files changed, 1263 insertions(+), 682 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index ce86ba8f..ff04a82f 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -329,19 +329,18 @@ function DP.copy_and_reformulate( return sub end +# InfiniteOpt-internal primitive: for an InfiniteOpt variable, return +# its per-support values as a Vector. `JuMP.value` returns a scalar +# (finite vars), Vector (1 infinite param), or N-D Array (multiple +# independent param groups). `vcat` lifts a scalar to a 1-element +# Vector; `vec` flattens any N-D Array to a Vector. +_per_support_values(variable::InfiniteOpt.GeneralVariableRef) = + vec(vcat(JuMP.value(variable))) + # Read per-support values from the transformation backend. function DP.extract_solution(model::InfiniteOpt.InfiniteModel) - dvars = DP.collect_cutting_planes_vars(model) - V = eltype(dvars) - T = JuMP.value_type(typeof(model)) - sol = Dict{V, Vector{T}}() - for v in dvars - transcription_var = InfiniteOpt.transformation_variable(v) - var_prefs = InfiniteOpt.parameter_refs(v) - sol[v] = isempty(var_prefs) ? [JuMP.value(transcription_var)] : - JuMP.value.(vec(transcription_var)) - end - return sol + return Dict(variable => _per_support_values(variable) + for variable in DP.collect_cutting_planes_vars(model)) end # Add a pointwise-sum cut directly to the transformation backend and mark @@ -377,163 +376,668 @@ end ################################################################################ # LOA FOR INFINITEMODEL ################################################################################ -# Dispatch overrides for InfiniteModel. The base LOA algorithm in src/loa.jl -# is written for finite (scalar) models; these overrides handle transcription -# to a flat master and per-support binary/variable indexing. - -# Helper: map an InfiniteOpt var to its flat master var(s) via transcription + -# copy_map. Returns a vector for infinite vars, scalar for finite. -function _tv_map(v, copy_map) - tv = InfiniteOpt.transformation_variable(v) - return tv isa AbstractArray ? [copy_map[fv] for fv in vec(tv)] : copy_map[tv] -end - -# Convert InfiniteModel x_values to flat-var x_values (for objective OA cut). -function _flat_xk(model::InfiniteOpt.InfiniteModel, x_values) - fxk = Dict{JuMP.VariableRef, Float64}() - for (v, val) in x_values - tv = InfiniteOpt.transformation_variable(v) - if tv isa AbstractArray - vals = val isa AbstractVector ? val : fill(Float64(val), length(tv)) - for (i, fv) in enumerate(vec(tv)) - fxk[fv] = vals[i] - end - else - fxk[tv] = val isa Number ? Float64(val) : Float64(first(val)) +# Dispatch overrides for InfiniteModel. The base LOA in src/loa.jl is +# written for finite (scalar) models. The InfiniteOpt master and feas +# submodel are themselves InfiniteModels, with per-support handling +# via point evaluation on infinite `GeneralVariableRef`s. + +DP.any_active(actives::AbstractVector{Bool}) = any(actives) + +# `JuMP.value` returns a per-support `Array` for infinite vars and a +# scalar for finite vars. The `> 0.5` cutoff handles solver-side +# integer-feasibility slack (e.g. HiGHS can return 2.75e-40 for a +# "0" binary), where direct `Bool(val)` would `InexactError`. +# `JuMP.value` returns a per-support `Array` for infinite vars and a +# scalar for finite vars; `round(Bool, ·)` handles both via broadcast +# and absorbs solver integer-feasibility slack. +function DP.combination_val(v::InfiniteOpt.GeneralVariableRef) + val = JuMP.value(v) + return val isa AbstractArray ? vec(round.(Bool, val)) : round(Bool, val) +end + +_supports_of(v::InfiniteOpt.GeneralVariableRef) = + vec(InfiniteOpt.supports(only(InfiniteOpt.parameter_refs(v)))) + +_is_point_var(v::InfiniteOpt.GeneralVariableRef) = + InfiniteOpt.dispatch_variable_ref(v) isa InfiniteOpt.PointVariableRef + +# A `MeasureRef` collapses `f(x, t)` over an infinite parameter into +# one ref; a `ParameterFunctionRef` collapses a parameter-dependent +# function value into one ref. Either hides decision-variable +# dependence from MOI Nonlinear AD, so LOA needs to transcribe the +# enclosing expression to recover correct gradients. +DP.is_aggregate_ref(v::InfiniteOpt.GeneralVariableRef) = + InfiniteOpt.dispatch_variable_ref(v) isa Union{ + InfiniteOpt.MeasureRef, InfiniteOpt.ParameterFunctionRef} + +# Resolve a `PointVariableRef` (e.g., `L(0)` from a boundary +# condition) to the master's corresponding point variable: look up +# the underlying infinite var in `var_map`, then point-evaluate at +# the same support values. For all other variable refs (parameters, +# infinite/finite decision vars), fall back to direct lookup. +function DP.replace_variables_in_constraint( + v::InfiniteOpt.GeneralVariableRef, var_map::AbstractDict + ) + if _is_point_var(v) + underlying = InfiniteOpt.infinite_variable_ref(v) + return var_map[underlying](InfiniteOpt.parameter_values(v)...) + end + return var_map[v] +end + +# Per-support fix via point constraints. Used by the LOA feas +# submodel (`fix_indicator(feas_binary, combination_value)`) when the +# master returned a per-support combination. +function DP.fix_indicator( + binary_ref::InfiniteOpt.GeneralVariableRef, + values::AbstractVector{Bool} + ) + model = JuMP.owner_model(binary_ref) + for (k, support) in enumerate(_supports_of(binary_ref)) + JuMP.@constraint(model, + binary_ref(support) == (values[k] ? 1.0 : 0.0)) + end + return +end + +# Bool active arises from `_set_covering_combinations`, which keys +# combinations on `LogicalVariableRef → Bool` regardless of whether +# the indicator is infinite. Broadcast over all supports for an +# infinite indicator; for a finite (or point-variable) ref, fall +# through to the base scalar dispatch. +function DP.add_no_good_terms( + cut, binary_ref::InfiniteOpt.GeneralVariableRef, active::Bool + ) + isempty(InfiniteOpt.parameter_refs(binary_ref)) && + return invoke(DP.add_no_good_terms, + Tuple{Any, Any, Bool}, cut, binary_ref, active) + for support in _supports_of(binary_ref) + DP.add_no_good_terms(cut, binary_ref(support), active) + end + return +end + +# AbstractVector active arises from `combination_val` after a master +# solve, which yields per-support `Vector{Bool}`. +function DP.add_no_good_terms( + cut, binary_ref::InfiniteOpt.GeneralVariableRef, + actives::AbstractVector + ) + supports = _supports_of(binary_ref) + for (k, support) in enumerate(supports) + DP.add_no_good_terms(cut, binary_ref(support), actives[k]) + end + return +end + +function DP.cut_info( + binary_ref::InfiniteOpt.GeneralVariableRef, active::Bool, + linearization_point, variable_map, dual + ) + supports = _supports_of(binary_ref) + return _cut_sites(binary_ref, supports, + fill(true, length(supports)), + linearization_point, variable_map, dual) +end + +function DP.cut_info( + binary_ref::InfiniteOpt.GeneralVariableRef, + actives::AbstractVector, + linearization_point, variable_map, dual + ) + return _cut_sites(binary_ref, _supports_of(binary_ref), actives, + linearization_point, variable_map, dual) +end + +function _cut_sites( + binary_ref::InfiniteOpt.GeneralVariableRef, + supports::AbstractVector, + actives::AbstractVector, + linearization_point::AbstractDict, + variable_map::AbstractDict, + dual + ) + sites = Any[] + for (k, support) in enumerate(supports) + actives[k] || continue + point_var_map = Dict{ + InfiniteOpt.GeneralVariableRef, + InfiniteOpt.GeneralVariableRef + }() + for (variable, master_var) in variable_map + point_var_map[variable] = _at_support(master_var, support) end + point = Dict{InfiniteOpt.GeneralVariableRef, Float64}() + for (variable, point_value) in linearization_point + point[variable] = _at(point_value, k) + end + push!(sites, ( + binary_ref(support), + point, + point_var_map, + _at(dual, k) + )) end - return fxk + return sites end -#detect number of supports from a bin_map; used below in `build_loa_master` -function _detect_K(bin_map) - for (_, bvs) in bin_map - bvs isa AbstractVector && return length(bvs) +# Slice a per-support container at index `k`; pass scalars through. +_at(values::AbstractArray, k::Integer) = values[k] +_at(scalar, ::Integer) = scalar + +# Point-evaluate an InfiniteOpt var at `support` if it's infinite; +# return the var as-is if it's finite. +_at_support(v::InfiniteOpt.GeneralVariableRef, support) = + isempty(InfiniteOpt.parameter_refs(v)) ? v : v(support) + +# Override the base `copy_variables_onto_model` for `InfiniteModel`: in addition +# to decision variables, copy infinite parameters (with supports), +# derivatives, and parameter functions. `PointVariableRef`s (e.g. +# `L(0)` from boundary conditions) are skipped — they re-emerge on +# the target via `replace_variables_in_constraint`. +function DP.copy_variables_onto_model( + target::InfiniteOpt.InfiniteModel, + source::InfiniteOpt.InfiniteModel + ) + ref_map = Dict{InfiniteOpt.GeneralVariableRef, + InfiniteOpt.GeneralVariableRef}() + for p in InfiniteOpt.all_parameters(source) + domain = InfiniteOpt.infinite_domain(p) + supports = Float64.(InfiniteOpt.supports(p)) + param = InfiniteOpt.build_parameter(error, domain; + supports = supports) + ref_map[p] = InfiniteOpt.add_parameter(target, param, + JuMP.name(p)) end - return 1 + for v in JuMP.all_variables(source) + # Point variables (e.g. `L(0)` from boundary conditions) are + # implicit evaluations of their underlying infinite var; they + # carry NaN start values by default and don't need to be + # copied as primary decision variables. They re-emerge on the + # target via `replace_variables_in_constraint`. + _is_point_var(v) && continue + prefs = InfiniteOpt.parameter_refs(v) + var_type = isempty(prefs) ? nothing : + InfiniteOpt.Infinite(Tuple(ref_map[p] for p in prefs)...) + props = DP.VariableProperties( + DP.get_variable_info(v), JuMP.name(v), nothing, var_type) + ref_map[v] = DP.create_variable(target, props) + end + for d in InfiniteOpt.all_derivatives(source) + vref = InfiniteOpt.derivative_argument(d) + pref = InfiniteOpt.operator_parameter(d) + new_d = InfiniteOpt.deriv(ref_map[vref], ref_map[pref]) + info = DP.get_variable_info(d) + info.has_lb && JuMP.set_lower_bound(new_d, info.lower_bound) + info.has_ub && JuMP.set_upper_bound(new_d, info.upper_bound) + ref_map[d] = new_d + end + for pfunc in InfiniteOpt.all_parameter_functions(source) + func = InfiniteOpt.raw_function(pfunc) + prefs = InfiniteOpt.parameter_refs(pfunc) + mapped_prefs = Tuple(ref_map[p] for p in prefs) + pref_arg = length(mapped_prefs) == 1 ? + only(mapped_prefs) : mapped_prefs + param_func = InfiniteOpt.build_parameter_function(error, + func, pref_arg) + ref_map[pfunc] = InfiniteOpt.add_parameter_function(target, + param_func) + end + return ref_map end -#transcribe the BigM'd InfiniteModel to flat, copy it, create alpha_oa. -#Number of supports K is stashed in `master.model.ext[:_loa_K]` for the -#per-support OA cut and combo overrides below. -function DP.build_loa_master(model::InfiniteOpt.InfiniteModel, method::DP.LOA) - InfiniteOpt.build_transformation_backend!(model) - flat = InfiniteOpt.transformation_model(model) - orig_obj = JuMP.objective_function(flat) - master, copy_map = JuMP.copy_model(flat) +# Build map: transcribed input JuMP var → master point variable. +# For an infinite input var v, every transcribed support `v_k` +# maps to `ref_map[v](d_k)` (master point variable). Used as +# `objective_ref_map` so the objective OA cut can linearize the +# (already flat) transcribed objective and land in master point +# variables. +# +# Why the transcribe-then-AD detour exists: MOI Nonlinear AD has +# no walker for `InfiniteOpt.MeasureRef`, so an objective like +# `∫(f(z, t), t)` cannot be differentiated directly. The objective +# OA cut therefore transcribes the input model once, ADs the +# resulting flat scalar objective, and uses this map to translate +# the gradient back into master point variables. Per-support +# DISJUNCT cuts do NOT need this — they linearize natively in +# InfiniteModel space via `cut_info`. If the objective contains no +# measures, this whole layer is dead weight; killing it would +# require either banning measure objectives or hand-writing a +# `_linearize_at` that walks `MeasureRef` symbolically. +function _transcribed_to_master_point( + model::InfiniteOpt.InfiniteModel, + ref_map::AbstractDict + ) + result = Dict{JuMP.VariableRef, InfiniteOpt.GeneralVariableRef}() + for v in DP.collect_all_vars(model) + # Point vars share their transcribed instance with the + # underlying infinite var's per-support transcription, so + # skipping them here doesn't lose any transcribed→master + # mappings. + _is_point_var(v) && continue + master_var = ref_map[v] + transcribed = InfiniteOpt.transformation_variable(v) + prefs = InfiniteOpt.parameter_refs(v) + if isempty(prefs) + result[transcribed] = master_var + else + supports = vec(InfiniteOpt.supports(only(prefs))) + for (k, ref) in enumerate(vec(transcribed)) + result[ref] = master_var(supports[k]) + end + end + end + return result +end + +# Build the LOA master as a fresh empty `InfiniteModel` populated +# with the input model's structural skeleton plus its linear +# constraints. Install `alpha_oa` as a finite variable. +# `binary_map[indicator]` and `variable_map[v]` hold single +# InfiniteOpt vars on the master; per-support handling happens +# downstream via point evaluation on those refs. OA cuts added in +# the LOA loop are point-evaluated scalar constraints on the master +# InfiniteModel; transcription is rebuilt before each master solve. +# +# Objective handling branches on `has_aggregate_ref`. When the +# objective is aggregate-free (no MeasureRef / ParameterFunctionRef), +# `original_objective` is the InfiniteOpt objective itself and +# `objective_ref_map = ref_map`, so AD walks the InfiniteOpt +# expression directly. When it contains an aggregate, AD cannot see +# inside it; we fall back to transcribing the input model and using +# the flat scalar objective with a transcribed-to-master point map. +function DP.build_loa_master( + model::InfiniteOpt.InfiniteModel, method::DP.LOA + ) + master = InfiniteOpt.InfiniteModel() JuMP.set_optimizer(master, method.mip_optimizer) JuMP.set_silent(master) - bin_map = Dict{DP.LogicalVariableRef, Any}() - for (ind, bv) in DP._indicator_to_binary(model) - bin_map[ind] = _tv_map(bv, copy_map) + + ref_map = DP.copy_variables_onto_model(master, model) + + variable_type = InfiniteOpt.GeneralVariableRef + for (F, S) in JuMP.list_of_constraint_types(model) + F === variable_type && continue + DP._is_linear_F(F) || continue + for cref in JuMP.all_constraints(model, F, S) + con = JuMP.constraint_object(cref) + new_func = DP.replace_variables_in_constraint( + con.func, ref_map) + JuMP.@constraint(master, new_func in con.set) + end end - var_map = Dict{InfiniteOpt.GeneralVariableRef, Any}() + + raw_objective = JuMP.objective_function(model) + objective_sense = JuMP.objective_sense(model) + if DP.has_aggregate_ref(raw_objective) + InfiniteOpt.build_transformation_backend!(model) + transcribed_input = InfiniteOpt.transformation_model(model) + original_objective = JuMP.objective_function(transcribed_input) + objective_ref_map = _transcribed_to_master_point( + model, ref_map) + else + original_objective = raw_objective + objective_ref_map = ref_map + end + + alpha_oa = JuMP.@variable(master, base_name = "alpha_oa") + JuMP.@objective(master, objective_sense, alpha_oa) + + binary_map = Dict{DP.LogicalVariableRef, Any}() + for (indicator, binary_ref) in DP._indicator_to_binary(model) + binary_map[indicator] = DP._remap_indicator_to_binary( + binary_ref, ref_map) + end + variable_map = Dict{InfiniteOpt.GeneralVariableRef, + InfiniteOpt.GeneralVariableRef}() for v in DP.collect_all_vars(model) - var_map[v] = _tv_map(v, copy_map) + _is_point_var(v) && continue + variable_map[v] = ref_map[v] end - #also store a flat→master copy_map for objective OA cuts - flat_copy_map = Dict{JuMP.VariableRef, JuMP.VariableRef}() - for v in JuMP.all_variables(flat) - flat_copy_map[v] = copy_map[v] + + return (model = master, binary_map = binary_map, + variable_map = variable_map, + objective_sense = objective_sense, + original_objective = original_objective, + alpha_oa = alpha_oa, + objective_ref_map = objective_ref_map) +end + +# Override the disjunct-cut loop for `InfiniteModel`. Same shape as +# the base loop, but each constraint is checked for aggregate refs. +# Aggregate constraints (e.g. those containing a `MeasureRef`) are +# transcribed via `InfiniteOpt.transformation_expression`, then +# handed back to the base `_add_oa_cut_for_constraint` as a regular +# `JuMP.ScalarConstraint` over flat `JuMP.VariableRef`s — which the +# base AD pipeline can linearize correctly. +# +# `transcribed_to_master` and `transcribed_xk` are built lazily once +# per `add_disjunct_oa_cuts` call and shared across all aggregate +# constraints in the iteration. +function DP.add_disjunct_oa_cuts( + model::InfiniteOpt.InfiniteModel, + master::NamedTuple, + result::NamedTuple, + method::DP.LOA + ) + sign_factor, penalty_sign = DP._disjunct_cut_coefficients( + Val(master.objective_sense)) + transcribed_to_master = Ref{Any}(nothing) + transcribed_xk = Ref{Any}(nothing) + ensure_transcribed = function () + transcribed_to_master[] === nothing || return + InfiniteOpt.build_transformation_backend!(model) + transcribed_to_master[] = _transcribed_to_master_point( + model, master.variable_map) + transcribed_xk[] = _transcribe_linearization_point( + model, result.linearization_point) + return end - obj_sense = JuMP.objective_sense(master) - alpha_oa = JuMP.@variable(master, base_name = "alpha_oa") - JuMP.@objective(master, obj_sense, alpha_oa) - m = (model = master, bin_map = bin_map, var_map = var_map, - obj_sense = obj_sense, orig_obj = orig_obj, - alpha_oa = alpha_oa, obj_ref_map = flat_copy_map) - master.ext[:_loa_K] = _detect_K(bin_map) - master.ext[:_loa_flat_copy_map] = flat_copy_map - return m -end - -#fix per-support via point equality constraints -function DP.fix_combo_binaries(model::InfiniteOpt.InfiniteModel, combo) - crefs = InfiniteOpt.InfOptConstraintRef[] - for (ind, val) in combo - bv = DP._indicator_to_binary(model)[ind] - if val isa Bool - JuMP.fix(bv, val ? 1.0 : 0.0; force = true) - else - sups = InfiniteOpt.supports(first(InfiniteOpt.parameter_refs(bv))) - for (k, s) in enumerate(vec(sups)) - push!(crefs, - JuMP.@constraint(model, bv(s) == (val[k] ? 1.0 : 0.0))) + for (indicator, active) in result.combination + DP.any_active(active) || continue + haskey(master.binary_map, indicator) || continue + haskey(DP._indicator_to_constraints(model), indicator) || + continue + for orig_constraint_ref in + DP._indicator_to_constraints(model)[indicator] + orig_constraint_ref isa DP.DisjunctConstraintRef || + continue + constraint = DP._disjunct_constraints(model)[ + JuMP.index(orig_constraint_ref)].constraint + dual = get(result.duals, orig_constraint_ref, nothing) + dual === nothing && continue + if DP.has_aggregate_ref(constraint.func) + ensure_transcribed() + transcribed_func = + InfiniteOpt.transformation_expression( + constraint.func) + transcribed_constraint = JuMP.ScalarConstraint( + transcribed_func, constraint.set) + for (binary_ref, _, _, dual_value) in DP.cut_info( + master.binary_map[indicator], active, + result.linearization_point, + master.variable_map, dual) + DP._add_oa_cut_for_constraint( + transcribed_constraint, master, binary_ref, + transcribed_xk[], transcribed_to_master[], + dual_value, method, sign_factor, + penalty_sign) + end + continue + end + for (binary_ref, linearization_point, var_map, + dual_value) in DP.cut_info( + master.binary_map[indicator], active, + result.linearization_point, + master.variable_map, dual) + DP._add_oa_cut_for_constraint( + constraint, master, binary_ref, + linearization_point, var_map, dual_value, + method, sign_factor, penalty_sign) end end end - model.ext[:_loa_fix_crefs] = crefs end -function DP.unfix_combo_binaries(model::InfiniteOpt.InfiniteModel, combo) - if haskey(model.ext, :_loa_fix_crefs) - for c in model.ext[:_loa_fix_crefs] - JuMP.is_valid(model, c) && JuMP.delete(model, c) +# Fix indicator binaries on the NLP model. Bool: fix the whole +# infinite var across all supports. Vector{Bool}: fix per-support via +# point equality constraints (refs stashed for `unfix_combination_binaries`). +function DP.fix_combination_binaries( + model::InfiniteOpt.InfiniteModel, + combination::AbstractDict + ) + fixing_constraint_refs = InfiniteOpt.InfOptConstraintRef[] + for (indicator, value) in combination + binary_ref = DP._indicator_to_binary(model)[indicator] + _fix_binary_at_supports( + fixing_constraint_refs, model, binary_ref, value + ) + end + model.ext[:_loa_fixing_constraint_refs] = fixing_constraint_refs +end + +function _fix_binary_at_supports( + _, + _, + binary_ref::InfiniteOpt.GeneralVariableRef, + value::Bool + ) + JuMP.fix(binary_ref, value ? 1.0 : 0.0; force = true) + return +end + +function _fix_binary_at_supports( + refs::AbstractVector, + model::InfiniteOpt.InfiniteModel, + binary_ref::InfiniteOpt.GeneralVariableRef, + values::AbstractVector{Bool} + ) + for (k, support) in enumerate(_supports_of(binary_ref)) + push!(refs, JuMP.@constraint(model, + binary_ref(support) == (values[k] ? 1.0 : 0.0))) + end + return +end + +function DP.set_start_values( + ::InfiniteOpt.InfiniteModel, + linearization_point::AbstractDict + ) + for (variable, values) in linearization_point + _set_starts_for_transcribed( + InfiniteOpt.transformation_variable(variable), + values + ) + end +end + +# Infinite var: per-support transcribed array +function _set_starts_for_transcribed( + transcribed::AbstractArray, + values::AbstractVector + ) + for (k, ref) in enumerate(vec(transcribed)) + JuMP.set_start_value(ref, values[k]) + end + return +end + +# Finite var: single transcribed ref, `values` is 1-element +_set_starts_for_transcribed( + transcribed::JuMP.AbstractVariableRef, + values::AbstractVector + ) = + JuMP.set_start_value(transcribed, values[1]) + +# Finite var: single transcribed ref, scalar value (feas-side path) +_set_starts_for_transcribed( + transcribed::JuMP.AbstractVariableRef, + value::Real + ) = + JuMP.set_start_value(transcribed, value) + +function DP.unfix_combination_binaries( + model::InfiniteOpt.InfiniteModel, + combination::AbstractDict + ) + if haskey(model.ext, :_loa_fixing_constraint_refs) + for fixing_ref in model.ext[:_loa_fixing_constraint_refs] + JuMP.is_valid(model, fixing_ref) && + JuMP.delete(model, fixing_ref) end - delete!(model.ext, :_loa_fix_crefs) + delete!(model.ext, :_loa_fixing_constraint_refs) end - for (ind, val) in combo - val isa Bool || continue - bv = DP._indicator_to_binary(model)[ind] - JuMP.is_fixed(bv) && JuMP.unfix(bv) + for (indicator, value) in combination + binary_ref = DP._indicator_to_binary(model)[indicator] + _unfix_binary_at_supports(binary_ref, value) end end -#Transcribe the BigM'd InfiniteModel, then hand off to the base -#`copy_model_with_constraints` on the flat model. `disjunct_crefs` -#are InfiniteModel-level refs; for the InfiniteOpt path we pass an -#empty Vector and the base includes every non-bound flat constraint -#(flat has no GDPData so no reformulation-constraint set to skip), -#giving us the whole transcribed+BigM'd model. Per-combo flat pruning -#is deferred. fwd_map is rekeyed to InfiniteModel vars for use by -#`read_*_solution`; obj_ref_map stays keyed by flat vars (the -#flat-level objective OA cut lives there). +# Bool: unfix the whole-var fix +function _unfix_binary_at_supports( + binary_ref::InfiniteOpt.GeneralVariableRef, + ::Bool + ) + JuMP.is_fixed(binary_ref) && JuMP.unfix(binary_ref) + return +end + +# Vector{Bool}: per-support fixing was via constraints already deleted above +_unfix_binary_at_supports( + ::InfiniteOpt.GeneralVariableRef, + ::AbstractVector{Bool} + ) = nothing + +# Build the LOA feasibility-restoration submodel as a fresh +# `InfiniteModel`: copy the structural skeleton, then copy every +# non-reformulation constraint plus the active disjuncts' +# `disjunct_constraint_refs`. `fwd_map` maps input InfiniteOpt vars +# to single feas-side InfiniteOpt vars; per-support handling falls +# through to the `GeneralVariableRef` dispatches above. function DP.copy_model_with_constraints( model::InfiniteOpt.InfiniteModel, - disjunct_crefs::Vector{<:DP.DisjunctConstraintRef}, + disjunct_constraint_refs::Vector{<:DP.DisjunctConstraintRef}, method::DP.LOA ) - InfiniteOpt.build_transformation_backend!(model) - flat = InfiniteOpt.transformation_model(model) - flat_crefs = DP.DisjunctConstraintRef{typeof(flat)}[] - base = DP.copy_model_with_constraints(flat, flat_crefs, method) - fwd_map = Dict{InfiniteOpt.GeneralVariableRef, Any}() - decision_vars = DP.collect_all_vars(model) + sub_model = InfiniteOpt.InfiniteModel() + JuMP.set_optimizer(sub_model, method.nlp_optimizer) + JuMP.set_silent(sub_model) + + ref_map = DP.copy_variables_onto_model(sub_model, model) + + variable_type = InfiniteOpt.GeneralVariableRef + reform_set = DP.is_gdp_model(model) ? + Set(DP._reformulation_constraints(model)) : Set() + for (F, S) in JuMP.list_of_constraint_types(model) + F === variable_type && continue + for cref in JuMP.all_constraints(model, F, S) + cref in reform_set && continue + con = JuMP.constraint_object(cref) + new_func = DP.replace_variables_in_constraint( + con.func, ref_map) + JuMP.@constraint(sub_model, new_func in con.set) + end + end + for cref in disjunct_constraint_refs + con = JuMP.constraint_object(cref) + new_func = DP.replace_variables_in_constraint( + con.func, ref_map) + JuMP.@constraint(sub_model, new_func in con.set) + end + + decision_vars = filter(!_is_point_var, DP.collect_all_vars(model)) + fwd_map = Dict{InfiniteOpt.GeneralVariableRef, + InfiniteOpt.GeneralVariableRef}() for v in decision_vars - fwd_map[v] = _tv_map(v, base.sub.fwd_map) + fwd_map[v] = ref_map[v] end - return (sub = DP.GDPSubmodel(base.sub.model, decision_vars, fwd_map), - obj_ref_map = base.sub.fwd_map) + return (sub = DP.GDPSubmodel(sub_model, decision_vars, fwd_map), + objective_ref_map = ref_map) end -#read per-support JuMP values from either a vector of flat vars or a -#single scalar. Dispatch handles both transformation_variable (may be -#N-dim) and fwd_map (already flat) shapes. -_read_values(v::AbstractArray) = Float64[JuMP.value(fv) for fv in vec(v)] -_read_values(v) = Float64(JuMP.value(v)) +# Convert an InfiniteModel-var-keyed per-support point into a +# transcribed-JuMP-var-keyed scalar point. Companion to +# `_transcribed_to_master_point`: feeds the AD walker for the +# objective OA cut. See the WHY note above +# `_transcribed_to_master_point` for the MeasureRef/AD constraint +# that motivates this whole transcribe-then-AD layer. +function _transcribe_linearization_point( + model::InfiniteOpt.InfiniteModel, + linearization_point::AbstractDict + ) + T = eltype(valtype(linearization_point)) + transcribed = Dict{JuMP.VariableRef, T}() + for (variable, values) in linearization_point + _add_to_transcribed_dict( + transcribed, + InfiniteOpt.transformation_variable(variable), + values + ) + end + return transcribed +end -#extract per-support x-values from the InfiniteModel NLP; objective -#x-values are keyed by the flat transcription vars for the objective cut -function DP.read_primary_solution(model::InfiniteOpt.InfiniteModel) - x_vals = Dict{JuMP.AbstractVariableRef, Any}() - for v in DP.collect_all_vars(model) - JuMP.is_fixed(v) && continue - x_vals[v] = _read_values(InfiniteOpt.transformation_variable(v)) +# Infinite var: per-support transcribed array, values per-support +function _add_to_transcribed_dict( + d::AbstractDict, + ts::AbstractArray, + values::AbstractVector + ) + for (k, ref) in enumerate(vec(ts)) + d[ref] = values[k] end - return x_vals, _flat_xk(model, x_vals) + return end -#extract per-support x-values from the flat feas submodel. x_vals keys are -#InfiniteModel vars (via fwd_map); obj_x_values keys are flat vars. +# Finite var: single transcribed ref, values 1-element +_add_to_transcribed_dict( + d::AbstractDict, + ts::JuMP.AbstractVariableRef, + values::AbstractVector + ) = + (d[ts] = values[1]; nothing) + +# Finite var: single transcribed ref, scalar value (feas-side path) +_add_to_transcribed_dict( + d::AbstractDict, + ts::JuMP.AbstractVariableRef, + value::Real + ) = + (d[ts] = value; nothing) + +# Extract per-support x-values from the InfiniteModel NLP. The +# first return is keyed on InfiniteModel vars (used for disjunct +# OA cuts via `cut_info`). The second return is what the objective +# OA cut consumes: when the objective is aggregate-free we hand +# back the same dict (objective AD walks InfiniteOpt vars +# directly); when it has aggregates we transcribe so the flat +# objective's AD pipeline can read it. +function DP.read_primary_solution( + model::InfiniteOpt.InfiniteModel + ) + T = JuMP.value_type(typeof(model)) + linearization_point = Dict{ + InfiniteOpt.GeneralVariableRef, Vector{T} + }( + variable => _per_support_values(variable) + for variable in DP.collect_all_vars(model) + if !JuMP.is_fixed(variable)) + return linearization_point, + _objective_linearization_point(model, linearization_point) +end + +# Same split as `read_primary_solution`: InfiniteOpt-keyed dict for +# disjunct cuts, plus the matching objective-side point. function DP.read_feas_solution( - model::InfiniteOpt.InfiniteModel, feas + model::InfiniteOpt.InfiniteModel, feas::NamedTuple + ) + linearization_point = DP.extract_solution(feas.sub) + return linearization_point, + _objective_linearization_point(model, linearization_point) +end + +# Bridge `linearization_point` (InfiniteOpt-keyed, per-support +# `Vector` values) to the scalar-valued dict the base +# `_linearize_at` AD pipeline expects. Aggregate objectives go +# through transcription (flat JuMP-keyed scalar dict). +# Aggregate-free objectives use only finite vars; we keep the +# InfiniteOpt keys and collapse the 1-element per-support vector +# to a scalar so AD reads `xk[v]` as a number. +function _objective_linearization_point( + model::InfiniteOpt.InfiniteModel, + linearization_point::AbstractDict ) - x_vals = DP.extract_solution(feas.sub) - obj_xv = Dict{JuMP.VariableRef, Float64}() - for (flat_v, feas_v) in feas.obj_ref_map - obj_xv[flat_v] = Float64(JuMP.value(feas_v)) + if DP.has_aggregate_ref(JuMP.objective_function(model)) + return _transcribe_linearization_point( + model, linearization_point) + end + T = eltype(valtype(linearization_point)) + scalar_point = Dict{InfiniteOpt.GeneralVariableRef, T}() + for (variable, values) in linearization_point + isempty(InfiniteOpt.parameter_refs(variable)) || continue + scalar_point[variable] = values[1] end - return x_vals, obj_xv + return scalar_point end end diff --git a/src/loa.jl b/src/loa.jl index 0c4fdc65..eac14ccf 100644 --- a/src/loa.jl +++ b/src/loa.jl @@ -6,6 +6,9 @@ # Viswanathan & Grossmann (1990), Comp. & Chem. Eng. 14(7), 769-782 ################################################################################ +################################################################################ +# METHOD TYPE +################################################################################ """ LOA{O, P} <: AbstractReformulationMethod @@ -25,9 +28,12 @@ struct LOA{O, P} <: AbstractReformulationMethod function LOA( nlp_optimizer::O; mip_optimizer::P = nlp_optimizer, - max_iter::Int = 10, atol::Float64 = 1e-6, - rtol::Float64 = 1e-4, M_value::Float64 = 1e9, - max_slack::Float64 = 1000.0, OA_penalty_factor::Float64 = 1000.0 + max_iter::Int = 10, + atol::Float64 = 1e-6, + rtol::Float64 = 1e-4, + M_value::Float64 = 1e9, + max_slack::Float64 = 1000.0, + OA_penalty_factor::Float64 = 1000.0 ) where {O, P} new{O, P}(nlp_optimizer, mip_optimizer, max_iter, atol, rtol, M_value, max_slack, OA_penalty_factor) @@ -35,448 +41,410 @@ struct LOA{O, P} <: AbstractReformulationMethod end ################################################################################ -# MAIN ALGORITHM +# SENSE PRIMITIVES +################################################################################ +# Val(MIN/MAX)-dispatched primitives so the algorithm reads sense-agnostic. +_disjunct_cut_coefficients(::Val{_MOI.MIN_SENSE}) = (-1, 1) +_disjunct_cut_coefficients(::Val{_MOI.MAX_SENSE}) = (1, -1) +_worst_objective(::Val{_MOI.MIN_SENSE}) = Inf +_worst_objective(::Val{_MOI.MAX_SENSE}) = -Inf +_is_better(::Val{_MOI.MIN_SENSE}, new, best) = new < best +_is_better(::Val{_MOI.MAX_SENSE}, new, best) = new > best +_gap(::Val{_MOI.MIN_SENSE}, best, bound) = best - bound +_gap(::Val{_MOI.MAX_SENSE}, best, bound) = bound - best +_flip_sense(::Val{_MOI.MIN_SENSE}) = Val(_MOI.MAX_SENSE) +_flip_sense(::Val{_MOI.MAX_SENSE}) = Val(_MOI.MIN_SENSE) + +################################################################################ +# MAIN ALGORITHM ################################################################################ function reformulate_model(model::JuMP.AbstractModel, method::LOA) _clear_reformulations(model) - combos = _set_covering_combos(model) + combinations = _set_covering_combinations(model) reformulate_model(model, BigM(method.M_value)) master = build_loa_master(model, method) - reform_map = _build_reform_map(model) + reformulation_map = _build_reformulation_map(model) JuMP.set_optimizer(model, method.nlp_optimizer) JuMP.set_silent(model) - + # Cached for repeated use in `_worst_objective`, `_is_better`, + # `_flip_sense`, and `_loa_converged`. sense_token = Val(JuMP.objective_sense(model)) - best_obj = _worst_obj(sense_token) - is_better(o) = _is_better(sense_token, o, best_obj) + best_objective = _worst_objective(sense_token) + is_better(candidate) = _is_better(sense_token, candidate, best_objective) best_result = nothing - #Initialization Procedure (Türkay & Grossmann 1996 §2.2): solve the - #set-covering NLPs to seed the master with at least one OA cut per - #disjunct before the main iteration. - for combo in combos - result = _solve_nlp(model, combo, method, reform_map) - _add_no_good_cut(model, master, combo) + # Initialization Procedure (Türkay & Grossmann 1996, sec. 2.2): solve + # the set-covering NLPs to seed the master with at least one OA cut + # per disjunct before the main iteration. + for combination in combinations + result = _solve_nlp(model, combination, method, reformulation_map) + avoid_combination(master.model, combination, master.binary_map) _add_oa_cuts(model, master, result, method) if result.feasible && is_better(result.objective) - best_obj = result.objective + best_objective = result.objective best_result = result end end - master_bound = _worst_obj(_flip_sense(sense_token)) + master_bound = _worst_objective(_flip_sense(sense_token)) for iter in 1:method.max_iter JuMP.optimize!(master.model) JuMP.is_solved_and_feasible(master.model) || break master_bound = JuMP.objective_value(master.model) - _loa_converged(best_obj, master_bound, sense_token, method) && break - combo = _extract_combo(model, master) - result = _solve_nlp(model, combo, method, reform_map) - _add_no_good_cut(model, master, combo) + _loa_converged(best_objective, master_bound, sense_token, method) && break + combination = _extract_combination(model, master) + result = _solve_nlp(model, combination, method, reformulation_map) + avoid_combination(master.model, combination, master.binary_map) _add_oa_cuts(model, master, result, method) if result.feasible && is_better(result.objective) - best_obj = result.objective + best_objective = result.objective best_result = result end end - _finalize_model(model, best_result) + if best_result !== nothing + fix_combination_binaries(model, best_result.combination) + set_start_values(model, best_result.linearization_point) + end _set_solution_method(model, method) _set_ready_to_optimize(model, true) return end -#fix best combo permanently and set start values -function _finalize_model(model, best_result) - best_result === nothing && return - fix_combo_binaries(model, best_result.combo) - for (var, v) in best_result.x_values - JuMP.is_valid(model, var) || continue - v isa Number || continue - JuMP.set_start_value(var, v) - end +# Convergence: absolute gap ≤ atol or relative gap ≤ rtol. Distinct +# from CP's `separation_obj ≤ tolerance` check (different convergence +# shapes — gap vs. single-threshold), so not shared. +function _loa_converged( + best_objective::Real, + master_bound::Real, + sense_token::Val, + method::LOA + ) + (isinf(best_objective) || isinf(master_bound)) && return false + gap = _gap(sense_token, best_objective, master_bound) + gap <= method.atol && return true + abs(best_objective) > 1e-10 && + gap / abs(best_objective) <= method.rtol && return true + return false +end + +# Error fallback for unsupported model types. +function reformulate_model(::M, ::LOA) where {M} + error("reformulate_model not implemented for model type `$(M)` with LOA.") end ################################################################################ -# EXTENSION POINTS +# SET-COVERING INITIALIZATION ################################################################################ -""" - build_loa_master(model::JuMP.AbstractModel, method::LOA) - -Build the LOA master MILP as a deep copy of the BigM-reformulated -`model`, install the `alpha_oa` objective auxiliary, and wire up -`method.mip_optimizer`. OA and no-good cuts are added to the returned -master each iteration of the main LOA loop. - -## Returns -- `NamedTuple` with `model` (master MILP), `bin_map` - (indicator→binary), `var_map` (original→master var), - `obj_sense`, `orig_obj`, `alpha_oa`, and `obj_ref_map` - (objective-side map for linearization). -""" +# Türkay & Grossmann (1996), sec. 2.2: produce a minimal set of +# combinations that activates every indicator at least once, so the +# master starts with an OA cut per disjunct. Nested disjunctions are +# enumerated alongside top-level ones — inconsistent combinations +# (nested active under inactive parent) are handled by feasibility +# restoration at NLP-solve time. +# +# `K = max(disjunction sizes)` combinations suffice: combination `k` +# activates the `k`-th indicator of each disjunction, cycling via +# `mod1` for disjunctions shorter than `K`. Every indicator gets +# activated at least once over k=1..K. +# +# Note: keeps the less-specific `model::JuMP.AbstractModel` +# signature instead of `model::M where {M <: JuMP.AbstractModel}` to +# avoid ambiguity with InfiniteOpt overrides; this function is +# internal so no public-API impact. +function _set_covering_combinations(model::JuMP.AbstractModel) + LogicalRef = LogicalVariableRef{typeof(model)} + indicator_lists = [collect(d.constraint.indicators) + for (_, d) in _disjunctions(model)] + isempty(indicator_lists) && return Dict{LogicalRef, Bool}[] + K = maximum(length, indicator_lists) + return [ + Dict{LogicalRef, Bool}( + indicator => (indicator == indicators[mod1(k, length(indicators))]) + for indicators in indicator_lists + for indicator in indicators) + for k in 1:K + ] +end + +################################################################################ +# MASTER CONSTRUCTION +################################################################################ +# True for linear constraint function types (scalar/vector variable refs +# and affine expressions). LOA master per Türkay & Grossmann (1996) is +# pure MILP; nonlinear `f`, `g`, `h_{ij}` enter as OA cuts after each +# NLP solve. +_is_linear_F(::Type{<:JuMP.AbstractVariableRef}) = true +_is_linear_F(::Type{<:JuMP.GenericAffExpr}) = true +_is_linear_F(::Type{<:AbstractVector{<:JuMP.AbstractVariableRef}}) = true +_is_linear_F(::Type{<:AbstractVector{<:JuMP.GenericAffExpr}}) = true +_is_linear_F(::Type) = false + +# OVERRIDABLE. Build the LOA master MILP `M^b_{LA}` (Türkay & Grossmann +# 1996, eq. 12): copy decision variables and only the linear +# constraints, install `alpha_oa` as the objective auxiliary. Nonlinear +# objective and disjunct constraints enter as OA cuts after each NLP +# solve. Returns a NamedTuple of (model, binary_map, variable_map, +# objective_sense, original_objective, alpha_oa, objective_ref_map). function build_loa_master(model::JuMP.AbstractModel, method::LOA) - orig_obj = JuMP.objective_function(model) - master, copy_map = JuMP.copy_model(model) + original_objective = JuMP.objective_function(model) + objective_sense = JuMP.objective_sense(model) + variable_type = JuMP.variable_ref_type(typeof(model)) + + master = _copy_model(model) JuMP.set_optimizer(master, method.mip_optimizer) JuMP.set_silent(master) - bin_map = Dict{LogicalVariableRef, Any}() - for (ind, bv) in _indicator_to_binary(model) - bin_map[ind] = copy_map[bv] - end - #replace objective with alpha_oa; OA cuts are added each iteration - obj_sense = JuMP.objective_sense(master) - alpha_oa = JuMP.@variable(master, base_name = "alpha_oa") - JuMP.@objective(master, obj_sense, alpha_oa) - return (model = master, bin_map = bin_map, var_map = copy_map, - obj_sense = obj_sense, orig_obj = orig_obj, - alpha_oa = alpha_oa, obj_ref_map = copy_map) -end -""" - copy_model_with_constraints( - model::JuMP.AbstractModel, - disjunct_crefs::Vector{<:DisjunctConstraintRef}, - method::LOA - ) + variable_map = copy_variables_onto_model(master, model) -Build a raw feasibility-restoration submodel by deep-copying `model`'s -decision variables, then including only the problem's global -constraints (those not added by BigM reformulation) and the original -pre-BigM constraints of `disjunct_crefs`. The shared slack `u` and -`min u` objective are applied separately by [`_embed_feas_slack`](@ref). -Mirrors MBM's subset-taking [`copy_model_with_constraints`](@ref). - -## Returns -- `NamedTuple` with `sub::GDPSubmodel` (copied model and - original→copy forward map) and `obj_ref_map` (objective-side - reference map for linearizing the original objective at the feas - point). -""" -function copy_model_with_constraints( - model::JuMP.AbstractModel, - disjunct_crefs::Vector{<:DisjunctConstraintRef}, - method::LOA - ) - V = JuMP.variable_ref_type(model) - sub_model = _copy_model(model) - decision_vars = collect_all_vars(model) - fwd_map = Dict{V, V}() - for var in decision_vars - fwd_map[var] = variable_copy(sub_model, var) - end - VT = JuMP.variable_ref_type(typeof(model)) - reform_set = is_gdp_model(model) ? - Set(_reformulation_constraints(model)) : Set() for (F, S) in JuMP.list_of_constraint_types(model) - F === VT && continue - for cref in JuMP.all_constraints(model, F, S) - cref in reform_set && continue - con = JuMP.constraint_object(cref) - expr = _replace_variables_in_constraint(con.func, fwd_map) - JuMP.@constraint(sub_model, expr in con.set) - end - end - for cref in disjunct_crefs - con = JuMP.constraint_object(cref) - expr = _replace_variables_in_constraint(con.func, fwd_map) - JuMP.@constraint(sub_model, expr in con.set) - end - JuMP.set_optimizer(sub_model, method.nlp_optimizer) - JuMP.set_silent(sub_model) - return (sub = GDPSubmodel(sub_model, decision_vars, fwd_map), - obj_ref_map = fwd_map) -end - -# Convert the raw copy from `copy_model_with_constraints` into a V&G 1990 -# NLPF problem: shared slack `u` embedded in every constraint via -# `_slacken`, integrality relaxed, `min u` as the objective. -function _embed_feas_slack(feas) - m = feas.sub.model - u = JuMP.@variable(m, base_name = "_loa_u", lower_bound = 0.0) - VT = JuMP.variable_ref_type(typeof(m)) - to_slacken = Any[] - for (F, S) in JuMP.list_of_constraint_types(m) - F === VT && continue - for cref in JuMP.all_constraints(m, F, S) - push!(to_slacken, cref) - end - end - for cref in to_slacken - JuMP.is_valid(m, cref) || continue - con = JuMP.constraint_object(cref) - for (sf, ss) in _slacken(con.func, con.set, u) - JuMP.@constraint(m, sf in ss) + F === variable_type && continue + _is_linear_F(F) || continue + for constraint_ref in JuMP.all_constraints(model, F, S) + constraint = JuMP.constraint_object(constraint_ref) + new_func = replace_variables_in_constraint( + constraint.func, variable_map) + JuMP.@constraint(master, new_func in constraint.set) end - JuMP.delete(m, cref) end - JuMP.relax_integrality(m) - JuMP.@objective(m, Min, u) - return -end - -""" - fix_combo_binaries(model::JuMP.AbstractModel, combo)::Nothing -Fix every indicator binary in `combo` to its active / inactive value -on `model`. Complement indicators (stored as `1 - other_bv`) are -handled by fixing the underlying variable to the complement value via -[`fix_fv`](@ref). -""" -function fix_combo_binaries(model, combo) - for (ind, active) in combo - fix_fv(_indicator_to_binary(model)[ind], active) + binary_map = Dict{LogicalVariableRef, Any}() + for (indicator, binary_ref) in _indicator_to_binary(model) + binary_map[indicator] = _remap_indicator_to_binary( + binary_ref, variable_map) end -end -""" - unfix_combo_binaries(model::JuMP.AbstractModel, combo)::Nothing - -Undo the effect of [`fix_combo_binaries`](@ref): unfix every indicator -binary in `combo` on `model`. -""" -function unfix_combo_binaries(model, combo) - for (ind, _) in combo - unfix_fv(_indicator_to_binary(model)[ind]) - end -end + alpha_oa = JuMP.@variable(master, base_name = "alpha_oa") + JuMP.@objective(master, objective_sense, alpha_oa) -################################################################################ -# SET COVERING INITIALIZATION -################################################################################ -#Türkay & Grossmann (1996) §2.2: pick a minimal set of combos that -#activates every indicator at least once, so the master starts with an -#OA cut for each disjunct. We enumerate nested disjunctions alongside -#top-level ones — inconsistent combos (nested active under inactive -#parent) are handled by feasibility restoration at NLP-solve time. -function _set_covering_combos(model::JuMP.AbstractModel) - M = typeof(model) - LVR = LogicalVariableRef{M} - per_disj = Vector{Tuple{DisjunctionIndex, LVR}}[] - for (idx, disj_data) in _disjunctions(model) - push!(per_disj, [(idx, ind) for ind in disj_data.constraint.indicators]) - end - isempty(per_disj) && return Dict{LVR, Bool}[] - all_combos = [Tuple{DisjunctionIndex, LVR}[c...] - for c in Iterators.product(per_disj...)] - uncovered = Set{LVR}() - for group in per_disj - for (_, ind) in group - push!(uncovered, ind) - end - end - selected = Dict{LVR, Bool}[] - while !isempty(uncovered) - best_combo = nothing - best_count = 0 - for combo in all_combos - cnt = sum(ind in uncovered for (_, ind) in combo) - if cnt > best_count - best_count = cnt - best_combo = combo - end - end - best_combo === nothing && break - combo_dict = Dict{LVR, Bool}() - for (disj_idx, active_ind) in best_combo - disj_data = _disjunctions(model)[disj_idx] - for ind in disj_data.constraint.indicators - combo_dict[ind] = (ind == active_ind) - end - end - push!(selected, combo_dict) - for (_, active_ind) in best_combo - delete!(uncovered, active_ind) - end - end - return selected + return (model = master, binary_map = binary_map, + variable_map = variable_map, objective_sense = objective_sense, + original_objective = original_objective, alpha_oa = alpha_oa, + objective_ref_map = variable_map) end ################################################################################ -# NLP SUBPROBLEM +# NLP SUBPROBLEM ################################################################################ -# Solve the primary NLP for a fixed combo. If infeasible, build a fresh -# V&G 1990 NLPF submodel (globals + active-disjunct originals, with -# shared slack `u` and `min u` objective), solve it for a least- -# infeasible point to generate OA cuts from, then discard it. Returns -# a named tuple with -# `combo / x_values / duals / objective / feasible / obj_x_values`. +# Solve the primary NLP for a fixed combination. If feasible, read the +# primal point, duals, and objective and return. If infeasible, build a +# fresh V&G 1990 NLPF submodel (active-disjunct originals + globals, +# slackened with shared `u` and `min u` objective), solve it for a +# least-infeasible point, and return that. The NLPF is discarded. function _solve_nlp( - model::M, combo, method::LOA, reform_map + model::M, combination, method::LOA, reformulation_map ) where {M <: JuMP.AbstractModel} - DCRef = DisjunctConstraintRef{M} - empty_duals = Dict{DCRef, Any}() - fix_combo_binaries(model, combo) + fix_combination_binaries(model, combination) JuMP.optimize!(model, ignore_optimize_hook = true) if JuMP.is_solved_and_feasible(model) - x_vals, obj_xv = read_primary_solution(model) - duals = _collect_nlp_duals(model, combo, reform_map) - obj_val = JuMP.objective_value(model) - unfix_combo_binaries(model, combo) - return (combo = combo, x_values = x_vals, duals = duals, - objective = obj_val, feasible = true, obj_x_values = obj_xv) + lin_point, obj_lin_point = read_primary_solution(model) + duals = _collect_nlp_duals(model, combination, reformulation_map) + objective_val = JuMP.objective_value(model) + unfix_combination_binaries(model, combination) + return (combination = combination, + linearization_point = lin_point, duals = duals, + objective = objective_val, feasible = true, + objective_linearization_point = obj_lin_point) end - unfix_combo_binaries(model, combo) - # Build a fresh per-combo NLPF: only the active-disjunct originals - # + globals, slackened with a shared `u`. Discarded after solve. - active_crefs = _active_disjunct_crefs(model, combo) - feas = copy_model_with_constraints(model, active_crefs, method) + unfix_combination_binaries(model, combination) + active_refs = _active_disjunct_constraint_refs(model, combination) + feas = copy_model_with_constraints(model, active_refs, method) _embed_feas_slack(feas) - feas_fwd = feas.sub.fwd_map - for (ind, val) in combo - orig_bv = _indicator_to_binary(model)[ind] - haskey(feas_fwd, orig_bv) || continue - fix_fv(feas_fwd[orig_bv], val) + for (indicator, value) in combination + orig_binary_ref = _indicator_to_binary(model)[indicator] + fix_indicator(_remap_indicator_to_binary( + orig_binary_ref, feas.sub.fwd_map), value) end JuMP.optimize!(feas.sub.model) - feas_ok = JuMP.is_solved_and_feasible(feas.sub.model) - x_vals = feas_ok ? first(read_feas_solution(model, feas)) : + lin_point = JuMP.is_solved_and_feasible(feas.sub.model) ? + first(read_feas_solution(model, feas)) : Dict{JuMP.AbstractVariableRef, Any}() - return (combo = combo, x_values = x_vals, duals = empty_duals, - objective = Inf, feasible = false, obj_x_values = x_vals) + return (combination = combination, + linearization_point = lin_point, + duals = Dict{DisjunctConstraintRef{M}, Any}(), + objective = Inf, feasible = false, + objective_linearization_point = lin_point) end -# Collect the `DisjunctConstraintRef`s of every active indicator in -# `combo`. Used to feed `copy_model_with_constraints` the minimal -# per-combo subset for NLPF construction. -function _active_disjunct_crefs(model::M, combo) where {M} - crefs = DisjunctConstraintRef{M}[] - for (ind, active) in combo +# `DisjunctConstraintRef`s of every active indicator in `combination`. +function _active_disjunct_constraint_refs( + model::M, + combination::AbstractDict + ) where {M <: JuMP.AbstractModel} + refs = DisjunctConstraintRef{M}[] + for (indicator, active) in combination any_active(active) || continue - haskey(_indicator_to_constraints(model), ind) || continue - for cref in _indicator_to_constraints(model)[ind] - cref isa DisjunctConstraintRef || continue - push!(crefs, cref) + haskey(_indicator_to_constraints(model), indicator) || continue + for cref in _indicator_to_constraints(model)[indicator] + cref isa DisjunctConstraintRef && push!(refs, cref) end end - return crefs + return refs end -# Sum the duals of BigM-reformulated constraints for each active -# disjunct's original constraint ref. Used for OA cut generation. -function _collect_nlp_duals( - model::M, combo, reform_map - ) where {M <: JuMP.AbstractModel} - duals = Dict{DisjunctConstraintRef{M}, Any}() - JuMP.has_duals(model) || return duals - for (ind, active) in combo - active || continue - haskey(_indicator_to_constraints(model), ind) || continue - for cref in _indicator_to_constraints(model)[ind] - cref isa DisjunctConstraintRef || continue - duals[cref] = _sum_duals(reform_map, cref) - end +# OVERRIDABLE. Fix every indicator in `combination` on `model`. +function fix_combination_binaries( + model::JuMP.AbstractModel, + combination::AbstractDict + ) + for (indicator, active) in combination + fix_indicator(model, indicator, active) end - return duals end -""" - read_primary_solution(model::JuMP.AbstractModel)::Tuple{Dict, Dict} - -Read the primal solution of the primary NLP after a feasible solve. -Returns `(x_values, obj_x_values)` where both dicts are keyed by -variable reference; `obj_x_values` matches `x_values` for finite -models. The InfiniteOpt extension overrides this to return the -flat-transcription dict as the second element. -""" -function read_primary_solution(model::JuMP.AbstractModel) - x_vals = Dict{JuMP.AbstractVariableRef, Any}() - for v in JuMP.all_variables(model) - JuMP.is_fixed(v) && continue - x_vals[v] = JuMP.value(v) +# OVERRIDABLE. Inverse of `fix_combination_binaries`. +function unfix_combination_binaries( + model::JuMP.AbstractModel, + combination::AbstractDict + ) + for (indicator, _) in combination + unfix_indicator(model, indicator) end - return x_vals, x_vals end -""" - read_feas_solution( - model::JuMP.AbstractModel, feas - )::Tuple{Dict, Dict} - -Read the primal solution of the feasibility-restoration NLPF after a -feasible solve, keyed by original-model variables via `extract_solution` -on `feas.sub`. Same return contract as [`read_primary_solution`](@ref). -""" -function read_feas_solution(model::JuMP.AbstractModel, feas) - x_vals = extract_solution(feas.sub) - return x_vals, x_vals +# OVERRIDABLE. Build a raw V&G 1990 NLPF submodel: copy decision +# variables, copy global (non-reformulation) constraints and the active +# disjuncts' original pre-BigM constraints. The shared slack `u` and +# `min u` objective are layered on by `_embed_feas_slack`. Returns +# `(sub::GDPSubmodel, objective_ref_map)`. +function copy_model_with_constraints( + model::JuMP.AbstractModel, + disjunct_constraint_refs::Vector{<:DisjunctConstraintRef}, + method::LOA + ) + sub_model = _copy_model(model) + fwd_map = copy_variables_onto_model(sub_model, model) + variable_type = JuMP.variable_ref_type(typeof(model)) + reform_set = is_gdp_model(model) ? + Set(_reformulation_constraints(model)) : Set() + for (F, S) in JuMP.list_of_constraint_types(model) + F === variable_type && continue + for cref in JuMP.all_constraints(model, F, S) + cref in reform_set && continue + con = JuMP.constraint_object(cref) + expr = replace_variables_in_constraint(con.func, fwd_map) + JuMP.@constraint(sub_model, expr in con.set) + end + end + for cref in disjunct_constraint_refs + con = JuMP.constraint_object(cref) + expr = replace_variables_in_constraint(con.func, fwd_map) + JuMP.@constraint(sub_model, expr in con.set) + end + JuMP.set_optimizer(sub_model, method.nlp_optimizer) + JuMP.set_silent(sub_model) + return ( + sub = GDPSubmodel(sub_model, collect_all_vars(model), fwd_map), + objective_ref_map = fwd_map + ) end -""" - fix_fv(bv, val::Bool)::Nothing - -Fix a binary indicator reference `bv` to `val`. Dispatches on: -- `AbstractVariableRef`: calls `JuMP.fix(bv, val ? 1.0 : 0.0; force = true)`. -- `GenericAffExpr`: the complement-indicator form `1 - other_bv`; fix - `other_bv` to the complement of `val`. -- `AbstractArray`: iterate elementwise over `bvs` (and `vals` if also - array). Used by per-support InfiniteOpt indicators. -""" -fix_fv(bv, val::Bool) = JuMP.fix(bv, val ? 1.0 : 0.0; force = true) -function fix_fv(bv::JuMP.GenericAffExpr, val::Bool) - under, coeff = only(bv.terms) - JuMP.fix(under, val ? 0.0 : 1.0; force = true) +# Convert the raw copy into a V&G 1990 NLPF problem: shared slack +# embedded in every constraint via `_slacken`, `min slack` objective. +function _embed_feas_slack(feas::NamedTuple) + model = feas.sub.model + slack = JuMP.@variable(model, base_name = "_feas_slack", lower_bound = 0.0) + variable_type = JuMP.variable_ref_type(typeof(model)) + to_slacken = Any[] + for (F, S) in JuMP.list_of_constraint_types(model) + F === variable_type && continue + append!(to_slacken, JuMP.all_constraints(model, F, S)) + end + for constraint_ref in to_slacken + constraint = JuMP.constraint_object(constraint_ref) + for (slack_func, slack_set) in _slacken( + constraint.func, constraint.set, slack) + JuMP.@constraint(model, slack_func in slack_set) + end + JuMP.delete(model, constraint_ref) + end + JuMP.@objective(model, Min, slack) + return end -fix_fv(bvs::AbstractArray, val::Bool) = - (for bv in bvs; fix_fv(bv, val); end; return) -fix_fv(bvs::AbstractArray, vals::AbstractArray) = - (for (bv, v) in zip(bvs, vals); fix_fv(bv, v); end; return) -""" - unfix_fv(bv)::Nothing +# Apply slack `u` to a constraint based on its set type. ≤: `f − u`; +# ≥: `f + u`; ==: split into ≤ and ≥; otherwise passthrough. +_slacken(f, set::_MOI.LessThan, u) = [(f - u, set)] +_slacken(f, set::_MOI.GreaterThan, u) = [(f + u, set)] +function _slacken(f, set::_MOI.EqualTo, u) + b = _MOI.constant(set) + return [(f - u, _MOI.LessThan(b)), (f + u, _MOI.GreaterThan(b))] +end +_slacken(f, set, u) = [(f, set)] -Undo [`fix_fv`](@ref) on `bv`. No-op if `bv` is not currently fixed. -For complement AffExprs, unfixes the underlying variable. -""" -function unfix_fv(bv) - JuMP.is_fixed(bv) && JuMP.unfix(bv) - return +# OVERRIDABLE. Read the primal NLP solution. Returns +# `(linearization_point, objective_linearization_point)`; both dicts +# are equal here. InfiniteOpt overrides to return a transcribed scalar +# dict for the second element when needed. +function read_primary_solution(model::JuMP.AbstractModel) + linearization_point = Dict{JuMP.AbstractVariableRef, Any}() + for variable in JuMP.all_variables(model) + JuMP.is_fixed(variable) && continue + linearization_point[variable] = JuMP.value(variable) + end + return linearization_point, linearization_point end -function unfix_fv(bv::JuMP.GenericAffExpr) - under = only(keys(bv.terms)) - JuMP.is_fixed(under) && JuMP.unfix(under) - return + +# OVERRIDABLE. As `read_primary_solution`, but for the V&G +# feasibility-restoration submodel; values keyed back to original-model +# vars via `feas.sub`. +function read_feas_solution(model::JuMP.AbstractModel, feas::NamedTuple) + linearization_point = extract_solution(feas.sub) + return linearization_point, linearization_point end -unfix_fv(bvs::AbstractArray) = - (for bv in bvs; unfix_fv(bv); end; return) ################################################################################ -# REFORM MAP (BigM constraint index) +# BIGM DUAL COLLECTION ################################################################################ -function _build_reform_map(model::M) where {M <: JuMP.AbstractModel} +# Build a map `DisjunctConstraintRef → Vector{}` so +# `_collect_nlp_duals` can sum BigM duals per original disjunct +# constraint. Uses `_num_reform_constraints` to slice the flat +# reformulation-constraint list per original. (Brittle: assumes BigM +# emits constraints in disjunction-iteration order; a cleaner +# alternative would be to have BigM record this map directly.) +function _build_reformulation_map(model::M) where {M <: JuMP.AbstractModel} ref_cons = _reformulation_constraints(model) isempty(ref_cons) && return Dict{DisjunctConstraintRef{M}, Vector{Any}}() CRT = eltype(ref_cons) rmap = Dict{DisjunctConstraintRef{M}, Vector{CRT}}() - idx = 1 - for (_, disj_data) in _disjunctions(model) - for ind in disj_data.constraint.indicators - haskey(_indicator_to_constraints(model), ind) || continue - for cref in _indicator_to_constraints(model)[ind] - cref isa DisjunctConstraintRef || continue - con = _disjunct_constraints(model)[JuMP.index(cref)].constraint - n = _num_reform_cons(con) - idx + n - 1 <= length(ref_cons) || break - rmap[cref] = collect(ref_cons[idx:idx + n - 1]) - idx += n + cursor = 1 + for (_, disjunction_data) in _disjunctions(model) + for indicator in disjunction_data.constraint.indicators + haskey(_indicator_to_constraints(model), indicator) || continue + for constraint_ref in _indicator_to_constraints(model)[indicator] + constraint_ref isa DisjunctConstraintRef || continue + constraint = _disjunct_constraints(model)[ + JuMP.index(constraint_ref)].constraint + num_reform = _num_reform_constraints(constraint) + cursor + num_reform - 1 <= length(ref_cons) || break + rmap[constraint_ref] = collect( + ref_cons[cursor:cursor + num_reform - 1]) + cursor += num_reform end end end return rmap end -_num_reform_cons(::JuMP.ScalarConstraint{T, S}) where { +# Number of BigM-reformulated JuMP constraints per original constraint. +_num_reform_constraints(::JuMP.ScalarConstraint{T, S}) where { T <: Union{Number, JuMP.AbstractJuMPScalar}, S <: Union{_MOI.EqualTo, _MOI.Interval}} = 2 -_num_reform_cons(::JuMP.ScalarConstraint) = 1 -_num_reform_cons(con::JuMP.VectorConstraint{T, S}) where { +_num_reform_constraints(::JuMP.ScalarConstraint) = 1 +_num_reform_constraints(constraint::JuMP.VectorConstraint{T, S}) where { T <: Union{Number, JuMP.AbstractJuMPScalar}, - S <: _MOI.Zeros} = 2 * _MOI.dimension(con.set) -_num_reform_cons(con::JuMP.VectorConstraint) = _MOI.dimension(con.set) - -function _sum_duals(reform_map, cref) - haskey(reform_map, cref) || return 0.0 - rcs = reform_map[cref] + S <: _MOI.Zeros} = 2 * _MOI.dimension(constraint.set) +_num_reform_constraints(constraint::JuMP.VectorConstraint) = + _MOI.dimension(constraint.set) + +# Sum the duals of all reformulation constraints for one disjunct cref. +function _sum_duals( + reformulation_map::AbstractDict, + constraint_ref::DisjunctConstraintRef + ) + haskey(reformulation_map, constraint_ref) || return 0.0 + rcs = reformulation_map[constraint_ref] isempty(rcs) && return 0.0 total = JuMP.dual(rcs[1]) for i in 2:length(rcs) @@ -485,231 +453,191 @@ function _sum_duals(reform_map, cref) return total end -################################################################################ -# COMBO EXTRACTION + CUTS -################################################################################ -#extract combo from master solution. `combo_val` dispatches on scalar vs -#array bin_map values (extension adds the array method for per-support). -function _extract_combo( - model::M, master +# Sum BigM-reformulation duals per active disjunct constraint ref. +function _collect_nlp_duals( + model::M, + combination::AbstractDict, + reformulation_map::AbstractDict ) where {M <: JuMP.AbstractModel} - combo = Dict{LogicalVariableRef{M}, Any}() - for (_, disj_data) in _disjunctions(model) - for ind in disj_data.constraint.indicators - haskey(master.bin_map, ind) || continue - combo[ind] = combo_val(master.bin_map[ind]) + duals = Dict{DisjunctConstraintRef{M}, Any}() + JuMP.has_duals(model) || return duals + for (indicator, active) in combination + any_active(active) || continue + haskey(_indicator_to_constraints(model), indicator) || continue + for cref in _indicator_to_constraints(model)[indicator] + cref isa DisjunctConstraintRef || continue + duals[cref] = _sum_duals(reformulation_map, cref) end end - return combo -end -""" - combo_val(bv)::Bool - -Round the master's current binary solution for indicator ref `bv` to a -`Bool`. Scalar form returns `Bool`; `AbstractArray` form returns -`Vector{Bool}` per support for InfiniteOpt indicators. -""" -combo_val(bv) = round(JuMP.value(bv)) > 0.5 -combo_val(bvs::AbstractArray) = Bool.(round.(JuMP.value.(bvs))) - -function _add_no_good_cut(model, master, combo) - cut_expr = JuMP.AffExpr(0.0) - for (ind, active) in combo - haskey(master.bin_map, ind) || continue - add_ng_terms(cut_expr, master.bin_map[ind], active) - end - JuMP.@constraint(master.model, cut_expr >= 1.0) -end - -""" - add_ng_terms(cut, bv, active::Bool)::Nothing - -Assemble one indicator's contribution to the no-good cut `cut_expr >= -1`: add `1 - y_j` if the indicator was active in the excluded combo, -or `y_j` otherwise. `AbstractArray` forms (with Bool or per-support -Vector) fold per-support contributions for InfiniteOpt indicators. -""" -function add_ng_terms(cut, bv, active::Bool) - if active - JuMP.add_to_expression!(cut, -1.0, bv) - JuMP.add_to_expression!(cut, 1.0) - else - JuMP.add_to_expression!(cut, 1.0, bv) - end - return -end -add_ng_terms(cut, bvs::AbstractArray, active::Bool) = - add_ng_terms(cut, bvs, fill(active, length(bvs))) -function add_ng_terms(cut, bvs::AbstractArray, actives::AbstractArray) - for (bv, a) in zip(bvs, actives) - add_ng_terms(cut, bv, a) - end - return + return duals end -""" - any_active(active)::Bool - -Return `true` if any indicator value in `active` is truthy. Scalar -`Bool` returns itself; `AbstractVector{Bool}` reduces with `any` for -per-support InfiniteOpt indicators. -""" -any_active(active::Bool) = active -any_active(actives::AbstractVector{Bool}) = any(actives) - ################################################################################ -# LINEARIZATION HELPERS +# COMBO EXTRACTION (master → NLP) ################################################################################ -function _linearize_at(var::JuMP.AbstractVariableRef, xk, ref_map) - return JuMP.AffExpr(0.0, ref_map[var] => 1.0) -end -function _linearize_at(func::JuMP.GenericAffExpr, xk, ref_map) - result = JuMP.AffExpr(func.constant) - for (var, coef) in func.terms - JuMP.add_to_expression!(result, coef, ref_map[var]) +# Read each indicator's binary value from the master MILP solution. +# `combination_val` dispatches per binary type (scalar in base; +# extensions add per-support). +function _extract_combination( + model::M, + master::NamedTuple + ) where {M <: JuMP.AbstractModel} + combination = Dict{LogicalVariableRef{M}, Any}() + for (_, disjunction_data) in _disjunctions(model) + for indicator in disjunction_data.constraint.indicators + haskey(master.binary_map, indicator) || continue + combination[indicator] = combination_val(master.binary_map[indicator]) + end end - return result -end -################################################################################ -# SLACK HELPERS -################################################################################ -_slacken(f, set::_MOI.LessThan, u) = [(f - u, set)] -_slacken(f, set::_MOI.GreaterThan, u) = [(f + u, set)] -function _slacken(f, set::_MOI.EqualTo, u) - b = _MOI.constant(set) - return [(f - u, _MOI.LessThan(b)), (f + u, _MOI.GreaterThan(b))] + return combination end -_slacken(f, set, u) = [(f, set)] + +# OVERRIDABLE. One-shot float→Bool conversion; downstream dispatches on Bool. +combination_val(binary_ref) = round(Bool, JuMP.value(binary_ref)) ################################################################################ -# OA CUT GENERATION +# OA CUT EMISSION ################################################################################ -# Sense-dependent coefficients for OA and convergence calls. Dispatched -# on `Val(master.obj_sense)` so downstream call sites can read like -# regular Julia without branching on the sense. -_disjunct_cut_coeffs(::Val{_MOI.MIN_SENSE}) = (-1, 1) -_disjunct_cut_coeffs(::Val{_MOI.MAX_SENSE}) = (1, -1) -_worst_obj(::Val{_MOI.MIN_SENSE}) = Inf -_worst_obj(::Val{_MOI.MAX_SENSE}) = -Inf -_is_better(::Val{_MOI.MIN_SENSE}, new, best) = new < best -_is_better(::Val{_MOI.MAX_SENSE}, new, best) = new > best -_gap(::Val{_MOI.MIN_SENSE}, best, bound) = best - bound -_gap(::Val{_MOI.MAX_SENSE}, best, bound) = bound - best -_flip_sense(::Val{_MOI.MIN_SENSE}) = Val(_MOI.MAX_SENSE) -_flip_sense(::Val{_MOI.MAX_SENSE}) = Val(_MOI.MIN_SENSE) - -function _add_oa_cuts(model, master, result, method::LOA) - isempty(result.x_values) && return - _add_objective_oa_cut(master, result) - _add_disjunct_oa_cuts(model, master, result, method) +function _add_oa_cuts( + model::JuMP.AbstractModel, + master::NamedTuple, + result::NamedTuple, + method::LOA + ) + isempty(result.linearization_point) && return + linearization = _linearize_at(master.original_objective, + result.objective_linearization_point, master.objective_ref_map) + _add_objective_cut(Val(master.objective_sense), master, linearization) + add_disjunct_oa_cuts(model, master, result, method) return end -# Linearize the original objective at the NLP's solution and add the -# bounding cut `lin ≤ α` (min) or `lin ≥ α` (max) on the master. -function _add_objective_oa_cut(master, result) - lin = _linearize_at( - master.orig_obj, result.obj_x_values, master.obj_ref_map) - _add_obj_cut(Val(master.obj_sense), master, lin) - return -end -_add_obj_cut(::Val{_MOI.MIN_SENSE}, master, lin) = +# Bounding cut `lin ≤ α` (min) or `lin ≥ α` (max) on the master. +_add_objective_cut(::Val{_MOI.MIN_SENSE}, master, lin) = JuMP.@constraint(master.model, lin <= master.alpha_oa) -_add_obj_cut(::Val{_MOI.MAX_SENSE}, master, lin) = +_add_objective_cut(::Val{_MOI.MAX_SENSE}, master, lin) = JuMP.@constraint(master.model, lin >= master.alpha_oa) -# Add V&G 1990 augmented-penalty OA cuts for each active disjunct's -# nonlinear constraints: fresh per-cut slack `σ_ik` with a penalty term -# in the master objective, and cut body `s (lin - rhs) - σ ≤ M(1 - y)`. -function _add_disjunct_oa_cuts( - model, master, result, method::LOA +# OVERRIDABLE. Add V&G 1990 augmented-penalty OA cuts for each active +# disjunct's nonlinear constraints: fresh per-cut slack `σ_ik` with a +# penalty term in the master objective, and cut body +# `s (lin − rhs) − σ ≤ M(1 − y)`. +function add_disjunct_oa_cuts( + model::JuMP.AbstractModel, + master::NamedTuple, + result::NamedTuple, + method::LOA ) - sgn, pen = _disjunct_cut_coeffs(Val(master.obj_sense)) - for (ind, active) in result.combo + sign_factor, penalty_sign = _disjunct_cut_coefficients( + Val(master.objective_sense)) + for (indicator, active) in result.combination any_active(active) || continue - haskey(master.bin_map, ind) || continue - haskey(_indicator_to_constraints(model), ind) || continue - for orig_cref in _indicator_to_constraints(model)[ind] - orig_cref isa DisjunctConstraintRef || continue - con = _disjunct_constraints(model)[ - JuMP.index(orig_cref)].constraint - con.func isa JuMP.GenericAffExpr && continue - d = get(result.duals, orig_cref, nothing) - d === nothing && continue - rhs = _set_rhs(con.set) - for (bv, xk, smap, d_k) in cut_info( - master.bin_map[ind], active, - result.x_values, master.var_map, d) - s = sign(sgn * _dv(d_k)) - s == 0 && continue - lin_expr = _linearize_at(con.func, xk, smap) - slack = JuMP.@variable(master.model, - lower_bound = 0.0, upper_bound = method.max_slack) - JuMP.set_objective_function(master.model, - JuMP.objective_function(master.model) + - pen * method.OA_penalty_factor * slack) - JuMP.@constraint(master.model, - s * (lin_expr - rhs) - slack <= - method.M_value * (1 - bv)) + haskey(master.binary_map, indicator) || continue + haskey(_indicator_to_constraints(model), indicator) || continue + for cref in _indicator_to_constraints(model)[indicator] + cref isa DisjunctConstraintRef || continue + constraint = _disjunct_constraints(model)[ + JuMP.index(cref)].constraint + dual = get(result.duals, cref, nothing) + dual === nothing && continue + for (binary_ref, lin_point, var_map, dual_value) in cut_info( + master.binary_map[indicator], active, + result.linearization_point, master.variable_map, dual) + _add_oa_cut_for_constraint( + constraint, master, binary_ref, lin_point, + var_map, dual_value, method, sign_factor, penalty_sign) end end end end -""" - cut_info(bv, active, x_values, var_map, d) - -Yield the inputs `(bv, xk, smap, d_k)` needed to emit each OA cut for -one active disjunct constraint. The scalar form returns one tuple -(one cut per constraint). The `AbstractArray` form yields K tuples -with per-support-sliced `x_values`, `var_map`, and dual (one cut per -active support, used for InfiniteOpt indicators). -""" -cut_info(bv, active::Bool, x_values, var_map, d) = - ((bv, x_values, var_map, d),) -cut_info(bvs::AbstractArray, active::Bool, x_values, var_map, d) = - cut_info(bvs, fill(active, length(bvs)), x_values, var_map, d) -function cut_info( - bvs::AbstractArray, actives::AbstractArray, - x_values, var_map, d +# Linearize constraint at `linearization_point`, append a fresh per-cut +# slack with V&G penalty, gate by `M(1 − binary)`. Linear constraints +# are exact via BigM and skipped. +function _add_oa_cut_for_constraint( + constraint::JuMP.AbstractConstraint, + master::NamedTuple, + binary_ref, + linearization_point::AbstractDict, + var_map::AbstractDict, + dual_value, + method::LOA, + sign_factor::Int, + penalty_sign::Int ) - sites = Any[] - for k in 1:length(bvs) - actives[k] || continue - smap_k = Dict{Any, Any}( - v => (mv isa AbstractVector ? mv[k] : mv) - for (v, mv) in var_map) - x_k = Dict{Any, Any}( - v => (xv isa AbstractVector ? xv[k] : xv) - for (v, xv) in x_values) - d_k = d isa AbstractVector ? d[k] : d - push!(sites, (bvs[k], x_k, smap_k, d_k)) - end - return sites + _is_linear_F(typeof(constraint.func)) && return + sign_value = sign(sign_factor * _collapse_dual(dual_value)) + sign_value == 0 && return + rhs = _set_rhs(constraint.set) + linearization = _linearize_at(constraint.func, linearization_point, var_map) + slack = JuMP.@variable(master.model, + lower_bound = 0.0, upper_bound = method.max_slack) + JuMP.set_objective_function(master.model, + JuMP.objective_function(master.model) + + penalty_sign * method.OA_penalty_factor * slack) + JuMP.@constraint(master.model, + sign_value * (linearization - rhs) - slack <= + method.M_value * (1 - binary_ref)) + return end -# Collapse a dual value (scalar for a single reformulated constraint, -# vector for reform constraints that produced multiple JuMP constraints -# like Interval / EqualTo / Zeros) to a scalar sign-carrier. -_dv(d::Number) = d -_dv(d) = sum(d) +# OVERRIDABLE. Yield `(binary_ref, lin_point, var_map, dual)` per OA +# cut to emit. Scalar binary returns one tuple; InfiniteOpt overrides +# for per-support `Vector` binaries to yield multiple sliced tuples. +cut_info( + binary_ref::JuMP.AbstractVariableRef, + active::Bool, + linearization_point::AbstractDict, + variable_map::AbstractDict, + dual + ) = ((binary_ref, linearization_point, variable_map, dual),) -################################################################################ -# CONVERGENCE CHECK -################################################################################ -function _loa_converged(best_obj, master_bound, sense_token, method::LOA) - (isinf(best_obj) || isinf(master_bound)) && return false - gap = _gap(sense_token, best_obj, master_bound) - gap <= method.atol && return true - abs(best_obj) > 1e-10 && gap / abs(best_obj) <= method.rtol && return true - return false +# OVERRIDABLE. Truthiness of an active descriptor. InfiniteOpt +# overrides for per-support `Vector{Bool}`. +any_active(active::Bool) = active + +# Collapse a per-constraint dual (scalar or vector for Interval / +# EqualTo / Zeros) to a scalar sign-carrier. +_collapse_dual(dual::Number) = dual +_collapse_dual(dual) = sum(dual) + +# First-order Taylor for a single var or affine expression mapped into +# master space. (Quad/nonlinear `_linearize_at` lives in `utilities.jl` +# and uses MOI Nonlinear AD.) +function _linearize_at( + variable::JuMP.AbstractVariableRef, + ::AbstractDict, + ref_map::AbstractDict + ) + target = ref_map[variable] + return JuMP.GenericAffExpr{Float64, typeof(target)}(0.0, target => 1.0) +end +function _linearize_at( + func::JuMP.GenericAffExpr, + ::AbstractDict, + ref_map::AbstractDict + ) + V = valtype(ref_map) + T = JuMP.value_type(V) <: Number ? JuMP.value_type(V) : Float64 + result = JuMP.GenericAffExpr{T, V}(T(func.constant)) + for (variable, coefficient) in func.terms + JuMP.add_to_expression!(result, coefficient, ref_map[variable]) + end + return result end -_loa_converged(z_upper, z_lower, method::LOA) = - _loa_converged(z_upper, z_lower, Val(_MOI.MIN_SENSE), method) ################################################################################ -# ERROR FALLBACK +# FINALIZATION ################################################################################ -function reformulate_model(::M, ::LOA) where {M} - error("reformulate_model not implemented for model type `$(M)` with LOA.") +# OVERRIDABLE. Set JuMP start values from a scalar-valued +# linearization-point dict. Warm-starts the post-hook `optimize!` +# that JuMP fires after `reformulate_model` — without this the final +# NLP solve restarts from wherever the last LOA iteration left the +# model. InfiniteOpt overrides for per-support Vector values. +function set_start_values( + ::JuMP.AbstractModel, linearization_point::AbstractDict + ) + for (variable, value) in linearization_point + JuMP.set_start_value(variable, value) + end end diff --git a/src/mbm.jl b/src/mbm.jl index 23c5096e..cb726fc1 100644 --- a/src/mbm.jl +++ b/src/mbm.jl @@ -508,7 +508,8 @@ function replace_variables_in_constraint( W = _var_ref_type(T, var_map) new_aff = zero(JuMP.GenericAffExpr{C, W}) for (var, coef) in fun.terms - JuMP.add_to_expression!(new_aff, coef, var_map[var]) + JuMP.add_to_expression!(new_aff, coef, + replace_variables_in_constraint(var, var_map)) end new_aff.constant = new_aff.constant + fun.constant return new_aff @@ -522,7 +523,9 @@ function replace_variables_in_constraint( new_quad = zero(JuMP.GenericQuadExpr{C, W}) for (vars, coef) in fun.terms JuMP.add_to_expression!(new_quad, - coef * var_map[vars.a] * var_map[vars.b]) + coef * + replace_variables_in_constraint(vars.a, var_map) * + replace_variables_in_constraint(vars.b, var_map)) end new_aff = replace_variables_in_constraint(fun.aff, var_map) JuMP.add_to_expression!(new_quad, new_aff) diff --git a/src/utilities.jl b/src/utilities.jl index 9dd168e2..881c77fb 100644 --- a/src/utilities.jl +++ b/src/utilities.jl @@ -8,6 +8,23 @@ function _copy_model( return M() end +# OVERRIDABLE. Copy `source`'s decision variables into `target` (with +# bounds and integrality) and return a `source → target` ref map. +# Constraints and objective are NOT copied — the caller adds whichever +# subset it wants. InfiniteOpt overrides to additionally copy +# parameters, derivatives, and parameter functions. +function copy_variables_onto_model( + target::JuMP.AbstractModel, + source::JuMP.AbstractModel + ) + V = JuMP.variable_ref_type(typeof(source)) + ref_map = Dict{V, V}() + for variable in JuMP.all_variables(source) + ref_map[variable] = variable_copy(target, variable) + end + return ref_map +end + """ copy_and_reformulate(model, decision_vars, reform_method, method) @@ -75,6 +92,107 @@ function extract_solution(sub::GDPSubmodel) var => JuMP.value.(sub.fwd_map[var]) for var in sub.decision_vars) end +################################################################################ +# INDICATOR FIXING +################################################################################ +""" + fix_indicator(model, indicator::LogicalVariableRef, value::Bool) + fix_indicator(binary_ref, value::Bool) + +Fix a logical indicator's binary backing variable to `value` (true → +1.0, false → 0.0). The 3-arg form is the user-facing API: pass the +model and the `LogicalVariableRef`. The 2-arg form takes the binary +backing reference directly (or its complement-form expression +`1 - other_binary` when the indicator was declared as a complement). + +Mirrors [`relax_logical_vars`](@ref) for selectively fixing rather +than relaxing. +""" +fix_indicator(model::JuMP.AbstractModel, + indicator::LogicalVariableRef, value::Bool) = + fix_indicator(_indicator_to_binary(model)[indicator], value) +fix_indicator(binary_ref::JuMP.AbstractVariableRef, value::Bool) = + JuMP.fix(binary_ref, value ? 1.0 : 0.0; force = true) +function fix_indicator( + binary_ref::JuMP.GenericAffExpr, value::Bool + ) + underlying, _ = only(binary_ref.terms) + JuMP.fix(underlying, value ? 0.0 : 1.0; force = true) + return +end + +""" + unfix_indicator(model, indicator::LogicalVariableRef) + unfix_indicator(binary_ref) + +Undo [`fix_indicator`](@ref). No-op if not currently fixed. +""" +unfix_indicator(model::JuMP.AbstractModel, + indicator::LogicalVariableRef) = + unfix_indicator(_indicator_to_binary(model)[indicator]) +function unfix_indicator(binary_ref) + JuMP.is_fixed(binary_ref) && JuMP.unfix(binary_ref) + return +end +function unfix_indicator(binary_ref::JuMP.GenericAffExpr) + underlying = only(keys(binary_ref.terms)) + JuMP.is_fixed(underlying) && JuMP.unfix(underlying) + return +end + +################################################################################ +# NO-GOOD CUTS +################################################################################ +""" + avoid_combination(model, combination) + avoid_combination(model, combination, binary_map) + +Add a no-good cut to `model` excluding `combination` from any future +solution. The added constraint + Σ_{j active} (1 - y_j) + Σ_{j inactive} y_j ≥ 1 +forces at least one indicator to differ from `combination`. + +`combination` maps `LogicalVariableRef` → `Bool` (whether each +indicator was active). The 3-arg form lets you supply an explicit +`binary_map` (defaulting to `_indicator_to_binary(model)`) when the +binaries used in the cut belong to a copy of the original model +(e.g. an LOA master). + +Returns the constraint reference of the added cut. +""" +avoid_combination(model::JuMP.AbstractModel, combination) = + avoid_combination( + model, combination, _indicator_to_binary(model)) +function avoid_combination(model::JuMP.AbstractModel, + combination, binary_map + ) + V = JuMP.variable_ref_type(typeof(model)) + T = JuMP.value_type(typeof(model)) + cut = JuMP.GenericAffExpr{T, V}(zero(T)) + for (indicator, active) in combination + haskey(binary_map, indicator) || continue + add_no_good_terms(cut, binary_map[indicator], active) + end + return JuMP.@constraint(model, cut >= one(T)) +end + +""" + add_no_good_terms(cut, binary_ref, active::Bool) + +Append one indicator's contribution to a no-good cut expression: +adds `1 - y_j` if the indicator was active in the excluded +combination, or `y_j` otherwise. Used by [`avoid_combination`](@ref). +""" +function add_no_good_terms(cut, binary_ref, active::Bool) + if active + JuMP.add_to_expression!(cut, -1.0, binary_ref) + JuMP.add_to_expression!(cut, 1.0) + else + JuMP.add_to_expression!(cut, 1.0, binary_ref) + end + return +end + ################################################################################ # LOGICAL VARIABLE RELAXATION ################################################################################ @@ -421,6 +539,33 @@ end # for outer approximation methods (LOA, future OA variants). ################################################################################ +################################################################################ +# AGGREGATE-REF DETECTION +################################################################################ +# Predicate: does the variable ref aggregate multiple decision +# variables behind a single leaf? An "aggregate" ref is one that AD +# cannot see inside — e.g. an InfiniteOpt `MeasureRef` (`∫ f(x,t) dt` +# is one ref but depends on `x(t_1), …, x(t_K)`) or a +# `ParameterFunctionRef`. Base returns false; the InfiniteOpt +# extension overrides for aggregate ref types. +# +# When `has_aggregate_ref(expr)` is true, MOI Nonlinear AD on `expr` +# would treat the aggregate as a single opaque variable and produce a +# meaningless gradient. The LOA pipeline falls back to transcription +# in that case (flat scalar expression, AD on the flat form, then map +# back to master refs). +# +# #suggestions for names are welcome +is_aggregate_ref(::JuMP.AbstractVariableRef) = false + +function has_aggregate_ref(expr) + found = Ref(false) + _interrogate_variables(expr) do v + found[] || (found[] = is_aggregate_ref(v)) + end + return found[] +end + ################################################################################ # MOI NONLINEAR EXPRESSION CONVERSION ################################################################################ diff --git a/test/constraints/loa.jl b/test/constraints/loa.jl index 8009ece0..f75d1305 100644 --- a/test/constraints/loa.jl +++ b/test/constraints/loa.jl @@ -30,7 +30,7 @@ function test_set_covering_combos() @constraint(model, x >= 5, Disjunct(Y[2])) @disjunction(model, Y) - combos = DP._set_covering_combos(model) + combos = DP._set_covering_combinations(model) # Should cover both Y[1] and Y[2] all_active = Set() @@ -60,7 +60,7 @@ function test_no_good_cut() num_cons_before = length(JuMP.all_constraints( master_model; include_variable_in_set_constraints = false)) - DP._add_no_good_cut(model, master, combo) + DP.avoid_combination(master.model, combo, master.binary_map) num_cons_after = length(JuMP.all_constraints( master_model; include_variable_in_set_constraints = false)) @@ -71,10 +71,11 @@ end function test_loa_convergence_check() method = LOA(HiGHS.Optimizer; atol = 1e-6, rtol = 1e-4) - @test DP._loa_converged(1.0, 1.0, method) == true - @test DP._loa_converged(1.0, 0.9999, method) == true - @test DP._loa_converged(1.0, 0.5, method) == false - @test DP._loa_converged(1e-8, 0.0, method) == true + sense = Val(MOI.MIN_SENSE) + @test DP._loa_converged(1.0, 1.0, sense, method) == true + @test DP._loa_converged(1.0, 0.9999, sense, method) == true + @test DP._loa_converged(1.0, 0.5, sense, method) == false + @test DP._loa_converged(1e-8, 0.0, sense, method) == true end function test_loa_reformulate_simple() diff --git a/test/constraints/mbm.jl b/test/constraints/mbm.jl index 11f164da..ab9e8287 100644 --- a/test/constraints/mbm.jl +++ b/test/constraints/mbm.jl @@ -52,7 +52,7 @@ function test__replace_variables_quad_numeric_map() @test result3.aff.terms[y] ≈ 3.0 end -function test_replace_variables_in_constraint() +function testreplace_variables_in_constraint() model = Model() sub_model = Model() @variable(model, x[1:3]) @@ -809,7 +809,7 @@ end test_mbm() test__var_ref_type_numeric_map() test__replace_variables_quad_numeric_map() - test_replace_variables_in_constraint() + testreplace_variables_in_constraint() test_prepare_max_M_objective() test_raw_M() test_maximize_M() From b6563d78183cf6b71902bc4d74c7c6379e6ba7d1 Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Sun, 26 Apr 2026 23:24:37 -0400 Subject: [PATCH 36/59] . --- ext/InfiniteDisjunctiveProgramming.jl | 101 +++++++++++--------------- src/cuttingplanes.jl | 20 +++-- src/loa.jl | 33 ++------- src/utilities.jl | 2 +- test/constraints/cuttingplanes.jl | 4 +- 5 files changed, 66 insertions(+), 94 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index ff04a82f..747ae6a2 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -337,10 +337,15 @@ end _per_support_values(variable::InfiniteOpt.GeneralVariableRef) = vec(vcat(JuMP.value(variable))) -# Read per-support values from the transformation backend. +# Read per-support values from the transformation backend, keyed by +# InfiniteOpt vars. Skips fixed vars. The objective-side translation +# (transcribe-then-AD when the objective has aggregate refs) lives +# in the `_add_oa_cuts(::InfiniteModel, ...)` override below — base +# `extract_solution` doesn't need to anticipate it. function DP.extract_solution(model::InfiniteOpt.InfiniteModel) - return Dict(variable => _per_support_values(variable) - for variable in DP.collect_cutting_planes_vars(model)) + return Dict(var => _per_support_values(var) + for var in DP.collect_all_vars(model) + if !JuMP.is_fixed(var)) end # Add a pointwise-sum cut directly to the transformation backend and mark @@ -704,6 +709,41 @@ function DP.build_loa_master( end # Override the disjunct-cut loop for `InfiniteModel`. Same shape as +# Override `_add_oa_cuts` for `InfiniteModel` to translate the +# linearization point into the form the master's `original_objective` +# expects. The base `result.linearization_point` has per-support +# `Vector` values keyed on InfiniteOpt vars; the master's objective +# is either the raw InfiniteOpt expression (non-aggregate, expects +# scalar `xk[v]`) or the transcribed flat scalar expression +# (aggregate, expects transcribed-`JuMP.VariableRef`-keyed scalar +# dict). The translation produces whichever shape `_linearize_at` +# needs for this master. +function DP._add_oa_cuts( + model::InfiniteOpt.InfiniteModel, + master::NamedTuple, + result::NamedTuple, + method::DP.LOA + ) + isempty(result.linearization_point) && return + obj_point = if DP.has_aggregate_ref(JuMP.objective_function(model)) + _transcribe_linearization_point( + model, result.linearization_point) + else + T = eltype(valtype(result.linearization_point)) + Dict{InfiniteOpt.GeneralVariableRef, T}( + var => values[1] + for (var, values) in result.linearization_point + if isempty(InfiniteOpt.parameter_refs(var))) + end + linearization = DP._linearize_at(master.original_objective, + obj_point, master.objective_ref_map) + DP._add_objective_cut( + Val(master.objective_sense), master, linearization) + DP.add_disjunct_oa_cuts(model, master, result, method) + return +end + +# Override `add_disjunct_oa_cuts` for `InfiniteModel`. Same shape as # the base loop, but each constraint is checked for aggregate refs. # Aggregate constraints (e.g. those containing a `MeasureRef`) are # transcribed via `InfiniteOpt.transformation_expression`, then @@ -985,59 +1025,4 @@ _add_to_transcribed_dict( ) = (d[ts] = value; nothing) -# Extract per-support x-values from the InfiniteModel NLP. The -# first return is keyed on InfiniteModel vars (used for disjunct -# OA cuts via `cut_info`). The second return is what the objective -# OA cut consumes: when the objective is aggregate-free we hand -# back the same dict (objective AD walks InfiniteOpt vars -# directly); when it has aggregates we transcribe so the flat -# objective's AD pipeline can read it. -function DP.read_primary_solution( - model::InfiniteOpt.InfiniteModel - ) - T = JuMP.value_type(typeof(model)) - linearization_point = Dict{ - InfiniteOpt.GeneralVariableRef, Vector{T} - }( - variable => _per_support_values(variable) - for variable in DP.collect_all_vars(model) - if !JuMP.is_fixed(variable)) - return linearization_point, - _objective_linearization_point(model, linearization_point) -end - -# Same split as `read_primary_solution`: InfiniteOpt-keyed dict for -# disjunct cuts, plus the matching objective-side point. -function DP.read_feas_solution( - model::InfiniteOpt.InfiniteModel, feas::NamedTuple - ) - linearization_point = DP.extract_solution(feas.sub) - return linearization_point, - _objective_linearization_point(model, linearization_point) -end - -# Bridge `linearization_point` (InfiniteOpt-keyed, per-support -# `Vector` values) to the scalar-valued dict the base -# `_linearize_at` AD pipeline expects. Aggregate objectives go -# through transcription (flat JuMP-keyed scalar dict). -# Aggregate-free objectives use only finite vars; we keep the -# InfiniteOpt keys and collapse the 1-element per-support vector -# to a scalar so AD reads `xk[v]` as a number. -function _objective_linearization_point( - model::InfiniteOpt.InfiniteModel, - linearization_point::AbstractDict - ) - if DP.has_aggregate_ref(JuMP.objective_function(model)) - return _transcribe_linearization_point( - model, linearization_point) - end - T = eltype(valtype(linearization_point)) - scalar_point = Dict{InfiniteOpt.GeneralVariableRef, T}() - for (variable, values) in linearization_point - isempty(InfiniteOpt.parameter_refs(variable)) || continue - scalar_point[variable] = values[1] - end - return scalar_point -end - end diff --git a/src/cuttingplanes.jl b/src/cuttingplanes.jl index ddb3710d..91e26fe6 100644 --- a/src/cuttingplanes.jl +++ b/src/cuttingplanes.jl @@ -8,14 +8,17 @@ function collect_cutting_planes_vars(model::JuMP.AbstractModel) return collect_all_vars(model) end -# Extract solution from a solved model (in-place). Extensions -# override for models where values live on a backend. +# Read primal values from a solved model. Returns a scalar-valued +# `Dict{var, value}`, skipping fixed vars. CP callers wrap to +# per-support `Vector` shape via `_cp_per_support`. The InfiniteOpt +# extension overrides this dispatch to give per-support `Vector` +# values directly. function extract_solution(model::JuMP.AbstractModel) dvars = collect_cutting_planes_vars(model) V = eltype(dvars) T = JuMP.value_type(typeof(model)) - return Dict{V, Vector{T}}( - v => [JuMP.value(v)] for v in dvars) + return Dict{V, T}( + v => JuMP.value(v) for v in dvars if !JuMP.is_fixed(v)) end # Set quadratic separation objective: min Σ (x_k - rBM_k)². @@ -102,7 +105,7 @@ function reformulate_model( # Cutting plane loop: rBM <-> SEP until convergence for iter in 1:method.max_iter JuMP.optimize!(model, ignore_optimize_hook = true) - rBM_sol = extract_solution(model) + rBM_sol = _cp_per_support(extract_solution(model)) separation_obj, separation_sol = _solve_separation(separation, rBM_sol) if separation_obj <= method.seperation_tolerance break @@ -116,6 +119,13 @@ function reformulate_model( return end +# Wrap scalar `extract_solution(model).point` values into 1-element +# `Vector`s — uniform per-support shape that the CP loop expects. +# `Vector` values (from the InfiniteOpt extension's per-support read) +# pass through unchanged. +_cp_per_support(point::AbstractDict) = + Dict(v => val isa AbstractVector ? val : [val] for (v, val) in point) + ################################################################################ # ERROR MESSAGES ################################################################################ diff --git a/src/loa.jl b/src/loa.jl index eac14ccf..580071ec 100644 --- a/src/loa.jl +++ b/src/loa.jl @@ -238,14 +238,13 @@ function _solve_nlp( fix_combination_binaries(model, combination) JuMP.optimize!(model, ignore_optimize_hook = true) if JuMP.is_solved_and_feasible(model) - lin_point, obj_lin_point = read_primary_solution(model) + lin_point = extract_solution(model) duals = _collect_nlp_duals(model, combination, reformulation_map) objective_val = JuMP.objective_value(model) unfix_combination_binaries(model, combination) return (combination = combination, linearization_point = lin_point, duals = duals, - objective = objective_val, feasible = true, - objective_linearization_point = obj_lin_point) + objective = objective_val, feasible = true) end unfix_combination_binaries(model, combination) active_refs = _active_disjunct_constraint_refs(model, combination) @@ -258,13 +257,12 @@ function _solve_nlp( end JuMP.optimize!(feas.sub.model) lin_point = JuMP.is_solved_and_feasible(feas.sub.model) ? - first(read_feas_solution(model, feas)) : + extract_solution(feas.sub) : Dict{JuMP.AbstractVariableRef, Any}() return (combination = combination, linearization_point = lin_point, duals = Dict{DisjunctConstraintRef{M}, Any}(), - objective = Inf, feasible = false, - objective_linearization_point = lin_point) + objective = Inf, feasible = false) end # `DisjunctConstraintRef`s of every active indicator in `combination`. @@ -373,27 +371,6 @@ function _slacken(f, set::_MOI.EqualTo, u) end _slacken(f, set, u) = [(f, set)] -# OVERRIDABLE. Read the primal NLP solution. Returns -# `(linearization_point, objective_linearization_point)`; both dicts -# are equal here. InfiniteOpt overrides to return a transcribed scalar -# dict for the second element when needed. -function read_primary_solution(model::JuMP.AbstractModel) - linearization_point = Dict{JuMP.AbstractVariableRef, Any}() - for variable in JuMP.all_variables(model) - JuMP.is_fixed(variable) && continue - linearization_point[variable] = JuMP.value(variable) - end - return linearization_point, linearization_point -end - -# OVERRIDABLE. As `read_primary_solution`, but for the V&G -# feasibility-restoration submodel; values keyed back to original-model -# vars via `feas.sub`. -function read_feas_solution(model::JuMP.AbstractModel, feas::NamedTuple) - linearization_point = extract_solution(feas.sub) - return linearization_point, linearization_point -end - ################################################################################ # BIGM DUAL COLLECTION ################################################################################ @@ -506,7 +483,7 @@ function _add_oa_cuts( ) isempty(result.linearization_point) && return linearization = _linearize_at(master.original_objective, - result.objective_linearization_point, master.objective_ref_map) + result.linearization_point, master.objective_ref_map) _add_objective_cut(Val(master.objective_sense), master, linearization) add_disjunct_oa_cuts(model, master, result, method) return diff --git a/src/utilities.jl b/src/utilities.jl index 881c77fb..7d8e8a9d 100644 --- a/src/utilities.jl +++ b/src/utilities.jl @@ -84,7 +84,7 @@ end Read the primal solution of `sub.model` after a solve, keyed by the parent-model decision variables via `sub.fwd_map`. Shape follows -`fwd_map` values: `Vector`-valued fwd_maps (MBM/CP) yield per-support +`fwd_map` values: `Vector`-valued fwd_maps (CP/MBM) yield per-support `Vector`s; scalar fwd_maps (LOA feas) yield scalars. """ function extract_solution(sub::GDPSubmodel) diff --git a/test/constraints/cuttingplanes.jl b/test/constraints/cuttingplanes.jl index 9f3a7931..e15976dd 100644 --- a/test/constraints/cuttingplanes.jl +++ b/test/constraints/cuttingplanes.jl @@ -146,7 +146,7 @@ function test_cp_cut_generation() JuMP.set_silent(model) relaxed = DP.relax_logical_vars(model) optimize!(model, ignore_optimize_hook = true) - rBM_sol = DP.extract_solution(model) + rBM_sol = DP._cp_per_support(DP.extract_solution(model)) # Solve SEP DP._set_separation_objective(separation, rBM_sol) @@ -168,7 +168,7 @@ function test_cp_cut_generation() # Re-solve with cut → should tighten optimize!(model, ignore_optimize_hook = true) rBM_sol2 = DP.extract_solution(model) - @test rBM_sol2[x][1] ≈ 4.0 atol = 0.1 + @test rBM_sol2[x] ≈ 4.0 atol = 0.1 DP.unrelax_logical_vars(relaxed) end From efff096e40cb20f38d198de27a72e50036368c57 Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Sun, 26 Apr 2026 23:41:35 -0400 Subject: [PATCH 37/59] . --- src/cuttingplanes.jl | 23 ++++++++--------------- src/loa.jl | 2 +- src/utilities.jl | 10 +++++++++- test/constraints/cuttingplanes.jl | 4 ++-- 4 files changed, 20 insertions(+), 19 deletions(-) diff --git a/src/cuttingplanes.jl b/src/cuttingplanes.jl index 91e26fe6..63692258 100644 --- a/src/cuttingplanes.jl +++ b/src/cuttingplanes.jl @@ -8,17 +8,17 @@ function collect_cutting_planes_vars(model::JuMP.AbstractModel) return collect_all_vars(model) end -# Read primal values from a solved model. Returns a scalar-valued -# `Dict{var, value}`, skipping fixed vars. CP callers wrap to -# per-support `Vector` shape via `_cp_per_support`. The InfiniteOpt -# extension overrides this dispatch to give per-support `Vector` -# values directly. +# Read primal values from a solved model. Returns +# `Dict{var, Vector{value}}` — per-support shape uniformly: finite +# models trivially have one "support" (length-1 Vector), the +# InfiniteOpt extension overrides this dispatch to populate +# multi-support Vectors. Skips fixed vars. function extract_solution(model::JuMP.AbstractModel) dvars = collect_cutting_planes_vars(model) V = eltype(dvars) T = JuMP.value_type(typeof(model)) - return Dict{V, T}( - v => JuMP.value(v) for v in dvars if !JuMP.is_fixed(v)) + return Dict{V, Vector{T}}( + v => [JuMP.value(v)] for v in dvars if !JuMP.is_fixed(v)) end # Set quadratic separation objective: min Σ (x_k - rBM_k)². @@ -105,7 +105,7 @@ function reformulate_model( # Cutting plane loop: rBM <-> SEP until convergence for iter in 1:method.max_iter JuMP.optimize!(model, ignore_optimize_hook = true) - rBM_sol = _cp_per_support(extract_solution(model)) + rBM_sol = extract_solution(model) separation_obj, separation_sol = _solve_separation(separation, rBM_sol) if separation_obj <= method.seperation_tolerance break @@ -119,13 +119,6 @@ function reformulate_model( return end -# Wrap scalar `extract_solution(model).point` values into 1-element -# `Vector`s — uniform per-support shape that the CP loop expects. -# `Vector` values (from the InfiniteOpt extension's per-support read) -# pass through unchanged. -_cp_per_support(point::AbstractDict) = - Dict(v => val isa AbstractVector ? val : [val] for (v, val) in point) - ################################################################################ # ERROR MESSAGES ################################################################################ diff --git a/src/loa.jl b/src/loa.jl index 580071ec..fe857840 100644 --- a/src/loa.jl +++ b/src/loa.jl @@ -615,6 +615,6 @@ function set_start_values( ::JuMP.AbstractModel, linearization_point::AbstractDict ) for (variable, value) in linearization_point - JuMP.set_start_value(variable, value) + JuMP.set_start_value(variable, _unwrap_scalar(value)) end end diff --git a/src/utilities.jl b/src/utilities.jl index 7d8e8a9d..435ed27c 100644 --- a/src/utilities.jl +++ b/src/utilities.jl @@ -619,7 +619,7 @@ function _linearize_at( nlp, _MOI.Nonlinear.SparseReverseMode(), ord) _MOI.initialize(evaluator, [:Grad]) - xk_vec = [get(xk, v, zero(T)) for v in vars] + xk_vec = [_unwrap_scalar(get(xk, v, zero(T))) for v in vars] f_xk = _MOI.eval_objective(evaluator, xk_vec) grad = zeros(T, n) _MOI.eval_objective_gradient(evaluator, grad, xk_vec) @@ -641,3 +641,11 @@ end _set_rhs(s::Union{_MOI.LessThan, _MOI.GreaterThan, _MOI.EqualTo}) = _MOI.constant(s) _set_rhs(::Any) = 0.0 + +# Unwrap a 1-element per-support `Vector` to its scalar value; +# scalars pass through. `extract_solution` returns per-support +# `Vector`s uniformly (length-1 for finite, length-K for InfiniteOpt). +# AD pipelines and `set_start_value` need a scalar in the finite +# case; per-support consumers slice out a scalar themselves. +_unwrap_scalar(v::Real) = v +_unwrap_scalar(v::AbstractVector) = only(v) diff --git a/test/constraints/cuttingplanes.jl b/test/constraints/cuttingplanes.jl index e15976dd..9f3a7931 100644 --- a/test/constraints/cuttingplanes.jl +++ b/test/constraints/cuttingplanes.jl @@ -146,7 +146,7 @@ function test_cp_cut_generation() JuMP.set_silent(model) relaxed = DP.relax_logical_vars(model) optimize!(model, ignore_optimize_hook = true) - rBM_sol = DP._cp_per_support(DP.extract_solution(model)) + rBM_sol = DP.extract_solution(model) # Solve SEP DP._set_separation_objective(separation, rBM_sol) @@ -168,7 +168,7 @@ function test_cp_cut_generation() # Re-solve with cut → should tighten optimize!(model, ignore_optimize_hook = true) rBM_sol2 = DP.extract_solution(model) - @test rBM_sol2[x] ≈ 4.0 atol = 0.1 + @test rBM_sol2[x][1] ≈ 4.0 atol = 0.1 DP.unrelax_logical_vars(relaxed) end From 76a55f8e875ccc58b82a32139749d653fd391609 Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Mon, 27 Apr 2026 12:56:50 -0400 Subject: [PATCH 38/59] prelim --- ext/InfiniteDisjunctiveProgramming.jl | 5 ++++ src/loa.jl | 39 +++++++++++++++++++++++++++ test/constraints/loa.jl | 28 ++++++++++++++++++- 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 747ae6a2..3d980556 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -739,6 +739,11 @@ function DP._add_oa_cuts( obj_point, master.objective_ref_map) DP._add_objective_cut( Val(master.objective_sense), master, linearization) + # WIP: InfiniteOpt globals need per-support handling analogous to + # the obj_point translation above (transcribe-or-flatten). Until + # that's implemented, base `_add_global_oa_cuts` is skipped here — + # calling it would break on infinite vars (per-support `Vector` + # values flow into `_linearize_at`'s scalar-only AD path). DP.add_disjunct_oa_cuts(model, master, result, method) return end diff --git a/src/loa.jl b/src/loa.jl index fe857840..1d0e10bb 100644 --- a/src/loa.jl +++ b/src/loa.jl @@ -485,6 +485,7 @@ function _add_oa_cuts( linearization = _linearize_at(master.original_objective, result.linearization_point, master.objective_ref_map) _add_objective_cut(Val(master.objective_sense), master, linearization) + _add_global_oa_cuts(model, master, result, method) add_disjunct_oa_cuts(model, master, result, method) return end @@ -495,6 +496,44 @@ _add_objective_cut(::Val{_MOI.MIN_SENSE}, master, lin) = _add_objective_cut(::Val{_MOI.MAX_SENSE}, master, lin) = JuMP.@constraint(master.model, lin >= master.alpha_oa) +# WIP: finite-only. InfiniteOpt's `_add_oa_cuts(::InfiniteModel, ...)` +# override skips the call to this — globals over infinite vars yield +# per-support `Vector` values that the scalar-only `_linearize_at` AD +# path can't consume. A per-support-aware variant is the next step. +# +# Add the OA cut `g(x^l) + ∇g(x^l)^T (x − x^l) in con.set` for every +# nonlinear global constraint of `model` — the third cut class in +# Türkay & Grossmann (1996, eq. 12) alongside the objective and the +# disjunct cuts. Walks `JuMP.list_of_constraint_types`, skipping +# variable bounds, linear functions (already in the master), and +# BigM-reformulated forms (in `_reformulation_constraints`). +# `LessThan` and `GreaterThan` are supported; equalities and vector +# constraints are passed through to `JuMP.@constraint` as-is — +# valid for affine-after-linearization sets. +function _add_global_oa_cuts( + model::JuMP.AbstractModel, + master::NamedTuple, + result::NamedTuple, + method::LOA + ) + variable_type = JuMP.variable_ref_type(typeof(model)) + reform_set = is_gdp_model(model) ? + Set(_reformulation_constraints(model)) : Set() + for (F, S) in JuMP.list_of_constraint_types(model) + F === variable_type && continue + _is_linear_F(F) && continue + for cref in JuMP.all_constraints(model, F, S) + cref in reform_set && continue + con = JuMP.constraint_object(cref) + con isa JuMP.ScalarConstraint || continue + linearization = _linearize_at(con.func, + result.linearization_point, master.variable_map) + JuMP.@constraint(master.model, linearization in con.set) + end + end + return +end + # OVERRIDABLE. Add V&G 1990 augmented-penalty OA cuts for each active # disjunct's nonlinear constraints: fresh per-cut slack `σ_ik` with a # penalty term in the master objective, and cut body diff --git a/test/constraints/loa.jl b/test/constraints/loa.jl index f75d1305..712e48c1 100644 --- a/test/constraints/loa.jl +++ b/test/constraints/loa.jl @@ -1,4 +1,4 @@ -using HiGHS +using HiGHS, Ipopt, Juniper function test_loa_datatype() method = LOA(HiGHS.Optimizer) @@ -139,6 +139,31 @@ function test_loa_error_fallback() @test_throws ErrorException DP.reformulate_model(42, method) end +function test_loa_nonlinear_global() + # max x s.t. x^2 <= 25 (global), (x <= 3) ∨ (x <= 8), 0 <= x <= 10. + # Disjunct Y[2] permits x up to 8 but the global x^2 <= 25 bounds + # x to 5. Verifies that `_add_global_oa_cuts` emits the + # linearization of the global into the master without breaking + # the loop. Result must hit the global-binding optimum. + ipopt = optimizer_with_attributes(Ipopt.Optimizer, + "print_level" => 0, "sb" => "yes") + juniper = optimizer_with_attributes(Juniper.Optimizer, + "nl_solver" => ipopt, "log_levels" => []) + model = GDPModel(juniper) + set_silent(model) + @variable(model, 0 <= x <= 10) + @constraint(model, x^2 <= 25) + @variable(model, Y[1:2], Logical) + @constraint(model, x <= 3, Disjunct(Y[1])) + @constraint(model, x <= 8, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Max, x) + optimize!(model, + gdp_method = LOA(juniper; mip_optimizer = HiGHS.Optimizer)) + @test termination_status(model) in (MOI.OPTIMAL, MOI.LOCALLY_SOLVED) + @test objective_value(model) ≈ 5.0 atol = 1e-3 +end + function test_linearize_nonlinear_exp() # exp(x) + y at (1, 2): # f = e + 2, ∇f = [e, 1] @@ -219,6 +244,7 @@ end test_loa_solve_simple() test_loa_solve_two_disjunctions() test_loa_error_fallback() + test_loa_nonlinear_global() test_linearize_nonlinear_exp() test_linearize_nonlinear_sin() test_linearize_nonlinear_multivar() From 1edcb942e8453fe52f4ff1ed2214be3c2126b017 Mon Sep 17 00:00:00 2001 From: d227nguyen Date: Thu, 14 May 2026 12:51:38 -0400 Subject: [PATCH 39/59] Removed NLP-feas problem --- ext/InfiniteDisjunctiveProgramming.jl | 47 ----------- src/loa.jl | 112 +++----------------------- 2 files changed, 9 insertions(+), 150 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 3d980556..c875c280 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -933,53 +933,6 @@ _unfix_binary_at_supports( ::AbstractVector{Bool} ) = nothing -# Build the LOA feasibility-restoration submodel as a fresh -# `InfiniteModel`: copy the structural skeleton, then copy every -# non-reformulation constraint plus the active disjuncts' -# `disjunct_constraint_refs`. `fwd_map` maps input InfiniteOpt vars -# to single feas-side InfiniteOpt vars; per-support handling falls -# through to the `GeneralVariableRef` dispatches above. -function DP.copy_model_with_constraints( - model::InfiniteOpt.InfiniteModel, - disjunct_constraint_refs::Vector{<:DP.DisjunctConstraintRef}, - method::DP.LOA - ) - sub_model = InfiniteOpt.InfiniteModel() - JuMP.set_optimizer(sub_model, method.nlp_optimizer) - JuMP.set_silent(sub_model) - - ref_map = DP.copy_variables_onto_model(sub_model, model) - - variable_type = InfiniteOpt.GeneralVariableRef - reform_set = DP.is_gdp_model(model) ? - Set(DP._reformulation_constraints(model)) : Set() - for (F, S) in JuMP.list_of_constraint_types(model) - F === variable_type && continue - for cref in JuMP.all_constraints(model, F, S) - cref in reform_set && continue - con = JuMP.constraint_object(cref) - new_func = DP.replace_variables_in_constraint( - con.func, ref_map) - JuMP.@constraint(sub_model, new_func in con.set) - end - end - for cref in disjunct_constraint_refs - con = JuMP.constraint_object(cref) - new_func = DP.replace_variables_in_constraint( - con.func, ref_map) - JuMP.@constraint(sub_model, new_func in con.set) - end - - decision_vars = filter(!_is_point_var, DP.collect_all_vars(model)) - fwd_map = Dict{InfiniteOpt.GeneralVariableRef, - InfiniteOpt.GeneralVariableRef}() - for v in decision_vars - fwd_map[v] = ref_map[v] - end - return (sub = DP.GDPSubmodel(sub_model, decision_vars, fwd_map), - objective_ref_map = ref_map) -end - # Convert an InfiniteModel-var-keyed per-support point into a # transcribed-JuMP-var-keyed scalar point. Companion to # `_transcribed_to_master_point`: feeds the AD walker for the diff --git a/src/loa.jl b/src/loa.jl index 1d0e10bb..a6c9e297 100644 --- a/src/loa.jl +++ b/src/loa.jl @@ -4,6 +4,11 @@ # Türkay & Grossmann (1996), Comp. & Chem. Eng. 20(8), 959-978 # With augmented-penalty OA master from: # Viswanathan & Grossmann (1990), Comp. & Chem. Eng. 14(7), 769-782 +# +# Infeasible primary NLPs are handled solely via the master's augmented +# penalty: the combination is forbidden by a no-good cut and no OA cut +# is emitted from that iteration. No separate feasibility-restoration +# (NLPF) subproblem is built. ################################################################################ ################################################################################ @@ -228,10 +233,9 @@ end # NLP SUBPROBLEM ################################################################################ # Solve the primary NLP for a fixed combination. If feasible, read the -# primal point, duals, and objective and return. If infeasible, build a -# fresh V&G 1990 NLPF submodel (active-disjunct originals + globals, -# slackened with shared `u` and `min u` objective), solve it for a -# least-infeasible point, and return that. The NLPF is discarded. +# primal point, duals, and objective. If infeasible, return an empty +# result — the master's augmented penalty (slacks `σ_ik` on disjunct OA +# cuts) absorbs infeasibility and a no-good cut forbids the combination. function _solve_nlp( model::M, combination, method::LOA, reformulation_map ) where {M <: JuMP.AbstractModel} @@ -247,40 +251,12 @@ function _solve_nlp( objective = objective_val, feasible = true) end unfix_combination_binaries(model, combination) - active_refs = _active_disjunct_constraint_refs(model, combination) - feas = copy_model_with_constraints(model, active_refs, method) - _embed_feas_slack(feas) - for (indicator, value) in combination - orig_binary_ref = _indicator_to_binary(model)[indicator] - fix_indicator(_remap_indicator_to_binary( - orig_binary_ref, feas.sub.fwd_map), value) - end - JuMP.optimize!(feas.sub.model) - lin_point = JuMP.is_solved_and_feasible(feas.sub.model) ? - extract_solution(feas.sub) : - Dict{JuMP.AbstractVariableRef, Any}() return (combination = combination, - linearization_point = lin_point, + linearization_point = Dict{JuMP.AbstractVariableRef, Any}(), duals = Dict{DisjunctConstraintRef{M}, Any}(), objective = Inf, feasible = false) end -# `DisjunctConstraintRef`s of every active indicator in `combination`. -function _active_disjunct_constraint_refs( - model::M, - combination::AbstractDict - ) where {M <: JuMP.AbstractModel} - refs = DisjunctConstraintRef{M}[] - for (indicator, active) in combination - any_active(active) || continue - haskey(_indicator_to_constraints(model), indicator) || continue - for cref in _indicator_to_constraints(model)[indicator] - cref isa DisjunctConstraintRef && push!(refs, cref) - end - end - return refs -end - # OVERRIDABLE. Fix every indicator in `combination` on `model`. function fix_combination_binaries( model::JuMP.AbstractModel, @@ -301,76 +277,6 @@ function unfix_combination_binaries( end end -# OVERRIDABLE. Build a raw V&G 1990 NLPF submodel: copy decision -# variables, copy global (non-reformulation) constraints and the active -# disjuncts' original pre-BigM constraints. The shared slack `u` and -# `min u` objective are layered on by `_embed_feas_slack`. Returns -# `(sub::GDPSubmodel, objective_ref_map)`. -function copy_model_with_constraints( - model::JuMP.AbstractModel, - disjunct_constraint_refs::Vector{<:DisjunctConstraintRef}, - method::LOA - ) - sub_model = _copy_model(model) - fwd_map = copy_variables_onto_model(sub_model, model) - variable_type = JuMP.variable_ref_type(typeof(model)) - reform_set = is_gdp_model(model) ? - Set(_reformulation_constraints(model)) : Set() - for (F, S) in JuMP.list_of_constraint_types(model) - F === variable_type && continue - for cref in JuMP.all_constraints(model, F, S) - cref in reform_set && continue - con = JuMP.constraint_object(cref) - expr = replace_variables_in_constraint(con.func, fwd_map) - JuMP.@constraint(sub_model, expr in con.set) - end - end - for cref in disjunct_constraint_refs - con = JuMP.constraint_object(cref) - expr = replace_variables_in_constraint(con.func, fwd_map) - JuMP.@constraint(sub_model, expr in con.set) - end - JuMP.set_optimizer(sub_model, method.nlp_optimizer) - JuMP.set_silent(sub_model) - return ( - sub = GDPSubmodel(sub_model, collect_all_vars(model), fwd_map), - objective_ref_map = fwd_map - ) -end - -# Convert the raw copy into a V&G 1990 NLPF problem: shared slack -# embedded in every constraint via `_slacken`, `min slack` objective. -function _embed_feas_slack(feas::NamedTuple) - model = feas.sub.model - slack = JuMP.@variable(model, base_name = "_feas_slack", lower_bound = 0.0) - variable_type = JuMP.variable_ref_type(typeof(model)) - to_slacken = Any[] - for (F, S) in JuMP.list_of_constraint_types(model) - F === variable_type && continue - append!(to_slacken, JuMP.all_constraints(model, F, S)) - end - for constraint_ref in to_slacken - constraint = JuMP.constraint_object(constraint_ref) - for (slack_func, slack_set) in _slacken( - constraint.func, constraint.set, slack) - JuMP.@constraint(model, slack_func in slack_set) - end - JuMP.delete(model, constraint_ref) - end - JuMP.@objective(model, Min, slack) - return -end - -# Apply slack `u` to a constraint based on its set type. ≤: `f − u`; -# ≥: `f + u`; ==: split into ≤ and ≥; otherwise passthrough. -_slacken(f, set::_MOI.LessThan, u) = [(f - u, set)] -_slacken(f, set::_MOI.GreaterThan, u) = [(f + u, set)] -function _slacken(f, set::_MOI.EqualTo, u) - b = _MOI.constant(set) - return [(f - u, _MOI.LessThan(b)), (f + u, _MOI.GreaterThan(b))] -end -_slacken(f, set, u) = [(f, set)] - ################################################################################ # BIGM DUAL COLLECTION ################################################################################ From 9a9662fd8fc986789d035767d74f821f6b0d8a82 Mon Sep 17 00:00:00 2001 From: d227nguyen Date: Thu, 14 May 2026 15:59:32 -0400 Subject: [PATCH 40/59] Adding global constraints to cuts --- ext/InfiniteDisjunctiveProgramming.jl | 331 +++++++++++++------------- src/loa.jl | 93 ++++---- src/utilities.jl | 27 --- 3 files changed, 213 insertions(+), 238 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index c875c280..7c2024a2 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -340,7 +340,7 @@ _per_support_values(variable::InfiniteOpt.GeneralVariableRef) = # Read per-support values from the transformation backend, keyed by # InfiniteOpt vars. Skips fixed vars. The objective-side translation # (transcribe-then-AD when the objective has aggregate refs) lives -# in the `_add_oa_cuts(::InfiniteModel, ...)` override below — base +# in the `add_oa_cuts(::InfiniteModel, ...)` override below — base # `extract_solution` doesn't need to anticipate it. function DP.extract_solution(model::InfiniteOpt.InfiniteModel) return Dict(var => _per_support_values(var) @@ -411,10 +411,18 @@ _is_point_var(v::InfiniteOpt.GeneralVariableRef) = # function value into one ref. Either hides decision-variable # dependence from MOI Nonlinear AD, so LOA needs to transcribe the # enclosing expression to recover correct gradients. -DP.is_aggregate_ref(v::InfiniteOpt.GeneralVariableRef) = +_is_aggregate_ref(v::InfiniteOpt.GeneralVariableRef) = InfiniteOpt.dispatch_variable_ref(v) isa Union{ InfiniteOpt.MeasureRef, InfiniteOpt.ParameterFunctionRef} +function _has_aggregate_ref(expr) + found = Ref(false) + DP._interrogate_variables(expr) do v + found[] || (found[] = _is_aggregate_ref(v)) + end + return found[] +end + # Resolve a `PointVariableRef` (e.g., `L(0)` from a boundary # condition) to the master's corresponding point variable: look up # the underlying infinite var in `var_map`, then point-evaluate at @@ -430,21 +438,6 @@ function DP.replace_variables_in_constraint( return var_map[v] end -# Per-support fix via point constraints. Used by the LOA feas -# submodel (`fix_indicator(feas_binary, combination_value)`) when the -# master returned a per-support combination. -function DP.fix_indicator( - binary_ref::InfiniteOpt.GeneralVariableRef, - values::AbstractVector{Bool} - ) - model = JuMP.owner_model(binary_ref) - for (k, support) in enumerate(_supports_of(binary_ref)) - JuMP.@constraint(model, - binary_ref(support) == (values[k] ? 1.0 : 0.0)) - end - return -end - # Bool active arises from `_set_covering_combinations`, which keys # combinations on `LogicalVariableRef → Bool` regardless of whether # the indicator is infinite. Broadcast over all supports for an @@ -535,62 +528,6 @@ _at(scalar, ::Integer) = scalar _at_support(v::InfiniteOpt.GeneralVariableRef, support) = isempty(InfiniteOpt.parameter_refs(v)) ? v : v(support) -# Override the base `copy_variables_onto_model` for `InfiniteModel`: in addition -# to decision variables, copy infinite parameters (with supports), -# derivatives, and parameter functions. `PointVariableRef`s (e.g. -# `L(0)` from boundary conditions) are skipped — they re-emerge on -# the target via `replace_variables_in_constraint`. -function DP.copy_variables_onto_model( - target::InfiniteOpt.InfiniteModel, - source::InfiniteOpt.InfiniteModel - ) - ref_map = Dict{InfiniteOpt.GeneralVariableRef, - InfiniteOpt.GeneralVariableRef}() - for p in InfiniteOpt.all_parameters(source) - domain = InfiniteOpt.infinite_domain(p) - supports = Float64.(InfiniteOpt.supports(p)) - param = InfiniteOpt.build_parameter(error, domain; - supports = supports) - ref_map[p] = InfiniteOpt.add_parameter(target, param, - JuMP.name(p)) - end - for v in JuMP.all_variables(source) - # Point variables (e.g. `L(0)` from boundary conditions) are - # implicit evaluations of their underlying infinite var; they - # carry NaN start values by default and don't need to be - # copied as primary decision variables. They re-emerge on the - # target via `replace_variables_in_constraint`. - _is_point_var(v) && continue - prefs = InfiniteOpt.parameter_refs(v) - var_type = isempty(prefs) ? nothing : - InfiniteOpt.Infinite(Tuple(ref_map[p] for p in prefs)...) - props = DP.VariableProperties( - DP.get_variable_info(v), JuMP.name(v), nothing, var_type) - ref_map[v] = DP.create_variable(target, props) - end - for d in InfiniteOpt.all_derivatives(source) - vref = InfiniteOpt.derivative_argument(d) - pref = InfiniteOpt.operator_parameter(d) - new_d = InfiniteOpt.deriv(ref_map[vref], ref_map[pref]) - info = DP.get_variable_info(d) - info.has_lb && JuMP.set_lower_bound(new_d, info.lower_bound) - info.has_ub && JuMP.set_upper_bound(new_d, info.upper_bound) - ref_map[d] = new_d - end - for pfunc in InfiniteOpt.all_parameter_functions(source) - func = InfiniteOpt.raw_function(pfunc) - prefs = InfiniteOpt.parameter_refs(pfunc) - mapped_prefs = Tuple(ref_map[p] for p in prefs) - pref_arg = length(mapped_prefs) == 1 ? - only(mapped_prefs) : mapped_prefs - param_func = InfiniteOpt.build_parameter_function(error, - func, pref_arg) - ref_map[pfunc] = InfiniteOpt.add_parameter_function(target, - param_func) - end - return ref_map -end - # Build map: transcribed input JuMP var → master point variable. # For an infinite input var v, every transcribed support `v_k` # maps to `ref_map[v](d_k)` (master point variable). Used as @@ -635,16 +572,15 @@ function _transcribed_to_master_point( return result end -# Build the LOA master as a fresh empty `InfiniteModel` populated -# with the input model's structural skeleton plus its linear -# constraints. Install `alpha_oa` as a finite variable. -# `binary_map[indicator]` and `variable_map[v]` hold single -# InfiniteOpt vars on the master; per-support handling happens -# downstream via point evaluation on those refs. OA cuts added in -# the LOA loop are point-evaluated scalar constraints on the master -# InfiniteModel; transcription is rebuilt before each master solve. +# Build the LOA master from `JuMP.copy_model(model)` and strip the +# nonlinear constraints (they re-enter via OA cuts). `binary_map[ +# indicator]` and `variable_map[v]` hold single InfiniteOpt vars on +# the master; per-support handling happens downstream via point +# evaluation on those refs. OA cuts added in the LOA loop are +# point-evaluated scalar constraints on the master InfiniteModel; +# transcription is rebuilt before each master solve. # -# Objective handling branches on `has_aggregate_ref`. When the +# Objective handling branches on `_has_aggregate_ref`. When the # objective is aggregate-free (no MeasureRef / ParameterFunctionRef), # `original_objective` is the InfiniteOpt objective itself and # `objective_ref_map = ref_map`, so AD walks the InfiniteOpt @@ -654,27 +590,44 @@ end function DP.build_loa_master( model::InfiniteOpt.InfiniteModel, method::DP.LOA ) - master = InfiniteOpt.InfiniteModel() + master, copy_ref_map = JuMP.copy_model(model) + # `copy_model` copies the GDP optimize-hook; clear it so + # `optimize!(master)` doesn't re-trigger reformulation on the + # (empty-GDP-data) master copy. + JuMP.set_optimize_hook(master, nothing) JuMP.set_optimizer(master, method.mip_optimizer) JuMP.set_silent(master) - ref_map = DP.copy_variables_onto_model(master, model) - + # Strip nonlinear constraints; they re-enter via OA cuts. + # Variable bounds (F=GeneralVariableRef) are kept by `copy_model`. variable_type = InfiniteOpt.GeneralVariableRef - for (F, S) in JuMP.list_of_constraint_types(model) + for (F, S) in JuMP.list_of_constraint_types(master) F === variable_type && continue - DP._is_linear_F(F) || continue - for cref in JuMP.all_constraints(model, F, S) - con = JuMP.constraint_object(cref) - new_func = DP.replace_variables_in_constraint( - con.func, ref_map) - JuMP.@constraint(master, new_func in con.set) + DP._is_linear_F(F) && continue + for cref in JuMP.all_constraints(master, F, S) + JuMP.delete(master, cref) end end + # InfiniteReferenceMap supports indexing but not iteration; build + # a Dict so downstream LOA code can `haskey` / iterate over the + # source-side refs LOA cares about. + ref_map = Dict{InfiniteOpt.GeneralVariableRef, + InfiniteOpt.GeneralVariableRef}() + for v in DP.collect_all_vars(model) + _is_point_var(v) && continue + ref_map[v] = copy_ref_map[v] + end + for p in InfiniteOpt.all_parameters(model) + ref_map[p] = copy_ref_map[p] + end + for pfunc in InfiniteOpt.all_parameter_functions(model) + ref_map[pfunc] = copy_ref_map[pfunc] + end + raw_objective = JuMP.objective_function(model) objective_sense = JuMP.objective_sense(model) - if DP.has_aggregate_ref(raw_objective) + if _has_aggregate_ref(raw_objective) InfiniteOpt.build_transformation_backend!(model) transcribed_input = InfiniteOpt.transformation_model(model) original_objective = JuMP.objective_function(transcribed_input) @@ -709,23 +662,24 @@ function DP.build_loa_master( end # Override the disjunct-cut loop for `InfiniteModel`. Same shape as -# Override `_add_oa_cuts` for `InfiniteModel` to translate the +# Override `add_oa_cuts` for `InfiniteModel` to translate the # linearization point into the form the master's `original_objective` -# expects. The base `result.linearization_point` has per-support -# `Vector` values keyed on InfiniteOpt vars; the master's objective -# is either the raw InfiniteOpt expression (non-aggregate, expects -# scalar `xk[v]`) or the transcribed flat scalar expression -# (aggregate, expects transcribed-`JuMP.VariableRef`-keyed scalar -# dict). The translation produces whichever shape `_linearize_at` -# needs for this master. -function DP._add_oa_cuts( +# expects, and to route global OA cuts through transcription so they +# work over infinite vars and aggregate refs. The base +# `result.linearization_point` has per-support `Vector` values keyed +# on InfiniteOpt vars; the master's objective is either the raw +# InfiniteOpt expression (non-aggregate, expects scalar `xk[v]`) or +# the transcribed flat scalar expression (aggregate, expects +# transcribed-`JuMP.VariableRef`-keyed scalar dict). The translation +# produces whichever shape `_linearize_at` needs. +function DP.add_oa_cuts( model::InfiniteOpt.InfiniteModel, master::NamedTuple, result::NamedTuple, method::DP.LOA ) isempty(result.linearization_point) && return - obj_point = if DP.has_aggregate_ref(JuMP.objective_function(model)) + obj_point = if _has_aggregate_ref(JuMP.objective_function(model)) _transcribe_linearization_point( model, result.linearization_point) else @@ -739,15 +693,64 @@ function DP._add_oa_cuts( obj_point, master.objective_ref_map) DP._add_objective_cut( Val(master.objective_sense), master, linearization) - # WIP: InfiniteOpt globals need per-support handling analogous to - # the obj_point translation above (transcribe-or-flatten). Until - # that's implemented, base `_add_global_oa_cuts` is skipped here — - # calling it would break on infinite vars (per-support `Vector` - # values flow into `_linearize_at`'s scalar-only AD path). + _add_global_oa_cuts_infinite(model, master, result, method) DP.add_disjunct_oa_cuts(model, master, result, method) return end +# Global OA cuts for `InfiniteModel`. Mirrors base `_add_global_oa_cuts` +# but routes through transcription so per-support / aggregate-ref +# expressions reach `_linearize_at` as flat scalars over `JuMP.VariableRef`s. +# Aggregate-containing constraints (e.g. `∫f(x,t)dt ≤ 0`) transcribe +# to a single scalar expression. Constraints with infinite-parameter +# dependence (e.g. `x(t) ≥ 0`) transcribe to a per-support `AbstractArray` +# of scalar expressions — one OA cut is emitted per support. +function _add_global_oa_cuts_infinite( + model::InfiniteOpt.InfiniteModel, + master::NamedTuple, + result::NamedTuple, + method::DP.LOA + ) + variable_type = InfiniteOpt.GeneralVariableRef + reform_set = DP.is_gdp_model(model) ? + Set(DP._reformulation_constraints(model)) : Set() + transcribed_xk = Ref{Any}(nothing) + transcribed_to_master = Ref{Any}(nothing) + ensure_transcribed = function () + transcribed_xk[] === nothing || return + InfiniteOpt.build_transformation_backend!(model) + transcribed_xk[] = _transcribe_linearization_point( + model, result.linearization_point) + transcribed_to_master[] = _transcribed_to_master_point( + model, master.variable_map) + return + end + for (F, S) in JuMP.list_of_constraint_types(model) + F === variable_type && continue + DP._is_linear_F(F) && continue + for cref in JuMP.all_constraints(model, F, S) + cref in reform_set && continue + con = JuMP.constraint_object(cref) + con isa JuMP.ScalarConstraint || continue + ensure_transcribed() + transcribed_func = InfiniteOpt.transformation_expression( + con.func) + if transcribed_func isa AbstractArray + for tf in vec(transcribed_func) + lin = DP._linearize_at(tf, transcribed_xk[], + transcribed_to_master[]) + JuMP.@constraint(master.model, lin in con.set) + end + else + lin = DP._linearize_at(transcribed_func, + transcribed_xk[], transcribed_to_master[]) + JuMP.@constraint(master.model, lin in con.set) + end + end + end + return +end + # Override `add_disjunct_oa_cuts` for `InfiniteModel`. Same shape as # the base loop, but each constraint is checked for aggregate refs. # Aggregate constraints (e.g. those containing a `MeasureRef`) are @@ -791,7 +794,7 @@ function DP.add_disjunct_oa_cuts( JuMP.index(orig_constraint_ref)].constraint dual = get(result.duals, orig_constraint_ref, nothing) dual === nothing && continue - if DP.has_aggregate_ref(constraint.func) + if _has_aggregate_ref(constraint.func) ensure_transcribed() transcribed_func = InfiniteOpt.transformation_expression( @@ -824,35 +827,77 @@ function DP.add_disjunct_oa_cuts( end end -# Fix indicator binaries on the NLP model. Bool: fix the whole -# infinite var across all supports. Vector{Bool}: fix per-support via -# point equality constraints (refs stashed for `unfix_combination_binaries`). -function DP.fix_combination_binaries( +# Fix indicators for `combination`, run `f()`, then undo. Bool values +# are whole-var fixes via `JuMP.fix`; AbstractVector values are +# per-support point-equality constraints. Refs and fixed binaries are +# tracked in local state so cleanup runs from the same closure — no +# `model.ext` stash. +function DP.with_fixed_combination( + f, model::InfiniteOpt.InfiniteModel, combination::AbstractDict ) - fixing_constraint_refs = InfiniteOpt.InfOptConstraintRef[] + constraint_refs = InfiniteOpt.InfOptConstraintRef[] + fixed_binaries = InfiniteOpt.GeneralVariableRef[] + for (indicator, value) in combination + binary_ref = DP._indicator_to_binary(model)[indicator] + _apply_fix!( + constraint_refs, fixed_binaries, model, binary_ref, value) + end + try + return f() + finally + for ref in constraint_refs + JuMP.is_valid(model, ref) && JuMP.delete(model, ref) + end + for binary_ref in fixed_binaries + JuMP.is_fixed(binary_ref) && JuMP.unfix(binary_ref) + end + end +end + +# Finalize the LOA-best combination + warm start, leaving the model +# in a state the post-hook `optimize!` will accept. Mirrors +# `with_fixed_combination` for the fixing half but skips bookkeeping +# (these fixes stay). +function DP.commit_combination( + model::InfiniteOpt.InfiniteModel, + combination::AbstractDict, + linearization_point::AbstractDict + ) + constraint_refs = InfiniteOpt.InfOptConstraintRef[] + fixed_binaries = InfiniteOpt.GeneralVariableRef[] for (indicator, value) in combination binary_ref = DP._indicator_to_binary(model)[indicator] - _fix_binary_at_supports( - fixing_constraint_refs, model, binary_ref, value + _apply_fix!( + constraint_refs, fixed_binaries, model, binary_ref, value) + end + for (variable, values) in linearization_point + _set_starts_for_transcribed( + InfiniteOpt.transformation_variable(variable), + values ) end - model.ext[:_loa_fixing_constraint_refs] = fixing_constraint_refs + return end -function _fix_binary_at_supports( - _, - _, +# Bool: fix the whole infinite var across all supports. +function _apply_fix!( + ::AbstractVector, + fixed::AbstractVector, + ::InfiniteOpt.InfiniteModel, binary_ref::InfiniteOpt.GeneralVariableRef, value::Bool ) JuMP.fix(binary_ref, value ? 1.0 : 0.0; force = true) + push!(fixed, binary_ref) return end -function _fix_binary_at_supports( +# Vector{Bool}: per-support point-equality constraints. +function _apply_fix!( refs::AbstractVector, + ::AbstractVector, model::InfiniteOpt.InfiniteModel, binary_ref::InfiniteOpt.GeneralVariableRef, values::AbstractVector{Bool} @@ -864,18 +909,6 @@ function _fix_binary_at_supports( return end -function DP.set_start_values( - ::InfiniteOpt.InfiniteModel, - linearization_point::AbstractDict - ) - for (variable, values) in linearization_point - _set_starts_for_transcribed( - InfiniteOpt.transformation_variable(variable), - values - ) - end -end - # Infinite var: per-support transcribed array function _set_starts_for_transcribed( transcribed::AbstractArray, @@ -901,38 +934,6 @@ _set_starts_for_transcribed( ) = JuMP.set_start_value(transcribed, value) -function DP.unfix_combination_binaries( - model::InfiniteOpt.InfiniteModel, - combination::AbstractDict - ) - if haskey(model.ext, :_loa_fixing_constraint_refs) - for fixing_ref in model.ext[:_loa_fixing_constraint_refs] - JuMP.is_valid(model, fixing_ref) && - JuMP.delete(model, fixing_ref) - end - delete!(model.ext, :_loa_fixing_constraint_refs) - end - for (indicator, value) in combination - binary_ref = DP._indicator_to_binary(model)[indicator] - _unfix_binary_at_supports(binary_ref, value) - end -end - -# Bool: unfix the whole-var fix -function _unfix_binary_at_supports( - binary_ref::InfiniteOpt.GeneralVariableRef, - ::Bool - ) - JuMP.is_fixed(binary_ref) && JuMP.unfix(binary_ref) - return -end - -# Vector{Bool}: per-support fixing was via constraints already deleted above -_unfix_binary_at_supports( - ::InfiniteOpt.GeneralVariableRef, - ::AbstractVector{Bool} - ) = nothing - # Convert an InfiniteModel-var-keyed per-support point into a # transcribed-JuMP-var-keyed scalar point. Companion to # `_transcribed_to_master_point`: feeds the AD walker for the diff --git a/src/loa.jl b/src/loa.jl index a6c9e297..86dfb533 100644 --- a/src/loa.jl +++ b/src/loa.jl @@ -85,7 +85,7 @@ function reformulate_model(model::JuMP.AbstractModel, method::LOA) for combination in combinations result = _solve_nlp(model, combination, method, reformulation_map) avoid_combination(master.model, combination, master.binary_map) - _add_oa_cuts(model, master, result, method) + add_oa_cuts(model, master, result, method) if result.feasible && is_better(result.objective) best_objective = result.objective best_result = result @@ -101,7 +101,7 @@ function reformulate_model(model::JuMP.AbstractModel, method::LOA) combination = _extract_combination(model, master) result = _solve_nlp(model, combination, method, reformulation_map) avoid_combination(master.model, combination, master.binary_map) - _add_oa_cuts(model, master, result, method) + add_oa_cuts(model, master, result, method) if result.feasible && is_better(result.objective) best_objective = result.objective best_result = result @@ -109,8 +109,8 @@ function reformulate_model(model::JuMP.AbstractModel, method::LOA) end if best_result !== nothing - fix_combination_binaries(model, best_result.combination) - set_start_values(model, best_result.linearization_point) + commit_combination(model, best_result.combination, + best_result.linearization_point) end _set_solution_method(model, method) _set_ready_to_optimize(model, true) @@ -239,42 +239,59 @@ end function _solve_nlp( model::M, combination, method::LOA, reformulation_map ) where {M <: JuMP.AbstractModel} - fix_combination_binaries(model, combination) - JuMP.optimize!(model, ignore_optimize_hook = true) - if JuMP.is_solved_and_feasible(model) - lin_point = extract_solution(model) - duals = _collect_nlp_duals(model, combination, reformulation_map) - objective_val = JuMP.objective_value(model) - unfix_combination_binaries(model, combination) + return with_fixed_combination(model, combination) do + JuMP.optimize!(model, ignore_optimize_hook = true) + if JuMP.is_solved_and_feasible(model) + lin_point = extract_solution(model) + duals = _collect_nlp_duals( + model, combination, reformulation_map) + objective_val = JuMP.objective_value(model) + return (combination = combination, + linearization_point = lin_point, duals = duals, + objective = objective_val, feasible = true) + end return (combination = combination, - linearization_point = lin_point, duals = duals, - objective = objective_val, feasible = true) + linearization_point = Dict{JuMP.AbstractVariableRef, Any}(), + duals = Dict{DisjunctConstraintRef{M}, Any}(), + objective = Inf, feasible = false) end - unfix_combination_binaries(model, combination) - return (combination = combination, - linearization_point = Dict{JuMP.AbstractVariableRef, Any}(), - duals = Dict{DisjunctConstraintRef{M}, Any}(), - objective = Inf, feasible = false) end -# OVERRIDABLE. Fix every indicator in `combination` on `model`. -function fix_combination_binaries( +# OVERRIDABLE. Fix the indicators in `combination`, run `f()`, unfix. +# Holds the fix/unfix lifecycle in one call so extensions can manage +# any per-support bookkeeping locally without `model.ext` stashing. +function with_fixed_combination( + f, model::JuMP.AbstractModel, combination::AbstractDict ) for (indicator, active) in combination fix_indicator(model, indicator, active) end + try + return f() + finally + for (indicator, _) in combination + unfix_indicator(model, indicator) + end + end end -# OVERRIDABLE. Inverse of `fix_combination_binaries`. -function unfix_combination_binaries( +# OVERRIDABLE. Finalize the model with the LOA-optimal combination: +# fix indicators (left fixed for the post-hook `optimize!`) and warm +# start from the linearization point. +function commit_combination( model::JuMP.AbstractModel, - combination::AbstractDict + combination::AbstractDict, + linearization_point::AbstractDict ) - for (indicator, _) in combination - unfix_indicator(model, indicator) + for (indicator, active) in combination + fix_indicator(model, indicator, active) + end + for (variable, value) in linearization_point + JuMP.set_start_value(variable, _unwrap_scalar(value)) end + return end ################################################################################ @@ -381,7 +398,7 @@ combination_val(binary_ref) = round(Bool, JuMP.value(binary_ref)) ################################################################################ # OA CUT EMISSION ################################################################################ -function _add_oa_cuts( +function add_oa_cuts( model::JuMP.AbstractModel, master::NamedTuple, result::NamedTuple, @@ -402,11 +419,6 @@ _add_objective_cut(::Val{_MOI.MIN_SENSE}, master, lin) = _add_objective_cut(::Val{_MOI.MAX_SENSE}, master, lin) = JuMP.@constraint(master.model, lin >= master.alpha_oa) -# WIP: finite-only. InfiniteOpt's `_add_oa_cuts(::InfiniteModel, ...)` -# override skips the call to this — globals over infinite vars yield -# per-support `Vector` values that the scalar-only `_linearize_at` AD -# path can't consume. A per-support-aware variant is the next step. -# # Add the OA cut `g(x^l) + ∇g(x^l)^T (x − x^l) in con.set` for every # nonlinear global constraint of `model` — the third cut class in # Türkay & Grossmann (1996, eq. 12) alongside the objective and the @@ -416,6 +428,10 @@ _add_objective_cut(::Val{_MOI.MAX_SENSE}, master, lin) = # `LessThan` and `GreaterThan` are supported; equalities and vector # constraints are passed through to `JuMP.@constraint` as-is — # valid for affine-after-linearization sets. +# +# Finite-only. InfiniteOpt's `add_oa_cuts(::InfiniteModel, ...)` +# override uses `_add_global_oa_cuts_infinite`, which routes through +# transcription to handle per-support / aggregate-ref globals. function _add_global_oa_cuts( model::JuMP.AbstractModel, master::NamedTuple, @@ -548,18 +564,3 @@ function _linearize_at( return result end -################################################################################ -# FINALIZATION -################################################################################ -# OVERRIDABLE. Set JuMP start values from a scalar-valued -# linearization-point dict. Warm-starts the post-hook `optimize!` -# that JuMP fires after `reformulate_model` — without this the final -# NLP solve restarts from wherever the last LOA iteration left the -# model. InfiniteOpt overrides for per-support Vector values. -function set_start_values( - ::JuMP.AbstractModel, linearization_point::AbstractDict - ) - for (variable, value) in linearization_point - JuMP.set_start_value(variable, _unwrap_scalar(value)) - end -end diff --git a/src/utilities.jl b/src/utilities.jl index 435ed27c..790aaffc 100644 --- a/src/utilities.jl +++ b/src/utilities.jl @@ -539,33 +539,6 @@ end # for outer approximation methods (LOA, future OA variants). ################################################################################ -################################################################################ -# AGGREGATE-REF DETECTION -################################################################################ -# Predicate: does the variable ref aggregate multiple decision -# variables behind a single leaf? An "aggregate" ref is one that AD -# cannot see inside — e.g. an InfiniteOpt `MeasureRef` (`∫ f(x,t) dt` -# is one ref but depends on `x(t_1), …, x(t_K)`) or a -# `ParameterFunctionRef`. Base returns false; the InfiniteOpt -# extension overrides for aggregate ref types. -# -# When `has_aggregate_ref(expr)` is true, MOI Nonlinear AD on `expr` -# would treat the aggregate as a single opaque variable and produce a -# meaningless gradient. The LOA pipeline falls back to transcription -# in that case (flat scalar expression, AD on the flat form, then map -# back to master refs). -# -# #suggestions for names are welcome -is_aggregate_ref(::JuMP.AbstractVariableRef) = false - -function has_aggregate_ref(expr) - found = Ref(false) - _interrogate_variables(expr) do v - found[] || (found[] = is_aggregate_ref(v)) - end - return found[] -end - ################################################################################ # MOI NONLINEAR EXPRESSION CONVERSION ################################################################################ From ca6118344853dfe85cb6f6e439fc03b835a76fa4 Mon Sep 17 00:00:00 2001 From: d227nguyen Date: Fri, 15 May 2026 15:34:28 -0400 Subject: [PATCH 41/59] Global slack bug fix --- Project.toml | 13 ++-- ext/InfiniteDisjunctiveProgramming.jl | 24 +++++- src/loa.jl | 75 +++++++++++++++++-- .../InfiniteDisjunctiveProgramming.jl | 73 ++++++++++++++++++ 4 files changed, 170 insertions(+), 15 deletions(-) diff --git a/Project.toml b/Project.toml index 3ca54b77..158e638c 100644 --- a/Project.toml +++ b/Project.toml @@ -10,25 +10,28 @@ Reexport = "189a3867-3050-52da-a836-e630ba90ab69" [weakdeps] InfiniteOpt = "20393b10-9daf-11e9-18c9-8db751c92c57" +[sources] +InfiniteOpt = {path = "..\\InfiniteOpt.jl"} + [extensions] InfiniteDisjunctiveProgramming = "InfiniteOpt" [compat] Aqua = "0.8" +InfiniteOpt = "0.6" +Ipopt = "1.9.0" JuMP = "1.18" +Juniper = "0.9.3" Reexport = "1" julia = "1.10" -Juniper = "0.9.3" -Ipopt = "1.9.0" -InfiniteOpt = "0.6" [extras] Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" HiGHS = "87dc4568-4c63-4d18-b0c0-bb2238e4078b" -Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" +InfiniteOpt = "20393b10-9daf-11e9-18c9-8db751c92c57" Ipopt = "b6b21f68-93f8-5de0-b562-5493be1d77c9" Juniper = "2ddba703-00a4-53a7-87a5-e8b9971dde84" -InfiniteOpt = "20393b10-9daf-11e9-18c9-8db751c92c57" +Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [targets] test = ["Aqua", "HiGHS", "Test", "Juniper", "Ipopt", "InfiniteOpt"] diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 7c2024a2..99703e34 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -325,7 +325,10 @@ function DP.copy_and_reformulate( end sub = DP.GDPSubmodel(sub_copy, decision_vars, fwd_map) JuMP.set_optimizer(sub.model, method.optimizer) - JuMP.set_silent(sub.model) + # NOTE: previously called JuMP.set_silent(sub.model) here, but + # that hides the Gurobi B&B trace even when the caller passes + # OutputFlag=1 / LogToConsole=1 via optimizer_with_attributes. + # The caller is now responsible for silencing if desired. return sub end @@ -600,11 +603,20 @@ function DP.build_loa_master( # Strip nonlinear constraints; they re-enter via OA cuts. # Variable bounds (F=GeneralVariableRef) are kept by `copy_model`. + # An aggregate-wrapped constraint (e.g. `∫(x^2,t) ≤ c`) is + # structurally affine over a `MeasureRef` so `_is_linear_F` reads + # it as linear, but it expands to a nonlinear form on + # transcription — strip it too and let the OA cut re-add the + # linearized version. variable_type = InfiniteOpt.GeneralVariableRef for (F, S) in JuMP.list_of_constraint_types(master) F === variable_type && continue - DP._is_linear_F(F) && continue + is_linear = DP._is_linear_F(F) for cref in JuMP.all_constraints(master, F, S) + if is_linear + con = JuMP.constraint_object(cref) + _has_aggregate_ref(con.func) || continue + end JuMP.delete(master, cref) end end @@ -711,6 +723,8 @@ function _add_global_oa_cuts_infinite( result::NamedTuple, method::DP.LOA ) + _, penalty_sign = DP._disjunct_cut_coefficients( + Val(master.objective_sense)) variable_type = InfiniteOpt.GeneralVariableRef reform_set = DP.is_gdp_model(model) ? Set(DP._reformulation_constraints(model)) : Set() @@ -739,12 +753,14 @@ function _add_global_oa_cuts_infinite( for tf in vec(transcribed_func) lin = DP._linearize_at(tf, transcribed_xk[], transcribed_to_master[]) - JuMP.@constraint(master.model, lin in con.set) + DP._add_global_oa_row!(master, lin, con.set, + method, penalty_sign) end else lin = DP._linearize_at(transcribed_func, transcribed_xk[], transcribed_to_master[]) - JuMP.@constraint(master.model, lin in con.set) + DP._add_global_oa_row!(master, lin, con.set, + method, penalty_sign) end end end diff --git a/src/loa.jl b/src/loa.jl index 86dfb533..7bd59794 100644 --- a/src/loa.jl +++ b/src/loa.jl @@ -438,6 +438,8 @@ function _add_global_oa_cuts( result::NamedTuple, method::LOA ) + _, penalty_sign = _disjunct_cut_coefficients( + Val(master.objective_sense)) variable_type = JuMP.variable_ref_type(typeof(model)) reform_set = is_gdp_model(model) ? Set(_reformulation_constraints(model)) : Set() @@ -450,7 +452,8 @@ function _add_global_oa_cuts( con isa JuMP.ScalarConstraint || continue linearization = _linearize_at(con.func, result.linearization_point, master.variable_map) - JuMP.@constraint(master.model, linearization in con.set) + _add_global_oa_row!(master, linearization, con.set, + method, penalty_sign) end end return @@ -489,6 +492,22 @@ function add_disjunct_oa_cuts( end end +# Fresh nonnegative slack added to the master objective with the V&G +# 1990 penalty. Shared by the disjunct and global OA cuts so both get +# the same augmented-penalty treatment: a nonconvex linearization can +# be an invalid relaxation, and the penalized slack keeps the master +# feasible instead of letting accumulated cuts make it infeasible. +function _penalized_slack( + master::NamedTuple, method::LOA, penalty_sign::Int + ) + slack = JuMP.@variable(master.model, + lower_bound = 0.0, upper_bound = method.max_slack) + JuMP.set_objective_function(master.model, + JuMP.objective_function(master.model) + + penalty_sign * method.OA_penalty_factor * slack) + return slack +end + # Linearize constraint at `linearization_point`, append a fresh per-cut # slack with V&G penalty, gate by `M(1 − binary)`. Linear constraints # are exact via BigM and skipped. @@ -508,17 +527,61 @@ function _add_oa_cut_for_constraint( sign_value == 0 && return rhs = _set_rhs(constraint.set) linearization = _linearize_at(constraint.func, linearization_point, var_map) - slack = JuMP.@variable(master.model, - lower_bound = 0.0, upper_bound = method.max_slack) - JuMP.set_objective_function(master.model, - JuMP.objective_function(master.model) + - penalty_sign * method.OA_penalty_factor * slack) + slack = _penalized_slack(master, method, penalty_sign) JuMP.@constraint(master.model, sign_value * (linearization - rhs) - slack <= method.M_value * (1 - binary_ref)) return end +# Slacked global OA row(s) (V&G 1990). The nonconvex linearization may +# be an invalid relaxation, so each row carries a penalized slack +# rather than being a hard constraint — without this the accumulated +# global cuts make the master infeasible on nonconvex models. +# `EqualTo` / `Interval` get a two-sided pair sharing one slack. +# Unknown set types fall back to the prior hard cut. +function _add_global_oa_row!( + master::NamedTuple, lin, set::_MOI.LessThan, + method::LOA, penalty_sign::Int + ) + slack = _penalized_slack(master, method, penalty_sign) + JuMP.@constraint(master.model, lin - _MOI.constant(set) <= slack) + return +end +function _add_global_oa_row!( + master::NamedTuple, lin, set::_MOI.GreaterThan, + method::LOA, penalty_sign::Int + ) + slack = _penalized_slack(master, method, penalty_sign) + JuMP.@constraint(master.model, _MOI.constant(set) - lin <= slack) + return +end +function _add_global_oa_row!( + master::NamedTuple, lin, set::_MOI.EqualTo, + method::LOA, penalty_sign::Int + ) + slack = _penalized_slack(master, method, penalty_sign) + c = _MOI.constant(set) + JuMP.@constraint(master.model, lin - c <= slack) + JuMP.@constraint(master.model, c - lin <= slack) + return +end +function _add_global_oa_row!( + master::NamedTuple, lin, set::_MOI.Interval, + method::LOA, penalty_sign::Int + ) + slack = _penalized_slack(master, method, penalty_sign) + JuMP.@constraint(master.model, lin - set.upper <= slack) + JuMP.@constraint(master.model, set.lower - lin <= slack) + return +end +function _add_global_oa_row!( + master::NamedTuple, lin, set, ::LOA, ::Int + ) + JuMP.@constraint(master.model, lin in set) + return +end + # OVERRIDABLE. Yield `(binary_ref, lin_point, var_map, dual)` per OA # cut to emit. Scalar binary returns one tuple; InfiniteOpt overrides # for per-support `Vector` binaries to yield multiple sliced tuples. diff --git a/test/extensions/InfiniteDisjunctiveProgramming.jl b/test/extensions/InfiniteDisjunctiveProgramming.jl index 47bb884b..bacf987c 100644 --- a/test/extensions/InfiniteDisjunctiveProgramming.jl +++ b/test/extensions/InfiniteDisjunctiveProgramming.jl @@ -791,6 +791,63 @@ function test_CuttingPlanes_multiparameter() [MOI.OPTIMAL, MOI.LOCALLY_SOLVED] end +function test_loa_infinite_nonlinear_global() + # max ∫x dt s.t. x(t)^2 <= 25 (global, per-support nonlinear), + # (x <= 3) ∨ (x <= 8), 0 <= x <= 10 over t ∈ [0, 1]. + # Disjunct Y[2] permits x up to 8 but the global x^2 <= 25 caps + # x at 5. The per-support global transcribes to an `AbstractArray` + # of scalar constraints, so this exercises the array branch of + # `_add_global_oa_cuts_infinite`. Without the global cut the + # master would allow x = 8 and report 8.0; the binding optimum + # is ∫5 dt = 5. + ipopt = optimizer_with_attributes(Ipopt.Optimizer, + "print_level" => 0, "sb" => "yes") + model = InfiniteGDPModel(ipopt) + set_silent(model) + @infinite_parameter(model, t ∈ [0, 1], num_supports = 10) + @variable(model, 0 <= x <= 10, Infinite(t)) + @constraint(model, x^2 <= 25) + @variable(model, Y[1:2], InfiniteLogical(t)) + @constraint(model, x <= 3, Disjunct(Y[1])) + @constraint(model, x <= 8, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Max, ∫(x, t)) + optimize!(model, + gdp_method = LOA(ipopt; mip_optimizer = HiGHS.Optimizer)) + @test termination_status(model) in + (MOI.OPTIMAL, MOI.LOCALLY_SOLVED) + @test objective_value(model) ≈ 5.0 atol = 1e-2 +end + +function test_loa_infinite_aggregate_global() + # min ∫x dt s.t. ∫(x^2, t) <= 4 (aggregate global), x >= y, + # (y >= 1) ∨ (y >= 3), 0 <= x, y <= 10 over t ∈ [0, 1]. + # The aggregate global transcribes to a single scalar (the + # measure is flattened), exercising the non-array branch of + # `_add_global_oa_cuts_infinite`. Y[1] (y >= 1) is the cheaper + # disjunct: x = 1 satisfies x >= y and ∫x^2 = 1 <= 4, giving + # objective ∫1 dt = 1. + ipopt = optimizer_with_attributes(Ipopt.Optimizer, + "print_level" => 0, "sb" => "yes") + model = InfiniteGDPModel(ipopt) + set_silent(model) + @infinite_parameter(model, t ∈ [0, 1], num_supports = 10) + @variable(model, 0 <= x <= 10, Infinite(t)) + @variable(model, 0 <= y <= 10) + @constraint(model, ∫(x^2, t) <= 4) + @constraint(model, x >= y) + @variable(model, Y[1:2], Logical) + @constraint(model, y >= 1, Disjunct(Y[1])) + @constraint(model, y >= 3, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Min, ∫(x, t)) + optimize!(model, + gdp_method = LOA(ipopt; mip_optimizer = HiGHS.Optimizer)) + @test termination_status(model) in + (MOI.OPTIMAL, MOI.LOCALLY_SOLVED) + @test objective_value(model) ≈ 1.0 atol = 1e-2 +end + @testset "InfiniteDisjunctiveProgramming" begin @testset "Model" begin @@ -855,4 +912,20 @@ end test_CuttingPlanes_multiparameter() end + # LOA on InfiniteOpt needs `JuMP.copy_model(::InfiniteModel)` + # (build_loa_master). That lives in the InfiniteOpt fork and is + # not in the registered release, so guard the suite: it runs + # (and validates the global-cut paths) when the fork is dev'd + # into the env, and skips cleanly otherwise. + @testset "LOA" begin + if hasmethod(JuMP.copy_model, Tuple{InfiniteModel}) + test_loa_infinite_nonlinear_global() + test_loa_infinite_aggregate_global() + else + @info "Skipping InfiniteOpt LOA tests: " * + "JuMP.copy_model(::InfiniteModel) unavailable " * + "(registered InfiniteOpt; dev the fork to run)." + end + end + end From cd9dd700a6cf5b978f2a7867ecdd0017b01f1ac7 Mon Sep 17 00:00:00 2001 From: dnguyen227 <82475321+dnguyen227@users.noreply.github.com> Date: Tue, 19 May 2026 11:34:24 -0400 Subject: [PATCH 42/59] Update InfiniteOpt version to 0.6.2 --- Project.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Project.toml b/Project.toml index 3ca54b77..e1d401ae 100644 --- a/Project.toml +++ b/Project.toml @@ -20,7 +20,7 @@ Reexport = "1" julia = "1.10" Juniper = "0.9.3" Ipopt = "1.9.0" -InfiniteOpt = "0.6" +InfiniteOpt = "0.6.2" [extras] Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" From bb0db434f6e721beec5d8fcf85cf6919ceea6d77 Mon Sep 17 00:00:00 2001 From: d227nguyen Date: Sun, 24 May 2026 16:37:13 -0400 Subject: [PATCH 43/59] Add filter_constraints workflow --- ext/InfiniteDisjunctiveProgramming.jl | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 6399c9c1..5f77dce7 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -182,12 +182,11 @@ function DP.copy_model_with_constraints( constraints::Vector{<:DP.DisjunctConstraintRef}, method::DP._MBM ) - mini, ref_map = JuMP.copy_model(model) - - # Drop global constraints. - for cref in JuMP.all_constraints(mini) - JuMP.delete(mini, cref) - end + # Filter out every source constraint at copy time instead of + # copying then deleting. Equivalent end state, fewer allocations. + mini, ref_map = JuMP.copy_model( + model; filter_constraints = cref -> false + ) for cref in constraints con = JuMP.constraint_object(cref) From 34b3ea877bcecf6a82d00054d1a110ad425a2545 Mon Sep 17 00:00:00 2001 From: dnguyen227 <82475321+dnguyen227@users.noreply.github.com> Date: Mon, 25 May 2026 19:08:26 -0400 Subject: [PATCH 44/59] Bump InfiniteOpt version to 0.6.3 Updated InfiniteOpt version from 0.6.2 to 0.6.3. --- Project.toml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Project.toml b/Project.toml index e1d401ae..f3fbb43e 100644 --- a/Project.toml +++ b/Project.toml @@ -20,7 +20,7 @@ Reexport = "1" julia = "1.10" Juniper = "0.9.3" Ipopt = "1.9.0" -InfiniteOpt = "0.6.2" +InfiniteOpt = "0.6.3" [extras] Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" @@ -28,7 +28,6 @@ HiGHS = "87dc4568-4c63-4d18-b0c0-bb2238e4078b" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" Ipopt = "b6b21f68-93f8-5de0-b562-5493be1d77c9" Juniper = "2ddba703-00a4-53a7-87a5-e8b9971dde84" -InfiniteOpt = "20393b10-9daf-11e9-18c9-8db751c92c57" [targets] test = ["Aqua", "HiGHS", "Test", "Juniper", "Ipopt", "InfiniteOpt"] From 01d36dc33489e3ea1cf1c44d7c4027ea8cd3f5cf Mon Sep 17 00:00:00 2001 From: d227nguyen Date: Tue, 26 May 2026 11:52:18 -0400 Subject: [PATCH 45/59] . --- ext/InfiniteDisjunctiveProgramming.jl | 67 +++++++++++++++------------ 1 file changed, 38 insertions(+), 29 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 99703e34..f69f4793 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -182,12 +182,11 @@ function DP.copy_model_with_constraints( constraints::Vector{<:DP.DisjunctConstraintRef}, method::DP._MBM ) - mini, ref_map = JuMP.copy_model(model) - - # Drop global constraints. - for cref in JuMP.all_constraints(mini) - JuMP.delete(mini, cref) - end + # Skip every source constraint at copy time instead of + # copying-then-deleting. Equivalent end state, no churn. + mini, ref_map = JuMP.copy_model( + model; filter_constraints = cref -> false + ) for cref in constraints con = JuMP.constraint_object(cref) @@ -473,8 +472,14 @@ end function DP.cut_info( binary_ref::InfiniteOpt.GeneralVariableRef, active::Bool, - linearization_point, variable_map, dual + linearization_point::AbstractDict, + variable_map::AbstractDict, dual ) + # Finite binary (plain `Logical` indicator on an `InfiniteModel`) + # has no infinite parameters — no per-support breakdown to do, so + # emit a single site like the base scalar method. + isempty(InfiniteOpt.parameter_refs(binary_ref)) && + return ((binary_ref, linearization_point, variable_map, dual),) supports = _supports_of(binary_ref) return _cut_sites(binary_ref, supports, fill(true, length(supports)), @@ -484,7 +489,8 @@ end function DP.cut_info( binary_ref::InfiniteOpt.GeneralVariableRef, actives::AbstractVector, - linearization_point, variable_map, dual + linearization_point::AbstractDict, + variable_map::AbstractDict, dual ) return _cut_sites(binary_ref, _supports_of(binary_ref), actives, linearization_point, variable_map, dual) @@ -593,7 +599,26 @@ end function DP.build_loa_master( model::InfiniteOpt.InfiniteModel, method::DP.LOA ) - master, copy_ref_map = JuMP.copy_model(model) + # Linear, non-aggregate constraints stay on the master. Nonlinear + # constraints — and aggregate-wrapped affine ones like + # `∫(x^2,t) ≤ c`, which read linear via `_is_linear_F` over a + # `MeasureRef` but expand to a nonlinear form on transcription — + # re-enter as OA cuts after each NLP solve, so they are dropped + # at copy time instead of copied then deleted. Variable bounds + # proper live on VariableInfo and survive `copy_model` regardless; + # the `F === GeneralVariableRef` early-return covers + # variable-ref-as-constraint registrations. + variable_type = InfiniteOpt.GeneralVariableRef + master, copy_ref_map = JuMP.copy_model( + model; + filter_constraints = function (cref) + con = JuMP.constraint_object(cref) + F = typeof(con.func) + F === variable_type && return true + DP._is_linear_F(F) || return false + return !_has_aggregate_ref(con.func) + end + ) # `copy_model` copies the GDP optimize-hook; clear it so # `optimize!(master)` doesn't re-trigger reformulation on the # (empty-GDP-data) master copy. @@ -601,26 +626,6 @@ function DP.build_loa_master( JuMP.set_optimizer(master, method.mip_optimizer) JuMP.set_silent(master) - # Strip nonlinear constraints; they re-enter via OA cuts. - # Variable bounds (F=GeneralVariableRef) are kept by `copy_model`. - # An aggregate-wrapped constraint (e.g. `∫(x^2,t) ≤ c`) is - # structurally affine over a `MeasureRef` so `_is_linear_F` reads - # it as linear, but it expands to a nonlinear form on - # transcription — strip it too and let the OA cut re-add the - # linearized version. - variable_type = InfiniteOpt.GeneralVariableRef - for (F, S) in JuMP.list_of_constraint_types(master) - F === variable_type && continue - is_linear = DP._is_linear_F(F) - for cref in JuMP.all_constraints(master, F, S) - if is_linear - con = JuMP.constraint_object(cref) - _has_aggregate_ref(con.func) || continue - end - JuMP.delete(master, cref) - end - end - # InfiniteReferenceMap supports indexing but not iteration; build # a Dict so downstream LOA code can `haskey` / iterate over the # source-side refs LOA cares about. @@ -853,6 +858,9 @@ function DP.with_fixed_combination( model::InfiniteOpt.InfiniteModel, combination::AbstractDict ) + # Relax binaries so the NLP solver doesn't see ZeroOne (see the + # base `with_fixed_combination` for rationale). + relaxed_binaries = DP.relax_logical_vars(model) constraint_refs = InfiniteOpt.InfOptConstraintRef[] fixed_binaries = InfiniteOpt.GeneralVariableRef[] for (indicator, value) in combination @@ -869,6 +877,7 @@ function DP.with_fixed_combination( for binary_ref in fixed_binaries JuMP.is_fixed(binary_ref) && JuMP.unfix(binary_ref) end + DP.unrelax_logical_vars(relaxed_binaries) end end From 0ac6bd0d03d7a0ba6fb20d6cf52f410769789f97 Mon Sep 17 00:00:00 2001 From: d227nguyen Date: Tue, 26 May 2026 14:13:49 -0400 Subject: [PATCH 46/59] . --- ext/InfiniteDisjunctiveProgramming.jl | 121 +++++++------------------- src/loa.jl | 97 +++++++++++++++------ test/constraints/loa.jl | 56 ++++++++++++ 3 files changed, 158 insertions(+), 116 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 8d549fb9..f7cfa416 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -848,117 +848,58 @@ function DP.add_disjunct_oa_cuts( end end -# Fix indicators for `combination`, run `f()`, then undo. Bool values -# are whole-var fixes via `JuMP.fix`; AbstractVector values are -# per-support point-equality constraints. Refs and fixed binaries are -# tracked in local state so cleanup runs from the same closure — no -# `model.ext` stash. -function DP.with_fixed_combination( - f, - model::InfiniteOpt.InfiniteModel, - combination::AbstractDict +# Apply per-indicator fixes for `combination` and return a closure +# that reverses them. Scalar (`Bool`) values fix the whole infinite +# var via `JuMP.fix`; per-support `AbstractVector{Bool}` values fix +# each support with a point-equality constraint. Refs and fixed +# binaries live in the closure — no `model.ext` stash. Used by base +# `with_fixed_combination` and `commit_combination`. +function DP._fix_combination( + model::InfiniteOpt.InfiniteModel, combination::AbstractDict ) - # Relax binaries so the NLP solver doesn't see ZeroOne (see the - # base `with_fixed_combination` for rationale). - relaxed_binaries = DP.relax_logical_vars(model) constraint_refs = InfiniteOpt.InfOptConstraintRef[] fixed_binaries = InfiniteOpt.GeneralVariableRef[] for (indicator, value) in combination binary_ref = DP._indicator_to_binary(model)[indicator] - _apply_fix!( - constraint_refs, fixed_binaries, model, binary_ref, value) + if value isa AbstractVector + for (k, support) in enumerate(_supports_of(binary_ref)) + push!(constraint_refs, JuMP.@constraint(model, + binary_ref(support) == (value[k] ? 1.0 : 0.0))) + end + else + JuMP.fix(binary_ref, value ? 1.0 : 0.0; force = true) + push!(fixed_binaries, binary_ref) + end end - try - return f() - finally + return function () for ref in constraint_refs JuMP.is_valid(model, ref) && JuMP.delete(model, ref) end for binary_ref in fixed_binaries JuMP.is_fixed(binary_ref) && JuMP.unfix(binary_ref) end - DP.unrelax_logical_vars(relaxed_binaries) - end -end - -# Finalize the LOA-best combination + warm start, leaving the model -# in a state the post-hook `optimize!` will accept. Mirrors -# `with_fixed_combination` for the fixing half but skips bookkeeping -# (these fixes stay). -function DP.commit_combination( - model::InfiniteOpt.InfiniteModel, - combination::AbstractDict, - linearization_point::AbstractDict - ) - constraint_refs = InfiniteOpt.InfOptConstraintRef[] - fixed_binaries = InfiniteOpt.GeneralVariableRef[] - for (indicator, value) in combination - binary_ref = DP._indicator_to_binary(model)[indicator] - _apply_fix!( - constraint_refs, fixed_binaries, model, binary_ref, value) - end - for (variable, values) in linearization_point - _set_starts_for_transcribed( - InfiniteOpt.transformation_variable(variable), - values - ) - end - return -end - -# Bool: fix the whole infinite var across all supports. -function _apply_fix!( - ::AbstractVector, - fixed::AbstractVector, - ::InfiniteOpt.InfiniteModel, - binary_ref::InfiniteOpt.GeneralVariableRef, - value::Bool - ) - JuMP.fix(binary_ref, value ? 1.0 : 0.0; force = true) - push!(fixed, binary_ref) - return -end - -# Vector{Bool}: per-support point-equality constraints. -function _apply_fix!( - refs::AbstractVector, - ::AbstractVector, - model::InfiniteOpt.InfiniteModel, - binary_ref::InfiniteOpt.GeneralVariableRef, - values::AbstractVector{Bool} - ) - for (k, support) in enumerate(_supports_of(binary_ref)) - push!(refs, JuMP.@constraint(model, - binary_ref(support) == (values[k] ? 1.0 : 0.0))) end - return end -# Infinite var: per-support transcribed array -function _set_starts_for_transcribed( - transcribed::AbstractArray, +# Broadcast a per-support warm start across an infinite var's +# transcribed instances; for a finite var on an InfiniteModel, +# `transcription_variable` returns a single ref and `values` is +# length-1. +function DP._set_warm_start!( + variable::InfiniteOpt.GeneralVariableRef, values::AbstractVector ) - for (k, ref) in enumerate(vec(transcribed)) - JuMP.set_start_value(ref, values[k]) + transcribed = InfiniteOpt.transformation_variable(variable) + if transcribed isa AbstractArray + for (k, ref) in enumerate(vec(transcribed)) + JuMP.set_start_value(ref, values[k]) + end + else + JuMP.set_start_value(transcribed, only(values)) end return end -# Finite var: single transcribed ref, `values` is 1-element -_set_starts_for_transcribed( - transcribed::JuMP.AbstractVariableRef, - values::AbstractVector - ) = - JuMP.set_start_value(transcribed, values[1]) - -# Finite var: single transcribed ref, scalar value (feas-side path) -_set_starts_for_transcribed( - transcribed::JuMP.AbstractVariableRef, - value::Real - ) = - JuMP.set_start_value(transcribed, value) - # Convert an InfiniteModel-var-keyed per-support point into a # transcribed-JuMP-var-keyed scalar point. Companion to # `_transcribed_to_master_point`: feeds the AD walker for the diff --git a/src/loa.jl b/src/loa.jl index 7bd59794..8afcd5f7 100644 --- a/src/loa.jl +++ b/src/loa.jl @@ -15,15 +15,21 @@ # METHOD TYPE ################################################################################ """ - LOA{O, P} <: AbstractReformulationMethod + LOA{O, P, R} <: AbstractReformulationMethod Logic-based Outer Approximation solver for GDP models. Uses two models: the -original (BigM-reformulated, binaries fixed per iteration as an NLP) and a -master MILP copy that accumulates OA and no-good cuts. +original (reformulated by `inner_method`, binaries fixed per iteration as an +NLP) and a master MILP copy that accumulates OA and no-good cuts. + +`inner_method` defaults to `BigM(M_value)`; pass `MBM(optimizer)` to use +tighter per-constraint Ms. Other reformulations (`Hull`, `PSplit`) are not +yet supported — they emit constraints whose shapes the LOA dual-collection +logic can't slice. """ -struct LOA{O, P} <: AbstractReformulationMethod +struct LOA{O, P, R} <: AbstractReformulationMethod nlp_optimizer::O mip_optimizer::P + inner_method::R max_iter::Int atol::Float64 rtol::Float64 @@ -38,10 +44,11 @@ struct LOA{O, P} <: AbstractReformulationMethod rtol::Float64 = 1e-4, M_value::Float64 = 1e9, max_slack::Float64 = 1000.0, - OA_penalty_factor::Float64 = 1000.0 - ) where {O, P} - new{O, P}(nlp_optimizer, mip_optimizer, max_iter, atol, rtol, - M_value, max_slack, OA_penalty_factor) + OA_penalty_factor::Float64 = 1000.0, + inner_method::R = BigM(M_value) + ) where {O, P, R <: AbstractReformulationMethod} + new{O, P, R}(nlp_optimizer, mip_optimizer, inner_method, max_iter, + atol, rtol, M_value, max_slack, OA_penalty_factor) end end @@ -66,7 +73,7 @@ _flip_sense(::Val{_MOI.MAX_SENSE}) = Val(_MOI.MIN_SENSE) function reformulate_model(model::JuMP.AbstractModel, method::LOA) _clear_reformulations(model) combinations = _set_covering_combinations(model) - reformulate_model(model, BigM(method.M_value)) + reformulate_model(model, method.inner_method) master = build_loa_master(model, method) reformulation_map = _build_reformulation_map(model) @@ -97,6 +104,8 @@ function reformulate_model(model::JuMP.AbstractModel, method::LOA) JuMP.optimize!(master.model) JuMP.is_solved_and_feasible(master.model) || break master_bound = JuMP.objective_value(master.model) + @info "LOA iter $iter: LB=$master_bound UB=$best_objective " * + "gap=$(_gap(sense_token, best_objective, master_bound))" _loa_converged(best_objective, master_bound, sense_token, method) && break combination = _extract_combination(model, master) result = _solve_nlp(model, combination, method, reformulation_map) @@ -257,43 +266,70 @@ function _solve_nlp( end end -# OVERRIDABLE. Fix the indicators in `combination`, run `f()`, unfix. -# Holds the fix/unfix lifecycle in one call so extensions can manage -# any per-support bookkeeping locally without `model.ext` stashing. +# Fix `combination`, run `f()`, unfix. Logical binaries are relaxed +# from ZeroOne for the duration so a pure NLP solver (Ipopt) can +# handle the inner solve; restored on exit. Extensions override +# `_fix_combination` to handle per-support fixing without needing +# their own try/finally lifecycle. function with_fixed_combination( f, model::JuMP.AbstractModel, combination::AbstractDict ) - for (indicator, active) in combination - fix_indicator(model, indicator, active) - end + relaxed = relax_logical_vars(model) + undo = _fix_combination(model, combination) try return f() finally - for (indicator, _) in combination - unfix_indicator(model, indicator) - end + undo() + unrelax_logical_vars(relaxed) end end -# OVERRIDABLE. Finalize the model with the LOA-optimal combination: -# fix indicators (left fixed for the post-hook `optimize!`) and warm -# start from the linearization point. +# Finalize the model with the LOA-optimal combination: relax logical +# binaries (stripping ZeroOne so a pure NLP solver can handle the +# post-hook `JuMP.optimize!`), fix indicators at the committed values, +# and warm-start from the linearization point. After this, the model +# is no longer in a state suitable for re-running with a different +# `gdp_method`. function commit_combination( model::JuMP.AbstractModel, combination::AbstractDict, linearization_point::AbstractDict ) - for (indicator, active) in combination - fix_indicator(model, indicator, active) - end - for (variable, value) in linearization_point - JuMP.set_start_value(variable, _unwrap_scalar(value)) + relax_logical_vars(model) + _fix_combination(model, combination) + for (variable, values) in linearization_point + _set_warm_start!(variable, values) end return end +# OVERRIDABLE. Apply the combination's fixes and return a closure that +# undoes them. Base loops `fix_indicator`/`unfix_indicator`. The +# InfiniteOpt extension overrides this to handle per-support +# `Vector{Bool}` values via point-equality constraints, keeping all +# cleanup state captured in the returned closure. +function _fix_combination( + model::JuMP.AbstractModel, combination::AbstractDict + ) + for (indicator, value) in combination + fix_indicator(model, indicator, value) + end + return function () + for (indicator, _) in combination + unfix_indicator(model, indicator) + end + end +end + +# OVERRIDABLE. Write the LOA linearization point into a variable's +# warm start. `values` is always per-support shape (length-1 vector +# for finite vars); base unwraps. The InfiniteOpt extension overrides +# for `GeneralVariableRef` to broadcast across transcribed supports. +_set_warm_start!(variable, values::AbstractVector) = + JuMP.set_start_value(variable, only(values)) + ################################################################################ # BIGM DUAL COLLECTION ################################################################################ @@ -585,6 +621,8 @@ end # OVERRIDABLE. Yield `(binary_ref, lin_point, var_map, dual)` per OA # cut to emit. Scalar binary returns one tuple; InfiniteOpt overrides # for per-support `Vector` binaries to yield multiple sliced tuples. +# `GenericAffExpr` covers complement-form indicators (`1 - y`) stored +# in `binary_map` when a `Logical` is declared with `logical_complement`. cut_info( binary_ref::JuMP.AbstractVariableRef, active::Bool, @@ -592,6 +630,13 @@ cut_info( variable_map::AbstractDict, dual ) = ((binary_ref, linearization_point, variable_map, dual),) +cut_info( + binary_ref::JuMP.GenericAffExpr, + active::Bool, + linearization_point::AbstractDict, + variable_map::AbstractDict, + dual + ) = ((binary_ref, linearization_point, variable_map, dual),) # OVERRIDABLE. Truthiness of an active descriptor. InfiniteOpt # overrides for per-support `Vector{Bool}`. diff --git a/test/constraints/loa.jl b/test/constraints/loa.jl index 712e48c1..b92db13d 100644 --- a/test/constraints/loa.jl +++ b/test/constraints/loa.jl @@ -10,6 +10,8 @@ function test_loa_datatype() @test method.M_value == 1e9 @test method.max_slack == 1000.0 @test method.OA_penalty_factor == 1000.0 + @test method.inner_method isa BigM + @test method.inner_method.value == 1e9 method = LOA(HiGHS.Optimizer; max_iter = 50, atol = 1e-8, rtol = 1e-6, M_value = 1e6, max_slack = 500.0, @@ -20,6 +22,11 @@ function test_loa_datatype() @test method.M_value == 1e6 @test method.max_slack == 500.0 @test method.OA_penalty_factor == 200.0 + @test method.inner_method isa BigM + @test method.inner_method.value == 1e6 + + method = LOA(HiGHS.Optimizer; inner_method = MBM(HiGHS.Optimizer)) + @test method.inner_method isa MBM end function test_set_covering_combos() @@ -110,6 +117,25 @@ function test_loa_solve_simple() @test objective_value(model) ≈ 7.0 atol=1e-4 end +function test_loa_solve_simple_with_mbm() + # Same GDP as test_loa_solve_simple, but inner_method = MBM so the + # NLP model is built with per-constraint tight Ms instead of BigM. + # Optimum unchanged: x = 7 via the second disjunct. + model = GDPModel(HiGHS.Optimizer) + set_silent(model) + @variable(model, 0 <= x <= 10) + @variable(model, Y[1:2], Logical) + @constraint(model, x <= 3, Disjunct(Y[1])) + @constraint(model, x <= 7, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Max, x) + + optimize!(model, gdp_method = LOA(HiGHS.Optimizer; + inner_method = MBM(HiGHS.Optimizer))) + @test termination_status(model) == MOI.OPTIMAL + @test objective_value(model) ≈ 7.0 atol=1e-4 +end + function test_loa_solve_two_disjunctions() # Two disjunctions: max x + z # D1: (x <= 3) OR (x <= 7) @@ -164,6 +190,34 @@ function test_loa_nonlinear_global() @test objective_value(model) ≈ 5.0 atol = 1e-3 end +function test_loa_complement_indicator_nonlinear_disjunct() + # Regression: complement-form indicators store `1 - y_base` (an + # AffExpr) in `binary_map`. When the complement disjunct has a + # nonlinear constraint, `add_disjunct_oa_cuts` calls `cut_info` + # on that AffExpr; the AffExpr method must dispatch. + # + # Setup: 0 <= x <= 10, Y2 ≡ ¬Y1. + # Disjunct(Y1): x <= 3 (linear) + # Disjunct(Y2): x^2 <= 64 (nonlinear, optimum: x = 8 with Y2) + ipopt = optimizer_with_attributes(Ipopt.Optimizer, + "print_level" => 0, "sb" => "yes") + juniper = optimizer_with_attributes(Juniper.Optimizer, + "nl_solver" => ipopt, "log_levels" => []) + model = GDPModel(juniper) + set_silent(model) + @variable(model, 0 <= x <= 10) + @variable(model, Y1, Logical) + @variable(model, Y2, Logical, logical_complement = Y1) + @constraint(model, x <= 3, Disjunct(Y1)) + @constraint(model, x^2 <= 64, Disjunct(Y2)) + @disjunction(model, [Y1, Y2]) + @objective(model, Max, x) + optimize!(model, + gdp_method = LOA(juniper; mip_optimizer = HiGHS.Optimizer)) + @test termination_status(model) in (MOI.OPTIMAL, MOI.LOCALLY_SOLVED) + @test objective_value(model) ≈ 8.0 atol = 1e-3 +end + function test_linearize_nonlinear_exp() # exp(x) + y at (1, 2): # f = e + 2, ∇f = [e, 1] @@ -242,9 +296,11 @@ end test_loa_convergence_check() test_loa_reformulate_simple() test_loa_solve_simple() + test_loa_solve_simple_with_mbm() test_loa_solve_two_disjunctions() test_loa_error_fallback() test_loa_nonlinear_global() + test_loa_complement_indicator_nonlinear_disjunct() test_linearize_nonlinear_exp() test_linearize_nonlinear_sin() test_linearize_nonlinear_multivar() From 54c2bc5d7f625847110b61512e7f1517052ce6f0 Mon Sep 17 00:00:00 2001 From: d227nguyen Date: Tue, 26 May 2026 15:07:41 -0400 Subject: [PATCH 47/59] . --- ext/InfiniteDisjunctiveProgramming.jl | 71 +++++++++++++------ .../InfiniteDisjunctiveProgramming.jl | 66 +++++++++++++++++ 2 files changed, 116 insertions(+), 21 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index f7cfa416..993fcddc 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -402,8 +402,21 @@ function DP.combination_val(v::InfiniteOpt.GeneralVariableRef) return val isa AbstractArray ? vec(round.(Bool, val)) : round(Bool, val) end -_supports_of(v::InfiniteOpt.GeneralVariableRef) = - vec(InfiniteOpt.supports(only(InfiniteOpt.parameter_refs(v)))) +# Supports of the infinite parameter group `v` depends on. For a 1-D +# parameter, `supports(p)` is a `Vector{Float64}`; for a dependent +# group of dimension k, it is a k × N_supports `Matrix`. Returns each +# joint support point as one element — scalar for 1-D, column view for +# multi-D — so callers can iterate uniformly. Pair with `_at_support`, +# which splats vector supports into the variable's call form. +function _supports_of(v::InfiniteOpt.GeneralVariableRef) + p = only(InfiniteOpt.parameter_refs(v)) + sup = InfiniteOpt.supports(p) + ndims(sup) == 1 && return sup + # Materialize each column as a concrete `Vector{Float64}`; + # `eachcol` yields `SubArray` views that InfiniteOpt's + # `VectorTuple` constructor refuses. + return [sup[:, k] for k in axes(sup, 2)] +end _is_point_var(v::InfiniteOpt.GeneralVariableRef) = InfiniteOpt.dispatch_variable_ref(v) isa InfiniteOpt.PointVariableRef @@ -452,7 +465,7 @@ function DP.add_no_good_terms( return invoke(DP.add_no_good_terms, Tuple{Any, Any, Bool}, cut, binary_ref, active) for support in _supports_of(binary_ref) - DP.add_no_good_terms(cut, binary_ref(support), active) + DP.add_no_good_terms(cut, _at_support(binary_ref, support), active) end return end @@ -465,7 +478,8 @@ function DP.add_no_good_terms( ) supports = _supports_of(binary_ref) for (k, support) in enumerate(supports) - DP.add_no_good_terms(cut, binary_ref(support), actives[k]) + DP.add_no_good_terms( + cut, _at_support(binary_ref, support), actives[k]) end return end @@ -519,7 +533,7 @@ function _cut_sites( point[variable] = _at(point_value, k) end push!(sites, ( - binary_ref(support), + _at_support(binary_ref, support), point, point_var_map, _at(dual, k) @@ -528,12 +542,19 @@ function _cut_sites( return sites end -# Slice a per-support container at index `k`; pass scalars through. -_at(values::AbstractArray, k::Integer) = values[k] +# Slice a per-support container at index `k`. Length-1 vectors are +# finite-shape (one value applies at every support, per +# `_per_support_values` wrapping `JuMP.value` of a finite var as +# `[scalar]`) — return the single value regardless of `k`. Scalars +# pass through. +_at(values::AbstractArray, k::Integer) = + length(values) == 1 ? values[1] : values[k] _at(scalar, ::Integer) = scalar # Point-evaluate an InfiniteOpt var at `support` if it's infinite; -# return the var as-is if it's finite. +# return the var as-is if it's finite. `support` is one joint support +# point — a scalar for 1-D parameters, a vector for a multi-D +# dependent group — and `v(support)` matches both call forms. _at_support(v::InfiniteOpt.GeneralVariableRef, support) = isempty(InfiniteOpt.parameter_refs(v)) ? v : v(support) @@ -849,34 +870,42 @@ function DP.add_disjunct_oa_cuts( end # Apply per-indicator fixes for `combination` and return a closure -# that reverses them. Scalar (`Bool`) values fix the whole infinite -# var via `JuMP.fix`; per-support `AbstractVector{Bool}` values fix -# each support with a point-equality constraint. Refs and fixed -# binaries live in the closure — no `model.ext` stash. Used by base -# `with_fixed_combination` and `commit_combination`. +# that reverses them. Scalar (`Bool`) values delegate to base +# `fix_indicator` (which unwraps complement-form `1 - y` AffExprs to +# the underlying binary and inverts the target). Per-support +# `AbstractVector{Bool}` values fix each support via a point-equality +# constraint on the underlying infinite var. State lives in the +# closure — no `model.ext` stash. Used by base `with_fixed_combination` +# and `commit_combination`. function DP._fix_combination( model::InfiniteOpt.InfiniteModel, combination::AbstractDict ) constraint_refs = InfiniteOpt.InfOptConstraintRef[] - fixed_binaries = InfiniteOpt.GeneralVariableRef[] + fixed_indicators = DP.LogicalVariableRef{ + InfiniteOpt.InfiniteModel}[] for (indicator, value) in combination - binary_ref = DP._indicator_to_binary(model)[indicator] if value isa AbstractVector - for (k, support) in enumerate(_supports_of(binary_ref)) + binary_ref = DP._indicator_to_binary(model)[indicator] + target, target_for_active = binary_ref isa JuMP.GenericAffExpr ? + (only(keys(binary_ref.terms)), 0.0) : + (binary_ref, 1.0) + target_for_inactive = 1.0 - target_for_active + for (k, support) in enumerate(_supports_of(target)) push!(constraint_refs, JuMP.@constraint(model, - binary_ref(support) == (value[k] ? 1.0 : 0.0))) + _at_support(target, support) == + (value[k] ? target_for_active : target_for_inactive))) end else - JuMP.fix(binary_ref, value ? 1.0 : 0.0; force = true) - push!(fixed_binaries, binary_ref) + DP.fix_indicator(model, indicator, value) + push!(fixed_indicators, indicator) end end return function () for ref in constraint_refs JuMP.is_valid(model, ref) && JuMP.delete(model, ref) end - for binary_ref in fixed_binaries - JuMP.is_fixed(binary_ref) && JuMP.unfix(binary_ref) + for indicator in fixed_indicators + DP.unfix_indicator(model, indicator) end end end diff --git a/test/extensions/InfiniteDisjunctiveProgramming.jl b/test/extensions/InfiniteDisjunctiveProgramming.jl index bacf987c..dc70b802 100644 --- a/test/extensions/InfiniteDisjunctiveProgramming.jl +++ b/test/extensions/InfiniteDisjunctiveProgramming.jl @@ -819,6 +819,70 @@ function test_loa_infinite_nonlinear_global() @test objective_value(model) ≈ 5.0 atol = 1e-2 end +function test_loa_infinite_complement_indicator() + # Regression: `_indicator_to_binary(model)[Y2]` returns the AffExpr + # `1 - binary(Y1)` for a logical-complement indicator. Earlier the + # extension's `_fix_combination` called `JuMP.fix` directly on that + # AffExpr and crashed; now it delegates to base `fix_indicator` + # which unwraps and inverts. + # + # Linear disjuncts only — a nonlinear disjunct constraint on an + # infinite variable would also hit a separate gap where + # `cut_info` for a finite indicator does not slice the per-support + # linearization point. Out of scope for this regression. + # + # max ∫ x dt with 0 ≤ x ≤ 10 over t ∈ [0,1]: + # Y1: x ≤ 3 Y2 ≡ ¬Y1: x ≤ 8 + # Y2 is the maximizer (x = 8 across t), giving ∫8 dt = 8. + ipopt = optimizer_with_attributes(Ipopt.Optimizer, + "print_level" => 0, "sb" => "yes") + model = InfiniteGDPModel(ipopt) + set_silent(model) + @infinite_parameter(model, t ∈ [0, 1], num_supports = 5) + @variable(model, 0 <= x <= 10, Infinite(t)) + @variable(model, Y1, Logical) + @variable(model, Y2, Logical, logical_complement = Y1) + @constraint(model, x <= 3, Disjunct(Y1)) + @constraint(model, x <= 8, Disjunct(Y2)) + @disjunction(model, [Y1, Y2]) + @objective(model, Max, ∫(x, t)) + optimize!(model, + gdp_method = LOA(ipopt; mip_optimizer = HiGHS.Optimizer)) + @test termination_status(model) in (MOI.OPTIMAL, MOI.LOCALLY_SOLVED) + @test objective_value(model) ≈ 8.0 atol = 1e-2 +end + +function test_loa_infinite_multidim_parameter() + # Regression: multi-D dependent parameter group. `supports(ξ)` is + # a 2 × N matrix, not a vector. Earlier the extension called + # `vec(supports(ξ))`, flattened to 2·N scalars, then called + # `binary_ref(scalar)` on a 2-D infinite var — dim mismatch. + # Fixed by returning `eachcol(supports(ξ))` and splatting vector + # supports through `_at_support`. + # + # max w with 0 ≤ x ≤ 10 over ξ[1:2] ∈ [0,1]², w ≤ x at every joint + # support: + # Y[1]: x ≤ 3 Y[2]: x ≤ 8 + # Y[2] is the maximizer (x = 8 across ξ allows w = 8). + ipopt = optimizer_with_attributes(Ipopt.Optimizer, + "print_level" => 0, "sb" => "yes") + model = InfiniteGDPModel(ipopt) + set_silent(model) + @infinite_parameter(model, ξ[1:2] ∈ [0, 1], num_supports = 4) + @variable(model, 0 <= x <= 10, Infinite(ξ)) + @variable(model, 0 <= w <= 10) + @constraint(model, w <= x) + @variable(model, Y[1:2], InfiniteLogical(ξ)) + @constraint(model, x <= 3, Disjunct(Y[1])) + @constraint(model, x <= 8, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Max, w) + optimize!(model, + gdp_method = LOA(ipopt; mip_optimizer = HiGHS.Optimizer)) + @test termination_status(model) in (MOI.OPTIMAL, MOI.LOCALLY_SOLVED) + @test objective_value(model) ≈ 8.0 atol = 1e-2 +end + function test_loa_infinite_aggregate_global() # min ∫x dt s.t. ∫(x^2, t) <= 4 (aggregate global), x >= y, # (y >= 1) ∨ (y >= 3), 0 <= x, y <= 10 over t ∈ [0, 1]. @@ -921,6 +985,8 @@ end if hasmethod(JuMP.copy_model, Tuple{InfiniteModel}) test_loa_infinite_nonlinear_global() test_loa_infinite_aggregate_global() + test_loa_infinite_complement_indicator() + test_loa_infinite_multidim_parameter() else @info "Skipping InfiniteOpt LOA tests: " * "JuMP.copy_model(::InfiniteModel) unavailable " * From 23dede6630db3be20d44d3002a2f5717fe0bd408 Mon Sep 17 00:00:00 2001 From: d227nguyen Date: Fri, 29 May 2026 10:14:54 -0400 Subject: [PATCH 48/59] Intermediate --- Project.toml | 2 + ext/InfiniteDisjunctiveProgramming.jl | 351 +++++++++++- src/loa.jl | 521 +++++++++++++++--- src/utilities.jl | 90 --- test/constraints/loa.jl | 6 +- .../InfiniteDisjunctiveProgramming.jl | 36 +- 6 files changed, 798 insertions(+), 208 deletions(-) diff --git a/Project.toml b/Project.toml index dbff0ec1..db716b40 100644 --- a/Project.toml +++ b/Project.toml @@ -4,6 +4,7 @@ authors = ["hdavid16 "] version = "0.6.0" [deps] +Distributions = "31c24e10-a181-5473-b8eb-7969acd0382f" JuMP = "4076af6c-e467-56ae-b986-b466b2749572" Reexport = "189a3867-3050-52da-a836-e630ba90ab69" @@ -15,6 +16,7 @@ InfiniteDisjunctiveProgramming = "InfiniteOpt" [compat] Aqua = "0.8" +Distributions = "0.25.125" InfiniteOpt = "0.6.3" Ipopt = "1.9.0" JuMP = "1.18" diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 993fcddc..30022165 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -390,6 +390,190 @@ end DP.any_active(actives::AbstractVector{Bool}) = any(actives) +# Override `reformulate_model` for `InfiniteModel` so the LOA +# `supports_schedule` activates the multi-resolution loop. With +# `supports_schedule = nothing` this is identical to the base path. With +# a schedule + `coarse_builder = N -> InfiniteModel`, the wrapper: +# +# 1. For each N in the schedule, build a FRESH InfiniteModel via +# `coarse_builder(N)`. Apply warm-starts captured from the previous +# warmup level (indexed by variable name + parameter name, which +# survive across model rebuilds). Run LOA. Capture trajectory. +# Discard the warmup model. +# 2. Apply the final captured trajectory to the user's `model`. +# 3. Run a single-level LOA on the user's model. +# +# This sidesteps every accumulated-state failure mode we hit when +# mutating one InfiniteModel across resolutions (orphan point vars, +# stale parameter supports, transcription cache poisoning, etc.). +function DP.reformulate_model( + model::InfiniteOpt.InfiniteModel, method::DP.LOA + ) + schedule = method.supports_schedule + if schedule === nothing + DP._reformulate_loa_single_level(model, method) + DP._set_solution_method(model, method) + DP._set_ready_to_optimize(model, true) + return + end + + isempty(schedule) && error( + "LOA `supports_schedule` must be `nothing` or non-empty.") + builder = method.coarse_builder + builder === nothing && error( + "LOA `supports_schedule` requires `coarse_builder`.") + + trajectory = nothing + for (level, N) in enumerate(schedule) + method.verbose && println( + "LOA_MULTIRES warmup level=", level, " N=", N, + trajectory === nothing ? " (cold start)" : + " (warm-start from level $(level - 1))") + warmup_model = builder(N) + if trajectory !== nothing + _apply_trajectory_by_name!(warmup_model, trajectory) + end + warmup_method = _strip_schedule(method) + warmup_result = DP._reformulate_loa_single_level( + warmup_model, warmup_method) + if warmup_result === nothing + method.verbose && println("LOA_MULTIRES warmup level=", + level, " no commit; trajectory unchanged") + continue + end + trajectory = _capture_trajectory_by_name( + warmup_model, warmup_result) + method.verbose && println("LOA_MULTIRES warmup level=", + level, " obj=", warmup_result.objective) + end + + if trajectory !== nothing + method.verbose && println( + "LOA_MULTIRES applying warmup trajectory to user model") + _apply_trajectory_by_name!(model, trajectory) + end + DP._reformulate_loa_single_level(model, method) + DP._set_solution_method(model, method) + DP._set_ready_to_optimize(model, true) + return +end + +# Build a copy of `method` with `supports_schedule = nothing` so the +# warmup LOA runs as single-level (not recursively triggering its own +# multi-res). The `coarse_builder` is irrelevant once the schedule is +# stripped; nothing reads it. +function _strip_schedule(method::DP.LOA) + return DP.LOA(method.nlp_optimizer; + mip_optimizer = method.mip_optimizer, + inner_method = method.inner_method, + max_iter = method.max_iter, + atol = method.atol, rtol = method.rtol, + M_value = method.M_value, + max_slack = method.max_slack, + oa_penalty = method.oa_penalty, + verbose = method.verbose) +end + +# Capture the converged trajectory as a name-keyed dict so we can apply +# it to a freshly-built model whose `GeneralVariableRef`s are different +# objects. Also records each parameter's current supports (per name) so +# the apply step knows the source grid for interpolation. +function _capture_trajectory_by_name( + model::InfiniteOpt.InfiniteModel, result::NamedTuple + ) + name_to_values = Dict{String, Vector{Float64}}() + for (variable, values) in result.linearization_point + nm = JuMP.name(variable) + isempty(nm) && continue + name_to_values[nm] = collect(values) + end + name_to_supports = Dict{String, Vector{Float64}}() + for p in InfiniteOpt.all_parameters(model) + nm = JuMP.name(p) + isempty(nm) && continue + sup = InfiniteOpt.supports(p) + ndims(sup) == 1 || continue + name_to_supports[nm] = collect(sup) + end + return (values = name_to_values, supports = name_to_supports) +end + +# Apply a name-keyed trajectory to a fresh model: for each variable +# whose name appears in `trajectory.values`, interpolate its captured +# per-support values from the source parameter grid to this model's +# grid and write as `set_start_value` on the transcribed JuMP refs. +# Indicator binaries are skipped — they're not part of the primal +# warm-start. +function _apply_trajectory_by_name!( + target::InfiniteOpt.InfiniteModel, trajectory::NamedTuple + ) + indicator_binaries = Set{InfiniteOpt.GeneralVariableRef}() + for (_, bref) in DP._indicator_to_binary(target) + push!(indicator_binaries, bref isa JuMP.GenericAffExpr ? + only(keys(bref.terms)) : bref) + end + InfiniteOpt.build_transformation_backend!(target) + for v in JuMP.all_variables(target) + v in indicator_binaries && continue + nm = JuMP.name(v) + haskey(trajectory.values, nm) || continue + values = trajectory.values[nm] + _warm_start_by_name(target, v, values, trajectory.supports) + end + return +end + +# Variable-side warm-start: finite vars get the scalar value directly; +# infinite (single-parameter) vars get linearly interpolated from the +# captured source grid to this model's transcribed supports. +function _warm_start_by_name( + ::InfiniteOpt.InfiniteModel, + variable::InfiniteOpt.GeneralVariableRef, + values::AbstractVector, + captured_supports::AbstractDict + ) + prefs = InfiniteOpt.parameter_refs(variable) + if isempty(prefs) + length(values) == 1 || return + transcribed = InfiniteOpt.transformation_variable(variable) + transcribed isa JuMP.AbstractVariableRef || return + JuMP.set_start_value(transcribed, only(values)) + return + end + length(prefs) == 1 || return + pname = JuMP.name(first(prefs)) + haskey(captured_supports, pname) || return + old = captured_supports[pname] + length(values) == length(old) || return + new = InfiniteOpt.supports(first(prefs)) + ndims(new) == 1 || return + transcribed = InfiniteOpt.transformation_variable(variable) + transcribed isa AbstractArray || return + refs = vec(transcribed) + length(refs) == length(new) || return + for (k, s) in enumerate(new) + JuMP.set_start_value(refs[k], _linear_interp(old, values, s)) + end + return +end + +# Piecewise-linear interpolation of `ys` defined on the increasing +# nodes `xs`, evaluated at `q`. Clamps at the endpoints. Cheap and +# stable enough for warm-starts; only neighborhood-correctness matters. +function _linear_interp( + xs::AbstractVector{<:Real}, ys::AbstractVector{<:Real}, q::Real + ) + n = length(xs) + q <= xs[1] && return ys[1] + q >= xs[n] && return ys[n] + i = searchsortedlast(xs, q) + i == n && return ys[n] + x0, x1 = xs[i], xs[i + 1] + x1 == x0 && return ys[i] + t = (q - x0) / (x1 - x0) + return (1 - t) * ys[i] + t * ys[i + 1] +end + # `JuMP.value` returns a per-support `Array` for infinite vars and a # scalar for finite vars. The `> 0.5` cutoff handles solver-side # integer-feasibility slack (e.g. HiGHS can return 2.75e-40 for a @@ -402,6 +586,17 @@ function DP.combination_val(v::InfiniteOpt.GeneralVariableRef) return val isa AbstractArray ? vec(round.(Bool, val)) : round(Bool, val) end +# Complement-form binary (`1 - y_underlying`) stored in `binary_map` +# for indicators declared `logical_complement`. `JuMP.value` on the +# AffExpr returns a per-support `Vector{Float64}` when the underlying +# is infinite, or a scalar when it's finite. +function DP.combination_val( + v::JuMP.GenericAffExpr{C, <:InfiniteOpt.GeneralVariableRef} + ) where {C} + val = JuMP.value(v) + return val isa AbstractArray ? vec(round.(Bool, val)) : round(Bool, val) +end + # Supports of the infinite parameter group `v` depends on. For a 1-D # parameter, `supports(p)` is a `Vector{Float64}`; for a dependent # group of dimension k, it is a k × N_supports `Matrix`. Returns each @@ -484,34 +679,131 @@ function DP.add_no_good_terms( return end +# Complement-form AffExpr (`1 - y_underlying`) with per-support active +# descriptor — same fan-out as the variable-ref form, but the +# underlying var lives inside the AffExpr's terms. Use the underlying +# var's supports to drive the loop. +function DP.add_no_good_terms( + cut, binary_ref::JuMP.GenericAffExpr, + actives::AbstractVector + ) + underlying = only(keys(binary_ref.terms)) + underlying isa InfiniteOpt.GeneralVariableRef || return invoke( + DP.add_no_good_terms, + Tuple{Any, Any, AbstractVector}, cut, binary_ref, actives) + supports = _supports_of(underlying) + for (k, support) in enumerate(supports) + DP.add_no_good_terms( + cut, _at_support(binary_ref, support), actives[k]) + end + return +end + function DP.cut_info( - binary_ref::InfiniteOpt.GeneralVariableRef, active::Bool, + binary_ref::InfiniteOpt.GeneralVariableRef, + active::Bool, + constraint::JuMP.AbstractConstraint, linearization_point::AbstractDict, variable_map::AbstractDict, dual ) - # Finite binary (plain `Logical` indicator on an `InfiniteModel`) - # has no infinite parameters — no per-support breakdown to do, so - # emit a single site like the base scalar method. - isempty(InfiniteOpt.parameter_refs(binary_ref)) && - return ((binary_ref, linearization_point, variable_map, dual),) - supports = _supports_of(binary_ref) - return _cut_sites(binary_ref, supports, - fill(true, length(supports)), + return _infinite_cut_info(binary_ref, active, constraint.func, linearization_point, variable_map, dual) end function DP.cut_info( binary_ref::InfiniteOpt.GeneralVariableRef, actives::AbstractVector, + constraint::JuMP.AbstractConstraint, + linearization_point::AbstractDict, + variable_map::AbstractDict, dual + ) + return _infinite_cut_info(binary_ref, actives, constraint.func, + linearization_point, variable_map, dual) +end + +# Complement-form binary (`1 - y_underlying`). Same fan-out logic as +# the variable-ref form; the per-support `_at_support` rebuilds the +# AffExpr with its variable point-evaluated. +function DP.cut_info( + binary_ref::JuMP.GenericAffExpr, + active::Bool, + constraint::JuMP.AbstractConstraint, linearization_point::AbstractDict, variable_map::AbstractDict, dual ) - return _cut_sites(binary_ref, _supports_of(binary_ref), actives, + return _infinite_cut_info(binary_ref, active, constraint.func, linearization_point, variable_map, dual) end +# Complement-form binary with per-support active descriptor (e.g., +# `BitVector` from `_extract_combination` on an InfiniteOpt master +# where the indicator was declared `logical_complement`). +function DP.cut_info( + binary_ref::JuMP.GenericAffExpr, + actives::AbstractVector, + constraint::JuMP.AbstractConstraint, + linearization_point::AbstractDict, + variable_map::AbstractDict, dual + ) + return _infinite_cut_info(binary_ref, actives, constraint.func, + linearization_point, variable_map, dual) +end + +# Fan out across supports if either the binary or the constraint +# expression involves an infinite variable; otherwise emit one +# un-sliced site. The chosen supports come from the first infinite +# variable found in either expression. +function _infinite_cut_info( + binary_ref, active, constraint_func, + linearization_point, variable_map, dual + ) + supports = _relevant_supports(binary_ref, constraint_func) + supports === nothing && + return ((binary_ref, linearization_point, variable_map, dual),) + actives = active isa AbstractVector ? active : + fill(active, length(supports)) + return _cut_sites(binary_ref, supports, actives, + linearization_point, variable_map, dual) +end + +# Supports governing per-support fan-out — pick the first infinite +# variable found in the binary expression, falling back to the +# constraint expression. `nothing` means everything is finite, so the +# caller emits a single un-sliced site. +function _relevant_supports(binary_ref, constraint_func) + var = _find_infinite_var(binary_ref) + var === nothing && (var = _find_infinite_var(constraint_func)) + var === nothing && return nothing + return _supports_of(var) +end + +_find_infinite_var(v::InfiniteOpt.GeneralVariableRef) = + isempty(InfiniteOpt.parameter_refs(v)) ? nothing : v + +function _find_infinite_var(expr::JuMP.GenericAffExpr) + for v in keys(expr.terms) + v isa InfiniteOpt.GeneralVariableRef || continue + result = _find_infinite_var(v) + result === nothing || return result + end + return nothing +end + +function _find_infinite_var(expr) + found = Ref{Any}(nothing) + DP._interrogate_variables(expr) do v + found[] === nothing || return + v isa InfiniteOpt.GeneralVariableRef || return + isempty(InfiniteOpt.parameter_refs(v)) && return + found[] = v + end + return found[] +end + +_find_infinite_var(::Any) = nothing + function _cut_sites( - binary_ref::InfiniteOpt.GeneralVariableRef, + binary_ref, supports::AbstractVector, actives::AbstractVector, linearization_point::AbstractDict, @@ -558,6 +850,19 @@ _at(scalar, ::Integer) = scalar _at_support(v::InfiniteOpt.GeneralVariableRef, support) = isempty(InfiniteOpt.parameter_refs(v)) ? v : v(support) +# Rebuild a complement-form AffExpr (`1 - y_underlying`) with its +# variables point-evaluated, so per-support fan-out can use the +# AffExpr directly in the gating term `M(1 - binary_at_support)`. +function _at_support( + expr::JuMP.GenericAffExpr{C, V}, support + ) where {C, V <: InfiniteOpt.GeneralVariableRef} + result = JuMP.GenericAffExpr{C, V}(expr.constant) + for (var, coef) in expr.terms + JuMP.add_to_expression!(result, coef, _at_support(var, support)) + end + return result +end + # Build map: transcribed input JuMP var → master point variable. # For an infinite input var v, every transcribed support `v_k` # maps to `ref_map[v](d_k)` (master point variable). Used as @@ -619,7 +924,7 @@ end # the flat scalar objective with a transcribed-to-master point map. function DP.build_loa_master( model::InfiniteOpt.InfiniteModel, method::DP.LOA - ) + )::DP._LOAMaster # Linear, non-aggregate constraints stay on the master. Nonlinear # constraints — and aggregate-wrapped affine ones like # `∫(x^2,t) ≤ c`, which read linear via `_is_linear_F` over a @@ -691,12 +996,9 @@ function DP.build_loa_master( variable_map[v] = ref_map[v] end - return (model = master, binary_map = binary_map, - variable_map = variable_map, - objective_sense = objective_sense, - original_objective = original_objective, - alpha_oa = alpha_oa, - objective_ref_map = objective_ref_map) + return DP._LOAMaster(master, binary_map, variable_map, + objective_sense, original_objective, alpha_oa, + objective_ref_map) end # Override the disjunct-cut loop for `InfiniteModel`. Same shape as @@ -712,7 +1014,7 @@ end # produces whichever shape `_linearize_at` needs. function DP.add_oa_cuts( model::InfiniteOpt.InfiniteModel, - master::NamedTuple, + master::DP._LOAMaster, result::NamedTuple, method::DP.LOA ) @@ -745,7 +1047,7 @@ end # of scalar expressions — one OA cut is emitted per support. function _add_global_oa_cuts_infinite( model::InfiniteOpt.InfiniteModel, - master::NamedTuple, + master::DP._LOAMaster, result::NamedTuple, method::DP.LOA ) @@ -806,7 +1108,7 @@ end # constraints in the iteration. function DP.add_disjunct_oa_cuts( model::InfiniteOpt.InfiniteModel, - master::NamedTuple, + master::DP._LOAMaster, result::NamedTuple, method::DP.LOA ) @@ -845,6 +1147,7 @@ function DP.add_disjunct_oa_cuts( transcribed_func, constraint.set) for (binary_ref, _, _, dual_value) in DP.cut_info( master.binary_map[indicator], active, + transcribed_constraint, result.linearization_point, master.variable_map, dual) DP._add_oa_cut_for_constraint( @@ -857,7 +1160,7 @@ function DP.add_disjunct_oa_cuts( end for (binary_ref, linearization_point, var_map, dual_value) in DP.cut_info( - master.binary_map[indicator], active, + master.binary_map[indicator], active, constraint, result.linearization_point, master.variable_map, dual) DP._add_oa_cut_for_constraint( @@ -877,7 +1180,7 @@ end # constraint on the underlying infinite var. State lives in the # closure — no `model.ext` stash. Used by base `with_fixed_combination` # and `commit_combination`. -function DP._fix_combination( +function DP.fix_combination( model::InfiniteOpt.InfiniteModel, combination::AbstractDict ) constraint_refs = InfiniteOpt.InfOptConstraintRef[] @@ -914,7 +1217,7 @@ end # transcribed instances; for a finite var on an InfiniteModel, # `transcription_variable` returns a single ref and `values` is # length-1. -function DP._set_warm_start!( +function DP.set_warm_start!( variable::InfiniteOpt.GeneralVariableRef, values::AbstractVector ) diff --git a/src/loa.jl b/src/loa.jl index 8afcd5f7..aa429ca7 100644 --- a/src/loa.jl +++ b/src/loa.jl @@ -15,43 +15,93 @@ # METHOD TYPE ################################################################################ """ - LOA{O, P, R} <: AbstractReformulationMethod - -Logic-based Outer Approximation solver for GDP models. Uses two models: the -original (reformulated by `inner_method`, binaries fixed per iteration as an -NLP) and a master MILP copy that accumulates OA and no-good cuts. - -`inner_method` defaults to `BigM(M_value)`; pass `MBM(optimizer)` to use -tighter per-constraint Ms. Other reformulations (`Hull`, `PSplit`) are not -yet supported — they emit constraints whose shapes the LOA dual-collection -logic can't slice. + LOA{O, P, R, T} <: AbstractReformulationMethod + +Logic-based Outer Approximation solver for GDP models. Iterates between a +primary NLP (the original model reformulated by `inner_method` with +indicator binaries fixed per iteration) and a master MILP that +accumulates outer-approximation and no-good cuts. + +`inner_method` defaults to `BigM(M_value)`. `MBM(optimizer)` is also +supported. Other reformulations (`Hull`, `PSplit`) are not yet supported. + +## Fields +- `nlp_optimizer::O`: Solver used for the primary NLP. +- `mip_optimizer::P`: Solver used for the master MILP (defaults to + `nlp_optimizer`). +- `inner_method::R`: Reformulation applied to the primary NLP — `BigM` + or `MBM`. +- `max_iter::Int`: Maximum LOA iterations after set-covering seeding. +- `atol::T`, `rtol::T`: Absolute / relative gap tolerances for + convergence. +- `M_value::T`: Big-M used in the disjunct OA cut gating term. +- `max_slack::T`: Upper bound for each per-cut slack variable. +- `oa_penalty::T`: V&G 1990 penalty coefficient applied to slacks in + the master objective. """ -struct LOA{O, P, R} <: AbstractReformulationMethod +struct LOA{O, P, R, T} <: AbstractReformulationMethod nlp_optimizer::O mip_optimizer::P inner_method::R max_iter::Int - atol::Float64 - rtol::Float64 - M_value::Float64 - max_slack::Float64 - OA_penalty_factor::Float64 + atol::T + rtol::T + M_value::T + max_slack::T + oa_penalty::T + verbose::Bool + supports_schedule::Union{Nothing, Vector{Int}} + coarse_builder::Union{Nothing, Function} function LOA( nlp_optimizer::O; mip_optimizer::P = nlp_optimizer, max_iter::Int = 10, - atol::Float64 = 1e-6, - rtol::Float64 = 1e-4, - M_value::Float64 = 1e9, - max_slack::Float64 = 1000.0, - OA_penalty_factor::Float64 = 1000.0, - inner_method::R = BigM(M_value) - ) where {O, P, R <: AbstractReformulationMethod} - new{O, P, R}(nlp_optimizer, mip_optimizer, inner_method, max_iter, - atol, rtol, M_value, max_slack, OA_penalty_factor) + atol::T = 1e-6, + rtol::T = 1e-4, + M_value::T = 1e9, + max_slack::T = 1e3, + oa_penalty::T = 1e3, + inner_method::R = BigM(M_value), + verbose::Bool = false, + supports_schedule::Union{Nothing, Vector{Int}} = nothing, + coarse_builder::Union{Nothing, Function} = nothing + ) where {O, P, R <: AbstractReformulationMethod, T} + R <: Union{BigM, MBM} || error( + "LOA inner_method must be BigM or MBM (got $R). " * + "Hull and PSplit are not yet supported.") + supports_schedule === nothing || coarse_builder !== nothing || error( + "LOA `supports_schedule` requires a `coarse_builder = N -> " * + "InfiniteModel` that constructs a fresh model at each " * + "warmup resolution.") + new{O, P, R, T}(nlp_optimizer, mip_optimizer, inner_method, + max_iter, atol, rtol, M_value, max_slack, oa_penalty, verbose, + supports_schedule, coarse_builder) end end +################################################################################ +# LOA MASTER +################################################################################ +# TODO: Move `_LOAMaster` to `src/datatypes.jl` alongside `GDPSubmodel` +# once the LOA API stabilizes. Keeping it co-located with the algorithm +# while the field set is still in flux. +# +# Bundles the LOA master MILP and the maps the algorithm needs to +# translate original-model refs into master-model refs. +# `objective_ref_map` is split from `variable_map` because the +# InfiniteOpt aggregate-objective path uses a transcribed- +# `JuMP.VariableRef`-keyed map for the objective only, while constraint +# linearization uses the InfiniteOpt-keyed `variable_map`. +mutable struct _LOAMaster{M <: JuMP.AbstractModel, OF, AO, BM, VM, RM} + model::M + binary_map::BM + variable_map::VM + objective_sense::_MOI.OptimizationSense + original_objective::OF + alpha_oa::AO + objective_ref_map::RM +end + ################################################################################ # SENSE PRIMITIVES ################################################################################ @@ -71,6 +121,25 @@ _flip_sense(::Val{_MOI.MAX_SENSE}) = Val(_MOI.MIN_SENSE) # MAIN ALGORITHM ################################################################################ function reformulate_model(model::JuMP.AbstractModel, method::LOA) + method.supports_schedule === nothing || + error("`supports_schedule` is only meaningful for InfiniteOpt " * + "models; load InfiniteOpt to enable the multi-resolution path.") + _reformulate_loa_single_level(model, method) + _set_solution_method(model, method) + _set_ready_to_optimize(model, true) + return +end + +# Single-resolution LOA. Always uses set-covering for the +# initialization seeds; the InfiniteOpt multi-resolution wrapper +# carries trajectory information across levels via primal warm starts +# on transcribed JuMP variables (set before this is called), not by +# overriding the seed-combination loop. Returns `best_result` +# (NamedTuple with `combination` / `linearization_point` / etc.) or +# `nothing` if every NLP was infeasible. +function _reformulate_loa_single_level( + model::JuMP.AbstractModel, method::LOA + ) _clear_reformulations(model) combinations = _set_covering_combinations(model) reformulate_model(model, method.inner_method) @@ -79,51 +148,81 @@ function reformulate_model(model::JuMP.AbstractModel, method::LOA) reformulation_map = _build_reformulation_map(model) JuMP.set_optimizer(model, method.nlp_optimizer) JuMP.set_silent(model) - # Cached for repeated use in `_worst_objective`, `_is_better`, - # `_flip_sense`, and `_loa_converged`. sense_token = Val(JuMP.objective_sense(model)) best_objective = _worst_objective(sense_token) - is_better(candidate) = _is_better(sense_token, candidate, best_objective) best_result = nothing - # Initialization Procedure (Türkay & Grossmann 1996, sec. 2.2): solve - # the set-covering NLPs to seed the master with at least one OA cut - # per disjunct before the main iteration. - for combination in combinations - result = _solve_nlp(model, combination, method, reformulation_map) + # Initialization Procedure (Türkay & Grossmann 1996, sec. 2.2): + # solve K set-covering NLPs with cycling indicator combinations to + # seed the master with at least one OA cut per disjunct. + for (i, combination) in enumerate(combinations) + t_seed = @elapsed result = _solve_nlp( + model, combination, method, reformulation_map) avoid_combination(master.model, combination, master.binary_map) add_oa_cuts(model, master, result, method) - if result.feasible && is_better(result.objective) + if result.feasible && _is_better(sense_token, result.objective, + best_objective) best_objective = result.objective best_result = result end + method.verbose && _log_seed(i, t_seed, result, best_objective) end - master_bound = _worst_objective(_flip_sense(sense_token)) for iter in 1:method.max_iter - JuMP.optimize!(master.model) - JuMP.is_solved_and_feasible(master.model) || break + t_master = @elapsed JuMP.optimize!(master.model) + feasible = JuMP.is_solved_and_feasible(master.model) + method.verbose && _log_master(iter, master.model, t_master) + feasible || break master_bound = JuMP.objective_value(master.model) - @info "LOA iter $iter: LB=$master_bound UB=$best_objective " * - "gap=$(_gap(sense_token, best_objective, master_bound))" - _loa_converged(best_objective, master_bound, sense_token, method) && break + method.verbose && + _log_iter(iter, master_bound, best_objective, sense_token) + _loa_converged(best_objective, master_bound, sense_token, method) && + break combination = _extract_combination(model, master) - result = _solve_nlp(model, combination, method, reformulation_map) + t_nlp = @elapsed result = _solve_nlp( + model, combination, method, reformulation_map) avoid_combination(master.model, combination, master.binary_map) add_oa_cuts(model, master, result, method) - if result.feasible && is_better(result.objective) + if result.feasible && _is_better(sense_token, result.objective, + best_objective) best_objective = result.objective best_result = result end + method.verbose && _log_nlp(iter, t_nlp, result, best_objective) end if best_result !== nothing commit_combination(model, best_result.combination, best_result.linearization_point) end - _set_solution_method(model, method) - _set_ready_to_optimize(model, true) - return + return best_result +end + +# `verbose = true` trace helpers. Print to stdout so a `tee`-d log +# captures them; structured key=value so they're easy to grep. +function _log_seed(i::Int, t::Real, result::NamedTuple, best::Real) + println("LOA_SEED ", i, ": time=", round(t, digits = 3), + "s feasible=", result.feasible, " obj=", result.objective, + " best=", best) + flush(stdout) +end +function _log_master(iter::Int, master::JuMP.AbstractModel, t::Real) + println("LOA_MASTER iter=", iter, + " status=", JuMP.termination_status(master), + " feasible=", JuMP.is_solved_and_feasible(master), + " time=", round(t, digits = 3), "s") + flush(stdout) +end +function _log_iter(iter::Int, lb::Real, ub::Real, sense_token::Val) + println("LOA_ITER ", iter, ": LB=", lb, " UB=", ub, + " gap=", _gap(sense_token, ub, lb)) + flush(stdout) +end +function _log_nlp(iter::Int, t::Real, result::NamedTuple, best::Real) + println("LOA_NLP iter=", iter, + " time=", round(t, digits = 3), "s feasible=", result.feasible, + " obj=", result.objective, " best=", best) + flush(stdout) end # Convergence: absolute gap ≤ atol or relative gap ≤ rtol. Distinct @@ -155,18 +254,13 @@ end # combinations that activates every indicator at least once, so the # master starts with an OA cut per disjunct. Nested disjunctions are # enumerated alongside top-level ones — inconsistent combinations -# (nested active under inactive parent) are handled by feasibility -# restoration at NLP-solve time. +# (nested active under inactive parent) are handled by the no-good +# cut emitted from the infeasible NLP solve. # # `K = max(disjunction sizes)` combinations suffice: combination `k` # activates the `k`-th indicator of each disjunction, cycling via # `mod1` for disjunctions shorter than `K`. Every indicator gets -# activated at least once over k=1..K. -# -# Note: keeps the less-specific `model::JuMP.AbstractModel` -# signature instead of `model::M where {M <: JuMP.AbstractModel}` to -# avoid ambiguity with InfiniteOpt overrides; this function is -# internal so no public-API impact. +# activated at least once over k = 1..K. function _set_covering_combinations(model::JuMP.AbstractModel) LogicalRef = LogicalVariableRef{typeof(model)} indicator_lists = [collect(d.constraint.indicators) @@ -199,8 +293,7 @@ _is_linear_F(::Type) = false # 1996, eq. 12): copy decision variables and only the linear # constraints, install `alpha_oa` as the objective auxiliary. Nonlinear # objective and disjunct constraints enter as OA cuts after each NLP -# solve. Returns a NamedTuple of (model, binary_map, variable_map, -# objective_sense, original_objective, alpha_oa, objective_ref_map). +# solve. Returns an `_LOAMaster`. function build_loa_master(model::JuMP.AbstractModel, method::LOA) original_objective = JuMP.objective_function(model) objective_sense = JuMP.objective_sense(model) @@ -232,23 +325,24 @@ function build_loa_master(model::JuMP.AbstractModel, method::LOA) alpha_oa = JuMP.@variable(master, base_name = "alpha_oa") JuMP.@objective(master, objective_sense, alpha_oa) - return (model = master, binary_map = binary_map, - variable_map = variable_map, objective_sense = objective_sense, - original_objective = original_objective, alpha_oa = alpha_oa, - objective_ref_map = variable_map) + return _LOAMaster(master, binary_map, variable_map, objective_sense, + original_objective, alpha_oa, variable_map) end ################################################################################ # NLP SUBPROBLEM ################################################################################ # Solve the primary NLP for a fixed combination. If feasible, read the -# primal point, duals, and objective. If infeasible, return an empty -# result — the master's augmented penalty (slacks `σ_ik` on disjunct OA -# cuts) absorbs infeasibility and a no-good cut forbids the combination. +# primal point, duals, and objective. If infeasible, fall through to +# the NLPF (V&G 1990 eq. 8) approximation: a slacked version of the +# same problem that always solves, whose primal becomes the +# linearization site for OA cuts. The master still learns shape +# information from the failed combination instead of only adding a +# no-good cut. function _solve_nlp( model::M, combination, method::LOA, reformulation_map ) where {M <: JuMP.AbstractModel} - return with_fixed_combination(model, combination) do + primary = with_fixed_combination(model, combination) do JuMP.optimize!(model, ignore_optimize_hook = true) if JuMP.is_solved_and_feasible(model) lin_point = extract_solution(model) @@ -259,17 +353,169 @@ function _solve_nlp( linearization_point = lin_point, duals = duals, objective = objective_val, feasible = true) end - return (combination = combination, - linearization_point = Dict{JuMP.AbstractVariableRef, Any}(), - duals = Dict{DisjunctConstraintRef{M}, Any}(), - objective = Inf, feasible = false) + return nothing + end + primary === nothing || return primary + + # Primary NLP infeasible — try NLPF. + nlpf = _solve_nlpf(model, combination, method) + nlpf === nothing || return nlpf + + return (combination = combination, + linearization_point = Dict{JuMP.AbstractVariableRef, Any}(), + duals = Dict{DisjunctConstraintRef{M}, Any}(), + objective = Inf, feasible = false) +end + +################################################################################ +# NLPF (FEASIBILITY SUBPROBLEM) +################################################################################ +# V&G 1990 eq. 8: when the primary NLP is infeasible at a fixed +# combination, copy the model, slack every scalar inequality with a +# single nonnegative slack `u`, minimize `u`, and return the resulting +# primal point as an approximate linearization site. The point +# satisfies equalities and variable bounds exactly; inequalities can +# be violated by at most `u`. The "duals" returned are sign-only — the +# OA cut emitter uses `sign(dual)` to pick a direction, and for a +# slacked feasibility solve we know each active constraint's +# linearization is informative in the standard direction. +function _solve_nlpf( + model::M, combination, method::LOA + ) where {M <: JuMP.AbstractModel} + # NLPF needs scalar Bool combination values to fix binaries + # directly. Per-support `Vector{Bool}` from the InfiniteOpt master + # is out of scope for v1. + all(v -> v isa Bool, values(combination)) || return nothing + + copy, ref_map = JuMP.copy_model(model) + JuMP.set_optimizer(copy, method.nlp_optimizer) + JuMP.set_silent(copy) + + var_type = JuMP.variable_ref_type(typeof(copy)) + u = JuMP.@variable(copy, lower_bound = 0.0, base_name = "_nlpf_u") + + for (F, S) in JuMP.list_of_constraint_types(copy) + F === var_type && continue + _nlpf_should_slack(S) || continue + for cref in JuMP.all_constraints(copy, F, S) + con = JuMP.constraint_object(cref) + JuMP.delete(copy, cref) + new_func = _nlpf_slacked_func(con.func, u, con.set) + JuMP.@constraint(copy, new_func in con.set) + end + end + + JuMP.@objective(copy, Min, u) + + # Plain `JuMP.fix` (no `force = true`) — `force` triggers + # `delete_upper_bound` which fails on InfiniteOpt copies whose + # ZeroOne constraint refs didn't survive `JuMP.copy_model`. We + # leave the binary attribute intact on the copy; the fix pins + # the value to 0/1 which satisfies the ZeroOne anyway. Solvers + # that can't handle binaries (Ipopt) will error out — we catch + # and return `nothing` so the caller falls back to the existing + # no-good-only behavior. + for (indicator, value) in combination + original_binary = _indicator_to_binary(model)[indicator] + _nlpf_fix_on_copy(original_binary, value, ref_map) + end + + try + JuMP.optimize!(copy, ignore_optimize_hook = true) + catch + return nothing + end + JuMP.has_values(copy) || return nothing + + linearization_point = _nlpf_extract_primal(model, ref_map) + duals = _nlpf_sign_duals(model, combination) + return (combination = combination, + linearization_point = linearization_point, + duals = duals, + objective = Inf, feasible = false) +end + +function _nlpf_fix_on_copy( + binary::JuMP.AbstractVariableRef, value::Bool, ref_map + ) + target = ref_map[binary] + JuMP.is_fixed(target) && JuMP.unfix(target) + JuMP.fix(target, value ? 1.0 : 0.0) + return +end +function _nlpf_fix_on_copy( + binary::JuMP.GenericAffExpr, value::Bool, ref_map + ) + # complement form `1 - y_underlying`: indicator=true means + # underlying=0; indicator=false means underlying=1. + underlying = only(keys(binary.terms)) + _nlpf_fix_on_copy(underlying, !value, ref_map) + return +end + +_nlpf_should_slack(::Type{<:_MOI.LessThan}) = true +_nlpf_should_slack(::Type{<:_MOI.GreaterThan}) = true +_nlpf_should_slack(::Type) = false + +_nlpf_slacked_func(func, u, ::_MOI.LessThan) = func - u +_nlpf_slacked_func(func, u, ::_MOI.GreaterThan) = func + u + +# Read primal values from `copy` keyed by the original model's +# variables, in the same per-support shape `extract_solution` would +# have produced on the original. +function _nlpf_extract_primal( + model::JuMP.AbstractModel, ref_map + ) + V = JuMP.variable_ref_type(typeof(model)) + T = JuMP.value_type(typeof(model)) + result = Dict{V, Vector{T}}() + for v in collect_all_vars(model) + JuMP.is_fixed(v) && continue + target = try + ref_map[v] + catch + continue # var not in the copy's ref_map (e.g., parameter only) + end + val = JuMP.value(target) + result[v] = val isa AbstractArray ? vec(val) : [val] + end + return result +end + +# Sign-only "duals" on active disjunct constraints — the OA cut +# emitter only needs `sign(dual)` to pick the linearization direction, +# and for a slacked-feasibility solve each active constraint's +# linearization is meaningful in its standard direction. +function _nlpf_sign_duals( + model::M, combination::AbstractDict + ) where {M <: JuMP.AbstractModel} + duals = Dict{DisjunctConstraintRef{M}, Any}() + for (indicator, active) in combination + any_active(active) || continue + haskey(_indicator_to_constraints(model), indicator) || continue + for cref in _indicator_to_constraints(model)[indicator] + cref isa DisjunctConstraintRef || continue + con = _disjunct_constraints(model)[ + JuMP.index(cref)].constraint + duals[cref] = _nlpf_dual_sign(con.set) + end end + return duals end +_nlpf_dual_sign(::_MOI.LessThan) = 1.0 +_nlpf_dual_sign(::_MOI.GreaterThan) = -1.0 +_nlpf_dual_sign(::_MOI.EqualTo) = [1.0, 1.0] +_nlpf_dual_sign(::_MOI.Interval) = [1.0, 1.0] +_nlpf_dual_sign(s::_MOI.Nonpositives) = ones(_MOI.dimension(s)) +_nlpf_dual_sign(s::_MOI.Nonnegatives) = -ones(_MOI.dimension(s)) +_nlpf_dual_sign(s::_MOI.Zeros) = ones(_MOI.dimension(s)) +_nlpf_dual_sign(::Any) = 0.0 + # Fix `combination`, run `f()`, unfix. Logical binaries are relaxed # from ZeroOne for the duration so a pure NLP solver (Ipopt) can # handle the inner solve; restored on exit. Extensions override -# `_fix_combination` to handle per-support fixing without needing +# `fix_combination` to handle per-support fixing without needing # their own try/finally lifecycle. function with_fixed_combination( f, @@ -277,7 +523,7 @@ function with_fixed_combination( combination::AbstractDict ) relaxed = relax_logical_vars(model) - undo = _fix_combination(model, combination) + undo = fix_combination(model, combination) try return f() finally @@ -298,9 +544,9 @@ function commit_combination( linearization_point::AbstractDict ) relax_logical_vars(model) - _fix_combination(model, combination) + fix_combination(model, combination) for (variable, values) in linearization_point - _set_warm_start!(variable, values) + set_warm_start!(variable, values) end return end @@ -310,7 +556,7 @@ end # InfiniteOpt extension overrides this to handle per-support # `Vector{Bool}` values via point-equality constraints, keeping all # cleanup state captured in the returned closure. -function _fix_combination( +function fix_combination( model::JuMP.AbstractModel, combination::AbstractDict ) for (indicator, value) in combination @@ -327,7 +573,7 @@ end # warm start. `values` is always per-support shape (length-1 vector # for finite vars); base unwraps. The InfiniteOpt extension overrides # for `GeneralVariableRef` to broadcast across transcribed supports. -_set_warm_start!(variable, values::AbstractVector) = +set_warm_start!(variable, values::AbstractVector) = JuMP.set_start_value(variable, only(values)) ################################################################################ @@ -416,7 +662,7 @@ end # extensions add per-support). function _extract_combination( model::M, - master::NamedTuple + master::_LOAMaster ) where {M <: JuMP.AbstractModel} combination = Dict{LogicalVariableRef{M}, Any}() for (_, disjunction_data) in _disjunctions(model) @@ -436,7 +682,7 @@ combination_val(binary_ref) = round(Bool, JuMP.value(binary_ref)) ################################################################################ function add_oa_cuts( model::JuMP.AbstractModel, - master::NamedTuple, + master::_LOAMaster, result::NamedTuple, method::LOA ) @@ -470,7 +716,7 @@ _add_objective_cut(::Val{_MOI.MAX_SENSE}, master, lin) = # transcription to handle per-support / aggregate-ref globals. function _add_global_oa_cuts( model::JuMP.AbstractModel, - master::NamedTuple, + master::_LOAMaster, result::NamedTuple, method::LOA ) @@ -501,7 +747,7 @@ end # `s (lin − rhs) − σ ≤ M(1 − y)`. function add_disjunct_oa_cuts( model::JuMP.AbstractModel, - master::NamedTuple, + master::_LOAMaster, result::NamedTuple, method::LOA ) @@ -518,7 +764,7 @@ function add_disjunct_oa_cuts( dual = get(result.duals, cref, nothing) dual === nothing && continue for (binary_ref, lin_point, var_map, dual_value) in cut_info( - master.binary_map[indicator], active, + master.binary_map[indicator], active, constraint, result.linearization_point, master.variable_map, dual) _add_oa_cut_for_constraint( constraint, master, binary_ref, lin_point, @@ -534,13 +780,13 @@ end # be an invalid relaxation, and the penalized slack keeps the master # feasible instead of letting accumulated cuts make it infeasible. function _penalized_slack( - master::NamedTuple, method::LOA, penalty_sign::Int + master::_LOAMaster, method::LOA, penalty_sign::Int ) slack = JuMP.@variable(master.model, lower_bound = 0.0, upper_bound = method.max_slack) JuMP.set_objective_function(master.model, JuMP.objective_function(master.model) + - penalty_sign * method.OA_penalty_factor * slack) + penalty_sign * method.oa_penalty * slack) return slack end @@ -549,7 +795,7 @@ end # are exact via BigM and skipped. function _add_oa_cut_for_constraint( constraint::JuMP.AbstractConstraint, - master::NamedTuple, + master::_LOAMaster, binary_ref, linearization_point::AbstractDict, var_map::AbstractDict, @@ -577,7 +823,7 @@ end # `EqualTo` / `Interval` get a two-sided pair sharing one slack. # Unknown set types fall back to the prior hard cut. function _add_global_oa_row!( - master::NamedTuple, lin, set::_MOI.LessThan, + master::_LOAMaster, lin, set::_MOI.LessThan, method::LOA, penalty_sign::Int ) slack = _penalized_slack(master, method, penalty_sign) @@ -585,7 +831,7 @@ function _add_global_oa_row!( return end function _add_global_oa_row!( - master::NamedTuple, lin, set::_MOI.GreaterThan, + master::_LOAMaster, lin, set::_MOI.GreaterThan, method::LOA, penalty_sign::Int ) slack = _penalized_slack(master, method, penalty_sign) @@ -593,7 +839,7 @@ function _add_global_oa_row!( return end function _add_global_oa_row!( - master::NamedTuple, lin, set::_MOI.EqualTo, + master::_LOAMaster, lin, set::_MOI.EqualTo, method::LOA, penalty_sign::Int ) slack = _penalized_slack(master, method, penalty_sign) @@ -603,7 +849,7 @@ function _add_global_oa_row!( return end function _add_global_oa_row!( - master::NamedTuple, lin, set::_MOI.Interval, + master::_LOAMaster, lin, set::_MOI.Interval, method::LOA, penalty_sign::Int ) slack = _penalized_slack(master, method, penalty_sign) @@ -612,20 +858,27 @@ function _add_global_oa_row!( return end function _add_global_oa_row!( - master::NamedTuple, lin, set, ::LOA, ::Int + master::_LOAMaster, lin, set, ::LOA, ::Int ) JuMP.@constraint(master.model, lin in set) return end # OVERRIDABLE. Yield `(binary_ref, lin_point, var_map, dual)` per OA -# cut to emit. Scalar binary returns one tuple; InfiniteOpt overrides -# for per-support `Vector` binaries to yield multiple sliced tuples. +# cut to emit for `constraint`. Scalar binary returns one tuple; the +# InfiniteOpt extension overrides to fan out across supports of any +# infinite variable found in `binary_ref` OR `constraint.func`. The +# constraint must factor in because a finite indicator on an +# InfiniteModel can still gate a constraint that depends on an +# infinite var — one cut per support is needed, even though the +# indicator is scalar. +# # `GenericAffExpr` covers complement-form indicators (`1 - y`) stored # in `binary_map` when a `Logical` is declared with `logical_complement`. cut_info( binary_ref::JuMP.AbstractVariableRef, active::Bool, + constraint::JuMP.AbstractConstraint, linearization_point::AbstractDict, variable_map::AbstractDict, dual @@ -633,6 +886,7 @@ cut_info( cut_info( binary_ref::JuMP.GenericAffExpr, active::Bool, + constraint::JuMP.AbstractConstraint, linearization_point::AbstractDict, variable_map::AbstractDict, dual @@ -647,9 +901,16 @@ any_active(active::Bool) = active _collapse_dual(dual::Number) = dual _collapse_dual(dual) = sum(dual) +################################################################################ +# LINEARIZATION & EXPRESSION CONVERSION +################################################################################ +# TODO: Move this section out of `loa.jl` (likely back to +# `src/utilities.jl`) when a second OA-style method lands and these +# helpers earn their generality. Currently only LOA uses them, so +# they live next to the algorithm that consumes them. + # First-order Taylor for a single var or affine expression mapped into -# master space. (Quad/nonlinear `_linearize_at` lives in `utilities.jl` -# and uses MOI Nonlinear AD.) +# master space. function _linearize_at( variable::JuMP.AbstractVariableRef, ::AbstractDict, @@ -672,3 +933,83 @@ function _linearize_at( return result end +# Convert JuMP expression trees to Julia Expr with +# MOI.VariableIndex leaves for MOI.Nonlinear evaluation. +function _to_nlp_expr(expr::JuMP.GenericNonlinearExpr, idx::Dict) + args = Any[_to_nlp_expr(a, idx) for a in expr.args] + return Expr(:call, expr.head, args...) +end +function _to_nlp_expr(expr::JuMP.GenericAffExpr, idx::Dict) + parts = Any[expr.constant] + for (var, coef) in expr.terms + push!(parts, Expr(:call, :*, coef, _MOI.VariableIndex(idx[var]))) + end + length(parts) == 1 && return parts[1] + return Expr(:call, :+, parts...) +end +function _to_nlp_expr(expr::JuMP.GenericQuadExpr, idx::Dict) + parts = Any[_to_nlp_expr(expr.aff, idx)] + for (pair, coef) in expr.terms + push!(parts, Expr(:call, :*, coef, + _MOI.VariableIndex(idx[pair.a]), + _MOI.VariableIndex(idx[pair.b]))) + end + length(parts) == 1 && return parts[1] + return Expr(:call, :+, parts...) +end +function _to_nlp_expr(var::JuMP.AbstractVariableRef, idx::Dict) + return _MOI.VariableIndex(idx[var]) +end +_to_nlp_expr(x::Number, ::Dict) = x + +# First-order Taylor linearization of a quadratic or nonlinear +# expression at point xk via MOI.Nonlinear reverse-mode AD. +function _linearize_at( + func::Union{JuMP.GenericQuadExpr, JuMP.GenericNonlinearExpr}, + xk::Dict, ref_map + ) + vars = JuMP.AbstractVariableRef[] + _interrogate_variables(v -> push!(vars, v), func) + unique!(vars) + isempty(vars) && return JuMP.AffExpr(JuMP.value(v -> 0.0, func)) + + n = length(vars) + T = JuMP.value_type(typeof(JuMP.owner_model(vars[1]))) + idx = Dict(vars[i] => i for i in 1:n) + nlp = _MOI.Nonlinear.Model() + _MOI.Nonlinear.set_objective(nlp, _to_nlp_expr(func, idx)) + ord = [_MOI.VariableIndex(i) for i in 1:n] + evaluator = _MOI.Nonlinear.Evaluator( + nlp, _MOI.Nonlinear.SparseReverseMode(), ord) + _MOI.initialize(evaluator, [:Grad]) + + xk_vec = [_unwrap_scalar(get(xk, v, zero(T))) for v in vars] + f_xk = _MOI.eval_objective(evaluator, xk_vec) + grad = zeros(T, n) + _MOI.eval_objective_gradient(evaluator, grad, xk_vec) + + constant = T(f_xk) + for i in 1:n + constant -= grad[i] * xk_vec[i] + end + V = typeof(ref_map[vars[1]]) + result = JuMP.GenericAffExpr{T, V}(constant) + for i in 1:n + iszero(grad[i]) && continue + JuMP.add_to_expression!(result, grad[i], ref_map[vars[i]]) + end + return result +end + +# Extract RHS from an MOI set. +_set_rhs(s::Union{_MOI.LessThan, _MOI.GreaterThan, _MOI.EqualTo}) = + _MOI.constant(s) +_set_rhs(::Any) = 0.0 + +# Unwrap a 1-element per-support `Vector` to its scalar value; +# scalars pass through. `extract_solution` returns per-support +# `Vector`s uniformly (length-1 for finite, length-K for InfiniteOpt). +# AD pipelines and `set_start_value` need a scalar in the finite +# case; per-support consumers slice out a scalar themselves. +_unwrap_scalar(v::Real) = v +_unwrap_scalar(v::AbstractVector) = only(v) diff --git a/src/utilities.jl b/src/utilities.jl index 790aaffc..e5686af5 100644 --- a/src/utilities.jl +++ b/src/utilities.jl @@ -532,93 +532,3 @@ function _remap_constraint_to_indicator( ) where {M <: JuMP.AbstractModel} return disj_map[con_ref] end -################################################################################ -# LINEARIZATION & EXPRESSION CONVERSION -################################################################################ -# First-order Taylor approximation and MOI expression building -# for outer approximation methods (LOA, future OA variants). -################################################################################ - -################################################################################ -# MOI NONLINEAR EXPRESSION CONVERSION -################################################################################ -# Convert JuMP expression trees to Julia Expr with -# MOI.VariableIndex leaves for MOI.Nonlinear evaluation. -function _to_nlp_expr(expr::JuMP.GenericNonlinearExpr, idx::Dict) - args = Any[_to_nlp_expr(a, idx) for a in expr.args] - return Expr(:call, expr.head, args...) -end -function _to_nlp_expr(expr::JuMP.GenericAffExpr, idx::Dict) - parts = Any[expr.constant] - for (var, coef) in expr.terms - push!(parts, Expr(:call, :*, coef, _MOI.VariableIndex(idx[var]))) - end - length(parts) == 1 && return parts[1] - return Expr(:call, :+, parts...) -end -function _to_nlp_expr(expr::JuMP.GenericQuadExpr, idx::Dict) - parts = Any[_to_nlp_expr(expr.aff, idx)] - for (pair, coef) in expr.terms - push!(parts, Expr(:call, :*, coef, - _MOI.VariableIndex(idx[pair.a]), - _MOI.VariableIndex(idx[pair.b]))) - end - length(parts) == 1 && return parts[1] - return Expr(:call, :+, parts...) -end -function _to_nlp_expr(var::JuMP.AbstractVariableRef, idx::Dict) - return _MOI.VariableIndex(idx[var]) -end -_to_nlp_expr(x::Number, ::Dict) = x - -# First-order Taylor linearization of a quadratic or nonlinear -# expression at point xk via MOI.Nonlinear reverse-mode AD. -function _linearize_at( - func::Union{JuMP.GenericQuadExpr, JuMP.GenericNonlinearExpr}, - xk::Dict, ref_map - ) - vars = JuMP.AbstractVariableRef[] - _interrogate_variables(v -> push!(vars, v), func) - unique!(vars) - isempty(vars) && return JuMP.AffExpr(JuMP.value(v -> 0.0, func)) - - n = length(vars) - T = JuMP.value_type(typeof(JuMP.owner_model(vars[1]))) - idx = Dict(vars[i] => i for i in 1:n) - nlp = _MOI.Nonlinear.Model() - _MOI.Nonlinear.set_objective(nlp, _to_nlp_expr(func, idx)) - ord = [_MOI.VariableIndex(i) for i in 1:n] - evaluator = _MOI.Nonlinear.Evaluator( - nlp, _MOI.Nonlinear.SparseReverseMode(), ord) - _MOI.initialize(evaluator, [:Grad]) - - xk_vec = [_unwrap_scalar(get(xk, v, zero(T))) for v in vars] - f_xk = _MOI.eval_objective(evaluator, xk_vec) - grad = zeros(T, n) - _MOI.eval_objective_gradient(evaluator, grad, xk_vec) - - constant = T(f_xk) - for i in 1:n - constant -= grad[i] * xk_vec[i] - end - V = typeof(ref_map[vars[1]]) - result = JuMP.GenericAffExpr{T, V}(constant) - for i in 1:n - iszero(grad[i]) && continue - JuMP.add_to_expression!(result, grad[i], ref_map[vars[i]]) - end - return result -end - -# Extract RHS from an MOI set. -_set_rhs(s::Union{_MOI.LessThan, _MOI.GreaterThan, _MOI.EqualTo}) = - _MOI.constant(s) -_set_rhs(::Any) = 0.0 - -# Unwrap a 1-element per-support `Vector` to its scalar value; -# scalars pass through. `extract_solution` returns per-support -# `Vector`s uniformly (length-1 for finite, length-K for InfiniteOpt). -# AD pipelines and `set_start_value` need a scalar in the finite -# case; per-support consumers slice out a scalar themselves. -_unwrap_scalar(v::Real) = v -_unwrap_scalar(v::AbstractVector) = only(v) diff --git a/test/constraints/loa.jl b/test/constraints/loa.jl index b92db13d..732be28a 100644 --- a/test/constraints/loa.jl +++ b/test/constraints/loa.jl @@ -9,19 +9,19 @@ function test_loa_datatype() @test method.rtol == 1e-4 @test method.M_value == 1e9 @test method.max_slack == 1000.0 - @test method.OA_penalty_factor == 1000.0 + @test method.oa_penalty == 1000.0 @test method.inner_method isa BigM @test method.inner_method.value == 1e9 method = LOA(HiGHS.Optimizer; max_iter = 50, atol = 1e-8, rtol = 1e-6, M_value = 1e6, max_slack = 500.0, - OA_penalty_factor = 200.0) + oa_penalty = 200.0) @test method.max_iter == 50 @test method.atol == 1e-8 @test method.rtol == 1e-6 @test method.M_value == 1e6 @test method.max_slack == 500.0 - @test method.OA_penalty_factor == 200.0 + @test method.oa_penalty == 200.0 @test method.inner_method isa BigM @test method.inner_method.value == 1e6 diff --git a/test/extensions/InfiniteDisjunctiveProgramming.jl b/test/extensions/InfiniteDisjunctiveProgramming.jl index dc70b802..088cb10a 100644 --- a/test/extensions/InfiniteDisjunctiveProgramming.jl +++ b/test/extensions/InfiniteDisjunctiveProgramming.jl @@ -822,7 +822,7 @@ end function test_loa_infinite_complement_indicator() # Regression: `_indicator_to_binary(model)[Y2]` returns the AffExpr # `1 - binary(Y1)` for a logical-complement indicator. Earlier the - # extension's `_fix_combination` called `JuMP.fix` directly on that + # extension's `fix_combination` called `JuMP.fix` directly on that # AffExpr and crashed; now it delegates to base `fix_indicator` # which unwraps and inverts. # @@ -852,6 +852,39 @@ function test_loa_infinite_complement_indicator() @test objective_value(model) ≈ 8.0 atol = 1e-2 end +function test_loa_infinite_complement_nonlinear_disjunct() + # Regression: a finite (or complement-form AffExpr) indicator + # gating a NONLINEAR constraint on an infinite variable. Earlier + # `cut_info` decided fan-out from the indicator only — finite + # indicator → single un-sliced site → `_linearize_at` received + # per-support `Vector{Float64}` values and tripped on + # `_unwrap_scalar`'s `only(v)`. Fixed by inspecting the constraint + # expression too: any infinite var found there governs the fan-out + # supports, even when the indicator is scalar. + # + # max ∫ x dt with 0 ≤ x ≤ 10 over t ∈ [0,1]: + # Y1: x ≤ 3 Y2 ≡ ¬Y1: x² ≤ 64 + # Y2 (complement) is the maximizer (x = 8 across t), giving 8. + ipopt = optimizer_with_attributes(Ipopt.Optimizer, + "print_level" => 0, "sb" => "yes") + juniper = optimizer_with_attributes(Juniper.Optimizer, + "nl_solver" => ipopt, "log_levels" => []) + model = InfiniteGDPModel(juniper) + set_silent(model) + @infinite_parameter(model, t ∈ [0, 1], num_supports = 5) + @variable(model, 0 <= x <= 10, Infinite(t)) + @variable(model, Y1, Logical) + @variable(model, Y2, Logical, logical_complement = Y1) + @constraint(model, x <= 3, Disjunct(Y1)) + @constraint(model, x^2 <= 64, Disjunct(Y2)) + @disjunction(model, [Y1, Y2]) + @objective(model, Max, ∫(x, t)) + optimize!(model, + gdp_method = LOA(juniper; mip_optimizer = HiGHS.Optimizer)) + @test termination_status(model) in (MOI.OPTIMAL, MOI.LOCALLY_SOLVED) + @test objective_value(model) ≈ 8.0 atol = 1e-2 +end + function test_loa_infinite_multidim_parameter() # Regression: multi-D dependent parameter group. `supports(ξ)` is # a 2 × N matrix, not a vector. Earlier the extension called @@ -986,6 +1019,7 @@ end test_loa_infinite_nonlinear_global() test_loa_infinite_aggregate_global() test_loa_infinite_complement_indicator() + test_loa_infinite_complement_nonlinear_disjunct() test_loa_infinite_multidim_parameter() else @info "Skipping InfiniteOpt LOA tests: " * From af043e04abe5dccb9cdecaaa85ab68649f20443a Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Thu, 11 Jun 2026 16:09:55 -0400 Subject: [PATCH 49/59] . --- src/loa.jl | 10 ++++++---- test/constraints/loa.jl | 31 +++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/src/loa.jl b/src/loa.jl index aa429ca7..ae6a1c80 100644 --- a/src/loa.jl +++ b/src/loa.jl @@ -5,10 +5,12 @@ # With augmented-penalty OA master from: # Viswanathan & Grossmann (1990), Comp. & Chem. Eng. 14(7), 769-782 # -# Infeasible primary NLPs are handled solely via the master's augmented -# penalty: the combination is forbidden by a no-good cut and no OA cut -# is emitted from that iteration. No separate feasibility-restoration -# (NLPF) subproblem is built. +# Infeasible primary NLPs fall through to NLPF (V&G 1990 eq. 8): a +# slacked feasibility version of the same problem whose primal becomes +# the linearization site for OA cuts. A no-good cut still forbids that +# combination on the master. NLPF is bypassed for per-support +# Vector{Bool} combinations (InfiniteOpt multi-resolution master) — +# those fall back to no-good-only. ################################################################################ ################################################################################ diff --git a/test/constraints/loa.jl b/test/constraints/loa.jl index 732be28a..7a202264 100644 --- a/test/constraints/loa.jl +++ b/test/constraints/loa.jl @@ -289,6 +289,36 @@ function test_to_nlp_expr() @test DP._to_nlp_expr(42, idx) == 42 end +function test_loa_nlpf_infeasible_disjunct() + # Y1 disjunct constraint x^2 >= 200 is NLP-infeasible against the + # variable bound x in [0, 10] (max x^2 = 100). The primary NLP at + # the Y1=true seed therefore fails and NLPF (V&G 1990 eq. 8) must + # kick in: slack `u` on the GreaterThan, minimize u, return the + # x = 10 / u = 100 primal as the linearization site for the OA cut + # on x^2 >= 200. With the master's per-cut penalized slack + # absorbing the resulting (invalid in isolation) linearization and + # the no-good cut forbidding Y1, LOA should still converge to the + # Y2=true optimum x = 5. + ipopt = optimizer_with_attributes(Ipopt.Optimizer, + "print_level" => 0, "sb" => "yes") + juniper = optimizer_with_attributes(Juniper.Optimizer, + "nl_solver" => ipopt, "log_levels" => []) + model = GDPModel(juniper) + set_silent(model) + @variable(model, 0 <= x <= 10) + @variable(model, Y[1:2], Logical) + @constraint(model, x^2 >= 200, Disjunct(Y[1])) + @constraint(model, x <= 5, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Max, x) + optimize!(model, + gdp_method = LOA(juniper; mip_optimizer = HiGHS.Optimizer)) + @test termination_status(model) in (MOI.OPTIMAL, MOI.LOCALLY_SOLVED) + @test objective_value(model) ≈ 5.0 atol = 1e-3 + @test value(Y[2]) ≈ 1.0 atol = 1e-6 + @test value(Y[1]) ≈ 0.0 atol = 1e-6 +end + @testset "LOA" begin test_loa_datatype() test_set_covering_combos() @@ -301,6 +331,7 @@ end test_loa_error_fallback() test_loa_nonlinear_global() test_loa_complement_indicator_nonlinear_disjunct() + test_loa_nlpf_infeasible_disjunct() test_linearize_nonlinear_exp() test_linearize_nonlinear_sin() test_linearize_nonlinear_multivar() From f2dd80494a1a55c0d3f96483b886fc10434ad9a7 Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Thu, 11 Jun 2026 16:34:36 -0400 Subject: [PATCH 50/59] . --- ext/InfiniteDisjunctiveProgramming.jl | 20 ++++++ src/loa.jl | 62 ++++++++++++------- .../InfiniteDisjunctiveProgramming.jl | 30 +++++++++ 3 files changed, 89 insertions(+), 23 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 30022165..62a69b61 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -1213,6 +1213,26 @@ function DP.fix_combination( end end +# Per-support binary pin on an NLPF copy of an `InfiniteModel`. +# Triggered when the combination value is `AbstractVector{Bool}` — +# i.e., the indicator is itself infinite, so each support k must be +# pinned independently via a point-equality `binary(t_k) == value[k]`. +# Finite indicators on an `InfiniteModel` dispatch to the base scalar +# `JuMP.fix` path because `combination_val` returns a scalar `Bool`. +# Complement-form binaries are handled by base recursion before this +# dispatch fires. +function DP._nlpf_fix_on_copy( + copy::InfiniteOpt.InfiniteModel, + binary::InfiniteOpt.GeneralVariableRef, + value::AbstractVector{Bool} + ) + for (k, support) in enumerate(_supports_of(binary)) + JuMP.@constraint(copy, + _at_support(binary, support) == (value[k] ? 1.0 : 0.0)) + end + return +end + # Broadcast a per-support warm start across an infinite var's # transcribed instances; for a finite var on an InfiniteModel, # `transcription_variable` returns a single ref and `values` is diff --git a/src/loa.jl b/src/loa.jl index ae6a1c80..45669253 100644 --- a/src/loa.jl +++ b/src/loa.jl @@ -384,11 +384,6 @@ end function _solve_nlpf( model::M, combination, method::LOA ) where {M <: JuMP.AbstractModel} - # NLPF needs scalar Bool combination values to fix binaries - # directly. Per-support `Vector{Bool}` from the InfiniteOpt master - # is out of scope for v1. - all(v -> v isa Bool, values(combination)) || return nothing - copy, ref_map = JuMP.copy_model(model) JuMP.set_optimizer(copy, method.nlp_optimizer) JuMP.set_silent(copy) @@ -409,17 +404,16 @@ function _solve_nlpf( JuMP.@objective(copy, Min, u) - # Plain `JuMP.fix` (no `force = true`) — `force` triggers - # `delete_upper_bound` which fails on InfiniteOpt copies whose - # ZeroOne constraint refs didn't survive `JuMP.copy_model`. We - # leave the binary attribute intact on the copy; the fix pins - # the value to 0/1 which satisfies the ZeroOne anyway. Solvers - # that can't handle binaries (Ipopt) will error out — we catch - # and return `nothing` so the caller falls back to the existing - # no-good-only behavior. + # Translate each original-model indicator binary to its + # counterpart on the copy, then pin it to the combination value. + # `_nlpf_fix_on_copy` dispatches on value type: scalar `Bool` + # paths use `JuMP.fix`; per-support `AbstractVector{Bool}` paths + # require an `InfiniteModel`-side override (per-support point- + # equality) — see `ext/InfiniteDisjunctiveProgramming.jl`. for (indicator, value) in combination - original_binary = _indicator_to_binary(model)[indicator] - _nlpf_fix_on_copy(original_binary, value, ref_map) + binary = _binary_on_copy( + _indicator_to_binary(model)[indicator], ref_map) + _nlpf_fix_on_copy(copy, binary, value) end try @@ -437,24 +431,46 @@ function _solve_nlpf( objective = Inf, feasible = false) end +# Translate a binary reference from the original model to its +# counterpart on the copy. Direct refs go through `ref_map`; +# complement-form `1 - y_orig` rebuilds as `1 - ref_map[y_orig]`. +_binary_on_copy(binary::JuMP.AbstractVariableRef, ref_map) = + ref_map[binary] +function _binary_on_copy( + binary::JuMP.GenericAffExpr, ref_map + ) + underlying = only(keys(binary.terms)) + return 1.0 - ref_map[underlying] +end + +# Pin a copy-side binary to a combination value. Plain `JuMP.fix` +# (no `force = true`) — `force` triggers `delete_upper_bound` which +# fails on InfiniteOpt copies whose ZeroOne constraint refs didn't +# survive `JuMP.copy_model`. The fix pins the value to 0/1 which +# satisfies the ZeroOne anyway. Solvers that can't handle binaries +# (Ipopt) will error out — the caller catches and returns `nothing` +# so the iteration falls back to no-good-only behavior. function _nlpf_fix_on_copy( - binary::JuMP.AbstractVariableRef, value::Bool, ref_map + copy, binary::JuMP.AbstractVariableRef, value::Bool ) - target = ref_map[binary] - JuMP.is_fixed(target) && JuMP.unfix(target) - JuMP.fix(target, value ? 1.0 : 0.0) + JuMP.is_fixed(binary) && JuMP.unfix(binary) + JuMP.fix(binary, value ? 1.0 : 0.0) return end +# Complement form `1 - y_underlying`: indicator=value means +# underlying=!value. Recursion routes both scalar `Bool` and per- +# support `AbstractVector{Bool}` to the underlying-binary dispatch. function _nlpf_fix_on_copy( - binary::JuMP.GenericAffExpr, value::Bool, ref_map + copy, binary::JuMP.GenericAffExpr, value ) - # complement form `1 - y_underlying`: indicator=true means - # underlying=0; indicator=false means underlying=1. underlying = only(keys(binary.terms)) - _nlpf_fix_on_copy(underlying, !value, ref_map) + _nlpf_fix_on_copy(copy, underlying, _flip(value)) return end +_flip(v::Bool) = !v +_flip(v::AbstractVector{Bool}) = .!v + _nlpf_should_slack(::Type{<:_MOI.LessThan}) = true _nlpf_should_slack(::Type{<:_MOI.GreaterThan}) = true _nlpf_should_slack(::Type) = false diff --git a/test/extensions/InfiniteDisjunctiveProgramming.jl b/test/extensions/InfiniteDisjunctiveProgramming.jl index 088cb10a..c5df7165 100644 --- a/test/extensions/InfiniteDisjunctiveProgramming.jl +++ b/test/extensions/InfiniteDisjunctiveProgramming.jl @@ -916,6 +916,35 @@ function test_loa_infinite_multidim_parameter() @test objective_value(model) ≈ 8.0 atol = 1e-2 end +function test_loa_infinite_nlpf_infeasible_disjunct() + # InfiniteOpt LOA: Y[1]'s per-support constraint x^2 >= 200 is + # NLP-infeasible against the bound x in [0, 10] (max x^2 = 100). + # With infinite indicators `Y[1:2], InfiniteLogical(t)`, the + # combination value is `Vector{Bool}` — exercising the extension + # override `_nlpf_fix_on_copy(::InfiniteModel, + # ::GeneralVariableRef, ::AbstractVector{Bool})` that pins each + # support via point-equality. LOA must still converge to Y[2] + # active everywhere (x = 5), giving objective ∫x dt = 5. + ipopt = optimizer_with_attributes(Ipopt.Optimizer, + "print_level" => 0, "sb" => "yes") + juniper = optimizer_with_attributes(Juniper.Optimizer, + "nl_solver" => ipopt, "log_levels" => []) + model = InfiniteGDPModel(juniper) + set_silent(model) + @infinite_parameter(model, t ∈ [0, 1], num_supports = 3) + @variable(model, 0 <= x <= 10, Infinite(t)) + @variable(model, Y[1:2], InfiniteLogical(t)) + @constraint(model, x^2 >= 200, Disjunct(Y[1])) + @constraint(model, x <= 5, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Max, ∫(x, t)) + optimize!(model, + gdp_method = LOA(juniper; mip_optimizer = HiGHS.Optimizer)) + @test termination_status(model) in + (MOI.OPTIMAL, MOI.LOCALLY_SOLVED) + @test objective_value(model) ≈ 5.0 atol = 1e-2 +end + function test_loa_infinite_aggregate_global() # min ∫x dt s.t. ∫(x^2, t) <= 4 (aggregate global), x >= y, # (y >= 1) ∨ (y >= 3), 0 <= x, y <= 10 over t ∈ [0, 1]. @@ -1021,6 +1050,7 @@ end test_loa_infinite_complement_indicator() test_loa_infinite_complement_nonlinear_disjunct() test_loa_infinite_multidim_parameter() + test_loa_infinite_nlpf_infeasible_disjunct() else @info "Skipping InfiniteOpt LOA tests: " * "JuMP.copy_model(::InfiniteModel) unavailable " * From c674594025506820662de0bb9b9674fda33a96cb Mon Sep 17 00:00:00 2001 From: d227nguyen Date: Mon, 15 Jun 2026 17:05:31 -0400 Subject: [PATCH 51/59] . --- ext/InfiniteDisjunctiveProgramming.jl | 110 ++++++++++++- src/loa.jl | 220 +++++++++++++++++++++++--- 2 files changed, 305 insertions(+), 25 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 62a69b61..d0de8f4c 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -898,9 +898,15 @@ function _transcribed_to_master_point( if isempty(prefs) result[transcribed] = master_var else - supports = vec(InfiniteOpt.supports(only(prefs))) - for (k, ref) in enumerate(vec(transcribed)) - result[ref] = master_var(supports[k]) + # `_supports_of` returns scalars for 1-D parameters and + # vectors for multi-D dependent groups; `_at_support` + # routes both shapes through `v(support)`. Using + # `vec(InfiniteOpt.supports(...))` here would silently + # flatten a multi-D matrix into scalars and break point + # evaluation on dependent parameter groups. + for (k, support) in enumerate(_supports_of(v)) + result[vec(transcribed)[k]] = + _at_support(master_var, support) end end end @@ -996,9 +1002,98 @@ function DP.build_loa_master( variable_map[v] = ref_map[v] end + # Aggregate-wrapped LINEAR constraints (e.g. `𝔼(W, ξ) ≥ α`) + # were filtered out at `copy_model` time because `copy_model` + # cannot transfer MeasureRefs across InfiniteModels. The + # nonlinear-aggregate path re-adds them as OA cuts after each + # NLP solve, but `_add_global_oa_cuts_infinite` short-circuits + # on linear `F` — so without this step a linear chance + # constraint is missing from the master entirely, and the + # master will pick combinations that violate it. Transcribe + # each such constraint once and add the flat scalar form to + # the master directly. + _add_aggregate_linear_constraints!( + master, model, ref_map, variable_type) + return DP._LOAMaster(master, binary_map, variable_map, objective_sense, original_objective, alpha_oa, - objective_ref_map) + objective_ref_map, Any[]) +end + +# Walk the original model's linear-`F` constraints, transcribe those +# containing a `MeasureRef` / `ParameterFunctionRef`, and append the +# resulting flat scalar constraint to the master. No linearization +# needed (the constraint is already affine post-transcription) — +# `_linearize_at` on an `AffExpr` just substitutes variables via +# `transcribed_to_master`. Reuses the transformation backend that +# `_add_global_oa_cuts_infinite` will rebuild later; building it +# now is cheap and idempotent. +function _add_aggregate_linear_constraints!( + master::InfiniteOpt.InfiniteModel, + model::InfiniteOpt.InfiniteModel, + ref_map::AbstractDict, + variable_type::Type + ) + has_any = false + for (F, S) in JuMP.list_of_constraint_types(model) + F === variable_type && continue + DP._is_linear_F(F) || continue + for cref in JuMP.all_constraints(model, F, S) + con = JuMP.constraint_object(cref) + _has_aggregate_ref(con.func) || continue + has_any = true + break + end + has_any && break + end + has_any || return + + InfiniteOpt.build_transformation_backend!(model) + transcribed_to_master = _transcribed_to_master_point(model, ref_map) + # `_transcribed_to_master_point` only walks decision vars. Finite + # parameters (`@finite_parameter`) survive transcription as + # scalar JuMP variables and can appear in transcribed + # constraints (e.g. the `α` on the RHS of an event constraint). + # Map each transcribed-parameter JuMP var to the master's + # corresponding parameter so the constraint stays + # parameter-relative (the master then honors `set_value(α, ...)` + # without rebuild). + for p in InfiniteOpt.all_parameters(model) + transcribed_p = try + InfiniteOpt.transformation_variable(p) + catch + continue + end + transcribed_p isa JuMP.VariableRef || continue + haskey(ref_map, p) || continue + transcribed_to_master[transcribed_p] = ref_map[p] + end + # `_linearize_at(::GenericAffExpr, ...)` ignores its + # `linearization_point` arg; pass any empty dict. + empty_point = Dict{JuMP.VariableRef, Float64}() + for (F, S) in JuMP.list_of_constraint_types(model) + F === variable_type && continue + DP._is_linear_F(F) || continue + for cref in JuMP.all_constraints(model, F, S) + con = JuMP.constraint_object(cref) + _has_aggregate_ref(con.func) || continue + con isa JuMP.ScalarConstraint || continue + transcribed_func = InfiniteOpt.transformation_expression( + con.func) + if transcribed_func isa AbstractArray + for tf in vec(transcribed_func) + master_expr = DP._linearize_at( + tf, empty_point, transcribed_to_master) + JuMP.@constraint(master, master_expr in con.set) + end + else + master_expr = DP._linearize_at( + transcribed_func, empty_point, transcribed_to_master) + JuMP.@constraint(master, master_expr in con.set) + end + end + end + return end # Override the disjunct-cut loop for `InfiniteModel`. Same shape as @@ -1032,7 +1127,7 @@ function DP.add_oa_cuts( linearization = DP._linearize_at(master.original_objective, obj_point, master.objective_ref_map) DP._add_objective_cut( - Val(master.objective_sense), master, linearization) + Val(master.objective_sense), master, linearization, method) _add_global_oa_cuts_infinite(model, master, result, method) DP.add_disjunct_oa_cuts(model, master, result, method) return @@ -1154,7 +1249,7 @@ function DP.add_disjunct_oa_cuts( transcribed_constraint, master, binary_ref, transcribed_xk[], transcribed_to_master[], dual_value, method, sign_factor, - penalty_sign) + penalty_sign, result.feasible) end continue end @@ -1166,7 +1261,8 @@ function DP.add_disjunct_oa_cuts( DP._add_oa_cut_for_constraint( constraint, master, binary_ref, linearization_point, var_map, dual_value, - method, sign_factor, penalty_sign) + method, sign_factor, penalty_sign, + result.feasible) end end end diff --git a/src/loa.jl b/src/loa.jl index 45669253..b05712a3 100644 --- a/src/loa.jl +++ b/src/loa.jl @@ -102,6 +102,11 @@ mutable struct _LOAMaster{M <: JuMP.AbstractModel, OF, AO, BM, VM, RM} original_objective::OF alpha_oa::AO objective_ref_map::RM + # All penalized slacks ever appended to the master, in emission + # order — disjunct, global, and objective cuts. Iterated by + # `_check_slacks` after each master solve to diagnose whether + # `max_slack` is binding. + slacks::Vector{Any} end ################################################################################ @@ -157,16 +162,23 @@ function _reformulate_loa_single_level( # Initialization Procedure (Türkay & Grossmann 1996, sec. 2.2): # solve K set-covering NLPs with cycling indicator combinations to # seed the master with at least one OA cut per disjunct. + # `previous_result` carries the most recent FEASIBLE NLP primal + # forward as the warm start for the next NLP (T&G §2.3); NLPF + # solutions are skipped since their primal is slack-distorted. + previous_result = nothing for (i, combination) in enumerate(combinations) + _set_nlp_warm_start!(previous_result) t_seed = @elapsed result = _solve_nlp( model, combination, method, reformulation_map) avoid_combination(master.model, combination, master.binary_map) add_oa_cuts(model, master, result, method) + _check_penalty(result, method) if result.feasible && _is_better(sense_token, result.objective, best_objective) best_objective = result.objective best_result = result end + result.feasible && (previous_result = result) method.verbose && _log_seed(i, t_seed, result, best_objective) end @@ -175,21 +187,25 @@ function _reformulate_loa_single_level( feasible = JuMP.is_solved_and_feasible(master.model) method.verbose && _log_master(iter, master.model, t_master) feasible || break + _check_slacks(master, method) master_bound = JuMP.objective_value(master.model) method.verbose && _log_iter(iter, master_bound, best_objective, sense_token) _loa_converged(best_objective, master_bound, sense_token, method) && break combination = _extract_combination(model, master) + _set_nlp_warm_start!(previous_result) t_nlp = @elapsed result = _solve_nlp( model, combination, method, reformulation_map) avoid_combination(master.model, combination, master.binary_map) add_oa_cuts(model, master, result, method) + _check_penalty(result, method) if result.feasible && _is_better(sense_token, result.objective, best_objective) best_objective = result.objective best_result = result end + result.feasible && (previous_result = result) method.verbose && _log_nlp(iter, t_nlp, result, best_objective) end @@ -328,7 +344,7 @@ function build_loa_master(model::JuMP.AbstractModel, method::LOA) JuMP.@objective(master, objective_sense, alpha_oa) return _LOAMaster(master, binary_map, variable_map, objective_sense, - original_objective, alpha_oa, variable_map) + original_objective, alpha_oa, variable_map, Any[]) end ################################################################################ @@ -550,6 +566,22 @@ function with_fixed_combination( end end +# Iter-to-iter NLP warm start (T&G 1996 §2.3): seed the next primary +# NLP solve from the most recent FEASIBLE solution's primal point. +# No-op on the first seed iteration when `previous` is `nothing`, and +# no-op on iterations following an NLPF fall-through (caller does not +# update `previous_result` then), since NLPF's primal is slack- +# distorted and a worse start than the prior real NLP. Routes through +# the `set_warm_start!` dispatch so the InfiniteOpt extension's +# per-support broadcast handles `GeneralVariableRef` correctly. +function _set_nlp_warm_start!(previous) + previous === nothing && return + for (variable, values) in previous.linearization_point + set_warm_start!(variable, values) + end + return +end + # Finalize the model with the LOA-optimal combination: relax logical # binaries (stripping ZeroOne so a pure NLP solver can handle the # post-hook `JuMP.optimize!`), fix indicators at the committed values, @@ -672,6 +704,57 @@ function _collect_nlp_duals( return duals end +################################################################################ +# DIAGNOSTICS +################################################################################ +# V&G 1990 requires ρ > ‖λ*‖_∞ for the penalty term to dominate any +# admissible Lagrange multiplier — otherwise a slack can absorb a real +# violation and the OA cut becomes an invalid relaxation. Check after +# every feasible primary NLP and warn if the bound is at risk. NLPF +# returns sign-only duals (`±1`) so this is a no-op when `!feasible`. +function _check_penalty(result::NamedTuple, method::LOA) + result.feasible || return + isempty(result.duals) && return + max_dual = 0.0 + for (_, d) in result.duals + max_dual = max(max_dual, _max_abs_dual(d)) + end + if max_dual >= method.oa_penalty + @warn "LOA: oa_penalty = $(method.oa_penalty) ≤ max |dual| " * + "= $max_dual. V&G 1990 requires ρ > ‖λ‖_∞; slacks may " * + "absorb real OA-cut violations. Raise `oa_penalty`." + end + return +end +_max_abs_dual(d::Number) = abs(d) +_max_abs_dual(d::AbstractArray) = isempty(d) ? 0.0 : maximum(abs, d) +_max_abs_dual(::Nothing) = 0.0 + +# `max_slack` is a numerical guard (V&G 1990 leaves slacks unbounded; +# in practice an unbounded slack lets the master trivially feasible- +# itself out of the OA constraints). When a slack is at the cap, the +# master is effectively solving a stricter problem than V&G's master. +# Diagnose so the user can raise `max_slack` if the cap binds often. +function _check_slacks(master::_LOAMaster, method::LOA) + JuMP.has_values(master.model) || return + isempty(master.slacks) && return + cap = method.max_slack + threshold = 0.99 * cap + max_value = 0.0 + binding = 0 + for slack in master.slacks + val = JuMP.value(slack) + max_value = max(max_value, val) + val >= threshold && (binding += 1) + end + if binding > 0 + @warn "LOA: $binding slack(s) at max_slack = $cap (max " * + "value $max_value). Master is solving a stricter problem " * + "than V&G's; raise `max_slack` if this persists." + end + return +end + ################################################################################ # COMBO EXTRACTION (master → NLP) ################################################################################ @@ -707,17 +790,31 @@ function add_oa_cuts( isempty(result.linearization_point) && return linearization = _linearize_at(master.original_objective, result.linearization_point, master.objective_ref_map) - _add_objective_cut(Val(master.objective_sense), master, linearization) + _add_objective_cut( + Val(master.objective_sense), master, linearization, method) _add_global_oa_cuts(model, master, result, method) add_disjunct_oa_cuts(model, master, result, method) return end -# Bounding cut `lin ≤ α` (min) or `lin ≥ α` (max) on the master. -_add_objective_cut(::Val{_MOI.MIN_SENSE}, master, lin) = - JuMP.@constraint(master.model, lin <= master.alpha_oa) -_add_objective_cut(::Val{_MOI.MAX_SENSE}, master, lin) = - JuMP.@constraint(master.model, lin >= master.alpha_oa) +# Slacked objective cut (V&G 1990 eq. 6). For MIN, `lin ≤ α + σ` with +# `σ ≥ 0` penalized in the master objective so the slack drives to +# zero at convergence; the linearization is then `α ≥ lin` (standard +# OA bound). MAX is symmetric: `lin ≥ α − σ`. Without the slack, an +# accumulated bad linearization on a nonconvex objective can make the +# master infeasible — the disjunct and global cuts already carry +# slacks but the objective cut did not. +function _add_objective_cut( + sense_token::Val, master::_LOAMaster, lin, method::LOA + ) + _, penalty_sign = _disjunct_cut_coefficients(sense_token) + slack = _penalized_slack(master, method, penalty_sign) + _add_objective_cut_body(sense_token, master, lin, slack) +end +_add_objective_cut_body(::Val{_MOI.MIN_SENSE}, master, lin, slack) = + JuMP.@constraint(master.model, lin <= master.alpha_oa + slack) +_add_objective_cut_body(::Val{_MOI.MAX_SENSE}, master, lin, slack) = + JuMP.@constraint(master.model, lin >= master.alpha_oa - slack) # Add the OA cut `g(x^l) + ∇g(x^l)^T (x − x^l) in con.set` for every # nonlinear global constraint of `model` — the third cut class in @@ -786,31 +883,37 @@ function add_disjunct_oa_cuts( result.linearization_point, master.variable_map, dual) _add_oa_cut_for_constraint( constraint, master, binary_ref, lin_point, - var_map, dual_value, method, sign_factor, penalty_sign) + var_map, dual_value, method, sign_factor, + penalty_sign, result.feasible) end end end end # Fresh nonnegative slack added to the master objective with the V&G -# 1990 penalty. Shared by the disjunct and global OA cuts so both get -# the same augmented-penalty treatment: a nonconvex linearization can -# be an invalid relaxation, and the penalized slack keeps the master -# feasible instead of letting accumulated cuts make it infeasible. +# 1990 penalty. Shared by the disjunct, global, and objective OA cuts +# so all three carry the same augmented-penalty treatment: a nonconvex +# linearization can be an invalid relaxation, and the penalized slack +# keeps the master feasible instead of letting accumulated cuts make +# it infeasible. The slack is recorded on `master.slacks` so the +# `_check_slacks` diagnostic can see whether `max_slack` is binding. function _penalized_slack( master::_LOAMaster, method::LOA, penalty_sign::Int ) slack = JuMP.@variable(master.model, lower_bound = 0.0, upper_bound = method.max_slack) + push!(master.slacks, slack) JuMP.set_objective_function(master.model, JuMP.objective_function(master.model) + penalty_sign * method.oa_penalty * slack) return slack end -# Linearize constraint at `linearization_point`, append a fresh per-cut -# slack with V&G penalty, gate by `M(1 − binary)`. Linear constraints -# are exact via BigM and skipped. +# Linearize constraint at `linearization_point`, then dispatch on the +# constraint's set to emit slacked OA cut(s) gated by `M(1 − binary)`. +# Linear constraints are exact via BigM and skipped. `feasible` flags +# whether the linearization point came from a real NLP (true) or from +# NLPF (false); equality cuts behave differently in those two regimes. function _add_oa_cut_for_constraint( constraint::JuMP.AbstractConstraint, master::_LOAMaster, @@ -820,13 +923,94 @@ function _add_oa_cut_for_constraint( dual_value, method::LOA, sign_factor::Int, - penalty_sign::Int + penalty_sign::Int, + feasible::Bool ) _is_linear_F(typeof(constraint.func)) && return + linearization = _linearize_at( + constraint.func, linearization_point, var_map) + _emit_disjunct_oa_cut(constraint.set, master, binary_ref, + linearization, dual_value, method, sign_factor, + penalty_sign, feasible) + return +end + +# Inequality sets — one signed cut per Türkay-Grossmann 1996 / Duran- +# Grossmann 1986 OA convention. Primary NLP gives the real Lagrange +# multiplier (whose sign picks the active side); NLPF returns ±1 from +# `_nlpf_dual_sign(set)`. +function _emit_disjunct_oa_cut( + set::Union{_MOI.LessThan, _MOI.GreaterThan}, + master::_LOAMaster, binary_ref, linearization, dual_value, + method::LOA, sign_factor::Int, penalty_sign::Int, ::Bool + ) + sign_value = sign(sign_factor * _collapse_dual(dual_value)) + sign_value == 0 && return + rhs = _set_rhs(set) + slack = _penalized_slack(master, method, penalty_sign) + JuMP.@constraint(master.model, + sign_value * (linearization - rhs) - slack <= + method.M_value * (1 - binary_ref)) + return +end + +# Equality — feasible NLP: the summed BigM duals give the OA/ER +# multiplier sign (Duran-Grossmann 1986), so one signed cut suffices. +# Infeasible (NLPF): sign is uninformative, so emit both directions +# sharing one slack — mirrors the global-OA equality treatment in +# `_add_global_oa_row!(::EqualTo)`. +function _emit_disjunct_oa_cut( + set::_MOI.EqualTo, + master::_LOAMaster, binary_ref, linearization, dual_value, + method::LOA, sign_factor::Int, penalty_sign::Int, feasible::Bool + ) + c = _MOI.constant(set) + slack = _penalized_slack(master, method, penalty_sign) + if feasible + sign_value = sign(sign_factor * _collapse_dual(dual_value)) + sign_value == 0 && return + JuMP.@constraint(master.model, + sign_value * (linearization - c) - slack <= + method.M_value * (1 - binary_ref)) + return + end + JuMP.@constraint(master.model, + (linearization - c) - slack <= + method.M_value * (1 - binary_ref)) + JuMP.@constraint(master.model, + (c - linearization) - slack <= + method.M_value * (1 - binary_ref)) + return +end + +# Interval — always two-sided regardless of dual: there is no single +# rhs to sign against (`_set_rhs(::Interval) = 0` is wrong here), and +# any active boundary needs its linearization to gate the right side. +# Both rows share one slack so the V&G penalty is not double-counted. +function _emit_disjunct_oa_cut( + set::_MOI.Interval, + master::_LOAMaster, binary_ref, linearization, ::Any, + method::LOA, ::Int, penalty_sign::Int, ::Bool + ) + slack = _penalized_slack(master, method, penalty_sign) + JuMP.@constraint(master.model, + (linearization - set.upper) - slack <= + method.M_value * (1 - binary_ref)) + JuMP.@constraint(master.model, + (set.lower - linearization) - slack <= + method.M_value * (1 - binary_ref)) + return +end + +# Fallback for vector sets and any future set types — preserve the +# original single-signed behavior with `_collapse_dual` for the sign. +function _emit_disjunct_oa_cut( + set, master::_LOAMaster, binary_ref, linearization, dual_value, + method::LOA, sign_factor::Int, penalty_sign::Int, ::Bool + ) sign_value = sign(sign_factor * _collapse_dual(dual_value)) sign_value == 0 && return - rhs = _set_rhs(constraint.set) - linearization = _linearize_at(constraint.func, linearization_point, var_map) + rhs = _set_rhs(set) slack = _penalized_slack(master, method, penalty_sign) JuMP.@constraint(master.model, sign_value * (linearization - rhs) - slack <= From 3a8fa5b90717163beb9adbaa7a4b3f63c6f2ddee Mon Sep 17 00:00:00 2001 From: d227nguyen Date: Tue, 16 Jun 2026 09:25:27 -0400 Subject: [PATCH 52/59] Fix LOA BoundsError on equality cuts --- ext/InfiniteDisjunctiveProgramming.jl | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index d0de8f4c..7667c711 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -811,6 +811,7 @@ function _cut_sites( dual ) sites = Any[] + dual = _support_dual(dual, length(supports)) for (k, support) in enumerate(supports) actives[k] || continue point_var_map = Dict{ @@ -843,6 +844,15 @@ _at(values::AbstractArray, k::Integer) = length(values) == 1 ? values[1] : values[k] _at(scalar, ::Integer) = scalar +# A feasible NLP yields one dual per support (length == nsupp). A +# feasibility-restoration solve yields a set-shaped *sign* dual (e.g. +# EqualTo → [1, 1]) carrying only a direction, identical at every +# support. Collapse the latter to a scalar so per-support slicing +# broadcasts the sign instead of indexing past its length. +_support_dual(dual, nsupp::Integer) = + dual isa AbstractArray && length(dual) != nsupp && + length(dual) != 1 ? DP._collapse_dual(dual) : dual + # Point-evaluate an InfiniteOpt var at `support` if it's infinite; # return the var as-is if it's finite. `support` is one joint support # point — a scalar for 1-D parameters, a vector for a multi-D From c1f4bd3896b37f61bc28b799306e1d9b7793b9ef Mon Sep 17 00:00:00 2001 From: d227nguyen Date: Tue, 16 Jun 2026 11:23:04 -0400 Subject: [PATCH 53/59] LOA termination message for nonconvex --- src/loa.jl | 85 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 81 insertions(+), 4 deletions(-) diff --git a/src/loa.jl b/src/loa.jl index b05712a3..fc91d06d 100644 --- a/src/loa.jl +++ b/src/loa.jl @@ -182,17 +182,53 @@ function _reformulate_loa_single_level( method.verbose && _log_seed(i, t_seed, result, best_objective) end + # The master objective is a rigorous bound only for a convex (here, + # linear) inner problem. With nonlinear constraints the bound may be + # invalid, so terminate on V&G (1990) criterion 5 — stop once a + # feasible NLP no longer improves on the previous feasible NLP — + # rather than on the untrustworthy master gap. + rigorous = !_has_nonlinear_constraints(model) + warned = false + produced_bound = false + prev_nlp_objective = previous_result === nothing ? nothing : + previous_result.objective for iter in 1:method.max_iter t_master = @elapsed JuMP.optimize!(master.model) feasible = JuMP.is_solved_and_feasible(master.model) method.verbose && _log_master(iter, master.model, t_master) - feasible || break + if !feasible + # An infeasible/unbounded master before any bound is a + # degenerate exit, not convergence: LOA falls back to the + # best set-covering seed without ever iterating. Common + # when most NLP subproblems are infeasible, so the master + # lacks the OA cuts to bound `alpha_oa`. + produced_bound || @warn "LOA: master returned status " * + "$(JuMP.termination_status(master.model)) before any " * + "bound was produced; exiting with the best seed NLP " * + "incumbent ($best_objective). This is NOT a converged " * + "or certified optimum — often a sign the NLP " * + "subproblems are mostly infeasible." + break + end _check_slacks(master, method) master_bound = JuMP.objective_value(master.model) + produced_bound = true method.verbose && _log_iter(iter, master_bound, best_objective, sense_token) - _loa_converged(best_objective, master_bound, sense_token, method) && - break + if rigorous + _loa_converged( + best_objective, master_bound, sense_token, method) && + break + elseif !warned && _bound_crossed( + best_objective, master_bound, sense_token, method) + @warn "LOA: master bound ($master_bound) crossed the " * + "incumbent ($best_objective). The model appears " * + "nonconvex, so the OA cuts are not valid supports and " * + "the dual bound is NOT rigorous. Returning the best " * + "NLP incumbent as a heuristic (Viswanathan & " * + "Grossmann 1990)." + warned = true + end combination = _extract_combination(model, master) _set_nlp_warm_start!(previous_result) t_nlp = @elapsed result = _solve_nlp( @@ -205,8 +241,15 @@ function _reformulate_loa_single_level( best_objective = result.objective best_result = result end - result.feasible && (previous_result = result) method.verbose && _log_nlp(iter, t_nlp, result, best_objective) + # V&G (1990) criterion 5: without a rigorous bound, stop once a + # feasible NLP fails to improve on the previous feasible NLP. + if !rigorous && result.feasible + prev_nlp_objective !== nothing && !_is_better(sense_token, + result.objective, prev_nlp_objective) && break + prev_nlp_objective = result.objective + end + result.feasible && (previous_result = result) end if best_result !== nothing @@ -260,6 +303,24 @@ function _loa_converged( return false end +# A genuine bound crossing: the master "bound" is worse than the +# incumbent by more than the convergence tolerance (gap strongly +# negative). In valid convex OA, LB ≤ UB always, so this can only +# arise from invalid OA cuts on a nonconvex problem — V&G (1990) report +# the master objective exceeding the integer NLP optimum on every +# nonconvex test problem. The tolerance band separates a real crossing +# from floating-point / solver noise. +function _bound_crossed( + best_objective::Real, + master_bound::Real, + sense_token::Val, + method::LOA + ) + (isinf(best_objective) || isinf(master_bound)) && return false + gap = _gap(sense_token, best_objective, master_bound) + return gap < -(method.atol + method.rtol * abs(best_objective)) +end + # Error fallback for unsupported model types. function reformulate_model(::M, ::LOA) where {M} error("reformulate_model not implemented for model type `$(M)` with LOA.") @@ -307,6 +368,22 @@ _is_linear_F(::Type{<:AbstractVector{<:JuMP.AbstractVariableRef}}) = true _is_linear_F(::Type{<:AbstractVector{<:JuMP.GenericAffExpr}}) = true _is_linear_F(::Type) = false +# True if any constraint carries a non-affine function. LOA's master +# objective is a rigorous lower bound only when every constraint is +# linear: the inner problem is then a (linear, hence convex) GDP and +# the OA cuts are exact supporting hyperplanes. With a nonlinear +# constraint the problem may be nonconvex, the OA linearizations need +# not support the feasible set, and the master bound is not rigorous — +# so LOA terminates on V&G (1990) subproblem-improvement instead. +function _has_nonlinear_constraints(model::JuMP.AbstractModel) + variable_type = JuMP.variable_ref_type(typeof(model)) + for (F, _) in JuMP.list_of_constraint_types(model) + F === variable_type && continue + _is_linear_F(F) || return true + end + return false +end + # OVERRIDABLE. Build the LOA master MILP `M^b_{LA}` (Türkay & Grossmann # 1996, eq. 12): copy decision variables and only the linear # constraints, install `alpha_oa` as the objective auxiliary. Nonlinear From 128491f745809643848bfb1f7b3213b38fe77322 Mon Sep 17 00:00:00 2001 From: d227nguyen Date: Wed, 17 Jun 2026 13:48:58 -0400 Subject: [PATCH 54/59] Refactor LOA, drop multi-resolution, add iteration and total time limits --- ext/InfiniteDisjunctiveProgramming.jl | 194 +----------------------- src/loa.jl | 209 ++++++++++++++++---------- 2 files changed, 133 insertions(+), 270 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 7667c711..e96b04e5 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -390,190 +390,6 @@ end DP.any_active(actives::AbstractVector{Bool}) = any(actives) -# Override `reformulate_model` for `InfiniteModel` so the LOA -# `supports_schedule` activates the multi-resolution loop. With -# `supports_schedule = nothing` this is identical to the base path. With -# a schedule + `coarse_builder = N -> InfiniteModel`, the wrapper: -# -# 1. For each N in the schedule, build a FRESH InfiniteModel via -# `coarse_builder(N)`. Apply warm-starts captured from the previous -# warmup level (indexed by variable name + parameter name, which -# survive across model rebuilds). Run LOA. Capture trajectory. -# Discard the warmup model. -# 2. Apply the final captured trajectory to the user's `model`. -# 3. Run a single-level LOA on the user's model. -# -# This sidesteps every accumulated-state failure mode we hit when -# mutating one InfiniteModel across resolutions (orphan point vars, -# stale parameter supports, transcription cache poisoning, etc.). -function DP.reformulate_model( - model::InfiniteOpt.InfiniteModel, method::DP.LOA - ) - schedule = method.supports_schedule - if schedule === nothing - DP._reformulate_loa_single_level(model, method) - DP._set_solution_method(model, method) - DP._set_ready_to_optimize(model, true) - return - end - - isempty(schedule) && error( - "LOA `supports_schedule` must be `nothing` or non-empty.") - builder = method.coarse_builder - builder === nothing && error( - "LOA `supports_schedule` requires `coarse_builder`.") - - trajectory = nothing - for (level, N) in enumerate(schedule) - method.verbose && println( - "LOA_MULTIRES warmup level=", level, " N=", N, - trajectory === nothing ? " (cold start)" : - " (warm-start from level $(level - 1))") - warmup_model = builder(N) - if trajectory !== nothing - _apply_trajectory_by_name!(warmup_model, trajectory) - end - warmup_method = _strip_schedule(method) - warmup_result = DP._reformulate_loa_single_level( - warmup_model, warmup_method) - if warmup_result === nothing - method.verbose && println("LOA_MULTIRES warmup level=", - level, " no commit; trajectory unchanged") - continue - end - trajectory = _capture_trajectory_by_name( - warmup_model, warmup_result) - method.verbose && println("LOA_MULTIRES warmup level=", - level, " obj=", warmup_result.objective) - end - - if trajectory !== nothing - method.verbose && println( - "LOA_MULTIRES applying warmup trajectory to user model") - _apply_trajectory_by_name!(model, trajectory) - end - DP._reformulate_loa_single_level(model, method) - DP._set_solution_method(model, method) - DP._set_ready_to_optimize(model, true) - return -end - -# Build a copy of `method` with `supports_schedule = nothing` so the -# warmup LOA runs as single-level (not recursively triggering its own -# multi-res). The `coarse_builder` is irrelevant once the schedule is -# stripped; nothing reads it. -function _strip_schedule(method::DP.LOA) - return DP.LOA(method.nlp_optimizer; - mip_optimizer = method.mip_optimizer, - inner_method = method.inner_method, - max_iter = method.max_iter, - atol = method.atol, rtol = method.rtol, - M_value = method.M_value, - max_slack = method.max_slack, - oa_penalty = method.oa_penalty, - verbose = method.verbose) -end - -# Capture the converged trajectory as a name-keyed dict so we can apply -# it to a freshly-built model whose `GeneralVariableRef`s are different -# objects. Also records each parameter's current supports (per name) so -# the apply step knows the source grid for interpolation. -function _capture_trajectory_by_name( - model::InfiniteOpt.InfiniteModel, result::NamedTuple - ) - name_to_values = Dict{String, Vector{Float64}}() - for (variable, values) in result.linearization_point - nm = JuMP.name(variable) - isempty(nm) && continue - name_to_values[nm] = collect(values) - end - name_to_supports = Dict{String, Vector{Float64}}() - for p in InfiniteOpt.all_parameters(model) - nm = JuMP.name(p) - isempty(nm) && continue - sup = InfiniteOpt.supports(p) - ndims(sup) == 1 || continue - name_to_supports[nm] = collect(sup) - end - return (values = name_to_values, supports = name_to_supports) -end - -# Apply a name-keyed trajectory to a fresh model: for each variable -# whose name appears in `trajectory.values`, interpolate its captured -# per-support values from the source parameter grid to this model's -# grid and write as `set_start_value` on the transcribed JuMP refs. -# Indicator binaries are skipped — they're not part of the primal -# warm-start. -function _apply_trajectory_by_name!( - target::InfiniteOpt.InfiniteModel, trajectory::NamedTuple - ) - indicator_binaries = Set{InfiniteOpt.GeneralVariableRef}() - for (_, bref) in DP._indicator_to_binary(target) - push!(indicator_binaries, bref isa JuMP.GenericAffExpr ? - only(keys(bref.terms)) : bref) - end - InfiniteOpt.build_transformation_backend!(target) - for v in JuMP.all_variables(target) - v in indicator_binaries && continue - nm = JuMP.name(v) - haskey(trajectory.values, nm) || continue - values = trajectory.values[nm] - _warm_start_by_name(target, v, values, trajectory.supports) - end - return -end - -# Variable-side warm-start: finite vars get the scalar value directly; -# infinite (single-parameter) vars get linearly interpolated from the -# captured source grid to this model's transcribed supports. -function _warm_start_by_name( - ::InfiniteOpt.InfiniteModel, - variable::InfiniteOpt.GeneralVariableRef, - values::AbstractVector, - captured_supports::AbstractDict - ) - prefs = InfiniteOpt.parameter_refs(variable) - if isempty(prefs) - length(values) == 1 || return - transcribed = InfiniteOpt.transformation_variable(variable) - transcribed isa JuMP.AbstractVariableRef || return - JuMP.set_start_value(transcribed, only(values)) - return - end - length(prefs) == 1 || return - pname = JuMP.name(first(prefs)) - haskey(captured_supports, pname) || return - old = captured_supports[pname] - length(values) == length(old) || return - new = InfiniteOpt.supports(first(prefs)) - ndims(new) == 1 || return - transcribed = InfiniteOpt.transformation_variable(variable) - transcribed isa AbstractArray || return - refs = vec(transcribed) - length(refs) == length(new) || return - for (k, s) in enumerate(new) - JuMP.set_start_value(refs[k], _linear_interp(old, values, s)) - end - return -end - -# Piecewise-linear interpolation of `ys` defined on the increasing -# nodes `xs`, evaluated at `q`. Clamps at the endpoints. Cheap and -# stable enough for warm-starts; only neighborhood-correctness matters. -function _linear_interp( - xs::AbstractVector{<:Real}, ys::AbstractVector{<:Real}, q::Real - ) - n = length(xs) - q <= xs[1] && return ys[1] - q >= xs[n] && return ys[n] - i = searchsortedlast(xs, q) - i == n && return ys[n] - x0, x1 = xs[i], xs[i + 1] - x1 == x0 && return ys[i] - t = (q - x0) / (x1 - x0) - return (1 - t) * ys[i] + t * ys[i + 1] -end - # `JuMP.value` returns a per-support `Array` for infinite vars and a # scalar for finite vars. The `> 0.5` cutoff handles solver-side # integer-feasibility slack (e.g. HiGHS can return 2.75e-40 for a @@ -1022,7 +838,7 @@ function DP.build_loa_master( # master will pick combinations that violate it. Transcribe # each such constraint once and add the flat scalar form to # the master directly. - _add_aggregate_linear_constraints!( + _add_aggregate_linear_constraints( master, model, ref_map, variable_type) return DP._LOAMaster(master, binary_map, variable_map, @@ -1038,7 +854,7 @@ end # `transcribed_to_master`. Reuses the transformation backend that # `_add_global_oa_cuts_infinite` will rebuild later; building it # now is cheap and idempotent. -function _add_aggregate_linear_constraints!( +function _add_aggregate_linear_constraints( master::InfiniteOpt.InfiniteModel, model::InfiniteOpt.InfiniteModel, ref_map::AbstractDict, @@ -1186,13 +1002,13 @@ function _add_global_oa_cuts_infinite( for tf in vec(transcribed_func) lin = DP._linearize_at(tf, transcribed_xk[], transcribed_to_master[]) - DP._add_global_oa_row!(master, lin, con.set, + DP._add_global_oa_row(master, lin, con.set, method, penalty_sign) end else lin = DP._linearize_at(transcribed_func, transcribed_xk[], transcribed_to_master[]) - DP._add_global_oa_row!(master, lin, con.set, + DP._add_global_oa_row(master, lin, con.set, method, penalty_sign) end end @@ -1343,7 +1159,7 @@ end # transcribed instances; for a finite var on an InfiniteModel, # `transcription_variable` returns a single ref and `values` is # length-1. -function DP.set_warm_start!( +function DP.set_linearization_start( variable::InfiniteOpt.GeneralVariableRef, values::AbstractVector ) diff --git a/src/loa.jl b/src/loa.jl index fc91d06d..e1ce3c96 100644 --- a/src/loa.jl +++ b/src/loa.jl @@ -9,8 +9,8 @@ # slacked feasibility version of the same problem whose primal becomes # the linearization site for OA cuts. A no-good cut still forbids that # combination on the master. NLPF is bypassed for per-support -# Vector{Bool} combinations (InfiniteOpt multi-resolution master) — -# those fall back to no-good-only. +# Vector{Bool} combinations (InfiniteOpt per-support master) — those +# fall back to no-good-only. ################################################################################ ################################################################################ @@ -40,6 +40,13 @@ supported. Other reformulations (`Hull`, `PSplit`) are not yet supported. - `max_slack::T`: Upper bound for each per-cut slack variable. - `oa_penalty::T`: V&G 1990 penalty coefficient applied to slacks in the master objective. +- `iteration_time_limit::Float64`: Wall-clock budget (seconds) for the + LOA iteration loop (seeds + main loop). `Inf` (default) is no limit; + each subproblem solve is capped to the budget left so one solve can't + overrun it. +- `time_limit::Float64`: Overall wall-clock cap (seconds) covering the + iteration loop AND the final committed solve. `Inf` (default) is no + cap; when set, the final solve gets only the budget left under it. """ struct LOA{O, P, R, T} <: AbstractReformulationMethod nlp_optimizer::O @@ -52,8 +59,8 @@ struct LOA{O, P, R, T} <: AbstractReformulationMethod max_slack::T oa_penalty::T verbose::Bool - supports_schedule::Union{Nothing, Vector{Int}} - coarse_builder::Union{Nothing, Function} + iteration_time_limit::Float64 + time_limit::Float64 function LOA( nlp_optimizer::O; mip_optimizer::P = nlp_optimizer, @@ -65,19 +72,15 @@ struct LOA{O, P, R, T} <: AbstractReformulationMethod oa_penalty::T = 1e3, inner_method::R = BigM(M_value), verbose::Bool = false, - supports_schedule::Union{Nothing, Vector{Int}} = nothing, - coarse_builder::Union{Nothing, Function} = nothing + iteration_time_limit::Float64 = Inf, + time_limit::Float64 = Inf ) where {O, P, R <: AbstractReformulationMethod, T} R <: Union{BigM, MBM} || error( "LOA inner_method must be BigM or MBM (got $R). " * "Hull and PSplit are not yet supported.") - supports_schedule === nothing || coarse_builder !== nothing || error( - "LOA `supports_schedule` requires a `coarse_builder = N -> " * - "InfiniteModel` that constructs a fresh model at each " * - "warmup resolution.") new{O, P, R, T}(nlp_optimizer, mip_optimizer, inner_method, max_iter, atol, rtol, M_value, max_slack, oa_penalty, verbose, - supports_schedule, coarse_builder) + iteration_time_limit, time_limit) end end @@ -127,26 +130,12 @@ _flip_sense(::Val{_MOI.MAX_SENSE}) = Val(_MOI.MIN_SENSE) ################################################################################ # MAIN ALGORITHM ################################################################################ +# LOA reformulation entry point: set-covering seeds, the master/NLP +# iteration loop, commit the best combination, and mark the model ready. +# Plain `LOA` on an `InfiniteModel` dispatches here too — the InfiniteOpt +# overrides act on the inner solve steps (master build, NLP, cuts), not +# on this driver. function reformulate_model(model::JuMP.AbstractModel, method::LOA) - method.supports_schedule === nothing || - error("`supports_schedule` is only meaningful for InfiniteOpt " * - "models; load InfiniteOpt to enable the multi-resolution path.") - _reformulate_loa_single_level(model, method) - _set_solution_method(model, method) - _set_ready_to_optimize(model, true) - return -end - -# Single-resolution LOA. Always uses set-covering for the -# initialization seeds; the InfiniteOpt multi-resolution wrapper -# carries trajectory information across levels via primal warm starts -# on transcribed JuMP variables (set before this is called), not by -# overriding the seed-combination loop. Returns `best_result` -# (NamedTuple with `combination` / `linearization_point` / etc.) or -# `nothing` if every NLP was infeasible. -function _reformulate_loa_single_level( - model::JuMP.AbstractModel, method::LOA - ) _clear_reformulations(model) combinations = _set_covering_combinations(model) reformulate_model(model, method.inner_method) @@ -155,6 +144,11 @@ function _reformulate_loa_single_level( reformulation_map = _build_reformulation_map(model) JuMP.set_optimizer(model, method.nlp_optimizer) JuMP.set_silent(model) + t_start = time() + overall_deadline = t_start + method.time_limit + loop_deadline = + min(t_start + method.iteration_time_limit, overall_deadline) + original_time_limit = JuMP.time_limit_sec(model) sense_token = Val(JuMP.objective_sense(model)) best_objective = _worst_objective(sense_token) best_result = nothing @@ -167,9 +161,11 @@ function _reformulate_loa_single_level( # solutions are skipped since their primal is slack-distorted. previous_result = nothing for (i, combination) in enumerate(combinations) - _set_nlp_warm_start!(previous_result) + time() < loop_deadline || break + _set_nlp_warm_start(previous_result) t_seed = @elapsed result = _solve_nlp( - model, combination, method, reformulation_map) + model, combination, method, reformulation_map; + deadline = loop_deadline) avoid_combination(master.model, combination, master.binary_map) add_oa_cuts(model, master, result, method) _check_penalty(result, method) @@ -182,32 +178,22 @@ function _reformulate_loa_single_level( method.verbose && _log_seed(i, t_seed, result, best_objective) end - # The master objective is a rigorous bound only for a convex (here, - # linear) inner problem. With nonlinear constraints the bound may be - # invalid, so terminate on V&G (1990) criterion 5 — stop once a - # feasible NLP no longer improves on the previous feasible NLP — - # rather than on the untrustworthy master gap. + # Master bound is a rigorous dual bound only for a convex (linear) + # inner problem; otherwise stop on V&G (1990) criterion 5 + # (`_nlp_stalled`), not the untrustworthy gap. rigorous = !_has_nonlinear_constraints(model) - warned = false produced_bound = false prev_nlp_objective = previous_result === nothing ? nothing : previous_result.objective for iter in 1:method.max_iter + time() < loop_deadline || break + _cap_remaining_time(master.model, loop_deadline) t_master = @elapsed JuMP.optimize!(master.model) feasible = JuMP.is_solved_and_feasible(master.model) method.verbose && _log_master(iter, master.model, t_master) if !feasible - # An infeasible/unbounded master before any bound is a - # degenerate exit, not convergence: LOA falls back to the - # best set-covering seed without ever iterating. Common - # when most NLP subproblems are infeasible, so the master - # lacks the OA cuts to bound `alpha_oa`. - produced_bound || @warn "LOA: master returned status " * - "$(JuMP.termination_status(master.model)) before any " * - "bound was produced; exiting with the best seed NLP " * - "incumbent ($best_objective). This is NOT a converged " * - "or certified optimum — often a sign the NLP " * - "subproblems are mostly infeasible." + _check_degenerate_master(master, produced_bound, + best_objective) break end _check_slacks(master, method) @@ -217,22 +203,16 @@ function _reformulate_loa_single_level( _log_iter(iter, master_bound, best_objective, sense_token) if rigorous _loa_converged( - best_objective, master_bound, sense_token, method) && - break - elseif !warned && _bound_crossed( + best_objective, master_bound, sense_token, method) && break + else + _check_nonrigorous_bound( best_objective, master_bound, sense_token, method) - @warn "LOA: master bound ($master_bound) crossed the " * - "incumbent ($best_objective). The model appears " * - "nonconvex, so the OA cuts are not valid supports and " * - "the dual bound is NOT rigorous. Returning the best " * - "NLP incumbent as a heuristic (Viswanathan & " * - "Grossmann 1990)." - warned = true end combination = _extract_combination(model, master) - _set_nlp_warm_start!(previous_result) + _set_nlp_warm_start(previous_result) t_nlp = @elapsed result = _solve_nlp( - model, combination, method, reformulation_map) + model, combination, method, reformulation_map; + deadline = loop_deadline) avoid_combination(master.model, combination, master.binary_map) add_oa_cuts(model, master, result, method) _check_penalty(result, method) @@ -242,11 +222,11 @@ function _reformulate_loa_single_level( best_result = result end method.verbose && _log_nlp(iter, t_nlp, result, best_objective) - # V&G (1990) criterion 5: without a rigorous bound, stop once a - # feasible NLP fails to improve on the previous feasible NLP. + # V&G (1990) criterion 5: with no rigorous bound, stop once a + # feasible NLP stops improving on the previous feasible NLP. if !rigorous && result.feasible - prev_nlp_objective !== nothing && !_is_better(sense_token, - result.objective, prev_nlp_objective) && break + _nlp_stalled(sense_token, result.objective, + prev_nlp_objective) && break prev_nlp_objective = result.objective end result.feasible && (previous_result = result) @@ -256,7 +236,16 @@ function _reformulate_loa_single_level( commit_combination(model, best_result.combination, best_result.linearization_point) end - return best_result + if isfinite(method.time_limit) + # Overall cap: the final committed solve gets the budget left. + JuMP.set_time_limit_sec(model, max(0.0, overall_deadline - time())) + elseif isfinite(method.iteration_time_limit) + # Loop-only budget: restore so the final solve isn't crippled. + _restore_time_limit(model, original_time_limit) + end + _set_solution_method(model, method) + _set_ready_to_optimize(model, true) + return end # `verbose = true` trace helpers. Print to stdout so a `tee`-d log @@ -321,6 +310,13 @@ function _bound_crossed( return gap < -(method.atol + method.rtol * abs(best_objective)) end +# V&G (1990) criterion 5: a feasible NLP that does not improve on the +# previous feasible NLP signals the heuristic has converged. The first +# feasible NLP (`nothing` previous) is never stalled. +_nlp_stalled(sense_token::Val, objective::Real, previous::Real) = + !_is_better(sense_token, objective, previous) +_nlp_stalled(::Val, ::Real, ::Nothing) = false + # Error fallback for unsupported model types. function reformulate_model(::M, ::LOA) where {M} error("reformulate_model not implemented for model type `$(M)` with LOA.") @@ -427,6 +423,23 @@ end ################################################################################ # NLP SUBPROBLEM ################################################################################ +# Cap `target`'s solver to the wall-clock budget left before `deadline` +# (seconds, `time()` clock). No-op when `deadline` is `Inf` (no LOA time +# limit). Keeps one in-flight solve from overrunning the whole-loop +# budget; the loop also breaks once `deadline` passes. +function _cap_remaining_time(target::JuMP.AbstractModel, deadline::Float64) + isfinite(deadline) || return + JuMP.set_time_limit_sec(target, max(0.0, deadline - time())) + return +end + +# Restore the model's solver time limit after the loop — the factory +# value captured before capping, or unset if the factory set none. +_restore_time_limit(model::JuMP.AbstractModel, ::Nothing) = + JuMP.unset_time_limit_sec(model) +_restore_time_limit(model::JuMP.AbstractModel, seconds::Real) = + JuMP.set_time_limit_sec(model, seconds) + # Solve the primary NLP for a fixed combination. If feasible, read the # primal point, duals, and objective. If infeasible, fall through to # the NLPF (V&G 1990 eq. 8) approximation: a slacked version of the @@ -435,8 +448,10 @@ end # information from the failed combination instead of only adding a # no-good cut. function _solve_nlp( - model::M, combination, method::LOA, reformulation_map + model::M, combination, method::LOA, reformulation_map; + deadline::Float64 = Inf ) where {M <: JuMP.AbstractModel} + _cap_remaining_time(model, deadline) primary = with_fixed_combination(model, combination) do JuMP.optimize!(model, ignore_optimize_hook = true) if JuMP.is_solved_and_feasible(model) @@ -453,7 +468,7 @@ function _solve_nlp( primary === nothing || return primary # Primary NLP infeasible — try NLPF. - nlpf = _solve_nlpf(model, combination, method) + nlpf = _solve_nlpf(model, combination, method; deadline = deadline) nlpf === nothing || return nlpf return (combination = combination, @@ -475,11 +490,12 @@ end # slacked feasibility solve we know each active constraint's # linearization is informative in the standard direction. function _solve_nlpf( - model::M, combination, method::LOA + model::M, combination, method::LOA; deadline::Float64 = Inf ) where {M <: JuMP.AbstractModel} copy, ref_map = JuMP.copy_model(model) JuMP.set_optimizer(copy, method.nlp_optimizer) JuMP.set_silent(copy) + _cap_remaining_time(copy, deadline) var_type = JuMP.variable_ref_type(typeof(copy)) u = JuMP.@variable(copy, lower_bound = 0.0, base_name = "_nlpf_u") @@ -649,12 +665,12 @@ end # no-op on iterations following an NLPF fall-through (caller does not # update `previous_result` then), since NLPF's primal is slack- # distorted and a worse start than the prior real NLP. Routes through -# the `set_warm_start!` dispatch so the InfiniteOpt extension's +# the `set_linearization_start` dispatch so the InfiniteOpt extension's # per-support broadcast handles `GeneralVariableRef` correctly. -function _set_nlp_warm_start!(previous) +function _set_nlp_warm_start(previous) previous === nothing && return for (variable, values) in previous.linearization_point - set_warm_start!(variable, values) + set_linearization_start(variable, values) end return end @@ -673,7 +689,7 @@ function commit_combination( relax_logical_vars(model) fix_combination(model, combination) for (variable, values) in linearization_point - set_warm_start!(variable, values) + set_linearization_start(variable, values) end return end @@ -700,7 +716,7 @@ end # warm start. `values` is always per-support shape (length-1 vector # for finite vars); base unwraps. The InfiniteOpt extension overrides # for `GeneralVariableRef` to broadcast across transcribed supports. -set_warm_start!(variable, values::AbstractVector) = +set_linearization_start(variable, values::AbstractVector) = JuMP.set_start_value(variable, only(values)) ################################################################################ @@ -832,6 +848,37 @@ function _check_slacks(master::_LOAMaster, method::LOA) return end +# A master that is infeasible/unbounded before producing any bound is a +# degenerate exit, not convergence: LOA falls back to the best +# set-covering seed without iterating, usually because most NLP +# subproblems are infeasible and the master lacks the OA cuts to bound +# `alpha_oa`. No-op once a bound has been produced. +function _check_degenerate_master( + master::_LOAMaster, produced_bound::Bool, best_objective::Real + ) + produced_bound && return + @warn "LOA: master returned " * + "$(JuMP.termination_status(master.model)) before any bound; " * + "returning the best seed incumbent ($best_objective). Not a " * + "certified optimum — usually most NLP subproblems are infeasible." + return +end + +# A master bound that crosses the incumbent (`_bound_crossed`) proves the +# OA cuts are not valid supports: the model is nonconvex and the master +# gives no rigorous dual bound. LOA continues as a V&G (1990) heuristic +# and returns the best NLP incumbent. +function _check_nonrigorous_bound( + best_objective::Real, master_bound::Real, sense_token::Val, method::LOA + ) + _bound_crossed(best_objective, master_bound, sense_token, method) || + return + @warn "LOA: master bound ($master_bound) crossed the incumbent " * + "($best_objective); model is nonconvex, dual bound is not " * + "rigorous. Returning the best NLP incumbent (heuristic, V&G 1990)." + return +end + ################################################################################ # COMBO EXTRACTION (master → NLP) ################################################################################ @@ -926,7 +973,7 @@ function _add_global_oa_cuts( con isa JuMP.ScalarConstraint || continue linearization = _linearize_at(con.func, result.linearization_point, master.variable_map) - _add_global_oa_row!(master, linearization, con.set, + _add_global_oa_row(master, linearization, con.set, method, penalty_sign) end end @@ -1035,7 +1082,7 @@ end # multiplier sign (Duran-Grossmann 1986), so one signed cut suffices. # Infeasible (NLPF): sign is uninformative, so emit both directions # sharing one slack — mirrors the global-OA equality treatment in -# `_add_global_oa_row!(::EqualTo)`. +# `_add_global_oa_row(::EqualTo)`. function _emit_disjunct_oa_cut( set::_MOI.EqualTo, master::_LOAMaster, binary_ref, linearization, dual_value, @@ -1101,7 +1148,7 @@ end # global cuts make the master infeasible on nonconvex models. # `EqualTo` / `Interval` get a two-sided pair sharing one slack. # Unknown set types fall back to the prior hard cut. -function _add_global_oa_row!( +function _add_global_oa_row( master::_LOAMaster, lin, set::_MOI.LessThan, method::LOA, penalty_sign::Int ) @@ -1109,7 +1156,7 @@ function _add_global_oa_row!( JuMP.@constraint(master.model, lin - _MOI.constant(set) <= slack) return end -function _add_global_oa_row!( +function _add_global_oa_row( master::_LOAMaster, lin, set::_MOI.GreaterThan, method::LOA, penalty_sign::Int ) @@ -1117,7 +1164,7 @@ function _add_global_oa_row!( JuMP.@constraint(master.model, _MOI.constant(set) - lin <= slack) return end -function _add_global_oa_row!( +function _add_global_oa_row( master::_LOAMaster, lin, set::_MOI.EqualTo, method::LOA, penalty_sign::Int ) @@ -1127,7 +1174,7 @@ function _add_global_oa_row!( JuMP.@constraint(master.model, c - lin <= slack) return end -function _add_global_oa_row!( +function _add_global_oa_row( master::_LOAMaster, lin, set::_MOI.Interval, method::LOA, penalty_sign::Int ) @@ -1136,7 +1183,7 @@ function _add_global_oa_row!( JuMP.@constraint(master.model, set.lower - lin <= slack) return end -function _add_global_oa_row!( +function _add_global_oa_row( master::_LOAMaster, lin, set, ::LOA, ::Int ) JuMP.@constraint(master.model, lin in set) From 396b35c3585de90db0780eda0fa72c1b514fe9c6 Mon Sep 17 00:00:00 2001 From: d227nguyen Date: Sun, 21 Jun 2026 18:19:54 -0400 Subject: [PATCH 55/59] Removing leftover checks --- ext/InfiniteDisjunctiveProgramming.jl | 224 ++--- src/loa.jl | 882 ++++++------------ test/constraints/loa.jl | 143 ++- .../InfiniteDisjunctiveProgramming.jl | 61 +- 4 files changed, 534 insertions(+), 776 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index e96b04e5..260cbf3e 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -388,31 +388,6 @@ end # submodel are themselves InfiniteModels, with per-support handling # via point evaluation on infinite `GeneralVariableRef`s. -DP.any_active(actives::AbstractVector{Bool}) = any(actives) - -# `JuMP.value` returns a per-support `Array` for infinite vars and a -# scalar for finite vars. The `> 0.5` cutoff handles solver-side -# integer-feasibility slack (e.g. HiGHS can return 2.75e-40 for a -# "0" binary), where direct `Bool(val)` would `InexactError`. -# `JuMP.value` returns a per-support `Array` for infinite vars and a -# scalar for finite vars; `round(Bool, ·)` handles both via broadcast -# and absorbs solver integer-feasibility slack. -function DP.combination_val(v::InfiniteOpt.GeneralVariableRef) - val = JuMP.value(v) - return val isa AbstractArray ? vec(round.(Bool, val)) : round(Bool, val) -end - -# Complement-form binary (`1 - y_underlying`) stored in `binary_map` -# for indicators declared `logical_complement`. `JuMP.value` on the -# AffExpr returns a per-support `Vector{Float64}` when the underlying -# is infinite, or a scalar when it's finite. -function DP.combination_val( - v::JuMP.GenericAffExpr{C, <:InfiniteOpt.GeneralVariableRef} - ) where {C} - val = JuMP.value(v) - return val isa AbstractArray ? vec(round.(Bool, val)) : round(Bool, val) -end - # Supports of the infinite parameter group `v` depends on. For a 1-D # parameter, `supports(p)` is a `Vector{Float64}`; for a dependent # group of dimension k, it is a k × N_supports `Matrix`. Returns each @@ -515,71 +490,24 @@ function DP.add_no_good_terms( return end -function DP.cut_info( - binary_ref::InfiniteOpt.GeneralVariableRef, - active::Bool, - constraint::JuMP.AbstractConstraint, - linearization_point::AbstractDict, - variable_map::AbstractDict, dual - ) - return _infinite_cut_info(binary_ref, active, constraint.func, - linearization_point, variable_map, dual) -end - -function DP.cut_info( - binary_ref::InfiniteOpt.GeneralVariableRef, - actives::AbstractVector, - constraint::JuMP.AbstractConstraint, - linearization_point::AbstractDict, - variable_map::AbstractDict, dual - ) - return _infinite_cut_info(binary_ref, actives, constraint.func, - linearization_point, variable_map, dual) -end - -# Complement-form binary (`1 - y_underlying`). Same fan-out logic as -# the variable-ref form; the per-support `_at_support` rebuilds the -# AffExpr with its variable point-evaluated. -function DP.cut_info( - binary_ref::JuMP.GenericAffExpr, - active::Bool, - constraint::JuMP.AbstractConstraint, - linearization_point::AbstractDict, - variable_map::AbstractDict, dual - ) - return _infinite_cut_info(binary_ref, active, constraint.func, - linearization_point, variable_map, dual) -end - -# Complement-form binary with per-support active descriptor (e.g., -# `BitVector` from `_extract_combination` on an InfiniteOpt master -# where the indicator was declared `logical_complement`). -function DP.cut_info( - binary_ref::JuMP.GenericAffExpr, - actives::AbstractVector, - constraint::JuMP.AbstractConstraint, - linearization_point::AbstractDict, - variable_map::AbstractDict, dual - ) - return _infinite_cut_info(binary_ref, actives, constraint.func, - linearization_point, variable_map, dual) -end - # Fan out across supports if either the binary or the constraint # expression involves an infinite variable; otherwise emit one # un-sliced site. The chosen supports come from the first infinite # variable found in either expression. function _infinite_cut_info( - binary_ref, active, constraint_func, - linearization_point, variable_map, dual + binary_ref, + active, + constraint_func, + linearization_point, + variable_map ) supports = _relevant_supports(binary_ref, constraint_func) supports === nothing && - return ((binary_ref, linearization_point, variable_map, dual),) + return ((binary_ref, linearization_point, variable_map),) actives = active isa AbstractVector ? active : fill(active, length(supports)) return _cut_sites(binary_ref, supports, actives, - linearization_point, variable_map, dual) + linearization_point, variable_map) end # Supports governing per-support fan-out — pick the first infinite @@ -616,18 +544,14 @@ function _find_infinite_var(expr) return found[] end -_find_infinite_var(::Any) = nothing - function _cut_sites( binary_ref, supports::AbstractVector, actives::AbstractVector, linearization_point::AbstractDict, - variable_map::AbstractDict, - dual + variable_map::AbstractDict ) sites = Any[] - dual = _support_dual(dual, length(supports)) for (k, support) in enumerate(supports) actives[k] || continue point_var_map = Dict{ @@ -644,8 +568,7 @@ function _cut_sites( push!(sites, ( _at_support(binary_ref, support), point, - point_var_map, - _at(dual, k) + point_var_map )) end return sites @@ -660,15 +583,6 @@ _at(values::AbstractArray, k::Integer) = length(values) == 1 ? values[1] : values[k] _at(scalar, ::Integer) = scalar -# A feasible NLP yields one dual per support (length == nsupp). A -# feasibility-restoration solve yields a set-shaped *sign* dual (e.g. -# EqualTo → [1, 1]) carrying only a direction, identical at every -# support. Collapse the latter to a scalar so per-support slicing -# broadcasts the sign instead of indexing past its length. -_support_dual(dual, nsupp::Integer) = - dual isa AbstractArray && length(dual) != nsupp && - length(dual) != 1 ? DP._collapse_dual(dual) : dual - # Point-evaluate an InfiniteOpt var at `support` if it's infinite; # return the var as-is if it's finite. `support` is one joint support # point — a scalar for 1-D parameters, a vector for a multi-D @@ -703,7 +617,7 @@ end # resulting flat scalar objective, and uses this map to translate # the gradient back into master point variables. Per-support # DISJUNCT cuts do NOT need this — they linearize natively in -# InfiniteModel space via `cut_info`. If the objective contains no +# InfiniteModel space via `_infinite_cut_info`. If the objective has no # measures, this whole layer is dead weight; killing it would # require either banning measure objectives or hand-writing a # `_linearize_at` that walks `MeasureRef` symbolically. @@ -843,7 +757,7 @@ function DP.build_loa_master( return DP._LOAMaster(master, binary_map, variable_map, objective_sense, original_objective, alpha_oa, - objective_ref_map, Any[]) + objective_ref_map) end # Walk the original model's linear-`F` constraints, transcribe those @@ -885,11 +799,9 @@ function _add_aggregate_linear_constraints( # parameter-relative (the master then honors `set_value(α, ...)` # without rebuild). for p in InfiniteOpt.all_parameters(model) - transcribed_p = try - InfiniteOpt.transformation_variable(p) - catch - continue - end + InfiniteOpt.dispatch_variable_ref(p) isa + InfiniteOpt.FiniteParameterRef || continue + transcribed_p = InfiniteOpt.transformation_variable(p) transcribed_p isa JuMP.VariableRef || continue haskey(ref_map, p) || continue transcribed_to_master[transcribed_p] = ref_map[p] @@ -922,10 +834,9 @@ function _add_aggregate_linear_constraints( return end -# Override the disjunct-cut loop for `InfiniteModel`. Same shape as -# Override `add_oa_cuts` for `InfiniteModel` to translate the +# Override `add_oa_cuts` for `InfiniteModel`: translate the # linearization point into the form the master's `original_objective` -# expects, and to route global OA cuts through transcription so they +# expects, and route global OA cuts through transcription so they # work over infinite vars and aggregate refs. The base # `result.linearization_point` has per-support `Vector` values keyed # on InfiniteOpt vars; the master's objective is either the raw @@ -972,8 +883,7 @@ function _add_global_oa_cuts_infinite( result::NamedTuple, method::DP.LOA ) - _, penalty_sign = DP._disjunct_cut_coefficients( - Val(master.objective_sense)) + penalty_sign = DP._penalty_sign(Val(master.objective_sense)) variable_type = InfiniteOpt.GeneralVariableRef reform_set = DP.is_gdp_model(model) ? Set(DP._reformulation_constraints(model)) : Set() @@ -1033,8 +943,7 @@ function DP.add_disjunct_oa_cuts( result::NamedTuple, method::DP.LOA ) - sign_factor, penalty_sign = DP._disjunct_cut_coefficients( - Val(master.objective_sense)) + penalty_sign = DP._penalty_sign(Val(master.objective_sense)) transcribed_to_master = Ref{Any}(nothing) transcribed_xk = Ref{Any}(nothing) ensure_transcribed = function () @@ -1047,8 +956,7 @@ function DP.add_disjunct_oa_cuts( return end for (indicator, active) in result.combination - DP.any_active(active) || continue - haskey(master.binary_map, indicator) || continue + DP.is_active(active) || continue haskey(DP._indicator_to_constraints(model), indicator) || continue for orig_constraint_ref in @@ -1057,8 +965,6 @@ function DP.add_disjunct_oa_cuts( continue constraint = DP._disjunct_constraints(model)[ JuMP.index(orig_constraint_ref)].constraint - dual = get(result.duals, orig_constraint_ref, nothing) - dual === nothing && continue if _has_aggregate_ref(constraint.func) ensure_transcribed() transcribed_func = @@ -1066,42 +972,40 @@ function DP.add_disjunct_oa_cuts( constraint.func) transcribed_constraint = JuMP.ScalarConstraint( transcribed_func, constraint.set) - for (binary_ref, _, _, dual_value) in DP.cut_info( + for (binary_ref, _, _) in _infinite_cut_info( master.binary_map[indicator], active, - transcribed_constraint, - result.linearization_point, - master.variable_map, dual) + transcribed_func, result.linearization_point, + master.variable_map) DP._add_oa_cut_for_constraint( transcribed_constraint, master, binary_ref, transcribed_xk[], transcribed_to_master[], - dual_value, method, sign_factor, - penalty_sign, result.feasible) + method, penalty_sign) end continue end - for (binary_ref, linearization_point, var_map, - dual_value) in DP.cut_info( - master.binary_map[indicator], active, constraint, - result.linearization_point, - master.variable_map, dual) + for (binary_ref, linearization_point, var_map) in + _infinite_cut_info( + master.binary_map[indicator], active, + constraint.func, result.linearization_point, + master.variable_map) DP._add_oa_cut_for_constraint( constraint, master, binary_ref, - linearization_point, var_map, dual_value, - method, sign_factor, penalty_sign, - result.feasible) + linearization_point, var_map, method, penalty_sign) end end end end -# Apply per-indicator fixes for `combination` and return a closure -# that reverses them. Scalar (`Bool`) values delegate to base -# `fix_indicator` (which unwraps complement-form `1 - y` AffExprs to -# the underlying binary and inverts the target). Per-support -# `AbstractVector{Bool}` values fix each support via a point-equality -# constraint on the underlying infinite var. State lives in the -# closure — no `model.ext` stash. Used by base `with_fixed_combination` -# and `commit_combination`. +# Apply per-indicator fixes for `combination` and return a closure that +# reverses them. A scalar (`Bool`) value delegates to base +# `fix_indicator` (force-fix, which unwraps complement-form `1 - y` +# AffExprs). A per-support `AbstractVector{Bool}` value pins each support +# of the underlying infinite binary with a point-equality constraint. +# The returned closure deletes those constraints and unfixes, so the +# extension's `with_fixed_combination` can tear the fix down each +# iteration — a stale per-support pin would clash with the next +# combination. `commit_combination` ignores the closure, persisting the +# committed fix. function DP.fix_combination( model::InfiniteOpt.InfiniteModel, combination::AbstractDict ) @@ -1135,20 +1039,44 @@ function DP.fix_combination( end end -# Per-support binary pin on an NLPF copy of an `InfiniteModel`. -# Triggered when the combination value is `AbstractVector{Bool}` — -# i.e., the indicator is itself infinite, so each support k must be -# pinned independently via a point-equality `binary(t_k) == value[k]`. -# Finite indicators on an `InfiniteModel` dispatch to the base scalar -# `JuMP.fix` path because `combination_val` returns a scalar `Bool`. -# Complement-form binaries are handled by base recursion before this -# dispatch fires. -function DP._nlpf_fix_on_copy( - copy::InfiniteOpt.InfiniteModel, - binary::InfiniteOpt.GeneralVariableRef, - value::AbstractVector{Bool} +# OVERRIDE for InfiniteModel: fix the combination, run `f()`, then +# always undo. The finite base fixes in place and needs no teardown, but +# the infinite path pins per-support combinations with point-equality +# constraints (and scalar seeds with `JuMP.fix`) that must be cleared +# between iterations — a stale per-support pin would clash with the next +# combination — so this lifecycle needs the `try/finally` the base +# omits. The model is relaxed once in `reformulate_model`, so there is +# no relax/unrelax here. +function DP.with_fixed_combination( + f, model::InfiniteOpt.InfiniteModel, combination::AbstractDict + ) + undo = DP.fix_combination(model, combination) + try + return f() + finally + undo() + end +end + +# Pin a copy-side binary on an NLPF copy of an `InfiniteModel`. The +# infinite `with_fixed_combination` undoes the original's fix before the +# NLPF fall-through, so the copy arrives unfixed and is pinned here. The +# copy is discarded after the solve, so a point-equality constraint +# needs no teardown; using constraints rather than `JuMP.fix` avoids +# force-deleting bounds on the relaxed copy (whose bound refs may not +# survive `copy_model`). +function DP.nlpf_fix_on_copy( + copy::InfiniteOpt.InfiniteModel, binary, value::Bool + ) + JuMP.@constraint(copy, binary == (value ? 1.0 : 0.0)) + return +end +function DP.nlpf_fix_on_copy( + copy::InfiniteOpt.InfiniteModel, binary, value::AbstractVector{Bool} ) - for (k, support) in enumerate(_supports_of(binary)) + underlying = binary isa JuMP.GenericAffExpr ? + only(keys(binary.terms)) : binary + for (k, support) in enumerate(_supports_of(underlying)) JuMP.@constraint(copy, _at_support(binary, support) == (value[k] ? 1.0 : 0.0)) end diff --git a/src/loa.jl b/src/loa.jl index e1ce3c96..f03dcdb7 100644 --- a/src/loa.jl +++ b/src/loa.jl @@ -8,8 +8,8 @@ # Infeasible primary NLPs fall through to NLPF (V&G 1990 eq. 8): a # slacked feasibility version of the same problem whose primal becomes # the linearization site for OA cuts. A no-good cut still forbids that -# combination on the master. NLPF is bypassed for per-support -# Vector{Bool} combinations (InfiniteOpt per-support master) — those +# combination on the master. NLPF is bypassed for vector-valued +# `Vector{Bool}` combinations (which an extension may supply) — those # fall back to no-good-only. ################################################################################ @@ -34,12 +34,19 @@ supported. Other reformulations (`Hull`, `PSplit`) are not yet supported. - `inner_method::R`: Reformulation applied to the primary NLP — `BigM` or `MBM`. - `max_iter::Int`: Maximum LOA iterations after set-covering seeding. -- `atol::T`, `rtol::T`: Absolute / relative gap tolerances for - convergence. - `M_value::T`: Big-M used in the disjunct OA cut gating term. - `max_slack::T`: Upper bound for each per-cut slack variable. - `oa_penalty::T`: V&G 1990 penalty coefficient applied to slacks in the master objective. +- `convergence_tol::Float64`: Relative gap tolerance for the early + stop. The main loop exits once the master bound meets the incumbent + within this relative gap, provided the slacks also pass `slack_tol`. +- `slack_tol::Float64`: Largest total OA-cut slack (summed slack + variables) for which the master bound still counts as a valid + convergence certificate. Positive slack means the master is + violating its own cuts (the nonconvex case), so the bound is not + trustworthy and the loop keeps running to `max_iter` rather than + stopping on a spurious crossing. - `iteration_time_limit::Float64`: Wall-clock budget (seconds) for the LOA iteration loop (seeds + main loop). `Inf` (default) is no limit; each subproblem solve is capped to the budget left so one solve can't @@ -53,25 +60,23 @@ struct LOA{O, P, R, T} <: AbstractReformulationMethod mip_optimizer::P inner_method::R max_iter::Int - atol::T - rtol::T M_value::T max_slack::T oa_penalty::T - verbose::Bool + convergence_tol::Float64 + slack_tol::Float64 iteration_time_limit::Float64 time_limit::Float64 function LOA( nlp_optimizer::O; mip_optimizer::P = nlp_optimizer, max_iter::Int = 10, - atol::T = 1e-6, - rtol::T = 1e-4, M_value::T = 1e9, max_slack::T = 1e3, oa_penalty::T = 1e3, inner_method::R = BigM(M_value), - verbose::Bool = false, + convergence_tol::Float64 = 1e-6, + slack_tol::Float64 = 1e-4, iteration_time_limit::Float64 = Inf, time_limit::Float64 = Inf ) where {O, P, R <: AbstractReformulationMethod, T} @@ -79,7 +84,8 @@ struct LOA{O, P, R, T} <: AbstractReformulationMethod "LOA inner_method must be BigM or MBM (got $R). " * "Hull and PSplit are not yet supported.") new{O, P, R, T}(nlp_optimizer, mip_optimizer, inner_method, - max_iter, atol, rtol, M_value, max_slack, oa_penalty, verbose, + max_iter, M_value, max_slack, oa_penalty, + convergence_tol, slack_tol, iteration_time_limit, time_limit) end end @@ -93,10 +99,9 @@ end # # Bundles the LOA master MILP and the maps the algorithm needs to # translate original-model refs into master-model refs. -# `objective_ref_map` is split from `variable_map` because the -# InfiniteOpt aggregate-objective path uses a transcribed- -# `JuMP.VariableRef`-keyed map for the objective only, while constraint -# linearization uses the InfiniteOpt-keyed `variable_map`. +# `objective_ref_map` is split from `variable_map` so an extension can +# map the objective's linearization variables differently from the +# constraint ones; in base the two maps are identical. mutable struct _LOAMaster{M <: JuMP.AbstractModel, OF, AO, BM, VM, RM} model::M binary_map::BM @@ -105,43 +110,42 @@ mutable struct _LOAMaster{M <: JuMP.AbstractModel, OF, AO, BM, VM, RM} original_objective::OF alpha_oa::AO objective_ref_map::RM - # All penalized slacks ever appended to the master, in emission - # order — disjunct, global, and objective cuts. Iterated by - # `_check_slacks` after each master solve to diagnose whether - # `max_slack` is binding. - slacks::Vector{Any} end ################################################################################ # SENSE PRIMITIVES ################################################################################ # Val(MIN/MAX)-dispatched primitives so the algorithm reads sense-agnostic. -_disjunct_cut_coefficients(::Val{_MOI.MIN_SENSE}) = (-1, 1) -_disjunct_cut_coefficients(::Val{_MOI.MAX_SENSE}) = (1, -1) +_penalty_sign(::Val{_MOI.MIN_SENSE}) = 1 +_penalty_sign(::Val{_MOI.MAX_SENSE}) = -1 _worst_objective(::Val{_MOI.MIN_SENSE}) = Inf _worst_objective(::Val{_MOI.MAX_SENSE}) = -Inf _is_better(::Val{_MOI.MIN_SENSE}, new, best) = new < best _is_better(::Val{_MOI.MAX_SENSE}, new, best) = new > best _gap(::Val{_MOI.MIN_SENSE}, best, bound) = best - bound _gap(::Val{_MOI.MAX_SENSE}, best, bound) = bound - best -_flip_sense(::Val{_MOI.MIN_SENSE}) = Val(_MOI.MAX_SENSE) -_flip_sense(::Val{_MOI.MAX_SENSE}) = Val(_MOI.MIN_SENSE) ################################################################################ # MAIN ALGORITHM ################################################################################ # LOA reformulation entry point: set-covering seeds, the master/NLP # iteration loop, commit the best combination, and mark the model ready. -# Plain `LOA` on an `InfiniteModel` dispatches here too — the InfiniteOpt -# overrides act on the inner solve steps (master build, NLP, cuts), not -# on this driver. +# This driver is model-agnostic — extensions override the inner solve +# steps (master build, NLP, cuts), not this loop. function reformulate_model(model::JuMP.AbstractModel, method::LOA) _clear_reformulations(model) combinations = _set_covering_combinations(model) reformulate_model(model, method.inner_method) master = build_loa_master(model, method) - reformulation_map = _build_reformulation_map(model) + # Relax the original model's logical binaries once, permanently. The + # original model is only ever solved as the NLP (never as a MILP — + # the master is a separate copy), so its ZeroOne is never used. + # Stripping it here lets a pure NLP solver handle every solve and + # makes per-iteration fixing overwrite in place (no relax/unrelax, + # no fix/undo). Must follow build_loa_master so the master keeps its + # ZeroOne binaries. + relax_logical_vars(model) JuMP.set_optimizer(model, method.nlp_optimizer) JuMP.set_silent(model) t_start = time() @@ -152,6 +156,7 @@ function reformulate_model(model::JuMP.AbstractModel, method::LOA) sense_token = Val(JuMP.objective_sense(model)) best_objective = _worst_objective(sense_token) best_result = nothing + master_bound = nothing # Initialization Procedure (Türkay & Grossmann 1996, sec. 2.2): # solve K set-covering NLPs with cycling indicator combinations to @@ -160,75 +165,62 @@ function reformulate_model(model::JuMP.AbstractModel, method::LOA) # forward as the warm start for the next NLP (T&G §2.3); NLPF # solutions are skipped since their primal is slack-distorted. previous_result = nothing - for (i, combination) in enumerate(combinations) + for combination in combinations time() < loop_deadline || break _set_nlp_warm_start(previous_result) - t_seed = @elapsed result = _solve_nlp( - model, combination, method, reformulation_map; + result = _solve_nlp(model, combination, method; deadline = loop_deadline) avoid_combination(master.model, combination, master.binary_map) add_oa_cuts(model, master, result, method) - _check_penalty(result, method) - if result.feasible && _is_better(sense_token, result.objective, - best_objective) + if result.feasible && + _is_better(sense_token, result.objective, best_objective) best_objective = result.objective best_result = result end result.feasible && (previous_result = result) - method.verbose && _log_seed(i, t_seed, result, best_objective) end - # Master bound is a rigorous dual bound only for a convex (linear) - # inner problem; otherwise stop on V&G (1990) criterion 5 - # (`_nlp_stalled`), not the untrustworthy gap. - rigorous = !_has_nonlinear_constraints(model) - produced_bound = false - prev_nlp_objective = previous_result === nothing ? nothing : - previous_result.objective - for iter in 1:method.max_iter + # Master/NLP loop. Each iteration solves the master MILP for a lower + # bound (`master_bound` is the `alpha_oa` auxiliary, NOT the penalized + # objective the slacks inflate), then solves the NLP at the master's + # combination and updates the incumbent. The loop exits on whichever + # comes first: convergence (the bound meets the incumbent within + # `convergence_tol` while total slack is below `slack_tol`), an + # infeasible master (every combination forbidden by a no-good cut), + # `max_iter`, or the time budget. The slack gate is what makes the + # convergence stop safe without a convexity flag: on a convex inner + # problem the slacks collapse to zero and the crossing is a real + # optimality proof; on a nonconvex one the master pays slack to + # violate its own cuts, the gate stays shut, and the loop runs on to + # `max_iter` instead of stopping at a spurious crossing. + converged = false + for _ in 1:method.max_iter time() < loop_deadline || break _cap_remaining_time(master.model, loop_deadline) - t_master = @elapsed JuMP.optimize!(master.model) - feasible = JuMP.is_solved_and_feasible(master.model) - method.verbose && _log_master(iter, master.model, t_master) - if !feasible - _check_degenerate_master(master, produced_bound, - best_objective) - break - end - _check_slacks(master, method) - master_bound = JuMP.objective_value(master.model) - produced_bound = true - method.verbose && - _log_iter(iter, master_bound, best_objective, sense_token) - if rigorous - _loa_converged( - best_objective, master_bound, sense_token, method) && break - else - _check_nonrigorous_bound( - best_objective, master_bound, sense_token, method) + JuMP.optimize!(master.model) + JuMP.is_solved_and_feasible(master.model) || break + master_bound = JuMP.value(master.alpha_oa) + if best_result !== nothing + gap = _gap(sense_token, best_objective, master_bound) + total_slack = abs(JuMP.objective_value(master.model) - + master_bound) / method.oa_penalty + tol = method.convergence_tol * max(abs(best_objective), 1.0) + if gap <= tol && total_slack <= method.slack_tol + converged = true + break + end end combination = _extract_combination(model, master) _set_nlp_warm_start(previous_result) - t_nlp = @elapsed result = _solve_nlp( - model, combination, method, reformulation_map; + result = _solve_nlp(model, combination, method; deadline = loop_deadline) avoid_combination(master.model, combination, master.binary_map) add_oa_cuts(model, master, result, method) - _check_penalty(result, method) - if result.feasible && _is_better(sense_token, result.objective, - best_objective) + if result.feasible && + _is_better(sense_token, result.objective, best_objective) best_objective = result.objective best_result = result end - method.verbose && _log_nlp(iter, t_nlp, result, best_objective) - # V&G (1990) criterion 5: with no rigorous bound, stop once a - # feasible NLP stops improving on the previous feasible NLP. - if !rigorous && result.feasible - _nlp_stalled(sense_token, result.objective, - prev_nlp_objective) && break - prev_nlp_objective = result.objective - end result.feasible && (previous_result = result) end @@ -243,80 +235,47 @@ function reformulate_model(model::JuMP.AbstractModel, method::LOA) # Loop-only budget: restore so the final solve isn't crippled. _restore_time_limit(model, original_time_limit) end + _report_loa_gap(best_objective, best_result, master_bound, sense_token, + converged) _set_solution_method(model, method) _set_ready_to_optimize(model, true) return end -# `verbose = true` trace helpers. Print to stdout so a `tee`-d log -# captures them; structured key=value so they're easy to grep. -function _log_seed(i::Int, t::Real, result::NamedTuple, best::Real) - println("LOA_SEED ", i, ": time=", round(t, digits = 3), - "s feasible=", result.feasible, " obj=", result.objective, - " best=", best) - flush(stdout) -end -function _log_master(iter::Int, master::JuMP.AbstractModel, t::Real) - println("LOA_MASTER iter=", iter, - " status=", JuMP.termination_status(master), - " feasible=", JuMP.is_solved_and_feasible(master), - " time=", round(t, digits = 3), "s") - flush(stdout) -end -function _log_iter(iter::Int, lb::Real, ub::Real, sense_token::Val) - println("LOA_ITER ", iter, ": LB=", lb, " UB=", ub, - " gap=", _gap(sense_token, ub, lb)) - flush(stdout) -end -function _log_nlp(iter::Int, t::Real, result::NamedTuple, best::Real) - println("LOA_NLP iter=", iter, - " time=", round(t, digits = 3), "s feasible=", result.feasible, - " obj=", result.objective, " best=", best) - flush(stdout) -end - -# Convergence: absolute gap ≤ atol or relative gap ≤ rtol. Distinct -# from CP's `separation_obj ≤ tolerance` check (different convergence -# shapes — gap vs. single-threshold), so not shared. -function _loa_converged( - best_objective::Real, - master_bound::Real, - sense_token::Val, - method::LOA - ) - (isinf(best_objective) || isinf(master_bound)) && return false - gap = _gap(sense_token, best_objective, master_bound) - gap <= method.atol && return true - abs(best_objective) > 1e-10 && - gap / abs(best_objective) <= method.rtol && return true - return false -end - -# A genuine bound crossing: the master "bound" is worse than the -# incumbent by more than the convergence tolerance (gap strongly -# negative). In valid convex OA, LB ≤ UB always, so this can only -# arise from invalid OA cuts on a nonconvex problem — V&G (1990) report -# the master objective exceeding the integer NLP optimum on every -# nonconvex test problem. The tolerance band separates a real crossing -# from floating-point / solver noise. -function _bound_crossed( +# Report the final gap once the loop ends. `[converged]` = the loop hit +# its convergence test (the master bound met the incumbent within +# `convergence_tol` with total slack below `slack_tol`); `[limit hit]` = +# it stopped on `max_iter`, the time budget, or an exhausted master with +# a gap still open; `[no bound]` = the master never produced a bound +# (fell back to the best seed). `master_bound` is `alpha_oa`, a rigorous +# dual bound only for a convex (linear) inner problem; on a nonconvex +# model it can cross the incumbent, which the slack gate is meant to +# catch and the reported gap surfaces. +function _report_loa_gap( best_objective::Real, - master_bound::Real, + best_result, + master_bound, sense_token::Val, - method::LOA + converged::Bool ) - (isinf(best_objective) || isinf(master_bound)) && return false + if best_result === nothing + @warn "LOA finished: no feasible incumbent found." + return + end + if master_bound === nothing + @info "LOA finished [no bound]: incumbent $best_objective " * + "(master produced no bound; best seed kept)." + return + end gap = _gap(sense_token, best_objective, master_bound) - return gap < -(method.atol + method.rtol * abs(best_objective)) + relative = abs(best_objective) > 1e-10 ? + gap / abs(best_objective) : gap + label = converged ? "converged" : "limit hit" + @info "LOA finished [$label]: incumbent $best_objective, master " * + "bound $master_bound, gap $gap (relative $relative)." + return end -# V&G (1990) criterion 5: a feasible NLP that does not improve on the -# previous feasible NLP signals the heuristic has converged. The first -# feasible NLP (`nothing` previous) is never stalled. -_nlp_stalled(sense_token::Val, objective::Real, previous::Real) = - !_is_better(sense_token, objective, previous) -_nlp_stalled(::Val, ::Real, ::Nothing) = false - # Error fallback for unsupported model types. function reformulate_model(::M, ::LOA) where {M} error("reformulate_model not implemented for model type `$(M)` with LOA.") @@ -364,22 +323,6 @@ _is_linear_F(::Type{<:AbstractVector{<:JuMP.AbstractVariableRef}}) = true _is_linear_F(::Type{<:AbstractVector{<:JuMP.GenericAffExpr}}) = true _is_linear_F(::Type) = false -# True if any constraint carries a non-affine function. LOA's master -# objective is a rigorous lower bound only when every constraint is -# linear: the inner problem is then a (linear, hence convex) GDP and -# the OA cuts are exact supporting hyperplanes. With a nonlinear -# constraint the problem may be nonconvex, the OA linearizations need -# not support the feasible set, and the master bound is not rigorous — -# so LOA terminates on V&G (1990) subproblem-improvement instead. -function _has_nonlinear_constraints(model::JuMP.AbstractModel) - variable_type = JuMP.variable_ref_type(typeof(model)) - for (F, _) in JuMP.list_of_constraint_types(model) - F === variable_type && continue - _is_linear_F(F) || return true - end - return false -end - # OVERRIDABLE. Build the LOA master MILP `M^b_{LA}` (Türkay & Grossmann # 1996, eq. 12): copy decision variables and only the linear # constraints, install `alpha_oa` as the objective auxiliary. Nonlinear @@ -417,7 +360,7 @@ function build_loa_master(model::JuMP.AbstractModel, method::LOA) JuMP.@objective(master, objective_sense, alpha_oa) return _LOAMaster(master, binary_map, variable_map, objective_sense, - original_objective, alpha_oa, variable_map, Any[]) + original_objective, alpha_oa, variable_map) end ################################################################################ @@ -441,14 +384,15 @@ _restore_time_limit(model::JuMP.AbstractModel, seconds::Real) = JuMP.set_time_limit_sec(model, seconds) # Solve the primary NLP for a fixed combination. If feasible, read the -# primal point, duals, and objective. If infeasible, fall through to -# the NLPF (V&G 1990 eq. 8) approximation: a slacked version of the -# same problem that always solves, whose primal becomes the -# linearization site for OA cuts. The master still learns shape -# information from the failed combination instead of only adding a -# no-good cut. +# primal point and objective. If infeasible, fall through to the NLPF +# (V&G 1990 eq. 8) approximation: a slacked version of the same problem +# that always solves, whose primal becomes the linearization site for +# OA cuts. The master still learns shape information from the failed +# combination instead of only adding a no-good cut. function _solve_nlp( - model::M, combination, method::LOA, reformulation_map; + model::M, + combination, + method::LOA; deadline::Float64 = Inf ) where {M <: JuMP.AbstractModel} _cap_remaining_time(model, deadline) @@ -456,11 +400,9 @@ function _solve_nlp( JuMP.optimize!(model, ignore_optimize_hook = true) if JuMP.is_solved_and_feasible(model) lin_point = extract_solution(model) - duals = _collect_nlp_duals( - model, combination, reformulation_map) objective_val = JuMP.objective_value(model) return (combination = combination, - linearization_point = lin_point, duals = duals, + linearization_point = lin_point, objective = objective_val, feasible = true) end return nothing @@ -473,7 +415,6 @@ function _solve_nlp( return (combination = combination, linearization_point = Dict{JuMP.AbstractVariableRef, Any}(), - duals = Dict{DisjunctConstraintRef{M}, Any}(), objective = Inf, feasible = false) end @@ -485,13 +426,13 @@ end # single nonnegative slack `u`, minimize `u`, and return the resulting # primal point as an approximate linearization site. The point # satisfies equalities and variable bounds exactly; inequalities can -# be violated by at most `u`. The "duals" returned are sign-only — the -# OA cut emitter uses `sign(dual)` to pick a direction, and for a -# slacked feasibility solve we know each active constraint's -# linearization is informative in the standard direction. +# be violated by at most `u`. function _solve_nlpf( model::M, combination, method::LOA; deadline::Float64 = Inf ) where {M <: JuMP.AbstractModel} + # The original model's logical binaries are permanently relaxed + # (see reformulate_model), so this copy is continuous — any NLP + # solver, including pure-NLP ones like Ipopt, can solve NLPF. copy, ref_map = JuMP.copy_model(model) JuMP.set_optimizer(copy, method.nlp_optimizer) JuMP.set_silent(copy) @@ -513,30 +454,24 @@ function _solve_nlpf( JuMP.@objective(copy, Min, u) - # Translate each original-model indicator binary to its - # counterpart on the copy, then pin it to the combination value. - # `_nlpf_fix_on_copy` dispatches on value type: scalar `Bool` - # paths use `JuMP.fix`; per-support `AbstractVector{Bool}` paths - # require an `InfiniteModel`-side override (per-support point- - # equality) — see `ext/InfiniteDisjunctiveProgramming.jl`. + # Pin the copy's indicator binaries to the combination. The infinite + # `with_fixed_combination` undoes the original's fix after each + # solve, so this NLPF copy arrives unfixed and its `nlpf_fix_on_copy` + # override re-pins it. The finite path leaves the original fixed in + # place, so the copy inherits the fix and the base `nlpf_fix_on_copy` + # is a no-op. for (indicator, value) in combination binary = _binary_on_copy( _indicator_to_binary(model)[indicator], ref_map) - _nlpf_fix_on_copy(copy, binary, value) + nlpf_fix_on_copy(copy, binary, value) end - try - JuMP.optimize!(copy, ignore_optimize_hook = true) - catch - return nothing - end + JuMP.optimize!(copy, ignore_optimize_hook = true) JuMP.has_values(copy) || return nothing linearization_point = _nlpf_extract_primal(model, ref_map) - duals = _nlpf_sign_duals(model, combination) return (combination = combination, linearization_point = linearization_point, - duals = duals, objective = Inf, feasible = false) end @@ -545,40 +480,19 @@ end # complement-form `1 - y_orig` rebuilds as `1 - ref_map[y_orig]`. _binary_on_copy(binary::JuMP.AbstractVariableRef, ref_map) = ref_map[binary] -function _binary_on_copy( - binary::JuMP.GenericAffExpr, ref_map - ) +function _binary_on_copy(binary::JuMP.GenericAffExpr, ref_map) underlying = only(keys(binary.terms)) return 1.0 - ref_map[underlying] end -# Pin a copy-side binary to a combination value. Plain `JuMP.fix` -# (no `force = true`) — `force` triggers `delete_upper_bound` which -# fails on InfiniteOpt copies whose ZeroOne constraint refs didn't -# survive `JuMP.copy_model`. The fix pins the value to 0/1 which -# satisfies the ZeroOne anyway. Solvers that can't handle binaries -# (Ipopt) will error out — the caller catches and returns `nothing` -# so the iteration falls back to no-good-only behavior. -function _nlpf_fix_on_copy( - copy, binary::JuMP.AbstractVariableRef, value::Bool - ) - JuMP.is_fixed(binary) && JuMP.unfix(binary) - JuMP.fix(binary, value ? 1.0 : 0.0) - return -end -# Complement form `1 - y_underlying`: indicator=value means -# underlying=!value. Recursion routes both scalar `Bool` and per- -# support `AbstractVector{Bool}` to the underlying-binary dispatch. -function _nlpf_fix_on_copy( - copy, binary::JuMP.GenericAffExpr, value - ) - underlying = only(keys(binary.terms)) - _nlpf_fix_on_copy(copy, underlying, _flip(value)) - return -end - -_flip(v::Bool) = !v -_flip(v::AbstractVector{Bool}) = .!v +# OVERRIDABLE. Pin a copy-side binary to a combination value. Base +# no-op: the finite original stays fixed in place (its +# `with_fixed_combination` does not undo), so its NLPF copy inherits the +# fix. The InfiniteOpt extension overrides this — its original is +# cleaned up each iteration, so the copy needs pinning — using +# constraints rather than `JuMP.fix` to avoid force-deleting bounds on +# the relaxed copy. +nlpf_fix_on_copy(copy, binary, value) = nothing _nlpf_should_slack(::Type{<:_MOI.LessThan}) = true _nlpf_should_slack(::Type{<:_MOI.GreaterThan}) = true @@ -588,75 +502,35 @@ _nlpf_slacked_func(func, u, ::_MOI.LessThan) = func - u _nlpf_slacked_func(func, u, ::_MOI.GreaterThan) = func + u # Read primal values from `copy` keyed by the original model's -# variables, in the same per-support shape `extract_solution` would -# have produced on the original. -function _nlpf_extract_primal( - model::JuMP.AbstractModel, ref_map - ) +# variables, in the same vector shape `extract_solution` would have +# produced on the original. +function _nlpf_extract_primal(model::JuMP.AbstractModel, ref_map) V = JuMP.variable_ref_type(typeof(model)) T = JuMP.value_type(typeof(model)) result = Dict{V, Vector{T}}() for v in collect_all_vars(model) JuMP.is_fixed(v) && continue - target = try - ref_map[v] - catch - continue # var not in the copy's ref_map (e.g., parameter only) - end + target = ref_map[v] val = JuMP.value(target) result[v] = val isa AbstractArray ? vec(val) : [val] end return result end -# Sign-only "duals" on active disjunct constraints — the OA cut -# emitter only needs `sign(dual)` to pick the linearization direction, -# and for a slacked-feasibility solve each active constraint's -# linearization is meaningful in its standard direction. -function _nlpf_sign_duals( - model::M, combination::AbstractDict - ) where {M <: JuMP.AbstractModel} - duals = Dict{DisjunctConstraintRef{M}, Any}() - for (indicator, active) in combination - any_active(active) || continue - haskey(_indicator_to_constraints(model), indicator) || continue - for cref in _indicator_to_constraints(model)[indicator] - cref isa DisjunctConstraintRef || continue - con = _disjunct_constraints(model)[ - JuMP.index(cref)].constraint - duals[cref] = _nlpf_dual_sign(con.set) - end - end - return duals -end - -_nlpf_dual_sign(::_MOI.LessThan) = 1.0 -_nlpf_dual_sign(::_MOI.GreaterThan) = -1.0 -_nlpf_dual_sign(::_MOI.EqualTo) = [1.0, 1.0] -_nlpf_dual_sign(::_MOI.Interval) = [1.0, 1.0] -_nlpf_dual_sign(s::_MOI.Nonpositives) = ones(_MOI.dimension(s)) -_nlpf_dual_sign(s::_MOI.Nonnegatives) = -ones(_MOI.dimension(s)) -_nlpf_dual_sign(s::_MOI.Zeros) = ones(_MOI.dimension(s)) -_nlpf_dual_sign(::Any) = 0.0 - -# Fix `combination`, run `f()`, unfix. Logical binaries are relaxed -# from ZeroOne for the duration so a pure NLP solver (Ipopt) can -# handle the inner solve; restored on exit. Extensions override -# `fix_combination` to handle per-support fixing without needing -# their own try/finally lifecycle. +# Fix `combination` in place, then run `f()`. No relax/restore (the +# model's logical binaries are relaxed once, permanently, in +# reformulate_model) and no fix/undo: `fix_combination` overwrites in +# place, so there is no per-iteration state to unwind — hence no +# try/finally. On a solve error the model is left fixed to the last +# combination; the next `fix_combination` (or `commit_combination`) +# overwrites it. function with_fixed_combination( f, model::JuMP.AbstractModel, combination::AbstractDict ) - relaxed = relax_logical_vars(model) - undo = fix_combination(model, combination) - try - return f() - finally - undo() - unrelax_logical_vars(relaxed) - end + fix_combination(model, combination) + return f() end # Iter-to-iter NLP warm start (T&G 1996 §2.3): seed the next primary @@ -665,8 +539,8 @@ end # no-op on iterations following an NLPF fall-through (caller does not # update `previous_result` then), since NLPF's primal is slack- # distorted and a worse start than the prior real NLP. Routes through -# the `set_linearization_start` dispatch so the InfiniteOpt extension's -# per-support broadcast handles `GeneralVariableRef` correctly. +# the `set_linearization_start` dispatch so an extension can broadcast +# a vector-valued start across its variables. function _set_nlp_warm_start(previous) previous === nothing && return for (variable, values) in previous.linearization_point @@ -675,18 +549,16 @@ function _set_nlp_warm_start(previous) return end -# Finalize the model with the LOA-optimal combination: relax logical -# binaries (stripping ZeroOne so a pure NLP solver can handle the -# post-hook `JuMP.optimize!`), fix indicators at the committed values, -# and warm-start from the linearization point. After this, the model -# is no longer in a state suitable for re-running with a different -# `gdp_method`. +# Finalize the model with the LOA-optimal combination: fix indicators +# at the committed values (in place; the model is already permanently +# relaxed by reformulate_model) and warm-start from the linearization +# point. After this, the model is no longer in a state suitable for +# re-running with a different `gdp_method`. function commit_combination( model::JuMP.AbstractModel, combination::AbstractDict, linearization_point::AbstractDict ) - relax_logical_vars(model) fix_combination(model, combination) for (variable, values) in linearization_point set_linearization_start(variable, values) @@ -694,197 +566,40 @@ function commit_combination( return end -# OVERRIDABLE. Apply the combination's fixes and return a closure that -# undoes them. Base loops `fix_indicator`/`unfix_indicator`. The -# InfiniteOpt extension overrides this to handle per-support -# `Vector{Bool}` values via point-equality constraints, keeping all -# cleanup state captured in the returned closure. -function fix_combination( - model::JuMP.AbstractModel, combination::AbstractDict - ) +# OVERRIDABLE. Apply the combination's fixes in place. No undo closure: +# `fix_indicator` uses `force = true` so each call overwrites the prior +# fix, and the model is permanently relaxed, so there is nothing to +# restore. An extension overrides this for vector-valued `Vector{Bool}` +# values (per-support pins reused in place across iterations). +function fix_combination(model::JuMP.AbstractModel, combination::AbstractDict) for (indicator, value) in combination fix_indicator(model, indicator, value) end - return function () - for (indicator, _) in combination - unfix_indicator(model, indicator) - end - end + return end -# OVERRIDABLE. Write the LOA linearization point into a variable's -# warm start. `values` is always per-support shape (length-1 vector -# for finite vars); base unwraps. The InfiniteOpt extension overrides -# for `GeneralVariableRef` to broadcast across transcribed supports. +# OVERRIDABLE. Write the LOA linearization point into a variable's warm +# start. This is deliberately NOT a direct `JuMP.set_start_value` call. +# The linearization point is stored per-variable as a vector — length-1 +# for a scalar/finite variable, length-K for a variable carrying K +# support points (see `extract_solution`). For an InfiniteOpt infinite +# variable that per-support vector must be written to each of the K +# *transcribed* JuMP variables (`transformation_variable(v)` is an array +# of per-support refs); `JuMP.set_start_value` on the single infinite +# `GeneralVariableRef` cannot place a distinct start at each support. +# Base handles the scalar case by unwrapping `only(values)`; the +# extension overrides this seam to broadcast the vector across the +# transcribed instances. Hence the dispatch rather than a bare +# `set_start_value`. set_linearization_start(variable, values::AbstractVector) = JuMP.set_start_value(variable, only(values)) -################################################################################ -# BIGM DUAL COLLECTION -################################################################################ -# Build a map `DisjunctConstraintRef → Vector{}` so -# `_collect_nlp_duals` can sum BigM duals per original disjunct -# constraint. Uses `_num_reform_constraints` to slice the flat -# reformulation-constraint list per original. (Brittle: assumes BigM -# emits constraints in disjunction-iteration order; a cleaner -# alternative would be to have BigM record this map directly.) -function _build_reformulation_map(model::M) where {M <: JuMP.AbstractModel} - ref_cons = _reformulation_constraints(model) - isempty(ref_cons) && return Dict{DisjunctConstraintRef{M}, Vector{Any}}() - CRT = eltype(ref_cons) - rmap = Dict{DisjunctConstraintRef{M}, Vector{CRT}}() - cursor = 1 - for (_, disjunction_data) in _disjunctions(model) - for indicator in disjunction_data.constraint.indicators - haskey(_indicator_to_constraints(model), indicator) || continue - for constraint_ref in _indicator_to_constraints(model)[indicator] - constraint_ref isa DisjunctConstraintRef || continue - constraint = _disjunct_constraints(model)[ - JuMP.index(constraint_ref)].constraint - num_reform = _num_reform_constraints(constraint) - cursor + num_reform - 1 <= length(ref_cons) || break - rmap[constraint_ref] = collect( - ref_cons[cursor:cursor + num_reform - 1]) - cursor += num_reform - end - end - end - return rmap -end - -# Number of BigM-reformulated JuMP constraints per original constraint. -_num_reform_constraints(::JuMP.ScalarConstraint{T, S}) where { - T <: Union{Number, JuMP.AbstractJuMPScalar}, - S <: Union{_MOI.EqualTo, _MOI.Interval}} = 2 -_num_reform_constraints(::JuMP.ScalarConstraint) = 1 -_num_reform_constraints(constraint::JuMP.VectorConstraint{T, S}) where { - T <: Union{Number, JuMP.AbstractJuMPScalar}, - S <: _MOI.Zeros} = 2 * _MOI.dimension(constraint.set) -_num_reform_constraints(constraint::JuMP.VectorConstraint) = - _MOI.dimension(constraint.set) - -# Sum the duals of all reformulation constraints for one disjunct cref. -function _sum_duals( - reformulation_map::AbstractDict, - constraint_ref::DisjunctConstraintRef - ) - haskey(reformulation_map, constraint_ref) || return 0.0 - rcs = reformulation_map[constraint_ref] - isempty(rcs) && return 0.0 - total = JuMP.dual(rcs[1]) - for i in 2:length(rcs) - total = total .+ JuMP.dual(rcs[i]) - end - return total -end - -# Sum BigM-reformulation duals per active disjunct constraint ref. -function _collect_nlp_duals( - model::M, - combination::AbstractDict, - reformulation_map::AbstractDict - ) where {M <: JuMP.AbstractModel} - duals = Dict{DisjunctConstraintRef{M}, Any}() - JuMP.has_duals(model) || return duals - for (indicator, active) in combination - any_active(active) || continue - haskey(_indicator_to_constraints(model), indicator) || continue - for cref in _indicator_to_constraints(model)[indicator] - cref isa DisjunctConstraintRef || continue - duals[cref] = _sum_duals(reformulation_map, cref) - end - end - return duals -end - -################################################################################ -# DIAGNOSTICS -################################################################################ -# V&G 1990 requires ρ > ‖λ*‖_∞ for the penalty term to dominate any -# admissible Lagrange multiplier — otherwise a slack can absorb a real -# violation and the OA cut becomes an invalid relaxation. Check after -# every feasible primary NLP and warn if the bound is at risk. NLPF -# returns sign-only duals (`±1`) so this is a no-op when `!feasible`. -function _check_penalty(result::NamedTuple, method::LOA) - result.feasible || return - isempty(result.duals) && return - max_dual = 0.0 - for (_, d) in result.duals - max_dual = max(max_dual, _max_abs_dual(d)) - end - if max_dual >= method.oa_penalty - @warn "LOA: oa_penalty = $(method.oa_penalty) ≤ max |dual| " * - "= $max_dual. V&G 1990 requires ρ > ‖λ‖_∞; slacks may " * - "absorb real OA-cut violations. Raise `oa_penalty`." - end - return -end -_max_abs_dual(d::Number) = abs(d) -_max_abs_dual(d::AbstractArray) = isempty(d) ? 0.0 : maximum(abs, d) -_max_abs_dual(::Nothing) = 0.0 - -# `max_slack` is a numerical guard (V&G 1990 leaves slacks unbounded; -# in practice an unbounded slack lets the master trivially feasible- -# itself out of the OA constraints). When a slack is at the cap, the -# master is effectively solving a stricter problem than V&G's master. -# Diagnose so the user can raise `max_slack` if the cap binds often. -function _check_slacks(master::_LOAMaster, method::LOA) - JuMP.has_values(master.model) || return - isempty(master.slacks) && return - cap = method.max_slack - threshold = 0.99 * cap - max_value = 0.0 - binding = 0 - for slack in master.slacks - val = JuMP.value(slack) - max_value = max(max_value, val) - val >= threshold && (binding += 1) - end - if binding > 0 - @warn "LOA: $binding slack(s) at max_slack = $cap (max " * - "value $max_value). Master is solving a stricter problem " * - "than V&G's; raise `max_slack` if this persists." - end - return -end - -# A master that is infeasible/unbounded before producing any bound is a -# degenerate exit, not convergence: LOA falls back to the best -# set-covering seed without iterating, usually because most NLP -# subproblems are infeasible and the master lacks the OA cuts to bound -# `alpha_oa`. No-op once a bound has been produced. -function _check_degenerate_master( - master::_LOAMaster, produced_bound::Bool, best_objective::Real - ) - produced_bound && return - @warn "LOA: master returned " * - "$(JuMP.termination_status(master.model)) before any bound; " * - "returning the best seed incumbent ($best_objective). Not a " * - "certified optimum — usually most NLP subproblems are infeasible." - return -end - -# A master bound that crosses the incumbent (`_bound_crossed`) proves the -# OA cuts are not valid supports: the model is nonconvex and the master -# gives no rigorous dual bound. LOA continues as a V&G (1990) heuristic -# and returns the best NLP incumbent. -function _check_nonrigorous_bound( - best_objective::Real, master_bound::Real, sense_token::Val, method::LOA - ) - _bound_crossed(best_objective, master_bound, sense_token, method) || - return - @warn "LOA: master bound ($master_bound) crossed the incumbent " * - "($best_objective); model is nonconvex, dual bound is not " * - "rigorous. Returning the best NLP incumbent (heuristic, V&G 1990)." - return -end - ################################################################################ # COMBO EXTRACTION (master → NLP) ################################################################################ # Read each indicator's binary value from the master MILP solution. -# `combination_val` dispatches per binary type (scalar in base; -# extensions add per-support). +# `combination_val` returns a `Bool` (finite indicator) or per-support +# `Vector{Bool}` (infinite); downstream dispatches on that. function _extract_combination( model::M, master::_LOAMaster @@ -892,15 +607,24 @@ function _extract_combination( combination = Dict{LogicalVariableRef{M}, Any}() for (_, disjunction_data) in _disjunctions(model) for indicator in disjunction_data.constraint.indicators - haskey(master.binary_map, indicator) || continue - combination[indicator] = combination_val(master.binary_map[indicator]) + combination[indicator] = + combination_val(master.binary_map[indicator]) end end return combination end -# OVERRIDABLE. One-shot float→Bool conversion; downstream dispatches on Bool. -combination_val(binary_ref) = round(Bool, JuMP.value(binary_ref)) +# Read the master binary and round to `Bool`. Rounding is required (not +# defensive): a MILP solver returns binaries within its +# integer-feasibility tolerance — e.g. 2.75e-40 for a "0", 1.0000002 for +# a "1" — never exactly 0/1, so `Bool(value)` would throw `InexactError`. +# Not dispatched: the ref type is the same whether the indicator is +# finite or infinite; only `JuMP.value`'s return differs (scalar vs a +# per-support array), so we branch on the value and handle both here. +function combination_val(binary_ref) + val = JuMP.value(binary_ref) + return val isa AbstractArray ? vec(round.(Bool, val)) : round(Bool, val) +end ################################################################################ # OA CUT EMISSION @@ -929,9 +653,12 @@ end # master infeasible — the disjunct and global cuts already carry # slacks but the objective cut did not. function _add_objective_cut( - sense_token::Val, master::_LOAMaster, lin, method::LOA + sense_token::Val, + master::_LOAMaster, + lin, + method::LOA ) - _, penalty_sign = _disjunct_cut_coefficients(sense_token) + penalty_sign = _penalty_sign(sense_token) slack = _penalized_slack(master, method, penalty_sign) _add_objective_cut_body(sense_token, master, lin, slack) end @@ -950,17 +677,16 @@ _add_objective_cut_body(::Val{_MOI.MAX_SENSE}, master, lin, slack) = # constraints are passed through to `JuMP.@constraint` as-is — # valid for affine-after-linearization sets. # -# Finite-only. InfiniteOpt's `add_oa_cuts(::InfiniteModel, ...)` -# override uses `_add_global_oa_cuts_infinite`, which routes through -# transcription to handle per-support / aggregate-ref globals. +# Base (scalar-model) version. An extension that overrides `add_oa_cuts` +# supplies its own global-cut handling for constraints whose +# linearization is vector-valued or derived. function _add_global_oa_cuts( model::JuMP.AbstractModel, master::_LOAMaster, result::NamedTuple, method::LOA ) - _, penalty_sign = _disjunct_cut_coefficients( - Val(master.objective_sense)) + penalty_sign = _penalty_sign(Val(master.objective_sense)) variable_type = JuMP.variable_ref_type(typeof(model)) reform_set = is_gdp_model(model) ? Set(_reformulation_constraints(model)) : Set() @@ -990,26 +716,18 @@ function add_disjunct_oa_cuts( result::NamedTuple, method::LOA ) - sign_factor, penalty_sign = _disjunct_cut_coefficients( - Val(master.objective_sense)) + penalty_sign = _penalty_sign(Val(master.objective_sense)) for (indicator, active) in result.combination - any_active(active) || continue - haskey(master.binary_map, indicator) || continue + is_active(active) || continue haskey(_indicator_to_constraints(model), indicator) || continue for cref in _indicator_to_constraints(model)[indicator] cref isa DisjunctConstraintRef || continue constraint = _disjunct_constraints(model)[ JuMP.index(cref)].constraint - dual = get(result.duals, cref, nothing) - dual === nothing && continue - for (binary_ref, lin_point, var_map, dual_value) in cut_info( - master.binary_map[indicator], active, constraint, - result.linearization_point, master.variable_map, dual) - _add_oa_cut_for_constraint( - constraint, master, binary_ref, lin_point, - var_map, dual_value, method, sign_factor, - penalty_sign, result.feasible) - end + _add_oa_cut_for_constraint( + constraint, master, master.binary_map[indicator], + result.linearization_point, master.variable_map, + method, penalty_sign) end end end @@ -1019,14 +737,10 @@ end # so all three carry the same augmented-penalty treatment: a nonconvex # linearization can be an invalid relaxation, and the penalized slack # keeps the master feasible instead of letting accumulated cuts make -# it infeasible. The slack is recorded on `master.slacks` so the -# `_check_slacks` diagnostic can see whether `max_slack` is binding. -function _penalized_slack( - master::_LOAMaster, method::LOA, penalty_sign::Int - ) +# it infeasible. +function _penalized_slack(master::_LOAMaster, method::LOA, penalty_sign::Int) slack = JuMP.@variable(master.model, lower_bound = 0.0, upper_bound = method.max_slack) - push!(master.slacks, slack) JuMP.set_objective_function(master.model, JuMP.objective_function(master.model) + penalty_sign * method.oa_penalty * slack) @@ -1035,86 +749,81 @@ end # Linearize constraint at `linearization_point`, then dispatch on the # constraint's set to emit slacked OA cut(s) gated by `M(1 − binary)`. -# Linear constraints are exact via BigM and skipped. `feasible` flags -# whether the linearization point came from a real NLP (true) or from -# NLPF (false); equality cuts behave differently in those two regimes. +# Linear constraints are exact via BigM and skipped. function _add_oa_cut_for_constraint( constraint::JuMP.AbstractConstraint, master::_LOAMaster, binary_ref, linearization_point::AbstractDict, var_map::AbstractDict, - dual_value, method::LOA, - sign_factor::Int, - penalty_sign::Int, - feasible::Bool + penalty_sign::Int ) _is_linear_F(typeof(constraint.func)) && return linearization = _linearize_at( constraint.func, linearization_point, var_map) - _emit_disjunct_oa_cut(constraint.set, master, binary_ref, - linearization, dual_value, method, sign_factor, - penalty_sign, feasible) + _emit_disjunct_oa_cut( + constraint.set, master, binary_ref, linearization, method, + penalty_sign) return end -# Inequality sets — one signed cut per Türkay-Grossmann 1996 / Duran- -# Grossmann 1986 OA convention. Primary NLP gives the real Lagrange -# multiplier (whose sign picks the active side); NLPF returns ±1 from -# `_nlpf_dual_sign(set)`. +# Emit the slacked, gated OA cut(s) for a disjunct constraint. The cut +# direction is set-determined (Duran-Grossmann 1986 OA convention): a +# `≤` constraint linearizes in place, a `≥` constraint is negated, and +# equality / interval emit both directions sharing one slack. No dual is +# needed — the constraint's set fixes the direction. function _emit_disjunct_oa_cut( - set::Union{_MOI.LessThan, _MOI.GreaterThan}, - master::_LOAMaster, binary_ref, linearization, dual_value, - method::LOA, sign_factor::Int, penalty_sign::Int, ::Bool + set::_MOI.LessThan, + master::_LOAMaster, + binary_ref, + linearization, + method::LOA, + penalty_sign::Int ) - sign_value = sign(sign_factor * _collapse_dual(dual_value)) - sign_value == 0 && return - rhs = _set_rhs(set) slack = _penalized_slack(master, method, penalty_sign) JuMP.@constraint(master.model, - sign_value * (linearization - rhs) - slack <= + (linearization - _set_rhs(set)) - slack <= + method.M_value * (1 - binary_ref)) + return +end +function _emit_disjunct_oa_cut( + set::_MOI.GreaterThan, + master::_LOAMaster, + binary_ref, + linearization, + method::LOA, + penalty_sign::Int + ) + slack = _penalized_slack(master, method, penalty_sign) + JuMP.@constraint(master.model, + (_set_rhs(set) - linearization) - slack <= method.M_value * (1 - binary_ref)) return end - -# Equality — feasible NLP: the summed BigM duals give the OA/ER -# multiplier sign (Duran-Grossmann 1986), so one signed cut suffices. -# Infeasible (NLPF): sign is uninformative, so emit both directions -# sharing one slack — mirrors the global-OA equality treatment in -# `_add_global_oa_row(::EqualTo)`. function _emit_disjunct_oa_cut( set::_MOI.EqualTo, - master::_LOAMaster, binary_ref, linearization, dual_value, - method::LOA, sign_factor::Int, penalty_sign::Int, feasible::Bool + master::_LOAMaster, + binary_ref, + linearization, + method::LOA, + penalty_sign::Int ) c = _MOI.constant(set) slack = _penalized_slack(master, method, penalty_sign) - if feasible - sign_value = sign(sign_factor * _collapse_dual(dual_value)) - sign_value == 0 && return - JuMP.@constraint(master.model, - sign_value * (linearization - c) - slack <= - method.M_value * (1 - binary_ref)) - return - end JuMP.@constraint(master.model, - (linearization - c) - slack <= - method.M_value * (1 - binary_ref)) + (linearization - c) - slack <= method.M_value * (1 - binary_ref)) JuMP.@constraint(master.model, - (c - linearization) - slack <= - method.M_value * (1 - binary_ref)) + (c - linearization) - slack <= method.M_value * (1 - binary_ref)) return end - -# Interval — always two-sided regardless of dual: there is no single -# rhs to sign against (`_set_rhs(::Interval) = 0` is wrong here), and -# any active boundary needs its linearization to gate the right side. -# Both rows share one slack so the V&G penalty is not double-counted. function _emit_disjunct_oa_cut( set::_MOI.Interval, - master::_LOAMaster, binary_ref, linearization, ::Any, - method::LOA, ::Int, penalty_sign::Int, ::Bool + master::_LOAMaster, + binary_ref, + linearization, + method::LOA, + penalty_sign::Int ) slack = _penalized_slack(master, method, penalty_sign) JuMP.@constraint(master.model, @@ -1125,19 +834,19 @@ function _emit_disjunct_oa_cut( method.M_value * (1 - binary_ref)) return end - -# Fallback for vector sets and any future set types — preserve the -# original single-signed behavior with `_collapse_dual` for the sign. +# Fallback for vector / future set types: emit the `≤`-direction cut +# against the set's RHS (`_set_rhs` defaults to 0). function _emit_disjunct_oa_cut( - set, master::_LOAMaster, binary_ref, linearization, dual_value, - method::LOA, sign_factor::Int, penalty_sign::Int, ::Bool + set, + master::_LOAMaster, + binary_ref, + linearization, + method::LOA, + penalty_sign::Int ) - sign_value = sign(sign_factor * _collapse_dual(dual_value)) - sign_value == 0 && return - rhs = _set_rhs(set) slack = _penalized_slack(master, method, penalty_sign) JuMP.@constraint(master.model, - sign_value * (linearization - rhs) - slack <= + (linearization - _set_rhs(set)) - slack <= method.M_value * (1 - binary_ref)) return end @@ -1149,24 +858,33 @@ end # `EqualTo` / `Interval` get a two-sided pair sharing one slack. # Unknown set types fall back to the prior hard cut. function _add_global_oa_row( - master::_LOAMaster, lin, set::_MOI.LessThan, - method::LOA, penalty_sign::Int + master::_LOAMaster, + lin, + set::_MOI.LessThan, + method::LOA, + penalty_sign::Int ) slack = _penalized_slack(master, method, penalty_sign) JuMP.@constraint(master.model, lin - _MOI.constant(set) <= slack) return end function _add_global_oa_row( - master::_LOAMaster, lin, set::_MOI.GreaterThan, - method::LOA, penalty_sign::Int + master::_LOAMaster, + lin, + set::_MOI.GreaterThan, + method::LOA, + penalty_sign::Int ) slack = _penalized_slack(master, method, penalty_sign) JuMP.@constraint(master.model, _MOI.constant(set) - lin <= slack) return end function _add_global_oa_row( - master::_LOAMaster, lin, set::_MOI.EqualTo, - method::LOA, penalty_sign::Int + master::_LOAMaster, + lin, + set::_MOI.EqualTo, + method::LOA, + penalty_sign::Int ) slack = _penalized_slack(master, method, penalty_sign) c = _MOI.constant(set) @@ -1175,57 +893,28 @@ function _add_global_oa_row( return end function _add_global_oa_row( - master::_LOAMaster, lin, set::_MOI.Interval, - method::LOA, penalty_sign::Int + master::_LOAMaster, + lin, + set::_MOI.Interval, + method::LOA, + penalty_sign::Int ) slack = _penalized_slack(master, method, penalty_sign) JuMP.@constraint(master.model, lin - set.upper <= slack) JuMP.@constraint(master.model, set.lower - lin <= slack) return end -function _add_global_oa_row( - master::_LOAMaster, lin, set, ::LOA, ::Int - ) +function _add_global_oa_row(master::_LOAMaster, lin, set, ::LOA, ::Int) JuMP.@constraint(master.model, lin in set) return end -# OVERRIDABLE. Yield `(binary_ref, lin_point, var_map, dual)` per OA -# cut to emit for `constraint`. Scalar binary returns one tuple; the -# InfiniteOpt extension overrides to fan out across supports of any -# infinite variable found in `binary_ref` OR `constraint.func`. The -# constraint must factor in because a finite indicator on an -# InfiniteModel can still gate a constraint that depends on an -# infinite var — one cut per support is needed, even though the -# indicator is scalar. -# -# `GenericAffExpr` covers complement-form indicators (`1 - y`) stored -# in `binary_map` when a `Logical` is declared with `logical_complement`. -cut_info( - binary_ref::JuMP.AbstractVariableRef, - active::Bool, - constraint::JuMP.AbstractConstraint, - linearization_point::AbstractDict, - variable_map::AbstractDict, - dual - ) = ((binary_ref, linearization_point, variable_map, dual),) -cut_info( - binary_ref::JuMP.GenericAffExpr, - active::Bool, - constraint::JuMP.AbstractConstraint, - linearization_point::AbstractDict, - variable_map::AbstractDict, - dual - ) = ((binary_ref, linearization_point, variable_map, dual),) - -# OVERRIDABLE. Truthiness of an active descriptor. InfiniteOpt -# overrides for per-support `Vector{Bool}`. -any_active(active::Bool) = active - -# Collapse a per-constraint dual (scalar or vector for Interval / -# EqualTo / Zeros) to a scalar sign-carrier. -_collapse_dual(dual::Number) = dual -_collapse_dual(dual) = sum(dual) +# Is this indicator active in the combination? A finite indicator carries +# a `Bool`; an infinite one carries a per-support `Vector{Bool}` (active +# at some supports, not others). Used to skip disjuncts that are off +# everywhere when emitting OA cuts. +is_active(active::Bool) = active +is_active(active::AbstractVector{Bool}) = any(active) ################################################################################ # LINEARIZATION & EXPRESSION CONVERSION @@ -1292,7 +981,8 @@ _to_nlp_expr(x::Number, ::Dict) = x # expression at point xk via MOI.Nonlinear reverse-mode AD. function _linearize_at( func::Union{JuMP.GenericQuadExpr, JuMP.GenericNonlinearExpr}, - xk::Dict, ref_map + xk::Dict, + ref_map ) vars = JuMP.AbstractVariableRef[] _interrogate_variables(v -> push!(vars, v), func) @@ -1332,10 +1022,10 @@ _set_rhs(s::Union{_MOI.LessThan, _MOI.GreaterThan, _MOI.EqualTo}) = _MOI.constant(s) _set_rhs(::Any) = 0.0 -# Unwrap a 1-element per-support `Vector` to its scalar value; -# scalars pass through. `extract_solution` returns per-support -# `Vector`s uniformly (length-1 for finite, length-K for InfiniteOpt). -# AD pipelines and `set_start_value` need a scalar in the finite -# case; per-support consumers slice out a scalar themselves. +# Unwrap a 1-element `Vector` to its scalar value; scalars pass +# through. `extract_solution` returns `Vector`s uniformly (length-1 for +# a scalar variable, length-K for a vector-valued one). AD pipelines +# and `set_start_value` need a scalar in the scalar case; vector-valued +# consumers slice out a scalar themselves. _unwrap_scalar(v::Real) = v _unwrap_scalar(v::AbstractVector) = only(v) diff --git a/test/constraints/loa.jl b/test/constraints/loa.jl index 7a202264..e470bfbf 100644 --- a/test/constraints/loa.jl +++ b/test/constraints/loa.jl @@ -5,23 +5,23 @@ function test_loa_datatype() @test method.nlp_optimizer == HiGHS.Optimizer @test method.mip_optimizer == HiGHS.Optimizer @test method.max_iter == 10 - @test method.atol == 1e-6 - @test method.rtol == 1e-4 @test method.M_value == 1e9 @test method.max_slack == 1000.0 @test method.oa_penalty == 1000.0 + @test method.convergence_tol == 1e-6 + @test method.slack_tol == 1e-4 @test method.inner_method isa BigM @test method.inner_method.value == 1e9 - method = LOA(HiGHS.Optimizer; max_iter = 50, atol = 1e-8, - rtol = 1e-6, M_value = 1e6, max_slack = 500.0, - oa_penalty = 200.0) + method = LOA(HiGHS.Optimizer; max_iter = 50, M_value = 1e6, + max_slack = 500.0, oa_penalty = 200.0, + convergence_tol = 1e-4, slack_tol = 1e-3) @test method.max_iter == 50 - @test method.atol == 1e-8 - @test method.rtol == 1e-6 @test method.M_value == 1e6 @test method.max_slack == 500.0 @test method.oa_penalty == 200.0 + @test method.convergence_tol == 1e-4 + @test method.slack_tol == 1e-3 @test method.inner_method isa BigM @test method.inner_method.value == 1e6 @@ -75,16 +75,6 @@ function test_no_good_cut() @test num_cons_after == num_cons_before + 1 end -function test_loa_convergence_check() - method = LOA(HiGHS.Optimizer; atol = 1e-6, rtol = 1e-4) - - sense = Val(MOI.MIN_SENSE) - @test DP._loa_converged(1.0, 1.0, sense, method) == true - @test DP._loa_converged(1.0, 0.9999, sense, method) == true - @test DP._loa_converged(1.0, 0.5, sense, method) == false - @test DP._loa_converged(1e-8, 0.0, sense, method) == true -end - function test_loa_reformulate_simple() model = GDPModel(HiGHS.Optimizer) @variable(model, 0 <= x <= 10) @@ -193,8 +183,9 @@ end function test_loa_complement_indicator_nonlinear_disjunct() # Regression: complement-form indicators store `1 - y_base` (an # AffExpr) in `binary_map`. When the complement disjunct has a - # nonlinear constraint, `add_disjunct_oa_cuts` calls `cut_info` - # on that AffExpr; the AffExpr method must dispatch. + # nonlinear constraint, `add_disjunct_oa_cuts` feeds that AffExpr + # straight into the OA cut gating term `M(1 - binary)`, which must + # accept an AffExpr binary (not just a plain variable ref). # # Setup: 0 <= x <= 10, Y2 ≡ ¬Y1. # Disjunct(Y1): x <= 3 (linear) @@ -319,11 +310,119 @@ function test_loa_nlpf_infeasible_disjunct() @test value(Y[1]) ≈ 0.0 atol = 1e-6 end +function test_loa_sense_primitives() + # Sense-dispatched primitives the main loop reads through. The + # finite/infinite solve tests cover every branch except the + # minimize-sense gap (no minimizing model reaches a master bound in + # those tests), so pin all six here for both senses directly. + minv = Val(MOI.MIN_SENSE) + maxv = Val(MOI.MAX_SENSE) + @test DP._penalty_sign(minv) == 1 + @test DP._penalty_sign(maxv) == -1 + @test DP._worst_objective(minv) == Inf + @test DP._worst_objective(maxv) == -Inf + @test DP._is_better(minv, 3.0, 5.0) + @test !DP._is_better(minv, 5.0, 3.0) + @test DP._is_better(maxv, 5.0, 3.0) + @test !DP._is_better(maxv, 3.0, 5.0) + @test DP._gap(minv, 5.0, 3.0) == 2.0 + @test DP._gap(maxv, 3.0, 5.0) == 2.0 +end + +function test_loa_iteration_loop() + # Force the master/NLP refinement loop to run its body rather than + # exhaust the master on the seeds or converge on the first master + # solve. Two disjunctions over disjoint x/z half-lines put the + # optimum on an OFF-diagonal combination the set-covering seeds (the + # diagonal (Y1,W1) and (Y2,W2)) never try. After seeding, the master + # stays feasible and its bound beats the seed incumbent, so LOA + # enters the loop body, extracts the off-diagonal combination, solves + # its NLP, and updates the incumbent before converging. `Min` sense + # also drives the minimize-sense gap path. + # min x - z + # D1: (x <= 4) [Y1] v (x >= 6) [Y2] + # D2: (z <= 4) [W1] v (z >= 6) [W2] + # Optimum: Y1 & W2 -> x = 0, z = 10, obj = -10 (off-diagonal). + model = GDPModel(HiGHS.Optimizer) + set_silent(model) + @variable(model, 0 <= x <= 10) + @variable(model, 0 <= z <= 10) + @variable(model, Y[1:2], Logical) + @variable(model, W[1:2], Logical) + @constraint(model, x <= 4, Disjunct(Y[1])) + @constraint(model, x >= 6, Disjunct(Y[2])) + @disjunction(model, Y) + @constraint(model, z <= 4, Disjunct(W[1])) + @constraint(model, z >= 6, Disjunct(W[2])) + @disjunction(model, W) + @objective(model, Min, x - z) + optimize!(model, gdp_method = LOA(HiGHS.Optimizer)) + @test termination_status(model) == MOI.OPTIMAL + @test objective_value(model) ≈ -10.0 atol = 1e-4 + @test value(Y[1]) ≈ 1.0 atol = 1e-6 + @test value(W[2]) ≈ 1.0 atol = 1e-6 +end + +function test_loa_time_limits() + # Exercise the wall-clock budget paths. A finite `time_limit` + # (overall cap) drives `_cap_remaining_time` during the subproblem + # solves and the final-solve budget reset; a finite + # `iteration_time_limit` (loop-only budget) drives the post-loop + # time-limit restore from no prior limit (the `::Nothing` path). + # Both limits are generous: they govern the path taken, not the + # result. + function build_model() + model = GDPModel(HiGHS.Optimizer) + set_silent(model) + @variable(model, 0 <= x <= 10) + @variable(model, Y[1:2], Logical) + @constraint(model, x <= 3, Disjunct(Y[1])) + @constraint(model, x <= 7, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Max, x) + return model + end + + model = build_model() + optimize!(model, gdp_method = LOA(HiGHS.Optimizer; time_limit = 60.0)) + @test termination_status(model) == MOI.OPTIMAL + @test objective_value(model) ≈ 7.0 atol = 1e-4 + + model = build_model() + optimize!(model, + gdp_method = LOA(HiGHS.Optimizer; iteration_time_limit = 60.0)) + @test termination_status(model) == MOI.OPTIMAL + @test objective_value(model) ≈ 7.0 atol = 1e-4 +end + +function test_loa_no_feasible_incumbent() + # Every disjunct combination is NLP-infeasible: the global x == 20 + # contradicts the x <= 10 bound under any selection. NLPF does not + # slack equalities, so NLPF also fails (no values) and `_solve_nlp` + # returns its infeasible fallback. No seed or loop NLP yields a + # feasible incumbent, so `best_result` stays `nothing`, the master + # is infeasible (no bound), and LOA exits on the "no feasible + # incumbent" warning from `_report_loa_gap`. + model = GDPModel(HiGHS.Optimizer) + set_silent(model) + @variable(model, 0 <= x <= 10) + @constraint(model, x == 20) + @variable(model, Y[1:2], Logical) + @constraint(model, x <= 3, Disjunct(Y[1])) + @constraint(model, x <= 7, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Max, x) + method = LOA(HiGHS.Optimizer) + @test_logs (:warn, r"no feasible incumbent") match_mode = :any begin + DP.reformulate_model(model, method) + end + @test DP._ready_to_optimize(model) +end + @testset "LOA" begin test_loa_datatype() test_set_covering_combos() test_no_good_cut() - test_loa_convergence_check() test_loa_reformulate_simple() test_loa_solve_simple() test_loa_solve_simple_with_mbm() @@ -336,4 +435,8 @@ end test_linearize_nonlinear_sin() test_linearize_nonlinear_multivar() test_to_nlp_expr() + test_loa_sense_primitives() + test_loa_iteration_loop() + test_loa_time_limits() + test_loa_no_feasible_incumbent() end diff --git a/test/extensions/InfiniteDisjunctiveProgramming.jl b/test/extensions/InfiniteDisjunctiveProgramming.jl index c5df7165..707833fa 100644 --- a/test/extensions/InfiniteDisjunctiveProgramming.jl +++ b/test/extensions/InfiniteDisjunctiveProgramming.jl @@ -828,8 +828,8 @@ function test_loa_infinite_complement_indicator() # # Linear disjuncts only — a nonlinear disjunct constraint on an # infinite variable would also hit a separate gap where - # `cut_info` for a finite indicator does not slice the per-support - # linearization point. Out of scope for this regression. + # `_infinite_cut_info` for a finite indicator does not slice the + # per-support linearization point. Out of scope for this regression. # # max ∫ x dt with 0 ≤ x ≤ 10 over t ∈ [0,1]: # Y1: x ≤ 3 Y2 ≡ ¬Y1: x ≤ 8 @@ -855,7 +855,7 @@ end function test_loa_infinite_complement_nonlinear_disjunct() # Regression: a finite (or complement-form AffExpr) indicator # gating a NONLINEAR constraint on an infinite variable. Earlier - # `cut_info` decided fan-out from the indicator only — finite + # `_infinite_cut_info` decided fan-out from the indicator only — finite # indicator → single un-sliced site → `_linearize_at` received # per-support `Vector{Float64}` values and tripped on # `_unwrap_scalar`'s `only(v)`. Fixed by inspecting the constraint @@ -917,14 +917,18 @@ function test_loa_infinite_multidim_parameter() end function test_loa_infinite_nlpf_infeasible_disjunct() - # InfiniteOpt LOA: Y[1]'s per-support constraint x^2 >= 200 is - # NLP-infeasible against the bound x in [0, 10] (max x^2 = 100). - # With infinite indicators `Y[1:2], InfiniteLogical(t)`, the - # combination value is `Vector{Bool}` — exercising the extension - # override `_nlpf_fix_on_copy(::InfiniteModel, - # ::GeneralVariableRef, ::AbstractVector{Bool})` that pins each - # support via point-equality. LOA must still converge to Y[2] - # active everywhere (x = 5), giving objective ∫x dt = 5. + # InfiniteOpt LOA: Y[1]'s per-support constraint x >= 200 is + # NLP-infeasible against the bound x in [0, 10]. With infinite + # indicators `Y[1:2], InfiniteLogical(t)`, the infeasible + # Y[1]-everywhere seed makes the primary NLP fail, so NLPF runs: it + # copies the model, re-pins the binaries on the copy via + # `nlpf_fix_on_copy`, slacks the inequality, and returns a + # linearization point. The disjunct is deliberately LINEAR: + # the model is then convex, so LOA's master bound is rigorous and + # it converges to Y[2] active everywhere (x = 5), giving ∫x dt = 5. + # (A nonconvex infeasible disjunct such as x^2 >= 200 would make LOA + # a heuristic and let the local NLP solver report a constraint- + # violating point as solved — out of scope for the NLPF path here.) ipopt = optimizer_with_attributes(Ipopt.Optimizer, "print_level" => 0, "sb" => "yes") juniper = optimizer_with_attributes(Juniper.Optimizer, @@ -934,7 +938,7 @@ function test_loa_infinite_nlpf_infeasible_disjunct() @infinite_parameter(model, t ∈ [0, 1], num_supports = 3) @variable(model, 0 <= x <= 10, Infinite(t)) @variable(model, Y[1:2], InfiniteLogical(t)) - @constraint(model, x^2 >= 200, Disjunct(Y[1])) + @constraint(model, x >= 200, Disjunct(Y[1])) @constraint(model, x <= 5, Disjunct(Y[2])) @disjunction(model, Y) @objective(model, Max, ∫(x, t)) @@ -974,6 +978,38 @@ function test_loa_infinite_aggregate_global() @test objective_value(model) ≈ 1.0 atol = 1e-2 end +function test_loa_infinite_iteration_loop() + # Force the InfiniteModel LOA main loop to fix per-support + # Vector{Bool} combinations, exercising the in-place fix-constraint + # stash: create on the first main-loop fix, update via + # set_normalized_rhs on the committed re-fix. Two disjunctions over + # disjoint x/z half-lines put the optimum on an off-diagonal + # combination the scalar set-covering seeds never try, so the master + # stays feasible and the loop body runs with per-support values. + # min ∫(x - z) dt + # D1: (x <= 4) [Y1] ∨ (x >= 6) [Y2] + # D2: (z <= 4) [W1] ∨ (z >= 6) [W2] + # Optimum: Y1 & W2 everywhere → x = 0, z = 10, ∫(x - z) = -10. + model = InfiniteGDPModel(HiGHS.Optimizer) + set_silent(model) + @infinite_parameter(model, t ∈ [0, 1], num_supports = 3) + @variable(model, 0 <= x <= 10, Infinite(t)) + @variable(model, 0 <= z <= 10, Infinite(t)) + @variable(model, Y[1:2], InfiniteLogical(t)) + @variable(model, W[1:2], InfiniteLogical(t)) + @constraint(model, x <= 4, Disjunct(Y[1])) + @constraint(model, x >= 6, Disjunct(Y[2])) + @disjunction(model, Y) + @constraint(model, z <= 4, Disjunct(W[1])) + @constraint(model, z >= 6, Disjunct(W[2])) + @disjunction(model, W) + @objective(model, Min, ∫(x - z, t)) + optimize!(model, gdp_method = LOA(HiGHS.Optimizer)) + @test termination_status(model) in + (MOI.OPTIMAL, MOI.LOCALLY_SOLVED) + @test objective_value(model) ≈ -10.0 atol = 1e-3 +end + @testset "InfiniteDisjunctiveProgramming" begin @testset "Model" begin @@ -1051,6 +1087,7 @@ end test_loa_infinite_complement_nonlinear_disjunct() test_loa_infinite_multidim_parameter() test_loa_infinite_nlpf_infeasible_disjunct() + test_loa_infinite_iteration_loop() else @info "Skipping InfiniteOpt LOA tests: " * "JuMP.copy_model(::InfiniteModel) unavailable " * From 6a9d6e0ac9fbef647e9fcc811f89bceb74016fa4 Mon Sep 17 00:00:00 2001 From: d227nguyen Date: Sun, 28 Jun 2026 16:15:51 -0400 Subject: [PATCH 56/59] Reduce functions in loa workflow --- ext/InfiniteDisjunctiveProgramming.jl | 340 +++++------ src/datatypes.jl | 6 + src/hull.jl | 2 + src/loa.jl | 539 ++++++++++-------- src/model.jl | 1 + src/reformulate.jl | 1 + test/constraints/loa.jl | 119 +++- .../InfiniteDisjunctiveProgramming.jl | 107 +++- 8 files changed, 663 insertions(+), 452 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 260cbf3e..b155ee26 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -341,9 +341,9 @@ _per_support_values(variable::InfiniteOpt.GeneralVariableRef) = # Read per-support values from the transformation backend, keyed by # InfiniteOpt vars. Skips fixed vars. The objective-side translation -# (transcribe-then-AD when the objective has aggregate refs) lives -# in the `add_oa_cuts(::InfiniteModel, ...)` override below — base -# `extract_solution` doesn't need to anticipate it. +# (transcribe-then-AD when the objective has aggregate refs) lives in +# the `_objective_linearization_point(::InfiniteModel, ...)` override +# below — base `extract_solution` doesn't need to anticipate it. function DP.extract_solution(model::InfiniteOpt.InfiniteModel) return Dict(var => _per_support_values(var) for var in DP.collect_all_vars(model) @@ -603,34 +603,19 @@ function _at_support( return result end -# Build map: transcribed input JuMP var → master point variable. -# For an infinite input var v, every transcribed support `v_k` -# maps to `ref_map[v](d_k)` (master point variable). Used as -# `objective_ref_map` so the objective OA cut can linearize the -# (already flat) transcribed objective and land in master point -# variables. -# -# Why the transcribe-then-AD detour exists: MOI Nonlinear AD has -# no walker for `InfiniteOpt.MeasureRef`, so an objective like -# `∫(f(z, t), t)` cannot be differentiated directly. The objective -# OA cut therefore transcribes the input model once, ADs the -# resulting flat scalar objective, and uses this map to translate -# the gradient back into master point variables. Per-support -# DISJUNCT cuts do NOT need this — they linearize natively in -# InfiniteModel space via `_infinite_cut_info`. If the objective has no -# measures, this whole layer is dead weight; killing it would -# require either banning measure objectives or hand-writing a -# `_linearize_at` that walks `MeasureRef` symbolically. +# Map transcribed input JuMP var → master point variable, used as +# `objective_ref_map`. Needed because MOI AD can't walk a `MeasureRef`, +# so a measure objective is transcribed, AD'd flat, and the gradient +# mapped back to master point vars. Per-support disjunct cuts skip this +# (they linearize natively via `_infinite_cut_info`). function _transcribed_to_master_point( model::InfiniteOpt.InfiniteModel, ref_map::AbstractDict ) result = Dict{JuMP.VariableRef, InfiniteOpt.GeneralVariableRef}() for v in DP.collect_all_vars(model) - # Point vars share their transcribed instance with the - # underlying infinite var's per-support transcription, so - # skipping them here doesn't lose any transcribed→master - # mappings. + # Point vars share the infinite var's transcription, so skipping + # them loses no mappings. _is_point_var(v) && continue master_var = ref_map[v] transcribed = InfiniteOpt.transformation_variable(v) @@ -638,12 +623,9 @@ function _transcribed_to_master_point( if isempty(prefs) result[transcribed] = master_var else - # `_supports_of` returns scalars for 1-D parameters and - # vectors for multi-D dependent groups; `_at_support` - # routes both shapes through `v(support)`. Using - # `vec(InfiniteOpt.supports(...))` here would silently - # flatten a multi-D matrix into scalars and break point - # evaluation on dependent parameter groups. + # `_supports_of`/`_at_support` handle 1-D (scalar) and + # multi-D (vector) supports; `vec(supports(...))` would + # flatten multi-D and break point evaluation. for (k, support) in enumerate(_supports_of(v)) result[vec(transcribed)[k]] = _at_support(master_var, support) @@ -653,33 +635,19 @@ function _transcribed_to_master_point( return result end -# Build the LOA master from `JuMP.copy_model(model)` and strip the -# nonlinear constraints (they re-enter via OA cuts). `binary_map[ -# indicator]` and `variable_map[v]` hold single InfiniteOpt vars on -# the master; per-support handling happens downstream via point -# evaluation on those refs. OA cuts added in the LOA loop are -# point-evaluated scalar constraints on the master InfiniteModel; -# transcription is rebuilt before each master solve. -# -# Objective handling branches on `_has_aggregate_ref`. When the -# objective is aggregate-free (no MeasureRef / ParameterFunctionRef), -# `original_objective` is the InfiniteOpt objective itself and -# `objective_ref_map = ref_map`, so AD walks the InfiniteOpt -# expression directly. When it contains an aggregate, AD cannot see -# inside it; we fall back to transcribing the input model and using -# the flat scalar objective with a transcribed-to-master point map. +# Build the master from `JuMP.copy_model(model)`, keeping linear +# constraints and the InfiniteOpt vars (per-support handling is done +# downstream by point evaluation). The objective branches on +# `_has_aggregate_ref`: aggregate-free objectives AD the InfiniteOpt +# expression directly; aggregate ones are transcribed flat and mapped +# back via `_transcribed_to_master_point`. function DP.build_loa_master( model::InfiniteOpt.InfiniteModel, method::DP.LOA )::DP._LOAMaster - # Linear, non-aggregate constraints stay on the master. Nonlinear - # constraints — and aggregate-wrapped affine ones like - # `∫(x^2,t) ≤ c`, which read linear via `_is_linear_F` over a - # `MeasureRef` but expand to a nonlinear form on transcription — - # re-enter as OA cuts after each NLP solve, so they are dropped - # at copy time instead of copied then deleted. Variable bounds - # proper live on VariableInfo and survive `copy_model` regardless; - # the `F === GeneralVariableRef` early-return covers - # variable-ref-as-constraint registrations. + # Keep only linear, non-aggregate constraints. Nonlinear and + # aggregate-wrapped ones (e.g. `∫(x^2,t) ≤ c`) re-enter as OA cuts, + # so they are dropped at copy time. Variable bounds survive on + # VariableInfo regardless. variable_type = InfiniteOpt.GeneralVariableRef master, copy_ref_map = JuMP.copy_model( model; @@ -742,32 +710,60 @@ function DP.build_loa_master( variable_map[v] = ref_map[v] end - # Aggregate-wrapped LINEAR constraints (e.g. `𝔼(W, ξ) ≥ α`) - # were filtered out at `copy_model` time because `copy_model` - # cannot transfer MeasureRefs across InfiniteModels. The - # nonlinear-aggregate path re-adds them as OA cuts after each - # NLP solve, but `_add_global_oa_cuts_infinite` short-circuits - # on linear `F` — so without this step a linear chance - # constraint is missing from the master entirely, and the - # master will pick combinations that violate it. Transcribe - # each such constraint once and add the flat scalar form to - # the master directly. + # Aggregate-wrapped LINEAR constraints (e.g. `𝔼(W, ξ) ≥ α`) were + # dropped at copy time and the OA-cut path skips linear `F`, so add + # each one to the master directly as a transcribed flat scalar. _add_aggregate_linear_constraints( master, model, ref_map, variable_type) return DP._LOAMaster(master, binary_map, variable_map, objective_sense, original_objective, alpha_oa, - objective_ref_map) + objective_ref_map, DP._build_disaggregator( + model, method.inner_method, variable_map, binary_map)) end -# Walk the original model's linear-`F` constraints, transcribe those -# containing a `MeasureRef` / `ParameterFunctionRef`, and append the -# resulting flat scalar constraint to the master. No linearization -# needed (the constraint is already affine post-transcription) — -# `_linearize_at` on an `AffExpr` just substitutes variables via -# `transcribed_to_master`. Reuses the transformation backend that -# `_add_global_oa_cuts_infinite` will rebuild later; building it -# now is cheap and idempotent. +# Record one master-space disaggregation for `InfiniteModel`. The Hull +# cut emitter runs through the per-support fan-out over point-evaluated +# variables, so key the map by `(point variable, point binary)` at each +# support — `disaggregate_expression` then substitutes the point +# disaggregated variable per support. Finite (parameter-free) entries +# key once, unsliced. +function DP._record_disaggregation( + hull::DP._Hull, + ::InfiniteOpt.InfiniteModel, + variable, + binary, + disaggregated + ) + supports = _disaggregator_supports(variable, binary) + if supports === nothing + hull.disjunct_variables[(variable, binary)] = disaggregated + else + for support in supports + hull.disjunct_variables[(_at_support(variable, support), + _at_support(binary, support))] = + _at_support(disaggregated, support) + end + end + return +end + +# Supports the per-support fan-out gates this disjunct on (mirrors +# `_relevant_supports`): the binary's if the indicator is infinite, else +# the variable's if it is infinite, else `nothing` (both finite, one +# unsliced key). Keying off the binary lets a finite variable under an +# infinite indicator match the sliced binary the emitter looks up. +function _disaggregator_supports(master_var, master_bin) + binary_var = _find_infinite_var(master_bin) + binary_var === nothing || return _supports_of(binary_var) + isempty(InfiniteOpt.parameter_refs(master_var)) && return nothing + return _supports_of(master_var) +end + +# Transcribe each linear constraint containing a `MeasureRef` / +# `ParameterFunctionRef` and append the flat scalar form to the master. +# Already affine post-transcription, so `_linearize_at` just substitutes +# variables via `transcribed_to_master`. function _add_aggregate_linear_constraints( master::InfiniteOpt.InfiniteModel, model::InfiniteOpt.InfiniteModel, @@ -790,14 +786,11 @@ function _add_aggregate_linear_constraints( InfiniteOpt.build_transformation_backend!(model) transcribed_to_master = _transcribed_to_master_point(model, ref_map) - # `_transcribed_to_master_point` only walks decision vars. Finite - # parameters (`@finite_parameter`) survive transcription as - # scalar JuMP variables and can appear in transcribed - # constraints (e.g. the `α` on the RHS of an event constraint). - # Map each transcribed-parameter JuMP var to the master's - # corresponding parameter so the constraint stays - # parameter-relative (the master then honors `set_value(α, ...)` - # without rebuild). + # Finite parameters survive transcription as scalar JuMP vars and can + # appear in transcribed constraints, but `_transcribed_to_master_point` + # only walks decision vars. Map them to the master's parameters so the + # constraint stays parameter-relative (honors `set_value` without + # rebuild). for p in InfiniteOpt.all_parameters(model) InfiniteOpt.dispatch_variable_ref(p) isa InfiniteOpt.FiniteParameterRef || continue @@ -834,50 +827,34 @@ function _add_aggregate_linear_constraints( return end -# Override `add_oa_cuts` for `InfiniteModel`: translate the -# linearization point into the form the master's `original_objective` -# expects, and route global OA cuts through transcription so they -# work over infinite vars and aggregate refs. The base -# `result.linearization_point` has per-support `Vector` values keyed -# on InfiniteOpt vars; the master's objective is either the raw -# InfiniteOpt expression (non-aggregate, expects scalar `xk[v]`) or -# the transcribed flat scalar expression (aggregate, expects -# transcribed-`JuMP.VariableRef`-keyed scalar dict). The translation -# produces whichever shape `_linearize_at` needs. -function DP.add_oa_cuts( +# Objective linearization point for `InfiniteModel` (the seam the shared +# base `add_oa_cuts` calls). The master objective is either the raw +# InfiniteOpt expression (non-aggregate, expects a scalar `xk[v]` keyed +# on InfiniteOpt vars) or the transcribed flat scalar expression +# (aggregate, expects a transcribed-`JuMP.VariableRef`-keyed scalar +# dict). Translate the base per-support `Vector`-valued point into +# whichever shape `original_objective` needs. +function DP._objective_linearization_point( model::InfiniteOpt.InfiniteModel, - master::DP._LOAMaster, - result::NamedTuple, - method::DP.LOA + linearization_point::AbstractDict ) - isempty(result.linearization_point) && return - obj_point = if _has_aggregate_ref(JuMP.objective_function(model)) - _transcribe_linearization_point( - model, result.linearization_point) - else - T = eltype(valtype(result.linearization_point)) - Dict{InfiniteOpt.GeneralVariableRef, T}( - var => values[1] - for (var, values) in result.linearization_point - if isempty(InfiniteOpt.parameter_refs(var))) - end - linearization = DP._linearize_at(master.original_objective, - obj_point, master.objective_ref_map) - DP._add_objective_cut( - Val(master.objective_sense), master, linearization, method) - _add_global_oa_cuts_infinite(model, master, result, method) - DP.add_disjunct_oa_cuts(model, master, result, method) - return + _has_aggregate_ref(JuMP.objective_function(model)) && + return _transcribe_linearization_point(model, linearization_point) + T = eltype(valtype(linearization_point)) + return Dict{InfiniteOpt.GeneralVariableRef, T}( + var => values[1] + for (var, values) in linearization_point + if isempty(InfiniteOpt.parameter_refs(var))) end -# Global OA cuts for `InfiniteModel`. Mirrors base `_add_global_oa_cuts` -# but routes through transcription so per-support / aggregate-ref -# expressions reach `_linearize_at` as flat scalars over `JuMP.VariableRef`s. -# Aggregate-containing constraints (e.g. `∫f(x,t)dt ≤ 0`) transcribe -# to a single scalar expression. Constraints with infinite-parameter -# dependence (e.g. `x(t) ≥ 0`) transcribe to a per-support `AbstractArray` -# of scalar expressions — one OA cut is emitted per support. -function _add_global_oa_cuts_infinite( +# Global OA cuts for `InfiniteModel`: route through transcription so +# per-support / aggregate-ref expressions reach `_linearize_at` as flat +# scalars over `JuMP.VariableRef`s. Aggregate-containing constraints +# (e.g. `∫f(x,t)dt ≤ 0`) transcribe to a single scalar expression. +# Constraints with infinite-parameter dependence (e.g. `x(t) ≥ 0`) +# transcribe to a per-support `AbstractArray` of scalar expressions — +# one OA cut is emitted per support. +function DP._add_global_oa_cuts( model::InfiniteOpt.InfiniteModel, master::DP._LOAMaster, result::NamedTuple, @@ -926,86 +903,56 @@ function _add_global_oa_cuts_infinite( return end -# Override `add_disjunct_oa_cuts` for `InfiniteModel`. Same shape as -# the base loop, but each constraint is checked for aggregate refs. -# Aggregate constraints (e.g. those containing a `MeasureRef`) are -# transcribed via `InfiniteOpt.transformation_expression`, then -# handed back to the base `_add_oa_cut_for_constraint` as a regular -# `JuMP.ScalarConstraint` over flat `JuMP.VariableRef`s — which the -# base AD pipeline can linearize correctly. -# -# `transcribed_to_master` and `transcribed_xk` are built lazily once -# per `add_disjunct_oa_cuts` call and shared across all aggregate -# constraints in the iteration. -function DP.add_disjunct_oa_cuts( +# Per-constraint OA cut emission for `InfiniteModel` (the seam the base +# `add_disjunct_oa_cuts` driver calls). Non-aggregate constraints fan out +# per support via `_infinite_cut_info`; aggregate ones (`MeasureRef`) are +# transcribed flat and handed to the base `_add_oa_cut_for_constraint`. +# Transcription is rebuilt per aggregate constraint, not cached — a rare +# path (Hull errors on it), so the redundancy is a non-issue. +function DP._add_disjunct_constraint_oa_cuts( model::InfiniteOpt.InfiniteModel, + constraint::JuMP.AbstractConstraint, master::DP._LOAMaster, + binary_ref, + active, result::NamedTuple, - method::DP.LOA + method::DP.LOA, + penalty_sign::Int ) - penalty_sign = DP._penalty_sign(Val(master.objective_sense)) - transcribed_to_master = Ref{Any}(nothing) - transcribed_xk = Ref{Any}(nothing) - ensure_transcribed = function () - transcribed_to_master[] === nothing || return + if _has_aggregate_ref(constraint.func) InfiniteOpt.build_transformation_backend!(model) - transcribed_to_master[] = _transcribed_to_master_point( - model, master.variable_map) - transcribed_xk[] = _transcribe_linearization_point( + transcribed_func = InfiniteOpt.transformation_expression( + constraint.func) + transcribed_constraint = JuMP.ScalarConstraint( + transcribed_func, constraint.set) + transcribed_xk = _transcribe_linearization_point( model, result.linearization_point) + transcribed_to_master = _transcribed_to_master_point( + model, master.variable_map) + for (point_binary, _, _) in _infinite_cut_info(binary_ref, + active, transcribed_func, result.linearization_point, + master.variable_map) + DP._add_oa_cut_for_constraint(transcribed_constraint, master, + point_binary, transcribed_xk, transcribed_to_master, + method, penalty_sign) + end return end - for (indicator, active) in result.combination - DP.is_active(active) || continue - haskey(DP._indicator_to_constraints(model), indicator) || - continue - for orig_constraint_ref in - DP._indicator_to_constraints(model)[indicator] - orig_constraint_ref isa DP.DisjunctConstraintRef || - continue - constraint = DP._disjunct_constraints(model)[ - JuMP.index(orig_constraint_ref)].constraint - if _has_aggregate_ref(constraint.func) - ensure_transcribed() - transcribed_func = - InfiniteOpt.transformation_expression( - constraint.func) - transcribed_constraint = JuMP.ScalarConstraint( - transcribed_func, constraint.set) - for (binary_ref, _, _) in _infinite_cut_info( - master.binary_map[indicator], active, - transcribed_func, result.linearization_point, - master.variable_map) - DP._add_oa_cut_for_constraint( - transcribed_constraint, master, binary_ref, - transcribed_xk[], transcribed_to_master[], - method, penalty_sign) - end - continue - end - for (binary_ref, linearization_point, var_map) in - _infinite_cut_info( - master.binary_map[indicator], active, - constraint.func, result.linearization_point, - master.variable_map) - DP._add_oa_cut_for_constraint( - constraint, master, binary_ref, - linearization_point, var_map, method, penalty_sign) - end - end + for (point_binary, point, var_map) in _infinite_cut_info(binary_ref, + active, constraint.func, result.linearization_point, + master.variable_map) + DP._add_oa_cut_for_constraint(constraint, master, point_binary, + point, var_map, method, penalty_sign) end + return end -# Apply per-indicator fixes for `combination` and return a closure that -# reverses them. A scalar (`Bool`) value delegates to base -# `fix_indicator` (force-fix, which unwraps complement-form `1 - y` -# AffExprs). A per-support `AbstractVector{Bool}` value pins each support -# of the underlying infinite binary with a point-equality constraint. -# The returned closure deletes those constraints and unfixes, so the -# extension's `with_fixed_combination` can tear the fix down each -# iteration — a stale per-support pin would clash with the next -# combination. `commit_combination` ignores the closure, persisting the -# committed fix. +# Apply the combination's fixes and return a closure that reverses them. +# A `Bool` delegates to base `fix_indicator`; a per-support `Vector{Bool}` +# pins each support of the infinite binary with a point-equality +# constraint. The closure (used by `with_fixed_combination` to tear down +# each iteration) deletes those pins and unfixes; `commit_combination` +# ignores it to persist the committed fix. function DP.fix_combination( model::InfiniteOpt.InfiniteModel, combination::AbstractDict ) @@ -1039,14 +986,9 @@ function DP.fix_combination( end end -# OVERRIDE for InfiniteModel: fix the combination, run `f()`, then -# always undo. The finite base fixes in place and needs no teardown, but -# the infinite path pins per-support combinations with point-equality -# constraints (and scalar seeds with `JuMP.fix`) that must be cleared -# between iterations — a stale per-support pin would clash with the next -# combination — so this lifecycle needs the `try/finally` the base -# omits. The model is relaxed once in `reformulate_model`, so there is -# no relax/unrelax here. +# Fix the combination, run `f()`, then always undo. The infinite path's +# per-support point-equality pins must be cleared between iterations, so +# this needs the `try/finally` the finite base omits. function DP.with_fixed_combination( f, model::InfiniteOpt.InfiniteModel, combination::AbstractDict ) diff --git a/src/datatypes.jl b/src/datatypes.jl index 6abf23c0..14b96ab0 100644 --- a/src/datatypes.jl +++ b/src/datatypes.jl @@ -596,6 +596,11 @@ mutable struct GDPData{M <: JuMP.AbstractModel, V <: JuMP.AbstractVariableRef, C reformulation_variables::Vector{V} reformulation_constraints::Vector{C} + # Hull disaggregated variable for each (original variable, indicator). + # Recorded so logic-based OA can rebuild convex-hull cuts after the + # reformulation's temporary `_Hull` disaggregation state is discarded. + disaggregations::Dict{Tuple{V, LogicalVariableRef{M}}, V} + # Solution data solution_method::Union{Nothing, AbstractSolutionMethod} ready_to_optimize::Bool @@ -614,6 +619,7 @@ mutable struct GDPData{M <: JuMP.AbstractModel, V <: JuMP.AbstractVariableRef, C Dict{V, Tuple{T, T}}(), Vector{V}(), Vector{C}(), + Dict{Tuple{V, LogicalVariableRef{M}}, V}(), nothing, false, ) diff --git a/src/hull.jl b/src/hull.jl index 779369e9..a91a9d65 100644 --- a/src/hull.jl +++ b/src/hull.jl @@ -42,6 +42,8 @@ function _disaggregate_variable( #temp storage push!(method.disjunction_variables[vref], dvref) method.disjunct_variables[vref, bvref] = dvref + #persist for logic-based OA, which needs the map after reformulation + _disaggregations(model)[(vref, lvref)] = dvref #create bounding constraints dvname = JuMP.name(dvref) lbname = isempty(dvname) ? "" : "$(dvname)_lower_bound" diff --git a/src/loa.jl b/src/loa.jl index f03dcdb7..ef41b127 100644 --- a/src/loa.jl +++ b/src/loa.jl @@ -1,16 +1,9 @@ ################################################################################ # LOGIC-BASED OUTER APPROXIMATION (LOA) ################################################################################ -# Türkay & Grossmann (1996), Comp. & Chem. Eng. 20(8), 959-978 -# With augmented-penalty OA master from: -# Viswanathan & Grossmann (1990), Comp. & Chem. Eng. 14(7), 769-782 -# -# Infeasible primary NLPs fall through to NLPF (V&G 1990 eq. 8): a -# slacked feasibility version of the same problem whose primal becomes -# the linearization site for OA cuts. A no-good cut still forbids that -# combination on the master. NLPF is bypassed for vector-valued -# `Vector{Bool}` combinations (which an extension may supply) — those -# fall back to no-good-only. +# Iterate a primary NLP (inner-method reformulation, binaries fixed) and a +# master MILP accumulating OA and no-good cuts. Infeasible NLPs fall +# through to a slacked feasibility problem (NLPF) for the cut site. ################################################################################ ################################################################################ @@ -19,41 +12,25 @@ """ LOA{O, P, R, T} <: AbstractReformulationMethod -Logic-based Outer Approximation solver for GDP models. Iterates between a -primary NLP (the original model reformulated by `inner_method` with -indicator binaries fixed per iteration) and a master MILP that -accumulates outer-approximation and no-good cuts. - -`inner_method` defaults to `BigM(M_value)`. `MBM(optimizer)` is also -supported. Other reformulations (`Hull`, `PSplit`) are not yet supported. +Logic-based Outer Approximation solver for GDP models. Iterates a primary +NLP (original model reformulated by `inner_method`, binaries fixed per +iteration) and a master MILP accumulating OA and no-good cuts. +`inner_method` is `BigM` (default), `MBM`, or `Hull`. ## Fields -- `nlp_optimizer::O`: Solver used for the primary NLP. -- `mip_optimizer::P`: Solver used for the master MILP (defaults to - `nlp_optimizer`). -- `inner_method::R`: Reformulation applied to the primary NLP — `BigM` - or `MBM`. -- `max_iter::Int`: Maximum LOA iterations after set-covering seeding. -- `M_value::T`: Big-M used in the disjunct OA cut gating term. -- `max_slack::T`: Upper bound for each per-cut slack variable. -- `oa_penalty::T`: V&G 1990 penalty coefficient applied to slacks in - the master objective. -- `convergence_tol::Float64`: Relative gap tolerance for the early - stop. The main loop exits once the master bound meets the incumbent - within this relative gap, provided the slacks also pass `slack_tol`. -- `slack_tol::Float64`: Largest total OA-cut slack (summed slack - variables) for which the master bound still counts as a valid - convergence certificate. Positive slack means the master is - violating its own cuts (the nonconvex case), so the bound is not - trustworthy and the loop keeps running to `max_iter` rather than - stopping on a spurious crossing. -- `iteration_time_limit::Float64`: Wall-clock budget (seconds) for the - LOA iteration loop (seeds + main loop). `Inf` (default) is no limit; - each subproblem solve is capped to the budget left so one solve can't - overrun it. -- `time_limit::Float64`: Overall wall-clock cap (seconds) covering the - iteration loop AND the final committed solve. `Inf` (default) is no - cap; when set, the final solve gets only the budget left under it. +- `nlp_optimizer::O`: solver for the primary NLP. +- `mip_optimizer::P`: solver for the master MILP (default `nlp_optimizer`). +- `inner_method::R`: NLP reformulation — `BigM`, `MBM`, or `Hull`. +- `max_iter::Int`: max iterations after set-covering seeding. +- `M_value::T`: big-M for the disjunct OA cut gating term. +- `max_slack::T`: upper bound per slack variable. +- `oa_penalty::T`: penalty on slacks in the master objective. +- `convergence_tol::Float64`: relative gap tolerance for the early stop. +- `slack_tol::Float64`: max total slack for which the bound still counts + as converged (positive slack = nonconvex crossing, keep iterating). +- `iteration_time_limit::Float64`: budget (s) for the iteration loop. +- `time_limit::Float64`: overall budget (s) incl. the final solve + (default 3600; `Inf` disables). """ struct LOA{O, P, R, T} <: AbstractReformulationMethod nlp_optimizer::O @@ -78,11 +55,11 @@ struct LOA{O, P, R, T} <: AbstractReformulationMethod convergence_tol::Float64 = 1e-6, slack_tol::Float64 = 1e-4, iteration_time_limit::Float64 = Inf, - time_limit::Float64 = Inf + time_limit::Float64 = 3600.0 ) where {O, P, R <: AbstractReformulationMethod, T} - R <: Union{BigM, MBM} || error( - "LOA inner_method must be BigM or MBM (got $R). " * - "Hull and PSplit are not yet supported.") + R <: Union{BigM, MBM, Hull} || error( + "LOA inner_method must be BigM, MBM, or Hull (got $R). " * + "PSplit is not yet supported.") new{O, P, R, T}(nlp_optimizer, mip_optimizer, inner_method, max_iter, M_value, max_slack, oa_penalty, convergence_tol, slack_tol, @@ -93,16 +70,11 @@ end ################################################################################ # LOA MASTER ################################################################################ -# TODO: Move `_LOAMaster` to `src/datatypes.jl` alongside `GDPSubmodel` -# once the LOA API stabilizes. Keeping it co-located with the algorithm -# while the field set is still in flux. -# -# Bundles the LOA master MILP and the maps the algorithm needs to -# translate original-model refs into master-model refs. -# `objective_ref_map` is split from `variable_map` so an extension can -# map the objective's linearization variables differently from the -# constraint ones; in base the two maps are identical. -mutable struct _LOAMaster{M <: JuMP.AbstractModel, OF, AO, BM, VM, RM} +# The LOA master MILP plus maps from original- to master-model refs. +# `objective_ref_map` splits from `variable_map` so an extension can map +# objective vars separately (identical in base); `disaggregator` is the +# master `_Hull` for Hull cuts, `nothing` for Big-M / MBM. +mutable struct _LOAMaster{M <: JuMP.AbstractModel, OF, AO, BM, VM, RM, DG} model::M binary_map::BM variable_map::VM @@ -110,6 +82,7 @@ mutable struct _LOAMaster{M <: JuMP.AbstractModel, OF, AO, BM, VM, RM} original_objective::OF alpha_oa::AO objective_ref_map::RM + disaggregator::DG end ################################################################################ @@ -128,23 +101,18 @@ _gap(::Val{_MOI.MAX_SENSE}, best, bound) = bound - best ################################################################################ # MAIN ALGORITHM ################################################################################ -# LOA reformulation entry point: set-covering seeds, the master/NLP -# iteration loop, commit the best combination, and mark the model ready. -# This driver is model-agnostic — extensions override the inner solve -# steps (master build, NLP, cuts), not this loop. +# LOA entry point: set-covering seeds, the master/NLP loop, commit the +# best combination. Model-agnostic — extensions override the inner steps. function reformulate_model(model::JuMP.AbstractModel, method::LOA) _clear_reformulations(model) combinations = _set_covering_combinations(model) reformulate_model(model, method.inner_method) master = build_loa_master(model, method) - # Relax the original model's logical binaries once, permanently. The - # original model is only ever solved as the NLP (never as a MILP — - # the master is a separate copy), so its ZeroOne is never used. - # Stripping it here lets a pure NLP solver handle every solve and - # makes per-iteration fixing overwrite in place (no relax/unrelax, - # no fix/undo). Must follow build_loa_master so the master keeps its - # ZeroOne binaries. + # Relax the original's binaries once (it is only ever the NLP, never a + # MILP), so a pure NLP solver handles every solve and per-iteration + # fixing overwrites in place. After build_loa_master, which keeps the + # master's binaries. relax_logical_vars(model) JuMP.set_optimizer(model, method.nlp_optimizer) JuMP.set_silent(model) @@ -158,12 +126,9 @@ function reformulate_model(model::JuMP.AbstractModel, method::LOA) best_result = nothing master_bound = nothing - # Initialization Procedure (Türkay & Grossmann 1996, sec. 2.2): - # solve K set-covering NLPs with cycling indicator combinations to - # seed the master with at least one OA cut per disjunct. - # `previous_result` carries the most recent FEASIBLE NLP primal - # forward as the warm start for the next NLP (T&G §2.3); NLPF - # solutions are skipped since their primal is slack-distorted. + # Seed the master with one OA cut per disjunct via the set-covering + # NLPs. `previous_result` warm-starts the next NLP from the last + # feasible primal (NLPF primals are slack-distorted, so skipped). previous_result = nothing for combination in combinations time() < loop_deadline || break @@ -180,19 +145,12 @@ function reformulate_model(model::JuMP.AbstractModel, method::LOA) result.feasible && (previous_result = result) end - # Master/NLP loop. Each iteration solves the master MILP for a lower - # bound (`master_bound` is the `alpha_oa` auxiliary, NOT the penalized - # objective the slacks inflate), then solves the NLP at the master's - # combination and updates the incumbent. The loop exits on whichever - # comes first: convergence (the bound meets the incumbent within - # `convergence_tol` while total slack is below `slack_tol`), an - # infeasible master (every combination forbidden by a no-good cut), - # `max_iter`, or the time budget. The slack gate is what makes the - # convergence stop safe without a convexity flag: on a convex inner - # problem the slacks collapse to zero and the crossing is a real - # optimality proof; on a nonconvex one the master pays slack to - # violate its own cuts, the gate stays shut, and the loop runs on to - # `max_iter` instead of stopping at a spurious crossing. + # Master/NLP loop: solve the master for a bound (`master_bound` is + # `alpha_oa`, not the slack-penalized objective), solve the NLP at its + # combination, update the incumbent. Exit on convergence (bound meets + # incumbent within `convergence_tol` AND total slack below + # `slack_tol`), infeasible master, `max_iter`, or time. The slack gate + # keeps the stop safe: nonconvex crossings pay slack and keep going. converged = false for _ in 1:method.max_iter time() < loop_deadline || break @@ -242,15 +200,11 @@ function reformulate_model(model::JuMP.AbstractModel, method::LOA) return end -# Report the final gap once the loop ends. `[converged]` = the loop hit -# its convergence test (the master bound met the incumbent within -# `convergence_tol` with total slack below `slack_tol`); `[limit hit]` = -# it stopped on `max_iter`, the time budget, or an exhausted master with -# a gap still open; `[no bound]` = the master never produced a bound -# (fell back to the best seed). `master_bound` is `alpha_oa`, a rigorous -# dual bound only for a convex (linear) inner problem; on a nonconvex -# model it can cross the incumbent, which the slack gate is meant to -# catch and the reported gap surfaces. +# Report the final gap. `[converged]` = passed the convergence test; +# `[limit hit]` = stopped on max_iter / time / exhausted master with a +# gap open; `[no bound]` = the master never produced a bound. The bound +# is rigorous only for a convex inner problem; on a nonconvex one it can +# cross the incumbent (a negative gap). function _report_loa_gap( best_objective::Real, best_result, @@ -284,17 +238,10 @@ end ################################################################################ # SET-COVERING INITIALIZATION ################################################################################ -# Türkay & Grossmann (1996), sec. 2.2: produce a minimal set of -# combinations that activates every indicator at least once, so the -# master starts with an OA cut per disjunct. Nested disjunctions are -# enumerated alongside top-level ones — inconsistent combinations -# (nested active under inactive parent) are handled by the no-good -# cut emitted from the infeasible NLP solve. -# -# `K = max(disjunction sizes)` combinations suffice: combination `k` -# activates the `k`-th indicator of each disjunction, cycling via -# `mod1` for disjunctions shorter than `K`. Every indicator gets -# activated at least once over k = 1..K. +# `K = max disjunction size` combinations that activate every indicator +# at least once: combination `k` activates the `k`-th indicator of each +# disjunction, cycling via `mod1`. Inconsistent nested combinations are +# caught by the no-good cut from the infeasible NLP. function _set_covering_combinations(model::JuMP.AbstractModel) LogicalRef = LogicalVariableRef{typeof(model)} indicator_lists = [collect(d.constraint.indicators) @@ -313,21 +260,17 @@ end ################################################################################ # MASTER CONSTRUCTION ################################################################################ -# True for linear constraint function types (scalar/vector variable refs -# and affine expressions). LOA master per Türkay & Grossmann (1996) is -# pure MILP; nonlinear `f`, `g`, `h_{ij}` enter as OA cuts after each -# NLP solve. +# True for linear constraint function types (variable refs / affine). +# Nonlinear functions enter the master as OA cuts, not at copy time. _is_linear_F(::Type{<:JuMP.AbstractVariableRef}) = true _is_linear_F(::Type{<:JuMP.GenericAffExpr}) = true _is_linear_F(::Type{<:AbstractVector{<:JuMP.AbstractVariableRef}}) = true _is_linear_F(::Type{<:AbstractVector{<:JuMP.GenericAffExpr}}) = true _is_linear_F(::Type) = false -# OVERRIDABLE. Build the LOA master MILP `M^b_{LA}` (Türkay & Grossmann -# 1996, eq. 12): copy decision variables and only the linear +# OVERRIDABLE. Build the master MILP: copy the variables and linear # constraints, install `alpha_oa` as the objective auxiliary. Nonlinear -# objective and disjunct constraints enter as OA cuts after each NLP -# solve. Returns an `_LOAMaster`. +# objective and disjunct constraints enter as OA cuts per NLP solve. function build_loa_master(model::JuMP.AbstractModel, method::LOA) original_objective = JuMP.objective_function(model) objective_sense = JuMP.objective_sense(model) @@ -359,17 +302,51 @@ function build_loa_master(model::JuMP.AbstractModel, method::LOA) alpha_oa = JuMP.@variable(master, base_name = "alpha_oa") JuMP.@objective(master, objective_sense, alpha_oa) + disaggregator = _build_disaggregator(model, method.inner_method, + variable_map, binary_map) return _LOAMaster(master, binary_map, variable_map, objective_sense, - original_objective, alpha_oa, variable_map) + original_objective, alpha_oa, variable_map, disaggregator) +end + +# Master-space Hull disaggregator for the convex-hull cut emitter +# (`nothing` for Big-M / MBM). Remaps the recorded `(variable, indicator) +# -> disaggregated variable` map into master refs. +_build_disaggregator(::JuMP.AbstractModel, ::Union{BigM, MBM}, + variable_map, binary_map) = nothing +function _build_disaggregator( + model::JuMP.AbstractModel, + inner::Hull, + variable_map::AbstractDict, + binary_map::AbstractDict + ) + hull = _Hull(inner, Set{valtype(variable_map)}()) + for ((variable, indicator), disaggregated) in _disaggregations(model) + _record_disaggregation(hull, model, variable_map[variable], + binary_map[indicator], variable_map[disaggregated]) + end + return hull +end + +# OVERRIDABLE. Record one master-space disaggregation in the Hull +# disaggregator. Base keys it directly by `(variable, binary)`; the +# InfiniteOpt extension keys per support so per-support cut emission +# matches. +function _record_disaggregation( + hull::_Hull, + ::JuMP.AbstractModel, + variable, + binary, + disaggregated + ) + hull.disjunct_variables[(variable, binary)] = disaggregated + return end ################################################################################ # NLP SUBPROBLEM ################################################################################ -# Cap `target`'s solver to the wall-clock budget left before `deadline` -# (seconds, `time()` clock). No-op when `deadline` is `Inf` (no LOA time -# limit). Keeps one in-flight solve from overrunning the whole-loop -# budget; the loop also breaks once `deadline` passes. +# Cap `target`'s solver to the budget left before `deadline` so one solve +# can't overrun the loop. No-op when `deadline` is `Inf`. function _cap_remaining_time(target::JuMP.AbstractModel, deadline::Float64) isfinite(deadline) || return JuMP.set_time_limit_sec(target, max(0.0, deadline - time())) @@ -383,12 +360,9 @@ _restore_time_limit(model::JuMP.AbstractModel, ::Nothing) = _restore_time_limit(model::JuMP.AbstractModel, seconds::Real) = JuMP.set_time_limit_sec(model, seconds) -# Solve the primary NLP for a fixed combination. If feasible, read the -# primal point and objective. If infeasible, fall through to the NLPF -# (V&G 1990 eq. 8) approximation: a slacked version of the same problem -# that always solves, whose primal becomes the linearization site for -# OA cuts. The master still learns shape information from the failed -# combination instead of only adding a no-good cut. +# Solve the primary NLP at a fixed combination. If infeasible, fall +# through to NLPF (a slacked version that always solves) so the master +# still gets a linearization site, not just a no-good cut. function _solve_nlp( model::M, combination, @@ -421,18 +395,15 @@ end ################################################################################ # NLPF (FEASIBILITY SUBPROBLEM) ################################################################################ -# V&G 1990 eq. 8: when the primary NLP is infeasible at a fixed -# combination, copy the model, slack every scalar inequality with a -# single nonnegative slack `u`, minimize `u`, and return the resulting -# primal point as an approximate linearization site. The point -# satisfies equalities and variable bounds exactly; inequalities can -# be violated by at most `u`. +# When the primary NLP is infeasible, copy the model, slack every scalar +# inequality with one nonnegative `u`, minimize `u`, and return the +# primal as the linearization site (equalities/bounds exact, inequalities +# violated by at most `u`). function _solve_nlpf( model::M, combination, method::LOA; deadline::Float64 = Inf ) where {M <: JuMP.AbstractModel} - # The original model's logical binaries are permanently relaxed - # (see reformulate_model), so this copy is continuous — any NLP - # solver, including pure-NLP ones like Ipopt, can solve NLPF. + # The original's binaries are permanently relaxed, so this copy is + # continuous — any NLP solver (e.g. Ipopt) can solve NLPF. copy, ref_map = JuMP.copy_model(model) JuMP.set_optimizer(copy, method.nlp_optimizer) JuMP.set_silent(copy) @@ -454,12 +425,9 @@ function _solve_nlpf( JuMP.@objective(copy, Min, u) - # Pin the copy's indicator binaries to the combination. The infinite - # `with_fixed_combination` undoes the original's fix after each - # solve, so this NLPF copy arrives unfixed and its `nlpf_fix_on_copy` - # override re-pins it. The finite path leaves the original fixed in - # place, so the copy inherits the fix and the base `nlpf_fix_on_copy` - # is a no-op. + # Pin the copy's binaries to the combination. Finite: the original + # stays fixed so the copy inherits it (base no-op). Infinite: the + # original is unfixed each iteration, so the override re-pins here. for (indicator, value) in combination binary = _binary_on_copy( _indicator_to_binary(model)[indicator], ref_map) @@ -485,13 +453,9 @@ function _binary_on_copy(binary::JuMP.GenericAffExpr, ref_map) return 1.0 - ref_map[underlying] end -# OVERRIDABLE. Pin a copy-side binary to a combination value. Base -# no-op: the finite original stays fixed in place (its -# `with_fixed_combination` does not undo), so its NLPF copy inherits the -# fix. The InfiniteOpt extension overrides this — its original is -# cleaned up each iteration, so the copy needs pinning — using -# constraints rather than `JuMP.fix` to avoid force-deleting bounds on -# the relaxed copy. +# OVERRIDABLE. Pin a copy-side binary to a combination value. Base no-op +# (the finite copy inherits the original's fix); the InfiniteOpt +# extension re-pins via constraints. nlpf_fix_on_copy(copy, binary, value) = nothing _nlpf_should_slack(::Type{<:_MOI.LessThan}) = true @@ -517,13 +481,9 @@ function _nlpf_extract_primal(model::JuMP.AbstractModel, ref_map) return result end -# Fix `combination` in place, then run `f()`. No relax/restore (the -# model's logical binaries are relaxed once, permanently, in -# reformulate_model) and no fix/undo: `fix_combination` overwrites in -# place, so there is no per-iteration state to unwind — hence no -# try/finally. On a solve error the model is left fixed to the last -# combination; the next `fix_combination` (or `commit_combination`) -# overwrites it. +# Fix `combination` in place, then run `f()`. No undo: `fix_combination` +# overwrites in place and the binaries are permanently relaxed, so there +# is no per-iteration state to unwind. function with_fixed_combination( f, model::JuMP.AbstractModel, @@ -533,14 +493,9 @@ function with_fixed_combination( return f() end -# Iter-to-iter NLP warm start (T&G 1996 §2.3): seed the next primary -# NLP solve from the most recent FEASIBLE solution's primal point. -# No-op on the first seed iteration when `previous` is `nothing`, and -# no-op on iterations following an NLPF fall-through (caller does not -# update `previous_result` then), since NLPF's primal is slack- -# distorted and a worse start than the prior real NLP. Routes through -# the `set_linearization_start` dispatch so an extension can broadcast -# a vector-valued start across its variables. +# Iter-to-iter NLP warm start: seed the next NLP from the last FEASIBLE +# primal (no-op on the first seed and after an NLPF fall-through, whose +# primal is slack-distorted). Routes through `set_linearization_start`. function _set_nlp_warm_start(previous) previous === nothing && return for (variable, values) in previous.linearization_point @@ -566,11 +521,9 @@ function commit_combination( return end -# OVERRIDABLE. Apply the combination's fixes in place. No undo closure: -# `fix_indicator` uses `force = true` so each call overwrites the prior -# fix, and the model is permanently relaxed, so there is nothing to -# restore. An extension overrides this for vector-valued `Vector{Bool}` -# values (per-support pins reused in place across iterations). +# OVERRIDABLE. Apply the combination's fixes in place (force-fix, no +# undo). An extension overrides this for per-support `Vector{Bool}` +# values. function fix_combination(model::JuMP.AbstractModel, combination::AbstractDict) for (indicator, value) in combination fix_indicator(model, indicator, value) @@ -578,19 +531,9 @@ function fix_combination(model::JuMP.AbstractModel, combination::AbstractDict) return end -# OVERRIDABLE. Write the LOA linearization point into a variable's warm -# start. This is deliberately NOT a direct `JuMP.set_start_value` call. -# The linearization point is stored per-variable as a vector — length-1 -# for a scalar/finite variable, length-K for a variable carrying K -# support points (see `extract_solution`). For an InfiniteOpt infinite -# variable that per-support vector must be written to each of the K -# *transcribed* JuMP variables (`transformation_variable(v)` is an array -# of per-support refs); `JuMP.set_start_value` on the single infinite -# `GeneralVariableRef` cannot place a distinct start at each support. -# Base handles the scalar case by unwrapping `only(values)`; the -# extension overrides this seam to broadcast the vector across the -# transcribed instances. Hence the dispatch rather than a bare -# `set_start_value`. +# OVERRIDABLE. Warm-start a variable from the linearization point (stored +# as a per-support vector). Base unwraps the scalar; the InfiniteOpt +# extension broadcasts across the transcribed per-support refs. set_linearization_start(variable, values::AbstractVector) = JuMP.set_start_value(variable, only(values)) @@ -614,13 +557,9 @@ function _extract_combination( return combination end -# Read the master binary and round to `Bool`. Rounding is required (not -# defensive): a MILP solver returns binaries within its -# integer-feasibility tolerance — e.g. 2.75e-40 for a "0", 1.0000002 for -# a "1" — never exactly 0/1, so `Bool(value)` would throw `InexactError`. -# Not dispatched: the ref type is the same whether the indicator is -# finite or infinite; only `JuMP.value`'s return differs (scalar vs a -# per-support array), so we branch on the value and handle both here. +# Read the master binary and round to `Bool` (a MILP solver returns +# values within its integer tolerance, never exactly 0/1). Branches on +# the value: scalar (finite) or per-support array (infinite). function combination_val(binary_ref) val = JuMP.value(binary_ref) return val isa AbstractArray ? vec(round.(Bool, val)) : round(Bool, val) @@ -637,7 +576,8 @@ function add_oa_cuts( ) isempty(result.linearization_point) && return linearization = _linearize_at(master.original_objective, - result.linearization_point, master.objective_ref_map) + _objective_linearization_point(model, result.linearization_point), + master.objective_ref_map) _add_objective_cut( Val(master.objective_sense), master, linearization, method) _add_global_oa_cuts(model, master, result, method) @@ -645,13 +585,15 @@ function add_oa_cuts( return end -# Slacked objective cut (V&G 1990 eq. 6). For MIN, `lin ≤ α + σ` with -# `σ ≥ 0` penalized in the master objective so the slack drives to -# zero at convergence; the linearization is then `α ≥ lin` (standard -# OA bound). MAX is symmetric: `lin ≥ α − σ`. Without the slack, an -# accumulated bad linearization on a nonconvex objective can make the -# master infeasible — the disjunct and global cuts already carry -# slacks but the objective cut did not. +# OVERRIDABLE. The point at which the master objective is linearized. +# Base uses the raw point; an extension whose `original_objective` is +# transcribed or derived overrides this to match its shape. +_objective_linearization_point(::JuMP.AbstractModel, linearization_point) = + linearization_point + +# Slacked objective cut. MIN: `lin <= alpha_oa + sigma`; MAX symmetric. +# The slack (like the disjunct/global cuts) keeps a nonconvex objective +# linearization from making the master infeasible. function _add_objective_cut( sense_token::Val, master::_LOAMaster, @@ -667,19 +609,10 @@ _add_objective_cut_body(::Val{_MOI.MIN_SENSE}, master, lin, slack) = _add_objective_cut_body(::Val{_MOI.MAX_SENSE}, master, lin, slack) = JuMP.@constraint(master.model, lin >= master.alpha_oa - slack) -# Add the OA cut `g(x^l) + ∇g(x^l)^T (x − x^l) in con.set` for every -# nonlinear global constraint of `model` — the third cut class in -# Türkay & Grossmann (1996, eq. 12) alongside the objective and the -# disjunct cuts. Walks `JuMP.list_of_constraint_types`, skipping -# variable bounds, linear functions (already in the master), and -# BigM-reformulated forms (in `_reformulation_constraints`). -# `LessThan` and `GreaterThan` are supported; equalities and vector -# constraints are passed through to `JuMP.@constraint` as-is — -# valid for affine-after-linearization sets. -# -# Base (scalar-model) version. An extension that overrides `add_oa_cuts` -# supplies its own global-cut handling for constraints whose -# linearization is vector-valued or derived. +# OVERRIDABLE. Add an OA cut for every nonlinear global constraint, +# skipping variable bounds, linear functions, and reformulation +# constraints. Base (scalar) version; an extension overrides it for +# vector-valued / transcribed linearizations. function _add_global_oa_cuts( model::JuMP.AbstractModel, master::_LOAMaster, @@ -706,10 +639,9 @@ function _add_global_oa_cuts( return end -# OVERRIDABLE. Add V&G 1990 augmented-penalty OA cuts for each active -# disjunct's nonlinear constraints: fresh per-cut slack `σ_ik` with a -# penalty term in the master objective, and cut body -# `s (lin − rhs) − σ ≤ M(1 − y)`. +# Add slacked OA cuts for each active disjunct's nonlinear constraints. +# This driver (iterate active disjuncts and their constraints) is shared; +# the per-constraint emission is the OVERRIDABLE seam below. function add_disjunct_oa_cuts( model::JuMP.AbstractModel, master::_LOAMaster, @@ -724,20 +656,35 @@ function add_disjunct_oa_cuts( cref isa DisjunctConstraintRef || continue constraint = _disjunct_constraints(model)[ JuMP.index(cref)].constraint - _add_oa_cut_for_constraint( - constraint, master, master.binary_map[indicator], - result.linearization_point, master.variable_map, - method, penalty_sign) + _add_disjunct_constraint_oa_cuts(model, constraint, master, + master.binary_map[indicator], active, result, method, + penalty_sign) end end end -# Fresh nonnegative slack added to the master objective with the V&G -# 1990 penalty. Shared by the disjunct, global, and objective OA cuts -# so all three carry the same augmented-penalty treatment: a nonconvex -# linearization can be an invalid relaxation, and the penalized slack -# keeps the master feasible instead of letting accumulated cuts make -# it infeasible. +# OVERRIDABLE. Emit the OA cut(s) for one active disjunct constraint. +# Base emits a single cut; the InfiniteOpt extension fans out per +# support. `active` is unused in base but drives the extension's fan-out. +function _add_disjunct_constraint_oa_cuts( + ::JuMP.AbstractModel, + constraint::JuMP.AbstractConstraint, + master::_LOAMaster, + binary_ref, + active, + result::NamedTuple, + method::LOA, + penalty_sign::Int + ) + _add_oa_cut_for_constraint( + constraint, master, binary_ref, result.linearization_point, + master.variable_map, method, penalty_sign) + return +end + +# Fresh penalized slack added to the master objective. Shared by the +# disjunct, global, and objective cuts so a nonconvex (invalid) +# linearization can't make the master infeasible. function _penalized_slack(master::_LOAMaster, method::LOA, penalty_sign::Int) slack = JuMP.@variable(master.model, lower_bound = 0.0, upper_bound = method.max_slack) @@ -762,17 +709,41 @@ function _add_oa_cut_for_constraint( _is_linear_F(typeof(constraint.func)) && return linearization = _linearize_at( constraint.func, linearization_point, var_map) - _emit_disjunct_oa_cut( + _emit_disjunct_oa_cut(method.inner_method, constraint.set, master, binary_ref, linearization, method, penalty_sign) return end -# Emit the slacked, gated OA cut(s) for a disjunct constraint. The cut -# direction is set-determined (Duran-Grossmann 1986 OA convention): a -# `≤` constraint linearizes in place, a `≥` constraint is negated, and -# equality / interval emit both directions sharing one slack. No dual is -# needed — the constraint's set fixes the direction. +# Route the disjunct OA cut through the gating that matches the inner +# reformulation: Big-M / MBM gate with `M(1 - y)`; Hull emits the +# convex-hull cut on disaggregated variables (no big-M). +_emit_disjunct_oa_cut( + ::Union{BigM, MBM}, + set, + master::_LOAMaster, + binary_ref, + linearization, + method::LOA, + penalty_sign::Int + ) = + _emit_disjunct_oa_cut( + set, master, binary_ref, linearization, method, penalty_sign) +_emit_disjunct_oa_cut( + ::Hull, + set, + master::_LOAMaster, + binary_ref, + linearization, + method::LOA, + penalty_sign::Int + ) = + _emit_hull_oa_cut( + set, master, binary_ref, linearization, method, penalty_sign) + +# Emit the slacked, big-M-gated OA cut(s) for a disjunct constraint. The +# set fixes the direction: `<=` in place, `>=` negated, equality/interval +# both directions sharing one slack. function _emit_disjunct_oa_cut( set::_MOI.LessThan, master::_LOAMaster, @@ -851,12 +822,92 @@ function _emit_disjunct_oa_cut( return end -# Slacked global OA row(s) (V&G 1990). The nonconvex linearization may -# be an invalid relaxation, so each row carries a penalized slack -# rather than being a hard constraint — without this the accumulated -# global cuts make the master infeasible on nonconvex models. -# `EqualTo` / `Interval` get a two-sided pair sharing one slack. -# Unknown set types fall back to the prior hard cut. +# Convex-hull disjunct OA cut. `disaggregate_expression` rewrites the OA +# linearization `linearization - rhs` into disaggregated space (each +# variable becomes its per-disjunct copy, the constant scaled by the +# binary), giving the sharp cut that switches off at `y = 0` with no +# big-M. The slack handles the nonconvex case. Set dispatch mirrors +# `_emit_disjunct_oa_cut`. +function _emit_hull_oa_cut( + set::_MOI.LessThan, + master::_LOAMaster, + binary_ref, + linearization, + method::LOA, + penalty_sign::Int + ) + body = disaggregate_expression(master.model, + linearization - _set_rhs(set), binary_ref, master.disaggregator) + slack = _penalized_slack(master, method, penalty_sign) + JuMP.@constraint(master.model, body - slack <= 0) + return +end +function _emit_hull_oa_cut( + set::_MOI.GreaterThan, + master::_LOAMaster, + binary_ref, + linearization, + method::LOA, + penalty_sign::Int + ) + body = disaggregate_expression(master.model, + linearization - _set_rhs(set), binary_ref, master.disaggregator) + slack = _penalized_slack(master, method, penalty_sign) + JuMP.@constraint(master.model, body + slack >= 0) + return +end +function _emit_hull_oa_cut( + set::_MOI.EqualTo, + master::_LOAMaster, + binary_ref, + linearization, + method::LOA, + penalty_sign::Int + ) + body = disaggregate_expression(master.model, + linearization - _set_rhs(set), binary_ref, master.disaggregator) + slack = _penalized_slack(master, method, penalty_sign) + JuMP.@constraint(master.model, body - slack <= 0) + JuMP.@constraint(master.model, body + slack >= 0) + return +end +function _emit_hull_oa_cut( + set::_MOI.Interval, + master::_LOAMaster, + binary_ref, + linearization, + method::LOA, + penalty_sign::Int + ) + upper = disaggregate_expression(master.model, + linearization - set.upper, binary_ref, master.disaggregator) + lower = disaggregate_expression(master.model, + linearization - set.lower, binary_ref, master.disaggregator) + slack = _penalized_slack(master, method, penalty_sign) + JuMP.@constraint(master.model, upper - slack <= 0) + JuMP.@constraint(master.model, lower + slack >= 0) + return +end +# Fallback for vector / future set types: one upper-bound cut. +function _emit_hull_oa_cut( + set, + master::_LOAMaster, + binary_ref, + linearization, + method::LOA, + penalty_sign::Int + ) + body = disaggregate_expression(master.model, + linearization - _set_rhs(set), binary_ref, master.disaggregator) + slack = _penalized_slack(master, method, penalty_sign) + JuMP.@constraint(master.model, body - slack <= 0) + return +end + +# Slacked global OA row(s): each carries a penalized slack so a nonconvex +# (invalid) linearization can't make the master infeasible. EqualTo / +# Interval get a two-sided pair sharing one slack; unknown sets fall back +# to a hard cut. function _add_global_oa_row( master::_LOAMaster, lin, @@ -909,21 +960,14 @@ function _add_global_oa_row(master::_LOAMaster, lin, set, ::LOA, ::Int) return end -# Is this indicator active in the combination? A finite indicator carries -# a `Bool`; an infinite one carries a per-support `Vector{Bool}` (active -# at some supports, not others). Used to skip disjuncts that are off -# everywhere when emitting OA cuts. +# Is this indicator active anywhere? Finite: a `Bool`; infinite: a +# per-support `Vector{Bool}`. Skips disjuncts that are off everywhere. is_active(active::Bool) = active is_active(active::AbstractVector{Bool}) = any(active) ################################################################################ # LINEARIZATION & EXPRESSION CONVERSION ################################################################################ -# TODO: Move this section out of `loa.jl` (likely back to -# `src/utilities.jl`) when a second OA-style method lands and these -# helpers earn their generality. Currently only LOA uses them, so -# they live next to the algorithm that consumes them. - # First-order Taylor for a single var or affine expression mapped into # master space. function _linearize_at( @@ -1022,10 +1066,7 @@ _set_rhs(s::Union{_MOI.LessThan, _MOI.GreaterThan, _MOI.EqualTo}) = _MOI.constant(s) _set_rhs(::Any) = 0.0 -# Unwrap a 1-element `Vector` to its scalar value; scalars pass -# through. `extract_solution` returns `Vector`s uniformly (length-1 for -# a scalar variable, length-K for a vector-valued one). AD pipelines -# and `set_start_value` need a scalar in the scalar case; vector-valued -# consumers slice out a scalar themselves. +# Unwrap a 1-element `Vector` to its scalar; scalars pass through +# (`extract_solution` returns length-1 vectors for scalar variables). _unwrap_scalar(v::Real) = v _unwrap_scalar(v::AbstractVector) = only(v) diff --git a/src/model.jl b/src/model.jl index 0f26a4f0..efd6ff08 100644 --- a/src/model.jl +++ b/src/model.jl @@ -83,6 +83,7 @@ _indicator_to_constraints(model::JuMP.AbstractModel) = gdp_data(model).indicator _constraint_to_indicator(model::JuMP.AbstractModel) = gdp_data(model).constraint_to_indicator _reformulation_variables(model::JuMP.AbstractModel) = gdp_data(model).reformulation_variables _reformulation_constraints(model::JuMP.AbstractModel) = gdp_data(model).reformulation_constraints +_disaggregations(model::JuMP.AbstractModel) = gdp_data(model).disaggregations _variable_bounds(model::JuMP.AbstractModel) = gdp_data(model).variable_bounds _solution_method(model::JuMP.AbstractModel) = gdp_data(model).solution_method # Get the current solution method _ready_to_optimize(model::JuMP.AbstractModel) = gdp_data(model).ready_to_optimize # Determine if the model is ready to call `optimize!` without a optimize hook diff --git a/src/reformulate.jl b/src/reformulate.jl index 9a3b65cb..86270104 100644 --- a/src/reformulate.jl +++ b/src/reformulate.jl @@ -24,6 +24,7 @@ function _clear_reformulations(model::JuMP.AbstractModel) delete.(model, _reformulation_variables(model)) empty!(gdp_data(model).reformulation_constraints) empty!(gdp_data(model).reformulation_variables) + empty!(gdp_data(model).disaggregations) empty!(gdp_data(model).variable_bounds) return end diff --git a/test/constraints/loa.jl b/test/constraints/loa.jl index e470bfbf..16a06d9f 100644 --- a/test/constraints/loa.jl +++ b/test/constraints/loa.jl @@ -389,8 +389,8 @@ function test_loa_time_limits() @test objective_value(model) ≈ 7.0 atol = 1e-4 model = build_model() - optimize!(model, - gdp_method = LOA(HiGHS.Optimizer; iteration_time_limit = 60.0)) + optimize!(model, gdp_method = LOA(HiGHS.Optimizer; + iteration_time_limit = 60.0, time_limit = Inf)) @test termination_status(model) == MOI.OPTIMAL @test objective_value(model) ≈ 7.0 atol = 1e-4 end @@ -419,6 +419,116 @@ function test_loa_no_feasible_incumbent() @test DP._ready_to_optimize(model) end +function test_loa_hull_linear() + # Same GDP as test_loa_solve_simple, but inner_method = Hull so the + # NLP and master carry disaggregated variables and the disjunct + # cuts are convex-hull cuts rather than big-M. Optimum unchanged: + # x = 7 via the second disjunct. + model = GDPModel(HiGHS.Optimizer) + set_silent(model) + @variable(model, 0 <= x <= 10) + @variable(model, Y[1:2], Logical) + @constraint(model, x <= 3, Disjunct(Y[1])) + @constraint(model, x <= 7, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Max, x) + optimize!(model, gdp_method = LOA(HiGHS.Optimizer; + inner_method = Hull())) + @test termination_status(model) == MOI.OPTIMAL + @test objective_value(model) ≈ 7.0 atol = 1e-4 +end + +function test_loa_hull_two_disjunctions() + # Two independent disjunctions under Hull; optimum x = 7, z = 5. + model = GDPModel(HiGHS.Optimizer) + set_silent(model) + @variable(model, 0 <= x <= 10) + @variable(model, 0 <= z <= 10) + @variable(model, Y[1:2], Logical) + @variable(model, W[1:2], Logical) + @constraint(model, x <= 3, Disjunct(Y[1])) + @constraint(model, x <= 7, Disjunct(Y[2])) + @disjunction(model, Y) + @constraint(model, z <= 2, Disjunct(W[1])) + @constraint(model, z <= 5, Disjunct(W[2])) + @disjunction(model, W) + @objective(model, Max, x + z) + optimize!(model, gdp_method = LOA(HiGHS.Optimizer; + inner_method = Hull())) + @test termination_status(model) == MOI.OPTIMAL + @test objective_value(model) ≈ 12.0 atol = 1e-4 +end + +function test_loa_hull_nonlinear_global() + # max x s.t. x^2 <= 25 (global), (x <= 3) ∨ (x <= 8) under Hull. + # Linear disjuncts enter the master as exact Hull perspectives; the + # global nonlinear enters via global OA cuts. Optimum: x = 5. + ipopt = optimizer_with_attributes(Ipopt.Optimizer, + "print_level" => 0, "sb" => "yes") + juniper = optimizer_with_attributes(Juniper.Optimizer, + "nl_solver" => ipopt, "log_levels" => []) + model = GDPModel(juniper) + set_silent(model) + @variable(model, 0 <= x <= 10) + @constraint(model, x^2 <= 25) + @variable(model, Y[1:2], Logical) + @constraint(model, x <= 3, Disjunct(Y[1])) + @constraint(model, x <= 8, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Max, x) + optimize!(model, gdp_method = LOA(juniper; + mip_optimizer = HiGHS.Optimizer, inner_method = Hull())) + @test termination_status(model) in (MOI.OPTIMAL, MOI.LOCALLY_SOLVED) + @test objective_value(model) ≈ 5.0 atol = 1e-3 +end + +function test_loa_hull_nonlinear_disjunct() + # Nonlinear disjunct constraint under Hull: this is the case big-M + # and Hull genuinely differ on. 0 <= x <= 10, Y1: x <= 3, + # Y2: x^2 <= 64. The convex-hull OA cut disaggregates the + # linearization of x^2. Optimum: x = 8 via Y2. + ipopt = optimizer_with_attributes(Ipopt.Optimizer, + "print_level" => 0, "sb" => "yes") + juniper = optimizer_with_attributes(Juniper.Optimizer, + "nl_solver" => ipopt, "log_levels" => []) + model = GDPModel(juniper) + set_silent(model) + @variable(model, 0 <= x <= 10) + @variable(model, Y[1:2], Logical) + @constraint(model, x <= 3, Disjunct(Y[1])) + @constraint(model, x^2 <= 64, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Max, x) + optimize!(model, gdp_method = LOA(juniper; + mip_optimizer = HiGHS.Optimizer, inner_method = Hull())) + @test termination_status(model) in (MOI.OPTIMAL, MOI.LOCALLY_SOLVED) + @test objective_value(model) ≈ 8.0 atol = 1e-3 +end + +function test_loa_hull_complement_nonlinear() + # Hull with a complement indicator Y2 ≡ ¬Y1 and a nonlinear + # disjunct under Y2. The disaggregator is keyed by the complement + # binary `1 - y1` (an AffExpr); the cut must disaggregate against + # it. Optimum: x = 8 via Y2. + ipopt = optimizer_with_attributes(Ipopt.Optimizer, + "print_level" => 0, "sb" => "yes") + juniper = optimizer_with_attributes(Juniper.Optimizer, + "nl_solver" => ipopt, "log_levels" => []) + model = GDPModel(juniper) + set_silent(model) + @variable(model, 0 <= x <= 10) + @variable(model, Y1, Logical) + @variable(model, Y2, Logical, logical_complement = Y1) + @constraint(model, x <= 3, Disjunct(Y1)) + @constraint(model, x^2 <= 64, Disjunct(Y2)) + @disjunction(model, [Y1, Y2]) + @objective(model, Max, x) + optimize!(model, gdp_method = LOA(juniper; + mip_optimizer = HiGHS.Optimizer, inner_method = Hull())) + @test termination_status(model) in (MOI.OPTIMAL, MOI.LOCALLY_SOLVED) + @test objective_value(model) ≈ 8.0 atol = 1e-3 +end + @testset "LOA" begin test_loa_datatype() test_set_covering_combos() @@ -439,4 +549,9 @@ end test_loa_iteration_loop() test_loa_time_limits() test_loa_no_feasible_incumbent() + test_loa_hull_linear() + test_loa_hull_two_disjunctions() + test_loa_hull_nonlinear_global() + test_loa_hull_nonlinear_disjunct() + test_loa_hull_complement_nonlinear() end diff --git a/test/extensions/InfiniteDisjunctiveProgramming.jl b/test/extensions/InfiniteDisjunctiveProgramming.jl index 707833fa..e1011be6 100644 --- a/test/extensions/InfiniteDisjunctiveProgramming.jl +++ b/test/extensions/InfiniteDisjunctiveProgramming.jl @@ -797,7 +797,7 @@ function test_loa_infinite_nonlinear_global() # Disjunct Y[2] permits x up to 8 but the global x^2 <= 25 caps # x at 5. The per-support global transcribes to an `AbstractArray` # of scalar constraints, so this exercises the array branch of - # `_add_global_oa_cuts_infinite`. Without the global cut the + # `_add_global_oa_cuts`. Without the global cut the # master would allow x = 8 and report 8.0; the binding optimum # is ∫5 dt = 5. ipopt = optimizer_with_attributes(Ipopt.Optimizer, @@ -954,7 +954,7 @@ function test_loa_infinite_aggregate_global() # (y >= 1) ∨ (y >= 3), 0 <= x, y <= 10 over t ∈ [0, 1]. # The aggregate global transcribes to a single scalar (the # measure is flattened), exercising the non-array branch of - # `_add_global_oa_cuts_infinite`. Y[1] (y >= 1) is the cheaper + # `_add_global_oa_cuts`. Y[1] (y >= 1) is the cheaper # disjunct: x = 1 satisfies x >= y and ∫x^2 = 1 <= 4, giving # objective ∫1 dt = 1. ipopt = optimizer_with_attributes(Ipopt.Optimizer, @@ -1010,6 +1010,105 @@ function test_loa_infinite_iteration_loop() @test objective_value(model) ≈ -10.0 atol = 1e-3 end +function test_loa_infinite_hull_linear() + # InfiniteOpt LOA with inner_method = Hull. Linear disjuncts enter + # the master as exact per-support Hull perspectives over + # disaggregated infinite variables. max ∫x dt, Y1: x <= 3, + # Y2: x <= 8. Optimum: x = 8 across t → ∫8 dt = 8. + model = InfiniteGDPModel(HiGHS.Optimizer) + set_silent(model) + @infinite_parameter(model, t ∈ [0, 1], num_supports = 5) + @variable(model, 0 <= x <= 10, Infinite(t)) + @variable(model, Y[1:2], InfiniteLogical(t)) + @constraint(model, x <= 3, Disjunct(Y[1])) + @constraint(model, x <= 8, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Max, ∫(x, t)) + optimize!(model, gdp_method = LOA(HiGHS.Optimizer; + inner_method = Hull())) + @test termination_status(model) in (MOI.OPTIMAL, MOI.LOCALLY_SOLVED) + @test objective_value(model) ≈ 8.0 atol = 1e-2 +end + +function test_loa_infinite_hull_nonlinear_disjunct() + # The infinite Hull case big-M differs from: a NONLINEAR constraint + # on an infinite variable inside a disjunct, under a plain infinite + # indicator. The convex-hull OA cut is built per support over the + # disaggregated infinite variable (point-evaluated). max ∫x dt, + # Y1: x <= 3, Y2: x^2 <= 64. Optimum: x = 8 via Y2. + ipopt = optimizer_with_attributes(Ipopt.Optimizer, + "print_level" => 0, "sb" => "yes") + juniper = optimizer_with_attributes(Juniper.Optimizer, + "nl_solver" => ipopt, "log_levels" => []) + model = InfiniteGDPModel(juniper) + set_silent(model) + @infinite_parameter(model, t ∈ [0, 1], num_supports = 5) + @variable(model, 0 <= x <= 10, Infinite(t)) + @variable(model, Y[1:2], InfiniteLogical(t)) + @constraint(model, x <= 3, Disjunct(Y[1])) + @constraint(model, x^2 <= 64, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Max, ∫(x, t)) + optimize!(model, gdp_method = LOA(juniper; + mip_optimizer = HiGHS.Optimizer, inner_method = Hull())) + @test termination_status(model) in (MOI.OPTIMAL, MOI.LOCALLY_SOLVED) + @test objective_value(model) ≈ 8.0 atol = 1e-2 +end + +function test_loa_infinite_hull_complement_nonlinear() + # Hull with a complement indicator Y2 ≡ ¬Y1 gating a nonlinear + # constraint on an infinite variable. The per-support disaggregator + # is keyed by the point-evaluated complement binary `1 - y1(t_k)` + # (an AffExpr). max ∫x dt, Y1: x <= 3, Y2: x^2 <= 64. Optimum 8. + ipopt = optimizer_with_attributes(Ipopt.Optimizer, + "print_level" => 0, "sb" => "yes") + juniper = optimizer_with_attributes(Juniper.Optimizer, + "nl_solver" => ipopt, "log_levels" => []) + model = InfiniteGDPModel(juniper) + set_silent(model) + @infinite_parameter(model, t ∈ [0, 1], num_supports = 5) + @variable(model, 0 <= x <= 10, Infinite(t)) + @variable(model, Y1, Logical) + @variable(model, Y2, Logical, logical_complement = Y1) + @constraint(model, x <= 3, Disjunct(Y1)) + @constraint(model, x^2 <= 64, Disjunct(Y2)) + @disjunction(model, [Y1, Y2]) + @objective(model, Max, ∫(x, t)) + optimize!(model, gdp_method = LOA(juniper; + mip_optimizer = HiGHS.Optimizer, inner_method = Hull())) + @test termination_status(model) in (MOI.OPTIMAL, MOI.LOCALLY_SOLVED) + @test objective_value(model) ≈ 8.0 atol = 1e-2 +end + +function test_loa_infinite_hull_finite_var() + # A FINITE variable disaggregated inside a nonlinear disjunct + # constraint gated by an INFINITE indicator. The fan-out slices the + # binary per support, so the disaggregator must key the finite + # variable's copy by the per-support binary `y(t_k)` (driven off the + # binary, not the variable). max ∫x dt + w over t ∈ [0,1]: + # Y1: x^2 + w^2 <= 25 Y2: x <= 0 + # Y1 is active; x(t) = w = sqrt(12.5), so the optimum is + # ∫sqrt(12.5) dt + sqrt(12.5) = 2 sqrt(12.5) ≈ 7.0711. + ipopt = optimizer_with_attributes(Ipopt.Optimizer, + "print_level" => 0, "sb" => "yes") + juniper = optimizer_with_attributes(Juniper.Optimizer, + "nl_solver" => ipopt, "log_levels" => []) + model = InfiniteGDPModel(juniper) + set_silent(model) + @infinite_parameter(model, t ∈ [0, 1], num_supports = 4) + @variable(model, 0 <= x <= 10, Infinite(t)) + @variable(model, 0 <= w <= 10) + @variable(model, Y[1:2], InfiniteLogical(t)) + @constraint(model, x^2 + w^2 <= 25, Disjunct(Y[1])) + @constraint(model, x <= 0, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Max, ∫(x, t) + w) + optimize!(model, gdp_method = LOA(juniper; + mip_optimizer = HiGHS.Optimizer, inner_method = Hull())) + @test termination_status(model) in (MOI.OPTIMAL, MOI.LOCALLY_SOLVED) + @test objective_value(model) ≈ 7.0711 atol = 1e-2 +end + @testset "InfiniteDisjunctiveProgramming" begin @testset "Model" begin @@ -1088,6 +1187,10 @@ end test_loa_infinite_multidim_parameter() test_loa_infinite_nlpf_infeasible_disjunct() test_loa_infinite_iteration_loop() + test_loa_infinite_hull_linear() + test_loa_infinite_hull_nonlinear_disjunct() + test_loa_infinite_hull_complement_nonlinear() + test_loa_infinite_hull_finite_var() else @info "Skipping InfiniteOpt LOA tests: " * "JuMP.copy_model(::InfiniteModel) unavailable " * From 9896a78018faa49bb88ba5542bc0fc54d755d393 Mon Sep 17 00:00:00 2001 From: d227nguyen Date: Tue, 30 Jun 2026 16:11:00 -0400 Subject: [PATCH 57/59] LOA Hull inner method --- ext/InfiniteDisjunctiveProgramming.jl | 90 ++-- src/datatypes.jl | 19 +- src/hull.jl | 7 +- src/loa.jl | 426 ++++++------------ src/model.jl | 1 - src/reformulate.jl | 1 - test/constraints/loa.jl | 23 +- .../InfiniteDisjunctiveProgramming.jl | 4 +- 8 files changed, 200 insertions(+), 371 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index b155ee26..0381caf5 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -342,7 +342,7 @@ _per_support_values(variable::InfiniteOpt.GeneralVariableRef) = # Read per-support values from the transformation backend, keyed by # InfiniteOpt vars. Skips fixed vars. The objective-side translation # (transcribe-then-AD when the objective has aggregate refs) lives in -# the `_objective_linearization_point(::InfiniteModel, ...)` override +# the `objective_linearization_point(::InfiniteModel, ...)` override # below — base `extract_solution` doesn't need to anticipate it. function DP.extract_solution(model::InfiniteOpt.InfiniteModel) return Dict(var => _per_support_values(var) @@ -440,7 +440,7 @@ function DP.replace_variables_in_constraint( end # Bool active arises from `_set_covering_combinations`, which keys -# combinations on `LogicalVariableRef → Bool` regardless of whether +# combinations on `LogicalVariableRef -> Bool` regardless of whether # the indicator is infinite. Broadcast over all supports for an # infinite indicator; for a finite (or point-variable) ref, fall # through to the base scalar dispatch. @@ -524,15 +524,6 @@ end _find_infinite_var(v::InfiniteOpt.GeneralVariableRef) = isempty(InfiniteOpt.parameter_refs(v)) ? nothing : v -function _find_infinite_var(expr::JuMP.GenericAffExpr) - for v in keys(expr.terms) - v isa InfiniteOpt.GeneralVariableRef || continue - result = _find_infinite_var(v) - result === nothing || return result - end - return nothing -end - function _find_infinite_var(expr) found = Ref{Any}(nothing) DP._interrogate_variables(expr) do v @@ -603,7 +594,7 @@ function _at_support( return result end -# Map transcribed input JuMP var → master point variable, used as +# Map transcribed input JuMP var -> master point variable, used as # `objective_ref_map`. Needed because MOI AD can't walk a `MeasureRef`, # so a measure objective is transcribed, AD'd flat, and the gradient # mapped back to master point vars. Per-support disjunct cuts skip this @@ -642,7 +633,7 @@ end # expression directly; aggregate ones are transcribed flat and mapped # back via `_transcribed_to_master_point`. function DP.build_loa_master( - model::InfiniteOpt.InfiniteModel, method::DP.LOA + model::InfiniteOpt.InfiniteModel, method::DP.LOA, sink = nothing )::DP._LOAMaster # Keep only linear, non-aggregate constraints. Nonlinear and # aggregate-wrapped ones (e.g. `∫(x^2,t) ≤ c`) re-enter as OA cuts, @@ -719,7 +710,7 @@ function DP.build_loa_master( return DP._LOAMaster(master, binary_map, variable_map, objective_sense, original_objective, alpha_oa, objective_ref_map, DP._build_disaggregator( - model, method.inner_method, variable_map, binary_map)) + model, method.inner_method, variable_map, binary_map, sink)) end # Record one master-space disaggregation for `InfiniteModel`. The Hull @@ -728,7 +719,7 @@ end # support — `disaggregate_expression` then substitutes the point # disaggregated variable per support. Finite (parameter-free) entries # key once, unsliced. -function DP._record_disaggregation( +function DP.record_disaggregation( hull::DP._Hull, ::InfiniteOpt.InfiniteModel, variable, @@ -834,7 +825,7 @@ end # (aggregate, expects a transcribed-`JuMP.VariableRef`-keyed scalar # dict). Translate the base per-support `Vector`-valued point into # whichever shape `original_objective` needs. -function DP._objective_linearization_point( +function DP.objective_linearization_point( model::InfiniteOpt.InfiniteModel, linearization_point::AbstractDict ) @@ -854,7 +845,7 @@ end # Constraints with infinite-parameter dependence (e.g. `x(t) ≥ 0`) # transcribe to a per-support `AbstractArray` of scalar expressions — # one OA cut is emitted per support. -function DP._add_global_oa_cuts( +function DP.add_global_oa_cuts( model::InfiniteOpt.InfiniteModel, master::DP._LOAMaster, result::NamedTuple, @@ -907,9 +898,9 @@ end # `add_disjunct_oa_cuts` driver calls). Non-aggregate constraints fan out # per support via `_infinite_cut_info`; aggregate ones (`MeasureRef`) are # transcribed flat and handed to the base `_add_oa_cut_for_constraint`. -# Transcription is rebuilt per aggregate constraint, not cached — a rare -# path (Hull errors on it), so the redundancy is a non-issue. -function DP._add_disjunct_constraint_oa_cuts( +# `cache` memoizes the transcription maps once per pass (lazily, on the +# first aggregate constraint) so they are not rebuilt per constraint. +function DP.add_disjunct_constraint_oa_cuts( model::InfiniteOpt.InfiniteModel, constraint::JuMP.AbstractConstraint, master::DP._LOAMaster, @@ -917,18 +908,21 @@ function DP._add_disjunct_constraint_oa_cuts( active, result::NamedTuple, method::DP.LOA, - penalty_sign::Int + penalty_sign::Int, + cache ) if _has_aggregate_ref(constraint.func) - InfiniteOpt.build_transformation_backend!(model) + if cache[] === nothing + InfiniteOpt.build_transformation_backend!(model) + cache[] = (_transcribe_linearization_point( + model, result.linearization_point), + _transcribed_to_master_point(model, master.variable_map)) + end + transcribed_xk, transcribed_to_master = cache[] transcribed_func = InfiniteOpt.transformation_expression( constraint.func) transcribed_constraint = JuMP.ScalarConstraint( transcribed_func, constraint.set) - transcribed_xk = _transcribe_linearization_point( - model, result.linearization_point) - transcribed_to_master = _transcribed_to_master_point( - model, master.variable_map) for (point_binary, _, _) in _infinite_cut_info(binary_ref, active, transcribed_func, result.linearization_point, master.variable_map) @@ -986,36 +980,12 @@ function DP.fix_combination( end end -# Fix the combination, run `f()`, then always undo. The infinite path's -# per-support point-equality pins must be cleared between iterations, so -# this needs the `try/finally` the finite base omits. -function DP.with_fixed_combination( - f, model::InfiniteOpt.InfiniteModel, combination::AbstractDict - ) - undo = DP.fix_combination(model, combination) - try - return f() - finally - undo() - end -end - -# Pin a copy-side binary on an NLPF copy of an `InfiniteModel`. The -# infinite `with_fixed_combination` undoes the original's fix before the -# NLPF fall-through, so the copy arrives unfixed and is pinned here. The -# copy is discarded after the solve, so a point-equality constraint -# needs no teardown; using constraints rather than `JuMP.fix` avoids -# force-deleting bounds on the relaxed copy (whose bound refs may not -# survive `copy_model`). -function DP.nlpf_fix_on_copy( - copy::InfiniteOpt.InfiniteModel, binary, value::Bool - ) - JuMP.@constraint(copy, binary == (value ? 1.0 : 0.0)) - return -end -function DP.nlpf_fix_on_copy( - copy::InfiniteOpt.InfiniteModel, binary, value::AbstractVector{Bool} - ) +# Per-support fix for an infinite logical's `Vector{Bool}` value: an +# equality at each support. `with_fixed_combination` undid the original's +# fix before the NLPF fall-through, so this is the only fix on the copy. +# Equalities (not `JuMP.fix`) avoid force-deleting the relaxed copy's +# bounds, whose refs may not survive `copy_model`. +function DP._fix_binary_on_copy(copy, binary, value::AbstractVector) underlying = binary isa JuMP.GenericAffExpr ? only(keys(binary.terms)) : binary for (k, support) in enumerate(_supports_of(underlying)) @@ -1086,12 +1056,4 @@ _add_to_transcribed_dict( ) = (d[ts] = values[1]; nothing) -# Finite var: single transcribed ref, scalar value (feas-side path) -_add_to_transcribed_dict( - d::AbstractDict, - ts::JuMP.AbstractVariableRef, - value::Real - ) = - (d[ts] = value; nothing) - end diff --git a/src/datatypes.jl b/src/datatypes.jl index 14b96ab0..11e7afd7 100644 --- a/src/datatypes.jl +++ b/src/datatypes.jl @@ -417,8 +417,11 @@ constraints. """ struct Hull{T} <: AbstractReformulationMethod value::T - function Hull(ϵ::T = 1e-6) where {T} - new{T}(ϵ) + # Internal: LOA installs a Dict here to collect the disaggregation + # map during reformulation; `nothing` for every other use. + sink::Any + function Hull(ϵ::T = 1e-6; sink = nothing) where {T} + new{T}(ϵ, sink) end end @@ -427,11 +430,13 @@ mutable struct _Hull{V <: JuMP.AbstractVariableRef, T} <: AbstractReformulationM value::T disjunction_variables::Dict{V, Vector{V}} disjunct_variables::Dict{Tuple{V, Union{V, JuMP.GenericAffExpr{T, V}}}, V} + sink::Any function _Hull(method::Hull{T}, vrefs::Set{V}) where {T, V <: JuMP.AbstractVariableRef} new{V, T}( method.value, - Dict{V, Vector{V}}(vref => V[] for vref in vrefs), - Dict{Tuple{V, Union{V, JuMP.GenericAffExpr{T, V}}}, V}() + Dict{V, Vector{V}}(vref => V[] for vref in vrefs), + Dict{Tuple{V, Union{V, JuMP.GenericAffExpr{T, V}}}, V}(), + method.sink ) end end @@ -596,11 +601,6 @@ mutable struct GDPData{M <: JuMP.AbstractModel, V <: JuMP.AbstractVariableRef, C reformulation_variables::Vector{V} reformulation_constraints::Vector{C} - # Hull disaggregated variable for each (original variable, indicator). - # Recorded so logic-based OA can rebuild convex-hull cuts after the - # reformulation's temporary `_Hull` disaggregation state is discarded. - disaggregations::Dict{Tuple{V, LogicalVariableRef{M}}, V} - # Solution data solution_method::Union{Nothing, AbstractSolutionMethod} ready_to_optimize::Bool @@ -619,7 +619,6 @@ mutable struct GDPData{M <: JuMP.AbstractModel, V <: JuMP.AbstractVariableRef, C Dict{V, Tuple{T, T}}(), Vector{V}(), Vector{C}(), - Dict{Tuple{V, LogicalVariableRef{M}}, V}(), nothing, false, ) diff --git a/src/hull.jl b/src/hull.jl index a91a9d65..9075799f 100644 --- a/src/hull.jl +++ b/src/hull.jl @@ -42,8 +42,8 @@ function _disaggregate_variable( #temp storage push!(method.disjunction_variables[vref], dvref) method.disjunct_variables[vref, bvref] = dvref - #persist for logic-based OA, which needs the map after reformulation - _disaggregations(model)[(vref, lvref)] = dvref + #record into the LOA sink when one is installed (LOA-Hull only) + method.sink === nothing || (method.sink[(vref, lvref)] = dvref) #create bounding constraints dvname = JuMP.name(dvref) lbname = isempty(dvname) ? "" : "$(dvname)_lower_bound" @@ -256,7 +256,8 @@ function reformulate_disjunction(model::JuMP.AbstractModel, disj::Disjunction, m return ref_cons end function reformulate_disjunction(model::JuMP.AbstractModel, disj::Disjunction, method::_Hull) - return reformulate_disjunction(model, disj, Hull(method.value)) + return reformulate_disjunction(model, disj, + Hull(method.value; sink = method.sink)) end function reformulate_disjunct_constraint( diff --git a/src/loa.jl b/src/loa.jl index ef41b127..ff722d84 100644 --- a/src/loa.jl +++ b/src/loa.jl @@ -1,10 +1,6 @@ ################################################################################ # LOGIC-BASED OUTER APPROXIMATION (LOA) ################################################################################ -# Iterate a primary NLP (inner-method reformulation, binaries fixed) and a -# master MILP accumulating OA and no-good cuts. Infeasible NLPs fall -# through to a slacked feasibility problem (NLPF) for the cut site. -################################################################################ ################################################################################ # METHOD TYPE @@ -106,13 +102,14 @@ _gap(::Val{_MOI.MAX_SENSE}, best, bound) = bound - best function reformulate_model(model::JuMP.AbstractModel, method::LOA) _clear_reformulations(model) combinations = _set_covering_combinations(model) - reformulate_model(model, method.inner_method) - - master = build_loa_master(model, method) - # Relax the original's binaries once (it is only ever the NLP, never a - # MILP), so a pure NLP solver handles every solve and per-iteration - # fixing overwrites in place. After build_loa_master, which keeps the - # master's binaries. + # Hull needs the disaggregation map; thread a LOA-owned sink through + # the inner reformulation to collect it (kept off GDPData). + inner, sink = _loa_inner_method(model, method.inner_method) + reformulate_model(model, inner) + + master = build_loa_master(model, method, sink) + # The original is only ever the NLP (build_loa_master keeps the + # master's binaries), so relax it once; fixing overwrites in place. relax_logical_vars(model) JuMP.set_optimizer(model, method.nlp_optimizer) JuMP.set_silent(model) @@ -126,9 +123,8 @@ function reformulate_model(model::JuMP.AbstractModel, method::LOA) best_result = nothing master_bound = nothing - # Seed the master with one OA cut per disjunct via the set-covering - # NLPs. `previous_result` warm-starts the next NLP from the last - # feasible primal (NLPF primals are slack-distorted, so skipped). + # Seed the master with an OA cut per set-covering combination, each + # NLP warm-started from the last feasible primal. previous_result = nothing for combination in combinations time() < loop_deadline || break @@ -145,12 +141,9 @@ function reformulate_model(model::JuMP.AbstractModel, method::LOA) result.feasible && (previous_result = result) end - # Master/NLP loop: solve the master for a bound (`master_bound` is - # `alpha_oa`, not the slack-penalized objective), solve the NLP at its - # combination, update the incumbent. Exit on convergence (bound meets - # incumbent within `convergence_tol` AND total slack below - # `slack_tol`), infeasible master, `max_iter`, or time. The slack gate - # keeps the stop safe: nonconvex crossings pay slack and keep going. + # Master/NLP loop: `alpha_oa` is the bound, the NLP refines the + # incumbent. Exit on convergence (bound meets incumbent and slack + # settled), infeasible master, max_iter, or time. converged = false for _ in 1:method.max_iter time() < loop_deadline || break @@ -200,11 +193,8 @@ function reformulate_model(model::JuMP.AbstractModel, method::LOA) return end -# Report the final gap. `[converged]` = passed the convergence test; -# `[limit hit]` = stopped on max_iter / time / exhausted master with a -# gap open; `[no bound]` = the master never produced a bound. The bound -# is rigorous only for a convex inner problem; on a nonconvex one it can -# cross the incumbent (a negative gap). +# Report the final gap. The bound is rigorous only for a convex inner +# problem; on a nonconvex one it can cross the incumbent (negative gap). function _report_loa_gap( best_objective::Real, best_result, @@ -271,7 +261,13 @@ _is_linear_F(::Type) = false # OVERRIDABLE. Build the master MILP: copy the variables and linear # constraints, install `alpha_oa` as the objective auxiliary. Nonlinear # objective and disjunct constraints enter as OA cuts per NLP solve. -function build_loa_master(model::JuMP.AbstractModel, method::LOA) +# `sink` is the disaggregation map collected by the inner Hull +# reformulation (`nothing` for Big-M / MBM). +function build_loa_master( + model::JuMP.AbstractModel, + method::LOA, + sink = nothing + ) original_objective = JuMP.objective_function(model) objective_sense = JuMP.objective_sense(model) variable_type = JuMP.variable_ref_type(typeof(model)) @@ -303,25 +299,38 @@ function build_loa_master(model::JuMP.AbstractModel, method::LOA) JuMP.@objective(master, objective_sense, alpha_oa) disaggregator = _build_disaggregator(model, method.inner_method, - variable_map, binary_map) + variable_map, binary_map, sink) return _LOAMaster(master, binary_map, variable_map, objective_sense, original_objective, alpha_oa, variable_map, disaggregator) end +# The inner reformulation method LOA runs, plus the disaggregation sink to +# collect (Big-M / MBM need none). For Hull, return a sink-carrying copy +# so the reformulation records its `(variable, indicator) -> disaggregated +# variable` map into a fresh LOA-owned `Dict`. +_loa_inner_method(::JuMP.AbstractModel, inner::Union{BigM, MBM}) = + (inner, nothing) +function _loa_inner_method(model::JuMP.AbstractModel, inner::Hull) + V = JuMP.variable_ref_type(typeof(model)) + sink = Dict{Tuple{V, LogicalVariableRef{typeof(model)}}, V}() + return (Hull(inner.value; sink = sink), sink) +end + # Master-space Hull disaggregator for the convex-hull cut emitter -# (`nothing` for Big-M / MBM). Remaps the recorded `(variable, indicator) -# -> disaggregated variable` map into master refs. +# (`nothing` for Big-M / MBM). Remaps the collected `(variable, indicator) +# -> disaggregated variable` sink into master refs. _build_disaggregator(::JuMP.AbstractModel, ::Union{BigM, MBM}, - variable_map, binary_map) = nothing + variable_map, binary_map, sink) = nothing function _build_disaggregator( model::JuMP.AbstractModel, inner::Hull, variable_map::AbstractDict, - binary_map::AbstractDict + binary_map::AbstractDict, + sink ) hull = _Hull(inner, Set{valtype(variable_map)}()) - for ((variable, indicator), disaggregated) in _disaggregations(model) - _record_disaggregation(hull, model, variable_map[variable], + for ((variable, indicator), disaggregated) in sink + record_disaggregation(hull, model, variable_map[variable], binary_map[indicator], variable_map[disaggregated]) end return hull @@ -331,7 +340,7 @@ end # disaggregator. Base keys it directly by `(variable, binary)`; the # InfiniteOpt extension keys per support so per-support cut emission # matches. -function _record_disaggregation( +function record_disaggregation( hull::_Hull, ::JuMP.AbstractModel, variable, @@ -373,11 +382,9 @@ function _solve_nlp( primary = with_fixed_combination(model, combination) do JuMP.optimize!(model, ignore_optimize_hook = true) if JuMP.is_solved_and_feasible(model) - lin_point = extract_solution(model) - objective_val = JuMP.objective_value(model) return (combination = combination, - linearization_point = lin_point, - objective = objective_val, feasible = true) + linearization_point = extract_solution(model), + objective = JuMP.objective_value(model), feasible = true) end return nothing end @@ -400,7 +407,10 @@ end # primal as the linearization site (equalities/bounds exact, inequalities # violated by at most `u`). function _solve_nlpf( - model::M, combination, method::LOA; deadline::Float64 = Inf + model::M, + combination, + method::LOA; + deadline::Float64 = Inf ) where {M <: JuMP.AbstractModel} # The original's binaries are permanently relaxed, so this copy is # continuous — any NLP solver (e.g. Ipopt) can solve NLPF. @@ -425,14 +435,9 @@ function _solve_nlpf( JuMP.@objective(copy, Min, u) - # Pin the copy's binaries to the combination. Finite: the original - # stays fixed so the copy inherits it (base no-op). Infinite: the - # original is unfixed each iteration, so the override re-pins here. - for (indicator, value) in combination - binary = _binary_on_copy( - _indicator_to_binary(model)[indicator], ref_map) - nlpf_fix_on_copy(copy, binary, value) - end + # Fix the combination on the copy so NLPF solves at the chosen + # binaries, not by leaning on the copy inheriting the original's fix. + fix_combination_on_copy(copy, model, combination, ref_map) JuMP.optimize!(copy, ignore_optimize_hook = true) JuMP.has_values(copy) || return nothing @@ -443,20 +448,28 @@ function _solve_nlpf( objective = Inf, feasible = false) end -# Translate a binary reference from the original model to its -# counterpart on the copy. Direct refs go through `ref_map`; -# complement-form `1 - y_orig` rebuilds as `1 - ref_map[y_orig]`. -_binary_on_copy(binary::JuMP.AbstractVariableRef, ref_map) = - ref_map[binary] -function _binary_on_copy(binary::JuMP.GenericAffExpr, ref_map) - underlying = only(keys(binary.terms)) - return 1.0 - ref_map[underlying] +# Fix each indicator's binary on the NLPF copy at its combination value, +# mapped into copy space. Explicit so NLPF is self-contained rather than +# relying on the copy inheriting the original's fix. +function fix_combination_on_copy(copy, model, combination, ref_map) + for (indicator, value) in combination + binary = _binary_on_copy( + _indicator_to_binary(model)[indicator], ref_map) + _fix_binary_on_copy(copy, binary, value) + end + return end -# OVERRIDABLE. Pin a copy-side binary to a combination value. Base no-op -# (the finite copy inherits the original's fix); the InfiniteOpt -# extension re-pins via constraints. -nlpf_fix_on_copy(copy, binary, value) = nothing +# Map an original binary (or its `1 - y` complement) into NLPF-copy space. +_binary_on_copy(binary::JuMP.AbstractVariableRef, ref_map) = ref_map[binary] +_binary_on_copy(binary::JuMP.GenericAffExpr, ref_map) = + 1.0 - ref_map[only(keys(binary.terms))] + +# OVERRIDABLE. Fix one binary on the copy at a scalar value via an +# equality (the copy is discarded, so the fix needs no teardown). The +# InfiniteOpt extension adds a per-support method for a `Vector{Bool}`. +_fix_binary_on_copy(copy, binary, value::Bool) = + JuMP.@constraint(copy, binary == (value ? 1.0 : 0.0)) _nlpf_should_slack(::Type{<:_MOI.LessThan}) = true _nlpf_should_slack(::Type{<:_MOI.GreaterThan}) = true @@ -481,16 +494,20 @@ function _nlpf_extract_primal(model::JuMP.AbstractModel, ref_map) return result end -# Fix `combination` in place, then run `f()`. No undo: `fix_combination` -# overwrites in place and the binaries are permanently relaxed, so there -# is no per-iteration state to unwind. +# Fix `combination`, run `f()`, then undo. The finite undo is a no-op +# (fixes overwrite in place); the infinite `fix_combination` clears its +# per-support pins. function with_fixed_combination( f, model::JuMP.AbstractModel, combination::AbstractDict ) - fix_combination(model, combination) - return f() + undo = fix_combination(model, combination) + try + return f() + finally + undo() + end end # Iter-to-iter NLP warm start: seed the next NLP from the last FEASIBLE @@ -521,14 +538,14 @@ function commit_combination( return end -# OVERRIDABLE. Apply the combination's fixes in place (force-fix, no -# undo). An extension overrides this for per-support `Vector{Bool}` -# values. +# OVERRIDABLE. Apply the combination's fixes in place and return an undo +# closure (a no-op here, since the finite fixes overwrite in place). An +# extension overrides this for per-support `Vector{Bool}` values. function fix_combination(model::JuMP.AbstractModel, combination::AbstractDict) for (indicator, value) in combination fix_indicator(model, indicator, value) end - return + return () -> nothing end # OVERRIDABLE. Warm-start a variable from the linearization point (stored @@ -538,7 +555,7 @@ set_linearization_start(variable, values::AbstractVector) = JuMP.set_start_value(variable, only(values)) ################################################################################ -# COMBO EXTRACTION (master → NLP) +# COMBO EXTRACTION (master -> NLP) ################################################################################ # Read each indicator's binary value from the master MILP solution. # `combination_val` returns a `Bool` (finite indicator) or per-support @@ -576,11 +593,11 @@ function add_oa_cuts( ) isempty(result.linearization_point) && return linearization = _linearize_at(master.original_objective, - _objective_linearization_point(model, result.linearization_point), + objective_linearization_point(model, result.linearization_point), master.objective_ref_map) _add_objective_cut( Val(master.objective_sense), master, linearization, method) - _add_global_oa_cuts(model, master, result, method) + add_global_oa_cuts(model, master, result, method) add_disjunct_oa_cuts(model, master, result, method) return end @@ -588,7 +605,7 @@ end # OVERRIDABLE. The point at which the master objective is linearized. # Base uses the raw point; an extension whose `original_objective` is # transcribed or derived overrides this to match its shape. -_objective_linearization_point(::JuMP.AbstractModel, linearization_point) = +objective_linearization_point(::JuMP.AbstractModel, linearization_point) = linearization_point # Slacked objective cut. MIN: `lin <= alpha_oa + sigma`; MAX symmetric. @@ -613,7 +630,7 @@ _add_objective_cut_body(::Val{_MOI.MAX_SENSE}, master, lin, slack) = # skipping variable bounds, linear functions, and reformulation # constraints. Base (scalar) version; an extension overrides it for # vector-valued / transcribed linearizations. -function _add_global_oa_cuts( +function add_global_oa_cuts( model::JuMP.AbstractModel, master::_LOAMaster, result::NamedTuple, @@ -641,7 +658,8 @@ end # Add slacked OA cuts for each active disjunct's nonlinear constraints. # This driver (iterate active disjuncts and their constraints) is shared; -# the per-constraint emission is the OVERRIDABLE seam below. +# the per-constraint emission is the OVERRIDABLE seam below. `cache` is a +# per-pass scratch passed to each seam call (used by the extension). function add_disjunct_oa_cuts( model::JuMP.AbstractModel, master::_LOAMaster, @@ -649,6 +667,7 @@ function add_disjunct_oa_cuts( method::LOA ) penalty_sign = _penalty_sign(Val(master.objective_sense)) + cache = Ref{Any}(nothing) for (indicator, active) in result.combination is_active(active) || continue haskey(_indicator_to_constraints(model), indicator) || continue @@ -656,17 +675,18 @@ function add_disjunct_oa_cuts( cref isa DisjunctConstraintRef || continue constraint = _disjunct_constraints(model)[ JuMP.index(cref)].constraint - _add_disjunct_constraint_oa_cuts(model, constraint, master, + add_disjunct_constraint_oa_cuts(model, constraint, master, master.binary_map[indicator], active, result, method, - penalty_sign) + penalty_sign, cache) end end end # OVERRIDABLE. Emit the OA cut(s) for one active disjunct constraint. # Base emits a single cut; the InfiniteOpt extension fans out per -# support. `active` is unused in base but drives the extension's fan-out. -function _add_disjunct_constraint_oa_cuts( +# support. `active` is unused in base but drives the extension's fan-out; +# `cache` is a per-pass scratch the extension memoizes transcription into. +function add_disjunct_constraint_oa_cuts( ::JuMP.AbstractModel, constraint::JuMP.AbstractConstraint, master::_LOAMaster, @@ -674,7 +694,8 @@ function _add_disjunct_constraint_oa_cuts( active, result::NamedTuple, method::LOA, - penalty_sign::Int + penalty_sign::Int, + cache ) _add_oa_cut_for_constraint( constraint, master, binary_ref, result.linearization_point, @@ -694,9 +715,9 @@ function _penalized_slack(master::_LOAMaster, method::LOA, penalty_sign::Int) return slack end -# Linearize constraint at `linearization_point`, then dispatch on the -# constraint's set to emit slacked OA cut(s) gated by `M(1 − binary)`. -# Linear constraints are exact via BigM and skipped. +# Linearize the constraint at `linearization_point`, then emit slacked OA +# cut(s) gated to match the inner reformulation (Big-M / MBM or Hull). +# Linear constraints are exact via the reformulation and skipped. function _add_oa_cut_for_constraint( constraint::JuMP.AbstractConstraint, master::_LOAMaster, @@ -715,99 +736,19 @@ function _add_oa_cut_for_constraint( return end -# Route the disjunct OA cut through the gating that matches the inner -# reformulation: Big-M / MBM gate with `M(1 - y)`; Hull emits the -# convex-hull cut on disaggregated variables (no big-M). -_emit_disjunct_oa_cut( - ::Union{BigM, MBM}, - set, - master::_LOAMaster, - binary_ref, - linearization, - method::LOA, - penalty_sign::Int - ) = - _emit_disjunct_oa_cut( - set, master, binary_ref, linearization, method, penalty_sign) -_emit_disjunct_oa_cut( - ::Hull, - set, - master::_LOAMaster, - binary_ref, - linearization, - method::LOA, - penalty_sign::Int - ) = - _emit_hull_oa_cut( - set, master, binary_ref, linearization, method, penalty_sign) +# The `<= 0` directions of an OA cut for `set`: `lin - rhs` for LessThan, +# `rhs - lin` for GreaterThan, both for EqualTo / Interval. The caller +# adds the gating, slack, and (for Hull) disaggregation per direction. +_oa_cut_terms(set::_MOI.GreaterThan, lin) = (_set_rhs(set) - lin,) +_oa_cut_terms(set::_MOI.EqualTo, lin) = + (lin - _MOI.constant(set), _MOI.constant(set) - lin) +_oa_cut_terms(set::_MOI.Interval, lin) = (lin - set.upper, set.lower - lin) +_oa_cut_terms(set, lin) = (lin - _set_rhs(set),) -# Emit the slacked, big-M-gated OA cut(s) for a disjunct constraint. The -# set fixes the direction: `<=` in place, `>=` negated, equality/interval -# both directions sharing one slack. -function _emit_disjunct_oa_cut( - set::_MOI.LessThan, - master::_LOAMaster, - binary_ref, - linearization, - method::LOA, - penalty_sign::Int - ) - slack = _penalized_slack(master, method, penalty_sign) - JuMP.@constraint(master.model, - (linearization - _set_rhs(set)) - slack <= - method.M_value * (1 - binary_ref)) - return -end -function _emit_disjunct_oa_cut( - set::_MOI.GreaterThan, - master::_LOAMaster, - binary_ref, - linearization, - method::LOA, - penalty_sign::Int - ) - slack = _penalized_slack(master, method, penalty_sign) - JuMP.@constraint(master.model, - (_set_rhs(set) - linearization) - slack <= - method.M_value * (1 - binary_ref)) - return -end -function _emit_disjunct_oa_cut( - set::_MOI.EqualTo, - master::_LOAMaster, - binary_ref, - linearization, - method::LOA, - penalty_sign::Int - ) - c = _MOI.constant(set) - slack = _penalized_slack(master, method, penalty_sign) - JuMP.@constraint(master.model, - (linearization - c) - slack <= method.M_value * (1 - binary_ref)) - JuMP.@constraint(master.model, - (c - linearization) - slack <= method.M_value * (1 - binary_ref)) - return -end -function _emit_disjunct_oa_cut( - set::_MOI.Interval, - master::_LOAMaster, - binary_ref, - linearization, - method::LOA, - penalty_sign::Int - ) - slack = _penalized_slack(master, method, penalty_sign) - JuMP.@constraint(master.model, - (linearization - set.upper) - slack <= - method.M_value * (1 - binary_ref)) - JuMP.@constraint(master.model, - (set.lower - linearization) - slack <= - method.M_value * (1 - binary_ref)) - return -end -# Fallback for vector / future set types: emit the `≤`-direction cut -# against the set's RHS (`_set_rhs` defaults to 0). +# Big-M / MBM disjunct cut: each direction slacked and gated by +# `M(1 - binary)`. function _emit_disjunct_oa_cut( + ::Union{BigM, MBM}, set, master::_LOAMaster, binary_ref, @@ -816,80 +757,20 @@ function _emit_disjunct_oa_cut( penalty_sign::Int ) slack = _penalized_slack(master, method, penalty_sign) - JuMP.@constraint(master.model, - (linearization - _set_rhs(set)) - slack <= - method.M_value * (1 - binary_ref)) + for term in _oa_cut_terms(set, linearization) + JuMP.@constraint(master.model, + term - slack <= method.M_value * (1 - binary_ref)) + end return end -# Convex-hull disjunct OA cut. `disaggregate_expression` rewrites the OA -# linearization `linearization - rhs` into disaggregated space (each -# variable becomes its per-disjunct copy, the constant scaled by the -# binary), giving the sharp cut that switches off at `y = 0` with no -# big-M. The slack handles the nonconvex case. Set dispatch mirrors -# `_emit_disjunct_oa_cut`. -function _emit_hull_oa_cut( - set::_MOI.LessThan, - master::_LOAMaster, - binary_ref, - linearization, - method::LOA, - penalty_sign::Int - ) - body = disaggregate_expression(master.model, - linearization - _set_rhs(set), binary_ref, master.disaggregator) - slack = _penalized_slack(master, method, penalty_sign) - JuMP.@constraint(master.model, body - slack <= 0) - return -end -function _emit_hull_oa_cut( - set::_MOI.GreaterThan, - master::_LOAMaster, - binary_ref, - linearization, - method::LOA, - penalty_sign::Int - ) - body = disaggregate_expression(master.model, - linearization - _set_rhs(set), binary_ref, master.disaggregator) - slack = _penalized_slack(master, method, penalty_sign) - JuMP.@constraint(master.model, body + slack >= 0) - return -end -function _emit_hull_oa_cut( - set::_MOI.EqualTo, - master::_LOAMaster, - binary_ref, - linearization, - method::LOA, - penalty_sign::Int - ) - body = disaggregate_expression(master.model, - linearization - _set_rhs(set), binary_ref, master.disaggregator) - slack = _penalized_slack(master, method, penalty_sign) - JuMP.@constraint(master.model, body - slack <= 0) - JuMP.@constraint(master.model, body + slack >= 0) - return -end -function _emit_hull_oa_cut( - set::_MOI.Interval, - master::_LOAMaster, - binary_ref, - linearization, - method::LOA, - penalty_sign::Int - ) - upper = disaggregate_expression(master.model, - linearization - set.upper, binary_ref, master.disaggregator) - lower = disaggregate_expression(master.model, - linearization - set.lower, binary_ref, master.disaggregator) - slack = _penalized_slack(master, method, penalty_sign) - JuMP.@constraint(master.model, upper - slack <= 0) - JuMP.@constraint(master.model, lower + slack >= 0) - return -end -# Fallback for vector / future set types: one upper-bound cut. -function _emit_hull_oa_cut( +# Convex-hull disjunct cut: `disaggregate_expression` rewrites each +# direction into disaggregated space (variables -> per-disjunct copies, +# constant scaled by the binary), so the cut switches off at `y = 0` with +# no big-M. It is linear in its argument, so the two-sided sets need no +# special casing. +function _emit_disjunct_oa_cut( + ::Hull, set, master::_LOAMaster, binary_ref, @@ -897,62 +778,29 @@ function _emit_hull_oa_cut( method::LOA, penalty_sign::Int ) - body = disaggregate_expression(master.model, - linearization - _set_rhs(set), binary_ref, master.disaggregator) slack = _penalized_slack(master, method, penalty_sign) - JuMP.@constraint(master.model, body - slack <= 0) + for term in _oa_cut_terms(set, linearization) + body = disaggregate_expression( + master.model, term, binary_ref, master.disaggregator) + JuMP.@constraint(master.model, body - slack <= 0) + end return end -# Slacked global OA row(s): each carries a penalized slack so a nonconvex -# (invalid) linearization can't make the master infeasible. EqualTo / -# Interval get a two-sided pair sharing one slack; unknown sets fall back -# to a hard cut. +# Slacked global OA row(s): each direction carries a penalized slack so a +# nonconvex linearization can't make the master infeasible. Unknown sets +# fall back to a hard cut. function _add_global_oa_row( master::_LOAMaster, lin, - set::_MOI.LessThan, + set::Union{_MOI.LessThan, _MOI.GreaterThan, _MOI.EqualTo, _MOI.Interval}, method::LOA, penalty_sign::Int ) slack = _penalized_slack(master, method, penalty_sign) - JuMP.@constraint(master.model, lin - _MOI.constant(set) <= slack) - return -end -function _add_global_oa_row( - master::_LOAMaster, - lin, - set::_MOI.GreaterThan, - method::LOA, - penalty_sign::Int - ) - slack = _penalized_slack(master, method, penalty_sign) - JuMP.@constraint(master.model, _MOI.constant(set) - lin <= slack) - return -end -function _add_global_oa_row( - master::_LOAMaster, - lin, - set::_MOI.EqualTo, - method::LOA, - penalty_sign::Int - ) - slack = _penalized_slack(master, method, penalty_sign) - c = _MOI.constant(set) - JuMP.@constraint(master.model, lin - c <= slack) - JuMP.@constraint(master.model, c - lin <= slack) - return -end -function _add_global_oa_row( - master::_LOAMaster, - lin, - set::_MOI.Interval, - method::LOA, - penalty_sign::Int - ) - slack = _penalized_slack(master, method, penalty_sign) - JuMP.@constraint(master.model, lin - set.upper <= slack) - JuMP.@constraint(master.model, set.lower - lin <= slack) + for term in _oa_cut_terms(set, lin) + JuMP.@constraint(master.model, term <= slack) + end return end function _add_global_oa_row(master::_LOAMaster, lin, set, ::LOA, ::Int) diff --git a/src/model.jl b/src/model.jl index efd6ff08..0f26a4f0 100644 --- a/src/model.jl +++ b/src/model.jl @@ -83,7 +83,6 @@ _indicator_to_constraints(model::JuMP.AbstractModel) = gdp_data(model).indicator _constraint_to_indicator(model::JuMP.AbstractModel) = gdp_data(model).constraint_to_indicator _reformulation_variables(model::JuMP.AbstractModel) = gdp_data(model).reformulation_variables _reformulation_constraints(model::JuMP.AbstractModel) = gdp_data(model).reformulation_constraints -_disaggregations(model::JuMP.AbstractModel) = gdp_data(model).disaggregations _variable_bounds(model::JuMP.AbstractModel) = gdp_data(model).variable_bounds _solution_method(model::JuMP.AbstractModel) = gdp_data(model).solution_method # Get the current solution method _ready_to_optimize(model::JuMP.AbstractModel) = gdp_data(model).ready_to_optimize # Determine if the model is ready to call `optimize!` without a optimize hook diff --git a/src/reformulate.jl b/src/reformulate.jl index 86270104..9a3b65cb 100644 --- a/src/reformulate.jl +++ b/src/reformulate.jl @@ -24,7 +24,6 @@ function _clear_reformulations(model::JuMP.AbstractModel) delete.(model, _reformulation_variables(model)) empty!(gdp_data(model).reformulation_constraints) empty!(gdp_data(model).reformulation_variables) - empty!(gdp_data(model).disaggregations) empty!(gdp_data(model).variable_bounds) return end diff --git a/test/constraints/loa.jl b/test/constraints/loa.jl index 16a06d9f..198ada2a 100644 --- a/test/constraints/loa.jl +++ b/test/constraints/loa.jl @@ -158,7 +158,7 @@ end function test_loa_nonlinear_global() # max x s.t. x^2 <= 25 (global), (x <= 3) ∨ (x <= 8), 0 <= x <= 10. # Disjunct Y[2] permits x up to 8 but the global x^2 <= 25 bounds - # x to 5. Verifies that `_add_global_oa_cuts` emits the + # x to 5. Verifies that `add_global_oa_cuts` emits the # linearization of the global into the master without breaking # the loop. Result must hit the global-binding optimum. ipopt = optimizer_with_attributes(Ipopt.Optimizer, @@ -529,6 +529,26 @@ function test_loa_hull_complement_nonlinear() @test objective_value(model) ≈ 8.0 atol = 1e-3 end +function test_loa_hull_nested_sink() + # The inner disjunction (over y) is a disjunct of the outer (gated by + # z[1]). Hull's nested redispatch must propagate the LOA sink so the + # inner disaggregations are recorded, else LOA-Hull emits the nested + # cut on the aggregated variable. + model = GDPModel() + @variable(model, 0 <= x <= 10) + @variable(model, y[1:2], Logical) + @variable(model, z[1:2], Logical) + @constraint(model, x <= 3, Disjunct(y[1])) + @constraint(model, x <= 8, Disjunct(y[2])) + @disjunction(model, y, Disjunct(z[1])) + @constraint(model, x <= 1, Disjunct(z[2])) + @disjunction(model, z) + sink = Dict{Any, Any}() + DP.reformulate_model(model, Hull(; sink = sink)) + @test haskey(sink, (x, y[1])) + @test haskey(sink, (x, y[2])) +end + @testset "LOA" begin test_loa_datatype() test_set_covering_combos() @@ -554,4 +574,5 @@ end test_loa_hull_nonlinear_global() test_loa_hull_nonlinear_disjunct() test_loa_hull_complement_nonlinear() + test_loa_hull_nested_sink() end diff --git a/test/extensions/InfiniteDisjunctiveProgramming.jl b/test/extensions/InfiniteDisjunctiveProgramming.jl index e1011be6..6c73d55d 100644 --- a/test/extensions/InfiniteDisjunctiveProgramming.jl +++ b/test/extensions/InfiniteDisjunctiveProgramming.jl @@ -797,7 +797,7 @@ function test_loa_infinite_nonlinear_global() # Disjunct Y[2] permits x up to 8 but the global x^2 <= 25 caps # x at 5. The per-support global transcribes to an `AbstractArray` # of scalar constraints, so this exercises the array branch of - # `_add_global_oa_cuts`. Without the global cut the + # `add_global_oa_cuts`. Without the global cut the # master would allow x = 8 and report 8.0; the binding optimum # is ∫5 dt = 5. ipopt = optimizer_with_attributes(Ipopt.Optimizer, @@ -954,7 +954,7 @@ function test_loa_infinite_aggregate_global() # (y >= 1) ∨ (y >= 3), 0 <= x, y <= 10 over t ∈ [0, 1]. # The aggregate global transcribes to a single scalar (the # measure is flattened), exercising the non-array branch of - # `_add_global_oa_cuts`. Y[1] (y >= 1) is the cheaper + # `add_global_oa_cuts`. Y[1] (y >= 1) is the cheaper # disjunct: x = 1 satisfies x >= y and ∫x^2 = 1 <= 4, giving # objective ∫1 dt = 1. ipopt = optimizer_with_attributes(Ipopt.Optimizer, From c77f732c95ae4668cbed3a0074d2b26dcfe6b991 Mon Sep 17 00:00:00 2001 From: d227nguyen Date: Wed, 1 Jul 2026 16:18:55 -0400 Subject: [PATCH 58/59] Docstrings and tests for LOA --- docs/src/index.md | 12 +- ext/InfiniteDisjunctiveProgramming.jl | 64 ++--- src/datatypes.jl | 76 ++++++ src/loa.jl | 352 +++++++------------------- src/utilities.jl | 103 +++++++- test/constraints/loa.jl | 75 ------ test/utilities.jl | 75 ++++++ 7 files changed, 390 insertions(+), 367 deletions(-) diff --git a/docs/src/index.md b/docs/src/index.md index aa1902e3..615d598e 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -61,7 +61,7 @@ A [`GDPModel`](@ref) is a `JuMP Model` with a [`GDPData`](@ref) field in the mod - `Logical Constraints`: Selector (cardinality) or proposition (Boolean) constraints describing the relationships between the logical variables. - `Disjunct Constraints`: Constraints associated with each disjunct in the model. - `Disjunctions`: Disjunction constraints. -- `Solution Method`: The reformulation technique or solution method. Currently, supported methods include Big-M, Hull, and Indicator Constraints. +- `Solution Method`: The reformulation technique or solution method. Currently, supported methods include Big-M, Multiple Big-M, Hull, P-Split, Indicator Constraints, and Logic-based Outer Approximation (LOA). - `Reformulation Variables`: List of JuMP variables created when reformulating a GDP model into a MIP model. - `Reformulation Constraints`: List of constraints created when reformulating a GDP model into a MIP model. - `Ready to Optimize`: Flag indicating if the model can be optimized. @@ -163,9 +163,15 @@ The following reformulation methods are currently supported: 1. [Big-M](https://optimization.cbe.cornell.edu/index.php?title=Disjunctive_inequalities#Big-M_Reformulation[1][2]): The [`BigM`](@ref) struct is used. -2. [Hull](https://optimization.cbe.cornell.edu/index.php?title=Disjunctive_inequalities#Convex-Hull_Reformulation[1][2]): The [`Hull`](@ref) struct is used. +2. Multiple Big-M: A tighter big-M reformulation that computes a separate big-M value for each disjunct constraint (rather than a single global value) by solving auxiliary mini-models. This is invoked with the [`MBM`](@ref) struct, which requires an optimizer. -3. [Indicator](https://jump.dev/JuMP.jl/stable/manual/constraints/#Indicator-constraints): This method reformulates each disjunct constraint into an indicator constraint with the Boolean reformulation counterpart of the Logical variable used to define the disjunct constraint. This is invoked with [`Indicator`](@ref). +3. [Hull](https://optimization.cbe.cornell.edu/index.php?title=Disjunctive_inequalities#Convex-Hull_Reformulation[1][2]): The [`Hull`](@ref) struct is used. + +4. P-Split: A partitioned reformulation that splits the variables into groups and applies a P-split formulation to each group, giving a relaxation between Big-M and Hull in tightness. This is invoked with the [`PSplit`](@ref) struct. + +5. [Indicator](https://jump.dev/JuMP.jl/stable/manual/constraints/#Indicator-constraints): This method reformulates each disjunct constraint into an indicator constraint with the Boolean reformulation counterpart of the Logical variable used to define the disjunct constraint. This is invoked with [`Indicator`](@ref). + +6. Logic-based Outer Approximation (LOA): An iterative solution method for nonlinear GDPs, rather than a single-shot reformulation. It alternates between a primal NLP (the model reformulated by an inner method — `BigM`, `MBM`, or `Hull` — with the disjunct binaries fixed at the current selection) and a master MILP that accumulates outer-approximation and no-good cuts until the bound meets the incumbent. This is invoked with the [`LOA`](@ref) struct, e.g. `optimize!(m, gdp_method = LOA(Ipopt.Optimizer))`. ## Release Notes diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 0381caf5..c96f275d 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -633,12 +633,10 @@ end # expression directly; aggregate ones are transcribed flat and mapped # back via `_transcribed_to_master_point`. function DP.build_loa_master( - model::InfiniteOpt.InfiniteModel, method::DP.LOA, sink = nothing - )::DP._LOAMaster - # Keep only linear, non-aggregate constraints. Nonlinear and - # aggregate-wrapped ones (e.g. `∫(x^2,t) ≤ c`) re-enter as OA cuts, - # so they are dropped at copy time. Variable bounds survive on - # VariableInfo regardless. + model::InfiniteOpt.InfiniteModel, + method::DP.LOA, + sink = nothing + ) variable_type = InfiniteOpt.GeneralVariableRef master, copy_ref_map = JuMP.copy_model( model; @@ -650,16 +648,10 @@ function DP.build_loa_master( return !_has_aggregate_ref(con.func) end ) - # `copy_model` copies the GDP optimize-hook; clear it so - # `optimize!(master)` doesn't re-trigger reformulation on the - # (empty-GDP-data) master copy. JuMP.set_optimize_hook(master, nothing) JuMP.set_optimizer(master, method.mip_optimizer) JuMP.set_silent(master) - # InfiniteReferenceMap supports indexing but not iteration; build - # a Dict so downstream LOA code can `haskey` / iterate over the - # source-side refs LOA cares about. ref_map = Dict{InfiniteOpt.GeneralVariableRef, InfiniteOpt.GeneralVariableRef}() for v in DP.collect_all_vars(model) @@ -701,7 +693,7 @@ function DP.build_loa_master( variable_map[v] = ref_map[v] end - # Aggregate-wrapped LINEAR constraints (e.g. `𝔼(W, ξ) ≥ α`) were + # Aggregate-wrapped LINEAR constraints (e.g. `E(W, e) ≥ alpha`) were # dropped at copy time and the OA-cut path skips linear `F`, so add # each one to the master directly as a transcribed flat scalar. _add_aggregate_linear_constraints( @@ -713,12 +705,6 @@ function DP.build_loa_master( model, method.inner_method, variable_map, binary_map, sink)) end -# Record one master-space disaggregation for `InfiniteModel`. The Hull -# cut emitter runs through the per-support fan-out over point-evaluated -# variables, so key the map by `(point variable, point binary)` at each -# support — `disaggregate_expression` then substitutes the point -# disaggregated variable per support. Finite (parameter-free) entries -# key once, unsliced. function DP.record_disaggregation( hull::DP._Hull, ::InfiniteOpt.InfiniteModel, @@ -894,22 +880,36 @@ function DP.add_global_oa_cuts( return end -# Per-constraint OA cut emission for `InfiniteModel` (the seam the base -# `add_disjunct_oa_cuts` driver calls). Non-aggregate constraints fan out -# per support via `_infinite_cut_info`; aggregate ones (`MeasureRef`) are -# transcribed flat and handed to the base `_add_oa_cut_for_constraint`. -# `cache` memoizes the transcription maps once per pass (lazily, on the -# first aggregate constraint) so they are not rebuilt per constraint. -function DP.add_disjunct_constraint_oa_cuts( +# Override the disjunct-OA-cut pass for `InfiniteModel`: own the per-pass +# transcription `cache` and delegate each active disjunct constraint to +# the emitter below. Reuses the base active-disjunct walk so only the +# per-constraint emission differs from base. +function DP.add_disjunct_oa_cuts( model::InfiniteOpt.InfiniteModel, - constraint::JuMP.AbstractConstraint, master::DP._LOAMaster, - binary_ref, - active, result::NamedTuple, - method::DP.LOA, - penalty_sign::Int, - cache + method::DP.LOA + ) + penalty_sign = DP._penalty_sign(Val(master.objective_sense)) + cache = Ref{Any}(nothing) + DP._each_active_disjunct_constraint(model, master, + result) do binary_ref, active, constraint + _add_infinite_disjunct_constraint_oa_cuts(model, constraint, + master, binary_ref, active, result, method, penalty_sign, + cache) + end + return +end + +# Emit the OA cut(s) for one active disjunct constraint on an +# `InfiniteModel`. Non-aggregate constraints fan out per support via +# `_infinite_cut_info`; aggregate ones (`MeasureRef`) are transcribed flat +# and handed to `_add_oa_cut_for_constraint`. `cache` memoizes the +# transcription maps once per pass (lazily, on the first aggregate +# constraint). +function _add_infinite_disjunct_constraint_oa_cuts( + model, constraint, master, binary_ref, active, result, method, + penalty_sign, cache ) if _has_aggregate_ref(constraint.func) if cache[] === nothing diff --git a/src/datatypes.jl b/src/datatypes.jl index 11e7afd7..4d8c9208 100644 --- a/src/datatypes.jl +++ b/src/datatypes.jl @@ -571,6 +571,82 @@ A type for using indicator constraint approach for linear disjunctive constraint """ struct Indicator <: AbstractReformulationMethod end +################################################################################ +# LOA +################################################################################ +""" + LOA{O, P, R, T} <: AbstractReformulationMethod + +Logic-based Outer Approximation solver for GDP models. Iterates a primary +NLP (original model reformulated by `inner_method`, binaries fixed per +iteration) and a master MILP accumulating OA and no-good cuts. +`inner_method` is `BigM` (default), `MBM`, or `Hull`. + +## Fields +- `nlp_optimizer::O`: solver for the primary NLP. +- `mip_optimizer::P`: solver for the master MILP (default `nlp_optimizer`). +- `inner_method::R`: NLP reformulation — `BigM`, `MBM`, or `Hull`. +- `max_iter::Int`: max iterations after set-covering seeding. +- `M_value::T`: big-M for the disjunct OA cut gating term. +- `max_slack::T`: upper bound per slack variable. +- `oa_penalty::T`: penalty on slacks in the master objective. +- `convergence_tol::Float64`: relative gap tolerance for the early stop. +- `slack_tol::Float64`: max total slack for which the bound still counts + as converged (positive slack = nonconvex crossing, keep iterating). +- `iteration_time_limit::Float64`: budget (s) for the iteration loop. +- `time_limit::Float64`: overall budget (s) incl. the final solve + (default 3600; `Inf` disables). +""" +struct LOA{O, P, R, T} <: AbstractReformulationMethod + nlp_optimizer::O + mip_optimizer::P + inner_method::R + max_iter::Int + M_value::T + max_slack::T + oa_penalty::T + convergence_tol::Float64 + slack_tol::Float64 + iteration_time_limit::Float64 + time_limit::Float64 + function LOA( + nlp_optimizer::O; + mip_optimizer::P = nlp_optimizer, + max_iter::Int = 10, + M_value::T = 1e9, + max_slack::T = 1e3, + oa_penalty::T = 1e3, + inner_method::R = BigM(M_value), + convergence_tol::Float64 = 1e-6, + slack_tol::Float64 = 1e-4, + iteration_time_limit::Float64 = Inf, + time_limit::Float64 = 3600.0 + ) where {O, P, R <: AbstractReformulationMethod, T} + R <: Union{BigM, MBM, Hull} || error( + "LOA inner_method must be BigM, MBM, or Hull (got $R). " * + "PSplit is not yet supported.") + new{O, P, R, T}(nlp_optimizer, mip_optimizer, inner_method, + max_iter, M_value, max_slack, oa_penalty, + convergence_tol, slack_tol, + iteration_time_limit, time_limit) + end +end + +# The LOA master MILP plus maps from original- to master-model refs. +# `objective_ref_map` splits from `variable_map` so an extension can map +# objective vars separately (identical in base); `disaggregator` is the +# master `_Hull` for Hull cuts, `nothing` for Big-M / MBM. +mutable struct _LOAMaster{M <: JuMP.AbstractModel, OF, AO, BM, VM, RM, DG} + model::M + binary_map::BM + variable_map::VM + objective_sense::_MOI.OptimizationSense + original_objective::OF + alpha_oa::AO + objective_ref_map::RM + disaggregator::DG +end + ################################################################################ # GDP Data ################################################################################ diff --git a/src/loa.jl b/src/loa.jl index ff722d84..14d6e629 100644 --- a/src/loa.jl +++ b/src/loa.jl @@ -3,88 +3,8 @@ ################################################################################ ################################################################################ -# METHOD TYPE +# SENSE HANDLERS ################################################################################ -""" - LOA{O, P, R, T} <: AbstractReformulationMethod - -Logic-based Outer Approximation solver for GDP models. Iterates a primary -NLP (original model reformulated by `inner_method`, binaries fixed per -iteration) and a master MILP accumulating OA and no-good cuts. -`inner_method` is `BigM` (default), `MBM`, or `Hull`. - -## Fields -- `nlp_optimizer::O`: solver for the primary NLP. -- `mip_optimizer::P`: solver for the master MILP (default `nlp_optimizer`). -- `inner_method::R`: NLP reformulation — `BigM`, `MBM`, or `Hull`. -- `max_iter::Int`: max iterations after set-covering seeding. -- `M_value::T`: big-M for the disjunct OA cut gating term. -- `max_slack::T`: upper bound per slack variable. -- `oa_penalty::T`: penalty on slacks in the master objective. -- `convergence_tol::Float64`: relative gap tolerance for the early stop. -- `slack_tol::Float64`: max total slack for which the bound still counts - as converged (positive slack = nonconvex crossing, keep iterating). -- `iteration_time_limit::Float64`: budget (s) for the iteration loop. -- `time_limit::Float64`: overall budget (s) incl. the final solve - (default 3600; `Inf` disables). -""" -struct LOA{O, P, R, T} <: AbstractReformulationMethod - nlp_optimizer::O - mip_optimizer::P - inner_method::R - max_iter::Int - M_value::T - max_slack::T - oa_penalty::T - convergence_tol::Float64 - slack_tol::Float64 - iteration_time_limit::Float64 - time_limit::Float64 - function LOA( - nlp_optimizer::O; - mip_optimizer::P = nlp_optimizer, - max_iter::Int = 10, - M_value::T = 1e9, - max_slack::T = 1e3, - oa_penalty::T = 1e3, - inner_method::R = BigM(M_value), - convergence_tol::Float64 = 1e-6, - slack_tol::Float64 = 1e-4, - iteration_time_limit::Float64 = Inf, - time_limit::Float64 = 3600.0 - ) where {O, P, R <: AbstractReformulationMethod, T} - R <: Union{BigM, MBM, Hull} || error( - "LOA inner_method must be BigM, MBM, or Hull (got $R). " * - "PSplit is not yet supported.") - new{O, P, R, T}(nlp_optimizer, mip_optimizer, inner_method, - max_iter, M_value, max_slack, oa_penalty, - convergence_tol, slack_tol, - iteration_time_limit, time_limit) - end -end - -################################################################################ -# LOA MASTER -################################################################################ -# The LOA master MILP plus maps from original- to master-model refs. -# `objective_ref_map` splits from `variable_map` so an extension can map -# objective vars separately (identical in base); `disaggregator` is the -# master `_Hull` for Hull cuts, `nothing` for Big-M / MBM. -mutable struct _LOAMaster{M <: JuMP.AbstractModel, OF, AO, BM, VM, RM, DG} - model::M - binary_map::BM - variable_map::VM - objective_sense::_MOI.OptimizationSense - original_objective::OF - alpha_oa::AO - objective_ref_map::RM - disaggregator::DG -end - -################################################################################ -# SENSE PRIMITIVES -################################################################################ -# Val(MIN/MAX)-dispatched primitives so the algorithm reads sense-agnostic. _penalty_sign(::Val{_MOI.MIN_SENSE}) = 1 _penalty_sign(::Val{_MOI.MAX_SENSE}) = -1 _worst_objective(::Val{_MOI.MIN_SENSE}) = Inf @@ -95,15 +15,13 @@ _gap(::Val{_MOI.MIN_SENSE}, best, bound) = best - bound _gap(::Val{_MOI.MAX_SENSE}, best, bound) = bound - best ################################################################################ -# MAIN ALGORITHM +# MAIN LOOP ################################################################################ -# LOA entry point: set-covering seeds, the master/NLP loop, commit the -# best combination. Model-agnostic — extensions override the inner steps. +# LOA entry: set-covering seeds, the master/NLP loop, commit the best combo function reformulate_model(model::JuMP.AbstractModel, method::LOA) _clear_reformulations(model) combinations = _set_covering_combinations(model) - # Hull needs the disaggregation map; thread a LOA-owned sink through - # the inner reformulation to collect it (kept off GDPData). + # Hull needs the disaggregation map if its used as inner function inner, sink = _loa_inner_method(model, method.inner_method) reformulate_model(model, inner) @@ -142,8 +60,7 @@ function reformulate_model(model::JuMP.AbstractModel, method::LOA) end # Master/NLP loop: `alpha_oa` is the bound, the NLP refines the - # incumbent. Exit on convergence (bound meets incumbent and slack - # settled), infeasible master, max_iter, or time. + # incumbent. Exit on convergence, infeasible master, max_iter, or time. converged = false for _ in 1:method.max_iter time() < loop_deadline || break @@ -226,7 +143,7 @@ function reformulate_model(::M, ::LOA) where {M} end ################################################################################ -# SET-COVERING INITIALIZATION +# SET-COVERING INITIALIZATION (simple version from pyomo) ################################################################################ # `K = max disjunction size` combinations that activate every indicator # at least once: combination `k` activates the `k`-th indicator of each @@ -258,11 +175,20 @@ _is_linear_F(::Type{<:AbstractVector{<:JuMP.AbstractVariableRef}}) = true _is_linear_F(::Type{<:AbstractVector{<:JuMP.GenericAffExpr}}) = true _is_linear_F(::Type) = false -# OVERRIDABLE. Build the master MILP: copy the variables and linear -# constraints, install `alpha_oa` as the objective auxiliary. Nonlinear -# objective and disjunct constraints enter as OA cuts per NLP solve. -# `sink` is the disaggregation map collected by the inner Hull -# reformulation (`nothing` for Big-M / MBM). +""" + build_loa_master(model, method::LOA, sink = nothing)::_LOAMaster + +Build the LOA master MILP: copy `model`'s variables and linear +constraints, install the `alpha_oa` objective auxiliary, and record the +original-to-master reference maps. Nonlinear objective and disjunct +constraints are not copied; they enter later as OA cuts per NLP solve. +`sink` is the `(variable, indicator) -> disaggregated variable` map from +an inner Hull reformulation (`nothing` for Big-M / MBM). + +## Returns +- `_LOAMaster`: the master model with its reference maps and + disaggregator. +""" function build_loa_master( model::JuMP.AbstractModel, method::LOA, @@ -305,9 +231,9 @@ function build_loa_master( end # The inner reformulation method LOA runs, plus the disaggregation sink to -# collect (Big-M / MBM need none). For Hull, return a sink-carrying copy -# so the reformulation records its `(variable, indicator) -> disaggregated -# variable` map into a fresh LOA-owned `Dict`. +# collect (Big-M / MBM need none). +#For Hull, return a sink-carrying copy so the reformulation records its `(variable, indicator) +# -> disaggregated variable` map into a fresh LOA-owned `Dict`. _loa_inner_method(::JuMP.AbstractModel, inner::Union{BigM, MBM}) = (inner, nothing) function _loa_inner_method(model::JuMP.AbstractModel, inner::Hull) @@ -336,10 +262,13 @@ function _build_disaggregator( return hull end -# OVERRIDABLE. Record one master-space disaggregation in the Hull -# disaggregator. Base keys it directly by `(variable, binary)`; the -# InfiniteOpt extension keys per support so per-support cut emission -# matches. +""" + record_disaggregation(hull::_Hull, model, variable, binary, + disaggregated) + +Record one master-space disaggregation in the Hull `hull` used to gate +Hull OA cuts, keyed by `(variable, binary)`. +""" function record_disaggregation( hull::_Hull, ::JuMP.AbstractModel, @@ -354,16 +283,11 @@ end ################################################################################ # NLP SUBPROBLEM ################################################################################ -# Cap `target`'s solver to the budget left before `deadline` so one solve -# can't overrun the loop. No-op when `deadline` is `Inf`. function _cap_remaining_time(target::JuMP.AbstractModel, deadline::Float64) isfinite(deadline) || return JuMP.set_time_limit_sec(target, max(0.0, deadline - time())) return end - -# Restore the model's solver time limit after the loop — the factory -# value captured before capping, or unset if the factory set none. _restore_time_limit(model::JuMP.AbstractModel, ::Nothing) = JuMP.unset_time_limit_sec(model) _restore_time_limit(model::JuMP.AbstractModel, seconds::Real) = @@ -440,7 +364,9 @@ function _solve_nlpf( fix_combination_on_copy(copy, model, combination, ref_map) JuMP.optimize!(copy, ignore_optimize_hook = true) - JuMP.has_values(copy) || return nothing + # Use the primal only at a genuine feasible point; a solver can report + # `has_values` with a nonfeasible/NaN primal that would poison the cut. + JuMP.is_solved_and_feasible(copy) || return nothing linearization_point = _nlpf_extract_primal(model, ref_map) return (combination = combination, @@ -496,7 +422,9 @@ end # Fix `combination`, run `f()`, then undo. The finite undo is a no-op # (fixes overwrite in place); the infinite `fix_combination` clears its -# per-support pins. +# per-support fixed values. + +# TODO: Refactor to avoid this. function with_fixed_combination( f, model::JuMP.AbstractModel, @@ -538,9 +466,15 @@ function commit_combination( return end -# OVERRIDABLE. Apply the combination's fixes in place and return an undo -# closure (a no-op here, since the finite fixes overwrite in place). An -# extension overrides this for per-support `Vector{Bool}` values. +""" + fix_combination(model, combination::AbstractDict)::Function + +Fix each indicator in `combination` to its value on `model`, in place, +and return a zero-argument closure that undoes the fixes. + +## Returns +- `Function`: an undo closure called by `with_fixed_combination`. +""" function fix_combination(model::JuMP.AbstractModel, combination::AbstractDict) for (indicator, value) in combination fix_indicator(model, indicator, value) @@ -548,9 +482,12 @@ function fix_combination(model::JuMP.AbstractModel, combination::AbstractDict) return () -> nothing end -# OVERRIDABLE. Warm-start a variable from the linearization point (stored -# as a per-support vector). Base unwraps the scalar; the InfiniteOpt -# extension broadcasts across the transcribed per-support refs. +""" + set_linearization_start(variable, values::AbstractVector) + +Warm-start `variable` from the linearization point, unwrapping the single +element of `values` as its start value. +""" set_linearization_start(variable, values::AbstractVector) = JuMP.set_start_value(variable, only(values)) @@ -602,9 +539,12 @@ function add_oa_cuts( return end -# OVERRIDABLE. The point at which the master objective is linearized. -# Base uses the raw point; an extension whose `original_objective` is -# transcribed or derived overrides this to match its shape. +""" + objective_linearization_point(model, linearization_point) + +Return the point at which the master objective is linearized. Returns +`linearization_point` unchanged. +""" objective_linearization_point(::JuMP.AbstractModel, linearization_point) = linearization_point @@ -626,10 +566,15 @@ _add_objective_cut_body(::Val{_MOI.MIN_SENSE}, master, lin, slack) = _add_objective_cut_body(::Val{_MOI.MAX_SENSE}, master, lin, slack) = JuMP.@constraint(master.model, lin >= master.alpha_oa - slack) -# OVERRIDABLE. Add an OA cut for every nonlinear global constraint, -# skipping variable bounds, linear functions, and reformulation -# constraints. Base (scalar) version; an extension overrides it for -# vector-valued / transcribed linearizations. +""" + add_global_oa_cuts(model, master::_LOAMaster, result::NamedTuple, + method::LOA) + +Add a slacked OA cut to `master` for every nonlinear global (non-disjunct) +constraint of `model`, linearized at `result.linearization_point`. +Variable bounds, linear functions, and reformulation constraints are +skipped. +""" function add_global_oa_cuts( model::JuMP.AbstractModel, master::_LOAMaster, @@ -656,18 +601,11 @@ function add_global_oa_cuts( return end -# Add slacked OA cuts for each active disjunct's nonlinear constraints. -# This driver (iterate active disjuncts and their constraints) is shared; -# the per-constraint emission is the OVERRIDABLE seam below. `cache` is a -# per-pass scratch passed to each seam call (used by the extension). -function add_disjunct_oa_cuts( - model::JuMP.AbstractModel, - master::_LOAMaster, - result::NamedTuple, - method::LOA - ) - penalty_sign = _penalty_sign(Val(master.objective_sense)) - cache = Ref{Any}(nothing) +# Iterate each active disjunct's disjunct constraints, invoking +# `f(binary_ref, active, constraint)` per constraint. Shared by the base +# and InfiniteOpt `add_disjunct_oa_cuts` drivers so neither reimplements +# the active-disjunct walk. +function _each_active_disjunct_constraint(f, model, master, result) for (indicator, active) in result.combination is_active(active) || continue haskey(_indicator_to_constraints(model), indicator) || continue @@ -675,31 +613,33 @@ function add_disjunct_oa_cuts( cref isa DisjunctConstraintRef || continue constraint = _disjunct_constraints(model)[ JuMP.index(cref)].constraint - add_disjunct_constraint_oa_cuts(model, constraint, master, - master.binary_map[indicator], active, result, method, - penalty_sign, cache) + f(master.binary_map[indicator], active, constraint) end end + return end -# OVERRIDABLE. Emit the OA cut(s) for one active disjunct constraint. -# Base emits a single cut; the InfiniteOpt extension fans out per -# support. `active` is unused in base but drives the extension's fan-out; -# `cache` is a per-pass scratch the extension memoizes transcription into. -function add_disjunct_constraint_oa_cuts( - ::JuMP.AbstractModel, - constraint::JuMP.AbstractConstraint, +""" + add_disjunct_oa_cuts(model, master::_LOAMaster, result::NamedTuple, + method::LOA) + +Add a slacked OA cut to `master` for each active disjunct's nonlinear +constraints, linearized at `result.linearization_point` and gated to +match the inner reformulation. Emits one cut per constraint. +""" +function add_disjunct_oa_cuts( + model::JuMP.AbstractModel, master::_LOAMaster, - binary_ref, - active, result::NamedTuple, - method::LOA, - penalty_sign::Int, - cache + method::LOA ) - _add_oa_cut_for_constraint( - constraint, master, binary_ref, result.linearization_point, - master.variable_map, method, penalty_sign) + penalty_sign = _penalty_sign(Val(master.objective_sense)) + _each_active_disjunct_constraint(model, master, + result) do binary_ref, _active, constraint + _add_oa_cut_for_constraint(constraint, master, binary_ref, + result.linearization_point, master.variable_map, method, + penalty_sign) + end return end @@ -736,6 +676,11 @@ function _add_oa_cut_for_constraint( return end +# Extract RHS from an MOI set. +_set_rhs(s::Union{_MOI.LessThan, _MOI.GreaterThan, _MOI.EqualTo}) = + _MOI.constant(s) +_set_rhs(::Any) = 0.0 + # The `<= 0` directions of an OA cut for `set`: `lin - rhs` for LessThan, # `rhs - lin` for GreaterThan, both for EqualTo / Interval. The caller # adds the gating, slack, and (for Hull) disaggregation per direction. @@ -813,108 +758,3 @@ end is_active(active::Bool) = active is_active(active::AbstractVector{Bool}) = any(active) -################################################################################ -# LINEARIZATION & EXPRESSION CONVERSION -################################################################################ -# First-order Taylor for a single var or affine expression mapped into -# master space. -function _linearize_at( - variable::JuMP.AbstractVariableRef, - ::AbstractDict, - ref_map::AbstractDict - ) - target = ref_map[variable] - return JuMP.GenericAffExpr{Float64, typeof(target)}(0.0, target => 1.0) -end -function _linearize_at( - func::JuMP.GenericAffExpr, - ::AbstractDict, - ref_map::AbstractDict - ) - V = valtype(ref_map) - T = JuMP.value_type(V) <: Number ? JuMP.value_type(V) : Float64 - result = JuMP.GenericAffExpr{T, V}(T(func.constant)) - for (variable, coefficient) in func.terms - JuMP.add_to_expression!(result, coefficient, ref_map[variable]) - end - return result -end - -# Convert JuMP expression trees to Julia Expr with -# MOI.VariableIndex leaves for MOI.Nonlinear evaluation. -function _to_nlp_expr(expr::JuMP.GenericNonlinearExpr, idx::Dict) - args = Any[_to_nlp_expr(a, idx) for a in expr.args] - return Expr(:call, expr.head, args...) -end -function _to_nlp_expr(expr::JuMP.GenericAffExpr, idx::Dict) - parts = Any[expr.constant] - for (var, coef) in expr.terms - push!(parts, Expr(:call, :*, coef, _MOI.VariableIndex(idx[var]))) - end - length(parts) == 1 && return parts[1] - return Expr(:call, :+, parts...) -end -function _to_nlp_expr(expr::JuMP.GenericQuadExpr, idx::Dict) - parts = Any[_to_nlp_expr(expr.aff, idx)] - for (pair, coef) in expr.terms - push!(parts, Expr(:call, :*, coef, - _MOI.VariableIndex(idx[pair.a]), - _MOI.VariableIndex(idx[pair.b]))) - end - length(parts) == 1 && return parts[1] - return Expr(:call, :+, parts...) -end -function _to_nlp_expr(var::JuMP.AbstractVariableRef, idx::Dict) - return _MOI.VariableIndex(idx[var]) -end -_to_nlp_expr(x::Number, ::Dict) = x - -# First-order Taylor linearization of a quadratic or nonlinear -# expression at point xk via MOI.Nonlinear reverse-mode AD. -function _linearize_at( - func::Union{JuMP.GenericQuadExpr, JuMP.GenericNonlinearExpr}, - xk::Dict, - ref_map - ) - vars = JuMP.AbstractVariableRef[] - _interrogate_variables(v -> push!(vars, v), func) - unique!(vars) - isempty(vars) && return JuMP.AffExpr(JuMP.value(v -> 0.0, func)) - - n = length(vars) - T = JuMP.value_type(typeof(JuMP.owner_model(vars[1]))) - idx = Dict(vars[i] => i for i in 1:n) - nlp = _MOI.Nonlinear.Model() - _MOI.Nonlinear.set_objective(nlp, _to_nlp_expr(func, idx)) - ord = [_MOI.VariableIndex(i) for i in 1:n] - evaluator = _MOI.Nonlinear.Evaluator( - nlp, _MOI.Nonlinear.SparseReverseMode(), ord) - _MOI.initialize(evaluator, [:Grad]) - - xk_vec = [_unwrap_scalar(get(xk, v, zero(T))) for v in vars] - f_xk = _MOI.eval_objective(evaluator, xk_vec) - grad = zeros(T, n) - _MOI.eval_objective_gradient(evaluator, grad, xk_vec) - - constant = T(f_xk) - for i in 1:n - constant -= grad[i] * xk_vec[i] - end - V = typeof(ref_map[vars[1]]) - result = JuMP.GenericAffExpr{T, V}(constant) - for i in 1:n - iszero(grad[i]) && continue - JuMP.add_to_expression!(result, grad[i], ref_map[vars[i]]) - end - return result -end - -# Extract RHS from an MOI set. -_set_rhs(s::Union{_MOI.LessThan, _MOI.GreaterThan, _MOI.EqualTo}) = - _MOI.constant(s) -_set_rhs(::Any) = 0.0 - -# Unwrap a 1-element `Vector` to its scalar; scalars pass through -# (`extract_solution` returns length-1 vectors for scalar variables). -_unwrap_scalar(v::Real) = v -_unwrap_scalar(v::AbstractVector) = only(v) diff --git a/src/utilities.jl b/src/utilities.jl index e5686af5..7e61fdec 100644 --- a/src/utilities.jl +++ b/src/utilities.jl @@ -530,5 +530,106 @@ function _remap_constraint_to_indicator( ::Dict{DisjunctConstraintRef{M}, DisjunctConstraintRef{M}}, disj_map::Dict{DisjunctionRef{M}, DisjunctionRef{M}} ) where {M <: JuMP.AbstractModel} - return disj_map[con_ref] + return disj_map[con_ref] +end + +################################################################################ +# LINEARIZATION & EXPRESSION CONVERSION +################################################################################ +# First-order Taylor for a single var or affine expression mapped into +# master space. +function _linearize_at( + variable::JuMP.AbstractVariableRef, + ::AbstractDict, + ref_map::AbstractDict + ) + target = ref_map[variable] + return JuMP.GenericAffExpr{Float64, typeof(target)}(0.0, target => 1.0) +end +function _linearize_at( + func::JuMP.GenericAffExpr, + ::AbstractDict, + ref_map::AbstractDict + ) + V = valtype(ref_map) + T = JuMP.value_type(V) <: Number ? JuMP.value_type(V) : Float64 + result = JuMP.GenericAffExpr{T, V}(T(func.constant)) + for (variable, coefficient) in func.terms + JuMP.add_to_expression!(result, coefficient, ref_map[variable]) + end + return result +end + +# Convert JuMP expression trees to Julia Expr with +# MOI.VariableIndex leaves for MOI.Nonlinear evaluation. +function _to_nlp_expr(expr::JuMP.GenericNonlinearExpr, idx::Dict) + args = Any[_to_nlp_expr(a, idx) for a in expr.args] + return Expr(:call, expr.head, args...) end +function _to_nlp_expr(expr::JuMP.GenericAffExpr, idx::Dict) + parts = Any[expr.constant] + for (var, coef) in expr.terms + push!(parts, Expr(:call, :*, coef, _MOI.VariableIndex(idx[var]))) + end + length(parts) == 1 && return parts[1] + return Expr(:call, :+, parts...) +end +function _to_nlp_expr(expr::JuMP.GenericQuadExpr, idx::Dict) + parts = Any[_to_nlp_expr(expr.aff, idx)] + for (pair, coef) in expr.terms + push!(parts, Expr(:call, :*, coef, + _MOI.VariableIndex(idx[pair.a]), + _MOI.VariableIndex(idx[pair.b]))) + end + length(parts) == 1 && return parts[1] + return Expr(:call, :+, parts...) +end +function _to_nlp_expr(var::JuMP.AbstractVariableRef, idx::Dict) + return _MOI.VariableIndex(idx[var]) +end +_to_nlp_expr(x::Number, ::Dict) = x + +# First-order Taylor linearization of a quadratic or nonlinear +# expression at point xk via MOI.Nonlinear reverse-mode AD. +function _linearize_at( + func::Union{JuMP.GenericQuadExpr, JuMP.GenericNonlinearExpr}, + xk::Dict, + ref_map + ) + vars = JuMP.AbstractVariableRef[] + _interrogate_variables(v -> push!(vars, v), func) + unique!(vars) + isempty(vars) && return JuMP.AffExpr(JuMP.value(v -> 0.0, func)) + + n = length(vars) + T = JuMP.value_type(typeof(JuMP.owner_model(vars[1]))) + idx = Dict(vars[i] => i for i in 1:n) + nlp = _MOI.Nonlinear.Model() + _MOI.Nonlinear.set_objective(nlp, _to_nlp_expr(func, idx)) + ord = [_MOI.VariableIndex(i) for i in 1:n] + evaluator = _MOI.Nonlinear.Evaluator( + nlp, _MOI.Nonlinear.SparseReverseMode(), ord) + _MOI.initialize(evaluator, [:Grad]) + + xk_vec = [_unwrap_scalar(get(xk, v, zero(T))) for v in vars] + f_xk = _MOI.eval_objective(evaluator, xk_vec) + grad = zeros(T, n) + _MOI.eval_objective_gradient(evaluator, grad, xk_vec) + + constant = T(f_xk) + for i in 1:n + constant -= grad[i] * xk_vec[i] + end + V = typeof(ref_map[vars[1]]) + result = JuMP.GenericAffExpr{T, V}(constant) + for i in 1:n + iszero(grad[i]) && continue + JuMP.add_to_expression!(result, grad[i], ref_map[vars[i]]) + end + return result +end + +# Unwrap a 1-element `Vector` to its scalar; scalars pass through +# (`extract_solution` returns length-1 vectors for scalar variables). +_unwrap_scalar(v::Real) = v +_unwrap_scalar(v::AbstractVector) = only(v) diff --git a/test/constraints/loa.jl b/test/constraints/loa.jl index 198ada2a..c599102a 100644 --- a/test/constraints/loa.jl +++ b/test/constraints/loa.jl @@ -209,77 +209,6 @@ function test_loa_complement_indicator_nonlinear_disjunct() @test objective_value(model) ≈ 8.0 atol = 1e-3 end -function test_linearize_nonlinear_exp() - # exp(x) + y at (1, 2): - # f = e + 2, ∇f = [e, 1] - # linear: e*(x-1) + 1*(y-2) + (e+2) = e*x + y - model = GDPModel() - @variable(model, x) - @variable(model, y) - func = @expression(model, exp(x) + y) - xk = Dict{JuMP.AbstractVariableRef, Float64}( - x => 1.0, y => 2.0) - id_map = Dict(x => x, y => y) - lin = DP._linearize_at(func, xk, id_map) - @test JuMP.constant(lin) ≈ 0.0 atol = 1e-8 - @test JuMP.coefficient(lin, x) ≈ exp(1.0) atol = 1e-8 - @test JuMP.coefficient(lin, y) ≈ 1.0 atol = 1e-8 -end - -function test_linearize_nonlinear_sin() - # sin(x) at x = π/6: - # f = 0.5, f' = cos(π/6) = √3/2 - # linear: 0.5 + (√3/2)(x - π/6) - model = GDPModel() - @variable(model, x) - func = @expression(model, sin(x)) - xk = Dict{JuMP.AbstractVariableRef, Float64}( - x => π / 6) - id_map = Dict(x => x) - lin = DP._linearize_at(func, xk, id_map) - expected_const = 0.5 - (√3 / 2) * (π / 6) - @test JuMP.constant(lin) ≈ expected_const atol = 1e-8 - @test JuMP.coefficient(lin, x) ≈ √3 / 2 atol = 1e-8 -end - -function test_linearize_nonlinear_multivar() - # exp(x) * sin(y) at (1, π/2): - # f = e*1 = e, ∂f/∂x = e*sin(π/2) = e, ∂f/∂y = e*cos(π/2) = 0 - # linear: e + e*(x-1) + 0*(y-π/2) = e*x - model = GDPModel() - @variable(model, x) - @variable(model, y) - func = @expression(model, exp(x) * sin(y)) - xk = Dict{JuMP.AbstractVariableRef, Float64}( - x => 1.0, y => π / 2) - id_map = Dict(x => x, y => y) - lin = DP._linearize_at(func, xk, id_map) - @test JuMP.constant(lin) ≈ 0.0 atol = 1e-8 - @test JuMP.coefficient(lin, x) ≈ exp(1.0) atol = 1e-8 - @test JuMP.coefficient(lin, y) ≈ 0.0 atol = 1e-8 -end - -function test_to_nlp_expr() - model = GDPModel() - @variable(model, x) - @variable(model, y) - idx = Dict(x => 1, y => 2) - - # NonlinearExpr - nl = @expression(model, exp(x)) - e = DP._to_nlp_expr(nl, idx) - @test e == Expr(:call, :exp, MOI.VariableIndex(1)) - - # AffExpr - aff = @expression(model, 2x + 3y + 1) - e = DP._to_nlp_expr(aff, idx) - @test e isa Expr - @test e.head == :call && e.args[1] == :+ - - # Number - @test DP._to_nlp_expr(42, idx) == 42 -end - function test_loa_nlpf_infeasible_disjunct() # Y1 disjunct constraint x^2 >= 200 is NLP-infeasible against the # variable bound x in [0, 10] (max x^2 = 100). The primary NLP at @@ -561,10 +490,6 @@ end test_loa_nonlinear_global() test_loa_complement_indicator_nonlinear_disjunct() test_loa_nlpf_infeasible_disjunct() - test_linearize_nonlinear_exp() - test_linearize_nonlinear_sin() - test_linearize_nonlinear_multivar() - test_to_nlp_expr() test_loa_sense_primitives() test_loa_iteration_loop() test_loa_time_limits() diff --git a/test/utilities.jl b/test/utilities.jl index 2b87e6e1..2a98366d 100644 --- a/test/utilities.jl +++ b/test/utilities.jl @@ -238,6 +238,77 @@ function test_get_constant_variable() @test DP.get_constant(x) == 0.0 end +function test_linearize_nonlinear_exp() + # exp(x) + y at (1, 2): + # f = e + 2, ∇f = [e, 1] + # linear: e*(x-1) + 1*(y-2) + (e+2) = e*x + y + model = GDPModel() + @variable(model, x) + @variable(model, y) + func = @expression(model, exp(x) + y) + xk = Dict{JuMP.AbstractVariableRef, Float64}( + x => 1.0, y => 2.0) + id_map = Dict(x => x, y => y) + lin = DP._linearize_at(func, xk, id_map) + @test JuMP.constant(lin) ≈ 0.0 atol = 1e-8 + @test JuMP.coefficient(lin, x) ≈ exp(1.0) atol = 1e-8 + @test JuMP.coefficient(lin, y) ≈ 1.0 atol = 1e-8 +end + +function test_linearize_nonlinear_sin() + # sin(x) at x = π/6: + # f = 0.5, f' = cos(π/6) = √3/2 + # linear: 0.5 + (√3/2)(x - π/6) + model = GDPModel() + @variable(model, x) + func = @expression(model, sin(x)) + xk = Dict{JuMP.AbstractVariableRef, Float64}( + x => π / 6) + id_map = Dict(x => x) + lin = DP._linearize_at(func, xk, id_map) + expected_const = 0.5 - (√3 / 2) * (π / 6) + @test JuMP.constant(lin) ≈ expected_const atol = 1e-8 + @test JuMP.coefficient(lin, x) ≈ √3 / 2 atol = 1e-8 +end + +function test_linearize_nonlinear_multivar() + # exp(x) * sin(y) at (1, π/2): + # f = e*1 = e, ∂f/∂x = e*sin(π/2) = e, ∂f/∂y = e*cos(π/2) = 0 + # linear: e + e*(x-1) + 0*(y-π/2) = e*x + model = GDPModel() + @variable(model, x) + @variable(model, y) + func = @expression(model, exp(x) * sin(y)) + xk = Dict{JuMP.AbstractVariableRef, Float64}( + x => 1.0, y => π / 2) + id_map = Dict(x => x, y => y) + lin = DP._linearize_at(func, xk, id_map) + @test JuMP.constant(lin) ≈ 0.0 atol = 1e-8 + @test JuMP.coefficient(lin, x) ≈ exp(1.0) atol = 1e-8 + @test JuMP.coefficient(lin, y) ≈ 0.0 atol = 1e-8 +end + +function test_to_nlp_expr() + model = GDPModel() + @variable(model, x) + @variable(model, y) + idx = Dict(x => 1, y => 2) + + # NonlinearExpr + nl = @expression(model, exp(x)) + e = DP._to_nlp_expr(nl, idx) + @test e == Expr(:call, :exp, MOI.VariableIndex(1)) + + # AffExpr + aff = @expression(model, 2x + 3y + 1) + e = DP._to_nlp_expr(aff, idx) + @test e isa Expr + @test e.head == :call && e.args[1] == :+ + + # Number + @test DP._to_nlp_expr(42, idx) == 42 +end + @testset "Utility Functions" begin test_all_variables() test_collect_all_vars() @@ -245,4 +316,8 @@ end test_get_constant_quadratic() test_get_constant_number() test_get_constant_variable() + test_linearize_nonlinear_exp() + test_linearize_nonlinear_sin() + test_linearize_nonlinear_multivar() + test_to_nlp_expr() end From 531755261e63768c4573b721f8d0d73ab22c2308 Mon Sep 17 00:00:00 2001 From: d227nguyen Date: Wed, 1 Jul 2026 16:24:00 -0400 Subject: [PATCH 59/59] Reverting Project.toml --- Project.toml | 2 -- 1 file changed, 2 deletions(-) diff --git a/Project.toml b/Project.toml index db716b40..dbff0ec1 100644 --- a/Project.toml +++ b/Project.toml @@ -4,7 +4,6 @@ authors = ["hdavid16 "] version = "0.6.0" [deps] -Distributions = "31c24e10-a181-5473-b8eb-7969acd0382f" JuMP = "4076af6c-e467-56ae-b986-b466b2749572" Reexport = "189a3867-3050-52da-a836-e630ba90ab69" @@ -16,7 +15,6 @@ InfiniteDisjunctiveProgramming = "InfiniteOpt" [compat] Aqua = "0.8" -Distributions = "0.25.125" InfiniteOpt = "0.6.3" Ipopt = "1.9.0" JuMP = "1.18"