greenpick 0.3.0__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.3.0/src/greenpick.egg-info → greenpick-0.4.0}/PKG-INFO +11 -6
  2. {greenpick-0.3.0 → greenpick-0.4.0}/README.md +10 -5
  3. {greenpick-0.3.0 → greenpick-0.4.0}/pyproject.toml +1 -1
  4. {greenpick-0.3.0 → 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.3.0 → greenpick-0.4.0}/src/greenpick/data/regions.json +12 -3
  7. {greenpick-0.3.0 → greenpick-0.4.0}/src/greenpick/data/static_data.py +9 -0
  8. {greenpick-0.3.0 → greenpick-0.4.0}/src/greenpick/data/typical_repository.py +14 -0
  9. {greenpick-0.3.0 → greenpick-0.4.0}/src/greenpick/server.py +1 -1
  10. {greenpick-0.3.0 → greenpick-0.4.0}/src/greenpick/services/data_hint.py +16 -3
  11. {greenpick-0.3.0 → greenpick-0.4.0}/src/greenpick/services/forecast_service.py +4 -0
  12. {greenpick-0.3.0 → greenpick-0.4.0}/src/greenpick/services/recommender.py +1 -1
  13. {greenpick-0.3.0 → greenpick-0.4.0}/src/greenpick/services/residency_service.py +3 -0
  14. {greenpick-0.3.0 → greenpick-0.4.0}/src/greenpick/services/training_service.py +3 -0
  15. {greenpick-0.3.0 → greenpick-0.4.0/src/greenpick.egg-info}/PKG-INFO +11 -6
  16. {greenpick-0.3.0 → greenpick-0.4.0}/src/greenpick.egg-info/SOURCES.txt +1 -0
  17. {greenpick-0.3.0 → greenpick-0.4.0}/tests/test_services.py +59 -0
  18. {greenpick-0.3.0 → greenpick-0.4.0}/LICENSE +0 -0
  19. {greenpick-0.3.0 → greenpick-0.4.0}/setup.cfg +0 -0
  20. {greenpick-0.3.0 → greenpick-0.4.0}/src/greenpick/__main__.py +0 -0
  21. {greenpick-0.3.0 → greenpick-0.4.0}/src/greenpick/config.py +0 -0
  22. {greenpick-0.3.0 → greenpick-0.4.0}/src/greenpick/data/__init__.py +0 -0
  23. {greenpick-0.3.0 → greenpick-0.4.0}/src/greenpick/data/_net.py +0 -0
  24. {greenpick-0.3.0 → greenpick-0.4.0}/src/greenpick/data/auto_repository.py +0 -0
  25. {greenpick-0.3.0 → greenpick-0.4.0}/src/greenpick/data/canada_repository.py +0 -0
  26. {greenpick-0.3.0 → greenpick-0.4.0}/src/greenpick/data/eia_repository.py +0 -0
  27. {greenpick-0.3.0 → greenpick-0.4.0}/src/greenpick/data/electricitymaps_repository.py +0 -0
  28. {greenpick-0.3.0 → greenpick-0.4.0}/src/greenpick/data/emission_factors.json +0 -0
  29. {greenpick-0.3.0 → greenpick-0.4.0}/src/greenpick/data/entsoe_repository.py +0 -0
  30. {greenpick-0.3.0 → greenpick-0.4.0}/src/greenpick/data/gpu_power.json +0 -0
  31. {greenpick-0.3.0 → greenpick-0.4.0}/src/greenpick/data/inference_energy.json +0 -0
  32. {greenpick-0.3.0 → greenpick-0.4.0}/src/greenpick/data/instance_families.json +0 -0
  33. {greenpick-0.3.0 → greenpick-0.4.0}/src/greenpick/data/mock_repository.py +0 -0
  34. {greenpick-0.3.0 → greenpick-0.4.0}/src/greenpick/data/network.json +0 -0
  35. {greenpick-0.3.0 → greenpick-0.4.0}/src/greenpick/data/power.json +0 -0
  36. {greenpick-0.3.0 → greenpick-0.4.0}/src/greenpick/data/pricing.json +0 -0
  37. {greenpick-0.3.0 → greenpick-0.4.0}/src/greenpick/data/repository.py +0 -0
  38. {greenpick-0.3.0 → greenpick-0.4.0}/src/greenpick/data/typical.json +0 -0
  39. {greenpick-0.3.0 → greenpick-0.4.0}/src/greenpick/models.py +0 -0
  40. {greenpick-0.3.0 → greenpick-0.4.0}/src/greenpick/services/__init__.py +0 -0
  41. {greenpick-0.3.0 → greenpick-0.4.0}/src/greenpick/services/carbon_service.py +0 -0
  42. {greenpick-0.3.0 → greenpick-0.4.0}/src/greenpick/services/cost_service.py +0 -0
  43. {greenpick-0.3.0 → greenpick-0.4.0}/src/greenpick/services/inference_service.py +0 -0
  44. {greenpick-0.3.0 → greenpick-0.4.0}/src/greenpick/services/instance_service.py +0 -0
  45. {greenpick-0.3.0 → greenpick-0.4.0}/src/greenpick/services/latency_service.py +0 -0
  46. {greenpick-0.3.0 → greenpick-0.4.0}/src/greenpick/services/locality_service.py +0 -0
  47. {greenpick-0.3.0 → greenpick-0.4.0}/src/greenpick/services/metric_scaffold_service.py +0 -0
  48. {greenpick-0.3.0 → greenpick-0.4.0}/src/greenpick/services/rightsizing_service.py +0 -0
  49. {greenpick-0.3.0 → greenpick-0.4.0}/src/greenpick/services/scheduling_service.py +0 -0
  50. {greenpick-0.3.0 → greenpick-0.4.0}/src/greenpick/services/serving_service.py +0 -0
  51. {greenpick-0.3.0 → greenpick-0.4.0}/src/greenpick.egg-info/dependency_links.txt +0 -0
  52. {greenpick-0.3.0 → greenpick-0.4.0}/src/greenpick.egg-info/entry_points.txt +0 -0
  53. {greenpick-0.3.0 → greenpick-0.4.0}/src/greenpick.egg-info/requires.txt +0 -0
  54. {greenpick-0.3.0 → greenpick-0.4.0}/src/greenpick.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: greenpick
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Design-time sustainability co-pilot (MCP server): greener, cheaper cloud infrastructure decisions with carbon + cost + latency tradeoffs.
5
5
  Author-email: "Data Science Advisers, LLC" <info@datascienceadvisers.com>
