Skip to content

Commit cf0e90d

Browse files
committed
electricity: start on EM implementation
1 parent 90c051a commit cf0e90d

File tree

14 files changed

+377
-62
lines changed

14 files changed

+377
-62
lines changed

.github/workflows/test.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ jobs:
4444

4545
test-e2e:
4646
runs-on: ubuntu-latest
47+
env:
48+
ELECTRICITY_MAPS_API_KEY: ${{ secrets.SIMON_ELECTRICITYMAPS_SANDBOX_KEY }}
4749
steps:
4850
- uses: actions/checkout@v4
4951
- uses: actions/setup-python@v5
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import requests
2+
3+
ELECTRICITY_MAPS_LATEST_URL = (
4+
"https://api.electricitymap.org/v3/carbon-intensity/latest"
5+
)
6+
7+
8+
def fetch_carbon_intensity(api_key: str, zone: str) -> dict:
9+
"""Fetch real-time carbon intensity from the Electricity Maps API.
10+
11+
Returns a dict matching the factor format used by factors.yml::
12+
13+
{
14+
"unit": "kg CO2eq/kWh",
15+
"source": "Electricity Maps API (lifecycle)",
16+
"value": <carbonIntensity / 1000>,
17+
"min": <carbonIntensity / 1000>,
18+
"max": <carbonIntensity / 1000>,
19+
}
20+
21+
Raises ConnectionError on request failure and ValueError when the
22+
response does not contain the expected data.
23+
"""
24+
params = {"zone": zone, "emissionFactorType": "lifecycle"}
25+
headers = {"auth-token": api_key}
26+
27+
try:
28+
response = requests.get(
29+
ELECTRICITY_MAPS_LATEST_URL, params=params, headers=headers, timeout=10
30+
)
31+
response.raise_for_status()
32+
except requests.RequestException as exc:
33+
raise ConnectionError(f"Electricity Maps API request failed: {exc}") from exc
34+
35+
data = response.json()
36+
carbon_intensity = data.get("carbonIntensity")
37+
if carbon_intensity is None:
38+
raise ValueError(
39+
f"Electricity Maps API response missing 'carbonIntensity': {data}"
40+
)
41+
42+
value = carbon_intensity / 1000
43+
44+
return {
45+
"unit": "kg CO2eq/kWh",
46+
"source": "Electricity Maps API (lifecycle)",
47+
"value": value,
48+
"min": value,
49+
"max": value,
50+
}

boaviztapi/service/factor_provider.py

Lines changed: 7 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33

44
import yaml
55
from boaviztapi import data_dir
6+
from boaviztapi.service.electricitymaps import fetch_carbon_intensity
7+
from boaviztapi.utils.config import config
8+
from boaviztapi.utils.country import iso3_to_iso2
69

710
config_file = os.path.join(data_dir, "factors.yml")
811
impact_factors = yaml.load(Path(config_file).read_text(), Loader=yaml.CSafeLoader)
@@ -26,6 +29,10 @@ def get_gpu_impact_factor(component, phase, impact_type) -> dict:
2629

2730

2831
def get_electrical_impact_factor(usage_location, impact_type) -> dict:
32+
if config.electricitymaps_api_key and impact_type == "gwp":
33+
zone = iso3_to_iso2(usage_location)
34+
return fetch_carbon_intensity(config.electricitymaps_api_key, zone)
35+
2936
if impact_factors["electricity"].get(usage_location):
3037
if impact_factors["electricity"].get(usage_location).get(impact_type):
3138
return impact_factors["electricity"].get(usage_location).get(impact_type)
@@ -88,48 +95,3 @@ def get_iot_impact_factor(functional_block, hsl, impact_type):
8895
.get(hsl)["eol"][impact_type]
8996
)
9097
raise NotImplementedError
91-
92-
93-
"""
94-
_electricity_emission_factors_df = pd.read_csv(
95-
os.path.join(data_dir, 'electricity/electricity_impact_factors.csv'))
96-
class ElecFactorProvider:
97-
def get(self, criteria, location, date):
98-
pass
99-
def get_range(self, criteria, location, date1, date2):
100-
pass
101-
class BoaviztaFactors(ElecFactorProvider):
102-
def get(self, criteria, usage_location, date=None):
103-
sub = _electricity_emission_factors_df
104-
sub = sub[sub['code'] == usage_location]
105-
return float(sub[f"{criteria}_emission_factor"]), sub[f"{criteria}_emission_source"].iloc[0], 0, ["The impact factor is averaged over the year"]
106-
def get_range(self, criteria, usage_location, date1=None, date2=None):
107-
return self.get(criteria,usage_location)
108-
109-
class ElectricityMap(ElecFactorProvider):
110-
auth_token = "6QGqlsF7ZcdUN6TMB7jX9DMsYKeGHbVl"
111-
url = "https://api-access.electricitymaps.com/2w97h07rvxvuaa1g"
112-
now = datetime.now()
113-
def get(self, criteria, location, date):
114-
zone = self._location_to_em_zone(location)
115-
if self.now - timedelta(hours=1) < date < self.now + timedelta(hours=1):
116-
return self._get_current(zone)
117-
elif date < self.now:
118-
return self._get_history(zone, date)
119-
else:
120-
return NotImplementedError
121-
def get_range(self, criteria, zone, date1, date2):
122-
pass
123-
def _get_current(self, zone):
124-
reponse = requests.get(f"{self.url}/carbon-intensity/latest?zone={zone}",
125-
headers={"X-BLOBR-KEY": self.auth_token}).json()
126-
127-
return reponse["carbonIntensity"]/1000, f"electricity map response : {reponse}", 0, []
128-
def _get_history(self, zone, date):
129-
reponse = requests.get(f"{self.url}/carbon-intensity/history?zone={zone}&datetime={date}",
130-
headers={"X-BLOBR-KEY": self.auth_token }).json()
131-
132-
return reponse
133-
def _location_to_em_zone(self, location):
134-
return location
135-
"""

