Skip to content

Commit 5adee47

Browse files
committed
_is_linear test coverage
1 parent 025f9f8 commit 5adee47

4 files changed

Lines changed: 137 additions & 86 deletions

File tree

ext/InfiniteDisjunctiveProgramming.jl

Lines changed: 1 addition & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -408,25 +408,6 @@ function _transcribed_binary_refs(model::InfiniteOpt.InfiniteModel, indicator)
408408
return [1.0 - binary for binary in binaries]
409409
end
410410

411-
# Transcribed combination for an InfiniteModel: every support of the
412-
# underlying transcribed binary takes the indicator's value.
413-
function _transcribed_combination(
414-
model::InfiniteOpt.InfiniteModel,
415-
combination
416-
)
417-
result = Dict{JuMP.VariableRef, Bool}()
418-
binary_map = DP._indicator_to_binary(model)
419-
for (indicator, active) in combination
420-
binary_ref = binary_map[indicator]
421-
value = DP._underlying_value(binary_ref, active)
422-
for binary in _transcribed_refs(InfiniteOpt.transformation_variable(
423-
DP._underlying_binary(binary_ref)))
424-
result[binary] = value
425-
end
426-
end
427-
return result
428-
end
429-
430411
# Transcribe the inner Hull disaggregation map per support. A finite
431412
# variable under an infinite indicator keys its single disaggregated
432413
# copy by each per-support binary reference, mirroring the per-support
@@ -468,8 +449,6 @@ function DP.build_loa_problem(
468449
DP._underlying_binary(binary_ref))))
469450
end
470451
unique!(binaries)
471-
combinations = [_transcribed_combination(model, combination)
472-
for combination in DP._set_covering_combinations(model)]
473452

474453
disjunct_constraints = Tuple{Any, Any, Any}[]
475454
for (_, disjunction) in DP._disjunctions(model)
@@ -509,7 +488,7 @@ function DP.build_loa_problem(
509488
end
510489
end
511490

512-
return DP._LOAProblem(nlp, binaries, combinations,
491+
return DP._LOAProblem(nlp, binaries,
513492
disjunct_constraints, global_constraints,
514493
_transcribed_disaggregation_map(model, disaggregation_map))
515494
end

src/datatypes.jl

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -587,6 +587,8 @@ iteration) and a master MILP accumulating OA and no-good cuts.
587587
- `mip_optimizer::P`: solver for the master MILP (default `nlp_optimizer`).
588588
- `inner_method::R`: NLP reformulation — `BigM`, `MBM`, or `Hull`.
589589
- `max_iter::Int`: max iterations after set-covering seeding.
590+
- `set_cover_max_iter::Int`: max iterations of the set-covering seed
591+
loop.
590592
- `M_value::T`: big-M for the disjunct OA cut gating term.
591593
- `max_slack::T`: upper bound per slack variable.
592594
- `oa_penalty::T`: penalty on slacks in the master objective.
@@ -602,6 +604,7 @@ struct LOA{O, P, R, T} <: AbstractReformulationMethod
602604
mip_optimizer::P
603605
inner_method::R
604606
max_iter::Int
607+
set_cover_max_iter::Int
605608
M_value::T
606609
max_slack::T
607610
oa_penalty::T
@@ -613,6 +616,7 @@ struct LOA{O, P, R, T} <: AbstractReformulationMethod
613616
nlp_optimizer::O;
614617
mip_optimizer::P = nlp_optimizer,
615618
max_iter::Int = 10,
619+
set_cover_max_iter::Int = 8,
616620
M_value::T = 1e9,
617621
max_slack::T = 1e3,
618622
oa_penalty::T = 1e3,
@@ -626,24 +630,24 @@ struct LOA{O, P, R, T} <: AbstractReformulationMethod
626630
"LOA inner_method must be BigM, MBM, or Hull (got $R). " *
627631
"PSplit is not yet supported.")
628632
new{O, P, R, T}(nlp_optimizer, mip_optimizer, inner_method,
629-
max_iter, M_value, max_slack, oa_penalty,
633+
max_iter, set_cover_max_iter, M_value, max_slack, oa_penalty,
630634
convergence_tol, slack_tol,
631635
iteration_time_limit, time_limit)
632636
end
633637
end
634638

