diff --git a/src/Bridges/SplitIntervalBridge.jl b/src/Bridges/SplitIntervalBridge.jl index 150eb13..32c8459 100644 --- a/src/Bridges/SplitIntervalBridge.jl +++ b/src/Bridges/SplitIntervalBridge.jl @@ -19,6 +19,22 @@ The input function can be any `MOI.AbstractVectorFunction` (the first component `x` may be affine or quadratic); only the second component `y` must be a variable. +Since the lower bound of `y` is strictly smaller than its upper bound, +`xp` and `xn` cannot both be nonzero at a feasible point, so +`xp = max(x, 0)` and `xn = min(x, 0)`. In +[`MOI.Bridges.final_touch`](@ref), if the activity `x` has finite lower +and upper bounds `[lb, ub]`, the bounds `xp <= max(ub, 0)` and +`xn >= min(lb, 0)` are added. This is done in +[`MOI.Bridges.final_touch`](@ref) and not in +`MOI.Bridges.Constraint.bridge_constraint` because the bounds of `x` may +be set after the constraint is bridged. These bounds are needed by +[`MOI.Bridges.Constraint.SOS1ToMILPBridge`](@ref) in case the inner solver +does not support [`MOI.SOS1`](@ref) constraints. Since that bridge +requires both bounds anyway (`xp` and `xn` each appear in an +[`MOI.SOS1`](@ref) constraint), we use the same +`MOI.Utilities.get_bounds` method and add nothing when either bound is +infinite. + ## Source node `SplitIntervalBridge` supports: @@ -35,11 +51,15 @@ must be a variable. * [`MOI.VectorOfVariables`](@ref) in [`ComplementsWithSetType{MOI.LessThan{T}}`](@ref) * `G` in [`MOI.EqualTo{T}`](@ref) (the splitting equality) + * [`MOI.VariableIndex`](@ref) in [`MOI.LessThan{T}`](@ref) and + [`MOI.VariableIndex`](@ref) in [`MOI.GreaterThan{T}`](@ref) + (the bounds on `xp` and `xn`, added in + [`MOI.Bridges.final_touch`](@ref) if the activity is bounded) where `G` is the scalar function type of the first component. """ -struct SplitIntervalBridge{ +mutable struct SplitIntervalBridge{ T, G<:MOI.AbstractScalarFunction, F<:MOI.AbstractVectorFunction, @@ -57,6 +77,14 @@ struct SplitIntervalBridge{ xn::MOI.VariableIndex func::F set::ComplementsWithSetType{MOI.Interval{T}} + xp_upper::Union{ + Nothing, + MOI.ConstraintIndex{MOI.VariableIndex,MOI.LessThan{T}}, + } + xn_lower::Union{ + Nothing, + MOI.ConstraintIndex{MOI.VariableIndex,MOI.GreaterThan{T}}, + } end function MOI.Bridges.Constraint.bridge_constraint( @@ -73,11 +101,9 @@ function MOI.Bridges.Constraint.bridge_constraint( y_var = if y isa MOI.VariableIndex y else - # Extract the variable from a ScalarAffineFunction wrapping a single variable - @assert length(y.terms) == 1 && - isone(y.terms[1].coefficient) && - iszero(y.constant) - y.terms[1].variable + # Extract the variable from a function wrapping a single variable + @assert _is_single_variable(y) + _get_variable(y) end # Create xp >= 0 and xn <= 0 xp, _ = MOI.add_constrained_variable(model, MOI.GreaterThan(zero(T))) @@ -110,6 +136,8 @@ function MOI.Bridges.Constraint.bridge_constraint( xn, func, set, + nothing, + nothing, ) end @@ -151,6 +179,46 @@ function MOI.set( return end +MOI.Bridges.needs_final_touch(::SplitIntervalBridge) = true + +function MOI.Bridges.final_touch( + bridge::SplitIntervalBridge{T}, + model::MOI.ModelLike, +) where {T} + x = first(MOI.Utilities.eachscalar(bridge.func)) + ret = if x isa MOI.VariableIndex || x isa MOI.ScalarAffineFunction{T} + cache = Dict{MOI.VariableIndex,NTuple{2,T}}() + MOI.Utilities.get_bounds(model, cache, x) + else + nothing + end + if ret === nothing + if bridge.xp_upper !== nothing + MOI.delete(model, bridge.xp_upper) + bridge.xp_upper = nothing + end + if bridge.xn_lower !== nothing + MOI.delete(model, bridge.xn_lower) + bridge.xn_lower = nothing + end + return + end + lb, ub = ret + upper = MOI.LessThan(max(ub, zero(T))) + if bridge.xp_upper === nothing + bridge.xp_upper = MOI.add_constraint(model, bridge.xp, upper) + elseif MOI.get(model, MOI.ConstraintSet(), bridge.xp_upper) != upper + MOI.set(model, MOI.ConstraintSet(), bridge.xp_upper, upper) + end + lower = MOI.GreaterThan(min(lb, zero(T))) + if bridge.xn_lower === nothing + bridge.xn_lower = MOI.add_constraint(model, bridge.xn, lower) + elseif MOI.get(model, MOI.ConstraintSet(), bridge.xn_lower) != lower + MOI.set(model, MOI.ConstraintSet(), bridge.xn_lower, lower) + end + return +end + # Bridge metadata function MOI.Bridges.added_constrained_variable_types( @@ -166,6 +234,8 @@ function MOI.Bridges.added_constraint_types( (MOI.VectorOfVariables, ComplementsWithSetType{MOI.GreaterThan{T}}), (MOI.VectorOfVariables, ComplementsWithSetType{MOI.LessThan{T}}), (G, MOI.EqualTo{T}), + (MOI.VariableIndex, MOI.GreaterThan{T}), + (MOI.VariableIndex, MOI.LessThan{T}), ] end @@ -181,37 +251,45 @@ end # constraints that must be reported as part of the bridge. function MOI.get( - ::SplitIntervalBridge{T}, + bridge::SplitIntervalBridge{T}, ::MOI.NumberOfConstraints{MOI.VariableIndex,MOI.GreaterThan{T}}, )::Int64 where {T} - return 1 + return 1 + (bridge.xn_lower !== nothing) end function MOI.get( bridge::SplitIntervalBridge{T}, ::MOI.ListOfConstraintIndices{MOI.VariableIndex,MOI.GreaterThan{T}}, ) where {T} - return [ + ret = [ MOI.ConstraintIndex{MOI.VariableIndex,MOI.GreaterThan{T}}( bridge.xp.value, ), ] + if bridge.xn_lower !== nothing + push!(ret, bridge.xn_lower) + end + return ret end function MOI.get( - ::SplitIntervalBridge{T}, + bridge::SplitIntervalBridge{T}, ::MOI.NumberOfConstraints{MOI.VariableIndex,MOI.LessThan{T}}, )::Int64 where {T} - return 1 + return 1 + (bridge.xp_upper !== nothing) end function MOI.get( bridge::SplitIntervalBridge{T}, ::MOI.ListOfConstraintIndices{MOI.VariableIndex,MOI.LessThan{T}}, ) where {T} - return [ + ret = [ MOI.ConstraintIndex{MOI.VariableIndex,MOI.LessThan{T}}(bridge.xn.value), ] + if bridge.xp_upper !== nothing + push!(ret, bridge.xp_upper) + end + return ret end function MOI.get( diff --git a/test/Bridges/test_SplitIntervalBridge.jl b/test/Bridges/test_SplitIntervalBridge.jl index 73a4b38..2480051 100644 --- a/test/Bridges/test_SplitIntervalBridge.jl +++ b/test/Bridges/test_SplitIntervalBridge.jl @@ -64,6 +64,228 @@ function test_VectorAffineFunction_input() return end +function test_bounded_activity() + MOI.Bridges.runtests( + MathOptComplements.Bridges.SplitIntervalBridge, + """ + variables: x, y + x in Interval(-2.0, 4.0) + y in Interval(0.0, 1.0) + [x, y] in $M.ComplementsWithSetType{MOI.Interval{Float64}}(2) + """, + """ + variables: x, y, xp, xn + x in Interval(-2.0, 4.0) + y in Interval(0.0, 1.0) + xp >= 0.0 + xp <= 4.0 + xn <= 0.0 + xn >= -2.0 + 1.0 * x + -1.0 * xp + -1.0 * xn == 0.0 + [xp, y] in $M.ComplementsWithSetType{MOI.GreaterThan{Float64}}(2) + [xn, y] in $M.ComplementsWithSetType{MOI.LessThan{Float64}}(2) + """; + cannot_unbridge = true, + ) + return +end + +# The bounds on `xp` and `xn` are only needed by `SOS1ToMILPBridge`, which +# requires both bounds of the activity anyway, so no bound is added when +# the activity is bounded on only one side. +function test_upper_bounded_activity() + MOI.Bridges.runtests( + MathOptComplements.Bridges.SplitIntervalBridge, + """ + variables: x, y + x <= 4.0 + y in Interval(0.0, 1.0) + [x, y] in $M.ComplementsWithSetType{MOI.Interval{Float64}}(2) + """, + """ + variables: x, y, xp, xn + x <= 4.0 + y in Interval(0.0, 1.0) + xp >= 0.0 + xn <= 0.0 + 1.0 * x + -1.0 * xp + -1.0 * xn == 0.0 + [xp, y] in $M.ComplementsWithSetType{MOI.GreaterThan{Float64}}(2) + [xn, y] in $M.ComplementsWithSetType{MOI.LessThan{Float64}}(2) + """; + cannot_unbridge = true, + ) + return +end + +function test_lower_bounded_activity() + MOI.Bridges.runtests( + MathOptComplements.Bridges.SplitIntervalBridge, + """ + variables: x, y + x >= -2.0 + y in Interval(0.0, 1.0) + [x, y] in $M.ComplementsWithSetType{MOI.Interval{Float64}}(2) + """, + """ + variables: x, y, xp, xn + x >= -2.0 + y in Interval(0.0, 1.0) + xp >= 0.0 + xn <= 0.0 + 1.0 * x + -1.0 * xp + -1.0 * xn == 0.0 + [xp, y] in $M.ComplementsWithSetType{MOI.GreaterThan{Float64}}(2) + [xn, y] in $M.ComplementsWithSetType{MOI.LessThan{Float64}}(2) + """; + cannot_unbridge = true, + ) + return +end + +function test_positive_activity() + # With `x ∈ [1, 4]`, the negative part `xn = min(x, 0)` is fixed to zero. + MOI.Bridges.runtests( + MathOptComplements.Bridges.SplitIntervalBridge, + """ + variables: x, y + x in Interval(1.0, 4.0) + y in Interval(0.0, 1.0) + [x, y] in $M.ComplementsWithSetType{MOI.Interval{Float64}}(2) + """, + """ + variables: x, y, xp, xn + x in Interval(1.0, 4.0) + y in Interval(0.0, 1.0) + xp >= 0.0 + xp <= 4.0 + xn <= 0.0 + xn >= 0.0 + 1.0 * x + -1.0 * xp + -1.0 * xn == 0.0 + [xp, y] in $M.ComplementsWithSetType{MOI.GreaterThan{Float64}}(2) + [xn, y] in $M.ComplementsWithSetType{MOI.LessThan{Float64}}(2) + """; + cannot_unbridge = true, + ) + return +end + +function test_bounded_affine_activity() + # The activity `2x + 1` with `x ∈ [-2, 4]` lies in `[-3, 9]`. + MOI.Bridges.runtests( + MathOptComplements.Bridges.SplitIntervalBridge, + """ + variables: x, y + x in Interval(-2.0, 4.0) + y in Interval(0.0, 1.0) + [2.0 * x + 1.0, y] in $M.ComplementsWithSetType{MOI.Interval{Float64}}(2) + """, + """ + variables: x, y, xp, xn + x in Interval(-2.0, 4.0) + y in Interval(0.0, 1.0) + xp >= 0.0 + xp <= 9.0 + xn <= 0.0 + xn >= -3.0 + 2.0 * x + -1.0 * xp + -1.0 * xn == -1.0 + [xp, y] in $M.ComplementsWithSetType{MOI.GreaterThan{Float64}}(2) + [xn, y] in $M.ComplementsWithSetType{MOI.LessThan{Float64}}(2) + """; + cannot_unbridge = true, + ) + return +end + +function test_quadratic_activity() + # Bounds are only computed for variable and affine activities, so no + # bound is added on `xp` nor `xn` even though `x` is bounded. + MOI.Bridges.runtests( + MathOptComplements.Bridges.SplitIntervalBridge, + """ + variables: x, y + x in Interval(-2.0, 4.0) + y in Interval(0.0, 1.0) + [1.0 * x * x, y] in $M.ComplementsWithSetType{MOI.Interval{Float64}}(2) + """, + """ + variables: x, y, xp, xn + x in Interval(-2.0, 4.0) + y in Interval(0.0, 1.0) + xp >= 0.0 + xn <= 0.0 + 1.0 * x * x + -1.0 * xp + -1.0 * xn == 0.0 + [xp, y] in $M.ComplementsWithSetType{MOI.GreaterThan{Float64}}(2) + [xn, y] in $M.ComplementsWithSetType{MOI.LessThan{Float64}}(2) + """; + cannot_unbridge = true, + ) + return +end + +function test_final_touch_bound_modification() + T = Float64 + inner = MOI.Utilities.UniversalFallback(MOI.Utilities.Model{T}()) + model = MOI.Bridges.Constraint.SingleBridgeOptimizer{ + MathOptComplements.Bridges.SplitIntervalBridge{T}, + }( + inner, + ) + x = MOI.add_variable(model) + y, _ = MOI.add_constrained_variable(model, MOI.Interval(zero(T), one(T))) + MOI.add_constraint( + model, + MOI.VectorOfVariables([x, y]), + MathOptComplements.ComplementsWithSetType{MOI.Interval{T}}(2), + ) + F = MOI.VariableIndex + upper_sets(m) = [ + MOI.get(m, MOI.ConstraintSet(), ci) for + ci in MOI.get(m, MOI.ListOfConstraintIndices{F,MOI.LessThan{T}}()) + ] + lower_sets(m) = [ + MOI.get(m, MOI.ConstraintSet(), ci) for ci in + MOI.get(m, MOI.ListOfConstraintIndices{F,MOI.GreaterThan{T}}()) + ] + # `x` only has a lower bound so no bound is added on `xp` nor `xn`: + # the only `LessThan` is `xn <= 0` and the `GreaterThan` are `x >= -1` + # and `xp >= 0` + c_lb = MOI.add_constraint(model, x, MOI.GreaterThan(T(-1))) + MOI.Bridges.final_touch(model) + @test upper_sets(inner) == [MOI.LessThan(zero(T))] + @test sort(lower_sets(inner); by = s -> s.lower) == + [MOI.GreaterThan(T(-1)), MOI.GreaterThan(zero(T))] + # Adding `x <= 4` adds `xp <= 4` and `xn >= -1` at the next final_touch + c_ub = MOI.add_constraint(model, x, MOI.LessThan(T(4))) + MOI.Bridges.final_touch(model) + @test sort(upper_sets(inner); by = s -> s.upper) == + [MOI.LessThan(zero(T)), MOI.LessThan(T(4)), MOI.LessThan(T(4))] + @test sort(lower_sets(inner); by = s -> s.lower) == [ + MOI.GreaterThan(T(-1)), + MOI.GreaterThan(T(-1)), + MOI.GreaterThan(zero(T)), + ] + # Tightening to `x <= 2` updates the bound of `xp` + MOI.set(model, MOI.ConstraintSet(), c_ub, MOI.LessThan(T(2))) + MOI.Bridges.final_touch(model) + @test sort(upper_sets(inner); by = s -> s.upper) == + [MOI.LessThan(zero(T)), MOI.LessThan(T(2)), MOI.LessThan(T(2))] + # Tightening to `x >= -1/2` updates the bound of `xn` + MOI.set(model, MOI.ConstraintSet(), c_lb, MOI.GreaterThan(T(-1 // 2))) + MOI.Bridges.final_touch(model) + @test sort(lower_sets(inner); by = s -> s.lower) == [ + MOI.GreaterThan(T(-1 // 2)), + MOI.GreaterThan(T(-1 // 2)), + MOI.GreaterThan(zero(T)), + ] + # Removing the upper bound of `x` removes the bounds of both `xp` + # and `xn` + MOI.delete(model, c_ub) + MOI.Bridges.final_touch(model) + @test upper_sets(inner) == [MOI.LessThan(zero(T))] + @test sort(lower_sets(inner); by = s -> s.lower) == + [MOI.GreaterThan(T(-1 // 2)), MOI.GreaterThan(zero(T))] + return +end + end # TestSplitIntervalBridge TestSplitIntervalBridge.runtests()