greenpick 0.2.1__tar.gz → 0.4.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.1/src/greenpick.egg-info → greenpick-0.4.0}/PKG-INFO +17 -7
- {greenpick-0.2.1 → greenpick-0.4.0}/README.md +16 -6
- {greenpick-0.2.1 → greenpick-0.4.0}/pyproject.toml +1 -1
- {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/__init__.py +1 -1
- greenpick-0.4.0/src/greenpick/data/annual.json +13 -0
- {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/data/auto_repository.py +17 -14
- greenpick-0.4.0/src/greenpick/data/canada_repository.py +205 -0
- {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/data/emission_factors.json +5 -0
- {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/data/mock_repository.py +2 -0
- {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/data/regions.json +17 -3
- {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/data/static_data.py +9 -0
- {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/data/typical.json +2764 -924
- {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/data/typical_repository.py +14 -0
- {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/server.py +2 -2
- {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/services/data_hint.py +22 -5
- {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/services/forecast_service.py +4 -0
- {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/services/recommender.py +1 -1
- {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/services/residency_service.py +4 -0
- {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/services/training_service.py +3 -0
- {greenpick-0.2.1 → greenpick-0.4.0/src/greenpick.egg-info}/PKG-INFO +17 -7
- {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick.egg-info/SOURCES.txt +2 -0
- {greenpick-0.2.1 → greenpick-0.4.0}/tests/test_services.py +188 -3
- {greenpick-0.2.1 → greenpick-0.4.0}/LICENSE +0 -0
- {greenpick-0.2.1 → greenpick-0.4.0}/setup.cfg +0 -0
- {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/__main__.py +0 -0
- {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/config.py +0 -0
- {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/data/__init__.py +0 -0
- {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/data/_net.py +0 -0
- {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/data/eia_repository.py +0 -0
- {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/data/electricitymaps_repository.py +0 -0
- {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/data/entsoe_repository.py +0 -0
- {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/data/gpu_power.json +0 -0
- {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/data/inference_energy.json +0 -0
- {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/data/instance_families.json +0 -0
- {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/data/network.json +0 -0
- {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/data/power.json +0 -0
- {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/data/pricing.json +0 -0
- {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/data/repository.py +0 -0
- {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/models.py +0 -0
- {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/services/__init__.py +0 -0
- {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/services/carbon_service.py +0 -0
- {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/services/cost_service.py +0 -0
- {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/services/inference_service.py +0 -0
- {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/services/instance_service.py +0 -0
- {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/services/latency_service.py +0 -0
- {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/services/locality_service.py +0 -0
- {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/services/metric_scaffold_service.py +0 -0
- {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/services/rightsizing_service.py +0 -0
- {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/services/scheduling_service.py +0 -0
- {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/services/serving_service.py +0 -0
- {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick.egg-info/dependency_links.txt +0 -0
- {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick.egg-info/entry_points.txt +0 -0
- {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick.egg-info/requires.txt +0 -0
- {greenpick-0.2.1 → greenpick-0.4.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.4.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:
|
|
@@ -123,7 +124,7 @@ backfire guards** — is the part nothing else puts together.
|
|
|
123
124
|
|
|
124
125
|
## Scope
|
|
125
126
|
|
|
126
|
-
- Providers: **AWS, GCP, Azure** regions.
|
|
127
|
+
- Providers: **AWS, GCP, Azure** — 25 regions across the US, Europe, Canada, Asia and Australia.
|
|
127
128
|
- Workloads: long-running **web/API services**, **GPU training** runs, and **deferrable jobs**
|
|
128
129
|
(ETL / batch / backups) — placement and timing for each.
|
|
129
130
|
- Carbon = **grid carbon intensity by region** (directional, point-in-time; live US + EU), plus a
|
|
@@ -187,12 +188,19 @@ 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
|
|
193
197
|
`quality: "typical"` — a realistic norm, never presented as a current reading.
|
|
194
|
-
- **
|
|
195
|
-
|
|
198
|
+
- **Annual** — for Singapore, Tokyo and Mumbai/Pune, where no free hourly data exists: the country's
|
|
199
|
+
yearly lifecycle grid intensity from [Ember](https://ember-energy.org/) (via Our World in Data,
|
|
200
|
+
CC BY 4.0), with source and year (`data/annual.json`). Labelled `quality: "annual_average"`; fine for
|
|
201
|
+
comparing regions, but carries no timing signal.
|
|
202
|
+
- **Mock** — a hand-set illustrative value, labelled `quality: "mock"`, used only where nothing else
|
|
203
|
+
exists (currently Sydney).
|
|
196
204
|
- Cost: a **dated static pricing table** (`data/pricing.json`), refreshed from live Vantage
|
|
197
205
|
pricing via `scripts/refresh_pricing.py` using one matched method across all three clouds.
|
|
198
206
|
- Instance-family efficiency: directional perf-per-watt multipliers (`data/instance_families.json`).
|
|
@@ -238,6 +246,7 @@ Connect it to an MCP client (e.g. Kiro / Claude Code / Cursor) by pointing the c
|
|
|
238
246
|
GreenPick runs on labelled typical values until you give it two free keys:
|
|
239
247
|
[EIA](https://www.eia.gov/opendata/register.php) (US grid) and
|
|
240
248
|
[ENTSO-E](https://transparency.entsoe.eu/) (EU grid). Pass them as environment variables.
|
|
249
|
+
Canadian regions are live with just `GREENPICK_CARBON_SOURCE=auto` — their sources need no key.
|
|
241
250
|
(A `.env` file works for a source checkout; if you installed with `pip install greenpick`, use one
|
|
242
251
|
of the options below.)
|
|
243
252
|
|
|
@@ -295,8 +304,9 @@ Top-level `await` works in Jupyter. In a plain script, put the `with` block insi
|
|
|
295
304
|
and run it with `asyncio.run(...)`.
|
|
296
305
|
|
|
297
306
|
Every carbon figure includes a `quality` field: `live` means a current reading, `typical` means
|
|
298
|
-
this month's average from last year's real data,
|
|
299
|
-
with no
|
|
307
|
+
this month's average from last year's real data, `annual_average` means a sourced yearly national
|
|
308
|
+
figure (regions with no free hourly data), and `mock` means a hand-set value. Check it to confirm
|
|
309
|
+
live data is on. A result that isn't live also includes a
|
|
300
310
|
`live_data_hint` saying why (no key set, the source didn't respond, or no free source covers that
|
|
301
311
|
region) and what to do about it.
|
|
302
312
|
|
|
@@ -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:
|
|
@@ -93,7 +94,7 @@ backfire guards** — is the part nothing else puts together.
|
|
|
93
94
|
|
|
94
95
|
## Scope
|
|
95
96
|
|
|
96
|
-
- Providers: **AWS, GCP, Azure** regions.
|
|
97
|
+
- Providers: **AWS, GCP, Azure** — 25 regions across the US, Europe, Canada, Asia and Australia.
|
|
97
98
|
- Workloads: long-running **web/API services**, **GPU training** runs, and **deferrable jobs**
|
|
98
99
|
(ETL / batch / backups) — placement and timing for each.
|
|
99
100
|
- Carbon = **grid carbon intensity by region** (directional, point-in-time; live US + EU), plus a
|
|
@@ -157,12 +158,19 @@ 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
|
|
163
167
|
`quality: "typical"` — a realistic norm, never presented as a current reading.
|
|
164
|
-
- **
|
|
165
|
-
|
|
168
|
+
- **Annual** — for Singapore, Tokyo and Mumbai/Pune, where no free hourly data exists: the country's
|
|
169
|
+
yearly lifecycle grid intensity from [Ember](https://ember-energy.org/) (via Our World in Data,
|
|
170
|
+
CC BY 4.0), with source and year (`data/annual.json`). Labelled `quality: "annual_average"`; fine for
|
|
171
|
+
comparing regions, but carries no timing signal.
|
|
172
|
+
- **Mock** — a hand-set illustrative value, labelled `quality: "mock"`, used only where nothing else
|
|
173
|
+
exists (currently Sydney).
|
|
166
174
|
- Cost: a **dated static pricing table** (`data/pricing.json`), refreshed from live Vantage
|
|
167
175
|
pricing via `scripts/refresh_pricing.py` using one matched method across all three clouds.
|
|
168
176
|
- Instance-family efficiency: directional perf-per-watt multipliers (`data/instance_families.json`).
|
|
@@ -208,6 +216,7 @@ Connect it to an MCP client (e.g. Kiro / Claude Code / Cursor) by pointing the c
|
|
|
208
216
|
GreenPick runs on labelled typical values until you give it two free keys:
|
|
209
217
|
[EIA](https://www.eia.gov/opendata/register.php) (US grid) and
|
|
210
218
|
[ENTSO-E](https://transparency.entsoe.eu/) (EU grid). Pass them as environment variables.
|
|
219
|
+
Canadian regions are live with just `GREENPICK_CARBON_SOURCE=auto` — their sources need no key.
|
|
211
220
|
(A `.env` file works for a source checkout; if you installed with `pip install greenpick`, use one
|
|
212
221
|
of the options below.)
|
|
213
222
|
|
|
@@ -265,8 +274,9 @@ Top-level `await` works in Jupyter. In a plain script, put the `with` block insi
|
|
|
265
274
|
and run it with `asyncio.run(...)`.
|
|
266
275
|
|
|
267
276
|
Every carbon figure includes a `quality` field: `live` means a current reading, `typical` means
|
|
268
|
-
this month's average from last year's real data,
|
|
269
|
-
with no
|
|
277
|
+
this month's average from last year's real data, `annual_average` means a sourced yearly national
|
|
278
|
+
figure (regions with no free hourly data), and `mock` means a hand-set value. Check it to confirm
|
|
279
|
+
live data is on. A result that isn't live also includes a
|
|
270
280
|
`live_data_hint` saying why (no key set, the source didn't respond, or no free source covers that
|
|
271
281
|
region) and what to do about it.
|
|
272
282
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "greenpick"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4.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.4.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.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "Yearly average grid carbon intensity for regions with no free hourly data source. Lifecycle gCO2e/kWh (full lifecycle incl. upstream methane), the same basis as GreenPick's per-fuel factors, so these compare fairly with live/typical regions. National figures: no hourly or sub-national lifecycle data is freely available for these grids. Used with quality='annual_average'; no hourly profile, so no timing advice.",
|
|
3
|
+
"_source": "Ember yearly electricity data (lifecycle CO2 intensity of electricity generation), via Our World in Data grapher 'carbon-intensity-electricity'. CC BY 4.0.",
|
|
4
|
+
"_as_of": "2026-10-27",
|
|
5
|
+
"zones": {
|
|
6
|
+
"SG": { "country": "Singapore", "g_co2_per_kwh": 497.1, "year": 2025,
|
|
7
|
+
"note": "About 95% natural gas; a yearly figure is a fair description of this steady grid." },
|
|
8
|
+
"JP": { "country": "Japan", "g_co2_per_kwh": 477.3, "year": 2025,
|
|
9
|
+
"note": "National average; the Tokyo (TEPCO) area mix differs somewhat and varies by hour." },
|
|
10
|
+
"IN": { "country": "India", "g_co2_per_kwh": 670.1, "year": 2025,
|
|
11
|
+
"note": "National average; Maharashtra (Mumbai, Pune) runs about 6% above it on Ember's state-level data." }
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -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,30 @@
|
|
|
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" },
|
|
11
|
+
"ap-southeast-1": { "name": "Singapore", "lat": 1.29, "lon": 103.85, "zone": "SG", "annual_zone": "SG", "jurisdiction": "SG" },
|
|
12
|
+
"ap-northeast-1": { "name": "Tokyo", "lat": 35.68, "lon": 139.69, "zone": "JP-TK", "annual_zone": "JP", "jurisdiction": "JP" },
|
|
13
|
+
"ap-south-1": { "name": "Mumbai", "lat": 19.08, "lon": 72.88, "zone": "IN-WE", "annual_zone": "IN", "jurisdiction": "IN" }
|
|
10
14
|
},
|
|
11
15
|
"gcp": {
|
|
12
16
|
"us-central1": { "name": "Iowa", "lat": 41.26, "lon": -95.86, "zone": "US-MIDW-MISO", "eia_ba": "MISO", "jurisdiction": "US" },
|
|
13
17
|
"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" }
|
|
18
|
+
"us-west1": { "name": "Oregon", "lat": 45.60, "lon": -121.18, "zone": "US-NW-PACW", "eia_ba": "BPAT", "jurisdiction": "US" },
|
|
19
|
+
"northamerica-northeast1": { "name": "Montreal", "lat": 45.50, "lon": -73.57, "zone": "CA-QC", "ca_source": "hq", "jurisdiction": "CA" },
|
|
20
|
+
"northamerica-northeast2": { "name": "Toronto", "lat": 43.65, "lon": -79.38, "zone": "CA-ON", "ca_source": "ieso", "jurisdiction": "CA" },
|
|
21
|
+
"asia-southeast1": { "name": "Singapore", "lat": 1.35, "lon": 103.70, "zone": "SG", "annual_zone": "SG", "jurisdiction": "SG" },
|
|
22
|
+
"asia-northeast1": { "name": "Tokyo", "lat": 35.68, "lon": 139.69, "zone": "JP-TK", "annual_zone": "JP", "jurisdiction": "JP" },
|
|
23
|
+
"asia-south1": { "name": "Mumbai", "lat": 19.08, "lon": 72.88, "zone": "IN-WE", "annual_zone": "IN", "jurisdiction": "IN" }
|
|
15
24
|
},
|
|
16
25
|
"azure": {
|
|
17
26
|
"eastus": { "name": "Virginia", "lat": 37.37, "lon": -79.82, "zone": "US-MIDA-PJM", "eia_ba": "PJM", "jurisdiction": "US" },
|
|
18
27
|
"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" }
|
|
28
|
+
"swedencentral": { "name": "Sweden", "lat": 60.67, "lon": 17.14, "zone": "SE", "entsoe_eic": "10YSE-1--------K", "jurisdiction": "EEA" },
|
|
29
|
+
"canadaeast": { "name": "Quebec City", "lat": 46.81, "lon": -71.21, "zone": "CA-QC", "ca_source": "hq", "jurisdiction": "CA" },
|
|
30
|
+
"canadacentral": { "name": "Toronto", "lat": 43.65, "lon": -79.38, "zone": "CA-ON", "ca_source": "ieso", "jurisdiction": "CA" },
|
|
31
|
+
"southeastasia": { "name": "Singapore", "lat": 1.28, "lon": 103.83, "zone": "SG", "annual_zone": "SG", "jurisdiction": "SG" },
|
|
32
|
+
"japaneast": { "name": "Tokyo", "lat": 35.68, "lon": 139.77, "zone": "JP-TK", "annual_zone": "JP", "jurisdiction": "JP" },
|
|
33
|
+
"centralindia": { "name": "Pune", "lat": 18.52, "lon": 73.86, "zone": "IN-WE", "annual_zone": "IN", "jurisdiction": "IN" }
|
|
20
34
|
}
|
|
21
35
|
}
|
|
@@ -76,6 +76,15 @@ def typical() -> dict:
|
|
|
76
76
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
77
77
|
|
|
78
78
|
|
|
79
|
+
@lru_cache(maxsize=1)
|
|
80
|
+
def annual() -> dict:
|
|
81
|
+
"""Yearly average intensity for regions with no free hourly source (lifecycle, sourced)."""
|
|
82
|
+
path = _DATA_DIR / "annual.json"
|
|
83
|
+
if not path.exists():
|
|
84
|
+
return {}
|
|
85
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
86
|
+
|
|
87
|
+
|
|
79
88
|
@lru_cache(maxsize=1)
|
|
80
89
|
def gpu_power() -> dict:
|
|
81
90
|
"""Directional GPU/accelerator power model (TDP + utilization + host overhead) for training."""
|