greenpick 0.5.2__tar.gz → 0.6.0__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.5.2/src/greenpick.egg-info → greenpick-0.6.0}/PKG-INFO +6 -4
- {greenpick-0.5.2 → greenpick-0.6.0}/README.md +5 -3
- {greenpick-0.5.2 → greenpick-0.6.0}/pyproject.toml +1 -1
- {greenpick-0.5.2 → greenpick-0.6.0}/src/greenpick/__init__.py +1 -1
- {greenpick-0.5.2 → greenpick-0.6.0}/src/greenpick/server.py +56 -2
- greenpick-0.6.0/src/greenpick/services/justification_service.py +180 -0
- {greenpick-0.5.2 → greenpick-0.6.0}/src/greenpick/services/locality_service.py +12 -8
- greenpick-0.6.0/src/greenpick/services/migration_service.py +119 -0
- {greenpick-0.5.2 → greenpick-0.6.0/src/greenpick.egg-info}/PKG-INFO +6 -4
- {greenpick-0.5.2 → greenpick-0.6.0}/src/greenpick.egg-info/SOURCES.txt +2 -0
- {greenpick-0.5.2 → greenpick-0.6.0}/tests/test_services.py +60 -1
- {greenpick-0.5.2 → greenpick-0.6.0}/LICENSE +0 -0
- {greenpick-0.5.2 → greenpick-0.6.0}/setup.cfg +0 -0
- {greenpick-0.5.2 → greenpick-0.6.0}/src/greenpick/__main__.py +0 -0
- {greenpick-0.5.2 → greenpick-0.6.0}/src/greenpick/config.py +0 -0
- {greenpick-0.5.2 → greenpick-0.6.0}/src/greenpick/data/__init__.py +0 -0
- {greenpick-0.5.2 → greenpick-0.6.0}/src/greenpick/data/_net.py +0 -0
- {greenpick-0.5.2 → greenpick-0.6.0}/src/greenpick/data/annual.json +0 -0
- {greenpick-0.5.2 → greenpick-0.6.0}/src/greenpick/data/auto_repository.py +0 -0
- {greenpick-0.5.2 → greenpick-0.6.0}/src/greenpick/data/canada_repository.py +0 -0
- {greenpick-0.5.2 → greenpick-0.6.0}/src/greenpick/data/eia_repository.py +0 -0
- {greenpick-0.5.2 → greenpick-0.6.0}/src/greenpick/data/electricitymaps_repository.py +0 -0
- {greenpick-0.5.2 → greenpick-0.6.0}/src/greenpick/data/emission_factors.json +0 -0
- {greenpick-0.5.2 → greenpick-0.6.0}/src/greenpick/data/entsoe_repository.py +0 -0
- {greenpick-0.5.2 → greenpick-0.6.0}/src/greenpick/data/gpu_power.json +0 -0
- {greenpick-0.5.2 → greenpick-0.6.0}/src/greenpick/data/inference_energy.json +0 -0
- {greenpick-0.5.2 → greenpick-0.6.0}/src/greenpick/data/instance_families.json +0 -0
- {greenpick-0.5.2 → greenpick-0.6.0}/src/greenpick/data/mock_repository.py +0 -0
- {greenpick-0.5.2 → greenpick-0.6.0}/src/greenpick/data/network.json +0 -0
- {greenpick-0.5.2 → greenpick-0.6.0}/src/greenpick/data/power.json +0 -0
- {greenpick-0.5.2 → greenpick-0.6.0}/src/greenpick/data/pricing.json +0 -0
- {greenpick-0.5.2 → greenpick-0.6.0}/src/greenpick/data/regions.json +0 -0
- {greenpick-0.5.2 → greenpick-0.6.0}/src/greenpick/data/repository.py +0 -0
- {greenpick-0.5.2 → greenpick-0.6.0}/src/greenpick/data/static_data.py +0 -0
- {greenpick-0.5.2 → greenpick-0.6.0}/src/greenpick/data/typical.json +0 -0
- {greenpick-0.5.2 → greenpick-0.6.0}/src/greenpick/data/typical_repository.py +0 -0
- {greenpick-0.5.2 → greenpick-0.6.0}/src/greenpick/models.py +0 -0
- {greenpick-0.5.2 → greenpick-0.6.0}/src/greenpick/services/__init__.py +0 -0
- {greenpick-0.5.2 → greenpick-0.6.0}/src/greenpick/services/carbon_service.py +0 -0
- {greenpick-0.5.2 → greenpick-0.6.0}/src/greenpick/services/cost_service.py +0 -0
- {greenpick-0.5.2 → greenpick-0.6.0}/src/greenpick/services/data_hint.py +0 -0
- {greenpick-0.5.2 → greenpick-0.6.0}/src/greenpick/services/forecast_service.py +0 -0
- {greenpick-0.5.2 → greenpick-0.6.0}/src/greenpick/services/inference_service.py +0 -0
- {greenpick-0.5.2 → greenpick-0.6.0}/src/greenpick/services/instance_service.py +0 -0
- {greenpick-0.5.2 → greenpick-0.6.0}/src/greenpick/services/latency_service.py +0 -0
- {greenpick-0.5.2 → greenpick-0.6.0}/src/greenpick/services/metric_scaffold_service.py +0 -0
- {greenpick-0.5.2 → greenpick-0.6.0}/src/greenpick/services/offhours_service.py +0 -0
- {greenpick-0.5.2 → greenpick-0.6.0}/src/greenpick/services/recommender.py +0 -0
- {greenpick-0.5.2 → greenpick-0.6.0}/src/greenpick/services/residency_service.py +0 -0
- {greenpick-0.5.2 → greenpick-0.6.0}/src/greenpick/services/rightsizing_service.py +0 -0
- {greenpick-0.5.2 → greenpick-0.6.0}/src/greenpick/services/scheduling_service.py +0 -0
- {greenpick-0.5.2 → greenpick-0.6.0}/src/greenpick/services/serving_service.py +0 -0
- {greenpick-0.5.2 → greenpick-0.6.0}/src/greenpick/services/training_service.py +0 -0
- {greenpick-0.5.2 → greenpick-0.6.0}/src/greenpick/services/typical_ranking_service.py +0 -0
- {greenpick-0.5.2 → greenpick-0.6.0}/src/greenpick.egg-info/dependency_links.txt +0 -0
- {greenpick-0.5.2 → greenpick-0.6.0}/src/greenpick.egg-info/entry_points.txt +0 -0
- {greenpick-0.5.2 → greenpick-0.6.0}/src/greenpick.egg-info/requires.txt +0 -0
- {greenpick-0.5.2 → greenpick-0.6.0}/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.
|
|
3
|
+
Version: 0.6.0
|
|
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
|
|
@@ -44,7 +44,7 @@ Dynamic: license-file
|
|
|
44
44
|
<img src="https://img.shields.io/badge/license-MIT-green" alt="License: MIT" />
|
|
45
45
|
<img src="https://img.shields.io/badge/tests-passing-brightgreen" alt="Tests: passing" />
|
|
46
46
|
<img src="https://img.shields.io/badge/python-3.11%2B-blue" alt="Python 3.11+" />
|
|
47
|
-
<img src="https://img.shields.io/badge/MCP%20tools-
|
|
47
|
+
<img src="https://img.shields.io/badge/MCP%20tools-19-0ea5e9" alt="MCP tools: 19" />
|
|
48
48
|
</p>
|
|
49
49
|
|
|
50
50
|
A **design-time sustainability co-pilot** that runs as an MCP server inside your vibe-coding
|
|
@@ -52,7 +52,7 @@ environment (Cursor / Claude Code / Kiro). Ask it where to deploy, when to run a
|
|
|
52
52
|
right-size — and it answers with an honest **carbon + cost + latency** tradeoff, in-editor, in
|
|
53
53
|
seconds. (It responds when you ask; auto-nudging on infra-code changes is on the roadmap.)
|
|
54
54
|
|
|
55
|
-
> **Status: working —
|
|
55
|
+
> **Status: working — 19 tools.** Live carbon for US (EIA-930), EU (ENTSO-E) and Canada (Hydro-Québec,
|
|
56
56
|
> IESO — no key needed), cross-cloud cost
|
|
57
57
|
> parity (Vantage-refreshed), per-axis R/Y/G ratings, carbon-aware scheduling + day-ahead forecast,
|
|
58
58
|
> GPU-training **and LLM-inference** carbon, right-sizing, serving-pattern and data-locality/residency
|
|
@@ -143,7 +143,7 @@ See `docs/user-questions-backlog.md`.
|
|
|
143
143
|
|
|
144
144
|
## MCP tools
|
|
145
145
|
|
|
146
|
-
|
|
146
|
+
19 tools, grouped by what you're trying to do.
|
|
147
147
|
|
|
148
148
|
### Pick a region
|
|
149
149
|
|
|
@@ -178,6 +178,8 @@ See `docs/user-questions-backlog.md`.
|
|
|
178
178
|
| `rightsizing_check` | Right-sizing (the #1 FinOps win) with a carbon twin: if you're over-provisioned, a smaller size cuts **both** cost and carbon → the right-sized target with monthly $ and kg CO₂ saving. |
|
|
179
179
|
| `offhours_check` | For dev/test/staging that doesn't need to run 24/7: the monthly $ and kg CO2 saved by running it only N hours a week. Compute only, directional. |
|
|
180
180
|
| `data_locality_egress_check` | Warn when running compute away from where your data lives backfires: directional egress $ + coarse transfer carbon, and whether a greener-region move is still a net win. |
|
|
181
|
+
| `migration_payoff` | Does moving the **data** to the compute's region pay off? Payback in months, one-off vs monthly egress and transfer carbon, net over 12 months. Egress only. |
|
|
182
|
+
| `region_move_justification` | A short paste-ready paragraph for a pull request or decision record, with the carbon, cost, latency and egress numbers and their quality labels. Says so if the move is not an improvement. |
|
|
181
183
|
| `serving_pattern_advice` | For a user-facing platform: serve from one region or several? Traffic-weighted latency + carbon, with the multi-region data-sync egress and cross-jurisdiction residency catches. |
|
|
182
184
|
|
|
183
185
|
### Instrument & understand
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
<img src="https://img.shields.io/badge/license-MIT-green" alt="License: MIT" />
|
|
15
15
|
<img src="https://img.shields.io/badge/tests-passing-brightgreen" alt="Tests: passing" />
|
|
16
16
|
<img src="https://img.shields.io/badge/python-3.11%2B-blue" alt="Python 3.11+" />
|
|
17
|
-
<img src="https://img.shields.io/badge/MCP%20tools-
|
|
17
|
+
<img src="https://img.shields.io/badge/MCP%20tools-19-0ea5e9" alt="MCP tools: 19" />
|
|
18
18
|
</p>
|
|
19
19
|
|
|
20
20
|
A **design-time sustainability co-pilot** that runs as an MCP server inside your vibe-coding
|
|
@@ -22,7 +22,7 @@ environment (Cursor / Claude Code / Kiro). Ask it where to deploy, when to run a
|
|
|
22
22
|
right-size — and it answers with an honest **carbon + cost + latency** tradeoff, in-editor, in
|
|
23
23
|
seconds. (It responds when you ask; auto-nudging on infra-code changes is on the roadmap.)
|
|
24
24
|
|
|
25
|
-
> **Status: working —
|
|
25
|
+
> **Status: working — 19 tools.** Live carbon for US (EIA-930), EU (ENTSO-E) and Canada (Hydro-Québec,
|
|
26
26
|
> IESO — no key needed), cross-cloud cost
|
|
27
27
|
> parity (Vantage-refreshed), per-axis R/Y/G ratings, carbon-aware scheduling + day-ahead forecast,
|
|
28
28
|
> GPU-training **and LLM-inference** carbon, right-sizing, serving-pattern and data-locality/residency
|
|
@@ -113,7 +113,7 @@ See `docs/user-questions-backlog.md`.
|
|
|
113
113
|
|
|
114
114
|
## MCP tools
|
|
115
115
|
|
|
116
|
-
|
|
116
|
+
19 tools, grouped by what you're trying to do.
|
|
117
117
|
|
|
118
118
|
### Pick a region
|
|
119
119
|
|
|
@@ -148,6 +148,8 @@ See `docs/user-questions-backlog.md`.
|
|
|
148
148
|
| `rightsizing_check` | Right-sizing (the #1 FinOps win) with a carbon twin: if you're over-provisioned, a smaller size cuts **both** cost and carbon → the right-sized target with monthly $ and kg CO₂ saving. |
|
|
149
149
|
| `offhours_check` | For dev/test/staging that doesn't need to run 24/7: the monthly $ and kg CO2 saved by running it only N hours a week. Compute only, directional. |
|
|
150
150
|
| `data_locality_egress_check` | Warn when running compute away from where your data lives backfires: directional egress $ + coarse transfer carbon, and whether a greener-region move is still a net win. |
|
|
151
|
+
| `migration_payoff` | Does moving the **data** to the compute's region pay off? Payback in months, one-off vs monthly egress and transfer carbon, net over 12 months. Egress only. |
|
|
152
|
+
| `region_move_justification` | A short paste-ready paragraph for a pull request or decision record, with the carbon, cost, latency and egress numbers and their quality labels. Says so if the move is not an improvement. |
|
|
151
153
|
| `serving_pattern_advice` | For a user-facing platform: serve from one region or several? Traffic-weighted latency + carbon, with the multi-region data-sync egress and cross-jurisdiction residency catches. |
|
|
152
154
|
|
|
153
155
|
### Instrument & understand
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "greenpick"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.6.0"
|
|
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.
|
|
7
|
+
__version__ = "0.6.0"
|
|
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.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""MCP server: exposes GreenPick's
|
|
1
|
+
"""MCP server: exposes GreenPick's 19 tools over FastMCP.
|
|
2
2
|
|
|
3
3
|
Thin layer — parse/validate inputs, call services, return JSON-friendly dicts. All math
|
|
4
4
|
lives in services/; this file only wires tools to them.
|
|
@@ -26,6 +26,7 @@ from .services import (
|
|
|
26
26
|
carbon_service, recommender, scheduling_service, instance_service, locality_service,
|
|
27
27
|
training_service, forecast_service, serving_service, metric_scaffold_service,
|
|
28
28
|
rightsizing_service, inference_service, offhours_service, typical_ranking_service,
|
|
29
|
+
justification_service, migration_service,
|
|
29
30
|
)
|
|
30
31
|
from .models import ServingHub
|
|
31
32
|
from .services.data_hint import with_hint
|
|
@@ -48,7 +49,8 @@ all GPUs; report wall_clock_hours as the run length), estimate_inference_carbon.
|
|
|
48
49
|
serving_pattern_advice, greenest_regions_on_average (which regions are normally cleanest in a month); \
|
|
49
50
|
carbon_metric_scaffold for an app to track its own estimated carbon.
|
|
50
51
|
Before recommending a region move, run data_locality_egress_check: egress can erase the win. Mention \
|
|
51
|
-
any residency_note.
|
|
52
|
+
any residency_note. To weigh moving the data too, use migration_payoff; for a paste-ready paragraph \
|
|
53
|
+
for a pull request, use region_move_justification.
|
|
52
54
|
Every carbon figure carries source and quality fields. Report 'typical', 'mock', 'cached' or \
|
|
53
55
|
'historical' values as such, never as live readings — including figures derived from them, such as a kg CO2 saving. \
|
|
54
56
|
When a result includes live_data_hint, pass it on: it says why the data isn't live and how to turn \
|
|
@@ -369,6 +371,58 @@ async def data_locality_egress_check(
|
|
|
369
371
|
return with_hint(advice.model_dump())
|
|
370
372
|
|
|
371
373
|
|
|
374
|
+
@mcp.tool(annotations=LIVE_DATA_TOOL)
|
|
375
|
+
async def migration_payoff(
|
|
376
|
+
data_provider: str,
|
|
377
|
+
data_region: str,
|
|
378
|
+
compute_provider: str,
|
|
379
|
+
compute_region: str,
|
|
380
|
+
data_gb_to_migrate: float,
|
|
381
|
+
monthly_cross_boundary_gb: float,
|
|
382
|
+
horizon_months: float = 12,
|
|
383
|
+
) -> dict:
|
|
384
|
+
"""Does moving the DATA to the compute's region pay off, or should it stay? Follow-on to
|
|
385
|
+
data_locality_egress_check. Give where the data is now, where compute runs, how many GB you would
|
|
386
|
+
migrate once, and how many GB a month cross between them today. Returns the payback in months, the
|
|
387
|
+
one-off vs monthly egress cost and transfer carbon, and the net over the horizon (default 12 months).
|
|
388
|
+
Egress only: storage prices, request charges, downtime and effort are not included. Ask the user for
|
|
389
|
+
the GB figures rather than assuming them."""
|
|
390
|
+
if data_gb_to_migrate <= 0 or monthly_cross_boundary_gb < 0 or not 0 < horizon_months <= 120:
|
|
391
|
+
return {"error": "data_gb_to_migrate must be > 0, monthly_cross_boundary_gb >= 0, and "
|
|
392
|
+
"horizon_months between 0 and 120."}
|
|
393
|
+
return with_hint(await migration_service.migration_payoff(
|
|
394
|
+
_repo, data_provider, data_region, compute_provider, compute_region,
|
|
395
|
+
data_gb_to_migrate, monthly_cross_boundary_gb, horizon_months,
|
|
396
|
+
))
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
@mcp.tool(annotations=LIVE_DATA_TOOL)
|
|
400
|
+
async def region_move_justification(
|
|
401
|
+
from_provider: str,
|
|
402
|
+
from_region: str,
|
|
403
|
+
to_provider: str,
|
|
404
|
+
to_region: str,
|
|
405
|
+
vcpu: float,
|
|
406
|
+
ram_gb: float,
|
|
407
|
+
hours_per_month: float = 730,
|
|
408
|
+
user_lat: Optional[float] = None,
|
|
409
|
+
user_lon: Optional[float] = None,
|
|
410
|
+
monthly_cross_boundary_gb: float = 0,
|
|
411
|
+
) -> dict:
|
|
412
|
+
"""A short, paste-ready paragraph justifying a region choice, for a pull request or decision record.
|
|
413
|
+
Give the current and proposed region, the service size, and optionally the users' lat/lon (for
|
|
414
|
+
latency) and the GB a month that would still cross to data left behind (for egress and the net
|
|
415
|
+
carbon). Returns `pr_text` plus the underlying `facts`. If the move is not an improvement the text
|
|
416
|
+
says so. Show the user the text and the quality labels; it formats numbers GreenPick already
|
|
417
|
+
computes, and the wording should be reviewed before it is pasted."""
|
|
418
|
+
if not 0 < hours_per_month <= 744 or monthly_cross_boundary_gb < 0:
|
|
419
|
+
return {"error": "hours_per_month must be between 0 and 744, and monthly_cross_boundary_gb >= 0."}
|
|
420
|
+
return with_hint(await justification_service.region_move_justification(
|
|
421
|
+
_repo, from_provider, from_region, to_provider, to_region, vcpu, ram_gb,
|
|
422
|
+
hours_per_month, user_lat, user_lon, monthly_cross_boundary_gb,
|
|
423
|
+
))
|
|
424
|
+
|
|
425
|
+
|
|
372
426
|
@mcp.tool(annotations=LIVE_DATA_TOOL)
|
|
373
427
|
async def carbon_forecast(
|
|
374
428
|
provider: str, region: str, hours_ahead: int = 24, top_n: int = 5,
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""A paste-ready justification for a region choice, for a pull request or decision record.
|
|
2
|
+
|
|
3
|
+
GreenPick already computes the carbon, cost, latency and egress numbers behind a region move. What a
|
|
4
|
+
team needs next is a short paragraph they can put in the PR. This turns the numbers into that
|
|
5
|
+
paragraph. It adds no new data or maths, and it keeps the honesty labels attached: each carbon figure
|
|
6
|
+
says what kind of reading it is (live, typical, yearly average), and the text says these are design-time
|
|
7
|
+
estimates, not measurements or a quote.
|
|
8
|
+
|
|
9
|
+
If the move is NOT an improvement, the text says so plainly instead of dressing it up.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
from ..data.repository import CarbonRepository
|
|
16
|
+
from ..data.static_data import network_model, region_meta
|
|
17
|
+
from ..models import GeoPoint, Workload
|
|
18
|
+
from . import carbon_service, cost_service, latency_service, locality_service, residency_service
|
|
19
|
+
|
|
20
|
+
_QUALITY_PHRASE = {
|
|
21
|
+
"live": "live grid reading",
|
|
22
|
+
"cached": "recent cached grid reading",
|
|
23
|
+
"typical": "typical average for this time of year",
|
|
24
|
+
"annual_average": "yearly national average",
|
|
25
|
+
"historical": "recent historical average",
|
|
26
|
+
"forecast": "forecast",
|
|
27
|
+
"mock": "illustrative placeholder value",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _phrase(quality: str) -> str:
|
|
32
|
+
return _QUALITY_PHRASE.get(quality, quality)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _signed_pct(new: float, old: float) -> Optional[float]:
|
|
36
|
+
if not old:
|
|
37
|
+
return None
|
|
38
|
+
return round((new - old) / old * 100, 1)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def region_move_justification(
|
|
42
|
+
repo: CarbonRepository,
|
|
43
|
+
from_provider: str, from_region: str,
|
|
44
|
+
to_provider: str, to_region: str,
|
|
45
|
+
vcpu: float, ram_gb: float,
|
|
46
|
+
hours_per_month: float = 730,
|
|
47
|
+
user_lat: Optional[float] = None, user_lon: Optional[float] = None,
|
|
48
|
+
monthly_cross_boundary_gb: float = 0,
|
|
49
|
+
) -> dict:
|
|
50
|
+
wl = Workload(vcpu=vcpu, ram_gb=ram_gb, hours_per_month=hours_per_month)
|
|
51
|
+
a = await carbon_service.carbon_now(repo, from_provider, from_region) # type: ignore[arg-type]
|
|
52
|
+
b = await carbon_service.carbon_now(repo, to_provider, to_region) # type: ignore[arg-type]
|
|
53
|
+
kg_a = carbon_service.estimate_kg_co2_per_month(wl, a)
|
|
54
|
+
kg_b = carbon_service.estimate_kg_co2_per_month(wl, b)
|
|
55
|
+
kg_saving = round(kg_a - kg_b, 2)
|
|
56
|
+
carbon_pct = _signed_pct(kg_b, kg_a)
|
|
57
|
+
intensity_pct = _signed_pct(b.g_co2_per_kwh, a.g_co2_per_kwh)
|
|
58
|
+
|
|
59
|
+
cost_a = cost_service.estimate_monthly_cost_usd(from_provider, wl) # type: ignore[arg-type]
|
|
60
|
+
cost_b = cost_service.estimate_monthly_cost_usd(to_provider, wl) # type: ignore[arg-type]
|
|
61
|
+
cost_delta = round(cost_b - cost_a, 2) if cost_a is not None and cost_b is not None else None
|
|
62
|
+
|
|
63
|
+
added_latency = None
|
|
64
|
+
if user_lat is not None and user_lon is not None:
|
|
65
|
+
user = GeoPoint(lat=user_lat, lon=user_lon)
|
|
66
|
+
la = latency_service.estimate_latency_ms(from_provider, from_region, user) # type: ignore[arg-type]
|
|
67
|
+
lb = latency_service.estimate_latency_ms(to_provider, to_region, user) # type: ignore[arg-type]
|
|
68
|
+
if la is not None and lb is not None:
|
|
69
|
+
added_latency = round(lb - la)
|
|
70
|
+
|
|
71
|
+
# Egress: the data is assumed to STAY in the region you are moving away from.
|
|
72
|
+
egress_low = egress_high = transfer_kg = net_kg = None
|
|
73
|
+
locality = "same_region"
|
|
74
|
+
if monthly_cross_boundary_gb > 0:
|
|
75
|
+
locality, _sev = locality_service._classify(from_provider, from_region, to_provider, to_region)
|
|
76
|
+
if locality != "same_region":
|
|
77
|
+
low, high = locality_service.egress_rate_range(locality, from_provider)
|
|
78
|
+
if low is not None and high is not None:
|
|
79
|
+
egress_low = round(monthly_cross_boundary_gb * low, 2)
|
|
80
|
+
egress_high = round(monthly_cross_boundary_gb * high, 2)
|
|
81
|
+
transfer_kg = round(monthly_cross_boundary_gb * network_model()["kwh_per_gb_transfer"]
|
|
82
|
+
* a.g_co2_per_kwh / 1000.0, 2)
|
|
83
|
+
net_kg = round(kg_saving - transfer_kg, 2)
|
|
84
|
+
|
|
85
|
+
residency = residency_service.residency_flag(from_provider, from_region, to_provider, to_region)
|
|
86
|
+
size = f"{vcpu:g} vCPU / {ram_gb:g} GB"
|
|
87
|
+
move = f"{from_provider}/{from_region} to {to_provider}/{to_region}"
|
|
88
|
+
|
|
89
|
+
# ---- the paragraph ----
|
|
90
|
+
parts: list[str] = []
|
|
91
|
+
if kg_saving > 0:
|
|
92
|
+
parts.append(
|
|
93
|
+
f"Moving this {size} service from {from_provider}/{from_region} to "
|
|
94
|
+
f"{to_provider}/{to_region} cuts its estimated grid carbon from {kg_a:.1f} to {kg_b:.1f} kg "
|
|
95
|
+
f"CO2 a month ({carbon_pct:+.0f}%), because the grid is cleaner there "
|
|
96
|
+
f"({a.g_co2_per_kwh:.0f} to {b.g_co2_per_kwh:.0f} gCO2/kWh).")
|
|
97
|
+
elif kg_saving < 0:
|
|
98
|
+
parts.append(
|
|
99
|
+
f"Moving this {size} service from {from_provider}/{from_region} to "
|
|
100
|
+
f"{to_provider}/{to_region} would RAISE its estimated grid carbon from {kg_a:.1f} to "
|
|
101
|
+
f"{kg_b:.1f} kg CO2 a month ({carbon_pct:+.0f}%) ({a.g_co2_per_kwh:.0f} to "
|
|
102
|
+
f"{b.g_co2_per_kwh:.0f} gCO2/kWh), so this move is not justified on carbon.")
|
|
103
|
+
else:
|
|
104
|
+
parts.append(
|
|
105
|
+
f"Moving this {size} service from {from_provider}/{from_region} to "
|
|
106
|
+
f"{to_provider}/{to_region} makes no estimated difference to grid carbon.")
|
|
107
|
+
if cost_delta is None:
|
|
108
|
+
pass
|
|
109
|
+
elif cost_delta == 0:
|
|
110
|
+
parts.append("The compute list-price estimate is unchanged.")
|
|
111
|
+
elif cost_delta < 0:
|
|
112
|
+
parts.append(f"The compute list-price estimate falls by about ${abs(cost_delta):,.0f} a month.")
|
|
113
|
+
else:
|
|
114
|
+
parts.append(f"The compute list-price estimate rises by about ${cost_delta:,.0f} a month.")
|
|
115
|
+
if added_latency is not None:
|
|
116
|
+
if added_latency > 0:
|
|
117
|
+
parts.append(f"Estimated latency for the stated users rises by about {added_latency} ms.")
|
|
118
|
+
elif added_latency < 0:
|
|
119
|
+
parts.append(f"Estimated latency for the stated users falls by about {abs(added_latency)} ms.")
|
|
120
|
+
else:
|
|
121
|
+
parts.append("Estimated latency for the stated users is about the same.")
|
|
122
|
+
if net_kg is not None:
|
|
123
|
+
cost_part = (f" and about ${egress_low:,.0f}-${egress_high:,.0f} a month in egress"
|
|
124
|
+
if egress_low is not None else "")
|
|
125
|
+
if net_kg > 0:
|
|
126
|
+
parts.append(
|
|
127
|
+
f"The data stays in {from_provider}/{from_region} (~{monthly_cross_boundary_gb:,.0f} GB a "
|
|
128
|
+
f"month crossing), which adds about {transfer_kg:.1f} kg CO2{cost_part}, "
|
|
129
|
+
f"leaving a net saving of about {net_kg:.1f} kg CO2 a month.")
|
|
130
|
+
else:
|
|
131
|
+
parts.append(
|
|
132
|
+
f"But the data stays in {from_provider}/{from_region} (~{monthly_cross_boundary_gb:,.0f} GB "
|
|
133
|
+
f"a month crossing), which adds about {transfer_kg:.1f} kg CO2{cost_part}: the move "
|
|
134
|
+
f"backfires on carbon by about {abs(net_kg):.1f} kg a month unless the data moves too.")
|
|
135
|
+
elif (from_provider, from_region) != (to_provider, to_region):
|
|
136
|
+
parts.append("Data transfer between the regions was not assessed (no cross-region GB a month "
|
|
137
|
+
"was given), so the net effect of keeping data behind is not included.")
|
|
138
|
+
if residency:
|
|
139
|
+
parts.append("Data residency: the move crosses jurisdictions, so confirm it against your "
|
|
140
|
+
"data-governance requirements.")
|
|
141
|
+
parts.append(
|
|
142
|
+
f"Carbon is a {_phrase(a.quality)} for {from_region} and a {_phrase(b.quality)} for {to_region}. "
|
|
143
|
+
"These are design-time estimates from GreenPick, not measurements or a price quote.")
|
|
144
|
+
pr_text = " ".join(parts)
|
|
145
|
+
|
|
146
|
+
if kg_saving > 0:
|
|
147
|
+
summary = (f"{move}: about {kg_saving:.1f} kg CO2 a month less"
|
|
148
|
+
+ (f" ({net_kg:.1f} kg after data transfer)" if net_kg is not None else "")
|
|
149
|
+
+ (f", {added_latency:+d} ms latency" if added_latency is not None else "") + ".")
|
|
150
|
+
else:
|
|
151
|
+
summary = f"{move}: not an improvement on carbon ({kg_saving:+.1f} kg CO2 a month)."
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
"summary": summary,
|
|
155
|
+
"pr_text": pr_text,
|
|
156
|
+
"facts": {
|
|
157
|
+
"from": f"{from_provider}/{from_region}", "to": f"{to_provider}/{to_region}",
|
|
158
|
+
"workload": size, "hours_per_month": hours_per_month,
|
|
159
|
+
"from_g_co2_per_kwh": a.g_co2_per_kwh, "to_g_co2_per_kwh": b.g_co2_per_kwh,
|
|
160
|
+
"grid_intensity_change_pct": intensity_pct,
|
|
161
|
+
"from_kg_co2_per_month": kg_a, "to_kg_co2_per_month": kg_b,
|
|
162
|
+
"monthly_kg_co2_saving": kg_saving, "carbon_change_pct": carbon_pct,
|
|
163
|
+
"from_carbon_quality": a.quality, "to_carbon_quality": b.quality,
|
|
164
|
+
"from_carbon_source": a.source, "to_carbon_source": b.source,
|
|
165
|
+
"compute_cost_change_usd_per_month": cost_delta,
|
|
166
|
+
"added_latency_ms": added_latency,
|
|
167
|
+
"locality": locality,
|
|
168
|
+
"monthly_cross_boundary_gb": monthly_cross_boundary_gb,
|
|
169
|
+
"est_monthly_egress_usd_low": egress_low, "est_monthly_egress_usd_high": egress_high,
|
|
170
|
+
"est_monthly_transfer_kg_co2": transfer_kg,
|
|
171
|
+
"net_monthly_kg_co2_saving_after_transfer": net_kg,
|
|
172
|
+
"residency_note": residency,
|
|
173
|
+
},
|
|
174
|
+
"limitations": (
|
|
175
|
+
"Formats numbers GreenPick already computes; it checks nothing new. Carbon is a grid-intensity "
|
|
176
|
+
"estimate (see each quality label), cost is a list-price ballpark that does not vary by region "
|
|
177
|
+
"within one provider, latency is a geographic estimate, and the egress figure is directional. "
|
|
178
|
+
"The text assumes the data stays in the region you are moving away from. Review the wording "
|
|
179
|
+
"before you paste it."),
|
|
180
|
+
}
|
|
@@ -57,6 +57,17 @@ def _bump_severity_for_volume(severity: str, gb: float) -> str:
|
|
|
57
57
|
return order[base]
|
|
58
58
|
|
|
59
59
|
|
|
60
|
+
def egress_rate_range(locality: str, data_provider: str) -> tuple[float | None, float | None]:
|
|
61
|
+
"""LOW-HIGH $/GB for moving bytes across the given locality tier (see _classify)."""
|
|
62
|
+
tier = pricing().get("_egress_tiers_usd_per_gb", {}).get(locality, {})
|
|
63
|
+
low, high = tier.get("low"), tier.get("high")
|
|
64
|
+
if locality == "cross_cloud":
|
|
65
|
+
prov_internet = (pricing().get(data_provider) or {}).get("egress_usd_per_gb")
|
|
66
|
+
if prov_internet is not None and high is not None:
|
|
67
|
+
high = max(high, prov_internet) # don't under-quote this provider's internet rate
|
|
68
|
+
return low, high
|
|
69
|
+
|
|
70
|
+
|
|
60
71
|
async def data_locality_check(
|
|
61
72
|
repo: CarbonRepository,
|
|
62
73
|
compute_provider: Provider,
|
|
@@ -89,14 +100,7 @@ async def data_locality_check(
|
|
|
89
100
|
# Egress is a TIER LADDER by where the bytes cross — not one flat rate. Price the tier we just
|
|
90
101
|
# classified, as a LOW-HIGH range (region-pair rates vary). For cross-cloud/internet the rate
|
|
91
102
|
# is provider-specific, so anchor the high end to the data provider's own internet rate.
|
|
92
|
-
|
|
93
|
-
tier = tiers.get(locality, {})
|
|
94
|
-
rate_low = tier.get("low")
|
|
95
|
-
rate_high = tier.get("high")
|
|
96
|
-
if locality == "cross_cloud":
|
|
97
|
-
prov_internet = (pricing().get(data_provider) or {}).get("egress_usd_per_gb")
|
|
98
|
-
if prov_internet is not None and rate_high is not None:
|
|
99
|
-
rate_high = max(rate_high, prov_internet) # don't under-quote this provider's internet rate
|
|
103
|
+
rate_low, rate_high = egress_rate_range(locality, data_provider)
|
|
100
104
|
cost_low = round(monthly_cross_boundary_gb * rate_low, 2) if rate_low is not None else None
|
|
101
105
|
cost_high = round(monthly_cross_boundary_gb * rate_high, 2) if rate_high is not None else None
|
|
102
106
|
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Does moving the DATA too pay off, or should it stay put?
|
|
2
|
+
|
|
3
|
+
The follow-on to data_locality_egress_check. Say you move compute to a greener region but the data
|
|
4
|
+
stays behind. Every month, some GB cross between them and cost egress plus a little transfer carbon.
|
|
5
|
+
You can instead migrate the data once, which costs egress once, and then that traffic stops crossing.
|
|
6
|
+
This works out when the one-off cost is paid back by the monthly cost you stop paying.
|
|
7
|
+
|
|
8
|
+
The maths is deliberately simple. The one-off move and the monthly flow cross the same boundary, so
|
|
9
|
+
they use the same $/GB rate and the same kWh/GB coefficient. That makes payback in months equal to
|
|
10
|
+
GB to migrate divided by GB a month that would stop crossing, for money and for carbon alike. The
|
|
11
|
+
dollar and kg figures are shown on top so the size of the numbers is visible.
|
|
12
|
+
|
|
13
|
+
It only weighs egress. Storage price differences, request charges, downtime, double-running during the
|
|
14
|
+
migration and the effort of doing it are NOT included.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
from ..data.repository import CarbonRepository
|
|
21
|
+
from ..data.static_data import network_model
|
|
22
|
+
from . import carbon_service, locality_service, residency_service
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def migration_payoff(
|
|
26
|
+
repo: CarbonRepository,
|
|
27
|
+
data_provider: str, data_region: str,
|
|
28
|
+
compute_provider: str, compute_region: str,
|
|
29
|
+
data_gb_to_migrate: float,
|
|
30
|
+
monthly_cross_boundary_gb: float,
|
|
31
|
+
horizon_months: float = 12,
|
|
32
|
+
) -> dict:
|
|
33
|
+
locality, _sev = locality_service._classify(
|
|
34
|
+
compute_provider, compute_region, data_provider, data_region)
|
|
35
|
+
where = f"{data_provider}/{data_region} to {compute_provider}/{compute_region}"
|
|
36
|
+
if locality == "same_region":
|
|
37
|
+
return {
|
|
38
|
+
"summary": "Nothing to migrate: the data is already in the compute region.",
|
|
39
|
+
"data_location": f"{data_provider}/{data_region}",
|
|
40
|
+
"compute_location": f"{compute_provider}/{compute_region}",
|
|
41
|
+
"verdict": "already_co_located",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
low, high = locality_service.egress_rate_range(locality, data_provider)
|
|
45
|
+
reading = await carbon_service.carbon_now(repo, data_provider, data_region) # type: ignore[arg-type]
|
|
46
|
+
kwh_per_gb = network_model()["kwh_per_gb_transfer"]
|
|
47
|
+
kg_per_gb = kwh_per_gb * reading.g_co2_per_kwh / 1000.0
|
|
48
|
+
|
|
49
|
+
one_off_kg = round(data_gb_to_migrate * kg_per_gb, 2)
|
|
50
|
+
monthly_kg = round(monthly_cross_boundary_gb * kg_per_gb, 2)
|
|
51
|
+
one_off_usd = (round(data_gb_to_migrate * low, 2), round(data_gb_to_migrate * high, 2)) \
|
|
52
|
+
if low is not None and high is not None else (None, None)
|
|
53
|
+
monthly_usd = (round(monthly_cross_boundary_gb * low, 2), round(monthly_cross_boundary_gb * high, 2)) \
|
|
54
|
+
if low is not None and high is not None else (None, None)
|
|
55
|
+
|
|
56
|
+
payback_months: Optional[float] = None
|
|
57
|
+
if monthly_cross_boundary_gb > 0:
|
|
58
|
+
payback_months = round(data_gb_to_migrate / monthly_cross_boundary_gb, 1)
|
|
59
|
+
|
|
60
|
+
net_usd = None
|
|
61
|
+
net_kg = None
|
|
62
|
+
if payback_months is not None:
|
|
63
|
+
net_kg = round(horizon_months * monthly_kg - one_off_kg, 2)
|
|
64
|
+
if one_off_usd[0] is not None:
|
|
65
|
+
# worst case: pay the high rate to move and avoid only the low rate; best case: the reverse
|
|
66
|
+
net_usd = (round(horizon_months * monthly_usd[0] - one_off_usd[1], 2),
|
|
67
|
+
round(horizon_months * monthly_usd[1] - one_off_usd[0], 2))
|
|
68
|
+
|
|
69
|
+
residency = residency_service.residency_flag(
|
|
70
|
+
data_provider, data_region, compute_provider, compute_region)
|
|
71
|
+
|
|
72
|
+
if monthly_cross_boundary_gb <= 0:
|
|
73
|
+
verdict = "no_ongoing_traffic"
|
|
74
|
+
summary = (f"Moving the data buys no egress saving: no GB a month cross between "
|
|
75
|
+
f"{where}, so the one-off migration (~{data_gb_to_migrate:,.0f} GB) has nothing to pay "
|
|
76
|
+
"it back. Only move it for other reasons.")
|
|
77
|
+
elif payback_months <= horizon_months:
|
|
78
|
+
verdict = "pays_back"
|
|
79
|
+
summary = (f"Moving the data too pays back in about {payback_months:g} months "
|
|
80
|
+
f"(~{data_gb_to_migrate:,.0f} GB once vs ~{monthly_cross_boundary_gb:,.0f} GB a month "
|
|
81
|
+
f"no longer crossing), within your {horizon_months:g}-month horizon.")
|
|
82
|
+
else:
|
|
83
|
+
verdict = "does_not_pay_back"
|
|
84
|
+
summary = (f"Moving the data too does NOT pay back within {horizon_months:g} months: it takes "
|
|
85
|
+
f"about {payback_months:g} months (~{data_gb_to_migrate:,.0f} GB once vs "
|
|
86
|
+
f"~{monthly_cross_boundary_gb:,.0f} GB a month no longer crossing). Leave the data "
|
|
87
|
+
"where it is, or re-check the volumes.")
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
"summary": summary,
|
|
91
|
+
"verdict": verdict,
|
|
92
|
+
"data_location": f"{data_provider}/{data_region}",
|
|
93
|
+
"compute_location": f"{compute_provider}/{compute_region}",
|
|
94
|
+
"locality": locality,
|
|
95
|
+
"data_gb_to_migrate": data_gb_to_migrate,
|
|
96
|
+
"monthly_cross_boundary_gb": monthly_cross_boundary_gb,
|
|
97
|
+
"horizon_months": horizon_months,
|
|
98
|
+
"payback_months": payback_months,
|
|
99
|
+
"egress_rate_usd_per_gb_low": low, "egress_rate_usd_per_gb_high": high,
|
|
100
|
+
"one_off_migration_egress_usd_low": one_off_usd[0],
|
|
101
|
+
"one_off_migration_egress_usd_high": one_off_usd[1],
|
|
102
|
+
"monthly_egress_avoided_usd_low": monthly_usd[0],
|
|
103
|
+
"monthly_egress_avoided_usd_high": monthly_usd[1],
|
|
104
|
+
"one_off_transfer_kg_co2": one_off_kg,
|
|
105
|
+
"monthly_transfer_kg_co2_avoided": monthly_kg,
|
|
106
|
+
"net_over_horizon_usd_low": net_usd[0] if net_usd else None,
|
|
107
|
+
"net_over_horizon_usd_high": net_usd[1] if net_usd else None,
|
|
108
|
+
"net_over_horizon_kg_co2": net_kg,
|
|
109
|
+
"transfer_carbon_source": reading.source,
|
|
110
|
+
"transfer_carbon_quality": reading.quality,
|
|
111
|
+
"residency_note": residency,
|
|
112
|
+
"limitations": (
|
|
113
|
+
"Egress only. The cost is a dated list-price ballpark (a low-high range, no free tiers or "
|
|
114
|
+
"negotiated discounts) and the transfer carbon uses one kWh/GB coefficient that is "
|
|
115
|
+
"order-of-magnitude. Storage price differences, API/request charges, downtime, running both "
|
|
116
|
+
"copies during the migration and the work of migrating are NOT included. It assumes moving "
|
|
117
|
+
"the data stops the monthly traffic entirely. Payback is GB to migrate divided by GB a month "
|
|
118
|
+
"that stops crossing, because both use the same rate."),
|
|
119
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: greenpick
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
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
|
|
@@ -44,7 +44,7 @@ Dynamic: license-file
|
|
|
44
44
|
<img src="https://img.shields.io/badge/license-MIT-green" alt="License: MIT" />
|
|
45
45
|
<img src="https://img.shields.io/badge/tests-passing-brightgreen" alt="Tests: passing" />
|
|
46
46
|
<img src="https://img.shields.io/badge/python-3.11%2B-blue" alt="Python 3.11+" />
|
|
47
|
-
<img src="https://img.shields.io/badge/MCP%20tools-
|
|
47
|
+
<img src="https://img.shields.io/badge/MCP%20tools-19-0ea5e9" alt="MCP tools: 19" />
|
|
48
48
|
</p>
|
|
49
49
|
|
|
50
50
|
A **design-time sustainability co-pilot** that runs as an MCP server inside your vibe-coding
|
|
@@ -52,7 +52,7 @@ environment (Cursor / Claude Code / Kiro). Ask it where to deploy, when to run a
|
|
|
52
52
|
right-size — and it answers with an honest **carbon + cost + latency** tradeoff, in-editor, in
|
|
53
53
|
seconds. (It responds when you ask; auto-nudging on infra-code changes is on the roadmap.)
|
|
54
54
|
|
|
55
|
-
> **Status: working —
|
|
55
|
+
> **Status: working — 19 tools.** Live carbon for US (EIA-930), EU (ENTSO-E) and Canada (Hydro-Québec,
|
|
56
56
|
> IESO — no key needed), cross-cloud cost
|
|
57
57
|
> parity (Vantage-refreshed), per-axis R/Y/G ratings, carbon-aware scheduling + day-ahead forecast,
|
|
58
58
|
> GPU-training **and LLM-inference** carbon, right-sizing, serving-pattern and data-locality/residency
|
|
@@ -143,7 +143,7 @@ See `docs/user-questions-backlog.md`.
|
|
|
143
143
|
|
|
144
144
|
## MCP tools
|
|
145
145
|
|
|
146
|
-
|
|
146
|
+
19 tools, grouped by what you're trying to do.
|
|
147
147
|
|
|
148
148
|
### Pick a region
|
|
149
149
|
|
|
@@ -178,6 +178,8 @@ See `docs/user-questions-backlog.md`.
|
|
|
178
178
|
| `rightsizing_check` | Right-sizing (the #1 FinOps win) with a carbon twin: if you're over-provisioned, a smaller size cuts **both** cost and carbon → the right-sized target with monthly $ and kg CO₂ saving. |
|
|
179
179
|
| `offhours_check` | For dev/test/staging that doesn't need to run 24/7: the monthly $ and kg CO2 saved by running it only N hours a week. Compute only, directional. |
|
|
180
180
|
| `data_locality_egress_check` | Warn when running compute away from where your data lives backfires: directional egress $ + coarse transfer carbon, and whether a greener-region move is still a net win. |
|
|
181
|
+
| `migration_payoff` | Does moving the **data** to the compute's region pay off? Payback in months, one-off vs monthly egress and transfer carbon, net over 12 months. Egress only. |
|
|
182
|
+
| `region_move_justification` | A short paste-ready paragraph for a pull request or decision record, with the carbon, cost, latency and egress numbers and their quality labels. Says so if the move is not an improvement. |
|
|
181
183
|
| `serving_pattern_advice` | For a user-facing platform: serve from one region or several? Traffic-weighted latency + carbon, with the multi-region data-sync egress and cross-jurisdiction residency catches. |
|
|
182
184
|
|
|
183
185
|
### Instrument & understand
|
|
@@ -40,9 +40,11 @@ src/greenpick/services/data_hint.py
|
|
|
40
40
|
src/greenpick/services/forecast_service.py
|
|
41
41
|
src/greenpick/services/inference_service.py
|
|
42
42
|
src/greenpick/services/instance_service.py
|
|
43
|
+
src/greenpick/services/justification_service.py
|
|
43
44
|
src/greenpick/services/latency_service.py
|
|
44
45
|
src/greenpick/services/locality_service.py
|
|
45
46
|
src/greenpick/services/metric_scaffold_service.py
|
|
47
|
+
src/greenpick/services/migration_service.py
|
|
46
48
|
src/greenpick/services/offhours_service.py
|
|
47
49
|
src/greenpick/services/recommender.py
|
|
48
50
|
src/greenpick/services/residency_service.py
|
|
@@ -1802,7 +1802,7 @@ def test_every_tool_declares_all_four_annotation_hints():
|
|
|
1802
1802
|
import asyncio
|
|
1803
1803
|
from greenpick import server
|
|
1804
1804
|
tools = asyncio.run(server.mcp.list_tools())
|
|
1805
|
-
assert len(tools) ==
|
|
1805
|
+
assert len(tools) == 19
|
|
1806
1806
|
for t in tools:
|
|
1807
1807
|
a = t.annotations
|
|
1808
1808
|
assert a is not None, t.name
|
|
@@ -1909,3 +1909,62 @@ async def test_locality_summary_is_first_key_and_flags_missing_verdict():
|
|
|
1909
1909
|
assert "net win" in yes["summary"].lower() and not yes["summary"].startswith("NO NET VERDICT")
|
|
1910
1910
|
same = (await ls.data_locality_check(MockCarbonRepository(), "aws", "us-east-1", "aws", "us-east-1", 100)).model_dump()
|
|
1911
1911
|
assert next(iter(same)) == "summary" and same["summary"]
|
|
1912
|
+
|
|
1913
|
+
|
|
1914
|
+
# --- 0.6.0: region_move_justification and migration_payoff ---
|
|
1915
|
+
@pytest.mark.asyncio
|
|
1916
|
+
async def test_justification_win_with_data_left_behind():
|
|
1917
|
+
from greenpick.services import justification_service as js
|
|
1918
|
+
r = await js.region_move_justification(
|
|
1919
|
+
MockCarbonRepository(), "aws", "us-east-1", "aws", "eu-north-1", 4, 16,
|
|
1920
|
+
user_lat=40.71, user_lon=-74.01, monthly_cross_boundary_gb=100)
|
|
1921
|
+
f = r["facts"]
|
|
1922
|
+
assert next(iter(r)) == "summary" and f["monthly_kg_co2_saving"] > 0
|
|
1923
|
+
assert f["net_monthly_kg_co2_saving_after_transfer"] == round(
|
|
1924
|
+
f["monthly_kg_co2_saving"] - f["est_monthly_transfer_kg_co2"], 2)
|
|
1925
|
+
assert f["added_latency_ms"] > 0 and "RESIDENCY" in f["residency_note"]
|
|
1926
|
+
t = r["pr_text"]
|
|
1927
|
+
assert "cuts its estimated grid carbon" in t and "net saving" in t and "not measurements" in t
|
|
1928
|
+
|
|
1929
|
+
|
|
1930
|
+
@pytest.mark.asyncio
|
|
1931
|
+
async def test_justification_says_so_when_move_is_worse_or_data_assessment_missing():
|
|
1932
|
+
from greenpick.services import justification_service as js
|
|
1933
|
+
worse = await js.region_move_justification(MockCarbonRepository(), "aws", "eu-north-1", "aws", "us-east-1", 4, 16)
|
|
1934
|
+
assert worse["facts"]["monthly_kg_co2_saving"] < 0
|
|
1935
|
+
assert "RAISE" in worse["pr_text"] and "not justified" in worse["pr_text"]
|
|
1936
|
+
assert worse["summary"].endswith("CO2 a month).") and "not an improvement" in worse["summary"]
|
|
1937
|
+
assert "was not assessed" in worse["pr_text"] # no GB given: says what is not included
|
|
1938
|
+
|
|
1939
|
+
|
|
1940
|
+
@pytest.mark.asyncio
|
|
1941
|
+
async def test_justification_flags_backfire():
|
|
1942
|
+
from greenpick.services import justification_service as js
|
|
1943
|
+
r = await js.region_move_justification(
|
|
1944
|
+
MockCarbonRepository(), "aws", "us-east-1", "aws", "eu-north-1", 1, 2, monthly_cross_boundary_gb=100000)
|
|
1945
|
+
assert r["facts"]["net_monthly_kg_co2_saving_after_transfer"] < 0 and "backfires" in r["pr_text"]
|
|
1946
|
+
|
|
1947
|
+
|
|
1948
|
+
@pytest.mark.asyncio
|
|
1949
|
+
async def test_migration_payoff_months_equal_ratio_and_verdicts():
|
|
1950
|
+
from greenpick.services import migration_service as ms
|
|
1951
|
+
args = ("aws", "us-east-1", "aws", "eu-north-1")
|
|
1952
|
+
ok = await ms.migration_payoff(MockCarbonRepository(), *args, 500, 250)
|
|
1953
|
+
assert ok["payback_months"] == 2.0 and ok["verdict"] == "pays_back" and next(iter(ok)) == "summary"
|
|
1954
|
+
assert ok["net_over_horizon_kg_co2"] == round(12 * ok["monthly_transfer_kg_co2_avoided"]
|
|
1955
|
+
- ok["one_off_transfer_kg_co2"], 2)
|
|
1956
|
+
assert ok["net_over_horizon_usd_low"] <= ok["net_over_horizon_usd_high"]
|
|
1957
|
+
slow = await ms.migration_payoff(MockCarbonRepository(), *args, 5000, 100)
|
|
1958
|
+
assert slow["payback_months"] == 50.0 and slow["verdict"] == "does_not_pay_back"
|
|
1959
|
+
none = await ms.migration_payoff(MockCarbonRepository(), *args, 500, 0)
|
|
1960
|
+
assert none["verdict"] == "no_ongoing_traffic" and none["payback_months"] is None
|
|
1961
|
+
same = await ms.migration_payoff(MockCarbonRepository(), "aws", "us-east-1", "aws", "us-east-1", 500, 10)
|
|
1962
|
+
assert same["verdict"] == "already_co_located"
|
|
1963
|
+
|
|
1964
|
+
|
|
1965
|
+
@pytest.mark.asyncio
|
|
1966
|
+
async def test_new_tools_validate_inputs():
|
|
1967
|
+
bad = await _tool("migration_payoff")("aws", "us-east-1", "aws", "eu-north-1", 0, 10)
|
|
1968
|
+
assert "error" in bad
|
|
1969
|
+
bad2 = await _tool("region_move_justification")("aws", "us-east-1", "aws", "eu-north-1", 4, 16, hours_per_month=0)
|
|
1970
|
+
assert "error" in bad2
|
|
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
|
|
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
|