diff --git a/.github/workflows/installers.yml b/.github/workflows/installers.yml index 4178af026a..c4ef451108 100644 --- a/.github/workflows/installers.yml +++ b/.github/workflows/installers.yml @@ -72,7 +72,8 @@ jobs: python -m pip install numpy scipy==1.7.3 docutils "pytest<6" sphinx unittest-xml-reporting python -m pip install tinycc h5py sphinx pyparsing html5lib reportlab==3.6.6 pybind11 appdirs python -m pip install six numba mako ipython qtconsole xhtml2pdf unittest-xml-reporting pylint - python -m pip install qt5reactor periodictable uncertainties debugpy + python -m pip install qt5reactor periodictable uncertainties debugpy dominate + - name: Install PyQt (Windows + Linux) if: ${{ matrix.os != 'macos-latest' }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 54f1aa6768..3cd117db22 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -74,7 +74,9 @@ jobs: python -m pip install numpy scipy==1.7.3 docutils "pytest<6" sphinx unittest-xml-reporting python -m pip install tinycc h5py sphinx pyparsing html5lib reportlab==3.6.6 pybind11 appdirs python -m pip install six numba mako ipython qtconsole xhtml2pdf unittest-xml-reporting pylint - python -m pip install qt5reactor periodictable uncertainties debugpy + python -m pip install qt5reactor periodictable uncertainties debugpy dominate importlib_resources + python -m pip install html2text + - name: Install PyQt (Windows + Linux) if: ${{ matrix.os != 'macos-latest' }} diff --git a/.gitignore b/.gitignore index 9ab7a67578..8990c97167 100644 --- a/.gitignore +++ b/.gitignore @@ -63,6 +63,9 @@ default_categories.json **/logs tests.log +# Test reports +/src/sas/qtgui/Utilities/Reports/report_test* + # Installer files /sasview-install /installers/build diff --git a/build_tools/requirements.txt b/build_tools/requirements.txt index 43dbb92a8d..033afa2d1b 100644 --- a/build_tools/requirements.txt +++ b/build_tools/requirements.txt @@ -29,3 +29,7 @@ matplotlib==3.4.3; sys_platform == 'darwin' lxml pytools cffi +dominate +html5lib +importlib-resources +importlib_resources \ No newline at end of file diff --git a/src/sas/qtgui/MainWindow/DataExplorer.py b/src/sas/qtgui/MainWindow/DataExplorer.py index 632c55d941..9aae37e7e7 100644 --- a/src/sas/qtgui/MainWindow/DataExplorer.py +++ b/src/sas/qtgui/MainWindow/DataExplorer.py @@ -183,8 +183,8 @@ def enableGraphCombo(self, combo_text): """ Enables/disables "Assign Plot" elements """ - self.cbgraph.setEnabled(len(PlotHelper.currentPlots()) > 0) - self.cmdAppend.setEnabled(len(PlotHelper.currentPlots()) > 0) + self.cbgraph.setEnabled(len(PlotHelper.currentPlotIds()) > 0) + self.cmdAppend.setEnabled(len(PlotHelper.currentPlotIds()) > 0) def initPerspectives(self): """ @@ -956,7 +956,7 @@ def updateGraphCount(self, graphs): deleted graphs """ graph2, delete = graphs - graph_list = PlotHelper.currentPlots() + graph_list = PlotHelper.currentPlotIds() self.updateGraphCombo(graph_list) if not self.active_plots: @@ -983,11 +983,21 @@ def updatePerspectiveCombo(self, index): """ Notify the gui manager about the new perspective chosen. """ + + # Notify via communicator self.communicator.perspectiveChangedSignal.emit(self.cbFitting.itemText(index)) - self.chkBatch.setEnabled(self.parent.perspective().allowBatch()) - # Deactivate and uncheck the swap data option if the current perspective does not allow it - self.chkSwap.setEnabled(self.parent.perspective().allowSwap()) - if not self.parent.perspective().allowSwap(): + + # Set checkboxes + current_perspective = self.parent.perspective() + + allow_batch = False if current_perspective is None else current_perspective.allowBatch() + allow_swap = False if current_perspective is None else current_perspective.allowSwap() + + self.chkBatch.setEnabled(allow_batch) + self.chkSwap.setEnabled(allow_swap) + + # Using this conditional prevents the checkbox for going into the "neither checked nor unchecked" state + if not allow_swap: self.chkSwap.setCheckState(False) def itemFromDisplayName(self, name): @@ -1213,7 +1223,7 @@ def appendPlot(self): # old plot data plot_id = str(self.cbgraph.currentText()) try: - assert plot_id in PlotHelper.currentPlots(), "No such plot: %s" % (plot_id) + assert plot_id in PlotHelper.currentPlotIds(), "No such plot: %s" % (plot_id) except: return @@ -1828,7 +1838,7 @@ def closeAllPlots(self): Close all currently displayed plots """ - for plot_id in PlotHelper.currentPlots(): + for plot_id in PlotHelper.currentPlotIds(): try: plotter = PlotHelper.plotById(plot_id) plotter.close() @@ -1841,7 +1851,7 @@ def minimizeAllPlots(self): """ Minimize all currently displayed plots """ - for plot_id in PlotHelper.currentPlots(): + for plot_id in PlotHelper.currentPlotIds(): plotter = PlotHelper.plotById(plot_id) plotter.showMinimized() @@ -1853,7 +1863,7 @@ def closePlotsForItem(self, item): # {} -> 'Graph1' : HashableStandardItem() current_plot_items = {} - for plot_name in PlotHelper.currentPlots(): + for plot_name in PlotHelper.currentPlotIds(): current_plot_items[plot_name] = PlotHelper.plotById(plot_name).item # item and its hashable children @@ -1883,15 +1893,17 @@ def closePlotsForItem(self, item): pass # debugger anchor - def onAnalysisUpdate(self, new_perspective=""): + def onAnalysisUpdate(self, new_perspective_name: str): """ Update the perspective combo index based on passed string """ - assert new_perspective in Perspectives.PERSPECTIVES.keys() + assert new_perspective_name in Perspectives.PERSPECTIVES.keys() + self.cbFitting.blockSignals(True) - self.cbFitting.setCurrentIndex(self.cbFitting.findText(new_perspective)) + index = self.cbFitting.findText(new_perspective_name) + self.cbFitting.setCurrentIndex(index) self.cbFitting.blockSignals(False) - pass + def loadComplete(self, output): """ diff --git a/src/sas/qtgui/MainWindow/GuiManager.py b/src/sas/qtgui/MainWindow/GuiManager.py index dd93bd494d..5afadc8296 100644 --- a/src/sas/qtgui/MainWindow/GuiManager.py +++ b/src/sas/qtgui/MainWindow/GuiManager.py @@ -1,14 +1,15 @@ import sys import os -import subprocess import logging import json import webbrowser import traceback +from typing import Optional + from PyQt5.QtWidgets import * from PyQt5.QtGui import * -from PyQt5.QtCore import Qt, QLocale, QUrl +from PyQt5.QtCore import Qt, QLocale import matplotlib as mpl mpl.use("Qt5Agg") @@ -18,7 +19,7 @@ from twisted.internet import reactor # General SAS imports -from sas import get_local_config, get_custom_config +from sas import get_custom_config from sas.qtgui.Utilities.ConnectionProxy import ConnectionProxy from sas.qtgui.Utilities.SasviewLogger import setup_qt_logging @@ -31,7 +32,7 @@ from sas.qtgui.Utilities.GridPanel import BatchOutputPanel from sas.qtgui.Utilities.ResultPanel import ResultPanel -from sas.qtgui.Utilities.ReportDialog import ReportDialog +from sas.qtgui.Utilities.Reports.ReportDialog import ReportDialog from sas.qtgui.MainWindow.Acknowledgements import Acknowledgements from sas.qtgui.MainWindow.AboutBox import AboutBox from sas.qtgui.MainWindow.WelcomePanel import WelcomePanel @@ -48,12 +49,19 @@ from sas.qtgui.Calculators.ResolutionCalculatorPanel import ResolutionCalculatorPanel from sas.qtgui.Calculators.DataOperationUtilityPanel import DataOperationUtilityPanel + import sas.qtgui.Plotting.PlotHelper as PlotHelper # Perspectives import sas.qtgui.Perspectives as Perspectives +from sas.qtgui.Perspectives.perspective import Perspective + from sas.qtgui.Perspectives.Fitting.FittingPerspective import FittingWindow -from sas.qtgui.MainWindow.DataExplorer import DataExplorerWindow, DEFAULT_PERSPECTIVE +from sas.qtgui.Perspectives.Corfunc.CorfuncPerspective import CorfuncWindow +from sas.qtgui.Perspectives.Invariant.InvariantPerspective import InvariantWindow +from sas.qtgui.Perspectives.Inversion.InversionPerspective import InversionWindow + +from sas.qtgui.MainWindow.DataExplorer import DataExplorerWindow from sas.qtgui.Utilities.AddMultEditor import AddMultEditor from sas.qtgui.Utilities.ImageViewer import ImageViewer @@ -62,7 +70,7 @@ logger = logging.getLogger(__name__) -class GuiManager(object): +class GuiManager: """ Main SasView window functionality """ @@ -93,7 +101,7 @@ def __init__(self, parent=None): self.addTriggers() # Currently displayed perspective - self._current_perspective = None + self._current_perspective: Optional[Perspective] = None self.loadedPerspectives = {} # Populate the main window with stuff @@ -259,7 +267,7 @@ def plotSelectedSlot(self, plot_name): Set focus on the selected plot """ # loop over all visible plots and find the requested plot - for plot in PlotHelper.currentPlots(): + for plot in PlotHelper.currentPlotIds(): # take last plot if PlotHelper.plotById(plot).data[-1].name == plot_name: # set focus on the plot @@ -341,32 +349,82 @@ def workspace(self): """ return self._workspace.workspace - def perspectiveChanged(self, perspective_name): + def perspectiveChanged(self, new_perspective_name: str): """ Respond to change of the perspective signal """ - # Remove the previous perspective from the window - self.clearPerspectiveMenubarOptions(self._current_perspective) - if self._current_perspective: + + assert new_perspective_name in self.loadedPerspectives # supplied name should always be in loaded perspectives + + # Uncheck all menu items + for menuItem in self._workspace.menuAnalysis.actions(): + menuItem.setChecked(False) + + if self._current_perspective is not None: + + # Remove the fitting menu for now, will be replaced later if we move back to a perspective that supports it + # I do not like that this requires the menu action to exist to be correct + if self._current_perspective.supports_fitting_menu: + self._workspace.menubar.removeAction(self._workspace.menuFitting.menuAction()) + # Remove perspective and store in Perspective dictionary - self.loadedPerspectives[ - self._current_perspective.name] = self._current_perspective + self.loadedPerspectives[self._current_perspective.name] = self._current_perspective + self._workspace.workspace.removeSubWindow(self._current_perspective) self._workspace.workspace.removeSubWindow(self.subwindow) - # Get new perspective - self._current_perspective = self.loadedPerspectives[str(perspective_name)] - self.setupPerspectiveMenubarOptions(self._current_perspective) + # Get new perspective - note that _current_perspective is of type Optional[Perspective], + # but new_perspective is of type Perspective, thus call to Perspective members are safe + new_perspective = self.loadedPerspectives[new_perspective_name] - self.subwindow = self._workspace.workspace.addSubWindow( - self._current_perspective) + self._workspace.actionReport.setEnabled(new_perspective.supports_reports) + self._workspace.actionOpen_Analysis.setEnabled(False) + self._workspace.actionSave_Analysis.setEnabled(False) + + if new_perspective.isSerializable(): + self._workspace.actionOpen_Analysis.setEnabled(True) + self._workspace.actionSave_Analysis.setEnabled(True) + + if new_perspective.supports_fitting_menu: + # Put the fitting menu back in + # This is a bit involved but it is needed to preserve the menu ordering + self._workspace.menubar.removeAction(self._workspace.menuWindow.menuAction()) + self._workspace.menubar.removeAction(self._workspace.menuHelp.menuAction()) + + self._workspace.menubar.addAction(self._workspace.menuFitting.menuAction()) + + self._workspace.menubar.addAction(self._workspace.menuWindow.menuAction()) + self._workspace.menubar.addAction(self._workspace.menuHelp.menuAction()) + + # + # Selection on perspective choice menu + # + if isinstance(new_perspective, FittingWindow): + self.checkAnalysisOption(self._workspace.actionFitting) + + elif isinstance(new_perspective, InvariantWindow): + self.checkAnalysisOption(self._workspace.actionInvariant) + + elif isinstance(new_perspective, InversionWindow): + self.checkAnalysisOption(self._workspace.actionInversion) + + elif isinstance(new_perspective, CorfuncWindow): + self.checkAnalysisOption(self._workspace.actionCorfunc) + + + # + # Set up the window + # + self.subwindow = self._workspace.workspace.addSubWindow(new_perspective) # Resize to the workspace height workspace_height = self._workspace.workspace.sizeHint().height() - perspective_size = self._current_perspective.sizeHint() + perspective_size = new_perspective.sizeHint() perspective_width = perspective_size.width() - self._current_perspective.resize(perspective_width, workspace_height-10) + new_perspective.resize(perspective_width, workspace_height-10) + # Set the current perspective to new one and show + self._current_perspective = new_perspective self._current_perspective.show() def updatePerspective(self, data): @@ -773,16 +831,16 @@ def actionReport(self): """ Show the Fit Report dialog. """ - report_list = None - if getattr(self._current_perspective, "currentTab"): - try: - report_list = self._current_perspective.currentTab.getReport() - except Exception as ex: - logging.error("Report generation failed with: " + str(ex)) + if self._current_perspective is not None: + report_data = self._current_perspective.getReport() + + if report_data is None: + logging.info("Report data is empty, dialog not shown") + else: + self.report_dialog = ReportDialog(report_data=report_data, parent=self._parent) + self.report_dialog.show() + - if report_list is not None: - self.report_dialog = ReportDialog(parent=self, report_list=report_list) - self.report_dialog.show() def actionReset(self): """ @@ -1237,44 +1295,6 @@ def checkAnalysisOption(self, analysisMenuOption): self.uncheckAllMenuItems(self._workspace.menuAnalysis) analysisMenuOption.setChecked(True) - def clearPerspectiveMenubarOptions(self, perspective): - """ - When closing a perspective, clears the menu bar - """ - for menuItem in self._workspace.menuAnalysis.actions(): - menuItem.setChecked(False) - - if isinstance(self._current_perspective, Perspectives.PERSPECTIVES["Fitting"]): - self._workspace.menubar.removeAction(self._workspace.menuFitting.menuAction()) - - def setupPerspectiveMenubarOptions(self, perspective): - """ - When setting a perspective, sets up the menu bar - """ - self._workspace.actionReport.setEnabled(False) - self._workspace.actionOpen_Analysis.setEnabled(False) - self._workspace.actionSave_Analysis.setEnabled(False) - if hasattr(perspective, 'isSerializable') and perspective.isSerializable(): - self._workspace.actionOpen_Analysis.setEnabled(True) - self._workspace.actionSave_Analysis.setEnabled(True) - - if isinstance(perspective, Perspectives.PERSPECTIVES["Fitting"]): - self.checkAnalysisOption(self._workspace.actionFitting) - # Put the fitting menu back in - # This is a bit involved but it is needed to preserve the menu ordering - self._workspace.menubar.removeAction(self._workspace.menuWindow.menuAction()) - self._workspace.menubar.removeAction(self._workspace.menuHelp.menuAction()) - self._workspace.menubar.addAction(self._workspace.menuFitting.menuAction()) - self._workspace.menubar.addAction(self._workspace.menuWindow.menuAction()) - self._workspace.menubar.addAction(self._workspace.menuHelp.menuAction()) - self._workspace.actionReport.setEnabled(True) - - elif isinstance(perspective, Perspectives.PERSPECTIVES["Invariant"]): - self.checkAnalysisOption(self._workspace.actionInvariant) - elif isinstance(perspective, Perspectives.PERSPECTIVES["Inversion"]): - self.checkAnalysisOption(self._workspace.actionInversion) - elif isinstance(perspective, Perspectives.PERSPECTIVES["Corfunc"]): - self.checkAnalysisOption(self._workspace.actionCorfunc) def saveCustomConfig(self): """ diff --git a/src/sas/qtgui/MainWindow/MainWindow.py b/src/sas/qtgui/MainWindow/MainWindow.py index 8303a75d0d..1545e1086b 100644 --- a/src/sas/qtgui/MainWindow/MainWindow.py +++ b/src/sas/qtgui/MainWindow/MainWindow.py @@ -32,7 +32,7 @@ def __init__(self, screen_resolution, parent=None): # the two scrollbars will help managing the workspace. self.workspace.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) self.workspace.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) - self.screen_width = screen_resolution.width() + self.screen_width = screen_resolution.width() self.screen_height = screen_resolution.height() self.setCentralWidget(self.workspace) @@ -47,6 +47,7 @@ def __init__(self, screen_resolution, parent=None): except Exception as ex: import logging logging.error("Application failed with: "+str(ex)) + raise ex def closeEvent(self, event): if self.guiManager.quitApplication(): @@ -120,6 +121,3 @@ def run_sasview(): # No need to .exec_ - the reactor takes care of it. reactor.run() - -if __name__ == "__main__": - run_sasview() diff --git a/src/sas/qtgui/MainWindow/UnitTesting/DataExplorerTest.py b/src/sas/qtgui/MainWindow/UnitTesting/DataExplorerTest.py index c35f84a140..e4843a0585 100644 --- a/src/sas/qtgui/MainWindow/UnitTesting/DataExplorerTest.py +++ b/src/sas/qtgui/MainWindow/UnitTesting/DataExplorerTest.py @@ -604,7 +604,7 @@ def testNewPlot1D(self, test_patch): QApplication.processEvents() # The plot was registered - self.assertEqual(len(PlotHelper.currentPlots()), 1) + self.assertEqual(len(PlotHelper.currentPlotIds()), 1) self.assertTrue(self.form.cbgraph.isEnabled()) self.assertTrue(self.form.cmdAppend.isEnabled()) @@ -682,14 +682,14 @@ def testAppendPlot(self, test_patch): QApplication.processEvents() # See that we have two plots - self.assertEqual(len(PlotHelper.currentPlots()), 2) + self.assertEqual(len(PlotHelper.currentPlotIds()), 2) # Add data to plot #1 self.form.cbgraph.setCurrentIndex(1) self.form.appendPlot() # See that we still have two plots - self.assertEqual(len(PlotHelper.currentPlots()), 2) + self.assertEqual(len(PlotHelper.currentPlotIds()), 2) def testUpdateGraphCombo(self): """ @@ -1111,7 +1111,7 @@ def testClosePlotsForItem(self): QApplication.processEvents() # The plot was registered - self.assertEqual(len(PlotHelper.currentPlots()), 1) + self.assertEqual(len(PlotHelper.currentPlotIds()), 1) self.assertEqual(len(self.form.plot_widgets), 1) # could have leftovers from previous tests #self.assertEqual(list(self.form.plot_widgets.keys()), ['Graph3']) @@ -1124,7 +1124,7 @@ def testClosePlotsForItem(self): self.form.closePlotsForItem(model_item) # See that no plot remained - self.assertEqual(len(PlotHelper.currentPlots()), 0) + self.assertEqual(len(PlotHelper.currentPlotIds()), 0) self.assertEqual(len(self.form.plot_widgets), 0) def testPlotsFromMultipleData1D(self): diff --git a/src/sas/qtgui/Perspectives/Corfunc/CorfuncPerspective.py b/src/sas/qtgui/Perspectives/Corfunc/CorfuncPerspective.py index 5bcfaaf854..215d1837fe 100644 --- a/src/sas/qtgui/Perspectives/Corfunc/CorfuncPerspective.py +++ b/src/sas/qtgui/Perspectives/Corfunc/CorfuncPerspective.py @@ -4,6 +4,7 @@ # pylint: disable=E1101 # global +from PyQt5.QtGui import QStandardItem from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg \ as FigureCanvas from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT @@ -11,22 +12,26 @@ from numpy.linalg.linalg import LinAlgError import numpy as np +from typing import Optional, List, Tuple + from PyQt5 import QtCore from PyQt5 import QtGui, QtWidgets -from PyQt5.QtWidgets import QMessageBox # sas-global # pylint: disable=import-error, no-name-in-module + import sas.qtgui.Utilities.GuiUtils as GuiUtils +from sas.qtgui.Utilities.Reports.reportdata import ReportData +from sas.qtgui.Utilities.Reports import ReportBase + from sas.sascalc.corfunc.corfunc_calculator import CorfuncCalculator # pylint: enable=import-error, no-name-in-module # local from .UI.CorfuncPanel import Ui_CorfuncDialog +from .corefuncutil import WIDGETS from .saveextrapolated import SaveExtrapolatedPopup -from .corefuncutil import WIDGETS as W - - +from ..perspective import Perspective class MyMplCanvas(FigureCanvas): """Ultimately, this is a QWidget (as well as a FigureCanvasAgg, etc.).""" @@ -38,7 +43,7 @@ def __init__(self, model, width=5, height=4, dpi=100): FigureCanvas.__init__(self, self.fig) - self.data = None + self.data: Optional[Tuple[np.ndarray, np.ndarray, np.ndarray]] = None self.extrap = None self.dragging = None self.draggable = False @@ -66,9 +71,9 @@ def on_mouse_down(self, event): if self.on_legend(event.x, event.y): return - qmin = float(self.model.item(W.W_QMIN).text()) - qmax1 = float(self.model.item(W.W_QMAX).text()) - qmax2 = float(self.model.item(W.W_QCUTOFF).text()) + qmin = float(self.model.item(WIDGETS.W_QMIN).text()) + qmax1 = float(self.model.item(WIDGETS.W_QMAX).text()) + qmax2 = float(self.model.item(WIDGETS.W_QCUTOFF).text()) q = event.xdata @@ -89,11 +94,11 @@ def on_mouse_up(self, event): return if self.dragging == "qmin": - item = W.W_QMIN + item = WIDGETS.W_QMIN elif self.dragging == "qmax1": - item = W.W_QMAX + item = WIDGETS.W_QMAX else: - item = W.W_QCUTOFF + item = WIDGETS.W_QCUTOFF self.model.setItem(item, QtGui.QStandardItem(str(GuiUtils.formatNumber(event.xdata)))) @@ -108,11 +113,11 @@ def on_motion(self, event): return if self.dragging == "qmin": - item = W.W_QMIN + item = WIDGETS.W_QMIN elif self.dragging == "qmax1": - item = W.W_QMAX + item = WIDGETS.W_QMAX else: - item = W.W_QCUTOFF + item = WIDGETS.W_QCUTOFF self.model.setItem(item, QtGui.QStandardItem(str(GuiUtils.formatNumber(event.xdata)))) @@ -124,7 +129,7 @@ def draw_q_space(self): as the bounds set by self.qmin, self.qmax1, and self.qmax2. It will also plot the extrpolation in self.extrap, if it exists.""" - self.draggable = True; + self.draggable = True self.fig.clf() @@ -136,9 +141,9 @@ def draw_q_space(self): self.axes.set_title("Scattering data") self.fig.tight_layout() - qmin = float(self.model.item(W.W_QMIN).text()) - qmax1 = float(self.model.item(W.W_QMAX).text()) - qmax2 = float(self.model.item(W.W_QCUTOFF).text()) + qmin = float(self.model.item(WIDGETS.W_QMIN).text()) + qmax1 = float(self.model.item(WIDGETS.W_QMAX).text()) + qmax2 = float(self.model.item(WIDGETS.W_QCUTOFF).text()) if self.data: # self.axes.plot(self.data.x, self.data.y, label="Experimental Data") @@ -189,10 +194,16 @@ def draw_real_space(self): self.draw() -class CorfuncWindow(QtWidgets.QDialog, Ui_CorfuncDialog): +class CorfuncWindow(QtWidgets.QDialog, Ui_CorfuncDialog, Perspective): """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 + + name = "Corfunc" + ext = "crf" + + @property + def title(self): + """ Window title """ + return "Corfunc Perspective" trigger = QtCore.pyqtSignal(tuple) @@ -201,7 +212,7 @@ def __init__(self, parent=None): super(CorfuncWindow, self).__init__() self.setupUi(self) - self.setWindowTitle("Corfunc Perspective") + self.setWindowTitle(self.title) self.parent = parent self.mapper = None @@ -211,19 +222,19 @@ def __init__(self, parent=None): self.communicate.dataDeletedSignal.connect(self.removeData) self._calculator = CorfuncCalculator() self._allow_close = False - self._model_item = None + self._model_item: Optional[QStandardItem] = None self.data = None self.has_data = False self.txtLowerQMin.setText("0.0") self.txtLowerQMin.setEnabled(False) self.extrapolation_curve = None - self._canvas = MyMplCanvas(self.model) - self.plotLayout.insertWidget(0, self._canvas) - self.plotLayout.insertWidget(1, NavigationToolbar2QT(self._canvas, self)) - self._realplot = MyMplCanvas(self.model) - self.plotLayout.insertWidget(2, self._realplot) - self.plotLayout.insertWidget(3, NavigationToolbar2QT(self._realplot, self)) + self._q_space_plot = MyMplCanvas(self.model) + self.plotLayout.insertWidget(0, self._q_space_plot) + self.plotLayout.insertWidget(1, NavigationToolbar2QT(self._q_space_plot, self)) + self._real_space_plot = MyMplCanvas(self.model) # TODO: This is not a good name, or structure either + self.plotLayout.insertWidget(2, self._real_space_plot) + self.plotLayout.insertWidget(3, NavigationToolbar2QT(self._real_space_plot, self)) self.gridLayout_4.setColumnStretch(0, 1) self.gridLayout_4.setColumnStretch(1, 2) @@ -269,44 +280,44 @@ def setup_model(self): """Populate the model with default data.""" # filename item = QtGui.QStandardItem(self._path) - self.model.setItem(W.W_FILENAME, item) + self.model.setItem(WIDGETS.W_FILENAME, item) - self.model.setItem(W.W_QMIN, + self.model.setItem(WIDGETS.W_QMIN, QtGui.QStandardItem("0.01")) - self.model.setItem(W.W_QMAX, + self.model.setItem(WIDGETS.W_QMAX, QtGui.QStandardItem("0.20")) - self.model.setItem(W.W_QCUTOFF, + self.model.setItem(WIDGETS.W_QCUTOFF, QtGui.QStandardItem("0.22")) - self.model.setItem(W.W_BACKGROUND, + self.model.setItem(WIDGETS.W_BACKGROUND, QtGui.QStandardItem("0")) #self.model.setItem(W.W_TRANSFORM, # QtGui.QStandardItem("Fourier")) - self.model.setItem(W.W_GUINIERA, + self.model.setItem(WIDGETS.W_GUINIERA, QtGui.QStandardItem("0.0")) - self.model.setItem(W.W_GUINIERB, + self.model.setItem(WIDGETS.W_GUINIERB, QtGui.QStandardItem("0.0")) - self.model.setItem(W.W_PORODK, + self.model.setItem(WIDGETS.W_PORODK, QtGui.QStandardItem("0.0")) - self.model.setItem(W.W_PORODSIGMA, + self.model.setItem(WIDGETS.W_PORODSIGMA, QtGui.QStandardItem("0.0")) - self.model.setItem(W.W_CORETHICK, QtGui.QStandardItem(str(0))) - self.model.setItem(W.W_INTTHICK, QtGui.QStandardItem(str(0))) - self.model.setItem(W.W_HARDBLOCK, QtGui.QStandardItem(str(0))) - self.model.setItem(W.W_CRYSTAL, QtGui.QStandardItem(str(0))) - self.model.setItem(W.W_POLY, QtGui.QStandardItem(str(0))) - self.model.setItem(W.W_PERIOD, QtGui.QStandardItem(str(0))) + self.model.setItem(WIDGETS.W_CORETHICK, QtGui.QStandardItem(str(0))) + self.model.setItem(WIDGETS.W_INTTHICK, QtGui.QStandardItem(str(0))) + self.model.setItem(WIDGETS.W_HARDBLOCK, QtGui.QStandardItem(str(0))) + self.model.setItem(WIDGETS.W_CRYSTAL, QtGui.QStandardItem(str(0))) + self.model.setItem(WIDGETS.W_POLY, QtGui.QStandardItem(str(0))) + self.model.setItem(WIDGETS.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._canvas.draw_q_space() - self._realplot.data = None - self._realplot.extrap = None - self._realplot.draw_real_space() + self._q_space_plot.data = None + self._q_space_plot.extrap = None + self._q_space_plot.draw_q_space() + self._real_space_plot.data = None + self._real_space_plot.extrap = None + self._real_space_plot.draw_real_space() # Clear calculator, model, and data path self._calculator = CorfuncCalculator() self._model_item = None @@ -320,15 +331,15 @@ def model_changed(self, _): if not self.mapper: return self.mapper.toFirst() - self._canvas.draw_q_space() + self._q_space_plot.draw_q_space() def _update_calculator(self): - self._calculator.lowerq = float(self.model.item(W.W_QMIN).text()) - qmax1 = float(self.model.item(W.W_QMAX).text()) - qmax2 = float(self.model.item(W.W_QCUTOFF).text()) + self._calculator.lowerq = float(self.model.item(WIDGETS.W_QMIN).text()) + qmax1 = float(self.model.item(WIDGETS.W_QMAX).text()) + qmax2 = float(self.model.item(WIDGETS.W_QCUTOFF).text()) self._calculator.upperq = (qmax1, qmax2) self._calculator.background = \ - float(self.model.item(W.W_BACKGROUND).text()) + float(self.model.item(WIDGETS.W_BACKGROUND).text()) def extrapolate(self): """Extend the experiemntal data with guinier and porod curves.""" @@ -342,23 +353,23 @@ def extrapolate(self): "cutoff Q, or increasing the lower Q." QtWidgets.QMessageBox.warning(self, "Calculation Error", message) - self.model.setItem(W.W_GUINIERA, QtGui.QStandardItem("")) - self.model.setItem(W.W_GUINIERB, QtGui.QStandardItem("")) - self.model.setItem(W.W_PORODK, QtGui.QStandardItem("")) - self.model.setItem(W.W_PORODSIGMA, QtGui.QStandardItem("")) - self._canvas.extrap = None + self.model.setItem(WIDGETS.W_GUINIERA, QtGui.QStandardItem("")) + self.model.setItem(WIDGETS.W_GUINIERB, QtGui.QStandardItem("")) + self.model.setItem(WIDGETS.W_PORODK, QtGui.QStandardItem("")) + self.model.setItem(WIDGETS.W_PORODSIGMA, QtGui.QStandardItem("")) + self._q_space_plot.extrap = None self.model_changed(None) return finally: self.model.itemChanged.connect(self.model_changed) - self.model.setItem(W.W_GUINIERA, QtGui.QStandardItem("{:.3g}".format(params['A']))) - self.model.setItem(W.W_GUINIERB, QtGui.QStandardItem("{:.3g}".format(params['B']))) - self.model.setItem(W.W_PORODK, QtGui.QStandardItem("{:.3g}".format(params['K']))) - self.model.setItem(W.W_PORODSIGMA, - QtGui.QStandardItem("{:.4g}".format(params['sigma']))) + self.model.setItem(WIDGETS.W_GUINIERA, QtGui.QStandardItem("{:.3g}".format(params['A']))) + self.model.setItem(WIDGETS.W_GUINIERB, QtGui.QStandardItem("{:.3g}".format(params['B']))) + self.model.setItem(WIDGETS.W_PORODK, QtGui.QStandardItem("{:.3g}".format(params['K']))) + self.model.setItem(WIDGETS.W_PORODSIGMA, + QtGui.QStandardItem("{:.4g}".format(params['sigma']))) - self._canvas.extrap = extrapolation + self._q_space_plot.extrap = extrapolation self.model_changed(None) self.cmdTransform.setEnabled(True) self.cmdSaveExtrapolation.setEnabled(True) @@ -370,8 +381,8 @@ def transform(self): method = "fourier" - extrap = self._canvas.extrap - background = float(self.model.item(W.W_BACKGROUND).text()) + extrap = self._q_space_plot.extrap + background = float(self.model.item(WIDGETS.W_BACKGROUND).text()) def updatefn(msg): """Report progress of transformation.""" @@ -387,26 +398,26 @@ def completefn(transforms): def finish_transform(self, transforms): - self._realplot.data = transforms + self._real_space_plot.data = transforms self.update_real_space_plot(transforms) - self._realplot.draw_real_space() + self._real_space_plot.draw_real_space() self.cmdExtract.setEnabled(True) self.cmdSave.setEnabled(True) def extract(self): - transforms = self._realplot.data + transforms = self._real_space_plot.data params = self._calculator.extract_parameters(transforms[0]) self.model.itemChanged.disconnect(self.model_changed) - self.model.setItem(W.W_CORETHICK, QtGui.QStandardItem("{:.3g}".format(params['d0']))) - self.model.setItem(W.W_INTTHICK, QtGui.QStandardItem("{:.3g}".format(params['dtr']))) - self.model.setItem(W.W_HARDBLOCK, QtGui.QStandardItem("{:.3g}".format(params['Lc']))) - self.model.setItem(W.W_CRYSTAL, QtGui.QStandardItem("{:.3g}".format(params['fill']))) - self.model.setItem(W.W_POLY, QtGui.QStandardItem("{:.3g}".format(params['A']))) - self.model.setItem(W.W_PERIOD, QtGui.QStandardItem("{:.3g}".format(params['max']))) + self.model.setItem(WIDGETS.W_CORETHICK, QtGui.QStandardItem("{:.3g}".format(params['d0']))) + self.model.setItem(WIDGETS.W_INTTHICK, QtGui.QStandardItem("{:.3g}".format(params['dtr']))) + self.model.setItem(WIDGETS.W_HARDBLOCK, QtGui.QStandardItem("{:.3g}".format(params['Lc']))) + self.model.setItem(WIDGETS.W_CRYSTAL, QtGui.QStandardItem("{:.3g}".format(params['fill']))) + self.model.setItem(WIDGETS.W_POLY, QtGui.QStandardItem("{:.3g}".format(params['A']))) + self.model.setItem(WIDGETS.W_PERIOD, QtGui.QStandardItem("{:.3g}".format(params['max']))) self.model.itemChanged.connect(self.model_changed) self.model_changed(None) @@ -440,25 +451,25 @@ def setup_mapper(self): self.mapper.setOrientation(QtCore.Qt.Vertical) self.mapper.setModel(self.model) - self.mapper.addMapping(self.txtLowerQMax, W.W_QMIN) - self.mapper.addMapping(self.txtUpperQMin, W.W_QMAX) - self.mapper.addMapping(self.txtUpperQMax, W.W_QCUTOFF) - self.mapper.addMapping(self.txtBackground, W.W_BACKGROUND) + self.mapper.addMapping(self.txtLowerQMax, WIDGETS.W_QMIN) + self.mapper.addMapping(self.txtUpperQMin, WIDGETS.W_QMAX) + self.mapper.addMapping(self.txtUpperQMax, WIDGETS.W_QCUTOFF) + self.mapper.addMapping(self.txtBackground, WIDGETS.W_BACKGROUND) #self.mapper.addMapping(self.transformCombo, W.W_TRANSFORM) - self.mapper.addMapping(self.txtGuinierA, W.W_GUINIERA) - self.mapper.addMapping(self.txtGuinierB, W.W_GUINIERB) - self.mapper.addMapping(self.txtPorodK, W.W_PORODK) - self.mapper.addMapping(self.txtPorodSigma, W.W_PORODSIGMA) + self.mapper.addMapping(self.txtGuinierA, WIDGETS.W_GUINIERA) + self.mapper.addMapping(self.txtGuinierB, WIDGETS.W_GUINIERB) + self.mapper.addMapping(self.txtPorodK, WIDGETS.W_PORODK) + self.mapper.addMapping(self.txtPorodSigma, WIDGETS.W_PORODSIGMA) - self.mapper.addMapping(self.txtAvgCoreThick, W.W_CORETHICK) - self.mapper.addMapping(self.txtAvgIntThick, W.W_INTTHICK) - self.mapper.addMapping(self.txtAvgHardBlock, W.W_HARDBLOCK) - self.mapper.addMapping(self.txtPolydisp, W.W_POLY) - self.mapper.addMapping(self.txtLongPeriod, W.W_PERIOD) - self.mapper.addMapping(self.txtLocalCrystal, W.W_CRYSTAL) + self.mapper.addMapping(self.txtAvgCoreThick, WIDGETS.W_CORETHICK) + self.mapper.addMapping(self.txtAvgIntThick, WIDGETS.W_INTTHICK) + self.mapper.addMapping(self.txtAvgHardBlock, WIDGETS.W_HARDBLOCK) + self.mapper.addMapping(self.txtPolydisp, WIDGETS.W_POLY) + self.mapper.addMapping(self.txtLongPeriod, WIDGETS.W_PERIOD) + self.mapper.addMapping(self.txtLocalCrystal, WIDGETS.W_CRYSTAL) - self.mapper.addMapping(self.txtFilename, W.W_FILENAME) + self.mapper.addMapping(self.txtFilename, WIDGETS.W_FILENAME) self.mapper.toFirst() @@ -468,7 +479,7 @@ def calculate_background(self): try: background = self._calculator.compute_background() temp = QtGui.QStandardItem("{:.4g}".format(background)) - self.model.setItem(W.W_BACKGROUND, temp) + self.model.setItem(WIDGETS.W_BACKGROUND, temp) except (LinAlgError, ValueError): message = "These is not enough data in the fitting range. "\ "Try decreasing the upper Q or increasing the cutoff Q" @@ -499,18 +510,12 @@ def allowSwap(): """ return False - def setData(self, data_item, is_batch=False): + def setData(self, data_item: List[QStandardItem], is_batch=False): """ Obtain a QStandardItem object and dissect it to get Data1D/2D Pass it over to the calculator """ - if not isinstance(data_item, list): - msg = "Incorrect type passed to the Corfunc Perpsective" - raise AttributeError(msg) - if not isinstance(data_item[0], QtGui.QStandardItem): - 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 " @@ -530,26 +535,26 @@ def setData(self, data_item, is_batch=False): self.cmdExtrapolate.setEnabled(True) self.model.itemChanged.disconnect(self.model_changed) - self.model.setItem(W.W_GUINIERA, QtGui.QStandardItem("")) - self.model.setItem(W.W_GUINIERB, QtGui.QStandardItem("")) - self.model.setItem(W.W_PORODK, QtGui.QStandardItem("")) - self.model.setItem(W.W_PORODSIGMA, QtGui.QStandardItem("")) - self.model.setItem(W.W_CORETHICK, QtGui.QStandardItem("")) - self.model.setItem(W.W_INTTHICK, QtGui.QStandardItem("")) - self.model.setItem(W.W_HARDBLOCK, QtGui.QStandardItem("")) - 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(WIDGETS.W_GUINIERA, QtGui.QStandardItem("")) + self.model.setItem(WIDGETS.W_GUINIERB, QtGui.QStandardItem("")) + self.model.setItem(WIDGETS.W_PORODK, QtGui.QStandardItem("")) + self.model.setItem(WIDGETS.W_PORODSIGMA, QtGui.QStandardItem("")) + self.model.setItem(WIDGETS.W_CORETHICK, QtGui.QStandardItem("")) + self.model.setItem(WIDGETS.W_INTTHICK, QtGui.QStandardItem("")) + self.model.setItem(WIDGETS.W_HARDBLOCK, QtGui.QStandardItem("")) + self.model.setItem(WIDGETS.W_CRYSTAL, QtGui.QStandardItem("")) + self.model.setItem(WIDGETS.W_POLY, QtGui.QStandardItem("")) + self.model.setItem(WIDGETS.W_PERIOD, QtGui.QStandardItem("")) self.model.itemChanged.connect(self.model_changed) - self._canvas.data = data - self._canvas.extrap = None + self._q_space_plot.data = data + self._q_space_plot.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.model.setItem(WIDGETS.W_FILENAME, QtGui.QStandardItem(self._path)) + self._real_space_plot.data = None + self._real_space_plot.draw_real_space() self.has_data = True def setClosable(self, value=True): @@ -576,13 +581,6 @@ def closeEvent(self, event): # Maybe we should just minimize self.setWindowState(QtCore.Qt.WindowMinimized) - def title(self): - """ - Window title function used by certain error messages. - Check DataExplorer.py, line 355 - """ - return "Corfunc Perspective" - def on_save(self): """ Save corfunc state into a file @@ -596,7 +594,7 @@ def on_save(self): if "." not in f_name: f_name += ".crf" - data1, data3, data_idf = self._realplot.data + data1, data3, data_idf = self._real_space_plot.data with open(f_name, "w") as outfile: outfile.write("X 1D 3D IDF\n") @@ -681,34 +679,34 @@ def updateFromParameters(self, params): 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'))) + WIDGETS.W_GUINIERA, QtGui.QStandardItem(params.get('guinier_a', '0.0'))) self.model.setItem( - W.W_GUINIERB, QtGui.QStandardItem(params.get('guinier_b', '0.0'))) + WIDGETS.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( + WIDGETS.W_PORODK, QtGui.QStandardItem(params.get('porod_k', '0.0'))) + self.model.setItem(WIDGETS.W_PORODSIGMA, QtGui.QStandardItem( params.get('porod_sigma', '0.0'))) - self.model.setItem(W.W_CORETHICK, QtGui.QStandardItem( + self.model.setItem(WIDGETS.W_CORETHICK, QtGui.QStandardItem( params.get('avg_core_thick', '0'))) - self.model.setItem(W.W_INTTHICK, QtGui.QStandardItem( + self.model.setItem(WIDGETS.W_INTTHICK, QtGui.QStandardItem( params.get('avg_inter_thick', '0'))) - self.model.setItem(W.W_HARDBLOCK, QtGui.QStandardItem( + self.model.setItem(WIDGETS.W_HARDBLOCK, QtGui.QStandardItem( params.get('avg_hard_block_thick', '0'))) - self.model.setItem(W.W_CRYSTAL, QtGui.QStandardItem( + self.model.setItem(WIDGETS.W_CRYSTAL, QtGui.QStandardItem( params.get('local_crystalinity', '0'))) self.model.setItem( - W.W_POLY, QtGui.QStandardItem(params.get('polydispersity', '0'))) + WIDGETS.W_POLY, QtGui.QStandardItem(params.get('polydispersity', '0'))) self.model.setItem( - W.W_PERIOD, QtGui.QStandardItem(params.get('long_period', '0'))) + WIDGETS.W_PERIOD, QtGui.QStandardItem(params.get('long_period', '0'))) self.model.setItem( - W.W_FILENAME, QtGui.QStandardItem(params.get('data_name', ''))) + WIDGETS.W_FILENAME, QtGui.QStandardItem(params.get('data_name', ''))) self.model.setItem( - W.W_QMIN, QtGui.QStandardItem(params.get('lower_q_max', '0.01'))) + WIDGETS.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'))) + WIDGETS.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( + WIDGETS.W_QCUTOFF, QtGui.QStandardItem(params.get('upper_q_max', '0.22'))) + self.model.setItem(WIDGETS.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') @@ -719,3 +717,46 @@ def updateFromParameters(self, params): self.extrapolate() if params.get('long_period', '0') != '0': self.transform() + + def get_figures(self): + """ + Get plots for the report + """ + + return [self._real_space_plot.fig] + + @property + def real_space_figure(self): + return self._real_space_plot.fig + + @property + def q_space_figure(self): + return self._q_space_plot.fig + + @property + def supports_reports(self) -> bool: + return True + + def getReport(self) -> Optional[ReportData]: + if not self.has_data: + return None + + report = ReportBase("Correlation Function") + report.add_data_details(self.data) + + # Format keys + parameters = self.getState() + fancy_parameters = {} + + for key in parameters: + nice_key = " ".join([s.capitalize() for s in key.split("_")]) + if parameters[key].strip() == '': + fancy_parameters[nice_key] = '-' + else: + fancy_parameters[nice_key] = parameters[key] + + report.add_table_dict(fancy_parameters, ("Parameter", "Value")) + report.add_plot(self.q_space_figure) + report.add_plot(self.real_space_figure) + + return report.report_data \ No newline at end of file diff --git a/src/sas/qtgui/Perspectives/Fitting/FittingLogic.py b/src/sas/qtgui/Perspectives/Fitting/FittingLogic.py index d80b4ab95d..90f30c1e16 100644 --- a/src/sas/qtgui/Perspectives/Fitting/FittingLogic.py +++ b/src/sas/qtgui/Perspectives/Fitting/FittingLogic.py @@ -7,7 +7,7 @@ from sas.sascalc.dataloader.data_info import Source -class FittingLogic(object): +class FittingLogic: """ All the data-related logic. This class deals exclusively with Data1D/2D No QStandardModelIndex here. diff --git a/src/sas/qtgui/Perspectives/Fitting/FittingPerspective.py b/src/sas/qtgui/Perspectives/Fitting/FittingPerspective.py index 880fdd80ca..1e86f1fe9d 100644 --- a/src/sas/qtgui/Perspectives/Fitting/FittingPerspective.py +++ b/src/sas/qtgui/Perspectives/Fitting/FittingPerspective.py @@ -1,8 +1,8 @@ -from distutils.command.config import config - import numpy import copy +from typing import Optional + from PyQt5 import QtCore from PyQt5 import QtGui from PyQt5 import QtWidgets @@ -10,7 +10,6 @@ from bumps import options from bumps import fitters -import sas.qtgui.Utilities.LocalConfig as LocalConfig import sas.qtgui.Utilities.ObjectLibrary as ObjectLibrary import sas.qtgui.Utilities.GuiUtils as GuiUtils from sas.qtgui.Perspectives.Fitting.Constraint import Constraint @@ -19,19 +18,28 @@ from sas.qtgui.Perspectives.Fitting.ConstraintWidget import ConstraintWidget from sas.qtgui.Perspectives.Fitting.FittingOptions import FittingOptions from sas.qtgui.Perspectives.Fitting.GPUOptions import GPUOptions +from sas.qtgui.Perspectives.perspective import Perspective + +from sas.qtgui.Utilities.Reports.reportdata import ReportData -class FittingWindow(QtWidgets.QTabWidget): +class FittingWindow(QtWidgets.QTabWidget, Perspective): """ """ tabsModifiedSignal = QtCore.pyqtSignal() fittingStartedSignal = QtCore.pyqtSignal(list) fittingStoppedSignal = QtCore.pyqtSignal(list) - name = "Fitting" # For displaying in the combo box in DataExplorer - ext = "fitv" # Extension used for saving analyses + name = "Fitting" + ext = "fitv" + + @property + def title(self): + """ Window title""" + return "Fitting Perspective" + def __init__(self, parent=None, data=None): - super(FittingWindow, self).__init__() + super().__init__() self.parent = parent self._data = data @@ -94,6 +102,7 @@ def __init__(self, parent=None, data=None): self.plusButton.setToolTip("Add a new Fit Page") self.plusButton.clicked.connect(lambda: self.addFit(None)) + def updateWindowTitle(self): """ Update the window title with the current optimizer name @@ -498,12 +507,20 @@ def getCurrentStateAsXml(self): return state @property - def currentTab(self): + def currentTab(self): # TODO: More pythonic name """ Returns the tab widget currently shown """ return self.currentWidget() + @property + def currentFittingWidget(self) -> Optional[FittingWidget]: + current_tab = self.currentTab + if isinstance(current_tab, FittingWidget): + return current_tab + else: + return None + def getFitTabs(self): """ Returns the list of fitting tabs @@ -555,3 +572,17 @@ def getTabByName(self, name): if tab.modelName() == name: return tab return None + + @property + def supports_reports(self) -> bool: + return True + + def getReport(self) -> Optional[ReportData]: + """ Get the report from the current tab""" + fitting_widget = self.currentFittingWidget + return None if fitting_widget is None else fitting_widget.getReport() + + @property + def supports_fitting_menu(self) -> bool: + return True + diff --git a/src/sas/qtgui/Perspectives/Fitting/ReportPageLogic.py b/src/sas/qtgui/Perspectives/Fitting/ReportPageLogic.py index ab97dbbff1..73a7ceb859 100644 --- a/src/sas/qtgui/Perspectives/Fitting/ReportPageLogic.py +++ b/src/sas/qtgui/Perspectives/Fitting/ReportPageLogic.py @@ -3,21 +3,26 @@ import datetime import re import sys -import tempfile + +from typing import List import logging from io import BytesIO import urllib.parse +import html2text from matplotlib.backends.backend_agg import FigureCanvasAgg as FigureCanvas from bumps import options import sas.qtgui.Utilities.GuiUtils as GuiUtils +from sas.qtgui.Plotting.PlotterBase import PlotterBase +from sas.qtgui.Utilities.Reports.reportdata import ReportData + from sas.sasview import __version__ as SASVIEW_VERSION from sasmodels import __version__ as SASMODELS_VERSION - -class ReportPageLogic(object): +# TODO: Integrate with other reports +class ReportPageLogic: """ Logic for the Report Page functionality. Refactored from FittingWidget. """ @@ -29,22 +34,18 @@ def __init__(self, parent=None, kernel_module=None, data=None, index=None, param self._index = index self.params = params - @staticmethod - def cleanhtml(raw_html): - """Remove html tags from a document""" - cleanr = re.compile('<.*?>') - cleantext = re.sub(cleanr, '', raw_html) - return cleantext - def reportList(self): + def reportList(self) -> ReportData: # TODO: Rename to reference report object """ Return the HTML version of the full report """ if self.kernel_module is None: - report_txt = "No model defined" - report_html = HEADER % report_txt - images = [] - return [report_html, report_txt, images] + + text = "No model defined" + + return ReportData( + html=HEADER % text, + text=text) # Get plot image from plotpanel images = self.getImages() @@ -59,9 +60,10 @@ def reportList(self): report_html = report_header + report_parameters + imagesHTML - report_txt = self.cleanhtml(report_html) + report_txt = html2text.html2text(GuiUtils.replaceHTMLwithASCII(report_html)) - report_list = [report_html, report_txt, images] + # report_list = ReportData(html=report_html, text=report_txt, images=images) + report_list = ReportData(html=report_html, text=report_txt) return report_list @@ -98,7 +100,7 @@ def reportHeader(self): return report - def buildPlotsForReport(self, images): + def buildPlotsForReport(self, images): # TODO: Unify with other report image to html conversion """ Convert Matplotlib figure 'fig' into a tag for HTML use using base64 encoding. """ html = FEET_1 % self.data.name @@ -161,19 +163,19 @@ def reportParams(self): return report - def getImages(self): + def getImages(self) -> List[PlotterBase]: """ Create MPL figures for the current fit """ graphs = [] modelname = self.kernel_module.name if not modelname or self._index is None: - return None + return [] plot_ids = [plot.id for plot in GuiUtils.plotsFromModel(modelname, self._index)] # Active plots import sas.qtgui.Plotting.PlotHelper as PlotHelper - shown_plot_names = PlotHelper.currentPlots() + shown_plot_names = PlotHelper.currentPlotIds() # current_plots = list of graph names of currently shown plots # which are related to this dataset @@ -188,6 +190,7 @@ def getImages(self): # Simple html report template +# TODO Remove microsoft based stuff - probably implicit in the refactoring to come HEADER = "\n" HEADER += "\n" HEADER += " dict: # TODO: Better name, serializePage, pageData """ Serializes full state of this invariant page Called by Save Analysis @@ -1039,7 +1044,7 @@ def getPage(self): param_dict['data_id'] = str(self._data.id) return param_dict - def getState(self): + def getState(self): # TODO: Better name, serializeState, stateData """ Collects all active params into a dictionary of {name: value} :return: {name: value} diff --git a/src/sas/qtgui/Perspectives/Inversion/InversionPerspective.py b/src/sas/qtgui/Perspectives/Inversion/InversionPerspective.py index 229f49f290..d5247ae452 100644 --- a/src/sas/qtgui/Perspectives/Inversion/InversionPerspective.py +++ b/src/sas/qtgui/Perspectives/Inversion/InversionPerspective.py @@ -16,12 +16,13 @@ from sas.qtgui.Plotting.PlotterData import Data1D # Batch calculation display from sas.qtgui.Utilities.GridPanel import BatchInversionOutputPanel +from sas.qtgui.Perspectives.perspective import Perspective - -def is_float(value): - """Converts text input values to floats. Empty strings throw ValueError""" +def str_to_float(string: str): + """Converts text input values to float. + Failure to parse string returns zero""" try: - return float(value) + return float(string) except ValueError: return 0.0 @@ -35,13 +36,19 @@ def is_float(value): logger = logging.getLogger(__name__) -class InversionWindow(QtWidgets.QDialog, Ui_PrInversion): +class InversionWindow(QtWidgets.QDialog, Ui_PrInversion, Perspective): """ The main window for the P(r) Inversion perspective. """ name = "Inversion" - ext = "pr" # Extension used for saving analyses + ext = "pr" + + @property + def title(self) -> str: + """ Window title""" + return "P(r) Inversion Perspective" + estimateSignal = QtCore.pyqtSignal(tuple) estimateNTSignal = QtCore.pyqtSignal(tuple) estimateDynamicNTSignal = QtCore.pyqtSignal(tuple) @@ -49,10 +56,10 @@ class InversionWindow(QtWidgets.QDialog, Ui_PrInversion): calculateSignal = QtCore.pyqtSignal(tuple) def __init__(self, parent=None, data=None): - super(InversionWindow, self).__init__() + super().__init__() self.setupUi(self) - self.setWindowTitle("P(r) Inversion Perspective") + self.setWindowTitle(self.title) self._manager = parent #Needed for Batch fitting @@ -201,15 +208,15 @@ def setupLinks(self): self.backgroundInput.textChanged.connect( lambda: self.set_background(self.backgroundInput.text())) self.regularizationConstantInput.textChanged.connect( - lambda: self._calculator.set_alpha(is_float(self.regularizationConstantInput.text()))) + lambda: self._calculator.set_alpha(str_to_float(self.regularizationConstantInput.text()))) self.maxDistanceInput.textChanged.connect( - lambda: self._calculator.set_dmax(is_float(self.maxDistanceInput.text()))) + lambda: self._calculator.set_dmax(str_to_float(self.maxDistanceInput.text()))) self.maxQInput.editingFinished.connect(self.check_q_high) self.minQInput.editingFinished.connect(self.check_q_low) self.slitHeightInput.textChanged.connect( - lambda: self._calculator.set_slit_height(is_float(self.slitHeightInput.text()))) + lambda: self._calculator.set_slit_height(str_to_float(self.slitHeightInput.text()))) self.slitWidthInput.textChanged.connect( - lambda: self._calculator.set_slit_width(is_float(self.slitWidthInput.text()))) + lambda: self._calculator.set_slit_width(str_to_float(self.slitWidthInput.text()))) self.model.itemChanged.connect(self.model_changed) self.estimateNTSignal.connect(self._estimateNTUpdate) @@ -521,7 +528,7 @@ def setData(self, data_item=None, is_batch=False): if isinstance(self.logic.data, Data1D): self.setCurrentData(data) - def updateDataList(self, dataRef): + def updateDataList(self, dataRef): # TODO Typing, name, underscore prefix """Save the current data state of the window into self._data_list""" if dataRef is None: return diff --git a/src/sas/qtgui/Perspectives/perspective.py b/src/sas/qtgui/Perspectives/perspective.py new file mode 100644 index 0000000000..1c4b1c22bb --- /dev/null +++ b/src/sas/qtgui/Perspectives/perspective.py @@ -0,0 +1,133 @@ +from abc import ABCMeta, abstractmethod +from typing import List, Optional, Union, Dict + +from PyQt5.QtGui import QStandardItem +from PyQt5 import QtCore + +from sas.qtgui.Utilities.Reports.reportdata import ReportData + + +class PerspectiveMeta(type(QtCore.QObject), ABCMeta): + """ Metaclass for both ABC and Qt objects + + This is needed to enable the mixin of Perspective until + a future refactoring unified the Qt functionality of + all the perspectives and brings it into the base class + """ + + +class Perspective(object, metaclass=PerspectiveMeta): + + """ + Mixin class for all perspectives, + all perspectives should have these methods + """ + + @classmethod + @property + @abstractmethod + def name(cls) -> str: + """ Name of the perspective""" + + @property + @abstractmethod + def title(cls) -> str: + """ Window title""" + + + # + # Data I/O calls + # + + @abstractmethod + def setData(self, data_item: List[QStandardItem], is_batch: bool=False): + """ Set the data to be processed in this perspective, called when + the 'send data' button is pressed""" + pass # TODO: Should we really be passing Qt objects around, rather than actual data + + def removeData(self, data_list: Optional[Union[QStandardItem, List[QStandardItem]]]): + """ Remove data from """ + raise NotImplementedError(f"Remove data not implemented in {self.name}") + + def allowBatch(self) -> bool: + """ Can this perspective handle batch processing, default no""" + return False # TODO: Make into property + + def allowSwap(self) -> bool: + """ Does this perspective allow swapping of data, + i.e. replacement of data without changing parameters, + default no""" + return False # TODO: Make into property + + def swapData(self, new_data: QStandardItem): + """ Swap in new data without changing parameters""" + raise NotImplementedError(f"{self.name} perspective does not current support swapping data sets") + + # + # State loading/saving + # + + @classmethod + @property + @abstractmethod + def ext(cls) -> str: + """ File extension used when saving perspective data""" + # TODO: Refactor to save_file_extension + + def isSerializable(self) -> bool: + """ Can this perspective be serialised - default is no""" + return False # TODO: Refactor to serializable property + + def serialiseAll(self) -> dict: + raise NotImplementedError(f"{self.name} perspective is not serializable") + + @abstractmethod + def updateFromParameters(self, params: dict): # TODO: Pythonic name + """ Update the perspective using a dictionary of parameters + e.g. those loaded via open project or open analysis menu items""" + pass + + # TODO: Use a more ordered datastructure for constraints + def updateFromConstraints(self, constraints: Dict[str, list]): + """ + Updates all tabs with constraints present in *constraint_dict*, where + *constraint_dict* keys are the fit page name, and the value is a + list of constraints. A constraint is represented by a list [value, + param, value_ex, validate, function] of attributes of a Constraint + object""" # TODO: Sort out docstring + + + + # + # Other shared functionality + # + + def getReport(self) -> Optional[ReportData]: # TODO: Refactor to just report, or report_html + """ A string containing the HTML to be shown in the report""" + raise NotImplementedError(f"Report not implemented for {self.name}") + + + # + # Window behavior + # + + @abstractmethod + def setClosable(self, value: bool): + """ Set whether this perspective can be closed""" + pass # TODO: refactor to closable property + + def isClosable(self) -> bool: + return False # TODO: refactor to closable property + + # + # Menubar option + # + + @property + def supports_reports(self) -> bool: + return False + + @property + def supports_fitting_menu(self) -> bool: + return False + diff --git a/src/sas/qtgui/Plotting/PlotHelper.py b/src/sas/qtgui/Plotting/PlotHelper.py index bbbebad683..6a08b8aa7a 100644 --- a/src/sas/qtgui/Plotting/PlotHelper.py +++ b/src/sas/qtgui/Plotting/PlotHelper.py @@ -5,6 +5,9 @@ """ import sys +# TODO Refactor to allow typing without circular import +#from sas.qtgui.Plotting.PlotterBase import PlotterBase + this = sys.modules[__name__] this._plots = {} @@ -16,12 +19,13 @@ def clear(): """ this._plots = {} +#def addPlot(plot: PlotterBase): def addPlot(plot): """ Adds a new plot to the current dictionary of plots """ this._plot_id += 1 - this._plots["Graph%s"%str(this._plot_id)] = plot + this._plots["Graph%s"%str(this._plot_id)] = plot # TODO: Why??? def deletePlot(plot_id): """ @@ -30,7 +34,7 @@ def deletePlot(plot_id): if plot_id in this._plots: del this._plots[plot_id] -def currentPlots(): +def currentPlotIds(): """ Returns a list of IDs for all currently active plots """ diff --git a/src/sas/qtgui/Plotting/PlotterBase.py b/src/sas/qtgui/Plotting/PlotterBase.py index 1c2c1d48a6..1dff980cac 100644 --- a/src/sas/qtgui/Plotting/PlotterBase.py +++ b/src/sas/qtgui/Plotting/PlotterBase.py @@ -24,6 +24,8 @@ class PlotterBase(QtWidgets.QWidget): + #TODO: Describe what this class is + def __init__(self, parent=None, manager=None, quickplot=False): super(PlotterBase, self).__init__(parent) @@ -155,8 +157,8 @@ def item(self, item=None): self._item = item @property - def xLabel(self, xlabel=""): - """ x-label setter """ + def xLabel(self): + """ x-label getter """ return self.x_label @xLabel.setter @@ -165,8 +167,8 @@ def xLabel(self, xlabel=""): self.x_label = r'$%s$'% xlabel if xlabel else "" @property - def yLabel(self, ylabel=""): - """ y-label setter """ + def yLabel(self): + """ y-label getter """ return self.y_label @yLabel.setter diff --git a/src/sas/qtgui/Plotting/UnitTesting/PlotHelperTest.py b/src/sas/qtgui/Plotting/UnitTesting/PlotHelperTest.py index af43755f09..50646cdb54 100644 --- a/src/sas/qtgui/Plotting/UnitTesting/PlotHelperTest.py +++ b/src/sas/qtgui/Plotting/UnitTesting/PlotHelperTest.py @@ -38,13 +38,13 @@ def testFunctions(self): # Other properties #self.assertEqual(PlotHelper.currentPlots(), [plot_id, plot_id_2]) - self.assertTrue(set(PlotHelper.currentPlots()).issubset([plot_id, plot_id_2])) + self.assertTrue(set(PlotHelper.currentPlotIds()).issubset([plot_id, plot_id_2])) self.assertEqual(PlotHelper.plotById(plot_id), plot) self.assertEqual(PlotHelper.plotById(plot_id_2), plot2) # Delete a graph PlotHelper.deletePlot(plot_id) - self.assertEqual(PlotHelper.currentPlots(), [plot_id_2]) + self.assertEqual(PlotHelper.currentPlotIds(), [plot_id_2]) # Add another graph to see the counter plot3 = "Just another plot. Move along." diff --git a/src/sas/qtgui/Utilities/GuiUtils.py b/src/sas/qtgui/Utilities/GuiUtils.py index 287c520758..9122680b56 100644 --- a/src/sas/qtgui/Utilities/GuiUtils.py +++ b/src/sas/qtgui/Utilities/GuiUtils.py @@ -1052,33 +1052,45 @@ def formatValue(value): value = str(formatNumber(value, True)) return value +# TODO: This is currently case sensitive def replaceHTMLwithUTF8(html): """ Replace some important HTML-encoded characters with their UTF-8 equivalents """ # Angstrom - html_out = html.replace("Å", "Å") + html = html.replace("Å", "Å") # Hex + html = html.replace("Å", "Å") # Dec + # infinity - html_out = html_out.replace("∞", "∞") + html = html.replace("∞", "∞") # Hex + html = html.replace("∞", "∞") # Dec + # +/- - html_out = html_out.replace("±", "±") + html = html.replace("&#b1;", "±") # Hex + html = html.replace("±", "±") # Dec - return html_out + return html +# TODO: This is currently case sensitive def replaceHTMLwithASCII(html): """ Replace some important HTML-encoded characters with their ASCII equivalents """ - # Angstrom - html_out = html.replace("Å", "Ang") + + html = html.replace("Å", "Ang") # Hex + html = html.replace("Å", "Ang") # Dec + # infinity - html_out = html_out.replace("∞", "inf") + html = html.replace("∞", "inf") # Hex + html = html.replace("∞", "inf") # Dec + # +/- - html_out = html_out.replace("±", "+/-") + html = html.replace("±", "+/-") # Hex + html = html.replace("±", "+/-") # Dec - return html_out + return html def convertUnitToUTF8(unit): """ diff --git a/src/sas/qtgui/Utilities/ReportDialog.py b/src/sas/qtgui/Utilities/Reports/ReportDialog.py similarity index 61% rename from src/sas/qtgui/Utilities/ReportDialog.py rename to src/sas/qtgui/Utilities/Reports/ReportDialog.py index 4998b29ba8..26282078b3 100644 --- a/src/sas/qtgui/Utilities/ReportDialog.py +++ b/src/sas/qtgui/Utilities/Reports/ReportDialog.py @@ -4,6 +4,7 @@ import logging import traceback from xhtml2pdf import pisa +from typing import Optional from PyQt5 import QtWidgets, QtCore from PyQt5 import QtPrintSupport @@ -11,30 +12,29 @@ import sas.qtgui.Utilities.GuiUtils as GuiUtils import sas.qtgui.Utilities.ObjectLibrary as ObjectLibrary -from sas.qtgui.Utilities.UI.ReportDialogUI import Ui_ReportDialogUI +from sas.qtgui.Utilities.Reports.UI.ReportDialogUI import Ui_ReportDialogUI +from sas.qtgui.Utilities.Reports.reportdata import ReportData class ReportDialog(QtWidgets.QDialog, Ui_ReportDialogUI): """ Class for stateless grid-like printout of model parameters for mutiple models """ - def __init__(self, parent=None, report_list=None): + def __init__(self, report_data: ReportData, parent: Optional[QtCore.QObject]=None): - super(ReportDialog, self).__init__(parent._parent) + super().__init__(parent) self.setupUi(self) # disable the context help icon self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint) - assert isinstance(report_list, list) - assert len(report_list) == 3 + self.report_data = report_data - self.data_html, self.data_txt, self.data_images = report_list #self.save_location = None #if 'ReportDialog_directory' in ObjectLibrary.listObjects(): self.save_location = ObjectLibrary.getObject('ReportDialog_directory') # Fill in the table from input data - self.setupDialog(self.data_html) + self.setupDialog(self.report_data.html) # Command buttons self.cmdPrint.clicked.connect(self.onPrint) @@ -80,7 +80,7 @@ def onSave(self): else: location = self.save_location # Use a sensible filename default - default_name = os.path.join(location, 'fit_report.pdf') + default_name = os.path.join(location, 'report.pdf') kwargs = { 'parent' : self, @@ -111,76 +111,31 @@ def onSave(self): if not extension: filename = '.'.join((filename, ext)) - # Create files with charts - pictures = [] - if self.data_images is not None: - pictures = self.getPictures(basename) - - # self.data_html contains all images at the end of the report, in base64 form; - # replace them all with their saved on-disk filenames - cleanr = re.compile('

' - replacement_name += '\n' - # - Report: Fitting + Report diff --git a/src/sas/qtgui/Utilities/Reports/__init__.py b/src/sas/qtgui/Utilities/Reports/__init__.py new file mode 100644 index 0000000000..8ed43f28ec --- /dev/null +++ b/src/sas/qtgui/Utilities/Reports/__init__.py @@ -0,0 +1,2 @@ +from .reports import ReportBase +from .reportdata import ReportData \ No newline at end of file diff --git a/src/sas/qtgui/Utilities/Reports/report_style.css b/src/sas/qtgui/Utilities/Reports/report_style.css new file mode 100644 index 0000000000..a7eaa89427 --- /dev/null +++ b/src/sas/qtgui/Utilities/Reports/report_style.css @@ -0,0 +1,29 @@ +body { + background-color: white; + text-align: center; +} + +#main { + width: 500px; + margin: auto; +} + +table { + margin: auto; +} + +td { + text-align: left; +} + +th, td { + padding-top: 5px; + padding-bottom: 5px; + padding-left: 5px; + padding-right: 5px; +} + + +.sasview-details { + font-style: italic; +} \ No newline at end of file diff --git a/src/sas/qtgui/Utilities/Reports/reportdata.py b/src/sas/qtgui/Utilities/Reports/reportdata.py new file mode 100644 index 0000000000..5a86406d8a --- /dev/null +++ b/src/sas/qtgui/Utilities/Reports/reportdata.py @@ -0,0 +1,5 @@ +from typing import NamedTuple + +class ReportData(NamedTuple): + html: str = "No Data" + text: str = "No data" \ No newline at end of file diff --git a/src/sas/qtgui/Utilities/Reports/reports.py b/src/sas/qtgui/Utilities/Reports/reports.py new file mode 100644 index 0000000000..2784c442f6 --- /dev/null +++ b/src/sas/qtgui/Utilities/Reports/reports.py @@ -0,0 +1,330 @@ +from typing import List, Tuple, Iterable, Any, Dict, Optional + +import sys +import os +import datetime +try: + import importlib.resources as pkg_resources +except: + import importlib_resources as pkg_resources + +import base64 +from io import BytesIO + +import matplotlib.figure + +import dominate +from dominate.tags import * +from dominate.util import raw + +import html2text + +from xhtml2pdf import pisa + +import sas.sasview +import sasmodels +import logging + +from sas.qtgui.Utilities import GuiUtils +from sas.qtgui.Plotting.PlotterBase import Data1D +from sas.qtgui.Utilities.Reports.reportdata import ReportData + +# +# Utility classes +# + +class pretty_units(span): + """ HTML tag for units, prettifies angstroms, inverse angstroms and inverse cm + TODO: Should be replaced when there is a better way of handling units""" + tagname = "span" + + def __init__(self, unit_string: str, *args, **kwargs): + super().__init__(*args, **kwargs) + + clean_unit_string = unit_string.strip() + + if clean_unit_string == "A": + text = raw("Å") # Overring A + do_superscript_power = False + + elif clean_unit_string == "1/A" or clean_unit_string == "A^{-1}": + text = raw("Å") # Overring A + do_superscript_power = True + + elif clean_unit_string == "1/cm" or clean_unit_string == "cm^{-1}": + text = "cm" + do_superscript_power = True + + else: + text = clean_unit_string + do_superscript_power = False + + self.add(text) + + if do_superscript_power: + with self: + span("-1", style="vertical-align:super;") + + + +# +# Main report builder class +# + +class ReportBase: + def __init__(self, + title: str, + style_link: Optional[str]=None, + show_figure_section_title=True, + show_param_section_title=True): + + """ Holds a (DOM) representation of a report, the details that need + to go into the report can be added with reasonably simple calls, e.g. add_table, add_plot + + :param title: Report title + :param style_link: If provided will set style in the html to the specified URL, rather than embedding contents + from local report_style.css + :show_figure_section_title: Add h2 tag for figure section + :show_figure_section_title: Add h2 tag for parameters section + + + """ + + # + # Set up the html document and specify the general layout + # + + self._html_doc = dominate.document(title=title) + self.plots = [] + + with self._html_doc.head: + meta(http_equiv="Content-Type", content="text/html; charset=utf-8") + meta(name="Generator", content=f"SasView {sas.sasview.__version__}") + + if style_link is not None: + link(rel="stylesheet", href=style_link) + + else: + style_data = pkg_resources.read_text("sas.qtgui.Utilities.Reports", "report_style.css") + style(style_data) + + with self._html_doc.body: + with div(id="main"): + + with div(id="sasview"): + h1(title) + p(datetime.datetime.now().strftime("%I:%M%p, %B %d, %Y")) + with div(id="version-info"): + p(f"sasview {sas.sasview.__version__}, sasmodels {sasmodels.__version__}", cls="sasview-details") + + div(id="perspective") + with div(id="data"): + h2("Data") + + with div(id="model"): + + if show_param_section_title: + h2("Details") + + div(id="model-details") + div(id="model-parameters") + + with div(id='figures-outer'): + if show_figure_section_title: + h2("Figures") + + div(id="figures") + + def add_data_details(self, data: Data1D): + """ Add details of input data to the report""" + + n_points = len(getattr(data, 'x', [])) + low_q = getattr(data, "xmin", min(data.x) if len(data.x) > 0 else None) + high_q = getattr(data, "xmax", max(data.x) if len(data.x) > 0 else None) + + + table_data = [ + ["File", os.path.basename(data.filename)], + ["n Samples", n_points], + ["Q min", span(low_q, pretty_units(data.x_unit))], + ["Q max", span(high_q, pretty_units(data.x_unit))] + ] + + self.add_table(table_data, target_tag="data", column_prefix="data-column") + + + + def add_plot(self, fig: matplotlib.figure.Figure, image_type="png", figure_title: Optional[str]=None): + """ Add a plot to the report + + :param fig: matplotlib.figure.Figure, Matplotlib figure object to add + :param image_type: str, type of embedded image - 'svg' or 'png', defaults to 'svg' + :param figure_title: Optional[str] - Optionally add an html header tag, defaults to None + + :raises ValueError: if image_type is bad + """ + + if figure_title is not None: + h2(figure_title) + + if image_type == "svg": + logging.warning("xhtml2pdf does not currently support svg export to pdf.") + self._add_plot_svg(fig) + elif image_type == "png": + self._add_plot_png(fig) + else: + raise ValueError("image_type must be either 'svg' or 'png'") + + def _add_plot_svg(self, fig: matplotlib.figure.Figure): + try: + with BytesIO() as svg_output: + fig.savefig(svg_output, format="svg") + self.add_image_from_bytes(svg_output, file_type='svg+xml') + self.plots.append(fig) + + except PermissionError as ex: + logging.error("Creating of report images failed: %s" % str(ex)) + return + + def _add_plot_png(self, fig: matplotlib.figure.Figure): + try: + with BytesIO() as png_output: + if sys.platform == "darwin": + fig.savefig(png_output, format="png", dpi=150) + else: + fig.savefig(png_output, format="png", dpi=75) + + self.add_image_from_bytes(png_output, file_type='png') + self.plots.append(fig) + + except PermissionError as ex: + logging.error("Creating of report images failed: %s" % str(ex)) + return + + def add_image_from_file(self, filename: str): + """ Add image to report from a source file""" + extension = filename.split(".")[-1] + + with open(filename, 'rb') as fid: + bytes = BytesIO(fid.read()) + self.add_image_from_bytes(bytes, extension) + + def add_image_from_bytes(self, bytes: BytesIO, file_type='png'): + """ Add an image from a BytesIO object""" + + data64 = base64.b64encode(bytes.getvalue()) + with self._html_doc.getElementById("figures"): + img(src=f"data:image/{file_type};base64," + data64.decode("utf-8"), + style="width:100%") + + def add_table_dict(self, d: Dict[str, Any], titles: Optional[Tuple[str, str]]=None): + + self.add_table([[key, d[key]] for key in d], titles=titles) + + def add_table(self, + data: List[List[Any]], + titles: Optional[Iterable[str]]=None, + target_tag="model-parameters", + column_prefix="column"): + + """ Add a table of parameters to the report""" + with self._html_doc.getElementById(target_tag): + + with table(): + + if titles is not None: + with tr(): + for title in titles: + th(title) + + for row in sorted(data, key=lambda x: x[0]): + with tr(): + for i, value in enumerate(row): + td(value, cls=f"{column_prefix}-{i}") + + @property + def text(self) -> str: + """ Text version of the document (actually RST)""" + return html2text.html2text(GuiUtils.replaceHTMLwithASCII(self.html)).encode("ascii", "ignore").decode() + + @property + def html(self) -> str: + """ A string containing the html of the document""" + return str(self._html_doc) + + @property + def report_data(self) -> ReportData: + return ReportData( + self.html, + self.text) + + def save_html(self, filename: str): + with open(filename, 'w') as fid: + print(self._html_doc, file=fid) + + def save_text(self, filename: str): + with open(filename, 'w') as fid: + print(self.text, file=fid) + + def save_pdf(self, filename: str): + with open(filename, 'w+b') as fid: + try: + pisa.CreatePDF(str(self._html_doc), + dest=fid, + encoding='UTF-8') + + except Exception as ex: + import traceback + logging.error("Error creating pdf: " + str(ex) + "\n" + traceback.format_exc()) + + + + +def main(): + + """ This can be run locally without sasview to make it easy to adjust the report layout/styling, + it will generate a report with some arbitrary data""" + + from sas.sascalc.dataloader.loader import Loader + import os + import matplotlib.pyplot as plt + import numpy as np + loader = Loader() + + + # Constructor: + + # Select this one to use a link, not embedding, for the css - quicker messing about with styling with + # rb = ReportBase("Test Report", "report_style.css") + + # Use this to embed + rb = ReportBase("Test Report") + + # Arbitrary file used to add file info to the report + fileanem = "100nmSpheresNodQ.txt" + path_to_data = "../../../sasview/test/1d_data" + filename = os.path.join(path_to_data, fileanem) + data = loader.load(filename)[0] + + rb.add_data_details(data) + + # Some made up parameters + rb.add_table_dict({"A": 10, "B": 0.01, "C": 'stuff', "D": False}, ("Parameter", "Value")) + + # A test plot + x = np.arange(100) + y = (x-50)**2 + plt.plot(x, y) + rb.add_plot(plt.gcf(), image_type='png') + + # Save in the different formats + rb.save_html("report_test.html") + rb.save_pdf("report_test.pdf") + rb.save_text("report_test.rst") + + print(rb._html_doc) # Print the HTML version + print(rb.text) # Print the text version + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/sas/qtgui/Utilities/UnitTesting/ReportDialogTest.py b/src/sas/qtgui/Utilities/UnitTesting/ReportDialogTest.py index 58061c8331..896e48a294 100644 --- a/src/sas/qtgui/Utilities/UnitTesting/ReportDialogTest.py +++ b/src/sas/qtgui/Utilities/UnitTesting/ReportDialogTest.py @@ -4,19 +4,16 @@ import logging from xhtml2pdf import pisa -from unittest.mock import mock_open, patch - from unittest.mock import MagicMock from PyQt5 import QtWidgets, QtPrintSupport from PyQt5.QtTest import QTest # set up import paths -import path_prepare import sas.qtgui.Utilities.GuiUtils as GuiUtils # Local -from sas.qtgui.Utilities.ReportDialog import ReportDialog +from qtgui.Utilities.Reports.ReportDialog import ReportDialog if not QtWidgets.QApplication.instance(): app = QtWidgets.QApplication(sys.argv) @@ -36,7 +33,7 @@ def communicate(self): test_txt = "test_txt" test_images = [] self.test_list = [test_html, test_txt, test_images] - self.widget = ReportDialog(parent=dummy_manager(), report_list=self.test_list) + self.widget = ReportDialog(parent=dummy_manager(), report_data=self.test_list) def tearDown(self): '''Destroy the GUI''' @@ -83,7 +80,7 @@ def testOnSave(self): self.setUp() # conversion failed - self.widget.HTML2PDF = MagicMock(return_value=1) + self.widget.save_pdf = MagicMock(return_value=1) # invoke the method self.widget.onSave() @@ -95,8 +92,8 @@ def testOnSave(self): self.assertFalse(os.system.called) # conversion succeeded - temp_html2pdf = self.widget.HTML2PDF - self.widget.HTML2PDF = MagicMock(return_value=0) + temp_html2pdf = self.widget.save_pdf + self.widget.save_pdf = MagicMock(return_value=0) # invoke the method self.widget.onSave() @@ -118,14 +115,14 @@ def testOnSave(self): # HTML save QtWidgets.QFileDialog.getSaveFileName = MagicMock(return_value=["test.html", "(*.html)"]) - self.widget.onHTMLSave = MagicMock() + self.widget.write_string = MagicMock() # invoke the method self.widget.onSave() # Check that the file was saved - self.assertTrue(self.widget.onHTMLSave) + self.assertTrue(self.widget.write_string) - self.widget.HTML2PDF = temp_html2pdf + self.widget.save_pdf = temp_html2pdf def testGetPictures(self): @@ -143,7 +140,7 @@ class pisa_dummy(object): QTest.qWait(100) data = self.widget.txtBrowser.toHtml() - return_value = self.widget.HTML2PDF(data, "b") + return_value = self.widget.save_pdf(data, "b") self.assertTrue(pisa.CreatePDF.called) self.assertEqual(return_value, 0) @@ -154,7 +151,7 @@ class pisa_dummy(object): logging.error = MagicMock() #run the method - return_value = self.widget.HTML2PDF(data, "c") + return_value = self.widget.save_pdf(data, "c") self.assertTrue(logging.error.called) #logging.error.assert_called_with("Error creating pdf")