Skip to content

Commit 7d00a30

Browse files
committed
BUG/ENH: cleanup for Timedelta arithmetic
Fixes GH8813 Fixes GH5963 Fixes GH5436 If the other argument has a dtype attribute, I assume that it is ndarray-like and convert the `Timedelta` into a `np.timedelta64` object. Alternatively, we could just return `NotImplemented` and let the other type handle it, but this has the bonus of making `Timedelta` compatible with ndarrays. I also added a `Timedelta.to_timedelta64()` method to the public API. I couldn't find a listing for `Timedelta` in the API docs -- we should probably add that, right? Next up would be a similar treatment for `Timestamp`.
1 parent 0a2ea0a commit 7d00a30

File tree

5 files changed

+179
-61
lines changed

5 files changed

+179
-61
lines changed

doc/source/whatsnew/v0.15.2.txt

+7
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,11 @@ Enhancements
6666
- Added support for ``utcfromtimestamp()``, ``fromtimestamp()``, and ``combine()`` on `Timestamp` class (:issue:`5351`).
6767
- Added Google Analytics (`pandas.io.ga`) basic documentation (:issue:`8835`). See :ref:`here<remote_data.ga>`.
6868
- Added flag ``order_categoricals`` to ``StataReader`` and ``read_stata`` to select whether to order imported categorical data (:issue:`8836`). See :ref:`here <io.stata-categorical>` for more information on importing categorical variables from Stata data files.
69+
- ``Timedelta`` arithmetic returns ``NotImplemented`` in unknown cases, allowing extensions
70+
by custom classes (:issue:`8813`).
71+
- ``Timedelta`` now supports arithemtic with ``numpy.ndarray`` objects of the appropriate
72+
dtype (numpy 1.8 or newer only) (:issue:`8884`).
73+
- Added ``Timedelta.to_timedelta64`` method to the public API (:issue:`8884`).
6974

7075
.. _whatsnew_0152.performance:
7176

@@ -89,6 +94,8 @@ Bug Fixes
8994
- Bug in slicing a multi-index with an empty list and at least one boolean indexer (:issue:`8781`)
9095
- ``io.data.Options`` now raises ``RemoteDataError`` when no expiry dates are available from Yahoo (:issue:`8761`).
9196
- ``Timedelta`` kwargs may now be numpy ints and floats (:issue:`8757`).
97+
- Fixed several outstanding bugs for ``Timedelta`` arithmetic and comparisons
98+
(:issue:`8813`, :issue:`5963`, :issue:`5436`).
9299
- ``sql_schema`` now generates dialect appropriate ``CREATE TABLE`` statements (:issue:`8697`)
93100
- ``slice`` string method now takes step into account (:issue:`8754`)
94101
- Bug in ``BlockManager`` where setting values with different type would break block integrity (:issue:`8850`)

pandas/tseries/base.py

+5
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,7 @@ def __add__(self, other):
321321
else: # pragma: no cover
322322
return NotImplemented
323323
cls.__add__ = __add__
324+
cls.__radd__ = __add__
324325

325326
def __sub__(self, other):
326327
from pandas.core.index import Index
@@ -344,6 +345,10 @@ def __sub__(self, other):
344345
return NotImplemented
345346
cls.__sub__ = __sub__
346347

348+
def __rsub__(self, other):
349+
return -self + other
350+
cls.__rsub__ = __rsub__
351+
347352
cls.__iadd__ = __add__
348353
cls.__isub__ = __sub__
349354

pandas/tseries/tdi.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,7 @@ def _evaluate_with_timedelta_like(self, other, op, opstr):
311311
result = self._maybe_mask_results(result,convert='float64')
312312
return Index(result,name=self.name,copy=False)
313313

314-
raise TypeError("can only perform ops with timedelta like values")
314+
return NotImplemented
315315

316316
def _add_datelike(self, other):
317317

pandas/tseries/tests/test_timedeltas.py

+92-6
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from datetime import datetime, timedelta, time
55
import nose
66

7+
from distutils.version import LooseVersion
78
import numpy as np
89
import pandas as pd
910

