"""Dialog to configure off-specular parameters (smoothing and/or binning)."""
from mantid.simpleapi import logger
from matplotlib.lines import Line2D
from matplotlib.patches import Ellipse
from numpy import float64
from numpy.typing import NDArray
from qtpy import QtCore, QtWidgets
from quicknxs.enums import OffSpecXAxis
from quicknxs.presenters.data_manager import DataManager
from quicknxs.views import load_ui
from quicknxs.views.widgets import MPLWidget
[docs]
class OffSpecParametersDialog(QtWidgets.QDialog):
"""Combined dialog for off-specular smoothing and binning parameters."""
INTENSITY_MIN = 1e-6 # starting value for the color scale
INTENSITY_MAX = 1.0 # ending value for the color scale
GRID_OFFSET = 0.05 # Starting percentage offset of the grid area inside the whole plot area
drawing = False
def __init__(self, parent, data_manager: DataManager, show_smoothing: bool = False, show_binning: bool = False):
"""
Initialize the combined off-specular parameters dialog.
Parameters
----------
parent : QWidget
Parent widget
data_manager : DataManager
Data manager instance
show_smoothing : bool
Whether to show smoothing parameters
show_binning : bool
Whether to show binning parameters
"""
QtWidgets.QDialog.__init__(self, parent)
self.ui = load_ui("ui_smooth_dialog.ui", base_instance=self)
self.data_manager = data_manager
self.show_smoothing = show_smoothing
self.show_binning = show_binning
self.rect_region = None
self.sigma_1 = None
self.sigma_2 = None
self.sigma_3 = None
# Show/hide sections based on what's requested
self._configure_visibility()
# Load saved values from settings
self.load_settings()
# Connect signals for binning
if show_binning:
self.ui.offspec_bins_y.valueChanged.connect(self.update_bin_width)
self.ui.offspec_y_min.valueChanged.connect(self.update_bin_width)
self.ui.offspec_y_max.valueChanged.connect(self.update_bin_width)
# Connect signals for smoothing
if show_smoothing:
self.ui.sigmaX.valueChanged.connect(self.update_settings)
self.ui.sigmaY.valueChanged.connect(self.update_settings)
self.ui.sigmasCoupled.toggled.connect(self.update_sigma_coupling)
self.ui.rSigmas.valueChanged.connect(self.update_settings)
# Connect plot interaction for region selection (common to both binning and smoothing)
self.ui.plot.canvas.mpl_connect("motion_notify_event", self.plot_select)
self.ui.plot.canvas.mpl_connect("button_press_event", self.plot_select)
# Connect signals to update plot region
self.ui.offspec_x_min.valueChanged.connect(self.update_region)
self.ui.offspec_x_max.valueChanged.connect(self.update_region)
self.ui.offspec_y_min.valueChanged.connect(self.update_region)
self.ui.offspec_y_max.valueChanged.connect(self.update_region)
# Connect radio buttons to update coordinate ranges and redraw plot
self.ui.kizmkfzVSqz.toggled.connect(self.on_coordinate_system_changed)
self.ui.qxVSqz.toggled.connect(self.on_coordinate_system_changed)
self.ui.kizVSkfz.toggled.connect(self.on_coordinate_system_changed)
# Update bin width display on initialization if binning is shown
if show_binning:
self.update_bin_width()
# Draw the initial plot (deferred to avoid blocking)
QtCore.QTimer.singleShot(0, self.draw_plot)
def _configure_visibility(self):
"""Show/hide dialog sections based on which options are selected."""
# Smoothing-specific controls
if hasattr(self.ui, "smoothing_group"):
self.ui.smoothing_group.setVisible(self.show_smoothing)
# Binning group contains bins (needed for both) and error weighting (binning only)
# Show binning_group if either smoothing or binning is enabled
if hasattr(self.ui, "binning_group"):
self.ui.binning_group.setVisible(self.show_smoothing or self.show_binning)
# Error weighting checkbox is only for binning
if hasattr(self.ui, "error_weighting_checkbox"):
self.ui.error_weighting_checkbox.setVisible(self.show_binning)
# Update dialog title
if self.show_smoothing and self.show_binning:
self.setWindowTitle("Off-Specular Parameters (Smoothing & Binning)")
elif self.show_smoothing:
self.setWindowTitle("Off-Specular Parameters (Smoothing)")
elif self.show_binning:
self.setWindowTitle("Off-Specular Parameters (Binning)")
def _grid_region_coordinates(
self, x_min: float, x_max: float, y_min: float, y_max: float
) -> tuple[float, float, float, float]:
"""
Calculate the coordinates of box inside the plot area representing the grid region.
Parameters
----------
x_min: float
k_diff_min, qx_min or ki_z_min
x_max: float
k_diff_max, qx_max or ki_z_max
y_min: float
qz_min or kf_z_min
y_max: float
qz_max or kf_z_max
Returns
-------
tuple
Coordinates of the grid region box (x1, x2, y1, y2)
"""
x_offset = (x_max - x_min) * self.GRID_OFFSET
y_offset = (y_max - y_min) * self.GRID_OFFSET
return x_min + x_offset, x_max - x_offset, y_min + y_offset, y_max - y_offset
def _paint_intensities(
self,
ki_z: NDArray[float64],
kf_z: NDArray[float64],
Qx: NDArray[float64],
Qz: NDArray[float64],
I: NDArray[float64],
plot: MPLWidget,
):
"""
Color-paint the intensities versus appropriate X and Y coordinates.
Parameters
----------
ki_z : NDArray[float64]
Array of z-component of incident wave vector
kf_z : NDArray[float64]
Array of z-component of final wave vector
Qx : NDArray[float64]
Array of x-component of momentum transfer
Qz : NDArray[float64]
Array of z-component of momentum transfer
I : NDArray[float64]
Intensity array
plot : MPLWidget
The plot object to draw on
"""
common_args = {
"log": True,
"imin": self.INTENSITY_MIN,
"imax": self.INTENSITY_MAX,
"cmap": "jet",
"shading": "gouraud",
}
if self.ui.kizmkfzVSqz.isChecked():
x, y = (ki_z - kf_z), Qz
elif self.ui.qxVSqz.isChecked():
x, y = Qx, Qz
elif self.ui.kizVSkfz.isChecked():
x, y = ki_z, kf_z
else:
x, y = (ki_z - kf_z), Qz # Default
plot.pcolormesh(x, y, I, **common_args)
[docs]
def draw_plot(self):
"""Draw the off-specular data with the configured region overlay."""
if self.drawing:
return
self.drawing = True
plot = self.ui.plot
plot.clear()
plot.set_xticks_fontsize(8)
plot.set_yticks_fontsize(8)
# Initialize limits
qz_min, qz_max = 0.5, -0.1
qx_min, qx_max = -0.001, 0.001
ki_z_min, ki_z_max = 0.1, -0.1
kf_z_min, kf_z_max = 0.1, -0.1
k_diff_min, k_diff_max = 0.01, -0.01
Qzmax = 0.001
# Get first state from reduction_states
if not self.data_manager.reduction_states:
self.drawing = False
return
first_state = self.data_manager.reduction_states[0]
# Plot data from all runs in the reduction list
for item in self.data_manager.reduction_list:
# Check if off_spec data exists
if first_state not in item.cross_sections:
logger.warning(f"Cross section state '{first_state}' not found in item, skipping plot")
continue
if item.cross_sections[first_state].off_spec is None:
logger.warning(f"No off-specular data available for state '{first_state}', skipping plot")
continue
offspec = item.cross_sections[first_state].off_spec
Qx, Qz, ki_z, kf_z, I, _ = (offspec.Qx, offspec.Qz, offspec.ki_z, offspec.kf_z, offspec.S, offspec.dS)
n_total = len(I[0])
# P_0 and P_N are the number of points to cut in TOF on each side
p_0 = item.cross_sections[first_state].configuration.cut_first_n_points
p_n = n_total - item.cross_sections[first_state].configuration.cut_last_n_points
Qx = Qx[:, p_0:p_n]
Qz = Qz[:, p_0:p_n]
ki_z = ki_z[:, p_0:p_n]
kf_z = kf_z[:, p_0:p_n]
I = I[:, p_0:p_n]
# Extend the X and Y limits of the plotting area
try:
Qzmax = max(ki_z.max() * 2.0, Qzmax)
qz_max = max(Qz[I > 0].max(), qz_max)
qz_min = min(Qz[I > 0].min(), qz_min)
qx_min = min(qx_min, Qx[I > 0].min())
qx_max = max(qx_max, Qx[I > 0].max())
ki_z_min = min(ki_z_min, ki_z[I > 0].min())
ki_z_max = max(ki_z_max, ki_z[I > 0].max())
kf_z_min = min(kf_z_min, kf_z[I > 0].min())
kf_z_max = max(kf_z_max, kf_z[I > 0].max())
k_diff_min = min(k_diff_min, (ki_z - kf_z)[I > 0].min())
k_diff_max = max(k_diff_max, (ki_z - kf_z)[I > 0].max())
except Exception as exception:
logger.error(f"Error extending plotting limits: {exception}")
self._paint_intensities(ki_z, kf_z, Qx, Qz, I, plot)
# Set plot limits and labels based on selected axis type
if self.ui.qxVSqz.isChecked():
plot.canvas.ax.set_xlim([qx_min, qx_max])
plot.canvas.ax.set_ylim([qz_min, qz_max])
plot.set_xlabel("Q$_x$ [Å$^{-1}$]", fontsize=14)
plot.set_ylabel("Q$_z$ [Å$^{-1}$]", fontsize=14)
x1, x2, y1, y2 = self._grid_region_coordinates(qx_min, qx_max, qz_min, qz_max)
sigma_pos = (0.0, Qzmax / 3.0)
sigma_y_enabled = True
elif self.ui.kizVSkfz.isChecked():
plot.canvas.ax.set_xlim([ki_z_min, ki_z_max])
plot.canvas.ax.set_ylim([kf_z_min, kf_z_max])
plot.set_xlabel("k$_{i,z}$ [Å$^{-1}$]", fontsize=14)
plot.set_ylabel("k$_{f,z}$ [Å$^{-1}$]", fontsize=14)
x1, x2, y1, y2 = self._grid_region_coordinates(ki_z_min, ki_z_max, kf_z_min, kf_z_max)
sigma_pos = (Qzmax / 6.0, Qzmax / 6.0)
sigma_y_enabled = True
else:
# Default: k_i,z - k_f,z vs Q_z
plot.canvas.ax.set_xlim([k_diff_min, k_diff_max])
plot.canvas.ax.set_ylim([qz_min, qz_max])
plot.set_xlabel("k$_{i,z}$-k$_{f,z}$ [Å$^{-1}$]", fontsize=14)
plot.set_ylabel("Q$_z$ [Å$^{-1}$]", fontsize=14)
x1, x2, y1, y2 = self._grid_region_coordinates(k_diff_min, k_diff_max, qz_min, qz_max)
sigma_pos = (0.0, Qzmax / 3.0)
sigma_y_enabled = False
# Update spinboxes with calculated default values
self.ui.offspec_x_min.setValue(x1)
self.ui.offspec_x_max.setValue(x2)
self.ui.offspec_y_min.setValue(y1)
self.ui.offspec_y_max.setValue(y2)
# Draw the region rectangle
self.rect_region = Line2D([x1, x1, x2, x2, x1], [y1, y2, y2, y1, y1])
plot.canvas.ax.add_line(self.rect_region)
# Configure smoothing-specific elements if smoothing is enabled
if self.show_smoothing:
# Calculate sigma values
sigma_percentage = 0.005
min_sigma_size = 0.0001
sigma_x = max((x2 - x1) * sigma_percentage, min_sigma_size)
sigma_y = max((y2 - y1) * sigma_percentage, min_sigma_size)
self.ui.sigmaX.setValue(sigma_x)
self.ui.sigmaY.setValue(sigma_y)
self.ui.sigmaY.setEnabled(sigma_y_enabled)
# Set sigma coupling based on coordinate system
if self.ui.kizmkfzVSqz.isChecked():
self.ui.sigmasCoupled.setChecked(True)
elif self.ui.qxVSqz.isChecked():
self.ui.sigmasCoupled.setChecked(False)
else:
self.ui.sigmasCoupled.setChecked(True)
# Create sigma ellipses
sigma_ang = 0.0
self.sigma_1 = Ellipse(
sigma_pos, self.ui.sigmaX.value() * 2, self.ui.sigmaY.value() * 2, angle=sigma_ang, fill=False
)
self.sigma_2 = Ellipse(
sigma_pos, self.ui.sigmaX.value() * 4, self.ui.sigmaY.value() * 4, angle=sigma_ang, fill=False
)
self.sigma_3 = Ellipse(
sigma_pos, self.ui.sigmaX.value() * 6, self.ui.sigmaY.value() * 6, angle=sigma_ang, fill=False
)
plot.canvas.ax.add_artist(self.sigma_1)
plot.canvas.ax.add_artist(self.sigma_2)
plot.canvas.ax.add_artist(self.sigma_3)
# Show the plot
if plot.cplot is not None:
plot.cplot.set_clim([self.INTENSITY_MIN, self.INTENSITY_MAX])
plot.draw()
if self.show_smoothing:
self.update_sigma_coupling()
self.drawing = False
[docs]
def on_coordinate_system_changed(self):
"""Handle coordinate system radio button changes - recalculate ranges from data."""
if self.drawing:
return
self.draw_plot()
[docs]
def update_region(self):
"""Update the rectangle overlay showing the region."""
if self.drawing:
return
# Only update if plot has been initialized with data and rectangle exists
if self.rect_region is None:
return
x1 = self.ui.offspec_x_min.value()
x2 = self.ui.offspec_x_max.value()
y1 = self.ui.offspec_y_min.value()
y2 = self.ui.offspec_y_max.value()
# Update rectangle data
self.rect_region.set_data([x1, x1, x2, x2, x1], [y1, y2, y2, y1, y1])
self.ui.plot.draw()
[docs]
def update_settings(self):
"""Update smoothing sigma visualization (only called when smoothing is enabled)."""
if self.drawing or not self.show_smoothing:
return
self.drawing = True
# Redraw indicators (only if they exist)
if self.rect_region is not None:
x1 = self.ui.offspec_x_min.value()
x2 = self.ui.offspec_x_max.value()
y1 = self.ui.offspec_y_min.value()
y2 = self.ui.offspec_y_max.value()
self.rect_region.set_data([x1, x1, x2, x2, x1], [y1, y2, y2, y1, y1])
if self.sigma_1:
self.sigma_1.width = 2 * self.ui.sigmaX.value()
self.sigma_1.height = 2 * self.ui.sigmaY.value()
self.sigma_2.width = 4 * self.ui.sigmaX.value()
self.sigma_2.height = 4 * self.ui.sigmaY.value()
self.sigma_3.width = 6 * self.ui.sigmaX.value()
self.sigma_3.height = 6 * self.ui.sigmaY.value()
self.ui.plot.draw()
self.drawing = False
[docs]
def update_sigma_coupling(self):
"""Update sigma coupling state and UI element states."""
if not self.show_smoothing:
return
if self.ui.sigmasCoupled.isChecked():
self.ui.sigmaY.setEnabled(False)
self.ui.sigmaY.setValue(self.ui.sigmaX.value())
else:
self.ui.sigmaY.setEnabled(True)
self.update_settings()
[docs]
def plot_select(self, event):
"""Handle plot clicks to adjust the selection region."""
if event.button == 1 and event.xdata is not None:
x = event.xdata
y = event.ydata
x1 = self.ui.offspec_x_min.value()
x2 = self.ui.offspec_x_max.value()
y1 = self.ui.offspec_y_min.value()
y2 = self.ui.offspec_y_max.value()
if x < x1 or abs(x - x1) < abs(x - x2):
x1 = x
else:
x2 = x
if y < y1 or abs(y - y1) < abs(y - y2):
y1 = y
else:
y2 = y
self.drawing = True
self.ui.offspec_x_min.setValue(x1)
self.ui.offspec_x_max.setValue(x2)
self.ui.offspec_y_min.setValue(y1)
self.ui.offspec_y_max.setValue(y2)
self.drawing = False
# Update visualization: rectangle only for binning, rectangle + sigmas for smoothing
if self.show_smoothing:
self.update_settings() # Updates both rectangle and sigma ellipses
else:
self.update_region() # Updates only rectangle
[docs]
def load_settings(self):
"""Load parameter values from QSettings."""
settings = QtCore.QSettings(".quicknxs")
# Load shared region parameters
if settings.contains("offspec_binned/x_min"):
self.ui.offspec_x_min.setValue(float(settings.value("offspec_binned/x_min")))
if settings.contains("offspec_binned/x_max"):
self.ui.offspec_x_max.setValue(float(settings.value("offspec_binned/x_max")))
if settings.contains("offspec_binned/y_min"):
self.ui.offspec_y_min.setValue(float(settings.value("offspec_binned/y_min")))
if settings.contains("offspec_binned/y_max"):
self.ui.offspec_y_max.setValue(float(settings.value("offspec_binned/y_max")))
# Load binning-specific parameters
if self.show_binning:
if settings.contains("offspec_binned/bins_x"):
self.ui.offspec_bins_x.setValue(int(settings.value("offspec_binned/bins_x")))
if settings.contains("offspec_binned/bins_y"):
self.ui.offspec_bins_y.setValue(int(settings.value("offspec_binned/bins_y")))
if settings.contains("offspec_binned/error_weighting"):
self.ui.error_weighting_checkbox.setChecked(
settings.value("offspec_binned/error_weighting", False, type=bool)
)
# Load smoothing-specific parameters
if self.show_smoothing:
if settings.contains("offspec_smoothing/sigma_x"):
self.ui.sigmaX.setValue(float(settings.value("offspec_smoothing/sigma_x")))
if settings.contains("offspec_smoothing/sigma_y"):
self.ui.sigmaY.setValue(float(settings.value("offspec_smoothing/sigma_y")))
if settings.contains("offspec_smoothing/r_sigmas"):
self.ui.rSigmas.setValue(float(settings.value("offspec_smoothing/r_sigmas")))
if settings.contains("offspec_smoothing/sigmas_coupled"):
self.ui.sigmasCoupled.setChecked(settings.value("offspec_smoothing/sigmas_coupled", True, type=bool))
# Load coordinate system
if settings.contains("offspec_binned/coordinate_system"):
coord_sys = settings.value("offspec_binned/coordinate_system")
if coord_sys == OffSpecXAxis.KZI_VS_KZF:
self.ui.kizVSkfz.setChecked(True)
elif coord_sys == OffSpecXAxis.QX_VS_QZ:
self.ui.qxVSqz.setChecked(True)
else:
self.ui.kizmkfzVSqz.setChecked(True)
[docs]
def save_settings(self):
"""Save parameter values to QSettings."""
settings = QtCore.QSettings(".quicknxs")
# Save shared region parameters
settings.setValue("offspec_binned/x_min", self.ui.offspec_x_min.value())
settings.setValue("offspec_binned/x_max", self.ui.offspec_x_max.value())
settings.setValue("offspec_binned/y_min", self.ui.offspec_y_min.value())
settings.setValue("offspec_binned/y_max", self.ui.offspec_y_max.value())
# Save coordinate system
if self.ui.kizVSkfz.isChecked():
coord_sys = OffSpecXAxis.KZI_VS_KZF
elif self.ui.qxVSqz.isChecked():
coord_sys = OffSpecXAxis.QX_VS_QZ
else:
coord_sys = OffSpecXAxis.DELTA_KZ_VS_QZ
settings.setValue("offspec_binned/coordinate_system", coord_sys)
# Save bins parameters (common to both binning and smoothing)
settings.setValue("offspec_binned/bins_x", self.ui.offspec_bins_x.value())
settings.setValue("offspec_binned/bins_y", self.ui.offspec_bins_y.value())
# Save binning-specific parameters
if self.show_binning:
settings.setValue("offspec_binned/error_weighting", self.ui.error_weighting_checkbox.isChecked())
# Save smoothing-specific parameters
if self.show_smoothing:
settings.setValue("offspec_smoothing/sigma_x", self.ui.sigmaX.value())
settings.setValue("offspec_smoothing/sigma_y", self.ui.sigmaY.value())
settings.setValue("offspec_smoothing/r_sigmas", self.ui.rSigmas.value())
settings.setValue("offspec_smoothing/sigmas_coupled", self.ui.sigmasCoupled.isChecked())
[docs]
def update_bin_width(self):
"""Calculate and display the Qz bin width based on current settings."""
bins_y = self.ui.offspec_bins_y.value()
y_min = self.ui.offspec_y_min.value()
y_max = self.ui.offspec_y_max.value()
if bins_y > 0:
width = (y_max - y_min) / bins_y
self.ui.qz_bin_width_label.setText(f"{width:8.6f} 1/A")
else:
self.ui.qz_bin_width_label.setText("N/A")
[docs]
def get_parameters(self):
"""
Get the parameters as a dictionary.
Returns
-------
dict
Dictionary containing off-specular parameters
"""
params = {}
# Determine coordinate system setting
if self.ui.kizVSkfz.isChecked():
params["off_spec_x_axis"] = OffSpecXAxis.KZI_VS_KZF
elif self.ui.qxVSqz.isChecked():
params["off_spec_x_axis"] = OffSpecXAxis.QX_VS_QZ
else:
params["off_spec_x_axis"] = OffSpecXAxis.DELTA_KZ_VS_QZ
# Shared region parameters
params["off_spec_x_min"] = self.ui.offspec_x_min.value()
params["off_spec_x_max"] = self.ui.offspec_x_max.value()
params["off_spec_y_min"] = self.ui.offspec_y_min.value()
params["off_spec_y_max"] = self.ui.offspec_y_max.value()
# Bins parameters (common to both binning and smoothing)
params["off_spec_nxbins"] = self.ui.offspec_bins_x.value()
params["off_spec_nybins"] = self.ui.offspec_bins_y.value()
# Binning-specific parameters
if self.show_binning:
params["off_spec_err_weight"] = self.ui.error_weighting_checkbox.isChecked()
# Smoothing-specific parameters
if self.show_smoothing:
params["off_spec_sigmas"] = self.ui.rSigmas.value()
params["off_spec_sigmax"] = self.ui.sigmaX.value()
params["off_spec_sigmay"] = self.ui.sigmaY.value()
return params
[docs]
def accept(self):
"""Override accept to save settings before closing."""
self.save_settings()
super().accept()