greenpick 0.4.1__tar.gz → 0.5.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 (56) hide show
  1. {greenpick-0.4.1/src/greenpick.egg-info → greenpick-0.5.0}/PKG-INFO +7 -5
  2. {greenpick-0.4.1 → greenpick-0.5.0}/README.md +5 -3
  3. {greenpick-0.4.1 → greenpick-0.5.0}/pyproject.toml +2 -2
  4. {greenpick-0.4.1 → greenpick-0.5.0}/src/greenpick/__init__.py +1 -1
  5. {greenpick-0.4.1 → greenpick-0.5.0}/src/greenpick/models.py +29 -0
  6. {greenpick-0.4.1 → greenpick-0.5.0}/src/greenpick/server.py +74 -19
  7. greenpick-0.5.0/src/greenpick/services/offhours_service.py +82 -0
  8. greenpick-0.5.0/src/greenpick/services/typical_ranking_service.py +106 -0
  9. {greenpick-0.4.1 → greenpick-0.5.0/src/greenpick.egg-info}/PKG-INFO +7 -5
  10. {greenpick-0.4.1 → greenpick-0.5.0}/src/greenpick.egg-info/SOURCES.txt +2 -0
  11. {greenpick-0.4.1 → greenpick-0.5.0}/src/greenpick.egg-info/requires.txt +1 -1
  12. {greenpick-0.4.1 → greenpick-0.5.0}/tests/test_services.py +1884 -1794
  13. {greenpick-0.4.1 → greenpick-0.5.0}/LICENSE +0 -0
  14. {greenpick-0.4.1 → greenpick-0.5.0}/setup.cfg +0 -0
  15. {greenpick-0.4.1 → greenpick-0.5.0}/src/greenpick/__main__.py +0 -0
  16. {greenpick-0.4.1 → greenpick-0.5.0}/src/greenpick/config.py +0 -0
  17. {greenpick-0.4.1 → greenpick-0.5.0}/src/greenpick/data/__init__.py +0 -0
  18. {greenpick-0.4.1 → greenpick-0.5.0}/src/greenpick/data/_net.py +0 -0
  19. {greenpick-0.4.1 → greenpick-0.5.0}/src/greenpick/data/annual.json +0 -0
  20. {greenpick-0.4.1 → greenpick-0.5.0}/src/greenpick/data/auto_repository.py +0 -0
  21. {greenpick-0.4.1 → greenpick-0.5.0}/src/greenpick/data/canada_repository.py +0 -0
  22. {greenpick-0.4.1 → greenpick-0.5.0}/src/greenpick/data/eia_repository.py +0 -0
  23. {greenpick-0.4.1 → greenpick-0.5.0}/src/greenpick/data/electricitymaps_repository.py +0 -0
  24. {greenpick-0.4.1 → greenpick-0.5.0}/src/greenpick/data/emission_factors.json +0 -0
  25. {greenpick-0.4.1 → greenpick-0.5.0}/src/greenpick/data/entsoe_repository.py +0 -0
  26. {greenpick-0.4.1 → greenpick-0.5.0}/src/greenpick/data/gpu_power.json +0 -0
  27. {greenpick-0.4.1 → greenpick-0.5.0}/src/greenpick/data/inference_energy.json +0 -0
  28. {greenpick-0.4.1 → greenpick-0.5.0}/src/greenpick/data/instance_families.json +0 -0
  29. {greenpick-0.4.1 → greenpick-0.5.0}/src/greenpick/data/mock_repository.py +0 -0
  30. {greenpick-0.4.1 → greenpick-0.5.0}/src/greenpick/data/network.json +0 -0
  31. {greenpick-0.4.1 → greenpick-0.5.0}/src/greenpick/data/power.json +0 -0
  32. {greenpick-0.4.1 → greenpick-0.5.0}/src/greenpick/data/pricing.json +0 -0
  33. {greenpick-0.4.1 → greenpick-0.5.0}/src/greenpick/data/regions.json +0 -0
  34. {greenpick-0.4.1 → greenpick-0.5.0}/src/greenpick/data/repository.py +0 -0
  35. {greenpick-0.4.1 → greenpick-0.5.0}/src/greenpick/data/static_data.py +0 -0
  36. {greenpick-0.4.1 → greenpick-0.5.0}/src/greenpick/data/typical.json +0 -0
  37. {greenpick-0.4.1 → greenpick-0.5.0}/src/greenpick/data/typical_repository.py +0 -0
  38. {greenpick-0.4.1 → greenpick-0.5.0}/src/greenpick/services/__init__.py +0 -0
  39. {greenpick-0.4.1 → greenpick-0.5.0}/src/greenpick/services/carbon_service.py +0 -0
  40. {greenpick-0.4.1 → greenpick-0.5.0}/src/greenpick/services/cost_service.py +0 -0
  41. {greenpick-0.4.1 → greenpick-0.5.0}/src/greenpick/services/data_hint.py +0 -0
  42. {greenpick-0.4.1 → greenpick-0.5.0}/src/greenpick/services/forecast_service.py +0 -0
  43. {greenpick-0.4.1 → greenpick-0.5.0}/src/greenpick/services/inference_service.py +0 -0
  44. {greenpick-0.4.1 → greenpick-0.5.0}/src/greenpick/services/instance_service.py +0 -0
  45. {greenpick-0.4.1 → greenpick-0.5.0}/src/greenpick/services/latency_service.py +0 -0
  46. {greenpick-0.4.1 → greenpick-0.5.0}/src/greenpick/services/locality_service.py +0 -0
  47. {greenpick-0.4.1 → greenpick-0.5.0}/src/greenpick/services/metric_scaffold_service.py +0 -0
  48. {greenpick-0.4.1 → greenpick-0.5.0}/src/greenpick/services/recommender.py +0 -0
  49. {greenpick-0.4.1 → greenpick-0.5.0}/src/greenpick/services/residency_service.py +0 -0
  50. {greenpick-0.4.1 → greenpick-0.5.0}/src/greenpick/services/rightsizing_service.py +0 -0
  51. {greenpick-0.4.1 → greenpick-0.5.0}/src/greenpick/services/scheduling_service.py +0 -0
  52. {greenpick-0.4.1 → greenpick-0.5.0}/src/greenpick/services/serving_service.py +0 -0
  53. {greenpick-0.4.1 → greenpick-0.5.0}/src/greenpick/services/training_service.py +0 -0
  54. {greenpick-0.4.1 → greenpick-0.5.0}/src/greenpick.egg-info/dependency_links.txt +0 -0
  55. {greenpick-0.4.1 → greenpick-0.5.0}/src/greenpick.egg-info/entry_points.txt +0 -0
  56. {greenpick-0.4.1 → greenpick-0.5.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.4.1
3
+ Version: 0.5.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
@@ -19,7 +19,7 @@ Classifier: Topic :: Scientific/Engineering
19
19
  Requires-Python: >=3.11
20
20
  Description-Content-Type: text/markdown
21
21
  License-File: LICENSE
22
- Requires-Dist: mcp<2,>=1.2
22
+ Requires-Dist: mcp<2,>=1.7
23
23
  Requires-Dist: pydantic>=2.0
24
24
  Requires-Dist: httpx>=0.27
25
25
  Provides-Extra: dev
@@ -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.1"
3
+ version = "0.5.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"
@@ -19,7 +19,7 @@ classifiers = [
19
19
  "Topic :: Scientific/Engineering",
20
20
  ]
21
21
  dependencies = [
22
- "mcp>=1.2,<2", # Model Context Protocol SDK (FastMCP lives here). Pinned <2: mcp 2.x
22
+ "mcp>=1.7,<2", # Model Context Protocol SDK (FastMCP lives here). >=1.7: tool annotations. Pinned <2: mcp 2.x
23
23
  # renamed FastMCP -> MCPServer and changed APIs; this code targets the 1.x API.
24
24
  "pydantic>=2.0",
25
25
  "httpx>=0.27", # async HTTP for live data sources
@@ -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.1"
7
+ __version__ = "0.5.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.
@@ -348,3 +348,32 @@ class Recommendation(BaseModel):
348
348
  residency_note: Optional[str] = None # neutral heads-up if the pick crosses jurisdictions
349
349
  rationale: str = ""
350
350
  limitations: str = ""
351
+
352
+
353
+ class OffHoursAdvice(BaseModel):
354
+ """What switching a non-prod / idle environment off outside the hours it's needed saves.
355
+
356
+ The other FinOps basic next to right-sizing: the cheapest hour is the one you don't run.
357
+ Cost and carbon both scale with running hours, so the saving is the same fraction of each.
358
+ Directional, compute only."""
359
+ provider: Provider
360
+ region: str
361
+ vcpu: float
362
+ ram_gb: float
363
+ hours_per_week_on: float
364
+ hours_per_month_now: float # always-on baseline (730)
365
+ hours_per_month_scheduled: float
366
+ on_share_pct: float # share of the month it's running
367
+ cost_now_usd_per_month: Optional[float] = None
368
+ cost_scheduled_usd_per_month: Optional[float] = None
369
+ monthly_usd_saving: Optional[float] = None
370
+ cost_saving_pct: Optional[float] = None
371
+ carbon_now_kg_per_month: Optional[float] = None
372
+ carbon_scheduled_kg_per_month: Optional[float] = None
373
+ monthly_kg_co2_saving: Optional[float] = None
374
+ carbon_saving_pct: Optional[float] = None
375
+ is_worthwhile: bool = False
376
+ carbon_source: Optional[str] = None
377
+ carbon_quality: Optional[str] = None
378
+ rationale: str = ""
379
+ 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.
@@ -8,13 +8,24 @@ from __future__ import annotations
8
8
  from typing import Optional
9
9
 
10
10
  from mcp.server.fastmcp import FastMCP
11
+ from mcp.types import ToolAnnotations
12
+
13
+ # Tool annotations let MCP hosts decide whether to ask the user before a call (and some
14
+ # directories require them). Every GreenPick tool only reads and computes: nothing is created,
15
+ # changed or deleted, so repeating a call has no extra effect. Most fetch live grid data from
16
+ # outside services (EIA, ENTSO-E, Hydro-Québec, IESO) -> openWorldHint; the methodology tool
17
+ # returns fixed text only.
18
+ LIVE_DATA_TOOL = ToolAnnotations(readOnlyHint=True, destructiveHint=False,
19
+ idempotentHint=True, openWorldHint=True)
20
+ LOCAL_TOOL = ToolAnnotations(readOnlyHint=True, destructiveHint=False,
21
+ idempotentHint=True, openWorldHint=False)
11
22
 
12
23
  from .models import Workload, GeoPoint, Provider
13
24
  from .data import get_repository
14
25
  from .services import (
15
26
  carbon_service, recommender, scheduling_service, instance_service, locality_service,
16
27
  training_service, forecast_service, serving_service, metric_scaffold_service,
17
- rightsizing_service, inference_service,
28
+ rightsizing_service, inference_service, offhours_service, typical_ranking_service,
18
29
  )
19
30
  from .models import ServingHub
20
31
  from .services.data_hint import with_hint
@@ -27,13 +38,15 @@ INSTRUCTIONS = """\
27
38
  GreenPick advises on cloud infrastructure decisions BEFORE you deploy, weighing carbon, cost and \
28
39
  latency together. Strongest uses, in order:
29
40
  1. Right-sizing (rightsizing_check): an over-provisioned instance cut to a standard, buyable size \
30
- 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.
31
43
  2. Scheduling deferrable jobs: cleanest_hours_to_run (one-off), cleanest_time_of_day (recurring), \
32
44
  carbon_forecast (EU day-ahead).
33
45
  3. GPU training and LLM inference footprints: estimate_training_carbon (gpu_hours is the TOTAL across \
34
46
  all GPUs; report wall_clock_hours as the run length), estimate_inference_carbon.
35
47
  4. Efficiency and placement: greener_instance_family, compare_regions_tri_axis, greener_alternative_to, \
36
- 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.
37
50
  Before recommending a region move, run data_locality_egress_check: egress can erase the win. Mention \
38
51
  any residency_note.
39
52
  Every carbon figure carries source and quality fields. Report 'typical', 'mock', 'cached' or \
@@ -48,7 +61,7 @@ mcp = FastMCP("greenpick", instructions=INSTRUCTIONS)
48
61
  _repo = get_repository()
49
62
 
50
63
 
51
- @mcp.tool()
64
+ @mcp.tool(annotations=LIVE_DATA_TOOL)
52
65
  async def compare_regions_tri_axis(
53
66
  user_lat: float,
54
67
  user_lon: float,
@@ -80,7 +93,7 @@ async def compare_regions_tri_axis(
80
93
  return with_hint(rec.model_dump())
81
94
 
82
95
 
83
- @mcp.tool()
96
+ @mcp.tool(annotations=LIVE_DATA_TOOL)
84
97
  async def greener_alternative_to(
85
98
  current_region: str,
86
99
  user_lat: float,
@@ -110,7 +123,7 @@ async def greener_alternative_to(
110
123
  return with_hint(rec.model_dump())
111
124
 
112
125
 
113
- @mcp.tool()
126
+ @mcp.tool(annotations=LIVE_DATA_TOOL)
114
127
  async def estimate_workload_carbon(
115
128
  provider: str,
116
129
  region: str,
@@ -134,7 +147,7 @@ async def estimate_workload_carbon(
134
147
  })
135
148
 
136
149
 
137
- @mcp.tool()
150
+ @mcp.tool(annotations=LIVE_DATA_TOOL)
138
151
  async def region_carbon_now(provider: str, region: str) -> dict:
139
152
  """Current grid carbon intensity for a region, with source, timestamp, and data freshness.
140
153
  When the reading is live, also says how it compares with what's typical for that hour this month
@@ -149,7 +162,7 @@ async def region_carbon_now(provider: str, region: str) -> dict:
149
162
  return with_hint(out)
150
163
 
151
164
 
152
- @mcp.tool()
165
+ @mcp.tool(annotations=LIVE_DATA_TOOL)
153
166
  async def cleanest_hours_to_run(
154
167
  provider: str, region: str, top_n: int = 3, window_hours: Optional[int] = None,
155
168
  ) -> dict:
@@ -164,7 +177,7 @@ async def cleanest_hours_to_run(
164
177
  return with_hint(advice.model_dump())
165
178
 
166
179
 
167
- @mcp.tool()
180
+ @mcp.tool(annotations=LIVE_DATA_TOOL)
168
181
  async def greener_instance_family(
169
182
  provider: str,
170
183
  region: str,
@@ -192,7 +205,7 @@ async def greener_instance_family(
192
205
  return with_hint(advice.model_dump())
193
206
 
194
207
 
195
- @mcp.tool()
208
+ @mcp.tool(annotations=LIVE_DATA_TOOL)
196
209
  async def estimate_inference_carbon(
197
210
  size_class: str,
198
211
  monthly_requests: float,
@@ -214,7 +227,7 @@ async def estimate_inference_carbon(
214
227
  return with_hint(advice.model_dump())
215
228
 
216
229
 
217
- @mcp.tool()
230
+ @mcp.tool(annotations=LIVE_DATA_TOOL)
218
231
  async def rightsizing_check(
219
232
  provider: str,
220
233
  region: str,
@@ -237,7 +250,49 @@ async def rightsizing_check(
237
250
  return with_hint(advice.model_dump())
238
251
 
239
252
 
240
- @mcp.tool()
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
+
295
+ @mcp.tool(annotations=LIVE_DATA_TOOL)
241
296
  async def carbon_metric_scaffold(
242
297
  language: str = "python",
243
298
  region: Optional[str] = None,
@@ -260,7 +315,7 @@ async def carbon_metric_scaffold(
260
315
  return with_hint(scaffold.model_dump())
261
316
 
262
317
 
263
- @mcp.tool()
318
+ @mcp.tool(annotations=LIVE_DATA_TOOL)
264
319
  async def serving_pattern_advice(
265
320
  hubs: list[dict],
266
321
  vcpu: float = 1,
@@ -290,7 +345,7 @@ async def serving_pattern_advice(
290
345
  return with_hint(advice.model_dump())
291
346
 
292
347
 
293
- @mcp.tool()
348
+ @mcp.tool(annotations=LIVE_DATA_TOOL)
294
349
  async def data_locality_egress_check(
295
350
  compute_provider: str,
296
351
  compute_region: str,
@@ -312,7 +367,7 @@ async def data_locality_egress_check(
312
367
  return with_hint(advice.model_dump())
313
368
 
314
369
 
315
- @mcp.tool()
370
+ @mcp.tool(annotations=LIVE_DATA_TOOL)
316
371
  async def carbon_forecast(
317
372
  provider: str, region: str, hours_ahead: int = 24, top_n: int = 5,
318
373
  ) -> dict:
@@ -327,7 +382,7 @@ async def carbon_forecast(
327
382
  return with_hint(advice.model_dump())
328
383
 
329
384
 
330
- @mcp.tool()
385
+ @mcp.tool(annotations=LIVE_DATA_TOOL)
331
386
  async def cleanest_time_of_day(provider: str, region: str) -> dict:
332
387
  """For a RECURRING job (e.g. a nightly batch you run every day), find the region's cleanest
333
388
  time of day — each hour-of-day averaged across the recent days of data. Returns the cleanest/
@@ -337,7 +392,7 @@ async def cleanest_time_of_day(provider: str, region: str) -> dict:
337
392
  return with_hint(advice.model_dump())
338
393
 
339
394
 
340
- @mcp.tool()
395
+ @mcp.tool(annotations=LIVE_DATA_TOOL)
341
396
  async def estimate_training_carbon(
342
397
  gpu: str,
343
398
  gpu_count: int,
@@ -364,7 +419,7 @@ async def estimate_training_carbon(
364
419
  return with_hint(advice.model_dump())
365
420
 
366
421
 
367
- @mcp.tool()
422
+ @mcp.tool(annotations=LOCAL_TOOL)
368
423
  def explain_methodology_and_limits() -> dict:
369
424
  """GreenPick's assumptions and what it explicitly does NOT claim. Read this before trusting a number."""
370
425
  return {
@@ -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.1
3
+ Version: 0.5.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
@@ -19,7 +19,7 @@ Classifier: Topic :: Scientific/Engineering
19
19
  Requires-Python: >=3.11
20
20
  Description-Content-Type: text/markdown
21
21
  License-File: LICENSE
22
- Requires-Dist: mcp<2,>=1.2
22
+ Requires-Dist: mcp<2,>=1.7
23
23
  Requires-Dist: pydantic>=2.0
24
24
  Requires-Dist: httpx>=0.27
25
25
  Provides-Extra: dev
@@ -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
@@ -1,4 +1,4 @@
1
- mcp<2,>=1.2
1
+ mcp<2,>=1.7
2
2
  pydantic>=2.0
3
3
  httpx>=0.27
4
4