@@ -45,12 +46,12 @@ def test_construction(self):
4546
self.assertEqual(Timedelta(days=10,seconds=10).value, expected)
4647
self.assertEqual(Timedelta(days=10,milliseconds=10*1000).value, expected)
4748
self.assertEqual(Timedelta(days=10,microseconds=10*1000*1000).value, expected)
48-
49+
4950
# test construction with np dtypes
5051
# GH 8757
51-
timedelta_kwargs = {'days':'D', 'seconds':'s', 'microseconds':'us',
52+
timedelta_kwargs = {'days':'D', 'seconds':'s', 'microseconds':'us',
5253
'milliseconds':'ms', 'minutes':'m', 'hours':'h', 'weeks':'W'}
53-
npdtypes = [np.int64, np.int32, np.int16,
54+
npdtypes = [np.int64, np.int32, np.int16,
5455
np.float64, np.float32, np.float16]
5556
for npdtype in npdtypes:
5657
for pykwarg, npkwarg in timedelta_kwargs.items():
@@ -163,9 +164,17 @@ def test_identity(self):
163164
def test_conversion(self):
164165

165166
for td in [ Timedelta(10,unit='d'), Timedelta('1 days, 10:11:12.012345') ]:
166-
self.assertTrue(td == Timedelta(td.to_pytimedelta()))
167-
self.assertEqual(td,td.to_pytimedelta())
168-
self.assertEqual(td,np.timedelta64(td.value,'ns'))
167+
pydt = td.to_pytimedelta()
168+
self.assertTrue(td == Timedelta(pydt))
169+
self.assertEqual(td, pydt)
170+
self.assertTrue(isinstance(pydt, timedelta)
171+
and not isinstance(pydt, Timedelta))
172+
173+
self.assertEqual(td, np.timedelta64(td.value, 'ns'))
174+
td64 = td.to_timedelta64()
175+
self.assertEqual(td64, np.timedelta64(td.value, 'ns'))
176+
self.assertEqual(td, td64)
177+
self.assertTrue(isinstance(td64, np.timedelta64))
169178

170179
# this is NOT equal and cannot be roundtriped (because of the nanos)
171180
td = Timedelta('1 days, 10:11:12.012345678')
@@ -204,6 +213,15 @@ def test_ops(self):
204213
self.assertRaises(TypeError, lambda : td + 2)
205214
self.assertRaises(TypeError, lambda : td - 2)
206215

216+
def test_ops_offsets(self):
217+
td = Timedelta(10, unit='d')
218+
self.assertEqual(Timedelta(241, unit='h'), td + pd.offsets.Hour(1))
219+
self.assertEqual(Timedelta(241, unit='h'), pd.offsets.Hour(1) + td)
220+
self.assertEqual(240, td / pd.offsets.Hour(1))
221+
self.assertEqual(1 / 240.0, pd.offsets.Hour(1) / td)
222+
self.assertEqual(Timedelta(239, unit='h'), td - pd.offsets.Hour(1))
223+
self.assertEqual(Timedelta(-239, unit='h'), pd.offsets.Hour(1) - td)
224+
207225
def test_freq_conversion(self):
208226

209227
td = Timedelta('1 days 2 hours 3 ns')
@@ -214,6 +232,74 @@ def test_freq_conversion(self):
214232
result = td / np.timedelta64(1,'ns')
215233
self.assertEquals(result, td.value)
216234

235+
def test_ops_ndarray(self):
236+
td = Timedelta('1 day')
237+
238+
# timedelta, timedelta
239+
other = pd.to_timedelta(['1 day']).values
240+
expected = pd.to_timedelta(['2 days']).values
241+
self.assert_numpy_array_equal(td + other, expected)
242+
if LooseVersion(np.__version__) >= '1.8':
243+
self.assert_numpy_array_equal(other + td, expected)
244+
self.assertRaises(TypeError, lambda: td + np.array([1]))
245+
self.assertRaises(TypeError, lambda: np.array([1]) + td)
246+
247+
expected = pd.to_timedelta(['0 days']).values
248+
self.assert_numpy_array_equal(td - other, expected)
249+
if LooseVersion(np.__version__) >= '1.8':
250+
self.assert_numpy_array_equal(-other + td, expected)
251+
self.assertRaises(TypeError, lambda: td - np.array([1]))
252+
self.assertRaises(TypeError, lambda: np.array([1]) - td)
253+
254+
expected = pd.to_timedelta(['2 days']).values
255+
self.assert_numpy_array_equal(td * np.array([2]), expected)
256+
self.assert_numpy_array_equal(np.array([2]) * td, expected)
257+
self.assertRaises(TypeError, lambda: td * other)
258+
self.assertRaises(TypeError, lambda: other * td)
259+
260+
self.assert_numpy_array_equal(td / other, np.array([1]))
261+
if LooseVersion(np.__version__) >= '1.8':
262+
self.assert_numpy_array_equal(other / td, np.array([1]))
263+
264+
# timedelta, datetime
265+
other = pd.to_datetime(['2000-01-01']).values
266+
expected = pd.to_datetime(['2000-01-02']).values
267+
self.assert_numpy_array_equal(td + other, expected)
268+
if LooseVersion(np.__version__) >= '1.8':
269+
self.assert_numpy_array_equal(other + td, expected)
270+
271+
expected = pd.to_datetime(['1999-12-31']).values
272+
self.assert_numpy_array_equal(-td + other, expected)
273+
if LooseVersion(np.__version__) >= '1.8':
274+
self.assert_numpy_array_equal(other - td, expected)
275+
276+
def test_ops_series(self):
277+
# regression test for GH8813
278+
td = Timedelta('1 day')
279+
other = pd.Series([1, 2])
280+
expected = pd.Series(pd.to_timedelta(['1 day', '2 days']))
281+
tm.assert_series_equal(expected, td * other)
282+
tm.assert_series_equal(expected, other * td)
283+
284+
def test_compare_timedelta_series(self):
285+
# regresssion test for GH5963
286+
s = pd.Series([timedelta(days=1), timedelta(days=2)])
287+
actual = s > timedelta(days=1)
288+
expected = pd.Series([False, True])
289+
tm.assert_series_equal(actual, expected)
290+
291+
def test_ops_notimplemented(self):
292+
class Other:
293+
pass
294+
other = Other()
295+
296+
td = Timedelta('1 day')
297+
self.assertTrue(td.__add__(other) is NotImplemented)
298+
self.assertTrue(td.__sub__(other) is NotImplemented)
299+
self.assertTrue(td.__truediv__(other) is NotImplemented)
300+
self.assertTrue(td.__mul__(other) is NotImplemented)
301+
self.assertTrue(td.__floordiv__(td) is NotImplemented)
302+
217303
def test_fields(self):
218304
rng = to_timedelta('1 days, 10:11:12')
219305
self.assertEqual(rng.days,1)

pandas/tslib.pyx

+74-54
Original file line numberDiff line numberDiff line change
@@ -531,6 +531,12 @@ class Timestamp(_Timestamp):
531531
self.nanosecond/3600.0/1e+9
532532
)/24.0)
533533

