diff --git a/hexrdgui/calibration/calibration_dialog.py b/hexrdgui/calibration/calibration_dialog.py index 3c7a3a082..432d0e600 100644 --- a/hexrdgui/calibration/calibration_dialog.py +++ b/hexrdgui/calibration/calibration_dialog.py @@ -3,7 +3,6 @@ import yaml from PySide6.QtCore import QObject, Qt, Signal -from PySide6.QtGui import QColor from PySide6.QtWidgets import QComboBox, QDoubleSpinBox, QMessageBox, QSpinBox from hexrd.fitting.calibration.lmfit_param_handling import ( @@ -12,11 +11,15 @@ ) from hexrdgui import resource_loader +from hexrdgui.calibration.tree_item_models import ( + DefaultCalibrationTreeItemModel, + DeltaCalibrationTreeItemModel, +) from hexrdgui.constants import ViewType from hexrdgui.hexrd_config import HexrdConfig from hexrdgui.pinhole_correction_editor import PinholeCorrectionEditor from hexrdgui.tree_views.multi_column_dict_tree_view import ( - MultiColumnDictTreeItemModel, MultiColumnDictTreeView + MultiColumnDictTreeView ) from hexrdgui.ui_loader import UiLoader from hexrdgui.utils.dialog import add_help_url @@ -557,97 +560,9 @@ def tree_view_columns(self): @property def tree_view_model_class(self): if self.delta_boundaries: - return DeltaTreeItemModel + return DeltaCalibrationTreeItemModel else: - return DefaultTreeItemModel - - -def _tree_columns_to_indices(columns): - return { - 'Key': 0, - **{ - k: list(columns).index(k) + 1 for k in columns - } - } - - -class TreeItemModel(MultiColumnDictTreeItemModel): - """Subclass the tree item model so we can customize some behavior""" - - def set_config_val(self, path, value): - super().set_config_val(path, value) - # Now set the parameter too - param_path = path[:-1] + ['_param'] - try: - param = self.config_val(param_path) - except KeyError: - raise Exception('Failed to set parameter!', param_path) - - # Now set the attribute on the param - attribute = path[-1].removeprefix('_') - - setattr(param, attribute, value) - - -class DefaultTreeItemModel(TreeItemModel): - """This model uses minimum/maximum for the boundary constraints""" - COLUMNS = { - 'Value': '_value', - 'Vary': '_vary', - 'Minimum': '_min', - 'Maximum': '_max', - } - COLUMN_INDICES = _tree_columns_to_indices(COLUMNS) - - VALUE_IDX = COLUMN_INDICES['Value'] - MAX_IDX = COLUMN_INDICES['Maximum'] - MIN_IDX = COLUMN_INDICES['Minimum'] - BOUND_INDICES = (VALUE_IDX, MAX_IDX, MIN_IDX) - - def data(self, index, role): - if role == Qt.ForegroundRole and index.column() in self.BOUND_INDICES: - # If a value hit the boundary, color both the boundary and the - # value red. - item = self.get_item(index) - if not item.child_items: - atol = 1e-3 - pairs = [ - (self.VALUE_IDX, self.MAX_IDX), - (self.VALUE_IDX, self.MIN_IDX), - ] - for pair in pairs: - if index.column() not in pair: - continue - - if abs(item.data(pair[0]) - item.data(pair[1])) < atol: - return QColor('red') - - return super().data(index, role) - - -class DeltaTreeItemModel(TreeItemModel): - """This model uses the delta for the parameters""" - COLUMNS = { - 'Value': '_value', - 'Vary': '_vary', - 'Delta': '_delta', - } - COLUMN_INDICES = _tree_columns_to_indices(COLUMNS) - - VALUE_IDX = COLUMN_INDICES['Value'] - DELTA_IDX = COLUMN_INDICES['Delta'] - BOUND_INDICES = (VALUE_IDX, DELTA_IDX) - - def data(self, index, role): - if role == Qt.ForegroundRole and index.column() in self.BOUND_INDICES: - # If a delta is zero, color both the delta and the value red. - item = self.get_item(index) - if not item.child_items: - atol = 1e-3 - if abs(item.data(self.DELTA_IDX)) < atol: - return QColor('red') - - return super().data(index, role) + return DefaultCalibrationTreeItemModel TILT_LABELS_EULER = { diff --git a/hexrdgui/calibration/tree_item_models.py b/hexrdgui/calibration/tree_item_models.py new file mode 100644 index 000000000..a84ee9382 --- /dev/null +++ b/hexrdgui/calibration/tree_item_models.py @@ -0,0 +1,94 @@ +from PySide6.QtCore import Qt +from PySide6.QtGui import QColor + +from hexrdgui.tree_views.multi_column_dict_tree_view import ( + MultiColumnDictTreeItemModel +) + + +def _tree_columns_to_indices(columns): + return { + 'Key': 0, + **{ + k: list(columns).index(k) + 1 for k in columns + } + } + + +class CalibrationTreeItemModel(MultiColumnDictTreeItemModel): + """Subclass the tree item model so we can customize some behavior""" + + def set_config_val(self, path, value): + super().set_config_val(path, value) + # Now set the parameter too + param_path = path[:-1] + ['_param'] + try: + param = self.config_val(param_path) + except KeyError: + raise Exception('Failed to set parameter!', param_path) + + # Now set the attribute on the param + attribute = path[-1].removeprefix('_') + + setattr(param, attribute, value) + + +class DefaultCalibrationTreeItemModel(CalibrationTreeItemModel): + """This model uses minimum/maximum for the boundary constraints""" + COLUMNS = { + 'Value': '_value', + 'Vary': '_vary', + 'Minimum': '_min', + 'Maximum': '_max', + } + COLUMN_INDICES = _tree_columns_to_indices(COLUMNS) + + VALUE_IDX = COLUMN_INDICES['Value'] + MAX_IDX = COLUMN_INDICES['Maximum'] + MIN_IDX = COLUMN_INDICES['Minimum'] + BOUND_INDICES = (VALUE_IDX, MAX_IDX, MIN_IDX) + + def data(self, index, role): + if role == Qt.ForegroundRole and index.column() in self.BOUND_INDICES: + # If a value hit the boundary, color both the boundary and the + # value red. + item = self.get_item(index) + if not item.child_items: + atol = 1e-3 + pairs = [ + (self.VALUE_IDX, self.MAX_IDX), + (self.VALUE_IDX, self.MIN_IDX), + ] + for pair in pairs: + if index.column() not in pair: + continue + + if abs(item.data(pair[0]) - item.data(pair[1])) < atol: + return QColor('red') + + return super().data(index, role) + + +class DeltaCalibrationTreeItemModel(CalibrationTreeItemModel): + """This model uses the delta for the parameters""" + COLUMNS = { + 'Value': '_value', + 'Vary': '_vary', + 'Delta': '_delta', + } + COLUMN_INDICES = _tree_columns_to_indices(COLUMNS) + + VALUE_IDX = COLUMN_INDICES['Value'] + DELTA_IDX = COLUMN_INDICES['Delta'] + BOUND_INDICES = (VALUE_IDX, DELTA_IDX) + + def data(self, index, role): + if role == Qt.ForegroundRole and index.column() in self.BOUND_INDICES: + # If a delta is zero, color both the delta and the value red. + item = self.get_item(index) + if not item.child_items: + atol = 1e-3 + if abs(item.data(self.DELTA_IDX)) < 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 03757f75e..625748a7b 100644 --- a/hexrdgui/calibration/wppf_options_dialog.py +++ b/hexrdgui/calibration/wppf_options_dialog.py @@ -1,15 +1,16 @@ +import copy from pathlib import Path import h5py import matplotlib.pyplot as plt import numpy as np +import re +import yaml -from PySide6.QtCore import Qt, QObject, QTimer, Signal -from PySide6.QtWidgets import ( - QCheckBox, QFileDialog, QHBoxLayout, QMessageBox, QSizePolicy, - QTableWidgetItem, QWidget -) +from PySide6.QtCore import QObject, Signal +from PySide6.QtWidgets import QFileDialog, QHBoxLayout, QMessageBox +from hexrdgui import resource_loader from hexrd.instrument import unwrap_dict_to_h5, unwrap_h5_to_dict from hexrd.material import _angstroms from hexrd.wppf import LeBail, Rietveld @@ -20,33 +21,32 @@ _generate_default_parameters_Rietveld, ) +from hexrdgui.calibration.tree_item_models import ( + DefaultCalibrationTreeItemModel, + DeltaCalibrationTreeItemModel, +) from hexrdgui.dynamic_widget import DynamicWidget from hexrdgui.hexrd_config import HexrdConfig -from hexrdgui.scientificspinbox import ScientificDoubleSpinBox +from hexrdgui.point_picker_dialog import PointPickerDialog from hexrdgui.select_items_dialog import SelectItemsDialog +from hexrdgui.tree_views.multi_column_dict_tree_view import ( + MultiColumnDictTreeView +) from hexrdgui.ui_loader import UiLoader from hexrdgui.utils import block_signals, clear_layout, has_nan from hexrdgui.wppf_style_picker import WppfStylePicker +import hexrdgui.resources.wppf.tree_views as tree_view_resources inverted_peakshape_dict = {v: k for k, v in peakshape_dict.items()} DEFAULT_PEAK_SHAPE = 'pvtch' -COLUMNS = { - 'name': 0, - 'value': 1, - 'minimum': 2, - 'maximum': 3, - 'vary': 4 -} - -LENGTH_SUFFIXES = ['_a', '_b', '_c'] - class WppfOptionsDialog(QObject): run = Signal() + undo_clicked = Signal() finished = Signal() def __init__(self, parent=None): @@ -59,19 +59,21 @@ def __init__(self, parent=None): self.populate_background_methods() self.populate_peakshape_methods() - self.value_spinboxes = [] - self.minimum_spinboxes = [] - self.maximum_spinboxes = [] - self.vary_checkboxes = [] - self.dynamic_background_widgets = [] + self.spline_points = [] self._wppf_object = None self._prev_background_method = None + self._undo_stack = [] + + self.params = self.generate_params() + self.initialize_tree_view() - self.reset_params() self.load_settings() + # Default setting for delta boundaries + self.delta_boundaries = False + self.update_gui() self.setup_connections() @@ -81,15 +83,19 @@ def setup_connections(self): self.ui.peak_shape.currentIndexChanged.connect(self.update_params) self.ui.background_method.currentIndexChanged.connect( self.update_background_parameters) + self.ui.delta_boundaries.toggled.connect( + self.on_delta_boundaries_toggled) self.ui.select_experiment_file_button.pressed.connect( self.select_experiment_file) self.ui.display_wppf_plot.toggled.connect( self.display_wppf_plot_toggled) self.ui.edit_plot_style.pressed.connect(self.edit_plot_style) + self.ui.pick_spline_points.clicked.connect(self.pick_spline_points) - self.ui.export_table.clicked.connect(self.export_table) - self.ui.import_table.clicked.connect(self.import_table) - self.ui.reset_table_to_defaults.clicked.connect(self.reset_params) + self.ui.export_params.clicked.connect(self.export_params) + self.ui.import_params.clicked.connect(self.import_params) + self.ui.reset_params_to_defaults.clicked.connect(self.reset_params) + self.ui.undo_last_run.clicked.connect(self.pop_undo_stack) self.ui.save_plot.pressed.connect(self.save_plot) self.ui.reset_object.pressed.connect(self.reset_object) @@ -138,6 +144,9 @@ def update_enable_states(self): enable_refinement_steps = self.method != 'Rietveld' self.ui.refinement_steps.setEnabled(enable_refinement_steps) + if not enable_refinement_steps: + # Also set the value to 1 + self.ui.refinement_steps.setValue(1) def populate_background_methods(self): self.ui.background_method.addItems(list(background_methods.keys())) @@ -225,13 +234,25 @@ def preview_spectrum(self): self.reset_object() def begin_run(self): + if self.background_method == 'spline': + points = self.background_method_dict['spline'] + if not points: + # Force points to be chosen now + self.pick_spline_points() + try: self.validate() except Exception as e: QMessageBox.critical(self.ui, 'HEXRD', str(e)) return + if self.delta_boundaries: + # If delta boundaries are being used, set the min/max according to + # the delta boundaries. Lmfit requires min/max to run. + self.apply_delta_boundaries() + self.save_settings() + self.push_undo_stack() self.run.emit() def finish(self): @@ -247,6 +268,11 @@ def validate(self): msg = 'All parameters are fixed. Need to vary at least one' raise Exception(msg) + if self.background_method == 'spline': + points = self.background_method_dict['spline'] + if not points: + raise Exception('Points must be chosen to use "spline" method') + def generate_params(self): kwargs = { 'method': self.method, @@ -257,8 +283,8 @@ def generate_params(self): return generate_params(**kwargs) def reset_params(self): - self.params = self.generate_params() - self.update_table() + self.params.param_dict = self.generate_params() + self.update_tree_view() def update_params(self): if not hasattr(self, 'params'): @@ -276,7 +302,7 @@ def update_params(self): param_dict[key] = param self.params.param_dict = param_dict - self.update_table() + self.update_tree_view() def show(self): self.ui.show() @@ -285,7 +311,7 @@ def select_materials(self): materials = self.powder_overlay_materials selected = self.selected_materials items = [(name, name in selected) for name in materials] - dialog = SelectItemsDialog(items, self.ui) + dialog = SelectItemsDialog(items, 'Select Materials', self.ui) if dialog.exec() and self.selected_materials != dialog.selected_items: self.selected_materials = dialog.selected_items self.update_params() @@ -340,6 +366,21 @@ def peak_shape(self, v): label = peakshape_dict[v] self.ui.peak_shape.setCurrentText(label) + @property + def peak_shape_tree_dict(self): + filename = f'peak_{self.peak_shape}.yml' + return load_yaml_dict(tree_view_resources, filename) + + @property + def background_tree_dict(self): + filename = f'background_{self.background_method}.yml' + return load_yaml_dict(tree_view_resources, filename) + + @property + def method_tree_dict(self): + filename = f'{self.method}.yml' + return load_yaml_dict(tree_view_resources, filename) + @property def peak_shape_index(self): return self.ui.peak_shape.currentIndex() @@ -376,6 +417,10 @@ def background_method_dict(self): if len(value) == 1: value = value[0] + if method == 'spline': + # For spline, the value is stored on self + value = self.spline_points + return {method: value} @background_method_dict.setter @@ -387,7 +432,11 @@ def background_method_dict(self, v): # Make sure these get updated (it may have already been called, but # calling it twice is not a problem) self.update_background_parameters() - if v[method]: + + if method == 'spline': + # Store the spline points on self + self.spline_points = v[method] + elif v[method]: widgets = self.dynamic_background_widgets if len(widgets) == 1: widgets[0].set_value(v[method]) @@ -399,6 +448,33 @@ def background_method_dict(self, v): # We probably need to update the parameters as well self.update_params() + def pick_spline_points(self): + if self.background_method != 'spline': + # Should not be using this method + return + + # Make a canvas with the spectrum plotted. + expt_spectrum = self.wppf_object_kwargs['expt_spectrum'] + fig, ax = plt.subplots() + ax.plot(*expt_spectrum.T, '-k') + + ax.set_xlabel(r'2$\theta$') + ax.set_ylabel(r'intensity (a.u.)') + + dialog = PointPickerDialog(fig.canvas, 'Pick Background Points', + parent=self.ui) + if not dialog.exec(): + # User canceled. + return + + # Make sure these are native types for saving + self.spline_points = ( + np.asarray([dialog.points]).tolist() if dialog.points else [] + ) + + # We must reset the WPPF object to reflect these changes + self.reset_object() + @property def limit_tth(self): return self.ui.limit_tth.isChecked() @@ -533,58 +609,11 @@ def edit_plot_style(self): dialog = WppfStylePicker(self.ui) dialog.ui.exec() - def create_label(self, v): - w = QTableWidgetItem(v) - w.setTextAlignment(Qt.AlignCenter) - return w - - def create_spinbox(self, v): - sb = ScientificDoubleSpinBox(self.ui.table) - sb.setKeyboardTracking(False) - sb.setValue(float(v)) - sb.valueChanged.connect(self.update_config) - - size_policy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - sb.setSizePolicy(size_policy) - return sb - - def create_value_spinbox(self, v): - sb = self.create_spinbox(v) - self.value_spinboxes.append(sb) - return sb - - def create_minimum_spinbox(self, v): - sb = self.create_spinbox(v) - self.minimum_spinboxes.append(sb) - return sb - - def create_maximum_spinbox(self, v): - sb = self.create_spinbox(v) - self.maximum_spinboxes.append(sb) - return sb - - def create_vary_checkbox(self, b): - cb = QCheckBox(self.ui.table) - cb.setChecked(b) - cb.toggled.connect(self.on_checkbox_toggled) - - self.vary_checkboxes.append(cb) - return self.create_table_widget(cb) - - def create_table_widget(self, w): - # These are required to center the widget... - tw = QWidget(self.ui.table) - layout = QHBoxLayout(tw) - layout.addWidget(w) - layout.setAlignment(Qt.AlignCenter) - layout.setContentsMargins(0, 0, 0, 0) - return tw - def update_gui(self): with block_signals(self.ui.display_wppf_plot): self.display_wppf_plot = HexrdConfig().display_wppf_plot self.update_background_parameters() - self.update_table() + self.update_tree_view() def update_background_parameters(self): if self.background_method == self._prev_background_method: @@ -593,6 +622,10 @@ def update_background_parameters(self): self._prev_background_method = self.background_method + # Update the visibility of this button + self.ui.pick_spline_points.setVisible( + self.background_method == 'spline') + main_layout = self.ui.background_method_parameters_layout clear_layout(main_layout) self.dynamic_background_widgets.clear() @@ -625,74 +658,251 @@ def update_background_parameters(self): # methods have parameters. self.update_params() - def clear_table(self): - self.value_spinboxes.clear() - self.minimum_spinboxes.clear() - self.maximum_spinboxes.clear() - self.vary_checkboxes.clear() - self.ui.table.clearContents() + def initialize_tree_view(self): + if hasattr(self, 'tree_view'): + # It has already been initialized + return + + tree_dict = self.tree_view_dict_of_params + self.tree_view = MultiColumnDictTreeView( + tree_dict, + self.tree_view_columns, + parent=self.parent(), + model_class=self.tree_view_model_class, + ) + self.tree_view.check_selection_index = 2 + self.ui.tree_view_layout.addWidget(self.tree_view) - def update_table(self): - table = self.ui.table + # Make the key section a little larger + self.tree_view.header().resizeSection(0, 300) + def reinitialize_tree_view(self): # Keep the same scroll position - scrollbar = table.verticalScrollBar() + scrollbar = self.tree_view.verticalScrollBar() scroll_value = scrollbar.value() - with block_signals(table): - self.clear_table() - self.ui.table.setRowCount(len(self.params.param_dict)) - for i, (key, param) in enumerate(self.params.param_dict.items()): - name = param.name - w = self.create_label(name) - table.setItem(i, COLUMNS['name'], w) - - w = self.create_value_spinbox(self.convert(name, param.value)) - table.setCellWidget(i, COLUMNS['value'], w) - - w = self.create_minimum_spinbox(self.convert(name, param.lb)) - w.setEnabled(param.vary) - table.setCellWidget(i, COLUMNS['minimum'], w) - - w = self.create_maximum_spinbox(self.convert(name, param.ub)) - w.setEnabled(param.vary) - table.setCellWidget(i, COLUMNS['maximum'], w) - - w = self.create_vary_checkbox(param.vary) - table.setCellWidget(i, COLUMNS['vary'], w) - - # During event processing, it looks like the scrollbar gets resized - # so its maximum is one less than one it actually is. Thus, if we - # set the value to the maximum right now, it will end up being one - # less than the actual maximum. - # Thus, we need to post an event to the event loop to set the - # scroll value after the other event processing. This works, but - # the UI still scrolls back one and then to the maximum. So it - # doesn't look that great. FIXME: figure out how to fix this. - QTimer.singleShot(0, lambda: scrollbar.setValue(scroll_value)) - - def on_checkbox_toggled(self): - self.update_min_max_enable_states() - self.update_config() - - def update_min_max_enable_states(self): - for i in range(len(self.params.param_dict)): - enable = self.vary_checkboxes[i].isChecked() - self.minimum_spinboxes[i].setEnabled(enable) - self.maximum_spinboxes[i].setEnabled(enable) - - def update_config(self): - for i, (name, param) in enumerate(self.params.param_dict.items()): - if any(name.endswith(x) for x in LENGTH_SUFFIXES): - # Convert from angstrom to nm for WPPF - multiplier = 0.1 + self.ui.tree_view_layout.removeWidget(self.tree_view) + self.tree_view.deleteLater() + del self.tree_view + self.initialize_tree_view() + + # Restore scroll bar position + self.tree_view.verticalScrollBar().setValue(scroll_value) + + def update_tree_view(self): + tree_dict = self.tree_view_dict_of_params + self.tree_view.model().config = tree_dict + self.tree_view.reset_gui() + + @property + def tree_view_dict_of_params(self): + params_dict = self.params.param_dict + + tree_dict = {} + template_dict = self.tree_view_mapping + + # Keep track of which params have been used. + used_params = [] + + def create_param_item(param): + used_params.append(param.name) + d = { + '_param': param, + '_value': param.value, + '_vary': bool(param.vary), + } + if self.delta_boundaries: + if not hasattr(param, 'delta'): + # We store the delta on the param object + # Default the delta to the minimum of the differences + diffs = [ + abs(param.min - param.value), + abs(param.max - param.value), + ] + param.delta = min(diffs) + + d['_delta'] = param.delta else: - multiplier = 1 + d.update(**{ + '_min': param.min, + '_max': param.max, + }) + return d + + # Treat these root keys specially + special_cases = [ + 'Materials', + ] + + def recursively_set_items(this_config, this_template): + param_set = False + for k, v in this_template.items(): + if k in special_cases: + # Skip over it + continue + + if isinstance(v, dict): + this_config.setdefault(k, {}) + if recursively_set_items(this_config[k], v): + param_set = True + else: + # Pop this key if no param was set + this_config.pop(k) + else: + # Assume it is a string. Grab it if in the params dict. + if v in params_dict: + this_config[k] = create_param_item(params_dict[v]) + param_set = True + + return param_set + + # First, recursively set items (except special cases) + recursively_set_items(tree_dict, template_dict) + + # If the background method is chebyshev, fill those in + if self.background_method == 'chebyshev': + # Put the background first + tree_dict = {'Background': {}, **tree_dict} + background = tree_dict['Background'] + i = 0 + while f'bkg_{i}' in params_dict: + background[i] = create_param_item(params_dict[f'bkg_{i}']) + i += 1 + + # Now generate the materials + materials_template = template_dict['Materials'].pop('{mat}') + + def recursively_format_site_id(mat, site_id, this_config, + this_template): + for k, v in this_template.items(): + if isinstance(v, dict): + this_config.setdefault(k, {}) + recursively_format_site_id(mat, site_id, this_config[k], v) + else: + # Should be a string. Replace {mat} and {site_id} if needed + kwargs = {} + if '{mat}' in v: + kwargs['mat'] = mat + + if '{site_id}' in v: + kwargs['site_id'] = site_id + + if kwargs: + v = v.format(**kwargs) + + if v in params_dict: + this_config[k] = create_param_item(params_dict[v]) + + def recursively_format_mat(mat, this_config, this_template): + for k, v in this_template.items(): + if k == 'Atomic Site: {site_id}': + # Identify all site IDs by regular expression + expr = re.compile(f'^{mat}_(.*)_x$') + site_ids = [] + for name in params_dict: + m = expr.match(name) + if m: + site_id = m.group(1) + if site_id not in site_ids: + site_ids.append(site_id) + + for site_id in site_ids: + new_k = k.format(site_id=site_id) + this_config.setdefault(new_k, {}) + recursively_format_site_id( + mat, + site_id, + this_config[new_k], + v, + ) + elif isinstance(v, dict): + this_config.setdefault(k, {}) + recursively_format_mat(mat, this_config[k], v) + else: + # Should be a string. Replace {mat} if needed + if '{mat}' in v: + v = v.format(mat=mat) + + if v in params_dict: + this_config[k] = create_param_item(params_dict[v]) + + mat_dict = tree_dict.setdefault('Materials', {}) + for mat in self.selected_materials: + this_config = mat_dict.setdefault(mat, {}) + this_template = copy.deepcopy(materials_template) + + # For the parameters, we need to convert dashes to underscores + mat = mat.replace('-', '_') + recursively_format_mat(mat, this_config, this_template) + + # Now all keys should have been used. Verify this is true. + if sorted(used_params) != sorted(list(params_dict)): + used = ', '.join(sorted(used_params)) + params = ', '.join(sorted(params_dict)) + msg = ( + f'Internal error: used_params ({used})\n\ndid not match ' + f'params_dict! ({params})' + ) + raise Exception(msg) + + return tree_dict + + @property + def tree_view_mapping(self): + # This will always be a deep copy, so we can modify. + method_dict = self.method_tree_dict + + # Insert the background and peak shape dicts too + method_dict['Background'] = self.background_tree_dict + method_dict['Instrumental Parameters']['Peak Parameters'] = ( + self.peak_shape_tree_dict + ) + + return method_dict + + @property + def tree_view_columns(self): + return self.tree_view_model_class.COLUMNS - param.value = self.value_spinboxes[i].value() * multiplier - param.lb = self.minimum_spinboxes[i].value() * multiplier - param.ub = self.maximum_spinboxes[i].value() * multiplier - param.vary = self.vary_checkboxes[i].isChecked() + @property + def tree_view_model_class(self): + if self.delta_boundaries: + return DeltaCalibrationTreeItemModel + else: + return DefaultCalibrationTreeItemModel + + @property + def delta_boundaries(self): + return self.ui.delta_boundaries.isChecked() + + @delta_boundaries.setter + def delta_boundaries(self, b): + self.ui.delta_boundaries.setChecked(b) + + def on_delta_boundaries_toggled(self, b): + # The columns have changed, so we need to reinitialize the tree view + self.reinitialize_tree_view() + + def apply_delta_boundaries(self): + # lmfit only uses min/max, not delta + # So if we used a delta, apply that to the min/max + + if not self.delta_boundaries: + # We don't actually need to apply delta boundaries... + return + + def recurse(cur): + for k, v in cur.items(): + if '_param' in v: + param = v['_param'] + # There should be a delta. + # We want an exception if it is missing. + param.min = param.value - param.delta + param.max = param.value + param.delta + elif isinstance(v, dict): + recurse(v) + + recurse(self.tree_view.model().config) @property def all_widgets(self): @@ -702,18 +912,10 @@ def all_widgets(self): 'peak_shape', 'background_method', 'experiment_file', - 'table', 'display_wppf_plot', ] return [getattr(self.ui, x) for x in names] - def convert(self, name, val): - # Check if we need to convert this data to other units - if any(name.endswith(x) for x in LENGTH_SUFFIXES): - # Convert from nm to Angstroms - return val * 10.0 - return val - @property def wppf_object(self): if self._wppf_object is None: @@ -748,8 +950,8 @@ def wppf_object_kwargs(self): else: x, y = HexrdConfig().last_unscaled_azimuthal_integral_data if isinstance(y, np.ma.MaskedArray): - # Fill any masked values with zero - y = y.filled(0) + # Fill any masked values with nan + y = y.filled(np.nan) # Re-format it to match the expected input format expt_spectrum = np.array(list(zip(x, y))) @@ -778,7 +980,9 @@ def wppf_object_kwargs(self): return { 'expt_spectrum': expt_spectrum, 'params': self.params, - 'phases': self.materials, + # Make a deep copy of the materials so that WPPF + # won't modify any arrays in place shared by our materials. + 'phases': copy.deepcopy(self.materials), 'wavelength': wavelength, 'bkgmethod': self.background_method_dict, 'peakshape': self.peak_shape, @@ -799,16 +1003,16 @@ def update_wppf_object(self): setattr(obj, key, val) - def export_table(self): + def export_params(self): selected_file, selected_filter = QFileDialog.getSaveFileName( - self.ui, 'Export Table', HexrdConfig().working_dir, + self.ui, 'Export Parameters', HexrdConfig().working_dir, 'HDF5 files (*.h5 *.hdf5)') if selected_file: HexrdConfig().working_dir = str(Path(selected_file).parent) - return self.export_params(selected_file) + return self.save_params(selected_file) - def export_params(self, filename): + def save_params(self, filename): filename = Path(filename) if filename.exists(): filename.unlink() @@ -819,16 +1023,16 @@ def export_params(self, filename): with h5py.File(filename, 'w') as wf: unwrap_dict_to_h5(wf, export_data) - def import_table(self): + def import_params(self): selected_file, selected_filter = QFileDialog.getOpenFileName( - self.ui, 'Import Table', HexrdConfig().working_dir, + self.ui, 'Import Parameters', HexrdConfig().working_dir, 'HDF5 files (*.h5 *.hdf5)') if selected_file: HexrdConfig().working_dir = str(Path(selected_file).parent) - return self.import_params(selected_file) + return self.load_params(selected_file) - def import_params(self, filename): + def load_params(self, filename): filename = Path(filename) if not filename.exists(): raise FileNotFoundError(filename) @@ -856,7 +1060,7 @@ def to_native_bools(d): for key in self.params.param_dict.keys(): self.params[key] = dict_to_param(import_params[key]) - self.update_table() + self.update_tree_view() def validate_import_params(self, import_params, filename): here = self.params.param_dict.keys() @@ -894,6 +1098,42 @@ def validate_import_params(self, import_params, filename): QMessageBox.critical(self.ui, 'HEXRD', msg) raise Exception(msg) + def push_undo_stack(self): + settings = HexrdConfig().config['calibration'].get('wppf', {}) + + stack_item = { + 'settings': settings, + 'spline_points': self.spline_points, + 'method': self.method, + 'refinement_steps': self.refinement_steps, + 'peak_shape': self.peak_shape, + 'background_method': self.background_method, + 'selected_materials': self.selected_materials, + '_wppf_object': self._wppf_object, + 'params': self.params, + } + + stack_item = {k: copy.deepcopy(v) for k, v in stack_item.items()} + + self._undo_stack.append(stack_item) + self.update_undo_enable_state() + + def pop_undo_stack(self): + stack_item = self._undo_stack.pop(-1) + + for k, v in stack_item.items(): + setattr(self, k, v) + + self.save_settings() + self.update_undo_enable_state() + self.update_enable_states() + self.update_tree_view() + + self.undo_clicked.emit() + + def update_undo_enable_state(self): + self.ui.undo_last_run.setEnabled(len(self._undo_stack) > 0) + def generate_params(method, materials, peak_shape, bkgmethod): func_dict = { @@ -920,6 +1160,18 @@ def dict_to_param(d): return Parameter(**d) +LOADED_YAML_DICTS = {} + + +def load_yaml_dict(module, filename): + key = (module.__name__, filename) + if key not in LOADED_YAML_DICTS: + text = resource_loader.load_resource(module, filename) + LOADED_YAML_DICTS[key] = yaml.safe_load(text) + + return copy.deepcopy(LOADED_YAML_DICTS[key]) + + if __name__ == '__main__': from PySide6.QtWidgets import QApplication diff --git a/hexrdgui/calibration/wppf_runner.py b/hexrdgui/calibration/wppf_runner.py index 5f4df0168..37611c23d 100644 --- a/hexrdgui/calibration/wppf_runner.py +++ b/hexrdgui/calibration/wppf_runner.py @@ -12,9 +12,11 @@ class WppfRunner: def __init__(self, parent=None): self.parent = parent + self.undo_stack = [] def clear(self): self.wppf_options_dialog = None + self.undo_stack.clear() def run(self): self.validate() @@ -35,6 +37,7 @@ def visible_powder_overlays(self): def select_options(self): dialog = WppfOptionsDialog(self.parent) dialog.run.connect(self.run_wppf) + dialog.undo_clicked.connect(self.pop_undo_stack) dialog.finished.connect(self.clear) dialog.show() self.wppf_options_dialog = dialog @@ -53,7 +56,8 @@ def run_wppf(self): refine_func() self.rerender_wppf() - self.write_lattice_params_to_materials() + self.push_undo_stack() + self.write_params_to_materials() self.update_param_values() def rerender_wppf(self): @@ -65,15 +69,15 @@ def rerender_wppf(self): # calls to the event loop in the future instead. QCoreApplication.processEvents() - def write_lattice_params_to_materials(self): + def write_params_to_materials(self): for name, wppf_mat in self.wppf_object.phases.phase_dict.items(): mat = HexrdConfig().material(name) # Work around differences in WPPF objects if isinstance(self.wppf_object, Rietveld): - lparms = wppf_mat['synchrotron'].lparms - else: - lparms = wppf_mat.lparms + wppf_mat = wppf_mat['synchrotron'] + + lparms = wppf_mat.lparms # Convert units from nm to angstroms lparms = copy.deepcopy(lparms) @@ -81,6 +85,38 @@ def write_lattice_params_to_materials(self): lparms[i] *= 10.0 mat.latticeParameters = lparms + mat.atominfo[:] = wppf_mat.atom_pos + mat.U[:] = wppf_mat.U + + HexrdConfig().flag_overlay_updates_for_material(name) + HexrdConfig().material_modified.emit(name) + + HexrdConfig().overlay_config_changed.emit() + + def push_undo_stack(self): + # Save the previous material parameters + mat_params = {} + for name in self.wppf_object.phases.phase_dict: + mat = HexrdConfig().material(name) + mat_params[name] = { + 'lparms': mat.lparms, + 'atominfo': mat.atominfo, + 'U': mat.U, + } + + # Make a deep copy of all parameters + self.undo_stack.append(copy.deepcopy(mat_params)) + + def pop_undo_stack(self): + entry = self.undo_stack.pop() + + for name, mat_params in entry.items(): + mat = HexrdConfig().material(name) + + mat.lparms = mat_params['lparms'] + mat.atominfo[:] = mat_params['atominfo'] + mat.U[:] = mat_params['U'] + HexrdConfig().flag_overlay_updates_for_material(name) HexrdConfig().material_modified.emit(name) diff --git a/hexrdgui/image_canvas.py b/hexrdgui/image_canvas.py index 774552737..b1174b332 100644 --- a/hexrdgui/image_canvas.py +++ b/hexrdgui/image_canvas.py @@ -1460,6 +1460,12 @@ def update_wppf_plot(self): self.wppf_plot = axis.scatter(*wppf_data, **style) + # Rescale. + # This actually ignores the scatter plot data when rescaling, + # which is fine. We will stay zoomed in on the line. + axis.relim() + axis.autoscale_view(scalex=False) + def detector_axis(self, detector_name): if self.mode == ViewType.raw: if HexrdConfig().stitch_raw_roi_images: diff --git a/hexrdgui/point_picker_dialog.py b/hexrdgui/point_picker_dialog.py new file mode 100644 index 000000000..eb16e13eb --- /dev/null +++ b/hexrdgui/point_picker_dialog.py @@ -0,0 +1,138 @@ +from matplotlib.backends.backend_qtagg import NavigationToolbar2QT +import matplotlib.pyplot as plt + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QDialog, QDialogButtonBox, QLabel, QVBoxLayout + + +class PointPickerDialog(QDialog): + def __init__(self, canvas, window_title='Pick Points', parent=None): + super().__init__(parent) + + if len(canvas.figure.get_axes()) != 1: + raise NotImplementedError('Only one axis is currently supported') + + self.setWindowTitle(window_title) + + layout = QVBoxLayout(self) + self.setLayout(layout) + + label = QLabel( + 'Left-click to add points, right-click to remove points' + ) + layout.addWidget(label) + layout.setAlignment(label, Qt.AlignHCenter) + + self.canvas = canvas + layout.addWidget(canvas) + + self.toolbar = NavigationToolbar2QT(canvas, self) + layout.addWidget(self.toolbar) + layout.setAlignment(self.toolbar, Qt.AlignHCenter) + + # Add a button box for accept/cancel + buttons = QDialogButtonBox.Ok | QDialogButtonBox.Cancel + self.button_box = QDialogButtonBox(buttons, self) + layout.addWidget(self.button_box) + + self.points = [] + self.scatter_artist = self.axis.scatter([], [], c='r', marker='x') + + # Default size + self.resize(800, 600) + + self.setup_connections() + + def setup_connections(self): + self.pick_event_id = self.canvas.mpl_connect( + 'button_press_event', self.point_picked) + + self.button_box.accepted.connect(self.accept) + self.button_box.rejected.connect(self.reject) + + self.finished.connect(self.on_finished) + + def on_finished(self): + # Perform any needed cleanup + self.disconnect() + + if self.scatter_artist is not None: + self.scatter_artist.remove() + self.scatter_artist = None + + def disconnect(self): + self.canvas.mpl_disconnect(self.pick_event_id) + + @property + def figure(self): + return self.canvas.figure + + @property + def axis(self): + # We currently assume only one axis + return self.figure.get_axes()[0] + + def point_picked(self, event): + if event.button == 3: + # Right-click removes points + self.undo_point() + return + + if event.button != 1: + # Ignore anything other than left-click at this point. + return + + if event.inaxes is None: + # The axis was not clicked. Ignore. + return + + if self.axis.get_navigate_mode() is not None: + # Zooming or panning is active. Ignore this point. + return + + self.points.append((event.xdata, event.ydata)) + self.update_scatter_plot() + + def undo_point(self): + if not self.points: + return + + self.points.pop() + self.update_scatter_plot() + + def update_scatter_plot(self): + # We unfortunately cannot set an empty list. So do nans instead. + points = self.points if self.points else [np.nan, np.nan] + self.scatter_artist.set_offsets(points) + self.canvas.draw_idle() + + +if __name__ == '__main__': + import sys + + import numpy as np + + from PySide6.QtWidgets import QApplication + + app = QApplication(sys.argv) + + # spectrum = np.load('spectrum.npy') + + # Generate 4 sine waves for a test + length = np.pi * 2 * 4 + num_points = 1000 + spectrum = np.vstack(( + np.arange(num_points), + np.sin(np.arange(0, length, length / num_points)) * 50 + 50, + )).T + + fig, ax = plt.subplots() + ax.plot(*spectrum.T, '-k') + + ax.set_xlabel(r'2$\theta$') + ax.set_ylabel(r'intensity (a.u.)') + + dialog = PointPickerDialog(fig.canvas) + dialog.show() + dialog.accepted.connect(lambda: print('Accepted:', dialog.points)) + app.exec() diff --git a/hexrdgui/resources/ui/wppf_options_dialog.ui b/hexrdgui/resources/ui/wppf_options_dialog.ui index ec0ca4e9d..7c1f20a4b 100644 --- a/hexrdgui/resources/ui/wppf_options_dialog.ui +++ b/hexrdgui/resources/ui/wppf_options_dialog.ui @@ -6,14 +6,154 @@ 0 0 - 635 - 703 + 1000 + 1000 WPPF Options Dialog + + + + Pick Spline Points + + + + + + + Parameter Settings + + + + + + Export + + + + + + + Import + + + + + + + Reset to Defaults + + + + + + + + + + + + + WPPF Method: + + + + + + + false + + + + + + + + + + + + Display WPPF plot in polar view? + + + + + + + Edit Plot Style + + + + + + + + + + + Limit 2θ? + + + + + + + false + + + false + + + ° + + + 8 + + + 100000.000000000000000 + + + + + + + false + + + + 0 + 0 + + + + - + + + + + + + false + + + false + + + ° + + + 8 + + + 100000.000000000000000 + + + + + @@ -28,65 +168,30 @@ - - - - <html><head/><body><p>Only materials with powder overlays can be selected</p></body></html> - + + + + + - Select Materials + Use Experiment File - - + + - Refinement Steps: + Background Method: - - - - QAbstractItemView::NoEditTriggers + + + + <html><head/><body><p>Only materials with powder overlays can be selected</p></body></html> - - true - - - - Name - - - - - Value - - - - - Minimum - - - - - Maximum - - - - - Vary - - - - - - - - - - WPPF Method: + Select Materials @@ -103,35 +208,14 @@ - - - - - - Display WPPF plot in polar view? - - - - - - - Edit Plot Style - - - - - - - + + - Peak shape: + Refinement Steps: - - - - + @@ -185,6 +269,16 @@ + + + + false + + + Undo Run + + + @@ -204,137 +298,35 @@ - - - - false - + + - Select File + Peak shape: - - - - Table Settings - - - - - - Export - - - - - - - Import - - - - - - - Reset to Defaults - - - - - - - - - - - - Limit 2θ? - - - - - - - false - - - false - - - ° - - - 8 - - - 100000.000000000000000 - - - - - - - false - - - - 0 - 0 - - - - - - - - - - - - false - - - false - - - ° - - - 8 - - - 100000.000000000000000 - - - - - - - - - - + + - Background Method: + Use delta boundaries - - + + false - + Select File - - - - Use Experiment File - - + + + + + @@ -351,6 +343,7 @@ refinement_steps peak_shape background_method + pick_spline_points use_experiment_file experiment_file select_experiment_file_button @@ -359,7 +352,10 @@ max_tth display_wppf_plot edit_plot_style - table + delta_boundaries + export_params + import_params + reset_params_to_defaults save_plot reset_object preview_spectrum diff --git a/hexrdgui/resources/wppf/tree_views/LeBail.yml b/hexrdgui/resources/wppf/tree_views/LeBail.yml new file mode 100644 index 000000000..2feb4ee0d --- /dev/null +++ b/hexrdgui/resources/wppf/tree_views/LeBail.yml @@ -0,0 +1,43 @@ +Background: # Filled in automatically +Instrumental Parameters: + Zero Point Error (Bragg-Brentano): 'zero_error' + Sample Displacement (Bragg-Brentano): 'shft' + Transparency Coefficient (Bragg-Brentano): 'trns' + Peak Broadening: + U: 'U' + V: 'V' + W: 'W' + Peak Parameters: # Filled in automatically +Materials: + '{mat}': + Stacking Fault: + α: '{mat}_sf_alpha' + β: '{mat}_twin_beta' + Peak Broadening: + Lorentzian Scherrer Broadening: '{mat}_X' + Gaussian Scherrer Broadening: '{mat}_P' + Microstrain: '{mat}_Y' + Lattice Constants: + a: '{mat}_a' + b: '{mat}_b' + c: '{mat}_c' + α: '{mat}_alpha' + β: '{mat}_beta' + γ: '{mat}_gamma' + Anisotropic Broadening: + Mixing Coefficient: '{mat}_eta_fwhm' + S₄₀₀: '{mat}_s400' + S₀₄₀: '{mat}_s040' + S₀₀₄: '{mat}_s004' + S₂₂₀: '{mat}_s220' + S₂₀₂: '{mat}_s202' + S₀₂₂: '{mat}_s022' + S₃₁₀: '{mat}_s310' + S₁₀₃: '{mat}_s103' + S₀₃₁: '{mat}_s031' + S₁₃₀: '{mat}_s130' + S₃₀₁: '{mat}_s301' + S₀₁₃: '{mat}_s013' + S₂₁₁: '{mat}_s211' + S₁₂₁: '{mat}_s121' + S₁₁₂: '{mat}_s112' diff --git a/hexrdgui/resources/wppf/tree_views/Rietveld.yml b/hexrdgui/resources/wppf/tree_views/Rietveld.yml new file mode 100644 index 000000000..a2200e9e5 --- /dev/null +++ b/hexrdgui/resources/wppf/tree_views/Rietveld.yml @@ -0,0 +1,54 @@ +Background: # Filled in automatically +Scale Factor: 'scale' +X-Ray Source: + Horizontal Polarization: 'Ph' +Instrumental Parameters: + Zero Point Error (Bragg-Brentano): 'zero_error' + Sample Displacement (Bragg-Brentano): 'shft' + Transparency Coefficient (Bragg-Brentano): 'trns' + Peak Broadening: + U: 'U' + V: 'V' + W: 'W' + Peak Parameters: # Filled in automatically +Materials: + '{mat}': + Stacking Fault: + α: '{mat}_sf_alpha' + β: '{mat}_twin_beta' + Phase Fraction: '{mat}_phase_fraction' + Peak Broadening: + Lorentzian Scherrer Broadening: '{mat}_X' + Gaussian Scherrer Broadening: '{mat}_P' + Microstrain: '{mat}_Y' + Lattice Constants: + a: '{mat}_a' + b: '{mat}_b' + c: '{mat}_c' + α: '{mat}_alpha' + β: '{mat}_beta' + γ: '{mat}_gamma' + 'Atomic Site: {site_id}': + Fractional Atom Positions: + X: '{mat}_{site_id}_x' + Y: '{mat}_{site_id}_y' + Z: '{mat}_{site_id}_z' + Site Occupation: '{mat}_{site_id}_occ' + Debye-Waller Factor: '{mat}_{site_id}_dw' + Anisotropic Broadening: + Mixing Coefficient: '{mat}_eta_fwhm' + S₄₀₀: '{mat}_s400' + S₀₄₀: '{mat}_s040' + S₀₀₄: '{mat}_s004' + S₂₂₀: '{mat}_s220' + S₂₀₂: '{mat}_s202' + S₀₂₂: '{mat}_s022' + S₃₁₀: '{mat}_s310' + S₁₀₃: '{mat}_s103' + S₀₃₁: '{mat}_s031' + S₁₃₀: '{mat}_s130' + S₃₀₁: '{mat}_s301' + S₀₁₃: '{mat}_s013' + S₂₁₁: '{mat}_s211' + S₁₂₁: '{mat}_s121' + S₁₁₂: '{mat}_s112' diff --git a/hexrdgui/resources/wppf/tree_views/background_chebyshev.yml b/hexrdgui/resources/wppf/tree_views/background_chebyshev.yml new file mode 100644 index 000000000..c1c467062 --- /dev/null +++ b/hexrdgui/resources/wppf/tree_views/background_chebyshev.yml @@ -0,0 +1 @@ +i: 'bkg_{i}' diff --git a/hexrdgui/resources/wppf/tree_views/background_snip1d.yml b/hexrdgui/resources/wppf/tree_views/background_snip1d.yml new file mode 100644 index 000000000..e69de29bb diff --git a/hexrdgui/resources/wppf/tree_views/background_spline.yml b/hexrdgui/resources/wppf/tree_views/background_spline.yml new file mode 100644 index 000000000..e69de29bb diff --git a/hexrdgui/resources/wppf/tree_views/peak_pvfcj.yml b/hexrdgui/resources/wppf/tree_views/peak_pvfcj.yml new file mode 100644 index 000000000..bb4711469 --- /dev/null +++ b/hexrdgui/resources/wppf/tree_views/peak_pvfcj.yml @@ -0,0 +1,3 @@ +Axial Divergence: + H/L: 'HL' + S/L: 'SL' diff --git a/hexrdgui/resources/wppf/tree_views/peak_pvpink.yml b/hexrdgui/resources/wppf/tree_views/peak_pvpink.yml new file mode 100644 index 000000000..0362e2fe5 --- /dev/null +++ b/hexrdgui/resources/wppf/tree_views/peak_pvpink.yml @@ -0,0 +1,5 @@ +peak asymmetry: + α₀: 'alpha0' + α₁: 'alpha1' + β₀: 'beta0' + β₁: 'beta1' diff --git a/hexrdgui/resources/wppf/tree_views/peak_pvtch.yml b/hexrdgui/resources/wppf/tree_views/peak_pvtch.yml new file mode 100644 index 000000000..e69de29bb diff --git a/hexrdgui/select_items_dialog.py b/hexrdgui/select_items_dialog.py index bbeb96d11..98cd9b111 100644 --- a/hexrdgui/select_items_dialog.py +++ b/hexrdgui/select_items_dialog.py @@ -5,9 +5,10 @@ class SelectItemsDialog(QDialog): - def __init__(self, items, parent=None): + def __init__(self, items, window_title='Select Items', parent=None): super().__init__(parent) + self.setWindowTitle(window_title) self.setLayout(QVBoxLayout(self)) self.select_items_widget = SelectItemsWidget(items, parent)