From b225b412370d46a5711978d6ac5d7a5e1964f194 Mon Sep 17 00:00:00 2001 From: d227nguyen Date: Thu, 24 Sep 2026 14:31:20 -0400 Subject: [PATCH 1/5] 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 From ae1d426868d72c5f952a73638e94aa151ea72a59 Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Tue, 6 Oct 2026 11:22:07 -0400 Subject: [PATCH 2/5] Public eval_at_zero function --- ext/InfiniteDisjunctiveProgramming.jl | 20 ++++++++ src/hull.jl | 51 ++++++++----------- test/constraints/hull.jl | 22 ++++---- .../InfiniteDisjunctiveProgramming.jl | 22 ++++++++ 4 files changed, 73 insertions(+), 42 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 2b9863cc..415a699c 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -38,6 +38,26 @@ function DP.requires_disaggregation(vref::InfiniteOpt.GeneralVariableRef) return !_is_parameter(vref) end +# Value of a reference with every decision variable at zero +const _zero_at_zero = Union{InfiniteOpt.InfiniteVariableRef, + InfiniteOpt.SemiInfiniteVariableRef, InfiniteOpt.PointVariableRef, + InfiniteOpt.FiniteVariableRef, InfiniteOpt.DerivativeRef} +DP.evaluate_at_zero(vref::InfiniteOpt.GeneralVariableRef) = + _evaluate_at_zero(InfiniteOpt.dispatch_variable_ref(vref)) +# JuMP's expression walk hands each leaf to `JuMP.value(f, leaf)`, which +# JuMP only defines for its own variable type; InfiniteOpt does not add +# it, so it is supplied here (skipped should InfiniteOpt ever define it). +if !hasmethod(JuMP.value, Tuple{Function, InfiniteOpt.GeneralVariableRef}) + JuMP.value(f::Function, vref::InfiniteOpt.GeneralVariableRef) = f(vref) +end +_evaluate_at_zero(::_zero_at_zero) = 0.0 +_evaluate_at_zero(pref::InfiniteOpt.FiniteParameterRef) = + convert(Float64, JuMP.parameter_value(pref)) +function _evaluate_at_zero(ref::InfiniteOpt.DispatchVariableRef) + error("Cannot evaluate `$ref` at zero: the hull perspective " * + "constant of a nonlinear disjunct constraint would depend on it.") +end + function DP.VariableProperties(vref::InfiniteOpt.GeneralVariableRef) info = DP.get_variable_info(vref) name = JuMP.name(vref) diff --git a/src/hull.jl b/src/hull.jl index 4a934dd3..8770ee47 100644 --- a/src/hull.jl +++ b/src/hull.jl @@ -285,34 +285,23 @@ 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 +""" + evaluate_at_zero(expr::Union{Number, JuMP.AbstractJuMPScalar})::Float64 + +Return the value of `expr` with every decision variable set to zero, the +constant \$f(0)\$ of the perspective function in the hull reformulation +of a nonlinear disjunct constraint. Expressions are evaluated by +`JuMP.value(evaluate_at_zero, expr)`, so every variable leaf comes back +through this function. Extensions with their own +`JuMP.AbstractVariableRef` subtype add a method for it, since what +"zero" means depends on the reference kind (for instance, an infinite +parameter is not a decision variable), and make sure +`JuMP.value(::Function, ::TheirRef)` exists so the walk reaches it. +""" +evaluate_at_zero(c::Number) = convert(Float64, c) +evaluate_at_zero(::JuMP.GenericVariableRef) = 0.0 +evaluate_at_zero(expr::JuMP.AbstractJuMPScalar) = + JuMP.value(evaluate_at_zero, expr) function reformulate_disjunct_constraint( model::JuMP.AbstractModel, @@ -321,7 +310,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 = _evaluate_at_zero(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 @@ -340,7 +329,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 = _evaluate_at_zero.(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 @@ -371,7 +360,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 = _evaluate_at_zero(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 b2d83ff9..f599debf 100644 --- a/test/constraints/hull.jl +++ b/test/constraints/hull.jl @@ -426,20 +426,20 @@ 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 + @test evaluate_at_zero(3) === 3.0 + @test evaluate_at_zero(x) === 0.0 + @test evaluate_at_zero(2x + 1.5) === 1.5 + @test evaluate_at_zero(x * y + 2x + 1.5) === 1.5 + @test evaluate_at_zero(exp(x) - 1) === 0.0 + @test evaluate_at_zero(log(x + 1) * (y + 2)) === 0.0 + @test 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 + @test evaluate_at_zero(branch) === 3.0 + @test 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( + @test evaluate_at_zero(op_f(x)) === 7.0 + @test_throws ErrorException evaluate_at_zero( NonlinearExpr(:op_missing, Any[x])) end diff --git a/test/extensions/InfiniteDisjunctiveProgramming.jl b/test/extensions/InfiniteDisjunctiveProgramming.jl index 42d0f1e5..ccd7c550 100644 --- a/test/extensions/InfiniteDisjunctiveProgramming.jl +++ b/test/extensions/InfiniteDisjunctiveProgramming.jl @@ -755,6 +755,27 @@ function test_mbm_with_derivatives() MOI.ALMOST_LOCALLY_SOLVED] end +# evaluate_at_zero on each kind of InfiniteOpt reference +function test_evaluate_at_zero_infinite() + model = InfiniteGDPModel() + @infinite_parameter(model, t ∈ [0, 1], num_supports = 3) + @variable(model, x, Infinite(t)) + @variable(model, z) + @finite_parameter(model, p == 2.0) + @parameter_function(model, pf == t -> 2 * t) + @test evaluate_at_zero(x) === 0.0 + @test evaluate_at_zero(z) === 0.0 + @test evaluate_at_zero(deriv(x, t)) === 0.0 + @test evaluate_at_zero(x(0.5)) === 0.0 + @test evaluate_at_zero(p) === 2.0 + @test evaluate_at_zero(exp(x) - 1 + p * z) === 0.0 + @test evaluate_at_zero(log(x + p) * (z + 1)) ≈ log(2.0) + @test_throws ErrorException evaluate_at_zero(t) + @test_throws ErrorException evaluate_at_zero(pf) + @test_throws ErrorException evaluate_at_zero(exp(x) - t) + @test_throws ErrorException evaluate_at_zero(∫(x, t)) +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. @@ -1229,6 +1250,7 @@ end @testset "Integration" begin test_infiniteopt_extension() test_methods() + test_evaluate_at_zero_infinite() test_hull_infinite_nonlinear() end From 13700a5efdc1837fa2ae891c6523f2ab815ed579 Mon Sep 17 00:00:00 2001 From: d227nguyen Date: Thu, 8 Oct 2026 10:21:21 -0400 Subject: [PATCH 3/5] Error message edit --- ext/InfiniteDisjunctiveProgramming.jl | 15 +++++++-------- src/hull.jl | 21 +++++++++++++++++---- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 415a699c..3cfdce3f 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -44,18 +44,17 @@ const _zero_at_zero = Union{InfiniteOpt.InfiniteVariableRef, InfiniteOpt.FiniteVariableRef, InfiniteOpt.DerivativeRef} DP.evaluate_at_zero(vref::InfiniteOpt.GeneralVariableRef) = _evaluate_at_zero(InfiniteOpt.dispatch_variable_ref(vref)) -# JuMP's expression walk hands each leaf to `JuMP.value(f, leaf)`, which -# JuMP only defines for its own variable type; InfiniteOpt does not add -# it, so it is supplied here (skipped should InfiniteOpt ever define it). -if !hasmethod(JuMP.value, Tuple{Function, InfiniteOpt.GeneralVariableRef}) - JuMP.value(f::Function, vref::InfiniteOpt.GeneralVariableRef) = f(vref) -end +# JuMP's nonlinear walk evaluates each leaf with `JuMP.value(f, leaf)`, +# which JuMP only defines for its own variable type +JuMP.value(f::Function, vref::InfiniteOpt.GeneralVariableRef) = f(vref) _evaluate_at_zero(::_zero_at_zero) = 0.0 _evaluate_at_zero(pref::InfiniteOpt.FiniteParameterRef) = convert(Float64, JuMP.parameter_value(pref)) function _evaluate_at_zero(ref::InfiniteOpt.DispatchVariableRef) - error("Cannot evaluate `$ref` at zero: the hull perspective " * - "constant of a nonlinear disjunct constraint would depend on it.") + error("Hull cannot reformulate a nonlinear disjunct constraint " * + "containing `$ref` of type $(typeof(ref)): the function does not " * + "reduce to a number when the decision variables are zero. Use " * + "BigM or MBM for this constraint.") end function DP.VariableProperties(vref::InfiniteOpt.GeneralVariableRef) diff --git a/src/hull.jl b/src/hull.jl index 8770ee47..d2b1107d 100644 --- a/src/hull.jl +++ b/src/hull.jl @@ -312,7 +312,11 @@ function reformulate_disjunct_constraint( con_func = _disaggregate_nl_expression(model, con.func, bvref, method) 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.") + error("Cannot apply Hull to the nonlinear disjunct constraint on " * + "`$(con.func)`: it evaluates to $(con_func0) at zero, so the " * + "perspective constant f(0) is not finite (for example " * + "`log(x)` or `1/x`). Shift the variables so the function is " * + "finite at zero, or use BigM or MBM.") end ϵ = method.value set_value = _set_value(con.set) @@ -330,8 +334,13 @@ function reformulate_disjunct_constraint( _disaggregate_nl_expression(model, con.func[i], bvref, method) ) 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.") + if any(isinf, con_func0) + rows = findall(isinf, con_func0) + error("Cannot apply Hull to the vector disjunct constraint on " * + "`$(con.func)`: rows $(rows) evaluate to $(con_func0[rows]) " * + "at zero, so the perspective constant f(0) is not finite (for " * + "example `log(x)` or `1/x`). Shift the variables so the " * + "function is finite at zero, or use BigM or MBM.") end ϵ = method.value new_func = JuMP.@expression(model, [i=1:con.set.dimension], @@ -362,7 +371,11 @@ function reformulate_disjunct_constraint( con_func = _disaggregate_nl_expression(model, con.func, bvref, method) 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.") + error("Cannot apply Hull to the nonlinear disjunct constraint on " * + "`$(con.func)`: it evaluates to $(con_func0) at zero, so the " * + "perspective constant f(0) is not finite (for example " * + "`log(x)` or `1/x`). Shift the variables so the function is " * + "finite at zero, or use BigM or MBM.") end ϵ = method.value new_func = JuMP.@expression(model, ((1-ϵ)*bvref+ϵ) * con_func - ϵ*(1-bvref)*con_func0) From 7bec7ce32c25726cb2f5de3df1968bced18867b1 Mon Sep 17 00:00:00 2001 From: d227nguyen Date: Thu, 8 Oct 2026 10:35:16 -0400 Subject: [PATCH 4/5] Nonlinears with generalvaraibelref --- ext/InfiniteDisjunctiveProgramming.jl | 10 +++++++--- src/hull.jl | 5 +++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 3cfdce3f..b781b566 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -44,9 +44,6 @@ const _zero_at_zero = Union{InfiniteOpt.InfiniteVariableRef, InfiniteOpt.FiniteVariableRef, InfiniteOpt.DerivativeRef} DP.evaluate_at_zero(vref::InfiniteOpt.GeneralVariableRef) = _evaluate_at_zero(InfiniteOpt.dispatch_variable_ref(vref)) -# JuMP's nonlinear walk evaluates each leaf with `JuMP.value(f, leaf)`, -# which JuMP only defines for its own variable type -JuMP.value(f::Function, vref::InfiniteOpt.GeneralVariableRef) = f(vref) _evaluate_at_zero(::_zero_at_zero) = 0.0 _evaluate_at_zero(pref::InfiniteOpt.FiniteParameterRef) = convert(Float64, JuMP.parameter_value(pref)) @@ -57,6 +54,13 @@ function _evaluate_at_zero(ref::InfiniteOpt.DispatchVariableRef) "BigM or MBM for this constraint.") end +function DP.evaluate_at_zero( + expr::JuMP.GenericNonlinearExpr{InfiniteOpt.GeneralVariableRef} + ) + expr = InfiniteOpt.map_expression(DP.evaluate_at_zero, expr) + return JuMP.value(DP.evaluate_at_zero, expr) +end + function DP.VariableProperties(vref::InfiniteOpt.GeneralVariableRef) info = DP.get_variable_info(vref) name = JuMP.name(vref) diff --git a/src/hull.jl b/src/hull.jl index d2b1107d..378e5b82 100644 --- a/src/hull.jl +++ b/src/hull.jl @@ -295,8 +295,9 @@ of a nonlinear disjunct constraint. Expressions are evaluated by through this function. Extensions with their own `JuMP.AbstractVariableRef` subtype add a method for it, since what "zero" means depends on the reference kind (for instance, an infinite -parameter is not a decision variable), and make sure -`JuMP.value(::Function, ::TheirRef)` exists so the walk reaches it. +parameter is not a decision variable). JuMP's nonlinear walk only +evaluates its own variable type, so they also add a method for +nonlinear expressions over their subtype. """ evaluate_at_zero(c::Number) = convert(Float64, c) evaluate_at_zero(::JuMP.GenericVariableRef) = 0.0 From 69f1e1b4d2079920314d58ec94fbe9dec40f6f60 Mon Sep 17 00:00:00 2001 From: d227nguyen Date: Thu, 8 Oct 2026 14:08:29 -0400 Subject: [PATCH 5/5] Hull f(0) keeps infinite parameters symbolic --- ext/InfiniteDisjunctiveProgramming.jl | 28 +++++++++------- src/hull.jl | 28 +++++++++------- .../InfiniteDisjunctiveProgramming.jl | 33 +++++++++++++++++-- 3 files changed, 62 insertions(+), 27 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index b781b566..7ed9e294 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -38,26 +38,30 @@ function DP.requires_disaggregation(vref::InfiniteOpt.GeneralVariableRef) return !_is_parameter(vref) end -# Value of a reference with every decision variable at zero -const _zero_at_zero = Union{InfiniteOpt.InfiniteVariableRef, - InfiniteOpt.SemiInfiniteVariableRef, InfiniteOpt.PointVariableRef, - InfiniteOpt.FiniteVariableRef, InfiniteOpt.DerivativeRef} +# Value of a reference with every decision variable at zero. Infinite +# parameters and parameter functions stay symbolic, so transcription +# evaluates the perspective constant f(0) at each support. DP.evaluate_at_zero(vref::InfiniteOpt.GeneralVariableRef) = - _evaluate_at_zero(InfiniteOpt.dispatch_variable_ref(vref)) -_evaluate_at_zero(::_zero_at_zero) = 0.0 -_evaluate_at_zero(pref::InfiniteOpt.FiniteParameterRef) = + _evaluate_at_zero(vref, InfiniteOpt.dispatch_variable_ref(vref)) +_evaluate_at_zero(vref, ::InfiniteOpt.DecisionVariableRef) = 0.0 +_evaluate_at_zero(vref, pref::InfiniteOpt.FiniteParameterRef) = convert(Float64, JuMP.parameter_value(pref)) -function _evaluate_at_zero(ref::InfiniteOpt.DispatchVariableRef) +_evaluate_at_zero(vref, ::Union{InfiniteOpt.IndependentParameterRef, + InfiniteOpt.DependentParameterRef, InfiniteOpt.ParameterFunctionRef}) = + vref +function _evaluate_at_zero(vref, ::InfiniteOpt.MeasureRef) error("Hull cannot reformulate a nonlinear disjunct constraint " * - "containing `$ref` of type $(typeof(ref)): the function does not " * - "reduce to a number when the decision variables are zero. Use " * - "BigM or MBM for this constraint.") + "containing the measure `$vref`: its argument cannot be " * + "evaluated with the decision variables at zero. Use BigM or " * + "MBM for this constraint.") end - +# Replace every reference by its value at zero; the result is a number +# unless infinite parameters remain, then it is an expression in them function DP.evaluate_at_zero( expr::JuMP.GenericNonlinearExpr{InfiniteOpt.GeneralVariableRef} ) expr = InfiniteOpt.map_expression(DP.evaluate_at_zero, expr) + isempty(InfiniteOpt.parameter_refs(expr)) || return expr return JuMP.value(DP.evaluate_at_zero, expr) end diff --git a/src/hull.jl b/src/hull.jl index 378e5b82..8ec09497 100644 --- a/src/hull.jl +++ b/src/hull.jl @@ -286,18 +286,22 @@ end ################################################################################ """ - evaluate_at_zero(expr::Union{Number, JuMP.AbstractJuMPScalar})::Float64 + evaluate_at_zero( + expr::Union{Number, JuMP.AbstractJuMPScalar} + )::Union{Float64, JuMP.AbstractJuMPScalar} -Return the value of `expr` with every decision variable set to zero, the -constant \$f(0)\$ of the perspective function in the hull reformulation -of a nonlinear disjunct constraint. Expressions are evaluated by +Return `expr` with every decision variable set to zero, the constant +\$f(0)\$ of the perspective function in the hull reformulation of a +nonlinear disjunct constraint. Expressions are evaluated by `JuMP.value(evaluate_at_zero, expr)`, so every variable leaf comes back through this function. Extensions with their own `JuMP.AbstractVariableRef` subtype add a method for it, since what -"zero" means depends on the reference kind (for instance, an infinite -parameter is not a decision variable). JuMP's nonlinear walk only -evaluates its own variable type, so they also add a method for -nonlinear expressions over their subtype. +"zero" means depends on the reference kind, and a method for nonlinear +expressions over their subtype, since JuMP's nonlinear walk only +evaluates its own variable type. An extension may return an expression +in quantities that are not decision variables, such as infinite +parameters; it is then substituted into the perspective constraint +as is. """ evaluate_at_zero(c::Number) = convert(Float64, c) evaluate_at_zero(::JuMP.GenericVariableRef) = 0.0 @@ -312,7 +316,7 @@ function reformulate_disjunct_constraint( ) where {T <: JuMP.GenericNonlinearExpr, S <: Union{_MOI.LessThan, _MOI.GreaterThan, _MOI.EqualTo}} con_func = _disaggregate_nl_expression(model, con.func, bvref, method) con_func0 = evaluate_at_zero(con.func) - if isinf(con_func0) + if con_func0 isa Number && isinf(con_func0) error("Cannot apply Hull to the nonlinear disjunct constraint on " * "`$(con.func)`: it evaluates to $(con_func0) at zero, so the " * "perspective constant f(0) is not finite (for example " * @@ -335,8 +339,8 @@ function reformulate_disjunct_constraint( _disaggregate_nl_expression(model, con.func[i], bvref, method) ) con_func0 = evaluate_at_zero.(con.func) - if any(isinf, con_func0) - rows = findall(isinf, con_func0) + rows = findall(v -> v isa Number && isinf(v), con_func0) + if !isempty(rows) error("Cannot apply Hull to the vector disjunct constraint on " * "`$(con.func)`: rows $(rows) evaluate to $(con_func0[rows]) " * "at zero, so the perspective constant f(0) is not finite (for " * @@ -371,7 +375,7 @@ function reformulate_disjunct_constraint( ) where {T <: JuMP.GenericNonlinearExpr, S <: _MOI.Interval} con_func = _disaggregate_nl_expression(model, con.func, bvref, method) con_func0 = evaluate_at_zero(con.func) - if isinf(con_func0) + if con_func0 isa Number && isinf(con_func0) error("Cannot apply Hull to the nonlinear disjunct constraint on " * "`$(con.func)`: it evaluates to $(con_func0) at zero, so the " * "perspective constant f(0) is not finite (for example " * diff --git a/test/extensions/InfiniteDisjunctiveProgramming.jl b/test/extensions/InfiniteDisjunctiveProgramming.jl index ccd7c550..749c471c 100644 --- a/test/extensions/InfiniteDisjunctiveProgramming.jl +++ b/test/extensions/InfiniteDisjunctiveProgramming.jl @@ -770,9 +770,10 @@ function test_evaluate_at_zero_infinite() @test evaluate_at_zero(p) === 2.0 @test evaluate_at_zero(exp(x) - 1 + p * z) === 0.0 @test evaluate_at_zero(log(x + p) * (z + 1)) ≈ log(2.0) - @test_throws ErrorException evaluate_at_zero(t) - @test_throws ErrorException evaluate_at_zero(pf) - @test_throws ErrorException evaluate_at_zero(exp(x) - t) + @test evaluate_at_zero(t) === t + @test evaluate_at_zero(pf) === pf + f0 = evaluate_at_zero(exp(x) - t) + @test f0 isa JuMP.GenericNonlinearExpr && parameter_refs(f0) == (t,) @test_throws ErrorException evaluate_at_zero(∫(x, t)) end @@ -802,6 +803,31 @@ function test_hull_infinite_nonlinear() @test all(value(Y[1])) end +# Hull on a nonlinear disjunct constraint that depends on the infinite +# parameter: f(0) = -t is an expression, substituted per support by the +# transcription. Only disjunct 1 is feasible, so x(t) = log(1 + t). +function test_hull_infinite_nonlinear_parameter() + 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 <= t, Disjunct(Y[1])) + @constraint(model, x <= -1, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Max, ∫(x, t)) + 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 value(x) ≈ log.(1 .+ supports(t)) atol = 1e-4 + @test all(value(Y[1])) +end + function test_CuttingPlanes_infinite_simple() model = InfiniteGDPModel(HiGHS.Optimizer) set_silent(model) @@ -1252,6 +1278,7 @@ end test_methods() test_evaluate_at_zero_infinite() test_hull_infinite_nonlinear() + test_hull_infinite_nonlinear_parameter() end @testset "Cutting Planes" begin