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.
Files changed (49) hide show
  1. {greenpick-0.1.5/src/greenpick.egg-info → greenpick-0.1.7}/PKG-INFO +32 -6
  2. {greenpick-0.1.5 → greenpick-0.1.7}/README.md +31 -5
  3. {greenpick-0.1.5 → greenpick-0.1.7}/pyproject.toml +1 -1
  4. {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/__init__.py +1 -1
  5. {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/models.py +5 -2
  6. {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/server.py +23 -1
  7. {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/services/rightsizing_service.py +11 -4
  8. {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/services/scheduling_service.py +26 -8
  9. {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/services/training_service.py +7 -5
  10. {greenpick-0.1.5 → greenpick-0.1.7/src/greenpick.egg-info}/PKG-INFO +32 -6
  11. {greenpick-0.1.5 → greenpick-0.1.7}/tests/test_services.py +81 -0
  12. {greenpick-0.1.5 → greenpick-0.1.7}/LICENSE +0 -0
  13. {greenpick-0.1.5 → greenpick-0.1.7}/setup.cfg +0 -0
  14. {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/__main__.py +0 -0
  15. {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/config.py +0 -0
  16. {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/data/__init__.py +0 -0
  17. {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/data/_net.py +0 -0
  18. {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/data/auto_repository.py +0 -0
  19. {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/data/eia_repository.py +0 -0
  20. {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/data/electricitymaps_repository.py +0 -0
  21. {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/data/emission_factors.json +0 -0
  22. {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/data/entsoe_repository.py +0 -0
  23. {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/data/gpu_power.json +0 -0
  24. {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/data/inference_energy.json +0 -0
  25. {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/data/instance_families.json +0 -0
  26. {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/data/mock_repository.py +0 -0
  27. {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/data/network.json +0 -0
  28. {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/data/power.json +0 -0
  29. {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/data/pricing.json +0 -0
  30. {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/data/regions.json +0 -0
  31. {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/data/repository.py +0 -0
  32. {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/data/static_data.py +0 -0
  33. {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/services/__init__.py +0 -0
  34. {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/services/carbon_service.py +0 -0
  35. {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/services/cost_service.py +0 -0
  36. {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/services/forecast_service.py +0 -0
  37. {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/services/inference_service.py +0 -0
  38. {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/services/instance_service.py +0 -0
  39. {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/services/latency_service.py +0 -0
  40. {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/services/locality_service.py +0 -0
  41. {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/services/metric_scaffold_service.py +0 -0
  42. {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/services/recommender.py +0 -0
  43. {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/services/residency_service.py +0 -0
  44. {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick/services/serving_service.py +0 -0
  45. {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick.egg-info/SOURCES.txt +0 -0
  46. {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick.egg-info/dependency_links.txt +0 -0
  47. {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick.egg-info/entry_points.txt +0 -0
  48. {greenpick-0.1.5 → greenpick-0.1.7}/src/greenpick.egg-info/requires.txt +0 -0
  49. {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.5
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**, set them before you start (or import) the server:
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 os
261
- os.environ["GREENPICK_CARBON_SOURCE"] = "auto"
262
- os.environ["EIA_API_KEY"] = "your-eia-key"
263
- os.environ["ENTSOE_TOKEN"] = "your-entsoe-token"
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**, set them before you start (or import) the server:
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 os
231
- os.environ["GREENPICK_CARBON_SOURCE"] = "auto"
232
- os.environ["EIA_API_KEY"] = "your-eia-key"
233
- os.environ["ENTSOE_TOKEN"] = "your-entsoe-token"
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.5"
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.5"
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
- start_period: str # the hour the window begins (start your job here)
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
- saving_pct: Optional[float] = None
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
- mcp = FastMCP("greenpick")
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
- usd_txt = f"~${usd_saving:,.0f}/mo" if usd_saving is not None else "cost"
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) to cut ~{saving_pct:.0f}% - {usd_txt} AND "
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, saving_pct=saving_pct,
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=win_hours[0].period, end_period=win_hours[-1].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 it at {win_hours[0].period}: that's "
125
- f"the cleanest {window_hours}h window (avg ~{avg:.0f} gCO2/kWh), "
126
- f"~{win_reduction:.0f}% cleaner than the worst {window_hours}h window.")
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 window starts at "
129
- f"{win_hours[0].period} (avg ~{avg:.0f} gCO2/kWh), but the grid is "
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; the cleanest {w.window_hours}h window to "
174
- f"start is {w.start_period} (avg ~{w.avg_g_co2_per_kwh:.0f} gCO2/kWh), "
175
- f"~{advice.window_reduction_pct:.0f}% cleaner than the worst same-length window - "
176
- f"training is deferrable, so this stacks on the region choice.")
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.5
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**, set them before you start (or import) the server:
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 os
261
- os.environ["GREENPICK_CARBON_SOURCE"] = "auto"
262
- os.environ["EIA_API_KEY"] = "your-eia-key"
263
- os.environ["ENTSOE_TOKEN"] = "your-entsoe-token"
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