Skip to content

Commit 70cda3d

Browse files
committed
Add LOA coverage tests
1 parent 441a806 commit 70cda3d

2 files changed

Lines changed: 193 additions & 14 deletions

File tree

test/constraints/loa.jl

Lines changed: 153 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -39,14 +39,24 @@ function test_set_covering_combos()
3939

4040
combos = DP._set_covering_combinations(model)
4141

42-
# Should cover both Y[1] and Y[2]
43-
all_active = Set()
44-
for combo in combos
45-
for (ind, active) in combo
46-
active && push!(all_active, ind)
47-
end
48-
end
49-
@test length(all_active) == 2
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]]
48+
end
49+
50+
function test_oa_cut_terms()
51+
# The `<= 0` cut directions per set, at a scalar linearization value
52+
# of 5: LessThan/GreaterThan give one signed direction, EqualTo and
53+
# Interval give both, unknown sets fall back to RHS 0.
54+
@test DP._oa_cut_terms(MOI.LessThan(2.0), 5.0) == (3.0,)
55+
@test DP._oa_cut_terms(MOI.GreaterThan(2.0), 5.0) == (-3.0,)
56+
@test DP._oa_cut_terms(MOI.EqualTo(2.0), 5.0) == (3.0, -3.0)
57+
@test DP._oa_cut_terms(MOI.Interval(1.0, 4.0), 5.0) == (1.0, -4.0)
58+
@test DP._set_rhs(MOI.ZeroOne()) == 0.0
59+
@test DP._oa_cut_terms(MOI.ZeroOne(), 5.0) == (5.0,)
5060
end
5161

5262
function test_no_good_cut()
@@ -68,16 +78,25 @@ function test_no_good_cut()
6878
num_cons_before = length(JuMP.all_constraints(
6979
master_model;
7080
include_variable_in_set_constraints = false))
71-
DP.avoid_combination(master.model, combo, master.variable_map)
81+
cref = DP.avoid_combination(master.model, combo, master.variable_map)
7282
num_cons_after = length(JuMP.all_constraints(
7383
master_model;
7484
include_variable_in_set_constraints = false))
7585

7686
@test num_cons_after == num_cons_before + 1
87+
# The cut is (1 - y1) + y2 >= 1, i.e. normalized -y1 + y2 >= 0:
88+
# excludes exactly the (Y1 active, Y2 inactive) combination.
89+
binary_map = DP._indicator_to_binary(model)
90+
y1 = master.variable_map[binary_map[Y[1]]]
91+
y2 = master.variable_map[binary_map[Y[2]]]
92+
@test JuMP.normalized_coefficient(cref, y1) == -1.0
93+
@test JuMP.normalized_coefficient(cref, y2) == 1.0
94+
@test JuMP.normalized_rhs(cref) == 0.0
7795
end
7896

7997
function test_loa_reformulate_simple()
8098
model = GDPModel(HiGHS.Optimizer)
99+
set_silent(model)
81100
@variable(model, 0 <= x <= 10)
82101
@variable(model, Y[1:2], Logical)
83102
@constraint(model, x <= 3, Disjunct(Y[1]))
@@ -89,6 +108,10 @@ function test_loa_reformulate_simple()
89108
DP.reformulate_model(model, method)
90109

91110
@test DP._ready_to_optimize(model)
111+
# The committed model solves to the LOA incumbent: x = 7 via Y[2].
112+
JuMP.optimize!(model, ignore_optimize_hook = true)
113+
@test objective_value(model) 7.0 atol = 1e-6
114+
@test value(x) 7.0 atol = 1e-6
92115
end
93116

94117
function test_loa_solve_simple()
@@ -181,6 +204,121 @@ function test_loa_nonlinear_global()
181204
@test objective_value(model) 5.0 atol = 1e-3
182205
end
183206

