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,203 @@
|
|
|
1
|
+
"""Assemble the tri-axis recommendation: carbon + cost + latency across candidate regions.
|
|
2
|
+
|
|
3
|
+
This is the heart of GreenPick's differentiation — not just "which region is cleanest",
|
|
4
|
+
but "cleanest within YOUR latency budget, and here's what it costs you".
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from ..models import (
|
|
9
|
+
Workload, GeoPoint, Provider, RegionCandidate, Recommendation,
|
|
10
|
+
)
|
|
11
|
+
from ..data.repository import CarbonRepository
|
|
12
|
+
from ..data.static_data import iter_regions
|
|
13
|
+
from . import carbon_service, cost_service, latency_service, residency_service
|
|
14
|
+
|
|
15
|
+
LIMITATIONS = (
|
|
16
|
+
"Directional guidance, not audited accounting. Grid-zone mapping is approximate; "
|
|
17
|
+
"carbon is point-in-time (not flow-traced consumption); cost is a ballpark, not a quote; "
|
|
18
|
+
"latency is a geographic estimate. 'Greener' assumes grid intensity dominates and ignores "
|
|
19
|
+
"embodied/hardware carbon and provider renewable-energy certificates. "
|
|
20
|
+
"Also: moving compute to a greener region can incur cross-region/cross-cloud EGRESS cost and "
|
|
21
|
+
"transfer carbon if your data stays put — run data_locality_egress_check before you move."
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _candidate_providers(providers: list[Provider] | None) -> set[str]:
|
|
26
|
+
return set(providers) if providers else {"aws", "gcp", "azure"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _assign_ratings(candidates: list[RegionCandidate]) -> None:
|
|
30
|
+
"""Relative R/Y/G per axis across the candidate set (lower is better for all three:
|
|
31
|
+
carbon intensity, latency, cost). Terciles: best third=green, middle=yellow, worst=red.
|
|
32
|
+
Transparent-by-construction — it's 'best available among your options', not an absolute
|
|
33
|
+
judgement. Candidates missing a value for an axis are left unrated for that axis."""
|
|
34
|
+
def rate(getter, setter):
|
|
35
|
+
vals = [(c, getter(c)) for c in candidates if getter(c) is not None]
|
|
36
|
+
if not vals:
|
|
37
|
+
return
|
|
38
|
+
ordered = sorted(vals, key=lambda cv: cv[1]) # ascending: lower = better
|
|
39
|
+
n = len(ordered)
|
|
40
|
+
for i, (c, _v) in enumerate(ordered):
|
|
41
|
+
frac = i / n # 0..1 position (0 = best)
|
|
42
|
+
setter(c, "green" if frac < 1/3 else "yellow" if frac < 2/3 else "red")
|
|
43
|
+
|
|
44
|
+
rate(lambda c: c.carbon.g_co2_per_kwh,
|
|
45
|
+
lambda c, r: setattr(c, "carbon_rating", r))
|
|
46
|
+
rate(lambda c: c.latency_ms_estimate,
|
|
47
|
+
lambda c, r: setattr(c, "latency_rating", r))
|
|
48
|
+
rate(lambda c: c.est_monthly_cost_usd,
|
|
49
|
+
lambda c, r: setattr(c, "cost_rating", r))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def compare_regions(
|
|
53
|
+
repo: CarbonRepository,
|
|
54
|
+
providers: list[Provider] | None,
|
|
55
|
+
user_location: GeoPoint,
|
|
56
|
+
workload: Workload,
|
|
57
|
+
optimize_for: str = "carbon", # "carbon" | "balanced"
|
|
58
|
+
max_added_latency_ms: float | None = None,
|
|
59
|
+
baseline_region: str | None = None,
|
|
60
|
+
regions: list[str] | None = None, # restrict ranking to these region codes
|
|
61
|
+
) -> Recommendation:
|
|
62
|
+
wanted = _candidate_providers(providers)
|
|
63
|
+
# Restrict to a caller-supplied candidate set, if given. Always keep the baseline region in
|
|
64
|
+
# play (you're implicitly comparing against where you are, even if you didn't list it).
|
|
65
|
+
region_filter: set[str] | None = None
|
|
66
|
+
if regions:
|
|
67
|
+
region_filter = {r for r in regions}
|
|
68
|
+
if baseline_region:
|
|
69
|
+
region_filter.add(baseline_region)
|
|
70
|
+
|
|
71
|
+
candidates: list[RegionCandidate] = []
|
|
72
|
+
for provider, region, _meta in iter_regions():
|
|
73
|
+
if provider not in wanted:
|
|
74
|
+
continue
|
|
75
|
+
if region_filter is not None and region not in region_filter:
|
|
76
|
+
continue
|
|
77
|
+
reading = await carbon_service.carbon_now(repo, provider, region)
|
|
78
|
+
cand = RegionCandidate(
|
|
79
|
+
region=region,
|
|
80
|
+
provider=provider, # type: ignore[arg-type]
|
|
81
|
+
carbon=reading,
|
|
82
|
+
est_monthly_cost_usd=cost_service.estimate_monthly_cost_usd(provider, workload), # type: ignore[arg-type]
|
|
83
|
+
latency_ms_estimate=latency_service.estimate_latency_ms(provider, region, user_location), # type: ignore[arg-type]
|
|
84
|
+
)
|
|
85
|
+
candidates.append(cand)
|
|
86
|
+
|
|
87
|
+
if not candidates:
|
|
88
|
+
hint = ""
|
|
89
|
+
if region_filter is not None:
|
|
90
|
+
hint = (f" The region filter {sorted(region_filter)} matched nothing in the catalog "
|
|
91
|
+
f"for providers {sorted(wanted)} — check the region codes.")
|
|
92
|
+
return Recommendation(candidates=[], rationale=f"No known regions matched the request.{hint}",
|
|
93
|
+
limitations=LIMITATIONS)
|
|
94
|
+
|
|
95
|
+
# Carbon ranking across the candidate set.
|
|
96
|
+
ordered = sorted(candidates, key=lambda c: c.carbon.g_co2_per_kwh)
|
|
97
|
+
for i, c in enumerate(ordered, start=1):
|
|
98
|
+
c.carbon_rank = i
|
|
99
|
+
|
|
100
|
+
# Per-axis relative R/Y/G ratings (shown alongside raw values so the user sees tradeoffs).
|
|
101
|
+
_assign_ratings(candidates)
|
|
102
|
+
|
|
103
|
+
# Baseline selection:
|
|
104
|
+
# - explicit baseline_region if the caller gave one (their current region), else
|
|
105
|
+
# - the region NEAREST the user (lowest estimated latency) — the implicit "where I'd
|
|
106
|
+
# deploy by default". (Previously fell back to the DIRTIEST region, which produced
|
|
107
|
+
# confusing "cleaner than <a region I didn't ask about>; -272ms" tradeoff lines.)
|
|
108
|
+
baseline = next((c for c in candidates if c.region == baseline_region), None)
|
|
109
|
+
if baseline is None:
|
|
110
|
+
with_latency = [c for c in candidates if c.latency_ms_estimate is not None]
|
|
111
|
+
baseline = (min(with_latency, key=lambda c: c.latency_ms_estimate) # type: ignore[arg-type]
|
|
112
|
+
if with_latency else ordered[-1])
|
|
113
|
+
|
|
114
|
+
for c in candidates:
|
|
115
|
+
if baseline.carbon.g_co2_per_kwh > 0:
|
|
116
|
+
c.carbon_vs_baseline_pct = round(
|
|
117
|
+
(c.carbon.g_co2_per_kwh - baseline.carbon.g_co2_per_kwh)
|
|
118
|
+
/ baseline.carbon.g_co2_per_kwh * 100, 1
|
|
119
|
+
)
|
|
120
|
+
if c.latency_ms_estimate is not None and baseline.latency_ms_estimate is not None:
|
|
121
|
+
c.added_latency_ms = round(c.latency_ms_estimate - baseline.latency_ms_estimate, 1)
|
|
122
|
+
c.tradeoff = _tradeoff_sentence(c, baseline)
|
|
123
|
+
|
|
124
|
+
# Latency budget with a tolerance band: a strict "+100ms" that excludes +105ms feels
|
|
125
|
+
# absurd to a user. Treat the cap as "~", allowing the larger of +15ms or +10% slack.
|
|
126
|
+
def within_budget(c: RegionCandidate) -> bool:
|
|
127
|
+
if max_added_latency_ms is None or c.added_latency_ms is None:
|
|
128
|
+
return True
|
|
129
|
+
slack = max(15.0, max_added_latency_ms * 0.10)
|
|
130
|
+
return c.added_latency_ms <= max_added_latency_ms + slack
|
|
131
|
+
|
|
132
|
+
eligible = [c for c in ordered if within_budget(c)]
|
|
133
|
+
pick = (eligible[0] if eligible else ordered[0])
|
|
134
|
+
|
|
135
|
+
# "No meaningful improvement" handling: if the best in-budget pick isn't materially
|
|
136
|
+
# cleaner than the baseline (>=5%), don't dress up the baseline as a win — say staying
|
|
137
|
+
# put is reasonable and surface what the greener options would cost.
|
|
138
|
+
MEANINGFUL_PCT = 5.0
|
|
139
|
+
pick_improvement = -(pick.carbon_vs_baseline_pct or 0.0) # positive = cleaner than baseline
|
|
140
|
+
is_baseline_pick = (pick.region == baseline.region and pick.provider == baseline.provider)
|
|
141
|
+
|
|
142
|
+
if is_baseline_pick or pick_improvement < MEANINGFUL_PCT:
|
|
143
|
+
# Find the greenest option overall (ignoring budget) to describe the tradeoff on offer.
|
|
144
|
+
greenest = ordered[0]
|
|
145
|
+
greener_improvement = -(greenest.carbon_vs_baseline_pct or 0.0)
|
|
146
|
+
if greener_improvement < MEANINGFUL_PCT:
|
|
147
|
+
rationale = (
|
|
148
|
+
f"Staying on {baseline.provider}/{baseline.region} is reasonable — no candidate "
|
|
149
|
+
f"region is meaningfully greener (all within ~{MEANINGFUL_PCT:.0f}%)."
|
|
150
|
+
)
|
|
151
|
+
else:
|
|
152
|
+
extra_lat = greenest.added_latency_ms
|
|
153
|
+
lat_txt = (f", but it adds ~{extra_lat:.0f}ms latency" if extra_lat and extra_lat > 0 else "")
|
|
154
|
+
rationale = (
|
|
155
|
+
f"Within your latency budget, {baseline.provider}/{baseline.region} is about as "
|
|
156
|
+
f"good as it gets — nothing meaningfully greener fits. The greenest option overall "
|
|
157
|
+
f"is {greenest.provider}/{greenest.region} (~{greener_improvement:.0f}% cleaner{lat_txt}); "
|
|
158
|
+
f"widen the latency budget to reach it."
|
|
159
|
+
)
|
|
160
|
+
recommended = f"{baseline.provider}/{baseline.region}"
|
|
161
|
+
else:
|
|
162
|
+
budget_txt = ""
|
|
163
|
+
if max_added_latency_ms is not None:
|
|
164
|
+
budget_txt = f" within ~{max_added_latency_ms:.0f}ms added latency"
|
|
165
|
+
rationale = (
|
|
166
|
+
f"{pick.provider}/{pick.region} is ~{pick_improvement:.0f}% cleaner than "
|
|
167
|
+
f"{baseline.provider}/{baseline.region}{budget_txt} "
|
|
168
|
+
f"({pick.carbon.g_co2_per_kwh:.0f} gCO2/kWh, source={pick.carbon.source}, "
|
|
169
|
+
f"quality={pick.carbon.quality})."
|
|
170
|
+
)
|
|
171
|
+
recommended = f"{pick.provider}/{pick.region}"
|
|
172
|
+
|
|
173
|
+
# Residency heads-up: does the recommended region sit in a different jurisdiction than the
|
|
174
|
+
# baseline (where they are today)? Neutral geographic flag, never a compliance verdict.
|
|
175
|
+
rec_prov, _, rec_region = recommended.partition("/")
|
|
176
|
+
residency_note = residency_service.residency_flag(
|
|
177
|
+
baseline.provider, baseline.region, rec_prov, rec_region,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
return Recommendation(
|
|
181
|
+
candidates=sorted(candidates, key=lambda c: c.carbon_rank or 999),
|
|
182
|
+
recommended_region=recommended,
|
|
183
|
+
residency_note=residency_note,
|
|
184
|
+
rationale=rationale,
|
|
185
|
+
limitations=LIMITATIONS,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _tradeoff_sentence(c: RegionCandidate, baseline: RegionCandidate) -> str:
|
|
190
|
+
parts: list[str] = []
|
|
191
|
+
if c.carbon_vs_baseline_pct is not None and c.region != baseline.region:
|
|
192
|
+
if c.carbon_vs_baseline_pct < 0:
|
|
193
|
+
parts.append(f"~{abs(c.carbon_vs_baseline_pct):.0f}% cleaner than {baseline.region}")
|
|
194
|
+
elif c.carbon_vs_baseline_pct > 0:
|
|
195
|
+
parts.append(f"~{c.carbon_vs_baseline_pct:.0f}% dirtier than {baseline.region}")
|
|
196
|
+
else:
|
|
197
|
+
parts.append(f"same carbon as {baseline.region}")
|
|
198
|
+
if c.added_latency_ms is not None and c.region != baseline.region:
|
|
199
|
+
sign = "+" if c.added_latency_ms >= 0 else ""
|
|
200
|
+
parts.append(f"{sign}{c.added_latency_ms:.0f}ms latency")
|
|
201
|
+
if c.est_monthly_cost_usd is not None:
|
|
202
|
+
parts.append(f"~${c.est_monthly_cost_usd:.0f}/mo")
|
|
203
|
+
return "; ".join(parts) if parts else "baseline"
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Data-residency AWARENESS — a geographic flag, NEVER a compliance verdict.
|
|
2
|
+
|
|
3
|
+
GreenPick's recommendations move compute between regions. If a move crosses a data-residency
|
|
4
|
+
boundary (most importantly, leaving the EEA), a builder handling regulated or personal data needs
|
|
5
|
+
to KNOW — but GreenPick is not qualified to rule on legality, and the legal landscape (adequacy
|
|
6
|
+
decisions, frameworks) shifts constantly. So we encode only the STABLE geographic fact — which
|
|
7
|
+
jurisdiction a region sits in — and raise a neutral heads-up when a move crosses one, with a hard
|
|
8
|
+
disclaimer pointing the user to their own data governance.
|
|
9
|
+
|
|
10
|
+
What this is: "this move leaves the EEA — residency rules MAY apply; check your governance."
|
|
11
|
+
What this isNOT: "this move is / isn't GDPR-compliant." We never say that.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
from ..data.static_data import region_meta
|
|
18
|
+
|
|
19
|
+
# Human labels for the stable jurisdiction tags in regions.json.
|
|
20
|
+
_LABEL = {
|
|
21
|
+
"EEA": "the EEA (EU + Norway/Iceland/Liechtenstein)",
|
|
22
|
+
"US": "the United States",
|
|
23
|
+
"AU": "Australia",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
DISCLAIMER = ("GreenPick does NOT assess legal compliance - this is a geographic heads-up only. "
|
|
27
|
+
"Confirm against your own data-residency / data-governance requirements.")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def jurisdiction_of(provider: str, region: str) -> Optional[str]:
|
|
31
|
+
meta = region_meta(provider, region) or {}
|
|
32
|
+
return meta.get("jurisdiction")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def residency_flag(from_provider: str, from_region: str,
|
|
36
|
+
to_provider: str, to_region: str) -> Optional[str]:
|
|
37
|
+
"""Return a neutral residency heads-up if moving from one region to another crosses a
|
|
38
|
+
jurisdiction boundary, else None. States only geography + the category of concern + the
|
|
39
|
+
disclaimer. Leaving the EEA is called out specifically (the GDPR transfer trigger)."""
|
|
40
|
+
j_from = jurisdiction_of(from_provider, from_region)
|
|
41
|
+
j_to = jurisdiction_of(to_provider, to_region)
|
|
42
|
+
if not j_from or not j_to or j_from == j_to:
|
|
43
|
+
return None # same jurisdiction (or unknown) -> no residency crossing to flag
|
|
44
|
+
|
|
45
|
+
to_label = _LABEL.get(j_to, j_to)
|
|
46
|
+
from_label = _LABEL.get(j_from, j_from)
|
|
47
|
+
if j_from == "EEA":
|
|
48
|
+
# The high-stakes direction: data leaving the EEA.
|
|
49
|
+
return (f"RESIDENCY: this move takes compute from {from_label} to {to_label} - i.e. OUT of "
|
|
50
|
+
f"the EEA. If the workload handles personal or regulated data, GDPR transfer rules "
|
|
51
|
+
f"may constrain or prohibit this. {DISCLAIMER}")
|
|
52
|
+
return (f"RESIDENCY: this move crosses jurisdictions ({from_label} → {to_label}). If the workload "
|
|
53
|
+
f"handles regulated or personal data, residency rules may apply. {DISCLAIMER}")
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Right-sizing — the #1 FinOps finding, with a carbon twin.
|
|
2
|
+
|
|
3
|
+
Over-provisioning is the most common cloud waste: you pay for (and emit carbon for) capacity you
|
|
4
|
+
don't use. This is a PURE efficiency dual-benefit — dropping to a size that still fits your real
|
|
5
|
+
utilisation cuts BOTH the bill and the carbon, no tradeoff. GreenPick reuses its cost coefficients
|
|
6
|
+
and power model; the user/agent supplies observed utilisation (no billing-account access needed).
|
|
7
|
+
|
|
8
|
+
Design-time and directional: it's an estimate from the utilisation you report, not a reading of
|
|
9
|
+
your running fleet. We keep sensible headroom so the recommendation never under-provisions.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
from ..models import Workload, Provider, RightsizingAdvice
|
|
16
|
+
from ..data.repository import CarbonRepository
|
|
17
|
+
from . import carbon_service, cost_service
|
|
18
|
+
|
|
19
|
+
# Keep this much headroom above observed peak so we don't right-size into a cliff.
|
|
20
|
+
_HEADROOM = 1.4
|
|
21
|
+
# Don't recommend a change unless it saves at least this fraction — avoids noisy micro-advice.
|
|
22
|
+
_MIN_SAVING_PCT = 10.0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _rightsized_dimension(provisioned: float, utilization: Optional[float]) -> float:
|
|
26
|
+
"""Target = observed usage x headroom, floored at a small minimum, never above provisioned
|
|
27
|
+
(right-sizing only shrinks here). If utilisation is unknown, leave the dimension unchanged."""
|
|
28
|
+
if utilization is None:
|
|
29
|
+
return provisioned
|
|
30
|
+
u = max(0.0, min(1.0, utilization))
|
|
31
|
+
target = provisioned * u * _HEADROOM
|
|
32
|
+
target = max(0.5, target) # floor: don't suggest sub-0.5 vCPU/GB
|
|
33
|
+
return min(provisioned, round(target, 1))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def rightsizing_check(
|
|
37
|
+
repo: CarbonRepository,
|
|
38
|
+
provider: Provider,
|
|
39
|
+
region: str,
|
|
40
|
+
vcpu: float,
|
|
41
|
+
ram_gb: float,
|
|
42
|
+
cpu_utilization: float,
|
|
43
|
+
ram_utilization: Optional[float] = None,
|
|
44
|
+
hours_per_month: float = 730,
|
|
45
|
+
) -> RightsizingAdvice:
|
|
46
|
+
rec_vcpu = _rightsized_dimension(vcpu, cpu_utilization)
|
|
47
|
+
rec_ram = _rightsized_dimension(ram_gb, ram_utilization)
|
|
48
|
+
|
|
49
|
+
now_wl = Workload(vcpu=vcpu, ram_gb=ram_gb, hours_per_month=hours_per_month)
|
|
50
|
+
rs_wl = Workload(vcpu=rec_vcpu, ram_gb=rec_ram, hours_per_month=hours_per_month)
|
|
51
|
+
|
|
52
|
+
reading = await carbon_service.carbon_now(repo, provider, region)
|
|
53
|
+
cost_now = cost_service.estimate_monthly_cost_usd(provider, now_wl)
|
|
54
|
+
cost_rs = cost_service.estimate_monthly_cost_usd(provider, rs_wl)
|
|
55
|
+
carbon_now = carbon_service.estimate_kg_co2_per_month(now_wl, reading)
|
|
56
|
+
carbon_rs = carbon_service.estimate_kg_co2_per_month(rs_wl, reading)
|
|
57
|
+
|
|
58
|
+
usd_saving = round(cost_now - cost_rs, 2) if (cost_now is not None and cost_rs is not None) else None
|
|
59
|
+
kg_saving = round(carbon_now - carbon_rs, 2)
|
|
60
|
+
saving_pct = (round((carbon_now - carbon_rs) / carbon_now * 100, 1)
|
|
61
|
+
if carbon_now > 0 else None)
|
|
62
|
+
|
|
63
|
+
is_oversized = saving_pct is not None and saving_pct >= _MIN_SAVING_PCT
|
|
64
|
+
|
|
65
|
+
if is_oversized:
|
|
66
|
+
usd_txt = f"~${usd_saving:,.0f}/mo" if usd_saving is not None else "cost"
|
|
67
|
+
rationale = (
|
|
68
|
+
f"Over-provisioned: at ~{cpu_utilization*100:.0f}% CPU use, {vcpu:g} vCPU / {ram_gb:g} GB "
|
|
69
|
+
f"is bigger than you need. Right-size to ~{rec_vcpu:g} vCPU / {rec_ram:g} GB (keeps ~40% "
|
|
70
|
+
f"headroom) to cut ~{saving_pct:.0f}% - {usd_txt} AND ~{kg_saving:.1f} kg CO2/mo. "
|
|
71
|
+
f"Same work, less waste: the cost and carbon win here are the same action.")
|
|
72
|
+
else:
|
|
73
|
+
rationale = (
|
|
74
|
+
f"Already reasonably sized: at ~{cpu_utilization*100:.0f}% CPU use, {vcpu:g} vCPU / "
|
|
75
|
+
f"{ram_gb:g} GB has sensible headroom. No material right-sizing saving (< "
|
|
76
|
+
f"{_MIN_SAVING_PCT:.0f}%) — look to region choice or scheduling instead.")
|
|
77
|
+
|
|
78
|
+
return RightsizingAdvice(
|
|
79
|
+
provider=provider, region=region,
|
|
80
|
+
provisioned_vcpu=vcpu, provisioned_ram_gb=ram_gb,
|
|
81
|
+
observed_cpu_utilization=cpu_utilization, observed_ram_utilization=ram_utilization,
|
|
82
|
+
recommended_vcpu=rec_vcpu, recommended_ram_gb=rec_ram, is_oversized=is_oversized,
|
|
83
|
+
cost_now_usd_per_month=cost_now, cost_rightsized_usd_per_month=cost_rs,
|
|
84
|
+
monthly_usd_saving=usd_saving,
|
|
85
|
+
carbon_now_kg_per_month=carbon_now, carbon_rightsized_kg_per_month=carbon_rs,
|
|
86
|
+
monthly_kg_co2_saving=kg_saving, saving_pct=saving_pct,
|
|
87
|
+
carbon_source=reading.source, carbon_quality=reading.quality,
|
|
88
|
+
rationale=rationale,
|
|
89
|
+
limitations=("Directional, design-time estimate from the utilisation YOU report — not a "
|
|
90
|
+
"reading of your running fleet, and not a quote. Keeps ~40% headroom above "
|
|
91
|
+
"observed peak; validate against real load before resizing production. Cost "
|
|
92
|
+
"uses the dated static table; carbon = power model x live grid intensity."),
|
|
93
|
+
)
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Carbon-aware scheduling: when is a region cleanest to run a deferrable job?
|
|
2
|
+
|
|
3
|
+
Reuses the repository's recent hourly history. Pure logic over the returned series — the data
|
|
4
|
+
layer decides live-vs-mock and labels quality honestly. Directional, design-time advice
|
|
5
|
+
("set your nightly job to this hour"), not a scheduler.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from ..models import (
|
|
13
|
+
SchedulingAdvice, CleanestWindow, HourlyCarbon, Provider,
|
|
14
|
+
DailyPatternAdvice, HourOfDayStat,
|
|
15
|
+
)
|
|
16
|
+
from ..data.repository import CarbonRepository
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _hour_index(period: str) -> Optional[int]:
|
|
20
|
+
"""Map a period label to an absolute hour index so we can detect CONTIGUOUS runs.
|
|
21
|
+
Handles ISO hours ('2026-10-12T18' / '2026-10-12T18:00') and mock labels ('H18').
|
|
22
|
+
Returns None if unrecognised (such a point can't anchor a window)."""
|
|
23
|
+
p = period.strip()
|
|
24
|
+
if p.startswith("H") and p[1:].isdigit():
|
|
25
|
+
return int(p[1:]) # mock: H00..H23, already sequential
|
|
26
|
+
iso = p if len(p) > 13 else p + ":00:00" # '...T18' -> '...T18:00:00'
|
|
27
|
+
iso = iso.replace("Z", "+00:00")
|
|
28
|
+
try:
|
|
29
|
+
dt = datetime.fromisoformat(iso)
|
|
30
|
+
except ValueError:
|
|
31
|
+
try:
|
|
32
|
+
dt = datetime.strptime(p[:13], "%Y-%m-%dT%H")
|
|
33
|
+
except ValueError:
|
|
34
|
+
return None
|
|
35
|
+
if dt.tzinfo is None:
|
|
36
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
37
|
+
return int(dt.timestamp() // 3600) # absolute hours since epoch
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _best_and_worst_window(series: list[HourlyCarbon], window_hours: int
|
|
41
|
+
) -> tuple[Optional[list[HourlyCarbon]], Optional[float]]:
|
|
42
|
+
"""Slide a window of `window_hours` over the CHRONOLOGICALLY ordered series, considering only
|
|
43
|
+
runs of genuinely consecutive hours (a gap breaks a window). Returns (best_window_hours,
|
|
44
|
+
reduction_pct_best_vs_worst), or (None, None) if no contiguous run is long enough."""
|
|
45
|
+
chrono = sorted(
|
|
46
|
+
[(h, _hour_index(h.period)) for h in series if _hour_index(h.period) is not None],
|
|
47
|
+
key=lambda t: t[1], # type: ignore[arg-type]
|
|
48
|
+
)
|
|
49
|
+
best: Optional[tuple[float, list[HourlyCarbon]]] = None
|
|
50
|
+
worst_avg: Optional[float] = None
|
|
51
|
+
for i in range(len(chrono) - window_hours + 1):
|
|
52
|
+
chunk = chrono[i:i + window_hours]
|
|
53
|
+
# Contiguity: indices must step by exactly 1 across the whole chunk.
|
|
54
|
+
idxs = [t[1] for t in chunk]
|
|
55
|
+
if any(idxs[j + 1] - idxs[j] != 1 for j in range(len(idxs) - 1)):
|
|
56
|
+
continue
|
|
57
|
+
hours = [t[0] for t in chunk]
|
|
58
|
+
avg = sum(h.g_co2_per_kwh for h in hours) / window_hours
|
|
59
|
+
if best is None or avg < best[0]:
|
|
60
|
+
best = (avg, hours)
|
|
61
|
+
if worst_avg is None or avg > worst_avg:
|
|
62
|
+
worst_avg = avg
|
|
63
|
+
if best is None:
|
|
64
|
+
return None, None
|
|
65
|
+
reduction = (round((worst_avg - best[0]) / worst_avg * 100, 1)
|
|
66
|
+
if worst_avg and worst_avg > 0 else None)
|
|
67
|
+
return best[1], reduction
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def cleanest_hours(
|
|
71
|
+
repo: CarbonRepository,
|
|
72
|
+
provider: Provider,
|
|
73
|
+
region: str,
|
|
74
|
+
top_n: int = 3,
|
|
75
|
+
window_hours: Optional[int] = None,
|
|
76
|
+
) -> SchedulingAdvice:
|
|
77
|
+
"""Cleanest recent hours to run a deferrable job. If `window_hours` >= 2, also find the
|
|
78
|
+
lowest-average CONTIGUOUS run of that many hours — the right question for a job of known
|
|
79
|
+
duration (you can't start one job across scattered non-adjacent hours)."""
|
|
80
|
+
history = await repo.carbon_history(provider, region)
|
|
81
|
+
# Derive source/quality from a current reading (history doesn't carry it).
|
|
82
|
+
now = await repo.carbon_now(provider, region)
|
|
83
|
+
|
|
84
|
+
if not history:
|
|
85
|
+
return SchedulingAdvice(
|
|
86
|
+
region=region, provider=provider, source=now.source, quality=now.quality,
|
|
87
|
+
hours_analyzed=0, cleanest=[],
|
|
88
|
+
note="No recent hourly history available for this region — can't advise on timing.",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
ordered = sorted(history, key=lambda h: h.g_co2_per_kwh)
|
|
92
|
+
cleanest = ordered[:max(1, top_n)]
|
|
93
|
+
lo = ordered[0].g_co2_per_kwh
|
|
94
|
+
hi = ordered[-1].g_co2_per_kwh
|
|
95
|
+
reduction = round((hi - lo) / hi * 100, 1) if hi > 0 else None
|
|
96
|
+
|
|
97
|
+
if reduction is not None and reduction >= 10:
|
|
98
|
+
note = (f"Running during the cleanest hour instead of the dirtiest cuts this region's grid "
|
|
99
|
+
f"carbon by ~{reduction:.0f}%. Good candidate for time-shifting deferrable work "
|
|
100
|
+
f"(batch jobs, ETL, backups, training).")
|
|
101
|
+
else:
|
|
102
|
+
note = ("This region's grid carbon is fairly flat over the window — time-shifting here "
|
|
103
|
+
"won't help much; focus on region choice instead.")
|
|
104
|
+
|
|
105
|
+
advice = SchedulingAdvice(
|
|
106
|
+
region=region, provider=provider, source=now.source, quality=now.quality,
|
|
107
|
+
hours_analyzed=len(history), cleanest=cleanest,
|
|
108
|
+
dirtiest_g_co2_per_kwh=hi, cleanest_g_co2_per_kwh=lo,
|
|
109
|
+
potential_reduction_pct=reduction, note=note,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Window mode: the cleanest contiguous run to START a job of this duration.
|
|
113
|
+
if window_hours and window_hours >= 2:
|
|
114
|
+
win_hours, win_reduction = _best_and_worst_window(history, window_hours)
|
|
115
|
+
if win_hours:
|
|
116
|
+
avg = round(sum(h.g_co2_per_kwh for h in win_hours) / len(win_hours), 1)
|
|
117
|
+
advice.window = CleanestWindow(
|
|
118
|
+
window_hours=window_hours,
|
|
119
|
+
start_period=win_hours[0].period, end_period=win_hours[-1].period,
|
|
120
|
+
avg_g_co2_per_kwh=avg, hours=win_hours,
|
|
121
|
+
)
|
|
122
|
+
advice.window_reduction_pct = win_reduction
|
|
123
|
+
if win_reduction is not None and win_reduction >= 10:
|
|
124
|
+
advice.note = (f"For a ~{window_hours}h job, start it at {win_hours[0].period}: that's "
|
|
125
|
+
f"the cleanest {window_hours}h window (avg ~{avg:.0f} gCO2/kWh), "
|
|
126
|
+
f"~{win_reduction:.0f}% cleaner than the worst {window_hours}h window.")
|
|
127
|
+
else:
|
|
128
|
+
advice.note = (f"For a ~{window_hours}h job, the cleanest window starts at "
|
|
129
|
+
f"{win_hours[0].period} (avg ~{avg:.0f} gCO2/kWh), but the grid is "
|
|
130
|
+
f"fairly flat — timing won't help much here.")
|
|
131
|
+
else:
|
|
132
|
+
advice.note = (f"Not enough contiguous history to find a {window_hours}h window; "
|
|
133
|
+
f"showing cleanest individual hours instead. " + advice.note)
|
|
134
|
+
|
|
135
|
+
return advice
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
async def cleanest_time_of_day(
|
|
139
|
+
repo: CarbonRepository,
|
|
140
|
+
provider: Provider,
|
|
141
|
+
region: str,
|
|
142
|
+
) -> DailyPatternAdvice:
|
|
143
|
+
"""The recurring cleanest time-of-day for a region, for timing a RECURRING job (e.g. a nightly
|
|
144
|
+
batch you'll run every day). Averages each hour-of-day across the days in the recent window.
|
|
145
|
+
|
|
146
|
+
Honest scope: the free data windows are only a few days, so this is a DAILY pattern over N days,
|
|
147
|
+
not a 7-day day-of-week pattern. We report 'the grid is consistently cleanest around 03:00',
|
|
148
|
+
not 'Sundays are cleanest'."""
|
|
149
|
+
history = await repo.carbon_history(provider, region)
|
|
150
|
+
now = await repo.carbon_now(provider, region)
|
|
151
|
+
|
|
152
|
+
limitations = ("Daily pattern over the recent window (a few days of free data), not a 7-day "
|
|
153
|
+
"weekly/day-of-week pattern. Hours are in the source's timezone (UTC for EIA/"
|
|
154
|
+
"ENTSO-E). Directional — the pattern can shift with weather and season.")
|
|
155
|
+
|
|
156
|
+
if not history:
|
|
157
|
+
return DailyPatternAdvice(
|
|
158
|
+
region=region, provider=provider, source=now.source, quality=now.quality,
|
|
159
|
+
days_analyzed=0, hours_analyzed=0,
|
|
160
|
+
note="No recent hourly history available for this region — can't derive a daily pattern.",
|
|
161
|
+
limitations=limitations,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# Group by hour-of-day (index % 24); track distinct days (index // 24) for an honest count.
|
|
165
|
+
buckets: dict[int, list[float]] = {}
|
|
166
|
+
days: set[int] = set()
|
|
167
|
+
for h in history:
|
|
168
|
+
idx = _hour_index(h.period)
|
|
169
|
+
if idx is None:
|
|
170
|
+
continue
|
|
171
|
+
hod = idx % 24
|
|
172
|
+
buckets.setdefault(hod, []).append(h.g_co2_per_kwh)
|
|
173
|
+
days.add(idx // 24)
|
|
174
|
+
|
|
175
|
+
if not buckets:
|
|
176
|
+
return DailyPatternAdvice(
|
|
177
|
+
region=region, provider=provider, source=now.source, quality=now.quality,
|
|
178
|
+
days_analyzed=0, hours_analyzed=len(history),
|
|
179
|
+
note="Couldn't parse hour-of-day from the history timestamps.",
|
|
180
|
+
limitations=limitations,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
stats = [
|
|
184
|
+
HourOfDayStat(hour_of_day=hod, avg_g_co2_per_kwh=round(sum(v) / len(v), 1),
|
|
185
|
+
days_observed=len(v))
|
|
186
|
+
for hod, v in buckets.items()
|
|
187
|
+
]
|
|
188
|
+
stats.sort(key=lambda s: s.avg_g_co2_per_kwh) # cleanest-first
|
|
189
|
+
cleanest = stats[0]
|
|
190
|
+
dirtiest = stats[-1]
|
|
191
|
+
reduction = (round((dirtiest.avg_g_co2_per_kwh - cleanest.avg_g_co2_per_kwh)
|
|
192
|
+
/ dirtiest.avg_g_co2_per_kwh * 100, 1)
|
|
193
|
+
if dirtiest.avg_g_co2_per_kwh > 0 else None)
|
|
194
|
+
|
|
195
|
+
n_days = max(1, len(days))
|
|
196
|
+
if reduction is not None and reduction >= 10:
|
|
197
|
+
note = (f"Over the last ~{n_days} day(s), this region is consistently cleanest around "
|
|
198
|
+
f"{cleanest.hour_of_day:02d}:00 (avg ~{cleanest.avg_g_co2_per_kwh:.0f} gCO2/kWh) and "
|
|
199
|
+
f"dirtiest around {dirtiest.hour_of_day:02d}:00 - ~{reduction:.0f}% apart. For a "
|
|
200
|
+
f"recurring job, standing it up at ~{cleanest.hour_of_day:02d}:00 captures most of it.")
|
|
201
|
+
else:
|
|
202
|
+
note = (f"Over the last ~{n_days} day(s), this region's time-of-day pattern is fairly flat; "
|
|
203
|
+
f"a fixed run-time won't help much — region choice is the bigger lever.")
|
|
204
|
+
|
|
205
|
+
return DailyPatternAdvice(
|
|
206
|
+
region=region, provider=provider, source=now.source, quality=now.quality,
|
|
207
|
+
days_analyzed=n_days, hours_analyzed=len(history),
|
|
208
|
+
by_hour_of_day=stats, # all observed hours-of-day, cleanest first (24 small rows)
|
|
209
|
+
cleanest_hour_of_day=cleanest.hour_of_day, dirtiest_hour_of_day=dirtiest.hour_of_day,
|
|
210
|
+
potential_reduction_pct=reduction, note=note, limitations=limitations,
|
|
211
|
+
)
|