diff --git a/src/FileFormats/LP/LP.jl b/src/FileFormats/LP/LP.jl index 1dc45c8c20..efce4b908f 100644 --- a/src/FileFormats/LP/LP.jl +++ b/src/FileFormats/LP/LP.jl @@ -236,6 +236,46 @@ function _write_bounds(io, model, S, variable_names, free_variables) return end +function _write_sos_constraints(io, model, variable_names) + T, F = Float64, MOI.VectorOfVariables + sos1_indices = MOI.get(model, MOI.ListOfConstraintIndices{F,MOI.SOS1{T}}()) + sos2_indices = MOI.get(model, MOI.ListOfConstraintIndices{F,MOI.SOS2{T}}()) + if length(sos1_indices) + length(sos2_indices) == 0 + return + end + println(io, "SOS") + for index in sos1_indices + _write_constraint(io, model, index, variable_names) + end + for index in sos2_indices + _write_constraint(io, model, index, variable_names) + end + return +end + +_to_string(::Type{MOI.SOS1{Float64}}) = "S1::" +_to_string(::Type{MOI.SOS2{Float64}}) = "S2::" + +function _write_constraint( + io::IO, + model::Model, + index::MOI.ConstraintIndex{MOI.VectorOfVariables,S}, + variable_names::Dict{MOI.VariableIndex,String}, +) where {S<:Union{MOI.SOS1{Float64},MOI.SOS2{Float64}}} + f = MOI.get(model, MOI.ConstraintFunction(), index) + s = MOI.get(model, MOI.ConstraintSet(), index) + name = MOI.get(model, MOI.ConstraintName(), index) + if name !== nothing && !isempty(name) + print(io, name, ": ") + end + print(io, _to_string(S)) + for (w, x) in zip(s.weights, f.variables) + print(io, " ", variable_names[x], ":", w) + end + println(io) + return +end + """ Base.write(io::IO, model::FileFormats.LP.Model) @@ -280,6 +320,7 @@ function Base.write(io::IO, model::Model) end _write_integrality(io, model, "General", MOI.Integer, variable_names) _write_integrality(io, model, "Binary", MOI.ZeroOne, variable_names) + _write_sos_constraints(io, model, variable_names) println(io, "End") return end @@ -295,6 +336,7 @@ const _KW_CONSTRAINTS = Val{:constraints}() const _KW_BOUNDS = Val{:bounds}() const _KW_INTEGER = Val{:integer}() const _KW_BINARY = Val{:binary}() +const _KW_SOS = Val{:sos}() const _KW_END = Val{:end}() const _KEYWORDS = Dict( @@ -323,6 +365,8 @@ const _KEYWORDS = Dict( "bin" => _KW_BINARY, "binary" => _KW_BINARY, "binaries" => _KW_BINARY, + # _KW_SOS + "sos" => _KW_SOS, # _KW_END "end" => _KW_END, ) @@ -473,50 +517,16 @@ end # _KW_CONSTRAINTS -function _parse_sos_constraint( - model::Model, - cache::_ReadCache, - line::AbstractString, -) - tokens = _tokenize(line) - if length(tokens) < 3 - error("Malformed SOS constraint: $(line)") - end - name = String(split(tokens[1], ":")[1]) - if tokens[2] == "S1::" - order = 1 - elseif tokens[2] == "S2::" - order = 2 - else - error("SOS of type $(tokens[2]) not recognised") - end - variables, weights = MOI.VariableIndex[], Float64[] - for token in tokens[3:end] - items = String.(split(token, ":")) - if length(items) != 2 - error("Invalid sequence: $(token)") - end - push!(variables, _get_variable_from_name(model, cache, items[1])) - push!(weights, parse(Float64, items[2])) - end - c_ref = if tokens[2] == "S1::" - MOI.add_constraint(model, variables, MOI.SOS1(weights)) - else - @assert tokens[2] == "S2::" - MOI.add_constraint(model, variables, MOI.SOS2(weights)) - end - MOI.set(model, MOI.ConstraintName(), c_ref, name) - return -end - function _parse_section( ::typeof(_KW_CONSTRAINTS), model::Model, cache::_ReadCache, line::AbstractString, ) - if match(r" S([1-2]):: ", line) !== nothing - _parse_sos_constraint(model, cache, line) + # SOS constraints should be in their own "SOS" section, but we can also + # recognize them if they're mixed into the constraint section. + if match(r" S([1-2])\w*:: ", line) !== nothing + _parse_section(_KW_SOS, model, cache, line) return end if isempty(cache.constraint_name) @@ -650,6 +660,49 @@ function _parse_section(::typeof(_KW_BINARY), model, cache, line) return end +# _KW_SOS + +function _parse_section( + ::typeof(_KW_SOS), + model::Model, + cache::_ReadCache, + line::AbstractString, +) + # SOS constraints can have all manner of whitespace issues with them. + # Normalize them here before attempting to do anything else. + line = replace(line, r"\s+:\s+" => ":") + line = replace(line, r"\s+::" => "::") + tokens = _tokenize(line) + if length(tokens) < 3 + error("Malformed SOS constraint: $(line)") + end + name = String(split(tokens[1], ":")[1]) + if tokens[2] == "S1::" + order = 1 + elseif tokens[2] == "S2::" + order = 2 + else + error("SOS of type $(tokens[2]) not recognised") + end + variables, weights = MOI.VariableIndex[], Float64[] + for token in tokens[3:end] + items = String.(split(token, ":")) + if length(items) != 2 + error("Invalid sequence: $(token)") + end + push!(variables, _get_variable_from_name(model, cache, items[1])) + push!(weights, parse(Float64, items[2])) + end + c_ref = if tokens[2] == "S1::" + MOI.add_constraint(model, variables, MOI.SOS1(weights)) + else + @assert tokens[2] == "S2::" + MOI.add_constraint(model, variables, MOI.SOS2(weights)) + end + MOI.set(model, MOI.ConstraintName(), c_ref, name) + return +end + # _KW_END function _parse_section( diff --git a/test/FileFormats/LP/LP.jl b/test/FileFormats/LP/LP.jl index ed0f13b420..ad8b986c03 100644 --- a/test/FileFormats/LP/LP.jl +++ b/test/FileFormats/LP/LP.jl @@ -29,6 +29,8 @@ c7: 1.5a + 1.6 == 0.2 c8: 1.7a + 1.8 in Interval(0.3, 0.4) x in ZeroOne() y in Integer() +c11: [x, y, z] in SOS1{Float64}([1.0, 2.0, 3.0]) +c12: [x, y, z] in SOS2{Float64}([3.3, 1.1, 2.2]) """, ) MOI.write_to_file(model, LP_TEST_FILE) @@ -50,6 +52,9 @@ y in Integer() "y\n" * "Binary\n" * "x\n" * + "SOS\n" * + "c11: S1:: x:1.0 y:2.0 z:3.0\n" * + "c12: S2:: x:3.3 y:1.1 z:2.2\n" * "End\n" @test !MOI.is_empty(model) @@ -308,6 +313,8 @@ function test_read_model1_tricky() @test occursin("\nV5\n", file) @test occursin("\nV6\n", file) @test occursin("Binary\nV8\n", file) + @test occursin("sos1: S1:: V1:1.0 V2:2.0 V3:3.0", file) + @test occursin("sos2: S2:: V1:8.5 V2:10.2 V3:18.3", file) return end diff --git a/test/FileFormats/LP/models/invalid_sos_constraint.lp b/test/FileFormats/LP/models/invalid_sos_constraint.lp index bb374e10dc..d6bc14b669 100644 --- a/test/FileFormats/LP/models/invalid_sos_constraint.lp +++ b/test/FileFormats/LP/models/invalid_sos_constraint.lp @@ -5,8 +5,9 @@ CON1: 1 V1 >= 0.0 CON2: 1 V2 >= 2.0 CON3: 1 V3 <= 2.5 CON4: 1 V5 + 1 V6 + 1 V7 <= 1.0 -csos1: S1:: V1:1 V3:2 V5:3 -csos2: S2:: +SOS + csos1: S1:: V1:1 V3:2 V5:3 + csos2: S2:: Bounds -inf <= V1 <= 3 -inf <= V2 <= 3 diff --git a/test/FileFormats/LP/models/invalid_sos_set.lp b/test/FileFormats/LP/models/invalid_sos_set.lp index 48b8abb1bf..b68bcd1c76 100644 --- a/test/FileFormats/LP/models/invalid_sos_set.lp +++ b/test/FileFormats/LP/models/invalid_sos_set.lp @@ -5,8 +5,9 @@ CON1: 1 V1 >= 0.0 CON2: 1 V2 >= 2.0 CON3: 1 V3 <= 2.5 CON4: 1 V5 + 1 V6 + 1 V7 <= 1.0 -csos1: S1:: V1:1 V3:2 V5:3 -csos2: S3:: V2:2 V4:1 V5:2.5 +SOS + csos1: S1:: V1:1 V3:2 V5:3 + csos2: S3:: V2:2 V4:1 V5:2.5 Bounds -inf <= V1 <= 3 -inf <= V2 <= 3 diff --git a/test/FileFormats/LP/models/model1_tricky.lp b/test/FileFormats/LP/models/model1_tricky.lp index 109b201d7f..37d675f37f 100644 --- a/test/FileFormats/LP/models/model1_tricky.lp +++ b/test/FileFormats/LP/models/model1_tricky.lp @@ -29,4 +29,7 @@ Var4 V5 \ integer variables can be listed (MOSEK) V6 \ or each new line Binary V8 +SOS + sos1: S1:: V1 : 1 V2 : 2 V3 : 3 + sos2: S2 :: V1:8.5 V2:10.2 V3:18.3 End