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.
- {greenpick-0.1.7/src/greenpick.egg-info → greenpick-0.1.8}/PKG-INFO +4 -2
- {greenpick-0.1.7 → greenpick-0.1.8}/README.md +3 -1
- {greenpick-0.1.7 → greenpick-0.1.8}/pyproject.toml +1 -1
- {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/__init__.py +1 -1
- {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/models.py +4 -0
- {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/server.py +20 -16
- greenpick-0.1.8/src/greenpick/services/data_hint.py +147 -0
- {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/services/forecast_service.py +19 -7
- {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/services/locality_service.py +1 -0
- {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/services/serving_service.py +2 -0
- {greenpick-0.1.7 → greenpick-0.1.8/src/greenpick.egg-info}/PKG-INFO +4 -2
- {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick.egg-info/SOURCES.txt +1 -0
- {greenpick-0.1.7 → greenpick-0.1.8}/tests/test_services.py +138 -4
- {greenpick-0.1.7 → greenpick-0.1.8}/LICENSE +0 -0
- {greenpick-0.1.7 → greenpick-0.1.8}/setup.cfg +0 -0
- {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/__main__.py +0 -0
- {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/config.py +0 -0
- {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/data/__init__.py +0 -0
- {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/data/_net.py +0 -0
- {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/data/auto_repository.py +0 -0
- {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/data/eia_repository.py +0 -0
- {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/data/electricitymaps_repository.py +0 -0
- {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/data/emission_factors.json +0 -0
- {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/data/entsoe_repository.py +0 -0
- {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/data/gpu_power.json +0 -0
- {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/data/inference_energy.json +0 -0
- {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/data/instance_families.json +0 -0
- {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/data/mock_repository.py +0 -0
- {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/data/network.json +0 -0
- {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/data/power.json +0 -0
- {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/data/pricing.json +0 -0
- {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/data/regions.json +0 -0
- {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/data/repository.py +0 -0
- {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/data/static_data.py +0 -0
- {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/services/__init__.py +0 -0
- {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/services/carbon_service.py +0 -0
- {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/services/cost_service.py +0 -0
- {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/services/inference_service.py +0 -0
- {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/services/instance_service.py +0 -0
- {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/services/latency_service.py +0 -0
- {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/services/metric_scaffold_service.py +0 -0
- {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/services/recommender.py +0 -0
- {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/services/residency_service.py +0 -0
- {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/services/rightsizing_service.py +0 -0
- {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/services/scheduling_service.py +0 -0
- {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick/services/training_service.py +0 -0
- {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick.egg-info/dependency_links.txt +0 -0
- {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick.egg-info/entry_points.txt +0 -0
- {greenpick-0.1.7 → greenpick-0.1.8}/src/greenpick.egg-info/requires.txt +0 -0
- {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.
|
|
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.
|
|
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
|
+
__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.
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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=
|
|
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.
|
|
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
|
|
722
|
-
#
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|