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
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Greener instance-family suggestion.
|
|
2
|
+
|
|
3
|
+
The honest carbon mechanism here is performance-per-watt: a newer-generation or ARM
|
|
4
|
+
(Graviton-class) family does the SAME work on less energy, so for a fixed grid intensity it
|
|
5
|
+
emits less CO2. We take the user's current general-purpose family, look up directional
|
|
6
|
+
energy multipliers for the same-size families in that cloud, and rank the greener options —
|
|
7
|
+
reporting the energy saving, the CO2 estimate (energy x the region's live grid intensity),
|
|
8
|
+
and a relative R/Y/G rating.
|
|
9
|
+
|
|
10
|
+
Deliberately NOT a pricing engine: we don't claim per-family prices we don't have. Cost is
|
|
11
|
+
left to the region/cost tools; this tool's axis is energy -> carbon.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
from ..models import (
|
|
18
|
+
Workload, Provider, CarbonReading,
|
|
19
|
+
GreenerComputeAdvice, FamilyOption,
|
|
20
|
+
)
|
|
21
|
+
from ..data.repository import CarbonRepository
|
|
22
|
+
from ..data.static_data import power_model, instance_families
|
|
23
|
+
from . import carbon_service
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
SUPPORTED_PROVIDERS = ("aws", "gcp", "azure")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def is_supported_provider(provider: str) -> bool:
|
|
30
|
+
return provider in SUPPORTED_PROVIDERS
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _families_for(provider: str) -> list[dict]:
|
|
34
|
+
data = instance_families().get(provider, {})
|
|
35
|
+
return list(data.get("_general_purpose_generations", []))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _base_kwh_per_month(workload: Workload) -> float:
|
|
39
|
+
"""Energy for the baseline (relative_energy = 1.00) family — same power model the
|
|
40
|
+
carbon estimate uses. Each family then scales this by its relative_energy."""
|
|
41
|
+
pm = power_model()
|
|
42
|
+
watts = (workload.vcpu * pm["watts_per_vcpu"] + workload.ram_gb * pm["watts_per_gb_ram"])
|
|
43
|
+
watts *= pm["pue_default"]
|
|
44
|
+
return watts / 1000.0 * workload.hours_per_month
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _rate(options: list[FamilyOption]) -> None:
|
|
48
|
+
"""Assign relative R/Y/G by energy terciles across the option set (greenest = green)."""
|
|
49
|
+
if not options:
|
|
50
|
+
return
|
|
51
|
+
ordered = sorted(options, key=lambda o: o.relative_energy)
|
|
52
|
+
n = len(ordered)
|
|
53
|
+
for i, opt in enumerate(ordered):
|
|
54
|
+
if n <= 2:
|
|
55
|
+
opt.rating = "green" if i == 0 else ("red" if i == n - 1 else "yellow")
|
|
56
|
+
else:
|
|
57
|
+
third = n / 3.0
|
|
58
|
+
opt.rating = "green" if i < third else ("red" if i >= 2 * third else "yellow")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def greener_instance_family(
|
|
62
|
+
repo: CarbonRepository,
|
|
63
|
+
provider: Provider,
|
|
64
|
+
region: str,
|
|
65
|
+
vcpu: float,
|
|
66
|
+
ram_gb: float,
|
|
67
|
+
current_family: Optional[str] = None,
|
|
68
|
+
hours_per_month: float = 730,
|
|
69
|
+
) -> GreenerComputeAdvice:
|
|
70
|
+
families = _families_for(provider)
|
|
71
|
+
if not families:
|
|
72
|
+
return GreenerComputeAdvice(
|
|
73
|
+
provider=provider, region=region, current_family=current_family,
|
|
74
|
+
rationale=f"No instance-family efficiency data for provider '{provider}'.",
|
|
75
|
+
limitations="Supported providers: aws, gcp, azure (general-purpose families only).",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
reading: CarbonReading = await carbon_service.carbon_now(repo, provider, region)
|
|
79
|
+
wl = Workload(vcpu=vcpu, ram_gb=ram_gb, hours_per_month=hours_per_month)
|
|
80
|
+
base_kwh = _base_kwh_per_month(wl)
|
|
81
|
+
|
|
82
|
+
# Resolve the current family (case-insensitive); if unknown/omitted, compare against the
|
|
83
|
+
# LEAST efficient family so savings are framed conservatively ("at least this much").
|
|
84
|
+
by_name = {f["family"].lower(): f for f in families}
|
|
85
|
+
current = by_name.get((current_family or "").lower())
|
|
86
|
+
current_rel = current["relative_energy"] if current else max(f["relative_energy"] for f in families)
|
|
87
|
+
current_resolved = current["family"] if current else None
|
|
88
|
+
|
|
89
|
+
options: list[FamilyOption] = []
|
|
90
|
+
for f in families:
|
|
91
|
+
rel = f["relative_energy"]
|
|
92
|
+
kwh = base_kwh * rel
|
|
93
|
+
kg = round(kwh * reading.g_co2_per_kwh / 1000.0, 2)
|
|
94
|
+
energy_saving = round((current_rel - rel) / current_rel * 100, 1) if current_rel > 0 else None
|
|
95
|
+
co2_saving = energy_saving # same grid intensity, so CO2 scales with energy
|
|
96
|
+
options.append(FamilyOption(
|
|
97
|
+
family=f["family"], arch=f["arch"], cpu=f["cpu"],
|
|
98
|
+
relative_energy=rel,
|
|
99
|
+
energy_saving_vs_current_pct=energy_saving,
|
|
100
|
+
est_kg_co2_per_month=kg,
|
|
101
|
+
co2_saving_vs_current_pct=co2_saving,
|
|
102
|
+
note=f.get("notes", ""),
|
|
103
|
+
))
|
|
104
|
+
|
|
105
|
+
options.sort(key=lambda o: o.relative_energy) # greenest first
|
|
106
|
+
_rate(options)
|
|
107
|
+
greenest = options[0]
|
|
108
|
+
|
|
109
|
+
# Rationale: honest about the mechanism and the ARM caveat.
|
|
110
|
+
if current_resolved and greenest.family.lower() == current_resolved.lower():
|
|
111
|
+
rationale = (f"You're already on {current_resolved}, the greenest general-purpose family "
|
|
112
|
+
f"available here. No change needed.")
|
|
113
|
+
else:
|
|
114
|
+
saving = greenest.energy_saving_vs_current_pct
|
|
115
|
+
arm_note = (" It's an ARM (Graviton-class) family, so this assumes your workload can run "
|
|
116
|
+
"on ARM - a recompile/container rebuild may be needed."
|
|
117
|
+
if greenest.arch == "arm" else "")
|
|
118
|
+
base = (f"vs {current_resolved}" if current_resolved
|
|
119
|
+
else "vs the least-efficient family here")
|
|
120
|
+
rationale = (f"{greenest.family} ({greenest.cpu}) does the same work on "
|
|
121
|
+
f"~{saving:.0f}% less energy {base}, so ~{saving:.0f}% less CO2 at this "
|
|
122
|
+
f"region's grid intensity.{arm_note}")
|
|
123
|
+
|
|
124
|
+
return GreenerComputeAdvice(
|
|
125
|
+
provider=provider, region=region, current_family=current_resolved,
|
|
126
|
+
grid_intensity_g_co2_per_kwh=reading.g_co2_per_kwh,
|
|
127
|
+
carbon_source=reading.source, carbon_quality=reading.quality,
|
|
128
|
+
options=options, recommended_family=greenest.family, rationale=rationale,
|
|
129
|
+
limitations=("Energy multipliers are DIRECTIONAL (perf-per-watt by generation/arch), "
|
|
130
|
+
"not measured for your workload; general-purpose families only; embodied "
|
|
131
|
+
"hardware carbon ignored. ARM options require ARM-compatible builds."),
|
|
132
|
+
)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Geographic latency estimate: great-circle distance -> rough round-trip ms.
|
|
2
|
+
|
|
3
|
+
A deliberate approximation (speed of light in fiber + routing overhead), good enough
|
|
4
|
+
for "is this region far from my users?" Not a measured ping.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import math
|
|
9
|
+
from ..models import GeoPoint, Provider
|
|
10
|
+
from ..data.static_data import region_meta
|
|
11
|
+
|
|
12
|
+
# Rough rule of thumb: ~fiber latency plus routing/queuing overhead.
|
|
13
|
+
# ~0.0107 ms per km one-way for light in fiber; double for round trip; add a fixed overhead.
|
|
14
|
+
_MS_PER_KM_RTT = 0.0107 * 2 * 1.4 # 1.4 fudge for non-great-circle routing
|
|
15
|
+
_FIXED_OVERHEAD_MS = 10.0
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _haversine_km(a: GeoPoint, b: GeoPoint) -> float:
|
|
19
|
+
r = 6371.0
|
|
20
|
+
dlat = math.radians(b.lat - a.lat)
|
|
21
|
+
dlon = math.radians(b.lon - a.lon)
|
|
22
|
+
h = (math.sin(dlat / 2) ** 2
|
|
23
|
+
+ math.cos(math.radians(a.lat)) * math.cos(math.radians(b.lat)) * math.sin(dlon / 2) ** 2)
|
|
24
|
+
return 2 * r * math.asin(math.sqrt(h))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def estimate_latency_ms(provider: Provider, region: str, user: GeoPoint) -> float | None:
|
|
28
|
+
meta = region_meta(provider, region)
|
|
29
|
+
if not meta:
|
|
30
|
+
return None
|
|
31
|
+
region_pt = GeoPoint(lat=meta["lat"], lon=meta["lon"])
|
|
32
|
+
km = _haversine_km(user, region_pt)
|
|
33
|
+
return round(_FIXED_OVERHEAD_MS + km * _MS_PER_KM_RTT, 1)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Data-locality / egress warning.
|
|
2
|
+
|
|
3
|
+
The trap this guards against: you move COMPUTE to a greener (often farther) region, but your
|
|
4
|
+
DATA stays put — a database, object store, or another service. Every byte crossing that
|
|
5
|
+
boundary is billed as egress and burns extra network energy. A naive "greener region" pick can
|
|
6
|
+
be eaten alive by egress cost, and sometimes by the transfer carbon too.
|
|
7
|
+
|
|
8
|
+
This tool is explicit by design: the caller tells us where compute runs, where the data lives,
|
|
9
|
+
and how much crosses per month. We classify the separation, give a DIRECTIONAL egress-cost
|
|
10
|
+
ballpark (the decision-relevant number), a deliberately-coarse transfer-carbon figure, and — if
|
|
11
|
+
the caller supplies the carbon saving the move buys — a net verdict on whether the move still
|
|
12
|
+
pays off.
|
|
13
|
+
|
|
14
|
+
Honesty posture: egress COST leads (it's concrete, if approximate). Transfer CARBON is secondary
|
|
15
|
+
and loudly flagged as order-of-magnitude, because the science on kWh/GB is unsettled.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
from ..models import DataLocalityWarning, Provider, CarbonReading
|
|
22
|
+
from ..data.repository import CarbonRepository
|
|
23
|
+
from ..data.static_data import pricing, network_model
|
|
24
|
+
from . import carbon_service, residency_service
|
|
25
|
+
|
|
26
|
+
LIMITATIONS = (
|
|
27
|
+
"Egress cost is DIRECTIONAL: a LOW-HIGH range for the locality tier (same-region / cross-region / "
|
|
28
|
+
"cross-cloud) from dated list prices — it ignores free-tier allowances, committed-use/negotiated "
|
|
29
|
+
"discounts, exact region-pair rates, and NAT/load-balancer fees, so treat it as a ballpark, not a "
|
|
30
|
+
"quote. Transfer carbon uses a single kWh/GB coefficient whose published estimates vary by orders "
|
|
31
|
+
"of magnitude; it's order-of-magnitude only. You supply where data lives and how much crosses — "
|
|
32
|
+
"GreenPick can't infer your architecture."
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _classify(compute_provider: str, compute_region: str,
|
|
37
|
+
data_provider: str, data_region: str) -> tuple[str, str]:
|
|
38
|
+
"""Return (locality, severity). Severity is the structural separation, before volume."""
|
|
39
|
+
if compute_provider == data_provider and compute_region == data_region:
|
|
40
|
+
return "same_region", "none"
|
|
41
|
+
if compute_provider == data_provider:
|
|
42
|
+
return "cross_region_same_cloud", "medium"
|
|
43
|
+
return "cross_cloud", "high"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _bump_severity_for_volume(severity: str, gb: float) -> str:
|
|
47
|
+
"""A tiny cross-boundary trickle barely matters; a firehose matters a lot. Nudge severity
|
|
48
|
+
by monthly volume so the warning tracks real exposure, not just topology."""
|
|
49
|
+
if severity == "none":
|
|
50
|
+
return "none"
|
|
51
|
+
order = ["low", "medium", "high"]
|
|
52
|
+
base = {"low": 0, "medium": 1, "high": 2}[severity]
|
|
53
|
+
if gb >= 1000:
|
|
54
|
+
base = min(2, base + 1)
|
|
55
|
+
elif gb < 50:
|
|
56
|
+
base = max(0, base - 1)
|
|
57
|
+
return order[base]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def data_locality_check(
|
|
61
|
+
repo: CarbonRepository,
|
|
62
|
+
compute_provider: Provider,
|
|
63
|
+
compute_region: str,
|
|
64
|
+
data_provider: Provider,
|
|
65
|
+
data_region: str,
|
|
66
|
+
monthly_cross_boundary_gb: float,
|
|
67
|
+
carbon_saving_kg_co2_from_move: Optional[float] = None,
|
|
68
|
+
) -> DataLocalityWarning:
|
|
69
|
+
compute_loc = f"{compute_provider}/{compute_region}"
|
|
70
|
+
data_loc = f"{data_provider}/{data_region}"
|
|
71
|
+
locality, severity = _classify(compute_provider, compute_region, data_provider, data_region)
|
|
72
|
+
|
|
73
|
+
if locality == "same_region":
|
|
74
|
+
return DataLocalityWarning(
|
|
75
|
+
compute_location=compute_loc, data_location=data_loc,
|
|
76
|
+
monthly_cross_boundary_gb=monthly_cross_boundary_gb,
|
|
77
|
+
locality=locality, severity="none",
|
|
78
|
+
egress_rate_usd_per_gb_low=0.0, egress_rate_usd_per_gb_high=0.0,
|
|
79
|
+
est_monthly_egress_usd_low=0.0, est_monthly_egress_usd_high=0.0,
|
|
80
|
+
est_monthly_transfer_kg_co2=0.0,
|
|
81
|
+
warning="Compute and data are co-located — no cross-boundary egress for this flow.",
|
|
82
|
+
recommendation="No locality penalty. Choose the region on carbon/latency/cost as usual.",
|
|
83
|
+
limitations=LIMITATIONS,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
severity = _bump_severity_for_volume(severity, monthly_cross_boundary_gb)
|
|
87
|
+
|
|
88
|
+
# Egress is a TIER LADDER by where the bytes cross — not one flat rate. Price the tier we just
|
|
89
|
+
# classified, as a LOW-HIGH range (region-pair rates vary). For cross-cloud/internet the rate
|
|
90
|
+
# is provider-specific, so anchor the high end to the data provider's own internet rate.
|
|
91
|
+
tiers = pricing().get("_egress_tiers_usd_per_gb", {})
|
|
92
|
+
tier = tiers.get(locality, {})
|
|
93
|
+
rate_low = tier.get("low")
|
|
94
|
+
rate_high = tier.get("high")
|
|
95
|
+
if locality == "cross_cloud":
|
|
96
|
+
prov_internet = (pricing().get(data_provider) or {}).get("egress_usd_per_gb")
|
|
97
|
+
if prov_internet is not None and rate_high is not None:
|
|
98
|
+
rate_high = max(rate_high, prov_internet) # don't under-quote this provider's internet rate
|
|
99
|
+
cost_low = round(monthly_cross_boundary_gb * rate_low, 2) if rate_low is not None else None
|
|
100
|
+
cost_high = round(monthly_cross_boundary_gb * rate_high, 2) if rate_high is not None else None
|
|
101
|
+
|
|
102
|
+
# Transfer carbon: energy to move the bytes x the grid intensity where that energy is drawn.
|
|
103
|
+
# Use the data region's grid as the representative intensity (coarse — energy is spread along
|
|
104
|
+
# the path, but this keeps it tied to a real, live number).
|
|
105
|
+
kwh_per_gb = network_model()["kwh_per_gb_transfer"]
|
|
106
|
+
reading: CarbonReading = await carbon_service.carbon_now(repo, data_provider, data_region)
|
|
107
|
+
transfer_kwh = monthly_cross_boundary_gb * kwh_per_gb
|
|
108
|
+
transfer_kg = round(transfer_kwh * reading.g_co2_per_kwh / 1000.0, 2)
|
|
109
|
+
|
|
110
|
+
# Net verdict only if the caller told us what the move saves.
|
|
111
|
+
verdict = None
|
|
112
|
+
if carbon_saving_kg_co2_from_move is not None:
|
|
113
|
+
net = carbon_saving_kg_co2_from_move - transfer_kg
|
|
114
|
+
if net > 0:
|
|
115
|
+
verdict = (f"Still a net win: the move saves ~{carbon_saving_kg_co2_from_move:.1f} kg "
|
|
116
|
+
f"CO2/mo, the extra transfer costs ~{transfer_kg:.1f} kg, net "
|
|
117
|
+
f"~{net:.1f} kg saved. (Egress COST may still argue against it.)")
|
|
118
|
+
else:
|
|
119
|
+
verdict = (f"Backfires on carbon: the move saves ~{carbon_saving_kg_co2_from_move:.1f} kg "
|
|
120
|
+
f"CO2/mo but the extra transfer adds ~{transfer_kg:.1f} kg - net "
|
|
121
|
+
f"~{abs(net):.1f} kg WORSE. Keep compute near the data, or move the data too.")
|
|
122
|
+
|
|
123
|
+
sep = ("across clouds" if locality == "cross_cloud" else "across regions in the same cloud")
|
|
124
|
+
if cost_low is not None and cost_high is not None:
|
|
125
|
+
cost_txt = (f"roughly ${cost_low:,.0f}-${cost_high:,.0f}/mo in egress "
|
|
126
|
+
f"(~${rate_low:.2f}-${rate_high:.2f}/GB for this tier)")
|
|
127
|
+
else:
|
|
128
|
+
cost_txt = "an egress cost (rate unknown for this tier)"
|
|
129
|
+
warning = (f"Compute and data are separated {sep}: ~{monthly_cross_boundary_gb:,.0f} GB/mo would "
|
|
130
|
+
f"cross the boundary, costing {cost_txt}, plus ~{transfer_kg:.1f} kg CO2/mo of extra "
|
|
131
|
+
f"transfer. A greener-region move can be undone by this.")
|
|
132
|
+
|
|
133
|
+
if severity == "high":
|
|
134
|
+
rec = ("High exposure. Co-locate compute with the data, or move the data to the greener "
|
|
135
|
+
"region too. If you can't, confirm the carbon/cost saving beats this egress penalty "
|
|
136
|
+
"before moving compute alone.")
|
|
137
|
+
elif severity == "medium":
|
|
138
|
+
rec = ("Moderate exposure. Weigh the egress cost against the region's carbon/cost saving; "
|
|
139
|
+
"co-locating is usually cleaner and cheaper.")
|
|
140
|
+
else:
|
|
141
|
+
rec = ("Low exposure at this volume. The locality penalty is small — the region's "
|
|
142
|
+
"carbon/latency/cost tradeoff probably dominates.")
|
|
143
|
+
|
|
144
|
+
# Residency heads-up: compute processing data that lives in another jurisdiction is exactly the
|
|
145
|
+
# residency-sensitive case. Flag the data-region -> compute-region crossing (neutral, not legal).
|
|
146
|
+
residency_note = residency_service.residency_flag(
|
|
147
|
+
data_provider, data_region, compute_provider, compute_region,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
return DataLocalityWarning(
|
|
151
|
+
compute_location=compute_loc, data_location=data_loc,
|
|
152
|
+
monthly_cross_boundary_gb=monthly_cross_boundary_gb,
|
|
153
|
+
locality=locality, severity=severity,
|
|
154
|
+
egress_rate_usd_per_gb_low=rate_low, egress_rate_usd_per_gb_high=rate_high,
|
|
155
|
+
est_monthly_egress_usd_low=cost_low, est_monthly_egress_usd_high=cost_high,
|
|
156
|
+
est_monthly_transfer_kg_co2=transfer_kg,
|
|
157
|
+
carbon_saving_kg_co2_from_move=carbon_saving_kg_co2_from_move,
|
|
158
|
+
net_carbon_verdict=verdict, residency_note=residency_note,
|
|
159
|
+
warning=warning, recommendation=rec, limitations=LIMITATIONS,
|
|
160
|
+
)
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""carbon_metric_scaffold: emit paste-in instrumentation so an app measures its OWN estimated carbon.
|
|
2
|
+
|
|
3
|
+
GreenPick does not run or measure anything here. Software can't read its own wattage, so the app
|
|
4
|
+
measures the cheap proxies it CAN observe (CPU utilisation, provisioned vCPU/RAM, uptime) and
|
|
5
|
+
applies GreenPick's power model x the region's grid intensity to emit an ESTIMATED gCO2 metric.
|
|
6
|
+
GreenPick's job is to supply (a) the code and (b) the coefficients that make the number meaningful.
|
|
7
|
+
|
|
8
|
+
Hard guardrails baked into the output:
|
|
9
|
+
- The metric is self-labelled `estimated_gco2*` with a comment stating it's directional, model-based,
|
|
10
|
+
NOT metered energy — because it will live in production dashboards and get quoted.
|
|
11
|
+
- v1 is SELF-tracking (watch your own app over time/version/region/load), NOT cross-app ranking,
|
|
12
|
+
which would need a shared spec to be anything but noise.
|
|
13
|
+
- Deliverable is a snippet + coefficients + methodology note, NOT a framework/SDK.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
from ..models import CarbonMetricScaffold, Provider
|
|
20
|
+
from ..data.repository import CarbonRepository
|
|
21
|
+
from ..data.static_data import power_model
|
|
22
|
+
from . import carbon_service
|
|
23
|
+
|
|
24
|
+
SUPPORTED_LANGUAGES = ("python",)
|
|
25
|
+
|
|
26
|
+
METHODOLOGY = (
|
|
27
|
+
"estimated_gco2_per_hour = ((vcpu * watts_per_vcpu * cpu_utilisation) + (ram_gb * watts_per_gb_ram)) "
|
|
28
|
+
"* pue / 1000 * grid_g_co2_per_kwh. Proxies (CPU util, vCPU, RAM, uptime) are what an app can "
|
|
29
|
+
"actually observe; the watts/PUE come from GreenPick's documented power model; grid intensity from "
|
|
30
|
+
"the region. It is an ESTIMATE, not a measurement of real energy drawn."
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
LIMITATIONS = (
|
|
34
|
+
"DIRECTIONAL estimate, not metered energy — software cannot read its true wattage, so this models "
|
|
35
|
+
"it from CPU utilisation and a published power model. Ignores GPU/accelerator, disk/network, "
|
|
36
|
+
"embodied carbon, and real hardware variance. If you hardcode a grid intensity it goes stale — "
|
|
37
|
+
"refresh it (or call region_carbon_now) periodically. Use for SELF-tracking over time, NOT to rank "
|
|
38
|
+
"against other apps (different boundaries/coefficients make that meaningless)."
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _python_snippet(vcpu_default: float, ram_default: float, pm: dict,
|
|
43
|
+
grid: Optional[float], region: Optional[str]) -> str:
|
|
44
|
+
watts_vcpu = pm["watts_per_vcpu"]
|
|
45
|
+
watts_ram = pm["watts_per_gb_ram"]
|
|
46
|
+
pue = pm["pue_default"]
|
|
47
|
+
grid_literal = repr(grid) if grid is not None else "None"
|
|
48
|
+
region_comment = f" # region: {region}" if region else ""
|
|
49
|
+
return f'''# --- GreenPick carbon metric (PASTE INTO YOUR APP) ---------------------------------
|
|
50
|
+
# estimated_gco2 is a DIRECTIONAL ESTIMATE from CPU utilisation x a documented power model
|
|
51
|
+
# x grid carbon intensity. It is NOT a measurement of real energy drawn. Use it to watch THIS
|
|
52
|
+
# app's footprint over time/versions/regions - not to compare against other apps.
|
|
53
|
+
# Model + coefficients: GreenPick (power.json). Methodology in the accompanying note.
|
|
54
|
+
import time
|
|
55
|
+
|
|
56
|
+
# --- Coefficients from GreenPick's power model (safe to keep; refresh grid intensity periodically)
|
|
57
|
+
_WATTS_PER_VCPU = {watts_vcpu}
|
|
58
|
+
_WATTS_PER_GB_RAM = {watts_ram}
|
|
59
|
+
_PUE = {pue}
|
|
60
|
+
_GRID_G_CO2_PER_KWH = {grid_literal}{region_comment} # set/refresh for YOUR region; see region_carbon_now
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class CarbonEstimator:
|
|
64
|
+
"""Estimates this process's grid carbon from observable proxies. Directional, not metered."""
|
|
65
|
+
|
|
66
|
+
def __init__(self, vcpu: float = {vcpu_default}, ram_gb: float = {ram_default},
|
|
67
|
+
grid_g_co2_per_kwh: float | None = _GRID_G_CO2_PER_KWH):
|
|
68
|
+
self.vcpu = vcpu
|
|
69
|
+
self.ram_gb = ram_gb
|
|
70
|
+
self.grid = grid_g_co2_per_kwh
|
|
71
|
+
self._start = time.time()
|
|
72
|
+
|
|
73
|
+
def estimated_watts(self, cpu_utilisation: float) -> float:
|
|
74
|
+
"""cpu_utilisation in 0..1 (e.g. psutil.cpu_percent()/100)."""
|
|
75
|
+
u = max(0.0, min(1.0, cpu_utilisation))
|
|
76
|
+
return (self.vcpu * _WATTS_PER_VCPU * u + self.ram_gb * _WATTS_PER_GB_RAM) * _PUE
|
|
77
|
+
|
|
78
|
+
def estimated_gco2_per_hour(self, cpu_utilisation: float) -> float | None:
|
|
79
|
+
if self.grid is None:
|
|
80
|
+
return None # no grid intensity set -> can't estimate carbon honestly
|
|
81
|
+
return self.estimated_watts(cpu_utilisation) / 1000.0 * self.grid
|
|
82
|
+
|
|
83
|
+
def estimated_gco2_since_start(self, avg_cpu_utilisation: float) -> float | None:
|
|
84
|
+
rate = self.estimated_gco2_per_hour(avg_cpu_utilisation)
|
|
85
|
+
if rate is None:
|
|
86
|
+
return None
|
|
87
|
+
hours = (time.time() - self._start) / 3600.0
|
|
88
|
+
return rate * hours
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# Example: expose as a metric (Prometheus-style gauge, a log line, or a /metrics field).
|
|
92
|
+
# import psutil
|
|
93
|
+
# est = CarbonEstimator(vcpu=2, ram_gb=4)
|
|
94
|
+
# gco2_per_hour = est.estimated_gco2_per_hour(psutil.cpu_percent() / 100.0)
|
|
95
|
+
# # -> publish `estimated_gco2_per_hour` to your dashboard
|
|
96
|
+
# ---------------------------------------------------------------------------------'''
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
async def carbon_metric_scaffold(
|
|
100
|
+
repo: CarbonRepository,
|
|
101
|
+
language: str = "python",
|
|
102
|
+
region: Optional[str] = None,
|
|
103
|
+
provider: Optional[Provider] = None,
|
|
104
|
+
vcpu: float = 1,
|
|
105
|
+
ram_gb: float = 2,
|
|
106
|
+
) -> CarbonMetricScaffold:
|
|
107
|
+
lang = language.strip().lower()
|
|
108
|
+
if lang not in SUPPORTED_LANGUAGES:
|
|
109
|
+
return CarbonMetricScaffold(
|
|
110
|
+
language=language,
|
|
111
|
+
how_to_use=f"Language '{language}' not supported yet. v1 emits Python; "
|
|
112
|
+
f"the math (see methodology) ports easily to any language.",
|
|
113
|
+
methodology=METHODOLOGY, limitations=LIMITATIONS,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
pm = power_model()
|
|
117
|
+
grid = None
|
|
118
|
+
source = quality = None
|
|
119
|
+
if region is not None:
|
|
120
|
+
prov = provider or _guess_provider(region)
|
|
121
|
+
if prov is not None:
|
|
122
|
+
reading = await carbon_service.carbon_now(repo, prov, region) # type: ignore[arg-type]
|
|
123
|
+
grid = reading.g_co2_per_kwh
|
|
124
|
+
source, quality = reading.source, reading.quality
|
|
125
|
+
|
|
126
|
+
code = _python_snippet(vcpu, ram_gb, pm, grid, region)
|
|
127
|
+
how = ("Paste the snippet into your app. Instantiate CarbonEstimator with your instance's vcpu/"
|
|
128
|
+
"ram_gb, feed it CPU utilisation (e.g. psutil.cpu_percent()/100), and publish "
|
|
129
|
+
"estimated_gco2_per_hour to your dashboard/metrics. Set the grid intensity for your "
|
|
130
|
+
"region — if you gave GreenPick a region it's baked in; otherwise call region_carbon_now "
|
|
131
|
+
"and refresh it periodically so it doesn't go stale.")
|
|
132
|
+
return CarbonMetricScaffold(
|
|
133
|
+
language="python", region=region,
|
|
134
|
+
grid_intensity_g_co2_per_kwh=grid, carbon_source=source, carbon_quality=quality,
|
|
135
|
+
coefficients={
|
|
136
|
+
"watts_per_vcpu": pm["watts_per_vcpu"],
|
|
137
|
+
"watts_per_gb_ram": pm["watts_per_gb_ram"],
|
|
138
|
+
"pue_default": pm["pue_default"],
|
|
139
|
+
},
|
|
140
|
+
code=code, how_to_use=how, methodology=METHODOLOGY, limitations=LIMITATIONS,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _guess_provider(region: str) -> Optional[str]:
|
|
145
|
+
from ..data.static_data import iter_regions
|
|
146
|
+
for prov, reg, _meta in iter_regions():
|
|
147
|
+
if reg == region:
|
|
148
|
+
return prov
|
|
149
|
+
return None
|