Skip to content

Commit b8e0a5c

Browse files
authored
BUG: Timedelta.round near implementation bounds (#39601)
1 parent 85eeef3 commit b8e0a5c

File tree

7 files changed

+243
-161
lines changed

7 files changed

+243
-161
lines changed

doc/source/whatsnew/v1.3.0.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,7 @@ Datetimelike
279279
- Bug in :class:`Categorical` incorrectly typecasting ``datetime`` object to ``Timestamp`` (:issue:`38878`)
280280
- Bug in comparisons between :class:`Timestamp` object and ``datetime64`` objects just outside the implementation bounds for nanosecond ``datetime64`` (:issue:`39221`)
281281
- Bug in :meth:`Timestamp.round`, :meth:`Timestamp.floor`, :meth:`Timestamp.ceil` for values near the implementation bounds of :class:`Timestamp` (:issue:`39244`)
282+
- Bug in :meth:`Timedelta.round`, :meth:`Timedelta.floor`, :meth:`Timedelta.ceil` for values near the implementation bounds of :class:`Timedelta` (:issue:`38964`)
282283
- Bug in :func:`date_range` incorrectly creating :class:`DatetimeIndex` containing ``NaT`` instead of raising ``OutOfBoundsDatetime`` in corner cases (:issue:`24124`)
283284

284285
Timedelta

pandas/_libs/tslibs/fields.pyx

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -636,3 +636,154 @@ def get_locale_names(name_type: str, locale: object = None):
636636
"""
637637
with set_locale(locale, LC_TIME):
638638
return getattr(LocaleTime(), name_type)
639+
640+
641+
# ---------------------------------------------------------------------
642+
# Rounding
643+
644+
645+
class RoundTo:
646+
"""
647+
enumeration defining the available rounding modes
648+
649+
Attributes
650+
----------
651+
MINUS_INFTY
652+
round towards -∞, or floor [2]_
653+
PLUS_INFTY
654+
round towards +∞, or ceil [3]_
655+
NEAREST_HALF_EVEN
656+
round to nearest, tie-break half to even [6]_
657+
NEAREST_HALF_MINUS_INFTY
658+
round to nearest, tie-break half to -∞ [5]_
659+
NEAREST_HALF_PLUS_INFTY
660+
round to nearest, tie-break half to +∞ [4]_
661+
662+
663+
References
664+
----------
665+
.. [1] "Rounding - Wikipedia"
666+
https://en.wikipedia.org/wiki/Rounding
667+
.. [2] "Rounding down"
668+
https://en.wikipedia.org/wiki/Rounding#Rounding_down
669+
.. [3] "Rounding up"
670+
https://en.wikipedia.org/wiki/Rounding#Rounding_up
671+
.. [4] "Round half up"
672+
https://en.wikipedia.org/wiki/Rounding#Round_half_up
673+
.. [5] "Round half down"
674+
https://en.wikipedia.org/wiki/Rounding#Round_half_down
675+
.. [6] "Round half to even"
676+
https://en.wikipedia.org/wiki/Rounding#Round_half_to_even
677+
"""
678+
@property
679+
def MINUS_INFTY(self) -> int:
680+
return 0
681+
682+
@property
683+
def PLUS_INFTY(self) -> int:
684+
return 1
685+
686+
@property
687+
def NEAREST_HALF_EVEN(self) -> int:
688+
return 2
689+
690+
@property
691+
def NEAREST_HALF_PLUS_INFTY(self) -> int:
692+
return 3
693+
694+
@property
695+
def NEAREST_HALF_MINUS_INFTY(self) -> int:
696+
return 4
697+
698+
699+
cdef inline ndarray[int64_t] _floor_int64(int64_t[:] values, int64_t unit):
700+
cdef:
701+
Py_ssize_t i, n = len(values)
702+
ndarray[int64_t] result = np.empty(n, dtype="i8")
703+
int64_t res, value
704+
705+
with cython.overflowcheck(True):
706+
for i in range(n):
707+
value = values[i]
708+
if value == NPY_NAT:
709+
res = NPY_NAT
710+
else:
711+
res = value - value % unit
712+
result[i] = res
713+
714+
return result
715+
716+
717+
cdef inline ndarray[int64_t] _ceil_int64(int64_t[:] values, int64_t unit):
718+
cdef:
719+
Py_ssize_t i, n = len(values)
720+
ndarray[int64_t] result = np.empty(n, dtype="i8")
721+
int64_t res, value
722+
723+
with cython.overflowcheck(True):
724+
for i in range(n):
725+
value = values[i]
726+
727+
if value == NPY_NAT:
728+
res = NPY_NAT
729+
else:
730+
remainder = value % unit
731+
if remainder == 0:
732+
res = value
733+
else:
734+
res = value + (unit - remainder)
735+
736+
result[i] = res
737+
738+
return result
739+
740+
741+
cdef inline ndarray[int64_t] _rounddown_int64(values, int64_t unit):
742+
return _ceil_int64(values - unit // 2, unit)
743+
744+
745+
cdef inline ndarray[int64_t] _roundup_int64(values, int64_t unit):
746+
return _floor_int64(values + unit // 2, unit)
747+
748+
749+
def round_nsint64(values: np.ndarray, mode: RoundTo, nanos) -> np.ndarray:
750+
"""
751+
Applies rounding mode at given frequency
752+
753+
Parameters
754+
----------
755+
values : np.ndarray[int64_t]`
756+
mode : instance of `RoundTo` enumeration
757+
nanos : np.int64
758+
Freq to round to, expressed in nanoseconds
759+
760+
Returns
761+
-------
762+
np.ndarray[int64_t]
763+
"""
764+
cdef:
765+
int64_t unit = nanos
766+
767+
if mode == RoundTo.MINUS_INFTY:
768+
return _floor_int64(values, unit)
769+
elif mode == RoundTo.PLUS_INFTY:
770+
return _ceil_int64(values, unit)
771+
elif mode == RoundTo.NEAREST_HALF_MINUS_INFTY:
772+
return _rounddown_int64(values, unit)
773+
elif mode == RoundTo.NEAREST_HALF_PLUS_INFTY:
774+
return _roundup_int64(values, unit)
775+
elif mode == RoundTo.NEAREST_HALF_EVEN:
776+
# for odd unit there is no need of a tie break
777+
if unit % 2:
778+
return _rounddown_int64(values, unit)
779+
quotient, remainder = np.divmod(values, unit)
780+
mask = np.logical_or(
781+
remainder > (unit // 2),
782+
np.logical_and(remainder == (unit // 2), quotient % 2)
783+
)
784+
quotient[mask] += 1
785+
return quotient * unit
786+
787+
# if/elif above should catch all rounding modes defined in enum 'RoundTo':
788+
# if flow of control arrives here, it is a bug
789+
raise ValueError("round_nsint64 called with an unrecognized rounding mode")

pandas/_libs/tslibs/timedeltas.pyx

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ from pandas._libs.tslibs.util cimport (
4747
is_integer_object,
4848
is_timedelta64_object,
4949
)
50+
from pandas._libs.tslibs.fields import RoundTo, round_nsint64
5051

5152
# ----------------------------------------------------------------------
5253
# Constants
@@ -1297,14 +1298,18 @@ class Timedelta(_Timedelta):
12971298
object_state = self.value,
12981299
return (Timedelta, object_state)
12991300

1300-
def _round(self, freq, rounder):
1301+
@cython.cdivision(True)
1302+
def _round(self, freq, mode):
13011303
cdef:
1302-
int64_t result, unit
1304+
int64_t result, unit, remainder
1305+
ndarray[int64_t] arr
13031306

13041307
from pandas._libs.tslibs.offsets import to_offset
13051308
unit = to_offset(freq).nanos
1306-
result = unit * rounder(self.value / float(unit))
1307-
return Timedelta(result, unit='ns')
1309+
1310+
arr = np.array([self.value], dtype="i8")
1311+
result = round_nsint64(arr, mode, unit)[0]
1312+
return Timedelta(result, unit="ns")
13081313

13091314
def round(self, freq):
13101315
"""
@@ -1323,7 +1328,7 @@ class Timedelta(_Timedelta):
13231328
------
13241329
ValueError if the freq cannot be converted
13251330
"""
1326-
return self._round(freq, np.round)
1331+
return self._round(freq, RoundTo.NEAREST_HALF_EVEN)
13271332

13281333
def floor(self, freq):
13291334
"""
@@ -1334,7 +1339,7 @@ class Timedelta(_Timedelta):
13341339
freq : str
13351340
Frequency string indicating the flooring resolution.
13361341
"""
1337-
return self._round(freq, np.floor)
1342+
return self._round(freq, RoundTo.MINUS_INFTY)
13381343

13391344
def ceil(self, freq):
13401345
"""
@@ -1345,7 +1350,7 @@ class Timedelta(_Timedelta):
13451350
freq : str
13461351
Frequency string indicating the ceiling resolution.
13471352
"""
1348-
return self._round(freq, np.ceil)
1353+
return self._round(freq, RoundTo.PLUS_INFTY)
13491354

13501355
# ----------------------------------------------------------------
13511356
# Arithmetic Methods

0 commit comments

Comments
 (0)