greenpick 0.1.0__py3-none-any.whl

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.
Files changed (42) hide show
  1. greenpick/__init__.py +7 -0
  2. greenpick/__main__.py +18 -0
  3. greenpick/config.py +58 -0
  4. greenpick/data/__init__.py +28 -0
  5. greenpick/data/auto_repository.py +47 -0
  6. greenpick/data/eia_repository.py +156 -0
  7. greenpick/data/electricitymaps_repository.py +104 -0
  8. greenpick/data/emission_factors.json +35 -0
  9. greenpick/data/entsoe_repository.py +401 -0
  10. greenpick/data/gpu_power.json +26 -0
  11. greenpick/data/inference_energy.json +50 -0
  12. greenpick/data/instance_families.json +32 -0
  13. greenpick/data/mock_repository.py +53 -0
  14. greenpick/data/network.json +7 -0
  15. greenpick/data/power.json +7 -0
  16. greenpick/data/pricing.json +31 -0
  17. greenpick/data/regions.json +22 -0
  18. greenpick/data/repository.py +27 -0
  19. greenpick/data/static_data.py +105 -0
  20. greenpick/models.py +342 -0
  21. greenpick/server.py +349 -0
  22. greenpick/services/__init__.py +5 -0
  23. greenpick/services/carbon_service.py +28 -0
  24. greenpick/services/cost_service.py +21 -0
  25. greenpick/services/forecast_service.py +72 -0
  26. greenpick/services/inference_service.py +100 -0
  27. greenpick/services/instance_service.py +132 -0
  28. greenpick/services/latency_service.py +33 -0
  29. greenpick/services/locality_service.py +160 -0
  30. greenpick/services/metric_scaffold_service.py +149 -0
  31. greenpick/services/recommender.py +203 -0
  32. greenpick/services/residency_service.py +53 -0
  33. greenpick/services/rightsizing_service.py +93 -0
  34. greenpick/services/scheduling_service.py +211 -0
  35. greenpick/services/serving_service.py +202 -0
  36. greenpick/services/training_service.py +209 -0
  37. greenpick-0.1.0.dist-info/METADATA +271 -0
  38. greenpick-0.1.0.dist-info/RECORD +42 -0
  39. greenpick-0.1.0.dist-info/WHEEL +5 -0
  40. greenpick-0.1.0.dist-info/entry_points.txt +2 -0
  41. greenpick-0.1.0.dist-info/licenses/LICENSE +21 -0
  42. greenpick-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,401 @@
