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
greenpick/__init__.py ADDED
@@ -0,0 +1,7 @@
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.0"
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
+ }