diff --git a/src/sas/qtgui/Calculators/DataOperationUtilityPanel.py b/src/sas/qtgui/Calculators/DataOperationUtilityPanel.py index 2b42442340..f2c4afb1dc 100644 --- a/src/sas/qtgui/Calculators/DataOperationUtilityPanel.py +++ b/src/sas/qtgui/Calculators/DataOperationUtilityPanel.py @@ -88,9 +88,9 @@ def updateCombobox(self, filenames): list_datafiles = [] for key_id in list(filenames.keys()): - if filenames[key_id].title: + if filenames[key_id].name: # filenames with titles - new_title = filenames[key_id].title + new_title = filenames[key_id].name list_datafiles.append(new_title) self.list_data_items.append(new_title) @@ -355,8 +355,8 @@ def _findId(self, name): for key_id in list(self.filenames.keys()): # data with title - if self.filenames[key_id].title: - input = self.filenames[key_id].title + if self.filenames[key_id].name: + input = self.filenames[key_id].name # data without title else: input = str(key_id) diff --git a/src/sas/qtgui/MainWindow/DataExplorer.py b/src/sas/qtgui/MainWindow/DataExplorer.py index 1c25b54a57..aab3d9b8ae 100644 --- a/src/sas/qtgui/MainWindow/DataExplorer.py +++ b/src/sas/qtgui/MainWindow/DataExplorer.py @@ -30,7 +30,8 @@ import sas.qtgui.Perspectives as Perspectives DEFAULT_PERSPECTIVE = "Fitting" -ANALYSIS_TYPES = ['Fitting (*.fitv)', 'Inversion (*.pr)', 'All File (*.*)'] +ANALYSIS_TYPES = ['Fitting (*.fitv)', 'Inversion (*.pr)', 'Invariant (*.inv)', + 'Corfunc (*.crf)', 'All Files (*.*)'] logger = logging.getLogger(__name__) @@ -270,14 +271,11 @@ def loadProject(self): filename = QtWidgets.QFileDialog.getOpenFileName(**kwargs)[0] if filename: self.default_project_location = os.path.dirname(filename) - # Inversion perspective will remove all data with delete + # Delete all data and initialize all perspectives self.deleteAllItems() - # Currently project load is available only for fitting - if self.cbFitting.currentText != DEFAULT_PERSPECTIVE: - self.cbFitting.setCurrentIndex( - self.cbFitting.findText(DEFAULT_PERSPECTIVE)) - # delete all (including the default) tabs - self._perspective().deleteAllTabs() + self.cbFitting.disconnect() + self.parent.loadAllPerspectives() + self.initPerspectives() self.readProject(filename) def loadAnalysis(self): @@ -512,9 +510,11 @@ def readProject(self, filename): grid_page.append(grid_name) self.parent.showBatchOutput(grid_page) continue + # Store constraint pages until all individual fits are open if 'cs_tab' in key: cs_keys.append(key) continue + # Load last visible perspective as stored in project file if 'visible_perspective' in key: visible_perspective = value # send newly created items to the perspective @@ -598,6 +598,14 @@ def updatePerspectiveWithProperties(self, key, value): params = value['pr_params'] self.sendItemToPerspective(items[0]) self._perspective().updateFromParameters(params) + if 'invar_params' in value: + self.cbFitting.setCurrentIndex(self.cbFitting.findText('Invariant')) + self.sendItemToPerspective(items[0]) + self._perspective().updateFromParameters(value['invar_params']) + if 'corfunc_params' in value: + self.cbFitting.setCurrentIndex(self.cbFitting.findText('Corfunc')) + self.sendItemToPerspective(items[0]) + self._perspective().updateFromParameters(value['corfunc_params']) if 'cs_tab' in key and 'is_constraint' in value: # Create a C&S page self._perspective().addConstraintTab() @@ -1679,6 +1687,7 @@ def deleteAllItems(self): self.communicator.dataDeletedSignal.emit(deleted_items) # update stored_data self.manager.update_stored_data(deleted_names) + self.manager.delete_data(data_id=[], theory_id=[], delete_all=True) # Clear the model self.model.clear() diff --git a/src/sas/qtgui/MainWindow/GuiManager.py b/src/sas/qtgui/MainWindow/GuiManager.py index ea4414ab33..c4a344076f 100644 --- a/src/sas/qtgui/MainWindow/GuiManager.py +++ b/src/sas/qtgui/MainWindow/GuiManager.py @@ -97,6 +97,7 @@ def __init__(self, parent=None): # Currently displayed perspective self._current_perspective = None + self.loadedPerspectives = {} # Populate the main window with stuff self.addWidgets() @@ -126,11 +127,7 @@ def addWidgets(self): Populate the main window with widgets """ # Preload all perspectives - loaded_dict = {} - for name, perspective in Perspectives.PERSPECTIVES.items(): - loaded_perspective = perspective(parent=self) - loaded_dict[name] = loaded_perspective - self.loadedPerspectives = loaded_dict + self.loadAllPerspectives() # Add FileDialog widget as docked self.filesWidget = DataExplorerWindow(self._parent, self, manager=self._data_manager) @@ -187,6 +184,32 @@ def addWidgets(self): self.DataOperation = DataOperationUtilityPanel(self) self.FileConverter = FileConverterWidget(self) + def loadAllPerspectives(self): + # Close any existing perspectives to prevent multiple open instances + self.closeAllPerspectives() + # Load all perspectives + loaded_dict = {} + for name, perspective in Perspectives.PERSPECTIVES.items(): + try: + loaded_perspective = perspective(parent=self) + loaded_dict[name] = loaded_perspective + except Exception as e: + logger.log(f"Unable to load {name} perspective.\n{e}") + self.loadedPerspectives = loaded_dict + + def closeAllPerspectives(self): + # Close all perspectives if they are open + if isinstance(self.loadedPerspectives, dict): + for name, perspective in self.loadedPerspectives.items(): + try: + perspective.setClosable(True) + self._workspace.workspace.removeSubWindow(self.subwindow) + perspective.close() + except Exception as e: + logger.log(f"Unable to close {name} perspective\n{e}") + self.loadedPerspectives = {} + self._current_perspective = None + def addCategories(self): """ Make sure categories.json exists and if not compile it and install in ~/.sasview @@ -661,7 +684,7 @@ def actionSave_Project(self): # Save from all serializable perspectives # Analysis should return {data-id: serialized-state} for name, per in self.loadedPerspectives.items(): - if hasattr(per, 'isSerializable') and per.isSerializable: + if hasattr(per, 'isSerializable') and per.isSerializable(): analysis = per.serializeAll() for key, value in analysis.items(): if key in final_data: diff --git a/src/sas/qtgui/Perspectives/Corfunc/CorfuncPerspective.py b/src/sas/qtgui/Perspectives/Corfunc/CorfuncPerspective.py index 1c68d66ec1..5f1d7b99cd 100644 --- a/src/sas/qtgui/Perspectives/Corfunc/CorfuncPerspective.py +++ b/src/sas/qtgui/Perspectives/Corfunc/CorfuncPerspective.py @@ -189,6 +189,7 @@ def draw_real_space(self): class CorfuncWindow(QtWidgets.QDialog, Ui_CorfuncDialog): """Displays the correlation function analysis of sas data.""" name = "Corfunc" # For displaying in the combo box + ext = " crf" # File extension used for saving analysis files trigger = QtCore.pyqtSignal(tuple) @@ -203,10 +204,12 @@ def __init__(self, parent=None): self.mapper = None self._path = "" self.model = QtGui.QStandardItemModel(self) - self.communicate = GuiUtils.Communicate() + self.communicate = self.parent.communicator() + self.communicate.dataDeletedSignal.connect(self.removeData) self._calculator = CorfuncCalculator() self._allow_close = False self._model_item = None + self.has_data = False self.txtLowerQMin.setText("0.0") self.txtLowerQMin.setEnabled(False) @@ -230,6 +233,12 @@ def __init__(self, parent=None): # Set up the mapper self.setup_mapper() + def isSerializable(self): + """ + Tell the caller that this perspective writes its state + """ + return True + def setup_slots(self): """Connect the buttons to their appropriate slots.""" self.cmdExtrapolate.clicked.connect(self.extrapolate) @@ -280,6 +289,29 @@ def setup_model(self): self.model.setItem(W.W_POLY, QtGui.QStandardItem(str(0))) self.model.setItem(W.W_PERIOD, QtGui.QStandardItem(str(0))) + def removeData(self, data_list=None): + """Remove the existing data reference from the Invariant Persepective""" + if not data_list or self._model_item not in data_list: + return + # Clear data plots + self._canvas.data = None + self._canvas.extrap = None + self._realplot.data = None + self._realplot.extrap = None + # Clear calculator, model, and data path + self._calculator = CorfuncCalculator() + self._model_item = None + self.has_data = False + self._path = "" + # Pass an empty dictionary to set all inputs to their default values + self.updateFromParameters({}) + # Disable buttons to return to base state + self.cmdExtrapolate.setEnabled(False) + self.cmdTransform.setEnabled(False) + self.cmdExtract.setEnabled(False) + self.cmdSave.setEnabled(False) + self.cmdCalculateBg.setEnabled(False) + def model_changed(self, _): """Actions to perform when the data is updated""" if not self.mapper: @@ -475,9 +507,17 @@ def setData(self, data_item, is_batch=False): msg = "Incorrect type passed to the Corfunc Perspective" raise AttributeError(msg) + if self.has_data: + msg = "Data is already loaded into the Corfunc perspective. Sending a new data set " + msg += f"will remove the Corfunc analysis for {self._path}. Continue?" + dialog = QtWidgets.QMessageBox(self, text=msg) + dialog.setStandardButtons(QtWidgets.QMessageBox.Ok | QtWidgets.QMessageBox.Cancel) + retval = dialog.exec_() + if retval == QtWidgets.QMessageBox.Cancel: + return + model_item = data_item[0] data = GuiUtils.dataFromItem(model_item) - self._path = data.name self._model_item = model_item self._calculator.set_data(data) self.cmdCalculateBg.setEnabled(True) @@ -494,16 +534,17 @@ def setData(self, data_item, is_batch=False): self.model.setItem(W.W_CRYSTAL, QtGui.QStandardItem("")) self.model.setItem(W.W_POLY, QtGui.QStandardItem("")) self.model.setItem(W.W_PERIOD, QtGui.QStandardItem("")) - self.model.setItem(W.W_FILENAME, QtGui.QStandardItem(self._path)) self.model.itemChanged.connect(self.model_changed) self._canvas.data = data self._canvas.extrap = None self.model_changed(None) self.cmdTransform.setEnabled(False) + self._path = data.name + self.model.setItem(W.W_FILENAME, QtGui.QStandardItem(self._path)) self._realplot.data = None self._realplot.draw_real_space() - + self.has_data = True def setClosable(self, value=True): """ @@ -520,8 +561,8 @@ def closeEvent(self, event): if self._allow_close: # reset the closability flag self.setClosable(value=False) - # Tell the MdiArea to close the container - if self.parent: + # Tell the MdiArea to close the container if it is visible + if self.parentWidget(): self.parentWidget().close() event.accept() else: @@ -556,3 +597,111 @@ def on_save(self): np.savetxt(outfile, np.vstack([(data1.x, data1.y, data3.y, data_idf.y)]).T) # pylint: enable=invalid-name + + def serializeAll(self): + """ + Serialize the corfunc state so data can be saved + Corfunc is not batch-ready so this will only effect a single page + :return: {data-id: {self.name: {corfunc-state}}} + """ + return self.serializeCurrentPage() + + def serializeCurrentPage(self): + """ + Serialize and return a dictionary of {data_id: corfunc-state} + Return empty dictionary if no data + :return: {data-id: {self.name: {corfunc-state}}} + """ + state = {} + if self.has_data: + tab_data = self.getPage() + data_id = tab_data.pop('data_id', '') + state[data_id] = {'corfunc_params': tab_data} + return state + + def getPage(self): + """ + Serializes full state of this corfunc page + Called by Save Analysis + :return: {corfunc-state} + """ + # Get all parameters from page + data = GuiUtils.dataFromItem(self._model_item) + param_dict = self.getState() + param_dict['data_name'] = str(data.name) + param_dict['data_id'] = str(data.id) + return param_dict + + def getState(self): + """ + Collects all active params into a dictionary of {name: value} + :return: {name: value} + """ + return { + 'guinier_a': self.txtGuinierA.text(), + 'guinier_b': self.txtGuinierB.text(), + 'porod_k': self.txtPorodK.text(), + 'porod_sigma': self.txtPorodSigma.text(), + 'avg_core_thick': self.txtAvgCoreThick.text(), + 'avg_inter_thick': self.txtAvgIntThick.text(), + 'avg_hard_block_thick': self.txtAvgHardBlock.text(), + 'local_crystalinity': self.txtLocalCrystal.text(), + 'polydispersity': self.txtPolydisp.text(), + 'long_period': self.txtLongPeriod.text(), + 'lower_q_max': self.txtLowerQMax.text(), + 'upper_q_min': self.txtUpperQMin.text(), + 'upper_q_max': self.txtUpperQMax.text(), + 'background': self.txtBackground.text(), + } + + def updateFromParameters(self, params): + """ + Called by Open Project, Open Analysis, and removeData + :param params: {param_name: value} -> Default values used if not valid + :return: None + """ + # Params should be a dictionary + if not isinstance(params, dict): + c_name = params.__class__.__name__ + msg = "Corfunc.updateFromParameters expects a dictionary" + raise TypeError(f"{msg}: {c_name} received") + # Assign values to 'Invariant' tab inputs - use defaults if not found + self.model.setItem( + W.W_GUINIERA, QtGui.QStandardItem(params.get('guinier_a', '0.0'))) + self.model.setItem( + W.W_GUINIERB, QtGui.QStandardItem(params.get('guinier_b', '0.0'))) + self.model.setItem( + W.W_PORODK, QtGui.QStandardItem(params.get('porod_k', '0.0'))) + self.model.setItem(W.W_PORODSIGMA, QtGui.QStandardItem( + params.get('porod_sigma', '0.0'))) + self.model.setItem(W.W_CORETHICK, QtGui.QStandardItem( + params.get('avg_core_thick', '0'))) + self.model.setItem(W.W_INTTHICK, QtGui.QStandardItem( + params.get('avg_inter_thick', '0'))) + self.model.setItem(W.W_HARDBLOCK, QtGui.QStandardItem( + params.get('avg_hard_block_thick', '0'))) + self.model.setItem(W.W_CRYSTAL, QtGui.QStandardItem( + params.get('local_crystalinity', '0'))) + self.model.setItem( + W.W_POLY, QtGui.QStandardItem(params.get('polydispersity', '0'))) + self.model.setItem( + W.W_PERIOD, QtGui.QStandardItem(params.get('long_period', '0'))) + self.model.setItem( + W.W_FILENAME, QtGui.QStandardItem(params.get('data_name', ''))) + self.model.setItem( + W.W_QMIN, QtGui.QStandardItem(params.get('lower_q_max', '0.01'))) + self.model.setItem( + W.W_QMAX, QtGui.QStandardItem(params.get('upper_q_min', '0.20'))) + self.model.setItem( + W.W_QCUTOFF, QtGui.QStandardItem(params.get('upper_q_max', '0.22'))) + self.model.setItem(W.W_BACKGROUND, QtGui.QStandardItem( + params.get('background', '0'))) + self.cmdCalculateBg.setEnabled(params.get('background', '0') != '0') + self.cmdSave.setEnabled(params.get('guinier_a', '0.0') != '0.0') + self.cmdExtrapolate.setEnabled(params.get('guinier_a', '0.0') != '0.0') + self.cmdTransform.setEnabled(params.get('long_period', '0') != '0.0') + self.cmdExtract.setEnabled(params.get('long_period', '0') != '0.0') + if params.get('guinier_a', '0.0') != '0.0': + self.extrapolate() + if params.get('long_period', '0') != '0.0': + self.transform() diff --git a/src/sas/qtgui/Perspectives/Corfunc/UnitTesting/CorfuncTest.py b/src/sas/qtgui/Perspectives/Corfunc/UnitTesting/CorfuncTest.py index e28b122c30..1a69698f2c 100755 --- a/src/sas/qtgui/Perspectives/Corfunc/UnitTesting/CorfuncTest.py +++ b/src/sas/qtgui/Perspectives/Corfunc/UnitTesting/CorfuncTest.py @@ -9,6 +9,7 @@ from PyQt5.QtTest import QTest from sas.qtgui.Perspectives.Corfunc.CorfuncPerspective import CorfuncWindow +from sas.qtgui.Plotting.PlotterData import Data1D from sas.sascalc.dataloader.loader import Loader from sas.qtgui.MainWindow.DataManager import DataManager import sas.qtgui.Utilities.GuiUtils as GuiUtils @@ -38,6 +39,10 @@ def communicate(self): return GuiUtils.Communicate() self.widget = CorfuncWindow(dummy_manager()) + reference_data1 = Data1D(x=[0.1, 0.2, 0.3, 0.4, 0.5], y=[1000, 1000, 100, 10, 1], dy=[0.0, 0.0, 0.0, 0.0, 0.0]) + reference_data1.filename = "Test A" + GuiUtils.dataFromItem = MagicMock(return_value=reference_data1) + self.fakeData = QtGui.QStandardItem("test") def tearDown(self): '''Destroy the CorfuncWindow''' @@ -50,6 +55,23 @@ def testDefaults(self): self.assertEqual(self.widget.windowTitle(), "Corfunc Perspective") self.assertEqual(self.widget.model.columnCount(), 1) self.assertEqual(self.widget.model.rowCount(), 16) + self.assertEqual(self.widget.txtLowerQMin.text(), '0.0') + self.assertFalse(self.widget.txtLowerQMin.isEnabled()) + self.assertEqual(self.widget.txtFilename.text(), '') + self.assertEqual(self.widget.txtLowerQMax.text(), '0.01') + self.assertEqual(self.widget.txtUpperQMin.text(), '0.20') + self.assertEqual(self.widget.txtUpperQMax.text(), '0.22') + self.assertEqual(self.widget.txtBackground.text(), '0') + self.assertEqual(self.widget.txtGuinierA.text(), '0.0') + self.assertEqual(self.widget.txtGuinierB.text(), '0.0') + self.assertEqual(self.widget.txtPorodK.text(), '0.0') + self.assertEqual(self.widget.txtPorodSigma.text(), '0.0') + self.assertEqual(self.widget.txtAvgCoreThick.text(), '0') + self.assertEqual(self.widget.txtAvgIntThick.text(), '0') + self.assertEqual(self.widget.txtAvgHardBlock.text(), '0') + self.assertEqual(self.widget.txtPolydisp.text(), '0') + self.assertEqual(self.widget.txtLongPeriod.text(), '0') + self.assertEqual(self.widget.txtLocalCrystal.text(), '0') def testOnCalculate(self): """ Test onCompute function """ @@ -110,6 +132,68 @@ def testProcess(self): # self.assertTrue(float(self.widget.longPeriod.text()) > # float(self.widget.avgCoreThick.text()) > 0) + def testSerialization(self): + """ Serialization routines """ + self.widget.setData([self.fakeData]) + self.assertTrue(hasattr(self.widget, 'isSerializable')) + self.assertTrue(self.widget.isSerializable()) + self.checkFakeDataState() + data = GuiUtils.dataFromItem(self.widget._model_item) + data_id = str(data.id) + # Test three separate serialization routines + state_all = self.widget.serializeAll() + state_one = self.widget.serializeCurrentPage() + page = self.widget.getPage() + # Pull out params from state + params_dict = state_all.get(data_id) + params = params_dict.get('corfunc_params') + # Tests + self.assertEqual(len(state_all), len(state_one)) + self.assertEqual(len(state_all), 1) + # getPage should include an extra param 'data_id' removed by serialize + self.assertNotEqual(len(params), len(page)) + self.assertEqual(len(params), 15) + self.assertEqual(len(page), 16) + + def testRemoveData(self): + self.widget.setData([self.fakeData]) + self.checkFakeDataState() + # Removing something not already in the perspective should do nothing + self.widget.removeData([]) + self.checkFakeDataState() + # Removing the data from the perspective should set it to base state + self.widget.removeData([self.fakeData]) + # Be sure the defaults hold true after data removal + self.testDefaults() + + def testLoadParams(self): + self.widget.setData([self.fakeData]) + self.checkFakeDataState() + pageState = self.widget.getPage() + self.widget.updateFromParameters(pageState) + self.checkFakeDataState() + self.widget.removeData([self.fakeData]) + self.testDefaults() + + def checkFakeDataState(self): + self.assertEqual(self.widget.txtFilename.text(), 'data') + self.assertEqual(self.widget.txtLowerQMin.text(), '0.0') + self.assertFalse(self.widget.txtLowerQMin.isEnabled()) + self.assertEqual(self.widget.txtLowerQMax.text(), '0.01') + self.assertEqual(self.widget.txtUpperQMin.text(), '0.20') + self.assertEqual(self.widget.txtUpperQMax.text(), '0.22') + self.assertEqual(self.widget.txtBackground.text(), '0') + self.assertEqual(self.widget.txtGuinierA.text(), '') + self.assertEqual(self.widget.txtGuinierB.text(), '') + self.assertEqual(self.widget.txtPorodK.text(), '') + self.assertEqual(self.widget.txtPorodSigma.text(), '') + self.assertEqual(self.widget.txtAvgCoreThick.text(), '') + self.assertEqual(self.widget.txtAvgIntThick.text(), '') + self.assertEqual(self.widget.txtAvgHardBlock.text(), '') + self.assertEqual(self.widget.txtPolydisp.text(), '') + self.assertEqual(self.widget.txtLongPeriod.text(), '') + self.assertEqual(self.widget.txtLocalCrystal.text(), '') + if __name__ == "__main__": unittest.main() diff --git a/src/sas/qtgui/Perspectives/Fitting/FittingPerspective.py b/src/sas/qtgui/Perspectives/Fitting/FittingPerspective.py index b28503fa50..e7e10d4539 100644 --- a/src/sas/qtgui/Perspectives/Fitting/FittingPerspective.py +++ b/src/sas/qtgui/Perspectives/Fitting/FittingPerspective.py @@ -221,8 +221,9 @@ def closeEvent(self, event): if self._allow_close: # reset the closability flag self.setClosable(value=False) - # Tell the MdiArea to close the container - self.parentWidget().close() + # Tell the MdiArea to close the container if it is visible + if self.parentWidget(): + self.parentWidget().close() event.accept() else: # Maybe we should just minimize @@ -246,7 +247,6 @@ def addFit(self, data, is_batch=False, tab_index=None): self.tabs.append(tab) if data: self.updateFitDict(data, tab_name) - #self.maxIndex += 1 self.maxIndex = tab_index + 1 icon = QtGui.QIcon() @@ -254,7 +254,7 @@ def addFit(self, data, is_batch=False, tab_index=None): icon.addPixmap(QtGui.QPixmap("src/sas/qtgui/images/icons/layers.svg")) self.addTab(tab, icon, tab_name) # Show the new tab - self.setCurrentWidget(tab); + self.setCurrentWidget(tab) # Notify listeners self.tabsModifiedSignal.emit() @@ -304,14 +304,6 @@ def getCSTabName(self): page_name = "Const. & Simul. Fit" return page_name - def deleteAllTabs(self): - """ - Explicitly deletes all the fittabs, leaving nothing. - This is in preparation for the project load step. - """ - for tab_index in range(len(self.tabs)): - self.closeTabByIndex(tab_index) - def closeTabByIndex(self, index): """ Close/delete a tab with the given index. @@ -419,7 +411,11 @@ def setData(self, data_item=None, is_batch=False, tab_index=None): available_tabs = [tab.acceptsData() for tab in self.tabs] if tab_index is not None: - self.addFit(data, is_batch=is_batch, tab_index=tab_index) + if tab_index >= self.maxIndex: + self.addFit(data, is_batch=is_batch, tab_index=tab_index) + else: + self.setCurrentIndex(tab_index-1) + self.swapData(data) return if numpy.any(available_tabs): first_good_tab = available_tabs.index(True) diff --git a/src/sas/qtgui/Perspectives/Fitting/UnitTesting/FitPageTest.py b/src/sas/qtgui/Perspectives/Fitting/UnitTesting/FitPageTest.py index f5578453d6..b4cadf4a23 100644 --- a/src/sas/qtgui/Perspectives/Fitting/UnitTesting/FitPageTest.py +++ b/src/sas/qtgui/Perspectives/Fitting/UnitTesting/FitPageTest.py @@ -27,7 +27,7 @@ def testDefaults(self): self.assertEqual(self.page.current_factor, "") self.assertEqual(self.page.page_id, 0) self.assertFalse(self.page.data_is_loaded) - self.assertEqual(self.page.filename, "") + self.assertEqual(self.page.name, "") self.assertIsNone(self.page.data) self.assertIsNone(self.page.kernel_module) self.assertEqual(self.page.parameters_to_fit, []) diff --git a/src/sas/qtgui/Perspectives/Fitting/UnitTesting/FittingLogicTest.py b/src/sas/qtgui/Perspectives/Fitting/UnitTesting/FittingLogicTest.py index eac81e8bf0..f170c236bd 100644 --- a/src/sas/qtgui/Perspectives/Fitting/UnitTesting/FittingLogicTest.py +++ b/src/sas/qtgui/Perspectives/Fitting/UnitTesting/FittingLogicTest.py @@ -114,8 +114,8 @@ def testNew1DPlot(self): self.assertIsInstance(new_plot, Data1D) self.assertFalse(new_plot.is_data) self.assertEqual(new_plot.dy.size, 3) - self.assertEqual(new_plot.title, "boop [poop]") - self.assertEqual(new_plot.name, "boop [poop]") + self.assertEqual(new_plot.title, "boop [boop]") + self.assertEqual(new_plot.name, "boop [boop]") def testNew2DPlot(self): """ diff --git a/src/sas/qtgui/Perspectives/Fitting/UnitTesting/FittingPerspectiveTest.py b/src/sas/qtgui/Perspectives/Fitting/UnitTesting/FittingPerspectiveTest.py index 27fd6b92e0..fa2d592853 100644 --- a/src/sas/qtgui/Perspectives/Fitting/UnitTesting/FittingPerspectiveTest.py +++ b/src/sas/qtgui/Perspectives/Fitting/UnitTesting/FittingPerspectiveTest.py @@ -272,6 +272,8 @@ def testGetConstraintTab(self): def testSerialization(self): ''' Serialize fit pages and check data ''' + self.assertTrue(hasattr(self.widget, 'isSerializable')) + self.assertTrue(self.widget.isSerializable()) data = Data1D(x=[1,2], y=[1,2]) GuiUtils.dataFromItem = MagicMock(return_value=data) item = QtGui.QStandardItem("test") diff --git a/src/sas/qtgui/Perspectives/Invariant/InvariantPerspective.py b/src/sas/qtgui/Perspectives/Invariant/InvariantPerspective.py index bb9f3a0ff0..82ca66afa7 100644 --- a/src/sas/qtgui/Perspectives/Invariant/InvariantPerspective.py +++ b/src/sas/qtgui/Perspectives/Invariant/InvariantPerspective.py @@ -39,6 +39,7 @@ class InvariantWindow(QtWidgets.QDialog, Ui_tabbedInvariantUI): # The controller which is responsible for managing signal slots connections # for the gui and providing an interface to the data model. name = "Invariant" # For displaying in the combo box in DataExplorer + ext = 'inv' # File extension used for saving analyses def __init__(self, parent=None): super(InvariantWindow, self).__init__() @@ -64,12 +65,10 @@ def __init__(self, parent=None): self._low_extrapolate = False self._low_guinier = True self._low_fit = False - self._low_power_value = False self._low_points = NPOINTS_Q_INTERP self._low_power_value = DEFAULT_POWER_LOW self._high_extrapolate = False - self._high_power_value = False self._high_fit = False self._high_points = NPOINTS_Q_INTERP self._high_power_value = DEFAULT_POWER_LOW @@ -78,6 +77,7 @@ def __init__(self, parent=None): self.resize(self.minimumSizeHint()) self.communicate = self._manager.communicator() + self.communicate.dataDeletedSignal.connect(self.removeData) self._data = None self._path = "" @@ -144,6 +144,12 @@ def setClosable(self, value=True): self._allow_close = value + def isSerializable(self): + """ + Tell the caller that this perspective writes its state + """ + return True + def closeEvent(self, event): """ Overwrite QDialog close method to allow for custom widget close @@ -151,8 +157,9 @@ def closeEvent(self, event): if self._allow_close: # reset the closability flag self.setClosable(value=False) - # Tell the MdiArea to close the container - self.parentWidget().close() + # Tell the MdiArea to close the container if it is visible + if self.parentWidget(): + self.parentWidget().close() event.accept() else: event.ignore() @@ -789,6 +796,21 @@ def setData(self, data_item=None, is_batch=False): # update GUI and model with info from loaded data self.updateGuiFromFile(data=data) + def removeData(self, data_list=None): + """Remove the existing data reference from the Invariant Persepective""" + if not data_list or self._model_item not in data_list: + return + self._data = None + self._model_item = None + self._path = "" + self.txtName.setText('') + self._porod = None + # Pass an empty dictionary to set all inputs to their default values + self.updateFromParameters({}) + # Disable buttons to return to base state + self.cmdCalculate.setEnabled(False) + self.cmdStatus.setEnabled(False) + def updateGuiFromFile(self, data=None): """ update display in GUI and plot @@ -823,6 +845,121 @@ def updateGuiFromFile(self, data=None): # and specific surface if porod checked self.calculateInvariant() + def serializeAll(self): + """ + Serialize the invariant state so data can be saved + Invariant is not batch-ready so this will only effect a single page + :return: {data-id: {self.name: {invariant-state}}} + """ + return self.serializeCurrentPage() + + def serializeCurrentPage(self): + """ + Serialize and return a dictionary of {data_id: invariant-state} + Return empty dictionary if no data + :return: {data-id: {self.name: {invariant-state}}} + """ + state = {} + if self._data: + tab_data = self.getPage() + data_id = tab_data.pop('data_id', '') + state[data_id] = {'invar_params': tab_data} + return state + + def getPage(self): + """ + Serializes full state of this invariant page + Called by Save Analysis + :return: {invariant-state} + """ + # Get all parameters from page + param_dict = self.getState() + if self._data: + param_dict['data_name'] = str(self._data.name) + param_dict['data_id'] = str(self._data.id) + return param_dict + + def getState(self): + """ + Collects all active params into a dictionary of {name: value} + :return: {name: value} + """ + # Be sure model has been updated + self.updateFromModel() + return { + 'extrapolated_q_min': self.txtExtrapolQMin.text(), + 'extrapolated_q_max': self.txtExtrapolQMax.text(), + 'vol_fraction': self.txtVolFract.text(), + 'vol_fraction_err': self.txtVolFractErr.text(), + 'specific_surface': self.txtSpecSurf.text(), + 'specific_surface_err': self.txtSpecSurfErr.text(), + 'invariant_total': self.txtInvariantTot.text(), + 'invariant_total_err': self.txtInvariantTotErr.text(), + 'background': self.txtBackgd.text(), + 'contrast': self.txtContrast.text(), + 'scale': self.txtScale.text(), + 'porod': self.txtPorodCst.text(), + 'low_extrapolate': self.chkLowQ.isChecked(), + 'low_points': self.txtNptsLowQ.text(), + 'low_guinier': self.rbGuinier.isChecked(), + 'low_fit_rb': self.rbFitLowQ.isChecked(), + 'low_power_value': self.txtPowerLowQ.text(), + 'high_extrapolate': self.chkHighQ.isChecked(), + 'high_points': self.txtNptsHighQ.text(), + 'high_fit_rb': self.rbFitHighQ.isChecked(), + 'high_power_value': self.txtPowerHighQ.text(), + 'total_q_min': self.txtTotalQMin.text(), + 'total_q_max': self.txtTotalQMax.text(), + } + + def updateFromParameters(self, params): + """ + Called by Open Project and Open Analysis + :param params: {param_name: value} + :return: None + """ + # Params should be a dictionary + if not isinstance(params, dict): + c_name = params.__class__.__name__ + msg = "Invariant.updateFromParameters expects a dictionary" + raise TypeError(f"{msg}: {c_name} received") + # Assign values to 'Invariant' tab inputs - use defaults if not found + self.txtTotalQMin.setText(str(params.get('total_q_min', '0.0'))) + self.txtTotalQMax.setText(str(params.get('total_q_max', '0.0'))) + self.txtExtrapolQMax.setText(str(params.get('extrapolated_q_max', + Q_MAXIMUM))) + self.txtExtrapolQMin.setText(str(params.get('extrapolated_q_min', + Q_MINIMUM))) + self.txtVolFract.setText(str(params.get('vol_fraction', ''))) + self.txtVolFractErr.setText(str(params.get('vol_fraction_err', ''))) + self.txtSpecSurf.setText(str(params.get('specific_surface', ''))) + self.txtSpecSurfErr.setText(str(params.get('specific_surface_err', ''))) + self.txtInvariantTot.setText(str(params.get('invariant_total', ''))) + self.txtInvariantTotErr.setText( + str(params.get('invariant_total_err', ''))) + # Assign values to 'Options' tab inputs - use defaults if not found + self.txtBackgd.setText(str(params.get('background', '0.0'))) + self.txtScale.setText(str(params.get('scale', '1.0'))) + self.txtContrast.setText(str(params.get('contrast', '8e-06'))) + self.txtPorodCst.setText(str(params.get('porod', '0.0'))) + # Toggle extrapolation buttons to enable other inputs + self.chkLowQ.setChecked(params.get('low_extrapolate', False)) + self.chkHighQ.setChecked(params.get('high_extrapolate', False)) + self.txtPowerLowQ.setText( + str(params.get('low_power_value', DEFAULT_POWER_LOW))) + self.txtNptsLowQ.setText( + str(params.get('low_points', NPOINTS_Q_INTERP))) + self.rbGuinier.setChecked(params.get('low_guinier', True)) + self.rbFitLowQ.setChecked(params.get('low_fit_rb', False)) + self.txtNptsHighQ.setText( + str(params.get('high_points', NPOINTS_Q_INTERP))) + self.rbFitHighQ.setChecked(params.get('high_fit_rb', True)) + self.txtPowerHighQ.setText( + str(params.get('high_power_value', DEFAULT_POWER_LOW))) + # Update once all inputs are changed + self.updateFromModel() + self.plotResult(self.model) + def allowBatch(self): """ Tell the caller that we don't accept multiple data instances diff --git a/src/sas/qtgui/Perspectives/Invariant/UnitTesting/InvariantPerspectiveTest.py b/src/sas/qtgui/Perspectives/Invariant/UnitTesting/InvariantPerspectiveTest.py index e87e8b52aa..52d535e8f6 100644 --- a/src/sas/qtgui/Perspectives/Invariant/UnitTesting/InvariantPerspectiveTest.py +++ b/src/sas/qtgui/Perspectives/Invariant/UnitTesting/InvariantPerspectiveTest.py @@ -17,8 +17,6 @@ from sas.qtgui.Perspectives.Invariant.InvariantDetails import DetailsDialog from sas.qtgui.Perspectives.Invariant.InvariantUtils import WIDGETS from sas.qtgui.Plotting.PlotterData import Data1D -from sas.qtgui.MainWindow.GuiManager import GuiManager -from sas.qtgui.MainWindow.DataExplorer import DataExplorerWindow import sas.qtgui.Utilities.GuiUtils as GuiUtils @@ -47,6 +45,9 @@ def communicate(self): return GuiUtils.Communicate() self.widget = InvariantWindow(dummy_manager()) + data = Data1D(x=[1, 2], y=[1, 2]) + GuiUtils.dataFromItem = MagicMock(return_value=data) + self.fakeData = QtGui.QStandardItem("test") def tearDown(self): """Destroy the DataOperationUtility""" @@ -449,11 +450,7 @@ def testSetupMapper(self): def testSetData(self): """ """ self.widget.updateGuiFromFile = MagicMock() - - data = Data1D(x=[1, 2], y=[1, 2]) - GuiUtils.dataFromItem = MagicMock(return_value=data) - item = QtGui.QStandardItem("test") - self.widget.setData([item]) + self.widget.setData([self.fakeData]) self.assertTrue(self.widget.updateGuiFromFile.called_once()) @@ -469,6 +466,91 @@ def TestCheckQExtrapolatedData(self): self.assertTrue(GuiUtils.updateModelItemStatus.called_once()) + def testSerialization(self): + """ Serialization routines """ + self.assertTrue(hasattr(self.widget, 'isSerializable')) + self.assertTrue(self.widget.isSerializable()) + self.widget.setData([self.fakeData]) + self.checkFakeDataState() + data_return = GuiUtils.dataFromItem(self.widget._model_item) + data_id = str(data_return.id) + # Test three separate serialization routines + state_all = self.widget.serializeAll() + state_one = self.widget.serializeCurrentPage() + page = self.widget.getPage() + # Pull out params from state + params = state_all[data_id]['invar_params'] + # Tests + self.assertEqual(len(state_all), len(state_one)) + self.assertEqual(len(state_all), 1) + # getPage should include an extra param 'data_id' removed by serialize + self.assertNotEqual(len(params), len(page)) + self.assertEqual(len(params), 24) + self.assertEqual(len(page), 25) + + def testLoadParams(self): + self.widget.setData([self.fakeData]) + self.checkFakeDataState() + pageState = self.widget.getPage() + self.widget.updateFromParameters(pageState) + self.widget.removeData([self.fakeData]) + self.testDefaults() + + def testRemoveData(self): + self.widget.setData([self.fakeData]) + self.checkFakeDataState() + # Removing something not already in the perspective should do nothing + self.widget.removeData([]) + # Be sure the defaults hold true after data removal + self.widget.removeData([self.fakeData]) + self.testDefaults() + + def checkFakeDataState(self): + """ Ensure the state is constant every time the fake data set loaded """ + self.assertIsNotNone(self.widget._data) + + # push buttons enabled + self.assertFalse(self.widget.cmdStatus.isEnabled()) + self.assertFalse(self.widget.cmdCalculate.isEnabled()) + + # disabled, read only line edits + self.assertFalse(self.widget.txtName.isEnabled()) + self.assertTrue(self.widget.txtVolFract.isReadOnly()) + self.assertTrue(self.widget.txtVolFractErr.isReadOnly()) + + self.assertTrue(self.widget.txtSpecSurf.isReadOnly()) + self.assertTrue(self.widget.txtSpecSurfErr.isReadOnly()) + + self.assertTrue(self.widget.txtInvariantTot.isReadOnly()) + self.assertTrue(self.widget.txtInvariantTotErr.isReadOnly()) + + self.assertFalse(self.widget.txtBackgd.isReadOnly()) + self.assertFalse(self.widget.txtScale.isReadOnly()) + self.assertFalse(self.widget.txtContrast.isReadOnly()) + self.assertFalse(self.widget.txtPorodCst.isReadOnly()) + + self.assertTrue(self.widget.txtExtrapolQMin.isEnabled()) + self.assertTrue(self.widget.txtExtrapolQMax.isEnabled()) + + self.assertFalse(self.widget.txtNptsLowQ.isReadOnly()) + self.assertFalse(self.widget.txtNptsHighQ.isReadOnly()) + + # content of line edits + self.assertEqual(self.widget.txtName.text(), 'data') + self.assertEqual(self.widget.txtTotalQMin.text(), '1') + self.assertEqual(self.widget.txtTotalQMax.text(), '2') + self.assertEqual(self.widget.txtBackgd.text(), '0.0') + self.assertEqual(self.widget.txtScale.text(), '1.0') + self.assertEqual(self.widget.txtContrast.text(), '8e-06') + self.assertEqual(self.widget.txtExtrapolQMin.text(), '1e-05') + self.assertEqual(self.widget.txtExtrapolQMax.text(), '10') + self.assertEqual(self.widget.txtPowerLowQ.text(), '4') + self.assertEqual(self.widget.txtPowerHighQ.text(), '4') + + # unchecked checkboxes + self.assertFalse(self.widget.chkLowQ.isChecked()) + self.assertFalse(self.widget.chkHighQ.isChecked()) + if __name__ == "__main__": unittest.main() diff --git a/src/sas/qtgui/Perspectives/Inversion/InversionPerspective.py b/src/sas/qtgui/Perspectives/Inversion/InversionPerspective.py index 37072091cc..c983597f5f 100644 --- a/src/sas/qtgui/Perspectives/Inversion/InversionPerspective.py +++ b/src/sas/qtgui/Perspectives/Inversion/InversionPerspective.py @@ -164,8 +164,9 @@ def closeEvent(self, event): if self._allowClose: # reset the closability flag self.setClosable(value=False) - # Tell the MdiArea to close the container - self.parentWidget().close() + # Tell the MdiArea to close the container if it is visible + if self.parentWidget(): + self.parentWidget().close() event.accept() else: event.ignore() diff --git a/src/sas/qtgui/Perspectives/Inversion/UnitTesting/InversionPerspectiveTest.py b/src/sas/qtgui/Perspectives/Inversion/UnitTesting/InversionPerspectiveTest.py index b6010edb11..70e106f865 100644 --- a/src/sas/qtgui/Perspectives/Inversion/UnitTesting/InversionPerspectiveTest.py +++ b/src/sas/qtgui/Perspectives/Inversion/UnitTesting/InversionPerspectiveTest.py @@ -307,6 +307,8 @@ def testOpenExplorerWindow(self): def testSerialization(self): """ Serialization routines """ + self.assertTrue(hasattr(self.widget, 'isSerializable')) + self.assertTrue(self.widget.isSerializable()) self.widget.setData([self.fakeData1]) self.oneDataSetState() data_id = self.widget.currentTabDataId()[0] diff --git a/src/sas/qtgui/Utilities/GuiUtils.py b/src/sas/qtgui/Utilities/GuiUtils.py index a42981379d..6ee469cffc 100644 --- a/src/sas/qtgui/Utilities/GuiUtils.py +++ b/src/sas/qtgui/Utilities/GuiUtils.py @@ -1212,7 +1212,7 @@ def jdefault(o): objects that can't otherwise be serialized need to be converted """ # tuples and sets (TODO: default JSONEncoder converts tuples to lists, create custom Encoder that preserves tuples) - if isinstance(o, (tuple, set)): + if isinstance(o, (tuple, set, np.float)): content = { 'data': list(o) } return add_type(content, type(o)) diff --git a/src/sas/sascalc/dataloader/data_info.py b/src/sas/sascalc/dataloader/data_info.py index 7ab9916e56..404d28848c 100644 --- a/src/sas/sascalc/dataloader/data_info.py +++ b/src/sas/sascalc/dataloader/data_info.py @@ -141,8 +141,8 @@ def __init__(self, data=None, err_data=None, qx_data=None, self.zmin = zmin self.zmax = zmax - self.y_bins = x_bins - self.x_bins = y_bins + self.y_bins = x_bins if x_bins else [] + self.x_bins = y_bins if y_bins else [] def xaxis(self, label, unit): """