diff --git a/doc/source/whatsnew/v3.0.0.rst b/doc/source/whatsnew/v3.0.0.rst index bf3a6745aae3d..1b513c199c672 100644 --- a/doc/source/whatsnew/v3.0.0.rst +++ b/doc/source/whatsnew/v3.0.0.rst @@ -1195,6 +1195,7 @@ Indexing - Bug in :meth:`Index.get_indexer` and similar methods when ``NaN`` is located at or after position 128 (:issue:`58924`) - Bug in :meth:`MultiIndex.insert` when a new value inserted to a datetime-like level gets cast to ``NaT`` and fails indexing (:issue:`60388`) - Bug in :meth:`Series.__setitem__` when assigning boolean series with boolean indexer will raise ``LossySetitemError`` (:issue:`57338`) +- Bug in indexing ``obj.loc[start:stop]`` with a :class:`DatetimeIndex` and :class:`Timestamp` endpoints with higher resolution than the index (:issue:`63262`) - Bug in printing :attr:`Index.names` and :attr:`MultiIndex.levels` would not escape single quotes (:issue:`60190`) - Bug in reindexing of :class:`DataFrame` with :class:`PeriodDtype` columns in case of consolidated block (:issue:`60980`, :issue:`60273`) - Bug in :meth:`DataFrame.loc.__getitem__` and :meth:`DataFrame.iloc.__getitem__` with a :class:`CategoricalDtype` column with integer categories raising when trying to index a row containing a ``NaN`` entry (:issue:`58954`) diff --git a/pandas/core/indexes/datetimelike.py b/pandas/core/indexes/datetimelike.py index e81181e49eef7..fd061666c1f00 100644 --- a/pandas/core/indexes/datetimelike.py +++ b/pandas/core/indexes/datetimelike.py @@ -11,6 +11,7 @@ from typing import ( TYPE_CHECKING, Any, + Literal, Self, cast, final, @@ -20,16 +21,18 @@ from pandas._libs import ( NaT, - Timedelta, lib, ) from pandas._libs.tslibs import ( BaseOffset, Resolution, Tick, + Timedelta, + Timestamp, parsing, to_offset, ) +from pandas._libs.tslibs.dtypes import abbrev_to_npy_unit from pandas.compat.numpy import function as nv from pandas.errors import ( InvalidIndexError, @@ -933,6 +936,22 @@ def _from_join_target(self, result: np.ndarray): result = result.view(self._data._ndarray.dtype) return self._data._from_backing_data(result) + def _searchsorted_monotonic(self, label, side: Literal["left", "right"] = "left"): + if ( + self.is_monotonic_increasing + and isinstance(label, (Timestamp, Timedelta)) + and abbrev_to_npy_unit(label.unit) > abbrev_to_npy_unit(self.unit) + ): + # For non-matching units we can safely round down (with side=right) + # This is needed for GH#63262 + if side == "right": + label = label.as_unit(self.unit) # this should always be a round-down + else: + # round up + label = label.ceil(self.unit).as_unit(self.unit) + + return super()._searchsorted_monotonic(label, side) + # -------------------------------------------------------------------- # List-like Methods diff --git a/pandas/tests/indexes/datetimes/test_indexing.py b/pandas/tests/indexes/datetimes/test_indexing.py index 8bb02563233a4..2f7eaa2c209cd 100644 --- a/pandas/tests/indexes/datetimes/test_indexing.py +++ b/pandas/tests/indexes/datetimes/test_indexing.py @@ -656,6 +656,32 @@ def test_maybe_cast_slice_duplicate_monotonic(self): class TestGetSliceBounds: + @pytest.mark.parametrize("as_td", [True, False]) + def test_get_slice_bound_mismatched_unit(self, as_td): + # GH#63262 + index = date_range(start="2000-01-01", freq="h", periods=8) + + td = pd.Timedelta(1) + ts = Timestamp("2000-01-01 01:00:00") + start = ts - td + stop = ts + td + if as_td: + index = index - Timestamp(0).as_unit("us") + start = start - Timestamp(0).as_unit("us") + stop = stop - Timestamp(0).as_unit("us") + + left = index.get_slice_bound(start, side="left") + assert left == 1 + right = index.get_slice_bound(stop, side="right") + assert right == 2 + + # The user-facing behavior is slicing with .loc, so let's test that + # explicitly while we're here. + ser = pd.Series(1, index=index) + result = ser.loc[start:stop] + expected = ser.iloc[1:2] + tm.assert_series_equal(result, expected) + @pytest.mark.parametrize("box", [date, datetime, Timestamp]) @pytest.mark.parametrize("side, expected", [("left", 4), ("right", 5)]) def test_get_slice_bounds_datetime_within(