From 4de20dce34c4231182f2ace253cbeaf86565a691 Mon Sep 17 00:00:00 2001 From: ktehranchi Date: Wed, 1 Oct 2025 09:44:33 -0700 Subject: [PATCH] update reserves model to psy5 --- src/r2x/enums.py | 1 + src/r2x/models/__init__.py | 2 +- src/r2x/models/services.py | 168 ++++++++++++++++++++++--------------- 3 files changed, 103 insertions(+), 68 deletions(-) diff --git a/src/r2x/enums.py b/src/r2x/enums.py index 58a86ee1..2d59a304 100644 --- a/src/r2x/enums.py +++ b/src/r2x/enums.py @@ -19,6 +19,7 @@ class ReserveDirection(StrEnum): UP = "UP" DOWN = "DOWN" + SYMMETRIC = "SYMMETRIC" class ACBusTypes(StrEnum): diff --git a/src/r2x/models/__init__.py b/src/r2x/models/__init__.py index 90df4ebb..b6c58979 100644 --- a/src/r2x/models/__init__.py +++ b/src/r2x/models/__init__.py @@ -49,5 +49,5 @@ StartTimeLimits, UpDown, ) -from .services import Reserve, TransmissionInterface, VariableReserve +from .services import ConstantReserve, Reserve, TransmissionInterface, VariableReserve from .topology import ACBus, Arc, Area, Bus, DCBus, LoadZone diff --git a/src/r2x/models/services.py b/src/r2x/models/services.py index ab9a51a6..6b6ef1b3 100644 --- a/src/r2x/models/services.py +++ b/src/r2x/models/services.py @@ -2,103 +2,137 @@ from typing import Annotated -from pydantic import Field, NonNegativeFloat, PositiveFloat +from pydantic import Field -from r2x.enums import ReserveDirection, ReserveType +from r2x.enums import ReserveDirection from r2x.models.core import Service from r2x.models.named_tuples import MinMax -from r2x.models.topology import LoadZone -from r2x.units import Percentage class Reserve(Service): - """Class representing a reserve contribution.""" + """Base class representing a reserve contribution. + + This is a base class for reserve products. For specific reserve types, + use ConstantReserve or VariableReserve classes. + """ time_frame: Annotated[ - PositiveFloat, - Field(description="Timeframe in which the reserve is required in seconds"), - ] = 1e30 - region: ( - Annotated[ - LoadZone, - Field(description="LoadZone where reserve requirement is required."), - ] - | None - ) = None - vors: Annotated[ float, - Field(description="Value of reserve shortage in $/MW. Any positive value as as soft constraint."), - ] = -1 - duration: ( - Annotated[ - PositiveFloat, - Field(description="Time over which the required response must be maintained in seconds."), - ] - | None - ) = None - reserve_type: ReserveType - load_risk: ( - Annotated[ - Percentage, - Field( - description="Proportion of Load that contributes to the requirement.", - ), - ] - | None - ) = None - # ramp_rate: float | None = None # NOTE: Maybe we do not need this. - max_requirement: float = 0 # Should we specify which variable is the time series for? - direction: ReserveDirection - - @classmethod - def example(cls) -> "Reserve": - return Reserve( - name="ExampleReserve", - region=LoadZone.example(), - direction=ReserveDirection.UP, - reserve_type=ReserveType.REGULATION, - ) - - -class VariableReserve(Reserve): - time_frame: Annotated[ - NonNegativeFloat, - Field(description="Timeframe in which the reserve is required in seconds"), - ] = 0.0 - requirement: Annotated[ - NonNegativeFloat | None, Field( - description="the value of required reserves in p.u (SYSTEM_BASE), validation range: (0, nothing)" + ge=0.0, + description=( + "Saturation time_frame in minutes to provide reserve contribution, " + "validation range: (0, nothing)" + ) ), - ] + ] = 0.0 sustained_time: Annotated[ - NonNegativeFloat, - Field(description="the time in seconds reserve contribution must sustained at a specified level"), + float, + Field( + ge=0.0, + description=( + "The time in seconds reserve contribution must be sustained at a specified level, " + "validation range: (0, nothing)" + ) + ), ] = 3600.0 max_output_fraction: Annotated[ - NonNegativeFloat, + float, Field( ge=0.0, le=1.0, - description="the time in seconds reserve contribution must sustained at a specified level", + description=( + "The maximum fraction of each device's output that can be assigned to the service, " + "validation range: (0, 1)" + ) ), ] = 1.0 max_participation_factor: Annotated[ - NonNegativeFloat, + float, Field( ge=0.0, le=1.0, - description="the maximum portion [0, 1.0] of the reserve that can be contributed per device", + description=( + "The maximum portion [0, 1.0] of the reserve that can be contributed per device, " + "validation range: (0, 1)" + ) ), ] = 1.0 deployed_fraction: Annotated[ - NonNegativeFloat, + float, Field( ge=0.0, le=1.0, - description="Fraction of service procurement that is assumed to be actually deployed.", + description=( + "Fraction of service procurement that is assumed to be actually deployed. " + "Most commonly, this is assumed to be either 0.0 or 1.0, validation range: (0, 1)" + ) ), - ] = 3600.0 + ] = 0.0 + direction: ReserveDirection + + @classmethod + def example(cls) -> "Reserve": + return Reserve( + name="ExampleReserve", + direction=ReserveDirection.UP, + ) + + +class ConstantReserve(Reserve): + """A reserve product with a constant procurement requirement. + + Such as 3% of the system base power at all times. This reserve product includes online generators that can + respond right away after an unexpected contingency, such as a transmission line or generator outage. When + defining the reserve, the ReserveDirection must be specified to define this as a ReserveUp, ReserveDown, + or ReserveSymmetric. + """ + + requirement: Annotated[ + float, + Field( + ge=0.0, + description=( + "The value of required reserves in p.u. (SYSTEM_BASE), " + "validation range: (0, nothing)" + ) + ), + ] + + @classmethod + def example(cls) -> "ConstantReserve": + return ConstantReserve( + name="ExampleConstantReserve", + direction=ReserveDirection.UP, + requirement=0.03, # 3% of system base + ) + + +class VariableReserve(Reserve): + """A reserve product with a time-varying procurement requirement. + + Such as a higher requirement during hours with an expected high load or high ramp. This reserve product + includes online generators that can respond right away after an unexpected contingency, such as a + transmission line or generator outage. When defining the reserve, the ReserveDirection must be specified + to define this as a ReserveUp, ReserveDown, or ReserveSymmetric. To model the time varying requirement, + a "requirement" time series should be added to this reserve. + """ + + requirement: Annotated[ + float, + Field( + ge=0.0, + description="The required quantity of the product should be scaled by a TimeSeriesData" + ), + ] + + @classmethod + def example(cls) -> "VariableReserve": + return VariableReserve( + name="ExampleVariableReserve", + direction=ReserveDirection.UP, + requirement=0.05, # 5% of system base + ) class TransmissionInterface(Service):