Skip to content

Commit c63f39f

Browse files
authored
Accept mixed time column in IAMC data (#251)
* allow supplying years and datetime values in one "time" column * add mixed time format core tests * fix comment length * fix docstring indentation * delete doubled `.drop()` call in `RunIamcData.remove()` * fix pandas edgecase when only pd.Timestamps are supplied * skip creating the year and datetime columns if no ANNUAL/DATETIME values exist
1 parent d4aba97 commit c63f39f

2 files changed

Lines changed: 158 additions & 36 deletions

File tree

ixmp4/core/iamc/data.py

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from typing import TYPE_CHECKING, TypeVar
22

3+
import numpy as np
34
import pandas as pd
45

56
# TODO Import this from typing when dropping Python 3.11
@@ -34,10 +35,23 @@ def _rename_arg_cols(df: pd.DataFrame) -> pd.DataFrame:
3435
"category": "step_category",
3536
"subannual": "step_category",
3637
"datetime": "step_datetime",
37-
"time": "step_datetime",
3838
}
3939
)
4040

41+
@staticmethod
42+
def _split_time_col(df: pd.DataFrame) -> pd.DataFrame:
43+
time = df["time"]
44+
is_year = time.apply(lambda x: isinstance(x, (int, np.integer)))
45+
46+
if is_year.any():
47+
df["year"] = pd.Series(np.nan, index=df.index, dtype="object")
48+
df.loc[is_year, "year"] = time.loc[is_year]
49+
50+
if not is_year.all():
51+
df["datetime"] = pd.to_datetime(time.where(~is_year), errors="coerce")
52+
53+
return df.drop(columns=["time"])
54+
4155
@classmethod
4256
def _convert_to_std_format(
4357
cls, df: pd.DataFrame, join_runs: bool, join_run_id: bool
@@ -104,8 +118,8 @@ def _get_or_create_ts(self, df: pd.DataFrame) -> pd.DataFrame:
104118
ts_df = ts_df.rename(columns={"id": "time_series__id"})
105119

106120
# merge on the identity columns
107-
return pd.merge(
108-
df, ts_df, how="left", on=id_cols, suffixes=(None, "_y")
121+
return pd.merge(df, ts_df, how="left", on=id_cols, suffixes=(None, "_y")).drop(
122+
columns=id_cols
109123
) # tada, df with 'time_series__id' added from the database.
110124

111125
def add(self, df: pd.DataFrame, type: Type | str | None = None) -> None:
@@ -142,9 +156,13 @@ def add(self, df: pd.DataFrame, type: Type | str | None = None) -> None:
142156
- value
143157
144158
Any combination of:
145-
- step_year for ANNUAL data points
146-
- step_year and step_category for CATEGORICAL data points
147-
- step_datetime for DATETIME data points
159+
- (``year`` or ``step_year``) for ANNUAL data points
160+
- (``year`` or ``step_year``) and (``category`` or ``step_category``)
161+
for CATEGORICAL data points
162+
- (``datetime`` or ``step_datetime``) for DATETIME data points
163+
- ``time`` with integer and datetime values and optionally
164+
(``category`` or ``step_category``) for integer (year) rows.
165+
``time`` will overwrite the ``year`` and ``datetime`` columns.
148166
149167
You may optionally supply the type column for mixed data points:
150168
- type
@@ -162,6 +180,8 @@ def add(self, df: pd.DataFrame, type: Type | str | None = None) -> None:
162180
"""
163181

164182
self._run.require_lock()
183+
if "time" in df.columns:
184+
df = self._split_time_col(df)
165185
df = self._rename_arg_cols(df)
166186
df["run__id"] = self._run.id
167187
df = self._get_or_create_ts(df)
@@ -221,6 +241,8 @@ def remove(self, df: pd.DataFrame, type: Type | str | None = None) -> None:
221241
``with run.transact("message"):``.
222242
"""
223243
self._run.require_lock()
244+
if "time" in df.columns:
245+
df = self._split_time_col(df)
224246
df = self._rename_arg_cols(df)
225247
df["run__id"] = self._run.id
226248
# NOTE: This creates ts and deletes them right after
@@ -230,7 +252,6 @@ def remove(self, df: pd.DataFrame, type: Type | str | None = None) -> None:
230252
type = Type[type.upper()]
231253
df["type"] = type
232254

233-
df = df.drop(columns=["unit", "variable", "region"])
234255
self._backend.iamc.datapoints.bulk_delete(df)
235256

236257
def tabulate(

tests/core/test_iamc_data.py

Lines changed: 130 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -680,6 +680,32 @@ def expected_data(self) -> pd.DataFrame:
680680
def input_data(self, expected_data: pd.DataFrame) -> pd.DataFrame:
681681
return expected_data.copy()
682682

683+
def _canonical_sort_mixed_safe(self, df: pd.DataFrame) -> pd.DataFrame:
684+
"""Computes a sort index with categorical and object dtype special
685+
case handling. Then uses the index to sort the original df."""
686+
sorted_cols = df.columns.sort_values().to_list()
687+
sort_df = df.copy()
688+
689+
for col in sorted_cols:
690+
series = sort_df[col]
691+
692+
if isinstance(series.dtype, pd.CategoricalDtype):
693+
# Avoid unordered categorical sort errors.
694+
sort_df[col] = series.astype("string")
695+
continue
696+
697+
if pd.api.types.is_object_dtype(series):
698+
try:
699+
series.sort_values()
700+
except TypeError:
701+
# Normalize only non-orderable mixed object columns.
702+
sort_df[col] = series.map(
703+
lambda v: "" if pd.isna(v) else f"{type(v).__name__}:{v}"
704+
)
705+
706+
order = sort_df.sort_values(by=sorted_cols).index
707+
return df.loc[order].reset_index(drop=True)
708+
683709
def test_iamc_data_input(
684710
self,
685711
run: ixmp4.Run,
@@ -691,8 +717,8 @@ def test_iamc_data_input(
691717

692718
ret = run.iamc.tabulate()
693719
pdt.assert_frame_equal(
694-
self.canonical_sort(expected_data),
695-
self.canonical_sort(ret),
720+
self._canonical_sort_mixed_safe(expected_data),
721+
self._canonical_sort_mixed_safe(ret),
696722
check_like=True,
697723
)
698724

@@ -702,10 +728,26 @@ def test_iamc_data_input(
702728
assert run.iamc.tabulate().empty
703729

704730

705-
class TestAnnualIamcInputData(IamcDataAnnual, IamcDataInputTest):
706-
@pytest.fixture
707-
def expected_data(self, test_data_add: pd.DataFrame) -> pd.DataFrame:
708-
return test_data_add.copy()
731+
class TestAnnualIamcInputData(IamcDataInputTest):
732+
@pytest.fixture(scope="class")
733+
def expected_data(
734+
self,
735+
regions: list[ixmp4.Region],
736+
units: list[ixmp4.Unit],
737+
) -> pd.DataFrame:
738+
return pd.DataFrame(
739+
[
740+
["Region 1", "Unit 1", "Variable 1", 2000, 1.1],
741+
["Region 1", "Unit 1", "Variable 1", 2010, 1.3],
742+
["Region 1", "Unit 2", "Variable 2", 2020, 1.5],
743+
["Region 1", "Unit 2", "Variable 2", 2030, 1.7],
744+
["Region 2", "Unit 1", "Variable 1", 2000, 2.1],
745+
["Region 2", "Unit 1", "Variable 1", 2010, 2.3],
746+
["Region 2", "Unit 2", "Variable 2", 2020, 2.5],
747+
["Region 2", "Unit 2", "Variable 2", 2030, 2.7],
748+
],
749+
columns=["region", "unit", "variable", "year", "value"],
750+
).astype({"year": "Int64"})
709751

710752
@pytest.fixture
711753
def input_data(self, expected_data: pd.DataFrame) -> pd.DataFrame:
@@ -722,15 +764,12 @@ class TestCategoricalIamcInputData(IamcDataInputTest):
722764
@pytest.fixture
723765
def expected_data(self) -> pd.DataFrame:
724766
return pd.DataFrame(
725-
{
726-
"region": ["Region 1", "Region 2"],
727-
"variable": ["Variable 1", "Variable 2"],
728-
"unit": ["Unit 1", "Unit 2"],
729-
"year": pd.Series([2000, 2010], dtype="Int64"),
730-
"subannual": ["Summer", "Winter"],
731-
"value": [1.1, 2.3],
732-
}
733-
)
767+
[
768+
["Region 1", "Variable 1", "Unit 1", 2000, "Summer", 1.1],
769+
["Region 2", "Variable 2", "Unit 2", 2010, "Winter", 2.3],
770+
],
771+
columns=["region", "variable", "unit", "year", "subannual", "value"],
772+
).astype({"year": "Int64"})
734773

735774
@pytest.fixture
736775
def input_data(self, expected_data: pd.DataFrame) -> pd.DataFrame:
@@ -748,33 +787,95 @@ class TestDatetimeIamcInputData(IamcDataInputTest):
748787
@pytest.fixture
749788
def expected_data(self) -> pd.DataFrame:
750789
return pd.DataFrame(
751-
{
752-
"region": ["Region 1", "Region 2"],
753-
"variable": ["Variable 1", "Variable 2"],
754-
"unit": ["Unit 1", "Unit 2"],
755-
"time": pd.to_datetime(["2000-01-01 00:00:00", "2010-06-01 12:34:56"]),
756-
"value": [1.1, 2.3],
757-
}
790+
[
791+
[
792+
"Region 1",
793+
"Variable 1",
794+
"Unit 1",
795+
pd.Timestamp("2000-01-01 00:00:00"),
796+
1.1,
797+
],
798+
[
799+
"Region 2",
800+
"Variable 2",
801+
"Unit 2",
802+
pd.Timestamp("2010-06-01 12:34:56"),
803+
2.3,
804+
],
805+
],
806+
columns=["region", "variable", "unit", "time", "value"],
807+
)
808+
809+
810+
class TestDatetimeAsStringIamcInputData(TestDatetimeIamcInputData):
811+
@pytest.fixture
812+
def input_data(self, expected_data: pd.DataFrame) -> pd.DataFrame:
813+
input_df = expected_data.copy()
814+
input_df["time"] = (
815+
input_df["time"].dt.strftime("%Y-%m-%d %H:%M:%S").astype("string")
816+
)
817+
return input_df
818+
819+
820+
class TestSingleRowDatetimeIamcInputData(IamcDataInputTest):
821+
@pytest.fixture
822+
def expected_data(self) -> pd.DataFrame:
823+
return pd.DataFrame(
824+
[
825+
[
826+
"Region 1",
827+
"Variable 1",
828+
"Unit 1",
829+
pd.Timestamp("2000-01-01 00:00:00"),
830+
1.1,
831+
],
832+
],
833+
columns=["region", "variable", "unit", "time", "value"],
758834
)
759835

760836
@pytest.fixture
761837
def input_data(self, expected_data: pd.DataFrame) -> pd.DataFrame:
762838
input_df = expected_data.copy()
763-
input_df["region"] = input_df["region"].astype("category")
764-
input_df["unit"] = input_df["unit"].astype("category")
765-
input_df["variable"] = input_df["variable"].astype("category")
766839
input_df["time"] = (
767840
input_df["time"].dt.strftime("%Y-%m-%d %H:%M:%S").astype("string")
768841
)
769-
input_df["value"] = input_df["value"].astype("float32")
770842
return input_df
771843

772844

773-
class TestObjectStringsIamcInputData(IamcDataAnnual, IamcDataInputTest):
845+
class TestMixedIamcInputData(IamcDataInputTest):
774846
@pytest.fixture
775-
def expected_data(self, test_data_add: pd.DataFrame) -> pd.DataFrame:
776-
return test_data_add.copy()
847+
def expected_data(self) -> pd.DataFrame:
848+
return pd.DataFrame(
849+
[
850+
# ANNUAL
851+
["Region 1", "Variable 1", "Unit 1", 2000, None, 0.1],
852+
["Region 2", "Variable 2", "Unit 2", 2010, None, 0.23],
853+
# CATEGORICAL
854+
["Region 1", "Variable 1", "Unit 1", 2000, "Summer", 1.1],
855+
["Region 2", "Variable 2", "Unit 2", 2010, "Winter", 2.3],
856+
# DATETIME
857+
[
858+
"Region 1",
859+
"Variable 1",
860+
"Unit 1",
861+
pd.Timestamp("2000-01-01 00:00:00"),
862+
None,
863+
101.0,
864+
],
865+
[
866+
"Region 2",
867+
"Variable 2",
868+
"Unit 2",
869+
pd.Timestamp("2010-06-01 12:34:56"),
870+
None,
871+
3.14,
872+
],
873+
],
874+
columns=["region", "variable", "unit", "time", "subannual", "value"],
875+
)
876+
777877

878+
class TestObjectStringsIamcInputData(TestAnnualIamcInputData):
778879
@pytest.fixture
779880
def input_data(self, expected_data: pd.DataFrame) -> pd.DataFrame:
780881
input_df = expected_data.copy()

0 commit comments

Comments
 (0)