Skip to content
7 changes: 7 additions & 0 deletions docs/Project.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down
46 changes: 25 additions & 21 deletions docs/src/reference/formulation_library.md
Original file line number Diff line number Diff line change
Expand Up @@ -514,31 +514,35 @@ no-op.

## [Service Formulations](@id service_formulations)

| Formulation | Service type | Argument stage | Model stage |
|:------------------------------------------------------ |:--------------------------- |:---------------------------------------------------------------------------------------------- |:---------------------------------------------------------- |
| `RangeReserve` | `PSY.Reserve` | `RequirementTimeSeriesParameter` (omitted for `ConstantReserve`), `ActivePowerReserveVariable` | `RequirementConstraint`, `ParticipationFractionConstraint` |
| `RampReserve` | `PSY.Reserve` | as above | as above **+ `RampConstraint`** |
| `NonSpinningReserve` | `PSY.OfflineReserve` | as above, but **no** device-range expression wiring | as above **+ `ReservePowerConstraint`** |
| `StepwiseCostReserve` (operating reserve demand curve) | `PSY.Reserve` | `ServiceRequirementVariable` + demand-curve slope/breakpoint parameters | `RequirementConstraint` only — no participation constraint |
| `GroupRangeReserve` | `PSY.GroupReserve` | no variables | `RequirementConstraint` across contributing services |
| `ConstantMaxInterfaceFlow` | `PSY.TransmissionInterface` | optional slacks, `InterfaceTotalFlow` expression | `InterfaceFlowLimit` (`"ub"`/`"lb"`) |
| `VariableMaxInterfaceFlow` | `PSY.TransmissionInterface` | as above **+ min/max flow-limit parameters** | as above, with parameterized limits |

`GroupRangeReserve` is deliberately constructed **last** in both stages, because it aggregates the other
services' variables.

!!! warning "GroupRangeReserve does not support slacks"
| Formulation | Service type | Argument stage | Model stage |
|:------------------------------------------------------ |:--------------------------- |:-------------------------------------------------------------------------------------------------------- |:---------------------------------------------------------- |
| `RangeReserve` | `PSY.Reserve` | `RequirementTimeSeriesParameter` (omitted for static-requirement reserves), `ActivePowerReserveVariable` | `RequirementConstraint`, `ParticipationFractionConstraint` |
| `RampReserve` | `PSY.Reserve` | as above | as above **+ `RampConstraint`** |
| `NonSpinningReserve` | `PSY.OfflineReserve` | as above, but **no** device-range expression wiring | as above **+ `ReservePowerConstraint`** |
| `StepwiseCostReserve` (operating reserve demand curve) | `PSY.Reserve` | `ServiceRequirementVariable` + demand-curve slope/breakpoint parameters | `RequirementConstraint` only — no participation constraint |
| `GroupRangeReserve` | `PSY.GroupReserve` | no variables | `RequirementConstraint` across contributing services |
| `GroupStepwiseCostReserve` (elastic group) | `PSY.GroupReserve` | `ServiceRequirementVariable` + group demand-curve slope/breakpoint parameters | `RequirementConstraint`: member awards ≥ the group demand |
| `ConstantMaxInterfaceFlow` | `PSY.TransmissionInterface` | optional slacks, `InterfaceTotalFlow` expression | `InterfaceFlowLimit` (`"ub"`/`"lb"`) |
| `VariableMaxInterfaceFlow` | `PSY.TransmissionInterface` | as above **+ min/max flow-limit parameters** | as above, with parameterized limits |

The group formulations are deliberately constructed **last** in both stages, because they aggregate
the other services' award variables. A `PSY.GroupReserve` accepts only the group formulations (and
vice versa); a mis-paired `ServiceModel` fails at declaration. A service whose demand driver is
degenerate (zero requirement under the requirement formulations, no demand curve under the stepwise
ones) is skipped as demand and built as supply only, so it can serve a group.

!!! warning "Group formulations do not support slacks"

`GroupRangeReserve`'s requirement-constraint builder reads a `slack_vars` binding that is never
created, so a `ServiceModel` with `use_slacks = true` raises `UndefVarError`. A group reserve
also cannot currently be built end to end: a `PSY.GroupReserve` aggregates services rather
than devices, so its contributing-device list is empty and construction errors out before the
requirement constraint is reached.
`use_slacks = true` on a group `ServiceModel` is ignored: reserve slacks attach to the
requirement rows of the device-backed formulations, and no slack is added to a group's
clearing constraint.

Reserve contributions reach a device through `get_expression_type_for_reserve`: for thermal,
renewable and hydro an up-reserve enters `ActivePowerRangeExpressionUB` (+1) and a down-reserve
`ActivePowerRangeExpressionLB` (−1); storage and hybrid instead route everything into
`TotalReserveOffering`. Any other device type hits an error — loads, sources, condensers and shunts
`ActivePowerRangeExpressionLB` (−1); a controllable load is the inverse (an up-reserve is committed
shed, entering `ActivePowerRangeExpressionLB` with −1, and a down-reserve is committed extra
consumption, entering `ActivePowerRangeExpressionUB` with +1); storage and hybrid instead route
everything into `TotalReserveOffering`. Any other device type hits an error — sources, condensers and shunts
cannot contribute to a reserve.

Service `meta` strings are **per-instance**, not a fixed vocabulary: every reserve container is
Expand Down
1 change: 1 addition & 0 deletions src/PowerOperationsModels.jl
Original file line number Diff line number Diff line change
Expand Up @@ -923,6 +923,7 @@ export AbstractServiceFormulation
export AbstractReservesFormulation
export PIDSmoothACE
export GroupRangeReserve
export GroupStepwiseCostReserve
export RangeReserve
export StepwiseCostReserve
export RampReserve
Expand Down
72 changes: 69 additions & 3 deletions src/common_models/add_to_expression.jl
Original file line number Diff line number Diff line change
Expand Up @@ -1987,9 +1987,7 @@ function add_to_expression!(
T <: ActivePowerRangeExpressionUB,
U <: VariableType,
V <: PSY.Component,
# OfflineReserve (non-spin) is upward-only and has no direction param, so it routes to the
# same upper-bound expression as a ReserveUp reserve.
X <: Union{PSY.Reserve{PSY.ReserveUp}, PSY.OfflineReserve},
X <: UP_RESERVE,
W <: AbstractReservesFormulation,
}
service_name = PSY.get_name(service)
Expand Down Expand Up @@ -2413,6 +2411,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},
Expand Down
7 changes: 7 additions & 0 deletions src/core/formulations.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
"""
Expand Down
7 changes: 5 additions & 2 deletions src/core/problem_template.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
11 changes: 9 additions & 2 deletions src/core/reserve_traits.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading
Loading