Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions doc/whats-new.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:`11113`).
By `Ian Hunt-Isaak <https://github.com/ianhi>`_.
- :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:`11113`).
By `Ian Hunt-Isaak <https://github.com/ianhi>`_.

Breaking Changes
~~~~~~~~~~~~~~~~
Expand Down
13 changes: 10 additions & 3 deletions xarray/core/indexes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -1559,11 +1559,18 @@ 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):
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)
else:
Expand Down
18 changes: 13 additions & 5 deletions xarray/indexes/range_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand Down
11 changes: 10 additions & 1 deletion xarray/tests/test_coordinate_transform.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand Down
37 changes: 33 additions & 4 deletions xarray/tests/test_range_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,16 +225,39 @@ 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)
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)

# 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
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})
Expand All @@ -257,8 +280,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)
Expand Down
Loading