From 2752b54a9a6ebdcb8d845e31e6a2f761ea1cacde Mon Sep 17 00:00:00 2001 From: Patrick Avery Date: Wed, 5 Nov 2025 11:46:46 -0600 Subject: [PATCH 1/3] Add units and conversion capabilities to WPPF This adds the basic capabilities for units and unit conversion to the WPPF module, and a few initial examples, including for lattice parameters. The lattice parameter lengths are automatically converted from nanometers to Angstroms for display (and back when the user modifies them). It also adds Angstrom units to the lattice lengths, and degree units to the lattice angles. We'll use this same infrastructure for other unit conversions. Signed-off-by: Patrick Avery --- hexrdgui/calibration/tree_item_models.py | 75 +++++++- hexrdgui/calibration/wppf_options_dialog.py | 164 +++++++++++++++++- hexrdgui/resources/wppf/tree_views/LeBail.yml | 4 +- .../resources/wppf/tree_views/Rietveld.yml | 4 +- .../tree_views/multi_column_dict_tree_view.py | 8 + 5 files changed, 237 insertions(+), 18 deletions(-) diff --git a/hexrdgui/calibration/tree_item_models.py b/hexrdgui/calibration/tree_item_models.py index 9183f9366..b9c8929f3 100644 --- a/hexrdgui/calibration/tree_item_models.py +++ b/hexrdgui/calibration/tree_item_models.py @@ -1,3 +1,5 @@ +import numpy as np + from PySide6.QtCore import Qt from PySide6.QtGui import QColor @@ -34,15 +36,46 @@ def set_config_val(self, path, value): # Now set the attribute on the param attribute = path[-1].removeprefix('_') + if attribute in ('value', 'min', 'max', 'delta'): + # Check if there is a conversion we need to make before proceeding + config = self.config_path(path[:-1]) + if config.get('_conversion_funcs'): + # Apply the conversion + value = config['_conversion_funcs']['from_display'](value) + # Swap the min/max if they ought to be swapped + # (due to the conversion resulting in an inverse proportionality) + if ( + config.get('_min_max_inverted') and + attribute in ('min', 'max') + ): + attribute = 'max' if attribute == 'min' else 'min' + if attribute == 'value': # Make sure the min/max are shifted to accomodate this value if value < param.min or value > param.max: + config = self.config_path(path[:-1]) + conversion_funcs = config.get('_conversion_funcs') + min_key = '_min' + max_key = '_max' + if conversion_funcs and config.get('_min_max_inverted'): + min_key, max_key = max_key, min_key + + def convert_if_needed(v): + if conversion_funcs is None: + return v + + return conversion_funcs['to_display'](v) + # Shift the min/max to accomodate, because lmfit won't # let us set the value otherwise. param.min = value - (param.value - param.min) param.max = value + (param.max - param.value) - super().set_config_val(path[:-1] + ['_min'], param.min) - super().set_config_val(path[:-1] + ['_max'], param.max) + super().set_config_val( + path[:-1] + [min_key], convert_if_needed(param.min), + ) + super().set_config_val( + path[:-1] + [max_key], convert_if_needed(param.max), + ) col = list(self.COLUMNS.values()).index(path[-1]) + 1 index = self.create_index(path[:-1], col) @@ -50,11 +83,14 @@ def set_config_val(self, path, value): if '_min' in self.COLUMNS.values(): # Get the GUI to update - for name in ('_min', '_max'): + for name, key in zip(('_min', '_max'), (min_key, max_key)): col = list(self.COLUMNS.values()).index(name) + 1 index = self.create_index(path[:-1], col) item = self.get_item(index) - item.set_data(index.column(), getattr(param, name[1:])) + item.set_data( + index.column(), + convert_if_needed(getattr(param, key[1:])), + ) self.dataChanged.emit(index, index) setattr(param, attribute, value) @@ -82,7 +118,29 @@ def data(self, index, role): return QColor(color) - return super().data(index, role) + data = super().data(index, role) + + if ( + role in (Qt.DisplayRole, Qt.EditRole) and + index.column() in self.BOUND_INDICES and + data is not None + ): + # Check if there are any units that should be displayed + item = self.get_item(index) + path = self.path_to_item(item) + config = self.config_path(path) + + if role == Qt.DisplayRole and config.get('_units'): + if isinstance(data, float): + # Make sure it is rounded to 3 decimal places + data = round(data, 3) + + # Don't attach units to infinity + is_inf = isinstance(data, float) and np.isinf(data) + if not is_inf: + data = f"{data}{config['_units']}" + + return data class DefaultCalibrationTreeItemModel(CalibrationTreeItemModel): @@ -116,7 +174,12 @@ def data(self, index, role): if index.column() not in pair: continue - if abs(item.data(pair[0]) - item.data(pair[1])) < atol: + data0 = item.data(pair[0]) + data1 = item.data(pair[1]) + if ( + np.all([np.isinf(x) for x in (data0, data1)]) or + abs(data0 - data1) < atol + ): return QColor('red') return super().data(index, role) diff --git a/hexrdgui/calibration/wppf_options_dialog.py b/hexrdgui/calibration/wppf_options_dialog.py index e04807909..00b07e9fb 100644 --- a/hexrdgui/calibration/wppf_options_dialog.py +++ b/hexrdgui/calibration/wppf_options_dialog.py @@ -1,6 +1,7 @@ import copy from functools import partial from pathlib import Path +import re import sys import time @@ -8,7 +9,6 @@ import lmfit import matplotlib.pyplot as plt import numpy as np -import re import yaml from PySide6.QtCore import QObject, Signal @@ -1158,14 +1158,45 @@ def tree_view_dict_of_params(self): # Keep track of which params have been used. used_params = [] - def create_param_item(param): + def create_param_item(param, units=None, conversion_funcs=None, + min_max_inverted=False): + + # Convert to display units if needed + def convert_if_needed(x): + if conversion_funcs is None: + return x + + return conversion_funcs['to_display'](x) + + def convert_stderr_if_needed(x): + if x == '--': + return x + + if conversion_funcs is None: + return x + + if 'to_display_stderr' in conversion_funcs: + # Special conversion needed + return conversion_funcs['to_display_stderr']( + param.value, + x, + ) + + # Default to the regular conversion + return convert_if_needed(x) + used_params.append(param.name) + stderr = stderr_values.get(param.name, '--') d = { '_param': param, - '_value': param.value, + '_value': convert_if_needed(param.value), '_vary': bool(param.vary), - '_stderr': stderr_values.get(param.name, '--'), + '_stderr': convert_stderr_if_needed(stderr), + '_units': units, + '_conversion_funcs': conversion_funcs, + '_min_max_inverted': min_max_inverted, } + if self.delta_boundaries: if not hasattr(param, 'delta'): # We store the delta on the param object @@ -1176,12 +1207,15 @@ def create_param_item(param): ] param.delta = min(diffs) - d['_delta'] = param.delta + d['_delta'] = convert_if_needed(param.delta) else: d.update(**{ - '_min': param.min, - '_max': param.max, + '_min': convert_if_needed(param.min), + '_max': convert_if_needed(param.max), }) + if min_max_inverted: + # Swap the min and max + d['_min'], d['_max'] = d['_max'], d['_min'] # Make a callback for when `vary` gets modified by the user. f = partial(self.on_param_vary_modified, param=param) @@ -1211,6 +1245,10 @@ def recursively_set_items(this_config, this_template): else: # Assume it is a string. Grab it if in the params. if v in params: + units = None + if v == 'zero_error': + units = '°' + this_config[k] = create_param_item(params[v]) param_set = True @@ -1321,8 +1359,42 @@ def recursively_format_mat(mat, this_config, this_template): if '{mat}' in v: v = v.format(mat=sanitized_mat) + # Determine if units and conversion funcs are needed + # We can't have a global dict of these, because some + # labels are the same. For example, 'α' is also in the + # stacking fault parameters. + units = None + conversion_funcs = None + min_max_inverted = False + prefix = sanitized_mat + if v == f'{prefix}_X': + wlen = HexrdConfig().beam_wavelength + conversion_funcs = mat_lx_to_p_funcs_factory(wlen) + min_max_inverted = True + elif v == f'{prefix}_Y': + units = '%' + conversion_funcs = mat_ly_to_s_funcs + elif v == f'{prefix}_P': + wlen = HexrdConfig().beam_wavelength + conversion_funcs = mat_gp_to_p_funcs_factory(wlen) + min_max_inverted = True + elif v in [f'{prefix}_{k}' for k in ('a', 'b', 'c')]: + units = ' Å' + conversion_funcs = nm_to_angstroms_funcs + elif v in [f'{prefix}_{k}' for k in ('α', 'β', 'γ')]: + units = '°' + elif re.search(rf'^{prefix}_s\d\d\d$', v): + # It is a stacking parameter + conversion_funcs = shkl_to_angstroms_minus_4_funcs + units = ' Å⁻⁴' + if v in params: - this_config[k] = create_param_item(params[v]) + this_config[k] = create_param_item( + params[v], + units, + conversion_funcs, + min_max_inverted, + ) mat_dict = tree_dict.setdefault('Materials', {}) for mat in self.selected_materials: @@ -2381,6 +2453,82 @@ def changed_signal(w): return w.valueChanged +nm_to_angstroms_funcs = { + 'to_display': lambda x: x * 10, + 'from_display': lambda x: x / 10, +} + + +shkl_to_angstroms_minus_4_funcs = { + 'to_display': lambda x: x / 1000, + 'from_display': lambda x: x * 1000, +} + + +mat_ly_to_s_funcs = { + 'to_display': lambda ly: ly * 100 * np.pi / 18000, + 'from_display': lambda s: s / 100 / np.pi * 18000, + 'to_display_stderr': lambda ly, ly_stderr: 100 * np.pi / 18000 * ly_stderr +} + + +def mat_lx_to_p_funcs_factory(wlen: float) -> dict: + k = 0.91 + + def to_display(lx: float): + if abs(lx) <= 1e-8: + return np.inf + elif np.isinf(lx): + return 0 + + return 18000 * k * wlen / np.pi / lx + + def from_display(p: float): + if abs(p) <= 1e-8: + return np.inf + elif np.isinf(p): + return 0 + + return 18000 * k * wlen / np.pi / p + + def to_display_stderr(lx: float, lx_stderr: float) -> float: + return 18000 * k * wlen / np.pi / (lx**2) * lx_stderr + + return { + 'to_display': to_display, + 'from_display': from_display, + 'to_display_stderr': to_display_stderr, + } + + +def mat_gp_to_p_funcs_factory(wlen: float) -> dict: + k = 0.91 + def to_display(gp: float) -> float: + if abs(gp) <= 1e-8: + return np.inf + elif np.isinf(gp): + return 0 + + return 18000 * k * wlen / np.pi / np.sqrt(gp) + + def from_display(p: float) -> float: + if abs(p) <= 1e-8: + return np.inf + elif np.isinf(p): + return 0 + + return (18000 * k * wlen / np.pi / p)**2 + + def to_display_stderr(gp: float, gp_stderr: float) -> float: + return 9000 * k * wlen / np.pi / (gp**1.5) * gp_stderr + + return { + 'to_display': to_display, + 'from_display': from_display, + 'to_display_stderr': to_display_stderr, + } + + if __name__ == '__main__': from PySide6.QtWidgets import QApplication diff --git a/hexrdgui/resources/wppf/tree_views/LeBail.yml b/hexrdgui/resources/wppf/tree_views/LeBail.yml index 2feb4ee0d..e73d8db6b 100644 --- a/hexrdgui/resources/wppf/tree_views/LeBail.yml +++ b/hexrdgui/resources/wppf/tree_views/LeBail.yml @@ -14,8 +14,8 @@ Materials: α: '{mat}_sf_alpha' β: '{mat}_twin_beta' Peak Broadening: - Lorentzian Scherrer Broadening: '{mat}_X' - Gaussian Scherrer Broadening: '{mat}_P' + Lorentzian Particle Size: '{mat}_X' + Gaussian Particle Size: '{mat}_P' Microstrain: '{mat}_Y' Lattice Constants: a: '{mat}_a' diff --git a/hexrdgui/resources/wppf/tree_views/Rietveld.yml b/hexrdgui/resources/wppf/tree_views/Rietveld.yml index a2200e9e5..6b41112f6 100644 --- a/hexrdgui/resources/wppf/tree_views/Rietveld.yml +++ b/hexrdgui/resources/wppf/tree_views/Rietveld.yml @@ -18,8 +18,8 @@ Materials: β: '{mat}_twin_beta' Phase Fraction: '{mat}_phase_fraction' Peak Broadening: - Lorentzian Scherrer Broadening: '{mat}_X' - Gaussian Scherrer Broadening: '{mat}_P' + Lorentzian Particle Size: '{mat}_X' + Gaussian Particle Size: '{mat}_P' Microstrain: '{mat}_Y' Lattice Constants: a: '{mat}_a' diff --git a/hexrdgui/tree_views/multi_column_dict_tree_view.py b/hexrdgui/tree_views/multi_column_dict_tree_view.py index 7e2c2df38..a654ddf47 100644 --- a/hexrdgui/tree_views/multi_column_dict_tree_view.py +++ b/hexrdgui/tree_views/multi_column_dict_tree_view.py @@ -324,6 +324,14 @@ def state_changed(self): def createEditor(self, parent, option, index): editor = super().createEditor(parent, option, index) + + if isinstance(editor, ScientificDoubleSpinBox): + item = self.model.get_item(index) + path = self.model.path_to_item(item) + config = self.model.config_path(path) + if config.get('_units'): + editor.setSuffix(config['_units']) + if self.tree_view.has_disabled_editors: item = self.model.get_item(index) path = self.model.path_to_item(item) + [index.column()] From e1ec5ad6db01dae2f71030585ec4813845076f7c Mon Sep 17 00:00:00 2001 From: Patrick Avery Date: Mon, 10 Nov 2025 18:12:40 -0600 Subject: [PATCH 2/3] Use micrometers for particle sizes Signed-off-by: Patrick Avery --- hexrdgui/calibration/wppf_options_dialog.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/hexrdgui/calibration/wppf_options_dialog.py b/hexrdgui/calibration/wppf_options_dialog.py index 00b07e9fb..9b2fbfc50 100644 --- a/hexrdgui/calibration/wppf_options_dialog.py +++ b/hexrdgui/calibration/wppf_options_dialog.py @@ -1368,14 +1368,18 @@ def recursively_format_mat(mat, this_config, this_template): min_max_inverted = False prefix = sanitized_mat if v == f'{prefix}_X': - wlen = HexrdConfig().beam_wavelength + # Provide wavelength in micrometers + wlen = HexrdConfig().beam_wavelength / 1e4 + units = ' µm' conversion_funcs = mat_lx_to_p_funcs_factory(wlen) min_max_inverted = True elif v == f'{prefix}_Y': units = '%' conversion_funcs = mat_ly_to_s_funcs elif v == f'{prefix}_P': - wlen = HexrdConfig().beam_wavelength + # Provide wavelength in micrometers + wlen = HexrdConfig().beam_wavelength / 1e4 + units = ' µm' conversion_funcs = mat_gp_to_p_funcs_factory(wlen) min_max_inverted = True elif v in [f'{prefix}_{k}' for k in ('a', 'b', 'c')]: @@ -2503,6 +2507,7 @@ def to_display_stderr(lx: float, lx_stderr: float) -> float: def mat_gp_to_p_funcs_factory(wlen: float) -> dict: k = 0.91 + def to_display(gp: float) -> float: if abs(gp) <= 1e-8: return np.inf From f3ff2d15bb457ed6ccce5e98d1b34b9f281247ff Mon Sep 17 00:00:00 2001 From: Patrick Avery Date: Mon, 1 Dec 2025 13:08:36 -0600 Subject: [PATCH 3/3] Use same float formatting that Qt uses This was the default formatting that Qt was using before for floats. This keeps us from accidentally rounding smaller numbers to zero, like the anisotropic broadening parameters, which are often on the order of 1e-4. Signed-off-by: Patrick Avery --- hexrdgui/calibration/tree_item_models.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/hexrdgui/calibration/tree_item_models.py b/hexrdgui/calibration/tree_item_models.py index b9c8929f3..f8b5499ed 100644 --- a/hexrdgui/calibration/tree_item_models.py +++ b/hexrdgui/calibration/tree_item_models.py @@ -131,13 +131,13 @@ def data(self, index, role): config = self.config_path(path) if role == Qt.DisplayRole and config.get('_units'): - if isinstance(data, float): - # Make sure it is rounded to 3 decimal places - data = round(data, 3) - - # Don't attach units to infinity is_inf = isinstance(data, float) and np.isinf(data) + # Don't attach units to infinity if not is_inf: + if isinstance(data, float): + # Format it into a string + data = f'{data:.6g}' + data = f"{data}{config['_units']}" return data