From f953a68f8c32a2b6c4461b072f9c378de56c3b2f Mon Sep 17 00:00:00 2001 From: Ian Hunt-Isaak Date: Wed, 28 Jan 2026 17:07:57 -0500 Subject: [PATCH 1/5] FEAT/FIX support methods other than nearest for RangeIndex + end inclusiveness --- xarray/core/indexes.py | 18 ++++++++-- xarray/indexes/range_index.py | 18 +++++++--- xarray/tests/test_coordinate_transform.py | 11 +++++- xarray/tests/test_range_index.py | 43 ++++++++++++++++++++--- 4 files changed, 77 insertions(+), 13 deletions(-) diff --git a/xarray/core/indexes.py b/xarray/core/indexes.py index 74729d8e105..cc68b48849a 100644 --- a/xarray/core/indexes.py +++ b/xarray/core/indexes.py @@ -1514,9 +1514,9 @@ def sel( from xarray.core.dataarray import DataArray from xarray.core.variable import Variable - if method != "nearest": + if method is not None and method != "nearest": raise ValueError( - "CoordinateTransformIndex only supports selection with method='nearest'" + "CoordinateTransformIndex only supports selection with method='nearest' or None" ) labels_set = set(labels) @@ -1559,11 +1559,23 @@ def sel( results: dict[str, Variable | DataArray] = {} dims0 = tuple(dim_size0) + eps = np.finfo(float).eps * 1000 for dim, pos in dim_positions.items(): # TODO: rounding the decimal positions is not always the behavior we expect # (there are different ways to represent implicit intervals) # we should probably make this customizable. - pos = np.round(pos).astype("int") + if method == "nearest": + pos = np.round(pos).astype("int") + else: + # Exact matching: check if positions are close to integers + if not np.all(np.abs(pos - (rounded := np.round(pos))) < eps): + coord_name = ( + self.transform.coord_names[0] + if len(self.transform.coord_names) == 1 + else dim + ) + raise KeyError(f"not all values found in index {coord_name!r}") + pos = rounded.astype("int") if isinstance(label0_obj, Variable): results[dim] = Variable(dims0, pos) else: diff --git a/xarray/indexes/range_index.py b/xarray/indexes/range_index.py index 0a402ce663f..c1794f91f91 100644 --- a/xarray/indexes/range_index.py +++ b/xarray/indexes/range_index.py @@ -415,9 +415,6 @@ def sel( ) -> IndexSelResult: label = labels[self.dim] - if method != "nearest": - raise ValueError("RangeIndex only supports selection with method='nearest'") - # TODO: for RangeIndex it might not be too hard to support tolerance if tolerance is not None: raise ValueError( @@ -430,9 +427,20 @@ def sel( positions = self.transform.reverse( {self.coord_name: np.array([label.start, label.stop])} ) - pos = np.round(positions[self.dim]).astype("int") + pos = positions[self.dim] + # Small tolerance for floating point noise + # e.g., (0.7 - 0.1) / 0.1 = 5.999999999999999 instead of 6.0 + eps = np.finfo(float).eps * 1000 + if method == "nearest": + pos = np.round(pos).astype("int") + else: + pos = ( + int(np.ceil(pos[0] - eps)), + int(np.floor(pos[1] + eps)), + ) new_start = max(pos[0], 0) - new_stop = min(pos[1], self.size) + # +1 to be endpoint inclusive like pandas is + new_stop = min(pos[1] + 1, self.size) return IndexSelResult({self.dim: slice(new_start, new_stop)}) else: # otherwise convert to basic (array) indexing diff --git a/xarray/tests/test_coordinate_transform.py b/xarray/tests/test_coordinate_transform.py index 9e38763d251..d9220ed7b30 100644 --- a/xarray/tests/test_coordinate_transform.py +++ b/xarray/tests/test_coordinate_transform.py @@ -221,7 +221,16 @@ def test_coordinate_transform_sel() -> None: # doesn't work with coordinate transform index coordinate variables) assert actual.equals(expected) - with pytest.raises(ValueError, match=r".*only supports selection.*nearest"): + # exact values should work without method="nearest" + # scale=2.0, shape=(4,4) -> x,y values are 0.0, 2.0, 4.0, 6.0 + actual_exact = ds.sel( + x=xr.Variable("z", [0.0, 4.0]), y=xr.Variable("z", [0.0, 2.0]) + ) + expected_exact = ds.isel(x=xr.Variable("z", [0, 2]), y=xr.Variable("z", [0, 1])) + assert actual_exact.equals(expected_exact) + + # non-exact values without method="nearest" should raise KeyError + with pytest.raises(KeyError, match=r"not all values found in index"): ds.sel(x=xr.Variable("z", [0.5, 5.5]), y=xr.Variable("z", [0.0, 0.5])) with pytest.raises(ValueError, match=r"missing labels for coordinate.*y"): diff --git a/xarray/tests/test_range_index.py b/xarray/tests/test_range_index.py index 732bf1ef5c4..f6ef417061e 100644 --- a/xarray/tests/test_range_index.py +++ b/xarray/tests/test_range_index.py @@ -225,9 +225,10 @@ def test_range_index_empty_slice() -> None: def test_range_index_sel() -> None: ds = create_dataset_arange(0.0, 1.0, 0.1) - # start-stop slice + # start-stop slice (inclusive on both ends) + # 0.12 rounds to position 1 (value 0.1), 0.28 rounds to position 3 (value 0.3) actual = ds.sel(x=slice(0.12, 0.28), method="nearest") - expected = create_dataset_arange(0.1, 0.3, 0.1) + expected = ds.isel(x=slice(1, 4)) assert_identical(actual, expected, check_default_indexes=False, check_indexes=True) # start-stop-step slice @@ -235,6 +236,34 @@ def test_range_index_sel() -> None: expected = ds.isel(x=range(0, 10, 2)) assert_identical(actual, expected, check_default_indexes=False, check_indexes=True) + # slice selection should match equivalent isel + actual = ds.sel(x=slice(0.2, 0.7), method="nearest") + expected = ds.isel(x=slice(2, 8)) + assert_identical(actual, expected, check_default_indexes=False, check_indexes=True) + assert isinstance(actual.xindexes["x"], RangeIndex) + + # values near boundaries should round correctly (0.79999 -> 0.8) + actual = ds.sel(x=slice(0.2, 0.79999), method="nearest") + expected = ds.isel(x=slice(2, 9)) # 0.79999 rounds to position 8, +1 = 9 + assert_identical(actual, expected, check_default_indexes=False, check_indexes=True) + + # default method (no method parameter) uses ceil/floor + # slice(0.2, 0.7): ceil(2.0) = 2, floor(7.0) = 7, +1 = 8 + # Note: RangeIndex handles floating point precision correctly (includes 0.7) + actual = ds.sel(x=slice(0.2, 0.7)) + expected = ds.isel(x=slice(2, 8)) + assert_identical(actual, expected, check_default_indexes=False, check_indexes=True) + + # default method with non-exact boundaries + # slice(0.15, 0.65): ceil(1.5) = 2, floor(6.5) = 6, +1 = 7 + actual = ds.sel(x=slice(0.15, 0.65)) + expected = ds.isel(x=slice(2, 7)) + assert_identical(actual, expected, check_default_indexes=False, check_indexes=True) + + # reverse slice returns empty (matching pandas behavior) + actual = ds.sel(x=slice(0.7, 0.2), method="nearest") + assert actual.sizes["x"] == 0 + # basic indexing actual = ds.sel(x=0.52, method="nearest") expected = xr.Dataset(coords={"x": 0.5}) @@ -257,8 +286,14 @@ def test_range_index_sel() -> None: expected = xr.Dataset(coords={"x": ("y", [0.5, 0.6])}).set_xindex("x") assert_allclose(actual, expected, check_default_indexes=False) - with pytest.raises(ValueError, match=r"RangeIndex only supports.*method.*nearest"): - ds.sel(x=0.1) + # exact value should work without method + actual = ds.sel(x=0.1) + expected = xr.Dataset(coords={"x": 0.1}) + assert_allclose(actual, expected) + + # non-exact value without method should raise KeyError + with pytest.raises(KeyError, match=r"not all values found in index"): + ds.sel(x=0.15) with pytest.raises(ValueError, match=r"RangeIndex doesn't support.*tolerance"): ds.sel(x=0.1, method="nearest", tolerance=1e-3) From fa542ede62e59c8e41ce01b3bba15286419ec05b Mon Sep 17 00:00:00 2001 From: Ian Hunt-Isaak Date: Wed, 28 Jan 2026 17:12:11 -0500 Subject: [PATCH 2/5] whats new --- doc/whats-new.rst | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/doc/whats-new.rst b/doc/whats-new.rst index df0756f9fd0..4dc6d14d1a3 100644 --- a/doc/whats-new.rst +++ b/doc/whats-new.rst @@ -14,6 +14,16 @@ v2026.02.0 (unreleased) New Features ~~~~~~~~~~~~ +- :py:class:`~xarray.indexes.RangeIndex` now supports label-based slice selection + with both ``method="nearest"`` (rounds to nearest positions) and ``method=None`` + (exact matching with ceil/floor). Both are inclusive on the stop value, matching + pandas label-based slicing behavior (:pull:`XXXX`). + By `Ian Hunt-Isaak `_. +- :py:class:`~xarray.core.indexes.CoordinateTransformIndex` now supports + ``method=None`` for exact matching in addition to ``method="nearest"``. + When using exact matching, a ``KeyError`` is raised if values don't match + index positions (:pull:`XXXX`). + By `Ian Hunt-Isaak `_. Breaking Changes ~~~~~~~~~~~~~~~~ From dc92b078e30fcd612c29e80a5337367cea00d566 Mon Sep 17 00:00:00 2001 From: Ian Hunt-Isaak Date: Wed, 28 Jan 2026 17:19:00 -0500 Subject: [PATCH 3/5] simplifyy --- xarray/core/indexes.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/xarray/core/indexes.py b/xarray/core/indexes.py index cc68b48849a..b001478cfaf 100644 --- a/xarray/core/indexes.py +++ b/xarray/core/indexes.py @@ -1569,12 +1569,7 @@ def sel( else: # Exact matching: check if positions are close to integers if not np.all(np.abs(pos - (rounded := np.round(pos))) < eps): - coord_name = ( - self.transform.coord_names[0] - if len(self.transform.coord_names) == 1 - else dim - ) - raise KeyError(f"not all values found in index {coord_name!r}") + raise KeyError(f"not all values found in index {dim!r}") pos = rounded.astype("int") if isinstance(label0_obj, Variable): results[dim] = Variable(dims0, pos) From 255650c311349ebe2915aced8a99f38cc12df626 Mon Sep 17 00:00:00 2001 From: Ian Hunt-Isaak Date: Wed, 28 Jan 2026 17:24:04 -0500 Subject: [PATCH 4/5] consolidate --- xarray/tests/test_range_index.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/xarray/tests/test_range_index.py b/xarray/tests/test_range_index.py index f6ef417061e..54b3ccab4a5 100644 --- a/xarray/tests/test_range_index.py +++ b/xarray/tests/test_range_index.py @@ -230,18 +230,13 @@ def test_range_index_sel() -> None: actual = ds.sel(x=slice(0.12, 0.28), method="nearest") expected = ds.isel(x=slice(1, 4)) assert_identical(actual, expected, check_default_indexes=False, check_indexes=True) + assert isinstance(actual.xindexes["x"], RangeIndex) # start-stop-step slice actual = ds.sel(x=slice(0.0, 1.0, 0.2), method="nearest") expected = ds.isel(x=range(0, 10, 2)) assert_identical(actual, expected, check_default_indexes=False, check_indexes=True) - # slice selection should match equivalent isel - actual = ds.sel(x=slice(0.2, 0.7), method="nearest") - expected = ds.isel(x=slice(2, 8)) - assert_identical(actual, expected, check_default_indexes=False, check_indexes=True) - assert isinstance(actual.xindexes["x"], RangeIndex) - # values near boundaries should round correctly (0.79999 -> 0.8) actual = ds.sel(x=slice(0.2, 0.79999), method="nearest") expected = ds.isel(x=slice(2, 9)) # 0.79999 rounds to position 8, +1 = 9 @@ -249,7 +244,6 @@ def test_range_index_sel() -> None: # default method (no method parameter) uses ceil/floor # slice(0.2, 0.7): ceil(2.0) = 2, floor(7.0) = 7, +1 = 8 - # Note: RangeIndex handles floating point precision correctly (includes 0.7) actual = ds.sel(x=slice(0.2, 0.7)) expected = ds.isel(x=slice(2, 8)) assert_identical(actual, expected, check_default_indexes=False, check_indexes=True) From 9308100162454c8403cd70d04955fad62ff5ddd5 Mon Sep 17 00:00:00 2001 From: Ian Hunt-Isaak Date: Wed, 28 Jan 2026 17:26:02 -0500 Subject: [PATCH 5/5] pr number --- doc/whats-new.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/whats-new.rst b/doc/whats-new.rst index 4dc6d14d1a3..82e63f47d91 100644 --- a/doc/whats-new.rst +++ b/doc/whats-new.rst @@ -17,12 +17,12 @@ New Features - :py:class:`~xarray.indexes.RangeIndex` now supports label-based slice selection with both ``method="nearest"`` (rounds to nearest positions) and ``method=None`` (exact matching with ceil/floor). Both are inclusive on the stop value, matching - pandas label-based slicing behavior (:pull:`XXXX`). + pandas label-based slicing behavior (:pull:`11113`). By `Ian Hunt-Isaak `_. - :py:class:`~xarray.core.indexes.CoordinateTransformIndex` now supports ``method=None`` for exact matching in addition to ``method="nearest"``. When using exact matching, a ``KeyError`` is raised if values don't match - index positions (:pull:`XXXX`). + index positions (:pull:`11113`). By `Ian Hunt-Isaak `_. Breaking Changes