Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
2127887
Add GroupStepwiseCostReserve: elastic group ORDC over member-service …
rodrigomha Aug 13, 2026
e53293c
Simplify group support: GroupReserve is an AbstractReserve
rodrigomha Aug 13, 2026
1878196
Add load reserve provision under PowerLoadDispatch
rodrigomha Aug 13, 2026
e6af246
Add energy + reserve co-clearing integration test; service docs close…
rodrigomha Aug 13, 2026
fd038f8
update comment.
rodrigomha Aug 13, 2026
bd5b3b2
move testing
rodrigomha Aug 13, 2026
da42d94
Support OfflineReserve as ORDC supply from storage and loads
rodrigomha Aug 13, 2026
3382f8f
Pin PSY psy6's unregistered OpenAPI deps in every environment
rodrigomha Aug 16, 2026
b1f7781
Merge branch 'main' into rh/new_load_and_group_services
jd-lara Aug 18, 2026
fb9bbf3
Add GroupStepwiseCostReserve: elastic group ORDC over member-service …
rodrigomha Aug 13, 2026
7b10d91
Simplify group support: GroupReserve is an AbstractReserve
rodrigomha Aug 13, 2026
3c07945
Add load reserve provision under PowerLoadDispatch
rodrigomha Aug 13, 2026
1311a0d
Add energy + reserve co-clearing integration test; service docs close…
rodrigomha Aug 13, 2026
c4e09af
update comment.
rodrigomha Aug 13, 2026
ea169ad
move testing
rodrigomha Aug 13, 2026
d0762b4
Support OfflineReserve as ORDC supply from storage and loads
rodrigomha Aug 13, 2026
a398872
Pin PSY psy6's unregistered OpenAPI deps in every environment
rodrigomha Aug 16, 2026
4301ba5
Merge branch 'rh/new_load_and_group_services' of github.com:NREL-Sien…
jd-lara Aug 18, 2026
1a93e6a
Fix charge-side reserve-assignment direction in StorageDispatchWithRe…
rodrigomha Aug 18, 2026
3403a51
Add reserve_coverage attribute: energy/AS decoupling for StorageDispa…
rodrigomha Aug 18, 2026
2521ead
Pin the test env's PowerOperationsModels to the working tree
rodrigomha Aug 18, 2026
af1f71f
Catch costless market-bid loads selling reserves
jd-lara Aug 19, 2026
5f7b6c8
Drop the stale disable note on the hydro pump energy test
jd-lara Aug 19, 2026
3fa2221
Add a time-series group demand curve regression test for GroupStepwis…
rodrigomha Aug 19, 2026
7901365
split decision for OfflineReserves in Thermal
rodrigomha Aug 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
54 changes: 31 additions & 23 deletions docs/src/reference/formulation_library.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|:-------- |:--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
Expand Down Expand Up @@ -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
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
107 changes: 101 additions & 6 deletions src/common_models/add_to_expression.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -1999,13 +2001,30 @@ 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!(
expression[name, t],
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
Expand Down Expand Up @@ -2413,14 +2432,86 @@ 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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't follow the 2nd sentence of this comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is mostly that the bound for ReserveUp for Generators is done using the Upper Bound since ReserveUp is provided by increasing generation, while for loads ReserveUp is provided by curtailing load, and hence bounded via LowerBound.

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},
::U,
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,
Expand Down Expand Up @@ -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,
Expand Down
16 changes: 16 additions & 0 deletions src/core/constraints.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 10 additions & 0 deletions src/core/expressions.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 8 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 Expand Up @@ -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.

Expand Down
Loading
Loading