source: sasview/src/sas/qtgui/MainWindow/DataExplorer.py @ c71b20a

Last change on this file since c71b20a was c71b20a, checked in by ibressler, 10 months ago

FittingWidget?: showing also new plots on recalc conditionally

  • cherry-picked from ESS_GUI_poly_plot branch
  • Property mode set to 100644
File size: 49.9 KB
Line 
1# global
2import sys
3import os
4import time
5import logging
6
7from PyQt5 import QtCore
8from PyQt5 import QtGui
9from PyQt5 import QtWidgets
10
11from twisted.internet import threads
12
13# SASCALC
14from sas.sascalc.dataloader.loader import Loader
15
16# QTGUI
17import sas.qtgui.Utilities.GuiUtils as GuiUtils
18import sas.qtgui.Plotting.PlotHelper as PlotHelper
19
20from sas.qtgui.Plotting.PlotterData import Data1D
21from sas.qtgui.Plotting.PlotterData import Data2D
22from sas.qtgui.Plotting.Plotter import Plotter
23from sas.qtgui.Plotting.Plotter2D import Plotter2D
24from sas.qtgui.Plotting.MaskEditor import MaskEditor
25
26from sas.qtgui.MainWindow.DataManager import DataManager
27from sas.qtgui.MainWindow.DroppableDataLoadWidget import DroppableDataLoadWidget
28
29import sas.qtgui.Perspectives as Perspectives
30
31DEFAULT_PERSPECTIVE = "Fitting"
32
33logger = logging.getLogger(__name__)
34
35class DataExplorerWindow(DroppableDataLoadWidget):
36    # The controller which is responsible for managing signal slots connections
37    # for the gui and providing an interface to the data model.
38
39    def __init__(self, parent=None, guimanager=None, manager=None):
40        super(DataExplorerWindow, self).__init__(parent, guimanager)
41
42        # Main model for keeping loaded data
43        self.model = QtGui.QStandardItemModel(self)
44        # Secondary model for keeping frozen data sets
45        self.theory_model = QtGui.QStandardItemModel(self)
46
47        # GuiManager is the actual parent, but we needed to also pass the QMainWindow
48        # in order to set the widget parentage properly.
49        self.parent = guimanager
50        self.loader = Loader()
51        self.manager = manager if manager is not None else DataManager()
52        self.txt_widget = QtWidgets.QTextEdit(None)
53
54        # Be careful with twisted threads.
55        self.mutex = QtCore.QMutex()
56
57        # Plot widgets {name:widget}, required to keep track of plots shown as MDI subwindows
58        self.plot_widgets = {}
59
60        # Active plots {id:Plotter1D/2D}, required to keep track of currently displayed plots
61        self.active_plots = {}
62
63        # Connect the buttons
64        self.cmdLoad.clicked.connect(self.loadFile)
65        self.cmdDeleteData.clicked.connect(self.deleteFile)
66        self.cmdDeleteTheory.clicked.connect(self.deleteTheory)
67        self.cmdFreeze.clicked.connect(self.freezeTheory)
68        self.cmdSendTo.clicked.connect(self.sendData)
69        self.cmdNew.clicked.connect(self.newPlot)
70        self.cmdNew_2.clicked.connect(self.newPlot)
71        self.cmdAppend.clicked.connect(self.appendPlot)
72        self.cmdAppend_2.clicked.connect(self.appendPlot)
73        self.cmdHelp.clicked.connect(self.displayHelp)
74        self.cmdHelp_2.clicked.connect(self.displayHelp)
75
76        # Fill in the perspectives combo
77        self.initPerspectives()
78
79        # Custom context menu
80        self.treeView.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
81        self.treeView.customContextMenuRequested.connect(self.onCustomContextMenu)
82        self.contextMenu()
83
84        # Same menus for the theory view
85        self.freezeView.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
86        self.freezeView.customContextMenuRequested.connect(self.onCustomContextMenu)
87
88        # Connect the comboboxes
89        self.cbSelect.currentIndexChanged.connect(self.selectData)
90
91        #self.closeEvent.connect(self.closeEvent)
92        self.currentChanged.connect(self.onTabSwitch)
93        self.communicator = self.parent.communicator()
94        self.communicator.fileReadSignal.connect(self.loadFromURL)
95        self.communicator.activeGraphsSignal.connect(self.updateGraphCount)
96        self.communicator.activeGraphName.connect(self.updatePlotName)
97        self.communicator.plotUpdateSignal.connect(self.displayData)
98        self.communicator.maskEditorSignal.connect(self.showEditDataMask)
99        self.communicator.extMaskEditorSignal.connect(self.extShowEditDataMask)
100        self.communicator.changeDataExplorerTabSignal.connect(self.changeTabs)
101
102        self.cbgraph.editTextChanged.connect(self.enableGraphCombo)
103        self.cbgraph.currentIndexChanged.connect(self.enableGraphCombo)
104
105        # Proxy model for showing a subset of Data1D/Data2D content
106        self.data_proxy = QtCore.QSortFilterProxyModel(self)
107        self.data_proxy.setSourceModel(self.model)
108
109        # Don't show "empty" rows with data objects
110        self.data_proxy.setFilterRegExp(r"[^()]")
111
112        # The Data viewer is QTreeView showing the proxy model
113        self.treeView.setModel(self.data_proxy)
114
115        # Proxy model for showing a subset of Theory content
116        self.theory_proxy = QtCore.QSortFilterProxyModel(self)
117        self.theory_proxy.setSourceModel(self.theory_model)
118
119        # Don't show "empty" rows with data objects
120        self.theory_proxy.setFilterRegExp(r"[^()]")
121
122        # Theory model view
123        self.freezeView.setModel(self.theory_proxy)
124
125        self.enableGraphCombo(None)
126
127        # Current view on model
128        self.current_view = self.treeView
129
130    def closeEvent(self, event):
131        """
132        Overwrite the close event - no close!
133        """
134        event.ignore()
135
136    def onTabSwitch(self, index):
137        """ Callback for tab switching signal """
138        if index == 0:
139            self.current_view = self.treeView
140        else:
141            self.current_view = self.freezeView
142
143    def changeTabs(self, tab=0):
144        """
145        Switch tabs of the data explorer
146        0: data tab
147        1: theory tab
148        """
149        assert(tab in [0,1])
150        self.setCurrentIndex(tab)
151
152    def displayHelp(self):
153        """
154        Show the "Loading data" section of help
155        """
156        tree_location = "/user/qtgui/MainWindow/data_explorer_help.html"
157        self.parent.showHelp(tree_location)
158
159    def enableGraphCombo(self, combo_text):
160        """
161        Enables/disables "Assign Plot" elements
162        """
163        self.cbgraph.setEnabled(len(PlotHelper.currentPlots()) > 0)
164        self.cmdAppend.setEnabled(len(PlotHelper.currentPlots()) > 0)
165
166    def initPerspectives(self):
167        """
168        Populate the Perspective combobox and define callbacks
169        """
170        available_perspectives = sorted([p for p in list(Perspectives.PERSPECTIVES.keys())])
171        if available_perspectives:
172            self.cbFitting.clear()
173            self.cbFitting.addItems(available_perspectives)
174        self.cbFitting.currentIndexChanged.connect(self.updatePerspectiveCombo)
175        # Set the index so we see the default (Fitting)
176        self.cbFitting.setCurrentIndex(self.cbFitting.findText(DEFAULT_PERSPECTIVE))
177
178    def _perspective(self):
179        """
180        Returns the current perspective
181        """
182        return self.parent.perspective()
183
184    def loadFromURL(self, url):
185        """
186        Threaded file load
187        """
188        load_thread = threads.deferToThread(self.readData, url)
189        load_thread.addCallback(self.loadComplete)
190        load_thread.addErrback(self.loadFailed)
191
192    def loadFile(self, event=None):
193        """
194        Called when the "Load" button pressed.
195        Opens the Qt "Open File..." dialog
196        """
197        path_str = self.chooseFiles()
198        if not path_str:
199            return
200        self.loadFromURL(path_str)
201
202    def loadFolder(self, event=None):
203        """
204        Called when the "File/Load Folder" menu item chosen.
205        Opens the Qt "Open Folder..." dialog
206        """
207        folder = QtWidgets.QFileDialog.getExistingDirectory(self, "Choose a directory", "",
208              QtWidgets.QFileDialog.ShowDirsOnly | QtWidgets.QFileDialog.DontUseNativeDialog)
209        if folder is None:
210            return
211
212        folder = str(folder)
213
214        if not os.path.isdir(folder):
215            return
216
217        # get content of dir into a list
218        path_str = [os.path.join(os.path.abspath(folder), filename)
219                    for filename in os.listdir(folder)]
220
221        self.loadFromURL(path_str)
222
223    def loadProject(self):
224        """
225        Called when the "Open Project" menu item chosen.
226        """
227        kwargs = {
228            'parent'    : self,
229            'caption'   : 'Open Project',
230            'filter'    : 'Project (*.json);;All files (*.*)',
231            'options'   : QtWidgets.QFileDialog.DontUseNativeDialog
232        }
233        filename = QtWidgets.QFileDialog.getOpenFileName(**kwargs)[0]
234        if filename:
235            load_thread = threads.deferToThread(self.readProject, filename)
236            load_thread.addCallback(self.readProjectComplete)
237            load_thread.addErrback(self.readProjectFailed)
238
239    def loadFailed(self, reason):
240        """
241        """
242        print("file load FAILED: ", reason)
243        pass
244
245    def readProjectFailed(self, reason):
246        """
247        """
248        print("readProjectFailed FAILED: ", reason)
249        pass
250
251    def readProject(self, filename):
252        self.communicator.statusBarUpdateSignal.emit("Loading Project... %s" % os.path.basename(filename))
253        try:
254            manager = DataManager()
255            with open(filename, 'r') as infile:
256                manager.load_from_readable(infile)
257
258            self.communicator.statusBarUpdateSignal.emit("Loaded Project: %s" % os.path.basename(filename))
259            return manager
260
261        except:
262            self.communicator.statusBarUpdateSignal.emit("Failed: %s" % os.path.basename(filename))
263            raise
264
265    def readProjectComplete(self, manager):
266        self.model.clear()
267
268        self.manager.assign(manager)
269        self.model.beginResetModel()
270        for id, item in self.manager.get_all_data().items():
271            self.updateModel(item.data, item.path)
272
273        self.model.endResetModel()
274
275    def saveProject(self):
276        """
277        Called when the "Save Project" menu item chosen.
278        """
279        kwargs = {
280            'parent'    : self,
281            'caption'   : 'Save Project',
282            'filter'    : 'Project (*.json)',
283            'options'   : QtWidgets.QFileDialog.DontUseNativeDialog
284        }
285        name_tuple = QtWidgets.QFileDialog.getSaveFileName(**kwargs)
286        filename = name_tuple[0]
287        if filename:
288            _, extension = os.path.splitext(filename)
289            if not extension:
290                filename = '.'.join((filename, 'json'))
291            self.communicator.statusBarUpdateSignal.emit("Saving Project... %s\n" % os.path.basename(filename))
292            with open(filename, 'w') as outfile:
293                self.manager.save_to_writable(outfile)
294
295    def deleteFile(self, event):
296        """
297        Delete selected rows from the model
298        """
299        # Assure this is indeed wanted
300        delete_msg = "This operation will delete the checked data sets and all the dependents." +\
301                     "\nDo you want to continue?"
302        reply = QtWidgets.QMessageBox.question(self,
303                                           'Warning',
304                                           delete_msg,
305                                           QtWidgets.QMessageBox.Yes,
306                                           QtWidgets.QMessageBox.No)
307
308        if reply == QtWidgets.QMessageBox.No:
309            return
310
311        # Figure out which rows are checked
312        ind = -1
313        # Use 'while' so the row count is forced at every iteration
314        deleted_items = []
315        deleted_names = []
316        while ind < self.model.rowCount():
317            ind += 1
318            item = self.model.item(ind)
319
320            if item and item.isCheckable() and item.checkState() == QtCore.Qt.Checked:
321                # Delete these rows from the model
322                deleted_names.append(str(self.model.item(ind).text()))
323                deleted_items.append(item)
324
325                self.model.removeRow(ind)
326                # Decrement index since we just deleted it
327                ind -= 1
328
329        # Let others know we deleted data
330        self.communicator.dataDeletedSignal.emit(deleted_items)
331
332        # update stored_data
333        self.manager.update_stored_data(deleted_names)
334
335    def deleteTheory(self, event):
336        """
337        Delete selected rows from the theory model
338        """
339        # Assure this is indeed wanted
340        delete_msg = "This operation will delete the checked data sets and all the dependents." +\
341                     "\nDo you want to continue?"
342        reply = QtWidgets.QMessageBox.question(self,
343                                           'Warning',
344                                           delete_msg,
345                                           QtWidgets.QMessageBox.Yes,
346                                           QtWidgets.QMessageBox.No)
347
348        if reply == QtWidgets.QMessageBox.No:
349            return
350
351        # Figure out which rows are checked
352        ind = -1
353        # Use 'while' so the row count is forced at every iteration
354        while ind < self.theory_model.rowCount():
355            ind += 1
356            item = self.theory_model.item(ind)
357            if item and item.isCheckable() and item.checkState() == QtCore.Qt.Checked:
358                # Delete these rows from the model
359                self.theory_model.removeRow(ind)
360                # Decrement index since we just deleted it
361                ind -= 1
362
363        # pass temporarily kept as a breakpoint anchor
364        pass
365
366    def sendData(self, event):
367        """
368        Send selected item data to the current perspective and set the relevant notifiers
369        """
370        # Set the signal handlers
371        self.communicator.updateModelFromPerspectiveSignal.connect(self.updateModelFromPerspective)
372
373        def isItemReady(index):
374            item = self.model.item(index)
375            return item.isCheckable() and item.checkState() == QtCore.Qt.Checked
376
377        # Figure out which rows are checked
378        selected_items = [self.model.item(index)
379                          for index in range(self.model.rowCount())
380                          if isItemReady(index)]
381
382        if len(selected_items) < 1:
383            return
384
385        # Which perspective has been selected?
386        if len(selected_items) > 1 and not self._perspective().allowBatch():
387            msg = self._perspective().title() + " does not allow multiple data."
388            msgbox = QtWidgets.QMessageBox()
389            msgbox.setIcon(QtWidgets.QMessageBox.Critical)
390            msgbox.setText(msg)
391            msgbox.setStandardButtons(QtWidgets.QMessageBox.Ok)
392            retval = msgbox.exec_()
393            return
394
395        # Notify the GuiManager about the send request
396        self._perspective().setData(data_item=selected_items, is_batch=self.chkBatch.isChecked())
397
398    def freezeCheckedData(self):
399        """
400        Convert checked results (fitted model, residuals) into separate dataset.
401        """
402        outer_index = -1
403        theories_copied = 0
404        orig_model_size = self.model.rowCount()
405        while outer_index < orig_model_size:
406            outer_index += 1
407            outer_item = self.model.item(outer_index)
408            if not outer_item:
409                continue
410            if not outer_item.isCheckable():
411                continue
412            # Look for checked inner items
413            inner_index = -1
414            while inner_index < outer_item.rowCount():
415               inner_item = outer_item.child(inner_index)
416               inner_index += 1
417               if not inner_item:
418                   continue
419               if not inner_item.isCheckable():
420                   continue
421               if inner_item.checkState() != QtCore.Qt.Checked:
422                   continue
423               self.model.beginResetModel()
424               theories_copied += 1
425               new_item = self.cloneTheory(inner_item)
426               self.model.appendRow(new_item)
427               self.model.endResetModel()
428
429        freeze_msg = ""
430        if theories_copied == 0:
431            return
432        elif theories_copied == 1:
433            freeze_msg = "1 theory copied to a separate data set"
434        elif theories_copied > 1:
435            freeze_msg = "%i theories copied to separate data sets" % theories_copied
436        else:
437            freeze_msg = "Unexpected number of theories copied: %i" % theories_copied
438            raise AttributeError(freeze_msg)
439        self.communicator.statusBarUpdateSignal.emit(freeze_msg)
440
441    def freezeTheory(self, event):
442        """
443        Freeze selected theory rows.
444
445        "Freezing" means taking the plottable data from the Theory item
446        and copying it to a separate top-level item in Data.
447        """
448        # Figure out which rows are checked
449        # Use 'while' so the row count is forced at every iteration
450        outer_index = -1
451        theories_copied = 0
452        while outer_index < self.theory_model.rowCount():
453            outer_index += 1
454            outer_item = self.theory_model.item(outer_index)
455            if not outer_item:
456                continue
457            if outer_item.isCheckable() and \
458                   outer_item.checkState() == QtCore.Qt.Checked:
459                self.model.beginResetModel()
460                theories_copied += 1
461                new_item = self.cloneTheory(outer_item)
462                self.model.appendRow(new_item)
463                self.model.endResetModel()
464
465        freeze_msg = ""
466        if theories_copied == 0:
467            return
468        elif theories_copied == 1:
469            freeze_msg = "1 theory copied from the Theory tab as a data set"
470        elif theories_copied > 1:
471            freeze_msg = "%i theories copied from the Theory tab as data sets" % theories_copied
472        else:
473            freeze_msg = "Unexpected number of theories copied: %i" % theories_copied
474            raise AttributeError(freeze_msg)
475        self.communicator.statusBarUpdateSignal.emit(freeze_msg)
476        # Actively switch tabs
477        self.setCurrentIndex(1)
478
479    def cloneTheory(self, item_from):
480        """
481        Manually clone theory items into a new HashableItem
482        """
483        new_item = GuiUtils.HashableStandardItem()
484        new_item.setCheckable(True)
485        new_item.setCheckState(QtCore.Qt.Checked)
486        info_item = QtGui.QStandardItem("Info")
487        data_item = QtGui.QStandardItem()
488        data_item.setData(item_from.child(0).data())
489        new_item.setText(item_from.text())
490        new_item.setChild(0, data_item)
491        new_item.setChild(1, info_item)
492        # Append a "unique" descriptor to the name
493        time_bit = str(time.time())[7:-1].replace('.', '')
494        new_name = new_item.text() + '_@' + time_bit
495        new_item.setText(new_name)
496        # Change the underlying data so it is no longer a theory
497        try:
498            new_item.child(0).data().is_data = True
499        except AttributeError:
500            #no data here, pass
501            pass
502        return new_item
503
504    def recursivelyCloneItem(self, item):
505        """
506        Clone QStandardItem() object
507        """
508        new_item = item.clone()
509        # clone doesn't do deepcopy :(
510        for child_index in range(item.rowCount()):
511            child_item = self.recursivelyCloneItem(item.child(child_index))
512            new_item.setChild(child_index, child_item)
513        return new_item
514
515    def updatePlotName(self, name_tuple):
516        """
517        Modify the name of the current plot
518        """
519        old_name, current_name = name_tuple
520        ind = self.cbgraph.findText(old_name)
521        self.cbgraph.setCurrentIndex(ind)
522        self.cbgraph.setItemText(ind, current_name)
523
524    def updateGraphCount(self, graph_list):
525        """
526        Modify the graph name combo and potentially remove
527        deleted graphs
528        """
529        self.updateGraphCombo(graph_list)
530
531        if not self.active_plots:
532            return
533        new_plots = [PlotHelper.plotById(plot) for plot in graph_list]
534        active_plots_copy = list(self.active_plots.keys())
535        for plot in active_plots_copy:
536            if self.active_plots[plot] in new_plots:
537                continue
538            self.active_plots.pop(plot)
539
540    def updateGraphCombo(self, graph_list):
541        """
542        Modify Graph combo box on graph add/delete
543        """
544        orig_text = self.cbgraph.currentText()
545        self.cbgraph.clear()
546        self.cbgraph.insertItems(0, graph_list)
547        ind = self.cbgraph.findText(orig_text)
548        if ind > 0:
549            self.cbgraph.setCurrentIndex(ind)
550
551    def updatePerspectiveCombo(self, index):
552        """
553        Notify the gui manager about the new perspective chosen.
554        """
555        self.communicator.perspectiveChangedSignal.emit(self.cbFitting.itemText(index))
556        self.chkBatch.setEnabled(self.parent.perspective().allowBatch())
557
558    def itemFromFilename(self, filename):
559        """
560        Retrieves model item corresponding to the given filename
561        """
562        item = GuiUtils.itemFromFilename(filename, self.model)
563        return item
564
565    def displayFile(self, filename=None, is_data=True, id=None):
566        """
567        Forces display of charts for the given filename
568        """
569        model = self.model if is_data else self.theory_model
570        # Now query the model item for available plots
571        plots = GuiUtils.plotsFromFilename(filename, model)
572        # Each fitpage contains the name based on fit widget number
573        fitpage_name = "" if id is None else "M"+str(id)
574        # groups plots which move into an own window
575        new_plots = dict(int = [], res = [], pd = [])
576        for item, plot in plots.items():
577            if self.updatePlot(plot) and filename != plot.name:
578                continue
579            # Don't plot intermediate results, e.g. P(Q), S(Q)
580            match = GuiUtils.theory_plot_ID_pattern.match(plot.id)
581            # 2nd match group contains the identifier for the intermediate
582            # result, if present (e.g. "[P(Q)]")
583            if match and match.groups()[1] != None:
584                continue
585            # Don't include plots from different fitpages, but always include the original data
586            if fitpage_name in plot.name or filename == plot.name:
587                # 'sophisticated' test to generate standalone plot for residuals
588                # this should be done by some kind of grouping by lists
589                # which helps to indicate which go into a single plot window
590                if 'esiduals' in plot.title:
591                    plot.yscale = 'linear'
592                    new_plots['res'].append((item, plot))
593                elif 'olydispersity' in plot.title:
594                    plot.yscale = 'linear'
595                    new_plots['pd'].append((item, plot))
596                else:
597                    new_plots['int'].append((item, plot))
598
599        # create entirely new plots for those which could not be updated
600        for plots in new_plots.values():
601            if len(plots):
602                self.plotData(plots)
603
604    def displayData(self, data_list, id=None):
605        """
606        Forces display of charts for the given data set
607        """
608        for plot_to_show in data_list:
609            # may there be duplicates? list(OrderedDict.fromkeys(data_list))
610            # passed plot is used ONLY to figure out its title,
611            # so all the charts related by it can be pulled from
612            # the data explorer indices.
613            filename = plot_to_show.filename
614            self.displayFile(filename=filename, is_data=plot_to_show.is_data, id=id)
615
616    def addDataPlot2D(self, plot_set, item):
617        """
618        Create a new 2D plot and add it to the workspace
619        """
620        plot2D = Plotter2D(self)
621        plot2D.item = item
622        plot2D.plot(plot_set)
623        self.addPlot(plot2D)
624        self.active_plots[plot2D.data.name] = plot2D
625        #============================================
626        # Experimental hook for silx charts
627        #============================================
628        ## Attach silx
629        #from silx.gui import qt
630        #from silx.gui.plot import StackView
631        #sv = StackView()
632        #sv.setColormap("jet", autoscale=True)
633        #sv.setStack(plot_set.data.reshape(1,100,100))
634        ##sv.setLabels(["x: -10 to 10 (200 samples)",
635        ##              "y: -10 to 5 (150 samples)"])
636        #sv.show()
637        #============================================
638
639    def plotData(self, plots, transform=True):
640        """
641        Takes 1D/2D data and generates a single plot (1D) or multiple plots (2D)
642        """
643        # Call show on requested plots
644        # All same-type charts in one plot
645        for item, plot_set in plots:
646            if isinstance(plot_set, Data1D):
647                if not 'new_plot' in locals():
648                    new_plot = Plotter(self)
649                    new_plot.item = item
650                new_plot.plot(plot_set, transform=transform)
651                # active_plots may contain multiple charts
652                self.active_plots[plot_set.name] = new_plot
653            elif isinstance(plot_set, Data2D):
654                self.addDataPlot2D(plot_set, item)
655            else:
656                msg = "Incorrect data type passed to Plotting"
657                raise AttributeError(msg)
658
659        if 'new_plot' in locals() and \
660            hasattr(new_plot, 'data') and \
661            isinstance(new_plot.data, Data1D):
662                self.addPlot(new_plot)
663
664    def newPlot(self):
665        """
666        Select checked data and plot it
667        """
668        # Check which tab is currently active
669        if self.current_view == self.treeView:
670            plots = GuiUtils.plotsFromCheckedItems(self.model)
671        else:
672            plots = GuiUtils.plotsFromCheckedItems(self.theory_model)
673
674        self.plotData(plots)
675
676    def addPlot(self, new_plot):
677        """
678        Helper method for plot bookkeeping
679        """
680        # Update the global plot counter
681        title = str(PlotHelper.idOfPlot(new_plot))
682        new_plot.setWindowTitle(title)
683
684        # Set the object name to satisfy the Squish object picker
685        new_plot.setObjectName(title)
686
687        # Add the plot to the workspace
688        plot_widget = self.parent.workspace().addSubWindow(new_plot)
689
690        # Show the plot
691        new_plot.show()
692        new_plot.canvas.draw()
693
694        # Update the plot widgets dict
695        self.plot_widgets[title]=plot_widget
696
697        # Update the active chart list
698        #self.active_plots[new_plot.data.id] = new_plot
699
700    def appendPlot(self):
701        """
702        Add data set(s) to the existing matplotlib chart
703        """
704        # new plot data; check which tab is currently active
705        if self.current_view == self.treeView:
706            new_plots = GuiUtils.plotsFromCheckedItems(self.model)
707        else:
708            new_plots = GuiUtils.plotsFromCheckedItems(self.theory_model)
709
710        # old plot data
711        plot_id = str(self.cbgraph.currentText())
712
713        assert plot_id in PlotHelper.currentPlots(), "No such plot: %s"%(plot_id)
714
715        old_plot = PlotHelper.plotById(plot_id)
716
717        # Add new data to the old plot, if data type is the same.
718        for _, plot_set in new_plots:
719            if type(plot_set) is type(old_plot._data):
720                old_plot.data = plot_set
721                old_plot.plot()
722                # need this for lookup - otherwise this plot will never update
723                self.active_plots[plot_set.name] = old_plot
724
725    def updatePlot(self, data):
726        """
727        Modify existing plot for immediate response and returns True.
728        Returns false, if the plot does not exist already.
729        """
730        try: # there might be a list or a single value being passed
731            data = data[0]
732        except TypeError:
733            pass
734        assert type(data).__name__ in ['Data1D', 'Data2D']
735
736        ids_keys = list(self.active_plots.keys())
737        ids_vals = [val.data.name for val in self.active_plots.values()]
738
739        data_id = data.name
740        if data_id in ids_keys:
741            self.active_plots[data_id].replacePlot(data_id, data)
742            return True
743        elif data_id in ids_vals:
744            list(self.active_plots.values())[ids_vals.index(data_id)].replacePlot(data_id, data)
745            return True
746        return False
747
748    def chooseFiles(self):
749        """
750        Shows the Open file dialog and returns the chosen path(s)
751        """
752        # List of known extensions
753        wlist = self.getWlist()
754
755        # Location is automatically saved - no need to keep track of the last dir
756        # But only with Qt built-in dialog (non-platform native)
757        paths = QtWidgets.QFileDialog.getOpenFileNames(self, "Choose a file", "",
758                wlist, None, QtWidgets.QFileDialog.DontUseNativeDialog)[0]
759        if not paths:
760            return
761
762        if not isinstance(paths, list):
763            paths = [paths]
764
765        return paths
766
767    def readData(self, path):
768        """
769        verbatim copy-paste from
770           sasgui.guiframe.local_perspectives.data_loader.data_loader.py
771        slightly modified for clarity
772        """
773        message = ""
774        log_msg = ''
775        output = {}
776        any_error = False
777        data_error = False
778        error_message = ""
779        number_of_files = len(path)
780        self.communicator.progressBarUpdateSignal.emit(0.0)
781
782        for index, p_file in enumerate(path):
783            basename = os.path.basename(p_file)
784            _, extension = os.path.splitext(basename)
785            if extension.lower() in GuiUtils.EXTENSIONS:
786                any_error = True
787                log_msg = "Data Loader cannot "
788                log_msg += "load: %s\n" % str(p_file)
789                log_msg += """Please try to open that file from "open project" """
790                log_msg += """or "open analysis" menu\n"""
791                error_message = log_msg + "\n"
792                logging.info(log_msg)
793                continue
794
795            try:
796                message = "Loading Data... " + str(basename) + "\n"
797
798                # change this to signal notification in GuiManager
799                self.communicator.statusBarUpdateSignal.emit(message)
800
801                output_objects = self.loader.load(p_file)
802
803                # Some loaders return a list and some just a single Data1D object.
804                # Standardize.
805                if not isinstance(output_objects, list):
806                    output_objects = [output_objects]
807
808                for item in output_objects:
809                    # cast sascalc.dataloader.data_info.Data1D into
810                    # sasgui.guiframe.dataFitting.Data1D
811                    # TODO : Fix it
812                    new_data = self.manager.create_gui_data(item, p_file)
813                    output[new_data.id] = new_data
814
815                    # Model update should be protected
816                    self.mutex.lock()
817                    self.updateModel(new_data, p_file)
818                    #self.model.reset()
819                    QtWidgets.QApplication.processEvents()
820                    self.mutex.unlock()
821
822                    if hasattr(item, 'errors'):
823                        for error_data in item.errors:
824                            data_error = True
825                            message += "\tError: {0}\n".format(error_data)
826                    else:
827
828                        logging.error("Loader returned an invalid object:\n %s" % str(item))
829                        data_error = True
830
831            except Exception as ex:
832                logging.error(sys.exc_info()[1])
833
834                any_error = True
835            if any_error or error_message != "":
836                if error_message == "":
837                    error = "Error: " + str(sys.exc_info()[1]) + "\n"
838                    error += "while loading Data: \n%s\n" % str(basename)
839                    error_message += "The data file you selected could not be loaded.\n"
840                    error_message += "Make sure the content of your file"
841                    error_message += " is properly formatted.\n\n"
842                    error_message += "When contacting the SasView team, mention the"
843                    error_message += " following:\n%s" % str(error)
844                elif data_error:
845                    base_message = "Errors occurred while loading "
846                    base_message += "{0}\n".format(basename)
847                    base_message += "The data file loaded but with errors.\n"
848                    error_message = base_message + error_message
849                else:
850                    error_message += "%s\n" % str(p_file)
851
852            current_percentage = int(100.0* index/number_of_files)
853            self.communicator.progressBarUpdateSignal.emit(current_percentage)
854
855        if any_error or error_message:
856            logging.error(error_message)
857            status_bar_message = "Errors occurred while loading %s" % format(basename)
858            self.communicator.statusBarUpdateSignal.emit(status_bar_message)
859
860        else:
861            message = "Loading Data Complete! "
862        message += log_msg
863        # Notify the progress bar that the updates are over.
864        self.communicator.progressBarUpdateSignal.emit(-1)
865        self.communicator.statusBarUpdateSignal.emit(message)
866
867        return output, message
868
869    def getWlist(self):
870        """
871        Wildcards of files we know the format of.
872        """
873        # Display the Qt Load File module
874        cards = self.loader.get_wildcards()
875
876        # get rid of the wx remnant in wildcards
877        # TODO: modify sasview loader get_wildcards method, after merge,
878        # so this kludge can be avoided
879        new_cards = []
880        for item in cards:
881            new_cards.append(item[:item.find("|")])
882        wlist = ';;'.join(new_cards)
883
884        return wlist
885
886    def setItemsCheckability(self, model, dimension=None, checked=False):
887        """
888        For a given model, check or uncheck all items of given dimension
889        """
890        mode = QtCore.Qt.Checked if checked else QtCore.Qt.Unchecked
891
892        assert isinstance(checked, bool)
893
894        types = (None, Data1D, Data2D)
895        assert dimension in types
896
897        for index in range(model.rowCount()):
898            item = model.item(index)
899            if dimension is not None and not isinstance(GuiUtils.dataFromItem(item), dimension):
900                continue
901            if item.isCheckable() and item.checkState() != mode:
902                item.setCheckState(mode)
903            # look for all children
904            for inner_index in range(item.rowCount()):
905                child = item.child(inner_index)
906                if child.isCheckable() and child.checkState() != mode:
907                    child.setCheckState(mode)
908
909    def selectData(self, index):
910        """
911        Callback method for modifying the TreeView on Selection Options change
912        """
913        if not isinstance(index, int):
914            msg = "Incorrect type passed to DataExplorer.selectData()"
915            raise AttributeError(msg)
916
917        # Respond appropriately
918        if index == 0:
919            self.setItemsCheckability(self.model, checked=True)
920
921        elif index == 1:
922            # De-select All
923            self.setItemsCheckability(self.model, checked=False)
924
925        elif index == 2:
926            # Select All 1-D
927            self.setItemsCheckability(self.model, dimension=Data1D, checked=True)
928
929        elif index == 3:
930            # Unselect All 1-D
931            self.setItemsCheckability(self.model, dimension=Data1D, checked=False)
932
933        elif index == 4:
934            # Select All 2-D
935            self.setItemsCheckability(self.model, dimension=Data2D, checked=True)
936
937        elif index == 5:
938            # Unselect All 2-D
939            self.setItemsCheckability(self.model, dimension=Data2D, checked=False)
940
941        else:
942            msg = "Incorrect value in the Selection Option"
943            # Change this to a proper logging action
944            raise Exception(msg)
945
946    def contextMenu(self):
947        """
948        Define actions and layout of the right click context menu
949        """
950        # Create a custom menu based on actions defined in the UI file
951        self.context_menu = QtWidgets.QMenu(self)
952        self.context_menu.addAction(self.actionDataInfo)
953        self.context_menu.addAction(self.actionSaveAs)
954        self.context_menu.addAction(self.actionQuickPlot)
955        self.context_menu.addSeparator()
956        self.context_menu.addAction(self.actionQuick3DPlot)
957        self.context_menu.addAction(self.actionEditMask)
958        self.context_menu.addSeparator()
959        self.context_menu.addAction(self.actionDelete)
960
961
962        # Define the callbacks
963        self.actionDataInfo.triggered.connect(self.showDataInfo)
964        self.actionSaveAs.triggered.connect(self.saveDataAs)
965        self.actionQuickPlot.triggered.connect(self.quickDataPlot)
966        self.actionQuick3DPlot.triggered.connect(self.quickData3DPlot)
967        self.actionEditMask.triggered.connect(self.showEditDataMask)
968        self.actionDelete.triggered.connect(self.deleteItem)
969
970    def onCustomContextMenu(self, position):
971        """
972        Show the right-click context menu in the data treeview
973        """
974        index = self.current_view.indexAt(position)
975        proxy = self.current_view.model()
976        model = proxy.sourceModel()
977
978        if not index.isValid():
979            return
980        model_item = model.itemFromIndex(proxy.mapToSource(index))
981        # Find the mapped index
982        orig_index = model_item.isCheckable()
983        if not orig_index:
984            return
985        # Check the data to enable/disable actions
986        is_2D = isinstance(GuiUtils.dataFromItem(model_item), Data2D)
987        self.actionQuick3DPlot.setEnabled(is_2D)
988        self.actionEditMask.setEnabled(is_2D)
989        # Fire up the menu
990        self.context_menu.exec_(self.current_view.mapToGlobal(position))
991
992    def showDataInfo(self):
993        """
994        Show a simple read-only text edit with data information.
995        """
996        index = self.current_view.selectedIndexes()[0]
997        proxy = self.current_view.model()
998        model = proxy.sourceModel()
999        model_item = model.itemFromIndex(proxy.mapToSource(index))
1000
1001        data = GuiUtils.dataFromItem(model_item)
1002        if isinstance(data, Data1D):
1003            text_to_show = GuiUtils.retrieveData1d(data)
1004            # Hardcoded sizes to enable full width rendering with default font
1005            self.txt_widget.resize(420,600)
1006        else:
1007            text_to_show = GuiUtils.retrieveData2d(data)
1008            # Hardcoded sizes to enable full width rendering with default font
1009            self.txt_widget.resize(700,600)
1010
1011        self.txt_widget.setReadOnly(True)
1012        self.txt_widget.setWindowFlags(QtCore.Qt.Window)
1013        self.txt_widget.setWindowIcon(QtGui.QIcon(":/res/ball.ico"))
1014        self.txt_widget.setWindowTitle("Data Info: %s" % data.filename)
1015        self.txt_widget.clear()
1016        self.txt_widget.insertPlainText(text_to_show)
1017
1018        self.txt_widget.show()
1019        # Move the slider all the way up, if present
1020        vertical_scroll_bar = self.txt_widget.verticalScrollBar()
1021        vertical_scroll_bar.triggerAction(QtWidgets.QScrollBar.SliderToMinimum)
1022
1023    def saveDataAs(self):
1024        """
1025        Save the data points as either txt or xml
1026        """
1027        index = self.current_view.selectedIndexes()[0]
1028        proxy = self.current_view.model()
1029        model = proxy.sourceModel()
1030        model_item = model.itemFromIndex(proxy.mapToSource(index))
1031
1032        data = GuiUtils.dataFromItem(model_item)
1033        if isinstance(data, Data1D):
1034            GuiUtils.saveData1D(data)
1035        else:
1036            GuiUtils.saveData2D(data)
1037
1038    def quickDataPlot(self):
1039        """
1040        Frozen plot - display an image of the plot
1041        """
1042        index = self.current_view.selectedIndexes()[0]
1043        proxy = self.current_view.model()
1044        model = proxy.sourceModel()
1045        model_item = model.itemFromIndex(proxy.mapToSource(index))
1046
1047        data = GuiUtils.dataFromItem(model_item)
1048
1049        method_name = 'Plotter'
1050        if isinstance(data, Data2D):
1051            method_name='Plotter2D'
1052
1053        self.new_plot = globals()[method_name](self, quickplot=True)
1054        self.new_plot.data = data
1055        #new_plot.plot(marker='o')
1056        self.new_plot.plot()
1057
1058        # Update the global plot counter
1059        title = "Plot " + data.name
1060        self.new_plot.setWindowTitle(title)
1061
1062        # Show the plot
1063        self.new_plot.show()
1064
1065    def quickData3DPlot(self):
1066        """
1067        Slowish 3D plot
1068        """
1069        index = self.current_view.selectedIndexes()[0]
1070        proxy = self.current_view.model()
1071        model = proxy.sourceModel()
1072        model_item = model.itemFromIndex(proxy.mapToSource(index))
1073
1074        data = GuiUtils.dataFromItem(model_item)
1075
1076        self.new_plot = Plotter2D(self, quickplot=True, dimension=3)
1077        self.new_plot.data = data
1078        self.new_plot.plot()
1079
1080        # Update the global plot counter
1081        title = "Plot " + data.name
1082        self.new_plot.setWindowTitle(title)
1083
1084        # Show the plot
1085        self.new_plot.show()
1086
1087    def extShowEditDataMask(self):
1088        self.showEditDataMask()
1089
1090    def showEditDataMask(self, data=None):
1091        """
1092        Mask Editor for 2D plots
1093        """
1094        try:
1095            if data is None or not isinstance(data, Data2D):
1096                index = self.current_view.selectedIndexes()[0]
1097                proxy = self.current_view.model()
1098                model = proxy.sourceModel()
1099                model_item = model.itemFromIndex(proxy.mapToSource(index))
1100
1101                data = GuiUtils.dataFromItem(model_item)
1102
1103            if data is None or not isinstance(data, Data2D):
1104                msg = QtWidgets.QMessageBox()
1105                msg.setIcon(QtWidgets.QMessageBox.Information)
1106                msg.setText("Error: cannot apply mask. \
1107                                Please select a 2D dataset.")
1108                msg.setStandardButtons(QtWidgets.QMessageBox.Cancel)
1109                msg.exec_()
1110                return
1111        except:
1112            msg = QtWidgets.QMessageBox()
1113            msg.setIcon(QtWidgets.QMessageBox.Information)
1114            msg.setText("Error: No dataset selected. \
1115                            Please select a 2D dataset.")
1116            msg.setStandardButtons(QtWidgets.QMessageBox.Cancel)
1117            msg.exec_()
1118            return
1119
1120        mask_editor = MaskEditor(self, data)
1121        # Modal dialog here.
1122        mask_editor.exec_()
1123
1124    def deleteItem(self):
1125        """
1126        Delete the current item
1127        """
1128        # Assure this is indeed wanted
1129        delete_msg = "This operation will delete the selected data sets " +\
1130                     "and all the dependents." +\
1131                     "\nDo you want to continue?"
1132        reply = QtWidgets.QMessageBox.question(self,
1133                                           'Warning',
1134                                           delete_msg,
1135                                           QtWidgets.QMessageBox.Yes,
1136                                           QtWidgets.QMessageBox.No)
1137
1138        if reply == QtWidgets.QMessageBox.No:
1139            return
1140
1141        # Every time a row is removed, the indices change, so we'll just remove
1142        # rows and keep calling selectedIndexes until it returns an empty list.
1143        indices = self.current_view.selectedIndexes()
1144
1145        proxy = self.current_view.model()
1146        model = proxy.sourceModel()
1147
1148        deleted_items = []
1149        deleted_names = []
1150
1151        while len(indices) > 0:
1152            index = indices[0]
1153            row_index = proxy.mapToSource(index)
1154            item_to_delete = model.itemFromIndex(row_index)
1155            if item_to_delete and item_to_delete.isCheckable():
1156                row = row_index.row()
1157
1158                # store the deleted item details so we can pass them on later
1159                deleted_names.append(item_to_delete.text())
1160                deleted_items.append(item_to_delete)
1161
1162                # Delete corresponding open plots
1163                self.closePlotsForItem(item_to_delete)
1164
1165                if item_to_delete.parent():
1166                    # We have a child item - delete from it
1167                    item_to_delete.parent().removeRow(row)
1168                else:
1169                    # delete directly from model
1170                    model.removeRow(row)
1171            indices = self.current_view.selectedIndexes()
1172
1173        # Let others know we deleted data
1174        self.communicator.dataDeletedSignal.emit(deleted_items)
1175
1176        # update stored_data
1177        self.manager.update_stored_data(deleted_names)
1178
1179    def closePlotsForItem(self, item):
1180        """
1181        Given standard item, close all its currently displayed plots
1182        """
1183        # item - HashableStandardItems of active plots
1184
1185        # {} -> 'Graph1' : HashableStandardItem()
1186        current_plot_items = {}
1187        for plot_name in PlotHelper.currentPlots():
1188            current_plot_items[plot_name] = PlotHelper.plotById(plot_name).item
1189
1190        # item and its hashable children
1191        items_being_deleted = []
1192        if item.rowCount() > 0:
1193            items_being_deleted = [item.child(n) for n in range(item.rowCount())
1194                                   if isinstance(item.child(n), GuiUtils.HashableStandardItem)]
1195        items_being_deleted.append(item)
1196        # Add the parent in case a child is selected
1197        if isinstance(item.parent(), GuiUtils.HashableStandardItem):
1198            items_being_deleted.append(item.parent())
1199
1200        # Compare plot items and items to delete
1201        plots_to_close = set(current_plot_items.values()) & set(items_being_deleted)
1202
1203        for plot_item in plots_to_close:
1204            for plot_name in current_plot_items.keys():
1205                if plot_item == current_plot_items[plot_name]:
1206                    plotter = PlotHelper.plotById(plot_name)
1207                    # try to delete the plot
1208                    try:
1209                        plotter.close()
1210                        #self.parent.workspace().removeSubWindow(plotter)
1211                        self.plot_widgets[plot_name].close()
1212                        self.plot_widgets.pop(plot_name, None)
1213                    except AttributeError as ex:
1214                        logging.error("Closing of %s failed:\n %s" % (plot_name, str(ex)))
1215
1216        pass # debugger anchor
1217
1218    def onAnalysisUpdate(self, new_perspective=""):
1219        """
1220        Update the perspective combo index based on passed string
1221        """
1222        assert new_perspective in Perspectives.PERSPECTIVES.keys()
1223        self.cbFitting.blockSignals(True)
1224        self.cbFitting.setCurrentIndex(self.cbFitting.findText(new_perspective))
1225        self.cbFitting.blockSignals(False)
1226        pass
1227
1228    def loadComplete(self, output):
1229        """
1230        Post message to status bar and update the data manager
1231        """
1232        assert isinstance(output, tuple)
1233
1234        # Reset the model so the view gets updated.
1235        #self.model.reset()
1236        self.communicator.progressBarUpdateSignal.emit(-1)
1237
1238        output_data = output[0]
1239        message = output[1]
1240        # Notify the manager of the new data available
1241        self.communicator.statusBarUpdateSignal.emit(message)
1242        self.communicator.fileDataReceivedSignal.emit(output_data)
1243        self.manager.add_data(data_list=output_data)
1244
1245    def loadFailed(self, reason):
1246        print("File Load Failed with:\n", reason)
1247        pass
1248
1249    def updateModel(self, data, p_file):
1250        """
1251        Add data and Info fields to the model item
1252        """
1253        # Structure of the model
1254        # checkbox + basename
1255        #     |-------> Data.D object
1256        #     |-------> Info
1257        #                 |----> Title:
1258        #                 |----> Run:
1259        #                 |----> Type:
1260        #                 |----> Path:
1261        #                 |----> Process
1262        #                          |-----> process[0].name
1263        #     |-------> THEORIES
1264
1265        # Top-level item: checkbox with label
1266        checkbox_item = GuiUtils.HashableStandardItem()
1267        checkbox_item.setCheckable(True)
1268        checkbox_item.setCheckState(QtCore.Qt.Checked)
1269        checkbox_item.setText(os.path.basename(p_file))
1270
1271        # Add the actual Data1D/Data2D object
1272        object_item = GuiUtils.HashableStandardItem()
1273        object_item.setData(data)
1274
1275        checkbox_item.setChild(0, object_item)
1276
1277        # Add rows for display in the view
1278        info_item = GuiUtils.infoFromData(data)
1279
1280        # Set info_item as the first child
1281        checkbox_item.setChild(1, info_item)
1282
1283        # Caption for the theories
1284        checkbox_item.setChild(2, QtGui.QStandardItem("THEORIES"))
1285
1286        # New row in the model
1287        self.model.beginResetModel()
1288        self.model.appendRow(checkbox_item)
1289        self.model.endResetModel()
1290
1291    def updateModelFromPerspective(self, model_item):
1292        """
1293        Receive an update model item from a perspective
1294        Make sure it is valid and if so, replace it in the model
1295        """
1296        # Assert the correct type
1297        if not isinstance(model_item, QtGui.QStandardItem):
1298            msg = "Wrong data type returned from calculations."
1299            raise AttributeError(msg)
1300
1301        # TODO: Assert other properties
1302
1303        # Reset the view
1304        ##self.model.reset()
1305        # Pass acting as a debugger anchor
1306        pass
1307
1308    def updateTheoryFromPerspective(self, model_item):
1309        """
1310        Receive an update theory item from a perspective
1311        Make sure it is valid and if so, replace/add in the model
1312        """
1313        # Assert the correct type
1314        if not isinstance(model_item, QtGui.QStandardItem):
1315            msg = "Wrong data type returned from calculations."
1316            raise AttributeError(msg)
1317
1318        # Check if there are any other items for this tab
1319        # If so, delete them
1320        current_tab_name = model_item.text()
1321        for current_index in range(self.theory_model.rowCount()):
1322            #if current_tab_name in self.theory_model.item(current_index).text():
1323            if current_tab_name == self.theory_model.item(current_index).text():
1324                self.theory_model.removeRow(current_index)
1325                break
1326
1327        # send in the new item
1328        self.theory_model.appendRow(model_item)
1329
1330    def deleteIntermediateTheoryPlotsByModelID(self, model_id):
1331        """Given a model's ID, deletes all items in the theory item model which reference the same ID. Useful in the
1332        case of intermediate results disappearing when changing calculations (in which case you don't want them to be
1333        retained in the list)."""
1334        items_to_delete = []
1335        for r in range(self.theory_model.rowCount()):
1336            item = self.theory_model.item(r, 0)
1337            data = item.child(0).data()
1338            if not hasattr(data, "id"):
1339                return
1340            match = GuiUtils.theory_plot_ID_pattern.match(data.id)
1341            if match:
1342                item_model_id = match.groups()[-1]
1343                if item_model_id == model_id:
1344                    # Only delete those identified as an intermediate plot
1345                    if match.groups()[2] not in (None, ""):
1346                        items_to_delete.append(item)
1347
1348        for item in items_to_delete:
1349            self.theory_model.removeRow(item.row())
Note: See TracBrowser for help on using the repository browser.