greenpick 0.3.0__tar.gz → 0.4.1__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.3.0/src/greenpick.egg-info → greenpick-0.4.1}/PKG-INFO +16 -10
  2. {greenpick-0.3.0 → greenpick-0.4.1}/README.md +15 -9
  3. {greenpick-0.3.0 → greenpick-0.4.1}/pyproject.toml +1 -1
  4. {greenpick-0.3.0 → greenpick-0.4.1}/src/greenpick/__init__.py +1 -1
  5. greenpick-0.4.1/src/greenpick/data/annual.json +15 -0
  6. {greenpick-0.3.0 → greenpick-0.4.1}/src/greenpick/data/regions.json +13 -4
  7. {greenpick-0.3.0 → greenpick-0.4.1}/src/greenpick/data/static_data.py +9 -0
  8. {greenpick-0.3.0 → greenpick-0.4.1}/src/greenpick/data/typical_repository.py +14 -0
  9. {greenpick-0.3.0 → greenpick-0.4.1}/src/greenpick/server.py +1 -1
  10. {greenpick-0.3.0 → greenpick-0.4.1}/src/greenpick/services/data_hint.py +16 -3
  11. {greenpick-0.3.0 → greenpick-0.4.1}/src/greenpick/services/forecast_service.py +4 -0
  12. {greenpick-0.3.0 → greenpick-0.4.1}/src/greenpick/services/recommender.py +1 -1
  13. {greenpick-0.3.0 → greenpick-0.4.1}/src/greenpick/services/residency_service.py +3 -0
  14. {greenpick-0.3.0 → greenpick-0.4.1}/src/greenpick/services/training_service.py +3 -0
  15. {greenpick-0.3.0 → greenpick-0.4.1/src/greenpick.egg-info}/PKG-INFO +16 -10
  16. {greenpick-0.3.0 → greenpick-0.4.1}/src/greenpick.egg-info/SOURCES.txt +1 -0
  17. {greenpick-0.3.0 → greenpick-0.4.1}/tests/test_services.py +75 -4
  18. {greenpick-0.3.0 → greenpick-0.4.1}/LICENSE +0 -0
  19. {greenpick-0.3.0 → greenpick-0.4.1}/setup.cfg +0 -0
  20. {greenpick-0.3.0 → greenpick-0.4.1}/src/greenpick/__main__.py +0 -0
  21. {greenpick-0.3.0 → greenpick-0.4.1}/src/greenpick/config.py +0 -0
  22. {greenpick-0.3.0 → greenpick-0.4.1}/src/greenpick/data/__init__.py +0 -0
  23. {greenpick-0.3.0 → greenpick-0.4.1}/src/greenpick/data/_net.py +0 -0
  24. {greenpick-0.3.0 → greenpick-0.4.1}/src/greenpick/data/auto_repository.py +0 -0
  25. {greenpick-0.3.0 → greenpick-0.4.1}/src/greenpick/data/canada_repository.py +0 -0
  26. {greenpick-0.3.0 → greenpick-0.4.1}/src/greenpick/data/eia_repository.py +0 -0
  27. {greenpick-0.3.0 → greenpick-0.4.1}/src/greenpick/data/electricitymaps_repository.py +0 -0
  28. {greenpick-0.3.0 → greenpick-0.4.1}/src/greenpick/data/emission_factors.json +0 -0
  29. {greenpick-0.3.0 → greenpick-0.4.1}/src/greenpick/data/entsoe_repository.py +0 -0
  30. {greenpick-0.3.0 → greenpick-0.4.1}/src/greenpick/data/gpu_power.json +0 -0
  31. {greenpick-0.3.0 → greenpick-0.4.1}/src/greenpick/data/inference_energy.json +0 -0
  32. {greenpick-0.3.0 → greenpick-0.4.1}/src/greenpick/data/instance_families.json +0 -0
  33. {greenpick-0.3.0 → greenpick-0.4.1}/src/greenpick/data/mock_repository.py +0 -0
  34. {greenpick-0.3.0 → greenpick-0.4.1}/src/greenpick/data/network.json +0 -0
  35. {greenpick-0.3.0 → greenpick-0.4.1}/src/greenpick/data/power.json +0 -0
  36. {greenpick-0.3.0 → greenpick-0.4.1}/src/greenpick/data/pricing.json +0 -0
  37. {greenpick-0.3.0 → greenpick-0.4.1}/src/greenpick/data/repository.py +0 -0
  38. {greenpick-0.3.0 → greenpick-0.4.1}/src/greenpick/data/typical.json +0 -0
  39. {greenpick-0.3.0 → greenpick-0.4.1}/src/greenpick/models.py +0 -0
  40. {greenpick-0.3.0 → greenpick-0.4.1}/src/greenpick/services/__init__.py +0 -0
  41. {greenpick-0.3.0 → greenpick-0.4.1}/src/greenpick/services/carbon_service.py +0 -0
  42. {greenpick-0.3.0 → greenpick-0.4.1}/src/greenpick/services/cost_service.py +0 -0
  43. {greenpick-0.3.0 → greenpick-0.4.1}/src/greenpick/services/inference_service.py +0 -0
  44. {greenpick-0.3.0 → greenpick-0.4.1}/src/greenpick/services/instance_service.py +0 -0
  45. {greenpick-0.3.0 → greenpick-0.4.1}/src/greenpick/services/latency_service.py +0 -0
  46. {greenpick-0.3.0 → greenpick-0.4.1}/src/greenpick/services/locality_service.py +0 -0
  47. {greenpick-0.3.0 → greenpick-0.4.1}/src/greenpick/services/metric_scaffold_service.py +0 -0
  48. {greenpick-0.3.0 → greenpick-0.4.1}/src/greenpick/services/rightsizing_service.py +0 -0
  49. {greenpick-0.3.0 → greenpick-0.4.1}/src/greenpick/services/scheduling_service.py +0 -0
  50. {greenpick-0.3.0 → greenpick-0.4.1}/src/greenpick/services/serving_service.py +0 -0
  51. {greenpick-0.3.0 → greenpick-0.4.1}/src/greenpick.egg-info/dependency_links.txt +0 -0
  52. {greenpick-0.3.0 → greenpick-0.4.1}/src/greenpick.egg-info/entry_points.txt +0 -0
  53. {greenpick-0.3.0 → greenpick-0.4.1}/src/greenpick.egg-info/requires.txt +0 -0
  54. {greenpick-0.3.0 → greenpick-0.4.1}/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.0
