diff --git a/.gitignore b/.gitignore index 0639b86..16b89e9 100644 --- a/.gitignore +++ b/.gitignore @@ -19,9 +19,10 @@ __pycache__/ .env venv -# scratch files and folders +# scratch files, folders and dev sqlite3 databases scratch* cook* +*.sqlite3 # Code generation airtable diff --git a/CodeGenerationTools/GridworksCore/Enum/EnumInit/DeriveEnumInit.xslt b/CodeGenerationTools/GridworksCore/Enum/EnumInit/DeriveEnumInit.xslt index 984ec19..108c111 100644 --- a/CodeGenerationTools/GridworksCore/Enum/EnumInit/DeriveEnumInit.xslt +++ b/CodeGenerationTools/GridworksCore/Enum/EnumInit/DeriveEnumInit.xslt @@ -79,6 +79,11 @@ from gwatn.enums. +# hacks +from gwatn.enums.hack_price_method import PriceMethod +from gwatn.enums.hack_recognized_p_node_alias import RecognizedPNodeAlias +from gwatn.enums.hack_weather_method import WeatherMethod +from gwatn.enums.hack_weather_source import WeatherSource __all__ = [ @@ -95,6 +100,10 @@ __all__ = [ + "PriceMethod", + "RecognizedPNodeAlias", + "WeatherMethod", + "WeatherSource", ] diff --git a/docs/apis/json/atn-outside-temp-regr-coeffs.json b/docs/apis/json/atn-outside-temp-regr-coeffs.json new file mode 100644 index 0000000..011c4fd --- /dev/null +++ b/docs/apis/json/atn-outside-temp-regr-coeffs.json @@ -0,0 +1,31 @@ +{ + "gwapi": "001", + "type_name": "atn.outside.temp.regr.coeffs", + "version": "000", + "owner": "gridworks@gridworks-consulting.com", + "description": ". Coefficients for a linear regression of avg power leaving a building as a function of weather: PowerOut = Alpha + Beta * OutsideTempF These are an example of Slowly Varying State variables maintained for a thermal storage heating Terminal Asset by its AtomicTNode and Scada.", + "url": "https://gridworks-atn.readthedocs.io/en/latest/data-categories.html#slowly-varying-state-variables", + "properties": { + "Alpha": { + "type": "integer", + "title": "Alpha (units: W)", + "required": true + }, + "Beta": { + "type": "number", + "title": "Beta (units: W / deg F) ", + "required": true + }, + "TypeName": { + "type": "string", + "value": "atn.outside.temp.regr.coeffs.000", + "title": "The type name" + }, + "Version": { + "type": "string", + "title": "The type version", + "default": "000", + "required": true + } + } +} diff --git a/docs/apis/json/atn-params-simpleresistivehydronic.json b/docs/apis/json/atn-params-simpleresistivehydronic.json index 416e516..29e751b 100644 --- a/docs/apis/json/atn-params-simpleresistivehydronic.json +++ b/docs/apis/json/atn-params-simpleresistivehydronic.json @@ -26,7 +26,7 @@ "const": "2127aba6", "title": "VersantA1StorageHeatTariff", "url": "https://github.com/thegridelectric/gridworks-ps/blob/dev/input_data/electricity_prices/isone/distp__w.isone.stetson__2022__gw.me.versant.a1.res.ets.csv", - "description": "Versant is a utility serving customers in Maine, and in particular serves much of the area behind the Keene Rd Constraint in the [GridWorks Millinocket Demo](https://gridworks.readthedocs.io/en/latest/millinocket-demo.html#background). Alternately known as the 'Home Eco Rate With Bonus Meter, Time-of-Use.' Look for rate A1 in Versant [rate schedules](https://www.versantpower.com/residential/rates/rates-schedules/); details are also available [here](https://drive.google.com/drive/u/0/folders/1mhIeNj2JWVyIJrQnSHmBDOkBpNnRRVKB). More: Service under this rate will be available to residential customers with thermal energy storage devices, electric battery storage devices, and/or vehicle chargers who agree to install a second metered point of delivery. The customer will be subject to inspections to ensure that the thermal storage device, electric battery storage device, and electric vehicle charger(s) are sized appropriately for residential use. If the thermal storage device, electric battery storage device, and electric vehicle charger(s) do not pass Company inspection, then the service will be denied. Service will be single-phase, alternating current, 60 hertz, at one standard secondary distribution voltage. Customers taking service under this rate schedule are responsible for paying both Distribution Service and Stranded Cost." + "description": "Versant is a utility serving customers in Maine, and in particular serves much of the area behind the Keene Rd Constraint in the [GridWorks Millinocket Demo](https://gridworks.readthedocs.io/en/latest/millinocket-demo.html#background). Alternately known as the 'Home Eco Rate With Bonus Meter, Time-of-Use.' Look for rate A1 in Versant [rate schedules](https://www.versantpower.com/residential/rates/rates-schedules/); details are also available [here](https://drive.google.com/drive/u/0/folders/1mhIeNj2JWVyIJrQnSHmBDOkBpNnRRVKB). More: Service under this rate will be available to residential customers with thermal energy storage devices, electric battery storage devices, and/or vehicle chargers who agree to install a second metered point of delivery. The customer will be subject to inspections to ensure that the thermal storage device, electric battery storage device, and electric vehicle charger(s) are sized appropriately for residential use. If the thermal storage device, electric battery storage device, and electric vehicle charger(s) do not pass Company inspection, then the service will be denied. Service will be single-phase, alternating current, 60 hertz, at one standard secondary distribution voltage. Customers taking service under this rate schedule are responsible for paying both Distribution Service and Stranded Cost. See attached csv for instantiation of this rate as an 8760." }, { "const": "ea5c675a", @@ -36,7 +36,7 @@ { "const": "54aec3a7", "title": "VersantA20HeatTariff", - "description": "Versant is a utility serving customers in Maine, and in particular serves much of the area behind the Keene Rd Constraint in the [GridWorks Millinocket Demo](https://gridworks.readthedocs.io/en/latest/millinocket-demo.html#background). This is an alternative tariff available for electric heat." + "description": "Versant is a utility serving customers in Maine, and in particular serves much of the area behind the Keene Rd Constraint in the [GridWorks Millinocket Demo](https://gridworks.readthedocs.io/en/latest/millinocket-demo.html#background). This is an alternative tariff available for electric heat." } ] }, @@ -140,7 +140,7 @@ "title": "", "required": true }, - "DistributionTariffDollarsPerMwh": { + "FlatDistributionTariffDollarsPerMwh": { "type": "integer", "title": "", "required": true @@ -165,12 +165,12 @@ "title": "", "required": true }, - "FixedPumpGpm": { + "CirculatorPumpGpm": { "type": "number", "title": "", "required": true }, - "ReturnWaterFixedDeltaT": { + "ReturnWaterDeltaTempF": { "type": "integer", "title": "", "required": true @@ -195,6 +195,16 @@ "title": "", "required": true }, + "StorePassiveLossRatio": { + "type": "number", + "title": "", + "required": true + }, + "AmbientTempStoreF": { + "type": "integer", + "title": "", + "required": true + }, "TypeName": { "type": "string", "value": "atn.params.simpleresistivehydronic.000", diff --git a/docs/apis/json/flo-params-simpleresistivehydronic.json b/docs/apis/json/flo-params-simpleresistivehydronic.json index effd61d..1fad5a9 100644 --- a/docs/apis/json/flo-params-simpleresistivehydronic.json +++ b/docs/apis/json/flo-params-simpleresistivehydronic.json @@ -17,6 +17,34 @@ } }, "enums": { + "DistributionTariff000": { + "type": "string", + "name": "distribution.tariff.000", + "description": "Name of distribution tariff of local network company/utility", + "oneOf": [ + { + "const": "00000000", + "title": "Unknown", + "description": "" + }, + { + "const": "2127aba6", + "title": "VersantA1StorageHeatTariff", + "url": "https://github.com/thegridelectric/gridworks-ps/blob/dev/input_data/electricity_prices/isone/distp__w.isone.stetson__2022__gw.me.versant.a1.res.ets.csv", + "description": "Versant is a utility serving customers in Maine, and in particular serves much of the area behind the Keene Rd Constraint in the [GridWorks Millinocket Demo](https://gridworks.readthedocs.io/en/latest/millinocket-demo.html#background). Alternately known as the 'Home Eco Rate With Bonus Meter, Time-of-Use.' Look for rate A1 in Versant [rate schedules](https://www.versantpower.com/residential/rates/rates-schedules/); details are also available [here](https://drive.google.com/drive/u/0/folders/1mhIeNj2JWVyIJrQnSHmBDOkBpNnRRVKB). More: Service under this rate will be available to residential customers with thermal energy storage devices, electric battery storage devices, and/or vehicle chargers who agree to install a second metered point of delivery. The customer will be subject to inspections to ensure that the thermal storage device, electric battery storage device, and electric vehicle charger(s) are sized appropriately for residential use. If the thermal storage device, electric battery storage device, and electric vehicle charger(s) do not pass Company inspection, then the service will be denied. Service will be single-phase, alternating current, 60 hertz, at one standard secondary distribution voltage. Customers taking service under this rate schedule are responsible for paying both Distribution Service and Stranded Cost. See attached csv for instantiation of this rate as an 8760." + }, + { + "const": "ea5c675a", + "title": "VersantATariff", + "description": "Versant is a utility serving customers in Maine, and in particular serves much of the area behind the Keene Rd Constraint in the [GridWorks Millinocket Demo](https://gridworks.readthedocs.io/en/latest/millinocket-demo.html#background). The A Tariff is their standard residential tariff. Look for rate A in Versant [rate schedules](https://www.versantpower.com/residential/rates/rates-schedules/)" + }, + { + "const": "54aec3a7", + "title": "VersantA20HeatTariff", + "description": "Versant is a utility serving customers in Maine, and in particular serves much of the area behind the Keene Rd Constraint in the [GridWorks Millinocket Demo](https://gridworks.readthedocs.io/en/latest/millinocket-demo.html#background). This is an alternative tariff available for electric heat." + } + ] + }, "RecognizedCurrencyUnit000": { "type": "string", "name": "recognized.currency.unit.000", @@ -38,6 +66,28 @@ "description": "Pounds sterling" } ] + }, + "EnergySupplyType000": { + "type": "string", + "name": "energy.supply.type.000", + "description": "", + "oneOf": [ + { + "const": "00000000", + "title": "Unknown", + "description": "" + }, + { + "const": "cb18f937", + "title": "StandardOffer", + "description": "" + }, + { + "const": "e9dc99a6", + "title": "RealtimeLocalLmp", + "description": "" + } + ] } }, "properties": { @@ -88,6 +138,11 @@ "title": "", "required": true }, + "StorageSteps": { + "type": "integer", + "title": "", + "required": true + }, "StoreSizeGallons": { "type": "integer", "title": "", @@ -98,7 +153,7 @@ "title": "", "required": true }, - "ElementMaxPowerKw": { + "RatedPowerKw": { "type": "number", "title": "", "required": true @@ -108,27 +163,52 @@ "title": "", "required": true }, - "FixedPumpGpm": { + "CirculatorPumpGpm": { "type": "number", "title": "", "required": true }, - "ReturnWaterFixedDeltaT": { + "ReturnWaterDeltaTempF": { "type": "integer", "title": "", "required": true }, - "SliceDurationMinutes": { + "RoomTempF": { "type": "integer", "title": "", "required": true }, + "AmbientPowerInKw": { + "type": "number", + "title": "", + "required": true + }, + "HouseWorstCaseTempF": { + "type": "number", + "title": "", + "required": true + }, + "StorePassiveLossRatio": { + "type": "number", + "title": "", + "required": true + }, "PowerLostFromHouseKwList": { "type": "number", "title": "", "required": true }, - "OutsideTempF": { + "AmbientTempStoreF": { + "type": "integer", + "title": "", + "required": true + }, + "SliceDurationMinutes": { + "type": "integer", + "title": "", + "required": true + }, + "RealtimeElectricityPrice": { "type": "number", "title": "", "required": true @@ -138,7 +218,7 @@ "title": "", "required": true }, - "RealtimeElectricityPrice": { + "OutsideTempF": { "type": "number", "title": "", "required": true @@ -149,13 +229,13 @@ "title": "", "required": true }, - "WeatherUid": { + "DistPriceUid": { "type": "string", "format": "UuidCanonicalTextual", "title": "", "required": true }, - "DistPriceUid": { + "WeatherUid": { "type": "string", "format": "UuidCanonicalTextual", "title": "", @@ -167,6 +247,33 @@ "title": "", "required": true }, + "Tariff": { + "type": "string", + "format": "DistributionTariff000", + "title": "", + "required": true + }, + "EnergyType": { + "type": "string", + "format": "EnergySupplyType000", + "title": "", + "required": true + }, + "StandardOfferPriceDollarsPerMwh": { + "type": "integer", + "title": "", + "required": true + }, + "FlatDistributionTariffDollarsPerMwh": { + "type": "integer", + "title": "", + "required": true + }, + "StartingStoreIdx": { + "type": "integer", + "title": "", + "required": true + }, "TypeName": { "type": "string", "value": "flo.params.simpleresistivehydronic.000", diff --git a/docs/apis/types.rst b/docs/apis/types.rst index 688956f..9d9426c 100644 --- a/docs/apis/types.rst +++ b/docs/apis/types.rst @@ -10,6 +10,10 @@ AtnBid ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ .. literalinclude:: json/atn-bid.json +AtnOutsideTempRegrCoeffs +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +.. literalinclude:: json/atn-outside-temp-regr-coeffs.json + AtnParams ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ .. literalinclude:: json/atn-params.json diff --git a/docs/data-categories.rst b/docs/data-categories.rst new file mode 100644 index 0000000..83c936b --- /dev/null +++ b/docs/data-categories.rst @@ -0,0 +1,12 @@ +Data Categories +================ + + +Slowly Varying State Variables +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +This refers to state variables for a TerminalAsset that are likely to stay the +same day-to-day but still are likely to change during normal lifecycle operations. + +A good example is the linear regression coefficients used for predicting building +heat loss as a function of weather. diff --git a/docs/hello-atn.rst b/docs/hello-atn.rst index a9b3453..4fa058f 100644 --- a/docs/hello-atn.rst +++ b/docs/hello-atn.rst @@ -1,2 +1,14 @@ Hello AtomicTNode ================= + +AtomicTNodes intro - what they are, what they do. + +Walk through the tutorial using an open-source demo strategy for what we call a simple +resistive hydronic heating system. This code emakes some simplifying assumptions that +one would want to adjust for better performance in the field, but captures many of +the core functional concepts of an AtomicTNode operating for a thermal storage +asset that is indeed an appropriate part of the mix for a high renewables future. + +.. image:: images/simple-resistive-hydronic.png + :alt: Simple Resistive Hydronic model + :align: center diff --git a/docs/images/simple-resistive-hydronic.png b/docs/images/simple-resistive-hydronic.png new file mode 100644 index 0000000..dad0bd8 Binary files /dev/null and b/docs/images/simple-resistive-hydronic.png differ diff --git a/docs/index.rst b/docs/index.rst index 9fdb417..5a3665e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -64,10 +64,7 @@ Installation :caption: Code Support Hello AtomicTNode - Brick Storage Heater model - Forward Looking Optimization - Simple Scada Simulation - Lexicon + Lexicon .. toctree:: diff --git a/docs/lexicon.rst b/docs/lexicon.rst new file mode 100644 index 0000000..e0c1658 --- /dev/null +++ b/docs/lexicon.rst @@ -0,0 +1,14 @@ +GridWorks Atn lexicon +--------------------- + +See `GridWorks Lexicon `_. + +.. toctree:: + :hidden: + :maxdepth: 2 + :caption: Participate + Brick Storage Heater strategy + Data Categories + Forward Looking Optimization + Simple Resistive Hydronic strategy + Simple Scada Simulation diff --git a/docs/sdk-types.rst b/docs/sdk-types.rst index a90d86c..163a132 100644 --- a/docs/sdk-types.rst +++ b/docs/sdk-types.rst @@ -20,6 +20,7 @@ forth between type instances and Python objects. AcceptedBid AtnBid + AtnOutsideTempRegrCoeffs AtnParams AtnParamsBrickstorageheater AtnParamsReport diff --git a/docs/simple-resistive-hydronic.rst b/docs/simple-resistive-hydronic.rst new file mode 100644 index 0000000..2c4026b --- /dev/null +++ b/docs/simple-resistive-hydronic.rst @@ -0,0 +1,8 @@ +SimpleResistiveHydronic Atn Model +=================================== + + + +.. image:: images/simple-resistive-hydronic.png + :alt: Simple Resistive Hydronic model + :align: center diff --git a/docs/types/atn-outside-temp-regr-coeffs.rst b/docs/types/atn-outside-temp-regr-coeffs.rst new file mode 100644 index 0000000..9b9e508 --- /dev/null +++ b/docs/types/atn-outside-temp-regr-coeffs.rst @@ -0,0 +1,15 @@ +AtnOutsideTempRegrCoeffs +========================== +Python pydantic class corresponding to json type ```atn.outside.temp.regr.coeffs```. + +.. autoclass:: gwatn.types.AtnOutsideTempRegrCoeffs + :members: + +**Alpha**: + - Description: Alpha (units: W) + +**Beta**: + - Description: Beta (units: W / deg F) + +.. autoclass:: gwatn.types.AtnOutsideTempRegrCoeffs_Maker + :members: diff --git a/docs/types/atn-params-simpleresistivehydronic.rst b/docs/types/atn-params-simpleresistivehydronic.rst index 1bc6cf8..1732da9 100644 --- a/docs/types/atn-params-simpleresistivehydronic.rst +++ b/docs/types/atn-params-simpleresistivehydronic.rst @@ -36,7 +36,7 @@ Python pydantic class corresponding to json type ```atn.params.simpleresistiveh **StandardOfferPriceDollarsPerMwh**: - Description: -**DistributionTariffDollarsPerMwh**: +**FlatDistributionTariffDollarsPerMwh**: - Description: **StoreSizeGallons**: @@ -51,10 +51,10 @@ Python pydantic class corresponding to json type ```atn.params.simpleresistiveh **RequiredSourceWaterTempF**: - Description: -**FixedPumpGpm**: +**CirculatorPumpGpm**: - Description: -**ReturnWaterFixedDeltaT**: +**ReturnWaterDeltaTempF**: - Description: **AnnualHvacKwhTh**: @@ -69,6 +69,12 @@ Python pydantic class corresponding to json type ```atn.params.simpleresistiveh **RoomTempF**: - Description: +**StorePassiveLossRatio**: + - Description: + +**AmbientTempStoreF**: + - Description: + .. autoclass:: gwatn.types.atn_params_simpleresistivehydronic.check_is_left_right_dot :members: diff --git a/docs/types/flo-params-simpleresistivehydronic.rst b/docs/types/flo-params-simpleresistivehydronic.rst index 2d755df..f39e54d 100644 --- a/docs/types/flo-params-simpleresistivehydronic.rst +++ b/docs/types/flo-params-simpleresistivehydronic.rst @@ -34,54 +34,87 @@ Python pydantic class corresponding to json type ```flo.params.simpleresistiveh **StartMinuteUtc**: - Description: +**StorageSteps**: + - Description: + **StoreSizeGallons**: - Description: **MaxStoreTempF**: - Description: -**ElementMaxPowerKw**: +**RatedPowerKw**: - Description: **RequiredSourceWaterTempF**: - Description: -**FixedPumpGpm**: +**CirculatorPumpGpm**: - Description: -**ReturnWaterFixedDeltaT**: +**ReturnWaterDeltaTempF**: - Description: -**SliceDurationMinutes**: +**RoomTempF**: + - Description: + +**AmbientPowerInKw**: + - Description: + +**HouseWorstCaseTempF**: + - Description: + +**StorePassiveLossRatio**: - Description: **PowerLostFromHouseKwList**: - Description: -**OutsideTempF**: +**AmbientTempStoreF**: - Description: -**DistributionPrice**: +**SliceDurationMinutes**: - Description: **RealtimeElectricityPrice**: - Description: +**DistributionPrice**: + - Description: + +**OutsideTempF**: + - Description: + **RtElecPriceUid**: - Description: - Format: UuidCanonicalTextual -**WeatherUid**: +**DistPriceUid**: - Description: - Format: UuidCanonicalTextual -**DistPriceUid**: +**WeatherUid**: - Description: - Format: UuidCanonicalTextual **CurrencyUnit**: - Description: +**Tariff**: + - Description: + +**EnergyType**: + - Description: + +**StandardOfferPriceDollarsPerMwh**: + - Description: + +**FlatDistributionTariffDollarsPerMwh**: + - Description: + +**StartingStoreIdx**: + - Description: + .. autoclass:: gwatn.types.flo_params_simpleresistivehydronic.check_is_uuid_canonical_textual :members: diff --git a/pyproject.toml b/pyproject.toml index a366e10..4c90015 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "gridworks-atn" -version = "0.3.8" +version = "0.4.0" description = "Gridworks Atn Spaceheat" authors = ["GridWorks "] license = "None" @@ -28,7 +28,6 @@ gridworks-proactor = "^0.2.2" gridworks-ps = "^0.0.1" - [tool.poetry.dev-dependencies] Pygments = ">=2.10.0" black = ">=21.10b0" diff --git a/src/gwatn/api_types.py b/src/gwatn/api_types.py index 5fc25d1..446ba04 100644 --- a/src/gwatn/api_types.py +++ b/src/gwatn/api_types.py @@ -5,6 +5,7 @@ from gwatn.types import AcceptedBid_Maker from gwatn.types import AtnBid_Maker +from gwatn.types import AtnOutsideTempRegrCoeffs_Maker from gwatn.types import AtnParams_Maker from gwatn.types import AtnParamsBrickstorageheater_Maker from gwatn.types import AtnParamsReport_Maker @@ -90,6 +91,7 @@ def type_makers() -> List[HeartbeatA_Maker]: return [ AcceptedBid_Maker, AtnBid_Maker, + AtnOutsideTempRegrCoeffs_Maker, AtnParams_Maker, AtnParamsBrickstorageheater_Maker, AtnParamsReport_Maker, @@ -181,6 +183,7 @@ def version_by_type_name() -> Dict[str, str]: v: Dict[str, str] = { "accepted.bid": "000", "atn.bid": "001", + "atn.outside.temp.regr.coeffs": "000", "atn.params": "000", "atn.params.brickstorageheater": "000", "atn.params.report": "000", @@ -270,6 +273,7 @@ def status_by_versioned_type_name() -> Dict[str, str]: v: Dict[str, str] = { "accepted.bid.000": "Active", "atn.bid.001": "Active", + "atn.outside.temp.regr.coeffs.000": "Pending", "atn.params.000": "Active", "atn.params.brickstorageheater.000": "Active", "atn.params.report.000": "Active", diff --git a/src/gwatn/data_classes/d_graph.py b/src/gwatn/data_classes/d_graph.py index a5bd0c2..d0051b8 100644 --- a/src/gwatn/data_classes/d_graph.py +++ b/src/gwatn/data_classes/d_graph.py @@ -43,21 +43,64 @@ def __init__( max_power_in: float, wh_exponent: int = 3, ): + """ + + Args: + d_graph_id: + graph_strategy_alias: + flo_start_unix_time_s: + slice_duration_hrs: + timezone_string: + default_storage_steps: + starting_store_idx: + home_city: + currency_unit: + max_storage: + max_power_in: + wh_exponent: + + """ self.graph_strategy_alias = graph_strategy_alias self.starting_store_idx = starting_store_idx - self.edges: Dict[ - DNode, List[DEdge] - ] = ( - {} - ) # a dictionary that takes nodes to lists of edges connecting that node to nodes in the next time slice - self.best_edge: Dict[ - DNode, DEdge - ] = {} # a dictionary that takes node to its best edge in the dijsktra path - self.node: Dict[ - int, Dict[int, DNode] - ] = ( - {} - ) # self.node[jj][kk] is the node with time slice index jj and energy index kk + + self.node: Dict[int, Dict[int, DNode]] = {} + """ + self.node is a dictionary representing the nodes in the graph. + + The dictionary structure is as follows: + - The outer dictionary's keys represent the time slice index (jj). + - The inner dictionary's keys represent the energy index (kk). + - The corresponding value is the node with the given time slice index (jj) and energy index (kk). + + Example usage: self.node[jj][kk] returns the node with time slice index jj and energy index kk. + """ + + self.edge: Dict[int, Dict[int, Dict[int, DEdge]]] = {} + """ + self.edge is a dictionary representing the edges in the graph. Each edge is an object + determined by its starting timeslice index (start_ts_idx), its starting store index + (start_idx) and its ending store index (end_idx) + + The dictionary structure as follows: + - The first key represents the starting timeslice index + - The second key represents the starting store index + - The third key represents the final store index + + Example usage: self.edge[ts_idx][start_idx][end_idx] returns the edge starting at the node + with time slice index start_ts_idx and store index start_idx, and ending with the node + with time slice index start_ts_idx + 1 and store end_idx. + """ + + self.best_edge: Dict[DNode, DEdge] = {} + """ + self.best_edge[node] is the optimal edge choice going forward from node. + + That is, its a dictionary mapping each node to the best next edge on the least-cost path + going forward to the end of the graph from that node. This dictionary is filled out in the + `solve_dijkstra` method, as the key step in using Dijkstra's algorithm, which solves + for the least-cost path not only for the starting node but for all nodes in the graph. + """ + self.slice_duration_hrs = slice_duration_hrs self.time_slices: int = len(self.slice_duration_hrs) self.default_storage_steps = default_storage_steps @@ -122,7 +165,7 @@ def solve_dijkstra(self) -> None: ts_idx = self.time_slices - mm bad_slice = True for node in self.node[ts_idx].values(): - edges = self.edges[node] + edges = list(self.edge[ts_idx][node.store_idx].values()) # options gives a list of edge choices along with the cost of the path # from the node assuming that this edge is chosen, and that from that point # on the optimal path is chosen. diff --git a/src/gwatn/dev_utils/price/actual_data/__init__.py b/src/gwatn/dev_utils/price/actual_data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/gwatn/dev_utils/price/actual_data/init_actual_prices_db.py b/src/gwatn/dev_utils/price/actual_data/init_actual_prices_db.py new file mode 100644 index 0000000..c33f873 --- /dev/null +++ b/src/gwatn/dev_utils/price/actual_data/init_actual_prices_db.py @@ -0,0 +1,23 @@ +import os + + +def main(db_file: str = "src/satn/dev_utils/price/actual_data/actual_price_db.sqlite3"): + script_lines = [ + "#!/bin/sh\n", + f"sqlite3 {db_file} < str: + db_file = DB_FILE + db = sqlite3.connect(db_file) + cursor = db.cursor() + cmd = f"SELECT file_name FROM csv_file_by_key WHERE type_name = 'csv.distp.sync.1_0_0' AND price_uid = '{price_uid}'" + rows = cursor.execute(cmd).fetchall() + if len(rows) == 0: + raise Exception( + f"PriceUid {price_uid} is not associated with a csv.distp.sync.1_0_0 message!!" + ) + + # TODO: turn this into sending back an error message + cursor.close() + db.close() + file = rows[0][0] + return file + + +def payload_from_file( + distp_type_name: str, + distp_csv: str, + flo_start_utc: datetime.datetime, + flo_total_time_hrs: int, +) -> DistpSync100Payload: + if distp_type_name == "csv.distp.sync.1_0_0": + try: + orig_csv = Csv_Distp_Sync_1_0_0( + distp_electricity_price_csv=distp_csv + ).payload + except FileNotFoundError: + raise Exception(f"Cannot find price file {distp_csv}!") + else: + raise Exception(f"Does not handle TypeName {distp_type_name}") + + uniform_slice_duration_hrs = orig_csv.UniformSliceDurationHrs + total_slices = int(flo_total_time_hrs / uniform_slice_duration_hrs) + + req = R_Gnode_Distp_Sync_Req_1_0_0( + agent=test_dummy.TEST_DUMMY_AGENT, + to_g_node_alias=test_dummy.TEST_DUMMY_G_NODE_ALIAS, + p_node_alias=orig_csv.PNodeAlias, + method_alias=orig_csv.MethodAlias, + start_utc=flo_start_utc, + uniform_slice_duration_hrs=orig_csv.UniformSliceDurationHrs, + total_slices=total_slices, + timezone_string=orig_csv.TimezoneString, + currency_unit=orig_csv.CurrencyUnit, + ).payload + + return distp_sync_100_paired_request(req=req, agent=test_dummy.TEST_DUMMY_AGENT) + + +def distp_sync_100_paired_request( + req: RGnodeDistpSyncRec100Payload, agent +) -> DistpSync100Payload: + uniform_slice_duration_hrs = req.UniformSliceDurationHrs + total_slices = req.TotalSlices + slice_duration_hr_string = f"[{uniform_slice_duration_hrs}] * {total_slices}" + db_file = DB_FILE + db = sqlite3.connect(db_file) + cursor = db.cursor() + + cmd = ( + f"SELECT price_uid FROM distp_sync_100 WHERE " + f"p_node_alias = '{req.PNodeAlias}' AND " + f"start_year_utc = {req.StartYearUtc} AND " + f"start_month_utc = {req.StartMonthUtc} AND " + f"start_day_utc = {req.StartDayUtc} AND " + f"start_hour_utc = {req.StartHourUtc} AND " + f"start_minute_utc = {req.StartMinuteUtc} AND " + f"method_alias = '{req.MethodAlias}' AND " + f"currency_unit = '{req.CurrencyUnit}' AND " + f"slice_duration_hrs = '{slice_duration_hr_string}'" + ) + + rows = cursor.execute(cmd).fetchall() + cursor.close() + db.close() + if len(rows) == 0: + file = create_new_distp_sync_100_and_return_filename(req) + else: + price_uid = rows[0][0] + db = sqlite3.connect(db_file) + cursor = db.cursor() + cmd = f"SELECT file_name FROM csv_file_by_key WHERE type_name = 'csv.distp.sync.1_0_0' AND price_uid = '{price_uid}'" + rows = cursor.execute(cmd).fetchall() + if len(rows) == 0: + raise Exception( + f"PriceUid {price_uid} is in table distp_sync_100 but not csv_file_by_key!!" + ) + cursor.close() + db.close() + file = rows[0][0] + + payload = Csv_Distp_Sync_1_0_0(file).paired_rabbit_payload(agent=agent) + is_valid, errors = payload.is_valid() + if not is_valid: + raise SchemaError(f"Errors making payload: {errors}") + + return payload + + +def create_new_distp_sync_100_and_return_filename( + req: RGnodeDistpSyncRec100Payload, +) -> str: + start_utc = pendulum.datetime( + year=req.StartYearUtc, + month=req.StartMonthUtc, + day=req.StartDayUtc, + hour=req.StartHourUtc, + minute=req.StartMinuteUtc, + ) + prices = [] + comment = "" + while len(prices) < req.TotalSlices: + orig_length = len(prices) + prices, new_comment = get_expanded_prices_and_comment(prices=prices, req=req) + if len(prices) == orig_length: + raise Exception( + f"No source file for reg prices after {len(prices)} slices with PNodeAlias {req.PNodeAlias}, MethodAlias {req.MethodAlias}, CurrencyUnit {req.CurrencyUnit} with flo starting {start_utc}') " + ) + comment += new_comment + if len(prices) == 0: + raise Exception(f"No source price data found") + + new = R_Distp_Sync_1_0_0( + agent=test_dummy.TEST_DUMMY_AGENT, + p_node_alias=req.PNodeAlias, + method_alias=req.MethodAlias, + comment=comment, + start_utc=start_utc, + uniform_slice_duration_hrs=req.UniformSliceDurationHrs, + timezone_string=req.TimezoneString, + currency_unit=req.CurrencyUnit, + prices=prices, + price_uid=str(uuid.uuid4()), + ).payload + + slice_duration_hr_string = f"[{new.UniformSliceDurationHrs}] * {len(new.Prices)}" + new_file = create_new_distp_sync_csv_from_payload(c=new) + db_file = "src/satn/dev_utils/price/forecast_data/ps_db.sqlite3" + db = sqlite3.connect(db_file) + cursor = db.cursor() + cmd = f"INSERT INTO distp_sync_100 (price_uid, p_node_alias, start_year_utc, start_month_utc, start_day_utc, start_hour_utc, start_minute_utc, method_alias, currency_unit, slice_duration_hrs) VALUES ('{new.PriceUid}','{new.PNodeAlias}',{new.StartYearUtc},{new.StartMonthUtc},{new.StartDayUtc},{new.StartHourUtc},{new.StartMinuteUtc},'{new.MethodAlias}', '{new.CurrencyUnit}','{slice_duration_hr_string}')" + cursor.execute(cmd) + cmd = f"INSERT INTO csv_file_by_key (price_uid, type_name, file_name) VALUES ('{new.PriceUid}','csv.distp.sync.1_0_0','{new_file}')" + cursor.execute(cmd) + db.commit() + cursor.close() + db.close() + return new_file + + +def create_new_distp_sync_csv_from_payload(c: DistpSync100Payload) -> str: + iso = c.PNodeAlias.split(".")[1] + uid_pre = c.PriceUid.split("-")[0] + hours = int(c.UniformSliceDurationHrs * len(c.Prices)) + file = f"src/satn/dev_utils/price/forecast_data/{iso}/distp__{c.PNodeAlias}__{c.StartYearUtc}__{hours}__{c.MethodAlias}__{uid_pre}.csv" + print(f"Creating new dist price file {file}") + lines = [ + f"MpAlias,csv.distp.sync.1_0_0\n", + f"PNodeAlias,{c.PNodeAlias}\n", + f"MethodAlias,{c.MethodAlias}\n", + f"Comment,{c.Comment}\n", + f"StartYearUtc,{c.StartYearUtc}\n", + f"StartMonthUtc,{c.StartMonthUtc}\n", + f"StartDayUtc,{c.StartDayUtc}\n", + f"StartHourUtc,{c.StartHourUtc}\n", + f"StartMinuteUtc,{c.StartMinuteUtc}\n", + f"UniformSliceDurationHrs,{c.UniformSliceDurationHrs}\n", + f"TimezoneString,{c.TimezoneString}\n", + f"CurrencyUnit,{c.CurrencyUnit}\n", + f"PriceUid,{c.PriceUid}\n", + f"Header, Distribution Electricity Price (Currency Unit/MWh)\n", + ] + for price in c.Prices: + lines.append(f"{price}\n") + with open(file, "w") as outfile: + outfile.writelines(lines) + return file + + +def get_expanded_prices_and_comment(prices: list, req: RGnodeDistpSyncRec100Payload): + distp_sync_source_file_by_uid = ( + price_source_files.get_distp_sync_source_file_by_uid() + ) + distp_oneprice_source_file_by_uid = ( + price_source_files.get_distp_oneprice_source_file_by_uid() + ) + distp_source_file_by_uid = { + **distp_sync_source_file_by_uid, + **distp_oneprice_source_file_by_uid, + } + flo_start_utc = pendulum.datetime( + year=req.StartYearUtc, + month=req.StartMonthUtc, + day=req.StartDayUtc, + hour=req.StartHourUtc, + minute=req.StartMinuteUtc, + ) + flo_end_utc = flo_start_utc + pendulum.duration( + hours=req.TotalSlices * req.UniformSliceDurationHrs + ) + marker_time_utc = flo_start_utc + pendulum.duration( + hours=len(prices) * req.UniformSliceDurationHrs + ) + db_file = "src/satn/dev_utils/price/forecast_data/ps_db.sqlite3" + db = sqlite3.connect(db_file) + cursor = db.cursor() + + cmd = ( + f"SELECT price_uid FROM distp_sync_100 WHERE p_node_alias = '{req.PNodeAlias}' AND " + f"method_alias = '{req.MethodAlias}' AND " + f"currency_unit = '{req.CurrencyUnit}'" + ) + + rows = cursor.execute(cmd).fetchall() + cursor.close() + db.close() + price_uids = list( + filter( + lambda x: x in distp_source_file_by_uid.keys(), map(lambda x: x[0], rows) + ) + ) + if len(price_uids) == 0: + raise Exception( + f"That is strange. No price source file matches PNodeAlias {req.PNodeAlias}, MethodAlias {req.MethodAlias}, CurrencyUnit {req.CurrencyUnit}" + ) + + candidate_start = {} + candidate_final_slice_start = {} + slice_d = {} + for uid in price_uids: + file = f"../gridworks-ps/{distp_source_file_by_uid[uid]}" + if uid in distp_sync_source_file_by_uid.keys(): + c = Csv_Distp_Sync_1_0_0(file).payload + csv_start = pendulum.datetime( + year=c.StartYearUtc, + month=c.StartMonthUtc, + day=c.StartDayUtc, + hour=c.StartHourUtc, + minute=c.StartMinuteUtc, + ) + candidate_start[uid] = csv_start + slice_d[uid] = c.UniformSliceDurationHrs + candidate_final_slice_start[uid] = csv_start + pendulum.duration( + hours=c.UniformSliceDurationHrs * (len(c.Prices) - 1) + ) + elif uid in distp_oneprice_source_file_by_uid.keys(): + c = Csv_Distp_Oneprice_1_0_0(file).payload + csv_start = pendulum.datetime( + year=c.StartYearUtc, + month=c.StartMonthUtc, + day=c.StartDayUtc, + hour=c.StartHourUtc, + minute=c.StartMinuteUtc, + ) + candidate_start[uid] = csv_start + slice_d[uid] = c.UniformSliceDurationHrs + else: + raise Exception( + "Strange. prices should be either distp.sync or distp.oneprice" + ) + + price_uids = list( + filter( + lambda x: candidate_start[x] <= marker_time_utc + and slice_d[x] == req.UniformSliceDurationHrs, + price_uids, + ) + ) + if len(price_uids) == 0: + raise Exception( + f"No source file for distprice with PNodeAlias {req.PNodeAlias}, MethodAlias {req.MethodAlias}, CurrencyUnit {req.CurrencyUnit} with flo starting {flo_start_utc}" + ) + + good_oneprices = list( + filter(lambda x: x in distp_oneprice_source_file_by_uid.keys(), price_uids) + ) + if len(good_oneprices) > 0: + best_uid = good_oneprices[0] + c = Csv_Distp_Oneprice_1_0_0(distp_oneprice_source_file_by_uid[best_uid]) + else: + price_uids = list( + filter(lambda x: x in distp_sync_source_file_by_uid.keys(), price_uids) + ) + best_uid = max( + price_uids, key=lambda x: candidate_final_slice_start[x] - flo_end_utc + ) + if candidate_final_slice_start[best_uid] < flo_start_utc: + raise Exception( + f"No source file for dist prices with PNodeAlias {req.PNodeAlias}, MethodAlias {req.MethodAlias}, CurrencyUnit {req.CurrencyUnit} with flo starting {flo_start_utc}" + ) + + file = f"../gridworks-ps/{distp_sync_source_file_by_uid[best_uid]}" + c = Csv_Distp_Sync_1_0_0(file).payload + + offset_row = int( + (flo_start_utc - candidate_start[best_uid]).total_seconds() + / 3600 + / c.UniformSliceDurationHrs + ) + if marker_time_utc > candidate_final_slice_start[best_uid]: + can_add_value = False + else: + can_add_value = True + i = offset_row + while can_add_value: + prices.append(c.Prices[i]) + marker_time_utc += pendulum.duration(hours=c.UniformSliceDurationHrs) + i += 1 + if ( + marker_time_utc > candidate_final_slice_start[best_uid] + or len(prices) >= req.TotalSlices + ): + can_add_value = False + comment = f"Used {i - offset_row} slices from {distp_source_file_by_uid[best_uid]}" + return prices, comment diff --git a/src/gwatn/dev_utils/price/eprt_forecast_sync_100_handler.py b/src/gwatn/dev_utils/price/eprt_forecast_sync_100_handler.py new file mode 100644 index 0000000..6c36de8 --- /dev/null +++ b/src/gwatn/dev_utils/price/eprt_forecast_sync_100_handler.py @@ -0,0 +1,187 @@ +import os +import sqlite3 +import uuid + +import pendulum + +import gwatn.types.hack_test_dummy as test_dummy +from gwatn.types.eprt.gt_eprt_forecast_sync_1_0_0 import Gt_Eprt_Forecast_Sync_1_0_0 +from gwatn.types.eprt.gt_eprt_forecast_sync_1_0_0 import GtEprtForecastSync100 + +# Message payloads for messages received +from gwatn.types.gnode_eprequest_ps.r_get_eprt_forecast_sync.r_get_eprt_forecast_sync_1_0_0 import ( + Payload as RGetEprtForecastSync100, +) +from gwatn.types.ps_electricityprices_gnode.csv_eprt_forecast_sync.csv_eprt_forecast_sync_1_0_0 import ( + Csv_Eprt_Forecast_Sync_1_0_0, +) +from gwatn.types.ps_electricityprices_gnode.r_eprt_forecast_sync.r_eprt_forecast_sync_1_0_0 import ( + Payload as REprtForecastSync100Payload, +) + + +# MessageMakers for reading eprt (electricity price real time) from csvs + + +DB_FILE = "src/satn/dev_utils/price/forecast_data/ps_db.sqlite3" +GITIGNORED_FILE_DIR_ROOT = "input_data/gitignored/electricity_prices" + + +def csv_file_by_uid(price_uid: str) -> str: + db_file = DB_FILE + db = sqlite3.connect(db_file) + cursor = db.cursor() + cmd = f"SELECT file_name FROM csv_file_by_key WHERE type_name = 'csv.eprt.forecast.sync.1_0_0' AND price_uid = '{price_uid}'" + rows = cursor.execute(cmd).fetchall() + if len(rows) == 0: + raise Exception( + f"PriceUid {price_uid} is not associated with any csv.eprt.forecast.sync.1_0_0 files!!" + ) + # TODO: turn this into sending back an error message + cursor.close() + db.close() + file = rows[0][0] + return file + + +def response_to_gnode_eprt_sync_request( + req: RGetEprtForecastSync100, agent: test_dummy.TEST_DUMMY_AGENT +) -> REprtForecastSync100Payload: + db_file = DB_FILE + forecast_start_utc = pendulum.datetime( + year=req.StartYearUtc, + month=req.StartMonthUtc, + day=req.StartDayUtc, + hour=req.StartHourUtc, + minute=req.StartMinuteUtc, + ) + forecast_start_unix_s = forecast_start_utc.int_timestamp + if agent == test_dummy.TEST_DUMMY_AGENT: + request_received_unix_s = forecast_start_unix_s - 250 + else: + request_received_unix_s = agent.latest_time_unix_s + uniform_slice_duration_minutes = int(req.UniformSliceDurationHrs * 60) + real_p_node_alias = ".".join(["w"] + req.PNodeAlias.split(".")[1:]) + db = sqlite3.connect(db_file) + cursor = db.cursor() + cmd = ( + f"SELECT price_uid, forecast_generated_time_unix_s FROM eprt_forecast_sync_100 WHERE p_node_alias = '{real_p_node_alias}' AND method_alias = '{req.MethodAlias}' " + + f"AND uniform_slice_duration_minutes = {uniform_slice_duration_minutes} AND total_slices = {req.TotalSlices} and forecast_start_time_unix_s = {forecast_start_unix_s} AND forecast_generated_time_unix_s < {request_received_unix_s}" + ) + rows = cursor.execute(cmd).fetchall() + if len(rows) == 0: + return None + latest_prediction_uid = sorted(rows, key=lambda x: -x[1])[0][0] + cmd = f"SELECT file_name FROM csv_file_by_key WHERE type_name = 'csv.eprt.forecast.sync.1_0_0' AND price_uid = '{latest_prediction_uid }'" + files = cursor.execute(cmd).fetchall() + if len(files) != 1: + raise Exception(f"Database problem with price_uid {latest_prediction_uid}") + file = files[0][0] + response = Csv_Eprt_Forecast_Sync_1_0_0( + real_time_electricity_price_csv=file + ).payload + if len(response.Core.Prices) == req.TotalSlices: + return response + elif len(response.Core.Prices) < req.TotalSlices: + raise NotImplementedError( + f"file has {len(response.Core.Prices)} prices and request asked for {req.TotalSlices}" + ) + else: + new_prices = response.Core.Prices[0 : len(req.TotalSlices)] + new_price_uid = str(uuid.uuid4()) + new_comment = ( + response.Core.Comment + f" from beginning of {response.Core.PriceUid}" + ) + core = Gt_Eprt_Forecast_Sync_1_0_0( + prices=new_prices, + method_alias=response.Core.MethodAlias, + p_node_alias=response.Core.PNodeAlias, + timezone_string=response.Core.TimezoneString, + currency_unit=response.Core.TimezoneString, + uniform_slice_duration_minutes=response.Core.UniformSliceDurationMinutes, + forecast_start_iso8601_utc=response.Core.ForecastStartIso8601Utc, + forecast_generated_iso8601_utc=response.Core.ForecastGeneratedIso8601Utc, + price_uid=new_price_uid, + comment=new_comment, + ).payload + create_if_missing_forecast_sync_csv_from_rabbit_payload(core=core) + + +def create_if_missing_forecast_sync_csv_from_rabbit_payload( + core: GtEprtForecastSync100, +) -> GtEprtForecastSync100: + db = sqlite3.connect(DB_FILE) + cursor = db.cursor() + cmd = f"SELECT file_name, type_name, price_uid FROM csv_file_by_key WHERE price_uid = '{core.PriceUid}'" + price_uid_rows = cursor.execute(cmd).fetchall() + cmd = ( + f"SELECT price_uid FROM eprt_forecast_sync_100 WHERE p_node_alias = '{core.PNodeAlias}'" + + f" AND forecast_generated_time_unix_s = {pendulum.parse(core.ForecastGeneratedIso8601Utc).int_timestamp}" + + f" AND forecast_start_time_unix_s = {pendulum.parse(core.ForecastStartIso8601Utc).int_timestamp}" + + f" AND method_alias = '{core.MethodAlias}'" + + f" AND total_slices = {len(core.Prices)}" + + f" AND uniform_slice_duration_minutes = {core.UniformSliceDurationMinutes}" + ) + eprt_forecast_rows = cursor.execute(cmd).fetchall() + if len(eprt_forecast_rows) > 0: + # this forecast already exists. Populate the core and return it. + price_uid = eprt_forecast_rows[0][0] + cmd = f"SELECT file_name from csv_file_by_key WHERE price_uid = '{price_uid}'" + file_rows = cursor.execute(cmd).fetchall() + if len(file_rows) == 0: + raise Exception( + f"db integrity error with {price_uid} between tables csv_file_by_key and file_name from csv_file_by_key\n check {DB_FILE}" + ) + price_file = file_rows[0][0] + existing_core = Csv_Eprt_Forecast_Sync_1_0_0(price_file=price_file).payload.Core + return existing_core + if len(price_uid_rows) > 0: + if price_uid_rows[0][1] != "csv.eprt.forecast.sync.1_0_0": + raise Exception( + f"{DB_FILE} integrity error! Expecting type_name csv.eprt.forecast.sync.1_0_0 for {price_file} but file but shows up in csv_file_by_key with type_name {rows[0][1]}" + ) + if len(eprt_forecast_rows) == 0: + raise Exception( + f"Data integrity error with eprt_forecast_sync_100 table! \nprice_uid exists but does not match msg .... try SELECT type_name, file_name FROM csv_file_by_key WHERE price_uid = '{core.PriceUid}'; \n {core} \n db location: {DB_FILE}" + ) + + forecast_start_utc = pendulum.parse(core.ForecastStartIso8601Utc) + iso = core.PNodeAlias.split(".")[1] + real_p_node_alias = ".".join(["w"] + core.PNodeAlias.split(".")[1:]) + hours = int(core.UniformSliceDurationMinutes * len(core.Prices) / 60) + uid_pre = core.PriceUid.split("-")[0] + price_file = f'{GITIGNORED_FILE_DIR_ROOT}/{iso}/eprt__{forecast_start_utc.strftime("%Y%m%dT%H%M")}__{real_p_node_alias}___{hours}__{core.MethodAlias}__{uid_pre}.csv' + lines = [ + f"MpAlias,csv.eprt.forecast.sync.1_0_0\n", + f"PNodeAlias,{core.PNodeAlias}\n", + f"MethodAlias,{core.MethodAlias}\n", + f"Comment,{core.Comment}\n", + f"ForecastGeneratedIso8601Utc,{core.ForecastGeneratedIso8601Utc}\n", + f"ForecastStartIso8601Utc,{core.ForecastStartIso8601Utc}\n", + f"UniformSliceDurationMinutes,{core.UniformSliceDurationMinutes}\n", + f"TimezoneString,{core.TimezoneString}\n", + f"CurrencyUnit,{core.CurrencyUnit}\n", + f"PriceUid,{core.PriceUid}\n", + f"Header,Forecast Real Time LMP Electricity Price (Currency Unit/MWh)\n", + ] + for price in core.Prices: + lines.append(f"{price}\n") + with open(price_file, "w") as outfile: + outfile.writelines(lines) + + try: + cmd = f"INSERT INTO eprt_forecast_sync_100 (price_uid, p_node_alias, forecast_generated_time_unix_s, forecast_start_Time_unix_s, method_alias, currency_unit, uniform_slice_duration_minutes, total_slices) VALUES ('{core.PriceUid}','{core.PNodeAlias}',{pendulum.parse(core.ForecastGeneratedIso8601Utc).int_timestamp},{pendulum.parse(core.ForecastStartIso8601Utc).int_timestamp},'{core.MethodAlias}','{core.CurrencyUnit}',{core.UniformSliceDurationMinutes}, {len(core.Prices)})" + cursor.execute(cmd) + cmd = f"INSERT INTO csv_file_by_key (price_uid, type_name, file_name) VALUES ('{core.PriceUid}', 'csv.eprt.forecast.sync.1_0_0', '{price_file}');\n" + cursor.execute(cmd) + db.commit() + except Exception as e: + os.system(f"rm {price_file}") + db.close() + raise Exception( + f"Failure storing new local forecast {price_file} in ps_db.sqlite3! Not generating: {e}" + ) + cursor.close() + db.close() + print(f"Done creating new csv.eprt.forecast.sync.1_0_0 price file {price_file}") + return core diff --git a/src/gwatn/dev_utils/price/eprt_sync_100_handler.py b/src/gwatn/dev_utils/price/eprt_sync_100_handler.py new file mode 100644 index 0000000..5e05302 --- /dev/null +++ b/src/gwatn/dev_utils/price/eprt_sync_100_handler.py @@ -0,0 +1,320 @@ +import datetime +import sqlite3 +import uuid + +import gwprice.dev_utils.price_source_files as price_source_files +import pendulum +from gridworks.errors import SchemaError +from satn.dev_utils.price.input_handler import get_flo_starttime_from_eprt_csv + +import gwatn.types.hack_test_dummy as test_dummy +from gwatn.types.gnode_eprequest_ps.r_get_eprt_forecast_sync.r_get_eprt_forecast_sync_1_0_0 import ( + Payload as RGetEprtForecastSync100Payload, +) +from gwatn.types.gnode_eprequest_ps.r_gnode_eprt_sync_req.r_gnode_eprt_sync_req_1_0_0 import ( + R_Gnode_Eprt_Sync_Req_1_0_0, +) +from gwatn.types.ps_electricityprices_gnode.csv_eprt_sync.csv_eprt_sync_1_0_0 import ( + Csv_Eprt_Sync_1_0_0, +) +from gwatn.types.ps_electricityprices_gnode.r_eprt_sync.r_eprt_sync_1_0_0 import ( + Payload as EprtSync100Payload, +) +from gwatn.types.ps_electricityprices_gnode.r_eprt_sync.r_eprt_sync_1_0_0 import ( + R_Eprt_Sync_1_0_0, +) + + +# MessageMakers for reading eprt (electricity price real time) from csvs + +DB_FILE = "src/satn/dev_utils/price/forecast_data/ps_db.sqlite3" +GITIGNORED_FILE_DIR_ROOT = "input_data/gitignored/electricity_prices" + + +def csv_file_by_uid(price_uid: str) -> str: + db_file = DB_FILE + db = sqlite3.connect(db_file) + cursor = db.cursor() + cmd = f"SELECT file_name FROM csv_file_by_key WHERE type_name = 'csv.eprt.sync.1_0_0' AND price_uid = '{price_uid}'" + rows = cursor.execute(cmd).fetchall() + if len(rows) == 0: + raise Exception( + f"PriceUid {price_uid} is not associated with any csv.eprt.sync.1_0_0 files!!" + ) + # TODO: turn this into sending back an error message + cursor.close() + db.close() + file = rows[0][0] + return file + + +def payload_from_file( + eprt_type_name: str, + eprt_csv: str, + csv_starting_offset_hours: int, + flo_total_time_hrs: int, +) -> EprtSync100Payload: + print(f"eprt_csv is {eprt_csv}") + start_datetime_utc = get_flo_starttime_from_eprt_csv( + rt_price_type_name=eprt_type_name, + rt_price_csv=eprt_csv, + csv_starting_offset_hours=csv_starting_offset_hours, + ) + + if eprt_type_name == "csv.eprt.sync.1_0_0": + try: + orig_csv = Csv_Eprt_Sync_1_0_0( + real_time_electricity_price_csv=eprt_csv + ).payload + except FileNotFoundError: + raise Exception(f"Cannot find price file {eprt_csv}!") + else: + raise Exception(f"Does not handle TypeName {eprt_type_name}") + + uniform_slice_duration_hrs = orig_csv.UniformSliceDurationHrs + total_slices = int(flo_total_time_hrs / uniform_slice_duration_hrs) + req = R_Gnode_Eprt_Sync_Req_1_0_0( + agent=test_dummy.TEST_DUMMY_AGENT, + to_g_node_alias=test_dummy.TEST_DUMMY_G_NODE_ALIAS, + p_node_alias=orig_csv.PNodeAlias, + method_alias=orig_csv.MethodAlias, + start_utc=start_datetime_utc, + uniform_slice_duration_hrs=orig_csv.UniformSliceDurationHrs, + total_slices=total_slices, + timezone_string=orig_csv.TimezoneString, + currency_unit=orig_csv.CurrencyUnit, + ).payload + payload = response_to_paired_request(req=req, agent=test_dummy.TEST_DUMMY_AGENT) + return payload + + +def response_to_paired_request( + req: RGetEprtForecastSync100Payload, agent +) -> EprtSync100Payload: + uniform_slice_duration_hrs = req.UniformSliceDurationHrs + total_slices = req.TotalSlices + slice_duration_hr_string = f"[{uniform_slice_duration_hrs}] * {total_slices}" + db_file = DB_FILE + db = sqlite3.connect(db_file) + cursor = db.cursor() + + cmd = ( + f"SELECT price_uid FROM eprt_sync_100 WHERE " + f"p_node_alias = '{req.PNodeAlias}' AND " + f"start_year_utc = {req.StartYearUtc} AND " + f"start_month_utc = {req.StartMonthUtc} AND " + f"start_day_utc = {req.StartDayUtc} AND " + f"start_hour_utc = {req.StartHourUtc} AND " + f"start_minute_utc = {req.StartMinuteUtc} AND " + f"method_alias = '{req.MethodAlias}' AND " + f"currency_unit = '{req.CurrencyUnit}' AND " + f"slice_duration_hrs = '{slice_duration_hr_string}'" + ) + rows = cursor.execute(cmd).fetchall() + cursor.close() + db.close() + if len(rows) == 0: + file = create_new_eprt_sync_100_and_return_filename(req) + else: + price_uid = rows[0][0] + db = sqlite3.connect(db_file) + cursor = db.cursor() + cmd = f"SELECT file_name FROM csv_file_by_key WHERE type_name = 'csv.eprt.sync.1_0_0' AND price_uid = '{price_uid}' " + rows = cursor.execute(cmd).fetchall() + if len(rows) == 0: + raise Exception( + f"PriceUid {price_uid} is in table eprt_sync_100 but not csv_file_by_key!!" + ) + cursor.close() + db.close() + file = rows[0][0] + payload = Csv_Eprt_Sync_1_0_0(file).paired_rabbit_payload(agent=agent) + is_valid, errors = payload.is_valid() + if not is_valid: + raise SchemaError(f"Errors making payload: {errors}") + return payload + + +def create_new_eprt_sync_100_and_return_filename( + req: RGetEprtForecastSync100Payload, +) -> str: + start_utc = pendulum.datetime( + year=req.StartYearUtc, + month=req.StartMonthUtc, + day=req.StartDayUtc, + hour=req.StartHourUtc, + minute=req.StartMinuteUtc, + ) + prices = [] + comment = "" + while len(prices) < req.TotalSlices: + orig_length = len(prices) + prices, new_comment = get_expanded_prices_and_comment(prices=prices, req=req) + if len(prices) == orig_length: + raise Exception( + f"No source file for reg prices after {len(prices)} slices with PNodeAlias {req.PNodeAlias}, MethodAlias {req.MethodAlias}, CurrencyUnit {req.CurrencyUnit} with flo starting {start_utc}') " + ) + comment += new_comment + if len(prices) == 0: + raise Exception(f"No source price data found") + + new = R_Eprt_Sync_1_0_0( + agent=test_dummy.TEST_DUMMY_AGENT, + prices=prices, + method_alias=req.MethodAlias, + p_node_alias=req.PNodeAlias, + comment=comment, + start_utc=start_utc, + uniform_slice_duration_hrs=req.UniformSliceDurationHrs, + timezone_string=req.TimezoneString, + currency_unit=req.CurrencyUnit, + price_uid=str(uuid.uuid4()), + ).payload + + slice_duration_hr_string = f"[{new.UniformSliceDurationHrs}] * {len(new.Prices)}" + new_file = create_new_eprt_sync_csv_from_payload(c=new) + print(f"Done creating new eprt price file") + db = sqlite3.connect(DB_FILE) + cursor = db.cursor() + cmd = f"INSERT INTO eprt_sync_100 (price_uid, p_node_alias, start_year_utc, start_month_utc, start_day_utc, start_hour_utc, start_minute_utc, method_alias, currency_unit, slice_duration_hrs) VALUES ('{new.PriceUid}','{new.PNodeAlias}',{new.StartYearUtc},{new.StartMonthUtc},{new.StartDayUtc},{new.StartHourUtc},{new.StartMinuteUtc},'{new.MethodAlias}', '{new.CurrencyUnit}','{slice_duration_hr_string}')" + cursor.execute(cmd) + + cmd = f"INSERT INTO csv_file_by_key (price_uid, type_name, file_name) VALUES ('{new.PriceUid}','csv.eprt.sync.1_0_0','{new_file}')" + cursor.execute(cmd) + db.commit() + cursor.close() + db.close() + return new_file + + +def create_new_eprt_sync_csv_from_payload(c: EprtSync100Payload) -> str: + forecast_start_utc = pendulum.datetime( + year=c.StartYearUtc, + month=c.StartMonthUtc, + day=c.StartDayUtc, + hour=c.StartHourUtc, + minute=c.StartMinuteUtc, + ) + iso = c.PNodeAlias.split(".")[1] + uid_pre = c.PriceUid.split("-")[0] + hours = int(c.UniformSliceDurationHrs * len(c.Prices)) + file = f'{GITIGNORED_FILE_DIR_ROOT}/{iso}/eprt__{forecast_start_utc.strftime("%Y%m%dT%H%M")}__{hours}__{c.PNodeAlias}__{c.MethodAlias}__{uid_pre}.csv' + print(f"Creating new eprt sync 100 price file {file}") + lines = [ + f"MpAlias,csv.eprt.sync.1_0_0\n", + f"PNodeAlias,{c.PNodeAlias}\n", + f"MethodAlias,{c.MethodAlias}\n", + f"Comment,{c.Comment}\n", + f"StartYearUtc,{c.StartYearUtc}\n", + f"StartMonthUtc,{c.StartMonthUtc}\n", + f"StartDayUtc,{c.StartDayUtc}\n", + f"StartHourUtc,{c.StartHourUtc}\n", + f"StartMinuteUtc,{c.StartMinuteUtc}\n", + f"UniformSliceDurationHrs,{c.UniformSliceDurationHrs}\n", + f"TimezoneString,{c.TimezoneString}\n", + f"CurrencyUnit,{c.CurrencyUnit}\n", + f"PriceUid,{c.PriceUid}\n", + f"Header,Real Time LMP Electricity Price (Currency Unit/MWh)\n", + ] + for price in c.Prices: + lines.append(f"{price}\n") + with open(file, "w") as outfile: + outfile.writelines(lines) + return file + + +def get_expanded_prices_and_comment(prices: list, req: RGetEprtForecastSync100Payload): + eprt_source_file_by_uid = price_source_files.get_eprt_sync_source_file_by_uid() + flo_start_utc = pendulum.datetime( + year=req.StartYearUtc, + month=req.StartMonthUtc, + day=req.StartDayUtc, + hour=req.StartHourUtc, + minute=req.StartMinuteUtc, + ) + flo_end = flo_start_utc + pendulum.duration( + hours=req.TotalSlices * req.UniformSliceDurationHrs + ) + marker_time = flo_start_utc + pendulum.duration( + hours=len(prices) * req.UniformSliceDurationHrs + ) + db_file = "src/satn/dev_utils/price/forecast_data/ps_db.sqlite3" + db = sqlite3.connect(db_file) + cursor = db.cursor() + + cmd = ( + f"SELECT price_uid FROM eprt_sync_100 WHERE p_node_alias = '{req.PNodeAlias}' AND " + f"method_alias = '{req.MethodAlias}' AND " + f"currency_unit = '{req.CurrencyUnit}'" + ) + + rows = cursor.execute(cmd).fetchall() + cursor.close() + db.close() + price_uids = list( + filter(lambda x: x in eprt_source_file_by_uid.keys(), map(lambda x: x[0], rows)) + ) + if len(price_uids) == 0: + raise Exception( + f"That is strange. No price source file matches PNodeAlias {req.PNodeAlias}, MethodAlias {req.MethodAlias}, CurrencyUnit {req.CurrencyUnit}" + ) + + candidate_start = {} + candidate_final_slice_start = {} + slice_d = {} + for uid in price_uids: + file = f"../gridworks-ps/{eprt_source_file_by_uid[uid]}" + c = Csv_Eprt_Sync_1_0_0(file).payload + csv_start = pendulum.datetime( + year=c.StartYearUtc, + month=c.StartMonthUtc, + day=c.StartDayUtc, + hour=c.StartHourUtc, + minute=c.StartMinuteUtc, + ) + candidate_start[uid] = csv_start + candidate_final_slice_start[uid] = csv_start + pendulum.duration( + hours=c.UniformSliceDurationHrs * (len(c.Prices) - 1) + ) + slice_d[uid] = c.UniformSliceDurationHrs + price_uids = list( + filter( + lambda x: candidate_start[x] <= marker_time + and slice_d[x] == req.UniformSliceDurationHrs, + price_uids, + ) + ) + if len(price_uids) == 0: + raise Exception( + f"No source file for rt prices with PNodeAlias {req.PNodeAlias}, MethodAlias {req.MethodAlias}, CurrencyUnit {req.CurrencyUnit} with flo starting {flo_start_utc}" + ) + + best_uid = max(price_uids, key=lambda x: candidate_final_slice_start[x] - flo_end) + if candidate_final_slice_start[best_uid] < flo_start_utc: + raise Exception( + f"No source file for rt prices with PNodeAlias {req.PNodeAlias}, MethodAlias {req.MethodAlias}, CurrencyUnit {req.CurrencyUnit} with flo starting {flo_start_utc}" + ) + + file = f"../gridworks-ps/{eprt_source_file_by_uid[best_uid]}" + c = Csv_Eprt_Sync_1_0_0(file).payload + offset_row = int( + (flo_start_utc - candidate_start[best_uid]).total_seconds() + / 3600 + / c.UniformSliceDurationHrs + ) + if marker_time > candidate_final_slice_start[best_uid]: + can_add_value = False + else: + can_add_value = True + i = offset_row + while can_add_value: + prices.append(c.Prices[i]) + marker_time += pendulum.duration(hours=c.UniformSliceDurationHrs) + i += 1 + if ( + marker_time > candidate_final_slice_start[best_uid] + or len(prices) >= req.TotalSlices + ): + can_add_value = False + comment = f"Used {i - offset_row} slices from {eprt_source_file_by_uid[best_uid]}" + return prices, comment diff --git a/src/gwatn/dev_utils/price/forecast_data/__init__.py b/src/gwatn/dev_utils/price/forecast_data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/gwatn/dev_utils/price/forecast_data/caiso/__init__.py b/src/gwatn/dev_utils/price/forecast_data/caiso/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/gwatn/dev_utils/price/forecast_data/ercot/__init__.py b/src/gwatn/dev_utils/price/forecast_data/ercot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/gwatn/dev_utils/price/forecast_data/gw/__init__.py b/src/gwatn/dev_utils/price/forecast_data/gw/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/gwatn/dev_utils/price/forecast_data/init_ps_db.py b/src/gwatn/dev_utils/price/forecast_data/init_ps_db.py new file mode 100644 index 0000000..9a6db3c --- /dev/null +++ b/src/gwatn/dev_utils/price/forecast_data/init_ps_db.py @@ -0,0 +1,96 @@ +import os + + +def main( + db_file: str = "src/satn/dev_utils/price/forecast_data/ps_db.sqlite3", +): + script_lines = [ + "#!/bin/sh\n", + f"sqlite3 {db_file} < str: + db_file = DB_FILE + db = sqlite3.connect(db_file) + cursor = db.cursor() + cmd = f"SELECT file_name FROM csv_file_by_key WHERE type_name = 'csv.regp.sync.1_0_0' AND price_uid = '{price_uid}'" + rows = cursor.execute(cmd).fetchall() + if len(rows) == 0: + raise Exception( + f"PriceUid {price_uid} is not associated with a csv.regp.sync.1_0_0 message!!" + ) + # TODO: turn this into sending back an error message + cursor.close() + db.close() + file = rows[0][0] + return file + + +def payload_from_file( + regp_type_name: str, + regp_csv: str, + flo_start_utc: datetime.datetime, + flo_total_time_hrs: int, +) -> RegpSync100Payload: + if regp_type_name == "csv.regp.sync.1_0_0": + try: + orig_csv = Csv_Regp_Sync_1_0_0(reg_price_csv=regp_csv).payload + except FileNotFoundError: + raise Exception(f"Cannot find price file {regp_csv}!") + else: + raise Exception(f"Does not handle TypeName {regp_type_name}") + uniform_slice_duration_hrs = orig_csv.UniformSliceDurationHrs + total_slices = int(flo_total_time_hrs / uniform_slice_duration_hrs) + + req = R_Gnode_Regp_Sync_Req_1_0_0( + agent=test_dummy.TEST_DUMMY_AGENT, + to_g_node_alias=test_dummy.TEST_DUMMY_G_NODE_ALIAS, + p_node_alias=orig_csv.PNodeAlias, + method_alias=orig_csv.MethodAlias, + start_utc=flo_start_utc, + uniform_slice_duration_hrs=orig_csv.UniformSliceDurationHrs, + total_slices=total_slices, + timezone_string=orig_csv.TimezoneString, + currency_unit=orig_csv.CurrencyUnit, + ).payload + + payload = regp_sync_100_paired_request(req=req, agent=test_dummy.TEST_DUMMY_AGENT) + return payload + + +def regp_sync_100_paired_request( + req: RGnodeRegpSyncReq100Payload, agent +) -> RegpSync100Payload: + uniform_slice_duration_hrs = req.UniformSliceDurationHrs + total_slices = req.TotalSlices + slice_duration_hr_string = f"[{uniform_slice_duration_hrs}] * {total_slices}" + db_file = DB_FILE + db = sqlite3.connect(db_file) + cursor = db.cursor() + + cmd = ( + f"SELECT price_uid FROM regp_sync_100 WHERE " + f"p_node_alias = '{req.PNodeAlias}' AND " + f"start_year_utc = {req.StartYearUtc} AND " + f"start_month_utc = {req.StartMonthUtc} AND " + f"start_day_utc = {req.StartDayUtc} AND " + f"start_hour_utc = {req.StartHourUtc} AND " + f"start_minute_utc = {req.StartMinuteUtc} AND " + f"method_alias = '{req.MethodAlias}' AND " + f"currency_unit = '{req.CurrencyUnit}' AND " + f"slice_duration_hrs = '{slice_duration_hr_string}'" + ) + + rows = cursor.execute(cmd).fetchall() + cursor.close() + db.close() + if len(rows) == 0: + file = create_new_regp_sync_100_and_return_filename(req) + else: + price_uid = rows[0][0] + db = sqlite3.connect(db_file) + cursor = db.cursor() + cmd = f"SELECT file_name FROM csv_file_by_key WHERE type_name = 'csv.regp.sync.100' AND price_uid = '{price_uid}'" + rows = cursor.execute(cmd).fetchall() + if len(rows) == 0: + raise Exception( + f"PriceUid {price_uid} is in table regp_sync_100 but no match in csv_file_by_key!!" + ) + cursor.close() + db.close() + file = rows[0][0] + payload = Csv_Regp_Sync_1_0_0(file).paired_rabbit_payload(agent=agent) + is_valid, errors = payload.is_valid() + if not is_valid: + raise SchemaError(f"Errors making payload: {errors}") + return payload + + +def create_new_regp_sync_100_and_return_filename( + req: RGnodeRegpSyncReq100Payload, +) -> str: + flo_start = pendulum.datetime( + year=req.StartYearUtc, + month=req.StartMonthUtc, + day=req.StartDayUtc, + hour=req.StartHourUtc, + minute=req.StartMinuteUtc, + ) + prices = [] + comment = "" + while len(prices) < req.TotalSlices: + orig_length = len(prices) + prices, new_comment = get_expanded_prices_and_comment(prices=prices, req=req) + if len(prices) == orig_length: + raise Exception( + f"No source file for reg prices after {len(prices)} slices with PNodeAlias {req.PNodeAlias}, MethodAlias {req.MethodAlias}, CurrencyUnit {req.CurrencyUnit} with flo starting {flo_start}') " + ) + comment += new_comment + if len(prices) == 0: + raise Exception(f"No source price data found") + + new = R_Regp_Sync_1_0_0( + agent=test_dummy.TEST_DUMMY_AGENT, + p_node_alias=req.PNodeAlias, + method_alias=req.MethodAlias, + comment=comment, + start_utc=flo_start, + uniform_slice_duration_hrs=req.UniformSliceDurationHrs, + timezone_string=req.TimezoneString, + currency_unit=req.CurrencyUnit, + price_uid=str(uuid.uuid4()), + prices=prices, + ).payload + + slice_duration_hr_string = f"[{new.UniformSliceDurationHrs}] * {len(new.Prices)}" + new_file = create_new_regp_sync_csv_from_payload(c=new) + db_file = "src/satn/dev_utils/price/forecast_data/ps_db.sqlite3" + db = sqlite3.connect(db_file) + cursor = db.cursor() + cmd = f"INSERT INTO regp_sync_100 (price_uid, p_node_alias, start_year_utc, start_month_utc, start_day_utc, start_hour_utc, start_minute_utc, method_alias, currency_unit, slice_duration_hrs) VALUES ('{new.PriceUid}','{new.PNodeAlias}',{new.StartYearUtc},{new.StartMonthUtc},{new.StartDayUtc},{new.StartHourUtc},{new.StartMinuteUtc},'{new.MethodAlias}', '{new.CurrencyUnit}','{slice_duration_hr_string}')" + cursor.execute(cmd) + cmd = f"INSERT INTO csv_file_by_key (price_uid, type_name, file_name) VALUES ('{new.PriceUid}','csv.regp.sync.1_0_0','{new_file}')" + cursor.execute(cmd) + db.commit() + cursor.close() + db.close() + return new_file + + +def create_new_regp_sync_csv_from_payload(c: RegpSync100Payload): + iso = c.PNodeAlias.split(".")[1] + uid_pre = c.PriceUid.split("-")[0] + hours = int(c.UniformSliceDurationHrs * len(c.Prices)) + file = f"src/satn/dev_utils/price/forecast_data/{iso}/regp__{c.PNodeAlias}__{c.StartYearUtc}__{hours}__{c.MethodAlias}__{uid_pre}.csv" + print(f"Creating new reg price file {file}") + lines = [ + f"MpAlias,csv.regp.sync.1_0_0\n", + f"PNodeAlias,{c.PNodeAlias}\n", + f"MethodAlias,{c.MethodAlias}\n", + f"Comment,{c.Comment}\n", + f"StartYearUtc,{c.StartYearUtc}\n", + f"StartMonthUtc,{c.StartMonthUtc}\n", + f"StartDayUtc,{c.StartDayUtc}\n", + f"StartHourUtc,{c.StartHourUtc}\n", + f"StartMinuteUtc,{c.StartMinuteUtc}\n", + f"UniformSliceDurationHrs,{c.UniformSliceDurationHrs}\n", + f"TimezoneString,{c.TimezoneString}\n", + f"CurrencyUnit,{c.CurrencyUnit}\n", + f"PriceUid,{c.PriceUid}\n", + f"Header,Regulation Price (Currency Unit/MWh)\n", + ] + for price in c.Prices: + lines.append(f"{price}\n") + with open(file, "w") as outfile: + outfile.writelines(lines) + return file + + +def get_expanded_prices_and_comment(prices: list, req: RGnodeRegpSyncReq100Payload): + regp_source_file_by_uid = price_source_files.get_regp_sync_source_file_by_uid() + flo_start_utc = pendulum.datetime( + year=req.StartYearUtc, + month=req.StartMonthUtc, + day=req.StartDayUtc, + hour=req.StartHourUtc, + minute=req.StartMinuteUtc, + ) + flo_end = flo_start_utc + pendulum.duration( + hours=req.TotalSlices * req.UniformSliceDurationHrs + ) + marker_time = flo_start_utc + pendulum.duration( + hours=len(prices) * req.UniformSliceDurationHrs + ) + db_file = "src/satn/dev_utils/price/forecast_data/ps_db.sqlite3" + db = sqlite3.connect(db_file) + cursor = db.cursor() + + cmd = ( + f"SELECT price_uid FROM regp_sync_100 WHERE p_node_alias = '{req.PNodeAlias}' AND " + f"method_alias = '{req.MethodAlias}' AND " + f"currency_unit = '{req.CurrencyUnit}'" + ) + + rows = cursor.execute(cmd).fetchall() + cursor.close() + db.close() + price_uids = list( + filter(lambda x: x in regp_source_file_by_uid.keys(), map(lambda x: x[0], rows)) + ) + if len(price_uids) == 0: + raise Exception( + f"That is strange. No price source file matches PNodeAlias {req.PNodeAlias}, MethodAlias {req.MethodAlias}, CurrencyUnit {req.CurrencyUnit}" + ) + + candidate_start = {} + candidate_final_slice_start = {} + slice_d = {} + for uid in price_uids: + file = f"../gridworks-ps/{regp_source_file_by_uid[uid]}" + c = Csv_Regp_Sync_1_0_0(file).payload + csv_start_utc = pendulum.datetime( + year=c.StartYearUtc, + month=c.StartMonthUtc, + day=c.StartDayUtc, + hour=c.StartHourUtc, + minute=c.StartMinuteUtc, + ) + candidate_start[uid] = csv_start_utc + candidate_final_slice_start[uid] = csv_start_utc + pendulum.duration( + hours=c.UniformSliceDurationHrs * (len(c.Prices) - 1) + ) + slice_d[uid] = c.UniformSliceDurationHrs + price_uids = list( + filter( + lambda x: candidate_start[x] <= marker_time + and slice_d[x] == req.UniformSliceDurationHrs, + price_uids, + ) + ) + if len(price_uids) == 0: + raise Exception( + f"No source file for reg prices with PNodeAlias {req.PNodeAlias}, MethodAlias {req.MethodAlias}, CurrencyUnit {req.CurrencyUnit} with flo starting {flo_start_utc}" + ) + + best_uid = max(price_uids, key=lambda x: candidate_final_slice_start[x] - flo_end) + if candidate_final_slice_start[best_uid] < flo_start_utc: + raise Exception( + f"No source file for reg prices with PNodeAlias {req.PNodeAlias}, MethodAlias {req.MethodAlias}, CurrencyUnit {req.CurrencyUnit} with flo starting {flo_start_utc}" + ) + + file = f"../gridworks-ps/{regp_source_file_by_uid[best_uid]}" + + c = Csv_Regp_Sync_1_0_0(file).payload + + offset_row = int( + (flo_start_utc - candidate_start[best_uid]).total_seconds() + / 3600 + / c.UniformSliceDurationHrs + ) + if marker_time > candidate_final_slice_start[best_uid]: + can_add_value = False + else: + can_add_value = True + + i = offset_row + while can_add_value: + prices.append(c.Prices[i]) + marker_time += pendulum.duration(hours=c.UniformSliceDurationHrs) + i += 1 + if ( + marker_time > candidate_final_slice_start[best_uid] + or len(prices) >= req.TotalSlices + ): + can_add_value = False + comment = f"Used {i - offset_row} slices from {regp_source_file_by_uid[best_uid]}" + return prices, comment diff --git a/src/gwatn/enums/__init__.py b/src/gwatn/enums/__init__.py index 8943488..e9e6f9f 100644 --- a/src/gwatn/enums/__init__.py +++ b/src/gwatn/enums/__init__.py @@ -27,6 +27,12 @@ # From gwatn from gwatn.enums.distribution_tariff import DistributionTariff from gwatn.enums.energy_supply_type import EnergySupplyType + +# hacks +from gwatn.enums.hack_price_method import PriceMethod +from gwatn.enums.hack_recognized_p_node_alias import RecognizedPNodeAlias +from gwatn.enums.hack_weather_method import WeatherMethod +from gwatn.enums.hack_weather_source import WeatherSource from gwatn.enums.recognized_irradiance_type import RecognizedIrradianceType from gwatn.enums.recognized_temperature_unit import RecognizedTemperatureUnit @@ -56,4 +62,8 @@ "TelemetryName", "Unit", "UniverseType", + "PriceMethod", + "RecognizedPNodeAlias", + "WeatherMethod", + "WeatherSource", ] diff --git a/src/gwatn/enums/distribution_tariff.py b/src/gwatn/enums/distribution_tariff.py index 9046fb6..a148605 100644 --- a/src/gwatn/enums/distribution_tariff.py +++ b/src/gwatn/enums/distribution_tariff.py @@ -11,9 +11,9 @@ class DistributionTariff(StrEnum): Choices and descriptions: * Unknown: - * VersantA1StorageHeatTariff: Versant is a utility serving customers in Maine, and in particular serves much of the area behind the Keene Rd Constraint in the [GridWorks Millinocket Demo](https://gridworks.readthedocs.io/en/latest/millinocket-demo.html#background). Alternately known as the "Home Eco Rate With Bonus Meter, Time-of-Use". Look for rate A1 in Versant [rate schedules](https://www.versantpower.com/residential/rates/rates-schedules/); details are also available [here](https://drive.google.com/drive/u/0/folders/1mhIeNj2JWVyIJrQnSHmBDOkBpNnRRVKB). More: Service under this rate will be available to residential customers with thermal energy storage devices, electric battery storage devices, and/or vehicle chargers who agree to install a second metered point of delivery. The customer will be subject to inspections to ensure that the thermal storage device, electric battery storage device, and electric vehicle charger(s) are sized appropriately for residential use. If the thermal storage device, electric battery storage device, and electric vehicle charger(s) do not pass Company inspection, then the service will be denied. Service will be single-phase, alternating current, 60 hertz, at one standard secondary distribution voltage. Customers taking service under this rate schedule are responsible for paying both Distribution Service and Stranded Cost.. [More Info](https://github.com/thegridelectric/gridworks-ps/blob/dev/input_data/electricity_prices/isone/distp__w.isone.stetson__2020__gw.me.versant.a1.res.ets.csv). + * VersantA1StorageHeatTariff: Versant is a utility serving customers in Maine, and in particular serves much of the area behind the Keene Rd Constraint in the [GridWorks Millinocket Demo](https://gridworks.readthedocs.io/en/latest/millinocket-demo.html#background). Alternately known as the 'Home Eco Rate With Bonus Meter, Time-of-Use.' Look for rate A1 in Versant [rate schedules](https://www.versantpower.com/residential/rates/rates-schedules/); details are also available [here](https://drive.google.com/drive/u/0/folders/1mhIeNj2JWVyIJrQnSHmBDOkBpNnRRVKB). More: Service under this rate will be available to residential customers with thermal energy storage devices, electric battery storage devices, and/or vehicle chargers who agree to install a second metered point of delivery. The customer will be subject to inspections to ensure that the thermal storage device, electric battery storage device, and electric vehicle charger(s) are sized appropriately for residential use. If the thermal storage device, electric battery storage device, and electric vehicle charger(s) do not pass Company inspection, then the service will be denied. Service will be single-phase, alternating current, 60 hertz, at one standard secondary distribution voltage. Customers taking service under this rate schedule are responsible for paying both Distribution Service and Stranded Cost. See attached csv for instantiation of this rate as an 8760.. [More Info](https://github.com/thegridelectric/gridworks-ps/blob/dev/input_data/electricity_prices/isone/distp__w.isone.stetson__2022__gw.me.versant.a1.res.ets.csv). * VersantATariff: Versant is a utility serving customers in Maine, and in particular serves much of the area behind the Keene Rd Constraint in the [GridWorks Millinocket Demo](https://gridworks.readthedocs.io/en/latest/millinocket-demo.html#background). The A Tariff is their standard residential tariff. Look for rate A in Versant [rate schedules](https://www.versantpower.com/residential/rates/rates-schedules/) - * VersantA20HeatTariff: + * VersantA20HeatTariff: Versant is a utility serving customers in Maine, and in particular serves much of the area behind the Keene Rd Constraint in the [GridWorks Millinocket Demo](https://gridworks.readthedocs.io/en/latest/millinocket-demo.html#background). This is an alternative tariff available for electric heat. """ Unknown = auto() diff --git a/src/gwatn/strategies/brick_storage_heater/atn.py b/src/gwatn/strategies/brick_storage_heater/atn.py index d0b197a..010df69 100644 --- a/src/gwatn/strategies/brick_storage_heater/atn.py +++ b/src/gwatn/strategies/brick_storage_heater/atn.py @@ -247,7 +247,7 @@ def respond_to_price(self) -> None: power_watts=self._power_watts, store_kwh=int(self.store_kwh), max_store_kwh=int( - strategy_utils.get_max_store_kwh_th( + strategy_utils.get_max_energy_kwh( max_brick_temp_c=self.atn_params.MaxBrickTempC, c=self.atn_params.C, room_temp_f=self.atn_params.RoomTempF, @@ -319,7 +319,7 @@ def update_store_level(self) -> None: # self.store_kwh = ... self.store_kwh = ( store_idx - * strategy_utils.get_max_store_kwh_th( + * strategy_utils.get_max_energy_kwh( max_brick_temp_c=self.atn_params.MaxBrickTempC, c=self.atn_params.C, room_temp_f=self.atn_params.RoomTempF, @@ -504,7 +504,7 @@ def store_idx(self) -> int: return round( self.atn_params.StorageSteps * self.store_kwh - / strategy_utils.get_max_store_kwh_th( + / strategy_utils.get_max_energy_kwh( max_brick_temp_c=self.atn_params.MaxBrickTempC, c=self.atn_params.C, room_temp_f=self.atn_params.RoomTempF, diff --git a/src/gwatn/strategies/brick_storage_heater/flo.py b/src/gwatn/strategies/brick_storage_heater/flo.py index 2fc47c4..fa1ee0f 100644 --- a/src/gwatn/strategies/brick_storage_heater/flo.py +++ b/src/gwatn/strategies/brick_storage_heater/flo.py @@ -37,7 +37,7 @@ def __init__( [0] * len(self.params.RealtimeElectricityPrice) ) - self.max_energy_kwh_th = strategy_utils.get_max_store_kwh_th(self.params) + self.max_energy_kwh_th = strategy_utils.get_max_energy_kwh(self.params) self.currency_unit = self.params.CurrencyUnit self.temp_unit = self.params.TempUnit diff --git a/src/gwatn/strategies/brick_storage_heater/flo_output.py b/src/gwatn/strategies/brick_storage_heater/flo_output.py index ff2a5f9..b392ccf 100644 --- a/src/gwatn/strategies/brick_storage_heater/flo_output.py +++ b/src/gwatn/strategies/brick_storage_heater/flo_output.py @@ -436,10 +436,10 @@ def export_params_xlsx( w.write("E8", flo.params.ZeroPotentialEnergyWaterTempF) w.write("D9", "TotalStorageKwh", derived_format_bold) - w.write("E9", round(flo.max_energy_kwh_th, 1), derived_format) + w.write("E9", round(flo.max_energy_kwh, 1), derived_format) w.write("D10", "TotalStorage BTU", derived_format_bold) - w.write("E10", round(cf.BTU_PER_KWH * flo.max_energy_kwh_th), derived_format) + w.write("E10", round(cf.BTU_PER_KWH * flo.max_energy_kwh), derived_format) w.write("D12", "EmitterPumpFeedbackModel", bold) w.write("E12", flo.params.EmitterPumpFeedbackModel.value) diff --git a/src/gwatn/strategies/simple_resistive_hydronic/atn.py b/src/gwatn/strategies/simple_resistive_hydronic/atn.py index d0b197a..cd4fe38 100644 --- a/src/gwatn/strategies/simple_resistive_hydronic/atn.py +++ b/src/gwatn/strategies/simple_resistive_hydronic/atn.py @@ -1,12 +1,10 @@ -""" BrickStorageHeater AtomicTNode Strategy. +""" SimpleResistiveHydronic AtomicTNode Strategy. - This is a heating and bidding strategy for a thermal storage heater that is - designed to heat part or all of a single room. The storage medium is ceramic - bricks and the heating source is resistive elements embedded in those bricks. + This is a heating and bidding strategy for a simple resistive hydronic heating + system: one or more 120 gallon domestic hot water tanks heated with resistive + elements and providing hot water to a hydronic heating system. - This kind of heater is often called a Night Storage Heater in the UK, and - is often also referred to (ambigiously, since there are other kinds) as - an Electric Thermal Storage (ETS) heater. + [schematic](https://gridworks-atn.readthedocs.io/en/latest/simple-resistive-hydronic.html) """ import functools import logging @@ -28,13 +26,13 @@ from gridworks.algo_utils import BasicAccount from gridworks.data_classes.market_type import Rt60Gate30B from gridworks.utils import RestfulResponse +from satn.strategies.simple_resistive_hydronic import Edge__BrickStorageHeater as Edge import gwatn.atn_utils as atn_utils import gwatn.brick_storage_heater.dev_io as dev_io import gwatn.brick_storage_heater.strategy_utils as strategy_utils import gwatn.config as config from gwatn.atn_actor_base import AtnActorBase -from gwatn.brick_storage_heater.edge import Edge__BrickStorageHeater as Edge from gwatn.brick_storage_heater.flo import Flo__BrickStorageHeater as Flo from gwatn.brick_storage_heater.strategy_utils import SlotStuff from gwatn.enums import GNodeRole @@ -43,8 +41,8 @@ from gwatn.enums import UniverseType from gwatn.types import AcceptedBid_Maker from gwatn.types import AtnParams -from gwatn.types import AtnParamsBrickstorageheater from gwatn.types import AtnParamsReport_Maker +from gwatn.types import AtnParamsSimpleresistivehydronic from gwatn.types import HeartbeatA from gwatn.types import HeartbeatA_Maker from gwatn.types import LatestPrice @@ -66,9 +64,8 @@ LOGGER.setLevel(logging.WARNING) -class Atn__BrickStorageHeater(AtnActorBase): - """AtomicTNode HeatPumpWithBoostStore strategy for thermal storage heat pump - space heating system""" +class Atn__SimpleResistiveHydronic(AtnActorBase): + """AtomicTNode SimpleResistiveHydronic strategy""" def __init__( self, @@ -77,7 +74,7 @@ def __init__( ), ): super().__init__(settings) - LOGGER.info("Initializing HeatPumpWithBoostStore Atn") + LOGGER.info("Initializing SimpleResistiveHydronic Atn") self.algo_acct: BasicAccount = BasicAccount(settings.sk.get_secret_value()) self.algo_client: AlgodClient = AlgodClient( settings.algo_api_secrets.algod_token.get_secret_value(), @@ -247,7 +244,7 @@ def respond_to_price(self) -> None: power_watts=self._power_watts, store_kwh=int(self.store_kwh), max_store_kwh=int( - strategy_utils.get_max_store_kwh_th( + strategy_utils.get_max_energy_kwh( max_brick_temp_c=self.atn_params.MaxBrickTempC, c=self.atn_params.C, room_temp_f=self.atn_params.RoomTempF, @@ -319,7 +316,7 @@ def update_store_level(self) -> None: # self.store_kwh = ... self.store_kwh = ( store_idx - * strategy_utils.get_max_store_kwh_th( + * strategy_utils.get_max_energy_kwh( max_brick_temp_c=self.atn_params.MaxBrickTempC, c=self.atn_params.C, room_temp_f=self.atn_params.RoomTempF, @@ -504,7 +501,7 @@ def store_idx(self) -> int: return round( self.atn_params.StorageSteps * self.store_kwh - / strategy_utils.get_max_store_kwh_th( + / strategy_utils.get_max_energy_kwh( max_brick_temp_c=self.atn_params.MaxBrickTempC, c=self.atn_params.C, room_temp_f=self.atn_params.RoomTempF, diff --git a/src/gwatn/strategies/simple_resistive_hydronic/dev_io.py b/src/gwatn/strategies/simple_resistive_hydronic/dev_io.py index 8027b88..6ce3d10 100644 --- a/src/gwatn/strategies/simple_resistive_hydronic/dev_io.py +++ b/src/gwatn/strategies/simple_resistive_hydronic/dev_io.py @@ -8,16 +8,16 @@ from typing import Optional import pendulum -import satn.strategies.heatpumpwithbooststore.strategy_utils as strategy_utils from pydantic import BaseModel -from satn.strategies.heatpumpwithbooststore.strategy_utils import SlotStuff -from satn.types import AtnParamsHeatpumpwithbooststore as AtnParams -from satn.types import AtnParamsHeatpumpwithbooststore_Maker as AtnParams_Maker -from satn.types import FloParamsHeatpumpwithbooststore as FloParams import gwatn.atn_utils as atn_utils +import gwatn.strategies.simple_resistive_hydronic.strategy_utils as strategy_utils +from gwatn.strategies.simple_resistive_hydronic.strategy_utils import SlotStuff from gwatn.types import AtnBid from gwatn.types import AtnParamsReport_Maker +from gwatn.types import AtnParamsSimpleresistivehydronic as AtnParams +from gwatn.types import AtnParamsSimpleresistivehydronic_Maker as AtnParams_Maker +from gwatn.types import FloParamsSimpleresistivehydronic as FloParams from gwatn.types import MarketSlot from gwatn.types.ps_distprices_gnode.csv_distp_sync.csv_distp_sync_1_0_0 import ( Csv_Distp_Sync_1_0_0, @@ -40,8 +40,8 @@ DATA_DIR = "input_data" EVENTSTORE_DIR = f"{DATA_DIR}/eventstore" -ELEC_PRICE_FILE = "input_data/electricity_prices/isone/eprt__w.isone.stetson__2020.csv" -DIST_PRICE_FILE = "input_data/electricity_prices/isone/distp__w.isone.stetson__2020__gw.me.versant.a1.res.ets.csv" +ELEC_PRICE_FILE = "../gridworks-ps/input_data/electricity_prices/isone/eprt__w.isone.stetson__2020.csv" +DIST_PRICE_FILE = "../gridworks-ps/input_data/electricity_prices/isone/distp__w.isone.stetson__2020__gw.me.versant.a1.res.ets.csv" WEATHER_PRICE_FILE = ( "input_data/weather/us/me/temp__ws.us.me.millinocketairport__2020.csv" ) diff --git a/src/gwatn/strategies/simple_resistive_hydronic/edge.py b/src/gwatn/strategies/simple_resistive_hydronic/edge.py index 1314e37..734cc4e 100644 --- a/src/gwatn/strategies/simple_resistive_hydronic/edge.py +++ b/src/gwatn/strategies/simple_resistive_hydronic/edge.py @@ -1,4 +1,4 @@ -""" Heatpumpwithbooststore Flo Edge Class Definition """ +""" SimpleResistiveHydronic Flo Edge Class Definition """ from typing import Optional from typing import no_type_check @@ -8,7 +8,7 @@ SIG_FIGS_FOR_OUTPUT = 6 -class Edge__BrickStorageHeater(DEdge): +class Edge_SimpleResistiveHydronic(DEdge): def __init__( self, start_ts_idx: int, @@ -29,5 +29,7 @@ def __init__( def __repr__(self) -> str: rep = f"DEdge => Time Slice Idx: {self.start_ts_idx}, StartIdx: {self.start_idx}, EndIdx: {self.end_idx}" if self.cost is not None: - rep += f" Cost => {self.cost}" + rep += f", Cost => ${round(self.cost, 3)}" + if self.avg_kw is not None: + rep += f", Avg Power => {round(self.avg_kw, 2)} kW" return rep diff --git a/src/gwatn/strategies/simple_resistive_hydronic/flo.py b/src/gwatn/strategies/simple_resistive_hydronic/flo.py index 2fc47c4..11f8fe5 100644 --- a/src/gwatn/strategies/simple_resistive_hydronic/flo.py +++ b/src/gwatn/strategies/simple_resistive_hydronic/flo.py @@ -7,44 +7,48 @@ from typing import Optional from typing import no_type_check +import gridworks.conversion_factors as cf import numpy as np import pendulum -import gwatn.brick_storage_heater.strategy_utils as strategy_utils -from gwatn.brick_storage_heater.edge import Edge__BrickStorageHeater as Edge -from gwatn.brick_storage_heater.node import Node_BrickStorageHeater as Node +import gwatn.strategies.simple_resistive_hydronic.flo_utils as flo_utils from gwatn.data_classes.d_graph import DGraph -from gwatn.types import FloParamsBrickstorageheater as FloParams +from gwatn.strategies.simple_resistive_hydronic.edge import ( + Edge_SimpleResistiveHydronic as Edge, +) +from gwatn.strategies.simple_resistive_hydronic.node import ( + Node_SimpleResistiveHydronic as Node, +) +from gwatn.types import FloParamsSimpleresistivehydronic -class Flo__BrickStorageHeater(DGraph): +class Flo_SimpleResistiveHydronic(DGraph): MAGIC_HEAT_PUMP_DELTA_F = 20 FAILED_HEATING_PENALTY_DOLLARS = 10**6 def __init__( self, - params: FloParams, + params: FloParamsSimpleresistivehydronic, d_graph_id: str, ): - self.params = params + """ + Args: + params: + d_graph_id: + """ + self.toast = 1 + self.params: FloParamsSimpleresistivehydronic = params self.RealtimeElectricityPrice = np.array(self.params.RealtimeElectricityPrice) self.DistributionPrice = np.array(self.params.DistributionPrice) - if self.params.IsRegulating: - self.reg_price_per_mwh = np.array(self.params.RegulationPrice) - else: - self.reg_price_per_mwh = np.array( - [0] * len(self.params.RealtimeElectricityPrice) - ) - self.max_energy_kwh_th = strategy_utils.get_max_store_kwh_th(self.params) + self.max_energy_kwh = flo_utils.get_max_energy_kwh(self.params) self.currency_unit = self.params.CurrencyUnit - self.temp_unit = self.params.TempUnit DGraph.__init__( self, d_graph_id=d_graph_id, - graph_strategy_alias="SpaceHeat__HeatPumpWithBoostStore__Flo", + graph_strategy_alias="SimpleResistiveHydronic", flo_start_unix_time_s=pendulum.datetime( year=self.params.StartYearUtc, month=self.params.StartMonthUtc, @@ -58,33 +62,86 @@ def __init__( timezone_string=self.params.TimezoneString, home_city=self.params.HomeCity, currency_unit=self.params.CurrencyUnit, - max_storage=self.max_energy_kwh_th, - max_power_in=self.params.RatedMaxPowerKw, + max_storage=self.max_energy_kwh, + max_power_in=self.params.RatedPowerKw, wh_exponent=3, ) - self.e_step: float = self.max_energy_kwh_th / self.params.StorageSteps - room_temp_c = (self.params.RoomTempF - 32) * 5 / 9 - temp_range_c = self.params.MaxBrickTempC - room_temp_c - self.temp_step_c: float = temp_range_c / self.params.StorageSteps - self.store_kwh_per_deg_c = self.max_energy_kwh_th / temp_range_c + # Override base class dictionaries with SimpleResistiveHydronic objects + self.node: Dict[int, Dict[int, Node]] = {} + """ + self.node is a dictionary representing the nodes in the graph. + + The dictionary structure is as follows: + - The outer dictionary's keys represent the time slice index (jj). + - The inner dictionary's keys represent the energy index (kk). + - The corresponding value is the node with the given time slice index (jj) and energy index (kk). + + Example usage: self.node[jj][kk] returns the node with time slice index jj and energy index kk. + """ + + self.edge: Dict[int, Dict[int, Dict[int, Edge]]] = {} + """ + self.edge is a dictionary representing the edges in the graph. Each edge is an object + determined by its starting timeslice index (start_ts_idx), its starting store index + (start_idx) and its ending store index (end_idx) + + The dictionary structure as follows: + - The first key represents the starting timeslice index + - The second key represents the starting store index + - The third key represents the final store index + + Example usage: self.edge[ts_idx][start_idx][end_idx] returns the edge starting at the node + with time slice index start_ts_idx and store index start_idx, and ending with the node + with time slice index start_ts_idx + 1 and store end_idx. + """ + + self.best_edge: Dict[Node, Edge] = {} + """ + self.best_edge[node] is the optimal edge choice going forward from node. + + That is, its a dictionary mapping each node to the best next edge on the least-cost path + going forward to the end of the graph from that node. This dictionary is filled out in the + `solve_dijkstra` method, as the key step in using Dijkstra's algorithm, which solves + for the least-cost path not only for the starting node but for all nodes in the graph. + """ + + self.e_step_wh: float = 1000 * self.max_energy_kwh / self.params.StorageSteps + """ + The increment of energy, in watt-hours, associated to incrementing the store index by 1. + """ self.energy_cost_per_kwh: Dict[int, float] = {} self.set_energy_cost_per_kwh() - self.uncosted_edges: Dict[Edge, int] = {} - self.failed_in_cost_boost_preferred: int = 0 - self.failed_in_cost_hp_preferred: int = 0 + self.uncosted_edges: Dict[ + Edge, int + ] = {} # using dict keys instead of list for faster searching self.create_graph() self.solve_dijkstra() def create_slice_nodes(self, ts_idx: int) -> None: - """Creates nodes for time slice ts_idx, equally spaced by self.e_step, - running from params.ZeroPotentialEnergyWaterTempF up to params.MaxStoreTempF. + """ + Create slice nodes for a given timeslice index. + + This function creates and initializes slice nodes for a specific timeslice index. + Each slice node represents a storage step and stores the energy at that step in Wh. + + Args: + ts_idx (int): The timeslice index. - Sets the energy store in kwh of enthalpy (latent heat in this case) as well - as the average boost water temp. + Returns: + None + Note: + The created slice nodes are stored in the `node` attribute of the object. """ - ... + self.node[ts_idx] = {} + for store_idx in range(self.params.StorageSteps + 1): + new_node = Node( + ts_idx=ts_idx, + store_idx=store_idx, + energy_wh=store_idx * self.e_step_wh, + ) + self.node[ts_idx][store_idx] = new_node def create_nodes(self) -> None: for ts_idx in range(self.time_slices + 1): @@ -124,49 +181,89 @@ def get_failed_edge(self, node: Node) -> Edge: self.set_failed_edge_properties(edge) return edge - def get_uncosted_edge( - self, node: Node, delta_energy_kwh: float, existing_edges: List[Edge] = [] - ) -> Optional[Edge]: - """Finds a randomized passing edge consistent with delta_energy_kwh. - For example, if delta_energy_kwh falls exactly half way between - two energy steps it will return each step with probability 50% - """ - if node.store_enthalpy_kwh + delta_energy_kwh < 0: - return None - delta_idx_as_float = delta_energy_kwh / self.e_step - lower_delta_idx = math.floor(delta_idx_as_float) - frac = delta_idx_as_float - lower_delta_idx - random_adder = random.choices(population=[0, 1], weights=[1 - frac, frac], k=1)[ - 0 - ] - if node.store_idx + lower_delta_idx == self.params.StorageSteps: - delta_idx = lower_delta_idx - else: - delta_idx = lower_delta_idx + random_adder - edge = Edge( - start_ts_idx=node.ts_idx, - start_idx=node.store_idx, - end_idx=node.store_idx + delta_idx, + def create_uncosted_edges_from_node(self, node: Node) -> None: + """Creates edges starting at a node.""" + edges: List[Edge] = [] + self.edge[node.ts_idx][node.store_idx] = {} + + slice_hrs = self.params.SliceDurationMinutes[node.ts_idx] / 60 + requested_energy_wh = ( + self.params.PowerLostFromHouseKwList[node.ts_idx] * 1000 * slice_hrs + ) + passive_loss_wh = flo_utils.get_passive_loss_wh( + self.params, node.ts_idx, node.store_idx + ) + real_next_e_min_wh = node.energy_wh - requested_energy_wh - passive_loss_wh + + # note this is clearly an idealization. For example, in the summer when it is hot + # and there is no need for heat, our model shows the tank staying at return water temp + # with no passive loss. + idealized_next_e_min_wh = max(0.0, real_next_e_min_wh) + + real_next_e_max_wh = ( + node.energy_wh + + self.params.RatedPowerKw * 1000 + - requested_energy_wh + - passive_loss_wh ) - if edge not in existing_edges: + real_next_e_max_wh = min(real_next_e_max_wh, self.max_energy_kwh * 1000) + idealized_next_e_max_wh = max(0.0, real_next_e_max_wh) + + min_next_idx = round(idealized_next_e_min_wh / self.e_step_wh) + max_next_idx = round(idealized_next_e_max_wh / self.e_step_wh) + + if min_next_idx > max_next_idx: + raise Exception(f"Conceptual error in get_uncosted_edges_from_node! Fix!!") + + for i in range(min_next_idx, max_next_idx + 1): + edge = Edge( + start_ts_idx=node.ts_idx, + start_idx=node.store_idx, + end_idx=i, + ) self.uncosted_edges[edge] = 1 - return edge - else: - # in this case the edge is already in the existing_edges, so return None - return None + self.edge[node.ts_idx][node.store_idx][i] = edge - def get_edges_from_node(self, node: Node) -> List[Edge]: - """Creates edges starting at a node.""" - ts_idx = node.ts_idx - return [] + def create_uncosted_edges(self): + for jj in range(self.time_slices): + # if jj % 500 == 0: + # print(f"{time.time() - st:1.0f} seconds for {jj} slices") + self.edge[jj] = {} + for node in self.node[jj].values(): + self.create_uncosted_edges_from_node(node=node) def set_edge_cost(self, edge: Edge) -> None: """Given an edge: - - set cost to penalty if there is no way to meet the energy requirements - - otherwise set the cost for the optimal choice of using the boost and - the heat pump + - set the edge cost to the cost of electricity required to buy the amount of electricity + needed to meet the heating requests of the house following this edge """ - ... + node = self.node[edge.start_ts_idx][edge.start_idx] + slice_hrs = self.params.SliceDurationMinutes[node.ts_idx] / 60 + requested_energy_wh = ( + self.params.PowerLostFromHouseKwList[node.ts_idx] * 1000 * slice_hrs + ) + passive_loss_wh = flo_utils.get_passive_loss_wh( + self.params, node.ts_idx, node.store_idx + ) + + delta_store_wh = (edge.end_idx - edge.start_idx) * self.e_step_wh + + boost_e_used_wh = delta_store_wh + requested_energy_wh + passive_loss_wh + if boost_e_used_wh < -self.e_step_wh / 2: + raise Exception( + f"Trouble with edge {edge}! boost energy {round(boost_e_used_wh / 1000,2)} kW " + f"is less than 0 by more than half an e step {round(self.e_step_wh / 2, 2)} kW." + ) + + boost_e_used_kwh = boost_e_used_wh / 1000 + + edge.cost = boost_e_used_kwh * self.energy_cost_per_kwh[edge.start_ts_idx] + edge.avg_kw = boost_e_used_kwh / slice_hrs + + def add_all_edge_costs(self) -> None: + for edge in self.uncosted_edges.keys(): + self.set_edge_cost(edge) + self.uncosted_edges = {} def create_graph(self) -> None: # print(f"Creating graph nodes, edges and edge weights") @@ -174,17 +271,10 @@ def create_graph(self) -> None: self.create_nodes() nt = time.time() # print(f"{time.time() - st:1.0f} seconds to make nodes") - for jj in range(self.time_slices): - # if jj % 500 == 0: - # print(f"{time.time() - st:1.0f} seconds for {jj} slices") - for node in self.node[jj].values(): - edges = self.get_edges_from_node(node=node) - self.edges[node] = edges + self.create_uncosted_edges() et = time.time() # print(f"{et - nt:1.0f} seconds for building edges") - for edge in self.uncosted_edges.keys(): - self.set_edge_cost(edge) - self.uncosted_edges = {} + self.add_all_edge_costs() ct = time.time() # print(f"{ct - et:1.0f} seconds for costing edges") tt = time.time() - st diff --git a/src/gwatn/strategies/simple_resistive_hydronic/flo_output.py b/src/gwatn/strategies/simple_resistive_hydronic/flo_output.py index ff2a5f9..cd22a70 100644 --- a/src/gwatn/strategies/simple_resistive_hydronic/flo_output.py +++ b/src/gwatn/strategies/simple_resistive_hydronic/flo_output.py @@ -4,11 +4,16 @@ import pendulum import xlsxwriter -import gwatn.brick_storage_heater.strategy_utils as strategy_utils -from gwatn.brick_storage_heater.edge import Edge__BrickStorageHeater as Edge -from gwatn.brick_storage_heater.flo import Flo__BrickStorageHeater as Flo -from gwatn.brick_storage_heater.node import Node_BrickStorageHeater as Node from gwatn.enums import RecognizedCurrencyUnit +from gwatn.strategies.simple_resistive_hydronic.edge import ( + Edge_SimpleResistiveHydronic as Edge, +) +from gwatn.strategies.simple_resistive_hydronic.flo import ( + Flo_SimpleResistiveHydronic as Flo, +) +from gwatn.strategies.simple_resistive_hydronic.node import ( + Node_SimpleResistiveHydronic as Node, +) from gwatn.types import AtnParamsBrickstorageheater as AtnParams @@ -122,24 +127,17 @@ def export_best_path_info( round(sum(flo.params.OutsideTempF) / len(flo.params.OutsideTempF), 2), bold_format, ) - w.write(8, 0, "COP", header_format) - avg_cop = sum(flo.cop.values()) / len(flo.cop) - w.write(8, 1, round(avg_cop, 2), bold_format) - w.write(9, 0, "House Power Required AvgKw", header_format) + + w.write(9, 0, "Power Lost From House AvgKw", header_format) w.write( 9, 1, - round(sum(flo.params.PowerRequiredByHouseFromSystemAvgKwList), 2), + round(sum(flo.params.PowerLostFromHouseKwList), 2), bold_format, ) w.write(10, 0, "Required Source Water Temp F", header_format) - avg_swt = round((sum(swt_list) / len(swt_list)), 0) - w.write(10, 1, avg_swt, bold_format) - w.write(11, 0, "Max HeatPump kWh thermal", header_format) - avg_max_thermal_hp_kwh = round( - (sum(flo.max_thermal_hp_kwh.values()) / len(flo.max_thermal_hp_kwh)), 2 - ) - w.write(11, 1, avg_max_thermal_hp_kwh, bold_format) + swt = flo.params.RequiredSourceWaterTempF + w.write(10, 1, swt, bold_format) w.write(12, 0, "Outputs", header_format) @@ -149,10 +147,7 @@ def export_best_path_info( w.write(2, jj + 2, local_time.strftime("%m/%d")) w.write(3, jj + 2, local_time.strftime("%H:%M")) w.write(4, jj + 2, flo.RealtimeElectricityPrice[jj], currency_format) - if flo.params.IsRegulating: - w.write(5, jj + 2, flo.reg_price_per_mwh[jj], currency_format) - else: - w.write(5, jj + 2, "", gray_filler_format) + w.write(5, jj + 2, "", gray_filler_format) dp = flo.DistributionPrice[jj] LIGHT_GREEN_HEX = "#bbe3a6" LIGHT_RED_HEX = "#ff6363" @@ -195,7 +190,7 @@ def export_best_path_info( w.write( 9, jj + 2, round(flo.params.PowerRequiredByHouseFromSystemAvgKwList[jj], 2) ) - w.write(10, jj + 2, round(swt_list[jj], 0)) + w.write(10, jj + 2, round(swt, 0)) w.write(11, jj + 2, round(flo.max_thermal_hp_kwh[jj], 2)) w.write(12, jj + 2, "", gray_filler_format) @@ -216,13 +211,12 @@ def export_best_path_info( best_idx = starting_store_idx dist_cost = [] min_dist_price_per_mwh = min(flo.DistributionPrice) - + slice_duration = flo.params.Slice for jj in range(flo.time_slices): edge: Edge = flo.best_edge[node] store_temp_f.append(node.store_avg_water_temp_f) - hp_kwh = edge.hp_electricity_avg_kw + kwh = edge.avg_kw boost_kwh = edge.boost_electricity_used_avg_kw - opt_heatpump_electricity_used_kwh.append(hp_kwh) opt_boost_electricity_used_kwh.append(boost_kwh) opt_energy_cost_dollars.append(edge.cost) hours_since_start = sum(flo.slice_duration_hrs[0:jj]) @@ -346,7 +340,7 @@ def export_params_xlsx( w = workbook.add_worksheet("Params") derived_format_bold = workbook.add_format({"bold": True, "font_color": "green"}) derived_format = workbook.add_format({"font_color": "green"}) - swt_list = flo_utils.get_source_water_temp_f_list(flo.params) + w.set_column("A:A", 31) w.set_column("D:D", 31) w.set_column("G:G", 31) @@ -436,10 +430,10 @@ def export_params_xlsx( w.write("E8", flo.params.ZeroPotentialEnergyWaterTempF) w.write("D9", "TotalStorageKwh", derived_format_bold) - w.write("E9", round(flo.max_energy_kwh_th, 1), derived_format) + w.write("E9", round(flo.max_energy_kwh, 1), derived_format) w.write("D10", "TotalStorage BTU", derived_format_bold) - w.write("E10", round(cf.BTU_PER_KWH * flo.max_energy_kwh_th), derived_format) + w.write("E10", round(cf.BTU_PER_KWH * flo.max_energy_kwh), derived_format) w.write("D12", "EmitterPumpFeedbackModel", bold) w.write("E12", flo.params.EmitterPumpFeedbackModel.value) @@ -483,9 +477,3 @@ def export_params_xlsx( w.write("A33", "DistPriceUid", bold) w.write("B33", flo.params.DistPriceUid) - - if flo.params.IsRegulating: - w.write("A34", "LocalRegulationFile", bold) - w.write("B34", regp_sync_100_handler.csv_file_by_uid(flo.params.RegPriceUid)) - w.write("A35", "RegPriceUid", bold) - w.write("B35", flo.params.RegPriceUid) diff --git a/src/gwatn/strategies/simple_resistive_hydronic/flo_utils.py b/src/gwatn/strategies/simple_resistive_hydronic/flo_utils.py index c8d2c37..822bdeb 100644 --- a/src/gwatn/strategies/simple_resistive_hydronic/flo_utils.py +++ b/src/gwatn/strategies/simple_resistive_hydronic/flo_utils.py @@ -1,230 +1,199 @@ -from typing import List +from gwatn import conversion_factors as cf +from gwatn.errors import PhysicalSystemFailure +from gwatn.types import FloParamsSimpleresistivehydronic -import gridworks.conversion_factors as cf # TODO change to from gwatn import conversion_factors as cf -from satn.enums import ShDistPumpFeedbackModel -from satn.enums import ShMixingValveFeedbackModel -from satn.types import FloParamsHeatpumpwithbooststore as FloParams -import gwatn.errors as errors +def get_max_energy_kwh(params: FloParamsSimpleresistivehydronic) -> float: + """ + Calculates the maximum usable energy stored in the water tanks for a heating system modeled using + the Simple Resistive Hydronic AtomicTNode strategy. + This model assumes idealized stratification, where the water is in a single cylindrical tank + and has two temperatures: a high temperature, `params.MaxStoreTempF`, and a low temperature, + which is the return water temp (RWT) from the distribution system. There is a totally + horizontal thermocline; that is, above a certain height in the tank all water is the max + temp, and below that height all water is the return water temp. -def get_max_store_kwh_th(params: FloParams) -> float: - """Can be duck-typed with AtnParams as well""" - return ( - cf.KWH_TH_PER_GALLON_PER_DEG_F - * params.StoreSizeGallons - * (params.MaxStoreTempF - params.ZeroPotentialEnergyWaterTempF) - ) + The state of maximum energy in the tank is when the entire tank is at the highest temperature. + However, determining the amount of energy in kilowatt-hours (kWh) requires establishing a baseline + energy level. In this calculation, the assumption is made that the baseline energy is the amount + of energy the tank would contain if it were uniformly at the return water temp. This choice + provides a reasonable approximation for the "zero energy" reference in this model. + This model of a water tank heated by resistive elements is highly idealized. -def get_house_worst_case_heat_output_avg_kw(params: FloParams) -> float: - design_t = params.HouseWorstCaseTempF - this_run_t = min(params.OutsideTempF) - room_t = params.RoomTempF - p = params.PowerRequiredByHouseFromSystemAvgKwList - this_run_max_system_kw = max(p) - this_run_max_kw_in = this_run_max_system_kw + params.AmbientPowerInKw - dd_max_kw_in = this_run_max_kw_in * (room_t - design_t) / (room_t - this_run_t) - return dd_max_kw_in - params.AmbientPowerInKw + Args: + params (FloParamsSimpleresistivehydronic): The parameters of the heating system. Specifically, + it uses these attributes of a FloParamsSimpleresistivehydronic object: + - StoreSizeGallons + - MaxStoreTempF + - RequiredSourceWaterTempF + - ReturnWaterDeltaTempF + This function can also be used with objects that can be duck-typed as `AtnParams`. + + Returns: + float: The maximum usable energy stored in kilowatt-hours (kWh) in the water tanks. + """ + return_water_temp_f = params.RequiredSourceWaterTempF - params.ReturnWaterDeltaTempF + store_size_pounds = params.StoreSizeGallons * cf.POUNDS_OF_WATER_PER_GALLON + # Calculate the maximum energy in btus + # Original defn of a BTU: the amount of heat (energy) required to raise the temperature of one + # pound of water by one degree Fahrenheit. https://en.wikipedia.org/wiki/British_thermal_unit + max_energy_btu = store_size_pounds * (params.MaxStoreTempF - return_water_temp_f) -def get_source_water_temp_f_list(params: FloParams) -> List[float]: - """The SourceWaterTemp or SWT is the water in the hydronic pipes - going into the emitters, after the mixing valve. This - temperature is determined by the outside temperature and - is regulated by the emitter circulator pump feedback mechanism and, - when the SWT is above the MaxHeatPumpSourceWaterTempF, the mixing valve - that mixes the water coming from the boost and the - IntermediateWaterTemp (see graphic in explanatory artifact) + # Calculate the maximum energy in kWh + max_energy_kwh = max_energy_btu / cf.BTU_PER_KWH - Explanatory artifact: LINK + return max_energy_kwh - Args: - params: TeaParams. - power_required_by_house_from_system_avg_kw: A list (by time slice) of the - power required by the house from the system. This will be less than the - the actual power required by the house by the ambient power supplied from - other sources (other electrical appliances, ambient solar, animals) + +def get_max_system_heat_output_avg_kw( + params: FloParamsSimpleresistivehydronic, +) -> float: """ + Calculates the maximum heat output attainable by a heating system modeled using + the Simple Resistive Hydronic AtomicTNode strategy. - swt: List[float] = [] - system_heat_list = params.PowerRequiredByHouseFromSystemAvgKwList - for i in range(len(system_heat_list)): - system_heat_avg_kw = system_heat_list[i] - try: - this_slice_swt = get_source_water_temp_f( - params=params, system_heat_kw=system_heat_avg_kw - ) - except errors.PhysicalSystemFailure: - raise Exception( - f"Trouble for slice {i} and system_heat_avg_kw {system_heat_avg_kw}" - ) - swt.append(this_slice_swt) - swt.append(params.ZeroPotentialEnergyWaterTempF) - return swt - - -def get_source_water_temp_f(params: FloParams, system_heat_kw: float) -> float: - """Returns SourceWaterTempF for given this system_heat_avg_kw, - and ShDistPumpFeedbackModel of ConstantGpm. Does not - let SourceWaterTempF go below params.ZeroPotentialEnergyWaterTempF + This calculation takes into account the parameters of the system, including the gallons per minute + (gpm) of the circulator pump for the single-zone distribution system and the difference + (delta_temp_f) between the source/supply water temperature (SWT) entering the distribution system + and the return water temperature (RWT) coming back from the distribution system. - Args: - params (FloParams): Params for the Flo. - system_heat_avg_kw (float): the heat the system is putting - into the house + Please note that this model assumes a constant speed for the circulator pump and a fixed temperature delta. + It provides a reasonable estimate for understanding the mechanics of single-zone homes, but may not accurately + model multi-zone homes or address all potential issues. - Raises: - errors.PhysicalSystemFailure: raised if derived - SourceWaterTempF exceeds EmitterMaxSafeSwtF + Finally, note that this is not the same as the amount of heat that a house requires on the coldest + day of a Typical Modeled Year. + + For more information on the Simple Resistive Hydronic model, please visit: + - AtomicTNode Simple Resistive Hydronic model: [Params API](https://gridworks-atn.readthedocs.io/en/latest/simple-resistive-hydronic.html) + - Params API: [FloParamsSimpleresistivehydronic](https://gridworks-atn.readthedocs.io/en/latest/types/flo-params-simpleresistivehydronic.html) + + + Args: + params (FloParamsSimpleresistivehydronic): The parameters of the heating system. Specifically: + - CirculatorPumpGpm + - ReturnWaterDeltaTempF Returns: - float: SourceWaterTempF + float: The maximum heat output in kilowatts (kW) that the heating system can provide. + """ - if system_heat_kw < 0: - raise Exception(f"System does not TAKE heat from house") - if system_heat_kw == 0: - return params.ZeroPotentialEnergyWaterTempF - if params.DistPumpFeedbackModel == ShDistPumpFeedbackModel.ConstantDeltaT: - return get_constant_delta_t_swt(params=params, system_heat_kw=system_heat_kw) - else: - return get_constant_gpm_swt(params=params, system_heat_kw=system_heat_kw) + gpm = params.CirculatorPumpGpm + pounds_per_hr = 60 * gpm * cf.POUNDS_OF_WATER_PER_GALLON + delta_temp_f = params.ReturnWaterDeltaTempF + + # Original defn of a BTU: the amount of heat (energy) required to raise the temperature of one + # pound of water by one degree Fahrenheit. https://en.wikipedia.org/wiki/British_thermal_unit + # + # note that many US HVAC tradespeople use this as the form of energy, instead of the SI units + # of Joules or kWh. There are 3412 BTUs per kWh - useful to memorize if you are paying attention + # to electric heating. + max_dist_system_btus_per_hour = pounds_per_hr * delta_temp_f + max_dist_system_out_kw = max_dist_system_btus_per_hour / cf.BTU_PER_KWH + + design_day_kw = min(max_dist_system_out_kw, params.RatedPowerKw) -def get_constant_gpm_swt(params: FloParams, system_heat_kw: float) -> float: - """Returns SourceWaterTempF for given this system_heat_avg_kw, - and ShDistPumpFeedbackModel of ConstantGpm. Does not - let SourceWaterTempF go below params.ZeroPotentialEnergyWaterTempF. + return design_day_kw + + +def get_house_worst_case_heat_output_avg_kw( + params: FloParamsSimpleresistivehydronic, +) -> float: + worst_t = params.HouseWorstCaseTempF + this_run_t = min(params.OutsideTempF) + room_t = params.RoomTempF + p = params.PowerLostFromHouseKwList + this_run_max_system_kw = max(p) + this_run_max_kw_in = this_run_max_system_kw + params.AmbientPowerInKw + dd_max_kw_in = this_run_max_kw_in * (room_t - worst_t) / (room_t - this_run_t) + return dd_max_kw_in - params.AmbientPowerInKw + + +# TODO: move into FloParams as an axiom. +def check_params_consistency(params: FloParamsSimpleresistivehydronic) -> None: + """ + Checks if the heating system can meet the physical requirements of the house. + + If the house's worst-case heat requirement exceeds the maximum output of the system output, or if the required source water temperature is higher than the maximum store temperature, an exception + of type PhysicalSystemFailure is raised. Args: - params (FloParams): Params for the Flo. Uses - - RoomTempF - - SystemMaxHeatOutputDeltaTempF - - EmitterMaxSafeSwtF - - SystemMaxHeatOutputSwtF - - SystemMaxHeatOutputGpm - system_heat_avg_kw (float): The heat provided by the system - into the house - system_heat_avg_kw (float): the heat the system ixs putting - into the house + params: An instance of FloParamsSimpleresistivehydronic containing the parameters for the heating system. Raises: - errors.PhysicalSystemFailure: raised if derived - SourceWaterTempF exceeds EmitterMaxSafeSwtF - - Returns: - float: SourceWaterTempF + PhysicalSystemFailure: If the house's worst-case heat requirement exceeds the maximum system output + or if the required source water temperature is higher than the maximum store temperature. """ - rt = params.RoomTempF - ddd = params.SystemMaxHeatOutputDeltaTempF - dd_gpm = params.SystemMaxHeatOutputGpm - c = cf.POUNDS_OF_WATER_PER_GALLON * cf.MINUTES_PER_HOUR / cf.BTU_PER_KWH - dd_swt = params.SystemMaxHeatOutputSwtF - denominator = c * dd_gpm * (1 - (dd_swt - rt - ddd) / (dd_swt - rt)) - constant_running_swt = rt + (system_heat_kw / denominator) - constant_running_swt = max( - constant_running_swt, params.ZeroPotentialEnergyWaterTempF - ) + max_system_out = get_max_system_heat_output_avg_kw(params) + max_house_out = get_house_worst_case_heat_output_avg_kw(params) + + if max_house_out > max_system_out: + raise PhysicalSystemFailure( + f"Max house requirement on worst case annual temp of " + f"{params.HouseWorstCaseTempF} " + f"{round(max_house_out, 2)} kW exceeds max system " + f"output of {round(max_system_out, 2)}" + ) - if params.MixingValveFeedbackModel == ShMixingValveFeedbackModel.ConstantSwt: - return params.SystemMaxHeatOutputSwtF - elif params.MixingValveFeedbackModel == ShMixingValveFeedbackModel.NaiveVariableSwt: - if constant_running_swt > params.EmitterMaxSafeSwtF: - raise errors.PhysicalSystemFailure( - "Pump strategy: ConstantGpm. MixingValve: " - f"NaiveVariable. Constant running swt {constant_running_swt} F exceeds" - f" EmitterMaxSafeSwtF {params.EmitterMaxSafeSwtF}!" - ) - return constant_running_swt - elif ( - params.MixingValveFeedbackModel - == ShMixingValveFeedbackModel.CautiousVariableSwt - ): - cautious_swt = constant_running_swt + params.CautiousMixingValveTempDeltaF - if cautious_swt > params.EmitterMaxSafeSwtF: - raise errors.PhysicalSystemFailure( - "Pump strategy: ConstantGpm. MixingValve: " - f"CautiousVariable. Cautious {cautious_swt} F (hotter by {params.CautiousMixingValveTempDeltaF}" - f" than constant running temp) exceeds" - f" EmitterMaxSafeSwtF {params.EmitterMaxSafeSwtF}!" - ) - return cautious_swt - else: - raise Exception( - f"Unknown ShMixingValveFeedbackModel {params.MixingValveFeedbackModel}" + this_run_lowest_ot = min(params.OutsideTempF) + if this_run_lowest_ot < params.HouseWorstCaseTempF: + raise ValueError( + f"min outside temp {this_run_lowest_ot} F is lower than" + f"House Worst Case Temp {params.HouseWorstCaseTempF} F!" + ) + + if params.RequiredSourceWaterTempF > params.MaxStoreTempF: + raise PhysicalSystemFailure( + f"Store temp cannot keep house warm! MaxStoreTempF is {params.MaxStoreTempF} F" + f" and Required Source Water Temp is {params.RequiredSourceWaterTempF} F." ) -def get_constant_delta_t_swt(params: FloParams, system_heat_kw: float) -> float: - """Calculates Source Water Temp (SWT) for a system with - a constant delta T feedback control mechanism for its circulator - pump/thermostat. Does not - let SourceWaterTempF go below params.ZeroPotentialEnergyWaterTempF. +def get_passive_loss_wh( + params: FloParamsSimpleresistivehydronic, ts_idx: int, store_idx: int +) -> float: + """ + Returns the loss of energy from the tank for a time slice due to radiating through the tank insulation, + as a function of the starting node. - params (FloParams): Params for the Flo. Uses - - RoomTempF - - SystemMaxHeatOutputDeltaTempF - - EmitterMaxSafeSwtF - - SystemMaxHeatOutputSwtF - - SystemMaxHeatOutputGpm - system_heat_avg_kw (float): The heat provided by the system - into the house - system_heat_avg_kw (float): the heat the system is putting - into the house + A more accurate variant would provide passive loss as a function of an edge. However, given how small + this loss typically is, this simplification is pretty insignificant compared to other inaccuracies + of the model and we didn't think it was worth the additional complexity. - Raises: - errors.PhysicalSystemFailure: raised if derived - SourceWaterTempF exceeds EmitterMaxSafeSwtF + Args: + params: + ts_idx + store_idx: Returns: - float: SourceWaterTempF + Energy lost passively from the tank through its insulation in the time slice, + in Watt Hours. + """ - p_req = system_heat_kw - if p_req <= 0: - return params.ZeroPotentialEnergyWaterTempF - rt = params.RoomTempF - ddd = params.SystemMaxHeatOutputDeltaTempF - - max_e_out = params.SystemMaxHeatOutputKwAvg - dd_swt = params.SystemMaxHeatOutputSwtF - base = (dd_swt - rt - ddd) / (dd_swt - rt) - exp = max_e_out / p_req - - numerator = rt + ddd - rt * (base**exp) - denominator = 1 - (base**exp) - if denominator == 0: - raise Exception(f"About to divide by zero. base = {base} exp = {exp}") - constant_running_swt: float = numerator / denominator - constant_running_swt = max( - constant_running_swt, params.ZeroPotentialEnergyWaterTempF + slice_hrs = params.SliceDurationMinutes[ts_idx] / 60 + hot_ratio = store_idx / params.StorageSteps + hot_pounds = hot_ratio * params.StoreSizeGallons * cf.POUNDS_OF_WATER_PER_GALLON + hot_energy_wh = ( + (params.MaxStoreTempF - params.AmbientTempStoreF) + * hot_pounds + * 1000 + / cf.BTU_PER_KWH ) - if params.MixingValveFeedbackModel == ShMixingValveFeedbackModel.ConstantSwt: - return params.SystemMaxHeatOutputSwtF - elif params.MixingValveFeedbackModel == ShMixingValveFeedbackModel.NaiveVariableSwt: - if constant_running_swt > params.EmitterMaxSafeSwtF: - raise errors.PhysicalSystemFailure( - "Pump: ConstantDeltaT, MixingValve: " - f"NaiveVariable. {constant_running_swt} F exceeds" - f" EmitterMaxSafeSwtF {params.EmitterMaxSafeSwtF}!" - ) - - return constant_running_swt - elif ( - params.MixingValveFeedbackModel - == ShMixingValveFeedbackModel.CautiousVariableSwt - ): - cautious_swt: float = ( - constant_running_swt + params.CautiousMixingValveTempDeltaF - ) - if cautious_swt > params.EmitterMaxSafeSwtF: - raise errors.PhysicalSystemFailure( - "Pump strategy: ConstantDeltaT. MixingValve: " - f"CautiousVariable. Cautious {cautious_swt} F (hotter by {params.CautiousMixingValveTempDeltaF}" - f" than constant running temp) exceeds" - f" EmitterMaxSafeSwtF {params.EmitterMaxSafeSwtF}!" - ) - return cautious_swt - else: - raise Exception( - f"Unknown ShMixingValveFeedbackModel {params.MixingValveFeedbackModel}" - ) + hot_loss_wh = params.StorePassiveLossRatio * slice_hrs * hot_energy_wh + + rwt_pounds = ( + (1 - hot_ratio) * params.StoreSizeGallons * cf.POUNDS_OF_WATER_PER_GALLON + ) + rwt_f = params.RequiredSourceWaterTempF - params.ReturnWaterDeltaTempF + rwt_energy_wh = ( + (rwt_f - params.AmbientTempStoreF) * rwt_pounds * 1000 / cf.BTU_PER_KWH + ) + rwt_loss_wh = params.StorePassiveLossRatio * slice_hrs * rwt_energy_wh + + passive_loss_wh = hot_loss_wh + rwt_loss_wh + + return passive_loss_wh diff --git a/src/gwatn/strategies/simple_resistive_hydronic/make_dev_input_data.py b/src/gwatn/strategies/simple_resistive_hydronic/make_dev_input_data.py index 6785a31..415f6e8 100644 --- a/src/gwatn/strategies/simple_resistive_hydronic/make_dev_input_data.py +++ b/src/gwatn/strategies/simple_resistive_hydronic/make_dev_input_data.py @@ -2,10 +2,10 @@ import json import pendulum -from satn.types import AtnParamsHeatpumpwithbooststore as AtnParams -from satn.types import AtnParamsHeatpumpwithbooststore_Maker as AtnParams_Maker from gwatn.types import AtnParamsReport_Maker +from gwatn.types import AtnParamsSimpleresistivehydronic as AtnParams +from gwatn.types import AtnParamsSimpleresistivehydronic_Maker as AtnParams_Maker params_file = "input_data/atn_params_data.csv" diff --git a/src/gwatn/strategies/simple_resistive_hydronic/node.py b/src/gwatn/strategies/simple_resistive_hydronic/node.py index 639994b..a91d962 100644 --- a/src/gwatn/strategies/simple_resistive_hydronic/node.py +++ b/src/gwatn/strategies/simple_resistive_hydronic/node.py @@ -1,4 +1,4 @@ -""" HeatPumpWithBoostStore DNode Definition +""" SimpleResistiveHydronic DNode Definition """ from typing import Optional @@ -8,21 +8,19 @@ SIG_FIGS_FOR_OUTPUT = 6 -class Node_BrickStorageHeater(DNode): +class Node_SimpleResistiveHydronic(DNode): def __init__( self, ts_idx: int, store_idx: int, - store_enthalpy_kwh: Optional[int] = None, - store_avg_brick_temp_c: Optional[float] = None, + energy_wh: float, ): DNode.__init__( self, ts_idx=ts_idx, store_idx=store_idx, ) - self.store_enthalpy_kwh = store_enthalpy_kwh - self.store_avg_brick_temp_c = store_avg_brick_temp_c + self.energy_wh = energy_wh def __repr__( self, @@ -30,4 +28,6 @@ def __repr__( rep = f"DNode => TimeSliceIdx: {self.ts_idx}, StoreIdx: {self.store_idx}" if self.path_cost: rep += f", Path cost: ${round(self.path_cost, 3)}" + if self.energy_wh: + rep += f", Energy: {round(self.energy_wh / 1000, 2)} kWh" return rep diff --git a/src/gwatn/strategies/simple_resistive_hydronic/simple_scada_sim.py b/src/gwatn/strategies/simple_resistive_hydronic/simple_scada_sim.py index bfb823d..e52b8e8 100644 --- a/src/gwatn/strategies/simple_resistive_hydronic/simple_scada_sim.py +++ b/src/gwatn/strategies/simple_resistive_hydronic/simple_scada_sim.py @@ -39,8 +39,8 @@ from gwatn.types import HeartbeatB_Maker from gwatn.types import JoinDispatchContract_Maker from gwatn.types import ScadaCertTransfer_Maker -from gwatn.types import SimScadaDriverReportBsh as SimScadaDriverReport -from gwatn.types import SimScadaDriverReportBsh_Maker as SimScadaDriverReport_Maker +from gwatn.types import SimplesimDriverReport +from gwatn.types import SimplesimDriverReport_Maker from gwatn.types import SimTimestep from gwatn.types import SimTimestep_Maker from gwatn.types import SnapshotBrickstorageheater as Snapshot @@ -195,7 +195,7 @@ def route_message( self.heartbeat_from_atn(ping=payload) except: LOGGER.exception("Error in heartbeat_from_atn") - elif payload.TypeName == SimScadaDriverReport_Maker.type_name: + elif payload.TypeName == SimplesimDriverReport_Maker.type_name: try: self.sim_scada_driver_report_received(payload) except: @@ -537,7 +537,7 @@ def dispatch_contract_confirmed_received(self, payload: DispatchContractConfirme # Make the below into abstractmethods if pulling out base class ######################################################### - def sim_scada_driver_report_received(self, payload: SimScadaDriverReport) -> None: + def simplesim_driver_report_received(self, payload: SimplesimDriverReport) -> None: """This gets received right before the top of the hour, from our best simulation of the TerminalAsset (which is happening in the AtomicTNode).""" diff --git a/src/gwatn/strategies/simple_resistive_hydronic/strategy_utils.py b/src/gwatn/strategies/simple_resistive_hydronic/strategy_utils.py index 2b8c0a9..1684be0 100644 --- a/src/gwatn/strategies/simple_resistive_hydronic/strategy_utils.py +++ b/src/gwatn/strategies/simple_resistive_hydronic/strategy_utils.py @@ -1,16 +1,16 @@ from typing import Optional -import gridworks.conversion_factors as cf # TODO change to from gwatn import conversion_factors as cf import numpy as np from pydantic import BaseModel -from satn.strategies.heatpumpwithbooststore.flo import ( - Flo__HeatpumpWithBoostStore as Flo, -) -from satn.types import AtnParamsHeatpumpwithbooststore as AtnParams -from satn.types import FloParamsHeatpumpwithbooststore as FloParams from gwatn import atn_utils +from gwatn import conversion_factors as cf +from gwatn.strategies.simple_resistive_hydronic.flo import ( + Flo_SimpleResistiveHydronic as Flo, +) from gwatn.types import AtnBid +from gwatn.types import AtnParamsSimpleresistivehydronic as AtnParams +from gwatn.types import FloParamsSimpleresistivehydronic as FloParams from gwatn.types import MarketSlot @@ -91,19 +91,6 @@ def is_dummy_flo_params(flo_params: FloParams) -> bool: ########################################## -def get_k( - system_max_heat_output_delta_temp_f: int, - system_max_heat_output_gpm: float, - system_max_heat_output_swt_f: int, - room_temp_f: int, -) -> float: - dt = system_max_heat_output_delta_temp_f - gpm = system_max_heat_output_gpm - swt = system_max_heat_output_swt_f - rt = room_temp_f - return float(gpm * np.log(1 - dt / (swt - rt))) - - def get_system_max_heat_output_kw_avg( system_max_heat_output_gpm: float, system_max_heat_output_delta_temp_f: float ) -> float: diff --git a/src/gwatn/strategies/simple_resistive_hydronic/tea.py b/src/gwatn/strategies/simple_resistive_hydronic/tea.py deleted file mode 100644 index 8e25ab2..0000000 --- a/src/gwatn/strategies/simple_resistive_hydronic/tea.py +++ /dev/null @@ -1,264 +0,0 @@ -import csv -import uuid -from typing import List - -import numpy as np -import pendulum -import satn.dev_utils.price.distp_sync_100_handler as distp_sync_100_handler -import satn.dev_utils.price.eprt_sync_100_handler as eprt_sync_100_handler -import satn.dev_utils.weather.weather_forecast_sync_100_handler as weather_forecast_sync_100_handler -import satn.strategies.heatpumpwithbooststore.flo_utils as flo_utils -import satn.strategies.heatpumpwithbooststore.strategy_utils as strategy_utils -from satn.enums import ShDistPumpFeedbackModel -from satn.enums import ShMixingValveFeedbackModel -from satn.strategies.heatpumpwithbooststore.flo import Flo__HeatpumpWithBoostStore -from satn.strategies.heatpumpwithbooststore.tea_config import TeaParams -from satn.strategies.heatpumpwithbooststore.tea_output import export_xlsx -from satn.types.flo_params_heatpumpwithbooststore import ( - FloParamsHeatpumpwithbooststore as FloParams, -) - -import gwatn.errors as errors -from gwatn.enums import DistributionTariff -from gwatn.enums import EnergySupplyType -from gwatn.enums import RecognizedCurrencyUnit -from gwatn.enums import RecognizedTemperatureUnit -from gwatn.types.ps_distprices_gnode.r_distp_sync.r_distp_sync_1_0_0 import ( - Payload as DistpSync100Payload, -) -from gwatn.types.ps_electricityprices_gnode.r_eprt_sync.r_eprt_sync_1_0_0 import ( - Payload as EprtSync100Payload, -) -from gwatn.types.ws_forecast_gnode.r_weather_forecast_sync.r_weather_forecast_sync_1_0_0 import ( - Payload as RWeatherForecastSync100Payload, -) - - -def get_electricity_prices(tea_params: TeaParams) -> EprtSync100Payload: - ep = eprt_sync_100_handler.payload_from_file( - eprt_type_name=tea_params.real_time_electricity_price_type_name, - eprt_csv=tea_params.real_time_electricity_price_csv, - csv_starting_offset_hours=tea_params.price_csv_starting_offset_hours, - flo_total_time_hrs=tea_params.flo_total_time_hrs, - ) - if ep.CurrencyUnit != tea_params.currency_unit: - raise Exception( - f"Currency unit for {tea_params.real_time_electricity_price_csv} does not match params.currency_unit of {tea_params.currency_unit}" - ) - return ep - - -def get_flo_start_utc(tea_params: TeaParams) -> pendulum.datetime: - ep = get_electricity_prices(tea_params) - return pendulum.datetime( - year=ep.StartYearUtc, - month=ep.StartMonthUtc, - day=ep.StartDayUtc, - hour=ep.StartHourUtc, - minute=ep.StartMinuteUtc, - ) - - -def get_distribution_prices( - tea_params: TeaParams, flo_start_utc: pendulum.datetime -) -> DistpSync100Payload: - dp = distp_sync_100_handler.payload_from_file( - distp_type_name=tea_params.dist_price_type_name, - distp_csv=tea_params.dist_price_csv, - flo_start_utc=flo_start_utc, - flo_total_time_hrs=tea_params.flo_total_time_hrs, - ) - if dp.CurrencyUnit != tea_params.currency_unit: - raise Exception( - f"currency unit for {tea_params.dist_price_type_name} does not match params.currency_unit of {tea_params.currency_unit}" - ) - return dp - - -def get_weather_forecasts( - tea_params: TeaParams, flo_start_utc: pendulum.datetime -) -> RWeatherForecastSync100Payload: - wp = weather_forecast_sync_100_handler.payload_from_file( - file_name=tea_params.weather_csv, - request_start_datetime_utc=flo_start_utc, - total_time_hrs=tea_params.flo_total_time_hrs, - ) - if wp.TempUnit != tea_params.temp_unit: - raise Exception( - f"temp unit for {tea_params.weather_csv} does not match params.temp_unit of {tea_params.temp_unit}" - ) - return wp - - -def get_desired_heat_from_csv(tea_params: TeaParams) -> List[float]: - ep = get_electricity_prices(tea_params) - if tea_params.price_csv_starting_offset_hours != 0: - raise NotImplementedError( - f"heat profile has not been adjusted to offsets from start of year" - ) - - with open(tea_params.scaled_heat_profile_csv, newline="") as csvfile: - reader = csv.reader(csvfile, delimiter=",") - p = [] - for row in reader: - p.append(float(row[0])) - if len(p) != 8784: - raise Exception( - f"scaled heat profile {tea_params.scaled_heat_profile_csv} should have 8784 hours" - ) - annual_desired_house_heat_profile = ( - tea_params.annual_hvac_kwh_th * np.array(p) - ).tolist() - return annual_desired_house_heat_profile[0 : len(ep.Prices)] - - -def get_flo_params(tea_params: TeaParams) -> FloParams: - ep = get_electricity_prices(tea_params) - flo_start_utc = get_flo_start_utc(tea_params) - dp = get_distribution_prices(tea_params, flo_start_utc) - weather = get_weather_forecasts(tea_params, flo_start_utc) - pump_model = ShDistPumpFeedbackModel(tea_params.emitter_pump_feedback_model_value) - mixing_valve_model = ShMixingValveFeedbackModel( - tea_params.mixing_valve_feedback_model_value - ) - # PowerRequiredByHouseFromSystemAvgKw - power_required_by_house_from_system_avg_kw_list: List[ - float - ] = get_desired_heat_from_csv(tea_params) - - flo_params = FloParams( - GNodeAlias="d1.tea.atn", - FloParamsUid=str(uuid.uuid4()), - PowerRequiredByHouseFromSystemAvgKwList=power_required_by_house_from_system_avg_kw_list, - HouseWorstCaseTempF=tea_params.house_worst_case_temp_f, - EmitterMaxSafeSwtF=tea_params.emitter_max_safe_swt_f, - CirculatorPumpMaxGpm=tea_params.circulator_pump_max_gpm, - SystemMaxHeatOutputKwAvg=strategy_utils.get_system_max_heat_output_kw_avg( - system_max_heat_output_gpm=tea_params.system_max_heat_output_gpm, - system_max_heat_output_delta_temp_f=tea_params.system_max_heat_output_delta_temp_f, - ), - RtElecPriceUid=ep.PriceUid, - DistPriceUid=dp.PriceUid, - RegPriceUid=None, - WeatherUid=weather.WeatherUid, - K=strategy_utils.get_k( - system_max_heat_output_delta_temp_f=tea_params.system_max_heat_output_delta_temp_f, - system_max_heat_output_gpm=tea_params.system_max_heat_output_gpm, - system_max_heat_output_swt_f=tea_params.system_max_heat_output_swt_f, - room_temp_f=tea_params.room_temp_f, - ), - StartYearUtc=ep.StartYearUtc, - StartMonthUtc=ep.StartMonthUtc, - StartDayUtc=ep.StartDayUtc, - StartHourUtc=ep.StartHourUtc, - StartMinuteUtc=ep.StartMinuteUtc, - TimezoneString=tea_params.timezone_string, - SliceDurationMinutes=[ep.UniformSliceDurationHrs * 60] * len(ep.Prices), - HomeCity=tea_params.home_city, - AmbientTempStoreF=tea_params.ambient_temp_store_f, - StorePassiveLossRatio=tea_params.store_passive_loss_ratio, - SystemMaxHeatOutputDeltaTempF=tea_params.system_max_heat_output_delta_temp_f, - SystemMaxHeatOutputGpm=tea_params.system_max_heat_output_gpm, - SystemMaxHeatOutputSwtF=tea_params.system_max_heat_output_swt_f, - IsRegulating=False, - OutsideTempF=weather.Temperatures, - HeatpumpTariff=DistributionTariff(tea_params.heatpump_tariff_value), - HeatpumpEnergySupplyType=EnergySupplyType( - tea_params.heatpump_energy_supply_type_value - ), - BoostTariff=DistributionTariff(tea_params.boost_tariff_value), - BoostEnergySupplyType=EnergySupplyType( - tea_params.boost_energy_supply_type_value - ), - StandardOfferPriceDollarsPerMwh=tea_params.standard_offer_price_dollars_per_mwh, - DistributionTariffDollarsPerMwh=tea_params.flat_tariff_dollars_per_mwh, - RealtimeElectricityPrice=ep.Prices, - DistributionPrice=dp.Prices, - RoomTempF=tea_params.room_temp_f, - AmbientPowerInKw=tea_params.ambient_power_in_kw, - MaxHeatpumpSourceWaterTempF=tea_params.max_heatpump_source_water_temp_f, - ZeroPotentialEnergyWaterTempF=tea_params.zero_potential_energy_water_temp_f, - MaxStoreTempF=tea_params.max_store_temp_f, - StorageSteps=tea_params.storage_steps, - StoreSizeGallons=tea_params.store_size_gallons, - RatedHeatpumpElectricityKw=tea_params.rated_heatpump_electricity_kw, - StoreMaxPowerKw=tea_params.store_max_power_kw, - DistPumpFeedbackModel=pump_model, - MixingValveFeedbackModel=mixing_valve_model, - CautiousMixingValveTempDeltaF=tea_params.cautious_mixing_valve_temp_delta_f, - TempUnit=RecognizedTemperatureUnit(tea_params.temp_unit), - CurrencyUnit=RecognizedCurrencyUnit(tea_params.currency_unit), - StartingIdx=int(tea_params.storage_steps / 2), - Cop1TempF=tea_params.cop_1_temp_f, - Cop4TempF=tea_params.cop_4_temp_f, - RegulationPrice=[], - ) - - return flo_params - - -def get_flo(flo_params: FloParams) -> Flo__HeatpumpWithBoostStore: - return Flo__HeatpumpWithBoostStore( - params=flo_params, - d_graph_id=str(uuid.uuid4()), - ) - - -if __name__ == "__main__": - tea_params = TeaParams(_env_file="tea_params/heatpumpwithbooststore.env") - flo_params = get_flo_params(tea_params) - p = flo_params.PowerRequiredByHouseFromSystemAvgKwList - t = flo_params.OutsideTempF - house_dd_max_heat_kw = flo_utils.get_house_worst_case_heat_output_avg_kw(flo_params) - - print(f"Max required heat this run: {round(max(p),2)} kW") - print(f"Max required heat on design day:{round(house_dd_max_heat_kw,2)} kW") - print( - f"flo_params.SystemMaxHeatOutputKwAvg: {round(flo_params.SystemMaxHeatOutputKwAvg,2)} kW" - ) - if max(p) > flo_params.SystemMaxHeatOutputKwAvg: - # if house_dd_max_heat_kw > flo_params.SystemMaxHeatOutputKwAvg: - raise errors.PhysicalSystemFailure( - f"House requires {round(house_dd_max_heat_kw,2)} kW on the" - f" design day but flo_params.SystemMaxHeatOutputKwAvg is only {round(flo_params.SystemMaxHeatOutputKwAvg,2)} kW!" - ) - if flo_params.CirculatorPumpMaxGpm < flo_params.SystemMaxHeatOutputGpm: - raise errors.PhysicalSystemFailure( - f"CirculatorPumpMaxGpm {flo_params.CirculatorPumpMaxGpm} is less than" - f" SystemMaxHeatOutputKwAvg {round(flo_params.SystemMaxHeatOutputKwAvg,2)}" - ) - if ( - flo_params.MixingValveFeedbackModel - == ShMixingValveFeedbackModel.NaiveVariableSwt - ): - if flo_params.EmitterMaxSafeSwtF < flo_params.SystemMaxHeatOutputSwtF: - raise errors.PhysicalSystemFailure( - f".EmitterMaxSafeSwtF {flo_params.EmitterMaxSafeSwtF} is less than" - f" SystemMaxHeatOutputSwtF {round(flo_params.SystemMaxHeatOutputSwtF,1)}" - ) - if ( - flo_params.MixingValveFeedbackModel - == ShMixingValveFeedbackModel.CautiousVariableSwt - ): - if ( - flo_params.EmitterMaxSafeSwtF - < flo_params.SystemMaxHeatOutputSwtF - + flo_params.CautiousMixingValveTempDeltaF - ): - raise errors.PhysicalSystemFailure( - "MixingValveModel: CautiousVariableSwt." - f" EmitterMaxSafeSwtF {flo_params.EmitterMaxSafeSwtF} is less than" - " SystemMaxHeatOutputSwtF - CautiousMixingValveTempDeltaF: " - f"{round(flo_params.SystemMaxHeatOutputSwtF - flo_params.CautiousMixingValveTempDeltaF,1)}" - ) - - print(f"Coldest temp this run: {min(t)} F") - print(f"Design day temp: {flo_params.HouseWorstCaseTempF} F") - swt = flo_utils.get_source_water_temp_f_list(params=flo_params) - - print(f"Max swt : {round(max(swt),1)}") - flo = get_flo(flo_params) - - if flo.node[0][50].path_cost > 100000: - print("FAILED") - export_xlsx(tea_params=tea_params, flo=flo, export_graph=tea_params.export_graph) diff --git a/src/gwatn/strategies/simple_resistive_hydronic/tea_config.py b/src/gwatn/strategies/simple_resistive_hydronic/tea_config.py deleted file mode 100644 index 4e542c0..0000000 --- a/src/gwatn/strategies/simple_resistive_hydronic/tea_config.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Settings for the GridWorks Scada, readable from environment and/or from env files.""" -from pydantic import BaseSettings -from satn.enums import ShDistPumpFeedbackModel -from satn.enums import ShMixingValveFeedbackModel - - -DEFAULT_ENV_FILE = "../tea_settings/heatpumpwithbooststore.env" - - -class TeaParams(BaseSettings): - """Settings for the HeatPumpWithBoostStore Technoeconomic analysis.""" - - house_worst_case_temp_f: int = -7 - emitter_max_safe_swt_f: int = 160 - system_max_heat_output_swt_f: int = 150 - circulator_pump_max_gpm: float = 6 - system_max_heat_output_delta_temp_f: int - system_max_heat_output_gpm: float - max_store_temp_f: int = 210 - room_temp_f: int = 70 - ambient_power_in_kw: float = 1.14 - flo_total_time_hrs: int = 48 - export_graph: bool = False - storage_steps: int = 100 - edge_options: int = 2 - price_csv_starting_offset_hours: int = 0 - timezone_string: str = "US/Eastern" - zero_heat_delta_f: int = 3 - max_heatpump_source_water_temp_f: int = 140 - design_day_temp_f: int = -7 - store_size_gallons: int = 240 - rated_heatpump_electricity_kw: float = 5.5 - store_max_power_kw: float = 9 - cop_1_temp_f: int = 0 - cop_4_temp_f: int = 50 - store_passive_loss_ratio: float = 0.003 - house_no_energy_needed_temp_f: int = 65 - space_heat_thermostat_setpoint_f: int = 68 - annual_hvac_kwh_th: int = 25000 - annual_solar_gain_kwh_th: int = 5000 - ambient_temp_store_f: int = 65 - currency_unit: str = "USD" - temp_unit: str = "F" - home_city: str = "MILLINOCKET_ME" - standard_offer_price_dollars_per_mwh: float = 110 - flat_tariff_dollars_per_mwh: float = 70 - real_time_electricity_price_type_name: str = "csv.eprt.sync.1_0_0" - real_time_electricity_price_csv: str = "../gridworks-ps/input_data/electricity_prices/isone/eprt__w.isone.stetson__2020.csv" - dist_price_type_name: str = "csv.distp.sync.1_0_0" - dist_price_csv: str = "input_data/electricity_prices/isone/distp__w.isone.stetson__2020__gw.me.versant.a1.res.ets.csv" - weather_type_name: str = "csv.weather.forecast.sync.1_0_0" - weather_csv: str = ( - "input_data/weather/us/me/temp__ws.us.me.millinocketairport__2020.csv" - ) - scaled_heat_profile_csv: str = "input_data/misc/millinocket_heat_profile_2020.csv" - zero_potential_energy_water_temp_f: int = 100 - emitter_pump_feedback_model_value: ShDistPumpFeedbackModel = ( - ShDistPumpFeedbackModel.ConstantGpm.value - ) - mixing_valve_feedback_model_value: ShMixingValveFeedbackModel = ( - ShMixingValveFeedbackModel.ConstantSwt.value - ) - cautious_mixing_valve_temp_delta_f: int = 5 - heatpump_tariff_value: str = "CmpHeatTariff" - heatpump_energy_supply_type_value: str = "StandardOffer" - boost_tariff_value: str = "CmpStorageHeatTariff" - boost_energy_supply_type_value: str = "RealtimeLocalLmp" - # When this is uncommented, timezone_string disappears - # @validator('timezone_string') - # def is_recognized_timezone(cls, v): - # assert pytz.timezone(v) - - class Config: - env_prefix = "TEA_" - env_nested_delimiter = "__" - use_enum_values = True diff --git a/src/gwatn/strategies/simple_resistive_hydronic/tea_output.py b/src/gwatn/strategies/simple_resistive_hydronic/tea_output.py deleted file mode 100644 index 401c0c3..0000000 --- a/src/gwatn/strategies/simple_resistive_hydronic/tea_output.py +++ /dev/null @@ -1,561 +0,0 @@ -import time - -import pendulum -import satn.dev_utils.price.distp_sync_100_handler as distp_sync_100_handler -import satn.dev_utils.price.eprt_sync_100_handler as eprt_sync_100_handler -import satn.dev_utils.price.regp_sync_100_handler as regp_sync_100_handler -import satn.dev_utils.weather.weather_forecast_sync_100_handler as weather_forecast_sync_100_handler -import satn.strategies.heatpumpwithbooststore.flo_utils as flo_utils -import xlsxwriter -from satn.enums import ShDistPumpFeedbackModel -from satn.strategies.heatpumpwithbooststore.edge import ( - Edge__HeatpumpWithBoostStore as Edge, -) -from satn.strategies.heatpumpwithbooststore.flo import Flo__HeatpumpWithBoostStore -from satn.strategies.heatpumpwithbooststore.node import ( - Node__HeatpumpWithBoostStore as Node, -) -from satn.strategies.heatpumpwithbooststore.tea_config import TeaParams - -import gwatn.conversion_factors as cf -from gwatn.enums import RecognizedCurrencyUnit - - -OUTPUT_FOLDER = "src/satn/strategies/heatpumpwithbooststore/output_data" - -ON_PEAK_DIST_PRICE_PER_MWH_CUTOFF = 100 -SHOULDER_PEAK_DIST_PRICE_PER_MWH_CUTOFF = 50 - - -def export_xlsx( - tea_params: TeaParams, flo: Flo__HeatpumpWithBoostStore, export_graph: bool -): - file = OUTPUT_FOLDER + "/result_{}_{}_{}_{}.xlsx".format( - flo.home_city, - flo.flo_start_utc.year, - flo.graph_strategy_alias, - int(time.time()), - ) - file = file.lower() - print(file) - - workbook = xlsxwriter.Workbook(file) - start_idx = int(flo.params.StorageSteps / 2) - - # Add to gsr for more blank rows - gsr = 30 - w = export_best_path_info(flo=flo, workbook=workbook, start_idx=start_idx) - if export_graph: - export_flo_graph( - flo=flo, - workbook=workbook, - worksheet=w, - start_idx=start_idx, - graph_start_row=gsr, - ) - - export_params_xlsx(tea_params=tea_params, flo=flo, workbook=workbook) - workbook.close() - - -def export_best_path_info( - flo: Flo__HeatpumpWithBoostStore, - workbook: xlsxwriter.workbook.Workbook, - start_idx: int, -): - w = workbook.add_worksheet(f"start {100 * start_idx / flo.params.StorageSteps}%") - w.freeze_panes(0, 2) - title_format = workbook.add_format({"bold": True}) - title_format.set_font_size(14) - bold_format = workbook.add_format({"bold": True}) - gray_filler_format = workbook.add_format({"bg_color": "#edf0f2"}) - header_format = workbook.add_format({"bold": True, "text_wrap": True}) - mwh_format = workbook.add_format({"bold": True, "num_format": '0.00" MWh"'}) - - currency_format = workbook.add_format({"num_format": "$#,##0.00"}) - currency_bold_format = workbook.add_format( - {"bold": True, "num_format": "$#,##0.00"} - ) - if flo.params.CurrencyUnit == RecognizedCurrencyUnit.GBP: - currency_format = workbook.add_format( - {"num_format": "_-[$£-en-GB]* #,##0.00_-"} - ) - currency_bold_format = workbook.add_format( - {"bold": True, "num_format": "_-[$£-en-GB]* #,##0.00_-"} - ) - - w.set_column("A:A", 26) - w.set_column("B:B", 15) - OPT_PATH_STATE_VAR_ROW = 14 - - swt_list = flo_utils.get_source_water_temp_f_list(flo.params) - w.write(0, 0, "GNode Alias: millinocket.moss", title_format) - w.write(1, 0, flo.graph_strategy_alias) - w.write(2, 0, f"FLO start {flo.params.TimezoneString} ", header_format) - local_start = pendulum.timezone(flo.timezone_string).convert(flo.flo_start_utc) - w.write(2, 1, local_start.strftime("%Y/%m/%d %H:%M")) - - w.write(3, 0, "Total hours", header_format) - w.write(3, 1, sum(flo.slice_duration_hrs), bold_format) - - w.write(4, 0, "Rt Energy Price ($/MWh)", header_format) - w.write( - 4, - 1, - sum(flo.RealtimeElectricityPrice) / len(flo.RealtimeElectricityPrice), - currency_bold_format, - ) - # w.write(5, 0, "Flat rate for hp ($/MWh)", header_format) - # if flo.params.IsRegulating: - # w.write(5, 0, "Regulation Price ($/MWh)", header_format) - # w.write( - # 5, 1, sum(flo.reg_price_per_mwh) / len(flo.reg_price_per_mwh), currency_bold_format - # ) - - w.write(6, 0, "Dist Price ($/MWh)", header_format) - w.write( - 6, - 1, - sum(flo.DistributionPrice) / len(flo.DistributionPrice), - currency_bold_format, - ) - w.write(7, 0, "Outside Temp F", header_format) - w.write( - 7, - 1, - round(sum(flo.params.OutsideTempF) / len(flo.params.OutsideTempF), 2), - bold_format, - ) - w.write(8, 0, "COP", header_format) - avg_cop = sum(flo.cop.values()) / len(flo.cop) - w.write(8, 1, round(avg_cop, 2), bold_format) - w.write(9, 0, "House Power Required AvgKw", header_format) - w.write( - 9, - 1, - round(sum(flo.params.PowerRequiredByHouseFromSystemAvgKwList), 2), - bold_format, - ) - w.write(10, 0, "Required Source Water Temp F", header_format) - avg_swt = round((sum(swt_list) / len(swt_list)), 0) - w.write(10, 1, avg_swt, bold_format) - w.write(11, 0, "Max HeatPump kWh thermal", header_format) - avg_max_thermal_hp_kwh = round( - (sum(flo.max_thermal_hp_kwh.values()) / len(flo.max_thermal_hp_kwh)), 2 - ) - w.write(11, 1, avg_max_thermal_hp_kwh, bold_format) - - w.write(12, 0, "Outputs", header_format) - - for jj in range(flo.time_slices): - hours_since_start = sum(flo.slice_duration_hrs[0:jj]) - local_time = local_start.add(hours=hours_since_start) - w.write(2, jj + 2, local_time.strftime("%m/%d")) - w.write(3, jj + 2, local_time.strftime("%H:%M")) - w.write(4, jj + 2, flo.RealtimeElectricityPrice[jj], currency_format) - if flo.params.IsRegulating: - w.write(5, jj + 2, flo.reg_price_per_mwh[jj], currency_format) - else: - w.write(5, jj + 2, "", gray_filler_format) - dp = flo.DistributionPrice[jj] - LIGHT_GREEN_HEX = "#bbe3a6" - LIGHT_RED_HEX = "#ff6363" - if dp > ON_PEAK_DIST_PRICE_PER_MWH_CUTOFF: - if flo.params.CurrencyUnit == RecognizedCurrencyUnit.USD: - dist_format = workbook.add_format( - {"bg_color": LIGHT_RED_HEX, "num_format": "$#,##0.00"} - ) - elif flo.params.CurrencyUnit == RecognizedCurrencyUnit.GBP: - dist_format = workbook.add_format( - { - "bg_color": LIGHT_RED_HEX, - "num_format": "_-[$£-en-GB]* #,##0.00_-", - } - ) - elif dp > SHOULDER_PEAK_DIST_PRICE_PER_MWH_CUTOFF: - if flo.params.CurrencyUnit == RecognizedCurrencyUnit.USD: - dist_format = workbook.add_format( - {"bg_color": "yellow", "num_format": "$#,##0.00"} - ) - elif flo.params.CurrencyUnit == RecognizedCurrencyUnit.GBP: - dist_format = workbook.add_format( - {"bg_color": "yellow", "num_format": "_-[$£-en-GB]* #,##0.00_-"} - ) - else: - if flo.params.CurrencyUnit == RecognizedCurrencyUnit.USD: - dist_format = workbook.add_format( - {"bg_color": LIGHT_GREEN_HEX, "num_format": "$#,##0.00"} - ) - elif flo.params.CurrencyUnit == RecognizedCurrencyUnit.GBP: - dist_format = workbook.add_format( - { - "bg_color": LIGHT_GREEN_HEX, - "num_format": "_-[$£-en-GB]* #,##0.00_-", - } - ) - w.write(6, jj + 2, flo.DistributionPrice[jj], dist_format) - w.write(7, jj + 2, flo.params.OutsideTempF[jj]) - w.write(8, jj + 2, flo.cop[jj]) - w.write( - 9, jj + 2, round(flo.params.PowerRequiredByHouseFromSystemAvgKwList[jj], 2) - ) - w.write(10, jj + 2, round(swt_list[jj], 0)) - w.write(11, jj + 2, round(flo.max_thermal_hp_kwh[jj], 2)) - - w.write(12, jj + 2, "", gray_filler_format) - - node: Node = flo.node[0][start_idx] - w.write(OPT_PATH_STATE_VAR_ROW, 0, "Store Temp (F)", header_format) - w.write(OPT_PATH_STATE_VAR_ROW + 1, 0, "HeatPump kWh thermal", header_format) - w.write(OPT_PATH_STATE_VAR_ROW + 2, 0, "HeatPump kWh electric", header_format) - w.write(OPT_PATH_STATE_VAR_ROW + 3, 0, "Boost kWh electric", header_format) - w.write(OPT_PATH_STATE_VAR_ROW + 4, 0, "Energy cost (¢)", header_format) - w.write(OPT_PATH_STATE_VAR_ROW + 5, 0, "Hours Since Start", header_format) - - store_temp_f = [] - opt_heatpump_electricity_used_kwh = [] - opt_boost_electricity_used_kwh = [] - opt_energy_cost_dollars = [] - - best_idx = start_idx - dist_cost = [] - min_dist_price_per_mwh = min(flo.DistributionPrice) - - for jj in range(flo.time_slices): - edge: Edge = flo.best_edge[node] - store_temp_f.append(node.store_avg_water_temp_f) - hp_kwh = edge.hp_electricity_avg_kw - boost_kwh = edge.boost_electricity_used_avg_kw - opt_heatpump_electricity_used_kwh.append(hp_kwh) - opt_boost_electricity_used_kwh.append(boost_kwh) - opt_energy_cost_dollars.append(edge.cost) - hours_since_start = sum(flo.slice_duration_hrs[0:jj]) - - w.write(OPT_PATH_STATE_VAR_ROW, jj + 2, round(node.store_avg_water_temp_f, 2)) - w.write( - OPT_PATH_STATE_VAR_ROW + 1, - jj + 2, - round(edge.hp_thermal_energy_generated_avg_kw, 3), - ) - w.write( - OPT_PATH_STATE_VAR_ROW + 2, jj + 2, round(edge.hp_electricity_avg_kw, 3) - ) - w.write( - OPT_PATH_STATE_VAR_ROW + 3, - jj + 2, - round(edge.boost_electricity_used_avg_kw, 3), - ) - w.write(OPT_PATH_STATE_VAR_ROW + 4, jj + 2, round(edge.cost * 100, 2)) - w.write(OPT_PATH_STATE_VAR_ROW + 5, jj + 2, round(hours_since_start, 1)) - node = flo.node[jj + 1][edge.end_idx] - - w.write( - OPT_PATH_STATE_VAR_ROW, - 1, - round(sum(store_temp_f) / len(store_temp_f), 0), - bold_format, - ) - w.write( - OPT_PATH_STATE_VAR_ROW + 2, - 1, - sum(opt_heatpump_electricity_used_kwh) / 1000, - mwh_format, - ) - w.write( - OPT_PATH_STATE_VAR_ROW + 3, - 1, - sum(opt_boost_electricity_used_kwh) / 1000, - mwh_format, - ) - w.write( - OPT_PATH_STATE_VAR_ROW + 4, - 1, - sum(opt_energy_cost_dollars), - currency_bold_format, - ) - - w.write("H1", "Electricity cost of this path") - total_cost = sum(opt_energy_cost_dollars) - w.write("G1", total_cost, currency_bold_format) - w.write("H2", "Total electricity MWh") - total_electricity_mwh = ( - sum(opt_boost_electricity_used_kwh) + sum(opt_heatpump_electricity_used_kwh) - ) / 1000 - w.write("G2", total_electricity_mwh, mwh_format) - - total_btu = sum(flo.params.PowerRequiredByHouseFromSystemAvgKwList) * cf.BTU_PER_KWH - gallons_oil = total_btu / cf.BTU_PER_GALLON_OF_OIL / 0.85 - # assumes 85% efficient oil boiler - - w.write("L1", "Gallons of Oil") - w.write("K1", round(gallons_oil), bold_format) - - w.write("L2", "Equivalent Price of Oil") - w.write("K2", total_cost / gallons_oil, currency_bold_format) - # w.write("H3", "Flat rate comparison") - - return w - - -def export_pointer( - flo: Flo__HeatpumpWithBoostStore, - workbook: xlsxwriter.workbook.Workbook, - worksheet: xlsxwriter.workbook.Worksheet, - start_idx: int, - graph_start_row: int, -): - worksheet.freeze_panes(graph_start_row - 1, 2) - header_format = workbook.add_format({"bold": True, "text_wrap": True}) - percent_format = workbook.add_format({"num_format": '00.0"%"'}) - best_path_format = workbook.add_format({"bold": True, "bg_color": "#CDEBA6"}) - best_idx = start_idx - - worksheet.write(graph_start_row - 1, 0, "Percent full", header_format) - - for mm in range(flo.params.StorageSteps + 1): - kk = flo.params.StorageSteps - mm - percent = round(100 * kk / flo.params.StorageSteps, 1) - worksheet.write(graph_start_row + mm, 0, percent, percent_format) - for jj in range(flo.time_slices): - best_node = flo.node[jj][best_idx] - for mm in range(flo.params.StorageSteps + 1): - kk = flo.params.StorageSteps - mm - node = flo.node[jj][kk] - edges = flo.edges[node] - if kk == best_idx: - try: - worksheet.write( - graph_start_row + mm, - jj + 2, - -round(node.path_benefit, 4), - best_path_format, - ) - except: - print(f"failed for best {jj},{kk}") - else: - try: - worksheet.write( - graph_start_row + mm, - jj + 2, - round(node.path_cost, 4), - ) - except: - print(f"failed for {jj},{kk}") - best_idx = flo.best_edge[best_node].end_idx - - -def export_flo_graph( - flo: Flo__HeatpumpWithBoostStore, - workbook: xlsxwriter.workbook.Workbook, - worksheet: xlsxwriter.workbook.Worksheet, - start_idx: int, - graph_start_row: int, -): - worksheet.freeze_panes(graph_start_row - 1, 2) - header_format = workbook.add_format({"bold": True, "text_wrap": True}) - percent_format = workbook.add_format({"num_format": '00.0"%"'}) - best_path_format = workbook.add_format({"bold": True, "bg_color": "#CDEBA6"}) - best_idx = start_idx - - worksheet.write(graph_start_row - 1, 0, "Percent full", header_format) - - for mm in range(flo.params.StorageSteps + 1): - kk = flo.params.StorageSteps - mm - percent = round(100 * kk / flo.params.StorageSteps, 1) - worksheet.write(graph_start_row + mm, 0, percent, percent_format) - for jj in range(flo.time_slices): - best_node = flo.node[jj][best_idx] - for mm in range(flo.params.StorageSteps + 1): - kk = flo.params.StorageSteps - mm - node = flo.node[jj][kk] - if kk == best_idx: - try: - worksheet.write( - graph_start_row + mm, - jj + 2, - -round(node.path_benefit, 4), - best_path_format, - ) - except: - print(f"failed for best {jj},{kk}") - else: - try: - worksheet.write( - graph_start_row + mm, - jj + 2, - round(node.path_cost, 4), - ) - except: - print(f"failed for {jj},{kk}") - best_idx = flo.best_edge[best_node].end_idx - - -def export_params_xlsx( - tea_params: TeaParams, - flo: Flo__HeatpumpWithBoostStore, - workbook: xlsxwriter.workbook.Workbook, -): - bold = workbook.add_format({"bold": True}) - w = workbook.add_worksheet("Params") - derived_format_bold = workbook.add_format({"bold": True, "font_color": "green"}) - derived_format = workbook.add_format({"font_color": "green"}) - swt_list = flo_utils.get_source_water_temp_f_list(flo.params) - w.set_column("A:A", 31) - w.set_column("D:D", 31) - w.set_column("G:G", 31) - w.write("A1", "Key Parameters", bold) - - t = flo.params.OutsideTempF - w.write("A4", "This Run ColdestTempF ", derived_format_bold) - w.write("B4", min(t), derived_format) - - w.write("A5", "HouseWorstCaseTempF ", bold) - w.write("B5", flo.params.HouseWorstCaseTempF) - - w.write("A7", "SystemMaxHeatOutputKwAvg", bold) - w.write("B7", round(flo.params.SystemMaxHeatOutputKwAvg, 2)) - - p = flo.params.PowerRequiredByHouseFromSystemAvgKwList - w.write("A8", "This Run MaxHeatOutputKwAvg", derived_format_bold) - w.write("B8", round(max(p), 2), derived_format) - - house_wc_kw = flo_utils.get_house_worst_case_heat_output_avg_kw(flo.params) - w.write("A9", "HouseWorstCaseHeatOuputAvgKw", derived_format_bold) - w.write("B9", round(house_wc_kw, 1), derived_format) - - w.write("A10", "HouseWorstCaseHeatOuput BTU/hr", derived_format_bold) - w.write("B10", round(house_wc_kw * cf.BTU_PER_KWH), derived_format) - - w.write("A12", "EmitterMaxSafeSwtF", bold) - w.write("B12", flo.params.EmitterMaxSafeSwtF) - - w.write("A13", "This Run SystemMaxHeatOutputSwtF", bold) - w.write("B13", round(max(swt_list))) - - w.write("A14", "SystemMaxHeatOutputSWTF ", bold) - w.write("B14", tea_params.system_max_heat_output_swt_f) - - w.write("A15", "HeatPumpMaxWaterTempF ", bold) - w.write("B15", flo.params.MaxHeatpumpSourceWaterTempF) - - w.write("A16", "RatedHeatpumpElectricityKw", bold) - w.write("B16", flo.params.RatedHeatpumpElectricityKw) - - w.write("A17", "StoreMaxPowerKw", bold) - w.write("B17", flo.params.StoreMaxPowerKw) - - if flo.params.DistPumpFeedbackModel == ShDistPumpFeedbackModel.ConstantDeltaT: - w.write("A19", "SystemMaxHeatOutputDeltaTempF", bold) - w.write("B19", flo.params.SystemMaxHeatOutputDeltaTempF) - - w.write("A20", "SystemMaxHeatOutputGpm", derived_format_bold) - w.write("B20", round(flo.params.SystemMaxHeatOutputGpm, 2), derived_format) - else: - w.write("A19", "SystemMaxHeatOutputDeltaTempF", bold) - w.write("B19", flo.params.SystemMaxHeatOutputDeltaTempF) - - w.write("A20", "SystemMaxHeatOutputGpm", derived_format_bold) - w.write("B20", round(flo.params.SystemMaxHeatOutputGpm, 2), derived_format) - - w.write("A21", "Cop1TempF", bold) - w.write("B21", flo.params.Cop1TempF) - - w.write("A22", "Cop4TempF", bold) - w.write("B22", flo.params.Cop4TempF) - - w.write("A23", "StorePassiveLossRatio", bold) - w.write("B23", flo.params.StorePassiveLossRatio) - - w.write("A25", "StorageSteps", bold) - w.write("B25", flo.params.StorageSteps) - - ############# - - annual_kwh = tea_params.annual_hvac_kwh_th - annual_btu = round(cf.BTU_PER_KWH * annual_kwh) - w.write("D3", "Annual HVAC kWhTh", bold) - w.write("E3", annual_kwh) - - w.write("D4", "Annual HVAC MBTU", derived_format_bold) - w.write("E4", round(annual_btu / 10**6), derived_format) - - w.write("D6", "StoreSizeGallons", bold) - w.write("E6", flo.params.StoreSizeGallons) - - w.write("D7", "MaxStoreTempF", bold) - w.write("E7", flo.params.MaxStoreTempF) - - w.write("D8", "ZeroPotentialEnergyWaterTempF", bold) - w.write("E8", flo.params.ZeroPotentialEnergyWaterTempF) - - w.write("D9", "TotalStorageKwh", derived_format_bold) - w.write("E9", round(flo.max_energy_kwh_th, 1), derived_format) - - w.write("D10", "TotalStorage BTU", derived_format_bold) - w.write("E10", round(cf.BTU_PER_KWH * flo.max_energy_kwh_th), derived_format) - - w.write("D12", "SistPumpFeedbackModel", bold) - w.write("E12", flo.params.DistPumpFeedbackModel.value) - - w.write("D13", "MixingValveFeedbackModel", bold) - w.write("E13", flo.params.MixingValveFeedbackModel.value) - - w.write("D14", "IsRegulating", bold) - w.write("E14", flo.params.IsRegulating) - - w.write("D19", "RoomTempF", bold) - w.write("E19", flo.params.RoomTempF) - - w.write("D20", "AmbientPowerInKw", bold) - w.write("E20", flo.params.AmbientPowerInKw) - - ############### - - w.write("G3", "HeatpumpTariff", bold) - w.write("H3", flo.params.HeatpumpTariff.value) - - w.write("G4", "HeatpumpEnergySupplyType", bold) - w.write("H4", flo.params.HeatpumpEnergySupplyType.value) - - w.write("G5", "BoostTariff", bold) - w.write("H5", flo.params.BoostTariff.value) - - w.write("G6", "BoostEnergySupplyType", bold) - w.write("H6", flo.params.BoostEnergySupplyType.value) - - w.write("G7", "StandardOfferPriceDollarsPerMwh", bold) - w.write("H7", flo.params.StandardOfferPriceDollarsPerMwh) - - w.write("G8", "DistributionTariffDollarsPerMwh", bold) - w.write("H8", flo.params.DistributionTariffDollarsPerMwh) - - file = weather_forecast_sync_100_handler.csv_file_by_uid(flo.params.WeatherUid) - w.write("A28", "LocalWeatherFile", bold) - w.write("B28", file) - w.write("A29", "WeatherUid", bold) - w.write("B29", flo.params.WeatherUid) - - w.write("A30", "LocalRtPriceFile", bold) - w.write( - "B30", - eprt_sync_100_handler.csv_file_by_uid(price_uid=flo.params.RtElecPriceUid), - ) - - w.write("A31", "RtElecPriceUid", bold) - w.write("B31", flo.params.RtElecPriceUid) - - w.write("A32", "LocalDistPriceFile", bold) - w.write( - "B32", distp_sync_100_handler.csv_file_by_uid(price_uid=flo.params.DistPriceUid) - ) - w.write("A33", "DistPriceUid", bold) - w.write("B33", flo.params.DistPriceUid) - - w.write("A35", "Heat Profile", bold) - w.write("B35", tea_params.scaled_heat_profile_csv) - - if flo.params.IsRegulating: - w.write("A34", "LocalRegulationFile", bold) - w.write("B34", regp_sync_100_handler.csv_file_by_uid(flo.params.RegPriceUid)) - w.write("A35", "RegPriceUid", bold) - w.write("B35", flo.params.RegPriceUid) diff --git a/src/gwatn/types/__init__.py b/src/gwatn/types/__init__.py index 6c4be83..6446f13 100644 --- a/src/gwatn/types/__init__.py +++ b/src/gwatn/types/__init__.py @@ -95,6 +95,8 @@ from gwatn.types.accepted_bid import AcceptedBid_Maker from gwatn.types.atn_bid import AtnBid from gwatn.types.atn_bid import AtnBid_Maker +from gwatn.types.atn_outside_temp_regr_coeffs import AtnOutsideTempRegrCoeffs +from gwatn.types.atn_outside_temp_regr_coeffs import AtnOutsideTempRegrCoeffs_Maker from gwatn.types.atn_params import AtnParams from gwatn.types.atn_params import AtnParams_Maker from gwatn.types.atn_params_brickstorageheater import AtnParamsBrickstorageheater @@ -180,6 +182,8 @@ "AcceptedBid_Maker", "AtnBid", "AtnBid_Maker", + "AtnOutsideTempRegrCoeffs", + "AtnOutsideTempRegrCoeffs_Maker", "AtnParams", "AtnParams_Maker", "AtnParamsBrickstorageheater", diff --git a/src/gwatn/types/atn_outside_temp_regr_coeffs.py b/src/gwatn/types/atn_outside_temp_regr_coeffs.py new file mode 100644 index 0000000..948a716 --- /dev/null +++ b/src/gwatn/types/atn_outside_temp_regr_coeffs.py @@ -0,0 +1,93 @@ +"""Type atn.outside.temp.regr.coeffs, version 000""" +import json +from typing import Any +from typing import Dict +from typing import Literal + +from gridworks.errors import SchemaError +from pydantic import BaseModel +from pydantic import Field +from pydantic import validator + + +class AtnOutsideTempRegrCoeffs(BaseModel): + """. + + Coefficients for a linear regression of avg power leaving a building as a function of weather: + + PowerOut = Alpha + Beta * OutsideTempF + + These are an example of Slowly Varying State variables maintained for a thermal storage heating Terminal Asset by + its AtomicTNode and Scada. + [More info](https://gridworks-atn.readthedocs.io/en/latest/data-categories.html#slowly-varying-state-variables). + """ + + Alpha: int = Field( + title="Alpha (units: W)", + default=200, + ) + Beta: float = Field( + title="Beta (units: W / deg F) ", + default=-1.5, + ) + TypeName: Literal["atn.outside.temp.regr.coeffs"] = "atn.outside.temp.regr.coeffs" + Version: str = "000" + + def as_dict(self) -> Dict[str, Any]: + d = self.dict() + return d + + def as_type(self) -> str: + return json.dumps(self.as_dict()) + + def __hash__(self): + return hash((type(self),) + tuple(self.__dict__.values())) # noqa + + +class AtnOutsideTempRegrCoeffs_Maker: + type_name = "atn.outside.temp.regr.coeffs" + version = "000" + + def __init__(self, alpha: int, beta: float): + self.tuple = AtnOutsideTempRegrCoeffs( + Alpha=alpha, + Beta=beta, + # + ) + + @classmethod + def tuple_to_type(cls, tuple: AtnOutsideTempRegrCoeffs) -> str: + """ + Given a Python class object, returns the serialized JSON type object + """ + return tuple.as_type() + + @classmethod + def type_to_tuple(cls, t: str) -> AtnOutsideTempRegrCoeffs: + """ + Given a serialized JSON type object, returns the Python class object + """ + try: + d = json.loads(t) + except TypeError: + raise SchemaError("Type must be string or bytes!") + if not isinstance(d, dict): + raise SchemaError(f"Deserializing {t} must result in dict!") + return cls.dict_to_tuple(d) + + @classmethod + def dict_to_tuple(cls, d: dict[str, Any]) -> AtnOutsideTempRegrCoeffs: + d2 = dict(d) + if "Alpha" not in d2.keys(): + raise SchemaError(f"dict {d2} missing Alpha") + if "Beta" not in d2.keys(): + raise SchemaError(f"dict {d2} missing Beta") + if "TypeName" not in d2.keys(): + raise SchemaError(f"dict {d2} missing TypeName") + + return AtnOutsideTempRegrCoeffs( + Alpha=d2["Alpha"], + Beta=d2["Beta"], + TypeName=d2["TypeName"], + Version="000", + ) diff --git a/src/gwatn/types/atn_params_simpleresistivehydronic.py b/src/gwatn/types/atn_params_simpleresistivehydronic.py index 0358cee..e267c41 100644 --- a/src/gwatn/types/atn_params_simpleresistivehydronic.py +++ b/src/gwatn/types/atn_params_simpleresistivehydronic.py @@ -276,8 +276,8 @@ class AtnParamsSimpleresistivehydronic(BaseModel): title="StandardOfferPriceDollarsPerMwh", default=110, ) - DistributionTariffDollarsPerMwh: int = Field( - title="DistributionTariffDollarsPerMwh", + FlatDistributionTariffDollarsPerMwh: int = Field( + title="FlatDistributionTariffDollarsPerMwh", default=113, ) StoreSizeGallons: int = Field( @@ -296,12 +296,12 @@ class AtnParamsSimpleresistivehydronic(BaseModel): title="RequiredSourceWaterTempF", default=120, ) - FixedPumpGpm: float = Field( - title="FixedPumpGpm", + CirculatorPumpGpm: float = Field( + title="CirculatorPumpGpm", default=5.5, ) - ReturnWaterFixedDeltaT: int = Field( - title="ReturnWaterFixedDeltaT", + ReturnWaterDeltaTempF: int = Field( + title="ReturnWaterDeltaTempF", default=20, ) AnnualHvacKwhTh: int = Field( @@ -320,6 +320,14 @@ class AtnParamsSimpleresistivehydronic(BaseModel): title="RoomTempF", default=68, ) + StorePassiveLossRatio: float = Field( + title="StorePassiveLossRatio", + default=0.005, + ) + AmbientTempStoreF: int = Field( + title="AmbientTempStoreF", + default=65, + ) TypeName: Literal[ "atn.params.simpleresistivehydronic" ] = "atn.params.simpleresistivehydronic" @@ -387,17 +395,19 @@ def __init__( tariff: DistributionTariff, energy_type: EnergySupplyType, standard_offer_price_dollars_per_mwh: int, - distribution_tariff_dollars_per_mwh: int, + flat_distribution_tariff_dollars_per_mwh: int, store_size_gallons: int, max_store_temp_f: int, element_max_power_kw: float, required_source_water_temp_f: int, - fixed_pump_gpm: float, - return_water_fixed_delta_t: int, + circulator_pump_gpm: float, + return_water_delta_temp_f: int, annual_hvac_kwh_th: int, ambient_power_in_kw: float, house_worst_case_temp_f: int, room_temp_f: int, + store_passive_loss_ratio: float, + ambient_temp_store_f: int, ): self.tuple = AtnParamsSimpleresistivehydronic( GNodeAlias=g_node_alias, @@ -410,17 +420,19 @@ def __init__( Tariff=tariff, EnergyType=energy_type, StandardOfferPriceDollarsPerMwh=standard_offer_price_dollars_per_mwh, - DistributionTariffDollarsPerMwh=distribution_tariff_dollars_per_mwh, + FlatDistributionTariffDollarsPerMwh=flat_distribution_tariff_dollars_per_mwh, StoreSizeGallons=store_size_gallons, MaxStoreTempF=max_store_temp_f, ElementMaxPowerKw=element_max_power_kw, RequiredSourceWaterTempF=required_source_water_temp_f, - FixedPumpGpm=fixed_pump_gpm, - ReturnWaterFixedDeltaT=return_water_fixed_delta_t, + CirculatorPumpGpm=circulator_pump_gpm, + ReturnWaterDeltaTempF=return_water_delta_temp_f, AnnualHvacKwhTh=annual_hvac_kwh_th, AmbientPowerInKw=ambient_power_in_kw, HouseWorstCaseTempF=house_worst_case_temp_f, RoomTempF=room_temp_f, + StorePassiveLossRatio=store_passive_loss_ratio, + AmbientTempStoreF=ambient_temp_store_f, # ) @@ -486,8 +498,8 @@ def dict_to_tuple(cls, d: dict[str, Any]) -> AtnParamsSimpleresistivehydronic: d2["EnergyType"] = EnergySupplyType.default() if "StandardOfferPriceDollarsPerMwh" not in d2.keys(): raise SchemaError(f"dict {d2} missing StandardOfferPriceDollarsPerMwh") - if "DistributionTariffDollarsPerMwh" not in d2.keys(): - raise SchemaError(f"dict {d2} missing DistributionTariffDollarsPerMwh") + if "FlatDistributionTariffDollarsPerMwh" not in d2.keys(): + raise SchemaError(f"dict {d2} missing FlatDistributionTariffDollarsPerMwh") if "StoreSizeGallons" not in d2.keys(): raise SchemaError(f"dict {d2} missing StoreSizeGallons") if "MaxStoreTempF" not in d2.keys(): @@ -496,10 +508,10 @@ def dict_to_tuple(cls, d: dict[str, Any]) -> AtnParamsSimpleresistivehydronic: raise SchemaError(f"dict {d2} missing ElementMaxPowerKw") if "RequiredSourceWaterTempF" not in d2.keys(): raise SchemaError(f"dict {d2} missing RequiredSourceWaterTempF") - if "FixedPumpGpm" not in d2.keys(): - raise SchemaError(f"dict {d2} missing FixedPumpGpm") - if "ReturnWaterFixedDeltaT" not in d2.keys(): - raise SchemaError(f"dict {d2} missing ReturnWaterFixedDeltaT") + if "CirculatorPumpGpm" not in d2.keys(): + raise SchemaError(f"dict {d2} missing CirculatorPumpGpm") + if "ReturnWaterDeltaTempF" not in d2.keys(): + raise SchemaError(f"dict {d2} missing ReturnWaterDeltaTempF") if "AnnualHvacKwhTh" not in d2.keys(): raise SchemaError(f"dict {d2} missing AnnualHvacKwhTh") if "AmbientPowerInKw" not in d2.keys(): @@ -508,6 +520,10 @@ def dict_to_tuple(cls, d: dict[str, Any]) -> AtnParamsSimpleresistivehydronic: raise SchemaError(f"dict {d2} missing HouseWorstCaseTempF") if "RoomTempF" not in d2.keys(): raise SchemaError(f"dict {d2} missing RoomTempF") + if "StorePassiveLossRatio" not in d2.keys(): + raise SchemaError(f"dict {d2} missing StorePassiveLossRatio") + if "AmbientTempStoreF" not in d2.keys(): + raise SchemaError(f"dict {d2} missing AmbientTempStoreF") if "TypeName" not in d2.keys(): raise SchemaError(f"dict {d2} missing TypeName") @@ -522,17 +538,21 @@ def dict_to_tuple(cls, d: dict[str, Any]) -> AtnParamsSimpleresistivehydronic: Tariff=d2["Tariff"], EnergyType=d2["EnergyType"], StandardOfferPriceDollarsPerMwh=d2["StandardOfferPriceDollarsPerMwh"], - DistributionTariffDollarsPerMwh=d2["DistributionTariffDollarsPerMwh"], + FlatDistributionTariffDollarsPerMwh=d2[ + "FlatDistributionTariffDollarsPerMwh" + ], StoreSizeGallons=d2["StoreSizeGallons"], MaxStoreTempF=d2["MaxStoreTempF"], ElementMaxPowerKw=d2["ElementMaxPowerKw"], RequiredSourceWaterTempF=d2["RequiredSourceWaterTempF"], - FixedPumpGpm=d2["FixedPumpGpm"], - ReturnWaterFixedDeltaT=d2["ReturnWaterFixedDeltaT"], + CirculatorPumpGpm=d2["CirculatorPumpGpm"], + ReturnWaterDeltaTempF=d2["ReturnWaterDeltaTempF"], AnnualHvacKwhTh=d2["AnnualHvacKwhTh"], AmbientPowerInKw=d2["AmbientPowerInKw"], HouseWorstCaseTempF=d2["HouseWorstCaseTempF"], RoomTempF=d2["RoomTempF"], + StorePassiveLossRatio=d2["StorePassiveLossRatio"], + AmbientTempStoreF=d2["AmbientTempStoreF"], TypeName=d2["TypeName"], Version="000", ) diff --git a/src/gwatn/types/flo_params_simpleresistivehydronic.py b/src/gwatn/types/flo_params_simpleresistivehydronic.py index 3a76893..8694269 100644 --- a/src/gwatn/types/flo_params_simpleresistivehydronic.py +++ b/src/gwatn/types/flo_params_simpleresistivehydronic.py @@ -13,9 +13,76 @@ from pydantic import Field from pydantic import validator +from gwatn.enums import DistributionTariff +from gwatn.enums import EnergySupplyType from gwatn.enums import RecognizedCurrencyUnit +class DistributionTariff000SchemaEnum: + enum_name: str = "distribution.tariff.000" + symbols: List[str] = [ + "00000000", + "2127aba6", + "ea5c675a", + "54aec3a7", + ] + + @classmethod + def is_symbol(cls, candidate: str) -> bool: + if candidate in cls.symbols: + return True + return False + + +class DistributionTariff000(StrEnum): + Unknown = auto() + VersantA1StorageHeatTariff = auto() + VersantATariff = auto() + VersantA20HeatTariff = auto() + + @classmethod + def default(cls) -> "DistributionTariff000": + return cls.Unknown + + @classmethod + def values(cls) -> List[str]: + return [elt.value for elt in cls] + + +class DistributionTariffMap: + @classmethod + def type_to_local(cls, symbol: str) -> DistributionTariff: + if not DistributionTariff000SchemaEnum.is_symbol(symbol): + raise SchemaError(f"{symbol} must belong to DistributionTariff000 symbols") + versioned_enum = cls.type_to_versioned_enum_dict[symbol] + return as_enum(versioned_enum, DistributionTariff, DistributionTariff.default()) + + @classmethod + def local_to_type(cls, distribution_tariff: DistributionTariff) -> str: + if not isinstance(distribution_tariff, DistributionTariff): + raise SchemaError( + f"{distribution_tariff} must be of type {DistributionTariff}" + ) + versioned_enum = as_enum( + distribution_tariff, DistributionTariff000, DistributionTariff000.default() + ) + return cls.versioned_enum_to_type_dict[versioned_enum] + + type_to_versioned_enum_dict: Dict[str, DistributionTariff000] = { + "00000000": DistributionTariff000.Unknown, + "2127aba6": DistributionTariff000.VersantA1StorageHeatTariff, + "ea5c675a": DistributionTariff000.VersantATariff, + "54aec3a7": DistributionTariff000.VersantA20HeatTariff, + } + + versioned_enum_to_type_dict: Dict[DistributionTariff000, str] = { + DistributionTariff000.Unknown: "00000000", + DistributionTariff000.VersantA1StorageHeatTariff: "2127aba6", + DistributionTariff000.VersantATariff: "ea5c675a", + DistributionTariff000.VersantA20HeatTariff: "54aec3a7", + } + + class RecognizedCurrencyUnit000SchemaEnum: enum_name: str = "recognized.currency.unit.000" symbols: List[str] = [ @@ -83,6 +150,67 @@ def local_to_type(cls, recognized_currency_unit: RecognizedCurrencyUnit) -> str: } +class EnergySupplyType000SchemaEnum: + enum_name: str = "energy.supply.type.000" + symbols: List[str] = [ + "00000000", + "cb18f937", + "e9dc99a6", + ] + + @classmethod + def is_symbol(cls, candidate: str) -> bool: + if candidate in cls.symbols: + return True + return False + + +class EnergySupplyType000(StrEnum): + Unknown = auto() + StandardOffer = auto() + RealtimeLocalLmp = auto() + + @classmethod + def default(cls) -> "EnergySupplyType000": + return cls.Unknown + + @classmethod + def values(cls) -> List[str]: + return [elt.value for elt in cls] + + +class EnergySupplyTypeMap: + @classmethod + def type_to_local(cls, symbol: str) -> EnergySupplyType: + if not EnergySupplyType000SchemaEnum.is_symbol(symbol): + raise SchemaError(f"{symbol} must belong to EnergySupplyType000 symbols") + versioned_enum = cls.type_to_versioned_enum_dict[symbol] + return as_enum(versioned_enum, EnergySupplyType, EnergySupplyType.default()) + + @classmethod + def local_to_type(cls, energy_supply_type: EnergySupplyType) -> str: + if not isinstance(energy_supply_type, EnergySupplyType): + raise SchemaError( + f"{energy_supply_type} must be of type {EnergySupplyType}" + ) + versioned_enum = as_enum( + energy_supply_type, EnergySupplyType000, EnergySupplyType000.default() + ) + return cls.versioned_enum_to_type_dict[versioned_enum] + + type_to_versioned_enum_dict: Dict[str, EnergySupplyType000] = { + "00000000": EnergySupplyType000.Unknown, + "cb18f937": EnergySupplyType000.StandardOffer, + "e9dc99a6": EnergySupplyType000.RealtimeLocalLmp, + } + + versioned_enum_to_type_dict: Dict[EnergySupplyType000, str] = { + EnergySupplyType000.Unknown: "00000000", + EnergySupplyType000.StandardOffer: "cb18f937", + EnergySupplyType000.RealtimeLocalLmp: "e9dc99a6", + } + + def check_is_uuid_canonical_textual(v: str) -> None: """ UuidCanonicalTextual format: A string of hex words separated by hyphens @@ -176,63 +304,107 @@ class FloParamsSimpleresistivehydronic(BaseModel): title="StartMinuteUtc", default=0, ) + StorageSteps: int = Field( + title="StorageSteps", + default=100, + ) StoreSizeGallons: int = Field( title="StoreSizeGallons", default=240, ) MaxStoreTempF: int = Field( title="MaxStoreTempF", - default=190, + default=210, ) - ElementMaxPowerKw: float = Field( - title="ElementMaxPowerKw", + RatedPowerKw: float = Field( + title="RatedPowerKw", default=9.5, ) RequiredSourceWaterTempF: int = Field( title="RequiredSourceWaterTempF", default=120, ) - FixedPumpGpm: float = Field( - title="FixedPumpGpm", + CirculatorPumpGpm: float = Field( + title="CirculatorPumpGpm", default=4.5, ) - ReturnWaterFixedDeltaT: int = Field( - title="ReturnWaterFixedDeltaT", + ReturnWaterDeltaTempF: int = Field( + title="ReturnWaterDeltaTempF", default=20, ) - SliceDurationMinutes: List[int] = Field( - title="SliceDurationMinutes", - default=[60], + RoomTempF: int = Field( + title="RoomTempF", + default=70, + ) + AmbientPowerInKw: float = Field( + title="AmbientPowerInKw", + default=1.2, + ) + HouseWorstCaseTempF: float = Field( + title="HouseWorstCaseTempF", + default=-7, + ) + StorePassiveLossRatio: float = Field( + title="StorePassiveLossRatio", + default=0.005, ) PowerLostFromHouseKwList: List[float] = Field( title="PowerLostFromHouseKwList", default=[3.42], ) - OutsideTempF: List[float] = Field( - title="OutsideTempF", - default=[-5.1], + AmbientTempStoreF: int = Field( + title="AmbientTempStoreF", + default=65, ) - DistributionPrice: List[float] = Field( - title="DistributionPrice", - default=[40.0], + SliceDurationMinutes: List[int] = Field( + title="SliceDurationMinutes", + default=[60], ) RealtimeElectricityPrice: List[float] = Field( title="RealtimeElectricityPrice", default=[10.35], ) + DistributionPrice: List[float] = Field( + title="DistributionPrice", + default=[40.0], + ) + OutsideTempF: List[float] = Field( + title="OutsideTempF", + default=[-5.1], + ) RtElecPriceUid: str = Field( title="RtElecPriceUid", ) - WeatherUid: str = Field( - title="WeatherUid", - ) DistPriceUid: str = Field( title="DistPriceUid", ) + WeatherUid: str = Field( + title="WeatherUid", + ) CurrencyUnit: RecognizedCurrencyUnit = Field( title="CurrencyUnit", default=RecognizedCurrencyUnit.USD, ) + Tariff: DistributionTariff = Field( + title="Tariff", + default=DistributionTariff.VersantA1StorageHeatTariff, + ) + EnergyType: EnergySupplyType = Field( + title="EnergyType", + default=EnergySupplyType.RealtimeLocalLmp, + ) + StandardOfferPriceDollarsPerMwh: int = Field( + title="StandardOfferPriceDollarsPerMwh", + default=110, + ) + FlatDistributionTariffDollarsPerMwh: int = Field( + title="FlatDistributionTariffDollarsPerMwh", + default=113, + ) + StartingStoreIdx: int = Field( + title="StartingStoreIdx", + default=50, + ) TypeName: Literal[ "flo.params.simpleresistivehydronic" ] = "flo.params.simpleresistivehydronic" @@ -266,23 +438,23 @@ def _check_rt_elec_price_uid(cls, v: str) -> str: ) return v - @validator("WeatherUid") - def _check_weather_uid(cls, v: str) -> str: + @validator("DistPriceUid") + def _check_dist_price_uid(cls, v: str) -> str: try: check_is_uuid_canonical_textual(v) except ValueError as e: raise ValueError( - f"WeatherUid failed UuidCanonicalTextual format validation: {e}" + f"DistPriceUid failed UuidCanonicalTextual format validation: {e}" ) return v - @validator("DistPriceUid") - def _check_dist_price_uid(cls, v: str) -> str: + @validator("WeatherUid") + def _check_weather_uid(cls, v: str) -> str: try: check_is_uuid_canonical_textual(v) except ValueError as e: raise ValueError( - f"DistPriceUid failed UuidCanonicalTextual format validation: {e}" + f"WeatherUid failed UuidCanonicalTextual format validation: {e}" ) return v @@ -290,6 +462,14 @@ def _check_dist_price_uid(cls, v: str) -> str: def _check_currency_unit(cls, v: RecognizedCurrencyUnit) -> RecognizedCurrencyUnit: return as_enum(v, RecognizedCurrencyUnit, RecognizedCurrencyUnit.UNKNOWN) + @validator("Tariff") + def _check_tariff(cls, v: DistributionTariff) -> DistributionTariff: + return as_enum(v, DistributionTariff, DistributionTariff.Unknown) + + @validator("EnergyType") + def _check_energy_type(cls, v: EnergySupplyType) -> EnergySupplyType: + return as_enum(v, EnergySupplyType, EnergySupplyType.Unknown) + def as_dict(self) -> Dict[str, Any]: d = self.dict() del d["CurrencyUnit"] @@ -299,6 +479,14 @@ def as_dict(self) -> Dict[str, Any]: d["CurrencyUnitGtEnumSymbol"] = RecognizedCurrencyUnitMap.local_to_type( CurrencyUnit ) + del d["Tariff"] + Tariff = as_enum(self.Tariff, DistributionTariff, DistributionTariff.default()) + d["TariffGtEnumSymbol"] = DistributionTariffMap.local_to_type(Tariff) + del d["EnergyType"] + EnergyType = as_enum( + self.EnergyType, EnergySupplyType, EnergySupplyType.default() + ) + d["EnergyTypeGtEnumSymbol"] = EnergySupplyTypeMap.local_to_type(EnergyType) return d def as_type(self) -> str: @@ -323,21 +511,32 @@ def __init__( start_day_utc: int, start_hour_utc: int, start_minute_utc: int, + storage_steps: int, store_size_gallons: int, max_store_temp_f: int, - element_max_power_kw: float, + rated_power_kw: float, required_source_water_temp_f: int, - fixed_pump_gpm: float, - return_water_fixed_delta_t: int, - slice_duration_minutes: List[int], + circulator_pump_gpm: float, + return_water_delta_temp_f: int, + room_temp_f: int, + ambient_power_in_kw: float, + house_worst_case_temp_f: float, + store_passive_loss_ratio: float, power_lost_from_house_kw_list: List[float], - outside_temp_f: List[float], - distribution_price: List[float], + ambient_temp_store_f: int, + slice_duration_minutes: List[int], realtime_electricity_price: List[float], + distribution_price: List[float], + outside_temp_f: List[float], rt_elec_price_uid: str, - weather_uid: str, dist_price_uid: str, + weather_uid: str, currency_unit: RecognizedCurrencyUnit, + tariff: DistributionTariff, + energy_type: EnergySupplyType, + standard_offer_price_dollars_per_mwh: int, + flat_distribution_tariff_dollars_per_mwh: int, + starting_store_idx: int, ): self.tuple = FloParamsSimpleresistivehydronic( GNodeAlias=g_node_alias, @@ -349,21 +548,32 @@ def __init__( StartDayUtc=start_day_utc, StartHourUtc=start_hour_utc, StartMinuteUtc=start_minute_utc, + StorageSteps=storage_steps, StoreSizeGallons=store_size_gallons, MaxStoreTempF=max_store_temp_f, - ElementMaxPowerKw=element_max_power_kw, + RatedPowerKw=rated_power_kw, RequiredSourceWaterTempF=required_source_water_temp_f, - FixedPumpGpm=fixed_pump_gpm, - ReturnWaterFixedDeltaT=return_water_fixed_delta_t, - SliceDurationMinutes=slice_duration_minutes, + CirculatorPumpGpm=circulator_pump_gpm, + ReturnWaterDeltaTempF=return_water_delta_temp_f, + RoomTempF=room_temp_f, + AmbientPowerInKw=ambient_power_in_kw, + HouseWorstCaseTempF=house_worst_case_temp_f, + StorePassiveLossRatio=store_passive_loss_ratio, PowerLostFromHouseKwList=power_lost_from_house_kw_list, - OutsideTempF=outside_temp_f, - DistributionPrice=distribution_price, + AmbientTempStoreF=ambient_temp_store_f, + SliceDurationMinutes=slice_duration_minutes, RealtimeElectricityPrice=realtime_electricity_price, + DistributionPrice=distribution_price, + OutsideTempF=outside_temp_f, RtElecPriceUid=rt_elec_price_uid, - WeatherUid=weather_uid, DistPriceUid=dist_price_uid, + WeatherUid=weather_uid, CurrencyUnit=currency_unit, + Tariff=tariff, + EnergyType=energy_type, + StandardOfferPriceDollarsPerMwh=standard_offer_price_dollars_per_mwh, + FlatDistributionTariffDollarsPerMwh=flat_distribution_tariff_dollars_per_mwh, + StartingStoreIdx=starting_store_idx, # ) @@ -408,34 +618,46 @@ def dict_to_tuple(cls, d: dict[str, Any]) -> FloParamsSimpleresistivehydronic: raise SchemaError(f"dict {d2} missing StartHourUtc") if "StartMinuteUtc" not in d2.keys(): raise SchemaError(f"dict {d2} missing StartMinuteUtc") + if "StorageSteps" not in d2.keys(): + raise SchemaError(f"dict {d2} missing StorageSteps") if "StoreSizeGallons" not in d2.keys(): raise SchemaError(f"dict {d2} missing StoreSizeGallons") if "MaxStoreTempF" not in d2.keys(): raise SchemaError(f"dict {d2} missing MaxStoreTempF") - if "ElementMaxPowerKw" not in d2.keys(): - raise SchemaError(f"dict {d2} missing ElementMaxPowerKw") + if "RatedPowerKw" not in d2.keys(): + raise SchemaError(f"dict {d2} missing RatedPowerKw") if "RequiredSourceWaterTempF" not in d2.keys(): raise SchemaError(f"dict {d2} missing RequiredSourceWaterTempF") - if "FixedPumpGpm" not in d2.keys(): - raise SchemaError(f"dict {d2} missing FixedPumpGpm") - if "ReturnWaterFixedDeltaT" not in d2.keys(): - raise SchemaError(f"dict {d2} missing ReturnWaterFixedDeltaT") - if "SliceDurationMinutes" not in d2.keys(): - raise SchemaError(f"dict {d2} missing SliceDurationMinutes") + if "CirculatorPumpGpm" not in d2.keys(): + raise SchemaError(f"dict {d2} missing CirculatorPumpGpm") + if "ReturnWaterDeltaTempF" not in d2.keys(): + raise SchemaError(f"dict {d2} missing ReturnWaterDeltaTempF") + if "RoomTempF" not in d2.keys(): + raise SchemaError(f"dict {d2} missing RoomTempF") + if "AmbientPowerInKw" not in d2.keys(): + raise SchemaError(f"dict {d2} missing AmbientPowerInKw") + if "HouseWorstCaseTempF" not in d2.keys(): + raise SchemaError(f"dict {d2} missing HouseWorstCaseTempF") + if "StorePassiveLossRatio" not in d2.keys(): + raise SchemaError(f"dict {d2} missing StorePassiveLossRatio") if "PowerLostFromHouseKwList" not in d2.keys(): raise SchemaError(f"dict {d2} missing PowerLostFromHouseKwList") - if "OutsideTempF" not in d2.keys(): - raise SchemaError(f"dict {d2} missing OutsideTempF") - if "DistributionPrice" not in d2.keys(): - raise SchemaError(f"dict {d2} missing DistributionPrice") + if "AmbientTempStoreF" not in d2.keys(): + raise SchemaError(f"dict {d2} missing AmbientTempStoreF") + if "SliceDurationMinutes" not in d2.keys(): + raise SchemaError(f"dict {d2} missing SliceDurationMinutes") if "RealtimeElectricityPrice" not in d2.keys(): raise SchemaError(f"dict {d2} missing RealtimeElectricityPrice") + if "DistributionPrice" not in d2.keys(): + raise SchemaError(f"dict {d2} missing DistributionPrice") + if "OutsideTempF" not in d2.keys(): + raise SchemaError(f"dict {d2} missing OutsideTempF") if "RtElecPriceUid" not in d2.keys(): raise SchemaError(f"dict {d2} missing RtElecPriceUid") - if "WeatherUid" not in d2.keys(): - raise SchemaError(f"dict {d2} missing WeatherUid") if "DistPriceUid" not in d2.keys(): raise SchemaError(f"dict {d2} missing DistPriceUid") + if "WeatherUid" not in d2.keys(): + raise SchemaError(f"dict {d2} missing WeatherUid") if "CurrencyUnitGtEnumSymbol" not in d2.keys(): raise SchemaError(f"dict {d2} missing CurrencyUnitGtEnumSymbol") if ( @@ -447,6 +669,26 @@ def dict_to_tuple(cls, d: dict[str, Any]) -> FloParamsSimpleresistivehydronic: ) else: d2["CurrencyUnit"] = RecognizedCurrencyUnit.default() + if "TariffGtEnumSymbol" not in d2.keys(): + raise SchemaError(f"dict {d2} missing TariffGtEnumSymbol") + if d2["TariffGtEnumSymbol"] in DistributionTariff000SchemaEnum.symbols: + d2["Tariff"] = DistributionTariffMap.type_to_local(d2["TariffGtEnumSymbol"]) + else: + d2["Tariff"] = DistributionTariff.default() + if "EnergyTypeGtEnumSymbol" not in d2.keys(): + raise SchemaError(f"dict {d2} missing EnergyTypeGtEnumSymbol") + if d2["EnergyTypeGtEnumSymbol"] in EnergySupplyType000SchemaEnum.symbols: + d2["EnergyType"] = EnergySupplyTypeMap.type_to_local( + d2["EnergyTypeGtEnumSymbol"] + ) + else: + d2["EnergyType"] = EnergySupplyType.default() + if "StandardOfferPriceDollarsPerMwh" not in d2.keys(): + raise SchemaError(f"dict {d2} missing StandardOfferPriceDollarsPerMwh") + if "FlatDistributionTariffDollarsPerMwh" not in d2.keys(): + raise SchemaError(f"dict {d2} missing FlatDistributionTariffDollarsPerMwh") + if "StartingStoreIdx" not in d2.keys(): + raise SchemaError(f"dict {d2} missing StartingStoreIdx") if "TypeName" not in d2.keys(): raise SchemaError(f"dict {d2} missing TypeName") @@ -460,21 +702,34 @@ def dict_to_tuple(cls, d: dict[str, Any]) -> FloParamsSimpleresistivehydronic: StartDayUtc=d2["StartDayUtc"], StartHourUtc=d2["StartHourUtc"], StartMinuteUtc=d2["StartMinuteUtc"], + StorageSteps=d2["StorageSteps"], StoreSizeGallons=d2["StoreSizeGallons"], MaxStoreTempF=d2["MaxStoreTempF"], - ElementMaxPowerKw=d2["ElementMaxPowerKw"], + RatedPowerKw=d2["RatedPowerKw"], RequiredSourceWaterTempF=d2["RequiredSourceWaterTempF"], - FixedPumpGpm=d2["FixedPumpGpm"], - ReturnWaterFixedDeltaT=d2["ReturnWaterFixedDeltaT"], - SliceDurationMinutes=d2["SliceDurationMinutes"], + CirculatorPumpGpm=d2["CirculatorPumpGpm"], + ReturnWaterDeltaTempF=d2["ReturnWaterDeltaTempF"], + RoomTempF=d2["RoomTempF"], + AmbientPowerInKw=d2["AmbientPowerInKw"], + HouseWorstCaseTempF=d2["HouseWorstCaseTempF"], + StorePassiveLossRatio=d2["StorePassiveLossRatio"], PowerLostFromHouseKwList=d2["PowerLostFromHouseKwList"], - OutsideTempF=d2["OutsideTempF"], - DistributionPrice=d2["DistributionPrice"], + AmbientTempStoreF=d2["AmbientTempStoreF"], + SliceDurationMinutes=d2["SliceDurationMinutes"], RealtimeElectricityPrice=d2["RealtimeElectricityPrice"], + DistributionPrice=d2["DistributionPrice"], + OutsideTempF=d2["OutsideTempF"], RtElecPriceUid=d2["RtElecPriceUid"], - WeatherUid=d2["WeatherUid"], DistPriceUid=d2["DistPriceUid"], + WeatherUid=d2["WeatherUid"], CurrencyUnit=d2["CurrencyUnit"], + Tariff=d2["Tariff"], + EnergyType=d2["EnergyType"], + StandardOfferPriceDollarsPerMwh=d2["StandardOfferPriceDollarsPerMwh"], + FlatDistributionTariffDollarsPerMwh=d2[ + "FlatDistributionTariffDollarsPerMwh" + ], + StartingStoreIdx=d2["StartingStoreIdx"], TypeName=d2["TypeName"], Version="000", ) diff --git a/tests/simple_resistive_hydronic/__init__.py b/tests/simple_resistive_hydronic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/simple_resistive_hydronic/test_flo.py b/tests/simple_resistive_hydronic/test_flo.py new file mode 100644 index 0000000..82074c7 --- /dev/null +++ b/tests/simple_resistive_hydronic/test_flo.py @@ -0,0 +1,58 @@ +import uuid + +import gridworks.conversion_factors as cf + +import gwatn.strategies.simple_resistive_hydronic.flo_utils as flo_utils +from gwatn.strategies.simple_resistive_hydronic.flo import ( + Flo_SimpleResistiveHydronic as Flo, +) +from gwatn.types import FloParamsSimpleresistivehydronic as FloParams + + +def test_flo_and_utils(): + params = FloParams( + GNodeAlias="dw1.test", + FloParamsUid=str(uuid.uuid4()), + RtElecPriceUid=str(uuid.uuid4()), + DistPriceUid=str(uuid.uuid4()), + WeatherUid=str(uuid.uuid4()), + ) + + # Testing get_max_energy_kwh + water_store_in_pounds = params.StoreSizeGallons * cf.POUNDS_OF_WATER_PER_GALLON + assert water_store_in_pounds == 2001.6 + rwt_f = params.RequiredSourceWaterTempF - params.ReturnWaterDeltaTempF + temp_delta_f = params.MaxStoreTempF - rwt_f + assert temp_delta_f == 110 + + # (pounds of water) * (temp_delta_f) is BTUS + energy_btu = water_store_in_pounds * temp_delta_f + assert energy_btu == 220176 + # cf.BTU_PER_KWH is 3412 + energy_kwh = energy_btu / cf.BTU_PER_KWH + assert 64.52 < energy_kwh < 64.53 + assert flo_utils.get_max_energy_kwh(params) == energy_kwh + + flo = Flo(params=params, d_graph_id=str(uuid.uuid4())) + + assert flo.max_energy_kwh == flo.e_step_wh * flo.params.StorageSteps / 1000 + + store_idx = 70 + ts_idx = 0 + # demonstrate that passive loss is the same whether or not the tank is stratified + # or perfectly mixed + hot_ratio = store_idx / params.StorageSteps + rwt_f = params.RequiredSourceWaterTempF - params.ReturnWaterDeltaTempF + avg_temp_f = hot_ratio * params.MaxStoreTempF + (1 - hot_ratio) * rwt_f + store_pounds = params.StoreSizeGallons * cf.POUNDS_OF_WATER_PER_GALLON + energy_above_ambient_wh = ( + (avg_temp_f - params.AmbientTempStoreF) * store_pounds * 1000 / cf.BTU_PER_KWH + ) + slice_hrs = params.SliceDurationMinutes[ts_idx] / 60 + passive_loss_this_slice = ( + params.StorePassiveLossRatio * slice_hrs * energy_above_ambient_wh + ) + # compare with results of flo_utils.get_passive_loss_wh, rounded to 3 + assert round(passive_loss_this_slice, 3) == round( + flo_utils.get_passive_loss_wh(params, ts_idx, store_idx), 3 + ) diff --git a/tests/types/test_atn_outside_temp_regr_coeffs.py b/tests/types/test_atn_outside_temp_regr_coeffs.py new file mode 100644 index 0000000..6002060 --- /dev/null +++ b/tests/types/test_atn_outside_temp_regr_coeffs.py @@ -0,0 +1,76 @@ +"""Tests atn.outside.temp.regr.coeffs type, version 000""" +import json + +import pytest +from gridworks.errors import SchemaError +from pydantic import ValidationError + +from gwatn.types import AtnOutsideTempRegrCoeffs_Maker as Maker + + +def test_atn_outside_temp_regr_coeffs_generated() -> None: + d = { + "Alpha": 200, + "Beta": -1.5, + "TypeName": "atn.outside.temp.regr.coeffs", + "Version": "000", + } + + with pytest.raises(SchemaError): + Maker.type_to_tuple(d) + + with pytest.raises(SchemaError): + Maker.type_to_tuple('"not a dict"') + + # Test type_to_tuple + gtype = json.dumps(d) + gtuple = Maker.type_to_tuple(gtype) + + # test type_to_tuple and tuple_to_type maps + assert Maker.type_to_tuple(Maker.tuple_to_type(gtuple)) == gtuple + + # test Maker init + t = Maker( + alpha=gtuple.Alpha, + beta=gtuple.Beta, + ).tuple + assert t == gtuple + + ###################################### + # SchemaError raised if missing a required attribute + ###################################### + + d2 = dict(d) + del d2["TypeName"] + with pytest.raises(SchemaError): + Maker.dict_to_tuple(d2) + + d2 = dict(d) + del d2["Alpha"] + with pytest.raises(SchemaError): + Maker.dict_to_tuple(d2) + + d2 = dict(d) + del d2["Beta"] + with pytest.raises(SchemaError): + Maker.dict_to_tuple(d2) + + ###################################### + # Behavior on incorrect types + ###################################### + + d2 = dict(d, Alpha="200.1") + with pytest.raises(ValidationError): + Maker.dict_to_tuple(d2) + + d2 = dict(d, Beta="this is not a float") + with pytest.raises(ValidationError): + Maker.dict_to_tuple(d2) + + ###################################### + # SchemaError raised if TypeName is incorrect + ###################################### + + d2 = dict(d, TypeName="not the type alias") + with pytest.raises(ValidationError): + Maker.dict_to_tuple(d2) diff --git a/tests/types/test_atn_params_simpleresistivehydronic.py b/tests/types/test_atn_params_simpleresistivehydronic.py index 046fb2a..63a47d7 100644 --- a/tests/types/test_atn_params_simpleresistivehydronic.py +++ b/tests/types/test_atn_params_simpleresistivehydronic.py @@ -23,17 +23,19 @@ def test_atn_params_simpleresistivehydronic_generated() -> None: "TariffGtEnumSymbol": "2127aba6", "EnergyTypeGtEnumSymbol": "e9dc99a6", "StandardOfferPriceDollarsPerMwh": 110, - "DistributionTariffDollarsPerMwh": 113, + "FlatDistributionTariffDollarsPerMwh": 113, "StoreSizeGallons": 240, "MaxStoreTempF": 210, "ElementMaxPowerKw": 9.5, "RequiredSourceWaterTempF": 120, - "FixedPumpGpm": 5.5, - "ReturnWaterFixedDeltaT": 20, + "CirculatorPumpGpm": 5.5, + "ReturnWaterDeltaTempF": 20, "AnnualHvacKwhTh": 25000, "AmbientPowerInKw": 1.2, "HouseWorstCaseTempF": -7, "RoomTempF": 68, + "StorePassiveLossRatio": 0.005, + "AmbientTempStoreF": 65, "TypeName": "atn.params.simpleresistivehydronic", "Version": "000", } @@ -63,17 +65,19 @@ def test_atn_params_simpleresistivehydronic_generated() -> None: tariff=gtuple.Tariff, energy_type=gtuple.EnergyType, standard_offer_price_dollars_per_mwh=gtuple.StandardOfferPriceDollarsPerMwh, - distribution_tariff_dollars_per_mwh=gtuple.DistributionTariffDollarsPerMwh, + flat_distribution_tariff_dollars_per_mwh=gtuple.FlatDistributionTariffDollarsPerMwh, store_size_gallons=gtuple.StoreSizeGallons, max_store_temp_f=gtuple.MaxStoreTempF, element_max_power_kw=gtuple.ElementMaxPowerKw, required_source_water_temp_f=gtuple.RequiredSourceWaterTempF, - fixed_pump_gpm=gtuple.FixedPumpGpm, - return_water_fixed_delta_t=gtuple.ReturnWaterFixedDeltaT, + circulator_pump_gpm=gtuple.CirculatorPumpGpm, + return_water_delta_temp_f=gtuple.ReturnWaterDeltaTempF, annual_hvac_kwh_th=gtuple.AnnualHvacKwhTh, ambient_power_in_kw=gtuple.AmbientPowerInKw, house_worst_case_temp_f=gtuple.HouseWorstCaseTempF, room_temp_f=gtuple.RoomTempF, + store_passive_loss_ratio=gtuple.StorePassiveLossRatio, + ambient_temp_store_f=gtuple.AmbientTempStoreF, ).tuple assert t == gtuple @@ -137,7 +141,7 @@ def test_atn_params_simpleresistivehydronic_generated() -> None: Maker.dict_to_tuple(d2) d2 = dict(d) - del d2["DistributionTariffDollarsPerMwh"] + del d2["FlatDistributionTariffDollarsPerMwh"] with pytest.raises(SchemaError): Maker.dict_to_tuple(d2) @@ -162,12 +166,12 @@ def test_atn_params_simpleresistivehydronic_generated() -> None: Maker.dict_to_tuple(d2) d2 = dict(d) - del d2["FixedPumpGpm"] + del d2["CirculatorPumpGpm"] with pytest.raises(SchemaError): Maker.dict_to_tuple(d2) d2 = dict(d) - del d2["ReturnWaterFixedDeltaT"] + del d2["ReturnWaterDeltaTempF"] with pytest.raises(SchemaError): Maker.dict_to_tuple(d2) @@ -191,6 +195,16 @@ def test_atn_params_simpleresistivehydronic_generated() -> None: with pytest.raises(SchemaError): Maker.dict_to_tuple(d2) + d2 = dict(d) + del d2["StorePassiveLossRatio"] + with pytest.raises(SchemaError): + Maker.dict_to_tuple(d2) + + d2 = dict(d) + del d2["AmbientTempStoreF"] + with pytest.raises(SchemaError): + Maker.dict_to_tuple(d2) + ###################################### # Behavior on incorrect types ###################################### @@ -220,7 +234,7 @@ def test_atn_params_simpleresistivehydronic_generated() -> None: with pytest.raises(ValidationError): Maker.dict_to_tuple(d2) - d2 = dict(d, DistributionTariffDollarsPerMwh="113.1") + d2 = dict(d, FlatDistributionTariffDollarsPerMwh="113.1") with pytest.raises(ValidationError): Maker.dict_to_tuple(d2) @@ -240,11 +254,11 @@ def test_atn_params_simpleresistivehydronic_generated() -> None: with pytest.raises(ValidationError): Maker.dict_to_tuple(d2) - d2 = dict(d, FixedPumpGpm="this is not a float") + d2 = dict(d, CirculatorPumpGpm="this is not a float") with pytest.raises(ValidationError): Maker.dict_to_tuple(d2) - d2 = dict(d, ReturnWaterFixedDeltaT="20.1") + d2 = dict(d, ReturnWaterDeltaTempF="20.1") with pytest.raises(ValidationError): Maker.dict_to_tuple(d2) @@ -264,6 +278,14 @@ def test_atn_params_simpleresistivehydronic_generated() -> None: with pytest.raises(ValidationError): Maker.dict_to_tuple(d2) + d2 = dict(d, StorePassiveLossRatio="this is not a float") + with pytest.raises(ValidationError): + Maker.dict_to_tuple(d2) + + d2 = dict(d, AmbientTempStoreF="65.1") + with pytest.raises(ValidationError): + Maker.dict_to_tuple(d2) + ###################################### # SchemaError raised if TypeName is incorrect ###################################### diff --git a/tests/types/test_flo_params_simpleresistivehydronic.py b/tests/types/test_flo_params_simpleresistivehydronic.py index 8b63722..12c2472 100644 --- a/tests/types/test_flo_params_simpleresistivehydronic.py +++ b/tests/types/test_flo_params_simpleresistivehydronic.py @@ -5,6 +5,8 @@ from gridworks.errors import SchemaError from pydantic import ValidationError +from gwatn.enums import DistributionTariff +from gwatn.enums import EnergySupplyType from gwatn.enums import RecognizedCurrencyUnit from gwatn.types import FloParamsSimpleresistivehydronic_Maker as Maker @@ -20,21 +22,32 @@ def test_flo_params_simpleresistivehydronic_generated() -> None: "StartDayUtc": 1, "StartHourUtc": 0, "StartMinuteUtc": 0, + "StorageSteps": 100, "StoreSizeGallons": 240, - "MaxStoreTempF": 190, - "ElementMaxPowerKw": 9.5, + "MaxStoreTempF": 210, + "RatedPowerKw": 9.5, "RequiredSourceWaterTempF": 120, - "FixedPumpGpm": 4.5, - "ReturnWaterFixedDeltaT": 20, - "SliceDurationMinutes": [60], + "CirculatorPumpGpm": 4.5, + "ReturnWaterDeltaTempF": 20, + "RoomTempF": 70, + "AmbientPowerInKw": 1.2, + "HouseWorstCaseTempF": -7, + "StorePassiveLossRatio": 0.005, "PowerLostFromHouseKwList": [3.42], - "OutsideTempF": [-5.1], - "DistributionPrice": [40.0], + "AmbientTempStoreF": 65, + "SliceDurationMinutes": [60], "RealtimeElectricityPrice": [10.35], + "DistributionPrice": [40.0], + "OutsideTempF": [-5.1], "RtElecPriceUid": "bd2ec5c5-40b9-4b61-ad1b-4613370246d6", - "WeatherUid": "3bbcb552-52e3-4b86-84e0-084959f9fc0f", "DistPriceUid": "b91ef8e7-50d7-4587-bf13-a3af7ecdb83a", + "WeatherUid": "3bbcb552-52e3-4b86-84e0-084959f9fc0f", "CurrencyUnitGtEnumSymbol": "e57c5143", + "TariffGtEnumSymbol": "2127aba6", + "EnergyTypeGtEnumSymbol": "e9dc99a6", + "StandardOfferPriceDollarsPerMwh": 110, + "FlatDistributionTariffDollarsPerMwh": 113, + "StartingStoreIdx": 50, "TypeName": "flo.params.simpleresistivehydronic", "Version": "000", } @@ -63,21 +76,32 @@ def test_flo_params_simpleresistivehydronic_generated() -> None: start_day_utc=gtuple.StartDayUtc, start_hour_utc=gtuple.StartHourUtc, start_minute_utc=gtuple.StartMinuteUtc, + storage_steps=gtuple.StorageSteps, store_size_gallons=gtuple.StoreSizeGallons, max_store_temp_f=gtuple.MaxStoreTempF, - element_max_power_kw=gtuple.ElementMaxPowerKw, + rated_power_kw=gtuple.RatedPowerKw, required_source_water_temp_f=gtuple.RequiredSourceWaterTempF, - fixed_pump_gpm=gtuple.FixedPumpGpm, - return_water_fixed_delta_t=gtuple.ReturnWaterFixedDeltaT, - slice_duration_minutes=gtuple.SliceDurationMinutes, + circulator_pump_gpm=gtuple.CirculatorPumpGpm, + return_water_delta_temp_f=gtuple.ReturnWaterDeltaTempF, + room_temp_f=gtuple.RoomTempF, + ambient_power_in_kw=gtuple.AmbientPowerInKw, + house_worst_case_temp_f=gtuple.HouseWorstCaseTempF, + store_passive_loss_ratio=gtuple.StorePassiveLossRatio, power_lost_from_house_kw_list=gtuple.PowerLostFromHouseKwList, - outside_temp_f=gtuple.OutsideTempF, - distribution_price=gtuple.DistributionPrice, + ambient_temp_store_f=gtuple.AmbientTempStoreF, + slice_duration_minutes=gtuple.SliceDurationMinutes, realtime_electricity_price=gtuple.RealtimeElectricityPrice, + distribution_price=gtuple.DistributionPrice, + outside_temp_f=gtuple.OutsideTempF, rt_elec_price_uid=gtuple.RtElecPriceUid, - weather_uid=gtuple.WeatherUid, dist_price_uid=gtuple.DistPriceUid, + weather_uid=gtuple.WeatherUid, currency_unit=gtuple.CurrencyUnit, + tariff=gtuple.Tariff, + energy_type=gtuple.EnergyType, + standard_offer_price_dollars_per_mwh=gtuple.StandardOfferPriceDollarsPerMwh, + flat_distribution_tariff_dollars_per_mwh=gtuple.FlatDistributionTariffDollarsPerMwh, + starting_store_idx=gtuple.StartingStoreIdx, ).tuple assert t == gtuple @@ -135,6 +159,11 @@ def test_flo_params_simpleresistivehydronic_generated() -> None: with pytest.raises(SchemaError): Maker.dict_to_tuple(d2) + d2 = dict(d) + del d2["StorageSteps"] + with pytest.raises(SchemaError): + Maker.dict_to_tuple(d2) + d2 = dict(d) del d2["StoreSizeGallons"] with pytest.raises(SchemaError): @@ -146,7 +175,7 @@ def test_flo_params_simpleresistivehydronic_generated() -> None: Maker.dict_to_tuple(d2) d2 = dict(d) - del d2["ElementMaxPowerKw"] + del d2["RatedPowerKw"] with pytest.raises(SchemaError): Maker.dict_to_tuple(d2) @@ -156,17 +185,32 @@ def test_flo_params_simpleresistivehydronic_generated() -> None: Maker.dict_to_tuple(d2) d2 = dict(d) - del d2["FixedPumpGpm"] + del d2["CirculatorPumpGpm"] with pytest.raises(SchemaError): Maker.dict_to_tuple(d2) d2 = dict(d) - del d2["ReturnWaterFixedDeltaT"] + del d2["ReturnWaterDeltaTempF"] with pytest.raises(SchemaError): Maker.dict_to_tuple(d2) d2 = dict(d) - del d2["SliceDurationMinutes"] + del d2["RoomTempF"] + with pytest.raises(SchemaError): + Maker.dict_to_tuple(d2) + + d2 = dict(d) + del d2["AmbientPowerInKw"] + with pytest.raises(SchemaError): + Maker.dict_to_tuple(d2) + + d2 = dict(d) + del d2["HouseWorstCaseTempF"] + with pytest.raises(SchemaError): + Maker.dict_to_tuple(d2) + + d2 = dict(d) + del d2["StorePassiveLossRatio"] with pytest.raises(SchemaError): Maker.dict_to_tuple(d2) @@ -176,12 +220,12 @@ def test_flo_params_simpleresistivehydronic_generated() -> None: Maker.dict_to_tuple(d2) d2 = dict(d) - del d2["OutsideTempF"] + del d2["AmbientTempStoreF"] with pytest.raises(SchemaError): Maker.dict_to_tuple(d2) d2 = dict(d) - del d2["DistributionPrice"] + del d2["SliceDurationMinutes"] with pytest.raises(SchemaError): Maker.dict_to_tuple(d2) @@ -191,12 +235,17 @@ def test_flo_params_simpleresistivehydronic_generated() -> None: Maker.dict_to_tuple(d2) d2 = dict(d) - del d2["RtElecPriceUid"] + del d2["DistributionPrice"] with pytest.raises(SchemaError): Maker.dict_to_tuple(d2) d2 = dict(d) - del d2["WeatherUid"] + del d2["OutsideTempF"] + with pytest.raises(SchemaError): + Maker.dict_to_tuple(d2) + + d2 = dict(d) + del d2["RtElecPriceUid"] with pytest.raises(SchemaError): Maker.dict_to_tuple(d2) @@ -205,11 +254,41 @@ def test_flo_params_simpleresistivehydronic_generated() -> None: with pytest.raises(SchemaError): Maker.dict_to_tuple(d2) + d2 = dict(d) + del d2["WeatherUid"] + with pytest.raises(SchemaError): + Maker.dict_to_tuple(d2) + d2 = dict(d) del d2["CurrencyUnitGtEnumSymbol"] with pytest.raises(SchemaError): Maker.dict_to_tuple(d2) + d2 = dict(d) + del d2["TariffGtEnumSymbol"] + with pytest.raises(SchemaError): + Maker.dict_to_tuple(d2) + + d2 = dict(d) + del d2["EnergyTypeGtEnumSymbol"] + with pytest.raises(SchemaError): + Maker.dict_to_tuple(d2) + + d2 = dict(d) + del d2["StandardOfferPriceDollarsPerMwh"] + with pytest.raises(SchemaError): + Maker.dict_to_tuple(d2) + + d2 = dict(d) + del d2["FlatDistributionTariffDollarsPerMwh"] + with pytest.raises(SchemaError): + Maker.dict_to_tuple(d2) + + d2 = dict(d) + del d2["StartingStoreIdx"] + with pytest.raises(SchemaError): + Maker.dict_to_tuple(d2) + ###################################### # Behavior on incorrect types ###################################### @@ -234,15 +313,19 @@ def test_flo_params_simpleresistivehydronic_generated() -> None: with pytest.raises(ValidationError): Maker.dict_to_tuple(d2) + d2 = dict(d, StorageSteps="100.1") + with pytest.raises(ValidationError): + Maker.dict_to_tuple(d2) + d2 = dict(d, StoreSizeGallons="240.1") with pytest.raises(ValidationError): Maker.dict_to_tuple(d2) - d2 = dict(d, MaxStoreTempF="190.1") + d2 = dict(d, MaxStoreTempF="210.1") with pytest.raises(ValidationError): Maker.dict_to_tuple(d2) - d2 = dict(d, ElementMaxPowerKw="this is not a float") + d2 = dict(d, RatedPowerKw="this is not a float") with pytest.raises(ValidationError): Maker.dict_to_tuple(d2) @@ -250,17 +333,55 @@ def test_flo_params_simpleresistivehydronic_generated() -> None: with pytest.raises(ValidationError): Maker.dict_to_tuple(d2) - d2 = dict(d, FixedPumpGpm="this is not a float") + d2 = dict(d, CirculatorPumpGpm="this is not a float") + with pytest.raises(ValidationError): + Maker.dict_to_tuple(d2) + + d2 = dict(d, ReturnWaterDeltaTempF="20.1") with pytest.raises(ValidationError): Maker.dict_to_tuple(d2) - d2 = dict(d, ReturnWaterFixedDeltaT="20.1") + d2 = dict(d, RoomTempF="70.1") + with pytest.raises(ValidationError): + Maker.dict_to_tuple(d2) + + d2 = dict(d, AmbientPowerInKw="this is not a float") + with pytest.raises(ValidationError): + Maker.dict_to_tuple(d2) + + d2 = dict(d, HouseWorstCaseTempF="this is not a float") + with pytest.raises(ValidationError): + Maker.dict_to_tuple(d2) + + d2 = dict(d, StorePassiveLossRatio="this is not a float") + with pytest.raises(ValidationError): + Maker.dict_to_tuple(d2) + + d2 = dict(d, AmbientTempStoreF="65.1") with pytest.raises(ValidationError): Maker.dict_to_tuple(d2) d2 = dict(d, CurrencyUnitGtEnumSymbol="hi") Maker.dict_to_tuple(d2).CurrencyUnit = RecognizedCurrencyUnit.default() + d2 = dict(d, TariffGtEnumSymbol="hi") + Maker.dict_to_tuple(d2).Tariff = DistributionTariff.default() + + d2 = dict(d, EnergyTypeGtEnumSymbol="hi") + Maker.dict_to_tuple(d2).EnergyType = EnergySupplyType.default() + + d2 = dict(d, StandardOfferPriceDollarsPerMwh="110.1") + with pytest.raises(ValidationError): + Maker.dict_to_tuple(d2) + + d2 = dict(d, FlatDistributionTariffDollarsPerMwh="113.1") + with pytest.raises(ValidationError): + Maker.dict_to_tuple(d2) + + d2 = dict(d, StartingStoreIdx="50.1") + with pytest.raises(ValidationError): + Maker.dict_to_tuple(d2) + ###################################### # SchemaError raised if TypeName is incorrect ###################################### @@ -285,11 +406,11 @@ def test_flo_params_simpleresistivehydronic_generated() -> None: with pytest.raises(ValidationError): Maker.dict_to_tuple(d2) - d2 = dict(d, WeatherUid="d4be12d5-33ba-4f1f-b9e5") + d2 = dict(d, DistPriceUid="d4be12d5-33ba-4f1f-b9e5") with pytest.raises(ValidationError): Maker.dict_to_tuple(d2) - d2 = dict(d, DistPriceUid="d4be12d5-33ba-4f1f-b9e5") + d2 = dict(d, WeatherUid="d4be12d5-33ba-4f1f-b9e5") with pytest.raises(ValidationError): Maker.dict_to_tuple(d2)