greenpick 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- greenpick/__init__.py +7 -0
- greenpick/__main__.py +18 -0
- greenpick/config.py +58 -0
- greenpick/data/__init__.py +28 -0
- greenpick/data/auto_repository.py +47 -0
- greenpick/data/eia_repository.py +156 -0
- greenpick/data/electricitymaps_repository.py +104 -0
- greenpick/data/emission_factors.json +35 -0
- greenpick/data/entsoe_repository.py +401 -0
- greenpick/data/gpu_power.json +26 -0
- greenpick/data/inference_energy.json +50 -0
- greenpick/data/instance_families.json +32 -0
- greenpick/data/mock_repository.py +53 -0
- greenpick/data/network.json +7 -0
- greenpick/data/power.json +7 -0
- greenpick/data/pricing.json +31 -0
- greenpick/data/regions.json +22 -0
- greenpick/data/repository.py +27 -0
- greenpick/data/static_data.py +105 -0
- greenpick/models.py +342 -0
- greenpick/server.py +349 -0
- greenpick/services/__init__.py +5 -0
- greenpick/services/carbon_service.py +28 -0
- greenpick/services/cost_service.py +21 -0
- greenpick/services/forecast_service.py +72 -0
- greenpick/services/inference_service.py +100 -0
- greenpick/services/instance_service.py +132 -0
- greenpick/services/latency_service.py +33 -0
- greenpick/services/locality_service.py +160 -0
- greenpick/services/metric_scaffold_service.py +149 -0
- greenpick/services/recommender.py +203 -0
- greenpick/services/residency_service.py +53 -0
- greenpick/services/rightsizing_service.py +93 -0
- greenpick/services/scheduling_service.py +211 -0
- greenpick/services/serving_service.py +202 -0
- greenpick/services/training_service.py +209 -0
- greenpick-0.1.0.dist-info/METADATA +271 -0
- greenpick-0.1.0.dist-info/RECORD +42 -0
- greenpick-0.1.0.dist-info/WHEEL +5 -0
- greenpick-0.1.0.dist-info/entry_points.txt +2 -0
- greenpick-0.1.0.dist-info/licenses/LICENSE +21 -0
- greenpick-0.1.0.dist-info/top_level.txt +1 -0
greenpick/__init__.py
ADDED
greenpick/__main__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Entry point: run the GreenPick MCP server over stdio.
|
|
2
|
+
|
|
3
|
+
python -m greenpick
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from .config import SETTINGS
|
|
9
|
+
from .server import mcp
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main() -> None:
|
|
13
|
+
logging.basicConfig(level=getattr(logging, SETTINGS.log_level, logging.INFO))
|
|
14
|
+
mcp.run() # stdio transport by default — what MCP clients expect
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
if __name__ == "__main__":
|
|
18
|
+
main()
|
greenpick/config.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Runtime configuration, read from environment (see .env.example).
|
|
2
|
+
|
|
3
|
+
Kept tiny and dependency-light: just os.environ, no settings framework.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
# Load the project-root .env BEFORE reading settings, with NO third-party dependency.
|
|
11
|
+
# Why not python-dotenv: the MCP client may launch the server with an interpreter where the
|
|
12
|
+
# ambiguous `dotenv` import resolves to the wrong package (collision between python-dotenv and
|
|
13
|
+
# the unrelated `dotenv` pkg), silently skipping the load -> everything defaults to mock. A .env
|
|
14
|
+
# is just KEY=value lines, so we parse it ourselves, deterministically by path. A real environment
|
|
15
|
+
# variable (e.g. set in the MCP server's `env` block) still WINS — we don't overwrite os.environ.
|
|
16
|
+
def _load_project_dotenv() -> None:
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
env_path = Path(__file__).resolve().parents[2] / ".env" # src/greenpick/config.py -> project root
|
|
19
|
+
try:
|
|
20
|
+
text = env_path.read_text(encoding="utf-8")
|
|
21
|
+
except OSError:
|
|
22
|
+
return
|
|
23
|
+
for line in text.splitlines():
|
|
24
|
+
line = line.strip()
|
|
25
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
26
|
+
continue
|
|
27
|
+
key, _, val = line.partition("=")
|
|
28
|
+
key = key.strip()
|
|
29
|
+
val = val.strip().strip('"').strip("'")
|
|
30
|
+
if key and key not in os.environ: # real env wins; don't clobber
|
|
31
|
+
os.environ[key] = val
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_load_project_dotenv()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Settings:
|
|
39
|
+
carbon_source: str # mock | eia | entsoe | electricitymaps | auto
|
|
40
|
+
electricitymaps_token: str
|
|
41
|
+
eia_api_key: str
|
|
42
|
+
entsoe_token: str
|
|
43
|
+
cache_ttl_seconds: int
|
|
44
|
+
log_level: str
|
|
45
|
+
|
|
46
|
+
@staticmethod
|
|
47
|
+
def from_env() -> "Settings":
|
|
48
|
+
return Settings(
|
|
49
|
+
carbon_source=os.environ.get("GREENPICK_CARBON_SOURCE", "mock").strip().lower(),
|
|
50
|
+
electricitymaps_token=os.environ.get("ELECTRICITYMAPS_TOKEN", "").strip(),
|
|
51
|
+
eia_api_key=os.environ.get("EIA_API_KEY", "").strip(),
|
|
52
|
+
entsoe_token=os.environ.get("ENTSOE_TOKEN", "").strip(),
|
|
53
|
+
cache_ttl_seconds=int(os.environ.get("GREENPICK_CACHE_TTL_SECONDS", "1800")),
|
|
54
|
+
log_level=os.environ.get("GREENPICK_LOG_LEVEL", "INFO").strip().upper(),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
SETTINGS = Settings.from_env()
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Data layer: swappable carbon sources behind one repository interface.
|
|
2
|
+
|
|
3
|
+
`get_repository()` is the single place that reads config and picks a source, so the rest
|
|
4
|
+
of the app never hard-codes a provider.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from ..config import SETTINGS
|
|
9
|
+
from .repository import CarbonRepository
|
|
10
|
+
from .mock_repository import MockCarbonRepository
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_repository() -> CarbonRepository:
|
|
14
|
+
source = SETTINGS.carbon_source
|
|
15
|
+
if source == "electricitymaps":
|
|
16
|
+
from .electricitymaps_repository import ElectricityMapsRepository
|
|
17
|
+
return ElectricityMapsRepository(SETTINGS.electricitymaps_token, SETTINGS.cache_ttl_seconds)
|
|
18
|
+
if source == "eia":
|
|
19
|
+
from .eia_repository import EIARepository
|
|
20
|
+
return EIARepository(SETTINGS.eia_api_key, SETTINGS.cache_ttl_seconds)
|
|
21
|
+
if source == "entsoe":
|
|
22
|
+
from .entsoe_repository import ENTSOERepository
|
|
23
|
+
return ENTSOERepository(SETTINGS.entsoe_token, SETTINGS.cache_ttl_seconds)
|
|
24
|
+
if source == "auto":
|
|
25
|
+
# Route per region: EIA for US (eia_ba), ENTSO-E for EU (entsoe_eic), else mock.
|
|
26
|
+
from .auto_repository import AutoRepository
|
|
27
|
+
return AutoRepository(SETTINGS.eia_api_key, SETTINGS.entsoe_token, SETTINGS.cache_ttl_seconds)
|
|
28
|
+
return MockCarbonRepository()
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Auto router: pick the right live source per region.
|
|
2
|
+
|
|
3
|
+
- US regions (have an `eia_ba`) -> EIA
|
|
4
|
+
- EU regions (have an `entsoe_eic`) -> ENTSO-E
|
|
5
|
+
- anything else -> mock (honestly labelled)
|
|
6
|
+
|
|
7
|
+
Lets a single GREENPICK_CARBON_SOURCE=auto give live data on both continents.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from ..models import CarbonReading, Provider, HourlyCarbon, ForecastHour
|
|
12
|
+
from .mock_repository import MockCarbonRepository
|
|
13
|
+
from .eia_repository import EIARepository
|
|
14
|
+
from .entsoe_repository import ENTSOERepository
|
|
15
|
+
from .static_data import region_meta
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AutoRepository:
|
|
19
|
+
def __init__(self, eia_key: str, entsoe_token: str, cache_ttl_seconds: int = 1800) -> None:
|
|
20
|
+
self._eia = EIARepository(eia_key, cache_ttl_seconds)
|
|
21
|
+
self._entsoe = ENTSOERepository(entsoe_token, cache_ttl_seconds)
|
|
22
|
+
self._mock = MockCarbonRepository()
|
|
23
|
+
|
|
24
|
+
async def carbon_now(self, provider: Provider, region: str) -> CarbonReading:
|
|
25
|
+
meta = region_meta(provider, region) or {}
|
|
26
|
+
if meta.get("eia_ba"):
|
|
27
|
+
return await self._eia.carbon_now(provider, region)
|
|
28
|
+
if meta.get("entsoe_eic"):
|
|
29
|
+
return await self._entsoe.carbon_now(provider, region)
|
|
30
|
+
return await self._mock.carbon_now(provider, region)
|
|
31
|
+
|
|
32
|
+
async def carbon_history(self, provider: Provider, region: str) -> list[HourlyCarbon]:
|
|
33
|
+
meta = region_meta(provider, region) or {}
|
|
34
|
+
if meta.get("eia_ba"):
|
|
35
|
+
return await self._eia.carbon_history(provider, region)
|
|
36
|
+
if meta.get("entsoe_eic"):
|
|
37
|
+
return await self._entsoe.carbon_history(provider, region)
|
|
38
|
+
return await self._mock.carbon_history(provider, region)
|
|
39
|
+
|
|
40
|
+
async def renewable_forecast(self, provider: Provider, region: str, hours_ahead: int = 24
|
|
41
|
+
) -> list[ForecastHour]:
|
|
42
|
+
"""Forward renewable forecast — only EU (ENTSO-E A69) has a free one. US/other -> []
|
|
43
|
+
so the scheduling layer degrades to the historical pattern, labelled honestly."""
|
|
44
|
+
meta = region_meta(provider, region) or {}
|
|
45
|
+
if meta.get("entsoe_eic"):
|
|
46
|
+
return await self._entsoe.renewable_forecast(provider, region, hours_ahead)
|
|
47
|
+
return []
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""EIA-930 carbon source (US balancing authorities).
|
|
2
|
+
|
|
3
|
+
Free US government data — no license tiers or activation gating (unlike Electricity Maps'
|
|
4
|
+
free tier). We pull the latest hourly generation mix by fuel type for a region's balancing
|
|
5
|
+
authority, then weight by per-fuel emission factors to get a directional gCO2/kWh.
|
|
6
|
+
|
|
7
|
+
Key (free, instant): https://www.eia.gov/opendata/ -> Register.
|
|
8
|
+
Endpoint: GET https://api.eia.gov/v2/electricity/rto/fuel-type-data/data/
|
|
9
|
+
params: api_key, frequency=hourly, data[0]=value, facets[respondent][]=<BA>,
|
|
10
|
+
sort by period desc, length=... (we grab the most recent hour's fuels)
|
|
11
|
+
rows: { period, respondent, fueltype (COL/NG/NUC/WAT/WND/SUN/...), value (MWh), ... }
|
|
12
|
+
|
|
13
|
+
Carbon intensity = sum(gen_fuel * factor_fuel) / sum(gen_fuel). Directional, not audited.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
import time
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
|
|
23
|
+
from ..models import CarbonReading, Provider, HourlyCarbon
|
|
24
|
+
from .mock_repository import MockCarbonRepository
|
|
25
|
+
from .static_data import region_meta, emission_factors
|
|
26
|
+
|
|
27
|
+
log = logging.getLogger("greenpick.eia")
|
|
28
|
+
|
|
29
|
+
_URL = "https://api.eia.gov/v2/electricity/rto/fuel-type-data/data/"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class EIARepository:
|
|
33
|
+
def __init__(self, api_key: str, cache_ttl_seconds: int = 1800) -> None:
|
|
34
|
+
self._key = api_key
|
|
35
|
+
self._ttl = cache_ttl_seconds
|
|
36
|
+
self._cache: dict[str, tuple[float, CarbonReading]] = {} # BA -> (fetched_at, reading)
|
|
37
|
+
self._fallback = MockCarbonRepository()
|
|
38
|
+
self._warned_no_key = False
|
|
39
|
+
|
|
40
|
+
async def carbon_now(self, provider: Provider, region: str) -> CarbonReading:
|
|
41
|
+
meta = region_meta(provider, region) or {}
|
|
42
|
+
ba = meta.get("eia_ba")
|
|
43
|
+
|
|
44
|
+
if not self._key:
|
|
45
|
+
if not self._warned_no_key:
|
|
46
|
+
log.warning("No EIA_API_KEY set — falling back to mock. Register free at "
|
|
47
|
+
"https://www.eia.gov/opendata/")
|
|
48
|
+
self._warned_no_key = True
|
|
49
|
+
return await self._fallback.carbon_now(provider, region)
|
|
50
|
+
if not ba:
|
|
51
|
+
# Non-US region (no balancing authority) -> EIA can't help; mock, labelled.
|
|
52
|
+
return await self._fallback.carbon_now(provider, region)
|
|
53
|
+
|
|
54
|
+
hit = self._cache.get(ba)
|
|
55
|
+
if hit and (time.time() - hit[0]) < self._ttl:
|
|
56
|
+
cached = hit[1]
|
|
57
|
+
return cached.model_copy(update={"region": region, "provider": provider, "quality": "cached"})
|
|
58
|
+
|
|
59
|
+
rows = await self._fetch_rows(ba)
|
|
60
|
+
if rows is None:
|
|
61
|
+
return await self._fallback.carbon_now(provider, region)
|
|
62
|
+
reading = self._mix_to_reading(provider, region, ba, rows)
|
|
63
|
+
if reading is None:
|
|
64
|
+
return await self._fallback.carbon_now(provider, region)
|
|
65
|
+
self._cache[ba] = (time.time(), reading)
|
|
66
|
+
return reading
|
|
67
|
+
|
|
68
|
+
async def _fetch_rows(self, ba: str) -> list | None:
|
|
69
|
+
"""Fetch recent hourly fuel rows for a BA. Returns None on auth/network error
|
|
70
|
+
(caller falls back to mock)."""
|
|
71
|
+
try:
|
|
72
|
+
params = {
|
|
73
|
+
"api_key": self._key,
|
|
74
|
+
"frequency": "hourly",
|
|
75
|
+
"data[0]": "value",
|
|
76
|
+
"facets[respondent][]": ba,
|
|
77
|
+
"sort[0][column]": "period",
|
|
78
|
+
"sort[0][direction]": "desc",
|
|
79
|
+
"length": "2000", # ~5-7 days of hourly rows x fuels — enough for a daily pattern
|
|
80
|
+
}
|
|
81
|
+
async with httpx.AsyncClient(timeout=12) as client:
|
|
82
|
+
resp = await client.get(_URL, params=params)
|
|
83
|
+
if resp.status_code in (401, 403):
|
|
84
|
+
log.warning("EIA rejected the key (HTTP %s) — using mock. Check EIA_API_KEY.",
|
|
85
|
+
resp.status_code)
|
|
86
|
+
return None
|
|
87
|
+
resp.raise_for_status()
|
|
88
|
+
return resp.json().get("response", {}).get("data", [])
|
|
89
|
+
except Exception as e:
|
|
90
|
+
log.debug("EIA call failed for BA %s (%s) — mock fallback", ba, e)
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
async def carbon_history(self, provider: Provider, region: str) -> list[HourlyCarbon]:
|
|
94
|
+
"""Per-hour carbon intensity from the recent EIA window (same call as carbon_now)."""
|
|
95
|
+
meta = region_meta(provider, region) or {}
|
|
96
|
+
ba = meta.get("eia_ba")
|
|
97
|
+
if not self._key or not ba:
|
|
98
|
+
return await self._fallback.carbon_history(provider, region)
|
|
99
|
+
rows = await self._fetch_rows(ba)
|
|
100
|
+
if not rows:
|
|
101
|
+
return await self._fallback.carbon_history(provider, region)
|
|
102
|
+
factors = emission_factors()["factors_g_co2_per_kwh"]
|
|
103
|
+
# Group fuel rows by hour, compute weighted intensity per hour.
|
|
104
|
+
by_hour: dict[str, list] = {}
|
|
105
|
+
for r in rows:
|
|
106
|
+
by_hour.setdefault(r.get("period"), []).append(r)
|
|
107
|
+
out: list[HourlyCarbon] = []
|
|
108
|
+
for period, hour_rows in by_hour.items():
|
|
109
|
+
total = weighted = 0.0
|
|
110
|
+
for r in hour_rows:
|
|
111
|
+
fuel = r.get("fueltype")
|
|
112
|
+
try:
|
|
113
|
+
mwh = float(r.get("value") or 0)
|
|
114
|
+
except (TypeError, ValueError):
|
|
115
|
+
continue
|
|
116
|
+
if mwh > 0 and fuel in factors:
|
|
117
|
+
total += mwh
|
|
118
|
+
weighted += mwh * factors[fuel]
|
|
119
|
+
if total > 0:
|
|
120
|
+
out.append(HourlyCarbon(period=period, g_co2_per_kwh=round(weighted / total, 1)))
|
|
121
|
+
return out or await self._fallback.carbon_history(provider, region)
|
|
122
|
+
|
|
123
|
+
def _mix_to_reading(self, provider: Provider, region: str, ba: str, rows: list) -> CarbonReading | None:
|
|
124
|
+
"""Take the most recent hour's fuel rows, weight by emission factors -> gCO2/kWh."""
|
|
125
|
+
if not rows:
|
|
126
|
+
return None
|
|
127
|
+
# Rows are sorted period desc; the first row's period is the latest available hour.
|
|
128
|
+
latest_period = rows[0].get("period")
|
|
129
|
+
factors = emission_factors()["factors_g_co2_per_kwh"]
|
|
130
|
+
|
|
131
|
+
total_mwh = 0.0
|
|
132
|
+
weighted = 0.0
|
|
133
|
+
for r in rows:
|
|
134
|
+
if r.get("period") != latest_period:
|
|
135
|
+
break # only the most recent hour
|
|
136
|
+
fuel = r.get("fueltype")
|
|
137
|
+
try:
|
|
138
|
+
mwh = float(r.get("value") or 0)
|
|
139
|
+
except (TypeError, ValueError):
|
|
140
|
+
continue
|
|
141
|
+
if mwh <= 0 or fuel not in factors:
|
|
142
|
+
continue
|
|
143
|
+
total_mwh += mwh
|
|
144
|
+
weighted += mwh * factors[fuel]
|
|
145
|
+
|
|
146
|
+
if total_mwh <= 0:
|
|
147
|
+
return None
|
|
148
|
+
intensity = weighted / total_mwh
|
|
149
|
+
return CarbonReading(
|
|
150
|
+
region=region,
|
|
151
|
+
provider=provider,
|
|
152
|
+
g_co2_per_kwh=round(intensity, 1),
|
|
153
|
+
source="eia",
|
|
154
|
+
timestamp=str(latest_period or datetime.now(timezone.utc).isoformat()),
|
|
155
|
+
quality="live",
|
|
156
|
+
)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Electricity Maps free-tier carbon source.
|
|
2
|
+
|
|
3
|
+
Live grid carbon intensity per zone, with honest degradation:
|
|
4
|
+
- zone not on the free tier / not mapped -> mock fallback (labelled quality="mock")
|
|
5
|
+
- network / rate-limit error -> mock fallback
|
|
6
|
+
- bad or missing token -> mock fallback (warned once; it's a config issue)
|
|
7
|
+
- value flagged isEstimated by the API -> quality="live_estimated" (still real, but modelled)
|
|
8
|
+
|
|
9
|
+
Free tier + token: https://electricitymaps.com/free-tier
|
|
10
|
+
API: GET https://api.electricitymap.org/v3/carbon-intensity/latest?zone=XX
|
|
11
|
+
header: auth-token: <token>
|
|
12
|
+
-> { "carbonIntensity": <gCO2/kWh>, "datetime": "...", "isEstimated": bool, ... }
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
import time
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
from ..models import CarbonReading, Provider, HourlyCarbon
|
|
23
|
+
from .mock_repository import MockCarbonRepository
|
|
24
|
+
from .static_data import region_meta
|
|
25
|
+
|
|
26
|
+
log = logging.getLogger("greenpick.electricitymaps")
|
|
27
|
+
|
|
28
|
+
_BASE = "https://api.electricitymap.org/v3/carbon-intensity/latest"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ElectricityMapsRepository:
|
|
32
|
+
def __init__(self, token: str, cache_ttl_seconds: int = 1800) -> None:
|
|
33
|
+
self._token = token
|
|
34
|
+
self._ttl = cache_ttl_seconds
|
|
35
|
+
self._cache: dict[str, tuple[float, CarbonReading]] = {} # zone -> (fetched_at, reading)
|
|
36
|
+
self._fallback = MockCarbonRepository()
|
|
37
|
+
self._token_rejected = False # once a 401/403 is seen, stop calling with a bad key
|
|
38
|
+
self._warned_no_token = False
|
|
39
|
+
|
|
40
|
+
async def carbon_history(self, provider: Provider, region: str) -> list[HourlyCarbon]:
|
|
41
|
+
# Not implemented for this (unused) source; degrade to the fallback curve.
|
|
42
|
+
return await self._fallback.carbon_history(provider, region)
|
|
43
|
+
|
|
44
|
+
async def carbon_now(self, provider: Provider, region: str) -> CarbonReading:
|
|
45
|
+
meta = region_meta(provider, region)
|
|
46
|
+
zone = (meta or {}).get("zone")
|
|
47
|
+
|
|
48
|
+
if not self._token:
|
|
49
|
+
if not self._warned_no_token:
|
|
50
|
+
log.warning("No ELECTRICITYMAPS_TOKEN set — falling back to mock data. "
|
|
51
|
+
"Get a free key at https://electricitymaps.com/free-tier")
|
|
52
|
+
self._warned_no_token = True
|
|
53
|
+
return await self._fallback.carbon_now(provider, region)
|
|
54
|
+
|
|
55
|
+
if self._token_rejected or not zone:
|
|
56
|
+
# Bad key already seen, or no zone mapping for this region -> mock, labelled.
|
|
57
|
+
return await self._fallback.carbon_now(provider, region)
|
|
58
|
+
|
|
59
|
+
# Fresh cache hit?
|
|
60
|
+
hit = self._cache.get(zone)
|
|
61
|
+
if hit and (time.time() - hit[0]) < self._ttl:
|
|
62
|
+
cached = hit[1]
|
|
63
|
+
return cached.model_copy(update={
|
|
64
|
+
"region": region, "provider": provider,
|
|
65
|
+
"quality": "cached" if cached.quality in ("live", "live_estimated", "cached") else cached.quality,
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
async with httpx.AsyncClient(timeout=8) as client:
|
|
70
|
+
resp = await client.get(_BASE, params={"zone": zone},
|
|
71
|
+
headers={"auth-token": self._token})
|
|
72
|
+
|
|
73
|
+
if resp.status_code in (401, 403):
|
|
74
|
+
# Config problem, not a per-region issue. Warn once, then stop trying.
|
|
75
|
+
self._token_rejected = True
|
|
76
|
+
log.warning("Electricity Maps rejected the token (HTTP %s) — using mock data. "
|
|
77
|
+
"Check ELECTRICITYMAPS_TOKEN.", resp.status_code)
|
|
78
|
+
return await self._fallback.carbon_now(provider, region)
|
|
79
|
+
if resp.status_code == 404:
|
|
80
|
+
# Zone not covered on this plan/mapping. Silent per-region fallback.
|
|
81
|
+
log.debug("Zone %s not available (404) — mock fallback for %s/%s", zone, provider, region)
|
|
82
|
+
return await self._fallback.carbon_now(provider, region)
|
|
83
|
+
resp.raise_for_status()
|
|
84
|
+
|
|
85
|
+
payload = resp.json()
|
|
86
|
+
intensity = payload.get("carbonIntensity")
|
|
87
|
+
if intensity is None:
|
|
88
|
+
return await self._fallback.carbon_now(provider, region)
|
|
89
|
+
|
|
90
|
+
reading = CarbonReading(
|
|
91
|
+
region=region,
|
|
92
|
+
provider=provider,
|
|
93
|
+
g_co2_per_kwh=float(intensity),
|
|
94
|
+
source="electricitymaps",
|
|
95
|
+
timestamp=str(payload.get("datetime", datetime.now(timezone.utc).isoformat())),
|
|
96
|
+
quality="live_estimated" if payload.get("isEstimated") else "live",
|
|
97
|
+
)
|
|
98
|
+
self._cache[zone] = (time.time(), reading)
|
|
99
|
+
return reading
|
|
100
|
+
|
|
101
|
+
except Exception as e:
|
|
102
|
+
# Network/parse/rate-limit -> mock fallback rather than erroring the tool.
|
|
103
|
+
log.debug("Electricity Maps call failed for zone %s (%s) — mock fallback", zone, e)
|
|
104
|
+
return await self._fallback.carbon_now(provider, region)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "Per-fuel carbon intensity (gCO2eq/kWh), used to turn an EIA fuel-generation mix into a weighted grid carbon intensity. Order-of-magnitude lifecycle-style figures consistent with public literature (IPCC AR5 medians etc.). DIRECTIONAL estimates, not audited factors. Keys are EIA fueltype codes.",
|
|
3
|
+
"_source": "IPCC AR5 lifecycle emissions medians (approx), rounded. Replace with cited values before any non-directional use.",
|
|
4
|
+
"factors_g_co2_per_kwh": {
|
|
5
|
+
"COL": 820,
|
|
6
|
+
"NG": 490,
|
|
7
|
+
"OIL": 650,
|
|
8
|
+
"OTH": 700,
|
|
9
|
+
"NUC": 12,
|
|
10
|
+
"WAT": 24,
|
|
11
|
+
"WND": 11,
|
|
12
|
+
"SUN": 48,
|
|
13
|
+
"GEO": 38,
|
|
14
|
+
"BIO": 230
|
|
15
|
+
},
|
|
16
|
+
"_fuel_names": {
|
|
17
|
+
"COL": "Coal", "NG": "Natural gas", "OIL": "Petroleum", "OTH": "Other",
|
|
18
|
+
"NUC": "Nuclear", "WAT": "Hydro", "WND": "Wind", "SUN": "Solar",
|
|
19
|
+
"GEO": "Geothermal", "BIO": "Biomass"
|
|
20
|
+
},
|
|
21
|
+
"_comment_entsoe": "Maps ENTSO-E A75 psrType codes to the same per-fuel factors above, so the EU source reuses one emission-factor table. B-codes per ENTSO-E standard.",
|
|
22
|
+
"entsoe_psrtype_to_factor": {
|
|
23
|
+
"B01": 230, "B02": 820, "B03": 490, "B04": 490, "B05": 820, "B06": 650,
|
|
24
|
+
"B07": 650, "B08": 650, "B09": 38, "B10": 24, "B11": 24, "B12": 24,
|
|
25
|
+
"B14": 12, "B15": 230, "B16": 48, "B17": 230, "B18": 11, "B19": 11, "B20": 24
|
|
26
|
+
},
|
|
27
|
+
"_entsoe_psrtype_names": {
|
|
28
|
+
"B01": "Biomass", "B02": "Lignite", "B03": "Coal gas", "B04": "Natural gas",
|
|
29
|
+
"B05": "Hard coal", "B06": "Oil", "B07": "Oil shale", "B08": "Peat",
|
|
30
|
+
"B09": "Geothermal", "B10": "Hydro pumped storage", "B11": "Hydro run-of-river",
|
|
31
|
+
"B12": "Hydro reservoir", "B14": "Nuclear", "B15": "Other renewable",
|
|
32
|
+
"B16": "Solar", "B17": "Waste", "B18": "Wind offshore", "B19": "Wind onshore",
|
|
33
|
+
"B20": "Other"
|
|
34
|
+
}
|
|
35
|
+
}
|