diff --git a/src/sas/qtgui/Perspectives/Invariant/InvariantPerspective.py b/src/sas/qtgui/Perspectives/Invariant/InvariantPerspective.py
index c5df43a454..fa69870fa0 100644
--- a/src/sas/qtgui/Perspectives/Invariant/InvariantPerspective.py
+++ b/src/sas/qtgui/Perspectives/Invariant/InvariantPerspective.py
@@ -78,9 +78,18 @@ def __init__(self, parent=None):
self._background: float = 0.0
self._scale: float = 1.0
self._contrast: float | None = None
+ self._contrast_err: float | None = None
self._porod: float | None = None
+ self._porod_err: float | None = None
self._volfrac1: float | None = None
- self._volfrac2: float | None = None
+ self._volfrac1_err: float | None = None
+
+ # Old extrapolation parameters
+ self._qmax_lowq: float | None = None
+ self._qmin_highq: float | None = None
+ self._qmax_highq: float | None = None
+ self._ex_power_lowq: float | None = None
+ self._ex_power_highq: float | None = None
# New extrapolation options
self._low_extrapolate: bool = False
@@ -111,11 +120,11 @@ def __init__(self, parent=None):
# Modify font in order to display Angstrom symbol correctly
new_font = 'font-family: -apple-system, "Helvetica Neue", "Ubuntu";'
self.lblTotalQUnits.setStyleSheet(new_font)
+ self.lblPorodCstUnits.setStyleSheet(new_font)
+ self.lblContrastUnits.setStyleSheet(new_font)
+ self.lblContrastUnits_2.setStyleSheet(new_font)
self.lblSpecificSurfaceUnits.setStyleSheet(new_font)
self.lblInvariantTotalQUnits.setStyleSheet(new_font)
- self.lblContrastUnits.setStyleSheet(new_font)
- self.lblPorodCstUnits.setStyleSheet(new_font)
- self.lblBackgroundUnits.setStyleSheet(new_font)
# To remove blue square around line edits
self.txtBackgd.setAttribute(QtCore.Qt.WA_MacShowFocusRect, False)
@@ -144,7 +153,6 @@ def __init__(self, parent=None):
self.txtContrast.setValidator(GuiUtils.DoubleValidator())
self.txtScale.setValidator(GuiUtils.DoubleValidator())
self.txtVolFrac1.setValidator(GuiUtils.DoubleValidator())
- self.txtVolFrac2.setValidator(GuiUtils.DoubleValidator())
# Start with all Extrapolation options disabled
self.enable_extrapolation_options(False)
@@ -243,13 +251,26 @@ def update_from_model(self) -> None:
self._background = float(self.model.item(WIDGETS.W_BACKGROUND).text())
if self.model.item(WIDGETS.W_CONTRAST).text() != "None" and self.model.item(WIDGETS.W_CONTRAST).text() != "":
self._contrast = float(self.model.item(WIDGETS.W_CONTRAST).text())
+ if (
+ self.model.item(WIDGETS.W_CONTRAST_ERR).text() != "None"
+ and self.model.item(WIDGETS.W_CONTRAST_ERR).text() != ""
+ ):
+ self._contrast_err = float(self.model.item(WIDGETS.W_CONTRAST_ERR).text())
self._scale = float(self.model.item(WIDGETS.W_SCALE).text())
if self.model.item(WIDGETS.W_POROD_CST).text() != "None" and self.model.item(WIDGETS.W_POROD_CST).text() != "":
self._porod = float(self.model.item(WIDGETS.W_POROD_CST).text())
+ if (
+ self.model.item(WIDGETS.W_POROD_CST_ERR).text() != "None"
+ and self.model.item(WIDGETS.W_POROD_CST_ERR).text() != ""
+ ):
+ self._porod_err = float(self.model.item(WIDGETS.W_POROD_CST_ERR).text())
if self.model.item(WIDGETS.W_VOLFRAC1).text() != "None" and self.model.item(WIDGETS.W_VOLFRAC1).text() != "":
self._volfrac1 = float(self.model.item(WIDGETS.W_VOLFRAC1).text())
- if self.model.item(WIDGETS.W_VOLFRAC2).text() != "None" and self.model.item(WIDGETS.W_VOLFRAC2).text() != "":
- self._volfrac2 = float(self.model.item(WIDGETS.W_VOLFRAC2).text())
+ if (
+ self.model.item(WIDGETS.W_VOLFRAC1_ERR).text() != "None"
+ and self.model.item(WIDGETS.W_VOLFRAC1_ERR).text() != ""
+ ):
+ self._volfrac1_err = float(self.model.item(WIDGETS.W_VOLFRAC1_ERR).text())
self._low_extrapolate = str(self.model.item(WIDGETS.W_ENABLE_LOWQ_EX).text()) == "true"
self._low_guinier = self.rbLowQGuinier_ex.isChecked()
@@ -297,15 +318,17 @@ def deferredPlot(self, model: QtGui.QStandardItemModel) -> None:
def allow_calculation(self) -> None:
"""Enable the calculate button if either volume fraction or contrast is selected"""
+
+ # Check if data is available
if self._data is None:
self.cmdCalculate.setEnabled(False)
- self.cmdCalculate.setText("Calculate (No data)")
+ self.cmdCalculate.setText("Calculate (No Data)")
return
- if self.rbVolFrac.isChecked() and self.txtVolFrac1.text() != "":
- self.cmdCalculate.setEnabled(True)
- self.cmdCalculate.setText("Calculate")
- elif self.rbContrast.isChecked() and self.txtContrast.text() != "":
+ # Check if volume fraction or contrast is selected and has valid input
+ if (self.rbVolFrac.isChecked() and self.txtVolFrac1.text().strip() != "") or (
+ self.rbContrast.isChecked() and self.txtContrast.text().strip() != ""
+ ):
self.cmdCalculate.setEnabled(True)
self.cmdCalculate.setText("Calculate")
else:
@@ -525,7 +548,7 @@ def calculate_thread(self, extrapolation: str) -> None:
if self.rbContrast.isChecked() and self._contrast:
try:
volume_fraction, volume_fraction_error = self._calculator.get_volume_fraction_with_error(
- self._contrast, extrapolation=extrapolation
+ self._contrast, contrast_err=self._contrast_err, extrapolation=extrapolation
)
except (ValueError, ZeroDivisionError, RuntimeError, AttributeError, TypeError) as ex:
calculation_failed = True
@@ -543,7 +566,7 @@ def calculate_thread(self, extrapolation: str) -> None:
if self.rbVolFrac.isChecked() and self._volfrac1:
try:
contrast_out, contrast_out_error = self._calculator.get_contrast_with_error(
- self._volfrac1, extrapolation=extrapolation
+ self._volfrac1, volume_err=self._volfrac1_err, extrapolation=extrapolation
)
except (ValueError, ZeroDivisionError, RuntimeError, AttributeError, TypeError) as ex:
calculation_failed: bool = True
@@ -558,12 +581,20 @@ def calculate_thread(self, extrapolation: str) -> None:
surface: float | str | None = ""
surface_error: float | str | None = ""
- if self._porod:
+ if self._porod and self._porod > 0:
# Use calculated contrast if in volume fraction mode, otherwise use input contrast
contrast_for_surface = contrast_out if self.rbVolFrac.isChecked() and self._volfrac1 else self._contrast
+ contrast_for_surface_err = (
+ contrast_out_error if self.rbVolFrac.isChecked() and self._volfrac1 else self._contrast_err
+ )
if contrast_for_surface:
try:
- surface, surface_error = self._calculator.get_surface_with_error(contrast_for_surface, self._porod)
+ surface, surface_error = self._calculator.get_surface_with_error(
+ contrast_for_surface,
+ self._porod,
+ contrast_err=contrast_for_surface_err,
+ porod_const_err=self._porod_err,
+ )
except (ValueError, ZeroDivisionError, RuntimeError, AttributeError, TypeError) as ex:
calculation_failed: bool = True
msg += str(ex)
@@ -695,16 +726,23 @@ def setupSlots(self):
self.txtBackgd.editingFinished.connect(self.updateFromGui)
self.txtScale.editingFinished.connect(self.updateFromGui)
self.txtContrast.editingFinished.connect(self.updateFromGui)
+ self.txtContrastErr.editingFinished.connect(self.updateFromGui)
self.txtPorodCst.editingFinished.connect(self.updateFromGui)
self.txtVolFrac1.editingFinished.connect(self.updateFromGui)
self.txtVolFrac1.editingFinished.connect(self.checkVolFrac)
- self.txtVolFrac2.editingFinished.connect(self.updateFromGui)
+ self.txtVolFrac1Err.editingFinished.connect(self.updateFromGui)
# Extrapolation parameters
- # Q range fields
+ # Q range fields - real-time updates while typing
+ self.txtGuinierEnd_ex.textEdited.connect(self.on_extrapolation_text_editing_1)
+ self.txtPorodStart_ex.textEdited.connect(self.on_extrapolation_text_editing_2)
+ self.txtPorodEnd_ex.textEdited.connect(self.on_extrapolation_text_editing_3)
+
+ # Q range fields - validation when done editing
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.setValidator(GuiUtils.DoubleValidator())
self.txtPorodStart_ex.setValidator(GuiUtils.DoubleValidator())
self.txtPorodEnd_ex.setValidator(GuiUtils.DoubleValidator())
@@ -854,19 +892,43 @@ def on_extrapolation_slider_changed(self, state: ExtrapolationParameters) -> Non
self.model.setItem(WIDGETS.W_POROD_END_EX, QtGui.QStandardItem(format_string % state.point_3))
self.notify_extrapolation_text_box_validity(state, show_dialog=True)
+ def on_extrapolation_text_editing_1(self) -> None:
+ """Called while user is typing in Guinier end text box"""
+ 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=False)
+
+ def on_extrapolation_text_editing_2(self) -> None:
+ """Called while user is typing in Porod start text box"""
+ 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=False)
+
+ def on_extrapolation_text_editing_3(self) -> None:
+ """Called while user is typing in Porod end text box"""
+ 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)
+
def on_extrapolation_text_changed_1(self) -> None:
+ """Called when editing finished in Guinier end text box"""
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)
def on_extrapolation_text_changed_2(self) -> None:
+ """Called when editing finished in Porod start text box"""
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)
def on_extrapolation_text_changed_3(self) -> None:
+ """Called when editing finished in Porod end text box"""
value: str = self.txtPorodEnd_ex.text()
params = self.extrapolation_parameters._replace(point_3=safe_float(value))
self.slider.extrapolation_parameters = params
@@ -874,7 +936,12 @@ def on_extrapolation_text_changed_3(self) -> None:
def notify_extrapolation_text_box_validity(
self, params: ExtrapolationParameters, show_dialog: bool = False
- ) -> None:
+ ) -> bool:
+ """
+ Notify the user if the extrapolation text box values are invalid.
+ Returns True if all values are valid, False otherwise.
+ """
+
# 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
@@ -945,14 +1012,12 @@ def checkQExtrapolatedData(self) -> None:
GuiUtils.updateModelItemStatus(self._manager.filesWidget.model, self._path, name, self.sender().checkState())
def checkVolFrac(self) -> None:
- """Check if volfrac1, volfrac2 are strictly between 0 and 1, and volfrac1 + volfrac2 = 1"""
- if self.txtVolFrac1.text() and self.txtVolFrac2.text():
+ """Check if volfrac1 is strictly between 0 and 1"""
+ if self.txtVolFrac1.text().strip() != "":
try:
vf1 = float(self.txtVolFrac1.text())
- vf2 = float(self.txtVolFrac2.text())
except ValueError:
self.txtVolFrac1.setStyleSheet(BG_RED)
- self.txtVolFrac2.setStyleSheet(BG_RED)
self.cmdCalculate.setEnabled(False)
msg = "Volume fractions must be valid numbers."
dialog = QtWidgets.QMessageBox(self, text=msg)
@@ -961,13 +1026,11 @@ def checkVolFrac(self) -> None:
dialog.setStandardButtons(QtWidgets.QMessageBox.Ok)
dialog.exec_()
return
- if 0 < vf1 < 1 and 0 < vf2 < 1 and round(vf1 + vf2, 3) == 1.0:
+ if 0 < vf1 < 1:
self.txtVolFrac1.setStyleSheet(BG_DEFAULT)
- self.txtVolFrac2.setStyleSheet(BG_DEFAULT)
self.allow_calculation()
else:
self.txtVolFrac1.setStyleSheet(BG_RED)
- self.txtVolFrac2.setStyleSheet(BG_RED)
self.cmdCalculate.setEnabled(False)
msg = "Volume fractions must be between 0 and 1, and their sum must equal 1."
dialog = QtWidgets.QMessageBox(self, text=msg)
@@ -982,10 +1045,12 @@ def updateFromGui(self) -> None:
possible_senders: list[str] = [
"txtBackgd",
"txtContrast",
+ "txtContrastErr",
"txtPorodCst",
+ "txtPorodCstErr",
"txtScale",
"txtVolFrac1",
- "txtVolFrac2",
+ "txtVolFrac1Err",
"txtLowQPower_ex",
"txtHighQPower_ex",
]
@@ -993,10 +1058,12 @@ def updateFromGui(self) -> None:
related_widgets: list[WIDGETS] = [
WIDGETS.W_BACKGROUND,
WIDGETS.W_CONTRAST,
+ WIDGETS.W_CONTRAST_ERR,
WIDGETS.W_POROD_CST,
+ WIDGETS.W_POROD_CST_ERR,
WIDGETS.W_SCALE,
WIDGETS.W_VOLFRAC1,
- WIDGETS.W_VOLFRAC2,
+ WIDGETS.W_VOLFRAC1_ERR,
WIDGETS.W_LOWQ_POWER_VALUE_EX,
WIDGETS.W_HIGHQ_POWER_VALUE_EX,
]
@@ -1037,10 +1104,11 @@ def updateFromGui(self) -> None:
sender_to_attr = {
"txtBackgd": "_background",
"txtContrast": "_contrast",
+ "txtContrastErr": "_contrast_err",
"txtPorodCst": "_porod",
"txtScale": "_scale",
"txtVolFrac1": "_volfrac1",
- "txtVolFrac2": "_volfrac2",
+ "txtVolFrac1Err": "_volfrac1_err",
"txtLowQPower_ex": "_low_power_value",
"txtHighQPower_ex": "_high_power_value",
}
@@ -1048,18 +1116,9 @@ def updateFromGui(self) -> None:
if sender_name in sender_to_attr:
setattr(self, sender_to_attr[sender_name], new_value)
- # Auto-set _volfrac2 to 1 - _volfrac1 when txtVolFrac1 changes
- if sender_name == "txtVolFrac1":
- self.txtVolFrac2.setEnabled(False)
- self._volfrac2 = round(1 - self._volfrac1, 3)
- # Update the model and UI for volfrac2
- volfrac2_item = QtGui.QStandardItem(str(self._volfrac2))
- self.model.setItem(WIDGETS.W_VOLFRAC2, volfrac2_item)
- self.txtVolFrac2.setText(str(self._volfrac2))
-
self.allow_calculation()
- except ValueError:
- # empty field, just skip
+ except (ValueError, TypeError):
+ # empty field or invalid input, just skip
self.sender().setStyleSheet(BG_RED)
self.cmdCalculate.setEnabled(False)
@@ -1078,7 +1137,7 @@ def volFracToggle(self, toggle: bool) -> None:
def _update_contrast_volfrac_state(self, use_contrast: bool) -> None:
"""
Update the enabled state of contrast and volume fraction fields.
-
+
Args:
use_contrast: If True, contrast is input and volume fraction is output.
If False, volume fraction is input and contrast is output.
@@ -1089,8 +1148,9 @@ def _update_contrast_volfrac_state(self, use_contrast: bool) -> None:
# Input fields
self.txtContrast.setEnabled(use_contrast)
+ self.txtContrastErr.setEnabled(use_contrast)
self.txtVolFrac1.setEnabled(not use_contrast)
- self.txtVolFrac2.setEnabled(not use_contrast)
+ self.txtVolFrac1Err.setEnabled(not use_contrast)
# Output fields (grey out the one that's being used as input)
self.txtVolFract.setEnabled(use_contrast)
@@ -1176,20 +1236,36 @@ def setupModel(self) -> None:
else:
item: QtGui.QStandardItem = QtGui.QStandardItem("")
self.model.setItem(WIDGETS.W_CONTRAST, item)
+ item: QtGui.QStandardItem = QtGui.QStandardItem(str(self._contrast_err))
+ self.model.setItem(WIDGETS.W_CONTRAST_ERR, item)
item: QtGui.QStandardItem = QtGui.QStandardItem(str(self._scale))
self.model.setItem(WIDGETS.W_SCALE, item)
- # leave line edit empty if Porod constant not defined
- if self._porod is not None:
- item: QtGui.QStandardItem = QtGui.QStandardItem(str(self._porod))
- else:
- item: QtGui.QStandardItem = QtGui.QStandardItem("")
- self.model.setItem(WIDGETS.W_POROD_CST, item)
- # add volume fraction to the model
- item: QtGui.QStandardItem = QtGui.QStandardItem(str(self._volfrac1))
+ # leave line edit empty if value is not defined
+ item: QtGui.QStandardItem = (
+ QtGui.QStandardItem(str(self._porod)) if self._porod is not None else QtGui.QStandardItem("")
+ )
+ self.model.setItem(WIDGETS.W_POROD_CST, item)
+ item: QtGui.QStandardItem = (
+ QtGui.QStandardItem(str(self._porod_err)) if self._porod_err is not None else QtGui.QStandardItem("")
+ )
+ self.model.setItem(WIDGETS.W_POROD_CST_ERR, item)
+ item: QtGui.QStandardItem = (
+ QtGui.QStandardItem(str(self._contrast)) if self._contrast is not None else QtGui.QStandardItem("")
+ )
+ self.model.setItem(WIDGETS.W_CONTRAST, item)
+ item: QtGui.QStandardItem = (
+ QtGui.QStandardItem(str(self._contrast_err)) if self._contrast_err is not None else QtGui.QStandardItem("")
+ )
+ self.model.setItem(WIDGETS.W_CONTRAST_ERR, item)
+ item: QtGui.QStandardItem = (
+ QtGui.QStandardItem(str(self._volfrac1)) if self._volfrac1 is not None else QtGui.QStandardItem("")
+ )
self.model.setItem(WIDGETS.W_VOLFRAC1, item)
- item: QtGui.QStandardItem = QtGui.QStandardItem(str(self._volfrac2))
- self.model.setItem(WIDGETS.W_VOLFRAC2, item)
+ item: QtGui.QStandardItem = (
+ QtGui.QStandardItem(str(self._volfrac1_err)) if self._volfrac1_err is not None else QtGui.QStandardItem("")
+ )
+ self.model.setItem(WIDGETS.W_VOLFRAC1_ERR, item)
# add enable contrast/volfrac to the model
item: QtGui.QStandardItem = QtGui.QStandardItem("true")
@@ -1234,9 +1310,15 @@ def setupMapper(self) -> None:
# Contrast
self.mapper.addMapping(self.txtContrast, WIDGETS.W_CONTRAST)
+ self.mapper.addMapping(self.txtContrastErr, WIDGETS.W_CONTRAST_ERR)
+
+ # Volume fraction
+ self.mapper.addMapping(self.txtVolFrac1, WIDGETS.W_VOLFRAC1)
+ self.mapper.addMapping(self.txtVolFrac1Err, WIDGETS.W_VOLFRAC1_ERR)
# Porod constant
self.mapper.addMapping(self.txtPorodCst, WIDGETS.W_POROD_CST)
+ self.mapper.addMapping(self.txtPorodCstErr, WIDGETS.W_POROD_CST_ERR)
# Output
self.mapper.addMapping(self.txtVolFract, WIDGETS.W_VOLUME_FRACTION)
@@ -1306,6 +1388,7 @@ def setData(self, data_item: QtGui.QStandardItem = None, is_batch: bool = False)
def fractional_position(f):
return np.exp(f * log_data_max + (1 - f) * log_data_min)
+
self.model.setItem(WIDGETS.W_GUINIER_END_EX, QtGui.QStandardItem("%.7g" % fractional_position(0.15)))
self.model.setItem(WIDGETS.W_POROD_START_EX, QtGui.QStandardItem("%.7g" % fractional_position(0.85)))
self.model.setItem(WIDGETS.W_POROD_END_EX, QtGui.QStandardItem("%.7g" % Q_MAXIMUM))
@@ -1342,6 +1425,7 @@ def removeData(self, data_list: list | None = None) -> None:
self.txtName.setText("")
self.txtFileName.setText("")
self._porod = None
+ self._porod_err = None
# Pass an empty dictionary to set all inputs to their default values
self.updateFromParameters({})
# Disable buttons to return to base state
@@ -1430,8 +1514,13 @@ def serializeState(self) -> dict:
"invariant_total_err": self.txtInvariantTotErr.text(),
"background": self.txtBackgd.text(),
"contrast": self.txtContrast.text(),
+ "contrast_err": self.txtContrastErr.text(),
"scale": self.txtScale.text(),
"porod": self.txtPorodCst.text(),
+ "volfrac1": self.txtVolFrac1.text(),
+ "volfrac1_err": self.txtVolFrac1Err.text(),
+ "enable_contrast": self.rbContrast.isChecked(),
+ "enable_volfrac": self.rbVolFrac.isChecked(),
"total_q_min": self.txtTotalQMin.text(),
"total_q_max": self.txtTotalQMax.text(),
"guinier_end_low_q_ex": self.txtGuinierEnd_ex.text(),
@@ -1475,22 +1564,25 @@ def updateFromParameters(self, params: dict) -> None:
self.txtBackgd.setText(str(params.get("background", "0.0")))
self.txtScale.setText(str(params.get("scale", "1.0")))
self.txtContrast.setText(str(params.get("contrast", "")))
+ self.txtContrastErr.setText(str(params.get("contrast_err", "0.0")))
self.txtPorodCst.setText(str(params.get("porod", "0.0")))
-
- # Extrapolation tab
- self.txtGuinierEnd_ex.setText(str(params.get("qmax_lowq", "")))
- self.txtPorodStart_ex.setText(str(params.get("qmin_highq", "")))
- self.txtPorodEnd_ex.setText(str(params.get("qmax_highq", "")))
- self.txtLowQPower_ex.setText(str(params.get("lowQPower", DEFAULT_POWER_VALUE)))
- self.txtHighQPower_ex.setText(str(params.get("highQPower", DEFAULT_POWER_VALUE)))
- self.chkLowQ_ex.setChecked(params.get("lowQ", False))
- self.chkHighQ_ex.setChecked(params.get("highQ", False))
- self.rbLowQGuinier_ex.setChecked(params.get("lowQGuinier", False))
- self.rbLowQPower_ex.setChecked(params.get("lowQPower", False))
- self.rbLowQFit_ex.setChecked(params.get("lowQFit", False))
- self.rbLowQFix_ex.setChecked(params.get("lowQFix", False))
- self.rbHighQFit_ex.setChecked(params.get("highQFit", False))
- self.rbHighQFix_ex.setChecked(params.get("highQFix", False))
+ self.txtVolFrac1.setText(str(params.get("volfrac1", "0.0")))
+ self.txtVolFrac1Err.setText(str(params.get("volfrac1_err", "0.0")))
+
+ # Extrapolation tab - use new _ex suffix variables
+ self.txtGuinierEnd_ex.setText(str(params.get("guinier_end_low_q_ex", "")))
+ self.txtPorodStart_ex.setText(str(params.get("porod_start_high_q_ex", "")))
+ self.txtPorodEnd_ex.setText(str(params.get("porod_end_high_q_ex", "")))
+ self.txtLowQPower_ex.setText(str(params.get("power_low_q_ex", DEFAULT_POWER_VALUE)))
+ self.txtHighQPower_ex.setText(str(params.get("power_high_q_ex", DEFAULT_POWER_VALUE)))
+ self.chkLowQ_ex.setChecked(params.get("enable_low_q_ex", False))
+ self.chkHighQ_ex.setChecked(params.get("enable_high_q_ex", False))
+ self.rbLowQGuinier_ex.setChecked(params.get("low_q_guinier_ex", False))
+ self.rbLowQPower_ex.setChecked(params.get("low_q_power_ex", False))
+ self.rbLowQFit_ex.setChecked(params.get("low_q_fit_ex", False))
+ self.rbLowQFix_ex.setChecked(params.get("low_q_fix_ex", False))
+ self.rbHighQFit_ex.setChecked(params.get("high_q_fit_ex", False))
+ self.rbHighQFix_ex.setChecked(params.get("high_q_fix_ex", False))
# Update once all inputs are changed
self.update_from_model()
diff --git a/src/sas/qtgui/Perspectives/Invariant/InvariantUtils.py b/src/sas/qtgui/Perspectives/Invariant/InvariantUtils.py
index 2b97ac329a..88f9a3567f 100755
--- a/src/sas/qtgui/Perspectives/Invariant/InvariantUtils.py
+++ b/src/sas/qtgui/Perspectives/Invariant/InvariantUtils.py
@@ -7,8 +7,11 @@
"W_BACKGROUND",
"W_SCALE",
"W_CONTRAST",
+ "W_CONTRAST_ERR",
"W_POROD_CST",
+ "W_POROD_CST_ERR",
"W_VOLFRAC1",
+ "W_VOLFRAC1_ERR",
"W_VOLFRAC2",
"W_ENABLE_CONTRAST",
"W_ENABLE_VOLFRAC",
diff --git a/src/sas/qtgui/Perspectives/Invariant/UI/TabbedInvariantUI.ui b/src/sas/qtgui/Perspectives/Invariant/UI/TabbedInvariantUI.ui
index 8abb5f6de0..d42b545207 100755
--- a/src/sas/qtgui/Perspectives/Invariant/UI/TabbedInvariantUI.ui
+++ b/src/sas/qtgui/Perspectives/Invariant/UI/TabbedInvariantUI.ui
@@ -7,7 +7,7 @@
0
0
650
- 580
+ 600
@@ -19,7 +19,7 @@
650
- 580
+ 600
@@ -31,7 +31,7 @@
10
10
631
- 521
+ 551
@@ -47,7 +47,7 @@
- 1
+ 0
@@ -151,100 +151,103 @@
-
-
-
-
+
+
-
Background:
- -
+
-
- -
-
+
-
+
- <html><head/><body><p>cm<span style=" vertical-align:super;">-1</span></p></body></html>
+ Scale:
- -
-
-
- -
-
-
- Contrast:
-
-
+
-
+
- -
-
+
+
+ -
+
+
-
+
- -
-
+
-
+
- Volume Fraction 1:
+ +/-
- -
-
+
-
+
- -
+
-
- <html><head/><body><p>Å<span style=" vertical-align:super;">-2</span></p></body></html>
+ <html><head/><body><p align="center">Å<span style=" vertical-align:super;">-2</span></p></body></html>
- -
-
-
-
-
+
+
+ -
+
- Volume Fraction 2:
+ +/-
- -
-
+
-
+
+
+ -
+
- Scale:
+ +/-
- -
+
-
+
+
+ -
<html><head/><body><p>Porod constant:</p></body></html>
- -
-
-
- <html><head/><body><p>(cm Å<span style=" vertical-align:super;">4</span>)<span style=" vertical-align:super;">-1</span></p></body></html>
-
-
-
- -
+
-
Porod constant (optional)
- -
+
-
+
+
+ <html><head/><body><p>(cm Å<span style=" vertical-align:super;">4</span>)<span style=" vertical-align:super;">-1</span></p></body></html>
+
+
+
+ -
- Use Contrast
+ Contrast:
- -
+
-
@@ -253,8 +256,7 @@
- Use Volume
- Fraction
+ Volume Fraction:
@@ -346,7 +348,7 @@
-
- <html><head/><body><p>Å<span style=" vertical-align:super;">-1</span></p></body></html>
+ <html><head/><body><p align="center">Å<span style=" vertical-align:super;">-1</span></p></body></html>
@@ -513,6 +515,13 @@
+ -
+
+
+ <html><head/><body><p align="center">Å<span style=" vertical-align:super;">-2</span></p></body></html>
+
+
+
@@ -606,7 +615,7 @@
-
-
+
<html><head/><body><p>Å<span style=" vertical-align:super;">-1</span></p></body></html>
@@ -709,7 +718,7 @@
-
-
+
0
@@ -771,7 +780,7 @@
-
-
+
0
@@ -863,8 +872,8 @@
- 20
- 531
+ 10
+ 560
611
31
@@ -924,12 +933,24 @@
txtName
txtTotalQMin
txtTotalQMax
- txtVolFract
- txtVolFractErr
- txtSpecSurf
- txtSpecSurfErr
- txtInvariantTot
- txtInvariantTotErr
+ txtBackgd
+ txtScale
+ txtPorodCst
+ txtPorodCstErr
+ txtContrast
+ txtContrastErr
+ txtVolFrac1
+ txtVolFrac1Err
+ chkLowQ_ex
+ rbLowQGuinier_ex
+ rbLowQPower_ex
+ rbLowQFit_ex
+ rbLowQFix_ex
+ txtLowQPower_ex
+ chkHighQ_ex
+ rbHighQFit_ex
+ rbHighQFix_ex
+ txtHighQPower_ex
cmdCalculate
cmdStatus
cmdHelp
diff --git a/src/sas/qtgui/Perspectives/Invariant/UnitTesting/InvariantPerspectiveTest.py b/src/sas/qtgui/Perspectives/Invariant/UnitTesting/InvariantPerspectiveTest.py
index 001f2e7ea7..05467018f9 100644
--- a/src/sas/qtgui/Perspectives/Invariant/UnitTesting/InvariantPerspectiveTest.py
+++ b/src/sas/qtgui/Perspectives/Invariant/UnitTesting/InvariantPerspectiveTest.py
@@ -465,3 +465,34 @@ def checkFakeDataState(self, widget):
# unchecked checkboxes
assert not widget.chkLowQ.isChecked()
assert not widget.chkHighQ.isChecked()
+
+ def test_allow_calculation_requires_input(self, widget):
+ # Start with no data -> button disabled
+ widget._data = None
+ widget.allow_calculation()
+ assert not widget.cmdCalculate.isEnabled()
+
+ # Fake that we have data
+ widget._data = self.data
+
+ # Contrast mode: no contrast -> disabled
+ widget.rbContrast.setChecked(True)
+ widget.txtContrast.setText('')
+ widget.allow_calculation()
+ assert not widget.cmdCalculate.isEnabled()
+
+ # Contrast mode: valid contrast -> enabled
+ widget.txtContrast.setText('2.2e-6')
+ widget.allow_calculation()
+ assert widget.cmdCalculate.isEnabled()
+
+ # Volume fraction mode: no vol frac -> disabled
+ widget.rbVolFrac.setChecked(True)
+ widget.txtVolFrac1.setText('')
+ widget.allow_calculation()
+ assert not widget.cmdCalculate.isEnabled()
+
+ # Volume fraction mode: valid vol frac -> enabled
+ widget.txtVolFrac1.setText('0.01')
+ widget.allow_calculation()
+ assert widget.cmdCalculate.isEnabled()
diff --git a/src/sas/qtgui/Perspectives/Invariant/media/bookmark.png b/src/sas/qtgui/Perspectives/Invariant/media/bookmark.png
deleted file mode 100755
index 780a0a98c0..0000000000
Binary files a/src/sas/qtgui/Perspectives/Invariant/media/bookmark.png and /dev/null differ
diff --git a/src/sas/qtgui/Perspectives/Invariant/media/image005.png b/src/sas/qtgui/Perspectives/Invariant/media/image005.png
deleted file mode 100755
index 661f3a5f60..0000000000
Binary files a/src/sas/qtgui/Perspectives/Invariant/media/image005.png and /dev/null differ
diff --git a/src/sas/qtgui/Perspectives/Invariant/media/image_contrast_calculation.png b/src/sas/qtgui/Perspectives/Invariant/media/image_contrast_calculation.png
new file mode 100644
index 0000000000..3bd9961773
Binary files /dev/null and b/src/sas/qtgui/Perspectives/Invariant/media/image_contrast_calculation.png differ
diff --git a/src/sas/qtgui/Perspectives/Invariant/media/image_extrapolated_data.png b/src/sas/qtgui/Perspectives/Invariant/media/image_extrapolated_data.png
new file mode 100644
index 0000000000..759769c194
Binary files /dev/null and b/src/sas/qtgui/Perspectives/Invariant/media/image_extrapolated_data.png differ
diff --git a/src/sas/qtgui/Perspectives/Invariant/media/image_extrapolation_tab.png b/src/sas/qtgui/Perspectives/Invariant/media/image_extrapolation_tab.png
new file mode 100644
index 0000000000..3345d51097
Binary files /dev/null and b/src/sas/qtgui/Perspectives/Invariant/media/image_extrapolation_tab.png differ
diff --git a/src/sas/qtgui/Perspectives/Invariant/media/image_invariant_details.png b/src/sas/qtgui/Perspectives/Invariant/media/image_invariant_details.png
deleted file mode 100755
index a4a6a30580..0000000000
Binary files a/src/sas/qtgui/Perspectives/Invariant/media/image_invariant_details.png and /dev/null differ
diff --git a/src/sas/qtgui/Perspectives/Invariant/media/image_invariant_load_data.png b/src/sas/qtgui/Perspectives/Invariant/media/image_invariant_load_data.png
index 6a3c1637b4..73044e46e1 100755
Binary files a/src/sas/qtgui/Perspectives/Invariant/media/image_invariant_load_data.png and b/src/sas/qtgui/Perspectives/Invariant/media/image_invariant_load_data.png differ
diff --git a/src/sas/qtgui/Perspectives/Invariant/media/image_invariant_option_tab.png b/src/sas/qtgui/Perspectives/Invariant/media/image_invariant_option_tab.png
deleted file mode 100755
index 1585dbd964..0000000000
Binary files a/src/sas/qtgui/Perspectives/Invariant/media/image_invariant_option_tab.png and /dev/null differ
diff --git a/src/sas/qtgui/Perspectives/Invariant/media/image_invariant_outplot_plot.png b/src/sas/qtgui/Perspectives/Invariant/media/image_invariant_outplot_plot.png
deleted file mode 100755
index c7759f508b..0000000000
Binary files a/src/sas/qtgui/Perspectives/Invariant/media/image_invariant_outplot_plot.png and /dev/null differ
diff --git a/src/sas/qtgui/Perspectives/Invariant/media/image_status.png b/src/sas/qtgui/Perspectives/Invariant/media/image_status.png
new file mode 100644
index 0000000000..d6e14776b0
Binary files /dev/null and b/src/sas/qtgui/Perspectives/Invariant/media/image_status.png differ
diff --git a/src/sas/qtgui/Perspectives/Invariant/media/invariant_help.rst b/src/sas/qtgui/Perspectives/Invariant/media/invariant_help.rst
index 31d70e18e5..aa4b3cd6b3 100755
--- a/src/sas/qtgui/Perspectives/Invariant/media/invariant_help.rst
+++ b/src/sas/qtgui/Perspectives/Invariant/media/invariant_help.rst
@@ -124,10 +124,10 @@ Data Extrapolation
The difficulty with using $Q^*$ arises from the fact that experimental data is
never measured over the range $0 \le q \le \infty$ and it is thus usually
necessary to extrapolate the experimental data to both low and high $q$.
-Currently, SasView allows extrapolation to any user-defined low and high $q$.
-The default range is $10^{-5} \le q \le 10$ |Ang^-1|. Note that the integrals
-above are weighted by $q^2$ or $q$. Thus the high-$q$ extrapolation is weighted
-far more heavily than the low-$q$ extrapolation so that having data measured to
+Currently, SasView allows extrapolation on a low and high $q$ range of
+$10^{-5} \le q \le 10$ |Ang^-1|. Note that the integrals above are weighted by
+$q^2$ or $q$. Thus the high-$q$ extrapolation is weighted far more heavily
+than the low-$q$ extrapolation so that having data measured to
as large a value of $q_{max}$ as possible can be surprisingly important.
Low-\ $q$ region (<= $q_{min}$ in data):
@@ -135,7 +135,7 @@ Low-\ $q$ region (<= $q_{min}$ in data):
* The Guinier function $I_0.exp(-q^2 R_g^2/3)$ can be used, where $I_0$
and $R_g$ are obtained by fitting the data within the range $q_{min}$ to
$q_{min+j}$ where $j$ is the user-chosen number of points from which to
- extrapolate. The default is the first 10 points. Alternatively a power
+ extrapolate. The default is the lower 15\% of the data points. Alternatively a power
law, similar to the high $q$ extrapolation, can be used but this is not
recommended!
@@ -150,7 +150,7 @@ High-\ $q$ region (>= $q_{max}$ in data):
such an extrapolation. The fitted constant(s) $A$ ($m$) is/are obtained by
fitting the data within the range $q_{max-j}$ to $q_{max}$ where, again,
$j$ is the user chosen number of points from which to extrapolate, the
- default again being the last 10 points.
+ default again being the upper 15\% of the data points.
.. note:: While the high $q$ exponent should generally be close to 4 for a
system with sharp interfaces, in the special case of *infinite* slit
@@ -170,8 +170,7 @@ where $\Delta\rho = (\rho_1 - \rho_2)$ is the SLD contrast and $\phi_1$ and
$\phi_2$ are the volume fractions of the two phases ($\phi_1 + \phi_2 = 1$).
Thus from the invariant one can either calculate the volume fractions of the
two phases given the contrast or, calculate the contrast given the volume
-fraction. However, the current implementation in SasView only allows for the
-former: extracting the volume fraction given a known contrast factor.
+fraction.
Volume Fraction
^^^^^^^^^^^^^^^
@@ -207,6 +206,18 @@ the volume fraction in the Invariant analysis window.
incorrectly entered, or that the dataset is simply not suitable for
invariant analysis.
+SLD Contrast
+^^^^^^^^^^^^
+In cases where the volume fraction of the particles is known, the invariant
+can provide an estimate of the contrast term. Rearranging the expression for $Q^*$ yields
+
+.. math::
+
+ (\Delta\rho)^2 = \frac{Q^*}{2 \pi^2 \phi_1 \phi_2}
+
+where $\phi_1$ is the known volume fraction of the minority phase, and $\phi_2 =
+1 - \phi_1$.
+
Specific Surface Area
^^^^^^^^^^^^^^^^^^^^^
The total surface area per unit volume is an important quantity for a variety of
@@ -309,44 +320,46 @@ Select a dataset and use the *Send To* button on the *Data Explorer* to load
the dataset into the *Invariant* panel. Or select *Invariant* from the
*Analysis* category in the menu bar.
-.. image:: image_invariant_load_data.png
+.. image:: image_contrast_calculation.png
-A first estimate of $Q^*$ should be computed automatically but should be
-ignored as it will be incorect until the proper contrast term is specified.
-
-Use the box on the *Options* tab to specify the contrast term(i.e. difference
-in SLDs). Note this must be specified for the eventual value of $Q^*$ to be on
-an absolute scale and to therefore have any meaning).
-
-.. warning:: **The user must provide the correct SLD contrast** for the data
- they are analysing in the *Options* tab of the Invariant window **and then**
- click on *Compute* before examining/using any displayed value of the
- invariant or volume fraction. **The default contrast has been deliberately
- set to the unlikley-to-be-realistic value of 8e-06** |Ang^-2|\ .
+The Invariant panel will open showing the data plot. The user can select either
+'contrast' or 'volume fraction' calculation mode using the radio buttons, and
+enter the known value in the relevant box. Errors can be specified if known,
+otherwise the calculation will proceed assuming no error in the known value.
+After a valid value is entered, the 'Calculate' button will become active. Press
+this button to perform the Invariant calculation. The calculated value of the
+Invariant and the derived value (volume fraction or contrast) will be displayed
+in the relevant boxes at the bottom of the panel.
Optional: Also in this tab a background term to subtract from the data can be
specified (if the data is not already properly background subtracted), the data
can be rescaled if necessary (e.g. to be on an absolute scale) and a value for
$C_p$ can be specified (required if the specific surface area $S_v$ is desired).
-.. image:: image_invariant_option_tab.png
+.. image:: image_invariant_extrapolation_tab.png
-Adjust the extrapolation types as necessary by checking the relevant *Enable
-Extrapolate* check boxes. If power law extrapolations are chosen, the exponent
-can be either held fixed or fitted. The number of points, $Npts$, to be used
-for the basis of the extrapolation can also be specified.
+ Switch to the *Extrapolation* tab.
+
+Adjust the extrapolation types as necessary by checking the relevant check boxes.
+If power law extrapolations are chosen, the exponent can be either held fixed or fitted.
+The points over which the extrapolations are fitted can also be adjusted by changing the
+slider positions, or entering a specific value in the relevant box. The default values
+are to use the lower and upper 15% of the data points for the low and high $q$
+extrapolations respectively.
In most cases the default values will suffice. Click the *Compute* button.
+.. image:: image_extrapolated_data.png
+
.. note:: As mentioned above in the `Data Extrapolation`_ section, the
extrapolation ranges are currently fixed and not adjustable. They are
designed to keep the computation time reasonable while including as
much of the total $q$ range as should be necessary for any SAS data.
-The details of the calculation are available by clicking the *Status*
-button at the bottom right of the panel.
+The details of the calculation are available in brief at the bottom of the panel,
+or in more detail by clicking the *Status* button at the bottom right of the panel.
-.. image:: image_invariant_details.png
+.. image:: image_status.png
If more than 10% of the computed $Q^*$ value comes from the areas under
the extrapolated curves, proceed with caution.
@@ -374,5 +387,4 @@ References
.. ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ
-.. note:: This help document was last changed (completely re-written) by Paul
- Butler and Steve King, March-July 2020
+.. note:: This help document was last changed by Sujaya Shrestha, 7 January 2026.
diff --git a/src/sas/qtgui/Perspectives/Invariant/media/redo.png b/src/sas/qtgui/Perspectives/Invariant/media/redo.png
deleted file mode 100755
index f855817cdf..0000000000
Binary files a/src/sas/qtgui/Perspectives/Invariant/media/redo.png and /dev/null differ
diff --git a/src/sas/qtgui/Perspectives/Invariant/media/report.png b/src/sas/qtgui/Perspectives/Invariant/media/report.png
deleted file mode 100755
index eb909bfb00..0000000000
Binary files a/src/sas/qtgui/Perspectives/Invariant/media/report.png and /dev/null differ
diff --git a/src/sas/qtgui/Perspectives/Invariant/media/save.png b/src/sas/qtgui/Perspectives/Invariant/media/save.png
deleted file mode 100755
index 0e572336f1..0000000000
Binary files a/src/sas/qtgui/Perspectives/Invariant/media/save.png and /dev/null differ
diff --git a/src/sas/qtgui/Perspectives/Invariant/media/undo.png b/src/sas/qtgui/Perspectives/Invariant/media/undo.png
deleted file mode 100755
index f792b26959..0000000000
Binary files a/src/sas/qtgui/Perspectives/Invariant/media/undo.png and /dev/null differ
diff --git a/src/sas/sascalc/invariant/invariant.py b/src/sas/sascalc/invariant/invariant.py
index 369fbd658a..6f9e2953e0 100644
--- a/src/sas/sascalc/invariant/invariant.py
+++ b/src/sas/sascalc/invariant/invariant.py
@@ -1007,9 +1007,9 @@ def get_volume_fraction_with_error(self, contrast, contrast_err=0.0, extrapolati
- 10^(-8) converts from cm^-1 to A^-1
- q_star: the invariant, in cm^-1A^-3, including extrapolated values
if they have been requested
- - sigq: the invariant uncertainty
+ - sig_Q: the invariant uncertainty
- sigcontrast: the contrast uncertainty
- - sigV: the volume uncertainty
+ - sig_V: the volume uncertainty
The uncertainty will be set to -1 if it can't be computed.
diff --git a/test/sasinvariant/utest_data_handling.py b/test/sasinvariant/utest_data_handling.py
index 35d00cfe2e..1b6c97f1f4 100644
--- a/test/sasinvariant/utest_data_handling.py
+++ b/test/sasinvariant/utest_data_handling.py
@@ -383,6 +383,52 @@ def test_bad_parameter_name(self):
self.assertRaises(ValueError, inv.set_extrapolation, 'high', npts=4,
function='guinier')
+ def test_volume_fraction_uncertainty_increases_with_contrast_err(self):
+ """
+ Checks if the uncertainty calculated for volume fraction scales with the uncertainty entered for contrast
+ """
+ inv = invariant.InvariantCalculator(self.data)
+ contrast = 2.2e-6
+ _, dv_small = inv.get_volume_fraction_with_error(contrast, contrast_err=0.1 * contrast)
+ _, dv_large = inv.get_volume_fraction_with_error(contrast, contrast_err=0.5 * contrast)
+ self.assertGreater(dv_large, dv_small)
+
+ def test_contrast_uncertainty_increases_with_volume_err(self):
+ """
+ Checks if the uncertainty calculated for contrast scales with the uncertainty entered for volume fraction
+ """
+ inv = invariant.InvariantCalculator(self.data)
+ volume = 0.01
+ _, dc_small = inv.get_contrast_with_error(volume, volume_err=0.001)
+ _, dc_large = inv.get_contrast_with_error(volume, volume_err=0.01)
+ self.assertGreater(dc_large, dc_small)
+
+ def test_surface_uncertainty_increases_with_input_err(self):
+ """
+ Checks if the uncertainty calculated for specific surface scales with the uncertainty entered for:
+ - SLD contrast
+ - Porod constant
+ """
+ inv = invariant.InvariantCalculator(self.data)
+ contrast = 2.2e-6
+ porod = 1.825e-7
+
+ _, ds_small_contrast = inv.get_surface_with_error(
+ contrast, porod, contrast_err=0.1 * contrast
+ )
+ _, ds_large_contrast = inv.get_surface_with_error(
+ contrast, porod, contrast_err=0.5 * contrast
+ )
+ self.assertGreater(ds_large_contrast, ds_small_contrast)
+
+ _, ds_small_porod = inv.get_surface_with_error(
+ contrast, porod, porod_const_err=0.1 * porod
+ )
+ _, ds_large_porod = inv.get_surface_with_error(
+ contrast, porod, porod_const_err=0.5 * porod
+ )
+ self.assertGreater(ds_large_porod, ds_small_porod)
+
class TestGuinierExtrapolation(unittest.TestCase):
"""