Skip to content

Commit 6dbd125

Browse files
authored
Merge pull request #1039 from Hinetziedacted/add-nordic-emission-factors
Add Nordic region emission factors and update emissions logic
2 parents f28a4f7 + bab3ea1 commit 6dbd125

5 files changed

Lines changed: 242 additions & 2 deletions

File tree

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,7 @@ flake8...................................................................Passed
258258

259259
If any of the linters/formatters fail, check the difference with `git diff`, add the differences if there is no behavior changes (isort and black might have change some coding style or import order, this is expected it is their job) with `git add` and finally try to commit again `git commit ...`.
260260

261-
You can also run `pre-commit` with `uv run pre-commit run --all-file` to check all file.
261+
You can also run `pre-commit` with `uv run pre-commit run --all-files` if you have some changes staged but you are not ready yet to commit.
262262

263263

264264
<!-- TOC --><a name="dependencies-management"></a>

codecarbon/core/emissions.py

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ def get_private_infra_emissions(self, energy: Energy, geo: GeoMetadata) -> float
156156
)
157157

158158
compute_with_regional_data: bool = (geo.region is not None) and (
159-
geo.country_iso_code.upper() in ["USA", "CAN"]
159+
geo.country_iso_code.upper() in ["USA", "CAN", "SWE", "NOR", "FIN"]
160160
)
161161

162162
if compute_with_regional_data:
@@ -170,16 +170,72 @@ def get_private_infra_emissions(self, energy: Energy, geo: GeoMetadata) -> float
170170
)
171171
return self.get_country_emissions(energy, geo)
172172

