diff --git a/src/erlab/interactive/imagetool/slicer.py b/src/erlab/interactive/imagetool/slicer.py index fa2d28e8..21c93386 100644 --- a/src/erlab/interactive/imagetool/slicer.py +++ b/src/erlab/interactive/imagetool/slicer.py @@ -308,15 +308,18 @@ def qsel_args_from_indexers( if dim in binned_dim_set: coord = data[dim][selector].values center = float(np.round(coord.mean(), order)) - width = float(np.round(coord[-1] - coord[0] + inc, order)) + width = float(np.abs(np.round(coord[-1] - coord[0] + inc, order))) out[dim] = center - if not np.allclose( - data[dim] - .sel({dim: slice(center - width / 2, center + width / 2)}) - .values, - coord, - ): + slice_obj = slice(center - width / 2, center + width / 2) + if coord[0] > coord[-1]: + slice_obj = slice( + slice_obj.stop, + slice_obj.start, + -slice_obj.step if slice_obj.step is not None else None, + ) + + if not np.allclose(data[dim].sel({dim: slice_obj}).values, coord): raise ValueError( "Bin does not contain the same values as the original data." ) @@ -1342,7 +1345,18 @@ def _bin_slice( center = self._indices[cursor][axis] if self._binned[cursor][axis]: window = self._bins[cursor][axis] - return slice(center - window // 2, center + (window - 1) // 2 + 1) + start = center - window // 2 + stop = center + (window - 1) // 2 + 1 + start = max(0, start) + stop = min(self._obj.shape[axis], stop) + size = stop - start + if size < window: + missing = window - size + if start == 0: + stop = min(self._obj.shape[axis], stop + missing) + elif stop == self._obj.shape[axis]: + start = max(0, start - missing) + return slice(start, stop) if int_if_one: return center return slice(center, center + 1) @@ -1353,8 +1367,7 @@ def _bin_along_axis( center = self._indices[cursor][axis] if not self._binned[cursor][axis]: return self._obj.data[(slice(None),) * axis + (center,)] - window = self._bins[cursor][axis] - selection = slice(center - window // 2, center + (window - 1) // 2 + 1) + selection = self._bin_slice(cursor, axis) return erlab.interactive.imagetool.fastbinning.fast_nanmean_skipcheck( self._obj.data[(slice(None),) * axis + (selection,)], axis=axis, @@ -1373,7 +1386,6 @@ def _bin_along_multiaxis( """ # Internal callers pass sorted, unique axes. reduced_axes: Sequence[int] = axis - bins = self._bins[cursor] binned = self._binned[cursor] indices = self._indices[cursor] selection: list[slice | int] = [slice(None)] * self._obj.ndim @@ -1386,11 +1398,7 @@ def _bin_along_multiaxis( # Keep binned axes in the view and track their positions after any # earlier unbinned axes have been removed by integer indexing. any_binned = True - center = indices[ax] - window = bins[ax] - selection[ax] = slice( - center - window // 2, center + (window - 1) // 2 + 1 - ) + selection[ax] = self._bin_slice(cursor, ax) selected_axis.append(ax - dropped) else: all_binned = False diff --git a/tests/interactive/imagetool/test_slicer.py b/tests/interactive/imagetool/test_slicer.py index ffbe7303..1675a9c9 100644 --- a/tests/interactive/imagetool/test_slicer.py +++ b/tests/interactive/imagetool/test_slicer.py @@ -474,6 +474,19 @@ def test_bin_along_axis_unbinned_matches_integer_index_selection(qtbot) -> None: np.testing.assert_allclose(slicer._bin_along_axis(0, 1), values[:, 3, :]) +def test_bin_slice_descending_axis_boundary_window_two(qtbot) -> None: + data = xr.DataArray( + np.zeros((4, 3), dtype=np.float32), + dims=("x", "y"), + coords={"x": np.array([4.0, 3.0, 2.0, 1.0]), "y": np.arange(3)}, + ) + slicer = ArraySlicer(data, parent=QtCore.QObject()) + slicer.set_indices(0, [0, 1], update=False) + slicer.set_bin(0, 0, 2, update=False) + + assert slicer._bin_slice(0, 0) == slice(0, 2) + + def test_point_value_unbinned_returns_current_scalar(qtbot) -> None: values = np.arange(4 * 5, dtype=np.float32).reshape(4, 5) data = xr.DataArray( @@ -588,6 +601,44 @@ def test_qsel_args_from_indexers_validates_live_bin_coordinates() -> None: qsel_args_from_indexers(data, {"x": slice(2, 5)}, ("x",)) +def test_qsel_args_desc_uniform_descending_axis_emits_positive_width() -> None: + data = xr.DataArray( + np.arange(5, dtype=np.float32), + dims=("x",), + coords={"x": np.array([5.0, 4.0, 3.0, 2.0, 1.0])}, + ) + + args = qsel_args_from_indexers(data, {"x": slice(0, 2)}, ("x",)) + + assert args["x"] == 4.5 + assert args["x_width"] > 0.0 + + actual = data.qsel(args) + expected = data.sortby("x").sel( + x=slice( + args["x"] - args["x_width"] / 2, + args["x"] + args["x_width"] / 2, + ) + ) + np.testing.assert_allclose(actual.values, expected.mean().values) + + +def test_xslice_descending_hidden_axis_with_boundary_bin_succeeds() -> None: + values = np.arange(10, dtype=np.float32).reshape(2, 5) + data = xr.DataArray( + values, + dims=("x", "y"), + coords={"x": np.arange(2), "y": np.array([5.0, 4.0, 3.0, 2.0, 1.0])}, + ) + slicer = ArraySlicer(data, parent=QtCore.QObject()) + slicer.set_indices(0, [0, 0], update=False) + slicer.set_bin(0, 1, 2, update=False) + + selected = slicer.xslice(0, (0,)) + + np.testing.assert_allclose(selected.values, np.array([0.5, 5.5], dtype=np.float32)) + + def test_isel_code_uses_call_kwargs_formatting(qtbot) -> None: data = xr.DataArray( np.zeros((4, 5, 6), dtype=np.float32),