greenpick 0.4.2__tar.gz → 0.5.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 (56) hide show
  1. {greenpick-0.4.2/src/greenpick.egg-info → greenpick-0.5.1}/PKG-INFO +6 -4
  2. {greenpick-0.4.2 → greenpick-0.5.1}/README.md +5 -3
  3. {greenpick-0.4.2 → greenpick-0.5.1}/pyproject.toml +1 -1
  4. {greenpick-0.4.2 → greenpick-0.5.1}/src/greenpick/__init__.py +1 -1
  5. {greenpick-0.4.2 → greenpick-0.5.1}/src/greenpick/models.py +30 -0
  6. {greenpick-0.4.2 → greenpick-0.5.1}/src/greenpick/server.py +51 -5
  7. {greenpick-0.4.2 → greenpick-0.5.1}/src/greenpick/services/locality_service.py +7 -4
  8. greenpick-0.5.1/src/greenpick/services/offhours_service.py +82 -0
  9. greenpick-0.5.1/src/greenpick/services/typical_ranking_service.py +106 -0
  10. {greenpick-0.4.2 → greenpick-0.5.1/src/greenpick.egg-info}/PKG-INFO +6 -4
  11. {greenpick-0.4.2 → greenpick-0.5.1}/src/greenpick.egg-info/SOURCES.txt +2 -0
  12. {greenpick-0.4.2 → greenpick-0.5.1}/tests/test_services.py +1897 -1846
  13. {greenpick-0.4.2 → greenpick-0.5.1}/LICENSE +0 -0
  14. {greenpick-0.4.2 → greenpick-0.5.1}/setup.cfg +0 -0
  15. {greenpick-0.4.2 → greenpick-0.5.1}/src/greenpick/__main__.py +0 -0
  16. {greenpick-0.4.2 → greenpick-0.5.1}/src/greenpick/config.py +0 -0
  17. {greenpick-0.4.2 → greenpick-0.5.1}/src/greenpick/data/__init__.py +0 -0
  18. {greenpick-0.4.2 → greenpick-0.5.1}/src/greenpick/data/_net.py +0 -0
  19. {greenpick-0.4.2 → greenpick-0.5.1}/src/greenpick/data/annual.json +0 -0
  20. {greenpick-0.4.2 → greenpick-0.5.1}/src/greenpick/data/auto_repository.py +0 -0
  21. {greenpick-0.4.2 → greenpick-0.5.1}/src/greenpick/data/canada_repository.py +0 -0
  22. {greenpick-0.4.2 → greenpick-0.5.1}/src/greenpick/data/eia_repository.py +0 -0
  23. {greenpick-0.4.2 → greenpick-0.5.1}/src/greenpick/data/electricitymaps_repository.py +0 -0
  24. {greenpick-0.4.2 → greenpick-0.5.1}/src/greenpick/data/emission_factors.json +0 -0
  25. {greenpick-0.4.2 → greenpick-0.5.1}/src/greenpick/data/entsoe_repository.py +0 -0
  26. {greenpick-0.4.2 → greenpick-0.5.1}/src/greenpick/data/gpu_power.json +0 -0
  27. {greenpick-0.4.2 → greenpick-0.5.1}/src/greenpick/data/inference_energy.json +0 -0
  28. {greenpick-0.4.2 → greenpick-0.5.1}/src/greenpick/data/instance_families.json +0 -0
  29. {greenpick-0.4.2 → greenpick-0.5.1}/src/greenpick/data/mock_repository.py +0 -0
  30. {greenpick-0.4.2 → greenpick-0.5.1}/src/greenpick/data/network.json +0 -0
  31. {greenpick-0.4.2 → greenpick-0.5.1}/src/greenpick/data/power.json +0 -0
  32. {greenpick-0.4.2 → greenpick-0.5.1}/src/greenpick/data/pricing.json +0 -0
  33. {greenpick-0.4.2 → greenpick-0.5.1}/src/greenpick/data/regions.json +0 -0
  34. {greenpick-0.4.2 → greenpick-0.5.1}/src/greenpick/data/repository.py +0 -0
  35. {greenpick-0.4.2 → greenpick-0.5.1}/src/greenpick/data/static_data.py +0 -0
  36. {greenpick-0.4.2 → greenpick-0.5.1}/src/greenpick/data/typical.json +0 -0
  37. {greenpick-0.4.2 → greenpick-0.5.1}/src/greenpick/data/typical_repository.py +0 -0
  38. {greenpick-0.4.2 → greenpick-0.5.1}/src/greenpick/services/__init__.py +0 -0
  39. {greenpick-0.4.2 → greenpick-0.5.1}/src/greenpick/services/carbon_service.py +0 -0
  40. {greenpick-0.4.2 → greenpick-0.5.1}/src/greenpick/services/cost_service.py +0 -0
  41. {greenpick-0.4.2 → greenpick-0.5.1}/src/greenpick/services/data_hint.py +0 -0
  42. {greenpick-0.4.2 → greenpick-0.5.1}/src/greenpick/services/forecast_service.py +0 -0
  43. {greenpick-0.4.2 → greenpick-0.5.1}/src/greenpick/services/inference_service.py +0 -0
  44. {greenpick-0.4.2 → greenpick-0.5.1}/src/greenpick/services/instance_service.py +0 -0
  45. {greenpick-0.4.2 → greenpick-0.5.1}/src/greenpick/services/latency_service.py +0 -0
  46. {greenpick-0.4.2 → greenpick-0.5.1}/src/greenpick/services/metric_scaffold_service.py +0 -0
  47. {greenpick-0.4.2 → greenpick-0.5.1}/src/greenpick/services/recommender.py +0 -0
  48. {greenpick-0.4.2 → greenpick-0.5.1}/src/greenpick/services/residency_service.py +0 -0
  49. {greenpick-0.4.2 → greenpick-0.5.1}/src/greenpick/services/rightsizing_service.py +0 -0
  50. {greenpick-0.4.2 → greenpick-0.5.1}/src/greenpick/services/scheduling_service.py +0 -0
  51. {greenpick-0.4.2 → greenpick-0.5.1}/src/greenpick/services/serving_service.py +0 -0
  52. {greenpick-0.4.2 → greenpick-0.5.1}/src/greenpick/services/training_service.py +0 -0
  53. {greenpick-0.4.2 → greenpick-0.5.1}/src/greenpick.egg-info/dependency_links.txt +0 -0
  54. {greenpick-0.4.2 → greenpick-0.5.1}/src/greenpick.egg-info/entry_points.txt +0 -0
  55. {greenpick-0.4.2 → greenpick-0.5.1}/src/greenpick.egg-info/requires.txt +0 -0
  56. {greenpick-0.4.2 → greenpick-0.5.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.4.2
3
+ Version: 0.5.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
@@ -44,7 +44,7 @@ Dynamic: license-file
44
44
  <img src="https://img.shields.io/badge/license-MIT-green" alt="License: MIT" />
45
45
  <img src="https://img.shields.io/badge/tests-passing-brightgreen" alt="Tests: passing" />
46
46
  <img src="https://img.shields.io/badge/python-3.11%2B-blue" alt="Python 3.11+" />
47
- <img src="https://img.shields.io/badge/MCP%20tools-15-0ea5e9" alt="MCP tools: 15" />
47
+ <img src="https://img.shields.io/badge/MCP%20tools-17-0ea5e9" alt="MCP tools: 17" />
48
48
  </p>
49
49
 
50
50
  A **design-time sustainability co-pilot** that runs as an MCP server inside your vibe-coding
@@ -52,7 +52,7 @@ 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), EU (ENTSO-E) and Canada (Hydro-Québec,
55
+ > **Status: working — 17 tools.** Live carbon for US (EIA-930), EU (ENTSO-E) and Canada (Hydro-Québec,
56
56
  > IESO — no key needed), cross-cloud cost
57
57
  > parity (Vantage-refreshed), per-axis R/Y/G ratings, carbon-aware scheduling + day-ahead forecast,
58
58
  > GPU-training **and LLM-inference** carbon, right-sizing, serving-pattern and data-locality/residency
@@ -143,7 +143,7 @@ See `docs/user-questions-backlog.md`.
143
143
 
144
144
  ## MCP tools
145
145
 
146
- 15 tools, grouped by what you're trying to do.
146
+ 17 tools, grouped by what you're trying to do.
147
147
 
148
148
  ### Pick a region
149
149
 
@@ -159,6 +159,7 @@ See `docs/user-questions-backlog.md`.
159
159
  |------|--------------|
160
160
  | `cleanest_hours_to_run` | Cleanest recent hours to time a deferrable job (ETL, backups, training), + % reduction. Pass `window_hours` for a job of known duration → cleanest contiguous window to start at. |
161
161
  | `cleanest_time_of_day` | For a **recurring** job (nightly batch): the region's cleanest time of day, averaged across recent days. A daily pattern, not a 7-day weekly one. |
162
+ | `greenest_regions_on_average` | Which regions are cleanest on a **typical** day in a given month (or the whole year), ranked from last year's real data. Offline, no keys; a planning view, not today's grid. |
162
163
  | `carbon_forecast` | Forward-looking: which **upcoming** hours will be cleanest, from the EU day-ahead wind+solar forecast (ENTSO-E). Relative ranking; US/uncovered zones degrade to the recent historical pattern. |
163
164
 
164
165
  ### Estimate a workload's carbon
@@ -175,6 +176,7 @@ See `docs/user-questions-backlog.md`.
175
176
  |------|--------------|
176
177
  | `greener_instance_family` | A greener same-size family in the same region via perf-per-watt (newer generation / ARM Graviton-class does the same work on less energy). |
177
178
  | `rightsizing_check` | Right-sizing (the #1 FinOps win) with a carbon twin: if you're over-provisioned, a smaller size cuts **both** cost and carbon → the right-sized target with monthly $ and kg CO₂ saving. |
179
+ | `offhours_check` | For dev/test/staging that doesn't need to run 24/7: the monthly $ and kg CO2 saved by running it only N hours a week. Compute only, directional. |
178
180
  | `data_locality_egress_check` | Warn when running compute away from where your data lives backfires: directional egress $ + coarse transfer carbon, and whether a greener-region move is still a net win. |
179
181
  | `serving_pattern_advice` | For a user-facing platform: serve from one region or several? Traffic-weighted latency + carbon, with the multi-region data-sync egress and cross-jurisdiction residency catches. |
180
182
 
@@ -14,7 +14,7 @@
14
14
  <img src="https://img.shields.io/badge/license-MIT-green" alt="License: MIT" />
15
15
  <img src="https://img.shields.io/badge/tests-passing-brightgreen" alt="Tests: passing" />
16
16
  <img src="https://img.shields.io/badge/python-3.11%2B-blue" alt="Python 3.11+" />
17
- <img src="https://img.shields.io/badge/MCP%20tools-15-0ea5e9" alt="MCP tools: 15" />
17
+ <img src="https://img.shields.io/badge/MCP%20tools-17-0ea5e9" alt="MCP tools: 17" />
18
18
  </p>
19
19
 
20
20
  A **design-time sustainability co-pilot** that runs as an MCP server inside your vibe-coding
@@ -22,7 +22,7 @@ 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), EU (ENTSO-E) and Canada (Hydro-Québec,
25
+ > **Status: working — 17 tools.** Live carbon for US (EIA-930), EU (ENTSO-E) and Canada (Hydro-Québec,
26
26
  > IESO — no key needed), cross-cloud cost
27
27
  > parity (Vantage-refreshed), per-axis R/Y/G ratings, carbon-aware scheduling + day-ahead forecast,
28
28
  > GPU-training **and LLM-inference** carbon, right-sizing, serving-pattern and data-locality/residency
@@ -113,7 +113,7 @@ See `docs/user-questions-backlog.md`.
113
113
 
114
114
  ## MCP tools
115
115
 
116
- 15 tools, grouped by what you're trying to do.
116
+ 17 tools, grouped by what you're trying to do.
117
117
 
118
118
  ### Pick a region
119
119
 
@@ -129,6 +129,7 @@ See `docs/user-questions-backlog.md`.
129
129
  |------|--------------|
130
130
  | `cleanest_hours_to_run` | Cleanest recent hours to time a deferrable job (ETL, backups, training), + % reduction. Pass `window_hours` for a job of known duration → cleanest contiguous window to start at. |
131
131
  | `cleanest_time_of_day` | For a **recurring** job (nightly batch): the region's cleanest time of day, averaged across recent days. A daily pattern, not a 7-day weekly one. |
132
+ | `greenest_regions_on_average` | Which regions are cleanest on a **typical** day in a given month (or the whole year), ranked from last year's real data. Offline, no keys; a planning view, not today's grid. |
132
133
  | `carbon_forecast` | Forward-looking: which **upcoming** hours will be cleanest, from the EU day-ahead wind+solar forecast (ENTSO-E). Relative ranking; US/uncovered zones degrade to the recent historical pattern. |
133
134
 
134
135
  ### Estimate a workload's carbon
@@ -145,6 +146,7 @@ See `docs/user-questions-backlog.md`.
145
146
  |------|--------------|
146
147
  | `greener_instance_family` | A greener same-size family in the same region via perf-per-watt (newer generation / ARM Graviton-class does the same work on less energy). |
147
148
  | `rightsizing_check` | Right-sizing (the #1 FinOps win) with a carbon twin: if you're over-provisioned, a smaller size cuts **both** cost and carbon → the right-sized target with monthly $ and kg CO₂ saving. |
149
+ | `offhours_check` | For dev/test/staging that doesn't need to run 24/7: the monthly $ and kg CO2 saved by running it only N hours a week. Compute only, directional. |
148
150
  | `data_locality_egress_check` | Warn when running compute away from where your data lives backfires: directional egress $ + coarse transfer carbon, and whether a greener-region move is still a net win. |
149
151
  | `serving_pattern_advice` | For a user-facing platform: serve from one region or several? Traffic-weighted latency + carbon, with the multi-region data-sync egress and cross-jurisdiction residency catches. |
150
152
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "greenpick"
3
- version = "0.4.2"
3
+ version = "0.5.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.4.2"
7
+ __version__ = "0.5.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.
@@ -317,6 +317,7 @@ class DataLocalityWarning(BaseModel):
317
317
  transfer_carbon_quality: Optional[str] = None
318
318
  carbon_saving_kg_co2_from_move: Optional[float] = None # echoed back if provided
319
319
  net_carbon_verdict: Optional[str] = None # set only when a saving was supplied
320
+ verdict_status: str = "not_evaluated" # 'evaluated' only when a saving was supplied
320
321
  residency_note: Optional[str] = None # neutral cross-jurisdiction heads-up, if any
321
322
  warning: str = ""
322
323
  recommendation: str = ""
@@ -348,3 +349,32 @@ class Recommendation(BaseModel):
348
349
  residency_note: Optional[str] = None # neutral heads-up if the pick crosses jurisdictions
349
350
  rationale: str = ""
350
351
  limitations: str = ""
352
+
353
+
354
+ class OffHoursAdvice(BaseModel):
355
+ """What switching a non-prod / idle environment off outside the hours it's needed saves.
356
+
357
+ The other FinOps basic next to right-sizing: the cheapest hour is the one you don't run.
358
+ Cost and carbon both scale with running hours, so the saving is the same fraction of each.
359
+ Directional, compute only."""
360
+ provider: Provider
361
+ region: str
362
+ vcpu: float
363
+ ram_gb: float
364
+ hours_per_week_on: float
365
+ hours_per_month_now: float # always-on baseline (730)
366
+ hours_per_month_scheduled: float
367
+ on_share_pct: float # share of the month it's running
368
+ cost_now_usd_per_month: Optional[float] = None
369
+ cost_scheduled_usd_per_month: Optional[float] = None
370
+ monthly_usd_saving: Optional[float] = None
371
+ cost_saving_pct: Optional[float] = None
372
+ carbon_now_kg_per_month: Optional[float] = None
373
+ carbon_scheduled_kg_per_month: Optional[float] = None
374
+ monthly_kg_co2_saving: Optional[float] = None
375
+ carbon_saving_pct: Optional[float] = None
376
+ is_worthwhile: bool = False
377
+ carbon_source: Optional[str] = None
378
+ carbon_quality: Optional[str] = None
379
+ rationale: str = ""
380
+ limitations: str = ""
@@ -1,4 +1,4 @@
1
- """MCP server: exposes GreenPick's 15 tools over FastMCP.
1
+ """MCP server: exposes GreenPick's 17 tools over FastMCP.
2
2
 
3
3
  Thin layer — parse/validate inputs, call services, return JSON-friendly dicts. All math
4
4
  lives in services/; this file only wires tools to them.
@@ -25,7 +25,7 @@ from .data import get_repository
25
25
  from .services import (
26
26
  carbon_service, recommender, scheduling_service, instance_service, locality_service,
27
27
  training_service, forecast_service, serving_service, metric_scaffold_service,
28
- rightsizing_service, inference_service,
28
+ rightsizing_service, inference_service, offhours_service, typical_ranking_service,
29
29
  )
30
30
  from .models import ServingHub
31
31
  from .services.data_hint import with_hint
@@ -38,13 +38,15 @@ INSTRUCTIONS = """\
38
38
  GreenPick advises on cloud infrastructure decisions BEFORE you deploy, weighing carbon, cost and \
39
39
  latency together. Strongest uses, in order:
40
40
  1. Right-sizing (rightsizing_check): an over-provisioned instance cut to a standard, buyable size \
41
- saves money and carbon in the same move. Lead with the monthly dollar saving.
41
+ saves money and carbon in the same move. Lead with the monthly dollar saving. For dev, test or other \
42
+ environments that don't need to run 24/7, offhours_check shows what switching them off saves.
42
43
  2. Scheduling deferrable jobs: cleanest_hours_to_run (one-off), cleanest_time_of_day (recurring), \
43
44
  carbon_forecast (EU day-ahead).
44
45
  3. GPU training and LLM inference footprints: estimate_training_carbon (gpu_hours is the TOTAL across \
45
46
  all GPUs; report wall_clock_hours as the run length), estimate_inference_carbon.
46
47
  4. Efficiency and placement: greener_instance_family, compare_regions_tri_axis, greener_alternative_to, \
47
- serving_pattern_advice; carbon_metric_scaffold for an app to track its own estimated carbon.
48
+ serving_pattern_advice, greenest_regions_on_average (which regions are normally cleanest in a month); \
49
+ carbon_metric_scaffold for an app to track its own estimated carbon.
48
50
  Before recommending a region move, run data_locality_egress_check: egress can erase the win. Mention \
49
51
  any residency_note.
50
52
  Every carbon figure carries source and quality fields. Report 'typical', 'mock', 'cached' or \
@@ -248,6 +250,48 @@ async def rightsizing_check(
248
250
  return with_hint(advice.model_dump())
249
251
 
250
252
 
253
+ @mcp.tool(annotations=LIVE_DATA_TOOL)
254
+ async def offhours_check(
255
+ provider: str,
256
+ region: str,
257
+ vcpu: float,
258
+ ram_gb: float,
259
+ hours_per_week_on: float,
260
+ ) -> dict:
261
+ """Non-prod / idle scheduling, the other FinOps basic next to right-sizing: "this doesn't need to
262
+ run 24/7." Give the instance size and how many hours a WEEK it actually needs to be on (weekdays
263
+ 9-17 is 40, weekdays 7-19 is 60, around the clock is 168). Returns the monthly $ AND kg CO2 saved
264
+ by switching it off the rest of the time. Lead with the dollar number. Only for on-demand capacity
265
+ that tolerates being off (dev, test, staging, demo, batch); reserved instances and savings plans
266
+ are paid for regardless. Directional, compute only."""
267
+ if not 0 < hours_per_week_on <= 168:
268
+ return {"provider": provider, "region": region,
269
+ "error": "hours_per_week_on must be between 0 and 168 (there are 168 hours in a week)."}
270
+ advice = await offhours_service.offhours_check(
271
+ _repo, provider, region, vcpu, ram_gb, hours_per_week_on, # type: ignore[arg-type]
272
+ )
273
+ return with_hint(advice.model_dump())
274
+
275
+
276
+ @mcp.tool(annotations=LOCAL_TOOL)
277
+ def greenest_regions_on_average(
278
+ month: Optional[int] = None,
279
+ whole_year: bool = False,
280
+ providers: Optional[list[str]] = None,
281
+ regions: Optional[list[str]] = None,
282
+ top_n: Optional[int] = None,
283
+ ) -> dict:
284
+ """Which regions are normally the cleanest in a given month (default: the current one), or over
285
+ the whole year? Ranks regions by their TYPICAL average grid carbon, built from a year of real
286
+ hourly data, with each region's cleanest and dirtiest month. Use it to pick a region on a monthly
287
+ norm rather than a single hour; use region_carbon_now for what the grid is doing today. Carbon only
288
+ (no cost or latency; compare_regions_tri_axis weighs all three). Offline, needs no keys. Regions
289
+ labelled annual_average have only a yearly national figure and are not month-specific."""
290
+ return typical_ranking_service.rank_regions(
291
+ month=month, whole_year=whole_year, providers=providers, regions=regions, top_n=top_n,
292
+ )
293
+
294
+
251
295
  @mcp.tool(annotations=LIVE_DATA_TOOL)
252
296
  async def carbon_metric_scaffold(
253
297
  language: str = "python",
@@ -314,7 +358,9 @@ async def data_locality_egress_check(
314
358
  compute runs, where the data lives (provider+region), and monthly GB crossing between them.
315
359
  Returns a directional egress-COST ballpark, a coarse transfer-carbon figure, and — if you
316
360
  pass the carbon saving the greener-region move buys — whether it's still a net win. Use this
317
- before accepting a greener-region suggestion when your data stays put."""
361
+ before accepting a greener-region suggestion when your data stays put. Use the user's real GB and
362
+ data location; if unknown, ask rather than assume. If verdict_status is 'not_evaluated' there is NO
363
+ answer yet to 'does it backfire?': say so, and get carbon_saving_kg_co2_from_move first."""
318
364
  advice = await locality_service.data_locality_check(
319
365
  _repo, compute_provider, compute_region, data_provider, data_region, # type: ignore[arg-type]
320
366
  monthly_cross_boundary_gb=monthly_cross_boundary_gb,
@@ -109,7 +109,9 @@ async def data_locality_check(
109
109
 
110
110
  # Net verdict only if the caller told us what the move saves.
111
111
  verdict = None
112
+ verdict_status = "not_evaluated"
112
113
  if carbon_saving_kg_co2_from_move is not None:
114
+ verdict_status = "evaluated"
113
115
  net = carbon_saving_kg_co2_from_move - transfer_kg
114
116
  if net > 0:
115
117
  verdict = (f"Still a net win: the move saves ~{carbon_saving_kg_co2_from_move:.1f} kg "
@@ -130,9 +132,10 @@ async def data_locality_check(
130
132
  f"cross the boundary, costing {cost_txt}, plus ~{transfer_kg:.1f} kg CO2/mo of extra "
131
133
  f"transfer. A greener-region move can be undone by this.")
132
134
  if verdict is None:
133
- # Point the caller at the optional input that turns this into a net win/backfire verdict.
134
- warning += (" For a net verdict, pass carbon_saving_kg_co2_from_move (the kg CO2/mo the move "
135
- "saves, e.g. the difference between estimate_workload_carbon in the two regions).")
135
+ # Say up front that the question 'does it backfire?' is NOT answered yet, and what is missing.
136
+ warning = ("NO NET VERDICT: you did not pass carbon_saving_kg_co2_from_move, so this does NOT say "
137
+ "whether the move backfires on carbon. Ask the user for it, or compute it as the difference "
138
+ "between estimate_workload_carbon in the two regions, and call again. " + warning)
136
139
 
137
140
  if severity == "high":
138
141
  rec = ("High exposure. Co-locate compute with the data, or move the data to the greener "
@@ -160,6 +163,6 @@ async def data_locality_check(
160
163
  est_monthly_transfer_kg_co2=transfer_kg,
161
164
  transfer_carbon_source=reading.source, transfer_carbon_quality=reading.quality,
162
165
  carbon_saving_kg_co2_from_move=carbon_saving_kg_co2_from_move,
163
- net_carbon_verdict=verdict, residency_note=residency_note,
166
+ net_carbon_verdict=verdict, verdict_status=verdict_status, residency_note=residency_note,
164
167
  warning=warning, recommendation=rec, limitations=LIMITATIONS,
165
168
  )
@@ -0,0 +1,82 @@
1
+ """Off-hours scheduling: "this doesn't need to run 24/7."
2
+
3
+ Dev, test, staging, demo and batch environments are often left running all night and all weekend.
4
+ Switching them off when nobody uses them is the other FinOps basic next to right-sizing, and it has
5
+ the same dual benefit: cost and carbon both scale with running hours, so every hour you don't run
6
+ saves both, with no tradeoff in the hours you do.
7
+
8
+ Design-time and directional. It reuses GreenPick's cost table and power model unchanged: the only
9
+ new input is how many hours a week the environment actually needs to be on.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from ..data.repository import CarbonRepository
14
+ from ..models import OffHoursAdvice, Provider, Workload
15
+ from . import carbon_service, cost_service
16
+
17
+ HOURS_PER_WEEK_ALWAYS_ON = 168
18
+ HOURS_PER_MONTH_ALWAYS_ON = 730 # GreenPick's always-on baseline
19
+ WEEKS_PER_MONTH = HOURS_PER_MONTH_ALWAYS_ON / HOURS_PER_WEEK_ALWAYS_ON # ~4.345
20
+ _MIN_SAVING_PCT = 10.0 # below this, not worth the machinery
21
+
22
+
23
+ async def offhours_check(
24
+ repo: CarbonRepository,
25
+ provider: Provider,
26
+ region: str,
27
+ vcpu: float,
28
+ ram_gb: float,
29
+ hours_per_week_on: float,
30
+ ) -> OffHoursAdvice:
31
+ scheduled_hours = round(min(HOURS_PER_MONTH_ALWAYS_ON, hours_per_week_on * WEEKS_PER_MONTH), 1)
32
+ now_wl = Workload(vcpu=vcpu, ram_gb=ram_gb, hours_per_month=HOURS_PER_MONTH_ALWAYS_ON)
33
+ sched_wl = Workload(vcpu=vcpu, ram_gb=ram_gb, hours_per_month=scheduled_hours)
34
+
35
+ reading = await carbon_service.carbon_now(repo, provider, region)
36
+ cost_now = cost_service.estimate_monthly_cost_usd(provider, now_wl)
37
+ cost_s = cost_service.estimate_monthly_cost_usd(provider, sched_wl)
38
+ carbon_now = carbon_service.estimate_kg_co2_per_month(now_wl, reading)
39
+ carbon_s = carbon_service.estimate_kg_co2_per_month(sched_wl, reading)
40
+
41
+ usd_saving = round(cost_now - cost_s, 2) if cost_now is not None and cost_s is not None else None
42
+ cost_pct = round((cost_now - cost_s) / cost_now * 100, 1) if cost_now and cost_s is not None else None
43
+ kg_saving = round(carbon_now - carbon_s, 2)
44
+ carbon_pct = round((carbon_now - carbon_s) / carbon_now * 100, 1) if carbon_now > 0 else None
45
+ on_share = round(scheduled_hours / HOURS_PER_MONTH_ALWAYS_ON * 100, 1)
46
+ worthwhile = bool(carbon_pct is not None and carbon_pct >= _MIN_SAVING_PCT)
47
+
48
+ if worthwhile:
49
+ usd_txt = f"~${usd_saving:,.0f}/mo" if usd_saving is not None else "a lower bill"
50
+ rationale = (
51
+ f"Running ~{hours_per_week_on:g} h a week instead of around the clock (168 h) means it is on "
52
+ f"{on_share:.0f}% of the time: ~{cost_pct:.0f}% cheaper ({usd_txt} saved) AND "
53
+ f"~{carbon_pct:.0f}% less carbon (~{kg_saving:.1f} kg CO2/mo, {reading.quality} grid data). "
54
+ f"Same hours of use, nothing running when nobody is there. Only worth it for an "
55
+ f"environment that can be off, and you need something to stop and start it on schedule.")
56
+ else:
57
+ rationale = (
58
+ f"At ~{hours_per_week_on:g} h a week it is already on {on_share:.0f}% of the time, so "
59
+ f"switching it off outside those hours would save under {_MIN_SAVING_PCT:.0f}% — not worth "
60
+ f"building a scheduler for. Look to right-sizing or region choice instead.")
61
+
62
+ return OffHoursAdvice(
63
+ provider=provider, region=region, vcpu=vcpu, ram_gb=ram_gb,
64
+ hours_per_week_on=hours_per_week_on,
65
+ hours_per_month_now=HOURS_PER_MONTH_ALWAYS_ON, hours_per_month_scheduled=scheduled_hours,
66
+ on_share_pct=on_share,
67
+ cost_now_usd_per_month=cost_now, cost_scheduled_usd_per_month=cost_s,
68
+ monthly_usd_saving=usd_saving, cost_saving_pct=cost_pct,
69
+ carbon_now_kg_per_month=carbon_now, carbon_scheduled_kg_per_month=carbon_s,
70
+ monthly_kg_co2_saving=kg_saving, carbon_saving_pct=carbon_pct,
71
+ is_worthwhile=worthwhile,
72
+ carbon_source=reading.source, carbon_quality=reading.quality,
73
+ rationale=rationale,
74
+ limitations=(
75
+ "Directional, compute only. A stopped instance still costs for its attached disks, IP "
76
+ "addresses and snapshots, so the real bill falls a little less than shown. Reserved "
77
+ "instances and savings plans are paid for whether the instance runs or not, so they cancel "
78
+ "the saving: this applies to on-demand capacity. Assumes the instance works as hard when it "
79
+ "is on, and that start-up time and data warm-up are acceptable. Carbon uses today's grid "
80
+ "intensity; the hours you actually run may be cleaner or dirtier (see cleanest_time_of_day). "
81
+ "Hours per week: weekdays 9-17 is 40, weekdays 7-19 is 60, around the clock is 168."),
82
+ )
@@ -0,0 +1,106 @@
1
+ """Which regions are greenest ON AVERAGE for a month (or the whole year)?
2
+
3
+ Answers the "across a whole month, which region is greenest on average?" question from the data
4
+ GreenPick already bundles: typical.json (12 months of real hourly EIA / ENTSO-E / Hydro-Québec /
5
+ IESO data, averaged per calendar month) and annual.json (a sourced yearly national figure, for
6
+ regions with no free hourly data).
7
+
8
+ Pure and offline: no network, no keys. Because it is built from LAST year's data it describes what
9
+ is typical for the time of year, not what the grid is doing today (use region_carbon_now or
10
+ compare_regions_tri_axis for that).
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import calendar
15
+ from datetime import datetime, timezone
16
+ from typing import Optional
17
+
18
+ from ..data.static_data import iter_regions
19
+ from ..data.typical_repository import annual_entry, typical_entry
20
+
21
+ LIMITATIONS = (
22
+ "Typical figures are built from the last 12 months of real hourly grid data, averaged per "
23
+ "calendar month. They describe what is normal for the time of year, not today's grid, and "
24
+ "a given year can differ (wind, hydro, outages). Regions marked annual_average have only a "
25
+ "yearly national figure, so they are not month-specific: compare them with the monthly "
26
+ "figures with care. Carbon only; this ranking ignores cost and latency (use "
27
+ "compare_regions_tri_axis for the three together). Directional, not audited."
28
+ )
29
+
30
+
31
+ def _month_name(m: int) -> str:
32
+ return calendar.month_name[m]
33
+
34
+
35
+ def rank_regions(
36
+ month: Optional[int] = None,
37
+ whole_year: bool = False,
38
+ providers: Optional[list[str]] = None,
39
+ regions: Optional[list[str]] = None,
40
+ top_n: Optional[int] = None,
41
+ ) -> dict:
42
+ now_month = datetime.now(timezone.utc).month
43
+ m = month or now_month
44
+ if not whole_year and not 1 <= m <= 12:
45
+ return {"error": f"month must be 1-12, got {month}.", "ranking": []}
46
+
47
+ rows = []
48
+ for prov, reg, meta in iter_regions():
49
+ if providers and prov not in providers:
50
+ continue
51
+ if regions and reg not in regions:
52
+ continue
53
+ entry = typical_entry(prov, reg)
54
+ if entry and entry.get("months"):
55
+ months = {int(k): v for k, v in entry["months"].items()}
56
+ if whole_year:
57
+ hours = sum(v["hours"] for v in months.values())
58
+ value = sum(v["avg_g_co2_per_kwh"] * v["hours"] for v in months.values()) / hours
59
+ elif m in months:
60
+ value = months[m]["avg_g_co2_per_kwh"]
61
+ else:
62
+ continue
63
+ avgs = {k: v["avg_g_co2_per_kwh"] for k, v in months.items()}
64
+ best, worst = min(avgs, key=avgs.get), max(avgs, key=avgs.get)
65
+ rows.append({
66
+ "provider": prov, "region": reg, "name": meta.get("name"),
67
+ "g_co2_per_kwh": round(value, 1), "quality": "typical",
68
+ "source": entry["source"], "data_from": entry["from"][:7], "data_to": entry["to"][:7],
69
+ "year_low_g_co2_per_kwh": avgs[best], "year_high_g_co2_per_kwh": avgs[worst],
70
+ "cleanest_month": _month_name(best), "dirtiest_month": _month_name(worst),
71
+ })
72
+ continue
73
+ yearly = annual_entry(prov, reg)
74
+ if yearly:
75
+ rows.append({
76
+ "provider": prov, "region": reg, "name": meta.get("name"),
77
+ "g_co2_per_kwh": yearly["g_co2_per_kwh"], "quality": "annual_average",
78
+ "source": "ember_annual", "data_from": str(yearly["year"]), "data_to": str(yearly["year"]),
79
+ "year_low_g_co2_per_kwh": None, "year_high_g_co2_per_kwh": None,
80
+ "cleanest_month": None, "dirtiest_month": None,
81
+ })
82
+
83
+ if not rows:
84
+ return {"error": "No regions matched.", "ranking": []}
85
+
86
+ rows.sort(key=lambda r: r["g_co2_per_kwh"])
87
+ worst = rows[-1]["g_co2_per_kwh"]
88
+ for i, r in enumerate(rows, 1):
89
+ r["rank"] = i
90
+ r["vs_dirtiest_pct"] = round((r["g_co2_per_kwh"] - worst) / worst * 100, 1) if worst else None
91
+ shown = rows[:top_n] if top_n else rows
92
+
93
+ period = "the whole year" if whole_year else _month_name(m)
94
+ best_r, worst_r = rows[0], rows[-1]
95
+ ratio = worst_r["g_co2_per_kwh"] / best_r["g_co2_per_kwh"] if best_r["g_co2_per_kwh"] else None
96
+ spread = f" (~{ratio:.0f}x apart)" if ratio and ratio >= 2 else ""
97
+ rationale = (
98
+ f"On a typical {period if whole_year else period + ' day'}, the greenest of {len(rows)} regions is "
99
+ f"{best_r['provider']}/{best_r['region']} at ~{best_r['g_co2_per_kwh']:.0f} gCO2/kWh and the "
100
+ f"dirtiest is {worst_r['provider']}/{worst_r['region']} at ~{worst_r['g_co2_per_kwh']:.0f}"
101
+ f"{spread}. Built from last year's real data, so it shows what is normal for the time of "
102
+ f"year, not what the grid is doing right now.")
103
+ return {
104
+ "period": period, "regions_ranked": len(rows), "ranking": shown,
105
+ "rationale": rationale, "limitations": LIMITATIONS,
106
+ }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: greenpick
3
- Version: 0.4.2
3
+ Version: 0.5.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
@@ -44,7 +44,7 @@ Dynamic: license-file
44
44
  <img src="https://img.shields.io/badge/license-MIT-green" alt="License: MIT" />
45
45
  <img src="https://img.shields.io/badge/tests-passing-brightgreen" alt="Tests: passing" />
46
46
  <img src="https://img.shields.io/badge/python-3.11%2B-blue" alt="Python 3.11+" />
47
- <img src="https://img.shields.io/badge/MCP%20tools-15-0ea5e9" alt="MCP tools: 15" />
47
+ <img src="https://img.shields.io/badge/MCP%20tools-17-0ea5e9" alt="MCP tools: 17" />
48
48
  </p>
49
49
 
50
50
  A **design-time sustainability co-pilot** that runs as an MCP server inside your vibe-coding
@@ -52,7 +52,7 @@ 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), EU (ENTSO-E) and Canada (Hydro-Québec,
55
+ > **Status: working — 17 tools.** Live carbon for US (EIA-930), EU (ENTSO-E) and Canada (Hydro-Québec,
56
56
  > IESO — no key needed), cross-cloud cost
57
57
  > parity (Vantage-refreshed), per-axis R/Y/G ratings, carbon-aware scheduling + day-ahead forecast,
58
58
  > GPU-training **and LLM-inference** carbon, right-sizing, serving-pattern and data-locality/residency
@@ -143,7 +143,7 @@ See `docs/user-questions-backlog.md`.
143
143
 
144
144
  ## MCP tools
145
145
 
146
- 15 tools, grouped by what you're trying to do.
146
+ 17 tools, grouped by what you're trying to do.
147
147
 
148
148
  ### Pick a region
149
149
 
@@ -159,6 +159,7 @@ See `docs/user-questions-backlog.md`.
159
159
  |------|--------------|
160
160
  | `cleanest_hours_to_run` | Cleanest recent hours to time a deferrable job (ETL, backups, training), + % reduction. Pass `window_hours` for a job of known duration → cleanest contiguous window to start at. |
161
161
  | `cleanest_time_of_day` | For a **recurring** job (nightly batch): the region's cleanest time of day, averaged across recent days. A daily pattern, not a 7-day weekly one. |
162
+ | `greenest_regions_on_average` | Which regions are cleanest on a **typical** day in a given month (or the whole year), ranked from last year's real data. Offline, no keys; a planning view, not today's grid. |
162
163
  | `carbon_forecast` | Forward-looking: which **upcoming** hours will be cleanest, from the EU day-ahead wind+solar forecast (ENTSO-E). Relative ranking; US/uncovered zones degrade to the recent historical pattern. |
163
164
 
164
165
  ### Estimate a workload's carbon
@@ -175,6 +176,7 @@ See `docs/user-questions-backlog.md`.
175
176
  |------|--------------|
176
177
  | `greener_instance_family` | A greener same-size family in the same region via perf-per-watt (newer generation / ARM Graviton-class does the same work on less energy). |
177
178
  | `rightsizing_check` | Right-sizing (the #1 FinOps win) with a carbon twin: if you're over-provisioned, a smaller size cuts **both** cost and carbon → the right-sized target with monthly $ and kg CO₂ saving. |
179
+ | `offhours_check` | For dev/test/staging that doesn't need to run 24/7: the monthly $ and kg CO2 saved by running it only N hours a week. Compute only, directional. |
178
180
  | `data_locality_egress_check` | Warn when running compute away from where your data lives backfires: directional egress $ + coarse transfer carbon, and whether a greener-region move is still a net win. |
179
181
  | `serving_pattern_advice` | For a user-facing platform: serve from one region or several? Traffic-weighted latency + carbon, with the multi-region data-sync egress and cross-jurisdiction residency catches. |
180
182
 
@@ -43,10 +43,12 @@ src/greenpick/services/instance_service.py
43
43
  src/greenpick/services/latency_service.py
44
44
  src/greenpick/services/locality_service.py
45
45
  src/greenpick/services/metric_scaffold_service.py
46
+ src/greenpick/services/offhours_service.py
46
47
  src/greenpick/services/recommender.py
47
48
  src/greenpick/services/residency_service.py
48
49
  src/greenpick/services/rightsizing_service.py
49
50
  src/greenpick/services/scheduling_service.py
50
51
  src/greenpick/services/serving_service.py
51
52
  src/greenpick/services/training_service.py
53
+ src/greenpick/services/typical_ranking_service.py
52
54
  tests/test_services.py