diff --git a/doc/source/whatsnew/v3.0.0.rst b/doc/source/whatsnew/v3.0.0.rst index 2d5d7120bfee9..29122eb25ddfc 100644 --- a/doc/source/whatsnew/v3.0.0.rst +++ b/doc/source/whatsnew/v3.0.0.rst @@ -919,6 +919,7 @@ Timedelta ^^^^^^^^^ - Accuracy improvement in :meth:`Timedelta.to_pytimedelta` to round microseconds consistently for large nanosecond based Timedelta (:issue:`57841`) - Bug in :class:`Timedelta` constructor failing to raise when passed an invalid keyword (:issue:`53801`) +- Bug in :class:`Timedelta` when constructed from Tick offsets (e.g., ``Timedelta(offsets.Hour(1))``) that could produce incorrect results in arithmetic. Timedelta now handles Hour, Minute, and Second offsets consistently with keyword arguments (:issue:`62310`) - Bug in :meth:`DataFrame.cumsum` which was raising ``IndexError`` if dtype is ``timedelta64[ns]`` (:issue:`57956`) - Bug in multiplication operations with ``timedelta64`` dtype failing to raise ``TypeError`` when multiplying by ``bool`` objects or dtypes (:issue:`58054`) diff --git a/pandas/_libs/tslibs/timedeltas.pyx b/pandas/_libs/tslibs/timedeltas.pyx index c13b0c4cd78a5..61fd7c8f0b241 100644 --- a/pandas/_libs/tslibs/timedeltas.pyx +++ b/pandas/_libs/tslibs/timedeltas.pyx @@ -2012,6 +2012,8 @@ class Timedelta(_Timedelta): "milliseconds", "microseconds", "nanoseconds"} def __new__(cls, object value=_no_input, unit=None, **kwargs): + cdef NPY_DATETIMEUNIT in_reso + cdef NPY_DATETIMEUNIT target_reso unsupported_kwargs = set(kwargs) unsupported_kwargs.difference_update(cls._req_any_kwargs_new) if unsupported_kwargs or ( @@ -2134,9 +2136,30 @@ class Timedelta(_Timedelta): return cls._from_value_and_reso(new_value, reso=new_reso) elif is_tick_object(value): - new_reso = get_supported_reso(value._creso) - new_value = delta_to_nanoseconds(value, reso=new_reso) - return cls._from_value_and_reso(new_value, reso=new_reso) + # GH#62310 + # Handle Tick offsets + in_reso = value._creso + + if in_reso == NPY_DATETIMEUNIT.NPY_FR_ns: + target_reso = NPY_DATETIMEUNIT.NPY_FR_ns + elif in_reso == NPY_DATETIMEUNIT.NPY_FR_us: + target_reso = NPY_DATETIMEUNIT.NPY_FR_us + elif in_reso == NPY_DATETIMEUNIT.NPY_FR_ms: + target_reso = NPY_DATETIMEUNIT.NPY_FR_ms + elif in_reso in ( + NPY_DATETIMEUNIT.NPY_FR_s, + NPY_DATETIMEUNIT.NPY_FR_m, + NPY_DATETIMEUNIT.NPY_FR_h, + ): + target_reso = NPY_DATETIMEUNIT.NPY_FR_s + else: + raise ValueError( + f"Value must be Timedelta, string, integer, float, timedelta " + f"or convertible, not {type(value).__name__}" + ) + + new_value = delta_to_nanoseconds(value, reso=target_reso) + return cls._from_value_and_reso(new_value, reso=target_reso) elif is_integer_object(value) or is_float_object(value): # unit=None is de-facto 'ns' diff --git a/pandas/tests/scalar/timedelta/test_constructors.py b/pandas/tests/scalar/timedelta/test_constructors.py index c9904a318e22d..ee72fc7234d98 100644 --- a/pandas/tests/scalar/timedelta/test_constructors.py +++ b/pandas/tests/scalar/timedelta/test_constructors.py @@ -13,6 +13,7 @@ NaT, Timedelta, TimedeltaIndex, + Timestamp, offsets, to_timedelta, ) @@ -263,6 +264,36 @@ def test_from_tick_reso(): Timedelta(tick) +def test_tick_offset_arithmetic_consistency(): + # GH#62310: Timedelta construction from Tick offset objects should + # behave consistently with Timedelta keyword arguments + + # Construct Timedelta from Tick offset vs keyword argument + tick_td = Timedelta(offsets.Hour(1)) + kwarg_td = Timedelta(hours=1) + + # Both should represent the same duration + assert tick_td == kwarg_td + assert tick_td.value == kwarg_td.value + + # Subtraction with Timestamp + ts = Timestamp("2020-01-01 02:00:00") + result1 = ts - tick_td + result2 = ts - kwarg_td + assert result1 == result2 == Timestamp("2020-01-01 01:00:00") + + # Addition with Timestamp + ts2 = Timestamp("2020-01-01 00:00:00") + result3 = ts2 + tick_td + result4 = ts2 + kwarg_td + assert result3 == result4 == Timestamp("2020-01-01 01:00:00") + + # Timedelta arithmetic + assert tick_td - kwarg_td == Timedelta(0) + assert kwarg_td - tick_td == Timedelta(0) + assert tick_td + kwarg_td == Timedelta(hours=2) + + def test_construction(): expected = np.timedelta64(10, "D").astype("m8[ns]").view("i8") assert Timedelta(10, unit="D")._value == expected