6
6
  License-Expression: MIT
@@ -124,7 +124,7 @@ backfire guards** — is the part nothing else puts together.
124
124
 
125
125
  ## Scope
126
126
 
127
- - Providers: **AWS, GCP, Azure** regions.
127
+ - Providers: **AWS, GCP, Azure** — 25 regions across the US, Europe, Canada, Asia and Australia.
128
128
  - Workloads: long-running **web/API services**, **GPU training** runs, and **deferrable jobs**
129
129
  (ETL / batch / backups) — placement and timing for each.
130
130
  - Carbon = **grid carbon intensity by region** (directional, point-in-time; live US + EU), plus a
@@ -195,8 +195,12 @@ See `docs/user-questions-backlog.md`.
195
195
  intensity and average hour-of-day profile (UTC), computed from 12 months of real hourly EIA-930 /
196
196
  ENTSO-E data (`data/typical.json`, refreshed by `scripts/refresh_typical.py`). Labelled
197
197
  `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).
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).
200
204
  - Cost: a **dated static pricing table** (`data/pricing.json`), refreshed from live Vantage
201
205
  pricing via `scripts/refresh_pricing.py` using one matched method across all three clouds.
202
206
  - Instance-family efficiency: directional perf-per-watt multipliers (`data/instance_families.json`).
@@ -300,8 +304,9 @@ Top-level `await` works in Jupyter. In a plain script, put the `with` block insi
300
304
  and run it with `asyncio.run(...)`.
301
305
 
302
306
  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
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
305
310
  `live_data_hint` saying why (no key set, the source didn't respond, or no free source covers that
306
311
  region) and what to do about it.
307
312
 
@@ -94,7 +94,7 @@ backfire guards** — is the part nothing else puts together.
94
94
 
95
95
  ## Scope
96
96
 
97
- - Providers: **AWS, GCP, Azure** regions.
97
+ - Providers: **AWS, GCP, Azure** — 25 regions across the US, Europe, Canada, Asia and Australia.
98
98
  - Workloads: long-running **web/API services**, **GPU training** runs, and **deferrable jobs**
99
99
  (ETL / batch / backups) — placement and timing for each.
100
100
  - Carbon = **grid carbon intensity by region** (directional, point-in-time; live US + EU), plus a
@@ -165,8 +165,12 @@ See `docs/user-questions-backlog.md`.
165
165
  intensity and average hour-of-day profile (UTC), computed from 12 months of real hourly EIA-930 /
166
166
  ENTSO-E data (`data/typical.json`, refreshed by `scripts/refresh_typical.py`). Labelled
167
167
  `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).
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).
170
174
  - Cost: a **dated static pricing table** (`data/pricing.json`), refreshed from live Vantage
