From 8fc237c1913d3dc5aaee42473e541b87b05ed23c Mon Sep 17 00:00:00 2001 From: Daniel Huppmann Date: Mon, 2 Feb 2026 23:31:19 +0100 Subject: [PATCH 1/6] Add a simple `aggregate_kyoto_gases()` method --- pyam/core.py | 21 +++++++++++++++ pyam/emissions.py | 40 +++++++++++++++++++++++++++ tests/test_emissions.py | 60 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+) create mode 100644 pyam/emissions.py create mode 100644 tests/test_emissions.py diff --git a/pyam/core.py b/pyam/core.py index cbfd3e497..28f1bdb45 100755 --- a/pyam/core.py +++ b/pyam/core.py @@ -11,6 +11,7 @@ import pandas as pd from pandas.api.types import is_integer +from pyam.emissions import SPECIES_UNIT_MAPPING, aggregate_kyoto_gases from pyam.netcdf import to_xarray try: @@ -1864,6 +1865,26 @@ def check_internal_consistency(self, components=False, **kwargs): ] ] + def aggregate_kyoto_gases(self, *, metric: str, append: bool = False): + """Compute the aggregate Kyoto gases from a set of species using a GWP metric + + metric: str + A global warming potential (GWP) metric supported by :mod:`iam_units`, + e.g. 'AR6GWP100'. + append : bool, optional + Append the aggregate emissions timeseries to `self` and return None, + else return aggregated emissions timeseries as new :class:`IamDataFrame`. + + See Also + -------- + pyam.IamDataFrame.convert_unit + + """ + data = concat(aggregate_kyoto_gases(self, metric)).aggregate( + f"Emissions|Kyoto Gases [{metric}]", components=SPECIES_UNIT_MAPPING.keys() + ) + return self._finalize(data, append=append) + def slice(self, *, keep=True, **kwargs): """Return a (filtered) slice object of the IamDataFrame timeseries data index diff --git a/pyam/emissions.py b/pyam/emissions.py new file mode 100644 index 000000000..06f156b62 --- /dev/null +++ b/pyam/emissions.py @@ -0,0 +1,40 @@ +from pyam.exceptions import raise_data_error + +REQUIRED_SPECIES = ["Emissions|CO2", "Emissions|CH4", "Emissions|N2O"] + +SPECIES_UNIT_MAPPING = { + "Emissions|CO2": "Mt CO2/yr", + "Emissions|CH4": "Mt CH4/yr", + "Emissions|N2O": "kt N2O/yr", + "Emissions|SF6": "kt SF6/yr", + "Emissions|C2F6": "kt C2F6/yr", + "Emissions|C6F14": "kt C6F14/yr", + "Emissions|CF4": "kt CF4/yr", + "Emissions|HFC|HFC125": "kt HFC125/yr", + "Emissions|HFC|HFC134a": "kt HFC134a/yr", + "Emissions|HFC|HFC143a": "kt HFC143a/yr", + "Emissions|HFC|HFC227ea": "kt HFC227ea/yr", + "Emissions|HFC|HFC23": "kt HFC23/yr", + "Emissions|HFC|HFC32": "kt HFC32/yr", + # inconsistent notation between iam-units and common-definitions + # "Emissions|HFC|HFC43-10": "kt HFC43-10/yr", +} + + +def aggregate_kyoto_gases(df: "IamDataFrame", metric: str): + """Internal implementation of the `aggregate_kyoto_gases` function""" + + missing = df.require_data(variable=REQUIRED_SPECIES) + if missing is not None: + raise_data_error("Missing species for aggregation", missing) + + df_list = list() + for species, unit in SPECIES_UNIT_MAPPING.items(): + if species in df.variable: + df_list.append( + df.filter(variable=species).convert_unit( + unit, "Mt CO2-equiv/yr", context=metric + ) + ) + + return df_list diff --git a/tests/test_emissions.py b/tests/test_emissions.py new file mode 100644 index 000000000..a1366af01 --- /dev/null +++ b/tests/test_emissions.py @@ -0,0 +1,60 @@ +import pandas as pd +import pytest + +from pyam import IamDataFrame +from pyam.testing import assert_iamframe_equal + +EMISSIONS_SPECIES_DATA = pd.DataFrame( + [ + ["Emissions|CO2", "Mt CO2/yr", 42885.41, 33011.87, 24642.81], + ["Emissions|CH4", "Mt CH4/yr", 413.63, 287.42, 233.97], + ["Emissions|N2O", "kt N2O/yr", 11623.95, 9005.23, 8177.40], + ["Emissions|SF6", "kt SF6/yr", 8.01, 5.26, 2.60], + ["Emissions|HFC|HFC125", "kt HFC125/yr", 98.76, 57.44, 16.71], + ["Emissions|HFC|HFC134a", "kt HFC134a/yr", 248.84, 144.53, 42.41], + ["Emissions|HFC|HFC143a", "kt HFC143a/yr", 40.59, 23.61, 6.87], + ["Emissions|HFC|HFC23", "kt HFC23/yr", 7.13, 4.24, 1.55], + ["Emissions|HFC|HFC32", "kt HFC32/yr", 61.18, 35.55, 10.29], + ], + columns=["variable", "unit", 2020, 2025, 2030], +) + + +EXP_GHG_DATA = pd.DataFrame( + [ + [ + "Emissions|Kyoto Gases [AR6GWP100]", + "Mt CO2-equiv/yr", + 58938.34, + 44284.49, + 33666.64, + ] + ], + columns=["variable", "unit", 2020, 2025, 2030], +) + + +@pytest.mark.parametrize("append", ((False, True))) +def test_kyoto_ghg(append): + df_args = dict(model="model_a", scenario="scenario_a", region="World") + df = IamDataFrame(EMISSIONS_SPECIES_DATA, **df_args) + exp = IamDataFrame(EXP_GHG_DATA, **df_args) + + if append: + obs = df.copy() + obs.aggregate_kyoto_gases(metric="AR6GWP100", append=append) + exp = df.append(exp) + else: + obs = df.aggregate_kyoto_gases(metric="AR6GWP100") + + assert_iamframe_equal(exp, obs) + + +def test_kyoto_ghg_raises(): + df_args = dict(model="model_a", scenario="scenario_a", region="World") + df = IamDataFrame(EMISSIONS_SPECIES_DATA, **df_args) + df.filter(variable="Emissions|CH4", keep=False, inplace=True) + + match = "Missing species for aggregation:.* scenario_a Emissions|CH4" + with pytest.raises(ValueError, match=match): + df.aggregate_kyoto_gases(metric="AR6GWP100") From 2aa3e475a76cf436e9f89f04cb5dd4af53234943 Mon Sep 17 00:00:00 2001 From: Daniel Huppmann Date: Mon, 2 Feb 2026 23:48:14 +0100 Subject: [PATCH 2/6] Make ruff --- pyam/emissions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyam/emissions.py b/pyam/emissions.py index 06f156b62..1e044d2b1 100644 --- a/pyam/emissions.py +++ b/pyam/emissions.py @@ -21,7 +21,7 @@ } -def aggregate_kyoto_gases(df: "IamDataFrame", metric: str): +def aggregate_kyoto_gases(df, metric: str): """Internal implementation of the `aggregate_kyoto_gases` function""" missing = df.require_data(variable=REQUIRED_SPECIES) From cbacf0941011fbae8a0e483c128c375006598c9a Mon Sep 17 00:00:00 2001 From: Daniel Huppmann Date: Sat, 28 Mar 2026 16:44:33 +0100 Subject: [PATCH 3/6] Use full list of species --- pyam/emissions.py | 42 ++++++++++++++++++++++++++---------------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/pyam/emissions.py b/pyam/emissions.py index 1e044d2b1..0eabfc6c9 100644 --- a/pyam/emissions.py +++ b/pyam/emissions.py @@ -2,22 +2,32 @@ REQUIRED_SPECIES = ["Emissions|CO2", "Emissions|CH4", "Emissions|N2O"] -SPECIES_UNIT_MAPPING = { - "Emissions|CO2": "Mt CO2/yr", - "Emissions|CH4": "Mt CH4/yr", - "Emissions|N2O": "kt N2O/yr", - "Emissions|SF6": "kt SF6/yr", - "Emissions|C2F6": "kt C2F6/yr", - "Emissions|C6F14": "kt C6F14/yr", - "Emissions|CF4": "kt CF4/yr", - "Emissions|HFC|HFC125": "kt HFC125/yr", - "Emissions|HFC|HFC134a": "kt HFC134a/yr", - "Emissions|HFC|HFC143a": "kt HFC143a/yr", - "Emissions|HFC|HFC227ea": "kt HFC227ea/yr", - "Emissions|HFC|HFC23": "kt HFC23/yr", - "Emissions|HFC|HFC32": "kt HFC32/yr", - # inconsistent notation between iam-units and common-definitions - # "Emissions|HFC|HFC43-10": "kt HFC43-10/yr", +ALL_KYOTO_SPECIES = { + "Emissions|CO2", + "Emissions|CH4", + "Emissions|N2O", + "Emissions|HFC125", + "Emissions|HFC134a", + "Emissions|HFC143a", + "Emissions|HFC152a", + "Emissions|HFC227ea", + "Emissions|HFC23", + "Emissions|HFC236fa", + "Emissions|HFC245fa", + "Emissions|HFC32", + "Emissions|HFC365mfc", + "Emissions|HFC4310mee", + "Emissions|NF3", + "Emissions|SF6", + "Emissions|C2F6", + "Emissions|C3F8", + "Emissions|C4F10", + "Emissions|C5F12", + "Emissions|C6F14", + "Emissions|C7F16", + "Emissions|C8F18", + "Emissions|CF4", + "Emissions|cC4F8", } From fd1d5c42a823dbc89a019c27e68f743543fdb644 Mon Sep 17 00:00:00 2001 From: Daniel Huppmann Date: Sat, 28 Mar 2026 16:44:54 +0100 Subject: [PATCH 4/6] Add to release notes --- RELEASE_NOTES.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index a63a402c5..226ca8638 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,7 @@ # Next Release +- [#952](https://github.com/IAMconsortium/pyam/pull/952) Add an `aggregate_kyoto_gases()` method + # Release v3.3.0 ## Highlights From 330610660268f4b327035c32688e55eea84f0706 Mon Sep 17 00:00:00 2001 From: Daniel Huppmann Date: Sat, 11 Apr 2026 07:26:49 +0200 Subject: [PATCH 5/6] Refactor name of internal `aggregate_data` function --- pyam/aggregation.py | 4 ++-- pyam/core.py | 9 +++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/pyam/aggregation.py b/pyam/aggregation.py index b4b4f289b..efe17fede 100644 --- a/pyam/aggregation.py +++ b/pyam/aggregation.py @@ -13,7 +13,7 @@ logger = logging.getLogger(__name__) -def _aggregate(df, variable, components=None, method="sum"): +def aggregate_data(df, variable, components=None, method="sum"): """Internal implementation of the `aggregate` function""" if components is not None: @@ -74,7 +74,7 @@ def _aggregate_recursive(df, variable, recursive): var_list = {reduce_hierarchy(v, -1) for v in components} # a temporary dataframe allows to distinguish between full data and new data - _data_agg = _aggregate(_df, variable=var_list) + _data_agg = aggregate_data(_df, variable=var_list) # check if data for intermediate variables already exists with adjust_log_level("pyam.core"): diff --git a/pyam/core.py b/pyam/core.py index 28f1bdb45..37ddc7dc8 100755 --- a/pyam/core.py +++ b/pyam/core.py @@ -24,11 +24,11 @@ from pyam._compare import _compare from pyam.aggregation import ( - _aggregate, _aggregate_recursive, _aggregate_region, _aggregate_time, _group_and_agg, + aggregate_data, ) from pyam.compute import IamComputeAccessor from pyam.exceptions import format_log_message, raise_data_error @@ -247,6 +247,7 @@ def _finalize(self, data, append, **args): else: if data is None or data.empty: return _empty_iamframe(self.dimensions + ["value"]) + return IamDataFrame(data, meta=self.meta, **args) def __getitem__(self, key): @@ -1460,7 +1461,7 @@ def aggregate( _aggregate_recursive(self, variable, recursive), meta=self.meta ) else: - _df = _aggregate(self, variable, components=components, method=method) + _df = aggregate_data(self, variable, components=components, method=method) # append to `self` or return as `IamDataFrame` return self._finalize(_df, append=append) @@ -1500,7 +1501,7 @@ def check_aggregate( """ # compute aggregate from components, return None if no components - df_components = _aggregate(self, variable, components, method) + df_components = aggregate_data(self, variable, components, method) if df_components is None: return @@ -1865,7 +1866,7 @@ def check_internal_consistency(self, components=False, **kwargs): ] ] - def aggregate_kyoto_gases(self, *, metric: str, append: bool = False): + def aggregate_kyoto_ghg(self, *, metric: str, append: bool = False): """Compute the aggregate Kyoto gases from a set of species using a GWP metric metric: str From 0bf0de6ab5fde1901919906f6bb51a280511391a Mon Sep 17 00:00:00 2001 From: Daniel Huppmann Date: Sat, 11 Apr 2026 07:31:13 +0200 Subject: [PATCH 6/6] Refactor implementation --- pyam/core.py | 13 +++++++--- pyam/emissions.py | 54 ++++++++++++++++++++--------------------- tests/test_emissions.py | 6 ++--- 3 files changed, 39 insertions(+), 34 deletions(-) diff --git a/pyam/core.py b/pyam/core.py index 37ddc7dc8..2757ce752 100755 --- a/pyam/core.py +++ b/pyam/core.py @@ -11,7 +11,7 @@ import pandas as pd from pandas.api.types import is_integer -from pyam.emissions import SPECIES_UNIT_MAPPING, aggregate_kyoto_gases +from pyam.emissions import aggregate_kyoto_ghg from pyam.netcdf import to_xarray try: @@ -1881,10 +1881,15 @@ def aggregate_kyoto_ghg(self, *, metric: str, append: bool = False): pyam.IamDataFrame.convert_unit """ - data = concat(aggregate_kyoto_gases(self, metric)).aggregate( - f"Emissions|Kyoto Gases [{metric}]", components=SPECIES_UNIT_MAPPING.keys() + return self._finalize( + aggregate_kyoto_ghg( + self, + metric, + f"Emissions|Kyoto Gases [{metric}]", + "Mt CO2-equiv/yr", + ), + append=append, ) - return self._finalize(data, append=append) def slice(self, *, keep=True, **kwargs): """Return a (filtered) slice object of the IamDataFrame timeseries data index diff --git a/pyam/emissions.py b/pyam/emissions.py index 0eabfc6c9..507ad08d3 100644 --- a/pyam/emissions.py +++ b/pyam/emissions.py @@ -1,22 +1,24 @@ +from pyam.aggregation import aggregate_data from pyam.exceptions import raise_data_error -REQUIRED_SPECIES = ["Emissions|CO2", "Emissions|CH4", "Emissions|N2O"] +REQUIRED_KYOTO_SPECIES = ["Emissions|CO2", "Emissions|CH4", "Emissions|N2O"] + ALL_KYOTO_SPECIES = { "Emissions|CO2", "Emissions|CH4", "Emissions|N2O", - "Emissions|HFC125", - "Emissions|HFC134a", - "Emissions|HFC143a", - "Emissions|HFC152a", - "Emissions|HFC227ea", - "Emissions|HFC23", - "Emissions|HFC236fa", - "Emissions|HFC245fa", - "Emissions|HFC32", - "Emissions|HFC365mfc", - "Emissions|HFC4310mee", + "Emissions|HFC|HFC125", + "Emissions|HFC|HFC134a", + "Emissions|HFC|HFC143a", + "Emissions|HFC|HFC152a", + "Emissions|HFC|HFC227ea", + "Emissions|HFC|HFC23", + "Emissions|HFC|HFC236fa", + "Emissions|HFC|HFC245fa", + "Emissions|HFC|HFC32", + "Emissions|HFC|HFC365mfc", + "Emissions|HFC|HFC4310mee", "Emissions|NF3", "Emissions|SF6", "Emissions|C2F6", @@ -31,20 +33,18 @@ } -def aggregate_kyoto_gases(df, metric: str): - """Internal implementation of the `aggregate_kyoto_gases` function""" +def aggregate_kyoto_ghg(df, metric: str, target_variable: str, target_unit: str): + """Internal implementation of the `aggregate_kyoto_ghg` function""" + + _df = df.filter(variable=ALL_KYOTO_SPECIES) - missing = df.require_data(variable=REQUIRED_SPECIES) + missing = _df.require_data(variable=REQUIRED_KYOTO_SPECIES) if missing is not None: - raise_data_error("Missing species for aggregation", missing) - - df_list = list() - for species, unit in SPECIES_UNIT_MAPPING.items(): - if species in df.variable: - df_list.append( - df.filter(variable=species).convert_unit( - unit, "Mt CO2-equiv/yr", context=metric - ) - ) - - return df_list + raise_data_error( + "Missing emission species required for Kyoto GHG aggregation", missing + ) + + for unit in _df.unit: + _df.convert_unit(unit, target_unit, context=metric, inplace=True) + + return aggregate_data(_df, target_variable, components=_df.variable) diff --git a/tests/test_emissions.py b/tests/test_emissions.py index a1366af01..ee2bb35c0 100644 --- a/tests/test_emissions.py +++ b/tests/test_emissions.py @@ -42,10 +42,10 @@ def test_kyoto_ghg(append): if append: obs = df.copy() - obs.aggregate_kyoto_gases(metric="AR6GWP100", append=append) + obs.aggregate_kyoto_ghg(metric="AR6GWP100", append=append) exp = df.append(exp) else: - obs = df.aggregate_kyoto_gases(metric="AR6GWP100") + obs = df.aggregate_kyoto_ghg(metric="AR6GWP100") assert_iamframe_equal(exp, obs) @@ -57,4 +57,4 @@ def test_kyoto_ghg_raises(): match = "Missing species for aggregation:.* scenario_a Emissions|CH4" with pytest.raises(ValueError, match=match): - df.aggregate_kyoto_gases(metric="AR6GWP100") + df.aggregate_kyoto_ghg(metric="AR6GWP100")