kardashev 0.1.0__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,5 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ dist/
5
+ *.egg-info/
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.4
2
+ Name: kardashev
3
+ Version: 0.1.0
4
+ Summary: Python client for the Kardashev Labs energy data API
5
+ Project-URL: Homepage, https://kardashevlabs.org
6
+ Project-URL: API, https://data.kardashevlabs.org/docs
7
+ Project-URL: Source, https://github.com/kardashev-lab/kardashev-py
8
+ Requires-Python: >=3.9
9
+ Requires-Dist: httpx>=0.24
10
+ Provides-Extra: dev
11
+ Requires-Dist: pandas; extra == 'dev'
12
+ Requires-Dist: pytest; extra == 'dev'
13
+ Requires-Dist: pytest-httpx; extra == 'dev'
14
+ Provides-Extra: pandas
15
+ Requires-Dist: pandas>=1.5; extra == 'pandas'
@@ -0,0 +1,4 @@
1
+ from kardashev.client import Client
2
+
3
+ __all__ = ["Client"]
4
+ __version__ = "0.1.0"
@@ -0,0 +1,534 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import date
4
+ from typing import Any, Optional, Union
5
+
6
+ import httpx
7
+
8
+ _BASE = "https://data.kardashevlabs.org"
9
+
10
+ try:
11
+ import pandas as pd
12
+ _PANDAS = True
13
+ except ImportError:
14
+ _PANDAS = False
15
+
16
+
17
+ def _to_df(data: list[dict]) -> Any:
18
+ if _PANDAS and data:
19
+ df = pd.DataFrame(data)
20
+ for col in df.columns:
21
+ if "ts" in col or col in ("start_time", "end_time", "report_date"):
22
+ try:
23
+ df[col] = pd.to_datetime(df[col], utc=True)
24
+ except Exception:
25
+ pass
26
+ return df
27
+ return data
28
+
29
+
30
+ def _fmt(d: Optional[date]) -> Optional[str]:
31
+ return d.isoformat() if d else None
32
+
33
+
34
+ class Client:
35
+ """Python client for the Kardashev Labs energy data API.
36
+
37
+ Parameters
38
+ ----------
39
+ base_url:
40
+ Override the API base URL (useful for local dev).
41
+ timeout:
42
+ HTTP timeout in seconds.
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ base_url: str = _BASE,
48
+ timeout: float = 30.0,
49
+ ) -> None:
50
+ self._http = httpx.Client(base_url=base_url.rstrip("/"), timeout=timeout)
51
+
52
+ def _get(self, path: str, **params: Any) -> list[dict]:
53
+ clean = {k: v for k, v in params.items() if v is not None}
54
+ r = self._http.get(path, params=clean)
55
+ r.raise_for_status()
56
+ return r.json()
57
+
58
+ # ------------------------------------------------------------------
59
+ # Fuel mix
60
+ # ------------------------------------------------------------------
61
+
62
+ def fuel_mix(
63
+ self,
64
+ iso: str,
65
+ start: Optional[date] = None,
66
+ end: Optional[date] = None,
67
+ hours: int = 24,
68
+ limit: int = 2000,
69
+ ) -> Any:
70
+ """Real-time fuel mix (MW by fuel type) for an ISO."""
71
+ return _to_df(self._get(
72
+ "/fuel-mix",
73
+ iso=iso.upper(),
74
+ start=_fmt(start),
75
+ end=_fmt(end),
76
+ hours=hours,
77
+ limit=limit,
78
+ ))
79
+
80
+ # ------------------------------------------------------------------
81
+ # Carbon intensity
82
+ # ------------------------------------------------------------------
83
+
84
+ def carbon(
85
+ self,
86
+ iso: str,
87
+ start: Optional[date] = None,
88
+ end: Optional[date] = None,
89
+ hours: int = 24,
90
+ limit: int = 2000,
91
+ ) -> Any:
92
+ """Hourly carbon intensity (lbs CO₂/MWh) for an ISO."""
93
+ return _to_df(self._get(
94
+ "/carbon",
95
+ iso=iso.upper(),
96
+ start=_fmt(start),
97
+ end=_fmt(end),
98
+ hours=hours,
99
+ limit=limit,
100
+ ))
101
+
102
+ def carbon_latest(self, iso: Optional[str] = None) -> Any:
103
+ """Latest carbon snapshot — one row per ISO."""
104
+ return _to_df(self._get("/carbon/latest", iso=iso.upper() if iso else None))
105
+
106
+ # ------------------------------------------------------------------
107
+ # LMP
108
+ # ------------------------------------------------------------------
109
+
110
+ def lmp(
111
+ self,
112
+ iso: str,
113
+ node_id: Optional[str] = None,
114
+ market: str = "RT",
115
+ start: Optional[date] = None,
116
+ end: Optional[date] = None,
117
+ hours: int = 24,
118
+ limit: int = 2000,
119
+ ) -> Any:
120
+ """Locational marginal prices (LMP, energy, congestion, loss)."""
121
+ return _to_df(self._get(
122
+ "/lmp",
123
+ iso=iso.upper(),
124
+ node_id=node_id,
125
+ market=market.upper(),
126
+ start=_fmt(start),
127
+ end=_fmt(end),
128
+ hours=hours,
129
+ limit=limit,
130
+ ))
131
+
132
+ def lmp_map(self, iso: str, market: str = "RT") -> Any:
133
+ """Latest LMP for all nodes with lat/lng — for map rendering."""
134
+ return _to_df(self._get("/lmp/map", iso=iso.upper(), market=market.upper()))
135
+
136
+ def lmp_hubs(self, iso: Optional[str] = None) -> Any:
137
+ """List all tracked LMP pricing nodes."""
138
+ return _to_df(self._get("/lmp/hubs", iso=iso.upper() if iso else None))
139
+
140
+ # ------------------------------------------------------------------
141
+ # Load
142
+ # ------------------------------------------------------------------
143
+
144
+ def load(
145
+ self,
146
+ iso: Optional[str] = None,
147
+ start: Optional[date] = None,
148
+ end: Optional[date] = None,
149
+ hours: int = 24,
150
+ limit: int = 2000,
151
+ ) -> Any:
152
+ """Actual grid load (MW) by ISO."""
153
+ return _to_df(self._get(
154
+ "/load",
155
+ iso=iso.upper() if iso else None,
156
+ start=_fmt(start),
157
+ end=_fmt(end),
158
+ hours=hours,
159
+ limit=limit,
160
+ ))
161
+
162
+ def load_forecast(
163
+ self,
164
+ iso: Optional[str] = None,
165
+ hours: int = 24,
166
+ ) -> Any:
167
+ """Load forecast (MW) for the next N hours."""
168
+ return _to_df(self._get(
169
+ "/load/forecast",
170
+ iso=iso.upper() if iso else None,
171
+ hours=hours,
172
+ ))
173
+
174
+ # ------------------------------------------------------------------
175
+ # Generation
176
+ # ------------------------------------------------------------------
177
+
178
+ def generation(
179
+ self,
180
+ iso: Optional[str] = None,
181
+ fuel_type: Optional[str] = None,
182
+ start: Optional[date] = None,
183
+ end: Optional[date] = None,
184
+ hours: int = 24,
185
+ limit: int = 2000,
186
+ ) -> Any:
187
+ """Generation by fuel type for an ISO."""
188
+ return _to_df(self._get(
189
+ "/generation",
190
+ iso=iso.upper() if iso else None,
191
+ fuel_type=fuel_type,
192
+ start=_fmt(start),
193
+ end=_fmt(end),
194
+ hours=hours,
195
+ limit=limit,
196
+ ))
197
+
198
+ # ------------------------------------------------------------------
199
+ # Curtailment
200
+ # ------------------------------------------------------------------
201
+
202
+ def curtailment(
203
+ self,
204
+ iso: Optional[str] = None,
205
+ start: Optional[date] = None,
206
+ end: Optional[date] = None,
207
+ hours: int = 24,
208
+ limit: int = 2000,
209
+ ) -> Any:
210
+ """Renewable curtailment (MWh) by ISO."""
211
+ return _to_df(self._get(
212
+ "/curtailment",
213
+ iso=iso.upper() if iso else None,
214
+ start=_fmt(start),
215
+ end=_fmt(end),
216
+ hours=hours,
217
+ limit=limit,
218
+ ))
219
+
220
+ # ------------------------------------------------------------------
221
+ # Interchange
222
+ # ------------------------------------------------------------------
223
+
224
+ def interchange(
225
+ self,
226
+ ba: str,
227
+ start: Optional[date] = None,
228
+ end: Optional[date] = None,
229
+ hours: int = 24,
230
+ limit: int = 5000,
231
+ ) -> Any:
232
+ """Net interchange (MW) from a balancing authority to neighbors."""
233
+ return _to_df(self._get(
234
+ "/interchange",
235
+ ba=ba.upper(),
236
+ start=_fmt(start),
237
+ end=_fmt(end),
238
+ hours=hours,
239
+ limit=limit,
240
+ ))
241
+
242
+ # ------------------------------------------------------------------
243
+ # Natural gas
244
+ # ------------------------------------------------------------------
245
+
246
+ def nat_gas(
247
+ self,
248
+ hub: Optional[str] = None,
249
+ start: Optional[date] = None,
250
+ end: Optional[date] = None,
251
+ days: int = 90,
252
+ limit: int = 5000,
253
+ ) -> Any:
254
+ """Daily natural gas spot prices ($/MMBtu) at major US hubs."""
255
+ return _to_df(self._get(
256
+ "/natural-gas",
257
+ hub=hub,
258
+ start=_fmt(start),
259
+ end=_fmt(end),
260
+ days=days,
261
+ limit=limit,
262
+ ))
263
+
264
+ def nat_gas_storage(
265
+ self,
266
+ region: Optional[str] = None,
267
+ start: Optional[date] = None,
268
+ end: Optional[date] = None,
269
+ weeks: int = 52,
270
+ limit: int = 2000,
271
+ ) -> Any:
272
+ """Weekly EIA natural gas in storage (Bcf) by region."""
273
+ return _to_df(self._get(
274
+ "/natural-gas/storage",
275
+ region=region,
276
+ start=_fmt(start),
277
+ end=_fmt(end),
278
+ weeks=weeks,
279
+ limit=limit,
280
+ ))
281
+
282
+ # ------------------------------------------------------------------
283
+ # Weather
284
+ # ------------------------------------------------------------------
285
+
286
+ def weather(
287
+ self,
288
+ iso: Optional[str] = None,
289
+ start: Optional[date] = None,
290
+ end: Optional[date] = None,
291
+ hours: int = 24,
292
+ limit: int = 2000,
293
+ ) -> Any:
294
+ """Hourly temperature at representative ISO hub cities."""
295
+ return _to_df(self._get(
296
+ "/weather",
297
+ iso=iso.upper() if iso else None,
298
+ start=_fmt(start),
299
+ end=_fmt(end),
300
+ hours=hours,
301
+ limit=limit,
302
+ ))
303
+
304
+ # ------------------------------------------------------------------
305
+ # BPA
306
+ # ------------------------------------------------------------------
307
+
308
+ def bpa(
309
+ self,
310
+ start: Optional[date] = None,
311
+ end: Optional[date] = None,
312
+ hours: int = 24,
313
+ limit: int = 2000,
314
+ ) -> Any:
315
+ """BPA 5-min balancing area: wind, hydro, thermal, load."""
316
+ return _to_df(self._get(
317
+ "/bpa",
318
+ start=_fmt(start),
319
+ end=_fmt(end),
320
+ hours=hours,
321
+ limit=limit,
322
+ ))
323
+
324
+ # ------------------------------------------------------------------
325
+ # Generator outages
326
+ # ------------------------------------------------------------------
327
+
328
+ def outages(
329
+ self,
330
+ iso: Optional[str] = None,
331
+ outage_type: Optional[str] = None,
332
+ active_only: bool = False,
333
+ days: int = 7,
334
+ limit: int = 2000,
335
+ ) -> Any:
336
+ """Generator outages (unit-level and aggregate) by ISO."""
337
+ return _to_df(self._get(
338
+ "/outages",
339
+ iso=iso.upper() if iso else None,
340
+ outage_type=outage_type,
341
+ active_only=active_only if active_only else None,
342
+ days=days,
343
+ limit=limit,
344
+ ))
345
+
346
+ def outages_summary(self, iso: Optional[str] = None) -> Any:
347
+ """Total MW in outage grouped by ISO × outage type."""
348
+ return _to_df(self._get(
349
+ "/outages/summary",
350
+ iso=iso.upper() if iso else None,
351
+ ))
352
+
353
+ # ------------------------------------------------------------------
354
+ # Ancillary services
355
+ # ------------------------------------------------------------------
356
+
357
+ def ancillary(
358
+ self,
359
+ iso: Optional[str] = None,
360
+ market: Optional[str] = None,
361
+ service_type: Optional[str] = None,
362
+ start: Optional[date] = None,
363
+ end: Optional[date] = None,
364
+ hours: int = 24,
365
+ limit: int = 2000,
366
+ ) -> Any:
367
+ """Ancillary service clearing prices and operational capacity."""
368
+ return _to_df(self._get(
369
+ "/ancillary",
370
+ iso=iso.upper() if iso else None,
371
+ market=market.upper() if market else None,
372
+ service_type=service_type,
373
+ start=_fmt(start),
374
+ end=_fmt(end),
375
+ hours=hours,
376
+ limit=limit,
377
+ ))
378
+
379
+ def ancillary_latest(self, iso: Optional[str] = None) -> Any:
380
+ """Latest ancillary snapshot — one row per (ISO, market, service_type)."""
381
+ return _to_df(self._get(
382
+ "/ancillary/latest",
383
+ iso=iso.upper() if iso else None,
384
+ ))
385
+
386
+ # ------------------------------------------------------------------
387
+ # Nuclear
388
+ # ------------------------------------------------------------------
389
+
390
+ def nuclear_status(self, iso: Optional[str] = None) -> Any:
391
+ """Current nuclear unit capacity and output."""
392
+ return _to_df(self._get(
393
+ "/nuclear/status",
394
+ iso=iso.upper() if iso else None,
395
+ ))
396
+
397
+ # ------------------------------------------------------------------
398
+ # Emissions
399
+ # ------------------------------------------------------------------
400
+
401
+ def emissions(
402
+ self,
403
+ iso: Optional[str] = None,
404
+ start: Optional[date] = None,
405
+ end: Optional[date] = None,
406
+ hours: int = 24,
407
+ limit: int = 2000,
408
+ ) -> Any:
409
+ """SO₂, NOₓ, CO₂ emissions by ISO."""
410
+ return _to_df(self._get(
411
+ "/emissions",
412
+ iso=iso.upper() if iso else None,
413
+ start=_fmt(start),
414
+ end=_fmt(end),
415
+ hours=hours,
416
+ limit=limit,
417
+ ))
418
+
419
+ # ------------------------------------------------------------------
420
+ # Hydro
421
+ # ------------------------------------------------------------------
422
+
423
+ def hydro(
424
+ self,
425
+ iso: Optional[str] = None,
426
+ start: Optional[date] = None,
427
+ end: Optional[date] = None,
428
+ hours: int = 24,
429
+ limit: int = 2000,
430
+ ) -> Any:
431
+ """Hydro generation and reservoir conditions."""
432
+ return _to_df(self._get(
433
+ "/hydro",
434
+ iso=iso.upper() if iso else None,
435
+ start=_fmt(start),
436
+ end=_fmt(end),
437
+ hours=hours,
438
+ limit=limit,
439
+ ))
440
+
441
+ # ------------------------------------------------------------------
442
+ # Solar
443
+ # ------------------------------------------------------------------
444
+
445
+ def solar(
446
+ self,
447
+ iso: Optional[str] = None,
448
+ start: Optional[date] = None,
449
+ end: Optional[date] = None,
450
+ hours: int = 24,
451
+ limit: int = 2000,
452
+ ) -> Any:
453
+ """Solar generation and curtailment by ISO."""
454
+ return _to_df(self._get(
455
+ "/solar",
456
+ iso=iso.upper() if iso else None,
457
+ start=_fmt(start),
458
+ end=_fmt(end),
459
+ hours=hours,
460
+ limit=limit,
461
+ ))
462
+
463
+ # ------------------------------------------------------------------
464
+ # Interconnection queue
465
+ # ------------------------------------------------------------------
466
+
467
+ def queue(
468
+ self,
469
+ iso: Optional[str] = None,
470
+ status: Optional[str] = None,
471
+ fuel_type: Optional[str] = None,
472
+ limit: int = 5000,
473
+ ) -> Any:
474
+ """Generator interconnection queue entries."""
475
+ return _to_df(self._get(
476
+ "/queue",
477
+ iso=iso.upper() if iso else None,
478
+ status=status,
479
+ fuel_type=fuel_type,
480
+ limit=limit,
481
+ ))
482
+
483
+ # ------------------------------------------------------------------
484
+ # Commodities
485
+ # ------------------------------------------------------------------
486
+
487
+ def commodities(
488
+ self,
489
+ commodity: Optional[str] = None,
490
+ start: Optional[date] = None,
491
+ end: Optional[date] = None,
492
+ days: int = 90,
493
+ limit: int = 2000,
494
+ ) -> Any:
495
+ """Commodity prices (coal, uranium, carbon credits)."""
496
+ return _to_df(self._get(
497
+ "/commodities",
498
+ commodity=commodity,
499
+ start=_fmt(start),
500
+ end=_fmt(end),
501
+ days=days,
502
+ limit=limit,
503
+ ))
504
+
505
+ # ------------------------------------------------------------------
506
+ # Carbon markets
507
+ # ------------------------------------------------------------------
508
+
509
+ def carbon_markets(
510
+ self,
511
+ market: Optional[str] = None,
512
+ start: Optional[date] = None,
513
+ end: Optional[date] = None,
514
+ days: int = 90,
515
+ limit: int = 2000,
516
+ ) -> Any:
517
+ """Carbon credit prices (RGGI, WCI, VCM)."""
518
+ return _to_df(self._get(
519
+ "/carbon-markets",
520
+ market=market,
521
+ start=_fmt(start),
522
+ end=_fmt(end),
523
+ days=days,
524
+ limit=limit,
525
+ ))
526
+
527
+ def close(self) -> None:
528
+ self._http.close()
529
+
530
+ def __enter__(self) -> "Client":
531
+ return self
532
+
533
+ def __exit__(self, *_: Any) -> None:
534
+ self.close()
@@ -0,0 +1,19 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "kardashev"
7
+ version = "0.1.0"
8
+ description = "Python client for the Kardashev Labs energy data API"
9
+ requires-python = ">=3.9"
10
+ dependencies = ["httpx>=0.24"]
11
+
12
+ [project.optional-dependencies]
13
+ pandas = ["pandas>=1.5"]
14
+ dev = ["pytest", "pytest-httpx", "pandas"]
15
+
16
+ [project.urls]
17
+ Homepage = "https://kardashevlabs.org"
18
+ API = "https://data.kardashevlabs.org/docs"
19
+ Source = "https://github.com/kardashev-lab/kardashev-py"
@@ -0,0 +1,72 @@
1
+ """Smoke tests using pytest-httpx to mock the API."""
2
+ from datetime import date
3
+
4
+ from pytest_httpx import HTTPXMock
5
+
6
+ from kardashev import Client
7
+
8
+ try:
9
+ import pandas as pd
10
+ _PANDAS = True
11
+ except ImportError:
12
+ _PANDAS = False
13
+
14
+ BASE = "https://data.kardashevlabs.org"
15
+
16
+ FUEL_MIX_RESPONSE = [
17
+ {"ts": "2024-01-01T00:00:00Z", "iso": "CAISO", "fuel_type": "solar", "mw": 5000.0}
18
+ ]
19
+
20
+
21
+ def _first(result, col: str):
22
+ if _PANDAS:
23
+ return result.iloc[0][col]
24
+ return result[0][col]
25
+
26
+
27
+ def _len(result) -> int:
28
+ return len(result)
29
+
30
+
31
+ def test_fuel_mix(httpx_mock: HTTPXMock):
32
+ httpx_mock.add_response(
33
+ url=f"{BASE}/fuel-mix?iso=CAISO&hours=24&limit=2000",
34
+ json=FUEL_MIX_RESPONSE,
35
+ )
36
+ with Client() as kl:
37
+ result = kl.fuel_mix("CAISO")
38
+ assert _len(result) == 1
39
+ assert _first(result, "iso") == "CAISO"
40
+
41
+
42
+ def test_fuel_mix_with_dates(httpx_mock: HTTPXMock):
43
+ httpx_mock.add_response(
44
+ url=f"{BASE}/fuel-mix?iso=CAISO&start=2024-01-01&end=2024-01-07&hours=24&limit=2000",
45
+ json=FUEL_MIX_RESPONSE,
46
+ )
47
+ with Client() as kl:
48
+ result = kl.fuel_mix(
49
+ "CAISO",
50
+ start=date(2024, 1, 1),
51
+ end=date(2024, 1, 7),
52
+ )
53
+ assert _len(result) == 1
54
+
55
+
56
+ def test_carbon_latest(httpx_mock: HTTPXMock):
57
+ httpx_mock.add_response(
58
+ url=f"{BASE}/carbon/latest",
59
+ json=[{"iso": "CAISO", "ts": "2024-01-01T00:00:00Z", "lbs_co2_per_mwh": 450.0, "total_mw": 30000.0, "pct_clean": 60.0}],
60
+ )
61
+ with Client() as kl:
62
+ result = kl.carbon_latest()
63
+ assert _first(result, "iso") == "CAISO"
64
+
65
+
66
+ def test_iso_uppercased(httpx_mock: HTTPXMock):
67
+ httpx_mock.add_response(
68
+ url=f"{BASE}/fuel-mix?iso=ERCOT&hours=24&limit=2000",
69
+ json=[],
70
+ )
71
+ with Client() as kl:
72
+ kl.fuel_mix("ercot")