635639
# The problem the LOA loop operates on: the model solved as the NLP
636640
# subproblem, the binary variables backing the indicators, the
637-
# set-covering seed combinations, the nonlinear disjunct
638-
# `(binary_ref, function, set)` triples (`binary_ref` is the binary or
639-
# its `1 - y` complement expression), the nonlinear global
640-
# `(function, set)` pairs, and the `(variable, binary_ref) ->
641+
# nonlinear disjunct `(binary_ref, function, set)` triples (`binary_ref`
642+
# is the binary or its `1 - y` complement expression), the nonlinear
643+
# global `(function, set)` pairs, and the `(variable, binary_ref) ->
641644
# disaggregated variable` map from an inner Hull reformulation
642-
# (`nothing` for Big-M / MBM). Built by `build_loa_problem`.
645+
# (`nothing` for Big-M / MBM). Built by `build_loa_problem`. The
646+
# set-covering seed generates its combinations on the fly from the
647+
# master, so none are stored here.
643648
struct _LOAProblem{M <: JuMP.AbstractModel, V <: JuMP.AbstractVariableRef}
644649
nlp::M
645650
binaries::Vector{V}
646-
covering_combinations::Vector{Dict{V, Bool}}
647651
disjunct_constraints::Vector{Tuple{Any, Any, Any}}
648652
global_constraints::Vector{Tuple{Any, Any}}
649653
disaggregation_map::Any

src/loa.jl

Lines changed: 85 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,33 @@ function reformulate_model(model::JuMP.AbstractModel, method::LOA)
4444
best_result = nothing
4545
master_bound = nothing
4646

47-
# Seed the master with an OA cut per set-covering combination, each
48-
# NLP warm-started from the last feasible primal.
47+
# Set-covering seed. This mimics Pyomo GDPopt's set-covering
48+
# initialization: borrow the master as a covering MILP (only its
49+
# objective changes), let it pick a combination that activates the
50+
# nonlinear disjuncts still lacking a linearization, solve the NLP
51+
# there, and seed the resulting OA and no-good cuts. Each NLP
52+
# warm-starts from the last feasible primal.
4953
previous_result = nothing
50-
for combination in problem.covering_combinations
54+
cover_disjuncts = _cover_disjuncts(problem)
55+
needs_cover = trues(length(cover_disjuncts))
56+
num_covered = 0
57+
for iteration in 1:method.set_cover_max_iter
58+
(iteration == 1 || any(needs_cover)) || break
5159
time() < loop_deadline || break
60+
# Swap in the covering objective, solve, read off the combination,
61+
# then restore the OA objective (with its accumulated slack
62+
# penalties) before emitting cuts against it.
63+
oa_objective = JuMP.objective_function(master.model)
64+
JuMP.@objective(master.model, Max,
65+
_cover_objective(master, cover_disjuncts, needs_cover,
66+
num_covered))
67+
_cap_remaining_time(master.model, loop_deadline)
68+
JuMP.optimize!(master.model)
69+
solved = JuMP.is_solved_and_feasible(master.model)
70+
combination = solved ? _extract_combination(problem, master) : nothing
71+
JuMP.set_objective_sense(master.model, master.objective_sense)
72+
JuMP.set_objective_function(master.model, oa_objective)
73+
solved || break
5274
_set_nlp_warm_start(previous_result)
5375
result = _solve_nlp(problem, combination, method;
5476
deadline = loop_deadline)
@@ -60,6 +82,17 @@ function reformulate_model(model::JuMP.AbstractModel, method::LOA)
6082
best_result = result
6183
end
6284
result.feasible && (previous_result = result)
85+
# A disjunct counts as covered only once it is active in a
86+
# feasible NLP. An infeasible combination still contributes its
87+
# no-good cut above but leaves the coverage targets untouched.
88+
if result.feasible
89+
for i in eachindex(cover_disjuncts)
90+
needs_cover[i] || continue
91+
_disjunct_active(result.combination, cover_disjuncts[i]) &&
92+
(needs_cover[i] = false)
93+
end
94+
num_covered = count(!, needs_cover)
95+
end
6396
end
6497

