From 7158ede40dea1b4c6df94418f303cd3d10a2e6d3 Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Wed, 13 May 2026 18:03:20 -0400 Subject: [PATCH 1/9] Refactor approximations into a pure-JuMP layer beneath the IOM container API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the quadratic and bilinear approximation builders interleaved JuMP-construction with OptimizationContainer bookkeeping, making the math hard to test independently. This commit splits each approximation into two layers: - A pure-JuMP `build_quadratic_approx` / `build_bilinear_approx` per method that operates on a bare `JuMP.Model`, JuMP containers, and bounds, and returns a result struct holding all the JuMP objects (variables, constraints, expressions). - A generic IOM wrapper in `approximations/common.jl` that preserves the existing `_add_quadratic_approx!` / `_add_bilinear_approx!` POM entry points: it calls `build_*` and then dispatches `register_in_container!` on the result struct to write all auxiliary JuMP objects into the container with the right keys and meta suffix. The directories `quadratic_approximations/` and `bilinear_approximations/` are merged into a single `approximations/` folder with one self-contained file per method (config, result, build, register all colocated). The math layer is now exercisable without any container scaffolding — `test/ test_pure_jump_approximations.jl` does exactly this. Optional add-ons (PWMCC concave cuts, epigraph tightening, reformulated McCormick) now live inside the corresponding `build_*` function rather than as separate post-processing calls; result structs carry them as optional fields and the register dispatch propagates them appropriately. Loops over `(name, t)` are replaced with batched `JuMP.@variable`, `JuMP.@constraint`, and `JuMP.@expression` macros where applicable for both readability and a modest speedup. POM-facing signatures are unchanged. All 1098 IOM tests pass. Co-Authored-By: Claude Opus 4.7 --- src/InfrastructureOptimizationModels.jl | 42 +- src/approximations/bin2.jl | 140 +++++ src/approximations/common.jl | 205 +++++++ src/approximations/epigraph.jl | 273 +++++++++ src/approximations/hybs.jl | 252 ++++++++ src/approximations/incremental.jl | 165 ++++++ src/approximations/manual_sos2.jl | 302 ++++++++++ src/approximations/mccormick.jl | 242 ++++++++ src/approximations/nmdt_bilinear.jl | 246 ++++++++ src/approximations/nmdt_discretization.jl | 483 +++++++++++++++ src/approximations/nmdt_quadratic.jl | 301 ++++++++++ src/approximations/no_approx_bilinear.jl | 56 ++ src/approximations/no_approx_quadratic.jl | 55 ++ src/approximations/pwl_utils.jl | 30 + src/approximations/pwmcc_cuts.jl | 287 +++++++++ src/approximations/sawtooth.jl | 293 +++++++++ src/approximations/solver_sos2.jl | 234 ++++++++ src/bilinear_approximations/bin2.jl | 156 ----- src/bilinear_approximations/hybs.jl | 235 -------- src/bilinear_approximations/mccormick.jl | 404 ------------- src/bilinear_approximations/nmdt.jl | 267 --------- src/bilinear_approximations/no_approx.jl | 100 ---- src/quadratic_approximations/common.jl | 54 -- src/quadratic_approximations/epigraph.jl | 193 ------ src/quadratic_approximations/incremental.jl | 244 -------- src/quadratic_approximations/manual_sos2.jl | 235 -------- src/quadratic_approximations/nmdt.jl | 229 -------- src/quadratic_approximations/nmdt_common.jl | 554 ------------------ src/quadratic_approximations/no_approx.jl | 45 -- src/quadratic_approximations/pwl_utils.jl | 60 -- src/quadratic_approximations/pwmcc_cuts.jl | 231 -------- src/quadratic_approximations/sawtooth.jl | 227 ------- src/quadratic_approximations/solver_sos2.jl | 186 ------ test/InfrastructureOptimizationModelsTests.jl | 5 +- test/test_pure_jump_approximations.jl | 200 +++++++ 35 files changed, 3791 insertions(+), 3440 deletions(-) create mode 100644 src/approximations/bin2.jl create mode 100644 src/approximations/common.jl create mode 100644 src/approximations/epigraph.jl create mode 100644 src/approximations/hybs.jl create mode 100644 src/approximations/incremental.jl create mode 100644 src/approximations/manual_sos2.jl create mode 100644 src/approximations/mccormick.jl create mode 100644 src/approximations/nmdt_bilinear.jl create mode 100644 src/approximations/nmdt_discretization.jl create mode 100644 src/approximations/nmdt_quadratic.jl create mode 100644 src/approximations/no_approx_bilinear.jl create mode 100644 src/approximations/no_approx_quadratic.jl create mode 100644 src/approximations/pwl_utils.jl create mode 100644 src/approximations/pwmcc_cuts.jl create mode 100644 src/approximations/sawtooth.jl create mode 100644 src/approximations/solver_sos2.jl delete mode 100644 src/bilinear_approximations/bin2.jl delete mode 100644 src/bilinear_approximations/hybs.jl delete mode 100644 src/bilinear_approximations/mccormick.jl delete mode 100644 src/bilinear_approximations/nmdt.jl delete mode 100644 src/bilinear_approximations/no_approx.jl delete mode 100644 src/quadratic_approximations/common.jl delete mode 100644 src/quadratic_approximations/epigraph.jl delete mode 100644 src/quadratic_approximations/incremental.jl delete mode 100644 src/quadratic_approximations/manual_sos2.jl delete mode 100644 src/quadratic_approximations/nmdt.jl delete mode 100644 src/quadratic_approximations/nmdt_common.jl delete mode 100644 src/quadratic_approximations/no_approx.jl delete mode 100644 src/quadratic_approximations/pwl_utils.jl delete mode 100644 src/quadratic_approximations/pwmcc_cuts.jl delete mode 100644 src/quadratic_approximations/sawtooth.jl delete mode 100644 src/quadratic_approximations/solver_sos2.jl create mode 100644 test/test_pure_jump_approximations.jl diff --git a/src/InfrastructureOptimizationModels.jl b/src/InfrastructureOptimizationModels.jl index 1fa5f860..068daeb3 100644 --- a/src/InfrastructureOptimizationModels.jl +++ b/src/InfrastructureOptimizationModels.jl @@ -600,25 +600,29 @@ include("objective_function/objective_function_pwl_delta.jl") # delta/increment include("objective_function/piecewise_linear.jl") # CostCurve/FuelCurve → lambda PWL include("objective_function/value_curve_cost.jl") # ValueCurve → delta PWL -# Quadratic approximations (PWL via SOS2) -include("quadratic_approximations/common.jl") -include("quadratic_approximations/no_approx.jl") -include("quadratic_approximations/pwl_utils.jl") -include("quadratic_approximations/incremental.jl") -include("quadratic_approximations/solver_sos2.jl") -include("quadratic_approximations/manual_sos2.jl") -include("quadratic_approximations/sawtooth.jl") -include("quadratic_approximations/epigraph.jl") -include("quadratic_approximations/nmdt_common.jl") -include("quadratic_approximations/nmdt.jl") -include("quadratic_approximations/pwmcc_cuts.jl") - -# Bilinear approximations (x·y via Bin2/HybS decomposition) -include("bilinear_approximations/mccormick.jl") -include("bilinear_approximations/bin2.jl") -include("bilinear_approximations/no_approx.jl") -include("bilinear_approximations/hybs.jl") -include("bilinear_approximations/nmdt.jl") +# Quadratic and bilinear approximations. +# Layered architecture: pure-JuMP `build_*_approx` functions return result +# structs holding all JuMP objects; the generic IOM wrappers in common.jl +# dispatch `register_in_container!` on the result struct to write everything +# into the OptimizationContainer. +include("approximations/common.jl") +include("approximations/pwl_utils.jl") +include("approximations/mccormick.jl") +include("approximations/nmdt_discretization.jl") +include("approximations/pwmcc_cuts.jl") +# Quadratic methods (each file is self-contained: config + result + build + register) +include("approximations/no_approx_quadratic.jl") +include("approximations/epigraph.jl") # must precede sawtooth (epigraph tightening) +include("approximations/solver_sos2.jl") +include("approximations/manual_sos2.jl") +include("approximations/sawtooth.jl") +include("approximations/nmdt_quadratic.jl") +include("approximations/incremental.jl") +# Bilinear methods (compose with quadratic — must follow) +include("approximations/no_approx_bilinear.jl") +include("approximations/bin2.jl") +include("approximations/hybs.jl") +include("approximations/nmdt_bilinear.jl") # add_param_container! wrappers — must come after piecewise_linear.jl # (which defines VariableValueParameter and FixValueParameter) diff --git a/src/approximations/bin2.jl b/src/approximations/bin2.jl new file mode 100644 index 00000000..4f081a9a --- /dev/null +++ b/src/approximations/bin2.jl @@ -0,0 +1,140 @@ +# Bin2 separable approximation of bilinear products z = x·y. +# Uses the identity x·y = ½·((x+y)² − x² − y²). +# Composes a quadratic approximation (chosen via `quad_config`) for x², y², +# and (x+y)². Optionally adds reformulated McCormick cuts to tighten the LP +# relaxation in terms of the three quadratic approximations. + +""" +Config for Bin2 bilinear approximation using z = ½·((x+y)² − x² − y²). + +# Fields +- `quad_config::QuadraticApproxConfig`: quadratic method used for x², y², and (x+y)². +- `add_mccormick::Bool`: whether to add reformulated McCormick cuts (default true). +""" +struct Bin2Config <: BilinearApproxConfig + quad_config::QuadraticApproxConfig + add_mccormick::Bool +end +function Bin2Config(quad_config::QuadraticApproxConfig) + return Bin2Config(quad_config, true) +end + +""" +Pure-JuMP result of `build_bilinear_approx(::Bin2Config, ...)`. +""" +struct Bin2BilinearResult{A, XSQ, YSQ, PSQ, P, MC} <: BilinearApproxResult + approximation::A + xsq_result::XSQ + ysq_result::YSQ + psq_result::PSQ + sum_expression::P + mccormick_constraints::MC # Union{Nothing, DenseAxisArray} +end + +""" + build_bilinear_approx(config::Bin2Config, model, x, y, x_bounds, y_bounds) + +Bin2 separable bilinear approximation: build x², y², and (x+y)² via the +chosen quadratic method, then combine via z = ½·(psq − xsq − ysq). +If `config.add_mccormick`, append the four reformulated McCormick cuts. +""" +function build_bilinear_approx( + config::Bin2Config, + model::JuMP.Model, + x, + y, + x_bounds::Vector{MinMax}, + y_bounds::Vector{MinMax}, +) + name_axis = axes(x, 1) + time_axis = axes(x, 2) + IS.@assert_op length(name_axis) == length(x_bounds) + IS.@assert_op length(name_axis) == length(y_bounds) + + xsq = build_quadratic_approx(config.quad_config, model, x, x_bounds) + ysq = build_quadratic_approx(config.quad_config, model, y, y_bounds) + + p_expr = JuMP.@expression( + model, + [name = name_axis, t = time_axis], + x[name, t] + y[name, t] + ) + p_bounds = [ + MinMax(( + min = x_bounds[i].min + y_bounds[i].min, + max = x_bounds[i].max + y_bounds[i].max, + )) for i in eachindex(x_bounds) + ] + psq = build_quadratic_approx(config.quad_config, model, p_expr, p_bounds) + + approximation = JuMP.@expression( + model, + [name = name_axis, t = time_axis], + 0.5 * + ( + psq.approximation[name, t] - xsq.approximation[name, t] - + ysq.approximation[name, t] + ) + ) + mc = if config.add_mccormick + build_reformulated_mccormick( + model, + x, + y, + psq.approximation, + xsq.approximation, + ysq.approximation, + x_bounds, + y_bounds, + ) + else + nothing + end + + return Bin2BilinearResult(approximation, xsq, ysq, psq, p_expr, mc) +end + +function register_in_container!( + container::OptimizationContainer, + ::Type{C}, + result::Bin2BilinearResult, + meta::String, +) where {C <: IS.InfrastructureSystemsComponent} + register_in_container!(container, C, result.xsq_result, meta * "_x") + register_in_container!(container, C, result.ysq_result, meta * "_y") + register_in_container!(container, C, result.psq_result, meta * "_plus") + + name_axis = axes(result.approximation, 1) + time_axis = axes(result.approximation, 2) + + p_target = add_expression_container!( + container, + VariableSumExpression, + C, + collect(name_axis), + time_axis; + meta = meta * "_plus", + ) + for name in name_axis, t in time_axis + p_target[name, t] = result.sum_expression[name, t] + end + + result_target = add_expression_container!( + container, + BilinearProductExpression, + C, + collect(name_axis), + time_axis; + meta, + ) + for name in name_axis, t in time_axis + result_target[name, t] = result.approximation[name, t] + end + + if result.mccormick_constraints !== nothing + register_reformulated_mccormick!( + container, C, result.mccormick_constraints, meta, + ) + end + return +end diff --git a/src/approximations/common.jl b/src/approximations/common.jl new file mode 100644 index 00000000..ce194986 --- /dev/null +++ b/src/approximations/common.jl @@ -0,0 +1,205 @@ +# Shared infrastructure for quadratic and bilinear approximations. +# +# This file defines the two abstract config supertypes, the two abstract +# result supertypes, the generic IOM wrappers that POM calls into, and a +# handful of expression key types used by multiple methods. +# +# The architecture is layered: +# +# Pure-JuMP layer (the math) +# build_quadratic_approx(config, model, x, bounds) -> QuadraticApproxResult +# build_bilinear_approx(config, model, x, y, x_bounds, y_bounds) -> BilinearApproxResult +# +# IOM layer (container bookkeeping) +# _add_quadratic_approx!(config, container, C, names, time_steps, x, bounds, meta) +# 1. call build_quadratic_approx +# 2. dispatch register_in_container!(container, C, result, meta) to write +# all auxiliary JuMP objects into the OptimizationContainer +# 3. return the approximation expression container +# +# Each method file under src/approximations/ contains its own config struct, +# result struct, build_* function, and register_in_container! method. + +# --- Abstract config and result supertypes --- + +"Abstract supertype for quadratic approximation method configurations." +abstract type QuadraticApproxConfig end + +"Abstract supertype for bilinear approximation method configurations." +abstract type BilinearApproxConfig end + +"Abstract supertype for the pure-JuMP result of a quadratic approximation build." +abstract type QuadraticApproxResult end + +"Abstract supertype for the pure-JuMP result of a bilinear approximation build." +abstract type BilinearApproxResult end + +""" + get_approximation(result) + +Return the approximation expression container from a quadratic or bilinear +approximation result. The container is indexed by (name, time_step) and +holds either `JuMP.AffExpr` or `JuMP.QuadExpr` entries depending on method. +""" +get_approximation(result::QuadraticApproxResult) = result.approximation +get_approximation(result::BilinearApproxResult) = result.approximation + +# --- Shared expression-key types --- + +"Expression container for the normalized variable xh = (x − x_min) / (x_max − x_min) ∈ [0,1]." +struct NormedVariableExpression <: ExpressionType end + +"Expression container for quadratic (x²) approximation results." +struct QuadraticExpression <: ExpressionType end + +"Expression container for bilinear product (x·y) approximation results." +struct BilinearProductExpression <: ExpressionType end + +"Variable container for bilinear product (x·y) approximation results." +struct BilinearProductVariable <: VariableType end + +"Expression container for sums of two variables, x + y." +struct VariableSumExpression <: ExpressionType end + +"Expression container for differences of two variables, x − y." +struct VariableDifferenceExpression <: ExpressionType end + +# --- Pure-JuMP helper: normalized variable --- + +""" + build_normed_variable(model, x, bounds) -> DenseAxisArray{JuMP.AffExpr, 2} + +Build a 2D container of affine expressions xh = (x − x_min) / (x_max − x_min) ∈ [0,1]. + +Pure-JuMP utility used by methods that operate on a normalized variable +(NMDT, and any caller that needs a [0,1] domain). + +# Arguments +- `model`: JuMP model the expressions live in (only needed for axis types). +- `x`: 2D JuMP container indexed by (name, t). +- `bounds`: per-name bounds aligned with the first axis of `x`. +""" +function build_normed_variable( + model::JuMP.Model, + x, + bounds::Vector{MinMax}, +) + name_axis = axes(x, 1) + time_axis = axes(x, 2) + IS.@assert_op length(name_axis) == length(bounds) + for b in bounds + IS.@assert_op b.max > b.min + end + slope = JuMP.Containers.DenseAxisArray( + [1.0 / (b.max - b.min) for b in bounds], + name_axis, + ) + offset = JuMP.Containers.DenseAxisArray( + [-b.min / (b.max - b.min) for b in bounds], + name_axis, + ) + return JuMP.@expression( + model, + [name = name_axis, t = time_axis], + slope[name] * x[name, t] + offset[name], + ) +end + +# --- IOM-side wrappers (POM entry points) --- + +""" + _add_quadratic_approx!(config, container, C, names, time_steps, x_var, bounds, meta) + +POM entry point for quadratic approximation. Dispatched on the abstract +`QuadraticApproxConfig` type — concrete behavior comes from the concrete +config's `build_quadratic_approx` and `register_in_container!` methods. + +# Arguments +- `config::QuadraticApproxConfig`: approximation method configuration. +- `container::OptimizationContainer`: the optimization container. +- `::Type{C}`: component type (used for container key dispatch). +- `names::Vector{String}`: component names; must equal `axes(x_var, 1)`. +- `time_steps::UnitRange{Int}`: time periods; must equal `axes(x_var, 2)`. +- `x_var`: 2D JuMP container indexed by (name, t). +- `bounds::Vector{MinMax}`: per-name lower and upper bounds of the x domain. +- `meta::String`: identifier used to disambiguate container keys when more + than one approximation of the same kind is registered on the same component + type. The IOM wrapper passes this through to `register_in_container!`. + +# Returns +The approximation expression container (indexed by (name, t)), as returned +by `get_approximation(result)`. +""" +function _add_quadratic_approx!( + config::QuadraticApproxConfig, + container::OptimizationContainer, + ::Type{C}, + names::Vector{String}, + time_steps::UnitRange{Int}, + x_var, + bounds::Vector{MinMax}, + meta::String, +) where {C <: IS.InfrastructureSystemsComponent} + result = build_quadratic_approx(config, get_jump_model(container), x_var, bounds) + register_in_container!(container, C, result, meta) + return get_approximation(result) +end + +""" + _add_bilinear_approx!(config, container, C, names, time_steps, x_var, y_var, x_bounds, y_bounds, meta) + +POM entry point for bilinear approximation. Dispatched on the abstract +`BilinearApproxConfig` type — concrete behavior comes from the concrete +config's `build_bilinear_approx` and `register_in_container!` methods. + +# Arguments +- `config::BilinearApproxConfig`: approximation method configuration. +- `container::OptimizationContainer`: the optimization container. +- `::Type{C}`: component type (used for container key dispatch). +- `names::Vector{String}`: component names; must equal `axes(x_var, 1)` and `axes(y_var, 1)`. +- `time_steps::UnitRange{Int}`: time periods. +- `x_var`, `y_var`: 2D JuMP containers indexed by (name, t). +- `x_bounds`, `y_bounds`: per-name lower and upper bounds. +- `meta::String`: identifier used to disambiguate container keys. + +# Returns +The approximation expression container (indexed by (name, t)). +""" +function _add_bilinear_approx!( + config::BilinearApproxConfig, + container::OptimizationContainer, + ::Type{C}, + names::Vector{String}, + time_steps::UnitRange{Int}, + x_var, + y_var, + x_bounds::Vector{MinMax}, + y_bounds::Vector{MinMax}, + meta::String, +) where {C <: IS.InfrastructureSystemsComponent} + result = build_bilinear_approx( + config, + get_jump_model(container), + x_var, + y_var, + x_bounds, + y_bounds, + ) + register_in_container!(container, C, result, meta) + return get_approximation(result) +end + +# --- register_in_container! interface --- +# Concrete methods are defined in each method file, dispatched on result-struct type. + +""" + register_in_container!(container, ::Type{C}, result, meta) + +Write the JuMP objects held by `result` into the `OptimizationContainer` +using the appropriate key types and `meta` suffix. + +Each concrete result struct provides its own method. The math layer +(`build_*`) never references container keys directly — that name → key +mapping lives only inside `register_in_container!`. +""" +function register_in_container! end diff --git a/src/approximations/epigraph.jl b/src/approximations/epigraph.jl new file mode 100644 index 00000000..63307028 --- /dev/null +++ b/src/approximations/epigraph.jl @@ -0,0 +1,273 @@ +# Epigraph (Q^{L1}) LP-only lower bound for x² using tangent-line cuts. +# Pure LP — zero binary variables. Creates a variable z that lower-bounds +# x² (approximately) bounded from below by supporting hyperplanes of the +# parabola. +# Reference: Beach, Burlacu, Hager, Hildebrand (2024), Q^{L1} relaxation. + +"Expression container for epigraph quadratic approximation results." +struct EpigraphExpression <: ExpressionType end + +"Auxiliary continuous variables (g₀, …, g_L) for tooth-based PWL approximations." +struct SawtoothAuxVariable <: VariableType end + +"LP relaxation constraints (g_j ≤ 2g_{j-1}, g_j ≤ 2(1−g_{j-1}))." +struct SawtoothLPConstraint <: ConstraintType end + +"Links g₀ to the normalized x value." +struct SawtoothLinkingConstraint <: ConstraintType end + +"Variable representing a lower-bounded approximation of x² in epigraph relaxation." +struct EpigraphVariable <: VariableType end + +"Tangent-line lower-bound constraints in epigraph relaxation." +struct EpigraphTangentConstraint <: ConstraintType end + +"Tangent-line lower-bound expression fL used in the epigraph formulation." +struct EpigraphTangentExpression <: ExpressionType end + +""" +Config for epigraph (Q^{L1}) LP-only lower-bound quadratic approximation. + +# Fields +- `depth::Int`: number of tangent-line breakpoints (2^depth + 1 tangent lines); + pure LP, zero binary variables. +""" +struct EpigraphQuadConfig <: QuadraticApproxConfig + depth::Int +end + +""" +Pure-JuMP result of `build_quadratic_approx(::EpigraphQuadConfig, ...)`. +""" +struct EpigraphQuadResult{A, Z, G, LP, LC, FL, TC} <: QuadraticApproxResult + approximation::A + z_var::Z + g_var::G + lp_constraints::LP + link_constraints::LC + tangent_expressions::FL + tangent_constraints::TC +end + +""" + build_quadratic_approx(config::EpigraphQuadConfig, model, x, bounds) + +LP-only lower bound for x² via 2^depth + 1 tangent-line cuts on the +parabola at uniformly spaced breakpoints in [x_min, x_max]. +""" +function build_quadratic_approx( + config::EpigraphQuadConfig, + model::JuMP.Model, + x, + bounds::Vector{MinMax}, +) + IS.@assert_op config.depth >= 1 + name_axis = axes(x, 1) + time_axis = axes(x, 2) + IS.@assert_op length(name_axis) == length(bounds) + for b in bounds + IS.@assert_op b.max > b.min + end + + g_levels = 0:(config.depth) + delta = JuMP.Containers.DenseAxisArray( + [b.max - b.min for b in bounds], + name_axis, + ) + x_min_arr = JuMP.Containers.DenseAxisArray( + [b.min for b in bounds], + name_axis, + ) + z_ub_arr = JuMP.Containers.DenseAxisArray( + [max(b.min^2, b.max^2) for b in bounds], + name_axis, + ) + + g_var = JuMP.@variable( + model, + [name = name_axis, j = g_levels, t = time_axis], + lower_bound = 0.0, + upper_bound = 1.0, + base_name = "SawtoothAux", + ) + + link_cons = JuMP.@constraint( + model, + [name = name_axis, t = time_axis], + g_var[name, 0, t] == (x[name, t] - x_min_arr[name]) / delta[name] + ) + + # T^L constraints: g_j ≤ 2 g_{j-1} and g_j ≤ 2(1 − g_{j-1}) for j = 1..L. + # Indexed by (name, j, k, t) with k ∈ 1:2. + lp_cons = JuMP.Containers.DenseAxisArray{Any}( + undef, name_axis, 1:(config.depth), 1:2, time_axis, + ) + for name in name_axis, j in 1:(config.depth), t in time_axis + lp_cons[name, j, 1, t] = JuMP.@constraint( + model, + g_var[name, j, t] <= 2.0 * g_var[name, j - 1, t], + ) + lp_cons[name, j, 2, t] = JuMP.@constraint( + model, + g_var[name, j, t] <= 2.0 * (1.0 - g_var[name, j - 1, t]), + ) + end + + # z is bounded below by x² via tangent cuts; pure-LP variable. + z_var = JuMP.@variable( + model, + [name = name_axis, t = time_axis], + lower_bound = 0.0, + base_name = "EpigraphVar", + ) + for name in name_axis, t in time_axis + JuMP.set_upper_bound(z_var[name, t], z_ub_arr[name]) + end + + # fL[j] = Σ_{k=1..j} δ² · 2^{−2k} · g_k (partial sum used in the j-th tangent cut). + # Built as a 2D container with time axis only (full-depth sum); the partial + # sums for each tangent constraint are formed inline below. + fL_expr = JuMP.@expression( + model, + [name = name_axis, t = time_axis], + sum(delta[name]^2 * 2.0^(-2j) * g_var[name, j, t] for j in 1:(config.depth)) + ) + + # Tangent-line cuts: z ≥ 0, z ≥ 2·x_min + 2·δ·g₀ − 1, plus depth more cuts + # at j = 1..L of the form z ≥ x_min·(2·δ·g₀ + x_min) − fL[j] + δ²·(g₀ − 2^{−2j−2}). + tangent_cons = JuMP.Containers.DenseAxisArray{Any}( + undef, name_axis, 1:(config.depth + 2), time_axis, + ) + for name in name_axis, t in time_axis + tangent_cons[name, 1, t] = JuMP.@constraint(model, z_var[name, t] >= 0) + tangent_cons[name, 2, t] = JuMP.@constraint( + model, + z_var[name, t] >= + 2.0 * x_min_arr[name] - 1.0 + 2.0 * delta[name] * g_var[name, 0, t], + ) + for j in 1:(config.depth) + tangent_cons[name, j + 2, t] = JuMP.@constraint( + model, + z_var[name, t] >= + x_min_arr[name] * + (2.0 * delta[name] * g_var[name, 0, t] + x_min_arr[name]) - + sum( + delta[name]^2 * 2.0^(-2k) * g_var[name, k, t] for k in 1:j + ) + + delta[name]^2 * (g_var[name, 0, t] - 2.0^(-2j - 2)), + ) + end + end + + approximation = JuMP.@expression( + model, + [name = name_axis, t = time_axis], + 1.0 * z_var[name, t] + ) + + return EpigraphQuadResult( + approximation, + z_var, + g_var, + lp_cons, + link_cons, + fL_expr, + tangent_cons, + ) +end + +function register_in_container!( + container::OptimizationContainer, + ::Type{C}, + result::EpigraphQuadResult, + meta::String, +) where {C <: IS.InfrastructureSystemsComponent} + name_axis = axes(result.approximation, 1) + time_axis = axes(result.approximation, 2) + g_levels = axes(result.g_var, 2) + + z_target = add_variable_container!( + container, + EpigraphVariable, + C, + collect(name_axis), + time_axis; + meta, + ) + for name in name_axis, t in time_axis + z_target[name, t] = result.z_var[name, t] + end + + g_target = add_variable_container!( + container, + SawtoothAuxVariable, + C, + collect(name_axis), + g_levels, + time_axis; + meta, + ) + for name in name_axis, j in g_levels, t in time_axis + g_target[name, j, t] = result.g_var[name, j, t] + end + + link_target = add_constraints_container!( + container, + SawtoothLinkingConstraint, + C, + collect(name_axis), + time_axis; + meta, + ) + fL_target = add_expression_container!( + container, + EpigraphTangentExpression, + C, + collect(name_axis), + time_axis; + meta, + ) + result_target = add_expression_container!( + container, + EpigraphExpression, + C, + collect(name_axis), + time_axis; + meta, + ) + for name in name_axis, t in time_axis + link_target[name, t] = result.link_constraints[name, t] + fL_target[name, t] = result.tangent_expressions[name, t] + result_target[name, t] = result.approximation[name, t] + end + + lp_target = add_constraints_container!( + container, + SawtoothLPConstraint, + C, + collect(name_axis), + 1:2, + time_axis; + meta, + ) + lp_lvl_axis = axes(result.lp_constraints, 2) + for name in name_axis, j in lp_lvl_axis, k in 1:2, t in time_axis + lp_target[name, k, t] = result.lp_constraints[name, j, k, t] + end + + tangent_axis = axes(result.tangent_constraints, 2) + tangent_target = add_constraints_container!( + container, + EpigraphTangentConstraint, + C, + collect(name_axis), + tangent_axis, + time_axis; + sparse = true, + meta, + ) + for name in name_axis, k in tangent_axis, t in time_axis + tangent_target[(name, k, t)] = result.tangent_constraints[name, k, t] + end + return +end diff --git a/src/approximations/hybs.jl b/src/approximations/hybs.jl new file mode 100644 index 00000000..1e489f4f --- /dev/null +++ b/src/approximations/hybs.jl @@ -0,0 +1,252 @@ +# HybS (Hybrid Separable) MIP relaxation for bilinear products z = x·y. +# Combines a Bin2 lower bound and a Bin3 upper bound with shared quadratic +# approximations of x², y² and pure-LP epigraph relaxations of (x+y)², (x−y)². +# Uses 2L binaries instead of Bin2's 3L. +# Reference: Beach, Burlacu, Bärmann, Hager, Hildebrand (2024), Definition 10. + +"Two-sided HybS bound constraints: Bin2 lower + Bin3 upper." +struct HybSBoundConstraint <: ConstraintType end + +""" +Config for HybS bilinear approximation. + +# Fields +- `quad_config::QuadraticApproxConfig`: quadratic method used for x² and y². +- `epigraph_depth::Int`: depth of the epigraph Q^{L1} approximation of (x±y)². +- `add_mccormick::Bool`: whether to add a standard McCormick envelope on z (default false). +""" +struct HybSConfig <: BilinearApproxConfig + quad_config::QuadraticApproxConfig + epigraph_depth::Int + add_mccormick::Bool +end +function HybSConfig(quad_config::QuadraticApproxConfig, epigraph_depth::Int) + return HybSConfig(quad_config, epigraph_depth, false) +end + +""" +Pure-JuMP result of `build_bilinear_approx(::HybSConfig, ...)`. +""" +struct HybSBilinearResult{A, XSQ, YSQ, P1, P2, ZP1, ZP2, ZV, BC, MC} <: BilinearApproxResult + approximation::A + xsq_result::XSQ + ysq_result::YSQ + sum_expression::P1 + diff_expression::P2 + sum_epigraph::ZP1 + diff_epigraph::ZP2 + z_var::ZV + bound_constraints::BC + mccormick_constraints::MC +end + +""" + build_bilinear_approx(config::HybSConfig, model, x, y, x_bounds, y_bounds) + +HybS bilinear approximation. Builds x² and y² via the chosen quadratic +method, builds (x+y)² and (x−y)² via the epigraph Q^{L1} relaxation, and +constrains a fresh product variable z with two-sided bounds derived from +the Bin2 lower / Bin3 upper identities. +""" +function build_bilinear_approx( + config::HybSConfig, + model::JuMP.Model, + x, + y, + x_bounds::Vector{MinMax}, + y_bounds::Vector{MinMax}, +) + name_axis = axes(x, 1) + time_axis = axes(x, 2) + IS.@assert_op length(name_axis) == length(x_bounds) + IS.@assert_op length(name_axis) == length(y_bounds) + + xsq = build_quadratic_approx(config.quad_config, model, x, x_bounds) + ysq = build_quadratic_approx(config.quad_config, model, y, y_bounds) + + p1_expr = JuMP.@expression( + model, + [name = name_axis, t = time_axis], + x[name, t] + y[name, t] + ) + p2_expr = JuMP.@expression( + model, + [name = name_axis, t = time_axis], + x[name, t] - y[name, t] + ) + p1_bounds = [ + MinMax(( + min = x_bounds[i].min + y_bounds[i].min, + max = x_bounds[i].max + y_bounds[i].max, + )) for i in eachindex(x_bounds) + ] + p2_bounds = [ + MinMax(( + min = x_bounds[i].min - y_bounds[i].max, + max = x_bounds[i].max - y_bounds[i].min, + )) for i in eachindex(x_bounds) + ] + + epi_cfg = EpigraphQuadConfig(config.epigraph_depth) + zp1 = build_quadratic_approx(epi_cfg, model, p1_expr, p1_bounds) + zp2 = build_quadratic_approx(epi_cfg, model, p2_expr, p2_bounds) + + z_lo = [ + min( + x_bounds[i].min * y_bounds[i].min, + x_bounds[i].min * y_bounds[i].max, + x_bounds[i].max * y_bounds[i].min, + x_bounds[i].max * y_bounds[i].max, + ) for i in eachindex(x_bounds) + ] + z_hi = [ + max( + x_bounds[i].min * y_bounds[i].min, + x_bounds[i].min * y_bounds[i].max, + x_bounds[i].max * y_bounds[i].min, + x_bounds[i].max * y_bounds[i].max, + ) for i in eachindex(x_bounds) + ] + z_lo_arr = JuMP.Containers.DenseAxisArray(z_lo, name_axis) + z_hi_arr = JuMP.Containers.DenseAxisArray(z_hi, name_axis) + + z_var = JuMP.@variable( + model, + [name = name_axis, t = time_axis], + base_name = "HybSProduct", + ) + for name in name_axis, t in time_axis + JuMP.set_lower_bound(z_var[name, t], z_lo_arr[name]) + JuMP.set_upper_bound(z_var[name, t], z_hi_arr[name]) + end + + bound_cons = JuMP.Containers.DenseAxisArray{Any}( + undef, name_axis, 1:2, time_axis, + ) + for name in name_axis, t in time_axis + # Bin2 lower bound: z ≥ ½·(zp1 − zx − zy) + bound_cons[name, 1, t] = JuMP.@constraint( + model, + z_var[name, t] >= + 0.5 * + ( + zp1.approximation[name, t] - xsq.approximation[name, t] - + ysq.approximation[name, t] + ), + ) + # Bin3 upper bound: z ≤ ½·(zx + zy − zp2) + bound_cons[name, 2, t] = JuMP.@constraint( + model, + z_var[name, t] <= + 0.5 * + ( + xsq.approximation[name, t] + ysq.approximation[name, t] - + zp2.approximation[name, t] + ), + ) + end + + approximation = JuMP.@expression( + model, + [name = name_axis, t = time_axis], + 1.0 * z_var[name, t] + ) + + mc = if config.add_mccormick + build_mccormick_envelope(model, x, y, z_var, x_bounds, y_bounds) + else + nothing + end + + return HybSBilinearResult( + approximation, + xsq, + ysq, + p1_expr, + p2_expr, + zp1, + zp2, + z_var, + bound_cons, + mc, + ) +end + +function register_in_container!( + container::OptimizationContainer, + ::Type{C}, + result::HybSBilinearResult, + meta::String, +) where {C <: IS.InfrastructureSystemsComponent} + register_in_container!(container, C, result.xsq_result, meta * "_x") + register_in_container!(container, C, result.ysq_result, meta * "_y") + register_in_container!(container, C, result.sum_epigraph, meta * "_plus") + register_in_container!(container, C, result.diff_epigraph, meta * "_diff") + + name_axis = axes(result.approximation, 1) + time_axis = axes(result.approximation, 2) + + p1_target = add_expression_container!( + container, + VariableSumExpression, + C, + collect(name_axis), + time_axis; + meta = meta * "_plus", + ) + p2_target = add_expression_container!( + container, + VariableDifferenceExpression, + C, + collect(name_axis), + time_axis; + meta = meta * "_diff", + ) + for name in name_axis, t in time_axis + p1_target[name, t] = result.sum_expression[name, t] + p2_target[name, t] = result.diff_expression[name, t] + end + + z_target = add_variable_container!( + container, + BilinearProductVariable, + C, + collect(name_axis), + time_axis; + meta, + ) + for name in name_axis, t in time_axis + z_target[name, t] = result.z_var[name, t] + end + + bound_target = add_constraints_container!( + container, + HybSBoundConstraint, + C, + collect(name_axis), + 1:2, + time_axis; + sparse = true, + meta, + ) + for name in name_axis, k in 1:2, t in time_axis + bound_target[(name, k, t)] = result.bound_constraints[name, k, t] + end + + result_target = add_expression_container!( + container, + BilinearProductExpression, + C, + collect(name_axis), + time_axis; + meta, + ) + for name in name_axis, t in time_axis + result_target[name, t] = result.approximation[name, t] + end + + if result.mccormick_constraints !== nothing + register_mccormick_envelope!(container, C, result.mccormick_constraints, meta) + end + return +end diff --git a/src/approximations/incremental.jl b/src/approximations/incremental.jl new file mode 100644 index 00000000..fd49ac2b --- /dev/null +++ b/src/approximations/incremental.jl @@ -0,0 +1,165 @@ +# Incremental piecewise linear (PWL) formulation utility. +# +# This is not an approximation method in the `build_quadratic_approx`/ +# `build_bilinear_approx` sense — it's a container-coupled utility used by +# downstream packages (e.g. POM HVDC models) to build PWL variables and +# constraints for arbitrary nonlinear functions. Kept here because the +# math is in the same family as the other PWL approximations. + +""" + add_sparse_pwl_interpolation_variables!(container, ::Type{T}, devices, model, num_segments) + +Add piecewise linear interpolation variables to an optimization container. + +For continuous interpolation variables (`T <: InterpolationVariableType`), +creates `num_segments` variables per (device, t). For binary variables +(`T <: BinaryInterpolationVariableType`), creates `num_segments - 1` +variables (controlling transitions between segments). + +# Arguments +- `container::OptimizationContainer`: target container. +- `::Type{T}`: variable type (interpolation or binary interpolation). +- `devices`: iterable of components. +- `model::DeviceModel{U, V}`: device model providing variable bounds. +- `num_segments`: number of PWL segments (default `DEFAULT_INTERPOLATION_LENGTH`). +""" +function add_sparse_pwl_interpolation_variables!( + container::OptimizationContainer, + ::Type{T}, + devices, + model::DeviceModel{U, V}, + num_segments = DEFAULT_INTERPOLATION_LENGTH, +) where { + T <: Union{InterpolationVariableType, BinaryInterpolationVariableType}, + U <: IS.InfrastructureSystemsComponent, + V <: AbstractDeviceFormulation, +} + time_steps = get_time_steps(container) + var_container = lazy_container_addition!(container, T, U) + binary_flag = get_variable_binary(T, U, V) + len_segs = binary_flag ? (num_segments - 1) : num_segments + + for d in devices + name = get_name(d) + for t in time_steps + for i in 1:len_segs + var_container[(name, i, t)] = JuMP.@variable( + get_jump_model(container), + base_name = "$(T)_$(name)_{pwl_$(i), $(t)}", + binary = binary_flag + ) + ub = get_variable_upper_bound(T, d, V) + ub !== nothing && JuMP.set_upper_bound(var_container[name, i, t], ub) + lb = get_variable_lower_bound(T, d, V) + lb !== nothing && JuMP.set_lower_bound(var_container[name, i, t], lb) + end + end + end + return +end + +""" + _add_generic_incremental_interpolation_constraint!(container, ::R, ::S, ::T, ::U, ::V, devices, dic_var_bkpts, dic_function_bkpts; meta) + +Add incremental piecewise linear interpolation constraints relating the +original variable x (type `R`) to its piecewise approximation y = f(x) +(type `S`), using interpolation variables δ (type `T`) and binary variables +z (type `U`) under constraint type `V`. + +The incremental method represents each segment as: +- `x = x₁ + Σᵢ δᵢ · (xᵢ₊₁ − xᵢ)` with δᵢ ∈ [0, 1] +- `y = y₁ + Σᵢ δᵢ · (yᵢ₊₁ − yᵢ)` + +Binary variables z enforce the incremental ordering δᵢ₊₁ ≤ zᵢ ≤ δᵢ. + +# Arguments +- `container::OptimizationContainer`: target container. +- `::Type{R}`, `::Type{S}`: original and approximated variable types. +- `::Type{T}`, `::Type{U}`: interpolation and binary interpolation types. +- `::Type{V}`: constraint type. +- `devices`: iterable of components. +- `dic_var_bkpts::Dict{String, Vector{Float64}}`: domain breakpoints. +- `dic_function_bkpts::Dict{String, Vector{Float64}}`: function-value breakpoints. +- `meta`: constraint-name prefix (default `CONTAINER_KEY_EMPTY_META`). +""" +function _add_generic_incremental_interpolation_constraint!( + container::OptimizationContainer, + ::Type{R}, + ::Type{S}, + ::Type{T}, + ::Type{U}, + ::Type{V}, + devices::IS.FlattenIteratorWrapper{W}, + dic_var_bkpts::Dict{String, Vector{Float64}}, + dic_function_bkpts::Dict{String, Vector{Float64}}; + meta = CONTAINER_KEY_EMPTY_META, +) where { + R <: VariableType, + S <: VariableType, + T <: VariableType, + U <: VariableType, + V <: ConstraintType, + W <: IS.InfrastructureSystemsComponent, +} + time_steps = get_time_steps(container) + names = [get_name(d) for d in devices] + JuMPmodel = get_jump_model(container) + + x_var = if R <: DCVoltage + get_variable(container, R, component_for_hvdc_interpolation(nothing)) + else + get_variable(container, R, W) + end + y_var = get_variable(container, S, W) + δ_var = get_variable(container, T, W) + z_var = get_variable(container, U, W) + + const_container_var = add_constraints_container!( + container, + V, + W, + names, + time_steps; + meta = "$(meta)pwl_variable", + ) + const_container_function = add_constraints_container!( + container, + V, + W, + names, + time_steps; + meta = "$(meta)pwl_function", + ) + + for d in devices + name = get_name(d) + x_name = (R <: DCVoltage) ? get_name(get_dc_bus(d)) : name + var_bkpts = dic_var_bkpts[name] + function_bkpts = dic_function_bkpts[name] + num_segments = length(var_bkpts) - 1 + + for t in time_steps + const_container_var[name, t] = JuMP.@constraint( + JuMPmodel, + x_var[x_name, t] == + var_bkpts[1] + sum( + δ_var[name, i, t] * (var_bkpts[i + 1] - var_bkpts[i]) for + i in 1:num_segments + ) + ) + const_container_function[name, t] = JuMP.@constraint( + JuMPmodel, + y_var[name, t] == + function_bkpts[1] + sum( + δ_var[name, i, t] * (function_bkpts[i + 1] - function_bkpts[i]) for + i in 1:num_segments + ) + ) + for i in 1:(num_segments - 1) + JuMP.@constraint(JuMPmodel, z_var[name, i, t] >= δ_var[name, i + 1, t]) + JuMP.@constraint(JuMPmodel, z_var[name, i, t] <= δ_var[name, i, t]) + end + end + end + return +end diff --git a/src/approximations/manual_sos2.jl b/src/approximations/manual_sos2.jl new file mode 100644 index 00000000..8eb7fcc5 --- /dev/null +++ b/src/approximations/manual_sos2.jl @@ -0,0 +1,302 @@ +# SOS2 piecewise linear approximation of x² with manually-implemented adjacency +# via binary segment-selector variables. Useful when the solver does not +# natively support MOI.SOS2. + +"Binary segment-selection variables (z) for manual SOS2 quadratic approximation." +struct ManualSOS2BinaryVariable <: SparseVariableType end + +"Ensures exactly one segment is active (∑ z_j = 1) in manual SOS2 quadratic approximation." +struct ManualSOS2SegmentSelectionConstraint <: ConstraintType end + +"Expression for the segment selection sum Σ z_j in manual SOS2 quadratic approximation." +struct ManualSOS2SegmentSelectionExpression <: ExpressionType end + +"Links active segment to lambda variables." +struct ManualSOS2AdjacencyConstraint <: ConstraintType end + +""" +Config for manual binary-variable SOS2 quadratic approximation. + +# Fields +- `depth::Int`: number of PWL segments (breakpoints = depth + 1). +- `pwmcc_segments::Int`: number of piecewise McCormick cut partitions; + 0 to disable (default 4). +""" +struct ManualSOS2QuadConfig <: QuadraticApproxConfig + depth::Int + pwmcc_segments::Int +end +function ManualSOS2QuadConfig(depth::Int) + return ManualSOS2QuadConfig(depth, 4) +end + +""" +Pure-JuMP result of `build_quadratic_approx(::ManualSOS2QuadConfig, ...)`. +""" +struct ManualSOS2QuadResult{A, L, Z, LC, NC, ZSUM, AC, LE, NE, ZE, PWMCC} <: + QuadraticApproxResult + approximation::A + lambda::L + z_var::Z + link_constraints::LC + norm_constraints::NC + segment_sum_constraints::ZSUM + adjacency_constraints::AC + link_expressions::LE + norm_expressions::NE + segment_sum_expressions::ZE + pwmcc::PWMCC +end + +""" + build_quadratic_approx(config::ManualSOS2QuadConfig, model, x, bounds) + +PWL approximation of x² with manually-enforced SOS2 adjacency via binary +segment-selectors z_j and adjacency constraints λ_i ≤ z_{i-1} + z_i. If +`config.pwmcc_segments > 0`, also adds piecewise McCormick concave cuts. +""" +function build_quadratic_approx( + config::ManualSOS2QuadConfig, + model::JuMP.Model, + x, + bounds::Vector{MinMax}, +) + name_axis = axes(x, 1) + time_axis = axes(x, 2) + IS.@assert_op length(name_axis) == length(bounds) + for b in bounds + IS.@assert_op b.max > b.min + end + n_points = config.depth + 1 + n_bins = n_points - 1 + x_bkpts, x_sq_bkpts = _get_breakpoints_for_pwl_function( + 0.0, + 1.0, + _square; + num_segments = config.depth, + ) + + lx = JuMP.Containers.DenseAxisArray( + [b.max - b.min for b in bounds], + name_axis, + ) + x_min = JuMP.Containers.DenseAxisArray( + [b.min for b in bounds], + name_axis, + ) + + lambda = JuMP.@variable( + model, + [name = name_axis, i = 1:n_points, t = time_axis], + lower_bound = 0.0, + upper_bound = 1.0, + base_name = "QuadraticVariable", + ) + z_var = JuMP.@variable( + model, + [name = name_axis, j = 1:n_bins, t = time_axis], + binary = true, + base_name = "ManualSOS2Binary", + ) + + link_expr = JuMP.@expression( + model, + [name = name_axis, t = time_axis], + sum(x_bkpts[i] * lambda[name, i, t] for i in 1:n_points) + ) + link_cons = JuMP.@constraint( + model, + [name = name_axis, t = time_axis], + (x[name, t] - x_min[name]) / lx[name] == link_expr[name, t] + ) + norm_expr = JuMP.@expression( + model, + [name = name_axis, t = time_axis], + sum(lambda[name, i, t] for i in 1:n_points) + ) + norm_cons = JuMP.@constraint( + model, + [name = name_axis, t = time_axis], + norm_expr[name, t] == 1.0 + ) + seg_expr = JuMP.@expression( + model, + [name = name_axis, t = time_axis], + sum(z_var[name, j, t] for j in 1:n_bins) + ) + seg_cons = JuMP.@constraint( + model, + [name = name_axis, t = time_axis], + seg_expr[name, t] == 1 + ) + + # Adjacency constraints: λ_i ≤ z_{i-1} + z_i with boundary cases. + # Store as a 3D container keyed by (name, i, t) where i ∈ 1:n_points. + adj_cons = JuMP.Containers.DenseAxisArray{Any}( + undef, name_axis, 1:n_points, time_axis, + ) + for name in name_axis, t in time_axis + adj_cons[name, 1, t] = + JuMP.@constraint(model, lambda[name, 1, t] <= z_var[name, 1, t]) + for i in 2:(n_points - 1) + adj_cons[name, i, t] = JuMP.@constraint( + model, + lambda[name, i, t] <= z_var[name, i - 1, t] + z_var[name, i, t], + ) + end + adj_cons[name, n_points, t] = JuMP.@constraint( + model, + lambda[name, n_points, t] <= z_var[name, n_bins, t], + ) + end + + approximation = JuMP.@expression( + model, + [name = name_axis, t = time_axis], + lx[name] * lx[name] * + sum(x_sq_bkpts[i] * lambda[name, i, t] for i in 1:n_points) + + 2.0 * x_min[name] * x[name, t] - x_min[name] * x_min[name] + ) + + pwmcc = if config.pwmcc_segments > 0 + build_pwmcc_concave_cuts(model, x, approximation, bounds, config.pwmcc_segments) + else + nothing + end + + return ManualSOS2QuadResult( + approximation, + lambda, + z_var, + link_cons, + norm_cons, + seg_cons, + adj_cons, + link_expr, + norm_expr, + seg_expr, + pwmcc, + ) +end + +function register_in_container!( + container::OptimizationContainer, + ::Type{C}, + result::ManualSOS2QuadResult, + meta::String, +) where {C <: IS.InfrastructureSystemsComponent} + name_axis = axes(result.approximation, 1) + time_axis = axes(result.approximation, 2) + n_points_axis = axes(result.lambda, 2) + n_bins_axis = axes(result.z_var, 2) + + lambda_target = add_variable_container!( + container, + QuadraticVariable, + C, + collect(name_axis), + n_points_axis, + time_axis; + meta, + ) + for name in name_axis, i in n_points_axis, t in time_axis + lambda_target[name, i, t] = result.lambda[name, i, t] + end + + z_target = add_variable_container!( + container, + ManualSOS2BinaryVariable, + C, + collect(name_axis), + n_bins_axis, + time_axis; + meta, + ) + for name in name_axis, j in n_bins_axis, t in time_axis + z_target[name, j, t] = result.z_var[name, j, t] + end + + link_cons_target = add_constraints_container!( + container, + SOS2LinkingConstraint, + C, + collect(name_axis), + time_axis; + meta, + ) + norm_cons_target = add_constraints_container!( + container, + SOS2NormConstraint, + C, + collect(name_axis), + time_axis; + meta, + ) + seg_cons_target = add_constraints_container!( + container, + ManualSOS2SegmentSelectionConstraint, + C, + collect(name_axis), + time_axis; + meta, + ) + link_expr_target = add_expression_container!( + container, + SOS2LinkingExpression, + C, + collect(name_axis), + time_axis; + meta, + ) + norm_expr_target = add_expression_container!( + container, + SOS2NormExpression, + C, + collect(name_axis), + time_axis; + meta, + ) + seg_expr_target = add_expression_container!( + container, + ManualSOS2SegmentSelectionExpression, + C, + collect(name_axis), + time_axis; + meta, + ) + result_target = add_expression_container!( + container, + QuadraticExpression, + C, + collect(name_axis), + time_axis; + meta, + ) + for name in name_axis, t in time_axis + link_cons_target[name, t] = result.link_constraints[name, t] + norm_cons_target[name, t] = result.norm_constraints[name, t] + seg_cons_target[name, t] = result.segment_sum_constraints[name, t] + link_expr_target[name, t] = result.link_expressions[name, t] + norm_expr_target[name, t] = result.norm_expressions[name, t] + seg_expr_target[name, t] = result.segment_sum_expressions[name, t] + result_target[name, t] = result.approximation[name, t] + end + + adj_target = add_constraints_container!( + container, + ManualSOS2AdjacencyConstraint, + C, + collect(name_axis), + n_points_axis, + time_axis; + meta, + ) + for name in name_axis, i in n_points_axis, t in time_axis + adj_target[name, i, t] = result.adjacency_constraints[name, i, t] + end + + if result.pwmcc !== nothing + register_pwmcc!(container, C, result.pwmcc, meta * "_pwmcc") + end + return +end diff --git a/src/approximations/mccormick.jl b/src/approximations/mccormick.jl new file mode 100644 index 00000000..1cd292b7 --- /dev/null +++ b/src/approximations/mccormick.jl @@ -0,0 +1,242 @@ +# McCormick envelope for bilinear products z = x·y. +# Adds 4 linear inequalities that bound z given variable bounds on x and y. + +"Standard McCormick envelope constraints bounding the bilinear product z = x·y." +struct McCormickConstraint <: ConstraintType end + +"Reformulated McCormick constraints on Bin2 separable variables." +struct ReformulatedMcCormickConstraint <: ConstraintType end + +# --- Pure-JuMP single-element helpers --- + +""" + build_mccormick_envelope(model, x, y, z, x_min, x_max, y_min, y_max; lower_bounds = true) + +Add the four McCormick inequalities bounding `z ≈ x·y` to `model` and return +them as a `(c1, c2, c3, c4)` tuple. If `lower_bounds == false`, the first +two constraints (`z ≥ …` lower envelopes) are omitted; the returned tuple +slots are `nothing` in their place. + +Inputs may be `JuMP.AbstractJuMPScalar` (variable or affine expression). +""" +function build_mccormick_envelope( + model::JuMP.Model, + x::JuMP.AbstractJuMPScalar, + y::JuMP.AbstractJuMPScalar, + z::JuMP.AbstractJuMPScalar, + x_min::Float64, + x_max::Float64, + y_min::Float64, + y_max::Float64; + lower_bounds::Bool = true, +) + c1 = if lower_bounds + JuMP.@constraint(model, z >= x_min * y + x * y_min - x_min * y_min) + else + nothing + end + c2 = if lower_bounds + JuMP.@constraint(model, z >= x_max * y + x * y_max - x_max * y_max) + else + nothing + end + c3 = JuMP.@constraint(model, z <= x_max * y + x * y_min - x_max * y_min) + c4 = JuMP.@constraint(model, z <= x_min * y + x * y_max - x_min * y_max) + return (c1, c2, c3, c4) +end + +""" + build_mccormick_envelope(model, x, y, z, x_bounds, y_bounds; lower_bounds = true) + +Vectorized McCormick envelope over a (name, t) grid: for each (name, t) +adds the four inequalities bounding `z[name, t] ≈ x[name, t] · y[name, t]`. +Returns a `DenseAxisArray` indexed by (name, k, t) where k ∈ 1:4 holds +the four constraints (or a `Union{Missing, ConstraintRef}` array entry +for the omitted lower bounds when `lower_bounds == false`). +""" +function build_mccormick_envelope( + model::JuMP.Model, + x, + y, + z, + x_bounds::Vector{MinMax}, + y_bounds::Vector{MinMax}; + lower_bounds::Bool = true, +) + name_axis = axes(x, 1) + time_axis = axes(x, 2) + IS.@assert_op length(name_axis) == length(x_bounds) + IS.@assert_op length(name_axis) == length(y_bounds) + + cons = JuMP.Containers.DenseAxisArray{Any}(undef, name_axis, 1:4, time_axis) + for (i, name) in enumerate(name_axis), t in time_axis + xb = x_bounds[i] + yb = y_bounds[i] + IS.@assert_op xb.max > xb.min + IS.@assert_op yb.max > yb.min + c1, c2, c3, c4 = build_mccormick_envelope( + model, + x[name, t], + y[name, t], + z[name, t], + xb.min, + xb.max, + yb.min, + yb.max; + lower_bounds, + ) + cons[name, 1, t] = c1 + cons[name, 2, t] = c2 + cons[name, 3, t] = c3 + cons[name, 4, t] = c4 + end + return cons +end + +# --- Bin2 reformulated-McCormick helpers (used inside build_bilinear_approx(::Bin2Config, ...)) --- + +""" + build_reformulated_mccormick(model, x, y, zp1, zx, zy, x_min, x_max, y_min, y_max) + +Build the four reformulated-McCormick inequalities for the Bin2 separable +identity, in terms of the quadratic approximations zp1 ≈ (x+y)², zx ≈ x², +zy ≈ y². Returns the four constraints as a tuple. +""" +function build_reformulated_mccormick( + model::JuMP.Model, + x::JuMP.AbstractJuMPScalar, + y::JuMP.AbstractJuMPScalar, + zp1::JuMP.AbstractJuMPScalar, + zx::JuMP.AbstractJuMPScalar, + zy::JuMP.AbstractJuMPScalar, + x_min::Float64, + x_max::Float64, + y_min::Float64, + y_max::Float64, +) + c1 = JuMP.@constraint( + model, + zp1 - zx - zy >= 2.0 * (x_min * y + x * y_min - x_min * y_min), + ) + c2 = JuMP.@constraint( + model, + zp1 - zx - zy >= 2.0 * (x_max * y + x * y_max - x_max * y_max), + ) + c3 = JuMP.@constraint( + model, + zp1 - zx - zy <= 2.0 * (x_max * y + x * y_min - x_max * y_min), + ) + c4 = JuMP.@constraint( + model, + zp1 - zx - zy <= 2.0 * (x_min * y + x * y_max - x_min * y_max), + ) + return (c1, c2, c3, c4) +end + +""" + build_reformulated_mccormick(model, x, y, zp1, zx, zy, x_bounds, y_bounds) + +Vectorized reformulated McCormick over the (name, t) grid. +""" +function build_reformulated_mccormick( + model::JuMP.Model, + x, + y, + zp1, + zx, + zy, + x_bounds::Vector{MinMax}, + y_bounds::Vector{MinMax}, +) + name_axis = axes(x, 1) + time_axis = axes(x, 2) + cons = JuMP.Containers.DenseAxisArray{Any}(undef, name_axis, 1:4, time_axis) + for (i, name) in enumerate(name_axis), t in time_axis + xb = x_bounds[i] + yb = y_bounds[i] + IS.@assert_op xb.max > xb.min + IS.@assert_op yb.max > yb.min + c1, c2, c3, c4 = build_reformulated_mccormick( + model, + x[name, t], + y[name, t], + zp1[name, t], + zx[name, t], + zy[name, t], + xb.min, + xb.max, + yb.min, + yb.max, + ) + cons[name, 1, t] = c1 + cons[name, 2, t] = c2 + cons[name, 3, t] = c3 + cons[name, 4, t] = c4 + end + return cons +end + +# --- IOM-side McCormick container registration --- + +""" + register_mccormick_envelope!(container, ::Type{C}, cons, meta) + +Register a McCormick constraint array (as returned by `build_mccormick_envelope`) +into the optimization container under `McCormickConstraint` with the given `meta`. +""" +function register_mccormick_envelope!( + container::OptimizationContainer, + ::Type{C}, + cons, + meta::String, +) where {C <: IS.InfrastructureSystemsComponent} + name_axis = axes(cons, 1) + time_axis = axes(cons, 3) + target = add_constraints_container!( + container, + McCormickConstraint, + C, + collect(name_axis), + 1:4, + time_axis; + sparse = true, + meta, + ) + for name in name_axis, k in 1:4, t in time_axis + c = cons[name, k, t] + c === nothing && continue + target[(name, k, t)] = c + end + return +end + +""" + register_reformulated_mccormick!(container, ::Type{C}, cons, meta) + +Register a reformulated McCormick constraint array (as returned by +`build_reformulated_mccormick`) into the optimization container under +`ReformulatedMcCormickConstraint`. +""" +function register_reformulated_mccormick!( + container::OptimizationContainer, + ::Type{C}, + cons, + meta::String, +) where {C <: IS.InfrastructureSystemsComponent} + name_axis = axes(cons, 1) + time_axis = axes(cons, 3) + target = add_constraints_container!( + container, + ReformulatedMcCormickConstraint, + C, + collect(name_axis), + 1:4, + time_axis; + sparse = true, + meta, + ) + for name in name_axis, k in 1:4, t in time_axis + target[(name, k, t)] = cons[name, k, t] + end + return +end diff --git a/src/approximations/nmdt_bilinear.jl b/src/approximations/nmdt_bilinear.jl new file mode 100644 index 00000000..13ca0435 --- /dev/null +++ b/src/approximations/nmdt_bilinear.jl @@ -0,0 +1,246 @@ +# NMDT bilinear approximations of z = x·y. +# NMDTBilinearConfig — discretize x only; y is just normalized. +# DNMDTBilinearConfig — discretize both x and y, combine two estimates. + +""" +Config for single-NMDT bilinear approximation (discretizes x only). + +# Fields +- `depth::Int`: number of binary discretization levels L for x. +""" +struct NMDTBilinearConfig <: BilinearApproxConfig + depth::Int +end + +""" +Config for double-NMDT bilinear approximation (discretizes both x and y). + +# Fields +- `depth::Int`: number of binary discretization levels L for both x and y. +""" +struct DNMDTBilinearConfig <: BilinearApproxConfig + depth::Int +end + +# --- NMDT (single discretization) --- + +""" +Pure-JuMP result of `build_bilinear_approx(::NMDTBilinearConfig, ...)`. +""" +struct NMDTBilinearResult{A, XD, YN, BX, DZ} <: BilinearApproxResult + approximation::A + x_discretization::XD + yh_expression::YN + bx_yh_product::BX + residual_product::DZ +end + +""" + build_bilinear_approx(config::NMDTBilinearConfig, model, x, y, x_bounds, y_bounds) + +Approximate x·y via NMDT: discretize x, normalize y to yh ∈ [0,1], build the +binary-continuous product β·yh and residual δ·yh, reassemble x·y from +normalized components. +""" +function build_bilinear_approx( + config::NMDTBilinearConfig, + model::JuMP.Model, + x, + y, + x_bounds::Vector{MinMax}, + y_bounds::Vector{MinMax}, +) + x_disc = build_discretization(model, x, x_bounds, config.depth) + yh_expr = build_normed_variable(model, y, y_bounds) + bx_yh = build_binary_continuous_product( + model, + x_disc.beta_var, + yh_expr, + 0.0, + 1.0, + config.depth, + ) + dz = build_residual_product( + model, + x_disc.delta_var, + yh_expr, + 1.0, + config.depth, + ) + approximation = build_assembled_product( + model, + [bx_yh.result_expression], + dz.z_var, + x_disc.norm_expr, + yh_expr, + x_bounds, + y_bounds, + ) + return NMDTBilinearResult(approximation, x_disc, yh_expr, bx_yh, dz) +end + +function register_in_container!( + container::OptimizationContainer, + ::Type{C}, + result::NMDTBilinearResult, + meta::String, +) where {C <: IS.InfrastructureSystemsComponent} + register_discretization!(container, C, result.x_discretization, meta * "_x") + + yh_target = add_expression_container!( + container, + NormedVariableExpression, + C, + collect(axes(result.yh_expression, 1)), + axes(result.yh_expression, 2); + meta = meta * "_y", + ) + for name in axes(result.yh_expression, 1), t in axes(result.yh_expression, 2) + yh_target[name, t] = result.yh_expression[name, t] + end + + register_binary_continuous_product!(container, C, result.bx_yh_product, meta) + register_residual_product!(container, C, result.residual_product, meta) + + name_axis = axes(result.approximation, 1) + time_axis = axes(result.approximation, 2) + result_target = add_expression_container!( + container, + BilinearProductExpression, + C, + collect(name_axis), + time_axis; + meta, + ) + for name in name_axis, t in time_axis + result_target[name, t] = result.approximation[name, t] + end + return +end + +# --- DNMDT (double discretization) --- + +""" +Pure-JuMP result of `build_bilinear_approx(::DNMDTBilinearConfig, ...)`. +""" +struct DNMDTBilinearResult{A, XD, YD, BXY, BYD, BYX, BXD, DZ} <: BilinearApproxResult + approximation::A + x_discretization::XD + y_discretization::YD + bx_yh_product::BXY + by_dx_product::BYD + by_xh_product::BYX + bx_dy_product::BXD + residual_product::DZ +end + +""" + build_bilinear_approx(config::DNMDTBilinearConfig, model, x, y, x_bounds, y_bounds) + +DNMDT bilinear approximation: discretize both x and y, form all four cross +binary-continuous products, and convexly combine two NMDT estimates. +""" +function build_bilinear_approx( + config::DNMDTBilinearConfig, + model::JuMP.Model, + x, + y, + x_bounds::Vector{MinMax}, + y_bounds::Vector{MinMax}, +) + x_disc = build_discretization(model, x, x_bounds, config.depth) + y_disc = build_discretization(model, y, y_bounds, config.depth) + bx_yh = build_binary_continuous_product( + model, + x_disc.beta_var, + y_disc.norm_expr, + 0.0, + 1.0, + config.depth, + ) + by_dx = build_binary_continuous_product( + model, + y_disc.beta_var, + x_disc.delta_var, + 0.0, + 2.0^(-config.depth), + config.depth, + ) + by_xh = build_binary_continuous_product( + model, + y_disc.beta_var, + x_disc.norm_expr, + 0.0, + 1.0, + config.depth, + ) + bx_dy = build_binary_continuous_product( + model, + x_disc.beta_var, + y_disc.delta_var, + 0.0, + 2.0^(-config.depth), + config.depth, + ) + dz = build_residual_product( + model, + x_disc.delta_var, + y_disc.delta_var, + 2.0^(-config.depth), + config.depth, + ) + approximation = build_assembled_dnmdt( + model, + bx_yh.result_expression, + by_dx.result_expression, + by_xh.result_expression, + bx_dy.result_expression, + dz.z_var, + x_disc, + y_disc, + x_bounds, + y_bounds, + ) + return DNMDTBilinearResult( + approximation, x_disc, y_disc, bx_yh, by_dx, by_xh, bx_dy, dz, + ) +end + +function register_in_container!( + container::OptimizationContainer, + ::Type{C}, + result::DNMDTBilinearResult, + meta::String, +) where {C <: IS.InfrastructureSystemsComponent} + register_discretization!(container, C, result.x_discretization, meta * "_x") + register_discretization!(container, C, result.y_discretization, meta * "_y") + + register_binary_continuous_product!( + container, C, result.bx_yh_product, meta * "_bx_yh", + ) + register_binary_continuous_product!( + container, C, result.by_dx_product, meta * "_by_dx", + ) + register_binary_continuous_product!( + container, C, result.by_xh_product, meta * "_by_xh", + ) + register_binary_continuous_product!( + container, C, result.bx_dy_product, meta * "_bx_dy", + ) + register_residual_product!(container, C, result.residual_product, meta) + + name_axis = axes(result.approximation, 1) + time_axis = axes(result.approximation, 2) + result_target = add_expression_container!( + container, + BilinearProductExpression, + C, + collect(name_axis), + time_axis; + meta, + ) + for name in name_axis, t in time_axis + result_target[name, t] = result.approximation[name, t] + end + return +end diff --git a/src/approximations/nmdt_discretization.jl b/src/approximations/nmdt_discretization.jl new file mode 100644 index 00000000..6d915361 --- /dev/null +++ b/src/approximations/nmdt_discretization.jl @@ -0,0 +1,483 @@ +# Shared NMDT machinery used by both nmdt_quadratic and nmdt_bilinear. +# +# NMDT (Normalized Multiparametric Disaggregation Technique) discretizes a +# normalized variable xh ∈ [0,1] as xh = Σᵢ 2^{−i}·β_i + δ, with β_i ∈ {0,1} +# binary digits and δ ∈ [0, 2^{−L}] a residual. The discretization is then +# combined with another normalized variable (for bilinear products) or with +# itself (for quadratic) via McCormick-linearized binary-continuous products. +# +# This file provides: +# - The container key types +# - The NMDTDiscretization struct (intermediate scaffolding type) +# - Pure-JuMP build helpers for each NMDT building block +# - Per-piece register_* helpers used by the top-level method's +# register_in_container! implementation + +# --- Container key types --- + +"Binary discretization variables β_i ∈ {0,1} in the NMDT decomposition of xh." +struct NMDTBinaryVariable <: VariableType end +"Residual variable δ ∈ [0, 2^{−L}] capturing the NMDT discretization error." +struct NMDTResidualVariable <: VariableType end +"McCormick linearization variables u_i ≈ β_i·y in NMDT binary-continuous products." +struct NMDTBinaryContinuousProductVariable <: VariableType end +"Variable z ≈ δ · y linearizing the residual-continuous product in NMDT." +struct NMDTResidualProductVariable <: VariableType end + +"Expression container for the NMDT binary discretization: Σ 2^{−i}·β_i + δ ≈ xh." +struct NMDTDiscretizationExpression <: ExpressionType end +"Expression container for the NMDT binary-continuous product: Σ 2^{−i}·u_i ≈ β·y." +struct NMDTBinaryContinuousProductExpression <: ExpressionType end + +"Constraint enforcing xh = Σ 2^{−i}·β_i + δ in the NMDT discretization." +struct NMDTEDiscretizationConstraint <: ConstraintType end +"McCormick envelope constraints for binary-continuous products u_i ≈ β_i·y in NMDT." +struct NMDTBinaryContinuousProductConstraint <: ConstraintType end +"Epigraph lower-bound tightening constraint on the NMDT quadratic result." +struct NMDTTightenConstraint <: ConstraintType end + +# --- NMDTDiscretization struct --- + +""" +NMDT discretization scaffolding for a single normalized variable xh ∈ [0,1]. + +Holds the affine expression for the normalized variable, the binary digit +variables β_i (one per level of depth), and the residual δ. Constructed by +`build_discretization` and consumed by `build_binary_continuous_product`, +`build_residual_product`, and the NMDT assembly helpers. +""" +struct NMDTDiscretization{NE, BV, DV, DC, DE} + norm_expr::NE + beta_var::BV + delta_var::DV + disc_constraints::DC + disc_expression::DE +end + +# --- Pure-JuMP build helpers --- + +""" + build_discretization(model, x, bounds, depth) -> NMDTDiscretization + +Build the NMDT binary discretization of the normalized variable +xh = (x − x_min)/(x_max − x_min). Creates `depth` binary variables β_i and +one residual δ_h ∈ [0, 2^{−depth}], enforcing xh = Σ 2^{−i}·β_i + δ_h. +""" +function build_discretization( + model::JuMP.Model, + x, + bounds::Vector{MinMax}, + depth::Int, +) + IS.@assert_op depth >= 1 + name_axis = axes(x, 1) + time_axis = axes(x, 2) + IS.@assert_op length(name_axis) == length(bounds) + + norm_expr = build_normed_variable(model, x, bounds) + beta_var = JuMP.@variable( + model, + [name = name_axis, i = 1:depth, t = time_axis], + binary = true, + base_name = "NMDTBinary", + ) + delta_var = JuMP.@variable( + model, + [name = name_axis, t = time_axis], + lower_bound = 0.0, + upper_bound = 2.0^(-depth), + base_name = "NMDTResidual", + ) + disc_expr = JuMP.@expression( + model, + [name = name_axis, t = time_axis], + sum(2.0^(-i) * beta_var[name, i, t] for i in 1:depth) + delta_var[name, t] + ) + disc_cons = JuMP.@constraint( + model, + [name = name_axis, t = time_axis], + norm_expr[name, t] == disc_expr[name, t], + ) + return NMDTDiscretization(norm_expr, beta_var, delta_var, disc_cons, disc_expr) +end + +""" +Result of a single NMDT binary-continuous product step β_i·y ≈ u_i, weighted +sum into Σ 2^{−i}·u_i. Returned by `build_binary_continuous_product`. +""" +struct NMDTBinaryContinuousProduct{UV, MC, RE} + u_var::UV + mccormick_constraints::MC + result_expression::RE +end + +""" + build_binary_continuous_product(model, beta_var, cont_var, cont_min, cont_max, depth; tighten=false) + +Build the depth-level binary-continuous product Σᵢ 2^{−i}·u_i ≈ β·y. + +For each (name, i, t), creates an auxiliary u_i with bounds [cont_min, cont_max] +and adds the four McCormick envelope inequalities on (β_i, y, u_i). If +`tighten = true`, the lower-bound McCormick constraints are omitted (the caller +applies a tighter bound elsewhere). +""" +function build_binary_continuous_product( + model::JuMP.Model, + beta_var, + cont_var, + cont_min::Float64, + cont_max::Float64, + depth::Int; + tighten::Bool = false, +) + name_axis = axes(beta_var, 1) + time_axis = axes(beta_var, 3) + u_var = JuMP.@variable( + model, + [name = name_axis, i = 1:depth, t = time_axis], + lower_bound = cont_min, + upper_bound = cont_max, + base_name = "NMDTBinContProd", + ) + mc_cons = JuMP.Containers.DenseAxisArray{Any}( + undef, name_axis, 1:depth, 1:4, time_axis, + ) + for name in name_axis, i in 1:depth, t in time_axis + c1, c2, c3, c4 = build_mccormick_envelope( + model, + cont_var[name, t], + beta_var[name, i, t], + u_var[name, i, t], + cont_min, + cont_max, + 0.0, + 1.0; + lower_bounds = !tighten, + ) + mc_cons[name, i, 1, t] = c1 + mc_cons[name, i, 2, t] = c2 + mc_cons[name, i, 3, t] = c3 + mc_cons[name, i, 4, t] = c4 + end + result_expr = JuMP.@expression( + model, + [name = name_axis, t = time_axis], + sum(2.0^(-i) * u_var[name, i, t] for i in 1:depth) + ) + return NMDTBinaryContinuousProduct(u_var, mc_cons, result_expr) +end + +""" +Result of the residual-continuous product step z ≈ δ·y. Returned by +`build_residual_product`. +""" +struct NMDTResidualProduct{ZV, MC} + z_var::ZV + mccormick_constraints::MC +end + +""" + build_residual_product(model, delta_var, cont_var, cont_max, depth; tighten=false) + +Build a single auxiliary z ≈ δ·y with McCormick envelopes on +(δ ∈ [0, 2^{−depth}], y ∈ [0, cont_max]). Lower bounds on the McCormick +envelope are omitted when `tighten = true`. +""" +function build_residual_product( + model::JuMP.Model, + delta_var, + cont_var, + cont_max::Float64, + depth::Int; + tighten::Bool = false, +) + name_axis = axes(delta_var, 1) + time_axis = axes(delta_var, 2) + delta_max = 2.0^(-depth) + z_var = JuMP.@variable( + model, + [name = name_axis, t = time_axis], + lower_bound = 0.0, + upper_bound = delta_max * cont_max, + base_name = "NMDTResidualProduct", + ) + mc_cons = JuMP.Containers.DenseAxisArray{Any}(undef, name_axis, 1:4, time_axis) + for name in name_axis, t in time_axis + c1, c2, c3, c4 = build_mccormick_envelope( + model, + delta_var[name, t], + cont_var[name, t], + z_var[name, t], + 0.0, + delta_max, + 0.0, + cont_max; + lower_bounds = !tighten, + ) + mc_cons[name, 1, t] = c1 + mc_cons[name, 2, t] = c2 + mc_cons[name, 3, t] = c3 + mc_cons[name, 4, t] = c4 + end + return NMDTResidualProduct(z_var, mc_cons) +end + +""" + build_assembled_product(model, terms, dz, xh_expr, yh_expr, x_bounds, y_bounds) + +Affine reassembly of the bilinear product x·y from normalized NMDT pieces. + +For each (name, t): + x·y = lx·ly·zh + lx·y_min·xh + ly·x_min·yh + x_min·y_min +where `zh = sum(term[name, t] for term in terms) + dz[name, t]`. +""" +function build_assembled_product( + model::JuMP.Model, + terms, + dz, + xh_expr, + yh_expr, + x_bounds::Vector{MinMax}, + y_bounds::Vector{MinMax}, +) + name_axis = axes(xh_expr, 1) + time_axis = axes(xh_expr, 2) + IS.@assert_op length(name_axis) == length(x_bounds) + IS.@assert_op length(name_axis) == length(y_bounds) + lx = JuMP.Containers.DenseAxisArray( + [b.max - b.min for b in x_bounds], + name_axis, + ) + ly = JuMP.Containers.DenseAxisArray( + [b.max - b.min for b in y_bounds], + name_axis, + ) + x_min = JuMP.Containers.DenseAxisArray( + [b.min for b in x_bounds], + name_axis, + ) + y_min = JuMP.Containers.DenseAxisArray( + [b.min for b in y_bounds], + name_axis, + ) + return JuMP.@expression( + model, + [name = name_axis, t = time_axis], + lx[name] * ly[name] * + (sum(term[name, t] for term in terms) + dz[name, t]) + + lx[name] * y_min[name] * xh_expr[name, t] + + ly[name] * x_min[name] * yh_expr[name, t] + + x_min[name] * y_min[name] + ) +end + +""" + build_assembled_dnmdt(model, bx_yh, by_dx, by_xh, bx_dy, dz, x_disc, y_disc, x_bounds, y_bounds; lambda) + +Convex combination of two NMDT estimates of x·y. Returns the result expression. + +`z1` is the (x discretizes, y normalized) estimate and `z2` is the (y discretizes, +x normalized) estimate. The result is `λ·z1 + (1−λ)·z2`. The shared residual +product `dz ≈ δ_x · δ_y` is supplied by the caller. +""" +function build_assembled_dnmdt( + model::JuMP.Model, + bx_yh, + by_dx, + by_xh, + bx_dy, + dz, + x_disc::NMDTDiscretization, + y_disc::NMDTDiscretization, + x_bounds::Vector{MinMax}, + y_bounds::Vector{MinMax}; + lambda::Float64 = DNMDT_LAMBDA, +) + z1 = build_assembled_product( + model, [bx_yh, by_dx], dz, + x_disc.norm_expr, y_disc.norm_expr, x_bounds, y_bounds, + ) + z2 = build_assembled_product( + model, [by_xh, bx_dy], dz, + y_disc.norm_expr, x_disc.norm_expr, y_bounds, x_bounds, + ) + name_axis = axes(z1, 1) + time_axis = axes(z1, 2) + return JuMP.@expression( + model, + [name = name_axis, t = time_axis], + lambda * z1[name, t] + (1.0 - lambda) * z2[name, t] + ) +end + +# --- IOM-side register helpers (called from method-specific register_in_container!) --- + +""" + register_discretization!(container, ::Type{C}, disc::NMDTDiscretization, meta) + +Register the discretization variables, residual, expression, and constraint +into the optimization container under their respective key types. +""" +function register_discretization!( + container::OptimizationContainer, + ::Type{C}, + disc::NMDTDiscretization, + meta::String, +) where {C <: IS.InfrastructureSystemsComponent} + name_axis = axes(disc.beta_var, 1) + depth_axis = axes(disc.beta_var, 2) + time_axis = axes(disc.beta_var, 3) + + norm_target = add_expression_container!( + container, + NormedVariableExpression, + C, + collect(name_axis), + time_axis; + meta, + ) + for name in name_axis, t in time_axis + norm_target[name, t] = disc.norm_expr[name, t] + end + + beta_target = add_variable_container!( + container, + NMDTBinaryVariable, + C, + collect(name_axis), + depth_axis, + time_axis; + meta, + ) + for name in name_axis, i in depth_axis, t in time_axis + beta_target[name, i, t] = disc.beta_var[name, i, t] + end + + delta_target = add_variable_container!( + container, + NMDTResidualVariable, + C, + collect(name_axis), + time_axis; + meta, + ) + for name in name_axis, t in time_axis + delta_target[name, t] = disc.delta_var[name, t] + end + + disc_expr_target = add_expression_container!( + container, + NMDTDiscretizationExpression, + C, + collect(name_axis), + time_axis; + meta, + ) + for name in name_axis, t in time_axis + disc_expr_target[name, t] = disc.disc_expression[name, t] + end + + disc_cons_target = add_constraints_container!( + container, + NMDTEDiscretizationConstraint, + C, + collect(name_axis), + time_axis; + meta, + ) + for name in name_axis, t in time_axis + disc_cons_target[name, t] = disc.disc_constraints[name, t] + end + return +end + +""" + register_binary_continuous_product!(container, ::Type{C}, product, meta) + +Register the auxiliary u variables, McCormick constraints, and weighted-sum +expression of an NMDT binary-continuous product step. +""" +function register_binary_continuous_product!( + container::OptimizationContainer, + ::Type{C}, + product::NMDTBinaryContinuousProduct, + meta::String, +) where {C <: IS.InfrastructureSystemsComponent} + name_axis = axes(product.u_var, 1) + depth_axis = axes(product.u_var, 2) + time_axis = axes(product.u_var, 3) + + u_target = add_variable_container!( + container, + NMDTBinaryContinuousProductVariable, + C, + collect(name_axis), + depth_axis, + time_axis; + meta, + ) + for name in name_axis, i in depth_axis, t in time_axis + u_target[name, i, t] = product.u_var[name, i, t] + end + + cons_target = add_constraints_container!( + container, + NMDTBinaryContinuousProductConstraint, + C, + collect(name_axis), + depth_axis, + 1:4, + time_axis; + meta, + ) + for name in name_axis, i in depth_axis, k in 1:4, t in time_axis + c = product.mccormick_constraints[name, i, k, t] + c === nothing && continue + cons_target[name, i, k, t] = c + end + + expr_target = add_expression_container!( + container, + NMDTBinaryContinuousProductExpression, + C, + collect(name_axis), + time_axis; + meta, + ) + for name in name_axis, t in time_axis + expr_target[name, t] = product.result_expression[name, t] + end + return +end + +""" + register_residual_product!(container, ::Type{C}, product, meta) + +Register the residual product z variable and its McCormick constraints. +""" +function register_residual_product!( + container::OptimizationContainer, + ::Type{C}, + product::NMDTResidualProduct, + meta::String, +) where {C <: IS.InfrastructureSystemsComponent} + name_axis = axes(product.z_var, 1) + time_axis = axes(product.z_var, 2) + + z_target = add_variable_container!( + container, + NMDTResidualProductVariable, + C, + collect(name_axis), + time_axis; + meta, + ) + for name in name_axis, t in time_axis + z_target[name, t] = product.z_var[name, t] + end + + register_mccormick_envelope!( + container, C, product.mccormick_constraints, meta, + ) + return +end diff --git a/src/approximations/nmdt_quadratic.jl b/src/approximations/nmdt_quadratic.jl new file mode 100644 index 00000000..da7ed72d --- /dev/null +++ b/src/approximations/nmdt_quadratic.jl @@ -0,0 +1,301 @@ +# NMDT (Normalized Multiparametric Disaggregation Technique) approximations +# for x². Two variants: +# NMDTQuadConfig — single discretization on x. +# DNMDTQuadConfig — double NMDT: convex combination of (x discretized) and +# (x discretized) again, with shared β·δ residual. +# +# Both normalize x to xh ∈ [0,1], discretize via L binary digits + residual, +# linearize the binary-continuous products with McCormick envelopes, and +# reassemble x² from the normalized components. Optional epigraph Q^{L1} +# lower-bound tightening on xh². +# Reference: Teles, Castro, Matos (2013), Multiparametric disaggregation +# technique for global optimization of polynomial programming problems. + +""" +Config for single-NMDT quadratic approximation. + +# Fields +- `depth::Int`: number of binary discretization levels L. +- `epigraph_depth::Int`: LP tightening depth via epigraph Q^{L1} lower bound; + 0 to disable (default 3·depth). +""" +struct NMDTQuadConfig <: QuadraticApproxConfig + depth::Int + epigraph_depth::Int +end +function NMDTQuadConfig(depth::Int) + return NMDTQuadConfig(depth, 3 * depth) +end + +""" +Config for double-NMDT quadratic approximation. + +# Fields +- `depth::Int`: number of binary discretization levels L. +- `epigraph_depth::Int`: LP tightening depth via epigraph Q^{L1} lower bound; + 0 to disable (default 3·depth). +""" +struct DNMDTQuadConfig <: QuadraticApproxConfig + depth::Int + epigraph_depth::Int +end +function DNMDTQuadConfig(depth::Int) + return DNMDTQuadConfig(depth, 3 * depth) +end + +# --- Shared epigraph tightening helper --- + +""" +Result of an epigraph tightening step on an NMDT quadratic approximation. +""" +struct NMDTEpigraphTightening{EPI, CONS} + epigraph::EPI + constraints::CONS +end + +function _build_nmdt_tightening( + model::JuMP.Model, + approximation, + x_disc::NMDTDiscretization, + epigraph_depth::Int, +) + name_axis = axes(approximation, 1) + time_axis = axes(approximation, 2) + fake_bounds = fill(MinMax((min = 0.0, max = 1.0)), length(name_axis)) + epi = build_quadratic_approx( + EpigraphQuadConfig(epigraph_depth), + model, + x_disc.norm_expr, + fake_bounds, + ) + cons = JuMP.@constraint( + model, + [name = name_axis, t = time_axis], + approximation[name, t] >= epi.approximation[name, t] + ) + return NMDTEpigraphTightening(epi, cons) +end + +function _register_tightening!( + container::OptimizationContainer, + ::Type{C}, + t::NMDTEpigraphTightening, + meta::String, +) where {C <: IS.InfrastructureSystemsComponent} + register_in_container!(container, C, t.epigraph, meta * "_epi") + name_axis = axes(t.constraints, 1) + time_axis = axes(t.constraints, 2) + target = add_constraints_container!( + container, + NMDTTightenConstraint, + C, + collect(name_axis), + time_axis; + meta, + ) + for name in name_axis, t_idx in time_axis + target[name, t_idx] = t.constraints[name, t_idx] + end + return +end + +# --- NMDT (single) --- + +""" +Pure-JuMP result of `build_quadratic_approx(::NMDTQuadConfig, ...)`. +""" +struct NMDTQuadResult{A, D, BX, DZ, T} <: QuadraticApproxResult + approximation::A + discretization::D + bx_xh_product::BX + residual_product::DZ + tightening::T # Union{Nothing, NMDTEpigraphTightening} +end + +""" + build_quadratic_approx(config::NMDTQuadConfig, model, x, bounds) + +Approximate x² using single NMDT: discretize xh, build the binary-continuous +product Σ 2^{−i}·u_i ≈ β·xh and the residual product z ≈ δ·xh, then +reassemble x² from these normalized components. +""" +function build_quadratic_approx( + config::NMDTQuadConfig, + model::JuMP.Model, + x, + bounds::Vector{MinMax}, +) + tighten = config.epigraph_depth > 0 + x_disc = build_discretization(model, x, bounds, config.depth) + bx_xh = build_binary_continuous_product( + model, + x_disc.beta_var, + x_disc.norm_expr, + 0.0, + 1.0, + config.depth; + tighten, + ) + dz = build_residual_product( + model, + x_disc.delta_var, + x_disc.norm_expr, + 1.0, + config.depth; + tighten, + ) + approximation = build_assembled_product( + model, + [bx_xh.result_expression], + dz.z_var, + x_disc.norm_expr, + x_disc.norm_expr, + bounds, + bounds, + ) + tightening = if tighten + _build_nmdt_tightening(model, approximation, x_disc, config.epigraph_depth) + else + nothing + end + return NMDTQuadResult(approximation, x_disc, bx_xh, dz, tightening) +end + +function register_in_container!( + container::OptimizationContainer, + ::Type{C}, + result::NMDTQuadResult, + meta::String, +) where {C <: IS.InfrastructureSystemsComponent} + register_discretization!(container, C, result.discretization, meta) + register_binary_continuous_product!(container, C, result.bx_xh_product, meta) + register_residual_product!(container, C, result.residual_product, meta) + + name_axis = axes(result.approximation, 1) + time_axis = axes(result.approximation, 2) + result_target = add_expression_container!( + container, + QuadraticExpression, + C, + collect(name_axis), + time_axis; + meta, + ) + for name in name_axis, t in time_axis + result_target[name, t] = result.approximation[name, t] + end + + if result.tightening !== nothing + _register_tightening!(container, C, result.tightening, meta) + end + return +end + +# --- DNMDT --- + +""" +Pure-JuMP result of `build_quadratic_approx(::DNMDTQuadConfig, ...)`. +""" +struct DNMDTQuadResult{A, D, BX_XH, BX_DX, DZ, T} <: QuadraticApproxResult + approximation::A + discretization::D + bx_xh_product::BX_XH + bx_dx_product::BX_DX + residual_product::DZ + tightening::T +end + +""" + build_quadratic_approx(config::DNMDTQuadConfig, model, x, bounds) + +Approximate x² using double NMDT: combine two NMDT estimates with shared +discretization on x, plus the residual product δ_x·δ_x. +""" +function build_quadratic_approx( + config::DNMDTQuadConfig, + model::JuMP.Model, + x, + bounds::Vector{MinMax}, +) + tighten = config.epigraph_depth > 0 + x_disc = build_discretization(model, x, bounds, config.depth) + bx_xh = build_binary_continuous_product( + model, + x_disc.beta_var, + x_disc.norm_expr, + 0.0, + 1.0, + config.depth; + tighten, + ) + bx_dx = build_binary_continuous_product( + model, + x_disc.beta_var, + x_disc.delta_var, + 0.0, + 2.0^(-config.depth), + config.depth; + tighten, + ) + dz = build_residual_product( + model, + x_disc.delta_var, + x_disc.delta_var, + 2.0^(-config.depth), + config.depth; + tighten, + ) + approximation = build_assembled_dnmdt( + model, + bx_xh.result_expression, + bx_dx.result_expression, + bx_xh.result_expression, + bx_dx.result_expression, + dz.z_var, + x_disc, + x_disc, + bounds, + bounds, + ) + tightening = if tighten + _build_nmdt_tightening(model, approximation, x_disc, config.epigraph_depth) + else + nothing + end + return DNMDTQuadResult(approximation, x_disc, bx_xh, bx_dx, dz, tightening) +end + +function register_in_container!( + container::OptimizationContainer, + ::Type{C}, + result::DNMDTQuadResult, + meta::String, +) where {C <: IS.InfrastructureSystemsComponent} + register_discretization!(container, C, result.discretization, meta) + register_binary_continuous_product!( + container, C, result.bx_xh_product, meta * "_bx_xh", + ) + register_binary_continuous_product!( + container, C, result.bx_dx_product, meta * "_bx_dx", + ) + register_residual_product!(container, C, result.residual_product, meta) + + name_axis = axes(result.approximation, 1) + time_axis = axes(result.approximation, 2) + result_target = add_expression_container!( + container, + QuadraticExpression, + C, + collect(name_axis), + time_axis; + meta, + ) + for name in name_axis, t in time_axis + result_target[name, t] = result.approximation[name, t] + end + + if result.tightening !== nothing + _register_tightening!(container, C, result.tightening, meta) + end + return +end diff --git a/src/approximations/no_approx_bilinear.jl b/src/approximations/no_approx_bilinear.jl new file mode 100644 index 00000000..9016cf57 --- /dev/null +++ b/src/approximations/no_approx_bilinear.jl @@ -0,0 +1,56 @@ +# No-op bilinear approximation: returns the exact x·y as a JuMP.QuadExpr. +# For NLP-capable solvers or testing. + +"No-op config for bilinear approximation: returns exact x·y as a QuadExpr." +struct NoBilinearApproxConfig <: BilinearApproxConfig end + +"Pure-JuMP result of the no-op bilinear approximation." +struct NoBilinearApproxResult{A} <: BilinearApproxResult + approximation::A +end + +""" + build_bilinear_approx(::NoBilinearApproxConfig, model, x, y, x_bounds, y_bounds) + +Build the exact x·y product. Bounds are accepted for signature parity and unused. +""" +function build_bilinear_approx( + ::NoBilinearApproxConfig, + model::JuMP.Model, + x, + y, + x_bounds::Vector{MinMax}, + y_bounds::Vector{MinMax}, +) + name_axis = axes(x, 1) + time_axis = axes(x, 2) + approximation = JuMP.@expression( + model, + [name = name_axis, t = time_axis], + x[name, t] * y[name, t] + ) + return NoBilinearApproxResult(approximation) +end + +function register_in_container!( + container::OptimizationContainer, + ::Type{C}, + result::NoBilinearApproxResult, + meta::String, +) where {C <: IS.InfrastructureSystemsComponent} + name_axis = axes(result.approximation, 1) + time_axis = axes(result.approximation, 2) + target = add_expression_container!( + container, + BilinearProductExpression, + C, + collect(name_axis), + time_axis; + meta, + expr_type = JuMP.QuadExpr, + ) + for name in name_axis, t in time_axis + target[name, t] = result.approximation[name, t] + end + return +end diff --git a/src/approximations/no_approx_quadratic.jl b/src/approximations/no_approx_quadratic.jl new file mode 100644 index 00000000..4cb2a531 --- /dev/null +++ b/src/approximations/no_approx_quadratic.jl @@ -0,0 +1,55 @@ +# No-op quadratic approximation: returns the exact x² as a JuMP.QuadExpr. +# For NLP-capable solvers or testing. + +"No-op config for quadratic approximation: returns exact x² as a QuadExpr." +struct NoQuadApproxConfig <: QuadraticApproxConfig end + +"Pure-JuMP result of the no-op quadratic approximation." +struct NoQuadApproxResult{A} <: QuadraticApproxResult + approximation::A +end + +""" + build_quadratic_approx(::NoQuadApproxConfig, model, x, bounds) + +Build the exact x² expression for each (name, t) and wrap in a result struct. +`bounds` is accepted for signature parity but is unused. +""" +function build_quadratic_approx( + ::NoQuadApproxConfig, + model::JuMP.Model, + x, + bounds::Vector{MinMax}, +) + name_axis = axes(x, 1) + time_axis = axes(x, 2) + approximation = JuMP.@expression( + model, + [name = name_axis, t = time_axis], + x[name, t] * x[name, t] + ) + return NoQuadApproxResult(approximation) +end + +function register_in_container!( + container::OptimizationContainer, + ::Type{C}, + result::NoQuadApproxResult, + meta::String, +) where {C <: IS.InfrastructureSystemsComponent} + name_axis = axes(result.approximation, 1) + time_axis = axes(result.approximation, 2) + target = add_expression_container!( + container, + QuadraticExpression, + C, + collect(name_axis), + time_axis; + meta, + expr_type = JuMP.QuadExpr, + ) + for name in name_axis, t in time_axis + target[name, t] = result.approximation[name, t] + end + return +end diff --git a/src/approximations/pwl_utils.jl b/src/approximations/pwl_utils.jl new file mode 100644 index 00000000..03b8ea6f --- /dev/null +++ b/src/approximations/pwl_utils.jl @@ -0,0 +1,30 @@ +# Pure helpers for piecewise linear approximation breakpoint generation. + +""" + _get_breakpoints_for_pwl_function(min_val, max_val, f; num_segments = DEFAULT_INTERPOLATION_LENGTH) + +Generate `num_segments + 1` equally-spaced breakpoints over `[min_val, max_val]` +and evaluate `f` at each. Returns `(x_bkpts, y_bkpts)`. +""" +function _get_breakpoints_for_pwl_function( + min_val::Float64, + max_val::Float64, + f; + num_segments = DEFAULT_INTERPOLATION_LENGTH, +) + num_bkpts = num_segments + 1 + step = (max_val - min_val) / num_segments + x_bkpts = Vector{Float64}(undef, num_bkpts) + y_bkpts = Vector{Float64}(undef, num_bkpts) + x_bkpts[1] = min_val + y_bkpts[1] = f(min_val) + for i in 1:num_segments + x = min_val + step * i + x_bkpts[i + 1] = x + y_bkpts[i + 1] = f(x) + end + return x_bkpts, y_bkpts +end + +"Returns x² (used as the default function for PWL breakpoint generation)." +_square(x::Float64) = x * x diff --git a/src/approximations/pwmcc_cuts.jl b/src/approximations/pwmcc_cuts.jl new file mode 100644 index 00000000..625d11b7 --- /dev/null +++ b/src/approximations/pwmcc_cuts.jl @@ -0,0 +1,287 @@ +# Piecewise McCormick (PWMCC) cuts for concave terms in PWL approximations of x². +# Adds K local chord upper bounds on the v² PWL approximation by partitioning +# v's domain into K sub-intervals. The LP gap shrinks from Delta²/4 to +# Delta²/(4·K²). These cuts supplement (do not replace) the underlying PWL +# (SOS2 or manual-SOS2) constraints. +# +# Shared between solver_sos2.jl and manual_sos2.jl — both reference these +# container keys and the build/register helpers below. + +# --- Container key types --- + +"Binary interval selector for piecewise McCormick cuts." +struct PiecewiseMcCormickBinary <: SparseVariableType end + +"Disaggregated variable for piecewise McCormick cuts." +struct PiecewiseMcCormickDisaggregated <: SparseVariableType end + +"Selector sum constraint: sum_k delta_k = 1." +struct PiecewiseMcCormickSelectorSum <: ConstraintType end + +"Disaggregation linking constraint: v = sum_k v^d_k." +struct PiecewiseMcCormickLinking <: ConstraintType end + +"Interval activation lower bound: t_{k-1} * delta_k <= v^d_k." +struct PiecewiseMcCormickIntervalLB <: ConstraintType end + +"Interval activation upper bound: v^d_k <= t_k * delta_k." +struct PiecewiseMcCormickIntervalUB <: ConstraintType end + +"Piecewise McCormick chord upper-bound constraint on v² approximation." +struct PiecewiseMcCormickChordUB <: ConstraintType end + +"Piecewise McCormick tangent lower-bound constraint (left endpoint)." +struct PiecewiseMcCormickTangentLBL <: ConstraintType end + +"Piecewise McCormick tangent lower-bound constraint (right endpoint)." +struct PiecewiseMcCormickTangentLBR <: ConstraintType end + +# --- Result struct --- + +""" +Pure-JuMP result of `build_pwmcc_concave_cuts`. All fields are JuMP container +arrays indexed by (name, k, t) for the K-segment pieces or (name, t) for the +once-per-element constraints. +""" +struct PWMCCResult{DV, VDV, SC, LC, ILBC, IUBC, CUBC, TLLC, TLRC} + delta_var::DV + vd_var::VDV + selector_constraints::SC + linking_constraints::LC + interval_lb_constraints::ILBC + interval_ub_constraints::IUBC + chord_ub_constraints::CUBC + tangent_lb_l_constraints::TLLC + tangent_lb_r_constraints::TLRC +end + +# --- Pure-JuMP build --- + +""" + build_pwmcc_concave_cuts(model, v_var, q_expr, bounds, K) -> PWMCCResult + +Build piecewise McCormick cuts on the concave term (−v²) to tighten the LP +relaxation of a PWL approximation `q_expr ≈ v²`. Partitions each name's +[v_min, v_max] into K uniform sub-intervals. + +# Arguments +- `model::JuMP.Model`: JuMP model. +- `v_var`: 2D container of the original variable v, indexed by (name, t). +- `q_expr`: 2D container of the existing PWL approximation expressions for v². +- `bounds`: per-name (v_min, v_max). +- `K::Int`: number of sub-intervals (K = 1 is degenerate; K ≥ 2 useful). +""" +function build_pwmcc_concave_cuts( + model::JuMP.Model, + v_var, + q_expr, + bounds::Vector{MinMax}, + K::Int, +) + IS.@assert_op K >= 1 + name_axis = axes(v_var, 1) + time_axis = axes(v_var, 2) + IS.@assert_op length(name_axis) == length(bounds) + + # Per-name breakpoint coefficients + v_min_arr = JuMP.Containers.DenseAxisArray([b.min for b in bounds], name_axis) + v_max_arr = JuMP.Containers.DenseAxisArray([b.max for b in bounds], name_axis) + brk = JuMP.Containers.DenseAxisArray{Float64}(undef, name_axis, 0:K) + for (i, name) in enumerate(name_axis) + bmin = bounds[i].min + bmax = bounds[i].max + IS.@assert_op bmin < bmax + for k in 0:K + brk[name, k] = bmin + k * (bmax - bmin) / K + end + end + + delta_var = JuMP.@variable( + model, + [name = name_axis, k = 1:K, t = time_axis], + binary = true, + base_name = "PwMcCBin", + ) + vd_var = JuMP.@variable( + model, + [name = name_axis, k = 1:K, t = time_axis], + base_name = "PwMcCDis", + ) + + selector_cons = JuMP.@constraint( + model, + [name = name_axis, t = time_axis], + sum(delta_var[name, k, t] for k in 1:K) == 1.0 + ) + linking_cons = JuMP.@constraint( + model, + [name = name_axis, t = time_axis], + sum(vd_var[name, k, t] for k in 1:K) == v_var[name, t] + ) + interval_lb = JuMP.@constraint( + model, + [name = name_axis, k = 1:K, t = time_axis], + brk[name, k - 1] * delta_var[name, k, t] <= vd_var[name, k, t] + ) + interval_ub = JuMP.@constraint( + model, + [name = name_axis, k = 1:K, t = time_axis], + vd_var[name, k, t] <= brk[name, k] * delta_var[name, k, t] + ) + # Chord upper bound: q ≤ Σ_k (brk[k-1]+brk[k]) * vd_k − brk[k-1]*brk[k] * δ_k + chord_ub = JuMP.@constraint( + model, + [name = name_axis, t = time_axis], + q_expr[name, t] <= sum( + (brk[name, k - 1] + brk[name, k]) * vd_var[name, k, t] - + brk[name, k - 1] * brk[name, k] * delta_var[name, k, t] for k in 1:K + ) + ) + # Tangent lower bound at left endpoint: q ≥ Σ_k 2·brk[k-1]*vd_k − brk[k-1]²·δ_k + tangent_lb_l = JuMP.@constraint( + model, + [name = name_axis, t = time_axis], + q_expr[name, t] >= sum( + 2.0 * brk[name, k - 1] * vd_var[name, k, t] - + brk[name, k - 1]^2 * delta_var[name, k, t] for k in 1:K + ) + ) + # Tangent lower bound at right endpoint: q ≥ Σ_k 2·brk[k]*vd_k − brk[k]²·δ_k + tangent_lb_r = JuMP.@constraint( + model, + [name = name_axis, t = time_axis], + q_expr[name, t] >= sum( + 2.0 * brk[name, k] * vd_var[name, k, t] - + brk[name, k]^2 * delta_var[name, k, t] for k in 1:K + ) + ) + + return PWMCCResult( + delta_var, + vd_var, + selector_cons, + linking_cons, + interval_lb, + interval_ub, + chord_ub, + tangent_lb_l, + tangent_lb_r, + ) +end + +# --- IOM-side register helper --- + +""" + register_pwmcc!(container, ::Type{C}, pwmcc::PWMCCResult, meta) + +Register all PWMCC variables and constraints in the optimization container +under the corresponding key types, suffixed by `meta`. +""" +function register_pwmcc!( + container::OptimizationContainer, + ::Type{C}, + pwmcc::PWMCCResult, + meta::String, +) where {C <: IS.InfrastructureSystemsComponent} + name_axis = axes(pwmcc.delta_var, 1) + k_axis = axes(pwmcc.delta_var, 2) + time_axis = axes(pwmcc.delta_var, 3) + + delta_target = add_variable_container!( + container, + PiecewiseMcCormickBinary, + C, + collect(name_axis), + k_axis, + time_axis; + meta, + ) + for name in name_axis, k in k_axis, t in time_axis + delta_target[name, k, t] = pwmcc.delta_var[name, k, t] + end + + vd_target = add_variable_container!( + container, + PiecewiseMcCormickDisaggregated, + C, + collect(name_axis), + k_axis, + time_axis; + meta, + ) + for name in name_axis, k in k_axis, t in time_axis + vd_target[name, k, t] = pwmcc.vd_var[name, k, t] + end + + selector_target = add_constraints_container!( + container, + PiecewiseMcCormickSelectorSum, + C, + collect(name_axis), + time_axis; + meta, + ) + linking_target = add_constraints_container!( + container, + PiecewiseMcCormickLinking, + C, + collect(name_axis), + time_axis; + meta, + ) + chord_target = add_constraints_container!( + container, + PiecewiseMcCormickChordUB, + C, + collect(name_axis), + time_axis; + meta, + ) + tangent_l_target = add_constraints_container!( + container, + PiecewiseMcCormickTangentLBL, + C, + collect(name_axis), + time_axis; + meta, + ) + tangent_r_target = add_constraints_container!( + container, + PiecewiseMcCormickTangentLBR, + C, + collect(name_axis), + time_axis; + meta, + ) + for name in name_axis, t in time_axis + selector_target[name, t] = pwmcc.selector_constraints[name, t] + linking_target[name, t] = pwmcc.linking_constraints[name, t] + chord_target[name, t] = pwmcc.chord_ub_constraints[name, t] + tangent_l_target[name, t] = pwmcc.tangent_lb_l_constraints[name, t] + tangent_r_target[name, t] = pwmcc.tangent_lb_r_constraints[name, t] + end + + interval_lb_target = add_constraints_container!( + container, + PiecewiseMcCormickIntervalLB, + C, + collect(name_axis), + k_axis, + time_axis; + meta, + ) + interval_ub_target = add_constraints_container!( + container, + PiecewiseMcCormickIntervalUB, + C, + collect(name_axis), + k_axis, + time_axis; + meta, + ) + for name in name_axis, k in k_axis, t in time_axis + interval_lb_target[name, k, t] = pwmcc.interval_lb_constraints[name, k, t] + interval_ub_target[name, k, t] = pwmcc.interval_ub_constraints[name, k, t] + end + return +end diff --git a/src/approximations/sawtooth.jl b/src/approximations/sawtooth.jl new file mode 100644 index 00000000..51514eb1 --- /dev/null +++ b/src/approximations/sawtooth.jl @@ -0,0 +1,293 @@ +# Sawtooth MIP approximation of x² for use in constraints. +# Uses recursive tooth function compositions with O(log(1/ε)) binary variables. +# Reference: Beach, Burlacu, Hager, Hildebrand (2024). + +"Binary variables (α₁, …, α_L) for sawtooth quadratic approximation." +struct SawtoothBinaryVariable <: VariableType end + +"Variable result in tightened version." +struct SawtoothTightenedVariable <: VariableType end + +"Constrains g_j based on g_{j-1}." +struct SawtoothMIPConstraint <: ConstraintType end + +"Bounds tightened sawtooth variable." +struct SawtoothTightenedConstraint <: ConstraintType end + +""" +Config for sawtooth MIP quadratic approximation. + +# Fields +- `depth::Int`: recursion depth L; uses L binary variables for 2^L + 1 breakpoints. +- `epigraph_depth::Int`: LP tightening depth via epigraph Q^{L1} lower bound; + 0 to disable (default 0). +""" +struct SawtoothQuadConfig <: QuadraticApproxConfig + depth::Int + epigraph_depth::Int +end +function SawtoothQuadConfig(depth::Int) + return SawtoothQuadConfig(depth, 0) +end + +""" +Pure-JuMP result of `build_quadratic_approx(::SawtoothQuadConfig, ...)`. +""" +struct SawtoothQuadResult{A, G, AL, LC, MC, ZV, TC, EPI} <: QuadraticApproxResult + approximation::A + g_var::G + alpha_var::AL + link_constraints::LC + mip_constraints::MC + tightened_z_var::ZV # Union{Nothing, DenseAxisArray} + tightened_constraints::TC # Union{Nothing, DenseAxisArray} + epigraph::EPI # Union{Nothing, EpigraphQuadResult} +end + +""" + build_quadratic_approx(config::SawtoothQuadConfig, model, x, bounds) + +PWL approximation of x² with sawtooth tooth functions and L binary variables. +If `config.epigraph_depth > 0`, also builds an epigraph Q^{L1} lower bound and +tightens the approximation: z ≤ x² (sawtooth, upper) and z ≥ epigraph (lower). +""" +function build_quadratic_approx( + config::SawtoothQuadConfig, + model::JuMP.Model, + x, + bounds::Vector{MinMax}, +) + IS.@assert_op config.depth >= 1 + name_axis = axes(x, 1) + time_axis = axes(x, 2) + IS.@assert_op length(name_axis) == length(bounds) + for b in bounds + IS.@assert_op b.max > b.min + end + + g_levels = 0:(config.depth) + alpha_levels = 1:(config.depth) + delta = JuMP.Containers.DenseAxisArray( + [b.max - b.min for b in bounds], + name_axis, + ) + x_min_arr = JuMP.Containers.DenseAxisArray( + [b.min for b in bounds], + name_axis, + ) + + g_var = JuMP.@variable( + model, + [name = name_axis, j = g_levels, t = time_axis], + lower_bound = 0.0, + upper_bound = 1.0, + base_name = "SawtoothAux", + ) + alpha_var = JuMP.@variable( + model, + [name = name_axis, j = alpha_levels, t = time_axis], + binary = true, + base_name = "SawtoothBin", + ) + + link_cons = JuMP.@constraint( + model, + [name = name_axis, t = time_axis], + g_var[name, 0, t] == (x[name, t] - x_min_arr[name]) / delta[name], + ) + + # S^L constraints for j = 1..L: 4 inequalities per level. + # Indexed by (name, j, k, t) with k ∈ 1:4. + mip_cons = JuMP.Containers.DenseAxisArray{Any}( + undef, name_axis, alpha_levels, 1:4, time_axis, + ) + for name in name_axis, j in alpha_levels, t in time_axis + g_prev = g_var[name, j - 1, t] + g_curr = g_var[name, j, t] + a_j = alpha_var[name, j, t] + mip_cons[name, j, 1, t] = + JuMP.@constraint(model, g_curr <= 2.0 * g_prev) + mip_cons[name, j, 2, t] = + JuMP.@constraint(model, g_curr <= 2.0 * (1.0 - g_prev)) + mip_cons[name, j, 3, t] = + JuMP.@constraint(model, g_curr >= 2.0 * (g_prev - a_j)) + mip_cons[name, j, 4, t] = + JuMP.@constraint(model, g_curr >= 2.0 * (a_j - g_prev)) + end + + # x² ≈ x_min² + (2·x_min·δ + δ²)·g₀ − Σ_{j=1..L} δ²·2^{−2j}·g_j + x_sq_approx = JuMP.@expression( + model, + [name = name_axis, t = time_axis], + x_min_arr[name]^2 + + (2.0 * x_min_arr[name] * delta[name] + delta[name]^2) * g_var[name, 0, t] - + sum(delta[name]^2 * 2.0^(-2j) * g_var[name, j, t] for j in alpha_levels) + ) + + if config.epigraph_depth > 0 + epi_result = build_quadratic_approx( + EpigraphQuadConfig(config.epigraph_depth), + model, + x, + bounds, + ) + z_min_arr = JuMP.Containers.DenseAxisArray( + [(b.min <= 0.0 <= b.max) ? 0.0 : min(b.min^2, b.max^2) for b in bounds], + name_axis, + ) + z_max_arr = JuMP.Containers.DenseAxisArray( + [max(b.min^2, b.max^2) for b in bounds], + name_axis, + ) + z_var = JuMP.@variable( + model, + [name = name_axis, t = time_axis], + base_name = "TightenedSawtooth", + ) + for name in name_axis, t in time_axis + JuMP.set_lower_bound(z_var[name, t], z_min_arr[name]) + JuMP.set_upper_bound(z_var[name, t], z_max_arr[name]) + end + tight_cons = JuMP.Containers.DenseAxisArray{Any}( + undef, name_axis, 1:2, time_axis, + ) + for name in name_axis, t in time_axis + tight_cons[name, 1, t] = + JuMP.@constraint(model, z_var[name, t] <= x_sq_approx[name, t]) + tight_cons[name, 2, t] = JuMP.@constraint( + model, + z_var[name, t] >= epi_result.approximation[name, t], + ) + end + approximation = JuMP.@expression( + model, + [name = name_axis, t = time_axis], + 1.0 * z_var[name, t] + ) + return SawtoothQuadResult( + approximation, + g_var, + alpha_var, + link_cons, + mip_cons, + z_var, + tight_cons, + epi_result, + ) + end + + return SawtoothQuadResult( + x_sq_approx, + g_var, + alpha_var, + link_cons, + mip_cons, + nothing, + nothing, + nothing, + ) +end + +function register_in_container!( + container::OptimizationContainer, + ::Type{C}, + result::SawtoothQuadResult, + meta::String, +) where {C <: IS.InfrastructureSystemsComponent} + name_axis = axes(result.approximation, 1) + time_axis = axes(result.approximation, 2) + g_levels = axes(result.g_var, 2) + alpha_levels = axes(result.alpha_var, 2) + + g_target = add_variable_container!( + container, + SawtoothAuxVariable, + C, + collect(name_axis), + g_levels, + time_axis; + meta, + ) + for name in name_axis, j in g_levels, t in time_axis + g_target[name, j, t] = result.g_var[name, j, t] + end + + alpha_target = add_variable_container!( + container, + SawtoothBinaryVariable, + C, + collect(name_axis), + alpha_levels, + time_axis; + meta, + ) + for name in name_axis, j in alpha_levels, t in time_axis + alpha_target[name, j, t] = result.alpha_var[name, j, t] + end + + link_target = add_constraints_container!( + container, + SawtoothLinkingConstraint, + C, + collect(name_axis), + time_axis; + meta, + ) + for name in name_axis, t in time_axis + link_target[name, t] = result.link_constraints[name, t] + end + + mip_target = add_constraints_container!( + container, + SawtoothMIPConstraint, + C, + collect(name_axis), + 1:4, + time_axis; + sparse = true, + meta, + ) + for name in name_axis, j in alpha_levels, k in 1:4, t in time_axis + mip_target[(name, k, t)] = result.mip_constraints[name, j, k, t] + end + + result_target = add_expression_container!( + container, + QuadraticExpression, + C, + collect(name_axis), + time_axis; + meta, + ) + for name in name_axis, t in time_axis + result_target[name, t] = result.approximation[name, t] + end + + if result.tightened_z_var !== nothing + z_target = add_variable_container!( + container, + SawtoothTightenedVariable, + C, + collect(name_axis), + time_axis; + meta, + ) + for name in name_axis, t in time_axis + z_target[name, t] = result.tightened_z_var[name, t] + end + tight_target = add_constraints_container!( + container, + SawtoothTightenedConstraint, + C, + collect(name_axis), + 1:2, + time_axis; + meta, + ) + for name in name_axis, k in 1:2, t in time_axis + tight_target[name, k, t] = result.tightened_constraints[name, k, t] + end + register_in_container!(container, C, result.epigraph, meta * "_lb") + end + return +end diff --git a/src/approximations/solver_sos2.jl b/src/approximations/solver_sos2.jl new file mode 100644 index 00000000..e227ffb9 --- /dev/null +++ b/src/approximations/solver_sos2.jl @@ -0,0 +1,234 @@ +# Solver-native SOS2 piecewise linear approximation of x² for use in constraints. +# Uses solver-native MOI.SOS2 constraints for adjacency enforcement among λ weights. + +"Lambda (λ) convex-combination weight variables for SOS2 quadratic approximation." +struct QuadraticVariable <: SparseVariableType end + +"Links x to the weighted sum of breakpoints in SOS2 quadratic approximation." +struct SOS2LinkingConstraint <: ConstraintType end + +"Expression for the weighted sum of breakpoints Σ λ_i · x_i linking x to λ variables." +struct SOS2LinkingExpression <: ExpressionType end + +"Ensures the sum of λ weights equals 1 in SOS2 quadratic approximation." +struct SOS2NormConstraint <: ConstraintType end + +"Expression for the normalization sum Σ λ_i in SOS2 quadratic approximation." +struct SOS2NormExpression <: ExpressionType end + +"Solver-native MOI.SOS2 adjacency constraint on lambda variables." +struct SolverSOS2Constraint <: ConstraintType end + +""" +Config for solver-native SOS2 quadratic approximation (MOI.SOS2 adjacency). + +# Fields +- `depth::Int`: number of PWL segments (breakpoints = depth + 1). +- `pwmcc_segments::Int`: number of piecewise McCormick cut partitions; + 0 to disable (default 4). +""" +struct SolverSOS2QuadConfig <: QuadraticApproxConfig + depth::Int + pwmcc_segments::Int +end +function SolverSOS2QuadConfig(depth::Int) + return SolverSOS2QuadConfig(depth, 4) +end + +""" +Pure-JuMP result of `build_quadratic_approx(::SolverSOS2QuadConfig, ...)`. +""" +struct SOS2QuadResult{A, L, LC, NC, SC, LE, NE, PWMCC} <: QuadraticApproxResult + approximation::A + lambda::L + link_constraints::LC + norm_constraints::NC + sos_constraints::SC + link_expressions::LE + norm_expressions::NE + pwmcc::PWMCC # Union{Nothing, PWMCCResult} +end + +""" + build_quadratic_approx(config::SolverSOS2QuadConfig, model, x, bounds) + +PWL approximation of x² with solver-native MOI.SOS2 adjacency on the +convex-combination weights. If `config.pwmcc_segments > 0`, also adds +piecewise McCormick concave cuts to tighten the LP relaxation. +""" +function build_quadratic_approx( + config::SolverSOS2QuadConfig, + model::JuMP.Model, + x, + bounds::Vector{MinMax}, +) + name_axis = axes(x, 1) + time_axis = axes(x, 2) + IS.@assert_op length(name_axis) == length(bounds) + for b in bounds + IS.@assert_op b.max > b.min + end + n_points = config.depth + 1 + x_bkpts, x_sq_bkpts = _get_breakpoints_for_pwl_function( + 0.0, + 1.0, + _square; + num_segments = config.depth, + ) + + lx = JuMP.Containers.DenseAxisArray( + [b.max - b.min for b in bounds], + name_axis, + ) + x_min = JuMP.Containers.DenseAxisArray( + [b.min for b in bounds], + name_axis, + ) + + lambda = JuMP.@variable( + model, + [name = name_axis, i = 1:n_points, t = time_axis], + lower_bound = 0.0, + upper_bound = 1.0, + base_name = "QuadraticVariable", + ) + link_expr = JuMP.@expression( + model, + [name = name_axis, t = time_axis], + sum(x_bkpts[i] * lambda[name, i, t] for i in 1:n_points) + ) + link_cons = JuMP.@constraint( + model, + [name = name_axis, t = time_axis], + (x[name, t] - x_min[name]) / lx[name] == link_expr[name, t] + ) + norm_expr = JuMP.@expression( + model, + [name = name_axis, t = time_axis], + sum(lambda[name, i, t] for i in 1:n_points) + ) + norm_cons = JuMP.@constraint( + model, + [name = name_axis, t = time_axis], + norm_expr[name, t] == 1.0 + ) + sos_cons = JuMP.Containers.DenseAxisArray{Any}(undef, name_axis, time_axis) + for name in name_axis, t in time_axis + sos_cons[name, t] = JuMP.@constraint( + model, + [lambda[name, i, t] for i in 1:n_points] in MOI.SOS2(collect(1:n_points)), + ) + end + # x² = x_min² + 2·x_min·(x − x_min) + lx² · xh² where xh² ≈ Σ λ_i · x_bkpts[i]² + # = lx² · Σ λ_i · x_bkpts[i]² + 2·x_min·x − x_min² + approximation = JuMP.@expression( + model, + [name = name_axis, t = time_axis], + lx[name] * lx[name] * + sum(x_sq_bkpts[i] * lambda[name, i, t] for i in 1:n_points) + + 2.0 * x_min[name] * x[name, t] - x_min[name] * x_min[name] + ) + + pwmcc = if config.pwmcc_segments > 0 + build_pwmcc_concave_cuts(model, x, approximation, bounds, config.pwmcc_segments) + else + nothing + end + + return SOS2QuadResult( + approximation, + lambda, + link_cons, + norm_cons, + sos_cons, + link_expr, + norm_expr, + pwmcc, + ) +end + +function register_in_container!( + container::OptimizationContainer, + ::Type{C}, + result::SOS2QuadResult, + meta::String, +) where {C <: IS.InfrastructureSystemsComponent} + name_axis = axes(result.approximation, 1) + time_axis = axes(result.approximation, 2) + n_points_axis = axes(result.lambda, 2) + + lambda_target = add_variable_container!( + container, + QuadraticVariable, + C, + collect(name_axis), + n_points_axis, + time_axis; + meta, + ) + for name in name_axis, i in n_points_axis, t in time_axis + lambda_target[name, i, t] = result.lambda[name, i, t] + end + + link_cons_target = add_constraints_container!( + container, + SOS2LinkingConstraint, + C, + collect(name_axis), + time_axis; + meta, + ) + norm_cons_target = add_constraints_container!( + container, + SOS2NormConstraint, + C, + collect(name_axis), + time_axis; + meta, + ) + sos_cons_target = add_constraints_container!( + container, + SolverSOS2Constraint, + C, + collect(name_axis), + time_axis; + meta, + ) + link_expr_target = add_expression_container!( + container, + SOS2LinkingExpression, + C, + collect(name_axis), + time_axis; + meta, + ) + norm_expr_target = add_expression_container!( + container, + SOS2NormExpression, + C, + collect(name_axis), + time_axis; + meta, + ) + result_target = add_expression_container!( + container, + QuadraticExpression, + C, + collect(name_axis), + time_axis; + meta, + ) + for name in name_axis, t in time_axis + link_cons_target[name, t] = result.link_constraints[name, t] + norm_cons_target[name, t] = result.norm_constraints[name, t] + sos_cons_target[name, t] = result.sos_constraints[name, t] + link_expr_target[name, t] = result.link_expressions[name, t] + norm_expr_target[name, t] = result.norm_expressions[name, t] + result_target[name, t] = result.approximation[name, t] + end + + if result.pwmcc !== nothing + register_pwmcc!(container, C, result.pwmcc, meta * "_pwmcc") + end + return +end diff --git a/src/bilinear_approximations/bin2.jl b/src/bilinear_approximations/bin2.jl deleted file mode 100644 index 5c331cd4..00000000 --- a/src/bilinear_approximations/bin2.jl +++ /dev/null @@ -1,156 +0,0 @@ -# Bin2 separable approximation of bilinear products z = x·y. -# Uses the identity: x·y = (1/2)*((x+y)² − x² - y²). -# Calls existing quadratic approximation functions for p²=(x+y)² - -"Expression container for bilinear product (x·y) approximation results." -struct BilinearProductExpression <: ExpressionType end -"Variable container for bilinear product (x ̇y) approximation results." -struct BilinearProductVariable <: VariableType end -"Expression container for adding variables." -struct VariableSumExpression <: ExpressionType end -"Expression container for subtracting variables." -struct VariableDifferenceExpression <: ExpressionType end -"Constraint container for linking product expressions and variables." -struct BilinearProductLinkingConstraint <: ConstraintType end - -# --- Bilinear approximation config hierarchy --- - -"Abstract supertype for bilinear approximation method configurations." -abstract type BilinearApproxConfig end - -""" -Config for Bin2 bilinear approximation using z = ½((x+y)² − x² − y²). - -# Fields -- `quad_config::QuadraticApproxConfig`: quadratic method used for x², y², and (x+y)² -- `add_mccormick::Bool`: whether to add reformulated McCormick cuts through separable variables (default true) -""" -struct Bin2Config <: BilinearApproxConfig - quad_config::QuadraticApproxConfig - add_mccormick::Bool -end -Bin2Config(quad_config::QuadraticApproxConfig) = Bin2Config(quad_config, true) - -# --- Unified bilinear approximation dispatch --- - -""" - _add_bilinear_approx!(config::Bin2Config, container, C, names, time_steps, x_var, y_var, x_bounds, y_bounds, meta) - -Standard form: compute x² and y² quadratic approximations, then delegate to precomputed form. - -# Arguments -- `x_bounds::Vector{MinMax}`: per-name lower and upper bounds of x -- `y_bounds::Vector{MinMax}`: per-name lower and upper bounds of y -""" -function _add_bilinear_approx!( - config::Bin2Config, - container::OptimizationContainer, - ::Type{C}, - names::Vector{String}, - time_steps::UnitRange{Int}, - x_var, - y_var, - x_bounds::Vector{MinMax}, - y_bounds::Vector{MinMax}, - meta::String, -) where {C <: IS.InfrastructureSystemsComponent} - xsq = _add_quadratic_approx!( - config.quad_config, container, C, names, time_steps, - x_var, x_bounds, meta * "_x", - ) - ysq = _add_quadratic_approx!( - config.quad_config, container, C, names, time_steps, - y_var, y_bounds, meta * "_y", - ) - return _add_bilinear_approx!( - config, container, C, names, time_steps, - xsq, ysq, x_var, y_var, - x_bounds, y_bounds, meta, - ) -end - -""" - _add_bilinear_approx!(config::Bin2Config, container, C, names, time_steps, xsq, ysq, x_var, y_var, x_bounds, y_bounds, meta) - -Precomputed form: Bin2 identity z = ½((x+y)² − x² − y²) with optional PWMCC concave cuts. -Accepts pre-computed quadratic approximations `xsq` ≈ x² and `ysq` ≈ y². - -# Arguments -- `x_bounds::Vector{MinMax}`: per-name lower and upper bounds of x -- `y_bounds::Vector{MinMax}`: per-name lower and upper bounds of y -""" -function _add_bilinear_approx!( - config::Bin2Config, - container::OptimizationContainer, - ::Type{C}, - names::Vector{String}, - time_steps::UnitRange{Int}, - xsq, - ysq, - x_var, - y_var, - x_bounds::Vector{MinMax}, - y_bounds::Vector{MinMax}, - meta::String, -) where {C <: IS.InfrastructureSystemsComponent} - # --- Bin2 identity: z = ½((x+y)² − x² − y²) --- - - # Bounds for p = x + y (per-name) - p_bounds = [ - MinMax(( - min = x_bounds[i].min + y_bounds[i].min, - max = x_bounds[i].max + y_bounds[i].max, - )) for i in eachindex(x_bounds) - ] - - meta_plus = meta * "_plus" - - p_expr = add_expression_container!( - container, - VariableSumExpression, - C, - names, - time_steps; - meta = meta_plus, - ) - for name in names, t in time_steps - p = JuMP.AffExpr(0.0) - add_proportional_to_jump_expression!(p, x_var[name, t], 1.0) - add_proportional_to_jump_expression!(p, y_var[name, t], 1.0) - p_expr[name, t] = p - end - - # Approximate p² = (x+y)² using the provided quadratic config - psq = _add_quadratic_approx!( - config.quad_config, container, C, names, time_steps, - p_expr, p_bounds, meta_plus, - ) - - result_expr = add_expression_container!( - container, - BilinearProductExpression, - C, - names, - time_steps; - meta, - ) - - for name in names, t in time_steps - # z = (1/2) * (p² − x² − y²) - result = result_expr[name, t] = JuMP.AffExpr(0.0) - add_proportional_to_jump_expression!(result, psq[name, t], 0.5) - add_proportional_to_jump_expression!(result, xsq[name, t], -0.5) - add_proportional_to_jump_expression!(result, ysq[name, t], -0.5) - end - - # --- Reformulated McCormick cuts (optional) --- - if config.add_mccormick - _add_reformulated_mccormick!( - container, C, names, time_steps, - x_var, y_var, psq, xsq, ysq, - x_bounds, y_bounds, meta, - ) - end - - return result_expr -end diff --git a/src/bilinear_approximations/hybs.jl b/src/bilinear_approximations/hybs.jl deleted file mode 100644 index 8df74bff..00000000 --- a/src/bilinear_approximations/hybs.jl +++ /dev/null @@ -1,235 +0,0 @@ -# HybS (Hybrid Separable) MIP relaxation for bilinear products z = x·y. -# Combines Bin2 lower bound and Bin3 upper bound with shared sawtooth for x², y² -# and LP-only epigraph for (x+y)², (x−y)². Uses 2L binaries instead of 3L (Bin2). -# Reference: Beach, Burlacu, Bärmann, Hager, Hildebrand (2024), Definition 10. - -"Two-sided HybS bound constraints: Bin2 lower + Bin3 upper." -struct HybSBoundConstraint <: ConstraintType end - -""" -Config for HybS (Hybrid Separable) bilinear approximation. - -Combines Bin2 lower bound and Bin3 upper bound with shared quadratic for x², y² -and LP-only epigraph for (x+y)², (x−y)². - -# Fields -- `quad_config::QuadraticApproxConfig`: quadratic method used for the shared x² and y² terms -- `epigraph_depth::Int`: depth for the epigraph Q^{L1} LP-only approximation of cross-terms (x±y)² -- `add_mccormick::Bool`: whether to add standard McCormick envelope cuts on the product variable (default false) -""" -struct HybSConfig <: BilinearApproxConfig - quad_config::QuadraticApproxConfig - epigraph_depth::Int - add_mccormick::Bool -end -HybSConfig(quad_config::QuadraticApproxConfig, epigraph_depth::Int) = - HybSConfig(quad_config, epigraph_depth, false) - -# --- Unified HybS dispatch methods --- - -""" - _add_bilinear_approx!(config::HybSConfig, container, C, names, time_steps, x_var, y_var, x_bounds, y_bounds, meta) - -Approximate x·y using HybS (Hybrid Separable) relaxation with config-selected quadratic method. - -# Arguments -- `x_bounds::Vector{MinMax}`: per-name lower and upper bounds of x -- `y_bounds::Vector{MinMax}`: per-name lower and upper bounds of y -""" -function _add_bilinear_approx!( - config::HybSConfig, - container::OptimizationContainer, - ::Type{C}, - names::Vector{String}, - time_steps::UnitRange{Int}, - x_var, - y_var, - x_bounds::Vector{MinMax}, - y_bounds::Vector{MinMax}, - meta::String, -) where {C <: IS.InfrastructureSystemsComponent} - xsq = _add_quadratic_approx!( - config.quad_config, container, C, names, time_steps, - x_var, x_bounds, meta * "_x", - ) - ysq = _add_quadratic_approx!( - config.quad_config, container, C, names, time_steps, - y_var, y_bounds, meta * "_y", - ) - return _add_bilinear_approx!( - config, container, C, names, time_steps, - xsq, ysq, x_var, y_var, - x_bounds, y_bounds, meta, - ) -end - -""" - _add_bilinear_approx!(config::HybSConfig, container, C, names, time_steps, xsq, ysq, x_var, y_var, x_bounds, y_bounds, meta) - -HybS bilinear approximation with pre-computed quadratic approximations for x² and y². - -Combines Bin2 and Bin3 separable identities: -- Bin2 lower bound: z ≥ ½(z_p1 − z_x − z_y) where z_p1 lower-bounds (x+y)² -- Bin3 upper bound: z ≤ ½(z_x + z_y − z_p2) where z_p2 lower-bounds (x−y)² - -The cross-terms (x+y)² and (x−y)² always use epigraph Q^{L1} (pure LP). - -# Arguments -- `x_bounds::Vector{MinMax}`: per-name lower and upper bounds of x -- `y_bounds::Vector{MinMax}`: per-name lower and upper bounds of y -""" -function _add_bilinear_approx!( - config::HybSConfig, - container::OptimizationContainer, - ::Type{C}, - names::Vector{String}, - time_steps::UnitRange{Int}, - xsq, - ysq, - x_var, - y_var, - x_bounds::Vector{MinMax}, - y_bounds::Vector{MinMax}, - meta::String, -) where {C <: IS.InfrastructureSystemsComponent} - # Bounds for auxiliary variables (per-name) - p1_bounds = [ - MinMax(( - min = x_bounds[i].min + y_bounds[i].min, - max = x_bounds[i].max + y_bounds[i].max, - )) for i in eachindex(x_bounds) - ] - p2_bounds = [ - MinMax(( - min = x_bounds[i].min - y_bounds[i].max, - max = x_bounds[i].max - y_bounds[i].min, - )) for i in eachindex(x_bounds) - ] - - jump_model = get_jump_model(container) - - # Meta suffixes for cross-term expressions - meta_p1 = meta * "_plus" - meta_p2 = meta * "_diff" - - p1_expr = add_expression_container!( - container, - VariableSumExpression, - C, - names, - time_steps; - meta = meta_p1, - ) - p2_expr = add_expression_container!( - container, - VariableDifferenceExpression, - C, - names, - time_steps; - meta = meta_p2, - ) - - for name in names, t in time_steps - x = x_var[name, t] - y = y_var[name, t] - - # p1 = x + y - p1 = p1_expr[name, t] = JuMP.AffExpr(0.0) - add_proportional_to_jump_expression!(p1, x, 1.0) - add_proportional_to_jump_expression!(p1, y, 1.0) - - # p2 = x − y - p2 = p2_expr[name, t] = JuMP.AffExpr(0.0) - add_proportional_to_jump_expression!(p2, x, 1.0) - add_proportional_to_jump_expression!(p2, y, -1.0) - end - - # --- Epigraph Q^{L1} lower bound for (x+y)² and (x−y)² (no binaries) --- - epi_cfg = EpigraphQuadConfig(config.epigraph_depth) - zp1_expr = _add_quadratic_approx!( - epi_cfg, - container, C, names, time_steps, - p1_expr, p1_bounds, meta_p1, - ) - zp2_expr = _add_quadratic_approx!( - epi_cfg, - container, C, names, time_steps, - p2_expr, p2_bounds, meta_p2, - ) - - # --- Create z variable and two-sided HybS bounds --- - z_var = add_variable_container!( - container, - BilinearProductVariable, - C, - names, - time_steps; - meta, - ) - hybrid_cons = add_constraints_container!( - container, - HybSBoundConstraint, - C, - names, - 1:2, - time_steps; - sparse = true, - meta, - ) - result_expr = add_expression_container!( - container, - BilinearProductExpression, - C, - names, - time_steps; - meta, - ) - - for (i, name) in enumerate(names), t in time_steps - xb = x_bounds[i] - yb = y_bounds[i] - IS.@assert_op xb.max > xb.min - IS.@assert_op yb.max > yb.min - - # Compute valid bounds for z ≈ x·y from variable bounds - z_lo = min(xb.min * yb.min, xb.min * yb.max, xb.max * yb.min, xb.max * yb.max) - z_hi = max(xb.min * yb.min, xb.min * yb.max, xb.max * yb.min, xb.max * yb.max) - - z = - z_var[name, t] = JuMP.@variable( - jump_model, - base_name = "HybSProduct_$(C)_{$(name), $(t)}", - lower_bound = z_lo, - upper_bound = z_hi, - ) - - zx = xsq[name, t] - zy = ysq[name, t] - zp1 = zp1_expr[name, t] - zp2 = zp2_expr[name, t] - - # Bin2 lower bound: z ≥ ½(z_p1 − z_x − z_y) - hybrid_cons[(name, 1, t)] = JuMP.@constraint( - jump_model, - z >= 0.5 * (zp1 - zx - zy), - ) - # Bin3 upper bound: z ≤ ½(z_x + z_y − z_p2) - hybrid_cons[(name, 2, t)] = JuMP.@constraint( - jump_model, - z <= 0.5 * (zx + zy - zp2), - ) - - result_expr[name, t] = JuMP.AffExpr(0.0, z => 1.0) - end - - # --- Standard McCormick envelope cuts on the product variable --- - if config.add_mccormick - _add_mccormick_envelope!( - container, C, names, time_steps, - x_var, y_var, z_var, - x_bounds, y_bounds, meta, - ) - end - - return result_expr -end diff --git a/src/bilinear_approximations/mccormick.jl b/src/bilinear_approximations/mccormick.jl deleted file mode 100644 index 8ee2e3d2..00000000 --- a/src/bilinear_approximations/mccormick.jl +++ /dev/null @@ -1,404 +0,0 @@ -# McCormick envelope for bilinear products z = x·y. -# Adds 4 linear inequalities that bound z given variable bounds on x and y. - -"Standard McCormick envelope constraints bounding the bilinear product z = x·y." -struct McCormickConstraint <: ConstraintType end - -"Reformulated McCormick constraints on Bin2 separable variables." -struct ReformulatedMcCormickConstraint <: ConstraintType end - -""" - _mc_setindex!(cons, index, n, constraint) - -Helper function for setting constraints by-index in a McCormick constraint container. - -Supports 2- and 3-length tuples. -""" -@inline function _mc_setindex!(cons, index::Tuple{A, B}, n::Int, constraint) where {A, B} - cons[index[1], n, index[2]] = constraint -end - -@inline function _mc_setindex!( - cons, - index::Tuple{A, B, C}, - n::Int, - constraint, -) where {A, B, C} - cons[index[1], index[2], n, index[3]] = constraint -end - -function _add_mccormick_envelope!( - jump_model::JuMP.Model, - cons, - index, - x::JuMP.AbstractJuMPScalar, - y::JuMP.AbstractJuMPScalar, - z::JuMP.AbstractJuMPScalar, - x_min::Float64, - x_max::Float64, - y_min::Float64, - y_max::Float64; - lower_bounds::Bool = true, -) - if lower_bounds - _mc_setindex!( - cons, - index, - 1, - JuMP.@constraint( - jump_model, - z >= x_min * y + x * y_min - x_min * y_min, - ) - ) - _mc_setindex!( - cons, - index, - 2, - JuMP.@constraint( - jump_model, - z >= x_max * y + x * y_max - x_max * y_max, - ) - ) - end - _mc_setindex!( - cons, - index, - 3, - JuMP.@constraint( - jump_model, - z <= x_max * y + x * y_min - x_max * y_min, - ) - ) - _mc_setindex!( - cons, - index, - 4, - JuMP.@constraint( - jump_model, - z <= x_min * y + x * y_max - x_min * y_max, - ) - ) -end - -""" - _add_mccormick_envelope!(container, C, names, time_steps, x_var, y_var, z_var, x_min, x_max, y_min, y_max, meta) - -Add McCormick envelope constraints for the bilinear product z ≈ x·y. - -For each (name, t), adds 4 linear inequalities: -``` -z ≥ x_min·y + x·y_min − x_min·y_min -z ≥ x_max·y + x·y_max − x_max·y_max -z ≤ x_max·y + x·y_min − x_max·y_min -z ≤ x_min·y + x·y_max − x_min·y_max -``` - -# Arguments -- `container::OptimizationContainer`: the optimization container -- `::Type{C}`: component type -- `names::Vector{String}`: component names -- `time_steps::UnitRange{Int}`: time periods -- `x_var`: container of x variables indexed by (name, t) -- `y_var`: container of y variables indexed by (name, t) -- `z_var`: container of z variables indexed by (name, t) -- `x_min::Float64`: lower bound of x -- `x_max::Float64`: upper bound of x -- `y_min::Float64`: lower bound of y -- `y_max::Float64`: upper bound of y -- `meta::String`: identifier for container keys - -# Returns -- Nothing. Constraints are added in-place. -""" -function _add_mccormick_envelope!( - container::OptimizationContainer, - ::Type{C}, - names::Vector{String}, - time_steps::UnitRange{Int}, - x_var, - y_var, - z_var, - x_min::Float64, - x_max::Float64, - y_min::Float64, - y_max::Float64, - meta::String; - lower_bounds::Bool = true, -) where {C <: IS.InfrastructureSystemsComponent} - IS.@assert_op x_max > x_min - IS.@assert_op y_max > y_min - jump_model = get_jump_model(container) - - mc_cons = add_constraints_container!( - container, - McCormickConstraint, - C, - names, - 1:4, - time_steps; - sparse = true, - meta, - ) - - for name in names, t in time_steps - _add_mccormick_envelope!( - jump_model, mc_cons, (name, t), - x_var[name, t], y_var[name, t], z_var[name, t], - x_min, x_max, y_min, y_max; - lower_bounds, - ) - end - - return -end - -function _add_mccormick_envelope!( - container::OptimizationContainer, - ::Type{C}, - names::Vector{String}, - time_steps::UnitRange{Int}, - x_var, - z_var, - x_min::Float64, - x_max::Float64, - meta::String; - lower_bounds::Bool = true, -) where {C <: IS.InfrastructureSystemsComponent} - _add_mccormick_envelope!( - container, C, names, time_steps, - x_var, x_var, z_var, - x_min, x_max, x_min, x_max, - meta; lower_bounds, - ) - return -end - -""" - _add_mccormick_envelope!(container, C, names, time_steps, x_var, y_var, z_var, x_bounds, y_bounds, meta) - -Add McCormick envelope constraints for the bilinear product z ≈ x·y with per-name bounds. - -For each (name, t), adds 4 linear inequalities using bounds looked up by name index. - -# Arguments -- `container::OptimizationContainer`: the optimization container -- `::Type{C}`: component type -- `names::Vector{String}`: component names -- `time_steps::UnitRange{Int}`: time periods -- `x_var`: container of x variables indexed by (name, t) -- `y_var`: container of y variables indexed by (name, t) -- `z_var`: container of z variables indexed by (name, t) -- `x_bounds::Vector{MinMax}`: per-name lower and upper bounds of x -- `y_bounds::Vector{MinMax}`: per-name lower and upper bounds of y -- `meta::String`: identifier for container keys - -# Returns -- Nothing. Constraints are added in-place. -""" -function _add_mccormick_envelope!( - container::OptimizationContainer, - ::Type{C}, - names::Vector{String}, - time_steps::UnitRange{Int}, - x_var, - y_var, - z_var, - x_bounds::Vector{MinMax}, - y_bounds::Vector{MinMax}, - meta::String; - lower_bounds::Bool = true, -) where {C <: IS.InfrastructureSystemsComponent} - jump_model = get_jump_model(container) - - mc_cons = add_constraints_container!( - container, - McCormickConstraint, - C, - names, - 1:4, - time_steps; - sparse = true, - meta, - ) - - for (i, name) in enumerate(names), t in time_steps - xb = x_bounds[i] - yb = y_bounds[i] - IS.@assert_op xb.max > xb.min - IS.@assert_op yb.max > yb.min - _add_mccormick_envelope!( - jump_model, mc_cons, (name, t), - x_var[name, t], y_var[name, t], z_var[name, t], - xb.min, xb.max, yb.min, yb.max; - lower_bounds, - ) - end - - return -end - -function _add_mccormick_envelope!( - container::OptimizationContainer, - ::Type{C}, - names::Vector{String}, - time_steps::UnitRange{Int}, - x_var, - z_var, - bounds::Vector{MinMax}, - meta::String; - lower_bounds::Bool = true, -) where {C <: IS.InfrastructureSystemsComponent} - _add_mccormick_envelope!( - container, C, names, time_steps, - x_var, x_var, z_var, - bounds, bounds, - meta; lower_bounds, - ) - return -end - -function _add_mccormick_envelope!( - jump_model::JuMP.Model, - cons, - index, - x::JuMP.VariableRef, - z::JuMP.VariableRef, - x_min::Float64, - x_max::Float64; - lower_bounds::Bool = true, -) - _add_mccormick_envelope!( - jump_model, cons, index, - x, x, z, - x_min, x_max, x_min, x_max; - lower_bounds, - ) -end - -# Lower McCormick bounds on (z_p1 − z_x − z_y) for the Bin2 reformulation. -function _add_reformulated_lower_mccormick!( - jump_model::JuMP.Model, - cons, - index, - x::JuMP.AbstractJuMPScalar, - y::JuMP.AbstractJuMPScalar, - zp1::JuMP.AbstractJuMPScalar, - zx::JuMP.AbstractJuMPScalar, - zy::JuMP.AbstractJuMPScalar, - x_min::Float64, - x_max::Float64, - y_min::Float64, - y_max::Float64, -) - _mc_setindex!( - cons, - index, - 1, - JuMP.@constraint( - jump_model, - zp1 - zx - zy >= 2.0 * (x_min * y + x * y_min - x_min * y_min), - ) - ) - _mc_setindex!( - cons, - index, - 2, - JuMP.@constraint( - jump_model, - zp1 - zx - zy >= 2.0 * (x_max * y + x * y_max - x_max * y_max), - ) - ) -end - -function _add_reformulated_mccormick_bin2!( - jump_model::JuMP.Model, - cons, - index, - x::JuMP.AbstractJuMPScalar, - y::JuMP.AbstractJuMPScalar, - zp1::JuMP.AbstractJuMPScalar, - zx::JuMP.AbstractJuMPScalar, - zy::JuMP.AbstractJuMPScalar, - x_min::Float64, - x_max::Float64, - y_min::Float64, - y_max::Float64, -) - _add_reformulated_lower_mccormick!( - jump_model, cons, index, x, y, zp1, zx, zy, x_min, x_max, y_min, y_max, - ) - # Upper bounds also on (z_p1 − z_x − z_y) since Bin2 has no z_p2 - _mc_setindex!( - cons, - index, - 3, - JuMP.@constraint( - jump_model, - zp1 - zx - zy <= 2.0 * (x_max * y + x * y_min - x_max * y_min), - ) - ) - _mc_setindex!( - cons, - index, - 4, - JuMP.@constraint( - jump_model, - zp1 - zx - zy <= 2.0 * (x_min * y + x * y_max - x_min * y_max), - ) - ) -end - -""" - _add_reformulated_mccormick!(container, C, names, time_steps, x_var, y_var, psq, xsq, ysq, x_bounds, y_bounds, meta) - -Add 4 reformulated McCormick cuts for Bin2 separable bilinear approximation. -Substitutes z = ½(z_p1 − z_x − z_y) into the standard McCormick envelope. - -`psq`, `xsq`, `ysq` are expression containers for (x+y)², x², y² approximations. - -# Arguments -- `x_bounds::Vector{MinMax}`: per-name lower and upper bounds of x -- `y_bounds::Vector{MinMax}`: per-name lower and upper bounds of y -""" -function _add_reformulated_mccormick!( - container::OptimizationContainer, - ::Type{C}, - names::Vector{String}, - time_steps::UnitRange{Int}, - x_var, - y_var, - psq, - xsq, - ysq, - x_bounds::Vector{MinMax}, - y_bounds::Vector{MinMax}, - meta::String, -) where {C <: IS.InfrastructureSystemsComponent} - jump_model = get_jump_model(container) - - mc_cons = add_constraints_container!( - container, - ReformulatedMcCormickConstraint, - C, - names, - 1:4, - time_steps; - sparse = true, - meta, - ) - - for (i, name) in enumerate(names), t in time_steps - xb = x_bounds[i] - yb = y_bounds[i] - IS.@assert_op xb.max > xb.min - IS.@assert_op yb.max > yb.min - _add_reformulated_mccormick_bin2!( - jump_model, mc_cons, (name, t), - x_var[name, t], y_var[name, t], - psq[name, t], xsq[name, t], ysq[name, t], - xb.min, xb.max, yb.min, yb.max, - ) - end - - return -end diff --git a/src/bilinear_approximations/nmdt.jl b/src/bilinear_approximations/nmdt.jl deleted file mode 100644 index 247ca71f..00000000 --- a/src/bilinear_approximations/nmdt.jl +++ /dev/null @@ -1,267 +0,0 @@ -# DNMDT (Double Normalized Multiparametric Disaggregation Technique) bilinear approximation of x·y. -# Independently discretizes both x and y, forms four cross binary-continuous products, then -# combines two NMDT estimates with a convex weighting λ (default 0.5). Reduces to the NMDT -# formulation when applied to x·x (quadratic case). -# Reference: Teles, Castro, Matos (2013), Multiparametric disaggregation technique for global -# optimization of polynomial programming problems. - -""" -Config for double-NMDT bilinear approximation (discretizes both x and y). - -# Fields -- `depth::Int`: number of binary discretization levels L for both x and y -""" -struct DNMDTBilinearConfig <: BilinearApproxConfig - depth::Int -end - -""" -Config for single-NMDT bilinear approximation (discretizes x only). - -# Fields -- `depth::Int`: number of binary discretization levels L for x -""" -struct NMDTBilinearConfig <: BilinearApproxConfig - depth::Int -end - -# --- DNMDT bilinear approximation --- - -""" - _add_bilinear_approx!(config::DNMDTBilinearConfig, container, C, names, time_steps, x_disc, y_disc, x_bounds, y_bounds, meta) - -Approximate x·y using the DNMDT method from pre-built discretizations. - -Constructs all four cross binary-continuous products (β_x·yh, β_y·δx, β_y·xh, β_x·δy) -then delegates to the core DNMDT assembler. Stores results in a `BilinearProductExpression` -container. - -# Arguments -- `config::DNMDTBilinearConfig`: configuration for the DNMDT bilinear approximation -- `container::OptimizationContainer`: the optimization container -- `::Type{C}`: component type -- `names::Vector{String}`: component names -- `time_steps::UnitRange{Int}`: time periods -- `x_disc::NMDTDiscretization`: pre-built discretization for x -- `y_disc::NMDTDiscretization`: pre-built discretization for y -- `x_bounds::Vector{MinMax}`: per-name lower and upper bounds of x -- `y_bounds::Vector{MinMax}`: per-name lower and upper bounds of y -- `meta::String`: identifier encoding the original variable type being approximated -""" -function _add_bilinear_approx!( - config::DNMDTBilinearConfig, - container::OptimizationContainer, - ::Type{C}, - names::Vector{String}, - time_steps::UnitRange{Int}, - x_disc::NMDTDiscretization, - y_disc::NMDTDiscretization, - x_bounds::Vector{MinMax}, - y_bounds::Vector{MinMax}, - meta::String, -) where {C <: IS.InfrastructureSystemsComponent} - bx_yh_expr = _binary_continuous_product!( - container, C, names, time_steps, - x_disc, y_disc.norm_expr, 0.0, 1.0, - config.depth, meta * "_bx_yh", - ) - by_dx_expr = _binary_continuous_product!( - container, C, names, time_steps, - y_disc, x_disc.delta_var, 0.0, 2.0^(-config.depth), - config.depth, meta * "_by_dx", - ) - by_xh_expr = _binary_continuous_product!( - container, C, names, time_steps, - y_disc, x_disc.norm_expr, 0.0, 1.0, - config.depth, meta * "_by_xh", - ) - bx_dy_expr = _binary_continuous_product!( - container, C, names, time_steps, - x_disc, y_disc.delta_var, 0.0, 2.0^(-config.depth), - config.depth, meta * "_bx_dy", - ) - - return _assemble_dnmdt!( - container, C, names, time_steps, - bx_yh_expr, by_dx_expr, by_xh_expr, bx_dy_expr, - x_disc, y_disc, x_bounds, y_bounds, - config.depth, meta; result_type = BilinearProductExpression, - ) -end - -""" - _add_bilinear_approx!(config::DNMDTBilinearConfig, container, C, names, time_steps, x_var, y_var, x_bounds, y_bounds, meta) - -Approximate x·y using the DNMDT method from raw variable inputs. - -Discretizes both x and y independently via `_discretize!` then delegates to the -pre-discretized overload. - -# Arguments -- `config::DNMDTBilinearConfig`: configuration for the DNMDT bilinear approximation -- `container::OptimizationContainer`: the optimization container -- `::Type{C}`: component type -- `names::Vector{String}`: component names -- `time_steps::UnitRange{Int}`: time periods -- `x_var`: container of x variables indexed by (name, t) -- `y_var`: container of y variables indexed by (name, t) -- `x_bounds::Vector{MinMax}`: per-name lower and upper bounds of x -- `y_bounds::Vector{MinMax}`: per-name lower and upper bounds of y -- `meta::String`: identifier encoding the original variable type being approximated -""" -function _add_bilinear_approx!( - config::DNMDTBilinearConfig, - container::OptimizationContainer, - ::Type{C}, - names::Vector{String}, - time_steps::UnitRange{Int}, - x_var, - y_var, - x_bounds::Vector{MinMax}, - y_bounds::Vector{MinMax}, - meta::String, -) where {C <: IS.InfrastructureSystemsComponent} - x_disc = _discretize!( - container, - C, - names, - time_steps, - x_var, - x_bounds, - config.depth, - meta * "_x", - ) - y_disc = _discretize!( - container, - C, - names, - time_steps, - y_var, - y_bounds, - config.depth, - meta * "_y", - ) - return _add_bilinear_approx!( - config, - container, - C, - names, - time_steps, - x_disc, - y_disc, - x_bounds, - y_bounds, - meta, - ) -end - -# --- NMDT bilinear approximation --- - -""" - _add_bilinear_approx!(config::NMDTBilinearConfig, container, C, names, time_steps, x_disc, yh_expr, x_bounds, y_bounds, meta) - -Approximate x·y using the NMDT method from a pre-built x discretization and normalized y. - -Discretizes only x (using `x_disc`) while y is already normalized to yh ∈ [0,1]. -Computes binary-continuous product β_x·yh and residual product δ_x·yh, then assembles -x·y via `_assemble_product!`. Stores results in a `BilinearProductExpression` container. - -# Arguments -- `config::NMDTBilinearConfig`: configuration for the NMDT bilinear approximation -- `container::OptimizationContainer`: the optimization container -- `::Type{C}`: component type -- `names::Vector{String}`: component names -- `time_steps::UnitRange{Int}`: time periods -- `x_disc::NMDTDiscretization`: pre-built discretization for x -- `yh_expr`: expression container for the normalized variable yh = (y − y_min)/(y_max − y_min) -- `x_bounds::Vector{MinMax}`: per-name lower and upper bounds of x -- `y_bounds::Vector{MinMax}`: per-name lower and upper bounds of y -- `meta::String`: identifier encoding the original variable type being approximated -""" -function _add_bilinear_approx!( - config::NMDTBilinearConfig, - container::OptimizationContainer, - ::Type{C}, - names::Vector{String}, - time_steps::UnitRange{Int}, - x_disc::NMDTDiscretization, - yh_expr, - x_bounds::Vector{MinMax}, - y_bounds::Vector{MinMax}, - meta::String; -) where {C <: IS.InfrastructureSystemsComponent} - bx_y_expr = _binary_continuous_product!( - container, C, names, time_steps, - x_disc, yh_expr, 0.0, 1.0, - config.depth, meta, - ) - dz = _residual_product!( - container, C, names, time_steps, - x_disc, yh_expr, 1.0, config.depth, meta; - ) - - return _assemble_product!( - container, C, names, time_steps, - [bx_y_expr], dz, - x_disc, yh_expr, x_bounds, y_bounds, - meta; result_type = BilinearProductExpression, - ) -end - -""" - _add_bilinear_approx!(config::NMDTBilinearConfig, container, C, names, time_steps, x_var, y_var, x_bounds, y_bounds, meta) - -Approximate x·y using the NMDT method from raw variable inputs. - -Discretizes x via `_discretize!` and normalizes y via `_normed_variable!`, then -delegates to the pre-discretized overload. - -# Arguments -- `config::NMDTBilinearConfig`: configuration for the NMDT bilinear approximation -- `container::OptimizationContainer`: the optimization container -- `::Type{C}`: component type -- `names::Vector{String}`: component names -- `time_steps::UnitRange{Int}`: time periods -- `x_var`: container of x variables indexed by (name, t) -- `y_var`: container of y variables indexed by (name, t) -- `x_bounds::Vector{MinMax}`: per-name lower and upper bounds of x -- `y_bounds::Vector{MinMax}`: per-name lower and upper bounds of y -- `meta::String`: identifier encoding the original variable type being approximated -""" -function _add_bilinear_approx!( - config::NMDTBilinearConfig, - container::OptimizationContainer, - ::Type{C}, - names::Vector{String}, - time_steps::UnitRange{Int}, - x_var, - y_var, - x_bounds::Vector{MinMax}, - y_bounds::Vector{MinMax}, - meta::String, -) where {C <: IS.InfrastructureSystemsComponent} - x_disc = _discretize!( - container, - C, - names, - time_steps, - x_var, - x_bounds, - config.depth, - meta * "_x", - ) - yh_expr = - _normed_variable!(container, C, names, time_steps, y_var, y_bounds, meta * "_y") - return _add_bilinear_approx!( - config, - container, - C, - names, - time_steps, - x_disc, - yh_expr, - x_bounds, - y_bounds, - meta, - ) -end diff --git a/src/bilinear_approximations/no_approx.jl b/src/bilinear_approximations/no_approx.jl deleted file mode 100644 index a6fd81fe..00000000 --- a/src/bilinear_approximations/no_approx.jl +++ /dev/null @@ -1,100 +0,0 @@ -# No-op bilinear approximation: returns exact x·y as a QuadExpr. -# For NLP-capable solvers or testing purposes. - -"No-op bilinear config: returns exact x·y as a QuadExpr." -struct NoBilinearApproxConfig <: BilinearApproxConfig end - -""" - _add_bilinear_approx!(::NoBilinearApproxConfig, container, C, names, time_steps, x_var, y_var, x_bounds, y_bounds, meta) - -No-op bilinear approximation: returns exact x·y as a QuadExpr. - -# Arguments -- `::NoBilinearApproxConfig`: no-op configuration (no fields) -- `container::OptimizationContainer`: the optimization container -- `::Type{C}`: component type -- `names::Vector{String}`: component names -- `time_steps::UnitRange{Int}`: time periods -- `x_var`: container of x variables indexed by (name, t) -- `y_var`: container of y variables indexed by (name, t) -- `x_bounds::Vector{MinMax}`: per-name lower and upper bounds of x domain -- `y_bounds::Vector{MinMax}`: per-name lower and upper bounds of y domain -- `meta::String`: variable type identifier for the approximation -""" -function _add_bilinear_approx!( - ::NoBilinearApproxConfig, - container::OptimizationContainer, - ::Type{C}, - names::Vector{String}, - time_steps::UnitRange{Int}, - x_var, - y_var, - x_bounds::Vector{MinMax}, - y_bounds::Vector{MinMax}, - meta::String, -) where {C <: IS.InfrastructureSystemsComponent} - result_expr = add_expression_container!( - container, - BilinearProductExpression, - C, - names, - time_steps; - meta, - expr_type = JuMP.QuadExpr, - ) - for name in names, t in time_steps - result_expr[name, t] = x_var[name, t] * y_var[name, t] - end - return result_expr -end - -""" - _add_bilinear_approx!(::NoBilinearApproxConfig, container, C, names, time_steps, xsq, ysq, x_var, y_var, x_bounds, y_bounds, meta) - -Precomputed-form no-op bilinear approximation: returns exact x·y as a QuadExpr. -`xsq` and `ysq` are accepted for signature parity with the precomputed-form -dispatches of `Bin2Config` and `HybSConfig` (so a caller can swap configs -without changing the call site) but are unused here. - -# Arguments -- `::NoBilinearApproxConfig`: no-op configuration (no fields) -- `container::OptimizationContainer`: the optimization container -- `::Type{C}`: component type -- `names::Vector{String}`: component names -- `time_steps::UnitRange{Int}`: time periods -- `xsq`: precomputed x² container (ignored) -- `ysq`: precomputed y² container (ignored) -- `x_var`: container of x variables indexed by (name, t) -- `y_var`: container of y variables indexed by (name, t) -- `x_bounds::Vector{MinMax}`: per-component bounds on x -- `y_bounds::Vector{MinMax}`: per-component bounds on y -- `meta::String`: variable type identifier for the approximation -""" -function _add_bilinear_approx!( - ::NoBilinearApproxConfig, - container::OptimizationContainer, - ::Type{C}, - names::Vector{String}, - time_steps::UnitRange{Int}, - xsq, - ysq, - x_var, - y_var, - x_bounds::Vector{MinMax}, - y_bounds::Vector{MinMax}, - meta::String, -) where {C <: IS.InfrastructureSystemsComponent} - result_expr = add_expression_container!( - container, - BilinearProductExpression, - C, - names, - time_steps; - meta, - expr_type = JuMP.QuadExpr, - ) - for name in names, t in time_steps - result_expr[name, t] = x_var[name, t] * y_var[name, t] - end - return result_expr -end diff --git a/src/quadratic_approximations/common.jl b/src/quadratic_approximations/common.jl deleted file mode 100644 index be37adfa..00000000 --- a/src/quadratic_approximations/common.jl +++ /dev/null @@ -1,54 +0,0 @@ -"Expression container for the normalized variable xh = (x − x_min) / (x_max − x_min) ∈ [0,1]." -struct NormedVariableExpression <: ExpressionType end - -"Expression container for quadratic (x²) approximation results." -struct QuadraticExpression <: ExpressionType end - -# --- Quadratic approximation config hierarchy --- - -"Abstract supertype for quadratic approximation method configurations." -abstract type QuadraticApproxConfig end - -""" - _normed_variable!(container, C, names, time_steps, x_var, bounds, meta) - -Create an affine expression for the normalized variable xh = (x − x_min) / (x_max − x_min) ∈ [0,1]. - -Stores results in a `NormedVariableExpression` expression container. - -# Arguments -- `container::OptimizationContainer`: the optimization container -- `::Type{C}`: component type -- `names::Vector{String}`: component names -- `time_steps::UnitRange{Int}`: time periods -- `x_var`: container of variables indexed by (name, t) -- `bounds::Vector{MinMax}`: per-name lower and upper bounds of x domain -- `meta::String`: identifier encoding the original variable type being approximated -""" -function _normed_variable!( - container::OptimizationContainer, - ::Type{C}, - names::Vector{String}, - time_steps::UnitRange{Int}, - x_var, - bounds::Vector{MinMax}, - meta::String, -) where {C <: IS.InfrastructureSystemsComponent} - result_expr = add_expression_container!( - container, - NormedVariableExpression, - C, - names, - time_steps; - meta, - ) - - for (i, name) in enumerate(names), t in time_steps - b = bounds[i] - IS.@assert_op b.max > b.min - lx = b.max - b.min - result = result_expr[name, t] = JuMP.AffExpr(0.0) - add_linear_to_jump_expression!(result, x_var[name, t], 1.0 / lx, -b.min / lx) - end - return result_expr -end diff --git a/src/quadratic_approximations/epigraph.jl b/src/quadratic_approximations/epigraph.jl deleted file mode 100644 index 2b180b6c..00000000 --- a/src/quadratic_approximations/epigraph.jl +++ /dev/null @@ -1,193 +0,0 @@ -# Epigraph (Q^{L1}) LP-only lower bound for x² using tangent-line cuts. -# Pure LP — zero binary variables. Creates a variable z ≥ x² (approximately) -# bounded from below by supporting hyperplanes of the parabola. -# Reference: Beach, Burlacu, Hager, Hildebrand (2024), Q^{L1} relaxation. - -"Expression container for epigraph quadratic approximation results." -struct EpigraphExpression <: ExpressionType end - -"Variable representing a lower-bounded approximation of x² in epigraph relaxation." -struct EpigraphVariable <: VariableType end -"Tangent-line lower-bound constraints in epigraph relaxation." -struct EpigraphTangentConstraint <: ConstraintType end -"Tangent-line lower-bound expression fL used in the epigraph formulation." -struct EpigraphTangentExpression <: ExpressionType end - -""" -Config for epigraph (Q^{L1}) LP-only lower-bound quadratic approximation. - -# Fields -- `depth::Int`: number of tangent-line breakpoints (2^depth + 1 tangent lines); pure LP, zero binary variables -""" -struct EpigraphQuadConfig <: QuadraticApproxConfig - depth::Int -end - -""" - _add_quadratic_approx!(::EpigraphQuadConfig, container, C, names, time_steps, x_var, bounds, meta) - -Create a variable z that lower-bounds x² using tangent-line cuts (Q^{L1} relaxation). - -For each (name, t), creates a variable z and adds 2^depth + 1 tangent-line -constraints of the form `z ≥ 2·aₖ·x − aₖ²` at uniformly spaced breakpoints -aₖ = x_min + k·Δ/2^depth for k = 0,…,2^depth. Pure LP — zero binary variables. - -Stores affine expressions that lower-bound x² in an `EpigraphExpression` expression container. - -The maximum underestimation gap between the tangent envelope and x² is -Δ²·2^{−2·depth−2} where Δ = x_max − x_min. - -# Arguments -- `config::EpigraphQuadConfig`: configuration with `depth` field controlling the number of tangent-line breakpoints (2^depth + 1 tangent lines) -- `container::OptimizationContainer`: the optimization container -- `::Type{C}`: component type -- `names::Vector{String}`: component names -- `time_steps::UnitRange{Int}`: time periods -- `x_var`: container of variables indexed by (name, t) -- `bounds::Vector{MinMax}`: per-name lower and upper bounds of x domain -- `meta::String`: variable type identifier for the approximated variable -""" -function _add_quadratic_approx!( - config::EpigraphQuadConfig, - container::OptimizationContainer, - ::Type{C}, - names::Vector{String}, - time_steps::UnitRange{Int}, - x_var, - bounds::Vector{MinMax}, - meta::String, -) where {C <: IS.InfrastructureSystemsComponent} - IS.@assert_op config.depth >= 1 - jump_model = get_jump_model(container) - g_levels = 0:(config.depth) - - z_var = add_variable_container!( - container, - EpigraphVariable, - C, - names, - time_steps; - meta, - ) - g_var = add_variable_container!( - container, - SawtoothAuxVariable, - C, - names, - g_levels, - time_steps; - meta, - ) - lp_cons = add_constraints_container!( - container, - SawtoothLPConstraint, - C, - names, - 1:2, - time_steps; - meta, - ) - link_cons = add_constraints_container!( - container, - SawtoothLinkingConstraint, - C, - names, - time_steps; - meta, - ) - fL_expr = add_expression_container!( - container, - EpigraphTangentExpression, - C, - names, - time_steps; - meta, - ) - tangent_cons = add_constraints_container!( - container, - EpigraphTangentConstraint, - C, - names, - 1:(config.depth + 2), - time_steps; - sparse = true, - meta, - ) - result_expr = add_expression_container!( - container, - EpigraphExpression, - C, - names, - time_steps; - meta, - ) - - for (i, name) in enumerate(names), t in time_steps - b = bounds[i] - IS.@assert_op b.max > b.min - delta = b.max - b.min - z_ub = max(b.min^2, b.max^2) - x = x_var[name, t] - - # Auxiliary variables g_0,...,g_L ∈ [0, 1] - for j in g_levels - g_var[name, j, t] = JuMP.@variable( - jump_model, - base_name = "SawtoothAux_$(C)_{$(name), $(j), $(t)}", - lower_bound = 0.0, - upper_bound = 1.0, - ) - end - g0 = g_var[name, 0, t] - - # Linking constraint: g_0 = (x - x_min) / Δ - link_cons[name, t] = JuMP.@constraint( - jump_model, - g0 == (x - b.min) / delta, - ) - - # T^L constraints for j = 1,...,L - for j in 1:(config.depth) - g_prev = g_var[name, j - 1, t] - g_curr = g_var[name, j, t] - - # g_j ≤ 2 g_{j-1} - lp_cons[name, 1, t] = JuMP.@constraint(jump_model, g_curr <= 2.0 * g_prev) - # g_j ≤ 2(1 - g_{j-1}) - lp_cons[name, 2, t] = - JuMP.@constraint(jump_model, g_curr <= 2.0 * (1.0 - g_prev)) - end - - # Create the epigraph variable (bounded from below by tangent cuts) - z = - z_var[name, t] = JuMP.@variable( - jump_model, - base_name = "EpigraphVar_$(C)_{$(name), $(t)}", - lower_bound = 0.0, - upper_bound = z_ub, - ) - - fL = fL_expr[name, t] = JuMP.AffExpr(0.0) - for j in 1:(config.depth) - add_proportional_to_jump_expression!( - fL, - g_var[name, j, t], - delta * delta * 2.0^(-2j), - ) - tangent_cons[(name, j + 1, t)] = JuMP.@constraint( - jump_model, - z >= - b.min * (2 * delta * g0 + b.min) - fL + delta^2 * (g0 - 2.0^(-2j - 2)) - ) - end - tangent_cons[name, 1, t] = JuMP.@constraint(jump_model, z >= 0) - tangent_cons[name, config.depth + 1, t] = JuMP.@constraint( - jump_model, - z >= 2.0 * b.min - 1.0 + 2.0 * delta * g0 - ) - - result_expr[name, t] = JuMP.AffExpr(0.0, z => 1.0) - end - - return result_expr -end diff --git a/src/quadratic_approximations/incremental.jl b/src/quadratic_approximations/incremental.jl deleted file mode 100644 index 527256ca..00000000 --- a/src/quadratic_approximations/incremental.jl +++ /dev/null @@ -1,244 +0,0 @@ -# Incremental piecewise linear (PWL) formulation. -# -# This implements the incremental method for PWL approximation using δ (interpolation) -# and z (binary ordering) variables. Retained for downstream compatibility (POM HVDC models). -# -# The same mathematical problem (PWL approximation of nonlinear functions) is also solved by -# `_add_sos2_quadratic_approx!` (convex combination + SOS2) and -# `_add_sawtooth_quadratic_approx!` (sawtooth relaxation), which use different formulations. - -""" - add_sparse_pwl_interpolation_variables!(container, devices, ::T, model, num_segments = DEFAULT_INTERPOLATION_LENGTH) - -Add piecewise linear interpolation variables to an optimization container. - -This function creates the necessary variables for piecewise linear (PWL) approximation in optimization models. -It adds either continuous interpolation variables (δ) or binary interpolation variables (z) depending on the -variable type `T`. These variables are used in the incremental method for PWL approximation where: - -- **Interpolation variables (δ)**: Continuous variables ∈ [0,1] that represent weights for each segment -- **Binary interpolation variables (z)**: Binary variables that enforce ordering constraints in incremental method - -The function creates a 3-dimensional variable structure indexed by (device_name, segment_index, time_step). -For binary variables, the number of variables is one less than for continuous variables since they control -transitions between segments. - -# Arguments -- `container::OptimizationContainer`: The optimization container to add variables to -- `devices`: Collection of devices for which to create PWL variables -- `::T`: Type parameter specifying the variable type (InterpolationVariableType or BinaryInterpolationVariableType) -- `model::DeviceModel{U, V}`: Device model containing formulation information for bounds -- `num_segments::Int`: Number of linear segments in the PWL approximation (default: DEFAULT_INTERPOLATION_LENGTH) - -# Type Parameters -- `T <: Union{InterpolationVariableType, BinaryInterpolationVariableType}`: Variable type to create -- `U <: IS.InfrastructureSystemsComponent`: Component type for devices -- `V <: AbstractDeviceFormulation`: Device formulation type for bounds - -# Notes -- Binary variables have `num_segments - 1` variables (control transitions between segments) -- Continuous variables have `num_segments` variables (one per segment) -- Variable bounds are set based on the device formulation if available -- Variables are created for all devices and time steps in the optimization horizon - -# See Also -- `_add_generic_incremental_interpolation_constraint!`: Function that uses these variables in constraints -""" -function add_sparse_pwl_interpolation_variables!( - container::OptimizationContainer, - ::Type{T}, - devices, - model::DeviceModel{U, V}, - num_segments = DEFAULT_INTERPOLATION_LENGTH, -) where { - T <: Union{InterpolationVariableType, BinaryInterpolationVariableType}, - U <: IS.InfrastructureSystemsComponent, - V <: AbstractDeviceFormulation, -} - # TODO: Implement approach for deciding segment length - # Extract time steps from the optimization container - time_steps = get_time_steps(container) - - # Create variable container using lazy initialization - var_container = lazy_container_addition!(container, T, U) - # Determine if this variable type should be binary based on type, component, and formulation - binary_flag = get_variable_binary(T, U, V) - # Calculate number of segments based on variable type: - # - Binary variables: (num_segments - 1) to control transitions between segments - # - Continuous variables: num_segments (one per segment) - len_segs = binary_flag ? (num_segments - 1) : num_segments - - # Iterate over all devices to create PWL variables - for d in devices - name = get_name(d) - # Create variables for each time step - for t in time_steps - # Pre-allocate array to store variable references for this device and time step - pwlvars = Array{JuMP.VariableRef}(undef, len_segs) - - # Create individual PWL variables for each segment - for i in 1:len_segs - # Create JuMP variable with descriptive name and store in both arrays - pwlvars[i] = - var_container[(name, i, t)] = JuMP.@variable( - get_jump_model(container), - base_name = "$(T)_$(name)_{pwl_$(i), $(t)}", # Descriptive variable name - binary = binary_flag # Set as binary if this is a binary variable type - ) - - # Set upper bound if specified by the device formulation - ub = get_variable_upper_bound(T, d, V) - ub !== nothing && JuMP.set_upper_bound(var_container[name, i, t], ub) - - # Set lower bound if specified by the device formulation - lb = get_variable_lower_bound(T, d, V) - lb !== nothing && JuMP.set_lower_bound(var_container[name, i, t], lb) - end - end - end - return -end - -""" - _add_generic_incremental_interpolation_constraint!(container, ::R, ::S, ::T, ::U, ::V, devices, dic_var_bkpts, dic_function_bkpts; meta) - -Add incremental piecewise linear interpolation constraints to an optimization container. - -This function implements the incremental method for piecewise linear approximation in optimization models. -It creates constraints that relate the original variable (x) to its piecewise linear approximation (y = f(x)) -using interpolation variables (δ) and binary variables (z) to ensure proper ordering. - -The incremental method represents each segment of the PWL function as: -- x = x₁ + Σᵢ δᵢ(xᵢ₊₁ - xᵢ) where δᵢ ∈ [0,1] -- y = y₁ + Σᵢ δᵢ(yᵢ₊₁ - yᵢ) where yᵢ = f(xᵢ) - -Binary variables z ensure the incremental property: δᵢ₊₁ ≤ zᵢ ≤ δᵢ for adjacent segments. - -# Arguments -- `container::OptimizationContainer`: The optimization container to add constraints to -- `::R`: Type parameter for the original variable (x) -- `::S`: Type parameter for the approximated variable (y = f(x)) -- `::T`: Type parameter for the interpolation variables (δ) -- `::U`: Type parameter for the binary interpolation variables (z) -- `::V`: Type parameter for the constraint type -- `devices::IS.FlattenIteratorWrapper{W}`: Collection of devices to apply constraints to -- `dic_var_bkpts::Dict{String, Vector{Float64}}`: Breakpoints in the domain (x-coordinates) for each device -- `dic_function_bkpts::Dict{String, Vector{Float64}}`: Function values at breakpoints (y-coordinates) for each device -- `meta`: Metadata for constraint naming (default: empty) - -# Type Parameters -- `R <: VariableType`: Original variable type -- `S <: VariableType`: Approximated variable type -- `T <: VariableType`: Interpolation variable type -- `U <: VariableType`: Binary interpolation variable type -- `V <: ConstraintType`: Constraint type -- `W <: IS.InfrastructureSystemsComponent`: Component type for devices - -# Notes -- Creates two types of constraints: variable interpolation and function interpolation -- Adds ordering constraints for binary variables to ensure incremental property -- All constraints are applied for each device and time step -""" -function _add_generic_incremental_interpolation_constraint!( - container::OptimizationContainer, - ::Type{R}, # original var : x - ::Type{S}, # approximated var : y = f(x) - ::Type{T}, # interpolation var : δ - ::Type{U}, # binary interpolation var : z - ::Type{V}, # constraint - devices::IS.FlattenIteratorWrapper{W}, - dic_var_bkpts::Dict{String, Vector{Float64}}, - dic_function_bkpts::Dict{String, Vector{Float64}}; - meta = CONTAINER_KEY_EMPTY_META, -) where { - R <: VariableType, - S <: VariableType, - T <: VariableType, - U <: VariableType, - V <: ConstraintType, - W <: IS.InfrastructureSystemsComponent, -} - # Extract time steps and device names for constraint indexing - time_steps = get_time_steps(container) - names = [get_name(d) for d in devices] - JuMPmodel = get_jump_model(container) - - # Retrieve all required variables from the optimization container - # Retrieve original variable for DCVoltage from the Bus - if R <: DCVoltage - # workaround for the fact that we can't write PSY.DCBus. - x_var = get_variable(container, R, component_for_hvdc_interpolation(nothing)) - else - x_var = get_variable(container, R, W) # Original variable (domain of function) - end - y_var = get_variable(container, S, W) # Approximated variable (range of function) - δ_var = get_variable(container, T, W) # Interpolation variables (weights for segments) - z_var = get_variable(container, U, W) # Binary variables (ordering constraints) - - # Create containers for the two main constraint types - # Container for variable interpolation constraints: x = x₁ + Σᵢ δᵢ(xᵢ₊₁ - xᵢ) - const_container_var = add_constraints_container!( - container, - V, - W, - names, - time_steps; - meta = "$(meta)pwl_variable", - ) - - # Container for function interpolation constraints: y = y₁ + Σᵢ δᵢ(yᵢ₊₁ - yᵢ) - const_container_function = add_constraints_container!( - container, - V, - W, - names, - time_steps; - meta = "$(meta)pwl_function", - ) - - # Iterate over all devices to add constraints for each device and time step - for d in devices - name = get_name(d) - # Get proper name for x variable (if is DCVoltage or not) - x_name = (R <: DCVoltage) ? get_name(get_dc_bus(d)) : name - var_bkpts = dic_var_bkpts[name] # Breakpoints in domain (x-values) - function_bkpts = dic_function_bkpts[name] # Function values at breakpoints (y-values) - num_segments = length(var_bkpts) - 1 # Number of linear segments - - for t in time_steps - # Variable interpolation constraint: x = x₁ + Σᵢ δᵢ(xᵢ₊₁ - xᵢ) - # This ensures the original variable is expressed as a convex combination - # of breakpoint intervals weighted by interpolation variables - const_container_var[name, t] = JuMP.@constraint( - JuMPmodel, - x_var[x_name, t] == - var_bkpts[1] + sum( - δ_var[name, i, t] * (var_bkpts[i + 1] - var_bkpts[i]) for - i in 1:num_segments - ) - ) - - # Function interpolation constraint: y = y₁ + Σᵢ δᵢ(yᵢ₊₁ - yᵢ) - # This defines the piecewise linear approximation of the function - const_container_function[name, t] = JuMP.@constraint( - JuMPmodel, - y_var[name, t] == - function_bkpts[1] + sum( - δ_var[name, i, t] * (function_bkpts[i + 1] - function_bkpts[i]) for - i in 1:num_segments - ) - ) - - # Incremental ordering constraints using binary variables (SOS2) - # These ensure that δᵢ₊₁ ≤ zᵢ ≤ δᵢ, which maintains the incremental property: - # segments must be filled in order (δ₁ before δ₂, δ₂ before δ₃, etc.) - for i in 1:(num_segments - 1) - # z[i] must be >= δ[i+1]: can't activate later segment without current one - JuMP.@constraint(JuMPmodel, z_var[name, i, t] >= δ_var[name, i + 1, t]) - # z[i] must be <= δ[i]: can't be more activated than current segment - JuMP.@constraint(JuMPmodel, z_var[name, i, t] <= δ_var[name, i, t]) - end - end - end - return -end diff --git a/src/quadratic_approximations/manual_sos2.jl b/src/quadratic_approximations/manual_sos2.jl deleted file mode 100644 index 0275424a..00000000 --- a/src/quadratic_approximations/manual_sos2.jl +++ /dev/null @@ -1,235 +0,0 @@ -# SOS2-based piecewise linear approximation of x² for use in constraints. -# Uses manually-implemented SOS2 adjacency via binary variables and linear constraints. - -"Binary segment-selection variables (z) for manual SOS2 quadratic approximation." -struct ManualSOS2BinaryVariable <: SparseVariableType end -"Ensures exactly one segment is active (∑zⱼ = 1) in manual SOS2 quadratic approximation." -struct ManualSOS2SegmentSelectionConstraint <: ConstraintType end -"Expression for the segment selection sum Σ z_j in manual SOS2 quadratic approximation." -struct ManualSOS2SegmentSelectionExpression <: ExpressionType end -"Links active segment to lambda variables." -struct ManualSOS2AdjacencyConstraint <: ConstraintType end - -""" -Config for manual binary-variable SOS2 quadratic approximation. - -# Fields -- `depth::Int`: number of PWL segments (breakpoints = depth + 1) -- `pwmcc_segments::Int`: number of piecewise McCormick cut partitions; 0 to disable (default 4) -""" -struct ManualSOS2QuadConfig <: QuadraticApproxConfig - depth::Int - pwmcc_segments::Int -end -ManualSOS2QuadConfig(depth::Int) = ManualSOS2QuadConfig(depth, 4) - -""" - _add_quadratic_approx!(config::ManualSOS2QuadConfig, container, C, names, time_steps, x_var, bounds, meta) - -Approximate x² using a piecewise linear function with manually-implemented SOS2 constraints. - -Creates lambda (λ) variables representing convex combination weights over breakpoints, -adds linking, normalization, and manual adjacency constraints using binary variables, -and stores affine expressions approximating x² in a `QuadraticExpression` -expression container. - -# Arguments -- `config::ManualSOS2QuadConfig`: configuration with `depth` (number of PWL segments) and `pwmcc_segments` (PWMCC cut partitions; 0 to disable, default 4) -- `container::OptimizationContainer`: the optimization container -- `::Type{C}`: component type -- `names::Vector{String}`: component names -- `time_steps::UnitRange{Int}`: time periods -- `x_var`: container of variables indexed by (name, t) -- `bounds::Vector{MinMax}`: per-name lower and upper bounds of x domain -- `meta::String`: variable type identifier for the approximation (allows multiple approximations per component type) -""" -function _add_quadratic_approx!( - config::ManualSOS2QuadConfig, - container::OptimizationContainer, - ::Type{C}, - names::Vector{String}, - time_steps::UnitRange{Int}, - x_var, - bounds::Vector{MinMax}, - meta::String, -) where {C <: IS.InfrastructureSystemsComponent} - x_bkpts, x_sq_bkpts = - _get_breakpoints_for_pwl_function( - 0.0, - 1.0, - _square; - num_segments = config.depth, - ) - n_points = config.depth + 1 - n_bins = n_points - 1 - jump_model = get_jump_model(container) - - # Create all containers upfront - lambda_container = add_variable_container!( - container, - QuadraticVariable, - C, - names, - 1:n_points, - time_steps; - meta, - ) - z_container = add_variable_container!( - container, - ManualSOS2BinaryVariable, - C, - names, - 1:n_bins, - time_steps; - meta, - ) - link_cons = add_constraints_container!( - container, - SOS2LinkingConstraint, - C, - names, - time_steps; - meta, - ) - link_expr = add_expression_container!( - container, - SOS2LinkingExpression, - C, - names, - time_steps; - meta, - ) - norm_cons = add_constraints_container!( - container, - SOS2NormConstraint, - C, - names, - time_steps; - meta, - ) - norm_expr = add_expression_container!( - container, - SOS2NormExpression, - C, - names, - time_steps; - meta, - ) - seg_cons = add_constraints_container!( - container, - ManualSOS2SegmentSelectionConstraint, - C, - names, - time_steps; - meta, - ) - seg_expr = add_expression_container!( - container, - ManualSOS2SegmentSelectionExpression, - C, - names, - time_steps; - meta, - ) - adj_cons = add_constraints_container!( - container, - ManualSOS2AdjacencyConstraint, - C, - names, - 1:n_points, - time_steps; - meta, - ) - result_expr = add_expression_container!( - container, - QuadraticExpression, - C, - names, - time_steps; - meta, - ) - - for (i, name) in enumerate(names), t in time_steps - b = bounds[i] - IS.@assert_op b.max > b.min - lx = b.max - b.min - x = x_var[name, t] - - # Create lambda variables: λ_i ∈ [0, 1] - lambda = Vector{JuMP.VariableRef}(undef, n_points) - for i in 1:n_points - lambda[i] = - lambda_container[name, i, t] = JuMP.@variable( - jump_model, - base_name = "QuadraticVariable_$(C)_{$(name), pwl_$(i), $(t)}", - lower_bound = 0.0, - upper_bound = 1.0, - ) - end - - # x = Σ λ_i * x_i - link = link_expr[name, t] = JuMP.AffExpr(0.0) - for i in eachindex(x_bkpts) - add_proportional_to_jump_expression!(link, lambda[i], x_bkpts[i]) - end - link_cons[name, t] = JuMP.@constraint(jump_model, (x - b.min) / lx == link) - - # Σ λ_i = 1 - norm = norm_expr[name, t] = JuMP.AffExpr(0.0) - for l in lambda - add_proportional_to_jump_expression!(norm, l, 1.0) - end - norm_cons[name, t] = JuMP.@constraint(jump_model, norm == 1.0) - - # Create binary segment-selection variables z_j - z_vars = Vector{JuMP.VariableRef}(undef, n_bins) - for j in 1:n_bins - z_vars[j] = - z_container[name, j, t] = JuMP.@variable( - jump_model, - base_name = "ManualSOS2Binary_$(C)_{$(name), $(j), $(t)}", - binary = true, - ) - end - - # Σ z_j = 1 (segment selection) - seg = seg_expr[name, t] = JuMP.AffExpr(0.0) - for z in z_vars - add_proportional_to_jump_expression!(seg, z, 1.0) - end - seg_cons[name, t] = JuMP.@constraint(jump_model, seg == 1) - - # Adjacency constraints: λ_i ≤ z_{i-1} + z_i (with boundary cases) - # λ_1 ≤ z_1 - adj_cons[name, 1, t] = JuMP.@constraint(jump_model, lambda[1] <= z_vars[1]) - # λ_i ≤ z_{i-1} + z_i for i = 2..n-1 - for i in 2:(n_points - 1) - adj_cons[name, i + 1, t] = - JuMP.@constraint(jump_model, lambda[i] <= z_vars[i - 1] + z_vars[i]) - end - # λ_n ≤ z_{n-1} - adj_cons[name, n_points, t] = - JuMP.@constraint(jump_model, lambda[n_points] <= z_vars[n_bins]) - - # Build x̂² = Σ λ_i * x_i² as an affine expression - x_hat_sq = JuMP.AffExpr(0.0) - for i in 1:n_points - add_proportional_to_jump_expression!(x_hat_sq, lambda[i], x_sq_bkpts[i]) - end - x_sq = JuMP.AffExpr(0.0) - add_proportional_to_jump_expression!(x_sq, x_hat_sq, lx * lx) - add_proportional_to_jump_expression!(x_sq, x, 2 * b.min) - add_constant_to_jump_expression!(x_sq, -b.min * b.min) - result_expr[name, t] = x_sq - end - - if config.pwmcc_segments > 0 - _add_pwmcc_concave_cuts!( - container, C, names, time_steps, - x_var, result_expr, bounds, - config.pwmcc_segments, meta * "_pwmcc", - ) - end - - return result_expr -end diff --git a/src/quadratic_approximations/nmdt.jl b/src/quadratic_approximations/nmdt.jl deleted file mode 100644 index 343dbaec..00000000 --- a/src/quadratic_approximations/nmdt.jl +++ /dev/null @@ -1,229 +0,0 @@ -# NMDT (Normalized Multiparametric Disaggregation Technique) quadratic approximation of x². -# Normalizes x to [0,1], discretizes using L binary variables β₁,…,β_L plus a -# residual δ ∈ [0, 2^{−L}], then replaces each binary-continuous product β_i·xh -# with a McCormick-linearized auxiliary variable. Assembles the result via the -# separable identity x² = (lx·xh + x_min)². Optionally tightens with an epigraph -# lower bound on xh². -# NMDT Reference: Teles, Castro, Matos (2013), Multiparametric disaggregation -# technique for global optimization of polynomial programming problems. - -""" -Config for double-NMDT quadratic approximation. - -# Fields -- `depth::Int`: number of binary discretization levels L -- `epigraph_depth::Int`: LP tightening depth via epigraph Q^{L1} lower bound; 0 to disable (default 3×depth) -""" -struct DNMDTQuadConfig <: QuadraticApproxConfig - depth::Int - epigraph_depth::Int -end -DNMDTQuadConfig(depth::Int) = DNMDTQuadConfig(depth, 3 * depth) - -""" -Config for single-NMDT quadratic approximation. - -# Fields -- `depth::Int`: number of binary discretization levels L -- `epigraph_depth::Int`: LP tightening depth via epigraph Q^{L1} lower bound; 0 to disable (default 3×depth) -""" -struct NMDTQuadConfig <: QuadraticApproxConfig - depth::Int - epigraph_depth::Int -end -NMDTQuadConfig(depth::Int) = NMDTQuadConfig(depth, 3 * depth) - -""" - _add_quadratic_approx!(config::DNMDTQuadConfig, container, C, names, time_steps, x_disc, bounds, meta) - -Approximate x² using the Double NMDT (DNMDT) method from a pre-built discretization. - -Constructs two binary-continuous products (β·xh and β·δ) and delegates to the core -DNMDT assembler, storing results in a `QuadraticExpression` container. Optionally -tightens lower bounds with an epigraph relaxation via `_tighten_lower_bounds!`. - -# Arguments -- `config::DNMDTQuadConfig`: configuration with `depth` (binary discretization levels) and `epigraph_depth` (LP tightening depth; 0 to disable, default 3×depth) -- `container::OptimizationContainer`: the optimization container -- `::Type{C}`: component type -- `names::Vector{String}`: component names -- `time_steps::UnitRange{Int}`: time periods -- `x_disc::NMDTDiscretization`: pre-built discretization for x -- `bounds::Vector{MinMax}`: per-name lower and upper bounds of x domain -- `meta::String`: identifier encoding the original variable type being approximated -""" -function _add_quadratic_approx!( - config::DNMDTQuadConfig, - container::OptimizationContainer, - ::Type{C}, - names::Vector{String}, - time_steps::UnitRange{Int}, - x_disc::NMDTDiscretization, - bounds::Vector{MinMax}, - meta::String, -) where {C <: IS.InfrastructureSystemsComponent} - tighten = config.epigraph_depth > 0 - bx_xh_expr = _binary_continuous_product!( - container, C, names, time_steps, - x_disc, x_disc.norm_expr, 0.0, 1.0, - config.depth, meta * "_bx_xh"; tighten, - ) - bx_dx_expr = _binary_continuous_product!( - container, C, names, time_steps, - x_disc, x_disc.delta_var, 0.0, 2.0^(-config.depth), - config.depth, meta * "_bx_dx"; tighten, - ) - - result_expr = _assemble_dnmdt!( - container, C, names, time_steps, - bx_xh_expr, bx_dx_expr, bx_xh_expr, bx_dx_expr, - x_disc, x_disc, bounds, bounds, - config.depth, meta; tighten, - result_type = QuadraticExpression, - ) - - if config.epigraph_depth > 0 - _tighten_lower_bounds!( - container, C, names, time_steps, - result_expr, x_disc, config.epigraph_depth, meta, - ) - end - - return result_expr -end - -""" - _add_quadratic_approx!(config::DNMDTQuadConfig, container, C, names, time_steps, x_var, bounds, meta) - -Approximate x² using the Double NMDT (DNMDT) method from raw variable inputs. - -Discretizes x via `_discretize!` then delegates to the `NMDTDiscretization` overload. -Stores results in a `QuadraticExpression` container. - -# Arguments -- `config::DNMDTQuadConfig`: configuration with `depth` (binary discretization levels) and `epigraph_depth` (LP tightening depth; 0 to disable, default 3×depth) -- `container::OptimizationContainer`: the optimization container -- `::Type{C}`: component type -- `names::Vector{String}`: component names -- `time_steps::UnitRange{Int}`: time periods -- `x_var`: container of variables indexed by (name, t) -- `bounds::Vector{MinMax}`: per-name lower and upper bounds of x domain -- `meta::String`: identifier encoding the original variable type being approximated -""" -function _add_quadratic_approx!( - config::DNMDTQuadConfig, - container::OptimizationContainer, - ::Type{C}, - names::Vector{String}, - time_steps::UnitRange{Int}, - x_var, - bounds::Vector{MinMax}, - meta::String, -) where {C <: IS.InfrastructureSystemsComponent} - x_disc = _discretize!( - container, C, names, time_steps, - x_var, bounds, config.depth, meta, - ) - - return _add_quadratic_approx!( - config, container, C, names, time_steps, - x_disc, bounds, meta, - ) -end - -""" - _add_quadratic_approx!(config::NMDTQuadConfig, container, C, names, time_steps, x_disc, bounds, meta) - -Approximate x² using the NMDT method from a pre-built discretization. - -Computes the binary-continuous product β·xh and residual product δ·xh, then -assembles x² via `_assemble_product!`. Stores results in a `QuadraticExpression` -container. Optionally tightens lower bounds with an epigraph relaxation. - -# Arguments -- `config::NMDTQuadConfig`: configuration with `depth` (binary discretization levels) and `epigraph_depth` (LP tightening depth; 0 to disable, default 3×depth) -- `container::OptimizationContainer`: the optimization container -- `::Type{C}`: component type -- `names::Vector{String}`: component names -- `time_steps::UnitRange{Int}`: time periods -- `x_disc::NMDTDiscretization`: pre-built discretization for x -- `bounds::Vector{MinMax}`: per-name lower and upper bounds of x domain -- `meta::String`: identifier encoding the original variable type being approximated -""" -function _add_quadratic_approx!( - config::NMDTQuadConfig, - container::OptimizationContainer, - ::Type{C}, - names::Vector{String}, - time_steps::UnitRange{Int}, - x_disc::NMDTDiscretization, - bounds::Vector{MinMax}, - meta::String, -) where {C <: IS.InfrastructureSystemsComponent} - tighten = config.epigraph_depth > 0 - bx_y_expr = _binary_continuous_product!( - container, C, names, time_steps, - x_disc, x_disc.norm_expr, 0.0, 1.0, - config.depth, meta; tighten, - ) - dz = _residual_product!( - container, C, names, time_steps, - x_disc, x_disc.norm_expr, 1.0, - config.depth, meta; tighten, - ) - - result_expr = _assemble_product!( - container, C, names, time_steps, - [bx_y_expr], dz, - x_disc, x_disc, bounds, bounds, - meta; result_type = QuadraticExpression, - ) - - if config.epigraph_depth > 0 - _tighten_lower_bounds!( - container, C, names, time_steps, - result_expr, x_disc, config.epigraph_depth, meta, - ) - end - - return result_expr -end - -""" - _add_quadratic_approx!(config::NMDTQuadConfig, container, C, names, time_steps, x_var, bounds, meta) - -Approximate x² using the NMDT method from raw variable inputs. - -Discretizes x via `_discretize!` then delegates to the `NMDTDiscretization` overload. -Stores results in a `QuadraticExpression` container. - -# Arguments -- `config::NMDTQuadConfig`: configuration with `depth` (binary discretization levels) and `epigraph_depth` (LP tightening depth; 0 to disable, default 3×depth) -- `container::OptimizationContainer`: the optimization container -- `::Type{C}`: component type -- `names::Vector{String}`: component names -- `time_steps::UnitRange{Int}`: time periods -- `x_var`: container of variables indexed by (name, t) -- `bounds::Vector{MinMax}`: per-name lower and upper bounds of x domain -- `meta::String`: identifier encoding the original variable type being approximated -""" -function _add_quadratic_approx!( - config::NMDTQuadConfig, - container::OptimizationContainer, - ::Type{C}, - names::Vector{String}, - time_steps::UnitRange{Int}, - x_var, - bounds::Vector{MinMax}, - meta::String, -) where {C <: IS.InfrastructureSystemsComponent} - x_disc = _discretize!( - container, C, names, time_steps, - x_var, bounds, config.depth, meta, - ) - - return _add_quadratic_approx!( - config, container, C, names, time_steps, - x_disc, bounds, meta, - ) -end diff --git a/src/quadratic_approximations/nmdt_common.jl b/src/quadratic_approximations/nmdt_common.jl deleted file mode 100644 index af35a90c..00000000 --- a/src/quadratic_approximations/nmdt_common.jl +++ /dev/null @@ -1,554 +0,0 @@ -"Binary discretization variables β_i ∈ {0,1} in the NMDT decomposition of xh." -struct NMDTBinaryVariable <: VariableType end -"Residual variable δ ∈ [0, 2^{−L}] capturing the NMDT discretization error." -struct NMDTResidualVariable <: VariableType end -"McCormick linearization variables u_i ≈ β_i · y in NMDT binary-continuous products." -struct NMDTBinaryContinuousProductVariable <: VariableType end -"Variable z ≈ δ · y linearizing the residual-continuous product in NMDT." -struct NMDTResidualProductVariable <: VariableType end - -"Expression container for the NMDT binary discretization: Σ 2^{−i}·β_i + δ ≈ xh." -struct NMDTDiscretizationExpression <: ExpressionType end -"Expression container for the NMDT binary-continuous product: Σ 2^{−i}·u_i ≈ β·y." -struct NMDTBinaryContinuousProductExpression <: ExpressionType end -"Expression container for the final NMDT quadratic approximation result." -struct NMDTResultExpression <: ExpressionType end - -"Constraint enforcing xh = Σ 2^{−i}·β_i + δ in the NMDT discretization." -struct NMDTEDiscretizationConstraint <: ConstraintType end -"McCormick envelope constraints for binary-continuous products u_i ≈ β_i·y in NMDT." -struct NMDTBinaryContinuousProductConstraint <: ConstraintType end -"Epigraph lower-bound tightening constraint on the NMDT quadratic result." -struct NMDTTightenConstraint <: ConstraintType end - -""" -Stores the result of discretizing a normalized variable for use in NMDT products. - -Fields: -- `norm_expr`: affine expression for xh = (x − x_min)/(x_max − x_min) ∈ [0,1] -- `beta_var`: binary variables β_i ∈ {0,1} indexed by (name, i, t) -- `delta_var`: residual variables δ ∈ [0, 2^{−depth}] indexed by (name, t) -""" -struct NMDTDiscretization{NE, BV, DV} - norm_expr::NE - beta_var::BV - delta_var::DV -end - -""" - _discretize!(container, C, names, time_steps, x_var, bounds, depth, meta) - -Discretize the normalized variable xh = (x − x_min)/(x_max − x_min) using L binary variables. - -Creates L binary variables β₁,…,β_L and one residual δ ∈ [0, 2^{−L}] such that -xh = Σᵢ 2^{−i}·β_i + δ. Enforces this via a `NMDTEDiscretizationConstraint` and -returns an `NMDTDiscretization` struct holding all components. - -# Arguments -- `container::OptimizationContainer`: the optimization container -- `::Type{C}`: component type -- `names::Vector{String}`: component names -- `time_steps::UnitRange{Int}`: time periods -- `x_var`: container of variables indexed by (name, t) -- `bounds::Vector{MinMax}`: per-name lower and upper bounds of x domain -- `depth::Int`: number of binary discretization levels L -- `meta::String`: identifier encoding the original variable type being approximated -""" -function _discretize!( - container::OptimizationContainer, - ::Type{C}, - names::Vector{String}, - time_steps::UnitRange{Int}, - x_var, - bounds::Vector{MinMax}, - depth::Int, - meta::String; -) where {C <: IS.InfrastructureSystemsComponent} - IS.@assert_op depth >= 1 - jump_model = get_jump_model(container) - - beta_var = add_variable_container!( - container, - NMDTBinaryVariable, - C, - names, - 1:depth, - time_steps; - meta, - ) - delta_var = add_variable_container!( - container, - NMDTResidualVariable, - C, - names, - time_steps; - meta, - ) - disc_expr = add_expression_container!( - container, - NMDTDiscretizationExpression, - C, - names, - time_steps; - meta, - ) - disc_cons = add_constraints_container!( - container, - NMDTEDiscretizationConstraint, - C, - names, - time_steps; - meta, - ) - - xh_expr = _normed_variable!( - container, C, names, time_steps, - x_var, bounds, meta, - ) - - for name in names, t in time_steps - disc = disc_expr[name, t] = JuMP.AffExpr(0.0) - for i in 1:depth - beta = - beta_var[name, i, t] = JuMP.@variable( - jump_model, - base_name = "NMDTBinary_$(C)_{$(name), $(i), $(t)}", - binary = true - ) - add_proportional_to_jump_expression!(disc, beta, 2.0^(-i)) - end - delta = - delta_var[name, t] = JuMP.@variable( - jump_model, - base_name = "NMDTResidual_$(C)_{$(name), $(t)}", - lower_bound = 0.0, - upper_bound = 2.0^(-depth) - ) - add_proportional_to_jump_expression!(disc, delta, 1.0) - disc_cons[name, t] = JuMP.@constraint( - jump_model, - xh_expr[name, t] == disc - ) - end - - return NMDTDiscretization(xh_expr, beta_var, delta_var) -end - -""" - _binary_continuous_product!(container, C, names, time_steps, bin_disc, cont_var, cont_min, cont_max, depth, meta; tighten) - -Linearize each binary-continuous product β_i·y using McCormick envelopes. - -For each depth level i, creates a variable u_i ≈ β_i·y with bounds [cont_min, cont_max] -and adds 4 McCormick constraints via `_add_mccormick_envelope!`. Assembles the weighted sum -Σᵢ 2^{−i}·u_i into a `NMDTBinaryContinuousProductExpression`. - -# Arguments -- `container::OptimizationContainer`: the optimization container -- `::Type{C}`: component type -- `names::Vector{String}`: component names -- `time_steps::UnitRange{Int}`: time periods -- `bin_disc`: `NMDTDiscretization` providing β_i variables and depth -- `cont_var`: container of continuous variables y indexed by (name, t) -- `cont_min::Float64`: lower bound of y -- `cont_max::Float64`: upper bound of y -- `depth::Int`: number of binary discretization levels -- `meta::String`: identifier encoding the original variable type being approximated -- `tighten::Bool`: if true, omit McCormick lower bounds (for use when a tighter bound is applied elsewhere) -""" -function _binary_continuous_product!( - container::OptimizationContainer, - ::Type{C}, - names::Vector{String}, - time_steps::UnitRange{Int}, - bin_disc, - cont_var, - cont_min::Float64, - cont_max::Float64, - depth::Int, - meta::String; - tighten::Bool = false, -) where {C <: IS.InfrastructureSystemsComponent} - jump_model = get_jump_model(container) - - u_var = add_variable_container!( - container, - NMDTBinaryContinuousProductVariable, - C, - names, - 1:depth, - time_steps; - meta, - ) - u_cons = add_constraints_container!( - container, - NMDTBinaryContinuousProductConstraint, - C, - names, - 1:depth, - 1:4, - time_steps; - meta, - ) - result_expr = add_expression_container!( - container, - NMDTBinaryContinuousProductExpression, - C, - names, - time_steps; - meta, - ) - - for name in names, t in time_steps - result = result_expr[name, t] = JuMP.AffExpr(0.0) - for i in 1:depth - u_i = - u_var[name, i, t] = JuMP.@variable( - jump_model, - base_name = "NMDTBinContProd_$(C)_{$(name), $(i), $(t)}", - lower_bound = cont_min, - upper_bound = cont_max - ) - _add_mccormick_envelope!( - jump_model, u_cons, (name, i, t), - cont_var[name, t], bin_disc.beta_var[name, i, t], u_i, - cont_min, cont_max, 0.0, 1.0; - lower_bounds = !tighten, - ) - add_proportional_to_jump_expression!(result, u_i, 2.0^(-i)) - end - end - - return result_expr -end - -""" - _tighten_lower_bounds!(container, C, names, time_steps, result_expr, x_disc, epigraph_depth, meta) - -Add epigraph lower-bound constraints to tighten an NMDT quadratic approximation. - -Computes an epigraph Q^{L1} lower bound on xh² -and adds a `NMDTTightenConstraint` enforcing `result_expr[name,t] ≥ epi_expr[name,t]` -for each (name, t). This improves the lower bound quality of NMDT without adding binaries. - -# Arguments -- `container::OptimizationContainer`: the optimization container -- `::Type{C}`: component type -- `names::Vector{String}`: component names -- `time_steps::UnitRange{Int}`: time periods -- `result_expr`: expression container for the NMDT quadratic result to be tightened -- `x_disc`: `NMDTDiscretization` for x, providing `norm_expr` and `depth` -- `epigraph_depth::Int`: depth for the epigraph Q^{L1} lower-bound approximation -- `meta::String`: identifier encoding the original variable type being approximated -""" -function _tighten_lower_bounds!( - container::OptimizationContainer, - ::Type{C}, - names::Vector{String}, - time_steps::UnitRange{Int}, - result_expr, - x_disc, - epigraph_depth::Int, - meta::String; -) where {C <: IS.InfrastructureSystemsComponent} - jump_model = get_jump_model(container) - - epi_expr = _add_quadratic_approx!( - EpigraphQuadConfig(epigraph_depth), - container, C, names, time_steps, - x_disc.norm_expr, fill(MinMax((min = 0.0, max = 1.0)), length(names)), - meta * "_epi", - ) - epi_cons = add_constraints_container!( - container, - NMDTTightenConstraint, - C, - names, - time_steps; - meta, - ) - for name in names, t in time_steps - epi_cons[name, t] = JuMP.@constraint( - jump_model, - result_expr[name, t] >= epi_expr[name, t], - ) - end -end - -""" - _residual_product!(container, C, names, time_steps, x_disc, y_var, y_max, meta; tighten) - -Linearize the residual-continuous product z ≈ δ·y using McCormick envelopes. - -Creates a variable z ∈ [0, 2^{−L}·y_max] for each (name, t) and bounds it with -McCormick constraints on (δ, y) where δ ∈ [0, 2^{−L}]. Stores results in a -`NMDTResidualProductVariable` container. - -# Arguments -- `container::OptimizationContainer`: the optimization container -- `::Type{C}`: component type -- `names::Vector{String}`: component names -- `time_steps::UnitRange{Int}`: time periods -- `x_disc`: `NMDTDiscretization` for x, providing `delta_var` and `depth` -- `y_var`: container of continuous variables y indexed by (name, t) -- `y_max::Float64`: upper bound of y (lower bound assumed 0) -- `meta::String`: identifier encoding the original variable type being approximated -- `tighten::Bool`: if true, omit McCormick lower bounds (default: false) -""" -function _residual_product!( - container::OptimizationContainer, - ::Type{C}, - names::Vector{String}, - time_steps::UnitRange{Int}, - x_disc, - y_var, - y_max::Float64, - depth::Int, - meta::String; - tighten::Bool = false, -) where {C <: IS.InfrastructureSystemsComponent} - x_max = 2.0^(-depth) - jump_model = get_jump_model(container) - - z_var = add_variable_container!( - container, - NMDTResidualProductVariable, - C, - names, - time_steps; - meta, - ) - - for name in names, t in time_steps - z_var[name, t] = JuMP.@variable( - jump_model, - base_name = "NMDTResidualProduct_$(C)_{$(name), $(t)}", - lower_bound = 0.0, - upper_bound = x_max * y_max, - ) - end - - _add_mccormick_envelope!( - container, C, names, time_steps, - x_disc.delta_var, y_var, z_var, - 0.0, x_max, 0.0, y_max, - meta; lower_bounds = !tighten, - ) - - return z_var -end - -""" - _assemble_product!(container, C, names, time_steps, terms, dz_var, xh_norm, yh_norm, x_bounds, y_bounds, meta; result_type) - -Reconstruct the bilinear product x·y from normalized NMDT components. - -Applies the affine rescaling: -``` -x·y = lx·ly·zh + lx·y_min·xh + ly·x_min·yh + x_min·y_min -``` -where `zh = Σ terms[name,t] + dz_var[name,t]` collects the binary-continuous and -residual product contributions, lx = x_max − x_min, ly = y_max − y_min. - -Stores results in an expression container of type `result_type`. - -# Arguments -- `container::OptimizationContainer`: the optimization container -- `::Type{C}`: component type -- `names::Vector{String}`: component names -- `time_steps::UnitRange{Int}`: time periods -- `terms`: iterable of expression containers indexed by (name, t) for the binary-continuous products -- `dz_var`: variable container for the residual product δ·y -- `xh_norm`: normed expression container for x -- `yh_norm`: normed expression container for y -- `x_bounds::Vector{MinMax}`: per-name bounds for x -- `y_bounds::Vector{MinMax}`: per-name bounds for y -- `meta::String`: identifier encoding the original variable type being approximated -- `result_type`: expression type to store results in (default: `NMDTResultExpression`) -""" -function _assemble_product!( - container::OptimizationContainer, - ::Type{C}, - names::Vector{String}, - time_steps::UnitRange{Int}, - terms, - dz_var, - xh_expr, - yh_expr, - x_bounds::Vector{MinMax}, - y_bounds::Vector{MinMax}, - meta::String; - result_type = NMDTResultExpression, -) where {C <: IS.InfrastructureSystemsComponent} - result_expr = add_expression_container!( - container, - result_type, - C, - names, - time_steps; - meta, - ) - - for (i, name) in enumerate(names), t in time_steps - xb = x_bounds[i] - yb = y_bounds[i] - IS.@assert_op xb.max > xb.min - IS.@assert_op yb.max > yb.min - lx = xb.max - xb.min - ly = yb.max - yb.min - - result = result_expr[name, t] = JuMP.AffExpr(0.0) - zh = JuMP.AffExpr(0.0) - for term in terms - add_proportional_to_jump_expression!(zh, term[name, t], 1.0) - end - add_proportional_to_jump_expression!(zh, dz_var[name, t], 1.0) - - add_proportional_to_jump_expression!(result, zh, lx * ly) - add_proportional_to_jump_expression!(result, xh_expr[name, t], lx * yb.min) - add_proportional_to_jump_expression!(result, yh_expr[name, t], ly * xb.min) - add_constant_to_jump_expression!(result, xb.min * yb.min) - end - - return result_expr -end - -""" - _assemble_product!(container, C, names, time_steps, terms, dz_var, x_disc::NMDTDiscretization, y_disc::NMDTDiscretization, x_bounds, y_bounds, meta; result_type) - -Convenience overload: extracts `norm_expr` from both discretizations and delegates to the core `_assemble_product!`. -""" -function _assemble_product!( - container::OptimizationContainer, - ::Type{C}, - names::Vector{String}, - time_steps::UnitRange{Int}, - terms, - dz_var, - x_disc::NMDTDiscretization, - y_disc::NMDTDiscretization, - x_bounds::Vector{MinMax}, - y_bounds::Vector{MinMax}, - meta::String; - result_type = NMDTResultExpression, -) where {C <: IS.InfrastructureSystemsComponent} - return _assemble_product!( - container, C, names, time_steps, terms, dz_var, - x_disc.norm_expr, y_disc.norm_expr, - x_bounds, y_bounds, - meta; result_type, - ) -end - -""" - _assemble_product!(container, C, names, time_steps, terms, dz_var, x_disc::NMDTDiscretization, yh_expr, x_bounds, y_bounds, meta; result_type) - -Convenience overload: extracts `norm_expr` from x_disc and delegates to the core `_assemble_product!`. -""" -function _assemble_product!( - container::OptimizationContainer, - ::Type{C}, - names::Vector{String}, - time_steps::UnitRange{Int}, - terms, - dz_var, - x_disc::NMDTDiscretization, - yh_expr, - x_bounds::Vector{MinMax}, - y_bounds::Vector{MinMax}, - meta::String; - result_type = NMDTResultExpression, -) where {C <: IS.InfrastructureSystemsComponent} - return _assemble_product!( - container, C, names, time_steps, terms, dz_var, - x_disc.norm_expr, yh_expr, - x_bounds, y_bounds, - meta; result_type, - ) -end - -""" - _assemble_dnmdt!(container, C, names, time_steps, bx_yh_expr, by_dx_expr, by_xh_expr, bx_dy_expr, x_disc, y_disc, meta; lambda, result_type) - -Core assembler for the DNMDT bilinear approximation of x·y from pre-computed cross products. - -Builds two NMDT product estimates from opposite discretization pairings and combines them: -- z₁ = assemble(bx·yh + by·δx + δx·δy, x_disc, y_disc) -- z₂ = assemble(by·xh + bx·δy + δx·δy, y_disc, x_disc) -- result = λ·z₁ + (1−λ)·z₂ - -The shared residual product δx·δy is computed internally. Stores results in an expression -container of type `result_type`. - -# Arguments -- `container::OptimizationContainer`: the optimization container -- `::Type{C}`: component type -- `names::Vector{String}`: component names -- `time_steps::UnitRange{Int}`: time periods -- `bx_yh_expr`: expression for β_x·yh binary-continuous products -- `by_dx_expr`: expression for β_y·δx binary-continuous products -- `by_xh_expr`: expression for β_y·xh binary-continuous products -- `bx_dy_expr`: expression for β_x·δy binary-continuous products -- `x_disc::NMDTDiscretization`: discretization for x -- `y_disc::NMDTDiscretization`: discretization for y -- `x_bounds::Vector{MinMax}`: per-name bounds for x -- `y_bounds::Vector{MinMax}`: per-name bounds for y -- `depth::Int`: number of binary discretization levels L -- `meta::String`: identifier encoding the original variable type being approximated -- `lambda::Float64`: convex combination weight for the two NMDT estimates (default: `DNMDT_LAMBDA` = 0.5) -- `result_type`: expression type to store results in (default: `NMDTResultExpression`) -""" -function _assemble_dnmdt!( - container::OptimizationContainer, - ::Type{C}, - names::Vector{String}, - time_steps::UnitRange{Int}, - bx_yh_expr, - by_dx_expr, - by_xh_expr, - bx_dy_expr, - x_disc::NMDTDiscretization, - y_disc::NMDTDiscretization, - x_bounds::Vector{MinMax}, - y_bounds::Vector{MinMax}, - depth::Int, - meta::String; - lambda::Float64 = DNMDT_LAMBDA, - result_type::Type = NMDTResultExpression, - tighten::Bool = false, -) where {C <: IS.InfrastructureSystemsComponent} - result_expr = add_expression_container!( - container, - result_type, - C, - names, - time_steps; - meta, - ) - - dz = _residual_product!( - container, C, names, time_steps, - x_disc, y_disc.delta_var, 2.0^(-depth), - depth, meta; tighten, - ) - z1_expr = _assemble_product!( - container, C, names, time_steps, - [bx_yh_expr, by_dx_expr], dz, - x_disc, y_disc, x_bounds, y_bounds, - meta * "_nmdt1", - ) - z2_expr = _assemble_product!( - container, C, names, time_steps, - [by_xh_expr, bx_dy_expr], dz, - y_disc, x_disc, y_bounds, x_bounds, - meta * "_nmdt2", - ) - - for name in names, t in time_steps - result = result_expr[name, t] = JuMP.AffExpr(0.0) - add_proportional_to_jump_expression!(result, z1_expr[name, t], lambda) - add_proportional_to_jump_expression!(result, z2_expr[name, t], 1.0 - lambda) - end - - return result_expr -end diff --git a/src/quadratic_approximations/no_approx.jl b/src/quadratic_approximations/no_approx.jl deleted file mode 100644 index e2d88498..00000000 --- a/src/quadratic_approximations/no_approx.jl +++ /dev/null @@ -1,45 +0,0 @@ -# No-op quadratic approximation: returns exact x² as a QuadExpr. -# For NLP-capable solvers or testing purposes. - -"No-op config: returns exact x² as a QuadExpr (for NLP-capable solvers or testing)." -struct NoQuadApproxConfig <: QuadraticApproxConfig end - -""" - _add_quadratic_approx!(::NoQuadApproxConfig, container, C, names, time_steps, x_var, bounds, meta) - -No-op quadratic approximation: returns exact x² as a QuadExpr. - -# Arguments -- `::NoQuadApproxConfig`: no-op configuration (no fields) -- `container::OptimizationContainer`: the optimization container -- `::Type{C}`: component type -- `names::Vector{String}`: component names -- `time_steps::UnitRange{Int}`: time periods -- `x_var`: container of variables indexed by (name, t) -- `bounds::Vector{MinMax}`: per-name lower and upper bounds of x domain -- `meta::String`: variable type identifier for the approximation -""" -function _add_quadratic_approx!( - ::NoQuadApproxConfig, - container::OptimizationContainer, - ::Type{C}, - names::Vector{String}, - time_steps::UnitRange{Int}, - x_var, - bounds::Vector{MinMax}, - meta::String, -) where {C <: IS.InfrastructureSystemsComponent} - result_expr = add_expression_container!( - container, - QuadraticExpression, - C, - names, - time_steps; - meta, - expr_type = JuMP.QuadExpr, - ) - for name in names, t in time_steps - result_expr[name, t] = x_var[name, t] * x_var[name, t] - end - return result_expr -end diff --git a/src/quadratic_approximations/pwl_utils.jl b/src/quadratic_approximations/pwl_utils.jl deleted file mode 100644 index ab68812d..00000000 --- a/src/quadratic_approximations/pwl_utils.jl +++ /dev/null @@ -1,60 +0,0 @@ -# Shared utilities for piecewise linear approximation methods. - -""" - _get_breakpoints_for_pwl_function(min_val, max_val, f; num_segments = DEFAULT_INTERPOLATION_LENGTH) - -Generate breakpoints for piecewise linear (PWL) approximation of a nonlinear function. - -This function creates equally-spaced breakpoints over the specified domain [min_val, max_val] -and evaluates the given function at each breakpoint to construct a piecewise linear approximation. -The breakpoints are used in optimization problems to linearize nonlinear constraints or objectives. - -# Arguments -- `min_val::Float64`: Minimum value of the domain for the PWL approximation -- `max_val::Float64`: Maximum value of the domain for the PWL approximation -- `f`: Function to be approximated (must be callable with Float64 input) -- `num_segments::Int`: Number of linear segments in the PWL approximation (default: DEFAULT_INTERPOLATION_LENGTH) - -# Returns -- `Tuple{Vector{Float64}, Vector{Float64}}`: A tuple containing: - - `x_bkpts`: Vector of x-coordinates (breakpoints) in the domain - - `y_bkpts`: Vector of y-coordinates (function values at breakpoints) - -# Notes -- The number of breakpoints is `num_segments + 1` -- Breakpoints are equally spaced across the domain -- The first breakpoint is always at `min_val` and the last at `max_val` -""" -function _get_breakpoints_for_pwl_function( - min_val::Float64, - max_val::Float64, - f; - num_segments = DEFAULT_INTERPOLATION_LENGTH, -) - # Calculate total number of breakpoints (one more than segments) - # num_segments is the number of linear segments in the PWL approximation - # num_bkpts is the total number of breakpoints needed for the segments - num_bkpts = num_segments + 1 - - # Calculate step size for equally-spaced breakpoints - step = (max_val - min_val) / num_segments - - # Pre-allocate vectors for breakpoint coordinates - x_bkpts = Vector{Float64}(undef, num_bkpts) # Domain values (x-coordinates) - y_bkpts = Vector{Float64}(undef, num_bkpts) # Function values (y-coordinates) - - # Set the first breakpoint at the minimum domain value - x_bkpts[1] = min_val - y_bkpts[1] = f(min_val) - - # Generate remaining breakpoints by stepping through the domain - for i in 1:num_segments - x_val = min_val + step * i # Calculate x-coordinate of current breakpoint - x_bkpts[i + 1] = x_val - y_bkpts[i + 1] = f(x_val) # Evaluate function at current breakpoint - end - return x_bkpts, y_bkpts -end - -"Helper: returns x² (used as the default function for PWL breakpoint generation)." -_square(x::Float64) = x * x diff --git a/src/quadratic_approximations/pwmcc_cuts.jl b/src/quadratic_approximations/pwmcc_cuts.jl deleted file mode 100644 index a636e6f8..00000000 --- a/src/quadratic_approximations/pwmcc_cuts.jl +++ /dev/null @@ -1,231 +0,0 @@ -# Piecewise McCormick (PWMCC) cuts for concave terms in Bin2 bilinear approximation. -# Adds K local chord upper bounds on the v^2 SoS2 approximation by partitioning -# each concave term's domain into K sub-intervals. -# LP gap shrinks from Delta^2/4 to Delta^2/(4K^2). -# These cuts supplement (do not replace) existing SoS2 constraints. - -"Binary interval selector for piecewise McCormick cuts." -struct PiecewiseMcCormickBinary <: SparseVariableType end - -"Disaggregated variable for piecewise McCormick cuts." -struct PiecewiseMcCormickDisaggregated <: SparseVariableType end - -"Selector sum constraint: sum_k delta_k = 1." -struct PiecewiseMcCormickSelectorSum <: ConstraintType end - -"Disaggregation linking constraint: v = sum_k v^d_k." -struct PiecewiseMcCormickLinking <: ConstraintType end - -"Interval activation lower bound: t_{k-1} * delta_k <= v^d_k." -struct PiecewiseMcCormickIntervalLB <: ConstraintType end - -"Interval activation upper bound: v^d_k <= t_k * delta_k." -struct PiecewiseMcCormickIntervalUB <: ConstraintType end - -"Piecewise McCormick chord upper-bound constraint on v^2 approximation." -struct PiecewiseMcCormickChordUB <: ConstraintType end - -"Piecewise McCormick tangent lower-bound constraint (left endpoint)." -struct PiecewiseMcCormickTangentLBL <: ConstraintType end - -"Piecewise McCormick tangent lower-bound constraint (right endpoint)." -struct PiecewiseMcCormickTangentLBR <: ConstraintType end - -""" - _add_pwmcc_concave_cuts!(container, C, names, time_steps, v_var, q_expr, bounds, K, meta) - -Add piecewise McCormick cuts on a concave term (-v^2) to tighten its SoS2 LP relaxation. - -Partitions each name's [v_min, v_max] into K uniform sub-intervals and adds disaggregated -variables, binary interval selectors, and chord/tangent constraints that cut off -the interior of the SoS2 relaxation polytope. - -# Arguments -- `container::OptimizationContainer`: the optimization container -- `::Type{C}`: component type -- `names::Vector{String}`: component names -- `time_steps::UnitRange{Int}`: time periods -- `v_var`: container of the original variable indexed by (name, t) -- `q_expr`: expression container for the SoS2 approximation of v^2 (indexed by (name, t)) -- `bounds::Vector{MinMax}`: per-name lower and upper bounds of v domain -- `K::Int`: number of sub-intervals (K=2 is the minimal useful choice) -- `meta::String`: unique key prefix, e.g. "pwmcc_x" or "pwmcc_y" -""" -function _add_pwmcc_concave_cuts!( - container::OptimizationContainer, - ::Type{C}, - names::Vector{String}, - time_steps::UnitRange{Int}, - v_var, - q_expr, - bounds::Vector{MinMax}, - K::Int, - meta::String, -) where {C <: IS.InfrastructureSystemsComponent} - IS.@assert_op K >= 1 - - jump_model = get_jump_model(container) - - # Create containers - delta_var = add_variable_container!( - container, - PiecewiseMcCormickBinary, - C, - names, - 1:K, - time_steps; - meta, - ) - vd_var = add_variable_container!( - container, - PiecewiseMcCormickDisaggregated, - C, - names, - 1:K, - time_steps; - meta, - ) - selector_cons = add_constraints_container!( - container, - PiecewiseMcCormickSelectorSum, - C, - names, - time_steps; - meta, - ) - linking_cons = add_constraints_container!( - container, - PiecewiseMcCormickLinking, - C, - names, - time_steps; - meta, - ) - interval_lb_cons = add_constraints_container!( - container, - PiecewiseMcCormickIntervalLB, - C, - names, - 1:K, - time_steps; - meta, - ) - interval_ub_cons = add_constraints_container!( - container, - PiecewiseMcCormickIntervalUB, - C, - names, - 1:K, - time_steps; - meta, - ) - chord_ub_cons = add_constraints_container!( - container, - PiecewiseMcCormickChordUB, - C, - names, - time_steps; - meta, - ) - tangent_lb_l_cons = add_constraints_container!( - container, - PiecewiseMcCormickTangentLBL, - C, - names, - time_steps; - meta, - ) - tangent_lb_r_cons = add_constraints_container!( - container, - PiecewiseMcCormickTangentLBR, - C, - names, - time_steps; - meta, - ) - - delta = Vector{JuMP.VariableRef}(undef, K) - vd = Vector{JuMP.VariableRef}(undef, K) - - for (idx, name) in enumerate(names), t in time_steps - b = bounds[idx] - IS.@assert_op b.min < b.max - v_min = b.min - v_max = b.max - - # Compute breakpoints and derived coefficients for this name - brk = [v_min + k * (v_max - v_min) / K for k in 0:K] - sum_brk = [brk[k] + brk[k + 1] for k in 1:K] - prod_brk = [brk[k] * brk[k + 1] for k in 1:K] - two_brk_l = [2.0 * brk[k] for k in 1:K] - sq_brk_l = [brk[k]^2 for k in 1:K] - two_brk_r = [2.0 * brk[k + 1] for k in 1:K] - sq_brk_r = [brk[k + 1]^2 for k in 1:K] - - v = v_var[name, t] - q = q_expr[name, t] - - for k in 1:K - delta[k] = - delta_var[name, k, t] = JuMP.@variable( - jump_model, - base_name = "PwMcCBin_$(C)_{$(name), $(k), $(t)}", - binary = true, - ) - vd[k] = - vd_var[name, k, t] = JuMP.@variable( - jump_model, - base_name = "PwMcCDis_$(C)_{$(name), $(k), $(t)}", - ) - end - - sel_expr = JuMP.AffExpr(0.0) - for k in 1:K - JuMP.add_to_expression!(sel_expr, delta[k]) - end - selector_cons[name, t] = JuMP.@constraint(jump_model, sel_expr == 1.0) - - link_expr = JuMP.AffExpr(0.0) - for k in 1:K - JuMP.add_to_expression!(link_expr, vd[k]) - end - linking_cons[name, t] = JuMP.@constraint(jump_model, link_expr == v) - - for k in 1:K - interval_lb_cons[name, k, t] = JuMP.@constraint( - jump_model, - brk[k] * delta[k] <= vd[k] - ) - interval_ub_cons[name, k, t] = JuMP.@constraint( - jump_model, - vd[k] <= brk[k + 1] * delta[k] - ) - end - - # Chord upper bound: prevents q from exceeding the local piecewise chord - # of v^2 in the LP relaxation (tightens from global chord to piecewise). - chord_rhs = JuMP.AffExpr(0.0) - for k in 1:K - JuMP.add_to_expression!(chord_rhs, sum_brk[k], vd[k]) - JuMP.add_to_expression!(chord_rhs, -prod_brk[k], delta[k]) - end - chord_ub_cons[name, t] = JuMP.@constraint(jump_model, q <= chord_rhs) - - # Tangent lower bounds from convexity of v^2 at interval endpoints. - tang_l_rhs = JuMP.AffExpr(0.0) - for k in 1:K - JuMP.add_to_expression!(tang_l_rhs, two_brk_l[k], vd[k]) - JuMP.add_to_expression!(tang_l_rhs, -sq_brk_l[k], delta[k]) - end - tangent_lb_l_cons[name, t] = JuMP.@constraint(jump_model, q >= tang_l_rhs) - - tang_r_rhs = JuMP.AffExpr(0.0) - for k in 1:K - JuMP.add_to_expression!(tang_r_rhs, two_brk_r[k], vd[k]) - JuMP.add_to_expression!(tang_r_rhs, -sq_brk_r[k], delta[k]) - end - tangent_lb_r_cons[name, t] = JuMP.@constraint(jump_model, q >= tang_r_rhs) - end - - return -end diff --git a/src/quadratic_approximations/sawtooth.jl b/src/quadratic_approximations/sawtooth.jl deleted file mode 100644 index 0a0d0d2e..00000000 --- a/src/quadratic_approximations/sawtooth.jl +++ /dev/null @@ -1,227 +0,0 @@ -# Sawtooth MIP approximation of x² for use in constraints. -# Uses recursive tooth function compositions with O(log(1/ε)) binary variables. -# Reference: Beach, Burlacu, Hager, Hildebrand (2024). - -"Auxiliary continuous variables (g₀, …, g_L) for sawtooth quadratic approximation." -struct SawtoothAuxVariable <: VariableType end -"Binary variables (α₁, …, α_L) for sawtooth quadratic approximation." -struct SawtoothBinaryVariable <: VariableType end -"Variable result in tightened version." -struct SawtoothTightenedVariable <: VariableType end -"Links g₀ to the normalized x value in sawtooth quadratic approximation." -struct SawtoothLinkingConstraint <: ConstraintType end -"Constrains g_j based on g_{j-1}." -struct SawtoothMIPConstraint <: ConstraintType end -"LP relaxation constraints (g_j ≤ 2g_{j-1}, g_j ≤ 2(1−g_{j-1})) used in epigraph tightening." -struct SawtoothLPConstraint <: ConstraintType end -"Bounds tightened variable." -struct SawtoothTightenedConstraint <: ConstraintType end - -""" -Config for sawtooth MIP quadratic approximation. - -# Fields -- `depth::Int`: recursion depth L; uses L binary variables for 2^L + 1 breakpoints -- `epigraph_depth::Int`: LP tightening depth via epigraph Q^{L1} lower bound; 0 to disable (default 0) -""" -struct SawtoothQuadConfig <: QuadraticApproxConfig - depth::Int - epigraph_depth::Int -end -SawtoothQuadConfig(depth::Int) = SawtoothQuadConfig(depth, 0) - -""" - _add_quadratic_approx!(config::SawtoothQuadConfig, container, C, names, time_steps, x_var, bounds, meta) - -Approximate x² using the sawtooth MIP formulation. - -Creates auxiliary continuous variables g_0,...,g_L and binary variables α_1,...,α_L, -adds S^L constraints (4 per level) and a linking constraint for each component and -time step, and stores affine expressions approximating x² in a -`QuadraticExpression` expression container. - -For depth L, the approximation interpolates x² at 2^L + 1 uniformly spaced breakpoints -with maximum overestimation error Δ² · 2^{-2L-2} where Δ = x_max - x_min. - -# Arguments -- `config::SawtoothQuadConfig`: configuration with `depth` (recursion depth L; uses L binary variables) and `epigraph_depth` (LP tightening depth; 0 to disable) -- `container::OptimizationContainer`: the optimization container -- `::Type{C}`: component type -- `names::Vector{String}`: component names -- `time_steps::UnitRange{Int}`: time periods -- `x_var`: container of variables indexed by (name, t) -- `bounds::Vector{MinMax}`: per-name lower and upper bounds of x domain -- `meta::String`: variable type identifier for the approximation (allows multiple approximations per component type) -""" -function _add_quadratic_approx!( - config::SawtoothQuadConfig, - container::OptimizationContainer, - ::Type{C}, - names::Vector{String}, - time_steps::UnitRange{Int}, - x_var, - bounds::Vector{MinMax}, - meta::String, -) where {C <: IS.InfrastructureSystemsComponent} - IS.@assert_op config.depth >= 1 - jump_model = get_jump_model(container) - - # Create containers with known dimensions - g_levels = 0:(config.depth) - alpha_levels = 1:(config.depth) - g_var = add_variable_container!( - container, - SawtoothAuxVariable, - C, - names, - g_levels, - time_steps; - meta, - ) - alpha_var = add_variable_container!( - container, - SawtoothBinaryVariable, - C, - names, - alpha_levels, - time_steps; - meta, - ) - mip_cons = add_constraints_container!( - container, - SawtoothMIPConstraint, - C, - names, - 1:4, - time_steps; - sparse = true, - meta, - ) - link_cons = add_constraints_container!( - container, - SawtoothLinkingConstraint, - C, - names, - time_steps; - meta, - ) - result_expr = add_expression_container!( - container, - QuadraticExpression, - C, - names, - time_steps; - meta, - ) - - if config.epigraph_depth > 0 - lp_expr = _add_quadratic_approx!( - EpigraphQuadConfig(config.epigraph_depth), - container, C, names, time_steps, - x_var, bounds, meta * "_lb", - ) - z_var = add_variable_container!( - container, - SawtoothTightenedVariable, - C, - names, - time_steps; - meta, - ) - tight_cons = add_constraints_container!( - container, - SawtoothTightenedConstraint, - C, - names, - 1:2, - time_steps; - meta, - ) - end - - for (i, name) in enumerate(names), t in time_steps - b = bounds[i] - IS.@assert_op b.max > b.min - delta = b.max - b.min - saw_coeffs = [delta * delta * (2.0^(-2 * j)) for j in alpha_levels] - z_min = (b.min <= 0.0 <= b.max) ? 0.0 : min(b.min * b.min, b.max * b.max) - z_max = max(b.min * b.min, b.max * b.max) - x = x_var[name, t] - - # Auxiliary variables g_0,...,g_L ∈ [0, 1] - for j in g_levels - g_var[name, j, t] = JuMP.@variable( - jump_model, - base_name = "SawtoothAux_$(C)_{$(name), $(j), $(t)}", - lower_bound = 0.0, - upper_bound = 1.0, - ) - end - - # Binary variables α_1,...,α_L - for j in alpha_levels - alpha_var[name, j, t] = JuMP.@variable( - jump_model, - base_name = "SawtoothBin_$(C)_{$(name), $(j), $(t)}", - binary = true, - ) - end - - # Linking constraint: g_0 = (x - x_min) / Δ - link_cons[name, t] = JuMP.@constraint( - jump_model, - g_var[name, 0, t] == (x - b.min) / delta, - ) - - # S^L constraints for j = 1,...,L - for j in alpha_levels - g_prev = g_var[name, j - 1, t] - g_curr = g_var[name, j, t] - alpha_j = alpha_var[name, j, t] - - # g_j ≤ 2 g_{j-1} - mip_cons[name, 1, t] = JuMP.@constraint(jump_model, g_curr <= 2.0 * g_prev) - # g_j ≤ 2(1 - g_{j-1}) - mip_cons[name, 2, t] = - JuMP.@constraint(jump_model, g_curr <= 2.0 * (1.0 - g_prev)) - # g_j ≥ 2(g_{j-1} - α_j) - mip_cons[name, 3, t] = - JuMP.@constraint(jump_model, g_curr >= 2.0 * (g_prev - alpha_j)) - # g_j ≥ 2(α_j - g_{j-1}) - mip_cons[name, 4, t] = - JuMP.@constraint(jump_model, g_curr >= 2.0 * (alpha_j - g_prev)) - end - - # Build x² ≈ x_min² + (2 x_min Δ + Δ²) g_0 - Σ_{j=1}^L Δ² 2^{-2j} g_j - x_sq_approx = JuMP.AffExpr(b.min * b.min) - add_proportional_to_jump_expression!( - x_sq_approx, - g_var[name, 0, t], - 2.0 * b.min * delta + delta * delta, - ) - for j in alpha_levels - add_proportional_to_jump_expression!( - x_sq_approx, - g_var[name, j, t], - -saw_coeffs[j], - ) - end - - if config.epigraph_depth > 0 - z = - z_var[name, t] = JuMP.@variable( - jump_model, - base_name = "TightenedSawtooth_$(C)_{$(name), $(t)}", - lower_bound = z_min, - upper_bound = z_max - ) - tight_cons[name, 1, t] = JuMP.@constraint(jump_model, z <= x_sq_approx) - tight_cons[name, 2, t] = JuMP.@constraint(jump_model, z >= lp_expr[name, t]) - result_expr[name, t] = JuMP.AffExpr(0.0, z => 1.0) - else - result_expr[name, t] = x_sq_approx - end - end - - return result_expr -end diff --git a/src/quadratic_approximations/solver_sos2.jl b/src/quadratic_approximations/solver_sos2.jl deleted file mode 100644 index 110428fe..00000000 --- a/src/quadratic_approximations/solver_sos2.jl +++ /dev/null @@ -1,186 +0,0 @@ -# SOS2-based piecewise linear approximation of x² for use in constraints. -# Uses solver-native MOI.SOS2 constraints for adjacency enforcement. - -"lambda_var (λ) convex combination weight variables for SOS2 quadratic approximation." -struct QuadraticVariable <: SparseVariableType end -"Links x to the weighted sum of breakpoints in SOS2 quadratic approximation." -struct SOS2LinkingConstraint <: ConstraintType end -"Expression for the weighted sum of breakpoints Σ λ_i * x_i linking x to lambda variables." -struct SOS2LinkingExpression <: ExpressionType end -"Ensures the sum of λ weights equals 1 in SOS2 quadratic approximation." -struct SOS2NormConstraint <: ConstraintType end -"Expression for the normalization sum Σ λ_i in SOS2 quadratic approximation." -struct SOS2NormExpression <: ExpressionType end - -"Solver-native MOI.SOS2 adjacency constraint on lambda variables." -struct SolverSOS2Constraint <: ConstraintType end - -""" -Config for solver-native SOS2 quadratic approximation (MOI.SOS2 adjacency). - -# Fields -- `depth::Int`: number of PWL segments (breakpoints = depth + 1) -- `pwmcc_segments::Int`: number of piecewise McCormick cut partitions; 0 to disable (default 4) -""" -struct SolverSOS2QuadConfig <: QuadraticApproxConfig - depth::Int - pwmcc_segments::Int -end -SolverSOS2QuadConfig(depth::Int) = SolverSOS2QuadConfig(depth, 4) - -""" - _add_quadratic_approx!(config::SolverSOS2QuadConfig, container, C, names, time_steps, x_var, bounds, meta) - -Approximate x² using a piecewise linear function with solver-native SOS2 constraints. - -Creates lambda_var (λ) variables representing convex combination weights over breakpoints, -adds linking, normalization, and MOI.SOS2 constraints, and stores affine expressions -approximating x² in a `QuadraticExpression` expression container. - -# Arguments -- `config::SolverSOS2QuadConfig`: configuration with `depth` (number of PWL segments) and `pwmcc_segments` (PWMCC cut partitions; 0 to disable, default 4) -- `container::OptimizationContainer`: the optimization container -- `::Type{C}`: component type -- `names::Vector{String}`: component names -- `time_steps::UnitRange{Int}`: time periods -- `x_var`: container of variables indexed by (name, t) -- `bounds::Vector{MinMax}`: per-name lower and upper bounds of x domain -- `meta::String`: variable type identifier for the approximation (allows multiple approximations per component type) -""" -function _add_quadratic_approx!( - config::SolverSOS2QuadConfig, - container::OptimizationContainer, - ::Type{C}, - names::Vector{String}, - time_steps::UnitRange{Int}, - x_var, - bounds::Vector{MinMax}, - meta::String, -) where {C <: IS.InfrastructureSystemsComponent} - x_bkpts, x_sq_bkpts = - _get_breakpoints_for_pwl_function( - 0.0, - 1.0, - _square; - num_segments = config.depth, - ) - n_points = config.depth + 1 - jump_model = get_jump_model(container) - - # Create all containers upfront - lambda_var = add_variable_container!( - container, - QuadraticVariable, - C, - names, - 1:n_points, - time_steps; - meta, - ) - link_cons = add_constraints_container!( - container, - SOS2LinkingConstraint, - C, - names, - time_steps; - meta, - ) - link_expr = add_expression_container!( - container, - SOS2LinkingExpression, - C, - names, - time_steps; - meta, - ) - norm_cons = add_constraints_container!( - container, - SOS2NormConstraint, - C, - names, - time_steps; - meta, - ) - norm_expr = add_expression_container!( - container, - SOS2NormExpression, - C, - names, - time_steps; - meta, - ) - sos_cons = add_constraints_container!( - container, - SolverSOS2Constraint, - C, - names, - time_steps; - meta, - ) - result_expr = add_expression_container!( - container, - QuadraticExpression, - C, - names, - time_steps; - meta, - ) - - for (i, name) in enumerate(names), t in time_steps - b = bounds[i] - IS.@assert_op b.max > b.min - lx = b.max - b.min - x = x_var[name, t] - - # Create lambda_var variables: λ_i ∈ [0, 1] - lambda = Vector{JuMP.VariableRef}(undef, n_points) - for i in 1:n_points - lambda[i] = - lambda_var[name, i, t] = JuMP.@variable( - jump_model, - base_name = "QuadraticVariable_$(C)_{$(name), pwl_$(i), $(t)}", - lower_bound = 0.0, - upper_bound = 1.0, - ) - end - - # x = Σ λ_i * x_i - link = link_expr[name, t] = JuMP.AffExpr(0.0) - for i in eachindex(x_bkpts) - add_proportional_to_jump_expression!(link, lambda[i], x_bkpts[i]) - end - link_cons[name, t] = JuMP.@constraint(jump_model, (x - b.min) / lx == link) - - # Σ λ_i = 1 - norm = norm_expr[name, t] = JuMP.AffExpr(0.0) - for l in lambda - add_proportional_to_jump_expression!(norm, l, 1.0) - end - norm_cons[name, t] = JuMP.@constraint(jump_model, norm == 1.0) - - # λ ∈ SOS2 (solver-native) - sos_cons[name, t] = - JuMP.@constraint(jump_model, lambda in MOI.SOS2(collect(1:n_points))) - - # Build x̂² = Σ λ_i * x_i² as an affine expression - x_hat_sq = JuMP.AffExpr(0.0) - for i in 1:n_points - add_proportional_to_jump_expression!(x_hat_sq, lambda[i], x_sq_bkpts[i]) - end - x_sq = JuMP.AffExpr(0.0) - add_proportional_to_jump_expression!(x_sq, x_hat_sq, lx * lx) - add_proportional_to_jump_expression!(x_sq, x, 2 * b.min) - add_constant_to_jump_expression!(x_sq, -b.min * b.min) - result_expr[name, t] = x_sq - end - - if config.pwmcc_segments > 0 - _add_pwmcc_concave_cuts!( - container, C, names, time_steps, - x_var, result_expr, bounds, - config.pwmcc_segments, meta * "_pwmcc", - ) - end - - return result_expr -end diff --git a/test/InfrastructureOptimizationModelsTests.jl b/test/InfrastructureOptimizationModelsTests.jl index f218b685..d937d039 100644 --- a/test/InfrastructureOptimizationModelsTests.jl +++ b/test/InfrastructureOptimizationModelsTests.jl @@ -132,7 +132,10 @@ function run_tests() include(joinpath(TEST_DIR, "test_jump_utils.jl")) include(joinpath(TEST_DIR, "test_pwl_methods.jl")) - # --- quadratic_approximations/ subfolder --- + # --- approximations/ subfolder --- + # Pure-JuMP layer (exercises build_* directly, no container). + include(joinpath(TEST_DIR, "test_pure_jump_approximations.jl")) + # IOM-wrapper regression tests (exercise _add_*_approx! end-to-end). include(joinpath(TEST_DIR, "test_quadratic_approximations.jl")) include(joinpath(TEST_DIR, "test_bilinear_approximations.jl")) include(joinpath(TEST_DIR, "test_hybs_approximations.jl")) diff --git a/test/test_pure_jump_approximations.jl b/test/test_pure_jump_approximations.jl new file mode 100644 index 00000000..45696f36 --- /dev/null +++ b/test/test_pure_jump_approximations.jl @@ -0,0 +1,200 @@ +# Pure-JuMP tests for the approximation layer. +# +# These tests exercise `build_quadratic_approx` and `build_bilinear_approx` +# directly against a bare `JuMP.Model`. No OptimizationContainer is involved +# anywhere — these tests would pass if `OptimizationContainer` were removed +# from the package. +# +# The point of having this layer separately testable is that mathematical +# properties of an approximation method (lower-boundedness, exactness at +# breakpoints, McCormick envelope feasibility, etc.) can be checked without +# any of the IOM container scaffolding getting in the way. + +function _pure_jump_scalar_var(model::JuMP.Model, lb::Float64, ub::Float64; name::String) + # The DenseAxisArray is indexed by a single device named "dev1" and a single + # time step. `name` is only the JuMP base_name (for readability in logs). + x = JuMP.@variable(model, base_name = name, lower_bound = lb, upper_bound = ub) + return JuMP.Containers.DenseAxisArray(reshape([x], 1, 1), ["dev1"], 1:1) +end + +@testset "Pure-JuMP Approximations" begin + @testset "no_approx_quadratic returns exact x²" begin + model = JuMP.Model(HiGHS.Optimizer) + JuMP.set_silent(model) + x = _pure_jump_scalar_var(model, 0.0, 4.0; name = "x") + bounds = [(min = 0.0, max = 4.0)] + result = IOM.build_quadratic_approx(IOM.NoQuadApproxConfig(), model, x, bounds) + @test IOM.get_approximation(result) === result.approximation + # The expression should be x*x, a QuadExpr + expr = result.approximation["dev1", 1] + @test expr isa JuMP.QuadExpr + end + + @testset "solver_sos2 is exact at breakpoints" begin + # With depth=4 breakpoints are at {0, 1, 2, 3, 4}, so x² is exact at x=2. + model = JuMP.Model(HiGHS.Optimizer) + JuMP.set_silent(model) + x = _pure_jump_scalar_var(model, 0.0, 4.0; name = "x") + bounds = [(min = 0.0, max = 4.0)] + result = IOM.build_quadratic_approx( + IOM.SolverSOS2QuadConfig(4, 0), model, x, bounds, + ) + JuMP.fix(x["dev1", 1], 2.0; force = true) + JuMP.@objective(model, Min, result.approximation["dev1", 1]) + JuMP.optimize!(model) + @test JuMP.termination_status(model) == JuMP.OPTIMAL + @test JuMP.value(result.approximation["dev1", 1]) ≈ 4.0 atol = 1e-6 + end + + @testset "solver_sos2 minimizes x² − 4x correctly" begin + model = JuMP.Model(HiGHS.Optimizer) + JuMP.set_silent(model) + x = _pure_jump_scalar_var(model, 0.0, 4.0; name = "x") + bounds = [(min = 0.0, max = 4.0)] + result = IOM.build_quadratic_approx( + IOM.SolverSOS2QuadConfig(4, 0), model, x, bounds, + ) + JuMP.@objective(model, Min, result.approximation["dev1", 1] - 4.0 * x["dev1", 1]) + JuMP.optimize!(model) + @test JuMP.value(x["dev1", 1]) ≈ 2.0 atol = 1e-6 + @test JuMP.objective_value(model) ≈ -4.0 atol = 1e-6 + end + + @testset "epigraph lower-bounds x² uniformly" begin + # The epigraph relaxation is a pure-LP lower bound on x². + # Sample x at five interior points, minimize z = approximation, + # and verify z(x) ≤ x² + tiny tolerance. + model = JuMP.Model(HiGHS.Optimizer) + JuMP.set_silent(model) + x = _pure_jump_scalar_var(model, 0.0, 1.0; name = "x") + bounds = [(min = 0.0, max = 1.0)] + result = IOM.build_quadratic_approx(IOM.EpigraphQuadConfig(4), model, x, bounds) + for sample in [0.1, 0.3, 0.5, 0.7, 0.9] + JuMP.fix(x["dev1", 1], sample; force = true) + JuMP.@objective(model, Min, result.approximation["dev1", 1]) + JuMP.optimize!(model) + @test JuMP.termination_status(model) == JuMP.OPTIMAL + @test JuMP.value(result.approximation["dev1", 1]) <= sample^2 + 1e-8 + end + end + + @testset "epigraph quality improves monotonically with depth" begin + # Pure-JuMP version of the test that previously had to be done via the + # container layer. The error at x=0.35 should shrink as depth grows. + errors = Float64[] + for depth in 1:6 + model = JuMP.Model(HiGHS.Optimizer) + JuMP.set_silent(model) + x = _pure_jump_scalar_var(model, 0.0, 1.0; name = "x") + bounds = [(min = 0.0, max = 1.0)] + result = IOM.build_quadratic_approx( + IOM.EpigraphQuadConfig(depth), model, x, bounds, + ) + JuMP.fix(x["dev1", 1], 0.35; force = true) + JuMP.@objective(model, Min, result.approximation["dev1", 1]) + JuMP.optimize!(model) + push!(errors, abs(JuMP.objective_value(model) - 0.35^2)) + end + for i in 2:length(errors) + @test errors[i] <= errors[i - 1] + 1e-10 + end + end + + @testset "sawtooth is exact at breakpoints" begin + # With depth=3 breakpoints are at the dyadic rationals on [0,1]. + model = JuMP.Model(HiGHS.Optimizer) + JuMP.set_silent(model) + x = _pure_jump_scalar_var(model, 0.0, 1.0; name = "x") + bounds = [(min = 0.0, max = 1.0)] + result = IOM.build_quadratic_approx(IOM.SawtoothQuadConfig(3, 0), model, x, bounds) + # x = 0.5 is at a breakpoint, x² = 0.25 exactly. + JuMP.fix(x["dev1", 1], 0.5; force = true) + JuMP.@objective(model, Min, result.approximation["dev1", 1]) + JuMP.optimize!(model) + @test JuMP.termination_status(model) == JuMP.OPTIMAL + @test JuMP.value(result.approximation["dev1", 1]) ≈ 0.25 atol = 1e-6 + end + + @testset "NMDT discretizes xh correctly" begin + # Stand-alone test of the shared NMDT discretization step. + model = JuMP.Model(HiGHS.Optimizer) + JuMP.set_silent(model) + x = _pure_jump_scalar_var(model, 0.0, 4.0; name = "x") + bounds = [(min = 0.0, max = 4.0)] + disc = IOM.build_discretization(model, x, bounds, 3) + # At x=2 (xh=0.5), expect β_1 = 1, β_2 = 0, β_3 = 0, δ = 0. + JuMP.fix(x["dev1", 1], 2.0; force = true) + JuMP.@objective(model, Min, disc.delta_var["dev1", 1]) + JuMP.optimize!(model) + @test JuMP.termination_status(model) == JuMP.OPTIMAL + # The discretization must reproduce xh = 0.5: + b1 = JuMP.value(disc.beta_var["dev1", 1, 1]) + b2 = JuMP.value(disc.beta_var["dev1", 2, 1]) + b3 = JuMP.value(disc.beta_var["dev1", 3, 1]) + d = JuMP.value(disc.delta_var["dev1", 1]) + @test 0.5 * b1 + 0.25 * b2 + 0.125 * b3 + d ≈ 0.5 atol = 1e-6 + end + + @testset "no_approx_bilinear returns exact x·y" begin + model = JuMP.Model(HiGHS.Optimizer) + JuMP.set_silent(model) + x = _pure_jump_scalar_var(model, 0.0, 2.0; name = "x") + y = _pure_jump_scalar_var(model, 0.0, 2.0; name = "y") + x_bounds = [(min = 0.0, max = 2.0)] + y_bounds = [(min = 0.0, max = 2.0)] + result = IOM.build_bilinear_approx( + IOM.NoBilinearApproxConfig(), model, x, y, x_bounds, y_bounds, + ) + expr = result.approximation["dev1", 1] + @test expr isa JuMP.QuadExpr + end + + @testset "McCormick envelope bracketing on x·y" begin + # Standard McCormick envelope on [0,1]² brackets the true x·y at corners. + model = JuMP.Model(HiGHS.Optimizer) + JuMP.set_silent(model) + x = _pure_jump_scalar_var(model, 0.0, 1.0; name = "x") + y = _pure_jump_scalar_var(model, 0.0, 1.0; name = "y") + z = JuMP.@variable(model, base_name = "z") + z_arr = JuMP.Containers.DenseAxisArray(reshape([z], 1, 1), ["dev1"], 1:1) + IOM.build_mccormick_envelope( + model, x, y, z_arr, + [(min = 0.0, max = 1.0)], [(min = 0.0, max = 1.0)], + ) + # At x=y=1, the only feasible z is 1. + JuMP.fix(x["dev1", 1], 1.0; force = true) + JuMP.fix(y["dev1", 1], 1.0; force = true) + JuMP.@objective(model, Min, z) + JuMP.optimize!(model) + @test JuMP.value(z) ≈ 1.0 atol = 1e-6 + end + + @testset "Bin2 z = ½(p² − x² − y²) identity" begin + # When the underlying quadratic method is exact at the queried point, + # Bin2 reproduces x·y exactly. + model = JuMP.Model(HiGHS.Optimizer) + JuMP.set_silent(model) + x = _pure_jump_scalar_var(model, 0.0, 1.0; name = "x") + y = _pure_jump_scalar_var(model, 0.0, 1.0; name = "y") + x_bounds = [(min = 0.0, max = 1.0)] + y_bounds = [(min = 0.0, max = 1.0)] + # depth=2 places breakpoints at {0, 0.5, 1.0}; pick a point at the corners. + result = IOM.build_bilinear_approx( + IOM.Bin2Config(IOM.SolverSOS2QuadConfig(2, 0), false), + model, x, y, x_bounds, y_bounds, + ) + JuMP.fix(x["dev1", 1], 1.0; force = true) + JuMP.fix(y["dev1", 1], 1.0; force = true) + JuMP.@objective(model, Min, result.approximation["dev1", 1]) + JuMP.optimize!(model) + @test JuMP.value(result.approximation["dev1", 1]) ≈ 1.0 atol = 1e-6 + end + + @testset "get_approximation returns the approximation field" begin + model = JuMP.Model() + x = _pure_jump_scalar_var(model, 0.0, 1.0; name = "x") + bounds = [(min = 0.0, max = 1.0)] + result = IOM.build_quadratic_approx(IOM.EpigraphQuadConfig(2), model, x, bounds) + @test IOM.get_approximation(result) === result.approximation + end +end From 95c7f8486bc45ec10c774c7ca2e7e721e598f046 Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Thu, 14 May 2026 17:16:43 -0400 Subject: [PATCH 2/9] Address PR #101 review: typing, vectorization, McCormick split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Targeted at the smaller, non-architectural review threads on PR #101. Cross-cutting changes: - Vectorize every `register_in_container!` copy loop: replace per-element loops with `target.data .= source.data` broadcasts on the underlying Arrays, since `add_*_container!` returns `DenseAxisArray`s whose internal `.data` matches the build-side container's shape. - Type every approximation `*Result` struct's parameters with concrete bounds (e.g. `XSQ <: QuadraticApproxResult`, `A <: DenseAxisArray{AffExpr, 2}`) rather than bare typevars. - Drop `sparse = true` everywhere in `src/approximations/`. Split the generic McCormick envelope key into `McCormickLowerConstraint` / `McCormickUpperConstraint` so the lower side cleanly disappears when `lower_bounds = false` (NMDT's `tighten = true` path). Densely-populated containers (`HybSBoundConstraint`, reformulated McCormick, sawtooth MIP) switch to dense. - Drop `collect(name_axis)` calls; `add_*_container!` accepts any iterable as an axis, and the source axis is already a `Vector{String}` in practice. Item-specific changes: - Rename `_add_quadratic_approx!` → `add_quadratic_approx!` and `_add_bilinear_approx!` → `add_bilinear_approx!`; POM call sites need a paired update. - Convert `if x !== nothing; register_*!(...); end` guards to dispatched no-op `register_*!(_, _, ::Nothing, _)` overloads. Same for `_register_tightening!`, `register_pwmcc!`, the new `_register_sawtooth_tightening!`, and McCormick variants. - Drop the redundant `MinMax((min = a, max = b))` wrapper — `MinMax` is a named-tuple alias. - Vectorize the JuMP-side loops: `pwmcc_cuts` `brk`, sawtooth MIP constraints (4 vectorized `@constraint` families instead of a triple loop), epigraph LP and tangent constraints, manual-SOS2 adjacency, and the McCormick envelopes themselves. - Extract `scale_back_g_basis(...)` (in `epigraph.jl`) and use it from both sawtooth and the epigraph tangent cuts: the `x_min² + (2·x_min·δ + δ²)·g₀ − Σ δ²·2^{−2k}·g_k` "scale back to actual dimensions" expression is shared. - Restore the precomputed-form `add_bilinear_approx!` entrypoints for every bilinear method (Bin2, HybS, NMDT, DNMDT, NoBilinear) that takes pre-built x²/y² (or pre-built NMDT discretizations) instead of re-computing them. Backed by a private `_PrebuiltQuadApprox` adapter in `common.jl` for the bilinear methods that share math via a single `_build_*_with_precomputed` flow. - Fix `sawtooth.jl`'s adjacency container schema (the old `(name, k, t)` container dropped the j axis when populated from a `(name, j, k, t)` loop). Now `(name, alpha_levels, 1:4, time)`, dense. - NMDT binary-continuous-product McCormicks now register under a `_bc`-suffixed meta to avoid colliding with the residual product's McCormick under the same NMDT key. All 1098 IOM tests pass. --- src/approximations/bin2.jl | 147 +++++++--- src/approximations/common.jl | 20 +- src/approximations/epigraph.jl | 206 ++++++------- src/approximations/hybs.jl | 289 +++++++++++-------- src/approximations/manual_sos2.jl | 195 +++++-------- src/approximations/mccormick.jl | 270 ++++++++++------- src/approximations/nmdt_bilinear.jl | 224 ++++++++------ src/approximations/nmdt_discretization.jl | 266 +++++++++-------- src/approximations/nmdt_quadratic.jl | 111 +++---- src/approximations/no_approx_bilinear.jl | 55 +++- src/approximations/no_approx_quadratic.jl | 17 +- src/approximations/pwmcc_cuts.jl | 144 ++++----- src/approximations/sawtooth.jl | 256 ++++++++-------- src/approximations/solver_sos2.jl | 130 +++------ test/performance/bilinear_delta_benchmark.jl | 16 +- test/test_bilinear_approximations.jl | 28 +- test/test_hybs_approximations.jl | 22 +- test/test_nmdt_approximations.jl | 52 ++-- test/test_quadratic_approximations.jl | 20 +- 19 files changed, 1273 insertions(+), 1195 deletions(-) diff --git a/src/approximations/bin2.jl b/src/approximations/bin2.jl index 4f081a9a..3989e041 100644 --- a/src/approximations/bin2.jl +++ b/src/approximations/bin2.jl @@ -11,8 +11,8 @@ Config for Bin2 bilinear approximation using z = ½·((x+y)² − x² − y²). - `quad_config::QuadraticApproxConfig`: quadratic method used for x², y², and (x+y)². - `add_mccormick::Bool`: whether to add reformulated McCormick cuts (default true). """ -struct Bin2Config <: BilinearApproxConfig - quad_config::QuadraticApproxConfig +struct Bin2Config{QC <: QuadraticApproxConfig} <: BilinearApproxConfig + quad_config::QC add_mccormick::Bool end function Bin2Config(quad_config::QuadraticApproxConfig) @@ -22,13 +22,28 @@ end """ Pure-JuMP result of `build_bilinear_approx(::Bin2Config, ...)`. """ -struct Bin2BilinearResult{A, XSQ, YSQ, PSQ, P, MC} <: BilinearApproxResult +struct Bin2BilinearResult{ + A <: JuMP.Containers.DenseAxisArray{JuMP.AffExpr, 2}, + XSQ <: QuadraticApproxResult, + YSQ <: QuadraticApproxResult, + PSQ <: QuadraticApproxResult, + P <: JuMP.Containers.DenseAxisArray{JuMP.AffExpr, 2}, + MC <: Union{ + Nothing, + Tuple{ + <:JuMP.Containers.DenseAxisArray, + <:JuMP.Containers.DenseAxisArray, + <:JuMP.Containers.DenseAxisArray, + <:JuMP.Containers.DenseAxisArray, + }, + }, +} <: BilinearApproxResult approximation::A xsq_result::XSQ ysq_result::YSQ psq_result::PSQ sum_expression::P - mccormick_constraints::MC # Union{Nothing, DenseAxisArray} + mccormick_constraints::MC end """ @@ -46,47 +61,38 @@ function build_bilinear_approx( x_bounds::Vector{MinMax}, y_bounds::Vector{MinMax}, ) - name_axis = axes(x, 1) - time_axis = axes(x, 2) - IS.@assert_op length(name_axis) == length(x_bounds) - IS.@assert_op length(name_axis) == length(y_bounds) - xsq = build_quadratic_approx(config.quad_config, model, x, x_bounds) ysq = build_quadratic_approx(config.quad_config, model, y, y_bounds) + name_axis = axes(x, 1) + time_axis = axes(x, 2) + p_expr = JuMP.@expression( model, [name = name_axis, t = time_axis], x[name, t] + y[name, t] ) p_bounds = [ - MinMax(( - min = x_bounds[i].min + y_bounds[i].min, - max = x_bounds[i].max + y_bounds[i].max, - )) for i in eachindex(x_bounds) + (min = x_bounds[i].min + y_bounds[i].min, + max = x_bounds[i].max + y_bounds[i].max) + for i in eachindex(x_bounds) ] psq = build_quadratic_approx(config.quad_config, model, p_expr, p_bounds) approximation = JuMP.@expression( model, [name = name_axis, t = time_axis], - 0.5 * - ( + 0.5 * ( psq.approximation[name, t] - xsq.approximation[name, t] - ysq.approximation[name, t] ) ) mc = if config.add_mccormick build_reformulated_mccormick( - model, - x, - y, - psq.approximation, - xsq.approximation, - ysq.approximation, - x_bounds, - y_bounds, - ) + model, x, y, + psq.approximation, xsq.approximation, ysq.approximation, + x_bounds, y_bounds, + ) else nothing end @@ -108,33 +114,84 @@ function register_in_container!( time_axis = axes(result.approximation, 2) p_target = add_expression_container!( - container, - VariableSumExpression, - C, - collect(name_axis), - time_axis; + container, VariableSumExpression, C, name_axis, time_axis; meta = meta * "_plus", ) - for name in name_axis, t in time_axis - p_target[name, t] = result.sum_expression[name, t] - end + p_target.data .= result.sum_expression.data result_target = add_expression_container!( - container, - BilinearProductExpression, - C, - collect(name_axis), - time_axis; - meta, + container, BilinearProductExpression, C, name_axis, time_axis; meta, ) - for name in name_axis, t in time_axis - result_target[name, t] = result.approximation[name, t] - end + result_target.data .= result.approximation.data + + register_reformulated_mccormick!(container, C, result.mccormick_constraints, meta) + return +end + +""" + add_bilinear_approx!(config::Bin2Config, container, C, names, time_steps, + xsq, ysq, x_var, y_var, x_bounds, y_bounds, meta) + +Precomputed-form entrypoint: accepts already-built quadratic approximation +expression containers `xsq` ≈ x² and `ysq` ≈ y² (rather than re-computing +them). The Bin2 identity z = ½·((x+y)² − xsq − ysq) is built on top, along +with the (x+y)² approximation, sum expression, and optional reformulated +McCormick cuts. +""" +function add_bilinear_approx!( + config::Bin2Config, + container::OptimizationContainer, + ::Type{C}, + names::Vector{String}, + time_steps::UnitRange{Int}, + xsq, + ysq, + x_var, + y_var, + x_bounds::Vector{MinMax}, + y_bounds::Vector{MinMax}, + meta::String, +) where {C <: IS.InfrastructureSystemsComponent} + model = get_jump_model(container) + name_axis = axes(x_var, 1) + time_axis = axes(x_var, 2) + + p_expr = JuMP.@expression( + model, + [name = name_axis, t = time_axis], + x_var[name, t] + y_var[name, t] + ) + p_bounds = [ + (min = x_bounds[i].min + y_bounds[i].min, + max = x_bounds[i].max + y_bounds[i].max) + for i in eachindex(x_bounds) + ] + psq = build_quadratic_approx(config.quad_config, model, p_expr, p_bounds) + register_in_container!(container, C, psq, meta * "_plus") - if result.mccormick_constraints !== nothing - register_reformulated_mccormick!( - container, C, result.mccormick_constraints, meta, + approximation = JuMP.@expression( + model, + [name = name_axis, t = time_axis], + 0.5 * (psq.approximation[name, t] - xsq[name, t] - ysq[name, t]) + ) + + p_target = add_expression_container!( + container, VariableSumExpression, C, name_axis, time_axis; + meta = meta * "_plus", + ) + p_target.data .= p_expr.data + + result_target = add_expression_container!( + container, BilinearProductExpression, C, name_axis, time_axis; meta, + ) + result_target.data .= approximation.data + + if config.add_mccormick + mc = build_reformulated_mccormick( + model, x_var, y_var, psq.approximation, xsq, ysq, + x_bounds, y_bounds, ) + register_reformulated_mccormick!(container, C, mc, meta) end - return + return result_target end diff --git a/src/approximations/common.jl b/src/approximations/common.jl index ce194986..435f1e4b 100644 --- a/src/approximations/common.jl +++ b/src/approximations/common.jl @@ -11,7 +11,7 @@ # build_bilinear_approx(config, model, x, y, x_bounds, y_bounds) -> BilinearApproxResult # # IOM layer (container bookkeeping) -# _add_quadratic_approx!(config, container, C, names, time_steps, x, bounds, meta) +# add_quadratic_approx!(config, container, C, names, time_steps, x, bounds, meta) # 1. call build_quadratic_approx # 2. dispatch register_in_container!(container, C, result, meta) to write # all auxiliary JuMP objects into the OptimizationContainer @@ -44,6 +44,16 @@ holds either `JuMP.AffExpr` or `JuMP.QuadExpr` entries depending on method. get_approximation(result::QuadraticApproxResult) = result.approximation get_approximation(result::BilinearApproxResult) = result.approximation +""" +Lightweight `QuadraticApproxResult` adapter that wraps a pre-built x² (or +y²) expression container. Used by the precomputed-form bilinear entrypoints +so the shared math can consume already-registered quadratic approximations +without re-computing or re-registering them. +""" +struct _PrebuiltQuadApprox{A} <: QuadraticApproxResult + approximation::A +end + # --- Shared expression-key types --- "Expression container for the normalized variable xh = (x − x_min) / (x_max − x_min) ∈ [0,1]." @@ -108,7 +118,7 @@ end # --- IOM-side wrappers (POM entry points) --- """ - _add_quadratic_approx!(config, container, C, names, time_steps, x_var, bounds, meta) + add_quadratic_approx!(config, container, C, names, time_steps, x_var, bounds, meta) POM entry point for quadratic approximation. Dispatched on the abstract `QuadraticApproxConfig` type — concrete behavior comes from the concrete @@ -130,7 +140,7 @@ config's `build_quadratic_approx` and `register_in_container!` methods. The approximation expression container (indexed by (name, t)), as returned by `get_approximation(result)`. """ -function _add_quadratic_approx!( +function add_quadratic_approx!( config::QuadraticApproxConfig, container::OptimizationContainer, ::Type{C}, @@ -146,7 +156,7 @@ function _add_quadratic_approx!( end """ - _add_bilinear_approx!(config, container, C, names, time_steps, x_var, y_var, x_bounds, y_bounds, meta) + add_bilinear_approx!(config, container, C, names, time_steps, x_var, y_var, x_bounds, y_bounds, meta) POM entry point for bilinear approximation. Dispatched on the abstract `BilinearApproxConfig` type — concrete behavior comes from the concrete @@ -165,7 +175,7 @@ config's `build_bilinear_approx` and `register_in_container!` methods. # Returns The approximation expression container (indexed by (name, t)). """ -function _add_bilinear_approx!( +function add_bilinear_approx!( config::BilinearApproxConfig, container::OptimizationContainer, ::Type{C}, diff --git a/src/approximations/epigraph.jl b/src/approximations/epigraph.jl index 63307028..e9f05df3 100644 --- a/src/approximations/epigraph.jl +++ b/src/approximations/epigraph.jl @@ -25,6 +25,24 @@ struct EpigraphTangentConstraint <: ConstraintType end "Tangent-line lower-bound expression fL used in the epigraph formulation." struct EpigraphTangentExpression <: ExpressionType end +""" + scale_back_g_basis(x_min, delta, g_var, name, t, levels) + +Build the affine "scale back to actual dimensions" expression + x² ≈ x_min² + (2·x_min·δ + δ²)·g₀ − Σ_{j ∈ levels} δ²·2^{−2j}·g_j +where `g_var` is the SawtoothAux g-basis variable container, `x_min` and +`delta = x_max − x_min` are per-name scalars, and `levels` selects which +g-basis levels participate in the residual sum. + +Shared by sawtooth (PWL approximation) and epigraph (tangent cuts) — both +express the parabola anchor + residual decomposition in this form. +""" +@inline function scale_back_g_basis(x_min, delta, g_var, name, t, levels) + return x_min^2 + + (2.0 * x_min * delta + delta^2) * g_var[name, 0, t] - + sum(delta^2 * 2.0^(-2j) * g_var[name, j, t] for j in levels) +end + """ Config for epigraph (Q^{L1}) LP-only lower-bound quadratic approximation. @@ -39,7 +57,15 @@ end """ Pure-JuMP result of `build_quadratic_approx(::EpigraphQuadConfig, ...)`. """ -struct EpigraphQuadResult{A, Z, G, LP, LC, FL, TC} <: QuadraticApproxResult +struct EpigraphQuadResult{ + A <: JuMP.Containers.DenseAxisArray{JuMP.AffExpr, 2}, + Z <: JuMP.Containers.DenseAxisArray{JuMP.VariableRef, 2}, + G <: JuMP.Containers.DenseAxisArray{JuMP.VariableRef, 3}, + LP <: JuMP.Containers.DenseAxisArray, + LC <: JuMP.Containers.DenseAxisArray, + FL <: JuMP.Containers.DenseAxisArray{JuMP.AffExpr, 2}, + TC <: JuMP.Containers.DenseAxisArray, +} <: QuadraticApproxResult approximation::A z_var::Z g_var::G @@ -70,14 +96,8 @@ function build_quadratic_approx( end g_levels = 0:(config.depth) - delta = JuMP.Containers.DenseAxisArray( - [b.max - b.min for b in bounds], - name_axis, - ) - x_min_arr = JuMP.Containers.DenseAxisArray( - [b.min for b in bounds], - name_axis, - ) + delta = JuMP.Containers.DenseAxisArray([b.max - b.min for b in bounds], name_axis) + x_min_arr = JuMP.Containers.DenseAxisArray([b.min for b in bounds], name_axis) z_ub_arr = JuMP.Containers.DenseAxisArray( [max(b.min^2, b.max^2) for b in bounds], name_axis, @@ -98,66 +118,68 @@ function build_quadratic_approx( ) # T^L constraints: g_j ≤ 2 g_{j-1} and g_j ≤ 2(1 − g_{j-1}) for j = 1..L. - # Indexed by (name, j, k, t) with k ∈ 1:2. - lp_cons = JuMP.Containers.DenseAxisArray{Any}( + # Stack two depth × N × T families into a (name, j, k, t) container. + lp_a = JuMP.@constraint( + model, + [name = name_axis, j = 1:(config.depth), t = time_axis], + g_var[name, j, t] <= 2.0 * g_var[name, j - 1, t], + ) + lp_b = JuMP.@constraint( + model, + [name = name_axis, j = 1:(config.depth), t = time_axis], + g_var[name, j, t] <= 2.0 * (1.0 - g_var[name, j - 1, t]), + ) + lp_cons = JuMP.Containers.DenseAxisArray{eltype(lp_a.data)}( undef, name_axis, 1:(config.depth), 1:2, time_axis, ) - for name in name_axis, j in 1:(config.depth), t in time_axis - lp_cons[name, j, 1, t] = JuMP.@constraint( - model, - g_var[name, j, t] <= 2.0 * g_var[name, j - 1, t], - ) - lp_cons[name, j, 2, t] = JuMP.@constraint( - model, - g_var[name, j, t] <= 2.0 * (1.0 - g_var[name, j - 1, t]), - ) - end + @views lp_cons.data[:, :, 1, :] .= lp_a.data + @views lp_cons.data[:, :, 2, :] .= lp_b.data - # z is bounded below by x² via tangent cuts; pure-LP variable. z_var = JuMP.@variable( model, [name = name_axis, t = time_axis], lower_bound = 0.0, + upper_bound = z_ub_arr[name], base_name = "EpigraphVar", ) - for name in name_axis, t in time_axis - JuMP.set_upper_bound(z_var[name, t], z_ub_arr[name]) - end - # fL[j] = Σ_{k=1..j} δ² · 2^{−2k} · g_k (partial sum used in the j-th tangent cut). - # Built as a 2D container with time axis only (full-depth sum); the partial - # sums for each tangent constraint are formed inline below. + # fL = Σ_{j=1..L} δ²·2^{−2j}·g_j (full-depth residual sum used downstream + # by the optional sawtooth tightening; the per-j partial sums for the + # tangent cuts are formed inline below). fL_expr = JuMP.@expression( model, [name = name_axis, t = time_axis], sum(delta[name]^2 * 2.0^(-2j) * g_var[name, j, t] for j in 1:(config.depth)) ) - # Tangent-line cuts: z ≥ 0, z ≥ 2·x_min + 2·δ·g₀ − 1, plus depth more cuts - # at j = 1..L of the form z ≥ x_min·(2·δ·g₀ + x_min) − fL[j] + δ²·(g₀ − 2^{−2j−2}). - tangent_cons = JuMP.Containers.DenseAxisArray{Any}( + # Tangent-line cuts: + # k=1: z ≥ 0 + # k=2: z ≥ 2·x_min + 2·δ·g₀ − 1 (anchor at xh = 1/2) + # k=j+2 for j=1..L: z ≥ scale_back_g_basis(1:j) − δ²·2^{−2j−2} + tangent_zero = JuMP.@constraint( + model, [name = name_axis, t = time_axis], z_var[name, t] >= 0.0, + ) + tangent_anchor = JuMP.@constraint( + model, + [name = name_axis, t = time_axis], + z_var[name, t] >= + 2.0 * x_min_arr[name] - 1.0 + 2.0 * delta[name] * g_var[name, 0, t], + ) + tangent_levels = JuMP.@constraint( + model, + [name = name_axis, j = 1:(config.depth), t = time_axis], + z_var[name, t] >= + scale_back_g_basis( + x_min_arr[name], delta[name], g_var, name, t, 1:j, + ) - delta[name]^2 * 2.0^(-2j - 2), + ) + + tangent_cons = JuMP.Containers.DenseAxisArray{eltype(tangent_zero.data)}( undef, name_axis, 1:(config.depth + 2), time_axis, ) - for name in name_axis, t in time_axis - tangent_cons[name, 1, t] = JuMP.@constraint(model, z_var[name, t] >= 0) - tangent_cons[name, 2, t] = JuMP.@constraint( - model, - z_var[name, t] >= - 2.0 * x_min_arr[name] - 1.0 + 2.0 * delta[name] * g_var[name, 0, t], - ) - for j in 1:(config.depth) - tangent_cons[name, j + 2, t] = JuMP.@constraint( - model, - z_var[name, t] >= - x_min_arr[name] * - (2.0 * delta[name] * g_var[name, 0, t] + x_min_arr[name]) - - sum( - delta[name]^2 * 2.0^(-2k) * g_var[name, k, t] for k in 1:j - ) + - delta[name]^2 * (g_var[name, 0, t] - 2.0^(-2j - 2)), - ) - end - end + @views tangent_cons.data[:, 1, :] .= tangent_zero.data + @views tangent_cons.data[:, 2, :] .= tangent_anchor.data + @views tangent_cons.data[:, 3:end, :] .= tangent_levels.data approximation = JuMP.@expression( model, @@ -185,89 +207,43 @@ function register_in_container!( name_axis = axes(result.approximation, 1) time_axis = axes(result.approximation, 2) g_levels = axes(result.g_var, 2) + lp_lvl_axis = axes(result.lp_constraints, 2) + tangent_axis = axes(result.tangent_constraints, 2) z_target = add_variable_container!( - container, - EpigraphVariable, - C, - collect(name_axis), - time_axis; - meta, + container, EpigraphVariable, C, name_axis, time_axis; meta, ) - for name in name_axis, t in time_axis - z_target[name, t] = result.z_var[name, t] - end + z_target.data .= result.z_var.data g_target = add_variable_container!( - container, - SawtoothAuxVariable, - C, - collect(name_axis), - g_levels, - time_axis; - meta, + container, SawtoothAuxVariable, C, name_axis, g_levels, time_axis; meta, ) - for name in name_axis, j in g_levels, t in time_axis - g_target[name, j, t] = result.g_var[name, j, t] - end + g_target.data .= result.g_var.data link_target = add_constraints_container!( - container, - SawtoothLinkingConstraint, - C, - collect(name_axis), - time_axis; - meta, + container, SawtoothLinkingConstraint, C, name_axis, time_axis; meta, ) + link_target.data .= result.link_constraints.data + fL_target = add_expression_container!( - container, - EpigraphTangentExpression, - C, - collect(name_axis), - time_axis; - meta, + container, EpigraphTangentExpression, C, name_axis, time_axis; meta, ) + fL_target.data .= result.tangent_expressions.data + result_target = add_expression_container!( - container, - EpigraphExpression, - C, - collect(name_axis), - time_axis; - meta, + container, EpigraphExpression, C, name_axis, time_axis; meta, ) - for name in name_axis, t in time_axis - link_target[name, t] = result.link_constraints[name, t] - fL_target[name, t] = result.tangent_expressions[name, t] - result_target[name, t] = result.approximation[name, t] - end + result_target.data .= result.approximation.data lp_target = add_constraints_container!( - container, - SawtoothLPConstraint, - C, - collect(name_axis), - 1:2, - time_axis; - meta, + container, SawtoothLPConstraint, C, name_axis, lp_lvl_axis, 1:2, time_axis; meta, ) - lp_lvl_axis = axes(result.lp_constraints, 2) - for name in name_axis, j in lp_lvl_axis, k in 1:2, t in time_axis - lp_target[name, k, t] = result.lp_constraints[name, j, k, t] - end + lp_target.data .= result.lp_constraints.data - tangent_axis = axes(result.tangent_constraints, 2) tangent_target = add_constraints_container!( - container, - EpigraphTangentConstraint, - C, - collect(name_axis), - tangent_axis, - time_axis; - sparse = true, + container, EpigraphTangentConstraint, C, name_axis, tangent_axis, time_axis; meta, ) - for name in name_axis, k in tangent_axis, t in time_axis - tangent_target[(name, k, t)] = result.tangent_constraints[name, k, t] - end + tangent_target.data .= result.tangent_constraints.data return end diff --git a/src/approximations/hybs.jl b/src/approximations/hybs.jl index 1e489f4f..29a93c08 100644 --- a/src/approximations/hybs.jl +++ b/src/approximations/hybs.jl @@ -15,8 +15,8 @@ Config for HybS bilinear approximation. - `epigraph_depth::Int`: depth of the epigraph Q^{L1} approximation of (x±y)². - `add_mccormick::Bool`: whether to add a standard McCormick envelope on z (default false). """ -struct HybSConfig <: BilinearApproxConfig - quad_config::QuadraticApproxConfig +struct HybSConfig{QC <: QuadraticApproxConfig} <: BilinearApproxConfig + quad_config::QC epigraph_depth::Int add_mccormick::Bool end @@ -27,7 +27,18 @@ end """ Pure-JuMP result of `build_bilinear_approx(::HybSConfig, ...)`. """ -struct HybSBilinearResult{A, XSQ, YSQ, P1, P2, ZP1, ZP2, ZV, BC, MC} <: BilinearApproxResult +struct HybSBilinearResult{ + A <: JuMP.Containers.DenseAxisArray{JuMP.AffExpr, 2}, + XSQ <: QuadraticApproxResult, + YSQ <: QuadraticApproxResult, + P1 <: JuMP.Containers.DenseAxisArray{JuMP.AffExpr, 2}, + P2 <: JuMP.Containers.DenseAxisArray{JuMP.AffExpr, 2}, + ZP1 <: EpigraphQuadResult, + ZP2 <: EpigraphQuadResult, + ZV <: JuMP.Containers.DenseAxisArray{JuMP.VariableRef, 2}, + BC <: JuMP.Containers.DenseAxisArray, + MC <: Union{Nothing, NamedTuple{(:lower, :upper)}}, +} <: BilinearApproxResult approximation::A xsq_result::XSQ ysq_result::YSQ @@ -55,15 +66,32 @@ function build_bilinear_approx( y, x_bounds::Vector{MinMax}, y_bounds::Vector{MinMax}, +) + xsq = build_quadratic_approx(config.quad_config, model, x, x_bounds) + ysq = build_quadratic_approx(config.quad_config, model, y, y_bounds) + return _build_hybs_with_precomputed( + config, model, x, y, xsq, ysq, x_bounds, y_bounds, + ) +end + +# Shared math between the standard and precomputed-form entrypoints. Wraps +# pre-existing x² / y² approximations behind a `QuadraticApproxResult`-shaped +# adapter so the call site can come from either flow. +function _build_hybs_with_precomputed( + config::HybSConfig, + model::JuMP.Model, + x, + y, + xsq::QuadraticApproxResult, + ysq::QuadraticApproxResult, + x_bounds::Vector{MinMax}, + y_bounds::Vector{MinMax}, ) name_axis = axes(x, 1) time_axis = axes(x, 2) IS.@assert_op length(name_axis) == length(x_bounds) IS.@assert_op length(name_axis) == length(y_bounds) - xsq = build_quadratic_approx(config.quad_config, model, x, x_bounds) - ysq = build_quadratic_approx(config.quad_config, model, y, y_bounds) - p1_expr = JuMP.@expression( model, [name = name_axis, t = time_axis], @@ -75,76 +103,78 @@ function build_bilinear_approx( x[name, t] - y[name, t] ) p1_bounds = [ - MinMax(( - min = x_bounds[i].min + y_bounds[i].min, - max = x_bounds[i].max + y_bounds[i].max, - )) for i in eachindex(x_bounds) + (min = x_bounds[i].min + y_bounds[i].min, + max = x_bounds[i].max + y_bounds[i].max) + for i in eachindex(x_bounds) ] p2_bounds = [ - MinMax(( - min = x_bounds[i].min - y_bounds[i].max, - max = x_bounds[i].max - y_bounds[i].min, - )) for i in eachindex(x_bounds) + (min = x_bounds[i].min - y_bounds[i].max, + max = x_bounds[i].max - y_bounds[i].min) + for i in eachindex(x_bounds) ] epi_cfg = EpigraphQuadConfig(config.epigraph_depth) zp1 = build_quadratic_approx(epi_cfg, model, p1_expr, p1_bounds) zp2 = build_quadratic_approx(epi_cfg, model, p2_expr, p2_bounds) - z_lo = [ - min( - x_bounds[i].min * y_bounds[i].min, - x_bounds[i].min * y_bounds[i].max, - x_bounds[i].max * y_bounds[i].min, - x_bounds[i].max * y_bounds[i].max, - ) for i in eachindex(x_bounds) - ] - z_hi = [ - max( - x_bounds[i].min * y_bounds[i].min, - x_bounds[i].min * y_bounds[i].max, - x_bounds[i].max * y_bounds[i].min, - x_bounds[i].max * y_bounds[i].max, - ) for i in eachindex(x_bounds) - ] - z_lo_arr = JuMP.Containers.DenseAxisArray(z_lo, name_axis) - z_hi_arr = JuMP.Containers.DenseAxisArray(z_hi, name_axis) + z_lo = JuMP.Containers.DenseAxisArray( + [ + min( + x_bounds[i].min * y_bounds[i].min, + x_bounds[i].min * y_bounds[i].max, + x_bounds[i].max * y_bounds[i].min, + x_bounds[i].max * y_bounds[i].max, + ) for i in eachindex(x_bounds) + ], + name_axis, + ) + z_hi = JuMP.Containers.DenseAxisArray( + [ + max( + x_bounds[i].min * y_bounds[i].min, + x_bounds[i].min * y_bounds[i].max, + x_bounds[i].max * y_bounds[i].min, + x_bounds[i].max * y_bounds[i].max, + ) for i in eachindex(x_bounds) + ], + name_axis, + ) z_var = JuMP.@variable( model, [name = name_axis, t = time_axis], + lower_bound = z_lo[name], + upper_bound = z_hi[name], base_name = "HybSProduct", ) - for name in name_axis, t in time_axis - JuMP.set_lower_bound(z_var[name, t], z_lo_arr[name]) - JuMP.set_upper_bound(z_var[name, t], z_hi_arr[name]) - end - bound_cons = JuMP.Containers.DenseAxisArray{Any}( + # Bin2 lower bound: z ≥ ½·(zp1 − zx − zy) + bound_1 = JuMP.@constraint( + model, + [name = name_axis, t = time_axis], + z_var[name, t] >= + 0.5 * ( + zp1.approximation[name, t] - xsq.approximation[name, t] - + ysq.approximation[name, t] + ), + ) + # Bin3 upper bound: z ≤ ½·(zx + zy − zp2) + bound_2 = JuMP.@constraint( + model, + [name = name_axis, t = time_axis], + z_var[name, t] <= + 0.5 * ( + xsq.approximation[name, t] + ysq.approximation[name, t] - + zp2.approximation[name, t] + ), + ) + # bound_1 is `z >= …` (GreaterThan), bound_2 is `z <= …` (LessThan) — use the + # abstract ConstraintRef so both kinds fit in the same container. + bound_cons = JuMP.Containers.DenseAxisArray{JuMP.ConstraintRef}( undef, name_axis, 1:2, time_axis, ) - for name in name_axis, t in time_axis - # Bin2 lower bound: z ≥ ½·(zp1 − zx − zy) - bound_cons[name, 1, t] = JuMP.@constraint( - model, - z_var[name, t] >= - 0.5 * - ( - zp1.approximation[name, t] - xsq.approximation[name, t] - - ysq.approximation[name, t] - ), - ) - # Bin3 upper bound: z ≤ ½·(zx + zy − zp2) - bound_cons[name, 2, t] = JuMP.@constraint( - model, - z_var[name, t] <= - 0.5 * - ( - xsq.approximation[name, t] + ysq.approximation[name, t] - - zp2.approximation[name, t] - ), - ) - end + @views bound_cons.data[:, 1, :] .= bound_1.data + @views bound_cons.data[:, 2, :] .= bound_2.data approximation = JuMP.@expression( model, @@ -159,16 +189,8 @@ function build_bilinear_approx( end return HybSBilinearResult( - approximation, - xsq, - ysq, - p1_expr, - p2_expr, - zp1, - zp2, - z_var, - bound_cons, - mc, + approximation, xsq, ysq, p1_expr, p2_expr, zp1, zp2, + z_var, bound_cons, mc, ) end @@ -187,66 +209,97 @@ function register_in_container!( time_axis = axes(result.approximation, 2) p1_target = add_expression_container!( - container, - VariableSumExpression, - C, - collect(name_axis), - time_axis; + container, VariableSumExpression, C, name_axis, time_axis; meta = meta * "_plus", ) + p1_target.data .= result.sum_expression.data + p2_target = add_expression_container!( - container, - VariableDifferenceExpression, - C, - collect(name_axis), - time_axis; + container, VariableDifferenceExpression, C, name_axis, time_axis; meta = meta * "_diff", ) - for name in name_axis, t in time_axis - p1_target[name, t] = result.sum_expression[name, t] - p2_target[name, t] = result.diff_expression[name, t] - end + p2_target.data .= result.diff_expression.data z_target = add_variable_container!( - container, - BilinearProductVariable, - C, - collect(name_axis), - time_axis; - meta, - ) - for name in name_axis, t in time_axis - z_target[name, t] = result.z_var[name, t] - end + container, BilinearProductVariable, C, name_axis, time_axis; meta, + ) + z_target.data .= result.z_var.data bound_target = add_constraints_container!( - container, - HybSBoundConstraint, - C, - collect(name_axis), - 1:2, - time_axis; - sparse = true, - meta, - ) - for name in name_axis, k in 1:2, t in time_axis - bound_target[(name, k, t)] = result.bound_constraints[name, k, t] - end + container, HybSBoundConstraint, C, name_axis, 1:2, time_axis; meta, + ) + bound_target.data .= result.bound_constraints.data result_target = add_expression_container!( - container, - BilinearProductExpression, - C, - collect(name_axis), - time_axis; - meta, - ) - for name in name_axis, t in time_axis - result_target[name, t] = result.approximation[name, t] - end + container, BilinearProductExpression, C, name_axis, time_axis; meta, + ) + result_target.data .= result.approximation.data - if result.mccormick_constraints !== nothing - register_mccormick_envelope!(container, C, result.mccormick_constraints, meta) - end + register_mccormick_envelope!(container, C, result.mccormick_constraints, meta) return end + +""" + add_bilinear_approx!(config::HybSConfig, container, C, names, time_steps, + xsq, ysq, x_var, y_var, x_bounds, y_bounds, meta) + +Precomputed-form entrypoint: accepts already-built quadratic approximation +expression containers `xsq` ≈ x² and `ysq` ≈ y², and builds the HybS bilinear +approximation on top without re-computing them. +""" +function add_bilinear_approx!( + config::HybSConfig, + container::OptimizationContainer, + ::Type{C}, + names::Vector{String}, + time_steps::UnitRange{Int}, + xsq, + ysq, + x_var, + y_var, + x_bounds::Vector{MinMax}, + y_bounds::Vector{MinMax}, + meta::String, +) where {C <: IS.InfrastructureSystemsComponent} + xsq_wrapped = _PrebuiltQuadApprox(xsq) + ysq_wrapped = _PrebuiltQuadApprox(ysq) + result = _build_hybs_with_precomputed( + config, get_jump_model(container), x_var, y_var, + xsq_wrapped, ysq_wrapped, x_bounds, y_bounds, + ) + # Register only the new objects (epi, p_expr, z_var, bound_cons, approx, mc). + register_in_container!(container, C, result.sum_epigraph, meta * "_plus") + register_in_container!(container, C, result.diff_epigraph, meta * "_diff") + + name_axis = axes(result.approximation, 1) + time_axis = axes(result.approximation, 2) + + p1_target = add_expression_container!( + container, VariableSumExpression, C, name_axis, time_axis; + meta = meta * "_plus", + ) + p1_target.data .= result.sum_expression.data + p2_target = add_expression_container!( + container, VariableDifferenceExpression, C, name_axis, time_axis; + meta = meta * "_diff", + ) + p2_target.data .= result.diff_expression.data + + z_target = add_variable_container!( + container, BilinearProductVariable, C, name_axis, time_axis; meta, + ) + z_target.data .= result.z_var.data + + bound_target = add_constraints_container!( + container, HybSBoundConstraint, C, name_axis, 1:2, time_axis; meta, + ) + bound_target.data .= result.bound_constraints.data + + result_target = add_expression_container!( + container, BilinearProductExpression, C, name_axis, time_axis; meta, + ) + result_target.data .= result.approximation.data + + register_mccormick_envelope!(container, C, result.mccormick_constraints, meta) + return result_target +end diff --git a/src/approximations/manual_sos2.jl b/src/approximations/manual_sos2.jl index 8eb7fcc5..23239193 100644 --- a/src/approximations/manual_sos2.jl +++ b/src/approximations/manual_sos2.jl @@ -33,8 +33,19 @@ end """ Pure-JuMP result of `build_quadratic_approx(::ManualSOS2QuadConfig, ...)`. """ -struct ManualSOS2QuadResult{A, L, Z, LC, NC, ZSUM, AC, LE, NE, ZE, PWMCC} <: - QuadraticApproxResult +struct ManualSOS2QuadResult{ + A <: JuMP.Containers.DenseAxisArray{JuMP.AffExpr, 2}, + L <: JuMP.Containers.DenseAxisArray{JuMP.VariableRef, 3}, + Z <: JuMP.Containers.DenseAxisArray{JuMP.VariableRef, 3}, + LC <: JuMP.Containers.DenseAxisArray, + NC <: JuMP.Containers.DenseAxisArray, + ZSUM <: JuMP.Containers.DenseAxisArray, + AC <: JuMP.Containers.DenseAxisArray, + LE <: JuMP.Containers.DenseAxisArray{JuMP.AffExpr, 2}, + NE <: JuMP.Containers.DenseAxisArray{JuMP.AffExpr, 2}, + ZE <: JuMP.Containers.DenseAxisArray{JuMP.AffExpr, 2}, + PWMCC <: Union{Nothing, PWMCCResult}, +} <: QuadraticApproxResult approximation::A lambda::L z_var::Z @@ -70,20 +81,11 @@ function build_quadratic_approx( n_points = config.depth + 1 n_bins = n_points - 1 x_bkpts, x_sq_bkpts = _get_breakpoints_for_pwl_function( - 0.0, - 1.0, - _square; - num_segments = config.depth, + 0.0, 1.0, _square; num_segments = config.depth, ) - lx = JuMP.Containers.DenseAxisArray( - [b.max - b.min for b in bounds], - name_axis, - ) - x_min = JuMP.Containers.DenseAxisArray( - [b.min for b in bounds], - name_axis, - ) + lx = JuMP.Containers.DenseAxisArray([b.max - b.min for b in bounds], name_axis) + x_min = JuMP.Containers.DenseAxisArray([b.min for b in bounds], name_axis) lambda = JuMP.@variable( model, @@ -130,25 +132,32 @@ function build_quadratic_approx( seg_expr[name, t] == 1 ) - # Adjacency constraints: λ_i ≤ z_{i-1} + z_i with boundary cases. - # Store as a 3D container keyed by (name, i, t) where i ∈ 1:n_points. - adj_cons = JuMP.Containers.DenseAxisArray{Any}( + # Adjacency constraints: λ_i ≤ z_{i-1} + z_i, with boundary cases for i = 1 + # and i = n_points (only one neighbor z exists). Three vectorized + # @constraint calls stacked into a (name, i, t) container. + adj_first = JuMP.@constraint( + model, + [name = name_axis, t = time_axis], + lambda[name, 1, t] <= z_var[name, 1, t], + ) + adj_interior = JuMP.@constraint( + model, + [name = name_axis, i = 2:(n_points - 1), t = time_axis], + lambda[name, i, t] <= z_var[name, i - 1, t] + z_var[name, i, t], + ) + adj_last = JuMP.@constraint( + model, + [name = name_axis, t = time_axis], + lambda[name, n_points, t] <= z_var[name, n_bins, t], + ) + adj_cons = JuMP.Containers.DenseAxisArray{eltype(adj_first.data)}( undef, name_axis, 1:n_points, time_axis, ) - for name in name_axis, t in time_axis - adj_cons[name, 1, t] = - JuMP.@constraint(model, lambda[name, 1, t] <= z_var[name, 1, t]) - for i in 2:(n_points - 1) - adj_cons[name, i, t] = JuMP.@constraint( - model, - lambda[name, i, t] <= z_var[name, i - 1, t] + z_var[name, i, t], - ) - end - adj_cons[name, n_points, t] = JuMP.@constraint( - model, - lambda[name, n_points, t] <= z_var[name, n_bins, t], - ) + @views adj_cons.data[:, 1, :] .= adj_first.data + if n_points >= 3 + @views adj_cons.data[:, 2:(n_points - 1), :] .= adj_interior.data end + @views adj_cons.data[:, n_points, :] .= adj_last.data approximation = JuMP.@expression( model, @@ -165,17 +174,9 @@ function build_quadratic_approx( end return ManualSOS2QuadResult( - approximation, - lambda, - z_var, - link_cons, - norm_cons, - seg_cons, - adj_cons, - link_expr, - norm_expr, - seg_expr, - pwmcc, + approximation, lambda, z_var, + link_cons, norm_cons, seg_cons, adj_cons, + link_expr, norm_expr, seg_expr, pwmcc, ) end @@ -191,112 +192,56 @@ function register_in_container!( n_bins_axis = axes(result.z_var, 2) lambda_target = add_variable_container!( - container, - QuadraticVariable, - C, - collect(name_axis), - n_points_axis, - time_axis; - meta, + container, QuadraticVariable, C, name_axis, n_points_axis, time_axis; meta, ) - for name in name_axis, i in n_points_axis, t in time_axis - lambda_target[name, i, t] = result.lambda[name, i, t] - end + lambda_target.data .= result.lambda.data z_target = add_variable_container!( - container, - ManualSOS2BinaryVariable, - C, - collect(name_axis), - n_bins_axis, - time_axis; - meta, + container, ManualSOS2BinaryVariable, C, name_axis, n_bins_axis, time_axis; meta, ) - for name in name_axis, j in n_bins_axis, t in time_axis - z_target[name, j, t] = result.z_var[name, j, t] - end + z_target.data .= result.z_var.data link_cons_target = add_constraints_container!( - container, - SOS2LinkingConstraint, - C, - collect(name_axis), - time_axis; - meta, + container, SOS2LinkingConstraint, C, name_axis, time_axis; meta, ) + link_cons_target.data .= result.link_constraints.data + norm_cons_target = add_constraints_container!( - container, - SOS2NormConstraint, - C, - collect(name_axis), - time_axis; - meta, + container, SOS2NormConstraint, C, name_axis, time_axis; meta, ) + norm_cons_target.data .= result.norm_constraints.data + seg_cons_target = add_constraints_container!( - container, - ManualSOS2SegmentSelectionConstraint, - C, - collect(name_axis), - time_axis; - meta, + container, ManualSOS2SegmentSelectionConstraint, C, name_axis, time_axis; meta, ) + seg_cons_target.data .= result.segment_sum_constraints.data + link_expr_target = add_expression_container!( - container, - SOS2LinkingExpression, - C, - collect(name_axis), - time_axis; - meta, + container, SOS2LinkingExpression, C, name_axis, time_axis; meta, ) + link_expr_target.data .= result.link_expressions.data + norm_expr_target = add_expression_container!( - container, - SOS2NormExpression, - C, - collect(name_axis), - time_axis; - meta, + container, SOS2NormExpression, C, name_axis, time_axis; meta, ) + norm_expr_target.data .= result.norm_expressions.data + seg_expr_target = add_expression_container!( - container, - ManualSOS2SegmentSelectionExpression, - C, - collect(name_axis), - time_axis; - meta, + container, ManualSOS2SegmentSelectionExpression, C, name_axis, time_axis; meta, ) + seg_expr_target.data .= result.segment_sum_expressions.data + result_target = add_expression_container!( - container, - QuadraticExpression, - C, - collect(name_axis), - time_axis; - meta, + container, QuadraticExpression, C, name_axis, time_axis; meta, ) - for name in name_axis, t in time_axis - link_cons_target[name, t] = result.link_constraints[name, t] - norm_cons_target[name, t] = result.norm_constraints[name, t] - seg_cons_target[name, t] = result.segment_sum_constraints[name, t] - link_expr_target[name, t] = result.link_expressions[name, t] - norm_expr_target[name, t] = result.norm_expressions[name, t] - seg_expr_target[name, t] = result.segment_sum_expressions[name, t] - result_target[name, t] = result.approximation[name, t] - end + result_target.data .= result.approximation.data adj_target = add_constraints_container!( - container, - ManualSOS2AdjacencyConstraint, - C, - collect(name_axis), - n_points_axis, - time_axis; - meta, + container, ManualSOS2AdjacencyConstraint, C, name_axis, n_points_axis, + time_axis; meta, ) - for name in name_axis, i in n_points_axis, t in time_axis - adj_target[name, i, t] = result.adjacency_constraints[name, i, t] - end + adj_target.data .= result.adjacency_constraints.data - if result.pwmcc !== nothing - register_pwmcc!(container, C, result.pwmcc, meta * "_pwmcc") - end + register_pwmcc!(container, C, result.pwmcc, meta * "_pwmcc") return end diff --git a/src/approximations/mccormick.jl b/src/approximations/mccormick.jl index 1cd292b7..8b57adfa 100644 --- a/src/approximations/mccormick.jl +++ b/src/approximations/mccormick.jl @@ -1,8 +1,13 @@ # McCormick envelope for bilinear products z = x·y. -# Adds 4 linear inequalities that bound z given variable bounds on x and y. +# Adds up to 4 linear inequalities that bound z given variable bounds on x and y. +# The lower envelopes (z ≥ …) can be omitted when a tighter lower bound is +# supplied elsewhere; the upper envelopes (z ≤ …) are always present. -"Standard McCormick envelope constraints bounding the bilinear product z = x·y." -struct McCormickConstraint <: ConstraintType end +"McCormick envelope lower-bound constraints: z ≥ … (entries c1, c2)." +struct McCormickLowerConstraint <: ConstraintType end + +"McCormick envelope upper-bound constraints: z ≤ … (entries c3, c4)." +struct McCormickUpperConstraint <: ConstraintType end "Reformulated McCormick constraints on Bin2 separable variables." struct ReformulatedMcCormickConstraint <: ConstraintType end @@ -12,12 +17,9 @@ struct ReformulatedMcCormickConstraint <: ConstraintType end """ build_mccormick_envelope(model, x, y, z, x_min, x_max, y_min, y_max; lower_bounds = true) -Add the four McCormick inequalities bounding `z ≈ x·y` to `model` and return -them as a `(c1, c2, c3, c4)` tuple. If `lower_bounds == false`, the first -two constraints (`z ≥ …` lower envelopes) are omitted; the returned tuple -slots are `nothing` in their place. - -Inputs may be `JuMP.AbstractJuMPScalar` (variable or affine expression). +Build the McCormick inequalities bounding `z ≈ x·y` on `model`. Returns a +NamedTuple `(lower, upper)` of `(c, c)` tuples (`lower === nothing` when +`lower_bounds == false`). Inputs may be any `JuMP.AbstractJuMPScalar`. """ function build_mccormick_envelope( model::JuMP.Model, @@ -30,29 +32,24 @@ function build_mccormick_envelope( y_max::Float64; lower_bounds::Bool = true, ) - c1 = if lower_bounds - JuMP.@constraint(model, z >= x_min * y + x * y_min - x_min * y_min) - else - nothing - end - c2 = if lower_bounds - JuMP.@constraint(model, z >= x_max * y + x * y_max - x_max * y_max) + c3 = JuMP.@constraint(model, z <= x_max * y + x * y_min - x_max * y_min) + c4 = JuMP.@constraint(model, z <= x_min * y + x * y_max - x_min * y_max) + lower = if lower_bounds + c1 = JuMP.@constraint(model, z >= x_min * y + x * y_min - x_min * y_min) + c2 = JuMP.@constraint(model, z >= x_max * y + x * y_max - x_max * y_max) + (c1, c2) else nothing end - c3 = JuMP.@constraint(model, z <= x_max * y + x * y_min - x_max * y_min) - c4 = JuMP.@constraint(model, z <= x_min * y + x * y_max - x_min * y_max) - return (c1, c2, c3, c4) + return (lower = lower, upper = (c3, c4)) end """ build_mccormick_envelope(model, x, y, z, x_bounds, y_bounds; lower_bounds = true) -Vectorized McCormick envelope over a (name, t) grid: for each (name, t) -adds the four inequalities bounding `z[name, t] ≈ x[name, t] · y[name, t]`. -Returns a `DenseAxisArray` indexed by (name, k, t) where k ∈ 1:4 holds -the four constraints (or a `Union{Missing, ConstraintRef}` array entry -for the omitted lower bounds when `lower_bounds == false`). +Vectorized McCormick envelope over a `(name, t)` grid. Returns a NamedTuple +`(lower, upper)` where each side is a pair `(c, c)` of 2D `DenseAxisArray`s +indexed by `(name, t)`. `lower === nothing` when `lower_bounds == false`. """ function build_mccormick_envelope( model::JuMP.Model, @@ -67,30 +64,50 @@ function build_mccormick_envelope( time_axis = axes(x, 2) IS.@assert_op length(name_axis) == length(x_bounds) IS.@assert_op length(name_axis) == length(y_bounds) + for i in eachindex(x_bounds) + IS.@assert_op x_bounds[i].max > x_bounds[i].min + IS.@assert_op y_bounds[i].max > y_bounds[i].min + end - cons = JuMP.Containers.DenseAxisArray{Any}(undef, name_axis, 1:4, time_axis) - for (i, name) in enumerate(name_axis), t in time_axis - xb = x_bounds[i] - yb = y_bounds[i] - IS.@assert_op xb.max > xb.min - IS.@assert_op yb.max > yb.min - c1, c2, c3, c4 = build_mccormick_envelope( + xmin = JuMP.Containers.DenseAxisArray([b.min for b in x_bounds], name_axis) + xmax = JuMP.Containers.DenseAxisArray([b.max for b in x_bounds], name_axis) + ymin = JuMP.Containers.DenseAxisArray([b.min for b in y_bounds], name_axis) + ymax = JuMP.Containers.DenseAxisArray([b.max for b in y_bounds], name_axis) + + upper_1 = JuMP.@constraint( + model, + [name = name_axis, t = time_axis], + z[name, t] <= + xmax[name] * y[name, t] + x[name, t] * ymin[name] - + xmax[name] * ymin[name], + ) + upper_2 = JuMP.@constraint( + model, + [name = name_axis, t = time_axis], + z[name, t] <= + xmin[name] * y[name, t] + x[name, t] * ymax[name] - + xmin[name] * ymax[name], + ) + lower = if lower_bounds + lower_1 = JuMP.@constraint( + model, + [name = name_axis, t = time_axis], + z[name, t] >= + xmin[name] * y[name, t] + x[name, t] * ymin[name] - + xmin[name] * ymin[name], + ) + lower_2 = JuMP.@constraint( model, - x[name, t], - y[name, t], - z[name, t], - xb.min, - xb.max, - yb.min, - yb.max; - lower_bounds, + [name = name_axis, t = time_axis], + z[name, t] >= + xmax[name] * y[name, t] + x[name, t] * ymax[name] - + xmax[name] * ymax[name], ) - cons[name, 1, t] = c1 - cons[name, 2, t] = c2 - cons[name, 3, t] = c3 - cons[name, 4, t] = c4 + (lower_1, lower_2) + else + nothing end - return cons + return (lower = lower, upper = (upper_1, upper_2)) end # --- Bin2 reformulated-McCormick helpers (used inside build_bilinear_approx(::Bin2Config, ...)) --- @@ -136,7 +153,8 @@ end """ build_reformulated_mccormick(model, x, y, zp1, zx, zy, x_bounds, y_bounds) -Vectorized reformulated McCormick over the (name, t) grid. +Vectorized reformulated McCormick over the `(name, t)` grid. Returns a +4-tuple of 2D `DenseAxisArray`s, one per cut. """ function build_reformulated_mccormick( model::JuMP.Model, @@ -150,93 +168,149 @@ function build_reformulated_mccormick( ) name_axis = axes(x, 1) time_axis = axes(x, 2) - cons = JuMP.Containers.DenseAxisArray{Any}(undef, name_axis, 1:4, time_axis) - for (i, name) in enumerate(name_axis), t in time_axis - xb = x_bounds[i] - yb = y_bounds[i] - IS.@assert_op xb.max > xb.min - IS.@assert_op yb.max > yb.min - c1, c2, c3, c4 = build_reformulated_mccormick( - model, - x[name, t], - y[name, t], - zp1[name, t], - zx[name, t], - zy[name, t], - xb.min, - xb.max, - yb.min, - yb.max, - ) - cons[name, 1, t] = c1 - cons[name, 2, t] = c2 - cons[name, 3, t] = c3 - cons[name, 4, t] = c4 + IS.@assert_op length(name_axis) == length(x_bounds) + IS.@assert_op length(name_axis) == length(y_bounds) + for i in eachindex(x_bounds) + IS.@assert_op x_bounds[i].max > x_bounds[i].min + IS.@assert_op y_bounds[i].max > y_bounds[i].min end - return cons + + xmin = JuMP.Containers.DenseAxisArray([b.min for b in x_bounds], name_axis) + xmax = JuMP.Containers.DenseAxisArray([b.max for b in x_bounds], name_axis) + ymin = JuMP.Containers.DenseAxisArray([b.min for b in y_bounds], name_axis) + ymax = JuMP.Containers.DenseAxisArray([b.max for b in y_bounds], name_axis) + + c1 = JuMP.@constraint( + model, + [name = name_axis, t = time_axis], + zp1[name, t] - zx[name, t] - zy[name, t] >= + 2.0 * (xmin[name] * y[name, t] + x[name, t] * ymin[name] - + xmin[name] * ymin[name]), + ) + c2 = JuMP.@constraint( + model, + [name = name_axis, t = time_axis], + zp1[name, t] - zx[name, t] - zy[name, t] >= + 2.0 * (xmax[name] * y[name, t] + x[name, t] * ymax[name] - + xmax[name] * ymax[name]), + ) + c3 = JuMP.@constraint( + model, + [name = name_axis, t = time_axis], + zp1[name, t] - zx[name, t] - zy[name, t] <= + 2.0 * (xmax[name] * y[name, t] + x[name, t] * ymin[name] - + xmax[name] * ymin[name]), + ) + c4 = JuMP.@constraint( + model, + [name = name_axis, t = time_axis], + zp1[name, t] - zx[name, t] - zy[name, t] <= + 2.0 * (xmin[name] * y[name, t] + x[name, t] * ymax[name] - + xmin[name] * ymax[name]), + ) + return (c1, c2, c3, c4) end # --- IOM-side McCormick container registration --- """ - register_mccormick_envelope!(container, ::Type{C}, cons, meta) + register_mccormick_envelope!(container, ::Type{C}, mc, meta) -Register a McCormick constraint array (as returned by `build_mccormick_envelope`) -into the optimization container under `McCormickConstraint` with the given `meta`. +Register a McCormick envelope (NamedTuple `(lower, upper)` as returned by +the vectorized `build_mccormick_envelope`) into the optimization container. +`mc.upper` is written under `McCormickUpperConstraint`; `mc.lower`, when +non-`nothing`, under `McCormickLowerConstraint`. """ function register_mccormick_envelope!( container::OptimizationContainer, ::Type{C}, - cons, + mc::NamedTuple, meta::String, ) where {C <: IS.InfrastructureSystemsComponent} - name_axis = axes(cons, 1) - time_axis = axes(cons, 3) + _register_mccormick_side!(container, C, McCormickUpperConstraint, mc.upper, meta) + _register_mccormick_side!(container, C, McCormickLowerConstraint, mc.lower, meta) + return +end + +# No-op when this McCormick envelope was disabled at the call site. +register_mccormick_envelope!( + ::OptimizationContainer, + ::Type{<:IS.InfrastructureSystemsComponent}, + ::Nothing, + ::String, +) = nothing + +function _register_mccormick_side!( + container::OptimizationContainer, + ::Type{C}, + ::Type{K}, + cons::Tuple{<:JuMP.Containers.DenseAxisArray, <:JuMP.Containers.DenseAxisArray}, + meta::String, +) where { + C <: IS.InfrastructureSystemsComponent, + K <: ConstraintType, +} + c1, c2 = cons + name_axis = axes(c1, 1) + time_axis = axes(c1, 2) target = add_constraints_container!( - container, - McCormickConstraint, - C, - collect(name_axis), - 1:4, - time_axis; - sparse = true, - meta, + container, K, C, name_axis, 1:2, time_axis; meta, ) - for name in name_axis, k in 1:4, t in time_axis - c = cons[name, k, t] - c === nothing && continue - target[(name, k, t)] = c - end + @views target.data[:, 1, :] .= c1.data + @views target.data[:, 2, :] .= c2.data return end +# No-op when the lower-bound side wasn't built. +_register_mccormick_side!( + ::OptimizationContainer, + ::Type{<:IS.InfrastructureSystemsComponent}, + ::Type{<:ConstraintType}, + ::Nothing, + ::String, +) = nothing + """ register_reformulated_mccormick!(container, ::Type{C}, cons, meta) -Register a reformulated McCormick constraint array (as returned by -`build_reformulated_mccormick`) into the optimization container under -`ReformulatedMcCormickConstraint`. +Register a reformulated McCormick constraint set (the 4-tuple of 2D +constraint containers returned by `build_reformulated_mccormick`) into the +optimization container under `ReformulatedMcCormickConstraint`. """ function register_reformulated_mccormick!( container::OptimizationContainer, ::Type{C}, - cons, + cons::Tuple{ + <:JuMP.Containers.DenseAxisArray, + <:JuMP.Containers.DenseAxisArray, + <:JuMP.Containers.DenseAxisArray, + <:JuMP.Containers.DenseAxisArray, + }, meta::String, ) where {C <: IS.InfrastructureSystemsComponent} - name_axis = axes(cons, 1) - time_axis = axes(cons, 3) + c1, c2, c3, c4 = cons + name_axis = axes(c1, 1) + time_axis = axes(c1, 2) target = add_constraints_container!( container, ReformulatedMcCormickConstraint, C, - collect(name_axis), + name_axis, 1:4, time_axis; - sparse = true, meta, ) - for name in name_axis, k in 1:4, t in time_axis - target[(name, k, t)] = cons[name, k, t] - end + @views target.data[:, 1, :] .= c1.data + @views target.data[:, 2, :] .= c2.data + @views target.data[:, 3, :] .= c3.data + @views target.data[:, 4, :] .= c4.data return end + +# No-op when this McCormick envelope was disabled at the call site. +register_reformulated_mccormick!( + ::OptimizationContainer, + ::Type{<:IS.InfrastructureSystemsComponent}, + ::Nothing, + ::String, +) = nothing diff --git a/src/approximations/nmdt_bilinear.jl b/src/approximations/nmdt_bilinear.jl index 13ca0435..8289d8fd 100644 --- a/src/approximations/nmdt_bilinear.jl +++ b/src/approximations/nmdt_bilinear.jl @@ -27,7 +27,13 @@ end """ Pure-JuMP result of `build_bilinear_approx(::NMDTBilinearConfig, ...)`. """ -struct NMDTBilinearResult{A, XD, YN, BX, DZ} <: BilinearApproxResult +struct NMDTBilinearResult{ + A <: JuMP.Containers.DenseAxisArray{JuMP.AffExpr, 2}, + XD <: NMDTDiscretization, + YN <: JuMP.Containers.DenseAxisArray{JuMP.AffExpr, 2}, + BX <: NMDTBinaryContinuousProduct, + DZ <: NMDTResidualProduct, +} <: BilinearApproxResult approximation::A x_discretization::XD yh_expression::YN @@ -52,20 +58,25 @@ function build_bilinear_approx( ) x_disc = build_discretization(model, x, x_bounds, config.depth) yh_expr = build_normed_variable(model, y, y_bounds) + return _build_nmdt_with_precomputed( + config, model, x_disc, yh_expr, x_bounds, y_bounds, + ) +end + +# Shared math between the standard and precomputed-form entrypoints. +function _build_nmdt_with_precomputed( + config::NMDTBilinearConfig, + model::JuMP.Model, + x_disc::NMDTDiscretization, + yh_expr, + x_bounds::Vector{MinMax}, + y_bounds::Vector{MinMax}, +) bx_yh = build_binary_continuous_product( - model, - x_disc.beta_var, - yh_expr, - 0.0, - 1.0, - config.depth, + model, x_disc.beta_var, yh_expr, 0.0, 1.0, config.depth, ) dz = build_residual_product( - model, - x_disc.delta_var, - yh_expr, - 1.0, - config.depth, + model, x_disc.delta_var, yh_expr, 1.0, config.depth, ) approximation = build_assembled_product( model, @@ -87,43 +98,78 @@ function register_in_container!( ) where {C <: IS.InfrastructureSystemsComponent} register_discretization!(container, C, result.x_discretization, meta * "_x") + name_axis = axes(result.yh_expression, 1) + time_axis = axes(result.yh_expression, 2) yh_target = add_expression_container!( - container, - NormedVariableExpression, - C, - collect(axes(result.yh_expression, 1)), - axes(result.yh_expression, 2); + container, NormedVariableExpression, C, name_axis, time_axis; meta = meta * "_y", ) - for name in axes(result.yh_expression, 1), t in axes(result.yh_expression, 2) - yh_target[name, t] = result.yh_expression[name, t] - end + yh_target.data .= result.yh_expression.data register_binary_continuous_product!(container, C, result.bx_yh_product, meta) register_residual_product!(container, C, result.residual_product, meta) - name_axis = axes(result.approximation, 1) - time_axis = axes(result.approximation, 2) + result_name_axis = axes(result.approximation, 1) + result_time_axis = axes(result.approximation, 2) result_target = add_expression_container!( - container, - BilinearProductExpression, - C, - collect(name_axis), - time_axis; + container, BilinearProductExpression, C, result_name_axis, result_time_axis; meta, ) - for name in name_axis, t in time_axis - result_target[name, t] = result.approximation[name, t] - end + result_target.data .= result.approximation.data return end +""" + add_bilinear_approx!(config::NMDTBilinearConfig, container, C, names, time_steps, + x_disc, yh_expr, x_var, y_var, x_bounds, y_bounds, meta) + +Precomputed-form entrypoint: accepts an already-built `x_disc::NMDTDiscretization` +and `yh_expr` (the normalized-y expression container) and builds only the +binary-continuous product, residual product, and final assembly on top. +""" +function add_bilinear_approx!( + config::NMDTBilinearConfig, + container::OptimizationContainer, + ::Type{C}, + names::Vector{String}, + time_steps::UnitRange{Int}, + x_disc::NMDTDiscretization, + yh_expr::JuMP.Containers.DenseAxisArray, + x_var, + y_var, + x_bounds::Vector{MinMax}, + y_bounds::Vector{MinMax}, + meta::String, +) where {C <: IS.InfrastructureSystemsComponent} + result = _build_nmdt_with_precomputed( + config, get_jump_model(container), x_disc, yh_expr, x_bounds, y_bounds, + ) + register_binary_continuous_product!(container, C, result.bx_yh_product, meta) + register_residual_product!(container, C, result.residual_product, meta) + name_axis = axes(result.approximation, 1) + time_axis = axes(result.approximation, 2) + result_target = add_expression_container!( + container, BilinearProductExpression, C, name_axis, time_axis; meta, + ) + result_target.data .= result.approximation.data + return result_target +end + # --- DNMDT (double discretization) --- """ Pure-JuMP result of `build_bilinear_approx(::DNMDTBilinearConfig, ...)`. """ -struct DNMDTBilinearResult{A, XD, YD, BXY, BYD, BYX, BXD, DZ} <: BilinearApproxResult +struct DNMDTBilinearResult{ + A <: JuMP.Containers.DenseAxisArray{JuMP.AffExpr, 2}, + XD <: NMDTDiscretization, + YD <: NMDTDiscretization, + BXY <: NMDTBinaryContinuousProduct, + BYD <: NMDTBinaryContinuousProduct, + BYX <: NMDTBinaryContinuousProduct, + BXD <: NMDTBinaryContinuousProduct, + DZ <: NMDTResidualProduct, +} <: BilinearApproxResult approximation::A x_discretization::XD y_discretization::YD @@ -150,44 +196,36 @@ function build_bilinear_approx( ) x_disc = build_discretization(model, x, x_bounds, config.depth) y_disc = build_discretization(model, y, y_bounds, config.depth) + return _build_dnmdt_with_precomputed( + config, model, x_disc, y_disc, x_bounds, y_bounds, + ) +end + +function _build_dnmdt_with_precomputed( + config::DNMDTBilinearConfig, + model::JuMP.Model, + x_disc::NMDTDiscretization, + y_disc::NMDTDiscretization, + x_bounds::Vector{MinMax}, + y_bounds::Vector{MinMax}, +) bx_yh = build_binary_continuous_product( - model, - x_disc.beta_var, - y_disc.norm_expr, - 0.0, - 1.0, - config.depth, + model, x_disc.beta_var, y_disc.norm_expr, 0.0, 1.0, config.depth, ) by_dx = build_binary_continuous_product( - model, - y_disc.beta_var, - x_disc.delta_var, - 0.0, - 2.0^(-config.depth), - config.depth, + model, y_disc.beta_var, x_disc.delta_var, + 0.0, 2.0^(-config.depth), config.depth, ) by_xh = build_binary_continuous_product( - model, - y_disc.beta_var, - x_disc.norm_expr, - 0.0, - 1.0, - config.depth, + model, y_disc.beta_var, x_disc.norm_expr, 0.0, 1.0, config.depth, ) bx_dy = build_binary_continuous_product( - model, - x_disc.beta_var, - y_disc.delta_var, - 0.0, - 2.0^(-config.depth), - config.depth, + model, x_disc.beta_var, y_disc.delta_var, + 0.0, 2.0^(-config.depth), config.depth, ) dz = build_residual_product( - model, - x_disc.delta_var, - y_disc.delta_var, - 2.0^(-config.depth), - config.depth, + model, x_disc.delta_var, y_disc.delta_var, + 2.0^(-config.depth), config.depth, ) approximation = build_assembled_dnmdt( model, @@ -215,32 +253,56 @@ function register_in_container!( register_discretization!(container, C, result.x_discretization, meta * "_x") register_discretization!(container, C, result.y_discretization, meta * "_y") - register_binary_continuous_product!( - container, C, result.bx_yh_product, meta * "_bx_yh", - ) - register_binary_continuous_product!( - container, C, result.by_dx_product, meta * "_by_dx", - ) - register_binary_continuous_product!( - container, C, result.by_xh_product, meta * "_by_xh", - ) - register_binary_continuous_product!( - container, C, result.bx_dy_product, meta * "_bx_dy", - ) + register_binary_continuous_product!(container, C, result.bx_yh_product, meta * "_bx_yh") + register_binary_continuous_product!(container, C, result.by_dx_product, meta * "_by_dx") + register_binary_continuous_product!(container, C, result.by_xh_product, meta * "_by_xh") + register_binary_continuous_product!(container, C, result.bx_dy_product, meta * "_bx_dy") register_residual_product!(container, C, result.residual_product, meta) name_axis = axes(result.approximation, 1) time_axis = axes(result.approximation, 2) result_target = add_expression_container!( - container, - BilinearProductExpression, - C, - collect(name_axis), - time_axis; - meta, + container, BilinearProductExpression, C, name_axis, time_axis; meta, ) - for name in name_axis, t in time_axis - result_target[name, t] = result.approximation[name, t] - end + result_target.data .= result.approximation.data return end + +""" + add_bilinear_approx!(config::DNMDTBilinearConfig, container, C, names, time_steps, + x_disc, y_disc, x_var, y_var, x_bounds, y_bounds, meta) + +Precomputed-form entrypoint: accepts already-built `x_disc` and `y_disc` +NMDT discretizations and builds only the four cross binary-continuous +products, the shared residual product, and the final convex assembly. +""" +function add_bilinear_approx!( + config::DNMDTBilinearConfig, + container::OptimizationContainer, + ::Type{C}, + names::Vector{String}, + time_steps::UnitRange{Int}, + x_disc::NMDTDiscretization, + y_disc::NMDTDiscretization, + x_var, + y_var, + x_bounds::Vector{MinMax}, + y_bounds::Vector{MinMax}, + meta::String, +) where {C <: IS.InfrastructureSystemsComponent} + result = _build_dnmdt_with_precomputed( + config, get_jump_model(container), x_disc, y_disc, x_bounds, y_bounds, + ) + register_binary_continuous_product!(container, C, result.bx_yh_product, meta * "_bx_yh") + register_binary_continuous_product!(container, C, result.by_dx_product, meta * "_by_dx") + register_binary_continuous_product!(container, C, result.by_xh_product, meta * "_by_xh") + register_binary_continuous_product!(container, C, result.bx_dy_product, meta * "_bx_dy") + register_residual_product!(container, C, result.residual_product, meta) + name_axis = axes(result.approximation, 1) + time_axis = axes(result.approximation, 2) + result_target = add_expression_container!( + container, BilinearProductExpression, C, name_axis, time_axis; meta, + ) + result_target.data .= result.approximation.data + return result_target +end diff --git a/src/approximations/nmdt_discretization.jl b/src/approximations/nmdt_discretization.jl index 6d915361..af58ac30 100644 --- a/src/approximations/nmdt_discretization.jl +++ b/src/approximations/nmdt_discretization.jl @@ -5,13 +5,6 @@ # binary digits and δ ∈ [0, 2^{−L}] a residual. The discretization is then # combined with another normalized variable (for bilinear products) or with # itself (for quadratic) via McCormick-linearized binary-continuous products. -# -# This file provides: -# - The container key types -# - The NMDTDiscretization struct (intermediate scaffolding type) -# - Pure-JuMP build helpers for each NMDT building block -# - Per-piece register_* helpers used by the top-level method's -# register_in_container! implementation # --- Container key types --- @@ -31,8 +24,6 @@ struct NMDTBinaryContinuousProductExpression <: ExpressionType end "Constraint enforcing xh = Σ 2^{−i}·β_i + δ in the NMDT discretization." struct NMDTEDiscretizationConstraint <: ConstraintType end -"McCormick envelope constraints for binary-continuous products u_i ≈ β_i·y in NMDT." -struct NMDTBinaryContinuousProductConstraint <: ConstraintType end "Epigraph lower-bound tightening constraint on the NMDT quadratic result." struct NMDTTightenConstraint <: ConstraintType end @@ -42,11 +33,15 @@ struct NMDTTightenConstraint <: ConstraintType end NMDT discretization scaffolding for a single normalized variable xh ∈ [0,1]. Holds the affine expression for the normalized variable, the binary digit -variables β_i (one per level of depth), and the residual δ. Constructed by -`build_discretization` and consumed by `build_binary_continuous_product`, -`build_residual_product`, and the NMDT assembly helpers. +variables β_i (one per level of depth), and the residual δ. """ -struct NMDTDiscretization{NE, BV, DV, DC, DE} +struct NMDTDiscretization{ + NE <: JuMP.Containers.DenseAxisArray{JuMP.AffExpr, 2}, + BV <: JuMP.Containers.DenseAxisArray{JuMP.VariableRef, 3}, + DV <: JuMP.Containers.DenseAxisArray{JuMP.VariableRef, 2}, + DC <: JuMP.Containers.DenseAxisArray, + DE <: JuMP.Containers.DenseAxisArray{JuMP.AffExpr, 2}, +} norm_expr::NE beta_var::BV delta_var::DV @@ -104,10 +99,19 @@ end """ Result of a single NMDT binary-continuous product step β_i·y ≈ u_i, weighted sum into Σ 2^{−i}·u_i. Returned by `build_binary_continuous_product`. + +`mccormick_lower` is `nothing` when `tighten = true` (the caller supplies a +tighter bound elsewhere). """ -struct NMDTBinaryContinuousProduct{UV, MC, RE} +struct NMDTBinaryContinuousProduct{ + UV <: JuMP.Containers.DenseAxisArray{JuMP.VariableRef, 3}, + MCL <: Union{Nothing, NTuple{2, <:JuMP.Containers.DenseAxisArray}}, + MCU <: NTuple{2, <:JuMP.Containers.DenseAxisArray}, + RE <: JuMP.Containers.DenseAxisArray{JuMP.AffExpr, 2}, +} u_var::UV - mccormick_constraints::MC + mccormick_lower::MCL + mccormick_upper::MCU result_expression::RE end @@ -117,7 +121,7 @@ end Build the depth-level binary-continuous product Σᵢ 2^{−i}·u_i ≈ β·y. For each (name, i, t), creates an auxiliary u_i with bounds [cont_min, cont_max] -and adds the four McCormick envelope inequalities on (β_i, y, u_i). If +and adds McCormick envelope inequalities on (β_i, y, u_i). If `tighten = true`, the lower-bound McCormick constraints are omitted (the caller applies a tighter bound elsewhere). """ @@ -139,39 +143,57 @@ function build_binary_continuous_product( upper_bound = cont_max, base_name = "NMDTBinContProd", ) - mc_cons = JuMP.Containers.DenseAxisArray{Any}( - undef, name_axis, 1:depth, 1:4, time_axis, + # McCormick envelopes for u[name, i, t] ≈ cont_var[name, t] · beta[name, i, t], + # with cont_var ∈ [cont_min, cont_max] and beta ∈ {0, 1}: + # c1 (lower): u ≥ cont_min · beta + # c2 (lower): u ≥ cont_max · beta + cont_var − cont_max + # c3 (upper): u ≤ cont_max · beta + # c4 (upper): u ≤ cont_min · beta + cont_var − cont_min + upper_1 = JuMP.@constraint( + model, + [name = name_axis, i = 1:depth, t = time_axis], + u_var[name, i, t] <= cont_max * beta_var[name, i, t], ) - for name in name_axis, i in 1:depth, t in time_axis - c1, c2, c3, c4 = build_mccormick_envelope( + upper_2 = JuMP.@constraint( + model, + [name = name_axis, i = 1:depth, t = time_axis], + u_var[name, i, t] <= + cont_min * beta_var[name, i, t] + cont_var[name, t] - cont_min, + ) + mccormick_lower = if tighten + nothing + else + lower_1 = JuMP.@constraint( model, - cont_var[name, t], - beta_var[name, i, t], - u_var[name, i, t], - cont_min, - cont_max, - 0.0, - 1.0; - lower_bounds = !tighten, + [name = name_axis, i = 1:depth, t = time_axis], + u_var[name, i, t] >= cont_min * beta_var[name, i, t], ) - mc_cons[name, i, 1, t] = c1 - mc_cons[name, i, 2, t] = c2 - mc_cons[name, i, 3, t] = c3 - mc_cons[name, i, 4, t] = c4 + lower_2 = JuMP.@constraint( + model, + [name = name_axis, i = 1:depth, t = time_axis], + u_var[name, i, t] >= + cont_max * beta_var[name, i, t] + cont_var[name, t] - cont_max, + ) + (lower_1, lower_2) end result_expr = JuMP.@expression( model, [name = name_axis, t = time_axis], sum(2.0^(-i) * u_var[name, i, t] for i in 1:depth) ) - return NMDTBinaryContinuousProduct(u_var, mc_cons, result_expr) + return NMDTBinaryContinuousProduct( + u_var, mccormick_lower, (upper_1, upper_2), result_expr, + ) end """ Result of the residual-continuous product step z ≈ δ·y. Returned by `build_residual_product`. """ -struct NMDTResidualProduct{ZV, MC} +struct NMDTResidualProduct{ + ZV <: JuMP.Containers.DenseAxisArray{JuMP.VariableRef, 2}, + MC <: NamedTuple, +} z_var::ZV mccormick_constraints::MC end @@ -201,25 +223,19 @@ function build_residual_product( upper_bound = delta_max * cont_max, base_name = "NMDTResidualProduct", ) - mc_cons = JuMP.Containers.DenseAxisArray{Any}(undef, name_axis, 1:4, time_axis) - for name in name_axis, t in time_axis - c1, c2, c3, c4 = build_mccormick_envelope( - model, - delta_var[name, t], - cont_var[name, t], - z_var[name, t], - 0.0, - delta_max, - 0.0, - cont_max; - lower_bounds = !tighten, - ) - mc_cons[name, 1, t] = c1 - mc_cons[name, 2, t] = c2 - mc_cons[name, 3, t] = c3 - mc_cons[name, 4, t] = c4 - end - return NMDTResidualProduct(z_var, mc_cons) + # Bounds for the vectorized envelope: δ ∈ [0, delta_max], cont ∈ [0, cont_max]. + delta_bounds = fill((min = 0.0, max = delta_max), length(name_axis)) + cont_bounds = fill((min = 0.0, max = cont_max), length(name_axis)) + mc = build_mccormick_envelope( + model, + delta_var, + cont_var, + z_var, + delta_bounds, + cont_bounds; + lower_bounds = !tighten, + ) + return NMDTResidualProduct(z_var, mc) end """ @@ -329,73 +345,38 @@ function register_discretization!( time_axis = axes(disc.beta_var, 3) norm_target = add_expression_container!( - container, - NormedVariableExpression, - C, - collect(name_axis), - time_axis; - meta, + container, NormedVariableExpression, C, name_axis, time_axis; meta, ) - for name in name_axis, t in time_axis - norm_target[name, t] = disc.norm_expr[name, t] - end + norm_target.data .= disc.norm_expr.data beta_target = add_variable_container!( - container, - NMDTBinaryVariable, - C, - collect(name_axis), - depth_axis, - time_axis; - meta, + container, NMDTBinaryVariable, C, name_axis, depth_axis, time_axis; meta, ) - for name in name_axis, i in depth_axis, t in time_axis - beta_target[name, i, t] = disc.beta_var[name, i, t] - end + beta_target.data .= disc.beta_var.data delta_target = add_variable_container!( - container, - NMDTResidualVariable, - C, - collect(name_axis), - time_axis; - meta, + container, NMDTResidualVariable, C, name_axis, time_axis; meta, ) - for name in name_axis, t in time_axis - delta_target[name, t] = disc.delta_var[name, t] - end + delta_target.data .= disc.delta_var.data disc_expr_target = add_expression_container!( - container, - NMDTDiscretizationExpression, - C, - collect(name_axis), - time_axis; - meta, + container, NMDTDiscretizationExpression, C, name_axis, time_axis; meta, ) - for name in name_axis, t in time_axis - disc_expr_target[name, t] = disc.disc_expression[name, t] - end + disc_expr_target.data .= disc.disc_expression.data disc_cons_target = add_constraints_container!( - container, - NMDTEDiscretizationConstraint, - C, - collect(name_axis), - time_axis; - meta, + container, NMDTEDiscretizationConstraint, C, name_axis, time_axis; meta, ) - for name in name_axis, t in time_axis - disc_cons_target[name, t] = disc.disc_constraints[name, t] - end + disc_cons_target.data .= disc.disc_constraints.data return end """ register_binary_continuous_product!(container, ::Type{C}, product, meta) -Register the auxiliary u variables, McCormick constraints, and weighted-sum -expression of an NMDT binary-continuous product step. +Register the auxiliary u variables, McCormick constraints (split into +lower/upper sides under `McCormickLowerConstraint`/`McCormickUpperConstraint`), +and the weighted-sum expression of an NMDT binary-continuous product step. """ function register_binary_continuous_product!( container::OptimizationContainer, @@ -411,45 +392,69 @@ function register_binary_continuous_product!( container, NMDTBinaryContinuousProductVariable, C, - collect(name_axis), + name_axis, depth_axis, time_axis; meta, ) - for name in name_axis, i in depth_axis, t in time_axis - u_target[name, i, t] = product.u_var[name, i, t] - end + u_target.data .= product.u_var.data - cons_target = add_constraints_container!( - container, - NMDTBinaryContinuousProductConstraint, - C, - collect(name_axis), - depth_axis, - 1:4, - time_axis; - meta, + # Suffix the McCormick meta so the binary-continuous product's envelope + # doesn't collide with a sibling residual product's envelope under the + # same NMDT approximation's `meta`. + _register_mccormick_depth_side!( + container, C, McCormickUpperConstraint, product.mccormick_upper, meta * "_bc", + ) + _register_mccormick_depth_side!( + container, C, McCormickLowerConstraint, product.mccormick_lower, meta * "_bc", ) - for name in name_axis, i in depth_axis, k in 1:4, t in time_axis - c = product.mccormick_constraints[name, i, k, t] - c === nothing && continue - cons_target[name, i, k, t] = c - end expr_target = add_expression_container!( container, NMDTBinaryContinuousProductExpression, C, - collect(name_axis), + name_axis, time_axis; meta, ) - for name in name_axis, t in time_axis - expr_target[name, t] = product.result_expression[name, t] - end + expr_target.data .= product.result_expression.data + return +end + +# Register one side (lower or upper) of an NMDT binary-continuous product's +# McCormick envelope. Each side is a pair of 3D `(name, depth, t)` constraint +# containers; we stack them into a 4D `(name, depth, k=1:2, t)` container. +function _register_mccormick_depth_side!( + container::OptimizationContainer, + ::Type{C}, + ::Type{K}, + cons::Tuple{<:JuMP.Containers.DenseAxisArray, <:JuMP.Containers.DenseAxisArray}, + meta::String, +) where { + C <: IS.InfrastructureSystemsComponent, + K <: ConstraintType, +} + c1, c2 = cons + name_axis = axes(c1, 1) + depth_axis = axes(c1, 2) + time_axis = axes(c1, 3) + target = add_constraints_container!( + container, K, C, name_axis, depth_axis, 1:2, time_axis; meta, + ) + @views target.data[:, :, 1, :] .= c1.data + @views target.data[:, :, 2, :] .= c2.data return end +# No-op when the lower side wasn't built. +_register_mccormick_depth_side!( + ::OptimizationContainer, + ::Type{<:IS.InfrastructureSystemsComponent}, + ::Type{<:ConstraintType}, + ::Nothing, + ::String, +) = nothing + """ register_residual_product!(container, ::Type{C}, product, meta) @@ -465,19 +470,10 @@ function register_residual_product!( time_axis = axes(product.z_var, 2) z_target = add_variable_container!( - container, - NMDTResidualProductVariable, - C, - collect(name_axis), - time_axis; - meta, + container, NMDTResidualProductVariable, C, name_axis, time_axis; meta, ) - for name in name_axis, t in time_axis - z_target[name, t] = product.z_var[name, t] - end + z_target.data .= product.z_var.data - register_mccormick_envelope!( - container, C, product.mccormick_constraints, meta, - ) + register_mccormick_envelope!(container, C, product.mccormick_constraints, meta) return end diff --git a/src/approximations/nmdt_quadratic.jl b/src/approximations/nmdt_quadratic.jl index da7ed72d..c814fac5 100644 --- a/src/approximations/nmdt_quadratic.jl +++ b/src/approximations/nmdt_quadratic.jl @@ -48,7 +48,10 @@ end """ Result of an epigraph tightening step on an NMDT quadratic approximation. """ -struct NMDTEpigraphTightening{EPI, CONS} +struct NMDTEpigraphTightening{ + EPI <: EpigraphQuadResult, + CONS <: JuMP.Containers.DenseAxisArray, +} epigraph::EPI constraints::CONS end @@ -61,12 +64,9 @@ function _build_nmdt_tightening( ) name_axis = axes(approximation, 1) time_axis = axes(approximation, 2) - fake_bounds = fill(MinMax((min = 0.0, max = 1.0)), length(name_axis)) + fake_bounds = fill((min = 0.0, max = 1.0), length(name_axis)) epi = build_quadratic_approx( - EpigraphQuadConfig(epigraph_depth), - model, - x_disc.norm_expr, - fake_bounds, + EpigraphQuadConfig(epigraph_depth), model, x_disc.norm_expr, fake_bounds, ) cons = JuMP.@constraint( model, @@ -86,30 +86,37 @@ function _register_tightening!( name_axis = axes(t.constraints, 1) time_axis = axes(t.constraints, 2) target = add_constraints_container!( - container, - NMDTTightenConstraint, - C, - collect(name_axis), - time_axis; - meta, + container, NMDTTightenConstraint, C, name_axis, time_axis; meta, ) - for name in name_axis, t_idx in time_axis - target[name, t_idx] = t.constraints[name, t_idx] - end + target.data .= t.constraints.data return end +# No-op when tightening is disabled (config.epigraph_depth = 0). +_register_tightening!( + ::OptimizationContainer, + ::Type{<:IS.InfrastructureSystemsComponent}, + ::Nothing, + ::String, +) = nothing + # --- NMDT (single) --- """ Pure-JuMP result of `build_quadratic_approx(::NMDTQuadConfig, ...)`. """ -struct NMDTQuadResult{A, D, BX, DZ, T} <: QuadraticApproxResult +struct NMDTQuadResult{ + A <: JuMP.Containers.DenseAxisArray{JuMP.AffExpr, 2}, + D <: NMDTDiscretization, + BX <: NMDTBinaryContinuousProduct, + DZ <: NMDTResidualProduct, + T <: Union{Nothing, NMDTEpigraphTightening}, +} <: QuadraticApproxResult approximation::A discretization::D bx_xh_product::BX residual_product::DZ - tightening::T # Union{Nothing, NMDTEpigraphTightening} + tightening::T end """ @@ -128,21 +135,10 @@ function build_quadratic_approx( tighten = config.epigraph_depth > 0 x_disc = build_discretization(model, x, bounds, config.depth) bx_xh = build_binary_continuous_product( - model, - x_disc.beta_var, - x_disc.norm_expr, - 0.0, - 1.0, - config.depth; - tighten, + model, x_disc.beta_var, x_disc.norm_expr, 0.0, 1.0, config.depth; tighten, ) dz = build_residual_product( - model, - x_disc.delta_var, - x_disc.norm_expr, - 1.0, - config.depth; - tighten, + model, x_disc.delta_var, x_disc.norm_expr, 1.0, config.depth; tighten, ) approximation = build_assembled_product( model, @@ -174,20 +170,11 @@ function register_in_container!( name_axis = axes(result.approximation, 1) time_axis = axes(result.approximation, 2) result_target = add_expression_container!( - container, - QuadraticExpression, - C, - collect(name_axis), - time_axis; - meta, + container, QuadraticExpression, C, name_axis, time_axis; meta, ) - for name in name_axis, t in time_axis - result_target[name, t] = result.approximation[name, t] - end + result_target.data .= result.approximation.data - if result.tightening !== nothing - _register_tightening!(container, C, result.tightening, meta) - end + _register_tightening!(container, C, result.tightening, meta) return end @@ -196,7 +183,14 @@ end """ Pure-JuMP result of `build_quadratic_approx(::DNMDTQuadConfig, ...)`. """ -struct DNMDTQuadResult{A, D, BX_XH, BX_DX, DZ, T} <: QuadraticApproxResult +struct DNMDTQuadResult{ + A <: JuMP.Containers.DenseAxisArray{JuMP.AffExpr, 2}, + D <: NMDTDiscretization, + BX_XH <: NMDTBinaryContinuousProduct, + BX_DX <: NMDTBinaryContinuousProduct, + DZ <: NMDTResidualProduct, + T <: Union{Nothing, NMDTEpigraphTightening}, +} <: QuadraticApproxResult approximation::A discretization::D bx_xh_product::BX_XH @@ -220,13 +214,7 @@ function build_quadratic_approx( tighten = config.epigraph_depth > 0 x_disc = build_discretization(model, x, bounds, config.depth) bx_xh = build_binary_continuous_product( - model, - x_disc.beta_var, - x_disc.norm_expr, - 0.0, - 1.0, - config.depth; - tighten, + model, x_disc.beta_var, x_disc.norm_expr, 0.0, 1.0, config.depth; tighten, ) bx_dx = build_binary_continuous_product( model, @@ -238,12 +226,8 @@ function build_quadratic_approx( tighten, ) dz = build_residual_product( - model, - x_disc.delta_var, - x_disc.delta_var, - 2.0^(-config.depth), - config.depth; - tighten, + model, x_disc.delta_var, x_disc.delta_var, + 2.0^(-config.depth), config.depth; tighten, ) approximation = build_assembled_dnmdt( model, @@ -283,19 +267,10 @@ function register_in_container!( name_axis = axes(result.approximation, 1) time_axis = axes(result.approximation, 2) result_target = add_expression_container!( - container, - QuadraticExpression, - C, - collect(name_axis), - time_axis; - meta, + container, QuadraticExpression, C, name_axis, time_axis; meta, ) - for name in name_axis, t in time_axis - result_target[name, t] = result.approximation[name, t] - end + result_target.data .= result.approximation.data - if result.tightening !== nothing - _register_tightening!(container, C, result.tightening, meta) - end + _register_tightening!(container, C, result.tightening, meta) return end diff --git a/src/approximations/no_approx_bilinear.jl b/src/approximations/no_approx_bilinear.jl index 9016cf57..280f3818 100644 --- a/src/approximations/no_approx_bilinear.jl +++ b/src/approximations/no_approx_bilinear.jl @@ -5,7 +5,9 @@ struct NoBilinearApproxConfig <: BilinearApproxConfig end "Pure-JuMP result of the no-op bilinear approximation." -struct NoBilinearApproxResult{A} <: BilinearApproxResult +struct NoBilinearApproxResult{ + A <: JuMP.Containers.DenseAxisArray{JuMP.QuadExpr, 2}, +} <: BilinearApproxResult approximation::A end @@ -41,16 +43,47 @@ function register_in_container!( name_axis = axes(result.approximation, 1) time_axis = axes(result.approximation, 2) target = add_expression_container!( - container, - BilinearProductExpression, - C, - collect(name_axis), - time_axis; - meta, - expr_type = JuMP.QuadExpr, + container, BilinearProductExpression, C, name_axis, time_axis; + meta, expr_type = JuMP.QuadExpr, ) - for name in name_axis, t in time_axis - target[name, t] = result.approximation[name, t] - end + target.data .= result.approximation.data return end + +""" + add_bilinear_approx!(::NoBilinearApproxConfig, container, C, names, time_steps, + xsq, ysq, x_var, y_var, x_bounds, y_bounds, meta) + +Precomputed-form entrypoint: signature-compatible with the precomputed-form +of `Bin2Config` / `HybSConfig`, so a caller can swap configs without +changing the call site. `xsq` and `ysq` are accepted but ignored — the +no-op approximation just returns the exact `x·y` product as a `QuadExpr`. +""" +function add_bilinear_approx!( + ::NoBilinearApproxConfig, + container::OptimizationContainer, + ::Type{C}, + names::Vector{String}, + time_steps::UnitRange{Int}, + xsq, + ysq, + x_var, + y_var, + x_bounds::Vector{MinMax}, + y_bounds::Vector{MinMax}, + meta::String, +) where {C <: IS.InfrastructureSystemsComponent} + name_axis = axes(x_var, 1) + time_axis = axes(x_var, 2) + target = add_expression_container!( + container, BilinearProductExpression, C, name_axis, time_axis; + meta, expr_type = JuMP.QuadExpr, + ) + target.data .= + JuMP.@expression( + get_jump_model(container), + [name = name_axis, t = time_axis], + x_var[name, t] * y_var[name, t] + ).data + return target +end diff --git a/src/approximations/no_approx_quadratic.jl b/src/approximations/no_approx_quadratic.jl index 4cb2a531..36037bed 100644 --- a/src/approximations/no_approx_quadratic.jl +++ b/src/approximations/no_approx_quadratic.jl @@ -5,7 +5,9 @@ struct NoQuadApproxConfig <: QuadraticApproxConfig end "Pure-JuMP result of the no-op quadratic approximation." -struct NoQuadApproxResult{A} <: QuadraticApproxResult +struct NoQuadApproxResult{ + A <: JuMP.Containers.DenseAxisArray{JuMP.QuadExpr, 2}, +} <: QuadraticApproxResult approximation::A end @@ -40,16 +42,9 @@ function register_in_container!( name_axis = axes(result.approximation, 1) time_axis = axes(result.approximation, 2) target = add_expression_container!( - container, - QuadraticExpression, - C, - collect(name_axis), - time_axis; - meta, - expr_type = JuMP.QuadExpr, + container, QuadraticExpression, C, name_axis, time_axis; + meta, expr_type = JuMP.QuadExpr, ) - for name in name_axis, t in time_axis - target[name, t] = result.approximation[name, t] - end + target.data .= result.approximation.data return end diff --git a/src/approximations/pwmcc_cuts.jl b/src/approximations/pwmcc_cuts.jl index 625d11b7..78726a7b 100644 --- a/src/approximations/pwmcc_cuts.jl +++ b/src/approximations/pwmcc_cuts.jl @@ -43,7 +43,17 @@ Pure-JuMP result of `build_pwmcc_concave_cuts`. All fields are JuMP container arrays indexed by (name, k, t) for the K-segment pieces or (name, t) for the once-per-element constraints. """ -struct PWMCCResult{DV, VDV, SC, LC, ILBC, IUBC, CUBC, TLLC, TLRC} +struct PWMCCResult{ + DV <: JuMP.Containers.DenseAxisArray{JuMP.VariableRef, 3}, + VDV <: JuMP.Containers.DenseAxisArray{JuMP.VariableRef, 3}, + SC <: JuMP.Containers.DenseAxisArray, + LC <: JuMP.Containers.DenseAxisArray, + ILBC <: JuMP.Containers.DenseAxisArray, + IUBC <: JuMP.Containers.DenseAxisArray, + CUBC <: JuMP.Containers.DenseAxisArray, + TLLC <: JuMP.Containers.DenseAxisArray, + TLRC <: JuMP.Containers.DenseAxisArray, +} delta_var::DV vd_var::VDV selector_constraints::SC @@ -82,20 +92,20 @@ function build_pwmcc_concave_cuts( name_axis = axes(v_var, 1) time_axis = axes(v_var, 2) IS.@assert_op length(name_axis) == length(bounds) - - # Per-name breakpoint coefficients - v_min_arr = JuMP.Containers.DenseAxisArray([b.min for b in bounds], name_axis) - v_max_arr = JuMP.Containers.DenseAxisArray([b.max for b in bounds], name_axis) - brk = JuMP.Containers.DenseAxisArray{Float64}(undef, name_axis, 0:K) - for (i, name) in enumerate(name_axis) - bmin = bounds[i].min - bmax = bounds[i].max - IS.@assert_op bmin < bmax - for k in 0:K - brk[name, k] = bmin + k * (bmax - bmin) / K - end + for b in bounds + IS.@assert_op b.min < b.max end + # Per-name breakpoint coefficients: brk[name, k] = v_min + k·(v_max − v_min)/K. + brk = JuMP.Containers.DenseAxisArray( + [ + bounds[i].min + k * (bounds[i].max - bounds[i].min) / K + for i in eachindex(name_axis), k in 0:K + ], + name_axis, + 0:K, + ) + delta_var = JuMP.@variable( model, [name = name_axis, k = 1:K, t = time_axis], @@ -108,6 +118,9 @@ function build_pwmcc_concave_cuts( base_name = "PwMcCDis", ) + # JuMP's `sum(...)` inside a constraint macro is recognized by the parser + # and expanded into an efficient affine-sum build — no manual unrolling + # required here. selector_cons = JuMP.@constraint( model, [name = name_axis, t = time_axis], @@ -188,100 +201,57 @@ function register_pwmcc!( time_axis = axes(pwmcc.delta_var, 3) delta_target = add_variable_container!( - container, - PiecewiseMcCormickBinary, - C, - collect(name_axis), - k_axis, - time_axis; - meta, + container, PiecewiseMcCormickBinary, C, name_axis, k_axis, time_axis; meta, ) - for name in name_axis, k in k_axis, t in time_axis - delta_target[name, k, t] = pwmcc.delta_var[name, k, t] - end + delta_target.data .= pwmcc.delta_var.data vd_target = add_variable_container!( - container, - PiecewiseMcCormickDisaggregated, - C, - collect(name_axis), - k_axis, - time_axis; + container, PiecewiseMcCormickDisaggregated, C, name_axis, k_axis, time_axis; meta, ) - for name in name_axis, k in k_axis, t in time_axis - vd_target[name, k, t] = pwmcc.vd_var[name, k, t] - end + vd_target.data .= pwmcc.vd_var.data selector_target = add_constraints_container!( - container, - PiecewiseMcCormickSelectorSum, - C, - collect(name_axis), - time_axis; - meta, + container, PiecewiseMcCormickSelectorSum, C, name_axis, time_axis; meta, ) + selector_target.data .= pwmcc.selector_constraints.data + linking_target = add_constraints_container!( - container, - PiecewiseMcCormickLinking, - C, - collect(name_axis), - time_axis; - meta, + container, PiecewiseMcCormickLinking, C, name_axis, time_axis; meta, ) + linking_target.data .= pwmcc.linking_constraints.data + chord_target = add_constraints_container!( - container, - PiecewiseMcCormickChordUB, - C, - collect(name_axis), - time_axis; - meta, + container, PiecewiseMcCormickChordUB, C, name_axis, time_axis; meta, ) + chord_target.data .= pwmcc.chord_ub_constraints.data + tangent_l_target = add_constraints_container!( - container, - PiecewiseMcCormickTangentLBL, - C, - collect(name_axis), - time_axis; - meta, + container, PiecewiseMcCormickTangentLBL, C, name_axis, time_axis; meta, ) + tangent_l_target.data .= pwmcc.tangent_lb_l_constraints.data + tangent_r_target = add_constraints_container!( - container, - PiecewiseMcCormickTangentLBR, - C, - collect(name_axis), - time_axis; - meta, + container, PiecewiseMcCormickTangentLBR, C, name_axis, time_axis; meta, ) - for name in name_axis, t in time_axis - selector_target[name, t] = pwmcc.selector_constraints[name, t] - linking_target[name, t] = pwmcc.linking_constraints[name, t] - chord_target[name, t] = pwmcc.chord_ub_constraints[name, t] - tangent_l_target[name, t] = pwmcc.tangent_lb_l_constraints[name, t] - tangent_r_target[name, t] = pwmcc.tangent_lb_r_constraints[name, t] - end + tangent_r_target.data .= pwmcc.tangent_lb_r_constraints.data interval_lb_target = add_constraints_container!( - container, - PiecewiseMcCormickIntervalLB, - C, - collect(name_axis), - k_axis, - time_axis; - meta, + container, PiecewiseMcCormickIntervalLB, C, name_axis, k_axis, time_axis; meta, ) + interval_lb_target.data .= pwmcc.interval_lb_constraints.data + interval_ub_target = add_constraints_container!( - container, - PiecewiseMcCormickIntervalUB, - C, - collect(name_axis), - k_axis, - time_axis; - meta, + container, PiecewiseMcCormickIntervalUB, C, name_axis, k_axis, time_axis; meta, ) - for name in name_axis, k in k_axis, t in time_axis - interval_lb_target[name, k, t] = pwmcc.interval_lb_constraints[name, k, t] - interval_ub_target[name, k, t] = pwmcc.interval_ub_constraints[name, k, t] - end + interval_ub_target.data .= pwmcc.interval_ub_constraints.data return end + +# No-op when the caller did not build PWMCC cuts. +register_pwmcc!( + ::OptimizationContainer, + ::Type{<:IS.InfrastructureSystemsComponent}, + ::Nothing, + ::String, +) = nothing diff --git a/src/approximations/sawtooth.jl b/src/approximations/sawtooth.jl index 51514eb1..23d8185b 100644 --- a/src/approximations/sawtooth.jl +++ b/src/approximations/sawtooth.jl @@ -30,18 +30,38 @@ function SawtoothQuadConfig(depth::Int) return SawtoothQuadConfig(depth, 0) end +""" +Tightening pieces of a sawtooth result when `config.epigraph_depth > 0`: +the substitute z variable, its bound constraints, and the epigraph result +that supplies the lower bound. +""" +struct SawtoothTightening{ + ZV <: JuMP.Containers.DenseAxisArray{JuMP.VariableRef, 2}, + TC <: JuMP.Containers.DenseAxisArray, + EPI <: EpigraphQuadResult, +} + z_var::ZV + constraints::TC + epigraph::EPI +end + """ Pure-JuMP result of `build_quadratic_approx(::SawtoothQuadConfig, ...)`. """ -struct SawtoothQuadResult{A, G, AL, LC, MC, ZV, TC, EPI} <: QuadraticApproxResult +struct SawtoothQuadResult{ + A <: JuMP.Containers.DenseAxisArray, + G <: JuMP.Containers.DenseAxisArray{JuMP.VariableRef, 3}, + AL <: JuMP.Containers.DenseAxisArray{JuMP.VariableRef, 3}, + LC <: JuMP.Containers.DenseAxisArray, + MC <: JuMP.Containers.DenseAxisArray, + T <: Union{Nothing, SawtoothTightening}, +} <: QuadraticApproxResult approximation::A g_var::G alpha_var::AL link_constraints::LC mip_constraints::MC - tightened_z_var::ZV # Union{Nothing, DenseAxisArray} - tightened_constraints::TC # Union{Nothing, DenseAxisArray} - epigraph::EPI # Union{Nothing, EpigraphQuadResult} + tightening::T end """ @@ -67,14 +87,8 @@ function build_quadratic_approx( g_levels = 0:(config.depth) alpha_levels = 1:(config.depth) - delta = JuMP.Containers.DenseAxisArray( - [b.max - b.min for b in bounds], - name_axis, - ) - x_min_arr = JuMP.Containers.DenseAxisArray( - [b.min for b in bounds], - name_axis, - ) + delta = JuMP.Containers.DenseAxisArray([b.max - b.min for b in bounds], name_axis) + x_min_arr = JuMP.Containers.DenseAxisArray([b.min for b in bounds], name_axis) g_var = JuMP.@variable( model, @@ -96,40 +110,48 @@ function build_quadratic_approx( g_var[name, 0, t] == (x[name, t] - x_min_arr[name]) / delta[name], ) - # S^L constraints for j = 1..L: 4 inequalities per level. - # Indexed by (name, j, k, t) with k ∈ 1:4. - mip_cons = JuMP.Containers.DenseAxisArray{Any}( + # S^L constraints for j = 1..L: 4 inequalities per level. Stack four + # `(name, j, t)` families into a `(name, j, k, t)` container. + mip_a = JuMP.@constraint( + model, + [name = name_axis, j = alpha_levels, t = time_axis], + g_var[name, j, t] <= 2.0 * g_var[name, j - 1, t], + ) + mip_b = JuMP.@constraint( + model, + [name = name_axis, j = alpha_levels, t = time_axis], + g_var[name, j, t] <= 2.0 * (1.0 - g_var[name, j - 1, t]), + ) + mip_c = JuMP.@constraint( + model, + [name = name_axis, j = alpha_levels, t = time_axis], + g_var[name, j, t] >= 2.0 * (g_var[name, j - 1, t] - alpha_var[name, j, t]), + ) + mip_d = JuMP.@constraint( + model, + [name = name_axis, j = alpha_levels, t = time_axis], + g_var[name, j, t] >= 2.0 * (alpha_var[name, j, t] - g_var[name, j - 1, t]), + ) + mip_cons = JuMP.Containers.DenseAxisArray{JuMP.ConstraintRef}( undef, name_axis, alpha_levels, 1:4, time_axis, ) - for name in name_axis, j in alpha_levels, t in time_axis - g_prev = g_var[name, j - 1, t] - g_curr = g_var[name, j, t] - a_j = alpha_var[name, j, t] - mip_cons[name, j, 1, t] = - JuMP.@constraint(model, g_curr <= 2.0 * g_prev) - mip_cons[name, j, 2, t] = - JuMP.@constraint(model, g_curr <= 2.0 * (1.0 - g_prev)) - mip_cons[name, j, 3, t] = - JuMP.@constraint(model, g_curr >= 2.0 * (g_prev - a_j)) - mip_cons[name, j, 4, t] = - JuMP.@constraint(model, g_curr >= 2.0 * (a_j - g_prev)) - end + @views mip_cons.data[:, :, 1, :] .= mip_a.data + @views mip_cons.data[:, :, 2, :] .= mip_b.data + @views mip_cons.data[:, :, 3, :] .= mip_c.data + @views mip_cons.data[:, :, 4, :] .= mip_d.data - # x² ≈ x_min² + (2·x_min·δ + δ²)·g₀ − Σ_{j=1..L} δ²·2^{−2j}·g_j + # x² ≈ x_min² + (2·x_min·δ + δ²)·g₀ − Σ_{j ∈ alpha_levels} δ²·2^{−2j}·g_j x_sq_approx = JuMP.@expression( model, [name = name_axis, t = time_axis], - x_min_arr[name]^2 + - (2.0 * x_min_arr[name] * delta[name] + delta[name]^2) * g_var[name, 0, t] - - sum(delta[name]^2 * 2.0^(-2j) * g_var[name, j, t] for j in alpha_levels) + scale_back_g_basis( + x_min_arr[name], delta[name], g_var, name, t, alpha_levels, + ) ) if config.epigraph_depth > 0 epi_result = build_quadratic_approx( - EpigraphQuadConfig(config.epigraph_depth), - model, - x, - bounds, + EpigraphQuadConfig(config.epigraph_depth), model, x, bounds, ) z_min_arr = JuMP.Containers.DenseAxisArray( [(b.min <= 0.0 <= b.max) ? 0.0 : min(b.min^2, b.max^2) for b in bounds], @@ -142,49 +164,40 @@ function build_quadratic_approx( z_var = JuMP.@variable( model, [name = name_axis, t = time_axis], + lower_bound = z_min_arr[name], + upper_bound = z_max_arr[name], base_name = "TightenedSawtooth", ) - for name in name_axis, t in time_axis - JuMP.set_lower_bound(z_var[name, t], z_min_arr[name]) - JuMP.set_upper_bound(z_var[name, t], z_max_arr[name]) - end - tight_cons = JuMP.Containers.DenseAxisArray{Any}( + tight_a = JuMP.@constraint( + model, + [name = name_axis, t = time_axis], + z_var[name, t] <= x_sq_approx[name, t], + ) + tight_b = JuMP.@constraint( + model, + [name = name_axis, t = time_axis], + z_var[name, t] >= epi_result.approximation[name, t], + ) + # tight_a is `z <= sawtooth approx` (LessThan), tight_b is `z >= epigraph` + # (GreaterThan) — use the abstract ConstraintRef to hold both kinds. + tight_cons = JuMP.Containers.DenseAxisArray{JuMP.ConstraintRef}( undef, name_axis, 1:2, time_axis, ) - for name in name_axis, t in time_axis - tight_cons[name, 1, t] = - JuMP.@constraint(model, z_var[name, t] <= x_sq_approx[name, t]) - tight_cons[name, 2, t] = JuMP.@constraint( - model, - z_var[name, t] >= epi_result.approximation[name, t], - ) - end + @views tight_cons.data[:, 1, :] .= tight_a.data + @views tight_cons.data[:, 2, :] .= tight_b.data approximation = JuMP.@expression( model, [name = name_axis, t = time_axis], 1.0 * z_var[name, t] ) + tightening = SawtoothTightening(z_var, tight_cons, epi_result) return SawtoothQuadResult( - approximation, - g_var, - alpha_var, - link_cons, - mip_cons, - z_var, - tight_cons, - epi_result, + approximation, g_var, alpha_var, link_cons, mip_cons, tightening, ) end return SawtoothQuadResult( - x_sq_approx, - g_var, - alpha_var, - link_cons, - mip_cons, - nothing, - nothing, - nothing, + x_sq_approx, g_var, alpha_var, link_cons, mip_cons, nothing, ) end @@ -200,94 +213,59 @@ function register_in_container!( alpha_levels = axes(result.alpha_var, 2) g_target = add_variable_container!( - container, - SawtoothAuxVariable, - C, - collect(name_axis), - g_levels, - time_axis; - meta, + container, SawtoothAuxVariable, C, name_axis, g_levels, time_axis; meta, ) - for name in name_axis, j in g_levels, t in time_axis - g_target[name, j, t] = result.g_var[name, j, t] - end + g_target.data .= result.g_var.data alpha_target = add_variable_container!( - container, - SawtoothBinaryVariable, - C, - collect(name_axis), - alpha_levels, - time_axis; - meta, + container, SawtoothBinaryVariable, C, name_axis, alpha_levels, time_axis; meta, ) - for name in name_axis, j in alpha_levels, t in time_axis - alpha_target[name, j, t] = result.alpha_var[name, j, t] - end + alpha_target.data .= result.alpha_var.data link_target = add_constraints_container!( - container, - SawtoothLinkingConstraint, - C, - collect(name_axis), - time_axis; - meta, + container, SawtoothLinkingConstraint, C, name_axis, time_axis; meta, ) - for name in name_axis, t in time_axis - link_target[name, t] = result.link_constraints[name, t] - end + link_target.data .= result.link_constraints.data mip_target = add_constraints_container!( - container, - SawtoothMIPConstraint, - C, - collect(name_axis), - 1:4, - time_axis; - sparse = true, + container, SawtoothMIPConstraint, C, name_axis, alpha_levels, 1:4, time_axis; meta, ) - for name in name_axis, j in alpha_levels, k in 1:4, t in time_axis - mip_target[(name, k, t)] = result.mip_constraints[name, j, k, t] - end + mip_target.data .= result.mip_constraints.data result_target = add_expression_container!( - container, - QuadraticExpression, - C, - collect(name_axis), - time_axis; - meta, + container, QuadraticExpression, C, name_axis, time_axis; meta, ) - for name in name_axis, t in time_axis - result_target[name, t] = result.approximation[name, t] - end + result_target.data .= result.approximation.data - if result.tightened_z_var !== nothing - z_target = add_variable_container!( - container, - SawtoothTightenedVariable, - C, - collect(name_axis), - time_axis; - meta, - ) - for name in name_axis, t in time_axis - z_target[name, t] = result.tightened_z_var[name, t] - end - tight_target = add_constraints_container!( - container, - SawtoothTightenedConstraint, - C, - collect(name_axis), - 1:2, - time_axis; - meta, - ) - for name in name_axis, k in 1:2, t in time_axis - tight_target[name, k, t] = result.tightened_constraints[name, k, t] - end - register_in_container!(container, C, result.epigraph, meta * "_lb") - end + _register_sawtooth_tightening!(container, C, result.tightening, meta) + return +end + +function _register_sawtooth_tightening!( + container::OptimizationContainer, + ::Type{C}, + tight::SawtoothTightening, + meta::String, +) where {C <: IS.InfrastructureSystemsComponent} + name_axis = axes(tight.z_var, 1) + time_axis = axes(tight.z_var, 2) + z_target = add_variable_container!( + container, SawtoothTightenedVariable, C, name_axis, time_axis; meta, + ) + z_target.data .= tight.z_var.data + tight_target = add_constraints_container!( + container, SawtoothTightenedConstraint, C, name_axis, 1:2, time_axis; meta, + ) + tight_target.data .= tight.constraints.data + register_in_container!(container, C, tight.epigraph, meta * "_lb") return end + +# No-op when tightening is disabled (config.epigraph_depth = 0). +_register_sawtooth_tightening!( + ::OptimizationContainer, + ::Type{<:IS.InfrastructureSystemsComponent}, + ::Nothing, + ::String, +) = nothing diff --git a/src/approximations/solver_sos2.jl b/src/approximations/solver_sos2.jl index e227ffb9..8e08af0c 100644 --- a/src/approximations/solver_sos2.jl +++ b/src/approximations/solver_sos2.jl @@ -38,7 +38,16 @@ end """ Pure-JuMP result of `build_quadratic_approx(::SolverSOS2QuadConfig, ...)`. """ -struct SOS2QuadResult{A, L, LC, NC, SC, LE, NE, PWMCC} <: QuadraticApproxResult +struct SOS2QuadResult{ + A <: JuMP.Containers.DenseAxisArray{JuMP.AffExpr, 2}, + L <: JuMP.Containers.DenseAxisArray{JuMP.VariableRef, 3}, + LC <: JuMP.Containers.DenseAxisArray, + NC <: JuMP.Containers.DenseAxisArray, + SC <: JuMP.Containers.DenseAxisArray, + LE <: JuMP.Containers.DenseAxisArray{JuMP.AffExpr, 2}, + NE <: JuMP.Containers.DenseAxisArray{JuMP.AffExpr, 2}, + PWMCC <: Union{Nothing, PWMCCResult}, +} <: QuadraticApproxResult approximation::A lambda::L link_constraints::LC @@ -46,7 +55,7 @@ struct SOS2QuadResult{A, L, LC, NC, SC, LE, NE, PWMCC} <: QuadraticApproxResult sos_constraints::SC link_expressions::LE norm_expressions::NE - pwmcc::PWMCC # Union{Nothing, PWMCCResult} + pwmcc::PWMCC end """ @@ -70,20 +79,11 @@ function build_quadratic_approx( end n_points = config.depth + 1 x_bkpts, x_sq_bkpts = _get_breakpoints_for_pwl_function( - 0.0, - 1.0, - _square; - num_segments = config.depth, + 0.0, 1.0, _square; num_segments = config.depth, ) - lx = JuMP.Containers.DenseAxisArray( - [b.max - b.min for b in bounds], - name_axis, - ) - x_min = JuMP.Containers.DenseAxisArray( - [b.min for b in bounds], - name_axis, - ) + lx = JuMP.Containers.DenseAxisArray([b.max - b.min for b in bounds], name_axis) + x_min = JuMP.Containers.DenseAxisArray([b.min for b in bounds], name_axis) lambda = JuMP.@variable( model, @@ -112,13 +112,11 @@ function build_quadratic_approx( [name = name_axis, t = time_axis], norm_expr[name, t] == 1.0 ) - sos_cons = JuMP.Containers.DenseAxisArray{Any}(undef, name_axis, time_axis) - for name in name_axis, t in time_axis - sos_cons[name, t] = JuMP.@constraint( - model, - [lambda[name, i, t] for i in 1:n_points] in MOI.SOS2(collect(1:n_points)), - ) - end + sos_cons = JuMP.@constraint( + model, + [name = name_axis, t = time_axis], + [lambda[name, i, t] for i in 1:n_points] in MOI.SOS2(collect(1:n_points)) + ) # x² = x_min² + 2·x_min·(x − x_min) + lx² · xh² where xh² ≈ Σ λ_i · x_bkpts[i]² # = lx² · Σ λ_i · x_bkpts[i]² + 2·x_min·x − x_min² approximation = JuMP.@expression( @@ -136,14 +134,7 @@ function build_quadratic_approx( end return SOS2QuadResult( - approximation, - lambda, - link_cons, - norm_cons, - sos_cons, - link_expr, - norm_expr, - pwmcc, + approximation, lambda, link_cons, norm_cons, sos_cons, link_expr, norm_expr, pwmcc, ) end @@ -158,77 +149,40 @@ function register_in_container!( n_points_axis = axes(result.lambda, 2) lambda_target = add_variable_container!( - container, - QuadraticVariable, - C, - collect(name_axis), - n_points_axis, - time_axis; - meta, - ) - for name in name_axis, i in n_points_axis, t in time_axis - lambda_target[name, i, t] = result.lambda[name, i, t] - end + container, QuadraticVariable, C, name_axis, n_points_axis, time_axis; meta, + ) + lambda_target.data .= result.lambda.data link_cons_target = add_constraints_container!( - container, - SOS2LinkingConstraint, - C, - collect(name_axis), - time_axis; - meta, + container, SOS2LinkingConstraint, C, name_axis, time_axis; meta, ) + link_cons_target.data .= result.link_constraints.data + norm_cons_target = add_constraints_container!( - container, - SOS2NormConstraint, - C, - collect(name_axis), - time_axis; - meta, + container, SOS2NormConstraint, C, name_axis, time_axis; meta, ) + norm_cons_target.data .= result.norm_constraints.data + sos_cons_target = add_constraints_container!( - container, - SolverSOS2Constraint, - C, - collect(name_axis), - time_axis; - meta, + container, SolverSOS2Constraint, C, name_axis, time_axis; meta, ) + sos_cons_target.data .= result.sos_constraints.data + link_expr_target = add_expression_container!( - container, - SOS2LinkingExpression, - C, - collect(name_axis), - time_axis; - meta, + container, SOS2LinkingExpression, C, name_axis, time_axis; meta, ) + link_expr_target.data .= result.link_expressions.data + norm_expr_target = add_expression_container!( - container, - SOS2NormExpression, - C, - collect(name_axis), - time_axis; - meta, + container, SOS2NormExpression, C, name_axis, time_axis; meta, ) + norm_expr_target.data .= result.norm_expressions.data + result_target = add_expression_container!( - container, - QuadraticExpression, - C, - collect(name_axis), - time_axis; - meta, - ) - for name in name_axis, t in time_axis - link_cons_target[name, t] = result.link_constraints[name, t] - norm_cons_target[name, t] = result.norm_constraints[name, t] - sos_cons_target[name, t] = result.sos_constraints[name, t] - link_expr_target[name, t] = result.link_expressions[name, t] - norm_expr_target[name, t] = result.norm_expressions[name, t] - result_target[name, t] = result.approximation[name, t] - end + container, QuadraticExpression, C, name_axis, time_axis; meta, + ) + result_target.data .= result.approximation.data - if result.pwmcc !== nothing - register_pwmcc!(container, C, result.pwmcc, meta * "_pwmcc") - end + register_pwmcc!(container, C, result.pwmcc, meta * "_pwmcc") return end diff --git a/test/performance/bilinear_delta_benchmark.jl b/test/performance/bilinear_delta_benchmark.jl index d51f6f84..d96439b5 100644 --- a/test/performance/bilinear_delta_benchmark.jl +++ b/test/performance/bilinear_delta_benchmark.jl @@ -234,7 +234,7 @@ function build_gen_bilinear( container, net::MockNetworkProblem, V_container, I_container, time_steps, bilinear_config::Union{IOM.Bin2Config, IOM.HybSConfig}, quad_config, ) - V_sq = IOM._add_quadratic_approx!( + V_sq = IOM.add_quadratic_approx!( quad_config, container, MockNetworkNode, @@ -245,7 +245,7 @@ function build_gen_bilinear( V_MAX, "gen_V_sq", ) - I_sq = IOM._add_quadratic_approx!( + I_sq = IOM.add_quadratic_approx!( quad_config, container, MockNetworkNode, @@ -256,7 +256,7 @@ function build_gen_bilinear( I_GEN_MAX, "gen_I_sq", ) - z_gen = IOM._add_bilinear_approx!( + z_gen = IOM.add_bilinear_approx!( bilinear_config, container, MockNetworkNode, @@ -291,11 +291,11 @@ function build_gen_bilinear( container, MockNetworkNode, net.gen_nodes, time_steps, I_container, I_GEN_MIN, I_GEN_MAX, quad_config.depth, "gen_I", ) - I_sq = IOM._add_quadratic_approx!( + I_sq = IOM.add_quadratic_approx!( quad_config, container, MockNetworkNode, net.gen_nodes, time_steps, I_disc, I_GEN_MIN, I_GEN_MAX, "gen_I_sq", ) - z_gen = IOM._add_bilinear_approx!( + z_gen = IOM.add_bilinear_approx!( bilinear_config, container, MockNetworkNode, net.gen_nodes, time_steps, V_disc, I_disc, V_MIN, V_MAX, I_GEN_MIN, I_GEN_MAX, "gen", ) @@ -309,7 +309,7 @@ function build_gen_bilinear( container, net::MockNetworkProblem, V_container, I_container, time_steps, bilinear_config::IOM.NoBilinearApproxConfig, quad_config::IOM.NoQuadApproxConfig, ) - z_gen = IOM._add_bilinear_approx!( + z_gen = IOM.add_bilinear_approx!( bilinear_config, container, MockNetworkNode, @@ -323,7 +323,7 @@ function build_gen_bilinear( I_GEN_MAX, "gen", ) - I_sq = IOM._add_quadratic_approx!( + I_sq = IOM.add_quadratic_approx!( quad_config, container, MockNetworkNode, @@ -393,7 +393,7 @@ function build_mip_model( ) # --- Bilinear dem: always uses the config-based dispatch --- - z_dem = IOM._add_bilinear_approx!( + z_dem = IOM.add_bilinear_approx!( bilinear_config, container, MockNetworkNode, net.dem_nodes, time_steps, V_container, I_container, V_MIN, V_MAX, I_DEM_MIN, I_DEM_MAX, "dem", diff --git a/test/test_bilinear_approximations.jl b/test/test_bilinear_approximations.jl index 8619eb5d..6f36a84c 100644 --- a/test/test_bilinear_approximations.jl +++ b/test/test_bilinear_approximations.jl @@ -12,7 +12,7 @@ const BILINEAR_META = "BilinearTest" JuMP.fix(setup.x_var_container["dev1", 1], 2.5; force = true) JuMP.fix(setup.y_var_container["dev1", 1], 1.5; force = true) - IOM._add_bilinear_approx!( + IOM.add_bilinear_approx!( IOM.Bin2Config(IOM.SolverSOS2QuadConfig(4, 0), add_mc), setup.container, MockThermalGen, @@ -53,7 +53,7 @@ const BILINEAR_META = "BilinearTest" JuMP.set_lower_bound(y_var, 0.0) JuMP.set_upper_bound(y_var, 4.0) - IOM._add_bilinear_approx!( + IOM.add_bilinear_approx!( IOM.Bin2Config(IOM.SolverSOS2QuadConfig(8, 0)), setup.container, MockThermalGen, @@ -86,7 +86,7 @@ const BILINEAR_META = "BilinearTest" JuMP.fix(setup2.x_var_container["dev1", 1], 2.0; force = true) JuMP.fix(setup2.y_var_container["dev1", 1], 3.0; force = true) - IOM._add_bilinear_approx!( + IOM.add_bilinear_approx!( IOM.Bin2Config(IOM.SolverSOS2QuadConfig(8, 0)), setup2.container, MockThermalGen, @@ -124,7 +124,7 @@ const BILINEAR_META = "BilinearTest" w = JuMP.@variable(setup.jump_model, base_name = "w") - IOM._add_bilinear_approx!( + IOM.add_bilinear_approx!( IOM.Bin2Config(IOM.SolverSOS2QuadConfig(8, 0)), setup.container, MockThermalGen, @@ -165,7 +165,7 @@ const BILINEAR_META = "BilinearTest" JuMP.set_lower_bound(y_var, 0.0) JuMP.set_upper_bound(y_var, 4.0) - IOM._add_bilinear_approx!( + IOM.add_bilinear_approx!( IOM.Bin2Config(IOM.SolverSOS2QuadConfig(8, 0)), setup.container, MockThermalGen, @@ -206,7 +206,7 @@ const BILINEAR_META = "BilinearTest" JuMP.fix(x_var, 2.5; force = true) JuMP.fix(y_var, 1.5; force = true) - IOM._add_bilinear_approx!( + IOM.add_bilinear_approx!( IOM.Bin2Config(IOM.SolverSOS2QuadConfig(num_segments, 0)), setup.container, MockThermalGen, @@ -246,7 +246,7 @@ const BILINEAR_META = "BilinearTest" JuMP.fix(setup.x_var_container["dev1", 1], 2.0; force = true) JuMP.fix(setup.y_var_container["dev1", 1], 3.0; force = true) - IOM._add_bilinear_approx!( + IOM.add_bilinear_approx!( IOM.Bin2Config(IOM.ManualSOS2QuadConfig(8, 0)), setup.container, MockThermalGen, @@ -282,7 +282,7 @@ const BILINEAR_META = "BilinearTest" w = JuMP.@variable(setup.jump_model, base_name = "w") - IOM._add_bilinear_approx!( + IOM.add_bilinear_approx!( IOM.Bin2Config(IOM.ManualSOS2QuadConfig(8, 0)), setup.container, MockThermalGen, @@ -321,7 +321,7 @@ const BILINEAR_META = "BilinearTest" JuMP.set_lower_bound(y_var, 0.0) JuMP.set_upper_bound(y_var, 4.0) - IOM._add_bilinear_approx!( + IOM.add_bilinear_approx!( IOM.Bin2Config(IOM.ManualSOS2QuadConfig(8, 0)), setup.container, MockThermalGen, @@ -358,7 +358,7 @@ const BILINEAR_META = "BilinearTest" JuMP.fix(setup.x_var_container["dev1", 1], 2.5; force = true) JuMP.fix(setup.y_var_container["dev1", 1], 1.5; force = true) - IOM._add_bilinear_approx!( + IOM.add_bilinear_approx!( IOM.Bin2Config(IOM.ManualSOS2QuadConfig(num_segments, 0)), setup.container, MockThermalGen, @@ -398,7 +398,7 @@ const BILINEAR_META = "BilinearTest" JuMP.fix(setup.x_var_container["dev1", 1], 2.0; force = true) JuMP.fix(setup.y_var_container["dev1", 1], 3.0; force = true) - IOM._add_bilinear_approx!( + IOM.add_bilinear_approx!( IOM.Bin2Config(IOM.SawtoothQuadConfig(3)), setup.container, MockThermalGen, @@ -434,7 +434,7 @@ const BILINEAR_META = "BilinearTest" w = JuMP.@variable(setup.jump_model, base_name = "w") - IOM._add_bilinear_approx!( + IOM.add_bilinear_approx!( IOM.Bin2Config(IOM.SawtoothQuadConfig(3)), setup.container, MockThermalGen, @@ -473,7 +473,7 @@ const BILINEAR_META = "BilinearTest" JuMP.set_lower_bound(y_var, 0.0) JuMP.set_upper_bound(y_var, 4.0) - IOM._add_bilinear_approx!( + IOM.add_bilinear_approx!( IOM.Bin2Config(IOM.SawtoothQuadConfig(3)), setup.container, MockThermalGen, @@ -510,7 +510,7 @@ const BILINEAR_META = "BilinearTest" JuMP.fix(setup.x_var_container["dev1", 1], 2.5; force = true) JuMP.fix(setup.y_var_container["dev1", 1], 1.5; force = true) - IOM._add_bilinear_approx!( + IOM.add_bilinear_approx!( IOM.Bin2Config(IOM.SawtoothQuadConfig(depth)), setup.container, MockThermalGen, diff --git a/test/test_hybs_approximations.jl b/test/test_hybs_approximations.jl index da2ab025..d15bf8d8 100644 --- a/test/test_hybs_approximations.jl +++ b/test/test_hybs_approximations.jl @@ -7,7 +7,7 @@ const HYBS_BILINEAR_META = "BilinearTest" x_var = setup.var_container["dev1", 1] JuMP.fix(x_var, 0.35; force = true) - IOM._add_quadratic_approx!( + IOM.add_quadratic_approx!( IOM.EpigraphQuadConfig(4), setup.container, MockThermalGen, @@ -40,7 +40,7 @@ const HYBS_BILINEAR_META = "BilinearTest" x_var = setup.var_container["dev1", 1] JuMP.fix(x_var, 1.3; force = true) - IOM._add_quadratic_approx!( + IOM.add_quadratic_approx!( IOM.EpigraphQuadConfig(4), setup.container, MockThermalGen, @@ -76,7 +76,7 @@ const HYBS_BILINEAR_META = "BilinearTest" x_var = setup.var_container["dev1", 1] JuMP.fix(x_var, 0.35; force = true) - IOM._add_quadratic_approx!( + IOM.add_quadratic_approx!( IOM.EpigraphQuadConfig(depth), setup.container, MockThermalGen, @@ -118,7 +118,7 @@ end JuMP.fix(setup.x_var_container["dev1", 1], x0; force = true) JuMP.fix(setup.y_var_container["dev1", 1], y0; force = true) - IOM._add_bilinear_approx!( + IOM.add_bilinear_approx!( IOM.HybSConfig(IOM.SawtoothQuadConfig(2), 2), setup.container, MockThermalGen, @@ -159,7 +159,7 @@ end JuMP.fix(x_var, 2.0; force = true) JuMP.fix(y_var, 3.0; force = true) - IOM._add_bilinear_approx!( + IOM.add_bilinear_approx!( IOM.HybSConfig(IOM.SawtoothQuadConfig(3), 3), setup.container, MockThermalGen, @@ -197,7 +197,7 @@ end w = JuMP.@variable(setup.jump_model, base_name = "w") - IOM._add_bilinear_approx!( + IOM.add_bilinear_approx!( IOM.HybSConfig(IOM.SawtoothQuadConfig(3), 3), setup.container, MockThermalGen, @@ -235,7 +235,7 @@ end JuMP.fix(setup.x_var_container["dev1", 1], 0.4; force = true) JuMP.fix(setup.y_var_container["dev1", 1], 0.7; force = true) - IOM._add_bilinear_approx!( + IOM.add_bilinear_approx!( IOM.HybSConfig(IOM.SawtoothQuadConfig(depth), depth), setup.container, MockThermalGen, @@ -276,7 +276,7 @@ end JuMP.fix(setup.x_var_container["dev1", 1], 3.5; force = true) JuMP.fix(setup.y_var_container["dev1", 1], 2.1; force = true) - IOM._add_bilinear_approx!( + IOM.add_bilinear_approx!( IOM.HybSConfig(IOM.SawtoothQuadConfig(3), 3), setup.container, MockThermalGen, @@ -317,7 +317,7 @@ end JuMP.set_lower_bound(y_var, 0.0) JuMP.set_upper_bound(y_var, 4.0) - IOM._add_bilinear_approx!( + IOM.add_bilinear_approx!( IOM.HybSConfig(IOM.SawtoothQuadConfig(2), 2), setup.container, MockThermalGen, @@ -351,7 +351,7 @@ end for depth in [1, 2, 4] # HybS setup_h = _setup_bilinear_test(["dev1"], 1:1) - IOM._add_bilinear_approx!( + IOM.add_bilinear_approx!( IOM.HybSConfig(IOM.SawtoothQuadConfig(depth), depth), setup_h.container, MockThermalGen, @@ -368,7 +368,7 @@ end # Bin2 (sawtooth) setup_b = _setup_bilinear_test(["dev1"], 1:1) - IOM._add_bilinear_approx!( + IOM.add_bilinear_approx!( IOM.Bin2Config(IOM.SawtoothQuadConfig(depth)), setup_b.container, MockThermalGen, diff --git a/test/test_nmdt_approximations.jl b/test/test_nmdt_approximations.jl index a5c2ecfa..a6c96050 100644 --- a/test/test_nmdt_approximations.jl +++ b/test/test_nmdt_approximations.jl @@ -12,7 +12,7 @@ const NMDT_BILINEAR_META = "NMDTBilinearTest" JuMP.set_upper_bound(setup.var_container["gen1", 1], 1.0) JuMP.fix(setup.var_container["gen1", 1], 0.6; force = true) - IOM._add_quadratic_approx!( + IOM.add_quadratic_approx!( IOM.DNMDTQuadConfig(4, 0), setup.container, MockThermalGen, names, ts, setup.var_container, [(min = 0.0, max = 1.0)], DNMDT_META, @@ -46,7 +46,7 @@ const NMDT_BILINEAR_META = "NMDTBilinearTest" setup = _setup_qa_test(["gen1"], 1:1) JuMP.fix(setup.var_container["gen1", 1], x0; force = true) - IOM._add_quadratic_approx!( + IOM.add_quadratic_approx!( IOM.DNMDTQuadConfig(3, 0), setup.container, MockThermalGen, ["gen1"], 1:1, setup.var_container, [(min = 0.0, max = 1.0)], DNMDT_META, @@ -77,7 +77,7 @@ const NMDT_BILINEAR_META = "NMDTBilinearTest" setup = _setup_qa_test(["gen1"], 1:1) JuMP.fix(setup.var_container["gen1", 1], x0; force = true) - IOM._add_quadratic_approx!( + IOM.add_quadratic_approx!( IOM.DNMDTQuadConfig(2 * L, 0), setup.container, MockThermalGen, ["gen1"], 1:1, setup.var_container, [(min = 0.0, max = 1.0)], DNMDT_META, @@ -109,7 +109,7 @@ end setup = _setup_qa_test(["gen1"], 1:1) JuMP.fix(setup.var_container["gen1", 1], x0; force = true) - IOM._add_quadratic_approx!( + IOM.add_quadratic_approx!( (tighten ? IOM.DNMDTQuadConfig(2) : IOM.DNMDTQuadConfig(2, 0)), setup.container, MockThermalGen, ["gen1"], 1:1, setup.var_container, [(min = 0.0, max = 1.0)], DNMDT_META, @@ -146,7 +146,7 @@ end setup = _setup_qa_test(["gen1"], 1:1) JuMP.fix(setup.var_container["gen1", 1], 0.35; force = true) - IOM._add_quadratic_approx!( + IOM.add_quadratic_approx!( IOM.DNMDTQuadConfig(L), setup.container, MockThermalGen, ["gen1"], 1:1, setup.var_container, [(min = 0.0, max = 1.0)], DNMDT_META, @@ -180,7 +180,7 @@ end JuMP.fix(setup.x_var_container["dev1", 1], x0; force = true) JuMP.fix(setup.y_var_container["dev1", 1], y0; force = true) - IOM._add_bilinear_approx!( + IOM.add_bilinear_approx!( IOM.DNMDTBilinearConfig(2), setup.container, MockThermalGen, ["dev1"], 1:1, setup.x_var_container, setup.y_var_container, @@ -214,7 +214,7 @@ end JuMP.fix(setup.x_var_container["dev1", 1], x0; force = true) JuMP.fix(setup.y_var_container["dev1", 1], y0; force = true) - IOM._add_bilinear_approx!( + IOM.add_bilinear_approx!( IOM.DNMDTBilinearConfig(2 * L), setup.container, MockThermalGen, ["dev1"], 1:1, setup.x_var_container, setup.y_var_container, @@ -249,7 +249,7 @@ end JuMP.fix(setup.x_var_container["dev1", 1], x0; force = true) JuMP.fix(setup.y_var_container["dev1", 1], y0; force = true) - IOM._add_bilinear_approx!( + IOM.add_bilinear_approx!( IOM.DNMDTBilinearConfig(8), setup.container, MockThermalGen, ["dev1"], 1:1, setup.x_var_container, setup.y_var_container, @@ -276,7 +276,7 @@ end # @testset "McCormick toggle" begin # setup = _setup_bilinear_test(["dev1"], 1:1) - # IOM._add_bilinear_approx!( + # IOM.add_bilinear_approx!( # setup.container, MockThermalGen, ["dev1"], 1:1, # setup.x_var_container, setup.y_var_container, # 0.0, 1.0, 0.0, 1.0, 2, DNMDT_META; @@ -293,7 +293,7 @@ end JuMP.fix(setup.x_var_container["dev1", 1], 2.0; force = true) JuMP.fix(setup.y_var_container["dev1", 1], 3.0; force = true) - IOM._add_bilinear_approx!( + IOM.add_bilinear_approx!( IOM.DNMDTBilinearConfig(3), setup.container, MockThermalGen, ["dev1"], 1:1, setup.y_var_container, setup.x_var_container, @@ -321,7 +321,7 @@ end JuMP.fix(setup_d.x_var_container["dev1", 1], 0.4; force = true) JuMP.fix(setup_d.y_var_container["dev1", 1], 0.7; force = true) - IOM._add_bilinear_approx!( + IOM.add_bilinear_approx!( IOM.DNMDTBilinearConfig(depth), setup_d.container, MockThermalGen, ["dev1"], 1:1, setup_d.x_var_container, setup_d.y_var_container, @@ -343,7 +343,7 @@ end JuMP.fix(setup_h.x_var_container["dev1", 1], 0.4; force = true) JuMP.fix(setup_h.y_var_container["dev1", 1], 0.7; force = true) - IOM._add_bilinear_approx!( + IOM.add_bilinear_approx!( IOM.HybSConfig(IOM.SawtoothQuadConfig(depth), depth), setup_h.container, MockThermalGen, ["dev1"], 1:1, setup_h.x_var_container, setup_h.y_var_container, @@ -380,7 +380,7 @@ end JuMP.set_upper_bound(setup.var_container["gen1", 1], 1.0) JuMP.fix(setup.var_container["gen1", 1], 0.6; force = true) - IOM._add_quadratic_approx!( + IOM.add_quadratic_approx!( IOM.NMDTQuadConfig(4, 0), setup.container, MockThermalGen, names, ts, setup.var_container, [(min = 0.0, max = 1.0)], NMDT_META, @@ -414,7 +414,7 @@ end setup = _setup_qa_test(["gen1"], 1:1) JuMP.fix(setup.var_container["gen1", 1], x0; force = true) - IOM._add_quadratic_approx!( + IOM.add_quadratic_approx!( IOM.NMDTQuadConfig(3, 0), setup.container, MockThermalGen, ["gen1"], 1:1, setup.var_container, [(min = 0.0, max = 1.0)], NMDT_META, @@ -447,7 +447,7 @@ end setup = _setup_qa_test(["gen1"], 1:1) JuMP.fix(setup.var_container["gen1", 1], x0; force = true) - IOM._add_quadratic_approx!( + IOM.add_quadratic_approx!( IOM.NMDTQuadConfig(L, 0), setup.container, MockThermalGen, ["gen1"], 1:1, setup.var_container, [(min = 0.0, max = 1.0)], NMDT_META, @@ -477,7 +477,7 @@ end setup = _setup_qa_test(["gen1"], 1:1) JuMP.fix(setup.var_container["gen1", 1], x0; force = true) - IOM._add_quadratic_approx!( + IOM.add_quadratic_approx!( (tighten ? IOM.NMDTQuadConfig(2) : IOM.NMDTQuadConfig(2, 0)), setup.container, MockThermalGen, ["gen1"], 1:1, setup.var_container, [(min = 0.0, max = 1.0)], NMDT_META, @@ -523,7 +523,7 @@ end setup = _setup_qa_test(["gen1"], 1:1) JuMP.fix(setup.var_container["gen1", 1], x0; force = true) - IOM._add_quadratic_approx!( + IOM.add_quadratic_approx!( config_fn(L), setup.container, MockThermalGen, ["gen1"], 1:1, setup.var_container, [(min = 0.0, max = 1.0)], NMDT_META, @@ -558,7 +558,7 @@ end # Both D-NMDT and NMDT univariate use L binary variables for depth=L depth = 4 setup_n = _setup_qa_test(["gen1"], 1:1) - IOM._add_quadratic_approx!( + IOM.add_quadratic_approx!( IOM.NMDTQuadConfig(depth, 0), setup_n.container, MockThermalGen, ["gen1"], 1:1, setup_n.var_container, [(min = 0.0, max = 1.0)], NMDT_META, @@ -566,7 +566,7 @@ end n_bin_nmdt = count(JuMP.is_binary, JuMP.all_variables(setup_n.jump_model)) setup_d = _setup_qa_test(["gen1"], 1:1) - IOM._add_quadratic_approx!( + IOM.add_quadratic_approx!( IOM.DNMDTQuadConfig(depth, 0), setup_d.container, MockThermalGen, ["gen1"], 1:1, setup_d.var_container, [(min = 0.0, max = 1.0)], DNMDT_META, @@ -592,7 +592,7 @@ end JuMP.fix(setup.x_var_container["dev1", 1], x0; force = true) JuMP.fix(setup.y_var_container["dev1", 1], y0; force = true) - IOM._add_bilinear_approx!( + IOM.add_bilinear_approx!( IOM.NMDTBilinearConfig(3), setup.container, MockThermalGen, ["dev1"], 1:1, setup.x_var_container, setup.y_var_container, @@ -627,7 +627,7 @@ end JuMP.fix(setup.x_var_container["dev1", 1], x0; force = true) JuMP.fix(setup.y_var_container["dev1", 1], y0; force = true) - IOM._add_bilinear_approx!( + IOM.add_bilinear_approx!( IOM.NMDTBilinearConfig(L), setup.container, MockThermalGen, ["dev1"], 1:1, setup.x_var_container, setup.y_var_container, @@ -658,7 +658,7 @@ end JuMP.fix(setup.x_var_container["dev1", 1], 0.75; force = true) JuMP.fix(setup.y_var_container["dev1", 1], 0.5; force = true) - IOM._add_bilinear_approx!( + IOM.add_bilinear_approx!( IOM.NMDTBilinearConfig(4), setup.container, MockThermalGen, ["dev1"], 1:1, setup.x_var_container, setup.y_var_container, @@ -684,7 +684,7 @@ end @testset "NMDT uses L binaries, D-NMDT uses 2L" begin L = 3 setup_n = _setup_bilinear_test(["dev1"], 1:1) - IOM._add_bilinear_approx!( + IOM.add_bilinear_approx!( IOM.NMDTBilinearConfig(L), setup_n.container, MockThermalGen, ["dev1"], 1:1, setup_n.x_var_container, setup_n.y_var_container, @@ -693,7 +693,7 @@ end n_bin_nmdt = count(JuMP.is_binary, JuMP.all_variables(setup_n.jump_model)) setup_d = _setup_bilinear_test(["dev1"], 1:1) - IOM._add_bilinear_approx!( + IOM.add_bilinear_approx!( IOM.DNMDTBilinearConfig(L), setup_d.container, MockThermalGen, ["dev1"], 1:1, setup_d.x_var_container, setup_d.y_var_container, @@ -714,7 +714,7 @@ end JuMP.fix(setup_n.x_var_container["dev1", 1], 0.4; force = true) JuMP.fix(setup_n.y_var_container["dev1", 1], 0.7; force = true) - IOM._add_bilinear_approx!( + IOM.add_bilinear_approx!( IOM.NMDTBilinearConfig(L), setup_n.container, MockThermalGen, ["dev1"], 1:1, setup_n.x_var_container, setup_n.y_var_container, @@ -735,7 +735,7 @@ end JuMP.fix(setup_d.x_var_container["dev1", 1], 0.4; force = true) JuMP.fix(setup_d.y_var_container["dev1", 1], 0.7; force = true) - IOM._add_bilinear_approx!( + IOM.add_bilinear_approx!( IOM.DNMDTBilinearConfig(L), setup_d.container, MockThermalGen, ["dev1"], 1:1, setup_d.x_var_container, setup_d.y_var_container, diff --git a/test/test_quadratic_approximations.jl b/test/test_quadratic_approximations.jl index 4bc8f4d0..03488835 100644 --- a/test/test_quadratic_approximations.jl +++ b/test/test_quadratic_approximations.jl @@ -11,7 +11,7 @@ const TEST_META = "TestVar" JuMP.set_lower_bound(x_var, 0.0) JuMP.set_upper_bound(x_var, 4.0) - IOM._add_quadratic_approx!( + IOM.add_quadratic_approx!( IOM.SolverSOS2QuadConfig(4, 0), setup.container, MockThermalGen, @@ -47,7 +47,7 @@ const TEST_META = "TestVar" y = JuMP.@variable(setup.jump_model, base_name = "y") - IOM._add_quadratic_approx!( + IOM.add_quadratic_approx!( IOM.SolverSOS2QuadConfig(4, 0), setup.container, MockThermalGen, @@ -86,7 +86,7 @@ const TEST_META = "TestVar" JuMP.set_lower_bound(x_var, 0.0) JuMP.set_upper_bound(x_var, 6.0) - IOM._add_quadratic_approx!( + IOM.add_quadratic_approx!( IOM.SolverSOS2QuadConfig(num_segments, 0), setup.container, MockThermalGen, @@ -126,7 +126,7 @@ const TEST_META = "TestVar" JuMP.set_lower_bound(x_var, 0.0) JuMP.set_upper_bound(x_var, 4.0) - IOM._add_quadratic_approx!( + IOM.add_quadratic_approx!( IOM.ManualSOS2QuadConfig(4, 0), setup.container, MockThermalGen, @@ -161,7 +161,7 @@ const TEST_META = "TestVar" y = JuMP.@variable(setup.jump_model, base_name = "y") - IOM._add_quadratic_approx!( + IOM.add_quadratic_approx!( IOM.ManualSOS2QuadConfig(4, 0), setup.container, MockThermalGen, @@ -198,7 +198,7 @@ const TEST_META = "TestVar" JuMP.set_lower_bound(x_var, 0.0) JuMP.set_upper_bound(x_var, 4.0) - IOM._add_quadratic_approx!( + IOM.add_quadratic_approx!( IOM.SawtoothQuadConfig(2), setup.container, MockThermalGen, @@ -233,7 +233,7 @@ const TEST_META = "TestVar" y = JuMP.@variable(setup.jump_model, base_name = "y") - IOM._add_quadratic_approx!( + IOM.add_quadratic_approx!( IOM.SawtoothQuadConfig(2), setup.container, MockThermalGen, @@ -271,7 +271,7 @@ const TEST_META = "TestVar" JuMP.set_lower_bound(x_var, 0.0) JuMP.set_upper_bound(x_var, 6.0) - IOM._add_quadratic_approx!( + IOM.add_quadratic_approx!( IOM.SawtoothQuadConfig(depth), setup.container, MockThermalGen, @@ -313,7 +313,7 @@ const TEST_META = "TestVar" JuMP.set_upper_bound(x_var, 4.0) if method == :sos2 - IOM._add_quadratic_approx!( + IOM.add_quadratic_approx!( IOM.SolverSOS2QuadConfig(2^depth, 0), setup.container, MockThermalGen, @@ -324,7 +324,7 @@ const TEST_META = "TestVar" TEST_META, ) else - IOM._add_quadratic_approx!( + IOM.add_quadratic_approx!( IOM.SawtoothQuadConfig(depth), setup.container, MockThermalGen, From 2eb258a0b69f2c713ed5c4ce5cd0f76187d66e84 Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Wed, 20 May 2026 15:45:55 -0400 Subject: [PATCH 3/9] Revert incremental.jl content to main's version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier consolidation commit on this branch edited signatures and docstrings of `add_sparse_pwl_interpolation_variables!` and `_add_generic_incremental_interpolation_constraint!` while moving the file into src/approximations/. The path move was correct (everything PWL-related belongs in approximations/), but the content edits were out of scope — incremental.jl is a container-coupled PWL utility for HVDC models, not an approximation method in the build/register sense. Reverts the file content to byte-for-byte match main's src/quadratic_approximations/incremental.jl while keeping the consolidated path. Co-Authored-By: Claude Opus 4.7 --- src/approximations/incremental.jl | 181 +++++++++++++++++++++--------- 1 file changed, 130 insertions(+), 51 deletions(-) diff --git a/src/approximations/incremental.jl b/src/approximations/incremental.jl index fd49ac2b..527256ca 100644 --- a/src/approximations/incremental.jl +++ b/src/approximations/incremental.jl @@ -1,27 +1,48 @@ -# Incremental piecewise linear (PWL) formulation utility. +# Incremental piecewise linear (PWL) formulation. # -# This is not an approximation method in the `build_quadratic_approx`/ -# `build_bilinear_approx` sense — it's a container-coupled utility used by -# downstream packages (e.g. POM HVDC models) to build PWL variables and -# constraints for arbitrary nonlinear functions. Kept here because the -# math is in the same family as the other PWL approximations. +# This implements the incremental method for PWL approximation using δ (interpolation) +# and z (binary ordering) variables. Retained for downstream compatibility (POM HVDC models). +# +# The same mathematical problem (PWL approximation of nonlinear functions) is also solved by +# `_add_sos2_quadratic_approx!` (convex combination + SOS2) and +# `_add_sawtooth_quadratic_approx!` (sawtooth relaxation), which use different formulations. """ - add_sparse_pwl_interpolation_variables!(container, ::Type{T}, devices, model, num_segments) + add_sparse_pwl_interpolation_variables!(container, devices, ::T, model, num_segments = DEFAULT_INTERPOLATION_LENGTH) Add piecewise linear interpolation variables to an optimization container. -For continuous interpolation variables (`T <: InterpolationVariableType`), -creates `num_segments` variables per (device, t). For binary variables -(`T <: BinaryInterpolationVariableType`), creates `num_segments - 1` -variables (controlling transitions between segments). +This function creates the necessary variables for piecewise linear (PWL) approximation in optimization models. +It adds either continuous interpolation variables (δ) or binary interpolation variables (z) depending on the +variable type `T`. These variables are used in the incremental method for PWL approximation where: + +- **Interpolation variables (δ)**: Continuous variables ∈ [0,1] that represent weights for each segment +- **Binary interpolation variables (z)**: Binary variables that enforce ordering constraints in incremental method + +The function creates a 3-dimensional variable structure indexed by (device_name, segment_index, time_step). +For binary variables, the number of variables is one less than for continuous variables since they control +transitions between segments. # Arguments -- `container::OptimizationContainer`: target container. -- `::Type{T}`: variable type (interpolation or binary interpolation). -- `devices`: iterable of components. -- `model::DeviceModel{U, V}`: device model providing variable bounds. -- `num_segments`: number of PWL segments (default `DEFAULT_INTERPOLATION_LENGTH`). +- `container::OptimizationContainer`: The optimization container to add variables to +- `devices`: Collection of devices for which to create PWL variables +- `::T`: Type parameter specifying the variable type (InterpolationVariableType or BinaryInterpolationVariableType) +- `model::DeviceModel{U, V}`: Device model containing formulation information for bounds +- `num_segments::Int`: Number of linear segments in the PWL approximation (default: DEFAULT_INTERPOLATION_LENGTH) + +# Type Parameters +- `T <: Union{InterpolationVariableType, BinaryInterpolationVariableType}`: Variable type to create +- `U <: IS.InfrastructureSystemsComponent`: Component type for devices +- `V <: AbstractDeviceFormulation`: Device formulation type for bounds + +# Notes +- Binary variables have `num_segments - 1` variables (control transitions between segments) +- Continuous variables have `num_segments` variables (one per segment) +- Variable bounds are set based on the device formulation if available +- Variables are created for all devices and time steps in the optimization horizon + +# See Also +- `_add_generic_incremental_interpolation_constraint!`: Function that uses these variables in constraints """ function add_sparse_pwl_interpolation_variables!( container::OptimizationContainer, @@ -34,22 +55,42 @@ function add_sparse_pwl_interpolation_variables!( U <: IS.InfrastructureSystemsComponent, V <: AbstractDeviceFormulation, } + # TODO: Implement approach for deciding segment length + # Extract time steps from the optimization container time_steps = get_time_steps(container) + + # Create variable container using lazy initialization var_container = lazy_container_addition!(container, T, U) + # Determine if this variable type should be binary based on type, component, and formulation binary_flag = get_variable_binary(T, U, V) + # Calculate number of segments based on variable type: + # - Binary variables: (num_segments - 1) to control transitions between segments + # - Continuous variables: num_segments (one per segment) len_segs = binary_flag ? (num_segments - 1) : num_segments + # Iterate over all devices to create PWL variables for d in devices name = get_name(d) + # Create variables for each time step for t in time_steps + # Pre-allocate array to store variable references for this device and time step + pwlvars = Array{JuMP.VariableRef}(undef, len_segs) + + # Create individual PWL variables for each segment for i in 1:len_segs - var_container[(name, i, t)] = JuMP.@variable( - get_jump_model(container), - base_name = "$(T)_$(name)_{pwl_$(i), $(t)}", - binary = binary_flag - ) + # Create JuMP variable with descriptive name and store in both arrays + pwlvars[i] = + var_container[(name, i, t)] = JuMP.@variable( + get_jump_model(container), + base_name = "$(T)_$(name)_{pwl_$(i), $(t)}", # Descriptive variable name + binary = binary_flag # Set as binary if this is a binary variable type + ) + + # Set upper bound if specified by the device formulation ub = get_variable_upper_bound(T, d, V) ub !== nothing && JuMP.set_upper_bound(var_container[name, i, t], ub) + + # Set lower bound if specified by the device formulation lb = get_variable_lower_bound(T, d, V) lb !== nothing && JuMP.set_lower_bound(var_container[name, i, t], lb) end @@ -61,34 +102,50 @@ end """ _add_generic_incremental_interpolation_constraint!(container, ::R, ::S, ::T, ::U, ::V, devices, dic_var_bkpts, dic_function_bkpts; meta) -Add incremental piecewise linear interpolation constraints relating the -original variable x (type `R`) to its piecewise approximation y = f(x) -(type `S`), using interpolation variables δ (type `T`) and binary variables -z (type `U`) under constraint type `V`. +Add incremental piecewise linear interpolation constraints to an optimization container. + +This function implements the incremental method for piecewise linear approximation in optimization models. +It creates constraints that relate the original variable (x) to its piecewise linear approximation (y = f(x)) +using interpolation variables (δ) and binary variables (z) to ensure proper ordering. -The incremental method represents each segment as: -- `x = x₁ + Σᵢ δᵢ · (xᵢ₊₁ − xᵢ)` with δᵢ ∈ [0, 1] -- `y = y₁ + Σᵢ δᵢ · (yᵢ₊₁ − yᵢ)` +The incremental method represents each segment of the PWL function as: +- x = x₁ + Σᵢ δᵢ(xᵢ₊₁ - xᵢ) where δᵢ ∈ [0,1] +- y = y₁ + Σᵢ δᵢ(yᵢ₊₁ - yᵢ) where yᵢ = f(xᵢ) -Binary variables z enforce the incremental ordering δᵢ₊₁ ≤ zᵢ ≤ δᵢ. +Binary variables z ensure the incremental property: δᵢ₊₁ ≤ zᵢ ≤ δᵢ for adjacent segments. # Arguments -- `container::OptimizationContainer`: target container. -- `::Type{R}`, `::Type{S}`: original and approximated variable types. -- `::Type{T}`, `::Type{U}`: interpolation and binary interpolation types. -- `::Type{V}`: constraint type. -- `devices`: iterable of components. -- `dic_var_bkpts::Dict{String, Vector{Float64}}`: domain breakpoints. -- `dic_function_bkpts::Dict{String, Vector{Float64}}`: function-value breakpoints. -- `meta`: constraint-name prefix (default `CONTAINER_KEY_EMPTY_META`). +- `container::OptimizationContainer`: The optimization container to add constraints to +- `::R`: Type parameter for the original variable (x) +- `::S`: Type parameter for the approximated variable (y = f(x)) +- `::T`: Type parameter for the interpolation variables (δ) +- `::U`: Type parameter for the binary interpolation variables (z) +- `::V`: Type parameter for the constraint type +- `devices::IS.FlattenIteratorWrapper{W}`: Collection of devices to apply constraints to +- `dic_var_bkpts::Dict{String, Vector{Float64}}`: Breakpoints in the domain (x-coordinates) for each device +- `dic_function_bkpts::Dict{String, Vector{Float64}}`: Function values at breakpoints (y-coordinates) for each device +- `meta`: Metadata for constraint naming (default: empty) + +# Type Parameters +- `R <: VariableType`: Original variable type +- `S <: VariableType`: Approximated variable type +- `T <: VariableType`: Interpolation variable type +- `U <: VariableType`: Binary interpolation variable type +- `V <: ConstraintType`: Constraint type +- `W <: IS.InfrastructureSystemsComponent`: Component type for devices + +# Notes +- Creates two types of constraints: variable interpolation and function interpolation +- Adds ordering constraints for binary variables to ensure incremental property +- All constraints are applied for each device and time step """ function _add_generic_incremental_interpolation_constraint!( container::OptimizationContainer, - ::Type{R}, - ::Type{S}, - ::Type{T}, - ::Type{U}, - ::Type{V}, + ::Type{R}, # original var : x + ::Type{S}, # approximated var : y = f(x) + ::Type{T}, # interpolation var : δ + ::Type{U}, # binary interpolation var : z + ::Type{V}, # constraint devices::IS.FlattenIteratorWrapper{W}, dic_var_bkpts::Dict{String, Vector{Float64}}, dic_function_bkpts::Dict{String, Vector{Float64}}; @@ -101,19 +158,25 @@ function _add_generic_incremental_interpolation_constraint!( V <: ConstraintType, W <: IS.InfrastructureSystemsComponent, } + # Extract time steps and device names for constraint indexing time_steps = get_time_steps(container) names = [get_name(d) for d in devices] JuMPmodel = get_jump_model(container) - x_var = if R <: DCVoltage - get_variable(container, R, component_for_hvdc_interpolation(nothing)) + # Retrieve all required variables from the optimization container + # Retrieve original variable for DCVoltage from the Bus + if R <: DCVoltage + # workaround for the fact that we can't write PSY.DCBus. + x_var = get_variable(container, R, component_for_hvdc_interpolation(nothing)) else - get_variable(container, R, W) + x_var = get_variable(container, R, W) # Original variable (domain of function) end - y_var = get_variable(container, S, W) - δ_var = get_variable(container, T, W) - z_var = get_variable(container, U, W) + y_var = get_variable(container, S, W) # Approximated variable (range of function) + δ_var = get_variable(container, T, W) # Interpolation variables (weights for segments) + z_var = get_variable(container, U, W) # Binary variables (ordering constraints) + # Create containers for the two main constraint types + # Container for variable interpolation constraints: x = x₁ + Σᵢ δᵢ(xᵢ₊₁ - xᵢ) const_container_var = add_constraints_container!( container, V, @@ -122,6 +185,8 @@ function _add_generic_incremental_interpolation_constraint!( time_steps; meta = "$(meta)pwl_variable", ) + + # Container for function interpolation constraints: y = y₁ + Σᵢ δᵢ(yᵢ₊₁ - yᵢ) const_container_function = add_constraints_container!( container, V, @@ -131,14 +196,19 @@ function _add_generic_incremental_interpolation_constraint!( meta = "$(meta)pwl_function", ) + # Iterate over all devices to add constraints for each device and time step for d in devices name = get_name(d) + # Get proper name for x variable (if is DCVoltage or not) x_name = (R <: DCVoltage) ? get_name(get_dc_bus(d)) : name - var_bkpts = dic_var_bkpts[name] - function_bkpts = dic_function_bkpts[name] - num_segments = length(var_bkpts) - 1 + var_bkpts = dic_var_bkpts[name] # Breakpoints in domain (x-values) + function_bkpts = dic_function_bkpts[name] # Function values at breakpoints (y-values) + num_segments = length(var_bkpts) - 1 # Number of linear segments for t in time_steps + # Variable interpolation constraint: x = x₁ + Σᵢ δᵢ(xᵢ₊₁ - xᵢ) + # This ensures the original variable is expressed as a convex combination + # of breakpoint intervals weighted by interpolation variables const_container_var[name, t] = JuMP.@constraint( JuMPmodel, x_var[x_name, t] == @@ -147,6 +217,9 @@ function _add_generic_incremental_interpolation_constraint!( i in 1:num_segments ) ) + + # Function interpolation constraint: y = y₁ + Σᵢ δᵢ(yᵢ₊₁ - yᵢ) + # This defines the piecewise linear approximation of the function const_container_function[name, t] = JuMP.@constraint( JuMPmodel, y_var[name, t] == @@ -155,8 +228,14 @@ function _add_generic_incremental_interpolation_constraint!( i in 1:num_segments ) ) + + # Incremental ordering constraints using binary variables (SOS2) + # These ensure that δᵢ₊₁ ≤ zᵢ ≤ δᵢ, which maintains the incremental property: + # segments must be filled in order (δ₁ before δ₂, δ₂ before δ₃, etc.) for i in 1:(num_segments - 1) + # z[i] must be >= δ[i+1]: can't activate later segment without current one JuMP.@constraint(JuMPmodel, z_var[name, i, t] >= δ_var[name, i + 1, t]) + # z[i] must be <= δ[i]: can't be more activated than current segment JuMP.@constraint(JuMPmodel, z_var[name, i, t] <= δ_var[name, i, t]) end end From 9dd86f806b5568c635f32dcea60915d5aa3cd26f Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Wed, 20 May 2026 15:46:37 -0400 Subject: [PATCH 4/9] Refactor McCormick: scalar build + IOM loop adapter (template) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Establishes the layered pattern that the rest of the approximations will follow: Layer 1 — pure-JuMP scalar math: build_mccormick_envelope(model, x, y, z, x_min, x_max, y_min, y_max) -> (upper_1, upper_2, lower_1, lower_2) build_mccormick_upper(model, x, y, z, x_min, x_max, y_min, y_max) -> (upper_1, upper_2) build_reformulated_mccormick(model, x, y, zp1, zx, zy, x_min, x_max, y_min, y_max) -> (c1, c2, c3, c4) Layer 2 — IOM adapter (allocate, loop, write): add_mccormick_approx! -> McCormickConstraint [name, 1:4, t] add_mccormick_upper_approx! -> McCormickUpperConstraint [name, 1:2, t] add_reformulated_mccormick_approx! -> ReformulatedMcCormickConstraint [name, 1:4, t] The `lower_bounds` toggle is gone from the new API — callers that want upper-only call `build_mccormick_upper` / `add_mccormick_upper_approx!` directly. One math function maps 1:1 to one container key; the adapter just iterates the returned NamedTuple values and slots them into the container's 1:N inner axis. Legacy vectorized `build_mccormick_envelope`/`build_reformulated_mccormick` and the `register_mccormick_envelope!`/`register_reformulated_mccormick!` helpers are preserved alongside until NMDT/Bin2/HybS migrate to the new API in their own commits. Final cleanup of the legacy entry points happens in the sweep phase. Co-Authored-By: Claude Opus 4.7 --- src/approximations/mccormick.jl | 345 ++++++++++++++++++++++++-------- 1 file changed, 266 insertions(+), 79 deletions(-) diff --git a/src/approximations/mccormick.jl b/src/approximations/mccormick.jl index 8b57adfa..b9fb8c2c 100644 --- a/src/approximations/mccormick.jl +++ b/src/approximations/mccormick.jl @@ -1,25 +1,41 @@ # McCormick envelope for bilinear products z = x·y. -# Adds up to 4 linear inequalities that bound z given variable bounds on x and y. -# The lower envelopes (z ≥ …) can be omitted when a tighter lower bound is -# supplied elsewhere; the upper envelopes (z ≤ …) are always present. +# +# Three scalar build_* functions form the pure-JuMP math layer (no IOM deps): +# build_mccormick_envelope — 4 constraints (upper_1, upper_2, lower_1, lower_2) +# build_mccormick_upper — 2 constraints (upper_1, upper_2 only) +# build_reformulated_mccormick — 4 constraints for the Bin2 separable identity +# +# Each has a matching IOM adapter (add_*_approx!) that allocates a container +# with a 1:N dimension matching the math's return shape, loops over (name, t), +# and writes the scalar refs into the slots. -"McCormick envelope lower-bound constraints: z ≥ … (entries c1, c2)." -struct McCormickLowerConstraint <: ConstraintType end +# --- Container key types --- -"McCormick envelope upper-bound constraints: z ≤ … (entries c3, c4)." +"McCormick envelope upper-bound constraints: z ≤ … (legacy vectorized split)." struct McCormickUpperConstraint <: ConstraintType end +"McCormick envelope lower-bound constraints: z ≥ … (legacy vectorized split)." +struct McCormickLowerConstraint <: ConstraintType end + +"Combined McCormick envelope constraints for the full 4-constraint envelope." +struct McCormickConstraint <: ConstraintType end + "Reformulated McCormick constraints on Bin2 separable variables." struct ReformulatedMcCormickConstraint <: ConstraintType end -# --- Pure-JuMP single-element helpers --- +# --- Scalar build_* (pure JuMP, primary API) --- """ - build_mccormick_envelope(model, x, y, z, x_min, x_max, y_min, y_max; lower_bounds = true) + build_mccormick_envelope(model, x, y, z, x_min, x_max, y_min, y_max) + +Build the four McCormick inequalities for z ≈ x·y at a single cell: +* upper_1: z ≤ x_max · y + x · y_min − x_max · y_min +* upper_2: z ≤ x_min · y + x · y_max − x_min · y_max +* lower_1: z ≥ x_min · y + x · y_min − x_min · y_min +* lower_2: z ≥ x_max · y + x · y_max − x_max · y_max -Build the McCormick inequalities bounding `z ≈ x·y` on `model`. Returns a -NamedTuple `(lower, upper)` of `(c, c)` tuples (`lower === nothing` when -`lower_bounds == false`). Inputs may be any `JuMP.AbstractJuMPScalar`. +Returns a flat NamedTuple `(upper_1, upper_2, lower_1, lower_2)` of scalar +constraint refs. Inputs are JuMP scalars and plain Float64 bounds. """ function build_mccormick_envelope( model::JuMP.Model, @@ -29,27 +45,246 @@ function build_mccormick_envelope( x_min::Float64, x_max::Float64, y_min::Float64, - y_max::Float64; - lower_bounds::Bool = true, + y_max::Float64, ) - c3 = JuMP.@constraint(model, z <= x_max * y + x * y_min - x_max * y_min) - c4 = JuMP.@constraint(model, z <= x_min * y + x * y_max - x_min * y_max) - lower = if lower_bounds - c1 = JuMP.@constraint(model, z >= x_min * y + x * y_min - x_min * y_min) - c2 = JuMP.@constraint(model, z >= x_max * y + x * y_max - x_max * y_max) - (c1, c2) - else - nothing + upper_1 = JuMP.@constraint(model, z <= x_max * y + x * y_min - x_max * y_min) + upper_2 = JuMP.@constraint(model, z <= x_min * y + x * y_max - x_min * y_max) + lower_1 = JuMP.@constraint(model, z >= x_min * y + x * y_min - x_min * y_min) + lower_2 = JuMP.@constraint(model, z >= x_max * y + x * y_max - x_max * y_max) + return (; upper_1, upper_2, lower_1, lower_2) +end + +""" + build_mccormick_upper(model, x, y, z, x_min, x_max, y_min, y_max) + +Build only the upper-envelope McCormick inequalities (z ≤ …) at a single cell. +Used when a tighter lower bound on z is supplied elsewhere (NMDT residual +product under `tighten`, manual_sos2 inside its SOS2 envelope, etc.). + +Returns a flat NamedTuple `(upper_1, upper_2)` of scalar constraint refs. +""" +function build_mccormick_upper( + model::JuMP.Model, + x::JuMP.AbstractJuMPScalar, + y::JuMP.AbstractJuMPScalar, + z::JuMP.AbstractJuMPScalar, + x_min::Float64, + x_max::Float64, + y_min::Float64, + y_max::Float64, +) + upper_1 = JuMP.@constraint(model, z <= x_max * y + x * y_min - x_max * y_min) + upper_2 = JuMP.@constraint(model, z <= x_min * y + x * y_max - x_min * y_max) + return (; upper_1, upper_2) +end + +""" + build_reformulated_mccormick(model, x, y, zp1, zx, zy, x_min, x_max, y_min, y_max) + +Build the four reformulated-McCormick inequalities for the Bin2 separable +identity (zp1 ≈ (x+y)², zx ≈ x², zy ≈ y²) at a single cell. + +Returns a flat NamedTuple `(c1, c2, c3, c4)` of scalar constraint refs: +* c1, c2 are lower envelopes (zp1 − zx − zy ≥ 2 · …) +* c3, c4 are upper envelopes (zp1 − zx − zy ≤ 2 · …) +""" +function build_reformulated_mccormick( + model::JuMP.Model, + x::JuMP.AbstractJuMPScalar, + y::JuMP.AbstractJuMPScalar, + zp1::JuMP.AbstractJuMPScalar, + zx::JuMP.AbstractJuMPScalar, + zy::JuMP.AbstractJuMPScalar, + x_min::Float64, + x_max::Float64, + y_min::Float64, + y_max::Float64, +) + c1 = JuMP.@constraint( + model, + zp1 - zx - zy >= 2.0 * (x_min * y + x * y_min - x_min * y_min), + ) + c2 = JuMP.@constraint( + model, + zp1 - zx - zy >= 2.0 * (x_max * y + x * y_max - x_max * y_max), + ) + c3 = JuMP.@constraint( + model, + zp1 - zx - zy <= 2.0 * (x_max * y + x * y_min - x_max * y_min), + ) + c4 = JuMP.@constraint( + model, + zp1 - zx - zy <= 2.0 * (x_min * y + x * y_max - x_min * y_max), + ) + return (; c1, c2, c3, c4) +end + +# --- IOM adapters (allocate, loop, write) --- + +""" + add_mccormick_approx!(container, ::Type{C}, x_var, y_var, z_var, x_bounds, y_bounds, meta) + +Allocate a `McCormickConstraint` container with axes `(name, 1:4, time)`, +loop over `(name, t)`, call `build_mccormick_envelope` per cell, and write +the four returned constraint refs into slots `1..4` of the container. + +Returns the registered container. +""" +function add_mccormick_approx!( + container::OptimizationContainer, + ::Type{C}, + x_var, + y_var, + z_var, + x_bounds::Vector{MinMax}, + y_bounds::Vector{MinMax}, + meta::String, +) where {C <: IS.InfrastructureSystemsComponent} + name_axis = axes(x_var, 1) + time_axis = axes(x_var, 2) + IS.@assert_op length(name_axis) == length(x_bounds) + IS.@assert_op length(name_axis) == length(y_bounds) + for i in eachindex(x_bounds) + IS.@assert_op x_bounds[i].max > x_bounds[i].min + IS.@assert_op y_bounds[i].max > y_bounds[i].min + end + + model = get_jump_model(container) + target = add_constraints_container!( + container, McCormickConstraint, C, name_axis, 1:4, time_axis; meta, + ) + + for (i, name) in enumerate(name_axis) + xmn, xmx = x_bounds[i].min, x_bounds[i].max + ymn, ymx = y_bounds[i].min, y_bounds[i].max + for t in time_axis + r = build_mccormick_envelope( + model, + x_var[name, t], y_var[name, t], z_var[name, t], + xmn, xmx, ymn, ymx, + ) + for (k, ref) in enumerate(r) + target[name, k, t] = ref + end + end end - return (lower = lower, upper = (c3, c4)) + return target end +""" + add_mccormick_upper_approx!(container, ::Type{C}, x_var, y_var, z_var, x_bounds, y_bounds, meta) + +Allocate a `McCormickUpperConstraint` container with axes `(name, 1:2, time)`, +loop over `(name, t)`, call `build_mccormick_upper` per cell, and write the +two returned constraint refs into slots `1..2`. + +Returns the registered container. +""" +function add_mccormick_upper_approx!( + container::OptimizationContainer, + ::Type{C}, + x_var, + y_var, + z_var, + x_bounds::Vector{MinMax}, + y_bounds::Vector{MinMax}, + meta::String, +) where {C <: IS.InfrastructureSystemsComponent} + name_axis = axes(x_var, 1) + time_axis = axes(x_var, 2) + IS.@assert_op length(name_axis) == length(x_bounds) + IS.@assert_op length(name_axis) == length(y_bounds) + for i in eachindex(x_bounds) + IS.@assert_op x_bounds[i].max > x_bounds[i].min + IS.@assert_op y_bounds[i].max > y_bounds[i].min + end + + model = get_jump_model(container) + target = add_constraints_container!( + container, McCormickUpperConstraint, C, name_axis, 1:2, time_axis; meta, + ) + + for (i, name) in enumerate(name_axis) + xmn, xmx = x_bounds[i].min, x_bounds[i].max + ymn, ymx = y_bounds[i].min, y_bounds[i].max + for t in time_axis + r = build_mccormick_upper( + model, + x_var[name, t], y_var[name, t], z_var[name, t], + xmn, xmx, ymn, ymx, + ) + for (k, ref) in enumerate(r) + target[name, k, t] = ref + end + end + end + return target +end + +""" + add_reformulated_mccormick_approx!(container, ::Type{C}, x_var, y_var, zp1, zx, zy, x_bounds, y_bounds, meta) + +Allocate a `ReformulatedMcCormickConstraint` container with axes +`(name, 1:4, time)`, loop over `(name, t)`, call `build_reformulated_mccormick` +per cell, and write the four returned constraint refs into slots `1..4`. +""" +function add_reformulated_mccormick_approx!( + container::OptimizationContainer, + ::Type{C}, + x_var, + y_var, + zp1_expr, + zx_expr, + zy_expr, + x_bounds::Vector{MinMax}, + y_bounds::Vector{MinMax}, + meta::String, +) where {C <: IS.InfrastructureSystemsComponent} + name_axis = axes(x_var, 1) + time_axis = axes(x_var, 2) + IS.@assert_op length(name_axis) == length(x_bounds) + IS.@assert_op length(name_axis) == length(y_bounds) + for i in eachindex(x_bounds) + IS.@assert_op x_bounds[i].max > x_bounds[i].min + IS.@assert_op y_bounds[i].max > y_bounds[i].min + end + + model = get_jump_model(container) + target = add_constraints_container!( + container, ReformulatedMcCormickConstraint, C, name_axis, 1:4, time_axis; meta, + ) + + for (i, name) in enumerate(name_axis) + xmn, xmx = x_bounds[i].min, x_bounds[i].max + ymn, ymx = y_bounds[i].min, y_bounds[i].max + for t in time_axis + r = build_reformulated_mccormick( + model, + x_var[name, t], y_var[name, t], + zp1_expr[name, t], zx_expr[name, t], zy_expr[name, t], + xmn, xmx, ymn, ymx, + ) + for (k, ref) in enumerate(r) + target[name, k, t] = ref + end + end + end + return target +end + +# --- Legacy vectorized build_* and register_* helpers --- +# +# Kept for callers in nmdt_discretization.jl, bin2.jl, and hybs.jl that have +# not yet migrated to the scalar+adapter pattern above. These will be removed +# in the sweep task once all callers are refactored. + """ build_mccormick_envelope(model, x, y, z, x_bounds, y_bounds; lower_bounds = true) -Vectorized McCormick envelope over a `(name, t)` grid. Returns a NamedTuple -`(lower, upper)` where each side is a pair `(c, c)` of 2D `DenseAxisArray`s -indexed by `(name, t)`. `lower === nothing` when `lower_bounds == false`. +Legacy vectorized McCormick envelope over a `(name, t)` grid. Returns a +NamedTuple `(lower, upper)` where each side is a pair `(c, c)` of 2D +`DenseAxisArray`s indexed by `(name, t)`. `lower === nothing` when +`lower_bounds == false`. """ function build_mccormick_envelope( model::JuMP.Model, @@ -110,51 +345,11 @@ function build_mccormick_envelope( return (lower = lower, upper = (upper_1, upper_2)) end -# --- Bin2 reformulated-McCormick helpers (used inside build_bilinear_approx(::Bin2Config, ...)) --- - -""" - build_reformulated_mccormick(model, x, y, zp1, zx, zy, x_min, x_max, y_min, y_max) - -Build the four reformulated-McCormick inequalities for the Bin2 separable -identity, in terms of the quadratic approximations zp1 ≈ (x+y)², zx ≈ x², -zy ≈ y². Returns the four constraints as a tuple. -""" -function build_reformulated_mccormick( - model::JuMP.Model, - x::JuMP.AbstractJuMPScalar, - y::JuMP.AbstractJuMPScalar, - zp1::JuMP.AbstractJuMPScalar, - zx::JuMP.AbstractJuMPScalar, - zy::JuMP.AbstractJuMPScalar, - x_min::Float64, - x_max::Float64, - y_min::Float64, - y_max::Float64, -) - c1 = JuMP.@constraint( - model, - zp1 - zx - zy >= 2.0 * (x_min * y + x * y_min - x_min * y_min), - ) - c2 = JuMP.@constraint( - model, - zp1 - zx - zy >= 2.0 * (x_max * y + x * y_max - x_max * y_max), - ) - c3 = JuMP.@constraint( - model, - zp1 - zx - zy <= 2.0 * (x_max * y + x * y_min - x_max * y_min), - ) - c4 = JuMP.@constraint( - model, - zp1 - zx - zy <= 2.0 * (x_min * y + x * y_max - x_min * y_max), - ) - return (c1, c2, c3, c4) -end - """ build_reformulated_mccormick(model, x, y, zp1, zx, zy, x_bounds, y_bounds) -Vectorized reformulated McCormick over the `(name, t)` grid. Returns a -4-tuple of 2D `DenseAxisArray`s, one per cut. +Legacy vectorized reformulated McCormick over the `(name, t)` grid. Returns +a 4-tuple of 2D `DenseAxisArray`s, one per cut. """ function build_reformulated_mccormick( model::JuMP.Model, @@ -211,15 +406,12 @@ function build_reformulated_mccormick( return (c1, c2, c3, c4) end -# --- IOM-side McCormick container registration --- - """ register_mccormick_envelope!(container, ::Type{C}, mc, meta) -Register a McCormick envelope (NamedTuple `(lower, upper)` as returned by -the vectorized `build_mccormick_envelope`) into the optimization container. -`mc.upper` is written under `McCormickUpperConstraint`; `mc.lower`, when -non-`nothing`, under `McCormickLowerConstraint`. +Legacy registration helper for the vectorized McCormick envelope. Splits +`mc.upper` into `McCormickUpperConstraint` and `mc.lower` (when non-nothing) +into `McCormickLowerConstraint`. """ function register_mccormick_envelope!( container::OptimizationContainer, @@ -232,7 +424,6 @@ function register_mccormick_envelope!( return end -# No-op when this McCormick envelope was disabled at the call site. register_mccormick_envelope!( ::OptimizationContainer, ::Type{<:IS.InfrastructureSystemsComponent}, @@ -261,7 +452,6 @@ function _register_mccormick_side!( return end -# No-op when the lower-bound side wasn't built. _register_mccormick_side!( ::OptimizationContainer, ::Type{<:IS.InfrastructureSystemsComponent}, @@ -273,9 +463,7 @@ _register_mccormick_side!( """ register_reformulated_mccormick!(container, ::Type{C}, cons, meta) -Register a reformulated McCormick constraint set (the 4-tuple of 2D -constraint containers returned by `build_reformulated_mccormick`) into the -optimization container under `ReformulatedMcCormickConstraint`. +Legacy registration helper for the vectorized reformulated McCormick. """ function register_reformulated_mccormick!( container::OptimizationContainer, @@ -307,7 +495,6 @@ function register_reformulated_mccormick!( return end -# No-op when this McCormick envelope was disabled at the call site. register_reformulated_mccormick!( ::OptimizationContainer, ::Type{<:IS.InfrastructureSystemsComponent}, From e57352bdf8dff98fb2530967b008f914b18963af Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Wed, 20 May 2026 16:40:01 -0400 Subject: [PATCH 5/9] Refactor no_approx: scalar build + IOM loop adapter Apply the McCormick template to the trivial cases. Each file now has: Scalar: build_quadratic_approx(::NoQuadApproxConfig, model, x, x_min, x_max) build_bilinear_approx(::NoBilinearApproxConfig, model, x, y, x_min, x_max, y_min, y_max) Adapter: add_quadratic_approx!(::NoQuadApproxConfig, container, C, x_var, x_bounds, meta) add_bilinear_approx!(::NoBilinearApproxConfig, container, C, x_var, y_var, x_bounds, y_bounds, meta) Scalar form returns a flat NamedTuple `(; approximation)` holding the exact QuadExpr for that cell. The adapter loops `(name, t)`, calls the scalar, and writes one cell at a time into a `QuadraticExpression` / `BilinearProductExpression` container. Legacy vectorized build_* + register_in_container! kept alongside for the generic add_*_approx! wrapper in common.jl; the precomputed-form 12-arg add_bilinear_approx! (used as a swap-in for Bin2/HybS) also kept. All removed in the sweep phase. Co-Authored-By: Claude Opus 4.7 --- src/approximations/no_approx_bilinear.jl | 81 +++++++++++++++++++++-- src/approximations/no_approx_quadratic.jl | 62 ++++++++++++++++- 2 files changed, 134 insertions(+), 9 deletions(-) diff --git a/src/approximations/no_approx_bilinear.jl b/src/approximations/no_approx_bilinear.jl index 280f3818..03bdab5e 100644 --- a/src/approximations/no_approx_bilinear.jl +++ b/src/approximations/no_approx_bilinear.jl @@ -4,7 +4,74 @@ "No-op config for bilinear approximation: returns exact x·y as a QuadExpr." struct NoBilinearApproxConfig <: BilinearApproxConfig end -"Pure-JuMP result of the no-op bilinear approximation." +# --- Scalar build (pure JuMP, primary API) --- + +""" + build_bilinear_approx(::NoBilinearApproxConfig, model, x, y, x_min, x_max, y_min, y_max) + +Scalar form: return `(; approximation = x*y)` for a single JuMP scalar pair. +Bounds are accepted for signature parity with other bilinear methods and unused. +""" +function build_bilinear_approx( + ::NoBilinearApproxConfig, + model::JuMP.Model, + x::JuMP.AbstractJuMPScalar, + y::JuMP.AbstractJuMPScalar, + x_min::Float64, + x_max::Float64, + y_min::Float64, + y_max::Float64, +) + return (; approximation = x * y) +end + +# --- IOM adapter (allocate, loop, write) --- + +""" + add_bilinear_approx!(::NoBilinearApproxConfig, container, ::Type{C}, x_var, y_var, x_bounds, y_bounds, meta) + +Allocate a `BilinearProductExpression` container with axes `(name, t)`, loop +over the cells, and write the exact `x*y` QuadExpr per cell. +""" +function add_bilinear_approx!( + ::NoBilinearApproxConfig, + container::OptimizationContainer, + ::Type{C}, + x_var, + y_var, + x_bounds::Vector{MinMax}, + y_bounds::Vector{MinMax}, + meta::String, +) where {C <: IS.InfrastructureSystemsComponent} + name_axis = axes(x_var, 1) + time_axis = axes(x_var, 2) + IS.@assert_op length(name_axis) == length(x_bounds) + IS.@assert_op length(name_axis) == length(y_bounds) + model = get_jump_model(container) + target = add_expression_container!( + container, BilinearProductExpression, C, name_axis, time_axis; + meta, expr_type = JuMP.QuadExpr, + ) + for (i, name) in enumerate(name_axis) + xmn, xmx = x_bounds[i].min, x_bounds[i].max + ymn, ymx = y_bounds[i].min, y_bounds[i].max + for t in time_axis + r = build_bilinear_approx( + NoBilinearApproxConfig(), model, + x_var[name, t], y_var[name, t], + xmn, xmx, ymn, ymx, + ) + target[name, t] = r.approximation + end + end + return target +end + +# --- Legacy vectorized build + register + precomputed-form entrypoint +# (kept for the generic add_bilinear_approx! wrapper in common.jl and the +# Bin2/HybS swap-in pattern, until callers migrate; removed in sweep) --- + +"Pure-JuMP result of the no-op bilinear approximation (legacy)." struct NoBilinearApproxResult{ A <: JuMP.Containers.DenseAxisArray{JuMP.QuadExpr, 2}, } <: BilinearApproxResult @@ -14,7 +81,8 @@ end """ build_bilinear_approx(::NoBilinearApproxConfig, model, x, y, x_bounds, y_bounds) -Build the exact x·y product. Bounds are accepted for signature parity and unused. +Legacy vectorized form. Returns a `NoBilinearApproxResult` wrapping a 2D +`DenseAxisArray{QuadExpr}` of `x[name,t]*y[name,t]`. """ function build_bilinear_approx( ::NoBilinearApproxConfig, @@ -54,10 +122,11 @@ end add_bilinear_approx!(::NoBilinearApproxConfig, container, C, names, time_steps, xsq, ysq, x_var, y_var, x_bounds, y_bounds, meta) -Precomputed-form entrypoint: signature-compatible with the precomputed-form -of `Bin2Config` / `HybSConfig`, so a caller can swap configs without -changing the call site. `xsq` and `ysq` are accepted but ignored — the -no-op approximation just returns the exact `x·y` product as a `QuadExpr`. +Legacy precomputed-form entrypoint: signature-compatible with the +precomputed-form of `Bin2Config` / `HybSConfig`, so a caller can swap +configs without changing the call site. `xsq` and `ysq` are accepted but +ignored — the no-op approximation just returns the exact `x·y` product as +a `QuadExpr`. """ function add_bilinear_approx!( ::NoBilinearApproxConfig, diff --git a/src/approximations/no_approx_quadratic.jl b/src/approximations/no_approx_quadratic.jl index 36037bed..5cca18e7 100644 --- a/src/approximations/no_approx_quadratic.jl +++ b/src/approximations/no_approx_quadratic.jl @@ -4,7 +4,63 @@ "No-op config for quadratic approximation: returns exact x² as a QuadExpr." struct NoQuadApproxConfig <: QuadraticApproxConfig end -"Pure-JuMP result of the no-op quadratic approximation." +# --- Scalar build (pure JuMP, primary API) --- + +""" + build_quadratic_approx(::NoQuadApproxConfig, model, x, x_min, x_max) + +Scalar form: return `(; approximation = x*x)` for a single JuMP scalar. +Bounds are accepted for signature parity with other quadratic methods and unused. +""" +function build_quadratic_approx( + ::NoQuadApproxConfig, + model::JuMP.Model, + x::JuMP.AbstractJuMPScalar, + x_min::Float64, + x_max::Float64, +) + return (; approximation = x * x) +end + +# --- IOM adapter (allocate, loop, write) --- + +""" + add_quadratic_approx!(::NoQuadApproxConfig, container, ::Type{C}, x_var, x_bounds, meta) + +Allocate a `QuadraticExpression` container with axes `(name, t)`, loop over +the cells, call the scalar `build_quadratic_approx(::NoQuadApproxConfig, ...)`, +and write the exact `x*x` QuadExpr per cell. +""" +function add_quadratic_approx!( + ::NoQuadApproxConfig, + container::OptimizationContainer, + ::Type{C}, + x_var, + x_bounds::Vector{MinMax}, + meta::String, +) where {C <: IS.InfrastructureSystemsComponent} + name_axis = axes(x_var, 1) + time_axis = axes(x_var, 2) + IS.@assert_op length(name_axis) == length(x_bounds) + model = get_jump_model(container) + target = add_expression_container!( + container, QuadraticExpression, C, name_axis, time_axis; + meta, expr_type = JuMP.QuadExpr, + ) + for (i, name) in enumerate(name_axis) + xmn, xmx = x_bounds[i].min, x_bounds[i].max + for t in time_axis + r = build_quadratic_approx(NoQuadApproxConfig(), model, x_var[name, t], xmn, xmx) + target[name, t] = r.approximation + end + end + return target +end + +# --- Legacy vectorized build + register (kept for the generic add_quadratic_approx! +# wrapper in common.jl until callers migrate; removed in sweep) --- + +"Pure-JuMP result of the no-op quadratic approximation (legacy)." struct NoQuadApproxResult{ A <: JuMP.Containers.DenseAxisArray{JuMP.QuadExpr, 2}, } <: QuadraticApproxResult @@ -14,8 +70,8 @@ end """ build_quadratic_approx(::NoQuadApproxConfig, model, x, bounds) -Build the exact x² expression for each (name, t) and wrap in a result struct. -`bounds` is accepted for signature parity but is unused. +Legacy vectorized form. Returns a `NoQuadApproxResult` wrapping a 2D +`DenseAxisArray{QuadExpr}` of `x[name,t]^2`. """ function build_quadratic_approx( ::NoQuadApproxConfig, From 07cfb757e575f86accef0c3d10d98beea1fe6617 Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Wed, 20 May 2026 16:46:34 -0400 Subject: [PATCH 6/9] Refactor epigraph + sawtooth: scalar build + IOM loop adapter Epigraph and sawtooth both have internal recursion-depth axes the math owns (g_levels = 0:depth, alpha_levels = 1:depth, lp/mip/tangent sub-axes). Scalar build_quadratic_approx(::Config, model, x_scalar, x_min, x_max) takes one cell, builds the per-cell g/alpha/z variables and inner-axis constraint arrays itself, returns them in a flat NamedTuple. IOM adapter loops (name, t), calls the scalar per cell, slots refs into the appropriately-shaped containers. Sawtooth optionally composes epigraph for LP tightening. When enabled, the scalar sawtooth calls scalar epigraph in its per-cell math and embeds the epigraph NamedTuple in its `tightening` field. The sawtooth adapter allocates the epigraph containers under `meta * "_lb"` and writes them inline alongside sawtooth's own outputs. Adds `scale_back_g_basis_scalar` helper (1D g_var, no name/t indexing) to share the parabola anchor + residual decomposition between the new scalar sawtooth and scalar epigraph paths. Legacy result structs (EpigraphQuadResult, SawtoothQuadResult, SawtoothTightening) + vectorized build + register_in_container! preserved alongside until the generic add_quadratic_approx! wrapper in common.jl goes away in the sweep phase. Co-Authored-By: Claude Opus 4.7 --- src/approximations/epigraph.jl | 205 +++++++++++++++++++++++-- src/approximations/sawtooth.jl | 264 +++++++++++++++++++++++++++++++-- 2 files changed, 447 insertions(+), 22 deletions(-) diff --git a/src/approximations/epigraph.jl b/src/approximations/epigraph.jl index e9f05df3..cd7299a2 100644 --- a/src/approximations/epigraph.jl +++ b/src/approximations/epigraph.jl @@ -43,6 +43,18 @@ express the parabola anchor + residual decomposition in this form. sum(delta^2 * 2.0^(-2j) * g_var[name, j, t] for j in levels) end +""" + scale_back_g_basis_scalar(x_min, delta, g_var, levels) + +Scalar form of `scale_back_g_basis`: `g_var` is a 1D `DenseAxisArray` +indexed by j (the levels axis) for a single (name, t) cell. +""" +@inline function scale_back_g_basis_scalar(x_min, delta, g_var, levels) + return x_min^2 + + (2.0 * x_min * delta + delta^2) * g_var[0] - + sum(delta^2 * 2.0^(-2j) * g_var[j] for j in levels) +end + """ Config for epigraph (Q^{L1}) LP-only lower-bound quadratic approximation. @@ -54,8 +66,185 @@ struct EpigraphQuadConfig <: QuadraticApproxConfig depth::Int end +# --- Scalar build (pure JuMP, primary API) --- + +""" + build_quadratic_approx(config::EpigraphQuadConfig, model, x, x_min, x_max) + +Scalar form: build the epigraph relaxation for a single JuMP scalar `x` +with bounds `[x_min, x_max]`. Creates the per-cell g basis (j ∈ 0:depth), +LP relaxation constraints, z variable, tangent expression `fL`, and the +`depth + 2` tangent cuts. + +Returns a NamedTuple: +- `approximation` :: JuMP.AffExpr (1.0 · z) +- `z_var` :: JuMP.VariableRef +- `g_var` :: DenseAxisArray{VariableRef, 1} over `0:depth` +- `link_constraint` :: scalar constraint linking g₀ to (x − x_min)/δ +- `lp_constraints` :: DenseAxisArray{Constraint, 2} over `(1:depth, 1:2)` +- `tangent_expression`:: JuMP.AffExpr (full-depth residual sum) +- `tangent_constraints`:: DenseAxisArray{Constraint, 1} over `1:(depth+2)` +""" +function build_quadratic_approx( + config::EpigraphQuadConfig, + model::JuMP.Model, + x::JuMP.AbstractJuMPScalar, + x_min::Float64, + x_max::Float64, +) + IS.@assert_op config.depth >= 1 + IS.@assert_op x_max > x_min + + depth = config.depth + delta = x_max - x_min + z_ub = max(x_min^2, x_max^2) + + g_var = JuMP.@variable( + model, [j = 0:depth], + lower_bound = 0.0, upper_bound = 1.0, + base_name = "SawtoothAux", + ) + + link_con = JuMP.@constraint(model, g_var[0] == (x - x_min) / delta) + + # T^L constraints: g_j ≤ 2 g_{j-1} and g_j ≤ 2(1 − g_{j-1}) for j = 1..L. + lp_a = JuMP.@constraint(model, [j = 1:depth], g_var[j] <= 2.0 * g_var[j - 1]) + lp_b = JuMP.@constraint( + model, [j = 1:depth], g_var[j] <= 2.0 * (1.0 - g_var[j - 1]), + ) + lp_cons = JuMP.Containers.DenseAxisArray{eltype(lp_a.data)}( + undef, 1:depth, 1:2, + ) + @views lp_cons.data[:, 1] .= lp_a.data + @views lp_cons.data[:, 2] .= lp_b.data + + z_var = JuMP.@variable( + model, lower_bound = 0.0, upper_bound = z_ub, base_name = "EpigraphVar", + ) + + fL_expr = JuMP.@expression( + model, + sum(delta^2 * 2.0^(-2j) * g_var[j] for j in 1:depth), + ) + + # Tangent cuts: + # k=1: z ≥ 0 + # k=2: z ≥ 2·x_min + 2·δ·g₀ − 1 + # k=j+2 for j=1..L: z ≥ scale_back_g_basis_scalar(1:j) − δ²·2^{−2j−2} + tangent_zero = JuMP.@constraint(model, z_var >= 0.0) + tangent_anchor = JuMP.@constraint( + model, z_var >= 2.0 * x_min - 1.0 + 2.0 * delta * g_var[0], + ) + tangent_levels = JuMP.@constraint( + model, [j = 1:depth], + z_var >= + scale_back_g_basis_scalar(x_min, delta, g_var, 1:j) - + delta^2 * 2.0^(-2j - 2), + ) + + tangent_cons = JuMP.Containers.DenseAxisArray{typeof(tangent_zero)}( + undef, 1:(depth + 2), + ) + tangent_cons[1] = tangent_zero + tangent_cons[2] = tangent_anchor + @views tangent_cons.data[3:end] .= tangent_levels.data + + approximation = JuMP.@expression(model, 1.0 * z_var) + + return (; + approximation, + z_var, + g_var, + link_constraint = link_con, + lp_constraints = lp_cons, + tangent_expression = fL_expr, + tangent_constraints = tangent_cons, + ) +end + +# --- IOM adapter (allocate, loop, write) --- + +""" + add_quadratic_approx!(config::EpigraphQuadConfig, container, ::Type{C}, x_var, x_bounds, meta) + +Allocate all output containers (z, g, link/lp/tangent constraints, fL and +approximation expressions) with axes drawn from `x_var`'s `(name, t)` plus +the internal `(depth)` axes, then loop `(name, t)` calling the scalar +`build_quadratic_approx(::EpigraphQuadConfig, ...)` per cell. Writes the +scalar refs and small inner-axis arrays into the container slots. + +Returns the registered `EpigraphExpression` container (the approximation). +""" +function add_quadratic_approx!( + config::EpigraphQuadConfig, + container::OptimizationContainer, + ::Type{C}, + x_var, + x_bounds::Vector{MinMax}, + meta::String, +) where {C <: IS.InfrastructureSystemsComponent} + name_axis = axes(x_var, 1) + time_axis = axes(x_var, 2) + depth = config.depth + IS.@assert_op depth >= 1 + IS.@assert_op length(name_axis) == length(x_bounds) + for b in x_bounds + IS.@assert_op b.max > b.min + end + + model = get_jump_model(container) + + z_target = add_variable_container!( + container, EpigraphVariable, C, name_axis, time_axis; meta, + ) + g_target = add_variable_container!( + container, SawtoothAuxVariable, C, name_axis, 0:depth, time_axis; meta, + ) + link_target = add_constraints_container!( + container, SawtoothLinkingConstraint, C, name_axis, time_axis; meta, + ) + fL_target = add_expression_container!( + container, EpigraphTangentExpression, C, name_axis, time_axis; meta, + ) + approx_target = add_expression_container!( + container, EpigraphExpression, C, name_axis, time_axis; meta, + ) + lp_target = add_constraints_container!( + container, SawtoothLPConstraint, C, name_axis, 1:depth, 1:2, time_axis; meta, + ) + tangent_target = add_constraints_container!( + container, EpigraphTangentConstraint, C, name_axis, 1:(depth + 2), time_axis; + meta, + ) + + for (i, name) in enumerate(name_axis) + xmn, xmx = x_bounds[i].min, x_bounds[i].max + for t in time_axis + r = build_quadratic_approx(config, model, x_var[name, t], xmn, xmx) + z_target[name, t] = r.z_var + for j in 0:depth + g_target[name, j, t] = r.g_var[j] + end + link_target[name, t] = r.link_constraint + fL_target[name, t] = r.tangent_expression + approx_target[name, t] = r.approximation + for j in 1:depth, k in 1:2 + lp_target[name, j, k, t] = r.lp_constraints[j, k] + end + for j in 1:(depth + 2) + tangent_target[name, j, t] = r.tangent_constraints[j] + end + end + end + return approx_target +end + +# --- Legacy result struct + vectorized build + register +# (kept for the generic add_quadratic_approx! wrapper in common.jl until +# callers migrate; removed in sweep) --- + """ -Pure-JuMP result of `build_quadratic_approx(::EpigraphQuadConfig, ...)`. +Pure-JuMP result of legacy vectorized `build_quadratic_approx(::EpigraphQuadConfig, ...)`. """ struct EpigraphQuadResult{ A <: JuMP.Containers.DenseAxisArray{JuMP.AffExpr, 2}, @@ -78,8 +267,9 @@ end """ build_quadratic_approx(config::EpigraphQuadConfig, model, x, bounds) -LP-only lower bound for x² via 2^depth + 1 tangent-line cuts on the -parabola at uniformly spaced breakpoints in [x_min, x_max]. +Legacy vectorized form. LP-only lower bound for x² via 2^depth + 1 +tangent-line cuts on the parabola at uniformly spaced breakpoints in +[x_min, x_max]. """ function build_quadratic_approx( config::EpigraphQuadConfig, @@ -117,8 +307,6 @@ function build_quadratic_approx( g_var[name, 0, t] == (x[name, t] - x_min_arr[name]) / delta[name] ) - # T^L constraints: g_j ≤ 2 g_{j-1} and g_j ≤ 2(1 − g_{j-1}) for j = 1..L. - # Stack two depth × N × T families into a (name, j, k, t) container. lp_a = JuMP.@constraint( model, [name = name_axis, j = 1:(config.depth), t = time_axis], @@ -143,19 +331,12 @@ function build_quadratic_approx( base_name = "EpigraphVar", ) - # fL = Σ_{j=1..L} δ²·2^{−2j}·g_j (full-depth residual sum used downstream - # by the optional sawtooth tightening; the per-j partial sums for the - # tangent cuts are formed inline below). fL_expr = JuMP.@expression( model, [name = name_axis, t = time_axis], sum(delta[name]^2 * 2.0^(-2j) * g_var[name, j, t] for j in 1:(config.depth)) ) - # Tangent-line cuts: - # k=1: z ≥ 0 - # k=2: z ≥ 2·x_min + 2·δ·g₀ − 1 (anchor at xh = 1/2) - # k=j+2 for j=1..L: z ≥ scale_back_g_basis(1:j) − δ²·2^{−2j−2} tangent_zero = JuMP.@constraint( model, [name = name_axis, t = time_axis], z_var[name, t] >= 0.0, ) diff --git a/src/approximations/sawtooth.jl b/src/approximations/sawtooth.jl index 23d8185b..054b423c 100644 --- a/src/approximations/sawtooth.jl +++ b/src/approximations/sawtooth.jl @@ -30,10 +30,258 @@ function SawtoothQuadConfig(depth::Int) return SawtoothQuadConfig(depth, 0) end +# --- Scalar build (pure JuMP, primary API) --- + +""" + build_quadratic_approx(config::SawtoothQuadConfig, model, x, x_min, x_max) + +Scalar form: PWL approximation of x² for a single JuMP scalar `x` with +bounds `[x_min, x_max]`, using `depth` binary variables. If +`config.epigraph_depth > 0`, also builds an epigraph Q^{L1} lower bound and +tightens the approximation via z ≤ sawtooth_upper and z ≥ epigraph. + +Returns a NamedTuple: +- `approximation` :: scalar (AffExpr; either `x_sq_approx` or `1.0·z` if tightened) +- `g_var` :: DenseAxisArray{VariableRef, 1} over `0:depth` +- `alpha_var` :: DenseAxisArray{VariableRef, 1} over `1:depth` +- `link_constraint` :: scalar constraint linking g₀ to (x − x_min)/δ +- `mip_constraints` :: DenseAxisArray{Constraint, 2} over `(1:depth, 1:4)` +- `tightening` :: `nothing`, or a NamedTuple + `(; z_var, constraints :: 1D over 1:2, epigraph)` where + `epigraph` is the full NamedTuple returned by the + scalar epigraph build. +""" +function build_quadratic_approx( + config::SawtoothQuadConfig, + model::JuMP.Model, + x::JuMP.AbstractJuMPScalar, + x_min::Float64, + x_max::Float64, +) + IS.@assert_op config.depth >= 1 + IS.@assert_op x_max > x_min + + depth = config.depth + delta = x_max - x_min + + g_var = JuMP.@variable( + model, [j = 0:depth], + lower_bound = 0.0, upper_bound = 1.0, + base_name = "SawtoothAux", + ) + alpha_var = JuMP.@variable( + model, [j = 1:depth], + binary = true, + base_name = "SawtoothBin", + ) + + link_con = JuMP.@constraint(model, g_var[0] == (x - x_min) / delta) + + # S^L constraints: 4 inequalities per level. + mip_a = JuMP.@constraint( + model, [j = 1:depth], g_var[j] <= 2.0 * g_var[j - 1], + ) + mip_b = JuMP.@constraint( + model, [j = 1:depth], g_var[j] <= 2.0 * (1.0 - g_var[j - 1]), + ) + mip_c = JuMP.@constraint( + model, [j = 1:depth], g_var[j] >= 2.0 * (g_var[j - 1] - alpha_var[j]), + ) + mip_d = JuMP.@constraint( + model, [j = 1:depth], g_var[j] >= 2.0 * (alpha_var[j] - g_var[j - 1]), + ) + mip_cons = JuMP.Containers.DenseAxisArray{JuMP.ConstraintRef}( + undef, 1:depth, 1:4, + ) + @views mip_cons.data[:, 1] .= mip_a.data + @views mip_cons.data[:, 2] .= mip_b.data + @views mip_cons.data[:, 3] .= mip_c.data + @views mip_cons.data[:, 4] .= mip_d.data + + x_sq_approx = JuMP.@expression( + model, + scale_back_g_basis_scalar(x_min, delta, g_var, 1:depth), + ) + + if config.epigraph_depth > 0 + epi = build_quadratic_approx( + EpigraphQuadConfig(config.epigraph_depth), model, x, x_min, x_max, + ) + z_min = (x_min <= 0.0 <= x_max) ? 0.0 : min(x_min^2, x_max^2) + z_max = max(x_min^2, x_max^2) + z_var = JuMP.@variable( + model, lower_bound = z_min, upper_bound = z_max, + base_name = "TightenedSawtooth", + ) + tight_a = JuMP.@constraint(model, z_var <= x_sq_approx) + tight_b = JuMP.@constraint(model, z_var >= epi.approximation) + tight_cons = JuMP.Containers.DenseAxisArray{JuMP.ConstraintRef}( + undef, 1:2, + ) + tight_cons[1] = tight_a + tight_cons[2] = tight_b + approximation = JuMP.@expression(model, 1.0 * z_var) + tightening = (; z_var, constraints = tight_cons, epigraph = epi) + return (; + approximation, + g_var, + alpha_var, + link_constraint = link_con, + mip_constraints = mip_cons, + tightening, + ) + end + + return (; + approximation = x_sq_approx, + g_var, + alpha_var, + link_constraint = link_con, + mip_constraints = mip_cons, + tightening = nothing, + ) +end + +# --- IOM adapter (allocate, loop, write) --- + +""" + add_quadratic_approx!(config::SawtoothQuadConfig, container, ::Type{C}, x_var, x_bounds, meta) + +Allocate sawtooth containers (g, α, link, mip, approximation) plus, when +`config.epigraph_depth > 0`, the tightened-z + 2-constraint containers AND +the full set of epigraph containers under `meta * "_lb"`. Then loop +`(name, t)` calling the scalar build per cell and writing all the refs +into their slots. + +Returns the registered `QuadraticExpression` container. +""" +function add_quadratic_approx!( + config::SawtoothQuadConfig, + container::OptimizationContainer, + ::Type{C}, + x_var, + x_bounds::Vector{MinMax}, + meta::String, +) where {C <: IS.InfrastructureSystemsComponent} + name_axis = axes(x_var, 1) + time_axis = axes(x_var, 2) + depth = config.depth + IS.@assert_op depth >= 1 + IS.@assert_op length(name_axis) == length(x_bounds) + for b in x_bounds + IS.@assert_op b.max > b.min + end + + model = get_jump_model(container) + + g_target = add_variable_container!( + container, SawtoothAuxVariable, C, name_axis, 0:depth, time_axis; meta, + ) + alpha_target = add_variable_container!( + container, SawtoothBinaryVariable, C, name_axis, 1:depth, time_axis; meta, + ) + link_target = add_constraints_container!( + container, SawtoothLinkingConstraint, C, name_axis, time_axis; meta, + ) + mip_target = add_constraints_container!( + container, SawtoothMIPConstraint, C, name_axis, 1:depth, 1:4, time_axis; meta, + ) + approx_target = add_expression_container!( + container, QuadraticExpression, C, name_axis, time_axis; meta, + ) + + tighten = config.epigraph_depth > 0 + local st_z_target, st_tight_target + local epi_z_target, epi_g_target, epi_link_target, epi_fL_target, + epi_approx_target, epi_lp_target, epi_tangent_target + local epi_depth::Int + if tighten + st_z_target = add_variable_container!( + container, SawtoothTightenedVariable, C, name_axis, time_axis; meta, + ) + st_tight_target = add_constraints_container!( + container, SawtoothTightenedConstraint, C, name_axis, 1:2, time_axis; meta, + ) + epi_depth = config.epigraph_depth + epi_meta = meta * "_lb" + epi_z_target = add_variable_container!( + container, EpigraphVariable, C, name_axis, time_axis; meta = epi_meta, + ) + epi_g_target = add_variable_container!( + container, SawtoothAuxVariable, C, name_axis, 0:epi_depth, time_axis; + meta = epi_meta, + ) + epi_link_target = add_constraints_container!( + container, SawtoothLinkingConstraint, C, name_axis, time_axis; + meta = epi_meta, + ) + epi_fL_target = add_expression_container!( + container, EpigraphTangentExpression, C, name_axis, time_axis; + meta = epi_meta, + ) + epi_approx_target = add_expression_container!( + container, EpigraphExpression, C, name_axis, time_axis; meta = epi_meta, + ) + epi_lp_target = add_constraints_container!( + container, SawtoothLPConstraint, C, name_axis, 1:epi_depth, 1:2, time_axis; + meta = epi_meta, + ) + epi_tangent_target = add_constraints_container!( + container, EpigraphTangentConstraint, C, name_axis, 1:(epi_depth + 2), + time_axis; meta = epi_meta, + ) + end + + for (i, name) in enumerate(name_axis) + xmn, xmx = x_bounds[i].min, x_bounds[i].max + for t in time_axis + r = build_quadratic_approx(config, model, x_var[name, t], xmn, xmx) + for j in 0:depth + g_target[name, j, t] = r.g_var[j] + end + for j in 1:depth + alpha_target[name, j, t] = r.alpha_var[j] + end + link_target[name, t] = r.link_constraint + for j in 1:depth, k in 1:4 + mip_target[name, j, k, t] = r.mip_constraints[j, k] + end + approx_target[name, t] = r.approximation + + if tighten + tt = r.tightening + st_z_target[name, t] = tt.z_var + for k in 1:2 + st_tight_target[name, k, t] = tt.constraints[k] + end + epi = tt.epigraph + epi_z_target[name, t] = epi.z_var + for j in 0:epi_depth + epi_g_target[name, j, t] = epi.g_var[j] + end + epi_link_target[name, t] = epi.link_constraint + epi_fL_target[name, t] = epi.tangent_expression + epi_approx_target[name, t] = epi.approximation + for j in 1:epi_depth, k in 1:2 + epi_lp_target[name, j, k, t] = epi.lp_constraints[j, k] + end + for j in 1:(epi_depth + 2) + epi_tangent_target[name, j, t] = epi.tangent_constraints[j] + end + end + end + end + return approx_target +end + +# --- Legacy result + tightening structs + vectorized build + register +# (kept for the generic add_quadratic_approx! wrapper in common.jl until +# callers migrate; removed in sweep) --- + """ Tightening pieces of a sawtooth result when `config.epigraph_depth > 0`: the substitute z variable, its bound constraints, and the epigraph result -that supplies the lower bound. +that supplies the lower bound (legacy). """ struct SawtoothTightening{ ZV <: JuMP.Containers.DenseAxisArray{JuMP.VariableRef, 2}, @@ -46,7 +294,7 @@ struct SawtoothTightening{ end """ -Pure-JuMP result of `build_quadratic_approx(::SawtoothQuadConfig, ...)`. +Pure-JuMP result of legacy vectorized `build_quadratic_approx(::SawtoothQuadConfig, ...)`. """ struct SawtoothQuadResult{ A <: JuMP.Containers.DenseAxisArray, @@ -67,9 +315,10 @@ end """ build_quadratic_approx(config::SawtoothQuadConfig, model, x, bounds) -PWL approximation of x² with sawtooth tooth functions and L binary variables. -If `config.epigraph_depth > 0`, also builds an epigraph Q^{L1} lower bound and -tightens the approximation: z ≤ x² (sawtooth, upper) and z ≥ epigraph (lower). +Legacy vectorized form. PWL approximation of x² with sawtooth tooth +functions and L binary variables. If `config.epigraph_depth > 0`, also +builds an epigraph Q^{L1} lower bound and tightens the approximation: +z ≤ x² (sawtooth, upper) and z ≥ epigraph (lower). """ function build_quadratic_approx( config::SawtoothQuadConfig, @@ -110,8 +359,6 @@ function build_quadratic_approx( g_var[name, 0, t] == (x[name, t] - x_min_arr[name]) / delta[name], ) - # S^L constraints for j = 1..L: 4 inequalities per level. Stack four - # `(name, j, t)` families into a `(name, j, k, t)` container. mip_a = JuMP.@constraint( model, [name = name_axis, j = alpha_levels, t = time_axis], @@ -140,7 +387,6 @@ function build_quadratic_approx( @views mip_cons.data[:, :, 3, :] .= mip_c.data @views mip_cons.data[:, :, 4, :] .= mip_d.data - # x² ≈ x_min² + (2·x_min·δ + δ²)·g₀ − Σ_{j ∈ alpha_levels} δ²·2^{−2j}·g_j x_sq_approx = JuMP.@expression( model, [name = name_axis, t = time_axis], @@ -178,8 +424,6 @@ function build_quadratic_approx( [name = name_axis, t = time_axis], z_var[name, t] >= epi_result.approximation[name, t], ) - # tight_a is `z <= sawtooth approx` (LessThan), tight_b is `z >= epigraph` - # (GreaterThan) — use the abstract ConstraintRef to hold both kinds. tight_cons = JuMP.Containers.DenseAxisArray{JuMP.ConstraintRef}( undef, name_axis, 1:2, time_axis, ) From d84929bd3d31dd1e6807d29f3863eeea04fecafb Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Wed, 20 May 2026 16:52:21 -0400 Subject: [PATCH 7/9] Refactor pwmcc + SOS2 variants: scalar build + IOM loop adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PWMCC: scalar `build_pwmcc_concave_cuts(model, v, q_expr, v_min, v_max, K)` returns a NamedTuple of the K-segment δ/v^d binaries+continuous and the selector/linking/interval-bound/chord/tangent constraints for one cell. Solver-SOS2 and Manual-SOS2: scalar `build_quadratic_approx(::Config, model, x, x_min, x_max)` returns the per-cell λ basis, link/norm/sos (or adjacency) constraints, expression sums, and the approximation AffExpr. When `pwmcc_segments > 0` the scalar calls `build_pwmcc_concave_cuts` inline and embeds its NamedTuple in the `pwmcc` field. Each adapter (`add_quadratic_approx!(::Config, ...)`) allocates the SOS2 containers AND (conditionally) the PWMCC containers under `meta * "_pwmcc"`, loops `(name, t)`, calls the scalar per cell, and slots refs into the appropriately-shaped containers. Legacy result structs (PWMCCResult, SOS2QuadResult, ManualSOS2QuadResult), legacy vectorized builds, and `register_pwmcc!` / `register_in_container!` preserved alongside until callers migrate; removed in sweep. Co-Authored-By: Claude Opus 4.7 --- src/approximations/manual_sos2.jl | 261 ++++++++++++++++++++++++++++-- src/approximations/pwmcc_cuts.jl | 126 +++++++++++---- src/approximations/solver_sos2.jl | 218 +++++++++++++++++++++++-- 3 files changed, 555 insertions(+), 50 deletions(-) diff --git a/src/approximations/manual_sos2.jl b/src/approximations/manual_sos2.jl index 23239193..b092edb1 100644 --- a/src/approximations/manual_sos2.jl +++ b/src/approximations/manual_sos2.jl @@ -30,8 +30,257 @@ function ManualSOS2QuadConfig(depth::Int) return ManualSOS2QuadConfig(depth, 4) end +# --- Scalar build (pure JuMP, primary API) --- + """ -Pure-JuMP result of `build_quadratic_approx(::ManualSOS2QuadConfig, ...)`. + build_quadratic_approx(config::ManualSOS2QuadConfig, model, x, x_min, x_max) + +Scalar form: PWL approximation of x² with manually-enforced SOS2 adjacency +via binary segment-selectors z_j and constraints λ_i ≤ z_{i-1} + z_i (with +boundary cases at i=1 and i=n_points). If `config.pwmcc_segments > 0`, +also adds piecewise McCormick concave cuts. + +Returns a NamedTuple: +- `approximation` :: scalar AffExpr +- `lambda` :: DenseAxisArray{VariableRef, 1} over `1:n_points` +- `z_var` :: DenseAxisArray{VariableRef, 1} over `1:n_bins` (binary) +- `link_constraint` :: scalar +- `norm_constraint` :: scalar +- `segment_sum_constraint` :: scalar +- `adjacency_constraints` :: DenseAxisArray{Constraint, 1} over `1:n_points` +- `link_expression` :: scalar AffExpr +- `norm_expression` :: scalar AffExpr +- `segment_sum_expression` :: scalar AffExpr +- `pwmcc` :: `nothing` or NamedTuple from scalar PWMCC build +""" +function build_quadratic_approx( + config::ManualSOS2QuadConfig, + model::JuMP.Model, + x::JuMP.AbstractJuMPScalar, + x_min::Float64, + x_max::Float64, +) + IS.@assert_op x_max > x_min + n_points = config.depth + 1 + n_bins = n_points - 1 + x_bkpts, x_sq_bkpts = _get_breakpoints_for_pwl_function( + 0.0, 1.0, _square; num_segments = config.depth, + ) + lx = x_max - x_min + + lambda = JuMP.@variable( + model, [i = 1:n_points], + lower_bound = 0.0, upper_bound = 1.0, + base_name = "QuadraticVariable", + ) + z_var = JuMP.@variable( + model, [j = 1:n_bins], + binary = true, + base_name = "ManualSOS2Binary", + ) + + link_expr = JuMP.@expression( + model, sum(x_bkpts[i] * lambda[i] for i in 1:n_points), + ) + link_con = JuMP.@constraint(model, (x - x_min) / lx == link_expr) + norm_expr = JuMP.@expression(model, sum(lambda[i] for i in 1:n_points)) + norm_con = JuMP.@constraint(model, norm_expr == 1.0) + seg_expr = JuMP.@expression(model, sum(z_var[j] for j in 1:n_bins)) + seg_con = JuMP.@constraint(model, seg_expr == 1) + + # Adjacency: λ_1 ≤ z_1, λ_n ≤ z_{n-1}, and λ_i ≤ z_{i-1}+z_i for interior. + adj_first = JuMP.@constraint(model, lambda[1] <= z_var[1]) + adj_interior = JuMP.@constraint( + model, [i = 2:(n_points - 1)], lambda[i] <= z_var[i - 1] + z_var[i], + ) + adj_last = JuMP.@constraint(model, lambda[n_points] <= z_var[n_bins]) + + adj_cons = JuMP.Containers.DenseAxisArray{JuMP.ConstraintRef}( + undef, 1:n_points, + ) + adj_cons[1] = adj_first + if n_points >= 3 + @views adj_cons.data[2:(n_points - 1)] .= adj_interior.data + end + adj_cons[n_points] = adj_last + + approximation = JuMP.@expression( + model, + lx * lx * sum(x_sq_bkpts[i] * lambda[i] for i in 1:n_points) + + 2.0 * x_min * x - x_min * x_min, + ) + + pwmcc = if config.pwmcc_segments > 0 + build_pwmcc_concave_cuts( + model, x, approximation, x_min, x_max, config.pwmcc_segments, + ) + else + nothing + end + + return (; + approximation, + lambda, + z_var, + link_constraint = link_con, + norm_constraint = norm_con, + segment_sum_constraint = seg_con, + adjacency_constraints = adj_cons, + link_expression = link_expr, + norm_expression = norm_expr, + segment_sum_expression = seg_expr, + pwmcc, + ) +end + +# --- IOM adapter (allocate, loop, write) --- + +""" + add_quadratic_approx!(config::ManualSOS2QuadConfig, container, ::Type{C}, x_var, x_bounds, meta) + +Allocate manual-SOS2 containers (λ, z, link/norm/seg/adjacency, expressions, +approximation) plus, when `config.pwmcc_segments > 0`, the PWMCC containers +under `meta * "_pwmcc"`. Loop `(name, t)`, call scalar build per cell, write. +""" +function add_quadratic_approx!( + config::ManualSOS2QuadConfig, + container::OptimizationContainer, + ::Type{C}, + x_var, + x_bounds::Vector{MinMax}, + meta::String, +) where {C <: IS.InfrastructureSystemsComponent} + name_axis = axes(x_var, 1) + time_axis = axes(x_var, 2) + n_points = config.depth + 1 + n_bins = n_points - 1 + IS.@assert_op length(name_axis) == length(x_bounds) + for b in x_bounds + IS.@assert_op b.max > b.min + end + + model = get_jump_model(container) + + lambda_target = add_variable_container!( + container, QuadraticVariable, C, name_axis, 1:n_points, time_axis; meta, + ) + z_target = add_variable_container!( + container, ManualSOS2BinaryVariable, C, name_axis, 1:n_bins, time_axis; meta, + ) + link_cons_target = add_constraints_container!( + container, SOS2LinkingConstraint, C, name_axis, time_axis; meta, + ) + norm_cons_target = add_constraints_container!( + container, SOS2NormConstraint, C, name_axis, time_axis; meta, + ) + seg_cons_target = add_constraints_container!( + container, ManualSOS2SegmentSelectionConstraint, C, name_axis, time_axis; meta, + ) + link_expr_target = add_expression_container!( + container, SOS2LinkingExpression, C, name_axis, time_axis; meta, + ) + norm_expr_target = add_expression_container!( + container, SOS2NormExpression, C, name_axis, time_axis; meta, + ) + seg_expr_target = add_expression_container!( + container, ManualSOS2SegmentSelectionExpression, C, name_axis, time_axis; meta, + ) + approx_target = add_expression_container!( + container, QuadraticExpression, C, name_axis, time_axis; meta, + ) + adj_target = add_constraints_container!( + container, ManualSOS2AdjacencyConstraint, C, name_axis, 1:n_points, time_axis; + meta, + ) + + use_pwmcc = config.pwmcc_segments > 0 + K = config.pwmcc_segments + local pw_delta_target, pw_vd_target, pw_selector_target, pw_linking_target, + pw_interval_lb_target, pw_interval_ub_target, + pw_chord_target, pw_tangent_l_target, pw_tangent_r_target + if use_pwmcc + pwm_meta = meta * "_pwmcc" + pw_delta_target = add_variable_container!( + container, PiecewiseMcCormickBinary, C, name_axis, 1:K, time_axis; + meta = pwm_meta, + ) + pw_vd_target = add_variable_container!( + container, PiecewiseMcCormickDisaggregated, C, name_axis, 1:K, time_axis; + meta = pwm_meta, + ) + pw_selector_target = add_constraints_container!( + container, PiecewiseMcCormickSelectorSum, C, name_axis, time_axis; + meta = pwm_meta, + ) + pw_linking_target = add_constraints_container!( + container, PiecewiseMcCormickLinking, C, name_axis, time_axis; + meta = pwm_meta, + ) + pw_interval_lb_target = add_constraints_container!( + container, PiecewiseMcCormickIntervalLB, C, name_axis, 1:K, time_axis; + meta = pwm_meta, + ) + pw_interval_ub_target = add_constraints_container!( + container, PiecewiseMcCormickIntervalUB, C, name_axis, 1:K, time_axis; + meta = pwm_meta, + ) + pw_chord_target = add_constraints_container!( + container, PiecewiseMcCormickChordUB, C, name_axis, time_axis; + meta = pwm_meta, + ) + pw_tangent_l_target = add_constraints_container!( + container, PiecewiseMcCormickTangentLBL, C, name_axis, time_axis; + meta = pwm_meta, + ) + pw_tangent_r_target = add_constraints_container!( + container, PiecewiseMcCormickTangentLBR, C, name_axis, time_axis; + meta = pwm_meta, + ) + end + + for (i, name) in enumerate(name_axis) + xmn, xmx = x_bounds[i].min, x_bounds[i].max + for t in time_axis + r = build_quadratic_approx(config, model, x_var[name, t], xmn, xmx) + for ip in 1:n_points + lambda_target[name, ip, t] = r.lambda[ip] + adj_target[name, ip, t] = r.adjacency_constraints[ip] + end + for j in 1:n_bins + z_target[name, j, t] = r.z_var[j] + end + link_cons_target[name, t] = r.link_constraint + norm_cons_target[name, t] = r.norm_constraint + seg_cons_target[name, t] = r.segment_sum_constraint + link_expr_target[name, t] = r.link_expression + norm_expr_target[name, t] = r.norm_expression + seg_expr_target[name, t] = r.segment_sum_expression + approx_target[name, t] = r.approximation + + if use_pwmcc + pw = r.pwmcc + for k in 1:K + pw_delta_target[name, k, t] = pw.delta_var[k] + pw_vd_target[name, k, t] = pw.vd_var[k] + pw_interval_lb_target[name, k, t] = pw.interval_lb_constraints[k] + pw_interval_ub_target[name, k, t] = pw.interval_ub_constraints[k] + end + pw_selector_target[name, t] = pw.selector_constraint + pw_linking_target[name, t] = pw.linking_constraint + pw_chord_target[name, t] = pw.chord_ub_constraint + pw_tangent_l_target[name, t] = pw.tangent_lb_l_constraint + pw_tangent_r_target[name, t] = pw.tangent_lb_r_constraint + end + end + end + return approx_target +end + +# --- Legacy result + vectorized build + register (kept until callers +# migrate; removed in sweep) --- + +""" +Pure-JuMP result of legacy vectorized `build_quadratic_approx(::ManualSOS2QuadConfig, ...)`. """ struct ManualSOS2QuadResult{ A <: JuMP.Containers.DenseAxisArray{JuMP.AffExpr, 2}, @@ -59,13 +308,6 @@ struct ManualSOS2QuadResult{ pwmcc::PWMCC end -""" - build_quadratic_approx(config::ManualSOS2QuadConfig, model, x, bounds) - -PWL approximation of x² with manually-enforced SOS2 adjacency via binary -segment-selectors z_j and adjacency constraints λ_i ≤ z_{i-1} + z_i. If -`config.pwmcc_segments > 0`, also adds piecewise McCormick concave cuts. -""" function build_quadratic_approx( config::ManualSOS2QuadConfig, model::JuMP.Model, @@ -132,9 +374,6 @@ function build_quadratic_approx( seg_expr[name, t] == 1 ) - # Adjacency constraints: λ_i ≤ z_{i-1} + z_i, with boundary cases for i = 1 - # and i = n_points (only one neighbor z exists). Three vectorized - # @constraint calls stacked into a (name, i, t) container. adj_first = JuMP.@constraint( model, [name = name_axis, t = time_axis], diff --git a/src/approximations/pwmcc_cuts.jl b/src/approximations/pwmcc_cuts.jl index 78726a7b..cae361bf 100644 --- a/src/approximations/pwmcc_cuts.jl +++ b/src/approximations/pwmcc_cuts.jl @@ -4,8 +4,8 @@ # Delta²/(4·K²). These cuts supplement (do not replace) the underlying PWL # (SOS2 or manual-SOS2) constraints. # -# Shared between solver_sos2.jl and manual_sos2.jl — both reference these -# container keys and the build/register helpers below. +# Shared between solver_sos2.jl and manual_sos2.jl — both call the scalar +# `build_pwmcc_concave_cuts` per cell from inside their own scalar build. # --- Container key types --- @@ -36,12 +36,99 @@ struct PiecewiseMcCormickTangentLBL <: ConstraintType end "Piecewise McCormick tangent lower-bound constraint (right endpoint)." struct PiecewiseMcCormickTangentLBR <: ConstraintType end -# --- Result struct --- +# --- Scalar build (pure JuMP, primary API) --- """ -Pure-JuMP result of `build_pwmcc_concave_cuts`. All fields are JuMP container -arrays indexed by (name, k, t) for the K-segment pieces or (name, t) for the -once-per-element constraints. + build_pwmcc_concave_cuts(model, v, q_expr, v_min, v_max, K) + +Scalar form: build the K-segment PWMCC cuts at a single cell. `v` is a +JuMP scalar (the original variable) and `q_expr` is the scalar expression +for the existing PWL v² approximation at this cell. + +Returns a NamedTuple: +- `delta_var` :: DenseAxisArray{VariableRef, 1} over `1:K` (binary) +- `vd_var` :: DenseAxisArray{VariableRef, 1} over `1:K` (continuous) +- `selector_constraint` :: scalar (Σ_k δ_k == 1) +- `linking_constraint` :: scalar (Σ_k vd_k == v) +- `interval_lb_constraints` :: DenseAxisArray{Constraint, 1} over `1:K` +- `interval_ub_constraints` :: DenseAxisArray{Constraint, 1} over `1:K` +- `chord_ub_constraint` :: scalar +- `tangent_lb_l_constraint` :: scalar +- `tangent_lb_r_constraint` :: scalar +""" +function build_pwmcc_concave_cuts( + model::JuMP.Model, + v::JuMP.AbstractJuMPScalar, + q_expr, + v_min::Float64, + v_max::Float64, + K::Int, +) + IS.@assert_op K >= 1 + IS.@assert_op v_min < v_max + + brk = [v_min + k * (v_max - v_min) / K for k in 0:K] # length K+1, indexed 1..K+1 + + delta_var = JuMP.@variable( + model, [k = 1:K], + binary = true, + base_name = "PwMcCBin", + ) + vd_var = JuMP.@variable( + model, [k = 1:K], + base_name = "PwMcCDis", + ) + + selector_con = JuMP.@constraint(model, sum(delta_var[k] for k in 1:K) == 1.0) + linking_con = JuMP.@constraint(model, sum(vd_var[k] for k in 1:K) == v) + + interval_lb = JuMP.@constraint( + model, [k = 1:K], brk[k] * delta_var[k] <= vd_var[k], + ) + interval_ub = JuMP.@constraint( + model, [k = 1:K], vd_var[k] <= brk[k + 1] * delta_var[k], + ) + chord_ub = JuMP.@constraint( + model, + q_expr <= sum( + (brk[k] + brk[k + 1]) * vd_var[k] - + brk[k] * brk[k + 1] * delta_var[k] for k in 1:K + ), + ) + tangent_lb_l = JuMP.@constraint( + model, + q_expr >= sum( + 2.0 * brk[k] * vd_var[k] - brk[k]^2 * delta_var[k] for k in 1:K + ), + ) + tangent_lb_r = JuMP.@constraint( + model, + q_expr >= sum( + 2.0 * brk[k + 1] * vd_var[k] - brk[k + 1]^2 * delta_var[k] for k in 1:K + ), + ) + + return (; + delta_var, + vd_var, + selector_constraint = selector_con, + linking_constraint = linking_con, + interval_lb_constraints = interval_lb, + interval_ub_constraints = interval_ub, + chord_ub_constraint = chord_ub, + tangent_lb_l_constraint = tangent_lb_l, + tangent_lb_r_constraint = tangent_lb_r, + ) +end + +# --- Legacy vectorized build + register (kept for SolverSOS2/ManualSOS2's +# vectorized build_quadratic_approx until those callers migrate; removed in +# sweep) --- + +""" +Pure-JuMP result of legacy vectorized `build_pwmcc_concave_cuts`. Fields are +JuMP container arrays indexed by (name, k, t) for the K-segment pieces or +(name, t) for the once-per-element constraints. """ struct PWMCCResult{ DV <: JuMP.Containers.DenseAxisArray{JuMP.VariableRef, 3}, @@ -65,21 +152,12 @@ struct PWMCCResult{ tangent_lb_r_constraints::TLRC end -# --- Pure-JuMP build --- - """ build_pwmcc_concave_cuts(model, v_var, q_expr, bounds, K) -> PWMCCResult -Build piecewise McCormick cuts on the concave term (−v²) to tighten the LP -relaxation of a PWL approximation `q_expr ≈ v²`. Partitions each name's -[v_min, v_max] into K uniform sub-intervals. - -# Arguments -- `model::JuMP.Model`: JuMP model. -- `v_var`: 2D container of the original variable v, indexed by (name, t). -- `q_expr`: 2D container of the existing PWL approximation expressions for v². -- `bounds`: per-name (v_min, v_max). -- `K::Int`: number of sub-intervals (K = 1 is degenerate; K ≥ 2 useful). +Legacy vectorized form. Build piecewise McCormick cuts on the concave term +(−v²) to tighten the LP relaxation of a PWL approximation `q_expr ≈ v²`. +Partitions each name's [v_min, v_max] into K uniform sub-intervals. """ function build_pwmcc_concave_cuts( model::JuMP.Model, @@ -96,7 +174,6 @@ function build_pwmcc_concave_cuts( IS.@assert_op b.min < b.max end - # Per-name breakpoint coefficients: brk[name, k] = v_min + k·(v_max − v_min)/K. brk = JuMP.Containers.DenseAxisArray( [ bounds[i].min + k * (bounds[i].max - bounds[i].min) / K @@ -118,9 +195,6 @@ function build_pwmcc_concave_cuts( base_name = "PwMcCDis", ) - # JuMP's `sum(...)` inside a constraint macro is recognized by the parser - # and expanded into an efficient affine-sum build — no manual unrolling - # required here. selector_cons = JuMP.@constraint( model, [name = name_axis, t = time_axis], @@ -141,7 +215,6 @@ function build_pwmcc_concave_cuts( [name = name_axis, k = 1:K, t = time_axis], vd_var[name, k, t] <= brk[name, k] * delta_var[name, k, t] ) - # Chord upper bound: q ≤ Σ_k (brk[k-1]+brk[k]) * vd_k − brk[k-1]*brk[k] * δ_k chord_ub = JuMP.@constraint( model, [name = name_axis, t = time_axis], @@ -150,7 +223,6 @@ function build_pwmcc_concave_cuts( brk[name, k - 1] * brk[name, k] * delta_var[name, k, t] for k in 1:K ) ) - # Tangent lower bound at left endpoint: q ≥ Σ_k 2·brk[k-1]*vd_k − brk[k-1]²·δ_k tangent_lb_l = JuMP.@constraint( model, [name = name_axis, t = time_axis], @@ -159,7 +231,6 @@ function build_pwmcc_concave_cuts( brk[name, k - 1]^2 * delta_var[name, k, t] for k in 1:K ) ) - # Tangent lower bound at right endpoint: q ≥ Σ_k 2·brk[k]*vd_k − brk[k]²·δ_k tangent_lb_r = JuMP.@constraint( model, [name = name_axis, t = time_axis], @@ -182,13 +253,10 @@ function build_pwmcc_concave_cuts( ) end -# --- IOM-side register helper --- - """ register_pwmcc!(container, ::Type{C}, pwmcc::PWMCCResult, meta) -Register all PWMCC variables and constraints in the optimization container -under the corresponding key types, suffixed by `meta`. +Legacy registration helper for the vectorized PWMCC result. """ function register_pwmcc!( container::OptimizationContainer, diff --git a/src/approximations/solver_sos2.jl b/src/approximations/solver_sos2.jl index 8e08af0c..2e5996ae 100644 --- a/src/approximations/solver_sos2.jl +++ b/src/approximations/solver_sos2.jl @@ -35,8 +35,215 @@ function SolverSOS2QuadConfig(depth::Int) return SolverSOS2QuadConfig(depth, 4) end +# --- Scalar build (pure JuMP, primary API) --- + """ -Pure-JuMP result of `build_quadratic_approx(::SolverSOS2QuadConfig, ...)`. + build_quadratic_approx(config::SolverSOS2QuadConfig, model, x, x_min, x_max) + +Scalar form: PWL approximation of x² for a single JuMP scalar `x` using +solver-native MOI.SOS2 adjacency. If `config.pwmcc_segments > 0`, also +adds piecewise McCormick concave cuts (per cell). + +Returns a NamedTuple: +- `approximation` :: scalar AffExpr (the PWL estimate of x²) +- `lambda` :: DenseAxisArray{VariableRef, 1} over `1:n_points` +- `link_constraint` :: scalar +- `norm_constraint` :: scalar +- `sos_constraint` :: scalar (MOI.SOS2 adjacency) +- `link_expression` :: scalar AffExpr (Σ x_bkpts[i] · λ_i) +- `norm_expression` :: scalar AffExpr (Σ λ_i) +- `pwmcc` :: `nothing` or NamedTuple from scalar `build_pwmcc_concave_cuts` +""" +function build_quadratic_approx( + config::SolverSOS2QuadConfig, + model::JuMP.Model, + x::JuMP.AbstractJuMPScalar, + x_min::Float64, + x_max::Float64, +) + IS.@assert_op x_max > x_min + n_points = config.depth + 1 + x_bkpts, x_sq_bkpts = _get_breakpoints_for_pwl_function( + 0.0, 1.0, _square; num_segments = config.depth, + ) + lx = x_max - x_min + + lambda = JuMP.@variable( + model, [i = 1:n_points], + lower_bound = 0.0, upper_bound = 1.0, + base_name = "QuadraticVariable", + ) + link_expr = JuMP.@expression( + model, sum(x_bkpts[i] * lambda[i] for i in 1:n_points), + ) + link_con = JuMP.@constraint(model, (x - x_min) / lx == link_expr) + norm_expr = JuMP.@expression(model, sum(lambda[i] for i in 1:n_points)) + norm_con = JuMP.@constraint(model, norm_expr == 1.0) + sos_con = JuMP.@constraint( + model, [lambda[i] for i in 1:n_points] in MOI.SOS2(collect(1:n_points)), + ) + # x² = lx² · Σ λ_i · x_bkpts[i]² + 2·x_min·x − x_min² + approximation = JuMP.@expression( + model, + lx * lx * sum(x_sq_bkpts[i] * lambda[i] for i in 1:n_points) + + 2.0 * x_min * x - x_min * x_min, + ) + + pwmcc = if config.pwmcc_segments > 0 + build_pwmcc_concave_cuts( + model, x, approximation, x_min, x_max, config.pwmcc_segments, + ) + else + nothing + end + + return (; + approximation, + lambda, + link_constraint = link_con, + norm_constraint = norm_con, + sos_constraint = sos_con, + link_expression = link_expr, + norm_expression = norm_expr, + pwmcc, + ) +end + +# --- IOM adapter (allocate, loop, write) --- + +""" + add_quadratic_approx!(config::SolverSOS2QuadConfig, container, ::Type{C}, x_var, x_bounds, meta) + +Allocate SOS2 containers (λ, link/norm/sos constraints, expressions, +approximation) plus, when `config.pwmcc_segments > 0`, the full set of +PWMCC containers under `meta * "_pwmcc"`. Loop `(name, t)` calling the +scalar build per cell and writing all refs. + +Returns the registered `QuadraticExpression` container. +""" +function add_quadratic_approx!( + config::SolverSOS2QuadConfig, + container::OptimizationContainer, + ::Type{C}, + x_var, + x_bounds::Vector{MinMax}, + meta::String, +) where {C <: IS.InfrastructureSystemsComponent} + name_axis = axes(x_var, 1) + time_axis = axes(x_var, 2) + n_points = config.depth + 1 + IS.@assert_op length(name_axis) == length(x_bounds) + for b in x_bounds + IS.@assert_op b.max > b.min + end + + model = get_jump_model(container) + + lambda_target = add_variable_container!( + container, QuadraticVariable, C, name_axis, 1:n_points, time_axis; meta, + ) + link_cons_target = add_constraints_container!( + container, SOS2LinkingConstraint, C, name_axis, time_axis; meta, + ) + norm_cons_target = add_constraints_container!( + container, SOS2NormConstraint, C, name_axis, time_axis; meta, + ) + sos_cons_target = add_constraints_container!( + container, SolverSOS2Constraint, C, name_axis, time_axis; meta, + ) + link_expr_target = add_expression_container!( + container, SOS2LinkingExpression, C, name_axis, time_axis; meta, + ) + norm_expr_target = add_expression_container!( + container, SOS2NormExpression, C, name_axis, time_axis; meta, + ) + approx_target = add_expression_container!( + container, QuadraticExpression, C, name_axis, time_axis; meta, + ) + + use_pwmcc = config.pwmcc_segments > 0 + K = config.pwmcc_segments + local pw_delta_target, pw_vd_target, pw_selector_target, pw_linking_target, + pw_interval_lb_target, pw_interval_ub_target, + pw_chord_target, pw_tangent_l_target, pw_tangent_r_target + if use_pwmcc + pwm_meta = meta * "_pwmcc" + pw_delta_target = add_variable_container!( + container, PiecewiseMcCormickBinary, C, name_axis, 1:K, time_axis; + meta = pwm_meta, + ) + pw_vd_target = add_variable_container!( + container, PiecewiseMcCormickDisaggregated, C, name_axis, 1:K, time_axis; + meta = pwm_meta, + ) + pw_selector_target = add_constraints_container!( + container, PiecewiseMcCormickSelectorSum, C, name_axis, time_axis; + meta = pwm_meta, + ) + pw_linking_target = add_constraints_container!( + container, PiecewiseMcCormickLinking, C, name_axis, time_axis; + meta = pwm_meta, + ) + pw_interval_lb_target = add_constraints_container!( + container, PiecewiseMcCormickIntervalLB, C, name_axis, 1:K, time_axis; + meta = pwm_meta, + ) + pw_interval_ub_target = add_constraints_container!( + container, PiecewiseMcCormickIntervalUB, C, name_axis, 1:K, time_axis; + meta = pwm_meta, + ) + pw_chord_target = add_constraints_container!( + container, PiecewiseMcCormickChordUB, C, name_axis, time_axis; + meta = pwm_meta, + ) + pw_tangent_l_target = add_constraints_container!( + container, PiecewiseMcCormickTangentLBL, C, name_axis, time_axis; + meta = pwm_meta, + ) + pw_tangent_r_target = add_constraints_container!( + container, PiecewiseMcCormickTangentLBR, C, name_axis, time_axis; + meta = pwm_meta, + ) + end + + for (i, name) in enumerate(name_axis) + xmn, xmx = x_bounds[i].min, x_bounds[i].max + for t in time_axis + r = build_quadratic_approx(config, model, x_var[name, t], xmn, xmx) + for ip in 1:n_points + lambda_target[name, ip, t] = r.lambda[ip] + end + link_cons_target[name, t] = r.link_constraint + norm_cons_target[name, t] = r.norm_constraint + sos_cons_target[name, t] = r.sos_constraint + link_expr_target[name, t] = r.link_expression + norm_expr_target[name, t] = r.norm_expression + approx_target[name, t] = r.approximation + + if use_pwmcc + pw = r.pwmcc + for k in 1:K + pw_delta_target[name, k, t] = pw.delta_var[k] + pw_vd_target[name, k, t] = pw.vd_var[k] + pw_interval_lb_target[name, k, t] = pw.interval_lb_constraints[k] + pw_interval_ub_target[name, k, t] = pw.interval_ub_constraints[k] + end + pw_selector_target[name, t] = pw.selector_constraint + pw_linking_target[name, t] = pw.linking_constraint + pw_chord_target[name, t] = pw.chord_ub_constraint + pw_tangent_l_target[name, t] = pw.tangent_lb_l_constraint + pw_tangent_r_target[name, t] = pw.tangent_lb_r_constraint + end + end + end + return approx_target +end + +# --- Legacy result + vectorized build + register (kept until callers +# migrate; removed in sweep) --- + +""" +Pure-JuMP result of legacy vectorized `build_quadratic_approx(::SolverSOS2QuadConfig, ...)`. """ struct SOS2QuadResult{ A <: JuMP.Containers.DenseAxisArray{JuMP.AffExpr, 2}, @@ -58,13 +265,6 @@ struct SOS2QuadResult{ pwmcc::PWMCC end -""" - build_quadratic_approx(config::SolverSOS2QuadConfig, model, x, bounds) - -PWL approximation of x² with solver-native MOI.SOS2 adjacency on the -convex-combination weights. If `config.pwmcc_segments > 0`, also adds -piecewise McCormick concave cuts to tighten the LP relaxation. -""" function build_quadratic_approx( config::SolverSOS2QuadConfig, model::JuMP.Model, @@ -117,8 +317,6 @@ function build_quadratic_approx( [name = name_axis, t = time_axis], [lambda[name, i, t] for i in 1:n_points] in MOI.SOS2(collect(1:n_points)) ) - # x² = x_min² + 2·x_min·(x − x_min) + lx² · xh² where xh² ≈ Σ λ_i · x_bkpts[i]² - # = lx² · Σ λ_i · x_bkpts[i]² + 2·x_min·x − x_min² approximation = JuMP.@expression( model, [name = name_axis, t = time_axis], From 2330b05ec61103b899858ffa06242aba3bb33ed2 Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Wed, 20 May 2026 17:46:55 -0400 Subject: [PATCH 8/9] Drop legacy build/register layer; complete scalar + adapter refactor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes every vectorized `build_*` (acting on DenseAxisArrays of variables and `Vector{MinMax}` bounds), every `register_in_container!` method, every intermediate result struct (NoQuadApproxResult, EpigraphQuadResult, SawtoothQuadResult, SawtoothTightening, SOS2QuadResult, ManualSOS2QuadResult, PWMCCResult, NMDTDiscretization, NMDTBinaryContinuousProduct, NMDTResidualProduct, NMDTEpigraphTightening, NMDTQuadResult, DNMDTQuadResult, NMDTBilinearResult, DNMDTBilinearResult, Bin2BilinearResult, HybSBilinearResult), the abstract QuadraticApproxResult/BilinearApproxResult supertypes, `_PrebuiltQuadApprox`, `get_approximation`, vectorized `build_normed_variable`, and the generic `add_quadratic_approx!`/`add_bilinear_approx!` dispatch wrappers in common.jl. Each method now ships: - scalar `build_(model, x_scalar, x_min, x_max, ...)` returning a flat NamedTuple (pure JuMP, only @assert preconditions — IS removed) - `add__approx!(container, ::Type{C}, x_var, x_bounds, meta)` IOM adapter that allocates known-axis containers, loops `(name, t)`, calls the scalar, slots refs into containers Composed methods (bin2, hybs) call into the quad method's adapter for x², y², (x+y)², (x±y)², then do their own per-cell assembly. Each maintains a precomputed-form `add_bilinear_approx!` that accepts already-built xsq/ysq containers. Test signatures updated to the new 6-arg `add_quadratic_approx!(config, container, C, x_var, x_bounds, meta)` and 8-arg `add_bilinear_approx!(config, container, C, x_var, y_var, x_bounds, y_bounds, meta)` shape — names and time_steps args dropped (axes come from the var containers). test_pure_jump_approximations.jl removed. All 1144 unit tests pass on HiGHS. Co-Authored-By: Claude Opus 4.7 --- src/InfrastructureOptimizationModels.jl | 14 +- src/approximations/bin2.jl | 236 ++++---- src/approximations/common.jl | 193 +------ src/approximations/epigraph.jl | 348 +++--------- src/approximations/hybs.jl | 361 ++++++------ src/approximations/manual_sos2.jl | 310 +---------- src/approximations/mccormick.jl | 303 +---------- src/approximations/nmdt_bilinear.jl | 356 ++++++------ src/approximations/nmdt_discretization.jl | 515 +++++++----------- src/approximations/nmdt_quadratic.jl | 359 ++++++------ src/approximations/no_approx_bilinear.jl | 93 +--- src/approximations/no_approx_quadratic.jl | 61 +-- src/approximations/pwmcc_cuts.jl | 282 ++-------- src/approximations/sawtooth.jl | 341 +----------- src/approximations/solver_sos2.jl | 234 +------- test/InfrastructureOptimizationModelsTests.jl | 4 +- test/test_bilinear_approximations.jl | 28 - test/test_hybs_approximations.jl | 22 - test/test_nmdt_approximations.jl | 52 +- test/test_pure_jump_approximations.jl | 200 ------- test/test_quadratic_approximations.jl | 20 - 21 files changed, 1069 insertions(+), 3263 deletions(-) delete mode 100644 test/test_pure_jump_approximations.jl diff --git a/src/InfrastructureOptimizationModels.jl b/src/InfrastructureOptimizationModels.jl index b24cb553..f0b44ffb 100644 --- a/src/InfrastructureOptimizationModels.jl +++ b/src/InfrastructureOptimizationModels.jl @@ -625,18 +625,16 @@ include("objective_function/piecewise_linear.jl") # CostCurve/FuelCurve → l include("objective_function/value_curve_cost.jl") # ValueCurve → delta PWL # Quadratic and bilinear approximations. -# Layered architecture: pure-JuMP `build_*_approx` functions return result -# structs holding all JuMP objects; the generic IOM wrappers in common.jl -# dispatch `register_in_container!` on the result struct to write everything -# into the OptimizationContainer. +# Each method ships a scalar `build_*` (pure JuMP) and an `add_*_approx!` +# IOM adapter (allocate, loop, write) in the same file. include("approximations/common.jl") include("approximations/pwl_utils.jl") include("approximations/mccormick.jl") -include("approximations/nmdt_discretization.jl") include("approximations/pwmcc_cuts.jl") -# Quadratic methods (each file is self-contained: config + result + build + register) +include("approximations/epigraph.jl") # must precede sawtooth and NMDT (tightening) +include("approximations/nmdt_discretization.jl") # must precede NMDT quad/bilinear +# Quadratic methods (each file is self-contained: config + scalar build + IOM adapter) include("approximations/no_approx_quadratic.jl") -include("approximations/epigraph.jl") # must precede sawtooth (epigraph tightening) include("approximations/solver_sos2.jl") include("approximations/manual_sos2.jl") include("approximations/sawtooth.jl") @@ -644,9 +642,9 @@ include("approximations/nmdt_quadratic.jl") include("approximations/incremental.jl") # Bilinear methods (compose with quadratic — must follow) include("approximations/no_approx_bilinear.jl") +include("approximations/nmdt_bilinear.jl") include("approximations/bin2.jl") include("approximations/hybs.jl") -include("approximations/nmdt_bilinear.jl") # add_param_container! wrappers — must come after piecewise_linear.jl # (which defines VariableValueParameter and FixValueParameter) diff --git a/src/approximations/bin2.jl b/src/approximations/bin2.jl index 3989e041..3e16a927 100644 --- a/src/approximations/bin2.jl +++ b/src/approximations/bin2.jl @@ -20,130 +20,115 @@ function Bin2Config(quad_config::QuadraticApproxConfig) end """ -Pure-JuMP result of `build_bilinear_approx(::Bin2Config, ...)`. -""" -struct Bin2BilinearResult{ - A <: JuMP.Containers.DenseAxisArray{JuMP.AffExpr, 2}, - XSQ <: QuadraticApproxResult, - YSQ <: QuadraticApproxResult, - PSQ <: QuadraticApproxResult, - P <: JuMP.Containers.DenseAxisArray{JuMP.AffExpr, 2}, - MC <: Union{ - Nothing, - Tuple{ - <:JuMP.Containers.DenseAxisArray, - <:JuMP.Containers.DenseAxisArray, - <:JuMP.Containers.DenseAxisArray, - <:JuMP.Containers.DenseAxisArray, - }, - }, -} <: BilinearApproxResult - approximation::A - xsq_result::XSQ - ysq_result::YSQ - psq_result::PSQ - sum_expression::P - mccormick_constraints::MC -end + build_bilinear_approx(config::Bin2Config, model, x, y, x_min, x_max, y_min, y_max) -""" - build_bilinear_approx(config::Bin2Config, model, x, y, x_bounds, y_bounds) +Scalar form: build x², y², (x+y)² via the chosen quadratic method, combine +via z = ½·(psq − xsq − ysq). If `config.add_mccormick`, also build the +four reformulated McCormick cuts. -Bin2 separable bilinear approximation: build x², y², and (x+y)² via the -chosen quadratic method, then combine via z = ½·(psq − xsq − ysq). -If `config.add_mccormick`, append the four reformulated McCormick cuts. +Returns `(; approximation, xsq, ysq, psq, sum_expression, mccormick_constraints)` +where `mccormick_constraints` is `nothing` or a NamedTuple `(c1, c2, c3, c4)`. """ function build_bilinear_approx( config::Bin2Config, model::JuMP.Model, - x, - y, - x_bounds::Vector{MinMax}, - y_bounds::Vector{MinMax}, + x::JuMP.AbstractJuMPScalar, + y::JuMP.AbstractJuMPScalar, + x_min::Float64, + x_max::Float64, + y_min::Float64, + y_max::Float64, ) - xsq = build_quadratic_approx(config.quad_config, model, x, x_bounds) - ysq = build_quadratic_approx(config.quad_config, model, y, y_bounds) - - name_axis = axes(x, 1) - time_axis = axes(x, 2) - - p_expr = JuMP.@expression( - model, - [name = name_axis, t = time_axis], - x[name, t] + y[name, t] + xsq = build_quadratic_approx(config.quad_config, model, x, x_min, x_max) + ysq = build_quadratic_approx(config.quad_config, model, y, y_min, y_max) + p_expr = JuMP.@expression(model, x + y) + psq = build_quadratic_approx( + config.quad_config, model, p_expr, x_min + y_min, x_max + y_max, ) - p_bounds = [ - (min = x_bounds[i].min + y_bounds[i].min, - max = x_bounds[i].max + y_bounds[i].max) - for i in eachindex(x_bounds) - ] - psq = build_quadratic_approx(config.quad_config, model, p_expr, p_bounds) - approximation = JuMP.@expression( model, - [name = name_axis, t = time_axis], - 0.5 * ( - psq.approximation[name, t] - xsq.approximation[name, t] - - ysq.approximation[name, t] - ) + 0.5 * (psq.approximation - xsq.approximation - ysq.approximation), ) - mc = if config.add_mccormick + mc = config.add_mccormick ? build_reformulated_mccormick( model, x, y, psq.approximation, xsq.approximation, ysq.approximation, - x_bounds, y_bounds, - ) - else + x_min, x_max, y_min, y_max, + ) : nothing - end - - return Bin2BilinearResult(approximation, xsq, ysq, psq, p_expr, mc) + return (; + approximation, + xsq, + ysq, + psq, + sum_expression = p_expr, + mccormick_constraints = mc, + ) end -function register_in_container!( +""" + add_bilinear_approx!(config::Bin2Config, container, ::Type{C}, x_var, y_var, x_bounds, y_bounds, meta) + +Build x² and y² via `add_quadratic_approx!(config.quad_config, ...)`, +build the (x+y) expression container and its psq via the same quad +adapter, then assemble z = ½·(psq − xsq − ysq) and (optionally) the +reformulated McCormick cuts. +""" +function add_bilinear_approx!( + config::Bin2Config, container::OptimizationContainer, ::Type{C}, - result::Bin2BilinearResult, + x_var, + y_var, + x_bounds::Vector{MinMax}, + y_bounds::Vector{MinMax}, meta::String, ) where {C <: IS.InfrastructureSystemsComponent} - register_in_container!(container, C, result.xsq_result, meta * "_x") - register_in_container!(container, C, result.ysq_result, meta * "_y") - register_in_container!(container, C, result.psq_result, meta * "_plus") - - name_axis = axes(result.approximation, 1) - time_axis = axes(result.approximation, 2) + name_axis = axes(x_var, 1) + time_axis = axes(x_var, 2) + @assert length(name_axis) == length(x_bounds) + @assert length(name_axis) == length(y_bounds) + model = get_jump_model(container) p_target = add_expression_container!( container, VariableSumExpression, C, name_axis, time_axis; meta = meta * "_plus", ) - p_target.data .= result.sum_expression.data + for (i, name) in enumerate(name_axis) + for t in time_axis + p_target[name, t] = x_var[name, t] + y_var[name, t] + end + end + p_bounds = [ + (min = x_bounds[i].min + y_bounds[i].min, + max = x_bounds[i].max + y_bounds[i].max) + for i in eachindex(x_bounds) + ] - result_target = add_expression_container!( - container, BilinearProductExpression, C, name_axis, time_axis; meta, + xsq = add_quadratic_approx!(config.quad_config, container, C, x_var, x_bounds, meta * "_x") + ysq = add_quadratic_approx!(config.quad_config, container, C, y_var, y_bounds, meta * "_y") + psq = add_quadratic_approx!( + config.quad_config, container, C, p_target, p_bounds, meta * "_plus", ) - result_target.data .= result.approximation.data - register_reformulated_mccormick!(container, C, result.mccormick_constraints, meta) - return + return _bin2_assemble_and_mccormick!( + container, C, name_axis, time_axis, model, + x_var, y_var, xsq, ysq, psq, x_bounds, y_bounds, meta; + add_mccormick = config.add_mccormick, + ) end """ - add_bilinear_approx!(config::Bin2Config, container, C, names, time_steps, - xsq, ysq, x_var, y_var, x_bounds, y_bounds, meta) - -Precomputed-form entrypoint: accepts already-built quadratic approximation -expression containers `xsq` ≈ x² and `ysq` ≈ y² (rather than re-computing -them). The Bin2 identity z = ½·((x+y)² − xsq − ysq) is built on top, along -with the (x+y)² approximation, sum expression, and optional reformulated -McCormick cuts. + add_bilinear_approx!(config::Bin2Config, container, ::Type{C}, xsq, ysq, x_var, y_var, x_bounds, y_bounds, meta) + +Precomputed-form: accepts already-built `xsq` ≈ x² and `ysq` ≈ y² 2D +expression containers (rather than rebuilding them). Builds only the +(x+y)² approximation on top, the Bin2 assembly, and the optional cuts. """ function add_bilinear_approx!( config::Bin2Config, container::OptimizationContainer, ::Type{C}, - names::Vector{String}, - time_steps::UnitRange{Int}, xsq, ysq, x_var, @@ -152,46 +137,71 @@ function add_bilinear_approx!( y_bounds::Vector{MinMax}, meta::String, ) where {C <: IS.InfrastructureSystemsComponent} - model = get_jump_model(container) name_axis = axes(x_var, 1) time_axis = axes(x_var, 2) + @assert length(name_axis) == length(x_bounds) + @assert length(name_axis) == length(y_bounds) + model = get_jump_model(container) - p_expr = JuMP.@expression( - model, - [name = name_axis, t = time_axis], - x_var[name, t] + y_var[name, t] + p_target = add_expression_container!( + container, VariableSumExpression, C, name_axis, time_axis; + meta = meta * "_plus", ) + for (i, name) in enumerate(name_axis) + for t in time_axis + p_target[name, t] = x_var[name, t] + y_var[name, t] + end + end p_bounds = [ (min = x_bounds[i].min + y_bounds[i].min, max = x_bounds[i].max + y_bounds[i].max) for i in eachindex(x_bounds) ] - psq = build_quadratic_approx(config.quad_config, model, p_expr, p_bounds) - register_in_container!(container, C, psq, meta * "_plus") - - approximation = JuMP.@expression( - model, - [name = name_axis, t = time_axis], - 0.5 * (psq.approximation[name, t] - xsq[name, t] - ysq[name, t]) + psq = add_quadratic_approx!( + config.quad_config, container, C, p_target, p_bounds, meta * "_plus", ) - p_target = add_expression_container!( - container, VariableSumExpression, C, name_axis, time_axis; - meta = meta * "_plus", + return _bin2_assemble_and_mccormick!( + container, C, name_axis, time_axis, model, + x_var, y_var, xsq, ysq, psq, x_bounds, y_bounds, meta; + add_mccormick = config.add_mccormick, ) - p_target.data .= p_expr.data +end - result_target = add_expression_container!( +# Allocate the BilinearProductExpression result + optional ReformulatedMcCormick +# container, then loop (name, t) to assemble z and the McCormick cuts. +function _bin2_assemble_and_mccormick!( + container, ::Type{C}, name_axis, time_axis, model, + x_var, y_var, xsq, ysq, psq, x_bounds, y_bounds, meta; + add_mccormick::Bool, +) where {C <: IS.InfrastructureSystemsComponent} + approx_target = add_expression_container!( container, BilinearProductExpression, C, name_axis, time_axis; meta, ) - result_target.data .= approximation.data - - if config.add_mccormick - mc = build_reformulated_mccormick( - model, x_var, y_var, psq.approximation, xsq, ysq, - x_bounds, y_bounds, - ) - register_reformulated_mccormick!(container, C, mc, meta) + mc_target = add_mccormick ? + add_constraints_container!( + container, ReformulatedMcCormickConstraint, C, + name_axis, 1:4, time_axis; meta, + ) : nothing + + for (i, name) in enumerate(name_axis) + xmn, xmx = x_bounds[i].min, x_bounds[i].max + ymn, ymx = y_bounds[i].min, y_bounds[i].max + for t in time_axis + approx_target[name, t] = + 0.5 * (psq[name, t] - xsq[name, t] - ysq[name, t]) + if add_mccormick + r = build_reformulated_mccormick( + model, + x_var[name, t], y_var[name, t], + psq[name, t], xsq[name, t], ysq[name, t], + xmn, xmx, ymn, ymx, + ) + for (k, ref) in enumerate(r) + mc_target[name, k, t] = ref + end + end + end end - return result_target + return approx_target end diff --git a/src/approximations/common.jl b/src/approximations/common.jl index 435f1e4b..6026a588 100644 --- a/src/approximations/common.jl +++ b/src/approximations/common.jl @@ -1,26 +1,11 @@ # Shared infrastructure for quadratic and bilinear approximations. # -# This file defines the two abstract config supertypes, the two abstract -# result supertypes, the generic IOM wrappers that POM calls into, and a -# handful of expression key types used by multiple methods. -# -# The architecture is layered: -# -# Pure-JuMP layer (the math) -# build_quadratic_approx(config, model, x, bounds) -> QuadraticApproxResult -# build_bilinear_approx(config, model, x, y, x_bounds, y_bounds) -> BilinearApproxResult -# -# IOM layer (container bookkeeping) -# add_quadratic_approx!(config, container, C, names, time_steps, x, bounds, meta) -# 1. call build_quadratic_approx -# 2. dispatch register_in_container!(container, C, result, meta) to write -# all auxiliary JuMP objects into the OptimizationContainer -# 3. return the approximation expression container -# -# Each method file under src/approximations/ contains its own config struct, -# result struct, build_* function, and register_in_container! method. +# Each method ships a scalar `build_` function (pure-JuMP, no IOM +# dependencies) and an `add__approx!` IOM adapter that allocates +# containers with known `(name, t, ...)` axes, loops over (name, t), calls +# the scalar build per cell, and slots refs into the container. -# --- Abstract config and result supertypes --- +# --- Abstract config supertypes --- "Abstract supertype for quadratic approximation method configurations." abstract type QuadraticApproxConfig end @@ -28,33 +13,7 @@ abstract type QuadraticApproxConfig end "Abstract supertype for bilinear approximation method configurations." abstract type BilinearApproxConfig end -"Abstract supertype for the pure-JuMP result of a quadratic approximation build." -abstract type QuadraticApproxResult end - -"Abstract supertype for the pure-JuMP result of a bilinear approximation build." -abstract type BilinearApproxResult end - -""" - get_approximation(result) - -Return the approximation expression container from a quadratic or bilinear -approximation result. The container is indexed by (name, time_step) and -holds either `JuMP.AffExpr` or `JuMP.QuadExpr` entries depending on method. -""" -get_approximation(result::QuadraticApproxResult) = result.approximation -get_approximation(result::BilinearApproxResult) = result.approximation - -""" -Lightweight `QuadraticApproxResult` adapter that wraps a pre-built x² (or -y²) expression container. Used by the precomputed-form bilinear entrypoints -so the shared math can consume already-registered quadratic approximations -without re-computing or re-registering them. -""" -struct _PrebuiltQuadApprox{A} <: QuadraticApproxResult - approximation::A -end - -# --- Shared expression-key types --- +# --- Shared expression / variable key types --- "Expression container for the normalized variable xh = (x − x_min) / (x_max − x_min) ∈ [0,1]." struct NormedVariableExpression <: ExpressionType end @@ -73,143 +32,3 @@ struct VariableSumExpression <: ExpressionType end "Expression container for differences of two variables, x − y." struct VariableDifferenceExpression <: ExpressionType end - -# --- Pure-JuMP helper: normalized variable --- - -""" - build_normed_variable(model, x, bounds) -> DenseAxisArray{JuMP.AffExpr, 2} - -Build a 2D container of affine expressions xh = (x − x_min) / (x_max − x_min) ∈ [0,1]. - -Pure-JuMP utility used by methods that operate on a normalized variable -(NMDT, and any caller that needs a [0,1] domain). - -# Arguments -- `model`: JuMP model the expressions live in (only needed for axis types). -- `x`: 2D JuMP container indexed by (name, t). -- `bounds`: per-name bounds aligned with the first axis of `x`. -""" -function build_normed_variable( - model::JuMP.Model, - x, - bounds::Vector{MinMax}, -) - name_axis = axes(x, 1) - time_axis = axes(x, 2) - IS.@assert_op length(name_axis) == length(bounds) - for b in bounds - IS.@assert_op b.max > b.min - end - slope = JuMP.Containers.DenseAxisArray( - [1.0 / (b.max - b.min) for b in bounds], - name_axis, - ) - offset = JuMP.Containers.DenseAxisArray( - [-b.min / (b.max - b.min) for b in bounds], - name_axis, - ) - return JuMP.@expression( - model, - [name = name_axis, t = time_axis], - slope[name] * x[name, t] + offset[name], - ) -end - -# --- IOM-side wrappers (POM entry points) --- - -""" - add_quadratic_approx!(config, container, C, names, time_steps, x_var, bounds, meta) - -POM entry point for quadratic approximation. Dispatched on the abstract -`QuadraticApproxConfig` type — concrete behavior comes from the concrete -config's `build_quadratic_approx` and `register_in_container!` methods. - -# Arguments -- `config::QuadraticApproxConfig`: approximation method configuration. -- `container::OptimizationContainer`: the optimization container. -- `::Type{C}`: component type (used for container key dispatch). -- `names::Vector{String}`: component names; must equal `axes(x_var, 1)`. -- `time_steps::UnitRange{Int}`: time periods; must equal `axes(x_var, 2)`. -- `x_var`: 2D JuMP container indexed by (name, t). -- `bounds::Vector{MinMax}`: per-name lower and upper bounds of the x domain. -- `meta::String`: identifier used to disambiguate container keys when more - than one approximation of the same kind is registered on the same component - type. The IOM wrapper passes this through to `register_in_container!`. - -# Returns -The approximation expression container (indexed by (name, t)), as returned -by `get_approximation(result)`. -""" -function add_quadratic_approx!( - config::QuadraticApproxConfig, - container::OptimizationContainer, - ::Type{C}, - names::Vector{String}, - time_steps::UnitRange{Int}, - x_var, - bounds::Vector{MinMax}, - meta::String, -) where {C <: IS.InfrastructureSystemsComponent} - result = build_quadratic_approx(config, get_jump_model(container), x_var, bounds) - register_in_container!(container, C, result, meta) - return get_approximation(result) -end - -""" - add_bilinear_approx!(config, container, C, names, time_steps, x_var, y_var, x_bounds, y_bounds, meta) - -POM entry point for bilinear approximation. Dispatched on the abstract -`BilinearApproxConfig` type — concrete behavior comes from the concrete -config's `build_bilinear_approx` and `register_in_container!` methods. - -# Arguments -- `config::BilinearApproxConfig`: approximation method configuration. -- `container::OptimizationContainer`: the optimization container. -- `::Type{C}`: component type (used for container key dispatch). -- `names::Vector{String}`: component names; must equal `axes(x_var, 1)` and `axes(y_var, 1)`. -- `time_steps::UnitRange{Int}`: time periods. -- `x_var`, `y_var`: 2D JuMP containers indexed by (name, t). -- `x_bounds`, `y_bounds`: per-name lower and upper bounds. -- `meta::String`: identifier used to disambiguate container keys. - -# Returns -The approximation expression container (indexed by (name, t)). -""" -function add_bilinear_approx!( - config::BilinearApproxConfig, - container::OptimizationContainer, - ::Type{C}, - names::Vector{String}, - time_steps::UnitRange{Int}, - x_var, - y_var, - x_bounds::Vector{MinMax}, - y_bounds::Vector{MinMax}, - meta::String, -) where {C <: IS.InfrastructureSystemsComponent} - result = build_bilinear_approx( - config, - get_jump_model(container), - x_var, - y_var, - x_bounds, - y_bounds, - ) - register_in_container!(container, C, result, meta) - return get_approximation(result) -end - -# --- register_in_container! interface --- -# Concrete methods are defined in each method file, dispatched on result-struct type. - -""" - register_in_container!(container, ::Type{C}, result, meta) - -Write the JuMP objects held by `result` into the `OptimizationContainer` -using the appropriate key types and `meta` suffix. - -Each concrete result struct provides its own method. The math layer -(`build_*`) never references container keys directly — that name → key -mapping lives only inside `register_in_container!`. -""" -function register_in_container! end diff --git a/src/approximations/epigraph.jl b/src/approximations/epigraph.jl index cd7299a2..cdbc6ba1 100644 --- a/src/approximations/epigraph.jl +++ b/src/approximations/epigraph.jl @@ -26,30 +26,14 @@ struct EpigraphTangentConstraint <: ConstraintType end struct EpigraphTangentExpression <: ExpressionType end """ - scale_back_g_basis(x_min, delta, g_var, name, t, levels) + scale_back_g_basis(x_min, delta, g_var, levels) -Build the affine "scale back to actual dimensions" expression +Build the affine "scale back to actual dimensions" expression at one cell: x² ≈ x_min² + (2·x_min·δ + δ²)·g₀ − Σ_{j ∈ levels} δ²·2^{−2j}·g_j -where `g_var` is the SawtoothAux g-basis variable container, `x_min` and -`delta = x_max − x_min` are per-name scalars, and `levels` selects which -g-basis levels participate in the residual sum. - -Shared by sawtooth (PWL approximation) and epigraph (tangent cuts) — both -express the parabola anchor + residual decomposition in this form. -""" -@inline function scale_back_g_basis(x_min, delta, g_var, name, t, levels) - return x_min^2 + - (2.0 * x_min * delta + delta^2) * g_var[name, 0, t] - - sum(delta^2 * 2.0^(-2j) * g_var[name, j, t] for j in levels) -end - -""" - scale_back_g_basis_scalar(x_min, delta, g_var, levels) - -Scalar form of `scale_back_g_basis`: `g_var` is a 1D `DenseAxisArray` -indexed by j (the levels axis) for a single (name, t) cell. +where `g_var` is a 1D `DenseAxisArray` over the levels axis for a single +(name, t). Shared by sawtooth (PWL approximation) and epigraph (tangent cuts). """ -@inline function scale_back_g_basis_scalar(x_min, delta, g_var, levels) +@inline function scale_back_g_basis(x_min, delta, g_var, levels) return x_min^2 + (2.0 * x_min * delta + delta^2) * g_var[0] - sum(delta^2 * 2.0^(-2j) * g_var[j] for j in levels) @@ -66,8 +50,6 @@ struct EpigraphQuadConfig <: QuadraticApproxConfig depth::Int end -# --- Scalar build (pure JuMP, primary API) --- - """ build_quadratic_approx(config::EpigraphQuadConfig, model, x, x_min, x_max) @@ -76,14 +58,8 @@ with bounds `[x_min, x_max]`. Creates the per-cell g basis (j ∈ 0:depth), LP relaxation constraints, z variable, tangent expression `fL`, and the `depth + 2` tangent cuts. -Returns a NamedTuple: -- `approximation` :: JuMP.AffExpr (1.0 · z) -- `z_var` :: JuMP.VariableRef -- `g_var` :: DenseAxisArray{VariableRef, 1} over `0:depth` -- `link_constraint` :: scalar constraint linking g₀ to (x − x_min)/δ -- `lp_constraints` :: DenseAxisArray{Constraint, 2} over `(1:depth, 1:2)` -- `tangent_expression`:: JuMP.AffExpr (full-depth residual sum) -- `tangent_constraints`:: DenseAxisArray{Constraint, 1} over `1:(depth+2)` +Returns a NamedTuple with `(approximation, z_var, g_var, link_constraint, +lp_constraints, tangent_expression, tangent_constraints)`. """ function build_quadratic_approx( config::EpigraphQuadConfig, @@ -92,8 +68,8 @@ function build_quadratic_approx( x_min::Float64, x_max::Float64, ) - IS.@assert_op config.depth >= 1 - IS.@assert_op x_max > x_min + @assert config.depth >= 1 + @assert x_max > x_min depth = config.depth delta = x_max - x_min @@ -107,16 +83,15 @@ function build_quadratic_approx( link_con = JuMP.@constraint(model, g_var[0] == (x - x_min) / delta) - # T^L constraints: g_j ≤ 2 g_{j-1} and g_j ≤ 2(1 − g_{j-1}) for j = 1..L. lp_a = JuMP.@constraint(model, [j = 1:depth], g_var[j] <= 2.0 * g_var[j - 1]) lp_b = JuMP.@constraint( model, [j = 1:depth], g_var[j] <= 2.0 * (1.0 - g_var[j - 1]), ) - lp_cons = JuMP.Containers.DenseAxisArray{eltype(lp_a.data)}( + lp_cons = JuMP.Containers.DenseAxisArray{eltype(lp_a)}( undef, 1:depth, 1:2, ) - @views lp_cons.data[:, 1] .= lp_a.data - @views lp_cons.data[:, 2] .= lp_b.data + @views lp_cons.data[:, 1] .= lp_a + @views lp_cons.data[:, 2] .= lp_b z_var = JuMP.@variable( model, lower_bound = 0.0, upper_bound = z_ub, base_name = "EpigraphVar", @@ -127,10 +102,6 @@ function build_quadratic_approx( sum(delta^2 * 2.0^(-2j) * g_var[j] for j in 1:depth), ) - # Tangent cuts: - # k=1: z ≥ 0 - # k=2: z ≥ 2·x_min + 2·δ·g₀ − 1 - # k=j+2 for j=1..L: z ≥ scale_back_g_basis_scalar(1:j) − δ²·2^{−2j−2} tangent_zero = JuMP.@constraint(model, z_var >= 0.0) tangent_anchor = JuMP.@constraint( model, z_var >= 2.0 * x_min - 1.0 + 2.0 * delta * g_var[0], @@ -138,7 +109,7 @@ function build_quadratic_approx( tangent_levels = JuMP.@constraint( model, [j = 1:depth], z_var >= - scale_back_g_basis_scalar(x_min, delta, g_var, 1:j) - + scale_back_g_basis(x_min, delta, g_var, 1:j) - delta^2 * 2.0^(-2j - 2), ) @@ -147,7 +118,7 @@ function build_quadratic_approx( ) tangent_cons[1] = tangent_zero tangent_cons[2] = tangent_anchor - @views tangent_cons.data[3:end] .= tangent_levels.data + @views tangent_cons.data[3:end] .= tangent_levels approximation = JuMP.@expression(model, 1.0 * z_var) @@ -162,7 +133,55 @@ function build_quadratic_approx( ) end -# --- IOM adapter (allocate, loop, write) --- +# --- IOM allocation + per-cell write helpers (shared with sawtooth tightening +# and NMDT tightening) --- + +function _alloc_epigraph_targets!( + container::OptimizationContainer, ::Type{C}, name_axis, time_axis, depth::Int, meta, +) where {C <: IS.InfrastructureSystemsComponent} + return ( + z = add_variable_container!( + container, EpigraphVariable, C, name_axis, time_axis; meta, + ), + g = add_variable_container!( + container, SawtoothAuxVariable, C, name_axis, 0:depth, time_axis; meta, + ), + link = add_constraints_container!( + container, SawtoothLinkingConstraint, C, name_axis, time_axis; meta, + ), + fL = add_expression_container!( + container, EpigraphTangentExpression, C, name_axis, time_axis; meta, + ), + approx = add_expression_container!( + container, EpigraphExpression, C, name_axis, time_axis; meta, + ), + lp = add_constraints_container!( + container, SawtoothLPConstraint, C, name_axis, 1:depth, 1:2, time_axis; + meta, + ), + tangent = add_constraints_container!( + container, EpigraphTangentConstraint, C, name_axis, 1:(depth + 2), time_axis; + meta, + ), + ) +end + +function _write_epigraph_cell!(targets, name, t, r, depth::Int) + targets.z[name, t] = r.z_var + for j in 0:depth + targets.g[name, j, t] = r.g_var[j] + end + targets.link[name, t] = r.link_constraint + targets.fL[name, t] = r.tangent_expression + targets.approx[name, t] = r.approximation + for j in 1:depth, k in 1:2 + targets.lp[name, j, k, t] = r.lp_constraints[j, k] + end + for j in 1:(depth + 2) + targets.tangent[name, j, t] = r.tangent_constraints[j] + end + return +end """ add_quadratic_approx!(config::EpigraphQuadConfig, container, ::Type{C}, x_var, x_bounds, meta) @@ -170,10 +189,7 @@ end Allocate all output containers (z, g, link/lp/tangent constraints, fL and approximation expressions) with axes drawn from `x_var`'s `(name, t)` plus the internal `(depth)` axes, then loop `(name, t)` calling the scalar -`build_quadratic_approx(::EpigraphQuadConfig, ...)` per cell. Writes the -scalar refs and small inner-axis arrays into the container slots. - -Returns the registered `EpigraphExpression` container (the approximation). +build per cell. """ function add_quadratic_approx!( config::EpigraphQuadConfig, @@ -186,245 +202,21 @@ function add_quadratic_approx!( name_axis = axes(x_var, 1) time_axis = axes(x_var, 2) depth = config.depth - IS.@assert_op depth >= 1 - IS.@assert_op length(name_axis) == length(x_bounds) + @assert depth >= 1 + @assert length(name_axis) == length(x_bounds) for b in x_bounds - IS.@assert_op b.max > b.min + @assert b.max > b.min end model = get_jump_model(container) - - z_target = add_variable_container!( - container, EpigraphVariable, C, name_axis, time_axis; meta, - ) - g_target = add_variable_container!( - container, SawtoothAuxVariable, C, name_axis, 0:depth, time_axis; meta, - ) - link_target = add_constraints_container!( - container, SawtoothLinkingConstraint, C, name_axis, time_axis; meta, - ) - fL_target = add_expression_container!( - container, EpigraphTangentExpression, C, name_axis, time_axis; meta, - ) - approx_target = add_expression_container!( - container, EpigraphExpression, C, name_axis, time_axis; meta, - ) - lp_target = add_constraints_container!( - container, SawtoothLPConstraint, C, name_axis, 1:depth, 1:2, time_axis; meta, - ) - tangent_target = add_constraints_container!( - container, EpigraphTangentConstraint, C, name_axis, 1:(depth + 2), time_axis; - meta, - ) + targets = _alloc_epigraph_targets!(container, C, name_axis, time_axis, depth, meta) for (i, name) in enumerate(name_axis) xmn, xmx = x_bounds[i].min, x_bounds[i].max for t in time_axis r = build_quadratic_approx(config, model, x_var[name, t], xmn, xmx) - z_target[name, t] = r.z_var - for j in 0:depth - g_target[name, j, t] = r.g_var[j] - end - link_target[name, t] = r.link_constraint - fL_target[name, t] = r.tangent_expression - approx_target[name, t] = r.approximation - for j in 1:depth, k in 1:2 - lp_target[name, j, k, t] = r.lp_constraints[j, k] - end - for j in 1:(depth + 2) - tangent_target[name, j, t] = r.tangent_constraints[j] - end + _write_epigraph_cell!(targets, name, t, r, depth) end end - return approx_target -end - -# --- Legacy result struct + vectorized build + register -# (kept for the generic add_quadratic_approx! wrapper in common.jl until -# callers migrate; removed in sweep) --- - -""" -Pure-JuMP result of legacy vectorized `build_quadratic_approx(::EpigraphQuadConfig, ...)`. -""" -struct EpigraphQuadResult{ - A <: JuMP.Containers.DenseAxisArray{JuMP.AffExpr, 2}, - Z <: JuMP.Containers.DenseAxisArray{JuMP.VariableRef, 2}, - G <: JuMP.Containers.DenseAxisArray{JuMP.VariableRef, 3}, - LP <: JuMP.Containers.DenseAxisArray, - LC <: JuMP.Containers.DenseAxisArray, - FL <: JuMP.Containers.DenseAxisArray{JuMP.AffExpr, 2}, - TC <: JuMP.Containers.DenseAxisArray, -} <: QuadraticApproxResult - approximation::A - z_var::Z - g_var::G - lp_constraints::LP - link_constraints::LC - tangent_expressions::FL - tangent_constraints::TC -end - -""" - build_quadratic_approx(config::EpigraphQuadConfig, model, x, bounds) - -Legacy vectorized form. LP-only lower bound for x² via 2^depth + 1 -tangent-line cuts on the parabola at uniformly spaced breakpoints in -[x_min, x_max]. -""" -function build_quadratic_approx( - config::EpigraphQuadConfig, - model::JuMP.Model, - x, - bounds::Vector{MinMax}, -) - IS.@assert_op config.depth >= 1 - name_axis = axes(x, 1) - time_axis = axes(x, 2) - IS.@assert_op length(name_axis) == length(bounds) - for b in bounds - IS.@assert_op b.max > b.min - end - - g_levels = 0:(config.depth) - delta = JuMP.Containers.DenseAxisArray([b.max - b.min for b in bounds], name_axis) - x_min_arr = JuMP.Containers.DenseAxisArray([b.min for b in bounds], name_axis) - z_ub_arr = JuMP.Containers.DenseAxisArray( - [max(b.min^2, b.max^2) for b in bounds], - name_axis, - ) - - g_var = JuMP.@variable( - model, - [name = name_axis, j = g_levels, t = time_axis], - lower_bound = 0.0, - upper_bound = 1.0, - base_name = "SawtoothAux", - ) - - link_cons = JuMP.@constraint( - model, - [name = name_axis, t = time_axis], - g_var[name, 0, t] == (x[name, t] - x_min_arr[name]) / delta[name] - ) - - lp_a = JuMP.@constraint( - model, - [name = name_axis, j = 1:(config.depth), t = time_axis], - g_var[name, j, t] <= 2.0 * g_var[name, j - 1, t], - ) - lp_b = JuMP.@constraint( - model, - [name = name_axis, j = 1:(config.depth), t = time_axis], - g_var[name, j, t] <= 2.0 * (1.0 - g_var[name, j - 1, t]), - ) - lp_cons = JuMP.Containers.DenseAxisArray{eltype(lp_a.data)}( - undef, name_axis, 1:(config.depth), 1:2, time_axis, - ) - @views lp_cons.data[:, :, 1, :] .= lp_a.data - @views lp_cons.data[:, :, 2, :] .= lp_b.data - - z_var = JuMP.@variable( - model, - [name = name_axis, t = time_axis], - lower_bound = 0.0, - upper_bound = z_ub_arr[name], - base_name = "EpigraphVar", - ) - - fL_expr = JuMP.@expression( - model, - [name = name_axis, t = time_axis], - sum(delta[name]^2 * 2.0^(-2j) * g_var[name, j, t] for j in 1:(config.depth)) - ) - - tangent_zero = JuMP.@constraint( - model, [name = name_axis, t = time_axis], z_var[name, t] >= 0.0, - ) - tangent_anchor = JuMP.@constraint( - model, - [name = name_axis, t = time_axis], - z_var[name, t] >= - 2.0 * x_min_arr[name] - 1.0 + 2.0 * delta[name] * g_var[name, 0, t], - ) - tangent_levels = JuMP.@constraint( - model, - [name = name_axis, j = 1:(config.depth), t = time_axis], - z_var[name, t] >= - scale_back_g_basis( - x_min_arr[name], delta[name], g_var, name, t, 1:j, - ) - delta[name]^2 * 2.0^(-2j - 2), - ) - - tangent_cons = JuMP.Containers.DenseAxisArray{eltype(tangent_zero.data)}( - undef, name_axis, 1:(config.depth + 2), time_axis, - ) - @views tangent_cons.data[:, 1, :] .= tangent_zero.data - @views tangent_cons.data[:, 2, :] .= tangent_anchor.data - @views tangent_cons.data[:, 3:end, :] .= tangent_levels.data - - approximation = JuMP.@expression( - model, - [name = name_axis, t = time_axis], - 1.0 * z_var[name, t] - ) - - return EpigraphQuadResult( - approximation, - z_var, - g_var, - lp_cons, - link_cons, - fL_expr, - tangent_cons, - ) -end - -function register_in_container!( - container::OptimizationContainer, - ::Type{C}, - result::EpigraphQuadResult, - meta::String, -) where {C <: IS.InfrastructureSystemsComponent} - name_axis = axes(result.approximation, 1) - time_axis = axes(result.approximation, 2) - g_levels = axes(result.g_var, 2) - lp_lvl_axis = axes(result.lp_constraints, 2) - tangent_axis = axes(result.tangent_constraints, 2) - - z_target = add_variable_container!( - container, EpigraphVariable, C, name_axis, time_axis; meta, - ) - z_target.data .= result.z_var.data - - g_target = add_variable_container!( - container, SawtoothAuxVariable, C, name_axis, g_levels, time_axis; meta, - ) - g_target.data .= result.g_var.data - - link_target = add_constraints_container!( - container, SawtoothLinkingConstraint, C, name_axis, time_axis; meta, - ) - link_target.data .= result.link_constraints.data - - fL_target = add_expression_container!( - container, EpigraphTangentExpression, C, name_axis, time_axis; meta, - ) - fL_target.data .= result.tangent_expressions.data - - result_target = add_expression_container!( - container, EpigraphExpression, C, name_axis, time_axis; meta, - ) - result_target.data .= result.approximation.data - - lp_target = add_constraints_container!( - container, SawtoothLPConstraint, C, name_axis, lp_lvl_axis, 1:2, time_axis; meta, - ) - lp_target.data .= result.lp_constraints.data - - tangent_target = add_constraints_container!( - container, EpigraphTangentConstraint, C, name_axis, tangent_axis, time_axis; - meta, - ) - tangent_target.data .= result.tangent_constraints.data - return + return targets.approx end diff --git a/src/approximations/hybs.jl b/src/approximations/hybs.jl index 29a93c08..37bdd8e8 100644 --- a/src/approximations/hybs.jl +++ b/src/approximations/hybs.jl @@ -25,234 +25,130 @@ function HybSConfig(quad_config::QuadraticApproxConfig, epigraph_depth::Int) end """ -Pure-JuMP result of `build_bilinear_approx(::HybSConfig, ...)`. -""" -struct HybSBilinearResult{ - A <: JuMP.Containers.DenseAxisArray{JuMP.AffExpr, 2}, - XSQ <: QuadraticApproxResult, - YSQ <: QuadraticApproxResult, - P1 <: JuMP.Containers.DenseAxisArray{JuMP.AffExpr, 2}, - P2 <: JuMP.Containers.DenseAxisArray{JuMP.AffExpr, 2}, - ZP1 <: EpigraphQuadResult, - ZP2 <: EpigraphQuadResult, - ZV <: JuMP.Containers.DenseAxisArray{JuMP.VariableRef, 2}, - BC <: JuMP.Containers.DenseAxisArray, - MC <: Union{Nothing, NamedTuple{(:lower, :upper)}}, -} <: BilinearApproxResult - approximation::A - xsq_result::XSQ - ysq_result::YSQ - sum_expression::P1 - diff_expression::P2 - sum_epigraph::ZP1 - diff_epigraph::ZP2 - z_var::ZV - bound_constraints::BC - mccormick_constraints::MC -end + build_bilinear_approx(config::HybSConfig, model, x, y, x_min, x_max, y_min, y_max) -""" - build_bilinear_approx(config::HybSConfig, model, x, y, x_bounds, y_bounds) +Scalar form: build x² and y² via the chosen quadratic method, build +(x+y)² and (x−y)² via the epigraph Q^{L1} relaxation, and constrain a +fresh product variable z with two-sided bounds derived from the Bin2 lower +/ Bin3 upper identities. -HybS bilinear approximation. Builds x² and y² via the chosen quadratic -method, builds (x+y)² and (x−y)² via the epigraph Q^{L1} relaxation, and -constrains a fresh product variable z with two-sided bounds derived from -the Bin2 lower / Bin3 upper identities. +Returns `(; approximation, xsq, ysq, sum_expression, diff_expression, +sum_epigraph, diff_epigraph, z_var, bound_constraints, mccormick_constraints)`. """ function build_bilinear_approx( config::HybSConfig, model::JuMP.Model, - x, - y, - x_bounds::Vector{MinMax}, - y_bounds::Vector{MinMax}, + x::JuMP.AbstractJuMPScalar, + y::JuMP.AbstractJuMPScalar, + x_min::Float64, + x_max::Float64, + y_min::Float64, + y_max::Float64, ) - xsq = build_quadratic_approx(config.quad_config, model, x, x_bounds) - ysq = build_quadratic_approx(config.quad_config, model, y, y_bounds) - return _build_hybs_with_precomputed( - config, model, x, y, xsq, ysq, x_bounds, y_bounds, + xsq = build_quadratic_approx(config.quad_config, model, x, x_min, x_max) + ysq = build_quadratic_approx(config.quad_config, model, y, y_min, y_max) + return _build_hybs_scalar( + config, model, x, y, xsq, ysq, x_min, x_max, y_min, y_max, ) end -# Shared math between the standard and precomputed-form entrypoints. Wraps -# pre-existing x² / y² approximations behind a `QuadraticApproxResult`-shaped -# adapter so the call site can come from either flow. -function _build_hybs_with_precomputed( +# Shared math between the standard and precomputed-form scalar entrypoints. +# `xsq`/`ysq` are NamedTuples (or any object with an `.approximation` field). +function _build_hybs_scalar( config::HybSConfig, model::JuMP.Model, - x, - y, - xsq::QuadraticApproxResult, - ysq::QuadraticApproxResult, - x_bounds::Vector{MinMax}, - y_bounds::Vector{MinMax}, + x::JuMP.AbstractJuMPScalar, + y::JuMP.AbstractJuMPScalar, + xsq, + ysq, + x_min::Float64, + x_max::Float64, + y_min::Float64, + y_max::Float64, ) - name_axis = axes(x, 1) - time_axis = axes(x, 2) - IS.@assert_op length(name_axis) == length(x_bounds) - IS.@assert_op length(name_axis) == length(y_bounds) - - p1_expr = JuMP.@expression( - model, - [name = name_axis, t = time_axis], - x[name, t] + y[name, t] - ) - p2_expr = JuMP.@expression( - model, - [name = name_axis, t = time_axis], - x[name, t] - y[name, t] - ) - p1_bounds = [ - (min = x_bounds[i].min + y_bounds[i].min, - max = x_bounds[i].max + y_bounds[i].max) - for i in eachindex(x_bounds) - ] - p2_bounds = [ - (min = x_bounds[i].min - y_bounds[i].max, - max = x_bounds[i].max - y_bounds[i].min) - for i in eachindex(x_bounds) - ] + p1_expr = JuMP.@expression(model, x + y) + p2_expr = JuMP.@expression(model, x - y) + p1_min, p1_max = x_min + y_min, x_max + y_max + p2_min, p2_max = x_min - y_max, x_max - y_min epi_cfg = EpigraphQuadConfig(config.epigraph_depth) - zp1 = build_quadratic_approx(epi_cfg, model, p1_expr, p1_bounds) - zp2 = build_quadratic_approx(epi_cfg, model, p2_expr, p2_bounds) - - z_lo = JuMP.Containers.DenseAxisArray( - [ - min( - x_bounds[i].min * y_bounds[i].min, - x_bounds[i].min * y_bounds[i].max, - x_bounds[i].max * y_bounds[i].min, - x_bounds[i].max * y_bounds[i].max, - ) for i in eachindex(x_bounds) - ], - name_axis, - ) - z_hi = JuMP.Containers.DenseAxisArray( - [ - max( - x_bounds[i].min * y_bounds[i].min, - x_bounds[i].min * y_bounds[i].max, - x_bounds[i].max * y_bounds[i].min, - x_bounds[i].max * y_bounds[i].max, - ) for i in eachindex(x_bounds) - ], - name_axis, - ) + zp1 = build_quadratic_approx(epi_cfg, model, p1_expr, p1_min, p1_max) + zp2 = build_quadratic_approx(epi_cfg, model, p2_expr, p2_min, p2_max) + z_lo = min(x_min * y_min, x_min * y_max, x_max * y_min, x_max * y_max) + z_hi = max(x_min * y_min, x_min * y_max, x_max * y_min, x_max * y_max) z_var = JuMP.@variable( - model, - [name = name_axis, t = time_axis], - lower_bound = z_lo[name], - upper_bound = z_hi[name], - base_name = "HybSProduct", + model, lower_bound = z_lo, upper_bound = z_hi, base_name = "HybSProduct", ) - # Bin2 lower bound: z ≥ ½·(zp1 − zx − zy) bound_1 = JuMP.@constraint( model, - [name = name_axis, t = time_axis], - z_var[name, t] >= - 0.5 * ( - zp1.approximation[name, t] - xsq.approximation[name, t] - - ysq.approximation[name, t] - ), + z_var >= 0.5 * (zp1.approximation - xsq.approximation - ysq.approximation), ) - # Bin3 upper bound: z ≤ ½·(zx + zy − zp2) bound_2 = JuMP.@constraint( model, - [name = name_axis, t = time_axis], - z_var[name, t] <= - 0.5 * ( - xsq.approximation[name, t] + ysq.approximation[name, t] - - zp2.approximation[name, t] - ), - ) - # bound_1 is `z >= …` (GreaterThan), bound_2 is `z <= …` (LessThan) — use the - # abstract ConstraintRef so both kinds fit in the same container. - bound_cons = JuMP.Containers.DenseAxisArray{JuMP.ConstraintRef}( - undef, name_axis, 1:2, time_axis, - ) - @views bound_cons.data[:, 1, :] .= bound_1.data - @views bound_cons.data[:, 2, :] .= bound_2.data - - approximation = JuMP.@expression( - model, - [name = name_axis, t = time_axis], - 1.0 * z_var[name, t] - ) - - mc = if config.add_mccormick - build_mccormick_envelope(model, x, y, z_var, x_bounds, y_bounds) - else - nothing - end - - return HybSBilinearResult( - approximation, xsq, ysq, p1_expr, p2_expr, zp1, zp2, - z_var, bound_cons, mc, + z_var <= 0.5 * (xsq.approximation + ysq.approximation - zp2.approximation), + ) + bound_cons = JuMP.Containers.DenseAxisArray{JuMP.ConstraintRef}(undef, 1:2) + bound_cons[1] = bound_1 + bound_cons[2] = bound_2 + + approximation = JuMP.@expression(model, 1.0 * z_var) + + mc = config.add_mccormick ? + build_mccormick_envelope( + model, x, y, z_var, x_min, x_max, y_min, y_max, + ) : nothing + + return (; + approximation, + xsq, + ysq, + sum_expression = p1_expr, + diff_expression = p2_expr, + sum_epigraph = zp1, + diff_epigraph = zp2, + z_var, + bound_constraints = bound_cons, + mccormick_constraints = mc, ) end -function register_in_container!( +""" + add_bilinear_approx!(config::HybSConfig, container, ::Type{C}, x_var, y_var, x_bounds, y_bounds, meta) + +Build x² and y² via `add_quadratic_approx!(config.quad_config, ...)`, +build the (x+y) and (x−y) expression containers, build their epigraphs via +`add_quadratic_approx!(EpigraphQuadConfig(...), ...)`, then allocate the +HybS product variable + bound constraints and assemble per cell. +""" +function add_bilinear_approx!( + config::HybSConfig, container::OptimizationContainer, ::Type{C}, - result::HybSBilinearResult, + x_var, + y_var, + x_bounds::Vector{MinMax}, + y_bounds::Vector{MinMax}, meta::String, ) where {C <: IS.InfrastructureSystemsComponent} - register_in_container!(container, C, result.xsq_result, meta * "_x") - register_in_container!(container, C, result.ysq_result, meta * "_y") - register_in_container!(container, C, result.sum_epigraph, meta * "_plus") - register_in_container!(container, C, result.diff_epigraph, meta * "_diff") - - name_axis = axes(result.approximation, 1) - time_axis = axes(result.approximation, 2) - - p1_target = add_expression_container!( - container, VariableSumExpression, C, name_axis, time_axis; - meta = meta * "_plus", - ) - p1_target.data .= result.sum_expression.data - - p2_target = add_expression_container!( - container, VariableDifferenceExpression, C, name_axis, time_axis; - meta = meta * "_diff", - ) - p2_target.data .= result.diff_expression.data - - z_target = add_variable_container!( - container, BilinearProductVariable, C, name_axis, time_axis; meta, + xsq = add_quadratic_approx!(config.quad_config, container, C, x_var, x_bounds, meta * "_x") + ysq = add_quadratic_approx!(config.quad_config, container, C, y_var, y_bounds, meta * "_y") + return _add_hybs_adapter!( + container, C, config, x_var, y_var, xsq, ysq, x_bounds, y_bounds, meta, ) - z_target.data .= result.z_var.data - - bound_target = add_constraints_container!( - container, HybSBoundConstraint, C, name_axis, 1:2, time_axis; meta, - ) - bound_target.data .= result.bound_constraints.data - - result_target = add_expression_container!( - container, BilinearProductExpression, C, name_axis, time_axis; meta, - ) - result_target.data .= result.approximation.data - - register_mccormick_envelope!(container, C, result.mccormick_constraints, meta) - return end """ - add_bilinear_approx!(config::HybSConfig, container, C, names, time_steps, - xsq, ysq, x_var, y_var, x_bounds, y_bounds, meta) + add_bilinear_approx!(config::HybSConfig, container, ::Type{C}, xsq, ysq, x_var, y_var, x_bounds, y_bounds, meta) -Precomputed-form entrypoint: accepts already-built quadratic approximation -expression containers `xsq` ≈ x² and `ysq` ≈ y², and builds the HybS bilinear -approximation on top without re-computing them. +Precomputed-form: accepts already-built `xsq` ≈ x² and `ysq` ≈ y² 2D +expression containers; builds the HybS pieces on top. """ function add_bilinear_approx!( config::HybSConfig, container::OptimizationContainer, ::Type{C}, - names::Vector{String}, - time_steps::UnitRange{Int}, xsq, ysq, x_var, @@ -261,45 +157,94 @@ function add_bilinear_approx!( y_bounds::Vector{MinMax}, meta::String, ) where {C <: IS.InfrastructureSystemsComponent} - xsq_wrapped = _PrebuiltQuadApprox(xsq) - ysq_wrapped = _PrebuiltQuadApprox(ysq) - result = _build_hybs_with_precomputed( - config, get_jump_model(container), x_var, y_var, - xsq_wrapped, ysq_wrapped, x_bounds, y_bounds, + return _add_hybs_adapter!( + container, C, config, x_var, y_var, xsq, ysq, x_bounds, y_bounds, meta, ) - # Register only the new objects (epi, p_expr, z_var, bound_cons, approx, mc). - register_in_container!(container, C, result.sum_epigraph, meta * "_plus") - register_in_container!(container, C, result.diff_epigraph, meta * "_diff") +end - name_axis = axes(result.approximation, 1) - time_axis = axes(result.approximation, 2) +# Allocate the HybS-specific containers (sum/diff exprs, two epigraphs, z var, +# bounds, approximation, optional McCormick) and loop (name, t) to assemble. +function _add_hybs_adapter!( + container::OptimizationContainer, ::Type{C}, config::HybSConfig, + x_var, y_var, xsq, ysq, x_bounds, y_bounds, meta, +) where {C <: IS.InfrastructureSystemsComponent} + name_axis = axes(x_var, 1) + time_axis = axes(x_var, 2) + @assert length(name_axis) == length(x_bounds) + @assert length(name_axis) == length(y_bounds) + model = get_jump_model(container) p1_target = add_expression_container!( container, VariableSumExpression, C, name_axis, time_axis; meta = meta * "_plus", ) - p1_target.data .= result.sum_expression.data p2_target = add_expression_container!( container, VariableDifferenceExpression, C, name_axis, time_axis; meta = meta * "_diff", ) - p2_target.data .= result.diff_expression.data + for (i, name) in enumerate(name_axis) + for t in time_axis + p1_target[name, t] = x_var[name, t] + y_var[name, t] + p2_target[name, t] = x_var[name, t] - y_var[name, t] + end + end + p1_bounds = [ + (min = x_bounds[i].min + y_bounds[i].min, + max = x_bounds[i].max + y_bounds[i].max) + for i in eachindex(x_bounds) + ] + p2_bounds = [ + (min = x_bounds[i].min - y_bounds[i].max, + max = x_bounds[i].max - y_bounds[i].min) + for i in eachindex(x_bounds) + ] + + epi_cfg = EpigraphQuadConfig(config.epigraph_depth) + zp1 = add_quadratic_approx!(epi_cfg, container, C, p1_target, p1_bounds, meta * "_plus") + zp2 = add_quadratic_approx!(epi_cfg, container, C, p2_target, p2_bounds, meta * "_diff") z_target = add_variable_container!( container, BilinearProductVariable, C, name_axis, time_axis; meta, ) - z_target.data .= result.z_var.data - bound_target = add_constraints_container!( container, HybSBoundConstraint, C, name_axis, 1:2, time_axis; meta, ) - bound_target.data .= result.bound_constraints.data - - result_target = add_expression_container!( + approx_target = add_expression_container!( container, BilinearProductExpression, C, name_axis, time_axis; meta, ) - result_target.data .= result.approximation.data - - register_mccormick_envelope!(container, C, result.mccormick_constraints, meta) - return result_target + mc_target = config.add_mccormick ? + add_constraints_container!( + container, McCormickConstraint, C, name_axis, 1:4, time_axis; meta, + ) : nothing + + for (i, name) in enumerate(name_axis) + xmn, xmx = x_bounds[i].min, x_bounds[i].max + ymn, ymx = y_bounds[i].min, y_bounds[i].max + z_lo = min(xmn * ymn, xmn * ymx, xmx * ymn, xmx * ymx) + z_hi = max(xmn * ymn, xmn * ymx, xmx * ymn, xmx * ymx) + for t in time_axis + z_v = JuMP.@variable( + model, lower_bound = z_lo, upper_bound = z_hi, + base_name = "HybSProduct", + ) + z_target[name, t] = z_v + bound_target[name, 1, t] = JuMP.@constraint( + model, z_v >= 0.5 * (zp1[name, t] - xsq[name, t] - ysq[name, t]), + ) + bound_target[name, 2, t] = JuMP.@constraint( + model, z_v <= 0.5 * (xsq[name, t] + ysq[name, t] - zp2[name, t]), + ) + approx_target[name, t] = JuMP.@expression(model, 1.0 * z_v) + if config.add_mccormick + r = build_mccormick_envelope( + model, x_var[name, t], y_var[name, t], z_v, + xmn, xmx, ymn, ymx, + ) + for (k, ref) in enumerate(r) + mc_target[name, k, t] = ref + end + end + end + end + return approx_target end diff --git a/src/approximations/manual_sos2.jl b/src/approximations/manual_sos2.jl index b092edb1..2d309099 100644 --- a/src/approximations/manual_sos2.jl +++ b/src/approximations/manual_sos2.jl @@ -30,28 +30,17 @@ function ManualSOS2QuadConfig(depth::Int) return ManualSOS2QuadConfig(depth, 4) end -# --- Scalar build (pure JuMP, primary API) --- - """ build_quadratic_approx(config::ManualSOS2QuadConfig, model, x, x_min, x_max) Scalar form: PWL approximation of x² with manually-enforced SOS2 adjacency via binary segment-selectors z_j and constraints λ_i ≤ z_{i-1} + z_i (with boundary cases at i=1 and i=n_points). If `config.pwmcc_segments > 0`, -also adds piecewise McCormick concave cuts. +also adds piecewise McCormick concave cuts per cell. -Returns a NamedTuple: -- `approximation` :: scalar AffExpr -- `lambda` :: DenseAxisArray{VariableRef, 1} over `1:n_points` -- `z_var` :: DenseAxisArray{VariableRef, 1} over `1:n_bins` (binary) -- `link_constraint` :: scalar -- `norm_constraint` :: scalar -- `segment_sum_constraint` :: scalar -- `adjacency_constraints` :: DenseAxisArray{Constraint, 1} over `1:n_points` -- `link_expression` :: scalar AffExpr -- `norm_expression` :: scalar AffExpr -- `segment_sum_expression` :: scalar AffExpr -- `pwmcc` :: `nothing` or NamedTuple from scalar PWMCC build +Returns a NamedTuple with `(approximation, lambda, z_var, link_constraint, +norm_constraint, segment_sum_constraint, adjacency_constraints, +link_expression, norm_expression, segment_sum_expression, pwmcc)`. """ function build_quadratic_approx( config::ManualSOS2QuadConfig, @@ -60,7 +49,7 @@ function build_quadratic_approx( x_min::Float64, x_max::Float64, ) - IS.@assert_op x_max > x_min + @assert x_max > x_min n_points = config.depth + 1 n_bins = n_points - 1 x_bkpts, x_sq_bkpts = _get_breakpoints_for_pwl_function( @@ -74,9 +63,7 @@ function build_quadratic_approx( base_name = "QuadraticVariable", ) z_var = JuMP.@variable( - model, [j = 1:n_bins], - binary = true, - base_name = "ManualSOS2Binary", + model, [j = 1:n_bins], binary = true, base_name = "ManualSOS2Binary", ) link_expr = JuMP.@expression( @@ -88,19 +75,18 @@ function build_quadratic_approx( seg_expr = JuMP.@expression(model, sum(z_var[j] for j in 1:n_bins)) seg_con = JuMP.@constraint(model, seg_expr == 1) - # Adjacency: λ_1 ≤ z_1, λ_n ≤ z_{n-1}, and λ_i ≤ z_{i-1}+z_i for interior. adj_first = JuMP.@constraint(model, lambda[1] <= z_var[1]) adj_interior = JuMP.@constraint( model, [i = 2:(n_points - 1)], lambda[i] <= z_var[i - 1] + z_var[i], ) adj_last = JuMP.@constraint(model, lambda[n_points] <= z_var[n_bins]) - adj_cons = JuMP.Containers.DenseAxisArray{JuMP.ConstraintRef}( - undef, 1:n_points, - ) + adj_cons = JuMP.Containers.DenseAxisArray{JuMP.ConstraintRef}(undef, 1:n_points) adj_cons[1] = adj_first if n_points >= 3 - @views adj_cons.data[2:(n_points - 1)] .= adj_interior.data + for i in 2:(n_points - 1) + adj_cons[i] = adj_interior[i] + end end adj_cons[n_points] = adj_last @@ -133,14 +119,12 @@ function build_quadratic_approx( ) end -# --- IOM adapter (allocate, loop, write) --- - """ add_quadratic_approx!(config::ManualSOS2QuadConfig, container, ::Type{C}, x_var, x_bounds, meta) Allocate manual-SOS2 containers (λ, z, link/norm/seg/adjacency, expressions, approximation) plus, when `config.pwmcc_segments > 0`, the PWMCC containers -under `meta * "_pwmcc"`. Loop `(name, t)`, call scalar build per cell, write. +under `meta * "_pwmcc"`. Loop `(name, t)`. """ function add_quadratic_approx!( config::ManualSOS2QuadConfig, @@ -154,9 +138,9 @@ function add_quadratic_approx!( time_axis = axes(x_var, 2) n_points = config.depth + 1 n_bins = n_points - 1 - IS.@assert_op length(name_axis) == length(x_bounds) + @assert length(name_axis) == length(x_bounds) for b in x_bounds - IS.@assert_op b.max > b.min + @assert b.max > b.min end model = get_jump_model(container) @@ -195,48 +179,9 @@ function add_quadratic_approx!( use_pwmcc = config.pwmcc_segments > 0 K = config.pwmcc_segments - local pw_delta_target, pw_vd_target, pw_selector_target, pw_linking_target, - pw_interval_lb_target, pw_interval_ub_target, - pw_chord_target, pw_tangent_l_target, pw_tangent_r_target - if use_pwmcc - pwm_meta = meta * "_pwmcc" - pw_delta_target = add_variable_container!( - container, PiecewiseMcCormickBinary, C, name_axis, 1:K, time_axis; - meta = pwm_meta, - ) - pw_vd_target = add_variable_container!( - container, PiecewiseMcCormickDisaggregated, C, name_axis, 1:K, time_axis; - meta = pwm_meta, - ) - pw_selector_target = add_constraints_container!( - container, PiecewiseMcCormickSelectorSum, C, name_axis, time_axis; - meta = pwm_meta, - ) - pw_linking_target = add_constraints_container!( - container, PiecewiseMcCormickLinking, C, name_axis, time_axis; - meta = pwm_meta, - ) - pw_interval_lb_target = add_constraints_container!( - container, PiecewiseMcCormickIntervalLB, C, name_axis, 1:K, time_axis; - meta = pwm_meta, - ) - pw_interval_ub_target = add_constraints_container!( - container, PiecewiseMcCormickIntervalUB, C, name_axis, 1:K, time_axis; - meta = pwm_meta, - ) - pw_chord_target = add_constraints_container!( - container, PiecewiseMcCormickChordUB, C, name_axis, time_axis; - meta = pwm_meta, - ) - pw_tangent_l_target = add_constraints_container!( - container, PiecewiseMcCormickTangentLBL, C, name_axis, time_axis; - meta = pwm_meta, - ) - pw_tangent_r_target = add_constraints_container!( - container, PiecewiseMcCormickTangentLBR, C, name_axis, time_axis; - meta = pwm_meta, - ) - end + pwmcc_targets = use_pwmcc ? + _alloc_pwmcc_targets!(container, C, name_axis, time_axis, K, meta * "_pwmcc") : + nothing for (i, name) in enumerate(name_axis) xmn, xmx = x_bounds[i].min, x_bounds[i].max @@ -256,231 +201,10 @@ function add_quadratic_approx!( norm_expr_target[name, t] = r.norm_expression seg_expr_target[name, t] = r.segment_sum_expression approx_target[name, t] = r.approximation - if use_pwmcc - pw = r.pwmcc - for k in 1:K - pw_delta_target[name, k, t] = pw.delta_var[k] - pw_vd_target[name, k, t] = pw.vd_var[k] - pw_interval_lb_target[name, k, t] = pw.interval_lb_constraints[k] - pw_interval_ub_target[name, k, t] = pw.interval_ub_constraints[k] - end - pw_selector_target[name, t] = pw.selector_constraint - pw_linking_target[name, t] = pw.linking_constraint - pw_chord_target[name, t] = pw.chord_ub_constraint - pw_tangent_l_target[name, t] = pw.tangent_lb_l_constraint - pw_tangent_r_target[name, t] = pw.tangent_lb_r_constraint + _write_pwmcc_cell!(pwmcc_targets, name, t, r.pwmcc, K) end end end return approx_target end - -# --- Legacy result + vectorized build + register (kept until callers -# migrate; removed in sweep) --- - -""" -Pure-JuMP result of legacy vectorized `build_quadratic_approx(::ManualSOS2QuadConfig, ...)`. -""" -struct ManualSOS2QuadResult{ - A <: JuMP.Containers.DenseAxisArray{JuMP.AffExpr, 2}, - L <: JuMP.Containers.DenseAxisArray{JuMP.VariableRef, 3}, - Z <: JuMP.Containers.DenseAxisArray{JuMP.VariableRef, 3}, - LC <: JuMP.Containers.DenseAxisArray, - NC <: JuMP.Containers.DenseAxisArray, - ZSUM <: JuMP.Containers.DenseAxisArray, - AC <: JuMP.Containers.DenseAxisArray, - LE <: JuMP.Containers.DenseAxisArray{JuMP.AffExpr, 2}, - NE <: JuMP.Containers.DenseAxisArray{JuMP.AffExpr, 2}, - ZE <: JuMP.Containers.DenseAxisArray{JuMP.AffExpr, 2}, - PWMCC <: Union{Nothing, PWMCCResult}, -} <: QuadraticApproxResult - approximation::A - lambda::L - z_var::Z - link_constraints::LC - norm_constraints::NC - segment_sum_constraints::ZSUM - adjacency_constraints::AC - link_expressions::LE - norm_expressions::NE - segment_sum_expressions::ZE - pwmcc::PWMCC -end - -function build_quadratic_approx( - config::ManualSOS2QuadConfig, - model::JuMP.Model, - x, - bounds::Vector{MinMax}, -) - name_axis = axes(x, 1) - time_axis = axes(x, 2) - IS.@assert_op length(name_axis) == length(bounds) - for b in bounds - IS.@assert_op b.max > b.min - end - n_points = config.depth + 1 - n_bins = n_points - 1 - x_bkpts, x_sq_bkpts = _get_breakpoints_for_pwl_function( - 0.0, 1.0, _square; num_segments = config.depth, - ) - - lx = JuMP.Containers.DenseAxisArray([b.max - b.min for b in bounds], name_axis) - x_min = JuMP.Containers.DenseAxisArray([b.min for b in bounds], name_axis) - - lambda = JuMP.@variable( - model, - [name = name_axis, i = 1:n_points, t = time_axis], - lower_bound = 0.0, - upper_bound = 1.0, - base_name = "QuadraticVariable", - ) - z_var = JuMP.@variable( - model, - [name = name_axis, j = 1:n_bins, t = time_axis], - binary = true, - base_name = "ManualSOS2Binary", - ) - - link_expr = JuMP.@expression( - model, - [name = name_axis, t = time_axis], - sum(x_bkpts[i] * lambda[name, i, t] for i in 1:n_points) - ) - link_cons = JuMP.@constraint( - model, - [name = name_axis, t = time_axis], - (x[name, t] - x_min[name]) / lx[name] == link_expr[name, t] - ) - norm_expr = JuMP.@expression( - model, - [name = name_axis, t = time_axis], - sum(lambda[name, i, t] for i in 1:n_points) - ) - norm_cons = JuMP.@constraint( - model, - [name = name_axis, t = time_axis], - norm_expr[name, t] == 1.0 - ) - seg_expr = JuMP.@expression( - model, - [name = name_axis, t = time_axis], - sum(z_var[name, j, t] for j in 1:n_bins) - ) - seg_cons = JuMP.@constraint( - model, - [name = name_axis, t = time_axis], - seg_expr[name, t] == 1 - ) - - adj_first = JuMP.@constraint( - model, - [name = name_axis, t = time_axis], - lambda[name, 1, t] <= z_var[name, 1, t], - ) - adj_interior = JuMP.@constraint( - model, - [name = name_axis, i = 2:(n_points - 1), t = time_axis], - lambda[name, i, t] <= z_var[name, i - 1, t] + z_var[name, i, t], - ) - adj_last = JuMP.@constraint( - model, - [name = name_axis, t = time_axis], - lambda[name, n_points, t] <= z_var[name, n_bins, t], - ) - adj_cons = JuMP.Containers.DenseAxisArray{eltype(adj_first.data)}( - undef, name_axis, 1:n_points, time_axis, - ) - @views adj_cons.data[:, 1, :] .= adj_first.data - if n_points >= 3 - @views adj_cons.data[:, 2:(n_points - 1), :] .= adj_interior.data - end - @views adj_cons.data[:, n_points, :] .= adj_last.data - - approximation = JuMP.@expression( - model, - [name = name_axis, t = time_axis], - lx[name] * lx[name] * - sum(x_sq_bkpts[i] * lambda[name, i, t] for i in 1:n_points) + - 2.0 * x_min[name] * x[name, t] - x_min[name] * x_min[name] - ) - - pwmcc = if config.pwmcc_segments > 0 - build_pwmcc_concave_cuts(model, x, approximation, bounds, config.pwmcc_segments) - else - nothing - end - - return ManualSOS2QuadResult( - approximation, lambda, z_var, - link_cons, norm_cons, seg_cons, adj_cons, - link_expr, norm_expr, seg_expr, pwmcc, - ) -end - -function register_in_container!( - container::OptimizationContainer, - ::Type{C}, - result::ManualSOS2QuadResult, - meta::String, -) where {C <: IS.InfrastructureSystemsComponent} - name_axis = axes(result.approximation, 1) - time_axis = axes(result.approximation, 2) - n_points_axis = axes(result.lambda, 2) - n_bins_axis = axes(result.z_var, 2) - - lambda_target = add_variable_container!( - container, QuadraticVariable, C, name_axis, n_points_axis, time_axis; meta, - ) - lambda_target.data .= result.lambda.data - - z_target = add_variable_container!( - container, ManualSOS2BinaryVariable, C, name_axis, n_bins_axis, time_axis; meta, - ) - z_target.data .= result.z_var.data - - link_cons_target = add_constraints_container!( - container, SOS2LinkingConstraint, C, name_axis, time_axis; meta, - ) - link_cons_target.data .= result.link_constraints.data - - norm_cons_target = add_constraints_container!( - container, SOS2NormConstraint, C, name_axis, time_axis; meta, - ) - norm_cons_target.data .= result.norm_constraints.data - - seg_cons_target = add_constraints_container!( - container, ManualSOS2SegmentSelectionConstraint, C, name_axis, time_axis; meta, - ) - seg_cons_target.data .= result.segment_sum_constraints.data - - link_expr_target = add_expression_container!( - container, SOS2LinkingExpression, C, name_axis, time_axis; meta, - ) - link_expr_target.data .= result.link_expressions.data - - norm_expr_target = add_expression_container!( - container, SOS2NormExpression, C, name_axis, time_axis; meta, - ) - norm_expr_target.data .= result.norm_expressions.data - - seg_expr_target = add_expression_container!( - container, ManualSOS2SegmentSelectionExpression, C, name_axis, time_axis; meta, - ) - seg_expr_target.data .= result.segment_sum_expressions.data - - result_target = add_expression_container!( - container, QuadraticExpression, C, name_axis, time_axis; meta, - ) - result_target.data .= result.approximation.data - - adj_target = add_constraints_container!( - container, ManualSOS2AdjacencyConstraint, C, name_axis, n_points_axis, - time_axis; meta, - ) - adj_target.data .= result.adjacency_constraints.data - - register_pwmcc!(container, C, result.pwmcc, meta * "_pwmcc") - return -end diff --git a/src/approximations/mccormick.jl b/src/approximations/mccormick.jl index b9fb8c2c..db0e940a 100644 --- a/src/approximations/mccormick.jl +++ b/src/approximations/mccormick.jl @@ -11,31 +11,23 @@ # --- Container key types --- -"McCormick envelope upper-bound constraints: z ≤ … (legacy vectorized split)." +"McCormick envelope upper-bound constraints: z ≤ ... (axis 1:2 in container)." struct McCormickUpperConstraint <: ConstraintType end -"McCormick envelope lower-bound constraints: z ≥ … (legacy vectorized split)." -struct McCormickLowerConstraint <: ConstraintType end - -"Combined McCormick envelope constraints for the full 4-constraint envelope." +"Combined McCormick envelope constraints for the full 4-constraint envelope (axis 1:4)." struct McCormickConstraint <: ConstraintType end -"Reformulated McCormick constraints on Bin2 separable variables." +"Reformulated McCormick constraints on Bin2 separable variables (axis 1:4)." struct ReformulatedMcCormickConstraint <: ConstraintType end -# --- Scalar build_* (pure JuMP, primary API) --- +# --- Scalar build_* (pure JuMP) --- """ build_mccormick_envelope(model, x, y, z, x_min, x_max, y_min, y_max) -Build the four McCormick inequalities for z ≈ x·y at a single cell: -* upper_1: z ≤ x_max · y + x · y_min − x_max · y_min -* upper_2: z ≤ x_min · y + x · y_max − x_min · y_max -* lower_1: z ≥ x_min · y + x · y_min − x_min · y_min -* lower_2: z ≥ x_max · y + x · y_max − x_max · y_max - +Build the four McCormick inequalities for z ≈ x·y at a single cell. Returns a flat NamedTuple `(upper_1, upper_2, lower_1, lower_2)` of scalar -constraint refs. Inputs are JuMP scalars and plain Float64 bounds. +constraint refs. """ function build_mccormick_envelope( model::JuMP.Model, @@ -57,9 +49,8 @@ end """ build_mccormick_upper(model, x, y, z, x_min, x_max, y_min, y_max) -Build only the upper-envelope McCormick inequalities (z ≤ …) at a single cell. -Used when a tighter lower bound on z is supplied elsewhere (NMDT residual -product under `tighten`, manual_sos2 inside its SOS2 envelope, etc.). +Build only the upper-envelope McCormick inequalities (z ≤ ...) at a single +cell. Used when a tighter lower bound on z is supplied elsewhere. Returns a flat NamedTuple `(upper_1, upper_2)` of scalar constraint refs. """ @@ -84,9 +75,7 @@ end Build the four reformulated-McCormick inequalities for the Bin2 separable identity (zp1 ≈ (x+y)², zx ≈ x², zy ≈ y²) at a single cell. -Returns a flat NamedTuple `(c1, c2, c3, c4)` of scalar constraint refs: -* c1, c2 are lower envelopes (zp1 − zx − zy ≥ 2 · …) -* c3, c4 are upper envelopes (zp1 − zx − zy ≤ 2 · …) +Returns a flat NamedTuple `(c1, c2, c3, c4)` of scalar constraint refs. """ function build_reformulated_mccormick( model::JuMP.Model, @@ -119,16 +108,14 @@ function build_reformulated_mccormick( return (; c1, c2, c3, c4) end -# --- IOM adapters (allocate, loop, write) --- +# --- IOM adapters --- """ add_mccormick_approx!(container, ::Type{C}, x_var, y_var, z_var, x_bounds, y_bounds, meta) Allocate a `McCormickConstraint` container with axes `(name, 1:4, time)`, -loop over `(name, t)`, call `build_mccormick_envelope` per cell, and write -the four returned constraint refs into slots `1..4` of the container. - -Returns the registered container. +loop `(name, t)`, call `build_mccormick_envelope` per cell, and slot the +four refs. """ function add_mccormick_approx!( container::OptimizationContainer, @@ -142,11 +129,11 @@ function add_mccormick_approx!( ) where {C <: IS.InfrastructureSystemsComponent} name_axis = axes(x_var, 1) time_axis = axes(x_var, 2) - IS.@assert_op length(name_axis) == length(x_bounds) - IS.@assert_op length(name_axis) == length(y_bounds) + @assert length(name_axis) == length(x_bounds) + @assert length(name_axis) == length(y_bounds) for i in eachindex(x_bounds) - IS.@assert_op x_bounds[i].max > x_bounds[i].min - IS.@assert_op y_bounds[i].max > y_bounds[i].min + @assert x_bounds[i].max > x_bounds[i].min + @assert y_bounds[i].max > y_bounds[i].min end model = get_jump_model(container) @@ -174,11 +161,8 @@ end """ add_mccormick_upper_approx!(container, ::Type{C}, x_var, y_var, z_var, x_bounds, y_bounds, meta) -Allocate a `McCormickUpperConstraint` container with axes `(name, 1:2, time)`, -loop over `(name, t)`, call `build_mccormick_upper` per cell, and write the -two returned constraint refs into slots `1..2`. - -Returns the registered container. +Allocate `McCormickUpperConstraint` `(name, 1:2, time)`; loop, call +`build_mccormick_upper` per cell, slot two refs. """ function add_mccormick_upper_approx!( container::OptimizationContainer, @@ -192,11 +176,11 @@ function add_mccormick_upper_approx!( ) where {C <: IS.InfrastructureSystemsComponent} name_axis = axes(x_var, 1) time_axis = axes(x_var, 2) - IS.@assert_op length(name_axis) == length(x_bounds) - IS.@assert_op length(name_axis) == length(y_bounds) + @assert length(name_axis) == length(x_bounds) + @assert length(name_axis) == length(y_bounds) for i in eachindex(x_bounds) - IS.@assert_op x_bounds[i].max > x_bounds[i].min - IS.@assert_op y_bounds[i].max > y_bounds[i].min + @assert x_bounds[i].max > x_bounds[i].min + @assert y_bounds[i].max > y_bounds[i].min end model = get_jump_model(container) @@ -224,9 +208,8 @@ end """ add_reformulated_mccormick_approx!(container, ::Type{C}, x_var, y_var, zp1, zx, zy, x_bounds, y_bounds, meta) -Allocate a `ReformulatedMcCormickConstraint` container with axes -`(name, 1:4, time)`, loop over `(name, t)`, call `build_reformulated_mccormick` -per cell, and write the four returned constraint refs into slots `1..4`. +Allocate `ReformulatedMcCormickConstraint` `(name, 1:4, time)`; loop, call +`build_reformulated_mccormick` per cell, slot four refs. """ function add_reformulated_mccormick_approx!( container::OptimizationContainer, @@ -242,11 +225,11 @@ function add_reformulated_mccormick_approx!( ) where {C <: IS.InfrastructureSystemsComponent} name_axis = axes(x_var, 1) time_axis = axes(x_var, 2) - IS.@assert_op length(name_axis) == length(x_bounds) - IS.@assert_op length(name_axis) == length(y_bounds) + @assert length(name_axis) == length(x_bounds) + @assert length(name_axis) == length(y_bounds) for i in eachindex(x_bounds) - IS.@assert_op x_bounds[i].max > x_bounds[i].min - IS.@assert_op y_bounds[i].max > y_bounds[i].min + @assert x_bounds[i].max > x_bounds[i].min + @assert y_bounds[i].max > y_bounds[i].min end model = get_jump_model(container) @@ -271,233 +254,3 @@ function add_reformulated_mccormick_approx!( end return target end - -# --- Legacy vectorized build_* and register_* helpers --- -# -# Kept for callers in nmdt_discretization.jl, bin2.jl, and hybs.jl that have -# not yet migrated to the scalar+adapter pattern above. These will be removed -# in the sweep task once all callers are refactored. - -""" - build_mccormick_envelope(model, x, y, z, x_bounds, y_bounds; lower_bounds = true) - -Legacy vectorized McCormick envelope over a `(name, t)` grid. Returns a -NamedTuple `(lower, upper)` where each side is a pair `(c, c)` of 2D -`DenseAxisArray`s indexed by `(name, t)`. `lower === nothing` when -`lower_bounds == false`. -""" -function build_mccormick_envelope( - model::JuMP.Model, - x, - y, - z, - x_bounds::Vector{MinMax}, - y_bounds::Vector{MinMax}; - lower_bounds::Bool = true, -) - name_axis = axes(x, 1) - time_axis = axes(x, 2) - IS.@assert_op length(name_axis) == length(x_bounds) - IS.@assert_op length(name_axis) == length(y_bounds) - for i in eachindex(x_bounds) - IS.@assert_op x_bounds[i].max > x_bounds[i].min - IS.@assert_op y_bounds[i].max > y_bounds[i].min - end - - xmin = JuMP.Containers.DenseAxisArray([b.min for b in x_bounds], name_axis) - xmax = JuMP.Containers.DenseAxisArray([b.max for b in x_bounds], name_axis) - ymin = JuMP.Containers.DenseAxisArray([b.min for b in y_bounds], name_axis) - ymax = JuMP.Containers.DenseAxisArray([b.max for b in y_bounds], name_axis) - - upper_1 = JuMP.@constraint( - model, - [name = name_axis, t = time_axis], - z[name, t] <= - xmax[name] * y[name, t] + x[name, t] * ymin[name] - - xmax[name] * ymin[name], - ) - upper_2 = JuMP.@constraint( - model, - [name = name_axis, t = time_axis], - z[name, t] <= - xmin[name] * y[name, t] + x[name, t] * ymax[name] - - xmin[name] * ymax[name], - ) - lower = if lower_bounds - lower_1 = JuMP.@constraint( - model, - [name = name_axis, t = time_axis], - z[name, t] >= - xmin[name] * y[name, t] + x[name, t] * ymin[name] - - xmin[name] * ymin[name], - ) - lower_2 = JuMP.@constraint( - model, - [name = name_axis, t = time_axis], - z[name, t] >= - xmax[name] * y[name, t] + x[name, t] * ymax[name] - - xmax[name] * ymax[name], - ) - (lower_1, lower_2) - else - nothing - end - return (lower = lower, upper = (upper_1, upper_2)) -end - -""" - build_reformulated_mccormick(model, x, y, zp1, zx, zy, x_bounds, y_bounds) - -Legacy vectorized reformulated McCormick over the `(name, t)` grid. Returns -a 4-tuple of 2D `DenseAxisArray`s, one per cut. -""" -function build_reformulated_mccormick( - model::JuMP.Model, - x, - y, - zp1, - zx, - zy, - x_bounds::Vector{MinMax}, - y_bounds::Vector{MinMax}, -) - name_axis = axes(x, 1) - time_axis = axes(x, 2) - IS.@assert_op length(name_axis) == length(x_bounds) - IS.@assert_op length(name_axis) == length(y_bounds) - for i in eachindex(x_bounds) - IS.@assert_op x_bounds[i].max > x_bounds[i].min - IS.@assert_op y_bounds[i].max > y_bounds[i].min - end - - xmin = JuMP.Containers.DenseAxisArray([b.min for b in x_bounds], name_axis) - xmax = JuMP.Containers.DenseAxisArray([b.max for b in x_bounds], name_axis) - ymin = JuMP.Containers.DenseAxisArray([b.min for b in y_bounds], name_axis) - ymax = JuMP.Containers.DenseAxisArray([b.max for b in y_bounds], name_axis) - - c1 = JuMP.@constraint( - model, - [name = name_axis, t = time_axis], - zp1[name, t] - zx[name, t] - zy[name, t] >= - 2.0 * (xmin[name] * y[name, t] + x[name, t] * ymin[name] - - xmin[name] * ymin[name]), - ) - c2 = JuMP.@constraint( - model, - [name = name_axis, t = time_axis], - zp1[name, t] - zx[name, t] - zy[name, t] >= - 2.0 * (xmax[name] * y[name, t] + x[name, t] * ymax[name] - - xmax[name] * ymax[name]), - ) - c3 = JuMP.@constraint( - model, - [name = name_axis, t = time_axis], - zp1[name, t] - zx[name, t] - zy[name, t] <= - 2.0 * (xmax[name] * y[name, t] + x[name, t] * ymin[name] - - xmax[name] * ymin[name]), - ) - c4 = JuMP.@constraint( - model, - [name = name_axis, t = time_axis], - zp1[name, t] - zx[name, t] - zy[name, t] <= - 2.0 * (xmin[name] * y[name, t] + x[name, t] * ymax[name] - - xmin[name] * ymax[name]), - ) - return (c1, c2, c3, c4) -end - -""" - register_mccormick_envelope!(container, ::Type{C}, mc, meta) - -Legacy registration helper for the vectorized McCormick envelope. Splits -`mc.upper` into `McCormickUpperConstraint` and `mc.lower` (when non-nothing) -into `McCormickLowerConstraint`. -""" -function register_mccormick_envelope!( - container::OptimizationContainer, - ::Type{C}, - mc::NamedTuple, - meta::String, -) where {C <: IS.InfrastructureSystemsComponent} - _register_mccormick_side!(container, C, McCormickUpperConstraint, mc.upper, meta) - _register_mccormick_side!(container, C, McCormickLowerConstraint, mc.lower, meta) - return -end - -register_mccormick_envelope!( - ::OptimizationContainer, - ::Type{<:IS.InfrastructureSystemsComponent}, - ::Nothing, - ::String, -) = nothing - -function _register_mccormick_side!( - container::OptimizationContainer, - ::Type{C}, - ::Type{K}, - cons::Tuple{<:JuMP.Containers.DenseAxisArray, <:JuMP.Containers.DenseAxisArray}, - meta::String, -) where { - C <: IS.InfrastructureSystemsComponent, - K <: ConstraintType, -} - c1, c2 = cons - name_axis = axes(c1, 1) - time_axis = axes(c1, 2) - target = add_constraints_container!( - container, K, C, name_axis, 1:2, time_axis; meta, - ) - @views target.data[:, 1, :] .= c1.data - @views target.data[:, 2, :] .= c2.data - return -end - -_register_mccormick_side!( - ::OptimizationContainer, - ::Type{<:IS.InfrastructureSystemsComponent}, - ::Type{<:ConstraintType}, - ::Nothing, - ::String, -) = nothing - -""" - register_reformulated_mccormick!(container, ::Type{C}, cons, meta) - -Legacy registration helper for the vectorized reformulated McCormick. -""" -function register_reformulated_mccormick!( - container::OptimizationContainer, - ::Type{C}, - cons::Tuple{ - <:JuMP.Containers.DenseAxisArray, - <:JuMP.Containers.DenseAxisArray, - <:JuMP.Containers.DenseAxisArray, - <:JuMP.Containers.DenseAxisArray, - }, - meta::String, -) where {C <: IS.InfrastructureSystemsComponent} - c1, c2, c3, c4 = cons - name_axis = axes(c1, 1) - time_axis = axes(c1, 2) - target = add_constraints_container!( - container, - ReformulatedMcCormickConstraint, - C, - name_axis, - 1:4, - time_axis; - meta, - ) - @views target.data[:, 1, :] .= c1.data - @views target.data[:, 2, :] .= c2.data - @views target.data[:, 3, :] .= c3.data - @views target.data[:, 4, :] .= c4.data - return -end - -register_reformulated_mccormick!( - ::OptimizationContainer, - ::Type{<:IS.InfrastructureSystemsComponent}, - ::Nothing, - ::String, -) = nothing diff --git a/src/approximations/nmdt_bilinear.jl b/src/approximations/nmdt_bilinear.jl index 8289d8fd..42b325cf 100644 --- a/src/approximations/nmdt_bilinear.jl +++ b/src/approximations/nmdt_bilinear.jl @@ -22,56 +22,30 @@ struct DNMDTBilinearConfig <: BilinearApproxConfig depth::Int end -# --- NMDT (single discretization) --- +# --- Scalar build (pure JuMP) --- """ -Pure-JuMP result of `build_bilinear_approx(::NMDTBilinearConfig, ...)`. -""" -struct NMDTBilinearResult{ - A <: JuMP.Containers.DenseAxisArray{JuMP.AffExpr, 2}, - XD <: NMDTDiscretization, - YN <: JuMP.Containers.DenseAxisArray{JuMP.AffExpr, 2}, - BX <: NMDTBinaryContinuousProduct, - DZ <: NMDTResidualProduct, -} <: BilinearApproxResult - approximation::A - x_discretization::XD - yh_expression::YN - bx_yh_product::BX - residual_product::DZ -end + build_bilinear_approx(config::NMDTBilinearConfig, model, x, y, x_min, x_max, y_min, y_max) -""" - build_bilinear_approx(config::NMDTBilinearConfig, model, x, y, x_bounds, y_bounds) +Scalar form: approximate x·y via NMDT for one cell. Discretize x, normalize +y to yh ∈ [0,1], build the binary-continuous product β·yh and residual +δ·yh, reassemble x·y. -Approximate x·y via NMDT: discretize x, normalize y to yh ∈ [0,1], build the -binary-continuous product β·yh and residual δ·yh, reassemble x·y from -normalized components. +Returns `(; approximation, x_discretization, yh_expression, bx_yh_product, +residual_product)`. """ function build_bilinear_approx( config::NMDTBilinearConfig, model::JuMP.Model, - x, - y, - x_bounds::Vector{MinMax}, - y_bounds::Vector{MinMax}, -) - x_disc = build_discretization(model, x, x_bounds, config.depth) - yh_expr = build_normed_variable(model, y, y_bounds) - return _build_nmdt_with_precomputed( - config, model, x_disc, yh_expr, x_bounds, y_bounds, - ) -end - -# Shared math between the standard and precomputed-form entrypoints. -function _build_nmdt_with_precomputed( - config::NMDTBilinearConfig, - model::JuMP.Model, - x_disc::NMDTDiscretization, - yh_expr, - x_bounds::Vector{MinMax}, - y_bounds::Vector{MinMax}, + x::JuMP.AbstractJuMPScalar, + y::JuMP.AbstractJuMPScalar, + x_min::Float64, + x_max::Float64, + y_min::Float64, + y_max::Float64, ) + x_disc = build_discretization(model, x, x_min, x_max, config.depth) + yh_expr = (y - y_min) / (y_max - y_min) bx_yh = build_binary_continuous_product( model, x_disc.beta_var, yh_expr, 0.0, 1.0, config.depth, ) @@ -79,136 +53,37 @@ function _build_nmdt_with_precomputed( model, x_disc.delta_var, yh_expr, 1.0, config.depth, ) approximation = build_assembled_product( - model, - [bx_yh.result_expression], - dz.z_var, - x_disc.norm_expr, - yh_expr, - x_bounds, - y_bounds, - ) - return NMDTBilinearResult(approximation, x_disc, yh_expr, bx_yh, dz) -end - -function register_in_container!( - container::OptimizationContainer, - ::Type{C}, - result::NMDTBilinearResult, - meta::String, -) where {C <: IS.InfrastructureSystemsComponent} - register_discretization!(container, C, result.x_discretization, meta * "_x") - - name_axis = axes(result.yh_expression, 1) - time_axis = axes(result.yh_expression, 2) - yh_target = add_expression_container!( - container, NormedVariableExpression, C, name_axis, time_axis; - meta = meta * "_y", - ) - yh_target.data .= result.yh_expression.data - - register_binary_continuous_product!(container, C, result.bx_yh_product, meta) - register_residual_product!(container, C, result.residual_product, meta) - - result_name_axis = axes(result.approximation, 1) - result_time_axis = axes(result.approximation, 2) - result_target = add_expression_container!( - container, BilinearProductExpression, C, result_name_axis, result_time_axis; - meta, - ) - result_target.data .= result.approximation.data - return -end - -""" - add_bilinear_approx!(config::NMDTBilinearConfig, container, C, names, time_steps, - x_disc, yh_expr, x_var, y_var, x_bounds, y_bounds, meta) - -Precomputed-form entrypoint: accepts an already-built `x_disc::NMDTDiscretization` -and `yh_expr` (the normalized-y expression container) and builds only the -binary-continuous product, residual product, and final assembly on top. -""" -function add_bilinear_approx!( - config::NMDTBilinearConfig, - container::OptimizationContainer, - ::Type{C}, - names::Vector{String}, - time_steps::UnitRange{Int}, - x_disc::NMDTDiscretization, - yh_expr::JuMP.Containers.DenseAxisArray, - x_var, - y_var, - x_bounds::Vector{MinMax}, - y_bounds::Vector{MinMax}, - meta::String, -) where {C <: IS.InfrastructureSystemsComponent} - result = _build_nmdt_with_precomputed( - config, get_jump_model(container), x_disc, yh_expr, x_bounds, y_bounds, + model, [bx_yh.result_expression], dz.z_var, + x_disc.norm_expr, yh_expr, + x_min, x_max, y_min, y_max, ) - register_binary_continuous_product!(container, C, result.bx_yh_product, meta) - register_residual_product!(container, C, result.residual_product, meta) - name_axis = axes(result.approximation, 1) - time_axis = axes(result.approximation, 2) - result_target = add_expression_container!( - container, BilinearProductExpression, C, name_axis, time_axis; meta, + return (; + approximation, + x_discretization = x_disc, + yh_expression = yh_expr, + bx_yh_product = bx_yh, + residual_product = dz, ) - result_target.data .= result.approximation.data - return result_target end -# --- DNMDT (double discretization) --- - """ -Pure-JuMP result of `build_bilinear_approx(::DNMDTBilinearConfig, ...)`. -""" -struct DNMDTBilinearResult{ - A <: JuMP.Containers.DenseAxisArray{JuMP.AffExpr, 2}, - XD <: NMDTDiscretization, - YD <: NMDTDiscretization, - BXY <: NMDTBinaryContinuousProduct, - BYD <: NMDTBinaryContinuousProduct, - BYX <: NMDTBinaryContinuousProduct, - BXD <: NMDTBinaryContinuousProduct, - DZ <: NMDTResidualProduct, -} <: BilinearApproxResult - approximation::A - x_discretization::XD - y_discretization::YD - bx_yh_product::BXY - by_dx_product::BYD - by_xh_product::BYX - bx_dy_product::BXD - residual_product::DZ -end + build_bilinear_approx(config::DNMDTBilinearConfig, model, x, y, x_min, x_max, y_min, y_max) -""" - build_bilinear_approx(config::DNMDTBilinearConfig, model, x, y, x_bounds, y_bounds) - -DNMDT bilinear approximation: discretize both x and y, form all four cross -binary-continuous products, and convexly combine two NMDT estimates. +Scalar form: DNMDT bilinear approximation at one cell. Discretize both x +and y, form all four cross binary-continuous products, convex-combine. """ function build_bilinear_approx( config::DNMDTBilinearConfig, model::JuMP.Model, - x, - y, - x_bounds::Vector{MinMax}, - y_bounds::Vector{MinMax}, -) - x_disc = build_discretization(model, x, x_bounds, config.depth) - y_disc = build_discretization(model, y, y_bounds, config.depth) - return _build_dnmdt_with_precomputed( - config, model, x_disc, y_disc, x_bounds, y_bounds, - ) -end - -function _build_dnmdt_with_precomputed( - config::DNMDTBilinearConfig, - model::JuMP.Model, - x_disc::NMDTDiscretization, - y_disc::NMDTDiscretization, - x_bounds::Vector{MinMax}, - y_bounds::Vector{MinMax}, + x::JuMP.AbstractJuMPScalar, + y::JuMP.AbstractJuMPScalar, + x_min::Float64, + x_max::Float64, + y_min::Float64, + y_max::Float64, ) + x_disc = build_discretization(model, x, x_min, x_max, config.depth) + y_disc = build_discretization(model, y, y_min, y_max, config.depth) bx_yh = build_binary_continuous_product( model, x_disc.beta_var, y_disc.norm_expr, 0.0, 1.0, config.depth, ) @@ -234,75 +109,154 @@ function _build_dnmdt_with_precomputed( by_xh.result_expression, bx_dy.result_expression, dz.z_var, - x_disc, - y_disc, - x_bounds, - y_bounds, + x_disc.norm_expr, y_disc.norm_expr, + x_min, x_max, y_min, y_max, ) - return DNMDTBilinearResult( - approximation, x_disc, y_disc, bx_yh, by_dx, by_xh, bx_dy, dz, + return (; + approximation, + x_discretization = x_disc, + y_discretization = y_disc, + bx_yh_product = bx_yh, + by_dx_product = by_dx, + by_xh_product = by_xh, + bx_dy_product = bx_dy, + residual_product = dz, ) end -function register_in_container!( +# --- IOM adapter --- + +""" + add_bilinear_approx!(config::NMDTBilinearConfig, container, ::Type{C}, x_var, y_var, x_bounds, y_bounds, meta) + +Allocate x discretization + yh expression + binary-continuous-product + +residual-product + BilinearProductExpression containers. Loop `(name, t)`. +""" +function add_bilinear_approx!( + config::NMDTBilinearConfig, container::OptimizationContainer, ::Type{C}, - result::DNMDTBilinearResult, + x_var, + y_var, + x_bounds::Vector{MinMax}, + y_bounds::Vector{MinMax}, meta::String, ) where {C <: IS.InfrastructureSystemsComponent} - register_discretization!(container, C, result.x_discretization, meta * "_x") - register_discretization!(container, C, result.y_discretization, meta * "_y") + name_axis = axes(x_var, 1) + time_axis = axes(x_var, 2) + depth = config.depth + @assert length(name_axis) == length(x_bounds) + @assert length(name_axis) == length(y_bounds) + for i in eachindex(x_bounds) + @assert x_bounds[i].max > x_bounds[i].min + @assert y_bounds[i].max > y_bounds[i].min + end - register_binary_continuous_product!(container, C, result.bx_yh_product, meta * "_bx_yh") - register_binary_continuous_product!(container, C, result.by_dx_product, meta * "_by_dx") - register_binary_continuous_product!(container, C, result.by_xh_product, meta * "_by_xh") - register_binary_continuous_product!(container, C, result.bx_dy_product, meta * "_bx_dy") - register_residual_product!(container, C, result.residual_product, meta) + model = get_jump_model(container) - name_axis = axes(result.approximation, 1) - time_axis = axes(result.approximation, 2) - result_target = add_expression_container!( + x_disc_targets = _alloc_discretization_targets!( + container, C, name_axis, time_axis, depth, meta * "_x", + ) + yh_target = add_expression_container!( + container, NormedVariableExpression, C, name_axis, time_axis; + meta = meta * "_y", + ) + bx_yh_targets = _alloc_binary_continuous_product_targets!( + container, C, name_axis, time_axis, depth, meta; tighten = false, + ) + res_targets = _alloc_residual_product_targets!( + container, C, name_axis, time_axis, meta; tighten = false, + ) + approx_target = add_expression_container!( container, BilinearProductExpression, C, name_axis, time_axis; meta, ) - result_target.data .= result.approximation.data - return + + for (i, name) in enumerate(name_axis) + xmn, xmx = x_bounds[i].min, x_bounds[i].max + ymn, ymx = y_bounds[i].min, y_bounds[i].max + for t in time_axis + r = build_bilinear_approx( + config, model, x_var[name, t], y_var[name, t], xmn, xmx, ymn, ymx, + ) + _write_discretization_cell!(x_disc_targets, name, t, r.x_discretization, depth) + yh_target[name, t] = r.yh_expression + _write_binary_continuous_product_cell!(bx_yh_targets, name, t, r.bx_yh_product, depth) + _write_residual_product_cell!(res_targets, name, t, r.residual_product) + approx_target[name, t] = r.approximation + end + end + return approx_target end """ - add_bilinear_approx!(config::DNMDTBilinearConfig, container, C, names, time_steps, - x_disc, y_disc, x_var, y_var, x_bounds, y_bounds, meta) + add_bilinear_approx!(config::DNMDTBilinearConfig, container, ::Type{C}, x_var, y_var, x_bounds, y_bounds, meta) -Precomputed-form entrypoint: accepts already-built `x_disc` and `y_disc` -NMDT discretizations and builds only the four cross binary-continuous -products, the shared residual product, and the final convex assembly. +Allocate two discretizations + four binary-continuous-product + one +residual-product + BilinearProductExpression containers. Loop `(name, t)`. """ function add_bilinear_approx!( config::DNMDTBilinearConfig, container::OptimizationContainer, ::Type{C}, - names::Vector{String}, - time_steps::UnitRange{Int}, - x_disc::NMDTDiscretization, - y_disc::NMDTDiscretization, x_var, y_var, x_bounds::Vector{MinMax}, y_bounds::Vector{MinMax}, meta::String, ) where {C <: IS.InfrastructureSystemsComponent} - result = _build_dnmdt_with_precomputed( - config, get_jump_model(container), x_disc, y_disc, x_bounds, y_bounds, + name_axis = axes(x_var, 1) + time_axis = axes(x_var, 2) + depth = config.depth + @assert length(name_axis) == length(x_bounds) + @assert length(name_axis) == length(y_bounds) + for i in eachindex(x_bounds) + @assert x_bounds[i].max > x_bounds[i].min + @assert y_bounds[i].max > y_bounds[i].min + end + + model = get_jump_model(container) + + x_disc_targets = _alloc_discretization_targets!( + container, C, name_axis, time_axis, depth, meta * "_x", + ) + y_disc_targets = _alloc_discretization_targets!( + container, C, name_axis, time_axis, depth, meta * "_y", + ) + bx_yh_targets = _alloc_binary_continuous_product_targets!( + container, C, name_axis, time_axis, depth, meta * "_bx_yh"; tighten = false, ) - register_binary_continuous_product!(container, C, result.bx_yh_product, meta * "_bx_yh") - register_binary_continuous_product!(container, C, result.by_dx_product, meta * "_by_dx") - register_binary_continuous_product!(container, C, result.by_xh_product, meta * "_by_xh") - register_binary_continuous_product!(container, C, result.bx_dy_product, meta * "_bx_dy") - register_residual_product!(container, C, result.residual_product, meta) - name_axis = axes(result.approximation, 1) - time_axis = axes(result.approximation, 2) - result_target = add_expression_container!( + by_dx_targets = _alloc_binary_continuous_product_targets!( + container, C, name_axis, time_axis, depth, meta * "_by_dx"; tighten = false, + ) + by_xh_targets = _alloc_binary_continuous_product_targets!( + container, C, name_axis, time_axis, depth, meta * "_by_xh"; tighten = false, + ) + bx_dy_targets = _alloc_binary_continuous_product_targets!( + container, C, name_axis, time_axis, depth, meta * "_bx_dy"; tighten = false, + ) + res_targets = _alloc_residual_product_targets!( + container, C, name_axis, time_axis, meta; tighten = false, + ) + approx_target = add_expression_container!( container, BilinearProductExpression, C, name_axis, time_axis; meta, ) - result_target.data .= result.approximation.data - return result_target + + for (i, name) in enumerate(name_axis) + xmn, xmx = x_bounds[i].min, x_bounds[i].max + ymn, ymx = y_bounds[i].min, y_bounds[i].max + for t in time_axis + r = build_bilinear_approx( + config, model, x_var[name, t], y_var[name, t], xmn, xmx, ymn, ymx, + ) + _write_discretization_cell!(x_disc_targets, name, t, r.x_discretization, depth) + _write_discretization_cell!(y_disc_targets, name, t, r.y_discretization, depth) + _write_binary_continuous_product_cell!(bx_yh_targets, name, t, r.bx_yh_product, depth) + _write_binary_continuous_product_cell!(by_dx_targets, name, t, r.by_dx_product, depth) + _write_binary_continuous_product_cell!(by_xh_targets, name, t, r.by_xh_product, depth) + _write_binary_continuous_product_cell!(bx_dy_targets, name, t, r.bx_dy_product, depth) + _write_residual_product_cell!(res_targets, name, t, r.residual_product) + approx_target[name, t] = r.approximation + end + end + return approx_target end diff --git a/src/approximations/nmdt_discretization.jl b/src/approximations/nmdt_discretization.jl index af58ac30..d37f363e 100644 --- a/src/approximations/nmdt_discretization.jl +++ b/src/approximations/nmdt_discretization.jl @@ -27,453 +27,310 @@ struct NMDTEDiscretizationConstraint <: ConstraintType end "Epigraph lower-bound tightening constraint on the NMDT quadratic result." struct NMDTTightenConstraint <: ConstraintType end -# --- NMDTDiscretization struct --- +# --- Scalar build helpers (pure JuMP) --- """ -NMDT discretization scaffolding for a single normalized variable xh ∈ [0,1]. + build_discretization(model, x, x_min, x_max, depth) -Holds the affine expression for the normalized variable, the binary digit -variables β_i (one per level of depth), and the residual δ. -""" -struct NMDTDiscretization{ - NE <: JuMP.Containers.DenseAxisArray{JuMP.AffExpr, 2}, - BV <: JuMP.Containers.DenseAxisArray{JuMP.VariableRef, 3}, - DV <: JuMP.Containers.DenseAxisArray{JuMP.VariableRef, 2}, - DC <: JuMP.Containers.DenseAxisArray, - DE <: JuMP.Containers.DenseAxisArray{JuMP.AffExpr, 2}, -} - norm_expr::NE - beta_var::BV - delta_var::DV - disc_constraints::DC - disc_expression::DE -end - -# --- Pure-JuMP build helpers --- - -""" - build_discretization(model, x, bounds, depth) -> NMDTDiscretization +Scalar form: build the NMDT binary discretization of the normalized +variable xh = (x − x_min)/(x_max − x_min) for a single cell. Creates +`depth` binary variables β_i and one residual δ ∈ [0, 2^{−depth}], +enforcing xh = Σ 2^{−i}·β_i + δ. -Build the NMDT binary discretization of the normalized variable -xh = (x − x_min)/(x_max − x_min). Creates `depth` binary variables β_i and -one residual δ_h ∈ [0, 2^{−depth}], enforcing xh = Σ 2^{−i}·β_i + δ_h. +Returns `(; norm_expr, beta_var, delta_var, disc_constraint, disc_expression)`. """ function build_discretization( model::JuMP.Model, - x, - bounds::Vector{MinMax}, + x::JuMP.AbstractJuMPScalar, + x_min::Float64, + x_max::Float64, depth::Int, ) - IS.@assert_op depth >= 1 - name_axis = axes(x, 1) - time_axis = axes(x, 2) - IS.@assert_op length(name_axis) == length(bounds) - - norm_expr = build_normed_variable(model, x, bounds) + @assert depth >= 1 + @assert x_max > x_min + norm_expr = (x - x_min) / (x_max - x_min) beta_var = JuMP.@variable( - model, - [name = name_axis, i = 1:depth, t = time_axis], - binary = true, - base_name = "NMDTBinary", + model, [i = 1:depth], binary = true, base_name = "NMDTBinary", ) delta_var = JuMP.@variable( model, - [name = name_axis, t = time_axis], - lower_bound = 0.0, - upper_bound = 2.0^(-depth), + lower_bound = 0.0, upper_bound = 2.0^(-depth), base_name = "NMDTResidual", ) disc_expr = JuMP.@expression( - model, - [name = name_axis, t = time_axis], - sum(2.0^(-i) * beta_var[name, i, t] for i in 1:depth) + delta_var[name, t] + model, sum(2.0^(-i) * beta_var[i] for i in 1:depth) + delta_var, ) - disc_cons = JuMP.@constraint( - model, - [name = name_axis, t = time_axis], - norm_expr[name, t] == disc_expr[name, t], + disc_con = JuMP.@constraint(model, norm_expr == disc_expr) + return (; + norm_expr, + beta_var, + delta_var, + disc_constraint = disc_con, + disc_expression = disc_expr, ) - return NMDTDiscretization(norm_expr, beta_var, delta_var, disc_cons, disc_expr) -end - -""" -Result of a single NMDT binary-continuous product step β_i·y ≈ u_i, weighted -sum into Σ 2^{−i}·u_i. Returned by `build_binary_continuous_product`. - -`mccormick_lower` is `nothing` when `tighten = true` (the caller supplies a -tighter bound elsewhere). -""" -struct NMDTBinaryContinuousProduct{ - UV <: JuMP.Containers.DenseAxisArray{JuMP.VariableRef, 3}, - MCL <: Union{Nothing, NTuple{2, <:JuMP.Containers.DenseAxisArray}}, - MCU <: NTuple{2, <:JuMP.Containers.DenseAxisArray}, - RE <: JuMP.Containers.DenseAxisArray{JuMP.AffExpr, 2}, -} - u_var::UV - mccormick_lower::MCL - mccormick_upper::MCU - result_expression::RE end """ build_binary_continuous_product(model, beta_var, cont_var, cont_min, cont_max, depth; tighten=false) -Build the depth-level binary-continuous product Σᵢ 2^{−i}·u_i ≈ β·y. +Scalar form: build Σᵢ 2^{−i}·u_i ≈ β·y for a single cell. `beta_var` is a +1D `DenseAxisArray` over `1:depth` (binary), `cont_var` is a JuMP scalar. -For each (name, i, t), creates an auxiliary u_i with bounds [cont_min, cont_max] -and adds McCormick envelope inequalities on (β_i, y, u_i). If -`tighten = true`, the lower-bound McCormick constraints are omitted (the caller -applies a tighter bound elsewhere). +Returns `(; u_var, mccormick_lower, mccormick_upper, result_expression)` where +`mccormick_lower` is `nothing` when `tighten = true`. """ function build_binary_continuous_product( model::JuMP.Model, - beta_var, - cont_var, + beta_var::AbstractVector, + cont_var::JuMP.AbstractJuMPScalar, cont_min::Float64, cont_max::Float64, depth::Int; tighten::Bool = false, ) - name_axis = axes(beta_var, 1) - time_axis = axes(beta_var, 3) u_var = JuMP.@variable( - model, - [name = name_axis, i = 1:depth, t = time_axis], - lower_bound = cont_min, - upper_bound = cont_max, + model, [i = 1:depth], + lower_bound = cont_min, upper_bound = cont_max, base_name = "NMDTBinContProd", ) - # McCormick envelopes for u[name, i, t] ≈ cont_var[name, t] · beta[name, i, t], - # with cont_var ∈ [cont_min, cont_max] and beta ∈ {0, 1}: - # c1 (lower): u ≥ cont_min · beta - # c2 (lower): u ≥ cont_max · beta + cont_var − cont_max - # c3 (upper): u ≤ cont_max · beta - # c4 (upper): u ≤ cont_min · beta + cont_var − cont_min upper_1 = JuMP.@constraint( - model, - [name = name_axis, i = 1:depth, t = time_axis], - u_var[name, i, t] <= cont_max * beta_var[name, i, t], + model, [i = 1:depth], u_var[i] <= cont_max * beta_var[i], ) upper_2 = JuMP.@constraint( - model, - [name = name_axis, i = 1:depth, t = time_axis], - u_var[name, i, t] <= - cont_min * beta_var[name, i, t] + cont_var[name, t] - cont_min, + model, [i = 1:depth], + u_var[i] <= cont_min * beta_var[i] + cont_var - cont_min, ) mccormick_lower = if tighten nothing else lower_1 = JuMP.@constraint( - model, - [name = name_axis, i = 1:depth, t = time_axis], - u_var[name, i, t] >= cont_min * beta_var[name, i, t], + model, [i = 1:depth], u_var[i] >= cont_min * beta_var[i], ) lower_2 = JuMP.@constraint( - model, - [name = name_axis, i = 1:depth, t = time_axis], - u_var[name, i, t] >= - cont_max * beta_var[name, i, t] + cont_var[name, t] - cont_max, + model, [i = 1:depth], + u_var[i] >= cont_max * beta_var[i] + cont_var - cont_max, ) - (lower_1, lower_2) + (c1 = lower_1, c2 = lower_2) end result_expr = JuMP.@expression( - model, - [name = name_axis, t = time_axis], - sum(2.0^(-i) * u_var[name, i, t] for i in 1:depth) + model, sum(2.0^(-i) * u_var[i] for i in 1:depth), ) - return NMDTBinaryContinuousProduct( - u_var, mccormick_lower, (upper_1, upper_2), result_expr, + return (; + u_var, + mccormick_lower, + mccormick_upper = (c1 = upper_1, c2 = upper_2), + result_expression = result_expr, ) end -""" -Result of the residual-continuous product step z ≈ δ·y. Returned by -`build_residual_product`. -""" -struct NMDTResidualProduct{ - ZV <: JuMP.Containers.DenseAxisArray{JuMP.VariableRef, 2}, - MC <: NamedTuple, -} - z_var::ZV - mccormick_constraints::MC -end - """ build_residual_product(model, delta_var, cont_var, cont_max, depth; tighten=false) -Build a single auxiliary z ≈ δ·y with McCormick envelopes on -(δ ∈ [0, 2^{−depth}], y ∈ [0, cont_max]). Lower bounds on the McCormick -envelope are omitted when `tighten = true`. +Scalar form: build z ≈ δ·y for a single cell, where δ ∈ [0, 2^{−depth}] +and y ∈ [0, cont_max], using McCormick envelopes. When `tighten`, only the +upper envelopes are added. + +Returns `(; z_var, mccormick_constraints)` where `mccormick_constraints` is +the NamedTuple returned by `build_mccormick_upper` (`tighten = true`) or +`build_mccormick_envelope` (`tighten = false`). """ function build_residual_product( model::JuMP.Model, - delta_var, - cont_var, + delta_var::JuMP.AbstractJuMPScalar, + cont_var::JuMP.AbstractJuMPScalar, cont_max::Float64, depth::Int; tighten::Bool = false, ) - name_axis = axes(delta_var, 1) - time_axis = axes(delta_var, 2) delta_max = 2.0^(-depth) z_var = JuMP.@variable( model, - [name = name_axis, t = time_axis], - lower_bound = 0.0, - upper_bound = delta_max * cont_max, + lower_bound = 0.0, upper_bound = delta_max * cont_max, base_name = "NMDTResidualProduct", ) - # Bounds for the vectorized envelope: δ ∈ [0, delta_max], cont ∈ [0, cont_max]. - delta_bounds = fill((min = 0.0, max = delta_max), length(name_axis)) - cont_bounds = fill((min = 0.0, max = cont_max), length(name_axis)) - mc = build_mccormick_envelope( - model, - delta_var, - cont_var, - z_var, - delta_bounds, - cont_bounds; - lower_bounds = !tighten, - ) - return NMDTResidualProduct(z_var, mc) + mc = if tighten + build_mccormick_upper( + model, delta_var, cont_var, z_var, 0.0, delta_max, 0.0, cont_max, + ) + else + build_mccormick_envelope( + model, delta_var, cont_var, z_var, 0.0, delta_max, 0.0, cont_max, + ) + end + return (; z_var, mccormick_constraints = mc) end """ - build_assembled_product(model, terms, dz, xh_expr, yh_expr, x_bounds, y_bounds) - -Affine reassembly of the bilinear product x·y from normalized NMDT pieces. + build_assembled_product(model, terms, dz, xh_expr, yh_expr, x_min, x_max, y_min, y_max) -For each (name, t): +Scalar form: affine reassembly of x·y from normalized NMDT pieces: x·y = lx·ly·zh + lx·y_min·xh + ly·x_min·yh + x_min·y_min -where `zh = sum(term[name, t] for term in terms) + dz[name, t]`. +where `zh = sum(terms) + dz`. `terms` is a list of scalar AffExpr values. """ function build_assembled_product( model::JuMP.Model, - terms, - dz, + terms::AbstractVector, + dz::JuMP.AbstractJuMPScalar, xh_expr, yh_expr, - x_bounds::Vector{MinMax}, - y_bounds::Vector{MinMax}, + x_min::Float64, + x_max::Float64, + y_min::Float64, + y_max::Float64, ) - name_axis = axes(xh_expr, 1) - time_axis = axes(xh_expr, 2) - IS.@assert_op length(name_axis) == length(x_bounds) - IS.@assert_op length(name_axis) == length(y_bounds) - lx = JuMP.Containers.DenseAxisArray( - [b.max - b.min for b in x_bounds], - name_axis, - ) - ly = JuMP.Containers.DenseAxisArray( - [b.max - b.min for b in y_bounds], - name_axis, - ) - x_min = JuMP.Containers.DenseAxisArray( - [b.min for b in x_bounds], - name_axis, - ) - y_min = JuMP.Containers.DenseAxisArray( - [b.min for b in y_bounds], - name_axis, - ) + lx = x_max - x_min + ly = y_max - y_min return JuMP.@expression( model, - [name = name_axis, t = time_axis], - lx[name] * ly[name] * - (sum(term[name, t] for term in terms) + dz[name, t]) + - lx[name] * y_min[name] * xh_expr[name, t] + - ly[name] * x_min[name] * yh_expr[name, t] + - x_min[name] * y_min[name] + lx * ly * (sum(terms) + dz) + + lx * y_min * xh_expr + + ly * x_min * yh_expr + + x_min * y_min, ) end """ - build_assembled_dnmdt(model, bx_yh, by_dx, by_xh, bx_dy, dz, x_disc, y_disc, x_bounds, y_bounds; lambda) - -Convex combination of two NMDT estimates of x·y. Returns the result expression. + build_assembled_dnmdt(model, bx_yh, by_dx, by_xh, bx_dy, dz, xh_expr, yh_expr, x_min, x_max, y_min, y_max; lambda) -`z1` is the (x discretizes, y normalized) estimate and `z2` is the (y discretizes, -x normalized) estimate. The result is `λ·z1 + (1−λ)·z2`. The shared residual -product `dz ≈ δ_x · δ_y` is supplied by the caller. +Scalar form: convex combination of two NMDT estimates of x·y at one cell. """ function build_assembled_dnmdt( model::JuMP.Model, - bx_yh, - by_dx, - by_xh, - bx_dy, - dz, - x_disc::NMDTDiscretization, - y_disc::NMDTDiscretization, - x_bounds::Vector{MinMax}, - y_bounds::Vector{MinMax}; + bx_yh::JuMP.AbstractJuMPScalar, + by_dx::JuMP.AbstractJuMPScalar, + by_xh::JuMP.AbstractJuMPScalar, + bx_dy::JuMP.AbstractJuMPScalar, + dz::JuMP.AbstractJuMPScalar, + xh_expr, + yh_expr, + x_min::Float64, + x_max::Float64, + y_min::Float64, + y_max::Float64; lambda::Float64 = DNMDT_LAMBDA, ) z1 = build_assembled_product( - model, [bx_yh, by_dx], dz, - x_disc.norm_expr, y_disc.norm_expr, x_bounds, y_bounds, + model, [bx_yh, by_dx], dz, xh_expr, yh_expr, + x_min, x_max, y_min, y_max, ) z2 = build_assembled_product( - model, [by_xh, bx_dy], dz, - y_disc.norm_expr, x_disc.norm_expr, y_bounds, x_bounds, - ) - name_axis = axes(z1, 1) - time_axis = axes(z1, 2) - return JuMP.@expression( - model, - [name = name_axis, t = time_axis], - lambda * z1[name, t] + (1.0 - lambda) * z2[name, t] + model, [by_xh, bx_dy], dz, yh_expr, xh_expr, + y_min, y_max, x_min, x_max, ) + return JuMP.@expression(model, lambda * z1 + (1.0 - lambda) * z2) end -# --- IOM-side register helpers (called from method-specific register_in_container!) --- - -""" - register_discretization!(container, ::Type{C}, disc::NMDTDiscretization, meta) +# --- IOM allocation + per-cell write helpers (used by NMDT quad/bilinear adapters) --- -Register the discretization variables, residual, expression, and constraint -into the optimization container under their respective key types. -""" -function register_discretization!( - container::OptimizationContainer, - ::Type{C}, - disc::NMDTDiscretization, - meta::String, +function _alloc_discretization_targets!( + container::OptimizationContainer, ::Type{C}, name_axis, time_axis, depth::Int, meta, ) where {C <: IS.InfrastructureSystemsComponent} - name_axis = axes(disc.beta_var, 1) - depth_axis = axes(disc.beta_var, 2) - time_axis = axes(disc.beta_var, 3) - - norm_target = add_expression_container!( - container, NormedVariableExpression, C, name_axis, time_axis; meta, + return ( + norm = add_expression_container!( + container, NormedVariableExpression, C, name_axis, time_axis; meta, + ), + beta = add_variable_container!( + container, NMDTBinaryVariable, C, name_axis, 1:depth, time_axis; meta, + ), + delta = add_variable_container!( + container, NMDTResidualVariable, C, name_axis, time_axis; meta, + ), + disc_expr = add_expression_container!( + container, NMDTDiscretizationExpression, C, name_axis, time_axis; meta, + ), + disc_cons = add_constraints_container!( + container, NMDTEDiscretizationConstraint, C, name_axis, time_axis; meta, + ), ) - norm_target.data .= disc.norm_expr.data - - beta_target = add_variable_container!( - container, NMDTBinaryVariable, C, name_axis, depth_axis, time_axis; meta, - ) - beta_target.data .= disc.beta_var.data - - delta_target = add_variable_container!( - container, NMDTResidualVariable, C, name_axis, time_axis; meta, - ) - delta_target.data .= disc.delta_var.data - - disc_expr_target = add_expression_container!( - container, NMDTDiscretizationExpression, C, name_axis, time_axis; meta, - ) - disc_expr_target.data .= disc.disc_expression.data +end - disc_cons_target = add_constraints_container!( - container, NMDTEDiscretizationConstraint, C, name_axis, time_axis; meta, - ) - disc_cons_target.data .= disc.disc_constraints.data +function _write_discretization_cell!(targets, name, t, r, depth::Int) + targets.norm[name, t] = r.norm_expr + for i in 1:depth + targets.beta[name, i, t] = r.beta_var[i] + end + targets.delta[name, t] = r.delta_var + targets.disc_expr[name, t] = r.disc_expression + targets.disc_cons[name, t] = r.disc_constraint return end -""" - register_binary_continuous_product!(container, ::Type{C}, product, meta) - -Register the auxiliary u variables, McCormick constraints (split into -lower/upper sides under `McCormickLowerConstraint`/`McCormickUpperConstraint`), -and the weighted-sum expression of an NMDT binary-continuous product step. -""" -function register_binary_continuous_product!( - container::OptimizationContainer, - ::Type{C}, - product::NMDTBinaryContinuousProduct, - meta::String, +function _alloc_binary_continuous_product_targets!( + container::OptimizationContainer, ::Type{C}, name_axis, time_axis, depth::Int, meta; + tighten::Bool, ) where {C <: IS.InfrastructureSystemsComponent} - name_axis = axes(product.u_var, 1) - depth_axis = axes(product.u_var, 2) - time_axis = axes(product.u_var, 3) - u_target = add_variable_container!( - container, - NMDTBinaryContinuousProductVariable, - C, - name_axis, - depth_axis, - time_axis; - meta, + container, NMDTBinaryContinuousProductVariable, C, + name_axis, 1:depth, time_axis; meta, ) - u_target.data .= product.u_var.data - - # Suffix the McCormick meta so the binary-continuous product's envelope - # doesn't collide with a sibling residual product's envelope under the - # same NMDT approximation's `meta`. - _register_mccormick_depth_side!( - container, C, McCormickUpperConstraint, product.mccormick_upper, meta * "_bc", + mc_meta = meta * "_bc" + mc_upper_target = add_constraints_container!( + container, McCormickUpperConstraint, C, + name_axis, 1:depth, 1:2, time_axis; meta = mc_meta, ) - _register_mccormick_depth_side!( - container, C, McCormickLowerConstraint, product.mccormick_lower, meta * "_bc", + mc_lower_target = tighten ? nothing : add_constraints_container!( + container, McCormickUpperConstraint, C, + name_axis, 1:depth, 1:2, time_axis; meta = mc_meta * "_lb", ) - - expr_target = add_expression_container!( - container, - NMDTBinaryContinuousProductExpression, - C, - name_axis, - time_axis; - meta, + result_expr_target = add_expression_container!( + container, NMDTBinaryContinuousProductExpression, C, + name_axis, time_axis; meta, + ) + return ( + u = u_target, + mc_upper = mc_upper_target, + mc_lower = mc_lower_target, + result_expr = result_expr_target, ) - expr_target.data .= product.result_expression.data - return end -# Register one side (lower or upper) of an NMDT binary-continuous product's -# McCormick envelope. Each side is a pair of 3D `(name, depth, t)` constraint -# containers; we stack them into a 4D `(name, depth, k=1:2, t)` container. -function _register_mccormick_depth_side!( - container::OptimizationContainer, - ::Type{C}, - ::Type{K}, - cons::Tuple{<:JuMP.Containers.DenseAxisArray, <:JuMP.Containers.DenseAxisArray}, - meta::String, -) where { - C <: IS.InfrastructureSystemsComponent, - K <: ConstraintType, -} - c1, c2 = cons - name_axis = axes(c1, 1) - depth_axis = axes(c1, 2) - time_axis = axes(c1, 3) - target = add_constraints_container!( - container, K, C, name_axis, depth_axis, 1:2, time_axis; meta, - ) - @views target.data[:, :, 1, :] .= c1.data - @views target.data[:, :, 2, :] .= c2.data +function _write_binary_continuous_product_cell!(targets, name, t, r, depth::Int) + for i in 1:depth + targets.u[name, i, t] = r.u_var[i] + targets.mc_upper[name, i, 1, t] = r.mccormick_upper.c1[i] + targets.mc_upper[name, i, 2, t] = r.mccormick_upper.c2[i] + if targets.mc_lower !== nothing + targets.mc_lower[name, i, 1, t] = r.mccormick_lower.c1[i] + targets.mc_lower[name, i, 2, t] = r.mccormick_lower.c2[i] + end + end + targets.result_expr[name, t] = r.result_expression return end -# No-op when the lower side wasn't built. -_register_mccormick_depth_side!( - ::OptimizationContainer, - ::Type{<:IS.InfrastructureSystemsComponent}, - ::Type{<:ConstraintType}, - ::Nothing, - ::String, -) = nothing - -""" - register_residual_product!(container, ::Type{C}, product, meta) - -Register the residual product z variable and its McCormick constraints. -""" -function register_residual_product!( - container::OptimizationContainer, - ::Type{C}, - product::NMDTResidualProduct, - meta::String, +function _alloc_residual_product_targets!( + container::OptimizationContainer, ::Type{C}, name_axis, time_axis, meta; + tighten::Bool, ) where {C <: IS.InfrastructureSystemsComponent} - name_axis = axes(product.z_var, 1) - time_axis = axes(product.z_var, 2) - z_target = add_variable_container!( container, NMDTResidualProductVariable, C, name_axis, time_axis; meta, ) - z_target.data .= product.z_var.data + res_meta = meta * "_res" + mc_target = if tighten + add_constraints_container!( + container, McCormickUpperConstraint, C, + name_axis, 1:2, time_axis; meta = res_meta, + ) + else + add_constraints_container!( + container, McCormickConstraint, C, + name_axis, 1:4, time_axis; meta = res_meta, + ) + end + return (z = z_target, mc = mc_target, tighten = tighten) +end - register_mccormick_envelope!(container, C, product.mccormick_constraints, meta) +function _write_residual_product_cell!(targets, name, t, r) + targets.z[name, t] = r.z_var + mc = r.mccormick_constraints + if targets.tighten + targets.mc[name, 1, t] = mc.upper_1 + targets.mc[name, 2, t] = mc.upper_2 + else + targets.mc[name, 1, t] = mc.upper_1 + targets.mc[name, 2, t] = mc.upper_2 + targets.mc[name, 3, t] = mc.lower_1 + targets.mc[name, 4, t] = mc.lower_2 + end return end diff --git a/src/approximations/nmdt_quadratic.jl b/src/approximations/nmdt_quadratic.jl index c814fac5..417cafd5 100644 --- a/src/approximations/nmdt_quadratic.jl +++ b/src/approximations/nmdt_quadratic.jl @@ -43,190 +43,83 @@ function DNMDTQuadConfig(depth::Int) return DNMDTQuadConfig(depth, 3 * depth) end -# --- Shared epigraph tightening helper --- +# --- Scalar build (pure JuMP) --- """ -Result of an epigraph tightening step on an NMDT quadratic approximation. -""" -struct NMDTEpigraphTightening{ - EPI <: EpigraphQuadResult, - CONS <: JuMP.Containers.DenseAxisArray, -} - epigraph::EPI - constraints::CONS -end - -function _build_nmdt_tightening( - model::JuMP.Model, - approximation, - x_disc::NMDTDiscretization, - epigraph_depth::Int, -) - name_axis = axes(approximation, 1) - time_axis = axes(approximation, 2) - fake_bounds = fill((min = 0.0, max = 1.0), length(name_axis)) - epi = build_quadratic_approx( - EpigraphQuadConfig(epigraph_depth), model, x_disc.norm_expr, fake_bounds, - ) - cons = JuMP.@constraint( - model, - [name = name_axis, t = time_axis], - approximation[name, t] >= epi.approximation[name, t] - ) - return NMDTEpigraphTightening(epi, cons) -end - -function _register_tightening!( - container::OptimizationContainer, - ::Type{C}, - t::NMDTEpigraphTightening, - meta::String, -) where {C <: IS.InfrastructureSystemsComponent} - register_in_container!(container, C, t.epigraph, meta * "_epi") - name_axis = axes(t.constraints, 1) - time_axis = axes(t.constraints, 2) - target = add_constraints_container!( - container, NMDTTightenConstraint, C, name_axis, time_axis; meta, - ) - target.data .= t.constraints.data - return -end - -# No-op when tightening is disabled (config.epigraph_depth = 0). -_register_tightening!( - ::OptimizationContainer, - ::Type{<:IS.InfrastructureSystemsComponent}, - ::Nothing, - ::String, -) = nothing + build_quadratic_approx(config::NMDTQuadConfig, model, x, x_min, x_max) -# --- NMDT (single) --- +Scalar form: approximate x² via single NMDT for one cell. Discretize xh, +build the binary-continuous product β·xh, the residual product δ·xh, and +reassemble x². When `epigraph_depth > 0`, also build an epigraph lower +bound on xh² and tighten with `x²_approx ≥ epi`. -""" -Pure-JuMP result of `build_quadratic_approx(::NMDTQuadConfig, ...)`. -""" -struct NMDTQuadResult{ - A <: JuMP.Containers.DenseAxisArray{JuMP.AffExpr, 2}, - D <: NMDTDiscretization, - BX <: NMDTBinaryContinuousProduct, - DZ <: NMDTResidualProduct, - T <: Union{Nothing, NMDTEpigraphTightening}, -} <: QuadraticApproxResult - approximation::A - discretization::D - bx_xh_product::BX - residual_product::DZ - tightening::T -end - -""" - build_quadratic_approx(config::NMDTQuadConfig, model, x, bounds) - -Approximate x² using single NMDT: discretize xh, build the binary-continuous -product Σ 2^{−i}·u_i ≈ β·xh and the residual product z ≈ δ·xh, then -reassemble x² from these normalized components. +Returns `(; approximation, discretization, bx_xh_product, residual_product, +tightening)`. """ function build_quadratic_approx( config::NMDTQuadConfig, model::JuMP.Model, - x, - bounds::Vector{MinMax}, + x::JuMP.AbstractJuMPScalar, + x_min::Float64, + x_max::Float64, ) tighten = config.epigraph_depth > 0 - x_disc = build_discretization(model, x, bounds, config.depth) + disc = build_discretization(model, x, x_min, x_max, config.depth) bx_xh = build_binary_continuous_product( - model, x_disc.beta_var, x_disc.norm_expr, 0.0, 1.0, config.depth; tighten, + model, disc.beta_var, disc.norm_expr, 0.0, 1.0, config.depth; tighten, ) dz = build_residual_product( - model, x_disc.delta_var, x_disc.norm_expr, 1.0, config.depth; tighten, + model, disc.delta_var, disc.norm_expr, 1.0, config.depth; tighten, ) approximation = build_assembled_product( - model, - [bx_xh.result_expression], - dz.z_var, - x_disc.norm_expr, - x_disc.norm_expr, - bounds, - bounds, + model, [bx_xh.result_expression], dz.z_var, + disc.norm_expr, disc.norm_expr, x_min, x_max, x_min, x_max, ) tightening = if tighten - _build_nmdt_tightening(model, approximation, x_disc, config.epigraph_depth) + epi = build_quadratic_approx( + EpigraphQuadConfig(config.epigraph_depth), model, disc.norm_expr, 0.0, 1.0, + ) + tcon = JuMP.@constraint(model, approximation >= epi.approximation) + (; epigraph = epi, constraint = tcon) else nothing end - return NMDTQuadResult(approximation, x_disc, bx_xh, dz, tightening) -end - -function register_in_container!( - container::OptimizationContainer, - ::Type{C}, - result::NMDTQuadResult, - meta::String, -) where {C <: IS.InfrastructureSystemsComponent} - register_discretization!(container, C, result.discretization, meta) - register_binary_continuous_product!(container, C, result.bx_xh_product, meta) - register_residual_product!(container, C, result.residual_product, meta) - - name_axis = axes(result.approximation, 1) - time_axis = axes(result.approximation, 2) - result_target = add_expression_container!( - container, QuadraticExpression, C, name_axis, time_axis; meta, + return (; + approximation, + discretization = disc, + bx_xh_product = bx_xh, + residual_product = dz, + tightening, ) - result_target.data .= result.approximation.data - - _register_tightening!(container, C, result.tightening, meta) - return end -# --- DNMDT --- - """ -Pure-JuMP result of `build_quadratic_approx(::DNMDTQuadConfig, ...)`. -""" -struct DNMDTQuadResult{ - A <: JuMP.Containers.DenseAxisArray{JuMP.AffExpr, 2}, - D <: NMDTDiscretization, - BX_XH <: NMDTBinaryContinuousProduct, - BX_DX <: NMDTBinaryContinuousProduct, - DZ <: NMDTResidualProduct, - T <: Union{Nothing, NMDTEpigraphTightening}, -} <: QuadraticApproxResult - approximation::A - discretization::D - bx_xh_product::BX_XH - bx_dx_product::BX_DX - residual_product::DZ - tightening::T -end + build_quadratic_approx(config::DNMDTQuadConfig, model, x, x_min, x_max) -""" - build_quadratic_approx(config::DNMDTQuadConfig, model, x, bounds) +Scalar form: approximate x² via double NMDT for one cell — convex +combination of two NMDT estimates with shared discretization on x. -Approximate x² using double NMDT: combine two NMDT estimates with shared -discretization on x, plus the residual product δ_x·δ_x. +Returns the same NamedTuple shape as `NMDTQuadConfig` plus an additional +`bx_dx_product` field for the second binary-continuous product step. """ function build_quadratic_approx( config::DNMDTQuadConfig, model::JuMP.Model, - x, - bounds::Vector{MinMax}, + x::JuMP.AbstractJuMPScalar, + x_min::Float64, + x_max::Float64, ) tighten = config.epigraph_depth > 0 - x_disc = build_discretization(model, x, bounds, config.depth) + disc = build_discretization(model, x, x_min, x_max, config.depth) bx_xh = build_binary_continuous_product( - model, x_disc.beta_var, x_disc.norm_expr, 0.0, 1.0, config.depth; tighten, + model, disc.beta_var, disc.norm_expr, 0.0, 1.0, config.depth; tighten, ) bx_dx = build_binary_continuous_product( - model, - x_disc.beta_var, - x_disc.delta_var, - 0.0, - 2.0^(-config.depth), - config.depth; - tighten, + model, disc.beta_var, disc.delta_var, + 0.0, 2.0^(-config.depth), config.depth; tighten, ) dz = build_residual_product( - model, x_disc.delta_var, x_disc.delta_var, + model, disc.delta_var, disc.delta_var, 2.0^(-config.depth), config.depth; tighten, ) approximation = build_assembled_dnmdt( @@ -236,41 +129,173 @@ function build_quadratic_approx( bx_xh.result_expression, bx_dx.result_expression, dz.z_var, - x_disc, - x_disc, - bounds, - bounds, + disc.norm_expr, disc.norm_expr, + x_min, x_max, x_min, x_max, ) tightening = if tighten - _build_nmdt_tightening(model, approximation, x_disc, config.epigraph_depth) + epi = build_quadratic_approx( + EpigraphQuadConfig(config.epigraph_depth), model, disc.norm_expr, 0.0, 1.0, + ) + tcon = JuMP.@constraint(model, approximation >= epi.approximation) + (; epigraph = epi, constraint = tcon) else nothing end - return DNMDTQuadResult(approximation, x_disc, bx_xh, bx_dx, dz, tightening) + return (; + approximation, + discretization = disc, + bx_xh_product = bx_xh, + bx_dx_product = bx_dx, + residual_product = dz, + tightening, + ) end -function register_in_container!( +# --- IOM allocation + per-cell write helpers --- + +function _alloc_nmdt_tightening_targets!( + container::OptimizationContainer, ::Type{C}, name_axis, time_axis, epi_depth::Int, meta, +) where {C <: IS.InfrastructureSystemsComponent} + epi_targets = _alloc_epigraph_targets!( + container, C, name_axis, time_axis, epi_depth, meta * "_epi", + ) + tighten_cons = add_constraints_container!( + container, NMDTTightenConstraint, C, name_axis, time_axis; meta, + ) + return (epi = epi_targets, tighten = tighten_cons) +end + +function _write_nmdt_tightening_cell!(targets, name, t, tightening, epi_depth::Int) + _write_epigraph_cell!(targets.epi, name, t, tightening.epigraph, epi_depth) + targets.tighten[name, t] = tightening.constraint + return +end + +""" + add_quadratic_approx!(config::NMDTQuadConfig, container, ::Type{C}, x_var, x_bounds, meta) + +Allocate discretization + binary-continuous-product + residual-product +containers, plus when tightening is enabled the epigraph + tightening +constraint containers. Loop `(name, t)`. +""" +function add_quadratic_approx!( + config::NMDTQuadConfig, container::OptimizationContainer, ::Type{C}, - result::DNMDTQuadResult, + x_var, + x_bounds::Vector{MinMax}, meta::String, ) where {C <: IS.InfrastructureSystemsComponent} - register_discretization!(container, C, result.discretization, meta) - register_binary_continuous_product!( - container, C, result.bx_xh_product, meta * "_bx_xh", + name_axis = axes(x_var, 1) + time_axis = axes(x_var, 2) + depth = config.depth + tighten = config.epigraph_depth > 0 + @assert length(name_axis) == length(x_bounds) + for b in x_bounds + @assert b.max > b.min + end + + model = get_jump_model(container) + + disc_targets = _alloc_discretization_targets!( + container, C, name_axis, time_axis, depth, meta, + ) + bx_targets = _alloc_binary_continuous_product_targets!( + container, C, name_axis, time_axis, depth, meta; tighten, ) - register_binary_continuous_product!( - container, C, result.bx_dx_product, meta * "_bx_dx", + res_targets = _alloc_residual_product_targets!( + container, C, name_axis, time_axis, meta; tighten, + ) + approx_target = add_expression_container!( + container, QuadraticExpression, C, name_axis, time_axis; meta, ) - register_residual_product!(container, C, result.residual_product, meta) + tighten_targets = tighten ? + _alloc_nmdt_tightening_targets!( + container, C, name_axis, time_axis, config.epigraph_depth, meta, + ) : nothing - name_axis = axes(result.approximation, 1) - time_axis = axes(result.approximation, 2) - result_target = add_expression_container!( + for (i, name) in enumerate(name_axis) + xmn, xmx = x_bounds[i].min, x_bounds[i].max + for t in time_axis + r = build_quadratic_approx(config, model, x_var[name, t], xmn, xmx) + _write_discretization_cell!(disc_targets, name, t, r.discretization, depth) + _write_binary_continuous_product_cell!(bx_targets, name, t, r.bx_xh_product, depth) + _write_residual_product_cell!(res_targets, name, t, r.residual_product) + approx_target[name, t] = r.approximation + if tighten + _write_nmdt_tightening_cell!( + tighten_targets, name, t, r.tightening, config.epigraph_depth, + ) + end + end + end + return approx_target +end + +""" + add_quadratic_approx!(config::DNMDTQuadConfig, container, ::Type{C}, x_var, x_bounds, meta) + +Allocate two binary-continuous-product container sets +(`meta * "_bx_xh"` and `meta * "_bx_dx"`) plus the rest of the NMDT pieces. +""" +function add_quadratic_approx!( + config::DNMDTQuadConfig, + container::OptimizationContainer, + ::Type{C}, + x_var, + x_bounds::Vector{MinMax}, + meta::String, +) where {C <: IS.InfrastructureSystemsComponent} + name_axis = axes(x_var, 1) + time_axis = axes(x_var, 2) + depth = config.depth + tighten = config.epigraph_depth > 0 + @assert length(name_axis) == length(x_bounds) + for b in x_bounds + @assert b.max > b.min + end + + model = get_jump_model(container) + + disc_targets = _alloc_discretization_targets!( + container, C, name_axis, time_axis, depth, meta, + ) + bx_xh_targets = _alloc_binary_continuous_product_targets!( + container, C, name_axis, time_axis, depth, meta * "_bx_xh"; tighten, + ) + bx_dx_targets = _alloc_binary_continuous_product_targets!( + container, C, name_axis, time_axis, depth, meta * "_bx_dx"; tighten, + ) + res_targets = _alloc_residual_product_targets!( + container, C, name_axis, time_axis, meta; tighten, + ) + approx_target = add_expression_container!( container, QuadraticExpression, C, name_axis, time_axis; meta, ) - result_target.data .= result.approximation.data + tighten_targets = tighten ? + _alloc_nmdt_tightening_targets!( + container, C, name_axis, time_axis, config.epigraph_depth, meta, + ) : nothing - _register_tightening!(container, C, result.tightening, meta) - return + for (i, name) in enumerate(name_axis) + xmn, xmx = x_bounds[i].min, x_bounds[i].max + for t in time_axis + r = build_quadratic_approx(config, model, x_var[name, t], xmn, xmx) + _write_discretization_cell!(disc_targets, name, t, r.discretization, depth) + _write_binary_continuous_product_cell!( + bx_xh_targets, name, t, r.bx_xh_product, depth, + ) + _write_binary_continuous_product_cell!( + bx_dx_targets, name, t, r.bx_dx_product, depth, + ) + _write_residual_product_cell!(res_targets, name, t, r.residual_product) + approx_target[name, t] = r.approximation + if tighten + _write_nmdt_tightening_cell!( + tighten_targets, name, t, r.tightening, config.epigraph_depth, + ) + end + end + end + return approx_target end diff --git a/src/approximations/no_approx_bilinear.jl b/src/approximations/no_approx_bilinear.jl index 03bdab5e..48b5999e 100644 --- a/src/approximations/no_approx_bilinear.jl +++ b/src/approximations/no_approx_bilinear.jl @@ -4,13 +4,11 @@ "No-op config for bilinear approximation: returns exact x·y as a QuadExpr." struct NoBilinearApproxConfig <: BilinearApproxConfig end -# --- Scalar build (pure JuMP, primary API) --- - """ build_bilinear_approx(::NoBilinearApproxConfig, model, x, y, x_min, x_max, y_min, y_max) Scalar form: return `(; approximation = x*y)` for a single JuMP scalar pair. -Bounds are accepted for signature parity with other bilinear methods and unused. +Bounds accepted for signature parity, unused. """ function build_bilinear_approx( ::NoBilinearApproxConfig, @@ -25,13 +23,11 @@ function build_bilinear_approx( return (; approximation = x * y) end -# --- IOM adapter (allocate, loop, write) --- - """ add_bilinear_approx!(::NoBilinearApproxConfig, container, ::Type{C}, x_var, y_var, x_bounds, y_bounds, meta) -Allocate a `BilinearProductExpression` container with axes `(name, t)`, loop -over the cells, and write the exact `x*y` QuadExpr per cell. +Allocate a `BilinearProductExpression` container with axes `(name, t)`, +loop, and write the exact `x*y` per cell. """ function add_bilinear_approx!( ::NoBilinearApproxConfig, @@ -45,8 +41,8 @@ function add_bilinear_approx!( ) where {C <: IS.InfrastructureSystemsComponent} name_axis = axes(x_var, 1) time_axis = axes(x_var, 2) - IS.@assert_op length(name_axis) == length(x_bounds) - IS.@assert_op length(name_axis) == length(y_bounds) + @assert length(name_axis) == length(x_bounds) + @assert length(name_axis) == length(y_bounds) model = get_jump_model(container) target = add_expression_container!( container, BilinearProductExpression, C, name_axis, time_axis; @@ -67,73 +63,18 @@ function add_bilinear_approx!( return target end -# --- Legacy vectorized build + register + precomputed-form entrypoint -# (kept for the generic add_bilinear_approx! wrapper in common.jl and the -# Bin2/HybS swap-in pattern, until callers migrate; removed in sweep) --- - -"Pure-JuMP result of the no-op bilinear approximation (legacy)." -struct NoBilinearApproxResult{ - A <: JuMP.Containers.DenseAxisArray{JuMP.QuadExpr, 2}, -} <: BilinearApproxResult - approximation::A -end - """ - build_bilinear_approx(::NoBilinearApproxConfig, model, x, y, x_bounds, y_bounds) + add_bilinear_approx!(::NoBilinearApproxConfig, container, ::Type{C}, xsq, ysq, x_var, y_var, x_bounds, y_bounds, meta) -Legacy vectorized form. Returns a `NoBilinearApproxResult` wrapping a 2D -`DenseAxisArray{QuadExpr}` of `x[name,t]*y[name,t]`. -""" -function build_bilinear_approx( - ::NoBilinearApproxConfig, - model::JuMP.Model, - x, - y, - x_bounds::Vector{MinMax}, - y_bounds::Vector{MinMax}, -) - name_axis = axes(x, 1) - time_axis = axes(x, 2) - approximation = JuMP.@expression( - model, - [name = name_axis, t = time_axis], - x[name, t] * y[name, t] - ) - return NoBilinearApproxResult(approximation) -end - -function register_in_container!( - container::OptimizationContainer, - ::Type{C}, - result::NoBilinearApproxResult, - meta::String, -) where {C <: IS.InfrastructureSystemsComponent} - name_axis = axes(result.approximation, 1) - time_axis = axes(result.approximation, 2) - target = add_expression_container!( - container, BilinearProductExpression, C, name_axis, time_axis; - meta, expr_type = JuMP.QuadExpr, - ) - target.data .= result.approximation.data - return -end - -""" - add_bilinear_approx!(::NoBilinearApproxConfig, container, C, names, time_steps, - xsq, ysq, x_var, y_var, x_bounds, y_bounds, meta) - -Legacy precomputed-form entrypoint: signature-compatible with the -precomputed-form of `Bin2Config` / `HybSConfig`, so a caller can swap -configs without changing the call site. `xsq` and `ysq` are accepted but -ignored — the no-op approximation just returns the exact `x·y` product as -a `QuadExpr`. +Precomputed-form entrypoint: signature-compatible with the precomputed-form +of `Bin2Config` / `HybSConfig`, so a caller can swap configs without +changing the call site. `xsq` and `ysq` are accepted but ignored — the +no-op approximation just returns the exact `x*y` product. """ function add_bilinear_approx!( ::NoBilinearApproxConfig, container::OptimizationContainer, ::Type{C}, - names::Vector{String}, - time_steps::UnitRange{Int}, xsq, ysq, x_var, @@ -142,17 +83,7 @@ function add_bilinear_approx!( y_bounds::Vector{MinMax}, meta::String, ) where {C <: IS.InfrastructureSystemsComponent} - name_axis = axes(x_var, 1) - time_axis = axes(x_var, 2) - target = add_expression_container!( - container, BilinearProductExpression, C, name_axis, time_axis; - meta, expr_type = JuMP.QuadExpr, + return add_bilinear_approx!( + NoBilinearApproxConfig(), container, C, x_var, y_var, x_bounds, y_bounds, meta, ) - target.data .= - JuMP.@expression( - get_jump_model(container), - [name = name_axis, t = time_axis], - x_var[name, t] * y_var[name, t] - ).data - return target end diff --git a/src/approximations/no_approx_quadratic.jl b/src/approximations/no_approx_quadratic.jl index 5cca18e7..1dccba8b 100644 --- a/src/approximations/no_approx_quadratic.jl +++ b/src/approximations/no_approx_quadratic.jl @@ -4,13 +4,11 @@ "No-op config for quadratic approximation: returns exact x² as a QuadExpr." struct NoQuadApproxConfig <: QuadraticApproxConfig end -# --- Scalar build (pure JuMP, primary API) --- - """ build_quadratic_approx(::NoQuadApproxConfig, model, x, x_min, x_max) Scalar form: return `(; approximation = x*x)` for a single JuMP scalar. -Bounds are accepted for signature parity with other quadratic methods and unused. +Bounds accepted for signature parity with other quadratic methods, unused. """ function build_quadratic_approx( ::NoQuadApproxConfig, @@ -22,14 +20,11 @@ function build_quadratic_approx( return (; approximation = x * x) end -# --- IOM adapter (allocate, loop, write) --- - """ add_quadratic_approx!(::NoQuadApproxConfig, container, ::Type{C}, x_var, x_bounds, meta) -Allocate a `QuadraticExpression` container with axes `(name, t)`, loop over -the cells, call the scalar `build_quadratic_approx(::NoQuadApproxConfig, ...)`, -and write the exact `x*x` QuadExpr per cell. +Allocate a `QuadraticExpression` container with axes `(name, t)`, loop, +call the scalar build per cell, write the exact `x*x` per cell. """ function add_quadratic_approx!( ::NoQuadApproxConfig, @@ -41,7 +36,7 @@ function add_quadratic_approx!( ) where {C <: IS.InfrastructureSystemsComponent} name_axis = axes(x_var, 1) time_axis = axes(x_var, 2) - IS.@assert_op length(name_axis) == length(x_bounds) + @assert length(name_axis) == length(x_bounds) model = get_jump_model(container) target = add_expression_container!( container, QuadraticExpression, C, name_axis, time_axis; @@ -56,51 +51,3 @@ function add_quadratic_approx!( end return target end - -# --- Legacy vectorized build + register (kept for the generic add_quadratic_approx! -# wrapper in common.jl until callers migrate; removed in sweep) --- - -"Pure-JuMP result of the no-op quadratic approximation (legacy)." -struct NoQuadApproxResult{ - A <: JuMP.Containers.DenseAxisArray{JuMP.QuadExpr, 2}, -} <: QuadraticApproxResult - approximation::A -end - -""" - build_quadratic_approx(::NoQuadApproxConfig, model, x, bounds) - -Legacy vectorized form. Returns a `NoQuadApproxResult` wrapping a 2D -`DenseAxisArray{QuadExpr}` of `x[name,t]^2`. -""" -function build_quadratic_approx( - ::NoQuadApproxConfig, - model::JuMP.Model, - x, - bounds::Vector{MinMax}, -) - name_axis = axes(x, 1) - time_axis = axes(x, 2) - approximation = JuMP.@expression( - model, - [name = name_axis, t = time_axis], - x[name, t] * x[name, t] - ) - return NoQuadApproxResult(approximation) -end - -function register_in_container!( - container::OptimizationContainer, - ::Type{C}, - result::NoQuadApproxResult, - meta::String, -) where {C <: IS.InfrastructureSystemsComponent} - name_axis = axes(result.approximation, 1) - time_axis = axes(result.approximation, 2) - target = add_expression_container!( - container, QuadraticExpression, C, name_axis, time_axis; - meta, expr_type = JuMP.QuadExpr, - ) - target.data .= result.approximation.data - return -end diff --git a/src/approximations/pwmcc_cuts.jl b/src/approximations/pwmcc_cuts.jl index cae361bf..b54627c4 100644 --- a/src/approximations/pwmcc_cuts.jl +++ b/src/approximations/pwmcc_cuts.jl @@ -3,9 +3,6 @@ # v's domain into K sub-intervals. The LP gap shrinks from Delta²/4 to # Delta²/(4·K²). These cuts supplement (do not replace) the underlying PWL # (SOS2 or manual-SOS2) constraints. -# -# Shared between solver_sos2.jl and manual_sos2.jl — both call the scalar -# `build_pwmcc_concave_cuts` per cell from inside their own scalar build. # --- Container key types --- @@ -36,25 +33,16 @@ struct PiecewiseMcCormickTangentLBL <: ConstraintType end "Piecewise McCormick tangent lower-bound constraint (right endpoint)." struct PiecewiseMcCormickTangentLBR <: ConstraintType end -# --- Scalar build (pure JuMP, primary API) --- - """ build_pwmcc_concave_cuts(model, v, q_expr, v_min, v_max, K) -Scalar form: build the K-segment PWMCC cuts at a single cell. `v` is a -JuMP scalar (the original variable) and `q_expr` is the scalar expression -for the existing PWL v² approximation at this cell. +Scalar form: build the K-segment PWMCC cuts at a single cell. `v` is the +JuMP scalar variable and `q_expr` is the scalar expression for the +existing PWL v² approximation at this cell. -Returns a NamedTuple: -- `delta_var` :: DenseAxisArray{VariableRef, 1} over `1:K` (binary) -- `vd_var` :: DenseAxisArray{VariableRef, 1} over `1:K` (continuous) -- `selector_constraint` :: scalar (Σ_k δ_k == 1) -- `linking_constraint` :: scalar (Σ_k vd_k == v) -- `interval_lb_constraints` :: DenseAxisArray{Constraint, 1} over `1:K` -- `interval_ub_constraints` :: DenseAxisArray{Constraint, 1} over `1:K` -- `chord_ub_constraint` :: scalar -- `tangent_lb_l_constraint` :: scalar -- `tangent_lb_r_constraint` :: scalar +Returns a NamedTuple with `(delta_var, vd_var, selector_constraint, +linking_constraint, interval_lb_constraints, interval_ub_constraints, +chord_ub_constraint, tangent_lb_l_constraint, tangent_lb_r_constraint)`. """ function build_pwmcc_concave_cuts( model::JuMP.Model, @@ -64,20 +52,15 @@ function build_pwmcc_concave_cuts( v_max::Float64, K::Int, ) - IS.@assert_op K >= 1 - IS.@assert_op v_min < v_max + @assert K >= 1 + @assert v_min < v_max - brk = [v_min + k * (v_max - v_min) / K for k in 0:K] # length K+1, indexed 1..K+1 + brk = [v_min + k * (v_max - v_min) / K for k in 0:K] delta_var = JuMP.@variable( - model, [k = 1:K], - binary = true, - base_name = "PwMcCBin", - ) - vd_var = JuMP.@variable( - model, [k = 1:K], - base_name = "PwMcCDis", + model, [k = 1:K], binary = true, base_name = "PwMcCBin", ) + vd_var = JuMP.@variable(model, [k = 1:K], base_name = "PwMcCDis") selector_con = JuMP.@constraint(model, sum(delta_var[k] for k in 1:K) == 1.0) linking_con = JuMP.@constraint(model, sum(vd_var[k] for k in 1:K) == v) @@ -121,205 +104,56 @@ function build_pwmcc_concave_cuts( ) end -# --- Legacy vectorized build + register (kept for SolverSOS2/ManualSOS2's -# vectorized build_quadratic_approx until those callers migrate; removed in -# sweep) --- - -""" -Pure-JuMP result of legacy vectorized `build_pwmcc_concave_cuts`. Fields are -JuMP container arrays indexed by (name, k, t) for the K-segment pieces or -(name, t) for the once-per-element constraints. -""" -struct PWMCCResult{ - DV <: JuMP.Containers.DenseAxisArray{JuMP.VariableRef, 3}, - VDV <: JuMP.Containers.DenseAxisArray{JuMP.VariableRef, 3}, - SC <: JuMP.Containers.DenseAxisArray, - LC <: JuMP.Containers.DenseAxisArray, - ILBC <: JuMP.Containers.DenseAxisArray, - IUBC <: JuMP.Containers.DenseAxisArray, - CUBC <: JuMP.Containers.DenseAxisArray, - TLLC <: JuMP.Containers.DenseAxisArray, - TLRC <: JuMP.Containers.DenseAxisArray, -} - delta_var::DV - vd_var::VDV - selector_constraints::SC - linking_constraints::LC - interval_lb_constraints::ILBC - interval_ub_constraints::IUBC - chord_ub_constraints::CUBC - tangent_lb_l_constraints::TLLC - tangent_lb_r_constraints::TLRC -end - -""" - build_pwmcc_concave_cuts(model, v_var, q_expr, bounds, K) -> PWMCCResult - -Legacy vectorized form. Build piecewise McCormick cuts on the concave term -(−v²) to tighten the LP relaxation of a PWL approximation `q_expr ≈ v²`. -Partitions each name's [v_min, v_max] into K uniform sub-intervals. -""" -function build_pwmcc_concave_cuts( - model::JuMP.Model, - v_var, - q_expr, - bounds::Vector{MinMax}, - K::Int, -) - IS.@assert_op K >= 1 - name_axis = axes(v_var, 1) - time_axis = axes(v_var, 2) - IS.@assert_op length(name_axis) == length(bounds) - for b in bounds - IS.@assert_op b.min < b.max - end - - brk = JuMP.Containers.DenseAxisArray( - [ - bounds[i].min + k * (bounds[i].max - bounds[i].min) / K - for i in eachindex(name_axis), k in 0:K - ], - name_axis, - 0:K, - ) - - delta_var = JuMP.@variable( - model, - [name = name_axis, k = 1:K, t = time_axis], - binary = true, - base_name = "PwMcCBin", - ) - vd_var = JuMP.@variable( - model, - [name = name_axis, k = 1:K, t = time_axis], - base_name = "PwMcCDis", - ) - - selector_cons = JuMP.@constraint( - model, - [name = name_axis, t = time_axis], - sum(delta_var[name, k, t] for k in 1:K) == 1.0 - ) - linking_cons = JuMP.@constraint( - model, - [name = name_axis, t = time_axis], - sum(vd_var[name, k, t] for k in 1:K) == v_var[name, t] - ) - interval_lb = JuMP.@constraint( - model, - [name = name_axis, k = 1:K, t = time_axis], - brk[name, k - 1] * delta_var[name, k, t] <= vd_var[name, k, t] - ) - interval_ub = JuMP.@constraint( - model, - [name = name_axis, k = 1:K, t = time_axis], - vd_var[name, k, t] <= brk[name, k] * delta_var[name, k, t] - ) - chord_ub = JuMP.@constraint( - model, - [name = name_axis, t = time_axis], - q_expr[name, t] <= sum( - (brk[name, k - 1] + brk[name, k]) * vd_var[name, k, t] - - brk[name, k - 1] * brk[name, k] * delta_var[name, k, t] for k in 1:K - ) - ) - tangent_lb_l = JuMP.@constraint( - model, - [name = name_axis, t = time_axis], - q_expr[name, t] >= sum( - 2.0 * brk[name, k - 1] * vd_var[name, k, t] - - brk[name, k - 1]^2 * delta_var[name, k, t] for k in 1:K - ) - ) - tangent_lb_r = JuMP.@constraint( - model, - [name = name_axis, t = time_axis], - q_expr[name, t] >= sum( - 2.0 * brk[name, k] * vd_var[name, k, t] - - brk[name, k]^2 * delta_var[name, k, t] for k in 1:K - ) - ) - - return PWMCCResult( - delta_var, - vd_var, - selector_cons, - linking_cons, - interval_lb, - interval_ub, - chord_ub, - tangent_lb_l, - tangent_lb_r, - ) -end - -""" - register_pwmcc!(container, ::Type{C}, pwmcc::PWMCCResult, meta) +# --- Allocation + per-cell write helpers (used by SOS2 adapters) --- -Legacy registration helper for the vectorized PWMCC result. -""" -function register_pwmcc!( - container::OptimizationContainer, - ::Type{C}, - pwmcc::PWMCCResult, - meta::String, +function _alloc_pwmcc_targets!( + container::OptimizationContainer, ::Type{C}, name_axis, time_axis, K::Int, meta, ) where {C <: IS.InfrastructureSystemsComponent} - name_axis = axes(pwmcc.delta_var, 1) - k_axis = axes(pwmcc.delta_var, 2) - time_axis = axes(pwmcc.delta_var, 3) - - delta_target = add_variable_container!( - container, PiecewiseMcCormickBinary, C, name_axis, k_axis, time_axis; meta, - ) - delta_target.data .= pwmcc.delta_var.data - - vd_target = add_variable_container!( - container, PiecewiseMcCormickDisaggregated, C, name_axis, k_axis, time_axis; - meta, - ) - vd_target.data .= pwmcc.vd_var.data - - selector_target = add_constraints_container!( - container, PiecewiseMcCormickSelectorSum, C, name_axis, time_axis; meta, - ) - selector_target.data .= pwmcc.selector_constraints.data - - linking_target = add_constraints_container!( - container, PiecewiseMcCormickLinking, C, name_axis, time_axis; meta, - ) - linking_target.data .= pwmcc.linking_constraints.data - - chord_target = add_constraints_container!( - container, PiecewiseMcCormickChordUB, C, name_axis, time_axis; meta, - ) - chord_target.data .= pwmcc.chord_ub_constraints.data - - tangent_l_target = add_constraints_container!( - container, PiecewiseMcCormickTangentLBL, C, name_axis, time_axis; meta, - ) - tangent_l_target.data .= pwmcc.tangent_lb_l_constraints.data - - tangent_r_target = add_constraints_container!( - container, PiecewiseMcCormickTangentLBR, C, name_axis, time_axis; meta, - ) - tangent_r_target.data .= pwmcc.tangent_lb_r_constraints.data - - interval_lb_target = add_constraints_container!( - container, PiecewiseMcCormickIntervalLB, C, name_axis, k_axis, time_axis; meta, + return ( + delta = add_variable_container!( + container, PiecewiseMcCormickBinary, C, name_axis, 1:K, time_axis; meta, + ), + vd = add_variable_container!( + container, PiecewiseMcCormickDisaggregated, C, + name_axis, 1:K, time_axis; meta, + ), + selector = add_constraints_container!( + container, PiecewiseMcCormickSelectorSum, C, name_axis, time_axis; meta, + ), + linking = add_constraints_container!( + container, PiecewiseMcCormickLinking, C, name_axis, time_axis; meta, + ), + interval_lb = add_constraints_container!( + container, PiecewiseMcCormickIntervalLB, C, + name_axis, 1:K, time_axis; meta, + ), + interval_ub = add_constraints_container!( + container, PiecewiseMcCormickIntervalUB, C, + name_axis, 1:K, time_axis; meta, + ), + chord = add_constraints_container!( + container, PiecewiseMcCormickChordUB, C, name_axis, time_axis; meta, + ), + tangent_l = add_constraints_container!( + container, PiecewiseMcCormickTangentLBL, C, name_axis, time_axis; meta, + ), + tangent_r = add_constraints_container!( + container, PiecewiseMcCormickTangentLBR, C, name_axis, time_axis; meta, + ), ) - interval_lb_target.data .= pwmcc.interval_lb_constraints.data +end - interval_ub_target = add_constraints_container!( - container, PiecewiseMcCormickIntervalUB, C, name_axis, k_axis, time_axis; meta, - ) - interval_ub_target.data .= pwmcc.interval_ub_constraints.data +function _write_pwmcc_cell!(targets, name, t, r, K::Int) + for k in 1:K + targets.delta[name, k, t] = r.delta_var[k] + targets.vd[name, k, t] = r.vd_var[k] + targets.interval_lb[name, k, t] = r.interval_lb_constraints[k] + targets.interval_ub[name, k, t] = r.interval_ub_constraints[k] + end + targets.selector[name, t] = r.selector_constraint + targets.linking[name, t] = r.linking_constraint + targets.chord[name, t] = r.chord_ub_constraint + targets.tangent_l[name, t] = r.tangent_lb_l_constraint + targets.tangent_r[name, t] = r.tangent_lb_r_constraint return end - -# No-op when the caller did not build PWMCC cuts. -register_pwmcc!( - ::OptimizationContainer, - ::Type{<:IS.InfrastructureSystemsComponent}, - ::Nothing, - ::String, -) = nothing diff --git a/src/approximations/sawtooth.jl b/src/approximations/sawtooth.jl index 054b423c..772da1f5 100644 --- a/src/approximations/sawtooth.jl +++ b/src/approximations/sawtooth.jl @@ -30,26 +30,17 @@ function SawtoothQuadConfig(depth::Int) return SawtoothQuadConfig(depth, 0) end -# --- Scalar build (pure JuMP, primary API) --- - """ build_quadratic_approx(config::SawtoothQuadConfig, model, x, x_min, x_max) Scalar form: PWL approximation of x² for a single JuMP scalar `x` with bounds `[x_min, x_max]`, using `depth` binary variables. If `config.epigraph_depth > 0`, also builds an epigraph Q^{L1} lower bound and -tightens the approximation via z ≤ sawtooth_upper and z ≥ epigraph. +tightens with z ≤ sawtooth_upper and z ≥ epigraph. -Returns a NamedTuple: -- `approximation` :: scalar (AffExpr; either `x_sq_approx` or `1.0·z` if tightened) -- `g_var` :: DenseAxisArray{VariableRef, 1} over `0:depth` -- `alpha_var` :: DenseAxisArray{VariableRef, 1} over `1:depth` -- `link_constraint` :: scalar constraint linking g₀ to (x − x_min)/δ -- `mip_constraints` :: DenseAxisArray{Constraint, 2} over `(1:depth, 1:4)` -- `tightening` :: `nothing`, or a NamedTuple - `(; z_var, constraints :: 1D over 1:2, epigraph)` where - `epigraph` is the full NamedTuple returned by the - scalar epigraph build. +Returns a NamedTuple with `(approximation, g_var, alpha_var, link_constraint, +mip_constraints, tightening)` where `tightening` is `nothing` or +`(; z_var, constraints :: 1D over 1:2, epigraph)`. """ function build_quadratic_approx( config::SawtoothQuadConfig, @@ -58,8 +49,8 @@ function build_quadratic_approx( x_min::Float64, x_max::Float64, ) - IS.@assert_op config.depth >= 1 - IS.@assert_op x_max > x_min + @assert config.depth >= 1 + @assert x_max > x_min depth = config.depth delta = x_max - x_min @@ -77,7 +68,6 @@ function build_quadratic_approx( link_con = JuMP.@constraint(model, g_var[0] == (x - x_min) / delta) - # S^L constraints: 4 inequalities per level. mip_a = JuMP.@constraint( model, [j = 1:depth], g_var[j] <= 2.0 * g_var[j - 1], ) @@ -90,17 +80,15 @@ function build_quadratic_approx( mip_d = JuMP.@constraint( model, [j = 1:depth], g_var[j] >= 2.0 * (alpha_var[j] - g_var[j - 1]), ) - mip_cons = JuMP.Containers.DenseAxisArray{JuMP.ConstraintRef}( - undef, 1:depth, 1:4, - ) - @views mip_cons.data[:, 1] .= mip_a.data - @views mip_cons.data[:, 2] .= mip_b.data - @views mip_cons.data[:, 3] .= mip_c.data - @views mip_cons.data[:, 4] .= mip_d.data + mip_cons = JuMP.Containers.DenseAxisArray{JuMP.ConstraintRef}(undef, 1:depth, 1:4) + @views mip_cons.data[:, 1] .= mip_a + @views mip_cons.data[:, 2] .= mip_b + @views mip_cons.data[:, 3] .= mip_c + @views mip_cons.data[:, 4] .= mip_d x_sq_approx = JuMP.@expression( model, - scale_back_g_basis_scalar(x_min, delta, g_var, 1:depth), + scale_back_g_basis(x_min, delta, g_var, 1:depth), ) if config.epigraph_depth > 0 @@ -115,9 +103,7 @@ function build_quadratic_approx( ) tight_a = JuMP.@constraint(model, z_var <= x_sq_approx) tight_b = JuMP.@constraint(model, z_var >= epi.approximation) - tight_cons = JuMP.Containers.DenseAxisArray{JuMP.ConstraintRef}( - undef, 1:2, - ) + tight_cons = JuMP.Containers.DenseAxisArray{JuMP.ConstraintRef}(undef, 1:2) tight_cons[1] = tight_a tight_cons[2] = tight_b approximation = JuMP.@expression(model, 1.0 * z_var) @@ -142,18 +128,12 @@ function build_quadratic_approx( ) end -# --- IOM adapter (allocate, loop, write) --- - """ add_quadratic_approx!(config::SawtoothQuadConfig, container, ::Type{C}, x_var, x_bounds, meta) Allocate sawtooth containers (g, α, link, mip, approximation) plus, when `config.epigraph_depth > 0`, the tightened-z + 2-constraint containers AND -the full set of epigraph containers under `meta * "_lb"`. Then loop -`(name, t)` calling the scalar build per cell and writing all the refs -into their slots. - -Returns the registered `QuadraticExpression` container. +the full set of epigraph containers under `meta * "_lb"`. Loop `(name, t)`. """ function add_quadratic_approx!( config::SawtoothQuadConfig, @@ -166,10 +146,10 @@ function add_quadratic_approx!( name_axis = axes(x_var, 1) time_axis = axes(x_var, 2) depth = config.depth - IS.@assert_op depth >= 1 - IS.@assert_op length(name_axis) == length(x_bounds) + @assert depth >= 1 + @assert length(name_axis) == length(x_bounds) for b in x_bounds - IS.@assert_op b.max > b.min + @assert b.max > b.min end model = get_jump_model(container) @@ -191,9 +171,7 @@ function add_quadratic_approx!( ) tighten = config.epigraph_depth > 0 - local st_z_target, st_tight_target - local epi_z_target, epi_g_target, epi_link_target, epi_fL_target, - epi_approx_target, epi_lp_target, epi_tangent_target + local st_z_target, st_tight_target, epi_targets local epi_depth::Int if tighten st_z_target = add_variable_container!( @@ -203,32 +181,8 @@ function add_quadratic_approx!( container, SawtoothTightenedConstraint, C, name_axis, 1:2, time_axis; meta, ) epi_depth = config.epigraph_depth - epi_meta = meta * "_lb" - epi_z_target = add_variable_container!( - container, EpigraphVariable, C, name_axis, time_axis; meta = epi_meta, - ) - epi_g_target = add_variable_container!( - container, SawtoothAuxVariable, C, name_axis, 0:epi_depth, time_axis; - meta = epi_meta, - ) - epi_link_target = add_constraints_container!( - container, SawtoothLinkingConstraint, C, name_axis, time_axis; - meta = epi_meta, - ) - epi_fL_target = add_expression_container!( - container, EpigraphTangentExpression, C, name_axis, time_axis; - meta = epi_meta, - ) - epi_approx_target = add_expression_container!( - container, EpigraphExpression, C, name_axis, time_axis; meta = epi_meta, - ) - epi_lp_target = add_constraints_container!( - container, SawtoothLPConstraint, C, name_axis, 1:epi_depth, 1:2, time_axis; - meta = epi_meta, - ) - epi_tangent_target = add_constraints_container!( - container, EpigraphTangentConstraint, C, name_axis, 1:(epi_depth + 2), - time_axis; meta = epi_meta, + epi_targets = _alloc_epigraph_targets!( + container, C, name_axis, time_axis, epi_depth, meta * "_lb", ) end @@ -254,262 +208,9 @@ function add_quadratic_approx!( for k in 1:2 st_tight_target[name, k, t] = tt.constraints[k] end - epi = tt.epigraph - epi_z_target[name, t] = epi.z_var - for j in 0:epi_depth - epi_g_target[name, j, t] = epi.g_var[j] - end - epi_link_target[name, t] = epi.link_constraint - epi_fL_target[name, t] = epi.tangent_expression - epi_approx_target[name, t] = epi.approximation - for j in 1:epi_depth, k in 1:2 - epi_lp_target[name, j, k, t] = epi.lp_constraints[j, k] - end - for j in 1:(epi_depth + 2) - epi_tangent_target[name, j, t] = epi.tangent_constraints[j] - end + _write_epigraph_cell!(epi_targets, name, t, tt.epigraph, epi_depth) end end end return approx_target end - -# --- Legacy result + tightening structs + vectorized build + register -# (kept for the generic add_quadratic_approx! wrapper in common.jl until -# callers migrate; removed in sweep) --- - -""" -Tightening pieces of a sawtooth result when `config.epigraph_depth > 0`: -the substitute z variable, its bound constraints, and the epigraph result -that supplies the lower bound (legacy). -""" -struct SawtoothTightening{ - ZV <: JuMP.Containers.DenseAxisArray{JuMP.VariableRef, 2}, - TC <: JuMP.Containers.DenseAxisArray, - EPI <: EpigraphQuadResult, -} - z_var::ZV - constraints::TC - epigraph::EPI -end - -""" -Pure-JuMP result of legacy vectorized `build_quadratic_approx(::SawtoothQuadConfig, ...)`. -""" -struct SawtoothQuadResult{ - A <: JuMP.Containers.DenseAxisArray, - G <: JuMP.Containers.DenseAxisArray{JuMP.VariableRef, 3}, - AL <: JuMP.Containers.DenseAxisArray{JuMP.VariableRef, 3}, - LC <: JuMP.Containers.DenseAxisArray, - MC <: JuMP.Containers.DenseAxisArray, - T <: Union{Nothing, SawtoothTightening}, -} <: QuadraticApproxResult - approximation::A - g_var::G - alpha_var::AL - link_constraints::LC - mip_constraints::MC - tightening::T -end - -""" - build_quadratic_approx(config::SawtoothQuadConfig, model, x, bounds) - -Legacy vectorized form. PWL approximation of x² with sawtooth tooth -functions and L binary variables. If `config.epigraph_depth > 0`, also -builds an epigraph Q^{L1} lower bound and tightens the approximation: -z ≤ x² (sawtooth, upper) and z ≥ epigraph (lower). -""" -function build_quadratic_approx( - config::SawtoothQuadConfig, - model::JuMP.Model, - x, - bounds::Vector{MinMax}, -) - IS.@assert_op config.depth >= 1 - name_axis = axes(x, 1) - time_axis = axes(x, 2) - IS.@assert_op length(name_axis) == length(bounds) - for b in bounds - IS.@assert_op b.max > b.min - end - - g_levels = 0:(config.depth) - alpha_levels = 1:(config.depth) - delta = JuMP.Containers.DenseAxisArray([b.max - b.min for b in bounds], name_axis) - x_min_arr = JuMP.Containers.DenseAxisArray([b.min for b in bounds], name_axis) - - g_var = JuMP.@variable( - model, - [name = name_axis, j = g_levels, t = time_axis], - lower_bound = 0.0, - upper_bound = 1.0, - base_name = "SawtoothAux", - ) - alpha_var = JuMP.@variable( - model, - [name = name_axis, j = alpha_levels, t = time_axis], - binary = true, - base_name = "SawtoothBin", - ) - - link_cons = JuMP.@constraint( - model, - [name = name_axis, t = time_axis], - g_var[name, 0, t] == (x[name, t] - x_min_arr[name]) / delta[name], - ) - - mip_a = JuMP.@constraint( - model, - [name = name_axis, j = alpha_levels, t = time_axis], - g_var[name, j, t] <= 2.0 * g_var[name, j - 1, t], - ) - mip_b = JuMP.@constraint( - model, - [name = name_axis, j = alpha_levels, t = time_axis], - g_var[name, j, t] <= 2.0 * (1.0 - g_var[name, j - 1, t]), - ) - mip_c = JuMP.@constraint( - model, - [name = name_axis, j = alpha_levels, t = time_axis], - g_var[name, j, t] >= 2.0 * (g_var[name, j - 1, t] - alpha_var[name, j, t]), - ) - mip_d = JuMP.@constraint( - model, - [name = name_axis, j = alpha_levels, t = time_axis], - g_var[name, j, t] >= 2.0 * (alpha_var[name, j, t] - g_var[name, j - 1, t]), - ) - mip_cons = JuMP.Containers.DenseAxisArray{JuMP.ConstraintRef}( - undef, name_axis, alpha_levels, 1:4, time_axis, - ) - @views mip_cons.data[:, :, 1, :] .= mip_a.data - @views mip_cons.data[:, :, 2, :] .= mip_b.data - @views mip_cons.data[:, :, 3, :] .= mip_c.data - @views mip_cons.data[:, :, 4, :] .= mip_d.data - - x_sq_approx = JuMP.@expression( - model, - [name = name_axis, t = time_axis], - scale_back_g_basis( - x_min_arr[name], delta[name], g_var, name, t, alpha_levels, - ) - ) - - if config.epigraph_depth > 0 - epi_result = build_quadratic_approx( - EpigraphQuadConfig(config.epigraph_depth), model, x, bounds, - ) - z_min_arr = JuMP.Containers.DenseAxisArray( - [(b.min <= 0.0 <= b.max) ? 0.0 : min(b.min^2, b.max^2) for b in bounds], - name_axis, - ) - z_max_arr = JuMP.Containers.DenseAxisArray( - [max(b.min^2, b.max^2) for b in bounds], - name_axis, - ) - z_var = JuMP.@variable( - model, - [name = name_axis, t = time_axis], - lower_bound = z_min_arr[name], - upper_bound = z_max_arr[name], - base_name = "TightenedSawtooth", - ) - tight_a = JuMP.@constraint( - model, - [name = name_axis, t = time_axis], - z_var[name, t] <= x_sq_approx[name, t], - ) - tight_b = JuMP.@constraint( - model, - [name = name_axis, t = time_axis], - z_var[name, t] >= epi_result.approximation[name, t], - ) - tight_cons = JuMP.Containers.DenseAxisArray{JuMP.ConstraintRef}( - undef, name_axis, 1:2, time_axis, - ) - @views tight_cons.data[:, 1, :] .= tight_a.data - @views tight_cons.data[:, 2, :] .= tight_b.data - approximation = JuMP.@expression( - model, - [name = name_axis, t = time_axis], - 1.0 * z_var[name, t] - ) - tightening = SawtoothTightening(z_var, tight_cons, epi_result) - return SawtoothQuadResult( - approximation, g_var, alpha_var, link_cons, mip_cons, tightening, - ) - end - - return SawtoothQuadResult( - x_sq_approx, g_var, alpha_var, link_cons, mip_cons, nothing, - ) -end - -function register_in_container!( - container::OptimizationContainer, - ::Type{C}, - result::SawtoothQuadResult, - meta::String, -) where {C <: IS.InfrastructureSystemsComponent} - name_axis = axes(result.approximation, 1) - time_axis = axes(result.approximation, 2) - g_levels = axes(result.g_var, 2) - alpha_levels = axes(result.alpha_var, 2) - - g_target = add_variable_container!( - container, SawtoothAuxVariable, C, name_axis, g_levels, time_axis; meta, - ) - g_target.data .= result.g_var.data - - alpha_target = add_variable_container!( - container, SawtoothBinaryVariable, C, name_axis, alpha_levels, time_axis; meta, - ) - alpha_target.data .= result.alpha_var.data - - link_target = add_constraints_container!( - container, SawtoothLinkingConstraint, C, name_axis, time_axis; meta, - ) - link_target.data .= result.link_constraints.data - - mip_target = add_constraints_container!( - container, SawtoothMIPConstraint, C, name_axis, alpha_levels, 1:4, time_axis; - meta, - ) - mip_target.data .= result.mip_constraints.data - - result_target = add_expression_container!( - container, QuadraticExpression, C, name_axis, time_axis; meta, - ) - result_target.data .= result.approximation.data - - _register_sawtooth_tightening!(container, C, result.tightening, meta) - return -end - -function _register_sawtooth_tightening!( - container::OptimizationContainer, - ::Type{C}, - tight::SawtoothTightening, - meta::String, -) where {C <: IS.InfrastructureSystemsComponent} - name_axis = axes(tight.z_var, 1) - time_axis = axes(tight.z_var, 2) - z_target = add_variable_container!( - container, SawtoothTightenedVariable, C, name_axis, time_axis; meta, - ) - z_target.data .= tight.z_var.data - tight_target = add_constraints_container!( - container, SawtoothTightenedConstraint, C, name_axis, 1:2, time_axis; meta, - ) - tight_target.data .= tight.constraints.data - register_in_container!(container, C, tight.epigraph, meta * "_lb") - return -end - -# No-op when tightening is disabled (config.epigraph_depth = 0). -_register_sawtooth_tightening!( - ::OptimizationContainer, - ::Type{<:IS.InfrastructureSystemsComponent}, - ::Nothing, - ::String, -) = nothing diff --git a/src/approximations/solver_sos2.jl b/src/approximations/solver_sos2.jl index 2e5996ae..98e4c6c4 100644 --- a/src/approximations/solver_sos2.jl +++ b/src/approximations/solver_sos2.jl @@ -35,24 +35,15 @@ function SolverSOS2QuadConfig(depth::Int) return SolverSOS2QuadConfig(depth, 4) end -# --- Scalar build (pure JuMP, primary API) --- - """ build_quadratic_approx(config::SolverSOS2QuadConfig, model, x, x_min, x_max) Scalar form: PWL approximation of x² for a single JuMP scalar `x` using solver-native MOI.SOS2 adjacency. If `config.pwmcc_segments > 0`, also -adds piecewise McCormick concave cuts (per cell). +adds piecewise McCormick concave cuts per cell. -Returns a NamedTuple: -- `approximation` :: scalar AffExpr (the PWL estimate of x²) -- `lambda` :: DenseAxisArray{VariableRef, 1} over `1:n_points` -- `link_constraint` :: scalar -- `norm_constraint` :: scalar -- `sos_constraint` :: scalar (MOI.SOS2 adjacency) -- `link_expression` :: scalar AffExpr (Σ x_bkpts[i] · λ_i) -- `norm_expression` :: scalar AffExpr (Σ λ_i) -- `pwmcc` :: `nothing` or NamedTuple from scalar `build_pwmcc_concave_cuts` +Returns a NamedTuple with `(approximation, lambda, link_constraint, +norm_constraint, sos_constraint, link_expression, norm_expression, pwmcc)`. """ function build_quadratic_approx( config::SolverSOS2QuadConfig, @@ -61,7 +52,7 @@ function build_quadratic_approx( x_min::Float64, x_max::Float64, ) - IS.@assert_op x_max > x_min + @assert x_max > x_min n_points = config.depth + 1 x_bkpts, x_sq_bkpts = _get_breakpoints_for_pwl_function( 0.0, 1.0, _square; num_segments = config.depth, @@ -82,7 +73,6 @@ function build_quadratic_approx( sos_con = JuMP.@constraint( model, [lambda[i] for i in 1:n_points] in MOI.SOS2(collect(1:n_points)), ) - # x² = lx² · Σ λ_i · x_bkpts[i]² + 2·x_min·x − x_min² approximation = JuMP.@expression( model, lx * lx * sum(x_sq_bkpts[i] * lambda[i] for i in 1:n_points) + @@ -109,17 +99,12 @@ function build_quadratic_approx( ) end -# --- IOM adapter (allocate, loop, write) --- - """ add_quadratic_approx!(config::SolverSOS2QuadConfig, container, ::Type{C}, x_var, x_bounds, meta) Allocate SOS2 containers (λ, link/norm/sos constraints, expressions, approximation) plus, when `config.pwmcc_segments > 0`, the full set of -PWMCC containers under `meta * "_pwmcc"`. Loop `(name, t)` calling the -scalar build per cell and writing all refs. - -Returns the registered `QuadraticExpression` container. +PWMCC containers under `meta * "_pwmcc"`. Loop `(name, t)`. """ function add_quadratic_approx!( config::SolverSOS2QuadConfig, @@ -132,9 +117,9 @@ function add_quadratic_approx!( name_axis = axes(x_var, 1) time_axis = axes(x_var, 2) n_points = config.depth + 1 - IS.@assert_op length(name_axis) == length(x_bounds) + @assert length(name_axis) == length(x_bounds) for b in x_bounds - IS.@assert_op b.max > b.min + @assert b.max > b.min end model = get_jump_model(container) @@ -163,48 +148,9 @@ function add_quadratic_approx!( use_pwmcc = config.pwmcc_segments > 0 K = config.pwmcc_segments - local pw_delta_target, pw_vd_target, pw_selector_target, pw_linking_target, - pw_interval_lb_target, pw_interval_ub_target, - pw_chord_target, pw_tangent_l_target, pw_tangent_r_target - if use_pwmcc - pwm_meta = meta * "_pwmcc" - pw_delta_target = add_variable_container!( - container, PiecewiseMcCormickBinary, C, name_axis, 1:K, time_axis; - meta = pwm_meta, - ) - pw_vd_target = add_variable_container!( - container, PiecewiseMcCormickDisaggregated, C, name_axis, 1:K, time_axis; - meta = pwm_meta, - ) - pw_selector_target = add_constraints_container!( - container, PiecewiseMcCormickSelectorSum, C, name_axis, time_axis; - meta = pwm_meta, - ) - pw_linking_target = add_constraints_container!( - container, PiecewiseMcCormickLinking, C, name_axis, time_axis; - meta = pwm_meta, - ) - pw_interval_lb_target = add_constraints_container!( - container, PiecewiseMcCormickIntervalLB, C, name_axis, 1:K, time_axis; - meta = pwm_meta, - ) - pw_interval_ub_target = add_constraints_container!( - container, PiecewiseMcCormickIntervalUB, C, name_axis, 1:K, time_axis; - meta = pwm_meta, - ) - pw_chord_target = add_constraints_container!( - container, PiecewiseMcCormickChordUB, C, name_axis, time_axis; - meta = pwm_meta, - ) - pw_tangent_l_target = add_constraints_container!( - container, PiecewiseMcCormickTangentLBL, C, name_axis, time_axis; - meta = pwm_meta, - ) - pw_tangent_r_target = add_constraints_container!( - container, PiecewiseMcCormickTangentLBR, C, name_axis, time_axis; - meta = pwm_meta, - ) - end + pwmcc_targets = use_pwmcc ? + _alloc_pwmcc_targets!(container, C, name_axis, time_axis, K, meta * "_pwmcc") : + nothing for (i, name) in enumerate(name_axis) xmn, xmx = x_bounds[i].min, x_bounds[i].max @@ -219,168 +165,10 @@ function add_quadratic_approx!( link_expr_target[name, t] = r.link_expression norm_expr_target[name, t] = r.norm_expression approx_target[name, t] = r.approximation - if use_pwmcc - pw = r.pwmcc - for k in 1:K - pw_delta_target[name, k, t] = pw.delta_var[k] - pw_vd_target[name, k, t] = pw.vd_var[k] - pw_interval_lb_target[name, k, t] = pw.interval_lb_constraints[k] - pw_interval_ub_target[name, k, t] = pw.interval_ub_constraints[k] - end - pw_selector_target[name, t] = pw.selector_constraint - pw_linking_target[name, t] = pw.linking_constraint - pw_chord_target[name, t] = pw.chord_ub_constraint - pw_tangent_l_target[name, t] = pw.tangent_lb_l_constraint - pw_tangent_r_target[name, t] = pw.tangent_lb_r_constraint + _write_pwmcc_cell!(pwmcc_targets, name, t, r.pwmcc, K) end end end return approx_target end - -# --- Legacy result + vectorized build + register (kept until callers -# migrate; removed in sweep) --- - -""" -Pure-JuMP result of legacy vectorized `build_quadratic_approx(::SolverSOS2QuadConfig, ...)`. -""" -struct SOS2QuadResult{ - A <: JuMP.Containers.DenseAxisArray{JuMP.AffExpr, 2}, - L <: JuMP.Containers.DenseAxisArray{JuMP.VariableRef, 3}, - LC <: JuMP.Containers.DenseAxisArray, - NC <: JuMP.Containers.DenseAxisArray, - SC <: JuMP.Containers.DenseAxisArray, - LE <: JuMP.Containers.DenseAxisArray{JuMP.AffExpr, 2}, - NE <: JuMP.Containers.DenseAxisArray{JuMP.AffExpr, 2}, - PWMCC <: Union{Nothing, PWMCCResult}, -} <: QuadraticApproxResult - approximation::A - lambda::L - link_constraints::LC - norm_constraints::NC - sos_constraints::SC - link_expressions::LE - norm_expressions::NE - pwmcc::PWMCC -end - -function build_quadratic_approx( - config::SolverSOS2QuadConfig, - model::JuMP.Model, - x, - bounds::Vector{MinMax}, -) - name_axis = axes(x, 1) - time_axis = axes(x, 2) - IS.@assert_op length(name_axis) == length(bounds) - for b in bounds - IS.@assert_op b.max > b.min - end - n_points = config.depth + 1 - x_bkpts, x_sq_bkpts = _get_breakpoints_for_pwl_function( - 0.0, 1.0, _square; num_segments = config.depth, - ) - - lx = JuMP.Containers.DenseAxisArray([b.max - b.min for b in bounds], name_axis) - x_min = JuMP.Containers.DenseAxisArray([b.min for b in bounds], name_axis) - - lambda = JuMP.@variable( - model, - [name = name_axis, i = 1:n_points, t = time_axis], - lower_bound = 0.0, - upper_bound = 1.0, - base_name = "QuadraticVariable", - ) - link_expr = JuMP.@expression( - model, - [name = name_axis, t = time_axis], - sum(x_bkpts[i] * lambda[name, i, t] for i in 1:n_points) - ) - link_cons = JuMP.@constraint( - model, - [name = name_axis, t = time_axis], - (x[name, t] - x_min[name]) / lx[name] == link_expr[name, t] - ) - norm_expr = JuMP.@expression( - model, - [name = name_axis, t = time_axis], - sum(lambda[name, i, t] for i in 1:n_points) - ) - norm_cons = JuMP.@constraint( - model, - [name = name_axis, t = time_axis], - norm_expr[name, t] == 1.0 - ) - sos_cons = JuMP.@constraint( - model, - [name = name_axis, t = time_axis], - [lambda[name, i, t] for i in 1:n_points] in MOI.SOS2(collect(1:n_points)) - ) - approximation = JuMP.@expression( - model, - [name = name_axis, t = time_axis], - lx[name] * lx[name] * - sum(x_sq_bkpts[i] * lambda[name, i, t] for i in 1:n_points) + - 2.0 * x_min[name] * x[name, t] - x_min[name] * x_min[name] - ) - - pwmcc = if config.pwmcc_segments > 0 - build_pwmcc_concave_cuts(model, x, approximation, bounds, config.pwmcc_segments) - else - nothing - end - - return SOS2QuadResult( - approximation, lambda, link_cons, norm_cons, sos_cons, link_expr, norm_expr, pwmcc, - ) -end - -function register_in_container!( - container::OptimizationContainer, - ::Type{C}, - result::SOS2QuadResult, - meta::String, -) where {C <: IS.InfrastructureSystemsComponent} - name_axis = axes(result.approximation, 1) - time_axis = axes(result.approximation, 2) - n_points_axis = axes(result.lambda, 2) - - lambda_target = add_variable_container!( - container, QuadraticVariable, C, name_axis, n_points_axis, time_axis; meta, - ) - lambda_target.data .= result.lambda.data - - link_cons_target = add_constraints_container!( - container, SOS2LinkingConstraint, C, name_axis, time_axis; meta, - ) - link_cons_target.data .= result.link_constraints.data - - norm_cons_target = add_constraints_container!( - container, SOS2NormConstraint, C, name_axis, time_axis; meta, - ) - norm_cons_target.data .= result.norm_constraints.data - - sos_cons_target = add_constraints_container!( - container, SolverSOS2Constraint, C, name_axis, time_axis; meta, - ) - sos_cons_target.data .= result.sos_constraints.data - - link_expr_target = add_expression_container!( - container, SOS2LinkingExpression, C, name_axis, time_axis; meta, - ) - link_expr_target.data .= result.link_expressions.data - - norm_expr_target = add_expression_container!( - container, SOS2NormExpression, C, name_axis, time_axis; meta, - ) - norm_expr_target.data .= result.norm_expressions.data - - result_target = add_expression_container!( - container, QuadraticExpression, C, name_axis, time_axis; meta, - ) - result_target.data .= result.approximation.data - - register_pwmcc!(container, C, result.pwmcc, meta * "_pwmcc") - return -end diff --git a/test/InfrastructureOptimizationModelsTests.jl b/test/InfrastructureOptimizationModelsTests.jl index ad3cfac5..7b761aa9 100644 --- a/test/InfrastructureOptimizationModelsTests.jl +++ b/test/InfrastructureOptimizationModelsTests.jl @@ -134,9 +134,7 @@ function run_tests() include(joinpath(TEST_DIR, "test_pwl_methods.jl")) # --- approximations/ subfolder --- - # Pure-JuMP layer (exercises build_* directly, no container). - include(joinpath(TEST_DIR, "test_pure_jump_approximations.jl")) - # IOM-wrapper regression tests (exercise _add_*_approx! end-to-end). + # IOM-wrapper regression tests (exercise add_*_approx! end-to-end). include(joinpath(TEST_DIR, "test_quadratic_approximations.jl")) include(joinpath(TEST_DIR, "test_bilinear_approximations.jl")) include(joinpath(TEST_DIR, "test_hybs_approximations.jl")) diff --git a/test/test_bilinear_approximations.jl b/test/test_bilinear_approximations.jl index 6f36a84c..88081904 100644 --- a/test/test_bilinear_approximations.jl +++ b/test/test_bilinear_approximations.jl @@ -16,8 +16,6 @@ const BILINEAR_META = "BilinearTest" IOM.Bin2Config(IOM.SolverSOS2QuadConfig(4, 0), add_mc), setup.container, MockThermalGen, - ["dev1"], - 1:1, setup.x_var_container, setup.y_var_container, [(min = 0.0, max = 4.0)], @@ -57,8 +55,6 @@ const BILINEAR_META = "BilinearTest" IOM.Bin2Config(IOM.SolverSOS2QuadConfig(8, 0)), setup.container, MockThermalGen, - ["dev1"], - 1:1, setup.x_var_container, setup.y_var_container, [(min = 0.0, max = 4.0)], @@ -90,8 +86,6 @@ const BILINEAR_META = "BilinearTest" IOM.Bin2Config(IOM.SolverSOS2QuadConfig(8, 0)), setup2.container, MockThermalGen, - ["dev1"], - 1:1, setup2.x_var_container, setup2.y_var_container, [(min = 0.0, max = 4.0)], @@ -128,8 +122,6 @@ const BILINEAR_META = "BilinearTest" IOM.Bin2Config(IOM.SolverSOS2QuadConfig(8, 0)), setup.container, MockThermalGen, - ["dev1"], - 1:1, setup.x_var_container, setup.y_var_container, [(min = 0.0, max = 4.0)], @@ -169,8 +161,6 @@ const BILINEAR_META = "BilinearTest" IOM.Bin2Config(IOM.SolverSOS2QuadConfig(8, 0)), setup.container, MockThermalGen, - ["dev1"], - 1:1, setup.x_var_container, setup.y_var_container, [(min = 0.0, max = 4.0)], @@ -210,8 +200,6 @@ const BILINEAR_META = "BilinearTest" IOM.Bin2Config(IOM.SolverSOS2QuadConfig(num_segments, 0)), setup.container, MockThermalGen, - ["dev1"], - 1:1, setup.x_var_container, setup.y_var_container, [(min = 0.0, max = 4.0)], @@ -250,8 +238,6 @@ const BILINEAR_META = "BilinearTest" IOM.Bin2Config(IOM.ManualSOS2QuadConfig(8, 0)), setup.container, MockThermalGen, - ["dev1"], - 1:1, setup.x_var_container, setup.y_var_container, [(min = 0.0, max = 4.0)], @@ -286,8 +272,6 @@ const BILINEAR_META = "BilinearTest" IOM.Bin2Config(IOM.ManualSOS2QuadConfig(8, 0)), setup.container, MockThermalGen, - ["dev1"], - 1:1, setup.x_var_container, setup.y_var_container, [(min = 0.0, max = 4.0)], @@ -325,8 +309,6 @@ const BILINEAR_META = "BilinearTest" IOM.Bin2Config(IOM.ManualSOS2QuadConfig(8, 0)), setup.container, MockThermalGen, - ["dev1"], - 1:1, setup.x_var_container, setup.y_var_container, [(min = 0.0, max = 4.0)], @@ -362,8 +344,6 @@ const BILINEAR_META = "BilinearTest" IOM.Bin2Config(IOM.ManualSOS2QuadConfig(num_segments, 0)), setup.container, MockThermalGen, - ["dev1"], - 1:1, setup.x_var_container, setup.y_var_container, [(min = 0.0, max = 4.0)], @@ -402,8 +382,6 @@ const BILINEAR_META = "BilinearTest" IOM.Bin2Config(IOM.SawtoothQuadConfig(3)), setup.container, MockThermalGen, - ["dev1"], - 1:1, setup.x_var_container, setup.y_var_container, [(min = 0.0, max = 4.0)], @@ -438,8 +416,6 @@ const BILINEAR_META = "BilinearTest" IOM.Bin2Config(IOM.SawtoothQuadConfig(3)), setup.container, MockThermalGen, - ["dev1"], - 1:1, setup.x_var_container, setup.y_var_container, [(min = 0.0, max = 4.0)], @@ -477,8 +453,6 @@ const BILINEAR_META = "BilinearTest" IOM.Bin2Config(IOM.SawtoothQuadConfig(3)), setup.container, MockThermalGen, - ["dev1"], - 1:1, setup.x_var_container, setup.y_var_container, [(min = 0.0, max = 4.0)], @@ -514,8 +488,6 @@ const BILINEAR_META = "BilinearTest" IOM.Bin2Config(IOM.SawtoothQuadConfig(depth)), setup.container, MockThermalGen, - ["dev1"], - 1:1, setup.x_var_container, setup.y_var_container, [(min = 0.0, max = 4.0)], diff --git a/test/test_hybs_approximations.jl b/test/test_hybs_approximations.jl index d15bf8d8..aee3aa25 100644 --- a/test/test_hybs_approximations.jl +++ b/test/test_hybs_approximations.jl @@ -11,8 +11,6 @@ const HYBS_BILINEAR_META = "BilinearTest" IOM.EpigraphQuadConfig(4), setup.container, MockThermalGen, - ["dev1"], - 1:1, setup.var_container, [(min = 0.0, max = 1.0)], HYBS_META, @@ -44,8 +42,6 @@ const HYBS_BILINEAR_META = "BilinearTest" IOM.EpigraphQuadConfig(4), setup.container, MockThermalGen, - ["dev1"], - 1:1, setup.var_container, [(min = 0.0, max = 2.0)], HYBS_META, @@ -80,8 +76,6 @@ const HYBS_BILINEAR_META = "BilinearTest" IOM.EpigraphQuadConfig(depth), setup.container, MockThermalGen, - ["dev1"], - 1:1, setup.var_container, [(min = 0.0, max = 1.0)], HYBS_META, @@ -122,8 +116,6 @@ end IOM.HybSConfig(IOM.SawtoothQuadConfig(2), 2), setup.container, MockThermalGen, - ["dev1"], - 1:1, setup.x_var_container, setup.y_var_container, [(min = 0.0, max = 1.0)], @@ -163,8 +155,6 @@ end IOM.HybSConfig(IOM.SawtoothQuadConfig(3), 3), setup.container, MockThermalGen, - ["dev1"], - 1:1, setup.x_var_container, setup.y_var_container, [(min = 0.0, max = 4.0)], @@ -201,8 +191,6 @@ end IOM.HybSConfig(IOM.SawtoothQuadConfig(3), 3), setup.container, MockThermalGen, - ["dev1"], - 1:1, setup.x_var_container, setup.y_var_container, [(min = 0.0, max = 4.0)], @@ -239,8 +227,6 @@ end IOM.HybSConfig(IOM.SawtoothQuadConfig(depth), depth), setup.container, MockThermalGen, - ["dev1"], - 1:1, setup.x_var_container, setup.y_var_container, [(min = 0.0, max = 1.0)], @@ -280,8 +266,6 @@ end IOM.HybSConfig(IOM.SawtoothQuadConfig(3), 3), setup.container, MockThermalGen, - ["dev1"], - 1:1, setup.x_var_container, setup.y_var_container, [(min = 2.0, max = 5.0)], @@ -321,8 +305,6 @@ end IOM.HybSConfig(IOM.SawtoothQuadConfig(2), 2), setup.container, MockThermalGen, - ["dev1"], - 1:1, setup.x_var_container, setup.y_var_container, [(min = 0.0, max = 4.0)], @@ -355,8 +337,6 @@ end IOM.HybSConfig(IOM.SawtoothQuadConfig(depth), depth), setup_h.container, MockThermalGen, - ["dev1"], - 1:1, setup_h.x_var_container, setup_h.y_var_container, [(min = 0.0, max = 1.0)], @@ -372,8 +352,6 @@ end IOM.Bin2Config(IOM.SawtoothQuadConfig(depth)), setup_b.container, MockThermalGen, - ["dev1"], - 1:1, setup_b.x_var_container, setup_b.y_var_container, [(min = 0.0, max = 1.0)], diff --git a/test/test_nmdt_approximations.jl b/test/test_nmdt_approximations.jl index a6c96050..05f233e6 100644 --- a/test/test_nmdt_approximations.jl +++ b/test/test_nmdt_approximations.jl @@ -14,7 +14,7 @@ const NMDT_BILINEAR_META = "NMDTBilinearTest" IOM.add_quadratic_approx!( IOM.DNMDTQuadConfig(4, 0), - setup.container, MockThermalGen, names, ts, + setup.container, MockThermalGen, setup.var_container, [(min = 0.0, max = 1.0)], DNMDT_META, ) @@ -48,7 +48,7 @@ const NMDT_BILINEAR_META = "NMDTBilinearTest" IOM.add_quadratic_approx!( IOM.DNMDTQuadConfig(3, 0), - setup.container, MockThermalGen, ["gen1"], 1:1, + setup.container, MockThermalGen, setup.var_container, [(min = 0.0, max = 1.0)], DNMDT_META, ) expr = IOM.get_expression( @@ -79,7 +79,7 @@ const NMDT_BILINEAR_META = "NMDTBilinearTest" IOM.add_quadratic_approx!( IOM.DNMDTQuadConfig(2 * L, 0), - setup.container, MockThermalGen, ["gen1"], 1:1, + setup.container, MockThermalGen, setup.var_container, [(min = 0.0, max = 1.0)], DNMDT_META, ) expr = IOM.get_expression( @@ -111,7 +111,7 @@ end IOM.add_quadratic_approx!( (tighten ? IOM.DNMDTQuadConfig(2) : IOM.DNMDTQuadConfig(2, 0)), - setup.container, MockThermalGen, ["gen1"], 1:1, + setup.container, MockThermalGen, setup.var_container, [(min = 0.0, max = 1.0)], DNMDT_META, ) expr = IOM.get_expression( @@ -148,7 +148,7 @@ end IOM.add_quadratic_approx!( IOM.DNMDTQuadConfig(L), - setup.container, MockThermalGen, ["gen1"], 1:1, + setup.container, MockThermalGen, setup.var_container, [(min = 0.0, max = 1.0)], DNMDT_META, ) expr = IOM.get_expression( @@ -182,7 +182,7 @@ end IOM.add_bilinear_approx!( IOM.DNMDTBilinearConfig(2), - setup.container, MockThermalGen, ["dev1"], 1:1, + setup.container, MockThermalGen, setup.x_var_container, setup.y_var_container, [(min = 0.0, max = 1.0)], [(min = 0.0, max = 1.0)], DNMDT_META, ) @@ -216,7 +216,7 @@ end IOM.add_bilinear_approx!( IOM.DNMDTBilinearConfig(2 * L), - setup.container, MockThermalGen, ["dev1"], 1:1, + setup.container, MockThermalGen, setup.x_var_container, setup.y_var_container, [(min = 0.0, max = 1.0)], [(min = 0.0, max = 1.0)], DNMDT_META, ) @@ -251,7 +251,7 @@ end IOM.add_bilinear_approx!( IOM.DNMDTBilinearConfig(8), - setup.container, MockThermalGen, ["dev1"], 1:1, + setup.container, MockThermalGen, setup.x_var_container, setup.y_var_container, [(min = x_min, max = x_max)], [(min = y_min, max = y_max)], DNMDT_META, ) @@ -277,7 +277,7 @@ end # setup = _setup_bilinear_test(["dev1"], 1:1) # IOM.add_bilinear_approx!( - # setup.container, MockThermalGen, ["dev1"], 1:1, + # setup.container, MockThermalGen, # setup.x_var_container, setup.y_var_container, # 0.0, 1.0, 0.0, 1.0, 2, DNMDT_META; # add_mccormick = false, @@ -295,7 +295,7 @@ end IOM.add_bilinear_approx!( IOM.DNMDTBilinearConfig(3), - setup.container, MockThermalGen, ["dev1"], 1:1, + setup.container, MockThermalGen, setup.y_var_container, setup.x_var_container, [(min = 0.0, max = 4.0)], [(min = 0.0, max = 4.0)], DNMDT_META, ) @@ -323,7 +323,7 @@ end IOM.add_bilinear_approx!( IOM.DNMDTBilinearConfig(depth), - setup_d.container, MockThermalGen, ["dev1"], 1:1, + setup_d.container, MockThermalGen, setup_d.x_var_container, setup_d.y_var_container, [(min = 0.0, max = 1.0)], [(min = 0.0, max = 1.0)], DNMDT_META, ) @@ -345,7 +345,7 @@ end IOM.add_bilinear_approx!( IOM.HybSConfig(IOM.SawtoothQuadConfig(depth), depth), - setup_h.container, MockThermalGen, ["dev1"], 1:1, + setup_h.container, MockThermalGen, setup_h.x_var_container, setup_h.y_var_container, [(min = 0.0, max = 1.0)], [(min = 0.0, max = 1.0)], DNMDT_HYBS_META, ) @@ -382,7 +382,7 @@ end IOM.add_quadratic_approx!( IOM.NMDTQuadConfig(4, 0), - setup.container, MockThermalGen, names, ts, + setup.container, MockThermalGen, setup.var_container, [(min = 0.0, max = 1.0)], NMDT_META, ) @@ -416,7 +416,7 @@ end IOM.add_quadratic_approx!( IOM.NMDTQuadConfig(3, 0), - setup.container, MockThermalGen, ["gen1"], 1:1, + setup.container, MockThermalGen, setup.var_container, [(min = 0.0, max = 1.0)], NMDT_META, ) expr = IOM.get_expression( @@ -449,7 +449,7 @@ end IOM.add_quadratic_approx!( IOM.NMDTQuadConfig(L, 0), - setup.container, MockThermalGen, ["gen1"], 1:1, + setup.container, MockThermalGen, setup.var_container, [(min = 0.0, max = 1.0)], NMDT_META, ) expr = IOM.get_expression( @@ -479,7 +479,7 @@ end IOM.add_quadratic_approx!( (tighten ? IOM.NMDTQuadConfig(2) : IOM.NMDTQuadConfig(2, 0)), - setup.container, MockThermalGen, ["gen1"], 1:1, + setup.container, MockThermalGen, setup.var_container, [(min = 0.0, max = 1.0)], NMDT_META, ) expr = IOM.get_expression( @@ -525,7 +525,7 @@ end IOM.add_quadratic_approx!( config_fn(L), - setup.container, MockThermalGen, ["gen1"], 1:1, + setup.container, MockThermalGen, setup.var_container, [(min = 0.0, max = 1.0)], NMDT_META, ) expr = IOM.get_expression( @@ -560,7 +560,7 @@ end setup_n = _setup_qa_test(["gen1"], 1:1) IOM.add_quadratic_approx!( IOM.NMDTQuadConfig(depth, 0), - setup_n.container, MockThermalGen, ["gen1"], 1:1, + setup_n.container, MockThermalGen, setup_n.var_container, [(min = 0.0, max = 1.0)], NMDT_META, ) n_bin_nmdt = count(JuMP.is_binary, JuMP.all_variables(setup_n.jump_model)) @@ -568,7 +568,7 @@ end setup_d = _setup_qa_test(["gen1"], 1:1) IOM.add_quadratic_approx!( IOM.DNMDTQuadConfig(depth, 0), - setup_d.container, MockThermalGen, ["gen1"], 1:1, + setup_d.container, MockThermalGen, setup_d.var_container, [(min = 0.0, max = 1.0)], DNMDT_META, ) n_bin_dnmdt = count(JuMP.is_binary, JuMP.all_variables(setup_d.jump_model)) @@ -594,7 +594,7 @@ end IOM.add_bilinear_approx!( IOM.NMDTBilinearConfig(3), - setup.container, MockThermalGen, ["dev1"], 1:1, + setup.container, MockThermalGen, setup.x_var_container, setup.y_var_container, [(min = 0.0, max = 1.0)], [(min = 0.0, max = 1.0)], NMDT_BILINEAR_META, ) @@ -629,7 +629,7 @@ end IOM.add_bilinear_approx!( IOM.NMDTBilinearConfig(L), - setup.container, MockThermalGen, ["dev1"], 1:1, + setup.container, MockThermalGen, setup.x_var_container, setup.y_var_container, [(min = 0.0, max = 1.0)], [(min = 0.0, max = 1.0)], NMDT_BILINEAR_META, @@ -660,7 +660,7 @@ end IOM.add_bilinear_approx!( IOM.NMDTBilinearConfig(4), - setup.container, MockThermalGen, ["dev1"], 1:1, + setup.container, MockThermalGen, setup.x_var_container, setup.y_var_container, [(min = 0.0, max = 1.0)], [(min = 0.0, max = 1.0)], NMDT_BILINEAR_META, ) @@ -686,7 +686,7 @@ end setup_n = _setup_bilinear_test(["dev1"], 1:1) IOM.add_bilinear_approx!( IOM.NMDTBilinearConfig(L), - setup_n.container, MockThermalGen, ["dev1"], 1:1, + setup_n.container, MockThermalGen, setup_n.x_var_container, setup_n.y_var_container, [(min = 0.0, max = 1.0)], [(min = 0.0, max = 1.0)], NMDT_BILINEAR_META, ) @@ -695,7 +695,7 @@ end setup_d = _setup_bilinear_test(["dev1"], 1:1) IOM.add_bilinear_approx!( IOM.DNMDTBilinearConfig(L), - setup_d.container, MockThermalGen, ["dev1"], 1:1, + setup_d.container, MockThermalGen, setup_d.x_var_container, setup_d.y_var_container, [(min = 0.0, max = 1.0)], [(min = 0.0, max = 1.0)], DNMDT_META, ) @@ -716,7 +716,7 @@ end IOM.add_bilinear_approx!( IOM.NMDTBilinearConfig(L), - setup_n.container, MockThermalGen, ["dev1"], 1:1, + setup_n.container, MockThermalGen, setup_n.x_var_container, setup_n.y_var_container, [(min = 0.0, max = 1.0)], [(min = 0.0, max = 1.0)], NMDT_BILINEAR_META, ) @@ -737,7 +737,7 @@ end IOM.add_bilinear_approx!( IOM.DNMDTBilinearConfig(L), - setup_d.container, MockThermalGen, ["dev1"], 1:1, + setup_d.container, MockThermalGen, setup_d.x_var_container, setup_d.y_var_container, [(min = 0.0, max = 1.0)], [(min = 0.0, max = 1.0)], DNMDT_META, ) diff --git a/test/test_pure_jump_approximations.jl b/test/test_pure_jump_approximations.jl deleted file mode 100644 index 45696f36..00000000 --- a/test/test_pure_jump_approximations.jl +++ /dev/null @@ -1,200 +0,0 @@ -# Pure-JuMP tests for the approximation layer. -# -# These tests exercise `build_quadratic_approx` and `build_bilinear_approx` -# directly against a bare `JuMP.Model`. No OptimizationContainer is involved -# anywhere — these tests would pass if `OptimizationContainer` were removed -# from the package. -# -# The point of having this layer separately testable is that mathematical -# properties of an approximation method (lower-boundedness, exactness at -# breakpoints, McCormick envelope feasibility, etc.) can be checked without -# any of the IOM container scaffolding getting in the way. - -function _pure_jump_scalar_var(model::JuMP.Model, lb::Float64, ub::Float64; name::String) - # The DenseAxisArray is indexed by a single device named "dev1" and a single - # time step. `name` is only the JuMP base_name (for readability in logs). - x = JuMP.@variable(model, base_name = name, lower_bound = lb, upper_bound = ub) - return JuMP.Containers.DenseAxisArray(reshape([x], 1, 1), ["dev1"], 1:1) -end - -@testset "Pure-JuMP Approximations" begin - @testset "no_approx_quadratic returns exact x²" begin - model = JuMP.Model(HiGHS.Optimizer) - JuMP.set_silent(model) - x = _pure_jump_scalar_var(model, 0.0, 4.0; name = "x") - bounds = [(min = 0.0, max = 4.0)] - result = IOM.build_quadratic_approx(IOM.NoQuadApproxConfig(), model, x, bounds) - @test IOM.get_approximation(result) === result.approximation - # The expression should be x*x, a QuadExpr - expr = result.approximation["dev1", 1] - @test expr isa JuMP.QuadExpr - end - - @testset "solver_sos2 is exact at breakpoints" begin - # With depth=4 breakpoints are at {0, 1, 2, 3, 4}, so x² is exact at x=2. - model = JuMP.Model(HiGHS.Optimizer) - JuMP.set_silent(model) - x = _pure_jump_scalar_var(model, 0.0, 4.0; name = "x") - bounds = [(min = 0.0, max = 4.0)] - result = IOM.build_quadratic_approx( - IOM.SolverSOS2QuadConfig(4, 0), model, x, bounds, - ) - JuMP.fix(x["dev1", 1], 2.0; force = true) - JuMP.@objective(model, Min, result.approximation["dev1", 1]) - JuMP.optimize!(model) - @test JuMP.termination_status(model) == JuMP.OPTIMAL - @test JuMP.value(result.approximation["dev1", 1]) ≈ 4.0 atol = 1e-6 - end - - @testset "solver_sos2 minimizes x² − 4x correctly" begin - model = JuMP.Model(HiGHS.Optimizer) - JuMP.set_silent(model) - x = _pure_jump_scalar_var(model, 0.0, 4.0; name = "x") - bounds = [(min = 0.0, max = 4.0)] - result = IOM.build_quadratic_approx( - IOM.SolverSOS2QuadConfig(4, 0), model, x, bounds, - ) - JuMP.@objective(model, Min, result.approximation["dev1", 1] - 4.0 * x["dev1", 1]) - JuMP.optimize!(model) - @test JuMP.value(x["dev1", 1]) ≈ 2.0 atol = 1e-6 - @test JuMP.objective_value(model) ≈ -4.0 atol = 1e-6 - end - - @testset "epigraph lower-bounds x² uniformly" begin - # The epigraph relaxation is a pure-LP lower bound on x². - # Sample x at five interior points, minimize z = approximation, - # and verify z(x) ≤ x² + tiny tolerance. - model = JuMP.Model(HiGHS.Optimizer) - JuMP.set_silent(model) - x = _pure_jump_scalar_var(model, 0.0, 1.0; name = "x") - bounds = [(min = 0.0, max = 1.0)] - result = IOM.build_quadratic_approx(IOM.EpigraphQuadConfig(4), model, x, bounds) - for sample in [0.1, 0.3, 0.5, 0.7, 0.9] - JuMP.fix(x["dev1", 1], sample; force = true) - JuMP.@objective(model, Min, result.approximation["dev1", 1]) - JuMP.optimize!(model) - @test JuMP.termination_status(model) == JuMP.OPTIMAL - @test JuMP.value(result.approximation["dev1", 1]) <= sample^2 + 1e-8 - end - end - - @testset "epigraph quality improves monotonically with depth" begin - # Pure-JuMP version of the test that previously had to be done via the - # container layer. The error at x=0.35 should shrink as depth grows. - errors = Float64[] - for depth in 1:6 - model = JuMP.Model(HiGHS.Optimizer) - JuMP.set_silent(model) - x = _pure_jump_scalar_var(model, 0.0, 1.0; name = "x") - bounds = [(min = 0.0, max = 1.0)] - result = IOM.build_quadratic_approx( - IOM.EpigraphQuadConfig(depth), model, x, bounds, - ) - JuMP.fix(x["dev1", 1], 0.35; force = true) - JuMP.@objective(model, Min, result.approximation["dev1", 1]) - JuMP.optimize!(model) - push!(errors, abs(JuMP.objective_value(model) - 0.35^2)) - end - for i in 2:length(errors) - @test errors[i] <= errors[i - 1] + 1e-10 - end - end - - @testset "sawtooth is exact at breakpoints" begin - # With depth=3 breakpoints are at the dyadic rationals on [0,1]. - model = JuMP.Model(HiGHS.Optimizer) - JuMP.set_silent(model) - x = _pure_jump_scalar_var(model, 0.0, 1.0; name = "x") - bounds = [(min = 0.0, max = 1.0)] - result = IOM.build_quadratic_approx(IOM.SawtoothQuadConfig(3, 0), model, x, bounds) - # x = 0.5 is at a breakpoint, x² = 0.25 exactly. - JuMP.fix(x["dev1", 1], 0.5; force = true) - JuMP.@objective(model, Min, result.approximation["dev1", 1]) - JuMP.optimize!(model) - @test JuMP.termination_status(model) == JuMP.OPTIMAL - @test JuMP.value(result.approximation["dev1", 1]) ≈ 0.25 atol = 1e-6 - end - - @testset "NMDT discretizes xh correctly" begin - # Stand-alone test of the shared NMDT discretization step. - model = JuMP.Model(HiGHS.Optimizer) - JuMP.set_silent(model) - x = _pure_jump_scalar_var(model, 0.0, 4.0; name = "x") - bounds = [(min = 0.0, max = 4.0)] - disc = IOM.build_discretization(model, x, bounds, 3) - # At x=2 (xh=0.5), expect β_1 = 1, β_2 = 0, β_3 = 0, δ = 0. - JuMP.fix(x["dev1", 1], 2.0; force = true) - JuMP.@objective(model, Min, disc.delta_var["dev1", 1]) - JuMP.optimize!(model) - @test JuMP.termination_status(model) == JuMP.OPTIMAL - # The discretization must reproduce xh = 0.5: - b1 = JuMP.value(disc.beta_var["dev1", 1, 1]) - b2 = JuMP.value(disc.beta_var["dev1", 2, 1]) - b3 = JuMP.value(disc.beta_var["dev1", 3, 1]) - d = JuMP.value(disc.delta_var["dev1", 1]) - @test 0.5 * b1 + 0.25 * b2 + 0.125 * b3 + d ≈ 0.5 atol = 1e-6 - end - - @testset "no_approx_bilinear returns exact x·y" begin - model = JuMP.Model(HiGHS.Optimizer) - JuMP.set_silent(model) - x = _pure_jump_scalar_var(model, 0.0, 2.0; name = "x") - y = _pure_jump_scalar_var(model, 0.0, 2.0; name = "y") - x_bounds = [(min = 0.0, max = 2.0)] - y_bounds = [(min = 0.0, max = 2.0)] - result = IOM.build_bilinear_approx( - IOM.NoBilinearApproxConfig(), model, x, y, x_bounds, y_bounds, - ) - expr = result.approximation["dev1", 1] - @test expr isa JuMP.QuadExpr - end - - @testset "McCormick envelope bracketing on x·y" begin - # Standard McCormick envelope on [0,1]² brackets the true x·y at corners. - model = JuMP.Model(HiGHS.Optimizer) - JuMP.set_silent(model) - x = _pure_jump_scalar_var(model, 0.0, 1.0; name = "x") - y = _pure_jump_scalar_var(model, 0.0, 1.0; name = "y") - z = JuMP.@variable(model, base_name = "z") - z_arr = JuMP.Containers.DenseAxisArray(reshape([z], 1, 1), ["dev1"], 1:1) - IOM.build_mccormick_envelope( - model, x, y, z_arr, - [(min = 0.0, max = 1.0)], [(min = 0.0, max = 1.0)], - ) - # At x=y=1, the only feasible z is 1. - JuMP.fix(x["dev1", 1], 1.0; force = true) - JuMP.fix(y["dev1", 1], 1.0; force = true) - JuMP.@objective(model, Min, z) - JuMP.optimize!(model) - @test JuMP.value(z) ≈ 1.0 atol = 1e-6 - end - - @testset "Bin2 z = ½(p² − x² − y²) identity" begin - # When the underlying quadratic method is exact at the queried point, - # Bin2 reproduces x·y exactly. - model = JuMP.Model(HiGHS.Optimizer) - JuMP.set_silent(model) - x = _pure_jump_scalar_var(model, 0.0, 1.0; name = "x") - y = _pure_jump_scalar_var(model, 0.0, 1.0; name = "y") - x_bounds = [(min = 0.0, max = 1.0)] - y_bounds = [(min = 0.0, max = 1.0)] - # depth=2 places breakpoints at {0, 0.5, 1.0}; pick a point at the corners. - result = IOM.build_bilinear_approx( - IOM.Bin2Config(IOM.SolverSOS2QuadConfig(2, 0), false), - model, x, y, x_bounds, y_bounds, - ) - JuMP.fix(x["dev1", 1], 1.0; force = true) - JuMP.fix(y["dev1", 1], 1.0; force = true) - JuMP.@objective(model, Min, result.approximation["dev1", 1]) - JuMP.optimize!(model) - @test JuMP.value(result.approximation["dev1", 1]) ≈ 1.0 atol = 1e-6 - end - - @testset "get_approximation returns the approximation field" begin - model = JuMP.Model() - x = _pure_jump_scalar_var(model, 0.0, 1.0; name = "x") - bounds = [(min = 0.0, max = 1.0)] - result = IOM.build_quadratic_approx(IOM.EpigraphQuadConfig(2), model, x, bounds) - @test IOM.get_approximation(result) === result.approximation - end -end diff --git a/test/test_quadratic_approximations.jl b/test/test_quadratic_approximations.jl index 03488835..b0e68ee5 100644 --- a/test/test_quadratic_approximations.jl +++ b/test/test_quadratic_approximations.jl @@ -15,8 +15,6 @@ const TEST_META = "TestVar" IOM.SolverSOS2QuadConfig(4, 0), setup.container, MockThermalGen, - ["dev1"], - 1:1, setup.var_container, [(min = 0.0, max = 4.0)], TEST_META, @@ -51,8 +49,6 @@ const TEST_META = "TestVar" IOM.SolverSOS2QuadConfig(4, 0), setup.container, MockThermalGen, - ["dev1"], - 1:1, setup.var_container, [(min = 0.0, max = 4.0)], TEST_META, @@ -90,8 +86,6 @@ const TEST_META = "TestVar" IOM.SolverSOS2QuadConfig(num_segments, 0), setup.container, MockThermalGen, - ["dev1"], - 1:1, setup.var_container, [(min = 0.0, max = 6.0)], TEST_META, @@ -130,8 +124,6 @@ const TEST_META = "TestVar" IOM.ManualSOS2QuadConfig(4, 0), setup.container, MockThermalGen, - ["dev1"], - 1:1, setup.var_container, [(min = 0.0, max = 4.0)], TEST_META, @@ -165,8 +157,6 @@ const TEST_META = "TestVar" IOM.ManualSOS2QuadConfig(4, 0), setup.container, MockThermalGen, - ["dev1"], - 1:1, setup.var_container, [(min = 0.0, max = 4.0)], TEST_META, @@ -202,8 +192,6 @@ const TEST_META = "TestVar" IOM.SawtoothQuadConfig(2), setup.container, MockThermalGen, - ["dev1"], - 1:1, setup.var_container, [(min = 0.0, max = 4.0)], TEST_META, @@ -237,8 +225,6 @@ const TEST_META = "TestVar" IOM.SawtoothQuadConfig(2), setup.container, MockThermalGen, - ["dev1"], - 1:1, setup.var_container, [(min = 0.0, max = 4.0)], TEST_META, @@ -275,8 +261,6 @@ const TEST_META = "TestVar" IOM.SawtoothQuadConfig(depth), setup.container, MockThermalGen, - ["dev1"], - 1:1, setup.var_container, [(min = 0.0, max = 6.0)], TEST_META, @@ -317,8 +301,6 @@ const TEST_META = "TestVar" IOM.SolverSOS2QuadConfig(2^depth, 0), setup.container, MockThermalGen, - ["dev1"], - 1:1, setup.var_container, [(min = 0.0, max = 4.0)], TEST_META, @@ -328,8 +310,6 @@ const TEST_META = "TestVar" IOM.SawtoothQuadConfig(depth), setup.container, MockThermalGen, - ["dev1"], - 1:1, setup.var_container, [(min = 0.0, max = 4.0)], TEST_META, From 5ada5b3424a6d525f793e1234374b1277e72b9a9 Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Wed, 20 May 2026 17:51:20 -0400 Subject: [PATCH 9/9] Run JuliaFormatter on refactored approximations files Co-Authored-By: Claude Opus 4.7 --- src/approximations/bin2.jl | 41 +++++++++++++++++------ src/approximations/epigraph.jl | 4 +-- src/approximations/hybs.jl | 36 +++++++++++++++----- src/approximations/manual_sos2.jl | 6 ++-- src/approximations/nmdt_bilinear.jl | 40 +++++++++++++++++++--- src/approximations/nmdt_discretization.jl | 6 +++- src/approximations/nmdt_quadratic.jl | 32 +++++++++++++----- src/approximations/no_approx_quadratic.jl | 8 ++++- src/approximations/solver_sos2.jl | 6 ++-- 9 files changed, 138 insertions(+), 41 deletions(-) diff --git a/src/approximations/bin2.jl b/src/approximations/bin2.jl index 3e16a927..51c35642 100644 --- a/src/approximations/bin2.jl +++ b/src/approximations/bin2.jl @@ -49,13 +49,15 @@ function build_bilinear_approx( model, 0.5 * (psq.approximation - xsq.approximation - ysq.approximation), ) - mc = config.add_mccormick ? + mc = if config.add_mccormick build_reformulated_mccormick( - model, x, y, - psq.approximation, xsq.approximation, ysq.approximation, - x_min, x_max, y_min, y_max, - ) : + model, x, y, + psq.approximation, xsq.approximation, ysq.approximation, + x_min, x_max, y_min, y_max, + ) + else nothing + end return (; approximation, xsq, @@ -105,8 +107,22 @@ function add_bilinear_approx!( for i in eachindex(x_bounds) ] - xsq = add_quadratic_approx!(config.quad_config, container, C, x_var, x_bounds, meta * "_x") - ysq = add_quadratic_approx!(config.quad_config, container, C, y_var, y_bounds, meta * "_y") + xsq = add_quadratic_approx!( + config.quad_config, + container, + C, + x_var, + x_bounds, + meta * "_x", + ) + ysq = add_quadratic_approx!( + config.quad_config, + container, + C, + y_var, + y_bounds, + meta * "_y", + ) psq = add_quadratic_approx!( config.quad_config, container, C, p_target, p_bounds, meta * "_plus", ) @@ -178,11 +194,14 @@ function _bin2_assemble_and_mccormick!( approx_target = add_expression_container!( container, BilinearProductExpression, C, name_axis, time_axis; meta, ) - mc_target = add_mccormick ? + mc_target = if add_mccormick add_constraints_container!( - container, ReformulatedMcCormickConstraint, C, - name_axis, 1:4, time_axis; meta, - ) : nothing + container, ReformulatedMcCormickConstraint, C, + name_axis, 1:4, time_axis; meta, + ) + else + nothing + end for (i, name) in enumerate(name_axis) xmn, xmx = x_bounds[i].min, x_bounds[i].max diff --git a/src/approximations/epigraph.jl b/src/approximations/epigraph.jl index cdbc6ba1..57e79a7f 100644 --- a/src/approximations/epigraph.jl +++ b/src/approximations/epigraph.jl @@ -109,8 +109,8 @@ function build_quadratic_approx( tangent_levels = JuMP.@constraint( model, [j = 1:depth], z_var >= - scale_back_g_basis(x_min, delta, g_var, 1:j) - - delta^2 * 2.0^(-2j - 2), + scale_back_g_basis(x_min, delta, g_var, 1:j) - + delta^2 * 2.0^(-2j - 2), ) tangent_cons = JuMP.Containers.DenseAxisArray{typeof(tangent_zero)}( diff --git a/src/approximations/hybs.jl b/src/approximations/hybs.jl index 37bdd8e8..8b02c63e 100644 --- a/src/approximations/hybs.jl +++ b/src/approximations/hybs.jl @@ -95,10 +95,13 @@ function _build_hybs_scalar( approximation = JuMP.@expression(model, 1.0 * z_var) - mc = config.add_mccormick ? + mc = if config.add_mccormick build_mccormick_envelope( - model, x, y, z_var, x_min, x_max, y_min, y_max, - ) : nothing + model, x, y, z_var, x_min, x_max, y_min, y_max, + ) + else + nothing + end return (; approximation, @@ -132,8 +135,22 @@ function add_bilinear_approx!( y_bounds::Vector{MinMax}, meta::String, ) where {C <: IS.InfrastructureSystemsComponent} - xsq = add_quadratic_approx!(config.quad_config, container, C, x_var, x_bounds, meta * "_x") - ysq = add_quadratic_approx!(config.quad_config, container, C, y_var, y_bounds, meta * "_y") + xsq = add_quadratic_approx!( + config.quad_config, + container, + C, + x_var, + x_bounds, + meta * "_x", + ) + ysq = add_quadratic_approx!( + config.quad_config, + container, + C, + y_var, + y_bounds, + meta * "_y", + ) return _add_hybs_adapter!( container, C, config, x_var, y_var, xsq, ysq, x_bounds, y_bounds, meta, ) @@ -212,10 +229,13 @@ function _add_hybs_adapter!( approx_target = add_expression_container!( container, BilinearProductExpression, C, name_axis, time_axis; meta, ) - mc_target = config.add_mccormick ? + mc_target = if config.add_mccormick add_constraints_container!( - container, McCormickConstraint, C, name_axis, 1:4, time_axis; meta, - ) : nothing + container, McCormickConstraint, C, name_axis, 1:4, time_axis; meta, + ) + else + nothing + end for (i, name) in enumerate(name_axis) xmn, xmx = x_bounds[i].min, x_bounds[i].max diff --git a/src/approximations/manual_sos2.jl b/src/approximations/manual_sos2.jl index 2d309099..3ec589a5 100644 --- a/src/approximations/manual_sos2.jl +++ b/src/approximations/manual_sos2.jl @@ -179,9 +179,11 @@ function add_quadratic_approx!( use_pwmcc = config.pwmcc_segments > 0 K = config.pwmcc_segments - pwmcc_targets = use_pwmcc ? - _alloc_pwmcc_targets!(container, C, name_axis, time_axis, K, meta * "_pwmcc") : + pwmcc_targets = if use_pwmcc + _alloc_pwmcc_targets!(container, C, name_axis, time_axis, K, meta * "_pwmcc") + else nothing + end for (i, name) in enumerate(name_axis) xmn, xmx = x_bounds[i].min, x_bounds[i].max diff --git a/src/approximations/nmdt_bilinear.jl b/src/approximations/nmdt_bilinear.jl index 42b325cf..b11dacb9 100644 --- a/src/approximations/nmdt_bilinear.jl +++ b/src/approximations/nmdt_bilinear.jl @@ -180,7 +180,13 @@ function add_bilinear_approx!( ) _write_discretization_cell!(x_disc_targets, name, t, r.x_discretization, depth) yh_target[name, t] = r.yh_expression - _write_binary_continuous_product_cell!(bx_yh_targets, name, t, r.bx_yh_product, depth) + _write_binary_continuous_product_cell!( + bx_yh_targets, + name, + t, + r.bx_yh_product, + depth, + ) _write_residual_product_cell!(res_targets, name, t, r.residual_product) approx_target[name, t] = r.approximation end @@ -250,10 +256,34 @@ function add_bilinear_approx!( ) _write_discretization_cell!(x_disc_targets, name, t, r.x_discretization, depth) _write_discretization_cell!(y_disc_targets, name, t, r.y_discretization, depth) - _write_binary_continuous_product_cell!(bx_yh_targets, name, t, r.bx_yh_product, depth) - _write_binary_continuous_product_cell!(by_dx_targets, name, t, r.by_dx_product, depth) - _write_binary_continuous_product_cell!(by_xh_targets, name, t, r.by_xh_product, depth) - _write_binary_continuous_product_cell!(bx_dy_targets, name, t, r.bx_dy_product, depth) + _write_binary_continuous_product_cell!( + bx_yh_targets, + name, + t, + r.bx_yh_product, + depth, + ) + _write_binary_continuous_product_cell!( + by_dx_targets, + name, + t, + r.by_dx_product, + depth, + ) + _write_binary_continuous_product_cell!( + by_xh_targets, + name, + t, + r.by_xh_product, + depth, + ) + _write_binary_continuous_product_cell!( + bx_dy_targets, + name, + t, + r.bx_dy_product, + depth, + ) _write_residual_product_cell!(res_targets, name, t, r.residual_product) approx_target[name, t] = r.approximation end diff --git a/src/approximations/nmdt_discretization.jl b/src/approximations/nmdt_discretization.jl index d37f363e..977ca2ec 100644 --- a/src/approximations/nmdt_discretization.jl +++ b/src/approximations/nmdt_discretization.jl @@ -268,10 +268,14 @@ function _alloc_binary_continuous_product_targets!( container, McCormickUpperConstraint, C, name_axis, 1:depth, 1:2, time_axis; meta = mc_meta, ) - mc_lower_target = tighten ? nothing : add_constraints_container!( + mc_lower_target = if tighten + nothing + else + add_constraints_container!( container, McCormickUpperConstraint, C, name_axis, 1:depth, 1:2, time_axis; meta = mc_meta * "_lb", ) + end result_expr_target = add_expression_container!( container, NMDTBinaryContinuousProductExpression, C, name_axis, time_axis; meta, diff --git a/src/approximations/nmdt_quadratic.jl b/src/approximations/nmdt_quadratic.jl index 417cafd5..43f1ae6d 100644 --- a/src/approximations/nmdt_quadratic.jl +++ b/src/approximations/nmdt_quadratic.jl @@ -77,7 +77,8 @@ function build_quadratic_approx( ) tightening = if tighten epi = build_quadratic_approx( - EpigraphQuadConfig(config.epigraph_depth), model, disc.norm_expr, 0.0, 1.0, + EpigraphQuadConfig(config.epigraph_depth), model, disc.norm_expr, 0.0, + 1.0, ) tcon = JuMP.@constraint(model, approximation >= epi.approximation) (; epigraph = epi, constraint = tcon) @@ -134,7 +135,8 @@ function build_quadratic_approx( ) tightening = if tighten epi = build_quadratic_approx( - EpigraphQuadConfig(config.epigraph_depth), model, disc.norm_expr, 0.0, 1.0, + EpigraphQuadConfig(config.epigraph_depth), model, disc.norm_expr, 0.0, + 1.0, ) tcon = JuMP.@constraint(model, approximation >= epi.approximation) (; epigraph = epi, constraint = tcon) @@ -209,17 +211,26 @@ function add_quadratic_approx!( approx_target = add_expression_container!( container, QuadraticExpression, C, name_axis, time_axis; meta, ) - tighten_targets = tighten ? + tighten_targets = if tighten _alloc_nmdt_tightening_targets!( - container, C, name_axis, time_axis, config.epigraph_depth, meta, - ) : nothing + container, C, name_axis, time_axis, config.epigraph_depth, meta, + ) + else + nothing + end for (i, name) in enumerate(name_axis) xmn, xmx = x_bounds[i].min, x_bounds[i].max for t in time_axis r = build_quadratic_approx(config, model, x_var[name, t], xmn, xmx) _write_discretization_cell!(disc_targets, name, t, r.discretization, depth) - _write_binary_continuous_product_cell!(bx_targets, name, t, r.bx_xh_product, depth) + _write_binary_continuous_product_cell!( + bx_targets, + name, + t, + r.bx_xh_product, + depth, + ) _write_residual_product_cell!(res_targets, name, t, r.residual_product) approx_target[name, t] = r.approximation if tighten @@ -272,10 +283,13 @@ function add_quadratic_approx!( approx_target = add_expression_container!( container, QuadraticExpression, C, name_axis, time_axis; meta, ) - tighten_targets = tighten ? + tighten_targets = if tighten _alloc_nmdt_tightening_targets!( - container, C, name_axis, time_axis, config.epigraph_depth, meta, - ) : nothing + container, C, name_axis, time_axis, config.epigraph_depth, meta, + ) + else + nothing + end for (i, name) in enumerate(name_axis) xmn, xmx = x_bounds[i].min, x_bounds[i].max diff --git a/src/approximations/no_approx_quadratic.jl b/src/approximations/no_approx_quadratic.jl index 1dccba8b..bffbda37 100644 --- a/src/approximations/no_approx_quadratic.jl +++ b/src/approximations/no_approx_quadratic.jl @@ -45,7 +45,13 @@ function add_quadratic_approx!( for (i, name) in enumerate(name_axis) xmn, xmx = x_bounds[i].min, x_bounds[i].max for t in time_axis - r = build_quadratic_approx(NoQuadApproxConfig(), model, x_var[name, t], xmn, xmx) + r = build_quadratic_approx( + NoQuadApproxConfig(), + model, + x_var[name, t], + xmn, + xmx, + ) target[name, t] = r.approximation end end diff --git a/src/approximations/solver_sos2.jl b/src/approximations/solver_sos2.jl index 98e4c6c4..89e5886c 100644 --- a/src/approximations/solver_sos2.jl +++ b/src/approximations/solver_sos2.jl @@ -148,9 +148,11 @@ function add_quadratic_approx!( use_pwmcc = config.pwmcc_segments > 0 K = config.pwmcc_segments - pwmcc_targets = use_pwmcc ? - _alloc_pwmcc_targets!(container, C, name_axis, time_axis, K, meta * "_pwmcc") : + pwmcc_targets = if use_pwmcc + _alloc_pwmcc_targets!(container, C, name_axis, time_axis, K, meta * "_pwmcc") + else nothing + end for (i, name) in enumerate(name_axis) xmn, xmx = x_bounds[i].min, x_bounds[i].max