From ab5998302e0ea011ae2f8a5bc5e72141ac957cba Mon Sep 17 00:00:00 2001 From: odow Date: Thu, 31 Mar 2022 10:56:32 +1300 Subject: [PATCH 1/7] [FileFormats] large fix and refactor of the LP reader --- src/FileFormats/LP/LP.jl | 475 +------------------------------------ src/FileFormats/LP/read.jl | 456 +++++++++++++++++++++++++++++++++++ test/FileFormats/LP/LP.jl | 66 ++++-- 3 files changed, 506 insertions(+), 491 deletions(-) create mode 100644 src/FileFormats/LP/read.jl diff --git a/src/FileFormats/LP/LP.jl b/src/FileFormats/LP/LP.jl index 37502b81f6..36182e96e7 100644 --- a/src/FileFormats/LP/LP.jl +++ b/src/FileFormats/LP/LP.jl @@ -285,480 +285,7 @@ end # Base.read! # # ============================================================================== -const _COMMENT_REG = r"(.*?)\\(.*)" -const _READ_START_REG = r"^([\.0-9])" -function _strip_comment(line::String) - if occursin("\\", line) - m = match(_COMMENT_REG, line) - return strip(String(m[1])) - else - return strip(line) - end -end - -# a list of section keywords in lower-case -const _KEYWORDS = Dict( - "max" => Val{:obj}, - "maximize" => Val{:obj}, - "maximise" => Val{:obj}, - "maximum" => Val{:obj}, - "min" => Val{:obj}, - "minimize" => Val{:obj}, - "minimise" => Val{:obj}, - "minimum" => Val{:obj}, - "subject to" => Val{:constraints}, - "such that" => Val{:constraints}, - "st" => Val{:constraints}, - "s.t." => Val{:constraints}, - "bounds" => Val{:bounds}, - "bound" => Val{:bounds}, - "gen" => Val{:integer}, - "general" => Val{:integer}, - "generals" => Val{:integer}, - "bin" => Val{:binary}, - "binary" => Val{:binary}, - "binaries" => Val{:binary}, - "end" => Val{:quit}, -) - -const _SENSE_ALIAS = Dict( - "max" => MOI.MAX_SENSE, - "maximize" => MOI.MAX_SENSE, - "maximise" => MOI.MAX_SENSE, - "maximum" => MOI.MAX_SENSE, - "min" => MOI.MIN_SENSE, - "minimize" => MOI.MIN_SENSE, - "minimise" => MOI.MIN_SENSE, - "minimum" => MOI.MIN_SENSE, -) - -const _SUBJECT_TO_ALIAS = ["subject to", "such that", "st", "s.t."] - -const _CONSTRAINT_SENSE = Dict( - "<" => :le, - "<=" => :le, - "=" => :eq, - "==" => :eq, - ">" => :ge, - ">=" => :ge, -) - -function _verify_name(variable::String, maximum_length::Int) - if length(variable) > maximum_length - return false - end - m = match(_READ_START_REG, variable) - if m !== nothing - return false - end - m = match(NAME_REG, variable) - if m !== nothing - return false - end - return true -end - -mutable struct CacheLPModel - objective_function::MOI.ScalarAffineFunction - linear_constraint_function::MOI.ScalarAffineFunction - linear_constraint_set::MOI.AbstractScalarSet - linear_constraint_open::Bool - linear_constraint_name::String - num_linear_constraints::Int - variables_in_model::Dict{String,MOI.VariableIndex} - function CacheLPModel() - return new( - MOI.ScalarAffineFunction(MOI.ScalarAffineTerm{Float64}[], 0.0), - MOI.ScalarAffineFunction(MOI.ScalarAffineTerm{Float64}[], 0.0), - MOI.EqualTo(0.0), - false, - "", - 0, - Dict{String,MOI.VariableIndex}(), - ) - end -end - -_set_sense!(T, model::Model, line) = nothing -function _set_sense!(::Type{Val{:obj}}, model::Model, line) - return MOI.set(model, MOI.ObjectiveSense(), _SENSE_ALIAS[lowercase(line)]) -end - -function _add_new_variable!(model::Model, data_cache::CacheLPModel, name) - var = MOI.add_variable(model) - MOI.set(model, MOI.VariableName(), var, name) - data_cache.variables_in_model[name] = var - return var -end - -function _get_variable_from_name( - model::Model, - data_cache::CacheLPModel, - variable_name, -) - var_inside_model = get(data_cache.variables_in_model, variable_name, "") - if var_inside_model != "" - return var_inside_model - end - options = get_options(model) - if !_verify_name(variable_name, options.maximum_length) - error("Invalid variable name $variable_name") - end - return _add_new_variable!(model, data_cache, variable_name) -end - -function _tokenize(line::AbstractString) - return String.(split(line, " "; keepempty = false)) -end - -function _parse_float_from_bound(val::String) - lower_case_val = lowercase(val) - if lower_case_val == "-inf" || lower_case_val == "-infinity" - return -Inf - elseif lower_case_val == "+inf" || lower_case_val == "+infinity" - return Inf - else - return parse(Float64, lower_case_val) - end -end - -function _parse_affine_terms!( - model::Model, - data_cache::CacheLPModel, - tokens::Vector{String}, - section::String, - line::AbstractString, -) - affine_terms = MOI.ScalarAffineTerm{Float64}[] - while length(tokens) > 0 - variable = String(pop!(tokens)) - # In the case of objective functions this can be an objective constant - if section == "objective" - try - obj_constant = parse(Float64, variable) - if length(tokens) > 0 - _sign = pop!(tokens) - if _sign == "-" - obj_constant *= -1 - elseif _sign == "+" - else - error( - "Unable to parse $section due to bad operator: $(_sign) $(line)", - ) - end - end - data_cache.objective_function.constant += obj_constant - continue - catch - end - end - var = _get_variable_from_name(model, data_cache, variable) - if length(tokens) > 0 - coef_token = pop!(tokens) - else - coeff = 1.0 - push!(affine_terms, MOI.ScalarAffineTerm(coeff, var)) - continue - end - try - if coef_token == "+" - coeff = 1.0 - push!(affine_terms, MOI.ScalarAffineTerm(coeff, var)) - continue - elseif coef_token == "-" - coeff = -1.0 - push!(affine_terms, MOI.ScalarAffineTerm(coeff, var)) - continue - end - coeff = parse(Float64, coef_token) - catch - error( - "Unable to parse $section due to bad operator: $(_sign) $(line)", - ) - end - if length(tokens) > 0 - _sign = pop!(tokens) - if _sign == "-" - coeff *= -1 - elseif _sign == "+" - else - error( - "Unable to parse $section due to bad operator: $(_sign) $(line)", - ) - end - end - push!(affine_terms, MOI.ScalarAffineTerm(coeff, var)) - end - return affine_terms -end - -function _parse_sos!( - model::Model, - data_cache::CacheLPModel, - line::AbstractString, -) - tokens = _tokenize(line) - if length(tokens) < 3 - error(string("Malformed SOS constraint: ", line)) - end - sos_con_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 = MOI.VariableIndex[] - weights = Float64[] - for token in tokens[3:end] - items = String.(split(token, ":")) - if length(items) != 2 - error(string("Invalid sequence: ", token)) - end - push!(variables, _get_variable_from_name(model, data_cache, items[1])) - push!(weights, parse(Float64, items[2])) - end - sos_con = MOI.add_constraint( - model, - variables, - order == 1 ? MOI.SOS1(weights) : MOI.SOS2(weights), - ) - MOI.set(model, MOI.ConstraintName(), sos_con, sos_con_name) - # TODO I think this only works for SOS of one line - return -end - -function _parse_variable_type!( - model::Model, - data_cache::CacheLPModel, - line::AbstractString, - set::MOI.AbstractSet, -) - items = _tokenize(line) - for v in items - var = _get_variable_from_name(model, data_cache, v) - MOI.add_constraint(model, var, set) - end - return nothing -end -function _parse_section!( - ::Type{Val{:none}}, - model::Model, - data_cache::CacheLPModel, - line::AbstractString, -) - return nothing -end -function _parse_section!( - ::Type{Val{:quit}}, - model::Model, - data_cache::CacheLPModel, - line::AbstractString, -) - return error("Corrupted LP File. You have the lne $(line) after an end.") -end -function _parse_section!(::Type{Val{:integer}}, model, data_cache, line) - return _parse_variable_type!(model, data_cache, line, MOI.Integer()) -end -function _parse_section!(::Type{Val{:binary}}, model, data_cache, line) - return _parse_variable_type!(model, data_cache, line, MOI.ZeroOne()) -end - -function _parse_section!( - ::Type{Val{:obj}}, - model::Model, - data_cache::CacheLPModel, - line::AbstractString, -) - # okay so line should be the start of the objective - if occursin(":", line) - # throw away name - m = match(r"(.*?)\:(.*)", line) - line = String(m[2]) - end - tokens = _tokenize(line) - if length(tokens) == 0 # no objective - return MOI.set(model, MOI.ObjectiveSense(), MOI.FEASIBILITY_SENSE) - end - affine_terms = - _parse_affine_terms!(model, data_cache, tokens, "objective", line) - push!(data_cache.objective_function.terms, affine_terms...) - return -end - -function _parse_section!( - ::Type{Val{:constraints}}, - model::Model, - data_cache::CacheLPModel, - line::AbstractString, -) - if match(r" S([0-9]):: ", line) !== nothing - # it's an SOS constraint - _parse_sos!(model, data_cache, line) - return - end - if data_cache.linear_constraint_open == false - # parse the number of rows and add this name - data_cache.linear_constraint_name = "R$(data_cache.num_linear_constraints)" - end - if occursin(":", line) - if data_cache.linear_constraint_open == true - error("Malformed constraint $(line). Is the previous one valid?") - end - # throw away name - m = match(r"(.*?)\:(.*)", line) - data_cache.linear_constraint_name = String(m[1]) - line = String(m[2]) - end - data_cache.linear_constraint_open = true - - tokens = _tokenize(line) - if length(tokens) == 0 # no entries - return - elseif length(tokens) >= 2 && haskey(_CONSTRAINT_SENSE, tokens[end-1])# test if constraint ends this line - rhs = parse(Float64, pop!(tokens)) - sym = pop!(tokens) - if _CONSTRAINT_SENSE[sym] == :le - data_cache.linear_constraint_set = MOI.LessThan(rhs) - elseif _CONSTRAINT_SENSE[sym] == :ge - data_cache.linear_constraint_set = MOI.GreaterThan(rhs) - elseif _CONSTRAINT_SENSE[sym] == :eq - data_cache.linear_constraint_set = MOI.EqualTo(rhs) - end - # Finished - # Add constraint - c = MOI.add_constraint( - model, - data_cache.linear_constraint_function, - data_cache.linear_constraint_set, - ) - MOI.set( - model, - MOI.ConstraintName(), - c, - data_cache.linear_constraint_name, - ) - data_cache.num_linear_constraints += 1 - # Clear the constraint part of data_cache - data_cache.linear_constraint_set = MOI.EqualTo(0.0) - data_cache.linear_constraint_function = - MOI.ScalarAffineFunction(MOI.ScalarAffineTerm{Float64}[], 0.0) - data_cache.linear_constraint_name = "" - data_cache.linear_constraint_open = false - end - affine_terms = - _parse_affine_terms!(model, data_cache, tokens, "constraint", line) - push!(data_cache.linear_constraint_function.terms, affine_terms...) - return -end - -_bound_error(line::AbstractString) = error("Unable to parse bound: $(line)") -function _parse_section!( - ::Type{Val{:bounds}}, - model::Model, - data_cache::CacheLPModel, - line::AbstractString, -) - items = _tokenize(line) - v = "" - lb = -Inf - ub = Inf - if length(items) == 5 # ranged bound - v = items[3] - if (items[2] == "<=" || items[2] == "<") && - (items[4] == "<=" || items[4] == "<") # le - lb = _parse_float_from_bound(items[1]) - ub = _parse_float_from_bound(items[5]) - elseif (items[2] == ">=" || items[2] == ">") && - (items[4] == ">=" || items[4] == ">") # ge - lb = _parse_float_from_bound(items[5]) - ub = _parse_float_from_bound(items[1]) - else - _bound_error(line) - end - elseif length(items) == 3 # one sided - v = items[1] - if items[2] == "<=" || items[2] == "<" # le - ub = _parse_float_from_bound(items[3]) - if ub > 0.0 - lb = 0.0 - else - lb = -Inf - end - elseif items[2] == ">=" || items[2] == ">" # ge - lb = _parse_float_from_bound(items[3]) - ub = +Inf - elseif items[2] == "==" || items[2] == "=" # eq - lb = ub = _parse_float_from_bound(items[3]) - else - _bound_error(line) - end - elseif length(items) == 2 # free - if items[2] != "free" - _bound_error(line) - end - v = items[1] - else - _bound_error(line) - end - var = _get_variable_from_name(model, data_cache, v) - set = bounds_to_set(lb, ub) - if set !== nothing - MOI.add_constraint(model, var, set) - end - return -end - -function bounds_to_set(lower::Float64, upper::Float64) - if -Inf < lower < upper < Inf - return MOI.Interval(lower, upper) - elseif -Inf < lower && upper == Inf - return MOI.GreaterThan(lower) - elseif -Inf == lower && upper < Inf - return MOI.LessThan(upper) - elseif lower == upper - return MOI.EqualTo(upper) - end - return # free variable -end - -function _add_objective!(model::Model, data_cache::CacheLPModel) - MOI.set( - model, - MOI.ObjectiveFunction{MOI.ScalarAffineFunction{Float64}}(), - data_cache.objective_function, - ) - return -end - -""" - Base.read!(io::IO, model::FileFormats.LP.Model) - -Read `io` in the LP file format and store the result in `model`. -""" -function Base.read!(io::IO, model::Model) - if !MOI.is_empty(model) - error("Cannot read in file because model is not empty.") - end - data_cache = CacheLPModel() - section = Val{:none} - while !eof(io) - line = string(strip(readline(io))) - line = _strip_comment(line) - if line == "" # skip blank lines - continue - end - if haskey(_KEYWORDS, lowercase(line)) # section has changed - section = _KEYWORDS[lowercase(line)] - _set_sense!(section, model, line) - continue - end - _parse_section!(section, model, data_cache, line) - end - _add_objective!(model, data_cache) - return -end +include("read.jl") end diff --git a/src/FileFormats/LP/read.jl b/src/FileFormats/LP/read.jl new file mode 100644 index 0000000000..63e8f093ad --- /dev/null +++ b/src/FileFormats/LP/read.jl @@ -0,0 +1,456 @@ +const _COMMENT_REG = r"(.*?)\\(.*)" + +const _READ_START_REG = r"^([\.0-9])" + +function _strip_comment(line::String) + if occursin("\\", line) + m = match(_COMMENT_REG, line) + return strip(String(m[1])) + else + return strip(line) + end +end + +const _KW_OBJECTIVE = Val{:objective}() +const _KW_CONSTRAINTS = Val{:constraints}() +const _KW_BOUNDS = Val{:bounds}() +const _KW_INTEGER = Val{:integer}() +const _KW_BINARY = Val{:binary}() +const _KW_END = Val{:end}() + +const _KEYWORDS = Dict( + # _KW_OBJECTIVE + "max" => _KW_OBJECTIVE, + "maximize" => _KW_OBJECTIVE, + "maximise" => _KW_OBJECTIVE, + "maximum" => _KW_OBJECTIVE, + "min" => _KW_OBJECTIVE, + "minimize" => _KW_OBJECTIVE, + "minimise" => _KW_OBJECTIVE, + "minimum" => _KW_OBJECTIVE, + # _KW_CONSTRAINTS + "subject to" => _KW_CONSTRAINTS, + "such that" => _KW_CONSTRAINTS, + "st" => _KW_CONSTRAINTS, + "s.t." => _KW_CONSTRAINTS, + # _KW_BOUNDS + "bounds" => _KW_BOUNDS, + "bound" => _KW_BOUNDS, + # _KW_INTEGER + "gen" => _KW_INTEGER, + "general" => _KW_INTEGER, + "generals" => _KW_INTEGER, + # _KW_BINARY + "bin" => _KW_BINARY, + "binary" => _KW_BINARY, + "binaries" => _KW_BINARY, + # _KW_END + "end" => _KW_END, +) + +const _CONSTRAINT_SENSE = Dict( + "<" => :le, + "<=" => :le, + "=" => :eq, + "==" => :eq, + ">" => :ge, + ">=" => :ge, +) + +mutable struct _CacheLPModel + objective_function::MOI.ScalarAffineFunction{Float64} + linear_constraint_function::MOI.ScalarAffineFunction{Float64} + linear_constraint_set::MOI.AbstractScalarSet + linear_constraint_open::Bool + linear_constraint_name::String + num_linear_constraints::Int + variables_in_model::Dict{String,MOI.VariableIndex} + function _CacheLPModel() + return new( + MOI.ScalarAffineFunction(MOI.ScalarAffineTerm{Float64}[], 0.0), + MOI.ScalarAffineFunction(MOI.ScalarAffineTerm{Float64}[], 0.0), + MOI.EqualTo(0.0), + false, + "", + 0, + Dict{String,MOI.VariableIndex}(), + ) + end +end + +function _verify_name(name::String, maximum_length::Int) + if length(name) > maximum_length + error("Name exceeds maximum length: $name") + elseif match(_READ_START_REG, name) !== nothing + error("Name starts with invalid character: $name") + elseif match(NAME_REG, name) !== nothing + error("Name contains with invalid character: $name") + end + return +end + +function _get_variable_from_name( + model::Model, + cache::_CacheLPModel, + name::String, +) + current_variable = get(cache.variables_in_model, name, nothing) + if current_variable !== nothing + return current_variable + end + options = get_options(model) + _verify_name(name, options.maximum_length) + x = MOI.add_variable(model) + MOI.set(model, MOI.VariableName(), x, name) + cache.variables_in_model[name] = x + return x +end + +_tokenize(line::AbstractString) = String.(split(line, " "; keepempty = false)) + +@enum(_TokenType, _TOKEN_VARIABLE, _TOKEN_COEFFICIENT, _TOKEN_SIGN) + +function _parse_token(token::String) + if token == "+" + return _TOKEN_SIGN, +1.0 + elseif token == "-" + return _TOKEN_SIGN, -1.0 + end + coef = tryparse(Float64, token) + if coef === nothing + return _TOKEN_VARIABLE, token + else + return _TOKEN_COEFFICIENT, coef + end +end + +function _get_term(token_types, token_values, offset) + coef = 1.0 + if token_types[offset] == _TOKEN_SIGN + coef = token_values[offset] + offset += 1 + end + if token_types[offset] == _TOKEN_COEFFICIENT + coef *= token_values[offset] + offset += 1 + elseif token_types[offset] == _TOKEN_SIGN + error("Invalid line") + end + if offset > length(token_types) || token_types[offset] == _TOKEN_SIGN + # It's a standalone constant! + return coef, offset + end + @assert token_types[offset] == _TOKEN_VARIABLE + x = MOI.VariableIndex(Int64(token_values[offset])) + return MOI.ScalarAffineTerm(coef, x), offset + 1 +end + +function _parse_affine_terms( + terms::Vector{MOI.ScalarAffineTerm{Float64}}, + model::Model, + cache::_CacheLPModel, + tokens::Vector{String}, +) + N = length(tokens) + token_types = Vector{_TokenType}(undef, N) + token_values = Vector{Float64}(undef, N) + for i in 1:length(tokens) + token_type, token = _parse_token(tokens[i]) + token_types[i] = token_type + if token_type in (_TOKEN_SIGN, _TOKEN_COEFFICIENT) + token_values[i] = token::Float64 + else + @assert token_type == _TOKEN_VARIABLE + x = _get_variable_from_name(model, cache, token::String) + # A cheat for type-stability. Store `Float64` of the variable index! + token_values[i] = Float64(x.value) + end + end + offset = 1 + constant = 0.0 + while offset <= length(tokens) + term, offset = _get_term(token_types, token_values, offset) + if term isa MOI.ScalarAffineTerm{Float64} + push!(terms, term::MOI.ScalarAffineTerm{Float64}) + else + constant += term::Float64 + end + end + return constant +end + +# _KW_HEADER + +_parse_section(::Val{:header}, ::Model, ::_CacheLPModel, ::Any) = nothing + +# _KW_OBJECTIVE + +_set_objective_sense(::Any, ::Model, ::String) = nothing + +function _set_objective_sense( + ::typeof(_KW_OBJECTIVE), + model::Model, + sense::String, +) + if sense in ("max", "maximize", "maximise", "maximum") + MOI.set(model, MOI.ObjectiveSense(), MOI.MAX_SENSE) + else + @assert sense in ("min", "minimize", "minimise", "minimum") + MOI.set(model, MOI.ObjectiveSense(), MOI.MIN_SENSE) + end + return +end + +function _parse_section( + ::typeof(_KW_OBJECTIVE), + model::Model, + cache::_CacheLPModel, + line::AbstractString, +) + if occursin(":", line) + # Strip name of the objective + line = String(match(r"(.*?)\:(.*)", line)[2]) + end + tokens = _tokenize(line) + if length(tokens) == 0 + return + end + terms = cache.objective_function.terms + constant = _parse_affine_terms(terms, model, cache, tokens) + cache.objective_function.constant += constant + return +end + +# _KW_CONSTRAINTS + +function _parse_sos_constraint( + model::Model, + cache::_CacheLPModel, + line::AbstractString, +) + tokens = _tokenize(line) + if length(tokens) < 3 + error(string("Malformed SOS constraint: ", line)) + end + sos_con_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 = MOI.VariableIndex[] + weights = Float64[] + for token in tokens[3:end] + items = String.(split(token, ":")) + if length(items) != 2 + error(string("Invalid sequence: ", token)) + end + push!(variables, _get_variable_from_name(model, cache, items[1])) + push!(weights, parse(Float64, items[2])) + end + sos_con = MOI.add_constraint( + model, + variables, + order == 1 ? MOI.SOS1(weights) : MOI.SOS2(weights), + ) + MOI.set(model, MOI.ConstraintName(), sos_con, sos_con_name) + # TODO I think this only works for SOS of one line + return +end + +function _parse_section( + ::typeof(_KW_CONSTRAINTS), + model::Model, + cache::_CacheLPModel, + line::AbstractString, +) + if match(r" S([0-9]):: ", line) !== nothing + _parse_sos_constraint(model, cache, line) + return + end + if cache.linear_constraint_open == false + # We're starting a new constraint. Give it a name for now, but we might + # replace it with a proper strinng in the next if-block. + cache.linear_constraint_name = "R$(cache.num_linear_constraints)" + end + if occursin(":", line) + if cache.linear_constraint_open == true + error("Malformed constraint $(line). Is the previous one valid?") + end + m = match(r"(.*?)\:(.*)", line) + cache.linear_constraint_name = String(m[1]) + line = String(m[2]) + end + cache.linear_constraint_open = true + # Now start parsing the coefficients. + tokens = _tokenize(line) + if length(tokens) == 0 # no entries + return + end + is_finished = false + if length(tokens) >= 2 && tokens[end-1] in ("<", "<=", ">", ">=", "=", "==") + rhs = parse(Float64, pop!(tokens)) + sym = pop!(tokens) + if sym in ("<", "<=") + cache.linear_constraint_set = MOI.LessThan(rhs) + elseif sym in (">", ">=") + cache.linear_constraint_set = MOI.GreaterThan(rhs) + elseif sym in ("=", "==") + cache.linear_constraint_set = MOI.EqualTo(rhs) + end + is_finished = true + end + terms = cache.linear_constraint_function.terms + constant = _parse_affine_terms(terms, model, cache, tokens) + cache.linear_constraint_function.constant += constant + if is_finished + c = MOI.add_constraint( + model, + cache.linear_constraint_function, + cache.linear_constraint_set, + ) + MOI.set(model, MOI.ConstraintName(), c, cache.linear_constraint_name) + cache.num_linear_constraints += 1 + cache.linear_constraint_set = MOI.EqualTo(0.0) + cache.linear_constraint_function = + MOI.ScalarAffineFunction(MOI.ScalarAffineTerm{Float64}[], 0.0) + cache.linear_constraint_name = "" + cache.linear_constraint_open = false + end + return +end + +# _KW_BOUNDS + +function _parse_float(val::String) + lower_case_val = lowercase(val) + if lower_case_val in ("-inf", "-infinity") + return -Inf + elseif lower_case_val in ("+inf", "+infinity") + return Inf + else + return parse(Float64, lower_case_val) + end +end + +_is_less_than(token) = token in ("<=", "<") +_is_greater_than(token) = token in (">=", ">") +_is_equal_to(token) = token in ("==", "=") + +function _parse_section( + ::typeof(_KW_BOUNDS), + model::Model, + cache::_CacheLPModel, + line::AbstractString, +) + tokens = _tokenize(line) + if length(tokens) == 2 && tokens[2] == "free" + # Do nothing. Variable is free + return + end + lb, ub, name = -Inf, Inf, "" + if length(tokens) == 5 + name = tokens[3] + if _is_less_than(tokens[2]) && _is_less_than(tokens[4]) + lb = _parse_float(tokens[1]) + ub = _parse_float(tokens[5]) + elseif _is_greater_than(tokens[2]) && _is_greater_than(tokens[4]) + lb = _parse_float(tokens[5]) + ub = _parse_float(tokens[1]) + else + error("Unable to parse bound due to invalid inequalities: $(line)") + end + elseif length(tokens) == 3 + name = tokens[1] + if _is_less_than(tokens[2]) + ub = _parse_float(tokens[3]) + # LP files have default lower bounds of 0, unless the upper bound is + # less than 0. + lb = ub > 0.0 ? 0.0 : -Inf + elseif _is_greater_than(tokens[2]) + lb = _parse_float(tokens[3]) + elseif _is_equal_to(tokens[2]) + lb = ub = _parse_float(tokens[3]) + else + error("Unable to parse bound due to invalid inequalities: $(line)") + end + else + error("Unable to parse bound: $(line)") + end + x = _get_variable_from_name(model, cache, name) + if lb == ub + MOI.add_constraint(model, x, MOI.EqualTo(lb)) + elseif -Inf < lb < ub < Inf + MOI.add_constraint(model, x, MOI.Interval(lb, ub)) + elseif -Inf < lb + MOI.add_constraint(model, x, MOI.GreaterThan(lb)) + else + MOI.add_constraint(model, x, MOI.LessThan(ub)) + end + return +end + +# _KW_INTEGER + +function _parse_section(::typeof(_KW_INTEGER), model, cache, line) + for token in _tokenize(line) + x = _get_variable_from_name(model, cache, token) + MOI.add_constraint(model, x, MOI.Integer()) + end + return +end + +# _KW_BINARY + +function _parse_section(::typeof(_KW_BINARY), model, cache, line) + for token in _tokenize(line) + x = _get_variable_from_name(model, cache, token) + MOI.add_constraint(model, x, MOI.ZeroOne()) + end + return +end + +# _KW_END + +function _parse_section( + ::typeof(_KW_END), + ::Model, + ::_CacheLPModel, + line::AbstractString, +) + return error("Corrupted LP File. You have the lne $(line) after an end.") +end + +""" + Base.read!(io::IO, model::FileFormats.LP.Model) + +Read `io` in the LP file format and store the result in `model`. +""" +function Base.read!(io::IO, model::Model) + if !MOI.is_empty(model) + error("Cannot read in file because model is not empty.") + end + cache = _CacheLPModel() + section = Val{:header}() + while !eof(io) + line = _strip_comment(string(readline(io))) + if isempty(line) + continue + end + lower_line = lowercase(line) + if haskey(_KEYWORDS, lower_line) # section has changed + section = _KEYWORDS[lower_line] + _set_objective_sense(section, model, lower_line) + continue + end + _parse_section(section, model, cache, line) + end + MOI.set( + model, + MOI.ObjectiveFunction{MOI.ScalarAffineFunction{Float64}}(), + cache.objective_function, + ) + return +end diff --git a/test/FileFormats/LP/LP.jl b/test/FileFormats/LP/LP.jl index 3d51f19224..fa72c482fb 100644 --- a/test/FileFormats/LP/LP.jl +++ b/test/FileFormats/LP/LP.jl @@ -4,17 +4,17 @@ import MathOptInterface using Test const MOI = MathOptInterface -const MOIU = MOI.Utilities const LP = MOI.FileFormats.LP const LP_TEST_FILE = "test.lp" function test_show() @test sprint(show, LP.Model()) == "A .LP-file model" + return end function test_comprehensive_write() model = LP.Model() - MOIU.loadfromstring!( + MOI.Utilities.loadfromstring!( model, """ variables: a, x, y, z @@ -177,7 +177,7 @@ end function test_name_sanitization_other() model = LP.Model() - MOIU.loadfromstring!( + MOI.Utilities.loadfromstring!( model, """ variables: x @@ -196,7 +196,7 @@ end function test_free_variables() model = LP.Model() - MOIU.loadfromstring!( + MOI.Utilities.loadfromstring!( model, """ variables: x, y, z @@ -224,7 +224,7 @@ function test_quadratic_objective() model = LP.Model() @test_throws( MOI.UnsupportedAttribute, - MOIU.loadfromstring!( + MOI.Utilities.loadfromstring!( model, """ variables: x @@ -247,7 +247,20 @@ function test_read_example_lo1() constraints @test (MOI.VariableIndex, MOI.GreaterThan{Float64}) in constraints @test (MOI.VariableIndex, MOI.Interval{Float64}) in constraints - return nothing + io = IOBuffer() + write(io, model) + seekstart(io) + file = read(io, String) + @test occursin("maximize", file) + @test occursin("obj: 3 x1 + 1 x2 + 5 x3 + 1 x4", file) + @test occursin("c1: 3 x1 + 1 x2 + 2 x3 = 30", file) + @test occursin("c2: 2 x1 + 1 x2 + 3 x3 + 1 x4 >= 15", file) + @test occursin("c3: 2 x2 + 3 x4 <= 25", file) + @test occursin("x1 >= 0", file) + @test occursin("0 <= x2 <= 10", file) + @test occursin("x3 >= 0", file) + @test occursin("x4 >= 0", file) + return end function test_read_model2() @@ -272,7 +285,7 @@ function test_read_model2() obj_type = MOI.get(model, MOI.ObjectiveFunctionType()) obj_func = MOI.get(model, MOI.ObjectiveFunction{obj_type}()) @test obj_func.constant == 2.5 - return nothing + return end function test_read_model1_tricky() @@ -282,7 +295,7 @@ function test_read_model1_tricky() var_names = MOI.get.(model, MOI.VariableName(), MOI.VariableIndex.(1:8)) @test Set(var_names) == Set(["Var4", "V5", "V1", "V2", "V3", "V6", "V7", "V8"]) - return nothing + return end function test_read_corrupt() @@ -291,7 +304,7 @@ function test_read_corrupt() model, joinpath(@__DIR__, "models", "corrupt.lp"), ) - return nothing + return end function test_read_invalid_variable_name() @@ -300,7 +313,7 @@ function test_read_invalid_variable_name() model, joinpath(@__DIR__, "models", "invalid_variable_name.lp"), ) - return nothing + return end function test_read_invalid_affine_term_objective() @@ -309,7 +322,7 @@ function test_read_invalid_affine_term_objective() model, joinpath(@__DIR__, "models", "invalid_affine_term_objective.lp"), ) - return nothing + return end function test_read_invalid_affine_term_constraint() @@ -318,7 +331,7 @@ function test_read_invalid_affine_term_constraint() model, joinpath(@__DIR__, "models", "invalid_affine_term_constraint.lp"), ) - return nothing + return end function test_read_invalid_sos_set() @@ -327,7 +340,7 @@ function test_read_invalid_sos_set() model, joinpath(@__DIR__, "models", "invalid_sos_set.lp"), ) - return nothing + return end function test_read_invalid_sos_constraint() @@ -336,7 +349,7 @@ function test_read_invalid_sos_constraint() model, joinpath(@__DIR__, "models", "invalid_sos_constraint.lp"), ) - return nothing + return end function test_read_invalid_bound() @@ -345,7 +358,7 @@ function test_read_invalid_bound() model, joinpath(@__DIR__, "models", "invalid_bound.lp"), ) - return nothing + return end function test_read_invalid_constraint() @@ -354,7 +367,7 @@ function test_read_invalid_constraint() model, joinpath(@__DIR__, "models", "invalid_constraint.lp"), ) - return nothing + return end function test_read_model1() @@ -379,7 +392,26 @@ function test_read_model1() MathOptInterface.VectorOfVariables, MathOptInterface.SOS2{Float64}, ) in constraints - return nothing + return +end + +function test_objective_sense() + model = LP.Model() + cases = Dict( + "max" => MOI.MAX_SENSE, + "maximize" => MOI.MAX_SENSE, + "maximise" => MOI.MAX_SENSE, + "maximum" => MOI.MAX_SENSE, + "min" => MOI.MIN_SENSE, + "minimize" => MOI.MIN_SENSE, + "minimise" => MOI.MIN_SENSE, + "minimum" => MOI.MIN_SENSE, + ) + for (sense, result) in cases + LP._set_objective_sense(LP._KW_OBJECTIVE, model, sense) + @test MOI.get(model, MOI.ObjectiveSense()) == result + end + return end function runtests() From 7781be3096c81e6a0a6c50c4fde26d73afb6e1a8 Mon Sep 17 00:00:00 2001 From: odow Date: Thu, 31 Mar 2022 11:10:59 +1300 Subject: [PATCH 2/7] Updates --- src/FileFormats/LP/read.jl | 94 ++++++++++++++++++-------------------- 1 file changed, 44 insertions(+), 50 deletions(-) diff --git a/src/FileFormats/LP/read.jl b/src/FileFormats/LP/read.jl index 63e8f093ad..6b548da1fc 100644 --- a/src/FileFormats/LP/read.jl +++ b/src/FileFormats/LP/read.jl @@ -59,12 +59,12 @@ const _CONSTRAINT_SENSE = Dict( mutable struct _CacheLPModel objective_function::MOI.ScalarAffineFunction{Float64} - linear_constraint_function::MOI.ScalarAffineFunction{Float64} - linear_constraint_set::MOI.AbstractScalarSet - linear_constraint_open::Bool - linear_constraint_name::String - num_linear_constraints::Int - variables_in_model::Dict{String,MOI.VariableIndex} + constraint_function::MOI.ScalarAffineFunction{Float64} + constraint_set::MOI.AbstractScalarSet + constraint_open::Bool + contraint_name::String + num_constraints::Int + name_to_variable::Dict{String,MOI.VariableIndex} function _CacheLPModel() return new( MOI.ScalarAffineFunction(MOI.ScalarAffineTerm{Float64}[], 0.0), @@ -94,7 +94,7 @@ function _get_variable_from_name( cache::_CacheLPModel, name::String, ) - current_variable = get(cache.variables_in_model, name, nothing) + current_variable = get(cache.name_to_variable, name, nothing) if current_variable !== nothing return current_variable end @@ -102,7 +102,7 @@ function _get_variable_from_name( _verify_name(name, options.maximum_length) x = MOI.add_variable(model) MOI.set(model, MOI.VariableName(), x, name) - cache.variables_in_model[name] = x + cache.name_to_variable[name] = x return x end @@ -137,8 +137,7 @@ function _get_term(token_types, token_values, offset) error("Invalid line") end if offset > length(token_types) || token_types[offset] == _TOKEN_SIGN - # It's a standalone constant! - return coef, offset + return coef, offset # It's a standalone constant! end @assert token_types[offset] == _TOKEN_VARIABLE x = MOI.VariableIndex(Int64(token_values[offset])) @@ -166,8 +165,7 @@ function _parse_affine_terms( token_values[i] = Float64(x.value) end end - offset = 1 - constant = 0.0 + offset, constant = 1, 0.0 while offset <= length(tokens) term, offset = _get_term(token_types, token_values, offset) if term isa MOI.ScalarAffineTerm{Float64} @@ -207,8 +205,7 @@ function _parse_section( cache::_CacheLPModel, line::AbstractString, ) - if occursin(":", line) - # Strip name of the objective + if occursin(":", line) # Strip name of the objective line = String(match(r"(.*?)\:(.*)", line)[2]) end tokens = _tokenize(line) @@ -230,9 +227,9 @@ function _parse_sos_constraint( ) tokens = _tokenize(line) if length(tokens) < 3 - error(string("Malformed SOS constraint: ", line)) + error("Malformed SOS constraint: $(line)") end - sos_con_name = String.(split(tokens[1], ":"))[1] + name = String(split(tokens[1], ":")[1]) if tokens[2] == "S1::" order = 1 elseif tokens[2] == "S2::" @@ -240,23 +237,21 @@ function _parse_sos_constraint( else error("SOS of type $(tokens[2]) not recognised") end - variables = MOI.VariableIndex[] - weights = Float64[] + variables, weights = MOI.VariableIndex[], Float64[] for token in tokens[3:end] items = String.(split(token, ":")) if length(items) != 2 - error(string("Invalid sequence: ", token)) + error("Invalid sequence: $(token)") end push!(variables, _get_variable_from_name(model, cache, items[1])) push!(weights, parse(Float64, items[2])) end - sos_con = MOI.add_constraint( + c_ref = MOI.add_constraint( model, variables, order == 1 ? MOI.SOS1(weights) : MOI.SOS2(weights), ) - MOI.set(model, MOI.ConstraintName(), sos_con, sos_con_name) - # TODO I think this only works for SOS of one line + MOI.set(model, MOI.ConstraintName(), c_ref, name) return end @@ -270,68 +265,68 @@ function _parse_section( _parse_sos_constraint(model, cache, line) return end - if cache.linear_constraint_open == false + if cache.constraint_open == false # We're starting a new constraint. Give it a name for now, but we might # replace it with a proper strinng in the next if-block. - cache.linear_constraint_name = "R$(cache.num_linear_constraints)" + cache.contraint_name = "R$(cache.num_constraints)" end if occursin(":", line) - if cache.linear_constraint_open == true + if cache.constraint_open == true error("Malformed constraint $(line). Is the previous one valid?") end m = match(r"(.*?)\:(.*)", line) - cache.linear_constraint_name = String(m[1]) + cache.contraint_name = String(m[1]) line = String(m[2]) end - cache.linear_constraint_open = true - # Now start parsing the coefficients. + cache.constraint_open = true tokens = _tokenize(line) - if length(tokens) == 0 # no entries + if length(tokens) == 0 return end is_finished = false + # This checks if the constaint is finishing on this like. if length(tokens) >= 2 && tokens[end-1] in ("<", "<=", ">", ">=", "=", "==") rhs = parse(Float64, pop!(tokens)) sym = pop!(tokens) if sym in ("<", "<=") - cache.linear_constraint_set = MOI.LessThan(rhs) + cache.constraint_set = MOI.LessThan(rhs) elseif sym in (">", ">=") - cache.linear_constraint_set = MOI.GreaterThan(rhs) + cache.constraint_set = MOI.GreaterThan(rhs) elseif sym in ("=", "==") - cache.linear_constraint_set = MOI.EqualTo(rhs) + cache.constraint_set = MOI.EqualTo(rhs) end is_finished = true end - terms = cache.linear_constraint_function.terms + terms = cache.constraint_function.terms constant = _parse_affine_terms(terms, model, cache, tokens) - cache.linear_constraint_function.constant += constant + cache.constraint_function.constant += constant if is_finished c = MOI.add_constraint( model, - cache.linear_constraint_function, - cache.linear_constraint_set, + cache.constraint_function, + cache.constraint_set, ) - MOI.set(model, MOI.ConstraintName(), c, cache.linear_constraint_name) - cache.num_linear_constraints += 1 - cache.linear_constraint_set = MOI.EqualTo(0.0) - cache.linear_constraint_function = - MOI.ScalarAffineFunction(MOI.ScalarAffineTerm{Float64}[], 0.0) - cache.linear_constraint_name = "" - cache.linear_constraint_open = false + MOI.set(model, MOI.ConstraintName(), c, cache.contraint_name) + cache.num_constraints += 1 + cache.constraint_set = MOI.EqualTo(0.0) + empty!(cache.constraint_function.terms) + cache.constraint_function.constant = 0.0 + cache.contraint_name = "" + cache.constraint_open = false end return end # _KW_BOUNDS -function _parse_float(val::String) - lower_case_val = lowercase(val) - if lower_case_val in ("-inf", "-infinity") +function _parse_float(token::String) + coef = lowercase(token) + if coef in ("-inf", "-infinity") return -Inf - elseif lower_case_val in ("+inf", "+infinity") + elseif coef in ("+inf", "+infinity") return Inf else - return parse(Float64, lower_case_val) + return parse(Float64, coef) end end @@ -347,8 +342,7 @@ function _parse_section( ) tokens = _tokenize(line) if length(tokens) == 2 && tokens[2] == "free" - # Do nothing. Variable is free - return + return # Do nothing. Variable is free end lb, ub, name = -Inf, Inf, "" if length(tokens) == 5 From 7ed0a0460988a27af6254cc3088b653362f6594f Mon Sep 17 00:00:00 2001 From: odow Date: Thu, 31 Mar 2022 11:30:46 +1300 Subject: [PATCH 3/7] Add more tests --- test/FileFormats/LP/LP.jl | 162 +++++++----------- .../{corrupt.lp => invalid_after_end.lp} | 0 2 files changed, 64 insertions(+), 98 deletions(-) rename test/FileFormats/LP/models/{corrupt.lp => invalid_after_end.lp} (100%) diff --git a/test/FileFormats/LP/LP.jl b/test/FileFormats/LP/LP.jl index fa72c482fb..f3b7112677 100644 --- a/test/FileFormats/LP/LP.jl +++ b/test/FileFormats/LP/LP.jl @@ -234,6 +234,22 @@ minobjective: 1.0*x*x ) end +### +### Read tests +### + +function test_read_invalid() + models = joinpath(@__DIR__, "models") + for filename in filter(f -> startswith(f, "invalid_"), readdir(models)) + model = LP.Model() + @test_throws( + ErrorException, + MOI.read_from_file(model, joinpath(models, filename)), + ) + end + return +end + function test_read_example_lo1() model = LP.Model() MOI.read_from_file(model, joinpath(@__DIR__, "models", "example_lo1.lp")) @@ -263,31 +279,6 @@ function test_read_example_lo1() return end -function test_read_model2() - model = LP.Model() - MOI.read_from_file(model, joinpath(@__DIR__, "models", "model2.lp")) - @test MOI.get(model, MOI.NumberOfVariables()) == 8 - constraints = MOI.get(model, MOI.ListOfConstraintTypesPresent()) - @test (MOI.ScalarAffineFunction{Float64}, MOI.GreaterThan{Float64}) in - constraints - @test (MOI.ScalarAffineFunction{Float64}, MOI.LessThan{Float64}) in - constraints - @test (MOI.VariableIndex, MOI.GreaterThan{Float64}) in constraints - @test (MOI.VariableIndex, MOI.Interval{Float64}) in constraints - @test (MathOptInterface.VariableIndex, MathOptInterface.Integer) in - constraints - @test (MathOptInterface.VariableIndex, MathOptInterface.ZeroOne) in - constraints - # Adicionar testes dos bounds de V8 - @test MOI.get(model, MOI.VariableName(), MOI.VariableIndex(8)) == "V8" - @test model.variables.lower[8] == -Inf - @test model.variables.upper[8] == -3 - obj_type = MOI.get(model, MOI.ObjectiveFunctionType()) - obj_func = MOI.get(model, MOI.ObjectiveFunction{obj_type}()) - @test obj_func.constant == 2.5 - return -end - function test_read_model1_tricky() model = LP.Model() MOI.read_from_file(model, joinpath(@__DIR__, "models", "model1_tricky.lp")) @@ -295,78 +286,28 @@ function test_read_model1_tricky() var_names = MOI.get.(model, MOI.VariableName(), MOI.VariableIndex.(1:8)) @test Set(var_names) == Set(["Var4", "V5", "V1", "V2", "V3", "V6", "V7", "V8"]) - return -end - -function test_read_corrupt() - model = LP.Model() - @test_throws ErrorException MOI.read_from_file( - model, - joinpath(@__DIR__, "models", "corrupt.lp"), - ) - return -end - -function test_read_invalid_variable_name() - model = LP.Model() - @test_throws ErrorException MOI.read_from_file( - model, - joinpath(@__DIR__, "models", "invalid_variable_name.lp"), - ) - return -end - -function test_read_invalid_affine_term_objective() - model = LP.Model() - @test_throws ErrorException MOI.read_from_file( - model, - joinpath(@__DIR__, "models", "invalid_affine_term_objective.lp"), - ) - return -end - -function test_read_invalid_affine_term_constraint() - model = LP.Model() - @test_throws ErrorException MOI.read_from_file( - model, - joinpath(@__DIR__, "models", "invalid_affine_term_constraint.lp"), - ) - return -end - -function test_read_invalid_sos_set() - model = LP.Model() - @test_throws ErrorException MOI.read_from_file( - model, - joinpath(@__DIR__, "models", "invalid_sos_set.lp"), - ) - return -end - -function test_read_invalid_sos_constraint() - model = LP.Model() - @test_throws ErrorException MOI.read_from_file( - model, - joinpath(@__DIR__, "models", "invalid_sos_constraint.lp"), - ) - return -end - -function test_read_invalid_bound() - model = LP.Model() - @test_throws ErrorException MOI.read_from_file( - model, - joinpath(@__DIR__, "models", "invalid_bound.lp"), - ) - return -end - -function test_read_invalid_constraint() - model = LP.Model() - @test_throws ErrorException MOI.read_from_file( - model, - joinpath(@__DIR__, "models", "invalid_constraint.lp"), - ) + io = IOBuffer() + write(io, model) + seekstart(io) + file = read(io, String) + @test occursin("maximize", file) + @test occursin("obj: -1 Var4 + 1 V5", 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("R1: 1 V2 >= 2", file) + @test occursin("V1 <= 3", file) + @test occursin("Var4 >= 5.5", file) + @test occursin("V3 >= -3", file) + @test occursin("V5 = 1", file) + @test occursin("0 <= V2 <= 3", file) + @test occursin("0 <= V7 <= 1", file) + @test occursin("0 <= V8 <= 1", file) + @test occursin("V6 free", file) + @test occursin("\nVar4\n", file) + @test occursin("\nV5\n", file) + @test occursin("\nV6\n", file) + @test occursin("Binary\nV8\n", file) return end @@ -395,7 +336,32 @@ function test_read_model1() return end -function test_objective_sense() +function test_read_model2() + model = LP.Model() + MOI.read_from_file(model, joinpath(@__DIR__, "models", "model2.lp")) + @test MOI.get(model, MOI.NumberOfVariables()) == 8 + constraints = MOI.get(model, MOI.ListOfConstraintTypesPresent()) + @test (MOI.ScalarAffineFunction{Float64}, MOI.GreaterThan{Float64}) in + constraints + @test (MOI.ScalarAffineFunction{Float64}, MOI.LessThan{Float64}) in + constraints + @test (MOI.VariableIndex, MOI.GreaterThan{Float64}) in constraints + @test (MOI.VariableIndex, MOI.Interval{Float64}) in constraints + @test (MathOptInterface.VariableIndex, MathOptInterface.Integer) in + constraints + @test (MathOptInterface.VariableIndex, MathOptInterface.ZeroOne) in + constraints + # Adicionar testes dos bounds de V8 + @test MOI.get(model, MOI.VariableName(), MOI.VariableIndex(8)) == "V8" + @test model.variables.lower[8] == -Inf + @test model.variables.upper[8] == -3 + obj_type = MOI.get(model, MOI.ObjectiveFunctionType()) + obj_func = MOI.get(model, MOI.ObjectiveFunction{obj_type}()) + @test obj_func.constant == 2.5 + return +end + +function test_read_objective_sense() model = LP.Model() cases = Dict( "max" => MOI.MAX_SENSE, diff --git a/test/FileFormats/LP/models/corrupt.lp b/test/FileFormats/LP/models/invalid_after_end.lp similarity index 100% rename from test/FileFormats/LP/models/corrupt.lp rename to test/FileFormats/LP/models/invalid_after_end.lp From ad3a0fbf7205069dbbdbdda02edf554cf03c2a0c Mon Sep 17 00:00:00 2001 From: odow Date: Thu, 31 Mar 2022 12:04:20 +1300 Subject: [PATCH 4/7] Clean up --- src/FileFormats/LP/read.jl | 172 +++++++----------- test/FileFormats/LP/models/example_lo1.lp | 2 +- .../models/invalid_affine_term_constraint.lp | 2 +- .../models/invalid_affine_term_objective.lp | 2 +- test/FileFormats/LP/models/invalid_bound.lp | 2 +- .../LP/models/invalid_constraint.lp | 2 +- .../LP/models/invalid_variable_name.lp | 2 +- test/FileFormats/LP/models/model1_tricky.lp | 6 +- 8 files changed, 78 insertions(+), 112 deletions(-) diff --git a/src/FileFormats/LP/read.jl b/src/FileFormats/LP/read.jl index 6b548da1fc..e6443c1189 100644 --- a/src/FileFormats/LP/read.jl +++ b/src/FileFormats/LP/read.jl @@ -1,16 +1,3 @@ -const _COMMENT_REG = r"(.*?)\\(.*)" - -const _READ_START_REG = r"^([\.0-9])" - -function _strip_comment(line::String) - if occursin("\\", line) - m = match(_COMMENT_REG, line) - return strip(String(m[1])) - else - return strip(line) - end -end - const _KW_OBJECTIVE = Val{:objective}() const _KW_CONSTRAINTS = Val{:constraints}() const _KW_BOUNDS = Val{:bounds}() @@ -48,29 +35,16 @@ const _KEYWORDS = Dict( "end" => _KW_END, ) -const _CONSTRAINT_SENSE = Dict( - "<" => :le, - "<=" => :le, - "=" => :eq, - "==" => :eq, - ">" => :ge, - ">=" => :ge, -) - -mutable struct _CacheLPModel - objective_function::MOI.ScalarAffineFunction{Float64} +mutable struct _ReadCache + objective::MOI.ScalarAffineFunction{Float64} constraint_function::MOI.ScalarAffineFunction{Float64} - constraint_set::MOI.AbstractScalarSet - constraint_open::Bool - contraint_name::String + constraint_name::String num_constraints::Int name_to_variable::Dict{String,MOI.VariableIndex} - function _CacheLPModel() + function _ReadCache() return new( MOI.ScalarAffineFunction(MOI.ScalarAffineTerm{Float64}[], 0.0), MOI.ScalarAffineFunction(MOI.ScalarAffineTerm{Float64}[], 0.0), - MOI.EqualTo(0.0), - false, "", 0, Dict{String,MOI.VariableIndex}(), @@ -78,28 +52,19 @@ mutable struct _CacheLPModel end end -function _verify_name(name::String, maximum_length::Int) - if length(name) > maximum_length - error("Name exceeds maximum length: $name") - elseif match(_READ_START_REG, name) !== nothing - error("Name starts with invalid character: $name") - elseif match(NAME_REG, name) !== nothing - error("Name contains with invalid character: $name") - end - return -end - -function _get_variable_from_name( - model::Model, - cache::_CacheLPModel, - name::String, -) +function _get_variable_from_name(model::Model, cache::_ReadCache, name::String) current_variable = get(cache.name_to_variable, name, nothing) if current_variable !== nothing return current_variable end options = get_options(model) - _verify_name(name, options.maximum_length) + if length(name) > options.maximum_length + error("Name exceeds maximum length: $name") + elseif match(r"^([\.0-9])", name) !== nothing + error("Name starts with invalid character: $name") + elseif match(NAME_REG, name) !== nothing + error("Name contains with invalid character: $name") + end x = MOI.add_variable(model) MOI.set(model, MOI.VariableName(), x, name) cache.name_to_variable[name] = x @@ -145,9 +110,9 @@ function _get_term(token_types, token_values, offset) end function _parse_affine_terms( - terms::Vector{MOI.ScalarAffineTerm{Float64}}, + f::MOI.ScalarAffineFunction{Float64}, model::Model, - cache::_CacheLPModel, + cache::_ReadCache, tokens::Vector{String}, ) N = length(tokens) @@ -165,21 +130,21 @@ function _parse_affine_terms( token_values[i] = Float64(x.value) end end - offset, constant = 1, 0.0 + offset = 1 while offset <= length(tokens) term, offset = _get_term(token_types, token_values, offset) if term isa MOI.ScalarAffineTerm{Float64} - push!(terms, term::MOI.ScalarAffineTerm{Float64}) + push!(f.terms, term::MOI.ScalarAffineTerm{Float64}) else - constant += term::Float64 + f.constant += term::Float64 end end - return constant + return end # _KW_HEADER -_parse_section(::Val{:header}, ::Model, ::_CacheLPModel, ::Any) = nothing +_parse_section(::Val{:header}, ::Model, ::_ReadCache, ::Any) = nothing # _KW_OBJECTIVE @@ -202,7 +167,7 @@ end function _parse_section( ::typeof(_KW_OBJECTIVE), model::Model, - cache::_CacheLPModel, + cache::_ReadCache, line::AbstractString, ) if occursin(":", line) # Strip name of the objective @@ -210,11 +175,11 @@ function _parse_section( 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 - terms = cache.objective_function.terms - constant = _parse_affine_terms(terms, model, cache, tokens) - cache.objective_function.constant += constant + _parse_affine_terms(cache.objective, model, cache, tokens) return end @@ -222,7 +187,7 @@ end function _parse_sos_constraint( model::Model, - cache::_CacheLPModel, + cache::_ReadCache, line::AbstractString, ) tokens = _tokenize(line) @@ -246,11 +211,12 @@ function _parse_sos_constraint( push!(variables, _get_variable_from_name(model, cache, items[1])) push!(weights, parse(Float64, items[2])) end - c_ref = MOI.add_constraint( - model, - variables, - order == 1 ? MOI.SOS1(weights) : MOI.SOS2(weights), - ) + 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 @@ -258,61 +224,50 @@ end function _parse_section( ::typeof(_KW_CONSTRAINTS), model::Model, - cache::_CacheLPModel, + cache::_ReadCache, line::AbstractString, ) - if match(r" S([0-9]):: ", line) !== nothing + if match(r" S([1-2]):: ", line) !== nothing _parse_sos_constraint(model, cache, line) return end - if cache.constraint_open == false - # We're starting a new constraint. Give it a name for now, but we might - # replace it with a proper strinng in the next if-block. - cache.contraint_name = "R$(cache.num_constraints)" - end - if occursin(":", line) - if cache.constraint_open == true - error("Malformed constraint $(line). Is the previous one valid?") + if isempty(cache.constraint_name) + if occursin(":", line) + m = match(r"(.*?)\:(.*)", line) + cache.constraint_name = String(m[1]) + line = String(m[2]) + else + # Give it a temporary name for now + cache.constraint_name = "R$(cache.num_constraints)" end - m = match(r"(.*?)\:(.*)", line) - cache.contraint_name = String(m[1]) - line = String(m[2]) end - cache.constraint_open = true tokens = _tokenize(line) if length(tokens) == 0 + # Can happen if the name is on one line and the constraint on the next. return end - is_finished = false - # This checks if the constaint is finishing on this like. + # This checks if the constaint is finishing on this line. + constraint_set = nothing if length(tokens) >= 2 && tokens[end-1] in ("<", "<=", ">", ">=", "=", "==") rhs = parse(Float64, pop!(tokens)) sym = pop!(tokens) - if sym in ("<", "<=") - cache.constraint_set = MOI.LessThan(rhs) + constraint_set = if sym in ("<", "<=") + MOI.LessThan(rhs) elseif sym in (">", ">=") - cache.constraint_set = MOI.GreaterThan(rhs) - elseif sym in ("=", "==") - cache.constraint_set = MOI.EqualTo(rhs) + MOI.GreaterThan(rhs) + else + @assert sym in ("=", "==") + MOI.EqualTo(rhs) end - is_finished = true end - terms = cache.constraint_function.terms - constant = _parse_affine_terms(terms, model, cache, tokens) - cache.constraint_function.constant += constant - if is_finished - c = MOI.add_constraint( - model, - cache.constraint_function, - cache.constraint_set, - ) - MOI.set(model, MOI.ConstraintName(), c, cache.contraint_name) + _parse_affine_terms(cache.constraint_function, model, cache, tokens) + if constraint_set !== nothing + c = MOI.add_constraint(model, cache.constraint_function, constraint_set) + MOI.set(model, MOI.ConstraintName(), c, cache.constraint_name) cache.num_constraints += 1 - cache.constraint_set = MOI.EqualTo(0.0) empty!(cache.constraint_function.terms) cache.constraint_function.constant = 0.0 - cache.contraint_name = "" - cache.constraint_open = false + cache.constraint_name = "" end return end @@ -337,7 +292,7 @@ _is_equal_to(token) = token in ("==", "=") function _parse_section( ::typeof(_KW_BOUNDS), model::Model, - cache::_CacheLPModel, + cache::_ReadCache, line::AbstractString, ) tokens = _tokenize(line) @@ -411,12 +366,21 @@ end function _parse_section( ::typeof(_KW_END), ::Model, - ::_CacheLPModel, + ::_ReadCache, line::AbstractString, ) return error("Corrupted LP File. You have the lne $(line) after an end.") end +function _strip_comment(line::String) + if occursin("\\", line) + m = match(r"(.*?)\\(.*)", line) + return strip(String(m[1])) + else + return strip(line) + end +end + """ Base.read!(io::IO, model::FileFormats.LP.Model) @@ -426,7 +390,7 @@ function Base.read!(io::IO, model::Model) if !MOI.is_empty(model) error("Cannot read in file because model is not empty.") end - cache = _CacheLPModel() + cache = _ReadCache() section = Val{:header}() while !eof(io) line = _strip_comment(string(readline(io))) @@ -434,7 +398,7 @@ function Base.read!(io::IO, model::Model) continue end lower_line = lowercase(line) - if haskey(_KEYWORDS, lower_line) # section has changed + if haskey(_KEYWORDS, lower_line) section = _KEYWORDS[lower_line] _set_objective_sense(section, model, lower_line) continue @@ -444,7 +408,7 @@ function Base.read!(io::IO, model::Model) MOI.set( model, MOI.ObjectiveFunction{MOI.ScalarAffineFunction{Float64}}(), - cache.objective_function, + cache.objective, ) return end diff --git a/test/FileFormats/LP/models/example_lo1.lp b/test/FileFormats/LP/models/example_lo1.lp index 815068529c..7e8e3b18f7 100644 --- a/test/FileFormats/LP/models/example_lo1.lp +++ b/test/FileFormats/LP/models/example_lo1.lp @@ -10,4 +10,4 @@ bounds 0 <= x2 <= 10 0 <= x3 <= +infinity 0 <= x4 <= +infinity -end \ No newline at end of file +end diff --git a/test/FileFormats/LP/models/invalid_affine_term_constraint.lp b/test/FileFormats/LP/models/invalid_affine_term_constraint.lp index e4e7b41793..334a6bbd5c 100644 --- a/test/FileFormats/LP/models/invalid_affine_term_constraint.lp +++ b/test/FileFormats/LP/models/invalid_affine_term_constraint.lp @@ -10,4 +10,4 @@ bounds 0 <= x2 <= 10 0 <= x3 <= +infinity 0 <= x4 <= +infinity -end \ No newline at end of file +end diff --git a/test/FileFormats/LP/models/invalid_affine_term_objective.lp b/test/FileFormats/LP/models/invalid_affine_term_objective.lp index 5225aefa92..8844216423 100644 --- a/test/FileFormats/LP/models/invalid_affine_term_objective.lp +++ b/test/FileFormats/LP/models/invalid_affine_term_objective.lp @@ -10,4 +10,4 @@ bounds 0 <= x2 <= 10 0 <= x3 <= +infinity 0 <= x4 <= +infinity -end \ No newline at end of file +end diff --git a/test/FileFormats/LP/models/invalid_bound.lp b/test/FileFormats/LP/models/invalid_bound.lp index ea7679e2b2..3015587726 100644 --- a/test/FileFormats/LP/models/invalid_bound.lp +++ b/test/FileFormats/LP/models/invalid_bound.lp @@ -10,4 +10,4 @@ bounds 0 >= x2 <= 10 0 <= x3 <= +infinity 0 <= x4 <= +infinity -end \ No newline at end of file +end diff --git a/test/FileFormats/LP/models/invalid_constraint.lp b/test/FileFormats/LP/models/invalid_constraint.lp index dcfb890fda..c7d9610ead 100644 --- a/test/FileFormats/LP/models/invalid_constraint.lp +++ b/test/FileFormats/LP/models/invalid_constraint.lp @@ -10,4 +10,4 @@ bounds 0 <= x2 <= 10 0 <= x3 <= +infinity 0 <= x4 <= +infinity -end \ No newline at end of file +end diff --git a/test/FileFormats/LP/models/invalid_variable_name.lp b/test/FileFormats/LP/models/invalid_variable_name.lp index 2024eef4a1..650ac61a92 100644 --- a/test/FileFormats/LP/models/invalid_variable_name.lp +++ b/test/FileFormats/LP/models/invalid_variable_name.lp @@ -10,4 +10,4 @@ bounds 0 <= x2 <= 10 0 <= x3 <= +infinity 0 <= x4 <= +infinity -end \ No newline at end of file +end diff --git a/test/FileFormats/LP/models/model1_tricky.lp b/test/FileFormats/LP/models/model1_tricky.lp index d10d887563..f954f1e829 100644 --- a/test/FileFormats/LP/models/model1_tricky.lp +++ b/test/FileFormats/LP/models/model1_tricky.lp @@ -4,10 +4,12 @@ \ its a terrible idea Max \ this problem is a maximisation! -obj: -1 Var4 +obj: +-1 Var4 + 1 V5 Subject To -CON1: 1 V1 >= 0.0 +CON1: + 1 V1 >= 0.0 1 V2 >= 2.0 \ not named CON3: 1 V3 <= 2.5 CON4: 1 V5 + 1 V6 \ split constraint. we know it hasn't ended as missing operator From 6f82ef478aa07e9f65e0d0bd8d3f63d19649c7bb Mon Sep 17 00:00:00 2001 From: odow Date: Thu, 31 Mar 2022 12:06:16 +1300 Subject: [PATCH 5/7] Merge back to single file --- src/FileFormats/LP/LP.jl | 415 ++++++++++++++++++++++++++++++++++++- src/FileFormats/LP/read.jl | 414 ------------------------------------ 2 files changed, 414 insertions(+), 415 deletions(-) delete mode 100644 src/FileFormats/LP/read.jl diff --git a/src/FileFormats/LP/LP.jl b/src/FileFormats/LP/LP.jl index 36182e96e7..7be9b619d8 100644 --- a/src/FileFormats/LP/LP.jl +++ b/src/FileFormats/LP/LP.jl @@ -286,6 +286,419 @@ end # # ============================================================================== -include("read.jl") +const _KW_OBJECTIVE = Val{:objective}() +const _KW_CONSTRAINTS = Val{:constraints}() +const _KW_BOUNDS = Val{:bounds}() +const _KW_INTEGER = Val{:integer}() +const _KW_BINARY = Val{:binary}() +const _KW_END = Val{:end}() + +const _KEYWORDS = Dict( + # _KW_OBJECTIVE + "max" => _KW_OBJECTIVE, + "maximize" => _KW_OBJECTIVE, + "maximise" => _KW_OBJECTIVE, + "maximum" => _KW_OBJECTIVE, + "min" => _KW_OBJECTIVE, + "minimize" => _KW_OBJECTIVE, + "minimise" => _KW_OBJECTIVE, + "minimum" => _KW_OBJECTIVE, + # _KW_CONSTRAINTS + "subject to" => _KW_CONSTRAINTS, + "such that" => _KW_CONSTRAINTS, + "st" => _KW_CONSTRAINTS, + "s.t." => _KW_CONSTRAINTS, + # _KW_BOUNDS + "bounds" => _KW_BOUNDS, + "bound" => _KW_BOUNDS, + # _KW_INTEGER + "gen" => _KW_INTEGER, + "general" => _KW_INTEGER, + "generals" => _KW_INTEGER, + # _KW_BINARY + "bin" => _KW_BINARY, + "binary" => _KW_BINARY, + "binaries" => _KW_BINARY, + # _KW_END + "end" => _KW_END, +) + +mutable struct _ReadCache + objective::MOI.ScalarAffineFunction{Float64} + constraint_function::MOI.ScalarAffineFunction{Float64} + constraint_name::String + num_constraints::Int + name_to_variable::Dict{String,MOI.VariableIndex} + function _ReadCache() + return new( + MOI.ScalarAffineFunction(MOI.ScalarAffineTerm{Float64}[], 0.0), + MOI.ScalarAffineFunction(MOI.ScalarAffineTerm{Float64}[], 0.0), + "", + 0, + Dict{String,MOI.VariableIndex}(), + ) + end +end + +function _get_variable_from_name(model::Model, cache::_ReadCache, name::String) + current_variable = get(cache.name_to_variable, name, nothing) + if current_variable !== nothing + return current_variable + end + options = get_options(model) + if length(name) > options.maximum_length + error("Name exceeds maximum length: $name") + elseif match(r"^([\.0-9])", name) !== nothing + error("Name starts with invalid character: $name") + elseif match(NAME_REG, name) !== nothing + error("Name contains with invalid character: $name") + end + x = MOI.add_variable(model) + MOI.set(model, MOI.VariableName(), x, name) + cache.name_to_variable[name] = x + return x +end + +_tokenize(line::AbstractString) = String.(split(line, " "; keepempty = false)) + +@enum(_TokenType, _TOKEN_VARIABLE, _TOKEN_COEFFICIENT, _TOKEN_SIGN) + +function _parse_token(token::String) + if token == "+" + return _TOKEN_SIGN, +1.0 + elseif token == "-" + return _TOKEN_SIGN, -1.0 + end + coef = tryparse(Float64, token) + if coef === nothing + return _TOKEN_VARIABLE, token + else + return _TOKEN_COEFFICIENT, coef + end +end + +function _get_term(token_types, token_values, offset) + coef = 1.0 + if token_types[offset] == _TOKEN_SIGN + coef = token_values[offset] + offset += 1 + end + if token_types[offset] == _TOKEN_COEFFICIENT + coef *= token_values[offset] + offset += 1 + elseif token_types[offset] == _TOKEN_SIGN + error("Invalid line") + end + if offset > length(token_types) || token_types[offset] == _TOKEN_SIGN + return coef, offset # It's a standalone constant! + end + @assert token_types[offset] == _TOKEN_VARIABLE + x = MOI.VariableIndex(Int64(token_values[offset])) + return MOI.ScalarAffineTerm(coef, x), offset + 1 +end + +function _parse_affine_terms( + f::MOI.ScalarAffineFunction{Float64}, + model::Model, + cache::_ReadCache, + tokens::Vector{String}, +) + N = length(tokens) + token_types = Vector{_TokenType}(undef, N) + token_values = Vector{Float64}(undef, N) + for i in 1:length(tokens) + token_type, token = _parse_token(tokens[i]) + token_types[i] = token_type + if token_type in (_TOKEN_SIGN, _TOKEN_COEFFICIENT) + token_values[i] = token::Float64 + else + @assert token_type == _TOKEN_VARIABLE + x = _get_variable_from_name(model, cache, token::String) + # A cheat for type-stability. Store `Float64` of the variable index! + token_values[i] = Float64(x.value) + end + end + offset = 1 + while offset <= length(tokens) + term, offset = _get_term(token_types, token_values, offset) + if term isa MOI.ScalarAffineTerm{Float64} + push!(f.terms, term::MOI.ScalarAffineTerm{Float64}) + else + f.constant += term::Float64 + end + end + return +end + +# _KW_HEADER + +_parse_section(::Val{:header}, ::Model, ::_ReadCache, ::Any) = nothing + +# _KW_OBJECTIVE + +_set_objective_sense(::Any, ::Model, ::String) = nothing + +function _set_objective_sense( + ::typeof(_KW_OBJECTIVE), + model::Model, + sense::String, +) + if sense in ("max", "maximize", "maximise", "maximum") + MOI.set(model, MOI.ObjectiveSense(), MOI.MAX_SENSE) + else + @assert sense in ("min", "minimize", "minimise", "minimum") + MOI.set(model, MOI.ObjectiveSense(), MOI.MIN_SENSE) + end + return +end + +function _parse_section( + ::typeof(_KW_OBJECTIVE), + model::Model, + cache::_ReadCache, + line::AbstractString, +) + if occursin(":", line) # Strip name of the objective + line = String(match(r"(.*?)\:(.*)", line)[2]) + 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) + return +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) + return + end + if isempty(cache.constraint_name) + if occursin(":", line) + m = match(r"(.*?)\:(.*)", line) + cache.constraint_name = String(m[1]) + line = String(m[2]) + else + # Give it a temporary name for now + cache.constraint_name = "R$(cache.num_constraints)" + end + end + tokens = _tokenize(line) + if length(tokens) == 0 + # Can happen if the name is on one line and the constraint on the next. + return + end + # This checks if the constaint is finishing on this line. + constraint_set = nothing + if length(tokens) >= 2 && tokens[end-1] in ("<", "<=", ">", ">=", "=", "==") + rhs = parse(Float64, pop!(tokens)) + sym = pop!(tokens) + constraint_set = if sym in ("<", "<=") + MOI.LessThan(rhs) + elseif sym in (">", ">=") + MOI.GreaterThan(rhs) + else + @assert sym in ("=", "==") + MOI.EqualTo(rhs) + end + end + _parse_affine_terms(cache.constraint_function, model, cache, tokens) + if constraint_set !== nothing + c = MOI.add_constraint(model, cache.constraint_function, constraint_set) + MOI.set(model, MOI.ConstraintName(), c, cache.constraint_name) + cache.num_constraints += 1 + empty!(cache.constraint_function.terms) + cache.constraint_function.constant = 0.0 + cache.constraint_name = "" + end + return +end + +# _KW_BOUNDS + +function _parse_float(token::String) + coef = lowercase(token) + if coef in ("-inf", "-infinity") + return -Inf + elseif coef in ("+inf", "+infinity") + return Inf + else + return parse(Float64, coef) + end +end + +_is_less_than(token) = token in ("<=", "<") +_is_greater_than(token) = token in (">=", ">") +_is_equal_to(token) = token in ("==", "=") + +function _parse_section( + ::typeof(_KW_BOUNDS), + model::Model, + cache::_ReadCache, + line::AbstractString, +) + tokens = _tokenize(line) + if length(tokens) == 2 && tokens[2] == "free" + return # Do nothing. Variable is free + end + lb, ub, name = -Inf, Inf, "" + if length(tokens) == 5 + name = tokens[3] + if _is_less_than(tokens[2]) && _is_less_than(tokens[4]) + lb = _parse_float(tokens[1]) + ub = _parse_float(tokens[5]) + elseif _is_greater_than(tokens[2]) && _is_greater_than(tokens[4]) + lb = _parse_float(tokens[5]) + ub = _parse_float(tokens[1]) + else + error("Unable to parse bound due to invalid inequalities: $(line)") + end + elseif length(tokens) == 3 + name = tokens[1] + if _is_less_than(tokens[2]) + ub = _parse_float(tokens[3]) + # LP files have default lower bounds of 0, unless the upper bound is + # less than 0. + lb = ub > 0.0 ? 0.0 : -Inf + elseif _is_greater_than(tokens[2]) + lb = _parse_float(tokens[3]) + elseif _is_equal_to(tokens[2]) + lb = ub = _parse_float(tokens[3]) + else + error("Unable to parse bound due to invalid inequalities: $(line)") + end + else + error("Unable to parse bound: $(line)") + end + x = _get_variable_from_name(model, cache, name) + if lb == ub + MOI.add_constraint(model, x, MOI.EqualTo(lb)) + elseif -Inf < lb < ub < Inf + MOI.add_constraint(model, x, MOI.Interval(lb, ub)) + elseif -Inf < lb + MOI.add_constraint(model, x, MOI.GreaterThan(lb)) + else + MOI.add_constraint(model, x, MOI.LessThan(ub)) + end + return +end + +# _KW_INTEGER + +function _parse_section(::typeof(_KW_INTEGER), model, cache, line) + for token in _tokenize(line) + x = _get_variable_from_name(model, cache, token) + MOI.add_constraint(model, x, MOI.Integer()) + end + return +end + +# _KW_BINARY + +function _parse_section(::typeof(_KW_BINARY), model, cache, line) + for token in _tokenize(line) + x = _get_variable_from_name(model, cache, token) + MOI.add_constraint(model, x, MOI.ZeroOne()) + end + return +end + +# _KW_END + +function _parse_section( + ::typeof(_KW_END), + ::Model, + ::_ReadCache, + line::AbstractString, +) + return error("Corrupted LP File. You have the lne $(line) after an end.") +end + +function _strip_comment(line::String) + if occursin("\\", line) + m = match(r"(.*?)\\(.*)", line) + return strip(String(m[1])) + else + return strip(line) + end +end + +""" + Base.read!(io::IO, model::FileFormats.LP.Model) + +Read `io` in the LP file format and store the result in `model`. +""" +function Base.read!(io::IO, model::Model) + if !MOI.is_empty(model) + error("Cannot read in file because model is not empty.") + end + cache = _ReadCache() + section = Val{:header}() + while !eof(io) + line = _strip_comment(string(readline(io))) + if isempty(line) + continue + end + lower_line = lowercase(line) + if haskey(_KEYWORDS, lower_line) + section = _KEYWORDS[lower_line] + _set_objective_sense(section, model, lower_line) + continue + end + _parse_section(section, model, cache, line) + end + MOI.set( + model, + MOI.ObjectiveFunction{MOI.ScalarAffineFunction{Float64}}(), + cache.objective, + ) + return +end end diff --git a/src/FileFormats/LP/read.jl b/src/FileFormats/LP/read.jl deleted file mode 100644 index e6443c1189..0000000000 --- a/src/FileFormats/LP/read.jl +++ /dev/null @@ -1,414 +0,0 @@ -const _KW_OBJECTIVE = Val{:objective}() -const _KW_CONSTRAINTS = Val{:constraints}() -const _KW_BOUNDS = Val{:bounds}() -const _KW_INTEGER = Val{:integer}() -const _KW_BINARY = Val{:binary}() -const _KW_END = Val{:end}() - -const _KEYWORDS = Dict( - # _KW_OBJECTIVE - "max" => _KW_OBJECTIVE, - "maximize" => _KW_OBJECTIVE, - "maximise" => _KW_OBJECTIVE, - "maximum" => _KW_OBJECTIVE, - "min" => _KW_OBJECTIVE, - "minimize" => _KW_OBJECTIVE, - "minimise" => _KW_OBJECTIVE, - "minimum" => _KW_OBJECTIVE, - # _KW_CONSTRAINTS - "subject to" => _KW_CONSTRAINTS, - "such that" => _KW_CONSTRAINTS, - "st" => _KW_CONSTRAINTS, - "s.t." => _KW_CONSTRAINTS, - # _KW_BOUNDS - "bounds" => _KW_BOUNDS, - "bound" => _KW_BOUNDS, - # _KW_INTEGER - "gen" => _KW_INTEGER, - "general" => _KW_INTEGER, - "generals" => _KW_INTEGER, - # _KW_BINARY - "bin" => _KW_BINARY, - "binary" => _KW_BINARY, - "binaries" => _KW_BINARY, - # _KW_END - "end" => _KW_END, -) - -mutable struct _ReadCache - objective::MOI.ScalarAffineFunction{Float64} - constraint_function::MOI.ScalarAffineFunction{Float64} - constraint_name::String - num_constraints::Int - name_to_variable::Dict{String,MOI.VariableIndex} - function _ReadCache() - return new( - MOI.ScalarAffineFunction(MOI.ScalarAffineTerm{Float64}[], 0.0), - MOI.ScalarAffineFunction(MOI.ScalarAffineTerm{Float64}[], 0.0), - "", - 0, - Dict{String,MOI.VariableIndex}(), - ) - end -end - -function _get_variable_from_name(model::Model, cache::_ReadCache, name::String) - current_variable = get(cache.name_to_variable, name, nothing) - if current_variable !== nothing - return current_variable - end - options = get_options(model) - if length(name) > options.maximum_length - error("Name exceeds maximum length: $name") - elseif match(r"^([\.0-9])", name) !== nothing - error("Name starts with invalid character: $name") - elseif match(NAME_REG, name) !== nothing - error("Name contains with invalid character: $name") - end - x = MOI.add_variable(model) - MOI.set(model, MOI.VariableName(), x, name) - cache.name_to_variable[name] = x - return x -end - -_tokenize(line::AbstractString) = String.(split(line, " "; keepempty = false)) - -@enum(_TokenType, _TOKEN_VARIABLE, _TOKEN_COEFFICIENT, _TOKEN_SIGN) - -function _parse_token(token::String) - if token == "+" - return _TOKEN_SIGN, +1.0 - elseif token == "-" - return _TOKEN_SIGN, -1.0 - end - coef = tryparse(Float64, token) - if coef === nothing - return _TOKEN_VARIABLE, token - else - return _TOKEN_COEFFICIENT, coef - end -end - -function _get_term(token_types, token_values, offset) - coef = 1.0 - if token_types[offset] == _TOKEN_SIGN - coef = token_values[offset] - offset += 1 - end - if token_types[offset] == _TOKEN_COEFFICIENT - coef *= token_values[offset] - offset += 1 - elseif token_types[offset] == _TOKEN_SIGN - error("Invalid line") - end - if offset > length(token_types) || token_types[offset] == _TOKEN_SIGN - return coef, offset # It's a standalone constant! - end - @assert token_types[offset] == _TOKEN_VARIABLE - x = MOI.VariableIndex(Int64(token_values[offset])) - return MOI.ScalarAffineTerm(coef, x), offset + 1 -end - -function _parse_affine_terms( - f::MOI.ScalarAffineFunction{Float64}, - model::Model, - cache::_ReadCache, - tokens::Vector{String}, -) - N = length(tokens) - token_types = Vector{_TokenType}(undef, N) - token_values = Vector{Float64}(undef, N) - for i in 1:length(tokens) - token_type, token = _parse_token(tokens[i]) - token_types[i] = token_type - if token_type in (_TOKEN_SIGN, _TOKEN_COEFFICIENT) - token_values[i] = token::Float64 - else - @assert token_type == _TOKEN_VARIABLE - x = _get_variable_from_name(model, cache, token::String) - # A cheat for type-stability. Store `Float64` of the variable index! - token_values[i] = Float64(x.value) - end - end - offset = 1 - while offset <= length(tokens) - term, offset = _get_term(token_types, token_values, offset) - if term isa MOI.ScalarAffineTerm{Float64} - push!(f.terms, term::MOI.ScalarAffineTerm{Float64}) - else - f.constant += term::Float64 - end - end - return -end - -# _KW_HEADER - -_parse_section(::Val{:header}, ::Model, ::_ReadCache, ::Any) = nothing - -# _KW_OBJECTIVE - -_set_objective_sense(::Any, ::Model, ::String) = nothing - -function _set_objective_sense( - ::typeof(_KW_OBJECTIVE), - model::Model, - sense::String, -) - if sense in ("max", "maximize", "maximise", "maximum") - MOI.set(model, MOI.ObjectiveSense(), MOI.MAX_SENSE) - else - @assert sense in ("min", "minimize", "minimise", "minimum") - MOI.set(model, MOI.ObjectiveSense(), MOI.MIN_SENSE) - end - return -end - -function _parse_section( - ::typeof(_KW_OBJECTIVE), - model::Model, - cache::_ReadCache, - line::AbstractString, -) - if occursin(":", line) # Strip name of the objective - line = String(match(r"(.*?)\:(.*)", line)[2]) - 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) - return -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) - return - end - if isempty(cache.constraint_name) - if occursin(":", line) - m = match(r"(.*?)\:(.*)", line) - cache.constraint_name = String(m[1]) - line = String(m[2]) - else - # Give it a temporary name for now - cache.constraint_name = "R$(cache.num_constraints)" - end - end - tokens = _tokenize(line) - if length(tokens) == 0 - # Can happen if the name is on one line and the constraint on the next. - return - end - # This checks if the constaint is finishing on this line. - constraint_set = nothing - if length(tokens) >= 2 && tokens[end-1] in ("<", "<=", ">", ">=", "=", "==") - rhs = parse(Float64, pop!(tokens)) - sym = pop!(tokens) - constraint_set = if sym in ("<", "<=") - MOI.LessThan(rhs) - elseif sym in (">", ">=") - MOI.GreaterThan(rhs) - else - @assert sym in ("=", "==") - MOI.EqualTo(rhs) - end - end - _parse_affine_terms(cache.constraint_function, model, cache, tokens) - if constraint_set !== nothing - c = MOI.add_constraint(model, cache.constraint_function, constraint_set) - MOI.set(model, MOI.ConstraintName(), c, cache.constraint_name) - cache.num_constraints += 1 - empty!(cache.constraint_function.terms) - cache.constraint_function.constant = 0.0 - cache.constraint_name = "" - end - return -end - -# _KW_BOUNDS - -function _parse_float(token::String) - coef = lowercase(token) - if coef in ("-inf", "-infinity") - return -Inf - elseif coef in ("+inf", "+infinity") - return Inf - else - return parse(Float64, coef) - end -end - -_is_less_than(token) = token in ("<=", "<") -_is_greater_than(token) = token in (">=", ">") -_is_equal_to(token) = token in ("==", "=") - -function _parse_section( - ::typeof(_KW_BOUNDS), - model::Model, - cache::_ReadCache, - line::AbstractString, -) - tokens = _tokenize(line) - if length(tokens) == 2 && tokens[2] == "free" - return # Do nothing. Variable is free - end - lb, ub, name = -Inf, Inf, "" - if length(tokens) == 5 - name = tokens[3] - if _is_less_than(tokens[2]) && _is_less_than(tokens[4]) - lb = _parse_float(tokens[1]) - ub = _parse_float(tokens[5]) - elseif _is_greater_than(tokens[2]) && _is_greater_than(tokens[4]) - lb = _parse_float(tokens[5]) - ub = _parse_float(tokens[1]) - else - error("Unable to parse bound due to invalid inequalities: $(line)") - end - elseif length(tokens) == 3 - name = tokens[1] - if _is_less_than(tokens[2]) - ub = _parse_float(tokens[3]) - # LP files have default lower bounds of 0, unless the upper bound is - # less than 0. - lb = ub > 0.0 ? 0.0 : -Inf - elseif _is_greater_than(tokens[2]) - lb = _parse_float(tokens[3]) - elseif _is_equal_to(tokens[2]) - lb = ub = _parse_float(tokens[3]) - else - error("Unable to parse bound due to invalid inequalities: $(line)") - end - else - error("Unable to parse bound: $(line)") - end - x = _get_variable_from_name(model, cache, name) - if lb == ub - MOI.add_constraint(model, x, MOI.EqualTo(lb)) - elseif -Inf < lb < ub < Inf - MOI.add_constraint(model, x, MOI.Interval(lb, ub)) - elseif -Inf < lb - MOI.add_constraint(model, x, MOI.GreaterThan(lb)) - else - MOI.add_constraint(model, x, MOI.LessThan(ub)) - end - return -end - -# _KW_INTEGER - -function _parse_section(::typeof(_KW_INTEGER), model, cache, line) - for token in _tokenize(line) - x = _get_variable_from_name(model, cache, token) - MOI.add_constraint(model, x, MOI.Integer()) - end - return -end - -# _KW_BINARY - -function _parse_section(::typeof(_KW_BINARY), model, cache, line) - for token in _tokenize(line) - x = _get_variable_from_name(model, cache, token) - MOI.add_constraint(model, x, MOI.ZeroOne()) - end - return -end - -# _KW_END - -function _parse_section( - ::typeof(_KW_END), - ::Model, - ::_ReadCache, - line::AbstractString, -) - return error("Corrupted LP File. You have the lne $(line) after an end.") -end - -function _strip_comment(line::String) - if occursin("\\", line) - m = match(r"(.*?)\\(.*)", line) - return strip(String(m[1])) - else - return strip(line) - end -end - -""" - Base.read!(io::IO, model::FileFormats.LP.Model) - -Read `io` in the LP file format and store the result in `model`. -""" -function Base.read!(io::IO, model::Model) - if !MOI.is_empty(model) - error("Cannot read in file because model is not empty.") - end - cache = _ReadCache() - section = Val{:header}() - while !eof(io) - line = _strip_comment(string(readline(io))) - if isempty(line) - continue - end - lower_line = lowercase(line) - if haskey(_KEYWORDS, lower_line) - section = _KEYWORDS[lower_line] - _set_objective_sense(section, model, lower_line) - continue - end - _parse_section(section, model, cache, line) - end - MOI.set( - model, - MOI.ObjectiveFunction{MOI.ScalarAffineFunction{Float64}}(), - cache.objective, - ) - return -end From bbc1c5a1eac974fecbef3fb09d95e2c1d61e4f41 Mon Sep 17 00:00:00 2001 From: odow Date: Thu, 31 Mar 2022 12:17:15 +1300 Subject: [PATCH 6/7] Clarify we are using the CPLEX LP format --- src/FileFormats/LP/LP.jl | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/FileFormats/LP/LP.jl b/src/FileFormats/LP/LP.jl index 7be9b619d8..f652ec9590 100644 --- a/src/FileFormats/LP/LP.jl +++ b/src/FileFormats/LP/LP.jl @@ -573,9 +573,10 @@ function _parse_float(token::String) end end -_is_less_than(token) = token in ("<=", "<") -_is_greater_than(token) = token in (">=", ">") -_is_equal_to(token) = token in ("==", "=") +# Yes, the last elements here are really accepted by CPLEX... +_is_less_than(token) = token in ("<=", "<", "=<") +_is_greater_than(token) = token in (">=", ">", "=>") +_is_equal_to(token) = token in ("=", "==") function _parse_section( ::typeof(_KW_BOUNDS), @@ -673,6 +674,10 @@ end Base.read!(io::IO, model::FileFormats.LP.Model) Read `io` in the LP file format and store the result in `model`. + +This reader attempts to follow the CPLEX LP format, because others like the +lpsolve version are very...flexible...in how they accept input. Read more about +them here: http://lpsolve.sourceforge.net """ function Base.read!(io::IO, model::Model) if !MOI.is_empty(model) From 6567a96ad920896f282cd781412a692f17d7e920 Mon Sep 17 00:00:00 2001 From: odow Date: Thu, 31 Mar 2022 13:10:47 +1300 Subject: [PATCH 7/7] Improve code coverage --- src/FileFormats/LP/LP.jl | 4 ---- test/FileFormats/LP/LP.jl | 21 +++++++++++++++++++ test/FileFormats/LP/models/invalid_bound_2.lp | 7 +++++++ test/FileFormats/LP/models/model1_tricky.lp | 2 +- 4 files changed, 29 insertions(+), 5 deletions(-) create mode 100644 test/FileFormats/LP/models/invalid_bound_2.lp diff --git a/src/FileFormats/LP/LP.jl b/src/FileFormats/LP/LP.jl index f652ec9590..88da9f801f 100644 --- a/src/FileFormats/LP/LP.jl +++ b/src/FileFormats/LP/LP.jl @@ -430,10 +430,6 @@ function _parse_affine_terms( return end -# _KW_HEADER - -_parse_section(::Val{:header}, ::Model, ::_ReadCache, ::Any) = nothing - # _KW_OBJECTIVE _set_objective_sense(::Any, ::Model, ::String) = nothing diff --git a/test/FileFormats/LP/LP.jl b/test/FileFormats/LP/LP.jl index f3b7112677..ed0f13b420 100644 --- a/test/FileFormats/LP/LP.jl +++ b/test/FileFormats/LP/LP.jl @@ -380,6 +380,27 @@ function test_read_objective_sense() return end +function test_read_nonempty_model() + filename = joinpath(@__DIR__, "models", "model2.lp") + model = LP.Model() + MOI.read_from_file(model, filename) + @test_throws( + ErrorException("Cannot read in file because model is not empty."), + MOI.read_from_file(model, filename), + ) + return +end + +function test_read_maximum_length_error() + filename = joinpath(@__DIR__, "models", "model2.lp") + model = LP.Model(; maximum_length = 1) + @test_throws( + ErrorException("Name exceeds maximum length: V4"), + MOI.read_from_file(model, filename), + ) + return +end + function runtests() for name in names(@__MODULE__, all = true) if startswith("$(name)", "test_") diff --git a/test/FileFormats/LP/models/invalid_bound_2.lp b/test/FileFormats/LP/models/invalid_bound_2.lp new file mode 100644 index 0000000000..af32668df1 --- /dev/null +++ b/test/FileFormats/LP/models/invalid_bound_2.lp @@ -0,0 +1,7 @@ +maximize +obj: x1 +subject to +c1: x <= 11 +bounds + x1 != 10 +end diff --git a/test/FileFormats/LP/models/model1_tricky.lp b/test/FileFormats/LP/models/model1_tricky.lp index f954f1e829..109b201d7f 100644 --- a/test/FileFormats/LP/models/model1_tricky.lp +++ b/test/FileFormats/LP/models/model1_tricky.lp @@ -18,7 +18,7 @@ Bounds -inf <= V1 <= 3 V2 <= 3 V3 >= -3 -5.5 <= Var4 <= +inf ++inf >= Var4 >= 5.5 V5 = 1 \ fixed variable V6 free