tyba-client 0.4.13__tar.gz → 0.4.16__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.
Potentially problematic release.
This version of tyba-client might be problematic. Click here for more details.
- {tyba_client-0.4.13 → tyba_client-0.4.16}/PKG-INFO +3 -2
- {tyba_client-0.4.13 → tyba_client-0.4.16}/pyproject.toml +8 -5
- tyba_client-0.4.16/tyba_client/client.py +583 -0
- {tyba_client-0.4.13 → tyba_client-0.4.16}/tyba_client/forecast.py +90 -0
- {tyba_client-0.4.13 → tyba_client-0.4.16}/tyba_client/io.py +7 -2
- {tyba_client-0.4.13 → tyba_client-0.4.16}/tyba_client/operations.py +61 -1
- {tyba_client-0.4.13 → tyba_client-0.4.16}/tyba_client/solar_resource.py +6 -0
- tyba_client-0.4.13/tyba_client/client.py +0 -219
- {tyba_client-0.4.13 → tyba_client-0.4.16}/LICENSE +0 -0
- {tyba_client-0.4.13 → tyba_client-0.4.16}/PYPI_README.md +0 -0
- {tyba_client-0.4.13 → tyba_client-0.4.16}/tyba_client/__init__.py +0 -0
- {tyba_client-0.4.13 → tyba_client-0.4.16}/tyba_client/models.py +0 -0
- {tyba_client-0.4.13 → tyba_client-0.4.16}/tyba_client/utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
2
|
Name: tyba-client
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.16
|
|
4
4
|
Summary: A Python API client for the Tyba Public API
|
|
5
5
|
License: MIT
|
|
6
6
|
Author: Tyler Nisonoff
|
|
@@ -19,6 +19,7 @@ Requires-Dist: generation-models (>=0.10.6,<0.11.0)
|
|
|
19
19
|
Requires-Dist: marshmallow (>=3.12.1,<4.0.0)
|
|
20
20
|
Requires-Dist: pandas (>=1.3.2,<2.0.0) ; python_version < "3.9"
|
|
21
21
|
Requires-Dist: pandas (>=2.0.0,<3.0.0) ; python_version >= "3.9"
|
|
22
|
+
Requires-Dist: pydantic (>=2.10.6,<3.0.0)
|
|
22
23
|
Requires-Dist: requests (>=2.25.1,<3.0.0)
|
|
23
24
|
Requires-Dist: structlog (>=24.1.0,<25.0.0)
|
|
24
25
|
Description-Content-Type: text/markdown
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "tyba-client"
|
|
3
|
-
version = "0.4.
|
|
3
|
+
version = "0.4.16"
|
|
4
4
|
description = "A Python API client for the Tyba Public API"
|
|
5
5
|
authors = ["Tyler Nisonoff <tyler@tybaenergy.com>"]
|
|
6
6
|
license = "MIT"
|
|
@@ -20,18 +20,21 @@ pandas = [
|
|
|
20
20
|
]
|
|
21
21
|
structlog = "^24.1.0"
|
|
22
22
|
generation-models = "^0.10.6"
|
|
23
|
+
pydantic = "^2.10.6"
|
|
23
24
|
|
|
24
25
|
[tool.poetry.group.dev.dependencies]
|
|
25
26
|
pytest = "^8.1.1"
|
|
26
27
|
ipython = "^7.29.0"
|
|
27
28
|
ipdb = "^0.13.9"
|
|
28
|
-
sphinx = "^
|
|
29
|
-
autodoc-pydantic = "^
|
|
29
|
+
sphinx = { version = "^8.0", python = "^3.11" }
|
|
30
|
+
autodoc-pydantic = "^2.0.0"
|
|
31
|
+
sphinx-rtd-theme = "^3.0.0"
|
|
32
|
+
sphinx-autodoc-typehints = { version = "^3.0", python = "^3.11" }
|
|
30
33
|
boto3 = "^1.28.22"
|
|
31
|
-
nbsphinx = "
|
|
32
|
-
sphinx-rtd-theme = "^2.0.0"
|
|
34
|
+
nbsphinx = "<=0.9.6"
|
|
33
35
|
sphinx-gallery = "^0.15.0"
|
|
34
36
|
jupyter = "^1.0.0"
|
|
37
|
+
pandoc = "^2.4"
|
|
35
38
|
|
|
36
39
|
[build-system]
|
|
37
40
|
requires = ["poetry-core>=1.0.0"]
|
|
@@ -0,0 +1,583 @@
|
|
|
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
|
+
def get_status_v1(self, run_id: str):
|
|
397
|
+
"""`Deprecated, please use` :meth:`get_status`, `which will support additional results schemas in the near
|
|
398
|
+
future`. Identical to :meth:`get_status`, but returns results for completed simulations in the "V1"
|
|
399
|
+
SLD-style schema
|
|
400
|
+
|
|
401
|
+
:param run_id: ID of the scheduled model simulation
|
|
402
|
+
:return: :class:`~requests.Response` containing a JSON object with schema :class:`ModelStatus` (except with a
|
|
403
|
+
different schema for :attr:`~ModelStatus.result`)
|
|
404
|
+
"""
|
|
405
|
+
return self.get(f"get-status/{run_id}", params={"fmt": "v1"})
|
|
406
|
+
|
|
407
|
+
@staticmethod
|
|
408
|
+
def _wait_on_result(
|
|
409
|
+
run_id: str,
|
|
410
|
+
wait_time: int,
|
|
411
|
+
log_progress: bool,
|
|
412
|
+
getter: Callable[[str], Response],
|
|
413
|
+
) -> dict:
|
|
414
|
+
while True:
|
|
415
|
+
resp = getter(run_id)
|
|
416
|
+
resp.raise_for_status()
|
|
417
|
+
res = resp.json()
|
|
418
|
+
if res["status"] == "complete":
|
|
419
|
+
return res["result"]
|
|
420
|
+
elif res["status"] == "unknown":
|
|
421
|
+
raise UnknownRunId(f"No known model run with run_id '{run_id}'")
|
|
422
|
+
message = {"status": res["status"]}
|
|
423
|
+
if res.get("progress") is not None:
|
|
424
|
+
message["progress"] = f"{float(res['progress']) * 100:3.1f}%"
|
|
425
|
+
if log_progress:
|
|
426
|
+
logger.info("waiting on result", **message)
|
|
427
|
+
time.sleep(wait_time)
|
|
428
|
+
|
|
429
|
+
def wait_on_result(
|
|
430
|
+
self, run_id: str, wait_time: int = 5, log_progress: bool = False
|
|
431
|
+
) -> dict:
|
|
432
|
+
"""Poll for simulation status and, once complete, return the model results
|
|
433
|
+
|
|
434
|
+
:param run_id: ID of the scheduled model simulation
|
|
435
|
+
:param wait_time: time in seconds to wait between polling/updates
|
|
436
|
+
:param log_progress: indicate whether updates/progress should be logged/displayed. If ``True``, will
|
|
437
|
+
report both :attr:`~ModelStatus.status` and :attr:`~ModelStatus.progress` information
|
|
438
|
+
:return: results dictionary equivalent to :attr:`ModelStatus.result` returned by :meth:`get_status`, with the
|
|
439
|
+
exact schema depending on the model inputs
|
|
440
|
+
"""
|
|
441
|
+
return self._wait_on_result(
|
|
442
|
+
run_id, wait_time, log_progress, getter=self.get_status
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
def wait_on_result_v1(
|
|
446
|
+
self, run_id: str, wait_time: int = 5, log_progress: bool = False
|
|
447
|
+
):
|
|
448
|
+
"""`Deprecated, please use :meth:`wait_on_result`, which will support additional results schemas in the near
|
|
449
|
+
future`. Identical to :meth:`wait_on_result`, but returns results for completed simulations in the "V1"
|
|
450
|
+
SLD-style schema
|
|
451
|
+
|
|
452
|
+
:param run_id: ID of the scheduled model simulation
|
|
453
|
+
:param wait_time: time in seconds to wait between polling/updates
|
|
454
|
+
:param log_progress: indicate whether updates/progress should be logged/displayed. If ``True``, will
|
|
455
|
+
report both :attr:`~ModelStatus.status` and :attr:`~ModelStatus.progress` information
|
|
456
|
+
:return: results dictionary with "V1" SLD-style schema
|
|
457
|
+
"""
|
|
458
|
+
res = self._wait_on_result(
|
|
459
|
+
run_id, wait_time, log_progress, getter=self.get_status_v1
|
|
460
|
+
)
|
|
461
|
+
return parse_v1_result(res)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def parse_v1_result(res: dict):
|
|
465
|
+
""":meta private:"""
|
|
466
|
+
return {
|
|
467
|
+
"hourly": pd.concat(
|
|
468
|
+
{k: pd.DataFrame(v) for k, v in res["hourly"].items()}, axis=1
|
|
469
|
+
),
|
|
470
|
+
"waterfall": res["waterfall"],
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
class UnknownRunId(ValueError):
|
|
475
|
+
""":meta private:"""
|
|
476
|
+
pass
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
class NodeType(str, Enum):
|
|
480
|
+
"""Indicator of which type of physical infrastructure is associated with a particular market node"""
|
|
481
|
+
GENERATOR = "GENERATOR"
|
|
482
|
+
"""Not sure"""
|
|
483
|
+
SPTIE = "SPTIE"
|
|
484
|
+
"""Not sure"""
|
|
485
|
+
LOAD = "LOAD"
|
|
486
|
+
"""Not sure"""
|
|
487
|
+
INTERTIE = "INTERTIE"
|
|
488
|
+
"""Not sure"""
|
|
489
|
+
AGGREGATE = "AGGREGATE"
|
|
490
|
+
"""Not sure"""
|
|
491
|
+
HUB = "HUB"
|
|
492
|
+
"""Not sure"""
|
|
493
|
+
NA = "N/A"
|
|
494
|
+
"""Not sure"""
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
class NodeData(BaseModel):
|
|
498
|
+
"""Schema for node metadata"""
|
|
499
|
+
name: str
|
|
500
|
+
"""Name of the node"""
|
|
501
|
+
id: str
|
|
502
|
+
"""ID of the node"""
|
|
503
|
+
zone: str
|
|
504
|
+
"""Zone where the node is located within the ISO territory"""
|
|
505
|
+
type: NodeType
|
|
506
|
+
"""Identifier that indicates physical infrastructure associated with this node"""
|
|
507
|
+
da_start_year: float
|
|
508
|
+
"""First year in the Day Ahead (DA) market price dataset for this node"""
|
|
509
|
+
da_end_year: float
|
|
510
|
+
"""Final year in the Day Ahead (DA) market price dataset for this node"""
|
|
511
|
+
rt_start_year: int
|
|
512
|
+
"""First year in the Real Time (RT) market price dataset for this node"""
|
|
513
|
+
rt_end_year: int
|
|
514
|
+
"""Final year in the Real Time (RT) market price dataset for this node"""
|
|
515
|
+
substation: t.Optional[str] = None
|
|
516
|
+
"""Indicator of the grid substation associated with this node (not always present)"""
|
|
517
|
+
|
|
518
|
+
class PriceTimeSeries(BaseModel):
|
|
519
|
+
"""Schema for pricing data associated with a particular energy price node or ancillary pricing region"""
|
|
520
|
+
datetimes: list[str]
|
|
521
|
+
"""Beginning-of-interval datetimes for the hourly pricing given in local time.
|
|
522
|
+
|
|
523
|
+
- For energy prices, the datetimes are timezone-naive (no timezone identifier) but given in the local timezone
|
|
524
|
+
(i.e. including Daylight Savings Time or DST). E.g. The start of the year 2022 in ERCOT is given as
|
|
525
|
+
`'2022-01-01T00:00:00'` as opposed to `'2022-01-01T00:00:00-6:00'`. Leap days are represented by a single hour,
|
|
526
|
+
which should be dropped as a post-processing step.
|
|
527
|
+
- For ancillary prices, the datetimes are in local standard time (i.e. not including DST) but appear to be in
|
|
528
|
+
UTC ("Z" timezone identifier). E.g. The start of the year 2022 in ERCOT is given as `'2022-01-01T00:00:00Z'` and
|
|
529
|
+
not `'2022-01-01T00:00:00-6:00'`. Leap days are not included.
|
|
530
|
+
|
|
531
|
+
"""
|
|
532
|
+
prices: list[float]
|
|
533
|
+
"""Average hourly settlement prices for hours represented by :attr:`datetimes`.
|
|
534
|
+
"""
|
|
535
|
+
|
|
536
|
+
class NodeSearchData(BaseModel):
|
|
537
|
+
"""Schema for search-specific node metadata. The `(name 'xxxx')` to the right of the field names can be ignored"""
|
|
538
|
+
node_name: str = Field(..., alias='node/name')
|
|
539
|
+
"""Name of the node"""
|
|
540
|
+
node_id: str = Field(..., alias='node/id')
|
|
541
|
+
"""ID of the node"""
|
|
542
|
+
node_iso: str = Field(..., alias='node/iso')
|
|
543
|
+
"""ISO that the node belongs to"""
|
|
544
|
+
node_longitude: float = Field(..., alias='node/lng')
|
|
545
|
+
"""longitude of the point on the electrical grid associated with the node"""
|
|
546
|
+
node_latitude: float = Field(..., alias='node/lat')
|
|
547
|
+
"""latitude of the point on the electrical grid associated with the node"""
|
|
548
|
+
node_distance_meters: t.Optional[float] = Field(default=None, alias='node/distance-meters')
|
|
549
|
+
"""Distance from the node to the `location` parameter passed to :meth:`~LMP.search_nodes`. Not present if
|
|
550
|
+
`location` is not given.
|
|
551
|
+
"""
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
class AncillaryRegionData(BaseModel):
|
|
555
|
+
"""Schema for ancillary region metadata"""
|
|
556
|
+
region: str
|
|
557
|
+
"""Name of the region"""
|
|
558
|
+
start_year: int
|
|
559
|
+
"""First year in price dataset for the region and specified service"""
|
|
560
|
+
da_end_year: int
|
|
561
|
+
"""Final year in price dataset for the region and specified service"""
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
class ModelStatus(BaseModel):
|
|
565
|
+
"""Schema for model status and results"""
|
|
566
|
+
status: str
|
|
567
|
+
"""Status of the scheduled run. Possible values are explained in
|
|
568
|
+
:ref:`Tyba Model Run Status Codes <status_codes>`"""
|
|
569
|
+
progress: t.Optional[str]
|
|
570
|
+
"""Percentage value indicating the progress towards completing a model simulation. Only present if :attr:`status`
|
|
571
|
+
is not ``'complete'``.
|
|
572
|
+
|
|
573
|
+
- Note that in some cases the simulation may involve multiple optimization iterations, and the progress may appear
|
|
574
|
+
to start over as each additional iteration is undertaken
|
|
575
|
+
|
|
576
|
+
"""
|
|
577
|
+
result: t.Optional[V0Results]
|
|
578
|
+
"""Model simulation results dictionary with schema defined depending on the model inputs, e.g. scheduling a
|
|
579
|
+
:class:`~generation_models.generation_models.PVGenerationModel` will return a dictionary with
|
|
580
|
+
schema :class:`~generation_models.v0_output_schema.GenerationModelResults`. Only present if
|
|
581
|
+
:attr:`status` is ``'complete'``"""
|
|
582
|
+
|
|
583
|
+
|
|
@@ -3,6 +3,7 @@ from typing import List, Optional
|
|
|
3
3
|
|
|
4
4
|
|
|
5
5
|
class Forecast(object):
|
|
6
|
+
"""-"""
|
|
6
7
|
def __init__(self, client):
|
|
7
8
|
self.client = client
|
|
8
9
|
|
|
@@ -22,6 +23,18 @@ class Forecast(object):
|
|
|
22
23
|
prediction_lead_time_mins=None,
|
|
23
24
|
horizon_mins=None,
|
|
24
25
|
):
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
:param object_name:
|
|
29
|
+
:param product:
|
|
30
|
+
:param start_time:
|
|
31
|
+
:param end_time:
|
|
32
|
+
:param forecast_type:
|
|
33
|
+
:param predictions_per_hour:
|
|
34
|
+
:param prediction_lead_time_mins:
|
|
35
|
+
:param horizon_mins:
|
|
36
|
+
:return:
|
|
37
|
+
"""
|
|
25
38
|
return self.get(
|
|
26
39
|
"most_recent_forecast",
|
|
27
40
|
params={
|
|
@@ -49,6 +62,19 @@ class Forecast(object):
|
|
|
49
62
|
horizon_mins=None,
|
|
50
63
|
|
|
51
64
|
):
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
:param object_name:
|
|
68
|
+
:param product:
|
|
69
|
+
:param start_time:
|
|
70
|
+
:param end_time:
|
|
71
|
+
:param quantiles:
|
|
72
|
+
:param forecast_type:
|
|
73
|
+
:param predictions_per_hour:
|
|
74
|
+
:param prediction_lead_time_mins:
|
|
75
|
+
:param horizon_mins:
|
|
76
|
+
:return:
|
|
77
|
+
"""
|
|
52
78
|
return self.get(
|
|
53
79
|
"most_recent_probabilistic_forecast",
|
|
54
80
|
params={
|
|
@@ -78,6 +104,21 @@ class Forecast(object):
|
|
|
78
104
|
prediction_lead_time_mins=None,
|
|
79
105
|
horizon_mins=None,
|
|
80
106
|
):
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
:param object_name:
|
|
110
|
+
:param product:
|
|
111
|
+
:param start_time:
|
|
112
|
+
:param end_time:
|
|
113
|
+
:param days_ago:
|
|
114
|
+
:param before_time:
|
|
115
|
+
:param exact_vintage:
|
|
116
|
+
:param forecast_type:
|
|
117
|
+
:param predictions_per_hour:
|
|
118
|
+
:param prediction_lead_time_mins:
|
|
119
|
+
:param horizon_mins:
|
|
120
|
+
:return:
|
|
121
|
+
"""
|
|
81
122
|
return self.get(
|
|
82
123
|
"vintaged_forecast",
|
|
83
124
|
params={
|
|
@@ -110,6 +151,22 @@ class Forecast(object):
|
|
|
110
151
|
prediction_lead_time_mins=None,
|
|
111
152
|
horizon_mins=None,
|
|
112
153
|
):
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
:param object_name:
|
|
157
|
+
:param product:
|
|
158
|
+
:param start_time:
|
|
159
|
+
:param end_time:
|
|
160
|
+
:param quantiles:
|
|
161
|
+
:param days_ago:
|
|
162
|
+
:param before_time:
|
|
163
|
+
:param exact_vintage:
|
|
164
|
+
:param forecast_type:
|
|
165
|
+
:param predictions_per_hour:
|
|
166
|
+
:param prediction_lead_time_mins:
|
|
167
|
+
:param horizon_mins:
|
|
168
|
+
:return:
|
|
169
|
+
"""
|
|
113
170
|
return self.get(
|
|
114
171
|
"vintaged_probabilistic_forecast",
|
|
115
172
|
params={
|
|
@@ -139,6 +196,18 @@ class Forecast(object):
|
|
|
139
196
|
prediction_lead_time_mins=None,
|
|
140
197
|
horizon_mins=None,
|
|
141
198
|
):
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
:param object_name:
|
|
202
|
+
:param product:
|
|
203
|
+
:param vintage_start_time:
|
|
204
|
+
:param vintage_end_time:
|
|
205
|
+
:param forecast_type:
|
|
206
|
+
:param predictions_per_hour:
|
|
207
|
+
:param prediction_lead_time_mins:
|
|
208
|
+
:param horizon_mins:
|
|
209
|
+
:return:
|
|
210
|
+
"""
|
|
142
211
|
return self.get(
|
|
143
212
|
"forecasts_by_vintage",
|
|
144
213
|
params={
|
|
@@ -165,6 +234,19 @@ class Forecast(object):
|
|
|
165
234
|
prediction_lead_time_mins=None,
|
|
166
235
|
horizon_mins=None,
|
|
167
236
|
):
|
|
237
|
+
"""
|
|
238
|
+
|
|
239
|
+
:param object_name:
|
|
240
|
+
:param product:
|
|
241
|
+
:param quantiles:
|
|
242
|
+
:param vintage_start_time:
|
|
243
|
+
:param vintage_end_time:
|
|
244
|
+
:param forecast_type:
|
|
245
|
+
:param predictions_per_hour:
|
|
246
|
+
:param prediction_lead_time_mins:
|
|
247
|
+
:param horizon_mins:
|
|
248
|
+
:return:
|
|
249
|
+
"""
|
|
168
250
|
return self.get(
|
|
169
251
|
"probabilistic_forecasts_by_vintage",
|
|
170
252
|
params={
|
|
@@ -184,6 +266,14 @@ class Forecast(object):
|
|
|
184
266
|
self, object_name: str, product: str, start_time: datetime, end_time: datetime,
|
|
185
267
|
predictions_per_hour: Optional[int] = None
|
|
186
268
|
):
|
|
269
|
+
"""
|
|
270
|
+
|
|
271
|
+
:param object_name:
|
|
272
|
+
:param product:
|
|
273
|
+
:param start_time:
|
|
274
|
+
:param end_time:
|
|
275
|
+
:return:
|
|
276
|
+
"""
|
|
187
277
|
return self.get(
|
|
188
278
|
"actuals",
|
|
189
279
|
params={
|
|
@@ -5,6 +5,11 @@ from generation_models import (
|
|
|
5
5
|
ONDEfficiencyCurve,
|
|
6
6
|
ONDTemperatureDerateCurve,
|
|
7
7
|
)
|
|
8
|
+
from warnings import warn
|
|
9
|
+
|
|
10
|
+
warn("""tyba_client.io is deprecated in favor of using generation_models.utils.pvsyst_readers. Tyba will
|
|
11
|
+
cease to support tyba_client.io on 6/1/2025. See https://docs.tybaenergy.com/api/index.html or reach out
|
|
12
|
+
to us for help migrating.""", FutureWarning)
|
|
8
13
|
|
|
9
14
|
|
|
10
15
|
def read_pvsyst_file(path: str) -> dict:
|
|
@@ -53,7 +58,7 @@ def pv_module_from_pan(
|
|
|
53
58
|
bifacial_ground_clearance_height=1.0,
|
|
54
59
|
bifacial_transmission_factor: float = 0.013,
|
|
55
60
|
) -> PVModuleMermoudLejeune:
|
|
56
|
-
"""
|
|
61
|
+
"""DEPRECATED. See :func:`generation_models.utils.pvsyst_readers.pv_module_from_pan`."""
|
|
57
62
|
pan_blob = read_pvsyst_file(pan_file)
|
|
58
63
|
data = pan_blob["PVObject_"]["items"]
|
|
59
64
|
commercial = data["PVObject_Commercial"]["items"]
|
|
@@ -103,7 +108,7 @@ def pv_module_from_pan(
|
|
|
103
108
|
|
|
104
109
|
|
|
105
110
|
def inverter_from_ond(ond_file: str, includes_xfmr: bool = True) -> ONDInverter:
|
|
106
|
-
"""
|
|
111
|
+
"""DEPRECATED. See :func:`generation_models.utils.pvsyst_readers.inverter_from_ond`."""
|
|
107
112
|
ond = read_pvsyst_file(ond_file)
|
|
108
113
|
data = ond["PVObject_"]["items"]
|
|
109
114
|
converter = data["Converter"]["items"]
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import datetime
|
|
2
|
+
import json
|
|
2
3
|
from datetime import date
|
|
3
|
-
from typing import Optional
|
|
4
|
+
from typing import Optional, Literal
|
|
4
5
|
|
|
5
6
|
|
|
6
7
|
class Operations(object):
|
|
@@ -12,6 +13,10 @@ class Operations(object):
|
|
|
12
13
|
response.raise_for_status()
|
|
13
14
|
return response.text
|
|
14
15
|
|
|
16
|
+
|
|
17
|
+
def post(self, route, json):
|
|
18
|
+
return self.client.post(f"operations/{route}", json=json)
|
|
19
|
+
|
|
15
20
|
def performance_report(
|
|
16
21
|
self,
|
|
17
22
|
start_date: date,
|
|
@@ -82,3 +87,58 @@ class Operations(object):
|
|
|
82
87
|
if org_id:
|
|
83
88
|
params["org_id"] = org_id
|
|
84
89
|
return self.get("internal_api/assets", params=params)
|
|
90
|
+
|
|
91
|
+
def set_asset_overrides(
|
|
92
|
+
self,
|
|
93
|
+
asset_names: list[str],
|
|
94
|
+
field: str,
|
|
95
|
+
aggregation: Literal["global", "single_day", "single_day_hourly", "12x24"],
|
|
96
|
+
values,
|
|
97
|
+
service: Optional[str] = None,
|
|
98
|
+
date: Optional[datetime.date] = None,
|
|
99
|
+
):
|
|
100
|
+
assumption = {
|
|
101
|
+
"field": field,
|
|
102
|
+
"data": {"aggregation":aggregation }
|
|
103
|
+
}
|
|
104
|
+
match aggregation:
|
|
105
|
+
case "12x24":
|
|
106
|
+
assumption["data"] = {"aggregation": "12x24"} | values
|
|
107
|
+
case "single_day_hourly":
|
|
108
|
+
assumption["data"] = {
|
|
109
|
+
"aggregation": "single_day_hourly",
|
|
110
|
+
"date": date,
|
|
111
|
+
"values": values
|
|
112
|
+
}
|
|
113
|
+
case "single_day":
|
|
114
|
+
assumption["data"] = {
|
|
115
|
+
"aggregation": "single_day_hourly",
|
|
116
|
+
"values": values,
|
|
117
|
+
"date": date
|
|
118
|
+
}
|
|
119
|
+
case "global":
|
|
120
|
+
assumption["data"] = {
|
|
121
|
+
"aggregation": "global",
|
|
122
|
+
"value": values
|
|
123
|
+
}
|
|
124
|
+
if service:
|
|
125
|
+
assumption["service"] = service
|
|
126
|
+
request_data = {"asset_names": asset_names, "assumption": assumption}
|
|
127
|
+
|
|
128
|
+
res = self.post("internal_api/assets/override/", json=request_data)
|
|
129
|
+
if res.status_code != 200:
|
|
130
|
+
return {
|
|
131
|
+
"status_code": res.status_code,
|
|
132
|
+
"reason": res.reason,
|
|
133
|
+
"message": res.text
|
|
134
|
+
}
|
|
135
|
+
else:
|
|
136
|
+
return json.loads(res.text)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def overrides_schema(
|
|
141
|
+
self,
|
|
142
|
+
):
|
|
143
|
+
|
|
144
|
+
return json.loads(self.get("internal_api/overrides_schema"))
|
|
@@ -8,6 +8,12 @@ from generation_models import SolarResource, SolarResourceTimeSeries
|
|
|
8
8
|
from requests.exceptions import HTTPError
|
|
9
9
|
import typing as t
|
|
10
10
|
from io import StringIO
|
|
11
|
+
from warnings import warn
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
warn("""tyba_client.solar_resource is deprecated in favor of using generation_models.utils.psm_readers. Tyba will
|
|
15
|
+
cease to support tyba_client.solar_resource on 6/1/2025. See https://docs.tybaenergy.com/api/index.html or reach out
|
|
16
|
+
to us for help migrating.""", FutureWarning)
|
|
11
17
|
|
|
12
18
|
|
|
13
19
|
@dataclass
|
|
@@ -1,219 +0,0 @@
|
|
|
1
|
-
import pandas as pd
|
|
2
|
-
import typing as t
|
|
3
|
-
from requests import Response
|
|
4
|
-
|
|
5
|
-
from tyba_client.models import GenerationModel, PVStorageModel, StandaloneStorageModel
|
|
6
|
-
from tyba_client.forecast import Forecast
|
|
7
|
-
from generation_models import JobModel
|
|
8
|
-
import json
|
|
9
|
-
import requests
|
|
10
|
-
import time
|
|
11
|
-
from structlog import get_logger
|
|
12
|
-
from typing import Callable
|
|
13
|
-
|
|
14
|
-
from tyba_client.operations import Operations
|
|
15
|
-
|
|
16
|
-
logger = get_logger()
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
class Ancillary(object):
|
|
20
|
-
"""_"""
|
|
21
|
-
|
|
22
|
-
def __init__(self, services):
|
|
23
|
-
self.services = services
|
|
24
|
-
|
|
25
|
-
def get(self, route, params=None):
|
|
26
|
-
return self.services.get(f"ancillary/{route}", params=params)
|
|
27
|
-
|
|
28
|
-
def get_pricing_regions(self, *, iso, service, market):
|
|
29
|
-
"""_"""
|
|
30
|
-
return self.get("regions", {"iso": iso, "service": service, "market": market})
|
|
31
|
-
|
|
32
|
-
def get_prices(self, *, iso, service, market, region, start_year, end_year):
|
|
33
|
-
"""_"""
|
|
34
|
-
return self.get(
|
|
35
|
-
"prices",
|
|
36
|
-
{
|
|
37
|
-
"iso": iso,
|
|
38
|
-
"service": service,
|
|
39
|
-
"market": market,
|
|
40
|
-
"region": region,
|
|
41
|
-
"start_year": start_year,
|
|
42
|
-
"end_year": end_year,
|
|
43
|
-
},
|
|
44
|
-
)
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
class LMP(object):
|
|
48
|
-
"""_"""
|
|
49
|
-
|
|
50
|
-
def __init__(self, services):
|
|
51
|
-
self.services = services
|
|
52
|
-
self._route_base = "lmp"
|
|
53
|
-
|
|
54
|
-
def get(self, route, params=None):
|
|
55
|
-
return self.services.get(f"{self._route_base}/{route}", params=params)
|
|
56
|
-
|
|
57
|
-
def post(self, route, json):
|
|
58
|
-
return self.services.post(f"{self._route_base}/{route}", json=json)
|
|
59
|
-
|
|
60
|
-
def get_all_nodes(self, *, iso):
|
|
61
|
-
"""_"""
|
|
62
|
-
return self.get("nodes", {"iso": iso})
|
|
63
|
-
|
|
64
|
-
def get_prices(self, *, node_ids, market, start_year, end_year):
|
|
65
|
-
"""_"""
|
|
66
|
-
return self.get(
|
|
67
|
-
"prices",
|
|
68
|
-
{
|
|
69
|
-
"node_ids": json.dumps(node_ids),
|
|
70
|
-
"market": market,
|
|
71
|
-
"start_year": start_year,
|
|
72
|
-
"end_year": end_year,
|
|
73
|
-
},
|
|
74
|
-
)
|
|
75
|
-
|
|
76
|
-
def search_nodes(self, location: t.Optional[str] = None, node_name_filter: t.Optional[str] = None, iso_override: t.Optional[str] = None):
|
|
77
|
-
return self.get(route="search-nodes",
|
|
78
|
-
params={"location": location,
|
|
79
|
-
"node_name_filter": node_name_filter,
|
|
80
|
-
"iso_override": iso_override})
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
class Services(object):
|
|
84
|
-
"""_"""
|
|
85
|
-
|
|
86
|
-
def __init__(self, client):
|
|
87
|
-
self.client = client
|
|
88
|
-
self.ancillary = Ancillary(self)
|
|
89
|
-
self.lmp = LMP(self)
|
|
90
|
-
self._route_base = "services"
|
|
91
|
-
|
|
92
|
-
def get(self, route, params=None):
|
|
93
|
-
return self.client.get(f"{self._route_base}/{route}", params=params)
|
|
94
|
-
|
|
95
|
-
def post(self, route, json):
|
|
96
|
-
return self.client.post(f"{self._route_base}/{route}", json=json)
|
|
97
|
-
|
|
98
|
-
def get_all_isos(self):
|
|
99
|
-
"""_"""
|
|
100
|
-
return self.get("isos")
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
class Client(object):
|
|
104
|
-
"""Tyba valuation client class"""
|
|
105
|
-
|
|
106
|
-
DEFAULT_OPTIONS = {"version": "0.1"}
|
|
107
|
-
|
|
108
|
-
def __init__(
|
|
109
|
-
self,
|
|
110
|
-
personal_access_token,
|
|
111
|
-
host="https://dev.tybaenergy.com",
|
|
112
|
-
request_args=None,
|
|
113
|
-
):
|
|
114
|
-
"""A :class:`Client` object for interacting with Tyba's API."""
|
|
115
|
-
self.personal_access_token = personal_access_token
|
|
116
|
-
self.host = host
|
|
117
|
-
self.services = Services(self)
|
|
118
|
-
self.forecast = Forecast(self)
|
|
119
|
-
self.operations = Operations(self)
|
|
120
|
-
self.request_args = {} if request_args is None else request_args
|
|
121
|
-
|
|
122
|
-
def _auth_header(self):
|
|
123
|
-
return self.personal_access_token
|
|
124
|
-
|
|
125
|
-
def _base_url(self):
|
|
126
|
-
return self.host + "/public/" + self.DEFAULT_OPTIONS["version"] + "/"
|
|
127
|
-
|
|
128
|
-
def get(self, route, params=None):
|
|
129
|
-
return requests.get(
|
|
130
|
-
self._base_url() + route,
|
|
131
|
-
params=params,
|
|
132
|
-
headers={"Authorization": self._auth_header()},
|
|
133
|
-
**self.request_args,
|
|
134
|
-
)
|
|
135
|
-
|
|
136
|
-
def post(self, route, json):
|
|
137
|
-
return requests.post(
|
|
138
|
-
self._base_url() + route,
|
|
139
|
-
json=json,
|
|
140
|
-
headers={"Authorization": self._auth_header()},
|
|
141
|
-
**self.request_args,
|
|
142
|
-
)
|
|
143
|
-
|
|
144
|
-
def schedule_pv(self, pv_model: GenerationModel):
|
|
145
|
-
model_json_dict = pv_model.to_dict()
|
|
146
|
-
return self.post("schedule-pv", json=model_json_dict)
|
|
147
|
-
|
|
148
|
-
def schedule_storage(self, storage_model: StandaloneStorageModel):
|
|
149
|
-
model_json_dict = storage_model.to_dict()
|
|
150
|
-
return self.post("schedule-storage", json=model_json_dict)
|
|
151
|
-
|
|
152
|
-
def schedule_pv_storage(self, pv_storage_model: PVStorageModel):
|
|
153
|
-
model_json_dict = pv_storage_model.to_dict()
|
|
154
|
-
return self.post("schedule-pv-storage", json=model_json_dict)
|
|
155
|
-
|
|
156
|
-
def schedule(self, model: JobModel):
|
|
157
|
-
"""_"""
|
|
158
|
-
return self.post("schedule-job", json=model.dict())
|
|
159
|
-
|
|
160
|
-
def get_status(self, run_id: str):
|
|
161
|
-
"""_"""
|
|
162
|
-
url = "get-status/" + run_id
|
|
163
|
-
return self.get(url)
|
|
164
|
-
|
|
165
|
-
def get_status_v1(self, run_id: str):
|
|
166
|
-
"""_"""
|
|
167
|
-
return self.get(f"get-status/{run_id}", params={"fmt": "v1"})
|
|
168
|
-
|
|
169
|
-
@staticmethod
|
|
170
|
-
def _wait_on_result(
|
|
171
|
-
run_id: str,
|
|
172
|
-
wait_time: int,
|
|
173
|
-
log_progress: bool,
|
|
174
|
-
getter: Callable[[str], Response],
|
|
175
|
-
):
|
|
176
|
-
while True:
|
|
177
|
-
resp = getter(run_id)
|
|
178
|
-
resp.raise_for_status()
|
|
179
|
-
res = resp.json()
|
|
180
|
-
if res["status"] == "complete":
|
|
181
|
-
return res["result"]
|
|
182
|
-
elif res["status"] == "unknown":
|
|
183
|
-
raise UnknownRunId(f"No known model run with run_id '{run_id}'")
|
|
184
|
-
message = {"status": res["status"]}
|
|
185
|
-
if res.get("progress") is not None:
|
|
186
|
-
message["progress"] = f"{float(res['progress']) * 100:3.1f}%"
|
|
187
|
-
if log_progress:
|
|
188
|
-
logger.info("waiting on result", **message)
|
|
189
|
-
time.sleep(wait_time)
|
|
190
|
-
|
|
191
|
-
def wait_on_result(
|
|
192
|
-
self, run_id: str, wait_time: int = 5, log_progress: bool = False
|
|
193
|
-
):
|
|
194
|
-
"""_"""
|
|
195
|
-
return self._wait_on_result(
|
|
196
|
-
run_id, wait_time, log_progress, getter=self.get_status
|
|
197
|
-
)
|
|
198
|
-
|
|
199
|
-
def wait_on_result_v1(
|
|
200
|
-
self, run_id: str, wait_time: int = 5, log_progress: bool = False
|
|
201
|
-
):
|
|
202
|
-
"""_"""
|
|
203
|
-
res = self._wait_on_result(
|
|
204
|
-
run_id, wait_time, log_progress, getter=self.get_status_v1
|
|
205
|
-
)
|
|
206
|
-
return parse_v1_result(res)
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
def parse_v1_result(res: dict):
|
|
210
|
-
return {
|
|
211
|
-
"hourly": pd.concat(
|
|
212
|
-
{k: pd.DataFrame(v) for k, v in res["hourly"].items()}, axis=1
|
|
213
|
-
),
|
|
214
|
-
"waterfall": res["waterfall"],
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
class UnknownRunId(ValueError):
|
|
219
|
-
pass
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|