greenpick 0.2.0__tar.gz → 0.3.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.
- {greenpick-0.2.0/src/greenpick.egg-info → greenpick-0.3.0}/PKG-INFO +8 -3
- {greenpick-0.2.0 → greenpick-0.3.0}/README.md +7 -2
- {greenpick-0.2.0 → greenpick-0.3.0}/pyproject.toml +1 -1
- {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/__init__.py +1 -1
- {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/data/auto_repository.py +17 -14
- greenpick-0.3.0/src/greenpick/data/canada_repository.py +205 -0
- {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/data/emission_factors.json +5 -0
- {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/data/mock_repository.py +2 -0
- {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/data/regions.json +8 -3
- {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/data/typical.json +2764 -924
- {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/data/typical_repository.py +50 -0
- {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/server.py +12 -3
- {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/services/data_hint.py +6 -2
- {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/services/residency_service.py +1 -0
- {greenpick-0.2.0 → greenpick-0.3.0/src/greenpick.egg-info}/PKG-INFO +8 -3
- {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick.egg-info/SOURCES.txt +1 -0
- {greenpick-0.2.0 → greenpick-0.3.0}/tests/test_services.py +180 -3
- {greenpick-0.2.0 → greenpick-0.3.0}/LICENSE +0 -0
- {greenpick-0.2.0 → greenpick-0.3.0}/setup.cfg +0 -0
- {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/__main__.py +0 -0
- {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/config.py +0 -0
- {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/data/__init__.py +0 -0
- {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/data/_net.py +0 -0
- {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/data/eia_repository.py +0 -0
- {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/data/electricitymaps_repository.py +0 -0
- {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/data/entsoe_repository.py +0 -0
- {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/data/gpu_power.json +0 -0
- {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/data/inference_energy.json +0 -0
- {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/data/instance_families.json +0 -0
- {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/data/network.json +0 -0
- {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/data/power.json +0 -0
- {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/data/pricing.json +0 -0
- {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/data/repository.py +0 -0
- {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/data/static_data.py +0 -0
- {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/models.py +0 -0
- {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/services/__init__.py +0 -0
- {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/services/carbon_service.py +0 -0
- {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/services/cost_service.py +0 -0
- {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/services/forecast_service.py +0 -0
- {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/services/inference_service.py +0 -0
- {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/services/instance_service.py +0 -0
- {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/services/latency_service.py +0 -0
- {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/services/locality_service.py +0 -0
- {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/services/metric_scaffold_service.py +0 -0
- {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/services/recommender.py +0 -0
- {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/services/rightsizing_service.py +0 -0
- {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/services/scheduling_service.py +0 -0
- {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/services/serving_service.py +0 -0
- {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/services/training_service.py +0 -0
- {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick.egg-info/dependency_links.txt +0 -0
- {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick.egg-info/entry_points.txt +0 -0
- {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick.egg-info/requires.txt +0 -0
- {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: greenpick
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Design-time sustainability co-pilot (MCP server): greener, cheaper cloud infrastructure decisions with carbon + cost + latency tradeoffs.
|
|
5
5
|
Author-email: "Data Science Advisers, LLC" <info@datascienceadvisers.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -52,7 +52,8 @@ environment (Cursor / Claude Code / Kiro). Ask it where to deploy, when to run a
|
|
|
52
52
|
right-size — and it answers with an honest **carbon + cost + latency** tradeoff, in-editor, in
|
|
53
53
|
seconds. (It responds when you ask; auto-nudging on infra-code changes is on the roadmap.)
|
|
54
54
|
|
|
55
|
-
> **Status: working — 15 tools.** Live carbon for US (EIA-930)
|
|
55
|
+
> **Status: working — 15 tools.** Live carbon for US (EIA-930), EU (ENTSO-E) and Canada (Hydro-Québec,
|
|
56
|
+
> IESO — no key needed), cross-cloud cost
|
|
56
57
|
> parity (Vantage-refreshed), per-axis R/Y/G ratings, carbon-aware scheduling + day-ahead forecast,
|
|
57
58
|
> GPU-training **and LLM-inference** carbon, right-sizing, serving-pattern and data-locality/residency
|
|
58
59
|
> checks — all shipped. Offline or without API keys, falls back to clearly-labelled *typical* values:
|
|
@@ -149,7 +150,7 @@ See `docs/user-questions-backlog.md`.
|
|
|
149
150
|
|------|--------------|
|
|
150
151
|
| `compare_regions_tri_axis` | Rank candidate regions by carbon with cost + latency tradeoffs; recommend a pick, each axis rated R/Y/G. Pass `regions` to rank only the ones you'd actually consider. |
|
|
151
152
|
| `greener_alternative_to` | "You picked X — the greenest region within +N ms latency is Y (−Z% carbon, Δcost)." The core nudge. |
|
|
152
|
-
| `region_carbon_now` | Current gCO₂/kWh for a region, with source + timestamp + freshness. |
|
|
153
|
+
| `region_carbon_now` | Current gCO₂/kWh for a region, with source + timestamp + freshness — and, when live, whether that's cleaner, dirtier or about usual for that hour this month. |
|
|
153
154
|
|
|
154
155
|
### Time a job (scheduling)
|
|
155
156
|
|
|
@@ -187,6 +188,9 @@ See `docs/user-questions-backlog.md`.
|
|
|
187
188
|
|
|
188
189
|
- **EIA-930** (US balancing authorities) and **ENTSO-E** (EU zones) — live grid carbon,
|
|
189
190
|
auto-routed by region. Both also drive `cleanest_hours_to_run` from recent hourly history.
|
|
191
|
+
- **Hydro-Québec** (Montreal / Québec City regions) and **IESO** (Toronto regions) — live hourly
|
|
192
|
+
generation by source, public, **no key needed**. Hydro-Québec figures arrive 1.5–6 h late, so the
|
|
193
|
+
latest complete hour is used and its timestamp shown.
|
|
190
194
|
- **Typical** — the offline default and fallback: for each region, this calendar month's average
|
|
191
195
|
intensity and average hour-of-day profile (UTC), computed from 12 months of real hourly EIA-930 /
|
|
192
196
|
ENTSO-E data (`data/typical.json`, refreshed by `scripts/refresh_typical.py`). Labelled
|
|
@@ -238,6 +242,7 @@ Connect it to an MCP client (e.g. Kiro / Claude Code / Cursor) by pointing the c
|
|
|
238
242
|
GreenPick runs on labelled typical values until you give it two free keys:
|
|
239
243
|
[EIA](https://www.eia.gov/opendata/register.php) (US grid) and
|
|
240
244
|
[ENTSO-E](https://transparency.entsoe.eu/) (EU grid). Pass them as environment variables.
|
|
245
|
+
Canadian regions are live with just `GREENPICK_CARBON_SOURCE=auto` — their sources need no key.
|
|
241
246
|
(A `.env` file works for a source checkout; if you installed with `pip install greenpick`, use one
|
|
242
247
|
of the options below.)
|
|
243
248
|
|
|
@@ -22,7 +22,8 @@ environment (Cursor / Claude Code / Kiro). Ask it where to deploy, when to run a
|
|
|
22
22
|
right-size — and it answers with an honest **carbon + cost + latency** tradeoff, in-editor, in
|
|
23
23
|
seconds. (It responds when you ask; auto-nudging on infra-code changes is on the roadmap.)
|
|
24
24
|
|
|
25
|
-
> **Status: working — 15 tools.** Live carbon for US (EIA-930)
|
|
25
|
+
> **Status: working — 15 tools.** Live carbon for US (EIA-930), EU (ENTSO-E) and Canada (Hydro-Québec,
|
|
26
|
+
> IESO — no key needed), cross-cloud cost
|
|
26
27
|
> parity (Vantage-refreshed), per-axis R/Y/G ratings, carbon-aware scheduling + day-ahead forecast,
|
|
27
28
|
> GPU-training **and LLM-inference** carbon, right-sizing, serving-pattern and data-locality/residency
|
|
28
29
|
> checks — all shipped. Offline or without API keys, falls back to clearly-labelled *typical* values:
|
|
@@ -119,7 +120,7 @@ See `docs/user-questions-backlog.md`.
|
|
|
119
120
|
|------|--------------|
|
|
120
121
|
| `compare_regions_tri_axis` | Rank candidate regions by carbon with cost + latency tradeoffs; recommend a pick, each axis rated R/Y/G. Pass `regions` to rank only the ones you'd actually consider. |
|
|
121
122
|
| `greener_alternative_to` | "You picked X — the greenest region within +N ms latency is Y (−Z% carbon, Δcost)." The core nudge. |
|
|
122
|
-
| `region_carbon_now` | Current gCO₂/kWh for a region, with source + timestamp + freshness. |
|
|
123
|
+
| `region_carbon_now` | Current gCO₂/kWh for a region, with source + timestamp + freshness — and, when live, whether that's cleaner, dirtier or about usual for that hour this month. |
|
|
123
124
|
|
|
124
125
|
### Time a job (scheduling)
|
|
125
126
|
|
|
@@ -157,6 +158,9 @@ See `docs/user-questions-backlog.md`.
|
|
|
157
158
|
|
|
158
159
|
- **EIA-930** (US balancing authorities) and **ENTSO-E** (EU zones) — live grid carbon,
|
|
159
160
|
auto-routed by region. Both also drive `cleanest_hours_to_run` from recent hourly history.
|
|
161
|
+
- **Hydro-Québec** (Montreal / Québec City regions) and **IESO** (Toronto regions) — live hourly
|
|
162
|
+
generation by source, public, **no key needed**. Hydro-Québec figures arrive 1.5–6 h late, so the
|
|
163
|
+
latest complete hour is used and its timestamp shown.
|
|
160
164
|
- **Typical** — the offline default and fallback: for each region, this calendar month's average
|
|
161
165
|
intensity and average hour-of-day profile (UTC), computed from 12 months of real hourly EIA-930 /
|
|
162
166
|
ENTSO-E data (`data/typical.json`, refreshed by `scripts/refresh_typical.py`). Labelled
|
|
@@ -208,6 +212,7 @@ Connect it to an MCP client (e.g. Kiro / Claude Code / Cursor) by pointing the c
|
|
|
208
212
|
GreenPick runs on labelled typical values until you give it two free keys:
|
|
209
213
|
[EIA](https://www.eia.gov/opendata/register.php) (US grid) and
|
|
210
214
|
[ENTSO-E](https://transparency.entsoe.eu/) (EU grid). Pass them as environment variables.
|
|
215
|
+
Canadian regions are live with just `GREENPICK_CARBON_SOURCE=auto` — their sources need no key.
|
|
211
216
|
(A `.env` file works for a source checkout; if you installed with `pip install greenpick`, use one
|
|
212
217
|
of the options below.)
|
|
213
218
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "greenpick"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "Design-time sustainability co-pilot (MCP server): greener, cheaper cloud infrastructure decisions with carbon + cost + latency tradeoffs."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -4,7 +4,7 @@ Greener cloud-region choices with honest carbon + cost + latency tradeoffs,
|
|
|
4
4
|
delivered inside the vibe-coding loop. Directional guidance, not audited accounting.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
__version__ = "0.
|
|
7
|
+
__version__ = "0.3.0"
|
|
8
8
|
|
|
9
9
|
# httpx logs every request URL at INFO, and our live sources pass their API key / token as a URL
|
|
10
10
|
# query parameter — so at the default log level the secret would land in the MCP client's logs.
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
"""Auto router: pick the right live source per region.
|
|
2
2
|
|
|
3
|
-
- US regions (have an `eia_ba`)
|
|
4
|
-
- EU regions (have an `entsoe_eic`)
|
|
5
|
-
-
|
|
3
|
+
- US regions (have an `eia_ba`) -> EIA
|
|
4
|
+
- EU regions (have an `entsoe_eic`) -> ENTSO-E
|
|
5
|
+
- Canadian regions (have a `ca_source`) -> Hydro-Québec / IESO (no key needed)
|
|
6
|
+
- anything else -> typical / mock fallback (honestly labelled)
|
|
6
7
|
|
|
7
|
-
Lets a single GREENPICK_CARBON_SOURCE=auto give live data on
|
|
8
|
+
Lets a single GREENPICK_CARBON_SOURCE=auto give live data on all three.
|
|
8
9
|
"""
|
|
9
10
|
from __future__ import annotations
|
|
10
11
|
|
|
@@ -12,6 +13,7 @@ from ..models import CarbonReading, Provider, HourlyCarbon, ForecastHour
|
|
|
12
13
|
from .typical_repository import TypicalCarbonRepository
|
|
13
14
|
from .eia_repository import EIARepository
|
|
14
15
|
from .entsoe_repository import ENTSOERepository
|
|
16
|
+
from .canada_repository import CanadaRepository
|
|
15
17
|
from .static_data import region_meta
|
|
16
18
|
|
|
17
19
|
|
|
@@ -19,23 +21,24 @@ class AutoRepository:
|
|
|
19
21
|
def __init__(self, eia_key: str, entsoe_token: str, cache_ttl_seconds: int = 1800) -> None:
|
|
20
22
|
self._eia = EIARepository(eia_key, cache_ttl_seconds)
|
|
21
23
|
self._entsoe = ENTSOERepository(entsoe_token, cache_ttl_seconds)
|
|
24
|
+
self._canada = CanadaRepository(cache_ttl_seconds)
|
|
22
25
|
self._mock = TypicalCarbonRepository()
|
|
23
26
|
|
|
24
|
-
|
|
27
|
+
def _route(self, provider: Provider, region: str):
|
|
25
28
|
meta = region_meta(provider, region) or {}
|
|
26
29
|
if meta.get("eia_ba"):
|
|
27
|
-
return
|
|
30
|
+
return self._eia
|
|
28
31
|
if meta.get("entsoe_eic"):
|
|
29
|
-
return
|
|
30
|
-
|
|
32
|
+
return self._entsoe
|
|
33
|
+
if meta.get("ca_source"):
|
|
34
|
+
return self._canada
|
|
35
|
+
return self._mock
|
|
36
|
+
|
|
37
|
+
async def carbon_now(self, provider: Provider, region: str) -> CarbonReading:
|
|
38
|
+
return await self._route(provider, region).carbon_now(provider, region)
|
|
31
39
|
|
|
32
40
|
async def carbon_history(self, provider: Provider, region: str) -> list[HourlyCarbon]:
|
|
33
|
-
|
|
34
|
-
if meta.get("eia_ba"):
|
|
35
|
-
return await self._eia.carbon_history(provider, region)
|
|
36
|
-
if meta.get("entsoe_eic"):
|
|
37
|
-
return await self._entsoe.carbon_history(provider, region)
|
|
38
|
-
return await self._mock.carbon_history(provider, region)
|
|
41
|
+
return await self._route(provider, region).carbon_history(provider, region)
|
|
39
42
|
|
|
40
43
|
async def renewable_forecast(self, provider: Provider, region: str, hours_ahead: int = 24
|
|
41
44
|
) -> list[ForecastHour]:
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Canadian carbon sources: Hydro-Québec (Québec) and IESO (Ontario). Both free, no key.
|
|
2
|
+
|
|
3
|
+
- Hydro-Québec open data: hourly generation by source (hydraulique, eolien, solaire, thermique,
|
|
4
|
+
autres) for the last ~48h, via the opendatasoft API. Values lag 1.5-6h; the newest hours come
|
|
5
|
+
back empty until filled, so we use the latest hour that has a total.
|
|
6
|
+
- IESO: today's hourly output per generator, with each generator's fuel type
|
|
7
|
+
(PUB_GenOutputCapability.xml, updated hourly). Hours are "hour ending" in Eastern Standard Time
|
|
8
|
+
all year, so hour h of date D starts at D + (h-1)h EST = D + (h+4)h UTC.
|
|
9
|
+
|
|
10
|
+
Both are weighted with GreenPick's own per-fuel factors (emission_factors.json ->
|
|
11
|
+
canada_fuel_to_factor), the same way EIA and ENTSO-E mixes are, so regions compare fairly.
|
|
12
|
+
Production-based (imports/exports not traced), like the other sources. Falls back to the typical
|
|
13
|
+
monthly values when a fetch fails, with the same bounded timeouts and outage memory as ENTSO-E.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import logging
|
|
19
|
+
import time
|
|
20
|
+
from datetime import datetime, timedelta, timezone
|
|
21
|
+
from typing import Optional
|
|
22
|
+
from xml.etree import ElementTree as ET
|
|
23
|
+
|
|
24
|
+
import httpx
|
|
25
|
+
|
|
26
|
+
from ..models import CarbonReading, HourlyCarbon, Provider
|
|
27
|
+
from ._net import REQUEST_DEADLINE_S, REQUEST_TIMEOUT, describe_error
|
|
28
|
+
from .static_data import emission_factors, region_meta
|
|
29
|
+
from .typical_repository import TypicalCarbonRepository
|
|
30
|
+
|
|
31
|
+
log = logging.getLogger("greenpick.canada")
|
|
32
|
+
|
|
33
|
+
HQ_LIVE_URL = ("https://donnees.hydroquebec.com/api/explore/v2.1/catalog/datasets/"
|
|
34
|
+
"production-electricite-quebec/records")
|
|
35
|
+
IESO_LIVE_URL = "https://reports-public.ieso.ca/public/GenOutputCapability/PUB_GenOutputCapability.xml"
|
|
36
|
+
_OUTAGE_COOLDOWN_S = 300.0
|
|
37
|
+
_EST = timezone(timedelta(hours=-5)) # IESO publishes in EST year-round (no DST)
|
|
38
|
+
LABEL = {"hq": "Hydro-Québec", "ieso": "IESO"}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _factors(source: str) -> dict[str, float]:
|
|
42
|
+
return emission_factors()["canada_fuel_to_factor"][source]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def mix_to_intensity(source: str, mix: dict[str, float]) -> Optional[float]:
|
|
46
|
+
"""Weighted gCO2/kWh for a {fuel: MW} mix, ignoring unknown fuels and non-positive values."""
|
|
47
|
+
f = _factors(source)
|
|
48
|
+
parts = [(mw, f[fuel]) for fuel, mw in mix.items() if fuel in f and mw and mw > 0]
|
|
49
|
+
total = sum(mw for mw, _ in parts)
|
|
50
|
+
return round(sum(mw * g for mw, g in parts) / total, 1) if total > 0 else None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def hour_utc(timestamp: str) -> Optional[str]:
|
|
54
|
+
"""'2026-10-26T09:30:00+00:00' -> '2026-10-26T09' (UTC, floored to the hour)."""
|
|
55
|
+
try:
|
|
56
|
+
dt = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
|
|
57
|
+
except (ValueError, AttributeError):
|
|
58
|
+
return None
|
|
59
|
+
if dt.tzinfo is None:
|
|
60
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
61
|
+
return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def hq_rows_to_hourly(rows: list[dict], prefix: str = "valeurs_") -> dict[str, float]:
|
|
65
|
+
"""Hydro-Québec records -> {UTC hour: gCO2/kWh}. Live rows use 'valeurs_<source>' fields,
|
|
66
|
+
history rows use the bare source names (prefix='')."""
|
|
67
|
+
out: dict[str, float] = {}
|
|
68
|
+
for r in rows:
|
|
69
|
+
if not r.get(f"{prefix}total"):
|
|
70
|
+
continue # not filled in yet
|
|
71
|
+
period = hour_utc(r.get("date", ""))
|
|
72
|
+
mix = {k: r.get(f"{prefix}{k}") or 0.0 for k in _factors("hq")}
|
|
73
|
+
g = mix_to_intensity("hq", mix)
|
|
74
|
+
if period and g is not None:
|
|
75
|
+
out[period] = g
|
|
76
|
+
return out
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def ieso_period(day: str, hour_ending: int) -> Optional[str]:
|
|
80
|
+
"""IESO date + hour-ending (1..24, EST) -> UTC hour the interval starts, 'YYYY-MM-DDTHH'."""
|
|
81
|
+
try:
|
|
82
|
+
start = datetime.strptime(day, "%Y-%m-%d").replace(tzinfo=_EST) + timedelta(hours=hour_ending - 1)
|
|
83
|
+
except ValueError:
|
|
84
|
+
return None
|
|
85
|
+
return start.astimezone(timezone.utc).strftime("%Y-%m-%dT%H")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _strip(tag: str) -> str:
|
|
89
|
+
return tag.rsplit("}", 1)[-1]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def ieso_generators_to_hourly(xml_text: str) -> dict[str, float]:
|
|
93
|
+
"""PUB_GenOutputCapability.xml (per-generator, today) -> {UTC hour: gCO2/kWh}."""
|
|
94
|
+
root = ET.fromstring(xml_text)
|
|
95
|
+
day = next((e.text for e in root.iter() if _strip(e.tag) == "Date" and e.text), None)
|
|
96
|
+
if not day:
|
|
97
|
+
return {}
|
|
98
|
+
by_hour: dict[int, dict[str, float]] = {}
|
|
99
|
+
for gen in root.iter():
|
|
100
|
+
if _strip(gen.tag) != "Generator":
|
|
101
|
+
continue
|
|
102
|
+
fuel = next((c.text for c in gen if _strip(c.tag) == "FuelType"), None)
|
|
103
|
+
outputs = next((c for c in gen if _strip(c.tag) == "Outputs"), None)
|
|
104
|
+
if not fuel or outputs is None:
|
|
105
|
+
continue
|
|
106
|
+
for out in outputs:
|
|
107
|
+
vals = {_strip(c.tag): c.text for c in out}
|
|
108
|
+
try:
|
|
109
|
+
h, mw = int(vals.get("Hour") or 0), float(vals.get("EnergyMW") or "nan")
|
|
110
|
+
except ValueError:
|
|
111
|
+
continue
|
|
112
|
+
if h and mw == mw: # skip hours not reported yet (NaN)
|
|
113
|
+
bucket = by_hour.setdefault(h, {})
|
|
114
|
+
bucket[fuel] = bucket.get(fuel, 0.0) + mw
|
|
115
|
+
out: dict[str, float] = {}
|
|
116
|
+
for h, mix in by_hour.items():
|
|
117
|
+
g = mix_to_intensity("ieso", mix)
|
|
118
|
+
period = ieso_period(day, h)
|
|
119
|
+
if g is not None and period and sum(mix.values()) > 0:
|
|
120
|
+
out[period] = g
|
|
121
|
+
return out
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def ieso_fuel_hourly_to_hourly(xml_text: str) -> dict[str, float]:
|
|
125
|
+
"""PUB_GenOutputbyFuelHourly_<year>.xml (yearly, by fuel) -> {UTC hour: gCO2/kWh}."""
|
|
126
|
+
root = ET.fromstring(xml_text)
|
|
127
|
+
out: dict[str, float] = {}
|
|
128
|
+
for daily in root.iter():
|
|
129
|
+
if _strip(daily.tag) != "DailyData":
|
|
130
|
+
continue
|
|
131
|
+
day = next((c.text for c in daily if _strip(c.tag) == "Day"), None)
|
|
132
|
+
for hourly in daily:
|
|
133
|
+
if _strip(hourly.tag) != "HourlyData":
|
|
134
|
+
continue
|
|
135
|
+
hour = next((c.text for c in hourly if _strip(c.tag) == "Hour"), None)
|
|
136
|
+
mix: dict[str, float] = {}
|
|
137
|
+
for ft in hourly:
|
|
138
|
+
if _strip(ft.tag) != "FuelTotal":
|
|
139
|
+
continue
|
|
140
|
+
fuel = next((c.text for c in ft if _strip(c.tag) == "Fuel"), None)
|
|
141
|
+
val = next((o.text for o in ft.iter() if _strip(o.tag) == "Output"), None)
|
|
142
|
+
try:
|
|
143
|
+
mix[fuel] = float(val) # type: ignore[index]
|
|
144
|
+
except (TypeError, ValueError):
|
|
145
|
+
continue
|
|
146
|
+
g = mix_to_intensity("ieso", mix)
|
|
147
|
+
period = ieso_period(day or "", int(hour)) if hour and hour.isdigit() else None
|
|
148
|
+
if g is not None and period:
|
|
149
|
+
out[period] = g
|
|
150
|
+
return out
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class CanadaRepository:
|
|
154
|
+
"""Live Québec (Hydro-Québec) and Ontario (IESO) carbon, keyed off a region's ca_source."""
|
|
155
|
+
|
|
156
|
+
def __init__(self, cache_ttl_seconds: int = 1800) -> None:
|
|
157
|
+
self._ttl = cache_ttl_seconds
|
|
158
|
+
self._fallback = TypicalCarbonRepository()
|
|
159
|
+
self._cache: dict[str, tuple[float, dict[str, float]]] = {}
|
|
160
|
+
self._down_until: dict[str, float] = {}
|
|
161
|
+
|
|
162
|
+
async def _fetch(self, source: str) -> Optional[dict[str, float]]:
|
|
163
|
+
hit = self._cache.get(source)
|
|
164
|
+
if hit and time.time() - hit[0] < self._ttl:
|
|
165
|
+
return hit[1]
|
|
166
|
+
if time.time() < self._down_until.get(source, 0.0):
|
|
167
|
+
return None
|
|
168
|
+
try:
|
|
169
|
+
async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as client:
|
|
170
|
+
if source == "hq":
|
|
171
|
+
params = {"limit": "48", "order_by": "date desc", "where": "valeurs_total>0"}
|
|
172
|
+
resp = await asyncio.wait_for(client.get(HQ_LIVE_URL, params=params),
|
|
173
|
+
timeout=REQUEST_DEADLINE_S)
|
|
174
|
+
resp.raise_for_status()
|
|
175
|
+
hourly = hq_rows_to_hourly(resp.json().get("results", []))
|
|
176
|
+
else:
|
|
177
|
+
resp = await asyncio.wait_for(client.get(IESO_LIVE_URL), timeout=REQUEST_DEADLINE_S)
|
|
178
|
+
resp.raise_for_status()
|
|
179
|
+
hourly = ieso_generators_to_hourly(resp.text)
|
|
180
|
+
except Exception as e:
|
|
181
|
+
log.debug("%s fetch failed (%s) — typical fallback", LABEL[source], describe_error(e))
|
|
182
|
+
hourly = {}
|
|
183
|
+
if not hourly:
|
|
184
|
+
self._down_until[source] = time.time() + _OUTAGE_COOLDOWN_S
|
|
185
|
+
return None
|
|
186
|
+
self._cache[source] = (time.time(), hourly)
|
|
187
|
+
return hourly
|
|
188
|
+
|
|
189
|
+
async def carbon_now(self, provider: Provider, region: str) -> CarbonReading:
|
|
190
|
+
source = (region_meta(provider, region) or {}).get("ca_source")
|
|
191
|
+
hourly = await self._fetch(source) if source else None
|
|
192
|
+
if not hourly:
|
|
193
|
+
return await self._fallback.carbon_now(provider, region)
|
|
194
|
+
latest = max(hourly)
|
|
195
|
+
cached = self._cache.get(source, (0.0, {}))[0] < time.time() - 5
|
|
196
|
+
return CarbonReading(region=region, provider=provider, g_co2_per_kwh=hourly[latest],
|
|
197
|
+
source=source, timestamp=latest, # type: ignore[arg-type]
|
|
198
|
+
quality="cached" if cached else "live")
|
|
199
|
+
|
|
200
|
+
async def carbon_history(self, provider: Provider, region: str) -> list[HourlyCarbon]:
|
|
201
|
+
source = (region_meta(provider, region) or {}).get("ca_source")
|
|
202
|
+
hourly = await self._fetch(source) if source else None
|
|
203
|
+
if not hourly:
|
|
204
|
+
return await self._fallback.carbon_history(provider, region)
|
|
205
|
+
return [HourlyCarbon(period=p, g_co2_per_kwh=g) for p, g in sorted(hourly.items())]
|
|
@@ -18,6 +18,11 @@
|
|
|
18
18
|
"NUC": "Nuclear", "WAT": "Hydro", "WND": "Wind", "SUN": "Solar",
|
|
19
19
|
"GEO": "Geothermal", "BIO": "Biomass"
|
|
20
20
|
},
|
|
21
|
+
"_comment_canada": "Maps Canadian operators' published source categories to the same per-fuel factors above. hq = Hydro-Québec open data (hydraulique/eolien/solaire/thermique/autres; Québec thermal is mostly oil/diesel, 'autres' is biomass/biogas/small hydro). ieso = Ontario IESO fuel types.",
|
|
22
|
+
"canada_fuel_to_factor": {
|
|
23
|
+
"hq": { "hydraulique": 24, "eolien": 11, "solaire": 48, "thermique": 650, "autres": 230 },
|
|
24
|
+
"ieso": { "NUCLEAR": 12, "GAS": 490, "HYDRO": 24, "WIND": 11, "SOLAR": 48, "BIOFUEL": 230, "OTHER": 700 }
|
|
25
|
+
},
|
|
21
26
|
"_comment_entsoe": "Maps ENTSO-E A75 psrType codes to the same per-fuel factors above, so the EU source reuses one emission-factor table. B-codes per ENTSO-E standard.",
|
|
22
27
|
"entsoe_psrtype_to_factor": {
|
|
23
28
|
"B01": 230, "B02": 820, "B03": 490, "B04": 490, "B05": 820, "B06": 650,
|
|
@@ -6,16 +6,21 @@
|
|
|
6
6
|
"us-west-2": { "name": "Oregon", "lat": 45.87, "lon": -119.69, "zone": "US-NW-PACW", "eia_ba": "BPAT", "jurisdiction": "US" },
|
|
7
7
|
"eu-west-1": { "name": "Ireland", "lat": 53.41, "lon": -8.24, "zone": "IE", "entsoe_eic": "10Y1001A1001A59C", "jurisdiction": "EEA" },
|
|
8
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" }
|
|
9
|
+
"ap-southeast-2": { "name": "Sydney", "lat": -33.87, "lon": 151.21, "zone": "AU-NSW", "jurisdiction": "AU" },
|
|
10
|
+
"ca-central-1": { "name": "Montreal", "lat": 45.50, "lon": -73.57, "zone": "CA-QC", "ca_source": "hq", "jurisdiction": "CA" }
|
|
10
11
|
},
|
|
11
12
|
"gcp": {
|
|
12
13
|
"us-central1": { "name": "Iowa", "lat": 41.26, "lon": -95.86, "zone": "US-MIDW-MISO", "eia_ba": "MISO", "jurisdiction": "US" },
|
|
13
14
|
"europe-north1": { "name": "Finland", "lat": 60.57, "lon": 27.19, "zone": "FI", "entsoe_eic": "10YFI-1--------U", "jurisdiction": "EEA" },
|
|
14
|
-
"us-west1": { "name": "Oregon", "lat": 45.60, "lon": -121.18, "zone": "US-NW-PACW", "eia_ba": "BPAT", "jurisdiction": "US" }
|
|
15
|
+
"us-west1": { "name": "Oregon", "lat": 45.60, "lon": -121.18, "zone": "US-NW-PACW", "eia_ba": "BPAT", "jurisdiction": "US" },
|
|
16
|
+
"northamerica-northeast1": { "name": "Montreal", "lat": 45.50, "lon": -73.57, "zone": "CA-QC", "ca_source": "hq", "jurisdiction": "CA" },
|
|
17
|
+
"northamerica-northeast2": { "name": "Toronto", "lat": 43.65, "lon": -79.38, "zone": "CA-ON", "ca_source": "ieso", "jurisdiction": "CA" }
|
|
15
18
|
},
|
|
16
19
|
"azure": {
|
|
17
20
|
"eastus": { "name": "Virginia", "lat": 37.37, "lon": -79.82, "zone": "US-MIDA-PJM", "eia_ba": "PJM", "jurisdiction": "US" },
|
|
18
21
|
"northeurope": { "name": "Ireland", "lat": 53.35, "lon": -6.26, "zone": "IE", "entsoe_eic": "10Y1001A1001A59C", "jurisdiction": "EEA" },
|
|
19
|
-
"swedencentral": { "name": "Sweden", "lat": 60.67, "lon": 17.14, "zone": "SE", "entsoe_eic": "10YSE-1--------K", "jurisdiction": "EEA" }
|
|
22
|
+
"swedencentral": { "name": "Sweden", "lat": 60.67, "lon": 17.14, "zone": "SE", "entsoe_eic": "10YSE-1--------K", "jurisdiction": "EEA" },
|
|
23
|
+
"canadaeast": { "name": "Quebec City", "lat": 46.81, "lon": -71.21, "zone": "CA-QC", "ca_source": "hq", "jurisdiction": "CA" },
|
|
24
|
+
"canadacentral": { "name": "Toronto", "lat": 43.65, "lon": -79.38, "zone": "CA-ON", "ca_source": "ieso", "jurisdiction": "CA" }
|
|
20
25
|
}
|
|
21
26
|
}
|