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 5f77dce7..d9dc7b71 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -253,7 +253,7 @@ function _interpolate_at( args::NTuple{N, <:Real} ) where {N} # lower-corner cell index per dimension - idx_lo = ntuple(d -> + 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 @@ -324,7 +324,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 @@ -374,4 +377,123 @@ function DP.add_cut( return end +################################################################################ +# LOA FOR INFINITEMODEL +################################################################################ +# LOA override for InfiniteModel: after the inner reformulation, build +# the transformation backend and hand the LOA loop the backend model — +# a plain scalar JuMP model whose variables, measures, and constraints +# are already transcribed per support. The loop runs entirely on base +# code and solves the backend directly, which never invalidates it, so +# the committed fixes and warm starts survive the final `optimize!`. + +# Per-support transcribed refs of an InfiniteOpt object, as a Vector +# (finite objects give one element). +_transcribed_refs(x::AbstractArray) = vec(x) +_transcribed_refs(x) = [x] + +# Index a per-support vector, broadcasting a single-element (finite) +# vector across all supports. +_at(values::Vector, k::Int) = + length(values) == 1 ? values[1] : values[k] + +# Per-support transcribed binary references for an indicator: the +# transcribed underlying binary, complemented for a complement-form +# indicator. +function _transcribed_binary_refs(model::InfiniteOpt.InfiniteModel, indicator) + binary_ref = DP._indicator_to_binary(model)[indicator] + binaries = _transcribed_refs(InfiniteOpt.transformation_variable( + DP._underlying_binary(binary_ref))) + binary_ref isa JuMP.GenericAffExpr || return binaries + return [1.0 - binary for binary in binaries] +end + +# Transcribe the inner Hull disaggregation map per support. A finite +# variable under an infinite indicator keys its single disaggregated +# copy by each per-support binary reference, mirroring the per-support +# disjunct cuts that look it up. +_transcribed_disaggregation_map(::InfiniteOpt.InfiniteModel, ::Nothing) = + nothing +function _transcribed_disaggregation_map( + model::InfiniteOpt.InfiniteModel, + disaggregation_map + ) + result = Dict{Tuple{JuMP.VariableRef, + Union{JuMP.VariableRef, JuMP.AffExpr}}, JuMP.VariableRef}() + for ((variable, indicator), disaggregated) in disaggregation_map + binary_refs = _transcribed_binary_refs(model, indicator) + variables = _transcribed_refs( + InfiniteOpt.transformation_variable(variable)) + disaggregateds = _transcribed_refs(InfiniteOpt.transformation_variable( + disaggregated)) + n = max(length(binary_refs), length(variables), length(disaggregateds)) + for k in 1:n + result[(_at(variables, k), _at(binary_refs, k))] = + _at(disaggregateds, k) + end + end + return result +end + +function DP.build_loa_problem( + model::InfiniteOpt.InfiniteModel, + method::DP.LOA, + disaggregation_map = nothing + ) + InfiniteOpt.build_transformation_backend!(model) + nlp = InfiniteOpt.transformation_model(model) + + binaries = JuMP.VariableRef[] + for (_, binary_ref) in DP._indicator_to_binary(model) + append!(binaries, _transcribed_refs( + InfiniteOpt.transformation_variable( + DP._underlying_binary(binary_ref)))) + end + unique!(binaries) + + disjunct_constraints = Tuple{Union{JuMP.VariableRef, JuMP.AffExpr}, + JuMP.AbstractJuMPScalar, _MOI.AbstractScalarSet}[] + for (_, disjunction) in DP._disjunctions(model) + for indicator in disjunction.constraint.indicators + haskey(DP._indicator_to_constraints(model), indicator) || + continue + binary_refs = _transcribed_binary_refs(model, indicator) + for cref in DP._indicator_to_constraints(model)[indicator] + cref isa DP.DisjunctConstraintRef || continue + constraint = DP._disjunct_constraints(model)[ + JuMP.index(cref)].constraint + constraint isa JuMP.ScalarConstraint || continue + DP._is_linear_F(typeof(constraint.func)) && continue + funcs = _transcribed_refs(InfiniteOpt.transformation_expression( + constraint.func)) + for k in 1:max(length(binary_refs), length(funcs)) + push!(disjunct_constraints, + (_at(binary_refs, k), _at(funcs, k), constraint.set)) + end + end + end + end + + global_constraints = Tuple{JuMP.AbstractJuMPScalar, + _MOI.AbstractScalarSet}[] + reform_set = Set(DP._reformulation_constraints(model)) + for (F, S) in JuMP.list_of_constraint_types(model) + F === InfiniteOpt.GeneralVariableRef && continue + DP._is_linear_F(F) && continue + for cref in JuMP.all_constraints(model, F, S) + cref in reform_set && continue + constraint = JuMP.constraint_object(cref) + constraint isa JuMP.ScalarConstraint || continue + for func in _transcribed_refs(InfiniteOpt.transformation_expression( + constraint.func)) + push!(global_constraints, (func, constraint.set)) + end + end + end + + return DP._LOAProblem(nlp, binaries, + disjunct_constraints, global_constraints, + _transcribed_disaggregation_map(model, disaggregation_map)) +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/cuttingplanes.jl b/src/cuttingplanes.jl index dbc818e5..63692258 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 +# `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, 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 + v => [JuMP.value(v)] for v in dvars if !JuMP.is_fixed(v)) end # Set quadratic separation objective: min Σ (x_k - rBM_k)². diff --git a/src/datatypes.jl b/src/datatypes.jl index bdcd4c2e..d14defb8 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. + disaggregation_map::Union{Nothing, AbstractDict} + function Hull(ϵ::T = 1e-6; disaggregation_map = nothing) where {T} + new{T}(ϵ, disaggregation_map) 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} + disaggregation_map::Union{Nothing, AbstractDict} 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.disaggregation_map ) end end @@ -473,25 +478,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 """ @@ -567,6 +571,124 @@ 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. +- `set_cover_max_iter::Int`: max iterations of the set-covering seed + loop. +- `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. +- `use_nlpf::Bool`: solve the NLPF feasibility subproblem when the + primary NLP is infeasible (default `true`). When `false`, an + infeasible combination contributes only its no-good cut, no OA cuts. +- `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 + set_cover_max_iter::Int + M_value::T + max_slack::T + oa_penalty::T + use_nlpf::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, + set_cover_max_iter::Int = 8, + M_value::T = 1e9, + max_slack::T = 1e3, + oa_penalty::T = 1e3, + inner_method::R = BigM(M_value), + use_nlpf::Bool = true, + 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, set_cover_max_iter, M_value, max_slack, oa_penalty, + use_nlpf, convergence_tol, slack_tol, + iteration_time_limit, time_limit) + end +end + +# The problem the LOA loop operates on: the model solved as the NLP +# subproblem, the binary variables backing the indicators, the +# nonlinear disjunct `(binary_ref, function, set)` triples (`binary_ref` +# is the binary or its `1 - y` complement expression), the nonlinear +# global `(function, set)` pairs, and the `(variable, binary_ref) -> +# disaggregated variable` map from an inner Hull reformulation +# (`nothing` for Big-M / MBM). Built by `build_loa_problem`. The +# set-covering seed generates its combinations on the fly from the +# master, so none are stored here. +struct _LOAProblem{M <: JuMP.AbstractModel, V <: JuMP.AbstractVariableRef, T} + nlp::M + binaries::Vector{V} + disjunct_constraints::Vector{Tuple{Union{V, JuMP.GenericAffExpr{T, V}}, + JuMP.AbstractJuMPScalar, _MOI.AbstractScalarSet}} + global_constraints::Vector{Tuple{JuMP.AbstractJuMPScalar, + _MOI.AbstractScalarSet}} + disaggregation_map::Union{Nothing, + Dict{Tuple{V, Union{V, JuMP.GenericAffExpr{T, V}}}, V}} + + # Inner constructor deriving the value type `T` from the NLP model. + # `T` only appears inside `Union` field types, so the default + # constructors could be called without binding it (Aqua + # unbound_args); this replaces them. + function _LOAProblem( + nlp::M, + binaries::Vector{V}, + disjunct_constraints::Vector, + global_constraints::Vector, + disaggregation_map + ) where {M <: JuMP.AbstractModel, V <: JuMP.AbstractVariableRef} + return new{M, V, JuMP.value_type(M)}(nlp, binaries, + disjunct_constraints, global_constraints, disaggregation_map) + end +end + +# The LOA master MILP plus the NLP-to-master variable map. +# `objective` is the NLP's objective, linearized per iteration for +# the objective cuts; `disaggregator` is the master `_Hull` for Hull +# cuts, `nothing` for Big-M / MBM. +struct _LOAMaster{M <: JuMP.AbstractModel, VM, OF, AO, DG} + model::M + variable_map::VM + objective_sense::_MOI.OptimizationSense + objective::OF + alpha_oa::AO + disaggregator::DG +end + ################################################################################ # GDP Data ################################################################################ diff --git a/src/hull.jl b/src/hull.jl index 779369e9..6e43daad 100644 --- a/src/hull.jl +++ b/src/hull.jl @@ -42,6 +42,9 @@ function _disaggregate_variable( #temp storage push!(method.disjunction_variables[vref], dvref) method.disjunct_variables[vref, bvref] = dvref + #record into the LOA disaggregation map when installed (LOA-Hull only) + method.disaggregation_map === nothing || + (method.disaggregation_map[(vref, lvref)] = dvref) #create bounding constraints dvname = JuMP.name(dvref) lbname = isempty(dvname) ? "" : "$(dvname)_lower_bound" @@ -254,7 +257,9 @@ 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; + disaggregation_map = method.disaggregation_map)) end function reformulate_disjunct_constraint( diff --git a/src/loa.jl b/src/loa.jl new file mode 100644 index 00000000..3030305d --- /dev/null +++ b/src/loa.jl @@ -0,0 +1,690 @@ +################################################################################ +# LOGIC-BASED OUTER APPROXIMATION (LOA) +################################################################################ + +################################################################################ +# SENSE HANDLERS +################################################################################ +_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 + +################################################################################ +# MAIN LOOP +################################################################################ +# LOA entry: build the problem, seed with set-covering combinations, +# iterate the master/NLP loop, and commit the best combination. +function reformulate_model(model::JuMP.AbstractModel, method::LOA) + _clear_reformulations(model) + # Hull needs the disaggregation map if its used as inner function + inner, disaggregation_map = _loa_inner_method(model, method.inner_method) + reformulate_model(model, inner) + + problem = build_loa_problem(model, method, disaggregation_map) + # The master copies the NLP while its binaries still carry + # integrality; the NLP is then relaxed once and each iteration + # overwrites the binary fixes in place. + master = _build_loa_master(problem, method) + _relax_binaries(problem) + nlp = problem.nlp + JuMP.set_optimizer(nlp, method.nlp_optimizer) + JuMP.set_silent(nlp) + 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(nlp) + sense_token = Val(master.objective_sense) + best_objective = _worst_objective(sense_token) + best_result = nothing + master_bound = nothing + + # Set-covering seed. This mimics Pyomo GDPopt's set-covering + # initialization: borrow the master as a covering MILP (only its + # objective changes), let it pick a combination that activates the + # nonlinear disjuncts still lacking a linearization, solve the NLP + # there, and seed the resulting OA and no-good cuts. Each NLP + # warm-starts from the last feasible primal. + previous_result = nothing + cover_disjuncts = _cover_disjuncts(problem) + needs_cover = trues(length(cover_disjuncts)) + num_covered = 0 + for iteration in 1:method.set_cover_max_iter + (iteration == 1 || any(needs_cover)) || break + time() < loop_deadline || break + # Swap in the covering objective, solve, read off the combination, + # then restore the OA objective (with its accumulated slack + # penalties) before emitting cuts against it. + oa_objective = JuMP.objective_function(master.model) + JuMP.@objective(master.model, Max, + _cover_objective(master, cover_disjuncts, needs_cover, + num_covered)) + _cap_remaining_time(master.model, loop_deadline) + JuMP.optimize!(master.model) + solved = JuMP.is_solved_and_feasible(master.model) + combination = solved ? _extract_combination(problem, master) : nothing + JuMP.set_objective_sense(master.model, master.objective_sense) + JuMP.set_objective_function(master.model, oa_objective) + solved || break + _set_nlp_warm_start(previous_result) + result = _solve_nlp(problem, combination, method; + deadline = loop_deadline) + avoid_combination(master.model, combination, master.variable_map) + _add_oa_cuts(problem, master, 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) + # A disjunct counts as covered only once it is active in a + # feasible NLP. An infeasible combination still contributes its + # no-good cut above but leaves the coverage targets untouched. + if result.feasible + for i in eachindex(cover_disjuncts) + needs_cover[i] || continue + _disjunct_active(result.combination, cover_disjuncts[i]) && + (needs_cover[i] = false) + end + num_covered = count(!, needs_cover) + end + end + + # Master/NLP loop: `alpha_oa` is the bound, the NLP refines the + # incumbent. Exit on convergence, infeasible master, max_iter, or time. + converged = false + for _ in 1:method.max_iter + time() < loop_deadline || break + _cap_remaining_time(master.model, loop_deadline) + 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(problem, master) + _set_nlp_warm_start(previous_result) + result = _solve_nlp(problem, combination, method; + deadline = loop_deadline) + avoid_combination(master.model, combination, master.variable_map) + _add_oa_cuts(problem, master, 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) + end + + best_result === nothing || _commit_combination(best_result) + if isfinite(method.time_limit) + # Overall cap: the final committed solve gets the budget left. + JuMP.set_time_limit_sec(nlp, 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(nlp, 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 + +# 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, + master_bound, + sense_token::Val, + converged::Bool + ) + 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) + 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 + +# 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 + +################################################################################ +# SET-COVERING INITIALIZATION +################################################################################ +# The nonlinear disjuncts to cover: one entry per distinct indicator that +# owns a nonlinear disjunct constraint, carrying that indicator's +# `binary_ref`. Keyed by `(underlying binary, active value)` so the two +# disjuncts of a single-binary disjunction (`y` and its `1 - y` +# complement) stay distinct. Purely linear disjuncts need no cover: the +# inner reformulation already places them in the master exactly. +function _cover_disjuncts(problem::_LOAProblem{M, V, T}) where {M, V, T} + seen = Set{Tuple{V, Bool}}() + disjuncts = Union{V, JuMP.GenericAffExpr{T, V}}[] + for (binary_ref, _, _) in problem.disjunct_constraints + key = (_underlying_binary(binary_ref), + _underlying_value(binary_ref, true)) + key in seen && continue + push!(seen, key) + push!(disjuncts, binary_ref) + end + return disjuncts +end + +# The set-covering objective (master space), mimicking Pyomo GDPopt: +# maximize active disjuncts weighted `num_covered + 1` if still uncovered +# else `1`, so one uncovered disjunct outweighs every covered one and each +# solve must activate a new disjunct when the logic allows. Empty +# `disjuncts` gives the zero expression: a constant objective that still +# seeds one feasible combination (needed to bound `alpha_oa`). +function _cover_objective( + master::_LOAMaster, + disjuncts, + needs_cover, + num_covered::Int + ) + T = JuMP.value_type(typeof(master.model)) + V = JuMP.variable_ref_type(typeof(master.model)) + expr = JuMP.GenericAffExpr{T, V}(zero(T)) + for i in eachindex(disjuncts) + weight = needs_cover[i] ? num_covered + 1 : 1 + activation = _remap_indicator_to_binary(disjuncts[i], + master.variable_map) + JuMP.add_to_expression!(expr, T(weight), activation) + end + return expr +end + +################################################################################ +# FINITE PROBLEM CONSTRUCTION +################################################################################ +# The concrete variable behind an indicator's binary reference (the +# underlying variable of a `1 - y` complement expression), and the value +# it takes when the indicator is active. +_underlying_binary(binary_ref::JuMP.AbstractVariableRef) = binary_ref +_underlying_binary(binary_ref::JuMP.GenericAffExpr) = + only(keys(binary_ref.terms)) +_underlying_value(::JuMP.AbstractVariableRef, active::Bool) = active +_underlying_value(::JuMP.GenericAffExpr, active::Bool) = !active + +""" + build_loa_problem( + model::JuMP.AbstractModel, + method::LOA, + [disaggregation_map = nothing] + )::_LOAProblem + +Build the problem the LOA loop operates on from `model` (already +reformulated by the LOA inner method): the NLP subproblem, the binary +variables backing the indicators, the nonlinear disjunct +`(binary_ref, function, set)` triples, the nonlinear global +`(function, set)` pairs, and the Hull disaggregation map. The NLP is +`model` itself; the InfiniteOpt extension overloads this to build the +problem from the transcribed backend instead. + +## Returns +- `_LOAProblem`: the problem. +""" +function build_loa_problem( + model::JuMP.AbstractModel, + method::LOA, + disaggregation_map = nothing + ) + V = JuMP.variable_ref_type(typeof(model)) + T = JuMP.value_type(typeof(model)) + binary_map = _indicator_to_binary(model) + + binaries = V[_underlying_binary(binary_ref) + for (_, binary_ref) in binary_map] + unique!(binaries) + + disjunct_constraints = Tuple{Union{V, JuMP.GenericAffExpr{T, V}}, + JuMP.AbstractJuMPScalar, _MOI.AbstractScalarSet}[] + for (_, disjunction) in _disjunctions(model) + for indicator in disjunction.constraint.indicators + haskey(_indicator_to_constraints(model), indicator) || continue + binary_ref = binary_map[indicator] + for cref in _indicator_to_constraints(model)[indicator] + cref isa DisjunctConstraintRef || continue + constraint = _disjunct_constraints(model)[ + JuMP.index(cref)].constraint + constraint isa JuMP.ScalarConstraint || continue + _is_linear_F(typeof(constraint.func)) && continue + push!(disjunct_constraints, + (binary_ref, constraint.func, constraint.set)) + end + end + end + + global_constraints = Tuple{JuMP.AbstractJuMPScalar, + _MOI.AbstractScalarSet}[] + reform_set = Set(_reformulation_constraints(model)) + for (F, S) in JuMP.list_of_constraint_types(model) + F === V && continue + _is_linear_F(F) && continue + for cref in JuMP.all_constraints(model, F, S) + cref in reform_set && continue + constraint = JuMP.constraint_object(cref) + constraint isa JuMP.ScalarConstraint || continue + push!(global_constraints, (constraint.func, constraint.set)) + end + end + + binary_disaggregations = disaggregation_map === nothing ? nothing : + Dict{Tuple{V, Union{V, JuMP.GenericAffExpr{T, V}}}, V}( + (variable, binary_map[indicator]) => disaggregated + for ((variable, indicator), disaggregated) in disaggregation_map) + return _LOAProblem(model, binaries, disjunct_constraints, + global_constraints, binary_disaggregations) +end + +# The inner reformulation method LOA runs, plus the disaggregation map +# to collect (Big-M / MBM need none). For Hull, return a map-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)) + disaggregations = Dict{Tuple{V, LogicalVariableRef{typeof(model)}}, V}() + return (Hull(inner.value; disaggregation_map = disaggregations), + disaggregations) +end + +# Relax the binaries to continuous in [0, 1]; each NLP solve then +# overwrites their fixes in place. +function _relax_binaries(problem::_LOAProblem) + for binary in problem.binaries + JuMP.unset_binary(binary) + JuMP.set_lower_bound(binary, 0.0) + JuMP.set_upper_bound(binary, 1.0) + end + return +end + +################################################################################ +# MASTER CONSTRUCTION +################################################################################ +# 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 + +# Build the LOA master MILP: copy the NLP's variables and linear +# constraints, install the `alpha_oa` objective auxiliary, and record +# the NLP-to-master variable map. Nonlinear constraints are not +# copied; they enter later as OA cuts per NLP solve. +function _build_loa_master(problem::_LOAProblem, method::LOA) + nlp = problem.nlp + objective = JuMP.objective_function(nlp) + objective_sense = JuMP.objective_sense(nlp) + variable_type = JuMP.variable_ref_type(typeof(nlp)) + + master = _copy_model(nlp) + JuMP.set_optimizer(master, method.mip_optimizer) + JuMP.set_silent(master) + + variable_map = copy_variables_onto_model(master, nlp) + + for (F, S) in JuMP.list_of_constraint_types(nlp) + F === variable_type && continue + _is_linear_F(F) || continue + for constraint_ref in JuMP.all_constraints(nlp, 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 + end + + alpha_oa = JuMP.@variable(master, base_name = "alpha_oa") + JuMP.@objective(master, objective_sense, alpha_oa) + + disaggregator = _build_disaggregator(problem, variable_map, + method.inner_method) + return _LOAMaster(master, variable_map, objective_sense, objective, + alpha_oa, disaggregator) +end + +# Master-space Hull disaggregator for the convex-hull cut emitter +# (`nothing` for Big-M / MBM): remap the `(variable, binary_ref) -> +# disaggregated variable` map into master refs. +_build_disaggregator(::_LOAProblem, ::Dict, ::Union{BigM, MBM}) = nothing +function _build_disaggregator( + problem::_LOAProblem, + variable_map::Dict{V, V}, + inner::Hull + ) where {V <: JuMP.AbstractVariableRef} + hull = _Hull(inner, Set{valtype(variable_map)}()) + for ((variable, binary_ref), disaggregated) in problem.disaggregation_map + hull.disjunct_variables[(variable_map[variable], + _remap_indicator_to_binary(binary_ref, variable_map))] = + variable_map[disaggregated] + end + return hull +end + +################################################################################ +# NLP SUBPROBLEM +################################################################################ +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_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) + +# Fix each binary at its combination value, overwriting any previous +# fix in place. +function _fix_combination(combination::AbstractDict) + for (binary, value) in combination + JuMP.fix(binary, value ? 1.0 : 0.0; force = true) + end + return +end + +# Primal values keyed by the NLP's variables, skipping fixed ones +# (the combination binaries and any user-fixed variables). +function _extract_solution(nlp::JuMP.AbstractModel) + V = JuMP.variable_ref_type(typeof(nlp)) + T = JuMP.value_type(typeof(nlp)) + return Dict{V, T}(v => JuMP.value(v) + for v in JuMP.all_variables(nlp) if !JuMP.is_fixed(v)) +end + +# Solve the 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. NLPF can be +# switched off via `use_nlpf = false`. +function _solve_nlp( + problem::_LOAProblem, + combination, + method::LOA; + deadline::Float64 = Inf + ) + nlp = problem.nlp + _cap_remaining_time(nlp, deadline) + _fix_combination(combination) + JuMP.optimize!(nlp, ignore_optimize_hook = true) + if JuMP.is_solved_and_feasible(nlp) + return (combination = combination, + linearization_point = _extract_solution(nlp), + objective = JuMP.objective_value(nlp), feasible = true) + end + + # Primary NLP infeasible — try NLPF (unless disabled, in which case + # the combination contributes only its no-good cut). + if method.use_nlpf + nlpf = _solve_nlpf(problem, combination, method; deadline = deadline) + nlpf === nothing || return nlpf + end + + V = JuMP.variable_ref_type(typeof(nlp)) + T = JuMP.value_type(typeof(nlp)) + return (combination = combination, + linearization_point = Dict{V, T}(), + objective = Inf, feasible = false) +end + +################################################################################ +# NLPF (FEASIBILITY SUBPROBLEM) +################################################################################ +# When the primary NLP is infeasible, copy the NLP (fixes included), +# 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( + problem::_LOAProblem, + combination, + method::LOA; + deadline::Float64 = Inf + ) + copy, ref_map = JuMP.copy_model(problem.nlp) + 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") + + 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) + + JuMP.optimize!(copy, ignore_optimize_hook = true) + # 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 + + V = JuMP.variable_ref_type(typeof(problem.nlp)) + T = JuMP.value_type(typeof(problem.nlp)) + linearization_point = Dict{V, T}(v => JuMP.value(ref_map[v]) + for v in JuMP.all_variables(problem.nlp) if !JuMP.is_fixed(v)) + return (combination = combination, + linearization_point = linearization_point, + objective = Inf, feasible = false) +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 + +# 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). +function _set_nlp_warm_start(previous) + previous === nothing && return + for (variable, value) in previous.linearization_point + JuMP.set_start_value(variable, value) + end + return +end + +# Finalize the NLP with the LOA-optimal combination: fix the +# binaries at the committed values (in place; the NLP is already +# permanently relaxed) 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(result) + _fix_combination(result.combination) + for (variable, value) in result.linearization_point + JuMP.set_start_value(variable, value) + end + return +end + +################################################################################ +# COMBO EXTRACTION (master -> NLP) +################################################################################ +# Read each binary's master value, rounded to `Bool` (a MILP solver +# returns values within its integer tolerance, never exactly 0/1). +function _extract_combination(problem::_LOAProblem, master::_LOAMaster) + return Dict(binary => round(Bool, JuMP.value(master.variable_map[binary])) + for binary in problem.binaries) +end + +################################################################################ +# OA CUT EMISSION +################################################################################ +# Whether the disjunct behind `binary_ref` is active in `combination` +# (a complement reference inverts its underlying binary's value). +_disjunct_active(combination::AbstractDict, + binary_ref::JuMP.AbstractVariableRef) = combination[binary_ref] +_disjunct_active(combination::AbstractDict, binary_ref::JuMP.GenericAffExpr) = + !combination[_underlying_binary(binary_ref)] + +# Emit all OA cuts for one NLP result: the objective cut, a slacked row +# per nonlinear global, and a gated cut per active nonlinear disjunct +# constraint (Big-M / MBM or Hull gating per the inner method). +function _add_oa_cuts( + problem::_LOAProblem, + master::_LOAMaster, + result::NamedTuple, + method::LOA + ) + isempty(result.linearization_point) && return + sense_token = Val(master.objective_sense) + penalty_sign = _penalty_sign(sense_token) + linearization = _linearize_at(master.objective, + result.linearization_point, master.variable_map) + _add_objective_cut(sense_token, master, linearization, method) + for (func, set) in problem.global_constraints + linearization = _linearize_at(func, result.linearization_point, + master.variable_map) + _add_global_oa_row(master, linearization, set, method, penalty_sign) + end + for (binary_ref, func, set) in problem.disjunct_constraints + _disjunct_active(result.combination, binary_ref) || continue + linearization = _linearize_at(func, result.linearization_point, + master.variable_map) + _emit_disjunct_oa_cut(method.inner_method, set, master, + _remap_indicator_to_binary(binary_ref, master.variable_map), + linearization, method, penalty_sign) + end + return +end + +# 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, + lin, + method::LOA + ) + penalty_sign = _penalty_sign(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) + +# 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) + JuMP.set_objective_function(master.model, + JuMP.objective_function(master.model) + + penalty_sign * method.oa_penalty * slack) + return slack +end + +# Extract RHS from an MOI set. +_set_rhs(s::Union{_MOI.LessThan, _MOI.GreaterThan, _MOI.EqualTo}) = + _MOI.constant(s) + +# 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.LessThan, lin) = (lin - _set_rhs(set),) +_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) + +# 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, + linearization, + method::LOA, + penalty_sign::Int + ) + slack = _penalized_slack(master, method, penalty_sign) + 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 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, + linearization, + method::LOA, + penalty_sign::Int + ) + slack = _penalized_slack(master, method, penalty_sign) + 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 direction carries a penalized slack so a +# nonconvex linearization can't make the master infeasible. +function _add_global_oa_row( + master::_LOAMaster, + lin, + set, + method::LOA, + penalty_sign::Int + ) + slack = _penalized_slack(master, method, penalty_sign) + for term in _oa_cut_terms(set, lin) + JuMP.@constraint(master.model, term <= slack) + end + return +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/reformulate.jl b/src/reformulate.jl index 9a3b65cb..bb08f2b8 100644 --- a/src/reformulate.jl +++ b/src/reformulate.jl @@ -25,6 +25,20 @@ function _clear_reformulations(model::JuMP.AbstractModel) empty!(gdp_data(model).reformulation_constraints) empty!(gdp_data(model).reformulation_variables) empty!(gdp_data(model).variable_bounds) + _restore_logical_binaries(model) + return +end + +# An LOA solve leaves the logical binaries relaxed and fixed to its +# incumbent. Restore them to free binaries so a later reformulation of the +# model (or a copy of it) sees the integrality it expects. +function _restore_logical_binaries(model::JuMP.AbstractModel) + V = JuMP.variable_ref_type(typeof(model)) + for (_, bvar) in _indicator_to_binary(model) + bvar isa V || continue + JuMP.is_fixed(bvar) && JuMP.unfix(bvar) + JuMP.is_binary(bvar) || JuMP.set_binary(bvar) + end return end diff --git a/src/utilities.jl b/src/utilities.jl index bca1b748..2af2cc12 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) @@ -62,6 +79,121 @@ 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 (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 + +################################################################################ +# 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` (or a binary variable) → +`Bool` (whether it was active). The 3-arg form lets you supply an +explicit `binary_map` (defaulting to `_indicator_to_binary(model)`) +translating each combination key to the binary used in the cut, e.g. +when the binaries belong to a copy of the original model (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 ################################################################################ @@ -399,5 +531,101 @@ 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 = [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 diff --git a/test/constraints/loa.jl b/test/constraints/loa.jl new file mode 100644 index 00000000..a6ca14bc --- /dev/null +++ b/test/constraints/loa.jl @@ -0,0 +1,801 @@ +using HiGHS, Ipopt, Juniper + +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.set_cover_max_iter == 8 + @test method.M_value == 1e9 + @test method.max_slack == 1000.0 + @test method.oa_penalty == 1000.0 + @test method.use_nlpf == true + @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, M_value = 1e6, + max_slack = 500.0, oa_penalty = 200.0, use_nlpf = false, + convergence_tol = 1e-4, slack_tol = 1e-3) + @test method.max_iter == 50 + @test method.M_value == 1e6 + @test method.max_slack == 500.0 + @test method.oa_penalty == 200.0 + @test method.use_nlpf == false + @test method.convergence_tol == 1e-4 + @test method.slack_tol == 1e-3 + @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_cover_disjuncts() + # Only disjuncts owning a nonlinear constraint need covering: the two + # `x^2` disjuncts do, the two linear `x` disjuncts do not. + model = GDPModel() + @variable(model, -5 <= x <= 5) + @variable(model, Y[1:2], Logical) + @constraint(model, x^2 <= 3, Disjunct(Y[1])) + @constraint(model, x^2 >= 5, Disjunct(Y[2])) + @disjunction(model, Y) + @variable(model, W[1:2], Logical) + @constraint(model, x <= 1, Disjunct(W[1])) + @constraint(model, x >= -1, Disjunct(W[2])) + @disjunction(model, W) + + DP.reformulate_model(model, BigM(1e9)) + method = LOA(HiGHS.Optimizer) + problem = DP.build_loa_problem(model, method) + disjuncts = DP._cover_disjuncts(problem) + + # Two nonlinear disjuncts to cover; the linear W disjuncts omitted. + @test length(disjuncts) == 2 + binary_map = DP._indicator_to_binary(model) + underlying = Set(DP._underlying_binary(dref) for dref in disjuncts) + @test underlying == Set([DP._underlying_binary(binary_map[Y[1]]), + DP._underlying_binary(binary_map[Y[2]])]) +end + +function test_oa_cut_terms() + # The `<= 0` cut directions per set, at a scalar linearization value + # of 5: LessThan/GreaterThan give one signed direction, EqualTo and + # Interval give both. + @test DP._oa_cut_terms(MOI.LessThan(2.0), 5.0) == (3.0,) + @test DP._oa_cut_terms(MOI.GreaterThan(2.0), 5.0) == (-3.0,) + @test DP._oa_cut_terms(MOI.EqualTo(2.0), 5.0) == (3.0, -3.0) + @test DP._oa_cut_terms(MOI.Interval(1.0, 4.0), 5.0) == (1.0, -4.0) +end + +function test_is_linear_F() + # Scalar and vector variable-ref / affine function types are linear + # (copied into the master at build time); anything else, e.g. a + # quadratic, is nonlinear and enters only as an OA cut. + @test DP._is_linear_F(JuMP.VariableRef) + @test DP._is_linear_F(JuMP.AffExpr) + @test DP._is_linear_F(Vector{JuMP.VariableRef}) + @test DP._is_linear_F(Vector{JuMP.AffExpr}) + @test !DP._is_linear_F(JuMP.QuadExpr) +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)) + method = LOA(HiGHS.Optimizer) + problem = DP.build_loa_problem(model, method) + master = DP._build_loa_master(problem, method) + master_model = master.model + + binary_map = DP._indicator_to_binary(model) + combo = Dict( + DP._underlying_binary(binary_map[Y[1]]) => + DP._underlying_value(binary_map[Y[1]], true), + DP._underlying_binary(binary_map[Y[2]]) => + DP._underlying_value(binary_map[Y[2]], false)) + + num_cons_before = length(JuMP.all_constraints( + master_model; + include_variable_in_set_constraints = false)) + cref = DP.avoid_combination(master.model, combo, master.variable_map) + num_cons_after = length(JuMP.all_constraints( + master_model; + include_variable_in_set_constraints = false)) + + @test num_cons_after == num_cons_before + 1 + # The cut is (1 - y1) + y2 >= 1, i.e. normalized -y1 + y2 >= 0: + # excludes exactly the (Y1 active, Y2 inactive) combination. + y1 = master.variable_map[binary_map[Y[1]]] + y2 = master.variable_map[binary_map[Y[2]]] + @test JuMP.normalized_coefficient(cref, y1) == -1.0 + @test JuMP.normalized_coefficient(cref, y2) == 1.0 + @test JuMP.normalized_rhs(cref) == 0.0 +end + +function test_loa_reformulate_simple() + 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) + + method = LOA(HiGHS.Optimizer) + DP.reformulate_model(model, method) + + @test DP._ready_to_optimize(model) + # The committed model solves to the LOA incumbent: x = 7 via Y[2]. + JuMP.optimize!(model, ignore_optimize_hook = true) + @test objective_value(model) ≈ 7.0 atol = 1e-6 + @test value(x) ≈ 7.0 atol = 1e-6 +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_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) + # 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_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 the global-cut pass 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_loa_nonlinear_equality_global() + # max x s.t. x^2 == 25 (global nonlinear equality), (x <= 3) ∨ + # (x <= 8), 0 <= x <= 10. The Y[1] seed is NLP-infeasible (x <= 3 + # contradicts x = 5), so NLPF slacks the inequality while keeping + # the equality exact. The equality emits BOTH cut directions into + # the master. Optimum: x = 5 via Y[2]. + ipopt = optimizer_with_attributes(Ipopt.Optimizer, + "print_level" => 0, "sb" => "yes") + model = GDPModel(ipopt) + 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(ipopt; 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(x) ≈ 5.0 atol = 1e-3 + @test value(Y[2]) ≈ 1.0 atol = 1e-6 +end + +function test_loa_nonlinear_equality_disjunct() + # Nonlinear equality inside a disjunct: Y1: x <= 3, Y2: x^2 == 64. + # The disjunct cut emits both gated directions. Optimum: x = 8. + 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)) + @test termination_status(model) in (MOI.OPTIMAL, MOI.LOCALLY_SOLVED) + @test objective_value(model) ≈ 8.0 atol = 1e-3 + @test value(Y[2]) ≈ 1.0 atol = 1e-6 +end + +function test_loa_nonlinear_interval_disjunct() + # Nonlinear Interval constraint inside a disjunct: Y1: x <= 3, + # Y2: 36 <= x^2 <= 64. The disjunct cut emits both gated + # directions. 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, 36 <= x^2 <= 64, 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) ≈ 8.0 atol = 1e-3 + @test value(Y[2]) ≈ 1.0 atol = 1e-6 +end + +function test_loa_restores_prior_time_limit() + # A time limit set by the user before LOA must survive the loop's + # per-solve caps when only `iteration_time_limit` is finite (the + # `_restore_time_limit(::Real)` path). + 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) + set_time_limit_sec(model, 90.0) + optimize!(model, gdp_method = LOA(HiGHS.Optimizer; + iteration_time_limit = 60.0, time_limit = Inf)) + @test time_limit_sec(model) == 90.0 + @test termination_status(model) == MOI.OPTIMAL + @test objective_value(model) ≈ 7.0 atol = 1e-4 +end + +function test_loa_limit_hit_report() + # Stop the main loop on `max_iter = 1` after the master has produced + # a bound: the report must label the run "limit hit" (not converged), + # and the single loop iteration still finds the off-diagonal optimum. + 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) + method = LOA(HiGHS.Optimizer; max_iter = 1) + @test_logs (:info, r"limit hit") match_mode = :any begin + DP.reformulate_model(model, method) + end + JuMP.optimize!(model, ignore_optimize_hook = true) + @test objective_value(model) ≈ -10.0 atol = 1e-4 +end + +function test_loa_complement_indicator_nonlinear_disjunct() + # Regression: complement-form indicators carry `1 - y_base` (an + # AffExpr) as their binary reference. When the complement disjunct has a + # nonlinear constraint, the disjunct-cut pass 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) + # 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_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 + +function test_loa_nlpf_disabled() + # `use_nlpf = false` skips the NLPF fall-through entirely: an + # NLP-infeasible combination returns the bare infeasible sentinel + # (empty linearization point), so it contributes only its no-good + # cut. Same setup as test_loa_nlpf_infeasible_disjunct, where NLPF + # WOULD succeed (x = 10, u = 100) and return a nonempty + # linearization point — the empty point proves NLPF was skipped. + ipopt = optimizer_with_attributes(Ipopt.Optimizer, + "print_level" => 0, "sb" => "yes") + model = GDPModel() + @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) + + DP.reformulate_model(model, BigM(1e9)) + method = LOA(ipopt; use_nlpf = false) + problem = DP.build_loa_problem(model, method) + DP._relax_binaries(problem) + JuMP.set_optimizer(problem.nlp, method.nlp_optimizer) + JuMP.set_silent(problem.nlp) + + binary_map = DP._indicator_to_binary(model) + combination = Dict( + DP._underlying_binary(binary_map[Y[1]]) => true, + DP._underlying_binary(binary_map[Y[2]]) => false) + + result = DP._solve_nlp(problem, combination, method) + @test result.feasible == false + @test result.objective == Inf + @test isempty(result.linearization_point) + + # End-to-end: with NLPF off, LOA still reaches the Y2 optimum x = 5 + # off no-good cuts alone. + 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, use_nlpf = false)) + @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 +end + +function test_loa_solve_nlp_infeasible_fallback() + # `_solve_nlp` fallback: both the primary NLP and NLPF are + # infeasible at the fixed combination, so it returns the infeasible + # sentinel (objective = Inf, feasible = false, empty linearization + # point, combination echoed back unchanged). + # + # The global x^2 == 400 is unsatisfiable under 0 <= x <= 10 (needs + # x = 20). It is NONLINEAR, so it never enters the master and the + # primary NLP is the one that catches it as infeasible. It is an + # EQUALITY, so NLPF does not slack it and NLPF is infeasible too. + # This is what distinguishes the path from + # test_loa_no_feasible_incumbent, whose LINEAR equality makes the + # master infeasible first, short-circuiting the NLP solve entirely. + ipopt = optimizer_with_attributes(Ipopt.Optimizer, + "print_level" => 0, "sb" => "yes") + model = GDPModel() + @variable(model, 0 <= x <= 10) + @constraint(model, x^2 == 400) + @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) + + DP.reformulate_model(model, BigM(1e9)) + method = LOA(ipopt) + problem = DP.build_loa_problem(model, method) + DP._relax_binaries(problem) + JuMP.set_optimizer(problem.nlp, method.nlp_optimizer) + JuMP.set_silent(problem.nlp) + + binary_map = DP._indicator_to_binary(model) + combination = Dict( + DP._underlying_binary(binary_map[Y[1]]) => false, + DP._underlying_binary(binary_map[Y[2]]) => true) + + result = DP._solve_nlp(problem, combination, method) + @test result.feasible == false + @test result.objective == Inf + @test isempty(result.linearization_point) + @test result.combination === combination +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, time_limit = Inf)) + @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 + +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 + +function test_loa_hull_nested_disaggregation_map() + # The inner disjunction (over y) is a disjunct of the outer (gated by + # z[1]). Hull's nested redispatch must propagate the LOA + # disaggregation map 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) + disaggregations = Dict{Any, Any}() + DP.reformulate_model(model, Hull(; disaggregation_map = disaggregations)) + @test haskey(disaggregations, (x, y[1])) + @test haskey(disaggregations, (x, y[2])) +end + +function test_reformulate_after_loa_restores_binaries() + # LOA relaxes the logical binaries and fixes them to its incumbent, so + # a later reformulation of the same model must restore binary + # integrality. Regression: a nested disjunction's tight-M looked up a + # relaxed binary's bounds and threw a KeyError. + model = GDPModel(HiGHS.Optimizer) + set_silent(model) + @variable(model, 1 <= x <= 9) + @variable(model, Y[1:2], Logical) + @variable(model, W[1:2], Logical) + @constraint(model, 1 <= x <= 2, Disjunct(W[1])) + @constraint(model, 2 <= x <= 3, Disjunct(W[2])) + @constraint(model, x >= 8, Disjunct(Y[2])) + @disjunction(model, [W[1], W[2]], Disjunct(Y[1])) + @disjunction(model, [Y[1], Y[2]]) + @objective(model, Max, x) + + optimize!(model, gdp_method = LOA(HiGHS.Optimizer)) + bins = [v for (_, v) in DP._indicator_to_binary(model) + if v isa JuMP.VariableRef] + @test all(!JuMP.is_binary, bins) # LOA left them relaxed + + @test optimize!(model, gdp_method = BigM()) isa Nothing + @test all(JuMP.is_binary, bins) # restored by reformulation + @test termination_status(model) == MOI.OPTIMAL + @test objective_value(model) ≈ 9 +end + +@testset "LOA" begin + test_loa_datatype() + test_cover_disjuncts() + test_oa_cut_terms() + test_is_linear_F() + test_no_good_cut() + 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_nonlinear_equality_global() + test_loa_nonlinear_equality_disjunct() + test_loa_nonlinear_interval_disjunct() + test_loa_restores_prior_time_limit() + test_loa_limit_hit_report() + test_loa_complement_indicator_nonlinear_disjunct() + test_loa_nlpf_infeasible_disjunct() + test_loa_nlpf_disabled() + test_loa_solve_nlp_infeasible_fallback() + test_loa_sense_primitives() + 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() + test_loa_hull_nested_disaggregation_map() + test_reformulate_after_loa_restores_binaries() +end 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() diff --git a/test/extensions/InfiniteDisjunctiveProgramming.jl b/test/extensions/InfiniteDisjunctiveProgramming.jl index 47bb884b..5fe4186a 100644 --- a/test/extensions/InfiniteDisjunctiveProgramming.jl +++ b/test/extensions/InfiniteDisjunctiveProgramming.jl @@ -513,6 +513,76 @@ function test_mbm_finite_and_integer_var() [MOI.OPTIMAL, MOI.LOCALLY_SOLVED] end +function test_mbm_infinite_simple() + model = InfiniteGDPModel(HiGHS.Optimizer) + set_silent(model) + 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 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) + 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) + 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(transcribed; + 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) + @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) @@ -782,13 +852,353 @@ function test_CuttingPlanes_multiparameter() @objective(model, Min, ∫(∫(x, t), s)) - # Should not throw - @test optimize!(model, - gdp_method = CuttingPlanes( - HiGHS.Optimizer; max_iter = 5) - ) isa Nothing + optimize!(model, + gdp_method = CuttingPlanes(HiGHS.Optimizer; max_iter = 5)) @test termination_status(model) in [MOI.OPTIMAL, MOI.LOCALLY_SOLVED] + # Y[2] (x <= 3) is the minimizer: x = 0 across (t, s) → ∫∫x = 0. + @test objective_value(model) ≈ 0.0 atol = 1e-4 + @test all(isapprox.(value(x), 0.0; atol = 1e-6)) +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 per-support fan-out + # of the transcribed global constraints. 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 + @test all(value(Y[2])) + @test all(isapprox.(value(x), 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. Fixing and + # gating must resolve it onto the underlying binary's transcription + # with the value inverted. + # + # 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_complement_nonlinear_disjunct() + # Regression: a finite (complement-form AffExpr) indicator gating a + # NONLINEAR constraint on an infinite variable. The constraint + # transcribes to one scalar row per support while the binary + # reference is a single expression, so the fan-out must broadcast + # it across the constraint's supports. + # + # 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. Transcription + # expands a variable over ξ[1:2] across its joint supports; the + # transcribed binaries and constraint rows must stay aligned. + # + # 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_nlpf_infeasible_disjunct() + # 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 transcribed NLP (per-support binary fixes included), + # 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, + "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 >= 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]. + # The aggregate global transcribes to a single scalar (the + # measure is expanded), exercising the single-row branch of + # the transcribed global constraints. 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 + +function test_loa_infinite_aggregate_disjunct() + # An aggregate (measure) constraint INSIDE a disjunct: it + # transcribes to a single scalar row that must be gated by the + # indicator's binary. max ∫x dt with Y[1]: ∫(x^2, t) <= 4 and + # Y[2]: x <= 0. Y[1] is optimal: constant x = 2 gives ∫x^2 = 4 + # and ∫x dt = 2 (Y[2] caps the objective at 0). Big-M tightening + # is disabled: it needs bounds of every constraint variable and a + # `MeasureRef` has none. + 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, Y[1:2], Logical) + @constraint(model, ∫(x^2, t) <= 4, Disjunct(Y[1])) + @constraint(model, x <= 0, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Max, ∫(x, t)) + optimize!(model, gdp_method = LOA(ipopt; + mip_optimizer = HiGHS.Optimizer, inner_method = BigM(1e4, false))) + @test termination_status(model) in (MOI.OPTIMAL, MOI.LOCALLY_SOLVED) + @test objective_value(model) ≈ 2.0 atol = 1e-2 + @test value(Y[1]) +end + +function test_loa_infinite_iteration_loop() + # Force the InfiniteModel LOA main loop to fix combinations that + # vary per support. Two disjunctions over disjoint x/z half-lines + # put the optimum on an off-diagonal combination the set-covering + # seeds never try, so the master stays feasible and the loop body + # runs with master-extracted 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 + @test all(value(Y[1])) + @test all(value(W[2])) + @test all(isapprox.(value(x), 0.0; atol = 1e-6)) + @test all(isapprox.(value(z), 10.0; atol = 1e-6)) +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 + # transcribed disaggregated infinite variable. 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 + @test all(value(Y[2])) + @test all(isapprox.(value(x), 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 transcribed complement binary `1 - y1_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 single copy by each per-support binary (driven off the + # binary, not the variable). max ∫x dt + w over t ∈ [0,1]: + # Y1: x^2 + w^2 <= 25 Y2: x <= 0, w <= 3 + # Y1 is active (Y2 caps the objective at 3); 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])) + @constraint(model, w <= 3, 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 @@ -855,4 +1265,22 @@ end test_CuttingPlanes_multiparameter() end + # LOA runs on the transcribed backend, a plain JuMP model, so it + # needs no `JuMP.copy_model(::InfiniteModel)` from the InfiniteOpt + # fork (unlike MBM). + @testset "LOA" begin + test_loa_infinite_nonlinear_global() + test_loa_infinite_aggregate_global() + test_loa_infinite_aggregate_disjunct() + test_loa_infinite_complement_indicator() + test_loa_infinite_complement_nonlinear_disjunct() + 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() + end + 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 diff --git a/test/utilities.jl b/test/utilities.jl index 2b87e6e1..463bbb88 100644 --- a/test/utilities.jl +++ b/test/utilities.jl @@ -238,6 +238,125 @@ 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 + +function test_fix_unfix_indicator() + # `fix_indicator` drives the backing binary: a plain indicator fixes + # it at the value, a complement indicator fixes the underlying + # binary at the inverse. `unfix_indicator` undoes both forms. + model = GDPModel() + @variable(model, x) + @variable(model, Y1, Logical) + @variable(model, Y2, Logical, logical_complement = Y1) + @constraint(model, x <= 3, Disjunct(Y1)) + @constraint(model, x >= 5, Disjunct(Y2)) + @disjunction(model, [Y1, Y2]) + DP.reformulate_model(model, BigM()) + y1 = DP._indicator_to_binary(model)[Y1] + + DP.fix_indicator(model, Y1, true) + @test JuMP.is_fixed(y1) && JuMP.fix_value(y1) == 1.0 + DP.fix_indicator(model, Y1, false) + @test JuMP.fix_value(y1) == 0.0 + DP.unfix_indicator(model, Y1) + @test !JuMP.is_fixed(y1) + + DP.fix_indicator(model, Y2, true) # complement: underlying -> 0 + @test JuMP.fix_value(y1) == 0.0 + DP.fix_indicator(model, Y2, false) + @test JuMP.fix_value(y1) == 1.0 + DP.unfix_indicator(model, Y2) + @test !JuMP.is_fixed(y1) +end + +function test_avoid_combination_default_map() + # The 2-arg form resolves binaries via the model's own + # indicator-to-binary map. Excluding (Y1 active, Y2 inactive) adds + # (1 - y1) + y2 >= 1, i.e. normalized -y1 + y2 >= 0. + 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()) + binary_map = DP._indicator_to_binary(model) + + cref = DP.avoid_combination(model, Dict(Y[1] => true, Y[2] => false)) + @test JuMP.normalized_coefficient(cref, binary_map[Y[1]]) == -1.0 + @test JuMP.normalized_coefficient(cref, binary_map[Y[2]]) == 1.0 + @test JuMP.normalized_rhs(cref) == 0.0 +end + @testset "Utility Functions" begin test_all_variables() test_collect_all_vars() @@ -245,4 +364,10 @@ 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() + test_fix_unfix_indicator() + test_avoid_combination_default_map() end