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.
Files changed (42) hide show
  1. greenpick/__init__.py +7 -0
  2. greenpick/__main__.py +18 -0
  3. greenpick/config.py +58 -0
  4. greenpick/data/__init__.py +28 -0
  5. greenpick/data/auto_repository.py +47 -0
  6. greenpick/data/eia_repository.py +156 -0
  7. greenpick/data/electricitymaps_repository.py +104 -0
  8. greenpick/data/emission_factors.json +35 -0
  9. greenpick/data/entsoe_repository.py +401 -0
  10. greenpick/data/gpu_power.json +26 -0
  11. greenpick/data/inference_energy.json +50 -0
  12. greenpick/data/instance_families.json +32 -0
  13. greenpick/data/mock_repository.py +53 -0
  14. greenpick/data/network.json +7 -0
  15. greenpick/data/power.json +7 -0
  16. greenpick/data/pricing.json +31 -0
  17. greenpick/data/regions.json +22 -0
  18. greenpick/data/repository.py +27 -0
  19. greenpick/data/static_data.py +105 -0
  20. greenpick/models.py +342 -0
  21. greenpick/server.py +349 -0
  22. greenpick/services/__init__.py +5 -0
  23. greenpick/services/carbon_service.py +28 -0
  24. greenpick/services/cost_service.py +21 -0
  25. greenpick/services/forecast_service.py +72 -0
  26. greenpick/services/inference_service.py +100 -0
  27. greenpick/services/instance_service.py +132 -0
  28. greenpick/services/latency_service.py +33 -0
  29. greenpick/services/locality_service.py +160 -0
  30. greenpick/services/metric_scaffold_service.py +149 -0
  31. greenpick/services/recommender.py +203 -0
  32. greenpick/services/residency_service.py +53 -0
  33. greenpick/services/rightsizing_service.py +93 -0
  34. greenpick/services/scheduling_service.py +211 -0
  35. greenpick/services/serving_service.py +202 -0
  36. greenpick/services/training_service.py +209 -0
  37. greenpick-0.1.0.dist-info/METADATA +271 -0
  38. greenpick-0.1.0.dist-info/RECORD +42 -0
  39. greenpick-0.1.0.dist-info/WHEEL +5 -0
  40. greenpick-0.1.0.dist-info/entry_points.txt +2 -0
  41. greenpick-0.1.0.dist-info/licenses/LICENSE +21 -0
  42. 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
+ )