From bba98d0b2418f702cc3084481e20252f40682a2a Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Tue, 5 May 2026 15:49:00 -0400 Subject: [PATCH 1/9] claude port --- src/InfrastructureOptimizationModels.jl | 7 +++ src/core/standard_variables_expressions.jl | 16 ++++- src/objective_function/common.jl | 7 ++- src/objective_function/cost_term_helpers.jl | 61 ++++++++++++++++++-- src/objective_function/linear_curve.jl | 6 +- src/objective_function/piecewise_linear.jl | 4 +- src/objective_function/proportional.jl | 12 ++-- src/objective_function/quadratic_curve.jl | 16 ++--- src/objective_function/start_up_shut_down.jl | 8 +-- 9 files changed, 103 insertions(+), 34 deletions(-) diff --git a/src/InfrastructureOptimizationModels.jl b/src/InfrastructureOptimizationModels.jl index 1fa5f860..5eda1b82 100644 --- a/src/InfrastructureOptimizationModels.jl +++ b/src/InfrastructureOptimizationModels.jl @@ -443,6 +443,13 @@ export EmergencyUp export EmergencyDown export RawACE export ProductionCostExpression +export ConstituentCostExpression +export FuelCostExpression +export StartUpCostExpression +export ShutDownCostExpression +export FixedCostExpression +export VOMCostExpression +export CurtailmentCostExpression export FuelConsumptionExpression export ActivePowerRangeExpressionLB export ActivePowerRangeExpressionUB diff --git a/src/core/standard_variables_expressions.jl b/src/core/standard_variables_expressions.jl index 23aa9536..58cf8768 100644 --- a/src/core/standard_variables_expressions.jl +++ b/src/core/standard_variables_expressions.jl @@ -50,6 +50,13 @@ abstract type PostContingencyExpressions <: ExpressionType end # Concrete expression types used in IOM code struct ProductionCostExpression <: CostExpressions end +abstract type ConstituentCostExpression <: CostExpressions end +struct FuelCostExpression <: ConstituentCostExpression end +struct StartUpCostExpression <: ConstituentCostExpression end +struct ShutDownCostExpression <: ConstituentCostExpression end +struct FixedCostExpression <: ConstituentCostExpression end +struct VOMCostExpression <: ConstituentCostExpression end +struct CurtailmentCostExpression <: CostExpressions end struct FuelConsumptionExpression <: ExpressionType end struct ActivePowerRangeExpressionLB <: RangeConstraintLBExpressions end struct ActivePowerRangeExpressionUB <: RangeConstraintUBExpressions end @@ -75,8 +82,11 @@ should_write_resulting_value(::Type{ActivePowerBalance}) = true should_write_resulting_value(::Type{ReactivePowerBalance}) = true should_write_resulting_value(::Type{DCCurrentBalance}) = true -# ProductionCostExpression-specific container method (moved here from optimization_container.jl -# because it requires ProductionCostExpression to be defined first) +# CostExpressions container method (moved here from optimization_container.jl +# because it requires the cost expression types to be defined first). Covers +# ProductionCostExpression, ConstituentCostExpression subtypes, and +# CurtailmentCostExpression — all use a JuMP.QuadExpr container so quadratic +# fuel terms can be stored in the same expression. function add_expression_container!( container::OptimizationContainer, ::Type{T}, @@ -85,7 +95,7 @@ function add_expression_container!( sparse = false, meta = CONTAINER_KEY_EMPTY_META, ) where { - T <: ProductionCostExpression, + T <: CostExpressions, U <: Union{IS.InfrastructureSystemsComponent, IS.InfrastructureSystemsContainer}, } expr_container = diff --git a/src/objective_function/common.jl b/src/objective_function/common.jl index ae3c7fe4..ff110db4 100644 --- a/src/objective_function/common.jl +++ b/src/objective_function/common.jl @@ -20,6 +20,8 @@ function add_cost_to_expression!( cost_expression, ) end + _propagate_to_production_cost!( + container, S, T, component_name, time_period, cost_expression) return end @@ -69,7 +71,8 @@ function _add_vom_cost_to_objective!( variable_cost_data = variable_cost(op_cost, T, C, U) power_units = IS.get_power_units(variable_cost_data) cost_term = IS.get_proportional_term(IS.get_vom_cost(variable_cost_data)) - add_proportional_cost_invariant!(container, T, component, cost_term, power_units) + add_proportional_cost_invariant!( + container, T, component, cost_term, power_units, 1.0, VOMCostExpression) return end @@ -114,7 +117,7 @@ function _add_time_varying_fuel_variable_cost!( container, expression[name, t], FuelCostParameter, - ProductionCostExpression, + FuelCostExpression, V, name, t, diff --git a/src/objective_function/cost_term_helpers.jl b/src/objective_function/cost_term_helpers.jl index 2f3a85fe..b9b6ccde 100644 --- a/src/objective_function/cost_term_helpers.jl +++ b/src/objective_function/cost_term_helpers.jl @@ -7,6 +7,37 @@ # - No concrete expression or parameter types - caller passes them # - Single timestep only - looping stays in PSI/POM +####################################### +###### Constituent Propagation ######## +####################################### + +# Default no-op: any expression type that is not a ConstituentCostExpression +# does not propagate into the aggregate ProductionCostExpression. +_propagate_to_production_cost!( + ::OptimizationContainer, + ::Type{<:ExpressionType}, + ::Type{<:IS.InfrastructureSystemsComponent}, + ::String, + ::Int, + ::Any, +) = nothing + +# ConstituentCostExpression overload: also write the same cost into +# ProductionCostExpression so the aggregate stays consistent with its parts. +function _propagate_to_production_cost!( + container::OptimizationContainer, + ::Type{<:ConstituentCostExpression}, + ::Type{C}, + name::String, + t::Int, + cost, +) where {C <: IS.InfrastructureSystemsComponent} + has_container_key(container, ProductionCostExpression, C) || return + prod_expr = get_expression(container, ProductionCostExpression, C) + JuMP.add_to_expression!(prod_expr[name, t], cost) + return +end + ####################################### ######## Linear Cost Helpers ########## ####################################### @@ -40,6 +71,7 @@ function add_cost_term_invariant!( expr = get_expression(container, E, C) JuMP.add_to_expression!(expr[name, t], cost) end + _propagate_to_production_cost!(container, E, C, name, t, cost) add_to_objective_invariant_expression!(container, cost) return cost end @@ -76,6 +108,7 @@ function add_cost_term_variant!( expr = get_expression(container, E, C) JuMP.add_to_expression!(expr[name, t], cost) end + _propagate_to_production_cost!(container, E, C, name, t, cost) add_to_objective_variant_expression!(container, cost) return cost end @@ -113,6 +146,7 @@ function add_cost_term_variant!( expr = get_expression(container, E, C) JuMP.add_to_expression!(expr[name, t], cost) end + _propagate_to_production_cost!(container, E, C, name, t, cost) add_to_objective_variant_expression!(container, cost) return cost end @@ -122,7 +156,7 @@ end Add a proportional (linear) cost to the invariant objective across all time steps. Normalizes `cost_term` from `power_units` to system per-unit, multiplies by `dt` and -`multiplier`, then adds `variable * rate` to `ProductionCostExpression` and the invariant +`multiplier`, then adds `variable * rate` to the target expression `E` and the invariant objective for each time step. # Arguments @@ -132,6 +166,9 @@ objective for each time step. - `cost_term`: raw proportional cost (e.g., \$/MWh before normalization) - `power_units`: unit system of `cost_term` - `multiplier`: additional scalar (e.g., `objective_function_multiplier`, fuel cost) +- `E`: target cost expression type (e.g., `FuelCostExpression`, `VOMCostExpression`). + Constituent types auto-propagate into `ProductionCostExpression` via + `_propagate_to_production_cost!`. """ function add_proportional_cost_invariant!( container::OptimizationContainer, @@ -139,8 +176,9 @@ function add_proportional_cost_invariant!( component::C, cost_term::Float64, power_units::IS.UnitSystem, - multiplier::Float64 = 1.0, -) where {T <: VariableType, C <: IS.InfrastructureSystemsComponent} + multiplier::Float64, + ::Type{E}, +) where {T <: VariableType, C <: IS.InfrastructureSystemsComponent, E <: CostExpressions} iszero(cost_term) && return base_power = get_model_base_power(container) device_base_power = get_base_power(component) @@ -151,8 +189,21 @@ function add_proportional_cost_invariant!( rate = cost_per_unit * multiplier * dt for t in get_time_steps(container) variable = get_variable(container, T, C)[name, t] - add_cost_term_invariant!( - container, variable, rate, ProductionCostExpression, C, name, t) + add_cost_term_invariant!(container, variable, rate, E, C, name, t) end return end + +# Backward-compatible default: route to ProductionCostExpression for callers that +# haven't been updated to specify a constituent expression type. +add_proportional_cost_invariant!( + container::OptimizationContainer, + ::Type{T}, + component::C, + cost_term::Float64, + power_units::IS.UnitSystem, + multiplier::Float64 = 1.0, +) where {T <: VariableType, C <: IS.InfrastructureSystemsComponent} = + add_proportional_cost_invariant!( + container, T, component, cost_term, power_units, multiplier, + ProductionCostExpression) diff --git a/src/objective_function/linear_curve.jl b/src/objective_function/linear_curve.jl index 0faf1ade..c4881bc5 100644 --- a/src/objective_function/linear_curve.jl +++ b/src/objective_function/linear_curve.jl @@ -20,7 +20,8 @@ function add_variable_cost_to_objective!( proportional_term = get_proportional_term(get_function_data(value_curve)) multiplier = objective_function_multiplier(T, U) add_proportional_cost_invariant!( - container, T, component, proportional_term, power_units, multiplier) + container, T, component, proportional_term, power_units, multiplier, + FuelCostExpression) return end @@ -59,7 +60,8 @@ function add_variable_cost_to_objective!( # Multiplier is not necessary here. There is no negative cost for fuel curves. if fuel_cost isa Float64 add_proportional_cost_invariant!( - container, T, component, proportional_term, power_units, fuel_cost) + container, T, component, proportional_term, power_units, fuel_cost, + FuelCostExpression) else # Time-varying fuel cost: normalize, then delegate to variant path base_power = get_model_base_power(container) diff --git a/src/objective_function/piecewise_linear.jl b/src/objective_function/piecewise_linear.jl index cc1a06a6..46fd82fe 100644 --- a/src/objective_function/piecewise_linear.jl +++ b/src/objective_function/piecewise_linear.jl @@ -178,7 +178,7 @@ function add_variable_cost_to_objective!( for t in get_time_steps(container) add_cost_to_expression!( container, - ProductionCostExpression, + FuelCostExpression, pwl_cost_expressions[t], C, component_name, @@ -227,7 +227,7 @@ function add_variable_cost_to_objective!( pwl_cost_expression = pwl_fuel_consumption_expressions[t] * fuel_cost_value add_cost_to_expression!( container, - ProductionCostExpression, + FuelCostExpression, pwl_cost_expression, V, component_name, diff --git a/src/objective_function/proportional.jl b/src/objective_function/proportional.jl index 0abb7cae..7cf2e75d 100644 --- a/src/objective_function/proportional.jl +++ b/src/objective_function/proportional.jl @@ -29,14 +29,14 @@ function add_proportional_cost!( skip = skip_proportional_cost(d) for t in get_time_steps(container) if skip - # must-run etc.: bookkeep in ProductionCostExpression but not in objective + # must-run etc.: bookkeep in FixedCostExpression but not in objective add_cost_to_expression!( - container, ProductionCostExpression, rate, T, name, t) + container, FixedCostExpression, rate, T, name, t) else variable = get_variable(container, U, T)[name, t] add_cost_term_invariant!( container, variable, rate, - ProductionCostExpression, T, name, t, + FixedCostExpression, T, name, t, ) end end @@ -75,7 +75,7 @@ function add_proportional_cost_maybe_time_variant!( # Only add to expression, not objective add_cost_to_expression!( container, - ProductionCostExpression, + FixedCostExpression, rate, T, name, @@ -85,10 +85,10 @@ function add_proportional_cost_maybe_time_variant!( variable = get_variable(container, U, T)[name, t] if add_as_time_variant add_cost_term_variant!( - container, variable, rate, ProductionCostExpression, T, name, t) + container, variable, rate, FixedCostExpression, T, name, t) else add_cost_term_invariant!( - container, variable, rate, ProductionCostExpression, T, name, t) + container, variable, rate, FixedCostExpression, T, name, t) end end end diff --git a/src/objective_function/quadratic_curve.jl b/src/objective_function/quadratic_curve.jl index 65623a4f..48f6a182 100644 --- a/src/objective_function/quadratic_curve.jl +++ b/src/objective_function/quadratic_curve.jl @@ -12,23 +12,19 @@ function _add_quadraticcurve_variable_term_to_model!( name = get_name(component) var = get_variable(container, T, V)[name, time_period] - cost = if quadratic_term_per_unit >= eps() + if quadratic_term_per_unit >= eps() @debug "$name Quadratic Variable Cost" _group = LOG_GROUP_COST_FUNCTIONS name q_cost = (var .^ 2 * quadratic_term_per_unit + var * proportional_term_per_unit) * dt add_to_objective_invariant_expression!(container, q_cost) - q_cost + # add_cost_to_expression! handles ConstituentCostExpression -> ProductionCostExpression + # propagation via _propagate_to_production_cost!. + add_cost_to_expression!( + container, FuelCostExpression, q_cost, V, name, time_period) else add_cost_term_invariant!( container, var, proportional_term_per_unit * dt, - ProductionCostExpression, V, name, time_period) - end - - # For quadratic case, still need to add to expression (linear case handled by helper) - if quadratic_term_per_unit >= eps() && - has_container_key(container, ProductionCostExpression, V) - expr = get_expression(container, ProductionCostExpression, V) - JuMP.add_to_expression!(expr[name, time_period], cost) + FuelCostExpression, V, name, time_period) end return end diff --git a/src/objective_function/start_up_shut_down.jl b/src/objective_function/start_up_shut_down.jl index cb988603..e210f00b 100644 --- a/src/objective_function/start_up_shut_down.jl +++ b/src/objective_function/start_up_shut_down.jl @@ -55,7 +55,7 @@ function _add_shut_down_cost_per_device!( rate = cost_term * multiplier variable = get_variable(container, U, T)[name, t] add_cost_term_variant!( - container, variable, rate, ProductionCostExpression, T, name, t) + container, variable, rate, ShutDownCostExpression, T, name, t) end else cost_term = _shutdown_cost_value(get_shut_down(op_cost)) @@ -64,7 +64,7 @@ function _add_shut_down_cost_per_device!( for t in get_time_steps(container) variable = get_variable(container, U, T)[name, t] add_cost_term_invariant!( - container, variable, rate, ProductionCostExpression, T, name, t) + container, variable, rate, ShutDownCostExpression, T, name, t) end end end @@ -117,7 +117,7 @@ function _add_start_up_cost_to_objective!( rate = cost_term * multiplier variable = get_variable(container, T, C)[name, t] add_cost_term_variant!( - container, variable, rate, ProductionCostExpression, C, name, t) + container, variable, rate, StartUpCostExpression, C, name, t) end else raw_startup_cost = get_start_up(op_cost) @@ -127,7 +127,7 @@ function _add_start_up_cost_to_objective!( rate = cost_term * multiplier variable = get_variable(container, T, C)[name, t] add_cost_term_invariant!( - container, variable, rate, ProductionCostExpression, C, name, t) + container, variable, rate, StartUpCostExpression, C, name, t) end end return From fd0946d01f3ffe6c2e2c8d9b93a150adfef705e8 Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Tue, 5 May 2026 18:34:44 -0400 Subject: [PATCH 2/9] claude port --- src/core/parameter_container.jl | 180 ++++++++++++++++++++++++++++++++ src/utils/indexing.jl | 80 ++++++++------ 2 files changed, 226 insertions(+), 34 deletions(-) diff --git a/src/core/parameter_container.jl b/src/core/parameter_container.jl index d362c468..7ab83d35 100644 --- a/src/core/parameter_container.jl +++ b/src/core/parameter_container.jl @@ -108,7 +108,12 @@ function ParameterContainer(parameter_array, multiplier_array) end get_parameter_array(c::ParameterContainer) = c.parameter_array +# Underlying dense storage of the parameter array. `parent` on a JuMP `DenseAxisArray` +# returns the array itself, so reach for `.data` directly to bypass the axis-keyed lookup. +get_parameter_array_data(c::ParameterContainer) = get_parameter_array(c).data get_multiplier_array(c::ParameterContainer) = c.multiplier_array +# Same shortcut for the multiplier array — used by the integer-indexed fast path. +get_multiplier_array_data(c::ParameterContainer) = get_multiplier_array(c).data get_attributes(c::ParameterContainer) = c.attributes Base.length(c::ParameterContainer) = length(c.parameter_array) Base.size(c::ParameterContainer) = size(c.parameter_array) @@ -281,3 +286,178 @@ function set_parameter!( assign_maybe_broadcast!(get_parameter_array(container), parameter, ixs) return end + +# Fast-path setters that skip DenseAxisArray's string-keyed axis lookup. Callers pass +# `get_parameter_array_data(container)` once, then write into the underlying Array +# by integer indices. The (i, t) layout matches the canonical (component, time) axis +# order produced by `add_param_container!`. +# +# 2D scalar path: covers Float64 and Tuple{Vararg{Float64}} eltypes (the latter is +# used by piecewise-cost MarketBid parameters whose storage is a Matrix of tuples). +@inline function _set_parameter_at!( + parent_param::Array{T, 2}, + ::JuMP.Model, + value::T, + i::Int, + t::Int, +) where {T <: ValidDataParamEltypes} + parent_param[i, t] = value + return +end + +# 2D recurrent-rebuild paths: param storage is `Array{JuMP.VariableRef, 2}`. Either we +# need a fresh JuMP parameter (Float64 input) or we reuse one created by an earlier +# parallel branch type (VariableRef input). +@inline function _set_parameter_at!( + parent_param::Array{JuMP.VariableRef, 2}, + jump_model::JuMP.Model, + value::Float64, + i::Int, + t::Int, +) + parent_param[i, t] = add_jump_parameter(jump_model, value) + return +end + +@inline function _set_parameter_at!( + parent_param::Array{JuMP.VariableRef, 2}, + ::JuMP.Model, + parameter::JuMP.VariableRef, + i::Int, + t::Int, +) + parent_param[i, t] = parameter + return +end + +# 3D fast paths (parameter container with a middle additional axis, e.g. piecewise +# tranches). The supplied `value` is a length-(size(parent_param, 2)) vector that fills +# the middle axis at position (i, :, t). Eltype constrained to `ValidDataParamEltypes` +# so tuples-of-floats are also accepted (piecewise breakpoint storage). +@inline function _set_parameter_at!( + parent_param::Array{T, 3}, + ::JuMP.Model, + value::AbstractVector{<:T}, + i::Int, + t::Int, +) where {T <: ValidDataParamEltypes} + @views parent_param[i, :, t] .= value + return +end + +@inline function _set_parameter_at!( + parent_param::Array{JuMP.VariableRef, 3}, + jump_model::JuMP.Model, + value::AbstractVector{Float64}, + i::Int, + t::Int, +) + for k in 1:size(parent_param, 2) + parent_param[i, k, t] = add_jump_parameter(jump_model, value[k]) + end + return +end + +# Fast-path setters for the multiplier array, mirroring `_set_parameter_at!`. +# Multipliers are always Float64-valued (or tuples-of-floats for piecewise +# parameters), so a single typed family covers every call site. Callers should +# hoist `parent_mult = get_multiplier_array_data(parameter_container)` once +# above the device loop and pass the integer device row index. + +# 2D row fill: assigns `value` across the whole time slice for component `i` +# (the canonical pattern at parameter-creation time, where the multiplier is +# constant per device). +@inline function _set_multiplier_at!( + parent_mult::Array{T, 2}, + value::T, + i::Int, +) where {T <: ValidDataParamEltypes} + @views parent_mult[i, :] .= value + return +end + +# 2D scalar write at a single (component, time) cell. +@inline function _set_multiplier_at!( + parent_mult::Array{T, 2}, + value::T, + i::Int, + t::Int, +) where {T <: ValidDataParamEltypes} + parent_mult[i, t] = value + return +end + +# 3D row fill: assigns `value` across all (tranche, time) for component `i`. +@inline function _set_multiplier_at!( + parent_mult::Array{T, 3}, + value::T, + i::Int, +) where {T <: ValidDataParamEltypes} + @views parent_mult[i, :, :] .= value + return +end + +# 3D point write at a single (component, tranche, time) cell. +@inline function _set_multiplier_at!( + parent_mult::Array{T, 3}, + value::T, + i::Int, + j::Int, + t::Int, +) where {T <: ValidDataParamEltypes} + parent_mult[i, j, t] = value + return +end + +# Fast-path setters for the simulation-step parameter-VALUE update path. +# Used by `_update_parameter_values!` and `_fix_parameter_value!` overloads +# where the parameter container already exists and we are pushing new values +# into it from upstream model results or time series. Caller hoists +# `parent_param = get_parameter_array_data(parameter_container)` (and an +# optional `parent_mult` / `parent_var`) above the device loop, then writes +# by integer (i, t) — bypassing DenseAxisArray's string-keyed axis lookup. +# +# For Float64-typed storage we write directly; for `JuMP.VariableRef` storage +# we update the JuMP parameter's bound via `JuMP.fix(...; force=true)`. +@inline function _set_param_value_at!( + parent_param::Array{T, 2}, + value::T, + i::Int, + t::Int, +) where {T <: ValidDataParamEltypes} + @inbounds parent_param[i, t] = value + return +end + +@inline function _set_param_value_at!( + parent_param::Array{JuMP.VariableRef, 2}, + value::Float64, + i::Int, + t::Int, +) + @inbounds JuMP.fix(parent_param[i, t], value; force = true) + return +end + +# 3D paths for piecewise-tranche updates. +@inline function _set_param_value_at!( + parent_param::Array{T, 3}, + value::AbstractVector{<:T}, + i::Int, + t::Int, +) where {T <: ValidDataParamEltypes} + @inbounds @views parent_param[i, :, t] .= value + return +end + +@inline function _set_param_value_at!( + parent_param::Array{JuMP.VariableRef, 3}, + value::AbstractVector{Float64}, + i::Int, + t::Int, +) + @inbounds for k in 1:size(parent_param, 2) + JuMP.fix(parent_param[i, k, t], value[k]; force = true) + end + return +end diff --git a/src/utils/indexing.jl b/src/utils/indexing.jl index 542a7c87..ce8138cd 100644 --- a/src/utils/indexing.jl +++ b/src/utils/indexing.jl @@ -1,50 +1,62 @@ -# If `ixs` does not index all dimensions of `dest`, add a `:` for the rest (like Python's -# `...`) to prepare for broadcast-assigning. -function expand_ixs(ixs::NTuple{1, T}, dest::AbstractArray) where {T} - if length(ixs) <= ndims(dest) - return (ixs[1], fill(:, ndims(dest) - 1)...) - else - throw(ArgumentError("`ixs` must not index more dimensions than `dest` has")) - end +# Pad `ixs` with `:` for any unindexed middle dimensions of `dest` (Python `...`-style). +# Fast path for AbstractArrays where `N` is known at compile time → allocation-free `Val(N - K)`. +@inline function expand_ixs( + ixs::NTuple{K, Any}, + dest::AbstractArray{<:Any, N}, +) where {K, N} + K <= N || throw(ArgumentError("`ixs` must not index more dimensions than `dest` has")) + K == N && return ixs + # Single-element ixs is the leading axis; multi-element preserves first..last with `:` filling the middle. + K == 1 && return (only(ixs), ntuple(_ -> Colon(), Val(N - 1))...) + return (Base.front(ixs)..., ntuple(_ -> Colon(), Val(N - K))..., last(ixs)) end -function expand_ixs(ixs::Tuple{T, U}, dest::AbstractArray) where {T, U} - if length(ixs) <= ndims(dest) - return (ixs[1], fill(:, ndims(dest) - 2)..., ixs[end]) - else - throw(ArgumentError("`ixs` must not index more dimensions than `dest` has")) - end +# Fallback for non-AbstractArray containers (e.g. `HDF5.Dataset`) — `ndims` resolved at runtime. +@inline function expand_ixs(ixs::NTuple{K, Any}, dest) where {K} + N = ndims(dest) + K <= N || throw(ArgumentError("`ixs` must not index more dimensions than `dest` has")) + K == N && return ixs + K == 1 && return (only(ixs), ntuple(_ -> Colon(), N - 1)...) + return (Base.front(ixs)..., ntuple(_ -> Colon(), N - K)..., last(ixs)) end -function expand_ixs(ixs::Tuple, dest::AbstractArray) - if length(ixs) <= ndims(dest) - return (ixs[1:(end - 1)]..., fill(:, ndims(dest) - length(ixs))..., ixs[end]) - else - throw(ArgumentError("`ixs` must not index more dimensions than `dest` has")) - end +# Concrete fast path: scalar `src` with a fully-specified index tuple goes through `setindex!`. +@inline function assign_maybe_broadcast!( + dest::DenseAxisArray{T, N}, + src::T, + ixs::NTuple{N, Any}, +) where {T, N} + dest[ixs...] = src + return end -# If `src` is an array, we want to set a slice of `dest` equal to `src`. Broadcast -# assignment from integer-indexed `src` to DenseAxisArray `dest` slice of same shape doesn't -# work when `dest`'s axes being broadcast across aren't 1:n, but standard assignment does -# the trick in that case and (PERF) seems to not appreciably affect simulation performance -function assign_maybe_broadcast!(dest::AbstractArray, src::AbstractArray, ixs::Tuple) - expanded_axs = expand_ixs(ixs, dest) - dest[expanded_axs...] = src +# Array `src`: assign a slice of `dest` from `src` (standard assignment handles non-1:n axes). +# `dest` is left untyped so non-`AbstractArray` containers (e.g. `HDF5.Dataset`) are also accepted. +@inline function assign_maybe_broadcast!(dest, src::AbstractArray, ixs::Tuple) + dest[expand_ixs(ixs, dest)...] = src return end -# If `src` is a tuple or scalar, we want to set all values across a slice of `dest` equal to `src` -function assign_maybe_broadcast!(dest::AbstractArray, src, ixs::Tuple) - expanded_axs = expand_ixs(ixs, dest) - dest[expanded_axs...] .= Ref(src) +# Scalar/tuple `src`: broadcast the value across the indexed slice of `dest`. +@inline function assign_maybe_broadcast!(dest, src, ixs::Tuple) + expanded = expand_ixs(ixs, dest) + @views dest[expanded...] .= Ref(src) return end # Similar to assign_maybe_broadcast! but for fixing JuMP VariableRefs -fix_expand(dest::AbstractArray, src, ixs::Tuple) = +@inline function fix_maybe_broadcast!( + dest::DenseAxisArray{JuMP.VariableRef, N}, + src::Float64, + ixs::NTuple{N, Any}, +) where {N} + JuMP.fix(dest[ixs...], src; force = true) + return +end + +fix_expand(dest, src, ixs::Tuple) = fix_parameter_value.(dest[expand_ixs(ixs, dest)...], src) -fix_maybe_broadcast!(dest::AbstractArray, src::AbstractArray, ixs::Tuple) = +fix_maybe_broadcast!(dest, src::AbstractArray, ixs::Tuple) = fix_expand(dest, src, ixs) -fix_maybe_broadcast!(dest::AbstractArray, src, ixs::Tuple) = +fix_maybe_broadcast!(dest, src, ixs::Tuple) = fix_expand(dest, Ref(src), ixs) From babc94625f4cb3303c00377188ffce178d04e157 Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Wed, 6 May 2026 13:25:30 -0400 Subject: [PATCH 3/9] claude port --- src/InfrastructureOptimizationModels.jl | 9 +++++++++ src/common_models/interfaces.jl | 11 +++++++++-- src/core/network_model.jl | 11 ++++++----- src/core/optimization_container.jl | 2 +- 4 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/InfrastructureOptimizationModels.jl b/src/InfrastructureOptimizationModels.jl index 1fa5f860..df3a0309 100644 --- a/src/InfrastructureOptimizationModels.jl +++ b/src/InfrastructureOptimizationModels.jl @@ -471,6 +471,15 @@ export EventParameter # Abstract types for extensions (from InfrastructureSystems.Optimization) export AbstractPowerFlowEvaluationData +# Power flow in-the-loop extension points and helpers +export solve_powerflow! +export get_power_flow_data +export get_power_flow_evaluation_data +export reset_power_flow_is_solved! +export lookup_value +export get_entry_type +export get_component_names + # Status Enums (from InfrastructureSystems) export ModelBuildStatus export RunStatus diff --git a/src/common_models/interfaces.jl b/src/common_models/interfaces.jl index 1fa1ad88..11b86d8f 100644 --- a/src/common_models/interfaces.jl +++ b/src/common_models/interfaces.jl @@ -204,11 +204,18 @@ function start_up_cost( end """ -Extension point: Solve the power flow model. -Default: error. Concrete implementations require PowerFlows integration. +Extension point: Solve the power flow model and update aux-variable inputs. +Signature: `solve_powerflow!(pf_e_data, container, system)`. Concrete +implementations require PowerFlows integration; provided in PowerOperationsModels. """ function solve_powerflow! end +""" +Extension point: Get the underlying `PowerFlowData` from a `PowerFlowEvaluationData` wrapper. +Concrete implementation lives in the PowerFlows extension of POM. +""" +function get_power_flow_data end + """ Extension point: Calculate auxiliary variable values. Concrete implementations in PowerOperationsModels for specific aux variable types. diff --git a/src/core/network_model.jl b/src/core/network_model.jl index 928fa857..2fa49276 100644 --- a/src/core/network_model.jl +++ b/src/core/network_model.jl @@ -66,7 +66,11 @@ mutable struct NetworkModel{T <: AbstractPowerModel} network_reduction::PNM.NetworkReductionData reduce_radial_branches::Bool reduce_degree_two_branches::Bool - power_flow_evaluation::Vector{<:AbstractPowerFlowEvaluationModel} + # Stored as Vector{<:Any} so external power flow types (e.g., + # `PowerFlows.PowerFlowEvaluationModel`) that don't subtype IS's + # `AbstractPowerFlowEvaluationModel` can be supplied here. The actual + # validation/dispatch happens in `add_power_flow_data!` in POM/PowerFlowsExt. + power_flow_evaluation::Vector subsystem::Union{Nothing, String} hvdc_network_model::Union{Nothing, AbstractHVDCNetworkModel} modeled_branch_types::Vector{DataType} @@ -81,10 +85,7 @@ mutable struct NetworkModel{T <: AbstractPowerModel} reduce_degree_two_branches = false, subnetworks = Dict{Int, Set{Int}}(), duals = Vector{DataType}(), - power_flow_evaluation::Union{ - AbstractPowerFlowEvaluationModel, - Vector{<:AbstractPowerFlowEvaluationModel}, - } = AbstractPowerFlowEvaluationModel[], + power_flow_evaluation = AbstractPowerFlowEvaluationModel[], hvdc_network_model = nothing, ) where {T <: AbstractPowerModel} _check_pm_formulation(T) diff --git a/src/core/optimization_container.jl b/src/core/optimization_container.jl index 36f1979b..c7ce3113 100644 --- a/src/core/optimization_container.jl +++ b/src/core/optimization_container.jl @@ -1253,7 +1253,7 @@ function calculate_aux_variables!( # Power flow-related aux vars get calculated once per power flow for (i, pf_e_data) in enumerate(get_power_flow_evaluation_data(container)) @debug "Processing power flow $i" - solve_powerflow!(pf_e_data, container) + solve_powerflow!(pf_e_data, container, system) for key in pf_aux_var_keys calculate_aux_variable_value!(container, key, system) end From 5d241620a0ead4dcebcc1d867dec8ff436a89cee Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Fri, 8 May 2026 10:16:28 -0400 Subject: [PATCH 4/9] claude cost fn refactoring --- src/InfrastructureOptimizationModels.jl | 3 +- src/common_models/interfaces.jl | 4 +- src/core/optimization_container.jl | 2 +- src/objective_function/cost_term_helpers.jl | 43 ++++++++++++++++--- .../objective_function_pwl_lambda.jl | 8 ++-- 5 files changed, 48 insertions(+), 12 deletions(-) diff --git a/src/InfrastructureOptimizationModels.jl b/src/InfrastructureOptimizationModels.jl index 62fc264a..6fa5498e 100644 --- a/src/InfrastructureOptimizationModels.jl +++ b/src/InfrastructureOptimizationModels.jl @@ -223,6 +223,7 @@ export add_constant_to_jump_expression! export add_proportional_to_jump_expression! export add_linear_to_jump_expression! # Cost term helpers (generic objective function building blocks) +export add_cost_term_to_expression! export add_cost_term_invariant! export add_cost_term_variant! export add_pwl_variables_delta! @@ -479,7 +480,7 @@ export EventParameter export AbstractPowerFlowEvaluationData # Power flow in-the-loop extension points and helpers -export solve_powerflow! +export solve_power_flow! export get_power_flow_data export get_power_flow_evaluation_data export reset_power_flow_is_solved! diff --git a/src/common_models/interfaces.jl b/src/common_models/interfaces.jl index 11b86d8f..dad5885e 100644 --- a/src/common_models/interfaces.jl +++ b/src/common_models/interfaces.jl @@ -205,10 +205,10 @@ end """ Extension point: Solve the power flow model and update aux-variable inputs. -Signature: `solve_powerflow!(pf_e_data, container, system)`. Concrete +Signature: `solve_power_flow!(pf_e_data, container, system)`. Concrete implementations require PowerFlows integration; provided in PowerOperationsModels. """ -function solve_powerflow! end +function solve_power_flow! end """ Extension point: Get the underlying `PowerFlowData` from a `PowerFlowEvaluationData` wrapper. diff --git a/src/core/optimization_container.jl b/src/core/optimization_container.jl index c7ce3113..4ea03a97 100644 --- a/src/core/optimization_container.jl +++ b/src/core/optimization_container.jl @@ -1253,7 +1253,7 @@ function calculate_aux_variables!( # Power flow-related aux vars get calculated once per power flow for (i, pf_e_data) in enumerate(get_power_flow_evaluation_data(container)) @debug "Processing power flow $i" - solve_powerflow!(pf_e_data, container, system) + solve_power_flow!(pf_e_data, container, system) for key in pf_aux_var_keys calculate_aux_variable_value!(container, key, system) end diff --git a/src/objective_function/cost_term_helpers.jl b/src/objective_function/cost_term_helpers.jl index b9b6ccde..c952216b 100644 --- a/src/objective_function/cost_term_helpers.jl +++ b/src/objective_function/cost_term_helpers.jl @@ -43,21 +43,25 @@ end ####################################### """ -Add cost term to expression and invariant objective. +Add cost term to a target expression (no objective hook). -Computes `cost = quantity * rate`, adds to target expression (if present), -and adds to the time-invariant part of the objective. +Computes `cost = quantity * rate`, adds it to expression `E` for component `C` at +time `t` if that expression exists, and propagates into `ProductionCostExpression` +when `E <: ConstituentCostExpression`. Use this when the caller wants to record +the cost in an expression container without adding to the JuMP objective (e.g., +fuel consumption, where the term is a downstream quantity rather than a cost +that should be minimized). # Arguments - `container`: the optimization container - `quantity`: the value being costed (e.g., variable value, expression value) - `rate`: scalar cost rate (e.g., \$/MWh, \$/MMBTU) -- `E`: target expression type (caller provides, e.g., ProductionCostExpression) +- `E`: target expression type (caller provides) - `C`: component type - `name`: component name - `t`: time period """ -function add_cost_term_invariant!( +function add_cost_term_to_expression!( container::OptimizationContainer, quantity::JuMPOrFloat, rate::Float64, @@ -72,6 +76,35 @@ function add_cost_term_invariant!( JuMP.add_to_expression!(expr[name, t], cost) end _propagate_to_production_cost!(container, E, C, name, t, cost) + return cost +end + +""" +Add cost term to expression and invariant objective. + +Computes `cost = quantity * rate`, adds to target expression (if present), +propagates to `ProductionCostExpression` (when applicable), and adds to the +time-invariant part of the objective. + +# Arguments +- `container`: the optimization container +- `quantity`: the value being costed (e.g., variable value, expression value) +- `rate`: scalar cost rate (e.g., \$/MWh, \$/MMBTU) +- `E`: target expression type (caller provides, e.g., ProductionCostExpression) +- `C`: component type +- `name`: component name +- `t`: time period +""" +function add_cost_term_invariant!( + container::OptimizationContainer, + quantity::JuMPOrFloat, + rate::Float64, + ::Type{E}, + ::Type{C}, + name::String, + t::Int, +) where {E <: ExpressionType, C <: IS.InfrastructureSystemsComponent} + cost = add_cost_term_to_expression!(container, quantity, rate, E, C, name, t) add_to_objective_invariant_expression!(container, cost) return cost end diff --git a/src/objective_function/objective_function_pwl_lambda.jl b/src/objective_function/objective_function_pwl_lambda.jl index 8a55a59e..d8528141 100644 --- a/src/objective_function/objective_function_pwl_lambda.jl +++ b/src/objective_function/objective_function_pwl_lambda.jl @@ -126,7 +126,9 @@ function _determine_bin_lhs( sos_status::SOSStatusVariable, ::Type{T}, name::String, - period::Int) where {T <: IS.InfrastructureSystemsComponent} + period::Int; + on_var_type::Type{<:VariableType} = OnVariable, +) where {T <: IS.InfrastructureSystemsComponent} if sos_status == SOSStatusVariable.NO_VARIABLE @debug "Using Piecewise Linear cost function but no variable/parameter ref for ON status is passed. Default status will be set to online (1.0)" _group = LOG_GROUP_COST_FUNCTIONS @@ -136,9 +138,9 @@ function _determine_bin_lhs( LOG_GROUP_COST_FUNCTIONS return get_parameter(container, OnStatusParameter, T).parameter_array[name, period] elseif sos_status == SOSStatusVariable.VARIABLE - @debug "Using Piecewise Linear cost function with variable OnVariable $T" _group = + @debug "Using Piecewise Linear cost function with variable $on_var_type for $T" _group = LOG_GROUP_COST_FUNCTIONS - return get_variable(container, OnVariable, T)[name, period] + return get_variable(container, on_var_type, T)[name, period] else @assert false end From 033363e28f13f70bb5d185b9e5c4792da4ec0146 Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Fri, 8 May 2026 16:32:44 -0400 Subject: [PATCH 5/9] address PR #97 review feedback - restrict get_parameter_array_data/get_multiplier_array_data to ParameterContainer{<:DenseAxisArray} so SparseAxisArray-backed containers fail with MethodError instead of `no field data` - remove backward-compat 5-arg add_proportional_cost_invariant! shim (all callers already pass the constituent expression type) - guard quadratic-term test with abs() so negative quadratic terms still take the quadratic branch - add ProductionCostExpression propagation testset covering every entry point, the multi-constituent sum invariant, non-constituent paths to the JuMP objective, and the unregistered-container no-op - update test_linear_curve.jl callers to the new 7-arg signature Co-Authored-By: Claude Opus 4.7 --- src/core/parameter_container.jl | 7 +- src/objective_function/cost_term_helpers.jl | 14 -- src/objective_function/quadratic_curve.jl | 2 +- test/test_cost_term_helpers.jl | 237 ++++++++++++++++++++ test/test_linear_curve.jl | 5 + 5 files changed, 248 insertions(+), 17 deletions(-) diff --git a/src/core/parameter_container.jl b/src/core/parameter_container.jl index 7ab83d35..aa71385c 100644 --- a/src/core/parameter_container.jl +++ b/src/core/parameter_container.jl @@ -110,10 +110,13 @@ end get_parameter_array(c::ParameterContainer) = c.parameter_array # Underlying dense storage of the parameter array. `parent` on a JuMP `DenseAxisArray` # returns the array itself, so reach for `.data` directly to bypass the axis-keyed lookup. -get_parameter_array_data(c::ParameterContainer) = get_parameter_array(c).data +# Restricted to dense storage: `SparseAxisArray` has no `.data` field. +get_parameter_array_data(c::ParameterContainer{<:DenseAxisArray}) = + get_parameter_array(c).data get_multiplier_array(c::ParameterContainer) = c.multiplier_array # Same shortcut for the multiplier array — used by the integer-indexed fast path. -get_multiplier_array_data(c::ParameterContainer) = get_multiplier_array(c).data +get_multiplier_array_data(c::ParameterContainer{<:Any, <:DenseAxisArray}) = + get_multiplier_array(c).data get_attributes(c::ParameterContainer) = c.attributes Base.length(c::ParameterContainer) = length(c.parameter_array) Base.size(c::ParameterContainer) = size(c.parameter_array) diff --git a/src/objective_function/cost_term_helpers.jl b/src/objective_function/cost_term_helpers.jl index c952216b..dd27d2dc 100644 --- a/src/objective_function/cost_term_helpers.jl +++ b/src/objective_function/cost_term_helpers.jl @@ -226,17 +226,3 @@ function add_proportional_cost_invariant!( end return end - -# Backward-compatible default: route to ProductionCostExpression for callers that -# haven't been updated to specify a constituent expression type. -add_proportional_cost_invariant!( - container::OptimizationContainer, - ::Type{T}, - component::C, - cost_term::Float64, - power_units::IS.UnitSystem, - multiplier::Float64 = 1.0, -) where {T <: VariableType, C <: IS.InfrastructureSystemsComponent} = - add_proportional_cost_invariant!( - container, T, component, cost_term, power_units, multiplier, - ProductionCostExpression) diff --git a/src/objective_function/quadratic_curve.jl b/src/objective_function/quadratic_curve.jl index 48f6a182..82f16cbd 100644 --- a/src/objective_function/quadratic_curve.jl +++ b/src/objective_function/quadratic_curve.jl @@ -12,7 +12,7 @@ function _add_quadraticcurve_variable_term_to_model!( name = get_name(component) var = get_variable(container, T, V)[name, time_period] - if quadratic_term_per_unit >= eps() + if abs(quadratic_term_per_unit) >= eps() @debug "$name Quadratic Variable Cost" _group = LOG_GROUP_COST_FUNCTIONS name q_cost = (var .^ 2 * quadratic_term_per_unit + var * proportional_term_per_unit) * dt diff --git a/test/test_cost_term_helpers.jl b/test/test_cost_term_helpers.jl index ccfb828a..f4a286f2 100644 --- a/test/test_cost_term_helpers.jl +++ b/test/test_cost_term_helpers.jl @@ -547,4 +547,241 @@ Test types defined in test_utils/test_types.jl. @test JuMP.coefficient(cost_expr, pwl_vars[2]) ≈ 15.0 end end + + # Helpers for the ProductionCostExpression propagation testset + function _setup_prop_test_container(time_steps, names = ["gen1"]) + container = make_test_container(time_steps) + var_container = IOM.add_variable_container!( + container, TestCostVariable, MockThermalGen, names, time_steps, + ) + jump_model = IOM.get_jump_model(container) + for name in names, t in time_steps + var_container[name, t] = + JuMP.@variable(jump_model, base_name = "v_$(name)_$(t)") + end + return container, var_container + end + + _expr_coef(container, ::Type{E}, var, name, t) where {E} = JuMP.coefficient( + IOM.get_expression(container, E, MockThermalGen)[name, t], var, + ) + _inv_coef(container, var) = JuMP.coefficient( + IOM.get_invariant_terms(IOM.get_objective_expression(container)), var, + ) + _var_coef(container, var) = JuMP.coefficient( + IOM.get_variant_terms(IOM.get_objective_expression(container)), var, + ) + + @testset "ProductionCostExpression propagation" begin + time_steps = 1:1 + + @testset "add_cost_term_to_expression! propagates constituent → ProductionCost" begin + container, vars = _setup_prop_test_container(time_steps) + for E in (IOM.FuelCostExpression, IOM.ProductionCostExpression) + add_test_expression!(container, E, MockThermalGen, ["gen1"], time_steps) + end + r = 11.0 + IOM.add_cost_term_to_expression!( + container, vars["gen1", 1], r, + IOM.FuelCostExpression, MockThermalGen, "gen1", 1, + ) + @test _expr_coef( + container, IOM.FuelCostExpression, vars["gen1", 1], "gen1", 1) ≈ r + @test _expr_coef( + container, IOM.ProductionCostExpression, vars["gen1", 1], "gen1", 1) ≈ r + # No objective hook on this entry point. + @test _inv_coef(container, vars["gen1", 1]) ≈ 0.0 + @test _var_coef(container, vars["gen1", 1]) ≈ 0.0 + end + + @testset "add_cost_term_invariant! → constituent, ProductionCost, and invariant obj" begin + container, vars = _setup_prop_test_container(time_steps) + for E in (IOM.FuelCostExpression, IOM.ProductionCostExpression) + add_test_expression!(container, E, MockThermalGen, ["gen1"], time_steps) + end + r = 13.0 + IOM.add_cost_term_invariant!( + container, vars["gen1", 1], r, + IOM.FuelCostExpression, MockThermalGen, "gen1", 1, + ) + @test _expr_coef( + container, IOM.FuelCostExpression, vars["gen1", 1], "gen1", 1) ≈ r + @test _expr_coef( + container, IOM.ProductionCostExpression, vars["gen1", 1], "gen1", 1) ≈ r + @test _inv_coef(container, vars["gen1", 1]) ≈ r + @test _var_coef(container, vars["gen1", 1]) ≈ 0.0 + end + + @testset "add_cost_term_invariant! direct ProductionCost write does not recurse" begin + container, vars = _setup_prop_test_container(time_steps) + add_test_expression!( + container, IOM.ProductionCostExpression, MockThermalGen, + ["gen1"], time_steps, + ) + r = 17.0 + IOM.add_cost_term_invariant!( + container, vars["gen1", 1], r, + IOM.ProductionCostExpression, MockThermalGen, "gen1", 1, + ) + # ProductionCostExpression ⊀ ConstituentCostExpression, so the propagation + # hook is a no-op — exactly one write to ProductionCostExpression. + @test _expr_coef( + container, IOM.ProductionCostExpression, vars["gen1", 1], "gen1", 1) ≈ r + @test _inv_coef(container, vars["gen1", 1]) ≈ r + end + + @testset "add_cost_term_invariant! to non-constituent reaches obj, not ProductionCost" begin + container, vars = _setup_prop_test_container(time_steps) + add_test_expression!( + container, TestCostExpression, MockThermalGen, ["gen1"], time_steps, + ) + add_test_expression!( + container, IOM.ProductionCostExpression, MockThermalGen, + ["gen1"], time_steps, + ) + r = 19.0 + IOM.add_cost_term_invariant!( + container, vars["gen1", 1], r, + TestCostExpression, MockThermalGen, "gen1", 1, + ) + @test _expr_coef( + container, TestCostExpression, vars["gen1", 1], "gen1", 1) ≈ r + # TestCostExpression is not a ConstituentCostExpression — ProductionCost untouched. + @test _expr_coef( + container, IOM.ProductionCostExpression, + vars["gen1", 1], "gen1", 1) ≈ 0.0 + @test _inv_coef(container, vars["gen1", 1]) ≈ r + end + + @testset "add_cost_term_variant! (param-rate) propagates and hits variant obj" begin + container, vars = _setup_prop_test_container(time_steps) + for E in (IOM.FuelCostExpression, IOM.ProductionCostExpression) + add_test_expression!(container, E, MockThermalGen, ["gen1"], time_steps) + end + param_rate = 23.0 + add_test_parameter!( + container, TestCostParameter, MockThermalGen, + ["gen1"], time_steps, fill(param_rate, 1, length(time_steps)), + ) + IOM.add_cost_term_variant!( + container, vars["gen1", 1], TestCostParameter, + IOM.FuelCostExpression, MockThermalGen, "gen1", 1, + ) + @test _expr_coef( + container, IOM.FuelCostExpression, vars["gen1", 1], "gen1", 1) ≈ param_rate + @test _expr_coef( + container, IOM.ProductionCostExpression, + vars["gen1", 1], "gen1", 1) ≈ param_rate + @test _var_coef(container, vars["gen1", 1]) ≈ param_rate + @test _inv_coef(container, vars["gen1", 1]) ≈ 0.0 + end + + @testset "add_cost_term_variant! (explicit-rate) propagates and hits variant obj" begin + container, vars = _setup_prop_test_container(time_steps) + for E in (IOM.FuelCostExpression, IOM.ProductionCostExpression) + add_test_expression!(container, E, MockThermalGen, ["gen1"], time_steps) + end + r = 29.0 + IOM.add_cost_term_variant!( + container, vars["gen1", 1], r, + IOM.FuelCostExpression, MockThermalGen, "gen1", 1, + ) + @test _expr_coef( + container, IOM.FuelCostExpression, vars["gen1", 1], "gen1", 1) ≈ r + @test _expr_coef( + container, IOM.ProductionCostExpression, vars["gen1", 1], "gen1", 1) ≈ r + @test _var_coef(container, vars["gen1", 1]) ≈ r + @test _inv_coef(container, vars["gen1", 1]) ≈ 0.0 + end + + @testset "add_cost_to_expression! propagates constituent → ProductionCost" begin + container, vars = _setup_prop_test_container(time_steps) + for E in (IOM.FuelCostExpression, IOM.ProductionCostExpression) + add_test_expression!(container, E, MockThermalGen, ["gen1"], time_steps) + end + r = 31.0 + IOM.add_cost_to_expression!( + container, IOM.FuelCostExpression, r * vars["gen1", 1], + MockThermalGen, "gen1", 1, + ) + @test _expr_coef( + container, IOM.FuelCostExpression, vars["gen1", 1], "gen1", 1) ≈ r + @test _expr_coef( + container, IOM.ProductionCostExpression, vars["gen1", 1], "gen1", 1) ≈ r + # No objective hook on this entry point. + @test _inv_coef(container, vars["gen1", 1]) ≈ 0.0 + @test _var_coef(container, vars["gen1", 1]) ≈ 0.0 + end + + @testset "add_proportional_cost_invariant! propagates per-time-step" begin + ts = 1:3 + container, vars = _setup_prop_test_container(ts) + for E in (IOM.FuelCostExpression, IOM.ProductionCostExpression) + add_test_expression!(container, E, MockThermalGen, ["gen1"], ts) + end + device = make_mock_thermal("gen1"; base_power = 100.0) + cost_term = 7.0 + multiplier = 1.0 + # SYSTEM_BASE → no normalization; dt = 1 hour → rate = cost_term * multiplier. + IOM.add_proportional_cost_invariant!( + container, TestCostVariable, device, cost_term, + IS.UnitSystem.SYSTEM_BASE, multiplier, IOM.FuelCostExpression, + ) + expected = cost_term * multiplier + for t in ts + @test _expr_coef( + container, IOM.FuelCostExpression, + vars["gen1", t], "gen1", t) ≈ expected + @test _expr_coef( + container, IOM.ProductionCostExpression, + vars["gen1", t], "gen1", t) ≈ expected + @test _inv_coef(container, vars["gen1", t]) ≈ expected + end + end + + @testset "multiple constituents sum into ProductionCost without double-count" begin + container, vars = _setup_prop_test_container(time_steps) + for E in (IOM.FuelCostExpression, IOM.VOMCostExpression, + IOM.StartUpCostExpression, IOM.ProductionCostExpression) + add_test_expression!(container, E, MockThermalGen, ["gen1"], time_steps) + end + r_fuel, r_vom, r_su = 3.0, 5.0, 7.0 + for (E, r) in ((IOM.FuelCostExpression, r_fuel), + (IOM.VOMCostExpression, r_vom), + (IOM.StartUpCostExpression, r_su)) + IOM.add_cost_term_invariant!( + container, vars["gen1", 1], r, E, MockThermalGen, "gen1", 1, + ) + end + @test _expr_coef( + container, IOM.FuelCostExpression, vars["gen1", 1], "gen1", 1) ≈ r_fuel + @test _expr_coef( + container, IOM.VOMCostExpression, vars["gen1", 1], "gen1", 1) ≈ r_vom + @test _expr_coef( + container, IOM.StartUpCostExpression, vars["gen1", 1], "gen1", 1) ≈ r_su + # Each constituent contributes once; no double-count, no missing terms. + @test _expr_coef( + container, IOM.ProductionCostExpression, + vars["gen1", 1], "gen1", 1) ≈ r_fuel + r_vom + r_su + @test _inv_coef(container, vars["gen1", 1]) ≈ r_fuel + r_vom + r_su + end + + @testset "ProductionCost not registered: constituent write is a no-op for prod" begin + container, vars = _setup_prop_test_container(time_steps) + # Only the constituent container is registered. + add_test_expression!( + container, IOM.FuelCostExpression, MockThermalGen, ["gen1"], time_steps, + ) + r = 41.0 + IOM.add_cost_term_invariant!( + container, vars["gen1", 1], r, + IOM.FuelCostExpression, MockThermalGen, "gen1", 1, + ) + @test _expr_coef( + container, IOM.FuelCostExpression, vars["gen1", 1], "gen1", 1) ≈ r + @test !IOM.has_container_key( + container, IOM.ProductionCostExpression, MockThermalGen) + @test _inv_coef(container, vars["gen1", 1]) ≈ r + end + end end diff --git a/test/test_linear_curve.jl b/test/test_linear_curve.jl index b83661e0..3cb65451 100644 --- a/test/test_linear_curve.jl +++ b/test/test_linear_curve.jl @@ -73,6 +73,8 @@ end device, 25.0, IS.UnitSystem.NATURAL_UNITS, + 1.0, + IOM.ProductionCostExpression, ) expected_coef = 25.0 * 100.0 * 1.0 @@ -104,6 +106,7 @@ end 20.0, IS.UnitSystem.SYSTEM_BASE, 2.0, + IOM.ProductionCostExpression, ) expected_coef = 20.0 * 2.0 * 0.25 @@ -128,6 +131,8 @@ end device, 0.0, IS.UnitSystem.NATURAL_UNITS, + 1.0, + IOM.ProductionCostExpression, ) # Should have zero coefficients since cost_term is zero From d7303c6a931bc3c44dc46fd47534c53b19e5d78f Mon Sep 17 00:00:00 2001 From: Jose Daniel Lara Date: Mon, 18 May 2026 08:47:59 -0600 Subject: [PATCH 6/9] add external evaluator infrastructure --- src/core/external_evaluation.jl | 141 ++++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 src/core/external_evaluation.jl diff --git a/src/core/external_evaluation.jl b/src/core/external_evaluation.jl new file mode 100644 index 00000000..30acc09e --- /dev/null +++ b/src/core/external_evaluation.jl @@ -0,0 +1,141 @@ +""" +Abstract type for evaluator configurations attached to a `NetworkModel`. + +An `AbstractEvaluator` is stateless configuration that, when used to build an +`OptimizationContainer`, produces an `AbstractEvaluationData` (its runtime +counterpart) via [`initialize_evaluation_data`](@ref). + +Concrete subtypes (e.g. `PowerFlowEvaluationModel`) are defined in downstream +packages such as PowerOperationsModels and its PowerFlows extension. +""" +abstract type AbstractEvaluator end + +""" +Abstract type for runtime evaluator state stored inside the +`OptimizationContainer`'s [`EvaluationContainer`](@ref). + +Concrete subtypes wrap whatever domain-specific data (power-flow solver state, +exporter handles, etc.) the evaluator needs across iterations of +`calculate_aux_variables!`. +""" +abstract type AbstractEvaluationData end + +""" +Holds the evaluators registered on a `NetworkModel` and their associated +runtime data, keyed by concrete evaluator type. + +`evaluators` is populated by the user when constructing a `NetworkModel`; +`evaluation_data` is populated during `OptimizationContainer` construction by +calling [`initialize_evaluation_data`](@ref) for each registered evaluator. + +The split between the two dictionaries may evolve; for now we keep them as +parallel keyed dictionaries so that `evaluation_data[T]` is the runtime state +produced from `evaluators[T]`. +""" +mutable struct EvaluationContainer + evaluators::Dict{DataType, Any} + evaluation_data::Dict{DataType, AbstractEvaluationData} +end + +function EvaluationContainer() + return EvaluationContainer( + Dict{DataType, Any}(), + Dict{DataType, AbstractEvaluationData}(), + ) +end + +get_evaluators(ec::EvaluationContainer) = ec.evaluators +get_evaluation_data(ec::EvaluationContainer) = ec.evaluation_data + +get_evaluator(ec::EvaluationContainer, T::DataType) = ec.evaluators[T] +get_evaluation_data(ec::EvaluationContainer, T::DataType) = ec.evaluation_data[T] + +add_evaluator!(ec::EvaluationContainer, T::DataType, ev) = + (ec.evaluators[T] = ev) +add_evaluation_data!( + ec::EvaluationContainer, + T::DataType, + d::AbstractEvaluationData, +) = (ec.evaluation_data[T] = d) + +Base.isempty(ec::EvaluationContainer) = isempty(ec.evaluators) +Base.length(ec::EvaluationContainer) = length(ec.evaluators) +Base.haskey(ec::EvaluationContainer, T::DataType) = haskey(ec.evaluators, T) + +#= +========================================================================= +AbstractEvaluator interface (config-side) +========================================================================= +=# + +""" +Build the runtime state for `ev` and return an `AbstractEvaluationData`. +Called once during `OptimizationContainer` construction. Concrete methods are +registered in downstream packages. +""" +function initialize_evaluation_data( + ev::AbstractEvaluator, + container, + system, +) + error( + "initialize_evaluation_data not implemented for evaluator type $(typeof(ev)). " * + "Define `initialize_evaluation_data(::$(typeof(ev)), container, system) ::AbstractEvaluationData` " * + "in the package that owns the concrete type.", + ) +end + +#= +========================================================================= +AbstractEvaluationData interface (runtime-side) +========================================================================= +=# + +""" +Run the evaluation: read state from `container`/`system`, write aux-variable +inputs back into `container`, and mark `data` as solved. Concrete methods are +registered in downstream packages. +""" +function evaluate!(data::AbstractEvaluationData, container, system) + error( + "evaluate! not implemented for evaluation data type $(typeof(data)). " * + "Define `evaluate!(::$(typeof(data)), container, system)` " * + "in the package that owns the concrete type.", + ) +end + +""" +Mark `data` as not-yet-solved for the current iteration. Called by +`calculate_aux_variables!` at the top of each pass. +""" +function reset!(data::AbstractEvaluationData) + error( + "reset! not implemented for evaluation data type $(typeof(data)). " * + "Define `reset!(::$(typeof(data)))` " * + "in the package that owns the concrete type.", + ) +end + +""" +Return `true` if `data` has been solved in the current iteration. +""" +function is_solved(data::AbstractEvaluationData) + error( + "is_solved not implemented for evaluation data type $(typeof(data)). " * + "Define `is_solved(::$(typeof(data))) ::Bool` " * + "in the package that owns the concrete type.", + ) +end + +""" +Return the underlying domain-specific data wrapped by `data` (e.g., a +`PowerFlowData` for a power-flow evaluator). Used by aux-variable calculators +that need direct read access to the evaluator's output. +""" +function get_inner_data(data::AbstractEvaluationData) + error( + "get_inner_data not implemented for evaluation data type $(typeof(data)). " * + "Define `get_inner_data(::$(typeof(data)))` " * + "in the package that owns the concrete type.", + ) +end From b30c70db600b71d15df1ea5d294adc2418884749 Mon Sep 17 00:00:00 2001 From: Jose Daniel Lara Date: Mon, 18 May 2026 08:48:05 -0600 Subject: [PATCH 7/9] add interfaces --- src/common_models/interfaces.jl | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/src/common_models/interfaces.jl b/src/common_models/interfaces.jl index dad5885e..538793a0 100644 --- a/src/common_models/interfaces.jl +++ b/src/common_models/interfaces.jl @@ -203,19 +203,6 @@ function start_up_cost( ) end -""" -Extension point: Solve the power flow model and update aux-variable inputs. -Signature: `solve_power_flow!(pf_e_data, container, system)`. Concrete -implementations require PowerFlows integration; provided in PowerOperationsModels. -""" -function solve_power_flow! end - -""" -Extension point: Get the underlying `PowerFlowData` from a `PowerFlowEvaluationData` wrapper. -Concrete implementation lives in the PowerFlows extension of POM. -""" -function get_power_flow_data end - """ Extension point: Calculate auxiliary variable values. Concrete implementations in PowerOperationsModels for specific aux variable types. @@ -223,10 +210,11 @@ Concrete implementations in PowerOperationsModels for specific aux variable type function calculate_aux_variable_value! end """ -Extension point: Check if an auxiliary variable type comes from power flow evaluation. -Default: false. Override in POM for PowerFlowAuxVariableType subtypes. +Extension point: Check if an auxiliary variable type is produced by an external +evaluator (see `AbstractEvaluator`). Default: false. Override in POM for +evaluator-bound aux variable subtypes. """ -is_from_power_flow(::Type{<:AuxVariableType}) = false +is_from_evaluator(::Type{<:AuxVariableType}) = false """ Extension point: Get minimum and maximum limits for a given component, constraint type, and device formulation. From 0b8cf33bdb76e6ca1b72fda0eb93c71dedfd1c47 Mon Sep 17 00:00:00 2001 From: Jose Daniel Lara Date: Mon, 18 May 2026 08:48:15 -0600 Subject: [PATCH 8/9] update container --- src/core/network_model.jl | 26 ++++----- src/core/optimization_container.jl | 57 ++++++++++--------- test/InfrastructureOptimizationModelsTests.jl | 1 + 3 files changed, 40 insertions(+), 44 deletions(-) diff --git a/src/core/network_model.jl b/src/core/network_model.jl index 2fa49276..f619460c 100644 --- a/src/core/network_model.jl +++ b/src/core/network_model.jl @@ -12,11 +12,6 @@ function _check_pm_formulation(::Type{T}) where {T <: AbstractPowerModel} end end -# Note: PowerFlowEvaluationModel support has been moved to PowerOperationsModels -# These stub functions maintain API compatibility -_maybe_flatten_pfem(pfem::Vector) = pfem -_maybe_flatten_pfem(pfem) = [pfem] - """ Establishes the NetworkModel for a given AC network formulation type. @@ -39,8 +34,9 @@ Establishes the NetworkModel for a given AC network formulation type. subnetworks are inferred from PTDF/VirtualPTDF or discovered from the system. - `duals::Vector{DataType}` = Vector{DataType}() Constraint types for which duals should be recorded. -- `power_flow_evaluation::Union{AbstractPowerFlowEvaluationModel, Vector{<:AbstractPowerFlowEvaluationModel}}` - Power-flow evaluation model(s). A single model is flattened to a vector internally. +- `evaluations::EvaluationContainer` + External evaluators (e.g. power-flow) keyed by concrete evaluator type. + Default is an empty container — no evaluator runs. # Notes - `modeled_branch_types` and `reduced_branch_tracker` are internal fields managed by the model. @@ -51,8 +47,10 @@ Establishes the NetworkModel for a given AC network formulation type. # Examples (concrete types like PTDFPowerModel, CopperPlatePowerModel are defined in PowerSimulations) # ptdf = PNM.VirtualPTDF(system) +# ec = EvaluationContainer() +# add_evaluator!(ec, PFS.PowerFlowEvaluationModel, PFS.PowerFlowEvaluationModel()) # nw = NetworkModel(PTDFPowerModel; PTDF_matrix = ptdf, reduce_radial_branches = true, -# power_flow_evaluation = PFS.PowerFlowEvaluationModel()) +# evaluations = ec) # # nw2 = NetworkModel(CopperPlatePowerModel; subnetworks = Dict(1 => Set([1,2,3]))) """ @@ -66,11 +64,7 @@ mutable struct NetworkModel{T <: AbstractPowerModel} network_reduction::PNM.NetworkReductionData reduce_radial_branches::Bool reduce_degree_two_branches::Bool - # Stored as Vector{<:Any} so external power flow types (e.g., - # `PowerFlows.PowerFlowEvaluationModel`) that don't subtype IS's - # `AbstractPowerFlowEvaluationModel` can be supplied here. The actual - # validation/dispatch happens in `add_power_flow_data!` in POM/PowerFlowsExt. - power_flow_evaluation::Vector + evaluations::EvaluationContainer subsystem::Union{Nothing, String} hvdc_network_model::Union{Nothing, AbstractHVDCNetworkModel} modeled_branch_types::Vector{DataType} @@ -85,7 +79,7 @@ mutable struct NetworkModel{T <: AbstractPowerModel} reduce_degree_two_branches = false, subnetworks = Dict{Int, Set{Int}}(), duals = Vector{DataType}(), - power_flow_evaluation = AbstractPowerFlowEvaluationModel[], + evaluations = EvaluationContainer(), hvdc_network_model = nothing, ) where {T <: AbstractPowerModel} _check_pm_formulation(T) @@ -99,7 +93,7 @@ mutable struct NetworkModel{T <: AbstractPowerModel} PNM.NetworkReductionData(), reduce_radial_branches, reduce_degree_two_branches, - _maybe_flatten_pfem(power_flow_evaluation), + evaluations, nothing, hvdc_network_model, Vector{DataType}(), @@ -120,7 +114,7 @@ get_reference_buses(m::NetworkModel{T}) where {T <: AbstractPowerModel} = collect(keys(m.subnetworks)) get_subnetworks(m::NetworkModel) = m.subnetworks get_bus_area_map(m::NetworkModel) = m.bus_area_map -get_power_flow_evaluation(m::NetworkModel) = m.power_flow_evaluation +get_evaluations(m::NetworkModel) = m.evaluations has_subnetworks(m::NetworkModel) = !isempty(m.bus_area_map) get_subsystem(m::NetworkModel) = m.subsystem get_hvdc_network_model(m::NetworkModel) = m.hvdc_network_model diff --git a/src/core/optimization_container.jl b/src/core/optimization_container.jl index 4ea03a97..598fbfd3 100644 --- a/src/core/optimization_container.jl +++ b/src/core/optimization_container.jl @@ -79,11 +79,11 @@ mutable struct OptimizationContainer <: AbstractOptimizationContainer model_base_power::Float64 optimizer_stats::OptimizerStats built_for_recurrent_solves::Bool - pf_aux_var_keys::Vector{AuxVarKey} - non_pf_aux_var_keys::Vector{AuxVarKey} + evaluator_aux_var_keys::Vector{AuxVarKey} + standalone_aux_var_keys::Vector{AuxVarKey} metadata::OptimizationContainerMetadata default_time_series_type::Type - power_flow_evaluation_data::Vector{<:AbstractPowerFlowEvaluationData} + evaluations::EvaluationContainer end function OptimizationContainer( @@ -127,7 +127,7 @@ function OptimizationContainer( AuxVarKey[], OptimizationContainerMetadata(), T, - AbstractPowerFlowEvaluationData[], + EvaluationContainer(), ) end @@ -165,8 +165,7 @@ get_jump_model(container::OptimizationContainer) = container.JuMPmodel get_metadata(container::OptimizationContainer) = container.metadata get_optimizer_stats(container::OptimizationContainer) = container.optimizer_stats get_parameters(container::OptimizationContainer) = container.parameters -get_power_flow_evaluation_data(container::OptimizationContainer) = - container.power_flow_evaluation_data +get_evaluations(container::OptimizationContainer) = container.evaluations get_resolution(container::OptimizationContainer) = get_resolution(container.settings) get_settings(container::OptimizationContainer) = container.settings get_time_steps(container::OptimizationContainer) = container.time_steps @@ -180,9 +179,9 @@ is_synchronized(container::OptimizationContainer) = set_time_steps!(container::OptimizationContainer, time_steps::UnitRange{Int64}) = container.time_steps = time_steps -function reset_power_flow_is_solved!(container::OptimizationContainer) - for pf_e_data in get_power_flow_evaluation_data(container) - pf_e_data.is_solved = false +function reset_evaluations!(container::OptimizationContainer) + for data in values(get_evaluation_data(get_evaluations(container))) + reset!(data) end end @@ -352,8 +351,8 @@ function reset_optimization_model!(container::OptimizationContainer) for field in [:variables, :aux_variables, :constraints, :expressions, :duals] empty!(getfield(container, field)) end - empty!(container.pf_aux_var_keys) - empty!(container.non_pf_aux_var_keys) + empty!(container.evaluator_aux_var_keys) + empty!(container.standalone_aux_var_keys) container.initial_conditions_data = InitialConditionsData() container.objective_function = ObjectiveFunction() container.primal_values_cache = PrimalValuesCache() @@ -1230,9 +1229,9 @@ end function _cache_aux_variable_key_partitions!(container::OptimizationContainer) aux_var_keys = keys(get_aux_variables(container)) - pf_keys = filter(is_from_power_flow ∘ get_entry_type, aux_var_keys) - container.pf_aux_var_keys = collect(pf_keys) - container.non_pf_aux_var_keys = collect(setdiff(aux_var_keys, pf_keys)) + evaluator_keys = filter(is_from_evaluator ∘ get_entry_type, aux_var_keys) + container.evaluator_aux_var_keys = collect(evaluator_keys) + container.standalone_aux_var_keys = collect(setdiff(aux_var_keys, evaluator_keys)) return end @@ -1240,28 +1239,30 @@ function calculate_aux_variables!( container::OptimizationContainer, system::IS.InfrastructureSystemsContainer, ) - if isempty(container.pf_aux_var_keys) && isempty(container.non_pf_aux_var_keys) + if isempty(container.evaluator_aux_var_keys) && + isempty(container.standalone_aux_var_keys) _cache_aux_variable_key_partitions!(container) end - pf_aux_var_keys = container.pf_aux_var_keys - non_pf_aux_var_keys = container.non_pf_aux_var_keys - # We should only have power flow aux vars if we have power flow evaluators - @assert isempty(pf_aux_var_keys) || !isempty(get_power_flow_evaluation_data(container)) - - TimerOutputs.@timeit RUN_SIMULATION_TIMER "Power Flow Evaluation" begin - reset_power_flow_is_solved!(container) - # Power flow-related aux vars get calculated once per power flow - for (i, pf_e_data) in enumerate(get_power_flow_evaluation_data(container)) - @debug "Processing power flow $i" - solve_power_flow!(pf_e_data, container, system) - for key in pf_aux_var_keys + evaluator_aux_var_keys = container.evaluator_aux_var_keys + standalone_aux_var_keys = container.standalone_aux_var_keys + evaluation_data = get_evaluation_data(get_evaluations(container)) + # We should only have evaluator-bound aux vars if we have evaluators registered + @assert isempty(evaluator_aux_var_keys) || !isempty(evaluation_data) + + TimerOutputs.@timeit RUN_SIMULATION_TIMER "External Evaluation" begin + reset_evaluations!(container) + # Evaluator-bound aux vars get calculated once per evaluator + for (T, data) in evaluation_data + @debug "Processing evaluator $(T)" + evaluate!(data, container, system) + for key in evaluator_aux_var_keys calculate_aux_variable_value!(container, key, system) end end end # Other aux vars get calculated once at the end - for key in non_pf_aux_var_keys + for key in standalone_aux_var_keys calculate_aux_variable_value!(container, key, system) end return RunStatus.SUCCESSFULLY_FINALIZED diff --git a/test/InfrastructureOptimizationModelsTests.jl b/test/InfrastructureOptimizationModelsTests.jl index f218b685..97968b5e 100644 --- a/test/InfrastructureOptimizationModelsTests.jl +++ b/test/InfrastructureOptimizationModelsTests.jl @@ -106,6 +106,7 @@ function run_tests() include(joinpath(TEST_DIR, "test_optimization_container_metadata.jl")) # optimization_container_types.jl: no need for tests include(joinpath(TEST_DIR, "test_optimization_container.jl")) + include(joinpath(TEST_DIR, "test_external_evaluation.jl")) # optimization_problem_outputs_export.jl: low-complexity include(joinpath(TEST_DIR, "test_optimization_outputs.jl")) include(joinpath(TEST_DIR, "test_optimizer_stats.jl")) From 1980bab2870fa6c53624572fb3655e34ed19297c Mon Sep 17 00:00:00 2001 From: Jose Daniel Lara Date: Mon, 18 May 2026 08:48:21 -0600 Subject: [PATCH 9/9] testing --- src/InfrastructureOptimizationModels.jl | 33 +++--- test/test_basic_model_structs.jl | 27 +++-- test/test_external_evaluation.jl | 132 ++++++++++++++++++++++++ 3 files changed, 172 insertions(+), 20 deletions(-) create mode 100644 test/test_external_evaluation.jl diff --git a/src/InfrastructureOptimizationModels.jl b/src/InfrastructureOptimizationModels.jl index 6fa5498e..b7b500e2 100644 --- a/src/InfrastructureOptimizationModels.jl +++ b/src/InfrastructureOptimizationModels.jl @@ -59,9 +59,7 @@ import InfrastructureSystems.Optimization: AbstractStorageFormulation, AbstractLoadFormulation, AbstractHVDCNetworkModel, - AbstractPowerModel, - AbstractPowerFlowEvaluationModel, - AbstractPowerFlowEvaluationData + AbstractPowerModel import InfrastructureSystems: @scoped_enum, @@ -170,7 +168,7 @@ export InitialCondition export NetworkModel export get_PTDF_matrix, get_LODF_matrix, get_reduce_radial_branches export get_duals, get_reference_buses, get_subnetworks, get_bus_area_map -export get_power_flow_evaluation, has_subnetworks, get_subsystem +export get_evaluations, has_subnetworks, get_subsystem export set_subsystem!, add_dual! export requires_all_branch_models, supports_branch_filtering, ignores_branch_filtering export validate_network_model @@ -377,7 +375,7 @@ export get_incompatible_devices # Bulk export: symbols POM needs that weren't previously exported # Core types -export OptimizationContainer, OperationModel, AbstractPowerFlowEvaluationModel +export OptimizationContainer, OperationModel export ArgumentConstructStage, ModelConstructStage export EmulationModelStore, DeviceModelForBranches export SOSStatusVariable @@ -476,14 +474,22 @@ export SparseVariableType, InterpolationVariableType, BinaryInterpolationVariabl export UpperBound, LowerBound, BoundDirection, get_bound_direction export EventParameter -# Abstract types for extensions (from InfrastructureSystems.Optimization) -export AbstractPowerFlowEvaluationData - -# Power flow in-the-loop extension points and helpers -export solve_power_flow! -export get_power_flow_data -export get_power_flow_evaluation_data -export reset_power_flow_is_solved! +# External evaluation abstraction (replaces the PowerFlows-specific shims) +export AbstractEvaluator +export AbstractEvaluationData +export EvaluationContainer +export initialize_evaluation_data +export evaluate! +export reset! +export is_solved +export get_evaluation_data +export get_inner_data +export get_evaluators +export get_evaluator +export add_evaluator! +export add_evaluation_data! +export reset_evaluations! +export is_from_evaluator export lookup_value export get_entry_type export get_component_names @@ -566,6 +572,7 @@ include("core/operation_model_abstract_types.jl") include("core/network_reductions.jl") include("core/service_model.jl") include("core/device_model.jl") +include("core/external_evaluation.jl") include("core/network_model.jl") include("core/initial_conditions.jl") include("core/settings.jl") diff --git a/test/test_basic_model_structs.jl b/test/test_basic_model_structs.jl index 519e6f09..ecbae949 100644 --- a/test/test_basic_model_structs.jl +++ b/test/test_basic_model_structs.jl @@ -6,17 +6,25 @@ end @testset "NetworkModel Tests" begin @test_throws ArgumentError NetworkModel(AbstractPowerModel) + ec_multi = EvaluationContainer() + add_evaluator!(ec_multi, DCPowerFlow, DCPowerFlow()) + add_evaluator!( + ec_multi, + PSSEExportPowerFlow, + PSSEExportPowerFlow(:v33, "exports"), + ) @test NetworkModel( PTDFPowerModel; use_slacks = true, - power_flow_evaluation = [DCPowerFlow(), PSSEExportPowerFlow(:v33, "exports")], + evaluations = ec_multi, ) isa NetworkModel - @test NetworkModel( - PTDFPowerModel; - use_slacks = true, - power_flow_evaluation = ACPowerFlow(; - exporter = - PSSEExportPowerFlow( + + ec_ac = EvaluationContainer() + add_evaluator!( + ec_ac, + ACPowerFlow, + ACPowerFlow(; + exporter = PSSEExportPowerFlow( :v33, "exports"; name = "my_export_name", @@ -24,6 +32,11 @@ end overwrite = true, ), ), + ) + @test NetworkModel( + PTDFPowerModel; + use_slacks = true, + evaluations = ec_ac, ) isa NetworkModel end diff --git a/test/test_external_evaluation.jl b/test/test_external_evaluation.jl new file mode 100644 index 00000000..55a15362 --- /dev/null +++ b/test/test_external_evaluation.jl @@ -0,0 +1,132 @@ +import InfrastructureOptimizationModels: + AbstractEvaluator, + AbstractEvaluationData, + EvaluationContainer, + get_evaluators, + get_evaluation_data, + get_evaluator, + add_evaluator!, + add_evaluation_data!, + initialize_evaluation_data, + evaluate!, + reset!, + is_solved, + get_inner_data, + is_from_evaluator + +# Concrete-but-minimal subtypes for interface-stub coverage. No methods registered. +struct DummyEvaluator <: AbstractEvaluator end +struct DummyEvaluationData <: AbstractEvaluationData end + +# Full mock implementation used to exercise the dispatch path end-to-end. +struct MockEvaluator <: AbstractEvaluator + tag::Symbol +end + +mutable struct MockEvaluationData <: AbstractEvaluationData + evaluator::MockEvaluator + solved::Bool + payload::Vector{Float64} +end + +IOM.initialize_evaluation_data(ev::MockEvaluator, _container, _system) = + MockEvaluationData(ev, false, Float64[]) + +function IOM.evaluate!(data::MockEvaluationData, _container, _system) + push!(data.payload, length(data.payload) + 1.0) + data.solved = true + return +end + +IOM.reset!(data::MockEvaluationData) = (data.solved = false; return) +IOM.is_solved(data::MockEvaluationData) = data.solved +IOM.get_inner_data(data::MockEvaluationData) = data.payload + +@testset "EvaluationContainer CRUD" begin + ec = EvaluationContainer() + @test ec isa EvaluationContainer + @test isempty(ec) + @test length(ec) == 0 + @test !haskey(ec, MockEvaluator) + @test isempty(get_evaluators(ec)) + @test isempty(get_evaluation_data(ec)) + + ev = MockEvaluator(:primary) + add_evaluator!(ec, MockEvaluator, ev) + @test !isempty(ec) + @test length(ec) == 1 + @test haskey(ec, MockEvaluator) + @test get_evaluator(ec, MockEvaluator) === ev + + data = MockEvaluationData(ev, false, Float64[]) + add_evaluation_data!(ec, MockEvaluator, data) + @test get_evaluation_data(ec, MockEvaluator) === data + @test length(get_evaluation_data(ec)) == 1 +end + +@testset "AbstractEvaluator interface stubs throw with type names" begin + container = nothing + system = nothing + dummy_ev = DummyEvaluator() + dummy_data = DummyEvaluationData() + + err1 = @test_throws ErrorException initialize_evaluation_data( + dummy_ev, + container, + system, + ) + @test occursin("DummyEvaluator", err1.value.msg) + + err2 = @test_throws ErrorException evaluate!(dummy_data, container, system) + @test occursin("DummyEvaluationData", err2.value.msg) + + err3 = @test_throws ErrorException reset!(dummy_data) + @test occursin("DummyEvaluationData", err3.value.msg) + + err4 = @test_throws ErrorException is_solved(dummy_data) + @test occursin("DummyEvaluationData", err4.value.msg) + + err5 = @test_throws ErrorException get_inner_data(dummy_data) + @test occursin("DummyEvaluationData", err5.value.msg) +end + +@testset "MockEvaluator end-to-end dispatch" begin + ec = EvaluationContainer() + ev = MockEvaluator(:e1) + add_evaluator!(ec, MockEvaluator, ev) + data = initialize_evaluation_data(ev, nothing, nothing) + add_evaluation_data!(ec, MockEvaluator, data) + + @test !is_solved(data) + evaluate!(data, nothing, nothing) + @test is_solved(data) + @test get_inner_data(data) == [1.0] + + # Iterate via the dict view, as `calculate_aux_variables!` does + for (T, d) in get_evaluation_data(ec) + reset!(d) + end + @test !is_solved(data) + + evaluate!(data, nothing, nothing) + @test is_solved(data) + @test get_inner_data(data) == [1.0, 2.0] +end + +@testset "is_from_evaluator default is false" begin + @test is_from_evaluator(MockAuxVariable) === false +end + +@testset "NetworkModel evaluations field defaults to empty" begin + nw = IOM.NetworkModel(TestPowerModel) + @test IOM.get_evaluations(nw) isa EvaluationContainer + @test isempty(IOM.get_evaluations(nw)) +end + +@testset "NetworkModel evaluations accepts a populated container" begin + ec = EvaluationContainer() + add_evaluator!(ec, MockEvaluator, MockEvaluator(:populated)) + nw = IOM.NetworkModel(TestPowerModel; evaluations = ec) + @test IOM.get_evaluations(nw) === ec + @test haskey(IOM.get_evaluations(nw), MockEvaluator) +end