diff --git a/Project.toml b/Project.toml index a0abec5197..229ec62161 100644 --- a/Project.toml +++ b/Project.toml @@ -8,12 +8,12 @@ BenchmarkTools = "6e4b80f9-dd63-53aa-95a3-0cdb28fa8baf" LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" SparseArrays = "2f01184e-e22b-5df5-ae63-d93ebab69eaf" Unicode = "4ec0a83e-493e-50e2-b9ac-8f72acf5a8f5" +Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [compat] julia = "1" [extras] -Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [targets] -test = ["Test"] +test = [] diff --git a/docs/src/apireference.md b/docs/src/apireference.md index d46b9be883..2a592ea5f3 100644 --- a/docs/src/apireference.md +++ b/docs/src/apireference.md @@ -251,6 +251,7 @@ Semicontinuous Semiinteger SOS1 SOS2 +IndicatorSet ``` Functions for getting and setting properties of sets. diff --git a/src/Test/intlinear.jl b/src/Test/intlinear.jl index 89368d217b..26c7b2b311 100644 --- a/src/Test/intlinear.jl +++ b/src/Test/intlinear.jl @@ -356,9 +356,242 @@ function knapsacktest(model::MOI.ModelLike, config::TestConfig) end end +function indicator1_test(model::MOI.ModelLike, config::TestConfig) + atol = config.atol + rtol = config.rtol + # linear problem with indicator constraint + # max 2x1 + 3x2 + # s.t. x1 + x2 <= 10 + # z1 ==> x2 <= 8 + # z2 ==> x2 + x1/5 <= 9 + # z1 + z2 >= 1 + + MOI.empty!(model) + @test MOI.is_empty(model) + + @test MOI.supports(model, MOI.ObjectiveFunction{MOI.ScalarAffineFunction{Float64}}()) + @test MOI.supports(model, MOI.ObjectiveSense()) + @test MOI.supports_constraint(model, MOI.SingleVariable, MOI.ZeroOne) + @test MOI.supports_constraint(model, MOI.SingleVariable, MOI.Interval{Float64}) + @test MOI.supports_constraint(model, MOI.ScalarAffineFunction{Float64}, MOI.Interval{Float64}) + @test MOI.supports_constraint(model, MOI.VectorAffineFunction{Float64}, MOI.IndicatorSet{MOI.ACTIVATE_ON_ONE, MOI.LessThan{Float64}}) + x1 = MOI.add_variable(model) + x2 = MOI.add_variable(model) + z1 = MOI.add_variable(model) + z2 = MOI.add_variable(model) + MOI.add_constraint(model, z1, MOI.ZeroOne()) + MOI.add_constraint(model, z2, MOI.ZeroOne()) + f1 = MOI.VectorAffineFunction( + [MOI.VectorAffineTerm(1, MOI.ScalarAffineTerm(1.0, z1)), + MOI.VectorAffineTerm(2, MOI.ScalarAffineTerm(1.0, x2)), + ], + [0.0, 0.0] + ) + iset1 = MOI.IndicatorSet{MOI.ACTIVATE_ON_ONE}(MOI.LessThan(8.0)) + MOI.add_constraint(model, f1, iset1) + + f2 = MOI.VectorAffineFunction( + [MOI.VectorAffineTerm(1, MOI.ScalarAffineTerm(1.0, z2)), + MOI.VectorAffineTerm(2, MOI.ScalarAffineTerm(0.2, x1)), + MOI.VectorAffineTerm(2, MOI.ScalarAffineTerm(1.0, x2)), + ], + [0.0, 0.0], + ) + iset2 = MOI.IndicatorSet{MOI.ACTIVATE_ON_ONE}(MOI.LessThan(9.0)) + + MOI.add_constraint(model, f2, iset2) + + # Additional regular constraint. + MOI.add_constraint(model, + MOI.ScalarAffineFunction([MOI.ScalarAffineTerm(1.0, x1), MOI.ScalarAffineTerm(1.0, x2)], 0.0), + MOI.LessThan(10.0), + ) + + # Disjunction z1 ⋁ z2 + MOI.add_constraint(model, + MOI.ScalarAffineFunction([MOI.ScalarAffineTerm(1.0, z1), MOI.ScalarAffineTerm(1.0, z2)], 0.0), + MOI.GreaterThan(1.0), + ) + + MOI.set(model, MOI.ObjectiveFunction{MOI.ScalarAffineFunction{Float64}}(), + MOI.ScalarAffineFunction(MOI.ScalarAffineTerm.([2.0, 3.0], [x1, x2]), 0.0) + ) + MOI.set(model, MOI.ObjectiveSense(), MOI.MAX_SENSE) + + if config.solve + @test MOI.get(model, MOI.TerminationStatus()) == MOI.OPTIMIZE_NOT_CALLED + + MOI.optimize!(model) + + @test MOI.get(model, MOI.TerminationStatus()) == MOI.OPTIMAL + @test MOI.get(model, MOI.PrimalStatus()) == MOI.FEASIBLE_POINT + @test MOI.get(model, MOI.ObjectiveValue()) ≈ 28.75 atol=atol rtol=rtol + @test MOI.get(model, MOI.VariablePrimal(), x1) ≈ 1.25 atol=atol rtol=rtol + @test MOI.get(model, MOI.VariablePrimal(), x2) ≈ 8.75 atol=atol rtol=rtol + @test MOI.get(model, MOI.VariablePrimal(), z1) ≈ 0.0 atol=atol rtol=rtol + @test MOI.get(model, MOI.VariablePrimal(), z2) ≈ 1.0 atol=atol rtol=rtol + end +end + +function indicator2_test(model::MOI.ModelLike, config::TestConfig) + atol = config.atol + rtol = config.rtol + # linear problem with indicator constraint + # max 2x1 + 3x2 - 30 z2 + # s.t. x1 + x2 <= 10 + # z1 ==> x2 <= 8 + # z2 ==> x2 + x1/5 <= 9 + # z1 + z2 >= 1 + + MOI.empty!(model) + @test MOI.is_empty(model) + + # This is the same model as indicator_test1, except that the penalty on z2 forces z1 to be 1. + + x1 = MOI.add_variable(model) + x2 = MOI.add_variable(model) + z1 = MOI.add_variable(model) + z2 = MOI.add_variable(model) + MOI.add_constraint(model, z1, MOI.ZeroOne()) + MOI.add_constraint(model, z2, MOI.ZeroOne()) + f1 = MOI.VectorAffineFunction( + [MOI.VectorAffineTerm(1, MOI.ScalarAffineTerm(1.0, z1)), + MOI.VectorAffineTerm(2, MOI.ScalarAffineTerm(1.0, x2)), + ], + [0.0, 0.0] + ) + iset1 = MOI.IndicatorSet{MOI.ACTIVATE_ON_ONE}(MOI.LessThan(8.0)) + MOI.add_constraint(model, f1, iset1) + + f2 = MOI.VectorAffineFunction( + [MOI.VectorAffineTerm(1, MOI.ScalarAffineTerm(1.0, z2)), + MOI.VectorAffineTerm(2, MOI.ScalarAffineTerm(0.2, x1)), + MOI.VectorAffineTerm(2, MOI.ScalarAffineTerm(1.0, x2)), + ], + [0.0, 0.0], + ) + iset2 = MOI.IndicatorSet{MOI.ACTIVATE_ON_ONE}(MOI.LessThan(9.0)) + + MOI.add_constraint(model, f2, iset2) + + # additional regular constraint + MOI.add_constraint(model, + MOI.ScalarAffineFunction([MOI.ScalarAffineTerm(1.0, x1), MOI.ScalarAffineTerm(1.0, x2)], 0.0), + MOI.LessThan(10.0), + ) + + # disjunction z1 ⋁ z2 + MOI.add_constraint(model, + MOI.ScalarAffineFunction([MOI.ScalarAffineTerm(1.0, z1), MOI.ScalarAffineTerm(1.0, z2)], 0.0), + MOI.GreaterThan(1.0), + ) + + # objective penalized on z2 + MOI.set(model, MOI.ObjectiveFunction{MOI.ScalarAffineFunction{Float64}}(), + MOI.ScalarAffineFunction(MOI.ScalarAffineTerm.([2.0, 3.0, -30.0], [x1, x2, z2]), 0.0) + ) + MOI.set(model, MOI.ObjectiveSense(), MOI.MAX_SENSE) + + if config.solve + @test MOI.get(model, MOI.TerminationStatus()) == MOI.OPTIMIZE_NOT_CALLED + + MOI.optimize!(model) + + @test MOI.get(model, MOI.TerminationStatus()) == MOI.OPTIMAL + @test MOI.get(model, MOI.PrimalStatus()) == MOI.FEASIBLE_POINT + @test MOI.get(model, MOI.ObjectiveValue()) ≈ 28.0 atol=atol rtol=rtol + @test MOI.get(model, MOI.VariablePrimal(), x1) ≈ 2.0 atol=atol rtol=rtol + @test MOI.get(model, MOI.VariablePrimal(), x2) ≈ 8.0 atol=atol rtol=rtol + @test MOI.get(model, MOI.VariablePrimal(), z1) ≈ 1.0 atol=atol rtol=rtol + @test MOI.get(model, MOI.VariablePrimal(), z2) ≈ 0.0 atol=atol rtol=rtol + end +end + +function indicator3_test(model::MOI.ModelLike, config::TestConfig) + atol = config.atol + rtol = config.rtol + # linear problem with indicator constraint + # similar to indicator1_test with reversed z1 + # max 2x1 + 3x2 + # s.t. x1 + x2 <= 10 + # z1 == 0 ==> x2 <= 8 + # z2 == 1 ==> x2 + x1/5 <= 9 + # (1-z1) + z2 >= 1 <=> z2 - z1 >= 0 + + MOI.empty!(model) + @test MOI.is_empty(model) + + @test MOI.supports(model, MOI.ObjectiveFunction{MOI.ScalarAffineFunction{Float64}}()) + @test MOI.supports(model, MOI.ObjectiveSense()) + @test MOI.supports_constraint(model, MOI.SingleVariable, MOI.ZeroOne) + @test MOI.supports_constraint(model, MOI.SingleVariable, MOI.Interval{Float64}) + @test MOI.supports_constraint(model, MOI.ScalarAffineFunction{Float64}, MOI.Interval{Float64}) + @test MOI.supports_constraint(model, MOI.VectorAffineFunction{Float64}, MOI.IndicatorSet{MOI.ACTIVATE_ON_ONE, MOI.LessThan{Float64}}) + x1 = MOI.add_variable(model) + x2 = MOI.add_variable(model) + z1 = MOI.add_variable(model) + z2 = MOI.add_variable(model) + MOI.add_constraint(model, z1, MOI.ZeroOne()) + MOI.add_constraint(model, z2, MOI.ZeroOne()) + f1 = MOI.VectorAffineFunction( + [MOI.VectorAffineTerm(1, MOI.ScalarAffineTerm(1.0, z1)), + MOI.VectorAffineTerm(2, MOI.ScalarAffineTerm(1.0, x2)), + ], + [0.0, 0.0] + ) + iset1 = MOI.IndicatorSet{MOI.ACTIVATE_ON_ZERO}(MOI.LessThan(8.0)) + MOI.add_constraint(model, f1, iset1) + + f2 = MOI.VectorAffineFunction( + [MOI.VectorAffineTerm(1, MOI.ScalarAffineTerm(1.0, z2)), + MOI.VectorAffineTerm(2, MOI.ScalarAffineTerm(0.2, x1)), + MOI.VectorAffineTerm(2, MOI.ScalarAffineTerm(1.0, x2)), + ], + [0.0, 0.0], + ) + iset2 = MOI.IndicatorSet{MOI.ACTIVATE_ON_ONE}(MOI.LessThan(9.0)) + + MOI.add_constraint(model, f2, iset2) + + # Additional regular constraint. + MOI.add_constraint(model, + MOI.ScalarAffineFunction([MOI.ScalarAffineTerm(1.0, x1), MOI.ScalarAffineTerm(1.0, x2)], 0.0), + MOI.LessThan(10.0), + ) + + # Disjunction (1-z1) ⋁ z2 + MOI.add_constraint(model, + MOI.ScalarAffineFunction([MOI.ScalarAffineTerm(-1.0, z1), MOI.ScalarAffineTerm(1.0, z2)], 0.0), + MOI.GreaterThan(0.0), + ) + + MOI.set(model, MOI.ObjectiveFunction{MOI.ScalarAffineFunction{Float64}}(), + MOI.ScalarAffineFunction(MOI.ScalarAffineTerm.([2.0, 3.0], [x1, x2]), 0.0) + ) + MOI.set(model, MOI.ObjectiveSense(), MOI.MAX_SENSE) + + if config.solve + @test MOI.get(model, MOI.TerminationStatus()) == MOI.OPTIMIZE_NOT_CALLED + + MOI.optimize!(model) + + @test MOI.get(model, MOI.TerminationStatus()) == MOI.OPTIMAL + @test MOI.get(model, MOI.PrimalStatus()) == MOI.FEASIBLE_POINT + @test MOI.get(model, MOI.ObjectiveValue()) ≈ 28.75 atol=atol rtol=rtol + @test MOI.get(model, MOI.VariablePrimal(), x1) ≈ 1.25 atol=atol rtol=rtol + @test MOI.get(model, MOI.VariablePrimal(), x2) ≈ 8.75 atol=atol rtol=rtol + @test MOI.get(model, MOI.VariablePrimal(), z1) ≈ 1.0 atol=atol rtol=rtol + @test MOI.get(model, MOI.VariablePrimal(), z2) ≈ 1.0 atol=atol rtol=rtol + end +end + const intlineartests = Dict("knapsack" => knapsacktest, "int1" => int1test, "int2" => int2test, - "int3" => int3test) + "int3" => int3test, + "indicator1" => indicator1_test, + "indicator2" => indicator2_test, + "indicator3" => indicator3_test, + ) @moitestset intlinear diff --git a/src/sets.jl b/src/sets.jl index 9e12d83b97..4797e8ad69 100644 --- a/src/sets.jl +++ b/src/sets.jl @@ -435,6 +435,57 @@ Base.isapprox(a::T, b::T; kwargs...) where {T <: Union{SOS1, SOS2}} = isapprox(a dimension(s::Union{SOS1, SOS2}) = length(s.weights) +""" + ActivationCondition + +Activation condition for an indicator constraint. +The enum value is used as first type parameter of `IndicatorSet{A,S}`. +""" +@enum ActivationCondition begin + ACTIVATE_ON_ZERO + ACTIVATE_ON_ONE +end + +""" + IndicatorSet{A, S <: AbstractScalarSet}(set::S) + +``\\{((y, x) \\in \\{0, 1\\} \\times \\mathbb{R}^n : y = 0 \\implies x \\in set\\}`` +when `A` is `ACTIVATE_ON_ZERO` and +``\\{((y, x) \\in \\{0, 1\\} \\times \\mathbb{R}^n : y = 1 \\implies x \\in set\\}`` +when `A` is `ACTIVATE_ON_ONE`. + +`S` has to be a sub-type of `AbstractScalarSet`. +`A` is one of the value of the `ActivationCond` enum. +`IndicatorSet` is used with a `VectorAffineFunction` holding +the indicator variable first. + +Example: ``\\{(y, x) \\in \\{0, 1\\} \\times \\mathbb{R}^2 : y = 1 \\implies x_1 + x_2 \\leq 9 \\} `` + +```julia +f = MOI.VectorAffineFunction( + [MOI.VectorAffineTerm(1, MOI.ScalarAffineTerm(1.0, z)), + MOI.VectorAffineTerm(2, MOI.ScalarAffineTerm(0.2, x1)), + MOI.VectorAffineTerm(2, MOI.ScalarAffineTerm(1.0, x2)), + ], + [0.0, 0.0], +) + +indicator_set = MOI.IndicatorSet{MOI.ACTIVATE_ON_ONE}(MOI.LessThan(9.0)) + +MOI.add_constraint(model, f, indicator_set) +``` +""" +struct IndicatorSet{A, S <: AbstractScalarSet} <: AbstractVectorSet + set::S + IndicatorSet{A}(set::S) where {A, S <: AbstractScalarSet} = new{A,S}(set) +end + +dimension(::IndicatorSet) = 2 + +function Base.copy(set::IndicatorSet{A,S}) where {A,S} + return IndicatorSet{A}(copy(set.set)) +end + # isbits types, nothing to copy function Base.copy(set::Union{Reals, Zeros, Nonnegatives, Nonpositives, GreaterThan, LessThan, EqualTo, Interval, diff --git a/test/Test/intlinear.jl b/test/Test/intlinear.jl index 0bcbea02b7..000473bc06 100644 --- a/test/Test/intlinear.jl +++ b/test/Test/intlinear.jl @@ -28,3 +28,15 @@ MOIT.int3test(mock, config) MOIU.set_mock_optimize!(mock, (mock::MOIU.MockOptimizer) -> MOIU.mock_optimize!(mock, [1, 0, 0, 1, 1])) MOIT.knapsacktest(mock, config) +MOIU.set_mock_optimize!(mock, + (mock::MOIU.MockOptimizer) -> MOIU.mock_optimize!(mock, [1.25, 8.75, 0., 1.]) +) +MOIT.indicator1_test(mock, config) +MOIU.set_mock_optimize!(mock, + (mock::MOIU.MockOptimizer) -> MOIU.mock_optimize!(mock, [2.0, 8.0, 1., 0.]) +) +MOIT.indicator2_test(mock, config) +MOIU.set_mock_optimize!(mock, + (mock::MOIU.MockOptimizer) -> MOIU.mock_optimize!(mock, [1.25, 8.75, 1., 1.]) +) +MOIT.indicator3_test(mock, config) diff --git a/test/model.jl b/test/model.jl index 9dc02cfc17..1c1a5b6c77 100644 --- a/test/model.jl +++ b/test/model.jl @@ -1,5 +1,10 @@ + +const LessThanIndicatorSetOne{T} = MOI.IndicatorSet{MOI.ACTIVATE_ON_ONE, MOI.LessThan{T}} +const LessThanIndicatorSetZero{T} = MOI.IndicatorSet{MOI.ACTIVATE_ON_ZERO, MOI.LessThan{T}} + # Needed by test spread over several files, defining it here make it easier to comment out tests # Model supporting every MOI functions and sets + MOIU.@model(Model, (MOI.ZeroOne, MOI.Integer), (MOI.EqualTo, MOI.GreaterThan, MOI.LessThan, MOI.Interval, @@ -10,7 +15,7 @@ MOIU.@model(Model, MOI.PositiveSemidefiniteConeTriangle, MOI.PositiveSemidefiniteConeSquare, MOI.RootDetConeTriangle, MOI.RootDetConeSquare, MOI.LogDetConeTriangle, MOI.LogDetConeSquare), - (MOI.PowerCone, MOI.DualPowerCone, MOI.SOS1, MOI.SOS2), + (MOI.PowerCone, MOI.DualPowerCone, MOI.SOS1, MOI.SOS2, LessThanIndicatorSetOne, LessThanIndicatorSetZero), (MOI.SingleVariable,), (MOI.ScalarAffineFunction, MOI.ScalarQuadraticFunction), (MOI.VectorOfVariables,), diff --git a/test/sets.jl b/test/sets.jl index 6374184a9e..deb82310e8 100644 --- a/test/sets.jl +++ b/test/sets.jl @@ -1,3 +1,15 @@ +""" + MutLessThan{T<:Real} <: MOI.AbstractScalarSet + +A mutable `LessThan`-like set to test `copy` of indicator set +""" +mutable struct MutLessThan{T<:Real} <: MOI.AbstractScalarSet + upper::T + MutLessThan(v::T) where {T<:Real} = new{T}(v) +end + +Base.copy(mlt::MutLessThan) = MutLessThan(Base.copy(mlt.upper)) + @testset "Sets" begin @testset "Copy" begin @testset "for $S" for S in [MOI.SOS1, MOI.SOS2] @@ -6,5 +18,21 @@ s_copy.weights[1] = 2.0 @test s.weights[1] == 1.0 end + @testset "IndicatorSet" begin + s1 = MOI.IndicatorSet{MOI.ACTIVATE_ON_ONE}(MOI.LessThan(4.0)) + s2 = MOI.IndicatorSet{MOI.ACTIVATE_ON_ZERO}(MOI.GreaterThan(4.0)) + s1_copy = copy(s1) + s2_copy = copy(s2) + @test s1_copy isa MOI.IndicatorSet{MOI.ACTIVATE_ON_ONE} + @test s1 == s1_copy + @test s2_copy isa MOI.IndicatorSet{MOI.ACTIVATE_ON_ZERO} + @test s2 == s2_copy + s3 = MOI.IndicatorSet{MOI.ACTIVATE_ON_ZERO}(MutLessThan(4.0)) + s3_copy = copy(s3) + @test s3.set.upper ≈ 4.0 + s3_copy.set.upper = 5.0 + @test s3.set.upper ≈ 4.0 + @test s3_copy.set.upper ≈ 5.0 + end end end