From d4f40d23aecacc34507496824ba702f85e8f4883 Mon Sep 17 00:00:00 2001 From: Haohang Zhao <70002628+Zhaohh0706@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:28:59 +0800 Subject: [PATCH 1/2] feat(china): use official 2023 MEE/NBS provincial emission factors Add the 30 provincial electricity CO2 factors published in Announcement 2025 No. 47, a province name lookup, and a CHN branch next to USA/CAN in get_region_emissions. Tibet, Hong Kong, Macao, Taiwan and unknown regions keep using the national value. --- codecarbon/core/emissions.py | 42 ++- .../2023/china_provincial_emissions.json | 292 ++++++++++++++++++ codecarbon/input.py | 13 + pyproject.toml | 1 + tests/test_emissions.py | 143 +++++++++ 5 files changed, 489 insertions(+), 2 deletions(-) create mode 100644 codecarbon/data/private_infra/2023/china_provincial_emissions.json diff --git a/codecarbon/core/emissions.py b/codecarbon/core/emissions.py index 3b2f10fad..a3243219c 100644 --- a/codecarbon/core/emissions.py +++ b/codecarbon/core/emissions.py @@ -6,6 +6,7 @@ https://github.com/responsibleproblemsolving/energy-usage """ +import re from typing import TYPE_CHECKING, Dict, Optional from codecarbon.core import electricitymaps_api @@ -23,6 +24,10 @@ "FIN": {"FI"}, } +_CHINA_REGION_SUFFIX = re.compile( + r"\s+(autonomous region|municipality|province|sheng|shi|zizhiqu)$" +) + class Emissions: def __init__( @@ -180,7 +185,8 @@ def get_private_infra_emissions(self, energy: Energy, geo: GeoMetadata) -> float geo.country_iso_code.upper() if geo.country_iso_code is not None else None ) compute_with_regional_data: bool = (geo.region is not None) and ( - country_iso_code in ["USA", "CAN"] or self._is_supported_nordic_region(geo) + country_iso_code in ["USA", "CAN", "CHN"] + or self._is_supported_nordic_region(geo) ) if compute_with_regional_data: @@ -231,6 +237,30 @@ def _is_supported_nordic_region(geo: GeoMetadata) -> bool: ) return geo.region.upper() in country_regions + def _try_get_china_province_emissions( + self, energy: Energy, geo: GeoMetadata + ) -> Optional[float]: + """ + Computes emissions with the official 2023 MEE/NBS provincial factor. + Returns None if the province is unknown or not published (Tibet, + Hong Kong, Macao and Taiwan), so the caller uses the national value. + """ + if geo.region is None: + return None + + name = _CHINA_REGION_SUFFIX.sub("", " ".join(geo.region.lower().split())) + china_data = self._data_source.get_china_provincial_emissions_data() + for province, province_data in china_data["data"].items(): + if name in province_data["names"]: + emission_factor_g = province_data["emission_factor"] + emissions = emission_factor_g / 1000 * energy.kWh + logger.debug( + f"China province {province}: Retrieved emissions using static factor " + + f"{emission_factor_g} gCO2/kWh: {emissions * 1000} g CO2" + ) + return emissions + return None + def get_region_emissions(self, energy: Energy, geo: GeoMetadata) -> float: """ Computes emissions for a region on private infra. @@ -240,10 +270,11 @@ def get_region_emissions(self, energy: Energy, geo: GeoMetadata) -> float: get_private_infra_emissions ├─ Electricity Maps API (si token) - ├─ get_region_emissions (USA/CAN/SWE/NOR/FIN) + ├─ get_region_emissions (USA/CAN/CHN/SWE/NOR/FIN) │ └─ _try_get_nordic_region_emissions (pour SWE/NOR/FIN) │ └─ country_emissions_data (pour USA) │ └─ country_energy_mix_data (pour CAN) + │ └─ _try_get_china_province_emissions (pour CHN) └─ get_country_emissions (fallback) :param energy: Mean power consumption of the process (kWh) @@ -260,6 +291,13 @@ def get_region_emissions(self, energy: Energy, geo: GeoMetadata) -> float: ): return self.get_country_emissions(energy, geo) + # Handle China provinces (official MEE/NBS factors) + if geo.country_iso_code is not None and geo.country_iso_code.upper() == "CHN": + china_emissions = self._try_get_china_province_emissions(energy, geo) + if china_emissions is not None: + return china_emissions + return self.get_country_emissions(energy, geo) + # Handle USA and Canada regional data try: country_emissions_data = self._data_source.get_country_emissions_data( diff --git a/codecarbon/data/private_infra/2023/china_provincial_emissions.json b/codecarbon/data/private_infra/2023/china_provincial_emissions.json new file mode 100644 index 000000000..7e6b1ed04 --- /dev/null +++ b/codecarbon/data/private_infra/2023/china_provincial_emissions.json @@ -0,0 +1,292 @@ +{ + "data": { + "CN-BJ": { + "emission_factor": 555.4, + "unit": "gCO2/kWh", + "description": "Beijing", + "year": 2023, + "names": [ + "beijing" + ] + }, + "CN-TJ": { + "emission_factor": 679.6, + "unit": "gCO2/kWh", + "description": "Tianjin", + "year": 2023, + "names": [ + "tianjin" + ] + }, + "CN-HE": { + "emission_factor": 651.6, + "unit": "gCO2/kWh", + "description": "Hebei", + "year": 2023, + "names": [ + "hebei" + ] + }, + "CN-SX": { + "emission_factor": 663.4, + "unit": "gCO2/kWh", + "description": "Shanxi", + "year": 2023, + "names": [ + "shanxi" + ] + }, + "CN-NM": { + "emission_factor": 647.9, + "unit": "gCO2/kWh", + "description": "Inner Mongolia", + "year": 2023, + "names": [ + "inner mongolia", + "nei mongol", + "neimenggu" + ] + }, + "CN-LN": { + "emission_factor": 487.8, + "unit": "gCO2/kWh", + "description": "Liaoning", + "year": 2023, + "names": [ + "liaoning" + ] + }, + "CN-JL": { + "emission_factor": 467.1, + "unit": "gCO2/kWh", + "description": "Jilin", + "year": 2023, + "names": [ + "jilin" + ] + }, + "CN-HL": { + "emission_factor": 522.9, + "unit": "gCO2/kWh", + "description": "Heilongjiang", + "year": 2023, + "names": [ + "heilongjiang" + ] + }, + "CN-SH": { + "emission_factor": 573.7, + "unit": "gCO2/kWh", + "description": "Shanghai", + "year": 2023, + "names": [ + "shanghai" + ] + }, + "CN-JS": { + "emission_factor": 582.7, + "unit": "gCO2/kWh", + "description": "Jiangsu", + "year": 2023, + "names": [ + "jiangsu" + ] + }, + "CN-ZJ": { + "emission_factor": 497.4, + "unit": "gCO2/kWh", + "description": "Zhejiang", + "year": 2023, + "names": [ + "zhejiang" + ] + }, + "CN-AH": { + "emission_factor": 655.3, + "unit": "gCO2/kWh", + "description": "Anhui", + "year": 2023, + "names": [ + "anhui" + ] + }, + "CN-FJ": { + "emission_factor": 421.1, + "unit": "gCO2/kWh", + "description": "Fujian", + "year": 2023, + "names": [ + "fujian" + ] + }, + "CN-JX": { + "emission_factor": 583.6, + "unit": "gCO2/kWh", + "description": "Jiangxi", + "year": 2023, + "names": [ + "jiangxi" + ] + }, + "CN-SD": { + "emission_factor": 619.1, + "unit": "gCO2/kWh", + "description": "Shandong", + "year": 2023, + "names": [ + "shandong" + ] + }, + "CN-HA": { + "emission_factor": 589.7, + "unit": "gCO2/kWh", + "description": "Henan", + "year": 2023, + "names": [ + "henan" + ] + }, + "CN-HB": { + "emission_factor": 404.4, + "unit": "gCO2/kWh", + "description": "Hubei", + "year": 2023, + "names": [ + "hubei" + ] + }, + "CN-HN": { + "emission_factor": 497.6, + "unit": "gCO2/kWh", + "description": "Hunan", + "year": 2023, + "names": [ + "hunan" + ] + }, + "CN-GD": { + "emission_factor": 441.9, + "unit": "gCO2/kWh", + "description": "Guangdong", + "year": 2023, + "names": [ + "guangdong" + ] + }, + "CN-GX": { + "emission_factor": 447.6, + "unit": "gCO2/kWh", + "description": "Guangxi", + "year": 2023, + "names": [ + "guangxi", + "guangxi zhuang" + ] + }, + "CN-HI": { + "emission_factor": 364.8, + "unit": "gCO2/kWh", + "description": "Hainan", + "year": 2023, + "names": [ + "hainan" + ] + }, + "CN-CQ": { + "emission_factor": 558.1, + "unit": "gCO2/kWh", + "description": "Chongqing", + "year": 2023, + "names": [ + "chongqing" + ] + }, + "CN-SC": { + "emission_factor": 156.4, + "unit": "gCO2/kWh", + "description": "Sichuan", + "year": 2023, + "names": [ + "sichuan" + ] + }, + "CN-GZ": { + "emission_factor": 568.3, + "unit": "gCO2/kWh", + "description": "Guizhou", + "year": 2023, + "names": [ + "guizhou" + ] + }, + "CN-YN": { + "emission_factor": 133.3, + "unit": "gCO2/kWh", + "description": "Yunnan", + "year": 2023, + "names": [ + "yunnan" + ] + }, + "CN-SN": { + "emission_factor": 633.5, + "unit": "gCO2/kWh", + "description": "Shaanxi", + "year": 2023, + "names": [ + "shaanxi" + ] + }, + "CN-GS": { + "emission_factor": 447.1, + "unit": "gCO2/kWh", + "description": "Gansu", + "year": 2023, + "names": [ + "gansu" + ] + }, + "CN-QH": { + "emission_factor": 179.6, + "unit": "gCO2/kWh", + "description": "Qinghai", + "year": 2023, + "names": [ + "qinghai" + ] + }, + "CN-NX": { + "emission_factor": 618.7, + "unit": "gCO2/kWh", + "description": "Ningxia", + "year": 2023, + "names": [ + "ningxia", + "ningxia hui" + ] + }, + "CN-XJ": { + "emission_factor": 602.1, + "unit": "gCO2/kWh", + "description": "Xinjiang", + "year": 2023, + "names": [ + "xinjiang", + "xinjiang uygur", + "xinjiang uyghur" + ] + } + }, + "metadata": { + "source": "Ministry of Ecology and Environment (MEE) and National Bureau of Statistics (NBS) of China, Announcement 2025 No. 47 (published 2025-12-31), 'Announcement on the release of the 2023 electricity CO2 emission factors', Table 3: provincial average electricity CO2 emission factors, 2023.", + "source_url": "https://www.mee.gov.cn/xxgk2018/xxgk/xxgk01/202512/t20251231_1139517.html", + "announcement": "2025 No. 47", + "published": "2025-12-31", + "year": 2023, + "unit": "gCO2/kWh", + "convention": "CO2 only (not CO2eq, no lifecycle emissions), average over all electricity generated in the province (thermal, hydro, wind, nuclear, solar), location-based, accounting for inter-provincial electricity transfers. Values are the official figures as published (kgCO2/kWh x 1000).", + "not_published": "Tibet (Xizang), Hong Kong, Macao and Taiwan are not in the official table; they have no entry here and fall back to the national value for China.", + "names": "Lower-case province names as returned by the geolocation services (ipinfo.io returns the short English name, e.g. 'Inner Mongolia', 'Ningxia', 'Xinjiang', 'Guangxi'), plus common variants. Matching is case-insensitive, ignores a trailing 'province', 'municipality' or 'autonomous region', and is exact otherwise.", + "last_updated": "2026-10-12" + } +} diff --git a/codecarbon/input.py b/codecarbon/input.py index 4ed23db2b..eaf26644c 100644 --- a/codecarbon/input.py +++ b/codecarbon/input.py @@ -62,6 +62,11 @@ def _load_static_data() -> None: with open(path) as f: _CACHE["nordic_country_energy_mix"] = json.load(f) + # China provincial emission factors - used for emissions calculations + path = _get_resource_path("data/private_infra/2023/china_provincial_emissions.json") + with open(path) as f: + _CACHE["china_provincial_emissions"] = json.load(f) + _STATIC_DATA_LOADED = False @@ -211,6 +216,14 @@ def get_nordic_country_energy_mix_data(self) -> Dict: _ensure_static_data_loaded() return _CACHE["nordic_country_energy_mix"] + def get_china_provincial_emissions_data(self) -> Dict: + """ + Returns China provincial emission factors (MEE/NBS, 2023). + Data is loaded on first access and cached for all tracker instances. + """ + _ensure_static_data_loaded() + return _CACHE["china_provincial_emissions"] + class DataSourceException(Exception): pass diff --git a/pyproject.toml b/pyproject.toml index 7510aba48..57d3f2154 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -95,6 +95,7 @@ codecarbon = [ "data/private_infra/nordic_emissions.json", "data/private_infra/2016/usa_emissions.json", "data/private_infra/2023/canada_energy_mix.json", + "data/private_infra/2023/china_provincial_emissions.json", "viz/assets/car_icon.png", "viz/assets/house_icon.png", "viz/assets/tv_icon.png" diff --git a/tests/test_emissions.py b/tests/test_emissions.py index 96194f30b..3361f2d1d 100644 --- a/tests/test_emissions.py +++ b/tests/test_emissions.py @@ -375,3 +375,146 @@ def test_try_get_nordic_region_emissions_returns_none_on_data_loading_error(self # THEN self.assertIsNone(emissions) + + def _china_geo(self, region, country_iso_code="CHN", country_name="China"): + return GeoMetadata( + country_iso_code=country_iso_code, country_name=country_name, region=region + ) + + def test_get_emissions_PRIVATE_INFRA_CHINA_PROVINCE(self): + # WHEN + emissions = self._emissions.get_private_infra_emissions( + Energy.from_energy(kWh=2.0), self._china_geo("Sichuan") + ) + + # THEN + # Sichuan: 0.1564 kgCO2/kWh in the 2023 MEE/NBS table + assert isinstance(emissions, float) + self.assertAlmostEqual(emissions, 2.0 * 0.1564, places=6) + + def test_china_province_factors_match_official_table(self): + data = self._data_source.get_china_provincial_emissions_data()["data"] + + self.assertEqual(len(data), 30) + self.assertNotIn("CN-XZ", data) + self.assertAlmostEqual(data["CN-GD"]["emission_factor"], 441.9) + self.assertAlmostEqual(data["CN-YN"]["emission_factor"], 133.3) + self.assertAlmostEqual(data["CN-TJ"]["emission_factor"], 679.6) + + def test_china_province_name_variants(self): + expected_by_region = { + "beijing": "CN-BJ", + "Shanghai": "CN-SH", + "TIANJIN": "CN-TJ", + "Chongqing": "CN-CQ", + "Inner Mongolia": "CN-NM", + "inner mongolia autonomous region": "CN-NM", + "Nei Mongol": "CN-NM", + "Ningxia": "CN-NX", + "Ningxia Hui": "CN-NX", + "Ningxia Hui Autonomous Region": "CN-NX", + "Xinjiang": "CN-XJ", + "Xinjiang Uygur": "CN-XJ", + "Xinjiang Uyghur Autonomous Region": "CN-XJ", + "Guangxi": "CN-GX", + "Guangxi Zhuang": "CN-GX", + " Guangdong Province ": "CN-GD", + "Shaanxi": "CN-SN", + "Shanxi": "CN-SX", + } + data = self._data_source.get_china_provincial_emissions_data()["data"] + energy = Energy.from_energy(kWh=1.0) + + for region, key in expected_by_region.items(): + with self.subTest(region=region): + emissions = self._emissions.get_private_infra_emissions( + energy, self._china_geo(region) + ) + self.assertAlmostEqual( + emissions, data[key]["emission_factor"] / 1000, places=6 + ) + + def test_china_every_province_resolves_to_its_own_factor(self): + data = self._data_source.get_china_provincial_emissions_data()["data"] + energy = Energy.from_energy(kWh=1.0) + + for key, province in data.items(): + with self.subTest(province=key): + emissions = self._emissions.get_private_infra_emissions( + energy, self._china_geo(province["description"]) + ) + self.assertAlmostEqual( + emissions, province["emission_factor"] / 1000, places=6 + ) + + def test_china_province_names_are_unique(self): + data = self._data_source.get_china_provincial_emissions_data()["data"] + names = [name for province in data.values() for name in province["names"]] + + self.assertEqual(len(names), len(set(names))) + + def test_china_unpublished_or_unknown_regions_fall_back_to_national_value(self): + energy = Energy.from_energy(kWh=1.0) + national = self._emissions.get_country_emissions(energy, self._china_geo(None)) + + for region in [None, "Tibet", "Xizang", "Hong Kong", "Macao", "Taiwan", "xyz"]: + with self.subTest(region=region): + emissions = self._emissions.get_private_infra_emissions( + energy, self._china_geo(region) + ) + self.assertAlmostEqual(emissions, national, places=6) + + def test_china_fallback_does_not_log_warnings_or_errors(self): + with ( + patch("codecarbon.core.emissions.logger.error") as log_error, + patch("codecarbon.core.emissions.logger.warning") as log_warning, + ): + self._emissions.get_private_infra_emissions( + Energy.from_energy(kWh=1.0), self._china_geo("Tibet") + ) + + log_error.assert_not_called() + log_warning.assert_not_called() + + def test_china_province_name_outside_china_is_not_used(self): + energy = Energy.from_energy(kWh=1.0) + for country_iso_code, country_name in [ + ("HKG", "Hong Kong"), + ("TWN", "Taiwan"), + ("MAC", "Macao"), + ("FRA", "France"), + ]: + with self.subTest(country_iso_code=country_iso_code): + geo = self._china_geo("Guangdong", country_iso_code, country_name) + emissions = self._emissions.get_private_infra_emissions(energy, geo) + self.assertAlmostEqual( + emissions, + self._emissions.get_country_emissions(energy, geo), + places=6, + ) + + def test_china_province_data_error_falls_back_to_national_value(self): + energy = Energy.from_energy(kWh=1.0) + national = self._emissions.get_country_emissions(energy, self._china_geo(None)) + + with patch.object( + self._data_source, + "get_china_provincial_emissions_data", + side_effect=OSError("missing"), + ): + emissions = self._emissions.get_private_infra_emissions( + energy, self._china_geo("Sichuan") + ) + + self.assertAlmostEqual(emissions, national, places=6) + + def test_china_forced_intensity_takes_precedence(self): + emissions_calculator = Emissions( + self._data_source, force_carbon_intensity_g_co2e_kwh=100.0 + ) + + emissions = emissions_calculator.get_private_infra_emissions( + Energy.from_energy(kWh=1.0), self._china_geo("Sichuan") + ) + + self.assertAlmostEqual(emissions, 0.1, places=6) From 4b1f8477d82c81496048fb265eb7d931a2759a9a Mon Sep 17 00:00:00 2001 From: benoit-cty Date: Sat, 17 Oct 2026 10:55:35 +0200 Subject: [PATCH 2/2] docs: document regional carbon intensity data, including China provinces Add a 'Regional data' section to the methodology page listing the sub-national sources (USA, Canada, Nordic bidding zones, China provinces) and the CO2 vs CO2eq convention of the MEE/NBS factors. Co-Authored-By: Claude Fable 5.1 --- docs/explanation/methodology.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/docs/explanation/methodology.md b/docs/explanation/methodology.md index 5437ee158..ec3fbdb7d 100644 --- a/docs/explanation/methodology.md +++ b/docs/explanation/methodology.md @@ -34,6 +34,33 @@ or per country ( [here](https://github.com/mlco2/codecarbon/blob/master/codecarbon/data/private_infra/global_energy_mix.json) ). +### Regional data + +For some countries, CodeCarbon has sub-national carbon intensity data +and uses it when the geolocation service (or the `region` parameter) +reports a region in one of them: + +| Country | Granularity | Source | +|---------|-------------|--------| +| United States | State | [Emissions per state, 2016](https://github.com/mlco2/codecarbon/blob/master/codecarbon/data/private_infra/2016/usa_emissions.json) | +| Canada | Province | [Energy mix per province, 2023](https://github.com/mlco2/codecarbon/blob/master/codecarbon/data/private_infra/2023/canada_energy_mix.json) | +| Sweden, Norway, Finland | Electricity bidding zone (SE1-SE4, NO1-NO5, FI) | [ENTSO-E generation mix, 2025](https://github.com/mlco2/codecarbon/blob/master/codecarbon/data/private_infra/nordic_emissions.json) | +| China | Province (30 provinces, municipalities and autonomous regions) | [MEE/NBS Announcement 2025 No. 47, 2023 factors](https://github.com/mlco2/codecarbon/blob/master/codecarbon/data/private_infra/2023/china_provincial_emissions.json) | + +For China, the values are the official provincial average electricity +CO₂ emission factors published by the Ministry of Ecology and +Environment and the National Bureau of Statistics +([source](https://www.mee.gov.cn/xxgk2018/xxgk/xxgk01/202512/t20251231_1139517.html)). +They range from 133 gCO₂/kWh in Yunnan to 680 gCO₂/kWh in Tianjin, +whereas the national value is 582 gCO₂eq/kWh. Note that these official +factors count CO₂ only (no other greenhouse gases, no lifecycle +emissions), whereas the per-country values are in CO₂eq. Tibet, Hong +Kong, Macao and Taiwan are not in the official table and use the +country value. + +When a region is not found in the regional data, CodeCarbon falls back +to the country value. + If we don't have the global carbon intensity or electricity of a country, but we have its electricity mix, we used to compute the carbon intensity of electricity using this table: