From f135d3bf5ea0f8027f4aa36c0771bd3afac925ec Mon Sep 17 00:00:00 2001 From: Pawel Wolff Date: Fri, 22 May 2026 17:23:14 +0200 Subject: [PATCH] date/time conversion handles null-like objects --- src/earthkit/data/utils/dates.py | 50 ++++++++++++++++-------- tests/utils/test_dates.py | 66 ++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 16 deletions(-) diff --git a/src/earthkit/data/utils/dates.py b/src/earthkit/data/utils/dates.py index e3dcfe4ba..cb637365d 100644 --- a/src/earthkit/data/utils/dates.py +++ b/src/earthkit/data/utils/dates.py @@ -20,6 +20,27 @@ STEP_RANGE_PATTERN = re.compile(r"\d+-\d+") +def _is_null_like(value): + """Return True if value is a null-like object (None, NaN, NaT, pd.NaT).""" + if value is None: + return True + + try: + # covers nan, np.datetime64('NaT') and np.timedelta64('NaT') + if np.isnan(value): + return True + except Exception: + pass + try: + # covers pd.NaT + if np.isnan(value.second): + return True + except Exception: + pass + + return False + + def _handle_complex_object_datetime(dt: np.ndarray) -> np.ndarray: """Attempt to handle complex object datetime arrays.""" dt = dt.ravel() @@ -45,6 +66,9 @@ def convert(x): def to_datetime(dt): + if _is_null_like(dt): + return None + if isinstance(dt, datetime.datetime): return dt @@ -67,22 +91,6 @@ def to_datetime(dt): if hasattr(dt, "isoformat"): return datetime.datetime.fromisoformat(dt.isoformat()) - # if dt is NaT-like, then return None - if dt is None: - return dt - try: - # covers nan, np.datetime64('NaT') and np.timedelta64('NaT') - if np.isnan(dt): - return None - except Exception: - pass - try: - # covers pd.NaT - if np.isnan(dt.second): - return None - except Exception: - pass - dt = from_object(dt) return to_datetime(dt.to_datetime()) @@ -142,6 +150,9 @@ def to_date_list(obj): def to_time(dt): + if _is_null_like(dt): + return None + if isinstance(dt, float): dt = int(dt) @@ -194,6 +205,9 @@ def to_time_list(times): def to_timedelta(td): + if _is_null_like(td): + return None + if isinstance(td, int): return datetime.timedelta(hours=td) @@ -234,11 +248,15 @@ def to_timedelta_list(td): def numpy_timedelta_to_timedelta(td): + if np.isnat(td): + return None td = td.astype("timedelta64[s]").astype(int) return datetime.timedelta(seconds=int(td)) def numpy_datetime_to_datetime(dt): + if np.isnat(dt): + return None dt = dt.astype("datetime64[s]").astype(int) return datetime.datetime.fromtimestamp(int(dt), datetime.timezone.utc).replace(tzinfo=None) diff --git a/tests/utils/test_dates.py b/tests/utils/test_dates.py index 835a3b372..83d220714 100644 --- a/tests/utils/test_dates.py +++ b/tests/utils/test_dates.py @@ -10,8 +10,10 @@ # import datetime +import math import numpy as np +import pandas as pd import pytest from earthkit.data.utils.dates import ( @@ -363,6 +365,70 @@ def test_time_to_grib(d, expected_value, error): time_to_grib(d) +@pytest.mark.parametrize( + "d", + [ + None, + math.nan, + np.nan, + np.datetime64("NaT"), + np.timedelta64("NaT"), + pd.NaT, + ], +) +def test_to_datetime_null_like(d): + assert to_datetime(d) is None + + +@pytest.mark.parametrize( + "d", + [ + None, + math.nan, + np.nan, + np.datetime64("NaT"), + np.timedelta64("NaT"), + pd.NaT, + ], +) +def test_to_time_null_like(d): + assert to_time(d) is None + + +@pytest.mark.parametrize( + "d", + [ + None, + math.nan, + np.nan, + np.timedelta64("NaT"), + pd.NaT, + ], +) +def test_to_timedelta_null_like(d): + assert to_timedelta(d) is None + + +@pytest.mark.parametrize( + "d", + [ + np.datetime64("NaT"), + ], +) +def test_numpy_datetime_to_datetime_null_like(d): + assert numpy_datetime_to_datetime(d) is None + + +@pytest.mark.parametrize( + "d", + [ + np.timedelta64("NaT"), + ], +) +def test_numpy_timedelta_to_timedelta_null_like(d): + assert numpy_timedelta_to_timedelta(d) is None + + @pytest.mark.parametrize( "step,expected_value,error", [