207+
function test_loa_nonlinear_equality_global()
208+
# max x s.t. x^2 == 25 (global nonlinear equality), (x <= 3) ∨
209+
# (x <= 8), 0 <= x <= 10. The Y[1] seed is NLP-infeasible (x <= 3
210+
# contradicts x = 5), so NLPF slacks the inequality while keeping
211+
# the equality exact. The equality emits BOTH cut directions into
212+
# the master. Optimum: x = 5 via Y[2].
213+
ipopt = optimizer_with_attributes(Ipopt.Optimizer,
214+
"print_level" => 0, "sb" => "yes")
215+
model = GDPModel(ipopt)
216+
set_silent(model)
217+
@variable(model, 0 <= x <= 10)
218+
@constraint(model, x^2 == 25)
219+
@variable(model, Y[1:2], Logical)
220+
@constraint(model, x <= 3, Disjunct(Y[1]))
221+
@constraint(model, x <= 8, Disjunct(Y[2]))
222+
@disjunction(model, Y)
223+
@objective(model, Max, x)
224+
optimize!(model,
225+
gdp_method = LOA(ipopt; mip_optimizer = HiGHS.Optimizer))
226+
@test termination_status(model) in (MOI.OPTIMAL, MOI.LOCALLY_SOLVED)
227+
@test objective_value(model) 5.0 atol = 1e-3
228+
@test value(x) 5.0 atol = 1e-3
229+
@test value(Y[2]) 1.0 atol = 1e-6
230+
end
231+
232+
function test_loa_nonlinear_equality_disjunct()
233+
# Nonlinear equality inside a disjunct: Y1: x <= 3, Y2: x^2 == 64.
234+
# The disjunct cut emits both gated directions. Optimum: x = 8.
235+
ipopt = optimizer_with_attributes(Ipopt.Optimizer,
236+
"print_level" => 0, "sb" => "yes")
237+
juniper = optimizer_with_attributes(Juniper.Optimizer,
238+
"nl_solver" => ipopt, "log_levels" => [])
239+
model = GDPModel(juniper)
240+
set_silent(model)
241+
@variable(model, 0 <= x <= 10)
242+
@variable(model, Y[1:2], Logical)
243+
@constraint(model, x <= 3, Disjunct(Y[1]))
244+
@constraint(model, x^2 == 64, Disjunct(Y[2]))
245+
@disjunction(model, Y)
246+
@objective(model, Max, x)
247+
optimize!(model,
248+
gdp_method = LOA(juniper; mip_optimizer = HiGHS.Optimizer))
249+
@test termination_status(model) in (MOI.OPTIMAL, MOI.LOCALLY_SOLVED)
250+
@test objective_value(model) 8.0 atol = 1e-3
251+
@test value(Y[2]) 1.0 atol = 1e-6
252+
end
253+
254+
function test_loa_nonlinear_interval_disjunct()
255+
# Nonlinear Interval constraint inside a disjunct: Y1: x <= 3,
256+
# Y2: 36 <= x^2 <= 64. The disjunct cut emits both gated
257+
# directions. Optimum: x = 8 via Y2.
258+
ipopt = optimizer_with_attributes(Ipopt.Optimizer,
259+
"print_level" => 0, "sb" => "yes")
260+
juniper = optimizer_with_attributes(Juniper.Optimizer,
261+
"nl_solver" => ipopt, "log_levels" => [])
262+
model = GDPModel(juniper)
263+
set_silent(model)
264+
@variable(model, 0 <= x <= 10)
265+
@variable(model, Y[1:2], Logical)
266+
@constraint(model, x <= 3, Disjunct(Y[1]))
267+
@constraint(model, 36 <= x^2 <= 64, Disjunct(Y[2]))
268+
@disjunction(model, Y)
269+
@objective(model, Max, x)
270+
optimize!(model,
271+
gdp_method = LOA(juniper; mip_optimizer = HiGHS.Optimizer))
272+
@test termination_status(model) in (MOI.OPTIMAL, MOI.LOCALLY_SOLVED)
273+
@test objective_value(model) 8.0 atol = 1e-3
274+
@test value(Y[2]) 1.0 atol = 1e-6
275+
end
276+
277+
function test_loa_restores_prior_time_limit()
278+
# A time limit set by the user before LOA must survive the loop's
279+
# per-solve caps when only `iteration_time_limit` is finite (the
280+
# `_restore_time_limit(::Real)` path).
281+
model = GDPModel(HiGHS.Optimizer)
282+
set_silent(model)
283+
@variable(model, 0 <= x <= 10)
284+
@variable(model, Y[1:2], Logical)
285+
@constraint(model, x <= 3, Disjunct(Y[1]))
286+
@constraint(model, x <= 7, Disjunct(Y[2]))
287+
@disjunction(model, Y)
288+
@objective(model, Max, x)
289+
set_time_limit_sec(model, 90.0)
290+
optimize!(model, gdp_method = LOA(HiGHS.Optimizer;
291+
iteration_time_limit = 60.0, time_limit = Inf))
292+
@test time_limit_sec(model) == 90.0
293+
@test termination_status(model) == MOI.OPTIMAL
294+
@test objective_value(model) 7.0 atol = 1e-4
295+
end
296+
297+
function test_loa_limit_hit_report()
298+
# Stop the main loop on `max_iter = 1` after the master has produced
299+
# a bound: the report must label the run "limit hit" (not converged),
300+
# and the single loop iteration still finds the off-diagonal optimum.
301+
model = GDPModel(HiGHS.Optimizer)
302+
set_silent(model)
303+
@variable(model, 0 <= x <= 10)
304+
@variable(model, 0 <= z <= 10)
305+
@variable(model, Y[1:2], Logical)
306+
@variable(model, W[1:2], Logical)
307+
@constraint(model, x <= 4, Disjunct(Y[1]))
308+
@constraint(model, x >= 6, Disjunct(Y[2]))
309+
@disjunction(model, Y)
310+
@constraint(model, z <= 4, Disjunct(W[1]))
311+
@constraint(model, z >= 6, Disjunct(W[2]))
312+
@disjunction(model, W)
313+
@objective(model, Min, x - z)
314+
method = LOA(HiGHS.Optimizer; max_iter = 1)
315+
@test_logs (:info, r"limit hit") match_mode = :any begin
316+
DP.reformulate_model(model, method)
317+
end
318+
JuMP.optimize!(model, ignore_optimize_hook = true)
319+
@test objective_value(model) -10.0 atol = 1e-4
320+
end
321+
184322
function test_loa_complement_indicator_nonlinear_disjunct()
185323
# Regression: complement-form indicators carry `1 - y_base` (an
186324
# AffExpr) as their binary reference. When the complement disjunct has a
@@ -510,13 +648,19 @@ end
510648
@testset "LOA" begin
511649
test_loa_datatype()
512650
test_set_covering_combos()
651+
test_oa_cut_terms()
513652
test_no_good_cut()
514653
test_loa_reformulate_simple()
515654
test_loa_solve_simple()
516655
test_loa_solve_simple_with_mbm()
517656
test_loa_solve_two_disjunctions()
518657
test_loa_error_fallback()
519658
test_loa_nonlinear_global()
659+
test_loa_nonlinear_equality_global()
660+
test_loa_nonlinear_equality_disjunct()
661+
test_loa_nonlinear_interval_disjunct()
662+
test_loa_restores_prior_time_limit()
663+
test_loa_limit_hit_report()
520664
test_loa_complement_indicator_nonlinear_disjunct()
521665
test_loa_nlpf_infeasible_disjunct()
522666
test_loa_sense_primitives()