171
175
  pricing via `scripts/refresh_pricing.py` using one matched method across all three clouds.
172
176
  - Instance-family efficiency: directional perf-per-watt multipliers (`data/instance_families.json`).
@@ -270,8 +274,9 @@ Top-level `await` works in Jupyter. In a plain script, put the `with` block insi
270
274
  and run it with `asyncio.run(...)`.
271
275
 
272
276
  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
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
275
280
  `live_data_hint` saying why (no key set, the source didn't respond, or no free source covers that
276
281
  region) and what to do about it.
277
282
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "greenpick"
3
- version = "0.3.0"
3
+ version = "0.4.0"
4
4
  description = "Design-time sustainability co-pilot (MCP server): greener, cheaper cloud infrastructure decisions with carbon + cost + latency tradeoffs."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -4,7 +4,7 @@ Greener cloud-region choices with honest carbon + cost + latency tradeoffs,
4
4
  delivered inside the vibe-coding loop. Directional guidance, not audited accounting.
5
5
  """
6
6
 
7
- __version__ = "0.3.0"
7
+ __version__ = "0.4.0"
8
8
 
9
9
  # httpx logs every request URL at INFO, and our live sources pass their API key / token as a URL
10
10
  # query parameter — so at the default log level the secret would land in the MCP client's logs.
@@ -0,0 +1,13 @@
1
+ {
2
+ "_comment": "Yearly average grid carbon intensity for regions with no free hourly data source. Lifecycle gCO2e/kWh (full lifecycle incl. upstream methane), the same basis as GreenPick's per-fuel factors, so these compare fairly with live/typical regions. National figures: no hourly or sub-national lifecycle data is freely available for these grids. Used with quality='annual_average'; no hourly profile, so no timing advice.",
3
+ "_source": "Ember yearly electricity data (lifecycle CO2 intensity of electricity generation), via Our World in Data grapher 'carbon-intensity-electricity'. CC BY 4.0.",
4
+ "_as_of": "2026-10-27",
5
+ "zones": {
6
+ "SG": { "country": "Singapore", "g_co2_per_kwh": 497.1, "year": 2025,
7
+ "note": "About 95% natural gas; a yearly figure is a fair description of this steady grid." },
8
+ "JP": { "country": "Japan", "g_co2_per_kwh": 477.3, "year": 2025,
9
+ "note": "National average; the Tokyo (TEPCO) area mix differs somewhat and varies by hour." },
10
+ "IN": { "country": "India", "g_co2_per_kwh": 670.1, "year": 2025,
11
+ "note": "National average; Maharashtra (Mumbai, Pune) runs about 6% above it on Ember's state-level data." }
12
+ }
13
+ }
@@ -7,20 +7,29 @@
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
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
+ "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) — sound for comparing regions, no timing signal (no hourly profile). 'mock' = a hand-set illustrative value with a synthetic hourly pattern, used only where nothing else exists (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.",
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.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
@@ -124,7 +124,7 @@ backfire guards** — is the part nothing else puts together.
124
124
 
125
125
  ## Scope
126
126
 
127
- - Providers: **AWS, GCP, Azure** regions.
127
+ - Providers: **AWS, GCP, Azure** — 25 regions across the US, Europe, Canada, Asia and Australia.
128
128
  - Workloads: long-running **web/API services**, **GPU training** runs, and **deferrable jobs**
129
129
  (ETL / batch / backups) — placement and timing for each.
130
130
  - Carbon = **grid carbon intensity by region** (directional, point-in-time; live US + EU), plus a
@@ -195,8 +195,12 @@ See `docs/user-questions-backlog.md`.
195
195
  intensity and average hour-of-day profile (UTC), computed from 12 months of real hourly EIA-930 /
196
196
  ENTSO-E data (`data/typical.json`, refreshed by `scripts/refresh_typical.py`). Labelled
197
197
  `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).
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).
200
204
  - Cost: a **dated static pricing table** (`data/pricing.json`), refreshed from live Vantage
201
205
  pricing via `scripts/refresh_pricing.py` using one matched method across all three clouds.
202
206
  - Instance-family efficiency: directional perf-per-watt multipliers (`data/instance_families.json`).
@@ -300,8 +304,9 @@ Top-level `await` works in Jupyter. In a plain script, put the `with` block insi
300
304
  and run it with `asyncio.run(...)`.
301
305
 
302
306
  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
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
305
310
  `live_data_hint` saying why (no key set, the source didn't respond, or no free source covers that
306
311
  region) and what to do about it.
307
312
 
@@ -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
@@ -1721,3 +1721,62 @@ def test_montreal_is_a_greener_option_for_new_york():
1721
1721
  added = (latency_service.estimate_latency_ms("aws", "ca-central-1", nyc)
1722
1722
  - latency_service.estimate_latency_ms("aws", "us-east-1", nyc))
1723
1723
  assert added < 15 # within an East Coast latency budget
1724
+
1725
+
1726
+ # --- 0.4.0: Asia — Singapore, Tokyo, Mumbai/Pune on sourced yearly figures ---
1727
+
1728
+ from greenpick.data.typical_repository import annual_entry
1729
+
1730
+ _ASIA = [("aws", "ap-southeast-1", "SG"), ("aws", "ap-northeast-1", "JP"), ("aws", "ap-south-1", "IN"),
1731
+ ("gcp", "asia-southeast1", "SG"), ("gcp", "asia-northeast1", "JP"), ("gcp", "asia-south1", "IN"),
1732
+ ("azure", "southeastasia", "SG"), ("azure", "japaneast", "JP"), ("azure", "centralindia", "IN")]
1733
+
1734
+
1735
+ def test_asia_regions_have_a_sourced_yearly_figure():
1736
+ from greenpick.data.static_data import annual, region_meta
1737
+ assert "Ember" in annual()["_source"]
1738
+ for prov, reg, zone in _ASIA:
1739
+ meta = region_meta(prov, reg)
1740
+ assert meta["annual_zone"] == zone and meta["jurisdiction"] == zone
1741
+ e = annual_entry(prov, reg)
1742
+ assert e["year"] >= 2024 and 300 < e["g_co2_per_kwh"] < 900
1743
+
1744
+
1745
+ @pytest.mark.asyncio
1746
+ async def test_asia_reading_is_labelled_annual_and_has_no_hourly_shape():
1747
+ repo = TypicalCarbonRepository()
1748
+ r = await repo.carbon_now("aws", "ap-southeast-1")
1749
+ assert (r.quality, r.source) == ("annual_average", "ember_annual")
1750
+ assert r.g_co2_per_kwh == annual_entry("aws", "ap-southeast-1")["g_co2_per_kwh"]
1751
+ assert await repo.carbon_history("aws", "ap-southeast-1") == [] # never invent a curve
1752
+
1753
+
1754
+ @pytest.mark.asyncio
1755
+ async def test_timing_tools_say_there_is_no_hourly_data_for_asia():
1756
+ from greenpick.services import scheduling_service, forecast_service, training_service
1757
+ repo = TypicalCarbonRepository()
1758
+ tod = await scheduling_service.cleanest_time_of_day(repo, "aws", "ap-northeast-1")
1759
+ assert tod.hours_analyzed == 0 and "No recent hourly history" in tod.note
1760
+ fc = await forecast_service.carbon_forecast(repo, "aws", "ap-northeast-1")
1761
+ assert fc.quality == "annual_average" and "yearly average" in fc.note
1762
+ tr = await training_service.estimate_training_carbon(
1763
+ repo, gpu="a100", gpu_count=8, gpu_hours=40, region="ap-south-1", provider="aws")
1764
+ assert "no hourly data" in tr.rationale and "fairly flat" not in tr.rationale
1765
+
1766
+
1767
+ def test_hint_explains_annual_figures_without_asking_for_a_key(monkeypatch):
1768
+ monkeypatch.setattr(_config, "SETTINGS", _settings(source="auto", eia="k", entsoe="t"))
1769
+ r = {"provider": "gcp", "region": "asia-south1", "quality": "annual_average", "source": "ember_annual"}
1770
+ hint = data_hint.with_hint(r)["live_data_hint"]
1771
+ assert "yearly average grid intensity (Ember, lifecycle basis: India 2025)" in hint
1772
+ assert "no timing signal" in hint
1773
+ assert "set EIA_API_KEY" not in hint and "No free live source covers" not in hint
1774
+
1775
+
1776
+ @pytest.mark.asyncio
1777
+ async def test_asian_users_are_now_served_from_asia():
1778
+ from greenpick.services import serving_service
1779
+ hubs = [ServingHub(name="apac", lat=0, lon=0, share=1.0)]
1780
+ adv = await serving_service.serving_pattern(TypicalCarbonRepository(), hubs, Workload(vcpu=1, ram_gb=2))
1781
+ assert adv.single_region.region in ("ap-southeast-1", "asia-southeast1", "southeastasia")
1782
+ assert adv.single_region.weighted_latency_ms < 40 # was ~199 ms from Sydney
File without changes
File without changes