3
+ Version: 0.4.1
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
@@ -60,11 +60,12 @@ seconds. (It responds when you ask; auto-nudging on infra-code changes is on the
60
60
  > this month's average for each grid, from a year of real data.
61
61
 
62
62
  <p align="center">
63
- <img src="https://raw.githubusercontent.com/bithug01/greenpick/master/docs/img/region-carbon-30x.svg" alt="Same H100 training run, ~30× the carbon depending only on region (Stockholm vs Sydney)" width="560" />
63
+ <img src="https://raw.githubusercontent.com/bithug01/greenpick/master/docs/img/region-carbon-30x.svg" alt="Same H100 training run, ~28× the carbon depending only on region (Stockholm vs Sydney)" width="560" />
64
64
  </p>
65
65
 
66
- *The same 40-GPU-hour training run emits ~0.7 kg CO₂ in Stockholm vs ~19 kg in Sydney — a ~30×
67
- difference from region choice alone. Ask GreenPick and it surfaces that (with the cost and latency
66
+ *The same 40-GPU-hour training run emits ~0.6 kg CO₂ in Stockholm vs ~17.5 kg in Sydney — a ~28×
67
+ difference from region choice alone (typical October grids; Sydney on Ember's 2025 national figure,
68
+ a lower bound for its coal-heavier state grid). Ask GreenPick and it surfaces that (with the cost and latency
68
69
  tradeoff) while you're still in the editor, before you commit.*
69
70
 
70
71
  **▶ [See the 5-minute demo](docs/demo.md)** — three scenarios with real tool output (training placement, the egress backfire, the forecast).
@@ -124,7 +125,7 @@ backfire guards** — is the part nothing else puts together.
124
125
 
125
126
  ## Scope
126
127
 
127
- - Providers: **AWS, GCP, Azure** regions.
128
+ - Providers: **AWS, GCP, Azure** — 25 regions across the US, Europe, Canada, Asia and Australia.
128
129
  - Workloads: long-running **web/API services**, **GPU training** runs, and **deferrable jobs**
129
130
  (ETL / batch / backups) — placement and timing for each.
130
131
  - Carbon = **grid carbon intensity by region** (directional, point-in-time; live US + EU), plus a
@@ -195,8 +196,12 @@ See `docs/user-questions-backlog.md`.
195
196
  intensity and average hour-of-day profile (UTC), computed from 12 months of real hourly EIA-930 /
196
197
  ENTSO-E data (`data/typical.json`, refreshed by `scripts/refresh_typical.py`). Labelled
197
198
  `quality: "typical"` — a realistic norm, never presented as a current reading.
198
- - **Mock** — a hand-set illustrative value, labelled `quality: "mock"`, used only for regions no free
199
- source covers (e.g. Sydney).
199
+ - **Annual** — for Singapore, Tokyo, Mumbai/Pune and Sydney, where no free hourly data exists: the country's
200
+ yearly lifecycle grid intensity from [Ember](https://ember-energy.org/) (via Our World in Data,
201
+ CC BY 4.0), with source and year (`data/annual.json`). Labelled `quality: "annual_average"`; fine for
202
+ comparing regions, but carries no timing signal.
203
+ - **Mock** — a hand-set illustrative value, labelled `quality: "mock"`, kept only as a last resort if
204
+ every other source is missing. No catalog region relies on it.
200
205
  - Cost: a **dated static pricing table** (`data/pricing.json`), refreshed from live Vantage
201
206
  pricing via `scripts/refresh_pricing.py` using one matched method across all three clouds.
202
207
  - Instance-family efficiency: directional perf-per-watt multipliers (`data/instance_families.json`).
@@ -300,8 +305,9 @@ Top-level `await` works in Jupyter. In a plain script, put the `with` block insi
300
305
  and run it with `asyncio.run(...)`.
301
306
 
302
307
  Every carbon figure includes a `quality` field: `live` means a current reading, `typical` means
303
- this month's average from last year's real data, and `mock` means a hand-set value for a region
304
- with no real data. Check it to confirm live data is on. A result that isn't live also includes a
308
+ this month's average from last year's real data, `annual_average` means a sourced yearly national
309
+ figure (regions with no free hourly data), and `mock` means a hand-set value. Check it to confirm
310
+ live data is on. A result that isn't live also includes a
305
311
  `live_data_hint` saying why (no key set, the source didn't respond, or no free source covers that
306
312
  region) and what to do about it.
307
313
 
@@ -313,7 +319,7 @@ carbon, set two free keys (`EIA_API_KEY` for US, `ENTSOE_TOKEN` for EU) and
313
319
  `GREENPICK_CARBON_SOURCE=auto` — see [Turn on live data](#turn-on-live-data).
314
320
 
315
321
  **Why "directional" and not audited numbers?** Because it's for a *design-time decision*, not a
316
- compliance report. "Stockholm is ~30× cleaner than Sydney" is what you need to pick a region in
322
+ compliance report. "Stockholm is ~28× cleaner than Sydney" is what you need to pick a region in
317
323
  seconds — a certified figure is a different job for a different tool (Electricity Maps, WattTime).
318
324
  GreenPick is for *decisions*, not *disclosures*, and says so in every tool's limitations.
319
325
 
@@ -30,11 +30,12 @@ seconds. (It responds when you ask; auto-nudging on infra-code changes is on the
30
30
  > this month's average for each grid, from a year of real data.
31
31
 
32
32
  <p align="center">
33
- <img src="https://raw.githubusercontent.com/bithug01/greenpick/master/docs/img/region-carbon-30x.svg" alt="Same H100 training run, ~30× the carbon depending only on region (Stockholm vs Sydney)" width="560" />
33
+ <img src="https://raw.githubusercontent.com/bithug01/greenpick/master/docs/img/region-carbon-30x.svg" alt="Same H100 training run, ~28× the carbon depending only on region (Stockholm vs Sydney)" width="560" />
34
34
  </p>
35
35
 
36
- *The same 40-GPU-hour training run emits ~0.7 kg CO₂ in Stockholm vs ~19 kg in Sydney — a ~30×
37
- difference from region choice alone. Ask GreenPick and it surfaces that (with the cost and latency
36
+ *The same 40-GPU-hour training run emits ~0.6 kg CO₂ in Stockholm vs ~17.5 kg in Sydney — a ~28×
37
+ difference from region choice alone (typical October grids; Sydney on Ember's 2025 national figure,
38
+ a lower bound for its coal-heavier state grid). Ask GreenPick and it surfaces that (with the cost and latency
38
39
  tradeoff) while you're still in the editor, before you commit.*
39
40
 
40
41
  **▶ [See the 5-minute demo](docs/demo.md)** — three scenarios with real tool output (training placement, the egress backfire, the forecast).
@@ -94,7 +95,7 @@ backfire guards** — is the part nothing else puts together.
94
95
 
95
96
  ## Scope
96
97
 
97
- - Providers: **AWS, GCP, Azure** regions.
98
+ - Providers: **AWS, GCP, Azure** — 25 regions across the US, Europe, Canada, Asia and Australia.
98
99
  - Workloads: long-running **web/API services**, **GPU training** runs, and **deferrable jobs**
99
100
  (ETL / batch / backups) — placement and timing for each.
100
101
  - Carbon = **grid carbon intensity by region** (directional, point-in-time; live US + EU), plus a
@@ -165,8 +166,12 @@ See `docs/user-questions-backlog.md`.
165
166
  intensity and average hour-of-day profile (UTC), computed from 12 months of real hourly EIA-930 /
166
167
  ENTSO-E data (`data/typical.json`, refreshed by `scripts/refresh_typical.py`). Labelled
167
168
  `quality: "typical"` — a realistic norm, never presented as a current reading.
168
- - **Mock** — a hand-set illustrative value, labelled `quality: "mock"`, used only for regions no free
169
- source covers (e.g. Sydney).
169
+ - **Annual** — for Singapore, Tokyo, Mumbai/Pune and Sydney, where no free hourly data exists: the country's
170
+ yearly lifecycle grid intensity from [Ember](https://ember-energy.org/) (via Our World in Data,
171
+ CC BY 4.0), with source and year (`data/annual.json`). Labelled `quality: "annual_average"`; fine for
172
+ comparing regions, but carries no timing signal.
173
+ - **Mock** — a hand-set illustrative value, labelled `quality: "mock"`, kept only as a last resort if
174
+ every other source is missing. No catalog region relies on it.
170
175
  - Cost: a **dated static pricing table** (`data/pricing.json`), refreshed from live Vantage
171
176
  pricing via `scripts/refresh_pricing.py` using one matched method across all three clouds.
172
177
  - Instance-family efficiency: directional perf-per-watt multipliers (`data/instance_families.json`).
@@ -270,8 +275,9 @@ Top-level `await` works in Jupyter. In a plain script, put the `with` block insi
270
275
  and run it with `asyncio.run(...)`.
271
276
 
272
277
  Every carbon figure includes a `quality` field: `live` means a current reading, `typical` means
273
- this month's average from last year's real data, and `mock` means a hand-set value for a region
274
- with no real data. Check it to confirm live data is on. A result that isn't live also includes a
278
+ this month's average from last year's real data, `annual_average` means a sourced yearly national
279
+ figure (regions with no free hourly data), and `mock` means a hand-set value. Check it to confirm
280
+ live data is on. A result that isn't live also includes a
275
281
  `live_data_hint` saying why (no key set, the source didn't respond, or no free source covers that
276
282
  region) and what to do about it.
277
283
 
@@ -283,7 +289,7 @@ carbon, set two free keys (`EIA_API_KEY` for US, `ENTSOE_TOKEN` for EU) and
283
289
  `GREENPICK_CARBON_SOURCE=auto` — see [Turn on live data](#turn-on-live-data).
284
290
 
285
291
  **Why "directional" and not audited numbers?** Because it's for a *design-time decision*, not a
286
- compliance report. "Stockholm is ~30× cleaner than Sydney" is what you need to pick a region in
292
+ compliance report. "Stockholm is ~28× cleaner than Sydney" is what you need to pick a region in
287
293
  seconds — a certified figure is a different job for a different tool (Electricity Maps, WattTime).
288
294
  GreenPick is for *decisions*, not *disclosures*, and says so in every tool's limitations.
289
295
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "greenpick"
3
- version = "0.3.0"
3
+ version = "0.4.1"
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.3.0"
7
+ __version__ = "0.4.1"
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,15 @@
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
+ "AU": { "country": "Australia", "g_co2_per_kwh": 525.2, "year": 2025,
13
+ "note": "National average; New South Wales (Sydney) is more coal-heavy and runs higher, so treat this as a lower bound for Sydney." }
14
+ }
15
+ }
@@ -6,21 +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" },
10
- "ca-central-1": { "name": "Montreal", "lat": 45.50, "lon": -73.57, "zone": "CA-QC", "ca_source": "hq", "jurisdiction": "CA" }
9
+ "ap-southeast-2": { "name": "Sydney", "lat": -33.87, "lon": 151.21, "zone": "AU-NSW", "annual_zone": "AU", "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" }
11
14
  },
12
15
  "gcp": {
13
16
  "us-central1": { "name": "Iowa", "lat": 41.26, "lon": -95.86, "zone": "US-MIDW-MISO", "eia_ba": "MISO", "jurisdiction": "US" },
14
17
  "europe-north1": { "name": "Finland", "lat": 60.57, "lon": 27.19, "zone": "FI", "entsoe_eic": "10YFI-1--------U", "jurisdiction": "EEA" },
15
18
  "us-west1": { "name": "Oregon", "lat": 45.60, "lon": -121.18, "zone": "US-NW-PACW", "eia_ba": "BPAT", "jurisdiction": "US" },
16
19
  "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" }
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" }
18
24
  },
19
25
  "azure": {
20
26
  "eastus": { "name": "Virginia", "lat": 37.37, "lon": -79.82, "zone": "US-MIDA-PJM", "eia_ba": "PJM", "jurisdiction": "US" },
21
27
  "northeurope": { "name": "Ireland", "lat": 53.35, "lon": -6.26, "zone": "IE", "entsoe_eic": "10Y1001A1001A59C", "jurisdiction": "EEA" },
22
28
  "swedencentral": { "name": "Sweden", "lat": 60.67, "lon": 17.14, "zone": "SE", "entsoe_eic": "10YSE-1--------K", "jurisdiction": "EEA" },
23
29
  "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" }
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" }
25
34
  }
26
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."""
@@ -77,6 +77,13 @@ def typical_context(provider: str, region: str, g_co2_per_kwh: float, timestamp:
77
77
  }
78
78
 
79
79
 
80
+ def annual_entry(provider: str, region: str) -> Optional[dict]:
81
+ """The annual.json zone for a region with no free hourly source (country, g, year), or None."""
82
+ from .static_data import annual, region_meta
83
+ zone = (region_meta(provider, region) or {}).get("annual_zone") # type: ignore[arg-type]
84
+ return annual().get("zones", {}).get(zone) if zone else None
85
+
86
+
80
87
  class TypicalCarbonRepository:
81
88
  def __init__(self) -> None:
82
89
  self._mock = MockCarbonRepository()
@@ -85,6 +92,11 @@ class TypicalCarbonRepository:
85
92
  entry = typical_entry(provider, region)
86
93
  month = _month(entry) if entry else None
87
94
  if not month:
95
+ yearly = annual_entry(provider, region)
96
+ if yearly:
97
+ return CarbonReading(region=region, provider=provider,
98
+ g_co2_per_kwh=yearly["g_co2_per_kwh"], source="ember_annual",
99
+ timestamp=str(yearly["year"]), quality="annual_average")
88
100
  return await self._mock.carbon_now(provider, region)
89
101
  return CarbonReading(
90
102
  region=region,
@@ -101,6 +113,8 @@ class TypicalCarbonRepository:
101
113
  entry = typical_entry(provider, region)
102
114
  month = _month(entry) if entry else None
103
115
  if not month:
116
+ if annual_entry(provider, region):
117
+ return [] # a yearly figure has no hourly shape — don't invent one
104
118
  return await self._mock.carbon_history(provider, region)
105
119
  return [HourlyCarbon(period=f"H{h:02d}Z", g_co2_per_kwh=g)
106
120
  for h, g in enumerate(month["hourly_utc"]) if g is not None]
@@ -381,6 +381,6 @@ def explain_methodology_and_limits() -> dict:
381
381
  "forecast": "Day-ahead forecast is RELATIVE: upcoming hours ranked by forecast wind+solar output (ENTSO-E A69), not an absolute gCO2/kWh forecast. EU only; US/uncovered zones degrade to the recent historical pattern (labelled 'historical').",
382
382
  "data_residency": "GreenPick flags when a recommended move CROSSES a jurisdiction (esp. leaving the EEA) as a geographic heads-up only. It does NOT assess legal/GDPR compliance, does not encode adequacy decisions, and is not legal advice — confirm with your own data governance.",
383
383
  "ignored": "Embodied/hardware carbon and provider renewable-energy certificates.",
384
- "fallback_data": "Used when live data isn't available (no key, the source didn't respond, or GreenPick isn't set to live). 'typical' = this calendar month's average intensity and average hour-of-day profile (UTC) for the region, computed from 12 months of real hourly EIA-930 / ENTSO-E data with GreenPick's own emission factors (bundled typical.json, refreshed by scripts/refresh_typical.py) — a realistic norm, not a current reading. 'mock' = a hand-set illustrative value with a synthetic hourly pattern, used only for regions with no free data source (e.g. Sydney); timing advice on mock isn't meaningful. Both carry a live_data_hint saying why the result isn't live and how to turn live data on.",
384
+ "fallback_data": "Used when live data isn't available (no key, the source didn't respond, or GreenPick isn't set to live). 'typical' = this calendar month's average intensity and average hour-of-day profile (UTC) for the region, computed from 12 months of real hourly EIA-930 / ENTSO-E data with GreenPick's own emission factors (bundled typical.json, refreshed by scripts/refresh_typical.py) — a realistic norm, not a current reading. 'annual_average' = the country's yearly lifecycle grid intensity from Ember (via Our World in Data; bundled annual.json with source and year), for regions with no free hourly data (Singapore, Tokyo, Mumbai/Pune, Sydney — national figures, so Sydney's is a lower bound for its coal-heavier state) — sound for comparing regions, no timing signal (no hourly profile). 'mock' = a hand-set illustrative value with a synthetic hourly pattern, a last resort no catalog region relies on; timing advice on mock isn't meaningful. Both carry a live_data_hint saying why the result isn't live and how to turn live data on.",
385
385
  "data_sources": "GREENPICK_CARBON_SOURCE=auto routes US regions to EIA-930 and EU regions to ENTSO-E (both free, key needed), and Canadian regions to Hydro-Québec (Québec) and IESO (Ontario) open data (free, no key). All mixes are weighted with the same per-fuel factors. Without live data a region uses its clearly-labelled typical value (or mock where no free source exists). Electricity Maps is available when selected explicitly (GREENPICK_CARBON_SOURCE=electricitymaps).",
386
386
  }
@@ -53,13 +53,13 @@ def _walk(obj: Any) -> Iterator[dict]:
53
53
  yield from _walk(v)
54
54
 
55
55
 
56
- _FALLBACK = ("typical", "mock")
56
+ _FALLBACK = ("typical", "annual_average", "mock")
57
57
 
58
58
 
59
59
  def _scan(result: dict) -> tuple[dict[str, set[tuple[str, str]]], bool, set[str]]:
60
60
  """For each fallback kind ('typical' / 'mock'): the regions whose figures use it; whether any
61
61
  such figure had no locatable region; and which kinds fed timing advice."""
62
- regions: dict[str, set[tuple[str, str]]] = {k: set() for k in _FALLBACK}
62
+ regions: dict[str, set[tuple[str, str]]] = {k: set() for k in _FALLBACK} # noqa: C420
63
63
  unlocated = False
64
64
  timing: set[str] = set()
65
65
  for d in _walk(result):
@@ -117,15 +117,26 @@ def _typical_sentence(locs: set[tuple[str, str]]) -> str:
117
117
  f"real hourly data{span} — a realistic norm, not a current reading.")
118
118
 
119
119
 
120
+ def _annual_sentence(locs: set[tuple[str, str]]) -> str:
121
+ from ..data.typical_repository import annual_entry
122
+ found = sorted({(e["country"], e["year"]) for e in (annual_entry(p, r) for p, r in locs) if e})
123
+ which = ", ".join(f"{c} {y}" for c, y in found)
124
+ return (f"Annual figures here ({_names(list(locs))}) are the country's yearly average grid intensity "
125
+ f"(Ember, lifecycle basis{': ' + which if which else ''}) — no free hourly data exists for "
126
+ "these grids, so they're a sound basis for comparing regions but carry no timing signal.")
127
+
128
+
120
129
  def build_hint(result: dict) -> Optional[str]:
121
130
  by_kind, unlocated, timing = _scan(result)
122
- all_locs = by_kind["typical"] | by_kind["mock"]
131
+ all_locs = by_kind["typical"] | by_kind["annual_average"] | by_kind["mock"]
123
132
  if not all_locs and not unlocated:
124
133
  return None
125
134
 
126
135
  parts = []
127
136
  if by_kind["typical"]:
128
137
  parts.append(_typical_sentence(by_kind["typical"]))
138
+ if by_kind["annual_average"]:
139
+ parts.append(_annual_sentence(by_kind["annual_average"]))
129
140
  if by_kind["mock"] or (unlocated and not by_kind["typical"]):
130
141
  where = f" for {_names(list(by_kind['mock']))}" if by_kind["mock"] else ""
131
142
  parts.append(f"Mock figures{where} are a hand-set illustrative value for the grid, used where "
@@ -155,6 +166,8 @@ def build_hint(result: dict) -> Optional[str]:
155
166
  f"{_names(locs) or 'this region'}. Use GREENPICK_CARBON_SOURCE=auto to route "
156
167
  "each region to its own live source.")
157
168
  elif reason == "uncovered":
169
+ if all(loc in by_kind["annual_average"] for loc in locs):
170
+ continue # already explained by the annual sentence; no action to suggest
158
171
  parts.append(f"No free live source covers {_names(locs)} yet, so GreenPick uses its "
159
172
  "fallback there.")
160
173
  if "typical" in timing:
@@ -67,6 +67,10 @@ async def carbon_forecast(
67
67
  if pattern.cleanest_hour_of_day is not None:
68
68
  note += (f"A typical day here is cleanest around {pattern.cleanest_hour_of_day:02d}:00 "
69
69
  f"UTC - a reasonable default for a recurring job.")
70
+ elif pattern.quality == "annual_average":
71
+ source, quality = "ember_annual", "annual_average"
72
+ note = ("No day-ahead forecast or hourly data exists for this region — only a yearly "
73
+ "average — so GreenPick can't say which hours are cleaner here.")
70
74
  elif pattern.quality == "mock":
71
75
  # No live history either: the "pattern" is GreenPick's illustrative mock curve. Label it
72
76
  # mock — calling it 'historical' would pass a synthetic shape off as real past data.
@@ -146,7 +146,7 @@ async def compare_regions(
146
146
  if greener_improvement < MEANINGFUL_PCT:
147
147
  rationale = (
148
148
  f"Staying on {baseline.provider}/{baseline.region} is reasonable — no candidate "
149
- f"region is meaningfully greener (all within ~{MEANINGFUL_PCT:.0f}%)."
149
+ f"region is meaningfully greener (none is more than ~{MEANINGFUL_PCT:.0f}% cleaner)."
150
150
  )
151
151
  else:
152
152
  extra_lat = greenest.added_latency_ms
@@ -22,6 +22,9 @@ _LABEL = {
22
22
  "US": "the United States",
23
23
  "AU": "Australia",
24
24
  "CA": "Canada",
25
+ "SG": "Singapore",
26
+ "JP": "Japan",
27
+ "IN": "India",
25
28
  }
26
29
 
27
30
  DISCLAIMER = ("GreenPick does NOT assess legal compliance - this is a geographic heads-up only. "
@@ -186,6 +186,9 @@ async def estimate_training_carbon(
186
186
  f"deferrable, so this stacks on the region choice."
187
187
  + scheduling_service.small_gap_note(advice.cleanest_g_co2_per_kwh or 0,
188
188
  advice.dirtiest_g_co2_per_kwh or 0))
189
+ elif advice.hours_analyzed == 0:
190
+ time_txt = (" There's no hourly data for that region (only a yearly average), so GreenPick "
191
+ "can't advise on timing there — the region choice is the lever.")
189
192
  else:
190
193
  time_txt = (" That region's grid is fairly flat over the window, so when you run matters "
191
194
  "little there — the region choice is the lever.")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: greenpick
3
- Version: 0.3.0
3
+ Version: 0.4.1
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
@@ -60,11 +60,12 @@ seconds. (It responds when you ask; auto-nudging on infra-code changes is on the
60
60
  > this month's average for each grid, from a year of real data.
61
61
 
62
62
  <p align="center">
63
- <img src="https://raw.githubusercontent.com/bithug01/greenpick/master/docs/img/region-carbon-30x.svg" alt="Same H100 training run, ~30× the carbon depending only on region (Stockholm vs Sydney)" width="560" />
63
+ <img src="https://raw.githubusercontent.com/bithug01/greenpick/master/docs/img/region-carbon-30x.svg" alt="Same H100 training run, ~28× the carbon depending only on region (Stockholm vs Sydney)" width="560" />
64
64
  </p>
65
65
 
66
- *The same 40-GPU-hour training run emits ~0.7 kg CO₂ in Stockholm vs ~19 kg in Sydney — a ~30×
67
- difference from region choice alone. Ask GreenPick and it surfaces that (with the cost and latency
66
+ *The same 40-GPU-hour training run emits ~0.6 kg CO₂ in Stockholm vs ~17.5 kg in Sydney — a ~28×
67
+ difference from region choice alone (typical October grids; Sydney on Ember's 2025 national figure,
68
+ a lower bound for its coal-heavier state grid). Ask GreenPick and it surfaces that (with the cost and latency
68
69
  tradeoff) while you're still in the editor, before you commit.*
69
70
 
70
71
  **▶ [See the 5-minute demo](docs/demo.md)** — three scenarios with real tool output (training placement, the egress backfire, the forecast).
@@ -124,7 +125,7 @@ backfire guards** — is the part nothing else puts together.
124
125
 
125
126
  ## Scope
126
127
 
127
- - Providers: **AWS, GCP, Azure** regions.
128
+ - Providers: **AWS, GCP, Azure** — 25 regions across the US, Europe, Canada, Asia and Australia.
128
129
  - Workloads: long-running **web/API services**, **GPU training** runs, and **deferrable jobs**
129
130
  (ETL / batch / backups) — placement and timing for each.
130
131
  - Carbon = **grid carbon intensity by region** (directional, point-in-time; live US + EU), plus a
@@ -195,8 +196,12 @@ See `docs/user-questions-backlog.md`.
195
196
  intensity and average hour-of-day profile (UTC), computed from 12 months of real hourly EIA-930 /
196
197
  ENTSO-E data (`data/typical.json`, refreshed by `scripts/refresh_typical.py`). Labelled
197
198
  `quality: "typical"` — a realistic norm, never presented as a current reading.
198
- - **Mock** — a hand-set illustrative value, labelled `quality: "mock"`, used only for regions no free
199
- source covers (e.g. Sydney).
199
+ - **Annual** — for Singapore, Tokyo, Mumbai/Pune and Sydney, where no free hourly data exists: the country's
200
+ yearly lifecycle grid intensity from [Ember](https://ember-energy.org/) (via Our World in Data,
201
+ CC BY 4.0), with source and year (`data/annual.json`). Labelled `quality: "annual_average"`; fine for
202
+ comparing regions, but carries no timing signal.
203
+ - **Mock** — a hand-set illustrative value, labelled `quality: "mock"`, kept only as a last resort if
204
+ every other source is missing. No catalog region relies on it.
200
205
  - Cost: a **dated static pricing table** (`data/pricing.json`), refreshed from live Vantage
201
206
  pricing via `scripts/refresh_pricing.py` using one matched method across all three clouds.
202
207
  - Instance-family efficiency: directional perf-per-watt multipliers (`data/instance_families.json`).
@@ -300,8 +305,9 @@ Top-level `await` works in Jupyter. In a plain script, put the `with` block insi
300
305
  and run it with `asyncio.run(...)`.
301
306
 
302
307
  Every carbon figure includes a `quality` field: `live` means a current reading, `typical` means
303
- this month's average from last year's real data, and `mock` means a hand-set value for a region
304
- with no real data. Check it to confirm live data is on. A result that isn't live also includes a
308
+ this month's average from last year's real data, `annual_average` means a sourced yearly national
309
+ figure (regions with no free hourly data), and `mock` means a hand-set value. Check it to confirm
310
+ live data is on. A result that isn't live also includes a
305
311
  `live_data_hint` saying why (no key set, the source didn't respond, or no free source covers that
306
312
  region) and what to do about it.
307
313
 
@@ -313,7 +319,7 @@ carbon, set two free keys (`EIA_API_KEY` for US, `ENTSOE_TOKEN` for EU) and
313
319
  `GREENPICK_CARBON_SOURCE=auto` — see [Turn on live data](#turn-on-live-data).
314
320
 
315
321
  **Why "directional" and not audited numbers?** Because it's for a *design-time decision*, not a
316
- compliance report. "Stockholm is ~30× cleaner than Sydney" is what you need to pick a region in
322
+ compliance report. "Stockholm is ~28× cleaner than Sydney" is what you need to pick a region in
317
323
  seconds — a certified figure is a different job for a different tool (Electricity Maps, WattTime).
318
324
  GreenPick is for *decisions*, not *disclosures*, and says so in every tool's limitations.
319
325
 
@@ -14,6 +14,7 @@ src/greenpick.egg-info/requires.txt
14
14
  src/greenpick.egg-info/top_level.txt
15
15
  src/greenpick/data/__init__.py
16
16
  src/greenpick/data/_net.py
17
+ src/greenpick/data/annual.json
17
18
  src/greenpick/data/auto_repository.py
18
19
  src/greenpick/data/canada_repository.py
19
20
  src/greenpick/data/eia_repository.py
@@ -348,10 +348,10 @@ async def test_auto_router_picks_source_by_region(monkeypatch):
348
348
  repo = AutoRepository(eia_key="k", entsoe_token="t")
349
349
  us = await repo.carbon_now("aws", "us-east-1") # -> EIA
350
350
  eu = await repo.carbon_now("aws", "eu-north-1") # -> ENTSO-E
351
- syd = await repo.carbon_now("aws", "ap-southeast-2") # -> mock
351
+ syd = await repo.carbon_now("aws", "ap-southeast-2") # -> no live source: sourced yearly figure
352
352
  assert us.source == "eia"
353
353
  assert eu.source == "entsoe"
354
- assert syd.quality == "mock"
354
+ assert syd.quality == "annual_average"
355
355
 
356
356
 
357
357
  @pytest.mark.asyncio
@@ -1494,14 +1494,26 @@ async def test_typical_reading_is_this_months_real_average():
1494
1494
 
1495
1495
 
1496
1496
  @pytest.mark.asyncio
1497
- async def test_uncovered_region_still_falls_back_to_mock():
1497
+ async def test_mock_is_only_the_last_resort(monkeypatch):
1498
+ from greenpick.data import typical_repository as tr
1498
1499
  repo = TypicalCarbonRepository()
1500
+ # Sydney has no typical data but a sourced yearly figure -> annual, not mock.
1499
1501
  r = await repo.carbon_now("aws", "ap-southeast-2")
1500
- assert r.quality == "mock"
1502
+ assert r.quality == "annual_average" and r.g_co2_per_kwh == 525.2
1503
+ # With no yearly figure either, mock still answers (labelled) rather than failing.
1504
+ monkeypatch.setattr(tr, "annual_entry", lambda p, reg: None)
1505
+ m = await repo.carbon_now("aws", "ap-southeast-2")
1506
+ assert m.quality == "mock"
1501
1507
  hist = await repo.carbon_history("aws", "ap-southeast-2")
1502
1508
  assert hist and hist[0].period.startswith("H") and not hist[0].period.endswith("Z")
1503
1509
 
1504
1510
 
1511
+ def test_no_catalog_region_relies_on_mock():
1512
+ from greenpick.data.static_data import iter_regions
1513
+ for prov, reg, meta in iter_regions():
1514
+ assert typical_entry(prov, reg) or annual_entry(prov, reg), f"{prov}/{reg} would be mock"
1515
+
1516
+
1505
1517
  @pytest.mark.asyncio
1506
1518
  async def test_typical_history_is_the_months_real_profile_in_utc():
1507
1519
  from greenpick.services import scheduling_service as ss
@@ -1721,3 +1733,62 @@ def test_montreal_is_a_greener_option_for_new_york():
1721
1733
  added = (latency_service.estimate_latency_ms("aws", "ca-central-1", nyc)
1722
1734
  - latency_service.estimate_latency_ms("aws", "us-east-1", nyc))
1723
1735
  assert added < 15 # within an East Coast latency budget
1736
+
1737
+
1738
+ # --- 0.4.0: Asia — Singapore, Tokyo, Mumbai/Pune on sourced yearly figures ---
1739
+
1740
+ from greenpick.data.typical_repository import annual_entry
1741
+
1742
+ _ASIA = [("aws", "ap-southeast-1", "SG"), ("aws", "ap-northeast-1", "JP"), ("aws", "ap-south-1", "IN"),
1743
+ ("gcp", "asia-southeast1", "SG"), ("gcp", "asia-northeast1", "JP"), ("gcp", "asia-south1", "IN"),
1744
+ ("azure", "southeastasia", "SG"), ("azure", "japaneast", "JP"), ("azure", "centralindia", "IN")]
1745
+
1746
+
1747
+ def test_asia_regions_have_a_sourced_yearly_figure():
1748
+ from greenpick.data.static_data import annual, region_meta
1749
+ assert "Ember" in annual()["_source"]
1750
+ for prov, reg, zone in _ASIA:
1751
+ meta = region_meta(prov, reg)
1752
+ assert meta["annual_zone"] == zone and meta["jurisdiction"] == zone
1753
+ e = annual_entry(prov, reg)
1754
+ assert e["year"] >= 2024 and 300 < e["g_co2_per_kwh"] < 900
1755
+
1756
+
1757
+ @pytest.mark.asyncio
1758
+ async def test_asia_reading_is_labelled_annual_and_has_no_hourly_shape():
1759
+ repo = TypicalCarbonRepository()
1760
+ r = await repo.carbon_now("aws", "ap-southeast-1")
1761
+ assert (r.quality, r.source) == ("annual_average", "ember_annual")
1762
+ assert r.g_co2_per_kwh == annual_entry("aws", "ap-southeast-1")["g_co2_per_kwh"]
1763
+ assert await repo.carbon_history("aws", "ap-southeast-1") == [] # never invent a curve
1764
+
1765
+
1766
+ @pytest.mark.asyncio
1767
+ async def test_timing_tools_say_there_is_no_hourly_data_for_asia():
1768
+ from greenpick.services import scheduling_service, forecast_service, training_service
1769
+ repo = TypicalCarbonRepository()
1770
+ tod = await scheduling_service.cleanest_time_of_day(repo, "aws", "ap-northeast-1")
1771
+ assert tod.hours_analyzed == 0 and "No recent hourly history" in tod.note
1772
+ fc = await forecast_service.carbon_forecast(repo, "aws", "ap-northeast-1")
1773
+ assert fc.quality == "annual_average" and "yearly average" in fc.note
1774
+ tr = await training_service.estimate_training_carbon(
1775
+ repo, gpu="a100", gpu_count=8, gpu_hours=40, region="ap-south-1", provider="aws")
1776
+ assert "no hourly data" in tr.rationale and "fairly flat" not in tr.rationale
1777
+
1778
+
1779
+ def test_hint_explains_annual_figures_without_asking_for_a_key(monkeypatch):
1780
+ monkeypatch.setattr(_config, "SETTINGS", _settings(source="auto", eia="k", entsoe="t"))
1781
+ r = {"provider": "gcp", "region": "asia-south1", "quality": "annual_average", "source": "ember_annual"}
1782
+ hint = data_hint.with_hint(r)["live_data_hint"]
1783
+ assert "yearly average grid intensity (Ember, lifecycle basis: India 2025)" in hint
1784
+ assert "no timing signal" in hint
1785
+ assert "set EIA_API_KEY" not in hint and "No free live source covers" not in hint
1786
+
1787
+
1788
+ @pytest.mark.asyncio
1789
+ async def test_asian_users_are_now_served_from_asia():
1790
+ from greenpick.services import serving_service
1791
+ hubs = [ServingHub(name="apac", lat=0, lon=0, share=1.0)]
1792
+ adv = await serving_service.serving_pattern(TypicalCarbonRepository(), hubs, Workload(vcpu=1, ram_gb=2))
1793
+ assert adv.single_region.region in ("ap-southeast-1", "asia-southeast1", "southeastasia")
1794
+ assert adv.single_region.weighted_latency_ms < 40 # was ~199 ms from Sydney
File without changes
File without changes