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.
- {greenpick-0.1.6/src/greenpick.egg-info → greenpick-0.1.8}/PKG-INFO +7 -3
- {greenpick-0.1.6 → greenpick-0.1.8}/README.md +6 -2
- {greenpick-0.1.6 → greenpick-0.1.8}/pyproject.toml +1 -1
- {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/__init__.py +1 -1
- {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/models.py +9 -2
- {greenpick-0.1.6 → 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.6 → greenpick-0.1.8}/src/greenpick/services/forecast_service.py +19 -7
- {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/services/locality_service.py +1 -0
- {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/services/rightsizing_service.py +11 -4
- {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/services/scheduling_service.py +26 -8
- {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/services/serving_service.py +2 -0
- {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/services/training_service.py +7 -5
- {greenpick-0.1.6 → greenpick-0.1.8/src/greenpick.egg-info}/PKG-INFO +7 -3
- {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick.egg-info/SOURCES.txt +1 -0
- {greenpick-0.1.6 → greenpick-0.1.8}/tests/test_services.py +202 -4
- {greenpick-0.1.6 → greenpick-0.1.8}/LICENSE +0 -0
- {greenpick-0.1.6 → greenpick-0.1.8}/setup.cfg +0 -0
- {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/__main__.py +0 -0
- {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/config.py +0 -0
- {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/data/__init__.py +0 -0
- {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/data/_net.py +0 -0
- {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/data/auto_repository.py +0 -0
- {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/data/eia_repository.py +0 -0
- {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/data/electricitymaps_repository.py +0 -0
- {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/data/emission_factors.json +0 -0
- {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/data/entsoe_repository.py +0 -0
- {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/data/gpu_power.json +0 -0
- {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/data/inference_energy.json +0 -0
- {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/data/instance_families.json +0 -0
- {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/data/mock_repository.py +0 -0
- {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/data/network.json +0 -0
- {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/data/power.json +0 -0
- {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/data/pricing.json +0 -0
- {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/data/regions.json +0 -0
- {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/data/repository.py +0 -0
- {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/data/static_data.py +0 -0
- {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/services/__init__.py +0 -0
- {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/services/carbon_service.py +0 -0
- {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/services/cost_service.py +0 -0
- {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/services/inference_service.py +0 -0
- {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/services/instance_service.py +0 -0
- {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/services/latency_service.py +0 -0
- {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/services/metric_scaffold_service.py +0 -0
- {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/services/recommender.py +0 -0
- {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick/services/residency_service.py +0 -0
- {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick.egg-info/dependency_links.txt +0 -0
- {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick.egg-info/entry_points.txt +0 -0
- {greenpick-0.1.6 → greenpick-0.1.8}/src/greenpick.egg-info/requires.txt +0 -0
- {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.
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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.
|
|
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.
|
|
@@ -70,7 +70,8 @@ class ForecastAdvice(BaseModel):
|
|
|
70
70
|
class CleanestWindow(BaseModel):
|
|
71
71
|
"""The lowest-average contiguous run of N hours — for timing a job of known duration."""
|
|
72
72
|
window_hours: int
|
|
73
|
-
|
|
73
|
+
start_hour_of_day: Optional[int] = None # 0..23 — the advice: start around this hour each time
|
|
74
|
+
start_period: str # when that window began in the recent data (evidence, not a date to use)
|
|
74
75
|
end_period: str # the last hour in the window
|
|
75
76
|
avg_g_co2_per_kwh: float # mean intensity across the window
|
|
76
77
|
hours: list[HourlyCarbon] = [] # the per-hour breakdown of the chosen window
|
|
@@ -224,7 +225,9 @@ class RightsizingAdvice(BaseModel):
|
|
|
224
225
|
carbon_now_kg_per_month: Optional[float] = None
|
|
225
226
|
carbon_rightsized_kg_per_month: Optional[float] = None
|
|
226
227
|
monthly_kg_co2_saving: Optional[float] = None
|
|
227
|
-
|
|
228
|
+
cost_saving_pct: Optional[float] = None # % off the monthly bill
|
|
229
|
+
carbon_saving_pct: Optional[float] = None # % off monthly kg CO2
|
|
230
|
+
saving_pct: Optional[float] = None # = carbon_saving_pct; kept for compatibility
|
|
228
231
|
carbon_source: Optional[str] = None
|
|
229
232
|
carbon_quality: Optional[str] = None
|
|
230
233
|
rationale: str = ""
|
|
@@ -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
|
|
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,
|
|
@@ -83,13 +83,19 @@ async def rightsizing_check(
|
|
|
83
83
|
|
|
84
84
|
is_oversized = saving_pct is not None and saving_pct >= _MIN_SAVING_PCT
|
|
85
85
|
|
|
86
|
+
# Separate, labelled percentages: cost and carbon shrink by different fractions (price and power
|
|
87
|
+
# scale differently with vCPU vs RAM), so one unlabelled % beside a dollar figure misleads.
|
|
88
|
+
cost_pct = (round(usd_saving / cost_now * 100, 1)
|
|
89
|
+
if usd_saving is not None and cost_now else None)
|
|
90
|
+
|
|
86
91
|
if is_oversized:
|
|
87
|
-
|
|
92
|
+
cost_txt = (f"~{cost_pct:.0f}% cheaper (~${usd_saving:,.0f}/mo saved)"
|
|
93
|
+
if cost_pct is not None else "a lower bill")
|
|
88
94
|
rationale = (
|
|
89
95
|
f"Over-provisioned: at ~{cpu_utilization*100:.0f}% CPU use, {vcpu:g} vCPU / {ram_gb:g} GB "
|
|
90
96
|
f"is bigger than you need. Right-size to {rec_vcpu:g} vCPU / {rec_ram:g} GB (the smallest "
|
|
91
|
-
f"standard size that keeps ~40% headroom)
|
|
92
|
-
f"~{kg_saving:.1f} kg CO2/mo. "
|
|
97
|
+
f"standard size that keeps ~40% headroom): {cost_txt} AND ~{saving_pct:.0f}% less carbon "
|
|
98
|
+
f"(~{kg_saving:.1f} kg CO2/mo, {reading.quality} grid data). "
|
|
93
99
|
f"Same work, less waste: the cost and carbon win here are the same action.")
|
|
94
100
|
else:
|
|
95
101
|
rationale = (
|
|
@@ -105,7 +111,8 @@ async def rightsizing_check(
|
|
|
105
111
|
cost_now_usd_per_month=cost_now, cost_rightsized_usd_per_month=cost_rs,
|
|
106
112
|
monthly_usd_saving=usd_saving,
|
|
107
113
|
carbon_now_kg_per_month=carbon_now, carbon_rightsized_kg_per_month=carbon_rs,
|
|
108
|
-
monthly_kg_co2_saving=kg_saving,
|
|
114
|
+
monthly_kg_co2_saving=kg_saving,
|
|
115
|
+
cost_saving_pct=cost_pct, carbon_saving_pct=saving_pct, saving_pct=saving_pct,
|
|
109
116
|
carbon_source=reading.source, carbon_quality=reading.quality,
|
|
110
117
|
rationale=rationale,
|
|
111
118
|
limitations=("Directional, design-time estimate from the utilisation YOU report — not a "
|
|
@@ -37,6 +37,22 @@ def _hour_index(period: str) -> Optional[int]:
|
|
|
37
37
|
return int(dt.timestamp() // 3600) # absolute hours since epoch
|
|
38
38
|
|
|
39
39
|
|
|
40
|
+
def hour_of_day(period: str) -> Optional[int]:
|
|
41
|
+
"""0..23 hour-of-day for a period label (UTC for live ISO timestamps; mock 'H15' -> 15)."""
|
|
42
|
+
idx = _hour_index(period)
|
|
43
|
+
return None if idx is None else idx % 24
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def clock_label(period: str) -> str:
|
|
47
|
+
"""Turn a past period into advice you can act on: '15:00 UTC' (live) or '15:00' (mock hours
|
|
48
|
+
carry no timezone). Recent history says WHEN IN THE DAY is cleanest — the date it happened on
|
|
49
|
+
is evidence, not a start date, so it's never the headline."""
|
|
50
|
+
hod = hour_of_day(period)
|
|
51
|
+
if hod is None:
|
|
52
|
+
return period
|
|
53
|
+
return f"{hod:02d}:00" if period.strip().startswith("H") else f"{hod:02d}:00 UTC"
|
|
54
|
+
|
|
55
|
+
|
|
40
56
|
def _best_and_worst_window(series: list[HourlyCarbon], window_hours: int
|
|
41
57
|
) -> tuple[Optional[list[HourlyCarbon]], Optional[float]]:
|
|
42
58
|
"""Slide a window of `window_hours` over the CHRONOLOGICALLY ordered series, considering only
|
|
@@ -114,20 +130,22 @@ async def cleanest_hours(
|
|
|
114
130
|
win_hours, win_reduction = _best_and_worst_window(history, window_hours)
|
|
115
131
|
if win_hours:
|
|
116
132
|
avg = round(sum(h.g_co2_per_kwh for h in win_hours) / len(win_hours), 1)
|
|
133
|
+
start = win_hours[0].period
|
|
117
134
|
advice.window = CleanestWindow(
|
|
118
|
-
window_hours=window_hours,
|
|
119
|
-
start_period=
|
|
135
|
+
window_hours=window_hours, start_hour_of_day=hour_of_day(start),
|
|
136
|
+
start_period=start, end_period=win_hours[-1].period,
|
|
120
137
|
avg_g_co2_per_kwh=avg, hours=win_hours,
|
|
121
138
|
)
|
|
122
139
|
advice.window_reduction_pct = win_reduction
|
|
140
|
+
seen = f"(the cleanest {window_hours}h window in the recent data began {start})"
|
|
123
141
|
if win_reduction is not None and win_reduction >= 10:
|
|
124
|
-
advice.note = (f"For a ~{window_hours}h job, start
|
|
125
|
-
f"
|
|
126
|
-
f"
|
|
142
|
+
advice.note = (f"For a ~{window_hours}h job, start around {clock_label(start)} "
|
|
143
|
+
f"{seen}: avg ~{avg:.0f} gCO2/kWh, ~{win_reduction:.0f}% cleaner than "
|
|
144
|
+
f"the worst {window_hours}h window. A recent pattern, not a forecast.")
|
|
127
145
|
else:
|
|
128
|
-
advice.note = (f"For a ~{window_hours}h job, the cleanest
|
|
129
|
-
f"{
|
|
130
|
-
f"fairly flat — timing won't help much here.")
|
|
146
|
+
advice.note = (f"For a ~{window_hours}h job, the cleanest start is around "
|
|
147
|
+
f"{clock_label(start)} {seen}, avg ~{avg:.0f} gCO2/kWh, but the grid "
|
|
148
|
+
f"is fairly flat — timing won't help much here.")
|
|
131
149
|
else:
|
|
132
150
|
advice.note = (f"Not enough contiguous history to find a {window_hours}h window; "
|
|
133
151
|
f"showing cleanest individual hours instead. " + advice.note)
|
|
@@ -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;
|
|
174
|
-
f"
|
|
175
|
-
f"
|
|
176
|
-
f"
|
|
173
|
+
time_txt = (f" It runs ~{wall_clock_h}h wall-clock; start it around "
|
|
174
|
+
f"{scheduling_service.clock_label(w.start_period)} (the cleanest {w.window_hours}h "
|
|
175
|
+
f"window in the recent data began {w.start_period}, avg ~{w.avg_g_co2_per_kwh:.0f} "
|
|
176
|
+
f"gCO2/kWh), ~{advice.window_reduction_pct:.0f}% cleaner than the worst same-length "
|
|
177
|
+
f"window - training is deferrable, so this stacks on the region choice. "
|
|
178
|
+
f"A recent pattern, not a forecast.")
|
|
177
179
|
elif advice.potential_reduction_pct and advice.potential_reduction_pct >= 10 and advice.cleanest:
|
|
178
|
-
best = advice.cleanest[0].period
|
|
180
|
+
best = scheduling_service.clock_label(advice.cleanest[0].period)
|
|
179
181
|
time_txt = (f" Timing it to the cleanest hours in that region (around {best}) cuts a further "
|
|
180
182
|
f"~{advice.potential_reduction_pct:.0f}% vs the dirtiest hour - training is "
|
|
181
183
|
f"deferrable, so this stacks on the region choice.")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: greenpick
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.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
|
-
#
|
|
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
|
|
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,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
|
|
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
|