173+
def _try_get_nordic_region_emissions(
174+
self, energy: Energy, geo: GeoMetadata
175+
) -> Optional[float]:
176+
nordic_regions = {
177+
"SE1",
178+
"SE2",
179+
"SE3",
180+
"SE4",
181+
"NO1",
182+
"NO2",
183+
"NO3",
184+
"NO4",
185+
"NO5",
186+
"FI",
187+
}
188+
if geo.region is None:
189+
return None
190+
191+
region_upper = geo.region.upper()
192+
if region_upper not in nordic_regions:
193+
return None
194+
195+
try:
196+
nordic_data = self._data_source.get_nordic_country_energy_mix_data()
197+
region_data = nordic_data["data"].get(region_upper)
198+
if region_data:
199+
emission_factor_g = region_data["emission_factor"]
200+
emission_factor_kg = emission_factor_g / 1000
201+
emissions = emission_factor_kg * energy.kWh
202+
logger.debug(
203+
f"Nordic region {geo.region}: Retrieved emissions using static factor "
204+
+ f"{emission_factor_g} gCO2eq/kWh: {emissions * 1000} g CO2eq"
205+
)
206+
return emissions
207+
except Exception as e:
208+
logger.warning(
209+
f"Error loading Nordic emissions data for {geo.region}: {e}. "
210+
+ "Falling back to default emission calculation."
211+
)
212+
return None
213+
173214
def get_region_emissions(self, energy: Energy, geo: GeoMetadata) -> float:
174215
"""
175216
Computes emissions for a region on private infra.
176217
Given an quantity of power consumed, use regional data
177218
on emissions per unit power consumed or the mix of energy sources.
178219
https://github.com/responsibleproblemsolving/energy-usage#calculating-co2-emissions
220+
221+
get_private_infra_emissions
222+
├─ Electricity Maps API (si token)
223+
├─ get_region_emissions (USA/CAN/SWE/NOR/FIN)
224+
│ └─ _try_get_nordic_region_emissions (pour SWE/NOR/FIN)
225+
│ └─ country_emissions_data (pour USA)
226+
│ └─ country_energy_mix_data (pour CAN)
227+
└─ get_country_emissions (fallback)
228+
179229
:param energy: Mean power consumption of the process (kWh)
180230
:param geo: Country and region metadata.
181231
:return: CO2 emissions in kg
182232
"""
233+
# Handle Nordic regions (Sweden, Norway, Finland electricity bidding zones)
234+
nordic_emissions = self._try_get_nordic_region_emissions(energy, geo)
235+
if nordic_emissions is not None:
236+
return nordic_emissions
237+
238+
# Handle USA and Canada regional data
183239
try:
184240
country_emissions_data = self._data_source.get_country_emissions_data(
185241
geo.country_iso_code.lower()
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
{
2+
"data": {
3+
"SE1": {
4+
"emission_factor": 18.0,
5+
"unit": "gCO2eq/kWh",
6+
"description": "Sweden Bidding Zone 1 (Northern Sweden)",
7+
"year": 2024
8+
},
9+
"SE2": {
10+
"emission_factor": 18.0,
11+
"unit": "gCO2eq/kWh",
12+
"description": "Sweden Bidding Zone 2 (Central Sweden)",
13+
"year": 2024
14+
},
15+
"SE3": {
16+
"emission_factor": 18.0,
17+
"unit": "gCO2eq/kWh",
18+
"description": "Sweden Bidding Zone 3 (Southern Sweden)",
19+
"year": 2024
20+
},
21+
"SE4": {
22+
"emission_factor": 18.0,
23+
"unit": "gCO2eq/kWh",
24+
"description": "Sweden Bidding Zone 4 (Stockholm region)",
25+
"year": 2024
26+
},
27+
"NO1": {
28+
"emission_factor": 18.0,
29+
"unit": "gCO2eq/kWh",
30+
"description": "Norway Bidding Zone 1 (Oslo)",
31+
"year": 2024
32+
},
33+
"NO2": {
34+
"emission_factor": 18.0,
35+
"unit": "gCO2eq/kWh",
36+
"description": "Norway Bidding Zone 2 (Southern Norway)",
37+
"year": 2024
38+
},
39+
"NO3": {
40+
"emission_factor": 18.0,
41+
"unit": "gCO2eq/kWh",
42+
"description": "Norway Bidding Zone 3 (Central Norway)",
43+
"year": 2024
44+
},
45+
"NO4": {
46+
"emission_factor": 18.0,
47+
"unit": "gCO2eq/kWh",
48+
"description": "Norway Bidding Zone 4 (Northern Norway)",
49+
"year": 2024
50+
},
51+
"NO5": {
52+
"emission_factor": 18.0,
53+
"unit": "gCO2eq/kWh",
54+
"description": "Norway Bidding Zone 5 (Western Norway)",
55+
"year": 2024
56+
},
57+
"FI": {
58+
"emission_factor": 72.0,
59+
"unit": "gCO2eq/kWh",
60+
"description": "Finland",
61+
"year": 2025
62+
}
63+
},
64+
"metadata": {
65+
"source": "Based on historical averages from ENTSO-E data",
66+
"last_updated": "2026-01-24",
67+
"notes": "Static emission factors for Nordic regions. Sweden and Norway have very low carbon intensity due to high renewable energy (primarily hydro and nuclear). Finland has higher emissions due to greater fossil fuel dependency."
68+
}
69+
}

codecarbon/input.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,11 @@ def _load_static_data() -> None:
5353
path = _get_resource_path("data/hardware/cpu_power.csv")
5454
_CACHE["cpu_power"] = pd.read_csv(path)
5555

56+
# Nordic country energy mix - used for emissions calculations
57+
path = _get_resource_path("data/private_infra/nordic_emissions.json")
58+
with open(path) as f:
59+
_CACHE["nordic_country_energy_mix"] = json.load(f)
60+
5661

5762
# Load static data at module import
5863
_load_static_data()
@@ -182,6 +187,13 @@ def get_cpu_power_data(self) -> pd.DataFrame:
182187
"""
183188
return _CACHE["cpu_power"]
184189

190+
def get_nordic_country_energy_mix_data(self) -> Dict:
191+
"""
192+
Returns Nordic Country Energy Mix Data.
193+
Data is cached on first access per country.
194+
"""
195+
return _CACHE["nordic_country_energy_mix"]
196+
185197

186198
class DataSourceException(Exception):
187199
pass

tests/test_emissions.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import unittest
2+
from unittest.mock import patch
23

34
from codecarbon.core.emissions import Emissions
45
from codecarbon.core.units import Energy
@@ -172,3 +173,105 @@ def test_get_emissions_PRIVATE_INFRA_unknown_country(self):
172173
)
173174
assert isinstance(emissions, float)
174175
self.assertAlmostEqual(emissions, 0.475, places=2)
176+
177+
def test_get_emissions_PRIVATE_INFRA_NORDIC_REGION(self):
178+
# WHEN
179+
# Test Nordic region (Sweden SE2)
180+
181+
emissions = self._emissions.get_private_infra_emissions(
182+
Energy.from_energy(kWh=1.0),
183+
GeoMetadata(country_iso_code="SWE", country_name="Sweden", region="SE2"),
184+
)
185+
186+
# THEN
187+
# Nordic regions use static emission factors from the JSON file
188+
# SE2 has an emission factor specified in nordic_country_energy_mix.json
189+
assert isinstance(emissions, float)
190+
self.assertAlmostEqual(emissions, 0.018, places=6)
191+
192+
def test_get_emissions_PRIVATE_INFRA_NORDIC_FINLAND(self):
193+
# WHEN
194+
# Test Nordic region (Finland)
195+
196+
emissions = self._emissions.get_private_infra_emissions(
197+
Energy.from_energy(kWh=2.5),
198+
GeoMetadata(country_iso_code="FIN", country_name="Finland", region="FI"),
199+
)
200+
201+
# THEN
202+
# Finland (FI) should use Nordic static emission factors
203+
assert isinstance(emissions, float)
204+
expected_emissions = 0.072 * 2.5
205+
self.assertAlmostEqual(emissions, expected_emissions, places=6)
206+
207+
def test_get_emissions_PRIVATE_INFRA_NORDIC_REGION_uses_static_factor_without_token(
208+
self,
209+
):
210+
# GIVEN
211+
energy = Energy.from_energy(kWh=1.0)
212+
geo = GeoMetadata(country_iso_code="SWE", country_name="Sweden", region="SE2")
213+
214+
# WHEN
215+
emissions = self._emissions.get_private_infra_emissions(energy, geo)
216+
217+
# THEN
218+
expected_country = self._emissions.get_country_emissions(energy, geo)
219+
nordic_data = self._data_source.get_nordic_country_energy_mix_data()
220+
emission_factor_g = nordic_data["data"]["SE2"]["emission_factor"]
221+
expected_nordic = (emission_factor_g / 1000) * energy.kWh
222+
self.assertAlmostEqual(emissions, expected_nordic, places=6)
223+
self.assertNotAlmostEqual(emissions, expected_country, places=4)
224+
225+
def test_try_get_nordic_region_emissions_returns_none_without_region(self):
226+
# GIVEN
227+
energy = Energy.from_energy(kWh=1.0)
228+
geo = GeoMetadata(country_iso_code="SWE", country_name="Sweden", region=None)
229+
230+
# WHEN
231+
emissions = self._emissions._try_get_nordic_region_emissions(energy, geo)
232+
233+
# THEN
234+
self.assertIsNone(emissions)
235+
236+
def test_try_get_nordic_region_emissions_returns_none_for_non_nordic_region(self):
237+
# GIVEN
238+
energy = Energy.from_energy(kWh=1.0)
239+
geo = GeoMetadata(country_iso_code="SWE", country_name="Sweden", region="XYZ")
240+
241+
# WHEN
242+
emissions = self._emissions._try_get_nordic_region_emissions(energy, geo)
243+
244+
# THEN
245+
self.assertIsNone(emissions)
246+
247+
def test_try_get_nordic_region_emissions_returns_none_if_region_data_missing(self):
248+
# GIVEN
249+
energy = Energy.from_energy(kWh=1.0)
250+
geo = GeoMetadata(country_iso_code="SWE", country_name="Sweden", region="SE2")
251+
252+
# WHEN
253+
with patch.object(
254+
self._data_source,
255+
"get_nordic_country_energy_mix_data",
256+
return_value={"data": {}},
257+
):
258+
emissions = self._emissions._try_get_nordic_region_emissions(energy, geo)
259+
260+
# THEN
261+
self.assertIsNone(emissions)
262+
263+
def test_try_get_nordic_region_emissions_returns_none_on_data_loading_error(self):
264+
# GIVEN
265+
energy = Energy.from_energy(kWh=1.0)
266+
geo = GeoMetadata(country_iso_code="SWE", country_name="Sweden", region="SE2")
267+
268+
# WHEN
269+
with patch.object(
270+
self._data_source,
271+
"get_nordic_country_energy_mix_data",
272+
side_effect=Exception("boom"),
273+
):
274+
emissions = self._emissions._try_get_nordic_region_emissions(energy, geo)
275+
276+
# THEN
277+
self.assertIsNone(emissions)

0 commit comments

Comments
 (0)