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.
@@ -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,4 @@
1
+ from importlib.metadata import version
2
+
3
+
4
+ __version__ = version("tyba-client")
@@ -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
+