From 212788790312b5cc5bd56b07c54c56c139ee29cc Mon Sep 17 00:00:00 2001 From: rodrigomha Date: Thu, 13 Aug 2026 08:43:34 -0700 Subject: [PATCH 01/23] Add GroupStepwiseCostReserve: elastic group ORDC over member-service supply One demand curve on a PSY.GroupReserve is cleared by the summed awards of its contributing services: a dense ServiceRequirementVariable per group, a clearing constraint sum(member awards) >= demand variable (its dual is the group price), and the group curve priced through the delta-PWL path. Static and time-series group curves are both supported via the existing service-side TS machinery. - Group demand predicates mirror the service formulations: GroupRangeReserve is driven by the scalar requirement, GroupStepwiseCostReserve by the demand curve; degenerate groups skip as supply aggregates with a warning. - Group deferral generalized to a vector so up and down groups coexist. - RESERVE_PRODUCT_TYPES (definitions.jl) consolidates the open Union{PSY.AbstractReserve, PSY.GroupReserve} signature bound used across the reserve traits, PWL parameter chain, and objective plumbing. - Formulation-pairing guards: a GroupReserve accepts only group formulations and vice versa, failing with ArgumentError at ServiceModel declaration. - Tests cover build/solve, aggregation binding, merit order, no-group baseline, the degenerate skip, TS group curves, and the pairing guards. --- src/PowerOperationsModels.jl | 1 + src/common_models/add_expressions.jl | 24 +++ src/common_models/add_parameters.jl | 18 +- src/common_models/market_bid_overrides.jl | 2 +- src/common_models/market_bid_plumbing.jl | 2 +- src/core/definitions.jl | 6 + src/core/formulations.jl | 7 + src/core/reserve_traits.jl | 4 +- src/services_models/reserve_group.jl | 80 +++++-- src/services_models/reserves.jl | 77 +++++-- src/services_models/services_constructor.jl | 140 +++++++++--- test/test_group_stepwise_reserve.jl | 228 ++++++++++++++++++++ 12 files changed, 520 insertions(+), 69 deletions(-) create mode 100644 test/test_group_stepwise_reserve.jl diff --git a/src/PowerOperationsModels.jl b/src/PowerOperationsModels.jl index 1795d544..7cad215a 100644 --- a/src/PowerOperationsModels.jl +++ b/src/PowerOperationsModels.jl @@ -923,6 +923,7 @@ export AbstractServiceFormulation export AbstractReservesFormulation export PIDSmoothACE export GroupRangeReserve +export GroupStepwiseCostReserve export RangeReserve export StepwiseCostReserve export RampReserve diff --git a/src/common_models/add_expressions.jl b/src/common_models/add_expressions.jl index c4e2872b..0b1ed79d 100644 --- a/src/common_models/add_expressions.jl +++ b/src/common_models/add_expressions.jl @@ -244,3 +244,27 @@ function add_expressions!( ) return end + +# Group sibling of the reserve method above (`GroupReserve <: Service`, so it cannot share +# the `V <: PSY.AbstractReserve` bound; a `Union` bound on `V` would be ambiguous against it). +function add_expressions!( + container::OptimizationContainer, + ::Type{T}, + services::U, + model::ServiceModel{V, W}, +) where { + T <: CostExpressions, + U <: Union{Vector{D}, IS.FlattenIteratorWrapper{D}}, + V <: PSY.GroupReserve, + W <: AbstractReservesFormulation, +} where {D <: PSY.Component} + time_steps = get_time_steps(container) + add_expression_container!( + container, + T, + D, + [PSY.get_name(s) for s in services], + time_steps, + ) + return +end diff --git a/src/common_models/add_parameters.jl b/src/common_models/add_parameters.jl index d28b37e2..d08b3281 100644 --- a/src/common_models/add_parameters.jl +++ b/src/common_models/add_parameters.jl @@ -70,8 +70,8 @@ function add_parameters!( AbstractPiecewiseLinearSlopeParameter, AbstractPiecewiseLinearBreakpointParameter, }, - U <: PSY.AbstractReserve, - V <: PSY.AbstractReserve, + U <: RESERVE_PRODUCT_TYPES, + V <: RESERVE_PRODUCT_TYPES, W <: AbstractServiceFormulation, } if get_rebuild_model(get_settings(container)) && has_container_key(container, T, U) @@ -424,7 +424,7 @@ _get_time_series_name( DecrementalPiecewiseLinearBreakpointParameter, }, }, - service::PSY.AbstractReserve, + service::RESERVE_PRODUCT_TYPES, ::ServiceModel, ) = IS.get_name(IS.get_time_series_key(PSY.get_variable(service))) @@ -590,8 +590,8 @@ _ordc_ts_data(ts::IS.DeterministicSingleTimeSeries) = function calc_additional_axes( ::OptimizationContainer, ::Type{P}, - services::Vector{<:PSY.AbstractReserve}, - ::ServiceModel{<:PSY.AbstractReserve, W}, + services::Vector{<:RESERVE_PRODUCT_TYPES}, + ::ServiceModel{<:RESERVE_PRODUCT_TYPES, W}, ) where { P <: AbstractPiecewiseLinearSlopeParameter, W <: AbstractServiceFormulation, @@ -605,8 +605,8 @@ end function calc_additional_axes( ::OptimizationContainer, ::Type{P}, - services::Vector{<:PSY.AbstractReserve}, - ::ServiceModel{<:PSY.AbstractReserve, W}, + services::Vector{<:RESERVE_PRODUCT_TYPES}, + ::ServiceModel{<:RESERVE_PRODUCT_TYPES, W}, ) where { P <: AbstractPiecewiseLinearBreakpointParameter, W <: AbstractServiceFormulation, @@ -809,8 +809,8 @@ function _add_parameters!( AbstractPiecewiseLinearSlopeParameter, AbstractPiecewiseLinearBreakpointParameter, }, - U <: PSY.AbstractReserve, - V <: PSY.AbstractReserve, + U <: RESERVE_PRODUCT_TYPES, + V <: RESERVE_PRODUCT_TYPES, W <: AbstractServiceFormulation, } _add_objective_function_parameters!(container, T, services, model, W) diff --git a/src/common_models/market_bid_overrides.jl b/src/common_models/market_bid_overrides.jl index 7be945c1..8b20ea35 100644 --- a/src/common_models/market_bid_overrides.jl +++ b/src/common_models/market_bid_overrides.jl @@ -348,7 +348,7 @@ function add_pwl_term_delta!( ::Type{U}, ::Type{V}, ) where { - T <: PSY.AbstractReserve, + T <: RESERVE_PRODUCT_TYPES, U <: VariableType, V <: AbstractServiceFormulation, } diff --git a/src/common_models/market_bid_plumbing.jl b/src/common_models/market_bid_plumbing.jl index 1c32006d..fa73a602 100644 --- a/src/common_models/market_bid_plumbing.jl +++ b/src/common_models/market_bid_plumbing.jl @@ -110,7 +110,7 @@ get_offer_curves(::IOM.IncrementalOffer, op_cost::PSY.OfferCurveCost) = # service-side direction trait (`_reserve_offer_direction`) is decremental. get_offer_curves( ::IOM.OfferDirection, - service::PSY.AbstractReserve, + service::RESERVE_PRODUCT_TYPES, ) = PSY.get_variable(service) ################################################################################# diff --git a/src/core/definitions.jl b/src/core/definitions.jl index 653d3a92..0dae365c 100644 --- a/src/core/definitions.jl +++ b/src/core/definitions.jl @@ -112,4 +112,10 @@ const IGNORABLE_FILES = [ ] const OUTPUTS_DIR = "outputs" +# Any reserve product that carries demand-side state (requirement / demand curve): the +# device-backed reserve tree plus service-aggregating groups (`PSY.GroupReserve <: Service`, +# outside that tree). `AbstractReserve` keeps the union OPEN to future reserve subtypes. +# Signature-position only - never use as a field type or container eltype. +const RESERVE_PRODUCT_TYPES = Union{PSY.AbstractReserve, PSY.GroupReserve} + IS.@scoped_enum(COMPACT_PWL_STATUS, VALID = 1, INVALID = 2, UNDETERMINED = 3) diff --git a/src/core/formulations.jl b/src/core/formulations.jl index 94a3615c..67ffa7f0 100644 --- a/src/core/formulations.jl +++ b/src/core/formulations.jl @@ -436,6 +436,13 @@ with the `PSY.GroupReserve` component type. """ struct GroupRangeReserve <: AbstractReservesFormulation end +""" +Group analogue of [`StepwiseCostReserve`](@ref): one elastic demand curve (the `PSY.GroupReserve`'s +`variable`) is met by the summed awards of its contributing services - one demand, one clearing +price, with offers and caps living on the members. Ignores the group's `requirement`. +""" +struct GroupStepwiseCostReserve <: AbstractReservesFormulation end + """ Struct for to add reserves to be larger than a specified requirement """ diff --git a/src/core/reserve_traits.jl b/src/core/reserve_traits.jl index b770da79..5f00b60c 100644 --- a/src/core/reserve_traits.jl +++ b/src/core/reserve_traits.jl @@ -60,8 +60,8 @@ end # (union-splits cleanly over the two `variable` members; reserves are few and read at build, # so the cost is negligible). -"Whether a reserve's ORDC curve is time-varying. Dispatches on the value-curve type (no `isa`)." -_ordc_is_ts(s::PSY.AbstractReserve) = +"Whether a reserve's or group's ORDC curve is time-varying. Dispatches on the value-curve type (no `isa`)." +_ordc_is_ts(s::RESERVE_PRODUCT_TYPES) = _value_curve_is_ts(PSY.get_value_curve(PSY.get_variable(s))) _value_curve_is_ts(::PSY.TimeSeriesPiecewiseIncrementalCurve) = true _value_curve_is_ts(::PSY.PiecewiseIncrementalCurve) = false diff --git a/src/services_models/reserve_group.jl b/src/services_models/reserve_group.jl index e3b0276f..c495c1b5 100644 --- a/src/services_models/reserve_group.jl +++ b/src/services_models/reserve_group.jl @@ -10,6 +10,18 @@ function get_default_attributes( return Dict{String, Any}() end +function get_default_time_series_names( + ::Type{PSY.GroupReserve{T}}, + ::Type{GroupStepwiseCostReserve}) where {T <: PSY.ReserveDirection} + return Dict{String, Any}() +end + +function get_default_attributes( + ::Type{PSY.GroupReserve{T}}, + ::Type{GroupStepwiseCostReserve}) where {T <: PSY.ReserveDirection} + return Dict{String, Any}() +end + # ── Formulation-pairing guards ──────────────────────────────────────────────────────── # A `PSY.GroupReserve` aggregates other services, so only group formulations can model it, # and group formulations can model nothing else. These fallbacks fire inside the @@ -17,13 +29,15 @@ end # model fails at DECLARATION with an actionable message instead of a cryptic dispatch error # (or a silent no-op) at build time. The valid direction-applied pairs above are more # specific and win. +const _GROUP_FORMULATIONS = Union{GroupRangeReserve, GroupStepwiseCostReserve} function _throw_group_pairing_error(D::Type, B::Type) throw( ArgumentError( "ServiceModel($(D), $(B)) is invalid: `PSY.GroupReserve` aggregates other \ - services and must use a group formulation (e.g. GroupRangeReserve), and group \ - formulations apply only to `PSY.GroupReserve`.", + services and must use a group formulation (GroupRangeReserve or \ + GroupStepwiseCostReserve), and group formulations apply only to \ + `PSY.GroupReserve`.", ), ) end @@ -57,32 +71,36 @@ get_default_attributes( get_default_time_series_names( ::Type{D}, - ::Type{GroupRangeReserve}, -) where {D <: PSY.AbstractReserve} = _throw_group_pairing_error(D, GroupRangeReserve) + ::Type{B}, +) where {D <: PSY.AbstractReserve, B <: _GROUP_FORMULATIONS} = + _throw_group_pairing_error(D, B) get_default_attributes( ::Type{D}, - ::Type{GroupRangeReserve}, -) where {D <: PSY.AbstractReserve} = _throw_group_pairing_error(D, GroupRangeReserve) + ::Type{B}, +) where {D <: PSY.AbstractReserve, B <: _GROUP_FORMULATIONS} = + _throw_group_pairing_error(D, B) # Disambiguates the two guards' intersection (`GroupReserve <: AbstractReserve`) and gives # the bare-type declaration an actionable message. -_throw_group_direction_error(D::Type) = throw( +_throw_group_direction_error(D::Type, B::Type) = throw( ArgumentError( - "ServiceModel($(D), GroupRangeReserve) needs the reserve direction applied, \ - e.g. `ServiceModel(GroupReserve{ReserveUp}, GroupRangeReserve)`.", + "ServiceModel($(D), $(B)) needs the reserve direction applied, \ + e.g. `ServiceModel(GroupReserve{ReserveUp}, $(B))`.", ), ) get_default_time_series_names( ::Type{D}, - ::Type{GroupRangeReserve}, -) where {D <: PSY.GroupReserve} = _throw_group_direction_error(D) + ::Type{B}, +) where {D <: PSY.GroupReserve, B <: _GROUP_FORMULATIONS} = + _throw_group_direction_error(D, B) get_default_attributes( ::Type{D}, - ::Type{GroupRangeReserve}, -) where {D <: PSY.GroupReserve} = _throw_group_direction_error(D) + ::Type{B}, +) where {D <: PSY.GroupReserve, B <: _GROUP_FORMULATIONS} = + _throw_group_direction_error(D, B) ############################### Reserve Variables` ######################################### """ @@ -140,6 +158,42 @@ function add_constraints!( return end +################################ Group Stepwise (elastic) clearing ########################## +""" +Clearing constraint for [`GroupStepwiseCostReserve`](@ref): the summed member awards cover the +group's `ServiceRequirementVariable` (the demand bought along the group's curve). Its dual is +the group clearing price. +""" +function add_constraints!( + container::OptimizationContainer, + ::Type{RequirementConstraint}, + service::SR, + contributing_services::Vector{<:PSY.Service}, + model::ServiceModel{SR, GroupStepwiseCostReserve}, +) where {SR <: PSY.GroupReserve} + time_steps = get_time_steps(container) + service_name = PSY.get_name(service) + constraint = get_constraint(container, RequirementConstraint, SR) + requirement_variable = get_variable(container, ServiceRequirementVariable, SR) + + member_vars = _group_member_variables(container, contributing_services, time_steps) + jump_model = get_jump_model(container) + + for t in time_steps + vars = member_vars[t] + resource_expression = IOM.get_hinted_aff_expr(length(vars)) + for var in vars + JuMP.add_to_expression!(resource_expression, var) + end + constraint[service_name, t] = JuMP.@constraint( + jump_model, + resource_expression >= requirement_variable[service_name, t] + ) + end + + return +end + # Collect the group's contributing reserve variables into one bucket per time step, so the # constraint loop above indexes straight in rather than re-scanning per `(group, t)`. Services # of the same type share one `(service_name, device_name, time)` container, so each container diff --git a/src/services_models/reserves.jl b/src/services_models/reserves.jl index d4bec34b..3ee0472d 100644 --- a/src/services_models/reserves.jl +++ b/src/services_models/reserves.jl @@ -12,10 +12,12 @@ end get_variable_lower_bound(::Type{ActivePowerReserveVariable}, ::PSY.AbstractReserve, ::PSY.Device, ::Type) = 0.0 ############################### ServiceRequirementVariable (ORDC / StepwiseCostReserve) ################################ -# Only created on the StepwiseCostReserve construct path, so the formulation gates these to ORDC reserves. -get_variable_binary(::Type{ServiceRequirementVariable}, ::Type{<:PSY.AbstractReserve}, ::Type{<:AbstractReservesFormulation}) = false -get_variable_upper_bound(::Type{ServiceRequirementVariable}, ::PSY.AbstractReserve, d::PSY.Component, ::Type{<:AbstractReservesFormulation}) = PSY.get_max_active_power(d, PSY.SU) -get_variable_lower_bound(::Type{ServiceRequirementVariable}, ::PSY.AbstractReserve, ::PSY.Component, ::Type{<:AbstractReservesFormulation}) = 0.0 +# Only created on the StepwiseCostReserve / GroupStepwiseCostReserve construct paths, so the +# formulation gates these to curve-bearing reserves and groups (`GroupReserve <: Service`, hence +# the Union). +get_variable_binary(::Type{ServiceRequirementVariable}, ::Type{<:RESERVE_PRODUCT_TYPES}, ::Type{<:AbstractReservesFormulation}) = false +get_variable_upper_bound(::Type{ServiceRequirementVariable}, ::RESERVE_PRODUCT_TYPES, d::PSY.Component, ::Type{<:AbstractReservesFormulation}) = PSY.get_max_active_power(d, PSY.SU) +get_variable_lower_bound(::Type{ServiceRequirementVariable}, ::RESERVE_PRODUCT_TYPES, ::PSY.Component, ::Type{<:AbstractReservesFormulation}) = 0.0 # Reserve requirement in system units; the getter is units-aware for every reserve type. _get_requirement(service) = PSY.get_requirement(service, PSY.SU) @@ -46,6 +48,18 @@ _has_reserve_demand( service::PSY.AbstractReserve, ) = PSY.has_demand_curve(service) +# Group formulations mirror the service ones: `GroupRangeReserve` is driven by the group's +# scalar requirement, `GroupStepwiseCostReserve` by its demand curve (`requirement` ignored). +_has_reserve_demand( + ::ServiceModel{<:PSY.GroupReserve, GroupRangeReserve}, + group::PSY.GroupReserve, +) = !iszero(_get_requirement(group)) + +_has_reserve_demand( + ::ServiceModel{<:PSY.GroupReserve, GroupStepwiseCostReserve}, + group::PSY.GroupReserve, +) = PSY.has_demand_curve(group) + "Services in `services` that impose a demand of their own under `model`'s formulation." _demand_services(model::ServiceModel, services::Vector{<:PSY.AbstractReserve}) = [s for s in services if _has_reserve_demand(model, s)] @@ -65,12 +79,12 @@ _is_group_member(::PSY.System, ::PSY.GroupReserve) = false function _log_skipped_reserve_demand( sys::PSY.System, - service::PSY.AbstractReserve, - ::ServiceModel{<:PSY.AbstractReserve, F}, + service::RESERVE_PRODUCT_TYPES, + ::ServiceModel{<:RESERVE_PRODUCT_TYPES, F}, ) where {F <: AbstractReservesFormulation} name = PSY.get_name(service) - reason = F === StepwiseCostReserve ? "it has no operating reserve demand curve" : - "its requirement is zero" + reason = F in (StepwiseCostReserve, GroupStepwiseCostReserve) ? + "it has no operating reserve demand curve" : "its requirement is zero" if _is_group_member(sys, service) @debug "Service $(name) of type $(typeof(service)) is a GroupReserve member and $(reason); \ skipping its own demand-side model. It still contributes supply to its group." _group = @@ -90,15 +104,17 @@ get_parameter_multiplier(::Type{<:VariableValueParameter}, d::Type{<:PSY.Abstrac get_initial_parameter_value(::Type{<:VariableValueParameter}, d::Type{<:PSY.AbstractReserve}, ::Type{<:AbstractReservesFormulation}) = 0.0 objective_function_multiplier(::Type{ServiceRequirementVariable}, ::Type{StepwiseCostReserve}) = -1.0 +objective_function_multiplier(::Type{ServiceRequirementVariable}, ::Type{GroupStepwiseCostReserve}) = -1.0 uses_compact_power(::PSY.AbstractReserve, ::StepwiseCostReserve)=false -get_multiplier_value(::Type{<:AbstractPiecewiseLinearBreakpointParameter}, ::PSY.AbstractReserve, ::Type{<:AbstractReservesFormulation}) = 1.0 -get_multiplier_value(::Type{<:AbstractPiecewiseLinearSlopeParameter}, ::PSY.AbstractReserve, ::Type{<:AbstractReservesFormulation}) = 1.0 +uses_compact_power(::PSY.GroupReserve, ::GroupStepwiseCostReserve)=false +get_multiplier_value(::Type{<:AbstractPiecewiseLinearBreakpointParameter}, ::RESERVE_PRODUCT_TYPES, ::Type{<:AbstractReservesFormulation}) = 1.0 +get_multiplier_value(::Type{<:AbstractPiecewiseLinearSlopeParameter}, ::RESERVE_PRODUCT_TYPES, ::Type{<:AbstractReservesFormulation}) = 1.0 # Operating reserve demand curves (ORDC) are willingness-to-pay (concave), i.e. a decremental # offer. # Routes the reserve PWL cost path through IOM's OfferDirection dispatch; making # this incremental is a one-line change here. Mirrors `_onvar_offer_direction` / # `_vom_offer_direction` in market_bid_overrides.jl. -_reserve_offer_direction(::PSY.AbstractReserve) = IOM.DecrementalOffer() +_reserve_offer_direction(::RESERVE_PRODUCT_TYPES) = IOM.DecrementalOffer() #! format: on function get_initial_conditions_service_model( @@ -108,6 +124,14 @@ function get_initial_conditions_service_model( return ServiceModel(T, D) end +# `GroupReserve <: Service`, so the `AbstractReserve` method cannot cover it. +function get_initial_conditions_service_model( + ::IOM.AbstractOptimizationModel, + ::ServiceModel{T, D}, +) where {T <: PSY.GroupReserve, D <: AbstractReservesFormulation} + return ServiceModel(T, D) +end + function get_default_time_series_names( ::Type{<:PSY.Reserve}, ::Type{T}, @@ -150,7 +174,7 @@ function add_reserve_variables!( formulation, ) where { T <: ServiceRequirementVariable, - D <: PSY.AbstractReserve, + D <: RESERVE_PRODUCT_TYPES, } time_steps = get_time_steps(container) service_names = [PSY.get_name(s) for s in services] @@ -605,15 +629,32 @@ function add_to_objective_function!( return end +# The group's demand: price its ServiceRequirementVariable by the group demand curve (a +# benefit, multiplier -1). No offer costs on the group itself - offers live on the +# contributing services, priced by their own service models. +function add_to_objective_function!( + container::OptimizationContainer, + service::S, + ::ServiceModel{S, GroupStepwiseCostReserve}, +) where {S <: PSY.GroupReserve} + add_reserves_variable_cost!( + container, + ServiceRequirementVariable, + service, + GroupStepwiseCostReserve, + ) + return +end + function add_reserves_variable_cost!( container::OptimizationContainer, ::Type{U}, service::T, ::Type{V}, ) where { - T <: PSY.AbstractReserve, + T <: RESERVE_PRODUCT_TYPES, U <: VariableType, - V <: StepwiseCostReserve, + V <: Union{StepwiseCostReserve, GroupStepwiseCostReserve}, } _add_reserves_variable_cost_to_objective!(container, U, service, V) return @@ -622,9 +663,9 @@ end function _add_reserves_variable_cost_to_objective!( container::OptimizationContainer, ::Type{T}, - component::PSY.AbstractReserve, + component::RESERVE_PRODUCT_TYPES, ::Type{U}, -) where {T <: VariableType, U <: StepwiseCostReserve} +) where {T <: VariableType, U <: Union{StepwiseCostReserve, GroupStepwiseCostReserve}} component_name = PSY.get_name(component) @debug "PWL Variable Cost" _group = LOG_GROUP_COST_FUNCTIONS component_name # If array is full of tuples with zeros return 0.0 @@ -639,7 +680,7 @@ function _add_reserves_variable_cost_to_objective!( error( "Operating reserve demand curve $(component_name) has cost data of type \ $(typeof(variable_cost)), \ - but a `PSY.CostCurve` is required for the StepwiseCostReserve formulation.", + but a `PSY.CostCurve` is required for the $(U) formulation.", ) end @@ -677,7 +718,7 @@ function process_stepwise_cost_reserve_parameters!( container::OptimizationContainer, model::ServiceModel, services::Vector{D}, -) where {D <: PSY.AbstractReserve} +) where {D <: RESERVE_PRODUCT_TYPES} # Only time-series-backed ORDCs need the per-timestep slope/breakpoint parameters. ts_services = [s for s in services if _ordc_is_ts(s)] isempty(ts_services) && return diff --git a/src/services_models/services_constructor.jl b/src/services_models/services_constructor.jl index df166802..3cd4cd72 100644 --- a/src/services_models/services_constructor.jl +++ b/src/services_models/services_constructor.jl @@ -3,10 +3,16 @@ # reads each service's contributing devices from the nested per-service map # (`get_contributing_devices(model, service_name)`), and builds. Reserve variable and # constraint containers are shared per `(entry type, service type)`, with each service -# filling its own slice. `GroupRangeReserve` is deferred to last. +# filling its own slice. Group formulations are deferred to last (their members must exist). # # TODO(services stability): See issue #216. +# Group formulations aggregate other services' award variables, so they construct after +# every non-group service model. +_is_deferred_group_formulation(::Type{GroupRangeReserve}) = true +_is_deferred_group_formulation(::Type{GroupStepwiseCostReserve}) = true +_is_deferred_group_formulation(::Type) = false + # Collect the type's available services that have at least one modeled contributing device. # The concrete element type keeps `add_parameters!` / `add_service_variables!` dispatch happy. function _services_with_contributors( @@ -19,6 +25,23 @@ function _services_with_contributors( ] end +# Available groups of the type that reference at least one contributing service AND impose a +# demand under this formulation. A group is device-less by design, so `_services_with_contributors` +# (device-map filter) cannot apply; a demand-less group is skipped like a degenerate service. +# Comprehensions keep the eltype CONCRETE (`GroupReserve{ReserveUp, NaturalUnit}`): a bare +# `PSY.GroupReserve[]` accumulator would canonicalize container keys to the direction-less +# wrapper, which readers keyed by the model's `GroupReserve{Dir}` could never find. +function _groups_with_demand(model::ServiceModel, sys::PSY.System) + candidates = [ + g for g in get_available_components(model, sys) if + !isempty(PSY.get_contributing_services(g)) + ] + for g in candidates + _has_reserve_demand(model, g) || _log_skipped_reserve_demand(sys, g, model) + end + return [g for g in candidates if _has_reserve_demand(model, g)] +end + function construct_services!( container::OptimizationContainer, sys::PSY.System, @@ -30,10 +53,10 @@ function construct_services!( isempty(services_template) && return incompatible_device_types = get_incompatible_devices(devices_template) - groupservice = nothing + deferred_groups = Symbol[] for (key, service_model) in services_template - if get_formulation(service_model) === GroupRangeReserve # constructed last - groupservice = key + if _is_deferred_group_formulation(get_formulation(service_model)) + push!(deferred_groups, key) # constructed last continue end isempty(get_contributing_devices_map(service_model)) && continue @@ -47,15 +70,17 @@ function construct_services!( network_model, ) end - groupservice === nothing || construct_service!( - container, - sys, - stage, - services_template[groupservice], - devices_template, - incompatible_device_types, - network_model, - ) + for key in deferred_groups + construct_service!( + container, + sys, + stage, + services_template[key], + devices_template, + incompatible_device_types, + network_model, + ) + end return end @@ -70,10 +95,10 @@ function construct_services!( isempty(services_template) && return incompatible_device_types = get_incompatible_devices(devices_template) - groupservice = nothing + deferred_groups = Symbol[] for (key, service_model) in services_template - if get_formulation(service_model) === GroupRangeReserve # constructed last - groupservice = key + if _is_deferred_group_formulation(get_formulation(service_model)) + push!(deferred_groups, key) # constructed last continue end isempty(get_contributing_devices_map(service_model)) && continue @@ -87,15 +112,17 @@ function construct_services!( network_model, ) end - groupservice === nothing || construct_service!( - container, - sys, - stage, - services_template[groupservice], - devices_template, - incompatible_device_types, - network_model, - ) + for key in deferred_groups + construct_service!( + container, + sys, + stage, + services_template[key], + devices_template, + incompatible_device_types, + network_model, + ) + end return end @@ -438,6 +465,69 @@ function construct_service!( return end +""" + Constructs a service for GroupStepwiseCostReserve: the group's demand curve is cleared by + the summed awards of its contributing services. +""" +function construct_service!( + container::OptimizationContainer, + sys::PSY.System, + ::ArgumentConstructStage, + model::ServiceModel{SR, GroupStepwiseCostReserve}, + ::Dict{Symbol, DeviceModel}, + ::Set{<:DataType}, + ::NetworkModel{<:AbstractNetworkModel}, +) where {SR <: PSY.GroupReserve} + groups = _groups_with_demand(model, sys) + isempty(groups) && return + # Dense (group, time) container: the delta-PWL block constraint reads axes(variables). + add_reserve_variables!( + container, + ServiceRequirementVariable, + groups, + GroupStepwiseCostReserve(), + ) + add_expressions!(container, ProductionCostExpression, groups, model) + # Slope/breakpoint PWL cost params for time-series-backed group curves (no-op otherwise). + process_stepwise_cost_reserve_parameters!(container, model, groups) + for group in groups + check_activeservice_variables(container, PSY.get_contributing_services(group)) + end + return +end + +function construct_service!( + container::OptimizationContainer, + sys::PSY.System, + ::ModelConstructStage, + model::ServiceModel{SR, GroupStepwiseCostReserve}, + ::Dict{Symbol, DeviceModel}, + ::Set{<:DataType}, + ::NetworkModel{<:AbstractNetworkModel}, +) where {SR <: PSY.GroupReserve} + groups = _groups_with_demand(model, sys) + isempty(groups) && return + add_constraints_container!( + container, + RequirementConstraint, + SR, + PSY.get_name.(groups), + get_time_steps(container), + ) + for group in groups + add_constraints!( + container, + RequirementConstraint, + group, + PSY.get_contributing_services(group), + model, + ) + add_to_objective_function!(container, group, model) + end + add_constraint_dual!(container, sys, model) + return +end + function construct_service!( container::OptimizationContainer, sys::PSY.System, diff --git a/test/test_group_stepwise_reserve.jl b/test/test_group_stepwise_reserve.jl new file mode 100644 index 00000000..0c461aa4 --- /dev/null +++ b/test/test_group_stepwise_reserve.jl @@ -0,0 +1,228 @@ +# Elastic group ORDC (`GroupStepwiseCostReserve`): one demand curve on a `PSY.GroupReserve` +# is cleared by the summed awards of its contributing services. Members are supply-only +# `OnlineReserve`s (zero requirement, no curve); offers and caps live on the members. + +# Per-thermal MarketBidCost keeping the unit's own marginal energy cost, plus flat AS offers +# into `sub_a` and `sub_b` (cheap A, prohibitively priced B by default). +function _setup_group_reserve_offers!( + sys, + sub_a, + sub_b; + sub_a_price = 5.0, + sub_b_price = 9.0e5, + init_times = [DateTime("2024-01-01T00:00:00"), DateTime("2024-01-02T00:00:00")], + horizon = 24, + resolution = Hour(1), +) + offer_curve(price) = IS.PiecewiseStepData([0.0, 100.0], [price]) + for g in get_components(ThermalStandard, sys) + pmax = PSY.get_max_active_power(g, PSY.NU) + energy_slope = PSY.get_proportional_term( + PSY.get_value_curve(PSY.get_variable(get_operation_cost(g))), + ) + set_operation_cost!( + g, + MarketBidCost(; + no_load_cost = LinearCurve(0.0), + start_up = (hot = 0.0, warm = 0.0, cold = 0.0), + shut_down = LinearCurve(0.0), + incremental_offer_curves = make_market_bid_curve( + [0.0, pmax], [energy_slope], 0.0; power_units = IS.NaturalUnit(), + ), + ), + ) + for (svc, price) in ((sub_a, sub_a_price), (sub_b, sub_b_price)) + data = Dict(it => [offer_curve(price) for _ in 1:horizon] for it in init_times) + ts = Deterministic(PSY.get_name(svc), data, resolution) + PSY.set_service_bid!(sys, g, svc, ts, IS.NaturalUnit()) + end + end + return +end + +function build_group_reserve_system(; + sub_a_price = 5.0, + sub_b_price = 9.0e5, + group_curve = true, +) + sys = deepcopy(PSB.build_system(PSITestSystems, "c_sys5_uc"; add_reserves = true)) + thermals = collect(get_components(ThermalStandard, sys)) + sub_a = OnlineReserve{ReserveUp}(; + name = "GROUP_SUB_A", available = true, time_frame = 3600.0, requirement = 0.0) + sub_b = OnlineReserve{ReserveUp}(; + name = "GROUP_SUB_B", available = true, time_frame = 3600.0, requirement = 0.0) + add_service!(sys, sub_a, thermals) + add_service!(sys, sub_b, thermals) + group = if group_curve + GroupReserve{ReserveUp}(; + name = "UP_GROUP", + available = true, + requirement = 0.0, + variable = make_market_bid_curve( + [0.0, 40.0, 80.0], [80.0, 10.0], 0.0; power_units = IS.NaturalUnit(), + ), + contributing_services = Service[sub_a, sub_b], + ) + else + # `variable` defaults to the zero-offer sentinel: no demand curve. + GroupReserve{ReserveUp}(; + name = "UP_GROUP", + available = true, + requirement = 0.0, + contributing_services = Service[sub_a, sub_b], + ) + end + add_service!(sys, group) + _setup_group_reserve_offers!( + sys, + sub_a, + sub_b; + sub_a_price = sub_a_price, + sub_b_price = sub_b_price, + ) + return sys, group +end + +function _group_reserve_template(; include_group = true) + template = get_thermal_standard_uc_template() + set_service_model!(template, ServiceModel(OnlineReserve{ReserveUp}, RangeReserve)) + include_group && set_service_model!( + template, + ServiceModel(GroupReserve{ReserveUp}, GroupStepwiseCostReserve), + ) + return template +end + +_sub_cols(df, prefix) = [c for c in names(df) if startswith(c, prefix)] + +function _solve_group_model(sys; include_group = true) + model = DecisionModel( + _group_reserve_template(; include_group = include_group), + sys; + optimizer = HiGHS_optimizer, + store_variable_names = true, + ) + @test build!(model; output_dir = mktempdir(; cleanup = true)) == + IOM.ModelBuildStatus.BUILT + @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED + return model +end + +@testset "GroupStepwiseCostReserve: builds, solves, single group clearing constraint" begin + sys, group = build_group_reserve_system() + model = _solve_group_model(sys) + container = IOM.get_optimization_container(model) + @test IOM.has_container_key(container, ServiceRequirementVariable, typeof(group)) + @test IOM.has_container_key(container, POM.RequirementConstraint, typeof(group)) + res = IOM.OptimizationProblemOutputs(model) + demand = read_variable( + res, "ServiceRequirementVariable__GroupReserve__ReserveUp"; + table_format = TableFormat.WIDE, + ) + @test setdiff(names(demand), ["DateTime"]) == ["UP_GROUP"] +end + +@testset "GroupStepwiseCostReserve: aggregation binds member awards to group demand" begin + sys, _ = build_group_reserve_system() + model = _solve_group_model(sys) + res = IOM.OptimizationProblemOutputs(model) + demand = read_variable( + res, "ServiceRequirementVariable__GroupReserve__ReserveUp"; + table_format = TableFormat.WIDE, + ) + awards = read_variable( + res, "ActivePowerReserveVariable__OnlineReserve__ReserveUp"; + table_format = TableFormat.WIDE, + ) + sub_cols = _sub_cols(awards, "GROUP_SUB_") + @test !isempty(sub_cols) + for t in 1:24 + @test sum(awards[t, c] for c in sub_cols) ≈ demand[t, "UP_GROUP"] atol = 1e-3 + end +end + +@testset "GroupStepwiseCostReserve: sub-service merit order" begin + sys, _ = build_group_reserve_system() + model = _solve_group_model(sys) + res = IOM.OptimizationProblemOutputs(model) + awards = read_variable( + res, "ActivePowerReserveVariable__OnlineReserve__ReserveUp"; + table_format = TableFormat.WIDE, + ) + sub_a_total = sum(awards[1, c] for c in _sub_cols(awards, "GROUP_SUB_A")) + sub_b_total = sum(awards[1, c] for c in _sub_cols(awards, "GROUP_SUB_B")) + @test sub_a_total > 1.0 + @test sub_b_total <= 1e-2 + @test sub_a_total > sub_b_total +end + +@testset "GroupStepwiseCostReserve: no group model -> no procurement" begin + sys, _ = build_group_reserve_system() + model = _solve_group_model(sys; include_group = false) + res = IOM.OptimizationProblemOutputs(model) + awards = read_variable( + res, "ActivePowerReserveVariable__OnlineReserve__ReserveUp"; + table_format = TableFormat.WIDE, + ) + for t in 1:24, c in _sub_cols(awards, "GROUP_SUB_") + @test awards[t, c] <= 1e-2 + end +end + +@testset "Group formulation pairing fails at ServiceModel declaration" begin + # A GroupReserve accepts only group formulations, and group formulations accept only + # GroupReserve; mis-pairs must fail at declaration, not at build. + @test_throws ArgumentError ServiceModel(GroupReserve{ReserveUp}, RangeReserve) + @test_throws ArgumentError ServiceModel(GroupReserve{ReserveDown}, StepwiseCostReserve) + @test_throws ArgumentError ServiceModel(OnlineReserve{ReserveUp}, GroupRangeReserve) + @test_throws ArgumentError ServiceModel( + OnlineReserve{ReserveUp}, + GroupStepwiseCostReserve, + ) + @test_throws ArgumentError ServiceModel(OfflineReserve, GroupStepwiseCostReserve) + # The valid pairs still construct. + @test ServiceModel(GroupReserve{ReserveUp}, GroupRangeReserve) isa ServiceModel + @test ServiceModel(GroupReserve{ReserveUp}, GroupStepwiseCostReserve) isa ServiceModel +end + +@testset "GroupStepwiseCostReserve: curve-less group is skipped as degenerate demand" begin + sys, group = build_group_reserve_system(; group_curve = false) + model = _solve_group_model(sys) + container = IOM.get_optimization_container(model) + @test !IOM.has_container_key(container, ServiceRequirementVariable, typeof(group)) + @test !IOM.has_container_key(container, POM.RequirementConstraint, typeof(group)) +end + +@testset "GroupStepwiseCostReserve: time-series group curve builds, solves and clears" begin + sys, group = build_group_reserve_system() + baseline_curve = PSY.get_variable(group) + power_units = PSY.get_power_units(baseline_curve) + fd = PSY.get_function_data(PSY.get_value_curve(baseline_curve)) + pwl_ts = make_deterministic_ts( + sys, + "variable_cost", + fd, + (0.0, 0.0, 0.0), + (0.0, 0.0, 0.0); + override_min_x = 0.0, + override_max_x = last(get_x_coords(fd)), + ) + pwl_key = add_time_series!(sys, group, pwl_ts) + PSY.set_variable!(group, PSY.make_market_bid_ts_curve(pwl_key, nothing, power_units)) + + model = _solve_group_model(sys) + res = IOM.OptimizationProblemOutputs(model) + demand = read_variable( + res, "ServiceRequirementVariable__GroupReserve__ReserveUp"; + table_format = TableFormat.WIDE, + ) + awards = read_variable( + res, "ActivePowerReserveVariable__OnlineReserve__ReserveUp"; + table_format = TableFormat.WIDE, + ) + sub_cols = _sub_cols(awards, "GROUP_SUB_") + for t in 1:24 + @test demand[t, "UP_GROUP"] > 1.0 + @test sum(awards[t, c] for c in sub_cols) ≈ demand[t, "UP_GROUP"] atol = 1e-3 + end +end From e53293ccd00e81764bf491bc811690e6d3a8ec4a Mon Sep 17 00:00:00 2001 From: rodrigomha Date: Thu, 13 Aug 2026 09:55:09 -0700 Subject: [PATCH 02/23] Simplify group support: GroupReserve is an AbstractReserve PSY moved GroupReserve into the reserve tree, so the RESERVE_PRODUCT_TYPES alias and the per-type methods that existed only because groups sat outside it are gone: every former Union bound is plain PSY.AbstractReserve, the group get_initial_conditions_service_model and the CostExpressions container sibling fold into the AbstractReserve methods, and uses_compact_power opens to the abstract type. Formulation-pair bounds (Union{StepwiseCostReserve, GroupStepwiseCostReserve}) and the group demand predicates stay - they encode formulation semantics, not typing. The pairing guards merge with the #235 hardening set: valid direction-applied pairs for both group formulations, the generic-defaults disambiguator, the inverse guard over both formulations, and a direction-required error for bare GroupReserve declarations. --- src/common_models/add_expressions.jl | 24 --------------- src/common_models/add_parameters.jl | 18 +++++------ src/common_models/market_bid_overrides.jl | 2 +- src/common_models/market_bid_plumbing.jl | 2 +- src/core/definitions.jl | 6 ---- src/core/reserve_traits.jl | 2 +- src/services_models/reserves.jl | 37 +++++++++-------------- 7 files changed, 26 insertions(+), 65 deletions(-) diff --git a/src/common_models/add_expressions.jl b/src/common_models/add_expressions.jl index 0b1ed79d..c4e2872b 100644 --- a/src/common_models/add_expressions.jl +++ b/src/common_models/add_expressions.jl @@ -244,27 +244,3 @@ function add_expressions!( ) return end - -# Group sibling of the reserve method above (`GroupReserve <: Service`, so it cannot share -# the `V <: PSY.AbstractReserve` bound; a `Union` bound on `V` would be ambiguous against it). -function add_expressions!( - container::OptimizationContainer, - ::Type{T}, - services::U, - model::ServiceModel{V, W}, -) where { - T <: CostExpressions, - U <: Union{Vector{D}, IS.FlattenIteratorWrapper{D}}, - V <: PSY.GroupReserve, - W <: AbstractReservesFormulation, -} where {D <: PSY.Component} - time_steps = get_time_steps(container) - add_expression_container!( - container, - T, - D, - [PSY.get_name(s) for s in services], - time_steps, - ) - return -end diff --git a/src/common_models/add_parameters.jl b/src/common_models/add_parameters.jl index d08b3281..d28b37e2 100644 --- a/src/common_models/add_parameters.jl +++ b/src/common_models/add_parameters.jl @@ -70,8 +70,8 @@ function add_parameters!( AbstractPiecewiseLinearSlopeParameter, AbstractPiecewiseLinearBreakpointParameter, }, - U <: RESERVE_PRODUCT_TYPES, - V <: RESERVE_PRODUCT_TYPES, + U <: PSY.AbstractReserve, + V <: PSY.AbstractReserve, W <: AbstractServiceFormulation, } if get_rebuild_model(get_settings(container)) && has_container_key(container, T, U) @@ -424,7 +424,7 @@ _get_time_series_name( DecrementalPiecewiseLinearBreakpointParameter, }, }, - service::RESERVE_PRODUCT_TYPES, + service::PSY.AbstractReserve, ::ServiceModel, ) = IS.get_name(IS.get_time_series_key(PSY.get_variable(service))) @@ -590,8 +590,8 @@ _ordc_ts_data(ts::IS.DeterministicSingleTimeSeries) = function calc_additional_axes( ::OptimizationContainer, ::Type{P}, - services::Vector{<:RESERVE_PRODUCT_TYPES}, - ::ServiceModel{<:RESERVE_PRODUCT_TYPES, W}, + services::Vector{<:PSY.AbstractReserve}, + ::ServiceModel{<:PSY.AbstractReserve, W}, ) where { P <: AbstractPiecewiseLinearSlopeParameter, W <: AbstractServiceFormulation, @@ -605,8 +605,8 @@ end function calc_additional_axes( ::OptimizationContainer, ::Type{P}, - services::Vector{<:RESERVE_PRODUCT_TYPES}, - ::ServiceModel{<:RESERVE_PRODUCT_TYPES, W}, + services::Vector{<:PSY.AbstractReserve}, + ::ServiceModel{<:PSY.AbstractReserve, W}, ) where { P <: AbstractPiecewiseLinearBreakpointParameter, W <: AbstractServiceFormulation, @@ -809,8 +809,8 @@ function _add_parameters!( AbstractPiecewiseLinearSlopeParameter, AbstractPiecewiseLinearBreakpointParameter, }, - U <: RESERVE_PRODUCT_TYPES, - V <: RESERVE_PRODUCT_TYPES, + U <: PSY.AbstractReserve, + V <: PSY.AbstractReserve, W <: AbstractServiceFormulation, } _add_objective_function_parameters!(container, T, services, model, W) diff --git a/src/common_models/market_bid_overrides.jl b/src/common_models/market_bid_overrides.jl index 8b20ea35..7be945c1 100644 --- a/src/common_models/market_bid_overrides.jl +++ b/src/common_models/market_bid_overrides.jl @@ -348,7 +348,7 @@ function add_pwl_term_delta!( ::Type{U}, ::Type{V}, ) where { - T <: RESERVE_PRODUCT_TYPES, + T <: PSY.AbstractReserve, U <: VariableType, V <: AbstractServiceFormulation, } diff --git a/src/common_models/market_bid_plumbing.jl b/src/common_models/market_bid_plumbing.jl index fa73a602..1c32006d 100644 --- a/src/common_models/market_bid_plumbing.jl +++ b/src/common_models/market_bid_plumbing.jl @@ -110,7 +110,7 @@ get_offer_curves(::IOM.IncrementalOffer, op_cost::PSY.OfferCurveCost) = # service-side direction trait (`_reserve_offer_direction`) is decremental. get_offer_curves( ::IOM.OfferDirection, - service::RESERVE_PRODUCT_TYPES, + service::PSY.AbstractReserve, ) = PSY.get_variable(service) ################################################################################# diff --git a/src/core/definitions.jl b/src/core/definitions.jl index 0dae365c..653d3a92 100644 --- a/src/core/definitions.jl +++ b/src/core/definitions.jl @@ -112,10 +112,4 @@ const IGNORABLE_FILES = [ ] const OUTPUTS_DIR = "outputs" -# Any reserve product that carries demand-side state (requirement / demand curve): the -# device-backed reserve tree plus service-aggregating groups (`PSY.GroupReserve <: Service`, -# outside that tree). `AbstractReserve` keeps the union OPEN to future reserve subtypes. -# Signature-position only - never use as a field type or container eltype. -const RESERVE_PRODUCT_TYPES = Union{PSY.AbstractReserve, PSY.GroupReserve} - IS.@scoped_enum(COMPACT_PWL_STATUS, VALID = 1, INVALID = 2, UNDETERMINED = 3) diff --git a/src/core/reserve_traits.jl b/src/core/reserve_traits.jl index 5f00b60c..b341f1ea 100644 --- a/src/core/reserve_traits.jl +++ b/src/core/reserve_traits.jl @@ -61,7 +61,7 @@ end # so the cost is negligible). "Whether a reserve's or group's ORDC curve is time-varying. Dispatches on the value-curve type (no `isa`)." -_ordc_is_ts(s::RESERVE_PRODUCT_TYPES) = +_ordc_is_ts(s::PSY.AbstractReserve) = _value_curve_is_ts(PSY.get_value_curve(PSY.get_variable(s))) _value_curve_is_ts(::PSY.TimeSeriesPiecewiseIncrementalCurve) = true _value_curve_is_ts(::PSY.PiecewiseIncrementalCurve) = false diff --git a/src/services_models/reserves.jl b/src/services_models/reserves.jl index 3ee0472d..569e5be3 100644 --- a/src/services_models/reserves.jl +++ b/src/services_models/reserves.jl @@ -13,11 +13,10 @@ get_variable_lower_bound(::Type{ActivePowerReserveVariable}, ::PSY.AbstractReser ############################### ServiceRequirementVariable (ORDC / StepwiseCostReserve) ################################ # Only created on the StepwiseCostReserve / GroupStepwiseCostReserve construct paths, so the -# formulation gates these to curve-bearing reserves and groups (`GroupReserve <: Service`, hence -# the Union). -get_variable_binary(::Type{ServiceRequirementVariable}, ::Type{<:RESERVE_PRODUCT_TYPES}, ::Type{<:AbstractReservesFormulation}) = false -get_variable_upper_bound(::Type{ServiceRequirementVariable}, ::RESERVE_PRODUCT_TYPES, d::PSY.Component, ::Type{<:AbstractReservesFormulation}) = PSY.get_max_active_power(d, PSY.SU) -get_variable_lower_bound(::Type{ServiceRequirementVariable}, ::RESERVE_PRODUCT_TYPES, ::PSY.Component, ::Type{<:AbstractReservesFormulation}) = 0.0 +# formulation gates these to curve-bearing reserves and groups. +get_variable_binary(::Type{ServiceRequirementVariable}, ::Type{<:PSY.AbstractReserve}, ::Type{<:AbstractReservesFormulation}) = false +get_variable_upper_bound(::Type{ServiceRequirementVariable}, ::PSY.AbstractReserve, d::PSY.Component, ::Type{<:AbstractReservesFormulation}) = PSY.get_max_active_power(d, PSY.SU) +get_variable_lower_bound(::Type{ServiceRequirementVariable}, ::PSY.AbstractReserve, ::PSY.Component, ::Type{<:AbstractReservesFormulation}) = 0.0 # Reserve requirement in system units; the getter is units-aware for every reserve type. _get_requirement(service) = PSY.get_requirement(service, PSY.SU) @@ -79,8 +78,8 @@ _is_group_member(::PSY.System, ::PSY.GroupReserve) = false function _log_skipped_reserve_demand( sys::PSY.System, - service::RESERVE_PRODUCT_TYPES, - ::ServiceModel{<:RESERVE_PRODUCT_TYPES, F}, + service::PSY.AbstractReserve, + ::ServiceModel{<:PSY.AbstractReserve, F}, ) where {F <: AbstractReservesFormulation} name = PSY.get_name(service) reason = F in (StepwiseCostReserve, GroupStepwiseCostReserve) ? @@ -106,15 +105,15 @@ get_initial_parameter_value(::Type{<:VariableValueParameter}, d::Type{<:PSY.Abst objective_function_multiplier(::Type{ServiceRequirementVariable}, ::Type{StepwiseCostReserve}) = -1.0 objective_function_multiplier(::Type{ServiceRequirementVariable}, ::Type{GroupStepwiseCostReserve}) = -1.0 uses_compact_power(::PSY.AbstractReserve, ::StepwiseCostReserve)=false -uses_compact_power(::PSY.GroupReserve, ::GroupStepwiseCostReserve)=false -get_multiplier_value(::Type{<:AbstractPiecewiseLinearBreakpointParameter}, ::RESERVE_PRODUCT_TYPES, ::Type{<:AbstractReservesFormulation}) = 1.0 -get_multiplier_value(::Type{<:AbstractPiecewiseLinearSlopeParameter}, ::RESERVE_PRODUCT_TYPES, ::Type{<:AbstractReservesFormulation}) = 1.0 +uses_compact_power(::PSY.AbstractReserve, ::GroupStepwiseCostReserve)=false +get_multiplier_value(::Type{<:AbstractPiecewiseLinearBreakpointParameter}, ::PSY.AbstractReserve, ::Type{<:AbstractReservesFormulation}) = 1.0 +get_multiplier_value(::Type{<:AbstractPiecewiseLinearSlopeParameter}, ::PSY.AbstractReserve, ::Type{<:AbstractReservesFormulation}) = 1.0 # Operating reserve demand curves (ORDC) are willingness-to-pay (concave), i.e. a decremental # offer. # Routes the reserve PWL cost path through IOM's OfferDirection dispatch; making # this incremental is a one-line change here. Mirrors `_onvar_offer_direction` / # `_vom_offer_direction` in market_bid_overrides.jl. -_reserve_offer_direction(::RESERVE_PRODUCT_TYPES) = IOM.DecrementalOffer() +_reserve_offer_direction(::PSY.AbstractReserve) = IOM.DecrementalOffer() #! format: on function get_initial_conditions_service_model( @@ -124,14 +123,6 @@ function get_initial_conditions_service_model( return ServiceModel(T, D) end -# `GroupReserve <: Service`, so the `AbstractReserve` method cannot cover it. -function get_initial_conditions_service_model( - ::IOM.AbstractOptimizationModel, - ::ServiceModel{T, D}, -) where {T <: PSY.GroupReserve, D <: AbstractReservesFormulation} - return ServiceModel(T, D) -end - function get_default_time_series_names( ::Type{<:PSY.Reserve}, ::Type{T}, @@ -174,7 +165,7 @@ function add_reserve_variables!( formulation, ) where { T <: ServiceRequirementVariable, - D <: RESERVE_PRODUCT_TYPES, + D <: PSY.AbstractReserve, } time_steps = get_time_steps(container) service_names = [PSY.get_name(s) for s in services] @@ -652,7 +643,7 @@ function add_reserves_variable_cost!( service::T, ::Type{V}, ) where { - T <: RESERVE_PRODUCT_TYPES, + T <: PSY.AbstractReserve, U <: VariableType, V <: Union{StepwiseCostReserve, GroupStepwiseCostReserve}, } @@ -663,7 +654,7 @@ end function _add_reserves_variable_cost_to_objective!( container::OptimizationContainer, ::Type{T}, - component::RESERVE_PRODUCT_TYPES, + component::PSY.AbstractReserve, ::Type{U}, ) where {T <: VariableType, U <: Union{StepwiseCostReserve, GroupStepwiseCostReserve}} component_name = PSY.get_name(component) @@ -718,7 +709,7 @@ function process_stepwise_cost_reserve_parameters!( container::OptimizationContainer, model::ServiceModel, services::Vector{D}, -) where {D <: RESERVE_PRODUCT_TYPES} +) where {D <: PSY.AbstractReserve} # Only time-series-backed ORDCs need the per-timestep slope/breakpoint parameters. ts_services = [s for s in services if _ordc_is_ts(s)] isempty(ts_services) && return From 1878196ec3beeda188831f4ccbcd54433a84a71e Mon Sep 17 00:00:00 2001 From: rodrigomha Date: Thu, 13 Aug 2026 11:01:50 -0700 Subject: [PATCH 03/23] Add load reserve provision under PowerLoadDispatch A controllable load routes reserves inversely to generators: up reserve is committed shed (P - r_up >= 0), down reserve is committed extra consumption (P + r_down <= forecast). Dispatch limits move to the range expressions only when a reserve service is attached, and a costless load selling reserves fails loudly since nothing pins its consumption. --- src/common_models/add_to_expression.jl | 68 +++++ src/static_injector_models/electric_loads.jl | 49 +++- .../load_constructor.jl | 112 ++++++-- test/test_device_reserve_offers.jl | 252 ++++++++++++++++++ 4 files changed, 463 insertions(+), 18 deletions(-) diff --git a/src/common_models/add_to_expression.jl b/src/common_models/add_to_expression.jl index df03153b..e13ecc47 100644 --- a/src/common_models/add_to_expression.jl +++ b/src/common_models/add_to_expression.jl @@ -2413,6 +2413,74 @@ function add_to_expression!( return end +# Load up-reserve is committed shed: LB = P - Σ r_up, constrained >= 0. Generators route +# ReserveUp to the UB expression, so `V <: PSY.ElectricLoad` cannot shadow them. +function add_to_expression!( + container::OptimizationContainer, + ::Type{T}, + ::Type{U}, + service::X, + devices::Union{Vector{V}, IS.FlattenIteratorWrapper{V}}, + model::ServiceModel{X, W}, +) where { + T <: ActivePowerRangeExpressionLB, + U <: VariableType, + V <: PSY.ElectricLoad, + X <: PSY.Reserve{PSY.ReserveUp}, + W <: AbstractReservesFormulation, +} + service_name = PSY.get_name(service) + variable = get_variable(container, U, X) + if !has_container_key(container, T, V) + add_expressions!(container, T, devices, model) + end + expression = get_expression(container, T, V) + time_steps = get_time_steps(container) + for d in devices, t in time_steps + name = PSY.get_name(d) + add_proportional_to_jump_expression!( + expression[name, t], + variable[(service_name, name, t)], + -1.0, + ) + end + return +end + +# Load down-reserve is committed extra consumption: UB = P + Σ r_down, constrained by the +# load's forecast. +function add_to_expression!( + container::OptimizationContainer, + ::Type{T}, + ::Type{U}, + service::X, + devices::Union{Vector{V}, IS.FlattenIteratorWrapper{V}}, + model::ServiceModel{X, W}, +) where { + T <: ActivePowerRangeExpressionUB, + U <: VariableType, + V <: PSY.ElectricLoad, + X <: PSY.Reserve{PSY.ReserveDown}, + W <: AbstractReservesFormulation, +} + service_name = PSY.get_name(service) + variable = get_variable(container, U, X) + if !has_container_key(container, T, V) + add_expressions!(container, T, devices, model) + end + expression = get_expression(container, T, V) + time_steps = get_time_steps(container) + for d in devices, t in time_steps + name = PSY.get_name(d) + add_proportional_to_jump_expression!( + expression[name, t], + variable[(service_name, name, t)], + 1.0, + ) + end + return +end + function add_to_expression!( container::OptimizationContainer, ::Type{T}, diff --git a/src/static_injector_models/electric_loads.jl b/src/static_injector_models/electric_loads.jl index dac32a11..5cee88bd 100644 --- a/src/static_injector_models/electric_loads.jl +++ b/src/static_injector_models/electric_loads.jl @@ -37,6 +37,12 @@ get_variable_upper_bound(::Type{ShiftDownActivePowerVariable}, d::PSY.ElectricLo variable_cost(cost::PSY.OperationalCost, ::Type{ShiftUpActivePowerVariable}, ::PSY.ElectricLoad, ::Type{<:AbstractControllablePowerLoadFormulation})=PSY.get_variable(cost) variable_cost(cost::PSY.OperationalCost, ::Type{ShiftDownActivePowerVariable}, ::PSY.ElectricLoad, ::Type{<:AbstractControllablePowerLoadFormulation})=PSY.get_variable(cost) +########################### Reserve provision, ElectricLoad ################################ +# The inverse of a generator: up reserve is shed (P - r_up >= 0), down reserve is extra +# consumption (P + r_down <= forecast). Loads do not provide OfflineReserve. +get_expression_type_for_reserve(::Type{ActivePowerReserveVariable}, ::Type{<:PSY.ElectricLoad}, ::Type{<:PSY.Reserve{PSY.ReserveUp}}) = ActivePowerRangeExpressionLB +get_expression_type_for_reserve(::Type{ActivePowerReserveVariable}, ::Type{<:PSY.ElectricLoad}, ::Type{<:PSY.Reserve{PSY.ReserveDown}}) = ActivePowerRangeExpressionUB + ###################################################### # To avoid ambiguity with default_interface_methods.jl: @@ -259,10 +265,12 @@ function add_constraints!( return end +# Upper bound is the load's forecast; with reserves, `ActivePowerRangeExpressionUB` +# (= P + Σ r_down) rides the same bound, capping down awards by the forecast headroom. function add_constraints!( container::OptimizationContainer, ::Type{ActivePowerVariableLimitsConstraint}, - U::Type{<:VariableType}, + U::Type{<:Union{VariableType, ActivePowerRangeExpressionUB}}, devices::IS.FlattenIteratorWrapper{V}, model::DeviceModel{V, W}, ::NetworkModel{X}, @@ -279,6 +287,27 @@ function add_constraints!( return end +# `ActivePowerRangeExpressionLB` (= P - Σ r_up) >= 0: an up award cannot exceed the +# load's consumption. Reached only when the load carries a reserve service. +function add_constraints!( + container::OptimizationContainer, + T::Type{ActivePowerVariableLimitsConstraint}, + U::Type{ActivePowerRangeExpressionLB}, + devices::IS.FlattenIteratorWrapper{V}, + model::DeviceModel{V, W}, + ::NetworkModel{X}, +) where {V <: PSY.ControllableLoad, W <: PowerLoadDispatch, X <: AbstractNetworkModel} + add_range_constraints!(container, T, U, devices, model, X) + return +end + +# Only `min` is consumed (shed floor); the upper bound rides the forecast parameter. +get_min_max_limits( + d::PSY.ControllableLoad, + ::Type{ActivePowerVariableLimitsConstraint}, + ::Type{PowerLoadDispatch}, +) = (min = 0.0, max = PSY.get_max_active_power(d, PSY.SU)) + function add_constraints!( container::OptimizationContainer, T::Type{ActivePowerVariableLimitsConstraint}, @@ -523,9 +552,25 @@ end function add_to_objective_function!( container::OptimizationContainer, devices::IS.FlattenIteratorWrapper{T}, - ::DeviceModel{T, U}, + model::DeviceModel{T, U}, ::Type{<:AbstractNetworkModel}, ) where {T <: PSY.ControllableLoad, U <: PowerLoadDispatch} + # A costless load selling reserves has nothing pinning its consumption: fail loudly. + if has_service_model(model) + for d in devices + cost = PSY.get_operation_cost(d) + if cost isa PSY.LoadCost && PSY.get_variable(cost) == zero(PSY.CostCurve) + throw( + IS.ConflictingInputsError( + "PowerLoadDispatch load '$(PSY.get_name(d))' provides a reserve \ + service but its LoadCost value curve is zero; attach an \ + energy/VOLL value (e.g. set_operation_cost! with a priced \ + LoadCost) so its dispatch is pinned.", + ), + ) + end + end + end add_variable_cost!(container, ActivePowerVariable, devices, U) return end diff --git a/src/static_injector_models/load_constructor.jl b/src/static_injector_models/load_constructor.jl index fd135d05..dc542087 100644 --- a/src/static_injector_models/load_constructor.jl +++ b/src/static_injector_models/load_constructor.jl @@ -38,6 +38,27 @@ function construct_device!( network_model, ) + # With reserves, the dispatch limits move to the range expressions so awards consume + # shed/forecast headroom (load direction map in electric_loads.jl). + if has_service_model(model) + add_to_expression!( + container, + ActivePowerRangeExpressionLB, + ActivePowerVariable, + devices, + model, + network_model, + ) + add_to_expression!( + container, + ActivePowerRangeExpressionUB, + ActivePowerVariable, + devices, + model, + network_model, + ) + end + if haskey(get_time_series_names(model), ActivePowerTimeSeriesParameter) add_parameters!(container, ActivePowerTimeSeriesParameter, devices, model) end @@ -59,14 +80,33 @@ function construct_device!( sys, ) - add_constraints!( - container, - ActivePowerVariableLimitsConstraint, - ActivePowerVariable, - devices, - model, - network_model, - ) + if has_service_model(model) + add_constraints!( + container, + ActivePowerVariableLimitsConstraint, + ActivePowerRangeExpressionLB, + devices, + model, + network_model, + ) + add_constraints!( + container, + ActivePowerVariableLimitsConstraint, + ActivePowerRangeExpressionUB, + devices, + model, + network_model, + ) + else + add_constraints!( + container, + ActivePowerVariableLimitsConstraint, + ActivePowerVariable, + devices, + model, + network_model, + ) + end add_constraints!( container, ReactivePowerVariableLimitsConstraint, @@ -116,6 +156,27 @@ function construct_device!( network_model, ) + # With reserves, the dispatch limits move to the range expressions so awards consume + # shed/forecast headroom (load direction map in electric_loads.jl). + if has_service_model(model) + add_to_expression!( + container, + ActivePowerRangeExpressionLB, + ActivePowerVariable, + devices, + model, + network_model, + ) + add_to_expression!( + container, + ActivePowerRangeExpressionUB, + ActivePowerVariable, + devices, + model, + network_model, + ) + end + if haskey(get_time_series_names(model), ActivePowerTimeSeriesParameter) add_parameters!(container, ActivePowerTimeSeriesParameter, devices, model) end @@ -139,14 +200,33 @@ function construct_device!( sys, ) - add_constraints!( - container, - ActivePowerVariableLimitsConstraint, - ActivePowerVariable, - devices, - model, - network_model, - ) + if has_service_model(model) + add_constraints!( + container, + ActivePowerVariableLimitsConstraint, + ActivePowerRangeExpressionLB, + devices, + model, + network_model, + ) + add_constraints!( + container, + ActivePowerVariableLimitsConstraint, + ActivePowerRangeExpressionUB, + devices, + model, + network_model, + ) + else + add_constraints!( + container, + ActivePowerVariableLimitsConstraint, + ActivePowerVariable, + devices, + model, + network_model, + ) + end add_feedforward_constraints!(container, model, devices) add_to_objective_function!( diff --git a/test/test_device_reserve_offers.jl b/test/test_device_reserve_offers.jl index ccb01ce7..3464e1a0 100644 --- a/test/test_device_reserve_offers.jl +++ b/test/test_device_reserve_offers.jl @@ -319,3 +319,255 @@ end push!(PSY.get_ancillary_service_offers(mbc), reserve) @test POM._cost_offers_reserve(mbc, reserve) == true end + + +################################################################################# +# Load reserve provision (PowerLoadDispatch) +################################################################################# + +# Load reserve provision (`PowerLoadDispatch`): a controllable load provides UPWARD reserve +# by shedding (`P - r_up >= 0`, awards capped by consumption) and DOWNWARD reserve by +# consuming more (`P + r_down <= forecast`). `c_sys5_il`'s single interruptible load +# `IloadBus4` is the sole contributor to Reserve7 (up), Reserve8 (down) and ORDC1 (up, +# demand curve); the requirements exceed the load, so requirement models use slacks. + +const _IL_NAME = "IloadBus4" + +function _load_reserve_template(direction::Symbol) + template = get_thermal_dispatch_template_network() + set_device_model!(template, PSY.InterruptiblePowerLoad, PowerLoadDispatch) + direction === :up && set_service_model!( + template, + ServiceModel(OnlineReserve{ReserveUp}, RangeReserve; use_slacks = true), + ) + direction === :down && set_service_model!( + template, + ServiceModel(OnlineReserve{ReserveDown}, RangeReserve; use_slacks = true), + ) + return template +end + +function _solve_load_model(template, sys) + model = DecisionModel( + template, sys; + optimizer = HiGHS_optimizer, store_variable_names = true, + ) + @test build!(model; output_dir = mktempdir(; cleanup = true)) == + IOM.ModelBuildStatus.BUILT + @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED + return model +end + +_il_cols(df) = [c for c in names(df) if endswith(c, "__$(_IL_NAME)")] + +@testset "Load reserve direction map + folding methods" begin + @test POM.get_expression_type_for_reserve( + ActivePowerReserveVariable, PSY.InterruptiblePowerLoad, OnlineReserve{ReserveUp}, + ) == POM.ActivePowerRangeExpressionLB + @test POM.get_expression_type_for_reserve( + ActivePowerReserveVariable, PSY.InterruptiblePowerLoad, OnlineReserve{ReserveDown}, + ) == POM.ActivePowerRangeExpressionUB +end + +@testset "UP-reserve: award capped by consumption (shed headroom)" begin + sys = deepcopy(PSB.build_system(PSITestSystems, "c_sys5_il"; add_reserves = true)) + model = _solve_load_model(_load_reserve_template(:up), sys) + container = IOM.get_optimization_container(model) + @test IOM.has_container_key( + container, POM.ActivePowerRangeExpressionLB, PSY.InterruptiblePowerLoad, + ) + res = IOM.OptimizationProblemOutputs(model) + p = read_variable( + res, "ActivePowerVariable__InterruptiblePowerLoad"; + table_format = TableFormat.WIDE, + ) + awards = read_variable( + res, "ActivePowerReserveVariable__OnlineReserve__ReserveUp"; + table_format = TableFormat.WIDE, + ) + total_award = 0.0 + for t in 1:24 + awarded = sum(awards[t, c] for c in _il_cols(awards)) + @test awarded <= p[t, _IL_NAME] + 1e-4 + @test p[t, _IL_NAME] - awarded >= -1e-4 + total_award += awarded + end + @test total_award > 1.0 +end + +@testset "DOWN-reserve: award within forecast headroom" begin + sys = deepcopy(PSB.build_system(PSITestSystems, "c_sys5_il"; add_reserves = true)) + il = get_component(PSY.InterruptiblePowerLoad, sys, _IL_NAME) + pmax = PSY.get_max_active_power(il, PSY.NU) + model = _solve_load_model(_load_reserve_template(:down), sys) + res = IOM.OptimizationProblemOutputs(model) + p = read_variable( + res, "ActivePowerVariable__InterruptiblePowerLoad"; + table_format = TableFormat.WIDE, + ) + awards = read_variable( + res, "ActivePowerReserveVariable__OnlineReserve__ReserveDown"; + table_format = TableFormat.WIDE, + ) + hsl = read_parameter( + res, "ActivePowerTimeSeriesParameter__InterruptiblePowerLoad"; + table_format = TableFormat.WIDE, + ) + total_award = 0.0 + for t in 1:24 + awarded = sum(awards[t, c] for c in _il_cols(awards)) + @test awarded >= -1e-4 + @test p[t, _IL_NAME] + awarded <= hsl[t, _IL_NAME] + 1e-3 + @test p[t, _IL_NAME] + awarded <= pmax + 1e-3 + total_award += awarded + end + @test total_award > 1.0 +end + +@testset "VOLL-priced load: pinned at forecast, full up-shed, zero down" begin + sys = deepcopy(PSB.build_system(PSITestSystems, "c_sys5_il"; add_reserves = true)) + il = get_component(PSY.InterruptiblePowerLoad, sys, _IL_NAME) + set_operation_cost!( + il, + PSY.LoadCost(PSY.CostCurve(PSY.LinearCurve(5000.0, 0.0), IS.NaturalUnit()), 24.0), + ) + template = _load_reserve_template(:up) + set_service_model!( + template, + ServiceModel(OnlineReserve{ReserveDown}, RangeReserve; use_slacks = true), + ) + model = _solve_load_model(template, sys) + res = IOM.OptimizationProblemOutputs(model) + p = read_variable( + res, "ActivePowerVariable__InterruptiblePowerLoad"; + table_format = TableFormat.WIDE, + ) + up = read_variable( + res, "ActivePowerReserveVariable__OnlineReserve__ReserveUp"; + table_format = TableFormat.WIDE, + ) + dn = read_variable( + res, "ActivePowerReserveVariable__OnlineReserve__ReserveDown"; + table_format = TableFormat.WIDE, + ) + hsl = read_parameter( + res, "ActivePowerTimeSeriesParameter__InterruptiblePowerLoad"; + table_format = TableFormat.WIDE, + ) + up_total = 0.0 + for t in 1:24 + @test isapprox(p[t, _IL_NAME], hsl[t, _IL_NAME]; atol = 1e-1) + up_t = sum(up[t, c] for c in _il_cols(up)) + @test isapprox(up_t, p[t, _IL_NAME]; atol = 1e-1) + @test sum(dn[t, c] for c in _il_cols(dn)) <= 1e-2 + up_total += up_t + end + @test up_total > 1.0 +end + +@testset "No-reserve regression: pure-energy path unchanged" begin + sys = deepcopy(PSB.build_system(PSITestSystems, "c_sys5_il"; add_reserves = false)) + model = _solve_load_model(_load_reserve_template(:none), sys) + container = IOM.get_optimization_container(model) + @test !IOM.has_container_key( + container, POM.ActivePowerRangeExpressionLB, PSY.InterruptiblePowerLoad, + ) + @test !IOM.has_container_key( + container, POM.ActivePowerRangeExpressionUB, PSY.InterruptiblePowerLoad, + ) +end + +@testset "Costless load offering reserves fails loudly" begin + sys = deepcopy(PSB.build_system(PSITestSystems, "c_sys5_il"; add_reserves = true)) + il = get_component(PSY.InterruptiblePowerLoad, sys, _IL_NAME) + set_operation_cost!(il, PSY.LoadCost(nothing)) + model = DecisionModel( + _load_reserve_template(:up), sys; + optimizer = HiGHS_optimizer, + ) + @test build!(model; output_dir = mktempdir(; cleanup = true)) == + IOM.ModelBuildStatus.FAILED +end + +@testset "Co-provision: two up-services share one shed headroom" begin + sys = deepcopy(PSB.build_system(PSITestSystems, "c_sys5_il"; add_reserves = true)) + il = get_component(PSY.InterruptiblePowerLoad, sys, _IL_NAME) + set_operation_cost!( + il, + PSY.LoadCost(PSY.CostCurve(PSY.LinearCurve(5000.0, 0.0), IS.NaturalUnit()), 24.0), + ) + # A second requirement reserve on the same load; both clear under ONE per-type model. + second = OnlineReserve{ReserveUp}("Reserve7B", true, 30.0, 100.0) + add_service!(sys, second, [il]) + model = _solve_load_model(_load_reserve_template(:up), sys) + res = IOM.OptimizationProblemOutputs(model) + p = read_variable( + res, "ActivePowerVariable__InterruptiblePowerLoad"; + table_format = TableFormat.WIDE, + ) + awards = read_variable( + res, "ActivePowerReserveVariable__OnlineReserve__ReserveUp"; + table_format = TableFormat.WIDE, + ) + combined_total = 0.0 + for t in 1:24 + # One shared LB expression: a per-service headroom bug would allow up to 2*P. + combined = awards[t, "Reserve7__$(_IL_NAME)"] + awards[t, "Reserve7B__$(_IL_NAME)"] + @test combined <= p[t, _IL_NAME] + 1e-3 + combined_total += combined + end + @test combined_total > 1.0 +end + +@testset "Load offers into an elastic reserve: award bounded by the offer" begin + sys = deepcopy(PSB.build_system(PSITestSystems, "c_sys5_il"; add_reserves = true)) + il = get_component(PSY.InterruptiblePowerLoad, sys, _IL_NAME) + pmax = PSY.get_max_active_power(il, PSY.NU) + ordc = first(get_components(PSY.has_demand_curve, PSY.OnlineReserve, sys)) + offer_mw = 10.0 + set_operation_cost!( + il, + MarketBidCost(; + no_load_cost = LinearCurve(0.0), + start_up = (hot = 0.0, warm = 0.0, cold = 0.0), + shut_down = LinearCurve(0.0), + decremental_offer_curves = make_market_bid_curve( + [0.0, pmax], [5000.0], 0.0; power_units = IS.NaturalUnit(), + ), + ), + ) + offer_ts = Deterministic( + PSY.get_name(ordc), + Dict( + it => [IS.PiecewiseStepData([0.0, offer_mw], [0.0]) for _ in 1:24] for + it in [DateTime("2024-01-01T00:00:00"), DateTime("2024-01-02T00:00:00")] + ), + Hour(1), + ) + PSY.set_service_bid!(sys, il, ordc, offer_ts, IS.NaturalUnit()) + + template = get_thermal_dispatch_template_network() + set_device_model!(template, PSY.InterruptiblePowerLoad, PowerLoadDispatch) + set_service_model!( + template, + ServiceModel(OnlineReserve{ReserveUp}, StepwiseCostReserve), + ) + model = _solve_load_model(template, sys) + container = IOM.get_optimization_container(model) + @test IOM.has_container_key( + container, POM.PiecewiseLinearBlockReserveOffer, PSY.InterruptiblePowerLoad, + ) + res = IOM.OptimizationProblemOutputs(model) + awards = read_variable( + res, "ActivePowerReserveVariable__OnlineReserve__ReserveUp"; + table_format = TableFormat.WIDE, + ) + col = "$(PSY.get_name(ordc))__$(_IL_NAME)" + total = 0.0 + for t in 1:24 + @test awards[t, col] <= offer_mw + 1e-3 + total += awards[t, col] + end + # The zero-priced block clears against the elastic demand. + @test total > 1.0 +end From e6af246aa018fa2b97153fb6f7e4de0bf0e3b967 Mon Sep 17 00:00:00 2001 From: rodrigomha Date: Thu, 13 Aug 2026 11:02:07 -0700 Subject: [PATCH 04/23] Add energy + reserve co-clearing integration test; service docs close-out End-to-end market test: an elastic OnlineReserve (StepwiseCostReserve) and a GroupStepwiseCostReserve group co-clear against per-resource offers from thermal, storage, and load participants. Registers GroupStepwiseCostReserve in the formulation library, refreshes the stale group-reserve warnings there, and renames the remaining market-specific reserve identifiers in hydro to generic ones. --- docs/src/reference/formulation_library.md | 46 ++--- src/core/reserve_traits.jl | 2 +- .../hydro_generation.jl | 10 +- test/test_device_hydro_constructors.jl | 28 +-- test/test_device_reserve_offers.jl | 167 ++++++++++++++++++ 5 files changed, 212 insertions(+), 41 deletions(-) diff --git a/docs/src/reference/formulation_library.md b/docs/src/reference/formulation_library.md index 701fdb0c..570ea341 100644 --- a/docs/src/reference/formulation_library.md +++ b/docs/src/reference/formulation_library.md @@ -514,31 +514,35 @@ no-op. ## [Service Formulations](@id service_formulations) -| Formulation | Service type | Argument stage | Model stage | -|:------------------------------------------------------ |:--------------------------- |:---------------------------------------------------------------------------------------------- |:---------------------------------------------------------- | -| `RangeReserve` | `PSY.Reserve` | `RequirementTimeSeriesParameter` (omitted for `ConstantReserve`), `ActivePowerReserveVariable` | `RequirementConstraint`, `ParticipationFractionConstraint` | -| `RampReserve` | `PSY.Reserve` | as above | as above **+ `RampConstraint`** | -| `NonSpinningReserve` | `PSY.OfflineReserve` | as above, but **no** device-range expression wiring | as above **+ `ReservePowerConstraint`** | -| `StepwiseCostReserve` (operating reserve demand curve) | `PSY.Reserve` | `ServiceRequirementVariable` + demand-curve slope/breakpoint parameters | `RequirementConstraint` only — no participation constraint | -| `GroupRangeReserve` | `PSY.GroupReserve` | no variables | `RequirementConstraint` across contributing services | -| `ConstantMaxInterfaceFlow` | `PSY.TransmissionInterface` | optional slacks, `InterfaceTotalFlow` expression | `InterfaceFlowLimit` (`"ub"`/`"lb"`) | -| `VariableMaxInterfaceFlow` | `PSY.TransmissionInterface` | as above **+ min/max flow-limit parameters** | as above, with parameterized limits | - -`GroupRangeReserve` is deliberately constructed **last** in both stages, because it aggregates the other -services' variables. - -!!! warning "GroupRangeReserve does not support slacks" +| Formulation | Service type | Argument stage | Model stage | +|:------------------------------------------------------ |:--------------------------- |:-------------------------------------------------------------------------------------------------------- |:---------------------------------------------------------- | +| `RangeReserve` | `PSY.Reserve` | `RequirementTimeSeriesParameter` (omitted for static-requirement reserves), `ActivePowerReserveVariable` | `RequirementConstraint`, `ParticipationFractionConstraint` | +| `RampReserve` | `PSY.Reserve` | as above | as above **+ `RampConstraint`** | +| `NonSpinningReserve` | `PSY.OfflineReserve` | as above, but **no** device-range expression wiring | as above **+ `ReservePowerConstraint`** | +| `StepwiseCostReserve` (operating reserve demand curve) | `PSY.Reserve` | `ServiceRequirementVariable` + demand-curve slope/breakpoint parameters | `RequirementConstraint` only — no participation constraint | +| `GroupRangeReserve` | `PSY.GroupReserve` | no variables | `RequirementConstraint` across contributing services | +| `GroupStepwiseCostReserve` (elastic group) | `PSY.GroupReserve` | `ServiceRequirementVariable` + group demand-curve slope/breakpoint parameters | `RequirementConstraint`: member awards ≥ the group demand | +| `ConstantMaxInterfaceFlow` | `PSY.TransmissionInterface` | optional slacks, `InterfaceTotalFlow` expression | `InterfaceFlowLimit` (`"ub"`/`"lb"`) | +| `VariableMaxInterfaceFlow` | `PSY.TransmissionInterface` | as above **+ min/max flow-limit parameters** | as above, with parameterized limits | + +The group formulations are deliberately constructed **last** in both stages, because they aggregate +the other services' award variables. A `PSY.GroupReserve` accepts only the group formulations (and +vice versa); a mis-paired `ServiceModel` fails at declaration. A service whose demand driver is +degenerate (zero requirement under the requirement formulations, no demand curve under the stepwise +ones) is skipped as demand and built as supply only, so it can serve a group. + +!!! warning "Group formulations do not support slacks" - `GroupRangeReserve`'s requirement-constraint builder reads a `slack_vars` binding that is never - created, so a `ServiceModel` with `use_slacks = true` raises `UndefVarError`. A group reserve - also cannot currently be built end to end: a `PSY.GroupReserve` aggregates services rather - than devices, so its contributing-device list is empty and construction errors out before the - requirement constraint is reached. + `use_slacks = true` on a group `ServiceModel` is ignored: reserve slacks attach to the + requirement rows of the device-backed formulations, and no slack is added to a group's + clearing constraint. Reserve contributions reach a device through `get_expression_type_for_reserve`: for thermal, renewable and hydro an up-reserve enters `ActivePowerRangeExpressionUB` (+1) and a down-reserve -`ActivePowerRangeExpressionLB` (−1); storage and hybrid instead route everything into -`TotalReserveOffering`. Any other device type hits an error — loads, sources, condensers and shunts +`ActivePowerRangeExpressionLB` (−1); a controllable load is the inverse (an up-reserve is committed +shed, entering `ActivePowerRangeExpressionLB` with −1, and a down-reserve is committed extra +consumption, entering `ActivePowerRangeExpressionUB` with +1); storage and hybrid instead route +everything into `TotalReserveOffering`. Any other device type hits an error — sources, condensers and shunts cannot contribute to a reserve. Service `meta` strings are **per-instance**, not a fixed vocabulary: every reserve container is diff --git a/src/core/reserve_traits.jl b/src/core/reserve_traits.jl index b341f1ea..802204ae 100644 --- a/src/core/reserve_traits.jl +++ b/src/core/reserve_traits.jl @@ -31,7 +31,7 @@ struct ChargeSide <: ReserveSide end """ Direction of a reserve. `OfflineReserve` (non-spinning) has no direction type parameter and is -upward-only in every US market, so it maps to [`PSY.ReserveUp`](@ref). +upward-only in every US market, so it maps to `PSY.ReserveUp`. """ _reserve_direction(::PSY.Reserve{T}) where {T <: PSY.ReserveDirection} = T _reserve_direction(::PSY.OfflineReserve) = PSY.ReserveUp diff --git a/src/static_injector_models/hydro_generation.jl b/src/static_injector_models/hydro_generation.jl index e7ab6567..f8ade262 100644 --- a/src/static_injector_models/hydro_generation.jl +++ b/src/static_injector_models/hydro_generation.jl @@ -2286,29 +2286,29 @@ function calculate_aux_variable_value!( d = PSY.get_component(T, system, name) for t in time_steps if has_container_key(container, HydroServedReserveUpExpression, typeof(d)) - served_regup = jump_value( + served_reserve_up = jump_value( get_expression(container, HydroServedReserveUpExpression, T)[ name, t, ], ) else - served_regup = 0.0 + served_reserve_up = 0.0 end if has_container_key(container, HydroServedReserveDownExpression, typeof(d)) - served_regdn = jump_value( + served_reserve_down = jump_value( get_expression(container, HydroServedReserveDownExpression, T)[ name, t, ], ) else - served_regdn = 0.0 + served_reserve_down = 0.0 end aux_variable_container[name, t] = ( jump_value(p_variable_output[name, t]) + - served_regup - served_regdn + served_reserve_up - served_reserve_down ) * fraction_of_hour end end diff --git a/test/test_device_hydro_constructors.jl b/test/test_device_hydro_constructors.jl index e1b277bf..649307a7 100644 --- a/test/test_device_hydro_constructors.jl +++ b/test/test_device_hydro_constructors.jl @@ -439,12 +439,12 @@ end ) # Fix reserve parameters - reg_up = only(get_components(OnlineReserve{ReserveUp}, c_sys5_hy)) - reg_dn = only(get_components(OnlineReserve{ReserveDown}, c_sys5_hy)) - set_deployed_fraction!(reg_up, 0.0) - set_deployed_fraction!(reg_dn, 0.0) - set_requirement!(reg_up, 0.01 * PSY.SU) - set_requirement!(reg_dn, 0.01 * PSY.SU) + reserve_up = only(get_components(OnlineReserve{ReserveUp}, c_sys5_hy)) + reserve_down = only(get_components(OnlineReserve{ReserveDown}, c_sys5_hy)) + set_deployed_fraction!(reserve_up, 0.0) + set_deployed_fraction!(reserve_down, 0.0) + set_requirement!(reserve_up, 0.01 * PSY.SU) + set_requirement!(reserve_down, 0.01 * PSY.SU) hydro_budget = 24 eps = 1e-6 @@ -453,11 +453,11 @@ end # Update Service allocation # Remove reg up from hydro, but leave reg dn - remove_service!(hy, reg_up) + remove_service!(hy, reserve_up) # Add reg up to thermals for th in get_components(ThermalStandard, c_sys5_hy) - add_service!(th, reg_up, c_sys5_hy) + add_service!(th, reserve_up, c_sys5_hy) end max_power = get_max_active_power(hy, PSY.SU) @@ -528,12 +528,12 @@ end # The hydro unit is the sole contributing device for both reserves in this system, so # the down requirement guarantees a nonzero down award to detect. - reg_up = only(get_components(OnlineReserve{ReserveUp}, c_sys5_hy)) - reg_dn = only(get_components(OnlineReserve{ReserveDown}, c_sys5_hy)) - set_deployed_fraction!(reg_up, 0.0) - set_deployed_fraction!(reg_dn, 0.5) - set_requirement!(reg_up, 0.01 * PSY.SU) - set_requirement!(reg_dn, 0.01 * PSY.SU) + reserve_up = only(get_components(OnlineReserve{ReserveUp}, c_sys5_hy)) + reserve_down = only(get_components(OnlineReserve{ReserveDown}, c_sys5_hy)) + set_deployed_fraction!(reserve_up, 0.0) + set_deployed_fraction!(reserve_down, 0.5) + set_requirement!(reserve_up, 0.01 * PSY.SU) + set_requirement!(reserve_down, 0.01 * PSY.SU) transform_single_time_series!(c_sys5_hy, Hour(4), Hour(4)) diff --git a/test/test_device_reserve_offers.jl b/test/test_device_reserve_offers.jl index 3464e1a0..3c7feaa3 100644 --- a/test/test_device_reserve_offers.jl +++ b/test/test_device_reserve_offers.jl @@ -320,6 +320,173 @@ end @test POM._cost_offers_reserve(mbc, reserve) == true end +################################################################################# +# End-to-end energy + reserve co-clearing: an elastic reserve (demand curve under +# StepwiseCostReserve), an elastic group (GroupStepwiseCostReserve) over two supply-only +# sub-services, and per-resource offers from both generators and a controllable load. The +# load's cheap block into GROUP_SUB_A is deliberately the cheapest in the stack, so it must +# clear in full and stay bounded by its offered quantity, not its consumption. +################################################################################# + +const _MKT_INIT_TIMES = + [DateTime("2024-01-01T00:00:00"), DateTime("2024-01-02T00:00:00")] +const _MKT_LOAD = "IloadBus4" + +_mkt_offer_ts(svc, mw, price) = Deterministic( + PSY.get_name(svc), + Dict( + it => [IS.PiecewiseStepData([0.0, mw], [price]) for _ in 1:24] for + it in _MKT_INIT_TIMES + ), + Hour(1), +) + +_mkt_curve(x, y) = make_market_bid_curve(x, y, 0.0; power_units = IS.NaturalUnit()) + +function build_reserve_market_system(; load_offer_mw = 10.0, load_offer_price = 4.0) + sys = deepcopy(PSB.build_system(PSITestSystems, "c_sys5_il"; add_reserves = false)) + thermals = collect(get_components(ThermalStandard, sys)) + il = get_component(PSY.InterruptiblePowerLoad, sys, _MKT_LOAD) + + elastic = OnlineReserve{ReserveUp}(; + name = "ELASTIC_UP", + available = true, + time_frame = 5.0, + variable = _mkt_curve([0.0, 200.0, 400.0], [80.0, 15.0]), + ) + add_service!(sys, elastic, thermals) + + sub_a = OnlineReserve{ReserveUp}(; + name = "GROUP_SUB_A", available = true, time_frame = 3600.0, requirement = 0.0) + sub_b = OnlineReserve{ReserveUp}(; + name = "GROUP_SUB_B", available = true, time_frame = 3600.0, requirement = 0.0) + add_service!(sys, sub_a, vcat(PSY.Device[thermals...], il)) + add_service!(sys, sub_b, thermals) + + group = GroupReserve{ReserveUp}(; + name = "UP_GROUP", + available = true, + requirement = 0.0, + variable = _mkt_curve([0.0, 150.0, 300.0], [70.0, 12.0]), + contributing_services = Service[sub_a, sub_b], + ) + add_service!(sys, group) + + # Generators: energy at each unit's own marginal cost, flat AS offers into all three + # up-products with per-unit prices. + for (i, g) in enumerate(thermals) + pmax = PSY.get_max_active_power(g, PSY.NU) + energy_slope = PSY.get_proportional_term( + PSY.get_value_curve(PSY.get_variable(get_operation_cost(g))), + ) + set_operation_cost!( + g, + MarketBidCost(; + no_load_cost = LinearCurve(0.0), + start_up = (hot = 0.0, warm = 0.0, cold = 0.0), + shut_down = LinearCurve(0.0), + incremental_offer_curves = _mkt_curve([0.0, pmax], [energy_slope]), + ), + ) + for (svc, mw, price) in ( + (elastic, 30.0, 8.0 + i), + (sub_a, 25.0, 5.0 + i), + (sub_b, 20.0, 6.0 + i), + ) + PSY.set_service_bid!( + sys, + g, + svc, + _mkt_offer_ts(svc, mw, price), + IS.NaturalUnit(), + ) + end + end + + # Load: consumption valued at VOLL (consumes at forecast), plus one cheap block into + # GROUP_SUB_A - the cheapest offer in the whole stack. + pmax_il = PSY.get_max_active_power(il, PSY.NU) + set_operation_cost!( + il, + MarketBidCost(; + no_load_cost = LinearCurve(0.0), + start_up = (hot = 0.0, warm = 0.0, cold = 0.0), + shut_down = LinearCurve(0.0), + decremental_offer_curves = _mkt_curve([0.0, pmax_il], [5000.0]), + ), + ) + PSY.set_service_bid!( + sys, il, sub_a, _mkt_offer_ts(sub_a, load_offer_mw, load_offer_price), + IS.NaturalUnit(), + ) + return sys +end + +function _reserve_market_template() + template = get_thermal_standard_uc_template() + set_device_model!(template, PSY.InterruptiblePowerLoad, PowerLoadDispatch) + # ONE up-reserve model: the elastic service carries its curve; the curve-less, + # zero-requirement sub-services fall through to supply-only under the skip-gate. + set_service_model!( + template, + ServiceModel(OnlineReserve{ReserveUp}, StepwiseCostReserve), + ) + set_service_model!( + template, + ServiceModel(GroupReserve{ReserveUp}, GroupStepwiseCostReserve), + ) + return template +end + +@testset "Combined clearing: elastic reserve + elastic group + gen/load offers" begin + sys = build_reserve_market_system() + model = DecisionModel( + _reserve_market_template(), sys; + optimizer = HiGHS_optimizer, store_variable_names = true, + ) + @test build!(model; output_dir = mktempdir(; cleanup = true)) == + IOM.ModelBuildStatus.BUILT + @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED + + container = IOM.get_optimization_container(model) + # Per-resource offer machinery fired for both device classes. + @test IOM.has_container_key( + container, POM.PiecewiseLinearBlockReserveOffer, ThermalStandard, + ) + @test IOM.has_container_key( + container, POM.PiecewiseLinearBlockReserveOffer, PSY.InterruptiblePowerLoad, + ) + + res = IOM.OptimizationProblemOutputs(model) + elastic_dem = read_variable( + res, "ServiceRequirementVariable__OnlineReserve__ReserveUp"; + table_format = TableFormat.WIDE, + ) + group_dem = read_variable( + res, "ServiceRequirementVariable__GroupReserve__ReserveUp"; + table_format = TableFormat.WIDE, + ) + awards = read_variable( + res, "ActivePowerReserveVariable__OnlineReserve__ReserveUp"; + table_format = TableFormat.WIDE, + ) + sub_cols = [c for c in names(awards) if startswith(c, "GROUP_SUB_")] + load_col = "GROUP_SUB_A__$(_MKT_LOAD)" + @test load_col in names(awards) + + load_offer = 10.0 + for t in 1:24 + # Both elastic demands clear. + @test elastic_dem[t, "ELASTIC_UP"] > 1.0 + @test group_dem[t, "UP_GROUP"] > 1.0 + # Group aggregation: member awards cover the group demand. + @test sum(awards[t, c] for c in sub_cols) ≈ group_dem[t, "UP_GROUP"] atol = 1e-3 + # The load's award is bounded by its offered quantity, not its ~100 MW consumption, + # and the cheapest block clears in full. + @test awards[t, load_col] <= load_offer + 1e-3 + @test awards[t, load_col] >= load_offer - 1e-2 + end +end ################################################################################# # Load reserve provision (PowerLoadDispatch) From fd038f8f9a7d2e73f5597a52aa47d627784d93b6 Mon Sep 17 00:00:00 2001 From: rodrigomha Date: Thu, 13 Aug 2026 11:14:55 -0700 Subject: [PATCH 05/23] update comment. --- src/services_models/services_constructor.jl | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/services_models/services_constructor.jl b/src/services_models/services_constructor.jl index 3cd4cd72..504e202e 100644 --- a/src/services_models/services_constructor.jl +++ b/src/services_models/services_constructor.jl @@ -25,12 +25,10 @@ function _services_with_contributors( ] end -# Available groups of the type that reference at least one contributing service AND impose a -# demand under this formulation. A group is device-less by design, so `_services_with_contributors` -# (device-map filter) cannot apply; a demand-less group is skipped like a degenerate service. -# Comprehensions keep the eltype CONCRETE (`GroupReserve{ReserveUp, NaturalUnit}`): a bare -# `PSY.GroupReserve[]` accumulator would canonicalize container keys to the direction-less -# wrapper, which readers keyed by the model's `GroupReserve{Dir}` could never find. +# Groups are device-less, so the device-map filter above cannot apply. The comprehensions +# keep the eltype concrete (e.g. `GroupReserve{ReserveUp, NaturalUnit}`): a bare +# `PSY.GroupReserve[]` accumulator would canonicalize container keys direction-less, +# unreachable by readers keyed on `GroupReserve{Dir}`. function _groups_with_demand(model::ServiceModel, sys::PSY.System) candidates = [ g for g in get_available_components(model, sys) if From bd5b3b2982a9d1aca6570d7e6c948642676817d9 Mon Sep 17 00:00:00 2001 From: rodrigomha Date: Thu, 13 Aug 2026 11:15:13 -0700 Subject: [PATCH 06/23] move testing --- test/test_group_stepwise_reserve.jl | 228 --------------------------- test/test_services_constructor.jl | 229 ++++++++++++++++++++++++++++ 2 files changed, 229 insertions(+), 228 deletions(-) delete mode 100644 test/test_group_stepwise_reserve.jl diff --git a/test/test_group_stepwise_reserve.jl b/test/test_group_stepwise_reserve.jl deleted file mode 100644 index 0c461aa4..00000000 --- a/test/test_group_stepwise_reserve.jl +++ /dev/null @@ -1,228 +0,0 @@ -# Elastic group ORDC (`GroupStepwiseCostReserve`): one demand curve on a `PSY.GroupReserve` -# is cleared by the summed awards of its contributing services. Members are supply-only -# `OnlineReserve`s (zero requirement, no curve); offers and caps live on the members. - -# Per-thermal MarketBidCost keeping the unit's own marginal energy cost, plus flat AS offers -# into `sub_a` and `sub_b` (cheap A, prohibitively priced B by default). -function _setup_group_reserve_offers!( - sys, - sub_a, - sub_b; - sub_a_price = 5.0, - sub_b_price = 9.0e5, - init_times = [DateTime("2024-01-01T00:00:00"), DateTime("2024-01-02T00:00:00")], - horizon = 24, - resolution = Hour(1), -) - offer_curve(price) = IS.PiecewiseStepData([0.0, 100.0], [price]) - for g in get_components(ThermalStandard, sys) - pmax = PSY.get_max_active_power(g, PSY.NU) - energy_slope = PSY.get_proportional_term( - PSY.get_value_curve(PSY.get_variable(get_operation_cost(g))), - ) - set_operation_cost!( - g, - MarketBidCost(; - no_load_cost = LinearCurve(0.0), - start_up = (hot = 0.0, warm = 0.0, cold = 0.0), - shut_down = LinearCurve(0.0), - incremental_offer_curves = make_market_bid_curve( - [0.0, pmax], [energy_slope], 0.0; power_units = IS.NaturalUnit(), - ), - ), - ) - for (svc, price) in ((sub_a, sub_a_price), (sub_b, sub_b_price)) - data = Dict(it => [offer_curve(price) for _ in 1:horizon] for it in init_times) - ts = Deterministic(PSY.get_name(svc), data, resolution) - PSY.set_service_bid!(sys, g, svc, ts, IS.NaturalUnit()) - end - end - return -end - -function build_group_reserve_system(; - sub_a_price = 5.0, - sub_b_price = 9.0e5, - group_curve = true, -) - sys = deepcopy(PSB.build_system(PSITestSystems, "c_sys5_uc"; add_reserves = true)) - thermals = collect(get_components(ThermalStandard, sys)) - sub_a = OnlineReserve{ReserveUp}(; - name = "GROUP_SUB_A", available = true, time_frame = 3600.0, requirement = 0.0) - sub_b = OnlineReserve{ReserveUp}(; - name = "GROUP_SUB_B", available = true, time_frame = 3600.0, requirement = 0.0) - add_service!(sys, sub_a, thermals) - add_service!(sys, sub_b, thermals) - group = if group_curve - GroupReserve{ReserveUp}(; - name = "UP_GROUP", - available = true, - requirement = 0.0, - variable = make_market_bid_curve( - [0.0, 40.0, 80.0], [80.0, 10.0], 0.0; power_units = IS.NaturalUnit(), - ), - contributing_services = Service[sub_a, sub_b], - ) - else - # `variable` defaults to the zero-offer sentinel: no demand curve. - GroupReserve{ReserveUp}(; - name = "UP_GROUP", - available = true, - requirement = 0.0, - contributing_services = Service[sub_a, sub_b], - ) - end - add_service!(sys, group) - _setup_group_reserve_offers!( - sys, - sub_a, - sub_b; - sub_a_price = sub_a_price, - sub_b_price = sub_b_price, - ) - return sys, group -end - -function _group_reserve_template(; include_group = true) - template = get_thermal_standard_uc_template() - set_service_model!(template, ServiceModel(OnlineReserve{ReserveUp}, RangeReserve)) - include_group && set_service_model!( - template, - ServiceModel(GroupReserve{ReserveUp}, GroupStepwiseCostReserve), - ) - return template -end - -_sub_cols(df, prefix) = [c for c in names(df) if startswith(c, prefix)] - -function _solve_group_model(sys; include_group = true) - model = DecisionModel( - _group_reserve_template(; include_group = include_group), - sys; - optimizer = HiGHS_optimizer, - store_variable_names = true, - ) - @test build!(model; output_dir = mktempdir(; cleanup = true)) == - IOM.ModelBuildStatus.BUILT - @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED - return model -end - -@testset "GroupStepwiseCostReserve: builds, solves, single group clearing constraint" begin - sys, group = build_group_reserve_system() - model = _solve_group_model(sys) - container = IOM.get_optimization_container(model) - @test IOM.has_container_key(container, ServiceRequirementVariable, typeof(group)) - @test IOM.has_container_key(container, POM.RequirementConstraint, typeof(group)) - res = IOM.OptimizationProblemOutputs(model) - demand = read_variable( - res, "ServiceRequirementVariable__GroupReserve__ReserveUp"; - table_format = TableFormat.WIDE, - ) - @test setdiff(names(demand), ["DateTime"]) == ["UP_GROUP"] -end - -@testset "GroupStepwiseCostReserve: aggregation binds member awards to group demand" begin - sys, _ = build_group_reserve_system() - model = _solve_group_model(sys) - res = IOM.OptimizationProblemOutputs(model) - demand = read_variable( - res, "ServiceRequirementVariable__GroupReserve__ReserveUp"; - table_format = TableFormat.WIDE, - ) - awards = read_variable( - res, "ActivePowerReserveVariable__OnlineReserve__ReserveUp"; - table_format = TableFormat.WIDE, - ) - sub_cols = _sub_cols(awards, "GROUP_SUB_") - @test !isempty(sub_cols) - for t in 1:24 - @test sum(awards[t, c] for c in sub_cols) ≈ demand[t, "UP_GROUP"] atol = 1e-3 - end -end - -@testset "GroupStepwiseCostReserve: sub-service merit order" begin - sys, _ = build_group_reserve_system() - model = _solve_group_model(sys) - res = IOM.OptimizationProblemOutputs(model) - awards = read_variable( - res, "ActivePowerReserveVariable__OnlineReserve__ReserveUp"; - table_format = TableFormat.WIDE, - ) - sub_a_total = sum(awards[1, c] for c in _sub_cols(awards, "GROUP_SUB_A")) - sub_b_total = sum(awards[1, c] for c in _sub_cols(awards, "GROUP_SUB_B")) - @test sub_a_total > 1.0 - @test sub_b_total <= 1e-2 - @test sub_a_total > sub_b_total -end - -@testset "GroupStepwiseCostReserve: no group model -> no procurement" begin - sys, _ = build_group_reserve_system() - model = _solve_group_model(sys; include_group = false) - res = IOM.OptimizationProblemOutputs(model) - awards = read_variable( - res, "ActivePowerReserveVariable__OnlineReserve__ReserveUp"; - table_format = TableFormat.WIDE, - ) - for t in 1:24, c in _sub_cols(awards, "GROUP_SUB_") - @test awards[t, c] <= 1e-2 - end -end - -@testset "Group formulation pairing fails at ServiceModel declaration" begin - # A GroupReserve accepts only group formulations, and group formulations accept only - # GroupReserve; mis-pairs must fail at declaration, not at build. - @test_throws ArgumentError ServiceModel(GroupReserve{ReserveUp}, RangeReserve) - @test_throws ArgumentError ServiceModel(GroupReserve{ReserveDown}, StepwiseCostReserve) - @test_throws ArgumentError ServiceModel(OnlineReserve{ReserveUp}, GroupRangeReserve) - @test_throws ArgumentError ServiceModel( - OnlineReserve{ReserveUp}, - GroupStepwiseCostReserve, - ) - @test_throws ArgumentError ServiceModel(OfflineReserve, GroupStepwiseCostReserve) - # The valid pairs still construct. - @test ServiceModel(GroupReserve{ReserveUp}, GroupRangeReserve) isa ServiceModel - @test ServiceModel(GroupReserve{ReserveUp}, GroupStepwiseCostReserve) isa ServiceModel -end - -@testset "GroupStepwiseCostReserve: curve-less group is skipped as degenerate demand" begin - sys, group = build_group_reserve_system(; group_curve = false) - model = _solve_group_model(sys) - container = IOM.get_optimization_container(model) - @test !IOM.has_container_key(container, ServiceRequirementVariable, typeof(group)) - @test !IOM.has_container_key(container, POM.RequirementConstraint, typeof(group)) -end - -@testset "GroupStepwiseCostReserve: time-series group curve builds, solves and clears" begin - sys, group = build_group_reserve_system() - baseline_curve = PSY.get_variable(group) - power_units = PSY.get_power_units(baseline_curve) - fd = PSY.get_function_data(PSY.get_value_curve(baseline_curve)) - pwl_ts = make_deterministic_ts( - sys, - "variable_cost", - fd, - (0.0, 0.0, 0.0), - (0.0, 0.0, 0.0); - override_min_x = 0.0, - override_max_x = last(get_x_coords(fd)), - ) - pwl_key = add_time_series!(sys, group, pwl_ts) - PSY.set_variable!(group, PSY.make_market_bid_ts_curve(pwl_key, nothing, power_units)) - - model = _solve_group_model(sys) - res = IOM.OptimizationProblemOutputs(model) - demand = read_variable( - res, "ServiceRequirementVariable__GroupReserve__ReserveUp"; - table_format = TableFormat.WIDE, - ) - awards = read_variable( - res, "ActivePowerReserveVariable__OnlineReserve__ReserveUp"; - table_format = TableFormat.WIDE, - ) - sub_cols = _sub_cols(awards, "GROUP_SUB_") - for t in 1:24 - @test demand[t, "UP_GROUP"] > 1.0 - @test sum(awards[t, c] for c in sub_cols) ≈ demand[t, "UP_GROUP"] atol = 1e-3 - end -end diff --git a/test/test_services_constructor.jl b/test/test_services_constructor.jl index f79e782f..219f49db 100644 --- a/test/test_services_constructor.jl +++ b/test/test_services_constructor.jl @@ -1610,3 +1610,232 @@ end container, group, model, ) end + +# Elastic group ORDC (`GroupStepwiseCostReserve`): one demand curve on a `PSY.GroupReserve` +# is cleared by the summed awards of its contributing services. Members are supply-only +# `OnlineReserve`s (zero requirement, no curve); offers and caps live on the members. + +# Per-thermal MarketBidCost keeping the unit's own marginal energy cost, plus flat AS offers +# into `sub_a` and `sub_b` (cheap A, prohibitively priced B by default). +function _setup_group_reserve_offers!( + sys, + sub_a, + sub_b; + sub_a_price = 5.0, + sub_b_price = 9.0e5, + init_times = [DateTime("2024-01-01T00:00:00"), DateTime("2024-01-02T00:00:00")], + horizon = 24, + resolution = Hour(1), +) + offer_curve(price) = IS.PiecewiseStepData([0.0, 100.0], [price]) + for g in get_components(ThermalStandard, sys) + pmax = PSY.get_max_active_power(g, PSY.NU) + energy_slope = PSY.get_proportional_term( + PSY.get_value_curve(PSY.get_variable(get_operation_cost(g))), + ) + set_operation_cost!( + g, + MarketBidCost(; + no_load_cost = LinearCurve(0.0), + start_up = (hot = 0.0, warm = 0.0, cold = 0.0), + shut_down = LinearCurve(0.0), + incremental_offer_curves = make_market_bid_curve( + [0.0, pmax], [energy_slope], 0.0; power_units = IS.NaturalUnit(), + ), + ), + ) + for (svc, price) in ((sub_a, sub_a_price), (sub_b, sub_b_price)) + data = Dict(it => [offer_curve(price) for _ in 1:horizon] for it in init_times) + ts = Deterministic(PSY.get_name(svc), data, resolution) + PSY.set_service_bid!(sys, g, svc, ts, IS.NaturalUnit()) + end + end + return +end + +function build_group_reserve_system(; + sub_a_price = 5.0, + sub_b_price = 9.0e5, + group_curve = true, +) + sys = deepcopy(PSB.build_system(PSITestSystems, "c_sys5_uc"; add_reserves = true)) + thermals = collect(get_components(ThermalStandard, sys)) + sub_a = OnlineReserve{ReserveUp}(; + name = "GROUP_SUB_A", available = true, time_frame = 3600.0, requirement = 0.0) + sub_b = OnlineReserve{ReserveUp}(; + name = "GROUP_SUB_B", available = true, time_frame = 3600.0, requirement = 0.0) + add_service!(sys, sub_a, thermals) + add_service!(sys, sub_b, thermals) + group = if group_curve + GroupReserve{ReserveUp}(; + name = "UP_GROUP", + available = true, + requirement = 0.0, + variable = make_market_bid_curve( + [0.0, 40.0, 80.0], [80.0, 10.0], 0.0; power_units = IS.NaturalUnit(), + ), + contributing_services = Service[sub_a, sub_b], + ) + else + # `variable` defaults to the zero-offer sentinel: no demand curve. + GroupReserve{ReserveUp}(; + name = "UP_GROUP", + available = true, + requirement = 0.0, + contributing_services = Service[sub_a, sub_b], + ) + end + add_service!(sys, group) + _setup_group_reserve_offers!( + sys, + sub_a, + sub_b; + sub_a_price = sub_a_price, + sub_b_price = sub_b_price, + ) + return sys, group +end + +function _group_reserve_template(; include_group = true) + template = get_thermal_standard_uc_template() + set_service_model!(template, ServiceModel(OnlineReserve{ReserveUp}, RangeReserve)) + include_group && set_service_model!( + template, + ServiceModel(GroupReserve{ReserveUp}, GroupStepwiseCostReserve), + ) + return template +end + +_sub_cols(df, prefix) = [c for c in names(df) if startswith(c, prefix)] + +function _solve_group_model(sys; include_group = true) + model = DecisionModel( + _group_reserve_template(; include_group = include_group), + sys; + optimizer = HiGHS_optimizer, + store_variable_names = true, + ) + @test build!(model; output_dir = mktempdir(; cleanup = true)) == + IOM.ModelBuildStatus.BUILT + @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED + return model +end + +@testset "GroupStepwiseCostReserve: builds, solves, single group clearing constraint" begin + sys, group = build_group_reserve_system() + model = _solve_group_model(sys) + container = IOM.get_optimization_container(model) + @test IOM.has_container_key(container, ServiceRequirementVariable, typeof(group)) + @test IOM.has_container_key(container, POM.RequirementConstraint, typeof(group)) + res = IOM.OptimizationProblemOutputs(model) + demand = read_variable( + res, "ServiceRequirementVariable__GroupReserve__ReserveUp"; + table_format = TableFormat.WIDE, + ) + @test setdiff(names(demand), ["DateTime"]) == ["UP_GROUP"] +end + +@testset "GroupStepwiseCostReserve: aggregation binds member awards to group demand" begin + sys, _ = build_group_reserve_system() + model = _solve_group_model(sys) + res = IOM.OptimizationProblemOutputs(model) + demand = read_variable( + res, "ServiceRequirementVariable__GroupReserve__ReserveUp"; + table_format = TableFormat.WIDE, + ) + awards = read_variable( + res, "ActivePowerReserveVariable__OnlineReserve__ReserveUp"; + table_format = TableFormat.WIDE, + ) + sub_cols = _sub_cols(awards, "GROUP_SUB_") + @test !isempty(sub_cols) + for t in 1:24 + @test sum(awards[t, c] for c in sub_cols) ≈ demand[t, "UP_GROUP"] atol = 1e-3 + end +end + +@testset "GroupStepwiseCostReserve: sub-service merit order" begin + sys, _ = build_group_reserve_system() + model = _solve_group_model(sys) + res = IOM.OptimizationProblemOutputs(model) + awards = read_variable( + res, "ActivePowerReserveVariable__OnlineReserve__ReserveUp"; + table_format = TableFormat.WIDE, + ) + sub_a_total = sum(awards[1, c] for c in _sub_cols(awards, "GROUP_SUB_A")) + sub_b_total = sum(awards[1, c] for c in _sub_cols(awards, "GROUP_SUB_B")) + @test sub_a_total > 1.0 + @test sub_b_total <= 1e-2 + @test sub_a_total > sub_b_total +end + +@testset "GroupStepwiseCostReserve: no group model -> no procurement" begin + sys, _ = build_group_reserve_system() + model = _solve_group_model(sys; include_group = false) + res = IOM.OptimizationProblemOutputs(model) + awards = read_variable( + res, "ActivePowerReserveVariable__OnlineReserve__ReserveUp"; + table_format = TableFormat.WIDE, + ) + for t in 1:24, c in _sub_cols(awards, "GROUP_SUB_") + @test awards[t, c] <= 1e-2 + end +end + +@testset "Group formulation pairing fails at ServiceModel declaration" begin + # A GroupReserve accepts only group formulations, and group formulations accept only + # GroupReserve; mis-pairs must fail at declaration, not at build. + @test_throws ArgumentError ServiceModel(GroupReserve{ReserveUp}, RangeReserve) + @test_throws ArgumentError ServiceModel(GroupReserve{ReserveDown}, StepwiseCostReserve) + @test_throws ArgumentError ServiceModel(OnlineReserve{ReserveUp}, GroupRangeReserve) + @test_throws ArgumentError ServiceModel( + OnlineReserve{ReserveUp}, + GroupStepwiseCostReserve, + ) + @test_throws ArgumentError ServiceModel(OfflineReserve, GroupStepwiseCostReserve) + # The valid pairs still construct. + @test ServiceModel(GroupReserve{ReserveUp}, GroupRangeReserve) isa ServiceModel + @test ServiceModel(GroupReserve{ReserveUp}, GroupStepwiseCostReserve) isa ServiceModel +end + +@testset "GroupStepwiseCostReserve: curve-less group is skipped as degenerate demand" begin + sys, group = build_group_reserve_system(; group_curve = false) + model = _solve_group_model(sys) + container = IOM.get_optimization_container(model) + @test !IOM.has_container_key(container, ServiceRequirementVariable, typeof(group)) + @test !IOM.has_container_key(container, POM.RequirementConstraint, typeof(group)) +end + +@testset "GroupStepwiseCostReserve: time-series group curve builds, solves and clears" begin + sys, group = build_group_reserve_system() + baseline_curve = PSY.get_variable(group) + power_units = PSY.get_power_units(baseline_curve) + fd = PSY.get_function_data(PSY.get_value_curve(baseline_curve)) + pwl_ts = make_deterministic_ts( + sys, + "variable_cost", + fd, + (0.0, 0.0, 0.0), + (0.0, 0.0, 0.0); + override_min_x = 0.0, + override_max_x = last(get_x_coords(fd)), + ) + pwl_key = add_time_series!(sys, group, pwl_ts) + PSY.set_variable!(group, PSY.make_market_bid_ts_curve(pwl_key, nothing, power_units)) + + model = _solve_group_model(sys) + res = IOM.OptimizationProblemOutputs(model) + demand = read_variable( + res, "ServiceRequirementVariable__GroupReserve__ReserveUp"; + table_format = TableFormat.WIDE, + ) + awards = read_variable( + res, "ActivePowerReserveVariable__OnlineReserve__ReserveUp"; + table_format = TableFormat.WIDE, + ) + sub_cols = _sub_cols(awards, "GROUP_SUB_") + for t in 1:24 + @test demand[t, "UP_GROUP"] > 1.0 + @test sum(awards[t, c] for c in sub_cols) ≈ demand[t, "UP_GROUP"] atol = 1e-3 + end +end From da42d947014905de373a6443290177efa0fc3b9e Mon Sep 17 00:00:00 2001 From: rodrigomha Date: Thu, 13 Aug 2026 13:15:39 -0700 Subject: [PATCH 07/23] Support OfflineReserve as ORDC supply from storage and loads Non-spinning is upward-only, so OfflineReserve routes like an up reserve everywhere a device supplies it. New UP_RESERVE union in reserve_traits.jl; storage reserve-balance multipliers, coverage branches (two of which silently skipped OfflineReserve, one asserted), get_fraction, and the TotalReserveOffering fold widened; load routing and folding accept it as committed shed. The _modify_device_model! no-op is scoped to NonSpinningReserve, whose awards ride ReservePowerConstraint instead of the device range expressions. --- src/common_models/add_to_expression.jl | 6 +- src/core/problem_template.jl | 7 +- src/core/reserve_traits.jl | 7 ++ src/energy_storage_models/storage_models.jl | 44 ++++++------- src/static_injector_models/electric_loads.jl | 5 +- test/test_device_reserve_offers.jl | 53 +++++++++++++++ test/test_storage_device_models.jl | 68 ++++++++++++++++++++ 7 files changed, 160 insertions(+), 30 deletions(-) diff --git a/src/common_models/add_to_expression.jl b/src/common_models/add_to_expression.jl index e13ecc47..5a0f884f 100644 --- a/src/common_models/add_to_expression.jl +++ b/src/common_models/add_to_expression.jl @@ -1987,9 +1987,7 @@ function add_to_expression!( T <: ActivePowerRangeExpressionUB, U <: VariableType, V <: PSY.Component, - # OfflineReserve (non-spin) is upward-only and has no direction param, so it routes to the - # same upper-bound expression as a ReserveUp reserve. - X <: Union{PSY.Reserve{PSY.ReserveUp}, PSY.OfflineReserve}, + X <: UP_RESERVE, W <: AbstractReservesFormulation, } service_name = PSY.get_name(service) @@ -2426,7 +2424,7 @@ function add_to_expression!( T <: ActivePowerRangeExpressionLB, U <: VariableType, V <: PSY.ElectricLoad, - X <: PSY.Reserve{PSY.ReserveUp}, + X <: UP_RESERVE, W <: AbstractReservesFormulation, } service_name = PSY.get_name(service) diff --git a/src/core/problem_template.jl b/src/core/problem_template.jl index 09296ccb..6e6a9114 100644 --- a/src/core/problem_template.jl +++ b/src/core/problem_template.jl @@ -265,7 +265,7 @@ end function _modify_device_model!( devices_template::Dict{Symbol, DeviceModel}, - service_model::ServiceModel{<:PSY.Reserve, <:AbstractReservesFormulation}, + service_model::ServiceModel{<:PSY.AbstractReserve, <:AbstractReservesFormulation}, contributing_devices::Vector{<:PSY.Component}, ) # Type stability: explicitly type the Set to avoid widening @@ -284,9 +284,12 @@ function _modify_device_model!( return end +# NonSpinningReserve awards ride ReservePowerConstraint (offline thermal headroom), not the +# device range expressions, so device models must not register the service. Other reserve +# formulations (e.g. an OfflineReserve ORDC under StepwiseCostReserve) register normally. function _modify_device_model!( ::Dict{Symbol, DeviceModel}, - ::ServiceModel{<:PSY.OfflineReserve, <:AbstractReservesFormulation}, + ::ServiceModel{<:PSY.OfflineReserve, NonSpinningReserve}, ::Vector{<:PSY.Component}, ) return diff --git a/src/core/reserve_traits.jl b/src/core/reserve_traits.jl index 802204ae..aea5353a 100644 --- a/src/core/reserve_traits.jl +++ b/src/core/reserve_traits.jl @@ -36,6 +36,13 @@ upward-only in every US market, so it maps to `PSY.ReserveUp`. _reserve_direction(::PSY.Reserve{T}) where {T <: PSY.ReserveDirection} = T _reserve_direction(::PSY.OfflineReserve) = PSY.ReserveUp +""" +Upward reserve products a device can supply: up-direction reserves plus `OfflineReserve` +(non-spinning is upward-only). Excludes `GroupReserve` - devices serve a group's members, +never the group itself. +""" +const UP_RESERVE = Union{PSY.Reserve{PSY.ReserveUp}, PSY.OfflineReserve} + "Whether a reserve is non-spinning: `OfflineReserve` vs everything else under `AbstractReserve`." _is_offline(::PSY.OfflineReserve) = true _is_offline(::PSY.AbstractReserve) = false diff --git a/src/energy_storage_models/storage_models.jl b/src/energy_storage_models/storage_models.jl index 0870dd4a..ff4cb320 100644 --- a/src/energy_storage_models/storage_models.jl +++ b/src/energy_storage_models/storage_models.jl @@ -427,7 +427,7 @@ get_variable_multiplier( ::Type{StorageReserveBalanceExpression{PSY.ReserveDown, UnscaledReserve, ChargeSide}}, d::PSY.Storage, ::Type{StorageDispatchWithReserves}, - ::PSY.Reserve{PSY.ReserveUp}, + ::UP_RESERVE, ) = 0.0 get_variable_multiplier( @@ -443,7 +443,7 @@ get_variable_multiplier( ::Type{StorageReserveBalanceExpression{PSY.ReserveUp, UnscaledReserve, ChargeSide}}, d::PSY.Storage, ::Type{StorageDispatchWithReserves}, - ::PSY.Reserve{PSY.ReserveUp}, + ::UP_RESERVE, ) = 1.0 get_variable_multiplier( @@ -461,7 +461,7 @@ get_variable_multiplier( }, d::PSY.Storage, ::Type{StorageDispatchWithReserves}, - ::PSY.Reserve{PSY.ReserveUp}, + ::UP_RESERVE, ) = 0.0 get_variable_multiplier( @@ -479,7 +479,7 @@ get_variable_multiplier( ::Type{StorageReserveBalanceExpression{PSY.ReserveUp, UnscaledReserve, DischargeSide}}, d::PSY.Storage, ::Type{StorageDispatchWithReserves}, - ::PSY.Reserve{PSY.ReserveUp}, + ::UP_RESERVE, ) = 1.0 get_variable_multiplier( @@ -496,7 +496,7 @@ get_variable_multiplier( ::Type{StorageReserveBalanceExpression{PSY.ReserveDown, DeployedReserve, ChargeSide}}, d::PSY.Storage, ::Type{StorageDispatchWithReserves}, - ::PSY.Reserve{PSY.ReserveUp}, + ::UP_RESERVE, ) = 0.0 get_variable_multiplier( @@ -512,7 +512,7 @@ get_variable_multiplier( ::Type{StorageReserveBalanceExpression{PSY.ReserveUp, DeployedReserve, ChargeSide}}, d::PSY.Storage, ::Type{StorageDispatchWithReserves}, - ::PSY.Reserve{PSY.ReserveUp}, + ::UP_RESERVE, ) = 1.0 get_variable_multiplier( @@ -530,7 +530,7 @@ get_variable_multiplier( }, d::PSY.Storage, ::Type{StorageDispatchWithReserves}, - ::PSY.Reserve{PSY.ReserveUp}, + ::UP_RESERVE, ) = 0.0 get_variable_multiplier( @@ -548,7 +548,7 @@ get_variable_multiplier( ::Type{StorageReserveBalanceExpression{PSY.ReserveUp, DeployedReserve, DischargeSide}}, d::PSY.Storage, ::Type{StorageDispatchWithReserves}, - ::PSY.Reserve{PSY.ReserveUp}, + ::UP_RESERVE, ) = 1.0 get_variable_multiplier( @@ -561,16 +561,16 @@ get_variable_multiplier( #! format: off # Use 1.0 because this is to allow to reuse the code below on add_to_expression -get_fraction(::Type{StorageReserveBalanceExpression{PSY.ReserveUp, UnscaledReserve, DischargeSide}}, d::PSY.Reserve) = 1.0 -get_fraction(::Type{StorageReserveBalanceExpression{PSY.ReserveUp, UnscaledReserve, ChargeSide}}, d::PSY.Reserve) = 1.0 -get_fraction(::Type{StorageReserveBalanceExpression{PSY.ReserveDown, UnscaledReserve, DischargeSide}}, d::PSY.Reserve) = 1.0 -get_fraction(::Type{StorageReserveBalanceExpression{PSY.ReserveDown, UnscaledReserve, ChargeSide}}, d::PSY.Reserve) = 1.0 +get_fraction(::Type{StorageReserveBalanceExpression{PSY.ReserveUp, UnscaledReserve, DischargeSide}}, d::PSY.AbstractReserve) = 1.0 +get_fraction(::Type{StorageReserveBalanceExpression{PSY.ReserveUp, UnscaledReserve, ChargeSide}}, d::PSY.AbstractReserve) = 1.0 +get_fraction(::Type{StorageReserveBalanceExpression{PSY.ReserveDown, UnscaledReserve, DischargeSide}}, d::PSY.AbstractReserve) = 1.0 +get_fraction(::Type{StorageReserveBalanceExpression{PSY.ReserveDown, UnscaledReserve, ChargeSide}}, d::PSY.AbstractReserve) = 1.0 # Needs to implement served fraction in PSY -get_fraction(::Type{StorageReserveBalanceExpression{PSY.ReserveUp, DeployedReserve, DischargeSide}}, d::PSY.Reserve) = PSY.get_deployed_fraction(d) -get_fraction(::Type{StorageReserveBalanceExpression{PSY.ReserveUp, DeployedReserve, ChargeSide}}, d::PSY.Reserve) = PSY.get_deployed_fraction(d) -get_fraction(::Type{StorageReserveBalanceExpression{PSY.ReserveDown, DeployedReserve, DischargeSide}}, d::PSY.Reserve) = PSY.get_deployed_fraction(d) -get_fraction(::Type{StorageReserveBalanceExpression{PSY.ReserveDown, DeployedReserve, ChargeSide}}, d::PSY.Reserve) = PSY.get_deployed_fraction(d) +get_fraction(::Type{StorageReserveBalanceExpression{PSY.ReserveUp, DeployedReserve, DischargeSide}}, d::PSY.AbstractReserve) = PSY.get_deployed_fraction(d) +get_fraction(::Type{StorageReserveBalanceExpression{PSY.ReserveUp, DeployedReserve, ChargeSide}}, d::PSY.AbstractReserve) = PSY.get_deployed_fraction(d) +get_fraction(::Type{StorageReserveBalanceExpression{PSY.ReserveDown, DeployedReserve, DischargeSide}}, d::PSY.AbstractReserve) = PSY.get_deployed_fraction(d) +get_fraction(::Type{StorageReserveBalanceExpression{PSY.ReserveDown, DeployedReserve, ChargeSide}}, d::PSY.AbstractReserve) = PSY.get_deployed_fraction(d) #! format: on function add_to_expression!( @@ -719,7 +719,7 @@ function add_to_expression!( T <: TotalReserveOffering, U <: ActivePowerReserveVariable, UV <: PSY.Storage, - V <: PSY.Reserve, + V <: PSY.AbstractReserve, W <: AbstractReservesFormulation, } s_name = PSY.get_name(service) @@ -976,7 +976,7 @@ function add_constraints!( for service in services_set service_name = PSY.get_name(service) - if typeof(service) <: PSY.Reserve{PSY.ReserveUp} + if typeof(service) <: UP_RESERVE add_constraints_container!(container, T, V, names, @@ -1021,7 +1021,7 @@ function add_constraints!( V, _service_container_meta(service), ) - if typeof(service) <: PSY.Reserve{PSY.ReserveUp} + if typeof(service) <: UP_RESERVE con_discharge = get_constraint( container, T(), @@ -1118,7 +1118,7 @@ function add_constraints!( services_types = unique(typeof.(services_set)) for serv_type in services_types - if serv_type <: PSY.Reserve{PSY.ReserveUp} + if serv_type <: UP_RESERVE add_constraints_container!(container, T, V, names, @@ -1165,7 +1165,7 @@ function add_constraints!( V, _service_container_meta(service), ) - if typeof(service) <: PSY.Reserve{PSY.ReserveUp} + if typeof(service) <: UP_RESERVE push!( expr_up_discharge, sustained_param_discharge * reserve_var_discharge[ci_name, :], @@ -1180,7 +1180,7 @@ function add_constraints!( end end for serv_type in services_types - if serv_type <: PSY.Reserve{PSY.ReserveUp} + if serv_type <: UP_RESERVE con_discharge = get_constraint(container, T(), V, "$(serv_type)_discharge") total_sustained = JuMP.AffExpr() diff --git a/src/static_injector_models/electric_loads.jl b/src/static_injector_models/electric_loads.jl index 5cee88bd..79bbc60f 100644 --- a/src/static_injector_models/electric_loads.jl +++ b/src/static_injector_models/electric_loads.jl @@ -39,8 +39,9 @@ variable_cost(cost::PSY.OperationalCost, ::Type{ShiftDownActivePowerVariable}, : ########################### Reserve provision, ElectricLoad ################################ # The inverse of a generator: up reserve is shed (P - r_up >= 0), down reserve is extra -# consumption (P + r_down <= forecast). Loads do not provide OfflineReserve. -get_expression_type_for_reserve(::Type{ActivePowerReserveVariable}, ::Type{<:PSY.ElectricLoad}, ::Type{<:PSY.Reserve{PSY.ReserveUp}}) = ActivePowerRangeExpressionLB +# consumption (P + r_down <= forecast). OfflineReserve (non-spin) is upward-only, so a +# load provides it as committed shed like any up reserve. +get_expression_type_for_reserve(::Type{ActivePowerReserveVariable}, ::Type{<:PSY.ElectricLoad}, ::Type{<:UP_RESERVE}) = ActivePowerRangeExpressionLB get_expression_type_for_reserve(::Type{ActivePowerReserveVariable}, ::Type{<:PSY.ElectricLoad}, ::Type{<:PSY.Reserve{PSY.ReserveDown}}) = ActivePowerRangeExpressionUB ###################################################### diff --git a/test/test_device_reserve_offers.jl b/test/test_device_reserve_offers.jl index 3c7feaa3..39a081c2 100644 --- a/test/test_device_reserve_offers.jl +++ b/test/test_device_reserve_offers.jl @@ -488,6 +488,59 @@ end end end +@testset "OfflineReserve as ORDC: load and generator supply" begin + sys = build_reserve_market_system() + thermals = collect(get_components(ThermalStandard, sys)) + il = get_component(PSY.InterruptiblePowerLoad, sys, _MKT_LOAD) + nspin = OfflineReserve(; + name = "NSPIN", + available = true, + time_frame = 30.0, + variable = _mkt_curve([0.0, 100.0, 200.0], [65.0, 11.0]), + ) + add_service!(sys, nspin, vcat(PSY.Device[thermals...], il)) + # Every participant carries an offer: an un-offered contributor supplies for free and + # would crowd out the load's priced block. + for (i, g) in enumerate(thermals) + PSY.set_service_bid!( + sys, g, nspin, _mkt_offer_ts(nspin, 30.0, 6.0 + i), IS.NaturalUnit(), + ) + end + nspin_offer = 8.0 + PSY.set_service_bid!( + sys, il, nspin, _mkt_offer_ts(nspin, nspin_offer, 3.0), IS.NaturalUnit(), + ) + + template = _reserve_market_template() + set_service_model!(template, ServiceModel(OfflineReserve, StepwiseCostReserve)) + model = DecisionModel( + template, sys; + optimizer = HiGHS_optimizer, store_variable_names = true, + ) + @test build!(model; output_dir = mktempdir(; cleanup = true)) == + IOM.ModelBuildStatus.BUILT + @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED + + res = IOM.OptimizationProblemOutputs(model) + demand = read_variable( + res, "ServiceRequirementVariable__OfflineReserve"; + table_format = TableFormat.WIDE, + ) + awards = read_variable( + res, "ActivePowerReserveVariable__OfflineReserve"; + table_format = TableFormat.WIDE, + ) + load_col = "NSPIN__$(_MKT_LOAD)" + @test load_col in names(awards) + for t in 1:24 + @test demand[t, "NSPIN"] > 1.0 + # The load's cheapest-in-stack block clears in full and stays offer-bounded: its + # non-spin award rides the same shed-headroom (LB) routing as any up reserve. + @test awards[t, load_col] <= nspin_offer + 1e-3 + @test awards[t, load_col] >= nspin_offer - 1e-2 + end +end + ################################################################################# # Load reserve provision (PowerLoadDispatch) ################################################################################# diff --git a/test/test_storage_device_models.jl b/test/test_storage_device_models.jl index 7da27927..1b9db11b 100644 --- a/test/test_storage_device_models.jl +++ b/test/test_storage_device_models.jl @@ -319,6 +319,74 @@ end =# moi_tests(model, 434, 0, 526, 286, 125, false) end +@testset "OfflineReserve (non-spin) as ORDC supplied by storage" begin + sys = PSB.build_system(PSITestSystems, "c_sys5_bat"; add_reserves = false) + nspin = OfflineReserve(; + name = "NSPIN", + available = true, + time_frame = 30.0, + sustained_time = 3600.0, + variable = make_market_bid_curve( + [0.0, 20.0, 40.0], [60.0, 10.0], 0.0; power_units = IS.NaturalUnit(), + ), + ) + bat = get_component(EnergyReservoirStorage, sys, "Bat") + thermals = collect(get_components(ThermalStandard, sys)) + add_service!(sys, nspin, vcat(PSY.Device[thermals...], bat)) + + template = get_thermal_dispatch_template_network(CopperPlateNetworkModel) + set_device_model!( + template, + DeviceModel( + EnergyReservoirStorage, + StorageDispatchWithReserves; + attributes = Dict{String, Any}( + "reservation" => true, + "cycling_limits" => false, + "energy_target" => false, + "complete_coverage" => true, + "regularization" => false, + ), + ), + ) + set_device_model!(template, RenewableDispatch, FixedOutput) + set_service_model!(template, ServiceModel(OfflineReserve, StepwiseCostReserve)) + + model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) + @test build!(model; output_dir = mktempdir(; cleanup = true)) == + ModelBuildStatus.BUILT + + # The up-side SOC coverage rows must exist for the non-spin product (it routes as an + # upward reserve), including the complete-coverage family. + container = IOM.get_optimization_container(model) + meta = POM._service_container_meta(nspin) + @test IOM.has_container_key( + container, ReserveCoverageConstraint, EnergyReservoirStorage, + "$(meta)_discharge", + ) + @test IOM.has_container_key( + container, POM.ReserveCompleteCoverageConstraint, EnergyReservoirStorage, + "$(OfflineReserve{IS.NaturalUnit})_discharge", + ) + + # Balance rows carry charge + discharge - award: the award term is wired, not skipped. + con = IOM.get_constraints(model)[IOM.ConstraintKey( + StorageTotalReserveConstraint, OfflineReserve, "NSPIN_$EnergyReservoirStorage", + )] + @test all( + length(JuMP.constraint_object(con[n, t]).func.terms) == 3 + for n in axes(con)[1], t in axes(con)[2] + ) + + @test solve!(model) == RunStatus.SUCCESSFULLY_FINALIZED + res = IOM.OptimizationProblemOutputs(model) + demand = read_variable( + res, "ServiceRequirementVariable__OfflineReserve"; + table_format = TableFormat.WIDE, + ) + @test all(demand[t, "NSPIN"] > 1.0 for t in 1:24) +end + @testset "Test Storage Energy Target Constraint" begin template = get_thermal_dispatch_template_network(CopperPlateNetworkModel) device_model = DeviceModel( From 3382f8f9b98c01cc9b0719c4b110735416645cd6 Mon Sep 17 00:00:00 2001 From: rodrigomha Date: Sat, 15 Aug 2026 21:41:01 -0700 Subject: [PATCH 08/23] Pin PSY psy6's unregistered OpenAPI deps in every environment PowerSystems psy6 (post schema-matching merge) depends on the unregistered PowerCoreOpenAPIModels / PowerOperationsOpenAPIModels. Pkg ignores [sources] of non-root projects, so each environment that resolves PSY - root, test, docs - must pin them itself; CI failed with 'PowerOperationsOpenAPIModels has no known versions' on all jobs. Pins mirror PSY's own (monorepo main, subdirs) and are temporary until the packages are registered. --- docs/Project.toml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/Project.toml b/docs/Project.toml index 19dd7aa0..75488ae4 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -6,6 +6,8 @@ DocumenterInterLinks = "d12716ef-a0f6-4df4-a9f1-a5a34e75c656" InfrastructureOptimizationModels = "bed98974-b02a-5e2f-9ee0-a103f5c45069" InfrastructureSystems = "2cd47ed4-ca9b-11e9-27f2-ab636a7671f1" Literate = "98b081ad-f1c9-55d3-8b20-4c87d4299306" +PowerCoreOpenAPIModels = "b7b40286-e793-417d-a9a0-b1583e4da1cb" +PowerOperationsOpenAPIModels = "a372b6d7-45a2-44c2-8199-6a724b72e8ff" PowerNetworkMatrices = "bed98974-b02a-5e2f-9fe0-a103f5c450dd" PowerOperationsModels = "bed98974-b02a-5e2f-9ee0-a103f5c450dd" PowerSystems = "bcd98974-b02a-5e2f-9ee0-a103f5c450dd" @@ -16,6 +18,11 @@ InfrastructureSystems = {rev = "IS4", url = "https://github.com/Sienna-Platform/ PowerSystems = {rev = "psy6", url = "https://github.com/Sienna-Platform/PowerSystems.jl"} InfrastructureOptimizationModels = {rev = "main", url = "https://github.com/Sienna-Platform/InfrastructureOptimizationModels.jl"} PowerNetworkMatrices = {rev = "psy6", url = "https://github.com/Sienna-Platform/PowerNetworkMatrices.jl"} +# PSY psy6 depends on these unregistered packages; [sources] of non-root projects are +# ignored by Pkg, so every environment resolving PSY must pin them itself (same rev as +# PSY's own sources). Temporary until the OpenAPI packages are registered. +PowerCoreOpenAPIModels = {rev = "main", url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", subdir = "PowerCoreOpenAPIModels.jl"} +PowerOperationsOpenAPIModels = {rev = "main", url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", subdir = "PowerOperationsOpenAPIModels.jl"} [compat] Documenter = "^1.0" From fb9bbf3569373de4d8169e66246fec6fa198c632 Mon Sep 17 00:00:00 2001 From: rodrigomha Date: Thu, 13 Aug 2026 08:43:34 -0700 Subject: [PATCH 09/23] Add GroupStepwiseCostReserve: elastic group ORDC over member-service supply One demand curve on a PSY.GroupReserve is cleared by the summed awards of its contributing services: a dense ServiceRequirementVariable per group, a clearing constraint sum(member awards) >= demand variable (its dual is the group price), and the group curve priced through the delta-PWL path. Static and time-series group curves are both supported via the existing service-side TS machinery. - Group demand predicates mirror the service formulations: GroupRangeReserve is driven by the scalar requirement, GroupStepwiseCostReserve by the demand curve; degenerate groups skip as supply aggregates with a warning. - Group deferral generalized to a vector so up and down groups coexist. - RESERVE_PRODUCT_TYPES (definitions.jl) consolidates the open Union{PSY.AbstractReserve, PSY.GroupReserve} signature bound used across the reserve traits, PWL parameter chain, and objective plumbing. - Formulation-pairing guards: a GroupReserve accepts only group formulations and vice versa, failing with ArgumentError at ServiceModel declaration. - Tests cover build/solve, aggregation binding, merit order, no-group baseline, the degenerate skip, TS group curves, and the pairing guards. --- src/PowerOperationsModels.jl | 1 + src/common_models/add_expressions.jl | 24 +++ src/common_models/add_parameters.jl | 18 +- src/common_models/market_bid_overrides.jl | 2 +- src/common_models/market_bid_plumbing.jl | 2 +- src/core/definitions.jl | 6 + src/core/formulations.jl | 7 + src/core/reserve_traits.jl | 4 +- src/services_models/reserve_group.jl | 80 +++++-- src/services_models/reserves.jl | 77 +++++-- src/services_models/services_constructor.jl | 140 +++++++++--- test/test_group_stepwise_reserve.jl | 228 ++++++++++++++++++++ 12 files changed, 520 insertions(+), 69 deletions(-) create mode 100644 test/test_group_stepwise_reserve.jl diff --git a/src/PowerOperationsModels.jl b/src/PowerOperationsModels.jl index 1795d544..7cad215a 100644 --- a/src/PowerOperationsModels.jl +++ b/src/PowerOperationsModels.jl @@ -923,6 +923,7 @@ export AbstractServiceFormulation export AbstractReservesFormulation export PIDSmoothACE export GroupRangeReserve +export GroupStepwiseCostReserve export RangeReserve export StepwiseCostReserve export RampReserve diff --git a/src/common_models/add_expressions.jl b/src/common_models/add_expressions.jl index c4e2872b..0b1ed79d 100644 --- a/src/common_models/add_expressions.jl +++ b/src/common_models/add_expressions.jl @@ -244,3 +244,27 @@ function add_expressions!( ) return end + +# Group sibling of the reserve method above (`GroupReserve <: Service`, so it cannot share +# the `V <: PSY.AbstractReserve` bound; a `Union` bound on `V` would be ambiguous against it). +function add_expressions!( + container::OptimizationContainer, + ::Type{T}, + services::U, + model::ServiceModel{V, W}, +) where { + T <: CostExpressions, + U <: Union{Vector{D}, IS.FlattenIteratorWrapper{D}}, + V <: PSY.GroupReserve, + W <: AbstractReservesFormulation, +} where {D <: PSY.Component} + time_steps = get_time_steps(container) + add_expression_container!( + container, + T, + D, + [PSY.get_name(s) for s in services], + time_steps, + ) + return +end diff --git a/src/common_models/add_parameters.jl b/src/common_models/add_parameters.jl index d28b37e2..d08b3281 100644 --- a/src/common_models/add_parameters.jl +++ b/src/common_models/add_parameters.jl @@ -70,8 +70,8 @@ function add_parameters!( AbstractPiecewiseLinearSlopeParameter, AbstractPiecewiseLinearBreakpointParameter, }, - U <: PSY.AbstractReserve, - V <: PSY.AbstractReserve, + U <: RESERVE_PRODUCT_TYPES, + V <: RESERVE_PRODUCT_TYPES, W <: AbstractServiceFormulation, } if get_rebuild_model(get_settings(container)) && has_container_key(container, T, U) @@ -424,7 +424,7 @@ _get_time_series_name( DecrementalPiecewiseLinearBreakpointParameter, }, }, - service::PSY.AbstractReserve, + service::RESERVE_PRODUCT_TYPES, ::ServiceModel, ) = IS.get_name(IS.get_time_series_key(PSY.get_variable(service))) @@ -590,8 +590,8 @@ _ordc_ts_data(ts::IS.DeterministicSingleTimeSeries) = function calc_additional_axes( ::OptimizationContainer, ::Type{P}, - services::Vector{<:PSY.AbstractReserve}, - ::ServiceModel{<:PSY.AbstractReserve, W}, + services::Vector{<:RESERVE_PRODUCT_TYPES}, + ::ServiceModel{<:RESERVE_PRODUCT_TYPES, W}, ) where { P <: AbstractPiecewiseLinearSlopeParameter, W <: AbstractServiceFormulation, @@ -605,8 +605,8 @@ end function calc_additional_axes( ::OptimizationContainer, ::Type{P}, - services::Vector{<:PSY.AbstractReserve}, - ::ServiceModel{<:PSY.AbstractReserve, W}, + services::Vector{<:RESERVE_PRODUCT_TYPES}, + ::ServiceModel{<:RESERVE_PRODUCT_TYPES, W}, ) where { P <: AbstractPiecewiseLinearBreakpointParameter, W <: AbstractServiceFormulation, @@ -809,8 +809,8 @@ function _add_parameters!( AbstractPiecewiseLinearSlopeParameter, AbstractPiecewiseLinearBreakpointParameter, }, - U <: PSY.AbstractReserve, - V <: PSY.AbstractReserve, + U <: RESERVE_PRODUCT_TYPES, + V <: RESERVE_PRODUCT_TYPES, W <: AbstractServiceFormulation, } _add_objective_function_parameters!(container, T, services, model, W) diff --git a/src/common_models/market_bid_overrides.jl b/src/common_models/market_bid_overrides.jl index 7be945c1..8b20ea35 100644 --- a/src/common_models/market_bid_overrides.jl +++ b/src/common_models/market_bid_overrides.jl @@ -348,7 +348,7 @@ function add_pwl_term_delta!( ::Type{U}, ::Type{V}, ) where { - T <: PSY.AbstractReserve, + T <: RESERVE_PRODUCT_TYPES, U <: VariableType, V <: AbstractServiceFormulation, } diff --git a/src/common_models/market_bid_plumbing.jl b/src/common_models/market_bid_plumbing.jl index 1c32006d..fa73a602 100644 --- a/src/common_models/market_bid_plumbing.jl +++ b/src/common_models/market_bid_plumbing.jl @@ -110,7 +110,7 @@ get_offer_curves(::IOM.IncrementalOffer, op_cost::PSY.OfferCurveCost) = # service-side direction trait (`_reserve_offer_direction`) is decremental. get_offer_curves( ::IOM.OfferDirection, - service::PSY.AbstractReserve, + service::RESERVE_PRODUCT_TYPES, ) = PSY.get_variable(service) ################################################################################# diff --git a/src/core/definitions.jl b/src/core/definitions.jl index 653d3a92..0dae365c 100644 --- a/src/core/definitions.jl +++ b/src/core/definitions.jl @@ -112,4 +112,10 @@ const IGNORABLE_FILES = [ ] const OUTPUTS_DIR = "outputs" +# Any reserve product that carries demand-side state (requirement / demand curve): the +# device-backed reserve tree plus service-aggregating groups (`PSY.GroupReserve <: Service`, +# outside that tree). `AbstractReserve` keeps the union OPEN to future reserve subtypes. +# Signature-position only - never use as a field type or container eltype. +const RESERVE_PRODUCT_TYPES = Union{PSY.AbstractReserve, PSY.GroupReserve} + IS.@scoped_enum(COMPACT_PWL_STATUS, VALID = 1, INVALID = 2, UNDETERMINED = 3) diff --git a/src/core/formulations.jl b/src/core/formulations.jl index 94a3615c..67ffa7f0 100644 --- a/src/core/formulations.jl +++ b/src/core/formulations.jl @@ -436,6 +436,13 @@ with the `PSY.GroupReserve` component type. """ struct GroupRangeReserve <: AbstractReservesFormulation end +""" +Group analogue of [`StepwiseCostReserve`](@ref): one elastic demand curve (the `PSY.GroupReserve`'s +`variable`) is met by the summed awards of its contributing services - one demand, one clearing +price, with offers and caps living on the members. Ignores the group's `requirement`. +""" +struct GroupStepwiseCostReserve <: AbstractReservesFormulation end + """ Struct for to add reserves to be larger than a specified requirement """ diff --git a/src/core/reserve_traits.jl b/src/core/reserve_traits.jl index b770da79..5f00b60c 100644 --- a/src/core/reserve_traits.jl +++ b/src/core/reserve_traits.jl @@ -60,8 +60,8 @@ end # (union-splits cleanly over the two `variable` members; reserves are few and read at build, # so the cost is negligible). -"Whether a reserve's ORDC curve is time-varying. Dispatches on the value-curve type (no `isa`)." -_ordc_is_ts(s::PSY.AbstractReserve) = +"Whether a reserve's or group's ORDC curve is time-varying. Dispatches on the value-curve type (no `isa`)." +_ordc_is_ts(s::RESERVE_PRODUCT_TYPES) = _value_curve_is_ts(PSY.get_value_curve(PSY.get_variable(s))) _value_curve_is_ts(::PSY.TimeSeriesPiecewiseIncrementalCurve) = true _value_curve_is_ts(::PSY.PiecewiseIncrementalCurve) = false diff --git a/src/services_models/reserve_group.jl b/src/services_models/reserve_group.jl index e3b0276f..c495c1b5 100644 --- a/src/services_models/reserve_group.jl +++ b/src/services_models/reserve_group.jl @@ -10,6 +10,18 @@ function get_default_attributes( return Dict{String, Any}() end +function get_default_time_series_names( + ::Type{PSY.GroupReserve{T}}, + ::Type{GroupStepwiseCostReserve}) where {T <: PSY.ReserveDirection} + return Dict{String, Any}() +end + +function get_default_attributes( + ::Type{PSY.GroupReserve{T}}, + ::Type{GroupStepwiseCostReserve}) where {T <: PSY.ReserveDirection} + return Dict{String, Any}() +end + # ── Formulation-pairing guards ──────────────────────────────────────────────────────── # A `PSY.GroupReserve` aggregates other services, so only group formulations can model it, # and group formulations can model nothing else. These fallbacks fire inside the @@ -17,13 +29,15 @@ end # model fails at DECLARATION with an actionable message instead of a cryptic dispatch error # (or a silent no-op) at build time. The valid direction-applied pairs above are more # specific and win. +const _GROUP_FORMULATIONS = Union{GroupRangeReserve, GroupStepwiseCostReserve} function _throw_group_pairing_error(D::Type, B::Type) throw( ArgumentError( "ServiceModel($(D), $(B)) is invalid: `PSY.GroupReserve` aggregates other \ - services and must use a group formulation (e.g. GroupRangeReserve), and group \ - formulations apply only to `PSY.GroupReserve`.", + services and must use a group formulation (GroupRangeReserve or \ + GroupStepwiseCostReserve), and group formulations apply only to \ + `PSY.GroupReserve`.", ), ) end @@ -57,32 +71,36 @@ get_default_attributes( get_default_time_series_names( ::Type{D}, - ::Type{GroupRangeReserve}, -) where {D <: PSY.AbstractReserve} = _throw_group_pairing_error(D, GroupRangeReserve) + ::Type{B}, +) where {D <: PSY.AbstractReserve, B <: _GROUP_FORMULATIONS} = + _throw_group_pairing_error(D, B) get_default_attributes( ::Type{D}, - ::Type{GroupRangeReserve}, -) where {D <: PSY.AbstractReserve} = _throw_group_pairing_error(D, GroupRangeReserve) + ::Type{B}, +) where {D <: PSY.AbstractReserve, B <: _GROUP_FORMULATIONS} = + _throw_group_pairing_error(D, B) # Disambiguates the two guards' intersection (`GroupReserve <: AbstractReserve`) and gives # the bare-type declaration an actionable message. -_throw_group_direction_error(D::Type) = throw( +_throw_group_direction_error(D::Type, B::Type) = throw( ArgumentError( - "ServiceModel($(D), GroupRangeReserve) needs the reserve direction applied, \ - e.g. `ServiceModel(GroupReserve{ReserveUp}, GroupRangeReserve)`.", + "ServiceModel($(D), $(B)) needs the reserve direction applied, \ + e.g. `ServiceModel(GroupReserve{ReserveUp}, $(B))`.", ), ) get_default_time_series_names( ::Type{D}, - ::Type{GroupRangeReserve}, -) where {D <: PSY.GroupReserve} = _throw_group_direction_error(D) + ::Type{B}, +) where {D <: PSY.GroupReserve, B <: _GROUP_FORMULATIONS} = + _throw_group_direction_error(D, B) get_default_attributes( ::Type{D}, - ::Type{GroupRangeReserve}, -) where {D <: PSY.GroupReserve} = _throw_group_direction_error(D) + ::Type{B}, +) where {D <: PSY.GroupReserve, B <: _GROUP_FORMULATIONS} = + _throw_group_direction_error(D, B) ############################### Reserve Variables` ######################################### """ @@ -140,6 +158,42 @@ function add_constraints!( return end +################################ Group Stepwise (elastic) clearing ########################## +""" +Clearing constraint for [`GroupStepwiseCostReserve`](@ref): the summed member awards cover the +group's `ServiceRequirementVariable` (the demand bought along the group's curve). Its dual is +the group clearing price. +""" +function add_constraints!( + container::OptimizationContainer, + ::Type{RequirementConstraint}, + service::SR, + contributing_services::Vector{<:PSY.Service}, + model::ServiceModel{SR, GroupStepwiseCostReserve}, +) where {SR <: PSY.GroupReserve} + time_steps = get_time_steps(container) + service_name = PSY.get_name(service) + constraint = get_constraint(container, RequirementConstraint, SR) + requirement_variable = get_variable(container, ServiceRequirementVariable, SR) + + member_vars = _group_member_variables(container, contributing_services, time_steps) + jump_model = get_jump_model(container) + + for t in time_steps + vars = member_vars[t] + resource_expression = IOM.get_hinted_aff_expr(length(vars)) + for var in vars + JuMP.add_to_expression!(resource_expression, var) + end + constraint[service_name, t] = JuMP.@constraint( + jump_model, + resource_expression >= requirement_variable[service_name, t] + ) + end + + return +end + # Collect the group's contributing reserve variables into one bucket per time step, so the # constraint loop above indexes straight in rather than re-scanning per `(group, t)`. Services # of the same type share one `(service_name, device_name, time)` container, so each container diff --git a/src/services_models/reserves.jl b/src/services_models/reserves.jl index d4bec34b..3ee0472d 100644 --- a/src/services_models/reserves.jl +++ b/src/services_models/reserves.jl @@ -12,10 +12,12 @@ end get_variable_lower_bound(::Type{ActivePowerReserveVariable}, ::PSY.AbstractReserve, ::PSY.Device, ::Type) = 0.0 ############################### ServiceRequirementVariable (ORDC / StepwiseCostReserve) ################################ -# Only created on the StepwiseCostReserve construct path, so the formulation gates these to ORDC reserves. -get_variable_binary(::Type{ServiceRequirementVariable}, ::Type{<:PSY.AbstractReserve}, ::Type{<:AbstractReservesFormulation}) = false -get_variable_upper_bound(::Type{ServiceRequirementVariable}, ::PSY.AbstractReserve, d::PSY.Component, ::Type{<:AbstractReservesFormulation}) = PSY.get_max_active_power(d, PSY.SU) -get_variable_lower_bound(::Type{ServiceRequirementVariable}, ::PSY.AbstractReserve, ::PSY.Component, ::Type{<:AbstractReservesFormulation}) = 0.0 +# Only created on the StepwiseCostReserve / GroupStepwiseCostReserve construct paths, so the +# formulation gates these to curve-bearing reserves and groups (`GroupReserve <: Service`, hence +# the Union). +get_variable_binary(::Type{ServiceRequirementVariable}, ::Type{<:RESERVE_PRODUCT_TYPES}, ::Type{<:AbstractReservesFormulation}) = false +get_variable_upper_bound(::Type{ServiceRequirementVariable}, ::RESERVE_PRODUCT_TYPES, d::PSY.Component, ::Type{<:AbstractReservesFormulation}) = PSY.get_max_active_power(d, PSY.SU) +get_variable_lower_bound(::Type{ServiceRequirementVariable}, ::RESERVE_PRODUCT_TYPES, ::PSY.Component, ::Type{<:AbstractReservesFormulation}) = 0.0 # Reserve requirement in system units; the getter is units-aware for every reserve type. _get_requirement(service) = PSY.get_requirement(service, PSY.SU) @@ -46,6 +48,18 @@ _has_reserve_demand( service::PSY.AbstractReserve, ) = PSY.has_demand_curve(service) +# Group formulations mirror the service ones: `GroupRangeReserve` is driven by the group's +# scalar requirement, `GroupStepwiseCostReserve` by its demand curve (`requirement` ignored). +_has_reserve_demand( + ::ServiceModel{<:PSY.GroupReserve, GroupRangeReserve}, + group::PSY.GroupReserve, +) = !iszero(_get_requirement(group)) + +_has_reserve_demand( + ::ServiceModel{<:PSY.GroupReserve, GroupStepwiseCostReserve}, + group::PSY.GroupReserve, +) = PSY.has_demand_curve(group) + "Services in `services` that impose a demand of their own under `model`'s formulation." _demand_services(model::ServiceModel, services::Vector{<:PSY.AbstractReserve}) = [s for s in services if _has_reserve_demand(model, s)] @@ -65,12 +79,12 @@ _is_group_member(::PSY.System, ::PSY.GroupReserve) = false function _log_skipped_reserve_demand( sys::PSY.System, - service::PSY.AbstractReserve, - ::ServiceModel{<:PSY.AbstractReserve, F}, + service::RESERVE_PRODUCT_TYPES, + ::ServiceModel{<:RESERVE_PRODUCT_TYPES, F}, ) where {F <: AbstractReservesFormulation} name = PSY.get_name(service) - reason = F === StepwiseCostReserve ? "it has no operating reserve demand curve" : - "its requirement is zero" + reason = F in (StepwiseCostReserve, GroupStepwiseCostReserve) ? + "it has no operating reserve demand curve" : "its requirement is zero" if _is_group_member(sys, service) @debug "Service $(name) of type $(typeof(service)) is a GroupReserve member and $(reason); \ skipping its own demand-side model. It still contributes supply to its group." _group = @@ -90,15 +104,17 @@ get_parameter_multiplier(::Type{<:VariableValueParameter}, d::Type{<:PSY.Abstrac get_initial_parameter_value(::Type{<:VariableValueParameter}, d::Type{<:PSY.AbstractReserve}, ::Type{<:AbstractReservesFormulation}) = 0.0 objective_function_multiplier(::Type{ServiceRequirementVariable}, ::Type{StepwiseCostReserve}) = -1.0 +objective_function_multiplier(::Type{ServiceRequirementVariable}, ::Type{GroupStepwiseCostReserve}) = -1.0 uses_compact_power(::PSY.AbstractReserve, ::StepwiseCostReserve)=false -get_multiplier_value(::Type{<:AbstractPiecewiseLinearBreakpointParameter}, ::PSY.AbstractReserve, ::Type{<:AbstractReservesFormulation}) = 1.0 -get_multiplier_value(::Type{<:AbstractPiecewiseLinearSlopeParameter}, ::PSY.AbstractReserve, ::Type{<:AbstractReservesFormulation}) = 1.0 +uses_compact_power(::PSY.GroupReserve, ::GroupStepwiseCostReserve)=false +get_multiplier_value(::Type{<:AbstractPiecewiseLinearBreakpointParameter}, ::RESERVE_PRODUCT_TYPES, ::Type{<:AbstractReservesFormulation}) = 1.0 +get_multiplier_value(::Type{<:AbstractPiecewiseLinearSlopeParameter}, ::RESERVE_PRODUCT_TYPES, ::Type{<:AbstractReservesFormulation}) = 1.0 # Operating reserve demand curves (ORDC) are willingness-to-pay (concave), i.e. a decremental # offer. # Routes the reserve PWL cost path through IOM's OfferDirection dispatch; making # this incremental is a one-line change here. Mirrors `_onvar_offer_direction` / # `_vom_offer_direction` in market_bid_overrides.jl. -_reserve_offer_direction(::PSY.AbstractReserve) = IOM.DecrementalOffer() +_reserve_offer_direction(::RESERVE_PRODUCT_TYPES) = IOM.DecrementalOffer() #! format: on function get_initial_conditions_service_model( @@ -108,6 +124,14 @@ function get_initial_conditions_service_model( return ServiceModel(T, D) end +# `GroupReserve <: Service`, so the `AbstractReserve` method cannot cover it. +function get_initial_conditions_service_model( + ::IOM.AbstractOptimizationModel, + ::ServiceModel{T, D}, +) where {T <: PSY.GroupReserve, D <: AbstractReservesFormulation} + return ServiceModel(T, D) +end + function get_default_time_series_names( ::Type{<:PSY.Reserve}, ::Type{T}, @@ -150,7 +174,7 @@ function add_reserve_variables!( formulation, ) where { T <: ServiceRequirementVariable, - D <: PSY.AbstractReserve, + D <: RESERVE_PRODUCT_TYPES, } time_steps = get_time_steps(container) service_names = [PSY.get_name(s) for s in services] @@ -605,15 +629,32 @@ function add_to_objective_function!( return end +# The group's demand: price its ServiceRequirementVariable by the group demand curve (a +# benefit, multiplier -1). No offer costs on the group itself - offers live on the +# contributing services, priced by their own service models. +function add_to_objective_function!( + container::OptimizationContainer, + service::S, + ::ServiceModel{S, GroupStepwiseCostReserve}, +) where {S <: PSY.GroupReserve} + add_reserves_variable_cost!( + container, + ServiceRequirementVariable, + service, + GroupStepwiseCostReserve, + ) + return +end + function add_reserves_variable_cost!( container::OptimizationContainer, ::Type{U}, service::T, ::Type{V}, ) where { - T <: PSY.AbstractReserve, + T <: RESERVE_PRODUCT_TYPES, U <: VariableType, - V <: StepwiseCostReserve, + V <: Union{StepwiseCostReserve, GroupStepwiseCostReserve}, } _add_reserves_variable_cost_to_objective!(container, U, service, V) return @@ -622,9 +663,9 @@ end function _add_reserves_variable_cost_to_objective!( container::OptimizationContainer, ::Type{T}, - component::PSY.AbstractReserve, + component::RESERVE_PRODUCT_TYPES, ::Type{U}, -) where {T <: VariableType, U <: StepwiseCostReserve} +) where {T <: VariableType, U <: Union{StepwiseCostReserve, GroupStepwiseCostReserve}} component_name = PSY.get_name(component) @debug "PWL Variable Cost" _group = LOG_GROUP_COST_FUNCTIONS component_name # If array is full of tuples with zeros return 0.0 @@ -639,7 +680,7 @@ function _add_reserves_variable_cost_to_objective!( error( "Operating reserve demand curve $(component_name) has cost data of type \ $(typeof(variable_cost)), \ - but a `PSY.CostCurve` is required for the StepwiseCostReserve formulation.", + but a `PSY.CostCurve` is required for the $(U) formulation.", ) end @@ -677,7 +718,7 @@ function process_stepwise_cost_reserve_parameters!( container::OptimizationContainer, model::ServiceModel, services::Vector{D}, -) where {D <: PSY.AbstractReserve} +) where {D <: RESERVE_PRODUCT_TYPES} # Only time-series-backed ORDCs need the per-timestep slope/breakpoint parameters. ts_services = [s for s in services if _ordc_is_ts(s)] isempty(ts_services) && return diff --git a/src/services_models/services_constructor.jl b/src/services_models/services_constructor.jl index df166802..3cd4cd72 100644 --- a/src/services_models/services_constructor.jl +++ b/src/services_models/services_constructor.jl @@ -3,10 +3,16 @@ # reads each service's contributing devices from the nested per-service map # (`get_contributing_devices(model, service_name)`), and builds. Reserve variable and # constraint containers are shared per `(entry type, service type)`, with each service -# filling its own slice. `GroupRangeReserve` is deferred to last. +# filling its own slice. Group formulations are deferred to last (their members must exist). # # TODO(services stability): See issue #216. +# Group formulations aggregate other services' award variables, so they construct after +# every non-group service model. +_is_deferred_group_formulation(::Type{GroupRangeReserve}) = true +_is_deferred_group_formulation(::Type{GroupStepwiseCostReserve}) = true +_is_deferred_group_formulation(::Type) = false + # Collect the type's available services that have at least one modeled contributing device. # The concrete element type keeps `add_parameters!` / `add_service_variables!` dispatch happy. function _services_with_contributors( @@ -19,6 +25,23 @@ function _services_with_contributors( ] end +# Available groups of the type that reference at least one contributing service AND impose a +# demand under this formulation. A group is device-less by design, so `_services_with_contributors` +# (device-map filter) cannot apply; a demand-less group is skipped like a degenerate service. +# Comprehensions keep the eltype CONCRETE (`GroupReserve{ReserveUp, NaturalUnit}`): a bare +# `PSY.GroupReserve[]` accumulator would canonicalize container keys to the direction-less +# wrapper, which readers keyed by the model's `GroupReserve{Dir}` could never find. +function _groups_with_demand(model::ServiceModel, sys::PSY.System) + candidates = [ + g for g in get_available_components(model, sys) if + !isempty(PSY.get_contributing_services(g)) + ] + for g in candidates + _has_reserve_demand(model, g) || _log_skipped_reserve_demand(sys, g, model) + end + return [g for g in candidates if _has_reserve_demand(model, g)] +end + function construct_services!( container::OptimizationContainer, sys::PSY.System, @@ -30,10 +53,10 @@ function construct_services!( isempty(services_template) && return incompatible_device_types = get_incompatible_devices(devices_template) - groupservice = nothing + deferred_groups = Symbol[] for (key, service_model) in services_template - if get_formulation(service_model) === GroupRangeReserve # constructed last - groupservice = key + if _is_deferred_group_formulation(get_formulation(service_model)) + push!(deferred_groups, key) # constructed last continue end isempty(get_contributing_devices_map(service_model)) && continue @@ -47,15 +70,17 @@ function construct_services!( network_model, ) end - groupservice === nothing || construct_service!( - container, - sys, - stage, - services_template[groupservice], - devices_template, - incompatible_device_types, - network_model, - ) + for key in deferred_groups + construct_service!( + container, + sys, + stage, + services_template[key], + devices_template, + incompatible_device_types, + network_model, + ) + end return end @@ -70,10 +95,10 @@ function construct_services!( isempty(services_template) && return incompatible_device_types = get_incompatible_devices(devices_template) - groupservice = nothing + deferred_groups = Symbol[] for (key, service_model) in services_template - if get_formulation(service_model) === GroupRangeReserve # constructed last - groupservice = key + if _is_deferred_group_formulation(get_formulation(service_model)) + push!(deferred_groups, key) # constructed last continue end isempty(get_contributing_devices_map(service_model)) && continue @@ -87,15 +112,17 @@ function construct_services!( network_model, ) end - groupservice === nothing || construct_service!( - container, - sys, - stage, - services_template[groupservice], - devices_template, - incompatible_device_types, - network_model, - ) + for key in deferred_groups + construct_service!( + container, + sys, + stage, + services_template[key], + devices_template, + incompatible_device_types, + network_model, + ) + end return end @@ -438,6 +465,69 @@ function construct_service!( return end +""" + Constructs a service for GroupStepwiseCostReserve: the group's demand curve is cleared by + the summed awards of its contributing services. +""" +function construct_service!( + container::OptimizationContainer, + sys::PSY.System, + ::ArgumentConstructStage, + model::ServiceModel{SR, GroupStepwiseCostReserve}, + ::Dict{Symbol, DeviceModel}, + ::Set{<:DataType}, + ::NetworkModel{<:AbstractNetworkModel}, +) where {SR <: PSY.GroupReserve} + groups = _groups_with_demand(model, sys) + isempty(groups) && return + # Dense (group, time) container: the delta-PWL block constraint reads axes(variables). + add_reserve_variables!( + container, + ServiceRequirementVariable, + groups, + GroupStepwiseCostReserve(), + ) + add_expressions!(container, ProductionCostExpression, groups, model) + # Slope/breakpoint PWL cost params for time-series-backed group curves (no-op otherwise). + process_stepwise_cost_reserve_parameters!(container, model, groups) + for group in groups + check_activeservice_variables(container, PSY.get_contributing_services(group)) + end + return +end + +function construct_service!( + container::OptimizationContainer, + sys::PSY.System, + ::ModelConstructStage, + model::ServiceModel{SR, GroupStepwiseCostReserve}, + ::Dict{Symbol, DeviceModel}, + ::Set{<:DataType}, + ::NetworkModel{<:AbstractNetworkModel}, +) where {SR <: PSY.GroupReserve} + groups = _groups_with_demand(model, sys) + isempty(groups) && return + add_constraints_container!( + container, + RequirementConstraint, + SR, + PSY.get_name.(groups), + get_time_steps(container), + ) + for group in groups + add_constraints!( + container, + RequirementConstraint, + group, + PSY.get_contributing_services(group), + model, + ) + add_to_objective_function!(container, group, model) + end + add_constraint_dual!(container, sys, model) + return +end + function construct_service!( container::OptimizationContainer, sys::PSY.System, diff --git a/test/test_group_stepwise_reserve.jl b/test/test_group_stepwise_reserve.jl new file mode 100644 index 00000000..0c461aa4 --- /dev/null +++ b/test/test_group_stepwise_reserve.jl @@ -0,0 +1,228 @@ +# Elastic group ORDC (`GroupStepwiseCostReserve`): one demand curve on a `PSY.GroupReserve` +# is cleared by the summed awards of its contributing services. Members are supply-only +# `OnlineReserve`s (zero requirement, no curve); offers and caps live on the members. + +# Per-thermal MarketBidCost keeping the unit's own marginal energy cost, plus flat AS offers +# into `sub_a` and `sub_b` (cheap A, prohibitively priced B by default). +function _setup_group_reserve_offers!( + sys, + sub_a, + sub_b; + sub_a_price = 5.0, + sub_b_price = 9.0e5, + init_times = [DateTime("2024-01-01T00:00:00"), DateTime("2024-01-02T00:00:00")], + horizon = 24, + resolution = Hour(1), +) + offer_curve(price) = IS.PiecewiseStepData([0.0, 100.0], [price]) + for g in get_components(ThermalStandard, sys) + pmax = PSY.get_max_active_power(g, PSY.NU) + energy_slope = PSY.get_proportional_term( + PSY.get_value_curve(PSY.get_variable(get_operation_cost(g))), + ) + set_operation_cost!( + g, + MarketBidCost(; + no_load_cost = LinearCurve(0.0), + start_up = (hot = 0.0, warm = 0.0, cold = 0.0), + shut_down = LinearCurve(0.0), + incremental_offer_curves = make_market_bid_curve( + [0.0, pmax], [energy_slope], 0.0; power_units = IS.NaturalUnit(), + ), + ), + ) + for (svc, price) in ((sub_a, sub_a_price), (sub_b, sub_b_price)) + data = Dict(it => [offer_curve(price) for _ in 1:horizon] for it in init_times) + ts = Deterministic(PSY.get_name(svc), data, resolution) + PSY.set_service_bid!(sys, g, svc, ts, IS.NaturalUnit()) + end + end + return +end + +function build_group_reserve_system(; + sub_a_price = 5.0, + sub_b_price = 9.0e5, + group_curve = true, +) + sys = deepcopy(PSB.build_system(PSITestSystems, "c_sys5_uc"; add_reserves = true)) + thermals = collect(get_components(ThermalStandard, sys)) + sub_a = OnlineReserve{ReserveUp}(; + name = "GROUP_SUB_A", available = true, time_frame = 3600.0, requirement = 0.0) + sub_b = OnlineReserve{ReserveUp}(; + name = "GROUP_SUB_B", available = true, time_frame = 3600.0, requirement = 0.0) + add_service!(sys, sub_a, thermals) + add_service!(sys, sub_b, thermals) + group = if group_curve + GroupReserve{ReserveUp}(; + name = "UP_GROUP", + available = true, + requirement = 0.0, + variable = make_market_bid_curve( + [0.0, 40.0, 80.0], [80.0, 10.0], 0.0; power_units = IS.NaturalUnit(), + ), + contributing_services = Service[sub_a, sub_b], + ) + else + # `variable` defaults to the zero-offer sentinel: no demand curve. + GroupReserve{ReserveUp}(; + name = "UP_GROUP", + available = true, + requirement = 0.0, + contributing_services = Service[sub_a, sub_b], + ) + end + add_service!(sys, group) + _setup_group_reserve_offers!( + sys, + sub_a, + sub_b; + sub_a_price = sub_a_price, + sub_b_price = sub_b_price, + ) + return sys, group +end + +function _group_reserve_template(; include_group = true) + template = get_thermal_standard_uc_template() + set_service_model!(template, ServiceModel(OnlineReserve{ReserveUp}, RangeReserve)) + include_group && set_service_model!( + template, + ServiceModel(GroupReserve{ReserveUp}, GroupStepwiseCostReserve), + ) + return template +end + +_sub_cols(df, prefix) = [c for c in names(df) if startswith(c, prefix)] + +function _solve_group_model(sys; include_group = true) + model = DecisionModel( + _group_reserve_template(; include_group = include_group), + sys; + optimizer = HiGHS_optimizer, + store_variable_names = true, + ) + @test build!(model; output_dir = mktempdir(; cleanup = true)) == + IOM.ModelBuildStatus.BUILT + @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED + return model +end + +@testset "GroupStepwiseCostReserve: builds, solves, single group clearing constraint" begin + sys, group = build_group_reserve_system() + model = _solve_group_model(sys) + container = IOM.get_optimization_container(model) + @test IOM.has_container_key(container, ServiceRequirementVariable, typeof(group)) + @test IOM.has_container_key(container, POM.RequirementConstraint, typeof(group)) + res = IOM.OptimizationProblemOutputs(model) + demand = read_variable( + res, "ServiceRequirementVariable__GroupReserve__ReserveUp"; + table_format = TableFormat.WIDE, + ) + @test setdiff(names(demand), ["DateTime"]) == ["UP_GROUP"] +end + +@testset "GroupStepwiseCostReserve: aggregation binds member awards to group demand" begin + sys, _ = build_group_reserve_system() + model = _solve_group_model(sys) + res = IOM.OptimizationProblemOutputs(model) + demand = read_variable( + res, "ServiceRequirementVariable__GroupReserve__ReserveUp"; + table_format = TableFormat.WIDE, + ) + awards = read_variable( + res, "ActivePowerReserveVariable__OnlineReserve__ReserveUp"; + table_format = TableFormat.WIDE, + ) + sub_cols = _sub_cols(awards, "GROUP_SUB_") + @test !isempty(sub_cols) + for t in 1:24 + @test sum(awards[t, c] for c in sub_cols) ≈ demand[t, "UP_GROUP"] atol = 1e-3 + end +end + +@testset "GroupStepwiseCostReserve: sub-service merit order" begin + sys, _ = build_group_reserve_system() + model = _solve_group_model(sys) + res = IOM.OptimizationProblemOutputs(model) + awards = read_variable( + res, "ActivePowerReserveVariable__OnlineReserve__ReserveUp"; + table_format = TableFormat.WIDE, + ) + sub_a_total = sum(awards[1, c] for c in _sub_cols(awards, "GROUP_SUB_A")) + sub_b_total = sum(awards[1, c] for c in _sub_cols(awards, "GROUP_SUB_B")) + @test sub_a_total > 1.0 + @test sub_b_total <= 1e-2 + @test sub_a_total > sub_b_total +end + +@testset "GroupStepwiseCostReserve: no group model -> no procurement" begin + sys, _ = build_group_reserve_system() + model = _solve_group_model(sys; include_group = false) + res = IOM.OptimizationProblemOutputs(model) + awards = read_variable( + res, "ActivePowerReserveVariable__OnlineReserve__ReserveUp"; + table_format = TableFormat.WIDE, + ) + for t in 1:24, c in _sub_cols(awards, "GROUP_SUB_") + @test awards[t, c] <= 1e-2 + end +end + +@testset "Group formulation pairing fails at ServiceModel declaration" begin + # A GroupReserve accepts only group formulations, and group formulations accept only + # GroupReserve; mis-pairs must fail at declaration, not at build. + @test_throws ArgumentError ServiceModel(GroupReserve{ReserveUp}, RangeReserve) + @test_throws ArgumentError ServiceModel(GroupReserve{ReserveDown}, StepwiseCostReserve) + @test_throws ArgumentError ServiceModel(OnlineReserve{ReserveUp}, GroupRangeReserve) + @test_throws ArgumentError ServiceModel( + OnlineReserve{ReserveUp}, + GroupStepwiseCostReserve, + ) + @test_throws ArgumentError ServiceModel(OfflineReserve, GroupStepwiseCostReserve) + # The valid pairs still construct. + @test ServiceModel(GroupReserve{ReserveUp}, GroupRangeReserve) isa ServiceModel + @test ServiceModel(GroupReserve{ReserveUp}, GroupStepwiseCostReserve) isa ServiceModel +end + +@testset "GroupStepwiseCostReserve: curve-less group is skipped as degenerate demand" begin + sys, group = build_group_reserve_system(; group_curve = false) + model = _solve_group_model(sys) + container = IOM.get_optimization_container(model) + @test !IOM.has_container_key(container, ServiceRequirementVariable, typeof(group)) + @test !IOM.has_container_key(container, POM.RequirementConstraint, typeof(group)) +end + +@testset "GroupStepwiseCostReserve: time-series group curve builds, solves and clears" begin + sys, group = build_group_reserve_system() + baseline_curve = PSY.get_variable(group) + power_units = PSY.get_power_units(baseline_curve) + fd = PSY.get_function_data(PSY.get_value_curve(baseline_curve)) + pwl_ts = make_deterministic_ts( + sys, + "variable_cost", + fd, + (0.0, 0.0, 0.0), + (0.0, 0.0, 0.0); + override_min_x = 0.0, + override_max_x = last(get_x_coords(fd)), + ) + pwl_key = add_time_series!(sys, group, pwl_ts) + PSY.set_variable!(group, PSY.make_market_bid_ts_curve(pwl_key, nothing, power_units)) + + model = _solve_group_model(sys) + res = IOM.OptimizationProblemOutputs(model) + demand = read_variable( + res, "ServiceRequirementVariable__GroupReserve__ReserveUp"; + table_format = TableFormat.WIDE, + ) + awards = read_variable( + res, "ActivePowerReserveVariable__OnlineReserve__ReserveUp"; + table_format = TableFormat.WIDE, + ) + sub_cols = _sub_cols(awards, "GROUP_SUB_") + for t in 1:24 + @test demand[t, "UP_GROUP"] > 1.0 + @test sum(awards[t, c] for c in sub_cols) ≈ demand[t, "UP_GROUP"] atol = 1e-3 + end +end From 7b10d91d3a10672c1b748f339f12ec10cc0a711e Mon Sep 17 00:00:00 2001 From: rodrigomha Date: Thu, 13 Aug 2026 09:55:09 -0700 Subject: [PATCH 10/23] Simplify group support: GroupReserve is an AbstractReserve PSY moved GroupReserve into the reserve tree, so the RESERVE_PRODUCT_TYPES alias and the per-type methods that existed only because groups sat outside it are gone: every former Union bound is plain PSY.AbstractReserve, the group get_initial_conditions_service_model and the CostExpressions container sibling fold into the AbstractReserve methods, and uses_compact_power opens to the abstract type. Formulation-pair bounds (Union{StepwiseCostReserve, GroupStepwiseCostReserve}) and the group demand predicates stay - they encode formulation semantics, not typing. The pairing guards merge with the #235 hardening set: valid direction-applied pairs for both group formulations, the generic-defaults disambiguator, the inverse guard over both formulations, and a direction-required error for bare GroupReserve declarations. --- src/common_models/add_expressions.jl | 24 --------------- src/common_models/add_parameters.jl | 18 +++++------ src/common_models/market_bid_overrides.jl | 2 +- src/common_models/market_bid_plumbing.jl | 2 +- src/core/definitions.jl | 6 ---- src/core/reserve_traits.jl | 2 +- src/services_models/reserves.jl | 37 +++++++++-------------- 7 files changed, 26 insertions(+), 65 deletions(-) diff --git a/src/common_models/add_expressions.jl b/src/common_models/add_expressions.jl index 0b1ed79d..c4e2872b 100644 --- a/src/common_models/add_expressions.jl +++ b/src/common_models/add_expressions.jl @@ -244,27 +244,3 @@ function add_expressions!( ) return end - -# Group sibling of the reserve method above (`GroupReserve <: Service`, so it cannot share -# the `V <: PSY.AbstractReserve` bound; a `Union` bound on `V` would be ambiguous against it). -function add_expressions!( - container::OptimizationContainer, - ::Type{T}, - services::U, - model::ServiceModel{V, W}, -) where { - T <: CostExpressions, - U <: Union{Vector{D}, IS.FlattenIteratorWrapper{D}}, - V <: PSY.GroupReserve, - W <: AbstractReservesFormulation, -} where {D <: PSY.Component} - time_steps = get_time_steps(container) - add_expression_container!( - container, - T, - D, - [PSY.get_name(s) for s in services], - time_steps, - ) - return -end diff --git a/src/common_models/add_parameters.jl b/src/common_models/add_parameters.jl index d08b3281..d28b37e2 100644 --- a/src/common_models/add_parameters.jl +++ b/src/common_models/add_parameters.jl @@ -70,8 +70,8 @@ function add_parameters!( AbstractPiecewiseLinearSlopeParameter, AbstractPiecewiseLinearBreakpointParameter, }, - U <: RESERVE_PRODUCT_TYPES, - V <: RESERVE_PRODUCT_TYPES, + U <: PSY.AbstractReserve, + V <: PSY.AbstractReserve, W <: AbstractServiceFormulation, } if get_rebuild_model(get_settings(container)) && has_container_key(container, T, U) @@ -424,7 +424,7 @@ _get_time_series_name( DecrementalPiecewiseLinearBreakpointParameter, }, }, - service::RESERVE_PRODUCT_TYPES, + service::PSY.AbstractReserve, ::ServiceModel, ) = IS.get_name(IS.get_time_series_key(PSY.get_variable(service))) @@ -590,8 +590,8 @@ _ordc_ts_data(ts::IS.DeterministicSingleTimeSeries) = function calc_additional_axes( ::OptimizationContainer, ::Type{P}, - services::Vector{<:RESERVE_PRODUCT_TYPES}, - ::ServiceModel{<:RESERVE_PRODUCT_TYPES, W}, + services::Vector{<:PSY.AbstractReserve}, + ::ServiceModel{<:PSY.AbstractReserve, W}, ) where { P <: AbstractPiecewiseLinearSlopeParameter, W <: AbstractServiceFormulation, @@ -605,8 +605,8 @@ end function calc_additional_axes( ::OptimizationContainer, ::Type{P}, - services::Vector{<:RESERVE_PRODUCT_TYPES}, - ::ServiceModel{<:RESERVE_PRODUCT_TYPES, W}, + services::Vector{<:PSY.AbstractReserve}, + ::ServiceModel{<:PSY.AbstractReserve, W}, ) where { P <: AbstractPiecewiseLinearBreakpointParameter, W <: AbstractServiceFormulation, @@ -809,8 +809,8 @@ function _add_parameters!( AbstractPiecewiseLinearSlopeParameter, AbstractPiecewiseLinearBreakpointParameter, }, - U <: RESERVE_PRODUCT_TYPES, - V <: RESERVE_PRODUCT_TYPES, + U <: PSY.AbstractReserve, + V <: PSY.AbstractReserve, W <: AbstractServiceFormulation, } _add_objective_function_parameters!(container, T, services, model, W) diff --git a/src/common_models/market_bid_overrides.jl b/src/common_models/market_bid_overrides.jl index 8b20ea35..7be945c1 100644 --- a/src/common_models/market_bid_overrides.jl +++ b/src/common_models/market_bid_overrides.jl @@ -348,7 +348,7 @@ function add_pwl_term_delta!( ::Type{U}, ::Type{V}, ) where { - T <: RESERVE_PRODUCT_TYPES, + T <: PSY.AbstractReserve, U <: VariableType, V <: AbstractServiceFormulation, } diff --git a/src/common_models/market_bid_plumbing.jl b/src/common_models/market_bid_plumbing.jl index fa73a602..1c32006d 100644 --- a/src/common_models/market_bid_plumbing.jl +++ b/src/common_models/market_bid_plumbing.jl @@ -110,7 +110,7 @@ get_offer_curves(::IOM.IncrementalOffer, op_cost::PSY.OfferCurveCost) = # service-side direction trait (`_reserve_offer_direction`) is decremental. get_offer_curves( ::IOM.OfferDirection, - service::RESERVE_PRODUCT_TYPES, + service::PSY.AbstractReserve, ) = PSY.get_variable(service) ################################################################################# diff --git a/src/core/definitions.jl b/src/core/definitions.jl index 0dae365c..653d3a92 100644 --- a/src/core/definitions.jl +++ b/src/core/definitions.jl @@ -112,10 +112,4 @@ const IGNORABLE_FILES = [ ] const OUTPUTS_DIR = "outputs" -# Any reserve product that carries demand-side state (requirement / demand curve): the -# device-backed reserve tree plus service-aggregating groups (`PSY.GroupReserve <: Service`, -# outside that tree). `AbstractReserve` keeps the union OPEN to future reserve subtypes. -# Signature-position only - never use as a field type or container eltype. -const RESERVE_PRODUCT_TYPES = Union{PSY.AbstractReserve, PSY.GroupReserve} - IS.@scoped_enum(COMPACT_PWL_STATUS, VALID = 1, INVALID = 2, UNDETERMINED = 3) diff --git a/src/core/reserve_traits.jl b/src/core/reserve_traits.jl index 5f00b60c..b341f1ea 100644 --- a/src/core/reserve_traits.jl +++ b/src/core/reserve_traits.jl @@ -61,7 +61,7 @@ end # so the cost is negligible). "Whether a reserve's or group's ORDC curve is time-varying. Dispatches on the value-curve type (no `isa`)." -_ordc_is_ts(s::RESERVE_PRODUCT_TYPES) = +_ordc_is_ts(s::PSY.AbstractReserve) = _value_curve_is_ts(PSY.get_value_curve(PSY.get_variable(s))) _value_curve_is_ts(::PSY.TimeSeriesPiecewiseIncrementalCurve) = true _value_curve_is_ts(::PSY.PiecewiseIncrementalCurve) = false diff --git a/src/services_models/reserves.jl b/src/services_models/reserves.jl index 3ee0472d..569e5be3 100644 --- a/src/services_models/reserves.jl +++ b/src/services_models/reserves.jl @@ -13,11 +13,10 @@ get_variable_lower_bound(::Type{ActivePowerReserveVariable}, ::PSY.AbstractReser ############################### ServiceRequirementVariable (ORDC / StepwiseCostReserve) ################################ # Only created on the StepwiseCostReserve / GroupStepwiseCostReserve construct paths, so the -# formulation gates these to curve-bearing reserves and groups (`GroupReserve <: Service`, hence -# the Union). -get_variable_binary(::Type{ServiceRequirementVariable}, ::Type{<:RESERVE_PRODUCT_TYPES}, ::Type{<:AbstractReservesFormulation}) = false -get_variable_upper_bound(::Type{ServiceRequirementVariable}, ::RESERVE_PRODUCT_TYPES, d::PSY.Component, ::Type{<:AbstractReservesFormulation}) = PSY.get_max_active_power(d, PSY.SU) -get_variable_lower_bound(::Type{ServiceRequirementVariable}, ::RESERVE_PRODUCT_TYPES, ::PSY.Component, ::Type{<:AbstractReservesFormulation}) = 0.0 +# formulation gates these to curve-bearing reserves and groups. +get_variable_binary(::Type{ServiceRequirementVariable}, ::Type{<:PSY.AbstractReserve}, ::Type{<:AbstractReservesFormulation}) = false +get_variable_upper_bound(::Type{ServiceRequirementVariable}, ::PSY.AbstractReserve, d::PSY.Component, ::Type{<:AbstractReservesFormulation}) = PSY.get_max_active_power(d, PSY.SU) +get_variable_lower_bound(::Type{ServiceRequirementVariable}, ::PSY.AbstractReserve, ::PSY.Component, ::Type{<:AbstractReservesFormulation}) = 0.0 # Reserve requirement in system units; the getter is units-aware for every reserve type. _get_requirement(service) = PSY.get_requirement(service, PSY.SU) @@ -79,8 +78,8 @@ _is_group_member(::PSY.System, ::PSY.GroupReserve) = false function _log_skipped_reserve_demand( sys::PSY.System, - service::RESERVE_PRODUCT_TYPES, - ::ServiceModel{<:RESERVE_PRODUCT_TYPES, F}, + service::PSY.AbstractReserve, + ::ServiceModel{<:PSY.AbstractReserve, F}, ) where {F <: AbstractReservesFormulation} name = PSY.get_name(service) reason = F in (StepwiseCostReserve, GroupStepwiseCostReserve) ? @@ -106,15 +105,15 @@ get_initial_parameter_value(::Type{<:VariableValueParameter}, d::Type{<:PSY.Abst objective_function_multiplier(::Type{ServiceRequirementVariable}, ::Type{StepwiseCostReserve}) = -1.0 objective_function_multiplier(::Type{ServiceRequirementVariable}, ::Type{GroupStepwiseCostReserve}) = -1.0 uses_compact_power(::PSY.AbstractReserve, ::StepwiseCostReserve)=false -uses_compact_power(::PSY.GroupReserve, ::GroupStepwiseCostReserve)=false -get_multiplier_value(::Type{<:AbstractPiecewiseLinearBreakpointParameter}, ::RESERVE_PRODUCT_TYPES, ::Type{<:AbstractReservesFormulation}) = 1.0 -get_multiplier_value(::Type{<:AbstractPiecewiseLinearSlopeParameter}, ::RESERVE_PRODUCT_TYPES, ::Type{<:AbstractReservesFormulation}) = 1.0 +uses_compact_power(::PSY.AbstractReserve, ::GroupStepwiseCostReserve)=false +get_multiplier_value(::Type{<:AbstractPiecewiseLinearBreakpointParameter}, ::PSY.AbstractReserve, ::Type{<:AbstractReservesFormulation}) = 1.0 +get_multiplier_value(::Type{<:AbstractPiecewiseLinearSlopeParameter}, ::PSY.AbstractReserve, ::Type{<:AbstractReservesFormulation}) = 1.0 # Operating reserve demand curves (ORDC) are willingness-to-pay (concave), i.e. a decremental # offer. # Routes the reserve PWL cost path through IOM's OfferDirection dispatch; making # this incremental is a one-line change here. Mirrors `_onvar_offer_direction` / # `_vom_offer_direction` in market_bid_overrides.jl. -_reserve_offer_direction(::RESERVE_PRODUCT_TYPES) = IOM.DecrementalOffer() +_reserve_offer_direction(::PSY.AbstractReserve) = IOM.DecrementalOffer() #! format: on function get_initial_conditions_service_model( @@ -124,14 +123,6 @@ function get_initial_conditions_service_model( return ServiceModel(T, D) end -# `GroupReserve <: Service`, so the `AbstractReserve` method cannot cover it. -function get_initial_conditions_service_model( - ::IOM.AbstractOptimizationModel, - ::ServiceModel{T, D}, -) where {T <: PSY.GroupReserve, D <: AbstractReservesFormulation} - return ServiceModel(T, D) -end - function get_default_time_series_names( ::Type{<:PSY.Reserve}, ::Type{T}, @@ -174,7 +165,7 @@ function add_reserve_variables!( formulation, ) where { T <: ServiceRequirementVariable, - D <: RESERVE_PRODUCT_TYPES, + D <: PSY.AbstractReserve, } time_steps = get_time_steps(container) service_names = [PSY.get_name(s) for s in services] @@ -652,7 +643,7 @@ function add_reserves_variable_cost!( service::T, ::Type{V}, ) where { - T <: RESERVE_PRODUCT_TYPES, + T <: PSY.AbstractReserve, U <: VariableType, V <: Union{StepwiseCostReserve, GroupStepwiseCostReserve}, } @@ -663,7 +654,7 @@ end function _add_reserves_variable_cost_to_objective!( container::OptimizationContainer, ::Type{T}, - component::RESERVE_PRODUCT_TYPES, + component::PSY.AbstractReserve, ::Type{U}, ) where {T <: VariableType, U <: Union{StepwiseCostReserve, GroupStepwiseCostReserve}} component_name = PSY.get_name(component) @@ -718,7 +709,7 @@ function process_stepwise_cost_reserve_parameters!( container::OptimizationContainer, model::ServiceModel, services::Vector{D}, -) where {D <: RESERVE_PRODUCT_TYPES} +) where {D <: PSY.AbstractReserve} # Only time-series-backed ORDCs need the per-timestep slope/breakpoint parameters. ts_services = [s for s in services if _ordc_is_ts(s)] isempty(ts_services) && return From 3c07945516ccd0956e007ca2c81c3d01ca8ea144 Mon Sep 17 00:00:00 2001 From: rodrigomha Date: Thu, 13 Aug 2026 11:01:50 -0700 Subject: [PATCH 11/23] Add load reserve provision under PowerLoadDispatch A controllable load routes reserves inversely to generators: up reserve is committed shed (P - r_up >= 0), down reserve is committed extra consumption (P + r_down <= forecast). Dispatch limits move to the range expressions only when a reserve service is attached, and a costless load selling reserves fails loudly since nothing pins its consumption. --- src/common_models/add_to_expression.jl | 68 +++++ src/static_injector_models/electric_loads.jl | 49 +++- .../load_constructor.jl | 112 ++++++-- test/test_device_reserve_offers.jl | 252 ++++++++++++++++++ 4 files changed, 463 insertions(+), 18 deletions(-) diff --git a/src/common_models/add_to_expression.jl b/src/common_models/add_to_expression.jl index df03153b..e13ecc47 100644 --- a/src/common_models/add_to_expression.jl +++ b/src/common_models/add_to_expression.jl @@ -2413,6 +2413,74 @@ function add_to_expression!( return end +# Load up-reserve is committed shed: LB = P - Σ r_up, constrained >= 0. Generators route +# ReserveUp to the UB expression, so `V <: PSY.ElectricLoad` cannot shadow them. +function add_to_expression!( + container::OptimizationContainer, + ::Type{T}, + ::Type{U}, + service::X, + devices::Union{Vector{V}, IS.FlattenIteratorWrapper{V}}, + model::ServiceModel{X, W}, +) where { + T <: ActivePowerRangeExpressionLB, + U <: VariableType, + V <: PSY.ElectricLoad, + X <: PSY.Reserve{PSY.ReserveUp}, + W <: AbstractReservesFormulation, +} + service_name = PSY.get_name(service) + variable = get_variable(container, U, X) + if !has_container_key(container, T, V) + add_expressions!(container, T, devices, model) + end + expression = get_expression(container, T, V) + time_steps = get_time_steps(container) + for d in devices, t in time_steps + name = PSY.get_name(d) + add_proportional_to_jump_expression!( + expression[name, t], + variable[(service_name, name, t)], + -1.0, + ) + end + return +end + +# Load down-reserve is committed extra consumption: UB = P + Σ r_down, constrained by the +# load's forecast. +function add_to_expression!( + container::OptimizationContainer, + ::Type{T}, + ::Type{U}, + service::X, + devices::Union{Vector{V}, IS.FlattenIteratorWrapper{V}}, + model::ServiceModel{X, W}, +) where { + T <: ActivePowerRangeExpressionUB, + U <: VariableType, + V <: PSY.ElectricLoad, + X <: PSY.Reserve{PSY.ReserveDown}, + W <: AbstractReservesFormulation, +} + service_name = PSY.get_name(service) + variable = get_variable(container, U, X) + if !has_container_key(container, T, V) + add_expressions!(container, T, devices, model) + end + expression = get_expression(container, T, V) + time_steps = get_time_steps(container) + for d in devices, t in time_steps + name = PSY.get_name(d) + add_proportional_to_jump_expression!( + expression[name, t], + variable[(service_name, name, t)], + 1.0, + ) + end + return +end + function add_to_expression!( container::OptimizationContainer, ::Type{T}, diff --git a/src/static_injector_models/electric_loads.jl b/src/static_injector_models/electric_loads.jl index dac32a11..5cee88bd 100644 --- a/src/static_injector_models/electric_loads.jl +++ b/src/static_injector_models/electric_loads.jl @@ -37,6 +37,12 @@ get_variable_upper_bound(::Type{ShiftDownActivePowerVariable}, d::PSY.ElectricLo variable_cost(cost::PSY.OperationalCost, ::Type{ShiftUpActivePowerVariable}, ::PSY.ElectricLoad, ::Type{<:AbstractControllablePowerLoadFormulation})=PSY.get_variable(cost) variable_cost(cost::PSY.OperationalCost, ::Type{ShiftDownActivePowerVariable}, ::PSY.ElectricLoad, ::Type{<:AbstractControllablePowerLoadFormulation})=PSY.get_variable(cost) +########################### Reserve provision, ElectricLoad ################################ +# The inverse of a generator: up reserve is shed (P - r_up >= 0), down reserve is extra +# consumption (P + r_down <= forecast). Loads do not provide OfflineReserve. +get_expression_type_for_reserve(::Type{ActivePowerReserveVariable}, ::Type{<:PSY.ElectricLoad}, ::Type{<:PSY.Reserve{PSY.ReserveUp}}) = ActivePowerRangeExpressionLB +get_expression_type_for_reserve(::Type{ActivePowerReserveVariable}, ::Type{<:PSY.ElectricLoad}, ::Type{<:PSY.Reserve{PSY.ReserveDown}}) = ActivePowerRangeExpressionUB + ###################################################### # To avoid ambiguity with default_interface_methods.jl: @@ -259,10 +265,12 @@ function add_constraints!( return end +# Upper bound is the load's forecast; with reserves, `ActivePowerRangeExpressionUB` +# (= P + Σ r_down) rides the same bound, capping down awards by the forecast headroom. function add_constraints!( container::OptimizationContainer, ::Type{ActivePowerVariableLimitsConstraint}, - U::Type{<:VariableType}, + U::Type{<:Union{VariableType, ActivePowerRangeExpressionUB}}, devices::IS.FlattenIteratorWrapper{V}, model::DeviceModel{V, W}, ::NetworkModel{X}, @@ -279,6 +287,27 @@ function add_constraints!( return end +# `ActivePowerRangeExpressionLB` (= P - Σ r_up) >= 0: an up award cannot exceed the +# load's consumption. Reached only when the load carries a reserve service. +function add_constraints!( + container::OptimizationContainer, + T::Type{ActivePowerVariableLimitsConstraint}, + U::Type{ActivePowerRangeExpressionLB}, + devices::IS.FlattenIteratorWrapper{V}, + model::DeviceModel{V, W}, + ::NetworkModel{X}, +) where {V <: PSY.ControllableLoad, W <: PowerLoadDispatch, X <: AbstractNetworkModel} + add_range_constraints!(container, T, U, devices, model, X) + return +end + +# Only `min` is consumed (shed floor); the upper bound rides the forecast parameter. +get_min_max_limits( + d::PSY.ControllableLoad, + ::Type{ActivePowerVariableLimitsConstraint}, + ::Type{PowerLoadDispatch}, +) = (min = 0.0, max = PSY.get_max_active_power(d, PSY.SU)) + function add_constraints!( container::OptimizationContainer, T::Type{ActivePowerVariableLimitsConstraint}, @@ -523,9 +552,25 @@ end function add_to_objective_function!( container::OptimizationContainer, devices::IS.FlattenIteratorWrapper{T}, - ::DeviceModel{T, U}, + model::DeviceModel{T, U}, ::Type{<:AbstractNetworkModel}, ) where {T <: PSY.ControllableLoad, U <: PowerLoadDispatch} + # A costless load selling reserves has nothing pinning its consumption: fail loudly. + if has_service_model(model) + for d in devices + cost = PSY.get_operation_cost(d) + if cost isa PSY.LoadCost && PSY.get_variable(cost) == zero(PSY.CostCurve) + throw( + IS.ConflictingInputsError( + "PowerLoadDispatch load '$(PSY.get_name(d))' provides a reserve \ + service but its LoadCost value curve is zero; attach an \ + energy/VOLL value (e.g. set_operation_cost! with a priced \ + LoadCost) so its dispatch is pinned.", + ), + ) + end + end + end add_variable_cost!(container, ActivePowerVariable, devices, U) return end diff --git a/src/static_injector_models/load_constructor.jl b/src/static_injector_models/load_constructor.jl index fd135d05..dc542087 100644 --- a/src/static_injector_models/load_constructor.jl +++ b/src/static_injector_models/load_constructor.jl @@ -38,6 +38,27 @@ function construct_device!( network_model, ) + # With reserves, the dispatch limits move to the range expressions so awards consume + # shed/forecast headroom (load direction map in electric_loads.jl). + if has_service_model(model) + add_to_expression!( + container, + ActivePowerRangeExpressionLB, + ActivePowerVariable, + devices, + model, + network_model, + ) + add_to_expression!( + container, + ActivePowerRangeExpressionUB, + ActivePowerVariable, + devices, + model, + network_model, + ) + end + if haskey(get_time_series_names(model), ActivePowerTimeSeriesParameter) add_parameters!(container, ActivePowerTimeSeriesParameter, devices, model) end @@ -59,14 +80,33 @@ function construct_device!( sys, ) - add_constraints!( - container, - ActivePowerVariableLimitsConstraint, - ActivePowerVariable, - devices, - model, - network_model, - ) + if has_service_model(model) + add_constraints!( + container, + ActivePowerVariableLimitsConstraint, + ActivePowerRangeExpressionLB, + devices, + model, + network_model, + ) + add_constraints!( + container, + ActivePowerVariableLimitsConstraint, + ActivePowerRangeExpressionUB, + devices, + model, + network_model, + ) + else + add_constraints!( + container, + ActivePowerVariableLimitsConstraint, + ActivePowerVariable, + devices, + model, + network_model, + ) + end add_constraints!( container, ReactivePowerVariableLimitsConstraint, @@ -116,6 +156,27 @@ function construct_device!( network_model, ) + # With reserves, the dispatch limits move to the range expressions so awards consume + # shed/forecast headroom (load direction map in electric_loads.jl). + if has_service_model(model) + add_to_expression!( + container, + ActivePowerRangeExpressionLB, + ActivePowerVariable, + devices, + model, + network_model, + ) + add_to_expression!( + container, + ActivePowerRangeExpressionUB, + ActivePowerVariable, + devices, + model, + network_model, + ) + end + if haskey(get_time_series_names(model), ActivePowerTimeSeriesParameter) add_parameters!(container, ActivePowerTimeSeriesParameter, devices, model) end @@ -139,14 +200,33 @@ function construct_device!( sys, ) - add_constraints!( - container, - ActivePowerVariableLimitsConstraint, - ActivePowerVariable, - devices, - model, - network_model, - ) + if has_service_model(model) + add_constraints!( + container, + ActivePowerVariableLimitsConstraint, + ActivePowerRangeExpressionLB, + devices, + model, + network_model, + ) + add_constraints!( + container, + ActivePowerVariableLimitsConstraint, + ActivePowerRangeExpressionUB, + devices, + model, + network_model, + ) + else + add_constraints!( + container, + ActivePowerVariableLimitsConstraint, + ActivePowerVariable, + devices, + model, + network_model, + ) + end add_feedforward_constraints!(container, model, devices) add_to_objective_function!( diff --git a/test/test_device_reserve_offers.jl b/test/test_device_reserve_offers.jl index ccb01ce7..3464e1a0 100644 --- a/test/test_device_reserve_offers.jl +++ b/test/test_device_reserve_offers.jl @@ -319,3 +319,255 @@ end push!(PSY.get_ancillary_service_offers(mbc), reserve) @test POM._cost_offers_reserve(mbc, reserve) == true end + + +################################################################################# +# Load reserve provision (PowerLoadDispatch) +################################################################################# + +# Load reserve provision (`PowerLoadDispatch`): a controllable load provides UPWARD reserve +# by shedding (`P - r_up >= 0`, awards capped by consumption) and DOWNWARD reserve by +# consuming more (`P + r_down <= forecast`). `c_sys5_il`'s single interruptible load +# `IloadBus4` is the sole contributor to Reserve7 (up), Reserve8 (down) and ORDC1 (up, +# demand curve); the requirements exceed the load, so requirement models use slacks. + +const _IL_NAME = "IloadBus4" + +function _load_reserve_template(direction::Symbol) + template = get_thermal_dispatch_template_network() + set_device_model!(template, PSY.InterruptiblePowerLoad, PowerLoadDispatch) + direction === :up && set_service_model!( + template, + ServiceModel(OnlineReserve{ReserveUp}, RangeReserve; use_slacks = true), + ) + direction === :down && set_service_model!( + template, + ServiceModel(OnlineReserve{ReserveDown}, RangeReserve; use_slacks = true), + ) + return template +end + +function _solve_load_model(template, sys) + model = DecisionModel( + template, sys; + optimizer = HiGHS_optimizer, store_variable_names = true, + ) + @test build!(model; output_dir = mktempdir(; cleanup = true)) == + IOM.ModelBuildStatus.BUILT + @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED + return model +end + +_il_cols(df) = [c for c in names(df) if endswith(c, "__$(_IL_NAME)")] + +@testset "Load reserve direction map + folding methods" begin + @test POM.get_expression_type_for_reserve( + ActivePowerReserveVariable, PSY.InterruptiblePowerLoad, OnlineReserve{ReserveUp}, + ) == POM.ActivePowerRangeExpressionLB + @test POM.get_expression_type_for_reserve( + ActivePowerReserveVariable, PSY.InterruptiblePowerLoad, OnlineReserve{ReserveDown}, + ) == POM.ActivePowerRangeExpressionUB +end + +@testset "UP-reserve: award capped by consumption (shed headroom)" begin + sys = deepcopy(PSB.build_system(PSITestSystems, "c_sys5_il"; add_reserves = true)) + model = _solve_load_model(_load_reserve_template(:up), sys) + container = IOM.get_optimization_container(model) + @test IOM.has_container_key( + container, POM.ActivePowerRangeExpressionLB, PSY.InterruptiblePowerLoad, + ) + res = IOM.OptimizationProblemOutputs(model) + p = read_variable( + res, "ActivePowerVariable__InterruptiblePowerLoad"; + table_format = TableFormat.WIDE, + ) + awards = read_variable( + res, "ActivePowerReserveVariable__OnlineReserve__ReserveUp"; + table_format = TableFormat.WIDE, + ) + total_award = 0.0 + for t in 1:24 + awarded = sum(awards[t, c] for c in _il_cols(awards)) + @test awarded <= p[t, _IL_NAME] + 1e-4 + @test p[t, _IL_NAME] - awarded >= -1e-4 + total_award += awarded + end + @test total_award > 1.0 +end + +@testset "DOWN-reserve: award within forecast headroom" begin + sys = deepcopy(PSB.build_system(PSITestSystems, "c_sys5_il"; add_reserves = true)) + il = get_component(PSY.InterruptiblePowerLoad, sys, _IL_NAME) + pmax = PSY.get_max_active_power(il, PSY.NU) + model = _solve_load_model(_load_reserve_template(:down), sys) + res = IOM.OptimizationProblemOutputs(model) + p = read_variable( + res, "ActivePowerVariable__InterruptiblePowerLoad"; + table_format = TableFormat.WIDE, + ) + awards = read_variable( + res, "ActivePowerReserveVariable__OnlineReserve__ReserveDown"; + table_format = TableFormat.WIDE, + ) + hsl = read_parameter( + res, "ActivePowerTimeSeriesParameter__InterruptiblePowerLoad"; + table_format = TableFormat.WIDE, + ) + total_award = 0.0 + for t in 1:24 + awarded = sum(awards[t, c] for c in _il_cols(awards)) + @test awarded >= -1e-4 + @test p[t, _IL_NAME] + awarded <= hsl[t, _IL_NAME] + 1e-3 + @test p[t, _IL_NAME] + awarded <= pmax + 1e-3 + total_award += awarded + end + @test total_award > 1.0 +end + +@testset "VOLL-priced load: pinned at forecast, full up-shed, zero down" begin + sys = deepcopy(PSB.build_system(PSITestSystems, "c_sys5_il"; add_reserves = true)) + il = get_component(PSY.InterruptiblePowerLoad, sys, _IL_NAME) + set_operation_cost!( + il, + PSY.LoadCost(PSY.CostCurve(PSY.LinearCurve(5000.0, 0.0), IS.NaturalUnit()), 24.0), + ) + template = _load_reserve_template(:up) + set_service_model!( + template, + ServiceModel(OnlineReserve{ReserveDown}, RangeReserve; use_slacks = true), + ) + model = _solve_load_model(template, sys) + res = IOM.OptimizationProblemOutputs(model) + p = read_variable( + res, "ActivePowerVariable__InterruptiblePowerLoad"; + table_format = TableFormat.WIDE, + ) + up = read_variable( + res, "ActivePowerReserveVariable__OnlineReserve__ReserveUp"; + table_format = TableFormat.WIDE, + ) + dn = read_variable( + res, "ActivePowerReserveVariable__OnlineReserve__ReserveDown"; + table_format = TableFormat.WIDE, + ) + hsl = read_parameter( + res, "ActivePowerTimeSeriesParameter__InterruptiblePowerLoad"; + table_format = TableFormat.WIDE, + ) + up_total = 0.0 + for t in 1:24 + @test isapprox(p[t, _IL_NAME], hsl[t, _IL_NAME]; atol = 1e-1) + up_t = sum(up[t, c] for c in _il_cols(up)) + @test isapprox(up_t, p[t, _IL_NAME]; atol = 1e-1) + @test sum(dn[t, c] for c in _il_cols(dn)) <= 1e-2 + up_total += up_t + end + @test up_total > 1.0 +end + +@testset "No-reserve regression: pure-energy path unchanged" begin + sys = deepcopy(PSB.build_system(PSITestSystems, "c_sys5_il"; add_reserves = false)) + model = _solve_load_model(_load_reserve_template(:none), sys) + container = IOM.get_optimization_container(model) + @test !IOM.has_container_key( + container, POM.ActivePowerRangeExpressionLB, PSY.InterruptiblePowerLoad, + ) + @test !IOM.has_container_key( + container, POM.ActivePowerRangeExpressionUB, PSY.InterruptiblePowerLoad, + ) +end + +@testset "Costless load offering reserves fails loudly" begin + sys = deepcopy(PSB.build_system(PSITestSystems, "c_sys5_il"; add_reserves = true)) + il = get_component(PSY.InterruptiblePowerLoad, sys, _IL_NAME) + set_operation_cost!(il, PSY.LoadCost(nothing)) + model = DecisionModel( + _load_reserve_template(:up), sys; + optimizer = HiGHS_optimizer, + ) + @test build!(model; output_dir = mktempdir(; cleanup = true)) == + IOM.ModelBuildStatus.FAILED +end + +@testset "Co-provision: two up-services share one shed headroom" begin + sys = deepcopy(PSB.build_system(PSITestSystems, "c_sys5_il"; add_reserves = true)) + il = get_component(PSY.InterruptiblePowerLoad, sys, _IL_NAME) + set_operation_cost!( + il, + PSY.LoadCost(PSY.CostCurve(PSY.LinearCurve(5000.0, 0.0), IS.NaturalUnit()), 24.0), + ) + # A second requirement reserve on the same load; both clear under ONE per-type model. + second = OnlineReserve{ReserveUp}("Reserve7B", true, 30.0, 100.0) + add_service!(sys, second, [il]) + model = _solve_load_model(_load_reserve_template(:up), sys) + res = IOM.OptimizationProblemOutputs(model) + p = read_variable( + res, "ActivePowerVariable__InterruptiblePowerLoad"; + table_format = TableFormat.WIDE, + ) + awards = read_variable( + res, "ActivePowerReserveVariable__OnlineReserve__ReserveUp"; + table_format = TableFormat.WIDE, + ) + combined_total = 0.0 + for t in 1:24 + # One shared LB expression: a per-service headroom bug would allow up to 2*P. + combined = awards[t, "Reserve7__$(_IL_NAME)"] + awards[t, "Reserve7B__$(_IL_NAME)"] + @test combined <= p[t, _IL_NAME] + 1e-3 + combined_total += combined + end + @test combined_total > 1.0 +end + +@testset "Load offers into an elastic reserve: award bounded by the offer" begin + sys = deepcopy(PSB.build_system(PSITestSystems, "c_sys5_il"; add_reserves = true)) + il = get_component(PSY.InterruptiblePowerLoad, sys, _IL_NAME) + pmax = PSY.get_max_active_power(il, PSY.NU) + ordc = first(get_components(PSY.has_demand_curve, PSY.OnlineReserve, sys)) + offer_mw = 10.0 + set_operation_cost!( + il, + MarketBidCost(; + no_load_cost = LinearCurve(0.0), + start_up = (hot = 0.0, warm = 0.0, cold = 0.0), + shut_down = LinearCurve(0.0), + decremental_offer_curves = make_market_bid_curve( + [0.0, pmax], [5000.0], 0.0; power_units = IS.NaturalUnit(), + ), + ), + ) + offer_ts = Deterministic( + PSY.get_name(ordc), + Dict( + it => [IS.PiecewiseStepData([0.0, offer_mw], [0.0]) for _ in 1:24] for + it in [DateTime("2024-01-01T00:00:00"), DateTime("2024-01-02T00:00:00")] + ), + Hour(1), + ) + PSY.set_service_bid!(sys, il, ordc, offer_ts, IS.NaturalUnit()) + + template = get_thermal_dispatch_template_network() + set_device_model!(template, PSY.InterruptiblePowerLoad, PowerLoadDispatch) + set_service_model!( + template, + ServiceModel(OnlineReserve{ReserveUp}, StepwiseCostReserve), + ) + model = _solve_load_model(template, sys) + container = IOM.get_optimization_container(model) + @test IOM.has_container_key( + container, POM.PiecewiseLinearBlockReserveOffer, PSY.InterruptiblePowerLoad, + ) + res = IOM.OptimizationProblemOutputs(model) + awards = read_variable( + res, "ActivePowerReserveVariable__OnlineReserve__ReserveUp"; + table_format = TableFormat.WIDE, + ) + col = "$(PSY.get_name(ordc))__$(_IL_NAME)" + total = 0.0 + for t in 1:24 + @test awards[t, col] <= offer_mw + 1e-3 + total += awards[t, col] + end + # The zero-priced block clears against the elastic demand. + @test total > 1.0 +end From 1311a0df84fab9b9dd17b2d1dcb4ffa2d8ef432b Mon Sep 17 00:00:00 2001 From: rodrigomha Date: Thu, 13 Aug 2026 11:02:07 -0700 Subject: [PATCH 12/23] Add energy + reserve co-clearing integration test; service docs close-out End-to-end market test: an elastic OnlineReserve (StepwiseCostReserve) and a GroupStepwiseCostReserve group co-clear against per-resource offers from thermal, storage, and load participants. Registers GroupStepwiseCostReserve in the formulation library, refreshes the stale group-reserve warnings there, and renames the remaining market-specific reserve identifiers in hydro to generic ones. --- docs/src/reference/formulation_library.md | 46 ++--- src/core/reserve_traits.jl | 2 +- .../hydro_generation.jl | 10 +- test/test_device_hydro_constructors.jl | 28 +-- test/test_device_reserve_offers.jl | 167 ++++++++++++++++++ 5 files changed, 212 insertions(+), 41 deletions(-) diff --git a/docs/src/reference/formulation_library.md b/docs/src/reference/formulation_library.md index 701fdb0c..570ea341 100644 --- a/docs/src/reference/formulation_library.md +++ b/docs/src/reference/formulation_library.md @@ -514,31 +514,35 @@ no-op. ## [Service Formulations](@id service_formulations) -| Formulation | Service type | Argument stage | Model stage | -|:------------------------------------------------------ |:--------------------------- |:---------------------------------------------------------------------------------------------- |:---------------------------------------------------------- | -| `RangeReserve` | `PSY.Reserve` | `RequirementTimeSeriesParameter` (omitted for `ConstantReserve`), `ActivePowerReserveVariable` | `RequirementConstraint`, `ParticipationFractionConstraint` | -| `RampReserve` | `PSY.Reserve` | as above | as above **+ `RampConstraint`** | -| `NonSpinningReserve` | `PSY.OfflineReserve` | as above, but **no** device-range expression wiring | as above **+ `ReservePowerConstraint`** | -| `StepwiseCostReserve` (operating reserve demand curve) | `PSY.Reserve` | `ServiceRequirementVariable` + demand-curve slope/breakpoint parameters | `RequirementConstraint` only — no participation constraint | -| `GroupRangeReserve` | `PSY.GroupReserve` | no variables | `RequirementConstraint` across contributing services | -| `ConstantMaxInterfaceFlow` | `PSY.TransmissionInterface` | optional slacks, `InterfaceTotalFlow` expression | `InterfaceFlowLimit` (`"ub"`/`"lb"`) | -| `VariableMaxInterfaceFlow` | `PSY.TransmissionInterface` | as above **+ min/max flow-limit parameters** | as above, with parameterized limits | - -`GroupRangeReserve` is deliberately constructed **last** in both stages, because it aggregates the other -services' variables. - -!!! warning "GroupRangeReserve does not support slacks" +| Formulation | Service type | Argument stage | Model stage | +|:------------------------------------------------------ |:--------------------------- |:-------------------------------------------------------------------------------------------------------- |:---------------------------------------------------------- | +| `RangeReserve` | `PSY.Reserve` | `RequirementTimeSeriesParameter` (omitted for static-requirement reserves), `ActivePowerReserveVariable` | `RequirementConstraint`, `ParticipationFractionConstraint` | +| `RampReserve` | `PSY.Reserve` | as above | as above **+ `RampConstraint`** | +| `NonSpinningReserve` | `PSY.OfflineReserve` | as above, but **no** device-range expression wiring | as above **+ `ReservePowerConstraint`** | +| `StepwiseCostReserve` (operating reserve demand curve) | `PSY.Reserve` | `ServiceRequirementVariable` + demand-curve slope/breakpoint parameters | `RequirementConstraint` only — no participation constraint | +| `GroupRangeReserve` | `PSY.GroupReserve` | no variables | `RequirementConstraint` across contributing services | +| `GroupStepwiseCostReserve` (elastic group) | `PSY.GroupReserve` | `ServiceRequirementVariable` + group demand-curve slope/breakpoint parameters | `RequirementConstraint`: member awards ≥ the group demand | +| `ConstantMaxInterfaceFlow` | `PSY.TransmissionInterface` | optional slacks, `InterfaceTotalFlow` expression | `InterfaceFlowLimit` (`"ub"`/`"lb"`) | +| `VariableMaxInterfaceFlow` | `PSY.TransmissionInterface` | as above **+ min/max flow-limit parameters** | as above, with parameterized limits | + +The group formulations are deliberately constructed **last** in both stages, because they aggregate +the other services' award variables. A `PSY.GroupReserve` accepts only the group formulations (and +vice versa); a mis-paired `ServiceModel` fails at declaration. A service whose demand driver is +degenerate (zero requirement under the requirement formulations, no demand curve under the stepwise +ones) is skipped as demand and built as supply only, so it can serve a group. + +!!! warning "Group formulations do not support slacks" - `GroupRangeReserve`'s requirement-constraint builder reads a `slack_vars` binding that is never - created, so a `ServiceModel` with `use_slacks = true` raises `UndefVarError`. A group reserve - also cannot currently be built end to end: a `PSY.GroupReserve` aggregates services rather - than devices, so its contributing-device list is empty and construction errors out before the - requirement constraint is reached. + `use_slacks = true` on a group `ServiceModel` is ignored: reserve slacks attach to the + requirement rows of the device-backed formulations, and no slack is added to a group's + clearing constraint. Reserve contributions reach a device through `get_expression_type_for_reserve`: for thermal, renewable and hydro an up-reserve enters `ActivePowerRangeExpressionUB` (+1) and a down-reserve -`ActivePowerRangeExpressionLB` (−1); storage and hybrid instead route everything into -`TotalReserveOffering`. Any other device type hits an error — loads, sources, condensers and shunts +`ActivePowerRangeExpressionLB` (−1); a controllable load is the inverse (an up-reserve is committed +shed, entering `ActivePowerRangeExpressionLB` with −1, and a down-reserve is committed extra +consumption, entering `ActivePowerRangeExpressionUB` with +1); storage and hybrid instead route +everything into `TotalReserveOffering`. Any other device type hits an error — sources, condensers and shunts cannot contribute to a reserve. Service `meta` strings are **per-instance**, not a fixed vocabulary: every reserve container is diff --git a/src/core/reserve_traits.jl b/src/core/reserve_traits.jl index b341f1ea..802204ae 100644 --- a/src/core/reserve_traits.jl +++ b/src/core/reserve_traits.jl @@ -31,7 +31,7 @@ struct ChargeSide <: ReserveSide end """ Direction of a reserve. `OfflineReserve` (non-spinning) has no direction type parameter and is -upward-only in every US market, so it maps to [`PSY.ReserveUp`](@ref). +upward-only in every US market, so it maps to `PSY.ReserveUp`. """ _reserve_direction(::PSY.Reserve{T}) where {T <: PSY.ReserveDirection} = T _reserve_direction(::PSY.OfflineReserve) = PSY.ReserveUp diff --git a/src/static_injector_models/hydro_generation.jl b/src/static_injector_models/hydro_generation.jl index e7ab6567..f8ade262 100644 --- a/src/static_injector_models/hydro_generation.jl +++ b/src/static_injector_models/hydro_generation.jl @@ -2286,29 +2286,29 @@ function calculate_aux_variable_value!( d = PSY.get_component(T, system, name) for t in time_steps if has_container_key(container, HydroServedReserveUpExpression, typeof(d)) - served_regup = jump_value( + served_reserve_up = jump_value( get_expression(container, HydroServedReserveUpExpression, T)[ name, t, ], ) else - served_regup = 0.0 + served_reserve_up = 0.0 end if has_container_key(container, HydroServedReserveDownExpression, typeof(d)) - served_regdn = jump_value( + served_reserve_down = jump_value( get_expression(container, HydroServedReserveDownExpression, T)[ name, t, ], ) else - served_regdn = 0.0 + served_reserve_down = 0.0 end aux_variable_container[name, t] = ( jump_value(p_variable_output[name, t]) + - served_regup - served_regdn + served_reserve_up - served_reserve_down ) * fraction_of_hour end end diff --git a/test/test_device_hydro_constructors.jl b/test/test_device_hydro_constructors.jl index e1b277bf..649307a7 100644 --- a/test/test_device_hydro_constructors.jl +++ b/test/test_device_hydro_constructors.jl @@ -439,12 +439,12 @@ end ) # Fix reserve parameters - reg_up = only(get_components(OnlineReserve{ReserveUp}, c_sys5_hy)) - reg_dn = only(get_components(OnlineReserve{ReserveDown}, c_sys5_hy)) - set_deployed_fraction!(reg_up, 0.0) - set_deployed_fraction!(reg_dn, 0.0) - set_requirement!(reg_up, 0.01 * PSY.SU) - set_requirement!(reg_dn, 0.01 * PSY.SU) + reserve_up = only(get_components(OnlineReserve{ReserveUp}, c_sys5_hy)) + reserve_down = only(get_components(OnlineReserve{ReserveDown}, c_sys5_hy)) + set_deployed_fraction!(reserve_up, 0.0) + set_deployed_fraction!(reserve_down, 0.0) + set_requirement!(reserve_up, 0.01 * PSY.SU) + set_requirement!(reserve_down, 0.01 * PSY.SU) hydro_budget = 24 eps = 1e-6 @@ -453,11 +453,11 @@ end # Update Service allocation # Remove reg up from hydro, but leave reg dn - remove_service!(hy, reg_up) + remove_service!(hy, reserve_up) # Add reg up to thermals for th in get_components(ThermalStandard, c_sys5_hy) - add_service!(th, reg_up, c_sys5_hy) + add_service!(th, reserve_up, c_sys5_hy) end max_power = get_max_active_power(hy, PSY.SU) @@ -528,12 +528,12 @@ end # The hydro unit is the sole contributing device for both reserves in this system, so # the down requirement guarantees a nonzero down award to detect. - reg_up = only(get_components(OnlineReserve{ReserveUp}, c_sys5_hy)) - reg_dn = only(get_components(OnlineReserve{ReserveDown}, c_sys5_hy)) - set_deployed_fraction!(reg_up, 0.0) - set_deployed_fraction!(reg_dn, 0.5) - set_requirement!(reg_up, 0.01 * PSY.SU) - set_requirement!(reg_dn, 0.01 * PSY.SU) + reserve_up = only(get_components(OnlineReserve{ReserveUp}, c_sys5_hy)) + reserve_down = only(get_components(OnlineReserve{ReserveDown}, c_sys5_hy)) + set_deployed_fraction!(reserve_up, 0.0) + set_deployed_fraction!(reserve_down, 0.5) + set_requirement!(reserve_up, 0.01 * PSY.SU) + set_requirement!(reserve_down, 0.01 * PSY.SU) transform_single_time_series!(c_sys5_hy, Hour(4), Hour(4)) diff --git a/test/test_device_reserve_offers.jl b/test/test_device_reserve_offers.jl index 3464e1a0..3c7feaa3 100644 --- a/test/test_device_reserve_offers.jl +++ b/test/test_device_reserve_offers.jl @@ -320,6 +320,173 @@ end @test POM._cost_offers_reserve(mbc, reserve) == true end +################################################################################# +# End-to-end energy + reserve co-clearing: an elastic reserve (demand curve under +# StepwiseCostReserve), an elastic group (GroupStepwiseCostReserve) over two supply-only +# sub-services, and per-resource offers from both generators and a controllable load. The +# load's cheap block into GROUP_SUB_A is deliberately the cheapest in the stack, so it must +# clear in full and stay bounded by its offered quantity, not its consumption. +################################################################################# + +const _MKT_INIT_TIMES = + [DateTime("2024-01-01T00:00:00"), DateTime("2024-01-02T00:00:00")] +const _MKT_LOAD = "IloadBus4" + +_mkt_offer_ts(svc, mw, price) = Deterministic( + PSY.get_name(svc), + Dict( + it => [IS.PiecewiseStepData([0.0, mw], [price]) for _ in 1:24] for + it in _MKT_INIT_TIMES + ), + Hour(1), +) + +_mkt_curve(x, y) = make_market_bid_curve(x, y, 0.0; power_units = IS.NaturalUnit()) + +function build_reserve_market_system(; load_offer_mw = 10.0, load_offer_price = 4.0) + sys = deepcopy(PSB.build_system(PSITestSystems, "c_sys5_il"; add_reserves = false)) + thermals = collect(get_components(ThermalStandard, sys)) + il = get_component(PSY.InterruptiblePowerLoad, sys, _MKT_LOAD) + + elastic = OnlineReserve{ReserveUp}(; + name = "ELASTIC_UP", + available = true, + time_frame = 5.0, + variable = _mkt_curve([0.0, 200.0, 400.0], [80.0, 15.0]), + ) + add_service!(sys, elastic, thermals) + + sub_a = OnlineReserve{ReserveUp}(; + name = "GROUP_SUB_A", available = true, time_frame = 3600.0, requirement = 0.0) + sub_b = OnlineReserve{ReserveUp}(; + name = "GROUP_SUB_B", available = true, time_frame = 3600.0, requirement = 0.0) + add_service!(sys, sub_a, vcat(PSY.Device[thermals...], il)) + add_service!(sys, sub_b, thermals) + + group = GroupReserve{ReserveUp}(; + name = "UP_GROUP", + available = true, + requirement = 0.0, + variable = _mkt_curve([0.0, 150.0, 300.0], [70.0, 12.0]), + contributing_services = Service[sub_a, sub_b], + ) + add_service!(sys, group) + + # Generators: energy at each unit's own marginal cost, flat AS offers into all three + # up-products with per-unit prices. + for (i, g) in enumerate(thermals) + pmax = PSY.get_max_active_power(g, PSY.NU) + energy_slope = PSY.get_proportional_term( + PSY.get_value_curve(PSY.get_variable(get_operation_cost(g))), + ) + set_operation_cost!( + g, + MarketBidCost(; + no_load_cost = LinearCurve(0.0), + start_up = (hot = 0.0, warm = 0.0, cold = 0.0), + shut_down = LinearCurve(0.0), + incremental_offer_curves = _mkt_curve([0.0, pmax], [energy_slope]), + ), + ) + for (svc, mw, price) in ( + (elastic, 30.0, 8.0 + i), + (sub_a, 25.0, 5.0 + i), + (sub_b, 20.0, 6.0 + i), + ) + PSY.set_service_bid!( + sys, + g, + svc, + _mkt_offer_ts(svc, mw, price), + IS.NaturalUnit(), + ) + end + end + + # Load: consumption valued at VOLL (consumes at forecast), plus one cheap block into + # GROUP_SUB_A - the cheapest offer in the whole stack. + pmax_il = PSY.get_max_active_power(il, PSY.NU) + set_operation_cost!( + il, + MarketBidCost(; + no_load_cost = LinearCurve(0.0), + start_up = (hot = 0.0, warm = 0.0, cold = 0.0), + shut_down = LinearCurve(0.0), + decremental_offer_curves = _mkt_curve([0.0, pmax_il], [5000.0]), + ), + ) + PSY.set_service_bid!( + sys, il, sub_a, _mkt_offer_ts(sub_a, load_offer_mw, load_offer_price), + IS.NaturalUnit(), + ) + return sys +end + +function _reserve_market_template() + template = get_thermal_standard_uc_template() + set_device_model!(template, PSY.InterruptiblePowerLoad, PowerLoadDispatch) + # ONE up-reserve model: the elastic service carries its curve; the curve-less, + # zero-requirement sub-services fall through to supply-only under the skip-gate. + set_service_model!( + template, + ServiceModel(OnlineReserve{ReserveUp}, StepwiseCostReserve), + ) + set_service_model!( + template, + ServiceModel(GroupReserve{ReserveUp}, GroupStepwiseCostReserve), + ) + return template +end + +@testset "Combined clearing: elastic reserve + elastic group + gen/load offers" begin + sys = build_reserve_market_system() + model = DecisionModel( + _reserve_market_template(), sys; + optimizer = HiGHS_optimizer, store_variable_names = true, + ) + @test build!(model; output_dir = mktempdir(; cleanup = true)) == + IOM.ModelBuildStatus.BUILT + @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED + + container = IOM.get_optimization_container(model) + # Per-resource offer machinery fired for both device classes. + @test IOM.has_container_key( + container, POM.PiecewiseLinearBlockReserveOffer, ThermalStandard, + ) + @test IOM.has_container_key( + container, POM.PiecewiseLinearBlockReserveOffer, PSY.InterruptiblePowerLoad, + ) + + res = IOM.OptimizationProblemOutputs(model) + elastic_dem = read_variable( + res, "ServiceRequirementVariable__OnlineReserve__ReserveUp"; + table_format = TableFormat.WIDE, + ) + group_dem = read_variable( + res, "ServiceRequirementVariable__GroupReserve__ReserveUp"; + table_format = TableFormat.WIDE, + ) + awards = read_variable( + res, "ActivePowerReserveVariable__OnlineReserve__ReserveUp"; + table_format = TableFormat.WIDE, + ) + sub_cols = [c for c in names(awards) if startswith(c, "GROUP_SUB_")] + load_col = "GROUP_SUB_A__$(_MKT_LOAD)" + @test load_col in names(awards) + + load_offer = 10.0 + for t in 1:24 + # Both elastic demands clear. + @test elastic_dem[t, "ELASTIC_UP"] > 1.0 + @test group_dem[t, "UP_GROUP"] > 1.0 + # Group aggregation: member awards cover the group demand. + @test sum(awards[t, c] for c in sub_cols) ≈ group_dem[t, "UP_GROUP"] atol = 1e-3 + # The load's award is bounded by its offered quantity, not its ~100 MW consumption, + # and the cheapest block clears in full. + @test awards[t, load_col] <= load_offer + 1e-3 + @test awards[t, load_col] >= load_offer - 1e-2 + end +end ################################################################################# # Load reserve provision (PowerLoadDispatch) From c4e09af18bc4f71a5333ed6156d6316ab06e97ae Mon Sep 17 00:00:00 2001 From: rodrigomha Date: Thu, 13 Aug 2026 11:14:55 -0700 Subject: [PATCH 13/23] update comment. --- src/services_models/services_constructor.jl | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/services_models/services_constructor.jl b/src/services_models/services_constructor.jl index 3cd4cd72..504e202e 100644 --- a/src/services_models/services_constructor.jl +++ b/src/services_models/services_constructor.jl @@ -25,12 +25,10 @@ function _services_with_contributors( ] end -# Available groups of the type that reference at least one contributing service AND impose a -# demand under this formulation. A group is device-less by design, so `_services_with_contributors` -# (device-map filter) cannot apply; a demand-less group is skipped like a degenerate service. -# Comprehensions keep the eltype CONCRETE (`GroupReserve{ReserveUp, NaturalUnit}`): a bare -# `PSY.GroupReserve[]` accumulator would canonicalize container keys to the direction-less -# wrapper, which readers keyed by the model's `GroupReserve{Dir}` could never find. +# Groups are device-less, so the device-map filter above cannot apply. The comprehensions +# keep the eltype concrete (e.g. `GroupReserve{ReserveUp, NaturalUnit}`): a bare +# `PSY.GroupReserve[]` accumulator would canonicalize container keys direction-less, +# unreachable by readers keyed on `GroupReserve{Dir}`. function _groups_with_demand(model::ServiceModel, sys::PSY.System) candidates = [ g for g in get_available_components(model, sys) if From ea169ad00081fc60ed4ae982f73b2fa6da74c7ae Mon Sep 17 00:00:00 2001 From: rodrigomha Date: Thu, 13 Aug 2026 11:15:13 -0700 Subject: [PATCH 14/23] move testing --- test/test_group_stepwise_reserve.jl | 228 --------------------------- test/test_services_constructor.jl | 229 ++++++++++++++++++++++++++++ 2 files changed, 229 insertions(+), 228 deletions(-) delete mode 100644 test/test_group_stepwise_reserve.jl diff --git a/test/test_group_stepwise_reserve.jl b/test/test_group_stepwise_reserve.jl deleted file mode 100644 index 0c461aa4..00000000 --- a/test/test_group_stepwise_reserve.jl +++ /dev/null @@ -1,228 +0,0 @@ -# Elastic group ORDC (`GroupStepwiseCostReserve`): one demand curve on a `PSY.GroupReserve` -# is cleared by the summed awards of its contributing services. Members are supply-only -# `OnlineReserve`s (zero requirement, no curve); offers and caps live on the members. - -# Per-thermal MarketBidCost keeping the unit's own marginal energy cost, plus flat AS offers -# into `sub_a` and `sub_b` (cheap A, prohibitively priced B by default). -function _setup_group_reserve_offers!( - sys, - sub_a, - sub_b; - sub_a_price = 5.0, - sub_b_price = 9.0e5, - init_times = [DateTime("2024-01-01T00:00:00"), DateTime("2024-01-02T00:00:00")], - horizon = 24, - resolution = Hour(1), -) - offer_curve(price) = IS.PiecewiseStepData([0.0, 100.0], [price]) - for g in get_components(ThermalStandard, sys) - pmax = PSY.get_max_active_power(g, PSY.NU) - energy_slope = PSY.get_proportional_term( - PSY.get_value_curve(PSY.get_variable(get_operation_cost(g))), - ) - set_operation_cost!( - g, - MarketBidCost(; - no_load_cost = LinearCurve(0.0), - start_up = (hot = 0.0, warm = 0.0, cold = 0.0), - shut_down = LinearCurve(0.0), - incremental_offer_curves = make_market_bid_curve( - [0.0, pmax], [energy_slope], 0.0; power_units = IS.NaturalUnit(), - ), - ), - ) - for (svc, price) in ((sub_a, sub_a_price), (sub_b, sub_b_price)) - data = Dict(it => [offer_curve(price) for _ in 1:horizon] for it in init_times) - ts = Deterministic(PSY.get_name(svc), data, resolution) - PSY.set_service_bid!(sys, g, svc, ts, IS.NaturalUnit()) - end - end - return -end - -function build_group_reserve_system(; - sub_a_price = 5.0, - sub_b_price = 9.0e5, - group_curve = true, -) - sys = deepcopy(PSB.build_system(PSITestSystems, "c_sys5_uc"; add_reserves = true)) - thermals = collect(get_components(ThermalStandard, sys)) - sub_a = OnlineReserve{ReserveUp}(; - name = "GROUP_SUB_A", available = true, time_frame = 3600.0, requirement = 0.0) - sub_b = OnlineReserve{ReserveUp}(; - name = "GROUP_SUB_B", available = true, time_frame = 3600.0, requirement = 0.0) - add_service!(sys, sub_a, thermals) - add_service!(sys, sub_b, thermals) - group = if group_curve - GroupReserve{ReserveUp}(; - name = "UP_GROUP", - available = true, - requirement = 0.0, - variable = make_market_bid_curve( - [0.0, 40.0, 80.0], [80.0, 10.0], 0.0; power_units = IS.NaturalUnit(), - ), - contributing_services = Service[sub_a, sub_b], - ) - else - # `variable` defaults to the zero-offer sentinel: no demand curve. - GroupReserve{ReserveUp}(; - name = "UP_GROUP", - available = true, - requirement = 0.0, - contributing_services = Service[sub_a, sub_b], - ) - end - add_service!(sys, group) - _setup_group_reserve_offers!( - sys, - sub_a, - sub_b; - sub_a_price = sub_a_price, - sub_b_price = sub_b_price, - ) - return sys, group -end - -function _group_reserve_template(; include_group = true) - template = get_thermal_standard_uc_template() - set_service_model!(template, ServiceModel(OnlineReserve{ReserveUp}, RangeReserve)) - include_group && set_service_model!( - template, - ServiceModel(GroupReserve{ReserveUp}, GroupStepwiseCostReserve), - ) - return template -end - -_sub_cols(df, prefix) = [c for c in names(df) if startswith(c, prefix)] - -function _solve_group_model(sys; include_group = true) - model = DecisionModel( - _group_reserve_template(; include_group = include_group), - sys; - optimizer = HiGHS_optimizer, - store_variable_names = true, - ) - @test build!(model; output_dir = mktempdir(; cleanup = true)) == - IOM.ModelBuildStatus.BUILT - @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED - return model -end - -@testset "GroupStepwiseCostReserve: builds, solves, single group clearing constraint" begin - sys, group = build_group_reserve_system() - model = _solve_group_model(sys) - container = IOM.get_optimization_container(model) - @test IOM.has_container_key(container, ServiceRequirementVariable, typeof(group)) - @test IOM.has_container_key(container, POM.RequirementConstraint, typeof(group)) - res = IOM.OptimizationProblemOutputs(model) - demand = read_variable( - res, "ServiceRequirementVariable__GroupReserve__ReserveUp"; - table_format = TableFormat.WIDE, - ) - @test setdiff(names(demand), ["DateTime"]) == ["UP_GROUP"] -end - -@testset "GroupStepwiseCostReserve: aggregation binds member awards to group demand" begin - sys, _ = build_group_reserve_system() - model = _solve_group_model(sys) - res = IOM.OptimizationProblemOutputs(model) - demand = read_variable( - res, "ServiceRequirementVariable__GroupReserve__ReserveUp"; - table_format = TableFormat.WIDE, - ) - awards = read_variable( - res, "ActivePowerReserveVariable__OnlineReserve__ReserveUp"; - table_format = TableFormat.WIDE, - ) - sub_cols = _sub_cols(awards, "GROUP_SUB_") - @test !isempty(sub_cols) - for t in 1:24 - @test sum(awards[t, c] for c in sub_cols) ≈ demand[t, "UP_GROUP"] atol = 1e-3 - end -end - -@testset "GroupStepwiseCostReserve: sub-service merit order" begin - sys, _ = build_group_reserve_system() - model = _solve_group_model(sys) - res = IOM.OptimizationProblemOutputs(model) - awards = read_variable( - res, "ActivePowerReserveVariable__OnlineReserve__ReserveUp"; - table_format = TableFormat.WIDE, - ) - sub_a_total = sum(awards[1, c] for c in _sub_cols(awards, "GROUP_SUB_A")) - sub_b_total = sum(awards[1, c] for c in _sub_cols(awards, "GROUP_SUB_B")) - @test sub_a_total > 1.0 - @test sub_b_total <= 1e-2 - @test sub_a_total > sub_b_total -end - -@testset "GroupStepwiseCostReserve: no group model -> no procurement" begin - sys, _ = build_group_reserve_system() - model = _solve_group_model(sys; include_group = false) - res = IOM.OptimizationProblemOutputs(model) - awards = read_variable( - res, "ActivePowerReserveVariable__OnlineReserve__ReserveUp"; - table_format = TableFormat.WIDE, - ) - for t in 1:24, c in _sub_cols(awards, "GROUP_SUB_") - @test awards[t, c] <= 1e-2 - end -end - -@testset "Group formulation pairing fails at ServiceModel declaration" begin - # A GroupReserve accepts only group formulations, and group formulations accept only - # GroupReserve; mis-pairs must fail at declaration, not at build. - @test_throws ArgumentError ServiceModel(GroupReserve{ReserveUp}, RangeReserve) - @test_throws ArgumentError ServiceModel(GroupReserve{ReserveDown}, StepwiseCostReserve) - @test_throws ArgumentError ServiceModel(OnlineReserve{ReserveUp}, GroupRangeReserve) - @test_throws ArgumentError ServiceModel( - OnlineReserve{ReserveUp}, - GroupStepwiseCostReserve, - ) - @test_throws ArgumentError ServiceModel(OfflineReserve, GroupStepwiseCostReserve) - # The valid pairs still construct. - @test ServiceModel(GroupReserve{ReserveUp}, GroupRangeReserve) isa ServiceModel - @test ServiceModel(GroupReserve{ReserveUp}, GroupStepwiseCostReserve) isa ServiceModel -end - -@testset "GroupStepwiseCostReserve: curve-less group is skipped as degenerate demand" begin - sys, group = build_group_reserve_system(; group_curve = false) - model = _solve_group_model(sys) - container = IOM.get_optimization_container(model) - @test !IOM.has_container_key(container, ServiceRequirementVariable, typeof(group)) - @test !IOM.has_container_key(container, POM.RequirementConstraint, typeof(group)) -end - -@testset "GroupStepwiseCostReserve: time-series group curve builds, solves and clears" begin - sys, group = build_group_reserve_system() - baseline_curve = PSY.get_variable(group) - power_units = PSY.get_power_units(baseline_curve) - fd = PSY.get_function_data(PSY.get_value_curve(baseline_curve)) - pwl_ts = make_deterministic_ts( - sys, - "variable_cost", - fd, - (0.0, 0.0, 0.0), - (0.0, 0.0, 0.0); - override_min_x = 0.0, - override_max_x = last(get_x_coords(fd)), - ) - pwl_key = add_time_series!(sys, group, pwl_ts) - PSY.set_variable!(group, PSY.make_market_bid_ts_curve(pwl_key, nothing, power_units)) - - model = _solve_group_model(sys) - res = IOM.OptimizationProblemOutputs(model) - demand = read_variable( - res, "ServiceRequirementVariable__GroupReserve__ReserveUp"; - table_format = TableFormat.WIDE, - ) - awards = read_variable( - res, "ActivePowerReserveVariable__OnlineReserve__ReserveUp"; - table_format = TableFormat.WIDE, - ) - sub_cols = _sub_cols(awards, "GROUP_SUB_") - for t in 1:24 - @test demand[t, "UP_GROUP"] > 1.0 - @test sum(awards[t, c] for c in sub_cols) ≈ demand[t, "UP_GROUP"] atol = 1e-3 - end -end diff --git a/test/test_services_constructor.jl b/test/test_services_constructor.jl index f79e782f..219f49db 100644 --- a/test/test_services_constructor.jl +++ b/test/test_services_constructor.jl @@ -1610,3 +1610,232 @@ end container, group, model, ) end + +# Elastic group ORDC (`GroupStepwiseCostReserve`): one demand curve on a `PSY.GroupReserve` +# is cleared by the summed awards of its contributing services. Members are supply-only +# `OnlineReserve`s (zero requirement, no curve); offers and caps live on the members. + +# Per-thermal MarketBidCost keeping the unit's own marginal energy cost, plus flat AS offers +# into `sub_a` and `sub_b` (cheap A, prohibitively priced B by default). +function _setup_group_reserve_offers!( + sys, + sub_a, + sub_b; + sub_a_price = 5.0, + sub_b_price = 9.0e5, + init_times = [DateTime("2024-01-01T00:00:00"), DateTime("2024-01-02T00:00:00")], + horizon = 24, + resolution = Hour(1), +) + offer_curve(price) = IS.PiecewiseStepData([0.0, 100.0], [price]) + for g in get_components(ThermalStandard, sys) + pmax = PSY.get_max_active_power(g, PSY.NU) + energy_slope = PSY.get_proportional_term( + PSY.get_value_curve(PSY.get_variable(get_operation_cost(g))), + ) + set_operation_cost!( + g, + MarketBidCost(; + no_load_cost = LinearCurve(0.0), + start_up = (hot = 0.0, warm = 0.0, cold = 0.0), + shut_down = LinearCurve(0.0), + incremental_offer_curves = make_market_bid_curve( + [0.0, pmax], [energy_slope], 0.0; power_units = IS.NaturalUnit(), + ), + ), + ) + for (svc, price) in ((sub_a, sub_a_price), (sub_b, sub_b_price)) + data = Dict(it => [offer_curve(price) for _ in 1:horizon] for it in init_times) + ts = Deterministic(PSY.get_name(svc), data, resolution) + PSY.set_service_bid!(sys, g, svc, ts, IS.NaturalUnit()) + end + end + return +end + +function build_group_reserve_system(; + sub_a_price = 5.0, + sub_b_price = 9.0e5, + group_curve = true, +) + sys = deepcopy(PSB.build_system(PSITestSystems, "c_sys5_uc"; add_reserves = true)) + thermals = collect(get_components(ThermalStandard, sys)) + sub_a = OnlineReserve{ReserveUp}(; + name = "GROUP_SUB_A", available = true, time_frame = 3600.0, requirement = 0.0) + sub_b = OnlineReserve{ReserveUp}(; + name = "GROUP_SUB_B", available = true, time_frame = 3600.0, requirement = 0.0) + add_service!(sys, sub_a, thermals) + add_service!(sys, sub_b, thermals) + group = if group_curve + GroupReserve{ReserveUp}(; + name = "UP_GROUP", + available = true, + requirement = 0.0, + variable = make_market_bid_curve( + [0.0, 40.0, 80.0], [80.0, 10.0], 0.0; power_units = IS.NaturalUnit(), + ), + contributing_services = Service[sub_a, sub_b], + ) + else + # `variable` defaults to the zero-offer sentinel: no demand curve. + GroupReserve{ReserveUp}(; + name = "UP_GROUP", + available = true, + requirement = 0.0, + contributing_services = Service[sub_a, sub_b], + ) + end + add_service!(sys, group) + _setup_group_reserve_offers!( + sys, + sub_a, + sub_b; + sub_a_price = sub_a_price, + sub_b_price = sub_b_price, + ) + return sys, group +end + +function _group_reserve_template(; include_group = true) + template = get_thermal_standard_uc_template() + set_service_model!(template, ServiceModel(OnlineReserve{ReserveUp}, RangeReserve)) + include_group && set_service_model!( + template, + ServiceModel(GroupReserve{ReserveUp}, GroupStepwiseCostReserve), + ) + return template +end + +_sub_cols(df, prefix) = [c for c in names(df) if startswith(c, prefix)] + +function _solve_group_model(sys; include_group = true) + model = DecisionModel( + _group_reserve_template(; include_group = include_group), + sys; + optimizer = HiGHS_optimizer, + store_variable_names = true, + ) + @test build!(model; output_dir = mktempdir(; cleanup = true)) == + IOM.ModelBuildStatus.BUILT + @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED + return model +end + +@testset "GroupStepwiseCostReserve: builds, solves, single group clearing constraint" begin + sys, group = build_group_reserve_system() + model = _solve_group_model(sys) + container = IOM.get_optimization_container(model) + @test IOM.has_container_key(container, ServiceRequirementVariable, typeof(group)) + @test IOM.has_container_key(container, POM.RequirementConstraint, typeof(group)) + res = IOM.OptimizationProblemOutputs(model) + demand = read_variable( + res, "ServiceRequirementVariable__GroupReserve__ReserveUp"; + table_format = TableFormat.WIDE, + ) + @test setdiff(names(demand), ["DateTime"]) == ["UP_GROUP"] +end + +@testset "GroupStepwiseCostReserve: aggregation binds member awards to group demand" begin + sys, _ = build_group_reserve_system() + model = _solve_group_model(sys) + res = IOM.OptimizationProblemOutputs(model) + demand = read_variable( + res, "ServiceRequirementVariable__GroupReserve__ReserveUp"; + table_format = TableFormat.WIDE, + ) + awards = read_variable( + res, "ActivePowerReserveVariable__OnlineReserve__ReserveUp"; + table_format = TableFormat.WIDE, + ) + sub_cols = _sub_cols(awards, "GROUP_SUB_") + @test !isempty(sub_cols) + for t in 1:24 + @test sum(awards[t, c] for c in sub_cols) ≈ demand[t, "UP_GROUP"] atol = 1e-3 + end +end + +@testset "GroupStepwiseCostReserve: sub-service merit order" begin + sys, _ = build_group_reserve_system() + model = _solve_group_model(sys) + res = IOM.OptimizationProblemOutputs(model) + awards = read_variable( + res, "ActivePowerReserveVariable__OnlineReserve__ReserveUp"; + table_format = TableFormat.WIDE, + ) + sub_a_total = sum(awards[1, c] for c in _sub_cols(awards, "GROUP_SUB_A")) + sub_b_total = sum(awards[1, c] for c in _sub_cols(awards, "GROUP_SUB_B")) + @test sub_a_total > 1.0 + @test sub_b_total <= 1e-2 + @test sub_a_total > sub_b_total +end + +@testset "GroupStepwiseCostReserve: no group model -> no procurement" begin + sys, _ = build_group_reserve_system() + model = _solve_group_model(sys; include_group = false) + res = IOM.OptimizationProblemOutputs(model) + awards = read_variable( + res, "ActivePowerReserveVariable__OnlineReserve__ReserveUp"; + table_format = TableFormat.WIDE, + ) + for t in 1:24, c in _sub_cols(awards, "GROUP_SUB_") + @test awards[t, c] <= 1e-2 + end +end + +@testset "Group formulation pairing fails at ServiceModel declaration" begin + # A GroupReserve accepts only group formulations, and group formulations accept only + # GroupReserve; mis-pairs must fail at declaration, not at build. + @test_throws ArgumentError ServiceModel(GroupReserve{ReserveUp}, RangeReserve) + @test_throws ArgumentError ServiceModel(GroupReserve{ReserveDown}, StepwiseCostReserve) + @test_throws ArgumentError ServiceModel(OnlineReserve{ReserveUp}, GroupRangeReserve) + @test_throws ArgumentError ServiceModel( + OnlineReserve{ReserveUp}, + GroupStepwiseCostReserve, + ) + @test_throws ArgumentError ServiceModel(OfflineReserve, GroupStepwiseCostReserve) + # The valid pairs still construct. + @test ServiceModel(GroupReserve{ReserveUp}, GroupRangeReserve) isa ServiceModel + @test ServiceModel(GroupReserve{ReserveUp}, GroupStepwiseCostReserve) isa ServiceModel +end + +@testset "GroupStepwiseCostReserve: curve-less group is skipped as degenerate demand" begin + sys, group = build_group_reserve_system(; group_curve = false) + model = _solve_group_model(sys) + container = IOM.get_optimization_container(model) + @test !IOM.has_container_key(container, ServiceRequirementVariable, typeof(group)) + @test !IOM.has_container_key(container, POM.RequirementConstraint, typeof(group)) +end + +@testset "GroupStepwiseCostReserve: time-series group curve builds, solves and clears" begin + sys, group = build_group_reserve_system() + baseline_curve = PSY.get_variable(group) + power_units = PSY.get_power_units(baseline_curve) + fd = PSY.get_function_data(PSY.get_value_curve(baseline_curve)) + pwl_ts = make_deterministic_ts( + sys, + "variable_cost", + fd, + (0.0, 0.0, 0.0), + (0.0, 0.0, 0.0); + override_min_x = 0.0, + override_max_x = last(get_x_coords(fd)), + ) + pwl_key = add_time_series!(sys, group, pwl_ts) + PSY.set_variable!(group, PSY.make_market_bid_ts_curve(pwl_key, nothing, power_units)) + + model = _solve_group_model(sys) + res = IOM.OptimizationProblemOutputs(model) + demand = read_variable( + res, "ServiceRequirementVariable__GroupReserve__ReserveUp"; + table_format = TableFormat.WIDE, + ) + awards = read_variable( + res, "ActivePowerReserveVariable__OnlineReserve__ReserveUp"; + table_format = TableFormat.WIDE, + ) + sub_cols = _sub_cols(awards, "GROUP_SUB_") + for t in 1:24 + @test demand[t, "UP_GROUP"] > 1.0 + @test sum(awards[t, c] for c in sub_cols) ≈ demand[t, "UP_GROUP"] atol = 1e-3 + end +end From d0762b4ccb49624537eaed2f706f42facae3794f Mon Sep 17 00:00:00 2001 From: rodrigomha Date: Thu, 13 Aug 2026 13:15:39 -0700 Subject: [PATCH 15/23] Support OfflineReserve as ORDC supply from storage and loads Non-spinning is upward-only, so OfflineReserve routes like an up reserve everywhere a device supplies it. New UP_RESERVE union in reserve_traits.jl; storage reserve-balance multipliers, coverage branches (two of which silently skipped OfflineReserve, one asserted), get_fraction, and the TotalReserveOffering fold widened; load routing and folding accept it as committed shed. The _modify_device_model! no-op is scoped to NonSpinningReserve, whose awards ride ReservePowerConstraint instead of the device range expressions. --- src/common_models/add_to_expression.jl | 6 +- src/core/problem_template.jl | 7 +- src/core/reserve_traits.jl | 7 ++ src/energy_storage_models/storage_models.jl | 44 ++++++------- src/static_injector_models/electric_loads.jl | 5 +- test/test_device_reserve_offers.jl | 53 +++++++++++++++ test/test_storage_device_models.jl | 68 ++++++++++++++++++++ 7 files changed, 160 insertions(+), 30 deletions(-) diff --git a/src/common_models/add_to_expression.jl b/src/common_models/add_to_expression.jl index e13ecc47..5a0f884f 100644 --- a/src/common_models/add_to_expression.jl +++ b/src/common_models/add_to_expression.jl @@ -1987,9 +1987,7 @@ function add_to_expression!( T <: ActivePowerRangeExpressionUB, U <: VariableType, V <: PSY.Component, - # OfflineReserve (non-spin) is upward-only and has no direction param, so it routes to the - # same upper-bound expression as a ReserveUp reserve. - X <: Union{PSY.Reserve{PSY.ReserveUp}, PSY.OfflineReserve}, + X <: UP_RESERVE, W <: AbstractReservesFormulation, } service_name = PSY.get_name(service) @@ -2426,7 +2424,7 @@ function add_to_expression!( T <: ActivePowerRangeExpressionLB, U <: VariableType, V <: PSY.ElectricLoad, - X <: PSY.Reserve{PSY.ReserveUp}, + X <: UP_RESERVE, W <: AbstractReservesFormulation, } service_name = PSY.get_name(service) diff --git a/src/core/problem_template.jl b/src/core/problem_template.jl index 09296ccb..6e6a9114 100644 --- a/src/core/problem_template.jl +++ b/src/core/problem_template.jl @@ -265,7 +265,7 @@ end function _modify_device_model!( devices_template::Dict{Symbol, DeviceModel}, - service_model::ServiceModel{<:PSY.Reserve, <:AbstractReservesFormulation}, + service_model::ServiceModel{<:PSY.AbstractReserve, <:AbstractReservesFormulation}, contributing_devices::Vector{<:PSY.Component}, ) # Type stability: explicitly type the Set to avoid widening @@ -284,9 +284,12 @@ function _modify_device_model!( return end +# NonSpinningReserve awards ride ReservePowerConstraint (offline thermal headroom), not the +# device range expressions, so device models must not register the service. Other reserve +# formulations (e.g. an OfflineReserve ORDC under StepwiseCostReserve) register normally. function _modify_device_model!( ::Dict{Symbol, DeviceModel}, - ::ServiceModel{<:PSY.OfflineReserve, <:AbstractReservesFormulation}, + ::ServiceModel{<:PSY.OfflineReserve, NonSpinningReserve}, ::Vector{<:PSY.Component}, ) return diff --git a/src/core/reserve_traits.jl b/src/core/reserve_traits.jl index 802204ae..aea5353a 100644 --- a/src/core/reserve_traits.jl +++ b/src/core/reserve_traits.jl @@ -36,6 +36,13 @@ upward-only in every US market, so it maps to `PSY.ReserveUp`. _reserve_direction(::PSY.Reserve{T}) where {T <: PSY.ReserveDirection} = T _reserve_direction(::PSY.OfflineReserve) = PSY.ReserveUp +""" +Upward reserve products a device can supply: up-direction reserves plus `OfflineReserve` +(non-spinning is upward-only). Excludes `GroupReserve` - devices serve a group's members, +never the group itself. +""" +const UP_RESERVE = Union{PSY.Reserve{PSY.ReserveUp}, PSY.OfflineReserve} + "Whether a reserve is non-spinning: `OfflineReserve` vs everything else under `AbstractReserve`." _is_offline(::PSY.OfflineReserve) = true _is_offline(::PSY.AbstractReserve) = false diff --git a/src/energy_storage_models/storage_models.jl b/src/energy_storage_models/storage_models.jl index 0870dd4a..ff4cb320 100644 --- a/src/energy_storage_models/storage_models.jl +++ b/src/energy_storage_models/storage_models.jl @@ -427,7 +427,7 @@ get_variable_multiplier( ::Type{StorageReserveBalanceExpression{PSY.ReserveDown, UnscaledReserve, ChargeSide}}, d::PSY.Storage, ::Type{StorageDispatchWithReserves}, - ::PSY.Reserve{PSY.ReserveUp}, + ::UP_RESERVE, ) = 0.0 get_variable_multiplier( @@ -443,7 +443,7 @@ get_variable_multiplier( ::Type{StorageReserveBalanceExpression{PSY.ReserveUp, UnscaledReserve, ChargeSide}}, d::PSY.Storage, ::Type{StorageDispatchWithReserves}, - ::PSY.Reserve{PSY.ReserveUp}, + ::UP_RESERVE, ) = 1.0 get_variable_multiplier( @@ -461,7 +461,7 @@ get_variable_multiplier( }, d::PSY.Storage, ::Type{StorageDispatchWithReserves}, - ::PSY.Reserve{PSY.ReserveUp}, + ::UP_RESERVE, ) = 0.0 get_variable_multiplier( @@ -479,7 +479,7 @@ get_variable_multiplier( ::Type{StorageReserveBalanceExpression{PSY.ReserveUp, UnscaledReserve, DischargeSide}}, d::PSY.Storage, ::Type{StorageDispatchWithReserves}, - ::PSY.Reserve{PSY.ReserveUp}, + ::UP_RESERVE, ) = 1.0 get_variable_multiplier( @@ -496,7 +496,7 @@ get_variable_multiplier( ::Type{StorageReserveBalanceExpression{PSY.ReserveDown, DeployedReserve, ChargeSide}}, d::PSY.Storage, ::Type{StorageDispatchWithReserves}, - ::PSY.Reserve{PSY.ReserveUp}, + ::UP_RESERVE, ) = 0.0 get_variable_multiplier( @@ -512,7 +512,7 @@ get_variable_multiplier( ::Type{StorageReserveBalanceExpression{PSY.ReserveUp, DeployedReserve, ChargeSide}}, d::PSY.Storage, ::Type{StorageDispatchWithReserves}, - ::PSY.Reserve{PSY.ReserveUp}, + ::UP_RESERVE, ) = 1.0 get_variable_multiplier( @@ -530,7 +530,7 @@ get_variable_multiplier( }, d::PSY.Storage, ::Type{StorageDispatchWithReserves}, - ::PSY.Reserve{PSY.ReserveUp}, + ::UP_RESERVE, ) = 0.0 get_variable_multiplier( @@ -548,7 +548,7 @@ get_variable_multiplier( ::Type{StorageReserveBalanceExpression{PSY.ReserveUp, DeployedReserve, DischargeSide}}, d::PSY.Storage, ::Type{StorageDispatchWithReserves}, - ::PSY.Reserve{PSY.ReserveUp}, + ::UP_RESERVE, ) = 1.0 get_variable_multiplier( @@ -561,16 +561,16 @@ get_variable_multiplier( #! format: off # Use 1.0 because this is to allow to reuse the code below on add_to_expression -get_fraction(::Type{StorageReserveBalanceExpression{PSY.ReserveUp, UnscaledReserve, DischargeSide}}, d::PSY.Reserve) = 1.0 -get_fraction(::Type{StorageReserveBalanceExpression{PSY.ReserveUp, UnscaledReserve, ChargeSide}}, d::PSY.Reserve) = 1.0 -get_fraction(::Type{StorageReserveBalanceExpression{PSY.ReserveDown, UnscaledReserve, DischargeSide}}, d::PSY.Reserve) = 1.0 -get_fraction(::Type{StorageReserveBalanceExpression{PSY.ReserveDown, UnscaledReserve, ChargeSide}}, d::PSY.Reserve) = 1.0 +get_fraction(::Type{StorageReserveBalanceExpression{PSY.ReserveUp, UnscaledReserve, DischargeSide}}, d::PSY.AbstractReserve) = 1.0 +get_fraction(::Type{StorageReserveBalanceExpression{PSY.ReserveUp, UnscaledReserve, ChargeSide}}, d::PSY.AbstractReserve) = 1.0 +get_fraction(::Type{StorageReserveBalanceExpression{PSY.ReserveDown, UnscaledReserve, DischargeSide}}, d::PSY.AbstractReserve) = 1.0 +get_fraction(::Type{StorageReserveBalanceExpression{PSY.ReserveDown, UnscaledReserve, ChargeSide}}, d::PSY.AbstractReserve) = 1.0 # Needs to implement served fraction in PSY -get_fraction(::Type{StorageReserveBalanceExpression{PSY.ReserveUp, DeployedReserve, DischargeSide}}, d::PSY.Reserve) = PSY.get_deployed_fraction(d) -get_fraction(::Type{StorageReserveBalanceExpression{PSY.ReserveUp, DeployedReserve, ChargeSide}}, d::PSY.Reserve) = PSY.get_deployed_fraction(d) -get_fraction(::Type{StorageReserveBalanceExpression{PSY.ReserveDown, DeployedReserve, DischargeSide}}, d::PSY.Reserve) = PSY.get_deployed_fraction(d) -get_fraction(::Type{StorageReserveBalanceExpression{PSY.ReserveDown, DeployedReserve, ChargeSide}}, d::PSY.Reserve) = PSY.get_deployed_fraction(d) +get_fraction(::Type{StorageReserveBalanceExpression{PSY.ReserveUp, DeployedReserve, DischargeSide}}, d::PSY.AbstractReserve) = PSY.get_deployed_fraction(d) +get_fraction(::Type{StorageReserveBalanceExpression{PSY.ReserveUp, DeployedReserve, ChargeSide}}, d::PSY.AbstractReserve) = PSY.get_deployed_fraction(d) +get_fraction(::Type{StorageReserveBalanceExpression{PSY.ReserveDown, DeployedReserve, DischargeSide}}, d::PSY.AbstractReserve) = PSY.get_deployed_fraction(d) +get_fraction(::Type{StorageReserveBalanceExpression{PSY.ReserveDown, DeployedReserve, ChargeSide}}, d::PSY.AbstractReserve) = PSY.get_deployed_fraction(d) #! format: on function add_to_expression!( @@ -719,7 +719,7 @@ function add_to_expression!( T <: TotalReserveOffering, U <: ActivePowerReserveVariable, UV <: PSY.Storage, - V <: PSY.Reserve, + V <: PSY.AbstractReserve, W <: AbstractReservesFormulation, } s_name = PSY.get_name(service) @@ -976,7 +976,7 @@ function add_constraints!( for service in services_set service_name = PSY.get_name(service) - if typeof(service) <: PSY.Reserve{PSY.ReserveUp} + if typeof(service) <: UP_RESERVE add_constraints_container!(container, T, V, names, @@ -1021,7 +1021,7 @@ function add_constraints!( V, _service_container_meta(service), ) - if typeof(service) <: PSY.Reserve{PSY.ReserveUp} + if typeof(service) <: UP_RESERVE con_discharge = get_constraint( container, T(), @@ -1118,7 +1118,7 @@ function add_constraints!( services_types = unique(typeof.(services_set)) for serv_type in services_types - if serv_type <: PSY.Reserve{PSY.ReserveUp} + if serv_type <: UP_RESERVE add_constraints_container!(container, T, V, names, @@ -1165,7 +1165,7 @@ function add_constraints!( V, _service_container_meta(service), ) - if typeof(service) <: PSY.Reserve{PSY.ReserveUp} + if typeof(service) <: UP_RESERVE push!( expr_up_discharge, sustained_param_discharge * reserve_var_discharge[ci_name, :], @@ -1180,7 +1180,7 @@ function add_constraints!( end end for serv_type in services_types - if serv_type <: PSY.Reserve{PSY.ReserveUp} + if serv_type <: UP_RESERVE con_discharge = get_constraint(container, T(), V, "$(serv_type)_discharge") total_sustained = JuMP.AffExpr() diff --git a/src/static_injector_models/electric_loads.jl b/src/static_injector_models/electric_loads.jl index 5cee88bd..79bbc60f 100644 --- a/src/static_injector_models/electric_loads.jl +++ b/src/static_injector_models/electric_loads.jl @@ -39,8 +39,9 @@ variable_cost(cost::PSY.OperationalCost, ::Type{ShiftDownActivePowerVariable}, : ########################### Reserve provision, ElectricLoad ################################ # The inverse of a generator: up reserve is shed (P - r_up >= 0), down reserve is extra -# consumption (P + r_down <= forecast). Loads do not provide OfflineReserve. -get_expression_type_for_reserve(::Type{ActivePowerReserveVariable}, ::Type{<:PSY.ElectricLoad}, ::Type{<:PSY.Reserve{PSY.ReserveUp}}) = ActivePowerRangeExpressionLB +# consumption (P + r_down <= forecast). OfflineReserve (non-spin) is upward-only, so a +# load provides it as committed shed like any up reserve. +get_expression_type_for_reserve(::Type{ActivePowerReserveVariable}, ::Type{<:PSY.ElectricLoad}, ::Type{<:UP_RESERVE}) = ActivePowerRangeExpressionLB get_expression_type_for_reserve(::Type{ActivePowerReserveVariable}, ::Type{<:PSY.ElectricLoad}, ::Type{<:PSY.Reserve{PSY.ReserveDown}}) = ActivePowerRangeExpressionUB ###################################################### diff --git a/test/test_device_reserve_offers.jl b/test/test_device_reserve_offers.jl index 3c7feaa3..39a081c2 100644 --- a/test/test_device_reserve_offers.jl +++ b/test/test_device_reserve_offers.jl @@ -488,6 +488,59 @@ end end end +@testset "OfflineReserve as ORDC: load and generator supply" begin + sys = build_reserve_market_system() + thermals = collect(get_components(ThermalStandard, sys)) + il = get_component(PSY.InterruptiblePowerLoad, sys, _MKT_LOAD) + nspin = OfflineReserve(; + name = "NSPIN", + available = true, + time_frame = 30.0, + variable = _mkt_curve([0.0, 100.0, 200.0], [65.0, 11.0]), + ) + add_service!(sys, nspin, vcat(PSY.Device[thermals...], il)) + # Every participant carries an offer: an un-offered contributor supplies for free and + # would crowd out the load's priced block. + for (i, g) in enumerate(thermals) + PSY.set_service_bid!( + sys, g, nspin, _mkt_offer_ts(nspin, 30.0, 6.0 + i), IS.NaturalUnit(), + ) + end + nspin_offer = 8.0 + PSY.set_service_bid!( + sys, il, nspin, _mkt_offer_ts(nspin, nspin_offer, 3.0), IS.NaturalUnit(), + ) + + template = _reserve_market_template() + set_service_model!(template, ServiceModel(OfflineReserve, StepwiseCostReserve)) + model = DecisionModel( + template, sys; + optimizer = HiGHS_optimizer, store_variable_names = true, + ) + @test build!(model; output_dir = mktempdir(; cleanup = true)) == + IOM.ModelBuildStatus.BUILT + @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED + + res = IOM.OptimizationProblemOutputs(model) + demand = read_variable( + res, "ServiceRequirementVariable__OfflineReserve"; + table_format = TableFormat.WIDE, + ) + awards = read_variable( + res, "ActivePowerReserveVariable__OfflineReserve"; + table_format = TableFormat.WIDE, + ) + load_col = "NSPIN__$(_MKT_LOAD)" + @test load_col in names(awards) + for t in 1:24 + @test demand[t, "NSPIN"] > 1.0 + # The load's cheapest-in-stack block clears in full and stays offer-bounded: its + # non-spin award rides the same shed-headroom (LB) routing as any up reserve. + @test awards[t, load_col] <= nspin_offer + 1e-3 + @test awards[t, load_col] >= nspin_offer - 1e-2 + end +end + ################################################################################# # Load reserve provision (PowerLoadDispatch) ################################################################################# diff --git a/test/test_storage_device_models.jl b/test/test_storage_device_models.jl index 7da27927..1b9db11b 100644 --- a/test/test_storage_device_models.jl +++ b/test/test_storage_device_models.jl @@ -319,6 +319,74 @@ end =# moi_tests(model, 434, 0, 526, 286, 125, false) end +@testset "OfflineReserve (non-spin) as ORDC supplied by storage" begin + sys = PSB.build_system(PSITestSystems, "c_sys5_bat"; add_reserves = false) + nspin = OfflineReserve(; + name = "NSPIN", + available = true, + time_frame = 30.0, + sustained_time = 3600.0, + variable = make_market_bid_curve( + [0.0, 20.0, 40.0], [60.0, 10.0], 0.0; power_units = IS.NaturalUnit(), + ), + ) + bat = get_component(EnergyReservoirStorage, sys, "Bat") + thermals = collect(get_components(ThermalStandard, sys)) + add_service!(sys, nspin, vcat(PSY.Device[thermals...], bat)) + + template = get_thermal_dispatch_template_network(CopperPlateNetworkModel) + set_device_model!( + template, + DeviceModel( + EnergyReservoirStorage, + StorageDispatchWithReserves; + attributes = Dict{String, Any}( + "reservation" => true, + "cycling_limits" => false, + "energy_target" => false, + "complete_coverage" => true, + "regularization" => false, + ), + ), + ) + set_device_model!(template, RenewableDispatch, FixedOutput) + set_service_model!(template, ServiceModel(OfflineReserve, StepwiseCostReserve)) + + model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) + @test build!(model; output_dir = mktempdir(; cleanup = true)) == + ModelBuildStatus.BUILT + + # The up-side SOC coverage rows must exist for the non-spin product (it routes as an + # upward reserve), including the complete-coverage family. + container = IOM.get_optimization_container(model) + meta = POM._service_container_meta(nspin) + @test IOM.has_container_key( + container, ReserveCoverageConstraint, EnergyReservoirStorage, + "$(meta)_discharge", + ) + @test IOM.has_container_key( + container, POM.ReserveCompleteCoverageConstraint, EnergyReservoirStorage, + "$(OfflineReserve{IS.NaturalUnit})_discharge", + ) + + # Balance rows carry charge + discharge - award: the award term is wired, not skipped. + con = IOM.get_constraints(model)[IOM.ConstraintKey( + StorageTotalReserveConstraint, OfflineReserve, "NSPIN_$EnergyReservoirStorage", + )] + @test all( + length(JuMP.constraint_object(con[n, t]).func.terms) == 3 + for n in axes(con)[1], t in axes(con)[2] + ) + + @test solve!(model) == RunStatus.SUCCESSFULLY_FINALIZED + res = IOM.OptimizationProblemOutputs(model) + demand = read_variable( + res, "ServiceRequirementVariable__OfflineReserve"; + table_format = TableFormat.WIDE, + ) + @test all(demand[t, "NSPIN"] > 1.0 for t in 1:24) +end + @testset "Test Storage Energy Target Constraint" begin template = get_thermal_dispatch_template_network(CopperPlateNetworkModel) device_model = DeviceModel( From a39887200c01aa59d15e23bfb11c6a76a457a884 Mon Sep 17 00:00:00 2001 From: rodrigomha Date: Sat, 15 Aug 2026 21:41:01 -0700 Subject: [PATCH 16/23] Pin PSY psy6's unregistered OpenAPI deps in every environment PowerSystems psy6 (post schema-matching merge) depends on the unregistered PowerCoreOpenAPIModels / PowerOperationsOpenAPIModels. Pkg ignores [sources] of non-root projects, so each environment that resolves PSY - root, test, docs - must pin them itself; CI failed with 'PowerOperationsOpenAPIModels has no known versions' on all jobs. Pins mirror PSY's own (monorepo main, subdirs) and are temporary until the packages are registered. --- docs/Project.toml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/Project.toml b/docs/Project.toml index 19dd7aa0..75488ae4 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -6,6 +6,8 @@ DocumenterInterLinks = "d12716ef-a0f6-4df4-a9f1-a5a34e75c656" InfrastructureOptimizationModels = "bed98974-b02a-5e2f-9ee0-a103f5c45069" InfrastructureSystems = "2cd47ed4-ca9b-11e9-27f2-ab636a7671f1" Literate = "98b081ad-f1c9-55d3-8b20-4c87d4299306" +PowerCoreOpenAPIModels = "b7b40286-e793-417d-a9a0-b1583e4da1cb" +PowerOperationsOpenAPIModels = "a372b6d7-45a2-44c2-8199-6a724b72e8ff" PowerNetworkMatrices = "bed98974-b02a-5e2f-9fe0-a103f5c450dd" PowerOperationsModels = "bed98974-b02a-5e2f-9ee0-a103f5c450dd" PowerSystems = "bcd98974-b02a-5e2f-9ee0-a103f5c450dd" @@ -16,6 +18,11 @@ InfrastructureSystems = {rev = "IS4", url = "https://github.com/Sienna-Platform/ PowerSystems = {rev = "psy6", url = "https://github.com/Sienna-Platform/PowerSystems.jl"} InfrastructureOptimizationModels = {rev = "main", url = "https://github.com/Sienna-Platform/InfrastructureOptimizationModels.jl"} PowerNetworkMatrices = {rev = "psy6", url = "https://github.com/Sienna-Platform/PowerNetworkMatrices.jl"} +# PSY psy6 depends on these unregistered packages; [sources] of non-root projects are +# ignored by Pkg, so every environment resolving PSY must pin them itself (same rev as +# PSY's own sources). Temporary until the OpenAPI packages are registered. +PowerCoreOpenAPIModels = {rev = "main", url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", subdir = "PowerCoreOpenAPIModels.jl"} +PowerOperationsOpenAPIModels = {rev = "main", url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", subdir = "PowerOperationsOpenAPIModels.jl"} [compat] Documenter = "^1.0" From 1a93e6af59b800008a85356acd6f6cfabeb711c8 Mon Sep 17 00:00:00 2001 From: rodrigomha Date: Tue, 18 Aug 2026 16:11:09 -0700 Subject: [PATCH 17/23] Fix charge-side reserve-assignment direction in StorageDispatchWithReserves The assignment bounds applied the discharge-side convention (up raises power, down lowers it) to BOTH sides, but reserves swap roles on the charge side: a downward reserve INCREASES charging and an upward reserve DECREASES it - the same convention the deployment expressions already implement. As written, charge-side down-reserve room was capped at p_in (zero for an idle or discharging storage) instead of charge_max - p_in, and charge-side up room was over-granted as charge_max - p_in instead of p_in. Total up room for an idle storage exceeded the HSL - p_net capability; total down room collapsed to p_out. Map the assignment bounds in side-increasing/decreasing terms instead (discharge: Up/Down; charge: Down/Up). --- src/energy_storage_models/storage_models.jl | 27 ++++++++++----------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/src/energy_storage_models/storage_models.jl b/src/energy_storage_models/storage_models.jl index ff4cb320..1fadf671 100644 --- a/src/energy_storage_models/storage_models.jl +++ b/src/energy_storage_models/storage_models.jl @@ -888,21 +888,20 @@ function add_energybalance_without_reserves!( return end -# Reserve-assignment bounds for discharge (Up) / charge (Down): -# UB: power + up_assignment <= max -# LB: power - down_assignment >= min -# Same shape for both directions, parametrized by the "assignment" expression pair and -# power variable/limits; routed through `IOM.add_range_bound_constraint!`. +# Reserve-assignment bounds per side. Reserves swap roles on the charge side (down +# INCREASES charging, up DECREASES it), same convention as the deployment expressions: +# UB: power + increasing <= max (discharge: Up; charge: Down) +# LB: power - decreasing >= min (discharge: Down; charge: Up) _reserve_assignment_power_var(::Type{ReserveDischargeConstraint}) = ActivePowerOutVariable _reserve_assignment_power_var(::Type{ReserveChargeConstraint}) = ActivePowerInVariable -_reserve_assignment_up_expr(::Type{ReserveDischargeConstraint}) = +_reserve_assignment_increasing_expr(::Type{ReserveDischargeConstraint}) = StorageReserveBalanceExpression{PSY.ReserveUp, UnscaledReserve, DischargeSide} -_reserve_assignment_down_expr(::Type{ReserveDischargeConstraint}) = +_reserve_assignment_decreasing_expr(::Type{ReserveDischargeConstraint}) = StorageReserveBalanceExpression{PSY.ReserveDown, UnscaledReserve, DischargeSide} -_reserve_assignment_up_expr(::Type{ReserveChargeConstraint}) = - StorageReserveBalanceExpression{PSY.ReserveUp, UnscaledReserve, ChargeSide} -_reserve_assignment_down_expr(::Type{ReserveChargeConstraint}) = +_reserve_assignment_increasing_expr(::Type{ReserveChargeConstraint}) = StorageReserveBalanceExpression{PSY.ReserveDown, UnscaledReserve, ChargeSide} +_reserve_assignment_decreasing_expr(::Type{ReserveChargeConstraint}) = + StorageReserveBalanceExpression{PSY.ReserveUp, UnscaledReserve, ChargeSide} _reserve_assignment_limits(::Type{ReserveDischargeConstraint}, d) = PSY.get_output_active_power_limits(d, PSY.SU) _reserve_assignment_limits(::Type{ReserveChargeConstraint}, d) = @@ -927,8 +926,8 @@ function add_constraints!( time_steps = get_time_steps(container) jump_model = get_jump_model(container) power_var = get_variable(container, _reserve_assignment_power_var(T), V) - r_up = get_expression(container, _reserve_assignment_up_expr(T), V) - r_dn = get_expression(container, _reserve_assignment_down_expr(T), V) + r_inc = get_expression(container, _reserve_assignment_increasing_expr(T), V) + r_dec = get_expression(container, _reserve_assignment_decreasing_expr(T), V) con_ub = add_constraints_container!(container, T, V, names, time_steps; meta = "ub") con_lb = add_constraints_container!(container, T, V, names, time_steps; meta = "lb") @@ -937,10 +936,10 @@ function add_constraints!( limits = _reserve_assignment_limits(T, d) IOM.add_range_bound_constraint!( IOM.UpperBound(), jump_model, con_ub, name, t, - power_var[name, t] + r_up[name, t], limits.max) + power_var[name, t] + r_inc[name, t], limits.max) IOM.add_range_bound_constraint!( IOM.LowerBound(), jump_model, con_lb, name, t, - power_var[name, t] - r_dn[name, t], limits.min) + power_var[name, t] - r_dec[name, t], limits.min) end return end From 3403a51efa6dc55b37e8708c376876418930dd87 Mon Sep 17 00:00:00 2001 From: rodrigomha Date: Tue, 18 Aug 2026 16:11:29 -0700 Subject: [PATCH 18/23] Add reserve_coverage attribute: energy/AS decoupling for StorageDispatchWithReserves New attribute, default true (existing behavior unchanged). Setting false decouples ancillary services from the energy schedule, matching day-ahead market clearing where AS awards are bounded by offer quantity and capability only: - no ReserveCoverageConstraint / ReserveCoverageConstraintEndOfPeriod (SOC feasibility of awards is a real-time concern, not a clearing rule) - reserve deployment bounds use the no-reservation builders, so the AS band never depends on the reservation binary; the binary still governs energy charge/discharge exclusivity - the SOC evolution carries no expected reserve-deployment energy - complete_coverage is suppressed with a warning instead of silently combined Regression test builds a decoupled reserve-carrying storage and asserts the coverage constraints are absent, the reservation binary exists, and no deployment power-limit row references it. --- docs/src/reference/formulation_library.md | 8 ++- src/core/formulations.jl | 1 + .../storage_constructor.jl | 69 +++++++++++-------- src/energy_storage_models/storage_models.jl | 4 +- test/test_storage_device_models.jl | 60 ++++++++++++++++ 5 files changed, 110 insertions(+), 32 deletions(-) diff --git a/docs/src/reference/formulation_library.md b/docs/src/reference/formulation_library.md index 570ea341..caf33383 100644 --- a/docs/src/reference/formulation_library.md +++ b/docs/src/reference/formulation_library.md @@ -451,8 +451,12 @@ There is exactly one concrete storage formulation and one concrete hybrid formul ### [`StorageDispatchWithReserves`](@id storage_math_model) -Attributes: `"reservation"` (default `true`), `"cycling_limits"`, `"energy_target"`, -`"complete_coverage"`, `"regularization"` (all default `false`). +Attributes: `"reservation"`, `"reserve_coverage"` (default `true`; `reserve_coverage = false` +DECOUPLES energy and AS for day-ahead-style clearing: no SOC deployment-coverage constraints, +reserve bands bounded by capability instead of the reservation binary's dispatch side, and +`complete_coverage` ignored with a warning - the binary still governs energy exclusivity), +`"cycling_limits"`, `"energy_target"`, `"complete_coverage"`, `"regularization"` +(all default `false`). | Stage | Emits | |:-------- |:--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | diff --git a/src/core/formulations.jl b/src/core/formulations.jl index 67ffa7f0..ef15ff46 100644 --- a/src/core/formulations.jl +++ b/src/core/formulations.jl @@ -594,6 +594,7 @@ The formulation supports the following attributes when used in a [`PowerSimulati Combining cycle limits and energy target attributes is not recommended. Both attributes impose constraints on energy. There is no guarantee that the constraints can be satisfied simultaneously. + - `"reserve_coverage"`: Couples ancillary-service awards to the physical energy schedule. When `true` (default), the storage's state of charge must cover each service's sustained deployment (`ReserveCoverageConstraint` at both period endpoints) and the reserve band is limited by the reservation binary's dispatch side. Set `false` to DECOUPLE energy and AS, mirroring day-ahead market clearing (e.g., ERCOT DAM): AS awards are bounded by offer quantity and power capability only - no SOC feasibility, no reservation-binary coupling (the binary still governs energy charge/discharge exclusivity), no expected reserve-deployment energy in the SOC balance (deployment is a real-time settlement concept), and `complete_coverage` is ignored (with a warning). - `"complete_coverage"`: This attribute implements constraints that require the battery to cover the sum of all the ancillary services it participates in simultaneously. It is equivalent to holding energy in case all the services get deployed simultaneously. This constraint is added to the constraints that cover each service independently and corresponds to a more conservative operation regime. - `"regularization"`: This attribute smooths the charge/discharge profiles to avoid bang-bang solutions via a penalty on the absolute value of the intra-temporal variations of the charge and discharge power. Solving for optimal storage dispatch can stall in models with large amounts of curtailment or long periods with negative or zero prices due to numerical degeneracy. The regularization term is scaled by the storage device's power limits to normalize the term and avoid additional penalties to larger storage units. diff --git a/src/energy_storage_models/storage_constructor.jl b/src/energy_storage_models/storage_constructor.jl index 563e5786..b17038c2 100644 --- a/src/energy_storage_models/storage_constructor.jl +++ b/src/energy_storage_models/storage_constructor.jl @@ -75,21 +75,24 @@ function _add_ancillary_services!( model::DeviceModel{T, U}, network_model::NetworkModel{V}, ) where {T <: PSY.Storage, U <: StorageDispatchWithReserves, V <: AbstractNetworkModel} - add_constraints!( - container, - ReserveCoverageConstraint, - devices, - model, - network_model, - ) + # SOC coverage is physical feasibility, not market clearing - skipped when decoupled. + if get_attribute(model, "reserve_coverage") + add_constraints!( + container, + ReserveCoverageConstraint, + devices, + model, + network_model, + ) - add_constraints!( - container, - ReserveCoverageConstraintEndOfPeriod, - devices, - model, - network_model, - ) + add_constraints!( + container, + ReserveCoverageConstraintEndOfPeriod, + devices, + model, + network_model, + ) + end add_constraints!( container, @@ -165,7 +168,9 @@ function _active_power_and_energy_bounds( network_model::NetworkModel, ) where {T <: PSY.Storage, U <: StorageDispatchWithReserves} if has_service_model(model) - if get_attribute(model, "reservation") + # Decoupled AS (`reserve_coverage = false`): reserve bounds ignore the reservation + # binary; the binary still governs energy charge/discharge exclusivity. + if get_attribute(model, "reservation") && get_attribute(model, "reserve_coverage") add_reserve_range_constraint_with_deployment!( container, OutputActivePowerVariableLimitsConstraint, @@ -307,20 +312,26 @@ function _energy_constraints_and_objective!( if has_service_model(model) if get_attribute(model, "complete_coverage") - add_constraints!( - container, - ReserveCompleteCoverageConstraint, - devices, - model, - network_model, - ) - add_constraints!( - container, - ReserveCompleteCoverageConstraintEndOfPeriod, - devices, - model, - network_model, - ) + if get_attribute(model, "reserve_coverage") + add_constraints!( + container, + ReserveCompleteCoverageConstraint, + devices, + model, + network_model, + ) + add_constraints!( + container, + ReserveCompleteCoverageConstraintEndOfPeriod, + devices, + model, + network_model, + ) + else + @warn "complete_coverage = true is ignored because reserve_coverage = false: " * + "SOC coverage of AS awards (individual and complete) is disabled when " * + "energy and ancillary services are decoupled." + end end end diff --git a/src/energy_storage_models/storage_models.jl b/src/energy_storage_models/storage_models.jl index 1fadf671..32c28c5a 100644 --- a/src/energy_storage_models/storage_models.jl +++ b/src/energy_storage_models/storage_models.jl @@ -120,6 +120,7 @@ function get_default_attributes( "reservation" => true, "cycling_limits" => false, "energy_target" => false, + "reserve_coverage" => true, "complete_coverage" => false, "regularization" => false, ) @@ -748,7 +749,8 @@ function add_constraints!( model::DeviceModel{V, StorageDispatchWithReserves}, network_model::NetworkModel{X}, ) where {V <: PSY.Storage, X <: AbstractNetworkModel} - if has_service_model(model) + # Decoupled AS: the SOC evolution carries no expected reserve-deployment energy. + if has_service_model(model) && get_attribute(model, "reserve_coverage") add_energybalance_with_reserves!(container, devices, model, network_model) else add_energybalance_without_reserves!(container, devices, model, network_model) diff --git a/test/test_storage_device_models.jl b/test/test_storage_device_models.jl index 1b9db11b..b6be23c9 100644 --- a/test/test_storage_device_models.jl +++ b/test/test_storage_device_models.jl @@ -387,6 +387,66 @@ end @test all(demand[t, "NSPIN"] > 1.0 for t in 1:24) end +@testset "Energy/AS decoupling: reserve_coverage = false" begin + template = get_thermal_dispatch_template_network(CopperPlateNetworkModel) + device_model = DeviceModel( + EnergyReservoirStorage, + StorageDispatchWithReserves; + attributes = Dict{String, Any}( + "reservation" => true, + "cycling_limits" => false, + "energy_target" => false, + "reserve_coverage" => false, + # Deliberately true: the decoupling must suppress it (with a warning). + "complete_coverage" => true, + "regularization" => false, + ), + ) + set_device_model!(template, device_model) + set_device_model!(template, RenewableDispatch, FixedOutput) + set_service_model!(template, ServiceModel(OnlineReserve{ReserveUp}, RangeReserve)) + set_service_model!(template, ServiceModel(OnlineReserve{ReserveDown}, RangeReserve)) + c_sys5_bat = PSB.build_system(PSITestSystems, "c_sys5_bat"; add_reserves = true) + _deactivate_unmodeled_ordc!(c_sys5_bat) + model = DecisionModel(template, c_sys5_bat) + @test build!(model; output_dir = mktempdir(; cleanup = true)) == + ModelBuildStatus.BUILT + + constraints = IOM.get_constraints(model) + # No SOC coverage of any flavor: individual, end-of-period, or complete (the + # complete_coverage = true above must be suppressed by the decoupling). + coverage_types = ( + ReserveCoverageConstraint, + ReserveCoverageConstraintEndOfPeriod, + ReserveCompleteCoverageConstraint, + ReserveCompleteCoverageConstraintEndOfPeriod, + ) + @test all(k -> IOM.get_entry_type(k) ∉ coverage_types, keys(constraints)) + + # The reservation binary still exists: energy charge/discharge exclusivity is kept. + variables = IOM.get_variables(model) + resv_key = IOM.VariableKey(ReservationVariable, EnergyReservoirStorage) + @test haskey(variables, resv_key) + + # ...but the reserve band is decoupled from it: no deployment power-limit row may + # reference the binary (the no-reservation bound builders are used instead). + ss_vars = Set(vec(variables[resv_key].data)) + for ckey in keys(constraints) + IOM.get_entry_type(ckey) in ( + POM.OutputActivePowerVariableLimitsConstraint, + POM.InputActivePowerVariableLimitsConstraint, + ) || continue + arr = constraints[ckey] + data = arr isa JuMP.Containers.DenseAxisArray ? arr.data : arr + for i in eachindex(data) + isassigned(data, i) || continue + func = JuMP.constraint_object(data[i]).func + func isa JuMP.GenericAffExpr || continue + @test isempty(intersect(Set(keys(func.terms)), ss_vars)) + end + end +end + @testset "Test Storage Energy Target Constraint" begin template = get_thermal_dispatch_template_network(CopperPlateNetworkModel) device_model = DeviceModel( From 2521ead2f3d1650a6608d4222c29573f8973f7bb Mon Sep 17 00:00:00 2001 From: rodrigomha Date: Tue, 18 Aug 2026 16:11:29 -0700 Subject: [PATCH 19/23] Pin the test env's PowerOperationsModels to the working tree Without the self-pin a clean clone resolves POM from the registry and tests run against a released version instead of the checkout. --- test/Project.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/test/Project.toml b/test/Project.toml index 4050aa45..90825d0a 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -37,6 +37,7 @@ TimeSeries = "9e3dc215-6440-5c97-bce1-76c03772f85e" UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4" [sources] +PowerOperationsModels = {path = ".."} InfrastructureOptimizationModels = {rev = "main", url = "https://github.com/Sienna-Platform/InfrastructureOptimizationModels.jl"} InfrastructureSystems = {rev = "IS4", url = "https://github.com/Sienna-Platform/InfrastructureSystems.jl"} PowerCoreOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "PowerCoreOpenAPIModels.jl"} From af1f71f144241842d406028d1324dfd58b90741b Mon Sep 17 00:00:00 2001 From: Jose Daniel Lara Date: Tue, 18 Aug 2026 18:06:32 -0600 Subject: [PATCH 20/23] Catch costless market-bid loads selling reserves A load that sells reserves with no price on its consumption has nothing pinning that consumption, so the build rejects it. The check only recognized a zero LoadCost, but a ControllableLoad can carry a MarketBidCost just as well, and MarketBidCost defaults to a zero offer curve -- so the case the check exists to catch walked straight past it. Dispatch the emptiness test on the cost type instead of branching on isa, which also gives the market-bid variants somewhere to live: both read the decremental side, the one a load offers on, and reuse is_nontrivial_offer to tell a real curve from PSY's zero placeholder. --- src/static_injector_models/electric_loads.jl | 18 ++++++++++++++---- test/test_device_reserve_offers.jl | 12 ++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/static_injector_models/electric_loads.jl b/src/static_injector_models/electric_loads.jl index 79bbc60f..1887e76b 100644 --- a/src/static_injector_models/electric_loads.jl +++ b/src/static_injector_models/electric_loads.jl @@ -549,6 +549,16 @@ function add_constraints!( return end +# Is this cost curve zero-valued, i.e. it puts no price on the device's dispatch? +_is_costless_offer(cost::PSY.LoadCost) = PSY.get_variable(cost) == zero(PSY.CostCurve) +_is_costless_offer(cost::PSY.MarketBidCost) = + !IOM.is_nontrivial_offer(get_input_offer_curves(cost)) +# A real (non-placeholder) time-series key means a genuine decremental offer is attached; +# whether its resolved values are all zero is unknowable before the series is read. +_is_costless_offer(cost::PSY.MarketBidTimeSeriesCost) = + !IOM.is_nontrivial_offer(get_input_offer_curves(cost)) +_is_costless_offer(::PSY.OperationalCost) = false + ############################## FormulationControllable Load Cost ########################### function add_to_objective_function!( container::OptimizationContainer, @@ -560,13 +570,13 @@ function add_to_objective_function!( if has_service_model(model) for d in devices cost = PSY.get_operation_cost(d) - if cost isa PSY.LoadCost && PSY.get_variable(cost) == zero(PSY.CostCurve) + if _is_costless_offer(cost) throw( IS.ConflictingInputsError( "PowerLoadDispatch load '$(PSY.get_name(d))' provides a reserve \ - service but its LoadCost value curve is zero; attach an \ - energy/VOLL value (e.g. set_operation_cost! with a priced \ - LoadCost) so its dispatch is pinned.", + service but its cost curve is zero; attach an energy/VOLL value \ + (e.g. set_operation_cost! with a priced LoadCost, or a nonzero \ + MarketBidCost decremental offer) so its dispatch is pinned.", ), ) end diff --git a/test/test_device_reserve_offers.jl b/test/test_device_reserve_offers.jl index 39a081c2..2e9bc5fa 100644 --- a/test/test_device_reserve_offers.jl +++ b/test/test_device_reserve_offers.jl @@ -709,6 +709,18 @@ end IOM.ModelBuildStatus.FAILED end +@testset "Costless market-bid load offering reserves fails loudly" begin + sys = deepcopy(PSB.build_system(PSITestSystems, "c_sys5_il"; add_reserves = true)) + il = get_component(PSY.InterruptiblePowerLoad, sys, _IL_NAME) + set_operation_cost!(il, MarketBidCost()) + model = DecisionModel( + _load_reserve_template(:up), sys; + optimizer = HiGHS_optimizer, + ) + @test build!(model; output_dir = mktempdir(; cleanup = true)) == + IOM.ModelBuildStatus.FAILED +end + @testset "Co-provision: two up-services share one shed headroom" begin sys = deepcopy(PSB.build_system(PSITestSystems, "c_sys5_il"; add_reserves = true)) il = get_component(PSY.InterruptiblePowerLoad, sys, _IL_NAME) From 5f7b6c8eab1dbf8be4710b3242c7e996a440084f Mon Sep 17 00:00:00 2001 From: Jose Daniel Lara Date: Tue, 18 Aug 2026 18:06:38 -0600 Subject: [PATCH 21/23] Drop the stale disable note on the hydro pump energy test The comment said the test was disabled pending upstream work, but nothing disabled it and the sentence broke off mid-thought. The upstream cause is fixed: importing a zero-capacity reservoir no longer divides 0 by 0 into a NaN initial_level, which is what made solve! fail while writing the system out (PowerSystems 7b27254f8). The assertion passes, so the note is just misleading. --- test/test_device_hydro_constructors.jl | 1 - 1 file changed, 1 deletion(-) diff --git a/test/test_device_hydro_constructors.jl b/test/test_device_hydro_constructors.jl index 649307a7..ffb84f65 100644 --- a/test/test_device_hydro_constructors.jl +++ b/test/test_device_hydro_constructors.jl @@ -626,7 +626,6 @@ end psi_checkobjfun_test(model, GAEVF) end -# psy6: disabled pending upstream work in PowerSystems, on the import side rather than the @testset "Test Hydro Pump Energy Dispatch Formulations 2" begin output_dir = mktempdir(; cleanup = true) From 3fa22217eaa17bb619a81d6bad4030d7b8907308 Mon Sep 17 00:00:00 2001 From: rodrigomha Date: Wed, 19 Aug 2026 14:28:40 -0700 Subject: [PATCH 22/23] Add a time-series group demand curve regression test for GroupStepwiseCostReserve The group formulation has supported time-series-backed ORDCs since the stepwise parameter machinery generalized to the reserve tree, but had no dedicated coverage. The test alternates the group curve's demand cap 40/80 MW by hour at a decisive price and asserts the cleared group demand tracks the alternation exactly - something a static curve cannot produce. --- test/test_services_constructor.jl | 40 +++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/test/test_services_constructor.jl b/test/test_services_constructor.jl index 219f49db..d1ce2f89 100644 --- a/test/test_services_constructor.jl +++ b/test/test_services_constructor.jl @@ -1769,6 +1769,46 @@ end @test sub_a_total > sub_b_total end +@testset "GroupStepwiseCostReserve: time-series group demand curve clears per hour" begin + # The group ORDC can be time-series-backed (`CostCurve{TimeSeriesPiecewiseIncrementalCurve}`, + # same Union as the single reserves); the parameter machinery + # (`process_stepwise_cost_reserve_parameters!`) must feed hour-varying blocks into the + # clearing. Demand cap alternates 40/80 MW by hour at a price far above both the member + # offer and any commitment cost, so the cleared group demand must track the alternation + # exactly - a static curve cannot. (A moderate price lets commitment economics under-buy; + # the point here is the hourly caps, so make the value decisive.) + sys, group = build_group_reserve_system(; group_curve = false) + init_times = [DateTime("2024-01-01T00:00:00"), DateTime("2024-01-02T00:00:00")] + horizon = 24 + curves = [IS.PiecewiseStepData([0.0, isodd(h) ? 40.0 : 80.0], [9.0e4]) for h in 1:horizon] + data = Dict(it => copy(curves) for it in init_times) + PSY.add_time_series!( + sys, + group, + Deterministic("variable_cost", data, Hour(1)), + ) + key = IS.ForecastKey(; + time_series_type = IS.Deterministic, + name = "variable_cost", + initial_timestamp = first(init_times), + resolution = Hour(1), + horizon = Hour(horizon), + interval = Hour(24), + count = 2, + features = Dict{String, Any}(), + ) + PSY.set_variable!(group, PSY.make_market_bid_ts_curve(key, nothing, IS.NaturalUnit())) + @test PSY.has_demand_curve(group) + + model = _solve_group_model(sys) + res = IOM.OptimizationProblemOutputs(model) + demand = read_variable(res, "ServiceRequirementVariable__GroupReserve__ReserveUp"; + table_format = TableFormat.WIDE) + for t in 1:horizon + @test demand[t, "UP_GROUP"] ≈ (isodd(t) ? 40.0 : 80.0) atol = 1e-4 + end +end + @testset "GroupStepwiseCostReserve: no group model -> no procurement" begin sys, _ = build_group_reserve_system() model = _solve_group_model(sys; include_group = false) From 79013651cad903bc138e6fa8548b74d742352348 Mon Sep 17 00:00:00 2001 From: rodrigomha Date: Wed, 19 Aug 2026 17:08:51 -0700 Subject: [PATCH 23/23] split decision for OfflineReserves in Thermal --- src/common_models/add_to_expression.jl | 35 +++- src/core/constraints.jl | 16 ++ src/core/expressions.jl | 10 + src/core/reserve_traits.jl | 8 + .../thermal_generation.jl | 36 ++++ .../thermalgeneration_constructor.jl | 193 +++++++++++++++--- test/test_device_reserve_offers.jl | 126 ++++++++++++ test/test_services_constructor.jl | 3 +- 8 files changed, 391 insertions(+), 36 deletions(-) diff --git a/src/common_models/add_to_expression.jl b/src/common_models/add_to_expression.jl index 5a0f884f..2452ad0b 100644 --- a/src/common_models/add_to_expression.jl +++ b/src/common_models/add_to_expression.jl @@ -1957,7 +1957,11 @@ function add_to_expression!( model::DeviceModel{V, W}, network_model::NetworkModel{X}, ) where { - T <: Union{ActivePowerRangeExpressionUB, ActivePowerRangeExpressionLB}, + T <: Union{ + ActivePowerRangeExpressionUB, + ActivePowerRangeExpressionLB, + ActivePowerRangeExpressionOnlineUB, + }, U <: VariableType, V <: PSY.Device, W <: AbstractDeviceFormulation, @@ -1997,6 +2001,17 @@ function add_to_expression!( end expression = get_expression(container, T, V) time_steps = get_time_steps(container) + # Online up-reserves also occupy the online-only band row when a device carries an + # OfflineReserve (the OnlineUB expression exists only in that case); offline awards + # live solely in the shared UB band, freed from the commitment gate by + # OfflineReserveBandConstraint. + online_ub = + if !(service isa PSY.OfflineReserve) && + has_container_key(container, ActivePowerRangeExpressionOnlineUB, V) + get_expression(container, ActivePowerRangeExpressionOnlineUB, V) + else + nothing + end for d in devices, t in time_steps name = PSY.get_name(d) add_proportional_to_jump_expression!( @@ -2004,6 +2019,12 @@ function add_to_expression!( variable[(service_name, name, t)], 1.0, ) + online_ub === nothing && continue + add_proportional_to_jump_expression!( + online_ub[name, t], + variable[(service_name, name, t)], + 1.0, + ) end return end @@ -2486,7 +2507,11 @@ function add_to_expression!( devices::IS.FlattenIteratorWrapper{V}, model::DeviceModel{V, W}, ) where { - T <: Union{ActivePowerRangeExpressionUB, ActivePowerRangeExpressionLB}, + T <: Union{ + ActivePowerRangeExpressionUB, + ActivePowerRangeExpressionLB, + ActivePowerRangeExpressionOnlineUB, + }, U <: OnStatusParameter, V <: PSY.Device, W <: AbstractDeviceFormulation, @@ -2519,7 +2544,11 @@ function add_to_expression!( devices::IS.FlattenIteratorWrapper{V}, model::DeviceModel{V, W}, ) where { - T <: Union{ActivePowerRangeExpressionUB, ActivePowerRangeExpressionLB}, + T <: Union{ + ActivePowerRangeExpressionUB, + ActivePowerRangeExpressionLB, + ActivePowerRangeExpressionOnlineUB, + }, U <: OnStatusParameter, V <: PSY.ThermalGen, W <: AbstractThermalDispatchFormulation, diff --git a/src/core/constraints.jl b/src/core/constraints.jl index b745c2b8..cf5de7c1 100644 --- a/src/core/constraints.jl +++ b/src/core/constraints.jl @@ -1197,3 +1197,19 @@ e^{st}_{T} - e^{st+} + e^{st-} = E^{st}_{T}. ``` """ struct HybridEnergyTargetConstraint <: ConstraintType end + +""" +Band-plus-offline-capability row for unit-commitment devices contributing to an +`OfflineReserve`, on the shared `ActivePowerRangeExpressionUB` (`p + online + offline`): + +`p + online + offline <= pmax * u + q_limit * (1 - u)` + +with `q_limit = pmax` (an hourly DAM lets most units reach `pmax` from OFF), so the row is +the static range `<= pmax`. Committed: offline competes with the online products for the +HSL band. Off: the paired semi-continuous row on +`ActivePowerRangeExpressionOnlineUB` zeroes `p` and the online awards, leaving +`offline <= q_limit`. Single award variable per (device, service): the device's merged +offer curve prices both provision states (documented approximation - the online/offline +non-spin offer prices are not differentiated). +""" +struct OfflineReserveBandConstraint <: ConstraintType end diff --git a/src/core/expressions.jl b/src/core/expressions.jl index bf4c9b37..5ec88622 100644 --- a/src/core/expressions.jl +++ b/src/core/expressions.jl @@ -104,6 +104,16 @@ right-hand side of the system-level reserve balance. """ struct TotalReserveOffering <: ExpressionType end +""" +Online-only upper range expression for unit-commitment devices that contribute to an +`OfflineReserve`: `p + online_reserves`, bounded semi-continuously (`<= pmax * u`) so +online products die with the commitment. Built ONLY when the device model carries an +`OfflineReserve` service; the shared `ActivePowerRangeExpressionUB` then holds the full +band (`p + online + offline`) bounded by the offline capability row instead. See +[`OfflineReserveBandConstraint`](@ref). +""" +struct ActivePowerRangeExpressionOnlineUB <: RangeConstraintUBExpressions end + abstract type ReserveAggregationExpression{ D <: PSY.ReserveDirection, S <: ReserveScale, diff --git a/src/core/reserve_traits.jl b/src/core/reserve_traits.jl index aea5353a..e4e90163 100644 --- a/src/core/reserve_traits.jl +++ b/src/core/reserve_traits.jl @@ -82,3 +82,11 @@ written with the fully concrete instance type, and the two spellings do not matc """ _service_container_meta(service::PSY.Service) = "$(typeof(service))_$(PSY.get_name(service))" + +""" +Whether a `DeviceModel` carries an `OfflineReserve` service. Gates the offline-capability +machinery (`ActivePowerRangeExpressionOnlineUB` + `OfflineReserveBandConstraint`) so that +models without offline reserves build exactly the classic single semi-continuous band row. +""" +_has_offline_reserve_service(model::DeviceModel) = + any(sm -> get_component_type(sm) <: PSY.OfflineReserve, values(get_services(model))) diff --git a/src/static_injector_models/thermal_generation.jl b/src/static_injector_models/thermal_generation.jl index b2a2d059..c9d7c8cf 100644 --- a/src/static_injector_models/thermal_generation.jl +++ b/src/static_injector_models/thermal_generation.jl @@ -90,6 +90,7 @@ get_multiplier_value(::Type{FuelCostParameter}, d::PSY.ThermalGen, ::Type{<:Abst get_parameter_multiplier(::Type{<:VariableValueParameter}, d::PSY.ThermalGen, ::Type{<:AbstractThermalFormulation}) = 1.0 get_initial_parameter_value(::Type{<:VariableValueParameter}, d::PSY.ThermalGen, ::Type{<:AbstractThermalFormulation}) = 1.0 get_expression_multiplier(::Type{OnStatusParameter}, ::Type{ActivePowerRangeExpressionUB}, d::PSY.ThermalGen, ::Type{<:AbstractThermalFormulation}) = PSY.get_active_power_limits(d, PSY.SU).max +get_expression_multiplier(::Type{OnStatusParameter}, ::Type{ActivePowerRangeExpressionOnlineUB}, d::PSY.ThermalGen, ::Type{<:AbstractThermalFormulation}) = PSY.get_active_power_limits(d, PSY.SU).max get_expression_multiplier(::Type{OnStatusParameter}, ::Type{ActivePowerRangeExpressionLB}, d::PSY.ThermalGen, ::Type{<:AbstractThermalFormulation}) = PSY.get_active_power_limits(d, PSY.SU).min get_expression_multiplier(::Type{OnStatusParameter}, ::Type{ActivePowerRangeExpressionUB}, d::PSY.ThermalGen, ::Type{<:AbstractCompactUnitCommitment}) = PSY.get_active_power_limits(d, PSY.SU).max - PSY.get_active_power_limits(d, PSY.SU).min get_expression_multiplier(::Type{OnStatusParameter}, ::Type{ActivePowerRangeExpressionLB}, d::PSY.ThermalGen, ::Type{<:AbstractCompactUnitCommitment}) = 0.0 @@ -1696,3 +1697,38 @@ function IOM._add_semicontinuous_bound_range_constraints_impl!( end return end + +""" +Offline-capability band row for standard-UC devices contributing to an `OfflineReserve`: +the shared UB expression (`p + online + offline`) bounded by the STATIC `pmax` +(`q_limit = pmax`; the paired semi-continuous row on the online-only expression keeps +`p` and the online awards commitment-gated). See [`OfflineReserveBandConstraint`](@ref). +""" +function add_constraints!( + container::OptimizationContainer, + T::Type{OfflineReserveBandConstraint}, + devices::IS.FlattenIteratorWrapper{V}, + model::DeviceModel{V, W}, + ::NetworkModel{X}, +) where { + V <: PSY.ThermalGen, + W <: AbstractStandardUnitCommitment, + X <: AbstractNetworkModel, +} + time_steps = get_time_steps(container) + names = [PSY.get_name(d) for d in devices] + expression = get_expression(container, ActivePowerRangeExpressionUB, V) + jump_model = get_jump_model(container) + constraint = add_constraints_container!(container, T, V, names, time_steps) + for d in devices + name = PSY.get_name(d) + q_limit = PSY.get_active_power_limits(d, PSY.SU).max + for t in time_steps + constraint[name, t] = JuMP.@constraint( + jump_model, + expression[name, t] <= q_limit + ) + end + end + return +end diff --git a/src/static_injector_models/thermalgeneration_constructor.jl b/src/static_injector_models/thermalgeneration_constructor.jl index 0f6b9c9a..c892840b 100644 --- a/src/static_injector_models/thermalgeneration_constructor.jl +++ b/src/static_injector_models/thermalgeneration_constructor.jl @@ -150,6 +150,17 @@ function construct_device!( device_model, network_model, ) + if _has_offline_reserve_service(device_model) + # Online-only band expression; the shared UB keeps p + online + offline. + add_to_expression!( + container, + ActivePowerRangeExpressionOnlineUB, + ActivePowerVariable, + devices, + device_model, + network_model, + ) + end add_to_expression!( container, FuelConsumptionExpression, @@ -189,14 +200,34 @@ function construct_device!( device_model, network_model, ) - add_constraints!( - container, - ActivePowerVariableLimitsConstraint, - ActivePowerRangeExpressionUB, - devices, - device_model, - network_model, - ) + if _has_offline_reserve_service(device_model) + # Row A: p + online reserves stay commitment-gated on the online-only expression. + add_constraints!( + container, + ActivePowerVariableLimitsConstraint, + ActivePowerRangeExpressionOnlineUB, + devices, + device_model, + network_model, + ) + # Row B: p + online + offline <= pmax preserves an OFF unit's offline capability. + add_constraints!( + container, + OfflineReserveBandConstraint, + devices, + device_model, + network_model, + ) + else + add_constraints!( + container, + ActivePowerVariableLimitsConstraint, + ActivePowerRangeExpressionUB, + devices, + device_model, + network_model, + ) + end add_constraints!( container, ReactivePowerVariableLimitsConstraint, @@ -300,6 +331,17 @@ function construct_device!( device_model, network_model, ) + if _has_offline_reserve_service(device_model) + # Online-only band expression; the shared UB keeps p + online + offline. + add_to_expression!( + container, + ActivePowerRangeExpressionOnlineUB, + ActivePowerVariable, + devices, + device_model, + network_model, + ) + end add_to_expression!( container, FuelConsumptionExpression, @@ -336,14 +378,34 @@ function construct_device!( device_model, network_model, ) - add_constraints!( - container, - ActivePowerVariableLimitsConstraint, - ActivePowerRangeExpressionUB, - devices, - device_model, - network_model, - ) + if _has_offline_reserve_service(device_model) + # Row A: p + online reserves stay commitment-gated on the online-only expression. + add_constraints!( + container, + ActivePowerVariableLimitsConstraint, + ActivePowerRangeExpressionOnlineUB, + devices, + device_model, + network_model, + ) + # Row B: p + online + offline <= pmax preserves an OFF unit's offline capability. + add_constraints!( + container, + OfflineReserveBandConstraint, + devices, + device_model, + network_model, + ) + else + add_constraints!( + container, + ActivePowerVariableLimitsConstraint, + ActivePowerRangeExpressionUB, + devices, + device_model, + network_model, + ) + end add_constraints!(container, CommitmentConstraint, devices, device_model, network_model) add_constraints!(container, RampConstraint, devices, device_model, network_model) @@ -435,6 +497,17 @@ function construct_device!( device_model, network_model, ) + if _has_offline_reserve_service(device_model) + # Online-only band expression; the shared UB keeps p + online + offline. + add_to_expression!( + container, + ActivePowerRangeExpressionOnlineUB, + ActivePowerVariable, + devices, + device_model, + network_model, + ) + end add_to_expression!( container, FuelConsumptionExpression, @@ -482,14 +555,34 @@ function construct_device!( device_model, network_model, ) - add_constraints!( - container, - ActivePowerVariableLimitsConstraint, - ActivePowerRangeExpressionUB, - devices, - device_model, - network_model, - ) + if _has_offline_reserve_service(device_model) + # Row A: p + online reserves stay commitment-gated on the online-only expression. + add_constraints!( + container, + ActivePowerVariableLimitsConstraint, + ActivePowerRangeExpressionOnlineUB, + devices, + device_model, + network_model, + ) + # Row B: p + online + offline <= pmax preserves an OFF unit's offline capability. + add_constraints!( + container, + OfflineReserveBandConstraint, + devices, + device_model, + network_model, + ) + else + add_constraints!( + container, + ActivePowerVariableLimitsConstraint, + ActivePowerRangeExpressionUB, + devices, + device_model, + network_model, + ) + end add_constraints!( container, @@ -579,6 +672,17 @@ function construct_device!( device_model, network_model, ) + if _has_offline_reserve_service(device_model) + # Online-only band expression; the shared UB keeps p + online + offline. + add_to_expression!( + container, + ActivePowerRangeExpressionOnlineUB, + ActivePowerVariable, + devices, + device_model, + network_model, + ) + end add_to_expression!( container, FuelConsumptionExpression, @@ -626,14 +730,34 @@ function construct_device!( device_model, network_model, ) - add_constraints!( - container, - ActivePowerVariableLimitsConstraint, - ActivePowerRangeExpressionUB, - devices, - device_model, - network_model, - ) + if _has_offline_reserve_service(device_model) + # Row A: p + online reserves stay commitment-gated on the online-only expression. + add_constraints!( + container, + ActivePowerVariableLimitsConstraint, + ActivePowerRangeExpressionOnlineUB, + devices, + device_model, + network_model, + ) + # Row B: p + online + offline <= pmax preserves an OFF unit's offline capability. + add_constraints!( + container, + OfflineReserveBandConstraint, + devices, + device_model, + network_model, + ) + else + add_constraints!( + container, + ActivePowerVariableLimitsConstraint, + ActivePowerRangeExpressionUB, + devices, + device_model, + network_model, + ) + end add_constraints!(container, CommitmentConstraint, devices, device_model, network_model) if haskey(get_time_series_names(device_model), ActivePowerTimeSeriesParameter) @@ -1132,6 +1256,11 @@ function construct_device!( network_model::NetworkModel{<:AbstractNetworkModel}, ) devices = get_available_components(device_model, sys) + _has_offline_reserve_service(device_model) && error( + "OfflineReserve services on ThermalMultiStartUnitCommitment (compact UC) are not " * + "supported: the offline-capability band anchors to total pmax while compact " * + "power is a delta above pmin. Use a standard UC formulation.", + ) add_variables!( container, diff --git a/test/test_device_reserve_offers.jl b/test/test_device_reserve_offers.jl index 2e9bc5fa..a924c113 100644 --- a/test/test_device_reserve_offers.jl +++ b/test/test_device_reserve_offers.jl @@ -541,6 +541,132 @@ end end end +@testset "OfflineReserve as ORDC: OFF unit provides offline capability (UC)" begin + # Single-award-variable offline design: row A keeps p + online commitment-gated on the + # online-only expression; row B bounds the shared band p + online + offline by the + # static q_limit = pmax, so an OFF unit's offline capability survives. + sys = deepcopy(PSB.build_system(PSITestSystems, "c_sys5_uc")) + thermals = collect(get_components(ThermalStandard, sys)) + # Make one unit uneconomical for energy so the UC leaves it OFF; its cheap offline + # offer must clear regardless. + offunit = first(sort(thermals; by = PSY.get_name)) + # Service bids require an OfferCurveCost: convert every thermal to a MarketBidCost that + # keeps its energy slope; the off-unit gets a prohibitive slope + startup so the UC + # never commits it for energy. + for g in thermals + pmax_g = PSY.get_max_active_power(g, PSY.NU) + slope = if g === offunit + 1.0e4 + else + PSY.get_proportional_term( + PSY.get_value_curve(PSY.get_variable(get_operation_cost(g))), + ) + end + PSY.set_operation_cost!( + g, + MarketBidCost(; + no_load_cost = LinearCurve(0.0), + start_up = ( + hot = g === offunit ? 1.0e5 : 0.0, + warm = g === offunit ? 1.0e5 : 0.0, + cold = g === offunit ? 1.0e5 : 0.0, + ), + shut_down = LinearCurve(0.0), + incremental_offer_curves = make_market_bid_curve( + [0.0, pmax_g], [slope], 0.0; power_units = IS.NaturalUnit(), + ), + ), + ) + end + nspin = OfflineReserve(; + name = "NSPIN", + available = true, + time_frame = 30.0, + variable = _mkt_curve([0.0, 100.0, 200.0], [65.0, 11.0]), + ) + spin = OnlineReserve{ReserveUp}(; + name = "SPIN", available = true, time_frame = 10.0, requirement = 0.0, + variable = _mkt_curve([0.0, 50.0], [40.0]), + ) + add_service!(sys, nspin, PSY.Device[thermals...]) + add_service!(sys, spin, PSY.Device[thermals...]) + for (i, g) in enumerate(thermals) + price = g === offunit ? 0.01 : 6.0 + i + PSY.set_service_bid!( + sys, + g, + nspin, + _mkt_offer_ts(nspin, 80.0, price), + IS.NaturalUnit(), + ) + PSY.set_service_bid!( + sys, + g, + spin, + _mkt_offer_ts(spin, 30.0, price), + IS.NaturalUnit(), + ) + end + + template = get_thermal_standard_uc_template() + set_service_model!(template, ServiceModel(OfflineReserve, StepwiseCostReserve)) + set_service_model!( + template, + ServiceModel(OnlineReserve{ReserveUp}, StepwiseCostReserve), + ) + model = DecisionModel( + template, sys; + optimizer = HiGHS_optimizer, store_variable_names = true, + ) + @test build!(model; output_dir = mktempdir(; cleanup = true)) == + IOM.ModelBuildStatus.BUILT + @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED + + res = IOM.OptimizationProblemOutputs(model) + on = read_variable(res, OnVariable, ThermalStandard; table_format = TableFormat.WIDE) + nspin_awards = read_variable( + res, "ActivePowerReserveVariable__OfflineReserve"; + table_format = TableFormat.WIDE, + ) + spin_awards = read_variable( + res, "ActivePowerReserveVariable__OnlineReserve__ReserveUp"; + table_format = TableFormat.WIDE, + ) + off_name = PSY.get_name(offunit) + pmax = PSY.get_active_power_limits(offunit, PSY.NU).max + total_off_award = 0.0 + for t in 1:24 + @test on[t, off_name] < 0.5 # stays uncommitted + @test spin_awards[t, "SPIN__$(off_name)"] <= 1e-3 # row A: online dead when OFF + @test nspin_awards[t, "NSPIN__$(off_name)"] <= pmax + 1e-3 # row B capability + total_off_award += nspin_awards[t, "NSPIN__$(off_name)"] + end + # The cheapest offline offer in the stack clears from the OFF unit. + @test total_off_award > 1.0 + + # Zero-footprint: without an OfflineReserve service model, none of the offline + # machinery exists - no online-only expression, no band constraint. + template2 = get_thermal_standard_uc_template() + set_service_model!( + template2, + ServiceModel(OnlineReserve{ReserveUp}, StepwiseCostReserve), + ) + model2 = DecisionModel( + template2, sys; + optimizer = HiGHS_optimizer, store_variable_names = true, + ) + @test build!(model2; output_dir = mktempdir(; cleanup = true)) == + IOM.ModelBuildStatus.BUILT + container2 = IOM.get_optimization_container(model2) + @test !IOM.has_container_key( + container2, POM.ActivePowerRangeExpressionOnlineUB, ThermalStandard, + ) + @test all( + k -> IOM.get_entry_type(k) != POM.OfflineReserveBandConstraint, + keys(IOM.get_constraints(container2)), + ) +end + ################################################################################# # Load reserve provision (PowerLoadDispatch) ################################################################################# diff --git a/test/test_services_constructor.jl b/test/test_services_constructor.jl index d1ce2f89..90d62803 100644 --- a/test/test_services_constructor.jl +++ b/test/test_services_constructor.jl @@ -1780,7 +1780,8 @@ end sys, group = build_group_reserve_system(; group_curve = false) init_times = [DateTime("2024-01-01T00:00:00"), DateTime("2024-01-02T00:00:00")] horizon = 24 - curves = [IS.PiecewiseStepData([0.0, isodd(h) ? 40.0 : 80.0], [9.0e4]) for h in 1:horizon] + curves = + [IS.PiecewiseStepData([0.0, isodd(h) ? 40.0 : 80.0], [9.0e4]) for h in 1:horizon] data = Dict(it => copy(curves) for it in init_times) PSY.add_time_series!( sys,