534+
def __radd__(self, other):
535+
# __radd__ on cython extension types like _Timestamp is not used, so
536+
# define it here instead
537+
return self + other
538+
539+
534540
_nat_strings = set(['NaT','nat','NAT','nan','NaN','NAN'])
535541
class NaTType(_NaT):
536542
"""(N)ot-(A)-(T)ime, the time equivalent of NaN"""
@@ -1883,8 +1889,12 @@ class Timedelta(_Timedelta):
18831889
""" array view compat """
18841890
return np.timedelta64(self.value).view(dtype)
18851891

1886-
def _validate_ops_compat(self, other, op):
1887-
# return a boolean if we are compat with operating
1892+
def to_timedelta64(self):
1893+
""" Returns a numpy.timedelta64 object with 'ns' precision """
1894+
return np.timedelta64(self.value, 'ns')
1895+
1896+
def _validate_ops_compat(self, other):
1897+
# return True if we are compat with operating
18881898
if _checknull_with_nat(other):
18891899
return True
18901900
elif isinstance(other, (Timedelta, timedelta, np.timedelta64)):
@@ -1893,91 +1903,101 @@ class Timedelta(_Timedelta):
18931903
return True
18941904
elif hasattr(other,'delta'):
18951905
return True
1896-
raise TypeError("cannot operate add a Timedelta with op {op} for {typ}".format(op=op,typ=type(other)))
1897-
1898-
def __add__(self, other):
1899-
1900-
# a Timedelta with Series/Index like
1901-
if hasattr(other,'_typ'):
1902-
return other + self
1903-
1904-
# an offset
1905-
elif hasattr(other,'delta') and not isinstance(other, Timedelta):
1906-
return self + other.delta
1907-
1908-
# a datetimelike
1909-
elif isinstance(other, (Timestamp, datetime, np.datetime64)):
1910-
return Timestamp(other) + self
1911-
1912-
self._validate_ops_compat(other,'__add__')
1913-
1914-
other = Timedelta(other)
1915-
if other is NaT:
1916-
return NaT
1917-
return Timedelta(self.value + other.value, unit='ns')
1918-
1919-
def __sub__(self, other):
1920-
1921-
# a Timedelta with Series/Index like
1922-
if hasattr(other,'_typ'):
1923-
neg_other = -other
1924-
return neg_other + self
1925-
1926-
# an offset
1927-
elif hasattr(other,'delta') and not isinstance(other, Timedelta):
1928-
return self - other.delta
1906+
return False
19291907

