diff --git a/src/SolverAPI.jl b/src/SolverAPI.jl index 327a5be..fb52b48 100644 --- a/src/SolverAPI.jl +++ b/src/SolverAPI.jl @@ -97,13 +97,12 @@ function response( res["termination_status"] = string(MOI.get(model, MOI.TerminationStatus())) - options = get(() -> Dict{String,Any}(), json, :options) - format = get(options, :print_format, nothing) + format = get(json.options, :print_format, nothing) if !isnothing(format) res["model_string"] = print_model(model, format) end - if Bool(get(options, :print_only, false)) + if Bool(get(json.options, :print_only, false)) return res end @@ -178,12 +177,23 @@ function solve(fn, json::Request, solver::MOI.AbstractOptimizer) errors = validate(json) isempty(errors) || return response(; errors) + # TODO (dba) `SolverAPI.jl` should be decoupled from any solver specific code. + solver_info = Dict{Symbol,Any}() + if lowercase(get(json.options, :solver, "highs")) == "minizinc" + T = Int + solver_info[:use_indicator] = false + else + T = Float64 + solver_info[:use_indicator] = true + end + + model = MOI.instantiate(() -> solver; with_cache_type = T, with_bridge_type = T) + try - T, solver_info, model = initialize(json, solver) - load!(json, T, solver_info, model) + set_options!(model, json.options) + load!(model, json, T, solver_info) fn(model) - options = get(() -> Dict{String,Any}(), json, :options) - if !Bool(get(options, :print_only, false)) + if !Bool(get(json.options, :print_only, false)) MOI.optimize!(model) end return response(json, model, solver) @@ -208,6 +218,8 @@ function solve(fn, json::Request, solver::MOI.AbstractOptimizer) else rethrow() end + finally + MOI.empty!(model) end end solve(request::Request, solver::MOI.AbstractOptimizer) = @@ -232,30 +244,30 @@ function print_model(model::MOI.ModelLike, format::String) format_lower = lowercase(format) if format_lower == "moi" || format_lower == "latex" mime = MIME(format_lower == "latex" ? "text/latex" : "text/plain") - options = MOI.Utilities._PrintOptions( + print_options = MOI.Utilities._PrintOptions( mime; simplify_coefficients = true, print_types = false, ) return sprint() do io - return MOI.Utilities._print_model(io, options, model) + return MOI.Utilities._print_model(io, print_options, model) end elseif format_lower == "latex" # NOTE: there are options for latex_formulation, e.g. don't print MOI function/set types # https://jump.dev/MathOptInterface.jl/dev/submodules/Utilities/reference/#MathOptInterface.Utilities.latex_formulation return sprint(print, MOI.Utilities.latex_formulation(model)) elseif format_lower == "mof" - options = (; format = MOI.FileFormats.FORMAT_MOF, print_compact = true) + print_options = (; format = MOI.FileFormats.FORMAT_MOF, print_compact = true) elseif format_lower == "lp" - options = (; format = MOI.FileFormats.FORMAT_LP) + print_options = (; format = MOI.FileFormats.FORMAT_LP) elseif format_lower == "mps" - options = (; format = MOI.FileFormats.FORMAT_MPS) + print_options = (; format = MOI.FileFormats.FORMAT_MPS) elseif format_lower == "nl" - options = (; format = MOI.FileFormats.FORMAT_NL) + print_options = (; format = MOI.FileFormats.FORMAT_NL) else throw(Error(Unsupported, "File type \"$format\" not supported.")) end - dest = MOI.FileFormats.Model(; options...) + dest = MOI.FileFormats.Model(; print_options...) MOI.copy_to(dest, model) return sprint(write, dest) end @@ -265,16 +277,15 @@ function print_model(request::Request; T = Float64) throw(CompositeException(errors)) end - options = get(() -> Dict{String,Any}(), request, :options) # Default to MOI format. - format = get(options, :print_format, "MOI") + format = get(request.options, :print_format, "MOI") # TODO cleanup/refactor solver_info logic. use_indicator = T == Float64 solver_info = Dict{Symbol,Any}(:use_indicator => use_indicator) model = MOI.Utilities.Model{T}() - load!(request, T, solver_info, model) + load!(model, request, T, solver_info) return print_model(model, format) end print_model(request::Dict) = print_model(JSON3.read(JSON3.write(request))) @@ -289,7 +300,7 @@ function validate(json::Request)#::Vector{Error} valid_shape = true # Syntax. - for k in [:version, :sense, :variables, :constraints, :objectives] + for k in [:version, :sense, :variables, :constraints, :objectives, :options] if !haskey(json, k) valid_shape = false _err("Missing required field `$(k)`.") @@ -307,7 +318,7 @@ function validate(json::Request)#::Vector{Error} _err("Invalid version `$(repr(json.version))`. Only `\"0.1\"` is supported.") end - if haskey(json, :options) && !isa(json.options, JSON3.Object) + if !isa(json.options, JSON3.Object) _err("Invalid `options` field. Must be an object.") end @@ -326,20 +337,18 @@ function validate(json::Request)#::Vector{Error} _err("Invalid `sense` field. Must be one of `feas`, `min`, or `max`.") end - if haskey(json, :options) - for (T, k) in [(String, :print_format), (Number, :time_limit_sec)] - if haskey(json.options, k) && !isa(json.options[k], T) - _err("Invalid `options.$(k)` field. Must be of type `$(T)`.") - end + for (T, k) in [(String, :print_format), (Number, :time_limit_sec)] + if haskey(json.options, k) && !isa(json.options[k], T) + _err("Invalid `options.$(k)` field. Must be of type `$(T)`.") end + end - for k in [:silent, :print_only] - if haskey(json.options, k) - val = json.options[k] - # We allow `0` and `1` for convenience. - if !isa(val, Bool) && val isa Number && val != 0 && val != 1 - _err("Invalid `options.$(k)` field. Must be a boolean.") - end + for k in [:silent, :print_only] + if haskey(json.options, k) + val = json.options[k] + # We allow `0` and `1` for convenience. + if !isa(val, Bool) && val isa Number && val != 0 && val != 1 + _err("Invalid `options.$(k)` field. Must be a boolean.") end end end @@ -357,29 +366,16 @@ function validate(json::Request)#::Vector{Error} return out end -function initialize(json::Request, solver::MOI.AbstractOptimizer)#::Tuple{Type, Dict{Symbol, Any}, MOI.ModelLike} - solver_info = Dict{Symbol,Any}() - - # TODO (dba) `SolverAPI.jl` should be decoupled from any solver specific code. - options = get(() -> Dict{String,Any}(), json, :options) - if lowercase(get(options, :solver, "highs")) == "minizinc" - T = Int - solver_info[:use_indicator] = false - else - T = Float64 - solver_info[:use_indicator] = true - end - - model = MOI.instantiate(() -> solver; with_cache_type = T, with_bridge_type = T) - +# Set solver options. +function set_options!(model::MOI.ModelLike, options::JSON3.Object)#::Nothing if MOI.supports(model, MOI.TimeLimitSec()) # Set time limit, defaulting to 5min. MOI.set(model, MOI.TimeLimitSec(), Float64(get(options, :time_limit_sec, 300.0))) end - # Set other solver options. for (key, val) in options if key in [:solver, :print_format, :print_only, :time_limit_sec] + # Skip - these are handled elsewhere. continue elseif key == :silent MOI.set(model, MOI.Silent(), Bool(val)) @@ -392,10 +388,10 @@ function initialize(json::Request, solver::MOI.AbstractOptimizer)#::Tuple{Type, end end - return (T, solver_info, model) + return nothing end -function load!(json::Request, T::Type, solver_info::Dict{Symbol,Any}, model::MOI.ModelLike)#::Nothing +function load!(model::MOI.ModelLike, json::Request, T::Type, solver_info::Dict{Symbol,Any})#::Nothing # handle variables vars_map = Dict{String,MOI.VariableIndex}() vars = MOI.add_variables(model, length(json.variables)) diff --git a/test/all_tests.jl b/test/all_tests.jl index b9bef13..4bee924 100644 --- a/test/all_tests.jl +++ b/test/all_tests.jl @@ -21,7 +21,12 @@ end function run_solve(input::String) json = deserialize(input) - solver = get_solver(json.options.solver) + solver_name = try + json.options.solver + catch + "highs" + end + solver = get_solver(solver_name) output = solve(json, solver) return String(serialize(output)) end @@ -34,18 +39,19 @@ end # end of setup module. @testitem "print" setup = [SolverSetup] begin using SolverAPI: print_model - model = Dict( + json = Dict( :version => "0.1", :sense => "min", :variables => ["x"], :constraints => [["==", "x", 1], ["Int", "x"]], :objectives => ["x"], + :options => Dict(:print_format => "none"), ) # check MOI model printing for each format @testset "$f" for f in ["moi", "latex", "mof", "lp", "mps", "nl"] - request = Dict(model..., :options => Dict(:print_format => f)) - @test print_model(request) isa String + json[:options][:print_format] = f + @test print_model(json) isa String end end @@ -91,6 +97,8 @@ end ("missing_sense", "InvalidFormat"), # missing field version ("missing_version", "InvalidFormat"), + # missing field options + ("missing_options", "InvalidFormat"), # field variables is not a string ("vars_is_not_str", "InvalidFormat"), # field variables is not an array @@ -125,6 +133,7 @@ end ("unsupported_print_format", "Unsupported"), ] + # check that expected error types are returned for each json input @testset "$j" for (j, es...) in json_names_and_errors result = JSON3.read(run_solve(read_json("inputs", j))) @test haskey(result, :errors) && length(result.errors) >= 1 @@ -138,18 +147,21 @@ end # setup linear objective model with n variables n = 1000 vars = ["x$i" for i in 1:n] - model = Dict( + json = Dict( :version => "0.1", :sense => "max", :variables => vars, :constraints => [["Bin", v] for v in vars], :objectives => [vcat("+", 1, [["*", i, v] for (i, v) in enumerate(vars)])], + :options => Dict(:solver => "HiGHS"), ) # check that model is solved correctly without errors - output = solve(model, get_solver("HiGHS")) - @test output isa Dict{String,Any} - @test !haskey(output, "errors") - @test output["termination_status"] == "OPTIMAL" - @test output["results"][1]["objective_value"] ≈ 1 + div(n * (n + 1), 2) + @testset "solve n=$n" begin + output = solve(json, get_solver("HiGHS")) + @test output isa Dict{String,Any} + @test !haskey(output, "errors") + @test output["termination_status"] == "OPTIMAL" + @test output["results"][1]["objective_value"] ≈ 1 + div(n * (n + 1), 2) + end end diff --git a/test/inputs/missing_options.json b/test/inputs/missing_options.json new file mode 100644 index 0000000..9002375 --- /dev/null +++ b/test/inputs/missing_options.json @@ -0,0 +1 @@ +{"version":"0.1","sense":"feas","variables":["x"],"constraints":[["==","x",1],["Int","x"]],"objectives":[]}