diff --git a/Project.toml b/Project.toml index edc6c1f008..540978ab90 100644 --- a/Project.toml +++ b/Project.toml @@ -11,6 +11,7 @@ JSONSchema = "7d188eb4-7ad8-530c-ae41-71a32a6d4692" LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" MutableArithmetics = "d8a4904e-b15c-11e9-3269-09a3773c0cb0" OrderedCollections = "bac558e1-5e72-5ebc-8fee-abe8a469f55d" +Printf = "de0858da-6303-5e67-8744-51eddeeeb8d7" SparseArrays = "2f01184e-e22b-5df5-ae63-d93ebab69eaf" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" Unicode = "4ec0a83e-493e-50e2-b9ac-8f72acf5a8f5" diff --git a/docs/src/submodules/Utilities/overview.md b/docs/src/submodules/Utilities/overview.md index c643e9a432..4b6f8c3199 100644 --- a/docs/src/submodules/Utilities/overview.md +++ b/docs/src/submodules/Utilities/overview.md @@ -265,6 +265,52 @@ with model cache MOIU.GenericModel{Float64,MOIU.ModelFunctionConstraints{Float64 with optimizer MOIU.GenericOptimizer{Float64,MOIU.VectorOfConstraints{MOI.VectorAffineFunction{Float64},MOI.Complements}} ``` +## Printing + +Use `print` to print the formulation of the model. +```jldoctest utilities_print +julia> model = MOI.Utilities.Model{Float64}(); + +julia> x = MOI.add_variable(model) +MathOptInterface.VariableIndex(1) + +julia> MOI.set(model, MOI.VariableName(), x, "x_var") + +julia> f = MOI.SingleVariable(x) +MathOptInterface.SingleVariable(MathOptInterface.VariableIndex(1)) + +julia> MOI.add_constraint(model, f, MOI.ZeroOne()) +MathOptInterface.ConstraintIndex{MathOptInterface.SingleVariable,MathOptInterface.ZeroOne}(1) + +julia> MOI.set(model, MOI.ObjectiveFunction{typeof(f)}(), f) + +julia> MOI.set(model, MOI.ObjectiveSense(), MOI.MAX_SENSE) + +julia> print(model) +Maximize SingleVariable: + x_var + +Subject to: + +SingleVariable-in-ZeroOne + x_var ∈ {0, 1} +``` + +Use [`Utilities.latex_formulation`](@Ref) to display the model in LaTeX form: +```jldoctest utilities_print +julia> MOI.Utilities.latex_formulation(model) +$$ \begin{aligned} +\max\quad & x\_var \\ +\text{Subject to}\\ + & \text{SingleVariable-in-ZeroOne} \\ + & x\_var \in \{0, 1\} \\ +\end{aligned} $$ +``` + +!!! tip + In IJulia, calling `print` or ending a cell with + [`Utilities.latex_formulation`](@ref) will render the model in LaTex. + ## Copy utilities !!! info diff --git a/docs/src/submodules/Utilities/reference.md b/docs/src/submodules/Utilities/reference.md index f02f54e408..9889045153 100644 --- a/docs/src/submodules/Utilities/reference.md +++ b/docs/src/submodules/Utilities/reference.md @@ -47,6 +47,12 @@ Utilities.state Utilities.mode ``` +## Printing + +```@docs +Utilities.latex_formulation +``` + ## Copy utilities The following utilities can be used to implement [`copy_to`](@ref). See diff --git a/src/Utilities/Utilities.jl b/src/Utilities/Utilities.jl index 5dcee28f15..b689f2f0d1 100644 --- a/src/Utilities/Utilities.jl +++ b/src/Utilities/Utilities.jl @@ -64,6 +64,7 @@ include("parser.jl") include("mockoptimizer.jl") include("cachingoptimizer.jl") include("universalfallback.jl") +include("print.jl") include("lazy_iterators.jl") diff --git a/src/Utilities/print.jl b/src/Utilities/print.jl new file mode 100644 index 0000000000..5c0bee7fd7 --- /dev/null +++ b/src/Utilities/print.jl @@ -0,0 +1,649 @@ +using Printf + +_drop_moi(s) = replace(string(s), "MathOptInterface." => "") + +struct _PrintOptions{T<:MIME} + simplify_coefficients::Bool + default_name::String + print_types::Bool + + """ + _PrintOptions( + mime::MIME; + simplify_coefficients::Bool = false, + default_name::String = "v", + print_types::Bool = true, + ) + + A struct to control options for printing. + + ## Arguments + + * `simplify_coefficients` : Simplify coefficients if possible by omitting + them or removing trailing zeros. + * `default_name` : The name given to variables with an empty name. + * `print_types` : Print the MOI type of each function and set for clarity. + """ + function _PrintOptions( + mime::MIME; + simplify_coefficients::Bool = false, + default_name::String = "v", + print_types::Bool = true, + ) + return new{typeof(mime)}( + simplify_coefficients, + default_name, + print_types, + ) + end +end + +function _to_string(mime::MIME, args...; kwargs...) + return _to_string(_PrintOptions(mime), args...; kwargs...) +end + +#------------------------------------------------------------------------ +# Math Symbols +#------------------------------------------------------------------------ + +# REPL-specific symbols +# Anything here: https://en.wikipedia.org/wiki/Windows-1252 +# should probably work fine on Windows +function _to_string(::_PrintOptions, name::Symbol) + if name == :leq + return "<=" + elseif name == :geq + return ">=" + elseif name == :eq + return "==" + elseif name == :times + return "*" + elseif name == :sq + return "²" + elseif name == :in + return Sys.iswindows() ? "in" : "∈" + end +end + +function _to_string(::_PrintOptions{MIME"text/latex"}, name::Symbol) + if name == :leq + return "\\leq" + elseif name == :geq + return "\\geq" + elseif name == :eq + return "=" + elseif name == :times + return "\\times " + elseif name == :sq + return "^2" + elseif name == :in + return "\\in" + end +end + +#------------------------------------------------------------------------ +# Functions +#------------------------------------------------------------------------ + +function _to_string( + options::_PrintOptions, + model::MOI.ModelLike, + v::MOI.VariableIndex, +) + var_name = MOI.get(model, MOI.VariableName(), v) + if isempty(var_name) + return string(options.default_name, "[", v.value, "]") + else + return var_name + end +end + +function _to_string( + options::_PrintOptions{MIME"text/latex"}, + model::MOI.ModelLike, + v::MOI.VariableIndex, +) + var_name = MOI.get(model, MOI.VariableName(), v) + if isempty(var_name) + return string(options.default_name, "_{", v.value, "}") + end + # We need to escape latex math characters that appear in the name. + # However, it's probably impractical to catch everything, so let's just + # escape the common ones: + # Escape underscores to prevent them being treated as subscript markers. + var_name = replace(var_name, "_" => "\\_") + # Escape carets to prevent them being treated as superscript markers. + var_name = replace(var_name, "^" => "\\^") + # Convert any x[args] to x_{args} so that indices on x print as subscripts. + m = match(r"^(.*)\[(.+)\]$", var_name) + if m !== nothing + var_name = m[1] * "_{" * m[2] * "}" + end + return var_name +end + +function _shorten(options::_PrintOptions, x::Float64) + if options.simplify_coefficients && isinteger(x) + return string(round(Int, x)) + end + return string(x) +end + +function _to_string( + options::_PrintOptions, + model::MOI.ModelLike, + f::MOI.SingleVariable, +) + return _to_string(options, model, f.variable) +end + +""" + _to_string(options::_PrintOptions, c::Float64, x::String) + +Write a coefficient-name pair to string. There are a few cases to handle. + + | is_first | !is_first + ----------------------------- + +2.1x | "2.1 x" | " + 2.1 x" + -2.1x | "-2.1 x" | " - 2.1 x" + ----------------------------- + +2.0x | "2 x" | " + 2 x" + -2.0x | "-2 x" | " - 2 x" + +1.0x | "x" | " + x" + -1.0x | "-x" | " - x" +""" +function _to_string( + options::_PrintOptions, + c::Float64, + x::String; + is_first::Bool, +) + prefix = if is_first + c < 0 ? "-" : "" + else + c < 0 ? " - " : " + " + end + s = _shorten(options, abs(c)) + if options.simplify_coefficients && s == "1" + return string(prefix, x) + else + return string(prefix, s, " ", x) + end +end + +function _to_string( + options::_PrintOptions, + model::MOI.ModelLike, + term::MOI.ScalarAffineTerm; + is_first::Bool, +) + name = _to_string(options, model, term.variable) + return _to_string(options, term.coefficient, name; is_first = is_first) +end + +function _to_string( + options::_PrintOptions, + model::MOI.ModelLike, + f::MOI.ScalarAffineFunction, +) + s = _shorten(options, f.constant) + if options.simplify_coefficients && iszero(f.constant) + s = "" + end + is_first = isempty(s) + for term in f.terms + s *= _to_string(options, model, term; is_first = is_first) + is_first = false + end + return s +end + +function _to_string( + options::_PrintOptions, + model::MOI.ModelLike, + term::MOI.ScalarQuadraticTerm; + is_first::Bool, +) + name_1 = _to_string(options, model, term.variable_1) + name_2 = _to_string(options, model, term.variable_2) + # Be careful here when printing the coefficient. ScalarQuadraticFunction + # assumes an additional 0.5 factor! + coef = term.coefficient + name = if term.variable_1 == term.variable_2 + coef /= 2 + string(name_1, _to_string(options, :sq)) + else + string(name_1, _to_string(options, :times), name_2) + end + return _to_string(options, coef, name; is_first = is_first) +end + +function _to_string( + options::_PrintOptions, + model::MOI.ModelLike, + f::MOI.ScalarQuadraticFunction, +) + s = _shorten(options, f.constant) + if options.simplify_coefficients && iszero(f.constant) + s = "" + end + is_first = isempty(s) + for term in f.affine_terms + s *= _to_string(options, model, term; is_first = is_first) + is_first = false + end + for term in f.quadratic_terms + s *= _to_string(options, model, term; is_first = is_first) + is_first = false + end + return s +end + +function _to_string( + options::_PrintOptions, + model::MOI.ModelLike, + f::MOI.AbstractVectorFunction, +) + rows = map(fi -> _to_string(options, model, fi), eachscalar(f)) + max_length = maximum(length.(rows)) + s = join(map(r -> string("│", rpad(r, max_length), "│"), rows), '\n') + return string( + "┌", + rpad("", max_length), + "┐\n", + s, + "\n└", + rpad("", max_length), + "┘", + ) +end + +function _to_string( + options::_PrintOptions{MIME"text/latex"}, + model::MOI.ModelLike, + f::MOI.AbstractVectorFunction, +) + return string( + "\\begin{bmatrix}\n", + join( + map(fi -> _to_string(options, model, fi), eachscalar(f)), + "\\\\\n", + ), + "\\end{bmatrix}", + ) +end + +#------------------------------------------------------------------------ +# Sets +#------------------------------------------------------------------------ + +function _to_string(options::_PrintOptions, set::MOI.LessThan) + return string(_to_string(options, :leq), " ", _shorten(options, set.upper)) +end + +function _to_string(options::_PrintOptions{MIME"text/latex"}, set::MOI.LessThan) + return string("\\le ", _shorten(options, set.upper)) +end + +function _to_string(options::_PrintOptions, set::MOI.GreaterThan) + return string(_to_string(options, :geq), " ", _shorten(options, set.lower)) +end + +function _to_string( + options::_PrintOptions{MIME"text/latex"}, + set::MOI.GreaterThan, +) + return string("\\ge ", _shorten(options, set.lower)) +end + +function _to_string(options::_PrintOptions, set::MOI.EqualTo) + return string(_to_string(options, :eq), " ", _shorten(options, set.value)) +end + +function _to_string(options::_PrintOptions{MIME"text/latex"}, set::MOI.EqualTo) + return string("= ", _shorten(options, set.value)) +end + +function _to_string(options::_PrintOptions, set::MOI.Interval) + return string( + _to_string(options, :in), + " [", + _shorten(options, set.lower), + ", ", + _shorten(options, set.upper), + "]", + ) +end + +function _to_string(options::_PrintOptions{MIME"text/latex"}, set::MOI.Interval) + return string( + "\\in \\[", + _shorten(options, set.lower), + ", ", + _shorten(options, set.upper), + "\\]", + ) +end + +function _to_string(options::_PrintOptions, ::MOI.ZeroOne) + return string(_to_string(options, :in), " {0, 1}") +end + +_to_string(::_PrintOptions{MIME"text/latex"}, ::MOI.ZeroOne) = "\\in \\{0, 1\\}" + +function _to_string(options::_PrintOptions, ::MOI.Integer) + return string(_to_string(options, :in), " ℤ") +end + +function _to_string(::_PrintOptions{MIME"text/latex"}, ::MOI.Integer) + return "\\in \\mathbb{Z}" +end + +function _to_string(options::_PrintOptions, set::MOI.AbstractSet) + return string(_to_string(options, :in), " ", _drop_moi(set)) +end + +function _to_string(::_PrintOptions{MIME"text/latex"}, set::MOI.AbstractSet) + set_str = replace(replace(_drop_moi(set), "{" => "\\{"), "}" => "\\}") + return string("\\in \\text{", set_str, "}") +end + +#------------------------------------------------------------------------ +# Constraints +#------------------------------------------------------------------------ + +function _to_string( + options::_PrintOptions, + model::MOI.ModelLike, + cref::MOI.ConstraintIndex, +) + f = MOI.get(model, MOI.ConstraintFunction(), cref) + s = MOI.get(model, MOI.ConstraintSet(), cref) + return string(_to_string(options, model, f), " ", _to_string(options, s)) +end + +#------------------------------------------------------------------------ +# Nonlinear constraints +#------------------------------------------------------------------------ + +""" + _VariableNode + +A type used to work-around the default printing of Julia expressions. + +Without this type, if we subsititued the variable names into the expression +and then converted to a string, each variable would be printed with enclosing +`"`. + +To work-around this, create a new type and overload `show`. +""" +struct _VariableNode + x::String +end +Base.show(io::IO, x::_VariableNode) = print(io, x.x) + +_replace_names(::_PrintOptions, ::MOI.ModelLike, x, ::Any) = x +function _replace_names( + options::_PrintOptions, + model::MOI.ModelLike, + x::Expr, + lookup, +) + if x.head == :ref + return get!(lookup, x.args[2]) do + return _VariableNode(_to_string(options, model, x.args[2])) + end + else + for (i, arg) in enumerate(x.args) + x.args[i] = _replace_names(options, model, arg, lookup) + end + end + return x +end + +function _replace_nonlinear_latex(::_PrintOptions{MIME"text/latex"}, s::String) + s = replace(s, " * " => " \\times ") + s = replace(s, " >= " => " \\ge ") + s = replace(s, " <= " => " \\le ") + s = replace(s, " == " => " = ") + return s +end +_replace_nonlinear_latex(::_PrintOptions, s::String) = s + +function _print_nonlinear_constraints( + io::IO, + options::_PrintOptions{MIME"text/plain"}, + model::MOI.ModelLike, + block::MOI.NLPBlockData, +) + lookup = Dict{MOI.VariableIndex,_VariableNode}() + if options.print_types + println(io, "\nNonlinear") + end + has_expr = :ExprGraph in MOI.features_available(block.evaluator) + for (i, bound) in enumerate(block.constraint_bounds) + if has_expr + ex = MOI.constraint_expr(block.evaluator, i) + println(io, " ", _replace_names(options, model, ex, lookup)) + else + println(io, " ", bound.lower, " <= g_$(i)(x) <= ", bound.upper) + end + end +end + +function _print_nonlinear_constraints( + io::IO, + options::_PrintOptions{MIME"text/latex"}, + model::MOI.ModelLike, + block::MOI.NLPBlockData, +) + lookup = Dict{MOI.VariableIndex,_VariableNode}() + if options.print_types + println(io, " & \\text{Nonlinear} \\\\") + end + has_expr = :ExprGraph in MOI.features_available(block.evaluator) + for (i, bound) in enumerate(block.constraint_bounds) + if has_expr + ex = MOI.constraint_expr(block.evaluator, i) + nl_c = string(_replace_names(options, model, ex, lookup)) + println(io, " & ", _replace_nonlinear_latex(options, nl_c), " \\\\") + else + println( + io, + "& ", + bound.lower, + " \\le g_$(i)(x) \\le ", + bound.upper, + " \\\\", + ) + end + end +end + +function _print_nonlinear_constraints( + ::IO, + ::_PrintOptions, + ::MOI.ModelLike, + ::Nothing, +) + return +end + +#------------------------------------------------------------------------ +# ObjectiveFunction +#------------------------------------------------------------------------ + +function _objective_function_string( + options::_PrintOptions, + model::MOI.ModelLike, + ::Nothing, +) + F = MOI.get(model, MOI.ObjectiveFunctionType()) + f = MOI.get(model, MOI.ObjectiveFunction{F}()) + return _drop_moi(F), _to_string(options, model, f) +end + +function _objective_function_string( + options::_PrintOptions, + model::MOI.ModelLike, + block::MOI.NLPBlockData, +) + lookup = Dict{MOI.VariableIndex,_VariableNode}() + if block.has_objective + f = "f(x)" + if :ExprGraph in MOI.features_available(block.evaluator) + ex = MOI.objective_expr(block.evaluator) + f = string(_replace_names(options, model, ex, lookup)) + f = _replace_nonlinear_latex(options, f) + end + return "Nonlinear", f + else + return _objective_function_string(options, model, nothing) + end +end + +#------------------------------------------------------------------------ +# MOI.ModelLike +#------------------------------------------------------------------------ + +function _nlp_block(model::MOI.ModelLike) + try + block = MOI.get(model, MOI.NLPBlock()) + if :ExprGraph in MOI.features_available(block.evaluator) + MOI.initialize(block.evaluator, [:ExprGraph]) + end + return block + catch + return nothing + end +end + +""" + _print_model( + io::IO, + options::_PrintOptions{MIME"text/plain"}, + model::MOI.ModelLike, + ) + +Print a plain-text formulation of `model` to `io`. +""" +function _print_model( + io::IO, + options::_PrintOptions{MIME"text/plain"}, + model::MOI.ModelLike, +) + nlp_block = _nlp_block(model) + sense = MOI.get(model, MOI.ObjectiveSense()) + if sense == MOI.FEASIBILITY_SENSE + println(io, "Feasibility") + else + F, f = _objective_function_string(options, model, nlp_block) + sense_s = sense == MOI.MIN_SENSE ? "Minimize" : "Maximize" + if options.print_types + println(io, sense_s, " ", F, ":\n ", f) + else + println(io, sense_s, ": ", f) + end + end + println(io, "\nSubject to:") + for (F, S) in MOI.get(model, MOI.ListOfConstraintTypesPresent()) + if options.print_types + println(io, "\n$(_drop_moi(F))-in-$(_drop_moi(S))") + end + for cref in MOI.get(model, MOI.ListOfConstraintIndices{F,S}()) + s = _to_string(options, model, cref) + println(io, " ", replace(s, '\n' => "\n ")) + end + end + _print_nonlinear_constraints(io, options, model, nlp_block) + return +end + +""" + _print_model( + io::IO, + options::_PrintOptions{MIME"text/latex"}, + model::MOI.ModelLike, + ) + +Print a LaTeX formulation of `model` to `io`. +""" +function _print_model( + io::IO, + options::_PrintOptions{MIME"text/latex"}, + model::MOI.ModelLike, +) + nlp_block = _nlp_block(model) + println(io, "\$\$ \\begin{aligned}") + sense = MOI.get(model, MOI.ObjectiveSense()) + if sense == MOI.FEASIBILITY_SENSE + println(io, "\\text{feasibility}\\\\") + else + F, f = _objective_function_string(options, model, nlp_block) + sense_s = sense == MOI.MIN_SENSE ? "min" : "max" + println(io, "\\", sense_s, "\\quad & ", f, " \\\\") + end + println(io, "\\text{Subject to}\\\\") + for (F, S) in MOI.get(model, MOI.ListOfConstraintTypesPresent()) + if options.print_types + println(io, " & \\text{$(_drop_moi(F))-in-$(_drop_moi(S))} \\\\") + end + for cref in MOI.get(model, MOI.ListOfConstraintIndices{F,S}()) + println(io, " & ", _to_string(options, model, cref), " \\\\") + end + end + _print_nonlinear_constraints(io, options, model, nlp_block) + return print(io, "\\end{aligned} \$\$") +end + +#------------------------------------------------------------------------ +# Latex +#------------------------------------------------------------------------ + +struct _LatexModel{T<:MOI.ModelLike} + model::T + kwargs::Any +end + +""" + latex_formulation(model::MOI.ModelLike; kwargs...) + +Wrap `model` in a type so that it can be pretty-printed as `text/latex` in a +notebook like IJulia, or in Documenter. + +To render the model, end the cell with `latex_formulation(model)`, or call +`display(latex_formulation(model))` in to force the display of the model from +inside a function. + +Possible keyword arguments are: + + * `simplify_coefficients` : Simplify coefficients if possible by omitting + them or removing trailing zeros. + * `default_name` : The name given to variables with an empty name. + * `print_types` : Print the MOI type of each function and set for clarity. +""" +latex_formulation(model::MOI.ModelLike; kwargs...) = _LatexModel(model, kwargs) + +function Base.show(io::IO, model::_LatexModel) + return _print_model( + io, + _PrintOptions(MIME("text/latex"); model.kwargs...), + model.model, + ) +end + +Base.show(io::IO, ::MIME"text/latex", model::_LatexModel) = show(io, model) + +function Base.print(model::MOI.ModelLike; kwargs...) + for d in Base.Multimedia.displays + if Base.Multimedia.displayable(d, "text/latex") && + startswith("$(typeof(d))", "IJulia.") + return display(d, "text/latex", latex_formulation(model; kwargs...)) + end + end + return print(stdout, model; kwargs...) +end + +function Base.print(io::IO, model::MOI.ModelLike; kwargs...) + return _print_model(io, _PrintOptions(MIME("text/plain"); kwargs...), model) +end diff --git a/test/Utilities/Utilities.jl b/test/Utilities/Utilities.jl index df0037a83a..e6c94881e3 100644 --- a/test/Utilities/Utilities.jl +++ b/test/Utilities/Utilities.jl @@ -1,49 +1,10 @@ using Test -@testset "Functions" begin - include("functions.jl") -end -@testset "Mutable Arithmetics" begin - include("mutable_arithmetics.jl") -end -@testset "Sets" begin - include("sets.jl") -end -@testset "Constraints" begin - include("constraints.jl") -end -@testset "Variables" begin - include("variables.jl") -end -@testset "Model" begin - include("model.jl") -end -@testset "Universal Fallback" begin - include("universalfallback.jl") -end -@testset "Parser" begin - include("parser.jl") -end -@testset "Mock Optimizer" begin - include("mockoptimizer.jl") -end -@testset "Caching Optimizer" begin - include("cachingoptimizer.jl") -end -@testset "Copy" begin - include("copy.jl") -end - -@testset "CleverDicts" begin - include("CleverDicts.jl") -end -@testset "DoubleDicts" begin - include("DoubleDicts.jl") -end -@testset "Lazy iterators" begin - include("lazy_iterators.jl") -end - -@testset "Print with acronym" begin - include("print_with_acronym.jl") +for file in readdir(@__DIR__) + if file in ["Utilities.jl"] + continue + end + @testset "$(file)" begin + include(file) + end end diff --git a/test/Utilities/print.jl b/test/Utilities/print.jl new file mode 100644 index 0000000000..533b2047e6 --- /dev/null +++ b/test/Utilities/print.jl @@ -0,0 +1,588 @@ +module TestPrint + +using MathOptInterface +const MOI = MathOptInterface +const MOIU = MOI.Utilities + +using Test + +const LATEX = MIME("text/latex") +const PLAIN = MIME("text/plain") +const IN = Sys.iswindows() ? "in" : "∈" + +# Windows fun... +function _string_compare(a, b) + @test replace(a, "\r\n" => "\n") == replace(b, "\r\n" => "\n") + return +end + +function test_nonname_variable() + model = MOIU.Model{Float64}() + x = MOI.add_variable(model) + @test MOIU._to_string(PLAIN, model, x) == "v[1]" + @test MOIU._to_string(LATEX, model, x) == "v_{1}" +end + +function test_numbers() + options = + MOIU._PrintOptions(MIME("text/plain"); simplify_coefficients = true) + @test MOIU._to_string(options, 1.0, "x"; is_first = true) == "x" + @test MOIU._to_string(options, 1.0, "x"; is_first = false) == " + x" + @test MOIU._to_string(options, -1.0, "x"; is_first = true) == "-x" + @test MOIU._to_string(options, -1.0, "x"; is_first = false) == " - x" + @test MOIU._to_string(options, 1.2, "x"; is_first = true) == "1.2 x" + @test MOIU._to_string(options, 1.2, "x"; is_first = false) == " + 1.2 x" + @test MOIU._to_string(options, -1.2, "x"; is_first = true) == "-1.2 x" + @test MOIU._to_string(options, -1.2, "x"; is_first = false) == " - 1.2 x" + + options = + MOIU._PrintOptions(MIME("text/plain"); simplify_coefficients = false) + @test MOIU._to_string(options, 1.0, "x"; is_first = true) == "1.0 x" + @test MOIU._to_string(options, 1.0, "x"; is_first = false) == " + 1.0 x" + @test MOIU._to_string(options, -1.0, "x"; is_first = true) == "-1.0 x" + @test MOIU._to_string(options, -1.0, "x"; is_first = false) == " - 1.0 x" + @test MOIU._to_string(options, 1.2, "x"; is_first = true) == "1.2 x" + @test MOIU._to_string(options, 1.2, "x"; is_first = false) == " + 1.2 x" + @test MOIU._to_string(options, -1.2, "x"; is_first = true) == "-1.2 x" + @test MOIU._to_string(options, -1.2, "x"; is_first = false) == " - 1.2 x" +end + +function test_variable() + model = MOIU.Model{Float64}() + x = MOI.add_variable(model) + for (name, latex_name) in + [("x", "x"), ("x_y", "x\\_y"), ("x^2", "x\\^2"), ("x[a,b]", "x_{a,b}")] + MOI.set(model, MOI.VariableName(), x, name) + @test MOIU._to_string(PLAIN, model, x) == name + @test MOIU._to_string(LATEX, model, x) == latex_name + end +end + +function test_single_variable() + model = MOIU.Model{Float64}() + x = MOI.add_variable(model) + MOI.set(model, MOI.VariableName(), x, "x") + f = MOI.SingleVariable(x) + @test MOIU._to_string(PLAIN, model, f) == "x" + @test MOIU._to_string(LATEX, model, f) == "x" +end + +function test_ScalarAffineTerm() + model = MOIU.Model{Float64}() + x = MOI.add_variable(model) + MOI.set(model, MOI.VariableName(), x, "x") + @test MOIU._to_string( + PLAIN, + model, + MOI.ScalarAffineTerm(-1.2, x); + is_first = false, + ) == " - 1.2 x" + @test MOIU._to_string( + LATEX, + model, + MOI.ScalarAffineTerm(1.2, x); + is_first = false, + ) == " + 1.2 x" +end + +function test_ScalarAffineFunction() + model = MOIU.Model{Float64}() + x = MOI.add_variable(model) + MOI.set(model, MOI.VariableName(), x, "x") + f = MOI.ScalarAffineFunction( + MOI.ScalarAffineTerm.([-1.2, 1.3], [x, x]), + 1.4, + ) + @test MOIU._to_string(PLAIN, model, f) == "1.4 - 1.2 x + 1.3 x" + @test MOIU._to_string(LATEX, model, f) == "1.4 - 1.2 x + 1.3 x" +end + +function test_ScalarQuadraticTerm() + model = MOIU.Model{Float64}() + x = MOI.add_variable(model) + y = MOI.add_variable(model) + MOI.set(model, MOI.VariableName(), x, "x") + MOI.set(model, MOI.VariableName(), y, "y") + term = MOI.ScalarQuadraticTerm(-1.2, x, x) + @test MOIU._to_string(PLAIN, model, term; is_first = false) == " - 0.6 x²" + @test MOIU._to_string(LATEX, model, term; is_first = false) == " - 0.6 x^2" + term = MOI.ScalarQuadraticTerm(1.2, x, y) + @test MOIU._to_string(PLAIN, model, term; is_first = false) == " + 1.2 x*y" + @test MOIU._to_string(LATEX, model, term; is_first = false) == + " + 1.2 x\\times y" +end + +function test_ScalarQuadraticFunction() + model = MOIU.Model{Float64}() + x = MOI.add_variable(model) + y = MOI.add_variable(model) + MOI.set(model, MOI.VariableName(), x, "x") + MOI.set(model, MOI.VariableName(), y, "y") + f = MOI.ScalarQuadraticFunction( + MOI.ScalarAffineTerm.([-1.2, 1.3], [x, x]), + MOI.ScalarQuadraticTerm.([0.5, 0.6], [x, x], [x, y]), + 1.4, + ) + @test MOIU._to_string(PLAIN, model, f) == + "1.4 - 1.2 x + 1.3 x + 0.25 x² + 0.6 x*y" + @test MOIU._to_string(LATEX, model, f) == + "1.4 - 1.2 x + 1.3 x + 0.25 x^2 + 0.6 x\\times y" +end + +function test_VectorOfVariables() + model = MOIU.Model{Float64}() + x = MOI.add_variable(model) + y = MOI.add_variable(model) + MOI.set(model, MOI.VariableName(), x, "x") + MOI.set(model, MOI.VariableName(), y, "y") + f = MOI.VectorOfVariables([x, y]) + @test MOIU._to_string(PLAIN, model, f) == "┌ ┐\n│x│\n│y│\n└ ┘" + @test MOIU._to_string(LATEX, model, f) == + "\\begin{bmatrix}\nx\\\\\ny\\end{bmatrix}" +end + +function test_LessThan() + s = MOI.LessThan(1.2) + @test MOIU._to_string(PLAIN, s) == "<= 1.2" + @test MOIU._to_string(LATEX, s) == "\\le 1.2" +end + +function test_GreaterThan() + s = MOI.GreaterThan(1.2) + @test MOIU._to_string(PLAIN, s) == ">= 1.2" + @test MOIU._to_string(LATEX, s) == "\\ge 1.2" +end + +function test_EqualTo() + s = MOI.EqualTo(1.2) + @test MOIU._to_string(PLAIN, s) == "== 1.2" + @test MOIU._to_string(LATEX, s) == "= 1.2" +end + +function test_Interval() + s = MOI.Interval(1.2, 1.3) + @test MOIU._to_string(PLAIN, s) == "$(IN) [1.2, 1.3]" + @test MOIU._to_string(LATEX, s) == "\\in \\[1.2, 1.3\\]" +end + +function test_ZeroOne() + s = MOI.ZeroOne() + @test MOIU._to_string(PLAIN, s) == "$(IN) {0, 1}" + @test MOIU._to_string(LATEX, s) == "\\in \\{0, 1\\}" +end + +function test_Integer() + s = MOI.Integer() + @test MOIU._to_string(PLAIN, s) == "$(IN) ℤ" + @test MOIU._to_string(LATEX, s) == "\\in \\mathbb{Z}" +end + +function test_ExponentialCone() + s = MOI.ExponentialCone() + @test MOIU._to_string(PLAIN, s) == "$(IN) ExponentialCone()" + @test MOIU._to_string(LATEX, s) == "\\in \\text{ExponentialCone()}" +end + +function test_feasibility() + model = MOIU.Model{Float64}() + @test sprint(print, model) == "Feasibility\n\nSubject to:\n" + _string_compare( + sprint(print, MOIU.latex_formulation(model)), + raw""" + $$ \begin{aligned} + \text{feasibility}\\ + \text{Subject to}\\ + \end{aligned} $$""", + ) + return +end + +function test_min() + model = MOIU.Model{Float64}() + MOIU.loadfromstring!(model, "variables: x\nminobjective: x") + @test sprint(print, model) == """ + Minimize SingleVariable: + x + + Subject to: + """ + _string_compare( + sprint(print, MOIU.latex_formulation(model)), + raw""" + $$ \begin{aligned} + \min\quad & x \\ + \text{Subject to}\\ + \end{aligned} $$""", + ) + return +end + +function test_max() + model = MOIU.Model{Float64}() + MOIU.loadfromstring!(model, "variables: x\nmaxobjective: x") + @test sprint(print, model) == """ + Maximize SingleVariable: + x + + Subject to: + """ + _string_compare( + sprint(print, MOIU.latex_formulation(model)), + raw""" + $$ \begin{aligned} + \max\quad & x \\ + \text{Subject to}\\ + \end{aligned} $$""", + ) + return +end + +function test_model() + model = MOIU.Model{Float64}() + MOIU.loadfromstring!( + model, + """ + variables: x, y, z + minobjective: x + 2 + 3.1*y + -1.2*z + c1: x >= 0.1 + c2: y in ZeroOne() + c2: z in Integer() + c3: [x, y] in SecondOrderCone(2) + c4: [1, x, y] in SecondOrderCone(2) + c4: [1.0 * x * x, y, 1] in ExponentialCone() + c4: [1, 1.0 * x * x, y] in ExponentialCone() + c2: x in ZeroOne() + c5: 2.0 * x * x + y + -1 * z <= 1.0 + c5: x + x >= 1.0 + c5: x + x in Interval(1.0, 2.0) + c5: x + -1 * y == 0.0 + """, + ) + @test sprint(print, model) == """ + Minimize ScalarAffineFunction{Float64}: + 2.0 + 1.0 x + 3.1 y - 1.2 z + + Subject to: + + ScalarAffineFunction{Float64}-in-EqualTo{Float64} + 0.0 + 1.0 x - 1.0 y == 0.0 + + ScalarAffineFunction{Float64}-in-GreaterThan{Float64} + 0.0 + 2.0 x >= 1.0 + + ScalarAffineFunction{Float64}-in-Interval{Float64} + 0.0 + 2.0 x $(IN) [1.0, 2.0] + + ScalarQuadraticFunction{Float64}-in-LessThan{Float64} + 0.0 + 1.0 y - 1.0 z + 2.0 x² <= 1.0 + + VectorOfVariables-in-SecondOrderCone + ┌ ┐ + │x│ + │y│ + └ ┘ $(IN) SecondOrderCone(2) + + VectorAffineFunction{Float64}-in-SecondOrderCone + ┌ ┐ + │1.0 │ + │0.0 + 1.0 x│ + │0.0 + 1.0 y│ + └ ┘ $(IN) SecondOrderCone(2) + + VectorQuadraticFunction{Float64}-in-ExponentialCone + ┌ ┐ + │0.0 + 1.0 x²│ + │0.0 + 1.0 y │ + │1.0 │ + └ ┘ $(IN) ExponentialCone() + ┌ ┐ + │1.0 │ + │0.0 + 1.0 x²│ + │0.0 + 1.0 y │ + └ ┘ $(IN) ExponentialCone() + + SingleVariable-in-GreaterThan{Float64} + x >= 0.1 + + SingleVariable-in-Integer + z $(IN) ℤ + + SingleVariable-in-ZeroOne + x $(IN) {0, 1} + y $(IN) {0, 1} + """ +end + +function test_latex() + model = MOIU.Model{Float64}() + MOIU.loadfromstring!( + model, + """ + variables: x, y, z + minobjective: x + 2 + 3.1*y + -1.2*z + c1: x >= 0.1 + c2: y in ZeroOne() + c2: z in Integer() + c3: [x, y] in SecondOrderCone(2) + c4: [1, x, y] in SecondOrderCone(2) + c4: [1.0 * x * x, y, 1] in ExponentialCone() + c4: [1, 1.0 * x * x, y] in ExponentialCone() + c2: x in ZeroOne() + c5: 2.0 * x * x + y + -1 * z <= 1.0 + c5: x + x >= 1.0 + c5: x + x in Interval(1.0, 2.0) + c5: x + -1 * y == 0.0 + """, + ) + _string_compare( + sprint( + io -> show(io, MIME("text/latex"), MOIU.latex_formulation(model)), + ), + raw""" + $$ \begin{aligned} + \min\quad & 2.0 + 1.0 x + 3.1 y - 1.2 z \\ + \text{Subject to}\\ + & \text{ScalarAffineFunction{Float64}-in-EqualTo{Float64}} \\ + & 0.0 + 1.0 x - 1.0 y = 0.0 \\ + & \text{ScalarAffineFunction{Float64}-in-GreaterThan{Float64}} \\ + & 0.0 + 2.0 x \ge 1.0 \\ + & \text{ScalarAffineFunction{Float64}-in-Interval{Float64}} \\ + & 0.0 + 2.0 x \in \[1.0, 2.0\] \\ + & \text{ScalarQuadraticFunction{Float64}-in-LessThan{Float64}} \\ + & 0.0 + 1.0 y - 1.0 z + 2.0 x^2 \le 1.0 \\ + & \text{VectorOfVariables-in-SecondOrderCone} \\ + & \begin{bmatrix} + x\\ + y\end{bmatrix} \in \text{SecondOrderCone(2)} \\ + & \text{VectorAffineFunction{Float64}-in-SecondOrderCone} \\ + & \begin{bmatrix} + 1.0\\ + 0.0 + 1.0 x\\ + 0.0 + 1.0 y\end{bmatrix} \in \text{SecondOrderCone(2)} \\ + & \text{VectorQuadraticFunction{Float64}-in-ExponentialCone} \\ + & \begin{bmatrix} + 0.0 + 1.0 x^2\\ + 0.0 + 1.0 y\\ + 1.0\end{bmatrix} \in \text{ExponentialCone()} \\ + & \begin{bmatrix} + 1.0\\ + 0.0 + 1.0 x^2\\ + 0.0 + 1.0 y\end{bmatrix} \in \text{ExponentialCone()} \\ + & \text{SingleVariable-in-GreaterThan{Float64}} \\ + & x \ge 0.1 \\ + & \text{SingleVariable-in-Integer} \\ + & z \in \mathbb{Z} \\ + & \text{SingleVariable-in-ZeroOne} \\ + & x \in \{0, 1\} \\ + & y \in \{0, 1\} \\ + \end{aligned} $$""", + ) + return +end + +function test_latex_simplified() + model = MOIU.Model{Float64}() + MOIU.loadfromstring!( + model, + """ + variables: x, y, z + minobjective: x + 2 + 3.1*y + -1.2*z + c1: x >= 0.1 + c2: y in ZeroOne() + c2: z in Integer() + c3: [x, y] in SecondOrderCone(2) + c4: [1, x, y] in SecondOrderCone(2) + c4: [1.0 * x * x, y, 1] in ExponentialCone() + c4: [1, 1.0 * x * x, y] in ExponentialCone() + c2: x in ZeroOne() + c5: 2.0 * x * x + y + -1 * z <= 1.0 + c5: x + x >= 1.0 + c5: x + x in Interval(1.0, 2.0) + c5: x + -1 * y == 0.0 + """, + ) + model_string = sprint() do io + return MOIU._print_model( + io, + MOIU._PrintOptions( + MIME("text/latex"); + simplify_coefficients = true, + print_types = false, + ), + model, + ) + end + _string_compare( + model_string, + raw""" + $$ \begin{aligned} + \min\quad & 2 + x + 3.1 y - 1.2 z \\ + \text{Subject to}\\ + & x - y = 0 \\ + & 2 x \ge 1 \\ + & 2 x \in \[1, 2\] \\ + & y - z + 2 x^2 \le 1 \\ + & \begin{bmatrix} + x\\ + y\end{bmatrix} \in \text{SecondOrderCone(2)} \\ + & \begin{bmatrix} + 1\\ + x\\ + y\end{bmatrix} \in \text{SecondOrderCone(2)} \\ + & \begin{bmatrix} + x^2\\ + y\\ + 1\end{bmatrix} \in \text{ExponentialCone()} \\ + & \begin{bmatrix} + 1\\ + x^2\\ + y\end{bmatrix} \in \text{ExponentialCone()} \\ + & x \ge 0.1 \\ + & z \in \mathbb{Z} \\ + & x \in \{0, 1\} \\ + & y \in \{0, 1\} \\ + \end{aligned} $$""", + ) + return +end + +function test_plain_simplified() + model = MOIU.Model{Float64}() + MOIU.loadfromstring!( + model, + """ + variables: x, y, z + minobjective: x + -2 + 3.1*y + -1.2*z + c1: x >= 0.1 + c2: y in ZeroOne() + c2: z in Integer() + c3: [x, y] in SecondOrderCone(2) + c4: [1, x, y] in SecondOrderCone(2) + c4: [1.0 * x * x, y, 1] in ExponentialCone() + c4: [1, 1.0 * x * x, y] in ExponentialCone() + c2: x in ZeroOne() + c5: 2.0 * x * x + y + -1 * z <= 1.0 + c5: x + x >= 1.0 + c5: x + x in Interval(1.0, 2.0) + c5: x + -1 * y == 0.0 + """, + ) + model_string = sprint() do io + return MOIU._print_model( + io, + MOIU._PrintOptions( + MIME("text/plain"); + simplify_coefficients = true, + print_types = false, + ), + model, + ) + end + @test model_string == """ + Minimize: -2 + x + 3.1 y - 1.2 z + + Subject to: + x - y == 0 + 2 x >= 1 + 2 x $(IN) [1, 2] + y - z + 2 x² <= 1 + ┌ ┐ + │x│ + │y│ + └ ┘ $(IN) SecondOrderCone(2) + ┌ ┐ + │1│ + │x│ + │y│ + └ ┘ $(IN) SecondOrderCone(2) + ┌ ┐ + │x²│ + │y │ + │1 │ + └ ┘ $(IN) ExponentialCone() + ┌ ┐ + │1 │ + │x²│ + │y │ + └ ┘ $(IN) ExponentialCone() + x >= 0.1 + z $(IN) ℤ + x $(IN) {0, 1} + y $(IN) {0, 1} + """ +end + +function test_nlp() + model = MOI.Utilities.UniversalFallback(MOI.Utilities.Model{Float64}()) + v = MOI.add_variables(model, 4) + l = [1.1, 1.2, 1.3, 1.4] + u = [5.1, 5.2, 5.3, 5.4] + MOI.add_constraint.(model, MOI.SingleVariable.(v), MOI.GreaterThan.(l)) + MOI.add_constraint.(model, MOI.SingleVariable.(v), MOI.LessThan.(u)) + for i in 1:4 + MOI.set(model, MOI.VariableName(), v[i], "x[$i]") + end + lb, ub = [25.0, 40.0], [Inf, 40.0] + evaluator = MOI.Test.HS071(true) + block_data = MOI.NLPBlockData(MOI.NLPBoundsPair.(lb, ub), evaluator, true) + MOI.set(model, MOI.NLPBlock(), block_data) + MOI.set(model, MOI.ObjectiveSense(), MOI.MIN_SENSE) + @test sprint(print, model) == """ + Minimize Nonlinear: + x[1] * x[4] * (x[1] + x[2] + x[3]) + x[3] + + Subject to: + + SingleVariable-in-GreaterThan{Float64} + x[1] >= 1.1 + x[2] >= 1.2 + x[3] >= 1.3 + x[4] >= 1.4 + + SingleVariable-in-LessThan{Float64} + x[1] <= 5.1 + x[2] <= 5.2 + x[3] <= 5.3 + x[4] <= 5.4 + + Nonlinear + x[1] * x[2] * x[3] * x[4] >= 25.0 + x[1] ^ 2 + x[2] ^ 2 + x[3] ^ 2 + x[4] ^ 2 == 40.0 + """ + _string_compare( + sprint(print, MOIU.latex_formulation(model)), + raw""" + $$ \begin{aligned} + \min\quad & x_{1} \times x_{4} \times (x_{1} + x_{2} + x_{3}) + x_{3} \\ + \text{Subject to}\\ + & \text{SingleVariable-in-GreaterThan{Float64}} \\ + & x_{1} \ge 1.1 \\ + & x_{2} \ge 1.2 \\ + & x_{3} \ge 1.3 \\ + & x_{4} \ge 1.4 \\ + & \text{SingleVariable-in-LessThan{Float64}} \\ + & x_{1} \le 5.1 \\ + & x_{2} \le 5.2 \\ + & x_{3} \le 5.3 \\ + & x_{4} \le 5.4 \\ + & \text{Nonlinear} \\ + & x_{1} \times x_{2} \times x_{3} \times x_{4} \ge 25.0 \\ + & x_{1} ^ 2 + x_{2} ^ 2 + x_{3} ^ 2 + x_{4} ^ 2 = 40.0 \\ + \end{aligned} $$""", + ) + return +end + +function runtests() + for name in names(@__MODULE__; all = true) + if startswith("$(name)", "test_") + @testset "$(name)" begin + getfield(@__MODULE__, name)() + end + end + end +end + +end + +TestPrint.runtests()