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

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 1738173 was 9f4eaeb, checked in by Laura Forster <Awork@…>, 6 years ago

Error Handling for crashing when 'append to' is selected

sometimes a model can appear in datafiles, and if no graph is already created, selecting append to can crash sasview
r Please enter the commit message for your changes. Lines starting

  • Property mode set to 100644
File size: 50.7 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.updatePlot)
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        new_plots = []
575        for item, plot in plots.items():
576            if self.updatePlot(plot):
577                # Don't create plots which are already displayed
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 result, if present (e.g. "[P(Q)]")
582            if match and match.groups()[1] != None:
583                continue
584            # Don't include plots from different fitpages, but always include the original data
585            if fitpage_name in plot.name or filename in plot.name or filename == plot.filename:
586                # Residuals get their own plot
587                if plot.plot_role == Data1D.ROLE_RESIDUAL:
588                    plot.yscale='linear'
589                    self.plotData([(item, plot)])
590                else:
591                    new_plots.append((item, plot))
592
593        if new_plots:
594            self.plotData(new_plots)
595
596    def displayData(self, data_list, id=None):
597        """
598        Forces display of charts for the given data set
599        """
600        plot_to_show = data_list[0]
601        # passed plot is used ONLY to figure out its title,
602        # so all the charts related by it can be pulled from
603        # the data explorer indices.
604        filename = plot_to_show.filename
605        self.displayFile(filename=filename, is_data=plot_to_show.is_data, id=id)
606
607    def addDataPlot2D(self, plot_set, item):
608        """
609        Create a new 2D plot and add it to the workspace
610        """
611        plot2D = Plotter2D(self)
612        plot2D.item = item
613        plot2D.plot(plot_set)
614        self.addPlot(plot2D)
615        self.active_plots[plot2D.data.name] = plot2D
616        #============================================
617        # Experimental hook for silx charts
618        #============================================
619        ## Attach silx
620        #from silx.gui import qt
621        #from silx.gui.plot import StackView
622        #sv = StackView()
623        #sv.setColormap("jet", autoscale=True)
624        #sv.setStack(plot_set.data.reshape(1,100,100))
625        ##sv.setLabels(["x: -10 to 10 (200 samples)",
626        ##              "y: -10 to 5 (150 samples)"])
627        #sv.show()
628        #============================================
629
630    def plotData(self, plots, transform=True):
631        """
632        Takes 1D/2D data and generates a single plot (1D) or multiple plots (2D)
633        """
634        # Call show on requested plots
635        # All same-type charts in one plot
636        for item, plot_set in plots:
637            if isinstance(plot_set, Data1D):
638                if not 'new_plot' in locals():
639                    new_plot = Plotter(self)
640                    new_plot.item = item
641                new_plot.plot(plot_set, transform=transform)
642                # active_plots may contain multiple charts
643                self.active_plots[plot_set.name] = new_plot
644            elif isinstance(plot_set, Data2D):
645                self.addDataPlot2D(plot_set, item)
646            else:
647                msg = "Incorrect data type passed to Plotting"
648                raise AttributeError(msg)
649
650        if 'new_plot' in locals() and \
651            hasattr(new_plot, 'data') and \
652            isinstance(new_plot.data, Data1D):
653                self.addPlot(new_plot)
654
655    def newPlot(self):
656        """
657        Select checked data and plot it
658        """
659        # Check which tab is currently active
660        if self.current_view == self.treeView:
661            plots = GuiUtils.plotsFromCheckedItems(self.model)
662        else:
663            plots = GuiUtils.plotsFromCheckedItems(self.theory_model)
664
665        self.plotData(plots)
666
667    def addPlot(self, new_plot):
668        """
669        Helper method for plot bookkeeping
670        """
671        # Update the global plot counter
672        title = str(PlotHelper.idOfPlot(new_plot))
673        new_plot.setWindowTitle(title)
674
675        # Set the object name to satisfy the Squish object picker
676        new_plot.setObjectName(title)
677
678        # Add the plot to the workspace
679        plot_widget = self.parent.workspace().addSubWindow(new_plot)
680
681        # Show the plot
682        new_plot.show()
683        new_plot.canvas.draw()
684
685        # Update the plot widgets dict
686        self.plot_widgets[title]=plot_widget
687
688        # Update the active chart list
689        self.active_plots[new_plot.data.name] = new_plot
690
691    def appendPlot(self):
692        """
693        Add data set(s) to the existing matplotlib chart
694        """
695        # new plot data; check which tab is currently active
696        if self.current_view == self.treeView:
697            new_plots = GuiUtils.plotsFromCheckedItems(self.model)
698        else:
699            new_plots = GuiUtils.plotsFromCheckedItems(self.theory_model)
700
701        # old plot data
702        plot_id = str(self.cbgraph.currentText())
703        try:
704            assert plot_id in PlotHelper.currentPlots(), "No such plot: %s"%(plot_id)
705        except:
706            return
707
708        old_plot = PlotHelper.plotById(plot_id)
709
710        # Add new data to the old plot, if data type is the same.
711        for _, plot_set in new_plots:
712            if type(plot_set) is type(old_plot._data):
713                old_plot.data = plot_set
714                old_plot.plot()
715                # need this for lookup - otherwise this plot will never update
716                self.active_plots[plot_set.name] = old_plot
717
718    def updatePlot(self, data):
719        """
720        Modify existing plot for immediate response and returns True.
721        Returns false, if the plot does not exist already.
722        """
723        try: # there might be a list or a single value being passed
724            data = data[0]
725        except TypeError:
726            pass
727        assert type(data).__name__ in ['Data1D', 'Data2D']
728
729        ids_keys = list(self.active_plots.keys())
730        ids_vals = [val.data.name for val in self.active_plots.values()]
731
732        data_id = data.name
733        if data_id in ids_keys:
734            # We have data, let's replace data that needs replacing
735            if data.plot_role != Data1D.ROLE_DATA:
736                self.active_plots[data_id].replacePlot(data_id, data)
737            return True
738        elif data_id in ids_vals:
739            if data.plot_role != Data1D.ROLE_DATA:
740                list(self.active_plots.values())[ids_vals.index(data_id)].replacePlot(data_id, data)
741            return True
742        return False
743
744    def chooseFiles(self):
745        """
746        Shows the Open file dialog and returns the chosen path(s)
747        """
748        # List of known extensions
749        wlist = self.getWlist()
750
751        # Location is automatically saved - no need to keep track of the last dir
752        # But only with Qt built-in dialog (non-platform native)
753        paths = QtWidgets.QFileDialog.getOpenFileNames(self, "Choose a file", "",
754                wlist, None, QtWidgets.QFileDialog.DontUseNativeDialog)[0]
755        if not paths:
756            return
757
758        if not isinstance(paths, list):
759            paths = [paths]
760
761        return paths
762
763    def readData(self, path):
764        """
765        verbatim copy-paste from
766           sasgui.guiframe.local_perspectives.data_loader.data_loader.py
767        slightly modified for clarity
768        """
769        message = ""
770        log_msg = ''
771        output = {}
772        any_error = False
773        data_error = False
774        error_message = ""
775        number_of_files = len(path)
776        self.communicator.progressBarUpdateSignal.emit(0.0)
777
778        for index, p_file in enumerate(path):
779            basename = os.path.basename(p_file)
780            _, extension = os.path.splitext(basename)
781            if extension.lower() in GuiUtils.EXTENSIONS:
782                any_error = True
783                log_msg = "Data Loader cannot "
784                log_msg += "load: %s\n" % str(p_file)
785                log_msg += """Please try to open that file from "open project" """
786                log_msg += """or "open analysis" menu\n"""
787                error_message = log_msg + "\n"
788                logging.info(log_msg)
789                continue
790
791            try:
792                message = "Loading Data... " + str(basename) + "\n"
793
794                # change this to signal notification in GuiManager
795                self.communicator.statusBarUpdateSignal.emit(message)
796
797                output_objects = self.loader.load(p_file)
798
799                # Some loaders return a list and some just a single Data1D object.
800                # Standardize.
801                if not isinstance(output_objects, list):
802                    output_objects = [output_objects]
803
804                for item in output_objects:
805                    # cast sascalc.dataloader.data_info.Data1D into
806                    # sasgui.guiframe.dataFitting.Data1D
807                    # TODO : Fix it
808                    new_data = self.manager.create_gui_data(item, p_file)
809                    output[new_data.id] = new_data
810
811                    # Model update should be protected
812                    self.mutex.lock()
813                    self.updateModel(new_data, p_file)
814                    #self.model.reset()
815                    QtWidgets.QApplication.processEvents()
816                    self.mutex.unlock()
817
818                    if hasattr(item, 'errors'):
819                        for error_data in item.errors:
820                            data_error = True
821                            message += "\tError: {0}\n".format(error_data)
822                    else:
823
824                        logging.error("Loader returned an invalid object:\n %s" % str(item))
825                        data_error = True
826
827            except Exception as ex:
828                logging.error(sys.exc_info()[1])
829
830                any_error = True
831            if any_error or error_message != "":
832                if error_message == "":
833                    error = "Error: " + str(sys.exc_info()[1]) + "\n"
834                    error += "while loading Data: \n%s\n" % str(basename)
835                    error_message += "The data file you selected could not be loaded.\n"
836                    error_message += "Make sure the content of your file"
837                    error_message += " is properly formatted.\n\n"
838                    error_message += "When contacting the SasView team, mention the"
839                    error_message += " following:\n%s" % str(error)
840                elif data_error:
841                    base_message = "Errors occurred while loading "
842                    base_message += "{0}\n".format(basename)
843                    base_message += "The data file loaded but with errors.\n"
844                    error_message = base_message + error_message
845                else:
846                    error_message += "%s\n" % str(p_file)
847
848            current_percentage = int(100.0* index/number_of_files)
849            self.communicator.progressBarUpdateSignal.emit(current_percentage)
850
851        if any_error or error_message:
852            logging.error(error_message)
853            status_bar_message = "Errors occurred while loading %s" % format(basename)
854            self.communicator.statusBarUpdateSignal.emit(status_bar_message)
855
856        else:
857            message = "Loading Data Complete! "
858        message += log_msg
859        # Notify the progress bar that the updates are over.
860        self.communicator.progressBarUpdateSignal.emit(-1)
861        self.communicator.statusBarUpdateSignal.emit(message)
862
863        return output, message
864
865    def getWlist(self):
866        """
867        Wildcards of files we know the format of.
868        """
869        # Display the Qt Load File module
870        cards = self.loader.get_wildcards()
871
872        # get rid of the wx remnant in wildcards
873        # TODO: modify sasview loader get_wildcards method, after merge,
874        # so this kludge can be avoided
875        new_cards = []
876        for item in cards:
877            new_cards.append(item[:item.find("|")])
878        wlist = ';;'.join(new_cards)
879
880        return wlist
881
882    def setItemsCheckability(self, model, dimension=None, checked=False):
883        """
884        For a given model, check or uncheck all items of given dimension
885        """
886        mode = QtCore.Qt.Checked if checked else QtCore.Qt.Unchecked
887
888        assert isinstance(checked, bool)
889
890        types = (None, Data1D, Data2D)
891        assert dimension in types
892
893        for index in range(model.rowCount()):
894            item = model.item(index)
895            if dimension is not None and not isinstance(GuiUtils.dataFromItem(item), dimension):
896                continue
897            if item.isCheckable() and item.checkState() != mode:
898                item.setCheckState(mode)
899            # look for all children
900            for inner_index in range(item.rowCount()):
901                child = item.child(inner_index)
902                if child.isCheckable() and child.checkState() != mode:
903                    child.setCheckState(mode)
904
905    def selectData(self, index):
906        """
907        Callback method for modifying the TreeView on Selection Options change
908        """
909        if not isinstance(index, int):
910            msg = "Incorrect type passed to DataExplorer.selectData()"
911            raise AttributeError(msg)
912
913        # Respond appropriately
914        if index == 0:
915            self.setItemsCheckability(self.model, checked=True)
916
917        elif index == 1:
918            # De-select All
919            self.setItemsCheckability(self.model, checked=False)
920
921        elif index == 2:
922            # Select All 1-D
923            self.setItemsCheckability(self.model, dimension=Data1D, checked=True)
924
925        elif index == 3:
926            # Unselect All 1-D
927            self.setItemsCheckability(self.model, dimension=Data1D, checked=False)
928
929        elif index == 4:
930            # Select All 2-D
931            self.setItemsCheckability(self.model, dimension=Data2D, checked=True)
932
933        elif index == 5:
934            # Unselect All 2-D
935            self.setItemsCheckability(self.model, dimension=Data2D, checked=False)
936
937        else:
938            msg = "Incorrect value in the Selection Option"
939            # Change this to a proper logging action
940            raise Exception(msg)
941
942    def contextMenu(self):
943        """
944        Define actions and layout of the right click context menu
945        """
946        # Create a custom menu based on actions defined in the UI file
947        self.context_menu = QtWidgets.QMenu(self)
948        self.context_menu.addAction(self.actionDataInfo)
949        self.context_menu.addAction(self.actionSaveAs)
950        self.context_menu.addAction(self.actionQuickPlot)
951        self.context_menu.addSeparator()
952        self.context_menu.addAction(self.actionQuick3DPlot)
953        self.context_menu.addAction(self.actionEditMask)
954        #self.context_menu.addSeparator()
955        #self.context_menu.addAction(self.actionFreezeResults)
956        self.context_menu.addSeparator()
957        self.context_menu.addAction(self.actionDelete)
958
959
960        # Define the callbacks
961        self.actionDataInfo.triggered.connect(self.showDataInfo)
962        self.actionSaveAs.triggered.connect(self.saveDataAs)
963        self.actionQuickPlot.triggered.connect(self.quickDataPlot)
964        self.actionQuick3DPlot.triggered.connect(self.quickData3DPlot)
965        self.actionEditMask.triggered.connect(self.showEditDataMask)
966        self.actionDelete.triggered.connect(self.deleteItem)
967        self.actionFreezeResults.triggered.connect(self.freezeSelectedItems)
968
969    def onCustomContextMenu(self, position):
970        """
971        Show the right-click context menu in the data treeview
972        """
973        index = self.current_view.indexAt(position)
974        proxy = self.current_view.model()
975        model = proxy.sourceModel()
976
977        if not index.isValid():
978            return
979        model_item = model.itemFromIndex(proxy.mapToSource(index))
980        # Find the mapped index
981        orig_index = model_item.isCheckable()
982        if not orig_index:
983            return
984        # Check the data to enable/disable actions
985        is_2D = isinstance(GuiUtils.dataFromItem(model_item), Data2D)
986        self.actionQuick3DPlot.setEnabled(is_2D)
987        self.actionEditMask.setEnabled(is_2D)
988
989        # Freezing
990        # check that the selection has inner items
991        freeze_enabled = False
992        if model_item.parent() is not None:
993            freeze_enabled = True
994        self.actionFreezeResults.setEnabled(freeze_enabled)
995
996        # Fire up the menu
997        self.context_menu.exec_(self.current_view.mapToGlobal(position))
998
999    def showDataInfo(self):
1000        """
1001        Show a simple read-only text edit with data information.
1002        """
1003        index = self.current_view.selectedIndexes()[0]
1004        proxy = self.current_view.model()
1005        model = proxy.sourceModel()
1006        model_item = model.itemFromIndex(proxy.mapToSource(index))
1007
1008        data = GuiUtils.dataFromItem(model_item)
1009        if isinstance(data, Data1D):
1010            text_to_show = GuiUtils.retrieveData1d(data)
1011            # Hardcoded sizes to enable full width rendering with default font
1012            self.txt_widget.resize(420,600)
1013        else:
1014            text_to_show = GuiUtils.retrieveData2d(data)
1015            # Hardcoded sizes to enable full width rendering with default font
1016            self.txt_widget.resize(700,600)
1017
1018        self.txt_widget.setReadOnly(True)
1019        self.txt_widget.setWindowFlags(QtCore.Qt.Window)
1020        self.txt_widget.setWindowIcon(QtGui.QIcon(":/res/ball.ico"))
1021        self.txt_widget.setWindowTitle("Data Info: %s" % data.filename)
1022        self.txt_widget.clear()
1023        self.txt_widget.insertPlainText(text_to_show)
1024
1025        self.txt_widget.show()
1026        # Move the slider all the way up, if present
1027        vertical_scroll_bar = self.txt_widget.verticalScrollBar()
1028        vertical_scroll_bar.triggerAction(QtWidgets.QScrollBar.SliderToMinimum)
1029
1030    def saveDataAs(self):
1031        """
1032        Save the data points as either txt or xml
1033        """
1034        index = self.current_view.selectedIndexes()[0]
1035        proxy = self.current_view.model()
1036        model = proxy.sourceModel()
1037        model_item = model.itemFromIndex(proxy.mapToSource(index))
1038
1039        data = GuiUtils.dataFromItem(model_item)
1040        if isinstance(data, Data1D):
1041            GuiUtils.saveData1D(data)
1042        else:
1043            GuiUtils.saveData2D(data)
1044
1045    def quickDataPlot(self):
1046        """
1047        Frozen plot - display an image of the plot
1048        """
1049        index = self.current_view.selectedIndexes()[0]
1050        proxy = self.current_view.model()
1051        model = proxy.sourceModel()
1052        model_item = model.itemFromIndex(proxy.mapToSource(index))
1053
1054        data = GuiUtils.dataFromItem(model_item)
1055
1056        method_name = 'Plotter'
1057        if isinstance(data, Data2D):
1058            method_name='Plotter2D'
1059
1060        self.new_plot = globals()[method_name](self, quickplot=True)
1061        self.new_plot.data = data
1062        #new_plot.plot(marker='o')
1063        self.new_plot.plot()
1064
1065        # Update the global plot counter
1066        title = "Plot " + data.name
1067        self.new_plot.setWindowTitle(title)
1068
1069        # Show the plot
1070        self.new_plot.show()
1071
1072    def quickData3DPlot(self):
1073        """
1074        Slowish 3D plot
1075        """
1076        index = self.current_view.selectedIndexes()[0]
1077        proxy = self.current_view.model()
1078        model = proxy.sourceModel()
1079        model_item = model.itemFromIndex(proxy.mapToSource(index))
1080
1081        data = GuiUtils.dataFromItem(model_item)
1082
1083        self.new_plot = Plotter2D(self, quickplot=True, dimension=3)
1084        self.new_plot.data = data
1085        self.new_plot.plot()
1086
1087        # Update the global plot counter
1088        title = "Plot " + data.name
1089        self.new_plot.setWindowTitle(title)
1090
1091        # Show the plot
1092        self.new_plot.show()
1093
1094    def extShowEditDataMask(self):
1095        self.showEditDataMask()
1096
1097    def showEditDataMask(self, data=None):
1098        """
1099        Mask Editor for 2D plots
1100        """
1101        try:
1102            if data is None or not isinstance(data, Data2D):
1103                index = self.current_view.selectedIndexes()[0]
1104                proxy = self.current_view.model()
1105                model = proxy.sourceModel()
1106                model_item = model.itemFromIndex(proxy.mapToSource(index))
1107
1108                data = GuiUtils.dataFromItem(model_item)
1109
1110            if data is None or not isinstance(data, Data2D):
1111                msg = QtWidgets.QMessageBox()
1112                msg.setIcon(QtWidgets.QMessageBox.Information)
1113                msg.setText("Error: cannot apply mask. \
1114                                Please select a 2D dataset.")
1115                msg.setStandardButtons(QtWidgets.QMessageBox.Cancel)
1116                msg.exec_()
1117                return
1118        except:
1119            msg = QtWidgets.QMessageBox()
1120            msg.setIcon(QtWidgets.QMessageBox.Information)
1121            msg.setText("Error: No dataset selected. \
1122                            Please select a 2D dataset.")
1123            msg.setStandardButtons(QtWidgets.QMessageBox.Cancel)
1124            msg.exec_()
1125            return
1126
1127        mask_editor = MaskEditor(self, data)
1128        # Modal dialog here.
1129        mask_editor.exec_()
1130
1131    def freezeItem(self, item=None):
1132        """
1133        Freeze given item
1134        """
1135        if item is None:
1136            return
1137        self.model.beginResetModel()
1138        new_item = self.cloneTheory(item)
1139        self.model.appendRow(new_item)
1140        self.model.endResetModel()
1141
1142    def freezeSelectedItems(self):
1143        """
1144        Freeze selected items
1145        """
1146        indices = self.treeView.selectedIndexes()
1147
1148        proxy = self.treeView.model()
1149        model = proxy.sourceModel()
1150
1151        for index in indices:
1152            row_index = proxy.mapToSource(index)
1153            item_to_copy = model.itemFromIndex(row_index)
1154            if item_to_copy and item_to_copy.isCheckable():
1155                self.freezeItem(item_to_copy)
1156
1157    def deleteItem(self):
1158        """
1159        Delete the current item
1160        """
1161        # Assure this is indeed wanted
1162        delete_msg = "This operation will delete the selected data sets " +\
1163                     "and all the dependents." +\
1164                     "\nDo you want to continue?"
1165        reply = QtWidgets.QMessageBox.question(self,
1166                                           'Warning',
1167                                           delete_msg,
1168                                           QtWidgets.QMessageBox.Yes,
1169                                           QtWidgets.QMessageBox.No)
1170
1171        if reply == QtWidgets.QMessageBox.No:
1172            return
1173
1174        # Every time a row is removed, the indices change, so we'll just remove
1175        # rows and keep calling selectedIndexes until it returns an empty list.
1176        indices = self.current_view.selectedIndexes()
1177
1178        proxy = self.current_view.model()
1179        model = proxy.sourceModel()
1180
1181        deleted_items = []
1182        deleted_names = []
1183
1184        while len(indices) > 0:
1185            index = indices[0]
1186            row_index = proxy.mapToSource(index)
1187            item_to_delete = model.itemFromIndex(row_index)
1188            if item_to_delete and item_to_delete.isCheckable():
1189                row = row_index.row()
1190
1191                # store the deleted item details so we can pass them on later
1192                deleted_names.append(item_to_delete.text())
1193                deleted_items.append(item_to_delete)
1194
1195                # Delete corresponding open plots
1196                self.closePlotsForItem(item_to_delete)
1197
1198                if item_to_delete.parent():
1199                    # We have a child item - delete from it
1200                    item_to_delete.parent().removeRow(row)
1201                else:
1202                    # delete directly from model
1203                    model.removeRow(row)
1204            indices = self.current_view.selectedIndexes()
1205
1206        # Let others know we deleted data
1207        self.communicator.dataDeletedSignal.emit(deleted_items)
1208
1209        # update stored_data
1210        self.manager.update_stored_data(deleted_names)
1211
1212    def closePlotsForItem(self, item):
1213        """
1214        Given standard item, close all its currently displayed plots
1215        """
1216        # item - HashableStandardItems of active plots
1217
1218        # {} -> 'Graph1' : HashableStandardItem()
1219        current_plot_items = {}
1220        for plot_name in PlotHelper.currentPlots():
1221            current_plot_items[plot_name] = PlotHelper.plotById(plot_name).item
1222
1223        # item and its hashable children
1224        items_being_deleted = []
1225        if item.rowCount() > 0:
1226            items_being_deleted = [item.child(n) for n in range(item.rowCount())
1227                                   if isinstance(item.child(n), GuiUtils.HashableStandardItem)]
1228        items_being_deleted.append(item)
1229        # Add the parent in case a child is selected
1230        if isinstance(item.parent(), GuiUtils.HashableStandardItem):
1231            items_being_deleted.append(item.parent())
1232
1233        # Compare plot items and items to delete
1234        plots_to_close = set(current_plot_items.values()) & set(items_being_deleted)
1235
1236        for plot_item in plots_to_close:
1237            for plot_name in current_plot_items.keys():
1238                if plot_item == current_plot_items[plot_name]:
1239                    plotter = PlotHelper.plotById(plot_name)
1240                    # try to delete the plot
1241                    try:
1242                        plotter.close()
1243                        #self.parent.workspace().removeSubWindow(plotter)
1244                        self.plot_widgets[plot_name].close()
1245                        self.plot_widgets.pop(plot_name, None)
1246                    except AttributeError as ex:
1247                        logging.error("Closing of %s failed:\n %s" % (plot_name, str(ex)))
1248
1249        pass # debugger anchor
1250
1251    def onAnalysisUpdate(self, new_perspective=""):
1252        """
1253        Update the perspective combo index based on passed string
1254        """
1255        assert new_perspective in Perspectives.PERSPECTIVES.keys()
1256        self.cbFitting.blockSignals(True)
1257        self.cbFitting.setCurrentIndex(self.cbFitting.findText(new_perspective))
1258        self.cbFitting.blockSignals(False)
1259        pass
1260
1261    def loadComplete(self, output):
1262        """
1263        Post message to status bar and update the data manager
1264        """
1265        assert isinstance(output, tuple)
1266
1267        # Reset the model so the view gets updated.
1268        #self.model.reset()
1269        self.communicator.progressBarUpdateSignal.emit(-1)
1270
1271        output_data = output[0]
1272        message = output[1]
1273        # Notify the manager of the new data available
1274        self.communicator.statusBarUpdateSignal.emit(message)
1275        self.communicator.fileDataReceivedSignal.emit(output_data)
1276        self.manager.add_data(data_list=output_data)
1277
1278    def loadFailed(self, reason):
1279        print("File Load Failed with:\n", reason)
1280        pass
1281
1282    def updateModel(self, data, p_file):
1283        """
1284        Add data and Info fields to the model item
1285        """
1286        # Structure of the model
1287        # checkbox + basename
1288        #     |-------> Data.D object
1289        #     |-------> Info
1290        #                 |----> Title:
1291        #                 |----> Run:
1292        #                 |----> Type:
1293        #                 |----> Path:
1294        #                 |----> Process
1295        #                          |-----> process[0].name
1296        #     |-------> THEORIES
1297
1298        # Top-level item: checkbox with label
1299        checkbox_item = GuiUtils.HashableStandardItem()
1300        checkbox_item.setCheckable(True)
1301        checkbox_item.setCheckState(QtCore.Qt.Checked)
1302        checkbox_item.setText(os.path.basename(p_file))
1303
1304        # Add the actual Data1D/Data2D object
1305        object_item = GuiUtils.HashableStandardItem()
1306        object_item.setData(data)
1307
1308        checkbox_item.setChild(0, object_item)
1309
1310        # Add rows for display in the view
1311        info_item = GuiUtils.infoFromData(data)
1312
1313        # Set info_item as the first child
1314        checkbox_item.setChild(1, info_item)
1315
1316        # Caption for the theories
1317        checkbox_item.setChild(2, QtGui.QStandardItem("FIT RESULTS"))
1318
1319        # New row in the model
1320        self.model.beginResetModel()
1321        self.model.appendRow(checkbox_item)
1322        self.model.endResetModel()
1323
1324    def updateModelFromPerspective(self, model_item):
1325        """
1326        Receive an update model item from a perspective
1327        Make sure it is valid and if so, replace it in the model
1328        """
1329        # Assert the correct type
1330        if not isinstance(model_item, QtGui.QStandardItem):
1331            msg = "Wrong data type returned from calculations."
1332            raise AttributeError(msg)
1333
1334        # TODO: Assert other properties
1335
1336        # Reset the view
1337        ##self.model.reset()
1338        # Pass acting as a debugger anchor
1339        pass
1340
1341    def updateTheoryFromPerspective(self, model_item):
1342        """
1343        Receive an update theory item from a perspective
1344        Make sure it is valid and if so, replace/add in the model
1345        """
1346        # Assert the correct type
1347        if not isinstance(model_item, QtGui.QStandardItem):
1348            msg = "Wrong data type returned from calculations."
1349            raise AttributeError(msg)
1350
1351        # Check if there are any other items for this tab
1352        # If so, delete them
1353        current_tab_name = model_item.text()
1354        for current_index in range(self.theory_model.rowCount()):
1355            #if current_tab_name in self.theory_model.item(current_index).text():
1356            if current_tab_name == self.theory_model.item(current_index).text():
1357                self.theory_model.removeRow(current_index)
1358                break
1359
1360        # send in the new item
1361        self.theory_model.appendRow(model_item)
1362
1363    def deleteIntermediateTheoryPlotsByModelID(self, model_id):
1364        """Given a model's ID, deletes all items in the theory item model which reference the same ID. Useful in the
1365        case of intermediate results disappearing when changing calculations (in which case you don't want them to be
1366        retained in the list)."""
1367        items_to_delete = []
1368        for r in range(self.theory_model.rowCount()):
1369            item = self.theory_model.item(r, 0)
1370            data = item.child(0).data()
1371            if not hasattr(data, "id"):
1372                return
1373            match = GuiUtils.theory_plot_ID_pattern.match(data.id)
1374            if match:
1375                item_model_id = match.groups()[-1]
1376                if item_model_id == model_id:
1377                    # Only delete those identified as an intermediate plot
1378                    if match.groups()[2] not in (None, ""):
1379                        items_to_delete.append(item)
1380
1381        for item in items_to_delete:
1382            self.theory_model.removeRow(item.row())
Note: See TracBrowser for help on using the repository browser.