From 208b0f3a8f661a01e39483e0033362ec5a8f31ad Mon Sep 17 00:00:00 2001 From: mokashang Date: Wed, 27 May 2026 09:30:26 -0700 Subject: [PATCH 1/4] Preserve step in RangeIndex.arange and slicing RangeIndex stored only (start, stop, size) and re-derived the step as (stop - start) / size. That derivation only equals the requested spacing when (stop - start) is an exact multiple of it, so RangeIndex.arange and strided slicing silently changed the step and produced values that did not match numpy.arange. For example RangeIndex.arange(0.0, 1.0, 0.3) yielded a step of 0.25 and values [0, 0.25, 0.5, 0.75] instead of the expected [0, 0.3, 0.6, 0.9]. Carry the exact step through RangeCoordinateTransform: arange snaps stop to start + size * step and passes the step explicitly, and slicing scales the parent step by the slice's own step. Both now match numpy.arange. Fixes #11325 --- doc/whats-new.rst | 6 ++++++ xarray/indexes/range_index.py | 29 +++++++++++++++++++++-------- xarray/tests/test_range_index.py | 28 ++++++++++++++++++++++------ 3 files changed, 49 insertions(+), 14 deletions(-) diff --git a/doc/whats-new.rst b/doc/whats-new.rst index 0425452de8d..e3a350d9a56 100644 --- a/doc/whats-new.rst +++ b/doc/whats-new.rst @@ -33,6 +33,12 @@ Bug Fixes a ``zarr_format=3`` store with ``use_zarr_fill_value_as_mask=False``, so it is no longer silently lost on round-trip (:issue:`10269`). By `Davis Bennett `_. +- :py:meth:`~xarray.indexes.RangeIndex.arange` now preserves the requested + ``step`` instead of silently re-deriving it from ``(stop - start) / size``, so + its values match :py:func:`numpy.arange` when ``step`` does not evenly divide + the interval. Strided slicing of a :py:class:`~xarray.indexes.RangeIndex` now + preserves the step as well (:issue:`11325`). + By `mokashang `_. Documentation diff --git a/xarray/indexes/range_index.py b/xarray/indexes/range_index.py index 0a402ce663f..9b798d3a850 100644 --- a/xarray/indexes/range_index.py +++ b/xarray/indexes/range_index.py @@ -32,6 +32,7 @@ def __init__( coord_name: Hashable, dim: str, dtype: Any = None, + step: float | None = None, ): if dtype is None: dtype = np.dtype(np.float64) @@ -40,7 +41,12 @@ def __init__( self.start = start self.stop = stop - self._step = None # Will be calculated by property + # When ``step`` is not given it is derived from ``(stop - start) / size`` + # by the ``step`` property. That derivation is only correct when + # ``(stop - start)`` is an exact multiple of the spacing, so callers that + # know the exact spacing (e.g. ``arange`` and ``slice``) pass it here to + # avoid silently changing it. See GH11325. + self._step = step @property def coord_name(self) -> Hashable: @@ -121,21 +127,23 @@ def slice(self, sl: slice) -> "RangeCoordinateTransform": new_range = range(self.size)[sl] new_size = len(new_range) + # A slice scales the spacing by its own step, e.g. ``[::2]`` doubles it. + # Preserve the exact resulting step instead of letting it be re-derived + # from ``(stop - start) / size``, which would be wrong whenever the + # spacing does not evenly divide the interval. See GH11325. + new_step = self.step * new_range.step new_start = self.start + new_range.start * self.step - new_stop = self.start + new_range.stop * self.step + new_stop = new_start + new_size * new_step - result = type(self)( + return type(self)( new_start, new_stop, new_size, self.coord_name, self.dim, dtype=self.dtype, + step=new_step, ) - if new_size == 0: - # For empty slices, preserve step from parent - result._step = self.step - return result class RangeIndex(CoordinateTransformIndex): @@ -278,8 +286,13 @@ def arange( size = math.ceil((stop - start) / step) + # Snap ``stop`` to ``start + size * step`` and keep the exact ``step`` so + # that the materialized values match ``numpy.arange`` even when ``step`` + # does not evenly divide ``stop - start``. See GH11325. + stop = start + size * step + transform = RangeCoordinateTransform( - start, stop, size, coord_name, dim, dtype=dtype + start, stop, size, coord_name, dim, dtype=dtype, step=step ) return cls(transform) diff --git a/xarray/tests/test_range_index.py b/xarray/tests/test_range_index.py index 732bf1ef5c4..dc9165734f3 100644 --- a/xarray/tests/test_range_index.py +++ b/xarray/tests/test_range_index.py @@ -64,6 +64,19 @@ def test_range_index_arange_properties() -> None: assert index.step == 0.1 +def test_range_index_arange_step_not_dividing_interval() -> None: + # GH11325: when ``step`` does not evenly divide ``stop - start`` the + # requested step must still be honored and the materialized values must + # match ``numpy.arange`` (previously the step was silently re-derived from + # ``(stop - start) / size``, e.g. 0.25 instead of the requested 0.3). + index = RangeIndex.arange(0.0, 1.0, 0.3, dim="x") + assert index.step == 0.3 + assert index.size == 4 + actual = xr.Coordinates.from_xindex(index) + expected = xr.Coordinates({"x": np.arange(0.0, 1.0, 0.3)}) + assert_equal(actual, expected, check_default_indexes=False) + + def test_range_index_linspace() -> None: index = RangeIndex.linspace(0.0, 1.0, num=10, endpoint=False, dim="x") actual = xr.Coordinates.from_xindex(index) @@ -141,7 +154,13 @@ def test_range_index_isel() -> None: ds2 = create_dataset_arange(0.0, 3.0, 0.1) actual = ds2.isel(x=slice(4, None, 3)) expected = create_dataset_arange(0.4, 3.0, 0.3) - assert_identical(actual, expected, check_default_indexes=False, check_indexes=True) + # The strided slice now preserves the step (0.3), so its values match + # ``np.arange``. The index ``equals`` check (isclose-based) is used rather + # than ``assert_identical`` because the slice computes its step as ``0.1 * 3`` + # while ``arange`` uses the literal ``0.3``; these agree up to floating point + # round-off (GH11325). + assert actual.xindexes["x"].equals(expected.xindexes["x"]) + np.testing.assert_allclose(actual["x"].values, np.arange(0.0, 3.0, 0.1)[4::3]) # scalar actual = ds.isel(x=0) @@ -372,11 +391,8 @@ def test_range_index_equals_exact() -> None: # Create an index directly index1 = RangeIndex.arange(0.0, 0.3, 0.1, dim="x") - # Create the same index by slicing - this accumulates floating point error - index_large = RangeIndex.arange(0.0, 1.0, 0.1, dim="x") - ds_large = xr.Dataset(coords=xr.Coordinates.from_xindex(index_large)) - ds_sliced = ds_large.isel(x=slice(3)) - index2 = ds_sliced.xindexes["x"] + # Create an index whose start differs by a tiny floating point amount + index2 = RangeIndex.arange(1e-12, 0.3 + 1e-12, 0.1, dim="x") # Default (exact=False) should be equal due to np.isclose tolerance assert index1.equals(index2) From 0bd82fb9e9e946499a83b502c3a18aa7fb518a11 Mon Sep 17 00:00:00 2001 From: Deepak Cherian Date: Fri, 5 Jun 2026 08:49:10 -0600 Subject: [PATCH 2/4] Apply suggestion from @dcherian --- xarray/tests/test_range_index.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/xarray/tests/test_range_index.py b/xarray/tests/test_range_index.py index dc9165734f3..cba29458bca 100644 --- a/xarray/tests/test_range_index.py +++ b/xarray/tests/test_range_index.py @@ -154,11 +154,6 @@ def test_range_index_isel() -> None: ds2 = create_dataset_arange(0.0, 3.0, 0.1) actual = ds2.isel(x=slice(4, None, 3)) expected = create_dataset_arange(0.4, 3.0, 0.3) - # The strided slice now preserves the step (0.3), so its values match - # ``np.arange``. The index ``equals`` check (isclose-based) is used rather - # than ``assert_identical`` because the slice computes its step as ``0.1 * 3`` - # while ``arange`` uses the literal ``0.3``; these agree up to floating point - # round-off (GH11325). assert actual.xindexes["x"].equals(expected.xindexes["x"]) np.testing.assert_allclose(actual["x"].values, np.arange(0.0, 3.0, 0.1)[4::3]) From f787db80af06f4f6786b893618a5f739fe4178ec Mon Sep 17 00:00:00 2001 From: Deepak Cherian Date: Fri, 5 Jun 2026 08:49:27 -0600 Subject: [PATCH 3/4] Apply suggestion from @dcherian --- xarray/indexes/range_index.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/xarray/indexes/range_index.py b/xarray/indexes/range_index.py index 9b798d3a850..f1e94533391 100644 --- a/xarray/indexes/range_index.py +++ b/xarray/indexes/range_index.py @@ -41,11 +41,6 @@ def __init__( self.start = start self.stop = stop - # When ``step`` is not given it is derived from ``(stop - start) / size`` - # by the ``step`` property. That derivation is only correct when - # ``(stop - start)`` is an exact multiple of the spacing, so callers that - # know the exact spacing (e.g. ``arange`` and ``slice``) pass it here to - # avoid silently changing it. See GH11325. self._step = step @property From dd87811add21eeea39a0a8782cfe6f4664672cab Mon Sep 17 00:00:00 2001 From: Deepak Cherian Date: Fri, 5 Jun 2026 08:49:58 -0600 Subject: [PATCH 4/4] Apply suggestion from @dcherian --- xarray/indexes/range_index.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/xarray/indexes/range_index.py b/xarray/indexes/range_index.py index f1e94533391..4f8d52f6510 100644 --- a/xarray/indexes/range_index.py +++ b/xarray/indexes/range_index.py @@ -285,7 +285,7 @@ def arange( # that the materialized values match ``numpy.arange`` even when ``step`` # does not evenly divide ``stop - start``. See GH11325. stop = start + size * step - + # Snap `stop` to `start + size * step` transform = RangeCoordinateTransform( start, stop, size, coord_name, dim, dtype=dtype, step=step )