diff --git a/python/cudf/cudf/core/indexed_frame.py b/python/cudf/cudf/core/indexed_frame.py index 37b88e55c19a..f72ac492b336 100644 --- a/python/cudf/cudf/core/indexed_frame.py +++ b/python/cudf/cudf/core/indexed_frame.py @@ -184,6 +184,24 @@ ) +def _freq_after_take(original_freq, indexer): + """Return the freq that should apply after gathering rows by `indexer`. + + Matches pandas: if the positions in `indexer` form a constant step, + the new freq is step * original_freq; otherwise the new freq is None. + A result with 0 or 1 rows keeps the original freq. + """ + if original_freq is None: + return None + positions = cp.asarray(indexer) + if len(positions) <= 1: + return original_freq + diffs = cp.diff(positions) + if cp.all(diffs == diffs[0]): + return int(diffs[0]) * original_freq + return None + + def _get_unique_drop_labels(array): """Return labels to be dropped for IndexFrame.drop.""" if isinstance(array, (cudf.Series, cudf.Index, ColumnBase)): @@ -2422,6 +2440,133 @@ def truncate(self, before=None, after=None, axis=0, copy=True): slicer[axis] = slice(before, after) return self.loc[tuple(slicer)].copy() + @_performance_tracking + def between_time( + self, + start_time, + end_time, + inclusive: str = "both", + axis: Axis | None = None, + ) -> Self: + """ + Select values between particular times of the day (e.g., 9:00-9:30 AM). + + By setting ``start_time`` to be later than ``end_time``, you can get + the times that are *not* between the two times. + + Parameters + ---------- + start_time : datetime.time or str + Initial time as a time filter limit. + end_time : datetime.time or str + End time as a time filter limit. + inclusive : {"both", "neither", "left", "right"}, default "both" + Include boundaries; whether to set each bound as closed or open. + axis : {0 or 'index'}, None, default None + Axis on which to select. Only axis=0/'index' (rows) is supported. + + Returns + ------- + Series or DataFrame + Data from the original object filtered to the specified + time range. + + Raises + ------ + TypeError + If the index is not a :class:`~cudf.DatetimeIndex`. + + Examples + -------- + >>> import cudf + >>> i = cudf.date_range('2018-04-09', periods=4, freq='1D20min') + >>> ts = cudf.DataFrame({'A': [1, 2, 3, 4]}, index=i) + >>> ts + A + 2018-04-09 00:00:00 1 + 2018-04-10 00:20:00 2 + 2018-04-11 00:40:00 3 + 2018-04-12 01:00:00 4 + >>> ts.between_time('0:15', '0:45') + A + 2018-04-10 00:20:00 2 + 2018-04-11 00:40:00 3 + """ + if axis is not None and axis not in (0, "index"): + raise NotImplementedError("Only axis=0 is supported.") + + if not isinstance(self.index, cudf.DatetimeIndex): + raise TypeError("Index must be DatetimeIndex") + + if inclusive not in {"both", "neither", "left", "right"}: + raise ValueError( + "Inclusive has to be either 'both', 'neither', " + "'left' or 'right'" + ) + include_start = inclusive in {"both", "left"} + include_end = inclusive in {"both", "right"} + + indexer = self.index.indexer_between_time( + start_time, + end_time, + include_start=include_start, + include_end=include_end, + ) + result = self.iloc[indexer] + if isinstance(result.index, cudf.DatetimeIndex): + result.index._freq = _freq_after_take(self.index._freq, indexer) + return result + + @_performance_tracking + def at_time(self, time, axis: Axis | None = None) -> Self: + """ + Select values at particular time of day (e.g., 9:30AM). + + Parameters + ---------- + time : datetime.time or str + axis : {0 or 'index'}, None, default None + Axis on which to select. Only axis=0/'index' (rows) is supported. + + Returns + ------- + Series or DataFrame + + Raises + ------ + TypeError + If the index is not a :class:`~cudf.DatetimeIndex`. + + Examples + -------- + >>> import cudf + >>> i = cudf.date_range('2018-04-09', periods=4, freq='12h') + >>> ts = cudf.DataFrame({'A': [1, 2, 3, 4]}, index=i) + >>> ts + A + 2018-04-09 00:00:00 1 + 2018-04-09 12:00:00 2 + 2018-04-10 00:00:00 3 + 2018-04-10 12:00:00 4 + >>> ts.at_time('12:00') + A + 2018-04-09 12:00:00 2 + 2018-04-10 12:00:00 4 + """ + if axis is not None and axis not in (0, "index"): + raise NotImplementedError("Only axis=0 is supported.") + + if not isinstance(self.index, cudf.DatetimeIndex): + raise TypeError("Index must be DatetimeIndex") + + indexer = self.index.indexer_between_time( + time, time, include_start=True, include_end=True + ) + result = self.iloc[indexer] + if isinstance(result.index, cudf.DatetimeIndex): + result.index._freq = _freq_after_take(self.index._freq, indexer) + return result + @property def loc(self): """Select rows and columns by label or boolean mask. diff --git a/python/cudf/cudf/pandas/scripts/pandas-testing-plugin.py b/python/cudf/cudf/pandas/scripts/pandas-testing-plugin.py index f9249b5acef9..8be9cfd75125 100644 --- a/python/cudf/cudf/pandas/scripts/pandas-testing-plugin.py +++ b/python/cudf/cudf/pandas/scripts/pandas-testing-plugin.py @@ -550,8 +550,6 @@ def pytest_unconfigure(config): "tests/copy_view/test_methods.py::test_align_with_series_copy_false": "TODO: Add a reason for failure", "tests/copy_view/test_methods.py::test_assign_drop_duplicates[assign]": "TODO: Add a reason for failure", "tests/copy_view/test_methods.py::test_assign_drop_duplicates[drop_duplicates]": "TODO: Add a reason for failure", - "tests/copy_view/test_methods.py::test_between_time[obj0]": "TODO: Add a reason for failure", - "tests/copy_view/test_methods.py::test_between_time[obj1]": "TODO: Add a reason for failure", "tests/copy_view/test_methods.py::test_chained_methods[reset_index]": "TODO: Add a reason for failure", "tests/copy_view/test_methods.py::test_chained_where_mask[mask]": "TODO: Add a reason for failure", "tests/copy_view/test_methods.py::test_chained_where_mask[where]": "TODO: Add a reason for failure", diff --git a/python/cudf/cudf/tests/dataframe/methods/test_between_time_at_time.py b/python/cudf/cudf/tests/dataframe/methods/test_between_time_at_time.py new file mode 100644 index 000000000000..1082cfa01bca --- /dev/null +++ b/python/cudf/cudf/tests/dataframe/methods/test_between_time_at_time.py @@ -0,0 +1,91 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import pytest + +import cudf +from cudf.testing import assert_eq + + +@pytest.fixture +def gdf_pdf(): + dates = cudf.date_range("2018-04-09", periods=4, freq="1D20min") + df = cudf.DataFrame({"A": [1, 2, 3, 4]}, index=dates) + return df, df.to_pandas() + + +def test_between_time_basic(gdf_pdf): + gdf, pdf = gdf_pdf + expected = pdf.between_time("0:15", "0:45") + actual = gdf.between_time("0:15", "0:45") + assert_eq(actual, expected) + + +@pytest.mark.parametrize("inclusive", ["both", "neither", "left", "right"]) +def test_between_time_inclusive_modes(gdf_pdf, inclusive): + gdf, pdf = gdf_pdf + expected = pdf.between_time("0:20", "0:40", inclusive=inclusive) + actual = gdf.between_time("0:20", "0:40", inclusive=inclusive) + assert_eq(actual, expected) + + +def test_between_time_wraparound(gdf_pdf): + gdf, pdf = gdf_pdf + expected = pdf.between_time("0:45", "0:15") + actual = gdf.between_time("0:45", "0:15") + assert_eq(actual, expected) + + +def test_between_time_fractional_seconds(): + dates = cudf.date_range("2021-01-01 00:00:00", periods=5, freq="500ms") + gdf = cudf.DataFrame({"A": range(5)}, index=dates) + pdf = gdf.to_pandas() + + expected = pdf.between_time("00:00:00.250", "00:00:01.750") + actual = gdf.between_time("00:00:00.250", "00:00:01.750") + assert_eq(actual, expected) + + +def test_between_time_invalid_index(): + gdf = cudf.DataFrame({"A": [1, 2, 3]}) + with pytest.raises(TypeError): + gdf.between_time("0:15", "0:45") + + +def test_between_time_invalid_inclusive(gdf_pdf): + gdf, _ = gdf_pdf + with pytest.raises(ValueError): + gdf.between_time("0:15", "0:45", inclusive="oops") + + +def test_at_time_basic(): + dates = cudf.date_range("2018-04-09", periods=4, freq="12h") + gdf = cudf.DataFrame({"A": [1, 2, 3, 4]}, index=dates) + pdf = gdf.to_pandas() + + expected = pdf.at_time("12:00") + actual = gdf.at_time("12:00") + assert_eq(actual, expected) + + +def test_at_time_fractional_seconds(): + dates = cudf.date_range("2021-01-01 00:00:00", periods=5, freq="500ms") + gdf = cudf.DataFrame({"A": range(5)}, index=dates) + pdf = gdf.to_pandas() + + expected = pdf.at_time("00:00:01.500") + actual = gdf.at_time("00:00:01.500") + assert_eq(actual, expected) + + +def test_at_time_invalid_index(): + gdf = cudf.DataFrame({"A": [1, 2, 3]}) + with pytest.raises(TypeError): + gdf.at_time("12:00") + + +def test_at_time_invalid_axis(): + dates = cudf.date_range("2018-04-09", periods=4, freq="12h") + gdf = cudf.DataFrame({"A": [1, 2, 3, 4]}, index=dates) + with pytest.raises(NotImplementedError): + gdf.at_time("12:00", axis=1) diff --git a/python/cudf/cudf/tests/series/methods/test_between_time_at_time.py b/python/cudf/cudf/tests/series/methods/test_between_time_at_time.py new file mode 100644 index 000000000000..9de1962f37c0 --- /dev/null +++ b/python/cudf/cudf/tests/series/methods/test_between_time_at_time.py @@ -0,0 +1,31 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import pytest + +import cudf +from cudf.testing import assert_eq + + +@pytest.fixture +def gsr_psr(): + dates = cudf.date_range("2018-04-09", periods=4, freq="1D20min") + gsr = cudf.Series([1, 2, 3, 4], index=dates) + return gsr, gsr.to_pandas() + + +def test_series_between_time_basic(gsr_psr): + gsr, psr = gsr_psr + expected = psr.between_time("0:20", "0:40") + actual = gsr.between_time("0:20", "0:40") + assert_eq(actual, expected) + + +def test_series_at_time_basic(): + dates = cudf.date_range("2018-04-09", periods=4, freq="12h") + gsr = cudf.Series([1, 2, 3, 4], index=dates) + psr = gsr.to_pandas() + + expected = psr.at_time("12:00") + actual = gsr.at_time("12:00") + assert_eq(actual, expected)