test/extensions/InfiniteDisjunctiveProgramming.jl

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -852,13 +852,13 @@ function test_CuttingPlanes_multiparameter()
852852

853853
@objective(model, Min, ((x, t), s))
854854

855-
# Should not throw
856-
@test optimize!(model,
857-
gdp_method = CuttingPlanes(
858-
HiGHS.Optimizer; max_iter = 5)
859-
) isa Nothing
855+
optimize!(model,
856+
gdp_method = CuttingPlanes(HiGHS.Optimizer; max_iter = 5))
860857
@test termination_status(model) in
861858
[MOI.OPTIMAL, MOI.LOCALLY_SOLVED]
859+
# Y[2] (x <= 3) is the minimizer: x = 0 across (t, s) → ∫∫x = 0.
860+
@test objective_value(model) 0.0 atol = 1e-4
861+
@test all(isapprox.(value(x), 0.0; atol = 1e-6))
862862
end
863863

864864
function test_loa_infinite_nonlinear_global()
@@ -887,6 +887,8 @@ function test_loa_infinite_nonlinear_global()
887887
@test termination_status(model) in
888888
(MOI.OPTIMAL, MOI.LOCALLY_SOLVED)
889889
@test objective_value(model) 5.0 atol = 1e-2
890+
@test all(value(Y[2]))
891+
@test all(isapprox.(value(x), 5.0; atol = 1e-2))
890892
end
891893

