greenpick 0.1.6__tar.gz → 0.1.8__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 (50) hide show
  1. {greenpick-0.1.6/src/greenpick.egg-info → greenpick-0.1.8}/PKG-INFO +7 -3
  2. {greenpick-0.1.6 → greenpick-0.1.8}/README.md +6 -2
  3. {greenpick-0.1.6 → greenpick-0.1.8}/pyproject.toml +1 -1
  4. {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/__init__.py +1 -1
  5. {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/models.py +9 -2
  6. {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/server.py +20 -16
  7. greenpick-0.1.8/src/greenpick/services/data_hint.py +147 -0
  8. {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/services/forecast_service.py +19 -7
  9. {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/services/locality_service.py +1 -0
  10. {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/services/rightsizing_service.py +11 -4
  11. {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/services/scheduling_service.py +26 -8
  12. {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/services/serving_service.py +2 -0
  13. {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/services/training_service.py +7 -5
  14. {greenpick-0.1.6 → greenpick-0.1.8/src/greenpick.egg-info}/PKG-INFO +7 -3
  15. {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick.egg-info/SOURCES.txt +1 -0
  16. {greenpick-0.1.6 → greenpick-0.1.8}/tests/test_services.py +202 -4
  17. {greenpick-0.1.6 → greenpick-0.1.8}/LICENSE +0 -0
  18. {greenpick-0.1.6 → greenpick-0.1.8}/setup.cfg +0 -0
  19. {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/__main__.py +0 -0
  20. {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/config.py +0 -0
  21. {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/data/__init__.py +0 -0
  22. {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/data/_net.py +0 -0
  23. {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/data/auto_repository.py +0 -0
  24. {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/data/eia_repository.py +0 -0
  25. {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/data/electricitymaps_repository.py +0 -0
  26. {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/data/emission_factors.json +0 -0
  27. {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/data/entsoe_repository.py +0 -0
  28. {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/data/gpu_power.json +0 -0
  29. {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/data/inference_energy.json +0 -0
  30. {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/data/instance_families.json +0 -0
  31. {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/data/mock_repository.py +0 -0
  32. {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/data/network.json +0 -0
  33. {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/data/power.json +0 -0
  34. {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/data/pricing.json +0 -0
  35. {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/data/regions.json +0 -0
  36. {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/data/repository.py +0 -0
  37. {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/data/static_data.py +0 -0
  38. {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/services/__init__.py +0 -0
  39. {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/services/carbon_service.py +0 -0
  40. {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/services/cost_service.py +0 -0
  41. {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/services/inference_service.py +0 -0
  42. {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/services/instance_service.py +0 -0
  43. {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/services/latency_service.py +0 -0
  44. {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/services/metric_scaffold_service.py +0 -0
  45. {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/services/recommender.py +0 -0
  46. {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/services/residency_service.py +0 -0
  47. {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick.egg-info/dependency_links.txt +0 -0
  48. {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick.egg-info/entry_points.txt +0 -0
  49. {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick.egg-info/requires.txt +0 -0
  50. {greenpick-0.1.6 → greenpick-0.1.8}/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.6
3
+ Version: 0.1.8
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
@@ -260,6 +260,7 @@ environment variables to it:
260
260
 
261
261
  ```python
262
262
  import sys
263
+ # "mcp" is the MCP Python SDK (installed alongside greenpick), not a greenpick module.
263
264
  from mcp import ClientSession, StdioServerParameters
264
265
  from mcp.client.stdio import stdio_client
265
266
 
@@ -273,7 +274,8 @@ params = StdioServerParameters(
273
274
  },
274
275
  )
275
276
 
276
- # In Jupyter, errlog must be a real file (the notebook's stderr has no fileno()).
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").
277
279
  with open("greenpick-server.log", "w") as errlog:
278
280
  async with stdio_client(params, errlog=errlog) as (read, write):
279
281
  async with ClientSession(read, write) as session:
@@ -288,7 +290,9 @@ Top-level `await` works in Jupyter. In a plain script, put the `with` block insi
288
290
  and run it with `asyncio.run(...)`.
289
291
 
290
292
  Every carbon figure includes a `quality` field: `live` means real grid data, `mock` means a
291
- fallback value. Check it to confirm live data is on.
293
+ fallback value. Check it to confirm live data is on. A result that isn't live also includes a
294
+ `live_data_hint` saying why (no key set, the source didn't respond, or no free source covers that
295
+ region) and what to do about it.
292
296
 
293
297
  ## FAQ
294
298
 
@@ -230,6 +230,7 @@ environment variables to it:
230
230
 
231
231
  ```python
232
232
  import sys
233
+ # "mcp" is the MCP Python SDK (installed alongside greenpick), not a greenpick module.
233
234
  from mcp import ClientSession, StdioServerParameters
234
235
  from mcp.client.stdio import stdio_client
235
236
 
@@ -243,7 +244,8 @@ params = StdioServerParameters(
243
244
  },
244
245
  )
245
246
 
246
- # In Jupyter, errlog must be a real file (the notebook's stderr has no fileno()).
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").
247
249
  with open("greenpick-server.log", "w") as errlog:
248
250
  async with stdio_client(params, errlog=errlog) as (read, write):
249
251
  async with ClientSession(read, write) as session:
@@ -258,7 +260,9 @@ Top-level `await` works in Jupyter. In a plain script, put the `with` block insi
258
260
  and run it with `asyncio.run(...)`.
259
261
 
260
262
  Every carbon figure includes a `quality` field: `live` means real grid data, `mock` means a
261
- fallback value. Check it to confirm live data is on.
263
+ fallback value. Check it to confirm live data is on. A result that isn't live also includes a
264
+ `live_data_hint` saying why (no key set, the source didn't respond, or no free source covers that
265
+ region) and what to do about it.
262
266
 
263
267
  ## FAQ
264
268
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "greenpick"
3
- version = "0.1.6"
3
+ version = "0.1.8"
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.6"
7
+ __version__ = "0.1.8"
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 = ""
@@ -274,6 +277,8 @@ class ServingPerHubChoice(BaseModel):
274
277
  region: str
275
278
  latency_ms: float
276
279
  carbon_g_co2_per_kwh: float
280
+ carbon_source: Optional[str] = None
281
+ carbon_quality: Optional[str] = None
277
282
 
278
283
 
279
284
  class ServingPatternAdvice(BaseModel):
@@ -308,6 +313,8 @@ class DataLocalityWarning(BaseModel):
308
313
  est_monthly_egress_usd_low: Optional[float] = None # monthly cost range = rate range x GB
309
314
  est_monthly_egress_usd_high: Optional[float] = None
310
315
  est_monthly_transfer_kg_co2: Optional[float] = None # secondary, coarse
316
+ transfer_carbon_source: Optional[str] = None # grid reading behind the transfer carbon
317
+ transfer_carbon_quality: Optional[str] = None
311
318
  carbon_saving_kg_co2_from_move: Optional[float] = None # echoed back if provided
312
319
  net_carbon_verdict: Optional[str] = None # set only when a saving was supplied
313
320
  residency_note: Optional[str] = None # neutral cross-jurisdiction heads-up, if any
@@ -17,6 +17,7 @@ from .services import (
17
17
  rightsizing_service, inference_service,
18
18
  )
19
19
  from .models import ServingHub
20
+ from .services.data_hint import with_hint
20
21
 
21
22
  # Sent to every MCP client at connect. Agents learn a server from its tool list, not its README, and
22
23
  # the tool list starts with region-picking — so without this they lead with the rarest decision and
@@ -35,7 +36,9 @@ serving_pattern_advice; carbon_metric_scaffold for an app to track its own estim
35
36
  Before recommending a region move, run data_locality_egress_check: egress can erase the win. Mention \
36
37
  any residency_note.
37
38
  Every carbon figure carries source and quality fields. Report 'mock', 'cached' or 'historical' values \
38
- as such, never as live readings. If a call fails or output is missing, say so rather than describing \
39
+ as such, never as live readings — including figures derived from them, such as a kg CO2 saving. \
40
+ When a result includes live_data_hint, pass it on: it says why the data isn't live and how to turn \
41
+ live data on. If a call fails or output is missing, say so rather than describing \
39
42
  what the tool would return. GreenPick estimates; it does not measure running systems or report model \
40
43
  accuracy. Use explain_methodology_and_limits when asked how a number was produced.\
41
44
  """
@@ -73,7 +76,7 @@ async def compare_regions_tri_axis(
73
76
  baseline_region=baseline_region,
74
77
  regions=[r for r in (regions or [])] or None,
75
78
  )
76
- return rec.model_dump()
79
+ return with_hint(rec.model_dump())
77
80
 
78
81
 
79
82
  @mcp.tool()
@@ -103,7 +106,7 @@ async def greener_alternative_to(
103
106
  baseline_region=current_region,
104
107
  regions=[r for r in (regions or [])] or None,
105
108
  )
106
- return rec.model_dump()
109
+ return with_hint(rec.model_dump())
107
110
 
108
111
 
109
112
  @mcp.tool()
@@ -119,7 +122,7 @@ async def estimate_workload_carbon(
119
122
  reading = await carbon_service.carbon_now(_repo, provider, region) # type: ignore[arg-type]
120
123
  wl = Workload(vcpu=vcpu, ram_gb=ram_gb, hours_per_month=hours_per_month)
121
124
  kg = carbon_service.estimate_kg_co2_per_month(wl, reading)
122
- return {
125
+ return with_hint({
123
126
  "provider": provider,
124
127
  "region": region,
125
128
  "estimated_kg_co2_per_month": kg,
@@ -127,14 +130,14 @@ async def estimate_workload_carbon(
127
130
  "carbon_source": reading.source,
128
131
  "carbon_quality": reading.quality,
129
132
  "note": "Estimate only: documented power model x grid intensity. Not a measurement.",
130
- }
133
+ })
131
134
 
132
135
 
133
136
  @mcp.tool()
134
137
  async def region_carbon_now(provider: str, region: str) -> dict:
135
138
  """Current grid carbon intensity for a region, with source, timestamp, and data freshness."""
136
139
  reading = await carbon_service.carbon_now(_repo, provider, region) # type: ignore[arg-type]
137
- return reading.model_dump()
140
+ return with_hint(reading.model_dump())
138
141
 
139
142
 
140
143
  @mcp.tool()
@@ -149,7 +152,7 @@ async def cleanest_hours_to_run(
149
152
  advice = await scheduling_service.cleanest_hours(
150
153
  _repo, provider, region, top_n, window_hours=window_hours, # type: ignore[arg-type]
151
154
  )
152
- return advice.model_dump()
155
+ return with_hint(advice.model_dump())
153
156
 
154
157
 
155
158
  @mcp.tool()
@@ -177,7 +180,7 @@ async def greener_instance_family(
177
180
  _repo, provider, region, vcpu, ram_gb, # type: ignore[arg-type]
178
181
  current_family=current_family, hours_per_month=hours_per_month,
179
182
  )
180
- return advice.model_dump()
183
+ return with_hint(advice.model_dump())
181
184
 
182
185
 
183
186
  @mcp.tool()
@@ -199,7 +202,7 @@ async def estimate_inference_carbon(
199
202
  _repo, size_class, monthly_requests, avg_output_tokens=avg_output_tokens, # type: ignore[arg-type]
200
203
  region=region, provider=provider, # type: ignore[arg-type]
201
204
  )
202
- return advice.model_dump()
205
+ return with_hint(advice.model_dump())
203
206
 
204
207
 
205
208
  @mcp.tool()
@@ -222,7 +225,7 @@ async def rightsizing_check(
222
225
  _repo, provider, region, vcpu, ram_gb, cpu_utilization, # type: ignore[arg-type]
223
226
  ram_utilization=ram_utilization, hours_per_month=hours_per_month,
224
227
  )
225
- return advice.model_dump()
228
+ return with_hint(advice.model_dump())
226
229
 
227
230
 
228
231
  @mcp.tool()
@@ -245,7 +248,7 @@ async def carbon_metric_scaffold(
245
248
  _repo, language=language, region=region, provider=provider, # type: ignore[arg-type]
246
249
  vcpu=vcpu, ram_gb=ram_gb,
247
250
  )
248
- return scaffold.model_dump()
251
+ return with_hint(scaffold.model_dump())
249
252
 
250
253
 
251
254
  @mcp.tool()
@@ -275,7 +278,7 @@ async def serving_pattern_advice(
275
278
  _repo, parsed, Workload(vcpu=vcpu, ram_gb=ram_gb),
276
279
  providers=[p for p in (providers or [])] or None, # type: ignore[list-item]
277
280
  )
278
- return advice.model_dump()
281
+ return with_hint(advice.model_dump())
279
282
 
280
283
 
281
284
  @mcp.tool()
@@ -297,7 +300,7 @@ async def data_locality_egress_check(
297
300
  monthly_cross_boundary_gb=monthly_cross_boundary_gb,
298
301
  carbon_saving_kg_co2_from_move=carbon_saving_kg_co2_from_move,
299
302
  )
300
- return advice.model_dump()
303
+ return with_hint(advice.model_dump())
301
304
 
302
305
 
303
306
  @mcp.tool()
@@ -312,7 +315,7 @@ async def carbon_forecast(
312
315
  advice = await forecast_service.carbon_forecast(
313
316
  _repo, provider, region, hours_ahead=hours_ahead, top_n=top_n, # type: ignore[arg-type]
314
317
  )
315
- return advice.model_dump()
318
+ return with_hint(advice.model_dump())
316
319
 
317
320
 
318
321
  @mcp.tool()
@@ -322,7 +325,7 @@ async def cleanest_time_of_day(provider: str, region: str) -> dict:
322
325
  dirtiest hour-of-day and the % gap. Honest scope: a DAILY pattern over the few days of free
323
326
  data, NOT a 7-day day-of-week pattern. Use `cleanest_hours_to_run` for a one-off job's timing."""
324
327
  advice = await scheduling_service.cleanest_time_of_day(_repo, provider, region) # type: ignore[arg-type]
325
- return advice.model_dump()
328
+ return with_hint(advice.model_dump())
326
329
 
327
330
 
328
331
  @mcp.tool()
@@ -349,7 +352,7 @@ async def estimate_training_carbon(
349
352
  region=region, provider=provider, utilization=utilization, # type: ignore[arg-type]
350
353
  top_n_regions=top_n_regions,
351
354
  )
352
- return advice.model_dump()
355
+ return with_hint(advice.model_dump())
353
356
 
354
357
 
355
358
  @mcp.tool()
@@ -369,5 +372,6 @@ def explain_methodology_and_limits() -> dict:
369
372
  "forecast": "Day-ahead forecast is RELATIVE: upcoming hours ranked by forecast wind+solar output (ENTSO-E A69), not an absolute gCO2/kWh forecast. EU only; US/uncovered zones degrade to the recent historical pattern (labelled 'historical').",
370
373
  "data_residency": "GreenPick flags when a recommended move CROSSES a jurisdiction (esp. leaving the EEA) as a geographic heads-up only. It does NOT assess legal/GDPR compliance, does not encode adequacy decisions, and is not legal advice — confirm with your own data governance.",
371
374
  "ignored": "Embodied/hardware carbon and provider renewable-energy certificates.",
375
+ "mock_data": "Used when live data isn't available (no key, an uncovered region, or the source didn't respond). Each grid gets a fixed typical value — illustrative, not a current reading — and an illustrative hourly pattern, so timing advice on mock data isn't meaningful. Results that use it are labelled 'mock' and carry a live_data_hint saying why and how to turn live data on.",
372
376
  "data_sources": "GREENPICK_CARBON_SOURCE=auto routes US regions to EIA-930 and EU regions to ENTSO-E (both free, live); other regions, or any region without a key, use clearly-labelled mock data. Electricity Maps is available when selected explicitly (GREENPICK_CARBON_SOURCE=electricitymaps).",
373
377
  }
@@ -0,0 +1,147 @@
1
+ """Tell the user WHY a result isn't live, and how to turn live data on.
2
+
3
+ A tool result labelled `quality: "mock"` is honest, but on its own it reads like "this is all
4
+ GreenPick does". The server log explains the cause ("No EIA_API_KEY set ..."), but MCP clients
5
+ don't show server logs to users. So any result that leans on mock data gets a top-level
6
+ `live_data_hint`, worded for the actual cause:
7
+
8
+ - GreenPick is configured for mock data (the default) -> how to switch on live data
9
+ - the region's source needs a key that isn't set -> which key, where to get it free
10
+ - the key is set but the source didn't answer / refused it -> try later or check the key
11
+ - no free live source covers the region -> mock is the best available
12
+
13
+ Mock values are a typical figure per grid, not a current reading; mock HOURLY patterns are
14
+ illustrative, so timing advice built on them isn't meaningful. The hint says so.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from typing import Any, Iterator, Optional
19
+
20
+ from .. import config
21
+ from ..data.static_data import region_meta
22
+
23
+ TURN_ON_URL = "https://github.com/bithug01/greenpick#turn-on-live-data"
24
+ _KEYS = {
25
+ "EIA_API_KEY": "https://www.eia.gov/opendata/register.php",
26
+ "ENTSOE_TOKEN": "https://transparency.entsoe.eu/",
27
+ "ELECTRICITYMAPS_TOKEN": "https://www.electricitymaps.com/",
28
+ }
29
+ _QUALITY_KEYS = ("quality", "carbon_quality", "timing_quality", "transfer_carbon_quality")
30
+ _TIMING_KEYS = ("cleanest", "by_hour_of_day", "cleanest_hours", "cleanest_window", "cleanest_upcoming")
31
+
32
+
33
+ def _location(d: dict) -> Optional[tuple[str, str]]:
34
+ """(provider, region) for a result dict, from provider/region fields or a 'prov/region' string."""
35
+ if isinstance(d.get("provider"), str) and isinstance(d.get("region"), str):
36
+ return d["provider"], d["region"]
37
+ for key in ("recommended_region", "data_location"):
38
+ v = d.get(key)
39
+ if isinstance(v, str) and "/" in v:
40
+ prov, reg = v.split("/", 1)
41
+ return prov, reg
42
+ return None
43
+
44
+
45
+ def _walk(obj: Any) -> Iterator[dict]:
46
+ if isinstance(obj, dict):
47
+ yield obj
48
+ for v in obj.values():
49
+ yield from _walk(v)
50
+ elif isinstance(obj, list):
51
+ for v in obj:
52
+ yield from _walk(v)
53
+
54
+
55
+ def _scan(result: dict) -> tuple[set[tuple[str, str]], bool, bool]:
56
+ """Regions whose figures are mock, whether any mock figure had no locatable region, and
57
+ whether mock data fed timing advice."""
58
+ regions: set[tuple[str, str]] = set()
59
+ unlocated = False
60
+ timing = False
61
+ for d in _walk(result):
62
+ mock_keys = [k for k in _QUALITY_KEYS if d.get(k) == "mock"]
63
+ if not mock_keys:
64
+ continue
65
+ loc = _location(d)
66
+ if loc:
67
+ regions.add(loc)
68
+ else:
69
+ unlocated = True
70
+ if "timing_quality" in mock_keys or any(k in d for k in _TIMING_KEYS):
71
+ timing = True
72
+ return regions, unlocated, timing
73
+
74
+
75
+ def _reason(provider: str, region: str) -> tuple[str, str]:
76
+ """(reason, detail) for why this region's data is mock under the current settings."""
77
+ s = config.SETTINGS
78
+ meta = region_meta(provider, region) or {} # type: ignore[arg-type]
79
+ src = s.carbon_source
80
+
81
+ def needs(key_name: str, have: str, source_label: str) -> tuple[str, str]:
82
+ return ("no_key", key_name) if not have else ("unavailable", source_label)
83
+
84
+ if src == "auto":
85
+ if meta.get("eia_ba"):
86
+ return needs("EIA_API_KEY", s.eia_api_key, "EIA")
87
+ if meta.get("entsoe_eic"):
88
+ return needs("ENTSOE_TOKEN", s.entsoe_token, "ENTSO-E")
89
+ return "uncovered", ""
90
+ if src == "eia":
91
+ return needs("EIA_API_KEY", s.eia_api_key, "EIA") if meta.get("eia_ba") else ("wrong_source", "EIA (US only)")
92
+ if src == "entsoe":
93
+ return (needs("ENTSOE_TOKEN", s.entsoe_token, "ENTSO-E") if meta.get("entsoe_eic")
94
+ else ("wrong_source", "ENTSO-E (EU only)"))
95
+ if src == "electricitymaps":
96
+ return needs("ELECTRICITYMAPS_TOKEN", s.electricitymaps_token, "Electricity Maps")
97
+ return "setting", ""
98
+
99
+
100
+ def _names(locs: list[tuple[str, str]]) -> str:
101
+ return ", ".join(f"{p}/{r}" for p, r in sorted(locs))
102
+
103
+
104
+ def build_hint(result: dict) -> Optional[str]:
105
+ regions, unlocated, timing = _scan(result)
106
+ if not regions and not unlocated:
107
+ return None
108
+
109
+ parts = ["Mock carbon figures here are a typical value for each grid, not a current reading."]
110
+ groups: dict[tuple[str, str], list[tuple[str, str]]] = {}
111
+ for loc in regions:
112
+ groups.setdefault(_reason(*loc), []).append(loc)
113
+ if unlocated and not groups:
114
+ groups[_reason("", "")] = []
115
+
116
+ for (reason, detail), locs in sorted(groups.items()):
117
+ where = f" for {_names(locs)}" if locs else ""
118
+ if reason == "setting":
119
+ parts.append(
120
+ "GreenPick is set to mock data (GREENPICK_CARBON_SOURCE is unset or 'mock'). For live "
121
+ "data, set GREENPICK_CARBON_SOURCE=auto plus a free EIA_API_KEY (US) and/or "
122
+ f"ENTSOE_TOKEN (EU) in the server's environment: {TURN_ON_URL}")
123
+ elif reason == "no_key":
124
+ parts.append(f"For live data{where}, set {detail} (free: {_KEYS[detail]}) in the server's "
125
+ f"environment: {TURN_ON_URL}")
126
+ elif reason == "unavailable":
127
+ parts.append(f"{detail} didn't return data{where} just now, so GreenPick used its fallback. "
128
+ "Try again later, or check that the key is valid.")
129
+ elif reason == "wrong_source":
130
+ parts.append(f"GREENPICK_CARBON_SOURCE is set to {detail}, which doesn't cover "
131
+ f"{_names(locs) or 'this region'}. Use GREENPICK_CARBON_SOURCE=auto to route "
132
+ "each region to its own live source.")
133
+ elif reason == "uncovered":
134
+ parts.append(f"No free live source covers {_names(locs)} yet, so mock is the best "
135
+ "available there.")
136
+ if timing:
137
+ parts.append("The hourly pattern in mock data is illustrative, so timing advice (cleanest "
138
+ "hours, start windows, % reductions) isn't meaningful until live data is on.")
139
+ return " ".join(parts)
140
+
141
+
142
+ def with_hint(result: dict) -> dict:
143
+ """Add `live_data_hint` to a tool result that uses mock data; leave others unchanged."""
144
+ hint = build_hint(result)
145
+ if hint:
146
+ result["live_data_hint"] = hint
147
+ return result
@@ -58,15 +58,27 @@ async def carbon_forecast(
58
58
 
59
59
  # --- Degrade: no forward forecast for this region -> recent historical daily pattern ---
60
60
  pattern = await scheduling_service.cleanest_time_of_day(repo, provider, region)
61
- note = ("No day-ahead forecast available for this region, so this is the recent HISTORICAL "
62
- "pattern, not a true forecast. ")
63
- if pattern.cleanest_hour_of_day is not None:
64
- note += (f"Over the last ~{pattern.days_analyzed} day(s) it's been cleanest around "
65
- f"{pattern.cleanest_hour_of_day:02d}:00 - a reasonable default for a recurring job.")
61
+ if pattern.quality == "mock":
62
+ # No live history either: the "pattern" is GreenPick's illustrative mock curve. Label it
63
+ # mock — calling it 'historical' would pass a synthetic shape off as real past data.
64
+ source, quality = "mock", "mock"
65
+ note = ("No day-ahead forecast or live history is available for this region, so there's "
66
+ "no real timing signal here. ")
67
+ if pattern.cleanest_hour_of_day is not None:
68
+ note += (f"GreenPick's illustrative mock pattern is cleanest around "
69
+ f"{pattern.cleanest_hour_of_day:02d}:00, but that's not meaningful for scheduling.")
66
70
  else:
67
- note += "No usable recent history either."
71
+ source, quality = "historical_fallback", "historical"
72
+ note = ("No day-ahead forecast available for this region, so this is the recent HISTORICAL "
73
+ "pattern, not a true forecast. ")
74
+ if pattern.cleanest_hour_of_day is not None:
75
+ note += (f"Over the last ~{pattern.days_analyzed} day(s) it's been cleanest around "
76
+ f"{pattern.cleanest_hour_of_day:02d}:00 UTC - a reasonable default for a "
77
+ f"recurring job.")
78
+ else:
79
+ note += "No usable recent history either."
68
80
  return ForecastAdvice(
69
- region=region, provider=provider, source="historical_fallback", quality="historical",
81
+ region=region, provider=provider, source=source, quality=quality,
70
82
  hours_ahead=hours_ahead, cleanest_upcoming=[],
71
83
  note=note, limitations=LIMITATIONS,
72
84
  )
@@ -158,6 +158,7 @@ async def data_locality_check(
158
158
  egress_rate_usd_per_gb_low=rate_low, egress_rate_usd_per_gb_high=rate_high,
159
159
  est_monthly_egress_usd_low=cost_low, est_monthly_egress_usd_high=cost_high,
160
160
  est_monthly_transfer_kg_co2=transfer_kg,
161
+ transfer_carbon_source=reading.source, transfer_carbon_quality=reading.quality,
161
162
  carbon_saving_kg_co2_from_move=carbon_saving_kg_co2_from_move,
162
163
  net_carbon_verdict=verdict, residency_note=residency_note,
163
164
  warning=warning, recommendation=rec, limitations=LIMITATIONS,
@@ -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)
@@ -123,9 +123,11 @@ async def serving_pattern(
123
123
  nearby = [s for s in scored if s[0] <= near_min]
124
124
  nearby.sort(key=lambda t: t[1]) # greenest among the near-latency options
125
125
  l, g, p, r = nearby[0]
126
+ reading = carbon_cache[(p, r)]
126
127
  per_hub.append(ServingPerHubChoice(
127
128
  hub=h.name, provider=p, region=r, latency_ms=round(l, 1),
128
129
  carbon_g_co2_per_kwh=g,
130
+ carbon_source=reading.source, carbon_quality=reading.quality, # type: ignore[attr-defined]
129
131
  ))
130
132
 
131
133
  multi_wlat = None
@@ -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.6
3
+ Version: 0.1.8
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
@@ -260,6 +260,7 @@ environment variables to it:
260
260
 
261
261
  ```python
262
262
  import sys
263
+ # "mcp" is the MCP Python SDK (installed alongside greenpick), not a greenpick module.
263
264
  from mcp import ClientSession, StdioServerParameters
264
265
  from mcp.client.stdio import stdio_client
265
266
 
@@ -273,7 +274,8 @@ params = StdioServerParameters(
273
274
  },
274
275
  )
275
276
 
276
- # In Jupyter, errlog must be a real file (the notebook's stderr has no fileno()).
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").
277
279
  with open("greenpick-server.log", "w") as errlog:
278
280
  async with stdio_client(params, errlog=errlog) as (read, write):
279
281
  async with ClientSession(read, write) as session:
@@ -288,7 +290,9 @@ Top-level `await` works in Jupyter. In a plain script, put the `with` block insi
288
290
  and run it with `asyncio.run(...)`.
289
291
 
290
292
  Every carbon figure includes a `quality` field: `live` means real grid data, `mock` means a
291
- fallback value. Check it to confirm live data is on.
293
+ fallback value. Check it to confirm live data is on. A result that isn't live also includes a
294
+ `live_data_hint` saying why (no key set, the source didn't respond, or no free source covers that
295
+ region) and what to do about it.
292
296
 
293
297
  ## FAQ
294
298
 
@@ -32,6 +32,7 @@ src/greenpick/data/static_data.py
32
32
  src/greenpick/services/__init__.py
33
33
  src/greenpick/services/carbon_service.py
34
34
  src/greenpick/services/cost_service.py
35
+ src/greenpick/services/data_hint.py
35
36
  src/greenpick/services/forecast_service.py
36
37
  src/greenpick/services/inference_service.py
37
38
  src/greenpick/services/instance_service.py
@@ -718,13 +718,37 @@ from greenpick.services import forecast_service
718
718
 
719
719
 
720
720
  @pytest.mark.asyncio
721
- async def test_forecast_degrades_to_historical_without_forecast_source():
722
- # MockCarbonRepository has no renewable_forecast -> service falls back to historical pattern.
721
+ async def test_forecast_fallback_on_mock_history_is_labelled_mock():
722
+ # No forecast and only mock history: the fallback pattern is synthetic, so it must say mock,
723
+ # not 'historical' (which would pass an illustrative curve off as real past data).
723
724
  repo = MockCarbonRepository()
724
725
  adv = await forecast_service.carbon_forecast(repo, "aws", "us-east-1", hours_ahead=24)
726
+ assert adv.source == "mock"
727
+ assert adv.quality == "mock"
728
+ assert adv.cleanest_upcoming == []
729
+ assert "not meaningful" in adv.note
730
+
731
+
732
+ @pytest.mark.asyncio
733
+ async def test_forecast_degrades_to_historical_with_live_history(monkeypatch):
734
+ # US region with live EIA history but no day-ahead forecast -> a real historical fallback.
735
+ def handler(request: httpx.Request) -> httpx.Response:
736
+ data = []
737
+ for day in ("2026-10-10", "2026-10-11"):
738
+ data += [
739
+ {"period": f"{day}T02", "fueltype": "NUC", "value": "1800"},
740
+ {"period": f"{day}T02", "fueltype": "NG", "value": "200"},
741
+ {"period": f"{day}T18", "fueltype": "NUC", "value": "200"},
742
+ {"period": f"{day}T18", "fueltype": "NG", "value": "1800"},
743
+ ]
744
+ return httpx.Response(200, json={"response": {"data": data}})
745
+
746
+ orig = httpx.AsyncClient
747
+ monkeypatch.setattr(httpx, "AsyncClient",
748
+ lambda *a, **k: orig(transport=_mock_transport(handler)))
749
+ adv = await forecast_service.carbon_forecast(EIARepository(api_key="k"), "aws", "us-east-1")
725
750
  assert adv.source == "historical_fallback"
726
751
  assert adv.quality == "historical"
727
- assert adv.cleanest_upcoming == []
728
752
  assert "historical" in adv.note.lower()
729
753
 
730
754
 
@@ -1248,6 +1272,180 @@ def test_server_sends_instructions_that_lead_with_rightsizing():
1248
1272
  assert "mock" in text and "TOTAL" in text and "data_locality_egress_check" in text
1249
1273
  # Every tool named in the instructions actually exists.
1250
1274
  import re, asyncio
1251
- named = set(re.findall(r"\b[a-z]+(?:_[a-z]+)+\b", text)) - {"wall_clock_hours", "gpu_hours", "residency_note"}
1275
+ named = set(re.findall(r"\b[a-z]+(?:_[a-z]+)+\b", text)) - {"wall_clock_hours", "gpu_hours", "residency_note", "live_data_hint"}
1252
1276
  registered = {t.name for t in asyncio.run(server.mcp.list_tools())}
1253
1277
  assert named <= registered, named - registered
1278
+
1279
+
1280
+ # --- 0.1.7: labelled percentages; start advice as a time of day, not a past date ---
1281
+
1282
+ @pytest.mark.asyncio
1283
+ async def test_rightsizing_labels_cost_and_carbon_percentages_separately():
1284
+ from greenpick.services import rightsizing_service
1285
+ repo = MockCarbonRepository()
1286
+ adv = await rightsizing_service.rightsizing_check(
1287
+ repo, "aws", "us-east-1", vcpu=8, ram_gb=32, cpu_utilization=0.15,
1288
+ )
1289
+ # Cost % is the bill saving; carbon % is the kg saving. They differ, so both are reported.
1290
+ assert adv.cost_saving_pct == round(adv.monthly_usd_saving / adv.cost_now_usd_per_month * 100, 1)
1291
+ assert adv.carbon_saving_pct == round(adv.monthly_kg_co2_saving / adv.carbon_now_kg_per_month * 100, 1)
1292
+ assert adv.cost_saving_pct != adv.carbon_saving_pct
1293
+ assert adv.saving_pct == adv.carbon_saving_pct # compatibility field unchanged
1294
+ assert f"~{adv.cost_saving_pct:.0f}% cheaper" in adv.rationale
1295
+ assert f"~{adv.carbon_saving_pct:.0f}% less carbon" in adv.rationale
1296
+ assert "mock grid data" in adv.rationale # derived carbon carries its quality
1297
+
1298
+
1299
+ def test_clock_label_turns_past_periods_into_a_time_of_day():
1300
+ from greenpick.services import scheduling_service as ss
1301
+ assert ss.clock_label("2026-10-17T15") == "15:00 UTC"
1302
+ assert ss.clock_label("2026-10-17T15:00Z") == "15:00 UTC"
1303
+ assert ss.clock_label("H02") == "02:00" # mock hours have no timezone
1304
+ assert ss.hour_of_day("2026-10-17T15") == 15
1305
+
1306
+
1307
+ @pytest.mark.asyncio
1308
+ async def test_window_advice_leads_with_time_of_day_not_a_date(monkeypatch):
1309
+ from greenpick.services import scheduling_service as ss
1310
+ from greenpick.models import HourlyCarbon
1311
+
1312
+ class Repo(MockCarbonRepository):
1313
+ async def carbon_history(self, provider, region):
1314
+ # Two past days; 15:00-19:00 is clean on the 17th.
1315
+ out = []
1316
+ for day in ("2026-10-17", "2026-10-18"):
1317
+ for h in range(24):
1318
+ g = 60.0 if (day == "2026-10-17" and 15 <= h <= 19) else 200.0
1319
+ out.append(HourlyCarbon(period=f"{day}T{h:02d}", g_co2_per_kwh=g))
1320
+ return out
1321
+
1322
+ adv = await ss.cleanest_hours(Repo(), "aws", "us-west-2", window_hours=5)
1323
+ assert adv.window.start_hour_of_day == 15
1324
+ assert adv.window.start_period == "2026-10-17T15" # kept, as evidence
1325
+ assert adv.note.startswith("For a ~5h job, start around 15:00 UTC")
1326
+
1327
+
1328
+ @pytest.mark.asyncio
1329
+ async def test_training_rationale_says_start_around_a_time():
1330
+ from greenpick.services import training_service
1331
+ repo = MockCarbonRepository()
1332
+ adv = await training_service.estimate_training_carbon(
1333
+ repo, gpu="a100", gpu_count=8, gpu_hours=40, region="us-east-1", provider="aws",
1334
+ )
1335
+ assert adv.cleanest_window is not None and adv.cleanest_window.start_hour_of_day is not None
1336
+ assert f"start it around {adv.cleanest_window.start_hour_of_day:02d}:00" in adv.rationale
1337
+
1338
+
1339
+ def test_instructions_cover_derived_mock_figures():
1340
+ from greenpick import server
1341
+ assert "derived from them" in server.mcp.instructions
1342
+
1343
+
1344
+ # --- 0.1.8: a mock result says why it isn't live, and how to turn live data on ---
1345
+
1346
+ from greenpick import config as _config
1347
+ from greenpick.config import Settings
1348
+ from greenpick.services import data_hint
1349
+
1350
+
1351
+ def _settings(source="auto", eia="", entsoe="", em=""):
1352
+ return Settings(carbon_source=source, electricitymaps_token=em, eia_api_key=eia,
1353
+ entsoe_token=entsoe, cache_ttl_seconds=1800, log_level="INFO")
1354
+
1355
+
1356
+ def _mock(provider, region):
1357
+ return {"provider": provider, "region": region, "g_co2_per_kwh": 350.0,
1358
+ "source": "mock", "quality": "mock"}
1359
+
1360
+
1361
+ def test_hint_when_greenpick_is_set_to_mock(monkeypatch):
1362
+ monkeypatch.setattr(_config, "SETTINGS", _settings(source="mock"))
1363
+ hint = data_hint.with_hint(_mock("aws", "us-east-1"))["live_data_hint"]
1364
+ assert "typical value for each grid, not a current reading" in hint
1365
+ assert "GREENPICK_CARBON_SOURCE=auto" in hint and data_hint.TURN_ON_URL in hint
1366
+
1367
+
1368
+ def test_hint_names_the_missing_key(monkeypatch):
1369
+ monkeypatch.setattr(_config, "SETTINGS", _settings(source="auto", eia="", entsoe="tok"))
1370
+ hint = data_hint.with_hint(_mock("aws", "us-east-1"))["live_data_hint"]
1371
+ assert "set EIA_API_KEY" in hint and "eia.gov/opendata/register" in hint
1372
+ assert "ENTSOE_TOKEN" not in hint
1373
+
1374
+
1375
+ def test_hint_when_key_is_set_but_source_did_not_answer(monkeypatch):
1376
+ monkeypatch.setattr(_config, "SETTINGS", _settings(source="auto", entsoe="tok"))
1377
+ hint = data_hint.with_hint(_mock("aws", "eu-north-1"))["live_data_hint"]
1378
+ assert "ENTSO-E didn't return data for aws/eu-north-1" in hint
1379
+ assert "set ENTSOE_TOKEN" not in hint # don't tell them to add a key they have
1380
+
1381
+
1382
+ def test_hint_for_a_region_no_free_source_covers(monkeypatch):
1383
+ monkeypatch.setattr(_config, "SETTINGS", _settings(source="auto", eia="k", entsoe="t"))
1384
+ hint = data_hint.with_hint(_mock("aws", "ap-southeast-2"))["live_data_hint"]
1385
+ assert "No free live source covers aws/ap-southeast-2" in hint
1386
+ assert "set EIA_API_KEY" not in hint and "set ENTSOE_TOKEN" not in hint
1387
+
1388
+
1389
+ def test_no_hint_on_live_results(monkeypatch):
1390
+ monkeypatch.setattr(_config, "SETTINGS", _settings(source="auto", eia="k"))
1391
+ live = {"provider": "aws", "region": "us-east-1", "quality": "live", "source": "eia"}
1392
+ assert "live_data_hint" not in data_hint.with_hint(dict(live))
1393
+ cached = dict(live, quality="cached")
1394
+ assert "live_data_hint" not in data_hint.with_hint(cached)
1395
+
1396
+
1397
+ def test_hint_covers_nested_regions_and_flags_mock_timing(monkeypatch):
1398
+ monkeypatch.setattr(_config, "SETTINGS", _settings(source="auto", entsoe="tok"))
1399
+ result = {
1400
+ "recommended_region": "aws/eu-north-1", "timing_quality": "mock",
1401
+ "options": [
1402
+ {"provider": "aws", "region": "eu-north-1", "carbon_quality": "mock"},
1403
+ {"provider": "aws", "region": "ap-southeast-2", "carbon_quality": "mock"},
1404
+ {"provider": "aws", "region": "us-west-2", "carbon_quality": "live"},
1405
+ ],
1406
+ }
1407
+ hint = data_hint.with_hint(result)["live_data_hint"]
1408
+ assert "aws/eu-north-1" in hint and "aws/ap-southeast-2" in hint
1409
+ assert "us-west-2" not in hint
1410
+ assert "hourly pattern in mock data is illustrative" in hint
1411
+
1412
+
1413
+ @pytest.mark.asyncio
1414
+ async def test_every_carbon_tool_attaches_the_hint_on_mock(monkeypatch):
1415
+ # End to end through the MCP tool functions, with the server's repo on mock data.
1416
+ from greenpick import server
1417
+ monkeypatch.setattr(_config, "SETTINGS", _settings(source="mock"))
1418
+ monkeypatch.setattr(server, "_repo", MockCarbonRepository())
1419
+
1420
+ async def call(name, **kw):
1421
+ f = getattr(server, name)
1422
+ f = getattr(f, "fn", f)
1423
+ return await f(**kw)
1424
+
1425
+ calls = {
1426
+ "region_carbon_now": dict(provider="aws", region="us-east-1"),
1427
+ "rightsizing_check": dict(provider="aws", region="us-east-1", vcpu=8, ram_gb=32, cpu_utilization=0.15),
1428
+ "estimate_workload_carbon": dict(provider="aws", region="us-east-1", vcpu=4, ram_gb=16),
1429
+ "estimate_training_carbon": dict(gpu="a100", gpu_count=8, gpu_hours=40),
1430
+ "compare_regions_tri_axis": dict(user_lat=40.7, user_lon=-74.0, vcpu=4, ram_gb=16),
1431
+ "cleanest_time_of_day": dict(provider="aws", region="us-east-1"),
1432
+ "carbon_forecast": dict(provider="aws", region="us-east-1"),
1433
+ "data_locality_egress_check": dict(compute_provider="aws", compute_region="us-west-2",
1434
+ data_provider="aws", data_region="us-east-1",
1435
+ monthly_cross_boundary_gb=500),
1436
+ "serving_pattern_advice": dict(hubs=[{"name": "us", "share": 0.6}, {"name": "eu", "share": 0.4}]),
1437
+ }
1438
+ for name, kw in calls.items():
1439
+ r = await call(name, **kw)
1440
+ assert "live_data_hint" in r, name
1441
+
1442
+
1443
+ @pytest.mark.asyncio
1444
+ async def test_egress_and_serving_label_their_carbon_quality():
1445
+ from greenpick.services import locality_service, serving_service
1446
+ repo = MockCarbonRepository()
1447
+ eg = await locality_service.data_locality_check(repo, "aws", "us-west-2", "aws", "us-east-1", 500)
1448
+ assert eg.transfer_carbon_quality == "mock" and eg.transfer_carbon_source == "mock"
1449
+ hubs = [ServingHub(name="us", lat=0, lon=0, share=0.5), ServingHub(name="eu", lat=0, lon=0, share=0.5)]
1450
+ sv = await serving_service.serving_pattern(repo, hubs, Workload(vcpu=1, ram_gb=2))
1451
+ assert sv.multi_region and all(c.carbon_quality == "mock" for c in sv.multi_region)
File without changes
File without changes