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.

@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.3
2
2
  Name: tyba-client
3
- Version: 0.4.13
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.13"
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 = "^7.0.1"
29
- autodoc-pydantic = "^1.8.0"
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 = "^0.9.3"
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