Skip to content

Commit 16e6f3e

Browse files
jbrockmendelyehoshuadimarsky
authored andcommitted
POC/ENH: Timedelta min/max/resolution support non-nano (pandas-dev#47641)
ENH: Timedelta min/max/resolution support non-nano
1 parent ac411d4 commit 16e6f3e

File tree

2 files changed

+106
-16
lines changed

2 files changed

+106
-16
lines changed

pandas/_libs/tslibs/timedeltas.pyx

+64-8
Original file line numberDiff line numberDiff line change
@@ -950,14 +950,18 @@ cdef _timedelta_from_value_and_reso(int64_t value, NPY_DATETIMEUNIT reso):
950950
cdef:
951951
_Timedelta td_base
952952

953+
# For millisecond and second resos, we cannot actually pass int(value) because
954+
# many cases would fall outside of the pytimedelta implementation bounds.
955+
# We pass 0 instead, and override seconds, microseconds, days.
956+
# In principle we could pass 0 for ns and us too.
953957
if reso == NPY_FR_ns:
954958
td_base = _Timedelta.__new__(Timedelta, microseconds=int(value) // 1000)
955959
elif reso == NPY_DATETIMEUNIT.NPY_FR_us:
956960
td_base = _Timedelta.__new__(Timedelta, microseconds=int(value))
957961
elif reso == NPY_DATETIMEUNIT.NPY_FR_ms:
958-
td_base = _Timedelta.__new__(Timedelta, milliseconds=int(value))
962+
td_base = _Timedelta.__new__(Timedelta, milliseconds=0)
959963
elif reso == NPY_DATETIMEUNIT.NPY_FR_s:
960-
td_base = _Timedelta.__new__(Timedelta, seconds=int(value))
964+
td_base = _Timedelta.__new__(Timedelta, seconds=0)
961965
# Other resolutions are disabled but could potentially be implemented here:
962966
# elif reso == NPY_DATETIMEUNIT.NPY_FR_m:
963967
# td_base = _Timedelta.__new__(Timedelta, minutes=int(value))
@@ -977,6 +981,34 @@ cdef _timedelta_from_value_and_reso(int64_t value, NPY_DATETIMEUNIT reso):
977981
return td_base
978982

979983

984+
class MinMaxReso:
985+
"""
986+
We need to define min/max/resolution on both the Timedelta _instance_
987+
and Timedelta class. On an instance, these depend on the object's _reso.
988+
On the class, we default to the values we would get with nanosecond _reso.
989+
"""
990+
def __init__(self, name):
991+
self._name = name
992+
993+
def __get__(self, obj, type=None):
994+
if self._name == "min":
995+
val = np.iinfo(np.int64).min + 1
996+
elif self._name == "max":
997+
val = np.iinfo(np.int64).max
998+
else:
999+
assert self._name == "resolution"
1000+
val = 1
1001+
1002+
if obj is None:
1003+
# i.e. this is on the class, default to nanos
1004+
return Timedelta(val)
1005+
else:
1006+
return Timedelta._from_value_and_reso(val, obj._reso)
1007+
1008+
def __set__(self, obj, value):
1009+
raise AttributeError(f"{self._name} is not settable.")
1010+
1011+
9801012
# Similar to Timestamp/datetime, this is a construction requirement for
9811013
# timedeltas that we need to do object instantiation in python. This will
9821014
# serve as a C extension type that shadows the Python class, where we do any
@@ -990,6 +1022,36 @@ cdef class _Timedelta(timedelta):
9901022

9911023
# higher than np.ndarray and np.matrix
9921024
__array_priority__ = 100
1025+
min = MinMaxReso("min")
1026+
max = MinMaxReso("max")
1027+
resolution = MinMaxReso("resolution")
1028+
1029+
@property
1030+
def days(self) -> int: # TODO(cython3): make cdef property
1031+
# NB: using the python C-API PyDateTime_DELTA_GET_DAYS will fail
1032+
# (or be incorrect)
1033+
self._ensure_components()
1034+
return self._d
1035+
1036+
@property
1037+
def seconds(self) -> int: # TODO(cython3): make cdef property
1038+
# NB: using the python C-API PyDateTime_DELTA_GET_SECONDS will fail
1039+
# (or be incorrect)
1040+
self._ensure_components()
1041+
return self._h * 3600 + self._m * 60 + self._s
1042+
1043+
@property
1044+
def microseconds(self) -> int: # TODO(cython3): make cdef property
1045+
# NB: using the python C-API PyDateTime_DELTA_GET_MICROSECONDS will fail
1046+
# (or be incorrect)
1047+
self._ensure_components()
1048+
return self._ms * 1000 + self._us
1049+
1050+
def total_seconds(self) -> float:
1051+
"""Total seconds in the duration."""
1052+
# We need to override bc we overrided days/seconds/microseconds
1053+
# TODO: add nanos/1e9?
1054+
return self.days * 24 * 3600 + self.seconds + self.microseconds / 1_000_000
9931055

9941056
@property
9951057
def freq(self) -> None:
@@ -1979,9 +2041,3 @@ cdef _broadcast_floordiv_td64(
19792041
res = res.astype('f8')
19802042
res[mask] = np.nan
19812043
return res
1982-
1983-
1984-
# resolution in ns
1985-
Timedelta.min = Timedelta(np.iinfo(np.int64).min + 1)
1986-
Timedelta.max = Timedelta(np.iinfo(np.int64).max)
1987-
Timedelta.resolution = Timedelta(nanoseconds=1)

pandas/tests/scalar/timedelta/test_timedelta.py

+42-8
Original file line numberDiff line numberDiff line change
@@ -101,18 +101,23 @@ def test_as_unit_non_nano(self):
101101

102102

103103
class TestNonNano:
104-
@pytest.fixture(params=[7, 8, 9])
105-
def unit(self, request):
106-
# 7, 8, 9 correspond to second, millisecond, and microsecond, respectively
104+
@pytest.fixture(params=["s", "ms", "us"])
105+
def unit_str(self, request):
107106
return request.param
108107

108+
@pytest.fixture
109+
def unit(self, unit_str):
110+
# 7, 8, 9 correspond to second, millisecond, and microsecond, respectively
111+
attr = f"NPY_FR_{unit_str}"
112+
return getattr(NpyDatetimeUnit, attr).value
113+
109114
@pytest.fixture
110115
def val(self, unit):
111116
# microsecond that would be just out of bounds for nano
112117
us = 9223372800000000
113-
if unit == 9:
118+
if unit == NpyDatetimeUnit.NPY_FR_us.value:
114119
value = us
115-
elif unit == 8:
120+
elif unit == NpyDatetimeUnit.NPY_FR_ms.value:
116121
value = us // 1000
117122
else:
118123
value = us // 1_000_000
@@ -166,11 +171,11 @@ def test_to_timedelta64(self, td, unit):
166171

167172
assert isinstance(res, np.timedelta64)
168173
assert res.view("i8") == td.value
169-
if unit == 7:
174+
if unit == NpyDatetimeUnit.NPY_FR_s.value:
170175
assert res.dtype == "m8[s]"
171-
elif unit == 8:
176+
elif unit == NpyDatetimeUnit.NPY_FR_ms.value:
172177
assert res.dtype == "m8[ms]"
173-
elif unit == 9:
178+
elif unit == NpyDatetimeUnit.NPY_FR_us.value:
174179
assert res.dtype == "m8[us]"
175180

176181
def test_truediv_timedeltalike(self, td):
@@ -266,6 +271,35 @@ def test_addsub_mismatched_reso(self, td):
266271
with pytest.raises(ValueError, match=msg):
267272
other2 - td
268273

274+
def test_min(self, td):
275+
assert td.min <= td
276+
assert td.min._reso == td._reso
277+
assert td.min.value == NaT.value + 1
278+
279+
def test_max(self, td):
280+
assert td.max >= td
281+
assert td.max._reso == td._reso
282+
assert td.max.value == np.iinfo(np.int64).max
283+
284+
def test_resolution(self, td):
285+
expected = Timedelta._from_value_and_reso(1, td._reso)
286+
result = td.resolution
287+
assert result == expected
288+
assert result._reso == expected._reso
289+
290+
291+
def test_timedelta_class_min_max_resolution():
292+
# when accessed on the class (as opposed to an instance), we default
293+
# to nanoseconds
294+
assert Timedelta.min == Timedelta(NaT.value + 1)
295+
assert Timedelta.min._reso == NpyDatetimeUnit.NPY_FR_ns.value
296+
297+
assert Timedelta.max == Timedelta(np.iinfo(np.int64).max)
298+
assert Timedelta.max._reso == NpyDatetimeUnit.NPY_FR_ns.value
299+
300+
assert Timedelta.resolution == Timedelta(1)
301+
assert Timedelta.resolution._reso == NpyDatetimeUnit.NPY_FR_ns.value
302+
269303

270304
class TestTimedeltaUnaryOps:
271305
def test_invert(self):

0 commit comments

Comments
 (0)