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.
Files changed (53) hide show
  1. {greenpick-0.2.0/src/greenpick.egg-info → greenpick-0.3.0}/PKG-INFO +8 -3
  2. {greenpick-0.2.0 → greenpick-0.3.0}/README.md +7 -2
  3. {greenpick-0.2.0 → greenpick-0.3.0}/pyproject.toml +1 -1
  4. {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/__init__.py +1 -1
  5. {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/data/auto_repository.py +17 -14
  6. greenpick-0.3.0/src/greenpick/data/canada_repository.py +205 -0
  7. {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/data/emission_factors.json +5 -0
  8. {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/data/mock_repository.py +2 -0
  9. {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/data/regions.json +8 -3
  10. {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/data/typical.json +2764 -924
  11. {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/data/typical_repository.py +50 -0
  12. {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/server.py +12 -3
  13. {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/services/data_hint.py +6 -2
  14. {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/services/residency_service.py +1 -0
  15. {greenpick-0.2.0 → greenpick-0.3.0/src/greenpick.egg-info}/PKG-INFO +8 -3
  16. {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick.egg-info/SOURCES.txt +1 -0
  17. {greenpick-0.2.0 → greenpick-0.3.0}/tests/test_services.py +180 -3
  18. {greenpick-0.2.0 → greenpick-0.3.0}/LICENSE +0 -0
  19. {greenpick-0.2.0 → greenpick-0.3.0}/setup.cfg +0 -0
  20. {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/__main__.py +0 -0
  21. {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/config.py +0 -0
  22. {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/data/__init__.py +0 -0
  23. {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/data/_net.py +0 -0
  24. {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/data/eia_repository.py +0 -0
  25. {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/data/electricitymaps_repository.py +0 -0
  26. {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/data/entsoe_repository.py +0 -0
  27. {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/data/gpu_power.json +0 -0
  28. {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/data/inference_energy.json +0 -0
  29. {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/data/instance_families.json +0 -0
  30. {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/data/network.json +0 -0
  31. {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/data/power.json +0 -0
  32. {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/data/pricing.json +0 -0
  33. {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/data/repository.py +0 -0
  34. {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/data/static_data.py +0 -0
  35. {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/models.py +0 -0
  36. {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/services/__init__.py +0 -0
  37. {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/services/carbon_service.py +0 -0
  38. {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/services/cost_service.py +0 -0
  39. {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/services/forecast_service.py +0 -0
  40. {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/services/inference_service.py +0 -0
  41. {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/services/instance_service.py +0 -0
  42. {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/services/latency_service.py +0 -0
  43. {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/services/locality_service.py +0 -0
  44. {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/services/metric_scaffold_service.py +0 -0
  45. {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/services/recommender.py +0 -0
  46. {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/services/rightsizing_service.py +0 -0
  47. {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/services/scheduling_service.py +0 -0
  48. {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/services/serving_service.py +0 -0
  49. {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick/services/training_service.py +0 -0
  50. {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick.egg-info/dependency_links.txt +0 -0
  51. {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick.egg-info/entry_points.txt +0 -0
  52. {greenpick-0.2.0 → greenpick-0.3.0}/src/greenpick.egg-info/requires.txt +0 -0
  53. {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.2.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) 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:
@@ -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) 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:
@@ -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.2.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.2.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`) -> 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,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
  }