6598
# Master/NLP loop: `alpha_oa` is the bound, the NLP refines the
@@ -143,25 +176,50 @@ function reformulate_model(::M, ::LOA) where {M}
143176
end
144177

145178
################################################################################
146-
# SET-COVERING INITIALIZATION (simple version from pyomo)
179+
# SET-COVERING INITIALIZATION
147180
################################################################################
148-
# `K = max disjunction size` combinations that activate every indicator
149-
# at least once: combination `k` activates the `k`-th indicator of each
150-
# disjunction, cycling via `mod1`. Inconsistent nested combinations are
151-
# caught by the no-good cut from the infeasible NLP.
152-
function _set_covering_combinations(model::JuMP.AbstractModel)
153-
LogicalRef = LogicalVariableRef{typeof(model)}
154-
indicator_lists = [collect(d.constraint.indicators)
155-
for (_, d) in _disjunctions(model)]
156-
isempty(indicator_lists) && return Dict{LogicalRef, Bool}[]
157-
K = maximum(length, indicator_lists)
158-
return [
159-
Dict{LogicalRef, Bool}(
160-
indicator => (indicator == indicators[mod1(k, length(indicators))])
161-
for indicators in indicator_lists
162-
for indicator in indicators)
163-
for k in 1:K
164-
]
181+
# The nonlinear disjuncts to cover: one entry per distinct indicator that
182+
# owns a nonlinear disjunct constraint, carrying that indicator's
183+
# `binary_ref`. Keyed by `(underlying binary, active value)` so the two
184+
# disjuncts of a single-binary disjunction (`y` and its `1 - y`
185+
# complement) stay distinct. Purely linear disjuncts need no cover: the
186+
# inner reformulation already places them in the master exactly.
187+
function _cover_disjuncts(problem::_LOAProblem)
188+
V = eltype(problem.binaries)
189+
seen = Set{Tuple{V, Bool}}()
190+
disjuncts = Any[]
191+
for (binary_ref, _, _) in problem.disjunct_constraints
192+
key = (_underlying_binary(binary_ref),
193+
_underlying_value(binary_ref, true))
194+
key in seen && continue
195+
push!(seen, key)
196+
push!(disjuncts, binary_ref)
197+
end
198+
return disjuncts
199+
end
200+
201+
# The set-covering objective (master space), mimicking Pyomo GDPopt:
202+
# maximize active disjuncts weighted `num_covered + 1` if still uncovered
203+
# else `1`, so one uncovered disjunct outweighs every covered one and each
204+
# solve must activate a new disjunct when the logic allows. Empty
205+
# `disjuncts` gives the zero expression: a constant objective that still
206+
# seeds one feasible combination (needed to bound `alpha_oa`).
207+
function _cover_objective(
208+
master::_LOAMaster,
209+
disjuncts,
210+
needs_cover,
211+
num_covered::Int
212+
)
213+
T = JuMP.value_type(typeof(master.model))
214+
V = JuMP.variable_ref_type(typeof(master.model))
215+
expr = JuMP.GenericAffExpr{T, V}(zero(T))
216+
for i in eachindex(disjuncts)
217+
weight = needs_cover[i] ? num_covered + 1 : 1
218+
activation = _remap_indicator_to_binary(disjuncts[i],
219+
master.variable_map)
220+
JuMP.add_to_expression!(expr, T(weight), activation)
221+
end
222+
return expr
165223
end
166224

167225
################################################################################
@@ -176,20 +234,6 @@ _underlying_binary(binary_ref::JuMP.GenericAffExpr) =
176234
_underlying_value(::JuMP.AbstractVariableRef, active::Bool) = active
177235
_underlying_value(::JuMP.GenericAffExpr, active::Bool) = !active
178236

