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

Last change on this file since e0da307 was fce6c55, checked in by Piotr Rozyczko <rozyczko@…>, 6 years ago

Make the plots a bit more persistent and calculate bins properly. SASVIEW-969

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