diff --git a/doc/source/timedeltas.rst b/doc/source/timedeltas.rst index 50cff4c7bbdfb..9890c976c4e5a 100644 --- a/doc/source/timedeltas.rst +++ b/doc/source/timedeltas.rst @@ -283,6 +283,18 @@ Rounded division (floor-division) of a ``timedelta64[ns]`` Series by a scalar td // pd.Timedelta(days=3, hours=4) pd.Timedelta(days=3, hours=4) // td +The mod (%) and divmod operations are defined for ``Timedelta`` when operating with another timedelta-like or with a numeric argument. (:issue:`19365`) + +.. ipython:: python + + pd.Timedelta(hours=37) % datetime.timedelta(hours=2) + + # divmod against a timedelta-like returns a pair (int, Timedelta) + divmod(datetime.timedelta(hours=2), pd.Timedelta(minutes=11)) + + # divmod against a numeric returns a pair (Timedelta, Timedelta) + divmod(pd.Timedelta(hours=25), 86400000000000) + Attributes ---------- diff --git a/doc/source/whatsnew/v0.23.0.txt b/doc/source/whatsnew/v0.23.0.txt index a2198d9103528..70ac4393da2b3 100644 --- a/doc/source/whatsnew/v0.23.0.txt +++ b/doc/source/whatsnew/v0.23.0.txt @@ -117,6 +117,18 @@ resetting indexes. See the :ref:`Sorting by Indexes and Values # Sort by 'second' (index) and 'A' (column) df_multi.sort_values(by=['second', 'A']) +.. _whatsnew_0230.enhancements.timedelta_mod + +Timedelta mod method +^^^^^^^^^^^^^^^^^^^^ + +``mod`` (%) and ``divmod`` operations are now defined on ``Timedelta`` objects when operating with either timedelta-like or with numeric arguments. (:issue:`19365`) + +.. ipython:: python + + td = pd.Timedelta(hours=37) + td + .. _whatsnew_0230.enhancements.ran_inf: ``.rank()`` handles ``inf`` values when ``NaN`` are present @@ -571,6 +583,7 @@ Other API Changes - Set operations (union, difference...) on :class:`IntervalIndex` with incompatible index types will now raise a ``TypeError`` rather than a ``ValueError`` (:issue:`19329`) - :class:`DateOffset` objects render more simply, e.g. "" instead of "" (:issue:`19403`) - :func:`pandas.merge` provides a more informative error message when trying to merge on timezone-aware and timezone-naive columns (:issue:`15800`) +- :func:`Timedelta.__mod__`, :func:`Timedelta.__divmod__` now accept timedelta-like and numeric arguments instead of raising ``TypeError`` (:issue:`19365`) .. _whatsnew_0230.deprecations: diff --git a/pandas/_libs/tslibs/timedeltas.pyx b/pandas/_libs/tslibs/timedeltas.pyx index 37693068e0974..5a044dc7e33c1 100644 --- a/pandas/_libs/tslibs/timedeltas.pyx +++ b/pandas/_libs/tslibs/timedeltas.pyx @@ -482,11 +482,15 @@ def _binary_op_method_timedeltalike(op, name): # the PyDateTime_CheckExact case is for a datetime object that # is specifically *not* a Timestamp, as the Timestamp case will be # handled after `_validate_ops_compat` returns False below - from ..tslib import Timestamp + from timestamps import Timestamp return op(self, Timestamp(other)) # We are implicitly requiring the canonical behavior to be # defined by Timestamp methods. + elif is_timedelta64_object(other): + # other coerced to Timedelta below + pass + elif hasattr(other, 'dtype'): # nd-array like if other.dtype.kind not in ['m', 'M']: @@ -503,6 +507,9 @@ def _binary_op_method_timedeltalike(op, name): # failed to parse as timedelta return NotImplemented + if other is NaT: + # e.g. if original other was np.timedelta64('NaT') + return NaT return Timedelta(op(self.value, other.value), unit='ns') f.__name__ = name @@ -1044,8 +1051,10 @@ class Timedelta(_Timedelta): __rsub__ = _binary_op_method_timedeltalike(lambda x, y: y - x, '__rsub__') def __mul__(self, other): - if hasattr(other, 'dtype'): - # ndarray-like + if (hasattr(other, 'dtype') and + not (is_integer_object(other) or is_float_object(other))): + # ndarray-like; the integer/float object checks exclude + # numpy scalars return other * self.to_timedelta64() elif other is NaT: @@ -1060,7 +1069,10 @@ class Timedelta(_Timedelta): __rmul__ = __mul__ def __truediv__(self, other): - if hasattr(other, 'dtype'): + if is_timedelta64_object(other): + return self / Timedelta(other) + + elif hasattr(other, 'dtype'): return self.to_timedelta64() / other elif is_integer_object(other) or is_float_object(other): @@ -1076,7 +1088,10 @@ class Timedelta(_Timedelta): return self.value / float(other.value) def __rtruediv__(self, other): - if hasattr(other, 'dtype'): + if is_timedelta64_object(other): + return Timedelta(other) / self + + elif hasattr(other, 'dtype'): return other / self.to_timedelta64() elif not _validate_ops_compat(other): @@ -1096,9 +1111,20 @@ class Timedelta(_Timedelta): # just defer if hasattr(other, '_typ'): # Series, DataFrame, ... + if other._typ == 'dateoffset' and hasattr(other, 'delta'): + # Tick offset + return self // other.delta return NotImplemented - if hasattr(other, 'dtype'): + elif is_timedelta64_object(other): + return self // Timedelta(other) + + elif is_integer_object(other) or is_float_object(other): + return Timedelta(self.value // other, unit='ns') + + elif hasattr(other, 'dtype'): + # ndarray-like; the integer/float object checks exclude + # numpy scalars if other.dtype.kind == 'm': # also timedelta-like return _broadcast_floordiv_td64(self.value, other, _floordiv) @@ -1107,14 +1133,10 @@ class Timedelta(_Timedelta): return Timedelta(self.value // other) else: return self.to_timedelta64() // other - raise TypeError('Invalid dtype {dtype} for ' '{op}'.format(dtype=other.dtype, op='__floordiv__')) - elif is_integer_object(other) or is_float_object(other): - return Timedelta(self.value // other, unit='ns') - elif not _validate_ops_compat(other): return NotImplemented @@ -1128,8 +1150,14 @@ class Timedelta(_Timedelta): # just defer if hasattr(other, '_typ'): # Series, DataFrame, ... + if other._typ == 'dateoffset' and hasattr(other, 'delta'): + # Tick offset + return other.delta // self return NotImplemented + elif is_timedelta64_object(other): + return Timedelta(other) // self + if hasattr(other, 'dtype'): if other.dtype.kind == 'm': # also timedelta-like @@ -1149,6 +1177,24 @@ class Timedelta(_Timedelta): return np.nan return other.value // self.value + def __mod__(self, other): + # Naive implementation, room for optimization + return self.__divmod__(other)[1] + + def __rmod__(self, other): + # Naive implementation, room for optimization + return self.__rdivmod__(other)[1] + + def __divmod__(self, other): + # Naive implementation, room for optimization + div = self // other + return div, self - div * other + + def __rdivmod__(self, other): + # Naive implementation, room for optimization + div = other // self + return div, other - div * self + cdef _floordiv(int64_t value, right): return value // right diff --git a/pandas/tests/scalar/test_timedelta.py b/pandas/tests/scalar/test_timedelta.py index 667266be2a89b..c245aaa8a5021 100644 --- a/pandas/tests/scalar/test_timedelta.py +++ b/pandas/tests/scalar/test_timedelta.py @@ -2,7 +2,7 @@ import pytest import numpy as np -from datetime import timedelta +from datetime import datetime, timedelta import pandas as pd import pandas.util.testing as tm @@ -105,15 +105,6 @@ def test_timedelta_ops_scalar(self): result = base - offset assert result == expected_sub - def test_ops_offsets(self): - td = Timedelta(10, unit='d') - assert Timedelta(241, unit='h') == td + pd.offsets.Hour(1) - assert Timedelta(241, unit='h') == pd.offsets.Hour(1) + td - assert 240 == td / pd.offsets.Hour(1) - assert 1 / 240.0 == pd.offsets.Hour(1) / td - assert Timedelta(239, unit='h') == td - pd.offsets.Hour(1) - assert Timedelta(-239, unit='h') == pd.offsets.Hour(1) - td - def test_unary_ops(self): td = Timedelta(10, unit='d') @@ -127,6 +118,73 @@ def test_unary_ops(self): assert abs(-td) == td assert abs(-td) == Timedelta('10d') + def test_mul(self): + # GH#19365 + td = Timedelta(minutes=3) + + result = td * 2 + assert result == Timedelta(minutes=6) + + result = td * np.int64(1) + assert isinstance(result, Timedelta) + assert result == td + + result = td * 1.5 + assert result == Timedelta(minutes=4, seconds=30) + + result = td * np.array([3, 4], dtype='int64') + expected = np.array([9, 12], dtype='m8[m]').astype('m8[ns]') + tm.assert_numpy_array_equal(result, expected) + + with pytest.raises(TypeError): + # timedelta * datetime is gibberish + td * pd.Timestamp(2016, 1, 2) + + def test_add_datetimelike(self): + # GH#19365 + td = Timedelta(10, unit='d') + + result = td + datetime(2016, 1, 1) + assert result == pd.Timestamp(2016, 1, 11) + + result = td + pd.Timestamp('2018-01-12 18:09') + assert result == pd.Timestamp('2018-01-22 18:09') + + result = td + np.datetime64('2018-01-12') + assert result == pd.Timestamp('2018-01-22') + + @pytest.mark.parametrize('op', [lambda x, y: x + y, + lambda x, y: y + x]) + def test_add_timedeltalike(self, op): + td = Timedelta(10, unit='d') + + result = op(td, Timedelta(days=10)) + assert isinstance(result, Timedelta) + assert result == Timedelta(days=20) + + result = op(td, timedelta(days=9)) + assert isinstance(result, Timedelta) + assert result == Timedelta(days=19) + + result = op(td, pd.offsets.Hour(6)) + assert isinstance(result, Timedelta) + assert result == Timedelta(days=10, hours=6) + + result = op(td, np.timedelta64(-4, 'D')) + assert isinstance(result, Timedelta) + assert result == Timedelta(days=6) + + def test_sub_timedeltalike(self): + td = Timedelta(10, unit='d') + + result = td - pd.offsets.Hour(1) + assert isinstance(result, Timedelta) + assert result == Timedelta(239, unit='h') + + result = pd.offsets.Hour(1) - td + assert isinstance(result, Timedelta) + assert result == Timedelta(-239, unit='h') + def test_binary_ops_nat(self): td = Timedelta(10, unit='d') @@ -137,6 +195,12 @@ def test_binary_ops_nat(self): assert (td // pd.NaT) is np.nan assert (td // np.timedelta64('NaT')) is np.nan + # GH#19365 + assert td - np.timedelta64('NaT', 'ns') is pd.NaT + assert td + np.timedelta64('NaT', 'ns') is pd.NaT + assert np.timedelta64('NaT', 'ns') - td is pd.NaT + assert np.timedelta64('NaT', 'ns') + td is pd.NaT + def test_binary_ops_integers(self): td = Timedelta(10, unit='d') @@ -162,6 +226,16 @@ def test_binary_ops_with_timedelta(self): # invalid multiply with another timedelta pytest.raises(TypeError, lambda: td * td) + def test_div(self): + td = Timedelta(10, unit='d') + result = td / pd.offsets.Hour(1) + assert result == 240 + + def test_rdiv(self): + td = Timedelta(10, unit='d') + result = pd.offsets.Hour(1) / td + assert result == 1 / 240.0 + def test_floordiv(self): # GH#18846 td = Timedelta(hours=3, minutes=4) @@ -172,6 +246,10 @@ def test_floordiv(self): assert -td // scalar.to_pytimedelta() == -2 assert (2 * td) // scalar.to_timedelta64() == 2 + # GH#19365 + assert td // pd.offsets.Hour(1) == 3 + assert td // pd.offsets.Minute(2) == 92 + assert td // np.nan is pd.NaT assert np.isnan(td // pd.NaT) assert np.isnan(td // np.timedelta64('NaT')) @@ -217,6 +295,8 @@ def test_rfloordiv(self): assert (-td).__rfloordiv__(scalar.to_pytimedelta()) == -2 assert (2 * td).__rfloordiv__(scalar.to_timedelta64()) == 0 + assert pd.offsets.Hour(1) // Timedelta(minutes=25) == 2 + assert np.isnan(td.__rfloordiv__(pd.NaT)) assert np.isnan(td.__rfloordiv__(np.timedelta64('NaT'))) @@ -254,6 +334,178 @@ def test_rfloordiv(self): with pytest.raises(TypeError): ser // td + def test_mod_timedeltalike(self): + # GH#19365 + td = Timedelta(hours=37) + + # Timedelta-like others + result = td % Timedelta(hours=6) + assert isinstance(result, Timedelta) + assert result == Timedelta(hours=1) + + result = td % timedelta(minutes=60) + assert isinstance(result, Timedelta) + assert result == Timedelta(0) + + result = td % pd.offsets.Hour(5) + assert isinstance(result, Timedelta) + assert result == Timedelta(hours=2) + + result = td % np.timedelta64(2, 'h') + assert isinstance(result, Timedelta) + assert result == Timedelta(hours=1) + + result = td % NaT + assert result is NaT + + result = td % np.timedelta64('NaT', 'ns') + assert result is NaT + + def test_mod_numeric(self): + # GH#19365 + td = Timedelta(hours=37) + + # Numeric Others + result = td % 2 + assert isinstance(result, Timedelta) + assert result == Timedelta(0) + + result = td % 1e12 + assert isinstance(result, Timedelta) + assert result == Timedelta(minutes=3, seconds=20) + + result = td % int(1e12) + assert isinstance(result, Timedelta) + assert result == Timedelta(minutes=3, seconds=20) + + def test_mod_arraylike(self): + # GH#19365 + td = Timedelta(hours=37) + + # Array-like others + result = td % np.array([6, 5], dtype='timedelta64[h]') + expected = np.array([1, 2], dtype='timedelta64[h]').astype('m8[ns]') + tm.assert_numpy_array_equal(result, expected) + + result = td % pd.TimedeltaIndex(['6H', '5H']) + expected = pd.TimedeltaIndex(['1H', '2H']) + tm.assert_index_equal(result, expected) + + result = td % np.array([2, int(1e12)], dtype='i8') + expected = np.array([0, Timedelta(minutes=3, seconds=20).value], + dtype='m8[ns]') + tm.assert_numpy_array_equal(result, expected) + + def test_mod_invalid(self): + # GH#19365 + td = Timedelta(hours=37) + + with pytest.raises(TypeError): + td % pd.Timestamp('2018-01-22') + + with pytest.raises(TypeError): + td % [] + + def test_rmod(self): + # GH#19365 + td = Timedelta(minutes=3) + + result = timedelta(minutes=4) % td + assert isinstance(result, Timedelta) + assert result == Timedelta(minutes=1) + + result = np.timedelta64(5, 'm') % td + assert isinstance(result, Timedelta) + assert result == Timedelta(minutes=2) + + result = np.array([5, 6], dtype='m8[m]') % td + expected = np.array([2, 0], dtype='m8[m]').astype('m8[ns]') + tm.assert_numpy_array_equal(result, expected) + + def test_rmod_invalid(self): + # GH#19365 + td = Timedelta(minutes=3) + + with pytest.raises(TypeError): + pd.Timestamp('2018-01-22') % td + + with pytest.raises(TypeError): + 15 % td + + with pytest.raises(TypeError): + 16.0 % td + + with pytest.raises(TypeError): + np.array([22, 24]) % td + + def test_divmod(self): + # GH#19365 + td = Timedelta(days=2, hours=6) + + result = divmod(td, timedelta(days=1)) + assert result[0] == 2 + assert isinstance(result[1], Timedelta) + assert result[1] == Timedelta(hours=6) + + result = divmod(td, pd.offsets.Hour(-4)) + assert result[0] == -14 + assert isinstance(result[1], Timedelta) + assert result[1] == Timedelta(hours=-2) + + result = divmod(td, 54) + assert result[0] == Timedelta(hours=1) + assert isinstance(result[1], Timedelta) + assert result[1] == Timedelta(0) + + result = divmod(td, 53 * 3600 * 1e9) + assert result[0] == Timedelta(1, unit='ns') + assert isinstance(result[1], Timedelta) + assert result[1] == Timedelta(hours=1) + + assert result + result = divmod(td, np.nan) + assert result[0] is pd.NaT + assert result[1] is pd.NaT + + result = divmod(td, pd.NaT) + assert np.isnan(result[0]) + assert result[1] is pd.NaT + + def test_divmod_invalid(self): + # GH#19365 + td = Timedelta(days=2, hours=6) + + with pytest.raises(TypeError): + divmod(td, pd.Timestamp('2018-01-22')) + + def test_rdivmod(self): + # GH#19365 + result = divmod(timedelta(days=2, hours=6), Timedelta(days=1)) + assert result[0] == 2 + assert isinstance(result[1], Timedelta) + assert result[1] == Timedelta(hours=6) + + result = divmod(pd.offsets.Hour(54), Timedelta(hours=-4)) + assert result[0] == -14 + assert isinstance(result[1], Timedelta) + assert result[1] == Timedelta(hours=-2) + + def test_rdivmod_invalid(self): + # GH#19365 + td = Timedelta(minutes=3) + + with pytest.raises(TypeError): + divmod(pd.Timestamp('2018-01-22'), td) + + with pytest.raises(TypeError): + divmod(15, td) + + with pytest.raises(TypeError): + divmod(16.0, td) + + with pytest.raises(TypeError): + divmod(np.array([22, 24]), td) + class TestTimedeltaComparison(object): def test_comparison_object_array(self):