This is an automated email from the ASF dual-hosted git repository.

HyukjinKwon pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/master by this push:
     new 18d6d218e6b0 [SPARK-55299][PS] Infer the correct unit for calculated 
timedeltas
18d6d218e6b0 is described below

commit 18d6d218e6b05c746a55021aa8883405445f1fe6
Author: Fangchen Li <[email protected]>
AuthorDate: Tue Jun 23 07:42:02 2026 +0900

    [SPARK-55299][PS] Infer the correct unit for calculated timedeltas
    
    ### What changes were proposed in this pull request?
    
    This PR adds `_with_inferred_unit` to `TimedeltaOps.sub`/`rsub` to set the
    result dtype from the operand units (numpy `timedelta64` dtype, or
    `pd.Timedelta.unit` for scalars)
    
    ### Why are the changes needed?
    
    To match pandas 3 behavior.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes. On pandas 3, the dtype of a timedelta produced by subtraction now
    follows the finer resolution of the operands (capped at microseconds) 
instead
    of always timedelta64[us]. Behavior on pandas < 3.0.0 is unchanged.
    
    ### How was this patch tested?
    
    Unitetests added.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Opus 4.8
    
    Closes #56624 from fangchenli/SPARK-55299-timedelta-unit.
    
    Authored-by: Fangchen Li <[email protected]>
    Signed-off-by: Hyukjin Kwon <[email protected]>
---
 .../pyspark/pandas/data_type_ops/timedelta_ops.py  | 39 +++++++++++++++++++-
 .../tests/data_type_ops/test_timedelta_ops.py      | 43 ++++++++++++++++++++++
 2 files changed, 80 insertions(+), 2 deletions(-)

diff --git a/python/pyspark/pandas/data_type_ops/timedelta_ops.py 
b/python/pyspark/pandas/data_type_ops/timedelta_ops.py
index aa3e8f64801f..6f17474f61d8 100644
--- a/python/pyspark/pandas/data_type_ops/timedelta_ops.py
+++ b/python/pyspark/pandas/data_type_ops/timedelta_ops.py
@@ -18,6 +18,7 @@
 from datetime import timedelta
 from typing import Any, Union
 
+import numpy as np
 import pandas as pd
 from pandas.api.types import CategoricalDtype
 
@@ -72,6 +73,38 @@ class TimedeltaOps(DataTypeOps):
         else:
             return col.astype(self.dtype)
 
+    def _with_inferred_unit(
+        self, result: SeriesOrIndex, left: IndexOpsLike, right: 
Union[IndexOpsMixin, timedelta]
+    ) -> SeriesOrIndex:
+        # pandas 3.0.0+ promotes timedelta arithmetic to the finer resolution 
of the
+        # operands; before that, pandas-on-Spark represented timedelta as 
nanoseconds.
+        if LooseVersion(pd.__version__) < "3.0.0":
+            return result
+
+        def unit_of(obj: Union[IndexOpsMixin, timedelta]) -> str:
+            if isinstance(obj, IndexOpsMixin):
+                dtype = obj.dtype
+                if isinstance(dtype, np.dtype) and np.issubdtype(dtype, 
np.timedelta64):
+                    return np.datetime_data(dtype)[0]
+            elif isinstance(obj, pd.Timedelta):
+                return obj.unit
+            # datetime.timedelta scalars and object-backed interval columns 
map to microseconds.
+            return "us"
+
+        promoted = np.promote_types(
+            np.dtype(f"timedelta64[{unit_of(left)}]"),
+            np.dtype(f"timedelta64[{unit_of(right)}]"),
+        )
+        # DayTimeIntervalType stores microseconds and cannot represent finer 
resolutions.
+        if np.datetime_data(promoted)[0] == "ns":
+            promoted = np.dtype("timedelta64[us]")
+
+        field = result._internal.data_fields[0]
+        if field.dtype == promoted:
+            # Already the right resolution (the common us case); avoid 
rebuilding the field.
+            return result
+        return result._with_new_scol(result.spark.column, 
field=field.copy(dtype=promoted))
+
     def sub(self, left: IndexOpsLike, right: Any) -> SeriesOrIndex:
         _sanitize_list_like(right)
 
@@ -80,7 +113,8 @@ class TimedeltaOps(DataTypeOps):
             and isinstance(right.spark.data_type, DayTimeIntervalType)
             or isinstance(right, timedelta)
         ):
