diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 2ff70acae..551372259 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,6 +1,7 @@ -# Next Release +# Next release - [#813](https://github.com/IAMconsortium/pyam/pull/813) Fix a corner case in region-aggregation with missing data +- [#797](https://github.com/IAMconsortium/pyam/pull/797) Add `to_ixmp4()` method to write to an **ixmp4** platform # Release v2.1.0 diff --git a/pyam/core.py b/pyam/core.py index 7c61e41a0..7f1937538 100755 --- a/pyam/core.py +++ b/pyam/core.py @@ -11,6 +11,9 @@ from pathlib import Path from tempfile import TemporaryDirectory +import ixmp4 + +from pyam.ixmp4 import write_to_ixmp4 from pyam.slice import IamSlice from pyam.filter import filter_by_time_domain, filter_by_year, filter_by_dt_arg @@ -2333,6 +2336,16 @@ def diff(self, mapping, periods=1, append=False): # append to `self` or return as `IamDataFrame` return self._finalize(_value, append=append) + def to_ixmp4(self, platform: ixmp4.Platform): + """Save all scenarios as new default runs in an ixmp4 platform database instance + + Parameters + ---------- + platform : :class:`ixmp4.Platform` or str + The ixmp4 platform database instance to which the scenario data is saved + """ + write_to_ixmp4(platform, self) + def _to_file_format(self, iamc_index): """Return a dataframe suitable for writing to a file""" df = self.timeseries(iamc_index=iamc_index).reset_index() diff --git a/pyam/ixmp4.py b/pyam/ixmp4.py new file mode 100644 index 000000000..19a3ce8bc --- /dev/null +++ b/pyam/ixmp4.py @@ -0,0 +1,42 @@ +import ixmp4 +from ixmp4.core.region import RegionModel +from ixmp4.core.unit import UnitModel + + +def write_to_ixmp4(platform: ixmp4.Platform | str, df): + """Save all scenarios as new default runs in an ixmp4 platform database instance + + Parameters + ---------- + platform : :class:`ixmp4.Platform` or str + The ixmp4 platform database instance to which the scenario data is saved + df : pyam.IamDataFrame + The IamDataFrame instance with scenario data + """ + if df.time_domain != "year": + raise NotImplementedError("Only time_domain='year' is supported for now") + + if not isinstance(platform, ixmp4.Platform): + platform = ixmp4.Platform(platform) + + # TODO: implement a try-except to roll back changes if any error writing to platform + # depends on https://github.com/iiasa/ixmp4/issues/29 + # quickfix: ensure that units and regions exist before writing + for dimension, values, model in [ + ("regions", df.region, RegionModel), + ("units", df.unit, UnitModel), + ]: + platform_values = getattr(platform, dimension).tabulate().name.values + if missing := set(values).difference(platform_values): + raise model.NotFound( + ", ".join(missing) + + f". Use `Platform.{dimension}.create()` to add the missing {dimension}." + ) + + for model, scenario in df.index: + _df = df.filter(model=model, scenario=scenario) + + run = platform.runs.create(model=model, scenario=scenario) + run.iamc.add(_df.data) + run.meta = dict(_df.meta.iloc[0]) + run.set_as_default() diff --git a/pyam/utils.py b/pyam/utils.py index 9b3792b7a..6bfefe616 100644 --- a/pyam/utils.py +++ b/pyam/utils.py @@ -228,7 +228,7 @@ def _intuit_column_groups(df, index, include_index=False): elif isinstance(df, pd.DataFrame): existing_cols = existing_cols.union(df.columns) - # check that there is no column in the timeseries data with reserved names + # check that there is no unnamed column in the timeseries data if None in existing_cols: raise ValueError("Unnamed column in timeseries data: None") diff --git a/setup.cfg b/setup.cfg index 5c2603f0e..4fd25c099 100644 --- a/setup.cfg +++ b/setup.cfg @@ -27,7 +27,7 @@ python_requires = >=3.10, <3.12 # Please also add a section "Dependency changes" to the release notes install_requires = iam-units >= 2020.4.21 - ixmp4 >= 0.4.0 + ixmp4 >= 0.6.0 numpy >= 1.23.0, < 1.24 # requests included via ixmp4 # httpx[http2] included via ixmp4 diff --git a/tests/test_ixmp4.py b/tests/test_ixmp4.py new file mode 100644 index 000000000..2367a4182 --- /dev/null +++ b/tests/test_ixmp4.py @@ -0,0 +1,35 @@ +import pytest +from ixmp4.core import Platform +from ixmp4.data.backend import SqliteTestBackend +from ixmp4.core.region import RegionModel +from ixmp4.core.unit import UnitModel + + +def test_to_ixmp4_missing_region_raises(test_df_year): + """Writing to platform raises if region not defined""" + platform = Platform(_backend=SqliteTestBackend()) + with pytest.raises(RegionModel.NotFound, match="World. Use `Platform.regions."): + test_df_year.to_ixmp4(platform=platform) + + +def test_to_ixmp4_missing_unit_raises(test_df_year): + """Writing to platform raises if unit not defined""" + platform = Platform(_backend=SqliteTestBackend()) + platform.regions.create(name="World", hierarchy="common") + with pytest.raises(UnitModel.NotFound, match="EJ/yr. Use `Platform.units."): + test_df_year.to_ixmp4(platform=platform) + + +def test_ixmp4_integration(test_df): + """Write an IamDataFrame to the platform""" + platform = Platform(_backend=SqliteTestBackend()) + platform.regions.create(name="World", hierarchy="common") + platform.units.create(name="EJ/yr") + + if test_df.time_domain == "year": + test_df.to_ixmp4(platform=platform) + else: + with pytest.raises(NotImplementedError): + test_df.to_ixmp4(platform=platform) + + # TODO add test for reading data from ixmp4 platform