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.
Files changed (54) hide show
  1. {greenpick-0.2.1/src/greenpick.egg-info → greenpick-0.4.0}/PKG-INFO +17 -7
  2. {greenpick-0.2.1 → greenpick-0.4.0}/README.md +16 -6
  3. {greenpick-0.2.1 → greenpick-0.4.0}/pyproject.toml +1 -1
  4. {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/__init__.py +1 -1
  5. greenpick-0.4.0/src/greenpick/data/annual.json +13 -0
  6. {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/data/auto_repository.py +17 -14
  7. greenpick-0.4.0/src/greenpick/data/canada_repository.py +205 -0
  8. {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/data/emission_factors.json +5 -0
  9. {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/data/mock_repository.py +2 -0
  10. {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/data/regions.json +17 -3
  11. {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/data/static_data.py +9 -0
  12. {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/data/typical.json +2764 -924
  13. {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/data/typical_repository.py +14 -0
  14. {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/server.py +2 -2
  15. {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/services/data_hint.py +22 -5
  16. {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/services/forecast_service.py +4 -0
  17. {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/services/recommender.py +1 -1
  18. {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/services/residency_service.py +4 -0
  19. {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/services/training_service.py +3 -0
  20. {greenpick-0.2.1 → greenpick-0.4.0/src/greenpick.egg-info}/PKG-INFO +17 -7
  21. {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick.egg-info/SOURCES.txt +2 -0
  22. {greenpick-0.2.1 → greenpick-0.4.0}/tests/test_services.py +188 -3
  23. {greenpick-0.2.1 → greenpick-0.4.0}/LICENSE +0 -0
  24. {greenpick-0.2.1 → greenpick-0.4.0}/setup.cfg +0 -0
  25. {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/__main__.py +0 -0
  26. {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/config.py +0 -0
  27. {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/data/__init__.py +0 -0
  28. {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/data/_net.py +0 -0
  29. {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/data/eia_repository.py +0 -0
  30. {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/data/electricitymaps_repository.py +0 -0
  31. {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/data/entsoe_repository.py +0 -0
  32. {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/data/gpu_power.json +0 -0
  33. {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/data/inference_energy.json +0 -0
  34. {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/data/instance_families.json +0 -0
  35. {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/data/network.json +0 -0
  36. {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/data/power.json +0 -0
  37. {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/data/pricing.json +0 -0
  38. {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/data/repository.py +0 -0
  39. {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/models.py +0 -0
  40. {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/services/__init__.py +0 -0
  41. {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/services/carbon_service.py +0 -0
  42. {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/services/cost_service.py +0 -0
  43. {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/services/inference_service.py +0 -0
  44. {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/services/instance_service.py +0 -0
  45. {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/services/latency_service.py +0 -0
  46. {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/services/locality_service.py +0 -0
  47. {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/services/metric_scaffold_service.py +0 -0
  48. {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/services/rightsizing_service.py +0 -0
  49. {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/services/scheduling_service.py +0 -0
  50. {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick/services/serving_service.py +0 -0
  51. {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick.egg-info/dependency_links.txt +0 -0
  52. {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick.egg-info/entry_points.txt +0 -0
  53. {greenpick-0.2.1 → greenpick-0.4.0}/src/greenpick.egg-info/requires.txt +0 -0
  54. {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.2.1
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) and EU (ENTSO-E), cross-cloud cost
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
- - **Mock** — a hand-set illustrative value, labelled `quality: "mock"`, used only for regions no free
195
- source covers (e.g. Sydney).
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, and `mock` means a hand-set value for a region
299
- with no real data. Check it to confirm live data is on. A result that isn't live also includes a
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) and EU (ENTSO-E), cross-cloud cost
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
- - **Mock** — a hand-set illustrative value, labelled `quality: "mock"`, used only for regions no free
165
- source covers (e.g. Sydney).
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, and `mock` means a hand-set value for a region
269
- with no real data. Check it to confirm live data is on. A result that isn't live also includes a
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.2.1"
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.2.1"
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`) -> EIA
4
- - EU regions (have an `entsoe_eic`) -> ENTSO-E
5
- - anything else -> mock (honestly labelled)
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 both continents.
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
- async def carbon_now(self, provider: Provider, region: str) -> CarbonReading:
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 await self._eia.carbon_now(provider, region)
30
+ return self._eia
28
31
  if meta.get("entsoe_eic"):
29
- return await self._entsoe.carbon_now(provider, region)
30
- return await self._mock.carbon_now(provider, region)
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
- meta = region_meta(provider, region) or {}
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,
@@ -19,6 +19,8 @@ _ZONE_INTENSITY = {
19
19
  "SE": 25,
20
20
  "FI": 70,
21
21
  "AU-NSW": 580,
22
+ "CA-QC": 30,
23
+ "CA-ON": 80,
22
24
  }
23
25
  _DEFAULT = 400
24
26
 
@@ -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."""