-            return pyspark_column_op("__sub__", left, right)
+            result = pyspark_column_op("__sub__", left, right)
+            return self._with_inferred_unit(result, left, right)
         else:
             raise TypeError("Timedelta subtraction can only be applied to 
timedelta series.")
 
@@ -88,7 +122,8 @@ class TimedeltaOps(DataTypeOps):
         _sanitize_list_like(right)
 
         if isinstance(right, timedelta):
-            return pyspark_column_op("__rsub__", left, right)
+            result = pyspark_column_op("__rsub__", left, right)
+            return self._with_inferred_unit(result, left, right)
         else:
             raise TypeError("Timedelta subtraction can only be applied to 
timedelta series.")
 
diff --git a/python/pyspark/pandas/tests/data_type_ops/test_timedelta_ops.py 
b/python/pyspark/pandas/tests/data_type_ops/test_timedelta_ops.py
index 868850a27c27..34190920017a 100644
--- a/python/pyspark/pandas/tests/data_type_ops/test_timedelta_ops.py
+++ b/python/pyspark/pandas/tests/data_type_ops/test_timedelta_ops.py
@@ -17,10 +17,12 @@
 
 from datetime import timedelta
 
+import numpy as np
 import pandas as pd
 from pandas.api.types import CategoricalDtype
 
 import pyspark.pandas as ps
+from pyspark.loose_version import LooseVersion
 from pyspark.testing.pandasutils import PandasOnSparkTestCase
 from pyspark.pandas.tests.data_type_ops.testing_utils import OpsTestBase
 
@@ -73,6 +75,47 @@ class TimedeltaOpsTestsMixin:
         pdf, psdf = self.timedelta_pdf, self.timedelta_psdf
         self.assert_eq(pdf["that"] - pdf["this"], psdf["that"] - psdf["this"])
 
+    def test_sub_unit(self):
+        # pandas 3.0.0+ takes the finer resolution of the operands instead of 
always
+        # microseconds (SPARK-55299); before that timedelta64 is always 
nanoseconds.
+        if LooseVersion(pd.__version__) < "3.0.0":
+            return
+
+        for left_unit, right_unit in [("s", "s"), ("ms", "ms"), ("us", "us"), 
("s", "ms")]:
+            pser1 = pd.Series(np.array([3, 5, 8], 
dtype=f"timedelta64[{left_unit}]"))
+            pser2 = pd.Series(np.array([1, 2, 3], 
dtype=f"timedelta64[{right_unit}]"))
+            psser1, psser2 = ps.from_pandas(pser1), ps.from_pandas(pser2)
+            self.assert_eq(pser1 - pser2, psser1 - psser2)
+            self.assert_eq(pser1 - timedelta(seconds=1), psser1 - 
timedelta(seconds=1))
+            self.assert_eq(timedelta(seconds=1) - pser1, timedelta(seconds=1) 
- psser1)
+
+            # pd.Timedelta is a datetime.timedelta subclass that carries its 
own resolution.
+            for unit in ["s", "ms"]:
+                scalar = pd.Timedelta(1, unit=unit)
+                self.assert_eq(pser1 - scalar, psser1 - scalar)
+                self.assert_eq(scalar - pser1, scalar - psser1)
+
+        pidx = pd.Index(np.array([3, 5, 8], dtype="timedelta64[s]"))
+        psidx = ps.from_pandas(pidx)
+        self.assert_eq(pidx - pidx, psidx - psidx)
+
+        # DayTimeIntervalType cannot represent nanoseconds, so nanosecond 
operands are
+        # capped at microseconds rather than matching pandas' nanosecond 
result.
+        pser_ns = pd.Series(np.array([3, 5, 8], dtype="timedelta64[ns]"))
+        pser_s = pd.Series(np.array([3, 5, 8], dtype="timedelta64[s]"))
+        psser_ns, psser_s = ps.from_pandas(pser_ns), ps.from_pandas(pser_s)
+        self.assertEqual((psser_ns - psser_ns)._to_pandas().dtype, 
np.dtype("timedelta64[us]"))
+        self.assertEqual(
+            (psser_s - pd.Timedelta(1, unit="ns"))._to_pandas().dtype, 
np.dtype("timedelta64[us]")
+        )
+
+        # object-backed interval columns carry no numpy resolution; must not 
fail.
+        pser_obj = pd.Series([timedelta(days=1), timedelta(seconds=2)], 
dtype=object)
+        psser_obj = ps.from_pandas(pser_obj)
+        expected = pser_obj.astype("timedelta64[us]")
+        self.assert_eq(expected - timedelta(0), psser_obj - timedelta(0))
+        self.assert_eq(expected - expected, psser_obj - psser_obj)
+
     def test_mul(self):
         self.assertRaises(TypeError, lambda: self.psser * "x")
         self.assertRaises(TypeError, lambda: self.psser * 1)


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to