From cd81e79e57ff6397e53dd1fa8841baa5e14fb132 Mon Sep 17 00:00:00 2001 From: odow Date: Mon, 1 Aug 2022 21:11:51 +1200 Subject: [PATCH 1/3] [FileFormats.LP] add support for quadratic problems --- src/FileFormats/LP/LP.jl | 181 +++++++++++++++++--- test/FileFormats/LP/LP.jl | 130 +++++++++++++- test/FileFormats/LP/models/model1_tricky.lp | 5 +- 3 files changed, 286 insertions(+), 30 deletions(-) diff --git a/src/FileFormats/LP/LP.jl b/src/FileFormats/LP/LP.jl index f9b7bfb123..816d78578b 100644 --- a/src/FileFormats/LP/LP.jl +++ b/src/FileFormats/LP/LP.jl @@ -35,16 +35,10 @@ MOI.Utilities.@model( (), (MOI.SOS1, MOI.SOS2), (), - (MOI.ScalarAffineFunction,), + (MOI.ScalarQuadraticFunction, MOI.ScalarAffineFunction), (MOI.VectorOfVariables,), () ) -function MOI.supports( - ::Model{T}, - ::MOI.ObjectiveFunction{<:MOI.ScalarQuadraticFunction{T}}, -) where {T} - return false -end struct Options maximum_length::Int @@ -93,7 +87,8 @@ function _write_function( io::IO, ::Model, func::MOI.VariableIndex, - variable_names::Dict{MOI.VariableIndex,String}, + variable_names::Dict{MOI.VariableIndex,String}; + kwargs..., ) print(io, variable_names[func]) return @@ -103,7 +98,8 @@ function _write_function( io::IO, ::Model, func::MOI.ScalarAffineFunction{Float64}, - variable_names::Dict{MOI.VariableIndex,String}, + variable_names::Dict{MOI.VariableIndex,String}; + kwargs..., ) is_first_item = true if !(func.constant ≈ 0.0) @@ -125,6 +121,68 @@ function _write_function( return end +function _write_function( + io::IO, + ::Model, + func::MOI.ScalarQuadraticFunction{Float64}, + variable_names::Dict{MOI.VariableIndex,String}; + print_half::Bool = true, + kwargs..., +) + is_first_item = true + if !(func.constant ≈ 0.0) + _print_shortest(io, func.constant) + is_first_item = false + end + for term in func.affine_terms + if !(term.coefficient ≈ 0.0) + if is_first_item + _print_shortest(io, term.coefficient) + is_first_item = false + else + print(io, term.coefficient < 0 ? " - " : " + ") + _print_shortest(io, abs(term.coefficient)) + end + print(io, " ", variable_names[term.variable]) + end + end + if length(func.quadratic_terms) > 0 + if is_first_item + print(io, "[ ") + else + print(io, " + [ ") + end + is_first_item = true + for term in func.quadratic_terms + coefficient = term.coefficient + if !print_half && term.variable_1 == term.variable_2 + coefficient /= 2 + end + if coefficient ≈ 0.0 + continue + elseif is_first_item + _print_shortest(io, coefficient) + is_first_item = false + else + print(io, coefficient < 0 ? " - " : " + ") + _print_shortest(io, abs(coefficient)) + end + print(io, " ", variable_names[term.variable_1]) + if term.variable_1 == term.variable_2 + print(io, " ^ 2") + else + print(io, " * ", variable_names[term.variable_2]) + end + end + if print_half + print(io, " ]/2") + else + print(io, " ]") + end + end + return +end + function _write_constraint_suffix(io::IO, set::MOI.LessThan) print(io, " <= ") _print_shortest(io, set.upper) @@ -174,7 +232,7 @@ function _write_constraint( print(io, MOI.get(model, MOI.ConstraintName(), index), ": ") end _write_constraint_prefix(io, set) - _write_function(io, model, func, variable_names) + _write_function(io, model, func, variable_names; print_half = false) _write_constraint_suffix(io, set) return end @@ -233,6 +291,10 @@ function _write_constraints(io, model, S, variable_names) for index in MOI.get(model, MOI.ListOfConstraintIndices{F,S}()) _write_constraint(io, model, index, variable_names; write_name = true) end + F = MOI.ScalarQuadraticFunction{Float64} + for index in MOI.get(model, MOI.ListOfConstraintIndices{F,S}()) + _write_constraint(io, model, index, variable_names; write_name = true) + end return end @@ -382,7 +444,9 @@ const _KEYWORDS = Dict( mutable struct _ReadCache objective::MOI.ScalarAffineFunction{Float64} + quad_obj_terms::Vector{MOI.ScalarQuadraticTerm{Float64}} constraint_function::MOI.ScalarAffineFunction{Float64} + quad_terms::Vector{MOI.ScalarQuadraticTerm{Float64}} constraint_name::String num_constraints::Int name_to_variable::Dict{String,MOI.VariableIndex} @@ -390,7 +454,9 @@ mutable struct _ReadCache function _ReadCache() return new( MOI.ScalarAffineFunction(MOI.ScalarAffineTerm{Float64}[], 0.0), + MOI.ScalarQuadraticTerm{Float64}[], MOI.ScalarAffineFunction(MOI.ScalarAffineTerm{Float64}[], 0.0), + MOI.ScalarQuadraticTerm{Float64}[], "", 0, Dict{String,MOI.VariableIndex}(), @@ -424,13 +490,30 @@ end _tokenize(line::AbstractString) = String.(split(line, " "; keepempty = false)) -@enum(_TokenType, _TOKEN_VARIABLE, _TOKEN_COEFFICIENT, _TOKEN_SIGN) +@enum( + _TokenType, + _TOKEN_VARIABLE, + _TOKEN_COEFFICIENT, + _TOKEN_SIGN, + _TOKEN_QUADRATIC_OPEN, + _TOKEN_QUADRATIC_CLOSE, + _TOKEN_QUADRATIC_DIAG, + _TOKEN_QUADRATIC_OFF_DIAG, +) function _parse_token(token::String) if token == "+" return _TOKEN_SIGN, +1.0 elseif token == "-" return _TOKEN_SIGN, -1.0 + elseif startswith(token, "[") + return _TOKEN_QUADRATIC_OPEN, +1.0 + elseif startswith(token, "]") + return _TOKEN_QUADRATIC_CLOSE, 0.5 + elseif token == "^" + return _TOKEN_QUADRATIC_DIAG, +1.0 + elseif token == "*" + return _TOKEN_QUADRATIC_OFF_DIAG, +1.0 end coef = tryparse(Float64, token) if coef === nothing @@ -455,12 +538,30 @@ function _get_term(token_types, token_values, offset) if offset > length(token_types) || token_types[offset] == _TOKEN_SIGN return coef, offset # It's a standalone constant! end + if token_types[offset] == _TOKEN_QUADRATIC_OPEN + return _get_term(token_types, token_values, offset + 1) + end @assert token_types[offset] == _TOKEN_VARIABLE x = MOI.VariableIndex(Int64(token_values[offset])) - return MOI.ScalarAffineTerm(coef, x), offset + 1 + offset += 1 + if offset > length(token_types) || token_types[offset] == _TOKEN_SIGN + return MOI.ScalarAffineTerm(coef, x), offset + end + term = if token_types[offset] == _TOKEN_QUADRATIC_DIAG + MOI.ScalarQuadraticTerm(coef, x, x) + else + @assert token_types[offset] == _TOKEN_QUADRATIC_OFF_DIAG + y = MOI.VariableIndex(Int64(token_values[offset+1])) + MOI.ScalarQuadraticTerm(coef, x, y) + end + if get(token_types, offset + 2, nothing) == _TOKEN_QUADRATIC_CLOSE + return term, offset + 3 + else + return term, offset + 2 + end end -function _parse_affine_terms( +function _parse_function( f::MOI.ScalarAffineFunction{Float64}, model::Model, cache::_ReadCache, @@ -474,6 +575,10 @@ function _parse_affine_terms( token_types[i] = token_type if token_type in (_TOKEN_SIGN, _TOKEN_COEFFICIENT) token_values[i] = token::Float64 + elseif token_type in (_TOKEN_QUADRATIC_OPEN, _TOKEN_QUADRATIC_CLOSE) + token_values[i] = NaN + elseif token_type in (_TOKEN_QUADRATIC_DIAG, _TOKEN_QUADRATIC_OFF_DIAG) + token_values[i] = NaN else @assert token_type == _TOKEN_VARIABLE x = _get_variable_from_name(model, cache, token::String) @@ -486,6 +591,15 @@ function _parse_affine_terms( term, offset = _get_term(token_types, token_values, offset) if term isa MOI.ScalarAffineTerm{Float64} push!(f.terms, term::MOI.ScalarAffineTerm{Float64}) + elseif term isa MOI.ScalarQuadraticTerm{Float64} + push!(cache.quad_terms, term::MOI.ScalarQuadraticTerm{Float64}) + if tokens[offset-1] == "]" + for (i, term) in enumerate(cache.quad_terms) + x, y = term.variable_1, term.variable_2 + scale = (x == y ? 2 : 1) * term.coefficient + cache.quad_terms[i] = MOI.ScalarQuadraticTerm(scale, x, y) + end + end else f.constant += term::Float64 end @@ -520,13 +634,18 @@ function _parse_section( if occursin(":", line) # Strip name of the objective line = String(match(r"(.*?)\:(.*)", line)[2]) end + if occursin("^", line) + line = replace(line, "^" => " ^ ") + end tokens = _tokenize(line) if length(tokens) == 0 # Can happen if the name of the objective is on one line and the # expression is on the next. return end - _parse_affine_terms(cache.objective, model, cache, tokens) + _parse_function(cache.objective, model, cache, tokens) + append!(cache.quad_obj_terms, cache.quad_terms) + empty!(cache.quad_terms) return end @@ -554,6 +673,11 @@ function _parse_section( cache.constraint_name = "R$(cache.num_constraints)" end end + if occursin("^", line) + # Simplify parsing of constraints with ^2 terms by turning them into + # explicit " ^ 2" terms. This avoids ambiguity when parsing names. + line = replace(line, "^" => " ^ ") + end tokens = _tokenize(line) if length(tokens) == 0 # Can happen if the name is on one line and the constraint on the next. @@ -573,12 +697,22 @@ function _parse_section( MOI.EqualTo(rhs) end end - _parse_affine_terms(cache.constraint_function, model, cache, tokens) + _parse_function(cache.constraint_function, model, cache, tokens) if constraint_set !== nothing - c = MOI.add_constraint(model, cache.constraint_function, constraint_set) + f = if isempty(cache.quad_terms) + cache.constraint_function + else + MOI.ScalarQuadraticFunction( + cache.quad_terms, + cache.constraint_function.terms, + cache.constraint_function.constant, + ) + end + c = MOI.add_constraint(model, f, constraint_set) MOI.set(model, MOI.ConstraintName(), c, cache.constraint_name) cache.num_constraints += 1 empty!(cache.constraint_function.terms) + empty!(cache.quad_terms) cache.constraint_function.constant = 0.0 cache.constraint_name = "" end @@ -795,11 +929,16 @@ function Base.read!(io::IO, model::Model) end _parse_section(section, model, cache, line) end - MOI.set( - model, - MOI.ObjectiveFunction{MOI.ScalarAffineFunction{Float64}}(), - cache.objective, - ) + obj = if isempty(cache.quad_obj_terms) + cache.objective + else + MOI.ScalarQuadraticFunction( + cache.quad_obj_terms, + cache.objective.terms, + cache.objective.constant, + ) + end + MOI.set(model, MOI.ObjectiveFunction{typeof(obj)}(), obj) return end diff --git a/test/FileFormats/LP/LP.jl b/test/FileFormats/LP/LP.jl index e8f90e14cb..9e746208eb 100644 --- a/test/FileFormats/LP/LP.jl +++ b/test/FileFormats/LP/LP.jl @@ -231,18 +231,131 @@ y in Integer() "End\n" end -function test_quadratic_objective() +function test_quadratic_objective_diag() model = LP.Model() - @test_throws( - MOI.UnsupportedAttribute, - MOI.Utilities.loadfromstring!( - model, - """ + MOI.Utilities.loadfromstring!( + model, + """ variables: x minobjective: 1.0*x*x """, - ) ) + MOI.write_to_file(model, LP_TEST_FILE) + @test read(LP_TEST_FILE, String) == + "minimize\n" * + "obj: [ 2 x ^ 2 ]/2\n" * + "subject to\n" * + "Bounds\n" * + "x free\n" * + "End\n" + return +end + +function test_quadratic_objective_off_diag() + model = LP.Model() + MOI.Utilities.loadfromstring!( + model, + """ +variables: x, y +minobjective: 1.1 * x + 1.2 * y + 1.5*x*y + 1.3 +""", + ) + MOI.write_to_file(model, LP_TEST_FILE) + @test read(LP_TEST_FILE, String) == + "minimize\n" * + "obj: 1.3 + 1.1 x + 1.2 y + [ 1.5 x * y ]/2\n" * + "subject to\n" * + "Bounds\n" * + "x free\n" * + "y free\n" * + "End\n" + return +end + +function test_quadratic_objective_complicated() + model = LP.Model() + MOI.Utilities.loadfromstring!( + model, + """ +variables: x, y +minobjective: 1.1 * x + 1.2 * y + -1.1 * x * x + 1.5*x*y + 1.3 +""", + ) + MOI.write_to_file(model, LP_TEST_FILE) + @test read(LP_TEST_FILE, String) == + "minimize\n" * + "obj: 1.3 + 1.1 x + 1.2 y + [ -2.2 x ^ 2 + 1.5 x * y ]/2\n" * + "subject to\n" * + "Bounds\n" * + "x free\n" * + "y free\n" * + "End\n" + return +end + +function test_quadratic_constraint_diag() + model = LP.Model() + MOI.Utilities.loadfromstring!( + model, + """ +variables: x +c: 1.0*x*x <= 1.4 +""", + ) + MOI.write_to_file(model, LP_TEST_FILE) + @test read(LP_TEST_FILE, String) == + "minimize\n" * + "obj: \n" * + "subject to\n" * + "c: [ 1 x ^ 2 ] <= 1.4\n" * + "Bounds\n" * + "x free\n" * + "End\n" + return +end + +function test_quadratic_constraint_off_diag() + model = LP.Model() + MOI.Utilities.loadfromstring!( + model, + """ +variables: x, y +c: 1.1 * x + 1.2 * y + 1.5*x*y + 1.3 == 1.5 +""", + ) + MOI.write_to_file(model, LP_TEST_FILE) + @test read(LP_TEST_FILE, String) == + "minimize\n" * + "obj: \n" * + "subject to\n" * + "c: 1.3 + 1.1 x + 1.2 y + [ 1.5 x * y ] = 1.5\n" * + "Bounds\n" * + "x free\n" * + "y free\n" * + "End\n" + return +end + +function test_quadratic_constraint_complicated() + model = LP.Model() + MOI.Utilities.loadfromstring!( + model, + """ +variables: x, y +c: 1.1 * x + 1.2 * y + -1.1 * x * x + 1.5*x*y + 1.3 in Interval(-1.1, 1.4) +""", + ) + MOI.write_to_file(model, LP_TEST_FILE) + @test read(LP_TEST_FILE, String) == + "minimize\n" * + "obj: \n" * + "subject to\n" * + "c: -1.1 <= 1.3 + 1.1 x + 1.2 y + [ -1.1 x ^ 2 + 1.5 x * y ] <= 1.4\n" * + "Bounds\n" * + "x free\n" * + "y free\n" * + "End\n" + return end ### @@ -315,10 +428,11 @@ function test_read_model1_tricky() seekstart(io) file = read(io, String) @test occursin("maximize", file) - @test occursin("obj: -1 Var4 + 1 V5", file) + @test occursin("obj: -1 Var4 + 1 V5 + [ 1 Var4 ^ 2 - 1.2 V5 * V1 ]/2", file) @test occursin("CON3: 1 V3 <= 2.5", file) @test occursin("CON4: 1 V5 + 1 V6 + 1 V7 <= 1", file) @test occursin("CON1: 1 V1 >= 0", file) + @test occursin("CON5: [ 1 Var4 ^ 2 - 1.2 V5 * V1 ] <= 0", file) @test occursin("R1: 1 V2 >= 2", file) @test occursin("V1 <= 3", file) @test occursin("Var4 >= 5.5", file) diff --git a/test/FileFormats/LP/models/model1_tricky.lp b/test/FileFormats/LP/models/model1_tricky.lp index 37d675f37f..41255d45fd 100644 --- a/test/FileFormats/LP/models/model1_tricky.lp +++ b/test/FileFormats/LP/models/model1_tricky.lp @@ -6,7 +6,8 @@ Max \ this problem is a maximisation! obj: -1 Var4 - + 1 V5 + + 1 V5 + [ Var4^2 + - 1.2 V5 * V1 ]/2 Subject To CON1: 1 V1 >= 0.0 @@ -14,6 +15,8 @@ CON1: CON3: 1 V3 <= 2.5 CON4: 1 V5 + 1 V6 \ split constraint. we know it hasn't ended as missing operator + 1 V7 <= 1.0 + +CON5: [ Var4^2 - 1.2 V5 * V1 ] <= 0.0 Bounds -inf <= V1 <= 3 V2 <= 3 From 27c11cf684952a9788ec20a0a6168b43c2ac29c1 Mon Sep 17 00:00:00 2001 From: odow Date: Tue, 2 Aug 2022 09:57:31 +1200 Subject: [PATCH 2/3] Add more tests --- src/FileFormats/LP/LP.jl | 4 +--- test/FileFormats/LP/LP.jl | 18 ++++++++++++++++++ test/FileFormats/LP/models/tricky_quadratic.lp | 9 +++++++++ 3 files changed, 28 insertions(+), 3 deletions(-) create mode 100644 test/FileFormats/LP/models/tricky_quadratic.lp diff --git a/src/FileFormats/LP/LP.jl b/src/FileFormats/LP/LP.jl index 816d78578b..ba9185f804 100644 --- a/src/FileFormats/LP/LP.jl +++ b/src/FileFormats/LP/LP.jl @@ -158,9 +158,7 @@ function _write_function( if !print_half && term.variable_1 == term.variable_2 coefficient /= 2 end - if coefficient ≈ 0.0 - continue - elseif is_first_item + if is_first_item _print_shortest(io, coefficient) is_first_item = false else diff --git a/test/FileFormats/LP/LP.jl b/test/FileFormats/LP/LP.jl index 9e746208eb..9fb12da1d8 100644 --- a/test/FileFormats/LP/LP.jl +++ b/test/FileFormats/LP/LP.jl @@ -599,6 +599,24 @@ function test_infinite_interval() return end +function test_read_tricky_quadratic() + model = LP.Model() + filename = joinpath(@__DIR__, "models", "tricky_quadratic.lp") + MOI.read_from_file(model, filename) + io = IOBuffer() + write(io, model) + seekstart(io) + file = read(io, String) + print(file) + @test occursin("minimize", file) + @test occursin("obj: [ 2 x ^ 2 + 1 x * y ]/2", file) + @test occursin("c1: [ 1 x ^ 2 - 1 x * y ] <= 0", file) + @test occursin("c2: [ 0.5 x ^ 2 - 1 x * y ] <= 0", file) + @test occursin("x free", file) + @test occursin("y free", file) + return +end + function runtests() for name in names(@__MODULE__, all = true) if startswith("$(name)", "test_") diff --git a/test/FileFormats/LP/models/tricky_quadratic.lp b/test/FileFormats/LP/models/tricky_quadratic.lp new file mode 100644 index 0000000000..8b415ee8eb --- /dev/null +++ b/test/FileFormats/LP/models/tricky_quadratic.lp @@ -0,0 +1,9 @@ +minimize +obj: [ x * x + x * y ] \ Objective with no /2 +Subject To +c1: [ x^2 - x * y ] <= 0.0 +c2: [ x^2 - x * y ]/2 <= 0.0 +Bounds +x free +y free +End From 6e3f9f650bb2ad99a927dffb01c4004ae0ee0f73 Mon Sep 17 00:00:00 2001 From: odow Date: Tue, 2 Aug 2022 10:03:36 +1200 Subject: [PATCH 3/3] Fix whitespace issues with /2 --- src/FileFormats/LP/LP.jl | 7 +++++++ test/FileFormats/LP/LP.jl | 2 +- test/FileFormats/LP/models/model1_tricky.lp | 2 +- test/FileFormats/LP/models/tricky_quadratic.lp | 3 ++- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/FileFormats/LP/LP.jl b/src/FileFormats/LP/LP.jl index ba9185f804..cbea0831c2 100644 --- a/src/FileFormats/LP/LP.jl +++ b/src/FileFormats/LP/LP.jl @@ -635,6 +635,9 @@ function _parse_section( if occursin("^", line) line = replace(line, "^" => " ^ ") end + if occursin(r"\][\s/][\s/]+2", line) + line = replace(line, r"\][\s/][\s/]+2" => "]/2") + end tokens = _tokenize(line) if length(tokens) == 0 # Can happen if the name of the objective is on one line and the @@ -676,6 +679,10 @@ function _parse_section( # explicit " ^ 2" terms. This avoids ambiguity when parsing names. line = replace(line, "^" => " ^ ") end + if occursin(r"\][\s/][\s/]+2", line) + # Simplify parsing of ]/2 end blocks, which may contain whitespace. + line = replace(line, r"\][\s/][\s/]+2" => "]/2") + end tokens = _tokenize(line) if length(tokens) == 0 # Can happen if the name is on one line and the constraint on the next. diff --git a/test/FileFormats/LP/LP.jl b/test/FileFormats/LP/LP.jl index 9fb12da1d8..5854f8b8b0 100644 --- a/test/FileFormats/LP/LP.jl +++ b/test/FileFormats/LP/LP.jl @@ -607,11 +607,11 @@ function test_read_tricky_quadratic() write(io, model) seekstart(io) file = read(io, String) - print(file) @test occursin("minimize", file) @test occursin("obj: [ 2 x ^ 2 + 1 x * y ]/2", file) @test occursin("c1: [ 1 x ^ 2 - 1 x * y ] <= 0", file) @test occursin("c2: [ 0.5 x ^ 2 - 1 x * y ] <= 0", file) + @test occursin("c3: [ 0.5 x ^ 2 - 1 x * y ] <= 0", file) @test occursin("x free", file) @test occursin("y free", file) return diff --git a/test/FileFormats/LP/models/model1_tricky.lp b/test/FileFormats/LP/models/model1_tricky.lp index 41255d45fd..af8a97b31f 100644 --- a/test/FileFormats/LP/models/model1_tricky.lp +++ b/test/FileFormats/LP/models/model1_tricky.lp @@ -7,7 +7,7 @@ Max \ this problem is a maximisation! obj: -1 Var4 + 1 V5 + [ Var4^2 - - 1.2 V5 * V1 ]/2 + - 1.2 V5 * V1 ] /2 Subject To CON1: 1 V1 >= 0.0 diff --git a/test/FileFormats/LP/models/tricky_quadratic.lp b/test/FileFormats/LP/models/tricky_quadratic.lp index 8b415ee8eb..4d126a82a6 100644 --- a/test/FileFormats/LP/models/tricky_quadratic.lp +++ b/test/FileFormats/LP/models/tricky_quadratic.lp @@ -2,7 +2,8 @@ minimize obj: [ x * x + x * y ] \ Objective with no /2 Subject To c1: [ x^2 - x * y ] <= 0.0 -c2: [ x^2 - x * y ]/2 <= 0.0 +c2: [ x^2 - x * y ]/ 2 <= 0.0 +c3: [ x^2 - x * y ] / 2 <= 0.0 Bounds x free y free