greenpick 0.1.0__py3-none-any.whl
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/__init__.py +7 -0
- greenpick/__main__.py +18 -0
- greenpick/config.py +58 -0
- greenpick/data/__init__.py +28 -0
- greenpick/data/auto_repository.py +47 -0
- greenpick/data/eia_repository.py +156 -0
- greenpick/data/electricitymaps_repository.py +104 -0
- greenpick/data/emission_factors.json +35 -0
- greenpick/data/entsoe_repository.py +401 -0
- greenpick/data/gpu_power.json +26 -0
- greenpick/data/inference_energy.json +50 -0
- greenpick/data/instance_families.json +32 -0
- greenpick/data/mock_repository.py +53 -0
- greenpick/data/network.json +7 -0
- greenpick/data/power.json +7 -0
- greenpick/data/pricing.json +31 -0
- greenpick/data/regions.json +22 -0
- greenpick/data/repository.py +27 -0
- greenpick/data/static_data.py +105 -0
- greenpick/models.py +342 -0
- greenpick/server.py +349 -0
- greenpick/services/__init__.py +5 -0
- greenpick/services/carbon_service.py +28 -0
- greenpick/services/cost_service.py +21 -0
- greenpick/services/forecast_service.py +72 -0
- greenpick/services/inference_service.py +100 -0
- greenpick/services/instance_service.py +132 -0
- greenpick/services/latency_service.py +33 -0
- greenpick/services/locality_service.py +160 -0
- greenpick/services/metric_scaffold_service.py +149 -0
- greenpick/services/recommender.py +203 -0
- greenpick/services/residency_service.py +53 -0
- greenpick/services/rightsizing_service.py +93 -0
- greenpick/services/scheduling_service.py +211 -0
- greenpick/services/serving_service.py +202 -0
- greenpick/services/training_service.py +209 -0
- greenpick-0.1.0.dist-info/METADATA +271 -0
- greenpick-0.1.0.dist-info/RECORD +42 -0
- greenpick-0.1.0.dist-info/WHEEL +5 -0
- greenpick-0.1.0.dist-info/entry_points.txt +2 -0
- greenpick-0.1.0.dist-info/licenses/LICENSE +21 -0
- greenpick-0.1.0.dist-info/top_level.txt +1 -0
greenpick/server.py
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""MCP server: exposes GreenPick's 15 tools over FastMCP.
|
|
2
|
+
|
|
3
|
+
Thin layer — parse/validate inputs, call services, return JSON-friendly dicts. All math
|
|
4
|
+
lives in services/; this file only wires tools to them.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from mcp.server.fastmcp import FastMCP
|
|
11
|
+
|
|
12
|
+
from .models import Workload, GeoPoint, Provider
|
|
13
|
+
from .data import get_repository
|
|
14
|
+
from .services import (
|
|
15
|
+
carbon_service, recommender, scheduling_service, instance_service, locality_service,
|
|
16
|
+
training_service, forecast_service, serving_service, metric_scaffold_service,
|
|
17
|
+
rightsizing_service, inference_service,
|
|
18
|
+
)
|
|
19
|
+
from .models import ServingHub
|
|
20
|
+
|
|
21
|
+
mcp = FastMCP("greenpick")
|
|
22
|
+
_repo = get_repository()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@mcp.tool()
|
|
26
|
+
async def compare_regions_tri_axis(
|
|
27
|
+
user_lat: float,
|
|
28
|
+
user_lon: float,
|
|
29
|
+
vcpu: float,
|
|
30
|
+
ram_gb: float,
|
|
31
|
+
est_monthly_egress_gb: float = 0,
|
|
32
|
+
providers: Optional[list[str]] = None,
|
|
33
|
+
optimize_for: str = "carbon",
|
|
34
|
+
max_added_latency_ms: Optional[float] = None,
|
|
35
|
+
baseline_region: Optional[str] = None,
|
|
36
|
+
regions: Optional[list[str]] = None,
|
|
37
|
+
) -> dict:
|
|
38
|
+
"""Rank candidate cloud regions by carbon, with cost + latency tradeoffs, and recommend one.
|
|
39
|
+
Give the user's location (lat/lon) and the service shape (vcpu, ram_gb, egress). Pass `regions`
|
|
40
|
+
(e.g. ["us-east-1","us-west-2","eu-west-1"]) to rank ONLY those you'd actually consider,
|
|
41
|
+
instead of the whole catalog — your baseline is always kept in the comparison. If the pick is
|
|
42
|
+
in a different region from where your data lives, run data_locality_egress_check before moving:
|
|
43
|
+
cross-region/cross-cloud egress can erase the carbon/cost win."""
|
|
44
|
+
rec = await recommender.compare_regions(
|
|
45
|
+
_repo,
|
|
46
|
+
providers=[p for p in (providers or [])] or None, # type: ignore[list-item]
|
|
47
|
+
user_location=GeoPoint(lat=user_lat, lon=user_lon),
|
|
48
|
+
workload=Workload(vcpu=vcpu, ram_gb=ram_gb, est_monthly_egress_gb=est_monthly_egress_gb),
|
|
49
|
+
optimize_for=optimize_for,
|
|
50
|
+
max_added_latency_ms=max_added_latency_ms,
|
|
51
|
+
baseline_region=baseline_region,
|
|
52
|
+
regions=[r for r in (regions or [])] or None,
|
|
53
|
+
)
|
|
54
|
+
return rec.model_dump()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@mcp.tool()
|
|
58
|
+
async def greener_alternative_to(
|
|
59
|
+
current_region: str,
|
|
60
|
+
user_lat: float,
|
|
61
|
+
user_lon: float,
|
|
62
|
+
vcpu: float,
|
|
63
|
+
ram_gb: float,
|
|
64
|
+
est_monthly_egress_gb: float = 0,
|
|
65
|
+
max_added_latency_ms: float = 100,
|
|
66
|
+
providers: Optional[list[str]] = None,
|
|
67
|
+
regions: Optional[list[str]] = None,
|
|
68
|
+
) -> dict:
|
|
69
|
+
"""You picked `current_region` — find the greenest region within +max_added_latency_ms,
|
|
70
|
+
with the carbon/cost/latency delta vs. your current choice. The core 'nudge' tool. Pass
|
|
71
|
+
`regions` to limit the search to options you'd actually move to; your current region is
|
|
72
|
+
always included. Before accepting a move, run data_locality_egress_check if your data stays in
|
|
73
|
+
the current region — the egress to feed compute from afar can outweigh the greener-region win."""
|
|
74
|
+
rec = await recommender.compare_regions(
|
|
75
|
+
_repo,
|
|
76
|
+
providers=[p for p in (providers or [])] or None, # type: ignore[list-item]
|
|
77
|
+
user_location=GeoPoint(lat=user_lat, lon=user_lon),
|
|
78
|
+
workload=Workload(vcpu=vcpu, ram_gb=ram_gb, est_monthly_egress_gb=est_monthly_egress_gb),
|
|
79
|
+
optimize_for="carbon",
|
|
80
|
+
max_added_latency_ms=max_added_latency_ms,
|
|
81
|
+
baseline_region=current_region,
|
|
82
|
+
regions=[r for r in (regions or [])] or None,
|
|
83
|
+
)
|
|
84
|
+
return rec.model_dump()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@mcp.tool()
|
|
88
|
+
async def estimate_workload_carbon(
|
|
89
|
+
provider: str,
|
|
90
|
+
region: str,
|
|
91
|
+
vcpu: float,
|
|
92
|
+
ram_gb: float,
|
|
93
|
+
hours_per_month: float = 730,
|
|
94
|
+
) -> dict:
|
|
95
|
+
"""Rough kgCO2/month for a workload running in a region. An ESTIMATE (documented power
|
|
96
|
+
model x grid intensity), not a measurement."""
|
|
97
|
+
reading = await carbon_service.carbon_now(_repo, provider, region) # type: ignore[arg-type]
|
|
98
|
+
wl = Workload(vcpu=vcpu, ram_gb=ram_gb, hours_per_month=hours_per_month)
|
|
99
|
+
kg = carbon_service.estimate_kg_co2_per_month(wl, reading)
|
|
100
|
+
return {
|
|
101
|
+
"provider": provider,
|
|
102
|
+
"region": region,
|
|
103
|
+
"estimated_kg_co2_per_month": kg,
|
|
104
|
+
"grid_intensity_g_co2_per_kwh": reading.g_co2_per_kwh,
|
|
105
|
+
"carbon_source": reading.source,
|
|
106
|
+
"carbon_quality": reading.quality,
|
|
107
|
+
"note": "Estimate only: documented power model x grid intensity. Not a measurement.",
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@mcp.tool()
|
|
112
|
+
async def region_carbon_now(provider: str, region: str) -> dict:
|
|
113
|
+
"""Current grid carbon intensity for a region, with source, timestamp, and data freshness."""
|
|
114
|
+
reading = await carbon_service.carbon_now(_repo, provider, region) # type: ignore[arg-type]
|
|
115
|
+
return reading.model_dump()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@mcp.tool()
|
|
119
|
+
async def cleanest_hours_to_run(
|
|
120
|
+
provider: str, region: str, top_n: int = 3, window_hours: Optional[int] = None,
|
|
121
|
+
) -> dict:
|
|
122
|
+
"""Carbon-aware scheduling: given a region, report its cleanest recent hours so you can
|
|
123
|
+
time a DEFERRABLE job (nightly ETL, batch, backups, training) when the grid is greenest.
|
|
124
|
+
Returns the lowest-carbon hours + the % reduction vs. the dirtiest hour. Pass `window_hours`
|
|
125
|
+
(e.g. 6) for a job of known duration — it finds the cleanest CONTIGUOUS run of that many hours
|
|
126
|
+
to start at, since you can't split one job across scattered non-adjacent hours. Directional."""
|
|
127
|
+
advice = await scheduling_service.cleanest_hours(
|
|
128
|
+
_repo, provider, region, top_n, window_hours=window_hours, # type: ignore[arg-type]
|
|
129
|
+
)
|
|
130
|
+
return advice.model_dump()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@mcp.tool()
|
|
134
|
+
async def greener_instance_family(
|
|
135
|
+
provider: str,
|
|
136
|
+
region: str,
|
|
137
|
+
vcpu: float,
|
|
138
|
+
ram_gb: float,
|
|
139
|
+
current_family: Optional[str] = None,
|
|
140
|
+
hours_per_month: float = 730,
|
|
141
|
+
) -> dict:
|
|
142
|
+
"""Suggest a greener SAME-SIZE instance family in the SAME region. The mechanism is
|
|
143
|
+
performance-per-watt: a newer-generation or ARM (Graviton-class) family does the same
|
|
144
|
+
work on less energy -> less CO2 at the region's grid intensity. Give your current family
|
|
145
|
+
(e.g. 'm5', 'n2', 'Dv5') for a savings delta. Directional, not a measurement."""
|
|
146
|
+
if not instance_service.is_supported_provider(provider):
|
|
147
|
+
return {
|
|
148
|
+
"provider": provider,
|
|
149
|
+
"region": region,
|
|
150
|
+
"error": f"Unsupported provider '{provider}'.",
|
|
151
|
+
"supported_providers": list(instance_service.SUPPORTED_PROVIDERS),
|
|
152
|
+
"options": [],
|
|
153
|
+
}
|
|
154
|
+
advice = await instance_service.greener_instance_family(
|
|
155
|
+
_repo, provider, region, vcpu, ram_gb, # type: ignore[arg-type]
|
|
156
|
+
current_family=current_family, hours_per_month=hours_per_month,
|
|
157
|
+
)
|
|
158
|
+
return advice.model_dump()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@mcp.tool()
|
|
162
|
+
async def estimate_inference_carbon(
|
|
163
|
+
size_class: str,
|
|
164
|
+
monthly_requests: float,
|
|
165
|
+
avg_output_tokens: int = 300,
|
|
166
|
+
region: Optional[str] = None,
|
|
167
|
+
provider: Optional[str] = None,
|
|
168
|
+
) -> dict:
|
|
169
|
+
"""Energy + CO2 of running an LLM INFERENCE workload (serving queries) — the inference-side
|
|
170
|
+
sibling of estimate_training_carbon. Give the model SIZE CLASS (small | mid | large |
|
|
171
|
+
frontier_dense | moe, or a param alias like '8b' / '70b' / 'moe'), monthly request volume, and
|
|
172
|
+
avg OUTPUT tokens per request (output drives inference energy). Pass `region` to add CO2 at live
|
|
173
|
+
grid intensity. Returns a LOW-HIGH kWh/CO2 band (wide on purpose — serving choices move the same
|
|
174
|
+
model many-fold). Directional, most trustworthy for self-hosted/open models; keyed on size class,
|
|
175
|
+
NOT a named-model leaderboard. Smaller size class is the biggest lever."""
|
|
176
|
+
advice = await inference_service.estimate_inference_carbon(
|
|
177
|
+
_repo, size_class, monthly_requests, avg_output_tokens=avg_output_tokens, # type: ignore[arg-type]
|
|
178
|
+
region=region, provider=provider, # type: ignore[arg-type]
|
|
179
|
+
)
|
|
180
|
+
return advice.model_dump()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@mcp.tool()
|
|
184
|
+
async def rightsizing_check(
|
|
185
|
+
provider: str,
|
|
186
|
+
region: str,
|
|
187
|
+
vcpu: float,
|
|
188
|
+
ram_gb: float,
|
|
189
|
+
cpu_utilization: float,
|
|
190
|
+
ram_utilization: Optional[float] = None,
|
|
191
|
+
hours_per_month: float = 730,
|
|
192
|
+
) -> dict:
|
|
193
|
+
"""Right-sizing (the #1 FinOps win) with a carbon twin: if you're over-provisioned, a smaller
|
|
194
|
+
size cuts BOTH cost and carbon — same work, less waste, no tradeoff. Give the provisioned size
|
|
195
|
+
(vcpu, ram_gb) and observed CPU utilisation (0..1, e.g. 0.2 for 20%); optionally RAM utilisation.
|
|
196
|
+
Returns the right-sized target (keeps ~40% headroom) with the monthly $ AND kg CO2 saving. Lead
|
|
197
|
+
with the cost number, carbon is the twin. Directional, design-time — uses the utilisation you
|
|
198
|
+
report, not a reading of your fleet."""
|
|
199
|
+
advice = await rightsizing_service.rightsizing_check(
|
|
200
|
+
_repo, provider, region, vcpu, ram_gb, cpu_utilization, # type: ignore[arg-type]
|
|
201
|
+
ram_utilization=ram_utilization, hours_per_month=hours_per_month,
|
|
202
|
+
)
|
|
203
|
+
return advice.model_dump()
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@mcp.tool()
|
|
207
|
+
async def carbon_metric_scaffold(
|
|
208
|
+
language: str = "python",
|
|
209
|
+
region: Optional[str] = None,
|
|
210
|
+
provider: Optional[str] = None,
|
|
211
|
+
vcpu: float = 1,
|
|
212
|
+
ram_gb: float = 2,
|
|
213
|
+
) -> dict:
|
|
214
|
+
"""Emit paste-in instrumentation so the user's OWN app can report an ESTIMATED carbon metric
|
|
215
|
+
over time. Consider offering this when the user is building an app/service and wants ongoing
|
|
216
|
+
visibility into its footprint. GreenPick returns a code snippet (Python in v1) + the power-model
|
|
217
|
+
coefficients + a methodology note; the app measures observable proxies (CPU utilisation, vCPU/RAM,
|
|
218
|
+
uptime) and multiplies by GreenPick's power model x the region's grid intensity. Pass `region`
|
|
219
|
+
to bake in live grid intensity. The emitted number is a DIRECTIONAL estimate, self-labelled, NOT
|
|
220
|
+
metered energy — and it's for SELF-tracking, not ranking against other apps. GreenPick does not
|
|
221
|
+
run or measure anything itself."""
|
|
222
|
+
scaffold = await metric_scaffold_service.carbon_metric_scaffold(
|
|
223
|
+
_repo, language=language, region=region, provider=provider, # type: ignore[arg-type]
|
|
224
|
+
vcpu=vcpu, ram_gb=ram_gb,
|
|
225
|
+
)
|
|
226
|
+
return scaffold.model_dump()
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@mcp.tool()
|
|
230
|
+
async def serving_pattern_advice(
|
|
231
|
+
hubs: list[dict],
|
|
232
|
+
vcpu: float = 1,
|
|
233
|
+
ram_gb: float = 2,
|
|
234
|
+
providers: Optional[list[str]] = None,
|
|
235
|
+
) -> dict:
|
|
236
|
+
"""Design-time advice for a USER-FACING PLATFORM: should you serve users from ONE region or
|
|
237
|
+
SEVERAL? Consider calling this when the user is building a web app / dashboard / API that serves
|
|
238
|
+
end users. Give `hubs` as where your users are — a list like
|
|
239
|
+
[{"name":"eu","share":0.4},{"name":"us","share":0.4},{"name":"apac","share":0.2}] (known names
|
|
240
|
+
us/eu/uk/apac/au/india/japan/sa resolve automatically, or pass lat+lon). Rough shares are fine.
|
|
241
|
+
Scores single-region-for-all vs one-region-per-hub on weighted latency + grid carbon, and flags
|
|
242
|
+
the multi-region catches: cross-region data-sync egress and cross-jurisdiction residency. This is
|
|
243
|
+
design-time guidance, NOT a runtime meter — it never measures your running app."""
|
|
244
|
+
parsed = [
|
|
245
|
+
ServingHub(
|
|
246
|
+
name=str(h.get("name", f"hub{i}")),
|
|
247
|
+
lat=float(h.get("lat", 0) or 0), lon=float(h.get("lon", 0) or 0),
|
|
248
|
+
share=float(h.get("share", 1.0 / max(1, len(hubs)))),
|
|
249
|
+
)
|
|
250
|
+
for i, h in enumerate(hubs)
|
|
251
|
+
]
|
|
252
|
+
advice = await serving_service.serving_pattern(
|
|
253
|
+
_repo, parsed, Workload(vcpu=vcpu, ram_gb=ram_gb),
|
|
254
|
+
providers=[p for p in (providers or [])] or None, # type: ignore[list-item]
|
|
255
|
+
)
|
|
256
|
+
return advice.model_dump()
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@mcp.tool()
|
|
260
|
+
async def data_locality_egress_check(
|
|
261
|
+
compute_provider: str,
|
|
262
|
+
compute_region: str,
|
|
263
|
+
data_provider: str,
|
|
264
|
+
data_region: str,
|
|
265
|
+
monthly_cross_boundary_gb: float,
|
|
266
|
+
carbon_saving_kg_co2_from_move: Optional[float] = None,
|
|
267
|
+
) -> dict:
|
|
268
|
+
"""Will running compute away from where your DATA lives backfire via egress? Give where
|
|
269
|
+
compute runs, where the data lives (provider+region), and monthly GB crossing between them.
|
|
270
|
+
Returns a directional egress-COST ballpark, a coarse transfer-carbon figure, and — if you
|
|
271
|
+
pass the carbon saving the greener-region move buys — whether it's still a net win. Use this
|
|
272
|
+
before accepting a greener-region suggestion when your data stays put."""
|
|
273
|
+
advice = await locality_service.data_locality_check(
|
|
274
|
+
_repo, compute_provider, compute_region, data_provider, data_region, # type: ignore[arg-type]
|
|
275
|
+
monthly_cross_boundary_gb=monthly_cross_boundary_gb,
|
|
276
|
+
carbon_saving_kg_co2_from_move=carbon_saving_kg_co2_from_move,
|
|
277
|
+
)
|
|
278
|
+
return advice.model_dump()
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
@mcp.tool()
|
|
282
|
+
async def carbon_forecast(
|
|
283
|
+
provider: str, region: str, hours_ahead: int = 24, top_n: int = 5,
|
|
284
|
+
) -> dict:
|
|
285
|
+
"""Forward-looking scheduling: which UPCOMING hours will be cleanest? Uses the EU day-ahead
|
|
286
|
+
wind+solar forecast (ENTSO-E) to rank the greenest hours over the next `hours_ahead`. This is
|
|
287
|
+
a RELATIVE ranking (more forecast renewables = cleaner), not an absolute gCO2/kWh forecast.
|
|
288
|
+
EU regions get a true forecast; US/uncovered regions degrade to the recent historical pattern
|
|
289
|
+
(labelled 'historical'). Use this to PLAN a deferrable job, vs cleanest_hours_to_run (reactive)."""
|
|
290
|
+
advice = await forecast_service.carbon_forecast(
|
|
291
|
+
_repo, provider, region, hours_ahead=hours_ahead, top_n=top_n, # type: ignore[arg-type]
|
|
292
|
+
)
|
|
293
|
+
return advice.model_dump()
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@mcp.tool()
|
|
297
|
+
async def cleanest_time_of_day(provider: str, region: str) -> dict:
|
|
298
|
+
"""For a RECURRING job (e.g. a nightly batch you run every day), find the region's cleanest
|
|
299
|
+
time of day — each hour-of-day averaged across the recent days of data. Returns the cleanest/
|
|
300
|
+
dirtiest hour-of-day and the % gap. Honest scope: a DAILY pattern over the few days of free
|
|
301
|
+
data, NOT a 7-day day-of-week pattern. Use `cleanest_hours_to_run` for a one-off job's timing."""
|
|
302
|
+
advice = await scheduling_service.cleanest_time_of_day(_repo, provider, region) # type: ignore[arg-type]
|
|
303
|
+
return advice.model_dump()
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
@mcp.tool()
|
|
307
|
+
async def estimate_training_carbon(
|
|
308
|
+
gpu: str,
|
|
309
|
+
gpu_count: int,
|
|
310
|
+
gpu_hours: float,
|
|
311
|
+
region: Optional[str] = None,
|
|
312
|
+
provider: Optional[str] = None,
|
|
313
|
+
utilization: Optional[float] = None,
|
|
314
|
+
top_n_regions: int = 5,
|
|
315
|
+
) -> dict:
|
|
316
|
+
"""Carbon of a GPU training run, with greener WHERE (region) and WHEN (hours) guidance.
|
|
317
|
+
Give the GPU (e.g. 'a100', 'h100', 'l4'), how many, and total GPU-hours for the run. Omit
|
|
318
|
+
`region` to rank regions greenest-first; pass one to score just that region. Training is both
|
|
319
|
+
portable and deferrable, so this combines region choice with cleanest-hours timing. Directional
|
|
320
|
+
(TDP-based energy); GPU availability per region is NOT checked - verify your card is offered there.
|
|
321
|
+
Training data is often large: if your dataset lives in another region, run data_locality_egress_check
|
|
322
|
+
before shipping the job to the greenest region - the data egress can outweigh the carbon saving."""
|
|
323
|
+
advice = await training_service.estimate_training_carbon(
|
|
324
|
+
_repo, gpu, gpu_count, gpu_hours, # type: ignore[arg-type]
|
|
325
|
+
region=region, provider=provider, utilization=utilization, # type: ignore[arg-type]
|
|
326
|
+
top_n_regions=top_n_regions,
|
|
327
|
+
)
|
|
328
|
+
return advice.model_dump()
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
@mcp.tool()
|
|
332
|
+
def explain_methodology_and_limits() -> dict:
|
|
333
|
+
"""GreenPick's assumptions and what it explicitly does NOT claim. Read this before trusting a number."""
|
|
334
|
+
return {
|
|
335
|
+
"what_it_is": "Directional, design-time guidance for choosing a greener cloud region.",
|
|
336
|
+
"what_it_is_not": "Audited carbon accounting, ESG/Scope-2 reporting, or a real-time scheduler.",
|
|
337
|
+
"carbon": "Grid carbon intensity by region, point-in-time. Not flow-traced consumption accounting.",
|
|
338
|
+
"cost": "Ballpark from a dated static table (compute + egress). Not a quote.",
|
|
339
|
+
"latency": "Great-circle geographic estimate, not a measured ping.",
|
|
340
|
+
"carbon_estimate": "Workload CO2 = documented power model (vCPU/RAM -> watts) x PUE x grid intensity.",
|
|
341
|
+
"instance_family": "Greener-family suggestion uses DIRECTIONAL perf-per-watt multipliers by generation/arch (ARM/Graviton-class = biggest win), not a benchmark of your workload.",
|
|
342
|
+
"data_locality": "Egress check leads with a DIRECTIONAL cost ballpark (flat per-GB, ignores free tiers/tiered pricing); transfer carbon uses a single kWh/GB coefficient whose published estimates span orders of magnitude — order-of-magnitude only.",
|
|
343
|
+
"training": "Training carbon uses published GPU TDP (peak, not your run's measured draw) x a utilization assumption + host overhead x PUE, then x the region's grid intensity. GPU availability per region is NOT checked. Ignores spot/on-demand, checkpointing, multi-node networking.",
|
|
344
|
+
"inference": "LLM inference carbon is keyed on model SIZE CLASS (not a named product) with a wide LOW-HIGH band (quantisation/batching/serving move the same model many-fold); output tokens drive it. Directional, most trustworthy for self-hosted/open models; NOT a named-model leaderboard.",
|
|
345
|
+
"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').",
|
|
346
|
+
"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.",
|
|
347
|
+
"ignored": "Embodied/hardware carbon and provider renewable-energy certificates.",
|
|
348
|
+
"data_sources": "Electricity Maps free tier (primary); ENTSO-E / EIA / NESO fallbacks; annual averages offline.",
|
|
349
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Carbon readings (via the repository) + a workload CO2 ESTIMATE.
|
|
2
|
+
|
|
3
|
+
The estimate turns a workload into average watts (power model) -> kWh/month ->
|
|
4
|
+
multiply by grid intensity. Clearly an estimate, not a measurement.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from ..models import CarbonReading, Workload, Provider
|
|
9
|
+
from ..data.repository import CarbonRepository
|
|
10
|
+
from ..data.static_data import power_model
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def carbon_now(repo: CarbonRepository, provider: Provider, region: str) -> CarbonReading:
|
|
14
|
+
return await repo.carbon_now(provider, region)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def workload_kwh_per_month(workload: Workload) -> float:
|
|
18
|
+
pm = power_model()
|
|
19
|
+
watts = workload.vcpu * pm["watts_per_vcpu"] + workload.ram_gb * pm["watts_per_gb_ram"]
|
|
20
|
+
watts *= pm["pue_default"]
|
|
21
|
+
kwh = watts / 1000.0 * workload.hours_per_month
|
|
22
|
+
return kwh
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def estimate_kg_co2_per_month(workload: Workload, reading: CarbonReading) -> float:
|
|
26
|
+
kwh = workload_kwh_per_month(workload)
|
|
27
|
+
grams = kwh * reading.g_co2_per_kwh
|
|
28
|
+
return round(grams / 1000.0, 2)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Ballpark monthly cost (compute + egress) from the dated static pricing table.
|
|
2
|
+
|
|
3
|
+
Deterministic. Returns None if the provider isn't in the table rather than guessing.
|
|
4
|
+
NOT a quote — see data/pricing.json caveats.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from ..models import Workload, Provider
|
|
9
|
+
from ..data.static_data import pricing
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def estimate_monthly_cost_usd(provider: Provider, workload: Workload) -> float | None:
|
|
13
|
+
table = pricing().get(provider)
|
|
14
|
+
if not table:
|
|
15
|
+
return None
|
|
16
|
+
compute = (
|
|
17
|
+
workload.vcpu * table["compute_usd_per_vcpu_hour"]
|
|
18
|
+
+ workload.ram_gb * table["compute_usd_per_gb_ram_hour"]
|
|
19
|
+
) * workload.hours_per_month
|
|
20
|
+
egress = workload.est_monthly_egress_gb * table["egress_usd_per_gb"]
|
|
21
|
+
return round(compute + egress, 2)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Forward-looking carbon-aware scheduling: which UPCOMING hours will be cleanest?
|
|
2
|
+
|
|
3
|
+
Turns scheduling from reactive (recent history) to planned (day-ahead). The honest signal — per
|
|
4
|
+
the data spike — is the ENTSO-E day-ahead wind+solar forecast (A69), reliable across EU zones.
|
|
5
|
+
We rank upcoming hours by forecast renewable output: more wind+solar = cleaner hour. This is a
|
|
6
|
+
RELATIVE ranking, not a gCO2/kWh forecast (the free data can't support absolute intensity ahead).
|
|
7
|
+
|
|
8
|
+
Degrade path: no forecast for the region (US today, or a zone ENTSO-E has no A69 for) -> fall back
|
|
9
|
+
to the recent historical daily pattern we already compute, relabelled 'historical' so the user
|
|
10
|
+
knows it's backward-looking, not a true forecast.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
from ..models import ForecastAdvice, ForecastHour, Provider
|
|
17
|
+
from ..data.repository import CarbonRepository
|
|
18
|
+
from . import scheduling_service
|
|
19
|
+
|
|
20
|
+
LIMITATIONS = (
|
|
21
|
+
"RELATIVE forecast: upcoming hours ranked by day-ahead wind+solar output (more renewables = "
|
|
22
|
+
"cleaner), NOT an absolute gCO2/kWh forecast — the free day-ahead data has no forecast fuel mix "
|
|
23
|
+
"across all zones. EU only (ENTSO-E A69); US and uncovered zones degrade to the recent historical "
|
|
24
|
+
"pattern (labelled 'historical'). Directional; forecasts shift with weather."
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def carbon_forecast(
|
|
29
|
+
repo: CarbonRepository,
|
|
30
|
+
provider: Provider,
|
|
31
|
+
region: str,
|
|
32
|
+
hours_ahead: int = 24,
|
|
33
|
+
top_n: int = 5,
|
|
34
|
+
) -> ForecastAdvice:
|
|
35
|
+
forecast: list[ForecastHour] = []
|
|
36
|
+
getter = getattr(repo, "renewable_forecast", None)
|
|
37
|
+
if getter is not None:
|
|
38
|
+
forecast = await getter(provider, region, hours_ahead)
|
|
39
|
+
|
|
40
|
+
if forecast:
|
|
41
|
+
# Cleaner = MORE forecast renewable output. Rank descending by renewable_mw (or share if
|
|
42
|
+
# every hour has it, which is a better normaliser).
|
|
43
|
+
use_share = all(h.renewable_share is not None for h in forecast)
|
|
44
|
+
key = (lambda h: h.renewable_share) if use_share else (lambda h: h.renewable_mw)
|
|
45
|
+
by_clean = sorted(forecast, key=key, reverse=True) # type: ignore[arg-type,return-value]
|
|
46
|
+
peak = by_clean[0]
|
|
47
|
+
low = by_clean[-1]
|
|
48
|
+
metric = "renewable share" if use_share else "forecast wind+solar"
|
|
49
|
+
note = (f"Over the next ~{hours_ahead}h, the grid in {provider}/{region} is forecast "
|
|
50
|
+
f"cleanest around {peak.period} (highest {metric}) and dirtiest around "
|
|
51
|
+
f"{low.period}. Shift deferrable work toward the cleanest upcoming hours.")
|
|
52
|
+
return ForecastAdvice(
|
|
53
|
+
region=region, provider=provider, source="entsoe_forecast", quality="forecast",
|
|
54
|
+
hours_ahead=hours_ahead, cleanest_upcoming=by_clean[:max(1, top_n)],
|
|
55
|
+
peak_renewable_period=peak.period, low_renewable_period=low.period,
|
|
56
|
+
note=note, limitations=LIMITATIONS,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# --- Degrade: no forward forecast for this region -> recent historical daily pattern ---
|
|
60
|
+
pattern = await scheduling_service.cleanest_time_of_day(repo, provider, region)
|
|
61
|
+
note = ("No day-ahead forecast available for this region, so this is the recent HISTORICAL "
|
|
62
|
+
"pattern, not a true forecast. ")
|
|
63
|
+
if pattern.cleanest_hour_of_day is not None:
|
|
64
|
+
note += (f"Over the last ~{pattern.days_analyzed} day(s) it's been cleanest around "
|
|
65
|
+
f"{pattern.cleanest_hour_of_day:02d}:00 - a reasonable default for a recurring job.")
|
|
66
|
+
else:
|
|
67
|
+
note += "No usable recent history either."
|
|
68
|
+
return ForecastAdvice(
|
|
69
|
+
region=region, provider=provider, source="historical_fallback", quality="historical",
|
|
70
|
+
hours_ahead=hours_ahead, cleanest_upcoming=[],
|
|
71
|
+
note=note, limitations=LIMITATIONS,
|
|
72
|
+
)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""LLM inference carbon — the inference-side sibling of estimate_training_carbon.
|
|
2
|
+
|
|
3
|
+
Most of an LLM's lifetime carbon is INFERENCE (serving queries), not training. A developer building
|
|
4
|
+
on an LLM makes a carbon choice constantly and invisibly: which model (size), how many requests, how
|
|
5
|
+
many output tokens, where it runs. This estimates the energy + CO2 of that serving workload.
|
|
6
|
+
|
|
7
|
+
Honest scoping (per the data spike):
|
|
8
|
+
- Keyed on model SIZE CLASS, not named commercial products (vendors don't disclose serving hardware).
|
|
9
|
+
- OUTPUT tokens drive the estimate (generation dominates inference energy).
|
|
10
|
+
- Reported as a LOW-HIGH band, because quantisation/batching/serving move the same model many-fold.
|
|
11
|
+
- Carbon = energy x the region's live grid intensity (reuses the carbon repository), when a region
|
|
12
|
+
is given. Directional; most trustworthy for self-hosted/open models.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
from ..models import Provider, InferenceCarbonAdvice
|
|
19
|
+
from ..data.repository import CarbonRepository
|
|
20
|
+
from ..data.static_data import inference_energy, resolve_size_class
|
|
21
|
+
from . import carbon_service
|
|
22
|
+
|
|
23
|
+
LIMITATIONS = (
|
|
24
|
+
"DIRECTIONAL estimate, not a measurement. Keyed on model SIZE CLASS (not a named product — "
|
|
25
|
+
"vendors don't publish serving hardware/batching). The LOW-HIGH band is wide on purpose: "
|
|
26
|
+
"quantisation, batching, and request shaping move the same model's energy many-fold. Output "
|
|
27
|
+
"tokens drive it; input tokens are ignored. MoE models use the 'moe' class (they run near their "
|
|
28
|
+
"ACTIVE param count, not total). Most trustworthy for self-hosted/open models; a hosted API's "
|
|
29
|
+
"real figure depends on the provider's undisclosed setup. Not for ranking named commercial models."
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def estimate_inference_carbon(
|
|
34
|
+
repo: CarbonRepository,
|
|
35
|
+
size_class: str,
|
|
36
|
+
monthly_requests: float,
|
|
37
|
+
avg_output_tokens: int = 300,
|
|
38
|
+
region: Optional[str] = None,
|
|
39
|
+
provider: Optional[Provider] = None,
|
|
40
|
+
) -> InferenceCarbonAdvice:
|
|
41
|
+
resolved = resolve_size_class(size_class)
|
|
42
|
+
if resolved is None:
|
|
43
|
+
classes = ", ".join(inference_energy().get("size_classes", {}).keys())
|
|
44
|
+
return InferenceCarbonAdvice(
|
|
45
|
+
size_class=size_class, size_class_label=size_class,
|
|
46
|
+
monthly_requests=monthly_requests, avg_output_tokens=avg_output_tokens,
|
|
47
|
+
monthly_output_tokens=0, wh_per_1k_output_low=0, wh_per_1k_output_high=0,
|
|
48
|
+
monthly_kwh_low=0, monthly_kwh_high=0,
|
|
49
|
+
rationale=(f"Unknown size class '{size_class}'. Use one of: {classes} "
|
|
50
|
+
f"(or a param-count alias like 8b / 70b / moe)."),
|
|
51
|
+
limitations=LIMITATIONS,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
cls_key, spec = resolved
|
|
55
|
+
lo = spec["wh_per_1k_output_tokens_low"]
|
|
56
|
+
hi = spec["wh_per_1k_output_tokens_high"]
|
|
57
|
+
|
|
58
|
+
monthly_output_tokens = monthly_requests * avg_output_tokens
|
|
59
|
+
# Wh = (Wh per 1k output tokens) x (output tokens / 1000); kWh = Wh / 1000.
|
|
60
|
+
kwh_low = round(lo * monthly_output_tokens / 1000.0 / 1000.0, 3)
|
|
61
|
+
kwh_high = round(hi * monthly_output_tokens / 1000.0 / 1000.0, 3)
|
|
62
|
+
|
|
63
|
+
advice = InferenceCarbonAdvice(
|
|
64
|
+
size_class=cls_key, size_class_label=spec["label"],
|
|
65
|
+
monthly_requests=monthly_requests, avg_output_tokens=avg_output_tokens,
|
|
66
|
+
monthly_output_tokens=monthly_output_tokens,
|
|
67
|
+
wh_per_1k_output_low=lo, wh_per_1k_output_high=hi,
|
|
68
|
+
monthly_kwh_low=kwh_low, monthly_kwh_high=kwh_high,
|
|
69
|
+
limitations=LIMITATIONS,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
carbon_txt = ""
|
|
73
|
+
if region is not None:
|
|
74
|
+
prov = provider or _guess_provider(region)
|
|
75
|
+
if prov is not None:
|
|
76
|
+
reading = await carbon_service.carbon_now(repo, prov, region) # type: ignore[arg-type]
|
|
77
|
+
g = reading.g_co2_per_kwh
|
|
78
|
+
advice.region = region
|
|
79
|
+
advice.grid_intensity_g_co2_per_kwh = g
|
|
80
|
+
advice.carbon_source = reading.source
|
|
81
|
+
advice.carbon_quality = reading.quality
|
|
82
|
+
advice.monthly_kg_co2_low = round(kwh_low * g / 1000.0, 2)
|
|
83
|
+
advice.monthly_kg_co2_high = round(kwh_high * g / 1000.0, 2)
|
|
84
|
+
carbon_txt = (f" In {prov}/{region} ({g:.0f} gCO2/kWh, {reading.source}) that's "
|
|
85
|
+
f"~{advice.monthly_kg_co2_low:.1f}-{advice.monthly_kg_co2_high:.1f} kg CO2/mo.")
|
|
86
|
+
|
|
87
|
+
advice.rationale = (
|
|
88
|
+
f"{spec['label']}: ~{monthly_requests:,.0f} requests/mo x ~{avg_output_tokens} output tokens "
|
|
89
|
+
f"= ~{monthly_output_tokens/1e6:.1f}M output tokens -> ~{kwh_low:,.1f}-{kwh_high:,.1f} kWh/mo "
|
|
90
|
+
f"(band reflects serving choices: quantisation/batching/shaping).{carbon_txt} A smaller size "
|
|
91
|
+
f"class is the biggest lever - energy scales steeply with model size.")
|
|
92
|
+
return advice
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _guess_provider(region: str) -> Optional[str]:
|
|
96
|
+
from ..data.static_data import iter_regions
|
|
97
|
+
for prov, reg, _meta in iter_regions():
|
|
98
|
+
if reg == region:
|
|
99
|
+
return prov
|
|
100
|
+
return None
|