892894
function test_loa_infinite_complement_indicator()
@@ -1036,6 +1038,32 @@ function test_loa_infinite_aggregate_global()
10361038
@test objective_value(model) 1.0 atol = 1e-2
10371039
end
10381040

1041+
function test_loa_infinite_aggregate_disjunct()
1042+
# An aggregate (measure) constraint INSIDE a disjunct: it
1043+
# transcribes to a single scalar row that must be gated by the
1044+
# indicator's binary. max ∫x dt with Y[1]: ∫(x^2, t) <= 4 and
1045+
# Y[2]: x <= 0. Y[1] is optimal: constant x = 2 gives ∫x^2 = 4
1046+
# and ∫x dt = 2 (Y[2] caps the objective at 0). Big-M tightening
1047+
# is disabled: it needs bounds of every constraint variable and a
1048+
# `MeasureRef` has none.
1049+
ipopt = optimizer_with_attributes(Ipopt.Optimizer,
1050+
"print_level" => 0, "sb" => "yes")
1051+
model = InfiniteGDPModel(ipopt)
1052+
set_silent(model)
1053+
@infinite_parameter(model, t [0, 1], num_supports = 10)
1054+
@variable(model, 0 <= x <= 10, Infinite(t))
1055+
@variable(model, Y[1:2], Logical)
1056+
@constraint(model, (x^2, t) <= 4, Disjunct(Y[1]))
1057+
@constraint(model, x <= 0, Disjunct(Y[2]))
1058+
@disjunction(model, Y)
1059+
@objective(model, Max, (x, t))
1060+
optimize!(model, gdp_method = LOA(ipopt;
1061+
mip_optimizer = HiGHS.Optimizer, inner_method = BigM(1e4, false)))
1062+
@test termination_status(model) in (MOI.OPTIMAL, MOI.LOCALLY_SOLVED)
1063+
@test objective_value(model) 2.0 atol = 1e-2
1064+
@test value(Y[1])
1065+
end
1066+
10391067
function test_loa_infinite_iteration_loop()
10401068
# Force the InfiniteModel LOA main loop to fix combinations that
10411069
# vary per support. Two disjunctions over disjoint x/z half-lines
@@ -1064,6 +1092,10 @@ function test_loa_infinite_iteration_loop()
10641092
@test termination_status(model) in
10651093
(MOI.OPTIMAL, MOI.LOCALLY_SOLVED)
10661094
@test objective_value(model) -10.0 atol = 1e-3
1095+
@test all(value(Y[1]))
1096+
@test all(value(W[2]))
1097+
@test all(isapprox.(value(x), 0.0; atol = 1e-6))
1098+
@test all(isapprox.(value(z), 10.0; atol = 1e-6))
10671099
end
10681100

10691101
function test_loa_infinite_hull_linear()
@@ -1109,6 +1141,8 @@ function test_loa_infinite_hull_nonlinear_disjunct()
11091141
mip_optimizer = HiGHS.Optimizer, inner_method = Hull()))
11101142
@test termination_status(model) in (MOI.OPTIMAL, MOI.LOCALLY_SOLVED)
11111143
@test objective_value(model) 8.0 atol = 1e-2
1144+
@test all(value(Y[2]))
1145+
@test all(isapprox.(value(x), 8.0; atol = 1e-2))
11121146
end
11131147

11141148
function test_loa_infinite_hull_complement_nonlinear()
@@ -1237,6 +1271,7 @@ end
12371271
@testset "LOA" begin
12381272
test_loa_infinite_nonlinear_global()
12391273
test_loa_infinite_aggregate_global()
1274+
test_loa_infinite_aggregate_disjunct()
12401275
test_loa_infinite_complement_indicator()
12411276
test_loa_infinite_complement_nonlinear_disjunct()
12421277
test_loa_infinite_multidim_parameter()

0 commit comments

Comments
 (0)