greenpick 0.1.7__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.7/src/greenpick.egg-info → greenpick-0.1.8}/PKG-INFO +4 -2
  2. {greenpick-0.1.7 → greenpick-0.1.8}/README.md +3 -1
  3. {greenpick-0.1.7 → greenpick-0.1.8}/pyproject.toml +1 -1
  4. {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/__init__.py +1 -1
  5. {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/models.py +4 -0
  6. {greenpick-0.1.7 → 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.7 → greenpick-0.1.8}/src/greenpick/services/forecast_service.py +19 -7
  9. {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/services/locality_service.py +1 -0
  10. {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/services/serving_service.py +2 -0
  11. {greenpick-0.1.7 → greenpick-0.1.8/src/greenpick.egg-info}/PKG-INFO +4 -2
  12. {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick.egg-info/SOURCES.txt +1 -0
  13. {greenpick-0.1.7 → greenpick-0.1.8}/tests/test_services.py +138 -4
  14. {greenpick-0.1.7 → greenpick-0.1.8}/LICENSE +0 -0
  15. {greenpick-0.1.7 → greenpick-0.1.8}/setup.cfg +0 -0
  16. {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/__main__.py +0 -0
  17. {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/config.py +0 -0
  18. {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/data/__init__.py +0 -0
  19. {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/data/_net.py +0 -0
  20. {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/data/auto_repository.py +0 -0
  21. {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/data/eia_repository.py +0 -0
  22. {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/data/electricitymaps_repository.py +0 -0
  23. {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/data/emission_factors.json +0 -0
  24. {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/data/entsoe_repository.py +0 -0
  25. {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/data/gpu_power.json +0 -0
  26. {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/data/inference_energy.json +0 -0
  27. {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/data/instance_families.json +0 -0
  28. {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/data/mock_repository.py +0 -0
  29. {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/data/network.json +0 -0
  30. {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/data/power.json +0 -0
  31. {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/data/pricing.json +0 -0
  32. {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/data/regions.json +0 -0
  33. {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/data/repository.py +0 -0
  34. {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/data/static_data.py +0 -0
  35. {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/services/__init__.py +0 -0
  36. {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/services/carbon_service.py +0 -0
  37. {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/services/cost_service.py +0 -0
  38. {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/services/inference_service.py +0 -0
  39. {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/services/instance_service.py +0 -0
  40. {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/services/latency_service.py +0 -0
  41. {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/services/metric_scaffold_service.py +0 -0
  42. {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/services/recommender.py +0 -0
  43. {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/services/residency_service.py +0 -0
  44. {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/services/rightsizing_service.py +0 -0
  45. {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/services/scheduling_service.py +0 -0
  46. {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/services/training_service.py +0 -0
  47. {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick.egg-info/dependency_links.txt +0 -0
  48. {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick.egg-info/entry_points.txt +0 -0
  49. {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick.egg-info/requires.txt +0 -0
  50. {greenpick-0.1.7 → 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.7
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
@@ -290,7 +290,9 @@ Top-level `await` works in Jupyter. In a plain script, put the `with` block insi
290
290
  and run it with `asyncio.run(...)`.
291
291
 
292
292
  Every carbon figure includes a `quality` field: `live` means real grid data, `mock` means a
293
- 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.
294
296
 
295
297
  ## FAQ
296
298
 
@@ -260,7 +260,9 @@ Top-level `await` works in Jupyter. In a plain script, put the `with` block insi
260
260
  and run it with `asyncio.run(...)`.
261
261
 
262
262
  Every carbon figure includes a `quality` field: `live` means real grid data, `mock` means a
263
- 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.
264
266
 
265
267
  ## FAQ
266
268
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "greenpick"
3
- version = "0.1.7"
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.7"
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.
@@ -277,6 +277,8 @@ class ServingPerHubChoice(BaseModel):
277
277
  region: str
278
278
  latency_ms: float
279
279
  carbon_g_co2_per_kwh: float
280
+ carbon_source: Optional[str] = None
281
+ carbon_quality: Optional[str] = None
280
282
 
281
283
 
282
284
  class ServingPatternAdvice(BaseModel):
@@ -311,6 +313,8 @@ class DataLocalityWarning(BaseModel):
311
313
  est_monthly_egress_usd_low: Optional[float] = None # monthly cost range = rate range x GB
312
314
  est_monthly_egress_usd_high: Optional[float] = None
313
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
314
318
  carbon_saving_kg_co2_from_move: Optional[float] = None # echoed back if provided
315
319
  net_carbon_verdict: Optional[str] = None # set only when a saving was supplied
316
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 — 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
+ 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,
@@ -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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: greenpick
3
- Version: 0.1.7
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
@@ -290,7 +290,9 @@ Top-level `await` works in Jupyter. In a plain script, put the `with` block insi
290
290
  and run it with `asyncio.run(...)`.
291
291
 
292
292
  Every carbon figure includes a `quality` field: `live` means real grid data, `mock` means a
293
- 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.
294
296
 
295
297
  ## FAQ
296
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,7 +1272,7 @@ 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
1254
1278
 
@@ -1315,3 +1339,113 @@ async def test_training_rationale_says_start_around_a_time():
1315
1339
  def test_instructions_cover_derived_mock_figures():
1316
1340
  from greenpick import server
1317
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