From 416b3cc053945df6ffed3d63c5a71d242b203e88 Mon Sep 17 00:00:00 2001 From: Brock Date: Fri, 8 Jul 2022 08:54:47 -0700 Subject: [PATCH] ENH: Timedelta min/max/resolution support non-nano --- pandas/_libs/tslibs/timedeltas.pyx | 72 ++++++++++++++++--- .../tests/scalar/timedelta/test_timedelta.py | 50 ++++++++++--- 2 files changed, 106 insertions(+), 16 deletions(-) diff --git a/pandas/_libs/tslibs/timedeltas.pyx b/pandas/_libs/tslibs/timedeltas.pyx index 5fd3e33808800..fef2a317a4f26 100644 --- a/pandas/_libs/tslibs/timedeltas.pyx +++ b/pandas/_libs/tslibs/timedeltas.pyx @@ -950,14 +950,18 @@ cdef _timedelta_from_value_and_reso(int64_t value, NPY_DATETIMEUNIT reso): cdef: _Timedelta td_base + # For millisecond and second resos, we cannot actually pass int(value) because + # many cases would fall outside of the pytimedelta implementation bounds. + # We pass 0 instead, and override seconds, microseconds, days. + # In principle we could pass 0 for ns and us too. if reso == NPY_FR_ns: td_base = _Timedelta.__new__(Timedelta, microseconds=int(value) // 1000) elif reso == NPY_DATETIMEUNIT.NPY_FR_us: td_base = _Timedelta.__new__(Timedelta, microseconds=int(value)) elif reso == NPY_DATETIMEUNIT.NPY_FR_ms: - td_base = _Timedelta.__new__(Timedelta, milliseconds=int(value)) + td_base = _Timedelta.__new__(Timedelta, milliseconds=0) elif reso == NPY_DATETIMEUNIT.NPY_FR_s: - td_base = _Timedelta.__new__(Timedelta, seconds=int(value)) + td_base = _Timedelta.__new__(Timedelta, seconds=0) # Other resolutions are disabled but could potentially be implemented here: # elif reso == NPY_DATETIMEUNIT.NPY_FR_m: # td_base = _Timedelta.__new__(Timedelta, minutes=int(value)) @@ -977,6 +981,34 @@ cdef _timedelta_from_value_and_reso(int64_t value, NPY_DATETIMEUNIT reso): return td_base +class MinMaxReso: + """ + We need to define min/max/resolution on both the Timedelta _instance_ + and Timedelta class. On an instance, these depend on the object's _reso. + On the class, we default to the values we would get with nanosecond _reso. + """ + def __init__(self, name): + self._name = name + + def __get__(self, obj, type=None): + if self._name == "min": + val = np.iinfo(np.int64).min + 1 + elif self._name == "max": + val = np.iinfo(np.int64).max + else: + assert self._name == "resolution" + val = 1 + + if obj is None: + # i.e. this is on the class, default to nanos + return Timedelta(val) + else: + return Timedelta._from_value_and_reso(val, obj._reso) + + def __set__(self, obj, value): + raise AttributeError(f"{self._name} is not settable.") + + # Similar to Timestamp/datetime, this is a construction requirement for # timedeltas that we need to do object instantiation in python. This will # serve as a C extension type that shadows the Python class, where we do any @@ -990,6 +1022,36 @@ cdef class _Timedelta(timedelta): # higher than np.ndarray and np.matrix __array_priority__ = 100 + min = MinMaxReso("min") + max = MinMaxReso("max") + resolution = MinMaxReso("resolution") + + @property + def days(self) -> int: # TODO(cython3): make cdef property + # NB: using the python C-API PyDateTime_DELTA_GET_DAYS will fail + # (or be incorrect) + self._ensure_components() + return self._d + + @property + def seconds(self) -> int: # TODO(cython3): make cdef property + # NB: using the python C-API PyDateTime_DELTA_GET_SECONDS will fail + # (or be incorrect) + self._ensure_components() + return self._h * 3600 + self._m * 60 + self._s + + @property + def microseconds(self) -> int: # TODO(cython3): make cdef property + # NB: using the python C-API PyDateTime_DELTA_GET_MICROSECONDS will fail + # (or be incorrect) + self._ensure_components() + return self._ms * 1000 + self._us + + def total_seconds(self) -> float: + """Total seconds in the duration.""" + # We need to override bc we overrided days/seconds/microseconds + # TODO: add nanos/1e9? + return self.days * 24 * 3600 + self.seconds + self.microseconds / 1_000_000 @property def freq(self) -> None: @@ -1979,9 +2041,3 @@ cdef _broadcast_floordiv_td64( res = res.astype('f8') res[mask] = np.nan return res - - -# resolution in ns -Timedelta.min = Timedelta(np.iinfo(np.int64).min + 1) -Timedelta.max = Timedelta(np.iinfo(np.int64).max) -Timedelta.resolution = Timedelta(nanoseconds=1) diff --git a/pandas/tests/scalar/timedelta/test_timedelta.py b/pandas/tests/scalar/timedelta/test_timedelta.py index f9cc1c6878068..b6559385e1597 100644 --- a/pandas/tests/scalar/timedelta/test_timedelta.py +++ b/pandas/tests/scalar/timedelta/test_timedelta.py @@ -101,18 +101,23 @@ def test_as_unit_non_nano(self): class TestNonNano: - @pytest.fixture(params=[7, 8, 9]) - def unit(self, request): - # 7, 8, 9 correspond to second, millisecond, and microsecond, respectively + @pytest.fixture(params=["s", "ms", "us"]) + def unit_str(self, request): return request.param + @pytest.fixture + def unit(self, unit_str): + # 7, 8, 9 correspond to second, millisecond, and microsecond, respectively + attr = f"NPY_FR_{unit_str}" + return getattr(NpyDatetimeUnit, attr).value + @pytest.fixture def val(self, unit): # microsecond that would be just out of bounds for nano us = 9223372800000000 - if unit == 9: + if unit == NpyDatetimeUnit.NPY_FR_us.value: value = us - elif unit == 8: + elif unit == NpyDatetimeUnit.NPY_FR_ms.value: value = us // 1000 else: value = us // 1_000_000 @@ -166,11 +171,11 @@ def test_to_timedelta64(self, td, unit): assert isinstance(res, np.timedelta64) assert res.view("i8") == td.value - if unit == 7: + if unit == NpyDatetimeUnit.NPY_FR_s.value: assert res.dtype == "m8[s]" - elif unit == 8: + elif unit == NpyDatetimeUnit.NPY_FR_ms.value: assert res.dtype == "m8[ms]" - elif unit == 9: + elif unit == NpyDatetimeUnit.NPY_FR_us.value: assert res.dtype == "m8[us]" def test_truediv_timedeltalike(self, td): @@ -266,6 +271,35 @@ def test_addsub_mismatched_reso(self, td): with pytest.raises(ValueError, match=msg): other2 - td + def test_min(self, td): + assert td.min <= td + assert td.min._reso == td._reso + assert td.min.value == NaT.value + 1 + + def test_max(self, td): + assert td.max >= td + assert td.max._reso == td._reso + assert td.max.value == np.iinfo(np.int64).max + + def test_resolution(self, td): + expected = Timedelta._from_value_and_reso(1, td._reso) + result = td.resolution + assert result == expected + assert result._reso == expected._reso + + +def test_timedelta_class_min_max_resolution(): + # when accessed on the class (as opposed to an instance), we default + # to nanoseconds + assert Timedelta.min == Timedelta(NaT.value + 1) + assert Timedelta.min._reso == NpyDatetimeUnit.NPY_FR_ns.value + + assert Timedelta.max == Timedelta(np.iinfo(np.int64).max) + assert Timedelta.max._reso == NpyDatetimeUnit.NPY_FR_ns.value + + assert Timedelta.resolution == Timedelta(1) + assert Timedelta.resolution._reso == NpyDatetimeUnit.NPY_FR_ns.value + class TestTimedeltaUnaryOps: def test_invert(self):