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():