boaviztapi/utils/config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ class Settings(BaseSettings):
1313
extra="ignore",
1414
)
1515

16+
# Electricity Maps API key (if set, real-time GWP factors are fetched)
17+
electricitymaps_api_key: Optional[str] = None
18+
1619
# Location and usage defaults
1720
default_location: str = "EEE"
1821
default_usage: str = "DEFAULT"

boaviztapi/utils/country.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import pycountry
2+
3+
4+
def iso3_to_iso2(iso3_code: str) -> str:
5+
"""Convert an ISO 3166-1 alpha-3 country code to alpha-2.
6+
7+
Raises ValueError for codes that cannot be mapped to a valid
8+
Electricity Maps zone (e.g. WOR, EEE, or unknown codes).
9+
"""
10+
if iso3_code in ("WOR", "EEE"):
11+
raise ValueError(
12+
f"Cannot convert '{iso3_code}' to an Electricity Maps zone code"
13+
)
14+
15+
country = pycountry.countries.get(alpha_3=iso3_code)
16+
if country is None:
17+
raise ValueError(f"Unknown ISO3 country code: '{iso3_code}'")
18+
19+
return country.alpha_2

docs/docs/Explanations/usage/elec_factors.md

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,41 +13,46 @@ Users can give their own impact factors.
1313

1414
## Boavizta's impact factors
1515

16-
Users can use the average impact factors per country available in BoaviztAPI.
16+
Users can use the average impact factors per country available in BoaviztAPI.
1717

1818
!!!info
1919
Impact factors will depend on the `usage_location` defined by the user in usage object. By default, the average european mix is used.
2020

21-
`usage_location` are given in a trigram format, according to the [list of the available countries](countries.md).
21+
`usage_location` are given in a trigram format, according to the [list of the available countries](countries.md).
2222

2323
!!!info
2424
Available countries can be retrieve using the API endpoint `/v1/utils/country_code`.
2525

2626
You can find bellow the data source and methodology used for each impact criteria.
2727