1
+ """ENTSO-E Transparency Platform carbon source (EU zones).
2
+
3
+ Free EU grid data. We query A75 (Actual Generation per Production Type) for a region's
4
+ area (EIC code), take the most recent period's MW per production type, and weight by
5
+ per-fuel emission factors -> directional gCO2/kWh. Mirrors the EIA source for the EU.
6
+
7
+ Token (free): register at https://transparency.entsoe.eu/ then request API access
8
+ (email transparency@entsoe.eu per their docs) -> security token.
9
+ Endpoint: GET https://web-api.tp.entsoe.eu/api
10
+ params: securityToken, documentType=A75, processType=A16 (realised),
11
+ in_Domain=<area EIC>, periodStart/periodEnd = YYYYMMDDHHMM (UTC)
12
+ Response: XML with <TimeSeries> per production type:
13
+ <MktPSRType><psrType>B04</psrType></MktPSRType> and <Period><Point><quantity> MW.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ import re
19
+ import time
20
+ from datetime import datetime, timedelta, timezone
21
+ from xml.etree import ElementTree as ET
22
+
23
+ import httpx
24
+
25
+ from ..models import CarbonReading, Provider, HourlyCarbon, ForecastHour
26
+ from .mock_repository import MockCarbonRepository
27
+ from .static_data import region_meta, emission_factors
28
+
29
+ log = logging.getLogger("greenpick.entsoe")
30
+
31
+ _URL = "https://web-api.tp.entsoe.eu/api"
32
+
33
+
34
+ def _strip_ns(tag: str) -> str:
35
+ """ENTSO-E XML is namespaced; strip '{ns}tag' -> 'tag' for simple matching."""
36
+ return tag.rsplit("}", 1)[-1]
37
+
38
+
39
+ def _parse_interval_start(text: str) -> datetime | None:
40
+ """ENTSO-E timestamps look like '2026-10-11T18:00Z' or '2026-10-11T18:00:00Z'.
41
+ Parse to an aware UTC datetime; return None if unrecognisable."""
42
+ if not text:
43
+ return None
44
+ t = text.strip().replace("Z", "+00:00")
45
+ try:
46
+ dt = datetime.fromisoformat(t)
47
+ except ValueError:
48
+ return None
49
+ return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
50
+
51
+
52
+ def _resolution_minutes(text: str) -> int:
53
+ """Parse an ISO-8601 duration like 'PT60M' / 'PT15M' / 'PT1H' -> minutes.
54
+ Defaults to 60 if absent/unrecognised (A75 realised data is typically hourly)."""
55
+ if not text:
56
+ return 60
57
+ m = re.match(r"PT(?:(\d+)H)?(?:(\d+)M)?", text.strip())
58
+ if not m:
59
+ return 60
60
+ hours = int(m.group(1) or 0)
61
+ mins = int(m.group(2) or 0)
62
+ total = hours * 60 + mins
63
+ return total or 60
64
+
65
+
66
+ class ENTSOERepository:
67
+ def __init__(self, token: str, cache_ttl_seconds: int = 1800) -> None:
68
+ self._token = token
69
+ self._ttl = cache_ttl_seconds
70
+ self._cache: dict[str, tuple[float, CarbonReading]] = {} # EIC -> (fetched_at, reading)
71
+ self._fallback = MockCarbonRepository()
72
+ self._warned_no_token = False
73
+
74
+ async def carbon_now(self, provider: Provider, region: str) -> CarbonReading:
75
+ meta = region_meta(provider, region) or {}
76
+ eic = meta.get("entsoe_eic")
77
+
78
+ if not self._token:
79
+ if not self._warned_no_token:
80
+ log.warning("No ENTSOE_TOKEN set — falling back to mock. Register at "
81
+ "https://transparency.entsoe.eu/ and request API access.")
82
+ self._warned_no_token = True
83
+ return await self._fallback.carbon_now(provider, region)
84
+ if not eic:
85
+ # Non-EU region (no ENTSO-E area) -> mock, labelled.
86
+ return await self._fallback.carbon_now(provider, region)
87
+
88
+ hit = self._cache.get(eic)
89
+ if hit and (time.time() - hit[0]) < self._ttl:
90
+ cached = hit[1]
91
+ return cached.model_copy(update={"region": region, "provider": provider, "quality": "cached"})
92
+
93
+ xml_text = await self._fetch_xml(eic, hours_back=3)
94
+ if xml_text is None:
95
+ return await self._fallback.carbon_now(provider, region)
96
+ reading = self._xml_to_reading(provider, region, xml_text)
97
+ if reading is None:
98
+ return await self._fallback.carbon_now(provider, region)
99
+ self._cache[eic] = (time.time(), reading)
100
+ return reading
101
+
102
+ async def _fetch_xml(self, eic: str, hours_back: int) -> str | None:
103
+ """Fetch A75 realised-generation XML for an area over the last `hours_back` hours.
104
+ Retries transient empties/errors; returns the body text, or None on auth failure or
105
+ exhausted retries (callers fall back to mock). Shared by carbon_now and carbon_history."""
106
+ now = datetime.now(timezone.utc)
107
+ start = now - timedelta(hours=hours_back)
108
+ params = {
109
+ "securityToken": self._token,
110
+ "documentType": "A75",
111
+ "processType": "A16",
112
+ "in_Domain": eic,
113
+ "periodStart": start.strftime("%Y%m%d%H%M"),
114
+ "periodEnd": now.strftime("%Y%m%d%H%M"),
115
+ }
116
+
117
+ # ENTSO-E is occasionally slow / empty for a given zone (their docs warn about response
118
+ # times). Retry a couple of times before degrading to mock, since a transient miss would
119
+ # otherwise show a region as mock for the whole cache window.
120
+ import asyncio
121
+ attempts = 3
122
+ for attempt in range(1, attempts + 1):
123
+ try:
124
+ async with httpx.AsyncClient(timeout=15) as client:
125
+ resp = await client.get(_URL, params=params)
126
+ if resp.status_code in (401, 403):
127
+ log.warning("ENTSO-E rejected the token (HTTP %s) — using mock. Check ENTSOE_TOKEN.",
128
+ resp.status_code)
129
+ return None # auth: don't retry
130
+ resp.raise_for_status()
131
+ if resp.text and "<TimeSeries" in resp.text:
132
+ return resp.text
133
+ # 200 but no usable generation (transient empty) -> retry
134
+ log.debug("ENTSO-E empty for %s (attempt %d/%d)", eic, attempt, attempts)
135
+ except Exception as e:
136
+ log.debug("ENTSO-E call failed for %s (attempt %d/%d): %s", eic, attempt, attempts, e)
137
+ if attempt < attempts:
138
+ await asyncio.sleep(0.6 * attempt) # brief backoff: 0.6s, 1.2s
139
+ return None
140
+
141
+ async def _fetch_forecast_xml(self, eic: str, document_type: str, hours_ahead: int,
142
+ load: bool = False) -> str | None:
143
+ """Fetch a day-ahead FORWARD forecast document (periodStart=now). document_type is e.g.
144
+ 'A69' (wind+solar) or 'A65' (load). Load uses outBiddingZone_Domain, generation uses
145
+ in_Domain. Returns body text or None (auth fail / exhausted retries / empty)."""
146
+ now = datetime.now(timezone.utc)
147
+ end = now + timedelta(hours=hours_ahead)
148
+ params = {
149
+ "securityToken": self._token,
150
+ "documentType": document_type,
151
+ "processType": "A01", # day-ahead
152
+ "periodStart": now.strftime("%Y%m%d%H%M"),
153
+ "periodEnd": end.strftime("%Y%m%d%H%M"),
154
+ }
155
+ params["outBiddingZone_Domain" if load else "in_Domain"] = eic
156
+
157
+ import asyncio
158
+ attempts = 3
159
+ for attempt in range(1, attempts + 1):
160
+ try:
161
+ async with httpx.AsyncClient(timeout=15) as client:
162
+ resp = await client.get(_URL, params=params)
163
+ if resp.status_code in (401, 403):
164
+ log.warning("ENTSO-E rejected the token (HTTP %s) on forecast — using fallback.",
165
+ resp.status_code)
166
+ return None
167
+ resp.raise_for_status()
168
+ if resp.text and "<TimeSeries" in resp.text:
169
+ return resp.text
170
+ # 'No matching data' (common for A65 in some zones) is not retryable — bail quietly.
171
+ if "No matching data" in resp.text:
172
+ log.debug("ENTSO-E %s: no matching data for %s", document_type, eic)
173
+ return None
174
+ log.debug("ENTSO-E %s empty for %s (attempt %d/%d)", document_type, eic, attempt, attempts)
175
+ except Exception as e:
176
+ log.debug("ENTSO-E %s failed for %s (attempt %d/%d): %s",
177
+ document_type, eic, attempt, attempts, e)
178
+ if attempt < attempts:
179
+ await asyncio.sleep(0.6 * attempt)
180
+ return None
181
+
182
+ async def renewable_forecast(self, provider: Provider, region: str, hours_ahead: int = 24
183
+ ) -> list[ForecastHour]:
184
+ """Forward-looking renewable (wind+solar) output forecast per hour, for 'which upcoming
185
+ hours are cleaner' scheduling. Uses A69 (reliable across zones); where A65 load is also
186
+ available, adds a renewable-share ratio. Returns [] if no token/EIC/forecast (caller
187
+ falls back to the historical pattern). RELATIVE signal, not a gCO2/kWh forecast."""
188
+ meta = region_meta(provider, region) or {}
189
+ eic = meta.get("entsoe_eic")
190
+ if not self._token or not eic:
191
+ return []
192
+
193
+ a69 = await self._fetch_forecast_xml(eic, "A69", hours_ahead, load=False)
194
+ if a69 is None:
195
+ return []
196
+ renew_by_hour = self._forecast_mw_by_hour(a69) # sums wind+solar across its TimeSeries
197
+ if not renew_by_hour:
198
+ return []
199
+
200
+ # Optional refinement: load forecast (A65) -> renewable share. Best-effort; many zones 404.
201
+ load_by_hour: dict[str, float] = {}
202
+ a65 = await self._fetch_forecast_xml(eic, "A65", hours_ahead, load=True)
203
+ if a65 is not None:
204
+ load_by_hour = self._forecast_mw_by_hour(a65)
205
+
206
+ out: list[ForecastHour] = []
207
+ for hour_iso in sorted(renew_by_hour):
208
+ mw = round(renew_by_hour[hour_iso], 1)
209
+ share = None
210
+ load = load_by_hour.get(hour_iso)
211
+ if load and load > 0:
212
+ share = round(min(1.0, mw / load), 3)
213
+ out.append(ForecastHour(period=hour_iso, renewable_mw=mw, renewable_share=share))
214
+ return out
215
+
216
+ def _forecast_mw_by_hour(self, xml_text: str) -> dict[str, float]:
217
+ """Parse a forecast document (A69/A65) into {hour_iso -> summed MW}. Sums across all
218
+ TimeSeries (e.g. wind + solar), mapping each Point's position to its hour via the period
219
+ start + resolution. Sub-hourly points are summed into their hour (then we'd double-count,
220
+ so we AVERAGE within an hour per TimeSeries instead — see below)."""
221
+ try:
222
+ root = ET.fromstring(xml_text)
223
+ except ET.ParseError:
224
+ return {}
225
+
226
+ totals: dict[str, float] = {}
227
+ for ts in root.iter():
228
+ if _strip_ns(ts.tag) != "TimeSeries":
229
+ continue
230
+ for el in ts.iter():
231
+ if _strip_ns(el.tag) != "Period":
232
+ continue
233
+ start_dt = None
234
+ resolution_min = 60
235
+ for c in el.iter():
236
+ ct = _strip_ns(c.tag)
237
+ if ct == "start" and c.text and start_dt is None:
238
+ start_dt = _parse_interval_start(c.text)
239
+ elif ct == "resolution" and c.text:
240
+ resolution_min = _resolution_minutes(c.text)
241
+ if start_dt is None:
242
+ continue
243
+ # Collect per-hour: average sub-hourly points within an hour (MW is a rate, not a
244
+ # sum over sub-intervals), then add this TimeSeries' hourly value to the total.
245
+ hour_vals: dict[str, list[float]] = {}
246
+ for pt in el.iter():
247
+ if _strip_ns(pt.tag) != "Point":
248
+ continue
249
+ pos = qty = None
250
+ for c in pt:
251
+ ct = _strip_ns(c.tag)
252
+ if ct == "position" and c.text:
253
+ pos = int(c.text)
254
+ elif ct == "quantity" and c.text:
255
+ qty = float(c.text)
256
+ if pos is None or qty is None:
257
+ continue
258
+ point_dt = start_dt + timedelta(minutes=(pos - 1) * resolution_min)
259
+ hour_iso = point_dt.strftime("%Y-%m-%dT%H")
260
+ hour_vals.setdefault(hour_iso, []).append(qty)
261
+ for hour_iso, vals in hour_vals.items():
262
+ totals[hour_iso] = totals.get(hour_iso, 0.0) + sum(vals) / len(vals)
263
+ return totals
264
+
265
+ async def carbon_history(self, provider: Provider, region: str) -> list[HourlyCarbon]:
266
+ """Recent hourly carbon intensity for an EU zone, for scheduling deferrable work.
267
+
268
+ Parses A75 realised generation across the whole 24h window (every Point position,
269
+ not just the latest), aligns all production types onto their hour, and weights each
270
+ hour's fuel mix by emission factors. Falls back to the synthetic mock curve only when
271
+ there's no token, no EIC, or the fetch/parse yields nothing usable."""
272
+ meta = region_meta(provider, region) or {}
273
+ eic = meta.get("entsoe_eic")
274
+ if not self._token or not eic:
275
+ return await self._fallback.carbon_history(provider, region)
276
+
277
+ xml_text = await self._fetch_xml(eic, hours_back=24)
278
+ if xml_text is None:
279
+ return await self._fallback.carbon_history(provider, region)
280
+
281
+ series = self._xml_to_hourly(xml_text)
282
+ return series or await self._fallback.carbon_history(provider, region)
283
+
284
+ def _xml_to_hourly(self, xml_text: str) -> list[HourlyCarbon]:
285
+ """Parse A75 XML into an hourly carbon series.
286
+
287
+ For each TimeSeries (one production type) we walk every Point, map its position to a
288
+ timestamp (period start + (position-1) * resolution), and accumulate MW per fuel into
289
+ the Point's hour bucket. Then per hour: weighted mean of fuels -> gCO2/kWh.
290
+ Sub-hourly resolutions (e.g. PT15M) are summed into their containing hour."""
291
+ factor_map = emission_factors()["entsoe_psrtype_to_factor"]
292
+ try:
293
+ root = ET.fromstring(xml_text)
294
+ except ET.ParseError:
295
+ return []
296
+
297
+ # hour_iso -> {psrType -> summed MW} (MW summed across sub-hour points & timeseries chunks)
298
+ by_hour: dict[str, dict[str, float]] = {}
299
+
300
+ for ts in root.iter():
301
+ if _strip_ns(ts.tag) != "TimeSeries":
302
+ continue
303
+ psr = None
304
+ for el in ts.iter():
305
+ tag = _strip_ns(el.tag)
306
+ if tag == "psrType" and el.text:
307
+ psr = el.text.strip()
308
+ elif tag == "Period":
309
+ start_dt = None
310
+ resolution_min = 60
311
+ for c in el.iter():
312
+ ct = _strip_ns(c.tag)
313
+ if ct == "start" and c.text and start_dt is None:
314
+ start_dt = _parse_interval_start(c.text)
315
+ elif ct == "resolution" and c.text:
316
+ resolution_min = _resolution_minutes(c.text)
317
+ if start_dt is None or psr is None or psr not in factor_map:
318
+ continue
319
+ for pt in el.iter():
320
+ if _strip_ns(pt.tag) != "Point":
321
+ continue
322
+ pos = qty = None
323
+ for c in pt:
324
+ ct = _strip_ns(c.tag)
325
+ if ct == "position" and c.text:
326
+ pos = int(c.text)
327
+ elif ct == "quantity" and c.text:
328
+ qty = float(c.text)
329
+ if pos is None or qty is None:
330
+ continue
331
+ point_dt = start_dt + timedelta(minutes=(pos - 1) * resolution_min)
332
+ hour_iso = point_dt.strftime("%Y-%m-%dT%H")
333
+ bucket = by_hour.setdefault(hour_iso, {})
334
+ bucket[psr] = bucket.get(psr, 0.0) + qty
335
+
336
+ out: list[HourlyCarbon] = []
337
+ for hour_iso in sorted(by_hour):
338
+ fuels = by_hour[hour_iso]
339
+ total = sum(fuels.values())
340
+ if total <= 0:
341
+ continue
342
+ weighted = sum(mw * factor_map[f] for f, mw in fuels.items())
343
+ out.append(HourlyCarbon(period=hour_iso, g_co2_per_kwh=round(weighted / total, 1)))
344
+ return out
345
+
346
+ def _xml_to_reading(self, provider: Provider, region: str, xml_text: str) -> CarbonReading | None:
347
+ """Parse A75 XML: for each TimeSeries (one production type), take its latest
348
+ Point quantity; weight all types by emission factors -> gCO2/kWh."""
349
+ factor_map = emission_factors()["entsoe_psrtype_to_factor"]
350
+ try:
351
+ root = ET.fromstring(xml_text)
352
+ except ET.ParseError:
353
+ return None
354
+
355
+ gen_by_fuel: dict[str, float] = {} # psrType -> latest MW
356
+ latest_ts = ""
357
+
358
+ for ts in root.iter():
359
+ if _strip_ns(ts.tag) != "TimeSeries":
360
+ continue
361
+ psr = None
362
+ latest_qty = None
363
+ latest_pos_time = ""
364
+ for el in ts.iter():
365
+ tag = _strip_ns(el.tag)
366
+ if tag == "psrType" and el.text:
367
+ psr = el.text.strip()
368
+ elif tag == "Period":
369
+ # find the newest Point in this period by its 'position'
370
+ start_el = next((c for c in el.iter() if _strip_ns(c.tag) == "start"), None)
371
+ pstart = start_el.text if start_el is not None else ""
372
+ best_pos = -1
373
+ for pt in el.iter():
374
+ if _strip_ns(pt.tag) != "Point":
375
+ continue
376
+ pos = qty = None
377
+ for c in pt:
378
+ ct = _strip_ns(c.tag)
379
+ if ct == "position" and c.text:
380
+ pos = int(c.text)
381
+ elif ct == "quantity" and c.text:
382
+ qty = float(c.text)
383
+ if pos is not None and qty is not None and pos > best_pos:
384
+ best_pos, latest_qty, latest_pos_time = pos, qty, pstart or ""
385
+ if psr and latest_qty is not None and psr in factor_map:
386
+ # Keep the max across time series chunks (latest known MW for that fuel).
387
+ gen_by_fuel[psr] = max(gen_by_fuel.get(psr, 0.0), latest_qty)
388
+ latest_ts = latest_ts or latest_pos_time
389
+
390
+ total = sum(gen_by_fuel.values())
391
+ if total <= 0:
392
+ return None
393
+ weighted = sum(mw * factor_map[f] for f, mw in gen_by_fuel.items())
394
+ return CarbonReading(
395
+ region=region,
396
+ provider=provider,
397
+ g_co2_per_kwh=round(weighted / total, 1),
398
+ source="entsoe",
399
+ timestamp=latest_ts or datetime.now(timezone.utc).isoformat(),
400
+ quality="live",
401
+ )
@@ -0,0 +1,26 @@
1
+ {
2
+ "_comment": "Directional power model for datacenter GPU/accelerator training, used to estimate the energy (kWh) of a training run so it can be placed in a greener region and/or timed to the cleanest hours. tdp_watts are PUBLISHED peak board power (TDP) from NVIDIA datasheets and corroborating sources; a training run does NOT sit at TDP the whole time, so energy = tdp_watts * gpu_count * default_utilization * hours, plus a per-GPU host overhead, all x datacenter PUE at calc time. ORDER-OF-MAGNITUDE, directional estimates. NOT measured for your model/run. Replace with measured power telemetry before any non-directional use.",
3
+ "_as_of": "2026-10-12",
4
+ "_sources": "NVIDIA datasheets/product pages (A100 400W SXM; H100 700W SXM / 350W PCIe; H200 700W; T4 70W; L4 72W; V100 300W SXM; L40S 350W; B200 ~1000W; GB200 superchip ~1200W). Corroborated across NVIDIA, thundercompute, cyfuture, semianalysis, gmicloud (2024-2026). Values rounded.",
5
+ "_method": {
6
+ "formula": "energy_kwh = (sum over GPUs of [tdp_watts * utilization + host_overhead_watts_per_gpu]) / 1000 * gpu_hours_per_gpu * pue",
7
+ "utilization_note": "TDP is PEAK board power. Sustained training typically averages below TDP; default_utilization (0.85) is a documented directional assumption, overridable per call.",
8
+ "host_overhead_note": "Each accelerator is fed by host CPU/RAM/NIC/storage; host_overhead_watts_per_gpu is a rough per-GPU allocation of that non-GPU server draw. Directional.",
9
+ "pue_note": "Datacenter overhead multiplier taken from power.json pue_default at calculation time (not duplicated here)."
10
+ },
11
+ "default_utilization": 0.85,
12
+ "host_overhead_watts_per_gpu": 100,
13
+ "gpus": {
14
+ "t4": { "name": "NVIDIA T4", "arch": "Turing", "tdp_watts": 70, "class": "inference", "aliases": ["tesla-t4"] },
15
+ "l4": { "name": "NVIDIA L4", "arch": "Ada Lovelace", "tdp_watts": 72, "class": "inference", "aliases": [] },
16
+ "v100": { "name": "NVIDIA V100 (SXM)", "arch": "Volta", "tdp_watts": 300, "class": "training", "aliases": ["tesla-v100"] },
17
+ "l40s": { "name": "NVIDIA L40S", "arch": "Ada Lovelace", "tdp_watts": 350, "class": "training/graphics", "aliases": ["l40"] },
18
+ "a100": { "name": "NVIDIA A100 (SXM 80GB)", "arch": "Ampere", "tdp_watts": 400, "class": "training", "aliases": ["a100-80gb", "a100-sxm"] },
19
+ "a100-pcie": { "name": "NVIDIA A100 (PCIe)", "arch": "Ampere", "tdp_watts": 300, "class": "training", "aliases": [] },
20
+ "h100": { "name": "NVIDIA H100 (SXM)", "arch": "Hopper", "tdp_watts": 700, "class": "training", "aliases": ["h100-sxm"] },
21
+ "h100-pcie": { "name": "NVIDIA H100 (PCIe)", "arch": "Hopper", "tdp_watts": 350, "class": "training", "aliases": [] },
22
+ "h200": { "name": "NVIDIA H200", "arch": "Hopper", "tdp_watts": 700, "class": "training", "aliases": [] },
23
+ "b200": { "name": "NVIDIA B200", "arch": "Blackwell", "tdp_watts": 1000, "class": "training", "aliases": [] },
24
+ "gb200": { "name": "NVIDIA GB200 Superchip", "arch": "Blackwell", "tdp_watts": 1200, "class": "training", "aliases": ["gb200-superchip"], "_note": "Two Blackwell GPUs + Grace CPU; TDP is the combined superchip." }
25
+ }
26
+ }
@@ -0,0 +1,50 @@
1
+ {
2
+ "_comment": "Directional energy intensity for LLM INFERENCE, by model SIZE CLASS, in Wh per 1,000 OUTPUT tokens. Output tokens dominate inference energy (generation is the costly part; R^2>0.95 vs output length in measurement studies). These are LOW-HIGH bands, not point values: serving choices (quantisation, batching, request shaping) move energy by up to ~100x for the SAME model, so a single number would be false precision. Keyed on size class, NOT named commercial products (vendors don't disclose serving hardware/batching). Estimate, not a measurement; for self-hosted/open models the band is most trustworthy. Replace with your own measured J/token before any non-directional use.",
3
+ "_as_of": "2026-10-16",
4
+ "_unit": "Wh per 1000 output tokens",
5
+ "_sources": "Directional, synthesised from peer-reviewed inference-energy measurement studies (2024-2026): arXiv 2509.20241 (median 0.34 Wh/query, IQR 0.18-0.67, for >200B models on an H100 node w/ realistic utilisation+PUE); arXiv 2505.09598 (~0.42 Wh short GPT-4o query); arXiv 2407.16893 (70B ~2 orders of magnitude more energy/token than small models; output length predicts energy R^2>0.95); arXiv 2601.22362 (quantisation/batching/shaping => up to ~100x spread, same model). Bands rounded by GreenPick. Assume a ~300-output-token 'typical query' when converting to per-query.",
6
+ "_notes": {
7
+ "output_tokens_dominate": "Energy tracks OUTPUT (generated) tokens + response time far more than input tokens. The tool keys on output tokens; if only total tokens are known, assume ~25-40% are output or flag the assumption.",
8
+ "moe": "Mixture-of-Experts models run near their ACTIVE parameter count, not total — a '~400B MoE' behaves more like a mid/large dense model, NOT like dense-400B. Use the 'moe_frontier' class, not 'frontier_dense', for MoE.",
9
+ "serving_variance": "quantisation (int8/int4), batching, and request shaping can cut energy many-fold; a naive un-batched fp16 deployment sits near the HIGH end. Hence bands, not points."
10
+ },
11
+ "size_classes": {
12
+ "small": {
13
+ "label": "small (~1-7B params)",
14
+ "wh_per_1k_output_tokens_low": 0.15,
15
+ "wh_per_1k_output_tokens_high": 0.9,
16
+ "example_models": "Llama-3-8B, Mistral-7B, Phi-3"
17
+ },
18
+ "mid": {
19
+ "label": "mid (~8-34B params)",
20
+ "wh_per_1k_output_tokens_low": 0.6,
21
+ "wh_per_1k_output_tokens_high": 3.5,
22
+ "example_models": "Llama-3-13B, Mixtral-ish dense, Qwen-32B"
23
+ },
24
+ "large": {
25
+ "label": "large (~35-120B dense)",
26
+ "wh_per_1k_output_tokens_low": 2.5,
27
+ "wh_per_1k_output_tokens_high": 12.0,
28
+ "example_models": "Llama-3-70B, dense ~70B"
29
+ },
30
+ "frontier_dense": {
31
+ "label": "frontier dense (>200B)",
32
+ "wh_per_1k_output_tokens_low": 8.0,
33
+ "wh_per_1k_output_tokens_high": 30.0,
34
+ "example_models": "dense >200B (research-scale); ~0.34 Wh median per typical query"
35
+ },
36
+ "moe_frontier": {
37
+ "label": "frontier MoE (large total, smaller active)",
38
+ "wh_per_1k_output_tokens_low": 3.0,
39
+ "wh_per_1k_output_tokens_high": 15.0,
40
+ "example_models": "Mixture-of-Experts frontier models (active params << total)"
41
+ }
42
+ },
43
+ "_aliases": {
44
+ "1b": "small", "3b": "small", "7b": "small", "8b": "small",
45
+ "13b": "mid", "14b": "mid", "32b": "mid", "34b": "mid",
46
+ "70b": "large", "65b": "large",
47
+ "175b": "frontier_dense", "200b": "frontier_dense", "400b": "frontier_dense",
48
+ "moe": "moe_frontier", "mixtral": "moe_frontier"
49
+ }
50
+ }
@@ -0,0 +1,32 @@
1
+ {
2
+ "_comment": "Directional energy-efficiency (performance-per-watt) multipliers for general-purpose instance FAMILIES, used to suggest a greener same-size family in the same region. The carbon mechanism is real: newer process nodes and ARM (Graviton-class) cores do the same work on less energy. 'relative_energy' is the energy drawn to deliver a fixed amount of work, normalised so a modern x86 generation = 1.00; LOWER is greener. These are ORDER-OF-MAGNITUDE, directional figures consistent with public vendor/analyst claims (e.g. AWS Graviton ~60% less energy for the same performance; each x86 generation ~10-20% better perf/watt). NOT measured for your workload. Replace with cited, benchmarked values before any non-directional use.",
3
+ "_as_of": "2026-10-12",
4
+ "_source": "Directional: AWS Graviton energy claims; Intel/AMD generational perf-per-watt disclosures; SPECpower-style public results. Normalised and rounded by GreenPick. Do not cite as measured.",
5
+ "_relative_energy_note": "relative_energy LOWER = greener (less energy for the same work). arch 'arm' families are the biggest honest win.",
6
+ "aws": {
7
+ "_general_purpose_generations": [
8
+ { "family": "m5", "arch": "x86", "cpu": "Intel Xeon (Skylake/Cascade)", "relative_energy": 1.00, "generation": 5, "notes": "Baseline modern x86 general-purpose." },
9
+ { "family": "m6i", "arch": "x86", "cpu": "Intel Xeon (Ice Lake)", "relative_energy": 0.88, "generation": 6, "notes": "~10-15% better perf/watt than m5." },
10
+ { "family": "m7i", "arch": "x86", "cpu": "Intel Xeon (Sapphire Rapids)", "relative_energy": 0.80, "generation": 7, "notes": "Newer x86; further perf/watt gain." },
11
+ { "family": "m6g", "arch": "arm", "cpu": "AWS Graviton2", "relative_energy": 0.55, "generation": 6, "notes": "ARM; AWS claims large energy reduction for same work." },
12
+ { "family": "m7g", "arch": "arm", "cpu": "AWS Graviton3", "relative_energy": 0.45, "generation": 7, "notes": "Greenest general-purpose option where ARM is viable." }
13
+ ]
14
+ },
15
+ "gcp": {
16
+ "_general_purpose_generations": [
17
+ { "family": "n1", "arch": "x86", "cpu": "Intel (Skylake/Broadwell)", "relative_energy": 1.08, "generation": 1, "notes": "Older generation; least efficient." },
18
+ { "family": "n2", "arch": "x86", "cpu": "Intel Cascade/Ice Lake", "relative_energy": 1.00, "generation": 2, "notes": "Baseline modern x86." },
19
+ { "family": "n2d", "arch": "x86", "cpu": "AMD EPYC (Milan)", "relative_energy": 0.90, "generation": 2, "notes": "AMD; typically better perf/watt than n2." },
20
+ { "family": "c3", "arch": "x86", "cpu": "Intel Sapphire Rapids", "relative_energy": 0.80, "generation": 3, "notes": "Newer x86." },
21
+ { "family": "c4a", "arch": "arm", "cpu": "Google Axion", "relative_energy": 0.47, "generation": 4, "notes": "ARM (Axion); greenest where ARM is viable." }
22
+ ]
23
+ },
24
+ "azure": {
25
+ "_general_purpose_generations": [
26
+ { "family": "Dv4", "arch": "x86", "cpu": "Intel Cascade Lake", "relative_energy": 1.05, "generation": 4, "notes": "Older modern x86." },
27
+ { "family": "Dv5", "arch": "x86", "cpu": "Intel Ice Lake", "relative_energy": 1.00, "generation": 5, "notes": "Baseline modern x86." },
28
+ { "family": "Dasv5","arch": "x86", "cpu": "AMD EPYC (Milan)", "relative_energy": 0.90, "generation": 5, "notes": "AMD; better perf/watt than Dv5." },
29
+ { "family": "Dpsv5","arch": "arm", "cpu": "Ampere Altra", "relative_energy": 0.52, "generation": 5, "notes": "ARM (Ampere); large energy reduction for same work." }
30
+ ]
31
+ }
32
+ }
@@ -0,0 +1,53 @@
1
+ """Mock carbon source — deterministic, offline, the default.
2
+
3
+ Lets the whole server run and be tested without any API token or network. Values are
4
+ plausible, stable per region, and clearly labelled quality="mock" so nothing mistakes
5
+ them for real readings.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import math
10
+ from datetime import datetime, timezone
11
+ from ..models import CarbonReading, Provider, HourlyCarbon
12
+
13
+ # Rough, stable gCO2/kWh per approximate grid zone (illustrative, NOT real-time).
14
+ _ZONE_INTENSITY = {
15
+ "US-MIDA-PJM": 350,
16
+ "US-NW-PACW": 110,
17
+ "US-MIDW-MISO": 430,
18
+ "IE": 290,
19
+ "SE": 25,
20
+ "FI": 70,
21
+ "AU-NSW": 580,
22
+ }
23
+ _DEFAULT = 400
24
+
25
+
26
+ class MockCarbonRepository:
27
+ async def carbon_now(self, provider: Provider, region: str) -> CarbonReading:
28
+ from .static_data import region_meta
29
+ meta = region_meta(provider, region) or {}
30
+ zone = meta.get("zone", "")
31
+ intensity = _ZONE_INTENSITY.get(zone, _DEFAULT)
32
+ return CarbonReading(
33
+ region=region,
34
+ provider=provider,
35
+ g_co2_per_kwh=float(intensity),
36
+ source="mock",
37
+ timestamp=datetime.now(timezone.utc).isoformat(),
38
+ quality="mock",
39
+ )
40
+
41
+ async def carbon_history(self, provider: Provider, region: str) -> list[HourlyCarbon]:
42
+ """Synthetic 24h curve around the region's base intensity: cleaner overnight / midday
43
+ (solar), dirtier at evening peak — a plausible shape so scheduling logic is testable offline."""
44
+ from .static_data import region_meta
45
+ meta = region_meta(provider, region) or {}
46
+ base = _ZONE_INTENSITY.get(meta.get("zone", ""), _DEFAULT)
47
+ out: list[HourlyCarbon] = []
48
+ for h in range(24):
49
+ # +/-30% swing: trough ~04:00 and ~13:00, peak ~19:00.
50
+ factor = 1 + 0.3 * math.sin((h - 9) / 24 * 2 * math.pi) \
51
+ - 0.15 * math.cos((h - 13) / 24 * 4 * math.pi)
52
+ out.append(HourlyCarbon(period=f"H{h:02d}", g_co2_per_kwh=round(base * factor, 1)))
53
+ return out
@@ -0,0 +1,7 @@
1
+ {
2
+ "_comment": "Directional network data-transfer energy intensity, used ONLY for a secondary 'transfer carbon' signal in the data-locality / egress check. The primary output of that tool is egress COST; this carbon figure is deliberately coarse because the science is unsettled.",
3
+ "_as_of": "2026-10-12",
4
+ "kwh_per_gb_transfer": 0.03,
5
+ "_uncertainty": "WIDE. Published estimates for internet data-transfer energy intensity span ~0.004 to 136 kWh/GB (orders of magnitude). A peer-reviewed 2015 figure was ~0.06 kWh/GB, expected to roughly halve every ~2 years; 0.03 reflects continued efficiency gains. Recent literature argues per-GB intensity is a poor model and that network energy is not directly proportional to data volume. Treat this as order-of-magnitude only.",
6
+ "_sources": "Aslan et al. 2018 (Electricity Intensity of Internet Data Transmission, ~0.06 kWh/GB for 2015); Whole Grain Digital review (range 0.004-136 kWh/GB); Journal of Industrial Ecology 2024 (power-model critique of per-GB intensity). Not a measurement; replace with a cited, workload-specific model before any non-directional use."
7
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "_comment": "Simple, documented power model for the carbon ESTIMATE. Turns a workload (vCPU, RAM) into average watts, then kWh/month. These are deliberately rough published-style coefficients; this is an estimate, not a measurement. PUE is the datacenter overhead multiplier.",
3
+ "watts_per_vcpu": 3.5,
4
+ "watts_per_gb_ram": 0.4,
5
+ "pue_default": 1.2,
6
+ "_sources": "Coefficients are order-of-magnitude figures consistent with public cloud power-model literature (e.g. Cloud Carbon Footprint methodology). Replace with cited values before any non-directional use."
7
+ }
@@ -0,0 +1,31 @@
1
+ {
2
+ "_comment": "DATED, STATIC pricing ballpark for v1. Deterministic \u2014 the LLM must never invent these. Figures are rough on-demand approximations for a general-purpose vCPU+RAM service and per-GB internet egress; refresh periodically and update _as_of. NOT a quote.",
3
+ "_as_of": "2026-10-12",
4
+ "_units": {
5
+ "compute": "USD per vCPU-hour and USD per GB-RAM-hour",
6
+ "egress": "USD per GB to internet"
7
+ },
8
+ "_egress_comment": "Egress is NOT one price — it's a tier ladder by WHERE the bytes cross. We price the locality tier (same-region / cross-region / cross-cloud+internet) as a LOW-HIGH RANGE because region-pair rates vary. Per-GB, USD, DIRECTIONAL list prices as of _as_of; excludes free-tier allowances, committed-use/negotiated discounts, and NAT/load-balancer processing fees. Not a quote. Keys: internet rate stays per-provider (differs most); same-region & cross-region tiers are broadly consistent across AWS/GCP/Azure so we use one shared range.",
9
+ "_egress_tiers_usd_per_gb": {
10
+ "same_region": { "low": 0.0, "high": 0.01, "note": "Same region: free within an AZ over private IP; ~$0.01/GB each way if it crosses AZs." },
11
+ "cross_region_same_cloud": { "low": 0.01, "high": 0.02, "note": "Inter-region within one cloud: most pairs ~$0.01-0.02/GB; a few source regions/continent pairs run higher." },
12
+ "cross_cloud": { "low": 0.085, "high": 0.12, "note": "Leaving the cloud to another provider or the public internet: the expensive tier; GCP Premium ~$0.12, AWS ~$0.09, Azure ~$0.087 at entry bands." }
13
+ },
14
+ "_egress_sources": "AWS/GCP/Azure published data-transfer pricing pages + corroborating 2026 guides (usage.ai, nops.io, egresscost.com, redresscompliance, bacancy, tatacommunications). Rounded to ranges; verify against the provider pricing page for a specific region pair.",
15
+ "aws": {
16
+ "compute_usd_per_vcpu_hour": 0.0469,
17
+ "compute_usd_per_gb_ram_hour": 0.0039,
18
+ "egress_usd_per_gb": 0.09
19
+ },
20
+ "gcp": {
21
+ "compute_usd_per_vcpu_hour": 0.0438,
22
+ "compute_usd_per_gb_ram_hour": 0.0037,
23
+ "egress_usd_per_gb": 0.085
24
+ },
25
+ "azure": {
26
+ "compute_usd_per_vcpu_hour": 0.0472,
27
+ "compute_usd_per_gb_ram_hour": 0.0039,
28
+ "egress_usd_per_gb": 0.087
29
+ },
30
+ "_source": "Compute coefficients for [aws+azure+gcp] derived from Vantage live data using ONE matched method: general-purpose 4GB/vCPU families (AWS m5/m6i, GCP n2-standard, Azure D-series v5), on-demand Linux, mean $/vCPU across regions, 25% split to RAM. Egress remains static. Apples-to-apples DIRECTIONAL comparison, not a quote."
31
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "_comment": "Minimal v1 region catalog: coordinates (for latency estimate) and an approximate grid-zone key (for carbon lookup). Zone mapping is country/zone-approximate — a known v1 limitation. Expand as needed.",
3
+ "_jurisdiction_comment": "'jurisdiction' is a STABLE geographic tag (EEA / US / AU / ...), used ONLY to flag when a recommended move crosses a data-residency boundary (esp. leaving the EEA). It is NOT a compliance verdict and encodes NO legal/adequacy status (those change — e.g. EU adequacy lists and the US framework shift over time). GreenPick flags the crossing; the user confirms legality with their own data governance. EEA = 27 EU states + Norway/Iceland/Liechtenstein (the GDPR transfer boundary).",
4
+ "aws": {
5
+ "us-east-1": { "name": "N. Virginia", "lat": 38.95, "lon": -77.45, "zone": "US-MIDA-PJM", "eia_ba": "PJM", "jurisdiction": "US" },
6
+ "us-west-2": { "name": "Oregon", "lat": 45.87, "lon": -119.69, "zone": "US-NW-PACW", "eia_ba": "BPAT", "jurisdiction": "US" },
7
+ "eu-west-1": { "name": "Ireland", "lat": 53.41, "lon": -8.24, "zone": "IE", "entsoe_eic": "10Y1001A1001A59C", "jurisdiction": "EEA" },
8
+ "eu-north-1": { "name": "Stockholm", "lat": 59.33, "lon": 18.07, "zone": "SE", "entsoe_eic": "10YSE-1--------K", "jurisdiction": "EEA" },
9
+ "ap-southeast-2": { "name": "Sydney", "lat": -33.87, "lon": 151.21, "zone": "AU-NSW", "jurisdiction": "AU" },
10
+ "us-east-nyc": { "name": "New York (NYISO)", "lat": 40.71, "lon": -74.01, "zone": "US-NY-NYIS", "eia_ba": "NYIS", "jurisdiction": "US" }
11
+ },
12
+ "gcp": {
13
+ "us-central1": { "name": "Iowa", "lat": 41.26, "lon": -95.86, "zone": "US-MIDW-MISO", "eia_ba": "MISO", "jurisdiction": "US" },
14
+ "europe-north1": { "name": "Finland", "lat": 60.57, "lon": 27.19, "zone": "FI", "entsoe_eic": "10YFI-1--------U", "jurisdiction": "EEA" },
15
+ "us-west1": { "name": "Oregon", "lat": 45.60, "lon": -121.18, "zone": "US-NW-PACW", "eia_ba": "BPAT", "jurisdiction": "US" }
16
+ },
17
+ "azure": {
18
+ "eastus": { "name": "Virginia", "lat": 37.37, "lon": -79.82, "zone": "US-MIDA-PJM", "eia_ba": "PJM", "jurisdiction": "US" },
19
+ "northeurope": { "name": "Ireland", "lat": 53.35, "lon": -6.26, "zone": "IE", "entsoe_eic": "10Y1001A1001A59C", "jurisdiction": "EEA" },
20
+ "swedencentral": { "name": "Sweden", "lat": 60.67, "lon": 17.14, "zone": "SE", "entsoe_eic": "10YSE-1--------K", "jurisdiction": "EEA" }
21
+ }
22
+ }