diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml index b56b99b0bd..8dcf35cd2b 100644 --- a/.github/workflows/nightly-build.yml +++ b/.github/workflows/nightly-build.yml @@ -56,7 +56,7 @@ jobs: - name: Staple Release Build (OSX) if: ${{ startsWith(matrix.os, 'macos') }} - uses: devbotsxyz/xcode-staple@v1 + uses: BoundfoxStudios/action-xcode-staple@v1 with: product-path: "installers/dist/SasView-nightly-MacOSX.dmg" diff --git a/LICENSE.TXT b/LICENSE.TXT index a2f0710800..d5fb93139c 100644 --- a/LICENSE.TXT +++ b/LICENSE.TXT @@ -1,4 +1,4 @@ -Copyright (c) 2009-2022, SasView Developers +Copyright (c) 2009-2023, SasView Developers All rights reserved. diff --git a/build_tools/release_automation.py b/build_tools/release_automation.py index 06377e3a8e..07e0847cd0 100644 --- a/build_tools/release_automation.py +++ b/build_tools/release_automation.py @@ -14,9 +14,9 @@ #Should import release notes from git repo, for now will need to cut and paste sasview_data = { 'metadata': { - 'title': 'SasView version 5.0.5', - 'description': '5.0.5 release', - 'related_identifiers': [{'identifier': 'https://github.com/SasView/sasview/releases/tag/v5.0.5', + 'title': 'SasView version 5.0.6', + 'description': '5.0.6 release', + 'related_identifiers': [{'identifier': 'https://github.com/SasView/sasview/releases/tag/v5.0.6', 'relation': 'isAlternateIdentifier', 'scheme': 'url'}], 'contributors': [ {'name': 'Anuchitanukul, Atijit', 'affiliation': 'STFC - Rutherford Appleton Laboratory', 'type':'Researcher'}, @@ -49,6 +49,7 @@ {'name': 'Alina, Gervaise','affiliation': 'University of Tennessee Knoxville'}, {'name': 'Attala, Ziggy', 'affiliation': 'STFC - Rutherford Appleton Laboratory'}, {'name': 'Bakker, Jurrian','affiliation': 'Technical Unviersity Delft'}, + {'name': 'Beaucage, Peter','affiliation': 'National Institute of Standards and Technology', 'orcid': '0000-0002-2147-0728'}, {'name': 'Bouwman, Wim','affiliation': 'Technical Univeristy Deflt' }, {'name': 'Bourne, Robert', 'affiliation': 'STFC - Rutherford Appleton Laboratory'}, {'name': 'Butler, Paul','affiliation': 'National Institute of Standards and Technology', 'orcid': '0000-0002-5978-4714'}, @@ -63,7 +64,7 @@ {'name': 'Jackson, Andrew','affiliation': 'European Spallation Source ERIC', 'orcid': '0000-0002-6296-0336'}, {'name': 'King, Stephen','affiliation': 'STFC - Rutherford Appleton Laboratory', 'orcid': '0000-0003-3386-9151'}, {'name': 'Kienzle, Paul','affiliation': 'National Institute of Standards and Technology'}, - {'name': 'Krzywon, Jeff','affiliation': 'National Institute of Standards and Technology'}, + {'name': 'Krzywon, Jeff','affiliation': 'National Institute of Standards and Technology', 'orcid': '0000-0002-2380-4090'}, {'name': 'Maranville, Brian', 'affiliation': 'National Institute of Standards and Technology', 'orcid': '0000-0002-6105-8789'}, {'name': 'Martinez, Nicolas','affiliation': 'Institut Laue-Langevin'}, {'name': 'Murphy, Ryan', 'affiliation': 'National Institute of Standards and Technology', 'orcid': '0000-0002-4080-7525'}, @@ -76,7 +77,7 @@ {'name': 'Snow, Tim','affiliation': 'Diamond Light Source','orcid': '0000-0001-7146-6885'}, {'name': 'Washington, Adam','affiliation': 'STFC - Rutherford Appleton Laboratory'}, {'name': 'Wilkins, Lucas','affiliation': 'STFC - Rutherford Appleton Laboratory'}, - {'name': 'Wolf, Caitlyn','affiliation': 'National Institute of Standards and Technology'} + {'name': 'Wolf, Caitlyn','affiliation': 'National Institute of Standards and Technology', 'orcid': '0000-0002-2956-7049'} ], 'grants': [{'id': '10.13039/501100000780::654000'}], 'license': 'BSD-3-Clause', diff --git a/build_tools/requirements.txt b/build_tools/requirements.txt index b785e11727..c00355eca1 100644 --- a/build_tools/requirements.txt +++ b/build_tools/requirements.txt @@ -1,5 +1,5 @@ -numpy -scipy==1.7.3 +numpy<1.24 +scipy==1.10.0 docutils pytest pytest_qt diff --git a/docs/sphinx-docs/source/conf.py b/docs/sphinx-docs/source/conf.py index f695e6dfff..63e0ea8ae7 100644 --- a/docs/sphinx-docs/source/conf.py +++ b/docs/sphinx-docs/source/conf.py @@ -11,7 +11,7 @@ # All configuration values have a default; values that are commented out # serve to show the default. -import sys, os, collections +import sys, os, datetime # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the @@ -69,8 +69,9 @@ master_doc = 'index' # General information about the project. -project = u'SasView' -copyright = u'2022, The SasView Project' +year = datetime.datetime.now().year +project = 'SasView' +copyright = f'{year}, The SasView Project' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -79,9 +80,9 @@ # The version number must follow StrictVersion rules as outlined # in http://epydoc.sourceforge.net/stdlib/distutils.version.StrictVersion-class.html # The short X.Y version. -version = '5.0' +version = '6.0' # The full version, including e.g. alpha tags (a1). -release = '5.0.5' +release = '6.0.0a1' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/src/sas/cli.py b/src/sas/cli.py index e1ef60fa6e..4ecabcbf83 100644 --- a/src/sas/cli.py +++ b/src/sas/cli.py @@ -68,6 +68,8 @@ def parse_cli(argv): help="Open console to display output (windows only)") parser.add_argument("-q", "--quiet", action='store_true', help="Don't print banner when entering interactive mode") + parser.add_argument("-l", "--loglevel", type=str, + help="Logging level (production or development for now)") parser.add_argument("args", nargs="*", help="script followed by args") @@ -118,12 +120,18 @@ def main(logging="production"): cli = parse_cli(sys.argv) # Setup logger and sasmodels - if logging == "production": + if cli.loglevel: + logging = cli.loglevel + logging = logging.upper() + if logging == "PRODUCTION": log.production() - elif logging == "development": + elif logging == "DEVELOPMENT": log.development() + elif logging.upper() in {'DEBUG', 'INFO', 'WARN', 'ERROR', 'CRITICAL'}: + log.setup_logging(logging) else: raise ValueError(f"Unknown logging mode \"{logging}\"") + lib.setup_sasmodels() lib.setup_qt_env() # Note: does not import any gui libraries diff --git a/src/sas/qtgui/Calculators/DensityPanel.py b/src/sas/qtgui/Calculators/DensityPanel.py index 635d4ba81e..d8f642774e 100644 --- a/src/sas/qtgui/Calculators/DensityPanel.py +++ b/src/sas/qtgui/Calculators/DensityPanel.py @@ -57,8 +57,7 @@ def setupUi(self): self.ui = Ui_DensityPanel() self.ui.setupUi(self) - #self.setFixedSize(self.minimumSizeHint()) - self.resize(self.minimumSizeHint()) + self.setFixedSize(self.minimumSizeHint()) # set validators #self.ui.editMolecularFormula.setValidator(FormulaValidator(self.ui.editMolecularFormula)) diff --git a/src/sas/qtgui/Calculators/GenericScatteringCalculator.py b/src/sas/qtgui/Calculators/GenericScatteringCalculator.py index b22ba27761..e1a038f414 100644 --- a/src/sas/qtgui/Calculators/GenericScatteringCalculator.py +++ b/src/sas/qtgui/Calculators/GenericScatteringCalculator.py @@ -57,6 +57,7 @@ def __init__(self, parent=None): self.setupUi(self) # disable the context help icon self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint) + self.setFixedSize(self.minimumSizeHint()) self.manager = parent self.communicator = self.manager.communicator() diff --git a/src/sas/qtgui/Calculators/ResolutionCalculatorPanel.py b/src/sas/qtgui/Calculators/ResolutionCalculatorPanel.py index 34e9eaa5cd..3e2f2cf9c3 100644 --- a/src/sas/qtgui/Calculators/ResolutionCalculatorPanel.py +++ b/src/sas/qtgui/Calculators/ResolutionCalculatorPanel.py @@ -42,6 +42,7 @@ def __init__(self, parent=None): self.setupUi(self) # disable the context help icon self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint) + self.setFixedSize(self.minimumSizeHint()) self.manager = parent diff --git a/src/sas/qtgui/Calculators/SldPanel.py b/src/sas/qtgui/Calculators/SldPanel.py index 22400ca595..1b657e4c96 100644 --- a/src/sas/qtgui/Calculators/SldPanel.py +++ b/src/sas/qtgui/Calculators/SldPanel.py @@ -97,6 +97,7 @@ def __init__(self, parent=None): self.setupUi() # disable the context help icon self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint) + self.setFixedSize(self.minimumSizeHint()) self.setupModel() self.setupMapper() diff --git a/src/sas/qtgui/MainWindow/DataExplorer.py b/src/sas/qtgui/MainWindow/DataExplorer.py index d0ebdddf54..93148b6efb 100644 --- a/src/sas/qtgui/MainWindow/DataExplorer.py +++ b/src/sas/qtgui/MainWindow/DataExplorer.py @@ -22,8 +22,8 @@ from sas.qtgui.Plotting.PlotterData import Data1D from sas.qtgui.Plotting.PlotterData import Data2D from sas.qtgui.Plotting.PlotterData import DataRole -from sas.qtgui.Plotting.Plotter import Plotter -from sas.qtgui.Plotting.Plotter2D import Plotter2D +from sas.qtgui.Plotting.Plotter import Plotter, PlotterWidget +from sas.qtgui.Plotting.Plotter2D import Plotter2D, Plotter2DWidget from sas.qtgui.Plotting.MaskEditor import MaskEditor from sas.qtgui.MainWindow.DataManager import DataManager @@ -232,7 +232,7 @@ def loadFolder(self, event=None): caption = 'Choose a directory' options = QtWidgets.QFileDialog.ShowDirsOnly | QtWidgets.QFileDialog.DontUseNativeDialog directory = self.default_load_location - folder = QtWidgets.QFileDialog.getExistingDirectory(parent, caption, directory, "", options) + folder = QtWidgets.QFileDialog.getExistingDirectory(parent, caption, directory, options) if folder is None: return @@ -1086,11 +1086,17 @@ def displayData(self, data_list, id=None): plot_name = plot_to_show.name role = plot_to_show.plot_role - stand_alone_types = [DataRole.ROLE_RESIDUAL, DataRole.ROLE_STAND_ALONE] + stand_alone_types = [DataRole.ROLE_RESIDUAL, DataRole.ROLE_STAND_ALONE, DataRole.ROLE_POLYDISPERSITY] if (role in stand_alone_types and shown) or role == DataRole.ROLE_DELETABLE: # Nothing to do if stand-alone plot already shown or plot to be deleted continue + elif role == DataRole.ROLE_RESIDUAL and config.DISABLE_RESIDUAL_PLOT: + # Nothing to do if residuals are not plotted + continue + elif role == DataRole.ROLE_POLYDISPERSITY and config.DISABLE_POLYDISPERSITY_PLOT: + # Nothing to do if polydispersity plot is not plotted + continue elif role in stand_alone_types: # Stand-alone plots should always be separate self.plotData([(plot_item, plot_to_show)]) @@ -1132,7 +1138,7 @@ def addDataPlot2D(self, plot_set, item): """ Create a new 2D plot and add it to the workspace """ - plot2D = Plotter2D(self) + plot2D = Plotter2DWidget(parent=self, manager=self) plot2D.item = item plot2D.plot(plot_set) self.addPlot(plot2D) @@ -1160,7 +1166,7 @@ def plotData(self, plots, transform=True): for item, plot_set in plots: if isinstance(plot_set, Data1D): if 'new_plot' not in locals(): - new_plot = Plotter(self) + new_plot = PlotterWidget(manager=self, parent=self) new_plot.item = item new_plot.plot(plot_set, transform=transform) # active_plots may contain multiple charts @@ -1245,7 +1251,7 @@ def appendPlot(self): @staticmethod def appendOrUpdatePlot(self, data, plot): name = data.name - if isinstance(plot, Plotter2D) or name in plot.plot_dict.keys(): + if isinstance(plot, Plotter2DWidget) or name in plot.plot_dict.keys(): plot.replacePlot(name, data) else: plot.plot(data) diff --git a/src/sas/qtgui/MainWindow/GuiManager.py b/src/sas/qtgui/MainWindow/GuiManager.py index 4e53db5816..2ebc711792 100644 --- a/src/sas/qtgui/MainWindow/GuiManager.py +++ b/src/sas/qtgui/MainWindow/GuiManager.py @@ -122,6 +122,9 @@ def __init__(self, parent=None): logging.info(f" --- SasView session started, version {SASVIEW_VERSION}, {SASVIEW_RELEASE_DATE} ---") # Log the python version logging.info("Python: %s" % sys.version) + #logging.debug("Debug messages are shown.") + #logging.warn("Warnings are shown.") + #logging.error("Errors are shown.") # Set up the status bar self.statusBarSetup() diff --git a/src/sas/qtgui/Perspectives/Fitting/FittingUtilities.py b/src/sas/qtgui/Perspectives/Fitting/FittingUtilities.py index 825c66a628..48330b8090 100644 --- a/src/sas/qtgui/Perspectives/Fitting/FittingUtilities.py +++ b/src/sas/qtgui/Perspectives/Fitting/FittingUtilities.py @@ -643,7 +643,7 @@ def plotPolydispersities(model): data1d.symbol = 'Line' data1d.name = "{} polydispersity".format(name) data1d.id = data1d.name # placeholder, has to be completed later - data1d.plot_role = DataRole.ROLE_STAND_ALONE + data1d.plot_role = DataRole.ROLE_POLYDISPERSITY plots.append(data1d) return plots diff --git a/src/sas/qtgui/Perspectives/Fitting/FittingWidget.py b/src/sas/qtgui/Perspectives/Fitting/FittingWidget.py index 2d5088670d..fc89951667 100644 --- a/src/sas/qtgui/Perspectives/Fitting/FittingWidget.py +++ b/src/sas/qtgui/Perspectives/Fitting/FittingWidget.py @@ -223,6 +223,8 @@ def data(self, value): self.smearing_widget.resetSmearer() # Enable/disable UI components self.setEnablementOnDataLoad() + # Reinitialize model list for constrained/simult fitting + self.newModelSignal.emit() def initializeGlobals(self): """ diff --git a/src/sas/qtgui/Plotting/Plotter2D.py b/src/sas/qtgui/Plotting/Plotter2D.py index d939b0f199..1189cbec0a 100644 --- a/src/sas/qtgui/Plotting/Plotter2D.py +++ b/src/sas/qtgui/Plotting/Plotter2D.py @@ -19,9 +19,10 @@ from sas.qtgui.Plotting.BoxSum import BoxSum from sas.qtgui.Plotting.SlicerParameters import SlicerParameters -# TODO: move to sas.qtgui namespace from sas.qtgui.Plotting.Slicers.BoxSlicer import BoxInteractorX from sas.qtgui.Plotting.Slicers.BoxSlicer import BoxInteractorY +from sas.qtgui.Plotting.Slicers.WedgeSlicer import WedgeInteractorQ +from sas.qtgui.Plotting.Slicers.WedgeSlicer import WedgeInteractorPhi from sas.qtgui.Plotting.Slicers.AnnulusSlicer import AnnulusInteractor from sas.qtgui.Plotting.Slicers.SectorSlicer import SectorInteractor from sas.qtgui.Plotting.Slicers.BoxSum import BoxSumCalculator @@ -189,6 +190,10 @@ def createContextMenu(self): self.actionBoxAveragingX.triggered.connect(self.onBoxAveragingX) self.actionBoxAveragingY = self.contextMenu.addAction("&Box Averaging in Qy") self.actionBoxAveragingY.triggered.connect(self.onBoxAveragingY) + self.actionWedgeAveragingQ = self.contextMenu.addAction("&Wedge Averaging in Q") + self.actionWedgeAveragingQ.triggered.connect(self.onWedgeAveragingQ) + self.actionWedgeAveragingPhi = self.contextMenu.addAction("&Wedge Averaging in Phi") + self.actionWedgeAveragingPhi.triggered.connect(self.onWedgeAveragingPhi) # Additional items for slicer interaction if self.slicer: self.actionClearSlicer = self.contextMenu.addAction("&Clear Slicer") @@ -456,6 +461,20 @@ def onBoxAveragingY(self): """ self.setSlicer(slicer=BoxInteractorY) + def onWedgeAveragingQ(self): + """ + Perform 2D data averaging on Q + Create a new slicer . + """ + self.setSlicer(slicer=WedgeInteractorQ) + + def onWedgeAveragingPhi(self): + """ + Perform 2D data averaging on Phi + Create a new slicer . + """ + self.setSlicer(slicer=WedgeInteractorPhi) + def onColorMap(self): """ Display the color map dialog and modify the plot's map accordingly diff --git a/src/sas/qtgui/Plotting/PlotterData.py b/src/sas/qtgui/Plotting/PlotterData.py index 8fbaf7991e..e43539dcc0 100644 --- a/src/sas/qtgui/Plotting/PlotterData.py +++ b/src/sas/qtgui/Plotting/PlotterData.py @@ -27,6 +27,8 @@ class DataRole(Enum): ROLE_RESIDUAL = 3 # Stand alone is for plots that should be plotted separately ROLE_STAND_ALONE = 4 + # Polydispersity is for stand-alone polydispersity plot + ROLE_POLYDISPERSITY = 5 class Data1D(PlottableData1D, LoadData1D): diff --git a/src/sas/qtgui/Plotting/SlicerParameters.py b/src/sas/qtgui/Plotting/SlicerParameters.py index 274f558f25..de33f8721d 100644 --- a/src/sas/qtgui/Plotting/SlicerParameters.py +++ b/src/sas/qtgui/Plotting/SlicerParameters.py @@ -14,6 +14,8 @@ from sas.qtgui.Plotting.PlotterData import Data1D from sas.qtgui.Plotting.Slicers.BoxSlicer import BoxInteractorX from sas.qtgui.Plotting.Slicers.BoxSlicer import BoxInteractorY +from sas.qtgui.Plotting.Slicers.WedgeSlicer import WedgeInteractorQ +from sas.qtgui.Plotting.Slicers.WedgeSlicer import WedgeInteractorPhi from sas.qtgui.Plotting.Slicers.AnnulusSlicer import AnnulusInteractor from sas.qtgui.Plotting.Slicers.SectorSlicer import SectorInteractor @@ -56,7 +58,9 @@ def __init__(self, parent=None, 1: SectorInteractor, 2: AnnulusInteractor, 3: BoxInteractorX, - 4: BoxInteractorY} + 4: BoxInteractorY, + 5: WedgeInteractorQ, + 6: WedgeInteractorPhi} # Define a proxy model so cell enablement can be finegrained. self.proxy = ProxyModel(self) @@ -216,7 +220,7 @@ def onChooseFilesLocation(self): caption = 'Save files to:' options = QtWidgets.QFileDialog.ShowDirsOnly | QtWidgets.QFileDialog.DontUseNativeDialog directory = self.save_location - folder = QtWidgets.QFileDialog.getExistingDirectory(parent, caption, directory, "", options) + folder = QtWidgets.QFileDialog.getExistingDirectory(parent, caption, directory, options) if folder is None: return diff --git a/src/sas/qtgui/Plotting/Slicers/AnnulusSlicer.py b/src/sas/qtgui/Plotting/Slicers/AnnulusSlicer.py index af94748200..2c7e07c077 100644 --- a/src/sas/qtgui/Plotting/Slicers/AnnulusSlicer.py +++ b/src/sas/qtgui/Plotting/Slicers/AnnulusSlicer.py @@ -8,10 +8,13 @@ class AnnulusInteractor(BaseInteractor, SlicerModel): """ - Select an annulus through a 2D plot. - This interactor is used to average 2D data with the region - defined by 2 radius. - this class is defined by 2 Ringinterators. + AnnulusInteractor plots a data1D average of an annulus area defined in a + Data2D object. The data1D averaging itself is performed in sasdata by + manipulations.py + + This class uses the RingInteractor class to define two rings of radius + r1 and r2 (Q1 and Q2). All Q points at a constant angle phi from the x-axis + are averaged together to provide a 1D array in phi from 0 to 180 degrees. """ def __init__(self, base, axes, item=None, color='black', zorder=3): @@ -48,6 +51,7 @@ def __init__(self, base, axes, item=None, color='black', zorder=3): self.outer_circle.qmax = self.qmax * 1.2 self.update() self._post_data() + self.draw() self.setModelFromParams() @@ -148,7 +152,6 @@ def _post_data(self, nbins=None): if self.update_model: self.setModelFromParams() - self.draw() def validate(self, param_name, param_value): """ @@ -188,6 +191,7 @@ def moveend(self, ev): Redraw the plot with new parameters. """ self._post_data(self.nbins) + self.draw() def restore(self, ev): """ @@ -232,6 +236,7 @@ def setParams(self, params): self.outer_circle.set_cursor(outer, self.outer_circle._inner_mouse_y) # Post the data given the nbins entered by the user self._post_data(self.nbins) + self.draw() def draw(self): """ @@ -241,13 +246,13 @@ def draw(self): class RingInteractor(BaseInteractor): """ - Draw a ring Given a radius + Draw a ring on a data2D plot centered at (0,0) given a radius """ def __init__(self, base, axes, color='black', zorder=5, r=1.0, sign=1): """ :param: the color of the line that defined the ring :param r: the radius of the ring - :param sign: the direction of motion the the marker + :param sign: the direction of motion the marker """ BaseInteractor.__init__(self, base, axes, color=color) @@ -353,7 +358,8 @@ def move(self, x, y, ev): """ self._inner_mouse_x = x self._inner_mouse_y = y - self.base.base.update() + self.base.update() + self.base.draw() def set_cursor(self, x, y): """ diff --git a/src/sas/qtgui/Plotting/Slicers/Arc.py b/src/sas/qtgui/Plotting/Slicers/Arc.py deleted file mode 100644 index 4616479fb0..0000000000 --- a/src/sas/qtgui/Plotting/Slicers/Arc.py +++ /dev/null @@ -1,153 +0,0 @@ -""" - Arc slicer for 2D data -""" -import numpy as np - -from sas.qtgui.Plotting.Slicers.BaseInteractor import BaseInteractor - -class ArcInteractor(BaseInteractor): - """ - Select an annulus through a 2D plot - """ - def __init__(self, base, axes, color='black', zorder=5, r=1.0, - theta1=np.pi / 8, theta2=np.pi / 4): - BaseInteractor.__init__(self, base, axes, color=color) - self.markers = [] - self.axes = axes - self._mouse_x = r - self._mouse_y = 0 - self._save_x = r - self._save_y = 0 - self.scale = 10.0 - self.theta1 = theta1 - self.theta2 = theta2 - self.radius = r - [self.arc] = self.axes.plot([], [], linestyle='-', marker='', color=self.color) - self.npts = 20 - self.has_move = False - self.connect_markers([self.arc]) - self.update() - - def set_layer(self, n): - """ - Allow adding plot to the same panel - :param n: the number of layer - """ - self.layernum = n - self.update() - - def clear(self): - """ - Clear this slicer and its markers - """ - self.clear_markers() - try: - for item in self.markers: - item.remove() - self.arc.remove() - except: - # Old version of matplotlib - for item in range(len(self.axes.lines)): - del self.axes.lines[0] - - def get_radius(self): - """ - Return arc radius - """ - radius = np.sqrt(np.power(self._mouse_x, 2) + \ - np.power(self._mouse_y, 2)) - return radius - - def update(self, theta1=None, theta2=None, nbins=None, r=None): - """ - Update the plotted arc - :param theta1: starting angle of the arc - :param theta2: ending angle of the arc - :param nbins: number of points along the arc - :param r: radius of the arc - """ - # Plot inner circle - x = [] - y = [] - if theta1 is not None: - self.theta1 = theta1 - if theta2 is not None: - self.theta2 = theta2 - while self.theta2 < self.theta1: - self.theta2 += (2 * np.pi) - while self.theta2 >= (self.theta1 + 2 * np.pi): - self.theta2 -= (2 * np.pi) - self.npts = int((self.theta2 - self.theta1) / (np.pi / 120)) - - if r is None: - self.radius = np.sqrt(np.power(self._mouse_x, 2) + \ - np.power(self._mouse_y, 2)) - else: - self.radius = r - for i in range(self.npts): - phi = (self.theta2 - self.theta1) / (self.npts - 1) * i + self.theta1 - xval = 1.0 * self.radius * np.cos(phi) - yval = 1.0 * self.radius * np.sin(phi) - - x.append(xval) - y.append(yval) - self.arc.set_data(x, y) - - def save(self, ev): - """ - Remember the roughness for this layer and the next so that we - can restore on Esc. - """ - self._save_x = self._mouse_x - self._save_y = self._mouse_y - self.base.freeze_axes() - - def moveend(self, ev): - """ - After a dragging motion reset the flag self.has_move to False - :param ev: event - """ - self.has_move = False - - self.base.moveend(ev) - - def restore(self, ev): - """ - Restore the roughness for this layer. - """ - self._mouse_x = self._save_x - self._mouse_y = self._save_y - - def move(self, x, y, ev): - """ - Process move to a new position, making sure that the move is allowed. - """ - self._mouse_x = x - self._mouse_y = y - self.has_move = True - self.base.base.update() - - def set_cursor(self, radius, phi_min, phi_max, nbins): - """ - """ - self.theta1 = phi_min - self.theta2 = phi_max - self.update(nbins=nbins, r=radius) - - def get_params(self): - """ - """ - params = {} - params["radius"] = self.radius - params["theta1"] = self.theta1 - params["theta2"] = self.theta2 - return params - - def set_params(self, params): - """ - """ - x = params["radius"] - phi_max = self.theta2 - nbins = self.npts - self.set_cursor(x, self._mouse_y, phi_max, nbins) - diff --git a/src/sas/qtgui/Plotting/Slicers/ArcInteractor.py b/src/sas/qtgui/Plotting/Slicers/ArcInteractor.py new file mode 100644 index 0000000000..0a44e25d42 --- /dev/null +++ b/src/sas/qtgui/Plotting/Slicers/ArcInteractor.py @@ -0,0 +1,124 @@ +import numpy as np + +from sas.qtgui.Plotting.Slicers.BaseInteractor import BaseInteractor + +class ArcInteractor(BaseInteractor): + """ + Draw an arc on a data2D plot with a variable radius (centered at [0,0]). + User interaction adjusts the parameter r + + param r: radius from (0,0) of the arc on a data2D plot + param theta: angle from x-axis of the central point on the arc + param phi: angle from the centre point on the arc to each of its edges + """ + def __init__(self, base, axes, color='black', zorder=5, r=1.0, + theta=np.pi / 3, phi=np.pi / 8): + BaseInteractor.__init__(self, base, axes, color=color) + self.markers = [] + self.axes = axes + self.color = color + # Variables for the current mouse position + self._mouse_x = r + self._mouse_y = 0 + # Last known mouse position, for when the cursor moves off the plot + self._save_x = r + self._save_y = 0 + self.scale = 10.0 + # Key variables for drawing the interactor element + self.theta = theta + self.phi = phi + self.radius = r + # Calculate the marker coordinates and define the marker + self.marker = self.axes.plot([], [], linestyle='', + marker='s', markersize=10, + color=self.color, alpha=0.6, pickradius=5, + label='pick', zorder=zorder, + visible=True)[0] + # Define the arc + self.arc = self.axes.plot([], [], linestyle='-', marker='', color=self.color)[0] + # The number of points that make the arc line + self.npts = 40 + # Flag to keep track of motion + self.has_move = False + self.connect_markers([self.marker, self.arc]) + self.update() + + def set_layer(self, n): + """ + Allow adding plot to the same panel + :param n: the number of layer + """ + self.layernum = n + self.update() + + def clear(self): + """ + Clear this slicer and its markers + """ + self.clear_markers() + self.marker.remove() + self.arc.remove() + + def update(self, theta=None, phi=None, r=None): + """ + Draw the new roughness on the graph. + :param theta: angle from x-axis of the central point on the arc + :param phi: angle from the centre point on the arc to each of its edges + :param r: radius from (0,0) of the arc on a data2D plot + """ + if theta is not None: + self.theta = theta + if phi is not None: + self.phi = phi + if r is not None: + self.radius = r + # Calculate the points on the arc, and draw them + angle_offset = self.theta - self.phi + angle_factor = np.asarray([2 * self.phi / (self.npts - 1) * i + angle_offset for i in range(self.npts)]) + x = self.radius * np.cos(angle_factor) + y = self.radius * np.sin(angle_factor) + self.arc.set_data(x.tolist(), y.tolist()) + + # Calculate the new marker location, and draw that too + marker_x = self.radius * np.cos(self.theta - 0.5 * self.phi) + marker_y = self.radius * np.sin(self.theta - 0.5 * self.phi) + self.marker.set(xdata=[marker_x], ydata=[marker_y]) + + def save(self, ev): + """ + Remember the roughness for this layer and the next so that we + can restore on Esc. + """ + self._save_x = self._mouse_x + self._save_y = self._mouse_y + + def moveend(self, ev): + """ + After a dragging motion reset the flag self.has_move to False + :param ev: event + """ + self.has_move = False + self.base.moveend(ev) + + def restore(self, ev): + """ + Restore the roughness for this layer. + """ + self._mouse_x = self._save_x + self._mouse_y = self._save_y + + def move(self, x, y, ev): + """ + Process move to a new position. + """ + self._mouse_x = x + self._mouse_y = y + self.radius = np.sqrt(np.power(self._mouse_x, 2) + \ + np.power(self._mouse_y, 2)) + self.has_move = True + self.base.update() + self.base.draw() + + def set_cursor(self, x, y): + self.move(x, y, None) + self.update() diff --git a/src/sas/qtgui/Plotting/Slicers/AzimutSlicer.py b/src/sas/qtgui/Plotting/Slicers/AzimutSlicer.py deleted file mode 100644 index 9ecab61b07..0000000000 --- a/src/sas/qtgui/Plotting/Slicers/AzimutSlicer.py +++ /dev/null @@ -1,266 +0,0 @@ -# TODO: the line slicer should listen to all 2DREFRESH events, get the data and slice it -# before pushing a new 1D data update. -# -# TODO: NEED MAJOR REFACTOR -# -import numpy as np -from sas.qtgui.Plotting.Slicers.Arc import ArcInteractor -from sas.qtgui.Plotting.Slicers.RadiusInteractor import RadiusInteractor -from sas.qtgui.Plotting.Slicers.BaseInteractor import BaseInteractor - -class SectorInteractor(BaseInteractor): - """ - Select an annulus through a 2D plot - """ - def __init__(self, base, axes, color='black', zorder=3): - """ - """ - BaseInteractor.__init__(self, base, axes, color=color) - self.markers = [] - self.axes = axes - self.qmax = self.data.xmax - self.connect = self.base.connect - - # # Number of points on the plot - self.nbins = 100 - theta1 = 2 * np.pi / 3 - theta2 = -2 * np.pi / 3 - - # Inner circle - self.inner_circle = ArcInteractor(self, self.base.subplot, - zorder=zorder, - r=self.qmax / 2.0, - theta1=theta1, - theta2=theta2) - self.inner_circle.qmax = self.qmax - self.outer_circle = ArcInteractor(self, self.base.subplot, - zorder=zorder + 1, - r=self.qmax / 1.8, - theta1=theta1, - theta2=theta2) - self.outer_circle.qmax = self.qmax * 1.2 - # self.outer_circle.set_cursor(self.base.qmax/1.8, 0) - self.right_edge = RadiusInteractor(self, self.base.subplot, - zorder=zorder + 1, - arc1=self.inner_circle, - arc2=self.outer_circle, - theta=theta1) - self.left_edge = RadiusInteractor(self, self.base.subplot, - zorder=zorder + 1, - arc1=self.inner_circle, - arc2=self.outer_circle, - theta=theta2) - self.update() - self._post_data() - - def set_layer(self, n): - """ - """ - self.layernum = n - self.update() - - def clear(self): - """ - """ - self.clear_markers() - self.outer_circle.clear() - self.inner_circle.clear() - self.right_edge.clear() - self.left_edge.clear() - - def update(self): - """ - Respond to changes in the model by recalculating the profiles and - resetting the widgets. - """ - # Update locations - if self.inner_circle.has_move: - # print "inner circle has moved" - self.inner_circle.update() - r1 = self.inner_circle.get_radius() - r2 = self.outer_circle.get_radius() - self.right_edge.update(r1, r2) - self.left_edge.update(r1, r2) - if self.outer_circle.has_move: - # print "outer circle has moved" - self.outer_circle.update() - r1 = self.inner_circle.get_radius() - r2 = self.outer_circle.get_radius() - self.left_edge.update(r1, r2) - self.right_edge.update(r1, r2) - if self.right_edge.has_move: - # print "right edge has moved" - self.right_edge.update() - self.inner_circle.update(theta1=self.right_edge.get_angle(), - theta2=None) - self.outer_circle.update(theta1=self.right_edge.get_angle(), - theta2=None) - if self.left_edge.has_move: - # print "left Edge has moved" - self.left_edge.update() - self.inner_circle.update(theta1=None, - theta2=self.left_edge.get_angle()) - self.outer_circle.update(theta1=None, - theta2=self.left_edge.get_angle()) - - def save(self, ev): - """ - Remember the roughness for this layer and the next so that we - can restore on Esc. - """ - self.base.freeze_axes() - self.inner_circle.save(ev) - self.outer_circle.save(ev) - self.right_edge.save(ev) - self.left_edge.save(ev) - - def _post_data(self): - pass - - def post_data(self, new_sector): - """ post data averaging in Q""" - if self.inner_circle.get_radius() < self.outer_circle.get_radius(): - rmin = self.inner_circle.get_radius() - rmax = self.outer_circle.get_radius() - else: - rmin = self.outer_circle.get_radius() - rmax = self.inner_circle.get_radius() - if self.right_edge.get_angle() < self.left_edge.get_angle(): - phimin = self.right_edge.get_angle() - phimax = self.left_edge.get_angle() - else: - phimin = self.left_edge.get_angle() - phimax = self.right_edge.get_angle() - - sect = new_sector(r_min=rmin, r_max=rmax, - phi_min=phimin, phi_max=phimax) - sector = sect(self.data) - - from sas.qtgui.Plotting.PlotterData import Data1D - if hasattr(sector, "dxl"): - dxl = sector.dxl - else: - dxl = None - if hasattr(sector, "dxw"): - dxw = sector.dxw - else: - dxw = None - new_plot = Data1D(x=sector.x, y=sector.y, dy=sector.dy, - dxl=dxl, dxw=dxw) - new_plot.name = str(new_sector.__name__) + \ - "(" + self.data.name + ")" - new_plot.source = self.data.source - new_plot.interactive = True - # print "loader output.detector",output.source - new_plot.detector = self.data.detector - # If the data file does not tell us what the axes are, just assume... - new_plot.xaxis("\\rm{Q}", 'rad') - new_plot.yaxis("\\rm{Intensity} ", "cm^{-1}") - new_plot.group_id = str(new_sector.__name__) + self.data.name - - def validate(self, param_name, param_value): - """ - Test the proposed new value "value" for row "row" of parameters - """ - # Here, always return true - return True - - def moveend(self, ev): - #TODO: why is this empty? - pass - - def restore(self, ev): - """ - Restore the roughness for this layer. - """ - self.inner_circle.restore(ev) - self.outer_circle.restore(ev) - self.right_edge.restore(ev) - self.left_edge.restore(ev) - - def move(self, x, y, ev): - """ - Process move to a new position, making sure that the move is allowed. - """ - pass - - def set_cursor(self, x, y): - """ - """ - pass - - def get_params(self): - """ - """ - params = {} - params["r_min"] = self.inner_circle.get_radius() - params["r_max"] = self.outer_circle.get_radius() - params["phi_min"] = self.right_edge.get_angle() - params["phi_max"] = self.left_edge.get_angle() - params["nbins"] = self.nbins - return params - - def set_params(self, params): - """ - """ - # print "setparams on main slicer ",params - inner = params["r_min"] - outer = params["r_max"] - phi_min = params["phi_min"] - phi_max = params["phi_max"] - self.nbins = int(params["nbins"]) - - self.inner_circle.set_cursor(inner, phi_min, phi_max, self.nbins) - self.outer_circle.set_cursor(outer, phi_min, phi_max, self.nbins) - self.right_edge.set_cursor(inner, outer, phi_min) - self.left_edge.set_cursor(inner, outer, phi_max) - self._post_data() - - def freeze_axes(self): - """ - """ - self.base.freeze_axes() - - def thaw_axes(self): - """ - """ - self.base.thaw_axes() - - def draw(self): - """ - """ - self.base.draw() - -class SectorInteractorQ(SectorInteractor): - """ - """ - def __init__(self, base, axes, color='black', zorder=3): - """ - """ - SectorInteractor.__init__(self, base, axes, color=color) - self.base = base - self._post_data() - - def _post_data(self): - """ - """ - from sasdata.data_util.manipulations import SectorQ - self.post_data(SectorQ) - - -class SectorInteractorPhi(SectorInteractor): - """ - """ - def __init__(self, base, axes, color='black', zorder=3): - """ - """ - SectorInteractor.__init__(self, base, axes, color=color) - self.base = base - self._post_data() - - def _post_data(self): - """ - """ - from sasdata.data_util.manipulations import SectorPhi - self.post_data(SectorPhi) - diff --git a/src/sas/qtgui/Plotting/Slicers/BaseInteractor.py b/src/sas/qtgui/Plotting/Slicers/BaseInteractor.py index 9dffd71a74..5d23996837 100755 --- a/src/sas/qtgui/Plotting/Slicers/BaseInteractor.py +++ b/src/sas/qtgui/Plotting/Slicers/BaseInteractor.py @@ -140,7 +140,6 @@ def onDrag(self, ev): self.move(ev.xdata, ev.ydata, ev) else: self.restore(ev) - self.base.update() return True def onKey(self, ev): diff --git a/src/sas/qtgui/Plotting/Slicers/BoxSlicer.py b/src/sas/qtgui/Plotting/Slicers/BoxSlicer.py index bc93976247..65760e6f66 100644 --- a/src/sas/qtgui/Plotting/Slicers/BoxSlicer.py +++ b/src/sas/qtgui/Plotting/Slicers/BoxSlicer.py @@ -8,8 +8,16 @@ class BoxInteractor(BaseInteractor, SlicerModel): """ - BoxInteractor define a rectangle that return data1D average of Data2D - in a rectangle area defined by -x, x ,y, -y + BoxInteractor plots a data1D average of a rectangular area defined in + a Data2D object. The data1D averaging itself is performed in sasdata + by manipulations.py + + This class uses two other classes, HorizontalLines and VerticalLines, + to define the rectangle area: -x, x ,y, -y. It is subclassed by + BoxInteractorX and BoxInteracgtorY which define the direction of the + average. BoxInteractorX averages all the points from -y to +y as a + function of Q_x and BoxInteractorY averages all the points from + -x to +x as a function of Q_y """ def __init__(self, base, axes, item=None, color='black', zorder=3): BaseInteractor.__init__(self, base, axes, color=color) @@ -57,6 +65,7 @@ def __init__(self, base, axes, item=None, color='black', zorder=3): # of averaging data2D self.update() self._post_data() + self.draw() self.setModelFromParams() def update_and_post(self): @@ -65,6 +74,7 @@ def update_and_post(self): """ self.update() self._post_data() + self.draw() def set_layer(self, n): """ @@ -108,12 +118,9 @@ def save(self, ev): self.vertical_lines.save(ev) self.horizontal_lines.save(ev) - def _post_data(self): - pass - - def post_data(self, new_slab=None, nbins=None, direction=None): + def _post_data(self, new_slab=None, nbins=None, direction=None): """ - post data averaging in Qx or Qy given new_slab type + post 1D data averaging in Qx or Qy given new_slab type :param new_slab: slicer that determine with direction to average :param nbins: the number of points plotted when averaging @@ -195,7 +202,6 @@ def post_data(self, new_slab=None, nbins=None, direction=None): if self.update_model: self.setModelFromParams() - self.draw() def moveend(self, ev): """ @@ -250,10 +256,13 @@ def setParams(self, params): self.horizontal_lines.update(x=self.x, y=self.y) self.vertical_lines.update(x=self.x, y=self.y) - self.post_data(nbins=None) + self._post_data(nbins=None) + self.draw() def draw(self): """ + Draws the Canvas using the canvas.draw from the calling class + that instatiated this object. """ self.base.draw() @@ -261,7 +270,8 @@ def draw(self): class HorizontalLines(BaseInteractor): """ Draw 2 Horizontal lines centered on (0,0) that can move - on the x- direction and in opposite direction + on the x direction. The two lines move symmetrically (in opposite + directions). It also defines the x and -x position of a box. """ def __init__(self, base, axes, color='black', zorder=5, x=0.5, y=0.5): """ @@ -363,12 +373,15 @@ def move(self, x, y, ev): """ self.y = y self.has_move = True - self.base.base.update() + self.base.update() + self.base.draw() class VerticalLines(BaseInteractor): """ - Select an annulus through a 2D plot + Draw 2 vertical lines centered on (0,0) that can move + on the y direction. The two lines move symmetrically (in opposite + directions). It also defines the y and -y position of a box. """ def __init__(self, base, axes, color='black', zorder=5, x=0.5, y=0.5): """ @@ -470,12 +483,15 @@ def move(self, x, y, ev): """ self.has_move = True self.x = x - self.base.base.update() + self.base.update() + self.base.draw() class BoxInteractorX(BoxInteractor): """ - Average in Qx direction + Average in Qx direction. The data for all Qy at a constant Qx are + averaged together to provide a 1D array in Qx (to be plotted as a function + of Qx) """ def __init__(self, base, axes, item=None, color='black', zorder=3): BoxInteractor.__init__(self, base, axes, item=item, color=color) @@ -487,7 +503,7 @@ def _post_data(self): Post data creating by averaging in Qx direction """ from sasdata.data_util.manipulations import SlabX - self.post_data(SlabX, direction="X") + super()._post_data(SlabX, direction="X") def validate(self, param_name, param_value): """ @@ -506,7 +522,9 @@ def validate(self, param_name, param_value): class BoxInteractorY(BoxInteractor): """ - Average in Qy direction + Average in Qy direction. The data for all Qx at a constant Qy are + averaged together to provide a 1D array in Qy (to be plotted as a function + of Qy) """ def __init__(self, base, axes, item=None, color='black', zorder=3): BoxInteractor.__init__(self, base, axes, item=item, color=color) @@ -518,7 +536,7 @@ def _post_data(self): Post data creating by averaging in Qy direction """ from sasdata.data_util.manipulations import SlabY - self.post_data(SlabY, direction="Y") + super()._post_data(SlabY, direction="Y") def validate(self, param_name, param_value): """ diff --git a/src/sas/qtgui/Plotting/Slicers/BoxSum.py b/src/sas/qtgui/Plotting/Slicers/BoxSum.py index 9ede747fc1..efb1640b12 100644 --- a/src/sas/qtgui/Plotting/Slicers/BoxSum.py +++ b/src/sas/qtgui/Plotting/Slicers/BoxSum.py @@ -1,7 +1,3 @@ -""" -Boxsum Class: determine 2 rectangular area to compute -the sum of pixel of a Data. -""" import numpy from PySide6 import QtGui @@ -15,9 +11,17 @@ class BoxSumCalculator(BaseInteractor): """ - Boxsum Class: determine 2 rectangular area to compute - the sum of pixel of a Data. - Uses PointerInteractor , VerticalDoubleLine,HorizontalDoubleLine. + BoxSumCalculator Class computes properties (such as sum and average of + intensities) from a rectangular area defined in a data2D object. The actual + calculations are done by manipulations.py + + This class uses three other classes, PointerInteractor to define the center + of the rectangle, and VerticalDoubleLine and HorizontalDoubleLine to define + the rectangle x1,x2,y1,y2. + + ..TODO: the 3 classes here are the same as used by the BoxSlicer. These + should probably be abstracted out. + @param zorder: Artists with lower zorder values are drawn first. @param x_min: the minimum value of the x coordinate @param x_max: the maximum value of the x coordinate @@ -226,7 +230,6 @@ def postData(self): self.total, self.totalerror, self.points = boxtotal(self.data) if self.update_model: self.setModelFromParams() - self.draw() def moveend(self, ev): """ @@ -391,7 +394,8 @@ def move(self, x, y, ev): self.x = x self.y = y self.has_move = True - self.base.base.update() + self.base.update() + self.base.draw() def setCursor(self, x, y): """ @@ -558,7 +562,8 @@ def move(self, x, y, ev): self.x2 = self.center_x - delta self.half_width = numpy.fabs(self.x1 - self.x2) / 2 self.has_move = True - self.base.base.update() + self.base.update() + self.base.draw() def setCursor(self, x, y): """ @@ -569,7 +574,8 @@ def setCursor(self, x, y): class HorizontalDoubleLine(BaseInteractor): """ - Select an annulus through a 2D plot + Draw 2 horizontal lines moving in opposite direction and centered on + a point (PointInteractor) """ def __init__(self, base, axes, color='black', zorder=5, x=0.5, y=0.5, center_x=0.0, center_y=0.0): @@ -720,7 +726,8 @@ def move(self, x, y, ev): self.y2 = self.center_y - delta self.half_height = numpy.fabs(self.y1) - self.center_y self.has_move = True - self.base.base.update() + self.base.update() + self.base.draw() def setCursor(self, x, y): """ diff --git a/src/sas/qtgui/Plotting/Slicers/RadiusInteractor.py b/src/sas/qtgui/Plotting/Slicers/RadiusInteractor.py index 876e18537e..0bacc4a5e2 100755 --- a/src/sas/qtgui/Plotting/Slicers/RadiusInteractor.py +++ b/src/sas/qtgui/Plotting/Slicers/RadiusInteractor.py @@ -1,66 +1,92 @@ import numpy as np -from sas.qtgui.Plotting.Slicers.BaseInteractor import BaseInteractor +from sas.qtgui.Plotting.Slicers.BaseInteractor import BaseInteractor class RadiusInteractor(BaseInteractor): """ - Select an annulus through a 2D plot + Draw a pair of lines radiating from a center at [0,0], between radius + values r1 and r2 with and average angle from the x-axis of theta, and an + angular diaplacement of phi either side of this average. Used for example + to to define the left and right edges of the a wedge area on a plot, see + WedgeInteractor. User interaction adjusts the parameter phi. + + :param r1: radius of the inner end of the radial lines + :param r2: radius of the outer end of the radial lines + :param theta: average angle of the lines from the x-axis + :param phi: angular displacement of the lines either side of theta """ - def __init__(self, base, axes, color='black', zorder=5, arc1=None, - arc2=None, theta=np.pi / 8): - """ - """ - _BaseInteractor.__init__(self, base, axes, color=color) + def __init__(self, base, axes, color='black', zorder=5, r1=1.0, r2=2.0, + theta=np.pi / 3, phi=np.pi / 8): + BaseInteractor.__init__(self, base, axes, color=color) self.markers = [] self.axes = axes - self.r1 = arc1.get_radius() - self.r2 = arc2.get_radius() + self.color = color + # Key variables used when drawing the interactor element + self.r1 = r1 + self.r2 = r2 self.theta = theta - self.save_theta = theta - self.move_stop = False - self.theta_left = None - self.theta_right = None - self.arc1 = arc1 - self.arc2 = arc2 - x1 = self.r1 * np.cos(self.theta) - y1 = self.r1 * np.sin(self.theta) - x2 = self.r2 * np.cos(self.theta) - y2 = self.r2 * np.sin(self.theta) - self.line = self.axes.plot([x1, x2], [y1, y2], - linestyle='-', marker='', - color=self.color, - visible=True)[0] - self.phi = theta - self.npts = 20 + # Core variable altered by the user + self.phi = phi + # Last known phi value for when the cursor moves off the plot + self.save_phi = phi + # Variables for the left and right radial lines + l_x1 = self.r1 * np.cos(self.theta + self.phi) + l_y1 = self.r1 * np.sin(self.theta + self.phi) + l_x2 = self.r2 * np.cos(self.theta + self.phi) + l_y2 = self.r2 * np.sin(self.theta + self.phi) + r_x1 = self.r1 * np.cos(self.theta - self.phi) + r_y1 = self.r1 * np.sin(self.theta - self.phi) + r_x2 = self.r2 * np.cos(self.theta - self.phi) + r_y2 = self.r2 * np.sin(self.theta - self.phi) + # Define the left and right markers + self.l_marker = self.axes.plot([(l_x1+l_x2)/2], [(l_y1+l_y2)/2], + linestyle='', marker='s', markersize=10, + color=self.color, alpha=0.6, + pickradius=5, label='pick', + zorder=zorder, visible=True)[0] + self.r_marker = self.axes.plot([(r_x1+r_x2)/2], [(r_y1+r_y2)/2], + linestyle='', marker='s', markersize=10, + color=self.color, alpha=0.6, + pickradius=5, label='pick', + zorder=zorder, visible=True)[0] + # Define the left and right lines + self.l_line = self.axes.plot([l_x1, l_x2], [l_y1, l_y2], + linestyle='-', marker='', + color=self.color, visible=True)[0] + self.r_line = self.axes.plot([r_x1, r_x2], [r_y1, r_y2], + linestyle='-', marker='', + color=self.color, visible=True)[0] + # Flag to keep track of motion self.has_move = False - self.connect_markers([self.line]) + self.connect_markers([self.l_marker, self.l_line, + self.r_marker, self.r_line]) self.update() def set_layer(self, n): """ + Allow adding plot to the same panel + :param n: the number of layer """ self.layernum = n self.update() def clear(self): """ + Clear this slicer and its markers """ self.clear_markers() - try: - self.line.remove() - except: - # Old version of matplotlib - for item in range(len(self.axes.lines)): - del self.axes.lines[0] + self.l_marker.remove() + self.l_line.remove() + self.r_marker.remove() + self.r_line.remove() - def get_angle(self): - """ - """ - return self.theta - - def update(self, r1=None, r2=None, theta=None): + def update(self, r1=None, r2=None, theta=None, phi=None): """ Draw the new roughness on the graph. + :param r1: radius of the inner end of the radial lines + :param r2: radius of the outer end of the radial lines + :param theta: average angle of the lines from the x-axis + :param phi: angular displacement of the lines either side of theta """ if r1 is not None: self.r1 = r1 @@ -68,22 +94,34 @@ def update(self, r1=None, r2=None, theta=None): self.r2 = r2 if theta is not None: self.theta = theta - x1 = self.r1 * np.cos(self.theta) - y1 = self.r1 * np.sin(self.theta) - x2 = self.r2 * np.cos(self.theta) - y2 = self.r2 * np.sin(self.theta) - self.line.set(xdata=[x1, x2], ydata=[y1, y2]) + if phi is not None: + self.phi = phi + # Variables for the left and right radial lines + l_x1 = self.r1 * np.cos(self.theta + self.phi) + l_y1 = self.r1 * np.sin(self.theta + self.phi) + l_x2 = self.r2 * np.cos(self.theta + self.phi) + l_y2 = self.r2 * np.sin(self.theta + self.phi) + r_x1 = self.r1 * np.cos(self.theta - self.phi) + r_y1 = self.r1 * np.sin(self.theta - self.phi) + r_x2 = self.r2 * np.cos(self.theta - self.phi) + r_y2 = self.r2 * np.sin(self.theta - self.phi) + # Draw the updated markers and lines + self.l_marker.set(xdata=[(l_x1+l_x2)/2], ydata=[(l_y1+l_y2)/2]) + self.l_line.set(xdata=[l_x1, l_x2], ydata=[l_y1, l_y2]) + self.r_marker.set(xdata=[(r_x1+r_x2)/2], ydata=[(r_y1+r_y2)/2]) + self.r_line.set(xdata=[r_x1, r_x2], ydata=[r_y1, r_y2]) def save(self, ev): """ Remember the roughness for this layer and the next so that we can restore on Esc. """ - self.save_theta = np.arctan2(ev.y, ev.x) - self.base.freeze_axes() + self.save_phi = self.phi def moveend(self, ev): """ + Called when any dragging motion ends. + Redraw the plot with new parameters and set self.has_move to False. """ self.has_move = False self.base.moveend(ev) @@ -92,38 +130,22 @@ def restore(self, ev): """ Restore the roughness for this layer. """ - self.theta = self.save_theta + self.phi = self.save_phi def move(self, x, y, ev): """ - Process move to a new position, making sure that the move is allowed. + Process move to a new position. """ - self.theta = np.arctan2(y, x) + angle = np.arctan2(y, x) + phi = np.fabs(angle - self.theta) + if phi > np.pi: + phi = 2 * np.pi - phi + self.phi = phi self.has_move = True - self.base.base.update() + self.base.update() + self.base.draw() - def set_cursor(self, r_min, r_max, theta): - """ - """ - self.theta = theta - self.r1 = r_min - self.r2 = r_max + def set_cursor(self, x, y): + self.move(x, y, None) self.update() - def get_params(self): - """ - """ - params = {} - params["radius1"] = self.r1 - params["radius2"] = self.r2 - params["theta"] = self.theta - return params - - def set_params(self, params): - """ - """ - x1 = params["radius1"] - x2 = params["radius2"] - theta = params["theta"] - self.set_cursor(x1, x2, theta) - diff --git a/src/sas/qtgui/Plotting/Slicers/SectorSlicer.py b/src/sas/qtgui/Plotting/Slicers/SectorSlicer.py index cb47b09285..5ead14e7bc 100644 --- a/src/sas/qtgui/Plotting/Slicers/SectorSlicer.py +++ b/src/sas/qtgui/Plotting/Slicers/SectorSlicer.py @@ -1,6 +1,3 @@ -""" - Sector interactor -""" import numpy import logging @@ -13,7 +10,21 @@ class SectorInteractor(BaseInteractor, SlicerModel): """ - Draw a sector slicer.Allow to performQ averaging on data 2D + SectorInteractor plots a data1D average of a sector area defined in a + Data2D object. The data1D averaging itself is performed in sasdata by + manipulations.py. Sectors all go through a single point as (0,0). + + This class uses two other classes, LineInteractor and SideInteractor, to + define a sector centered around a main line defined by LineInteractor + which goes through 0,0 at some user settable angle theta from 0. The + sector itself is defined by the right and left sidelines, both of which + also go through (0,0), and set by SideInteractor from -phi to +phi around + the center line defined by the main line. All points at a constant Q from + -phi to +phi are averaged together to provide a 1D array in Q (to be + plotted as a function of Q). + + ..TODO: the 2 subclasses here are the same as used by the BoxSum. These + should probably be abstracted out. """ def __init__(self, base, axes, item=None, color='black', zorder=3): @@ -48,15 +59,18 @@ def __init__(self, base, axes, item=None, color='black', zorder=3): self.right_line = SideInteractor(self, self.axes, color='black', zorder=zorder, r=self.qmax, phi=-1 * self.phi, theta2=self.theta2) + self.right_line.update(right=True) self.right_line.qmax = self.qmax # Left Side line self.left_line = SideInteractor(self, self.axes, color='black', zorder=zorder, r=self.qmax, phi=self.phi, theta2=self.theta2) + self.left_line.update(left=True) self.left_line.qmax = self.qmax # draw the sector self.update() self._post_data() + self.draw() self.setModelFromParams() def set_layer(self, n): @@ -89,24 +103,23 @@ def update(self): if self.main_line.has_move: self.main_line.update() self.right_line.update(delta=-self.left_line.phi / 2, - mline=self.main_line.theta) + mline=self.main_line.theta, right=True) self.left_line.update(delta=self.left_line.phi / 2, - mline=self.main_line.theta) + mline=self.main_line.theta, left=True) # Check if the left side has moved and update the slicer accordingly if self.left_line.has_move: self.main_line.update() self.left_line.update(phi=None, delta=None, mline=self.main_line, side=True, left=True) self.right_line.update(phi=self.left_line.phi, delta=None, - mline=self.main_line, side=True, - left=False, right=True) + mline=self.main_line, side=True, right=True) # Check if the right side line has moved and update the slicer accordingly if self.right_line.has_move: self.main_line.update() self.right_line.update(phi=None, delta=None, mline=self.main_line, - side=True, left=False, right=True) + side=True, right=True) self.left_line.update(phi=self.right_line.phi, delta=None, - mline=self.main_line, side=True, left=False) + mline=self.main_line, side=True, left=True) def save(self, ev): """ @@ -178,7 +191,6 @@ def _post_data(self, nbins=None): if self.update_model: self.setModelFromParams() - self.draw() def validate(self, param_name, param_value): """ @@ -262,10 +274,11 @@ def setParams(self, params): self.main_line.update() self.right_line.update(phi=phi, delta=None, mline=self.main_line, side=True, right=True) - self.left_line.update(phi=phi, delta=None, - mline=self.main_line, side=True) + self.left_line.update(phi=phi, delta=None, mline=self.main_line, + side=True, left=True) # Post the new corresponding data self._post_data(nbins=self.nbins) + self.draw() def draw(self): """ @@ -276,7 +289,10 @@ def draw(self): class SideInteractor(BaseInteractor): """ - Draw an oblique line + Draws a line though 0,0 on a data2D plot with reference to a center line. + This is used to define both a left and right line which are always updated + together as they must remain symmetric at some phi value around the main + line (at -phi and +phi). :param phi: the phase between the middle line and one side line :param theta2: the angle between the middle line and x- axis @@ -445,7 +461,8 @@ def move(self, x, y, ev): self.phi = numpy.fabs(self.theta2 - self.theta) if self.phi > numpy.pi: self.phi = 2 * numpy.pi - numpy.fabs(self.theta2 - self.theta) - self.base.base.update() + self.base.update() + self.base.draw() def set_cursor(self, x, y): self.move(x, y, None) @@ -464,10 +481,16 @@ def setParams(self, params): class LineInteractor(BaseInteractor): """ - Select an annulus through a 2D plot + Draws a line though 0,0 on a data2D plot. This is used to define the + centerline around with other lines can be drawn to define a region of + interest (such as a sector). + + :param theta: the angle between the middle line and x- axis + :param half_length: Defaults to False. If True, the line is drawn from the + origin rather than across the whole graph. """ def __init__(self, base, axes, color='black', - zorder=5, r=1.0, theta=numpy.pi / 4): + zorder=5, r=1.0, theta=numpy.pi / 4, half_length=False): BaseInteractor.__init__(self, base, axes, color=color) self.markers = [] @@ -477,11 +500,16 @@ def __init__(self, base, axes, color='black', self.theta = theta self.radius = r self.scale = 10.0 + self.half_length = half_length # Inner circle x1 = self.radius * numpy.cos(self.theta) y1 = self.radius * numpy.sin(self.theta) - x2 = -1 * self.radius * numpy.cos(self.theta) - y2 = -1 * self.radius * numpy.sin(self.theta) + if not half_length: + x2 = -1 * self.radius * numpy.cos(self.theta) + y2 = -1 * self.radius * numpy.sin(self.theta) + else: + x2 = 0 + y2 = 0 # Inner circle marker self.inner_marker = self.axes.plot([x1 / 2.5], [y1 / 2.5], linestyle='', marker='s', markersize=10, @@ -492,7 +520,6 @@ def __init__(self, base, axes, color='black', self.line = self.axes.plot([x1, x2], [y1, y2], linestyle='-', marker='', color=self.color, visible=True)[0] - self.npts = 20 self.has_move = False self.connect_markers([self.inner_marker, self.line]) self.update() @@ -520,8 +547,12 @@ def update(self, theta=None): self.theta = theta x1 = self.radius * numpy.cos(self.theta) y1 = self.radius * numpy.sin(self.theta) - x2 = -1 * self.radius * numpy.cos(self.theta) - y2 = -1 * self.radius * numpy.sin(self.theta) + if not self.half_length: + x2 = -1 * self.radius * numpy.cos(self.theta) + y2 = -1 * self.radius * numpy.sin(self.theta) + else: + x2 = 0 + y2 = 0 self.inner_marker.set(xdata=[x1 / 2.5], ydata=[y1 / 2.5]) self.line.set(xdata=[x1, x2], ydata=[y1, y2]) @@ -549,7 +580,8 @@ def move(self, x, y, ev): """ self.theta = numpy.arctan2(y, x) self.has_move = True - self.base.base.update() + self.base.update() + self.base.draw() def set_cursor(self, x, y): self.move(x, y, None) diff --git a/src/sas/qtgui/Plotting/Slicers/WedgeSlicer.py b/src/sas/qtgui/Plotting/Slicers/WedgeSlicer.py new file mode 100644 index 0000000000..03ac2978a8 --- /dev/null +++ b/src/sas/qtgui/Plotting/Slicers/WedgeSlicer.py @@ -0,0 +1,360 @@ +import numpy as np + +from sas.qtgui.Plotting.Slicers.BaseInteractor import BaseInteractor +from sas.qtgui.Plotting.SlicerModel import SlicerModel +from sas.qtgui.Plotting.PlotterData import Data1D +import sas.qtgui.Utilities.GuiUtils as GuiUtils + +from sas.qtgui.Plotting.Slicers.ArcInteractor import ArcInteractor +from sas.qtgui.Plotting.Slicers.RadiusInteractor import RadiusInteractor +from sas.qtgui.Plotting.Slicers.SectorSlicer import LineInteractor + +class WedgeInteractor(BaseInteractor, SlicerModel): + """ + This WedgeInteractor is a cross between the SectorInteractor and the + AnnulusInteractor. It plots a data1D average of a wedge area defined in a + Data2D object, in either the Q direction or the Phi direction. The data1D + averaging itself is performed in sasdata by manipulations.py. + + This class uses three other classes, ArcInteractor (in ArcInteractor.py), + RadiusInteractor (in RadiusInteractor.py), and LineInteractor + (in SectorSlicer.py), to define a wedge area contained + between two radial lines running through (0,0) defining the left and right + edges of the wedge (similar to the sector), and two rings at Q1 and Q2 + (similar to the annulus). The wedge is centred on the line defined by + LineInteractor, which the radial lines move symmetrically around. + This class is itself subclassed by SectorInteractorPhi and + SectorInteractorQ which define the direction of the averaging. + SectorInteractorPhi averages all Q points at constant Phi (as for the + AnnulusSlicer) and SectorInteractorQ averages all phi points at constant Q + (as for the SectorSlicer). + """ + def __init__(self, base, axes, item=None, color='black', zorder=3): + + BaseInteractor.__init__(self, base, axes, color=color) + SlicerModel.__init__(self) + + self.markers = [] + self.axes = axes + self._item = item + self.qmax = max(self.data.xmax, np.fabs(self.data.xmin), + self.data.ymax, np.fabs(self.data.ymin)) + self.dqmin = min(np.fabs(self.data.qx_data)) + self.connect = self.base.connect + + # Number of points on the plot + self.nbins = 100 + # Radius of the inner edge of the wedge + self.r1 = self.qmax / 2.0 + # Radius of the outer edge of the wedge + self.r2 = self.qmax / 1.6 + # Angle of the central line + self.theta = np.pi / 3 + # Angle between the central line and the radial lines either side of it + self.phi = np.pi / 8 + # reference of the current data averager + self.averager = None + + self.inner_arc = ArcInteractor(self, self.axes, color='black', + zorder=zorder, r=self.r1, + theta=self.theta, phi=self.phi) + self.inner_arc.qmax = self.qmax + self.outer_arc = ArcInteractor(self, self.axes, color='black', + zorder=zorder + 1, r=self.r2, + theta=self.theta, phi=self.phi) + self.outer_arc.qmax = self.qmax * 1.2 + self.radial_lines = RadiusInteractor(self, self.axes, color='black', + zorder=zorder + 1, + r1=self.r1, r2=self.r2, + theta=self.theta, phi=self.phi) + self.radial_lines.qmax = self.qmax * 1.2 + self.central_line = LineInteractor(self, self.axes, color='black', + zorder=zorder, r=self.qmax * 1.414, + theta=self.theta, half_length=True) + self.central_line.qmax = self.qmax * 1.414 + self.update() + self.draw() + self._post_data() + self.setModelFromParams() + + def set_layer(self, n): + """ + Allow adding plot to the same panel + :param n: the number of layer + """ + self.layernum = n + self.update() + + def clear(self): + """ + Clear the slicer and all connected events related to this slicer + """ + self.averager = None + self.clear_markers() + self.outer_arc.clear() + self.inner_arc.clear() + self.radial_lines.clear() + self.central_line.clear() + self.base.connect.clearall() + + def update(self): + """ + If one of the interactors has been moved, update it and the parameter + it controls, then update the other interactors accordingly + """ + if self.inner_arc.has_move: + self.inner_arc.update() + self.r1 = self.inner_arc.radius + self.radial_lines.update(r1=self.r1) + if self.outer_arc.has_move: + self.outer_arc.update() + self.r2 = self.outer_arc.radius + self.radial_lines.update(r2=self.r2) + if self.radial_lines.has_move: + self.radial_lines.update() + self.phi = self.radial_lines.phi + self.inner_arc.update(phi=self.phi) + self.outer_arc.update(phi=self.phi) + if self.central_line.has_move: + self.central_line.update() + self.theta = self.central_line.theta + self.inner_arc.update(theta=self.theta) + self.outer_arc.update(theta=self.theta) + self.radial_lines.update(theta=self.theta) + + def save(self, ev): + """ + Remember the roughness for this layer and the next so that we + can restore on Esc. + """ + self.inner_arc.save(ev) + self.outer_arc.save(ev) + self.radial_lines.save(ev) + self.central_line.save(ev) + + def _post_data(self, new_sector=None, nbins=None): + """ + post 1D data averagin in Q or Phi given new_sector type + + :param new_sector: slicer used for directional averaging in Q or Phi + :param nbins: the number of point plotted when averaging + :TODO - Unlike other slicers, the two sector types are sufficiently + different that this method contains three instances of If (check class name) do x. + The point of post_data vs _post_data I think was to avoid this kind of thing and + suggests that in this case we may need a new method in the WedgeInteracgtorPhi + and WedgeInteracgtorQ to handle these specifics. Probably by creating the 1D plot + object in those top level classes along with the specifc attributes. + """ + # Data to average + data = self.data + if data is None: + return + + if self.inner_arc.radius < self.outer_arc.radius: + rmin = self.inner_arc.radius + rmax = self.outer_arc.radius + else: + rmin = self.outer_arc.radius + rmax = self.inner_arc.radius + phimin = self.central_line.theta - self.radial_lines.phi + phimax = self.central_line.theta + self.radial_lines.phi + + if nbins is not None: + self.nbins = nbins + if self.averager is None: + if new_sector is None: + msg = "post data:cannot average , averager is empty" + raise ValueError(msg) + self.averager = new_sector + + # Add pi to the angles before invoking sector averaging to transform angular + # range from python default of -pi,pi to 0,2pi suitable for manipulations + sect = self.averager(r_min=rmin, r_max=rmax, phi_min=phimin + np.pi, + phi_max=phimax + np.pi, nbins=self.nbins) + sect.fold = False + sector = sect(self.data) + + if hasattr(sector, "dxl"): + dxl = sector.dxl + else: + dxl = None + if hasattr(sector, "dxw"): + dxw = sector.dxw + else: + dxw = None + if self.averager.__name__ == 'SectorPhi': + # And here subtract pi when getting angular data back from wedge averaging in + # phi in manipulations to get back in the -pi,pi range. Also convert from + # radians to degrees for nicer display. + sector.x = (sector.x - np.pi) * 180 / np.pi + new_plot = Data1D(x=sector.x, y=sector.y, dy=sector.dy, dx=sector.dx) + new_plot.dxl = dxl + new_plot.dxw = dxw + new_plot.name = str(self.averager.__name__) + \ + "(" + self.data.name + ")" + new_plot.source = self.data.source + new_plot.interactive = True + new_plot.detector = self.data.detector + # If the data file does not tell us what the axes are, just assume... + if self.averager.__name__ == 'SectorPhi': + # angular plots usually require a linear x scale and better with + # a linear y scale as well. + new_plot.xaxis("\\rm{\phi}", "degrees") + new_plot.xtransform = 'x' + new_plot.ytransform = 'y' + else: + new_plot.xaxis("\\rm{Q}", 'A^{-1}') + new_plot.yaxis("\\rm{Intensity} ", "cm^{-1}") + + + new_plot.id = str(self.averager.__name__) + self.data.name + new_plot.group_id = new_plot.id + new_plot.is_data = True + item = self._item + if self._item.parent() is not None: + item = self._item.parent() + GuiUtils.updateModelItemWithPlot(item, new_plot, new_plot.id) + + self.base.manager.communicator.plotUpdateSignal.emit([new_plot]) + self.base.manager.communicator.forcePlotDisplaySignal.emit([item, new_plot]) + + if self.update_model: + self.setModelFromParams() + + def validate(self, param_name, param_value): + """ + Validate input from user. + Values get checked at apply time. + """ + + def check_radius_difference(param_name, other_radius_name, param_value): + if np.fabs(param_value - self.getParams()[other_radius_name]) < self.dqmin: + return "Inner and outer radii too close. Please adjust." + elif param_value > self.qmax: + return f"{param_name} exceeds maximum range. Please adjust." + return None + + def check_phi_difference(param_value): + if np.fabs(param_value) < 0.01: + return "Sector angles too close. Please adjust." + return None + + def check_bins(param_value): + if param_value < 1: + return "Number of bins cannot be <= 0. Please adjust." + return None + + validators = { + 'r_min': lambda value: check_radius_difference('r_min', 'r_max', value), + 'r_max': lambda value: check_radius_difference('r_max', 'r_min', value), + 'delta_phi [deg]': check_phi_difference, + 'nbins': check_bins + } + + if param_name in validators: + error_message = validators[param_name](param_value) + if error_message: + print(error_message) + return False + + return True + + def moveend(self, ev): + """ + Called after a dragging event. + Post the slicer new parameters and creates a new Data1D + corresponding to the new average + """ + self._post_data() + + def restore(self, ev): + """ + Restore the roughness for this layer. + """ + self.inner_arc.restore(ev) + self.outer_arc.restore(ev) + self.radial_lines.restore(ev) + self.central_line.restore(ev) + + def move(self, x, y, ev): + """ + Process move to a new position. + """ + pass + + def set_cursor(self, x, y): + pass + + def getParams(self): + """ + Store a copy of values of parameters of the slicer into a dictionary. + :return params: the dictionary created + """ + params = {} + params["r_min"] = self.inner_arc.radius + params["r_max"] = self.outer_arc.radius + params["phi [deg]"] = self.central_line.theta * 180 / np.pi + params["delta_phi [deg]"] = self.radial_lines.phi * 180 / np.pi + params["nbins"] = self.nbins + return params + + def setParams(self, params): + """ + Receive a dictionary and reset the slicer with values contained + in the values of the dictionary. + + :param params: a dictionary containing name of slicer parameters and + values the user assigned to the slicer. + """ + self.r1 = params["r_min"] + self.r2 = params["r_max"] + self.theta = params["phi [deg]"] * np.pi / 180 + self.phi = params["delta_phi [deg]"] * np.pi / 180 + self.nbins = int(params["nbins"]) + + self.inner_arc.update(theta=self.theta, phi=self.phi, r=self.r1) + self.outer_arc.update(theta=self.theta, phi=self.phi, r=self.r2) + self.radial_lines.update(r1=self.r1, r2=self.r2, + theta=self.theta, phi=self.phi) + self.central_line.update(theta=self.theta) + self._post_data() + self.draw() + + def draw(self): + """ + Draws the Canvas using the canvas.draw from the calling class + that instantiated this object. + """ + self.base.draw() + +class WedgeInteractorQ(WedgeInteractor): + """ + Average in Q direction. The data for all phi at a constant Q are + averaged together to provide a 1D array in Q (to be plotted as a function + of Q) + """ + def __init__(self, base, axes, item=None, color='black', zorder=3): + WedgeInteractor.__init__(self, base, axes, item=item, color=color) + self.base = base + self._post_data() + + def _post_data(self): + from sasdata.data_util.manipulations import SectorQ + super()._post_data(SectorQ) + + +class WedgeInteractorPhi(WedgeInteractor): + """ + Average in phi direction. The data for all Q at a constant phi are + averaged together to provide a 1D array in phi (to be plotted as a function + of phi) + """ + def __init__(self, base, axes, item=None, color='black', zorder=3): + WedgeInteractor.__init__(self, base, axes, item=item, color=color) + self.base = base + self._post_data() + + def _post_data(self): + from sasdata.data_util.manipulations import SectorPhi + super()._post_data(SectorPhi) + diff --git a/src/sas/qtgui/Plotting/UI/SlicerParametersUI.ui b/src/sas/qtgui/Plotting/UI/SlicerParametersUI.ui index 5cfbcb215e..0acde9cf04 100755 --- a/src/sas/qtgui/Plotting/UI/SlicerParametersUI.ui +++ b/src/sas/qtgui/Plotting/UI/SlicerParametersUI.ui @@ -7,7 +7,7 @@ 0 0 395 - 458 + 468 @@ -145,6 +145,16 @@ Box Interactor Y + + + Wedge Interactor Q + + + + + Wedge Interactor Phi + + diff --git a/src/sas/qtgui/Utilities/GuiUtils.py b/src/sas/qtgui/Utilities/GuiUtils.py index 2e439411d9..7166e15c3c 100644 --- a/src/sas/qtgui/Utilities/GuiUtils.py +++ b/src/sas/qtgui/Utilities/GuiUtils.py @@ -961,42 +961,67 @@ def replaceHTMLwithASCII(html): return html -def convertUnitToUTF8(unit): - """ - Convert ASCII unit display into UTF-8 symbol - """ - if unit == "1/A": - return "Å-1" - elif unit == "1/cm": - return "cm-1" - elif unit == "Ang": - return "Å" - elif unit == "1e-6/Ang^2": - return "10-62" - elif unit == "inf": - return "∞" - elif unit == "-inf": - return "-∞" - else: - return unit +def rstToHtml(s): + # Extract the unit and replacement parts + match_replace = re.match(r'(?:\.\. )?\|(.+?)\| replace:: (.+)', s) + match_unit = re.match(r'(?:\.\. )?\|(.+?)\| unicode:: (U\+\w+)', s) + unit = None + replacement = None + + + if match_unit: + # replace the 'unicode' section + unit, unicode_val = match_unit.groups() + # Convert the unicode value to actual character representation + replacement = chr(int(unicode_val[2:], 16)) + + if match_replace: + # replace the 'replace' section + + unit, replacement = match_replace.groups() + + # Convert the unit into a valid Python string condition + unit = unit.replace("\\", "").replace(" ", "") + + # Convert the replacement into the desired HTML format + replacement = replacement.replace("|Ang|", "Å").replace("\\ :sup:`", "").replace("`", "").replace("\\", "") + replacement = replacement.replace("|cdot|", "·").replace("|deg|", "°").replace("|pm|", "±") + + + return unit, replacement + +# RST_PROLOG conversion table +try: + from sasmodels.generate import RST_PROLOG +except ImportError: + RST_PROLOG = "" +RST_PROLOG_DICT = {} +input_rst_strings = RST_PROLOG.splitlines() +for line in input_rst_strings: + if line.startswith(".. |"): + key, value = rstToHtml(line) + RST_PROLOG_DICT[key] = value +# add units not in RST_PROLOG +# This section will be removed once all these units are added to sasmodels +RST_PROLOG_DICT["1/A"] = "Å-1" +RST_PROLOG_DICT["1/Ang"] = "Å-1" +RST_PROLOG_DICT["1/cm"] = "cm-1" +RST_PROLOG_DICT["1e-6/Ang^2"] = "10-62" +RST_PROLOG_DICT["1e15/cm^3"] = "1015/cm3" +RST_PROLOG_DICT["inf"] = "∞" +RST_PROLOG_DICT["-inf"] = "-∞" +RST_PROLOG_DICT["degrees"] = "°" + def convertUnitToHTML(unit): """ - Convert ASCII unit display into well rendering HTML - """ - if unit == "1/A": - return "Å-1" - elif unit == "1/cm": - return "cm-1" - elif unit == "Ang": - return "Å" - elif unit == "1e-6/Ang^2": - return "10-6/Å2" - elif unit == "inf": - return "∞" - elif unit == "-inf": - return "-∞" + Convert ASCII unit display into HTML symbol + """ + if unit in RST_PROLOG_DICT: + return RST_PROLOG_DICT[unit] else: + if unit is None or "None" in unit: + return "" return unit def parseName(name, expression): diff --git a/src/sas/qtgui/Utilities/Preferences/DisplayPreferencesWidget.py b/src/sas/qtgui/Utilities/Preferences/DisplayPreferencesWidget.py index abdd1d6143..da535cb4f6 100644 --- a/src/sas/qtgui/Utilities/Preferences/DisplayPreferencesWidget.py +++ b/src/sas/qtgui/Utilities/Preferences/DisplayPreferencesWidget.py @@ -6,7 +6,10 @@ class DisplayPreferencesWidget(PreferencesWidget): def __init__(self): super(DisplayPreferencesWidget, self).__init__("Display Settings") - self.config_params = ['QT_SCALE_FACTOR', 'QT_AUTO_SCREEN_SCALE_FACTOR'] + self.config_params = ['QT_SCALE_FACTOR', + 'QT_AUTO_SCREEN_SCALE_FACTOR', + 'DISABLE_RESIDUAL_PLOT', + 'DISABLE_POLYDISPERSITY_PLOT'] self.restart_params = {'QT_SCALE_FACTOR': 'QT Screen Scale Factor', 'QT_AUTO_SCREEN_SCALE_FACTOR': "Enable Automatic Scaling"} @@ -21,12 +24,26 @@ def _addAllWidgets(self): checked=config.QT_AUTO_SCREEN_SCALE_FACTOR) self.autoScaling.clicked.connect( lambda: self._stageChange('QT_AUTO_SCREEN_SCALE_FACTOR', self.autoScaling.isChecked())) + self.disableResidualPlot = self.addCheckBox( + title="Disable Residuals Display", + checked=config.DISABLE_RESIDUAL_PLOT) + self.disableResidualPlot.clicked.connect( + lambda: self._stageChange('DISABLE_RESIDUAL_PLOT', self.disableResidualPlot.isChecked())) + self.disablePolydispersityPlot = self.addCheckBox( + title="Disable Polydispersity Plot Display", + checked=config.DISABLE_POLYDISPERSITY_PLOT) + self.disablePolydispersityPlot.clicked.connect( + lambda: self._stageChange('DISABLE_POLYDISPERSITY_PLOT', self.disablePolydispersityPlot.isChecked())) def _toggleBlockAllSignaling(self, toggle): self.qtScaleFactor.blockSignals(toggle) self.autoScaling.blockSignals(toggle) + self.disableResidualPlot.blockSignals(toggle) + self.disablePolydispersityPlot.blockSignals(toggle) def _restoreFromConfig(self): self.qtScaleFactor.setText(str(config.QT_SCALE_FACTOR)) self.qtScaleFactor.setStyleSheet("background-color: white") self.autoScaling.setChecked(bool(config.QT_AUTO_SCREEN_SCALE_FACTOR)) + self.disableResidualPlot.setChecked(config.DISABLE_RESIDUAL_PLOT) + self.disablePolydispersityPlot.setChecked(config.DISABLE_POLYDISPERSITY_PLOT) diff --git a/src/sas/qtgui/Utilities/PythonSyntax.py b/src/sas/qtgui/Utilities/PythonSyntax.py index 2a5d88e39a..b863d4d993 100644 --- a/src/sas/qtgui/Utilities/PythonSyntax.py +++ b/src/sas/qtgui/Utilities/PythonSyntax.py @@ -81,6 +81,8 @@ def __init__(self, document, is_python=True): # syntax highlighting from this point onward self.tri_single = (QRegularExpression("'''"), 1, STYLES['string2']) self.tri_double = (QRegularExpression('"""'), 2, STYLES['string2']) + self.tri_single_raw = (QRegularExpression(r'r\'\'\''), 3, STYLES['string2']) + self.tri_double_raw = (QRegularExpression(r'r\"\"\"'), 4, STYLES['string2']) rules = [] @@ -131,14 +133,15 @@ def highlightBlock(self, text): """ # Do other syntax formatting for expression, nth, format in self.rules: - index = expression.indexIn(text, 0) + match = expression.match(text) + index = match.capturedStart(0) while index >= 0: # We actually want the index of the nth match - index = expression.pos(nth) - length = len(expression.cap(nth)) + index = match.capturedStart(nth) + length = match.capturedLength(nth) self.setFormat(index, length, format) - index = expression.indexIn(text, index + length) + index = match.capturedStart(index+length) self.setCurrentBlockState(0) @@ -147,6 +150,9 @@ def highlightBlock(self, text): if not in_multiline: in_multiline = self.match_multiline(text, *self.tri_double) + in_multiline = in_multiline or self.match_multiline(text, *self.tri_single_raw) + if not in_multiline: + in_multiline = self.match_multiline(text, *self.tri_double_raw) def match_multiline(self, text, delimiter, in_state, style): """Do highlighting of multi-line strings. ``delimiter`` should be a @@ -161,17 +167,20 @@ def match_multiline(self, text, delimiter, in_state, style): add = 0 # Otherwise, look for the delimiter on this line else: - start = delimiter.indexIn(text) + match = delimiter.match(text) + start = match.capturedStart(0) + end = match.capturedEnd(0) # Move past this match - add = delimiter.matchedLength() + add = end - start + 1 # As long as there's a delimiter match on this line... while start >= 0: - # Look for the ending delimiter - end = delimiter.indexIn(text, start + add) + # Look for the ending delimiter starting from where the last match ended + match = delimiter.match(text, start + add) + end = match.capturedEnd(0) # Ending delimiter on this line? if end >= add: - length = end - start + add + delimiter.matchedLength() + length = end - start + add self.setCurrentBlockState(0) # No; multi-line string else: @@ -180,7 +189,7 @@ def match_multiline(self, text, delimiter, in_state, style): # Apply formatting self.setFormat(start, length, style) # Look for the next match - start = delimiter.indexIn(text, start + length) + start = match.capturedStart(start + length) # Return True if still inside a multi-line string, False otherwise if self.currentBlockState() == in_state: diff --git a/src/sas/qtgui/Utilities/Reports/ReportDialog.py b/src/sas/qtgui/Utilities/Reports/ReportDialog.py index 18d7d24e9a..4c15d2fd0b 100644 --- a/src/sas/qtgui/Utilities/Reports/ReportDialog.py +++ b/src/sas/qtgui/Utilities/Reports/ReportDialog.py @@ -127,8 +127,9 @@ def write_string(string, filename): """ Write string to file """ - with open(filename, 'w') as f: - f.write(string) + with open(filename, 'wb') as f: + # weird unit symbols need to be saved as UTF-8 + f.write(bytes(string, 'utf-8')) @staticmethod def save_pdf(data, filename): diff --git a/src/sas/qtgui/Utilities/SasviewLogger.py b/src/sas/qtgui/Utilities/SasviewLogger.py index 68cdf4eead..23260be876 100644 --- a/src/sas/qtgui/Utilities/SasviewLogger.py +++ b/src/sas/qtgui/Utilities/SasviewLogger.py @@ -4,6 +4,9 @@ from PySide6.QtCore import QObject, Signal +LOG_FORMAT = "%(asctime)s - %(levelname)s: %(message)s" +DATE_FORMAT = "%H:%M:%S" + class QtPostman(QObject): messageWritten = Signal(str) @@ -23,16 +26,18 @@ def emit(self, record): self.postman.messageWritten.emit(message) def setup_qt_logging(): - # Define the default logger - logger = logging.getLogger() - # Add the qt-signal logger + logger = logging.root + + # If a QtHandler is already defined in log.ini then use it. This allows + # config to override the default message formatting. We don't do this + # by default because we may be using sasview as a library and don't + # want to load Qt. + for handler in logger.handlers: + if isinstance(handler, QtHandler): + return handler + handler = QtHandler() - handler.setFormatter(logging.Formatter( - fmt="%(asctime)s - %(levelname)s: %(message)s", - datefmt="%H:%M:%S" - )) + handler.setFormatter(logging.Formatter(fmt=LOG_FORMAT, datefmt=DATE_FORMAT)) logger.addHandler(handler) - return handler - \ No newline at end of file diff --git a/src/sas/qtgui/Utilities/UnitTesting/GuiUtilsTest.py b/src/sas/qtgui/Utilities/UnitTesting/GuiUtilsTest.py index 3cb45b1c3e..ca649cb915 100644 --- a/src/sas/qtgui/Utilities/UnitTesting/GuiUtilsTest.py +++ b/src/sas/qtgui/Utilities/UnitTesting/GuiUtilsTest.py @@ -507,36 +507,32 @@ def testReplaceHTMLwithASCII(self): s = "Å ∞ ±" assert replaceHTMLwithASCII(s) == "Ang inf +/-" - def testConvertUnitToUTF8(self): - ''' test unit string replacement''' + def testrstToHtml(self): + ''' test rst to html conversion''' s = None - assert convertUnitToUTF8(s) is None - - s = "" - assert convertUnitToUTF8(s) == s - - s = "aaaa" - assert convertUnitToUTF8(s) == s - - s = "1/A" - assert convertUnitToUTF8(s) == "Å-1" - - s = "Ang" - assert convertUnitToUTF8(s) == "Å" - - s = "1e-6/Ang^2" - assert convertUnitToUTF8(s) == "10-62" - - s = "inf" - assert convertUnitToUTF8(s) == "∞" + with pytest.raises(TypeError): + result = rstToHtml(s) + + s = ".. |Ang| unicode:: U+212B" + assert rstToHtml(s) == ('Ang', 'Å') + s = ".. |Ang^-1| replace:: |Ang|\ :sup:`-1`" + assert rstToHtml(s) == ('Ang^-1', 'Å-1') + s = ".. |1e-6Ang^-2| replace:: 10\ :sup:`-6`\ |Ang|\ :sup:`-2`" + assert rstToHtml(s) == ('1e-6Ang^-2', '10-6 Å-2') + s = ".. |cm^-1| replace:: cm\ :sup:`-1`" + assert rstToHtml(s) == ('cm^-1', 'cm-1') + s = ".. |deg| unicode:: U+00B0" + assert rstToHtml(s) == ('deg', '°') + s = ".. |cdot| unicode:: U+00B7" + assert rstToHtml(s) == ('cdot', '·') + s = "bad string" + assert rstToHtml(s) == (None, None) - s = "1/cm" - assert convertUnitToUTF8(s) == "cm-1" def testConvertUnitToHTML(self): ''' test unit string replacement''' s = None - assert convertUnitToHTML(s) is None + assert convertUnitToHTML(s) is "" s = "" assert convertUnitToHTML(s) == s @@ -545,23 +541,26 @@ def testConvertUnitToHTML(self): assert convertUnitToHTML(s) == s s = "1/A" - assert convertUnitToHTML(s) == "Å-1" + assert convertUnitToHTML(s) == "Å-1" s = "Ang" - assert convertUnitToHTML(s) == "Å" + assert convertUnitToHTML(s) == "Å" s = "1e-6/Ang^2" - assert convertUnitToHTML(s) == "10-6/Å2" + assert convertUnitToHTML(s) == "10-62" s = "inf" - assert convertUnitToHTML(s) == "∞" + assert convertUnitToHTML(s) == "∞" s = "-inf" - assert convertUnitToHTML(s) == "-∞" + assert convertUnitToHTML(s) == "-∞" s = "1/cm" assert convertUnitToHTML(s) == "cm-1" + s = "degrees" + assert convertUnitToHTML(s) == "°" + def testParseName(self): '''test parse out a string from the beinning of a string''' # good input diff --git a/src/sas/sascalc/calculator/geni.py b/src/sas/sascalc/calculator/geni.py index 07553ce394..4b148c395d 100644 --- a/src/sas/sascalc/calculator/geni.py +++ b/src/sas/sascalc/calculator/geni.py @@ -11,10 +11,6 @@ try: if os.environ.get('SAS_NUMBA', '1').lower() in ('1', 'yes', 'true', 't'): from numba import njit, prange - # Suppress numba debug info - import logging - numba_logger = logging.getLogger('numba') - numba_logger.setLevel(logging.WARNING) USE_NUMBA = True else: raise ImportError("fail") diff --git a/src/sas/system/config/config.py b/src/sas/system/config/config.py index 816110eb90..d3fd556955 100644 --- a/src/sas/system/config/config.py +++ b/src/sas/system/config/config.py @@ -197,6 +197,14 @@ def __init__(self): # sets the maximum number of characters per Fitting plot legend entry. self.FITTING_PLOT_LEGEND_MAX_LINE_LENGTH = 30 + # Residuals management + # If true, disables residual plot display + self.DISABLE_RESIDUAL_PLOT = False + + # Polydispersity plot management + # If true, disables polydispersity plot display + self.DISABLE_POLYDISPERSITY_PLOT = False + # Default fitting optimizer self.FITTING_DEFAULT_OPTIMIZER = 'lm' diff --git a/src/sas/system/config/config_meta.py b/src/sas/system/config/config_meta.py index 5c1f6cd079..c5fb60d444 100644 --- a/src/sas/system/config/config_meta.py +++ b/src/sas/system/config/config_meta.py @@ -1,3 +1,4 @@ +import re from typing import Dict, Any, List, Set import os import logging @@ -150,8 +151,11 @@ def load_from_file_object(self, file): raise MalformedFile("Malformed config file - no 'sasview_version' key") try: - parts = [int(s) for s in data["sasview_version"].split(".")] - if len(parts) != 3: + file_version = data["sasview_version"] + # Use the distutils strict version module regex to check if the version string is valid + # ref: https://epydoc.sourceforge.net/stdlib/distutils.version.StrictVersion-class.html + matcher = re.compile(r'(?x)^(\d+)\.(\d+)(\.(\d+))?([ab](\d+))?$') + if not matcher.match(file_version): raise Exception except Exception: @@ -165,7 +169,6 @@ def load_from_file_object(self, file): # Check major version - file_version = data["sasview_version"] file_major_version = file_version.split(".")[0] sasview_major_version = sas.system.version.__version__.split(".")[0] diff --git a/src/sas/system/legal.py b/src/sas/system/legal.py index 1c900b1dbd..5ea2522310 100644 --- a/src/sas/system/legal.py +++ b/src/sas/system/legal.py @@ -1,6 +1,6 @@ class Legal: def __init__(self): - self.copyright = "Copyright (c) 2009-2022 UTK, UMD, ESS, NIST, ORNL, ISIS, ILL, DLS, TUD, BAM and ANSTO" + self.copyright = "Copyright (c) 2009-2023 UTK, UMD, ESS, NIST, ORNL, ISIS, ILL, DLS, TUD, BAM and ANSTO" legal = Legal() \ No newline at end of file diff --git a/src/sas/system/log.ini b/src/sas/system/log.ini index 295eacd525..d9c6d4d473 100644 --- a/src/sas/system/log.ini +++ b/src/sas/system/log.ini @@ -28,58 +28,18 @@ keys=console,log_file [handler_console] class=logging.StreamHandler formatter=simple -level=WARNING -args=tuple() [handler_log_file] class=logging.FileHandler -level=WARNING formatter=detailed args=(os.path.join(os.path.expanduser("~"),'sasview.log'),"a") + ############################################################################### # Loggers [loggers] -keys=root,saspr,sasgui,sascalc,sasmodels,h5py,glshaders +keys=root [logger_root] -level=DEBUG -formatter=default -handlers=console,log_file - -[logger_sasmodels] -level=INFO -qualname=sas.models handlers=console,log_file -propagate=0 - -[logger_saspr] -level=INFO -qualname=sas.pr -handlers=console,log_file -propagate=0 - -[logger_sasgui] -level=DEBUG -qualname=sas.sasgui -handlers=console,log_file -propagate=0 - -[logger_sascalc] -level=INFO -qualname=sas.sascalc -handlers=console,log_file -propagate=0 - -[logger_h5py] -level=DEBUG -qualname=h5py -handlers= -propagate=0 - -[logger_glshaders] -level=DEBUG -qualname=OpenGL.GL.shaders -handlers= -propagate=0 \ No newline at end of file diff --git a/src/sas/system/log.py b/src/sas/system/log.py index e5e06dfd90..ae31cdf48a 100644 --- a/src/sas/system/log.py +++ b/src/sas/system/log.py @@ -12,70 +12,59 @@ Module that manages the global logging ''' +#BASE_LOGGER = 'sasview' +TRACED_PACKAGES = ('sas', 'sasmodels', 'sasdata', 'bumps', 'periodictable') +IGNORED_PACKAGES = { + 'matplotlib': 'ERROR', + 'numba': 'WARN', + 'h5py': 'ERROR', + 'ipykernel': 'CRITICAL', +} -class SetupLogger(object): - ''' - Called at the beginning of run.py or sasview.py - ''' +def setup_logging(level=logging.INFO): + # Setup the defaults + logging.captureWarnings(True) + for package in TRACED_PACKAGES: + logging.getLogger(package).setLevel(level) + for package, package_level in IGNORED_PACKAGES.items(): + logging.getLogger(package).setLevel(package_level) - def __init__(self, logger_name): - self._config_file = None - self._find_config_file() - self.name = logger_name + # SasView is often using the root logger to emit error messages. Until + # that is fixed we need to set the level of the root logger to the target + # level. Unfortunately that means that all broken third party packages + # (i.e., those that use the root logger rather than __name__) will also + # be set to that level, which is why we have to explicitly override them + # with the 'IGNORED_PACKAGES' list. The following regex will find most of + # the culprits: + # grep -R "logg\(ing\|er\)" src | grep -v .pyc | less + # TODO: use __name__ as the logger for all sasview log messages + logging.root.setLevel(level) - def config_production(self): - logger = logging.getLogger(self.name) - if not logger.root.handlers: - self._read_config_file() - logging.captureWarnings(True) - logger = logging.getLogger(self.name) - logging.getLogger('matplotlib').setLevel(logging.WARN) - logging.getLogger('numba').setLevel(logging.WARN) - return logger + # Apply the logging config after setting the defaults + try: + fd = importlib.resources.open_text('sas.system', 'log.ini') + logging.config.fileConfig(fd) + except FileNotFoundError: + print(f"ERROR: Log config '{filename}' not found...", file=sys.stderr) - def config_development(self): - ''' - ''' - self._read_config_file() - logger = logging.getLogger(self.name) - self._update_all_logs_to_debug(logger) - logging.captureWarnings(True) - logging.getLogger('matplotlib').setLevel(logging.WARN) - logging.getLogger('numba').setLevel(logging.WARN) - return logger - - def _read_config_file(self): - if self._config_file is not None: - logging.config.fileConfig(self._config_file) - - def _update_all_logs_to_debug(self, logger): - ''' - This updates all loggers and respective handlers to DEBUG - ''' - for handler in logger.handlers or logger.parent.handlers: - handler.setLevel(logging.DEBUG) - for name, _ in logging.Logger.manager.loggerDict.items(): - logging.getLogger(name).setLevel(logging.DEBUG) - - def _find_config_file(self, filename="log.ini"): - ''' - The config file is in: - Debug ./sasview/ - Packaging: sas/sasview/ - Packaging / production does not work well with absolute paths - thus importlib is used to find a filehandle to the resource - wherever it is actually located. - - Returns a TextIO instance that is open for reading the resource. - ''' - self._config_file = None - try: - self._config_file = importlib.resources.open_text('sas.system', filename) - except FileNotFoundError: - print(f"ERROR: '{filename}' not found...", file=sys.stderr) + #print_config() def production(): - return SetupLogger("sasview").config_production() + setup_logging('INFO') def development(): - return SetupLogger("sasview").config_development() + setup_logging('DEBUG') + +def print_config(msg="Logger config:"): + """ + When debugging the logging configuration it is handy to see exactly how + it is configured. To do so you will need to pip install the logging_tree + package and add *log.print_config()* at choice points in the code. + """ + try: + from logging_tree import printout + except ImportError: + print("log.print_config requires the logging_tree package from PyPI") + return + print(msg) + printout() diff --git a/src/sas/system/version.py b/src/sas/system/version.py index 1cd06c4312..b3aab77fe1 100644 --- a/src/sas/system/version.py +++ b/src/sas/system/version.py @@ -1,4 +1,4 @@ -__version__ = "5.0.5" -__release_date__ = "2022" +__version__ = "6.0.0a1" +__release_date__ = "2023" __build__ = "GIT_COMMIT"