From 1d7bafdcc1d8520575c77c6b916a2e2f9d7d4a8c Mon Sep 17 00:00:00 2001 From: MialLewis <95620982+MialLewis@users.noreply.github.com> Date: Tue, 12 Aug 2025 14:56:46 +0100 Subject: [PATCH 01/33] add initial toolbar --- .../widgets/sliceviewer/models/masking.py | 0 .../widgets/sliceviewer/presenters/masking.py | 0 .../sliceviewer/presenters/presenter.py | 17 ++++++++ .../widgets/sliceviewer/views/dataview.py | 42 +++++++++++++++++++ .../widgets/sliceviewer/views/toolbar.py | 19 +++++++++ 5 files changed, 78 insertions(+) create mode 100644 qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py create mode 100644 qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/masking.py diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/masking.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/masking.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/presenter.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/presenter.py index 217cc1f55e91..ba209162ba33 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/presenter.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/presenter.py @@ -30,6 +30,8 @@ from workbench.plotting.propertiesdialog import XAxisEditor, YAxisEditor DBLMAX = sys.float_info.max +MASK_SHAPE_OPTIONS = [ToolItemText.RECT_MASKING, ToolItemText.ELLI_MASKING, ToolItemText.POLY_MASKING] +MASK_PROCESS_OPTIONS = [ToolItemText.APPLY_MASKING, ToolItemText.EXPORT_MASKING] class SliceViewer(ObservingPresenter, SliceViewerBasePresenter): @@ -78,6 +80,9 @@ def __init__(self, ws, parent=None, window_flags=Qt.Window, model=None, view=Non if not self.model.can_support_non_axis_cuts(): self.view.data_view.disable_tool_button(ToolItemText.NONAXISALIGNEDCUTS) + # disable masking options until activated + self.toggle_masking_options() + self.view.data_view.help_button.clicked.connect(self.action_open_help_window) self.refresh_view() @@ -594,6 +599,18 @@ def _get_full_point(slice_point: List[float], xdata: float, ydata: float, xdim: full_point[ydim] = ydata return full_point + def toggle_masking_options(self, masking_option_selected=None): + if masking_option_selected: + self.view.data_view.enable_tool_button(masking_option_selected) + for p_opt in MASK_PROCESS_OPTIONS: + self.view.data_view.enable_tool_button(p_opt) + else: + for p_opt in MASK_PROCESS_OPTIONS: + self.view.data_view.disable_tool_button(p_opt) + for s_opt in MASK_SHAPE_OPTIONS: + if s_opt != masking_option_selected: + self.view.data_view.disable_tool_button(s_opt) + class SliceViewXAxisEditor(XAxisEditor): def __init__(self, canvas, axes, dimensions_changed): diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/views/dataview.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/views/dataview.py index e19ec99045e4..f9ed9b8ac2f0 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/views/dataview.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/views/dataview.py @@ -145,6 +145,12 @@ def __init__( self.mpl_toolbar.nonOrthogonalClicked.connect(self.on_non_orthogonal_axes_toggle) self.mpl_toolbar.zoomPanClicked.connect(self.presenter.zoom_pan_clicked) self.mpl_toolbar.zoomPanFinished.connect(self.on_data_limits_changed) + self.mpl_toolbar.maskingClicked.connect(self.on_masking_clicked) + self.mpl_toolbar.rectMaskingClicked.connect(self.on_rect_masking_clicked) + self.mpl_toolbar.elliMaskingClicked.connect(self.on_elli_masking_clicked) + self.mpl_toolbar.polyMaskingClicked.connect(self.on_poly_masking_clicked) + self.mpl_toolbar.exportMaskingClicked.connect(self.on_export_masking_clicked) + self.mpl_toolbar.applyMaskingClicked.connect(self.on_apply_masking_clicked) self.toolbar_layout.addWidget(self.mpl_toolbar) # Status bar @@ -482,6 +488,42 @@ def on_data_limits_changed(self): """ self.presenter.data_limits_changed() + def on_masking_clicked(self): + """ + React to masking clicked + """ + pass + + def on_rect_masking_clicked(self): + """ + React to rectangle masking selected + """ + pass + + def on_elli_masking_clicked(self): + """ + React to elliptical masking selected + """ + pass + + def on_poly_masking_clicked(self): + """ + React to polygon masking selected + """ + pass + + def on_export_masking_clicked(self): + """ + React to export masking selected + """ + pass + + def on_apply_masking_clicked(self): + """ + React to apply masking selected + """ + pass + def deactivate_and_disable_tool(self, tool_text): """Deactivate a tool as if the control had been pressed and disable the functionality""" self.deactivate_tool(tool_text) diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/views/toolbar.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/views/toolbar.py index 7733dc8c03ab..fc271950c73d 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/views/toolbar.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/views/toolbar.py @@ -22,6 +22,12 @@ class ToolItemText: NONORTHOGONAL_AXES = "NonOrthogonalAxes" SAVE = "Save" NONAXISALIGNEDCUTS = "NonAxisAlignedCuts" + MASKING = "Masking" + RECT_MASKING = "RectangleMasking" + ELLI_MASKING = "EllipticalMasking" + POLY_MASKING = "PolygonMasking" + APPLY_MASKING = "ApplyMasking" + EXPORT_MASKING = "ExportMasking" class SliceViewerNavigationToolbar(MantidNavigationToolbar): @@ -34,6 +40,12 @@ class SliceViewerNavigationToolbar(MantidNavigationToolbar): nonAlignedCutsClicked = Signal(bool) zoomPanClicked = Signal(bool) zoomPanFinished = Signal() + maskingClicked = Signal(bool) + rectMaskingClicked = Signal(bool) + elliMaskingClicked = Signal(bool) + polyMaskingClicked = Signal(bool) + applyMaskingClicked = Signal(bool) + exportMaskingClicked = Signal(bool) toolitems = ( MantidNavigationTool(ToolItemText.HOME, "Reset original view", "mdi.home", "homeClicked", None), @@ -53,6 +65,13 @@ class SliceViewerNavigationToolbar(MantidNavigationToolbar): ToolItemText.NONORTHOGONAL_AXES, "Toggle nonorthogonal axes on/off", "mdi.axis", "nonOrthogonalClicked", False ), MantidStandardNavigationTools.SEPARATOR, + MantidNavigationTool(ToolItemText.MASKING, "Toggle masking on/off", "mdi.transition-masked", "maskingClicked", False), + MantidNavigationTool(ToolItemText.RECT_MASKING, "Select rectangle mask", "mdi.square-outline", "rectMaskingClicked", False), + MantidNavigationTool(ToolItemText.ELLI_MASKING, "Select elliptical mask", "mdi.circle-outline", "elliMaskingClicked", False), + MantidNavigationTool(ToolItemText.POLY_MASKING, "Select polygon mask", "mdi.triangle-outline", "polyMaskingClicked", False), + MantidNavigationTool(ToolItemText.APPLY_MASKING, "Apply drawn mask", "mdi.checkbox-marked-outline", "applyMaskingClicked", False), + MantidNavigationTool(ToolItemText.EXPORT_MASKING, "Export drawn mask to table", "mdi.export", "exportMaskingClicked", False), + MantidStandardNavigationTools.SEPARATOR, MantidNavigationTool(ToolItemText.SAVE, "Save the figure", "mdi.content-save", "save_figure", None), ) From 70064a05816ab6a21040f3f5deebd1db379d3e39 Mon Sep 17 00:00:00 2001 From: MialLewis <95620982+MialLewis@users.noreply.github.com> Date: Tue, 12 Aug 2025 17:14:03 +0100 Subject: [PATCH 02/33] enable toggling of shape options --- .../sliceviewer/presenters/presenter.py | 45 ++++++++++++++----- .../widgets/sliceviewer/views/dataview.py | 28 ++++++------ .../sliceviewer/views/dataviewsubscriber.py | 24 ++++++++++ 3 files changed, 71 insertions(+), 26 deletions(-) diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/presenter.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/presenter.py index ba209162ba33..10f41fa096b7 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/presenter.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/presenter.py @@ -32,6 +32,7 @@ DBLMAX = sys.float_info.max MASK_SHAPE_OPTIONS = [ToolItemText.RECT_MASKING, ToolItemText.ELLI_MASKING, ToolItemText.POLY_MASKING] MASK_PROCESS_OPTIONS = [ToolItemText.APPLY_MASKING, ToolItemText.EXPORT_MASKING] +MASK_OPTIONS = MASK_SHAPE_OPTIONS + MASK_PROCESS_OPTIONS class SliceViewer(ObservingPresenter, SliceViewerBasePresenter): @@ -81,7 +82,7 @@ def __init__(self, ws, parent=None, window_flags=Qt.Window, model=None, view=Non self.view.data_view.disable_tool_button(ToolItemText.NONAXISALIGNEDCUTS) # disable masking options until activated - self.toggle_masking_options() + self._toggle_masking_options(False) self.view.data_view.help_button.clicked.connect(self.action_open_help_window) @@ -599,17 +600,37 @@ def _get_full_point(slice_point: List[float], xdata: float, ydata: float, xdim: full_point[ydim] = ydata return full_point - def toggle_masking_options(self, masking_option_selected=None): - if masking_option_selected: - self.view.data_view.enable_tool_button(masking_option_selected) - for p_opt in MASK_PROCESS_OPTIONS: - self.view.data_view.enable_tool_button(p_opt) - else: - for p_opt in MASK_PROCESS_OPTIONS: - self.view.data_view.disable_tool_button(p_opt) - for s_opt in MASK_SHAPE_OPTIONS: - if s_opt != masking_option_selected: - self.view.data_view.disable_tool_button(s_opt) + def _toggle_masking_options(self, active): + fn = self.view.data_view.enable_tool_button if active else self.view.data_view.disable_tool_button + for opt in MASK_OPTIONS: + fn(opt) + + def _check_masking_shapes(self, shape): + for opt in MASK_SHAPE_OPTIONS: + fn = self.view.data_view.activate_tool if opt == shape else self.view.data_view.deactivate_tool + fn(opt, False) + + def masking_clicked(self, active) -> None: + self._toggle_masking_options(active) + if active: + self.view.data_view.activate_tool(ToolItemText.RECT_MASKING, True) # default to rect masking + return + self._check_masking_shapes(None) + + def rect_masking_clicked(self, active) -> None: + self._check_masking_shapes(ToolItemText.RECT_MASKING) + + def elli_masking_clicked(self, active) -> None: + self._check_masking_shapes(ToolItemText.ELLI_MASKING) + + def poly_masking_clicked(self, active) -> None: + self._check_masking_shapes(ToolItemText.POLY_MASKING) + + def export_masking_clicked(self) -> None: + pass + + def apply_masking_clicked(self) -> None: + pass class SliceViewXAxisEditor(XAxisEditor): diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/views/dataview.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/views/dataview.py index f9ed9b8ac2f0..d89b93e51117 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/views/dataview.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/views/dataview.py @@ -488,54 +488,54 @@ def on_data_limits_changed(self): """ self.presenter.data_limits_changed() - def on_masking_clicked(self): + def on_masking_clicked(self, state): """ React to masking clicked """ - pass + self.presenter.masking_clicked(state) - def on_rect_masking_clicked(self): + def on_rect_masking_clicked(self, state): """ React to rectangle masking selected """ - pass + self.presenter.rect_masking_clicked(state) - def on_elli_masking_clicked(self): + def on_elli_masking_clicked(self, state): """ React to elliptical masking selected """ - pass + self.presenter.elli_masking_clicked(state) - def on_poly_masking_clicked(self): + def on_poly_masking_clicked(self, state): """ React to polygon masking selected """ - pass + self.presenter.poly_masking_clicked(state) def on_export_masking_clicked(self): """ React to export masking selected """ - pass + self.presenter.export_masking_clicked() def on_apply_masking_clicked(self): """ React to apply masking selected """ - pass + self.presenter.apply_masking_clicked() def deactivate_and_disable_tool(self, tool_text): """Deactivate a tool as if the control had been pressed and disable the functionality""" self.deactivate_tool(tool_text) self.disable_tool_button(tool_text) - def activate_tool(self, tool_text): + def activate_tool(self, tool_text, trigger=True): """Activate a given tool as if the control had been pressed""" - self.mpl_toolbar.set_action_checked(tool_text, True) + self.mpl_toolbar.set_action_checked(tool_text, True, trigger) - def deactivate_tool(self, tool_text): + def deactivate_tool(self, tool_text, trigger=True): """Deactivate a given tool as if the tool button had been pressed""" - self.mpl_toolbar.set_action_checked(tool_text, False) + self.mpl_toolbar.set_action_checked(tool_text, False, trigger) def enable_tool_button(self, tool_text): """Set a given tool button enabled so it can be interacted with""" diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/views/dataviewsubscriber.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/views/dataviewsubscriber.py index 395e4a31ceda..7f37d82e2897 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/views/dataviewsubscriber.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/views/dataviewsubscriber.py @@ -52,3 +52,27 @@ def slicepoint_changed(self) -> None: @abstractmethod def zoom_pan_clicked(self, active) -> None: pass + + @abstractmethod + def masking_clicked(self, active) -> None: + pass + + @abstractmethod + def rect_masking_clicked(self, active) -> None: + pass + + @abstractmethod + def elli_masking_clicked(self, active) -> None: + pass + + @abstractmethod + def poly_masking_clicked(self, active) -> None: + pass + + @abstractmethod + def export_masking_clicked(self) -> None: + pass + + @abstractmethod + def apply_masking_clicked(self) -> None: + pass From 57110a52c1e4cb8f709ea74c6c4a091ddf6bd54c Mon Sep 17 00:00:00 2001 From: MialLewis <95620982+MialLewis@users.noreply.github.com> Date: Thu, 14 Aug 2025 18:34:52 +0100 Subject: [PATCH 03/33] initial pass add selectors --- .../sliceviewer/presenters/base_presenter.py | 23 +++- .../sliceviewer/presenters/lineplots.py | 58 +-------- .../widgets/sliceviewer/presenters/masking.py | 118 ++++++++++++++++++ .../sliceviewer/presenters/presenter.py | 34 ++--- .../sliceviewer/presenters/selector.py | 58 +++++++++ .../widgets/sliceviewer/views/dataview.py | 32 ++++- .../sliceviewer/views/dataviewsubscriber.py | 2 +- 7 files changed, 244 insertions(+), 81 deletions(-) create mode 100644 qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/selector.py diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/base_presenter.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/base_presenter.py index 45c05e6ff423..e703edf083a2 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/base_presenter.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/base_presenter.py @@ -13,6 +13,7 @@ from mantidqt.widgets.sliceviewer.views.dataview import SliceViewerDataView from mantidqt.widgets.sliceviewer.views.dataviewsubscriber import IDataViewSubscriber from mantidqt.widgets.sliceviewer.views.toolbar import ToolItemText +from mantidqt.widgets.sliceviewer.presenters.masking import Masking class SliceViewerBasePresenter(IDataViewSubscriber, ABC): @@ -105,10 +106,13 @@ def region_selection(self, state): """ data_view = self._data_view if state: - # incompatible with drag zooming/panning as they both require drag selection + # incompatible with drag zooming, panning and masking as they require drag selection data_view.deactivate_and_disable_tool(ToolItemText.ZOOM) data_view.deactivate_and_disable_tool(ToolItemText.PAN) data_view.extents_set_enabled(False) + data_view.toggle_masking_options(False) + data_view.check_masking_shape_toolbar_icons(None) + data_view.deactivate_and_disable_tool(ToolItemText.MASKING) tool = RectangleSelectionLinePlot if data_view.line_plots_active: data_view.switch_line_plots_tool(RectangleSelectionLinePlot, self) @@ -117,9 +121,26 @@ def region_selection(self, state): else: data_view.enable_tool_button(ToolItemText.ZOOM) data_view.enable_tool_button(ToolItemText.PAN) + data_view.enable_tool_button(ToolItemText.MASKING) data_view.extents_set_enabled(True) data_view.switch_line_plots_tool(PixelLinePlot, self) + def masking(self, active) -> None: + self._data_view.toggle_masking_options(active) + if active: + self._data_view.deactivate_and_disable_tool(ToolItemText.ZOOM) + self._data_view.deactivate_and_disable_tool(ToolItemText.PAN) + self._data_view.deactivate_and_disable_tool(ToolItemText.REGIONSELECTION) + self._data_view.masking = Masking(self._data_view.ax) + self._data_view.masking.new_selector(ToolItemText.RECT_MASKING) # default to rect masking + self._data_view.activate_tool(ToolItemText.RECT_MASKING, True) + return + self._data_view.enable_tool(ToolItemText.ZOOM) + self._data_view.enable_tool(ToolItemText.PAN) + self._data_view.enable_tool(ToolItemText.REGIONSELECTION) + self._data_view.clear_masking_shapes() + self._data_view.check_masking_shape_toolbar_icons(None) + @abc.abstractmethod def get_extra_image_info_columns(self, xdata, ydata): pass diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/lineplots.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/lineplots.py index f73fdef56be7..fcaa4a5b5159 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/lineplots.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/lineplots.py @@ -6,15 +6,11 @@ # SPDX - License - Identifier: GPL - 3.0 + # This file is part of the mantidqt # std imports -from collections import namedtuple -from functools import lru_cache -from typing import Any, Optional, Tuple +from typing import Any, Tuple # 3rd party imports from matplotlib.axes import Axes -from matplotlib.image import AxesImage from matplotlib.gridspec import GridSpec -from matplotlib.transforms import Bbox, BboxTransform from matplotlib.widgets import RectangleSelector import numpy as np @@ -28,6 +24,8 @@ MoveMouseCursorRight, ) +from .selector import make_selector_class, cursor_info + # Limits for X/Y axes Limits = Tuple[Tuple[float, float], Tuple[float, float]] @@ -321,16 +319,6 @@ def handle_key(self, key): self.exporter.export_pixel_cut(self._cursor_pos, key) -class RectangleSelectorMtd(RectangleSelector): - def onmove(self, event): - """ - Only process event if inside the axes with which the selector was init - This fixes bug where the x/y of the event originated from the line plot axes not the colorfill axes - """ - if event.inaxes is None or self.ax == event.inaxes.axes: - super(RectangleSelectorMtd, self).onmove(event) - - class RectangleSelectionLinePlot(KeyHandler): """ Draws X/Y line plots from a rectangular selection by summing across @@ -347,7 +335,7 @@ def __init__(self, plotter: LinePlots, exporter: Any): super().__init__(plotter, exporter) ax = plotter.image_axes - self._selector = RectangleSelectorMtd( + self._selector = make_selector_class(RectangleSelector)( ax, self._on_rectangle_selected, useblit=False, # rectangle persists on button release @@ -405,41 +393,3 @@ def _on_rectangle_selected(self, eclick, erelease): plotter.plot_y_line(np.linspace(ymin, ymax, arr.shape[0])[imin:imax], np.sum(arr[imin:imax, jmin:jmax], axis=1)) plotter.update_line_plot_limits() plotter.redraw() - - -# Data type to store information related to a cursor over an image -CursorInfo = namedtuple("CursorInfo", ("array", "extent", "point")) - - -@lru_cache(maxsize=32) -def cursor_info(image: AxesImage, xdata: float, ydata: float, full_bbox: Bbox = None) -> Optional[CursorInfo]: - """Return information on the image for the given position in - data coordinates. - :param image: An instance of an image type - :param xdata: X data coordinate of cursor - :param ydata: Y data coordinate of cursor - :param full_bbox: Bbox of full workspace dimension to use for transforming mouse position - :return: None if point is not valid on the image else return CursorInfo type - """ - extent = image.get_extent() - xmin, xmax, ymin, ymax = extent - arr = image.get_array() - data_extent = Bbox([[ymin, xmin], [ymax, xmax]]) - array_extent = Bbox([[0, 0], arr.shape[:2]]) - if full_bbox is None: - trans = BboxTransform(boxin=data_extent, boxout=array_extent) - else: - # If the view is zoomed in and the slice is changed, then the image extents - # and data extents change. This causes the cursor to be transformed to the - # wrong point for certain MDH workspaces (since it cannot be dynamically rebinned). - # This will use the full WS data dimensions to do the transformation - trans = BboxTransform(boxin=full_bbox, boxout=array_extent) - point = trans.transform_point([ydata, xdata]) - if any(np.isnan(point)): - return None - - point = point.astype(int) - if 0 <= point[0] <= arr.shape[0] and 0 <= point[1] <= arr.shape[1]: - return CursorInfo(array=arr, extent=extent, point=point) - else: - return None diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/masking.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/masking.py index e69de29bb2d1..0476446bd2ba 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/masking.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/masking.py @@ -0,0 +1,118 @@ +from matplotlib.widgets import RectangleSelector, EllipseSelector, PolygonSelector +from matplotlib.axes import Axes +from abc import ABC + +from mantidqt.widgets.sliceviewer.views.toolbar import ToolItemText +from .selector import make_selector_class, cursor_info + + +class Masking: + """ + Manages Masking + """ + + def __init__(self, ax): + self._selectors = [] + self._active_selector = None + self._ax = ax + + def _new_selector(self, selector_type): + if self._active_selector and self._active_selector.mask_drawn: + self._selectors.append(self._active_selector) + self._active_selector.set_active(False) + self._active_selector = selector_type(self._ax) + self._active_selector.set_active(True) + + def new_selector(self, text: str): + match text: + case ToolItemText.RECT_MASKING: + self._new_selector(RectangleSelectionMasking) + case ToolItemText.ELLI_MASKING: + self._new_selector(EllipticalSelectionMasking) + case ToolItemText.POLY_MASKING: + self._new_selector(PolygonSelectionMasking) + + def export_selectors(self): + pass + + def apply_selectors(self): + pass + + +class SelectionMaskingBase(ABC): + def __init__(self, ax): + self._img = ax.images[0] + self._cursor_info = None + self.mask_drawn = False + self._selector = None + + def _on_selected(self, eclick, erelease): + """ + Callback when a shape has been draw on the axes + :param eclick: Event marking where the mouse was clicked + :param erelease: Event marking where the mouse was released + """ + cinfo_click = cursor_info(self._img, eclick.xdata, eclick.ydata) + if cinfo_click is None: + return + cinfo_release = cursor_info(self._img, erelease.xdata, erelease.ydata) + if cinfo_release is None: + return + + # Add shape object to collection + self._cursor_info = (cinfo_click, cinfo_release) + self.mask_drawn = True + + def set_active(self, state): + self._selector.set_active(state) + + +class RectangleSelectionMaskingBase(SelectionMaskingBase): + """ + Base class for a selector with a `RectangleSelector` base. + """ + + def __init__(self, ax: Axes, selector): + super().__init__(ax) + self._selector = make_selector_class(selector)( + ax, + self._on_selected, + useblit=False, # rectangle persists on button release + button=[1], + minspanx=5, + minspany=5, + spancoords="pixels", + interactive=True, + ) + + +class RectangleSelectionMasking(RectangleSelectionMaskingBase): + """ + Draws a mask from a rectangular selection + """ + + def __init__(self, ax): + super().__init__(ax, RectangleSelector) + + +class EllipticalSelectionMasking(RectangleSelectionMaskingBase): + """ + Draws a mask from an elliptical selection + """ + + def __init__(self, ax): + super().__init__(ax, EllipseSelector) + + +class PolygonSelectionMasking(SelectionMaskingBase): + """ + Draws a mask from a polygon selection + """ + + def __init__(self, ax: Axes): + super().__init__(ax) + self._selector = make_selector_class(PolygonSelector)( + ax, + self._on_selected, + useblit=False, # rectangle persists on button release + ) diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/presenter.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/presenter.py index 10f41fa096b7..9c96746dc7f8 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/presenter.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/presenter.py @@ -30,9 +30,6 @@ from workbench.plotting.propertiesdialog import XAxisEditor, YAxisEditor DBLMAX = sys.float_info.max -MASK_SHAPE_OPTIONS = [ToolItemText.RECT_MASKING, ToolItemText.ELLI_MASKING, ToolItemText.POLY_MASKING] -MASK_PROCESS_OPTIONS = [ToolItemText.APPLY_MASKING, ToolItemText.EXPORT_MASKING] -MASK_OPTIONS = MASK_SHAPE_OPTIONS + MASK_PROCESS_OPTIONS class SliceViewer(ObservingPresenter, SliceViewerBasePresenter): @@ -82,7 +79,7 @@ def __init__(self, ws, parent=None, window_flags=Qt.Window, model=None, view=Non self.view.data_view.disable_tool_button(ToolItemText.NONAXISALIGNEDCUTS) # disable masking options until activated - self._toggle_masking_options(False) + self.view.data_view.toggle_masking_options(False) self.view.data_view.help_button.clicked.connect(self.action_open_help_window) @@ -600,36 +597,25 @@ def _get_full_point(slice_point: List[float], xdata: float, ydata: float, xdim: full_point[ydim] = ydata return full_point - def _toggle_masking_options(self, active): - fn = self.view.data_view.enable_tool_button if active else self.view.data_view.disable_tool_button - for opt in MASK_OPTIONS: - fn(opt) - - def _check_masking_shapes(self, shape): - for opt in MASK_SHAPE_OPTIONS: - fn = self.view.data_view.activate_tool if opt == shape else self.view.data_view.deactivate_tool - fn(opt, False) - - def masking_clicked(self, active) -> None: - self._toggle_masking_options(active) - if active: - self.view.data_view.activate_tool(ToolItemText.RECT_MASKING, True) # default to rect masking - return - self._check_masking_shapes(None) - def rect_masking_clicked(self, active) -> None: - self._check_masking_shapes(ToolItemText.RECT_MASKING) + self.view.data_view.check_masking_shape_toolbar_icons(ToolItemText.RECT_MASKING) + self.view.data_view.masking.new_selector(ToolItemText.RECT_MASKING) def elli_masking_clicked(self, active) -> None: - self._check_masking_shapes(ToolItemText.ELLI_MASKING) + self.view.data_view.check_masking_shape_toolbar_icons(ToolItemText.ELLI_MASKING) + self.view.data_view.masking.new_selector(ToolItemText.ELLI_MASKING) def poly_masking_clicked(self, active) -> None: - self._check_masking_shapes(ToolItemText.POLY_MASKING) + self.view.data_view.check_masking_shape_toolbar_icons(ToolItemText.POLY_MASKING) + self.view.data_view.masking.new_selector(ToolItemText.POLY_MASKING) def export_masking_clicked(self) -> None: + # export masks to table workspace in ADS pass def apply_masking_clicked(self) -> None: + # create table workspace not in ADS (model) + # Apply table workspace to dataset pass diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/selector.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/selector.py new file mode 100644 index 000000000000..6aa02d64e1ef --- /dev/null +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/selector.py @@ -0,0 +1,58 @@ +from collections import namedtuple +from functools import lru_cache +from typing import Optional + +from matplotlib.image import AxesImage +from matplotlib.transforms import Bbox, BboxTransform +import numpy as np + +# Data type to store information related to a cursor over an image +CursorInfo = namedtuple("CursorInfo", ("array", "extent", "point")) + + +@lru_cache(maxsize=32) +def cursor_info(image: AxesImage, xdata: float, ydata: float, full_bbox: Bbox = None) -> Optional[CursorInfo]: + """Return information on the image for the given position in + data coordinates. + :param image: An instance of an image type + :param xdata: X data coordinate of cursor + :param ydata: Y data coordinate of cursor + :param full_bbox: Bbox of full workspace dimension to use for transforming mouse position + :return: None if point is not valid on the image else return CursorInfo type + """ + extent = image.get_extent() + xmin, xmax, ymin, ymax = extent + arr = image.get_array() + data_extent = Bbox([[ymin, xmin], [ymax, xmax]]) + array_extent = Bbox([[0, 0], arr.shape[:2]]) + if full_bbox is None: + trans = BboxTransform(boxin=data_extent, boxout=array_extent) + else: + # If the view is zoomed in and the slice is changed, then the image extents + # and data extents change. This causes the cursor to be transformed to the + # wrong point for certain MDH workspaces (since it cannot be dynamically rebinned). + # This will use the full WS data dimensions to do the transformation + trans = BboxTransform(boxin=full_bbox, boxout=array_extent) + point = trans.transform_point([ydata, xdata]) + if any(np.isnan(point)): + return None + + point = point.astype(int) + if 0 <= point[0] <= arr.shape[0] and 0 <= point[1] <= arr.shape[1]: + return CursorInfo(array=arr, extent=extent, point=point) + else: + return None + + +def make_selector_class(base): + def onmove(self, event): + """ + Only process event if inside the axes with which the selector was init + This fixes bug where the x/y of the event originated from the line plot axes not the colorfill axes + """ + if event.inaxes is None or self.ax == event.inaxes.axes: + super(SelectorMtd, self).onmove(event) + + SelectorMtd = type("SelectorMtd", (base,), {}) + SelectorMtd.onmove = onmove + return SelectorMtd diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/views/dataview.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/views/dataview.py index d89b93e51117..6823731ab4a6 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/views/dataview.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/views/dataview.py @@ -41,6 +41,10 @@ SCALENORM = "SliceViewer/scale_norm" POWERSCALE = "SliceViewer/scale_norm_power" +MASK_SHAPE_OPTIONS = [ToolItemText.RECT_MASKING, ToolItemText.ELLI_MASKING, ToolItemText.POLY_MASKING] +MASK_PROCESS_OPTIONS = [ToolItemText.APPLY_MASKING, ToolItemText.EXPORT_MASKING] +MASK_OPTIONS = MASK_SHAPE_OPTIONS + MASK_PROCESS_OPTIONS + class SliceViewerCanvas(ScrollZoomMixin, MantidFigureCanvas): pass @@ -67,6 +71,8 @@ def __init__( self._region_selection_on = False self._orig_lims = None + self._masking = None + self.colorbar_layout = QVBoxLayout() self.colorbar_layout.setContentsMargins(0, 0, 0, 0) self.colorbar_layout.setSpacing(0) @@ -199,6 +205,14 @@ def grid_on(self): def line_plotter(self): return self._line_plots + @property + def masking(self): + return self._masking + + @masking.setter + def masking(self, masking): + self._masking = masking + @property def nonorthogonal_mode(self): return self.nonortho_transform is not None @@ -492,7 +506,7 @@ def on_masking_clicked(self, state): """ React to masking clicked """ - self.presenter.masking_clicked(state) + self.presenter.masking(state) def on_rect_masking_clicked(self, state): """ @@ -524,6 +538,12 @@ def on_apply_masking_clicked(self): """ self.presenter.apply_masking_clicked() + def clear_masking_shapes(self): + """ + clear the masking shapes on the image + """ + pass + def deactivate_and_disable_tool(self, tool_text): """Deactivate a tool as if the control had been pressed and disable the functionality""" self.deactivate_tool(tool_text) @@ -764,3 +784,13 @@ def scale_norm_changed(self): def on_resize(self): if not self.line_plots_active and self.ax: self.ax.figure.tight_layout() # tight_layout doesn't work with LinePlots enabled atm + + def toggle_masking_options(self, active): + fn = self.enable_tool_button if active else self.disable_tool_button + for opt in MASK_OPTIONS: + fn(opt) + + def check_masking_shape_toolbar_icons(self, shape): + for opt in MASK_SHAPE_OPTIONS: + fn = self.activate_tool if opt == shape else self.deactivate_tool + fn(opt, False) diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/views/dataviewsubscriber.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/views/dataviewsubscriber.py index 7f37d82e2897..ca9cce92be90 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/views/dataviewsubscriber.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/views/dataviewsubscriber.py @@ -54,7 +54,7 @@ def zoom_pan_clicked(self, active) -> None: pass @abstractmethod - def masking_clicked(self, active) -> None: + def masking(self, active) -> None: pass @abstractmethod From 4a80592a984d6ab7fa0085ad216352d829a240ec Mon Sep 17 00:00:00 2001 From: MialLewis <95620982+MialLewis@users.noreply.github.com> Date: Thu, 14 Aug 2025 22:18:46 +0100 Subject: [PATCH 04/33] add selector deactivation --- .../sliceviewer/presenters/base_presenter.py | 10 ++-- .../widgets/sliceviewer/presenters/masking.py | 53 +++++++++++++------ .../widgets/sliceviewer/views/dataview.py | 6 --- 3 files changed, 43 insertions(+), 26 deletions(-) diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/base_presenter.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/base_presenter.py index e703edf083a2..335d2190b80a 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/base_presenter.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/base_presenter.py @@ -135,11 +135,13 @@ def masking(self, active) -> None: self._data_view.masking.new_selector(ToolItemText.RECT_MASKING) # default to rect masking self._data_view.activate_tool(ToolItemText.RECT_MASKING, True) return - self._data_view.enable_tool(ToolItemText.ZOOM) - self._data_view.enable_tool(ToolItemText.PAN) - self._data_view.enable_tool(ToolItemText.REGIONSELECTION) - self._data_view.clear_masking_shapes() + self._data_view.enable_tool_button(ToolItemText.ZOOM) + self._data_view.enable_tool_button(ToolItemText.PAN) + self._data_view.enable_tool_button(ToolItemText.REGIONSELECTION) + self._data_view.masking.clear() + self._data_view.masking = None self._data_view.check_masking_shape_toolbar_icons(None) + self._data_view.canvas.draw_idle() @abc.abstractmethod def get_extra_image_info_columns(self, xdata, ydata): diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/masking.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/masking.py index 0476446bd2ba..4ec8e3174216 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/masking.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/masking.py @@ -1,6 +1,6 @@ from matplotlib.widgets import RectangleSelector, EllipseSelector, PolygonSelector from matplotlib.axes import Axes -from abc import ABC +from abc import ABC, abstractmethod from mantidqt.widgets.sliceviewer.views.toolbar import ToolItemText from .selector import make_selector_class, cursor_info @@ -38,6 +38,11 @@ def export_selectors(self): def apply_selectors(self): pass + def clear(self): + self._active_selector.set_active(False) + for selector in self._selectors: + selector.clear() + class SelectionMaskingBase(ABC): def __init__(self, ax): @@ -46,26 +51,17 @@ def __init__(self, ax): self.mask_drawn = False self._selector = None + @abstractmethod def _on_selected(self, eclick, erelease): - """ - Callback when a shape has been draw on the axes - :param eclick: Event marking where the mouse was clicked - :param erelease: Event marking where the mouse was released - """ - cinfo_click = cursor_info(self._img, eclick.xdata, eclick.ydata) - if cinfo_click is None: - return - cinfo_release = cursor_info(self._img, erelease.xdata, erelease.ydata) - if cinfo_release is None: - return - - # Add shape object to collection - self._cursor_info = (cinfo_click, cinfo_release) - self.mask_drawn = True + pass def set_active(self, state): self._selector.set_active(state) + def clear(self): + for artist in self._selector.artists: + artist.remove() + class RectangleSelectionMaskingBase(SelectionMaskingBase): """ @@ -85,6 +81,23 @@ def __init__(self, ax: Axes, selector): interactive=True, ) + def _on_selected(self, eclick, erelease): + """ + Callback when a shape has been draw on the axes + :param eclick: Event marking where the mouse was clicked + :param erelease: Event marking where the mouse was released + """ + cinfo_click = cursor_info(self._img, eclick.xdata, eclick.ydata) + if cinfo_click is None: + return + cinfo_release = cursor_info(self._img, erelease.xdata, erelease.ydata) + if cinfo_release is None: + return + + # Add shape object to collection + self._cursor_info = (cinfo_click, cinfo_release) + self.mask_drawn = True + class RectangleSelectionMasking(RectangleSelectionMaskingBase): """ @@ -116,3 +129,11 @@ def __init__(self, ax: Axes): self._on_selected, useblit=False, # rectangle persists on button release ) + + def _on_selected(self, eclick, erelease=None): + """ + Callback when a polgyon shape has been draw on the axes + :param eclick: Event marking where the mouse was clicked + :param erelease: None for polygon selector + """ + self.mask_drawn = True diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/views/dataview.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/views/dataview.py index 6823731ab4a6..b83ad3e6628a 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/views/dataview.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/views/dataview.py @@ -538,12 +538,6 @@ def on_apply_masking_clicked(self): """ self.presenter.apply_masking_clicked() - def clear_masking_shapes(self): - """ - clear the masking shapes on the image - """ - pass - def deactivate_and_disable_tool(self, tool_text): """Deactivate a tool as if the control had been pressed and disable the functionality""" self.deactivate_tool(tool_text) From 1319459a5d85d75cf65cc3bc01173d29bfc15880 Mon Sep 17 00:00:00 2001 From: MialLewis <95620982+MialLewis@users.noreply.github.com> Date: Fri, 15 Aug 2025 14:37:21 +0100 Subject: [PATCH 05/33] fix issues with weak refs to selectors --- .../sliceviewer/presenters/base_presenter.py | 8 ++++++-- .../widgets/sliceviewer/presenters/masking.py | 15 ++++++++++++--- .../widgets/sliceviewer/presenters/selector.py | 14 ++++++++++++-- 3 files changed, 30 insertions(+), 7 deletions(-) diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/base_presenter.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/base_presenter.py index 335d2190b80a..0a29302ae519 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/base_presenter.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/base_presenter.py @@ -138,11 +138,15 @@ def masking(self, active) -> None: self._data_view.enable_tool_button(ToolItemText.ZOOM) self._data_view.enable_tool_button(ToolItemText.PAN) self._data_view.enable_tool_button(ToolItemText.REGIONSELECTION) - self._data_view.masking.clear() - self._data_view.masking = None + self._clean_up_masking() self._data_view.check_masking_shape_toolbar_icons(None) self._data_view.canvas.draw_idle() + def _clean_up_masking(self): + self._data_view.masking.clear_and_disconnect() + self._data_view.canvas.flush_events() # flush before we set masking to None + self._data_view.masking = None + @abc.abstractmethod def get_extra_image_info_columns(self, xdata, ydata): pass diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/masking.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/masking.py index 4ec8e3174216..0d75f445201b 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/masking.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/masking.py @@ -17,9 +17,14 @@ def __init__(self, ax): self._ax = ax def _new_selector(self, selector_type): - if self._active_selector and self._active_selector.mask_drawn: - self._selectors.append(self._active_selector) + if self._active_selector: self._active_selector.set_active(False) + self._active_selector.disconnect() + if self._active_selector.mask_drawn: + self._selectors.append(self._active_selector) + else: + self._active_selector.clear() + self._active_selector = selector_type(self._ax) self._active_selector.set_active(True) @@ -38,8 +43,9 @@ def export_selectors(self): def apply_selectors(self): pass - def clear(self): + def clear_and_disconnect(self): self._active_selector.set_active(False) + self._active_selector.disconnect() for selector in self._selectors: selector.clear() @@ -62,6 +68,9 @@ def clear(self): for artist in self._selector.artists: artist.remove() + def disconnect(self): + self._selector.disconnect_events() + class RectangleSelectionMaskingBase(SelectionMaskingBase): """ diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/selector.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/selector.py index 6aa02d64e1ef..fbd6393c30a1 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/selector.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/selector.py @@ -45,12 +45,22 @@ def cursor_info(image: AxesImage, xdata: float, ydata: float, full_bbox: Bbox = def make_selector_class(base): - def onmove(self, event): + def in_axes_event(self, event): """ Only process event if inside the axes with which the selector was init This fixes bug where the x/y of the event originated from the line plot axes not the colorfill axes """ - if event.inaxes is None or self.ax == event.inaxes.axes: + return event.inaxes is None or self.ax == event.inaxes.axes + + def invalid_first_event(self, event): + """ + Do not process the first event if there is no x/ydata. + This fixes bug where the mpl tries to access the previous event given the lack of x/yata, which is None + """ + return (not event.xdata or not event.ydata) and not self._prev_event + + def onmove(self, event): + if in_axes_event(self, event) and not invalid_first_event(self, event): super(SelectorMtd, self).onmove(event) SelectorMtd = type("SelectorMtd", (base,), {}) From 4ff221b94e7d5b5c4c761c45586cac10524a4c65 Mon Sep 17 00:00:00 2001 From: MialLewis <95620982+MialLewis@users.noreply.github.com> Date: Fri, 15 Aug 2025 18:24:01 +0100 Subject: [PATCH 06/33] finalise shape toolbar behaviour --- .../sliceviewer/presenters/base_presenter.py | 2 +- .../widgets/sliceviewer/presenters/masking.py | 144 +++++++++++------- 2 files changed, 89 insertions(+), 57 deletions(-) diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/base_presenter.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/base_presenter.py index 0a29302ae519..5646bc1e8b33 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/base_presenter.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/base_presenter.py @@ -131,7 +131,7 @@ def masking(self, active) -> None: self._data_view.deactivate_and_disable_tool(ToolItemText.ZOOM) self._data_view.deactivate_and_disable_tool(ToolItemText.PAN) self._data_view.deactivate_and_disable_tool(ToolItemText.REGIONSELECTION) - self._data_view.masking = Masking(self._data_view.ax) + self._data_view.masking = Masking(self._data_view) self._data_view.masking.new_selector(ToolItemText.RECT_MASKING) # default to rect masking self._data_view.activate_tool(ToolItemText.RECT_MASKING, True) return diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/masking.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/masking.py index 0d75f445201b..da0fa3939208 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/masking.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/masking.py @@ -1,61 +1,21 @@ from matplotlib.widgets import RectangleSelector, EllipseSelector, PolygonSelector -from matplotlib.axes import Axes from abc import ABC, abstractmethod from mantidqt.widgets.sliceviewer.views.toolbar import ToolItemText from .selector import make_selector_class, cursor_info - -class Masking: - """ - Manages Masking - """ - - def __init__(self, ax): - self._selectors = [] - self._active_selector = None - self._ax = ax - - def _new_selector(self, selector_type): - if self._active_selector: - self._active_selector.set_active(False) - self._active_selector.disconnect() - if self._active_selector.mask_drawn: - self._selectors.append(self._active_selector) - else: - self._active_selector.clear() - - self._active_selector = selector_type(self._ax) - self._active_selector.set_active(True) - - def new_selector(self, text: str): - match text: - case ToolItemText.RECT_MASKING: - self._new_selector(RectangleSelectionMasking) - case ToolItemText.ELLI_MASKING: - self._new_selector(EllipticalSelectionMasking) - case ToolItemText.POLY_MASKING: - self._new_selector(PolygonSelectionMasking) - - def export_selectors(self): - pass - - def apply_selectors(self): - pass - - def clear_and_disconnect(self): - self._active_selector.set_active(False) - self._active_selector.disconnect() - for selector in self._selectors: - selector.clear() +SHAPE_STYLE = {"alpha": 0.5, "linewidth": 1.75, "color": "black", "linestyle": "-"} +HANDLE_STYLE = {"alpha": 0.5, "markerfacecolor": "gray", "markersize": 4, "markeredgecolor": "gray"} +INACTIVE_HANDLE_STYLE = {"alpha": 0.5, "markerfacecolor": "gray", "markersize": 4, "markeredgecolor": "gray"} +INACTIVE_SHAPE_STYLE = {"alpha": 0.5, "linewidth": 1.75, "color": "gray", "linestyle": "-"} class SelectionMaskingBase(ABC): - def __init__(self, ax): - self._img = ax.images[0] + def __init__(self, dataview): self._cursor_info = None self.mask_drawn = False self._selector = None + self._dataview = dataview @abstractmethod def _on_selected(self, eclick, erelease): @@ -71,16 +31,24 @@ def clear(self): def disconnect(self): self._selector.disconnect_events() + @abstractmethod + def set_inactive_color(self): + pass + + @property + def _img(self): + return self._dataview.ax.images[0] + class RectangleSelectionMaskingBase(SelectionMaskingBase): """ Base class for a selector with a `RectangleSelector` base. """ - def __init__(self, ax: Axes, selector): - super().__init__(ax) + def __init__(self, dataview, selector): + super().__init__(dataview) self._selector = make_selector_class(selector)( - ax, + dataview.ax, self._on_selected, useblit=False, # rectangle persists on button release button=[1], @@ -88,6 +56,9 @@ def __init__(self, ax: Axes, selector): minspany=5, spancoords="pixels", interactive=True, + props={"fill": False, **SHAPE_STYLE}, + handle_props=HANDLE_STYLE, + ignore_event_outside=True, ) def _on_selected(self, eclick, erelease): @@ -106,6 +77,11 @@ def _on_selected(self, eclick, erelease): # Add shape object to collection self._cursor_info = (cinfo_click, cinfo_release) self.mask_drawn = True + self._dataview.mpl_toolbar.set_action_checked(SELECTOR_TO_TOOL_ITEM_TEXT[self.__class__], False, trigger=False) + + def set_inactive_color(self): + self._selector.set_props(fill=False, **INACTIVE_SHAPE_STYLE) + self._selector.set_handle_props(**INACTIVE_HANDLE_STYLE) class RectangleSelectionMasking(RectangleSelectionMaskingBase): @@ -113,8 +89,8 @@ class RectangleSelectionMasking(RectangleSelectionMaskingBase): Draws a mask from a rectangular selection """ - def __init__(self, ax): - super().__init__(ax, RectangleSelector) + def __init__(self, dataview): + super().__init__(dataview, RectangleSelector) class EllipticalSelectionMasking(RectangleSelectionMaskingBase): @@ -122,8 +98,8 @@ class EllipticalSelectionMasking(RectangleSelectionMaskingBase): Draws a mask from an elliptical selection """ - def __init__(self, ax): - super().__init__(ax, EllipseSelector) + def __init__(self, dataview): + super().__init__(dataview, EllipseSelector) class PolygonSelectionMasking(SelectionMaskingBase): @@ -131,12 +107,14 @@ class PolygonSelectionMasking(SelectionMaskingBase): Draws a mask from a polygon selection """ - def __init__(self, ax: Axes): - super().__init__(ax) + def __init__(self, dataview): + super().__init__(dataview) self._selector = make_selector_class(PolygonSelector)( - ax, + dataview.ax, self._on_selected, useblit=False, # rectangle persists on button release + props=SHAPE_STYLE, + handle_props=HANDLE_STYLE, ) def _on_selected(self, eclick, erelease=None): @@ -146,3 +124,57 @@ def _on_selected(self, eclick, erelease=None): :param erelease: None for polygon selector """ self.mask_drawn = True + self._dataview.mpl_toolbar.set_action_checked(SELECTOR_TO_TOOL_ITEM_TEXT[self.__class__], False, trigger=False) + + def set_inactive_color(self): + self._selector.set_props(**INACTIVE_SHAPE_STYLE) + self._selector.set_handle_props(**INACTIVE_HANDLE_STYLE) + + +TOOL_ITEM_TEXT_TO_SELECTOR = { + ToolItemText.RECT_MASKING: RectangleSelectionMasking, + ToolItemText.ELLI_MASKING: EllipticalSelectionMasking, + ToolItemText.POLY_MASKING: PolygonSelectionMasking, +} +SELECTOR_TO_TOOL_ITEM_TEXT = {v: k for k, v in TOOL_ITEM_TEXT_TO_SELECTOR.items()} + + +class Masking: + """ + Manages Masking + """ + + def __init__(self, dataview): + self._selectors = [] + self._active_selector = None + self._dataview = dataview + + def _new_selector(self, selector_type): + if self._active_selector: + self._active_selector.set_active(False) + self._active_selector.disconnect() + if self._active_selector.mask_drawn: + self._active_selector.set_inactive_color() + self._selectors.append(self._active_selector) + else: + self._active_selector.clear() + + self._active_selector = selector_type(self._dataview) + self._active_selector.set_active(True) + + def new_selector(self, text: str): + self._new_selector(TOOL_ITEM_TEXT_TO_SELECTOR[text]) + + def export_selectors(self): + pass + + def apply_selectors(self): + pass + + def clear_and_disconnect(self): + self._active_selector.set_active(False) + self._active_selector.disconnect() + if self._active_selector.mask_drawn: + self._active_selector.clear() + for selector in self._selectors: + selector.clear() From 71aebafb7382c86be6ce6df067f8ee81572864cb Mon Sep 17 00:00:00 2001 From: MialLewis <95620982+MialLewis@users.noreply.github.com> Date: Mon, 18 Aug 2025 16:35:38 +0100 Subject: [PATCH 07/33] first pass model --- .../widgets/sliceviewer/models/masking.py | 73 +++++++++++++++++++ .../sliceviewer/presenters/base_presenter.py | 9 +-- .../widgets/sliceviewer/presenters/masking.py | 65 +++++++++++------ .../sliceviewer/presenters/presenter.py | 8 +- .../widgets/sliceviewer/views/toolbar.py | 4 +- 5 files changed, 125 insertions(+), 34 deletions(-) diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py index e69de29bb2d1..bcbbd5ea01b3 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py @@ -0,0 +1,73 @@ +from abc import ABC, abstractmethod + + +class CursorInfoBase(ABC): + def __init__(self): + self._table_rows = None + + @abstractmethod + def generate_table_rows(self): + pass + + @property + def table_rows(self): + return self._table_rows + + +class RectCursorInfo(CursorInfoBase): + def __init__(self, click, release): + self._click = click + self._release = release + + def generate_table_rows(self): + return [] + + +class PolyCursorInfo(CursorInfoBase): + def __init__(self, nodes): + self._nodes = nodes + + def generate_table_rows(self): + return [] + + +class MaskingModel: + def __init__(self): + self._active_mask = None + self._masks = [] + + def update_active_mask(self, mask): + self._active_mask = mask + + def clear_active_mask(self): + self._active_mask = None + + def store_active_mask(self): + self._masks.append(self._active_mask) + self._active_mask = None + + def clear_stored_masks(self): + self._masks = [] + + def add_rect_cursor_info(self, click, release): + self.update_active_mask(RectCursorInfo(click=click, release=release)) + + def add_poly_cursor_info(self, nodes): + self.update_active_mask(PolyCursorInfo(nodes=nodes)) + + def create_table_workspace_from_rows(self, table_rows, store_in_ads): + # create table ws_from rows + return "ws" + + def generate_mask_table_ws(self, store_in_ads=True): + table_rows = [] + for info in self._masks: + table_rows.extend(info.generate_table_rows()) + return self.create_table_workspace_from_rows(table_rows, store_in_ads) + + def export_selectors(self): + _ = self.generate_mask_table_ws() + + def apply_selectors(self): + mask_ws = self.generate_mask_table_ws(store_in_ads=False) + # apply mask ws to underlying workspace diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/base_presenter.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/base_presenter.py index 5646bc1e8b33..d7017442e1db 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/base_presenter.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/base_presenter.py @@ -3,8 +3,7 @@ # Copyright © 2021 ISIS Rutherford Appleton Laboratory UKRI, # NScD Oak Ridge National Laboratory, European Spallation Source, # Institut Laue - Langevin & CSNS, Institute of High Energy Physics, CAS -import abc -from abc import ABC +from abc import ABC, abstractmethod from mantidqt.widgets.sliceviewer.models.base_model import SliceViewerBaseModel from mantidqt.widgets.sliceviewer.models.dimensions import Dimensions @@ -77,7 +76,7 @@ def new_plot_matrix(self): """Tell the view to display a new plot of an MatrixWorkspace""" self._data_view.plot_matrix(self.model.ws, distribution=not self.normalization) - @abc.abstractmethod + @abstractmethod def new_plot(self, *args, **kwargs): pass @@ -147,10 +146,10 @@ def _clean_up_masking(self): self._data_view.canvas.flush_events() # flush before we set masking to None self._data_view.masking = None - @abc.abstractmethod + @abstractmethod def get_extra_image_info_columns(self, xdata, ydata): pass - @abc.abstractmethod + @abstractmethod def is_integer_frame(self): pass diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/masking.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/masking.py index da0fa3939208..33f708189ad8 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/masking.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/masking.py @@ -3,6 +3,8 @@ from mantidqt.widgets.sliceviewer.views.toolbar import ToolItemText from .selector import make_selector_class, cursor_info +from mantidqt.widgets.sliceviewer.models.masking import MaskingModel + SHAPE_STYLE = {"alpha": 0.5, "linewidth": 1.75, "color": "black", "linestyle": "-"} HANDLE_STYLE = {"alpha": 0.5, "markerfacecolor": "gray", "markersize": 4, "markeredgecolor": "gray"} @@ -11,11 +13,11 @@ class SelectionMaskingBase(ABC): - def __init__(self, dataview): - self._cursor_info = None - self.mask_drawn = False + def __init__(self, dataview, model): + self._mask_drawn = False self._selector = None self._dataview = dataview + self._model = model @abstractmethod def _on_selected(self, eclick, erelease): @@ -39,14 +41,18 @@ def set_inactive_color(self): def _img(self): return self._dataview.ax.images[0] + @property + def mask_drawn(self): + return self._mask_drawn + class RectangleSelectionMaskingBase(SelectionMaskingBase): """ Base class for a selector with a `RectangleSelector` base. """ - def __init__(self, dataview, selector): - super().__init__(dataview) + def __init__(self, dataview, model, selector): + super().__init__(dataview, model) self._selector = make_selector_class(selector)( dataview.ax, self._on_selected, @@ -75,9 +81,9 @@ def _on_selected(self, eclick, erelease): return # Add shape object to collection - self._cursor_info = (cinfo_click, cinfo_release) - self.mask_drawn = True + self._mask_drawn = True self._dataview.mpl_toolbar.set_action_checked(SELECTOR_TO_TOOL_ITEM_TEXT[self.__class__], False, trigger=False) + self._model.add_rect_cursor_info(click=cinfo_click, release=cinfo_release) def set_inactive_color(self): self._selector.set_props(fill=False, **INACTIVE_SHAPE_STYLE) @@ -89,8 +95,8 @@ class RectangleSelectionMasking(RectangleSelectionMaskingBase): Draws a mask from a rectangular selection """ - def __init__(self, dataview): - super().__init__(dataview, RectangleSelector) + def __init__(self, dataview, model): + super().__init__(dataview, model, RectangleSelector) class EllipticalSelectionMasking(RectangleSelectionMaskingBase): @@ -98,8 +104,8 @@ class EllipticalSelectionMasking(RectangleSelectionMaskingBase): Draws a mask from an elliptical selection """ - def __init__(self, dataview): - super().__init__(dataview, EllipseSelector) + def __init__(self, dataview, model): + super().__init__(dataview, model, EllipseSelector) class PolygonSelectionMasking(SelectionMaskingBase): @@ -107,8 +113,8 @@ class PolygonSelectionMasking(SelectionMaskingBase): Draws a mask from a polygon selection """ - def __init__(self, dataview): - super().__init__(dataview) + def __init__(self, dataview, model): + super().__init__(dataview, model) self._selector = make_selector_class(PolygonSelector)( dataview.ax, self._on_selected, @@ -123,8 +129,10 @@ def _on_selected(self, eclick, erelease=None): :param eclick: Event marking where the mouse was clicked :param erelease: None for polygon selector """ - self.mask_drawn = True + self._mask_drawn = True self._dataview.mpl_toolbar.set_action_checked(SELECTOR_TO_TOOL_ITEM_TEXT[self.__class__], False, trigger=False) + nodes = [cursor_info(self._img, node[0], node[1]) for node in eclick] + self._model.add_poly_cursor_info(nodes) def set_inactive_color(self): self._selector.set_props(**INACTIVE_SHAPE_STYLE) @@ -148,33 +156,46 @@ def __init__(self, dataview): self._selectors = [] self._active_selector = None self._dataview = dataview + self._model = MaskingModel() - def _new_selector(self, selector_type): + def _reset_active_selector(self): if self._active_selector: self._active_selector.set_active(False) self._active_selector.disconnect() if self._active_selector.mask_drawn: self._active_selector.set_inactive_color() self._selectors.append(self._active_selector) + self._model.store_active_mask() else: self._active_selector.clear() + self._model.clear_active_mask() + self._active_selector = None - self._active_selector = selector_type(self._dataview) + def _new_selector(self, selector_type): + self._reset_active_selector() + self._active_selector = selector_type(self._dataview, self._model) self._active_selector.set_active(True) def new_selector(self, text: str): self._new_selector(TOOL_ITEM_TEXT_TO_SELECTOR[text]) def export_selectors(self): - pass + self._reset_active_selector() + self._model.export_selectors() def apply_selectors(self): - pass + self._reset_active_selector() + self._model.apply_selectors() def clear_and_disconnect(self): - self._active_selector.set_active(False) - self._active_selector.disconnect() - if self._active_selector.mask_drawn: - self._active_selector.clear() + if self._active_selector: + self._active_selector.set_active(False) + self._active_selector.disconnect() + if self._active_selector.mask_drawn: + self._active_selector.clear() for selector in self._selectors: selector.clear() + + def clear_model(self): + self._model.clear_active_mask() + self._model.clear_stored_masks() diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/presenter.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/presenter.py index 9c96746dc7f8..2fbb199622ef 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/presenter.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/presenter.py @@ -610,13 +610,11 @@ def poly_masking_clicked(self, active) -> None: self.view.data_view.masking.new_selector(ToolItemText.POLY_MASKING) def export_masking_clicked(self) -> None: - # export masks to table workspace in ADS - pass + self.view.data_view.masking.export_selectors() def apply_masking_clicked(self) -> None: - # create table workspace not in ADS (model) - # Apply table workspace to dataset - pass + # warn about mutating underlying data. + self.view.data_view.masking.apply_selectors() class SliceViewXAxisEditor(XAxisEditor): diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/views/toolbar.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/views/toolbar.py index fc271950c73d..571d64cc2028 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/views/toolbar.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/views/toolbar.py @@ -69,8 +69,8 @@ class SliceViewerNavigationToolbar(MantidNavigationToolbar): MantidNavigationTool(ToolItemText.RECT_MASKING, "Select rectangle mask", "mdi.square-outline", "rectMaskingClicked", False), MantidNavigationTool(ToolItemText.ELLI_MASKING, "Select elliptical mask", "mdi.circle-outline", "elliMaskingClicked", False), MantidNavigationTool(ToolItemText.POLY_MASKING, "Select polygon mask", "mdi.triangle-outline", "polyMaskingClicked", False), - MantidNavigationTool(ToolItemText.APPLY_MASKING, "Apply drawn mask", "mdi.checkbox-marked-outline", "applyMaskingClicked", False), - MantidNavigationTool(ToolItemText.EXPORT_MASKING, "Export drawn mask to table", "mdi.export", "exportMaskingClicked", False), + MantidNavigationTool(ToolItemText.APPLY_MASKING, "Apply drawn mask", "mdi.checkbox-marked-outline", "applyMaskingClicked", None), + MantidNavigationTool(ToolItemText.EXPORT_MASKING, "Export drawn mask to table", "mdi.export", "exportMaskingClicked", None), MantidStandardNavigationTools.SEPARATOR, MantidNavigationTool(ToolItemText.SAVE, "Save the figure", "mdi.content-save", "save_figure", None), ) From edde91eae8629ab744a22eafd01fdd01a4a9d8da Mon Sep 17 00:00:00 2001 From: MialLewis <95620982+MialLewis@users.noreply.github.com> Date: Tue, 19 Aug 2025 15:49:08 +0100 Subject: [PATCH 08/33] add rect and elliptical masking to model --- .../Algorithms/src/MaskBinsFromTable.cpp | 5 +- .../widgets/sliceviewer/models/masking.py | 71 ++++++++++++++++++- .../sliceviewer/presenters/imageinfowidget.py | 3 +- .../sliceviewer/presenters/lineplots.py | 6 +- .../widgets/sliceviewer/presenters/masking.py | 14 +++- .../sliceviewer/presenters/selector.py | 4 +- 6 files changed, 90 insertions(+), 13 deletions(-) diff --git a/Framework/Algorithms/src/MaskBinsFromTable.cpp b/Framework/Algorithms/src/MaskBinsFromTable.cpp index 496712028c40..a579f67c1186 100644 --- a/Framework/Algorithms/src/MaskBinsFromTable.cpp +++ b/Framework/Algorithms/src/MaskBinsFromTable.cpp @@ -75,7 +75,7 @@ void MaskBinsFromTable::maskBins(const API::MatrixWorkspace_sptr &dataws) { maskbins->initialize(); // Set properties - g_log.debug() << "Input to MaskBins: SpetraList = '" << m_spectraVec[ib] << "'; Xmin = " << m_xminVec[ib] + g_log.debug() << "Input to MaskBins: SpectraList = '" << m_spectraVec[ib] << "'; Xmin = " << m_xminVec[ib] << ", Xmax = " << m_xmaxVec[ib] << ".\n"; if (firstloop) { @@ -87,7 +87,8 @@ void MaskBinsFromTable::maskBins(const API::MatrixWorkspace_sptr &dataws) { maskbins->setProperty("InputWorkspace", outputws); } maskbins->setProperty("OutputWorkspace", this->getPropertyValue("OutputWorkspace")); - maskbins->setPropertyValue("SpectraList", m_spectraVec[ib]); + maskbins->setPropertyValue("InputWorkspaceIndexSet", m_spectraVec[ib]); + maskbins->setPropertyValue("InputWorkspaceIndexType", "SpectrumNumber"); maskbins->setProperty("XMin", m_xminVec[ib]); maskbins->setProperty("XMax", m_xmaxVec[ib]); diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py index bcbbd5ea01b3..7d2a5dd0631d 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py @@ -1,4 +1,14 @@ from abc import ABC, abstractmethod +from dataclasses import dataclass +from math import floor, ceil, sqrt +from mantid.api import WorkspaceFactory, AnalysisDataService + + +@dataclass +class TableRow: + spec_list: str + x_min: float + x_max: float class CursorInfoBase(ABC): @@ -14,17 +24,60 @@ def table_rows(self): return self._table_rows -class RectCursorInfo(CursorInfoBase): +class RectCursorInfoBase(CursorInfoBase, ABC): def __init__(self, click, release): + super().__init__() self._click = click self._release = release + def get_xy_data(self): + y_data = [self._click.data[1], self._release.data[1]] + x_data = [self._click.data[0], self._release.data[0]] + y_data.sort() + x_data.sort() + return x_data, y_data + + +class RectCursorInfo(RectCursorInfoBase): + def __init__(self, click, release): + super().__init__(click, release) + def generate_table_rows(self): - return [] + x_data, y_data = self.get_xy_data() + row = TableRow(spec_list=f"{floor(y_data[0])}-{ceil(y_data[-1])}", x_min=x_data[0], x_max=x_data[-1]) + return [row] + + +class ElliCursorInfo(RectCursorInfoBase): + def __init__(self, click, release): + super().__init__(click, release) + + def generate_table_rows(self): + x_data, y_data = self.get_xy_data() + y_min = floor(y_data[0]) # Need to consider numeric axes + y_max = ceil(y_data[-1]) + x_min, x_max = x_data[0], x_data[-1] + a = (x_max - x_min) / 2 + b = (y_max - y_min) / 2 + h = x_min + a + k = y_min + b + rows = [] + for y in range(y_min, y_max + 1): + x_min, x_max = self._calc_x_val(y, a, b, h, k) + rows.append(TableRow(spec_list=str(y), x_min=x_min, x_max=x_max)) + return rows + + def _calc_x_val(self, y, a, b, h, k): + return (h - self._calc_sqrt_portion(y, a, b, k)), (h + self._calc_sqrt_portion(y, a, b, k)) + + @staticmethod + def _calc_sqrt_portion(y, a, b, k): + return sqrt((a**2) * (1 - ((y - k) ** 2) / (b**2))) class PolyCursorInfo(CursorInfoBase): def __init__(self, nodes): + super().__init__() self._nodes = nodes def generate_table_rows(self): @@ -52,12 +105,24 @@ def clear_stored_masks(self): def add_rect_cursor_info(self, click, release): self.update_active_mask(RectCursorInfo(click=click, release=release)) + def add_elli_cursor_info(self, click, release): + self.update_active_mask(ElliCursorInfo(click=click, release=release)) + def add_poly_cursor_info(self, nodes): self.update_active_mask(PolyCursorInfo(nodes=nodes)) def create_table_workspace_from_rows(self, table_rows, store_in_ads): # create table ws_from rows - return "ws" + table_ws = WorkspaceFactory.createTable() + table_ws.addColumn("str", "SpectraList") + table_ws.addColumn("double", "XMin") + table_ws.addColumn("double", "XMax") + for row in table_rows: + if not row.x_min == row.x_max: # the min and max of the ellipse + table_ws.addRow([row.spec_list, row.x_min, row.x_max]) + if store_in_ads: + AnalysisDataService.addOrReplace("svmask_ws", table_ws) + return table_ws def generate_mask_table_ws(self, store_in_ads=True): table_rows = [] diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/imageinfowidget.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/imageinfowidget.py index ade135ef7946..d645a2275bad 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/imageinfowidget.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/imageinfowidget.py @@ -79,7 +79,8 @@ def _on_cursor_at_axesimage(self, xdata: float, ydata: float): return cinfo = cursor_info(self._image, xdata, ydata, full_bbox=self._cursor_transform) if cinfo is not None: - arr, _, (i, j) = cinfo + arr = cinfo.array + i, j = cinfo.point if (0 <= i < arr.shape[0]) and (0 <= j < arr.shape[1]) and not np.ma.is_masked(arr[i, j]): extra_cols = self._presenter.get_extra_image_info_columns(xdata, ydata) if (not self._cursor_transform) and self._image.transpose: diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/lineplots.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/lineplots.py index fcaa4a5b5159..b348c9386eb2 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/lineplots.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/lineplots.py @@ -289,7 +289,7 @@ def on_cursor_at(self, xdata: float, ydata: float): cinfo = cursor_info(plotter.image, xdata, ydata) if cinfo is not None: self._cursor_pos = (xdata, ydata) - arr, (xmin, xmax, ymin, ymax), (i, j) = cinfo + arr, (xmin, xmax, ymin, ymax), (i, j), _ = cinfo plotter.plot_x_line(np.linspace(xmin, xmax, arr.shape[1]), arr[i, :]) plotter.plot_y_line(np.linspace(ymin, ymax, arr.shape[0]), arr[:, j]) plotter.sync_plot_limits_with_colorbar() @@ -387,8 +387,8 @@ def _on_rectangle_selected(self, eclick, erelease): if cinfo_release is None: return - arr, (xmin, xmax, ymin, ymax), (imin, jmin) = cinfo_click - _, __, (imax, jmax) = cinfo_release + arr, (xmin, xmax, ymin, ymax), (imin, jmin), _ = cinfo_click + imax, jmax = cinfo_release.point plotter.plot_x_line(np.linspace(xmin, xmax, arr.shape[1])[jmin:jmax], np.sum(arr[imin:imax, jmin:jmax], axis=0)) plotter.plot_y_line(np.linspace(ymin, ymax, arr.shape[0])[imin:imax], np.sum(arr[imin:imax, jmin:jmax], axis=1)) plotter.update_line_plot_limits() diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/masking.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/masking.py index 33f708189ad8..9aa065fd5b71 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/masking.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/masking.py @@ -46,7 +46,7 @@ def mask_drawn(self): return self._mask_drawn -class RectangleSelectionMaskingBase(SelectionMaskingBase): +class RectangleSelectionMaskingBase(SelectionMaskingBase, ABC): """ Base class for a selector with a `RectangleSelector` base. """ @@ -67,6 +67,10 @@ def __init__(self, dataview, model, selector): ignore_event_outside=True, ) + @abstractmethod + def add_cursor_info(self, click, release): + pass + def _on_selected(self, eclick, erelease): """ Callback when a shape has been draw on the axes @@ -83,7 +87,7 @@ def _on_selected(self, eclick, erelease): # Add shape object to collection self._mask_drawn = True self._dataview.mpl_toolbar.set_action_checked(SELECTOR_TO_TOOL_ITEM_TEXT[self.__class__], False, trigger=False) - self._model.add_rect_cursor_info(click=cinfo_click, release=cinfo_release) + self.add_cursor_info(cinfo_click, cinfo_release) def set_inactive_color(self): self._selector.set_props(fill=False, **INACTIVE_SHAPE_STYLE) @@ -98,6 +102,9 @@ class RectangleSelectionMasking(RectangleSelectionMaskingBase): def __init__(self, dataview, model): super().__init__(dataview, model, RectangleSelector) + def add_cursor_info(self, click, release): + self._model.add_rect_cursor_info(click=click, release=release) + class EllipticalSelectionMasking(RectangleSelectionMaskingBase): """ @@ -107,6 +114,9 @@ class EllipticalSelectionMasking(RectangleSelectionMaskingBase): def __init__(self, dataview, model): super().__init__(dataview, model, EllipseSelector) + def add_cursor_info(self, click, release): + self._model.add_elli_cursor_info(click=click, release=release) + class PolygonSelectionMasking(SelectionMaskingBase): """ diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/selector.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/selector.py index fbd6393c30a1..f75dea73db40 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/selector.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/selector.py @@ -7,7 +7,7 @@ import numpy as np # Data type to store information related to a cursor over an image -CursorInfo = namedtuple("CursorInfo", ("array", "extent", "point")) +CursorInfo = namedtuple("CursorInfo", ("array", "extent", "point", "data")) @lru_cache(maxsize=32) @@ -39,7 +39,7 @@ def cursor_info(image: AxesImage, xdata: float, ydata: float, full_bbox: Bbox = point = point.astype(int) if 0 <= point[0] <= arr.shape[0] and 0 <= point[1] <= arr.shape[1]: - return CursorInfo(array=arr, extent=extent, point=point) + return CursorInfo(array=arr, extent=extent, point=point, data=(xdata, ydata)) else: return None From 9ffe34c061086f13033e2098d865b20992458cad Mon Sep 17 00:00:00 2001 From: MialLewis <95620982+MialLewis@users.noreply.github.com> Date: Tue, 19 Aug 2025 18:55:02 +0100 Subject: [PATCH 09/33] add intersecting line logic --- .../widgets/sliceviewer/models/masking.py | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py index 7d2a5dd0631d..c6dd0762900d 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py @@ -24,6 +24,14 @@ def table_rows(self): return self._table_rows +@dataclass +class Line: + start: float + end: float + m: float + c: float + + class RectCursorInfoBase(CursorInfoBase, ABC): def __init__(self, click, release): super().__init__() @@ -81,8 +89,58 @@ def __init__(self, nodes): self._nodes = nodes def generate_table_rows(self): + lines = self._generate_lines() + intersecting_lines = self._check_intersecting_lines(lines) + if intersecting_lines > 1: + raise RuntimeError("Polygon shapes with more than 1 intersection point are not supported.") return [] + def _generate_lines(self): + node_count = len(self._nodes) + lines = [] + for i in range(node_count): + line = (self._nodes[i].data, self._nodes[i + 1].data) if i < node_count - 1 else (self._nodes[i].data, self._nodes[0].data) + lines.append(self._generate_line(*line)) + return lines + + @staticmethod + def _generate_line(start, end): + m = (start[1] - end[1]) / (start[0] - end[0]) + c = start[1] - m * start[0] + return Line(start=start, end=end, m=m, c=c) + + def _check_intersecting_lines(self, lines): + line_count = len(lines) + cache = [] + intersecting_lines = 0 + for i in range(line_count): + for j in range(line_count): + pair = [i, j] + pair.sort() + if i != j and pair not in cache: + cache.append(pair) + if self._intersecting_line(lines[i], lines[j]): + intersecting_lines += 1 + return intersecting_lines + + @staticmethod + def _intersecting_line(line_1, line_2): + # if gradients are equal, or if lines intersect at a node + if line_1.m == line_2.m or (line_1.start == line_2.end or line_2.start == line_1.end): + return False + x = (line_1.c - line_2.c) / (line_2.m - line_1.m) + x_data = [line_1.start[0], line_1.end[0], line_2.start[0], line_2.end[0]] + x_data.sort() + if not (x_data[1] < x < x_data[2]): + return False + + y = line_1.m * x + line_1.c + y_data = [line_1.start[1], line_1.end[1], line_2.start[1], line_2.end[1]] + y_data.sort() + if not (y_data[1] < y < y_data[2]): + return False + return True + class MaskingModel: def __init__(self): From 06741868c82b5b47dc2053888021df5b0bc8b438 Mon Sep 17 00:00:00 2001 From: MialLewis <95620982+MialLewis@users.noreply.github.com> Date: Wed, 20 Aug 2025 11:04:19 +0100 Subject: [PATCH 10/33] add polygon masking model logic --- .../widgets/sliceviewer/models/masking.py | 54 ++++++++++++++----- 1 file changed, 42 insertions(+), 12 deletions(-) diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py index c6dd0762900d..0e725dca45dd 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py @@ -2,6 +2,7 @@ from dataclasses import dataclass from math import floor, ceil, sqrt from mantid.api import WorkspaceFactory, AnalysisDataService +from sys import float_info @dataclass @@ -39,10 +40,8 @@ def __init__(self, click, release): self._release = release def get_xy_data(self): - y_data = [self._click.data[1], self._release.data[1]] - x_data = [self._click.data[0], self._release.data[0]] - y_data.sort() - x_data.sort() + y_data = sorted([self._click.data[1], self._release.data[1]]) + x_data = sorted([self._click.data[0], self._release.data[0]]) return x_data, y_data @@ -89,11 +88,45 @@ def __init__(self, nodes): self._nodes = nodes def generate_table_rows(self): + rows = [] lines = self._generate_lines() intersecting_lines = self._check_intersecting_lines(lines) if intersecting_lines > 1: + # TODO: Remove offending shape. raise RuntimeError("Polygon shapes with more than 1 intersection point are not supported.") - return [] + y_min, y_max = self._extract_global_y_limits(lines) + for y in range(floor(y_min), ceil(y_max) + 1): + x_val_pairs = self._calculate_relevant_x_value_pairs(lines, y) + for x_min, x_max in x_val_pairs: + rows.append(TableRow(spec_list=str(y), x_min=x_min, x_max=x_max)) + return rows + + @staticmethod + def _calculate_relevant_x_value_pairs(lines, y): + x_vals = [] + for line in lines: + y_bounds = sorted([line.start[1], line.end[1]]) + if y_bounds[1] > y > y_bounds[0]: + x = (y - line.c) / line.m + x_vals.append(x) + x_vals.sort() + if not len(x_vals) % 2 == 0: + raise ValueError("To form a close bounded shape, each spectra must have an even number of points.") + open_close_pairs = [] + for i in range(0, len(x_vals), 2): + open_close_pairs.append((x_vals[i], x_vals[i + 1])) + return open_close_pairs + + @staticmethod + def _extract_global_y_limits(lines): + y_min = float_info.max + y_max = float_info.min + for y_val in [y for line in lines for y in (line.start[1], line.end[1])]: + if y_val < y_min: + y_min = y_val + if y_val > y_max: + y_max = y_val + return y_min, y_max def _generate_lines(self): node_count = len(self._nodes) @@ -115,8 +148,7 @@ def _check_intersecting_lines(self, lines): intersecting_lines = 0 for i in range(line_count): for j in range(line_count): - pair = [i, j] - pair.sort() + pair = sorted([i, j]) if i != j and pair not in cache: cache.append(pair) if self._intersecting_line(lines[i], lines[j]): @@ -129,14 +161,12 @@ def _intersecting_line(line_1, line_2): if line_1.m == line_2.m or (line_1.start == line_2.end or line_2.start == line_1.end): return False x = (line_1.c - line_2.c) / (line_2.m - line_1.m) - x_data = [line_1.start[0], line_1.end[0], line_2.start[0], line_2.end[0]] - x_data.sort() + x_data = sorted([line_1.start[0], line_1.end[0], line_2.start[0], line_2.end[0]]) if not (x_data[1] < x < x_data[2]): return False y = line_1.m * x + line_1.c - y_data = [line_1.start[1], line_1.end[1], line_2.start[1], line_2.end[1]] - y_data.sort() + y_data = sorted([line_1.start[1], line_1.end[1], line_2.start[1], line_2.end[1]]) if not (y_data[1] < y < y_data[2]): return False return True @@ -193,4 +223,4 @@ def export_selectors(self): def apply_selectors(self): mask_ws = self.generate_mask_table_ws(store_in_ads=False) - # apply mask ws to underlying workspace + # TODO: apply mask ws to underlying workspace From 3c036ceb7b6e79702fca48ed6b1c3055b2908732 Mon Sep 17 00:00:00 2001 From: MialLewis <95620982+MialLewis@users.noreply.github.com> Date: Wed, 20 Aug 2025 14:00:02 +0100 Subject: [PATCH 11/33] add direct map application --- .../mantidqt/widgets/sliceviewer/models/masking.py | 13 ++++++++++--- .../sliceviewer/presenters/base_presenter.py | 2 +- .../widgets/sliceviewer/presenters/masking.py | 4 ++-- .../widgets/sliceviewer/presenters/presenter.py | 2 ++ 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py index 0e725dca45dd..72e4aebb2207 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py @@ -1,7 +1,7 @@ from abc import ABC, abstractmethod from dataclasses import dataclass from math import floor, ceil, sqrt -from mantid.api import WorkspaceFactory, AnalysisDataService +from mantid.api import WorkspaceFactory, AnalysisDataService, AlgorithmManager from sys import float_info @@ -173,9 +173,10 @@ def _intersecting_line(line_1, line_2): class MaskingModel: - def __init__(self): + def __init__(self, ws_name): self._active_mask = None self._masks = [] + self._ws_name = ws_name def update_active_mask(self, mask): self._active_mask = mask @@ -223,4 +224,10 @@ def export_selectors(self): def apply_selectors(self): mask_ws = self.generate_mask_table_ws(store_in_ads=False) - # TODO: apply mask ws to underlying workspace + alg = AlgorithmManager.create("MaskBinsFromTable") + alg.initialize() + alg.setChild(True) + alg.setProperty("InputWorkspace", self._ws_name) + alg.setProperty("OutputWorkspace", self._ws_name) + alg.setProperty("MaskingInformation", mask_ws) + alg.execute() diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/base_presenter.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/base_presenter.py index d7017442e1db..f5d6fa20e0db 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/base_presenter.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/base_presenter.py @@ -130,7 +130,7 @@ def masking(self, active) -> None: self._data_view.deactivate_and_disable_tool(ToolItemText.ZOOM) self._data_view.deactivate_and_disable_tool(ToolItemText.PAN) self._data_view.deactivate_and_disable_tool(ToolItemText.REGIONSELECTION) - self._data_view.masking = Masking(self._data_view) + self._data_view.masking = Masking(self._data_view, self.model.ws.name()) self._data_view.masking.new_selector(ToolItemText.RECT_MASKING) # default to rect masking self._data_view.activate_tool(ToolItemText.RECT_MASKING, True) return diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/masking.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/masking.py index 9aa065fd5b71..e000a380789c 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/masking.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/masking.py @@ -162,11 +162,11 @@ class Masking: Manages Masking """ - def __init__(self, dataview): + def __init__(self, dataview, ws_name): self._selectors = [] self._active_selector = None self._dataview = dataview - self._model = MaskingModel() + self._model = MaskingModel(ws_name) def _reset_active_selector(self): if self._active_selector: diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/presenter.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/presenter.py index 2fbb199622ef..72975d648304 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/presenter.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/presenter.py @@ -611,10 +611,12 @@ def poly_masking_clicked(self, active) -> None: def export_masking_clicked(self) -> None: self.view.data_view.masking.export_selectors() + self.view.data_view.canvas.draw_idle() def apply_masking_clicked(self) -> None: # warn about mutating underlying data. self.view.data_view.masking.apply_selectors() + self.replace_workspace(self.model.ws.name(), self.model.ws) class SliceViewXAxisEditor(XAxisEditor): From 9b1d818895a792d0f307a169ed488852312b5bfa Mon Sep 17 00:00:00 2001 From: MialLewis <95620982+MialLewis@users.noreply.github.com> Date: Wed, 20 Aug 2025 15:43:04 +0100 Subject: [PATCH 12/33] handle polygon error --- .../widgets/sliceviewer/models/masking.py | 37 ++++++++----------- .../widgets/sliceviewer/presenters/masking.py | 14 ++++++- 2 files changed, 29 insertions(+), 22 deletions(-) diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py index 72e4aebb2207..3bbbdd36ac5e 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py @@ -85,26 +85,22 @@ def _calc_sqrt_portion(y, a, b, k): class PolyCursorInfo(CursorInfoBase): def __init__(self, nodes): super().__init__() - self._nodes = nodes + self._lines = self._generate_lines(nodes) + if not self._check_intersecting_lines(): + raise RuntimeError("Polygon shapes with more than 1 intersection point are not supported.") def generate_table_rows(self): rows = [] - lines = self._generate_lines() - intersecting_lines = self._check_intersecting_lines(lines) - if intersecting_lines > 1: - # TODO: Remove offending shape. - raise RuntimeError("Polygon shapes with more than 1 intersection point are not supported.") - y_min, y_max = self._extract_global_y_limits(lines) + y_min, y_max = self._extract_global_y_limits() for y in range(floor(y_min), ceil(y_max) + 1): - x_val_pairs = self._calculate_relevant_x_value_pairs(lines, y) + x_val_pairs = self._calculate_relevant_x_value_pairs(y) for x_min, x_max in x_val_pairs: rows.append(TableRow(spec_list=str(y), x_min=x_min, x_max=x_max)) return rows - @staticmethod - def _calculate_relevant_x_value_pairs(lines, y): + def _calculate_relevant_x_value_pairs(self, y): x_vals = [] - for line in lines: + for line in self._lines: y_bounds = sorted([line.start[1], line.end[1]]) if y_bounds[1] > y > y_bounds[0]: x = (y - line.c) / line.m @@ -117,22 +113,21 @@ def _calculate_relevant_x_value_pairs(lines, y): open_close_pairs.append((x_vals[i], x_vals[i + 1])) return open_close_pairs - @staticmethod - def _extract_global_y_limits(lines): + def _extract_global_y_limits(self): y_min = float_info.max y_max = float_info.min - for y_val in [y for line in lines for y in (line.start[1], line.end[1])]: + for y_val in [y for line in self._lines for y in (line.start[1], line.end[1])]: if y_val < y_min: y_min = y_val if y_val > y_max: y_max = y_val return y_min, y_max - def _generate_lines(self): - node_count = len(self._nodes) + def _generate_lines(self, nodes): + node_count = len(nodes) lines = [] for i in range(node_count): - line = (self._nodes[i].data, self._nodes[i + 1].data) if i < node_count - 1 else (self._nodes[i].data, self._nodes[0].data) + line = (nodes[i].data, nodes[i + 1].data) if i < node_count - 1 else (nodes[i].data, nodes[0].data) lines.append(self._generate_line(*line)) return lines @@ -142,8 +137,8 @@ def _generate_line(start, end): c = start[1] - m * start[0] return Line(start=start, end=end, m=m, c=c) - def _check_intersecting_lines(self, lines): - line_count = len(lines) + def _check_intersecting_lines(self): + line_count = len(self._lines) cache = [] intersecting_lines = 0 for i in range(line_count): @@ -151,9 +146,9 @@ def _check_intersecting_lines(self, lines): pair = sorted([i, j]) if i != j and pair not in cache: cache.append(pair) - if self._intersecting_line(lines[i], lines[j]): + if self._intersecting_line(self._lines[i], self._lines[j]): intersecting_lines += 1 - return intersecting_lines + return intersecting_lines <= 1 @staticmethod def _intersecting_line(line_1, line_2): diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/masking.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/masking.py index e000a380789c..0c39b766b472 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/masking.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/masking.py @@ -4,6 +4,7 @@ from mantidqt.widgets.sliceviewer.views.toolbar import ToolItemText from .selector import make_selector_class, cursor_info from mantidqt.widgets.sliceviewer.models.masking import MaskingModel +from mantid.kernel import logger SHAPE_STYLE = {"alpha": 0.5, "linewidth": 1.75, "color": "black", "linestyle": "-"} @@ -18,6 +19,7 @@ def __init__(self, dataview, model): self._selector = None self._dataview = dataview self._model = model + self._clear = False @abstractmethod def _on_selected(self, eclick, erelease): @@ -27,8 +29,11 @@ def set_active(self, state): self._selector.set_active(state) def clear(self): + if self._clear: + return for artist in self._selector.artists: artist.remove() + self._clear = True def disconnect(self): self._selector.disconnect_events() @@ -86,6 +91,7 @@ def _on_selected(self, eclick, erelease): # Add shape object to collection self._mask_drawn = True + self._clear = False self._dataview.mpl_toolbar.set_action_checked(SELECTOR_TO_TOOL_ITEM_TEXT[self.__class__], False, trigger=False) self.add_cursor_info(cinfo_click, cinfo_release) @@ -140,9 +146,15 @@ def _on_selected(self, eclick, erelease=None): :param erelease: None for polygon selector """ self._mask_drawn = True + self._clear = False self._dataview.mpl_toolbar.set_action_checked(SELECTOR_TO_TOOL_ITEM_TEXT[self.__class__], False, trigger=False) nodes = [cursor_info(self._img, node[0], node[1]) for node in eclick] - self._model.add_poly_cursor_info(nodes) + try: + self._model.add_poly_cursor_info(nodes) + except RuntimeError as e: + self.clear() + self._mask_drawn = False + logger.error(str(e)) def set_inactive_color(self): self._selector.set_props(**INACTIVE_SHAPE_STYLE) From c97dfca4a5dcfae9f31447115c1bbdfa42a8edc1 Mon Sep 17 00:00:00 2001 From: MialLewis <95620982+MialLewis@users.noreply.github.com> Date: Wed, 20 Aug 2025 16:05:42 +0100 Subject: [PATCH 13/33] deactivate selector after error --- .../mantidqt/widgets/sliceviewer/presenters/masking.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/masking.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/masking.py index 0c39b766b472..39a270c0dc33 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/masking.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/masking.py @@ -154,6 +154,9 @@ def _on_selected(self, eclick, erelease=None): except RuntimeError as e: self.clear() self._mask_drawn = False + self.disconnect() + self.set_active(False) + self._dataview.canvas.draw_idle() logger.error(str(e)) def set_inactive_color(self): From 3438bbe357644807d4ee4bbbc9037f275e584ce7 Mon Sep 17 00:00:00 2001 From: MialLewis <95620982+MialLewis@users.noreply.github.com> Date: Thu, 21 Aug 2025 10:50:47 +0100 Subject: [PATCH 14/33] handle mask removal from model if esc pressed --- .../mantidqt/widgets/sliceviewer/models/masking.py | 8 +++++--- .../mantidqt/widgets/sliceviewer/presenters/masking.py | 8 ++++++-- .../mantidqt/widgets/sliceviewer/presenters/selector.py | 7 ++++++- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py index 3bbbdd36ac5e..702a9d06cab6 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py @@ -180,8 +180,9 @@ def clear_active_mask(self): self._active_mask = None def store_active_mask(self): - self._masks.append(self._active_mask) - self._active_mask = None + if self._active_mask: + self._masks.append(self._active_mask) + self._active_mask = None def clear_stored_masks(self): self._masks = [] @@ -195,7 +196,8 @@ def add_elli_cursor_info(self, click, release): def add_poly_cursor_info(self, nodes): self.update_active_mask(PolyCursorInfo(nodes=nodes)) - def create_table_workspace_from_rows(self, table_rows, store_in_ads): + @staticmethod + def create_table_workspace_from_rows(table_rows, store_in_ads): # create table ws_from rows table_ws = WorkspaceFactory.createTable() table_ws.addColumn("str", "SpectraList") diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/masking.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/masking.py index 39a270c0dc33..7c1292dae32b 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/masking.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/masking.py @@ -50,6 +50,10 @@ def _img(self): def mask_drawn(self): return self._mask_drawn + def remove_mask_from_model(self): + if self._mask_drawn: + self._model.clear_active_mask() + class RectangleSelectionMaskingBase(SelectionMaskingBase, ABC): """ @@ -58,7 +62,7 @@ class RectangleSelectionMaskingBase(SelectionMaskingBase, ABC): def __init__(self, dataview, model, selector): super().__init__(dataview, model) - self._selector = make_selector_class(selector)( + self._selector = make_selector_class(selector, self.remove_mask_from_model)( dataview.ax, self._on_selected, useblit=False, # rectangle persists on button release @@ -131,7 +135,7 @@ class PolygonSelectionMasking(SelectionMaskingBase): def __init__(self, dataview, model): super().__init__(dataview, model) - self._selector = make_selector_class(PolygonSelector)( + self._selector = make_selector_class(PolygonSelector, self.remove_mask_from_model)( dataview.ax, self._on_selected, useblit=False, # rectangle persists on button release diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/selector.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/selector.py index f75dea73db40..c625221cf5e3 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/selector.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/selector.py @@ -44,7 +44,7 @@ def cursor_info(image: AxesImage, xdata: float, ydata: float, full_bbox: Bbox = return None -def make_selector_class(base): +def make_selector_class(base, remove_mask_handle): def in_axes_event(self, event): """ Only process event if inside the axes with which the selector was init @@ -63,6 +63,11 @@ def onmove(self, event): if in_axes_event(self, event) and not invalid_first_event(self, event): super(SelectorMtd, self).onmove(event) + def clear(self): + super(SelectorMtd, self).clear() + remove_mask_handle() + SelectorMtd = type("SelectorMtd", (base,), {}) SelectorMtd.onmove = onmove + SelectorMtd.clear = clear return SelectorMtd From 7f1cba32c5e27424029ca9cd63bcec49e19b314d Mon Sep 17 00:00:00 2001 From: MialLewis <95620982+MialLewis@users.noreply.github.com> Date: Fri, 22 Aug 2025 16:07:25 +0100 Subject: [PATCH 15/33] add numeric axis and bin centring partially finished --- .../widgets/sliceviewer/models/masking.py | 110 +++++++++++++----- .../sliceviewer/presenters/base_presenter.py | 3 +- .../widgets/sliceviewer/presenters/masking.py | 4 +- .../sliceviewer/presenters/selector.py | 5 +- 4 files changed, 85 insertions(+), 37 deletions(-) diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py index 702a9d06cab6..aee04891b761 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py @@ -3,6 +3,7 @@ from math import floor, ceil, sqrt from mantid.api import WorkspaceFactory, AnalysisDataService, AlgorithmManager from sys import float_info +from numpy import searchsorted, where @dataclass @@ -13,8 +14,9 @@ class TableRow: class CursorInfoBase(ABC): - def __init__(self): + def __init__(self, numeric_axis): self._table_rows = None + self._numeric_axis = numeric_axis @abstractmethod def generate_table_rows(self): @@ -24,6 +26,14 @@ def generate_table_rows(self): def table_rows(self): return self._table_rows + def get_y_val_index(self, y_val, apply_floor=False): + adj = 1 if apply_floor else 0 + return int(searchsorted(self._numeric_axis, y_val)) - adj + + @property + def numeric_axis(self): + return self._numeric_axis is not None + @dataclass class Line: @@ -34,8 +44,8 @@ class Line: class RectCursorInfoBase(CursorInfoBase, ABC): - def __init__(self, click, release): - super().__init__() + def __init__(self, click, release, is_numeric): + super().__init__(is_numeric) self._click = click self._release = release @@ -46,68 +56,100 @@ def get_xy_data(self): class RectCursorInfo(RectCursorInfoBase): - def __init__(self, click, release): - super().__init__(click, release) + def __init__(self, click, release, is_numeric): + super().__init__(click, release, is_numeric) def generate_table_rows(self): x_data, y_data = self.get_xy_data() - row = TableRow(spec_list=f"{floor(y_data[0])}-{ceil(y_data[-1])}", x_min=x_data[0], x_max=x_data[-1]) + if self.numeric_axis: + y_min, y_max = self.get_y_val_index(y_data[0]), self.get_y_val_index(y_data[-1]) - 1 + else: + bin_width = 1 + y_min, y_max = ceil(y_data[0] - bin_width / 2), ceil(y_data[-1] - bin_width / 2) + row = TableRow(spec_list=f"{y_min}-{y_max}", x_min=x_data[0], x_max=x_data[-1]) return [row] class ElliCursorInfo(RectCursorInfoBase): - def __init__(self, click, release): - super().__init__(click, release) + def __init__(self, click, release, is_numeric): + super().__init__(click, release, is_numeric) def generate_table_rows(self): x_data, y_data = self.get_xy_data() - y_min = floor(y_data[0]) # Need to consider numeric axes - y_max = ceil(y_data[-1]) + if self.numeric_axis: + y_min = self._numeric_axis[self.get_y_val_index(y_data[0], True)] + y_max = self._numeric_axis[self.get_y_val_index(y_data[1])] + y_range = self._numeric_axis[self.get_y_val_index(y_data[0], True) : self.get_y_val_index(y_data[1]) + 1] + base_index = where(self._numeric_axis == y_range[0])[0][0] + else: + bin_width = 1 + y_min = ceil(y_data[0] - bin_width / 2) + y_max = ceil(y_data[1] - bin_width / 2) + y_range = [n / 2 for n in range(y_min * 2, (y_max * 2) + 1)] # inclusive range with 0.5 step for greater resolution + base_index = 0 + x_min, x_max = x_data[0], x_data[-1] a = (x_max - x_min) / 2 b = (y_max - y_min) / 2 h = x_min + a k = y_min + b + + mid_point = (y_max - y_min) / 2 rows = [] - for y in range(y_min, y_max + 1): + for index, y in enumerate(y_range): x_min, x_max = self._calc_x_val(y, a, b, h, k) - rows.append(TableRow(spec_list=str(y), x_min=x_min, x_max=x_max)) + ws_index = self._get_ws_index(y, index, base_index, mid_point) + rows.append(TableRow(spec_list=str(ws_index), x_min=x_min, x_max=x_max)) return rows + def _get_ws_index(self, y, index, base_index, mid_point): + if self.numeric_axis: + return base_index + index + else: + if y > mid_point: + return ceil(y) + else: + return floor(y) + def _calc_x_val(self, y, a, b, h, k): return (h - self._calc_sqrt_portion(y, a, b, k)), (h + self._calc_sqrt_portion(y, a, b, k)) @staticmethod def _calc_sqrt_portion(y, a, b, k): - return sqrt((a**2) * (1 - ((y - k) ** 2) / (b**2))) + return sqrt(round((a**2) * (1 - ((y - k) ** 2) / (b**2)), 8)) # Round to alleviate floating point precision errors. class PolyCursorInfo(CursorInfoBase): - def __init__(self, nodes): - super().__init__() - self._lines = self._generate_lines(nodes) + def __init__(self, nodes, is_numeric): + super().__init__(is_numeric) + self._bin_width = 1 + self._lines = self._generate_lines(nodes, self._bin_width) if not self._check_intersecting_lines(): raise RuntimeError("Polygon shapes with more than 1 intersection point are not supported.") def generate_table_rows(self): rows = [] y_min, y_max = self._extract_global_y_limits() - for y in range(floor(y_min), ceil(y_max) + 1): + + y_range = [n / 2 for n in range(y_min * 2, (y_max * 2) + 1)] # inclusive range with 0.5 step for greater resolution + for y in y_range: x_val_pairs = self._calculate_relevant_x_value_pairs(y) for x_min, x_max in x_val_pairs: - rows.append(TableRow(spec_list=str(y), x_min=x_min, x_max=x_max)) + rows.append(TableRow(spec_list=str(ceil(y - self._bin_width / 2)), x_min=x_min, x_max=x_max)) return rows def _calculate_relevant_x_value_pairs(self, y): x_vals = [] for line in self._lines: y_bounds = sorted([line.start[1], line.end[1]]) - if y_bounds[1] > y > y_bounds[0]: + if y_bounds[1] >= y >= y_bounds[0]: x = (y - line.c) / line.m x_vals.append(x) x_vals.sort() if not len(x_vals) % 2 == 0: - raise ValueError("To form a close bounded shape, each spectra must have an even number of points.") + # To form a close bounded shape, each spectra must have an even number of points. + # Drop either the first or last x value, as this must correspond to a node in a line-node pair. + x_vals = x_vals[1:] if len(set(x_vals[:2])) == 1 else x_vals[:-1] open_close_pairs = [] for i in range(0, len(x_vals), 2): open_close_pairs.append((x_vals[i], x_vals[i + 1])) @@ -123,7 +165,7 @@ def _extract_global_y_limits(self): y_max = y_val return y_min, y_max - def _generate_lines(self, nodes): + def _generate_lines(self, nodes, bin_width): node_count = len(nodes) lines = [] for i in range(node_count): @@ -131,11 +173,14 @@ def _generate_lines(self, nodes): lines.append(self._generate_line(*line)) return lines - @staticmethod - def _generate_line(start, end): - m = (start[1] - end[1]) / (start[0] - end[0]) - c = start[1] - m * start[0] - return Line(start=start, end=end, m=m, c=c) + def _generate_line(self, start, end): + start_y = ceil(start[1] - self._bin_width / 2) + end_y = ceil(end[1] - self._bin_width / 2) + start_x = ceil(start[0] - self._bin_width / 2) + end_x = ceil(end[0] - self._bin_width / 2) + m = (start_y - end_y) / (start_x - end_x) + c = start_y - m * start_x + return Line(start=(start_x, start_y), end=(end_x, end_y), m=m, c=c) def _check_intersecting_lines(self): line_count = len(self._lines) @@ -168,10 +213,11 @@ def _intersecting_line(line_1, line_2): class MaskingModel: - def __init__(self, ws_name): + def __init__(self, ws_name, numeric_axis): self._active_mask = None self._masks = [] self._ws_name = ws_name + self._numeric_axis = numeric_axis def update_active_mask(self, mask): self._active_mask = mask @@ -188,13 +234,13 @@ def clear_stored_masks(self): self._masks = [] def add_rect_cursor_info(self, click, release): - self.update_active_mask(RectCursorInfo(click=click, release=release)) + self.update_active_mask(RectCursorInfo(click=click, release=release, is_numeric=self._numeric_axis)) def add_elli_cursor_info(self, click, release): - self.update_active_mask(ElliCursorInfo(click=click, release=release)) + self.update_active_mask(ElliCursorInfo(click=click, release=release, is_numeric=self._numeric_axis)) def add_poly_cursor_info(self, nodes): - self.update_active_mask(PolyCursorInfo(nodes=nodes)) + self.update_active_mask(PolyCursorInfo(nodes=nodes, is_numeric=self._numeric_axis)) @staticmethod def create_table_workspace_from_rows(table_rows, store_in_ads): @@ -204,8 +250,8 @@ def create_table_workspace_from_rows(table_rows, store_in_ads): table_ws.addColumn("double", "XMin") table_ws.addColumn("double", "XMax") for row in table_rows: - if not row.x_min == row.x_max: # the min and max of the ellipse - table_ws.addRow([row.spec_list, row.x_min, row.x_max]) + # if not row.x_min == row.x_max: # the min and max of the ellipse + table_ws.addRow([row.spec_list, row.x_min, row.x_max]) if store_in_ads: AnalysisDataService.addOrReplace("svmask_ws", table_ws) return table_ws diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/base_presenter.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/base_presenter.py index f5d6fa20e0db..2bc13451d2b7 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/base_presenter.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/base_presenter.py @@ -130,7 +130,8 @@ def masking(self, active) -> None: self._data_view.deactivate_and_disable_tool(ToolItemText.ZOOM) self._data_view.deactivate_and_disable_tool(ToolItemText.PAN) self._data_view.deactivate_and_disable_tool(ToolItemText.REGIONSELECTION) - self._data_view.masking = Masking(self._data_view, self.model.ws.name()) + numeric_axis = self.model.ws.getAxis(1).extractValues() if self.model.ws.getAxis(1).isNumeric() else None + self._data_view.masking = Masking(self._data_view, self.model.ws.name(), numeric_axis) self._data_view.masking.new_selector(ToolItemText.RECT_MASKING) # default to rect masking self._data_view.activate_tool(ToolItemText.RECT_MASKING, True) return diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/masking.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/masking.py index 7c1292dae32b..c766bfc1b294 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/masking.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/masking.py @@ -181,11 +181,11 @@ class Masking: Manages Masking """ - def __init__(self, dataview, ws_name): + def __init__(self, dataview, ws_name, is_numeric): self._selectors = [] self._active_selector = None self._dataview = dataview - self._model = MaskingModel(ws_name) + self._model = MaskingModel(ws_name, is_numeric) def _reset_active_selector(self): if self._active_selector: diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/selector.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/selector.py index c625221cf5e3..56d4396fbf2d 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/selector.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/selector.py @@ -44,7 +44,7 @@ def cursor_info(image: AxesImage, xdata: float, ydata: float, full_bbox: Bbox = return None -def make_selector_class(base, remove_mask_handle): +def make_selector_class(base, clear_handle=None): def in_axes_event(self, event): """ Only process event if inside the axes with which the selector was init @@ -65,7 +65,8 @@ def onmove(self, event): def clear(self): super(SelectorMtd, self).clear() - remove_mask_handle() + if clear_handle: + clear_handle() SelectorMtd = type("SelectorMtd", (base,), {}) SelectorMtd.onmove = onmove From f2c1e846e899f5f6b31ff3becc45c3b1458209e4 Mon Sep 17 00:00:00 2001 From: MialLewis <95620982+MialLewis@users.noreply.github.com> Date: Fri, 22 Aug 2025 19:35:16 +0100 Subject: [PATCH 16/33] correct spectrum num masking --- Framework/Algorithms/src/MaskBins.cpp | 7 ++- .../Algorithms/src/MaskBinsFromTable.cpp | 2 +- .../widgets/sliceviewer/models/masking.py | 58 +++++++++---------- .../sliceviewer/presenters/selector.py | 4 ++ 4 files changed, 36 insertions(+), 35 deletions(-) diff --git a/Framework/Algorithms/src/MaskBins.cpp b/Framework/Algorithms/src/MaskBins.cpp index 756323220c23..ef23c20188c0 100644 --- a/Framework/Algorithms/src/MaskBins.cpp +++ b/Framework/Algorithms/src/MaskBins.cpp @@ -27,9 +27,10 @@ using DataObjects::EventWorkspace_const_sptr; using DataObjects::EventWorkspace_sptr; void MaskBins::init() { - declareWorkspaceInputProperties("InputWorkspace", - "The name of the input workspace. Must contain histogram data.", - std::make_shared()); + declareWorkspaceInputProperties(IndexType::WorkspaceIndex) | + static_cast(IndexType::SpectrumNum)>( + "InputWorkspace", "The name of the input workspace. Must contain histogram data.", + std::make_shared()); declareProperty(std::make_unique>("OutputWorkspace", "", Direction::Output), "The name of the Workspace containing the masked bins."); diff --git a/Framework/Algorithms/src/MaskBinsFromTable.cpp b/Framework/Algorithms/src/MaskBinsFromTable.cpp index a579f67c1186..df465fc5c9f1 100644 --- a/Framework/Algorithms/src/MaskBinsFromTable.cpp +++ b/Framework/Algorithms/src/MaskBinsFromTable.cpp @@ -87,8 +87,8 @@ void MaskBinsFromTable::maskBins(const API::MatrixWorkspace_sptr &dataws) { maskbins->setProperty("InputWorkspace", outputws); } maskbins->setProperty("OutputWorkspace", this->getPropertyValue("OutputWorkspace")); - maskbins->setPropertyValue("InputWorkspaceIndexSet", m_spectraVec[ib]); maskbins->setPropertyValue("InputWorkspaceIndexType", "SpectrumNumber"); + maskbins->setPropertyValue("InputWorkspaceIndexSet", m_spectraVec[ib]); maskbins->setProperty("XMin", m_xminVec[ib]); maskbins->setProperty("XMax", m_xmaxVec[ib]); diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py index aee04891b761..abad650714cb 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py @@ -1,10 +1,12 @@ from abc import ABC, abstractmethod from dataclasses import dataclass -from math import floor, ceil, sqrt +from math import ceil, sqrt from mantid.api import WorkspaceFactory, AnalysisDataService, AlgorithmManager from sys import float_info from numpy import searchsorted, where +ALLOWABLE_ERROR = 8 + @dataclass class TableRow: @@ -17,6 +19,7 @@ class CursorInfoBase(ABC): def __init__(self, numeric_axis): self._table_rows = None self._numeric_axis = numeric_axis + self._bin_width = 1 @abstractmethod def generate_table_rows(self): @@ -34,11 +37,14 @@ def get_y_val_index(self, y_val, apply_floor=False): def numeric_axis(self): return self._numeric_axis is not None + def snap_to_bin_centre(self, val): + return ceil(val - self._bin_width / 2) + @dataclass class Line: - start: float - end: float + start: tuple[float] + end: tuple[float] m: float c: float @@ -64,8 +70,7 @@ def generate_table_rows(self): if self.numeric_axis: y_min, y_max = self.get_y_val_index(y_data[0]), self.get_y_val_index(y_data[-1]) - 1 else: - bin_width = 1 - y_min, y_max = ceil(y_data[0] - bin_width / 2), ceil(y_data[-1] - bin_width / 2) + y_min, y_max = self.snap_to_bin_centre(y_data[0]), self.snap_to_bin_centre(y_data[-1]) row = TableRow(spec_list=f"{y_min}-{y_max}", x_min=x_data[0], x_max=x_data[-1]) return [row] @@ -82,10 +87,9 @@ def generate_table_rows(self): y_range = self._numeric_axis[self.get_y_val_index(y_data[0], True) : self.get_y_val_index(y_data[1]) + 1] base_index = where(self._numeric_axis == y_range[0])[0][0] else: - bin_width = 1 - y_min = ceil(y_data[0] - bin_width / 2) - y_max = ceil(y_data[1] - bin_width / 2) - y_range = [n / 2 for n in range(y_min * 2, (y_max * 2) + 1)] # inclusive range with 0.5 step for greater resolution + y_min = self.snap_to_bin_centre(y_data[0]) + y_max = self.snap_to_bin_centre(y_data[1]) + y_range = [n / 3 for n in range(y_min * 3, (y_max * 3) + 1)] # inclusive range with 1/3 step for greater resolution base_index = 0 x_min, x_max = x_data[0], x_data[-1] @@ -94,36 +98,26 @@ def generate_table_rows(self): h = x_min + a k = y_min + b - mid_point = (y_max - y_min) / 2 rows = [] for index, y in enumerate(y_range): x_min, x_max = self._calc_x_val(y, a, b, h, k) - ws_index = self._get_ws_index(y, index, base_index, mid_point) + ws_index = base_index + index if self.numeric_axis else round(y) + x_min = x_min - 10**-ALLOWABLE_ERROR if x_min == x_max else x_min # slightly adjust min value so x vals are different. rows.append(TableRow(spec_list=str(ws_index), x_min=x_min, x_max=x_max)) return rows - def _get_ws_index(self, y, index, base_index, mid_point): - if self.numeric_axis: - return base_index + index - else: - if y > mid_point: - return ceil(y) - else: - return floor(y) - def _calc_x_val(self, y, a, b, h, k): return (h - self._calc_sqrt_portion(y, a, b, k)), (h + self._calc_sqrt_portion(y, a, b, k)) @staticmethod def _calc_sqrt_portion(y, a, b, k): - return sqrt(round((a**2) * (1 - ((y - k) ** 2) / (b**2)), 8)) # Round to alleviate floating point precision errors. + return sqrt(round((a**2) * (1 - ((y - k) ** 2) / (b**2)), ALLOWABLE_ERROR)) class PolyCursorInfo(CursorInfoBase): def __init__(self, nodes, is_numeric): super().__init__(is_numeric) - self._bin_width = 1 - self._lines = self._generate_lines(nodes, self._bin_width) + self._lines = self._generate_lines(nodes) if not self._check_intersecting_lines(): raise RuntimeError("Polygon shapes with more than 1 intersection point are not supported.") @@ -131,11 +125,11 @@ def generate_table_rows(self): rows = [] y_min, y_max = self._extract_global_y_limits() - y_range = [n / 2 for n in range(y_min * 2, (y_max * 2) + 1)] # inclusive range with 0.5 step for greater resolution + y_range = [n / 3 for n in range(y_min * 3, (y_max * 3) + 1)] # inclusive range with 1/3 step for greater resolution for y in y_range: x_val_pairs = self._calculate_relevant_x_value_pairs(y) for x_min, x_max in x_val_pairs: - rows.append(TableRow(spec_list=str(ceil(y - self._bin_width / 2)), x_min=x_min, x_max=x_max)) + rows.append(TableRow(spec_list=str(round(y)), x_min=x_min, x_max=x_max)) return rows def _calculate_relevant_x_value_pairs(self, y): @@ -152,7 +146,9 @@ def _calculate_relevant_x_value_pairs(self, y): x_vals = x_vals[1:] if len(set(x_vals[:2])) == 1 else x_vals[:-1] open_close_pairs = [] for i in range(0, len(x_vals), 2): - open_close_pairs.append((x_vals[i], x_vals[i + 1])) + x_min, x_max = x_vals[i], x_vals[i + 1] + x_min = x_min - 10**-ALLOWABLE_ERROR if x_min == x_max else x_min # slightly adjust min value so x vals are different. + open_close_pairs.append((x_min, x_max)) return open_close_pairs def _extract_global_y_limits(self): @@ -165,7 +161,7 @@ def _extract_global_y_limits(self): y_max = y_val return y_min, y_max - def _generate_lines(self, nodes, bin_width): + def _generate_lines(self, nodes): node_count = len(nodes) lines = [] for i in range(node_count): @@ -174,10 +170,10 @@ def _generate_lines(self, nodes, bin_width): return lines def _generate_line(self, start, end): - start_y = ceil(start[1] - self._bin_width / 2) - end_y = ceil(end[1] - self._bin_width / 2) - start_x = ceil(start[0] - self._bin_width / 2) - end_x = ceil(end[0] - self._bin_width / 2) + start_y = self.snap_to_bin_centre(start[1]) + end_y = self.snap_to_bin_centre(end[1]) + start_x = start[0] + end_x = end[0] m = (start_y - end_y) / (start_x - end_x) c = start_y - m * start_x return Line(start=(start_x, start_y), end=(end_x, end_y), m=m, c=c) diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/selector.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/selector.py index 56d4396fbf2d..25586ee50531 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/selector.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/selector.py @@ -68,7 +68,11 @@ def clear(self): if clear_handle: clear_handle() + def _press(self, event): + super(SelectorMtd, self)._press(event) + SelectorMtd = type("SelectorMtd", (base,), {}) SelectorMtd.onmove = onmove SelectorMtd.clear = clear + SelectorMtd._press = _press return SelectorMtd From 4a72197bac9f435cfef4458760d42f8d29bfdfb5 Mon Sep 17 00:00:00 2001 From: MialLewis <95620982+MialLewis@users.noreply.github.com> Date: Wed, 27 Aug 2025 14:47:22 +0100 Subject: [PATCH 17/33] fix edge case bugs --- .../widgets/sliceviewer/models/masking.py | 57 ++++++++++++++++--- 1 file changed, 49 insertions(+), 8 deletions(-) diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py index abad650714cb..98ccf8b11172 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py @@ -3,7 +3,7 @@ from math import ceil, sqrt from mantid.api import WorkspaceFactory, AnalysisDataService, AlgorithmManager from sys import float_info -from numpy import searchsorted, where +from numpy import searchsorted, where, inf ALLOWABLE_ERROR = 8 @@ -25,6 +25,44 @@ def __init__(self, numeric_axis): def generate_table_rows(self): pass + def consolidate_table_rows(self, table_rows): + @dataclass() + class XVal: + start: bool + val: float + + def sort_fn(e): + return e.val + + consolidated_rows = {} + for row in table_rows: + if row.spec_list not in consolidated_rows: + consolidated_rows[row.spec_list] = [] + consolidated_rows[row.spec_list].extend([XVal(start=True, val=row.x_min), XVal(start=False, val=row.x_max)]) + + new_table_rows = [] + for row in consolidated_rows.items(): + row[1].sort(key=sort_fn) + x_mins = [row[1][0].val] + x_maxs = [] + found_end = False + x_max = None + if len(row[1]) > 2: + for val in row[1][1:-1]: + if not found_end and not val.start: + found_end = True + x_max = val.val + elif found_end and not val.start: + x_max = val.val + elif found_end and val.start: + x_maxs.append(x_max) + x_mins.append(val.val) + found_end = False + x_maxs.append(row[1][-1].val) + for i in range(len(x_mins)): + new_table_rows.append(TableRow(spec_list=row[0], x_min=x_mins[i], x_max=x_maxs[i])) + return new_table_rows + @property def table_rows(self): return self._table_rows @@ -104,7 +142,7 @@ def generate_table_rows(self): ws_index = base_index + index if self.numeric_axis else round(y) x_min = x_min - 10**-ALLOWABLE_ERROR if x_min == x_max else x_min # slightly adjust min value so x vals are different. rows.append(TableRow(spec_list=str(ws_index), x_min=x_min, x_max=x_max)) - return rows + return self.consolidate_table_rows(rows) def _calc_x_val(self, y, a, b, h, k): return (h - self._calc_sqrt_portion(y, a, b, k)), (h + self._calc_sqrt_portion(y, a, b, k)) @@ -130,14 +168,14 @@ def generate_table_rows(self): x_val_pairs = self._calculate_relevant_x_value_pairs(y) for x_min, x_max in x_val_pairs: rows.append(TableRow(spec_list=str(round(y)), x_min=x_min, x_max=x_max)) - return rows + return self.consolidate_table_rows(rows) def _calculate_relevant_x_value_pairs(self, y): x_vals = [] for line in self._lines: y_bounds = sorted([line.start[1], line.end[1]]) if y_bounds[1] >= y >= y_bounds[0]: - x = (y - line.c) / line.m + x = (y - line.c) / line.m if (abs(line.m) != inf and abs(line.m) != 0) else line.start[0] x_vals.append(x) x_vals.sort() if not len(x_vals) % 2 == 0: @@ -196,14 +234,17 @@ def _intersecting_line(line_1, line_2): # if gradients are equal, or if lines intersect at a node if line_1.m == line_2.m or (line_1.start == line_2.end or line_2.start == line_1.end): return False + x = (line_1.c - line_2.c) / (line_2.m - line_1.m) - x_data = sorted([line_1.start[0], line_1.end[0], line_2.start[0], line_2.end[0]]) - if not (x_data[1] < x < x_data[2]): + line_1_x = sorted([line_1.start[0], line_1.end[0]]) + line_2_x = sorted([line_2.start[0], line_2.end[0]]) + if not (line_1_x[0] < x < line_1_x[1]) or not (line_2_x[0] < x < line_2_x[1]): return False y = line_1.m * x + line_1.c - y_data = sorted([line_1.start[1], line_1.end[1], line_2.start[1], line_2.end[1]]) - if not (y_data[1] < y < y_data[2]): + line_1_y = sorted([line_1.start[1], line_1.end[1]]) + line_2_y = sorted([line_2.start[1], line_2.end[1]]) + if not (line_1_y[0] < y < line_1_y[1]) or not (line_2_y[0] < y < line_2_y[1]): return False return True From c2a878ed0fc363b0ef99481a8c8569902aae4995 Mon Sep 17 00:00:00 2001 From: MialLewis <95620982+MialLewis@users.noreply.github.com> Date: Thu, 28 Aug 2025 14:34:38 +0100 Subject: [PATCH 18/33] disable masking on numeric y axis, handle tranpose --- .../SliceViewer/New_features/000000.rst | 1 + .../widgets/sliceviewer/models/masking.py | 82 ++++++++----------- .../sliceviewer/presenters/base_presenter.py | 7 +- .../widgets/sliceviewer/presenters/masking.py | 11 +-- .../sliceviewer/presenters/presenter.py | 5 ++ 5 files changed, 50 insertions(+), 56 deletions(-) create mode 100644 docs/source/release/v6.14.0/Workbench/SliceViewer/New_features/000000.rst diff --git a/docs/source/release/v6.14.0/Workbench/SliceViewer/New_features/000000.rst b/docs/source/release/v6.14.0/Workbench/SliceViewer/New_features/000000.rst new file mode 100644 index 000000000000..03bf216b5c0f --- /dev/null +++ b/docs/source/release/v6.14.0/Workbench/SliceViewer/New_features/000000.rst @@ -0,0 +1 @@ +- Added a masking feature to Sliceviewer for axis with a non-numerical y axis. This enables direct application of the mask to the underlying workspace, or the outputting of a table workspace that can be applied subsequently using ``MaskFromTableWorkspace``. diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py index 98ccf8b11172..a06f885d6873 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py @@ -3,7 +3,7 @@ from math import ceil, sqrt from mantid.api import WorkspaceFactory, AnalysisDataService, AlgorithmManager from sys import float_info -from numpy import searchsorted, where, inf +from numpy import inf ALLOWABLE_ERROR = 8 @@ -16,16 +16,17 @@ class TableRow: class CursorInfoBase(ABC): - def __init__(self, numeric_axis): + def __init__(self, transpose): self._table_rows = None - self._numeric_axis = numeric_axis self._bin_width = 1 + self._transpose = transpose @abstractmethod def generate_table_rows(self): pass - def consolidate_table_rows(self, table_rows): + @staticmethod + def consolidate_table_rows(table_rows): @dataclass() class XVal: start: bool @@ -67,14 +68,6 @@ def sort_fn(e): def table_rows(self): return self._table_rows - def get_y_val_index(self, y_val, apply_floor=False): - adj = 1 if apply_floor else 0 - return int(searchsorted(self._numeric_axis, y_val)) - adj - - @property - def numeric_axis(self): - return self._numeric_axis is not None - def snap_to_bin_centre(self, val): return ceil(val - self._bin_width / 2) @@ -88,47 +81,37 @@ class Line: class RectCursorInfoBase(CursorInfoBase, ABC): - def __init__(self, click, release, is_numeric): - super().__init__(is_numeric) + def __init__(self, click, release, transpose): + super().__init__(transpose) self._click = click self._release = release def get_xy_data(self): y_data = sorted([self._click.data[1], self._release.data[1]]) x_data = sorted([self._click.data[0], self._release.data[0]]) - return x_data, y_data + return (x_data, y_data) if not self._transpose else (y_data, x_data) class RectCursorInfo(RectCursorInfoBase): - def __init__(self, click, release, is_numeric): - super().__init__(click, release, is_numeric) + def __init__(self, click, release, transpose): + super().__init__(click, release, transpose) def generate_table_rows(self): x_data, y_data = self.get_xy_data() - if self.numeric_axis: - y_min, y_max = self.get_y_val_index(y_data[0]), self.get_y_val_index(y_data[-1]) - 1 - else: - y_min, y_max = self.snap_to_bin_centre(y_data[0]), self.snap_to_bin_centre(y_data[-1]) + y_min, y_max = self.snap_to_bin_centre(y_data[0]), self.snap_to_bin_centre(y_data[-1]) row = TableRow(spec_list=f"{y_min}-{y_max}", x_min=x_data[0], x_max=x_data[-1]) return [row] class ElliCursorInfo(RectCursorInfoBase): - def __init__(self, click, release, is_numeric): - super().__init__(click, release, is_numeric) + def __init__(self, click, release, transpose): + super().__init__(click, release, transpose) def generate_table_rows(self): x_data, y_data = self.get_xy_data() - if self.numeric_axis: - y_min = self._numeric_axis[self.get_y_val_index(y_data[0], True)] - y_max = self._numeric_axis[self.get_y_val_index(y_data[1])] - y_range = self._numeric_axis[self.get_y_val_index(y_data[0], True) : self.get_y_val_index(y_data[1]) + 1] - base_index = where(self._numeric_axis == y_range[0])[0][0] - else: - y_min = self.snap_to_bin_centre(y_data[0]) - y_max = self.snap_to_bin_centre(y_data[1]) - y_range = [n / 3 for n in range(y_min * 3, (y_max * 3) + 1)] # inclusive range with 1/3 step for greater resolution - base_index = 0 + y_min = self.snap_to_bin_centre(y_data[0]) + y_max = self.snap_to_bin_centre(y_data[1]) + y_range = [n / 3 for n in range(y_min * 3, (y_max * 3) + 1)] # inclusive range with 1/3 step for greater resolution x_min, x_max = x_data[0], x_data[-1] a = (x_max - x_min) / 2 @@ -139,9 +122,8 @@ def generate_table_rows(self): rows = [] for index, y in enumerate(y_range): x_min, x_max = self._calc_x_val(y, a, b, h, k) - ws_index = base_index + index if self.numeric_axis else round(y) x_min = x_min - 10**-ALLOWABLE_ERROR if x_min == x_max else x_min # slightly adjust min value so x vals are different. - rows.append(TableRow(spec_list=str(ws_index), x_min=x_min, x_max=x_max)) + rows.append(TableRow(spec_list=str(round(y)), x_min=x_min, x_max=x_max)) return self.consolidate_table_rows(rows) def _calc_x_val(self, y, a, b, h, k): @@ -153,8 +135,9 @@ def _calc_sqrt_portion(y, a, b, k): class PolyCursorInfo(CursorInfoBase): - def __init__(self, nodes, is_numeric): - super().__init__(is_numeric) + def __init__(self, nodes, transpose): + super().__init__(transpose) + self._lines = self._generate_lines(nodes) if not self._check_intersecting_lines(): raise RuntimeError("Polygon shapes with more than 1 intersection point are not supported.") @@ -208,10 +191,12 @@ def _generate_lines(self, nodes): return lines def _generate_line(self, start, end): - start_y = self.snap_to_bin_centre(start[1]) - end_y = self.snap_to_bin_centre(end[1]) - start_x = start[0] - end_x = end[0] + y_index = 1 if not self._transpose else 0 + x_index = 0 if not self._transpose else 1 + start_y = self.snap_to_bin_centre(start[y_index]) + end_y = self.snap_to_bin_centre(end[y_index]) + start_x = start[x_index] + end_x = end[x_index] m = (start_y - end_y) / (start_x - end_x) c = start_y - m * start_x return Line(start=(start_x, start_y), end=(end_x, end_y), m=m, c=c) @@ -250,11 +235,10 @@ def _intersecting_line(line_1, line_2): class MaskingModel: - def __init__(self, ws_name, numeric_axis): + def __init__(self, ws_name): self._active_mask = None self._masks = [] self._ws_name = ws_name - self._numeric_axis = numeric_axis def update_active_mask(self, mask): self._active_mask = mask @@ -270,14 +254,14 @@ def store_active_mask(self): def clear_stored_masks(self): self._masks = [] - def add_rect_cursor_info(self, click, release): - self.update_active_mask(RectCursorInfo(click=click, release=release, is_numeric=self._numeric_axis)) + def add_rect_cursor_info(self, click, release, transpose): + self.update_active_mask(RectCursorInfo(click=click, release=release, transpose=transpose)) - def add_elli_cursor_info(self, click, release): - self.update_active_mask(ElliCursorInfo(click=click, release=release, is_numeric=self._numeric_axis)) + def add_elli_cursor_info(self, click, release, transpose): + self.update_active_mask(ElliCursorInfo(click=click, release=release, transpose=transpose)) - def add_poly_cursor_info(self, nodes): - self.update_active_mask(PolyCursorInfo(nodes=nodes, is_numeric=self._numeric_axis)) + def add_poly_cursor_info(self, nodes, transpose): + self.update_active_mask(PolyCursorInfo(nodes=nodes, transpose=transpose)) @staticmethod def create_table_workspace_from_rows(table_rows, store_in_ads): diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/base_presenter.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/base_presenter.py index 2bc13451d2b7..45139761ad73 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/base_presenter.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/base_presenter.py @@ -21,6 +21,10 @@ def __init__(self, ws, data_view: SliceViewerDataView, model: SliceViewerBaseMod self._data_view: SliceViewerDataView = data_view self.normalization = False + # For now, disable masking if y axis is numeric. + if self.model.ws.getAxis(1).isNumeric(): + self._data_view.deactivate_and_disable_tool(ToolItemText.MASKING) + def show_all_data_clicked(self): """Instructs the view to show all data""" if WorkspaceInfo.is_ragged_matrix_workspace(self.model.ws): @@ -130,8 +134,7 @@ def masking(self, active) -> None: self._data_view.deactivate_and_disable_tool(ToolItemText.ZOOM) self._data_view.deactivate_and_disable_tool(ToolItemText.PAN) self._data_view.deactivate_and_disable_tool(ToolItemText.REGIONSELECTION) - numeric_axis = self.model.ws.getAxis(1).extractValues() if self.model.ws.getAxis(1).isNumeric() else None - self._data_view.masking = Masking(self._data_view, self.model.ws.name(), numeric_axis) + self._data_view.masking = Masking(self._data_view, self.model.ws.name()) self._data_view.masking.new_selector(ToolItemText.RECT_MASKING) # default to rect masking self._data_view.activate_tool(ToolItemText.RECT_MASKING, True) return diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/masking.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/masking.py index c766bfc1b294..37d31637c57d 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/masking.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/masking.py @@ -20,6 +20,7 @@ def __init__(self, dataview, model): self._dataview = dataview self._model = model self._clear = False + self._transpose = dataview.dimensions.get_states() == [1, 0] @abstractmethod def _on_selected(self, eclick, erelease): @@ -113,7 +114,7 @@ def __init__(self, dataview, model): super().__init__(dataview, model, RectangleSelector) def add_cursor_info(self, click, release): - self._model.add_rect_cursor_info(click=click, release=release) + self._model.add_rect_cursor_info(click=click, release=release, transpose=self._transpose) class EllipticalSelectionMasking(RectangleSelectionMaskingBase): @@ -125,7 +126,7 @@ def __init__(self, dataview, model): super().__init__(dataview, model, EllipseSelector) def add_cursor_info(self, click, release): - self._model.add_elli_cursor_info(click=click, release=release) + self._model.add_elli_cursor_info(click=click, release=release, transpose=self._transpose) class PolygonSelectionMasking(SelectionMaskingBase): @@ -154,7 +155,7 @@ def _on_selected(self, eclick, erelease=None): self._dataview.mpl_toolbar.set_action_checked(SELECTOR_TO_TOOL_ITEM_TEXT[self.__class__], False, trigger=False) nodes = [cursor_info(self._img, node[0], node[1]) for node in eclick] try: - self._model.add_poly_cursor_info(nodes) + self._model.add_poly_cursor_info(nodes=nodes, transpose=self._transpose) except RuntimeError as e: self.clear() self._mask_drawn = False @@ -181,11 +182,11 @@ class Masking: Manages Masking """ - def __init__(self, dataview, ws_name, is_numeric): + def __init__(self, dataview, ws_name): self._selectors = [] self._active_selector = None self._dataview = dataview - self._model = MaskingModel(ws_name, is_numeric) + self._model = MaskingModel(ws_name) def _reset_active_selector(self): if self._active_selector: diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/presenter.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/presenter.py index 72975d648304..acc439a09cda 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/presenter.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/presenter.py @@ -217,6 +217,11 @@ def dimensions_changed(self): else: data_view.disable_tool_button(ToolItemText.NONORTHOGONAL_AXES) + # Reset masking if dimensions changed + if self.view.data_view.masking: + self.view.data_view.masking.clear_and_disconnect() + self.view.data_view.masking.clear_model() + ws_type = WorkspaceInfo.get_ws_type(self.model.ws) if ws_type == WS_TYPE.MDH or ws_type == WS_TYPE.MDE: if ( From ef5a6329e17108e789f6676784bd5c2d5af1c53b Mon Sep 17 00:00:00 2001 From: MialLewis <95620982+MialLewis@users.noreply.github.com> Date: Tue, 2 Sep 2025 14:41:49 +0100 Subject: [PATCH 19/33] disable masking for md workspaces --- .../sliceviewer/presenters/base_presenter.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/base_presenter.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/base_presenter.py index 45139761ad73..f1d0fd0682c8 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/base_presenter.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/base_presenter.py @@ -7,7 +7,7 @@ from mantidqt.widgets.sliceviewer.models.base_model import SliceViewerBaseModel from mantidqt.widgets.sliceviewer.models.dimensions import Dimensions -from mantidqt.widgets.sliceviewer.models.workspaceinfo import WorkspaceInfo +from mantidqt.widgets.sliceviewer.models.workspaceinfo import WorkspaceInfo, WS_TYPE from mantidqt.widgets.sliceviewer.presenters.lineplots import PixelLinePlot, RectangleSelectionLinePlot from mantidqt.widgets.sliceviewer.views.dataview import SliceViewerDataView from mantidqt.widgets.sliceviewer.views.dataviewsubscriber import IDataViewSubscriber @@ -21,8 +21,7 @@ def __init__(self, ws, data_view: SliceViewerDataView, model: SliceViewerBaseMod self._data_view: SliceViewerDataView = data_view self.normalization = False - # For now, disable masking if y axis is numeric. - if self.model.ws.getAxis(1).isNumeric(): + if self._disable_masking: self._data_view.deactivate_and_disable_tool(ToolItemText.MASKING) def show_all_data_clicked(self): @@ -150,6 +149,20 @@ def _clean_up_masking(self): self._data_view.canvas.flush_events() # flush before we set masking to None self._data_view.masking = None + @property + def _disable_masking(self): + # Disable masking if not supported. + # If a use case arises, we could extend support to these areas + + # if not histo workspace + ws_type = WorkspaceInfo.get_ws_type(self.model.ws) + if not ws_type == WS_TYPE.MATRIX: + return True + # If y-axis is numeric. + if self.model.ws.getAxis(1).isNumeric(): + return True + return False + @abstractmethod def get_extra_image_info_columns(self, xdata, ydata): pass From 1ff063f0e1759c537e439830304a3d57f764ece5 Mon Sep 17 00:00:00 2001 From: MialLewis <95620982+MialLewis@users.noreply.github.com> Date: Tue, 2 Sep 2025 17:15:47 +0100 Subject: [PATCH 20/33] correct sv tests --- .../sliceviewer/test/test_base_presenter.py | 15 +++++++++ .../test/test_sliceviewer_imageinfowidget.py | 15 ++++++--- .../test/test_sliceviewer_presenter.py | 33 +++++++++++++++---- 3 files changed, 52 insertions(+), 11 deletions(-) diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/test/test_base_presenter.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/test/test_base_presenter.py index 73397b5bdea6..104b511a67fb 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/test/test_base_presenter.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/test/test_base_presenter.py @@ -43,6 +43,21 @@ def get_extra_image_info_columns(self): def is_integer_frame(self): return False, False + def apply_masking_clicked(self): + pass + + def elli_masking_clicked(self, active): + pass + + def export_masking_clicked(self): + pass + + def poly_masking_clicked(self, active): + pass + + def rect_masking_clicked(self, active): + pass + class SliceViewerBasePresenterTest(unittest.TestCase): def setUp(self): diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/test/test_sliceviewer_imageinfowidget.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/test/test_sliceviewer_imageinfowidget.py index cea9008ca871..e5df81f06508 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/test/test_sliceviewer_imageinfowidget.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/test/test_sliceviewer_imageinfowidget.py @@ -10,10 +10,13 @@ from sys import float_info import unittest from unittest.mock import MagicMock, patch +from collections import namedtuple from mantidqt.widgets.sliceviewer.presenters.imageinfowidget import ImageInfoWidget, ImageInfoTracker from mantidqt.widgets.sliceviewer.models.transform import NonOrthogonalTransform +CursorInfo = namedtuple("CursorInfo", ("array", "extent", "point")) + class ImageInfoTrackerTest(unittest.TestCase): def setUp(self): @@ -45,7 +48,9 @@ def test_nonortho_transform_applied_when_mesh_and_transfrom_true(self): @patch("mantidqt.widgets.sliceviewer.presenters.imageinfowidget.cursor_info") def test_nonortho_transform_not_applied_when_not_mesh(self, mock_cursorinfo): - mock_cursorinfo.return_value = (2 * eye(2, dtype=float), None, (1, 1)) # output simple 2D array and slice indices + mock_cursorinfo.return_value = CursorInfo( + array=2 * eye(2, dtype=float), extent=None, point=(1, 1) + ) # output simple 2D array and slice indices tracker = ImageInfoTracker( image=self.image, presenter=self.presenter, transform=self.nonortho_transform, do_transform=True, widget=self.image_info_widget ) @@ -59,7 +64,7 @@ def test_cursorAt_arguments_correct_when_not_mesh_and_not_transposed(self, mock_ underlying_array = array([[1.0, 2.0], [3.0, 4.0]]) xdata, ydata = 0.0, 1.0 # Data on image x and y axes at cursor position xindex, yindex = 0, 1 - mock_cursorinfo.return_value = (underlying_array, None, (xindex, yindex)) + mock_cursorinfo.return_value = CursorInfo(array=underlying_array, extent=None, point=(xindex, yindex)) image = self.image image.transpose = False tracker = ImageInfoTracker( @@ -74,7 +79,7 @@ def test_cursorAt_arguments_correct_when_not_mesh_and_transposed(self, mock_curs underlying_array = array([[1.0, 2.0], [3.0, 4.0]]) xdata, ydata = 0.0, 1.0 # Data on image x and y axes at cursor position xindex, yindex = 0, 1 - mock_cursorinfo.return_value = (underlying_array, None, (xindex, yindex)) + mock_cursorinfo.return_value = CursorInfo(array=underlying_array, extent=None, point=(xindex, yindex)) image = self.image image.transpose = True tracker = ImageInfoTracker( @@ -91,7 +96,7 @@ def test_cursorAt_called_with_extra_cols_specified_by_model(self, mock_cursorinf underlying_array = array([[1.0, 2.0], [3.0, 4.0]]) xdata, ydata = 0.0, 1.0 # Data on image x and y axes at cursor position xindex, yindex = 0, 1 - mock_cursorinfo.return_value = (underlying_array, None, (xindex, yindex)) + mock_cursorinfo.return_value = CursorInfo(array=underlying_array, extent=None, point=(xindex, yindex)) extra_cols = {"H": "1.0", "K": "2.0", "L": "0.0"} presenter_with_extra_cols = MagicMock() presenter_with_extra_cols.get_extra_image_info_columns.return_value = extra_cols @@ -109,7 +114,7 @@ def test_cursorAt_called_with_extra_cols_specified_by_model_mesh(self, mock_curs underlying_array = array([[1.0, 2.0], [3.0, 4.0]]) xdata, ydata = 0.0, 1.0 # Data on image x and y axes at cursor position xindex, yindex = 0, 1 - mock_cursorinfo.return_value = (underlying_array, None, (xindex, yindex)) + mock_cursorinfo.return_value = CursorInfo(array=underlying_array, extent=None, point=(xindex, yindex)) extra_cols = {"H": "1.0", "K": "2.0", "L": "0.0"} presenter_with_extra_cols = MagicMock() presenter_with_extra_cols.get_extra_image_info_columns.return_value = extra_cols diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/test/test_sliceviewer_presenter.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/test/test_sliceviewer_presenter.py index 44eac11fc8b4..49aa8163355a 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/test/test_sliceviewer_presenter.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/test/test_sliceviewer_presenter.py @@ -11,7 +11,7 @@ import unittest from unittest import mock from unittest.mock import patch, call -from mantid.api import IMDHistoWorkspace +from mantid.api import IMDHistoWorkspace, MatrixWorkspace from mantid.kernel import SpecialCoordinateSystem from mantidqt.utils.qt.testing import start_qapplication @@ -39,7 +39,7 @@ def _create_presenter(model: SliceViewerModel, view, mock_sliceinfo_cls, enable_nonortho_axes, supports_nonortho): data_view_mock = view.data_view data_view_mock.plot_MDH = mock.Mock() - presenter = SliceViewer(None, model=model, view=view) + presenter = SliceViewer(ws=model.ws, model=model, view=view) # Patch out things from the base presenter presenter.show_all_data_clicked = mock.Mock() @@ -122,7 +122,10 @@ def setUp(self): data_view4D.dimensions = dimensions4D self.view4D.data_view = data_view4D + mock_ws = mock.Mock(spec=MatrixWorkspace) self.model = mock.Mock(spec=SliceViewerModel) + # self.model._ws = mock_ws - May need this + self.model.ws = mock_ws self.model.get_ws = mock.Mock() self.model.get_data = mock.Mock() self.model.rebin = mock.Mock() @@ -148,13 +151,17 @@ def tearDown(self) -> None: self._ws_info_patcher.stop() def test_on_close(self): - pres = SliceViewer(mock.Mock(), model=mock.MagicMock(), view=mock.MagicMock()) + mock_model = mock.MagicMock() + mock_model.ws = mock.Mock(spec=MatrixWorkspace) + pres = SliceViewer(mock.Mock(), model=mock_model, view=mock.MagicMock()) self.assertIsNotNone(pres.ads_observer) pres.clear_observer() self.assertEqual(pres.ads_observer, None) def test_notify_close_from_qt(self): - pres = SliceViewer(mock.Mock(), model=mock.MagicMock(), view=mock.MagicMock()) + mock_model = mock.MagicMock() + mock_model.ws = mock.Mock(spec=MatrixWorkspace) + pres = SliceViewer(mock.Mock(), model=mock_model, view=mock.MagicMock()) pres.notify_close() self.assertIsNone(pres.view) @@ -268,7 +275,9 @@ def test_non_orthogonal_axes_toggled_on(self): presenter.nonorthogonal_axes(True) - data_view_mock.deactivate_and_disable_tool.assert_called_once_with(ToolItemText.REGIONSELECTION) + data_view_mock.deactivate_and_disable_tool.assert_has_calls( + [mock.call(ToolItemText.REGIONSELECTION)], [mock.call(ToolItemText.MASKING)] + ) data_view_mock.create_axes_nonorthogonal.assert_called_once() data_view_mock.create_axes_orthogonal.assert_not_called() data_view_mock.disable_tool_button.assert_has_calls([mock.call(ToolItemText.LINEPLOTS)]) @@ -539,6 +548,7 @@ def test_clear_observer_peaks_presenter_is_none(self): def test_delete_workspace(self): mock_model = mock.MagicMock() + mock_model.ws = mock.Mock(spec=MatrixWorkspace) mock_view = mock.MagicMock() pres = SliceViewer(mock.Mock(), model=mock_model, view=mock_view) mock_model.workspace_equals.return_value = True @@ -548,6 +558,7 @@ def test_delete_workspace(self): def test_workspace_not_deleted_with_different_name(self): mock_model = mock.MagicMock() + mock_model.ws = mock.Mock(spec=MatrixWorkspace) mock_view = mock.MagicMock() pres = SliceViewer(mock.Mock(), model=mock_model, view=mock_view) mock_model.workspace_equals.return_value = False @@ -557,6 +568,7 @@ def test_workspace_not_deleted_with_different_name(self): def test_delete_original_workspace(self): mock_model = mock.MagicMock() + mock_model.ws = mock.Mock(spec=MatrixWorkspace) mock_view = mock.MagicMock() pres = SliceViewer(mock.Mock(), model=mock_model, view=mock_view) mock_model.workspace_equals.return_value = False @@ -566,6 +578,7 @@ def test_delete_original_workspace(self): def test_delete_not_original_workspace(self): mock_model = mock.MagicMock() + mock_model.ws = mock.Mock(spec=MatrixWorkspace) mock_view = mock.MagicMock() pres = SliceViewer(mock.Mock(), model=mock_model, view=mock_view) mock_model.workspace_equals.return_value = False @@ -575,6 +588,7 @@ def test_delete_not_original_workspace(self): def test_replace_original_workspace(self): mock_model = mock.MagicMock() + mock_model.ws = mock.Mock(spec=MatrixWorkspace) mock_view = mock.MagicMock() pres = SliceViewer(mock.Mock(), model=mock_model, view=mock_view) mock_model.check_for_removed_original_workspace.return_value = True @@ -585,6 +599,7 @@ def test_replace_original_workspace(self): def test_replace_checking_original_workspace_fail(self): mock_model = mock.MagicMock() + mock_model.ws = mock.Mock(spec=MatrixWorkspace) mock_view = mock.MagicMock() pres = SliceViewer(mock.Mock(), model=mock_model, view=mock_view) mock_model.check_for_removed_original_workspace = mock.Mock(side_effect=RuntimeError) @@ -595,6 +610,7 @@ def test_replace_checking_original_workspace_fail(self): def test_replace_workspace_does_nothing_if_workspace_is_unchanged(self): mock_model = mock.MagicMock() + mock_model.ws = mock.Mock(spec=MatrixWorkspace) mock_view = mock.MagicMock() pres = SliceViewer(mock.Mock(), model=mock_model, view=mock_view) # TODO The return value here should be True but there is a bug in the @@ -611,6 +627,7 @@ def test_replace_workspace_does_nothing_if_workspace_is_unchanged(self): def test_replace_workspace_replaces_model(self): mock_model = mock.MagicMock() + mock_model.ws = mock.Mock(spec=MatrixWorkspace) mock_view = mock.MagicMock() pres = SliceViewer(mock.Mock(), model=mock_model, view=mock_view) mock_model.check_for_removed_original_workspace.return_value = False @@ -621,6 +638,7 @@ def test_replace_workspace_replaces_model(self): def test_rename_workspace(self): mock_model = mock.MagicMock() + mock_model.ws = mock.Mock(spec=MatrixWorkspace) mock_view = mock.MagicMock() pres = SliceViewer(mock.Mock(), model=mock_model, view=mock_view) mock_model.workspace_equals.return_value = True @@ -630,6 +648,7 @@ def test_rename_workspace(self): def test_rename_workspace_not_renamed_with_different_name(self): mock_model = mock.MagicMock() + mock_model.ws = mock.Mock(spec=MatrixWorkspace) mock_view = mock.MagicMock() pres = SliceViewer(mock.Mock(), model=mock_model, view=mock_view) mock_model.workspace_equals.return_value = False @@ -639,7 +658,9 @@ def test_rename_workspace_not_renamed_with_different_name(self): def test_clear_ADS(self): mock_view = mock.MagicMock() - pres = SliceViewer(mock.Mock(), model=mock.MagicMock(), view=mock_view) + model_mock = mock.MagicMock() + model_mock.ws = mock.Mock(spec=MatrixWorkspace) + pres = SliceViewer(mock.Mock(), model=model_mock, view=mock_view) pres.ADS_cleared() mock_view.emit_close.assert_called_once() From f3fd49d9ad19dc6c02f0846ac123f553e37e6a0e Mon Sep 17 00:00:00 2001 From: MialLewis <95620982+MialLewis@users.noreply.github.com> Date: Wed, 3 Sep 2025 09:43:28 +0100 Subject: [PATCH 21/33] fix region selector tests --- .../widgets/regionselector/presenter.py | 18 ++++++++ .../test/test_regionselector_presenter.py | 41 ++++++++++--------- .../sliceviewer/presenters/base_presenter.py | 2 +- 3 files changed, 40 insertions(+), 21 deletions(-) diff --git a/qt/python/mantidqt/mantidqt/widgets/regionselector/presenter.py b/qt/python/mantidqt/mantidqt/widgets/regionselector/presenter.py index b85d06019418..ba671c516e0d 100644 --- a/qt/python/mantidqt/mantidqt/widgets/regionselector/presenter.py +++ b/qt/python/mantidqt/mantidqt/widgets/regionselector/presenter.py @@ -258,3 +258,21 @@ def get_extra_image_info_columns(self, xdata, ydata): def is_integer_frame(self): return False, False + + def masking(self, active): + pass + + def rect_masking_clicked(self, active): + pass + + def elli_masking_clicked(self, active): + pass + + def poly_masking_clicked(self, active): + pass + + def export_masking_clicked(self): + pass + + def apply_masking_clicked(self): + pass diff --git a/qt/python/mantidqt/mantidqt/widgets/regionselector/test/test_regionselector_presenter.py b/qt/python/mantidqt/mantidqt/widgets/regionselector/test/test_regionselector_presenter.py index a2c526c95a54..f5083626ded4 100644 --- a/qt/python/mantidqt/mantidqt/widgets/regionselector/test/test_regionselector_presenter.py +++ b/qt/python/mantidqt/mantidqt/widgets/regionselector/test/test_regionselector_presenter.py @@ -10,6 +10,7 @@ from mantidqt.widgets.regionselector.presenter import RegionSelector from mantidqt.widgets.sliceviewer.models.workspaceinfo import WS_TYPE +from mantid.api import MatrixWorkspace class RegionSelectorTest(unittest.TestCase): @@ -24,10 +25,10 @@ def tearDown(self) -> None: self._ws_info_patcher.stop() def test_matrix_workspaces_allowed(self): - self.assertIsNotNone(RegionSelector(Mock(), view=Mock())) + self.assertIsNotNone(RegionSelector(ws=Mock(spec=MatrixWorkspace), view=Mock())) def test_show_all_data_not_called_on_creation(self): - region_selector = RegionSelector(ws=Mock(), view=Mock()) + region_selector = RegionSelector(ws=Mock(spec=MatrixWorkspace), view=Mock()) region_selector.show_all_data_clicked = Mock() region_selector.show_all_data_clicked.assert_not_called() @@ -49,7 +50,7 @@ def test_creation_without_workspace(self): def test_update_workspace_updates_model(self): region_selector = RegionSelector(view=Mock()) region_selector.show_all_data_clicked = Mock() - mock_ws = Mock() + mock_ws = Mock(spec=MatrixWorkspace) region_selector.update_workspace(mock_ws) @@ -57,7 +58,7 @@ def test_update_workspace_updates_model(self): def test_update_workspace_with_invalid_workspaces_fails(self): invalid_types = [WS_TYPE.MDH, WS_TYPE.MDE, None] - mock_ws = Mock() + mock_ws = Mock(spec=MatrixWorkspace) region_selector = RegionSelector(view=Mock()) for ws_type in invalid_types: @@ -69,7 +70,7 @@ def test_update_workspace_updates_view(self): mock_view = Mock() region_selector = RegionSelector(view=mock_view) region_selector.show_all_data_clicked = Mock() - mock_ws = Mock() + mock_ws = Mock(spec=MatrixWorkspace) region_selector.update_workspace(mock_ws) @@ -77,7 +78,7 @@ def test_update_workspace_updates_view(self): region_selector.show_all_data_clicked.assert_called_once() def test_add_rectangular_region_creates_selector(self): - region_selector = RegionSelector(ws=Mock(), view=self.mock_view) + region_selector = RegionSelector(ws=Mock(spec=MatrixWorkspace), view=self.mock_view) region_selector.add_rectangular_region("test", "black", "/") @@ -86,7 +87,7 @@ def test_add_rectangular_region_creates_selector(self): self.assertEqual("test", region_selector._selectors[0].region_type()) def test_add_second_rectangular_region_deactivates_first_selector(self): - region_selector = RegionSelector(ws=Mock(), view=self.mock_view) + region_selector = RegionSelector(ws=Mock(spec=MatrixWorkspace), view=self.mock_view) region_selector.add_rectangular_region("test", "black", "/") region_selector._drawing_region = False @@ -100,7 +101,7 @@ def test_add_second_rectangular_region_deactivates_first_selector(self): def test_clear_workspace_will_clear_all_the_selectors_and_model_workspace(self): region_selector = RegionSelector(view=self.mock_view) region_selector.show_all_data_clicked = Mock() - mock_ws = Mock() + mock_ws = Mock(spec=MatrixWorkspace) region_selector.update_workspace(mock_ws) region_selector.add_rectangular_region("test", "black", "/") @@ -176,7 +177,7 @@ def test_canvas_clicked_sets_only_one_selector_active_if_multiple_contain_point( selector_two.set_active.assert_called_once_with(False) def test_delete_key_pressed_will_do_nothing_if_no_selectors_exist(self): - region_selector = RegionSelector(ws=Mock(), view=Mock()) + region_selector = RegionSelector(ws=Mock(spec=MatrixWorkspace), view=Mock()) event = Mock() event.key = "delete" @@ -228,7 +229,7 @@ def test_delete_key_pressed_will_notify_region_changed(self): mock_observer.notifyRegionChanged.assert_called_once() def test_mouse_moved_will_not_set_override_cursor_if_no_selectors_exist(self): - region_selector = RegionSelector(ws=Mock(), view=Mock()) + region_selector = RegionSelector(ws=Mock(spec=MatrixWorkspace), view=Mock()) region_selector.view.set_override_cursor = Mock() event = Mock() @@ -275,7 +276,7 @@ def test_mouse_moved_will_set_override_cursor_if_hovering_over_active_selector(s region_selector.view.set_override_cursor.assert_called_once_with(True) def test_on_rectangle_selected_notifies_observer(self): - region_selector = RegionSelector(ws=Mock(), view=self.mock_view) + region_selector = RegionSelector(ws=Mock(spec=MatrixWorkspace), view=self.mock_view) mock_observer = Mock() region_selector.subscribe(mock_observer) @@ -285,7 +286,7 @@ def test_on_rectangle_selected_notifies_observer(self): mock_observer.notifyRegionChanged.assert_called_once() def test_cancel_drawing_region_will_remove_last_selector(self): - region_selector = RegionSelector(ws=Mock(), view=self.mock_view) + region_selector = RegionSelector(ws=Mock(spec=MatrixWorkspace), view=self.mock_view) region_selector.add_rectangular_region("test", "black", "/") self.assertEqual(1, len(region_selector._selectors)) region_selector.cancel_drawing_region() @@ -293,7 +294,7 @@ def test_cancel_drawing_region_will_remove_last_selector(self): def test_when_multiple_region_adds_are_requested_only_one_region_is_added(self): # Given - region_selector = RegionSelector(ws=Mock(), view=self.mock_view) + region_selector = RegionSelector(ws=Mock(spec=MatrixWorkspace), view=self.mock_view) region_selector.add_rectangular_region("test", "black", "/") self.assertEqual(1, len(region_selector._selectors)) @@ -305,11 +306,11 @@ def test_when_multiple_region_adds_are_requested_only_one_region_is_added(self): self.assertEqual(region_selector._selectors[0]._region_type, "test2") def test_cancel_drawing_region_with_no_selectors_does_not_crash(self): - region_selector = RegionSelector(ws=Mock(), view=Mock()) + region_selector = RegionSelector(ws=Mock(spec=MatrixWorkspace), view=Mock()) region_selector.cancel_drawing_region() def test_display_rectangular_region_creates_selector(self): - region_selector = RegionSelector(ws=Mock(), view=self._mock_view_with_axes_limits((2000, 10000), (0, 500))) + region_selector = RegionSelector(ws=Mock(spec=MatrixWorkspace), view=self._mock_view_with_axes_limits((2000, 10000), (0, 500))) # The expected extents should be within the axes x and y limits expected_extents = (4000, 6000, 100, 300) region_type = "test" @@ -321,7 +322,7 @@ def test_display_rectangular_region_creates_selector(self): def test_display_rectangular_region_y1_out_of_bounds_does_not_add_selector(self): y_limits = (200, 500) - region_selector = RegionSelector(ws=Mock(), view=self._mock_view_with_axes_limits((2000, 10000), y_limits)) + region_selector = RegionSelector(ws=Mock(spec=MatrixWorkspace), view=self._mock_view_with_axes_limits((2000, 10000), y_limits)) region_selector.display_rectangular_region("test", "black", "/", y_limits[0] - 50, y_limits[1]) @@ -329,14 +330,14 @@ def test_display_rectangular_region_y1_out_of_bounds_does_not_add_selector(self) def test_display_rectangular_region_y2_out_of_bounds_does_not_add_selector(self): y_limits = (200, 500) - region_selector = RegionSelector(ws=Mock(), view=self._mock_view_with_axes_limits((2000, 10000), y_limits)) + region_selector = RegionSelector(ws=Mock(spec=MatrixWorkspace), view=self._mock_view_with_axes_limits((2000, 10000), y_limits)) region_selector.display_rectangular_region("test", "black", "/", y_limits[0], y_limits[1] + 50) self.assertEqual(0, len(region_selector._selectors)) def test_display_rectangular_region_does_not_add_duplicate_selector(self): - region_selector = RegionSelector(ws=Mock(), view=self._mock_view_with_axes_limits((2000, 10000), (0, 500))) + region_selector = RegionSelector(ws=Mock(spec=MatrixWorkspace), view=self._mock_view_with_axes_limits((2000, 10000), (0, 500))) # The expected extents should be within the axes x and y limits expected_extents = (4000, 6000, 100, 300) region_type = "test" @@ -351,7 +352,7 @@ def test_display_rectangular_region_does_not_add_duplicate_selector(self): self._check_rectangular_region(region_selector._selectors[0], region_type, expected_extents) def test_display_rectangular_region_adds_second_different_selector(self): - region_selector = RegionSelector(ws=Mock(), view=self._mock_view_with_axes_limits((2000, 10000), (0, 500))) + region_selector = RegionSelector(ws=Mock(spec=MatrixWorkspace), view=self._mock_view_with_axes_limits((2000, 10000), (0, 500))) # The expected extents should be within the axes x and y limits expected_x_values = (4000, 6000) first_y_values = (100, 200) @@ -376,7 +377,7 @@ def _mock_selectors(selector_one_type="signal", selector_two_type="signal"): selector_one.extents = [1, 2, 3, 4] selector_two.extents = [5, 6, 7, 8] - region_selector = RegionSelector(ws=Mock(), view=Mock()) + region_selector = RegionSelector(ws=Mock(spec=MatrixWorkspace), view=Mock()) region_selector._selectors.append(selector_one) region_selector._selectors.append(selector_two) diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/base_presenter.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/base_presenter.py index f1d0fd0682c8..567af1b98ba9 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/base_presenter.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/base_presenter.py @@ -155,7 +155,7 @@ def _disable_masking(self): # If a use case arises, we could extend support to these areas # if not histo workspace - ws_type = WorkspaceInfo.get_ws_type(self.model.ws) + ws_type = None if not self.model.ws else WorkspaceInfo.get_ws_type(self.model.ws) if not ws_type == WS_TYPE.MATRIX: return True # If y-axis is numeric. From c056cfb6f0015d1f109201d066a269bc2c6c70bd Mon Sep 17 00:00:00 2001 From: MialLewis <95620982+MialLewis@users.noreply.github.com> Date: Wed, 3 Sep 2025 12:05:31 +0100 Subject: [PATCH 22/33] use index rather than spec number --- Framework/Algorithms/src/MaskBinsFromTable.cpp | 1 - .../mantidqt/widgets/sliceviewer/models/masking.py | 14 +++++++------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/Framework/Algorithms/src/MaskBinsFromTable.cpp b/Framework/Algorithms/src/MaskBinsFromTable.cpp index df465fc5c9f1..29712d15aa8c 100644 --- a/Framework/Algorithms/src/MaskBinsFromTable.cpp +++ b/Framework/Algorithms/src/MaskBinsFromTable.cpp @@ -87,7 +87,6 @@ void MaskBinsFromTable::maskBins(const API::MatrixWorkspace_sptr &dataws) { maskbins->setProperty("InputWorkspace", outputws); } maskbins->setProperty("OutputWorkspace", this->getPropertyValue("OutputWorkspace")); - maskbins->setPropertyValue("InputWorkspaceIndexType", "SpectrumNumber"); maskbins->setPropertyValue("InputWorkspaceIndexSet", m_spectraVec[ib]); maskbins->setProperty("XMin", m_xminVec[ib]); maskbins->setProperty("XMax", m_xmaxVec[ib]); diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py index a06f885d6873..5d30f3ab2f4b 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py @@ -5,7 +5,7 @@ from sys import float_info from numpy import inf -ALLOWABLE_ERROR = 8 +ALLOWABLE_ERROR_SIG_FIGS = 8 @dataclass @@ -99,7 +99,7 @@ def __init__(self, click, release, transpose): def generate_table_rows(self): x_data, y_data = self.get_xy_data() y_min, y_max = self.snap_to_bin_centre(y_data[0]), self.snap_to_bin_centre(y_data[-1]) - row = TableRow(spec_list=f"{y_min}-{y_max}", x_min=x_data[0], x_max=x_data[-1]) + row = TableRow(spec_list=f"{y_min - 1}-{y_max - 1}", x_min=x_data[0], x_max=x_data[-1]) return [row] @@ -122,8 +122,8 @@ def generate_table_rows(self): rows = [] for index, y in enumerate(y_range): x_min, x_max = self._calc_x_val(y, a, b, h, k) - x_min = x_min - 10**-ALLOWABLE_ERROR if x_min == x_max else x_min # slightly adjust min value so x vals are different. - rows.append(TableRow(spec_list=str(round(y)), x_min=x_min, x_max=x_max)) + x_min = x_min - 10**-ALLOWABLE_ERROR_SIG_FIGS if x_min == x_max else x_min # slightly adjust min value so x vals are different. + rows.append(TableRow(spec_list=str(round(y - 1)), x_min=x_min, x_max=x_max)) return self.consolidate_table_rows(rows) def _calc_x_val(self, y, a, b, h, k): @@ -131,7 +131,7 @@ def _calc_x_val(self, y, a, b, h, k): @staticmethod def _calc_sqrt_portion(y, a, b, k): - return sqrt(round((a**2) * (1 - ((y - k) ** 2) / (b**2)), ALLOWABLE_ERROR)) + return sqrt(round((a**2) * (1 - ((y - k) ** 2) / (b**2)), ALLOWABLE_ERROR_SIG_FIGS)) class PolyCursorInfo(CursorInfoBase): @@ -150,7 +150,7 @@ def generate_table_rows(self): for y in y_range: x_val_pairs = self._calculate_relevant_x_value_pairs(y) for x_min, x_max in x_val_pairs: - rows.append(TableRow(spec_list=str(round(y)), x_min=x_min, x_max=x_max)) + rows.append(TableRow(spec_list=str(round(y - 1)), x_min=x_min, x_max=x_max)) return self.consolidate_table_rows(rows) def _calculate_relevant_x_value_pairs(self, y): @@ -168,7 +168,7 @@ def _calculate_relevant_x_value_pairs(self, y): open_close_pairs = [] for i in range(0, len(x_vals), 2): x_min, x_max = x_vals[i], x_vals[i + 1] - x_min = x_min - 10**-ALLOWABLE_ERROR if x_min == x_max else x_min # slightly adjust min value so x vals are different. + x_min = x_min - 10**-ALLOWABLE_ERROR_SIG_FIGS if x_min == x_max else x_min # slightly adjust min value so x vals are different. open_close_pairs.append((x_min, x_max)) return open_close_pairs From e37257fb5fa549efeb3018ef79f5d0c1816d5339 Mon Sep 17 00:00:00 2001 From: MialLewis <95620982+MialLewis@users.noreply.github.com> Date: Wed, 3 Sep 2025 15:44:05 +0100 Subject: [PATCH 23/33] make code rabbit changes --- .../v6.14.0/Workbench/SliceViewer/New_features/000000.rst | 1 - .../mantidqt/mantidqt/widgets/sliceviewer/models/masking.py | 6 ++---- .../mantidqt/widgets/sliceviewer/presenters/selector.py | 2 +- .../widgets/sliceviewer/test/test_sliceviewer_presenter.py | 4 ++-- 4 files changed, 5 insertions(+), 8 deletions(-) delete mode 100644 docs/source/release/v6.14.0/Workbench/SliceViewer/New_features/000000.rst diff --git a/docs/source/release/v6.14.0/Workbench/SliceViewer/New_features/000000.rst b/docs/source/release/v6.14.0/Workbench/SliceViewer/New_features/000000.rst deleted file mode 100644 index 03bf216b5c0f..000000000000 --- a/docs/source/release/v6.14.0/Workbench/SliceViewer/New_features/000000.rst +++ /dev/null @@ -1 +0,0 @@ -- Added a masking feature to Sliceviewer for axis with a non-numerical y axis. This enables direct application of the mask to the underlying workspace, or the outputting of a table workspace that can be applied subsequently using ``MaskFromTableWorkspace``. diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py index 5d30f3ab2f4b..b3156d31afc6 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py @@ -263,18 +263,16 @@ def add_elli_cursor_info(self, click, release, transpose): def add_poly_cursor_info(self, nodes, transpose): self.update_active_mask(PolyCursorInfo(nodes=nodes, transpose=transpose)) - @staticmethod - def create_table_workspace_from_rows(table_rows, store_in_ads): + def create_table_workspace_from_rows(self, table_rows, store_in_ads): # create table ws_from rows table_ws = WorkspaceFactory.createTable() table_ws.addColumn("str", "SpectraList") table_ws.addColumn("double", "XMin") table_ws.addColumn("double", "XMax") for row in table_rows: - # if not row.x_min == row.x_max: # the min and max of the ellipse table_ws.addRow([row.spec_list, row.x_min, row.x_max]) if store_in_ads: - AnalysisDataService.addOrReplace("svmask_ws", table_ws) + AnalysisDataService.addOrReplace(f"{self._ws_name}_sv_mask_tbl", table_ws) return table_ws def generate_mask_table_ws(self, store_in_ads=True): diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/selector.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/selector.py index 25586ee50531..26b1913ce84a 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/selector.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/selector.py @@ -38,7 +38,7 @@ def cursor_info(image: AxesImage, xdata: float, ydata: float, full_bbox: Bbox = return None point = point.astype(int) - if 0 <= point[0] <= arr.shape[0] and 0 <= point[1] <= arr.shape[1]: + if 0 <= point[0] < arr.shape[0] and 0 <= point[1] < arr.shape[1]: return CursorInfo(array=arr, extent=extent, point=point, data=(xdata, ydata)) else: return None diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/test/test_sliceviewer_presenter.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/test/test_sliceviewer_presenter.py index 49aa8163355a..999ef3d5c772 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/test/test_sliceviewer_presenter.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/test/test_sliceviewer_presenter.py @@ -276,7 +276,7 @@ def test_non_orthogonal_axes_toggled_on(self): presenter.nonorthogonal_axes(True) data_view_mock.deactivate_and_disable_tool.assert_has_calls( - [mock.call(ToolItemText.REGIONSELECTION)], [mock.call(ToolItemText.MASKING)] + [mock.call(ToolItemText.MASKING), mock.call(ToolItemText.REGIONSELECTION)] ) data_view_mock.create_axes_nonorthogonal.assert_called_once() data_view_mock.create_axes_orthogonal.assert_not_called() @@ -304,7 +304,7 @@ def test_non_orthogonal_axes_toggled_off(self, mock_sliceinfo_cls): data_view_mock.create_axes_orthogonal.assert_called_once() data_view_mock.create_axes_nonorthogonal.assert_not_called() data_view_mock.plot_MDH.assert_called_once() - data_view_mock.enable_tool_button.assert_has_calls((mock.call(ToolItemText.LINEPLOTS), mock.call(ToolItemText.REGIONSELECTION))) + data_view_mock.enable_tool_button.assert_has_calls([mock.call(ToolItemText.LINEPLOTS), mock.call(ToolItemText.REGIONSELECTION)]) @mock.patch("mantidqt.widgets.sliceviewer.presenters.presenter.SliceInfo") def test_non_orthogonal_axes_toggled_off_not_enable_non_axis_cuts_if_not_supported(self, mock_sliceinfo_cls): From 3f1c7a784ca978110f5349631b77216bdb5a137a Mon Sep 17 00:00:00 2001 From: MialLewis <95620982+MialLewis@users.noreply.github.com> Date: Thu, 4 Sep 2025 12:43:07 +0100 Subject: [PATCH 24/33] slight test refactor --- .../sliceviewer/test/test_masking_model.py | 0 .../test/test_masking_presenter.py | 108 ++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 qt/python/mantidqt/mantidqt/widgets/sliceviewer/test/test_masking_model.py create mode 100644 qt/python/mantidqt/mantidqt/widgets/sliceviewer/test/test_masking_presenter.py diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/test/test_masking_model.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/test/test_masking_model.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/test/test_masking_presenter.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/test/test_masking_presenter.py new file mode 100644 index 000000000000..b225dd3fa43f --- /dev/null +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/test/test_masking_presenter.py @@ -0,0 +1,108 @@ +# Mantid Repository : https://github.com/mantidproject/mantid +# +# Copyright © 2021 ISIS Rutherford Appleton Laboratory UKRI, +# NScD Oak Ridge National Laboratory, European Spallation Source, +# Institut Laue - Langevin & CSNS, Institute of High Energy Physics, CAS +# SPDX - License - Identifier: GPL - 3.0 + +import unittest + +from unittest.mock import patch, Mock +from mantidqt.widgets.sliceviewer.presenters.masking import Masking +from mantidqt.widgets.sliceviewer.views.toolbar import ToolItemText + + +class SliceViewerBasePresenterTest(unittest.TestCase): + def setUp(self): + pass + + def tearDown(self) -> None: + pass + + @staticmethod + def _setup_presenter_test(active_selector=None, selector_returns=1): + mock_rect_selector_objs = [Mock() for _ in range(selector_returns)] + mock_rect_selector_cls = Mock(side_effect=mock_rect_selector_objs) + mock_dataview = Mock() + presenter = Masking(dataview=mock_dataview, ws_name=Mock()) + presenter._active_selector = active_selector + return presenter, { + "selector_cls": mock_rect_selector_cls, + "selector_obj": mock_rect_selector_objs, + "dataview": mock_dataview, + "active_selector": active_selector, + } + + @patch("mantidqt.widgets.sliceviewer.presenters.masking.MaskingModel") + def test_new_selector_no_active_selector(self, mock_masking_model_fn): + mock_model = Mock() + mock_masking_model_fn.return_value = mock_model + presenter, mocks = self._setup_presenter_test() + with patch( + "mantidqt.widgets.sliceviewer.presenters.masking.TOOL_ITEM_TEXT_TO_SELECTOR", {ToolItemText.RECT_MASKING: mocks["selector_cls"]} + ): + self.assertIsNone(presenter._active_selector) + presenter.new_selector(text=ToolItemText.RECT_MASKING) + mocks["selector_cls"].called_once_with(mocks["dataview"], mock_model) + mocks["selector_obj"][0].set_active.assert_called_once_with(True) + + @patch("mantidqt.widgets.sliceviewer.presenters.masking.MaskingModel") + def test_new_selector_active_selector_reset_no_mask_drawn(self, mock_masking_model_fn): + mock_model = Mock() + mock_masking_model_fn.return_value = mock_model + presenter, mocks = self._setup_presenter_test(active_selector=Mock(mask_drawn=False)) + with patch( + "mantidqt.widgets.sliceviewer.presenters.masking.TOOL_ITEM_TEXT_TO_SELECTOR", {ToolItemText.RECT_MASKING: mocks["selector_cls"]} + ): + presenter.new_selector(text=ToolItemText.RECT_MASKING) + mocks["selector_cls"].assert_called_once_with(mocks["dataview"], mock_model) + mocks["active_selector"].set_active.assert_called_once_with(False) + mocks["active_selector"].disconnect.assert_called_once() + mocks["active_selector"].clear.assert_called_once() + mocks["selector_obj"][0].set_active.assert_called_once_with(True) + mock_model.clear_active_mask.assert_called_once() + self.assertEqual(presenter._selectors, []) + + @patch("mantidqt.widgets.sliceviewer.presenters.masking.MaskingModel") + def test_new_selector_active_selector_reset_mask_drawn(self, mock_masking_model_fn): + mock_model = Mock() + mock_masking_model_fn.return_value = mock_model + presenter, mocks = self._setup_presenter_test(active_selector=Mock(mask_drawn=True)) + with patch( + "mantidqt.widgets.sliceviewer.presenters.masking.TOOL_ITEM_TEXT_TO_SELECTOR", {ToolItemText.RECT_MASKING: mocks["selector_cls"]} + ): + presenter.new_selector(text=ToolItemText.RECT_MASKING) + mocks["selector_cls"].assert_called_once_with(mocks["dataview"], mock_model) + mocks["active_selector"].set_active.assert_called_once_with(False) + mocks["active_selector"].disconnect.assert_called_once() + mocks["active_selector"].set_inactive_color.assert_called_once() + mock_model.store_active_mask.assert_called_once() + self.assertEqual([mocks["active_selector"]], presenter._selectors) + + @patch("mantidqt.widgets.sliceviewer.presenters.masking.MaskingModel") + def test_export_selectors(self, mock_masking_model_fn): + mock_model = Mock() + mock_masking_model_fn.return_value = mock_model + presenter, mocks = self._setup_presenter_test(active_selector=Mock(mask_drawn=True)) + with ( + patch( + "mantidqt.widgets.sliceviewer.presenters.masking.TOOL_ITEM_TEXT_TO_SELECTOR", + {ToolItemText.RECT_MASKING: mocks["selector_cls"]}, + ), + patch.object(Masking, "_reset_active_selector", autospec=True, wraps=Masking._reset_active_selector) as reset_spy, + ): + presenter = Masking(dataview=mocks["dataview"], ws_name=Mock()) + presenter.export_selectors() + reset_spy.assert_called_once() + + def test_apply_selectors(self): + pass + + def test_clear_and_disconnect(self): + pass + + def test_clear_model(self): + pass + + +if __name__ == "__main__": + unittest.main() From 543039194f358b2d2f502e7fad503ad18d711fb9 Mon Sep 17 00:00:00 2001 From: MialLewis <95620982+MialLewis@users.noreply.github.com> Date: Thu, 4 Sep 2025 14:17:57 +0100 Subject: [PATCH 25/33] finish main presenter tests --- .../test/test_masking_presenter.py | 72 ++++++++++++------- 1 file changed, 45 insertions(+), 27 deletions(-) diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/test/test_masking_presenter.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/test/test_masking_presenter.py index b225dd3fa43f..ff4c8d709079 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/test/test_masking_presenter.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/test/test_masking_presenter.py @@ -32,10 +32,9 @@ def _setup_presenter_test(active_selector=None, selector_returns=1): "active_selector": active_selector, } - @patch("mantidqt.widgets.sliceviewer.presenters.masking.MaskingModel") + @patch("mantidqt.widgets.sliceviewer.presenters.masking.MaskingModel", autospec=True) def test_new_selector_no_active_selector(self, mock_masking_model_fn): - mock_model = Mock() - mock_masking_model_fn.return_value = mock_model + mock_model = mock_masking_model_fn.return_value presenter, mocks = self._setup_presenter_test() with patch( "mantidqt.widgets.sliceviewer.presenters.masking.TOOL_ITEM_TEXT_TO_SELECTOR", {ToolItemText.RECT_MASKING: mocks["selector_cls"]} @@ -45,10 +44,9 @@ def test_new_selector_no_active_selector(self, mock_masking_model_fn): mocks["selector_cls"].called_once_with(mocks["dataview"], mock_model) mocks["selector_obj"][0].set_active.assert_called_once_with(True) - @patch("mantidqt.widgets.sliceviewer.presenters.masking.MaskingModel") + @patch("mantidqt.widgets.sliceviewer.presenters.masking.MaskingModel", autospec=True) def test_new_selector_active_selector_reset_no_mask_drawn(self, mock_masking_model_fn): - mock_model = Mock() - mock_masking_model_fn.return_value = mock_model + mock_model = mock_masking_model_fn.return_value presenter, mocks = self._setup_presenter_test(active_selector=Mock(mask_drawn=False)) with patch( "mantidqt.widgets.sliceviewer.presenters.masking.TOOL_ITEM_TEXT_TO_SELECTOR", {ToolItemText.RECT_MASKING: mocks["selector_cls"]} @@ -62,10 +60,9 @@ def test_new_selector_active_selector_reset_no_mask_drawn(self, mock_masking_mod mock_model.clear_active_mask.assert_called_once() self.assertEqual(presenter._selectors, []) - @patch("mantidqt.widgets.sliceviewer.presenters.masking.MaskingModel") + @patch("mantidqt.widgets.sliceviewer.presenters.masking.MaskingModel", autospec=True) def test_new_selector_active_selector_reset_mask_drawn(self, mock_masking_model_fn): - mock_model = Mock() - mock_masking_model_fn.return_value = mock_model + mock_model = mock_masking_model_fn.return_value presenter, mocks = self._setup_presenter_test(active_selector=Mock(mask_drawn=True)) with patch( "mantidqt.widgets.sliceviewer.presenters.masking.TOOL_ITEM_TEXT_TO_SELECTOR", {ToolItemText.RECT_MASKING: mocks["selector_cls"]} @@ -78,30 +75,51 @@ def test_new_selector_active_selector_reset_mask_drawn(self, mock_masking_model_ mock_model.store_active_mask.assert_called_once() self.assertEqual([mocks["active_selector"]], presenter._selectors) - @patch("mantidqt.widgets.sliceviewer.presenters.masking.MaskingModel") + @patch("mantidqt.widgets.sliceviewer.presenters.masking.MaskingModel", autospec=True) def test_export_selectors(self, mock_masking_model_fn): - mock_model = Mock() - mock_masking_model_fn.return_value = mock_model - presenter, mocks = self._setup_presenter_test(active_selector=Mock(mask_drawn=True)) - with ( - patch( - "mantidqt.widgets.sliceviewer.presenters.masking.TOOL_ITEM_TEXT_TO_SELECTOR", - {ToolItemText.RECT_MASKING: mocks["selector_cls"]}, - ), - patch.object(Masking, "_reset_active_selector", autospec=True, wraps=Masking._reset_active_selector) as reset_spy, - ): - presenter = Masking(dataview=mocks["dataview"], ws_name=Mock()) + mock_model = mock_masking_model_fn.return_value + presenter, mocks = self._setup_presenter_test() + with patch.object(Masking, "_reset_active_selector", autospec=True, wraps=Masking._reset_active_selector) as reset_spy: presenter.export_selectors() reset_spy.assert_called_once() + mock_model.export_selectors.assert_called_once() - def test_apply_selectors(self): - pass + @patch("mantidqt.widgets.sliceviewer.presenters.masking.MaskingModel", autospec=True) + def test_apply_selectors(self, mock_masking_model_fn): + mock_model = mock_masking_model_fn.return_value + presenter, mocks = self._setup_presenter_test(active_selector=Mock(mask_drawn=True)) + with patch.object(Masking, "_reset_active_selector", autospec=True, wraps=Masking._reset_active_selector) as reset_spy: + presenter.apply_selectors() + reset_spy.assert_called_once() + mock_model.apply_selectors.assert_called_once() - def test_clear_and_disconnect(self): - pass + def test_clear_and_disconnect_no_active_selector(self): + presenter, mocks = self._setup_presenter_test() + mock_selectors = [Mock() for _ in range(3)] + presenter._selectors = mock_selectors + self.assertIsNone(presenter._active_selector) + presenter.clear_and_disconnect() + for mock_selector in mock_selectors: + mock_selector.clear.assert_called_once() - def test_clear_model(self): - pass + def test_clear_and_disconnect_active_selector_with_mask(self): + presenter, mocks = self._setup_presenter_test(active_selector=Mock(mask_drawn=True)) + mock_selectors = [Mock() for _ in range(3)] + presenter._selectors = mock_selectors + presenter.clear_and_disconnect() + mocks["active_selector"].set_active.assert_called_once_with(False) + mocks["active_selector"].disconnect.assert_called_once() + mocks["active_selector"].clear.assert_called_once() + for mock_selector in mock_selectors: + mock_selector.clear.assert_called_once() + + @patch("mantidqt.widgets.sliceviewer.presenters.masking.MaskingModel", autospec=True) + def test_clear_model(self, mock_masking_model_fn): + mock_model = mock_masking_model_fn.return_value + presenter, mocks = self._setup_presenter_test() + presenter.clear_model() + mock_model.clear_active_mask.assert_called_once() + mock_model.clear_stored_masks.assert_called_once() if __name__ == "__main__": From f267e33b6466a42b4028c82274376e72e69afd3e Mon Sep 17 00:00:00 2001 From: MialLewis <95620982+MialLewis@users.noreply.github.com> Date: Thu, 4 Sep 2025 18:59:52 +0100 Subject: [PATCH 26/33] finish presenter tests --- qt/python/mantidqt/CMakeLists.txt | 1 + .../test/test_masking_presenter.py | 126 -------- .../test/test_sliceviewer_maskingpresenter.py | 297 ++++++++++++++++++ 3 files changed, 298 insertions(+), 126 deletions(-) delete mode 100644 qt/python/mantidqt/mantidqt/widgets/sliceviewer/test/test_masking_presenter.py create mode 100644 qt/python/mantidqt/mantidqt/widgets/sliceviewer/test/test_sliceviewer_maskingpresenter.py diff --git a/qt/python/mantidqt/CMakeLists.txt b/qt/python/mantidqt/CMakeLists.txt index eeb0596cedf6..150a38ce49eb 100644 --- a/qt/python/mantidqt/CMakeLists.txt +++ b/qt/python/mantidqt/CMakeLists.txt @@ -127,6 +127,7 @@ set(PYTHON_WIDGET_QT5_ONLY_TESTS mantidqt/widgets/sliceviewer/test/test_sliceviewer_cursortracker.py mantidqt/widgets/sliceviewer/test/test_sliceviewer_imageinfowidget.py mantidqt/widgets/sliceviewer/test/test_sliceviewer_lineplots.py + mantidqt/widgets/sliceviewer/test/test_sliceviewer_maskingpresenter.py mantidqt/widgets/sliceviewer/test/test_sliceviewer_model.py mantidqt/widgets/sliceviewer/test/test_sliceviewer_movemousecursor.py mantidqt/widgets/sliceviewer/test/test_sliceviewer_presenter.py diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/test/test_masking_presenter.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/test/test_masking_presenter.py deleted file mode 100644 index ff4c8d709079..000000000000 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/test/test_masking_presenter.py +++ /dev/null @@ -1,126 +0,0 @@ -# Mantid Repository : https://github.com/mantidproject/mantid -# -# Copyright © 2021 ISIS Rutherford Appleton Laboratory UKRI, -# NScD Oak Ridge National Laboratory, European Spallation Source, -# Institut Laue - Langevin & CSNS, Institute of High Energy Physics, CAS -# SPDX - License - Identifier: GPL - 3.0 + -import unittest - -from unittest.mock import patch, Mock -from mantidqt.widgets.sliceviewer.presenters.masking import Masking -from mantidqt.widgets.sliceviewer.views.toolbar import ToolItemText - - -class SliceViewerBasePresenterTest(unittest.TestCase): - def setUp(self): - pass - - def tearDown(self) -> None: - pass - - @staticmethod - def _setup_presenter_test(active_selector=None, selector_returns=1): - mock_rect_selector_objs = [Mock() for _ in range(selector_returns)] - mock_rect_selector_cls = Mock(side_effect=mock_rect_selector_objs) - mock_dataview = Mock() - presenter = Masking(dataview=mock_dataview, ws_name=Mock()) - presenter._active_selector = active_selector - return presenter, { - "selector_cls": mock_rect_selector_cls, - "selector_obj": mock_rect_selector_objs, - "dataview": mock_dataview, - "active_selector": active_selector, - } - - @patch("mantidqt.widgets.sliceviewer.presenters.masking.MaskingModel", autospec=True) - def test_new_selector_no_active_selector(self, mock_masking_model_fn): - mock_model = mock_masking_model_fn.return_value - presenter, mocks = self._setup_presenter_test() - with patch( - "mantidqt.widgets.sliceviewer.presenters.masking.TOOL_ITEM_TEXT_TO_SELECTOR", {ToolItemText.RECT_MASKING: mocks["selector_cls"]} - ): - self.assertIsNone(presenter._active_selector) - presenter.new_selector(text=ToolItemText.RECT_MASKING) - mocks["selector_cls"].called_once_with(mocks["dataview"], mock_model) - mocks["selector_obj"][0].set_active.assert_called_once_with(True) - - @patch("mantidqt.widgets.sliceviewer.presenters.masking.MaskingModel", autospec=True) - def test_new_selector_active_selector_reset_no_mask_drawn(self, mock_masking_model_fn): - mock_model = mock_masking_model_fn.return_value - presenter, mocks = self._setup_presenter_test(active_selector=Mock(mask_drawn=False)) - with patch( - "mantidqt.widgets.sliceviewer.presenters.masking.TOOL_ITEM_TEXT_TO_SELECTOR", {ToolItemText.RECT_MASKING: mocks["selector_cls"]} - ): - presenter.new_selector(text=ToolItemText.RECT_MASKING) - mocks["selector_cls"].assert_called_once_with(mocks["dataview"], mock_model) - mocks["active_selector"].set_active.assert_called_once_with(False) - mocks["active_selector"].disconnect.assert_called_once() - mocks["active_selector"].clear.assert_called_once() - mocks["selector_obj"][0].set_active.assert_called_once_with(True) - mock_model.clear_active_mask.assert_called_once() - self.assertEqual(presenter._selectors, []) - - @patch("mantidqt.widgets.sliceviewer.presenters.masking.MaskingModel", autospec=True) - def test_new_selector_active_selector_reset_mask_drawn(self, mock_masking_model_fn): - mock_model = mock_masking_model_fn.return_value - presenter, mocks = self._setup_presenter_test(active_selector=Mock(mask_drawn=True)) - with patch( - "mantidqt.widgets.sliceviewer.presenters.masking.TOOL_ITEM_TEXT_TO_SELECTOR", {ToolItemText.RECT_MASKING: mocks["selector_cls"]} - ): - presenter.new_selector(text=ToolItemText.RECT_MASKING) - mocks["selector_cls"].assert_called_once_with(mocks["dataview"], mock_model) - mocks["active_selector"].set_active.assert_called_once_with(False) - mocks["active_selector"].disconnect.assert_called_once() - mocks["active_selector"].set_inactive_color.assert_called_once() - mock_model.store_active_mask.assert_called_once() - self.assertEqual([mocks["active_selector"]], presenter._selectors) - - @patch("mantidqt.widgets.sliceviewer.presenters.masking.MaskingModel", autospec=True) - def test_export_selectors(self, mock_masking_model_fn): - mock_model = mock_masking_model_fn.return_value - presenter, mocks = self._setup_presenter_test() - with patch.object(Masking, "_reset_active_selector", autospec=True, wraps=Masking._reset_active_selector) as reset_spy: - presenter.export_selectors() - reset_spy.assert_called_once() - mock_model.export_selectors.assert_called_once() - - @patch("mantidqt.widgets.sliceviewer.presenters.masking.MaskingModel", autospec=True) - def test_apply_selectors(self, mock_masking_model_fn): - mock_model = mock_masking_model_fn.return_value - presenter, mocks = self._setup_presenter_test(active_selector=Mock(mask_drawn=True)) - with patch.object(Masking, "_reset_active_selector", autospec=True, wraps=Masking._reset_active_selector) as reset_spy: - presenter.apply_selectors() - reset_spy.assert_called_once() - mock_model.apply_selectors.assert_called_once() - - def test_clear_and_disconnect_no_active_selector(self): - presenter, mocks = self._setup_presenter_test() - mock_selectors = [Mock() for _ in range(3)] - presenter._selectors = mock_selectors - self.assertIsNone(presenter._active_selector) - presenter.clear_and_disconnect() - for mock_selector in mock_selectors: - mock_selector.clear.assert_called_once() - - def test_clear_and_disconnect_active_selector_with_mask(self): - presenter, mocks = self._setup_presenter_test(active_selector=Mock(mask_drawn=True)) - mock_selectors = [Mock() for _ in range(3)] - presenter._selectors = mock_selectors - presenter.clear_and_disconnect() - mocks["active_selector"].set_active.assert_called_once_with(False) - mocks["active_selector"].disconnect.assert_called_once() - mocks["active_selector"].clear.assert_called_once() - for mock_selector in mock_selectors: - mock_selector.clear.assert_called_once() - - @patch("mantidqt.widgets.sliceviewer.presenters.masking.MaskingModel", autospec=True) - def test_clear_model(self, mock_masking_model_fn): - mock_model = mock_masking_model_fn.return_value - presenter, mocks = self._setup_presenter_test() - presenter.clear_model() - mock_model.clear_active_mask.assert_called_once() - mock_model.clear_stored_masks.assert_called_once() - - -if __name__ == "__main__": - unittest.main() diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/test/test_sliceviewer_maskingpresenter.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/test/test_sliceviewer_maskingpresenter.py new file mode 100644 index 000000000000..ca1a0cedb68b --- /dev/null +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/test/test_sliceviewer_maskingpresenter.py @@ -0,0 +1,297 @@ +# Mantid Repository : https://github.com/mantidproject/mantid +# +# Copyright © 2021 ISIS Rutherford Appleton Laboratory UKRI, +# NScD Oak Ridge National Laboratory, European Spallation Source, +# Institut Laue - Langevin & CSNS, Institute of High Energy Physics, CAS +# SPDX - License - Identifier: GPL - 3.0 + +import unittest + +from unittest.mock import patch, Mock, call +from collections import namedtuple + +from mantidqt.widgets.sliceviewer.presenters.masking import Masking, INACTIVE_HANDLE_STYLE, INACTIVE_SHAPE_STYLE, TOOL_ITEM_TEXT_TO_SELECTOR +from mantidqt.widgets.sliceviewer.views.toolbar import ToolItemText + +from matplotlib.widgets import EllipseSelector + + +class SliceViewerMaskingPresenterTest(unittest.TestCase): + @staticmethod + def _setup_presenter_test(active_selector=None, selector_returns=1): + mock_rect_selector_objs = [Mock() for _ in range(selector_returns)] + mock_rect_selector_cls = Mock(side_effect=mock_rect_selector_objs) + mock_dataview = Mock() + presenter = Masking(dataview=mock_dataview, ws_name=Mock()) + presenter._active_selector = active_selector + return presenter, { + "selector_cls": mock_rect_selector_cls, + "selector_obj": mock_rect_selector_objs, + "dataview": mock_dataview, + "active_selector": active_selector, + } + + @patch("mantidqt.widgets.sliceviewer.presenters.masking.MaskingModel", autospec=True) + def test_new_selector_no_active_selector(self, mock_masking_model_fn): + mock_model = mock_masking_model_fn.return_value + presenter, mocks = self._setup_presenter_test() + with patch( + "mantidqt.widgets.sliceviewer.presenters.masking.TOOL_ITEM_TEXT_TO_SELECTOR", {ToolItemText.RECT_MASKING: mocks["selector_cls"]} + ): + self.assertIsNone(presenter._active_selector) + presenter.new_selector(text=ToolItemText.RECT_MASKING) + mocks["selector_cls"].called_once_with(mocks["dataview"], mock_model) + mocks["selector_obj"][0].set_active.assert_called_once_with(True) + + @patch("mantidqt.widgets.sliceviewer.presenters.masking.MaskingModel", autospec=True) + def test_new_selector_active_selector_reset_no_mask_drawn(self, mock_masking_model_fn): + mock_model = mock_masking_model_fn.return_value + presenter, mocks = self._setup_presenter_test(active_selector=Mock(mask_drawn=False)) + with patch( + "mantidqt.widgets.sliceviewer.presenters.masking.TOOL_ITEM_TEXT_TO_SELECTOR", {ToolItemText.RECT_MASKING: mocks["selector_cls"]} + ): + presenter.new_selector(text=ToolItemText.RECT_MASKING) + mocks["selector_cls"].assert_called_once_with(mocks["dataview"], mock_model) + mocks["active_selector"].set_active.assert_called_once_with(False) + mocks["active_selector"].disconnect.assert_called_once() + mocks["active_selector"].clear.assert_called_once() + mocks["selector_obj"][0].set_active.assert_called_once_with(True) + mock_model.clear_active_mask.assert_called_once() + self.assertEqual(presenter._selectors, []) + + @patch("mantidqt.widgets.sliceviewer.presenters.masking.MaskingModel", autospec=True) + def test_new_selector_active_selector_reset_mask_drawn(self, mock_masking_model_fn): + mock_model = mock_masking_model_fn.return_value + presenter, mocks = self._setup_presenter_test(active_selector=Mock(mask_drawn=True)) + with patch( + "mantidqt.widgets.sliceviewer.presenters.masking.TOOL_ITEM_TEXT_TO_SELECTOR", {ToolItemText.RECT_MASKING: mocks["selector_cls"]} + ): + presenter.new_selector(text=ToolItemText.RECT_MASKING) + mocks["selector_cls"].assert_called_once_with(mocks["dataview"], mock_model) + mocks["active_selector"].set_active.assert_called_once_with(False) + mocks["active_selector"].disconnect.assert_called_once() + mocks["active_selector"].set_inactive_color.assert_called_once() + mock_model.store_active_mask.assert_called_once() + self.assertEqual([mocks["active_selector"]], presenter._selectors) + + @patch("mantidqt.widgets.sliceviewer.presenters.masking.MaskingModel", autospec=True) + def test_export_selectors(self, mock_masking_model_fn): + mock_model = mock_masking_model_fn.return_value + presenter, mocks = self._setup_presenter_test() + with patch.object(Masking, "_reset_active_selector", autospec=True, wraps=Masking._reset_active_selector) as reset_spy: + presenter.export_selectors() + reset_spy.assert_called_once() + mock_model.export_selectors.assert_called_once() + + @patch("mantidqt.widgets.sliceviewer.presenters.masking.MaskingModel", autospec=True) + def test_apply_selectors(self, mock_masking_model_fn): + mock_model = mock_masking_model_fn.return_value + presenter, mocks = self._setup_presenter_test(active_selector=Mock(mask_drawn=True)) + with patch.object(Masking, "_reset_active_selector", autospec=True, wraps=Masking._reset_active_selector) as reset_spy: + presenter.apply_selectors() + reset_spy.assert_called_once() + mock_model.apply_selectors.assert_called_once() + + def test_clear_and_disconnect_no_active_selector(self): + presenter, mocks = self._setup_presenter_test() + mock_selectors = [Mock() for _ in range(3)] + presenter._selectors = mock_selectors + + self.assertIsNone(presenter._active_selector) + presenter.clear_and_disconnect() + for mock_selector in mock_selectors: + mock_selector.clear.assert_called_once() + + def test_clear_and_disconnect_active_selector_with_mask(self): + presenter, mocks = self._setup_presenter_test(active_selector=Mock(mask_drawn=True)) + mock_selectors = [Mock() for _ in range(3)] + presenter._selectors = mock_selectors + + presenter.clear_and_disconnect() + mocks["active_selector"].set_active.assert_called_once_with(False) + mocks["active_selector"].disconnect.assert_called_once() + mocks["active_selector"].clear.assert_called_once() + for mock_selector in mock_selectors: + mock_selector.clear.assert_called_once() + + @patch("mantidqt.widgets.sliceviewer.presenters.masking.MaskingModel", autospec=True) + def test_clear_model(self, mock_masking_model_fn): + mock_model = mock_masking_model_fn.return_value + presenter, mocks = self._setup_presenter_test() + presenter.clear_model() + mock_model.clear_active_mask.assert_called_once() + mock_model.clear_stored_masks.assert_called_once() + + +class SliceViewerSelectionMaskingTest(unittest.TestCase): + Click = namedtuple("Click", ["xdata", "ydata"]) + + @staticmethod + def _set_up_selector_test(text, transpose=False, images=None): + mock_dataview = Mock() + mock_dataview.dimensions.get_states.return_value = [1, 0] if transpose else [0, 1] + mock_dataview.ax.images = images + mock_model = Mock() + selector = TOOL_ITEM_TEXT_TO_SELECTOR[text](mock_dataview, mock_model) + return selector, { + "dataview": mock_dataview, + "model": mock_model, + } + + def test_add_cursor_info_rectangle(self): + with patch("mantidqt.widgets.sliceviewer.presenters.masking.make_selector_class"): + selector, mocks = self._set_up_selector_test(ToolItemText.RECT_MASKING) + + selector.add_cursor_info("test_click", "test_release") + mocks["model"].add_rect_cursor_info.assert_called_once_with(click="test_click", release="test_release", transpose=False) + + def test_add_cursor_info_elli(self): + with patch("mantidqt.widgets.sliceviewer.presenters.masking.make_selector_class"): + selector, mocks = self._set_up_selector_test(ToolItemText.ELLI_MASKING) + + selector.add_cursor_info("test_click", "test_release") + mocks["model"].add_elli_cursor_info.assert_called_once_with(click="test_click", release="test_release", transpose=False) + + def _test_inactive_color_rect_base(self, text): + with patch("mantidqt.widgets.sliceviewer.presenters.masking.make_selector_class") as selector_fn_mock: + internal_selector_mock = Mock() + selector_fn_mock.return_value.return_value = internal_selector_mock + selector, _ = self._set_up_selector_test(text) + + selector.set_inactive_color() + internal_selector_mock.set_props.assert_called_once_with(fill=False, **INACTIVE_SHAPE_STYLE) + internal_selector_mock.set_handle_props.assert_called_once_with(**INACTIVE_HANDLE_STYLE) + + def test_set_inactive_color_rectangle(self): + self._test_inactive_color_rect_base(ToolItemText.RECT_MASKING) + + def test_set_inactive_color_elli(self): + self._test_inactive_color_rect_base(ToolItemText.ELLI_MASKING) + + def test_set_inactive_color_poly(self): + with patch("mantidqt.widgets.sliceviewer.presenters.masking.make_selector_class") as selector_fn_mock: + internal_selector_mock = Mock() + selector_fn_mock.return_value.return_value = internal_selector_mock + selector, _ = self._set_up_selector_test(ToolItemText.POLY_MASKING) + + selector.set_inactive_color() + internal_selector_mock.set_props.assert_called_once_with(**INACTIVE_SHAPE_STYLE) + internal_selector_mock.set_handle_props.assert_called_once_with(**INACTIVE_HANDLE_STYLE) + + def test_set_active(self): + with patch("mantidqt.widgets.sliceviewer.presenters.masking.make_selector_class") as selector_fn_mock: + internal_selector_mock = Mock() + selector_fn_mock.return_value.return_value = internal_selector_mock + selector, _ = self._set_up_selector_test(ToolItemText.POLY_MASKING) + + selector.set_active(False) + selector.set_active(True) + internal_selector_mock.set_active.assert_has_calls([call(False), call(True)]) + + def test_clear(self): + with patch("mantidqt.widgets.sliceviewer.presenters.masking.make_selector_class") as selector_fn_mock: + internal_selector_mock = Mock() + mock_artists = [Mock() for _ in range(3)] + internal_selector_mock.artists = mock_artists + selector_fn_mock.return_value.return_value = internal_selector_mock + selector, _ = self._set_up_selector_test(ToolItemText.ELLI_MASKING) + + selector.clear() + selector.clear() # test second clear doesn't error + for artist in mock_artists: + artist.remove.assert_called_once() + + def test_disconnect(self): + with patch("mantidqt.widgets.sliceviewer.presenters.masking.make_selector_class") as selector_fn_mock: + internal_selector_mock = Mock() + selector_fn_mock.return_value.return_value = internal_selector_mock + selector, _ = self._set_up_selector_test(ToolItemText.RECT_MASKING) + + selector.disconnect() + internal_selector_mock.disconnect_events.assert_called_once() + + def test_remove_mask_from_model(self): + with patch("mantidqt.widgets.sliceviewer.presenters.masking.make_selector_class") as selector_fn_mock: + internal_selector_mock = Mock() + selector_fn_mock.return_value.return_value = internal_selector_mock + selector, mocks = self._set_up_selector_test(ToolItemText.RECT_MASKING) + + selector.remove_mask_from_model() + mocks["model"].clear_active_mask.assert_not_called() + selector._mask_drawn = True + selector.remove_mask_from_model() + mocks["model"].clear_active_mask.assert_called_once() + + def test_init(self): + with patch("mantidqt.widgets.sliceviewer.presenters.masking.make_selector_class") as selector_fn_mock: + internal_selector_mock = Mock() + selector_fn_mock.return_value.return_value = internal_selector_mock + + selector, mocks = self._set_up_selector_test(ToolItemText.ELLI_MASKING) + self.assertFalse(selector._transpose) + selector_fn_mock.assert_called_with(EllipseSelector, selector.remove_mask_from_model) + _, args, kwargs = selector_fn_mock.return_value.mock_calls[0] + self.assertEqual(args[1], selector._on_selected) + + def test_init_transpose(self): + with patch("mantidqt.widgets.sliceviewer.presenters.masking.make_selector_class") as selector_fn_mock: + internal_selector_mock = Mock() + selector_fn_mock.return_value.return_value = internal_selector_mock + + selector, mocks = self._set_up_selector_test(ToolItemText.ELLI_MASKING, transpose=True) + self.assertTrue(selector._transpose) + + def test_on_selected_rect(self): + with ( + patch("mantidqt.widgets.sliceviewer.presenters.masking.make_selector_class") as selector_fn_mock, + patch("mantidqt.widgets.sliceviewer.presenters.masking.cursor_info") as cursor_info_fn, + patch("mantidqt.widgets.sliceviewer.presenters.masking.RectangleSelectionMasking.add_cursor_info") as add_cursor_info_mock, + ): + internal_selector_mock = Mock() + selector_fn_mock.return_value.return_value = internal_selector_mock + selector, mocks = self._set_up_selector_test(ToolItemText.RECT_MASKING, images=["test"]) + cursor_info_mock = [Mock() for _ in range(2)] + cursor_info_fn.side_effect = cursor_info_mock + + selector._on_selected(self.Click(0, 0), self.Click(10, 10)) + mocks["dataview"].mpl_toolbar.set_action_checked.assert_called_once_with(ToolItemText.RECT_MASKING, False, trigger=False) + add_cursor_info_mock.assert_called_once_with(*cursor_info_mock) + + def test_on_selected_poly(self): + with ( + patch("mantidqt.widgets.sliceviewer.presenters.masking.make_selector_class") as selector_fn_mock, + patch("mantidqt.widgets.sliceviewer.presenters.masking.cursor_info") as cursor_info_fn, + ): + internal_selector_mock = Mock() + selector_fn_mock.return_value.return_value = internal_selector_mock + selector, mocks = self._set_up_selector_test(ToolItemText.POLY_MASKING, images=["test"]) + cursor_info_mock = [Mock() for _ in range(3)] + cursor_info_fn.side_effect = cursor_info_mock + + selector._on_selected([self.Click(0, 0), self.Click(5, 5), self.Click(0, 10)]) + mocks["dataview"].mpl_toolbar.set_action_checked.assert_called_once_with(ToolItemText.POLY_MASKING, False, trigger=False) + mocks["model"].add_poly_cursor_info.assert_called_with(nodes=cursor_info_mock, transpose=False) + + def test_on_selected_poly_error(self): + with ( + patch("mantidqt.widgets.sliceviewer.presenters.masking.make_selector_class") as selector_fn_mock, + patch("mantidqt.widgets.sliceviewer.presenters.masking.cursor_info") as cursor_info_fn, + ): + internal_selector_mock = Mock() + selector_fn_mock.return_value.return_value = internal_selector_mock + selector, mocks = self._set_up_selector_test(ToolItemText.POLY_MASKING, images=["test"]) + selector.clear = Mock() + selector.disconnect = Mock() + selector.set_active = Mock() + cursor_info_mock = [Mock() for _ in range(3)] + cursor_info_fn.side_effect = cursor_info_mock + mocks["model"].add_poly_cursor_info.side_effect = RuntimeError("test") + + selector._on_selected([self.Click(0, 0), self.Click(5, 5), self.Click(0, 10)]) + mocks["model"].add_poly_cursor_info.assert_called_with(nodes=cursor_info_mock, transpose=False) + selector.clear.assert_called_once() + selector.disconnect.assert_called_once() + selector.set_active.assert_called_once_with(False) + + +if __name__ == "__main__": + unittest.main() From fb3415b353e12108bac1638ba7080f6a9ddbf3fe Mon Sep 17 00:00:00 2001 From: MialLewis <95620982+MialLewis@users.noreply.github.com> Date: Fri, 5 Sep 2025 10:26:27 +0100 Subject: [PATCH 27/33] use spec num for masking --- Framework/Algorithms/src/MaskBinsFromTable.cpp | 6 ++++++ .../mantidqt/widgets/sliceviewer/models/masking.py | 7 ++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/Framework/Algorithms/src/MaskBinsFromTable.cpp b/Framework/Algorithms/src/MaskBinsFromTable.cpp index 29712d15aa8c..43d2e1ffa234 100644 --- a/Framework/Algorithms/src/MaskBinsFromTable.cpp +++ b/Framework/Algorithms/src/MaskBinsFromTable.cpp @@ -41,6 +41,10 @@ void MaskBinsFromTable::init() { std::make_unique>("MaskingInformation", "", Direction::Input), "Input TableWorkspace containing parameters, XMin and " "XMax and either SpectraList or DetectorIDsList"); + this->declareProperty( + std::make_unique("InputWorkspaceIndexType", static_cast(IndexType::SpectrumNum) | + static_cast(IndexType::WorkspaceIndex)), + "Identity input index list as spectra or index numbers"); } //---------------------------------------------------------------------------------------------- @@ -87,6 +91,8 @@ void MaskBinsFromTable::maskBins(const API::MatrixWorkspace_sptr &dataws) { maskbins->setProperty("InputWorkspace", outputws); } maskbins->setProperty("OutputWorkspace", this->getPropertyValue("OutputWorkspace")); + const std::string &indexType = getProperty("InputWorkspaceIndexType"); + maskbins->setPropertyValue("InputWorkspaceIndexType", indexType); maskbins->setPropertyValue("InputWorkspaceIndexSet", m_spectraVec[ib]); maskbins->setProperty("XMin", m_xminVec[ib]); maskbins->setProperty("XMax", m_xmaxVec[ib]); diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py index b3156d31afc6..061f4d62117c 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py @@ -99,7 +99,7 @@ def __init__(self, click, release, transpose): def generate_table_rows(self): x_data, y_data = self.get_xy_data() y_min, y_max = self.snap_to_bin_centre(y_data[0]), self.snap_to_bin_centre(y_data[-1]) - row = TableRow(spec_list=f"{y_min - 1}-{y_max - 1}", x_min=x_data[0], x_max=x_data[-1]) + row = TableRow(spec_list=f"{y_min}-{y_max}", x_min=x_data[0], x_max=x_data[-1]) return [row] @@ -123,7 +123,7 @@ def generate_table_rows(self): for index, y in enumerate(y_range): x_min, x_max = self._calc_x_val(y, a, b, h, k) x_min = x_min - 10**-ALLOWABLE_ERROR_SIG_FIGS if x_min == x_max else x_min # slightly adjust min value so x vals are different. - rows.append(TableRow(spec_list=str(round(y - 1)), x_min=x_min, x_max=x_max)) + rows.append(TableRow(spec_list=str(round(y)), x_min=x_min, x_max=x_max)) return self.consolidate_table_rows(rows) def _calc_x_val(self, y, a, b, h, k): @@ -150,7 +150,7 @@ def generate_table_rows(self): for y in y_range: x_val_pairs = self._calculate_relevant_x_value_pairs(y) for x_min, x_max in x_val_pairs: - rows.append(TableRow(spec_list=str(round(y - 1)), x_min=x_min, x_max=x_max)) + rows.append(TableRow(spec_list=str(round(y)), x_min=x_min, x_max=x_max)) return self.consolidate_table_rows(rows) def _calculate_relevant_x_value_pairs(self, y): @@ -292,4 +292,5 @@ def apply_selectors(self): alg.setProperty("InputWorkspace", self._ws_name) alg.setProperty("OutputWorkspace", self._ws_name) alg.setProperty("MaskingInformation", mask_ws) + alg.setProperty("InputWorkspaceIndexType", "SpectrumNumber") alg.execute() From 34dc6a4fcb551423fb9904188d9c065bf6d6522c Mon Sep 17 00:00:00 2001 From: MialLewis <95620982+MialLewis@users.noreply.github.com> Date: Fri, 5 Sep 2025 12:14:00 +0100 Subject: [PATCH 28/33] add masking model tests --- .../sliceviewer/test/test_masking_model.py | 0 .../test/test_sliceviewer_maskingmodel.py | 107 ++ .../test/test_sliceviewer_model.py | 1160 ----------------- 3 files changed, 107 insertions(+), 1160 deletions(-) delete mode 100644 qt/python/mantidqt/mantidqt/widgets/sliceviewer/test/test_masking_model.py create mode 100644 qt/python/mantidqt/mantidqt/widgets/sliceviewer/test/test_sliceviewer_maskingmodel.py delete mode 100644 qt/python/mantidqt/mantidqt/widgets/sliceviewer/test/test_sliceviewer_model.py diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/test/test_masking_model.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/test/test_masking_model.py deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/test/test_sliceviewer_maskingmodel.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/test/test_sliceviewer_maskingmodel.py new file mode 100644 index 000000000000..8ea8a70dfe63 --- /dev/null +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/test/test_sliceviewer_maskingmodel.py @@ -0,0 +1,107 @@ +# Mantid Repository : https://github.com/mantidproject/mantid +# +# Copyright © 2021 ISIS Rutherford Appleton Laboratory UKRI, +# NScD Oak Ridge National Laboratory, European Spallation Source, +# Institut Laue - Langevin & CSNS, Institute of High Energy Physics, CAS +# SPDX - License - Identifier: GPL - 3.0 + +import unittest + +from unittest.mock import patch +from collections import namedtuple + +from mantidqt.widgets.sliceviewer.models.masking import MaskingModel + + +class SliceViewerMaskingModelTest(unittest.TestCase): + TableRow = namedtuple("TableRow", ["spec_list", "x_min", "x_max"]) + + def setUp(self): + self.model = MaskingModel("test_ws") + + @staticmethod + def _set_up_model_test(text, transpose=False, images=None): + pass + + def test_clear_active_mask(self): + self.model._active_mask = "test" + self.model.clear_active_mask() + self.assertIsNone(self.model._active_mask) + + def test_store_active_mask(self): + self.model._active_mask = "test" + self.model.store_active_mask() + self._masks = ["test"] + + def test_store_active_mask_no_active_mask(self): + self.model.store_active_mask() + self.model._masks = None + + def test_clear_stored_masks(self): + self.model._active_mask = "test" + self.model.store_active_mask() + self.assertEqual(self.model._masks, ["test"]) + self.model.clear_stored_masks() + self.assertEqual([], self.model._masks) + + @patch("mantidqt.widgets.sliceviewer.models.masking.RectCursorInfo") + def test_add_rect_cursor_info(self, CursorInfo_mock): + kwargs = {"click": "test_click", "release": "test_release", "transpose": "test_transpose"} + self.model.add_rect_cursor_info(**kwargs) + CursorInfo_mock.assert_called_once_with(**kwargs) + + @patch("mantidqt.widgets.sliceviewer.models.masking.ElliCursorInfo") + def test_add_elli_cursor_info(self, CursorInfo_mock): + kwargs = {"click": "test_click", "release": "test_release", "transpose": "test_transpose"} + self.model.add_elli_cursor_info(**kwargs) + CursorInfo_mock.assert_called_once_with(**kwargs) + + @patch("mantidqt.widgets.sliceviewer.models.masking.PolyCursorInfo") + def test_add_poly_cursor_info(self, CursorInfo_mock): + kwargs = {"nodes": "nodes", "transpose": "test_transpose"} + self.model.add_poly_cursor_info(**kwargs) + CursorInfo_mock.assert_called_once_with(**kwargs) + + def test_create_table_workspace_from_rows(self): + table_ws = self.model.create_table_workspace_from_rows( + [self.TableRow("1", 5, 8), self.TableRow("2", 6, 9), self.TableRow("3-10", 7, 10)], False + ) + self.assertEqual(table_ws.column("SpectraList"), ["1", "2", "3-10"]) + self.assertEqual(table_ws.column("XMin"), [5, 6, 7]) + self.assertEqual(table_ws.column("XMax"), [8, 9, 10]) + + def test_create_table_workspace_from_rows_store_in_ads(self): + with patch("mantidqt.widgets.sliceviewer.models.masking.AnalysisDataService") as ads_mock: + table_ws = self.model.create_table_workspace_from_rows( + [self.TableRow("1", 5, 8), self.TableRow("2", 6, 9), self.TableRow("3-10", 7, 10)], True + ) + self.assertEqual(table_ws.column("SpectraList"), ["1", "2", "3-10"]) + self.assertEqual(table_ws.column("XMin"), [5, 6, 7]) + self.assertEqual(table_ws.column("XMax"), [8, 9, 10]) + ads_mock.addOrReplace.called_once_with("test_ws_sv_mask_tbl", table_ws) + + def test_generate_mask_table_ws(self): + pass + + def test_export_selectors(self): + pass + + def test_apply_selectors(self): + pass + + +class CursorInfoTest(unittest.TestCase): + Click = namedtuple("Click", ["xdata", "ydata"]) + + @staticmethod + def _set_up_model_test(text, transpose=False, images=None): + pass + + def test_clear_active_mask(self): + pass + + def test_store_active_mask(self): + pass + + +if __name__ == "__main__": + unittest.main() diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/test/test_sliceviewer_model.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/test/test_sliceviewer_model.py deleted file mode 100644 index 4605fc192785..000000000000 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/test/test_sliceviewer_model.py +++ /dev/null @@ -1,1160 +0,0 @@ -# Mantid Repository : https://github.com/mantidproject/mantid -# -# Copyright © 2018 ISIS Rutherford Appleton Laboratory UKRI, -# NScD Oak Ridge National Laboratory, European Spallation Source, -# Institut Laue - Langevin & CSNS, Institute of High Energy Physics, CAS -# SPDX - License - Identifier: GPL - 3.0 + -# This file is part of the mantid workbench. -# -# -from contextlib import contextmanager -import unittest -from unittest.mock import MagicMock, call, patch, DEFAULT, Mock - -from mantid.api import MatrixWorkspace, IMDEventWorkspace, IMDHistoWorkspace, MultipleExperimentInfos -from mantid.kernel import SpecialCoordinateSystem -from mantid.geometry import IMDDimension, OrientedLattice -import numpy as np -from mantidqt.widgets.sliceviewer.models.model import SliceViewerModel, MIN_WIDTH - - -# Mock helpers -def _create_mock_histoworkspace( - ndims: int, - coords: SpecialCoordinateSystem, - extents: tuple, - signal: np.array, - error: np.array, - nbins: tuple, - names: tuple, - units: tuple, - isq: tuple, -): - """ - :param ndims: The number of dimensions - :param coords: MD coordinate system - :param extents: Extents of each dimension - :param signal: Array to be returned as signal - :param error: Array to be returned as errors - :param nbins: Number of bins in each dimension - :param names: The name of each dimension - :param units: Unit labels for each dimension - :param isq: Boolean for each dimension defining if Q or not - """ - ws = _create_mock_workspace(IMDHistoWorkspace, coords, has_oriented_lattice=False, ndims=ndims) - ws.getSignalArray.return_value = signal - ws.getErrorSquaredArray.return_value = error - ws.hasOriginalWorkspace.side_effect = lambda index: False - return _add_dimensions(ws, names, isq, extents, nbins, units) - - -@contextmanager -def _attach_as_original(histo_ws, mde_ws): - """ - Temporarily attach an MDEventWorkspace to a MDHistoWorkspace - as an original workspace - :param histo_ws: A mock MDHistoWorkspace - :param mde_ws: A mock MDEventWorkspace - """ - histo_ws.hasOriginalWorkspace.side_effect = lambda index: True - yield - histo_ws.hasOriginalWorkspace.side_effect = lambda index: False - - -def _create_mock_mdeventworkspace(ndims: int, coords: SpecialCoordinateSystem, extents: tuple, names: tuple, units: tuple, isq: tuple): - """ - :param ndims: The number of dimensions - :param coords: MD coordinate system - :param extents: Extents of each dimension - :param names: The name of each dimension - :param units: Unit labels for each dimension - :param isq: Boolean for each dimension defining if Q or not - """ - ws = _create_mock_workspace(IMDEventWorkspace, coords, has_oriented_lattice=False, ndims=ndims) - return _add_dimensions(ws, names, isq, extents, nbins=(1,) * ndims, units=units) - - -def _create_mock_matrixworkspace( - x_axis: tuple, y_axis: tuple, distribution: bool, names: tuple = None, units: tuple = None, y_is_spectra=True -): - """ - :param x_axis: X axis values - :param y_axis: Y axis values - :param distribution: Value of distribution flag - :param names: The name of each dimension - :param units: Unit labels for each dimension - :param y_is_spectra: True if the Y axis is a spectra axis, else it's a Numeric - """ - ws = MagicMock(MatrixWorkspace) - ws.getNumDims.return_value = 2 - ws.getNumberHistograms.return_value = len(y_axis) - ws.isDistribution.return_value = distribution - ws.extractX.return_value = x_axis - axes = [MagicMock(), MagicMock(isSpectra=lambda: y_is_spectra, isNumeric=lambda: not y_is_spectra)] - ws.getAxis.side_effect = lambda i: axes[i] - axes[1].extractValues.return_value = np.array(y_axis) - if y_is_spectra: - axes[1].indexOfValue.side_effect = lambda i: i + 1 - - if names is None: - names = ("X", "Y") - - extents = (x_axis[0], x_axis[-1], y_axis[0], y_axis[-1]) - nbins = (len(x_axis) - 1, len(y_axis) - 1) - return _add_dimensions(ws, names, (False, False), extents, nbins, units) - - -def _create_mock_workspace(ws_type, coords: SpecialCoordinateSystem = None, has_oriented_lattice: bool = None, ndims: int = 2): - """ - :param ws_type: Used this as spec for Mock - :param coords: MD coordinate system for MD workspaces - :param has_oriented_lattice: If the mock should claim to have an attached a lattice - :param ndims: The number of dimensions - """ - ws = MagicMock(spec=ws_type) - if hasattr(ws, "getExperimentInfo"): - ws.getNumDims.return_value = ndims - ws.getNumNonIntegratedDims.return_value = ndims - if ws_type == IMDHistoWorkspace: - ws.isMDHistoWorkspace.return_value = True - ws.getNonIntegratedDimensions.return_value = [MagicMock(), MagicMock()] - ws.hasOriginalWorkspace.return_value = False - basis_mat = np.eye(ndims) - ws.getBasisVector.side_effect = lambda idim: basis_mat[:, idim] - ws.getDimension().getMDFrame().isQ.return_value = True - else: - ws.isMDHistoWorkspace.return_value = False - - ws.getSpecialCoordinateSystem.return_value = coords - run = MagicMock() - run.get.return_value = MagicMock() - run.get().value = np.eye(3).flatten() # proj matrix is always 3x3 - expt_info = MagicMock() - sample = MagicMock() - sample.hasOrientedLattice.return_value = has_oriented_lattice - if has_oriented_lattice: - lattice = OrientedLattice(1, 1, 1, 90, 90, 90) - sample.getOrientedLattice.return_value = lattice - expt_info.sample.return_value = sample - expt_info.run.return_value = run - ws.getExperimentInfo.return_value = expt_info - ws.getExperimentInfo().sample() - elif hasattr(ws, "getNumberHistograms"): - ws.getNumDims.return_value = 2 - ws.getNumberHistograms.return_value = 3 - mock_dimension = MagicMock() - mock_dimension.getNBins.return_value = 3 - ws.getDimension.return_value = mock_dimension - return ws - - -def _add_dimensions(mock_ws, names, isq, extents: tuple = None, nbins: tuple = None, units: tuple = None): - """ - :param mock_ws: An existing mock workspace object - :param names: The name of each dimension - :param isq: Boolean for each dimension defining if Q or not - :param extents: Extents of each dimension - :param nbins: Number of bins in each dimension - :param units: Unit labels for each dimension - """ - - def create_dimension(index): - dimension = MagicMock(spec=IMDDimension) - dimension.name = names[index] - mdframe = MagicMock() - mdframe.isQ.return_value = isq[index] - dimension.getMDFrame.return_value = mdframe - if units is not None: - dimension.getUnits.return_value = units[index] - if extents is not None: - dim_min, dim_max = extents[2 * index], extents[2 * index + 1] - dimension.getMinimum.return_value = dim_min - dimension.getMaximum.return_value = dim_max - if nbins is not None: - bin_width = (dim_max - dim_min) / nbins[index] - dimension.getBinWidth.return_value = bin_width - dimension.getX.side_effect = lambda i: dim_min + bin_width * i - dimension.getNBins.return_value = nbins[index] - return dimension - - dimensions = [create_dimension(index) for index in range(len(names))] - mock_ws.getDimension.side_effect = lambda index: dimensions[index] - return mock_ws - - -class ArraysEqual: - """Compare arrays for equality in mock.assert_called_with calls.""" - - def __init__(self, expected): - self._expected = expected - - def __eq__(self, other): - return np.all(self._expected == other) - - def __repr__(self): - """Return a string when the test comparison fails""" - return f"{self._expected}" - - -def create_mock_sliceinfo(indices: tuple): - """ - Create mock sliceinfo - :param indices: 3D indices defining permutation order of dimensions - """ - slice_info = MagicMock() - slice_info.transform.side_effect = lambda x: np.array([x[indices[0]], x[indices[1]], x[indices[2]]]) - return slice_info - - -class SliceViewerModelTest(unittest.TestCase): - @classmethod - def setUpClass(self): - self.ws_MD_3D = _create_mock_histoworkspace( - ndims=3, - coords=SpecialCoordinateSystem.NONE, - extents=(-3, 3, -10, 10, -1, 1), - signal=np.arange(100.0).reshape(5, 5, 4), - error=np.arange(100.0).reshape(5, 5, 4), - nbins=(5, 5, 4), - names=("Dim1", "Dim2", "Dim3"), - units=("MomentumTransfer", "EnergyTransfer", "Angstrom"), - isq=(False, False, False), - ) - self.ws_MD_3D.name.return_value = "ws_MD_3D" - self.ws_MDE_3D = _create_mock_mdeventworkspace( - ndims=3, - coords=SpecialCoordinateSystem.NONE, - extents=(-3, 3, -4, 4, -5, 5), - names=("h", "k", "l"), - units=("rlu", "rlu", "rlu"), - isq=(True, True, True), - ) - self.ws_MDE_4D = _create_mock_mdeventworkspace( - ndims=4, - coords=SpecialCoordinateSystem.NONE, - extents=(-2, 2, -3, 3, -4, 4, -5, 5), - names=("e", "h", "k", "l"), - units=("meV", "rlu", "rlu", "rlu"), - isq=(False, True, True, True), - ) - self.ws_MDE_3D.name.return_value = "ws_MDE_3D" - - self.ws2d_histo = _create_mock_matrixworkspace( - x_axis=(10, 20, 30), y_axis=(4, 6, 8), distribution=True, names=("Wavelength", "Energy transfer"), units=("Angstrom", "meV") - ) - self.ws2d_histo.name.return_value = "ws2d_histo" - - def setUp(self): - self.ws2d_histo.reset_mock() - - def test_init_with_valid_MatrixWorkspace(self): - mock_ws = MagicMock(spec=MatrixWorkspace) - mock_ws.getNumberHistograms.return_value = 2 - mock_ws.getDimension.return_value.getNBins.return_value = 2.0 - - self.assertIsNotNone(SliceViewerModel(mock_ws)) - - def test_init_with_valid_MDHistoWorkspace(self): - mock_ws = MagicMock(spec=MultipleExperimentInfos) - mock_ws.name = Mock(return_value="") - mock_ws.isMDHistoWorkspace = Mock(return_value=True) - mock_ws.getNumNonIntegratedDims = Mock(return_value=700) - mock_ws.numOriginalWorkspaces = Mock(return_value=0) - - with patch.object(SliceViewerModel, "_calculate_axes_angles"): - self.assertIsNotNone(SliceViewerModel(mock_ws)) - - def test_init_with_valid_MDEventWorkspace(self): - mock_ws = MagicMock(spec=MultipleExperimentInfos) - mock_ws.name = Mock(return_value="") - mock_ws.isMDHistoWorkspace = Mock(return_value=False) - mock_ws.getNumDims = Mock(return_value=4) - mock_ws.numOriginalWorkspaces = Mock(return_value=0) - - with patch.object(SliceViewerModel, "_calculate_axes_angles"): - self.assertIsNotNone(SliceViewerModel(mock_ws)) - - def test_init_raises_for_incorrect_workspace_type(self): - mock_ws = MagicMock() - - self.assertRaisesRegex(ValueError, "MatrixWorkspace and MDWorkspace", SliceViewerModel, mock_ws) - - def test_init_raises_if_fewer_than_two_histograms(self): - mock_ws = MagicMock(spec=MatrixWorkspace) - mock_ws.getNumberHistograms.return_value = 1 - - self.assertRaisesRegex(ValueError, "contain at least 2 spectra", SliceViewerModel, mock_ws) - - def test_init_raises_if_fewer_than_two_bins(self): - mock_ws = MagicMock(spec=MatrixWorkspace) - mock_ws.getNumberHistograms.return_value = 2 - mock_ws.getDimension.return_value.getNBins.return_value = 1 - - self.assertRaisesRegex(ValueError, "contain at least 2 bins", SliceViewerModel, mock_ws) - - def test_init_raises_if_fewer_than_two_integrated_dimensions(self): - mock_ws = MagicMock(spec=MultipleExperimentInfos) - mock_ws.isMDHistoWorkspace = Mock(return_value=True) - mock_ws.getNumNonIntegratedDims = Mock(return_value=1) - mock_ws.name = Mock(return_value="") - - self.assertRaisesRegex(ValueError, "at least 2 non-integrated dimensions", SliceViewerModel, mock_ws) - - def test_init_raises_if_fewer_than_two_dimensions(self): - mock_ws = MagicMock(spec=MultipleExperimentInfos) - mock_ws.isMDHistoWorkspace = Mock(return_value=False) - mock_ws.getNumDims = Mock(return_value=1) - mock_ws.name = Mock(return_value="") - - self.assertRaisesRegex(ValueError, "at least 2 dimensions", SliceViewerModel, mock_ws) - - @patch("mantidqt.widgets.sliceviewer.models.model.BinMD") - def test_model_MDE_basis_vectors_not_normalised_when_HKL(self, mock_binmd): - ws = _create_mock_mdeventworkspace( - ndims=3, - coords=SpecialCoordinateSystem.HKL, - extents=(-3, 3, -4, 4, -5, 5), - names=("h", "k", "l"), - units=("r.l.u.", "r.l.u.", "r.l.u."), - isq=(True, True, True), - ) - model = SliceViewerModel(ws) - mock_binmd.return_value = self.ws_MD_3D # different workspace - - self.assertNotEqual(model.get_ws((None, None, 0), (1, 2, 4)), ws) - - mock_binmd.assert_called_once_with( - AxisAligned=False, - NormalizeBasisVectors=False, - BasisVector0="h,r.l.u.,1.0,0.0,0.0", - BasisVector1="k,r.l.u.,0.0,1.0,0.0", - BasisVector2="l,r.l.u.,0.0,0.0,1.0", - EnableLogging=False, - InputWorkspace=ws, - OutputBins=[1, 2, 1], - OutputExtents=[-3, 3, -4, 4, -2.0, 2.0], - OutputWorkspace=ws.name() + "_svrebinned", - ) - mock_binmd.reset_mock() - - @patch("mantidqt.widgets.sliceviewer.models.model.BinMD") - def test_get_ws_MDE_with_limits_uses_limits_over_dimension_extents(self, mock_binmd): - model = SliceViewerModel(self.ws_MDE_3D) - mock_binmd.return_value = self.ws_MD_3D - - self.assertNotEqual(model.get_ws((None, None, 0), (1, 2, 4), ((-2, 2), (-1, 1)), [0, 1, None]), self.ws_MDE_3D) - - call_params = dict( - AxisAligned=False, - BasisVector0="h,rlu,1.0,0.0,0.0", - BasisVector1="k,rlu,0.0,1.0,0.0", - BasisVector2="l,rlu,0.0,0.0,1.0", - EnableLogging=False, - InputWorkspace=self.ws_MDE_3D, - OutputBins=[1, 2, 1], - OutputExtents=[-2, 2, -1, 1, -2.0, 2.0], - OutputWorkspace="ws_MDE_3D_svrebinned", - ) - mock_binmd.assert_called_once_with(**call_params) - mock_binmd.reset_mock() - - model.get_data((None, None, 0), (1, 2, 4), [0, 1, None], ((-2, 2), (-1, 1))) - mock_binmd.assert_called_once_with(**call_params) - - @patch("mantidqt.widgets.sliceviewer.models.model.BinMD") - def test_get_ws_mde_sets_minimum_width_on_data_limits(self, mock_binmd): - model = SliceViewerModel(self.ws_MDE_3D) - mock_binmd.return_value = self.ws_MD_3D - xmin = -5e-8 - xmax = 5e-8 - - self.assertNotEqual(model.get_ws((None, None, 0), (1, 2, 4), ((xmin, xmax), (-1, 1)), [0, 1, None]), self.ws_MDE_3D) - - call_params = dict( - AxisAligned=False, - BasisVector0="h,rlu,1.0,0.0,0.0", - BasisVector1="k,rlu,0.0,1.0,0.0", - BasisVector2="l,rlu,0.0,0.0,1.0", - EnableLogging=False, - InputWorkspace=self.ws_MDE_3D, - OutputBins=[1, 2, 1], - OutputExtents=[xmin, xmin + MIN_WIDTH, -1, 1, -2.0, 2.0], - OutputWorkspace="ws_MDE_3D_svrebinned", - ) - mock_binmd.assert_called_once_with(**call_params) - mock_binmd.reset_mock() - - def test_matrix_workspace_can_be_normalized_if_not_a_distribution(self): - non_distrib_ws2d = _create_mock_matrixworkspace((1, 2, 3), (4, 5, 6), distribution=False, names=("a", "b")) - model = SliceViewerModel(non_distrib_ws2d) - self.assertTrue(model.can_normalize_workspace()) - - def test_matrix_workspace_cannot_be_normalized_if_a_distribution(self): - model = SliceViewerModel(self.ws2d_histo) - self.assertFalse(model.can_normalize_workspace()) - - def test_MD_workspaces_cannot_be_normalized(self): - model = SliceViewerModel(self.ws_MD_3D) - self.assertFalse(model.can_normalize_workspace()) - - def test_MDE_workspaces_cannot_be_normalized(self): - model = SliceViewerModel(self.ws_MDE_3D) - self.assertFalse(model.can_normalize_workspace()) - - def test_MDH_workspace_in_hkl_supports_non_orthogonal_axes(self): - self._assert_supports_non_orthogonal_axes( - True, ws_type=IMDHistoWorkspace, coords=SpecialCoordinateSystem.HKL, has_oriented_lattice=True - ) - - def test_matrix_workspace_cannot_support_non_axis_aligned_cuts(self): - self._assert_supports_non_axis_aligned_cuts(False, ws_type=MatrixWorkspace) - - def test_MDE_requires_3dims_to_support_non_axis_aligned_cuts(self): - self._assert_supports_non_axis_aligned_cuts(False, ws_type=IMDEventWorkspace, coords=SpecialCoordinateSystem.HKL, ndims=2) - self._assert_supports_non_axis_aligned_cuts(True, ws_type=IMDEventWorkspace, coords=SpecialCoordinateSystem.HKL, ndims=3) - self._assert_supports_non_axis_aligned_cuts(False, ws_type=IMDEventWorkspace, coords=SpecialCoordinateSystem.HKL, ndims=4) - - def test_MDE_workspace_in_hkl_supports_non_orthogonal_axes(self): - self._assert_supports_non_orthogonal_axes( - True, ws_type=IMDEventWorkspace, coords=SpecialCoordinateSystem.HKL, has_oriented_lattice=True - ) - - def test_matrix_workspace_cannot_support_non_orthogonal_axes(self): - self._assert_supports_non_orthogonal_axes( - False, ws_type=MatrixWorkspace, coords=SpecialCoordinateSystem.HKL, has_oriented_lattice=None - ) - - def test_MDH_workspace_in_hkl_without_lattice_cannot_support_non_orthogonal_axes(self): - self._assert_supports_non_orthogonal_axes( - False, ws_type=IMDHistoWorkspace, coords=SpecialCoordinateSystem.HKL, has_oriented_lattice=False - ) - - def test_MDE_workspace_in_hkl_without_lattice_cannot_support_non_orthogonal_axes(self): - self._assert_supports_non_orthogonal_axes( - False, ws_type=IMDEventWorkspace, coords=SpecialCoordinateSystem.HKL, has_oriented_lattice=False - ) - - def test_MDH_workspace_in_non_hkl_cannot_support_non_orthogonal_axes(self): - self._assert_supports_non_orthogonal_axes( - False, ws_type=IMDHistoWorkspace, coords=SpecialCoordinateSystem.QLab, has_oriented_lattice=False - ) - self._assert_supports_non_orthogonal_axes( - False, ws_type=IMDHistoWorkspace, coords=SpecialCoordinateSystem.QLab, has_oriented_lattice=True - ) - - def test_MDE_workspace_in_non_hkl_cannot_support_non_orthogonal_axes(self): - self._assert_supports_non_orthogonal_axes( - False, ws_type=IMDEventWorkspace, coords=SpecialCoordinateSystem.QLab, has_oriented_lattice=False - ) - self._assert_supports_non_orthogonal_axes( - False, ws_type=IMDEventWorkspace, coords=SpecialCoordinateSystem.QLab, has_oriented_lattice=True - ) - - def test_matrix_workspace_cannot_support_peaks_overlay(self): - self._assert_supports_peaks_overlay(False, MatrixWorkspace) - - def test_md_workspace_with_fewer_than_three_dimensions_cannot_support_peaks_overlay(self): - self._assert_supports_peaks_overlay(False, IMDEventWorkspace, ndims=2) - self._assert_supports_peaks_overlay(False, IMDHistoWorkspace, ndims=2) - - def test_md_workspace_with_three_or_more_dimensions_can_support_peaks_overlay(self): - self._assert_supports_peaks_overlay(True, IMDEventWorkspace, ndims=3) - self._assert_supports_peaks_overlay(True, IMDHistoWorkspace, ndims=3) - - def test_title_for_matrixworkspace_just_contains_ws_name(self): - model = SliceViewerModel(self.ws2d_histo) - - self.assertEqual("Sliceviewer - ws2d_histo", model.get_title()) - - def test_title_for_mdeventworkspace_just_contains_ws_name(self): - model = SliceViewerModel(self.ws_MDE_3D) - - self.assertEqual("Sliceviewer - ws_MDE_3D", model.get_title()) - - def test_title_for_mdhistoworkspace_without_original_just_contains_ws_name(self): - model = SliceViewerModel(self.ws_MD_3D) - - self.assertEqual("Sliceviewer - ws_MD_3D", model.get_title()) - - def test_title_for_mdhistoworkspace_with_original(self): - with _attach_as_original(self.ws_MD_3D, self.ws_MDE_3D): - model = SliceViewerModel(self.ws_MD_3D) - - self.assertEqual("Sliceviewer - ws_MD_3D", model.get_title()) - - def test_calculate_axes_angles_returns_none_if_nonorthogonal_transform_not_supported(self): - model = SliceViewerModel(_create_mock_workspace(MatrixWorkspace, SpecialCoordinateSystem.QLab, has_oriented_lattice=False)) - - self.assertIsNone(model.get_axes_angles()) - - def test_calculate_axes_angles_uses_W_if_available_MDEvent(self): - # test MD event - ws = _create_mock_workspace(IMDEventWorkspace, SpecialCoordinateSystem.HKL, has_oriented_lattice=True) - ws.getExperimentInfo().run().get().value = [0, 1, 1, 0, 0, 1, 1, 0, 0] - model = SliceViewerModel(ws) - - axes_angles = model.get_axes_angles() - self.assertAlmostEqual(axes_angles[1, 2], np.pi / 4, delta=1e-10) - for iy in range(1, 3): - self.assertAlmostEqual(axes_angles[0, iy], np.pi / 2, delta=1e-10) - # test force_orthog works - axes_angles = model.get_axes_angles(force_orthogonal=True) - self.assertAlmostEqual(axes_angles[1, 2], np.pi / 2, delta=1e-10) - - def test_calculate_axes_angles_uses_basis_vectors_even_if_WMatrix_log_available_MDHisto(self): - # test MD histo - ws = _create_mock_workspace(IMDHistoWorkspace, SpecialCoordinateSystem.HKL, has_oriented_lattice=True) - ws.getExperimentInfo().run().get().value = [0, 1, 1, 0, 0, 1, 1, 0, 0] - model = SliceViewerModel(ws) - - # should revert to orthogonal (as given by basis vectors on workspace) - # i.e. not angles returned by proj_matrix in ws.getExperimentInfo().run().get().value - axes_angles = model.get_axes_angles() - self.assertAlmostEqual(axes_angles[1, 2], np.pi / 2, delta=1e-10) - for iy in range(1, 3): - self.assertAlmostEqual(axes_angles[0, iy], np.pi / 2, delta=1e-10) - - def test_calculate_axes_angles_uses_identity_if_W_unavailable_MDEvent(self): - # test MD event - ws = _create_mock_workspace(IMDEventWorkspace, SpecialCoordinateSystem.HKL, has_oriented_lattice=True) - ws.getExperimentInfo().run().get.side_effect = KeyError - model = SliceViewerModel(ws) - - axes_angles = model.get_axes_angles() - self.assertAlmostEqual(axes_angles[1, 2], np.pi / 2, delta=1e-10) - for iy in range(1, 3): - self.assertAlmostEqual(axes_angles[0, iy], np.pi / 2, delta=1e-10) - - def test_calculate_axes_angles_uses_identity_if_W_unavailable_MDHisto(self): - # test MD histo - ws = _create_mock_workspace(IMDHistoWorkspace, SpecialCoordinateSystem.HKL, has_oriented_lattice=True) - ws.getExperimentInfo().run().get.side_effect = KeyError - model = SliceViewerModel(ws) - - axes_angles = model.get_axes_angles() - self.assertAlmostEqual(axes_angles[1, 2], np.pi / 2, delta=1e-10) - for iy in range(1, 3): - self.assertAlmostEqual(axes_angles[0, iy], np.pi / 2, delta=1e-10) - - def test_calculate_axes_angles_uses_W_if_basis_vectors_unavailable_and_W_available_MDHisto(self): - # test MD histo - ws = _create_mock_workspace(IMDHistoWorkspace, SpecialCoordinateSystem.HKL, has_oriented_lattice=True, ndims=3) - ws.getBasisVector.side_effect = lambda x: [0.0] # will cause proj_matrix to be all zeros - ws.getExperimentInfo().run().get().value = [0, 1, 1, 0, 0, 1, 1, 0, 0] - model = SliceViewerModel(ws) - - axes_angles = model.get_axes_angles() - self.assertAlmostEqual(axes_angles[1, 2], np.pi / 4, delta=1e-10) - for iy in range(1, 3): - self.assertAlmostEqual(axes_angles[0, iy], np.pi / 2, delta=1e-10) - # test force_orthog works - axes_angles = model.get_axes_angles(force_orthogonal=True) - self.assertAlmostEqual(axes_angles[1, 2], np.pi / 2, delta=1e-10) - - def test_calc_proj_matrix_4D_workspace_nonQ_dims(self): - ws = _create_mock_histoworkspace( - ndims=4, - coords=SpecialCoordinateSystem.NONE, - extents=(-3, 3, -10, 10, -1, 1, -2, 2), - signal=np.arange(100.0).reshape(5, 5, 2, 2), - error=np.arange(100.0).reshape(5, 5, 2, 2), - nbins=(5, 5, 2, 2), - names=("00L", "HH0", "-KK0", "E"), - units=("rlu", "rlu", "rlu", "EnergyTransfer"), - isq=(True, True, True, False), - ) - # basis vectors as if ws was produced from BinMD call on MDEvent with dims E, H, K, L - basis_mat = np.array([[0, 0, 0, 1], [0, 1, -1, 0], [0, 1, 1, 0], [1, 0, 0, 0]]) - ws.getBasisVector.side_effect = lambda idim: basis_mat[:, idim] - model = SliceViewerModel(ws) - - proj_mat = model.get_proj_matrix() - - self.assertTrue(np.all(np.isclose(proj_mat, [[0, 1, -1], [0, 1, 1], [1, 0, 0]]))) - - @patch.multiple("mantidqt.widgets.sliceviewer.models.roi", ExtractSpectra=DEFAULT, Rebin=DEFAULT, SumSpectra=DEFAULT, Transpose=DEFAULT) - def test_export_roi_for_matrixworkspace(self, ExtractSpectra, **_): - xmin, xmax, ymin, ymax = -1.0, 3.0, 2.0, 4.0 - - def assert_call_as_expected(exp_xmin, exp_xmax, exp_start_index, exp_end_index, transpose, is_spectra): - mock_ws = _create_mock_matrixworkspace(x_axis=[10, 20, 30], y_axis=[1, 2, 3, 4, 5], distribution=False, y_is_spectra=is_spectra) - mock_ws.name.return_value = "mock_ws" - model = SliceViewerModel(mock_ws) - slicepoint, bin_params, dimension_indices = MagicMock(), MagicMock(), MagicMock() - - help_msg = model.export_roi_to_workspace(slicepoint, bin_params, ((xmin, xmax), (ymin, ymax)), transpose, dimension_indices) - - self.assertEqual("ROI created: mock_ws_roi", help_msg) - if is_spectra: - self.assertEqual(2, mock_ws.getAxis(1).indexOfValue.call_count) - else: - mock_ws.getAxis(1).extractValues.assert_called_once() - - ExtractSpectra.assert_called_once_with( - InputWorkspace=mock_ws, - OutputWorkspace="mock_ws_roi", - XMin=exp_xmin, - XMax=exp_xmax, - StartWorkspaceIndex=exp_start_index, - EndWorkspaceIndex=exp_end_index, - EnableLogging=True, - ) - ExtractSpectra.reset_mock() - - assert_call_as_expected(xmin, xmax, 3, 5, transpose=False, is_spectra=True) - assert_call_as_expected(2.0, 4.0, 0, 4, transpose=True, is_spectra=True) - assert_call_as_expected(xmin, xmax, 1, 3, transpose=False, is_spectra=False) - assert_call_as_expected(ymin, ymax, 0, 2, transpose=True, is_spectra=False) - - @patch("mantidqt.widgets.sliceviewer.models.roi.ExtractSpectra") - @patch("mantidqt.widgets.sliceviewer.models.roi.Rebin") - @patch("mantidqt.widgets.sliceviewer.models.roi.SumSpectra") - @patch("mantidqt.widgets.sliceviewer.models.roi.Transpose") - def test_export_cuts_for_matrixworkspace(self, mock_transpose, mock_sum_spectra, mock_rebin, mock_extract_spectra): - xmin, xmax, ymin, ymax = -4.0, 5.0, 6.0, 9.0 - - def assert_call_as_expected(mock_ws, transpose, export_type, is_spectra, is_ragged): - model = SliceViewerModel(mock_ws) - slicepoint, bin_params, dimension_indices = MagicMock(), MagicMock(), MagicMock() - - help_msg = model.export_cuts_to_workspace( - slicepoint, bin_params, ((xmin, xmax), (ymin, ymax)), transpose, dimension_indices, export_type - ) - - if export_type == "c": - if is_spectra: - mock_extract_spectra.assert_called_once() - if is_ragged: - mock_rebin.assert_called_once() - mock_sum_spectra.assert_called_once() - else: - if is_ragged: - self.assertEqual(2, mock_rebin.call_count) - else: - mock_rebin.assert_called_once() - self.assertEqual(1, mock_transpose.call_count) - self.assertEqual(1, mock_extract_spectra.call_count) - self.assertEqual("Cuts along X/Y created: mock_ws_cut_x & mock_ws_cut_y", help_msg) - elif export_type == "x": - mock_extract_spectra.assert_called_once() - if is_ragged: - self.assertEqual(1, mock_rebin.call_count) - if is_spectra: - mock_sum_spectra.assert_called_once() - else: - mock_transpose.assert_not_called() - self.assertEqual("Cut along X created: mock_ws_cut_x", help_msg) - elif export_type == "y": - mock_extract_spectra.assert_called_once() - mock_transpose.assert_called_once() - if is_ragged: - self.assertEqual(2, mock_rebin.call_count) - else: - self.assertEqual(1, mock_rebin.call_count) - self.assertEqual("Cut along Y created: mock_ws_cut_y", help_msg) - - mock_transpose.reset_mock() - mock_rebin.reset_mock() - mock_extract_spectra.reset_mock() - mock_sum_spectra.reset_mock() - - # end def - - for export_type in ("c", "x", "y"): - is_ragged = False - for is_spectra in (True, False): - mock_ws = _create_mock_matrixworkspace( - x_axis=[10, 20, 30], y_axis=[1, 2, 3, 4, 5], distribution=False, y_is_spectra=is_spectra - ) - mock_ws.name.return_value = "mock_ws" - assert_call_as_expected(mock_ws, transpose=False, export_type=export_type, is_spectra=is_spectra, is_ragged=is_ragged) - - is_ragged = True - mock_ws = _create_mock_matrixworkspace( - x_axis=[10, 20, 30, 11, 21, 31], y_axis=[1, 2, 3, 4, 5], distribution=False, y_is_spectra=is_spectra - ) - mock_ws.isCommonBins.return_value = False - mock_ws.name.return_value = "mock_ws" - assert_call_as_expected(mock_ws, transpose=False, export_type=export_type, is_spectra=is_spectra, is_ragged=is_ragged) - - @patch("mantidqt.widgets.sliceviewer.models.model.IntegrateMDHistoWorkspace") - @patch("mantidqt.widgets.sliceviewer.models.model.TransposeMD") - def test_export_pixel_cut_to_workspace_mdhisto(self, mock_transpose, mock_intMD): - slicepoint = [None, None, 0.5] - bin_params = [100, 100, 0.1] - - def assert_call_as_expected(transpose, cut_type): - xpos, ypos = 0.0, 2.0 - xidx, yidx = 0, 1 - dimension_indices = [0, 1, None] - if transpose: - xpos, ypos = ypos, xpos - xidx, yidx = yidx, xidx - dimension_indices = [1, 0, None] - - model = SliceViewerModel(self.ws_MD_3D) - help_msg = model.export_pixel_cut_to_workspace(slicepoint, bin_params, (xpos, ypos), transpose, dimension_indices, cut_type) - - xdim = self.ws_MD_3D.getDimension(xidx) - ydim = self.ws_MD_3D.getDimension(yidx) - - deltax = xdim.getBinWidth() - deltay = ydim.getBinWidth() - - limits_x = ((xdim.getMinimum(), xdim.getMaximum()), (ypos - 0.5 * deltay, ypos + 0.5 * deltay)) - limits_y = ((xpos - 0.5 * deltax, xpos + 0.5 * deltax), (ydim.getMinimum(), ydim.getMaximum())) - - zmin, zmax = 0.45, 0.55 - - assert_called = mock_intMD.assert_called_with if cut_type == "c" else mock_intMD.assert_called_once_with - - if not transpose: - if cut_type == "y" or cut_type == "c": - xmin, xmax = limits_y[xidx] - ymin, ymax = limits_y[yidx] - - ycut_params = dict( - InputWorkspace=self.ws_MD_3D, - P1Bin=[xmin, xmax], - P2Bin=[ymin, 0, ymax], - P3Bin=[zmin, zmax], - OutputWorkspace="ws_MD_3D_cut_y", - ) - assert_called(**ycut_params) - elif cut_type == "x" or cut_type == "c": - xmin, xmax = limits_x[xidx] - ymin, ymax = limits_x[yidx] - - xcut_params = dict( - InputWorkspace=self.ws_MD_3D, - P1Bin=[xmin, 0, xmax], - P2Bin=[ymin, ymax], - P3Bin=[zmin, zmax], - OutputWorkspace="ws_MD_3D_cut_x", - ) - assert_called(**xcut_params) - else: - if cut_type == "y" or cut_type == "c": - xmin, xmax = limits_y[xidx] - ymin, ymax = limits_y[yidx] - - ycut_params = dict( - InputWorkspace=self.ws_MD_3D, - P1Bin=[xmin, 0, xmax], - P2Bin=[ymin, ymax], - P3Bin=[zmin, zmax], - OutputWorkspace="ws_MD_3D_cut_y", - ) - assert_called(**ycut_params) - - elif cut_type == "x" or cut_type == "c": - xmin, xmax = limits_x[xidx] - ymin, ymax = limits_x[yidx] - - xcut_params = dict( - InputWorkspace=self.ws_MD_3D, - P1Bin=[xmin, xmax], - P2Bin=[ymin, 0, ymax], - P3Bin=[zmin, zmax], - OutputWorkspace="ws_MD_3D_cut_x", - ) - assert_called(**xcut_params) - - if cut_type == "c": - self.assertEqual("Cuts along X/Y created: ws_MD_3D_cut_x & ws_MD_3D_cut_y", help_msg) - elif cut_type == "x": - self.assertEqual("Cut along X created: ws_MD_3D_cut_x", help_msg) - elif cut_type == "y": - self.assertEqual("Cut along Y created: ws_MD_3D_cut_y", help_msg) - - mock_intMD.reset_mock() - - for cut_type in ("x", "y", "c"): - assert_call_as_expected(transpose=False, cut_type=cut_type) - assert_call_as_expected(transpose=True, cut_type=cut_type) - - @patch("mantidqt.widgets.sliceviewer.models.model.BinMD") - @patch("mantidqt.widgets.sliceviewer.models.model.TransposeMD") - def test_export_pixel_cut_to_workspace_mdevent(self, mock_transpose, mock_binmd): - slicepoint = [None, None, 0.5] - bin_params = [100, 100, 0.1] - - def assert_call_as_expected(transpose, cut_type): - xpos, ypos = 0.0, 2.0 - xidx, yidx = 0, 1 - dimension_indices = [0, 1, None] - if transpose: - xpos, ypos = ypos, xpos - xidx, yidx = yidx, xidx - dimension_indices = [1, 0, None] - - model = SliceViewerModel(self.ws_MDE_3D) - help_msg = model.export_pixel_cut_to_workspace(slicepoint, bin_params, (xpos, ypos), transpose, dimension_indices, cut_type) - - xdim = self.ws_MDE_3D.getDimension(xidx) - ydim = self.ws_MDE_3D.getDimension(yidx) - - deltax = xdim.getBinWidth() / bin_params[xidx] - deltay = ydim.getBinWidth() / bin_params[yidx] - - if cut_type == "x": - xmin, xmax = xdim.getMinimum(), xdim.getMaximum() - ymin, ymax = ypos - 0.5 * deltay, ypos + 0.5 * deltay - else: - xmin, xmax = xpos - 0.5 * deltax, xpos + 0.5 * deltax - ymin, ymax = ydim.getMinimum(), ydim.getMaximum() - - zmin, zmax = 0.45, 0.55 - - common_params = dict( - InputWorkspace=self.ws_MDE_3D, - AxisAligned=False, - BasisVector0="h,rlu,1.0,0.0,0.0", - BasisVector1="k,rlu,0.0,1.0,0.0", - BasisVector2="l,rlu,0.0,0.0,1.0", - ) - - assert_called = mock_binmd.assert_called_with if cut_type == "c" else mock_binmd.assert_called_once_with - - if not transpose: - extents = [xmin, xmax, ymin, ymax, zmin, zmax] - - if cut_type == "y" or cut_type == "c": - expected_bins = [1, 100, 1] - expected_ws = "ws_MDE_3D_cut_y" - expected_msg = "Cut along Y created: ws_MDE_3D_cut_y" - - assert_called(**common_params, OutputExtents=extents, OutputBins=expected_bins, OutputWorkspace=expected_ws) - elif cut_type == "x" or cut_type == "c": - expected_bins = [100, 1, 1] - expected_ws = "ws_MDE_3D_cut_x" - expected_msg = "Cut along X created: ws_MDE_3D_cut_x" - - assert_called(**common_params, OutputExtents=extents, OutputBins=expected_bins, OutputWorkspace=expected_ws) - - else: - extents = [ymin, ymax, xmin, xmax, zmin, zmax] - - if cut_type == "y" or cut_type == "c": - expected_bins = [100, 1, 1] - expected_ws = "ws_MDE_3D_cut_y" - expected_msg = "Cut along Y created: ws_MDE_3D_cut_y" - assert_called(**common_params, OutputExtents=extents, OutputBins=expected_bins, OutputWorkspace=expected_ws) - elif cut_type == "x" or cut_type == "c": - expected_bins = [1, 100, 1] - expected_ws = "ws_MDE_3D_cut_x" - expected_msg = "Cut along X created: ws_MDE_3D_cut_x" - assert_called(**common_params, OutputExtents=extents, OutputBins=expected_bins, OutputWorkspace=expected_ws) - - if cut_type == "c": - expected_msg = "Cuts along X/Y created: ws_MDE_3D_cut_x & ws_MDE_3D_cut_y" - elif cut_type == "x": - expected_msg = "Cut along X created: ws_MDE_3D_cut_x" - elif cut_type == "y": - expected_msg = "Cut along Y created: ws_MDE_3D_cut_y" - - self.assertEqual(expected_msg, help_msg) - mock_binmd.reset_mock() - - for cut_type in ("x", "y", "c"): - assert_call_as_expected(transpose=False, cut_type=cut_type) - assert_call_as_expected(transpose=True, cut_type=cut_type) - - @patch("mantidqt.widgets.sliceviewer.models.roi.extract_roi_matrix") - def test_export_pixel_cut_to_workspace_matrix(self, mock_extract_roi): - slicepoint = [None, None] - bin_params = [100, 100] - dimension_indices = [0, 1] - xpos, ypos = 1.5, 3.0 - - def assert_call_as_expected(transpose, cut_type): - model = SliceViewerModel(self.ws2d_histo) - mock_extract_roi.reset_mock() - - help_msg = model.export_pixel_cut_to_workspace(slicepoint, bin_params, (xpos, ypos), transpose, dimension_indices, cut_type) - - if not transpose: - if cut_type == "x" or cut_type == "c": - mock_extract_roi.assert_called_once_with(self.ws2d_histo, None, None, ypos, ypos, False, "ws2d_histo_cut_x") - self.assertEqual("Cut along X created: ws2d_histo_cut_x", help_msg) - elif cut_type == "y" or cut_type == "c": - mock_extract_roi.assert_called_once_with(self.ws2d_histo, xpos, xpos, None, None, True, "ws2d_histo_cut_y") - self.assertEqual("Cut along Y created: ws2d_histo_cut_y", help_msg) - else: - if cut_type == "x" or cut_type == "c": - mock_extract_roi.assert_called_once_with(self.ws2d_histo, ypos, ypos, None, None, True, "ws2d_histo_cut_y") - self.assertEqual("Cut along X created: ws2d_histo_cut_y", help_msg) - elif cut_type == "y" or cut_type == "c": - mock_extract_roi.assert_called_once_with(self.ws2d_histo, None, None, xpos, xpos, False, "ws2d_histo_cut_x") - self.assertEqual("Cut along Y created: ws2d_histo_cut_x", help_msg) - - for cut_type in ("x", "y", "c"): - assert_call_as_expected(transpose=False, cut_type=cut_type) - assert_call_as_expected(transpose=True, cut_type=cut_type) - - @patch("mantidqt.widgets.sliceviewer.models.model.TransposeMD") - @patch("mantidqt.widgets.sliceviewer.models.model.IntegrateMDHistoWorkspace") - def test_export_region_for_mdhisto_workspace(self, mock_intMD, mock_transposemd): - xmin, xmax, ymin, ymax = -1.0, 3.0, 2.0, 4.0 - slicepoint, bin_params = (None, None, 0.5), (100, 100, 0.1) - dimension_indices = [0, 1, None] # Value at index i is the index of the axis that dimension i is displayed on - transposed_dimension_indices = [1, 0, None] - - def assert_call_as_expected(transpose, dimension_indices, export_type, bin_params): - model = SliceViewerModel(self.ws_MD_3D) - - if export_type == "r": - model.export_roi_to_workspace(slicepoint, bin_params, ((xmin, xmax), (ymin, ymax)), transpose, dimension_indices) - else: - model.export_cuts_to_workspace( - slicepoint, bin_params, ((xmin, xmax), (ymin, ymax)), transpose, dimension_indices, export_type - ) - - if export_type == "c": - export_type = "xy" # will loop over this string as 'c' performs both 'x' and 'y' - - for export in export_type: - xbin, ybin = [xmin, xmax], [ymin, ymax] # create in loop as these are altered in case of both cuts - # perform transpose on limits - i.e map x/y on plot to basis of MD workspace p1/p2 - if not transpose: - p1_bin, p2_bin = xbin, ybin - else: - p2_bin, p1_bin = xbin, ybin - # determine which axis was binnned - if export == "x": - xbin.insert(1, 0.0) # insert 0 between min,max - this means preserve binning along this dim - out_name = "ws_MD_3D_cut_x" - transpose_axes = [1 if transpose else 0] # Axes argument of TransposeMD - elif export == "y": - ybin.insert(1, 0.0) - out_name = "ws_MD_3D_cut_y" - # check call to transposeMD - transpose_axes = [0 if transpose else 1] - else: - # export == 'r' - xbin.insert(1, 0.0) - ybin.insert(1, 0.0) - out_name = "ws_MD_3D_roi" - transpose_axes = [1, 0] if transpose else None - - dim = self.ws_MD_3D.getDimension(2) - half_bin_width = bin_params[2] / 2 if bin_params is not None else dim.getBinWidth() / 2 - - # check call to IntegrateMDHistoWorkspace - mock_intMD.assert_has_calls( - [ - call( - InputWorkspace=self.ws_MD_3D, - P1Bin=p1_bin, - P2Bin=p2_bin, - P3Bin=[slicepoint[2] - half_bin_width, slicepoint[2] + half_bin_width], - OutputWorkspace=out_name, - ) - ], - any_order=False, - ) - if transpose_axes is not None: - mock_transposemd.assert_has_calls( - [call(InputWorkspace=out_name, OutputWorkspace=out_name, Axes=transpose_axes)], any_order=False - ) - else: - mock_transposemd.assert_not_called() # ROI with Transpose == False - - mock_intMD.reset_mock() - mock_transposemd.reset_mock() - - for export_type in ("r", "x", "y", "c"): - assert_call_as_expected(transpose=False, dimension_indices=dimension_indices, export_type=export_type, bin_params=bin_params) - assert_call_as_expected(transpose=False, dimension_indices=dimension_indices, export_type=export_type, bin_params=None) - assert_call_as_expected( - transpose=True, dimension_indices=transposed_dimension_indices, export_type=export_type, bin_params=bin_params - ) - assert_call_as_expected( - transpose=True, dimension_indices=transposed_dimension_indices, export_type=export_type, bin_params=None - ) - - @patch("mantidqt.widgets.sliceviewer.models.model.TransposeMD") - @patch("mantidqt.widgets.sliceviewer.models.model.BinMD") - def test_export_region_for_mdevent_workspace(self, mock_binmd, mock_transposemd): - xmin, xmax, ymin, ymax = -1.0, 3.0, 2.0, 4.0 - slicepoint, bin_params = (None, None, 0.5), (100, 100, 0.1) - zmin, zmax = 0.45, 0.55 # 3rd dimension extents - dimension_indices = [0, 1, None] # Value at index i is the index of the axis that dimension i is displayed on - transposed_dimension_indices = [1, 0, None] - - def assert_call_as_expected(transpose, dimension_indices, export_type): - model = SliceViewerModel(self.ws_MDE_3D) - - if export_type == "r": - help_msg = model.export_roi_to_workspace(slicepoint, bin_params, ((xmin, xmax), (ymin, ymax)), transpose, dimension_indices) - else: - help_msg = model.export_cuts_to_workspace( - slicepoint, bin_params, ((xmin, xmax), (ymin, ymax)), transpose, dimension_indices, export_type - ) - - if transpose: - extents = [ymin, ymax, xmin, xmax, zmin, zmax] - else: - extents = [xmin, xmax, ymin, ymax, zmin, zmax] - common_call_params = dict( - InputWorkspace=self.ws_MDE_3D, - AxisAligned=False, - BasisVector0="h,rlu,1.0,0.0,0.0", - BasisVector1="k,rlu,0.0,1.0,0.0", - BasisVector2="l,rlu,0.0,0.0,1.0", - OutputExtents=extents, - ) - xcut_name, ycut_name = "ws_MDE_3D_cut_x", "ws_MDE_3D_cut_y" - if export_type == "r": - expected_help_msg = "ROI created: ws_MDE_3D_roi" - expected_calls = [call(**common_call_params, OutputBins=[100, 100, 1], OutputWorkspace="ws_MDE_3D_roi")] - elif export_type == "x": - expected_help_msg = f"Cut along X created: {xcut_name}" - expected_bins = [1, 100, 1] if transpose else [100, 1, 1] - expected_calls = [call(**common_call_params, OutputBins=expected_bins, OutputWorkspace=xcut_name)] - elif export_type == "y": - expected_help_msg = f"Cut along Y created: {ycut_name}" - expected_bins = [100, 1, 1] if transpose else [1, 100, 1] - expected_calls = [call(**common_call_params, OutputBins=expected_bins, OutputWorkspace=ycut_name)] - elif export_type == "c": - expected_help_msg = f"Cuts along X/Y created: {xcut_name} & {ycut_name}" - expected_bins = [100, 1, 1] if transpose else [1, 100, 1] - expected_calls = [ - call(**common_call_params, OutputBins=expected_bins, OutputWorkspace=xcut_name), - call(**common_call_params, OutputBins=expected_bins, OutputWorkspace=ycut_name), - ] - - mock_binmd.assert_has_calls(expected_calls, any_order=True) - if export_type == "r": - if transpose: - mock_transposemd.assert_called_once() - else: - mock_transposemd.assert_not_called() - else: - if export_type == "x": - index = 1 if transpose else 0 - expected_calls = [call(InputWorkspace=xcut_name, OutputWorkspace=xcut_name, Axes=[index])] - elif export_type == "y": - index = 0 if transpose else 1 - expected_calls = [call(InputWorkspace=ycut_name, OutputWorkspace=ycut_name, Axes=[index])] - elif export_type == "c": - xindex = 1 if transpose else 0 - yindex = 0 if transpose else 1 - expected_calls = [ - call(InputWorkspace=xcut_name, OutputWorkspace=xcut_name, Axes=[xindex]), - call(InputWorkspace=ycut_name, OutputWorkspace=ycut_name, Axes=[yindex]), - ] - - mock_transposemd.assert_has_calls(expected_calls, any_order=True) - - self.assertEqual(expected_help_msg, help_msg) - mock_binmd.reset_mock() - mock_transposemd.reset_mock() - - for export_type in ("r", "x", "y", "c"): - assert_call_as_expected(transpose=False, dimension_indices=dimension_indices, export_type=export_type) - assert_call_as_expected(transpose=True, dimension_indices=transposed_dimension_indices, export_type=export_type) - - @patch("mantidqt.widgets.sliceviewer.models.model.BinMD") - @patch("mantidqt.widgets.sliceviewer.models.roi.ExtractSpectra") - def test_export_region_raises_exception_if_operation_failed(self, mock_extract_spectra, mock_binmd): - def assert_error_returned_in_help(workspace, export_type, mock_alg, err_msg): - model = SliceViewerModel(workspace) - slicepoint, bin_params, dimension_indices = (None, None, None), MagicMock(), [0, 1, None] - mock_alg.side_effect = RuntimeError(err_msg) - try: - if export_type == "r": - help_msg = model.export_roi_to_workspace(slicepoint, bin_params, ((1.0, 2.0), (-1, 2.0)), True, dimension_indices) - else: - help_msg = model.export_cuts_to_workspace( - slicepoint, bin_params, ((1.0, 2.0), (-1, 2.0)), True, dimension_indices, export_type - ) - except Exception as exc: - help_msg = str(exc) - mock_alg.reset_mock() - - self.assertTrue(err_msg in help_msg) - - for export_type in ("r", "c", "x", "y"): - assert_error_returned_in_help(self.ws2d_histo, export_type, mock_extract_spectra, "ExtractSpectra failed") - for export_type in ("r", "c", "x", "y"): - assert_error_returned_in_help(self.ws_MDE_3D, export_type, mock_binmd, "BinMD failed") - - @patch("mantidqt.widgets.sliceviewer.models.model.SliceViewerModel.get_proj_matrix") - def test_get_hkl_from_full_point_returns_zeros_for_a_none_transform(self, mock_get_proj_matrix): - qdims = [0, 1, 2] - point_3d = [1.0, 2.0, 3.0] - - model = SliceViewerModel(self.ws_MDE_3D) - mock_get_proj_matrix.return_value = None - - hkl = model.get_hkl_from_full_point(point_3d, qdims) - - self.assertEqual((0.0, 0.0, 0.0), hkl) - - @patch("mantidqt.widgets.sliceviewer.models.model.SliceViewerModel.get_proj_matrix") - def test_get_hkl_from_full_point_for_3D_point(self, mock_get_proj_matrix): - qdims = [0, 1, 2] - point_3d = [1.0, 2.0, 3.0] # [x, y, z] = [h, k, l] - - model = SliceViewerModel(self.ws_MDE_3D) - mock_get_proj_matrix.return_value = np.array([[0, 1, -1], [0, 1, 1], [1, 0, 0]]) - - hkl = model.get_hkl_from_full_point(point_3d, qdims) - - self.assertEqual([-1.0, 5.0, 1.0], list(hkl)) - - @patch("mantidqt.widgets.sliceviewer.models.model.SliceViewerModel.get_proj_matrix") - def test_get_hkl_from_full_point_for_4D_point(self, mock_get_proj_matrix): - qdims = [1, 2, 3] - point_4d = [1.0, 2.0, 3.0, 4.0] # [y, x, z, ...] = [e, h, k, l] - - model = SliceViewerModel(self.ws_MDE_4D) - mock_get_proj_matrix.return_value = np.array([[1, 0, 0], [0, 1, 0], [0, 0, 1]]) - - hkl = model.get_hkl_from_full_point(point_4d, qdims) - - self.assertEqual([2.0, 3.0, 4.0], list(hkl)) - - @patch("mantidqt.widgets.sliceviewer.models.model.SliceViewerModel.get_proj_matrix") - def test_get_hkl_from_full_point_for_4D_point_with_transformation(self, mock_get_proj_matrix): - qdims = [1, 2, 3] - point_4d = [1.0, 2.0, 3.0, 4.0] # [y, z, ..., x] = [e, h, k, l] - - model = SliceViewerModel(self.ws_MDE_4D) - mock_get_proj_matrix.return_value = np.array([[2, 0, 0], [0, -1, 0], [0, 0, 3]]) - - hkl = model.get_hkl_from_full_point(point_4d, qdims) - - self.assertEqual([4.0, -3.0, 12.0], list(hkl)) - - @patch("mantidqt.widgets.sliceviewer.models.model.SliceViewerModel.number_of_active_original_workspaces") - def test_check_for_removed_original_workspace(self, mock_num_original_workspaces): - self.ws_MDE_4D.hasOriginalWorkspace.side_effect = lambda index: True - mock_num_original_workspaces.return_value = 1 - - model = SliceViewerModel(self.ws_MDE_4D) - - self.assertEqual(model.num_original_workspaces_at_init, 1) - self.assertFalse(model.check_for_removed_original_workspace()) - # original workspace has been deleted - mock_num_original_workspaces.return_value = 0 - self.assertTrue(model.check_for_removed_original_workspace()) - - # private - def _assert_supports_non_orthogonal_axes(self, expectation, ws_type, coords, has_oriented_lattice): - model = SliceViewerModel(_create_mock_workspace(ws_type, coords, has_oriented_lattice)) - self.assertEqual(expectation, model.can_support_nonorthogonal_axes()) - - def _assert_supports_non_axis_aligned_cuts(self, expectation, ws_type, coords=SpecialCoordinateSystem.HKL, ndims=3): - model = SliceViewerModel(_create_mock_workspace(ws_type, coords, has_oriented_lattice=False, ndims=ndims)) - self.assertEqual(expectation, model.can_support_non_axis_cuts()) - - def _assert_supports_peaks_overlay(self, expectation, ws_type, ndims=2): - ws = _create_mock_workspace(ws_type, coords=SpecialCoordinateSystem.QLab, has_oriented_lattice=False, ndims=ndims) - model = SliceViewerModel(ws) - self.assertEqual(expectation, model.can_support_peaks_overlays()) - - -if __name__ == "__main__": - unittest.main() From f8ea04519eca0e4518ad7b36f70a973eaceb048e Mon Sep 17 00:00:00 2001 From: MialLewis <95620982+MialLewis@users.noreply.github.com> Date: Fri, 5 Sep 2025 13:56:21 +0100 Subject: [PATCH 29/33] add masking model tests --- .../widgets/sliceviewer/models/masking.py | 2 +- .../test/test_sliceviewer_maskingmodel.py | 56 +++++++++++++++---- 2 files changed, 45 insertions(+), 13 deletions(-) diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py index 061f4d62117c..4797cf187bf0 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/models/masking.py @@ -282,7 +282,7 @@ def generate_mask_table_ws(self, store_in_ads=True): return self.create_table_workspace_from_rows(table_rows, store_in_ads) def export_selectors(self): - _ = self.generate_mask_table_ws() + _ = self.generate_mask_table_ws(store_in_ads=True) def apply_selectors(self): mask_ws = self.generate_mask_table_ws(store_in_ads=False) diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/test/test_sliceviewer_maskingmodel.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/test/test_sliceviewer_maskingmodel.py index 8ea8a70dfe63..cedfb19bfc09 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/test/test_sliceviewer_maskingmodel.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/test/test_sliceviewer_maskingmodel.py @@ -6,7 +6,7 @@ # SPDX - License - Identifier: GPL - 3.0 + import unittest -from unittest.mock import patch +from unittest.mock import patch, Mock, call from collections import namedtuple from mantidqt.widgets.sliceviewer.models.masking import MaskingModel @@ -16,7 +16,7 @@ class SliceViewerMaskingModelTest(unittest.TestCase): TableRow = namedtuple("TableRow", ["spec_list", "x_min", "x_max"]) def setUp(self): - self.model = MaskingModel("test_ws") + self.model = MaskingModel("test_ws_name") @staticmethod def _set_up_model_test(text, transpose=False, images=None): @@ -62,12 +62,14 @@ def test_add_poly_cursor_info(self, CursorInfo_mock): CursorInfo_mock.assert_called_once_with(**kwargs) def test_create_table_workspace_from_rows(self): - table_ws = self.model.create_table_workspace_from_rows( - [self.TableRow("1", 5, 8), self.TableRow("2", 6, 9), self.TableRow("3-10", 7, 10)], False - ) - self.assertEqual(table_ws.column("SpectraList"), ["1", "2", "3-10"]) - self.assertEqual(table_ws.column("XMin"), [5, 6, 7]) - self.assertEqual(table_ws.column("XMax"), [8, 9, 10]) + with patch("mantidqt.widgets.sliceviewer.models.masking.AnalysisDataService") as ads_mock: + table_ws = self.model.create_table_workspace_from_rows( + [self.TableRow("1", 5, 8), self.TableRow("2", 6, 9), self.TableRow("3-10", 7, 10)], False + ) + self.assertEqual(table_ws.column("SpectraList"), ["1", "2", "3-10"]) + self.assertEqual(table_ws.column("XMin"), [5, 6, 7]) + self.assertEqual(table_ws.column("XMax"), [8, 9, 10]) + ads_mock.addOrReplace.assert_not_called() def test_create_table_workspace_from_rows_store_in_ads(self): with patch("mantidqt.widgets.sliceviewer.models.masking.AnalysisDataService") as ads_mock: @@ -77,16 +79,46 @@ def test_create_table_workspace_from_rows_store_in_ads(self): self.assertEqual(table_ws.column("SpectraList"), ["1", "2", "3-10"]) self.assertEqual(table_ws.column("XMin"), [5, 6, 7]) self.assertEqual(table_ws.column("XMax"), [8, 9, 10]) - ads_mock.addOrReplace.called_once_with("test_ws_sv_mask_tbl", table_ws) + ads_mock.addOrReplace.assert_called_once_with("test_ws_name_sv_mask_tbl", table_ws) def test_generate_mask_table_ws(self): - pass + mock_masks = [Mock(generate_table_rows=Mock(return_value=[f"test_{i}"])) for i in range(3)] + self.model._masks = mock_masks + with patch("mantidqt.widgets.sliceviewer.models.masking.MaskingModel.create_table_workspace_from_rows") as create_tbl_mock: + self.model.generate_mask_table_ws(store_in_ads=False) + create_tbl_mock.assert_called_once_with(["test_0", "test_1", "test_2"], False) def test_export_selectors(self): - pass + with ( + patch("mantidqt.widgets.sliceviewer.models.masking.AnalysisDataService") as ads_mock, + patch("mantidqt.widgets.sliceviewer.models.masking.MaskingModel.generate_mask_table_ws") as gen_mask_tbl_mock, + ): + self.model.export_selectors() + ads_mock.assert_not_called() + gen_mask_tbl_mock.assert_called_once_with(store_in_ads=True) def test_apply_selectors(self): - pass + with ( + patch("mantidqt.widgets.sliceviewer.models.masking.AnalysisDataService") as ads_mock, + patch( + "mantidqt.widgets.sliceviewer.models.masking.MaskingModel.generate_mask_table_ws", return_value="test_ws" + ) as gen_mask_tbl_mock, + patch("mantidqt.widgets.sliceviewer.models.masking.AlgorithmManager") as alg_manager_mock, + ): + alg_mock = alg_manager_mock.create.return_value + self.model.apply_selectors() + ads_mock.assert_not_called() + gen_mask_tbl_mock.assert_called_once_with(store_in_ads=False) + alg_mock.create.called_once_with("MaksBinsFromTable") + alg_mock.setProperty.assert_has_calls( + [ + call("InputWorkspace", "test_ws_name"), + call("OutputWorkspace", "test_ws_name"), + call("MaskingInformation", "test_ws"), + call("InputWorkspaceIndexType", "SpectrumNumber"), + ] + ) + alg_mock.execute.assert_called_once() class CursorInfoTest(unittest.TestCase): From 3859e59baa3c1e8816cc711a583c267468c7d016 Mon Sep 17 00:00:00 2001 From: MialLewis <95620982+MialLewis@users.noreply.github.com> Date: Fri, 5 Sep 2025 16:57:16 +0100 Subject: [PATCH 30/33] add maskig model tests --- qt/python/mantidqt/CMakeLists.txt | 1 + .../test/test_sliceviewer_maskingmodel.py | 109 ++++++++++++++++-- 2 files changed, 103 insertions(+), 7 deletions(-) diff --git a/qt/python/mantidqt/CMakeLists.txt b/qt/python/mantidqt/CMakeLists.txt index 150a38ce49eb..19f2b71c962d 100644 --- a/qt/python/mantidqt/CMakeLists.txt +++ b/qt/python/mantidqt/CMakeLists.txt @@ -128,6 +128,7 @@ set(PYTHON_WIDGET_QT5_ONLY_TESTS mantidqt/widgets/sliceviewer/test/test_sliceviewer_imageinfowidget.py mantidqt/widgets/sliceviewer/test/test_sliceviewer_lineplots.py mantidqt/widgets/sliceviewer/test/test_sliceviewer_maskingpresenter.py + mantidqt/widgets/sliceviewer/test/test_sliceviewer_maskingmodel.py mantidqt/widgets/sliceviewer/test/test_sliceviewer_model.py mantidqt/widgets/sliceviewer/test/test_sliceviewer_movemousecursor.py mantidqt/widgets/sliceviewer/test/test_sliceviewer_presenter.py diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/test/test_sliceviewer_maskingmodel.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/test/test_sliceviewer_maskingmodel.py index cedfb19bfc09..89ec29efa119 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/test/test_sliceviewer_maskingmodel.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/test/test_sliceviewer_maskingmodel.py @@ -8,8 +8,10 @@ from unittest.mock import patch, Mock, call from collections import namedtuple +from functools import partial -from mantidqt.widgets.sliceviewer.models.masking import MaskingModel +from mantidqt.widgets.sliceviewer.models.masking import MaskingModel, RectCursorInfo, TableRow, ElliCursorInfo, PolyCursorInfo +from numpy import float64 class SliceViewerMaskingModelTest(unittest.TestCase): @@ -122,17 +124,110 @@ def test_apply_selectors(self): class CursorInfoTest(unittest.TestCase): - Click = namedtuple("Click", ["xdata", "ydata"]) + Click = namedtuple("Click", ["data"]) @staticmethod def _set_up_model_test(text, transpose=False, images=None): pass - def test_clear_active_mask(self): - pass - - def test_store_active_mask(self): - pass + def _assert_table_rows_delta(self, actual, expected, delta): + for i in range(len(expected)): + expected_row = expected[i] + for j in range(len(expected_row.__dict__)): + expected_val = list(expected_row.__dict__.values())[j] + actual_val = list(actual[i].__dict__.values())[j] + assert_fn = ( + self.assertEqual + if (isinstance(actual_val, str) or isinstance(expected_val, str)) + else partial(self.assertAlmostEqual, delta=delta) + ) + assert_fn(expected_val, actual_val, msg=f"Error in Row index {i}") + + def test_rect_cursor_info_generate_table_rows(self): + cursor_info = RectCursorInfo(self.Click((-5.2, 0.8)), self.Click((5.6, 10.8)), False) + row = cursor_info.generate_table_rows() + self.assertEqual(row, [TableRow(spec_list="1-11", x_min=-5.2, x_max=5.6)]) + + def test_elli_cursor_info_generate_table_rows(self): + cursor_info = ElliCursorInfo(self.Click((-5, 0)), self.Click((5, 10)), False) + actual_rows = cursor_info.generate_table_rows() + expected_table_rows = [ + TableRow(spec_list="0", x_min=-1.795, x_max=1.795), + TableRow(spec_list="1", x_min=-3.399, x_max=3.399), + TableRow(spec_list="2", x_min=-4.229, x_max=4.229), + TableRow(spec_list="3", x_min=-4.714, x_max=4.714), + TableRow(spec_list="4", x_min=-4.955, x_max=4.955), + TableRow(spec_list="5", x_min=-5.0, x_max=5.0), + TableRow(spec_list="6", x_min=-4.955, x_max=4.955), + TableRow(spec_list="7", x_min=-4.714, x_max=4.714), + TableRow(spec_list="8", x_min=-4.229, x_max=4.229), + TableRow(spec_list="9", x_min=-3.399, x_max=3.399), + TableRow(spec_list="10", x_min=-1.795, x_max=1.795), + ] + self._assert_table_rows_delta(expected_table_rows, actual_rows, delta=0.001) + + def test_poly_cursor_info_generate_table_rows(self): + # Actual alg uses float64. This is important as divide by 0 returns inf rather than an exception. + cursor_info = PolyCursorInfo( + [self.Click((float64(0), float64(0))), self.Click((float64(10), float64(5))), self.Click((float64(0), float64(10)))], False + ) + actual_rows = cursor_info.generate_table_rows() + expected_table_rows = [ + TableRow(spec_list="0", x_min=0, x_max=0), + TableRow(spec_list="0", x_min=0, x_max=0.666), + TableRow(spec_list="1", x_min=0, x_max=2.666), + TableRow(spec_list="2", x_min=0, x_max=4.666), + TableRow(spec_list="3", x_min=0, x_max=6.666), + TableRow(spec_list="4", x_min=0, x_max=8.666), + TableRow(spec_list="5", x_min=0, x_max=10.0), + TableRow(spec_list="6", x_min=0, x_max=8.666), + TableRow(spec_list="7", x_min=0, x_max=6.666), + TableRow(spec_list="8", x_min=0, x_max=4.666), + TableRow(spec_list="9", x_min=0, x_max=2.666), + TableRow(spec_list="10", x_min=0, x_max=0.666), + ] + self._assert_table_rows_delta(expected_table_rows, actual_rows, delta=0.001) + + def test_poly_cursor_info_intersecting_line_once(self): + cursor_info = PolyCursorInfo( + [ + self.Click((float64(0), float64(0))), + self.Click((float64(10), float64(10))), + self.Click((float64(0), float64(10))), + self.Click((float64(10), float64(0))), + ], + False, + ) + actual_rows = cursor_info.generate_table_rows() + expected_table_rows = [ + TableRow(spec_list="0", x_min=0.0, x_max=10.0), + TableRow(spec_list="1", x_min=0.666, x_max=9.333), + TableRow(spec_list="2", x_min=1.666, x_max=8.333), + TableRow(spec_list="3", x_min=2.666, x_max=7.333), + TableRow(spec_list="4", x_min=3.666, x_max=6.333), + TableRow(spec_list="5", x_min=4.666, x_max=5.333), + TableRow(spec_list="6", x_min=3.666, x_max=6.333), + TableRow(spec_list="7", x_min=2.666, x_max=7.333), + TableRow(spec_list="8", x_min=1.666, x_max=8.333), + TableRow(spec_list="9", x_min=0.666, x_max=9.333), + TableRow(spec_list="10", x_min=0.0, x_max=10.0), + ] + self._assert_table_rows_delta(expected_table_rows, actual_rows, delta=0.001) + + def test_poly_cursor_info_intersecting_line_twice(self): + with self.assertRaisesRegex( + expected_exception=RuntimeError, expected_regex="Polygon shapes with more than 1 intersection point are not supported." + ): + _ = PolyCursorInfo( + [ + self.Click((float64(0), float64(0))), + self.Click((float64(10), float64(10))), + self.Click((float64(0), float64(10))), + self.Click((float64(10), float64(0))), + self.Click((float64(10), float64(5))), + ], + False, + ) if __name__ == "__main__": From 8064451450cdcd1130ef0ce4d661042a2bb3bda7 Mon Sep 17 00:00:00 2001 From: MialLewis <95620982+MialLewis@users.noreply.github.com> Date: Fri, 5 Sep 2025 22:15:31 +0100 Subject: [PATCH 31/33] disable masking for refl gui --- .../widgets/regionselector/presenter.py | 5 ++++- .../sliceviewer/presenters/base_presenter.py | 17 ++++++++++++----- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/qt/python/mantidqt/mantidqt/widgets/regionselector/presenter.py b/qt/python/mantidqt/mantidqt/widgets/regionselector/presenter.py index ba671c516e0d..24007533d06a 100644 --- a/qt/python/mantidqt/mantidqt/widgets/regionselector/presenter.py +++ b/qt/python/mantidqt/mantidqt/widgets/regionselector/presenter.py @@ -53,10 +53,13 @@ def __init__(self, ws=None, parent=None, view=None, image_info_widget=None): self.notifyee = None self.view = view if view else RegionSelectorView(self, parent, image_info_widget=image_info_widget) - super().__init__(ws, self.view.data_view) + super().__init__(ws, self.view.data_view, disable_masking_override=True) self._selectors: list[Selector] = [] self._drawing_region = False + # For now, disable masking - not implemented for regionselector + self._toggle_masking_options(False) + if ws: self._initialise_dimensions(ws) self._set_workspace(ws) diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/base_presenter.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/base_presenter.py index 567af1b98ba9..0cd656d867ee 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/base_presenter.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/base_presenter.py @@ -16,12 +16,13 @@ class SliceViewerBasePresenter(IDataViewSubscriber, ABC): - def __init__(self, ws, data_view: SliceViewerDataView, model: SliceViewerBaseModel = None): + def __init__(self, ws, data_view: SliceViewerDataView, model: SliceViewerBaseModel = None, disable_masking_override=False): self.model = model if model else SliceViewerBaseModel(ws) self._data_view: SliceViewerDataView = data_view self.normalization = False + self._disable_masking_override = disable_masking_override - if self._disable_masking: + if self._is_masking_disabled: self._data_view.deactivate_and_disable_tool(ToolItemText.MASKING) def show_all_data_clicked(self): @@ -123,12 +124,13 @@ def region_selection(self, state): else: data_view.enable_tool_button(ToolItemText.ZOOM) data_view.enable_tool_button(ToolItemText.PAN) - data_view.enable_tool_button(ToolItemText.MASKING) + if not self._is_masking_disabled: + data_view.enable_tool_button(ToolItemText.MASKING) data_view.extents_set_enabled(True) data_view.switch_line_plots_tool(PixelLinePlot, self) def masking(self, active) -> None: - self._data_view.toggle_masking_options(active) + self._toggle_masking_options(active) if active: self._data_view.deactivate_and_disable_tool(ToolItemText.ZOOM) self._data_view.deactivate_and_disable_tool(ToolItemText.PAN) @@ -150,9 +152,11 @@ def _clean_up_masking(self): self._data_view.masking = None @property - def _disable_masking(self): + def _is_masking_disabled(self): # Disable masking if not supported. # If a use case arises, we could extend support to these areas + if self._disable_masking_override: + return True # if not histo workspace ws_type = None if not self.model.ws else WorkspaceInfo.get_ws_type(self.model.ws) @@ -163,6 +167,9 @@ def _disable_masking(self): return True return False + def _toggle_masking_options(self, active): + self._data_view.toggle_masking_options(active) + @abstractmethod def get_extra_image_info_columns(self, xdata, ydata): pass From 075a4e9e9da39c21cd1f6c9d06310b55ba4f327f Mon Sep 17 00:00:00 2001 From: MialLewis <95620982+MialLewis@users.noreply.github.com> Date: Fri, 5 Sep 2025 22:21:37 +0100 Subject: [PATCH 32/33] revert terrible coderabbit suggestion --- .../mantidqt/widgets/sliceviewer/presenters/selector.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/selector.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/selector.py index 26b1913ce84a..25586ee50531 100644 --- a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/selector.py +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/presenters/selector.py @@ -38,7 +38,7 @@ def cursor_info(image: AxesImage, xdata: float, ydata: float, full_bbox: Bbox = return None point = point.astype(int) - if 0 <= point[0] < arr.shape[0] and 0 <= point[1] < arr.shape[1]: + if 0 <= point[0] <= arr.shape[0] and 0 <= point[1] <= arr.shape[1]: return CursorInfo(array=arr, extent=extent, point=point, data=(xdata, ydata)) else: return None From 889489474aadc77ad57bd9395bba26aa32a42aa7 Mon Sep 17 00:00:00 2001 From: MialLewis <95620982+MialLewis@users.noreply.github.com> Date: Fri, 5 Sep 2025 22:31:01 +0100 Subject: [PATCH 33/33] readd accidently removed tests --- .../test/test_sliceviewer_model.py | 1160 +++++++++++++++++ 1 file changed, 1160 insertions(+) create mode 100644 qt/python/mantidqt/mantidqt/widgets/sliceviewer/test/test_sliceviewer_model.py diff --git a/qt/python/mantidqt/mantidqt/widgets/sliceviewer/test/test_sliceviewer_model.py b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/test/test_sliceviewer_model.py new file mode 100644 index 000000000000..4605fc192785 --- /dev/null +++ b/qt/python/mantidqt/mantidqt/widgets/sliceviewer/test/test_sliceviewer_model.py @@ -0,0 +1,1160 @@ +# Mantid Repository : https://github.com/mantidproject/mantid +# +# Copyright © 2018 ISIS Rutherford Appleton Laboratory UKRI, +# NScD Oak Ridge National Laboratory, European Spallation Source, +# Institut Laue - Langevin & CSNS, Institute of High Energy Physics, CAS +# SPDX - License - Identifier: GPL - 3.0 + +# This file is part of the mantid workbench. +# +# +from contextlib import contextmanager +import unittest +from unittest.mock import MagicMock, call, patch, DEFAULT, Mock + +from mantid.api import MatrixWorkspace, IMDEventWorkspace, IMDHistoWorkspace, MultipleExperimentInfos +from mantid.kernel import SpecialCoordinateSystem +from mantid.geometry import IMDDimension, OrientedLattice +import numpy as np +from mantidqt.widgets.sliceviewer.models.model import SliceViewerModel, MIN_WIDTH + + +# Mock helpers +def _create_mock_histoworkspace( + ndims: int, + coords: SpecialCoordinateSystem, + extents: tuple, + signal: np.array, + error: np.array, + nbins: tuple, + names: tuple, + units: tuple, + isq: tuple, +): + """ + :param ndims: The number of dimensions + :param coords: MD coordinate system + :param extents: Extents of each dimension + :param signal: Array to be returned as signal + :param error: Array to be returned as errors + :param nbins: Number of bins in each dimension + :param names: The name of each dimension + :param units: Unit labels for each dimension + :param isq: Boolean for each dimension defining if Q or not + """ + ws = _create_mock_workspace(IMDHistoWorkspace, coords, has_oriented_lattice=False, ndims=ndims) + ws.getSignalArray.return_value = signal + ws.getErrorSquaredArray.return_value = error + ws.hasOriginalWorkspace.side_effect = lambda index: False + return _add_dimensions(ws, names, isq, extents, nbins, units) + + +@contextmanager +def _attach_as_original(histo_ws, mde_ws): + """ + Temporarily attach an MDEventWorkspace to a MDHistoWorkspace + as an original workspace + :param histo_ws: A mock MDHistoWorkspace + :param mde_ws: A mock MDEventWorkspace + """ + histo_ws.hasOriginalWorkspace.side_effect = lambda index: True + yield + histo_ws.hasOriginalWorkspace.side_effect = lambda index: False + + +def _create_mock_mdeventworkspace(ndims: int, coords: SpecialCoordinateSystem, extents: tuple, names: tuple, units: tuple, isq: tuple): + """ + :param ndims: The number of dimensions + :param coords: MD coordinate system + :param extents: Extents of each dimension + :param names: The name of each dimension + :param units: Unit labels for each dimension + :param isq: Boolean for each dimension defining if Q or not + """ + ws = _create_mock_workspace(IMDEventWorkspace, coords, has_oriented_lattice=False, ndims=ndims) + return _add_dimensions(ws, names, isq, extents, nbins=(1,) * ndims, units=units) + + +def _create_mock_matrixworkspace( + x_axis: tuple, y_axis: tuple, distribution: bool, names: tuple = None, units: tuple = None, y_is_spectra=True +): + """ + :param x_axis: X axis values + :param y_axis: Y axis values + :param distribution: Value of distribution flag + :param names: The name of each dimension + :param units: Unit labels for each dimension + :param y_is_spectra: True if the Y axis is a spectra axis, else it's a Numeric + """ + ws = MagicMock(MatrixWorkspace) + ws.getNumDims.return_value = 2 + ws.getNumberHistograms.return_value = len(y_axis) + ws.isDistribution.return_value = distribution + ws.extractX.return_value = x_axis + axes = [MagicMock(), MagicMock(isSpectra=lambda: y_is_spectra, isNumeric=lambda: not y_is_spectra)] + ws.getAxis.side_effect = lambda i: axes[i] + axes[1].extractValues.return_value = np.array(y_axis) + if y_is_spectra: + axes[1].indexOfValue.side_effect = lambda i: i + 1 + + if names is None: + names = ("X", "Y") + + extents = (x_axis[0], x_axis[-1], y_axis[0], y_axis[-1]) + nbins = (len(x_axis) - 1, len(y_axis) - 1) + return _add_dimensions(ws, names, (False, False), extents, nbins, units) + + +def _create_mock_workspace(ws_type, coords: SpecialCoordinateSystem = None, has_oriented_lattice: bool = None, ndims: int = 2): + """ + :param ws_type: Used this as spec for Mock + :param coords: MD coordinate system for MD workspaces + :param has_oriented_lattice: If the mock should claim to have an attached a lattice + :param ndims: The number of dimensions + """ + ws = MagicMock(spec=ws_type) + if hasattr(ws, "getExperimentInfo"): + ws.getNumDims.return_value = ndims + ws.getNumNonIntegratedDims.return_value = ndims + if ws_type == IMDHistoWorkspace: + ws.isMDHistoWorkspace.return_value = True + ws.getNonIntegratedDimensions.return_value = [MagicMock(), MagicMock()] + ws.hasOriginalWorkspace.return_value = False + basis_mat = np.eye(ndims) + ws.getBasisVector.side_effect = lambda idim: basis_mat[:, idim] + ws.getDimension().getMDFrame().isQ.return_value = True + else: + ws.isMDHistoWorkspace.return_value = False + + ws.getSpecialCoordinateSystem.return_value = coords + run = MagicMock() + run.get.return_value = MagicMock() + run.get().value = np.eye(3).flatten() # proj matrix is always 3x3 + expt_info = MagicMock() + sample = MagicMock() + sample.hasOrientedLattice.return_value = has_oriented_lattice + if has_oriented_lattice: + lattice = OrientedLattice(1, 1, 1, 90, 90, 90) + sample.getOrientedLattice.return_value = lattice + expt_info.sample.return_value = sample + expt_info.run.return_value = run + ws.getExperimentInfo.return_value = expt_info + ws.getExperimentInfo().sample() + elif hasattr(ws, "getNumberHistograms"): + ws.getNumDims.return_value = 2 + ws.getNumberHistograms.return_value = 3 + mock_dimension = MagicMock() + mock_dimension.getNBins.return_value = 3 + ws.getDimension.return_value = mock_dimension + return ws + + +def _add_dimensions(mock_ws, names, isq, extents: tuple = None, nbins: tuple = None, units: tuple = None): + """ + :param mock_ws: An existing mock workspace object + :param names: The name of each dimension + :param isq: Boolean for each dimension defining if Q or not + :param extents: Extents of each dimension + :param nbins: Number of bins in each dimension + :param units: Unit labels for each dimension + """ + + def create_dimension(index): + dimension = MagicMock(spec=IMDDimension) + dimension.name = names[index] + mdframe = MagicMock() + mdframe.isQ.return_value = isq[index] + dimension.getMDFrame.return_value = mdframe + if units is not None: + dimension.getUnits.return_value = units[index] + if extents is not None: + dim_min, dim_max = extents[2 * index], extents[2 * index + 1] + dimension.getMinimum.return_value = dim_min + dimension.getMaximum.return_value = dim_max + if nbins is not None: + bin_width = (dim_max - dim_min) / nbins[index] + dimension.getBinWidth.return_value = bin_width + dimension.getX.side_effect = lambda i: dim_min + bin_width * i + dimension.getNBins.return_value = nbins[index] + return dimension + + dimensions = [create_dimension(index) for index in range(len(names))] + mock_ws.getDimension.side_effect = lambda index: dimensions[index] + return mock_ws + + +class ArraysEqual: + """Compare arrays for equality in mock.assert_called_with calls.""" + + def __init__(self, expected): + self._expected = expected + + def __eq__(self, other): + return np.all(self._expected == other) + + def __repr__(self): + """Return a string when the test comparison fails""" + return f"{self._expected}" + + +def create_mock_sliceinfo(indices: tuple): + """ + Create mock sliceinfo + :param indices: 3D indices defining permutation order of dimensions + """ + slice_info = MagicMock() + slice_info.transform.side_effect = lambda x: np.array([x[indices[0]], x[indices[1]], x[indices[2]]]) + return slice_info + + +class SliceViewerModelTest(unittest.TestCase): + @classmethod + def setUpClass(self): + self.ws_MD_3D = _create_mock_histoworkspace( + ndims=3, + coords=SpecialCoordinateSystem.NONE, + extents=(-3, 3, -10, 10, -1, 1), + signal=np.arange(100.0).reshape(5, 5, 4), + error=np.arange(100.0).reshape(5, 5, 4), + nbins=(5, 5, 4), + names=("Dim1", "Dim2", "Dim3"), + units=("MomentumTransfer", "EnergyTransfer", "Angstrom"), + isq=(False, False, False), + ) + self.ws_MD_3D.name.return_value = "ws_MD_3D" + self.ws_MDE_3D = _create_mock_mdeventworkspace( + ndims=3, + coords=SpecialCoordinateSystem.NONE, + extents=(-3, 3, -4, 4, -5, 5), + names=("h", "k", "l"), + units=("rlu", "rlu", "rlu"), + isq=(True, True, True), + ) + self.ws_MDE_4D = _create_mock_mdeventworkspace( + ndims=4, + coords=SpecialCoordinateSystem.NONE, + extents=(-2, 2, -3, 3, -4, 4, -5, 5), + names=("e", "h", "k", "l"), + units=("meV", "rlu", "rlu", "rlu"), + isq=(False, True, True, True), + ) + self.ws_MDE_3D.name.return_value = "ws_MDE_3D" + + self.ws2d_histo = _create_mock_matrixworkspace( + x_axis=(10, 20, 30), y_axis=(4, 6, 8), distribution=True, names=("Wavelength", "Energy transfer"), units=("Angstrom", "meV") + ) + self.ws2d_histo.name.return_value = "ws2d_histo" + + def setUp(self): + self.ws2d_histo.reset_mock() + + def test_init_with_valid_MatrixWorkspace(self): + mock_ws = MagicMock(spec=MatrixWorkspace) + mock_ws.getNumberHistograms.return_value = 2 + mock_ws.getDimension.return_value.getNBins.return_value = 2.0 + + self.assertIsNotNone(SliceViewerModel(mock_ws)) + + def test_init_with_valid_MDHistoWorkspace(self): + mock_ws = MagicMock(spec=MultipleExperimentInfos) + mock_ws.name = Mock(return_value="") + mock_ws.isMDHistoWorkspace = Mock(return_value=True) + mock_ws.getNumNonIntegratedDims = Mock(return_value=700) + mock_ws.numOriginalWorkspaces = Mock(return_value=0) + + with patch.object(SliceViewerModel, "_calculate_axes_angles"): + self.assertIsNotNone(SliceViewerModel(mock_ws)) + + def test_init_with_valid_MDEventWorkspace(self): + mock_ws = MagicMock(spec=MultipleExperimentInfos) + mock_ws.name = Mock(return_value="") + mock_ws.isMDHistoWorkspace = Mock(return_value=False) + mock_ws.getNumDims = Mock(return_value=4) + mock_ws.numOriginalWorkspaces = Mock(return_value=0) + + with patch.object(SliceViewerModel, "_calculate_axes_angles"): + self.assertIsNotNone(SliceViewerModel(mock_ws)) + + def test_init_raises_for_incorrect_workspace_type(self): + mock_ws = MagicMock() + + self.assertRaisesRegex(ValueError, "MatrixWorkspace and MDWorkspace", SliceViewerModel, mock_ws) + + def test_init_raises_if_fewer_than_two_histograms(self): + mock_ws = MagicMock(spec=MatrixWorkspace) + mock_ws.getNumberHistograms.return_value = 1 + + self.assertRaisesRegex(ValueError, "contain at least 2 spectra", SliceViewerModel, mock_ws) + + def test_init_raises_if_fewer_than_two_bins(self): + mock_ws = MagicMock(spec=MatrixWorkspace) + mock_ws.getNumberHistograms.return_value = 2 + mock_ws.getDimension.return_value.getNBins.return_value = 1 + + self.assertRaisesRegex(ValueError, "contain at least 2 bins", SliceViewerModel, mock_ws) + + def test_init_raises_if_fewer_than_two_integrated_dimensions(self): + mock_ws = MagicMock(spec=MultipleExperimentInfos) + mock_ws.isMDHistoWorkspace = Mock(return_value=True) + mock_ws.getNumNonIntegratedDims = Mock(return_value=1) + mock_ws.name = Mock(return_value="") + + self.assertRaisesRegex(ValueError, "at least 2 non-integrated dimensions", SliceViewerModel, mock_ws) + + def test_init_raises_if_fewer_than_two_dimensions(self): + mock_ws = MagicMock(spec=MultipleExperimentInfos) + mock_ws.isMDHistoWorkspace = Mock(return_value=False) + mock_ws.getNumDims = Mock(return_value=1) + mock_ws.name = Mock(return_value="") + + self.assertRaisesRegex(ValueError, "at least 2 dimensions", SliceViewerModel, mock_ws) + + @patch("mantidqt.widgets.sliceviewer.models.model.BinMD") + def test_model_MDE_basis_vectors_not_normalised_when_HKL(self, mock_binmd): + ws = _create_mock_mdeventworkspace( + ndims=3, + coords=SpecialCoordinateSystem.HKL, + extents=(-3, 3, -4, 4, -5, 5), + names=("h", "k", "l"), + units=("r.l.u.", "r.l.u.", "r.l.u."), + isq=(True, True, True), + ) + model = SliceViewerModel(ws) + mock_binmd.return_value = self.ws_MD_3D # different workspace + + self.assertNotEqual(model.get_ws((None, None, 0), (1, 2, 4)), ws) + + mock_binmd.assert_called_once_with( + AxisAligned=False, + NormalizeBasisVectors=False, + BasisVector0="h,r.l.u.,1.0,0.0,0.0", + BasisVector1="k,r.l.u.,0.0,1.0,0.0", + BasisVector2="l,r.l.u.,0.0,0.0,1.0", + EnableLogging=False, + InputWorkspace=ws, + OutputBins=[1, 2, 1], + OutputExtents=[-3, 3, -4, 4, -2.0, 2.0], + OutputWorkspace=ws.name() + "_svrebinned", + ) + mock_binmd.reset_mock() + + @patch("mantidqt.widgets.sliceviewer.models.model.BinMD") + def test_get_ws_MDE_with_limits_uses_limits_over_dimension_extents(self, mock_binmd): + model = SliceViewerModel(self.ws_MDE_3D) + mock_binmd.return_value = self.ws_MD_3D + + self.assertNotEqual(model.get_ws((None, None, 0), (1, 2, 4), ((-2, 2), (-1, 1)), [0, 1, None]), self.ws_MDE_3D) + + call_params = dict( + AxisAligned=False, + BasisVector0="h,rlu,1.0,0.0,0.0", + BasisVector1="k,rlu,0.0,1.0,0.0", + BasisVector2="l,rlu,0.0,0.0,1.0", + EnableLogging=False, + InputWorkspace=self.ws_MDE_3D, + OutputBins=[1, 2, 1], + OutputExtents=[-2, 2, -1, 1, -2.0, 2.0], + OutputWorkspace="ws_MDE_3D_svrebinned", + ) + mock_binmd.assert_called_once_with(**call_params) + mock_binmd.reset_mock() + + model.get_data((None, None, 0), (1, 2, 4), [0, 1, None], ((-2, 2), (-1, 1))) + mock_binmd.assert_called_once_with(**call_params) + + @patch("mantidqt.widgets.sliceviewer.models.model.BinMD") + def test_get_ws_mde_sets_minimum_width_on_data_limits(self, mock_binmd): + model = SliceViewerModel(self.ws_MDE_3D) + mock_binmd.return_value = self.ws_MD_3D + xmin = -5e-8 + xmax = 5e-8 + + self.assertNotEqual(model.get_ws((None, None, 0), (1, 2, 4), ((xmin, xmax), (-1, 1)), [0, 1, None]), self.ws_MDE_3D) + + call_params = dict( + AxisAligned=False, + BasisVector0="h,rlu,1.0,0.0,0.0", + BasisVector1="k,rlu,0.0,1.0,0.0", + BasisVector2="l,rlu,0.0,0.0,1.0", + EnableLogging=False, + InputWorkspace=self.ws_MDE_3D, + OutputBins=[1, 2, 1], + OutputExtents=[xmin, xmin + MIN_WIDTH, -1, 1, -2.0, 2.0], + OutputWorkspace="ws_MDE_3D_svrebinned", + ) + mock_binmd.assert_called_once_with(**call_params) + mock_binmd.reset_mock() + + def test_matrix_workspace_can_be_normalized_if_not_a_distribution(self): + non_distrib_ws2d = _create_mock_matrixworkspace((1, 2, 3), (4, 5, 6), distribution=False, names=("a", "b")) + model = SliceViewerModel(non_distrib_ws2d) + self.assertTrue(model.can_normalize_workspace()) + + def test_matrix_workspace_cannot_be_normalized_if_a_distribution(self): + model = SliceViewerModel(self.ws2d_histo) + self.assertFalse(model.can_normalize_workspace()) + + def test_MD_workspaces_cannot_be_normalized(self): + model = SliceViewerModel(self.ws_MD_3D) + self.assertFalse(model.can_normalize_workspace()) + + def test_MDE_workspaces_cannot_be_normalized(self): + model = SliceViewerModel(self.ws_MDE_3D) + self.assertFalse(model.can_normalize_workspace()) + + def test_MDH_workspace_in_hkl_supports_non_orthogonal_axes(self): + self._assert_supports_non_orthogonal_axes( + True, ws_type=IMDHistoWorkspace, coords=SpecialCoordinateSystem.HKL, has_oriented_lattice=True + ) + + def test_matrix_workspace_cannot_support_non_axis_aligned_cuts(self): + self._assert_supports_non_axis_aligned_cuts(False, ws_type=MatrixWorkspace) + + def test_MDE_requires_3dims_to_support_non_axis_aligned_cuts(self): + self._assert_supports_non_axis_aligned_cuts(False, ws_type=IMDEventWorkspace, coords=SpecialCoordinateSystem.HKL, ndims=2) + self._assert_supports_non_axis_aligned_cuts(True, ws_type=IMDEventWorkspace, coords=SpecialCoordinateSystem.HKL, ndims=3) + self._assert_supports_non_axis_aligned_cuts(False, ws_type=IMDEventWorkspace, coords=SpecialCoordinateSystem.HKL, ndims=4) + + def test_MDE_workspace_in_hkl_supports_non_orthogonal_axes(self): + self._assert_supports_non_orthogonal_axes( + True, ws_type=IMDEventWorkspace, coords=SpecialCoordinateSystem.HKL, has_oriented_lattice=True + ) + + def test_matrix_workspace_cannot_support_non_orthogonal_axes(self): + self._assert_supports_non_orthogonal_axes( + False, ws_type=MatrixWorkspace, coords=SpecialCoordinateSystem.HKL, has_oriented_lattice=None + ) + + def test_MDH_workspace_in_hkl_without_lattice_cannot_support_non_orthogonal_axes(self): + self._assert_supports_non_orthogonal_axes( + False, ws_type=IMDHistoWorkspace, coords=SpecialCoordinateSystem.HKL, has_oriented_lattice=False + ) + + def test_MDE_workspace_in_hkl_without_lattice_cannot_support_non_orthogonal_axes(self): + self._assert_supports_non_orthogonal_axes( + False, ws_type=IMDEventWorkspace, coords=SpecialCoordinateSystem.HKL, has_oriented_lattice=False + ) + + def test_MDH_workspace_in_non_hkl_cannot_support_non_orthogonal_axes(self): + self._assert_supports_non_orthogonal_axes( + False, ws_type=IMDHistoWorkspace, coords=SpecialCoordinateSystem.QLab, has_oriented_lattice=False + ) + self._assert_supports_non_orthogonal_axes( + False, ws_type=IMDHistoWorkspace, coords=SpecialCoordinateSystem.QLab, has_oriented_lattice=True + ) + + def test_MDE_workspace_in_non_hkl_cannot_support_non_orthogonal_axes(self): + self._assert_supports_non_orthogonal_axes( + False, ws_type=IMDEventWorkspace, coords=SpecialCoordinateSystem.QLab, has_oriented_lattice=False + ) + self._assert_supports_non_orthogonal_axes( + False, ws_type=IMDEventWorkspace, coords=SpecialCoordinateSystem.QLab, has_oriented_lattice=True + ) + + def test_matrix_workspace_cannot_support_peaks_overlay(self): + self._assert_supports_peaks_overlay(False, MatrixWorkspace) + + def test_md_workspace_with_fewer_than_three_dimensions_cannot_support_peaks_overlay(self): + self._assert_supports_peaks_overlay(False, IMDEventWorkspace, ndims=2) + self._assert_supports_peaks_overlay(False, IMDHistoWorkspace, ndims=2) + + def test_md_workspace_with_three_or_more_dimensions_can_support_peaks_overlay(self): + self._assert_supports_peaks_overlay(True, IMDEventWorkspace, ndims=3) + self._assert_supports_peaks_overlay(True, IMDHistoWorkspace, ndims=3) + + def test_title_for_matrixworkspace_just_contains_ws_name(self): + model = SliceViewerModel(self.ws2d_histo) + + self.assertEqual("Sliceviewer - ws2d_histo", model.get_title()) + + def test_title_for_mdeventworkspace_just_contains_ws_name(self): + model = SliceViewerModel(self.ws_MDE_3D) + + self.assertEqual("Sliceviewer - ws_MDE_3D", model.get_title()) + + def test_title_for_mdhistoworkspace_without_original_just_contains_ws_name(self): + model = SliceViewerModel(self.ws_MD_3D) + + self.assertEqual("Sliceviewer - ws_MD_3D", model.get_title()) + + def test_title_for_mdhistoworkspace_with_original(self): + with _attach_as_original(self.ws_MD_3D, self.ws_MDE_3D): + model = SliceViewerModel(self.ws_MD_3D) + + self.assertEqual("Sliceviewer - ws_MD_3D", model.get_title()) + + def test_calculate_axes_angles_returns_none_if_nonorthogonal_transform_not_supported(self): + model = SliceViewerModel(_create_mock_workspace(MatrixWorkspace, SpecialCoordinateSystem.QLab, has_oriented_lattice=False)) + + self.assertIsNone(model.get_axes_angles()) + + def test_calculate_axes_angles_uses_W_if_available_MDEvent(self): + # test MD event + ws = _create_mock_workspace(IMDEventWorkspace, SpecialCoordinateSystem.HKL, has_oriented_lattice=True) + ws.getExperimentInfo().run().get().value = [0, 1, 1, 0, 0, 1, 1, 0, 0] + model = SliceViewerModel(ws) + + axes_angles = model.get_axes_angles() + self.assertAlmostEqual(axes_angles[1, 2], np.pi / 4, delta=1e-10) + for iy in range(1, 3): + self.assertAlmostEqual(axes_angles[0, iy], np.pi / 2, delta=1e-10) + # test force_orthog works + axes_angles = model.get_axes_angles(force_orthogonal=True) + self.assertAlmostEqual(axes_angles[1, 2], np.pi / 2, delta=1e-10) + + def test_calculate_axes_angles_uses_basis_vectors_even_if_WMatrix_log_available_MDHisto(self): + # test MD histo + ws = _create_mock_workspace(IMDHistoWorkspace, SpecialCoordinateSystem.HKL, has_oriented_lattice=True) + ws.getExperimentInfo().run().get().value = [0, 1, 1, 0, 0, 1, 1, 0, 0] + model = SliceViewerModel(ws) + + # should revert to orthogonal (as given by basis vectors on workspace) + # i.e. not angles returned by proj_matrix in ws.getExperimentInfo().run().get().value + axes_angles = model.get_axes_angles() + self.assertAlmostEqual(axes_angles[1, 2], np.pi / 2, delta=1e-10) + for iy in range(1, 3): + self.assertAlmostEqual(axes_angles[0, iy], np.pi / 2, delta=1e-10) + + def test_calculate_axes_angles_uses_identity_if_W_unavailable_MDEvent(self): + # test MD event + ws = _create_mock_workspace(IMDEventWorkspace, SpecialCoordinateSystem.HKL, has_oriented_lattice=True) + ws.getExperimentInfo().run().get.side_effect = KeyError + model = SliceViewerModel(ws) + + axes_angles = model.get_axes_angles() + self.assertAlmostEqual(axes_angles[1, 2], np.pi / 2, delta=1e-10) + for iy in range(1, 3): + self.assertAlmostEqual(axes_angles[0, iy], np.pi / 2, delta=1e-10) + + def test_calculate_axes_angles_uses_identity_if_W_unavailable_MDHisto(self): + # test MD histo + ws = _create_mock_workspace(IMDHistoWorkspace, SpecialCoordinateSystem.HKL, has_oriented_lattice=True) + ws.getExperimentInfo().run().get.side_effect = KeyError + model = SliceViewerModel(ws) + + axes_angles = model.get_axes_angles() + self.assertAlmostEqual(axes_angles[1, 2], np.pi / 2, delta=1e-10) + for iy in range(1, 3): + self.assertAlmostEqual(axes_angles[0, iy], np.pi / 2, delta=1e-10) + + def test_calculate_axes_angles_uses_W_if_basis_vectors_unavailable_and_W_available_MDHisto(self): + # test MD histo + ws = _create_mock_workspace(IMDHistoWorkspace, SpecialCoordinateSystem.HKL, has_oriented_lattice=True, ndims=3) + ws.getBasisVector.side_effect = lambda x: [0.0] # will cause proj_matrix to be all zeros + ws.getExperimentInfo().run().get().value = [0, 1, 1, 0, 0, 1, 1, 0, 0] + model = SliceViewerModel(ws) + + axes_angles = model.get_axes_angles() + self.assertAlmostEqual(axes_angles[1, 2], np.pi / 4, delta=1e-10) + for iy in range(1, 3): + self.assertAlmostEqual(axes_angles[0, iy], np.pi / 2, delta=1e-10) + # test force_orthog works + axes_angles = model.get_axes_angles(force_orthogonal=True) + self.assertAlmostEqual(axes_angles[1, 2], np.pi / 2, delta=1e-10) + + def test_calc_proj_matrix_4D_workspace_nonQ_dims(self): + ws = _create_mock_histoworkspace( + ndims=4, + coords=SpecialCoordinateSystem.NONE, + extents=(-3, 3, -10, 10, -1, 1, -2, 2), + signal=np.arange(100.0).reshape(5, 5, 2, 2), + error=np.arange(100.0).reshape(5, 5, 2, 2), + nbins=(5, 5, 2, 2), + names=("00L", "HH0", "-KK0", "E"), + units=("rlu", "rlu", "rlu", "EnergyTransfer"), + isq=(True, True, True, False), + ) + # basis vectors as if ws was produced from BinMD call on MDEvent with dims E, H, K, L + basis_mat = np.array([[0, 0, 0, 1], [0, 1, -1, 0], [0, 1, 1, 0], [1, 0, 0, 0]]) + ws.getBasisVector.side_effect = lambda idim: basis_mat[:, idim] + model = SliceViewerModel(ws) + + proj_mat = model.get_proj_matrix() + + self.assertTrue(np.all(np.isclose(proj_mat, [[0, 1, -1], [0, 1, 1], [1, 0, 0]]))) + + @patch.multiple("mantidqt.widgets.sliceviewer.models.roi", ExtractSpectra=DEFAULT, Rebin=DEFAULT, SumSpectra=DEFAULT, Transpose=DEFAULT) + def test_export_roi_for_matrixworkspace(self, ExtractSpectra, **_): + xmin, xmax, ymin, ymax = -1.0, 3.0, 2.0, 4.0 + + def assert_call_as_expected(exp_xmin, exp_xmax, exp_start_index, exp_end_index, transpose, is_spectra): + mock_ws = _create_mock_matrixworkspace(x_axis=[10, 20, 30], y_axis=[1, 2, 3, 4, 5], distribution=False, y_is_spectra=is_spectra) + mock_ws.name.return_value = "mock_ws" + model = SliceViewerModel(mock_ws) + slicepoint, bin_params, dimension_indices = MagicMock(), MagicMock(), MagicMock() + + help_msg = model.export_roi_to_workspace(slicepoint, bin_params, ((xmin, xmax), (ymin, ymax)), transpose, dimension_indices) + + self.assertEqual("ROI created: mock_ws_roi", help_msg) + if is_spectra: + self.assertEqual(2, mock_ws.getAxis(1).indexOfValue.call_count) + else: + mock_ws.getAxis(1).extractValues.assert_called_once() + + ExtractSpectra.assert_called_once_with( + InputWorkspace=mock_ws, + OutputWorkspace="mock_ws_roi", + XMin=exp_xmin, + XMax=exp_xmax, + StartWorkspaceIndex=exp_start_index, + EndWorkspaceIndex=exp_end_index, + EnableLogging=True, + ) + ExtractSpectra.reset_mock() + + assert_call_as_expected(xmin, xmax, 3, 5, transpose=False, is_spectra=True) + assert_call_as_expected(2.0, 4.0, 0, 4, transpose=True, is_spectra=True) + assert_call_as_expected(xmin, xmax, 1, 3, transpose=False, is_spectra=False) + assert_call_as_expected(ymin, ymax, 0, 2, transpose=True, is_spectra=False) + + @patch("mantidqt.widgets.sliceviewer.models.roi.ExtractSpectra") + @patch("mantidqt.widgets.sliceviewer.models.roi.Rebin") + @patch("mantidqt.widgets.sliceviewer.models.roi.SumSpectra") + @patch("mantidqt.widgets.sliceviewer.models.roi.Transpose") + def test_export_cuts_for_matrixworkspace(self, mock_transpose, mock_sum_spectra, mock_rebin, mock_extract_spectra): + xmin, xmax, ymin, ymax = -4.0, 5.0, 6.0, 9.0 + + def assert_call_as_expected(mock_ws, transpose, export_type, is_spectra, is_ragged): + model = SliceViewerModel(mock_ws) + slicepoint, bin_params, dimension_indices = MagicMock(), MagicMock(), MagicMock() + + help_msg = model.export_cuts_to_workspace( + slicepoint, bin_params, ((xmin, xmax), (ymin, ymax)), transpose, dimension_indices, export_type + ) + + if export_type == "c": + if is_spectra: + mock_extract_spectra.assert_called_once() + if is_ragged: + mock_rebin.assert_called_once() + mock_sum_spectra.assert_called_once() + else: + if is_ragged: + self.assertEqual(2, mock_rebin.call_count) + else: + mock_rebin.assert_called_once() + self.assertEqual(1, mock_transpose.call_count) + self.assertEqual(1, mock_extract_spectra.call_count) + self.assertEqual("Cuts along X/Y created: mock_ws_cut_x & mock_ws_cut_y", help_msg) + elif export_type == "x": + mock_extract_spectra.assert_called_once() + if is_ragged: + self.assertEqual(1, mock_rebin.call_count) + if is_spectra: + mock_sum_spectra.assert_called_once() + else: + mock_transpose.assert_not_called() + self.assertEqual("Cut along X created: mock_ws_cut_x", help_msg) + elif export_type == "y": + mock_extract_spectra.assert_called_once() + mock_transpose.assert_called_once() + if is_ragged: + self.assertEqual(2, mock_rebin.call_count) + else: + self.assertEqual(1, mock_rebin.call_count) + self.assertEqual("Cut along Y created: mock_ws_cut_y", help_msg) + + mock_transpose.reset_mock() + mock_rebin.reset_mock() + mock_extract_spectra.reset_mock() + mock_sum_spectra.reset_mock() + + # end def + + for export_type in ("c", "x", "y"): + is_ragged = False + for is_spectra in (True, False): + mock_ws = _create_mock_matrixworkspace( + x_axis=[10, 20, 30], y_axis=[1, 2, 3, 4, 5], distribution=False, y_is_spectra=is_spectra + ) + mock_ws.name.return_value = "mock_ws" + assert_call_as_expected(mock_ws, transpose=False, export_type=export_type, is_spectra=is_spectra, is_ragged=is_ragged) + + is_ragged = True + mock_ws = _create_mock_matrixworkspace( + x_axis=[10, 20, 30, 11, 21, 31], y_axis=[1, 2, 3, 4, 5], distribution=False, y_is_spectra=is_spectra + ) + mock_ws.isCommonBins.return_value = False + mock_ws.name.return_value = "mock_ws" + assert_call_as_expected(mock_ws, transpose=False, export_type=export_type, is_spectra=is_spectra, is_ragged=is_ragged) + + @patch("mantidqt.widgets.sliceviewer.models.model.IntegrateMDHistoWorkspace") + @patch("mantidqt.widgets.sliceviewer.models.model.TransposeMD") + def test_export_pixel_cut_to_workspace_mdhisto(self, mock_transpose, mock_intMD): + slicepoint = [None, None, 0.5] + bin_params = [100, 100, 0.1] + + def assert_call_as_expected(transpose, cut_type): + xpos, ypos = 0.0, 2.0 + xidx, yidx = 0, 1 + dimension_indices = [0, 1, None] + if transpose: + xpos, ypos = ypos, xpos + xidx, yidx = yidx, xidx + dimension_indices = [1, 0, None] + + model = SliceViewerModel(self.ws_MD_3D) + help_msg = model.export_pixel_cut_to_workspace(slicepoint, bin_params, (xpos, ypos), transpose, dimension_indices, cut_type) + + xdim = self.ws_MD_3D.getDimension(xidx) + ydim = self.ws_MD_3D.getDimension(yidx) + + deltax = xdim.getBinWidth() + deltay = ydim.getBinWidth() + + limits_x = ((xdim.getMinimum(), xdim.getMaximum()), (ypos - 0.5 * deltay, ypos + 0.5 * deltay)) + limits_y = ((xpos - 0.5 * deltax, xpos + 0.5 * deltax), (ydim.getMinimum(), ydim.getMaximum())) + + zmin, zmax = 0.45, 0.55 + + assert_called = mock_intMD.assert_called_with if cut_type == "c" else mock_intMD.assert_called_once_with + + if not transpose: + if cut_type == "y" or cut_type == "c": + xmin, xmax = limits_y[xidx] + ymin, ymax = limits_y[yidx] + + ycut_params = dict( + InputWorkspace=self.ws_MD_3D, + P1Bin=[xmin, xmax], + P2Bin=[ymin, 0, ymax], + P3Bin=[zmin, zmax], + OutputWorkspace="ws_MD_3D_cut_y", + ) + assert_called(**ycut_params) + elif cut_type == "x" or cut_type == "c": + xmin, xmax = limits_x[xidx] + ymin, ymax = limits_x[yidx] + + xcut_params = dict( + InputWorkspace=self.ws_MD_3D, + P1Bin=[xmin, 0, xmax], + P2Bin=[ymin, ymax], + P3Bin=[zmin, zmax], + OutputWorkspace="ws_MD_3D_cut_x", + ) + assert_called(**xcut_params) + else: + if cut_type == "y" or cut_type == "c": + xmin, xmax = limits_y[xidx] + ymin, ymax = limits_y[yidx] + + ycut_params = dict( + InputWorkspace=self.ws_MD_3D, + P1Bin=[xmin, 0, xmax], + P2Bin=[ymin, ymax], + P3Bin=[zmin, zmax], + OutputWorkspace="ws_MD_3D_cut_y", + ) + assert_called(**ycut_params) + + elif cut_type == "x" or cut_type == "c": + xmin, xmax = limits_x[xidx] + ymin, ymax = limits_x[yidx] + + xcut_params = dict( + InputWorkspace=self.ws_MD_3D, + P1Bin=[xmin, xmax], + P2Bin=[ymin, 0, ymax], + P3Bin=[zmin, zmax], + OutputWorkspace="ws_MD_3D_cut_x", + ) + assert_called(**xcut_params) + + if cut_type == "c": + self.assertEqual("Cuts along X/Y created: ws_MD_3D_cut_x & ws_MD_3D_cut_y", help_msg) + elif cut_type == "x": + self.assertEqual("Cut along X created: ws_MD_3D_cut_x", help_msg) + elif cut_type == "y": + self.assertEqual("Cut along Y created: ws_MD_3D_cut_y", help_msg) + + mock_intMD.reset_mock() + + for cut_type in ("x", "y", "c"): + assert_call_as_expected(transpose=False, cut_type=cut_type) + assert_call_as_expected(transpose=True, cut_type=cut_type) + + @patch("mantidqt.widgets.sliceviewer.models.model.BinMD") + @patch("mantidqt.widgets.sliceviewer.models.model.TransposeMD") + def test_export_pixel_cut_to_workspace_mdevent(self, mock_transpose, mock_binmd): + slicepoint = [None, None, 0.5] + bin_params = [100, 100, 0.1] + + def assert_call_as_expected(transpose, cut_type): + xpos, ypos = 0.0, 2.0 + xidx, yidx = 0, 1 + dimension_indices = [0, 1, None] + if transpose: + xpos, ypos = ypos, xpos + xidx, yidx = yidx, xidx + dimension_indices = [1, 0, None] + + model = SliceViewerModel(self.ws_MDE_3D) + help_msg = model.export_pixel_cut_to_workspace(slicepoint, bin_params, (xpos, ypos), transpose, dimension_indices, cut_type) + + xdim = self.ws_MDE_3D.getDimension(xidx) + ydim = self.ws_MDE_3D.getDimension(yidx) + + deltax = xdim.getBinWidth() / bin_params[xidx] + deltay = ydim.getBinWidth() / bin_params[yidx] + + if cut_type == "x": + xmin, xmax = xdim.getMinimum(), xdim.getMaximum() + ymin, ymax = ypos - 0.5 * deltay, ypos + 0.5 * deltay + else: + xmin, xmax = xpos - 0.5 * deltax, xpos + 0.5 * deltax + ymin, ymax = ydim.getMinimum(), ydim.getMaximum() + + zmin, zmax = 0.45, 0.55 + + common_params = dict( + InputWorkspace=self.ws_MDE_3D, + AxisAligned=False, + BasisVector0="h,rlu,1.0,0.0,0.0", + BasisVector1="k,rlu,0.0,1.0,0.0", + BasisVector2="l,rlu,0.0,0.0,1.0", + ) + + assert_called = mock_binmd.assert_called_with if cut_type == "c" else mock_binmd.assert_called_once_with + + if not transpose: + extents = [xmin, xmax, ymin, ymax, zmin, zmax] + + if cut_type == "y" or cut_type == "c": + expected_bins = [1, 100, 1] + expected_ws = "ws_MDE_3D_cut_y" + expected_msg = "Cut along Y created: ws_MDE_3D_cut_y" + + assert_called(**common_params, OutputExtents=extents, OutputBins=expected_bins, OutputWorkspace=expected_ws) + elif cut_type == "x" or cut_type == "c": + expected_bins = [100, 1, 1] + expected_ws = "ws_MDE_3D_cut_x" + expected_msg = "Cut along X created: ws_MDE_3D_cut_x" + + assert_called(**common_params, OutputExtents=extents, OutputBins=expected_bins, OutputWorkspace=expected_ws) + + else: + extents = [ymin, ymax, xmin, xmax, zmin, zmax] + + if cut_type == "y" or cut_type == "c": + expected_bins = [100, 1, 1] + expected_ws = "ws_MDE_3D_cut_y" + expected_msg = "Cut along Y created: ws_MDE_3D_cut_y" + assert_called(**common_params, OutputExtents=extents, OutputBins=expected_bins, OutputWorkspace=expected_ws) + elif cut_type == "x" or cut_type == "c": + expected_bins = [1, 100, 1] + expected_ws = "ws_MDE_3D_cut_x" + expected_msg = "Cut along X created: ws_MDE_3D_cut_x" + assert_called(**common_params, OutputExtents=extents, OutputBins=expected_bins, OutputWorkspace=expected_ws) + + if cut_type == "c": + expected_msg = "Cuts along X/Y created: ws_MDE_3D_cut_x & ws_MDE_3D_cut_y" + elif cut_type == "x": + expected_msg = "Cut along X created: ws_MDE_3D_cut_x" + elif cut_type == "y": + expected_msg = "Cut along Y created: ws_MDE_3D_cut_y" + + self.assertEqual(expected_msg, help_msg) + mock_binmd.reset_mock() + + for cut_type in ("x", "y", "c"): + assert_call_as_expected(transpose=False, cut_type=cut_type) + assert_call_as_expected(transpose=True, cut_type=cut_type) + + @patch("mantidqt.widgets.sliceviewer.models.roi.extract_roi_matrix") + def test_export_pixel_cut_to_workspace_matrix(self, mock_extract_roi): + slicepoint = [None, None] + bin_params = [100, 100] + dimension_indices = [0, 1] + xpos, ypos = 1.5, 3.0 + + def assert_call_as_expected(transpose, cut_type): + model = SliceViewerModel(self.ws2d_histo) + mock_extract_roi.reset_mock() + + help_msg = model.export_pixel_cut_to_workspace(slicepoint, bin_params, (xpos, ypos), transpose, dimension_indices, cut_type) + + if not transpose: + if cut_type == "x" or cut_type == "c": + mock_extract_roi.assert_called_once_with(self.ws2d_histo, None, None, ypos, ypos, False, "ws2d_histo_cut_x") + self.assertEqual("Cut along X created: ws2d_histo_cut_x", help_msg) + elif cut_type == "y" or cut_type == "c": + mock_extract_roi.assert_called_once_with(self.ws2d_histo, xpos, xpos, None, None, True, "ws2d_histo_cut_y") + self.assertEqual("Cut along Y created: ws2d_histo_cut_y", help_msg) + else: + if cut_type == "x" or cut_type == "c": + mock_extract_roi.assert_called_once_with(self.ws2d_histo, ypos, ypos, None, None, True, "ws2d_histo_cut_y") + self.assertEqual("Cut along X created: ws2d_histo_cut_y", help_msg) + elif cut_type == "y" or cut_type == "c": + mock_extract_roi.assert_called_once_with(self.ws2d_histo, None, None, xpos, xpos, False, "ws2d_histo_cut_x") + self.assertEqual("Cut along Y created: ws2d_histo_cut_x", help_msg) + + for cut_type in ("x", "y", "c"): + assert_call_as_expected(transpose=False, cut_type=cut_type) + assert_call_as_expected(transpose=True, cut_type=cut_type) + + @patch("mantidqt.widgets.sliceviewer.models.model.TransposeMD") + @patch("mantidqt.widgets.sliceviewer.models.model.IntegrateMDHistoWorkspace") + def test_export_region_for_mdhisto_workspace(self, mock_intMD, mock_transposemd): + xmin, xmax, ymin, ymax = -1.0, 3.0, 2.0, 4.0 + slicepoint, bin_params = (None, None, 0.5), (100, 100, 0.1) + dimension_indices = [0, 1, None] # Value at index i is the index of the axis that dimension i is displayed on + transposed_dimension_indices = [1, 0, None] + + def assert_call_as_expected(transpose, dimension_indices, export_type, bin_params): + model = SliceViewerModel(self.ws_MD_3D) + + if export_type == "r": + model.export_roi_to_workspace(slicepoint, bin_params, ((xmin, xmax), (ymin, ymax)), transpose, dimension_indices) + else: + model.export_cuts_to_workspace( + slicepoint, bin_params, ((xmin, xmax), (ymin, ymax)), transpose, dimension_indices, export_type + ) + + if export_type == "c": + export_type = "xy" # will loop over this string as 'c' performs both 'x' and 'y' + + for export in export_type: + xbin, ybin = [xmin, xmax], [ymin, ymax] # create in loop as these are altered in case of both cuts + # perform transpose on limits - i.e map x/y on plot to basis of MD workspace p1/p2 + if not transpose: + p1_bin, p2_bin = xbin, ybin + else: + p2_bin, p1_bin = xbin, ybin + # determine which axis was binnned + if export == "x": + xbin.insert(1, 0.0) # insert 0 between min,max - this means preserve binning along this dim + out_name = "ws_MD_3D_cut_x" + transpose_axes = [1 if transpose else 0] # Axes argument of TransposeMD + elif export == "y": + ybin.insert(1, 0.0) + out_name = "ws_MD_3D_cut_y" + # check call to transposeMD + transpose_axes = [0 if transpose else 1] + else: + # export == 'r' + xbin.insert(1, 0.0) + ybin.insert(1, 0.0) + out_name = "ws_MD_3D_roi" + transpose_axes = [1, 0] if transpose else None + + dim = self.ws_MD_3D.getDimension(2) + half_bin_width = bin_params[2] / 2 if bin_params is not None else dim.getBinWidth() / 2 + + # check call to IntegrateMDHistoWorkspace + mock_intMD.assert_has_calls( + [ + call( + InputWorkspace=self.ws_MD_3D, + P1Bin=p1_bin, + P2Bin=p2_bin, + P3Bin=[slicepoint[2] - half_bin_width, slicepoint[2] + half_bin_width], + OutputWorkspace=out_name, + ) + ], + any_order=False, + ) + if transpose_axes is not None: + mock_transposemd.assert_has_calls( + [call(InputWorkspace=out_name, OutputWorkspace=out_name, Axes=transpose_axes)], any_order=False + ) + else: + mock_transposemd.assert_not_called() # ROI with Transpose == False + + mock_intMD.reset_mock() + mock_transposemd.reset_mock() + + for export_type in ("r", "x", "y", "c"): + assert_call_as_expected(transpose=False, dimension_indices=dimension_indices, export_type=export_type, bin_params=bin_params) + assert_call_as_expected(transpose=False, dimension_indices=dimension_indices, export_type=export_type, bin_params=None) + assert_call_as_expected( + transpose=True, dimension_indices=transposed_dimension_indices, export_type=export_type, bin_params=bin_params + ) + assert_call_as_expected( + transpose=True, dimension_indices=transposed_dimension_indices, export_type=export_type, bin_params=None + ) + + @patch("mantidqt.widgets.sliceviewer.models.model.TransposeMD") + @patch("mantidqt.widgets.sliceviewer.models.model.BinMD") + def test_export_region_for_mdevent_workspace(self, mock_binmd, mock_transposemd): + xmin, xmax, ymin, ymax = -1.0, 3.0, 2.0, 4.0 + slicepoint, bin_params = (None, None, 0.5), (100, 100, 0.1) + zmin, zmax = 0.45, 0.55 # 3rd dimension extents + dimension_indices = [0, 1, None] # Value at index i is the index of the axis that dimension i is displayed on + transposed_dimension_indices = [1, 0, None] + + def assert_call_as_expected(transpose, dimension_indices, export_type): + model = SliceViewerModel(self.ws_MDE_3D) + + if export_type == "r": + help_msg = model.export_roi_to_workspace(slicepoint, bin_params, ((xmin, xmax), (ymin, ymax)), transpose, dimension_indices) + else: + help_msg = model.export_cuts_to_workspace( + slicepoint, bin_params, ((xmin, xmax), (ymin, ymax)), transpose, dimension_indices, export_type + ) + + if transpose: + extents = [ymin, ymax, xmin, xmax, zmin, zmax] + else: + extents = [xmin, xmax, ymin, ymax, zmin, zmax] + common_call_params = dict( + InputWorkspace=self.ws_MDE_3D, + AxisAligned=False, + BasisVector0="h,rlu,1.0,0.0,0.0", + BasisVector1="k,rlu,0.0,1.0,0.0", + BasisVector2="l,rlu,0.0,0.0,1.0", + OutputExtents=extents, + ) + xcut_name, ycut_name = "ws_MDE_3D_cut_x", "ws_MDE_3D_cut_y" + if export_type == "r": + expected_help_msg = "ROI created: ws_MDE_3D_roi" + expected_calls = [call(**common_call_params, OutputBins=[100, 100, 1], OutputWorkspace="ws_MDE_3D_roi")] + elif export_type == "x": + expected_help_msg = f"Cut along X created: {xcut_name}" + expected_bins = [1, 100, 1] if transpose else [100, 1, 1] + expected_calls = [call(**common_call_params, OutputBins=expected_bins, OutputWorkspace=xcut_name)] + elif export_type == "y": + expected_help_msg = f"Cut along Y created: {ycut_name}" + expected_bins = [100, 1, 1] if transpose else [1, 100, 1] + expected_calls = [call(**common_call_params, OutputBins=expected_bins, OutputWorkspace=ycut_name)] + elif export_type == "c": + expected_help_msg = f"Cuts along X/Y created: {xcut_name} & {ycut_name}" + expected_bins = [100, 1, 1] if transpose else [1, 100, 1] + expected_calls = [ + call(**common_call_params, OutputBins=expected_bins, OutputWorkspace=xcut_name), + call(**common_call_params, OutputBins=expected_bins, OutputWorkspace=ycut_name), + ] + + mock_binmd.assert_has_calls(expected_calls, any_order=True) + if export_type == "r": + if transpose: + mock_transposemd.assert_called_once() + else: + mock_transposemd.assert_not_called() + else: + if export_type == "x": + index = 1 if transpose else 0 + expected_calls = [call(InputWorkspace=xcut_name, OutputWorkspace=xcut_name, Axes=[index])] + elif export_type == "y": + index = 0 if transpose else 1 + expected_calls = [call(InputWorkspace=ycut_name, OutputWorkspace=ycut_name, Axes=[index])] + elif export_type == "c": + xindex = 1 if transpose else 0 + yindex = 0 if transpose else 1 + expected_calls = [ + call(InputWorkspace=xcut_name, OutputWorkspace=xcut_name, Axes=[xindex]), + call(InputWorkspace=ycut_name, OutputWorkspace=ycut_name, Axes=[yindex]), + ] + + mock_transposemd.assert_has_calls(expected_calls, any_order=True) + + self.assertEqual(expected_help_msg, help_msg) + mock_binmd.reset_mock() + mock_transposemd.reset_mock() + + for export_type in ("r", "x", "y", "c"): + assert_call_as_expected(transpose=False, dimension_indices=dimension_indices, export_type=export_type) + assert_call_as_expected(transpose=True, dimension_indices=transposed_dimension_indices, export_type=export_type) + + @patch("mantidqt.widgets.sliceviewer.models.model.BinMD") + @patch("mantidqt.widgets.sliceviewer.models.roi.ExtractSpectra") + def test_export_region_raises_exception_if_operation_failed(self, mock_extract_spectra, mock_binmd): + def assert_error_returned_in_help(workspace, export_type, mock_alg, err_msg): + model = SliceViewerModel(workspace) + slicepoint, bin_params, dimension_indices = (None, None, None), MagicMock(), [0, 1, None] + mock_alg.side_effect = RuntimeError(err_msg) + try: + if export_type == "r": + help_msg = model.export_roi_to_workspace(slicepoint, bin_params, ((1.0, 2.0), (-1, 2.0)), True, dimension_indices) + else: + help_msg = model.export_cuts_to_workspace( + slicepoint, bin_params, ((1.0, 2.0), (-1, 2.0)), True, dimension_indices, export_type + ) + except Exception as exc: + help_msg = str(exc) + mock_alg.reset_mock() + + self.assertTrue(err_msg in help_msg) + + for export_type in ("r", "c", "x", "y"): + assert_error_returned_in_help(self.ws2d_histo, export_type, mock_extract_spectra, "ExtractSpectra failed") + for export_type in ("r", "c", "x", "y"): + assert_error_returned_in_help(self.ws_MDE_3D, export_type, mock_binmd, "BinMD failed") + + @patch("mantidqt.widgets.sliceviewer.models.model.SliceViewerModel.get_proj_matrix") + def test_get_hkl_from_full_point_returns_zeros_for_a_none_transform(self, mock_get_proj_matrix): + qdims = [0, 1, 2] + point_3d = [1.0, 2.0, 3.0] + + model = SliceViewerModel(self.ws_MDE_3D) + mock_get_proj_matrix.return_value = None + + hkl = model.get_hkl_from_full_point(point_3d, qdims) + + self.assertEqual((0.0, 0.0, 0.0), hkl) + + @patch("mantidqt.widgets.sliceviewer.models.model.SliceViewerModel.get_proj_matrix") + def test_get_hkl_from_full_point_for_3D_point(self, mock_get_proj_matrix): + qdims = [0, 1, 2] + point_3d = [1.0, 2.0, 3.0] # [x, y, z] = [h, k, l] + + model = SliceViewerModel(self.ws_MDE_3D) + mock_get_proj_matrix.return_value = np.array([[0, 1, -1], [0, 1, 1], [1, 0, 0]]) + + hkl = model.get_hkl_from_full_point(point_3d, qdims) + + self.assertEqual([-1.0, 5.0, 1.0], list(hkl)) + + @patch("mantidqt.widgets.sliceviewer.models.model.SliceViewerModel.get_proj_matrix") + def test_get_hkl_from_full_point_for_4D_point(self, mock_get_proj_matrix): + qdims = [1, 2, 3] + point_4d = [1.0, 2.0, 3.0, 4.0] # [y, x, z, ...] = [e, h, k, l] + + model = SliceViewerModel(self.ws_MDE_4D) + mock_get_proj_matrix.return_value = np.array([[1, 0, 0], [0, 1, 0], [0, 0, 1]]) + + hkl = model.get_hkl_from_full_point(point_4d, qdims) + + self.assertEqual([2.0, 3.0, 4.0], list(hkl)) + + @patch("mantidqt.widgets.sliceviewer.models.model.SliceViewerModel.get_proj_matrix") + def test_get_hkl_from_full_point_for_4D_point_with_transformation(self, mock_get_proj_matrix): + qdims = [1, 2, 3] + point_4d = [1.0, 2.0, 3.0, 4.0] # [y, z, ..., x] = [e, h, k, l] + + model = SliceViewerModel(self.ws_MDE_4D) + mock_get_proj_matrix.return_value = np.array([[2, 0, 0], [0, -1, 0], [0, 0, 3]]) + + hkl = model.get_hkl_from_full_point(point_4d, qdims) + + self.assertEqual([4.0, -3.0, 12.0], list(hkl)) + + @patch("mantidqt.widgets.sliceviewer.models.model.SliceViewerModel.number_of_active_original_workspaces") + def test_check_for_removed_original_workspace(self, mock_num_original_workspaces): + self.ws_MDE_4D.hasOriginalWorkspace.side_effect = lambda index: True + mock_num_original_workspaces.return_value = 1 + + model = SliceViewerModel(self.ws_MDE_4D) + + self.assertEqual(model.num_original_workspaces_at_init, 1) + self.assertFalse(model.check_for_removed_original_workspace()) + # original workspace has been deleted + mock_num_original_workspaces.return_value = 0 + self.assertTrue(model.check_for_removed_original_workspace()) + + # private + def _assert_supports_non_orthogonal_axes(self, expectation, ws_type, coords, has_oriented_lattice): + model = SliceViewerModel(_create_mock_workspace(ws_type, coords, has_oriented_lattice)) + self.assertEqual(expectation, model.can_support_nonorthogonal_axes()) + + def _assert_supports_non_axis_aligned_cuts(self, expectation, ws_type, coords=SpecialCoordinateSystem.HKL, ndims=3): + model = SliceViewerModel(_create_mock_workspace(ws_type, coords, has_oriented_lattice=False, ndims=ndims)) + self.assertEqual(expectation, model.can_support_non_axis_cuts()) + + def _assert_supports_peaks_overlay(self, expectation, ws_type, ndims=2): + ws = _create_mock_workspace(ws_type, coords=SpecialCoordinateSystem.QLab, has_oriented_lattice=False, ndims=ndims) + model = SliceViewerModel(ws) + self.assertEqual(expectation, model.can_support_peaks_overlays()) + + +if __name__ == "__main__": + unittest.main()