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

ESS_GUIESS_GUI_batch_fittingESS_GUI_bumps_abstractionESS_GUI_iss1116ESS_GUI_iss879ESS_GUI_openclESS_GUI_orderingESS_GUI_sync_sascalc
Last change on this file since fd7ef36 was fd7ef36, checked in by Torin Cooper-Bennun <torin.cooper-bennun@…>, 11 months ago

delete intermediate theory-only plots after model evaluation, before adding current ones

this applies only to beta approximation, whereby plots such as beta(Q)
and S_eff(Q) may be removed between calculations. however, since it does
not affect behaviour otherwise, I am pushing to ESS_GUI to ensure no
later conflicts occur

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