28+
## Electricity Maps Integration
29+
30+
You can use [Electricity Maps](https://app.electricitymaps.com/) to load live or historical electricity impact factors by setting the `ELECTRICITY_MAPS_API_KEY` environment variable to a valid API key for the Electricity Maps API.
31+
32+
**Note:** Electricity Maps currently only supports GWP as an impact factor. As a result, only the `gwp` factor of the _usage_ part of the impact will be based on Electricity Maps data.
33+
2834
### GWP - Global warming potential factor
2935

30-
_Source_ :
36+
_Source_ :
3137

3238
* For Europe (2019): [Quantification of the carbon intensity of electricity produced and used in Europe](https://www.sciencedirect.com/science/article/pii/S0306261921012149)
33-
* For the rest of the world: [Ember Climate](https://ember-climate.org/data/data-explorer)
34-
39+
* For the rest of the world: [Ember Climate](https://ember-climate.org/data/data-explorer)
3540

3641
### PE - Primary energy factor
3742

38-
_Source_ :
43+
_Source_ :
3944

40-
PE impact factor are not available in open access.
45+
PE impact factor are not available in open access.
4146
We use the consumption of fossil resources per kwh (APDf/kwh) per country and extrapolate this consumption to renewable energy :
4247

4348
```PE/kwh = ADPf/kwh / (1-%RenewableEnergyInMix)```
4449

4550
* `%RenewableEnergyInMix` (2016): [List of countries by renewable electricity production](https://en.wikipedia.org/wiki/List_of_countries_by_renewable_electricity_production) from IRENA
46-
* `ADPf` (2011): [Base IMPACTS® ADEME](https://base-impacts.ademe.fr/)
51+
* `ADPf` (2011): [Base IMPACTS® ADEME](https://base-impacts.ademe.fr/)
4752

4853
### Other impact factors
4954

50-
| Criteria | Implemented | Source |
55+
| Criteria | Implemented | Source |
5156
|----------|-------------|----------------------------------------------------------|
5257
| adp | yes | [Base IMPACTS® ADEME](https://base-impacts.ademe.fr/) |
5358
| gwppb | yes | [Base IMPACTS® ADEME](https://base-impacts.ademe.fr/) |
@@ -69,7 +74,3 @@ We use the consumption of fossil resources per kwh (APDf/kwh) per country and ex
6974
| epf | yes | [Base IMPACTS® ADEME](https://base-impacts.ademe.fr/) |
7075
| epm | yes | [Base IMPACTS® ADEME](https://base-impacts.ademe.fr/) |
7176
| ept | yes | [Base IMPACTS® ADEME](https://base-impacts.ademe.fr/) |
72-
73-
## Electricity map integration
74-
75-
Coming soon...

docs/docs/config.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,3 +102,13 @@ cpu_name_fuzzymatch_threshold: 62
102102
```
103103

104104
This can be overridden with the `BOAVIZTA_CPU_NAME_FUZZYMATCH_THRESHOLD` environment variable.
105+
106+
## Electricity Maps integration
107+
108+
The Electricity Maps integration can be activated by modifying the following parameter:
109+
110+
```
111+
electricity_maps_api_key: <your API key>
112+
```
113+
114+
These can be overridden with the `ELECTRICITY_MAPS_API_KEY` environment variable.

poetry.lock

Lines changed: 16 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ mangum = "^0.20.0"
2121
importlib-metadata = "^8.7.1"
2222
pyyaml = "^6.0.3"
2323
toml = "^0.10.2"
24+
requests = "^2.32.3"
25+
pycountry = "^24.6.1"
2426

2527
# Security updates
2628
aiohttp = "^3.13.3"
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import os
2+
3+
import pytest
4+
5+
from boaviztapi.service.factor_provider import get_electrical_impact_factor
6+
7+
ELECTRICITY_MAPS_API_KEY = os.environ.get("ELECTRICITY_MAPS_API_KEY")
8+
9+
pytestmark = [
10+
pytest.mark.e2e,
11+
pytest.mark.skipif(
12+
not ELECTRICITY_MAPS_API_KEY,
13+
reason="ELECTRICITY_MAPS_API_KEY environment variable not set",
14+
),
15+
]
16+
17+
18+
@pytest.fixture(autouse=True)
19+
def _set_api_key(monkeypatch):
20+
"""Inject the real API key into the application config."""
21+
monkeypatch.setattr(
22+
"boaviztapi.service.factor_provider.config.electricitymaps_api_key",
23+
ELECTRICITY_MAPS_API_KEY,
24+
)
25+
26+
27+
class TestGetElectricalImpactFactor:
28+
def test_returns_gwp_factor_for_known_country(self):
29+
"""get_electrical_impact_factor returns a valid GWP factor via Electricity Maps."""
30+
result = get_electrical_impact_factor("FRA", "gwp")
31+
32+
assert result["unit"] == "kg CO2eq/kWh"
33+
assert result["source"] == "Electricity Maps API (lifecycle)"
34+
assert isinstance(result["value"], float)
35+
assert result["value"] > 0
36+
assert result["min"] == result["value"]
37+
assert result["max"] == result["value"]
38+
39+
def test_non_gwp_falls_back_to_hardcoded(self):
40+
"""Non-gwp criteria fall back to hardcoded factors even when API key is set."""
41+
result = get_electrical_impact_factor("FRA", "pe")
42+
43+
assert "value" in result
44+
assert "unit" in result
45+
assert result.get("source") != "Electricity Maps API (lifecycle)"
46+
47+
def test_raises_value_error_for_unmappable_location(self):
48+
"""Locations like WOR cannot be converted to an Electricity Maps zone."""
49+
with pytest.raises(ValueError, match="Cannot convert"):
50+
get_electrical_impact_factor("WOR", "gwp")
51+
52+
def test_raises_value_error_for_unknown_country(self):
53+
"""Unknown ISO3 codes raise ValueError."""
54+
with pytest.raises(ValueError, match="Unknown ISO3 country code"):
55+
get_electrical_impact_factor("ZZZ", "gwp")

0 commit comments

Comments
 (0)