diff --git a/hexrdgui/calibration/wppf_options_dialog.py b/hexrdgui/calibration/wppf_options_dialog.py index 9d5cb77dc..df6ca4ef7 100644 --- a/hexrdgui/calibration/wppf_options_dialog.py +++ b/hexrdgui/calibration/wppf_options_dialog.py @@ -45,7 +45,7 @@ from hexrd.wppf.tds import ( VALID_SGNUMS as VALID_TDS_SGNUMS, ) -from hexrd.wppf.texture import HarmonicModel +from hexrd.wppf.texture import HarmonicModel, MarchDollaseModel from hexrd.wppf.WPPF import peakshape_dict from hexrd.wppf.wppfsupport import ( _generate_default_parameters_LeBail, @@ -81,6 +81,13 @@ DEFAULT_PEAK_SHAPE = 'pvtch' +AXIS_DISTRIBUTION_MODEL = 'axis_distribution' +MARCH_DOLLASE_MODEL = 'march_dollase' +TEXTURE_MODEL_LABELS = { + AXIS_DISTRIBUTION_MODEL: 'General Axis Distribution Function', + MARCH_DOLLASE_MODEL: 'March-Dollase', +} + class WppfOptionsDialog(QObject): run = Signal() @@ -462,7 +469,7 @@ def begin_run(self) -> None: self.save_settings() self.push_undo_stack() - if self.varying_texture_params: + if self.varying_harmonic_texture_params: # Ensure texture data is set on the WPPF object. # This might be time-consuming. # We also have to ensure there is a WPPF object @@ -515,8 +522,8 @@ def validate(self) -> None: if self.varying_texture_and_non_texture_params: msg = ( - 'Texture parameters cannot be varied at the same time as ' - 'non-texture parameters.' + 'Harmonic texture parameters cannot be varied at the same ' + 'time as other parameters.' ) raise Exception(msg) @@ -1577,6 +1584,14 @@ def recursively_format_mat( for mat_name in self.textured_materials: # Look for param names that match mat_name_sanitized = mat_name.replace('-', '_') + model_type = self.texture_model_type(mat_name) + if model_type == MARCH_DOLLASE_MODEL: + name = f'{mat_name_sanitized}_p_md' + if name in params: + mat_config = texture_dict.setdefault(mat_name, {}) + mat_config['P_md'] = create_param_item(params[name]) + continue + prefix = f'{mat_name_sanitized}_c_' matching_names = [k for k in params if k.startswith(prefix)] if not matching_names: @@ -2114,8 +2129,18 @@ def update_undo_enable_state(self) -> None: def texture_material_setting_widgets(self) -> list: return [ self.ui.include_texture_model, + self.ui.texture_model_type, self.ui.texture_ell_max, self.ui.texture_sample_symmetry, + *self.texture_preferred_axis_widgets, + ] + + @property + def texture_preferred_axis_widgets(self) -> list: + return [ + self.ui.texture_preferred_axis_h, + self.ui.texture_preferred_axis_k, + self.ui.texture_preferred_axis_l, ] @property @@ -2155,8 +2180,23 @@ def update_texture_model_gui(self) -> None: with block_signals(*self.texture_material_setting_widgets): self.ui.include_texture_model.setChecked(checked) - self.ui.texture_sample_symmetry.setCurrentText(settings['ssym']) - self.ui.texture_ell_max.setValue(settings['ell_max']) + model_type = self.texture_model_type(mat_name) + self.ui.texture_model_type.setCurrentText( + TEXTURE_MODEL_LABELS.get( + model_type, + TEXTURE_MODEL_LABELS[AXIS_DISTRIBUTION_MODEL], + ) + ) + defaults = self._default_texture_model_settings + self.ui.texture_sample_symmetry.setCurrentText( + settings.get('ssym', defaults['ssym']) + ) + self.ui.texture_ell_max.setValue( + settings.get('ell_max', defaults['ell_max']) + ) + hkl = self.texture_preferred_axes.get(mat_name, [1, 1, 1]) + for widget, value in zip(self.texture_preferred_axis_widgets, hkl): + widget.setValue(value) self.update_texture_model_enable_states() self.update_texture_index_label() @@ -2175,17 +2215,42 @@ def update_texture_model_enable_states(self) -> None: # Now enable/disable all the other widgets enable = w.isChecked() and not has_object - widgets = [ + for child in [ + self.ui.texture_model_type_label, + self.ui.texture_model_type, + ]: + child.setEnabled(enable) + + is_march_dollase = self.current_texture_model_type == MARCH_DOLLASE_MODEL + harmonic_widgets = [ self.ui.texture_sample_symmetry_label, self.ui.texture_sample_symmetry, self.ui.texture_ell_max_label, self.ui.texture_ell_max, ] + march_dollase_widgets = [ + self.ui.texture_preferred_axis_label, + self.ui.texture_preferred_axis_h_label, + self.ui.texture_preferred_axis_k_label, + self.ui.texture_preferred_axis_l_label, + *self.texture_preferred_axis_widgets, + ] - for w in widgets: - w.setEnabled(enable) + for child in harmonic_widgets: + child.setVisible(not is_march_dollase) + child.setEnabled(enable) - self.ui.spectrum_binning_group.setEnabled(is_rietveld) + for child in march_dollase_widgets: + child.setVisible(is_march_dollase) + child.setEnabled(enable) + + only_march_dollase = self.includes_texture and all( + self.texture_model_type(name) == MARCH_DOLLASE_MODEL + for name in self.textured_materials + ) + self.ui.spectrum_binning_group.setEnabled( + is_rietveld and not only_march_dollase + ) # Now figure out if we should enable pole figure plotting self.ui.texture_plot_pole_figures.setEnabled(self.can_plot_pole_figures) @@ -2194,28 +2259,58 @@ def on_texture_material_setting_changed(self) -> None: mat_name = self.ui.selected_texture_material.currentText() checked = self.ui.include_texture_model.isChecked() model_kwargs = self.texture_model_kwargs + model_types = self.texture_model_types + preferred_axes = self.texture_preferred_axes if not checked: - if mat_name in model_kwargs: - del model_kwargs[mat_name] + model_kwargs.pop(mat_name, None) + model_types.pop(mat_name, None) + preferred_axes.pop(mat_name, None) else: - ssym = self.ui.texture_sample_symmetry.currentText() - # Force ell_max to be an even number - ell_max = self.ui.texture_ell_max.value() - if ell_max % 2 != 0: - msg = 'Spherical harmonic max must be an even number' - QMessageBox.critical(self.ui, 'HEXRD', msg) - print(msg, file=sys.stderr) - - ell_max += 1 - w = self.ui.texture_ell_max - with block_signals(w): - w.setValue(ell_max) - - model_kwargs[mat_name] = { - 'ssym': ssym, - 'ell_max': ell_max, - } + model_type = self.current_texture_model_type + model_types[mat_name] = model_type + if model_type == MARCH_DOLLASE_MODEL: + hkl = [widget.value() for widget in self.texture_preferred_axis_widgets] + if not any(hkl): + QMessageBox.critical( + self.ui, + 'Invalid Preferred Axis', + 'Preferred axis cannot be 0 0 0.', + ) + with block_signals(*self.texture_preferred_axis_widgets): + for widget, value in zip( + self.texture_preferred_axis_widgets, + preferred_axes.get(mat_name, [1, 1, 1]), + ): + widget.setValue(value) + return + + preferred_axes[mat_name] = hkl + model_kwargs.setdefault( + mat_name, + { + 'ssym': self._default_texture_model_settings['ssym'], + 'ell_max': self._default_texture_model_settings['ell_max'], + }, + ) + else: + ssym = self.ui.texture_sample_symmetry.currentText() + # Force ell_max to be an even number + ell_max = self.ui.texture_ell_max.value() + if ell_max % 2 != 0: + msg = 'Spherical harmonic max must be an even number' + QMessageBox.critical(self.ui, 'HEXRD', msg) + print(msg, file=sys.stderr) + + ell_max += 1 + w = self.ui.texture_ell_max + with block_signals(w): + w.setValue(ell_max) + + model_kwargs[mat_name] = { + 'ssym': ssym, + 'ell_max': ell_max, + } self.update_texture_model_enable_states() @@ -2326,6 +2421,18 @@ def polar_extent(self) -> list[float] | None: @property def varying_texture_params(self) -> bool: + if self.varying_harmonic_texture_params: + return True + + for mat_name in self.textured_materials_sanitized: + name = f'{mat_name}_p_md' + if name in self.params and self.params[name].vary: + return True + + return False + + @property + def varying_harmonic_texture_params(self) -> bool: for mat_name in self.textured_materials_sanitized: prefix = f'{mat_name}_c_' for param in self.params.values(): @@ -2336,7 +2443,7 @@ def varying_texture_params(self) -> bool: @property def varying_texture_and_non_texture_params(self) -> bool: - if not self.varying_texture_params: + if not self.varying_harmonic_texture_params: return False prefixes = [f'{mat_name}_c_' for mat_name in self.textured_materials_sanitized] @@ -2360,7 +2467,7 @@ def is_zero(v: float) -> bool: # If we find any non-zero parameters, we can plot pole figures for model in obj.texture_model.values(): - if model is None: + if not isinstance(model, HarmonicModel): continue for name in model.parameter_names: @@ -2395,7 +2502,7 @@ def clear_texture_data(self) -> None: return for model in obj.texture_model.values(): - if model is None or not model.pfdata: + if not isinstance(model, HarmonicModel) or not model.pfdata: continue # Clear it @@ -2406,7 +2513,12 @@ def ensure_texture_data(self) -> None: if not isinstance(obj, Rietveld): raise Exception('Cannot make texture data without Rietveld object') - if obj.texture_models_have_pfdata: + harmonic_models = [ + model + for model in obj.texture_model.values() + if isinstance(model, HarmonicModel) + ] + if all(model.pfdata for model in harmonic_models): # Nothing to do return @@ -2419,7 +2531,7 @@ def on_error() -> None: self.async_runner.progress_title = 'Generating texture data...' self.async_runner.error_callback = on_error self.async_runner.run(self.update_texture_data) - while not obj.texture_models_have_pfdata and not had_error: + while not all(model.pfdata for model in harmonic_models) and not had_error: # Process events until we have pfdata. This will allows the # progress dialog to animate. QCoreApplication.processEvents() @@ -2467,16 +2579,28 @@ def on_texture_plot_pole_figures_clicked(self) -> None: if not isinstance(obj, Rietveld): return - if len(self.textured_materials) > 1: + plottable_materials = [ + name + for name, model in obj.texture_model.items() + if isinstance(model, HarmonicModel) + ] + if not plottable_materials: + return + + if len(plottable_materials) > 1: # Get the user to pick a material - mat_items = self.textured_materials mat_name, ok = QInputDialog.getItem( - self.ui, 'Pole Figures', 'Select material', mat_items, 0, False + self.ui, + 'Pole Figures', + 'Select material', + plottable_materials, + 0, + False, ) if not ok: return else: - mat_name = self.textured_materials[0] + mat_name = plottable_materials[0] mat = HexrdConfig().material(mat_name) assert mat is not None @@ -2528,7 +2652,7 @@ def update_pole_figure_plots(self) -> None: return for model in obj.texture_model.values(): - if model is None: + if not isinstance(model, HarmonicModel): continue if model.new_pf_plots_visible: @@ -2544,6 +2668,25 @@ def texture_settings(self) -> dict: @texture_settings.setter def texture_settings(self, v: dict) -> None: + # GUI-only model metadata used to be stored inside model_kwargs. + # Move it out so model_kwargs retains its legacy, constructor-safe + # shape and state files remain usable by older HEXRDGUI versions. + v.setdefault('model_types', {}) + v.setdefault('preferred_axes', {}) + for name, kwargs in v.setdefault('model_kwargs', {}).items(): + model_type = kwargs.pop('model_type', None) + if model_type is not None: + v['model_types'].setdefault(name, model_type) + + hkl = kwargs.pop('hkl', None) + if hkl is not None: + v['preferred_axes'].setdefault(name, hkl) + + kwargs.setdefault('ssym', self._default_texture_model_settings['ssym']) + kwargs.setdefault( + 'ell_max', self._default_texture_model_settings['ell_max'] + ) + self._texture_settings = v # Validate materials in the texture model dict are selected @@ -2553,14 +2696,22 @@ def texture_settings(self, v: dict) -> None: def prune_invalid_texture_materials(self) -> None: valid_mats = self.selected_materials - for name in list(self.texture_model_kwargs): - if name not in valid_mats: - self.texture_model_kwargs.pop(name) + settings_maps = [ + self.texture_model_kwargs, + self.texture_model_types, + self.texture_preferred_axes, + ] + for settings in settings_maps: + for name in list(settings): + if name not in valid_mats: + settings.pop(name) @property def _default_texture_settings(self) -> dict: return { 'model_kwargs': {}, + 'model_types': {}, + 'preferred_axes': {}, 'azimuthal_interval': 5, 'integration_range': 1, } @@ -2592,7 +2743,23 @@ def texture_model_kwargs(self) -> dict[str, Any]: return self.texture_settings['model_kwargs'] @property - def texture_model_dict(self) -> dict[str, HarmonicModel]: + def texture_model_types(self) -> dict[str, str]: + return self.texture_settings.setdefault('model_types', {}) + + @property + def texture_preferred_axes(self) -> dict[str, list[int]]: + return self.texture_settings.setdefault('preferred_axes', {}) + + @property + def current_texture_model_type(self) -> str: + label = self.ui.texture_model_type.currentText() + return next(k for k, v in TEXTURE_MODEL_LABELS.items() if v == label) + + def texture_model_type(self, mat_name: str) -> str: + return self.texture_model_types.get(mat_name, AXIS_DISTRIBUTION_MODEL) + + @property + def texture_model_dict(self) -> dict[str, HarmonicModel | MarchDollaseModel]: if self.method != 'Rietveld': return {} @@ -2600,15 +2767,22 @@ def texture_model_dict(self) -> dict[str, HarmonicModel]: settings = self.texture_settings for k, kwargs in settings['model_kwargs'].items(): mat = HexrdConfig().material(k) - ret[k] = HarmonicModel( - **{ - 'material': Material_Rietveld(material_obj=mat), - 'bvec': HexrdConfig().beam_vector, - 'evec': ct.eta_vec, - 'sample_rmat': HexrdConfig().sample_rmat, - **kwargs, - } - ) + material = Material_Rietveld(material_obj=mat) + model_type = self.texture_model_type(k) + if model_type == MARCH_DOLLASE_MODEL: + ret[k] = MarchDollaseModel( + material=material, + HKL=self.texture_preferred_axes.get(k, [1, 1, 1]), + ) + else: + ret[k] = HarmonicModel( + material=material, + bvec=HexrdConfig().beam_vector, + evec=ct.eta_vec, + sample_rmat=HexrdConfig().sample_rmat, + ssym=kwargs.get('ssym', 'axial'), + ell_max=kwargs.get('ell_max', 16), + ) return ret def update_texture_index_label(self) -> None: @@ -2619,7 +2793,7 @@ def update_texture_index_label(self) -> None: if not isinstance(obj, Rietveld) or obj.texture_model.get(mat_name) is None: v = 'None' else: - j = obj.texture_model[mat_name].J(self.params) + j = obj.texture_model[mat_name].texture_index(self.params) v = f'{j:.2f}' w.setText(f'Texture index: {v}') diff --git a/hexrdgui/calibration/wppf_runner.py b/hexrdgui/calibration/wppf_runner.py index e62d4771a..af3a2bbb0 100644 --- a/hexrdgui/calibration/wppf_runner.py +++ b/hexrdgui/calibration/wppf_runner.py @@ -67,10 +67,11 @@ def run_wppf(self) -> None: assert dialog is not None self.wppf_object = dialog.wppf_object varying_texture = dialog.varying_texture_params + varying_harmonic_texture = dialog.varying_harmonic_texture_params # Work around differences in WPPF objects if isinstance(self.wppf_object, Rietveld): - if varying_texture: + if varying_harmonic_texture: refine_func = self.wppf_object.RefineTexture else: refine_func = self.wppf_object.Refine diff --git a/hexrdgui/resources/ui/wppf_options_dialog.ui b/hexrdgui/resources/ui/wppf_options_dialog.ui index e7fcdeb2b..b0f998556 100644 --- a/hexrdgui/resources/ui/wppf_options_dialog.ui +++ b/hexrdgui/resources/ui/wppf_options_dialog.ui @@ -587,7 +587,7 @@ - + monoclinic @@ -644,6 +644,27 @@ + + + Texture model: + + + + + + + + General Axis Distribution Function + + + + + March-Dollase + + + + + Sample symmetry: @@ -664,6 +685,86 @@ + + + + Preferred axis: + + + + + + + + + h: + + + + + + + false + + + -1000 + + + 1000 + + + 1 + + + + + + + k: + + + + + + + false + + + -1000 + + + 1000 + + + 1 + + + + + + + l: + + + + + + + false + + + -1000 + + + 1000 + + + 1 + + + + + @@ -1037,8 +1138,12 @@ amorphous_expt_smoothing selected_texture_material include_texture_model + texture_model_type texture_sample_symmetry texture_ell_max + texture_preferred_axis_h + texture_preferred_axis_k + texture_preferred_axis_l texture_azimuthal_interval texture_integration_range texture_show_simulated_spectrum diff --git a/tests/test_wppf_options_dialog.py b/tests/test_wppf_options_dialog.py index 9827768c8..dbcaf7251 100644 --- a/tests/test_wppf_options_dialog.py +++ b/tests/test_wppf_options_dialog.py @@ -3,12 +3,15 @@ from types import SimpleNamespace import h5py +import lmfit import numpy as np import pytest from pytestqt.qtbot import QtBot from hexrdgui import state +from hexrdgui.calibration import wppf_options_dialog from hexrdgui.calibration.wppf_options_dialog import WppfOptionsDialog +from hexrdgui.calibration.wppf_runner import WppfRunner from hexrdgui.hexrd_config import HexrdConfig from hexrdgui.image_canvas import ImageCanvas @@ -213,3 +216,162 @@ def test_phase_fractions(qtbot: QtBot, monkeypatch: pytest.MonkeyPatch) -> None: dialog.validate() finally: HexrdConfig().remove_materials(names) + + +def test_march_dollase_settings_and_parameter_files( + qtbot: QtBot, + monkeypatch: pytest.MonkeyPatch, + tmp_path, +) -> None: + calibration = HexrdConfig().config['calibration'] + monkeypatch.setitem(calibration, 'wppf', {}) + + dialog = WppfOptionsDialog() + qtbot.addWidget(dialog.ui) + + # The setting-change callbacks normally regenerate all WPPF parameters. + # Keep this test focused on the texture GUI and persistence paths. + monkeypatch.setattr(dialog, 'update_params', lambda *args, **kwargs: None) + dialog.method = 'Rietveld' + dialog.ui.selected_texture_material.clear() + dialog.ui.selected_texture_material.addItem('Ni') + dialog.ui.include_texture_model.setChecked(True) + dialog.ui.texture_model_type.setCurrentText('March-Dollase') + dialog.ui.texture_preferred_axis_h.setValue(1) + dialog.ui.texture_preferred_axis_k.setValue(0) + dialog.ui.texture_preferred_axis_l.setValue(-1) + + expected_settings = { + 'model_types': {'Ni': wppf_options_dialog.MARCH_DOLLASE_MODEL}, + 'preferred_axes': {'Ni': [1, 0, -1]}, + } + settings = dialog.texture_settings + assert {k: settings[k] for k in expected_settings} == expected_settings + assert dialog.ui.texture_sample_symmetry.isHidden() + assert not dialog.ui.texture_preferred_axis_h.isHidden() + assert not dialog.ui.texture_preferred_axis_k.isHidden() + assert not dialog.ui.texture_preferred_axis_l.isHidden() + assert not dialog.ui.texture_azimuthal_interval.isEnabled() + assert not dialog.ui.texture_integration_range.isEnabled() + + constructed = {} + + class DummyMarchDollaseModel: + def __init__(self, **kwargs): + constructed.update(kwargs) + + monkeypatch.setattr( + wppf_options_dialog, 'MarchDollaseModel', DummyMarchDollaseModel + ) + monkeypatch.setattr( + wppf_options_dialog, + 'Material_Rietveld', + lambda material_obj: f'rietveld-{material_obj}', + ) + monkeypatch.setattr(HexrdConfig, 'material', lambda self, name: name) + assert isinstance(dialog.texture_model_dict['Ni'], DummyMarchDollaseModel) + assert constructed == {'material': 'rietveld-Ni', 'HKL': [1, 0, -1]} + + dialog.params = lmfit.Parameters() + dialog.params.add('Ni_p_md', value=1.25, min=0.0, vary=True) + tree = dialog.tree_view_dict_of_params + assert tree['Texture']['Ni']['P_md']['_param'] is dialog.params['Ni_p_md'] + + params_file = tmp_path / 'params.h5' + dialog.save_params(params_file) + dialog.params['Ni_p_md'].value = 2.0 + dialog.load_params(params_file) + assert dialog.params['Ni_p_md'].value == pytest.approx(1.25) + + dialog.save_settings() + state_file = tmp_path / 'state.h5' + snapshot = {'config_calibration': {'wppf': calibration['wppf']}} + with h5py.File(state_file, 'w') as f: + state._save_config(f, snapshot) + with h5py.File(state_file, 'r') as f: + loaded = state._load_config(f) + + loaded_wppf = loaded['config_calibration']['wppf'] + settings = loaded_wppf['texture_settings'] + assert {k: settings[k] for k in expected_settings} == expected_settings + assert loaded_wppf['params_dict']['Ni_p_md']['value'] == pytest.approx(1.25) + + +def test_saved_plot_contains_march_dollase_parameter( + qtbot: QtBot, + monkeypatch: pytest.MonkeyPatch, + tmp_path, +) -> None: + calibration = HexrdConfig().config['calibration'] + monkeypatch.setitem(calibration, 'wppf', {}) + + dialog = WppfOptionsDialog() + qtbot.addWidget(dialog.ui) + dialog.params = lmfit.Parameters() + dialog.params.add('Ni_p_md', value=1.25) + dialog._wppf_object = SimpleNamespace( + spectrum_sim=SimpleNamespace(x=np.arange(3), y=np.arange(3)), + weights=SimpleNamespace(y=np.ones(3)), + background=SimpleNamespace(y=np.zeros(3)), + amorphous_model=None, + ) + monkeypatch.setattr( + HexrdConfig(), + 'last_unscaled_azimuthal_integral_data', + (np.arange(3), np.ma.array(np.arange(3))), + ) + + output = tmp_path / 'plot.h5' + dialog.write_data(output) + + with h5py.File(output, 'r') as f: + assert f['params/Ni_p_md/value'][()] == pytest.approx(1.25) + + +def test_march_dollase_refinement( + qtbot: QtBot, + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Refine P_MD on a pattern simulated with P_MD = 1.5 + monkeypatch.setitem(HexrdConfig().config['calibration'], 'wppf', {}) + tth = np.linspace(2, 30, 1400) + + def set_lineout(y: np.ndarray) -> None: + data = (tth, np.ma.array(y)) + monkeypatch.setattr( + HexrdConfig(), 'last_unscaled_azimuthal_integral_data', data + ) + + set_lineout(np.full_like(tth, 100.0)) + dialog = WppfOptionsDialog() + qtbot.addWidget(dialog.ui) + dialog.use_statistical_weights = False + dialog.spline_points = [[3.0, 100.0], [29.0, 100.0]] + dialog.selected_materials = ['CeO2'] + dialog.method = 'Rietveld' + dialog.ui.include_texture_model.setChecked(True) + dialog.ui.texture_model_type.setCurrentText('March-Dollase') + dialog.ui.texture_preferred_axis_k.setValue(0) + dialog.ui.texture_preferred_axis_l.setValue(0) + for param in dialog.params.values(): + param.vary = False + + dialog.params['CeO2_p_md'].value = 1.5 + obj = dialog.wppf_object + assert obj.texture_model['CeO2'].HKL.tolist() == [1, 0, 0] + obj._set_params_vals_to_class(obj.params, force=True) + obj.computespectrum() + sim = obj.spectrum_sim + set_lineout(np.interp(tth, sim.x, np.nan_to_num(sim.y))) + dialog.reset_object() + + dialog.params['CeO2_p_md'].set(value=1.0, vary=True) + runner = WppfRunner() + runner.wppf_options_dialog = dialog + dialog.run.connect(runner.run_wppf) + dialog.begin_run() + assert dialog.params['CeO2_p_md'].value == pytest.approx(1.5, abs=1e-3) + assert dialog.ui.texture_index_label.text() == 'Texture index: 1.50' + + dialog.pop_undo_stack() + assert dialog.params['CeO2_p_md'].value == 1.0