From b07f71eaaae2773f1f8a1399c187269cf00c8f9f Mon Sep 17 00:00:00 2001 From: Oscar Dowson Date: Thu, 28 Sep 2023 08:57:21 +1300 Subject: [PATCH] [FileFormats.MOF] add MathOptFormat@1.6 support (#2293) --- docs/src/developer/checklists.md | 6 +- docs/src/submodules/FileFormats/overview.md | 2 +- src/FileFormats/MOF/MOF.jl | 41 +- .../{mof.1.5.schema.json => mof.schema.json} | 114 +++++- src/FileFormats/MOF/nonlinear.jl | 383 ------------------ src/FileFormats/MOF/read.jl | 82 +++- src/FileFormats/MOF/write.jl | 167 +++++++- test/FileFormats/MOF/MOF.jl | 123 ++++-- test/FileFormats/MOF/nlp.mof.json | 2 +- 9 files changed, 475 insertions(+), 445 deletions(-) rename src/FileFormats/MOF/{mof.1.5.schema.json => mof.schema.json} (92%) delete mode 100644 src/FileFormats/MOF/nonlinear.jl diff --git a/docs/src/developer/checklists.md b/docs/src/developer/checklists.md index 20ca93a926..18b4e97a46 100644 --- a/docs/src/developer/checklists.md +++ b/docs/src/developer/checklists.md @@ -129,9 +129,9 @@ Use this checklist when updating the version of MathOptFormat. ``` ## Basic - - [ ] The file at `src/FileFormats/MOF/mof.X.Y.schema.json` is updated - - [ ] The constants `SCHEMA_PATH`, `VERSION`, and `SUPPORTED_VERSIONS` are - updated in `src/FileFormats/MOF/MOF.jl` + - [ ] The file at `src/FileFormats/MOF/mof.schema.json` is updated + - [ ] The constant `_SUPPORTED_VERSIONS` is updated in + `src/FileFormats/MOF/MOF.jl` ## New sets diff --git a/docs/src/submodules/FileFormats/overview.md b/docs/src/submodules/FileFormats/overview.md index 6757f981d0..bfde1d7fb2 100644 --- a/docs/src/submodules/FileFormats/overview.md +++ b/docs/src/submodules/FileFormats/overview.md @@ -94,7 +94,7 @@ julia> print(read("file.mof.json", String)) "name": "MathOptFormat Model", "version": { "major": 1, - "minor": 5 + "minor": 6 }, "variables": [ { diff --git a/src/FileFormats/MOF/MOF.jl b/src/FileFormats/MOF/MOF.jl index 17a443122b..250a43d4cf 100644 --- a/src/FileFormats/MOF/MOF.jl +++ b/src/FileFormats/MOF/MOF.jl @@ -11,10 +11,26 @@ import OrderedCollections: OrderedDict import JSON import MathOptInterface as MOI -const SCHEMA_PATH = joinpath(@__DIR__, "mof.1.5.schema.json") -const VERSION = v"1.5" -const SUPPORTED_VERSIONS = - (v"1.5", v"1.4", v"1.3", v"1.2", v"1.1", v"1.0", v"0.6", v"0.5", v"0.4") +""" + SCHEMA_PATH::String + +The path to the latest version of the MathOptFormat schema supported by +MathOptInterface. +""" +const SCHEMA_PATH = joinpath(@__DIR__, "mof.schema.json") + +const _SUPPORTED_VERSIONS = ( + v"1.6", + v"1.5", + v"1.4", + v"1.3", + v"1.2", + v"1.1", + v"1.0", + v"0.6", + v"0.5", + v"0.4", +) const OrderedObject = OrderedDict{String,Any} const UnorderedObject = Dict{String,Any} @@ -84,15 +100,15 @@ MOI.Utilities.@model( MOI.BinPacking, MOI.Table, ), - (Nonlinear,), + (Nonlinear, MOI.ScalarNonlinearFunction), (MOI.ScalarAffineFunction, MOI.ScalarQuadraticFunction), - (MOI.VectorOfVariables,), + (MOI.VectorOfVariables, MOI.VectorNonlinearFunction), (MOI.VectorAffineFunction, MOI.VectorQuadraticFunction) ) # Indicator is handled by UniversalFallback. # Reified is handled by UniversalFallback. -# Scaled is handled bby UniversalFallback. +# Scaled is handled by UniversalFallback. const Model = MOI.Utilities.UniversalFallback{InnerModel{Float64}} @@ -100,13 +116,14 @@ struct Options print_compact::Bool warn::Bool differentiation_backend::MOI.Nonlinear.AbstractAutomaticDifferentiation + parse_as_nlpblock::Bool end function get_options(m::Model) return get( m.model.ext, :MOF_OPTIONS, - Options(false, false, MOI.Nonlinear.SparseReverseMode()), + Options(false, false, MOI.Nonlinear.SparseReverseMode(), true), ) end @@ -123,15 +140,19 @@ Keyword arguments are: - `differentiation_backend::MOI.Nonlinear.AbstractAutomaticDifferentiation = MOI.Nonlinear.SparseReverseMode()`: automatic differentiation backend to use when reading models with nonlinear constraints and objectives. + - `parse_as_nlpblock::Bool=true`: if `true` parse `"ScalarNonlinearFunction"` + into an `MOI.NLPBlock`. If `false`, `"ScalarNonlinearFunction"` are parsed as + `MOI.ScalarNonlinearFunction` functions. """ function Model(; print_compact::Bool = false, warn::Bool = false, differentiation_backend::MOI.Nonlinear.AbstractAutomaticDifferentiation = MOI.Nonlinear.SparseReverseMode(), + parse_as_nlpblock::Bool = true, ) model = MOI.Utilities.UniversalFallback(InnerModel{Float64}()) model.model.ext[:MOF_OPTIONS] = - Options(print_compact, warn, differentiation_backend) + Options(print_compact, warn, differentiation_backend, parse_as_nlpblock) return model end @@ -140,8 +161,6 @@ function Base.show(io::IO, ::Model) return end -include("nonlinear.jl") - include("read.jl") include("write.jl") diff --git a/src/FileFormats/MOF/mof.1.5.schema.json b/src/FileFormats/MOF/mof.schema.json similarity index 92% rename from src/FileFormats/MOF/mof.1.5.schema.json rename to src/FileFormats/MOF/mof.schema.json index 96f7e22897..dd73a688ea 100644 --- a/src/FileFormats/MOF/mof.1.5.schema.json +++ b/src/FileFormats/MOF/mof.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/schema#", - "$id": "https://jump.dev/MathOptFormat/schemas/mof.1.5.schema.json", + "$id": "https://jump.dev/MathOptFormat/schemas/mof.1.6.schema.json", "title": "The schema for MathOptFormat", "type": "object", "required": ["version", "variables", "objective", "constraints"], @@ -11,7 +11,7 @@ "required": ["minor", "major"], "properties": { "minor": { - "enum": [0, 1, 2, 3, 4, 5] + "enum": [0, 1, 2, 3, 4, 5, 6] }, "major": { "const": 1 @@ -199,9 +199,78 @@ "properties": { "type": { "enum": [ - "log", "log10", "exp", "sqrt", "floor", "ceil", - "abs", "cos", "sin", "tan", "acos", "asin", "atan", - "cosh", "sinh", "tanh", "acosh", "asinh", "atanh" + "abs", + "sqrt", + "cbrt", + "abs2", + "inv", + "log", + "log10", + "log2", + "log1p", + "exp", + "exp2", + "expm1", + "sin", + "cos", + "tan", + "sec", + "csc", + "cot", + "sind", + "cosd", + "tand", + "secd", + "cscd", + "cotd", + "asin", + "acos", + "atan", + "asec", + "acsc", + "acot", + "asind", + "acosd", + "atand", + "asecd", + "acscd", + "acotd", + "sinh", + "cosh", + "tanh", + "sech", + "csch", + "coth", + "asinh", + "acosh", + "atanh", + "asech", + "acsch", + "acoth", + "deg2rad", + "rad2deg", + "erf", + "erfinv", + "erfc", + "erfcinv", + "erfi", + "gamma", + "lgamma", + "digamma", + "invdigamma", + "trigamma", + "airyai", + "airybi", + "airyaiprime", + "airybiprime", + "besselj0", + "besselj1", + "bessely0", + "bessely1", + "erfcx", + "dawson", + "floor", + "ceil" ] }, "args": { @@ -218,7 +287,18 @@ "required": ["args"], "properties": { "type": { - "enum": ["/", "^"] + "enum": [ + "/", + "^", + "atan", + "&&", + "||", + "<=", + "<", + ">=", + ">", + "==" + ] }, "args": { "type": "array", @@ -234,7 +314,7 @@ "required": ["args"], "properties": { "type": { - "enum": ["+", "-", "*", "min", "max"] + "enum": ["+", "-", "*", "ifelse", "min", "max"] }, "args": { "type": "array", @@ -441,6 +521,26 @@ } } } + }, { + "description": "The vector-valued nonlinear function `f(x)`, comprised of a vector of `ScalarNonlinearFunction`.", + "required": ["rows", "node_list"], + "properties": { + "type": { + "const": "VectorNonlinearFunction" + }, + "rows": { + "type": "array", + "items": { + "$ref": "#/definitions/NonlinearTerm" + } + }, + "node_list": { + "type": "array", + "items": { + "$ref": "#/definitions/NonlinearTerm" + } + } + } }] }, "scalar_sets": { diff --git a/src/FileFormats/MOF/nonlinear.jl b/src/FileFormats/MOF/nonlinear.jl deleted file mode 100644 index 72d978018c..0000000000 --- a/src/FileFormats/MOF/nonlinear.jl +++ /dev/null @@ -1,383 +0,0 @@ -# Copyright (c) 2017: Miles Lubin and contributors -# Copyright (c) 2017: Google Inc. -# -# Use of this source code is governed by an MIT-style license that can be found -# in the LICENSE.md file or at https://opensource.org/licenses/MIT. - -# Overload for writing. -function moi_to_object(foo::Nonlinear, name_map::Dict{MOI.VariableIndex,String}) - node_list = OrderedObject[] - foo_object = convert_expr_to_mof(foo.expr, node_list, name_map) - return OrderedObject( - "type" => "ScalarNonlinearFunction", - "root" => foo_object, - "node_list" => node_list, - ) -end - -# Overload for reading. -function function_to_moi( - ::Val{:ScalarNonlinearFunction}, - object::T, - name_map::Dict{String,MOI.VariableIndex}, -) where {T<:Object} - node_list = T.(object["node_list"]) - expr = convert_mof_to_expr(object["root"], node_list, name_map) - return Nonlinear(expr) -end - -function lift_variable_indices(expr::Expr) - if expr.head == :ref && length(expr.args) == 2 && expr.args[1] == :x - return expr.args[2] - else - for (index, arg) in enumerate(expr.args) - expr.args[index] = lift_variable_indices(arg) - end - end - return expr -end - -lift_variable_indices(arg) = arg # Recursion fallback. - -function extract_function_and_set(expr::Expr) - if expr.head == :call # One-sided constraint or foo-in-set. - @assert length(expr.args) == 3 - if expr.args[1] == :in - # return expr.args[2], expr.args[3] - error("Constraints of the form foo-in-set aren't supported yet.") - elseif expr.args[1] == :(<=) - return expr.args[2], MOI.LessThan(expr.args[3]) - elseif expr.args[1] == :(>=) - return expr.args[2], MOI.GreaterThan(expr.args[3]) - elseif expr.args[1] == :(==) - return expr.args[2], MOI.EqualTo(expr.args[3]) - end - elseif expr.head == :comparison # Two-sided constraint. - @assert length(expr.args) == 5 - if expr.args[2] == expr.args[4] == :(<=) - return expr.args[3], MOI.Interval(expr.args[1], expr.args[5]) - elseif expr.args[2] == expr.args[4] == :(>=) - return expr.args[3], MOI.Interval(expr.args[5], expr.args[1]) - end - end - return error("Oops. The constraint $(expr) wasn't recognised.") -end - -function write_nlpblock( - object::T, - model::Model, - name_map::Dict{MOI.VariableIndex,String}, -) where {T<:Object} - nlp_block = MOI.get(model, MOI.NLPBlock()) - if nlp_block === nothing - return - end - MOI.initialize(nlp_block.evaluator, [:ExprGraph]) - variables = MOI.get(model, MOI.ListOfVariableIndices()) - if nlp_block.has_objective - objective = MOI.objective_expr(nlp_block.evaluator) - objective = lift_variable_indices(objective) - sense = MOI.get(model, MOI.ObjectiveSense()) - object["objective"] = T( - "sense" => moi_to_object(sense), - "function" => moi_to_object(Nonlinear(objective), name_map), - ) - end - for (row, bounds) in enumerate(nlp_block.constraint_bounds) - constraint = MOI.constraint_expr(nlp_block.evaluator, row) - (func, set) = extract_function_and_set(constraint) - func = lift_variable_indices(func) - push!( - object["constraints"], - T( - "function" => moi_to_object(Nonlinear(func), name_map), - "set" => moi_to_object(set, name_map), - ), - ) - end -end - -#= -Expr: - 2 * x + sin(x)^2 + y - -Tree form: - +-- (2) - +-- (*) --+ - | +-- (x) - (+) --+ - | +-- (sin) --+-- (x) - +-- (^) --+ - | +-- (2) - +-- (y) - -MOF format: - - { - "type": "nonlinear", - "root": {"type": "node", "index": 4}, - "node_list": [ - { - "type": "*", "args": [ - {"type": "real", "value": 2}, - {"type": "variable", "name": "x"} - ] - }, - { - "type": "sin", - "args": [ - {"type": "variable", "name", "x"} - ] - } - { - "type": "^", - "args": [ - {"type": "node", "index": 2}, - {"type": "real", "value": 2} - ] - }, - { - "type": "+", - "args": [ - {"type": "node", "index": 1}, - {"type": "node", "index": 3}, - {"type": "variable", "name": "y"} - ] - } - ] - } -=# - -""" - ARITY - -The arity of a nonlinear function. One of: - - `Nary` if the function accepts one or more arguments - - `Unary` if the function accepts exactly one argument - - `Binary` if the function accepts exactly two arguments - - `Ternary` if the function accepts exactly three arguments. -""" -@enum ARITY Nary Unary Binary Ternary - -# A nice error message telling the user they supplied the wrong number of -# arguments to a nonlinear function. -function validate_arguments(function_name, arity::ARITY, num_arguments::Int) - if ( - (arity == Nary && num_arguments < 1) || - (arity == Unary && num_arguments != 1) || - (arity == Binary && num_arguments != 2) || - (arity == Ternary && num_arguments != 3) - ) - error( - "The function $(function_name) is a $(arity) function, but you " * - "have passed $(num_arguments) arguments.", - ) - end -end - -""" - SUPPORTED_FUNCTIONS - -A vector of string-symbol pairs that map the MathOptFormat string representation -(i.e, the value of the `"type"` field) to the name of a Julia function (in -Symbol form). -""" -const SUPPORTED_FUNCTIONS = Pair{String,Tuple{Symbol,ARITY}}[ - # ========================================================================== - # The standard arithmetic functions. - # The addition operator: +(a, b, c, ...) = a + b + c + ... - # In the unary case, +(a) = a. - "+"=>(:+, Nary), - # The subtraction operator: -(a, b, c, ...) = a - b - c - ... - # In the unary case, -(a) = -a. - "-"=>(:-, Nary), - # The multiplication operator: *(a, b, c, ...) = a * b * c * ... - # In the unary case, *(a) = a. - "*"=>(:*, Nary), - # The division operator. This must have exactly two arguments. The first - # argument is the numerator, the second argument is the denominator: - # /(a, b) = a / b. - "/"=>(:/, Binary), - # ========================================================================== - # N-ary minimum and maximum functions. - "min"=>(:min, Nary), - "max"=>(:max, Nary), - # ========================================================================== - # floor(x) = ⌊x⌋ - "floor"=>(:floor, Unary), - # ceil(x) = ⌈x⌉ - "ceil"=>(:ceil, Unary), - # ========================================================================== - # The absolute value function: abs(x) = (x >= 0 ? x : -x). - "abs"=>(:abs, Unary), - # ========================================================================== - # Log- and power-related functions. - # A binary function for exponentiation: ^(a, b) = a ^ b. - "^"=>(:^, Binary), - # The natural exponential function: exp(x) = e^x. - "exp"=>(:exp, Unary), - # The base-e log function: y = log(x) => e^y = x. - "log"=>(:log, Unary), - # The base-10 log function: y = log10(x) => 10^y = x. - "log10"=>(:log10, Unary), - # The square root function: sqrt(x) = √x = x^(0.5). - "sqrt"=>(:sqrt, Unary), - # ========================================================================== - # Boolean operators - # A && B = A and B - # "&&" => (:&&, Binary), - # A || B = A or B - # "||" => (:||, Binary), - # ========================================================================== - # Comparison operators - "<"=>(:<, Binary), - "<="=>(:<=, Binary), - ">"=>(:>, Binary), - ">="=>(:>=, Binary), - "=="=>(:(==), Binary), - "!="=>(:!=, Binary), - # ========================================================================== - "ifelse"=>(:ifelse, Ternary), - # "not" => (:!, Unary), - # ========================================================================== - # The unary trigonometric functions. These must have exactly one argument. - "cos"=>(:cos, Unary), - "cosh"=>(:cosh, Unary), - "acos"=>(:acos, Unary), - "acosh"=>(:acosh, Unary), - "sin"=>(:sin, Unary), - "sinh"=>(:sinh, Unary), - "asin"=>(:asin, Unary), - "asinh"=>(:asinh, Unary), - "tan"=>(:tan, Unary), - "tanh"=>(:tanh, Unary), - "atan"=>(:atan, Unary), - # "atan2" => (:atan, Binary), - "atanh"=>(:atanh, Unary), -] - -# An internal helper dictionary that maps function names in Symbol form to their -# MathOptFormat string representation. -const FUNCTION_TO_STRING = Dict{Symbol,Tuple{String,ARITY}}() - -# An internal helper dictionary that maps function names in their MathOptFormat -# string representation to the symbol representing the Julia function. -const STRING_TO_FUNCTION = Dict{String,Tuple{Symbol,ARITY}}() - -# Programatically add the list of supported functions to the helper dictionaries -# for easy of look-up later. -for (mathoptformat_string, (julia_symbol, num_arguments)) in SUPPORTED_FUNCTIONS - FUNCTION_TO_STRING[julia_symbol] = (mathoptformat_string, num_arguments) - STRING_TO_FUNCTION[mathoptformat_string] = (julia_symbol, num_arguments) -end - -""" - convert_mof_to_expr( - node::T, node_list::Vector{T}, name_map::Dict{String, MOI.VariableIndex} - ) - -Convert a MathOptFormat node `node` into a Julia expression given a list of -MathOptFormat nodes in `node_list`. Variable names are mapped through `name_map` -to their variable index. -""" -function convert_mof_to_expr( - node::T, - node_list::Vector{T}, - name_map::Dict{String,MOI.VariableIndex}, -) where {T<:Object} - # TODO(odow): remove when v0.4 no longer supported. - head = haskey(node, "type") ? node["type"] : node["head"] - if head == "real" - return node["value"] - elseif head == "complex" - return Complex(node["real"], node["imag"]) - elseif head == "variable" - return name_map[node["name"]] - elseif head == "node" - return convert_mof_to_expr( - node_list[node["index"]], - node_list, - name_map, - ) - else - if !haskey(STRING_TO_FUNCTION, head) - error("Cannot convert MOF to Expr. Unknown function: $(head).") - end - (julia_symbol, arity) = STRING_TO_FUNCTION[head] - validate_arguments(head, arity, length(node["args"])) - expr = Expr(:call, julia_symbol) - for arg in node["args"] - push!(expr.args, convert_mof_to_expr(arg, node_list, name_map)) - end - return expr - end -end - -""" - convert_expr_to_mof( - node::T, - node_list::Vector{T}, - name_map::Dict{MOI.VariableIndex, String} - ) - -Convert a Julia expression into a MathOptFormat representation. Any intermediate -nodes that are required are appended to `node_list`. Variable indices are mapped -through `name_map` to their string name. -""" -function convert_expr_to_mof( - expr::Expr, - node_list::Vector{T}, - name_map::Dict{MOI.VariableIndex,String}, -) where {T<:Object} - if expr.head != :call - error("Expected an expression that was a function. Got $(expr).") - end - function_name = expr.args[1] - if !haskey(FUNCTION_TO_STRING, function_name) - error("Cannot convert Expr to MOF. Unknown function: $(function_name).") - end - (mathoptformat_string, arity) = FUNCTION_TO_STRING[function_name] - validate_arguments(function_name, arity, length(expr.args) - 1) - node = T("type" => mathoptformat_string, "args" => T[]) - for arg in @view(expr.args[2:end]) - push!(node["args"], convert_expr_to_mof(arg, node_list, name_map)) - end - push!(node_list, node) - return T("type" => "node", "index" => length(node_list)) -end - -# Recursion end for variables. -function convert_expr_to_mof( - variable::MOI.VariableIndex, - ::Vector{T}, - name_map::Dict{MOI.VariableIndex,String}, -) where {T<:Object} - return T("type" => "variable", "name" => name_map[variable]) -end - -# Recursion end for real constants. -function convert_expr_to_mof( - value::Real, - ::Vector{T}, - name_map::Dict{MOI.VariableIndex,String}, -) where {T<:Object} - return T("type" => "real", "value" => value) -end - -# Recursion end for complex numbers. -function convert_expr_to_mof( - value::Complex, - ::Vector{T}, - ::Dict{MOI.VariableIndex,String}, -) where {T<:Object} - return T("type" => "complex", "real" => real(value), "imag" => imag(value)) -end - -# Recursion fallback. -function convert_expr_to_mof( - fallback, - ::Vector{<:Object}, - ::Dict{MOI.VariableIndex,String}, -) - return error("Unexpected $(typeof(fallback)) encountered: $(fallback).") -end diff --git a/src/FileFormats/MOF/read.jl b/src/FileFormats/MOF/read.jl index 78df5254df..9e06feb6fe 100644 --- a/src/FileFormats/MOF/read.jl +++ b/src/FileFormats/MOF/read.jl @@ -15,48 +15,50 @@ function Base.read!(io::IO, model::Model) end object = JSON.parse(io; dicttype = UnorderedObject) file_version = _parse_mof_version(object["version"]::UnorderedObject) - if !(file_version in SUPPORTED_VERSIONS) + if !(file_version in _SUPPORTED_VERSIONS) + version = _SUPPORTED_VERSIONS[1] error( "Sorry, the file can't be read because this library supports " * - "v$(VERSION) of MathOptFormat, but the file you are trying to " * + "v$version of MathOptFormat, but the file you are trying to " * "read is v$(file_version).", ) end name_map = read_variables(model, object) read_objective(model, object, name_map) read_constraints(model, object, name_map) - _convert_to_nlpblock(model) + options = get_options(model) + if options.parse_as_nlpblock + _convert_to_nlpblock(model) + end return end function _convert_to_nlpblock(model::Model) needs_nlp_block = false nlp_model = MOI.Nonlinear.Model() + F = MOI.ScalarNonlinearFunction for S in ( MOI.LessThan{Float64}, MOI.GreaterThan{Float64}, MOI.EqualTo{Float64}, MOI.Interval{Float64}, ) - for ci in MOI.get(model, MOI.ListOfConstraintIndices{Nonlinear,S}()) + for ci in MOI.get(model, MOI.ListOfConstraintIndices{F,S}()) f = MOI.get(model, MOI.ConstraintFunction(), ci) set = MOI.get(model, MOI.ConstraintSet(), ci) - MOI.Nonlinear.add_constraint(nlp_model, f.expr, set) + MOI.Nonlinear.add_constraint(nlp_model, f, set) # We don't need this in `model` any more. MOI.delete(model, ci) needs_nlp_block = true end end - if MOI.get(model, MOI.ObjectiveFunctionType()) == Nonlinear - obj = MOI.get(model, MOI.ObjectiveFunction{Nonlinear}()) - MOI.Nonlinear.set_objective(nlp_model, obj.expr) + if MOI.get(model, MOI.ObjectiveFunctionType()) == F + obj = MOI.get(model, MOI.ObjectiveFunction{F}()) + MOI.Nonlinear.set_objective(nlp_model, obj) MOI.set( model, MOI.ObjectiveFunction{MOI.ScalarAffineFunction{Float64}}(), - MOI.ScalarAffineFunction{Float64}( - MOI.ScalarAffineTerm{Float64}[], - 0.0, - ), + zero(MOI.ScalarAffineFunction{Float64}), ) needs_nlp_block = true end @@ -193,6 +195,7 @@ end VectorAffineFunction, VectorQuadraticFunction, ScalarNonlinearFunction, + VectorNonlinearFunction, ) """ @@ -215,8 +218,8 @@ function function_to_moi( ::Dict{String,MOI.VariableIndex}, ) where {FunctionSymbol} return error( - "Version $(VERSION) of MathOptFormat does not support the function: " * - "$(FunctionSymbol).", + "Version $(_SUPPORTED_VERSIONS[1]) of MathOptFormat does not support " * + "the function: $(FunctionSymbol).", ) end @@ -240,6 +243,45 @@ function function_to_moi( return name_map[object["name"]::String] end +function function_to_moi( + ::Val{:ScalarNonlinearFunction}, + object::T, + name_map::Dict{String,MOI.VariableIndex}, +) where {T<:Object} + node_list = T.(object["node_list"]) + f = _parse_scalar_nonlinear_function(object["root"], node_list, name_map) + return f::MOI.ScalarNonlinearFunction +end + +function _parse_scalar_nonlinear_function( + node::T, + node_list::Vector{T}, + name_map::Dict{String,MOI.VariableIndex}, +) where {T<:Object} + head = node["type"] + if head == "real" + return node["value"] + elseif head == "complex" + return Complex(node["real"], node["imag"]) + elseif head == "variable" + return name_map[node["name"]] + elseif head == "node" + return _parse_scalar_nonlinear_function( + node_list[node["index"]], + node_list, + name_map, + ) + end + f = MOI.ScalarNonlinearFunction(Symbol(head), Any[]) + for arg in node["args"] + push!( + f.args, + _parse_scalar_nonlinear_function(arg, node_list, name_map), + ) + end + return f +end + # ========== Typed scalar functions ========== # Here, we deal with a special case: ScalarAffineTerm, ScalarQuadraticTerm, @@ -304,6 +346,18 @@ function function_to_moi( ) end +function function_to_moi( + ::Val{:VectorNonlinearFunction}, + object::T, + name_map::Dict{String,MOI.VariableIndex}, +) where {T<:Object} + node_list = T.(object["node_list"]) + rows = map(object["rows"]) do r + return _parse_scalar_nonlinear_function(r, node_list, name_map) + end + return MOI.VectorNonlinearFunction(rows) +end + # ========== Typed vector functions ========== function parse_vector_affine_term( diff --git a/src/FileFormats/MOF/write.jl b/src/FileFormats/MOF/write.jl index 222f6762d1..324870bb5f 100644 --- a/src/FileFormats/MOF/write.jl +++ b/src/FileFormats/MOF/write.jl @@ -14,8 +14,8 @@ function Base.write(io::IO, model::Model) object = OrderedObject( "name" => "MathOptFormat Model", "version" => OrderedObject( - "major" => Int(VERSION.major), - "minor" => Int(VERSION.minor), + "major" => Int(_SUPPORTED_VERSIONS[1].major), + "minor" => Int(_SUPPORTED_VERSIONS[1].minor), ), "variables" => Object[], "objective" => OrderedObject("sense" => "feasibility"), @@ -41,6 +41,78 @@ function write_variables(object, model::Model) return name_map end +function _lift_variable_indices(expr::Expr) + if expr.head == :ref && length(expr.args) == 2 && expr.args[1] == :x + return expr.args[2] + else + for (index, arg) in enumerate(expr.args) + expr.args[index] = _lift_variable_indices(arg) + end + end + return expr +end + +_lift_variable_indices(arg) = arg # Recursion fallback. + +function _extract_function_and_set(expr::Expr) + if expr.head == :call # One-sided constraint or foo-in-set. + @assert length(expr.args) == 3 + if expr.args[1] == :in + # return expr.args[2], expr.args[3] + error("Constraints of the form foo-in-set aren't supported yet.") + elseif expr.args[1] == :(<=) + return expr.args[2], MOI.LessThan(expr.args[3]) + elseif expr.args[1] == :(>=) + return expr.args[2], MOI.GreaterThan(expr.args[3]) + elseif expr.args[1] == :(==) + return expr.args[2], MOI.EqualTo(expr.args[3]) + end + elseif expr.head == :comparison # Two-sided constraint. + @assert length(expr.args) == 5 + if expr.args[2] == expr.args[4] == :(<=) + return expr.args[3], MOI.Interval(expr.args[1], expr.args[5]) + elseif expr.args[2] == expr.args[4] == :(>=) + return expr.args[3], MOI.Interval(expr.args[5], expr.args[1]) + end + end + return error("Oops. The constraint $(expr) wasn't recognised.") +end + +function write_nlpblock( + object::T, + model::Model, + name_map::Dict{MOI.VariableIndex,String}, +) where {T<:Object} + nlp_block = MOI.get(model, MOI.NLPBlock()) + if nlp_block === nothing + return + end + MOI.initialize(nlp_block.evaluator, [:ExprGraph]) + variables = MOI.get(model, MOI.ListOfVariableIndices()) + if nlp_block.has_objective + objective = MOI.objective_expr(nlp_block.evaluator) + objective = _lift_variable_indices(objective) + sense = MOI.get(model, MOI.ObjectiveSense()) + object["objective"] = T( + "sense" => moi_to_object(sense), + "function" => moi_to_object(Nonlinear(objective), name_map), + ) + end + for (row, bounds) in enumerate(nlp_block.constraint_bounds) + constraint = MOI.constraint_expr(nlp_block.evaluator, row) + (func, set) = _extract_function_and_set(constraint) + func = _lift_variable_indices(func) + push!( + object["constraints"], + T( + "function" => moi_to_object(Nonlinear(func), name_map), + "set" => moi_to_object(set, name_map), + ), + ) + end + return +end + function write_objective( object::T, model::Model, @@ -70,6 +142,7 @@ function write_constraints( push!(object["constraints"], moi_to_object(index, model, name_map)) end end + return end """ @@ -138,6 +211,83 @@ function moi_to_object( return OrderedObject("type" => "Variable", "name" => name_map[foo]) end +function _convert_nonlinear_to_mof( + expr::Expr, + node_list::Vector{T}, + name_map::Dict{MOI.VariableIndex,String}, +) where {T<:Object} + if expr.head != :call + error("Expected an expression that was a function. Got $(expr).") + end + node = T("type" => string(expr.args[1]), "args" => T[]) + for i in 2:length(expr.args) + arg = expr.args[i] + push!(node["args"], _convert_nonlinear_to_mof(arg, node_list, name_map)) + end + push!(node_list, node) + return T("type" => "node", "index" => length(node_list)) +end + +function _convert_nonlinear_to_mof( + f::MOI.ScalarNonlinearFunction, + node_list::Vector{T}, + name_map::Dict{MOI.VariableIndex,String}, +) where {T<:Object} + node = T("type" => string(f.head), "args" => T[]) + for arg in f.args + push!(node["args"], _convert_nonlinear_to_mof(arg, node_list, name_map)) + end + push!(node_list, node) + return T("type" => "node", "index" => length(node_list)) +end + +function _convert_nonlinear_to_mof( + variable::MOI.VariableIndex, + ::Vector{T}, + name_map::Dict{MOI.VariableIndex,String}, +) where {T<:Object} + return T("type" => "variable", "name" => name_map[variable]) +end + +function _convert_nonlinear_to_mof( + value::Real, + ::Vector{T}, + name_map::Dict{MOI.VariableIndex,String}, +) where {T<:Object} + return T("type" => "real", "value" => value) +end + +function _convert_nonlinear_to_mof( + value::Complex, + ::Vector{T}, + ::Dict{MOI.VariableIndex,String}, +) where {T<:Object} + return T("type" => "complex", "real" => real(value), "imag" => imag(value)) +end + +function moi_to_object(foo::Nonlinear, name_map::Dict{MOI.VariableIndex,String}) + node_list = OrderedObject[] + foo_object = _convert_nonlinear_to_mof(foo.expr, node_list, name_map) + return OrderedObject( + "type" => "ScalarNonlinearFunction", + "root" => foo_object, + "node_list" => node_list, + ) +end + +function moi_to_object( + foo::MOI.ScalarNonlinearFunction, + name_map::Dict{MOI.VariableIndex,String}, +) + node_list = OrderedObject[] + root = _convert_nonlinear_to_mof(foo, node_list, name_map) + return OrderedObject( + "type" => "ScalarNonlinearFunction", + "root" => root, + "node_list" => node_list, + ) +end + # ========== Typed scalar functions ========== function moi_to_object( @@ -196,6 +346,19 @@ function moi_to_object( ) end +function moi_to_object( + foo::MOI.VectorNonlinearFunction, + name_map::Dict{MOI.VariableIndex,String}, +) + node_list = OrderedObject[] + rows = [_convert_nonlinear_to_mof(f, node_list, name_map) for f in foo.rows] + return OrderedObject( + "type" => "VectorNonlinearFunction", + "rows" => rows, + "node_list" => node_list, + ) +end + # ========== Typed vector functions ========== function moi_to_object( diff --git a/test/FileFormats/MOF/MOF.jl b/test/FileFormats/MOF/MOF.jl index 810ccde364..359146a756 100644 --- a/test/FileFormats/MOF/MOF.jl +++ b/test/FileFormats/MOF/MOF.jl @@ -48,20 +48,29 @@ function _validate(filename::String) ret, ) end + return end + return end struct UnsupportedSet <: MOI.AbstractSet end struct UnsupportedFunction <: MOI.AbstractFunction end -function _test_model_equality(model_string, variables, constraints; suffix = "") - model = MOF.Model() +function _test_model_equality( + model_string, + variables, + constraints; + suffix = "", + kwargs..., +) + model = MOF.Model(; kwargs...) MOI.Utilities.loadfromstring!(model, model_string) MOI.write_to_file(model, TEST_MOF_FILE * suffix) - model_2 = MOF.Model() + model_2 = MOF.Model(; kwargs...) MOI.read_from_file(model_2, TEST_MOF_FILE * suffix) MOI.Test.util_test_models_equal(model, model_2, variables, constraints) - return _validate(TEST_MOF_FILE * suffix) + _validate(TEST_MOF_FILE * suffix) + return end # hs071 @@ -134,43 +143,37 @@ function test_nonlinear_error_handling() string_to_variable = Dict{String,MOI.VariableIndex}() variable_to_string = Dict{MOI.VariableIndex,String}() # Test unsupported function for Expr -> MOF. - @test_throws Exception MOF.convert_expr_to_mof( + @test_throws Exception MOF._convert_nonlinear_to_mof( :(not_supported_function(x)), node_list, variable_to_string, ) - # Test unsupported function for MOF -> Expr. - @test_throws Exception MOF.convert_mof_to_expr( - MOF.OrderedObject("type" => "not_supported_function", "value" => 1), - node_list, - string_to_variable, - ) # Test n-ary function with no arguments. - @test_throws Exception MOF.convert_expr_to_mof( + @test_throws Exception MOF._convert_nonlinear_to_mof( :(min()), node_list, variable_to_string, ) # Test unary function with two arguments. - @test_throws Exception MOF.convert_expr_to_mof( + @test_throws Exception MOF._convert_nonlinear_to_mof( :(sin(x, y)), node_list, variable_to_string, ) # Test binary function with one arguments. - @test_throws Exception MOF.convert_expr_to_mof( + @test_throws Exception MOF._convert_nonlinear_to_mof( :(^(x)), node_list, variable_to_string, ) # An expression with something other than :call as the head. - @test_throws Exception MOF.convert_expr_to_mof( + @test_throws Exception MOF._convert_nonlinear_to_mof( :(a <= b <= c), node_list, variable_to_string, ) # Hit the default fallback with an un-interpolated complex number. - @test_throws Exception MOF.convert_expr_to_mof( + @test_throws Exception MOF._convert_nonlinear_to_mof( :(1 + 2im), node_list, variable_to_string, @@ -181,15 +184,42 @@ function test_nonlinear_error_handling() [MOI.VariableIndex(1)], ) # Function-in-Set - @test_throws Exception MOF.extract_function_and_set(:(foo in set)) + @test_throws Exception MOF._extract_function_and_set(:(foo in set)) # Not a constraint. - @test_throws Exception MOF.extract_function_and_set(:(x^2)) + @test_throws Exception MOF._extract_function_and_set(:(x^2)) # Two-sided constraints - @test MOF.extract_function_and_set(:(1 <= x <= 2)) == - MOF.extract_function_and_set(:(2 >= x >= 1)) == + @test MOF._extract_function_and_set(:(1 <= x <= 2)) == + MOF._extract_function_and_set(:(2 >= x >= 1)) == (:x, MOI.Interval(1, 2)) # Less-than constraint. - @test MOF.extract_function_and_set(:(x <= 2)) == (:x, MOI.LessThan(2)) + @test MOF._extract_function_and_set(:(x <= 2)) == (:x, MOI.LessThan(2)) +end + +function _convert_mof_to_expr( + node::T, + node_list::Vector{T}, + name_map::Dict{String,MOI.VariableIndex}, +) where {T} + head = haskey(node, "type") ? node["type"] : node["head"] + if head == "real" + return node["value"] + elseif head == "complex" + return Complex(node["real"], node["imag"]) + elseif head == "variable" + return name_map[node["name"]] + elseif head == "node" + return _convert_mof_to_expr( + node_list[node["index"]], + node_list, + name_map, + ) + else + expr = Expr(:call, Symbol(head)) + for arg in node["args"] + push!(expr.args, _convert_mof_to_expr(arg, node_list, name_map)) + end + return expr + end end function test_Roundtrip_nonlinear_expressions() @@ -234,9 +264,10 @@ function test_Roundtrip_nonlinear_expressions() :(ifelse($x > 0, 1, $y)), ] node_list = MOF.OrderedObject[] - object = MOF.convert_expr_to_mof(expr, node_list, var_to_string) - @test MOF.convert_mof_to_expr(object, node_list, string_to_var) == expr + object = MOF._convert_nonlinear_to_mof(expr, node_list, var_to_string) + @test _convert_mof_to_expr(object, node_list, string_to_var) == expr end + return end function test_nonlinear_readingwriting() @@ -710,6 +741,52 @@ c1: [1.0*x*x + -2.0x + 1.0, 2.0y + -4.0] in Nonnegatives(2) ) end +function test_scalarnonlinearfunction_objective() + return _test_model_equality( + """ +variables: x +minobjective: ScalarNonlinearFunction(exp(x)) +""", + ["x"], + String[]; + parse_as_nlpblock = false, + ) +end + +function test_scalarnonlinearfunction_constraint() + return _test_model_equality( + """ +variables: x +c1: ScalarNonlinearFunction(exp(x)^2) <= 1.0 +""", + ["x"], + ["c1"]; + parse_as_nlpblock = false, + ) +end + +function test_vectornonlinearfunction_objective() + return _test_model_equality( + """ +variables: x +minobjective: VectorNonlinearFunction([exp(x), sin(x)^2]) +""", + ["x"], + String[], + ) +end + +function test_vectornonlinearfunction_constraint() + return _test_model_equality( + """ +variables: x +c1: VectorNonlinearFunction([exp(x), x]) in Complements(2) +""", + ["x"], + ["c1"], + ) +end + function test_ExponentialCone() return _test_model_equality( """ diff --git a/test/FileFormats/MOF/nlp.mof.json b/test/FileFormats/MOF/nlp.mof.json index bdb6e635dc..83567ae428 100644 --- a/test/FileFormats/MOF/nlp.mof.json +++ b/test/FileFormats/MOF/nlp.mof.json @@ -2,7 +2,7 @@ "name": "MathOptFormat Model", "version": { "major": 1, - "minor": 5 + "minor": 6 }, "variables": [ {