Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

POC/ENH: Timedelta min/max/resolution support non-nano #47641

Merged
merged 1 commit into from
Jul 8, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
mroeschke marked this conversation as resolved.
Show resolved Hide resolved
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