From b225b412370d46a5711978d6ac5d7a5e1964f194 Mon Sep 17 00:00:00 2001 From: d227nguyen Date: Thu, 24 Sep 2026 14:31:20 -0400 Subject: [PATCH] Fixing nonlinear expressions at zero (hull) --- src/hull.jl | 39 +++++++++++++++++-- test/constraints/hull.jl | 22 +++++++++++ .../InfiniteDisjunctiveProgramming.jl | 27 +++++++++++++ 3 files changed, 85 insertions(+), 3 deletions(-) diff --git a/src/hull.jl b/src/hull.jl index 779369e9..4a934dd3 100644 --- a/src/hull.jl +++ b/src/hull.jl @@ -281,6 +281,39 @@ function reformulate_disjunct_constraint( reform_con = JuMP.build_constraint(error, new_func, con.set) return [reform_con] end +################################################################################ +# NONLINEAR EXPRESSION AT ZERO +################################################################################ + +# Value of an expression with every variable set to zero, the constant +# f(0) of the perspective function. Checked here rather than through +# JuMP.value(f, expr), whose leaf needs a method per variable +# reference type, so any AbstractVariableRef works. +_evaluate_at_zero(c::Number) = convert(Float64, c) +_evaluate_at_zero(::JuMP.AbstractVariableRef) = 0.0 +_evaluate_at_zero(aff::JuMP.GenericAffExpr) = convert(Float64, aff.constant) +_evaluate_at_zero(quad::JuMP.GenericQuadExpr) = _evaluate_at_zero(quad.aff) +function _evaluate_at_zero(nlp::JuMP.GenericNonlinearExpr) + registry = _MOI.Nonlinear.OperatorRegistry() + args = Real[_evaluate_at_zero(arg) for arg in nlp.args] + op = nlp.head + if length(args) == 1 && haskey(registry.univariate_operator_to_id, op) + return _MOI.Nonlinear.eval_univariate_function(registry, op, args[1]) + elseif haskey(registry.multivariate_operator_to_id, op) + return _MOI.Nonlinear.eval_multivariate_function(registry, op, args) + elseif haskey(registry.logic_operator_to_id, op) + return _MOI.Nonlinear.eval_logic_function(registry, op, args...) + elseif haskey(registry.comparison_operator_to_id, op) + return _MOI.Nonlinear.eval_comparison_function(registry, op, args...) + end + # operators registered on the model + udf = _MOI.get(JuMP.owner_model(nlp), + _MOI.UserDefinedFunction(op, length(args))) + udf === nothing && error("Unable to evaluate nonlinear operator `$op` " * + "because it was not added as an operator.") + return first(udf)(args...) +end + function reformulate_disjunct_constraint( model::JuMP.AbstractModel, con::JuMP.ScalarConstraint{T, S}, @@ -288,7 +321,7 @@ function reformulate_disjunct_constraint( method::_Hull ) where {T <: JuMP.GenericNonlinearExpr, S <: Union{_MOI.LessThan, _MOI.GreaterThan, _MOI.EqualTo}} con_func = _disaggregate_nl_expression(model, con.func, bvref, method) - con_func0 = JuMP.value(v -> 0.0, con.func) + con_func0 = _evaluate_at_zero(con.func) if isinf(con_func0) error("Operator `$(con.func.head)` is not defined at 0, causing the perspective function on the Hull reformulation to fail.") end @@ -307,7 +340,7 @@ function reformulate_disjunct_constraint( con_func = JuMP.@expression(model, [i=1:con.set.dimension], _disaggregate_nl_expression(model, con.func[i], bvref, method) ) - con_func0 = JuMP.value.(v -> 0.0, con.func) + con_func0 = _evaluate_at_zero.(con.func) if any(isinf.(con_func0)) error("At least of of the operators `$([func.head for func in con.func])` is not defined at 0, causing the perspective function on the Hull reformulation to fail.") end @@ -338,7 +371,7 @@ function reformulate_disjunct_constraint( method::_Hull ) where {T <: JuMP.GenericNonlinearExpr, S <: _MOI.Interval} con_func = _disaggregate_nl_expression(model, con.func, bvref, method) - con_func0 = JuMP.value(v -> 0.0, con.func) + con_func0 = _evaluate_at_zero(con.func) if isinf(con_func0) error("Operator `$(con.func.head)` is not defined at 0, causing the perspective function on the Hull reformulation to fail.") end diff --git a/test/constraints/hull.jl b/test/constraints/hull.jl index d01b30e2..b2d83ff9 100644 --- a/test/constraints/hull.jl +++ b/test/constraints/hull.jl @@ -422,6 +422,27 @@ function test_vector_quadratic_hull_1sided(moiset) @test ref[1].set == moiset(2) end #less than, greater than, equalto +function test_evaluate_at_zero() + model = GDPModel() + @variable(model, 1 <= x <= 10) + @variable(model, 1 <= y <= 10) + @test DP._evaluate_at_zero(3) === 3.0 + @test DP._evaluate_at_zero(x) === 0.0 + @test DP._evaluate_at_zero(2x + 1.5) === 1.5 + @test DP._evaluate_at_zero(x * y + 2x + 1.5) === 1.5 + @test DP._evaluate_at_zero(exp(x) - 1) === 0.0 + @test DP._evaluate_at_zero(log(x + 1) * (y + 2)) === 0.0 + @test DP._evaluate_at_zero((x - 1)^2 / (y + 4)) === 0.25 + branch = NonlinearExpr(:ifelse, + Any[NonlinearExpr(:>=, Any[x, 1]), x, y + 3]) + @test DP._evaluate_at_zero(branch) === 3.0 + @test DP._evaluate_at_zero(log(x)) == -Inf + @operator(model, op_f, 1, v -> v^2 + 7) + @test DP._evaluate_at_zero(op_f(x)) === 7.0 + @test_throws ErrorException DP._evaluate_at_zero( + NonlinearExpr(:op_missing, Any[x])) +end + function test_scalar_nonlinear_hull_1sided_error() model = GDPModel() @variable(model, 10 <= x <= 100) @@ -678,6 +699,7 @@ end test_scalar_quadratic_hull_1sided(s) test_scalar_nonlinear_hull_1sided(s) end + test_evaluate_at_zero() test_scalar_nonlinear_hull_1sided_error() for s in (MOI.Nonpositives, MOI.Nonnegatives, MOI.Zeros) test_vector_var_hull_1sided(s) diff --git a/test/extensions/InfiniteDisjunctiveProgramming.jl b/test/extensions/InfiniteDisjunctiveProgramming.jl index 36ddefef..42d0f1e5 100644 --- a/test/extensions/InfiniteDisjunctiveProgramming.jl +++ b/test/extensions/InfiniteDisjunctiveProgramming.jl @@ -755,6 +755,32 @@ function test_mbm_with_derivatives() MOI.ALMOST_LOCALLY_SOLVED] end +# Hull on a non-quadratic nonlinear disjunct constraint of an infinite +# variable: the perspective constant f(0) is evaluated without a +# JuMP.value method for GeneralVariableRef. Optimum x = 0 in disjunct 1. +function test_hull_infinite_nonlinear() + model = InfiniteGDPModel() + @infinite_parameter(model, t ∈ [0, 1], num_supports = 5) + @variable(model, 0 <= x <= 10, Infinite(t)) + @variable(model, Y[1:2], InfiniteLogical(t)) + @constraint(model, exp(x) - 1 <= 2, Disjunct(Y[1])) + @constraint(model, x >= 5, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Min, ∫(x, t)) + @test DP.reformulate_model(model, Hull()) isa Nothing + juniper = JuMP.optimizer_with_attributes( + Juniper.Optimizer, + "nl_solver" => JuMP.optimizer_with_attributes( + Ipopt.Optimizer, "print_level" => 0), + "log_levels" => Symbol[]) + set_optimizer(model, juniper) + @test optimize!(model, gdp_method = Hull()) isa Nothing + @test termination_status(model) in + [MOI.OPTIMAL, MOI.LOCALLY_SOLVED] + @test objective_value(model) ≈ 0.0 atol = 1e-4 + @test all(value(Y[1])) +end + function test_CuttingPlanes_infinite_simple() model = InfiniteGDPModel(HiGHS.Optimizer) set_silent(model) @@ -1203,6 +1229,7 @@ end @testset "Integration" begin test_infiniteopt_extension() test_methods() + test_hull_infinite_nonlinear() end @testset "Cutting Planes" begin