greenpick 0.1.5__tar.gz → 0.1.7__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.
- {greenpick-0.1.5/src/greenpick.egg-info → greenpick-0.1.7}/PKG-INFO +32 -6
- {greenpick-0.1.5 → greenpick-0.1.7}/README.md +31 -5
- {greenpick-0.1.5 → greenpick-0.1.7}/pyproject.toml +1 -1
- {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/__init__.py +1 -1
- {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/models.py +5 -2
- {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/server.py +23 -1
- {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/services/rightsizing_service.py +11 -4
- {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/services/scheduling_service.py +26 -8
- {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/services/training_service.py +7 -5
- {greenpick-0.1.5 → greenpick-0.1.7/src/greenpick.egg-info}/PKG-INFO +32 -6
- {greenpick-0.1.5 → greenpick-0.1.7}/tests/test_services.py +81 -0
- {greenpick-0.1.5 → greenpick-0.1.7}/LICENSE +0 -0
- {greenpick-0.1.5 → greenpick-0.1.7}/setup.cfg +0 -0
- {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/__main__.py +0 -0
- {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/config.py +0 -0
- {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/data/__init__.py +0 -0
- {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/data/_net.py +0 -0
- {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/data/auto_repository.py +0 -0
- {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/data/eia_repository.py +0 -0
- {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/data/electricitymaps_repository.py +0 -0
- {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/data/emission_factors.json +0 -0
- {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/data/entsoe_repository.py +0 -0
- {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/data/gpu_power.json +0 -0
- {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/data/inference_energy.json +0 -0
- {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/data/instance_families.json +0 -0
- {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/data/mock_repository.py +0 -0
- {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/data/network.json +0 -0
- {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/data/power.json +0 -0
- {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/data/pricing.json +0 -0
- {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/data/regions.json +0 -0
- {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/data/repository.py +0 -0
- {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/data/static_data.py +0 -0
- {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/services/__init__.py +0 -0
- {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/services/carbon_service.py +0 -0
- {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/services/cost_service.py +0 -0
- {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/services/forecast_service.py +0 -0
- {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/services/inference_service.py +0 -0
- {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/services/instance_service.py +0 -0
- {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/services/latency_service.py +0 -0
- {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/services/locality_service.py +0 -0
- {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/services/metric_scaffold_service.py +0 -0
- {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/services/recommender.py +0 -0
- {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/services/residency_service.py +0 -0
- {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/services/serving_service.py +0 -0
- {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick.egg-info/SOURCES.txt +0 -0
- {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick.egg-info/dependency_links.txt +0 -0
- {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick.egg-info/entry_points.txt +0 -0
- {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick.egg-info/requires.txt +0 -0
- {greenpick-0.1.5 → greenpick-0.1.7}/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.1.
|
|
3
|
+
Version: 0.1.7
|
|
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
|
|
@@ -254,15 +254,41 @@ of the options below.)
|
|
|
254
254
|
}
|
|
255
255
|
```
|
|
256
256
|
|
|
257
|
-
**In a Jupyter notebook or Python script**,
|
|
257
|
+
**In a Jupyter notebook or Python script**, connect with the MCP client library and pass the keys
|
|
258
|
+
in `env`. The library starts GreenPick as a separate process and doesn't forward the notebook's own
|
|
259
|
+
environment variables to it:
|
|
258
260
|
|
|
259
261
|
```python
|
|
260
|
-
import
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
262
|
+
import sys
|
|
263
|
+
# "mcp" is the MCP Python SDK (installed alongside greenpick), not a greenpick module.
|
|
264
|
+
from mcp import ClientSession, StdioServerParameters
|
|
265
|
+
from mcp.client.stdio import stdio_client
|
|
266
|
+
|
|
267
|
+
params = StdioServerParameters(
|
|
268
|
+
command=sys.executable,
|
|
269
|
+
args=["-m", "greenpick"],
|
|
270
|
+
env={
|
|
271
|
+
"GREENPICK_CARBON_SOURCE": "auto",
|
|
272
|
+
"EIA_API_KEY": "your-eia-key",
|
|
273
|
+
"ENTSOE_TOKEN": "your-entsoe-token",
|
|
274
|
+
},
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
# Keep errlog=: in Jupyter it must be a real file, because the notebook's stderr has no fileno()
|
|
278
|
+
# (without it you get "UnsupportedOperation: fileno").
|
|
279
|
+
with open("greenpick-server.log", "w") as errlog:
|
|
280
|
+
async with stdio_client(params, errlog=errlog) as (read, write):
|
|
281
|
+
async with ClientSession(read, write) as session:
|
|
282
|
+
await session.initialize()
|
|
283
|
+
result = await session.call_tool(
|
|
284
|
+
"region_carbon_now", {"provider": "aws", "region": "us-west-2"}
|
|
285
|
+
)
|
|
286
|
+
print(result.content[0].text)
|
|
264
287
|
```
|
|
265
288
|
|
|
289
|
+
Top-level `await` works in Jupyter. In a plain script, put the `with` block inside an `async def`
|
|
290
|
+
and run it with `asyncio.run(...)`.
|
|
291
|
+
|
|
266
292
|
Every carbon figure includes a `quality` field: `live` means real grid data, `mock` means a
|
|
267
293
|
fallback value. Check it to confirm live data is on.
|
|
268
294
|
|
|
@@ -224,15 +224,41 @@ of the options below.)
|
|
|
224
224
|
}
|
|
225
225
|
```
|
|
226
226
|
|
|
227
|
-
**In a Jupyter notebook or Python script**,
|
|
227
|
+
**In a Jupyter notebook or Python script**, connect with the MCP client library and pass the keys
|
|
228
|
+
in `env`. The library starts GreenPick as a separate process and doesn't forward the notebook's own
|
|
229
|
+
environment variables to it:
|
|
228
230
|
|
|
229
231
|
```python
|
|
230
|
-
import
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
232
|
+
import sys
|
|
233
|
+
# "mcp" is the MCP Python SDK (installed alongside greenpick), not a greenpick module.
|
|
234
|
+
from mcp import ClientSession, StdioServerParameters
|
|
235
|
+
from mcp.client.stdio import stdio_client
|
|
236
|
+
|
|
237
|
+
params = StdioServerParameters(
|
|
238
|
+
command=sys.executable,
|
|
239
|
+
args=["-m", "greenpick"],
|
|
240
|
+
env={
|
|
241
|
+
"GREENPICK_CARBON_SOURCE": "auto",
|
|
242
|
+
"EIA_API_KEY": "your-eia-key",
|
|
243
|
+
"ENTSOE_TOKEN": "your-entsoe-token",
|
|
244
|
+
},
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
# Keep errlog=: in Jupyter it must be a real file, because the notebook's stderr has no fileno()
|
|
248
|
+
# (without it you get "UnsupportedOperation: fileno").
|
|
249
|
+
with open("greenpick-server.log", "w") as errlog:
|
|
250
|
+
async with stdio_client(params, errlog=errlog) as (read, write):
|
|
251
|
+
async with ClientSession(read, write) as session:
|
|
252
|
+
await session.initialize()
|
|
253
|
+
result = await session.call_tool(
|
|
254
|
+
"region_carbon_now", {"provider": "aws", "region": "us-west-2"}
|
|
255
|
+
)
|
|
256
|
+
print(result.content[0].text)
|
|
234
257
|
```
|
|
235
258
|
|
|
259
|
+
Top-level `await` works in Jupyter. In a plain script, put the `with` block inside an `async def`
|
|
260
|
+
and run it with `asyncio.run(...)`.
|
|
261
|
+
|
|
236
262
|
Every carbon figure includes a `quality` field: `live` means real grid data, `mock` means a
|
|
237
263
|
fallback value. Check it to confirm live data is on.
|
|
238
264
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "greenpick"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.7"
|
|
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.1.
|
|
7
|
+
__version__ = "0.1.7"
|
|
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.
|
|
@@ -70,7 +70,8 @@ class ForecastAdvice(BaseModel):
|
|
|
70
70
|
class CleanestWindow(BaseModel):
|
|
71
71
|
"""The lowest-average contiguous run of N hours — for timing a job of known duration."""
|
|
72
72
|
window_hours: int
|
|
73
|
-
|
|
73
|
+
start_hour_of_day: Optional[int] = None # 0..23 — the advice: start around this hour each time
|
|
74
|
+
start_period: str # when that window began in the recent data (evidence, not a date to use)
|
|
74
75
|
end_period: str # the last hour in the window
|
|
75
76
|
avg_g_co2_per_kwh: float # mean intensity across the window
|
|
76
77
|
hours: list[HourlyCarbon] = [] # the per-hour breakdown of the chosen window
|
|
@@ -224,7 +225,9 @@ class RightsizingAdvice(BaseModel):
|
|
|
224
225
|
carbon_now_kg_per_month: Optional[float] = None
|
|
225
226
|
carbon_rightsized_kg_per_month: Optional[float] = None
|
|
226
227
|
monthly_kg_co2_saving: Optional[float] = None
|
|
227
|
-
|
|
228
|
+
cost_saving_pct: Optional[float] = None # % off the monthly bill
|
|
229
|
+
carbon_saving_pct: Optional[float] = None # % off monthly kg CO2
|
|
230
|
+
saving_pct: Optional[float] = None # = carbon_saving_pct; kept for compatibility
|
|
228
231
|
carbon_source: Optional[str] = None
|
|
229
232
|
carbon_quality: Optional[str] = None
|
|
230
233
|
rationale: str = ""
|
|
@@ -18,7 +18,29 @@ from .services import (
|
|
|
18
18
|
)
|
|
19
19
|
from .models import ServingHub
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
# Sent to every MCP client at connect. Agents learn a server from its tool list, not its README, and
|
|
22
|
+
# the tool list starts with region-picking — so without this they lead with the rarest decision and
|
|
23
|
+
# skip the most valuable one. This names the strongest uses first and the reporting rules that matter.
|
|
24
|
+
INSTRUCTIONS = """\
|
|
25
|
+
GreenPick advises on cloud infrastructure decisions BEFORE you deploy, weighing carbon, cost and \
|
|
26
|
+
latency together. Strongest uses, in order:
|
|
27
|
+
1. Right-sizing (rightsizing_check): an over-provisioned instance cut to a standard, buyable size \
|
|
28
|
+
saves money and carbon in the same move. Lead with the monthly dollar saving.
|
|
29
|
+
2. Scheduling deferrable jobs: cleanest_hours_to_run (one-off), cleanest_time_of_day (recurring), \
|
|
30
|
+
carbon_forecast (EU day-ahead).
|
|
31
|
+
3. GPU training and LLM inference footprints: estimate_training_carbon (gpu_hours is the TOTAL across \
|
|
32
|
+
all GPUs; report wall_clock_hours as the run length), estimate_inference_carbon.
|
|
33
|
+
4. Efficiency and placement: greener_instance_family, compare_regions_tri_axis, greener_alternative_to, \
|
|
34
|
+
serving_pattern_advice; carbon_metric_scaffold for an app to track its own estimated carbon.
|
|
35
|
+
Before recommending a region move, run data_locality_egress_check: egress can erase the win. Mention \
|
|
36
|
+
any residency_note.
|
|
37
|
+
Every carbon figure carries source and quality fields. Report 'mock', 'cached' or 'historical' values \
|
|
38
|
+
as such, never as live readings — including figures derived from them, such as a kg CO2 saving. If a call fails or output is missing, say so rather than describing \
|
|
39
|
+
what the tool would return. GreenPick estimates; it does not measure running systems or report model \
|
|
40
|
+
accuracy. Use explain_methodology_and_limits when asked how a number was produced.\
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
mcp = FastMCP("greenpick", instructions=INSTRUCTIONS)
|
|
22
44
|
_repo = get_repository()
|
|
23
45
|
|
|
24
46
|
|
|
@@ -83,13 +83,19 @@ async def rightsizing_check(
|
|
|
83
83
|
|
|
84
84
|
is_oversized = saving_pct is not None and saving_pct >= _MIN_SAVING_PCT
|
|
85
85
|
|
|
86
|
+
# Separate, labelled percentages: cost and carbon shrink by different fractions (price and power
|
|
87
|
+
# scale differently with vCPU vs RAM), so one unlabelled % beside a dollar figure misleads.
|
|
88
|
+
cost_pct = (round(usd_saving / cost_now * 100, 1)
|
|
89
|
+
if usd_saving is not None and cost_now else None)
|
|
90
|
+
|
|
86
91
|
if is_oversized:
|
|
87
|
-
|
|
92
|
+
cost_txt = (f"~{cost_pct:.0f}% cheaper (~${usd_saving:,.0f}/mo saved)"
|
|
93
|
+
if cost_pct is not None else "a lower bill")
|
|
88
94
|
rationale = (
|
|
89
95
|
f"Over-provisioned: at ~{cpu_utilization*100:.0f}% CPU use, {vcpu:g} vCPU / {ram_gb:g} GB "
|
|
90
96
|
f"is bigger than you need. Right-size to {rec_vcpu:g} vCPU / {rec_ram:g} GB (the smallest "
|
|
91
|
-
f"standard size that keeps ~40% headroom)
|
|
92
|
-
f"~{kg_saving:.1f} kg CO2/mo. "
|
|
97
|
+
f"standard size that keeps ~40% headroom): {cost_txt} AND ~{saving_pct:.0f}% less carbon "
|
|
98
|
+
f"(~{kg_saving:.1f} kg CO2/mo, {reading.quality} grid data). "
|
|
93
99
|
f"Same work, less waste: the cost and carbon win here are the same action.")
|
|
94
100
|
else:
|
|
95
101
|
rationale = (
|
|
@@ -105,7 +111,8 @@ async def rightsizing_check(
|
|
|
105
111
|
cost_now_usd_per_month=cost_now, cost_rightsized_usd_per_month=cost_rs,
|
|
106
112
|
monthly_usd_saving=usd_saving,
|
|
107
113
|
carbon_now_kg_per_month=carbon_now, carbon_rightsized_kg_per_month=carbon_rs,
|
|
108
|
-
monthly_kg_co2_saving=kg_saving,
|
|
114
|
+
monthly_kg_co2_saving=kg_saving,
|
|
115
|
+
cost_saving_pct=cost_pct, carbon_saving_pct=saving_pct, saving_pct=saving_pct,
|
|
109
116
|
carbon_source=reading.source, carbon_quality=reading.quality,
|
|
110
117
|
rationale=rationale,
|
|
111
118
|
limitations=("Directional, design-time estimate from the utilisation YOU report — not a "
|
|
@@ -37,6 +37,22 @@ def _hour_index(period: str) -> Optional[int]:
|
|
|
37
37
|
return int(dt.timestamp() // 3600) # absolute hours since epoch
|
|
38
38
|
|
|
39
39
|
|
|
40
|
+
def hour_of_day(period: str) -> Optional[int]:
|
|
41
|
+
"""0..23 hour-of-day for a period label (UTC for live ISO timestamps; mock 'H15' -> 15)."""
|
|
42
|
+
idx = _hour_index(period)
|
|
43
|
+
return None if idx is None else idx % 24
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def clock_label(period: str) -> str:
|
|
47
|
+
"""Turn a past period into advice you can act on: '15:00 UTC' (live) or '15:00' (mock hours
|
|
48
|
+
carry no timezone). Recent history says WHEN IN THE DAY is cleanest — the date it happened on
|
|
49
|
+
is evidence, not a start date, so it's never the headline."""
|
|
50
|
+
hod = hour_of_day(period)
|
|
51
|
+
if hod is None:
|
|
52
|
+
return period
|
|
53
|
+
return f"{hod:02d}:00" if period.strip().startswith("H") else f"{hod:02d}:00 UTC"
|
|
54
|
+
|
|
55
|
+
|
|
40
56
|
def _best_and_worst_window(series: list[HourlyCarbon], window_hours: int
|
|
41
57
|
) -> tuple[Optional[list[HourlyCarbon]], Optional[float]]:
|
|
42
58
|
"""Slide a window of `window_hours` over the CHRONOLOGICALLY ordered series, considering only
|
|
@@ -114,20 +130,22 @@ async def cleanest_hours(
|
|
|
114
130
|
win_hours, win_reduction = _best_and_worst_window(history, window_hours)
|
|
115
131
|
if win_hours:
|
|
116
132
|
avg = round(sum(h.g_co2_per_kwh for h in win_hours) / len(win_hours), 1)
|
|
133
|
+
start = win_hours[0].period
|
|
117
134
|
advice.window = CleanestWindow(
|
|
118
|
-
window_hours=window_hours,
|
|
119
|
-
start_period=
|
|
135
|
+
window_hours=window_hours, start_hour_of_day=hour_of_day(start),
|
|
136
|
+
start_period=start, end_period=win_hours[-1].period,
|
|
120
137
|
avg_g_co2_per_kwh=avg, hours=win_hours,
|
|
121
138
|
)
|
|
122
139
|
advice.window_reduction_pct = win_reduction
|
|
140
|
+
seen = f"(the cleanest {window_hours}h window in the recent data began {start})"
|
|
123
141
|
if win_reduction is not None and win_reduction >= 10:
|
|
124
|
-
advice.note = (f"For a ~{window_hours}h job, start
|
|
125
|
-
f"
|
|
126
|
-
f"
|
|
142
|
+
advice.note = (f"For a ~{window_hours}h job, start around {clock_label(start)} "
|
|
143
|
+
f"{seen}: avg ~{avg:.0f} gCO2/kWh, ~{win_reduction:.0f}% cleaner than "
|
|
144
|
+
f"the worst {window_hours}h window. A recent pattern, not a forecast.")
|
|
127
145
|
else:
|
|
128
|
-
advice.note = (f"For a ~{window_hours}h job, the cleanest
|
|
129
|
-
f"{
|
|
130
|
-
f"fairly flat — timing won't help much here.")
|
|
146
|
+
advice.note = (f"For a ~{window_hours}h job, the cleanest start is around "
|
|
147
|
+
f"{clock_label(start)} {seen}, avg ~{avg:.0f} gCO2/kWh, but the grid "
|
|
148
|
+
f"is fairly flat — timing won't help much here.")
|
|
131
149
|
else:
|
|
132
150
|
advice.note = (f"Not enough contiguous history to find a {window_hours}h window; "
|
|
133
151
|
f"showing cleanest individual hours instead. " + advice.note)
|
|
@@ -170,12 +170,14 @@ async def estimate_training_carbon(
|
|
|
170
170
|
|
|
171
171
|
if advice.window and advice.window_reduction_pct and advice.window_reduction_pct >= 10:
|
|
172
172
|
w = advice.window
|
|
173
|
-
time_txt = (f" It runs ~{wall_clock_h}h wall-clock;
|
|
174
|
-
f"
|
|
175
|
-
f"
|
|
176
|
-
f"
|
|
173
|
+
time_txt = (f" It runs ~{wall_clock_h}h wall-clock; start it around "
|
|
174
|
+
f"{scheduling_service.clock_label(w.start_period)} (the cleanest {w.window_hours}h "
|
|
175
|
+
f"window in the recent data began {w.start_period}, avg ~{w.avg_g_co2_per_kwh:.0f} "
|
|
176
|
+
f"gCO2/kWh), ~{advice.window_reduction_pct:.0f}% cleaner than the worst same-length "
|
|
177
|
+
f"window - training is deferrable, so this stacks on the region choice. "
|
|
178
|
+
f"A recent pattern, not a forecast.")
|
|
177
179
|
elif advice.potential_reduction_pct and advice.potential_reduction_pct >= 10 and advice.cleanest:
|
|
178
|
-
best = advice.cleanest[0].period
|
|
180
|
+
best = scheduling_service.clock_label(advice.cleanest[0].period)
|
|
179
181
|
time_txt = (f" Timing it to the cleanest hours in that region (around {best}) cuts a further "
|
|
180
182
|
f"~{advice.potential_reduction_pct:.0f}% vs the dirtiest hour - training is "
|
|
181
183
|
f"deferrable, so this stacks on the region choice.")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: greenpick
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.7
|
|
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
|
|
@@ -254,15 +254,41 @@ of the options below.)
|
|
|
254
254
|
}
|
|
255
255
|
```
|
|
256
256
|
|
|
257
|
-
**In a Jupyter notebook or Python script**,
|
|
257
|
+
**In a Jupyter notebook or Python script**, connect with the MCP client library and pass the keys
|
|
258
|
+
in `env`. The library starts GreenPick as a separate process and doesn't forward the notebook's own
|
|
259
|
+
environment variables to it:
|
|
258
260
|
|
|
259
261
|
```python
|
|
260
|
-
import
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
262
|
+
import sys
|
|
263
|
+
# "mcp" is the MCP Python SDK (installed alongside greenpick), not a greenpick module.
|
|
264
|
+
from mcp import ClientSession, StdioServerParameters
|
|
265
|
+
from mcp.client.stdio import stdio_client
|
|
266
|
+
|
|
267
|
+
params = StdioServerParameters(
|
|
268
|
+
command=sys.executable,
|
|
269
|
+
args=["-m", "greenpick"],
|
|
270
|
+
env={
|
|
271
|
+
"GREENPICK_CARBON_SOURCE": "auto",
|
|
272
|
+
"EIA_API_KEY": "your-eia-key",
|
|
273
|
+
"ENTSOE_TOKEN": "your-entsoe-token",
|
|
274
|
+
},
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
# Keep errlog=: in Jupyter it must be a real file, because the notebook's stderr has no fileno()
|
|
278
|
+
# (without it you get "UnsupportedOperation: fileno").
|
|
279
|
+
with open("greenpick-server.log", "w") as errlog:
|
|
280
|
+
async with stdio_client(params, errlog=errlog) as (read, write):
|
|
281
|
+
async with ClientSession(read, write) as session:
|
|
282
|
+
await session.initialize()
|
|
283
|
+
result = await session.call_tool(
|
|
284
|
+
"region_carbon_now", {"provider": "aws", "region": "us-west-2"}
|
|
285
|
+
)
|
|
286
|
+
print(result.content[0].text)
|
|
264
287
|
```
|
|
265
288
|
|
|
289
|
+
Top-level `await` works in Jupyter. In a plain script, put the `with` block inside an `async def`
|
|
290
|
+
and run it with `asyncio.run(...)`.
|
|
291
|
+
|
|
266
292
|
Every carbon figure includes a `quality` field: `live` means real grid data, `mock` means a
|
|
267
293
|
fallback value. Check it to confirm live data is on.
|
|
268
294
|
|
|
@@ -1234,3 +1234,84 @@ async def test_entsoe_remembers_an_outage(monkeypatch):
|
|
|
1234
1234
|
await repo.carbon_history("aws", "eu-north-1")
|
|
1235
1235
|
assert again.quality == "mock"
|
|
1236
1236
|
assert calls["n"] == after_first
|
|
1237
|
+
|
|
1238
|
+
|
|
1239
|
+
# --- 0.1.6: server-level instructions steer agents (they read the tool list, not the README) ---
|
|
1240
|
+
|
|
1241
|
+
def test_server_sends_instructions_that_lead_with_rightsizing():
|
|
1242
|
+
from greenpick import server
|
|
1243
|
+
text = server.mcp.instructions or ""
|
|
1244
|
+
assert "rightsizing_check" in text
|
|
1245
|
+
# Right-sizing is named before region-picking (the tool list itself starts with region tools).
|
|
1246
|
+
assert text.index("rightsizing_check") < text.index("compare_regions_tri_axis")
|
|
1247
|
+
# The reporting rules that outside agents got wrong in testing.
|
|
1248
|
+
assert "mock" in text and "TOTAL" in text and "data_locality_egress_check" in text
|
|
1249
|
+
# Every tool named in the instructions actually exists.
|
|
1250
|
+
import re, asyncio
|
|
1251
|
+
named = set(re.findall(r"\b[a-z]+(?:_[a-z]+)+\b", text)) - {"wall_clock_hours", "gpu_hours", "residency_note"}
|
|
1252
|
+
registered = {t.name for t in asyncio.run(server.mcp.list_tools())}
|
|
1253
|
+
assert named <= registered, named - registered
|
|
1254
|
+
|
|
1255
|
+
|
|
1256
|
+
# --- 0.1.7: labelled percentages; start advice as a time of day, not a past date ---
|
|
1257
|
+
|
|
1258
|
+
@pytest.mark.asyncio
|
|
1259
|
+
async def test_rightsizing_labels_cost_and_carbon_percentages_separately():
|
|
1260
|
+
from greenpick.services import rightsizing_service
|
|
1261
|
+
repo = MockCarbonRepository()
|
|
1262
|
+
adv = await rightsizing_service.rightsizing_check(
|
|
1263
|
+
repo, "aws", "us-east-1", vcpu=8, ram_gb=32, cpu_utilization=0.15,
|
|
1264
|
+
)
|
|
1265
|
+
# Cost % is the bill saving; carbon % is the kg saving. They differ, so both are reported.
|
|
1266
|
+
assert adv.cost_saving_pct == round(adv.monthly_usd_saving / adv.cost_now_usd_per_month * 100, 1)
|
|
1267
|
+
assert adv.carbon_saving_pct == round(adv.monthly_kg_co2_saving / adv.carbon_now_kg_per_month * 100, 1)
|
|
1268
|
+
assert adv.cost_saving_pct != adv.carbon_saving_pct
|
|
1269
|
+
assert adv.saving_pct == adv.carbon_saving_pct # compatibility field unchanged
|
|
1270
|
+
assert f"~{adv.cost_saving_pct:.0f}% cheaper" in adv.rationale
|
|
1271
|
+
assert f"~{adv.carbon_saving_pct:.0f}% less carbon" in adv.rationale
|
|
1272
|
+
assert "mock grid data" in adv.rationale # derived carbon carries its quality
|
|
1273
|
+
|
|
1274
|
+
|
|
1275
|
+
def test_clock_label_turns_past_periods_into_a_time_of_day():
|
|
1276
|
+
from greenpick.services import scheduling_service as ss
|
|
1277
|
+
assert ss.clock_label("2026-10-17T15") == "15:00 UTC"
|
|
1278
|
+
assert ss.clock_label("2026-10-17T15:00Z") == "15:00 UTC"
|
|
1279
|
+
assert ss.clock_label("H02") == "02:00" # mock hours have no timezone
|
|
1280
|
+
assert ss.hour_of_day("2026-10-17T15") == 15
|
|
1281
|
+
|
|
1282
|
+
|
|
1283
|
+
@pytest.mark.asyncio
|
|
1284
|
+
async def test_window_advice_leads_with_time_of_day_not_a_date(monkeypatch):
|
|
1285
|
+
from greenpick.services import scheduling_service as ss
|
|
1286
|
+
from greenpick.models import HourlyCarbon
|
|
1287
|
+
|
|
1288
|
+
class Repo(MockCarbonRepository):
|
|
1289
|
+
async def carbon_history(self, provider, region):
|
|
1290
|
+
# Two past days; 15:00-19:00 is clean on the 17th.
|
|
1291
|
+
out = []
|
|
1292
|
+
for day in ("2026-10-17", "2026-10-18"):
|
|
1293
|
+
for h in range(24):
|
|
1294
|
+
g = 60.0 if (day == "2026-10-17" and 15 <= h <= 19) else 200.0
|
|
1295
|
+
out.append(HourlyCarbon(period=f"{day}T{h:02d}", g_co2_per_kwh=g))
|
|
1296
|
+
return out
|
|
1297
|
+
|
|
1298
|
+
adv = await ss.cleanest_hours(Repo(), "aws", "us-west-2", window_hours=5)
|
|
1299
|
+
assert adv.window.start_hour_of_day == 15
|
|
1300
|
+
assert adv.window.start_period == "2026-10-17T15" # kept, as evidence
|
|
1301
|
+
assert adv.note.startswith("For a ~5h job, start around 15:00 UTC")
|
|
1302
|
+
|
|
1303
|
+
|
|
1304
|
+
@pytest.mark.asyncio
|
|
1305
|
+
async def test_training_rationale_says_start_around_a_time():
|
|
1306
|
+
from greenpick.services import training_service
|
|
1307
|
+
repo = MockCarbonRepository()
|
|
1308
|
+
adv = await training_service.estimate_training_carbon(
|
|
1309
|
+
repo, gpu="a100", gpu_count=8, gpu_hours=40, region="us-east-1", provider="aws",
|
|
1310
|
+
)
|
|
1311
|
+
assert adv.cleanest_window is not None and adv.cleanest_window.start_hour_of_day is not None
|
|
1312
|
+
assert f"start it around {adv.cleanest_window.start_hour_of_day:02d}:00" in adv.rationale
|
|
1313
|
+
|
|
1314
|
+
|
|
1315
|
+
def test_instructions_cover_derived_mock_figures():
|
|
1316
|
+
from greenpick import server
|
|
1317
|
+
assert "derived from them" in server.mcp.instructions
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|