source: sasview/src/sas/qtgui/Plotting/Plotter2D.py @ 63467b6

ESS_GUIESS_GUI_batch_fittingESS_GUI_bumps_abstractionESS_GUI_iss1116ESS_GUI_openclESS_GUI_orderingESS_GUI_sync_sascalc
Last change on this file since 63467b6 was 63467b6, checked in by Piotr Rozyczko <piotr.rozyczko@…>, 6 years ago

Improved handling of 2d plot children. Refactored model tree search.

  • Property mode set to 100644
File size: 19.0 KB
Line 
1import copy
2import numpy
3import pylab
4import functools
5import logging
6
7from PyQt5 import QtCore
8from PyQt5 import QtGui
9from PyQt5 import QtWidgets
10
11DEFAULT_CMAP = pylab.cm.jet
12
13#import sys
14#print("SYS.PATH = ", sys.path)
15import matplotlib as mpl
16mpl.use("Qt5Agg")
17
18from mpl_toolkits.mplot3d import Axes3D
19
20from sas.sascalc.dataloader.manipulations import CircularAverage
21
22from sas.qtgui.Plotting.PlotterData import Data1D
23from sas.qtgui.Plotting.PlotterData import Data2D
24
25import sas.qtgui.Plotting.PlotUtilities as PlotUtilities
26import sas.qtgui.Utilities.GuiUtils as GuiUtils
27from sas.qtgui.Plotting.PlotterBase import PlotterBase
28from sas.qtgui.Plotting.ColorMap import ColorMap
29from sas.qtgui.Plotting.BoxSum import BoxSum
30from sas.qtgui.Plotting.SlicerParameters import SlicerParameters
31from sas.qtgui.Plotting.Binder import BindArtist
32
33# TODO: move to sas.qtgui namespace
34from sas.qtgui.Plotting.Slicers.BoxSlicer import BoxInteractorX
35from sas.qtgui.Plotting.Slicers.BoxSlicer import BoxInteractorY
36from sas.qtgui.Plotting.Slicers.AnnulusSlicer import AnnulusInteractor
37from sas.qtgui.Plotting.Slicers.SectorSlicer import SectorInteractor
38from sas.qtgui.Plotting.Slicers.BoxSum import BoxSumCalculator
39
40# Minimum value of Z for which we will present data.
41MIN_Z = -32
42
43class Plotter2DWidget(PlotterBase):
44    """
45    2D Plot widget for use with a QDialog
46    """
47    def __init__(self, parent=None, manager=None, quickplot=False, dimension=2):
48        self.dimension = dimension
49        super(Plotter2DWidget, self).__init__(parent, manager=manager, quickplot=quickplot)
50
51        self.cmap = DEFAULT_CMAP.name
52        # Default scale
53        self.scale = 'log_{10}'
54        # to set the order of lines drawn first.
55        self.slicer_z = 5
56        # Reference to the current slicer
57        self.slicer = None
58        self.slicer_widget = None
59        # Create Artist and bind it
60        self.connect = BindArtist(self.figure)
61        self.vmin = None
62        self.vmax = None
63        self.im = None
64
65        self.manager = manager
66
67    @property
68    def data(self):
69        return self._data
70
71    @data.setter
72    def data(self, data=None):
73        """ data setter """
74        self._data = data
75        self.qx_data = data.qx_data
76        self.qy_data = data.qy_data
77        self.xmin = data.xmin
78        self.xmax = data.xmax
79        self.ymin = data.ymin
80        self.ymax = data.ymax
81        self.zmin = data.zmin
82        self.zmax = data.zmax
83        self.label = data.name
84        self.xLabel = "%s(%s)"%(data._xaxis, data._xunit)
85        self.yLabel = "%s(%s)"%(data._yaxis, data._yunit)
86        self.title(title=data.title)
87
88    def plot(self, data=None, marker=None, show_colorbar=True, update=False):
89        """
90        Plot 2D self._data
91        marker - unused
92        """
93        # Assing data
94        if isinstance(data, Data2D):
95            self.data = data
96
97        if not self._data:
98            return
99
100        # Toggle the scale
101        zmin_2D_temp, zmax_2D_temp = self.calculateDepth()
102
103        # Prepare and show the plot
104        self.showPlot(data=self.data.data,
105                      qx_data=self.qx_data,
106                      qy_data=self.qy_data,
107                      xmin=self.xmin,
108                      xmax=self.xmax,
109                      ymin=self.ymin, ymax=self.ymax,
110                      cmap=self.cmap, zmin=zmin_2D_temp,
111                      zmax=zmax_2D_temp, show_colorbar=show_colorbar,
112                      update=update)
113
114    def calculateDepth(self):
115        """
116        Re-calculate the plot depth parameters depending on the scale
117        """
118        # Toggle the scale
119        zmin_temp = self.zmin if self.zmin else MIN_Z
120        zmax_temp = self.zmax
121        # self.scale predefined in the baseclass
122        # in numpy > 1.12 power(int, -int) raises ValueException
123        # "Integers to negative integer powers are not allowed."
124        if self.scale == 'log_{10}':
125            if self.zmin is not None:
126                zmin_temp = numpy.power(10.0, self.zmin)
127            if self.zmax is not None:
128                zmax_temp = numpy.power(10.0, self.zmax)
129        else:
130            if self.zmin is not None:
131                # min log value: no log(negative)
132                zmin_temp = MIN_Z if self.zmin <= 0 else numpy.log10(self.zmin)
133            if self.zmax is not None:
134                zmax_temp = numpy.log10(self.zmax)
135
136        return (zmin_temp, zmax_temp)
137
138
139    def createContextMenu(self):
140        """
141        Define common context menu and associated actions for the MPL widget
142        """
143        self.defaultContextMenu()
144
145        self.contextMenu.addSeparator()
146        self.actionDataInfo = self.contextMenu.addAction("&DataInfo")
147        self.actionDataInfo.triggered.connect(
148             functools.partial(self.onDataInfo, self.data))
149
150        self.actionSavePointsAsFile = self.contextMenu.addAction("&Save Points as a File")
151        self.actionSavePointsAsFile.triggered.connect(
152             functools.partial(self.onSavePoints, self.data))
153        self.contextMenu.addSeparator()
154
155        self.actionCircularAverage = self.contextMenu.addAction("&Perform Circular Average")
156        self.actionCircularAverage.triggered.connect(self.onCircularAverage)
157
158        self.actionSectorView = self.contextMenu.addAction("&Sector [Q View]")
159        self.actionSectorView.triggered.connect(self.onSectorView)
160        self.actionAnnulusView = self.contextMenu.addAction("&Annulus [Phi View]")
161        self.actionAnnulusView.triggered.connect(self.onAnnulusView)
162        self.actionBoxSum = self.contextMenu.addAction("&Box Sum")
163        self.actionBoxSum.triggered.connect(self.onBoxSum)
164        self.actionBoxAveragingX = self.contextMenu.addAction("&Box Averaging in Qx")
165        self.actionBoxAveragingX.triggered.connect(self.onBoxAveragingX)
166        self.actionBoxAveragingY = self.contextMenu.addAction("&Box Averaging in Qy")
167        self.actionBoxAveragingY.triggered.connect(self.onBoxAveragingY)
168        # Additional items for slicer interaction
169        if self.slicer:
170            self.actionClearSlicer = self.contextMenu.addAction("&Clear Slicer")
171            self.actionClearSlicer.triggered.connect(self.onClearSlicer)
172            if self.slicer.__class__.__name__ != "BoxSumCalculator":
173                self.actionEditSlicer = self.contextMenu.addAction("&Edit Slicer Parameters")
174                self.actionEditSlicer.triggered.connect(self.onEditSlicer)
175        self.contextMenu.addSeparator()
176        self.actionColorMap = self.contextMenu.addAction("&2D Color Map")
177        self.actionColorMap.triggered.connect(self.onColorMap)
178        self.contextMenu.addSeparator()
179        self.actionChangeScale = self.contextMenu.addAction("Toggle Linear/Log Scale")
180        self.actionChangeScale.triggered.connect(self.onToggleScale)
181
182    def createContextMenuQuick(self):
183        """
184        Define context menu and associated actions for the quickplot MPL widget
185        """
186        self.defaultContextMenu()
187
188        if self.dimension == 2:
189            self.actionToggleGrid = self.contextMenu.addAction("Toggle Grid On/Off")
190            self.contextMenu.addSeparator()
191        self.actionChangeScale = self.contextMenu.addAction("Toggle Linear/Log Scale")
192
193        # Define the callbacks
194        self.actionChangeScale.triggered.connect(self.onToggleScale)
195        if self.dimension == 2:
196            self.actionToggleGrid.triggered.connect(self.onGridToggle)
197
198    def onToggleScale(self, event):
199        """
200        Toggle axis and replot image
201        """
202        # self.scale predefined in the baseclass
203        if self.scale == 'log_{10}':
204            self.scale = 'linear'
205        else:
206            self.scale = 'log_{10}'
207
208        self.plot()
209
210    def onClearSlicer(self):
211        """
212        Remove all sclicers from the chart
213        """
214        if self.slicer is None:
215            return
216
217        self.slicer.clear()
218        self.canvas.draw()
219        self.slicer = None
220
221    def onEditSlicer(self):
222        """
223        Present a small dialog for manipulating the current slicer
224        """
225        assert self.slicer
226        # Only show the dialog if not currently shown
227        if self.slicer_widget:
228            return
229        def slicer_closed():
230            # Need to disconnect the signal!!
231            self.slicer_widget.close_signal.disconnect()
232            # reset slicer_widget on "Edit Slicer Parameters" window close
233            self.slicer_widget = None
234
235        self.param_model = self.slicer.model()
236        # Pass the model to the Slicer Parameters widget
237        self.slicer_widget = SlicerParameters(model=self.param_model,
238                                              validate_method=self.slicer.validate)
239        self.slicer_widget.close_signal.connect(slicer_closed)
240        # Add the plot to the workspace
241        self.manager.parent.workspace().addSubWindow(self.slicer_widget)
242
243        self.slicer_widget.show()
244
245    def onCircularAverage(self):
246        """
247        Perform circular averaging on Data2D
248        """
249        # Find the best number of bins
250        npt = numpy.sqrt(len(self.data.data[numpy.isfinite(self.data.data)]))
251        npt = numpy.floor(npt)
252        # compute the maximum radius of data2D
253        self.qmax = max(numpy.fabs(self.data.xmax),
254                        numpy.fabs(self.data.xmin))
255        self.ymax = max(numpy.fabs(self.data.ymax),
256                        numpy.fabs(self.data.ymin))
257        self.radius = numpy.sqrt(numpy.power(self.qmax, 2) + numpy.power(self.ymax, 2))
258        #Compute beam width
259        bin_width = (self.qmax + self.qmax) / npt
260        # Create data1D circular average of data2D
261        circle = CircularAverage(r_min=0, r_max=self.radius, bin_width=bin_width)
262        circ = circle(self.data)
263        dxl = circ.dxl if hasattr(circ, "dxl") else None
264        dxw = circ.dxw if hasattr(circ, "dxw") else None
265
266        new_plot = Data1D(x=circ.x, y=circ.y, dy=circ.dy, dx=circ.dx)
267        new_plot.dxl = dxl
268        new_plot.dxw = dxw
269        new_plot.name = new_plot.title = "Circ avg " + self.data.name
270        new_plot.source = self.data.source
271        new_plot.interactive = True
272        new_plot.detector = self.data.detector
273
274        # Define axes if not done yet.
275        new_plot.xaxis("\\rm{Q}", "A^{-1}")
276        if hasattr(self.data, "scale") and \
277                    self.data.scale == 'linear':
278            new_plot.ytransform = 'y'
279            new_plot.yaxis("\\rm{Residuals} ", "normalized")
280        else:
281            new_plot.yaxis("\\rm{Intensity} ", "cm^{-1}")
282
283        new_plot.group_id = "2daverage" + self.data.name
284        new_plot.id = "Circ avg " + self.data.name
285        new_plot.is_data = True
286        if self._item.parent() is not None:
287            item = self._item.parent()
288        GuiUtils.updateModelItemWithPlot(item, new_plot, new_plot.id)
289
290        self.manager.communicator.plotUpdateSignal.emit([new_plot])
291
292    def setSlicer(self, slicer):
293        """
294        Clear the previous slicer and create a new one.
295        slicer: slicer class to create
296        """
297        # Clear current slicer
298        if self.slicer is not None:
299            self.slicer.clear()
300        # Create a new slicer
301        self.slicer_z += 1
302        self.slicer = slicer(self, self.ax, item=self._item, zorder=self.slicer_z)
303        self.ax.set_ylim(self.data.ymin, self.data.ymax)
304        self.ax.set_xlim(self.data.xmin, self.data.xmax)
305        # Draw slicer
306        self.figure.canvas.draw()
307        self.slicer.update()
308
309        # Reset the model on the Edit slicer parameters widget
310        self.param_model = self.slicer.model()
311        if self.slicer_widget:
312            self.slicer_widget.setModel(self.param_model)
313
314    def onSectorView(self):
315        """
316        Perform sector averaging on Q and draw sector slicer
317        """
318        self.setSlicer(slicer=SectorInteractor)
319
320    def onAnnulusView(self):
321        """
322        Perform sector averaging on Phi and draw annulus slicer
323        """
324        self.setSlicer(slicer=AnnulusInteractor)
325
326    def onBoxSum(self):
327        """
328        Perform 2D Data averaging Qx and Qy.
329        Display box slicer details.
330        """
331        self.onClearSlicer()
332        self.slicer_z += 1
333        self.slicer = BoxSumCalculator(self, self.ax, zorder=self.slicer_z)
334
335        self.ax.set_ylim(self.data.ymin, self.data.ymax)
336        self.ax.set_xlim(self.data.xmin, self.data.xmax)
337        self.figure.canvas.draw()
338        self.slicer.update()
339
340        def boxWidgetClosed():
341            # Need to disconnect the signal!!
342            self.boxwidget.closeWidgetSignal.disconnect()
343            # reset box on "Edit Slicer Parameters" window close
344            self.manager.parent.workspace().removeSubWindow(self.boxwidget_subwindow)
345            self.boxwidget = None
346
347        # Get the BoxSumCalculator model.
348        self.box_sum_model = self.slicer.model()
349        # Pass the BoxSumCalculator model to the BoxSum widget
350        self.boxwidget = BoxSum(self, model=self.box_sum_model)
351        # Add the plot to the workspace
352        self.boxwidget_subwindow = self.manager.parent.workspace().addSubWindow(self.boxwidget)
353        self.boxwidget.closeWidgetSignal.connect(boxWidgetClosed)
354
355        self.boxwidget.show()
356
357    def onBoxAveragingX(self):
358        """
359        Perform 2D data averaging on Qx
360        Create a new slicer.
361        """
362        self.setSlicer(slicer=BoxInteractorX)
363
364    def onBoxAveragingY(self):
365        """
366        Perform 2D data averaging on Qy
367        Create a new slicer .
368        """
369        self.setSlicer(slicer=BoxInteractorY)
370
371    def onColorMap(self):
372        """
373        Display the color map dialog and modify the plot's map accordingly
374        """
375        color_map_dialog = ColorMap(self, cmap=self.cmap,
376                                    vmin=self.vmin,
377                                    vmax=self.vmax,
378                                    data=self.data)
379
380        color_map_dialog.apply_signal.connect(self.onApplyMap)
381
382        if color_map_dialog.exec_() == QtWidgets.QDialog.Accepted:
383            self.onApplyMap(color_map_dialog.norm(), color_map_dialog.cmap())
384
385    def onApplyMap(self, v_values, cmap):
386        """
387        Update the chart color map based on data passed from the widget
388        """
389        self.cmap = str(cmap)
390        self.vmin, self.vmax = v_values
391        # Redraw the chart with new cmap
392        self.plot()
393
394    def showPlot(self, data, qx_data, qy_data, xmin, xmax, ymin, ymax,
395                 zmin, zmax, label='data2D', cmap=DEFAULT_CMAP, show_colorbar=True,
396                 update=False):
397        """
398        Render and show the current data
399        """
400        self.qx_data = qx_data
401        self.qy_data = qy_data
402        self.xmin = xmin
403        self.xmax = xmax
404        self.ymin = ymin
405        self.ymax = ymax
406        self.zmin = zmin
407        self.zmax = zmax
408        # If we don't have any data, skip.
409        if data is None:
410            return
411        if data.ndim == 0:
412            return
413        elif data.ndim == 1:
414            output = PlotUtilities.build_matrix(data, self.qx_data, self.qy_data)
415        else:
416            output = copy.deepcopy(data)
417
418        # get the x and y_bin arrays.
419        x_bins, y_bins = PlotUtilities.get_bins(self.qx_data, self.qy_data)
420        self._data.x_bins = x_bins
421        self._data.y_bins = y_bins
422
423        zmin_temp = self.zmin
424        # check scale
425        if self.scale == 'log_{10}':
426            try:
427                if  self.zmin <= 0  and len(output[output > 0]) > 0:
428                    zmin_temp = self.zmin
429                    output[output > 0] = numpy.log10(output[output > 0])
430                elif self.zmin <= 0:
431                    zmin_temp = self.zmin
432                    output[output > 0] = numpy.zeros(len(output))
433                    output[output <= 0] = MIN_Z
434                else:
435                    zmin_temp = self.zmin
436                    output[output > 0] = numpy.log10(output[output > 0])
437            except:
438                #Too many problems in 2D plot with scale
439                output[output > 0] = numpy.log10(output[output > 0])
440                pass
441
442        self.cmap = cmap
443        if self.dimension != 3:
444            #Re-adjust colorbar
445            self.figure.subplots_adjust(left=0.2, right=.8, bottom=.2)
446
447            zmax_temp = self.zmax
448            if self.vmin is not None:
449                zmin_temp = self.vmin
450                zmax_temp = self.vmax
451            if self.im is not None and update:
452                self.im.set_data(output)
453            else:
454                self.im = self.ax.imshow(output, interpolation='nearest',
455                                vmin=zmin_temp, vmax=zmax_temp,
456                                cmap=self.cmap,
457                                extent=(self.xmin, self.xmax,
458                                        self.ymin, self.ymax))
459
460            cbax = self.figure.add_axes([0.88, 0.2, 0.02, 0.7])
461
462            # Current labels for axes
463            self.ax.set_ylabel(self.y_label)
464            self.ax.set_xlabel(self.x_label)
465
466            # Title only for regular charts
467            if not self.quickplot:
468                self.ax.set_title(label=self._title)
469
470            if cbax is None:
471                ax.set_frame_on(False)
472                cb = self.figure.colorbar(self.im, shrink=0.8, aspect=20)
473            else:
474                cb = self.figure.colorbar(self.im, cax=cbax)
475
476            cb.update_bruteforce(self.im)
477            cb.set_label('$' + self.scale + '$')
478
479            self.vmin = cb.vmin
480            self.vmax = cb.vmax
481
482            if show_colorbar is False:
483                cb.remove()
484
485        else:
486            # clear the previous 2D from memory
487            self.figure.clear()
488
489            self.figure.subplots_adjust(left=0.1, right=.8, bottom=.1)
490
491            data_x, data_y = numpy.meshgrid(self._data.x_bins[0:-1],
492                                            self._data.y_bins[0:-1])
493
494            ax = Axes3D(self.figure)
495
496            # Disable rotation for large sets.
497            # TODO: Define "large" for a dataset
498            SET_TOO_LARGE = 500
499            if len(data_x) > SET_TOO_LARGE:
500                ax.disable_mouse_rotation()
501
502            self.figure.canvas.resizing = False
503            im = ax.plot_surface(data_x, data_y, output, rstride=1,
504                                 cstride=1, cmap=cmap,
505                                 linewidth=0, antialiased=False)
506            self.ax.set_axis_off()
507
508        if self.dimension != 3:
509            self.figure.canvas.draw_idle()
510        else:
511            self.figure.canvas.draw()
512
513    def update(self):
514        self.figure.canvas.draw()
515
516    def draw(self):
517        self.figure.canvas.draw()
518
519    def replacePlot(self, id, new_plot):
520        """
521        Replace data in current chart.
522        This effectively refreshes the chart with changes to one of its plots
523        """
524        self.plot(data=new_plot)
525
526
527class Plotter2D(QtWidgets.QDialog, Plotter2DWidget):
528    """
529    Plotter widget implementation
530    """
531    def __init__(self, parent=None, quickplot=False, dimension=2):
532        QtWidgets.QDialog.__init__(self)
533        Plotter2DWidget.__init__(self, manager=parent, quickplot=quickplot, dimension=dimension)
534        icon = QtGui.QIcon()
535        icon.addPixmap(QtGui.QPixmap(":/res/ball.ico"), QtGui.QIcon.Normal, QtGui.QIcon.Off)
536        self.setWindowIcon(icon)
Note: See TracBrowser for help on using the repository browser.