diff --git a/packages/essdiffraction/src/ess/dream/diagnostics/voxel_viewer.py b/packages/essdiffraction/src/ess/dream/diagnostics/voxel_viewer.py index c07066235..4d2a8bfee 100644 --- a/packages/essdiffraction/src/ess/dream/diagnostics/voxel_viewer.py +++ b/packages/essdiffraction/src/ess/dream/diagnostics/voxel_viewer.py @@ -1,22 +1,17 @@ # SPDX-License-Identifier: BSD-3-Clause -# Copyright (c) 2024 Scipp contributors (https://github.com/scipp) +# Copyright (c) 2026 Scipp contributors (https://github.com/scipp) """Detector diagnostics for DREAM.""" -import math -from collections.abc import Callable, Iterable, Mapping -from functools import reduce +from collections import defaultdict +from collections.abc import Mapping from typing import Any -import ipywidgets as ipw -import numpy as np import scipp as sc -from plopp.core.typing import FigureLike -# This leads to y-axis: segment, x-axis: module -_STARTING_DIMS = ('segment', 'wire', 'strip', 'module', 'counter', 'sumo', 'sector') +from ess.reduce.diagnostics.flat_bank_viewer import FlatBankViewer -class FlatVoxelViewer(ipw.VBox): +class FlatVoxelViewer(FlatBankViewer): """Interactive 2D plot of all detector voxels. See `DREAM Detector Diagnostics @@ -26,8 +21,9 @@ class FlatVoxelViewer(ipw.VBox): def __init__( self, - data: Mapping[str, sc.DataArray], + data: Mapping[str, sc.DataArray] | sc.DataArray, *, + cbar: bool = True, rasterized: bool = True, **kwargs: Any, ) -> None: @@ -37,205 +33,41 @@ def __init__( ---------- data: Histogrammed data, one entry per bank. + cbar: + Whether to show a colorbar. rasterized: If ``True``, the figure is rasterized which improves rendering speed but reduces resolution. **kwargs: - Additional arguments passed to the plotting function. + Additional arguments passed to :func:`plopp.imagefigure`. """ - self._data = self._prepare_data(data) - self._bank_selector = _make_bank_selector(data.keys()) - self._bank = self._data[self._bank_selector.value] - - self._dim_selector = _DimensionSelector(self._bank.dims, self._update_view) - - self._fig_kwargs = {'rasterized': rasterized} | kwargs - self._figure_box = ipw.HBox([self._make_figure()]) - self._bank_selector.observe(self._select_bank, names='value') - - super().__init__( - [ - ipw.HBox([ipw.Label('Bank:'), self._bank_selector]), - self._figure_box, - self._dim_selector, - ] - ) - - def _select_bank(self, *_args: Any, **_kwargs: Any) -> None: - self._bank = self._data[self._bank_selector.value] - self._dim_selector.set_dims(self._bank.dims) - self._update_view() - - def _update_view(self, *_args: Any, **_kwargs: Any) -> None: - self._figure_box.children = [self._make_figure()] - - def _make_figure(self) -> FigureLike: - sel = self._dim_selector.value - fig = _flat_voxel_figure( - self._bank, sel['horizontal'], sel['vertical'], **self._fig_kwargs - ) - return fig - - @staticmethod - def _prepare_data(dg: sc.DataGroup) -> sc.DataGroup: - return sc.DataGroup( - { - name: bank.transpose( - [dim for dim in _STARTING_DIMS if dim in bank.dims] - ) - for name, bank in dg.items() - } - ) - - -class _DimensionSelector(ipw.VBox): - def __init__(self, dims: tuple[str, ...], callback: Callable[[dict], None]) -> None: - self._lock = False - self._callback = callback - - self._horizontal_buttons, self._vertical_buttons = self._make_buttons( - dims, *self._default_dims(dims) - ) - + vertical = ('counter', 'segment', 'wire') + horizontal = ('module', 'strip', 'sumo', 'sector') super().__init__( - [ - ipw.HBox([ipw.Label('X'), self._horizontal_buttons]), - ipw.HBox([ipw.Label('Y'), self._vertical_buttons]), - ] - ) - - def _make_buttons( - self, dims: tuple[str, ...], h_dim: str, v_dim: str - ) -> tuple[ipw.ToggleButtons, ipw.ToggleButtons]: - style = {'button_width': '10em'} - options = {dim.capitalize(): dim for dim in dims} - h_buttons = ipw.ToggleButtons(options=options, value=h_dim, style=style) - v_buttons = ipw.ToggleButtons(options=options, value=v_dim, style=style) - h_buttons.observe(self.update, names='value') - v_buttons.observe(self.update, names='value') - return h_buttons, v_buttons - - def set_dims(self, new_dims: tuple[str, ...]) -> None: - default_h, default_v = self._default_dims(new_dims) - options = {dim.capitalize(): dim for dim in new_dims} - self._lock = True - self._horizontal_buttons.options = options - self._vertical_buttons.options = options - self._horizontal_buttons.value = default_h - self._vertical_buttons.value = default_v - self._lock = False - - @staticmethod - def _default_dims(dims: tuple[str, ...]) -> tuple[str, str]: - return dims[math.ceil(len(dims) / 2)], dims[0] - - @property - def value(self): - return { - 'horizontal': self._horizontal_buttons.value, - 'vertical': self._vertical_buttons.value, - } - - def update(self, change: dict) -> None: - if self._lock: - return - clicked = change['owner'] - other = ( - self._vertical_buttons - if clicked is self._horizontal_buttons - else self._horizontal_buttons + _prepare_data(data), + dims=(*vertical, *horizontal), + sliced=(), + vertical=vertical, + horizontal=horizontal, + cbar=cbar, + rasterized=rasterized, + **kwargs, ) - if other.value == clicked.value: - self._lock = True # suppress update from `other` - other.value = change['old'] - self._lock = False - self._callback(change) - - -def _flat_voxel_figure( - data: sc.DataArray, - horizontal_dim: str, - vertical_dim: str, - **kwargs: Any, -) -> FigureLike: - kept_dims = {horizontal_dim, vertical_dim} - - to_flatten = [dim for dim in data.dims if dim not in kept_dims] - n = len(to_flatten) - flatten_to_h = [horizontal_dim, *to_flatten[n // 2 :]] - flatten_to_v = [vertical_dim, *to_flatten[: n // 2]] - - # Drop unused coordinates - aux = data.drop_coords(list(set(data.coords.keys()) - kept_dims)) - reordered = aux.transpose(flatten_to_v + flatten_to_h) - - h_coord = reordered.coords.pop(horizontal_dim) - v_coord = reordered.coords.pop(vertical_dim) - - flat = reordered.flatten(flatten_to_v, to='vertical').flatten( - flatten_to_h, to='horizontal' - ) - flat = flat.assign_coords( - {name: sc.arange(name, flat.sizes[name], unit=None) for name in flat.dims} - ) - - # This relies on the order of flatten_to_h/v - inner_volume_h = _product(data.sizes[d] for d in flatten_to_h[1:]) - inner_volume_v = _product(data.sizes[d] for d in flatten_to_v[1:]) - h_ticks = np.arange(0, flat.sizes['horizontal'], inner_volume_h) - v_ticks = np.arange(0, flat.sizes['vertical'], inner_volume_v) - - h_labels = [str(value) for value in h_coord.values] - v_labels = [str(value) for value in v_coord.values] - - fig = flat.plot(**kwargs) - - fig.ax.xaxis.set_ticks(ticks=h_ticks, labels=h_labels) - fig.ax.yaxis.set_ticks(ticks=v_ticks, labels=v_labels) - fig.canvas.xlabel = horizontal_dim.capitalize() - fig.canvas.ylabel = vertical_dim.capitalize() - - unwrap_indices = unwrap_flat_indices_2d( - {dim: reordered.sizes[dim] for dim in flatten_to_h}, - {dim: reordered.sizes[dim] for dim in flatten_to_v}, - ) - - def format_coord(x: float, y: float) -> str: - # Use round because axis coords are in the middle of bins. - indices = ( - f'{key.capitalize()}: {val}' - for key, val in unwrap_indices(round(x), round(y)).items() - ) - return f"{{{', '.join(indices)}}}" - - fig.ax.format_coord = format_coord - - return fig - - -def _product(it): - return reduce(lambda a, b: a * b, it) - - -def unwrap_flat_indices_2d( - x_sizes: dict[str, int], y_sizes: dict[str, int] -) -> Callable[[int, int], dict[str, int]]: - def unwrap(x: int, y: int) -> dict[str, int]: - return {**_unwrap_flat_index(x, x_sizes), **_unwrap_flat_index(y, y_sizes)} - - return unwrap - - -def _unwrap_flat_index(index: int, sizes: dict[str, int]) -> dict[str, int]: - res = [] - for key, size in reversed(sizes.items()): - res.append((key, index % size)) - index //= size - return dict(reversed(res)) # Reverse to reproduce the input order. -def _make_bank_selector(banks: Iterable[str]) -> ipw.ToggleButtons: - options = ( - (' '.join(s.capitalize() for s in bank.split('_')), bank) for bank in banks +def _prepare_data( + data: Mapping[str, sc.DataArray] | sc.DataArray, +) -> Mapping[str, sc.DataArray] | sc.DataArray: + if isinstance(data, sc.DataArray): + return data + + # Sort to get a consistent order + indices = defaultdict( + lambda: 10, + mantle=0, + endcap_forward=1, + endcap_backward=2, + high_resolution=3, + sans=4, ) - return ipw.ToggleButtons(options=options) + return dict(sorted(data.items(), key=lambda t: indices[t[0]])) diff --git a/packages/essdiffraction/tests/dream/diagnostics/voxel_viewer_test.py b/packages/essdiffraction/tests/dream/diagnostics/voxel_viewer_test.py deleted file mode 100644 index a982cf7ca..000000000 --- a/packages/essdiffraction/tests/dream/diagnostics/voxel_viewer_test.py +++ /dev/null @@ -1,57 +0,0 @@ -# SPDX-License-Identifier: BSD-3-Clause -# Copyright (c) 2023 Scipp contributors (https://github.com/scipp) - -from ess.dream.diagnostics.voxel_viewer import unwrap_flat_indices_2d - - -def test_unwrap_flat_indices_2d_single_dim() -> None: - unwrap = unwrap_flat_indices_2d({'a': 3}, {'b': 5}) - assert unwrap(0, 0) == {'a': 0, 'b': 0} - assert unwrap(1, 0) == {'a': 1, 'b': 0} - assert unwrap(2, 0) == {'a': 2, 'b': 0} - assert unwrap(0, 4) == {'a': 0, 'b': 4} - assert unwrap(2, 4) == {'a': 2, 'b': 4} - - -def test_unwrap_flat_indices_2d_two_x_dims() -> None: - unwrap = unwrap_flat_indices_2d({'a': 3, '2nd': 5}, {'b': 4}) - assert unwrap(0, 0) == {'a': 0, '2nd': 0, 'b': 0} - assert unwrap(0, 1) == {'a': 0, '2nd': 0, 'b': 1} - assert unwrap(0, 3) == {'a': 0, '2nd': 0, 'b': 3} - - assert unwrap(1, 0) == {'a': 0, '2nd': 1, 'b': 0} - assert unwrap(1, 1) == {'a': 0, '2nd': 1, 'b': 1} - assert unwrap(1, 3) == {'a': 0, '2nd': 1, 'b': 3} - - assert unwrap(4, 0) == {'a': 0, '2nd': 4, 'b': 0} - assert unwrap(4, 1) == {'a': 0, '2nd': 4, 'b': 1} - assert unwrap(4, 3) == {'a': 0, '2nd': 4, 'b': 3} - - assert unwrap(5, 0) == {'a': 1, '2nd': 0, 'b': 0} - assert unwrap(5, 2) == {'a': 1, '2nd': 0, 'b': 2} - assert unwrap(5, 3) == {'a': 1, '2nd': 0, 'b': 3} - - assert unwrap(11, 0) == {'a': 2, '2nd': 1, 'b': 0} - assert unwrap(11, 2) == {'a': 2, '2nd': 1, 'b': 2} - assert unwrap(11, 3) == {'a': 2, '2nd': 1, 'b': 3} - - assert unwrap(14, 0) == {'a': 2, '2nd': 4, 'b': 0} - assert unwrap(14, 1) == {'a': 2, '2nd': 4, 'b': 1} - assert unwrap(14, 2) == {'a': 2, '2nd': 4, 'b': 2} - - -def test_unwrap_flat_indices_2d_three_y_dims() -> None: - unwrap = unwrap_flat_indices_2d({'a': 2}, {'b': 4, 'c': 1, 'd': 3}) - assert unwrap(0, 0) == {'a': 0, 'b': 0, 'c': 0, 'd': 0} - assert unwrap(1, 0) == {'a': 1, 'b': 0, 'c': 0, 'd': 0} - - assert unwrap(0, 3) == {'a': 0, 'b': 1, 'c': 0, 'd': 0} - assert unwrap(1, 3) == {'a': 1, 'b': 1, 'c': 0, 'd': 0} - - assert unwrap(0, 7) == {'a': 0, 'b': 2, 'c': 0, 'd': 1} - assert unwrap(1, 7) == {'a': 1, 'b': 2, 'c': 0, 'd': 1} - - -def test_unwrap_flat_indices_2d_dim_order() -> None: - unwrap = unwrap_flat_indices_2d({'x': 3, 'a': 5, 'g': 1}, {'b': 4, 'y': 4}) - assert list(unwrap(0, 0)) == ['x', 'a', 'g', 'b', 'y'] diff --git a/packages/essreduce/src/ess/reduce/diagnostics/flat_bank_viewer.py b/packages/essreduce/src/ess/reduce/diagnostics/flat_bank_viewer.py new file mode 100644 index 000000000..f75e05aea --- /dev/null +++ b/packages/essreduce/src/ess/reduce/diagnostics/flat_bank_viewer.py @@ -0,0 +1,665 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026 Scipp contributors (https://github.com/scipp) +"""Viewer for high-dimensional data.""" + +from collections import Counter +from collections.abc import Callable, Iterable, Mapping +from dataclasses import dataclass +from functools import partial +from typing import Any, Literal + +import ipywidgets as ipw +import plopp as pp +import scipp as sc +from plopp.core.utils import scalar_to_string +from plopp.widgets.slicing import CombinedSlicer + + +class FlatBankViewer(ipw.VBox): + """Image plot of data with controls for flattening and slicing its dims.""" + + def __init__( + self, + data: Mapping[str, sc.DataArray] | sc.DataArray, + dims: Iterable[str] | None = None, + *, + sliced: Iterable[str] = (), + horizontal: Iterable[str] = (), + vertical: Iterable[str] = (), + cbar: bool = True, + rasterized: bool = True, + **fig_kwargs: Any, + ) -> None: + """Create a new view. + + Parameters + ---------- + data: + Data to show. A mapping (e.g., a data group) with one entry per bank + adds buttons for selecting a bank. The banks may have different dims + but must all have the same unit. + dims: + Order of the dimension controls. With multiple banks, dims that are not + in a bank are ignored for that bank. + sliced: + Dims that start in slice mode. + horizontal: + Dims that start flattened onto the horizontal axis. + The first one that is in a bank is the primary dim of that bank. + vertical: + Dims that start flattened onto the vertical axis. + The first one that is in a bank is the primary dim of that bank. + cbar: + Whether to show a colorbar. + rasterized: + If ``True``, the figure is rasterized which improves rendering + speed but reduces resolution. + **fig_kwargs: + Additional arguments passed to :func:`plopp.imagefigure`. + + Dims that are not in a bank are ignored for that bank. + Dims that are in none of ``sliced``, ``horizontal``, and ``vertical`` use + default settings. Settings may get adjusted to ensure that both axes have + a dim, as long as at least two dims are flattened. + """ + self._fig_kwargs = {"cbar": cbar, "rasterized": rasterized} | fig_kwargs + self._figure = None + self._figure_key = None + self._message = ipw.HTML( + "Put at least two dims into flatten mode to show a plot.", + layout={"display": "none"}, + ) + self._figure_box = ipw.Box([self._message]) + self._configs_box = ipw.VBox() + self._plot_data = None + self._unwrap_indices = None + + self._dims = None if dims is None else tuple(dims) + if isinstance(data, sc.DataArray): + self._banks = {"": data} + selector = None + else: + self._banks = _validate_banks(data) + selector = ipw.ToggleButtons(options=list(self._banks)) + selector.observe(lambda change: self._select(change["new"]), names="value") + self._layout = _validate_layout( + self._banks, + sliced=tuple(sliced), + horizontal=tuple(horizontal), + vertical=tuple(vertical), + ) + # Graphs and controls are created on first selection of a bank and kept + # afterwards, so each bank keeps its settings. + self._views: dict[str, _BankView] = {} + self._current = next(iter(self._banks)) + self._select(self._current) + + children = [self._figure_box, self._configs_box] + if selector is not None: + children.insert(0, ipw.HBox([ipw.Label("Bank:"), selector])) + super().__init__(children) + + @property + def configs(self) -> "_DimensionConfigs": + """Dimension controls of the current bank.""" + return self._views[self._current].configs + + @property + def node(self) -> pp.Node: + """Node that produces the flattened and sliced data of the current bank.""" + return self._views[self._current].node + + def _select(self, bank: str) -> None: + self._current = bank + if bank not in self._views: + da = self._banks[bank] + dims = self._dims + if dims is not None: + dims = [dim for dim in dims if dim in da.dims] + self._views[bank] = _BankView( + da, + dims, + _initial_settings(da.dims, **self._layout), + partial(self._on_bank_data, bank), + ) + view = self._views[bank] + self._configs_box.children = [view.configs] + # The sink does not cache (`_on_bank_data` returns None), so this calls + # `_show` with the cached data of `view.node`, or computes it for new banks. + view.sink() + + def _on_bank_data(self, bank: str, data: sc.DataArray | None) -> None: + # Only the current bank's controls are visible, but be defensive anyway. + if bank == self._current: + self._show(data) + + def _show(self, data: sc.DataArray | None) -> None: + if data is None: + self._message.layout.display = None + if self._figure is not None: + self._figure.layout.display = "none" + return + self._message.layout.display = "none" + + vertical_dim, horizontal_dim = data.dims + data = _to_plot_data(data) + self._plot_data = data + # Same order as used by `flatten`. + sizes = self._views[self._current].sizes + _, vertical, horizontal = _sort_dims(sizes, self.configs.settings) + self._unwrap_indices = _unwrap_flat_indices_2d( + {dim: sizes[dim] for dim in horizontal}, + {dim: sizes[dim] for dim in vertical}, + ) + + # Replacing the figure widget makes the frontend re-create the canvas, which + # flickers and shifts the layout. So use a single figure and modify that. + if self._figure is None: + input_node = pp.Node(data) + self._figure = pp.imagefigure(input_node, **self._fig_kwargs) + self._figure_key = input_node.id + self._figure_box.children = [self._message, self._figure] + # Must come after the figure's first update, which sets Plopp's formatter. + self._figure.ax.format_coord = self._format_coord + else: + self._figure.layout.display = None + artist = self._figure.artists[self._figure_key] + if artist.data.shape != data.shape: + # Plopp's image artists cannot change their shape; replace the artist. + self._figure.artists.pop(self._figure_key).remove() + # Plopp does not unregister this when removing an artist. + self._figure.canvas._coord_formatters.remove(artist.format_coord) + + self._figure.canvas.xlabel = horizontal_dim + self._figure.canvas.ylabel = vertical_dim + # Re-creates the artist if it was removed above and redraws the canvas. + self._figure.update({self._figure_key: data}) + + def _format_coord(self, x: float, y: float) -> str: + """Show the indices of all flattened dims and the value under the cursor.""" + # Use round because axis coords are in the middle of bins. + i, j = round(x), round(y) + n_vertical, n_horizontal = self._plot_data.shape + if not (0 <= i < n_horizontal and 0 <= j < n_vertical): + return "" + indices = ", ".join( + f"{dim.capitalize()}: {index}" + for dim, index in self._unwrap_indices(i, j).items() + ) + data = self._plot_data.data["vertical", j]["horizontal", i] + value = scalar_to_string(data) + return f"{{{indices}}}: {value}" + + +class _BankView: + """Controls and graph for a single data array.""" + + def __init__( + self, + da: sc.DataArray, + dims: Iterable[str] | None, + settings: Mapping[str, "_DimensionSettings"], + on_data: Callable[[sc.DataArray | None], None], + ) -> None: + self.sizes = dict(da.sizes) + self.configs = _DimensionConfigs(da, dims, settings) + self.node = _apply_slices( + _flatten(pp.Node(da), self.configs.settings_node), self.configs.slices_node + ) + # Leaf node: Plopp calls it with new data whenever anything upstream changes. + self.sink = pp.Node(on_data, self.node) + + +def _validate_banks(data: Mapping[str, Any]) -> dict[str, sc.DataArray]: + if not data: + raise ValueError("Need at least one data array.") + for key, value in data.items(): + if not isinstance(value, sc.DataArray): + raise TypeError( + f"Expected all entries to be data arrays, got {type(value).__name__} " + f"for '{key}'." + ) + return dict(data) + + +def _validate_layout( + banks: Mapping[str, sc.DataArray], + *, + sliced: tuple[str, ...], + horizontal: tuple[str, ...], + vertical: tuple[str, ...], +) -> dict[str, tuple[str, ...]]: + listed = [*sliced, *horizontal, *vertical] + if duplicates := sorted(dim for dim, count in Counter(listed).items() if count > 1): + raise ValueError( + f"Dims {duplicates} are listed more than once in " + "`sliced`, `horizontal`, and `vertical`." + ) + all_dims = {dim for da in banks.values() for dim in da.dims} + if unknown := [dim for dim in listed if dim not in all_dims]: + raise ValueError(f"Dims {unknown} are not in the data.") + return {"sliced": sliced, "horizontal": horizontal, "vertical": vertical} + + +def _initial_settings( + dims: Iterable[str], + *, + sliced: tuple[str, ...], + horizontal: tuple[str, ...], + vertical: tuple[str, ...], +) -> dict[str, "_DimensionSettings"]: + """Settings for the listed dims that are in ``dims``.""" + dims = set(dims) + settings = { + dim: _DimensionSettings(mode="slice", axis="vertical", primary=False) + for dim in sliced + if dim in dims + } + for axis, axis_dims in (("horizontal", horizontal), ("vertical", vertical)): + present = [dim for dim in axis_dims if dim in dims] + for i, dim in enumerate(present): + settings[dim] = _DimensionSettings( + mode="flatten", axis=axis, primary=i == 0 + ) + return settings + + +def _to_plot_data(da: sc.DataArray) -> sc.DataArray: + """Rename the dims to fixed names and use index coords. + + This way, a single figure can show any layout of flattened dims. + Also, this returns a new data array which protects the input from + modifications in `fig.update`. + """ + vertical_dim, horizontal_dim = da.dims + renamed = da.rename_dims({vertical_dim: "vertical", horizontal_dim: "horizontal"}) + return renamed.drop_coords(list(renamed.coords)).assign_coords( + {dim: sc.arange(dim, size, unit=None) for dim, size in renamed.sizes.items()} + ) + + +# Called with the DimensionConfig that changed and the traitlets change dict. +_DimensionCallback = Callable[["_DimensionConfig", dict[str, Any]], None] + + +@dataclass(frozen=True, slots=True) +class _DimensionSettings: + mode: Literal["flatten", "slice"] + axis: Literal["vertical", "horizontal"] + primary: bool + + +_DEFAULT_SETTINGS = _DimensionSettings(mode="flatten", axis="vertical", primary=False) + + +class _DimensionConfig(ipw.HBox): + def __init__( + self, + dim: str, + size: int, + coord: sc.Variable, + settings: _DimensionSettings = _DEFAULT_SETTINGS, + ) -> None: + self.dim = dim + self._slider_change_handlers = ipw.CallbackDispatcher() + self._config_change_handlers = ipw.CallbackDispatcher() + + self._mode_button = ipw.ToggleButtons( + options=["flatten", "slice"], + value=settings.mode, + style={"button_width": "9ch"}, + layout=ipw.Layout(width="max-content", margin="0 1em 0 0.5em"), + ) + self._slicer = CombinedSlicer(dim=dim, size=size, coord=coord) + # DimensionConfig shows its own dim label. + self._slicer.int_slicer.dim_label.layout.display = "none" + self._slicer.range_slicer.dim_label.layout.display = "none" + self._axis = ipw.RadioButtons( + options=["vertical", "horizontal"], + value=settings.axis, + description="axis", + ) + self._axis.add_class("sc-horizontal-radio") + self._primary = ipw.Checkbox( + value=settings.primary, description="Primary", indent=False + ) + + self._mode_button.observe(self._on_mode_switch, names="value") + # `CombinedSlicer.slider` is always the range slider; in single-handle mode + # the single slider drives it as `(i, i)`, so this catches both modes as well + # as edits in the text boxes. + self._slicer.slider.observe(self._emit_slider_change, names="value") + self._axis.observe(self._emit_config_change, names="value") + self._primary.observe(self._emit_config_change, names="value") + self._update_visibility() + + label = ipw.Label(dim, layout=ipw.Layout(width="10ch")) + + style = ipw.HTML( + """ + +""", + layout={"display": "none"}, + ) + super().__init__( + [ + style, + label, + self._mode_button, + self._slicer, + self._axis, + self._primary, + ], + layout=ipw.Layout(align_items="center"), + ) + + def _update_visibility(self) -> None: + slicing = self.mode == "slice" + self._slicer.layout.display = "flex" if slicing else "none" + self._axis.layout.display = self._primary.layout.display = ( + "none" if slicing else "flex" + ) + + def _on_mode_switch(self, change: dict[str, Any]) -> None: + self._update_visibility() + self._emit_config_change(change) + + @property + def mode(self) -> Literal["flatten", "slice"]: + return self._mode_button.value + + @property + def slider_value(self) -> tuple[int, int]: + """Inclusive index range ``(start, stop)``; ``(i, i)`` in single-handle mode.""" + return self._slicer.value + + @property + def axis(self) -> Literal["vertical", "horizontal"]: + return self._axis.value + + @axis.setter + def axis(self, value: Literal["vertical", "horizontal"]) -> None: + self._axis.value = value + + @property + def primary(self) -> bool: + return self._primary.value + + @primary.setter + def primary(self, value: bool) -> None: + self._primary.value = value + + @property + def settings(self) -> _DimensionSettings: + return _DimensionSettings(mode=self.mode, axis=self.axis, primary=self.primary) + + def on_slider_change( + self, callback: _DimensionCallback, remove: bool = False + ) -> None: + """Register a callback for (cheap) slider moves.""" + self._slider_change_handlers.register_callback(callback, remove=remove) + + def on_config_change( + self, callback: _DimensionCallback, remove: bool = False + ) -> None: + """Register a callback for any other (expensive) configuration change.""" + self._config_change_handlers.register_callback(callback, remove=remove) + + def _emit_slider_change(self, change: dict[str, Any]) -> None: + self._slider_change_handlers(self, change) + + def _emit_config_change(self, change: dict[str, Any]) -> None: + self._config_change_handlers(self, change) + + +class _UncachedNode(pp.Node): + """Input node that re-evaluates ``func`` on every request instead of caching.""" + + def request_data(self) -> Any: + return self.func() + + +class _DimensionConfigs(ipw.VBox): + def __init__( + self, + da: sc.DataArray, + dims: Iterable[str] | None = None, + settings: Mapping[str, _DimensionSettings] | None = None, + ) -> None: + """Create controls for the dims of ``da``. + + ``settings`` holds the initial settings; dims without an entry use the + defaults. These may get adjusted to fulfill the constraints, e.g., to + ensure both axes have a dim. + """ + self._slider_change_handlers = ipw.CallbackDispatcher() + self._config_change_handlers = ipw.CallbackDispatcher() + + dims = da.dims if dims is None else dims + settings = {} if settings is None else settings + self._configs = { + dim: _DimensionConfig( + dim, + size=da.sizes[dim], + coord=_slicer_coord(da, dim), + settings=settings.get(dim, _DEFAULT_SETTINGS), + ) + for dim in dims + } + for config in self._configs.values(): + config.on_slider_change(self._slider_change_handlers) + config.on_config_change(self._on_child_config_change) + + # Set while `_enforce_constraints` modifies child widgets, to suppress the + # events those modifications would otherwise emit. + self._enforcing = False + self._enforce_constraints(source=None, widget=None) + self._last_settings = self._get_settings() + + # Two separate graph inputs so that slider moves only invalidate the + # nodes downstream of `slices_node`; Plopp's per-node cache keeps the + # (expensive) results downstream of `settings_node` intact. + # `slices_node` must not cache: its value depends on the modes, but mode + # switches only notify `settings_node` (notifying both would make shared + # children run twice). Its children still get re-run via `settings_node`. + self.slices_node = _UncachedNode(self._get_slices) + self.slices_node.pretty_name = "DimensionConfigs " + self.settings_node = pp.Node(self._get_settings) + self.settings_node.pretty_name = "DimensionConfigs " + self.on_slider_change( + lambda _, change: self.slices_node.notify_children(change) + ) + self.on_config_change( + lambda _, change: self.settings_node.notify_children(change) + ) + + super().__init__(list(self._configs.values())) + + def __getitem__(self, dim: str) -> _DimensionConfig: + return self._configs[dim] + + @property + def settings(self) -> dict[str, _DimensionSettings]: + return self._get_settings() + + def on_slider_change( + self, callback: _DimensionCallback, remove: bool = False + ) -> None: + """Register a callback for slider moves in any child.""" + self._slider_change_handlers.register_callback(callback, remove=remove) + + def on_config_change( + self, callback: _DimensionCallback, remove: bool = False + ) -> None: + """Register a callback for any non-slider change in any child.""" + self._config_change_handlers.register_callback(callback, remove=remove) + + def _get_slices(self) -> dict[str, tuple[int, int]]: + return { + dim: config.slider_value + for dim, config in self._configs.items() + if config.mode == "slice" + } + + def _get_settings(self) -> dict[str, _DimensionSettings]: + return {dim: config.settings for dim, config in self._configs.items()} + + def _on_child_config_change( + self, config: _DimensionConfig, change: dict[str, Any] + ) -> None: + if self._enforcing: + # Caused by `_enforce_constraints`; the outer call emits the event. + return + self._enforce_constraints(source=config, widget=change["owner"]) + settings = self._get_settings() + if settings == self._last_settings: + # E.g., the user unchecked the only primary dim of an axis, and it got + # re-checked. + return + self._last_settings = settings + self._config_change_handlers(config, change) + + def _enforce_constraints( + self, source: _DimensionConfig | None, widget: ipw.Widget | None + ) -> None: + """Ensure there is at least one dim on each axis and exactly one primary dim + per axis. + + Only applies when at least two dims are in flatten mode. + ``source`` and ``widget`` identify the user's change, which takes precedence + over the state of other dims. + """ + flat = [c for c in self._configs.values() if c.mode == "flatten"] + if len(flat) < 2: + return + user_axis = source is not None and widget is source._axis + user_mode = source is not None and widget is source._mode_button + user_primary = source is not None and widget is source._primary + + self._enforcing = True + try: + for axis in ("vertical", "horizontal"): + if not any(c.axis == axis for c in flat): + # Never undo the axis the user just picked. But prefer moving a + # dim that was just switched to flatten mode. + candidates = [c for c in flat if not (user_axis and c is source)] + preferred = source if user_mode else None + _choose_dim_to_move(candidates, preferred).axis = axis + + for axis in ("vertical", "horizontal"): + group = [c for c in flat if c.axis == axis] + if user_primary and source in group: + # Checked: takes over from the previous primary. + # Unchecked: gets re-checked, like a radio button. + keep = source + else: + # Keep the existing primary over one brought in by `source`, + # e.g., by moving a primary dim to this axis. + primaries = [c for c in group if c.primary] + keep = next( + (c for c in primaries if c is not source), + (primaries or group)[0], + ) + for c in group: + c.primary = c is keep + finally: + self._enforcing = False + + +def _choose_dim_to_move( + candidates: list[_DimensionConfig], preferred: _DimensionConfig | None +) -> _DimensionConfig: + if preferred in candidates: + return preferred + non_primary = [c for c in candidates if not c.primary] + return (non_primary or candidates)[-1] + + +def _slicer_coord(da: sc.DataArray, dim: str) -> sc.Variable: + # Same fallback as Plopp's slice widgets. + if dim in da.coords: + return da.coords[dim] + return sc.arange(dim, da.sizes[dim], unit=None) + + +@pp.node +def _flatten( + da: sc.DataArray, settings: Mapping[str, _DimensionSettings] +) -> sc.DataArray | None: + sliced, vertical, horizontal = _sort_dims(da.dims, settings) + if not vertical or not horizontal: + # Fewer than two dims in flatten mode; nothing to plot. + return None + + return ( + da.transpose([*sliced, *vertical, *horizontal]) + .flatten(horizontal, to=" * ".join(horizontal)) + .flatten(vertical, to=" * ".join(vertical)) + ) + + +def _sort_dims( + reference_dims: Iterable[str], settings: Mapping[str, _DimensionSettings] +) -> tuple[list[str], list[str], list[str]]: + sliced = [] + vertical = [] + horizontal = [] + # Preserve order in da for cheaper transpose + for dim in reference_dims: + match settings[dim]: + case _DimensionSettings(mode="slice"): + sliced.append(dim) + case _DimensionSettings(axis="vertical", primary=False): + vertical.append(dim) + case _DimensionSettings(axis="vertical", primary=True): + vertical.insert(0, dim) + case _DimensionSettings(axis="horizontal", primary=False): + horizontal.append(dim) + case _DimensionSettings(axis="horizontal", primary=True): + horizontal.insert(0, dim) + return sliced, vertical, horizontal + + +@pp.node +def _apply_slices( + da: sc.DataArray | None, slices: Mapping[str, tuple[int, int]] +) -> sc.DataArray | None: + if da is None: + return None + for dim, (start, stop) in slices.items(): + da = da[dim, start] if start == stop else da[dim, start : stop + 1].sum(dim) + return da + + +def _unwrap_flat_indices_2d( + x_sizes: dict[str, int], y_sizes: dict[str, int] +) -> Callable[[int, int], dict[str, int]]: + def unwrap(x: int, y: int) -> dict[str, int]: + return {**_unwrap_flat_index(x, x_sizes), **_unwrap_flat_index(y, y_sizes)} + + return unwrap + + +def _unwrap_flat_index(index: int, sizes: dict[str, int]) -> dict[str, int]: + indices = [] + for dim, size in reversed(sizes.items()): + index, offset = divmod(index, size) + indices.append((dim, offset)) + return dict(reversed(indices)) # Restore the input order. diff --git a/packages/essreduce/tests/diagnostics/flat_bank_viewer_test.py b/packages/essreduce/tests/diagnostics/flat_bank_viewer_test.py new file mode 100644 index 000000000..6c481d5c5 --- /dev/null +++ b/packages/essreduce/tests/diagnostics/flat_bank_viewer_test.py @@ -0,0 +1,537 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026 Scipp contributors (https://github.com/scipp) +import math + +import plopp as pp +import pytest +import scipp as sc + +from ess.reduce.diagnostics.flat_bank_viewer import ( + FlatBankViewer, + _DimensionConfig, + _DimensionConfigs, + _DimensionSettings, + _unwrap_flat_indices_2d, +) + +DIMS = ("arc", "channel", "tube", "length") +SHAPE = (2, 3, 4, 5) + + +def make_data(dims: tuple[str, ...], shape: tuple[int, ...]) -> sc.DataArray: + return sc.DataArray( + sc.arange("x", float(math.prod(shape)), unit="counts").fold( + "x", dims=dims, shape=shape + ), + coords={ + dim: sc.arange(dim, size, unit=None) + for dim, size in zip(dims, shape, strict=True) + }, + ) + + +@pytest.fixture +def da() -> sc.DataArray: + return make_data(DIMS, SHAPE) + + +@pytest.fixture +def interactive_backend(): + # Without an interactive backend, Plopp creates figures that are not widgets. + pytest.importorskip("ipympl") + import matplotlib.pyplot as plt + + original = plt.get_backend() + plt.switch_backend("module://ipympl.backend_nbagg") + yield + plt.switch_backend(original) + + +def view_test(func): + """Mark a test that creates a figure.""" + func = pytest.mark.usefixtures("interactive_backend")(func) + # Emitted by ipympl's toolbar, not by the code under test. + return pytest.mark.filterwarnings( + "ignore:Passing unrecognized arguments to super\\(Toolbar\\):DeprecationWarning" + )(func) + + +def flat(axis: str, primary: bool = False) -> _DimensionSettings: + return _DimensionSettings(mode="flatten", axis=axis, primary=primary) + + +SLICE = _DimensionSettings(mode="slice", axis="vertical", primary=False) + + +def set_mode(configs: _DimensionConfigs, dim: str, mode: str) -> None: + # Simulates a click on the mode buttons. + configs[dim]._mode_button.value = mode + + +def summarize(configs: _DimensionConfigs) -> dict[str, str | tuple[str, bool]]: + return { + dim: "slice" if s.mode == "slice" else (s.axis, s.primary) + for dim, s in configs.settings.items() + } + + +def assert_valid_axes(configs: _DimensionConfigs) -> None: + flattened = [s for s in configs.settings.values() if s.mode == "flatten"] + for axis in ("vertical", "horizontal"): + on_axis = [s for s in flattened if s.axis == axis] + assert on_axis + assert sum(s.primary for s in on_axis) == 1 + + +def record_events(configs: _DimensionConfigs) -> dict[str, list[str]]: + events = {"slider": [], "config": []} + configs.on_slider_change(lambda config, _: events["slider"].append(config.dim)) + configs.on_config_change(lambda config, _: events["config"].append(config.dim)) + return events + + +def test_dimension_configs_initial_settings(da: sc.DataArray) -> None: + settings = { + "tube": SLICE, + "length": flat("vertical", primary=True), + "arc": flat("horizontal", primary=True), + } + configs = _DimensionConfigs(da, settings=settings) + assert summarize(configs) == { + "arc": ("horizontal", True), + "channel": ("vertical", False), + "tube": "slice", + "length": ("vertical", True), + } + assert configs["tube"]._slicer.layout.display == "flex" + assert configs["tube"]._axis.layout.display == "none" + assert configs["arc"]._slicer.layout.display == "none" + + +@pytest.mark.parametrize( + ("mode", "displays"), + [("slice", ("flex", "none", "none")), ("flatten", ("none", "flex", "flex"))], +) +def test_switching_mode_updates_visible_controls( + da: sc.DataArray, + mode: str, + displays: tuple[str, ...], +) -> None: + config = _DimensionConfig( + "tube", + size=da.sizes["tube"], + coord=da.coords["tube"], + settings=SLICE if mode == "flatten" else flat("vertical"), + ) + config._mode_button.value = mode + assert ( + tuple( + widget.layout.display + for widget in (config._slicer, config._axis, config._primary) + ) + == displays + ) + + +@pytest.mark.parametrize("axis", ["vertical", "horizontal"]) +def test_dimension_configs_initial_settings_get_fixed_to_satisfy_constraints( + da: sc.DataArray, + axis: str, +) -> None: + settings = {dim: flat(axis, primary=True) for dim in da.dims} + configs = _DimensionConfigs(da, settings=settings) + assert_valid_axes(configs) + + +def test_moving_slider_emits_only_slider_event(da: sc.DataArray) -> None: + configs = _DimensionConfigs(da) + set_mode(configs, "tube", "slice") + events = record_events(configs) + configs["tube"]._slicer.slider.value = (1, 2) + assert events == {"slider": ["tube"], "config": []} + + +@pytest.mark.parametrize("change", ["axis", "mode"]) +def test_moving_last_dim_off_an_axis_moves_another_dim_there( + da: sc.DataArray, + change: str, +) -> None: + configs = _DimensionConfigs(da) + (horizontal,) = [ + dim for dim, s in configs.settings.items() if s.axis == "horizontal" + ] + events = record_events(configs) + + if change == "axis": + configs[horizontal].axis = "vertical" + assert configs[horizontal].axis == "vertical" # The user's choice is kept. + else: + set_mode(configs, horizontal, "slice") + assert configs[horizontal].mode == "slice" + assert_valid_axes(configs) + assert events == {"slider": [], "config": [horizontal]} # One event per action. + + +def test_switching_dim_to_flatten_mode_fills_empty_axis(da: sc.DataArray) -> None: + settings = { + "arc": flat("vertical", primary=True), + "channel": SLICE, + "tube": SLICE, + "length": SLICE, + } + configs = _DimensionConfigs(da, settings=settings) + set_mode(configs, "tube", "flatten") + # Moves the dim that was just switched, not the existing one. + assert summarize(configs)["arc"] == ("vertical", True) + assert summarize(configs)["tube"] == ("horizontal", True) + + +@pytest.mark.parametrize( + ("dim", "checked", "primary", "config_events"), + [ + pytest.param("channel", True, "channel", ["channel"], id="replace-primary"), + pytest.param("arc", False, "arc", [], id="revert-unchecking-only-primary"), + ], +) +def test_primary_changes_preserve_one_primary_and_emit_only_effective_changes( + da: sc.DataArray, + dim: str, + checked: bool, + primary: str, + config_events: list[str], +) -> None: + settings = { + "arc": flat("vertical", primary=True), + "channel": flat("vertical"), + "tube": flat("vertical"), + "length": flat("horizontal", primary=True), + } + configs = _DimensionConfigs(da, settings=settings) + events = record_events(configs) + configs[dim].primary = checked + assert summarize(configs) == { + "arc": ("vertical", primary == "arc"), + "channel": ("vertical", primary == "channel"), + "tube": ("vertical", False), + "length": ("horizontal", True), + } + assert_valid_axes(configs) + assert events == {"slider": [], "config": config_events} + + +def test_moving_primary_dim_to_other_axis_keeps_existing_primary( + da: sc.DataArray, +) -> None: + settings = { + "arc": flat("vertical", primary=True), + "channel": flat("vertical"), + "tube": flat("horizontal"), + "length": flat("horizontal", primary=True), + } + configs = _DimensionConfigs(da, settings=settings) + configs["arc"].axis = "horizontal" + assert summarize(configs) == { + "arc": ("horizontal", False), + "channel": ("vertical", True), + "tube": ("horizontal", False), + "length": ("horizontal", True), + } + + +def test_constraints_are_not_enforced_with_fewer_than_two_flattened_dims( + da: sc.DataArray, +) -> None: + configs = _DimensionConfigs(da) + for dim in ("channel", "tube", "length"): + set_mode(configs, dim, "slice") + events = record_events(configs) + configs["arc"].axis = "horizontal" + assert configs["arc"].axis == "horizontal" + assert events["config"] == ["arc"] + + +def test_graph_slider_move_only_reruns_slice_branch(da: sc.DataArray) -> None: + configs = _DimensionConfigs(da) + calls = [] + + @pp.node + def use_settings(settings): + calls.append("settings") + return settings + + @pp.node + def use_slices(settings, slices): + calls.append(("slices", slices)) + return slices + + sink = pp.Node( + lambda _: calls.append("sink"), + use_slices(use_settings(configs.settings_node), configs.slices_node), + ) + sink() + assert calls == ["settings", ("slices", {}), "sink"] + + calls.clear() + set_mode(configs, "tube", "slice") + # Each node runs exactly once and sees the new slice. + assert calls == [ + "settings", + ("slices", {"tube": configs["tube"].slider_value}), + "sink", + ] + + calls.clear() + configs["tube"]._slicer.slider.value = (2, 2) + assert calls == [("slices", {"tube": (2, 2)}), "sink"] + + calls.clear() + set_mode(configs, "tube", "flatten") + assert calls == ["settings", ("slices", {}), "sink"] + + +@view_test +def test_view_shows_flattened_data(da: sc.DataArray) -> None: + view = FlatBankViewer( + da, horizontal=["length"], vertical=["channel", "arc", "tube"] + ) + assert len(view.children) == 2 # No bank selector. + assert view.node().dims == ("channel * arc * tube", "length") + assert view._plot_data.sizes == {"vertical": 24, "horizontal": 5} + assert view._figure.canvas.ylabel == "channel * arc * tube" + assert view._figure.canvas.xlabel == "length" + expected = view.node().data.rename_dims( + {"channel * arc * tube": "vertical", "length": "horizontal"} + ) + assert sc.identical(view._plot_data.data, expected) + + +@view_test +def test_view_updates_figure_in_place(da: sc.DataArray) -> None: + view = FlatBankViewer(da) + figure = view._figure + + set_mode(view.configs, "tube", "slice") + assert view._figure is figure + assert "tube" not in view._figure.canvas.ylabel + assert "tube" not in view._figure.canvas.xlabel + assert math.prod(view._plot_data.shape) == 2 * 3 * 5 + + view.configs["tube"]._slicer.slider.value = (3, 3) + assert view._figure is figure + assert sc.identical( + view._plot_data.data.sum(), + da["tube", 3].data.sum(), + ) + + +@view_test +def test_view_shows_message_with_fewer_than_two_flattened_dims( + da: sc.DataArray, +) -> None: + view = FlatBankViewer(da) + for dim in ("channel", "tube", "length"): + set_mode(view.configs, dim, "slice") + assert view.node() is None + assert view._message.layout.display is None + assert view._figure.layout.display == "none" + + set_mode(view.configs, "length", "flatten") + assert view._message.layout.display == "none" + assert view._figure.layout.display is None + + +@view_test +def test_view_hover_shows_indices_of_all_flattened_dims(da: sc.DataArray) -> None: + view = FlatBankViewer( + da, sliced=["tube"], horizontal=["length"], vertical=["channel", "arc"] + ) + view.configs["tube"]._slicer.slider.value = (1, 1) + # x=4 -> length 4; y=5 -> channel 2, arc 1 + text = view._format_coord(4.2, 4.8) + assert text.startswith("{Length: 4, Channel: 2, Arc: 1}: ") + expected = da["arc", 1]["channel", 2]["tube", 1]["length", 4].value + assert str(expected) in text + + +@view_test +@pytest.mark.parametrize(("x", "y"), [(-1, 0), (5, 0), (0, -1), (0, 6)]) +def test_view_hover_outside_data_is_empty(da: sc.DataArray, x: float, y: float) -> None: + view = FlatBankViewer( + da, sliced=["tube"], horizontal=["length"], vertical=["channel", "arc"] + ) + assert view._format_coord(x, y) == "" + + +@pytest.fixture +def banks(da: sc.DataArray) -> sc.DataGroup: + return sc.DataGroup( + { + "a": da, + "b": make_data(("wire", "strip"), (6, 7)), + "c": make_data(("arc", "pixel", "tube"), (2, 3, 4)), + } + ) + + +def bank_selector(view: FlatBankViewer): + return view.children[0].children[1] + + +@view_test +def test_view_with_data_group_switches_banks(banks: sc.DataGroup) -> None: + view = FlatBankViewer(banks) + selector = bank_selector(view) + assert list(selector.options) == ["a", "b", "c"] + figure = view._figure + + selector.value = "b" + assert view._configs_box.children == (view.configs,) + assert view._figure is figure + assert math.prod(view._plot_data.shape) == 6 * 7 + assert {view._figure.canvas.xlabel, view._figure.canvas.ylabel} == { + "wire", + "strip", + } + + +@view_test +def test_view_with_data_group_keeps_settings_per_bank(banks: sc.DataGroup) -> None: + view = FlatBankViewer(banks) + selector = bank_selector(view) + set_mode(view.configs, "tube", "slice") + view.configs["tube"]._slicer.slider.value = (2, 2) + expected = view._plot_data + + selector.value = "c" + assert view.configs["tube"].mode == "flatten" + selector.value = "a" + assert view.configs["tube"].mode == "slice" + assert sc.identical(view._plot_data, expected) + + +@view_test +def test_view_ignores_updates_from_other_banks(banks: sc.DataGroup) -> None: + view = FlatBankViewer(banks) + selector = bank_selector(view) + selector.value = "b" + configs_b = view.configs + selector.value = "a" + shown = view._plot_data + set_mode(configs_b, "wire", "slice") + assert view._plot_data is shown + + +@view_test +def test_view_initial_layout_per_bank(banks: sc.DataGroup) -> None: + view = FlatBankViewer( + banks, + sliced=["tube"], + horizontal=["length", "strip"], + vertical=["channel", "arc", "wire"], + ) + assert summarize(view.configs) == { + "arc": ("vertical", False), + "channel": ("vertical", True), + "tube": "slice", + "length": ("horizontal", True), + } + assert view._figure.canvas.ylabel == "channel * arc" + assert view._figure.canvas.xlabel == "length" + + bank_selector(view).value = "b" + # The first listed dim that is in the bank is the primary. + assert summarize(view.configs) == { + "wire": ("vertical", True), + "strip": ("horizontal", True), + } + + bank_selector(view).value = "c" + # 'pixel' is not listed and no listed horizontal dim is in the bank. + assert summarize(view.configs) == { + "arc": ("vertical", True), + "pixel": ("horizontal", True), + "tube": "slice", + } + + +@pytest.mark.parametrize( + ("layout", "message"), + [ + ({"sliced": ["arc"], "vertical": ["arc"]}, "more than once"), + ({"sliced": ["arc"], "horizontal": ["arc"]}, "more than once"), + ({"horizontal": ["arc"], "vertical": ["arc"]}, "more than once"), + ({"vertical": ["arc", "arc"]}, "more than once"), + ({"horizontal": ["nope"]}, "not in the data"), + ], +) +def test_view_rejects_invalid_layout( + banks: sc.DataGroup, + layout: dict[str, list[str]], + message: str, +) -> None: + with pytest.raises(ValueError, match=message): + FlatBankViewer(banks, **layout) + + +def test_view_rejects_empty_bank_mapping() -> None: + with pytest.raises(ValueError, match="at least one data array"): + FlatBankViewer({}) + + +def test_view_rejects_non_data_array_entries(banks: sc.DataGroup) -> None: + banks["scalar"] = sc.scalar(1.0) + with pytest.raises(TypeError, match="scalar"): + FlatBankViewer(banks) + + +def test_unwrap_flat_indices_2d_single_dim() -> None: + unwrap = _unwrap_flat_indices_2d({'a': 3}, {'b': 5}) + assert unwrap(0, 0) == {'a': 0, 'b': 0} + assert unwrap(1, 0) == {'a': 1, 'b': 0} + assert unwrap(2, 0) == {'a': 2, 'b': 0} + assert unwrap(0, 4) == {'a': 0, 'b': 4} + assert unwrap(2, 4) == {'a': 2, 'b': 4} + + +def test_unwrap_flat_indices_2d_two_x_dims() -> None: + unwrap = _unwrap_flat_indices_2d({'a': 3, '2nd': 5}, {'b': 4}) + assert unwrap(0, 0) == {'a': 0, '2nd': 0, 'b': 0} + assert unwrap(0, 1) == {'a': 0, '2nd': 0, 'b': 1} + assert unwrap(0, 3) == {'a': 0, '2nd': 0, 'b': 3} + + assert unwrap(1, 0) == {'a': 0, '2nd': 1, 'b': 0} + assert unwrap(1, 1) == {'a': 0, '2nd': 1, 'b': 1} + assert unwrap(1, 3) == {'a': 0, '2nd': 1, 'b': 3} + + assert unwrap(4, 0) == {'a': 0, '2nd': 4, 'b': 0} + assert unwrap(4, 1) == {'a': 0, '2nd': 4, 'b': 1} + assert unwrap(4, 3) == {'a': 0, '2nd': 4, 'b': 3} + + assert unwrap(5, 0) == {'a': 1, '2nd': 0, 'b': 0} + assert unwrap(5, 2) == {'a': 1, '2nd': 0, 'b': 2} + assert unwrap(5, 3) == {'a': 1, '2nd': 0, 'b': 3} + + assert unwrap(11, 0) == {'a': 2, '2nd': 1, 'b': 0} + assert unwrap(11, 2) == {'a': 2, '2nd': 1, 'b': 2} + assert unwrap(11, 3) == {'a': 2, '2nd': 1, 'b': 3} + + assert unwrap(14, 0) == {'a': 2, '2nd': 4, 'b': 0} + assert unwrap(14, 1) == {'a': 2, '2nd': 4, 'b': 1} + assert unwrap(14, 2) == {'a': 2, '2nd': 4, 'b': 2} + + +def test_unwrap_flat_indices_2d_three_y_dims() -> None: + unwrap = _unwrap_flat_indices_2d({'a': 2}, {'b': 4, 'c': 1, 'd': 3}) + assert unwrap(0, 0) == {'a': 0, 'b': 0, 'c': 0, 'd': 0} + assert unwrap(1, 0) == {'a': 1, 'b': 0, 'c': 0, 'd': 0} + + assert unwrap(0, 3) == {'a': 0, 'b': 1, 'c': 0, 'd': 0} + assert unwrap(1, 3) == {'a': 1, 'b': 1, 'c': 0, 'd': 0} + + assert unwrap(0, 7) == {'a': 0, 'b': 2, 'c': 0, 'd': 1} + assert unwrap(1, 7) == {'a': 1, 'b': 2, 'c': 0, 'd': 1} + + +def test_unwrap_flat_indices_2d_dim_order() -> None: + unwrap = _unwrap_flat_indices_2d({'x': 3, 'a': 5, 'g': 1}, {'b': 4, 'y': 4}) + assert list(unwrap(0, 0)) == ['x', 'a', 'g', 'b', 'y'] diff --git a/packages/essreduce/tests/package_test.py b/packages/essreduce/tests/package_test.py index 69e2d0e4c..f77366463 100644 --- a/packages/essreduce/tests/package_test.py +++ b/packages/essreduce/tests/package_test.py @@ -25,6 +25,7 @@ def test_has_version(): WIDGET_MODULES = ( 'ess.reduce.ui', 'ess.reduce.widgets', + 'ess.reduce.diagnostics.flat_bank_viewer', ) diff --git a/packages/essspectroscopy/src/ess/bifrost/diagnostics/__init__.py b/packages/essspectroscopy/src/ess/bifrost/diagnostics/__init__.py new file mode 100644 index 000000000..e9ecfc35a --- /dev/null +++ b/packages/essspectroscopy/src/ess/bifrost/diagnostics/__init__.py @@ -0,0 +1,10 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026 Scipp contributors (https://github.com/scipp) + +"""Instrument diagnostics tools for BIFROST.""" + +from .flat_detector_viewer import FlatDetectorViewer + +__all__ = [ + 'FlatDetectorViewer', +] diff --git a/packages/essspectroscopy/src/ess/bifrost/diagnostics/flat_detector_viewer.py b/packages/essspectroscopy/src/ess/bifrost/diagnostics/flat_detector_viewer.py new file mode 100644 index 000000000..e06fbd4c0 --- /dev/null +++ b/packages/essspectroscopy/src/ess/bifrost/diagnostics/flat_detector_viewer.py @@ -0,0 +1,50 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026 Scipp contributors (https://github.com/scipp) +"""Flat detector viewer for BIFROST.""" + +from collections.abc import Mapping +from typing import Any + +import scipp as sc + +from ess.reduce.diagnostics.flat_bank_viewer import FlatBankViewer + + +class FlatDetectorViewer(FlatBankViewer): + """Interactive 2D plot of all detector pixels.""" + + def __init__( + self, + data: Mapping[str, sc.DataArray] | sc.DataArray, + *, + cbar: bool = True, + rasterized: bool = True, + **kwargs: Any, + ) -> None: + """Create a new viewer. + + Parameters + ---------- + data: + Histogrammed data, one entry per bank. + cbar: + Whether to show a colorbar. + rasterized: + If ``True``, the figure is rasterized which improves rendering + speed but reduces resolution. + **kwargs: + Additional arguments passed to :func:`plopp.imagefigure`. + """ + vertical = ("arc", "tube") + horizontal = ("channel", "length") + sliced = set(data.dims) - set(vertical) - set(horizontal) + super().__init__( + data, + dims=(*vertical, *horizontal, *sliced), + sliced=sliced, + vertical=vertical, + horizontal=horizontal, + cbar=cbar, + rasterized=rasterized, + **kwargs, + ) diff --git a/packages/essspectroscopy/tests/package_test.py b/packages/essspectroscopy/tests/package_test.py index fa1173c99..e93ba2685 100644 --- a/packages/essspectroscopy/tests/package_test.py +++ b/packages/essspectroscopy/tests/package_test.py @@ -32,7 +32,7 @@ def test_has_version(): ) # Notebook-only modules that import widget packages at module level. # Their submodules are skipped as well. -WIDGET_MODULES = () +WIDGET_MODULES = ('ess.bifrost.diagnostics',) def test_import_works_without_optional_widget_packages():