greenpick 0.1.3__tar.gz → 0.1.5__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {greenpick-0.1.3/src/greenpick.egg-info → greenpick-0.1.5}/PKG-INFO +1 -1
- {greenpick-0.1.3 → greenpick-0.1.5}/pyproject.toml +1 -1
- greenpick-0.1.5/src/greenpick/__init__.py +15 -0
- greenpick-0.1.5/src/greenpick/data/_net.py +23 -0
- {greenpick-0.1.3 → greenpick-0.1.5}/src/greenpick/data/eia_repository.py +7 -3
- {greenpick-0.1.3 → greenpick-0.1.5}/src/greenpick/data/electricitymaps_repository.py +9 -4
- {greenpick-0.1.3 → greenpick-0.1.5}/src/greenpick/data/entsoe_repository.py +53 -40
- {greenpick-0.1.3 → greenpick-0.1.5}/src/greenpick/data/regions.json +1 -2
- {greenpick-0.1.3 → greenpick-0.1.5}/src/greenpick/models.py +1 -0
- {greenpick-0.1.3 → greenpick-0.1.5}/src/greenpick/server.py +3 -1
- {greenpick-0.1.3 → greenpick-0.1.5}/src/greenpick/services/locality_service.py +4 -0
- {greenpick-0.1.3 → greenpick-0.1.5}/src/greenpick/services/rightsizing_service.py +35 -11
- {greenpick-0.1.3 → greenpick-0.1.5}/src/greenpick/services/serving_service.py +6 -1
- {greenpick-0.1.3 → greenpick-0.1.5}/src/greenpick/services/training_service.py +3 -0
- {greenpick-0.1.3 → greenpick-0.1.5/src/greenpick.egg-info}/PKG-INFO +1 -1
- {greenpick-0.1.3 → greenpick-0.1.5}/src/greenpick.egg-info/SOURCES.txt +1 -0
- {greenpick-0.1.3 → greenpick-0.1.5}/tests/test_services.py +168 -10
- greenpick-0.1.3/src/greenpick/__init__.py +0 -7
- {greenpick-0.1.3 → greenpick-0.1.5}/LICENSE +0 -0
- {greenpick-0.1.3 → greenpick-0.1.5}/README.md +0 -0
- {greenpick-0.1.3 → greenpick-0.1.5}/setup.cfg +0 -0
- {greenpick-0.1.3 → greenpick-0.1.5}/src/greenpick/__main__.py +0 -0
- {greenpick-0.1.3 → greenpick-0.1.5}/src/greenpick/config.py +0 -0
- {greenpick-0.1.3 → greenpick-0.1.5}/src/greenpick/data/__init__.py +0 -0
- {greenpick-0.1.3 → greenpick-0.1.5}/src/greenpick/data/auto_repository.py +0 -0
- {greenpick-0.1.3 → greenpick-0.1.5}/src/greenpick/data/emission_factors.json +0 -0
- {greenpick-0.1.3 → greenpick-0.1.5}/src/greenpick/data/gpu_power.json +0 -0
- {greenpick-0.1.3 → greenpick-0.1.5}/src/greenpick/data/inference_energy.json +0 -0
- {greenpick-0.1.3 → greenpick-0.1.5}/src/greenpick/data/instance_families.json +0 -0
- {greenpick-0.1.3 → greenpick-0.1.5}/src/greenpick/data/mock_repository.py +0 -0
- {greenpick-0.1.3 → greenpick-0.1.5}/src/greenpick/data/network.json +0 -0
- {greenpick-0.1.3 → greenpick-0.1.5}/src/greenpick/data/power.json +0 -0
- {greenpick-0.1.3 → greenpick-0.1.5}/src/greenpick/data/pricing.json +0 -0
- {greenpick-0.1.3 → greenpick-0.1.5}/src/greenpick/data/repository.py +0 -0
- {greenpick-0.1.3 → greenpick-0.1.5}/src/greenpick/data/static_data.py +0 -0
- {greenpick-0.1.3 → greenpick-0.1.5}/src/greenpick/services/__init__.py +0 -0
- {greenpick-0.1.3 → greenpick-0.1.5}/src/greenpick/services/carbon_service.py +0 -0
- {greenpick-0.1.3 → greenpick-0.1.5}/src/greenpick/services/cost_service.py +0 -0
- {greenpick-0.1.3 → greenpick-0.1.5}/src/greenpick/services/forecast_service.py +0 -0
- {greenpick-0.1.3 → greenpick-0.1.5}/src/greenpick/services/inference_service.py +0 -0
- {greenpick-0.1.3 → greenpick-0.1.5}/src/greenpick/services/instance_service.py +0 -0
- {greenpick-0.1.3 → greenpick-0.1.5}/src/greenpick/services/latency_service.py +0 -0
- {greenpick-0.1.3 → greenpick-0.1.5}/src/greenpick/services/metric_scaffold_service.py +0 -0
- {greenpick-0.1.3 → greenpick-0.1.5}/src/greenpick/services/recommender.py +0 -0
- {greenpick-0.1.3 → greenpick-0.1.5}/src/greenpick/services/residency_service.py +0 -0
- {greenpick-0.1.3 → greenpick-0.1.5}/src/greenpick/services/scheduling_service.py +0 -0
- {greenpick-0.1.3 → greenpick-0.1.5}/src/greenpick.egg-info/dependency_links.txt +0 -0
- {greenpick-0.1.3 → greenpick-0.1.5}/src/greenpick.egg-info/entry_points.txt +0 -0
- {greenpick-0.1.3 → greenpick-0.1.5}/src/greenpick.egg-info/requires.txt +0 -0
- {greenpick-0.1.3 → greenpick-0.1.5}/src/greenpick.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: greenpick
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.5
|
|
4
4
|
Summary: Design-time sustainability co-pilot (MCP server): greener, cheaper cloud infrastructure decisions with carbon + cost + latency tradeoffs.
|
|
5
5
|
Author-email: "Data Science Advisers, LLC" <info@datascienceadvisers.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "greenpick"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.5"
|
|
4
4
|
description = "Design-time sustainability co-pilot (MCP server): greener, cheaper cloud infrastructure decisions with carbon + cost + latency tradeoffs."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""GreenPick — design-time sustainability co-pilot (MCP server).
|
|
2
|
+
|
|
3
|
+
Greener cloud-region choices with honest carbon + cost + latency tradeoffs,
|
|
4
|
+
delivered inside the vibe-coding loop. Directional guidance, not audited accounting.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.5"
|
|
8
|
+
|
|
9
|
+
# httpx logs every request URL at INFO, and our live sources pass their API key / token as a URL
|
|
10
|
+
# query parameter — so at the default log level the secret would land in the MCP client's logs.
|
|
11
|
+
# Keep the HTTP libraries at WARNING (no request lines) for every entry point that imports us.
|
|
12
|
+
import logging as _logging
|
|
13
|
+
|
|
14
|
+
for _name in ("httpx", "httpcore"):
|
|
15
|
+
_logging.getLogger(_name).setLevel(_logging.WARNING)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Shared networking helpers for the live carbon sources.
|
|
2
|
+
|
|
3
|
+
Two jobs:
|
|
4
|
+
- `describe_error`: a log-safe summary of a failed request. httpx exception messages embed the
|
|
5
|
+
full request URL, and our data sources pass their API key / token as a URL query parameter, so
|
|
6
|
+
logging the exception verbatim would write the secret into the MCP client's log files.
|
|
7
|
+
- Shared timeouts, so one slow upstream can't freeze a tool call.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
# Per-step limits for a single HTTP request (connect / read / write / pool).
|
|
14
|
+
REQUEST_TIMEOUT = httpx.Timeout(8.0, connect=5.0)
|
|
15
|
+
# Hard ceiling for ONE request end-to-end. httpx timeouts are per-step, so a server that trickles
|
|
16
|
+
# bytes can stretch a request far past them; asyncio.wait_for enforces a true wall-clock limit.
|
|
17
|
+
REQUEST_DEADLINE_S = 15.0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def describe_error(e: BaseException) -> str:
|
|
21
|
+
"""Error type plus HTTP status (if any) — never the URL, which carries the credential."""
|
|
22
|
+
status = getattr(getattr(e, "response", None), "status_code", None)
|
|
23
|
+
return f"{type(e).__name__}" + (f" (HTTP {status})" if status else "")
|
|
@@ -14,6 +14,7 @@ Carbon intensity = sum(gen_fuel * factor_fuel) / sum(gen_fuel). Directional, no
|
|
|
14
14
|
"""
|
|
15
15
|
from __future__ import annotations
|
|
16
16
|
|
|
17
|
+
import asyncio
|
|
17
18
|
import logging
|
|
18
19
|
import time
|
|
19
20
|
from datetime import datetime, timezone
|
|
@@ -23,6 +24,7 @@ import httpx
|
|
|
23
24
|
from ..models import CarbonReading, Provider, HourlyCarbon
|
|
24
25
|
from .mock_repository import MockCarbonRepository
|
|
25
26
|
from .static_data import region_meta, emission_factors
|
|
27
|
+
from ._net import REQUEST_TIMEOUT, REQUEST_DEADLINE_S, describe_error
|
|
26
28
|
|
|
27
29
|
log = logging.getLogger("greenpick.eia")
|
|
28
30
|
|
|
@@ -78,8 +80,9 @@ class EIARepository:
|
|
|
78
80
|
"sort[0][direction]": "desc",
|
|
79
81
|
"length": "2000", # ~5-7 days of hourly rows x fuels — enough for a daily pattern
|
|
80
82
|
}
|
|
81
|
-
async with httpx.AsyncClient(timeout=
|
|
82
|
-
resp = await client.get(_URL, params=params)
|
|
83
|
+
async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as client:
|
|
84
|
+
resp = await asyncio.wait_for(client.get(_URL, params=params),
|
|
85
|
+
timeout=REQUEST_DEADLINE_S)
|
|
83
86
|
if resp.status_code in (401, 403):
|
|
84
87
|
log.warning("EIA rejected the key (HTTP %s) — using mock. Check EIA_API_KEY.",
|
|
85
88
|
resp.status_code)
|
|
@@ -87,7 +90,8 @@ class EIARepository:
|
|
|
87
90
|
resp.raise_for_status()
|
|
88
91
|
return resp.json().get("response", {}).get("data", [])
|
|
89
92
|
except Exception as e:
|
|
90
|
-
|
|
93
|
+
# describe_error, not e: the exception text embeds the URL, which carries the key.
|
|
94
|
+
log.debug("EIA call failed for BA %s (%s) — mock fallback", ba, describe_error(e))
|
|
91
95
|
return None
|
|
92
96
|
|
|
93
97
|
async def carbon_history(self, provider: Provider, region: str) -> list[HourlyCarbon]:
|
|
@@ -13,6 +13,7 @@ API: GET https://api.electricitymap.org/v3/carbon-intensity/latest?zone=XX
|
|
|
13
13
|
"""
|
|
14
14
|
from __future__ import annotations
|
|
15
15
|
|
|
16
|
+
import asyncio
|
|
16
17
|
import logging
|
|
17
18
|
import time
|
|
18
19
|
from datetime import datetime, timezone
|
|
@@ -22,6 +23,7 @@ import httpx
|
|
|
22
23
|
from ..models import CarbonReading, Provider, HourlyCarbon
|
|
23
24
|
from .mock_repository import MockCarbonRepository
|
|
24
25
|
from .static_data import region_meta
|
|
26
|
+
from ._net import REQUEST_TIMEOUT, REQUEST_DEADLINE_S, describe_error
|
|
25
27
|
|
|
26
28
|
log = logging.getLogger("greenpick.electricitymaps")
|
|
27
29
|
|
|
@@ -66,9 +68,11 @@ class ElectricityMapsRepository:
|
|
|
66
68
|
})
|
|
67
69
|
|
|
68
70
|
try:
|
|
69
|
-
async with httpx.AsyncClient(timeout=
|
|
70
|
-
resp = await
|
|
71
|
-
|
|
71
|
+
async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as client:
|
|
72
|
+
resp = await asyncio.wait_for(
|
|
73
|
+
client.get(_BASE, params={"zone": zone}, headers={"auth-token": self._token}),
|
|
74
|
+
timeout=REQUEST_DEADLINE_S,
|
|
75
|
+
)
|
|
72
76
|
|
|
73
77
|
if resp.status_code in (401, 403):
|
|
74
78
|
# Config problem, not a per-region issue. Warn once, then stop trying.
|
|
@@ -100,5 +104,6 @@ class ElectricityMapsRepository:
|
|
|
100
104
|
|
|
101
105
|
except Exception as e:
|
|
102
106
|
# Network/parse/rate-limit -> mock fallback rather than erroring the tool.
|
|
103
|
-
log.debug("Electricity Maps call failed for zone %s (%s) — mock fallback",
|
|
107
|
+
log.debug("Electricity Maps call failed for zone %s (%s) — mock fallback",
|
|
108
|
+
zone, describe_error(e))
|
|
104
109
|
return await self._fallback.carbon_now(provider, region)
|
|
@@ -14,6 +14,7 @@ Response: XML with <TimeSeries> per production type:
|
|
|
14
14
|
"""
|
|
15
15
|
from __future__ import annotations
|
|
16
16
|
|
|
17
|
+
import asyncio
|
|
17
18
|
import logging
|
|
18
19
|
import re
|
|
19
20
|
import time
|
|
@@ -25,11 +26,17 @@ import httpx
|
|
|
25
26
|
from ..models import CarbonReading, Provider, HourlyCarbon, ForecastHour
|
|
26
27
|
from .mock_repository import MockCarbonRepository
|
|
27
28
|
from .static_data import region_meta, emission_factors
|
|
29
|
+
from ._net import REQUEST_TIMEOUT, REQUEST_DEADLINE_S, describe_error
|
|
28
30
|
|
|
29
31
|
log = logging.getLogger("greenpick.entsoe")
|
|
30
32
|
|
|
31
33
|
_URL = "https://web-api.tp.entsoe.eu/api"
|
|
32
34
|
|
|
35
|
+
# Retry policy for a slow/flaky upstream, bounded so a tool call never hangs on it:
|
|
36
|
+
_ATTEMPTS = 3
|
|
37
|
+
_FETCH_BUDGET_S = 20.0 # total wall-clock for ALL attempts + backoff of one fetch
|
|
38
|
+
_OUTAGE_COOLDOWN_S = 300.0 # after a failed fetch, skip that zone (labelled fallback) this long
|
|
39
|
+
|
|
33
40
|
|
|
34
41
|
def _strip_ns(tag: str) -> str:
|
|
35
42
|
"""ENTSO-E XML is namespaced; strip '{ns}tag' -> 'tag' for simple matching."""
|
|
@@ -70,6 +77,7 @@ class ENTSOERepository:
|
|
|
70
77
|
self._cache: dict[str, tuple[float, CarbonReading]] = {} # EIC -> (fetched_at, reading)
|
|
71
78
|
self._fallback = MockCarbonRepository()
|
|
72
79
|
self._warned_no_token = False
|
|
80
|
+
self._down_until: dict[str, float] = {} # EIC -> epoch secs; skip the zone until then
|
|
73
81
|
|
|
74
82
|
async def carbon_now(self, provider: Provider, region: str) -> CarbonReading:
|
|
75
83
|
meta = region_meta(provider, region) or {}
|
|
@@ -114,28 +122,57 @@ class ENTSOERepository:
|
|
|
114
122
|
"periodEnd": now.strftime("%Y%m%d%H%M"),
|
|
115
123
|
}
|
|
116
124
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
125
|
+
return await self._get_bounded(eic, params, label="A75")
|
|
126
|
+
|
|
127
|
+
def _is_down(self, eic: str) -> bool:
|
|
128
|
+
return time.time() < self._down_until.get(eic, 0.0)
|
|
129
|
+
|
|
130
|
+
async def _get_bounded(self, eic: str, params: dict, label: str,
|
|
131
|
+
no_match_is_final: bool = False) -> str | None:
|
|
132
|
+
"""GET with retries inside ONE wall-clock budget, and outage memory.
|
|
133
|
+
|
|
134
|
+
ENTSO-E is occasionally slow or empty for a zone (their docs warn about response times), so
|
|
135
|
+
we retry — but every attempt and backoff shares a single _FETCH_BUDGET_S, so a struggling
|
|
136
|
+
upstream degrades this call to the labelled fallback in seconds rather than freezing the tool.
|
|
137
|
+
If the zone still fails, remember it for _OUTAGE_COOLDOWN_S: the next calls (e.g. the other
|
|
138
|
+
regions in a ranking, then the timing lookup) fall back immediately instead of each waiting
|
|
139
|
+
out their own retry cycle. Returns the body text, or None (callers fall back)."""
|
|
140
|
+
if self._is_down(eic):
|
|
141
|
+
return None
|
|
142
|
+
deadline = time.monotonic() + _FETCH_BUDGET_S
|
|
143
|
+
for attempt in range(1, _ATTEMPTS + 1):
|
|
144
|
+
remaining = deadline - time.monotonic()
|
|
145
|
+
if remaining <= 0.5:
|
|
146
|
+
break
|
|
123
147
|
try:
|
|
124
|
-
async with httpx.AsyncClient(timeout=
|
|
125
|
-
resp = await client.get(_URL, params=params)
|
|
148
|
+
async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as client:
|
|
149
|
+
resp = await asyncio.wait_for(client.get(_URL, params=params),
|
|
150
|
+
timeout=min(remaining, REQUEST_DEADLINE_S))
|
|
126
151
|
if resp.status_code in (401, 403):
|
|
127
|
-
log.warning("ENTSO-E rejected the token (HTTP %s) — using
|
|
128
|
-
resp.status_code)
|
|
152
|
+
log.warning("ENTSO-E rejected the token (HTTP %s) on %s — using fallback. "
|
|
153
|
+
"Check ENTSOE_TOKEN.", resp.status_code, label)
|
|
129
154
|
return None # auth: don't retry
|
|
130
155
|
resp.raise_for_status()
|
|
131
156
|
if resp.text and "<TimeSeries" in resp.text:
|
|
132
157
|
return resp.text
|
|
133
|
-
#
|
|
134
|
-
|
|
158
|
+
# 'No matching data' (common for A65 in some zones) means "not offered", not an
|
|
159
|
+
# outage: bail quietly without retrying or marking the zone down.
|
|
160
|
+
if no_match_is_final and "No matching data" in resp.text:
|
|
161
|
+
log.debug("ENTSO-E %s: no matching data for %s", label, eic)
|
|
162
|
+
return None
|
|
163
|
+
log.debug("ENTSO-E %s empty for %s (attempt %d/%d)", label, eic, attempt, _ATTEMPTS)
|
|
135
164
|
except Exception as e:
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
165
|
+
# describe_error, not e: the exception text embeds the URL, which carries the token.
|
|
166
|
+
log.debug("ENTSO-E %s failed for %s (attempt %d/%d): %s",
|
|
167
|
+
label, eic, attempt, _ATTEMPTS, describe_error(e))
|
|
168
|
+
if attempt < _ATTEMPTS:
|
|
169
|
+
pause = 0.6 * attempt # brief backoff: 0.6s, 1.2s — inside the same budget
|
|
170
|
+
if deadline - time.monotonic() <= pause + 0.5:
|
|
171
|
+
break
|
|
172
|
+
await asyncio.sleep(pause)
|
|
173
|
+
self._down_until[eic] = time.time() + _OUTAGE_COOLDOWN_S
|
|
174
|
+
log.info("ENTSO-E unavailable for %s — using the labelled fallback for the next %ds.",
|
|
175
|
+
eic, int(_OUTAGE_COOLDOWN_S))
|
|
139
176
|
return None
|
|
140
177
|
|
|
141
178
|
async def _fetch_forecast_xml(self, eic: str, document_type: str, hours_ahead: int,
|
|
@@ -153,31 +190,7 @@ class ENTSOERepository:
|
|
|
153
190
|
"periodEnd": end.strftime("%Y%m%d%H%M"),
|
|
154
191
|
}
|
|
155
192
|
params["outBiddingZone_Domain" if load else "in_Domain"] = eic
|
|
156
|
-
|
|
157
|
-
import asyncio
|
|
158
|
-
attempts = 3
|
|
159
|
-
for attempt in range(1, attempts + 1):
|
|
160
|
-
try:
|
|
161
|
-
async with httpx.AsyncClient(timeout=15) as client:
|
|
162
|
-
resp = await client.get(_URL, params=params)
|
|
163
|
-
if resp.status_code in (401, 403):
|
|
164
|
-
log.warning("ENTSO-E rejected the token (HTTP %s) on forecast — using fallback.",
|
|
165
|
-
resp.status_code)
|
|
166
|
-
return None
|
|
167
|
-
resp.raise_for_status()
|
|
168
|
-
if resp.text and "<TimeSeries" in resp.text:
|
|
169
|
-
return resp.text
|
|
170
|
-
# 'No matching data' (common for A65 in some zones) is not retryable — bail quietly.
|
|
171
|
-
if "No matching data" in resp.text:
|
|
172
|
-
log.debug("ENTSO-E %s: no matching data for %s", document_type, eic)
|
|
173
|
-
return None
|
|
174
|
-
log.debug("ENTSO-E %s empty for %s (attempt %d/%d)", document_type, eic, attempt, attempts)
|
|
175
|
-
except Exception as e:
|
|
176
|
-
log.debug("ENTSO-E %s failed for %s (attempt %d/%d): %s",
|
|
177
|
-
document_type, eic, attempt, attempts, e)
|
|
178
|
-
if attempt < attempts:
|
|
179
|
-
await asyncio.sleep(0.6 * attempt)
|
|
180
|
-
return None
|
|
193
|
+
return await self._get_bounded(eic, params, label=document_type, no_match_is_final=True)
|
|
181
194
|
|
|
182
195
|
async def renewable_forecast(self, provider: Provider, region: str, hours_ahead: int = 24
|
|
183
196
|
) -> list[ForecastHour]:
|
|
@@ -6,8 +6,7 @@
|
|
|
6
6
|
"us-west-2": { "name": "Oregon", "lat": 45.87, "lon": -119.69, "zone": "US-NW-PACW", "eia_ba": "BPAT", "jurisdiction": "US" },
|
|
7
7
|
"eu-west-1": { "name": "Ireland", "lat": 53.41, "lon": -8.24, "zone": "IE", "entsoe_eic": "10Y1001A1001A59C", "jurisdiction": "EEA" },
|
|
8
8
|
"eu-north-1": { "name": "Stockholm", "lat": 59.33, "lon": 18.07, "zone": "SE", "entsoe_eic": "10YSE-1--------K", "jurisdiction": "EEA" },
|
|
9
|
-
"ap-southeast-2": { "name": "Sydney", "lat": -33.87, "lon": 151.21, "zone": "AU-NSW", "jurisdiction": "AU" }
|
|
10
|
-
"us-east-nyc": { "name": "New York (NYISO)", "lat": 40.71, "lon": -74.01, "zone": "US-NY-NYIS", "eia_ba": "NYIS", "jurisdiction": "US" }
|
|
9
|
+
"ap-southeast-2": { "name": "Sydney", "lat": -33.87, "lon": 151.21, "zone": "AU-NSW", "jurisdiction": "AU" }
|
|
11
10
|
},
|
|
12
11
|
"gcp": {
|
|
13
12
|
"us-central1": { "name": "Iowa", "lat": 41.26, "lon": -95.86, "zone": "US-MIDW-MISO", "eia_ba": "MISO", "jurisdiction": "US" },
|
|
@@ -164,6 +164,7 @@ class TrainingCarbonAdvice(BaseModel):
|
|
|
164
164
|
gpu_name: str
|
|
165
165
|
gpu_count: int
|
|
166
166
|
gpu_hours: float # total accelerator-hours for the run
|
|
167
|
+
wall_clock_hours: Optional[float] = None # ~gpu_hours / gpu_count (GPUs run in parallel)
|
|
167
168
|
utilization: float
|
|
168
169
|
est_energy_kwh: float
|
|
169
170
|
# Placement:
|
|
@@ -314,7 +314,9 @@ async def estimate_training_carbon(
|
|
|
314
314
|
top_n_regions: int = 5,
|
|
315
315
|
) -> dict:
|
|
316
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
|
|
317
|
+
Give the GPU (e.g. 'a100', 'h100', 'l4'), how many (gpu_count), and gpu_hours = TOTAL
|
|
318
|
+
accelerator-hours across ALL GPUs — e.g. 8 GPUs running for 10 hours is gpu_hours=80, not 10.
|
|
319
|
+
The output echoes wall_clock_hours (~gpu_hours / gpu_count) for reporting. Omit
|
|
318
320
|
`region` to rank regions greenest-first; pass one to score just that region. Training is both
|
|
319
321
|
portable and deferrable, so this combines region choice with cleanest-hours timing. Directional
|
|
320
322
|
(TDP-based energy); GPU availability per region is NOT checked - verify your card is offered there.
|
|
@@ -129,6 +129,10 @@ async def data_locality_check(
|
|
|
129
129
|
warning = (f"Compute and data are separated {sep}: ~{monthly_cross_boundary_gb:,.0f} GB/mo would "
|
|
130
130
|
f"cross the boundary, costing {cost_txt}, plus ~{transfer_kg:.1f} kg CO2/mo of extra "
|
|
131
131
|
f"transfer. A greener-region move can be undone by this.")
|
|
132
|
+
if verdict is None:
|
|
133
|
+
# Point the caller at the optional input that turns this into a net win/backfire verdict.
|
|
134
|
+
warning += (" For a net verdict, pass carbon_saving_kg_co2_from_move (the kg CO2/mo the move "
|
|
135
|
+
"saves, e.g. the difference between estimate_workload_carbon in the two regions).")
|
|
132
136
|
|
|
133
137
|
if severity == "high":
|
|
134
138
|
rec = ("High exposure. Co-locate compute with the data, or move the data to the greener "
|
|
@@ -22,15 +22,35 @@ _HEADROOM = 1.4
|
|
|
22
22
|
_MIN_SAVING_PCT = 10.0
|
|
23
23
|
|
|
24
24
|
|
|
25
|
-
def
|
|
26
|
-
"""
|
|
27
|
-
|
|
25
|
+
def _needed(provisioned: float, utilization: Optional[float]) -> float:
|
|
26
|
+
"""Capacity actually needed = observed usage x headroom. If utilisation is unknown, assume the
|
|
27
|
+
dimension is needed in full (we only right-size what the user told us about)."""
|
|
28
28
|
if utilization is None:
|
|
29
29
|
return provisioned
|
|
30
30
|
u = max(0.0, min(1.0, utilization))
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
return provisioned * u * _HEADROOM
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# Standard instance shapes shared by AWS / GCP / Azure: vCPU counts step in powers of two (plus the
|
|
35
|
+
# common 48/96/192), and memory comes at ~2 GB/vCPU (compute-optimised), ~4 GB/vCPU (general
|
|
36
|
+
# purpose) or ~8 GB/vCPU (memory-optimised). Recommending only these shapes means the answer is a
|
|
37
|
+
# size you can actually buy, and the saving is priced on that size — not on a fractional ideal.
|
|
38
|
+
_VCPU_SIZES = (1, 2, 4, 8, 16, 32, 48, 64, 96, 128, 192)
|
|
39
|
+
_GB_PER_VCPU = (2, 4, 8)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _standard_size(need_vcpu: float, need_ram: float,
|
|
43
|
+
prov_vcpu: float, prov_ram: float) -> tuple[float, float]:
|
|
44
|
+
"""Smallest standard shape covering both needs, never larger than what's provisioned
|
|
45
|
+
(right-sizing only shrinks or holds). Falls back to the provisioned size if nothing fits."""
|
|
46
|
+
for v in _VCPU_SIZES:
|
|
47
|
+
if v < need_vcpu or v > prov_vcpu:
|
|
48
|
+
continue
|
|
49
|
+
for ratio in _GB_PER_VCPU:
|
|
50
|
+
ram = v * ratio
|
|
51
|
+
if ram >= need_ram and ram <= prov_ram:
|
|
52
|
+
return float(v), float(ram)
|
|
53
|
+
return prov_vcpu, prov_ram
|
|
34
54
|
|
|
35
55
|
|
|
36
56
|
async def rightsizing_check(
|
|
@@ -43,8 +63,9 @@ async def rightsizing_check(
|
|
|
43
63
|
ram_utilization: Optional[float] = None,
|
|
44
64
|
hours_per_month: float = 730,
|
|
45
65
|
) -> RightsizingAdvice:
|
|
46
|
-
rec_vcpu =
|
|
47
|
-
|
|
66
|
+
rec_vcpu, rec_ram = _standard_size(
|
|
67
|
+
_needed(vcpu, cpu_utilization), _needed(ram_gb, ram_utilization), vcpu, ram_gb,
|
|
68
|
+
)
|
|
48
69
|
|
|
49
70
|
now_wl = Workload(vcpu=vcpu, ram_gb=ram_gb, hours_per_month=hours_per_month)
|
|
50
71
|
rs_wl = Workload(vcpu=rec_vcpu, ram_gb=rec_ram, hours_per_month=hours_per_month)
|
|
@@ -66,8 +87,9 @@ async def rightsizing_check(
|
|
|
66
87
|
usd_txt = f"~${usd_saving:,.0f}/mo" if usd_saving is not None else "cost"
|
|
67
88
|
rationale = (
|
|
68
89
|
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
|
|
70
|
-
f"headroom) to cut ~{saving_pct:.0f}% - {usd_txt} AND
|
|
90
|
+
f"is bigger than you need. Right-size to {rec_vcpu:g} vCPU / {rec_ram:g} GB (the smallest "
|
|
91
|
+
f"standard size that keeps ~40% headroom) to cut ~{saving_pct:.0f}% - {usd_txt} AND "
|
|
92
|
+
f"~{kg_saving:.1f} kg CO2/mo. "
|
|
71
93
|
f"Same work, less waste: the cost and carbon win here are the same action.")
|
|
72
94
|
else:
|
|
73
95
|
rationale = (
|
|
@@ -88,6 +110,8 @@ async def rightsizing_check(
|
|
|
88
110
|
rationale=rationale,
|
|
89
111
|
limitations=("Directional, design-time estimate from the utilisation YOU report — not a "
|
|
90
112
|
"reading of your running fleet, and not a quote. Keeps ~40% headroom above "
|
|
91
|
-
"observed peak
|
|
113
|
+
"observed peak, rounded up to a standard shape (power-of-two vCPU at ~2/4/8 GB "
|
|
114
|
+
"per vCPU) — confirm your provider offers that size in the family you need; "
|
|
115
|
+
"validate against real load before resizing production. Cost "
|
|
92
116
|
"uses the dated static table; carbon = power model x live grid intensity."),
|
|
93
117
|
)
|
|
@@ -159,8 +159,13 @@ async def serving_pattern(
|
|
|
159
159
|
recommended, rationale = _decide(single_plan, per_hub, multi_wlat, multi_blended,
|
|
160
160
|
len(distinct_regions))
|
|
161
161
|
|
|
162
|
+
# Echo hubs with the coordinates actually used (named hubs like "eu" resolve to representative
|
|
163
|
+
# coords), so the output never shows a misleading 0,0 for a hub that was resolved by name.
|
|
164
|
+
resolved_hubs = [ServingHub(name=h.name, lat=pt.lat, lon=pt.lon, share=h.share)
|
|
165
|
+
for h, pt in zip(hubs, points)]
|
|
166
|
+
|
|
162
167
|
return ServingPatternAdvice(
|
|
163
|
-
hubs=
|
|
168
|
+
hubs=resolved_hubs, single_region=single_plan, multi_region=per_hub,
|
|
164
169
|
multi_region_weighted_latency_ms=multi_wlat,
|
|
165
170
|
multi_region_blended_carbon_g_co2_per_kwh=multi_blended,
|
|
166
171
|
recommended_pattern=recommended, data_sync_egress_note=data_sync_note,
|
|
@@ -89,6 +89,9 @@ async def estimate_training_carbon(
|
|
|
89
89
|
|
|
90
90
|
base = TrainingCarbonAdvice(
|
|
91
91
|
gpu=gpu_key, gpu_name=spec["name"], gpu_count=gpu_count, gpu_hours=gpu_hours,
|
|
92
|
+
# Echo the run's duration so callers report it rather than re-deriving it (a common slip
|
|
93
|
+
# is reading gpu_hours as per-GPU and multiplying by gpu_count again).
|
|
94
|
+
wall_clock_hours=round(gpu_hours / max(1, gpu_count), 1),
|
|
92
95
|
utilization=util, est_energy_kwh=energy, limitations=LIMITATIONS,
|
|
93
96
|
)
|
|
94
97
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: greenpick
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.5
|
|
4
4
|
Summary: Design-time sustainability co-pilot (MCP server): greener, cheaper cloud infrastructure decisions with carbon + cost + latency tradeoffs.
|
|
5
5
|
Author-email: "Data Science Advisers, LLC" <info@datascienceadvisers.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -13,6 +13,7 @@ src/greenpick.egg-info/entry_points.txt
|
|
|
13
13
|
src/greenpick.egg-info/requires.txt
|
|
14
14
|
src/greenpick.egg-info/top_level.txt
|
|
15
15
|
src/greenpick/data/__init__.py
|
|
16
|
+
src/greenpick/data/_net.py
|
|
16
17
|
src/greenpick/data/auto_repository.py
|
|
17
18
|
src/greenpick/data/eia_repository.py
|
|
18
19
|
src/greenpick/data/electricitymaps_repository.py
|
|
@@ -179,20 +179,20 @@ from greenpick.data.eia_repository import EIARepository
|
|
|
179
179
|
|
|
180
180
|
@pytest.mark.asyncio
|
|
181
181
|
async def test_eia_mix_to_carbon_intensity(monkeypatch):
|
|
182
|
-
#
|
|
182
|
+
# PJM latest hour: 1000 MWh gas (490) + 1000 MWh nuclear (12) -> weighted mean = 251 gCO2/kWh.
|
|
183
183
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
184
|
-
assert request.url.params.get("facets[respondent][]") == "
|
|
184
|
+
assert request.url.params.get("facets[respondent][]") == "PJM"
|
|
185
185
|
return httpx.Response(200, json={"response": {"data": [
|
|
186
|
-
{"period": "2026-10-11T18", "respondent": "
|
|
187
|
-
{"period": "2026-10-11T18", "respondent": "
|
|
188
|
-
{"period": "2026-10-11T17", "respondent": "
|
|
186
|
+
{"period": "2026-10-11T18", "respondent": "PJM", "fueltype": "NG", "value": "1000"},
|
|
187
|
+
{"period": "2026-10-11T18", "respondent": "PJM", "fueltype": "NUC", "value": "1000"},
|
|
188
|
+
{"period": "2026-10-11T17", "respondent": "PJM", "fueltype": "NG", "value": "9999"}, # older hour, ignored
|
|
189
189
|
]}})
|
|
190
190
|
|
|
191
191
|
orig = httpx.AsyncClient
|
|
192
192
|
monkeypatch.setattr(httpx, "AsyncClient",
|
|
193
193
|
lambda *a, **k: orig(transport=_mock_transport(handler)))
|
|
194
194
|
repo = EIARepository(api_key="key")
|
|
195
|
-
r = await repo.carbon_now("aws", "us-east-
|
|
195
|
+
r = await repo.carbon_now("aws", "us-east-1") # maps to BA PJM
|
|
196
196
|
assert r.source == "eia"
|
|
197
197
|
assert r.quality == "live"
|
|
198
198
|
assert abs(r.g_co2_per_kwh - 251.0) < 0.5 # (490+12)/2
|
|
@@ -209,7 +209,7 @@ async def test_eia_non_us_region_falls_back_to_mock():
|
|
|
209
209
|
@pytest.mark.asyncio
|
|
210
210
|
async def test_eia_no_key_uses_mock():
|
|
211
211
|
repo = EIARepository(api_key="")
|
|
212
|
-
r = await repo.carbon_now("aws", "us-east-
|
|
212
|
+
r = await repo.carbon_now("aws", "us-east-1")
|
|
213
213
|
assert r.quality == "mock"
|
|
214
214
|
|
|
215
215
|
|
|
@@ -601,7 +601,7 @@ async def test_serving_concentrated_users_prefer_single_region():
|
|
|
601
601
|
assert adv.single_region is not None
|
|
602
602
|
assert adv.recommended_pattern == "single_region"
|
|
603
603
|
# Single region should be a US region (nearest the only hub).
|
|
604
|
-
assert adv.single_region.region in {"us-east-1", "us-west-2", "us-
|
|
604
|
+
assert adv.single_region.region in {"us-east-1", "us-west-2", "us-central1",
|
|
605
605
|
"us-west1", "eastus"}
|
|
606
606
|
|
|
607
607
|
|
|
@@ -982,7 +982,7 @@ async def test_cleanest_time_of_day_averages_across_days(monkeypatch):
|
|
|
982
982
|
monkeypatch.setattr(httpx, "AsyncClient",
|
|
983
983
|
lambda *a, **k: orig(transport=_mock_transport(handler)))
|
|
984
984
|
repo = EIARepository(api_key="k")
|
|
985
|
-
adv = await scheduling_service.cleanest_time_of_day(repo, "aws", "us-east-
|
|
985
|
+
adv = await scheduling_service.cleanest_time_of_day(repo, "aws", "us-east-1")
|
|
986
986
|
assert adv.days_analyzed == 3
|
|
987
987
|
assert adv.cleanest_hour_of_day == 2 # nuclear-heavy hour, every day
|
|
988
988
|
assert adv.dirtiest_hour_of_day == 18 # gas-heavy hour, every day
|
|
@@ -1072,7 +1072,165 @@ async def test_cleanest_hours_eia_multihour(monkeypatch):
|
|
|
1072
1072
|
monkeypatch.setattr(httpx, "AsyncClient",
|
|
1073
1073
|
lambda *a, **k: orig(transport=_mock_transport(handler)))
|
|
1074
1074
|
repo = EIARepository(api_key="k")
|
|
1075
|
-
hist = await repo.carbon_history("aws", "us-east-
|
|
1075
|
+
hist = await repo.carbon_history("aws", "us-east-1")
|
|
1076
1076
|
periods = {h.period: h.g_co2_per_kwh for h in hist}
|
|
1077
1077
|
assert set(periods) == {"2026-10-12T18", "2026-10-12T19"}
|
|
1078
1078
|
assert periods["2026-10-12T18"] < periods["2026-10-12T19"] # nuclear-heavy hour cleaner
|
|
1079
|
+
|
|
1080
|
+
|
|
1081
|
+
# --- 0.1.4 regressions: findings from outside-agent testing ---
|
|
1082
|
+
|
|
1083
|
+
@pytest.mark.asyncio
|
|
1084
|
+
async def test_rightsizing_recommends_a_standard_buyable_size():
|
|
1085
|
+
from greenpick.services import rightsizing_service
|
|
1086
|
+
repo = MockCarbonRepository()
|
|
1087
|
+
# 8 vCPU / 32 GB at 15% CPU, 20% RAM: the raw fit is ~1.7 vCPU / ~9 GB, which no cloud sells.
|
|
1088
|
+
adv = await rightsizing_service.rightsizing_check(
|
|
1089
|
+
repo, "aws", "us-east-1", vcpu=8, ram_gb=32, cpu_utilization=0.15, ram_utilization=0.20,
|
|
1090
|
+
)
|
|
1091
|
+
assert adv.is_oversized
|
|
1092
|
+
assert (adv.recommended_vcpu, adv.recommended_ram_gb) == (2.0, 16.0) # smallest shape that fits
|
|
1093
|
+
assert adv.recommended_ram_gb / adv.recommended_vcpu in (2, 4, 8)
|
|
1094
|
+
assert "standard size" in adv.rationale
|
|
1095
|
+
|
|
1096
|
+
|
|
1097
|
+
@pytest.mark.asyncio
|
|
1098
|
+
async def test_rightsizing_unknown_ram_keeps_ram_and_picks_a_real_shape():
|
|
1099
|
+
from greenpick.services import rightsizing_service
|
|
1100
|
+
repo = MockCarbonRepository()
|
|
1101
|
+
# RAM utilisation not given -> keep 32 GB; 2 vCPU / 32 GB isn't a standard shape, so 4 / 32.
|
|
1102
|
+
adv = await rightsizing_service.rightsizing_check(
|
|
1103
|
+
repo, "aws", "us-east-1", vcpu=8, ram_gb=32, cpu_utilization=0.18,
|
|
1104
|
+
)
|
|
1105
|
+
assert (adv.recommended_vcpu, adv.recommended_ram_gb) == (4.0, 32.0)
|
|
1106
|
+
|
|
1107
|
+
|
|
1108
|
+
@pytest.mark.asyncio
|
|
1109
|
+
async def test_training_echoes_wall_clock_hours():
|
|
1110
|
+
from greenpick.services import training_service
|
|
1111
|
+
repo = MockCarbonRepository()
|
|
1112
|
+
adv = await training_service.estimate_training_carbon(
|
|
1113
|
+
repo, gpu="a100", gpu_count=8, gpu_hours=150, region="eu-north-1", provider="aws",
|
|
1114
|
+
)
|
|
1115
|
+
# gpu_hours is TOTAL across GPUs: 150 GPU-hours on 8 GPUs runs ~18.8 h, not 150 h.
|
|
1116
|
+
assert adv.wall_clock_hours == 18.8
|
|
1117
|
+
|
|
1118
|
+
|
|
1119
|
+
@pytest.mark.asyncio
|
|
1120
|
+
async def test_egress_points_to_saving_input_only_when_missing():
|
|
1121
|
+
from greenpick.services import locality_service
|
|
1122
|
+
repo = MockCarbonRepository()
|
|
1123
|
+
without = await locality_service.data_locality_check(
|
|
1124
|
+
repo, "aws", "us-west-2", "aws", "us-east-1", 500,
|
|
1125
|
+
)
|
|
1126
|
+
assert "carbon_saving_kg_co2_from_move" in without.warning
|
|
1127
|
+
with_saving = await locality_service.data_locality_check(
|
|
1128
|
+
repo, "aws", "us-west-2", "aws", "us-east-1", 500, carbon_saving_kg_co2_from_move=10.0,
|
|
1129
|
+
)
|
|
1130
|
+
assert with_saving.net_carbon_verdict is not None
|
|
1131
|
+
assert "carbon_saving_kg_co2_from_move" not in with_saving.warning
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
@pytest.mark.asyncio
|
|
1135
|
+
async def test_serving_echoes_resolved_hub_coordinates():
|
|
1136
|
+
from greenpick.services import serving_service
|
|
1137
|
+
repo = MockCarbonRepository()
|
|
1138
|
+
hubs = [ServingHub(name="us", lat=0, lon=0, share=0.5), ServingHub(name="eu", lat=0, lon=0, share=0.5)]
|
|
1139
|
+
adv = await serving_service.serving_pattern(repo, hubs, Workload(vcpu=1, ram_gb=2))
|
|
1140
|
+
assert all((h.lat, h.lon) != (0.0, 0.0) for h in adv.hubs)
|
|
1141
|
+
|
|
1142
|
+
|
|
1143
|
+
def test_catalog_has_no_invented_aws_new_york_region():
|
|
1144
|
+
from greenpick.data.static_data import regions
|
|
1145
|
+
assert "us-east-nyc" not in regions()["aws"]
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
# --- 0.1.5: secrets never reach logs; a slow upstream can't freeze a tool call ---
|
|
1149
|
+
|
|
1150
|
+
import logging
|
|
1151
|
+
|
|
1152
|
+
|
|
1153
|
+
def test_http_libraries_do_not_log_request_urls():
|
|
1154
|
+
import greenpick # noqa: F401 (import side effect sets the levels)
|
|
1155
|
+
# httpx logs full request URLs (which carry our API keys) at INFO; we keep it at WARNING.
|
|
1156
|
+
assert logging.getLogger("httpx").getEffectiveLevel() >= logging.WARNING
|
|
1157
|
+
assert logging.getLogger("httpcore").getEffectiveLevel() >= logging.WARNING
|
|
1158
|
+
|
|
1159
|
+
|
|
1160
|
+
@pytest.mark.asyncio
|
|
1161
|
+
async def test_eia_key_never_appears_in_logs(monkeypatch, caplog):
|
|
1162
|
+
caplog.set_level(logging.DEBUG)
|
|
1163
|
+
secret = "EIA-SECRET-123"
|
|
1164
|
+
|
|
1165
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
1166
|
+
return httpx.Response(500, text="boom") # raise_for_status -> exception text embeds URL
|
|
1167
|
+
|
|
1168
|
+
orig = httpx.AsyncClient
|
|
1169
|
+
monkeypatch.setattr(httpx, "AsyncClient",
|
|
1170
|
+
lambda *a, **k: orig(transport=_mock_transport(handler)))
|
|
1171
|
+
r = await EIARepository(api_key=secret).carbon_now("aws", "us-east-1")
|
|
1172
|
+
assert r.quality == "mock"
|
|
1173
|
+
assert secret not in caplog.text
|
|
1174
|
+
|
|
1175
|
+
|
|
1176
|
+
@pytest.mark.asyncio
|
|
1177
|
+
async def test_entsoe_token_never_appears_in_logs(monkeypatch, caplog):
|
|
1178
|
+
caplog.set_level(logging.DEBUG)
|
|
1179
|
+
secret = "ENTSOE-SECRET-456"
|
|
1180
|
+
|
|
1181
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
1182
|
+
return httpx.Response(503, text="unavailable")
|
|
1183
|
+
|
|
1184
|
+
orig = httpx.AsyncClient
|
|
1185
|
+
monkeypatch.setattr(httpx, "AsyncClient",
|
|
1186
|
+
lambda *a, **k: orig(transport=_mock_transport(handler)))
|
|
1187
|
+
monkeypatch.setattr("greenpick.data.entsoe_repository.asyncio.sleep", _no_sleep)
|
|
1188
|
+
r = await ENTSOERepository(token=secret).carbon_now("aws", "eu-north-1")
|
|
1189
|
+
assert r.quality == "mock"
|
|
1190
|
+
assert secret not in caplog.text
|
|
1191
|
+
|
|
1192
|
+
|
|
1193
|
+
async def _no_sleep(_seconds):
|
|
1194
|
+
return None
|
|
1195
|
+
|
|
1196
|
+
|
|
1197
|
+
@pytest.mark.asyncio
|
|
1198
|
+
async def test_entsoe_slow_upstream_is_bounded(monkeypatch):
|
|
1199
|
+
import asyncio, time as _time
|
|
1200
|
+
from greenpick.data import entsoe_repository as er
|
|
1201
|
+
|
|
1202
|
+
async def handler(request: httpx.Request) -> httpx.Response:
|
|
1203
|
+
await asyncio.sleep(30) # an upstream that never answers in time
|
|
1204
|
+
return httpx.Response(200, text="")
|
|
1205
|
+
|
|
1206
|
+
orig = httpx.AsyncClient
|
|
1207
|
+
monkeypatch.setattr(httpx, "AsyncClient",
|
|
1208
|
+
lambda *a, **k: orig(transport=_mock_transport(handler)))
|
|
1209
|
+
monkeypatch.setattr(er, "_FETCH_BUDGET_S", 1.0)
|
|
1210
|
+
start = _time.monotonic()
|
|
1211
|
+
r = await ENTSOERepository(token="t").carbon_now("aws", "eu-north-1")
|
|
1212
|
+
assert r.quality == "mock" # degraded to the labelled fallback...
|
|
1213
|
+
assert _time.monotonic() - start < 3.0 # ...within the budget, not 30s+
|
|
1214
|
+
|
|
1215
|
+
|
|
1216
|
+
@pytest.mark.asyncio
|
|
1217
|
+
async def test_entsoe_remembers_an_outage(monkeypatch):
|
|
1218
|
+
calls = {"n": 0}
|
|
1219
|
+
|
|
1220
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
1221
|
+
calls["n"] += 1
|
|
1222
|
+
raise httpx.ConnectError("down", request=request)
|
|
1223
|
+
|
|
1224
|
+
orig = httpx.AsyncClient
|
|
1225
|
+
monkeypatch.setattr(httpx, "AsyncClient",
|
|
1226
|
+
lambda *a, **k: orig(transport=_mock_transport(handler)))
|
|
1227
|
+
monkeypatch.setattr("greenpick.data.entsoe_repository.asyncio.sleep", _no_sleep)
|
|
1228
|
+
repo = ENTSOERepository(token="t")
|
|
1229
|
+
await repo.carbon_now("aws", "eu-north-1")
|
|
1230
|
+
after_first = calls["n"]
|
|
1231
|
+
assert after_first >= 1
|
|
1232
|
+
# Same zone again (now, and as history): skipped immediately, no new upstream calls.
|
|
1233
|
+
again = await repo.carbon_now("aws", "eu-north-1")
|
|
1234
|
+
await repo.carbon_history("aws", "eu-north-1")
|
|
1235
|
+
assert again.quality == "mock"
|
|
1236
|
+
assert calls["n"] == after_first
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|