tyba-client 0.1.5__tar.gz → 0.5.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tyba_client-0.5.4/PKG-INFO +30 -0
- tyba_client-0.5.4/PYPI_README.md +8 -0
- tyba_client-0.5.4/pyproject.toml +37 -0
- tyba_client-0.5.4/tyba_client/__init__.py +4 -0
- tyba_client-0.5.4/tyba_client/client.py +555 -0
- tyba_client-0.5.4/tyba_client/forecast.py +286 -0
- tyba_client-0.5.4/tyba_client/operations.py +172 -0
- {tyba-client-0.1.5 → tyba_client-0.5.4}/tyba_client/utils.py +9 -4
- tyba-client-0.1.5/PKG-INFO +0 -20
- tyba-client-0.1.5/README.md +0 -1
- tyba-client-0.1.5/pyproject.toml +0 -24
- tyba-client-0.1.5/setup.py +0 -33
- tyba-client-0.1.5/tyba_client/__init__.py +0 -1
- tyba-client-0.1.5/tyba_client/client.py +0 -50
- tyba-client-0.1.5/tyba_client/models.py +0 -231
- {tyba-client-0.1.5 → tyba_client-0.5.4}/LICENSE +0 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tyba-client
|
|
3
|
+
Version: 0.5.4
|
|
4
|
+
Summary: A Python API client for the Tyba Public API
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Author: Tyler Nisonoff
|
|
8
|
+
Author-email: tyler@tybaenergy.com
|
|
9
|
+
Requires-Python: >=3.10,<4.0
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Requires-Dist: generation-models (>=0.10.8,<1.0.0)
|
|
17
|
+
Requires-Dist: pandas (>=2.3.2,<3.0.0)
|
|
18
|
+
Requires-Dist: requests (>=2.32.5,<3.0.0)
|
|
19
|
+
Requires-Dist: structlog (>=25.4.0,<26.0.0)
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
<h1 align="center">
|
|
23
|
+
<img src="https://cdn.prod.website-files.com/669594441bac4d6528273301/669594441bac4d6528273329_Group%20180.svg" alt="TYBA" width="300">
|
|
24
|
+
</h1><br>
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
Python client to Tyba's project simulation API.
|
|
28
|
+
|
|
29
|
+
__Documentation:__ https://docs.tybaenergy.com/api
|
|
30
|
+
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<h1 align="center">
|
|
2
|
+
<img src="https://cdn.prod.website-files.com/669594441bac4d6528273301/669594441bac4d6528273329_Group%20180.svg" alt="TYBA" width="300">
|
|
3
|
+
</h1><br>
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
Python client to Tyba's project simulation API.
|
|
7
|
+
|
|
8
|
+
__Documentation:__ https://docs.tybaenergy.com/api
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "tyba-client"
|
|
3
|
+
version = "0.5.4"
|
|
4
|
+
description = "A Python API client for the Tyba Public API"
|
|
5
|
+
authors = [
|
|
6
|
+
{ name = "Tyler Nisonoff <tyler@tybaenergy.com>" }
|
|
7
|
+
]
|
|
8
|
+
requires-python = ">=3.10,<4.0"
|
|
9
|
+
license = "MIT"
|
|
10
|
+
readme = "PYPI_README.md"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
[build-system]
|
|
14
|
+
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
|
15
|
+
build-backend = "poetry.core.masonry.api"
|
|
16
|
+
|
|
17
|
+
[tool.poetry.dependencies]
|
|
18
|
+
requests = "^2.32.5"
|
|
19
|
+
pandas = "^2.3.2"
|
|
20
|
+
structlog = "^25.4.0"
|
|
21
|
+
generation-models = ">=0.10.8,<1.0.0"
|
|
22
|
+
|
|
23
|
+
[tool.poetry.group.dev.dependencies]
|
|
24
|
+
pytest = "^8.4.1"
|
|
25
|
+
ipdb = "^0.13.13"
|
|
26
|
+
jupyter = "^1.1.1"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
[tool.poetry.group.docs.dependencies]
|
|
30
|
+
pandoc = "^2.4"
|
|
31
|
+
sphinx = { version = ">=8.2", python = ">=3.11" }
|
|
32
|
+
autodoc-pydantic = { version = "^2.2.0", python = ">=3.11" }
|
|
33
|
+
sphinx-autodoc-typehints = { version = ">=3.1.0", python = ">=3.11" }
|
|
34
|
+
sphinx-rtd-theme = { version = "^3.0.2", python = ">=3.11" }
|
|
35
|
+
sphinx-gallery = { version = "^0.19.0", python = ">=3.11" }
|
|
36
|
+
nbsphinx = { version = "<=0.9.6", python = ">=3.11" }
|
|
37
|
+
boto3 = { version = "^1.40.23", python = ">=3.11" }
|
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
import typing as t
|
|
3
|
+
from requests import Response
|
|
4
|
+
|
|
5
|
+
from tyba_client.forecast import Forecast
|
|
6
|
+
from generation_models import JobModel, GenerationModel, PVStorageModel, StandaloneStorageModel
|
|
7
|
+
|
|
8
|
+
from generation_models.v0_output_schema import (
|
|
9
|
+
GenerationModelResults,
|
|
10
|
+
PVStorageModelResults,
|
|
11
|
+
StandaloneStorageModelSimpleResults,
|
|
12
|
+
StandaloneStorageModelWithDownstreamResults
|
|
13
|
+
)
|
|
14
|
+
import json
|
|
15
|
+
import requests
|
|
16
|
+
import time
|
|
17
|
+
from structlog import get_logger
|
|
18
|
+
from typing import Callable
|
|
19
|
+
|
|
20
|
+
from tyba_client.operations import Operations
|
|
21
|
+
from pydantic import BaseModel, Field
|
|
22
|
+
from enum import Enum
|
|
23
|
+
|
|
24
|
+
logger = get_logger()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
V0Results = t.Union[
|
|
28
|
+
GenerationModelResults,
|
|
29
|
+
PVStorageModelResults,
|
|
30
|
+
StandaloneStorageModelSimpleResults,
|
|
31
|
+
StandaloneStorageModelWithDownstreamResults
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
class Market(str, Enum):
|
|
35
|
+
"""Indicator for which market to pull pricing data for"""
|
|
36
|
+
RT = "realtime"
|
|
37
|
+
"""Indicates pricing data for the Real Time (RT) Market is desired"""
|
|
38
|
+
DA = "dayahead"
|
|
39
|
+
"""Indicates pricing data for the Day Ahead (DA) Market is desired"""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class AncillaryService(str, Enum):
|
|
43
|
+
"""Indicator for which service to pull pricing data for"""
|
|
44
|
+
REGULATION_UP = "Regulation Up"
|
|
45
|
+
"""Indicates pricing data for the Regulation Up service is desired"""
|
|
46
|
+
REGULATION_DOWN = "Regulation Down"
|
|
47
|
+
"""Indicates pricing data for the Regulation Down service is desired"""
|
|
48
|
+
RESERVES = "Reserves"
|
|
49
|
+
"""Indicates pricing data for the Reserves service is desired"""
|
|
50
|
+
ECRS = "ECRS"
|
|
51
|
+
"""Indicates pricing data for the ERCOT Contingency Reserve Service is desired"""
|
|
52
|
+
|
|
53
|
+
class Ancillary(object):
|
|
54
|
+
"""Interface for accessing Tyba's historical *ancillary* price data"""
|
|
55
|
+
def __init__(self, services: 'Services'):
|
|
56
|
+
self.services = services
|
|
57
|
+
|
|
58
|
+
def get(self, route, params=None):
|
|
59
|
+
return self.services.get(f"ancillary/{route}", params=params)
|
|
60
|
+
|
|
61
|
+
def get_pricing_regions(self, *, iso: str, service: AncillaryService, market: Market) -> Response:
|
|
62
|
+
"""Get the name and available year ranges for all ancillary service pricing regions that meet the ISO,
|
|
63
|
+
service and market criteria.
|
|
64
|
+
|
|
65
|
+
:param iso: ISO name. Possible values can be found by calling :meth:`Services.get_all_isos`
|
|
66
|
+
:param service: specifies which ancillary service to pull prices for
|
|
67
|
+
:param market: specifies whether to pull day ahead or real time prices for the given service
|
|
68
|
+
:return: :class:`~requests.Response` containing an **array** of JSON objects with schema
|
|
69
|
+
:class:`AncillaryRegionData`. For example:
|
|
70
|
+
|
|
71
|
+
.. code:: python
|
|
72
|
+
|
|
73
|
+
[
|
|
74
|
+
{
|
|
75
|
+
'region': 'Pacific Northwest - SP15',
|
|
76
|
+
'start_year': 2010,
|
|
77
|
+
'end_year': 2025
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
'region': 'WAPA',
|
|
81
|
+
...
|
|
82
|
+
},
|
|
83
|
+
...
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
"""
|
|
87
|
+
return self.get("regions", {"iso": iso, "service": service, "market": market})
|
|
88
|
+
|
|
89
|
+
def get_prices(self, *, iso: str, service: AncillaryService, market: Market, region: str, start_year: int, end_year: int) -> Response:
|
|
90
|
+
"""Get price time series data for a single region/service combination
|
|
91
|
+
|
|
92
|
+
:param iso: ISO name. Possible values can be found by calling :meth:`Services.get_all_isos`
|
|
93
|
+
:param service: specifies which ancillary service to pull prices for
|
|
94
|
+
:param market: specifies whether to pull day ahead or real time prices for the given service
|
|
95
|
+
:param region: specific region within the ISO to pull prices for. Possible values can be found by calling
|
|
96
|
+
:meth:`get_pricing_regions`
|
|
97
|
+
:param start_year: the year prices should start
|
|
98
|
+
:param end_year: the year prices should end
|
|
99
|
+
:return: :class:`~requests.Response` containing a JSON object with schema :class:`PriceTimeSeries`. For example:
|
|
100
|
+
|
|
101
|
+
.. code:: python
|
|
102
|
+
|
|
103
|
+
{
|
|
104
|
+
'prices': [
|
|
105
|
+
61.7929,
|
|
106
|
+
58.1359,
|
|
107
|
+
61.4939,
|
|
108
|
+
....
|
|
109
|
+
],
|
|
110
|
+
'datetimes': [
|
|
111
|
+
'2022-01-01T00:00:00Z',
|
|
112
|
+
'2022-01-01T01:00:00Z',
|
|
113
|
+
'2022-01-01T02:00:00Z',
|
|
114
|
+
....
|
|
115
|
+
],
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
"""
|
|
119
|
+
return self.get(
|
|
120
|
+
"prices",
|
|
121
|
+
{
|
|
122
|
+
"iso": iso,
|
|
123
|
+
"service": service,
|
|
124
|
+
"market": market,
|
|
125
|
+
"region": region,
|
|
126
|
+
"start_year": start_year,
|
|
127
|
+
"end_year": end_year,
|
|
128
|
+
},
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class LMP(object):
|
|
133
|
+
"""Interface for accessing Tyba's historical *energy* price data
|
|
134
|
+
"""
|
|
135
|
+
def __init__(self, services: 'Services'):
|
|
136
|
+
self.services = services
|
|
137
|
+
self._route_base = "lmp"
|
|
138
|
+
|
|
139
|
+
def get(self, route, params=None):
|
|
140
|
+
return self.services.get(f"{self._route_base}/{route}", params=params)
|
|
141
|
+
|
|
142
|
+
def post(self, route, json):
|
|
143
|
+
return self.services.post(f"{self._route_base}/{route}", json=json)
|
|
144
|
+
|
|
145
|
+
def get_all_nodes(self, *, iso: str) -> requests.Response:
|
|
146
|
+
"""Get node names, IDs and other metadata for all nodes within the given ISO territory.
|
|
147
|
+
|
|
148
|
+
:param iso: ISO name. Possible values can be found by calling :meth:`Services.get_all_isos`
|
|
149
|
+
:return: :class:`~requests.Response` containing an **array** of JSON objects with schema
|
|
150
|
+
:class:`NodeData`. For example:
|
|
151
|
+
|
|
152
|
+
.. code:: python
|
|
153
|
+
|
|
154
|
+
[
|
|
155
|
+
{'da_end_year': 2025,
|
|
156
|
+
'rt_end_year': 2025,
|
|
157
|
+
'rt_start_year': 2023,
|
|
158
|
+
'name': 'CLAP_WWRSR1-APND',
|
|
159
|
+
'id': '10017280350',
|
|
160
|
+
'da_start_year': 2023,
|
|
161
|
+
'zone': 'SDGE',
|
|
162
|
+
'type': 'GENERATOR'},
|
|
163
|
+
{'da_start_year': 2015,
|
|
164
|
+
'rt_end_year': 2025,
|
|
165
|
+
'zone': '',
|
|
166
|
+
'name': 'ELCENTRO_2_N001:IVLY2',
|
|
167
|
+
'type': 'SPTIE',
|
|
168
|
+
'substation': '',
|
|
169
|
+
'da_end_year': 2025,
|
|
170
|
+
'id': '10003899356',
|
|
171
|
+
'rt_start_year': 2015},
|
|
172
|
+
...
|
|
173
|
+
]
|
|
174
|
+
|
|
175
|
+
"""
|
|
176
|
+
return self.get("nodes", {"iso": iso})
|
|
177
|
+
|
|
178
|
+
def get_prices(self, *, node_ids: list[str], market: Market, start_year: int, end_year: int) -> requests.Response:
|
|
179
|
+
"""Get price time series data for a list of node IDs
|
|
180
|
+
|
|
181
|
+
:param node_ids: list of IDs for which prices are desired
|
|
182
|
+
- Maximum length is 8 IDS
|
|
183
|
+
:param market: specifies whether to pull day ahead or real time market prices
|
|
184
|
+
:param start_year: the year prices should start
|
|
185
|
+
:param end_year: the year prices should end
|
|
186
|
+
:return: :class:`~requests.Response` containing a JSON object whose keys are node IDs and whose values
|
|
187
|
+
are objects with schema :class:`PriceTimeSeries`. For example:
|
|
188
|
+
|
|
189
|
+
.. code:: python
|
|
190
|
+
|
|
191
|
+
{
|
|
192
|
+
'10000802793': {
|
|
193
|
+
'prices': [
|
|
194
|
+
61.7929,
|
|
195
|
+
58.1359,
|
|
196
|
+
61.4939,
|
|
197
|
+
....
|
|
198
|
+
],
|
|
199
|
+
'datetimes': [
|
|
200
|
+
'2022-01-01T00:00:00',
|
|
201
|
+
'2022-01-01T01:00:00',
|
|
202
|
+
'2022-01-01T02:00:00',
|
|
203
|
+
....
|
|
204
|
+
],
|
|
205
|
+
...
|
|
206
|
+
},
|
|
207
|
+
'20000004677': {
|
|
208
|
+
...
|
|
209
|
+
},
|
|
210
|
+
...
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
"""
|
|
214
|
+
return self.get(
|
|
215
|
+
"prices",
|
|
216
|
+
{
|
|
217
|
+
"node_ids": json.dumps(node_ids),
|
|
218
|
+
"market": market,
|
|
219
|
+
"start_year": start_year,
|
|
220
|
+
"end_year": end_year,
|
|
221
|
+
},
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
def search_nodes(self, location: t.Optional[str] = None, node_name_filter: t.Optional[str] = None,
|
|
225
|
+
iso_override: t.Optional[str] = None) -> Response:
|
|
226
|
+
"""Get a list of matching nodes based on search criteria. Multiple search criteria (e.g. `location`
|
|
227
|
+
and `node_name_filter`) can be applied in a single request.
|
|
228
|
+
|
|
229
|
+
:param location: location information. There are 3 possible forms:
|
|
230
|
+
|
|
231
|
+
- city/state, e.g. `'dallas, tx'`
|
|
232
|
+
- address, e.g. `'12345 Anywhere Street, Anywhere, TX 12345'`
|
|
233
|
+
- latitude and longitude, e.g. `'29.760427, -95.369804'`
|
|
234
|
+
|
|
235
|
+
:param node_name_filter: partial node name with which to perform a pattern-match, e.g. `'HB_'`
|
|
236
|
+
:param iso_override: ISO signifier, used to constrain search to a single ISO. When equal to ``None``, all ISOs
|
|
237
|
+
are searched based on other criteria. Possible values can be found by calling :meth:`Services.get_all_isos`
|
|
238
|
+
:return: :class:`~requests.Response` containing a JSON object. If matching nodes are found, a `'nodes'` item
|
|
239
|
+
will contain an **array** of objects with schema :class:`NodeSearchData`. If no matching nodes are found,
|
|
240
|
+
an error code will be returned. As an example, a successful search result might look like:
|
|
241
|
+
|
|
242
|
+
.. code:: python
|
|
243
|
+
|
|
244
|
+
{
|
|
245
|
+
"nodes": [
|
|
246
|
+
{
|
|
247
|
+
"node/name": "HB_BUSAVG",
|
|
248
|
+
"node/id": "10000698380",
|
|
249
|
+
"node/iso": "ERCOT",
|
|
250
|
+
"node/lat": 30.850714,
|
|
251
|
+
"node/lng": -97.877628,
|
|
252
|
+
"node/distance-meters": 500.67458
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
"node/name": ...,
|
|
256
|
+
...
|
|
257
|
+
},
|
|
258
|
+
...
|
|
259
|
+
]
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
"""
|
|
263
|
+
return self.get(route="search-nodes",
|
|
264
|
+
params={"location": location,
|
|
265
|
+
"node_name_filter": node_name_filter,
|
|
266
|
+
"iso_override": iso_override})
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class Services(object):
|
|
270
|
+
"""Interface for accessing Tyba's historical price data
|
|
271
|
+
"""
|
|
272
|
+
def __init__(self, client: 'Client'):
|
|
273
|
+
|
|
274
|
+
self.client = client
|
|
275
|
+
self.ancillary: Ancillary = Ancillary(self)
|
|
276
|
+
"""Interface for accessing Tyba's historical *ancillary* price data"""
|
|
277
|
+
self.lmp: LMP = LMP(self)
|
|
278
|
+
"""Interface for accessing Tyba's historical *energy* price data"""
|
|
279
|
+
self._route_base = "services"
|
|
280
|
+
|
|
281
|
+
def get(self, route, params=None):
|
|
282
|
+
return self.client.get(f"{self._route_base}/{route}", params=params)
|
|
283
|
+
|
|
284
|
+
def post(self, route, json):
|
|
285
|
+
return self.client.post(f"{self._route_base}/{route}", json=json)
|
|
286
|
+
|
|
287
|
+
def get_all_isos(self) -> requests.Response:
|
|
288
|
+
"""Get of list of all independent system operators and regional transmission operators (generally all referred
|
|
289
|
+
to as ISOs) represented in Tyba's historical price data
|
|
290
|
+
|
|
291
|
+
:return: :class:`~requests.Response` containing JSON array of strings of the available ISO names
|
|
292
|
+
"""
|
|
293
|
+
return self.get("isos")
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
class Client(object):
|
|
297
|
+
"""High level interface for interacting with Tyba's API.
|
|
298
|
+
|
|
299
|
+
:param personal_access_token: required for using the python client/API, contact Tyba to obtain
|
|
300
|
+
"""
|
|
301
|
+
|
|
302
|
+
DEFAULT_OPTIONS = {"version": "0.1"}
|
|
303
|
+
|
|
304
|
+
def __init__(
|
|
305
|
+
self,
|
|
306
|
+
personal_access_token: str,
|
|
307
|
+
host: str = "https://dev.tybaenergy.com",
|
|
308
|
+
request_args: t.Optional[dict] = None,
|
|
309
|
+
):
|
|
310
|
+
self.personal_access_token = personal_access_token
|
|
311
|
+
self.host = host
|
|
312
|
+
self.services: Services = Services(self)
|
|
313
|
+
"""Interface for accessing Tyba's historical price data"""
|
|
314
|
+
self.forecast: Forecast = Forecast(self)
|
|
315
|
+
"""Interface for accessing Tyba's historical price data"""
|
|
316
|
+
self.operations = Operations(self)
|
|
317
|
+
self.request_args = {} if request_args is None else request_args
|
|
318
|
+
|
|
319
|
+
@property
|
|
320
|
+
def ancillary(self) -> Ancillary:
|
|
321
|
+
"""Shortcut to :class:`client.services.ancillary <Ancillary>`"""
|
|
322
|
+
return self.services.ancillary
|
|
323
|
+
|
|
324
|
+
@property
|
|
325
|
+
def lmp(self) -> LMP:
|
|
326
|
+
"""Shortcut to :class:`client.services.lmp <LMP>`"""
|
|
327
|
+
return self.services.lmp
|
|
328
|
+
|
|
329
|
+
def _auth_header(self):
|
|
330
|
+
return self.personal_access_token
|
|
331
|
+
|
|
332
|
+
def _base_url(self):
|
|
333
|
+
return self.host + "/public/" + self.DEFAULT_OPTIONS["version"] + "/"
|
|
334
|
+
|
|
335
|
+
def get(self, route, params=None):
|
|
336
|
+
return requests.get(
|
|
337
|
+
self._base_url() + route,
|
|
338
|
+
params=params,
|
|
339
|
+
headers={"Authorization": self._auth_header()},
|
|
340
|
+
**self.request_args,
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
def post(self, route, json):
|
|
344
|
+
return requests.post(
|
|
345
|
+
self._base_url() + route,
|
|
346
|
+
json=json,
|
|
347
|
+
headers={"Authorization": self._auth_header()},
|
|
348
|
+
**self.request_args,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
def schedule_pv(self, pv_model: GenerationModel) -> Response:
|
|
352
|
+
model_json_dict = pv_model.to_dict()
|
|
353
|
+
return self.post("schedule-pv", json=model_json_dict)
|
|
354
|
+
|
|
355
|
+
def schedule_storage(self, storage_model: StandaloneStorageModel) -> Response:
|
|
356
|
+
model_json_dict = storage_model.to_dict()
|
|
357
|
+
return self.post("schedule-storage", json=model_json_dict)
|
|
358
|
+
|
|
359
|
+
def schedule_pv_storage(self, pv_storage_model: PVStorageModel) -> Response:
|
|
360
|
+
model_json_dict = pv_storage_model.to_dict()
|
|
361
|
+
return self.post("schedule-pv-storage", json=model_json_dict)
|
|
362
|
+
|
|
363
|
+
def schedule(self, model: JobModel) -> Response:
|
|
364
|
+
"""Schedule a model simulation based on the given inputs
|
|
365
|
+
|
|
366
|
+
:param model: a class instance of one of the model classes, e.g.
|
|
367
|
+
:class:`~generation_models.generation_models.StandaloneStorageModel`. Contains all required inputs for
|
|
368
|
+
running a simulation
|
|
369
|
+
:return: :class:`~requests.Response` whose status code indicates whether the model was successfully scheduled.
|
|
370
|
+
If successful, the response will contain a JSON object with an ``'id'`` for the scheduled model run. This id
|
|
371
|
+
can be used with the :meth:`get_status` and :meth:`wait_on_result` endpoints to retrieve status updates and
|
|
372
|
+
model results. The presence of issues can be easily checked by calling the
|
|
373
|
+
:meth:`~requests.Response.raise_for_status` method of the response object. For example:
|
|
374
|
+
|
|
375
|
+
.. code:: python
|
|
376
|
+
|
|
377
|
+
resp = client.schedule(pv)
|
|
378
|
+
resp.raise-for_status() # this will raise an error if the model was not successfully scheduled
|
|
379
|
+
id_ = resp.json()["id"]
|
|
380
|
+
res = client.wait_on_result(id_)
|
|
381
|
+
|
|
382
|
+
"""
|
|
383
|
+
return self.post("schedule-job", json=model.dict())
|
|
384
|
+
|
|
385
|
+
def get_status(self, run_id: str):
|
|
386
|
+
"""Check the status and retrieve the results of a scheduled model simulation. If a simulation has not
|
|
387
|
+
completed, this endpoint returns the simulation status/progress. If the simulation has completed, it
|
|
388
|
+
returns the model results.
|
|
389
|
+
|
|
390
|
+
:param run_id: ID of the scheduled model simulation
|
|
391
|
+
:return: :class:`~requests.Response` containing a JSON object with schema :class:`ModelStatus`
|
|
392
|
+
"""
|
|
393
|
+
url = "get-status/" + run_id
|
|
394
|
+
return self.get(url)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
@staticmethod
|
|
398
|
+
def _wait_on_result(
|
|
399
|
+
run_id: str,
|
|
400
|
+
wait_time: int,
|
|
401
|
+
log_progress: bool,
|
|
402
|
+
getter: Callable[[str], Response],
|
|
403
|
+
) -> dict:
|
|
404
|
+
while True:
|
|
405
|
+
resp = getter(run_id)
|
|
406
|
+
resp.raise_for_status()
|
|
407
|
+
res = resp.json()
|
|
408
|
+
if res["status"] == "complete":
|
|
409
|
+
return res["result"]
|
|
410
|
+
elif res["status"] == "unknown":
|
|
411
|
+
raise UnknownRunId(f"No known model run with run_id '{run_id}'")
|
|
412
|
+
message = {"status": res["status"]}
|
|
413
|
+
if res.get("progress") is not None:
|
|
414
|
+
message["progress"] = f"{float(res['progress']) * 100:3.1f}%"
|
|
415
|
+
if log_progress:
|
|
416
|
+
logger.info("waiting on result", **message)
|
|
417
|
+
time.sleep(wait_time)
|
|
418
|
+
|
|
419
|
+
def wait_on_result(
|
|
420
|
+
self, run_id: str, wait_time: int = 5, log_progress: bool = False
|
|
421
|
+
) -> dict:
|
|
422
|
+
"""Poll for simulation status and, once complete, return the model results
|
|
423
|
+
|
|
424
|
+
:param run_id: ID of the scheduled model simulation
|
|
425
|
+
:param wait_time: time in seconds to wait between polling/updates
|
|
426
|
+
:param log_progress: indicate whether updates/progress should be logged/displayed. If ``True``, will
|
|
427
|
+
report both :attr:`~ModelStatus.status` and :attr:`~ModelStatus.progress` information
|
|
428
|
+
:return: results dictionary equivalent to :attr:`ModelStatus.result` returned by :meth:`get_status`, with the
|
|
429
|
+
exact schema depending on the model inputs
|
|
430
|
+
"""
|
|
431
|
+
return self._wait_on_result(
|
|
432
|
+
run_id, wait_time, log_progress, getter=self.get_status
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def parse_v1_result(res: dict):
|
|
437
|
+
""":meta private:"""
|
|
438
|
+
return {
|
|
439
|
+
"hourly": pd.concat(
|
|
440
|
+
{k: pd.DataFrame(v) for k, v in res["hourly"].items()}, axis=1
|
|
441
|
+
),
|
|
442
|
+
"waterfall": res["waterfall"],
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
class UnknownRunId(ValueError):
|
|
447
|
+
""":meta private:"""
|
|
448
|
+
pass
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
class NodeType(str, Enum):
|
|
452
|
+
"""Indicator of which type of physical infrastructure is associated with a particular market node"""
|
|
453
|
+
GENERATOR = "GENERATOR"
|
|
454
|
+
"""Not sure"""
|
|
455
|
+
SPTIE = "SPTIE"
|
|
456
|
+
"""Not sure"""
|
|
457
|
+
LOAD = "LOAD"
|
|
458
|
+
"""Not sure"""
|
|
459
|
+
INTERTIE = "INTERTIE"
|
|
460
|
+
"""Not sure"""
|
|
461
|
+
AGGREGATE = "AGGREGATE"
|
|
462
|
+
"""Not sure"""
|
|
463
|
+
HUB = "HUB"
|
|
464
|
+
"""Not sure"""
|
|
465
|
+
NA = "N/A"
|
|
466
|
+
"""Not sure"""
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
class NodeData(BaseModel):
|
|
470
|
+
"""Schema for node metadata"""
|
|
471
|
+
name: str
|
|
472
|
+
"""Name of the node"""
|
|
473
|
+
id: str
|
|
474
|
+
"""ID of the node"""
|
|
475
|
+
zone: str
|
|
476
|
+
"""Zone where the node is located within the ISO territory"""
|
|
477
|
+
type: NodeType
|
|
478
|
+
"""Identifier that indicates physical infrastructure associated with this node"""
|
|
479
|
+
da_start_year: float
|
|
480
|
+
"""First year in the Day Ahead (DA) market price dataset for this node"""
|
|
481
|
+
da_end_year: float
|
|
482
|
+
"""Final year in the Day Ahead (DA) market price dataset for this node"""
|
|
483
|
+
rt_start_year: int
|
|
484
|
+
"""First year in the Real Time (RT) market price dataset for this node"""
|
|
485
|
+
rt_end_year: int
|
|
486
|
+
"""Final year in the Real Time (RT) market price dataset for this node"""
|
|
487
|
+
substation: t.Optional[str] = None
|
|
488
|
+
"""Indicator of the grid substation associated with this node (not always present)"""
|
|
489
|
+
|
|
490
|
+
class PriceTimeSeries(BaseModel):
|
|
491
|
+
"""Schema for pricing data associated with a particular energy price node or ancillary pricing region"""
|
|
492
|
+
datetimes: list[str]
|
|
493
|
+
"""Beginning-of-interval datetimes for the hourly pricing given in local time.
|
|
494
|
+
|
|
495
|
+
- For energy prices, the datetimes are timezone-naive (no timezone identifier) but given in the local timezone
|
|
496
|
+
(i.e. including Daylight Savings Time or DST). E.g. The start of the year 2022 in ERCOT is given as
|
|
497
|
+
`'2022-01-01T00:00:00'` as opposed to `'2022-01-01T00:00:00-6:00'`. Leap days are represented by a single hour,
|
|
498
|
+
which should be dropped as a post-processing step.
|
|
499
|
+
- For ancillary prices, the datetimes are in local standard time (i.e. not including DST) but appear to be in
|
|
500
|
+
UTC ("Z" timezone identifier). E.g. The start of the year 2022 in ERCOT is given as `'2022-01-01T00:00:00Z'` and
|
|
501
|
+
not `'2022-01-01T00:00:00-6:00'`. Leap days are not included.
|
|
502
|
+
|
|
503
|
+
"""
|
|
504
|
+
prices: list[float]
|
|
505
|
+
"""Average hourly settlement prices for hours represented by :attr:`datetimes`.
|
|
506
|
+
"""
|
|
507
|
+
|
|
508
|
+
class NodeSearchData(BaseModel):
|
|
509
|
+
"""Schema for search-specific node metadata. The `(name 'xxxx')` to the right of the field names can be ignored"""
|
|
510
|
+
node_name: str = Field(..., alias='node/name')
|
|
511
|
+
"""Name of the node"""
|
|
512
|
+
node_id: str = Field(..., alias='node/id')
|
|
513
|
+
"""ID of the node"""
|
|
514
|
+
node_iso: str = Field(..., alias='node/iso')
|
|
515
|
+
"""ISO that the node belongs to"""
|
|
516
|
+
node_longitude: float = Field(..., alias='node/lng')
|
|
517
|
+
"""longitude of the point on the electrical grid associated with the node"""
|
|
518
|
+
node_latitude: float = Field(..., alias='node/lat')
|
|
519
|
+
"""latitude of the point on the electrical grid associated with the node"""
|
|
520
|
+
node_distance_meters: t.Optional[float] = Field(default=None, alias='node/distance-meters')
|
|
521
|
+
"""Distance from the node to the `location` parameter passed to :meth:`~LMP.search_nodes`. Not present if
|
|
522
|
+
`location` is not given.
|
|
523
|
+
"""
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
class AncillaryRegionData(BaseModel):
|
|
527
|
+
"""Schema for ancillary region metadata"""
|
|
528
|
+
region: str
|
|
529
|
+
"""Name of the region"""
|
|
530
|
+
start_year: int
|
|
531
|
+
"""First year in price dataset for the region and specified service"""
|
|
532
|
+
da_end_year: int
|
|
533
|
+
"""Final year in price dataset for the region and specified service"""
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
class ModelStatus(BaseModel):
|
|
537
|
+
"""Schema for model status and results"""
|
|
538
|
+
status: str
|
|
539
|
+
"""Status of the scheduled run. Possible values are explained in
|
|
540
|
+
:ref:`Tyba Model Run Status Codes <status_codes>`"""
|
|
541
|
+
progress: t.Optional[str]
|
|
542
|
+
"""Percentage value indicating the progress towards completing a model simulation. Only present if :attr:`status`
|
|
543
|
+
is not ``'complete'``.
|
|
544
|
+
|
|
545
|
+
- Note that in some cases the simulation may involve multiple optimization iterations, and the progress may appear
|
|
546
|
+
to start over as each additional iteration is undertaken
|
|
547
|
+
|
|
548
|
+
"""
|
|
549
|
+
result: t.Optional[V0Results]
|
|
550
|
+
"""Model simulation results dictionary with schema defined depending on the model inputs, e.g. scheduling a
|
|
551
|
+
:class:`~generation_models.generation_models.PVGenerationModel` will return a dictionary with
|
|
552
|
+
schema :class:`~generation_models.v0_output_schema.GenerationModelResults`. Only present if
|
|
553
|
+
:attr:`status` is ``'complete'``"""
|
|
554
|
+
|
|
555
|
+
|