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" diff --git a/docs/src/reference/formulation_library.md b/docs/src/reference/formulation_library.md index 701fdb0c..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 | |:-------- |:--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -514,31 +518,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/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_to_expression.jl b/src/common_models/add_to_expression.jl index df03153b..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, @@ -1987,9 +1991,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) @@ -1999,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!( @@ -2006,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 @@ -2413,6 +2432,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 <: UP_RESERVE, + 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}, @@ -2420,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, @@ -2453,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/formulations.jl b/src/core/formulations.jl index 94a3615c..ef15ff46 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 """ @@ -587,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/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 b770da79..e4e90163 100644 --- a/src/core/reserve_traits.jl +++ b/src/core/reserve_traits.jl @@ -31,11 +31,18 @@ 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 +""" +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 @@ -60,7 +67,7 @@ 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`)." +"Whether a reserve's or group's ORDC curve is time-varying. Dispatches on the value-curve type (no `isa`)." _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 @@ -75,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/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 0870dd4a..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, ) @@ -427,7 +428,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 +444,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 +462,7 @@ get_variable_multiplier( }, d::PSY.Storage, ::Type{StorageDispatchWithReserves}, - ::PSY.Reserve{PSY.ReserveUp}, + ::UP_RESERVE, ) = 0.0 get_variable_multiplier( @@ -479,7 +480,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 +497,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 +513,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 +531,7 @@ get_variable_multiplier( }, d::PSY.Storage, ::Type{StorageDispatchWithReserves}, - ::PSY.Reserve{PSY.ReserveUp}, + ::UP_RESERVE, ) = 0.0 get_variable_multiplier( @@ -548,7 +549,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 +562,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 +720,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) @@ -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) @@ -888,21 +890,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 +928,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 +938,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 @@ -976,7 +977,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 +1022,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 +1119,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 +1166,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 +1181,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/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..569e5be3 100644 --- a/src/services_models/reserves.jl +++ b/src/services_models/reserves.jl @@ -12,7 +12,8 @@ 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. +# Only created on the StepwiseCostReserve / GroupStepwiseCostReserve construct paths, so the +# 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 @@ -46,6 +47,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)] @@ -69,8 +82,8 @@ function _log_skipped_reserve_demand( ::ServiceModel{<:PSY.AbstractReserve, 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,7 +103,9 @@ 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 +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 @@ -605,6 +620,23 @@ 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}, @@ -613,7 +645,7 @@ function add_reserves_variable_cost!( ) where { T <: PSY.AbstractReserve, U <: VariableType, - V <: StepwiseCostReserve, + V <: Union{StepwiseCostReserve, GroupStepwiseCostReserve}, } _add_reserves_variable_cost_to_objective!(container, U, service, V) return @@ -624,7 +656,7 @@ function _add_reserves_variable_cost_to_objective!( ::Type{T}, component::PSY.AbstractReserve, ::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 +671,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 diff --git a/src/services_models/services_constructor.jl b/src/services_models/services_constructor.jl index df166802..504e202e 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,21 @@ function _services_with_contributors( ] end +# 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 + !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 +51,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 +68,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 +93,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 +110,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 +463,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/src/static_injector_models/electric_loads.jl b/src/static_injector_models/electric_loads.jl index dac32a11..1887e76b 100644 --- a/src/static_injector_models/electric_loads.jl +++ b/src/static_injector_models/electric_loads.jl @@ -37,6 +37,13 @@ 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). 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 + ###################################################### # To avoid ambiguity with default_interface_methods.jl: @@ -259,10 +266,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 +288,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}, @@ -519,13 +549,39 @@ 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, 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 _is_costless_offer(cost) + throw( + IS.ConflictingInputsError( + "PowerLoadDispatch load '$(PSY.get_name(d))' provides a reserve \ + 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 + end + end add_variable_cost!(container, ActivePowerVariable, devices, U) return end 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/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/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/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"} diff --git a/test/test_device_hydro_constructors.jl b/test/test_device_hydro_constructors.jl index e1b277bf..ffb84f65 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)) @@ -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) diff --git a/test/test_device_reserve_offers.jl b/test/test_device_reserve_offers.jl index ccb01ce7..a924c113 100644 --- a/test/test_device_reserve_offers.jl +++ b/test/test_device_reserve_offers.jl @@ -319,3 +319,613 @@ end push!(PSY.get_ancillary_service_offers(mbc), reserve) @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 + +@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 + +@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) +################################################################################# + +# 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 "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) + 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 diff --git a/test/test_services_constructor.jl b/test/test_services_constructor.jl index f79e782f..90d62803 100644 --- a/test/test_services_constructor.jl +++ b/test/test_services_constructor.jl @@ -1610,3 +1610,273 @@ 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: 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) + 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_storage_device_models.jl b/test/test_storage_device_models.jl index 7da27927..b6be23c9 100644 --- a/test/test_storage_device_models.jl +++ b/test/test_storage_device_models.jl @@ -319,6 +319,134 @@ 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 "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(