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

Last change on this file since 033b1f2 was d09f462f, checked in by Piotr Rozyczko <piotr.rozyczko@…>, 6 years ago

Some platforms still use older numpy without quantile(). Temporarily
revert the code.

  • Property mode set to 100644
File size: 21.8 KB
Line 
1import copy
2import numpy
3import functools
4import logging
5
6from PyQt5 import QtCore
7from PyQt5 import QtGui
8from PyQt5 import QtWidgets
9
10
11import matplotlib as mpl
12DEFAULT_CMAP = mpl.cm.jet
13
14from mpl_toolkits.mplot3d import Axes3D
15
16from sas.sascalc.dataloader.manipulations import CircularAverage
17
18from sas.qtgui.Plotting.PlotterData import Data1D
19from sas.qtgui.Plotting.PlotterData import Data2D
20
21import sas.qtgui.Plotting.PlotUtilities as PlotUtilities
22import sas.qtgui.Utilities.GuiUtils as GuiUtils
23from sas.qtgui.Plotting.PlotterBase import PlotterBase
24from sas.qtgui.Plotting.ColorMap import ColorMap
25from sas.qtgui.Plotting.BoxSum import BoxSum
26from sas.qtgui.Plotting.SlicerParameters import SlicerParameters
27from sas.qtgui.Plotting.Binder import BindArtist
28
29# TODO: move to sas.qtgui namespace
30from sas.qtgui.Plotting.Slicers.BoxSlicer import BoxInteractorX
31from sas.qtgui.Plotting.Slicers.BoxSlicer import BoxInteractorY
32from sas.qtgui.Plotting.Slicers.AnnulusSlicer import AnnulusInteractor
33from sas.qtgui.Plotting.Slicers.SectorSlicer import SectorInteractor
34from sas.qtgui.Plotting.Slicers.BoxSum import BoxSumCalculator
35
36# Minimum value of Z for which we will present data.
37MIN_Z = -32
38
39class Plotter2DWidget(PlotterBase):
40    """
41    2D Plot widget for use with a QDialog
42    """
43    def __init__(self, parent=None, manager=None, quickplot=False, dimension=2):
44        self.dimension = dimension
45        super(Plotter2DWidget, self).__init__(parent, manager=manager, quickplot=quickplot)
46
47        self.cmap = DEFAULT_CMAP.name
48        # Default scale
49        self.scale = 'log_{10}'
50        # to set the order of lines drawn first.
51        self.slicer_z = 5
52        # Reference to the current slicer
53        self.slicer = None
54        self.slicer_widget = None
55        # Create Artist and bind it
56        self.connect = BindArtist(self.figure)
57        self.vmin = None
58        self.vmax = None
59        self.im = None
60
61        self.manager = manager
62
63    @property
64    def data(self):
65        return self._data
66
67    @data.setter
68    def data(self, data=None):
69        """ data setter """
70        self._data = data
71        self.qx_data = data.qx_data
72        self.qy_data = data.qy_data
73        self.xmin = data.xmin
74        self.xmax = data.xmax
75        self.ymin = data.ymin
76        self.ymax = data.ymax
77        self.zmin = data.zmin
78        self.zmax = data.zmax
79        self.label = data.name
80        self.xLabel = "%s(%s)"%(data._xaxis, data._xunit)
81        self.yLabel = "%s(%s)"%(data._yaxis, data._yunit)
82        self.title(title=data.title)
83
84    def plot(self, data=None, marker=None, show_colorbar=True, update=False):
85        """
86        Plot 2D self._data
87        marker - unused
88        """
89        # Assing data
90        if isinstance(data, Data2D):
91            self.data = data
92
93        if not self._data:
94            return
95
96        # Toggle the scale
97        zmin_2D_temp, zmax_2D_temp = self.calculateDepth()
98
99        # Prepare and show the plot
100        self.showPlot(data=self.data.data,
101                      qx_data=self.qx_data,
102                      qy_data=self.qy_data,
103                      xmin=self.xmin,
104                      xmax=self.xmax,
105                      ymin=self.ymin, ymax=self.ymax,
106                      cmap=self.cmap, zmin=zmin_2D_temp,
107                      zmax=zmax_2D_temp, show_colorbar=show_colorbar,
108                      update=update)
109
110        self.updateCircularAverage()
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
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.closeWidgetSignal.disconnect()
230            self.manager.parent.workspace().removeSubWindow(self.slicer_subwindow)
231            # reset slicer_widget on "Edit Slicer Parameters" window close
232            self.slicer_widget = None
233
234        self.param_model = self.slicer.model()
235        # Pass the model to the Slicer Parameters widget
236        self.slicer_widget = SlicerParameters(model=self.param_model,
237                                              validate_method=self.slicer.validate)
238        self.slicer_widget.closeWidgetSignal.connect(slicer_closed)
239        # Add the plot to the workspace
240        self.slicer_subwindow = self.manager.parent.workspace().addSubWindow(self.slicer_widget)
241
242        self.slicer_widget.show()
243
244    def circularAverage(self):
245        """
246        Calculate the circular average and create the Data object for it
247        """
248        # Find the best number of bins
249        npt = numpy.sqrt(len(self.data.data[numpy.isfinite(self.data.data)]))
250        npt = numpy.floor(npt)
251        # compute the maximum radius of data2D
252        self.qmax = max(numpy.fabs(self.data.xmax),
253                        numpy.fabs(self.data.xmin))
254        self.ymax = max(numpy.fabs(self.data.ymax),
255                        numpy.fabs(self.data.ymin))
256        self.radius = numpy.sqrt(numpy.power(self.qmax, 2) + numpy.power(self.ymax, 2))
257        #Compute beam width
258        bin_width = (self.qmax + self.qmax) / npt
259        # Create data1D circular average of data2D
260        circle = CircularAverage(r_min=0, r_max=self.radius, bin_width=bin_width)
261        circ = circle(self.data)
262        dxl = circ.dxl if hasattr(circ, "dxl") else None
263        dxw = circ.dxw if hasattr(circ, "dxw") else None
264
265        new_plot = Data1D(x=circ.x, y=circ.y, dy=circ.dy, dx=circ.dx)
266        new_plot.dxl = dxl
267        new_plot.dxw = dxw
268        new_plot.name = new_plot.title = "Circ avg " + self.data.name
269        new_plot.source = self.data.source
270        new_plot.interactive = True
271        new_plot.detector = self.data.detector
272
273        # Define axes if not done yet.
274        new_plot.xaxis("\\rm{Q}", "A^{-1}")
275        if hasattr(self.data, "scale") and \
276                    self.data.scale == 'linear':
277            new_plot.ytransform = 'y'
278            new_plot.yaxis("\\rm{Residuals} ", "normalized")
279        else:
280            new_plot.yaxis("\\rm{Intensity} ", "cm^{-1}")
281
282        new_plot.group_id = "2daverage" + self.data.name
283        new_plot.id = "Circ avg " + self.data.name
284        new_plot.is_data = True
285
286        return new_plot
287
288    def onCircularAverage(self):
289        """
290        Perform circular averaging on Data2D
291        """
292        new_plot = self.circularAverage()
293
294        item = self._item
295        if self._item.parent() is not None:
296            item = self._item.parent()
297
298        GuiUtils.updateModelItemWithPlot(item, new_plot, new_plot.id)
299
300        self.manager.communicator.plotUpdateSignal.emit([new_plot])
301        self.manager.communicator.forcePlotDisplaySignal.emit([item, new_plot])
302
303    def updateCircularAverage(self):
304        """
305        Update circular averaging plot on Data2D change
306        """
307        if not hasattr(self,'_item'): return
308        item = self._item
309        if self._item.parent() is not None:
310            item = self._item.parent()
311
312        # Get all plots for current item
313        plots = GuiUtils.plotsFromModel("", item)
314        if plots is None: return
315        ca_caption = '2daverage'+self.data.name
316        # See if current item plots contain 2D average plot
317        has_plot = False
318        for plot in plots:
319            if plot.group_id is None: continue
320            if ca_caption in plot.group_id: has_plot=True
321        # return prematurely if no circular average plot found
322        if not has_plot: return
323
324        # Create a new plot
325        new_plot = self.circularAverage()
326
327        # Overwrite existing plot
328        GuiUtils.updateModelItemWithPlot(item, new_plot, new_plot.id)
329        # Show the new plot, if already visible
330        self.manager.communicator.plotUpdateSignal.emit([new_plot])
331
332        self.manager.communicator.forcePlotDisplaySignal.emit([item, new_plot])
333
334        # Show the plot
335
336    def setSlicer(self, slicer):
337        """
338        Clear the previous slicer and create a new one.
339        slicer: slicer class to create
340        """
341        # Clear current slicer
342        if self.slicer is not None:
343            self.slicer.clear()
344        # Create a new slicer
345        self.slicer_z += 1
346        self.slicer = slicer(self, self.ax, item=self._item, zorder=self.slicer_z)
347        self.ax.set_ylim(self.data.ymin, self.data.ymax)
348        self.ax.set_xlim(self.data.xmin, self.data.xmax)
349        # Draw slicer
350        self.figure.canvas.draw()
351        self.slicer.update()
352
353        # Reset the model on the Edit slicer parameters widget
354        self.param_model = self.slicer.model()
355        if self.slicer_widget:
356            self.slicer_widget.setModel(self.param_model)
357
358    def onSectorView(self):
359        """
360        Perform sector averaging on Q and draw sector slicer
361        """
362        self.setSlicer(slicer=SectorInteractor)
363
364    def onAnnulusView(self):
365        """
366        Perform sector averaging on Phi and draw annulus slicer
367        """
368        self.setSlicer(slicer=AnnulusInteractor)
369
370    def onBoxSum(self):
371        """
372        Perform 2D Data averaging Qx and Qy.
373        Display box slicer details.
374        """
375        self.onClearSlicer()
376        self.slicer_z += 1
377        self.slicer = BoxSumCalculator(self, self.ax, zorder=self.slicer_z)
378
379        self.ax.set_ylim(self.data.ymin, self.data.ymax)
380        self.ax.set_xlim(self.data.xmin, self.data.xmax)
381        self.figure.canvas.draw()
382        self.slicer.update()
383
384        def boxWidgetClosed():
385            # Need to disconnect the signal!!
386            self.boxwidget.closeWidgetSignal.disconnect()
387            # reset box on "Edit Slicer Parameters" window close
388            self.manager.parent.workspace().removeSubWindow(self.boxwidget_subwindow)
389            self.boxwidget = None
390
391        # Get the BoxSumCalculator model.
392        self.box_sum_model = self.slicer.model()
393        # Pass the BoxSumCalculator model to the BoxSum widget
394        self.boxwidget = BoxSum(self, model=self.box_sum_model)
395        # Add the plot to the workspace
396        self.boxwidget_subwindow = self.manager.parent.workspace().addSubWindow(self.boxwidget)
397        self.boxwidget.closeWidgetSignal.connect(boxWidgetClosed)
398
399        self.boxwidget.show()
400
401    def onBoxAveragingX(self):
402        """
403        Perform 2D data averaging on Qx
404        Create a new slicer.
405        """
406        self.setSlicer(slicer=BoxInteractorX)
407
408    def onBoxAveragingY(self):
409        """
410        Perform 2D data averaging on Qy
411        Create a new slicer .
412        """
413        self.setSlicer(slicer=BoxInteractorY)
414
415    def onColorMap(self):
416        """
417        Display the color map dialog and modify the plot's map accordingly
418        """
419        color_map_dialog = ColorMap(self, cmap=self.cmap,
420                                    vmin=self.vmin,
421                                    vmax=self.vmax,
422                                    data=self.data)
423
424        color_map_dialog.apply_signal.connect(self.onApplyMap)
425
426        if color_map_dialog.exec_() == QtWidgets.QDialog.Accepted:
427            self.onApplyMap(color_map_dialog.norm(), color_map_dialog.cmap())
428
429    def onApplyMap(self, v_values, cmap):
430        """
431        Update the chart color map based on data passed from the widget
432        """
433        self.cmap = str(cmap)
434        self.vmin, self.vmax = v_values
435        # Redraw the chart with new cmap
436        self.plot()
437
438    def showPlot(self, data, qx_data, qy_data, xmin, xmax, ymin, ymax,
439                 zmin, zmax, label='data2D', cmap=DEFAULT_CMAP, show_colorbar=True,
440                 update=False):
441        """
442        Render and show the current data
443        """
444        self.qx_data = qx_data
445        self.qy_data = qy_data
446        self.xmin = xmin
447        self.xmax = xmax
448        self.ymin = ymin
449        self.ymax = ymax
450        self.zmin = zmin
451        self.zmax = zmax
452        # If we don't have any data, skip.
453        if data is None:
454            return
455        if data.ndim == 0:
456            return
457        elif data.ndim == 1:
458            output = PlotUtilities.build_matrix(data, self.qx_data, self.qy_data)
459        else:
460            output = copy.deepcopy(data)
461
462        # get the x and y_bin arrays.
463        x_bins, y_bins = PlotUtilities.get_bins(self.qx_data, self.qy_data)
464        self._data.x_bins = x_bins
465        self._data.y_bins = y_bins
466
467        zmin_temp = self.zmin
468        # check scale
469        if self.scale == 'log_{10}':
470            #with numpy.errstate(all='ignore'):
471            #    output = numpy.log10(output)
472            #index = numpy.isfinite(output)
473            #if not index.all():
474            #    cutoff = (numpy.quantile(output[index], 0.05) - numpy.log10(2) if index.any() else 0.)
475            #    output[output < cutoff] = cutoff
476            #    output[~index] = cutoff
477            try:
478                if  self.zmin <= 0  and len(output[output > 0]) > 0:
479                    zmin_temp = self.zmin
480                    output[output > 0] = numpy.log10(output[output > 0])
481                elif self.zmin <= 0:
482                    zmin_temp = self.zmin
483                    output[output > 0] = numpy.zeros(len(output))
484                    output[output <= 0] = MIN_Z
485                else:
486                    zmin_temp = self.zmin
487                    output[output > 0] = numpy.log10(output[output > 0])
488            except:
489                #Too many problems in 2D plot with scale
490                output[output > 0] = numpy.log10(output[output > 0])
491                pass
492
493        vmin, vmax = None, None
494
495        self.cmap = cmap
496        if self.dimension != 3:
497            #Re-adjust colorbar
498            self.figure.subplots_adjust(left=0.2, right=.8, bottom=.2)
499
500            zmax_temp = self.zmax
501            if self.vmin is not None:
502                zmin_temp = self.vmin
503                zmax_temp = self.vmax
504            if self.im is not None and update:
505                self.im.set_data(output)
506            else:
507                self.im = self.ax.imshow(output, interpolation='nearest',
508                                origin='lower',
509                                vmin=zmin_temp, vmax=zmax_temp,
510                                cmap=self.cmap,
511                                extent=(self.xmin, self.xmax,
512                                        self.ymin, self.ymax))
513
514            cbax = self.figure.add_axes([0.88, 0.2, 0.02, 0.7])
515
516            # Current labels for axes
517            self.ax.set_ylabel(self.y_label)
518            self.ax.set_xlabel(self.x_label)
519
520            # Title only for regular charts
521            if not self.quickplot:
522                self.ax.set_title(label=self._title)
523
524            if cbax is None:
525                ax.set_frame_on(False)
526                cb = self.figure.colorbar(self.im, shrink=0.8, aspect=20)
527            else:
528                cb = self.figure.colorbar(self.im, cax=cbax)
529
530            cb.update_bruteforce(self.im)
531            cb.set_label('$' + self.scale + '$')
532
533            self.vmin = cb.vmin
534            self.vmax = cb.vmax
535
536            if show_colorbar is False:
537                cb.remove()
538
539        else:
540            # clear the previous 2D from memory
541            self.figure.clear()
542
543            self.figure.subplots_adjust(left=0.1, right=.8, bottom=.1)
544
545            data_x, data_y = numpy.meshgrid(self._data.x_bins[0:-1],
546                                            self._data.y_bins[0:-1])
547
548            ax = Axes3D(self.figure)
549
550            # Disable rotation for large sets.
551            # TODO: Define "large" for a dataset
552            SET_TOO_LARGE = 500
553            if len(data_x) > SET_TOO_LARGE:
554                ax.disable_mouse_rotation()
555
556            self.figure.canvas.resizing = False
557            im = ax.plot_surface(data_x, data_y, output, rstride=1,
558                                 cstride=1, cmap=cmap,
559                                 linewidth=0, antialiased=False)
560            self.ax.set_axis_off()
561
562        if self.dimension != 3:
563            self.figure.canvas.draw_idle()
564        else:
565            self.figure.canvas.draw()
566
567    def imageShow(self, img, origin=None):
568        """
569        Show background image
570        :Param img: [imread(path) from matplotlib.pyplot]
571        """
572        if origin is not None:
573            im = self.ax.imshow(img, origin=origin)
574        else:
575            im = self.ax.imshow(img)
576
577    def update(self):
578        self.figure.canvas.draw()
579
580    def draw(self):
581        self.figure.canvas.draw()
582
583    def replacePlot(self, id, new_plot):
584        """
585        Replace data in current chart.
586        This effectively refreshes the chart with changes to one of its plots
587        """
588        self.plot(data=new_plot)
589
590    def onMplMouseDown(self, event):
591        """
592        Display x/y/intensity on click
593        """
594        # Check that the LEFT button was pressed
595        if event.button != 1:
596            return
597
598        if event.inaxes is None:
599            return
600        x_click = 0.0
601        y_click = 0.0
602        try:
603            x_click = float(event.xdata)  # / size_x
604            y_click = float(event.ydata)  # / size_y
605        except:
606            self.position = None
607        x_str = GuiUtils.formatNumber(x_click)
608        y_str = GuiUtils.formatNumber(y_click)
609        coord_str = "x: {}, y: {}".format(x_str, y_str)
610        self.manager.communicator.statusBarUpdateSignal.emit(coord_str)
611
612class Plotter2D(QtWidgets.QDialog, Plotter2DWidget):
613    """
614    Plotter widget implementation
615    """
616    def __init__(self, parent=None, quickplot=False, dimension=2):
617        QtWidgets.QDialog.__init__(self)
618        Plotter2DWidget.__init__(self, manager=parent, quickplot=quickplot, dimension=dimension)
619        icon = QtGui.QIcon()
620        icon.addPixmap(QtGui.QPixmap(":/res/ball.ico"), QtGui.QIcon.Normal, QtGui.QIcon.Off)
621        self.setWindowIcon(icon)
Note: See TracBrowser for help on using the repository browser.