def test_ensure_that_the_ads_observer_calls_replace_handle(self, _): presenter = PeaksViewerCollectionPresenter(MagicMock()) presenter.replace_handle = MagicMock() self.assertTrue(isinstance(presenter._ads_observer, SliceViewerADSObserver)) presenter._ads_observer = SliceViewerADSObserver(presenter.replace_handle, presenter.rename_handle, presenter.clear_handle, presenter.delete_handle) CreateSampleWorkspace(OutputWorkspace="ws") CreateSampleWorkspace(OutputWorkspace="ws") presenter.replace_handle.assert_called_once()
def test_notify_called_for_each_subpresenter(self, mock_create_model): presenter = PeaksViewerCollectionPresenter(MagicMock()) with patch( "mantidqt.widgets.sliceviewer.peaksviewer.presenter.PeaksViewerPresenter" ) as presenter_mock: presenter_mock.side_effect = [MagicMock(), MagicMock()] child_presenters = [ presenter.append_peaksworkspace(f'peaks_{i}') for i in range(2) ] presenter.notify(PeaksViewerPresenter.Event.OverlayPeaks) for child in child_presenters: child.notify.assert_called_once_with( PeaksViewerPresenter.Event.OverlayPeaks)
def test_append_constructs_PeaksViewerPresenter(self, mock_single_presenter): peaks_workspace = PeaksViewerModel(create_mock_peaks_workspace(), 'r', 'b') mock_collection_view = MagicMock() mock_peaks_view = MagicMock() mock_collection_view.append_peaksviewer.return_value = mock_peaks_view presenter = PeaksViewerCollectionPresenter(mock_collection_view) presenter.append_peaksworkspace(peaks_workspace) mock_single_presenter.assert_has_calls( [call(peaks_workspace, mock_peaks_view)]) self.assertEqual(1, presenter.view.append_peaksviewer.call_count)
def _create_collection_presenter(self, models, single_presenter_mock): view = MagicMock() # make each call to to mock_presenter return a new mock object with # the appropriate return_value for the model side_effects = [] for model in models: mock = MagicMock() mock.model.return_value = model side_effects.append(mock) single_presenter_mock.side_effect = side_effects presenter = PeaksViewerCollectionPresenter(view) child_presenters = [] for peaks_ws in models: child_presenters.append(presenter.append_peaksworkspace(peaks_ws)) return presenter, child_presenters
def test_remove_removes_named_workspace(self, mock_create_model): names = ["peaks1", 'peaks2', 'peaks3'] presenter = PeaksViewerCollectionPresenter(MagicMock()) for name in names: presenter.append_peaksworkspace(name) presenter.remove_peaksworkspace(names[1]) self.assertEqual([names[0], names[2]], presenter.workspace_names())
def test_ensure_delete_handle_removes_workspace(self, _): presenter = PeaksViewerCollectionPresenter(MagicMock()) presenter.remove_peaksworkspace = MagicMock() presenter.workspace_names = MagicMock(return_value=["ws"]) presenter.delete_handle("ws") presenter.remove_peaksworkspace.assert_called_once_with("ws")
def test_hides_view_when_last_named_workspace_removed( self, mock_create_model): names = ["peaks1"] presenter = PeaksViewerCollectionPresenter(MagicMock()) presenter.append_peaksworkspace(names[0]) presenter.remove_peaksworkspace(names[0]) presenter._actions_view.set_peaksworkspace.assert_called_with([]) presenter._view.hide.assert_called()
def test_ensure_rename_handle_removes_and_re_adds_the_new_workspace_name(self, _): presenter = PeaksViewerCollectionPresenter(MagicMock()) presenter.workspace_names = MagicMock(return_value=["ws"]) def remove_peaks(ws_name): presenter.workspace_names().remove(ws_name) return 7 presenter.remove_peaksworkspace = MagicMock(side_effect=remove_peaks) presenter.overlay_peaksworkspaces = MagicMock() presenter.rename_handle("ws", "ws1") presenter.remove_peaksworkspace.assert_called_once_with("ws") presenter.overlay_peaksworkspaces.assert_called_once_with(["ws1"], index=7)
def test_ensure_clear_handle_removes_all_workspaces(self, _): presenter = PeaksViewerCollectionPresenter(MagicMock()) presenter.remove_peaksworkspace = MagicMock() presenter.workspace_names = MagicMock(return_value=["ws", "ws1", "ws2"]) presenter.clear_handle() presenter.remove_peaksworkspace.assert_any_call("ws") presenter.remove_peaksworkspace.assert_any_call("ws1") presenter.remove_peaksworkspace.assert_any_call("ws2") self.assertEqual(3, presenter.remove_peaksworkspace.call_count)
def test_append_uses_unused_fg_color(self, mock_create_model): names = ["peaks1", 'peaks2'] # setup with 1 workspace presenter = PeaksViewerCollectionPresenter(MagicMock()) presenter.append_peaksworkspace(names[0]) mock_create_model.assert_called_once_with( names[0], PeaksViewerCollectionPresenter.FG_COLORS[0], PeaksViewerCollectionPresenter.DEFAULT_BG_COLOR) mock_create_model.reset_mock() presenter.append_peaksworkspace(names[1]) mock_create_model.assert_called_once_with( names[1], PeaksViewerCollectionPresenter.FG_COLORS[1], PeaksViewerCollectionPresenter.DEFAULT_BG_COLOR)
def test_adding_peak(self, _): view = MagicMock() presenter = PeaksViewerCollectionPresenter(view) # test deactivating peak adding mode from sliceviewer presenter.deactivate_peak_add_delete() view.peak_actions_view.deactivate_peak_adding.assert_called_once() # test adding peak to peaksworkspace presenter.child_presenter = MagicMock() presenter.add_delete_peak([1, 2, 3]) presenter.child_presenter().add_peak.assert_called_once_with([1, 2, 3]) # test deactivate_zoom_pan presenter.deactivate_zoom_pan(False) view.deactivate_zoom_pan.assert_not_called() presenter.deactivate_zoom_pan(True) view.deactivate_zoom_pan.assert_called_once()
def _create_peaks_presenter_if_necessary(self): if self._peaks_presenter is None: self._peaks_presenter = PeaksViewerCollectionPresenter(self.view.peaks_view) return self._peaks_presenter
class SliceViewer(ObservingPresenter, SliceViewerBasePresenter): TEMPORARY_STATUS_TIMEOUT = 2000 def __init__(self, ws, parent=None, window_flags=Qt.Window, model=None, view=None, conf=None): """ Create a presenter for controlling the slice display for a workspace :param ws: Workspace containing data to display and slice :param parent: An optional parent widget :param window_flags: An optional set of window flags :param model: A model to define slicing operations. If None uses SliceViewerModel :param view: A view to display the operations. If None uses SliceViewerView """ model: SliceViewerModel = model if model else SliceViewerModel(ws) self.view = view if view else SliceViewerView(self, Dimensions.get_dimensions_info(ws), model.can_normalize_workspace(), parent, window_flags, conf) super().__init__(ws, self.view.data_view, model) self._logger = Logger("SliceViewer") self._peaks_presenter: PeaksViewerCollectionPresenter = None self._cutviewer_presenter = None self.conf = conf # Acts as a 'time capsule' to the properties of the model at this # point in the execution. By the time the ADS observer calls self.replace_workspace, # the workspace associated with self.model has already been changed. self.initial_model_properties = model.get_properties() self._new_plot_method, self.update_plot_data = self._decide_plot_update_methods() self.view.setWindowTitle(self.model.get_title()) self.view.data_view.create_axes_orthogonal( redraw_on_zoom=not WorkspaceInfo.can_support_dynamic_rebinning(self.model.ws)) if self.model.can_normalize_workspace(): self.view.data_view.set_normalization(ws) self.view.data_view.norm_opts.currentTextChanged.connect(self.normalization_changed) if not self.model.can_support_peaks_overlays(): self.view.data_view.disable_tool_button(ToolItemText.OVERLAY_PEAKS) # check whether to enable non-orthog view # don't know whether can always assume init with display indices (0,1) - so get sliceinfo sliceinfo = self.get_sliceinfo() if not sliceinfo.can_support_nonorthogonal_axes(): self.view.data_view.disable_tool_button(ToolItemText.NONORTHOGONAL_AXES) if not self.model.can_support_non_axis_cuts(): self.view.data_view.disable_tool_button(ToolItemText.NONAXISALIGNEDCUTS) self.view.data_view.help_button.clicked.connect(self.action_open_help_window) self.refresh_view() # Start the GUI with zoom selected. self.view.data_view.activate_tool(ToolItemText.ZOOM) self.ads_observer = SliceViewerADSObserver(self.replace_workspace, self.rename_workspace, self.ADS_cleared, self.delete_workspace) # simulate clicking on the home button, which will force all signal and slot connections # properly set. # NOTE: Some part of the connections are not set in the correct, resulting in a strange behavior # where the colorbar and view is not updated with switch between different scales. # This is a ducktape fix and should be revisited once we have a better way to do this. # NOTE: This workaround solve the problem, but it leads to a failure in # projectroot.qt.python.mantidqt_qt5.test_sliceviewer_presenter.test_sliceviewer_presenter # Given that this issue is not of high priority, we are leaving it as is for now. # self.show_all_data_clicked() def new_plot(self, *args, **kwargs): self._new_plot_method(*args, **kwargs) def new_plot_MDH(self, dimensions_transposing=False, dimensions_changing=False): """ Tell the view to display a new plot of an MDHistoWorkspace """ data_view = self.view.data_view limits = data_view.get_axes_limits() if limits is None or not WorkspaceInfo.can_support_dynamic_rebinning(self.model.ws): data_view.plot_MDH(self.model.get_ws(), slicepoint=self.get_slicepoint()) self._call_peaks_presenter_if_created("notify", PeaksViewerPresenter.Event.OverlayPeaks) else: self.new_plot_MDE(dimensions_transposing, dimensions_changing) def new_plot_MDE(self, dimensions_transposing=False, dimensions_changing=False): """ Tell the view to display a new plot of an MDEventWorkspace """ data_view = self.view.data_view limits = data_view.get_axes_limits() # The value at the i'th index of this tells us that the axis with that value (0 or 1) will display dimension i dimension_indices = self.view.dimensions.get_states() if dimensions_transposing: # Since the dimensions are transposing, the limits we have from the view are the wrong way around # with respect to the axes the dimensions are about to be displayed, so get the previous dimension states. dimension_indices = self.view.dimensions.get_previous_states() elif dimensions_changing: # If we are changing which dimensions are to be displayed, the limits we got from the view are stale # as they refer to the previous two dimensions that were displayed. limits = None data_view.plot_MDH( self.model.get_ws_MDE(slicepoint=self.get_slicepoint(), bin_params=data_view.dimensions.get_bin_params(), limits=limits, dimension_indices=dimension_indices)) self._call_peaks_presenter_if_created("notify", PeaksViewerPresenter.Event.OverlayPeaks) def update_plot_data_MDH(self): """ Update the view to display an updated MDHistoWorkspace slice/cut """ self.view.data_view.update_plot_data( self.model.get_data(self.get_slicepoint(), transpose=self.view.data_view.dimensions.transpose)) def update_plot_data_MDE(self): """ Update the view to display an updated MDEventWorkspace slice/cut """ data_view = self.view.data_view data_view.update_plot_data( self.model.get_data(self.get_slicepoint(), bin_params=data_view.dimensions.get_bin_params(), dimension_indices=data_view.dimensions.get_states(), limits=data_view.get_axes_limits(), transpose=self.view.data_view.dimensions.transpose)) def update_plot_data_matrix(self): # should never be called, since this workspace type is only 2D the plot dimensions never change pass def get_frame(self) -> SpecialCoordinateSystem: """Returns frame of workspace - require access for adding a peak in peaksviewer""" return self.model.get_frame() def get_sliceinfo(self, force_nonortho_mode: bool = False): """ :param force_nonortho_mode: if True then don't use orthogonal angles even if non_ortho mode == False - this is necessary because when non-ortho view is toggled the data_view is not updated at the point a new SliceInfo is created :return: a SliceInfo object describing the current slice and transform (which by default will be orthogonal if non-ortho mode is False) """ dimensions = self.view.data_view.dimensions non_ortho_mode = True if force_nonortho_mode else self.view.data_view.nonorthogonal_mode axes_angles = self.model.get_axes_angles(force_orthogonal=not non_ortho_mode) # None if can't support transform return SliceInfo(point=dimensions.get_slicepoint(), transpose=dimensions.transpose, range=dimensions.get_slicerange(), qflags=dimensions.qflags, axes_angles=axes_angles) def get_proj_matrix(self): return self.model.get_proj_matrix() def get_axes_limits(self): return self.view.data_view.get_axes_limits() def dimensions_changed(self): """Indicates that the dimensions have changed""" data_view = self._data_view sliceinfo = self.get_sliceinfo() if data_view.nonorthogonal_mode: if sliceinfo.can_support_nonorthogonal_axes(): # axes need to be recreated to have the correct transform associated data_view.create_axes_nonorthogonal(sliceinfo.get_northogonal_transform()) else: data_view.disable_tool_button(ToolItemText.NONORTHOGONAL_AXES) data_view.create_axes_orthogonal() else: if sliceinfo.can_support_nonorthogonal_axes(): data_view.enable_tool_button(ToolItemText.NONORTHOGONAL_AXES) else: data_view.disable_tool_button(ToolItemText.NONORTHOGONAL_AXES) ws_type = WorkspaceInfo.get_ws_type(self.model.ws) if ws_type == WS_TYPE.MDH or ws_type == WS_TYPE.MDE: if self.model.get_number_dimensions() > 2 and \ sliceinfo.slicepoint[data_view.dimensions.get_previous_states().index(None)] is None: # The dimension of the slicepoint has changed self.new_plot(dimensions_changing=True) else: self.new_plot(dimensions_transposing=True) else: self.new_plot() self._call_cutviewer_presenter_if_created("on_dimension_changed") def slicepoint_changed(self): """Indicates the slicepoint has been updated""" self._call_peaks_presenter_if_created("notify", PeaksViewerPresenter.Event.SlicePointChanged) self._call_cutviewer_presenter_if_created("on_slicepoint_changed") self.update_plot_data() def export_roi(self, limits): """Notify that an roi has been selected for export to a workspace :param limits: 2-tuple of ((left, right), (bottom, top)). These are in display order """ data_view = self.view.data_view try: self._show_status_message( self.model.export_roi_to_workspace(self.get_slicepoint(), bin_params=data_view.dimensions.get_bin_params(), limits=limits, transpose=data_view.dimensions.transpose, dimension_indices=data_view.dimensions.get_states())) except Exception as exc: self._logger.error(str(exc)) self._show_status_message("Error exporting ROI") def export_cut(self, limits, cut_type): """Notify that an roi has been selected for export to a workspace :param limits: 2-tuple of ((left, right), (bottom, top)). These are in display order and could be transposed w.r.t to the data :param cut: A string indicating the required cut type """ data_view = self.view.data_view try: self._show_status_message( self.model.export_cuts_to_workspace( self.get_slicepoint(), bin_params=data_view.dimensions.get_bin_params(), limits=limits, transpose=data_view.dimensions.transpose, dimension_indices=data_view.dimensions.get_states(), cut=cut_type)) except Exception as exc: self._logger.error(str(exc)) self._show_status_message("Error exporting roi cut") def export_pixel_cut(self, pos, axis): """Notify a single pixel line plot has been requested from the given position in data coordinates. :param pos: Position on the image :param axis: String indicating the axis the position relates to: 'x' or 'y' """ data_view = self.view.data_view try: self._show_status_message( self.model.export_pixel_cut_to_workspace( self.get_slicepoint(), bin_params=data_view.dimensions.get_bin_params(), pos=pos, transpose=data_view.dimensions.transpose, axis=axis)) except Exception as exc: self._logger.error(str(exc)) self._show_status_message("Error exporting single-pixel cut") def perform_non_axis_aligned_cut(self, vectors, extents, nbins): try: wscut_name = self.model.perform_non_axis_aligned_cut_to_workspace(vectors, extents, nbins) self._call_cutviewer_presenter_if_created('on_cut_done', wscut_name) except Exception as exc: self._logger.error(str(exc)) self._show_status_message("Error exporting single-pixel cut") def nonorthogonal_axes(self, state: bool): """ Toggle non-orthogonal axes on current view :param state: If true a request is being made to turn them on, else they should be turned off """ data_view = self.view.data_view if state: data_view.deactivate_and_disable_tool(ToolItemText.REGIONSELECTION) data_view.disable_tool_button(ToolItemText.NONAXISALIGNEDCUTS) data_view.disable_tool_button(ToolItemText.LINEPLOTS) # set transform from sliceinfo but ignore view as non-ortho state not set yet data_view.create_axes_nonorthogonal(self.get_sliceinfo(force_nonortho_mode=True).get_northogonal_transform()) self.show_all_data_clicked() else: data_view.create_axes_orthogonal() data_view.enable_tool_button(ToolItemText.LINEPLOTS) data_view.enable_tool_button(ToolItemText.REGIONSELECTION) data_view.enable_tool_button(ToolItemText.NONAXISALIGNEDCUTS) self.new_plot() def normalization_changed(self, norm_type): """ Notify the presenter that the type of normalization has changed. :param norm_type: "By bin width" = volume normalization else no normalization """ self.normalization = norm_type == "By bin width" self.new_plot() def overlay_peaks_workspaces(self): """ Request activation of peak overlay tools. - Asks user to select peaks workspace(s), taking into account any current selection - Attaches peaks table viewer/tools if new workspaces requested. Removes any unselected - Displays peaks on data display (if any left to display) """ names_overlayed = self._overlayed_peaks_workspaces() names_to_overlay = self.view.query_peaks_to_overlay(names_overlayed) if names_to_overlay is None: # cancelled return if names_to_overlay or names_overlayed: self._create_peaks_presenter_if_necessary().overlay_peaksworkspaces(names_to_overlay) else: self.view.peaks_view.hide() def non_axis_aligned_cut(self, state): data_view = self._data_view if state: if self._cutviewer_presenter is None: self._cutviewer_presenter = CutViewerPresenter(self, data_view.canvas) self.view.add_widget_to_splitter(self._cutviewer_presenter.get_view()) self._cutviewer_presenter.show_view() data_view.deactivate_tool(ToolItemText.ZOOM) for tool in [ToolItemText.REGIONSELECTION, ToolItemText.LINEPLOTS, ToolItemText.NONORTHOGONAL_AXES]: data_view.deactivate_and_disable_tool(tool) # turn off cursor tracking as this causes plot to resize interfering with interactive cutting tool data_view.track_cursor.setChecked(False) # on_track_cursor_state_change(False) else: self._cutviewer_presenter.hide_view() for tool in [ToolItemText.REGIONSELECTION, ToolItemText.LINEPLOTS]: data_view.enable_tool_button(tool) if self.get_sliceinfo().can_support_nonorthogonal_axes(): data_view.enable_tool_button(ToolItemText.NONORTHOGONAL_AXES) def replace_workspace(self, workspace_name, workspace): """ Called when the SliceViewerADSObserver has detected that a workspace has changed @param workspace_name: the name of the workspace that has changed @param workspace: the workspace that has changed """ if not self.model.workspace_equals(workspace_name): # TODO this is a dead branch, since the ADS observer will call this if the # names are the same, but the model "workspace_equals" simply checks for the same name return try: candidate_model = SliceViewerModel(workspace) candidate_model_properties = candidate_model.get_properties() for (property, value) in candidate_model_properties.items(): if self.initial_model_properties[property] != value: raise ValueError(f"The property {property} is different on the new workspace.") # New model is OK, proceed with updating Slice Viewer self.model = candidate_model self.new_plot, self.update_plot_data = self._decide_plot_update_methods() self.refresh_view() except ValueError as err: self._close_view_with_message( f"Closing Sliceviewer as the underlying workspace was changed: {str(err)}") return def refresh_view(self): """ Updates the view to enable/disable certain options depending on the model. """ if not self.view: return # we don't want to use model.get_ws for the image info widget as this needs # extra arguments depending on workspace type. ws = self.model.ws ws.readLock() try: self.view.data_view.image_info_widget.setWorkspace(ws) self.new_plot() finally: ws.unlock() def rename_workspace(self, old_name, new_name): if self.model.workspace_equals(old_name): self.model.set_ws_name(new_name) self.view.emit_rename(self.model.get_title(new_name)) def delete_workspace(self, ws_name): if self.model.workspace_equals(ws_name): self.view.emit_close() def ADS_cleared(self): if self.view: self.view.emit_close() def clear_observer(self): """Called by ObservingView on close event""" self.ads_observer = None if self._peaks_presenter is not None: self._peaks_presenter.clear_observer() def canvas_clicked(self, event): if self._peaks_presenter is not None: if event.inaxes: sliceinfo = self.get_sliceinfo() self._logger.debug(f"Coordinates selected x={event.xdata} y={event.ydata} z={sliceinfo.z_value}") pos = sliceinfo.inverse_transform([event.xdata, event.ydata, sliceinfo.z_value]) self._logger.debug(f"Coordinates transformed into {self.get_frame()} frame, pos={pos}") self._peaks_presenter.add_delete_peak(pos) self.view.data_view.canvas.draw_idle() def deactivate_zoom_pan(self): self.view.data_view.deactivate_zoom_pan() def zoom_pan_clicked(self, active): if active and self._peaks_presenter is not None: self._peaks_presenter.deactivate_peak_add_delete() # private api def _create_peaks_presenter_if_necessary(self): if self._peaks_presenter is None: self._peaks_presenter = PeaksViewerCollectionPresenter(self.view.peaks_view) return self._peaks_presenter def _call_peaks_presenter_if_created(self, attr, *args, **kwargs): """ Call a method on the peaks presenter if it has been created :param attr: The attribute to call :param *args: Positional-arguments to pass to call :param **kwargs Keyword-arguments to pass to call """ if self._peaks_presenter is not None: getattr(self._peaks_presenter, attr)(*args, **kwargs) def _call_cutviewer_presenter_if_created(self, attr, *args, **kwargs): """ Call a method on the peaks presenter if it has been created :param attr: The attribute to call :param *args: Positional-arguments to pass to call :param **kwargs Keyword-arguments to pass to call """ if self._cutviewer_presenter is not None: getattr(self._cutviewer_presenter, attr)(*args, **kwargs) def _show_status_message(self, message: str): """ Show a temporary message in the status of the view """ self.view.data_view.show_temporary_status_message(message, self.TEMPORARY_STATUS_TIMEOUT) def _overlayed_peaks_workspaces(self): """ :return: A list of names of the current PeaksWorkspaces overlayed """ current_workspaces = [] if self._peaks_presenter is not None: current_workspaces = self._peaks_presenter.workspace_names() return current_workspaces def _decide_plot_update_methods(self) -> Tuple[Callable, Callable]: """ Checks the type of workspace in self.model and decides which of the new_plot and update_plot_data methods to use :return: the new_plot method to use """ # TODO get rid of private access here ws_type = WorkspaceInfo.get_ws_type(self.model.ws) if ws_type == WS_TYPE.MDH: return self.new_plot_MDH, self.update_plot_data_MDH elif ws_type == WS_TYPE.MDE: return self.new_plot_MDE, self.update_plot_data_MDE else: return self.new_plot_matrix, self.update_plot_data_matrix def _close_view_with_message(self, message: str): self.view.emit_close() # inherited from ObservingView self._logger.warning(message) def notify_close(self): self.view = None def action_open_help_window(self): InterfaceManager().showHelpPage('qthelp://org.mantidproject/doc/workbench/sliceviewer.html')