Skip to content

BUG: Timedelta.round near implementation bounds #39601

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

Merged
merged 6 commits into from
Feb 5, 2021
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
1 change: 1 addition & 0 deletions doc/source/whatsnew/v1.3.0.rst
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,7 @@ Datetimelike
- Bug in :class:`Categorical` incorrectly typecasting ``datetime`` object to ``Timestamp`` (:issue:`38878`)
- Bug in comparisons between :class:`Timestamp` object and ``datetime64`` objects just outside the implementation bounds for nanosecond ``datetime64`` (:issue:`39221`)
- Bug in :meth:`Timestamp.round`, :meth:`Timestamp.floor`, :meth:`Timestamp.ceil` for values near the implementation bounds of :class:`Timestamp` (:issue:`39244`)
- Bug in :meth:`Timedelta.round`, :meth:`Timedelta.floor`, :meth:`Timedelta.ceil` for values near the implementation bounds of :class:`Timedelta` (:issue:`38964`)
- Bug in :func:`date_range` incorrectly creating :class:`DatetimeIndex` containing ``NaT`` instead of raising ``OutOfBoundsDatetime`` in corner cases (:issue:`24124`)

Timedelta
Expand Down
151 changes: 151 additions & 0 deletions pandas/_libs/tslibs/fields.pyx
Original file line number Diff line number Diff line change
Expand Up @@ -636,3 +636,154 @@ def get_locale_names(name_type: str, locale: object = None):
"""
with set_locale(locale, LC_TIME):
return getattr(LocaleTime(), name_type)


# ---------------------------------------------------------------------
# Rounding


class RoundTo:
"""
enumeration defining the available rounding modes

Attributes
----------
MINUS_INFTY
round towards -∞, or floor [2]_
PLUS_INFTY
round towards +∞, or ceil [3]_
NEAREST_HALF_EVEN
round to nearest, tie-break half to even [6]_
NEAREST_HALF_MINUS_INFTY
round to nearest, tie-break half to -∞ [5]_
NEAREST_HALF_PLUS_INFTY
round to nearest, tie-break half to +∞ [4]_


References
----------
.. [1] "Rounding - Wikipedia"
https://en.wikipedia.org/wiki/Rounding
.. [2] "Rounding down"
https://en.wikipedia.org/wiki/Rounding#Rounding_down
.. [3] "Rounding up"
https://en.wikipedia.org/wiki/Rounding#Rounding_up
.. [4] "Round half up"
https://en.wikipedia.org/wiki/Rounding#Round_half_up
.. [5] "Round half down"
https://en.wikipedia.org/wiki/Rounding#Round_half_down
.. [6] "Round half to even"
https://en.wikipedia.org/wiki/Rounding#Round_half_to_even
"""
@property
def MINUS_INFTY(self) -> int:
return 0

@property
def PLUS_INFTY(self) -> int:
return 1

@property
def NEAREST_HALF_EVEN(self) -> int:
return 2

@property
def NEAREST_HALF_PLUS_INFTY(self) -> int:
return 3

@property
def NEAREST_HALF_MINUS_INFTY(self) -> int:
return 4


cdef inline ndarray[int64_t] _floor_int64(int64_t[:] values, int64_t unit):
cdef:
Py_ssize_t i, n = len(values)
ndarray[int64_t] result = np.empty(n, dtype="i8")
int64_t res, value

with cython.overflowcheck(True):
for i in range(n):
value = values[i]
if value == NPY_NAT:
res = NPY_NAT
else:
res = value - value % unit
result[i] = res

return result


cdef inline ndarray[int64_t] _ceil_int64(int64_t[:] values, int64_t unit):
cdef:
Py_ssize_t i, n = len(values)
ndarray[int64_t] result = np.empty(n, dtype="i8")
int64_t res, value

with cython.overflowcheck(True):
for i in range(n):
value = values[i]

if value == NPY_NAT:
res = NPY_NAT
else:
remainder = value % unit
if remainder == 0:
res = value
else:
res = value + (unit - remainder)

result[i] = res

return result


cdef inline ndarray[int64_t] _rounddown_int64(values, int64_t unit):
return _ceil_int64(values - unit // 2, unit)


cdef inline ndarray[int64_t] _roundup_int64(values, int64_t unit):
return _floor_int64(values + unit // 2, unit)


def round_nsint64(values: np.ndarray, mode: RoundTo, nanos) -> np.ndarray:
"""
Applies rounding mode at given frequency

Parameters
----------
values : np.ndarray[int64_t]`
mode : instance of `RoundTo` enumeration
nanos : np.int64
Freq to round to, expressed in nanoseconds

Returns
-------
np.ndarray[int64_t]
"""
cdef:
int64_t unit = nanos

if mode == RoundTo.MINUS_INFTY:
return _floor_int64(values, unit)
elif mode == RoundTo.PLUS_INFTY:
return _ceil_int64(values, unit)
elif mode == RoundTo.NEAREST_HALF_MINUS_INFTY:
return _rounddown_int64(values, unit)
elif mode == RoundTo.NEAREST_HALF_PLUS_INFTY:
return _roundup_int64(values, unit)
elif mode == RoundTo.NEAREST_HALF_EVEN:
# for odd unit there is no need of a tie break
if unit % 2:
return _rounddown_int64(values, unit)
quotient, remainder = np.divmod(values, unit)
mask = np.logical_or(
remainder > (unit // 2),
np.logical_and(remainder == (unit // 2), quotient % 2)
)
quotient[mask] += 1
return quotient * unit

# if/elif above should catch all rounding modes defined in enum 'RoundTo':
# if flow of control arrives here, it is a bug
raise ValueError("round_nsint64 called with an unrecognized rounding mode")
19 changes: 12 additions & 7 deletions pandas/_libs/tslibs/timedeltas.pyx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ from pandas._libs.tslibs.util cimport (
is_integer_object,
is_timedelta64_object,
)
from pandas._libs.tslibs.fields import RoundTo, round_nsint64

# ----------------------------------------------------------------------
# Constants
Expand Down Expand Up @@ -1297,14 +1298,18 @@ class Timedelta(_Timedelta):
object_state = self.value,
return (Timedelta, object_state)

def _round(self, freq, rounder):
@cython.cdivision(True)
def _round(self, freq, mode):
cdef:
int64_t result, unit
int64_t result, unit, remainder
ndarray[int64_t] arr

from pandas._libs.tslibs.offsets import to_offset
unit = to_offset(freq).nanos
result = unit * rounder(self.value / float(unit))
return Timedelta(result, unit='ns')

arr = np.array([self.value], dtype="i8")
result = round_nsint64(arr, mode, unit)[0]
return Timedelta(result, unit="ns")

def round(self, freq):
"""
Expand All @@ -1323,7 +1328,7 @@ class Timedelta(_Timedelta):
------
ValueError if the freq cannot be converted
"""
return self._round(freq, np.round)
return self._round(freq, RoundTo.NEAREST_HALF_EVEN)

def floor(self, freq):
"""
Expand All @@ -1334,7 +1339,7 @@ class Timedelta(_Timedelta):
freq : str
Frequency string indicating the flooring resolution.
"""
return self._round(freq, np.floor)
return self._round(freq, RoundTo.MINUS_INFTY)

def ceil(self, freq):
"""
Expand All @@ -1345,7 +1350,7 @@ class Timedelta(_Timedelta):
freq : str
Frequency string indicating the ceiling resolution.
"""
return self._round(freq, np.ceil)
return self._round(freq, RoundTo.PLUS_INFTY)

# ----------------------------------------------------------------
# Arithmetic Methods
Expand Down
Loading