1930-
self._validate_ops_compat(other,'__sub__')
1908+
# higher than np.ndarray and np.matrix
1909+
__array_priority__ = 100
1910+
1911+
def _binary_op_method_timedeltalike(op, name):
1912+
# define a binary operation that only works if the other argument is
1913+
# timedelta like or an array of timedeltalike
1914+
def f(self, other):
1915+
# an offset
1916+
if hasattr(other, 'delta') and not isinstance(other, Timedelta):
1917+
return op(self, other.delta)
1918+
1919+
# a datetimelike
1920+
if (isinstance(other, (datetime, np.datetime64))
1921+
and not isinstance(other, (Timestamp, NaTType))):
1922+
return op(self, Timestamp(other))
1923+
1924+
# nd-array like
1925+
if hasattr(other, 'dtype'):
1926+
if other.dtype.kind not in ['m', 'M']:
1927+
# raise rathering than letting numpy return wrong answer
1928+
return NotImplemented
1929+
return op(self.to_timedelta64(), other)
1930+
1931+
if not self._validate_ops_compat(other):
1932+
return NotImplemented
1933+
1934+
other = Timedelta(other)
1935+
if other is NaT:
1936+
return NaT
1937+
return Timedelta(op(self.value, other.value), unit='ns')
1938+
f.__name__ = name
1939+
return f
19311940

1932-
other = Timedelta(other)
1933-
if other is NaT:
1934-
return NaT
1935-
return Timedelta(self.value - other.value, unit='ns')
1941+
__add__ = _binary_op_method_timedeltalike(lambda x, y: x + y, '__add__')
1942+
__radd__ = _binary_op_method_timedeltalike(lambda x, y: x + y, '__radd__')
1943+
__sub__ = _binary_op_method_timedeltalike(lambda x, y: x - y, '__sub__')
1944+
__rsub__ = _binary_op_method_timedeltalike(lambda x, y: y - x, '__rsub__')
19361945

19371946
def __mul__(self, other):
19381947

1948+
# nd-array like
1949+
if hasattr(other, 'dtype'):
1950+
return other * self.to_timedelta64()
1951+
19391952
if other is NaT:
19401953
return NaT
19411954

19421955
# only integers allowed
19431956
if not is_integer_object(other):
1944-
raise TypeError("cannot multiply a Timedelta with {typ}".format(typ=type(other)))
1957+
return NotImplemented
19451958

19461959
return Timedelta(other*self.value, unit='ns')
19471960

19481961
__rmul__ = __mul__
19491962

19501963
def __truediv__(self, other):
19511964

1952-
# a timedelta64 IS an integer object as well
1953-
if is_timedelta64_object(other):
1954-
return self.value/float(_delta_to_nanoseconds(other))
1965+
if hasattr(other, 'dtype'):
1966+
return self.to_timedelta64() / other
19551967

19561968
# pure integers
1957-
elif is_integer_object(other):
1969+
if is_integer_object(other):
19581970
return Timedelta(self.value/other, unit='ns')
19591971

1960-
self._validate_ops_compat(other,'__div__')
1972+
if not self._validate_ops_compat(other):
1973+
return NotImplemented
19611974

19621975
other = Timedelta(other)
19631976
if other is NaT:
19641977
return NaT
1965-
19661978
return self.value/float(other.value)
19671979

1968-
def _make_invalid(opstr):
1980+
def __rtruediv__(self, other):
1981+
if hasattr(other, 'dtype'):
1982+
return other / self.to_timedelta64()
19691983

1970-
def _invalid(other):
1971-
raise TypeError("cannot perform {opstr} with {typ}".format(opstr=opstr,typ=type(other)))
1984+
if not self._validate_ops_compat(other):
1985+
return NotImplemented
19721986

1973-
__rtruediv__ = _make_invalid('__rtruediv__')
1987+
other = Timedelta(other)
1988+
if other is NaT:
1989+
return NaT
1990+
return float(other.value) / self.value
19741991

19751992
if not PY3:
19761993
__div__ = __truediv__
1977-
__rdiv__ = _make_invalid('__rtruediv__')
1994+
__rdiv__ = __rtruediv__
1995+
1996+
def _not_implemented(self, *args, **kwargs):
1997+
return NotImplemented
19781998

1979-
__floordiv__ = _make_invalid('__floordiv__')
1980-
__rfloordiv__ = _make_invalid('__rfloordiv__')
1999+
__floordiv__ = _not_implemented
2000+
__rfloordiv__ = _not_implemented
19812001

19822002
def _op_unary_method(func, name):
19832003

0 commit comments

Comments
 (0)