diff --git a/docs/_static/plotting/slicer-plot.png b/docs/_static/plotting/slicer-plot.png index 272be162..a11d2ff8 100644 Binary files a/docs/_static/plotting/slicer-plot.png and b/docs/_static/plotting/slicer-plot.png differ diff --git a/docs/getting-started/numpy-pandas-xarray.ipynb b/docs/getting-started/numpy-pandas-xarray.ipynb index fc65f8af..5ceeac64 100644 --- a/docs/getting-started/numpy-pandas-xarray.ipynb +++ b/docs/getting-started/numpy-pandas-xarray.ipynb @@ -166,6 +166,8 @@ "# The latitude and longitude coordinates have units unrecognised by Scipp\n", "air.coords['lat'].attrs['units'] = 'degrees'\n", "air.coords['lon'].attrs['units'] = 'degrees'\n", + "# Optional: change the datetimes from ns to s for better formatting\n", + "air.coords['time'] = air.coords['time'].astype('datetime64[s]')\n", "air" ] }, @@ -280,7 +282,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.12" + "version": "3.12.7" } }, "nbformat": 4, diff --git a/docs/plotting/slicer-plot.ipynb b/docs/plotting/slicer-plot.ipynb index ce608737..3bd8c207 100644 --- a/docs/plotting/slicer-plot.ipynb +++ b/docs/plotting/slicer-plot.ipynb @@ -21,9 +21,11 @@ "outputs": [], "source": [ "%matplotlib widget\n", + "import scipp as sc\n", "import plopp as pp\n", + "from plopp.data.examples import clusters3d\n", "\n", - "da = pp.data.data3d()" + "da = clusters3d(nclusters=50, seed=12).hist(z=100, y=100, x=100)" ] }, { @@ -41,7 +43,7 @@ "metadata": {}, "outputs": [], "source": [ - "pp.slicer(da, keep=['x', 'y'])" + "pp.slicer(da, keep=['x', 'y'], logc=True)" ] }, { @@ -82,7 +84,7 @@ "metadata": {}, "outputs": [], "source": [ - "pp.slicer(da * da.coords['z'], keep=['x'], autoscale=False)" + "pp.slicer(da * sc.arange('z', 100), keep=['x'], autoscale=False)" ] }, { @@ -102,8 +104,9 @@ "\n", "\n", "\n", - "It is possible to display some animation controls (play button) next to the slider,\n", - "by using the `enable_player=True` option:" + "It is possible to display some animation controls (play button) next to the slider, by using the `enable_player=True` option.\n", + "\n", + "Note that we need to use a slider with a single handle via `mode='single'` for this to work:" ] }, { @@ -113,7 +116,7 @@ "metadata": {}, "outputs": [], "source": [ - "pp.slicer(da, keep=['x', 'y'], enable_player=True)" + "pp.slicer(da, keep=['x', 'y'], logc=True, enable_player=True, mode='single')" ] }, { @@ -146,7 +149,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.7" + "version": "3.12.12" } }, "nbformat": 4, diff --git a/src/plopp/plotting/slicer.py b/src/plopp/plotting/slicer.py index 6d1e8187..4abeca97 100644 --- a/src/plopp/plotting/slicer.py +++ b/src/plopp/plotting/slicer.py @@ -5,9 +5,10 @@ from itertools import groupby from typing import Literal +import numpy as np import scipp as sc -from ..core import widget_node +from ..core import Node, widget_node from ..core.typing import FigureLike, PlottableMulti from ..graphics import imagefigure, linefigure from .common import ( @@ -19,18 +20,42 @@ ) +def _maybe_reduce_dim(da, dims, op): + to_be_reduced = set(dims) & set(da.dims) + + # Small optimization: squeezing is much faster than reducing + to_be_squeezed = {dim for dim in to_be_reduced if da.sizes[dim] == 1} + if to_be_squeezed: + da = da.squeeze() + to_be_reduced -= to_be_squeezed + + if not to_be_reduced: + return da + + if 'mean' not in op: + return getattr(da, op)(to_be_reduced) + + # If the operation is a mean, there is currently a bug in the implementation + # in scipp where doing a mean over a subset of the array's dimensions gives the + # wrong result: https://github.com/scipp/scipp/issues/3841 + # Instead, we manually compute the mean + if 'nan' in op: + numerator = da.nansum(to_be_reduced) + denominator = (~sc.isnan(da)).to(dtype=int).sum(to_be_reduced) + else: + numerator = da.sum(to_be_reduced) + denominator = np.prod([da.sizes[dim] for dim in to_be_reduced]) + return numerator / denominator + + class Slicer: """ Class that slices out dimensions from the data and displays the resulting data as either a 1D line or a 2D image. - Note: - - This class primarily exists to facilitate unit testing. When running unit tests, we - are not in a Jupyter notebook, and the generated figures are not widgets that can - be placed in the `Box` widget container at the end of the `slicer` function. - We therefore place most of the code for creating a Slicer in this class, which is - under unit test coverage. The thin `slicer` wrapper is not covered by unit tests. + This class exists both for simplifying unit tests and for reuse by other plotting + functions that want to offer slicing functionality, + such as the :func:`superplot` function. Parameters ---------- @@ -46,6 +71,11 @@ class Slicer: be a list of dims. If no dims are provided, the last dim will be kept in the case of a 2-dimensional input, while the last two dims will be kept in the case of higher dimensional inputs. + mode: + The mode of the slicer. This can be 'single', 'range', or 'combined'. + operation: + The reduction operation to be applied to the sliced dimensions. This is ``sum`` + by default. **kwargs: The additional arguments are forwarded to the underlying 1D or 2D figures. """ @@ -57,8 +87,17 @@ def __init__( coords: list[str] | None = None, enable_player: bool = False, keep: list[str] | None = None, + mode: Literal['single', 'range', 'combined'] = 'combined', + operation: Literal[ + 'sum', 'mean', 'max', 'min', 'nansum', 'nanmean', 'nanmax', 'nanmin' + ] = 'sum', **kwargs, ): + if enable_player and mode != 'single': + raise ValueError( + 'The play button cannot be used with range sliders. Please set ' + 'mode to "single" to use the play button.' + ) nodes = input_to_nodes( obj, processor=partial(preprocess, ignore_size=True, coords=coords), @@ -93,15 +132,39 @@ def __init__( f"were not found in the input's dimensions {dims}." ) - from ..widgets import SliceWidget, slice_dims + from ..widgets import ( + CombinedSliceWidget, + RangeSliceWidget, + SliceWidget, + slice_dims, + ) + + other_dims = [dim for dim in dims if dim not in keep] + + match mode: + case 'single': + slicer_constr = SliceWidget + case 'range': + slicer_constr = RangeSliceWidget + case 'combined': + slicer_constr = CombinedSliceWidget + case _: + raise ValueError( + f"Invalid mode: {mode}. Expected one of 'single', " + f"'range', or 'combined'." + ) - self.slider = SliceWidget( + self.slider = slicer_constr( nodes[0](), - dims=[dim for dim in dims if dim not in keep], + dims=other_dims, enable_player=enable_player, ) self.slider_node = widget_node(self.slider) self.slice_nodes = [slice_dims(node, self.slider_node) for node in nodes] + self.reduce_nodes = [ + Node(_maybe_reduce_dim, da=node, dims=other_dims, op=operation) + for node in self.slice_nodes + ] args = categorize_args(**kwargs) @@ -118,7 +181,7 @@ def __init__( f'but {ndims} were requested.' ) - self.figure = make_figure(*self.slice_nodes) + self.figure = make_figure(*self.reduce_nodes) require_interactive_figure(self.figure, 'slicer') self.figure.bottom_bar.add(self.slider) @@ -147,7 +210,11 @@ def slicer( mask_color: str | None = None, nan_color: str | None = None, norm: Literal['linear', 'log'] | None = None, + operation: Literal[ + 'sum', 'mean', 'max', 'min', 'nansum', 'nanmean', 'nanmax', 'nanmin' + ] = 'sum', scale: dict[str, str] | None = None, + mode: Literal['single', 'range', 'combined'] = 'combined', title: str | None = None, vmax: sc.Variable | float | None = None, vmin: sc.Variable | float | None = None, @@ -209,11 +276,20 @@ def slicer( Colormap to use for masks in 2d plots. mask_color: Color of masks. + mode: + The type of slider to use for slicing. Can be either ``'single'`` for sliders + that select a single index along the sliced dimension, ``'range'`` for sliders + that select a range of indices along the sliced dimension, or ``'combined'`` for + sliders that allow both single index selection and range selection. + Defaults to ``'combined'``. nan_color: Color to use for NaN values in 2d plots. norm: Set to ``'log'`` for a logarithmic y-axis (1d plots) or logarithmic colorscale (2d plots). Legacy, prefer ``logy`` and ``logc`` instead. + operation: + The reduction operation to be applied to the sliced dimensions. This is ``sum`` + by default. scale: Change axis scaling between ``log`` and ``linear``. For example, specify ``scale={'time': 'log'}`` if you want log-scale for the ``time`` dimension. @@ -261,8 +337,11 @@ def slicer( logx=logx, logy=logy, mask_color=mask_color, + mask_cmap=mask_cmap, + mode=mode, nan_color=nan_color, norm=norm, + operation=operation, scale=scale, title=title, vmax=vmax, diff --git a/src/plopp/plotting/superplot.py b/src/plopp/plotting/superplot.py index c268fc30..d4b66155 100644 --- a/src/plopp/plotting/superplot.py +++ b/src/plopp/plotting/superplot.py @@ -113,6 +113,7 @@ def superplot( slicer = Slicer( obj, keep=keep, + mode='single', aspect=aspect, autoscale=autoscale, coords=coords, diff --git a/src/plopp/widgets/__init__.pyi b/src/plopp/widgets/__init__.pyi index ae1a2ef6..244d007a 100644 --- a/src/plopp/widgets/__init__.pyi +++ b/src/plopp/widgets/__init__.pyi @@ -6,7 +6,7 @@ from .checkboxes import Checkboxes from .clip3d import Clip3dTool, ClippingManager from .drawing import DrawingTool, PointsTool, PolygonTool, RectangleTool from .linesave import LineSaveTool -from .slice import RangeSliceWidget, SliceWidget, slice_dims +from .slicing import CombinedSliceWidget, RangeSliceWidget, SliceWidget, slice_dims from .toolbar import Toolbar, make_toolbar_canvas2d, make_toolbar_canvas3d from .tools import ButtonTool, ColorTool, ToggleTool @@ -17,6 +17,7 @@ __all__ = [ "Clip3dTool", "ClippingManager", "ColorTool", + "CombinedSliceWidget", "DrawingTool", "HBar", "LineSaveTool", diff --git a/src/plopp/widgets/slice.py b/src/plopp/widgets/slice.py deleted file mode 100644 index 39ef8ce8..00000000 --- a/src/plopp/widgets/slice.py +++ /dev/null @@ -1,193 +0,0 @@ -# SPDX-License-Identifier: BSD-3-Clause -# Copyright (c) 2023 Scipp contributors (https://github.com/scipp) - -from functools import partial -from typing import Any - -import ipywidgets as ipw -import scipp as sc - -from ..core import node -from ..core.utils import coord_element_to_string -from .box import VBar - - -class DimSlicer(ipw.VBox): - def __init__( - self, - dim: str, - size: int, - coord: sc.Variable, - slider_constr: type[ipw.Widget], - enable_player: bool = False, - ): - if enable_player and not issubclass(slider_constr, ipw.IntSlider): - raise TypeError( - "Player can only be enabled for IntSlider, not for IntRangeSlider." - ) - widget_args = { - 'step': 1, - 'min': 0, - 'max': size - 1, - 'value': (size - 1) // 2 if slider_constr is ipw.IntSlider else None, - 'continuous_update': True, - 'readout': False, - 'layout': {"width": "15.2em", "margin": "0px 0px 0px 10px"}, - } - self._is_bin_edges = coord.sizes[dim] > size - self.dim_label = ipw.Label(value=dim) - self.slider = slider_constr(**widget_args) - self.continuous_update = ipw.Checkbox( - value=True, - tooltip="Continuous update", - indent=False, - layout={"width": "1.52em"}, - ) - self.label = ipw.Label() - ipw.jslink( - (self.continuous_update, 'value'), (self.slider, 'continuous_update') - ) - - children = [self.dim_label, self.slider, self.continuous_update, self.label] - if enable_player: - self.player = ipw.Play( - value=self.slider.value, - min=self.slider.min, - max=self.slider.max, - step=self.slider.step, - interval=100, - description='Play', - ) - ipw.jslink((self.player, 'value'), (self.slider, 'value')) - children.insert(0, self.player) - - self.dim = dim - self.coord = coord - self._update_label({"new": self.slider.value}) - self.slider.observe(self._update_label, names='value') - - super().__init__([ipw.HBox(children)]) - - def _update_label(self, change: dict[str, Any]): - """ - Update the readout label with the coordinate value, instead of the integer - readout index. - """ - inds = change["new"] - if self._is_bin_edges: - if isinstance(inds, tuple): - inds = (inds[0], inds[1] + 1) - else: - inds = (inds, inds + 1) - self.label.value = coord_element_to_string(self.coord[self.dim, inds]) - - @property - def value(self) -> int | tuple[int, int]: - """ - The value of the slider. - """ - return self.slider.value - - @value.setter - def value(self, value: int | tuple[int, int]): - self.slider.value = value - - -class _BaseSliceWidget(VBar, ipw.ValueWidget): - def __init__( - self, - da: sc.DataArray, - dims: list[str], - slider_constr: ipw.Widget, - enable_player: bool = False, - ): - if isinstance(dims, str): - dims = [dims] - self.controls = {} - self.view = None - children = [] - - for dim in dims: - coord = ( - da.coords[dim] - if dim in da.coords - else sc.arange(dim, da.sizes[dim], unit=None) - ) - self.controls[dim] = DimSlicer( - dim=dim, - size=da.sizes[dim], - coord=coord, - slider_constr=slider_constr, - enable_player=enable_player, - ) - self.controls[dim].slider.observe(self._on_subwidget_change, names='value') - children.append(self.controls[dim]) - - self._on_subwidget_change() - super().__init__(children) - - def _on_subwidget_change(self, _=None): - """ - Update the value of the widget. - The value is a dict containing one entry per slider, giving the slider's value. - """ - self.value = {dim: slicer.slider.value for dim, slicer in self.controls.items()} - - -SliceWidget = partial(_BaseSliceWidget, slider_constr=ipw.IntSlider) -""" -Widgets containing a slider for each of the requested dimensions. -The widget uses the input data array to determine the range each slider should have. -Each slider also comes with a checkbox to toggle on and off the slider's continuous -update. - -Parameters ----------- -da: - The input data array. -dims: - The dimensions to make sliders for. -enable_player: - Add a play button to animate the slider if True. Defaults to False. - - .. versionadded:: 25.07.0 -""" - -RangeSliceWidget = partial(_BaseSliceWidget, slider_constr=ipw.IntRangeSlider) -""" -Widgets containing a slider for each of the requested dimensions. -The widget uses the input data array to determine the range each slider should have. -Each slider also comes with a checkbox to toggle on and off the slider's continuous -update. - -.. versionadded:: 24.04.0 - -Parameters ----------- -da: - The input data array. -dims: - The dimensions to make sliders for. -""" - - -@node -def slice_dims(data_array: sc.DataArray, slices: dict[str, slice]) -> sc.DataArray: - """ - Slice the data according to input slices. - - Parameters - ---------- - data_array: - The input data array to be sliced. - slices: - Dict of slices to apply for each dimension. - """ - out = data_array - for dim, sl in slices.items(): - if isinstance(sl, tuple): - # Include the stop index in the slice, as we expect both slider handles to - # be inclusive. - sl = slice(sl[0], sl[1] + 1) - out = out[dim, sl] - return out diff --git a/src/plopp/widgets/slicing.py b/src/plopp/widgets/slicing.py new file mode 100644 index 00000000..6af78284 --- /dev/null +++ b/src/plopp/widgets/slicing.py @@ -0,0 +1,584 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2023 Scipp contributors (https://github.com/scipp) + +from functools import partial + +import ipywidgets as ipw +import numpy as np +import scipp as sc +from traitlets import Any + +from ..core import node +from .box import VBar + + +def _find_closest_index(coord, value: str) -> int: + try: + v = float(value) + except ValueError: + v = sc.datetime(value).to(unit="ns").value.astype(int) + return np.argmin(np.abs(coord - v)) + + +class BoundedText(ipw.HBox, ipw.ValueWidget): + value = Any().tag(sync=True) + + def __init__( + self, + coord: sc.Variable, + index: int, + continuous_update: bool = False, + layout=None, + **kwargs, + ): + self._lock = False + self._coord = coord.values + if self._coord.dtype not in (sc.DType.datetime64, sc.DType.string): + self._underlying = self._coord + self._fmt = ".3E" + if layout is None: + layout = {"width": "11.5ch"} + else: + if self._coord.dtype == sc.DType.datetime64: + self._underlying = coord.to(unit="ns").values.astype(int) + else: + self._underlying = self._coord + self._fmt = "" + if layout is None: + layout = {"width": f"{len(str(self._coord[-1])) * 1.02}ch"} + + self._widget = ipw.Text( + continuous_update=continuous_update, value="", layout=layout, **kwargs + ) + self.min = 0 + self.max = len(self._coord) + # observe user edits + self._widget.observe(self._on_child_change, names="value") + # observe external value changes + self.observe(self._on_value_change, names="value") + + super().__init__([self._widget]) + self.value = index + + def _on_child_change(self, change): + if self._lock: + return + + new = _find_closest_index(coord=self._underlying, value=change["new"]) + new = min(max(new, self.min), self.max) + self._lock = True + self._widget.value = f"{self._coord[new]:{self._fmt}}" + self.value = new + self._lock = False + + def _on_value_change(self, change): + if self._lock: + return + + new = min(max(change["new"], self.min), self.max) + self._lock = True + self._widget.value = f"{self._coord[new]:{self._fmt}}" + self._lock = False + + +class BoundedBinEdgeText(ipw.HBox, ipw.ValueWidget): + value = Any().tag(sync=True) + + def __init__( + self, + coord: sc.Variable, + index: int, + continuous_update: bool = False, + layout=None, + **kwargs, + ): + self._lock = False + self._coord = coord.values + if self._coord.dtype not in (sc.DType.datetime64, sc.DType.string): + self._underlying = self._coord + self._fmt = ".3E" + if layout is None: + layout = {"width": "22.5ch"} + else: + if self._coord.dtype == sc.DType.datetime64: + self._underlying = coord.to(unit="ns").values.astype(int) + else: + self._underlying = self._coord + self._fmt = "" + if layout is None: + layout = {"width": f"{0.92 * (len(str(self._coord[-1])) * 2 + 3)}ch"} + + self._widget = ipw.Text( + continuous_update=continuous_update, value="", layout=layout, **kwargs + ) + self.min = 0 + self.max = len(self._coord) - 1 + # observe user edits + self._widget.observe(self._on_child_change, names="value") + # observe external value changes + self.observe(self._on_value_change, names="value") + + super().__init__([self._widget]) + self.value = index, index + 1 + + def _on_child_change(self, change): + if self._lock: + return + + new = [ + _find_closest_index(coord=self._underlying, value=x) + for x in change["new"].split(" : ") + ] + + if (" : " in change["new"]) and (" : " in change["old"]): + old2 = change["old"].split(" : ")[1] + new2 = change["new"].split(" : ")[1] + if old2 == new2: + new = new[0], new[0] + 1 + else: + new = new[1], new[1] + 1 + + new = [min(max(x, self.min), self.max) for x in new] + if len(new) == 1: + new = [new[0], new[0] + 1] + self._lock = True + self._widget.value = " : ".join(f"{self._coord[x]:{self._fmt}}" for x in new) + self.value = new + self._lock = False + + def _on_value_change(self, change): + if self._lock: + return + + new = [min(max(x, self.min), self.max) for x in change["new"]] + self._lock = True + self._widget.value = " : ".join(f"{self._coord[x]:{self._fmt}}" for x in new) + self._lock = False + + +class BoundsSingleWidget(ipw.HBox): + def __init__( + self, + coord: sc.Variable, + index: int, + ): + self._widget = BoundedText( + continuous_update=False, + coord=coord, + index=index, + ) + + super().__init__([self._widget]) + + @property + def value(self) -> float: + return (self._widget.value,) + + @value.setter + def value(self, value: tuple[float]): + self._widget.value = value[0] + + @property + def string_value(self) -> str: + return str(self._widget._widget.value) + + def set_observe_callback(self, callback: callable, **kwargs): + self._widget.observe(callback, **kwargs) + + +class BoundsSingleBinEdgesWidget(ipw.HBox): + def __init__(self, coord: sc.Variable, index: int): + self._widget = BoundedBinEdgeText( + continuous_update=False, + coord=coord, + index=index, + ) + super().__init__([self._widget]) + + @property + def value(self) -> float: + return self._widget.value + + @value.setter + def value(self, value: tuple[float]): + self._widget.value = value + + @property + def string_value(self) -> str: + return str(self._widget._widget.value) + + def set_observe_callback(self, callback: callable, **kwargs): + self._widget.observe(callback, **kwargs) + + +class BoundsRangeWidget(ipw.HBox): + def __init__( + self, + coord: sc.Variable, + index: tuple[int, int], + ): + self._min_widget = BoundedText( + continuous_update=False, + coord=coord, + index=index[0], + ) + + self._max_widget = BoundedText( + continuous_update=False, + coord=coord, + index=index[1], + ) + self._min_widget.observe(self._on_min_change, names='value') + self._max_widget.observe(self._on_max_change, names='value') + + super().__init__([self._min_widget, ipw.Label(value=":"), self._max_widget]) + + def set_observe_callback(self, callback: callable, **kwargs): + self._min_widget.observe(callback, **kwargs) + self._max_widget.observe(callback, **kwargs) + + def _on_min_change(self, change: dict): + self._max_widget.min = change["new"] + + def _on_max_change(self, change: dict): + self._min_widget.max = change["new"] + + @property + def value(self) -> tuple[float, float]: + return self._min_widget.value, self._max_widget.value + + @value.setter + def value(self, value: tuple[float, float]): + if value[0] > float(self._max_widget.value): + self._max_widget.value = value[1] + self._min_widget.value = value[0] + else: + self._min_widget.value = value[0] + self._max_widget.value = value[1] + + @property + def string_value(self) -> str: + return f"{self._min_widget._widget.value} : {self._max_widget._widget.value}" + + +class DimSlicer(ipw.HBox): + def __init__( + self, + dim: str, + size: int, + coord: sc.Variable, + slider_constr: type[ipw.Widget], + value: int | tuple[int, int] | None = None, + enable_player: bool = False, + width: str = "25em", + ): + self._kind = "single" if issubclass(slider_constr, ipw.IntSlider) else "range" + if enable_player and (self._kind != "single"): + raise TypeError( + "Player can only be enabled for IntSlider, not for IntRangeSlider." + ) + + self.dim = dim + self.coord = coord + self._is_bin_edges = self.coord.sizes[dim] > size + self.coord_min = self.coord.values[0] + self.coord_max = self.coord.values[-1] + + if value is None: + value = (size - 1) // 2 if self._kind == "single" else (0, size - 1) + + self.dim_label = ipw.Label(value=dim, layout={"margin": "0px 0px 0px 10px"}) + self.slider = slider_constr( + step=1, + min=0, + max=size - 1, + value=value, + continuous_update=True, + readout=False, + layout={"width": width, "margin": "0px 10px 0px 10px"}, + ) + self.continuous_update = ipw.Checkbox( + value=True, + tooltip="Continuous update", + indent=False, + layout={"width": "1.52em"}, + ) + + if self._kind == "range": + self.bounds = BoundsRangeWidget(coord=self.coord, index=value) + elif self._is_bin_edges: + self.bounds = BoundsSingleBinEdgesWidget(coord=self.coord, index=value) + else: + self.bounds = BoundsSingleWidget(coord=self.coord, index=value) + + self.unit = ipw.Label("" if self.coord.unit is None else f"[{self.coord.unit}]") + ipw.jslink( + (self.continuous_update, 'value'), (self.slider, 'continuous_update') + ) + + children = [self.dim_label, self.slider, self.continuous_update, self.bounds] + children.append(self.unit) + if enable_player: + self.player = ipw.Play( + value=self.slider.value, + min=self.slider.min, + max=self.slider.max, + step=self.slider.step, + interval=100, + description='Play', + ) + ipw.jslink((self.player, 'value'), (self.slider, 'value')) + children.insert(0, self.player) + + self._bounds_lock = False + self._update_label({"new": self.slider.value}) + self.slider.observe(self._update_label, names='value') + self.bounds.set_observe_callback(self._move_slider_to_label, names='value') + + super().__init__(children) + + def _update_label(self, change: dict): + """ + Update the readout label with the coordinate value, instead of the integer + readout index. + """ + inds = change["new"] + if self._is_bin_edges: + if isinstance(inds, tuple): + inds = (inds[0], inds[1] + 1) + else: + inds = (inds, inds + 1) + self._bounds_lock = True + self.bounds.value = np.atleast_1d(inds).tolist() + self._bounds_lock = False + + def _move_slider_to_label(self, change: dict): + """ + Move the slider to the position corresponding to the coordinate value in the + label, if possible. + """ + if self._bounds_lock: + return + inds = self.bounds.value + if len(inds) == 1: + self.slider.value = inds[0] + else: + if self._kind == "range": + self.slider.value = inds + else: + self.slider.value = inds[0] + + @property + def value(self) -> int | tuple[int, int]: + """ + The value of the slider. + """ + return self.slider.value + + @value.setter + def value(self, value: int | tuple[int, int]): + self.slider.value = value + + +class CombinedSlicer(ipw.HBox): + def __init__( + self, + dim: str, + size: int, + coord: sc.Variable, + width: str = "25em", + **ignored, + ): + self.int_slicer = DimSlicer( + dim=dim, + size=size, + coord=coord, + slider_constr=ipw.IntSlider, + value=0, + width=width, + ) + + self.range_slicer = DimSlicer( + dim=dim, size=size, coord=coord, slider_constr=ipw.IntRangeSlider + ) + + self.int_slicer.slider.observe(self.move_range, names='value') + + self.slider_toggler = ipw.ToggleButtons( + options=["o-o", "-o-"], + tooltips=['Range slider', 'Single handle slider'], + style={"button_width": "3.2em"}, + ) + self.slider_toggler.observe(self.toggle_slider_mode, names='value') + + super().__init__([self.slider_toggler, self.range_slicer]) + + def move_range(self, change): + self.range_slicer.slider.value = (change["new"], change["new"]) + + def toggle_slider_mode(self, change): + if change["new"] == "o-o": + self.children = [self.slider_toggler, self.range_slicer] + else: + self.int_slicer.slider.value = int( + 0.5 * sum(self.range_slicer.slider.value) + ) + self.children = [self.slider_toggler, self.int_slicer] + + @property + def slider(self) -> ipw.Widget: + return self.range_slicer.slider + + @property + def dim_label(self) -> ipw.Label: + return self.range_slicer.dim_label + + @property + def value(self) -> int | tuple[int, int]: + """ + The value of the slider. + """ + return self.slider.value + + @value.setter + def value(self, value: int | tuple[int, int]): + self.slider.value = value + + +class _BaseSliceWidget(VBar, ipw.ValueWidget): + def __init__( + self, + da: sc.DataArray, + dims: list[str], + slider_constr: ipw.Widget, + slicer_constr: type[DimSlicer] | type[CombinedSlicer], + enable_player: bool = False, + width: str = "25em", + ): + if isinstance(dims, str): + dims = [dims] + self.controls = {} + self.view = None + children = [] + + for dim in dims: + coord = ( + da.coords[dim] + if dim in da.coords + else sc.arange(dim, da.sizes[dim], unit=None) + ) + self.controls[dim] = slicer_constr( + dim=dim, + size=da.sizes[dim], + coord=coord, + slider_constr=slider_constr, + enable_player=enable_player, + width=width, + ) + self.controls[dim].slider.observe(self._on_subwidget_change, names='value') + children.append(self.controls[dim]) + + self._on_subwidget_change() + super().__init__(children) + + def _on_subwidget_change(self, _=None): + """ + Update the value of the widget. + The value is a dict containing one entry per slider, giving the slider's value. + """ + self.value = {dim: slicer.value for dim, slicer in self.controls.items()} + + +SliceWidget = partial( + _BaseSliceWidget, slider_constr=ipw.IntSlider, slicer_constr=DimSlicer +) +""" +Widgets containing a slider for each of the requested dimensions. +The widget uses the input data array to determine the range each slider should have. +Each slider also comes with a checkbox to toggle on and off the slider's continuous +update. + +Parameters +---------- +da: + The input data array. +dims: + The dimensions to make sliders for. +enable_player: + Add a play button to animate the slider if True. Defaults to False. +width: + The width of the sliders. Defaults to "25em". + + .. versionadded:: 25.07.0 +""" + +RangeSliceWidget = partial( + _BaseSliceWidget, + slider_constr=ipw.IntRangeSlider, + slicer_constr=DimSlicer, + enable_player=False, +) +""" +Widgets containing a range slider for each of the requested dimensions. +The widget uses the input data array to determine the range each slider should have. +Each slider also comes with a checkbox to toggle on and off the slider's continuous +update. + +.. versionadded:: 24.04.0 + +Parameters +---------- +da: + The input data array. +dims: + The dimensions to make sliders for. +width: + The width of the sliders. Defaults to "25em". +""" + +CombinedSliceWidget = partial( + _BaseSliceWidget, + slider_constr=None, + slicer_constr=CombinedSlicer, + enable_player=False, +) +""" +Widgets containing a combined slider (able to toggle between normal slider and range +slider) for each of the requested dimensions. +The widget uses the input data array to determine the range each slider should have. +Each slider also comes with a checkbox to toggle on and off the slider's continuous +update. + +.. versionadded:: 26.03.0 + +Parameters +---------- +da: + The input data array. +dims: + The dimensions to make sliders for. +width: + The width of the sliders. Defaults to "25em". +""" + + +@node +def slice_dims(data_array: sc.DataArray, slices: dict[str, slice]) -> sc.DataArray: + """ + Slice the data according to input slices. + + Parameters + ---------- + data_array: + The input data array to be sliced. + slices: + Dict of slices to apply for each dimension. + """ + out = data_array + for dim, sl in slices.items(): + if isinstance(sl, tuple): + # Include the stop index in the slice, as we expect both slider handles to + # be inclusive. + sl = slice(sl[0], sl[1] + 1) + out = out[dim, sl] + return out diff --git a/tests/plotting/slicer_test.py b/tests/plotting/slicer_test.py index 95e303a4..ef8c18ec 100644 --- a/tests/plotting/slicer_test.py +++ b/tests/plotting/slicer_test.py @@ -3,6 +3,7 @@ import pytest import scipp as sc +from scipp.testing import assert_allclose, assert_identical from plopp import Node from plopp.data.testing import data_array, dataset @@ -11,62 +12,132 @@ @pytest.mark.usefixtures("_parametrize_interactive_1d_backends") class TestSlicer1d: - def test_creation_keep_one_dim(self): + def test_creation_keep_one_dim_single_mode(self): da = data_array(ndim=3) - sl = Slicer(da, keep=['xx']) + sl = Slicer(da, keep=['xx'], mode='single') assert sl.slider.value == {'zz': 14, 'yy': 19} assert sl.slider.controls['yy'].slider.max == da.sizes['yy'] - 1 assert sl.slider.controls['zz'].slider.max == da.sizes['zz'] - 1 - assert sc.identical(sl.slice_nodes[0].request_data(), da['yy', 19]['zz', 14]) + assert_identical(sl.slice_nodes[0].request_data(), da['yy', 19]['zz', 14]) - def test_update_keep_one_dim(self): + def test_update_keep_one_dim_single_mode(self): da = data_array(ndim=3) - sl = Slicer(da, keep=['xx']) + sl = Slicer(da, keep=['xx'], mode='single') assert sl.slider.value == {'zz': 14, 'yy': 19} - assert sc.identical(sl.slice_nodes[0].request_data(), da['yy', 19]['zz', 14]) + assert_identical(sl.slice_nodes[0].request_data(), da['yy', 19]['zz', 14]) sl.slider.controls['yy'].value = 5 assert sl.slider.value == {'zz': 14, 'yy': 5} - assert sc.identical(sl.slice_nodes[0].request_data(), da['yy', 5]['zz', 14]) + assert_identical(sl.slice_nodes[0].request_data(), da['yy', 5]['zz', 14]) sl.slider.controls['zz'].value = 8 assert sl.slider.value == {'zz': 8, 'yy': 5} - assert sc.identical(sl.slice_nodes[0].request_data(), da['yy', 5]['zz', 8]) + assert_identical(sl.slice_nodes[0].request_data(), da['yy', 5]['zz', 8]) + + def test_creation_keep_one_dim_range_mode(self): + da = data_array(ndim=3) + sl = Slicer(da, keep=['xx'], mode='range') + assert sl.slider.value == {'zz': (0, 29), 'yy': (0, 39)} + assert sl.slider.controls['yy'].slider.max == da.sizes['yy'] - 1 + assert sl.slider.controls['zz'].slider.max == da.sizes['zz'] - 1 + assert_identical(sl.slice_nodes[0].request_data(), da['yy', 0:40]['zz', 0:30]) + assert_allclose( + sl.reduce_nodes[0].request_data(), + da['yy', 0:40]['zz', 0:30].sum(['yy', 'zz']), + ) + + def test_update_keep_one_dim_range_mode(self): + da = data_array(ndim=3) + sl = Slicer(da, keep=['xx'], mode='range') + assert sl.slider.value == {'zz': (0, 29), 'yy': (0, 39)} + assert_identical(sl.slice_nodes[0].request_data(), da['yy', 0:40]['zz', 0:30]) + sl.slider.controls['yy'].value = (5, 15) + assert sl.slider.value == {'zz': (0, 29), 'yy': (5, 15)} + assert_identical(sl.slice_nodes[0].request_data(), da['yy', 5:16]['zz', 0:30]) + assert_allclose( + sl.reduce_nodes[0].request_data(), + da['yy', 5:16]['zz', 0:30].sum(['yy', 'zz']), + ) + sl.slider.controls['zz'].value = (10, 20) + assert sl.slider.value == {'zz': (10, 20), 'yy': (5, 15)} + assert_identical(sl.slice_nodes[0].request_data(), da['yy', 5:16]['zz', 10:21]) + assert_allclose( + sl.reduce_nodes[0].request_data(), + da['yy', 5:16]['zz', 10:21].sum(['yy', 'zz']), + ) + + def test_creation_keep_one_dim_combined_mode(self): + da = data_array(ndim=3) + sl = Slicer(da, keep=['xx'], mode='combined') + assert sl.slider.value == {'zz': (0, 29), 'yy': (0, 39)} + assert sl.slider.controls['yy'].slider.max == da.sizes['yy'] - 1 + assert sl.slider.controls['zz'].slider.max == da.sizes['zz'] - 1 + assert_identical(sl.slice_nodes[0].request_data(), da['yy', 0:40]['zz', 0:30]) + assert_allclose( + sl.reduce_nodes[0].request_data(), + da['yy', 0:40]['zz', 0:30].sum(['yy', 'zz']), + ) + # now switch to single mode + sl.slider.controls['yy'].slider_toggler.value = "-o-" + sl.slider.controls['zz'].slider_toggler.value = "-o-" + assert sl.slider.value == {'zz': (14, 14), 'yy': (19, 19)} + assert_identical(sl.slice_nodes[0].request_data(), da['yy', 19:20]['zz', 14:15]) + + def test_update_keep_one_dim_combined_mode(self): + da = data_array(ndim=3) + sl = Slicer(da, keep=['xx'], mode='combined') + assert sl.slider.value == {'zz': (0, 29), 'yy': (0, 39)} + assert_identical(sl.slice_nodes[0].request_data(), da['yy', 0:40]['zz', 0:30]) + sl.slider.controls['yy'].value = (5, 15) + sl.slider.controls['zz'].value = (10, 20) + assert sl.slider.value == {'zz': (10, 20), 'yy': (5, 15)} + assert_identical(sl.slice_nodes[0].request_data(), da['yy', 5:16]['zz', 10:21]) + assert_allclose( + sl.reduce_nodes[0].request_data(), + da['yy', 5:16]['zz', 10:21].sum(['yy', 'zz']), + ) + # now switch to single mode + sl.slider.controls['yy'].slider_toggler.value = "-o-" + sl.slider.controls['zz'].slider_toggler.value = "-o-" + sl.slider.controls['yy'].value = (4, 4) + sl.slider.controls['zz'].value = (11, 11) + assert sl.slider.value == {'zz': (11, 11), 'yy': (4, 4)} + assert_identical(sl.slice_nodes[0].request_data(), da['yy', 4:5]['zz', 11:12]) def test_with_dataset(self): ds = dataset(ndim=2) - sl = Slicer(ds, keep=['xx']) + sl = Slicer(ds, keep=['xx'], mode='single') nodes = list(sl.figure.graph_nodes.values()) sl.slider.controls['yy'].value = 5 - assert sc.identical(nodes[0].request_data(), ds['a']['yy', 5]) - assert sc.identical(nodes[1].request_data(), ds['b']['yy', 5]) + assert_identical(nodes[0].request_data(), ds['a']['yy', 5]) + assert_identical(nodes[1].request_data(), ds['b']['yy', 5]) def test_with_data_group(self): da = data_array(ndim=2) dg = sc.DataGroup(a=da, b=da * 2.5) - sl = Slicer(dg, keep=['xx']) + sl = Slicer(dg, keep=['xx'], mode='single') nodes = list(sl.figure.graph_nodes.values()) sl.slider.controls['yy'].value = 5 - assert sc.identical(nodes[0].request_data(), dg['a']['yy', 5]) - assert sc.identical(nodes[1].request_data(), dg['b']['yy', 5]) + assert_identical(nodes[0].request_data(), dg['a']['yy', 5]) + assert_identical(nodes[1].request_data(), dg['b']['yy', 5]) def test_with_dict_of_data_arrays(self): a = data_array(ndim=2) b = data_array(ndim=2) * 2.5 - sl = Slicer({'a': a, 'b': b}, keep=['xx']) + sl = Slicer({'a': a, 'b': b}, keep=['xx'], mode='single') nodes = list(sl.figure.graph_nodes.values()) sl.slider.controls['yy'].value = 5 - assert sc.identical(nodes[0].request_data(), a['yy', 5]) - assert sc.identical(nodes[1].request_data(), b['yy', 5]) + assert_identical(nodes[0].request_data(), a['yy', 5]) + assert_identical(nodes[1].request_data(), b['yy', 5]) def test_with_data_arrays_same_shape_different_coord(self): a = data_array(ndim=2) b = data_array(ndim=2) * 2.5 b.coords['xx'] *= 1.5 - Slicer({'a': a, 'b': b}, keep=['xx']) + Slicer({'a': a, 'b': b}, keep=['xx'], mode='single') def test_with_data_arrays_different_shape_along_keep_dim(self): a = data_array(ndim=2) b = data_array(ndim=2) * 2.5 - Slicer({'a': a, 'b': b['xx', :10]}, keep=['xx']) + Slicer({'a': a, 'b': b['xx', :10]}, keep=['xx'], mode='single') def test_with_data_arrays_different_shape_along_non_keep_dim_raises(self): a = data_array(ndim=2) @@ -74,23 +145,23 @@ def test_with_data_arrays_different_shape_along_non_keep_dim_raises(self): with pytest.raises( ValueError, match='Slicer plot: all inputs must have the same sizes' ): - Slicer({'a': a, 'b': b['yy', :10]}, keep=['xx']) + Slicer({'a': a, 'b': b['yy', :10]}, keep=['xx'], mode='single') def test_raises_ValueError_when_given_binned_data(self): da = sc.data.table_xyz(100).bin(x=10, y=20) with pytest.raises(ValueError, match='Cannot plot binned data'): - Slicer(da, keep=['xx']) + Slicer(da, keep=['xx'], mode='single') def test_from_node_1d(self): da = data_array(ndim=2) - Slicer(Node(da)) + Slicer(Node(da), mode='single') def test_mixing_raw_data_and_nodes(self): a = data_array(ndim=2) b = 6.7 * a - Slicer({'a': Node(a), 'b': Node(b)}) - Slicer({'a': a, 'b': Node(b)}) - Slicer({'a': Node(a), 'b': b}) + Slicer({'a': Node(a), 'b': Node(b)}, mode='single') + Slicer({'a': a, 'b': Node(b)}, mode='single') + Slicer({'a': Node(a), 'b': b}, mode='single') def test_raises_when_requested_keep_dims_do_not_exist(self): da = data_array(ndim=3) @@ -98,7 +169,7 @@ def test_raises_when_requested_keep_dims_do_not_exist(self): ValueError, match='Slicer plot: one or more of the requested dims to be kept', ): - Slicer(da, keep=['time']) + Slicer(da, keep=['time'], mode='single') def test_raises_when_number_of_keep_dims_requested_is_bad(self): da = data_array(ndim=4) @@ -106,34 +177,114 @@ def test_raises_when_number_of_keep_dims_requested_is_bad(self): ValueError, match='Slicer plot: the number of dims to be kept must be 1 or 2', ): - Slicer(da, keep=['xx', 'yy', 'zz']) + Slicer(da, keep=['xx', 'yy', 'zz'], mode='single') with pytest.raises( ValueError, match='Slicer plot: the list of dims to be kept cannot be empty' ): - Slicer(da, keep=[]) + Slicer(da, keep=[], mode='single') @pytest.mark.usefixtures("_parametrize_interactive_2d_backends") class TestSlicer2d: - def test_creation_keep_two_dims(self): - da = data_array(ndim=3) - sl = Slicer(da, keep=['xx', 'yy']) + @pytest.mark.parametrize("binedges", [False, True]) + @pytest.mark.parametrize("datetime", [False, True]) + def test_creation_keep_two_dims_single_mode(self, binedges, datetime): + da = data_array(ndim=3, binedges=binedges) + if datetime: + da.coords['zz'] = sc.arange( + 'zz', + sc.datetime('2022-02-20T04:32:00'), + sc.datetime(f'2022-02-20T04:32:{da.sizes["zz"]}'), + ) + sl = Slicer(da, keep=['xx', 'yy'], mode='single') assert sl.slider.value == {'zz': 14} assert sl.slider.controls['zz'].slider.max == da.sizes['zz'] - 1 - assert sc.identical(sl.slice_nodes[0].request_data(), da['zz', 14]) + assert_identical(sl.slice_nodes[0].request_data(), da['zz', 14]) - def test_update_keep_two_dims(self): + def test_update_keep_two_dims_single_mode(self): da = data_array(ndim=3) - sl = Slicer(da, keep=['xx', 'yy']) + sl = Slicer(da, keep=['xx', 'yy'], mode='single') assert sl.slider.value == {'zz': 14} - assert sc.identical(sl.slice_nodes[0].request_data(), da['zz', 14]) + assert_identical(sl.slice_nodes[0].request_data(), da['zz', 14]) sl.slider.controls['zz'].value = 5 assert sl.slider.value == {'zz': 5} - assert sc.identical(sl.slice_nodes[0].request_data(), da['zz', 5]) + assert_identical(sl.slice_nodes[0].request_data(), da['zz', 5]) + + @pytest.mark.parametrize("binedges", [False, True]) + @pytest.mark.parametrize("datetime", [False, True]) + def test_creation_keep_two_dims_range_mode(self, binedges, datetime): + da = data_array(ndim=3, binedges=binedges) + if datetime: + da.coords['zz'] = sc.arange( + 'zz', + sc.datetime('2022-02-20T04:32:00'), + sc.datetime(f'2022-02-20T04:32:{da.sizes["zz"]}'), + ) + sl = Slicer(da, keep=['xx', 'yy'], mode='range') + assert sl.slider.value == {'zz': (0, 29)} + assert sl.slider.controls['zz'].slider.max == da.sizes['zz'] - 1 + assert_identical(sl.slice_nodes[0].request_data(), da['zz', 0:30]) + assert_allclose( + sl.reduce_nodes[0].request_data(), + da['zz', 0:30].sum('zz'), + ) + + def test_update_keep_two_dims_range_mode(self): + da = data_array(ndim=3) + sl = Slicer(da, keep=['xx', 'yy'], mode='range') + assert sl.slider.value == {'zz': (0, 29)} + assert_identical(sl.slice_nodes[0].request_data(), da['zz', 0:30]) + sl.slider.controls['zz'].value = (5, 15) + assert sl.slider.value == {'zz': (5, 15)} + assert_identical(sl.slice_nodes[0].request_data(), da['zz', 5:16]) + assert_allclose( + sl.reduce_nodes[0].request_data(), + da['zz', 5:16].sum('zz'), + ) + + @pytest.mark.parametrize("binedges", [False, True]) + @pytest.mark.parametrize("datetime", [False, True]) + def test_creation_keep_two_dims_combined_mode(self, binedges, datetime): + da = data_array(ndim=3, binedges=binedges) + if datetime: + da.coords['zz'] = sc.arange( + 'zz', + sc.datetime('2022-02-20T04:32:00'), + sc.datetime(f'2022-02-20T04:32:{da.sizes["zz"]}'), + ) + sl = Slicer(da, keep=['xx', 'yy'], mode='combined') + assert sl.slider.value == {'zz': (0, 29)} + assert sl.slider.controls['zz'].slider.max == da.sizes['zz'] - 1 + assert_identical(sl.slice_nodes[0].request_data(), da['zz', 0:30]) + assert_allclose( + sl.reduce_nodes[0].request_data(), + da['zz', 0:30].sum('zz'), + ) + # now switch to single mode + sl.slider.controls['zz'].slider_toggler.value = "-o-" + assert sl.slider.value == {'zz': (14, 14)} + assert_identical(sl.slice_nodes[0].request_data(), da['zz', 14:15]) + + def test_update_keep_two_dims_combined_mode(self): + da = data_array(ndim=3) + sl = Slicer(da, keep=['xx', 'yy'], mode='combined') + assert sl.slider.value == {'zz': (0, 29)} + assert_identical(sl.slice_nodes[0].request_data(), da['zz', 0:30]) + sl.slider.controls['zz'].value = (5, 15) + assert sl.slider.value == {'zz': (5, 15)} + assert_identical(sl.slice_nodes[0].request_data(), da['zz', 5:16]) + assert_allclose( + sl.reduce_nodes[0].request_data(), + da['zz', 5:16].sum('zz'), + ) + # now switch to single mode + sl.slider.controls['zz'].slider_toggler.value = "-o-" + assert sl.slider.value == {'zz': (10, 10)} + assert_identical(sl.slice_nodes[0].request_data(), da['zz', 10:11]) def test_from_node_2d(self): da = data_array(ndim=3) - Slicer(Node(da)) + Slicer(Node(da), mode='single') def test_update_triggers_autoscale(self): da = sc.DataArray( @@ -144,7 +295,7 @@ def test_update_triggers_autoscale(self): # `autoscale=True` should be the default, but there is no guarantee that it will # not change in the future, so we explicitly set it here to make the test # robust. - sl = Slicer(da, keep=['y', 'x'], autoscale=True) + sl = Slicer(da, keep=['y', 'x'], autoscale=True, mode='single') cm = sl.figure.view.colormapper # Colormapper fits to the values in the initial slice (slider value in the # middle) @@ -161,7 +312,7 @@ def test_no_autoscale(self): dim='x', sizes={'z': 20, 'y': 10, 'x': 5} ) ) - sl = Slicer(da, keep=['y', 'x'], autoscale=False) + sl = Slicer(da, keep=['y', 'x'], autoscale=False, mode='single') cm = sl.figure.view.colormapper # Colormapper fits to the values in the initial slice (slider value in the # middle) diff --git a/tests/widgets/slice_test.py b/tests/widgets/slice_test.py index 47208cfc..01a8944d 100644 --- a/tests/widgets/slice_test.py +++ b/tests/widgets/slice_test.py @@ -5,10 +5,10 @@ from scipp import identical from plopp.data.testing import data_array -from plopp.widgets import RangeSliceWidget, SliceWidget, slice_dims +from plopp.widgets import CombinedSliceWidget, RangeSliceWidget, SliceWidget, slice_dims -@pytest.mark.parametrize("widget", [SliceWidget, RangeSliceWidget]) +@pytest.mark.parametrize("widget", [SliceWidget, RangeSliceWidget, CombinedSliceWidget]) def test_slice_creation(widget): da = data_array(ndim=3) sw = widget(da, dims=['yy', 'xx']) @@ -29,19 +29,50 @@ def test_slice_value_property(): assert sw.value == {'xx': 10, 'yy': 15} -def test_slice_label_updates(): +def test_slice_label_updates_single_slice(): da = data_array(ndim=3) da.coords['xx'] *= 1.1 da.coords['yy'] *= 3.3 sw = SliceWidget(da, dims=['yy', 'xx']) sw.controls['xx'].value = 0 sw.controls['yy'].value = 0 - assert sw.controls['xx'].label.value == '0.0 [m]' + assert sw.controls['xx'].unit.value == '[m]' + assert float(sw.controls['xx'].bounds.string_value) == 0.0 sw.controls['xx'].value = 10 - assert sw.controls['xx'].label.value == '11.0 [m]' - assert sw.controls['yy'].label.value == '0.0 [m]' + assert float(sw.controls['xx'].bounds.string_value) == 11.0 + assert float(sw.controls['yy'].bounds.string_value) == 0.0 sw.controls['yy'].value = 15 - assert sw.controls['yy'].label.value == '49.5 [m]' + assert float(sw.controls['yy'].bounds.string_value) == 49.5 + + +def test_slice_label_updates_single_binedge_slice(): + da = data_array(ndim=3, binedges=True) + da.coords['zz'] *= 2.2 + sw = SliceWidget(da, dims=['zz']) + sw.controls['zz'].value = 0 + assert sw.controls['zz'].unit.value == '[m]' + bounds = sw.controls['zz'].bounds.string_value.split(":") + assert float(bounds[0]) == 0.0 + assert float(bounds[1]) == 2.2 + sw.controls['zz'].value = 10 + bounds = sw.controls['zz'].bounds.string_value.split(":") + assert float(bounds[0]) == 22.0 + assert float(bounds[1]) == 24.2 + + +def test_slice_label_updates_range_slice(): + da = data_array(ndim=3) + da.coords['zz'] *= 2.2 + sw = RangeSliceWidget(da, dims=['zz']) + sw.controls['zz'].value = (0, 1) + assert sw.controls['zz'].unit.value == '[m]' + bounds = sw.controls['zz'].bounds.string_value.split(":") + assert float(bounds[0]) == 0.0 + assert float(bounds[1]) == 2.2 + sw.controls['zz'].value = (10, 16) + bounds = sw.controls['zz'].bounds.string_value.split(":") + assert float(bounds[0]) == 22.0 + assert float(bounds[1]) == 16 * 2.2 def test_make_slice_widget_with_player():