179-
# Translate an indicator-level combination onto the underlying binary
180-
# variables, inverting the value for complement-form indicators.
181-
function _binary_combination(model::JuMP.AbstractModel, combination)
182-
V = JuMP.variable_ref_type(typeof(model))
183-
binary_map = _indicator_to_binary(model)
184-
result = Dict{V, Bool}()
185-
for (indicator, active) in combination
186-
binary_ref = binary_map[indicator]
187-
result[_underlying_binary(binary_ref)] =
188-
_underlying_value(binary_ref, active)
189-
end
190-
return result
191-
end
192-
193237
"""
194238
build_loa_problem(
195239
model::JuMP.AbstractModel,
@@ -199,11 +243,11 @@ end
199243
200244
Build the problem the LOA loop operates on from `model` (already
201245
reformulated by the LOA inner method): the NLP subproblem, the binary
202-
variables backing the indicators, the set-covering seed combinations,
203-
the nonlinear disjunct `(binary_ref, function, set)` triples, the
204-
nonlinear global `(function, set)` pairs, and the Hull disaggregation
205-
map. The NLP is `model` itself; the InfiniteOpt extension overloads
206-
this to build the problem from the transcribed backend instead.
246+
variables backing the indicators, the nonlinear disjunct
247+
`(binary_ref, function, set)` triples, the nonlinear global
248+
`(function, set)` pairs, and the Hull disaggregation map. The NLP is
249+
`model` itself; the InfiniteOpt extension overloads this to build the
250+
problem from the transcribed backend instead.
207251
208252
## Returns
209253
- `_LOAProblem`: the problem.
@@ -219,8 +263,6 @@ function build_loa_problem(
219263
binaries = V[_underlying_binary(binary_ref)
220264
for (_, binary_ref) in binary_map]
221265
unique!(binaries)
222-
combinations = [_binary_combination(model, combination)
223-
for combination in _set_covering_combinations(model)]
224266

225267
disjunct_constraints = Tuple{Any, Any, Any}[]
226268
for (_, disjunction) in _disjunctions(model)
@@ -255,7 +297,7 @@ function build_loa_problem(
255297
binary_disaggregations = disaggregation_map === nothing ? nothing :
256298
Dict((variable, binary_map[indicator]) => disaggregated
257299
for ((variable, indicator), disaggregated) in disaggregation_map)
258-
return _LOAProblem(model, binaries, combinations, disjunct_constraints,
300+
return _LOAProblem(model, binaries, disjunct_constraints,
259301
global_constraints, binary_disaggregations)
260302
end
261303

test/constraints/loa.jl

Lines changed: 40 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ function test_loa_datatype()
55
@test method.nlp_optimizer == HiGHS.Optimizer
66
@test method.mip_optimizer == HiGHS.Optimizer
77
@test method.max_iter == 10
8+
@test method.set_cover_max_iter == 8
89
@test method.M_value == 1e9
910
@test method.max_slack == 1000.0
1011
@test method.oa_penalty == 1000.0
@@ -29,22 +30,31 @@ function test_loa_datatype()
2930
@test method.inner_method isa MBM
3031
end
3132

32-
function test_set_covering_combos()
33+
function test_cover_disjuncts()
34+
# Only disjuncts owning a nonlinear constraint need covering: the two
35+
# `x^2` disjuncts do, the two linear `x` disjuncts do not.
3336
model = GDPModel()
34-
@variable(model, x)
37+
@variable(model, -5 <= x <= 5)
3538
@variable(model, Y[1:2], Logical)
36-
@constraint(model, x <= 3, Disjunct(Y[1]))
37-
@constraint(model, x >= 5, Disjunct(Y[2]))
39+
@constraint(model, x^2 <= 3, Disjunct(Y[1]))
40+
@constraint(model, x^2 >= 5, Disjunct(Y[2]))
3841
@disjunction(model, Y)
42+
@variable(model, W[1:2], Logical)
43+
@constraint(model, x <= 1, Disjunct(W[1]))
44+
@constraint(model, x >= -1, Disjunct(W[2]))
45+
@disjunction(model, W)
3946

40-
combos = DP._set_covering_combinations(model)
47+
DP.reformulate_model(model, BigM(1e9))
48+
method = LOA(HiGHS.Optimizer)
49+
problem = DP.build_loa_problem(model, method)
50+
disjuncts = DP._cover_disjuncts(problem)
4151

42-
# K = 2 combinations, each activating exactly one indicator, and
43-
# together covering both.
44-
@test length(combos) == 2
45-
@test all(count(values(combo)) == 1 for combo in combos)
46-
@test combos[1][Y[1]] && !combos[1][Y[2]]
47-
@test combos[2][Y[2]] && !combos[2][Y[1]]
52+
# Two nonlinear disjuncts to cover; the linear W disjuncts omitted.
53+
@test length(disjuncts) == 2
54+
binary_map = DP._indicator_to_binary(model)
55+
underlying = Set(DP._underlying_binary(dref) for dref in disjuncts)
56+
@test underlying == Set([DP._underlying_binary(binary_map[Y[1]]),
57+
DP._underlying_binary(binary_map[Y[2]])])
4858
end
4959

5060
function test_oa_cut_terms()
@@ -57,6 +67,17 @@ function test_oa_cut_terms()
5767
@test DP._oa_cut_terms(MOI.Interval(1.0, 4.0), 5.0) == (1.0, -4.0)
5868
end
5969

70+
function test_is_linear_F()
71+
# Scalar and vector variable-ref / affine function types are linear
72+
# (copied into the master at build time); anything else, e.g. a
73+
# quadratic, is nonlinear and enters only as an OA cut.
74+
@test DP._is_linear_F(JuMP.VariableRef)
75+
@test DP._is_linear_F(JuMP.AffExpr)
76+
@test DP._is_linear_F(Vector{JuMP.VariableRef})
77+
@test DP._is_linear_F(Vector{JuMP.AffExpr})
78+
@test !DP._is_linear_F(JuMP.QuadExpr)
79+
end
80+
6081
function test_no_good_cut()
6182
model = GDPModel()
6283
@variable(model, x)
@@ -71,7 +92,12 @@ function test_no_good_cut()
7192
master = DP._build_loa_master(problem, method)
7293
master_model = master.model
7394

74-
combo = DP._binary_combination(model, Dict(Y[1] => true, Y[2] => false))
95+
binary_map = DP._indicator_to_binary(model)
96+
combo = Dict(
97+
DP._underlying_binary(binary_map[Y[1]]) =>
98+
DP._underlying_value(binary_map[Y[1]], true),
99+
DP._underlying_binary(binary_map[Y[2]]) =>
100+
DP._underlying_value(binary_map[Y[2]], false))
75101

76102
num_cons_before = length(JuMP.all_constraints(
77103
master_model;
@@ -84,7 +110,6 @@ function test_no_good_cut()
84110
@test num_cons_after == num_cons_before + 1
85111
# The cut is (1 - y1) + y2 >= 1, i.e. normalized -y1 + y2 >= 0:
86112
# excludes exactly the (Y1 active, Y2 inactive) combination.
87-
binary_map = DP._indicator_to_binary(model)
88113
y1 = master.variable_map[binary_map[Y[1]]]
89114
y2 = master.variable_map[binary_map[Y[2]]]
90115
@test JuMP.normalized_coefficient(cref, y1) == -1.0
@@ -645,8 +670,9 @@ end
645670

646671
@testset "LOA" begin
647672
test_loa_datatype()
648-
test_set_covering_combos()
673+
test_cover_disjuncts()
649674
test_oa_cut_terms()
675+
test_is_linear_F()
650676
test_no_good_cut()
651677
test_loa_reformulate_simple()
652678
test_loa_solve_simple()

0 commit comments

Comments
 (0)