Skip to content

Commit

Permalink
POC/ENH: Timedelta min/max/resolution support non-nano (#47641)
Browse files Browse the repository at this point in the history
ENH: Timedelta min/max/resolution support non-nano
  • Loading branch information
jbrockmendel committed Jul 8, 2022
1 parent f6658ef commit 64ae0fe
Show file tree
Hide file tree
Showing 2 changed files with 106 additions and 16 deletions.
72 changes: 64 additions & 8 deletions pandas/_libs/tslibs/timedeltas.pyx
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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)
50 changes: 42 additions & 8 deletions pandas/tests/scalar/timedelta/test_timedelta.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down

0 comments on commit 64ae0fe

Please sign in to comment.