diff --git a/src/sas/qtgui/Perspectives/Corfunc/CorfuncPerspective.py b/src/sas/qtgui/Perspectives/Corfunc/CorfuncPerspective.py index fd361b6258..c395b38ab6 100644 --- a/src/sas/qtgui/Perspectives/Corfunc/CorfuncPerspective.py +++ b/src/sas/qtgui/Perspectives/Corfunc/CorfuncPerspective.py @@ -10,20 +10,15 @@ from PySide6.QtGui import QDoubleValidator, QStandardItem import sas.qtgui.Utilities.GuiUtils as GuiUtils - -# sas-global -# pylint: disable=import-error, no-name-in-module -from sas.qtgui.Perspectives.Corfunc.CorfuncSlider import CorfuncSlider from sas.qtgui.Perspectives.Corfunc.ExtractionCanvas import ExtractionCanvas from sas.qtgui.Perspectives.Corfunc.IDFCanvas import IDFCanvas from sas.qtgui.Perspectives.Corfunc.QSpaceCanvas import QSpaceCanvas from sas.qtgui.Perspectives.Corfunc.RealSpaceCanvas import RealSpaceCanvas from sas.qtgui.Plotting.PlotterData import Data1D +from sas.qtgui.Utilities.ExtrapolationSlider import ExtrapolationSlider from sas.qtgui.Utilities.Reports import ReportBase from sas.qtgui.Utilities.Reports.reportdata import ReportData from sas.sascalc.corfunc.calculation_data import ( - ExtrapolationInteractionState, - ExtrapolationParameters, GuinierData, LongPeriodMethod, PorodData, @@ -31,6 +26,7 @@ TransformedData, ) from sas.sascalc.corfunc.corfunc_calculator import CalculationError, CorfuncCalculator +from sas.sascalc.util import ExtrapolationInteractionState, ExtrapolationParameters from ..perspective import Perspective from .SaveExtrapolatedPopup import SaveExtrapolatedPopup @@ -84,7 +80,7 @@ def __init__(self, parent=None): self._running = False # Add slider widget - self.slider = CorfuncSlider() + self.slider = ExtrapolationSlider(lower_label="Guinier", upper_label="Porod", perspective ="Corfunc") self.sliderLayout.insertWidget(1, self.slider) # Plots @@ -122,6 +118,9 @@ def __init__(self, parent=None): self.update_readonly() + # Allow Go button only when data is loaded + self.allow_go() + def set_background_warning(self): if (self._calculator is None or self._calculator.background is None or @@ -156,7 +155,6 @@ def setup_slots(self): """Connect the buttons to their appropriate slots.""" self.cmdExtract.clicked.connect(self._run) - self.disable_go_button("No data loaded") self.cmdSave.clicked.connect(self.on_save_transformed) self.cmdSave.setEnabled(False) @@ -168,9 +166,9 @@ def setup_slots(self): self.model.itemChanged.connect(self.model_changed) - self.txtLowerQMax.textEdited.connect(self.on_extrapolation_text_changed_1) - self.txtUpperQMin.textEdited.connect(self.on_extrapolation_text_changed_2) - self.txtUpperQMax.textEdited.connect(self.on_extrapolation_text_changed_3) + self.txtLowerQMax.textChanged.connect(self.on_extrapolation_text_changed_1) + self.txtUpperQMin.textChanged.connect(self.on_extrapolation_text_changed_2) + self.txtUpperQMax.textChanged.connect(self.on_extrapolation_text_changed_3) self.txtLowerQMax.editingFinished.connect(self.on_extrapolation_text_finished_1) self.txtUpperQMin.editingFinished.connect(self.on_extrapolation_text_finished_2) self.txtUpperQMax.editingFinished.connect(self.on_extrapolation_text_finished_3) @@ -306,14 +304,6 @@ def _run(self): self.update_readonly() - if self.go_disabled: - msg = "Go Button disabled." - msg += "\nReason: " + self.go_disabled_reason - dialog = QtWidgets.QMessageBox(self, text=msg) - dialog.setWindowTitle("Go Button disabled") - dialog.exec() - return - if self._running: return @@ -420,37 +410,42 @@ def _run(self): self.update_readonly() - def enable_go_button(self): + def allow_go(self, reason: str | None = None): + """ + Disable Go button if reason is provided or if no data is loaded + :param reason: Reason why Go button should be disabled + """ + if self.data is None: + self.cmdExtract.setEnabled(False) + self.cmdExtract.setText("Go (No data loaded)") + return + + if reason is not None: + self.cmdExtract.setEnabled(False) + self.cmdExtract.setText(f"Go ({reason})") + return + self.cmdExtract.setEnabled(True) self.cmdExtract.setText("Go") - self.go_disabled = False - self.go_disabled_reason = None - - def disable_go_button(self, reason: str): - self.cmdExtract.setText("Go (disabled)") - self.go_disabled = True - self.go_disabled_reason = reason def check_extrapolation_entry(self, fits_enabled: list[str]): """ Disable Go button if extrapolation ranges empty or invalid """ if "background" not in fits_enabled: if self.txtBackground.text() == "": - self.disable_go_button("Extrapolation values not set") + self.allow_go("Extrapolation values not set") return if "guinier" not in fits_enabled: if (self.txtGuinierA.text() == "" or self.txtGuinierB.text() == ""): - self.disable_go_button("Extrapolation values not set") + self.allow_go("Extrapolation values not set") return if "porod" not in fits_enabled: if (self.txtPorodK.text() == "" or self.txtPorodSigma.text() == ""): - self.disable_go_button("Extrapolation values not set") + self.allow_go("Extrapolation values not set") return - self.enable_go_button() - def update_readonly(self): """ Disable text fields if the corresponding fit is enabled. @@ -537,11 +532,13 @@ def allowSwap(self): def extrapolation_parameters(self) -> ExtrapolationParameters | None: if self.data is not None: return ExtrapolationParameters( - min(self.data.x), - safe_float(self.model.item(WIDGETS.W_QMIN).text()), - safe_float(self.model.item(WIDGETS.W_QMAX).text()), - safe_float(self.model.item(WIDGETS.W_QCUTOFF).text()), - max(self.data.x)) + ex_q_min=None, + data_q_min=min(self.data.x), + point_1=safe_float(self.model.item(WIDGETS.W_QMIN).text()), + point_2=safe_float(self.model.item(WIDGETS.W_QMAX).text()), + point_3=safe_float(self.model.item(WIDGETS.W_QCUTOFF).text()), + data_q_max=max(self.data.x), + ex_q_max=None) else: return None @@ -601,8 +598,6 @@ def setData(self, data_item: list[QStandardItem], is_batch=False): log_data_min = math.log(min(self.data.x)) log_data_max = math.log(max(self.data.x)) - self.enable_go_button() - def fractional_position(f): return math.exp(f*log_data_max + (1-f)*log_data_min) @@ -641,6 +636,7 @@ def fractional_position(f): self.set_text_enable(True) self.has_data = True + self.allow_go() self.tabWidget.setCurrentIndex(0) self.set_background_warning() @@ -670,47 +666,38 @@ def closeEvent(self, event): # Maybe we should just minimize self.setWindowState(QtCore.Qt.WindowMinimized) - def on_extrapolation_text_changed_1(self, text): + def on_extrapolation_text_changed_1(self): """ Text in LowerQMax changed""" - - # - # Note: We need to update based on params below, not a call to self.extrapolation_parameters, - # because that value wont be updated until after the QLineEdit.textEdited signals are - # processed - # - - params = self.extrapolation_parameters._replace(point_1=safe_float(text)) + if self.extrapolation_parameters is None: + return + value: str = self.txtLowerQMax.text() + params = self.extrapolation_parameters._replace(point_1=safe_float(value)) self.slider.extrapolation_parameters = params self._q_space_plot.update_lines(ExtrapolationInteractionState(params)) self.notify_extrapolation_text_box_validity(params) + self.model.setItem(WIDGETS.W_QMIN,QtGui.QStandardItem(value)) - def on_extrapolation_text_changed_2(self, text): + def on_extrapolation_text_changed_2(self): """ Text in UpperQMin changed""" - - # - # Note: We need to update based on params below, not a call to self.extrapolation_parameters, - # because that value wont be updated until after the QLineEdit.textEdited signals are - # processed - # - - params = self.extrapolation_parameters._replace(point_2=safe_float(text)) + if self.extrapolation_parameters is None: + return + value: str = self.txtUpperQMin.text() + params = self.extrapolation_parameters._replace(point_2=safe_float(value)) self.slider.extrapolation_parameters = params self._q_space_plot.update_lines(ExtrapolationInteractionState(params)) self.notify_extrapolation_text_box_validity(params) + self.model.setItem(WIDGETS.W_QMAX,QtGui.QStandardItem(value)) - def on_extrapolation_text_changed_3(self, text): + def on_extrapolation_text_changed_3(self): """ Text in UpperQMax changed""" - - # - # Note: We need to update based on params below, not a call to self.extrapolation_parameters, - # because that value wont be updated until after the QLineEdit.textEdited signals are - # processed - # - - params = self.extrapolation_parameters._replace(point_3=safe_float(text)) + if self.extrapolation_parameters is None: + return + value: str = self.txtUpperQMax.text() + params = self.extrapolation_parameters._replace(point_3=safe_float(value)) self.slider.extrapolation_parameters = params self._q_space_plot.update_lines(ExtrapolationInteractionState(params)) self.notify_extrapolation_text_box_validity(params) + self.model.setItem(WIDGETS.W_QCUTOFF,QtGui.QStandardItem(value)) def on_extrapolation_text_finished_1(self): """ Editing finished in LowerQMax - show dialog if out of range""" @@ -748,13 +735,35 @@ def notify_extrapolation_text_box_validity(self, params, show_dialog=False): self.txtUpperQMax.setStyleSheet(RED if invalid_3 else NORMAL) # Show dialog if requested and values are out of range - if show_dialog and (p1 < qmin or p3 > qmax): - msg = "The slider values are out of range.\n" - msg += f"The minimum value is {qmin:.8g} and the maximum value is {qmax:.8g}" - dialog = QtWidgets.QMessageBox(self, text=msg) - dialog.setWindowTitle("Value out of range") - dialog.setStandardButtons(QtWidgets.QMessageBox.Ok) - dialog.exec_() + if show_dialog: + messages = [] + + if p1 <= qmin: + messages.append(f"The minimum value is {qmin:.8g}.") + self.txtLowerQMax.setText(f"{qmin + 1e-6:.7g}") + self.on_extrapolation_text_changed_1() + + if invalid_2: + messages.append( + f"Porod start must be after Guinier end: {p1:.8g} and before Porod end: {p3:.8g}." + ) + self.allow_go("Porod start out of range") + + if p3 >= qmax: + messages.append(f"The maximum value is {qmax:.8g}.") + self.txtUpperQMax.setText(f"{qmax - 1e-6:.7g}") + self.on_extrapolation_text_changed_3() + + if messages: + msg = "The slider values are out of range.\n" + "\n".join(messages) + dialog = QtWidgets.QMessageBox(self, text=msg) + dialog.setWindowTitle("Slider values out of range") + dialog.setStandardButtons(QtWidgets.QMessageBox.Ok) + dialog.exec_() + + if not (invalid_1 or invalid_2 or invalid_3): + self.allow_go() + def on_extrapolation_slider_changed(self, state: ExtrapolationParameters): """ Slider state changed""" diff --git a/src/sas/qtgui/Perspectives/Corfunc/QSpaceCanvas.py b/src/sas/qtgui/Perspectives/Corfunc/QSpaceCanvas.py index 68c0494f67..efa998c471 100644 --- a/src/sas/qtgui/Perspectives/Corfunc/QSpaceCanvas.py +++ b/src/sas/qtgui/Perspectives/Corfunc/QSpaceCanvas.py @@ -10,7 +10,7 @@ from sas.qtgui.Perspectives.Corfunc.CorfuncCanvas import CorfuncCanvas from sas.qtgui.Plotting.PlotterData import Data1D -from sas.sascalc.corfunc.calculation_data import ExtrapolationInteractionState +from sas.sascalc.util import ExtrapolationInteractionState class QSpaceCanvas(CorfuncCanvas): diff --git a/src/sas/qtgui/Perspectives/Invariant/InvariantPerspective.py b/src/sas/qtgui/Perspectives/Invariant/InvariantPerspective.py index c5df43a454..409faf0658 100644 --- a/src/sas/qtgui/Perspectives/Invariant/InvariantPerspective.py +++ b/src/sas/qtgui/Perspectives/Invariant/InvariantPerspective.py @@ -7,13 +7,13 @@ from twisted.internet import reactor, threads import sas.qtgui.Utilities.GuiUtils as GuiUtils -from sas.qtgui.Perspectives.Corfunc.CorfuncSlider import CorfuncSlider from sas.qtgui.Plotting import PlotterData from sas.qtgui.Plotting.PlotterData import Data1D, DataRole -from sas.sascalc.corfunc.calculation_data import ExtrapolationParameters +from sas.qtgui.Utilities.ExtrapolationSlider import ExtrapolationSlider # sas-global from sas.sascalc.invariant import invariant +from sas.sascalc.util import ExtrapolationParameters # local from ..perspective import Perspective @@ -99,10 +99,7 @@ def __init__(self, parent=None): self.no_extrapolation_plot: PlotterData | None = None # Slider - self.slider: CorfuncSlider = CorfuncSlider( - lower_label="Low-Q", - upper_label="High-Q", - ) + self.slider = ExtrapolationSlider(lower_label="Low-Q", upper_label="High-Q", perspective="Invariant") self.sliderLayout.insertWidget(1, self.slider) # no reason to have this widget resizable @@ -168,11 +165,13 @@ def __init__(self, parent=None): def extrapolation_parameters(self) -> ExtrapolationParameters | None: if self._data is not None: return ExtrapolationParameters( - safe_float(Q_MINIMUM), - safe_float(self.model.item(WIDGETS.W_GUINIER_END_EX).text()), - safe_float(self.model.item(WIDGETS.W_POROD_START_EX).text()), - safe_float(self.model.item(WIDGETS.W_POROD_END_EX).text()), - safe_float(Q_MAXIMUM), + ex_q_min=float(Q_MINIMUM), + data_q_min=safe_float(self.model.item(WIDGETS.W_QMIN).text()), + point_1=safe_float(self.model.item(WIDGETS.W_GUINIER_END_EX).text()), + point_2=safe_float(self.model.item(WIDGETS.W_POROD_START_EX).text()), + point_3=safe_float(self.model.item(WIDGETS.W_POROD_END_EX).text()), + data_q_max=safe_float(self.model.item(WIDGETS.W_QMAX).text()), + ex_q_max=float(Q_MAXIMUM), ) else: return None @@ -295,7 +294,7 @@ def deferredPlot(self, model: QtGui.QStandardItemModel) -> None: reactor.callFromThread(lambda: self.plot_result(model)) self.allow_calculation() - def allow_calculation(self) -> None: + def allow_calculation(self, state: bool = True) -> None: """Enable the calculate button if either volume fraction or contrast is selected""" if self._data is None: self.cmdCalculate.setEnabled(False) @@ -312,6 +311,9 @@ def allow_calculation(self) -> None: self.cmdCalculate.setEnabled(False) self.cmdCalculate.setText("Calculate (Enter volume fraction or contrast)") + if not state: + self.cmdCalculate.setEnabled(state) + def plot_result(self, model: QtGui.QStandardItemModel) -> None: """Plot result of calculation""" self.model = model @@ -348,7 +350,7 @@ def plot_result(self, model: QtGui.QStandardItemModel) -> None: self.low_extrapolation_plot.show_q_range_sliders = True self.low_extrapolation_plot.slider_update_on_move = False self.low_extrapolation_plot.slider_perspective_name = self.name - self.low_extrapolation_plot.slider_low_q_input = self.extrapolation_parameters.data_q_min + self.low_extrapolation_plot.slider_low_q_input = self.extrapolation_parameters.ex_q_min self.low_extrapolation_plot.slider_high_q_input = self.txtGuinierEnd_ex.text() self.low_extrapolation_plot.slider_high_q_setter = ["set_low_q_extrapolation_upper_limit"] self.low_extrapolation_plot.slider_high_q_getter = ["get_low_q_extrapolation_upper_limit"] @@ -582,7 +584,7 @@ def calculate_thread(self, extrapolation: str) -> None: return self.model if low_calculation_pass: - qmin_ext: float = float(self.extrapolation_parameters.data_q_min) + qmin_ext: float = float(self.extrapolation_parameters.ex_q_min) extrapolated_data = self._calculator.get_extra_data_low(self._low_points, q_start=qmin_ext) power_low: float | None = self._calculator.get_extrapolation_power(range="low") @@ -702,9 +704,12 @@ def setupSlots(self): # Extrapolation parameters # Q range fields - self.txtGuinierEnd_ex.editingFinished.connect(self.on_extrapolation_text_changed_1) - self.txtPorodStart_ex.editingFinished.connect(self.on_extrapolation_text_changed_2) - self.txtPorodEnd_ex.editingFinished.connect(self.on_extrapolation_text_changed_3) + self.txtGuinierEnd_ex.textChanged.connect(self.on_extrapolation_text_changed_1) + self.txtPorodStart_ex.textChanged.connect(self.on_extrapolation_text_changed_2) + self.txtPorodEnd_ex.textChanged.connect(self.on_extrapolation_text_changed_3) + self.txtGuinierEnd_ex.editingFinished.connect(self.on_extrapolation_text_finished) + self.txtPorodStart_ex.editingFinished.connect(self.on_extrapolation_text_finished) + self.txtPorodEnd_ex.editingFinished.connect(self.on_extrapolation_text_finished) self.txtGuinierEnd_ex.setValidator(GuiUtils.DoubleValidator()) self.txtPorodStart_ex.setValidator(GuiUtils.DoubleValidator()) self.txtPorodEnd_ex.setValidator(GuiUtils.DoubleValidator()) @@ -848,6 +853,7 @@ def lowFitAndFixToggle_ex(self) -> None: self.update_from_model() def on_extrapolation_slider_changed(self, state: ExtrapolationParameters) -> None: + """Handle when user changes any of the extrapolation slider values""" format_string: str = "%.7g" self.model.setItem(WIDGETS.W_GUINIER_END_EX, QtGui.QStandardItem(format_string % state.point_1)) self.model.setItem(WIDGETS.W_POROD_START_EX, QtGui.QStandardItem(format_string % state.point_2)) @@ -855,36 +861,63 @@ def on_extrapolation_slider_changed(self, state: ExtrapolationParameters) -> Non self.notify_extrapolation_text_box_validity(state, show_dialog=True) def on_extrapolation_text_changed_1(self) -> None: + """Handle when user changes the Guinier end text box""" + if self.extrapolation_parameters is None: + return value: str = self.txtGuinierEnd_ex.text() params = self.extrapolation_parameters._replace(point_1=safe_float(value)) self.slider.extrapolation_parameters = params - self.notify_extrapolation_text_box_validity(params, show_dialog=True) + self.notify_extrapolation_text_box_validity(params, show_dialog=False) + self.model.setItem(WIDGETS.W_GUINIER_END_EX, QtGui.QStandardItem(value)) def on_extrapolation_text_changed_2(self) -> None: + """Handle when user changes the Porod start text box""" + if self.extrapolation_parameters is None: + return value: str = self.txtPorodStart_ex.text() params = self.extrapolation_parameters._replace(point_2=safe_float(value)) self.slider.extrapolation_parameters = params - self.notify_extrapolation_text_box_validity(params, show_dialog=True) + self.notify_extrapolation_text_box_validity(params, show_dialog=False) + self.model.setItem(WIDGETS.W_POROD_START_EX, QtGui.QStandardItem(value)) def on_extrapolation_text_changed_3(self) -> None: + """Handle when user changes the Porod end text box""" + if self.extrapolation_parameters is None: + return value: str = self.txtPorodEnd_ex.text() params = self.extrapolation_parameters._replace(point_3=safe_float(value)) self.slider.extrapolation_parameters = params + self.notify_extrapolation_text_box_validity(params, show_dialog=False) + self.model.setItem(WIDGETS.W_POROD_END_EX, QtGui.QStandardItem(value)) + + def on_extrapolation_text_finished(self) -> None: + """Handle when user finishes editing any of the extrapolation text boxes""" + params = self.extrapolation_parameters self.notify_extrapolation_text_box_validity(params, show_dialog=True) def notify_extrapolation_text_box_validity( self, params: ExtrapolationParameters, show_dialog: bool = False ) -> None: + """ + Check validity of extrapolation text boxes and notify user if invalid + 1. point_1 < point_2 < point_3 + 2. point_1 > data_q_min + 3. point_3 < Q_MAXIMUM + 4. point_2 < data_q_max + 5. Highlight invalid text boxes in red + 6. Optionally show dialog if invalid + 7. Disable calculation if invalid + """ # Round values to 8 significant figures to avoid floating point precision issues - p1: float = float(f"{params.point_1:.7g}") # Guinier end - p2: float = float(f"{params.point_2:.7g}") # Porod start - p3: float = float(f"{params.point_3:.7g}") # Porod end + p1: float = float(f"{params.point_1:.7g}") # low q end + p2: float = float(f"{params.point_2:.7g}") # high q start + p3: float = float(f"{params.point_3:.7g}") # high q end data_q_min: float = float(f"{self._data.x.min():.7g}") # Actual data min data_q_max: float = float(f"{self._data.x.max():.7g}") # Actual data max qmax: float = Q_MAXIMUM # Determine validity flags such that data_q_min < point_1 < point_2 < point_3 < qmax - # Also p2 < data_q_max so that Porod start is within data range + # Also p2 < data_q_max so that high q start is within data range invalid_1: bool = p1 <= data_q_min or p1 >= p2 invalid_2: bool = p2 <= p1 or p2 >= p3 or p2 >= data_q_max invalid_3: bool = p3 <= p2 or p3 > qmax @@ -894,31 +927,43 @@ def notify_extrapolation_text_box_validity( self.txtPorodStart_ex.setStyleSheet(BG_RED if invalid_2 else BG_DEFAULT) self.txtPorodEnd_ex.setStyleSheet(BG_RED if invalid_3 else BG_DEFAULT) - # If requested, show dialog if values are out of range - if show_dialog and (p1 <= data_q_min or p3 > qmax): - msg = "The slider values are out of range.\n" - msg += f"The minimum value is {data_q_min:.7g} and the maximum value is {qmax:.7g}" - dialog = QtWidgets.QMessageBox(self, text=msg) - dialog.setWindowTitle("Value out of range") - dialog.setStandardButtons(QtWidgets.QMessageBox.Ok) - dialog.exec_() - if p1 < data_q_min: + # Shows warning dialog if any of the values are invalid + # Also reset out-of-range values to nearest valid value + if show_dialog: + messages = [] + if p1 <= data_q_min: + messages.append(f"The minimum value is {data_q_min:.7g}.") + print("changing p1") self.txtGuinierEnd_ex.setText(f"{data_q_min + 1e-7:.7g}") self.on_extrapolation_text_changed_1() + if p3 > qmax: + messages.append(f"The maximum value is {qmax:.7g}.") + print("changing p3") self.txtPorodEnd_ex.setText(f"{qmax:.7g}") self.on_extrapolation_text_changed_3() - # Show dialog if p2 is greater than data max - if show_dialog and (p2 > data_q_max): - msg = "The High-Q start value cannot be greater than the maximum Q value of the data.\n" - msg += f"The maximum Q value of the data is {data_q_max:.7g}" - dialog = QtWidgets.QMessageBox(self, text=msg) - dialog.setWindowTitle("Invalid High-Q Start Value") - dialog.setStandardButtons(QtWidgets.QMessageBox.Ok) - dialog.exec_() - self.txtPorodStart_ex.setText(str(data_q_max - 1e-7)) - self.on_extrapolation_text_changed_2() + if p2 >= data_q_max: + messages.append(f"The maximum Q value of the data is {data_q_max:.7g}.") + print("changing p2") + self.txtPorodStart_ex.setText(str(data_q_max - 1e-7)) + self.on_extrapolation_text_changed_2() + + if invalid_1 or invalid_2 or invalid_3: + messages.append("Please correct the invalid values highlighted in red.") + # this does not reset the text boxes, only notifies the user + + if messages: + dialog = QtWidgets.QMessageBox(self) + dialog.setWindowTitle("Invalid Extrapolation Values") + dialog.setIcon(QtWidgets.QMessageBox.Warning) + dialog.setText("\n".join(messages)) + dialog.setStandardButtons(QtWidgets.QMessageBox.Ok) + dialog.exec_() + + # Disable calculation if any of the values are invalid + self.allow_calculation(not (invalid_1 or invalid_2 or invalid_3)) + def stateChanged(self) -> None: """Catch modifications from low- and high-Q extrapolation check boxes""" diff --git a/src/sas/qtgui/Perspectives/Corfunc/CorfuncSlider.py b/src/sas/qtgui/Utilities/ExtrapolationSlider.py similarity index 64% rename from src/sas/qtgui/Perspectives/Corfunc/CorfuncSlider.py rename to src/sas/qtgui/Utilities/ExtrapolationSlider.py index 92095c3ad4..fb6764c53e 100644 --- a/src/sas/qtgui/Perspectives/Corfunc/CorfuncSlider.py +++ b/src/sas/qtgui/Utilities/ExtrapolationSlider.py @@ -1,14 +1,19 @@ import math +from enum import Enum import numpy as np from PySide6 import QtCore, QtGui, QtWidgets from PySide6.QtCore import Qt, Signal from PySide6.QtGui import QFontMetrics -from sas.sascalc.corfunc.calculation_data import ExtrapolationInteractionState, ExtrapolationParameters +from sas.sascalc.util import ExtrapolationInteractionState, ExtrapolationParameters -class CorfuncSlider(QtWidgets.QWidget): +class SliderPerspective(Enum): + INVARIANT = "Invariant" + CORFUNC = "Corfunc" + +class ExtrapolationSlider(QtWidgets.QWidget): """ Slider that allows the selection of the different Q-ranges involved in interpolation, and that provides some visual cues to how it works.""" @@ -16,10 +21,11 @@ class CorfuncSlider(QtWidgets.QWidget): valueEditing = Signal(ExtrapolationInteractionState, name='valueEditing') def __init__(self, - parameters: ExtrapolationParameters = ExtrapolationParameters(1,2,4,8,16), + lower_label: str, + upper_label: str, + perspective: SliderPerspective, + parameters: ExtrapolationParameters = ExtrapolationParameters(1,2,4,8,16,32,64), enabled: bool = False, - lower_label: str = "Guinier", - upper_label: str = "Porod", *args, **kwargs): super().__init__(*args, **kwargs) @@ -30,23 +36,33 @@ def __init__(self, if parameter_problems is not None: raise ValueError(parameter_problems) - self._min = parameters.data_q_min + self._min = parameters.ex_q_min # extrapolation min + self._data_min = parameters.data_q_min # actual data min self._point_1 = parameters.point_1 self._point_2 = parameters.point_2 self._point_3 = parameters.point_3 - self._max = parameters.data_q_max + self._data_max = parameters.data_q_max # actual data max + self._max = parameters.ex_q_max # extrapolation max + + # Default to data min/max if extrapolation min/max not set + if self._min is None: + self._min = self._data_min + if self._max is None: + self._max = self._data_max + self._lower_label = lower_label self._upper_label = upper_label + self.perspective = perspective # Display Parameters self.vertical_size = 20 self.left_pad = 60 self.right_pad = 60 self.line_width = 3 - self.guinier_color = QtGui.QColor('orange') + self.lower_color = QtGui.QColor('orange') self.data_color = QtGui.QColor('white') - self.porod_color = QtGui.QColor('green') + self.upper_color = QtGui.QColor('green') self.text_color = QtGui.QColor('black') self.line_drag_color = mix_colours(QtGui.QColor('white'), QtGui.QColor('black'), 0.4) self.hover_colour = QtGui.QColor('white') @@ -58,9 +74,9 @@ def __init__(self, # - define hover colours by mixing with a grey mix_color = QtGui.QColor('grey') mix_fraction = 0.7 - self.guinier_hover_color = mix_colours(self.guinier_color, mix_color, mix_fraction) + self.lower_hover_color = mix_colours(self.lower_color, mix_color, mix_fraction) self.data_hover_color = mix_colours(self.data_color, mix_color, mix_fraction) - self.porod_hover_color = mix_colours(self.porod_color, mix_color, mix_fraction) + self.upper_hover_color = mix_colours(self.upper_color, mix_color, mix_fraction) # Mouse control self._hovering = False @@ -79,22 +95,35 @@ def __init__(self, @staticmethod def find_parameter_problems(params: ExtrapolationParameters) -> str | None: - """ Check an extratpolation prarameters object for consistency + """ Check an extrapolation parameters object for consistency :param params: An extrapolation parameters object describing a desired state :returns: A description of the problem if it exists, otherwise None """ - if params.data_q_min >= params.point_1: - return "min_q should be smaller than q_point_1" - - if params.point_1 >= params.point_2: - return "q_point_1 should be smaller than q_point_2" - - if params.point_2 >= params.point_3: - return "q_point_2 should be smaller than q_point_3" - - if params.point_3 >= params.data_q_max: - return "q_point_3 should be smaller than max_q" + ex_min = params.ex_q_min + data_min = params.data_q_min + p1, p2, p3 = params.point_1, params.point_2, params.point_3 + data_max = params.data_q_max + ex_max = params.ex_q_max + + checks = [ + (p1 < data_min, "q_point_1 should be larger than min_q"), + (ex_min is not None and p1 <= ex_min, "q_point_1 should be larger than ex_q_min"), + (p1 >= p2, "q_point_1 should be smaller than q_point_2"), + (p2 >= p3, "q_point_2 should be smaller than q_point_3"), + ] + for cond, msg in checks: + if cond: + return msg + + if ex_max is not None: # extrapolating + if p2 >= data_max: + return "q_point_2 should be smaller than max_q" + if p3 >= ex_max: + return "q_point_3 should be smaller than ex_q_max" + else: + if p3 >= data_max: + return "q_point_3 should be smaller than max_q" return None @@ -162,10 +191,12 @@ def _mouse_inside(self, x, y): def _sanitise_new_position(self, line_id: int, new_position: int, delta=1) -> int: """ Returns a revised position for a line position that prevents the bounds being exceeded """ l1, l2, l3 = (int(x) for x in self.line_paint_positions) + data_min_px = int(self.transform(self._data_min)) + data_max_px = int(self.transform(self._data_max)) if line_id == 0: - if self.left_pad > new_position: - return self.left_pad + if self.left_pad > new_position or new_position <= data_min_px: + return data_min_px + delta elif new_position > l2: return l2 - delta else: @@ -176,14 +207,16 @@ def _sanitise_new_position(self, line_id: int, new_position: int, delta=1) -> in return l1 + delta elif new_position > l3: return l3 - delta + elif new_position >= data_max_px: + return data_max_px - delta else: return new_position elif line_id == 2: if l2 > new_position: return l2 + delta - elif new_position > self.width() - self.right_pad: - return self.width() - self.right_pad + elif new_position >= self.width() - self.right_pad: + return self.width() - self.right_pad - delta else: return new_position @@ -218,17 +251,18 @@ def set_boundary(self, index: int, q: float): @property def extrapolation_parameters(self): - return ExtrapolationParameters(self._min, self._point_1, self._point_2, self._point_3, self._max) + return ExtrapolationParameters(self._min, self._data_min, self._point_1, self._point_2, self._point_3, self._data_max, self._max) @extrapolation_parameters.setter def extrapolation_parameters(self, params: ExtrapolationParameters): if params is not None: - self._min = params.data_q_min + self._min = params.ex_q_min if params.ex_q_min is not None else params.data_q_min + self._data_min = params.data_q_min self._point_1 = params.point_1 self._point_2 = params.point_2 self._point_3 = params.point_3 - self._max = params.data_q_max - + self._data_max = params.data_q_max + self._max = params.ex_q_max if params.ex_q_max is not None else params.data_q_max self.update() @property @@ -276,8 +310,8 @@ def inverse_transform(self, px_value: float) -> float: return self._min*math.exp((px_value - self.left_pad)/self.scale) @property - def guinier_label_position(self) -> float: - """ Position to put the text for the guinier region""" + def lower_label_position(self) -> float: + """ Position to put the text for the lower region""" return 0.5 * self.transform(self._point_1) @property @@ -287,12 +321,18 @@ def data_label_centre(self) -> float: @property def transition_label_centre(self) -> float: - """ Centre of the data-porod transition""" + """ Centre of the data-upper transition""" return 0.5 * (self.transform(self._point_2) + self.transform(self._point_3)) @property - def porod_label_centre(self) -> float: - """ Centre of the Porod region""" + def upper_label_centre(self) -> float: + """ + Centre of the upper region + - Between point 2 and point 3 for invariant + - Between point 3 and widget edge for corfunc + """ + if self.perspective == "Invariant": + return 0.5 * (self.transform(self._point_2) + self.transform(self._point_3)) return 0.5 * (self.transform(self._point_3) + self.width()) @@ -312,83 +352,95 @@ def paintEvent(self, e): painter.fillRect(rect, brush) positions = [self.transform(self._min), + self.transform(self._data_min), self.transform(self._point_1), self.transform(self._point_2), self.transform(self._point_3), + self.transform(self._data_max), self.transform(self._max)] - - positions = [int(x) for x in positions] - widths = [positions[i+1] - positions[i] for i in range(4)] + + # compute widths for all adjacent segments + segment_widths = [positions[i+1] - positions[i] for i in range(len(positions)-1)] # - # Draw the sections + # Draw the sections covering the entire widget # brush.setStyle(Qt.SolidPattern) if self.isEnabled(): if self._hovering or self._dragging: - guinier_color = self.guinier_hover_color + lower_color = self.lower_hover_color data_color = self.data_hover_color - porod_color = self.porod_hover_color + upper_color = self.upper_hover_color else: - guinier_color = self.guinier_color + lower_color = self.lower_color data_color = self.data_color - porod_color = self.porod_color + upper_color = self.upper_color else: - guinier_color = self.disabled_non_data_color + lower_color = self.disabled_non_data_color data_color = self.data_color - porod_color = self.disabled_non_data_color - + upper_color = self.disabled_non_data_color - brush.setColor(guinier_color) + brush.setColor(lower_color) rect = QtCore.QRect(0, 0, self.left_pad, self.vertical_size) painter.fillRect(rect, brush) - grad = QtGui.QLinearGradient(positions[0], 0, positions[1], 0) - grad.setColorAt(0.0, guinier_color) - grad.setColorAt(1.0, data_color) - rect = QtCore.QRect(positions[0], 0, widths[0], self.vertical_size) - painter.fillRect(rect, grad) - - brush.setColor(data_color) - rect = QtCore.QRect(positions[1], 0, widths[1], self.vertical_size) - painter.fillRect(rect, brush) - - grad = QtGui.QLinearGradient(positions[2], 0, positions[3], 0) - grad.setColorAt(0.0, data_color) - grad.setColorAt(1.0, porod_color) - rect = QtCore.QRect(positions[2], 0, widths[2], self.vertical_size) - painter.fillRect(rect, grad) - - brush.setColor(porod_color) - rect = QtCore.QRect(positions[3], 0, widths[3] + self.right_pad, self.vertical_size) + # segment 0: lower; (gradient lower -> data) -> white; min/data_min -> point_1 (positions[2]) + lower_width = segment_widths[0] + segment_widths[1] + if lower_width > 0: + grad = QtGui.QLinearGradient(positions[0], 0, positions[2], 0) + grad.setColorAt(0.0, lower_color) + grad.setColorAt(1.0, data_color) + rect = QtCore.QRect(positions[0], 0, lower_width, self.vertical_size) + painter.fillRect(rect, grad) + + # segment 1: data; solid data color; point 1 -> point 2 (positions[3]) + if segment_widths[2] > 0: + brush.setColor(data_color) + rect = QtCore.QRect(positions[2], 0, segment_widths[2], self.vertical_size) + painter.fillRect(rect, brush) + + # segment 2: upper; gradient data->upper; point 2 -> point_3 (positions[4]) + if segment_widths[3] > 0: + grad = QtGui.QLinearGradient(positions[3], 0, positions[4], 0) + grad.setColorAt(0.0, data_color) + grad.setColorAt(1.0, upper_color) + rect = QtCore.QRect(positions[3], 0, segment_widths[3], self.vertical_size) + painter.fillRect(rect, grad) + + # remaining area from point_2 to right boundary: paint with upper_color + right_boundary = self.width() - self.right_pad + rest_start = positions[4] + rest_width = max(0, right_boundary - rest_start) + if rest_width > 0: + brush.setColor(upper_color) + rect = QtCore.QRect(rest_start, 0, rest_width, self.vertical_size) + painter.fillRect(rect, brush) + + # right pad (ensure full coverage to widget edge) + brush.setColor(upper_color) + rect = QtCore.QRect(self.width() - self.right_pad, 0, self.right_pad, self.vertical_size) painter.fillRect(rect, brush) # # Dividing lines # - # Data range lines - if self.isEnabled(): - pen = QtGui.QPen(mix_colours(self.hover_colour, guinier_color, 0.5), self.line_width) - else: - pen = QtGui.QPen(self.disabled_line_color, self.line_width) - + pen = QtGui.QPen(self.disabled_non_data_color, self.line_width) painter.setPen(pen) - painter.drawLine(self.left_pad, 0, self.left_pad, self.vertical_size) - - if self.isEnabled(): - pen = QtGui.QPen(mix_colours(self.hover_colour, porod_color, 0.5), self.line_width) - else: - pen = QtGui.QPen(self.disabled_line_color, self.line_width) + # extrapolation boundaries - min / max - render as grey (non-moveable) + if self._min is not None and self._max is not None: + painter.drawLine(positions[0], 0, positions[0], self.vertical_size) + painter.drawLine(positions[6], 0, positions[6], self.vertical_size) - painter.setPen(pen) - painter.drawLine(self.width() - self.right_pad, 0, self.width()-self.right_pad, self.vertical_size) + # data_min / data_max - render as grey (non-moveable) + painter.drawLine(positions[1], 0, positions[1], self.vertical_size) # data_min + painter.drawLine(positions[5], 0, positions[5], self.vertical_size) # data_max - # Main lines - for i, x in enumerate(positions[1:-1]): + # Main lines (point_1, point_2, point_3) + for i, x in enumerate(positions[2:5]): if self.isEnabled(): # different color if it's the one that will be moved if self._hovering and i == self._hover_id: @@ -407,17 +459,12 @@ def paintEvent(self, e): painter.setPen(pen) painter.drawLine(self._movement_line_position, 0, self._movement_line_position, self.vertical_size) - - # # Labels # - - - self._paint_label(self.guinier_label_position, self._lower_label) + self._paint_label(self.lower_label_position, self._lower_label) self._paint_label(self.data_label_centre, "Data") - # self._paint_label(self.transition_label_centre, "Transition") # Looks better without this - self._paint_label(self.porod_label_centre, self._upper_label) + self._paint_label(self.upper_label_centre, self._upper_label) def _paint_label(self, position: float, text: str, centre_justify=True): @@ -463,7 +510,7 @@ def mix_colours(a: QtGui.QColor, b: QtGui.QColor, k: float) -> QtGui.QColor: def main(): """ Show a demo of the slider """ app = QtWidgets.QApplication([]) - slider = CorfuncSlider(enabled=True) + slider = ExtrapolationSlider(lower_label="Low-Q", upper_label="High-Q", enabled=True) slider.show() app.exec_() diff --git a/src/sas/sascalc/corfunc/calculation_data.py b/src/sas/sascalc/corfunc/calculation_data.py index 40a85e6597..a6aed1f1fe 100644 --- a/src/sas/sascalc/corfunc/calculation_data.py +++ b/src/sas/sascalc/corfunc/calculation_data.py @@ -1,6 +1,6 @@ from dataclasses import dataclass from enum import Enum -from typing import Generic, NamedTuple, TypeVar +from typing import Generic, TypeVar from sasdata.dataloader.data_info import Data1D @@ -85,31 +85,3 @@ class LongPeriodMethod(EntryListEnum): """ Methods for estimating the long period """ MAX = 'max' DOUBLE_MIN = 'double-min' - - -@dataclass -class SettableExtrapolationParameters: - """ Extrapolation parameters that can be set by the user""" - point_1: float - point_2: float - point_3: float - - - -class ExtrapolationParameters(NamedTuple): - """ Represents the parameters defining extrapolation""" - data_q_min: float - point_1: float - point_2: float - point_3: float - data_q_max: float - -@dataclass -class ExtrapolationInteractionState: - """ Represents the state of the slider used to control extrapolation parameters - - Contains extrapolation parameters along with the representation of the hover state. - """ - extrapolation_parameters: ExtrapolationParameters - working_line_id: int | None = None - dragging_line_position: float | None = None diff --git a/src/sas/sascalc/corfunc/corfunc_calculator.py b/src/sas/sascalc/corfunc/corfunc_calculator.py index 56f09e9981..b8ba810c7e 100644 --- a/src/sas/sascalc/corfunc/corfunc_calculator.py +++ b/src/sas/sascalc/corfunc/corfunc_calculator.py @@ -15,18 +15,17 @@ from sasdata.dataloader.data_info import Data1D from sas.sascalc.corfunc.calculation_data import ( - ExtrapolationParameters, Fittable, GuinierData, LamellarParameters, LongPeriodMethod, PorodData, - SettableExtrapolationParameters, SupplementaryParameters, TangentMethod, TransformedData, ) from sas.sascalc.corfunc.smoothing import SmoothJoin +from sas.sascalc.util import ExtrapolationParameters, SettableExtrapolationParameters class CalculationError(Exception): diff --git a/src/sas/sascalc/util.py b/src/sas/sascalc/util.py index 5923735f58..0fd836a398 100644 --- a/src/sas/sascalc/util.py +++ b/src/sas/sascalc/util.py @@ -1,4 +1,5 @@ -from typing import Any +from dataclasses import dataclass +from typing import Any, NamedTuple def unique_preserve_order(seq: list[Any]) -> list[Any]: @@ -8,3 +9,33 @@ def unique_preserve_order(seq: list[Any]) -> list[Any]: seen = set() seen_add = seen.add return [x for x in seq if not (x in seen or seen_add(x))] + + +@dataclass +class SettableExtrapolationParameters: + """ Extrapolation parameters that can be set by the user""" + point_1: float + point_2: float + point_3: float + + +class ExtrapolationParameters(NamedTuple): + """ Represents the parameters defining extrapolation""" + ex_q_min: float | None + data_q_min: float + point_1: float + point_2: float + point_3: float + data_q_max: float + ex_q_max: float | None + + +@dataclass +class ExtrapolationInteractionState: + """ Represents the state of the slider used to control extrapolation parameters + + Contains extrapolation parameters along with the representation of the hover state. + """ + extrapolation_parameters: ExtrapolationParameters + working_line_id: int | None = None + dragging_line_position: float | None = None diff --git a/test/corfunc/utest_corfunc.py b/test/corfunc/utest_corfunc.py index 3b932e14b7..94bfe508cb 100644 --- a/test/corfunc/utest_corfunc.py +++ b/test/corfunc/utest_corfunc.py @@ -10,8 +10,8 @@ from sasdata.dataloader.data_info import Data1D -from sas.sascalc.corfunc.calculation_data import SettableExtrapolationParameters from sas.sascalc.corfunc.corfunc_calculator import CorfuncCalculator, extract_lamellar_parameters +from sas.sascalc.util import SettableExtrapolationParameters def find(filename):