Esempio n. 1
0
    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")
Esempio n. 2
0
    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())
Esempio n. 3
0
    def test_append_constructs_PeaksViewerPresenter_for_call(self, mock_create_model):
        # mock 2 models with the correct names and assume  create_peaksviewermodel makes them
        names = ["peaks1", 'peaks2']
        presenter = PeaksViewerCollectionPresenter(MagicMock())

        for name in names:
            presenter.append_peaksworkspace(name)

        self.assertEqual(names, presenter.workspace_names())
Esempio n. 4
0
    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)
Esempio n. 5
0
    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)
Esempio n. 6
0
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')