Source code for powderline.gateways.gsasii.setters

"""GSAS-II gateway — refinement-parameter setters.

Moved verbatim from ``powderline/kicker.py`` (multi-engine refactor).
"""
from __future__ import annotations
import numpy as np
from typing import Any
from GSASII import GSASIIlattice as G2lat
from powderline.gateways.gsasii.constraints import atom_refinement_plan, cell_refinement_plan

from powderline.gateways.gsasii.helpers import (
    has_active_refinement_parameter,
    require_phase,
    unpack_refinement_parameter,
)



# By default (G2obj.SetSampleDefault() called when adding histogram) histogram scale set to refine
def set_hist_scale(proj: Any, hist: Any, hist_scale_val: float = 1.0, hist_scale_refine_flag: bool = False, print_info: bool = False) -> None:
    """Set histogram scale and refine flag."""
    proj.data[hist.name]['Sample Parameters']['Scale'] = [hist_scale_val, hist_scale_refine_flag]
    if print_info:
        print(f"Set histogram scale to {hist_scale_val} with refine flag {hist_scale_refine_flag} for histogram {hist.name}")


[docs] def set_fit_range_hist(hist: Any, fit_range: tuple[float | None, float | None], print_info: bool = False) -> None: """Set fit range for histogram in GSAS-II project.""" min_val, max_val = fit_range old_limits = hist.data['Limits'][1].copy() # this is a view on the object, need to save old limits before changing if min_val is not None: hist.data['Limits'][1][0] = min_val if max_val is not None: hist.data['Limits'][1][1] = max_val if print_info: print('Updated fit limits in hist from ', old_limits, 'to', hist.data['Limits'][1])
[docs] def set_chebyshev_background(proj: Any, hist: Any, chebyshev_dict: dict, print_info: bool = False) -> None: """ Set the Chebyshev background for a given histogram. Chebyshev polynomials provide smooth curved backgrounds. Coefficients start at 0th order (constant term) and increase: [c0, c1, c2, ...] represents c0 + c1*T1(x) + c2*T2(x) + ... where Tn are Chebyshev polynomials. The function manipulates proj.data[hist.name]['Background'][0] which has structure: [background_type, refine_flag, num_coefficients, c0, c1, c2, ...] Parameters: proj: GSAS-II project object containing the histogram hist: Histogram object to set the background for chebyshev_dict: Dictionary containing Chebyshev background parameters: - num_coefficients (int): Number of Chebyshev coefficients - coefficients (list[float]): Coefficient values [c0, c1, c2, ...] - refine_flag (bool): Whether to refine background during fitting print_info: If True, print background configuration to stdout Returns: None Raises: ValueError: If number of coefficients doesn't match list length or required background entries are missing Examples: >>> chebyshev_dict = { ... 'num_coefficients': 3, ... 'coefficients': [100.0, -50.0, 10.0], ... 'refine_flag': True ... } >>> set_chebyshev_background(proj, hist, chebyshev_dict) """ # First parse the chebyshev_dict num_coefficients = chebyshev_dict.get('num_coefficients') coefficients = chebyshev_dict.get('coefficients') refine_flag = chebyshev_dict.get('refine_flag') # Defaults are not provided because those will be provided upstream # None will be caught in validation and trigger a default assignment. # The code below is an example of how to handle defaults here if needed. # This should be enforced with the pydantic schema when loading the recipe if coefficients is None: coefficients = [0.0] * num_coefficients if num_coefficients != len(coefficients): raise ValueError( "Number of coefficients does not match the length of the coefficients list." ) chebyshev_bkg = proj.data[hist.name]['Background'][0] # Check for default entries (type, refine flag, num coefficients) if len(chebyshev_bkg) < 3: raise ValueError("Chebyshev background list missing required entries. type, refine flag, num coefficients should be present by default.") # Set number of coefficients chebyshev_bkg[2] = num_coefficients # Pad or truncate the coefficients list in chebyshev_bkg to match num_coefficients # This would be done in an earlier validation step! needed_len = num_coefficients + 3 if len(chebyshev_bkg) < needed_len: chebyshev_bkg.extend([0.0] * (needed_len - len(chebyshev_bkg))) elif len(chebyshev_bkg) > needed_len: del chebyshev_bkg[needed_len:] # in-place truncate # Set coefficients for i, coeff in enumerate(coefficients): chebyshev_bkg[i + 3] = coeff # Set refine flag chebyshev_bkg[1] = refine_flag if print_info: print(f"Chebyshev background set with {chebyshev_bkg[2]} coeffiencients, refine_flag={chebyshev_bkg[1]}") print(f"Coefficients: {chebyshev_bkg[3:3+chebyshev_bkg[2]]}")
# function to set single peak background # this should be a rather exposed function # e.g., take lists of positions, intensities, sigmas, gammas, refine flags, etc. # This function should expect inputs from the background_dict['single_peaks'] structure
[docs] def set_single_peak_background(proj: Any, hist: Any, bkg_single_peaks_dict: dict, print_info: bool = False) -> None: """ Set single peak background for histogram using pseudo-Voigt profiles. Single peaks are useful for modeling known impurity peaks or other non-background features that shouldn't be included in the main phase refinement. Each peak is described by a pseudo-Voigt profile (weighted sum of Gaussian and Lorentzian). Peak profile: I(2θ) = intensity * [η*L(2θ) + (1-η)*G(2θ)] where: - G(2θ) is Gaussian with width sigma - L(2θ) is Lorentzian with width gamma - η (eta) is mixing parameter (0=pure Gaussian, 1=pure Lorentzian) Args: proj: GSAS-II project object hist: Histogram object to set single peaks for bkg_single_peaks_dict: Dictionary with keys: - positions: List of [[2θ, refine], ...] for peak positions - intensities: List of [[I, refine], ...] for peak heights - pv_gaussian_sigma_sq: List of [[σ², refine], ...] Gaussian variances, centideg² - pv_lorentzian_gamma: List of [[γ, refine], ...] for Lorentzian widths All lists must have same length (number of peaks) print_info: If True, print peak configuration to stdout Returns: None Raises: ValueError: If parameter lists have inconsistent lengths Examples: >>> # Two single peaks at 2θ=35.5° and 42.0° >>> single_peaks = { ... 'positions': [[35.5, False], [42.0, False]], ... 'intensities': [[50.0, True], [30.0, True]], ... 'pv_gaussian_sigma_sq': [[0.1, False], [0.1, False]], ... 'pv_lorentzian_gamma': [[0.15, False], [0.15, False]] ... } >>> set_single_peak_background(proj, hist, single_peaks) """ """ Set the single peak background for a given histogram. Parameters: proj: object The GSAS-II project containing the histogram. hist : object The histogram to set the background for. bkg_single_peaks_dict : dict Dictionary containing single peak background parameters: - positions : list of [float, bool]: [position value, refine flag] for each peak. These are two-theta values. Q / d can be passed upstream and converted in validation. - intensities: list of [float, bool]: [intensity value, refine flag] for each peak. - pv_gaussian_sigma_sq: list of [float, bool]: [Gaussian variance (centideg²), refine flag] for each peak. - pv_lorentzian_gamma: list of [float, bool]: [Lorentzian gamma value, refine flag] for each peak. print_info : bool Whether to print information about the set background. Returns: None """ # Parse the bkg_single_peaks_dict positions = bkg_single_peaks_dict.get('positions', []) intensities = bkg_single_peaks_dict.get('intensities', []) gaussian_sigmas = bkg_single_peaks_dict.get('pv_gaussian_sigma_sq', []) lorentzian_gammas = bkg_single_peaks_dict.get('pv_lorentzian_gamma', []) # Default intensity, sigma, gamma values to use if value input is None # TODO: Phase 2 upstream recipe builder — these defaults will be set before calling this function # default_intensity = 1.0 # default_sigma = 0.1 # default_gamma = 0.1 # default_refine_flag = False # Validate that all input lists have the same length if not (len(positions) == len(intensities) == len(gaussian_sigmas) == len(lorentzian_gammas)): raise ValueError("All input lists must have the same length.") num_peaks = len(positions) # only used for looping # Construct the single peak background list single_peak_bkg = [] for i in range(num_peaks): pos, pos_refine, pos_min, pos_max = unpack_refinement_parameter(positions[i], "positions") inten, inten_refine, inten_min, inten_max = unpack_refinement_parameter(intensities[i], "intensities") g_sigma, g_refine, g_sigma_min, g_sigma_max = unpack_refinement_parameter(gaussian_sigmas[i], "pv_gaussian_sigma_sq") l_gamma, l_refine, l_gamma_min, l_gamma_max = unpack_refinement_parameter(lorentzian_gammas[i], "pv_lorentzian_gamma") # TODO: move default behavior upstream # # Use default values if None provided # if inten is None: # inten = default_intensity # if g_sigma is None: # g_sigma = default_sigma # if l_gamma is None: # l_gamma = default_gamma # # Use default refine flags if None provided # if pos_refine is None: # pos_refine = default_refine_flag # if inten_refine is None: # inten_refine = default_refine_flag # if g_refine is None: # g_refine = default_refine_flag # if l_refine is None: # l_refine = default_refine_flag # Min / max not currently available in GSAS-II single peak background definition # They can be stored upstream for reference, but not used downstream currently. # [placeholder for code to handle min/max if needed in future] # Append peak parameters to the single peak background list # Each peak is a list: # [pos, pos_refine, inten, inten_refine, g_sigma, g_refine, l_gamma, l_refine] if None in [pos, pos_refine, inten, inten_refine, g_sigma, g_refine, l_gamma, l_refine]: continue # We only append if there are no Nones - this is a temp fix before phase2 # TODO: update this behavior in the future when this list will only be passed with valid contents single_peak_bkg.append([pos, pos_refine, inten, inten_refine, g_sigma, g_refine, l_gamma, l_refine]) # Append the single peak background to the histogram's background list if proj.data[hist.name]['Background'][1]['peaksList'] is None: proj.data[hist.name]['Background'][1]['peaksList'] = [] # Since single_peak_bkg is a list of peaks, we need to append each peak individually for peak in single_peak_bkg: proj.data[hist.name]['Background'][1]['peaksList'].append(peak) # Update the number of peaks proj.data[hist.name]['Background'][1]['nPeaks'] = len(proj.data[hist.name]['Background'][1]['peaksList']) # if print_info, then print details about the set background using the proj data, not the inputs if print_info: print(f"Single peak background set with {proj.data[hist.name]['Background'][1]['nPeaks']} peaks:") for peak in proj.data[hist.name]['Background'][1]['peaksList']: print(f" Position: {peak[0]} (refine: {peak[1]}), Intensity: {peak[2]} (refine: {peak[3]}), " f"Gaussian Sigma: {peak[4]} (refine: {peak[5]}), Lorentzian Gamma: {peak[6]} (refine: {peak[7]})")
def set_single_peaks(proj: Any, hist: Any, single_peaks_dict: dict, print_info: bool = False) -> None: """ Set single peaks in the Peak List (non-background peaks) with direct control over position, intensity, and pseudo-Voigt width parameters (sigma_sq, gamma). **Schema 0.24:** Values should be sigma² (variance), not sigma - no conversion is performed. Peak fitting mode is controlled by refinement strategy execution functions via refinement_controls.single_peak_fitting_mode and passed to hist.refine_peaks(). sigma_sq and gamma must be positive non-zero values. GSAS-II does not use negative values for FWHM calc and instead enforces a minimum (0.001 in centidegrees for sigma and gamma). In our approach, we raise an error for negative or zero values to avoid confusion. GSAS-II will output negative values if refined to such, but uses the near-zero value in PV calculation. Parameters: proj: GSAS-II project object hist: Histogram object single_peaks_dict: Dictionary containing peak parameters (positions, intensities, pv_gaussian_sigma_sq, pv_lorentzian_gamma) print_info: If True, print details about set peaks """ # Extract parameters from dict positions = single_peaks_dict.get('positions') intensities = single_peaks_dict.get('intensities') pv_gaussian_sigma_sq = single_peaks_dict.get('pv_gaussian_sigma_sq') pv_lorentzian_gamma = single_peaks_dict.get('pv_lorentzian_gamma') # Validate that all required lists are present required_keys = ['positions', 'intensities', 'pv_gaussian_sigma_sq', 'pv_lorentzian_gamma'] for key in required_keys: if single_peaks_dict.get(key) is None: raise ValueError(f"Missing required key '{key}' in single_peaks_dict") # Validate all lists are non-empty if not positions or len(positions) == 0: raise ValueError("positions list cannot be empty") if not intensities or len(intensities) == 0: raise ValueError("intensities list cannot be empty") if not pv_gaussian_sigma_sq or len(pv_gaussian_sigma_sq) == 0: raise ValueError("pv_gaussian_sigma_sq list cannot be empty") if not pv_lorentzian_gamma or len(pv_lorentzian_gamma) == 0: raise ValueError("pv_lorentzian_gamma list cannot be empty") # Validate all lists have the same length list_lengths = [ len(positions), len(intensities), len(pv_gaussian_sigma_sq), len(pv_lorentzian_gamma) ] if len(set(list_lengths)) > 1: raise ValueError( f"All peak parameter lists must have the same length. " f"Got: positions={len(positions)}, intensities={len(intensities)}, " f"pv_gaussian_sigma_sq={len(pv_gaussian_sigma_sq)}, pv_lorentzian_gamma={len(pv_lorentzian_gamma)}" ) num_peaks = len(positions) # Check if Peak List exists, initialize if not if 'Peak List' not in proj.data[hist.name]: import warnings warnings.warn(f"Peak List not found for histogram {hist.name}. Initializing with default structure.") proj.data[hist.name]['Peak List'] = { 'peaks': [], 'sigDict': {}, 'xtraPeaks': [], 'xtraMode': False } # Build peak list peaks_list = [] for i in range(num_peaks): # Extract values and refine flags from RefinementParameter format [value, refine_flag, min, max] pos_val, pos_refine = positions[i][0], positions[i][1] int_val, int_refine = intensities[i][0], intensities[i][1] sigma_sq_val, sigma_sq_refine = pv_gaussian_sigma_sq[i][0], pv_gaussian_sigma_sq[i][1] gamma_val, gamma_refine = pv_lorentzian_gamma[i][0], pv_lorentzian_gamma[i][1] # Input validation - check for invalid values if np.isnan(sigma_sq_val) or np.isinf(sigma_sq_val): raise ValueError(f"Peak {i}: sigma² value is NaN or inf ({sigma_sq_val}), which is always invalid") if np.isnan(gamma_val) or np.isinf(gamma_val): raise ValueError(f"Peak {i}: gamma value is NaN or inf ({gamma_val}), which is always invalid") # Check for negative or zero values, raise Error (while GSAS-II allows negative values, we will not in our approach) if sigma_sq_val <= 0: raise ValueError(f"Peak {i}: sigma² <= 0 ({sigma_sq_val}), which is invalid for peak fitting") if gamma_val <= 0: raise ValueError(f"Peak {i}: gamma <= 0 ({gamma_val}), which is invalid for peak fitting") # Convert boolean refine flags to integers (0 or 1) pos_flag = 1 if pos_refine else 0 int_flag = 1 if int_refine else 0 # Refine flag for sig² (variance) - input is already sigma² from GUI/calculations sig_sq_flag = 1 if sigma_sq_refine else 0 gamma_flag = 1 if gamma_refine else 0 # Build peak as: [pos, pos_flag, intensity, int_flag, sig², sig²_flag, gamma, gam_flag] peak = [pos_val, pos_flag, int_val, int_flag, sigma_sq_val, sig_sq_flag, gamma_val, gamma_flag] peaks_list.append(peak) # Set peaks in Peak List proj.data[hist.name]['Peak List']['peaks'] = peaks_list # Print info if requested if print_info: print(f"Single peaks (Peak List) set with {num_peaks} peaks:") for i, peak in enumerate(peaks_list): pos, pos_flag, intensity, int_flag, sig_sq, sig_sq_flag, gamma, gam_flag = peak print(f" Peak {i+1}: Position={pos:.4f} (refine: {bool(pos_flag)}), " f"Intensity={intensity:.4f} (refine: {bool(int_flag)}), " f"Sigma²={sig_sq:.6f}, (refine: {bool(sig_sq_flag)}), " f"Gamma={gamma:.6f} (refine: {bool(gam_flag)})") ################################################################## # Functions for phase parameterization @require_phase("Cannot set scale.") def set_phase_scale(proj: Any, hist: Any, phase_name: str, scale_param: list, print_info: bool = False) -> None: """ Set the phase scale factor in the GSAS-II project. Parameters: proj : GSAS-II project object The GSAS-II project containing the phase. hist : GSAS-II histogram object The histogram associated with the phase. phase_name : str The name of the phase to set the scale for. scale_param : list List containing [value, refine_flag, min, max] for the scale factor. Returns: None """ value, refine_flag, min_val, max_val = unpack_refinement_parameter(scale_param, "scale_param") # Set the scale factor value - defaults will be handled upstream in future in validation if value is not None: proj.data['Phases'][phase_name]['Histograms'][hist.name]['Scale'][0] = value # Set refinement flag if refine_flag is not None: proj.data['Phases'][phase_name]['Histograms'][hist.name]['Scale'][1] = refine_flag # Set min and max if provided - CURRENTLY NOT IMPLEMENETED. Unclear if allowed in GSAS-II. # if min_val is not None: # phase.set_parameter_min('Scale', min_val) # if max_val is not None: # phase.set_parameter_max('Scale', max_val) if print_info: print(f"Set scale for phase '{phase_name}' to {value} with refine_flag={refine_flag}") @require_phase("Cannot set unit cell parameters.") def set_phase_unit_cell(proj: Any, phase_name: str, unit_cell_dict: dict, print_info: bool = False) -> list[str]: """ Set the unit cell parameters in the GSAS-II project for a given phase. Per-parameter refine semantics: each cell parameter refines iff it is present with refine_flag=true; absent or false means fixed. Because GSAS-II has a single whole-cell refine flag, per-parameter control is achieved by setting that flag when any symmetry degree-of-freedom (DOF) group is requested and returning "Hold" constraint variable names (e.g. '0::A2') for the DOF groups that were not requested. Symmetry-linked parameters (e.g. cubic a=b=c, or the coupled monoclinic {a, c, beta}) refine together if any member is requested — see powderline.gateways.gsasii.constraints. Parameters: proj : GSAS-II project object The GSAS-II project containing the phase. phase_name : str The name of the phase to set the unit cell for. unit_cell_dict : dict Dictionary containing unit cell parameters and their parameterization. print_info : bool Whether to print information about the set unit cell. Returns: list[str] GSAS-II variable names to Hold (empty when the whole cell refines or is entirely fixed). The caller applies them via proj.add_HoldConstr. """ # Note: cell values are set verbatim; validating them against the phase's # crystal system is intentionally NOT done here (PowderLine is the engine, # not the arbiter of recipe correctness — see docs/reference/known-issues.md, KI-01). # Note2: hist not required since unit cell is phase-specific, not histogram-specific. # The delineation of phase and histogram parameters is confusing in GSAS-II documentation. # This makes sticking with a project based approach more straightforward. phase_names = [p.name for p in proj.phases()] cell_changed = False cell_params = ['a', 'b', 'c', 'alpha', 'beta', 'gamma'] # must match schema, and match order in GSAS-II for i, param in enumerate(cell_params): if unit_cell_dict.get(param) is not None: value, refine_flag, min_val, max_val = unpack_refinement_parameter(unit_cell_dict[param], param) # Set the unit cell parameter value - a=1, b=2, c=3, alpha=4, beta=5, gamma=6 if value is not None: proj.data['Phases'][phase_name]['General']['Cell'][i+1] = value cell_changed = True # Set min and max if provided - CURRENTLY NOT IMPLEMENTED (deferred; # GSAS-II's own min/max semantics differ from simple bounds). # Calculate and set volume from unit cell parameters only if cell was changed if cell_changed: cell = proj.data['Phases'][phase_name]['General']['Cell'][1:7] # get list of a, b, c, alpha, beta, gamma proj.data['Phases'][phase_name]['General']['Cell'][7] = G2lat.calc_V(G2lat.cell2A(cell)) # index 7 is volume # Translate per-parameter refine flags into the whole-cell flag + holds SGData = proj.data['Phases'][phase_name]['General']['SGData'] phase_idx = phase_names.index(phase_name) cell_plan = cell_refinement_plan(SGData, unit_cell_dict, phase_idx) proj.data['Phases'][phase_name]['General']['Cell'][0] = cell_plan.refine_cell if print_info: curr_cell = proj.data['Phases'][phase_name]['General']['Cell'] if cell_changed: cell_prms_dict = {"a": curr_cell[1], "b": curr_cell[2], "c": curr_cell[3], "alpha": curr_cell[4], "beta": curr_cell[5], "gamma": curr_cell[6], "volume": curr_cell[7]} print(f"Set unit cell parameters for phase '{phase_name}' to {[(key, value) for key, value in cell_prms_dict.items()]} with refine_flag={curr_cell[0]}") else: print(f"No unit cell parameters were changed for phase '{phase_name}'. Refine_flag={curr_cell[0]}") if cell_plan.holds: print(f"Holding fixed unit cell DOFs for phase '{phase_name}': {cell_plan.holds}") return cell_plan.holds @require_phase("Cannot set size broadening parameters.") def set_phase_size_broadening(proj: Any, hist: Any, phase_name: str, size_broadening_dict: dict, print_info: bool = False) -> None: """ Set size broadening parameters in the GSAS-II project for a given phase. Supports model branching for isotropic, uniaxial, and ellipsoidal models. Currently only isotropic model is implemented - others raise NotImplementedError. Parameters: proj : GSAS-II project object The GSAS-II project containing the phase. hist : GSAS-II histogram object The histogram associated with the phase. phase_name : str The name of the phase to set the size broadening for. size_broadening_dict : dict Dictionary containing size broadening parameters with 'model' key. Raises: NotImplementedError: For uniaxial or ellipsoidal models ValueError: For unknown model types or invalid parameters Returns: None """ # Extract model type (default to isotropic for backward compatibility) model = size_broadening_dict.get('model', 'isotropic') # Validate model type valid_models = ['isotropic', 'uniaxial', 'ellipsoidal'] if model not in valid_models: raise ValueError( f"Unknown size broadening model '{model}'. " f"Valid options: {valid_models}" ) # Set model type in GSAS-II data structure proj.data['Phases'][phase_name]['Histograms'][hist.name]['Size'][0] = model # Branch on model type if model == 'isotropic': _set_isotropic_size_broadening(proj, hist, phase_name, size_broadening_dict, print_info) elif model == 'uniaxial': raise NotImplementedError( "Uniaxial size broadening model is not yet implemented. " "Support planned for future release. Use 'isotropic' model instead." ) elif model == 'ellipsoidal': raise NotImplementedError( "Ellipsoidal size broadening model is not yet implemented. " "Support planned for future release. Use 'isotropic' model instead." ) def _set_isotropic_size_broadening(proj: Any, hist: Any, phase_name: str, size_broadening_dict: dict, print_info: bool = False) -> None: """ Set isotropic size broadening parameters (internal helper). Refactored from original set_phase_size_broadening function. """ # Example structure of proj.data['Phases'][phase_name]['Histograms'][hist.name]['Size']: # ['isotropic', # [0]: str for broadening type. 'isotropic', 'uniaxial', or 'ellipsoidal' # [1.0, 1.0, 1.0], # [1][0]: iso size or equatorial size, [1][1]: axial size (if uniaxial used, else ignored), [1][2]: LG_mix (eta parameter, 1 = Lorentzian, 0 = Gaussian) # [False, False, False], # boolean refinement flags. [2][0]: isotropic or uniaxial equatorial refine, [2][1]: uniaxial axial refine, [2][2]: LG_mix refine # [0, 0, 1], # hkl direction for uniaxial broadening. [3][0]: h, [3][1]: k, [3][2]: l # [1.0, 1.0, 1.0, 0.0, 0.0, 0.0], # ellipsoidal sizes. S11, S22, S33, S12, S13, S23 for [4][0:6] # [False, False, False, False, False, False]] # ellipsoidal size refine flags. S11, S22, S33, S12, S13, S23 for [5][0:6] ############################ # Set isotropic size parameters ############################ value, refine_flag, min_val, max_val = unpack_refinement_parameter( size_broadening_dict.get('isotropic_size'), "isotropic_size" ) # Set the size parameter value if value is not None: proj.data['Phases'][phase_name]['Histograms'][hist.name]['Size'][1][0] = value # Set refinement flag if refine_flag is not None: proj.data['Phases'][phase_name]['Histograms'][hist.name]['Size'][2][0] = refine_flag # Set min and max if provided - CURRENTLY NOT IMPLEMENTED. Unclear if allowed in GSAS-II. if print_info: print(f"Set size broadening size parameters for phase '{phase_name}' to {value} with refine_flag={refine_flag}") ############################ # Set size LG_eta parameter ############################ try: value, refine_flag, min_val, max_val = unpack_refinement_parameter( size_broadening_dict.get('LG_eta'), "LG_eta" ) except (ValueError, TypeError) as e: # Preserve original error message format raise ValueError(f"Size broadening 'LG_eta' parameter must be a list or None.\nType for phase {phase_name} is {type(size_broadening_dict['LG_eta'])}.") from e # Set the LG_eta parameter value if value is not None: proj.data['Phases'][phase_name]['Histograms'][hist.name]['Size'][1][2] = value # Set refinement flag if refine_flag is not None: proj.data['Phases'][phase_name]['Histograms'][hist.name]['Size'][2][2] = refine_flag # Set min and max if provided - CURRENTLY NOT IMPLEMENTED. Unclear if allowed in GSAS-II. if print_info: print(f"Set size broadening LG_eta parameters for phase '{phase_name}' to {value} with refine_flag={refine_flag}") # Setting strain broadening should be done in the same way as size broadening # The dictionary structure is very similar. We just need to map to the correct keys in the proj.data structure. @require_phase("Cannot set strain broadening parameters.") def set_phase_strain_broadening(proj: Any, hist: Any, phase_name: str, strain_broadening_dict: dict, print_info: bool = False) -> None: """ Set strain broadening parameters in the GSAS-II project for a given phase. Supports model branching for isotropic, uniaxial, and generalized (Stephens) models. Currently only isotropic model is implemented - others raise NotImplementedError. Parameters: proj : GSAS-II project object The GSAS-II project containing the phase. hist : GSAS-II histogram object The histogram associated with the phase. phase_name : str The name of the phase to set the strain broadening for. strain_broadening_dict : dict Dictionary containing strain broadening parameters with 'model' key. Raises: NotImplementedError: For uniaxial or generalized models ValueError: For unknown model types or invalid parameters Returns: None """ # Extract model type (default to isotropic for backward compatibility) model = strain_broadening_dict.get('model', 'isotropic') # Validate model type valid_models = ['isotropic', 'uniaxial', 'generalized'] if model not in valid_models: raise ValueError( f"Unknown strain broadening model '{model}'. " f"Valid options: {valid_models}" ) # Set model type in GSAS-II data structure proj.data['Phases'][phase_name]['Histograms'][hist.name]['Mustrain'][0] = model # Branch on model type if model == 'isotropic': _set_isotropic_strain_broadening(proj, hist, phase_name, strain_broadening_dict, print_info) elif model == 'uniaxial': raise NotImplementedError( "Uniaxial strain broadening model is not yet implemented. " "Support planned for future release. Use 'isotropic' model instead." ) elif model == 'generalized': raise NotImplementedError( "Generalized (Stephens) strain broadening model is not yet implemented. " "This model requires complex symmetry-dependent parameterization. " "Support planned for Phase 2. Use 'isotropic' model instead." ) def _set_isotropic_strain_broadening(proj: Any, hist: Any, phase_name: str, strain_broadening_dict: dict, print_info: bool = False) -> None: """ Set isotropic strain broadening parameters (internal helper). Refactored from original set_phase_strain_broadening function. """ ############################ # Set isotropic strain parameters ############################ value, refine_flag, min_val, max_val = unpack_refinement_parameter( strain_broadening_dict.get('isotropic_strain'), "isotropic_strain" ) # Set the strain parameter value if value is not None: proj.data['Phases'][phase_name]['Histograms'][hist.name]['Mustrain'][1][0] = value # Set refinement flag if refine_flag is not None: proj.data['Phases'][phase_name]['Histograms'][hist.name]['Mustrain'][2][0] = refine_flag # Set min and max if provided - CURRENTLY NOT IMPLEMENTED. Unclear if allowed in GSAS-II. if print_info: print(f"Set strain broadening strain parameters for phase '{phase_name}' to {value} with refine_flag={refine_flag}") ############################ # Set strain LG_eta parameter ############################ try: value, refine_flag, min_val, max_val = unpack_refinement_parameter( strain_broadening_dict.get('LG_eta'), "LG_eta" ) except (ValueError, TypeError) as e: # Preserve original error message format raise ValueError(f"Strain broadening dict key 'LG_eta' must hold a value that is a list or None.\nType for phase {phase_name} is {type(strain_broadening_dict['LG_eta'])}.") from e # Set the LG_eta parameter value if value is not None: proj.data['Phases'][phase_name]['Histograms'][hist.name]['Mustrain'][1][2] = value # Set refinement flag if refine_flag is not None: proj.data['Phases'][phase_name]['Histograms'][hist.name]['Mustrain'][2][2] = refine_flag # Set min and max if provided - CURRENTLY NOT IMPLEMENTED. Unclear if allowed in GSAS-II. if print_info: print(f"Set strain broadening LG_eta parameters for phase '{phase_name}' to {value} with refine_flag={refine_flag}") # Function to set atom parameters for a phase @require_phase("Cannot set atom parameters.") def set_phase_atom_parameters(proj: Any, phase_name: str, atom_parameters_dict: dict, print_info: bool = False) -> list[str]: """ Set atom parameters for a phase in the GSAS-II project. This function parses atom-specific refinement parameters (coordinates, occupancy, displacement) and constructs GSAS-II's refine_flags string ('F', 'X', 'U' combinations). Per-parameter refine semantics: each coordinate (x/y/z) and each anisotropic Uij component refines iff present with refine_flag=true; absent or false means fixed. GSAS-II's 'X'/'U' flags are per-atom, so per-component control is achieved by returning "Hold" constraint variable names (e.g. '0::dAy:3', '0::AU22:4') for the site-symmetry DOF groups that were not requested — see powderline.gateways.gsasii.constraints. Symmetry-linked components (e.g. x=y on an (x,x,z) site) refine together if any member is requested. Parameters: proj : GSAS-II project object The GSAS-II project containing the phase. phase_name : str The name of the phase to set the atom parameters for. atom_parameters_dict : dict Dictionary containing atom parameters and their parameterization. E.g., payload['phases'][phase_name]['parameterization']['atoms'] Expected structure: { 'atom_label': { 'x': [value, refine_flag, min, max], 'y': [value, refine_flag, min, max], 'z': [value, refine_flag, min, max], 'occupancy': [value, refine_flag, min, max], 'ADP': 'Uiso' or 'Uaniso', 'Uiso': [value, refine_flag, min, max], # if ADP='Uiso' 'Uaniso': { # if ADP='Uaniso' 'U11': [value, refine_flag, min, max], 'U22': [value, refine_flag, min, max], ... } } } print_info : bool, optional If True, print detailed information about the atom parameters being set. Default is False. Returns: list[str] GSAS-II variable names to Hold (empty when every requested DOF group refines in full). The caller applies them via proj.add_HoldConstr. Notes: proj.data['Phases'][phase_name]['Atoms'] is a list of lists, where each sublist represents an atom and its parameters. Sublist structure in atom_param_index_mapping below. Example: proj.data['Phases'][phase_name]['Atoms'][0] from LaB6 example returns: ['La', 'La', '', 0.0, 0.0, 0.0, 1.0, 'm3m', 1, 'I', 0.00858, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 844046454091148456] GSAS-II refine_flags string format (index 2): - 'F': refine occupancy - 'X': refine all coordinates (x, y, z together) - 'U': refine displacement parameters (isotropic or anisotropic) - Combinations: 'FXU', 'XU', 'F', etc. """ # Dictionary of atom parameter names to their indices in the atom list atom_param_index_mapping = { 'label': 0, # atom label 'element': 1, # element symbol 'refine_flags': 2, # string of refine flags e.g., 'F', 'X', 'U' or any combination such as 'FXU'. F for occupancy, X for xyz coords, U for displacement parameters 'x': 3, # fractional coordinate x value 'y': 4, # fractional coordinate y value 'z': 5, # fractional coordinate z value 'occupancy': 6, # float, referred to as frac in GSAS-II syntax 'site_symmetry': 7, # string representation of site symmetry 'multiplicity': 8, # multiplicity of the site, integer 'Utype': 9, # 'I' for isotropic, 'A' for anisotropic 'Uiso': 10, # value if isotropic 'U11': 11, # value if anisotropic 'U22': 12, # value if anisotropic 'U33': 13, # value if anisotropic 'U12': 14, # value if anisotropic 'U13': 15, # value if anisotropic 'U23': 16 # value if anisotropic # Note: index 17 is a random unique ID for the atom, not a parameter to set } phase_names = [p.name for p in proj.phases()] phase_idx = phase_names.index(phase_name) SGData = proj.data['Phases'][phase_name]['General']['SGData'] atom_labels_present = [x[0] for x in proj.data['Phases'][phase_name]['Atoms']] holds: list[str] = [] for atom_label, atom_params in atom_parameters_dict.items(): if atom_label not in atom_labels_present: print(f"Atom '{atom_label}' not found in phase '{phase_name}'. Skipping.") continue # Get the atom record atom_index = atom_labels_present.index(atom_label) atom_record = proj.data['Phases'][phase_name]['Atoms'][atom_index] # Get ADP type for this atom (required in schema 0.22) adp_type = atom_params.get('ADP') # Process coordinate parameters (x, y, z) — values only; refine flags # are translated by atom_refinement_plan below for coord in ['x', 'y', 'z']: if coord in atom_params and atom_params[coord] is not None: param_value = atom_params[coord] if param_value != [None, False, None, None]: value, refine_flag, min_val, max_val = unpack_refinement_parameter(param_value, coord) # Update coordinate value if provided if value is not None: atom_record[atom_param_index_mapping[coord]] = value # Process occupancy if 'occupancy' in atom_params and atom_params['occupancy'] is not None: param_value = atom_params['occupancy'] if param_value != [None, False, None, None]: value, refine_flag, min_val, max_val = unpack_refinement_parameter(param_value, 'occupancy') # Update occupancy value if provided if value is not None: atom_record[6] = value # Process displacement parameters based on ADP type if adp_type == 'Uiso': # Isotropic displacement parameters if 'Uiso' in atom_params and atom_params['Uiso'] is not None: param_value = atom_params['Uiso'] if param_value != [None, False, None, None]: value, refine_flag, min_val, max_val = unpack_refinement_parameter(param_value, 'Uiso') # Update Uiso value if provided if value is not None: atom_record[9] = 'I' # Set to isotropic atom_record[10] = value elif adp_type == 'Uaniso': # Anisotropic displacement parameters if 'Uaniso' in atom_params and atom_params['Uaniso'] is not None: uaniso_dict = atom_params['Uaniso'] # Track if any anisotropic values are being updated aniso_values_provided = [] # Process each anisotropic parameter for u_key in ['U11', 'U22', 'U33', 'U12', 'U13', 'U23']: if u_key in uaniso_dict and uaniso_dict[u_key] is not None: param_value = uaniso_dict[u_key] if param_value != [None, False, None, None]: value, refine_flag, min_val, max_val = unpack_refinement_parameter(param_value, u_key) # Update anisotropic value if provided if value is not None: atom_record[9] = 'A' # Set to anisotropic atom_record[atom_param_index_mapping[u_key]] = value aniso_values_provided.append(u_key) # Validate that if any anisotropic values are provided, all 6 must be provided if aniso_values_provided: required_keys = ['U11', 'U22', 'U33', 'U12', 'U13', 'U23'] missing_keys = [k for k in required_keys if k not in aniso_values_provided] if missing_keys: raise ValueError( f"Atom '{atom_label}' in phase '{phase_name}': " f"When updating anisotropic displacement parameters, all 6 values (U11-U23) must be provided. " f"Missing: {missing_keys}" ) # Translate per-parameter refine flags into GSAS-II's per-atom flag # string plus holds for the unrefined site-symmetry DOF groups. # xyz/SGData let the plan recompute a stale/blank site symmetry the # same way GSAS-II itself does (GetPhaseData's KeyError patch). atom_plan = atom_refinement_plan( atom_params, atom_record[7], phase_idx, atom_index, xyz=atom_record[3:6], SGData=SGData, ) atom_record[2] = atom_plan.refine_flags holds.extend(atom_plan.holds) # Verbose logging if print_info and atom_plan.refine_flags: adp_display = adp_type if adp_type else "inherited" print(f" Set refine flags for atom '{atom_label}' ({adp_display}): '{atom_plan.refine_flags}'") if atom_plan.holds: print(f" Holding fixed DOFs for atom '{atom_label}': {atom_plan.holds}") return holds # Main function to set phase parameterization from phases dictionary @require_phase("Cannot set preferred orientation.") def set_phase_preferred_orientation(proj: Any, hist: Any, phase_name: str, po_dict: dict, print_info: bool = False) -> None: """Write the recipe's March-Dollase block into the HAP ``'Pref.Ori.'`` entry. The entry GSAS-II created for the phase (``GSASIImath.SetDefaultDData``: ``['MD', 1.0, False, [0, 0, 1], 0, {}, [], 0.1]``) is kept and three slots are overwritten: ``[0]`` the type ``'MD'``, ``[1]`` the coefficient, ``[2]`` its refine flag, ``[3]`` the axis. Nothing else in the HAP dict changes. """ value, refine_flag, min_val, max_val = unpack_refinement_parameter(po_dict.get('ratio'), "ratio") entry = proj.data['Phases'][phase_name]['Histograms'][hist.name]['Pref.Ori.'] entry[0] = 'MD' if value is not None: entry[1] = float(value) if refine_flag is not None: entry[2] = bool(refine_flag) hkl = po_dict.get('hkl') if hkl is not None: entry[3] = [int(h) for h in hkl] if print_info: print(f"Set March-Dollase preferred orientation for phase '{phase_name}': r={value} refine={refine_flag} axis={entry[3]}") def set_phase_parameterization(proj: Any, hist: Any, phases_dict: dict, print_info: bool = False) -> list[str]: """ Set phase parameterization in the GSAS-II project from a phases dictionary. Parameters: proj : GSAS-II project object The GSAS-II project containing the phases. hist : GSAS-II histogram object The histogram associated with the phases. phases_dict : dict Dictionary containing phase parameterization information. E.g., payload['phases'] print_info : bool, optional If True, print information about the parameterization being set. Default is False. Returns: list[str] Accumulated GSAS-II "Hold" variable names from the unit-cell and atom setters (per-parameter refine flags). The caller applies them once via proj.add_HoldConstr. """ holds: list[str] = [] for phase_name, phase_info in phases_dict.items(): # In schema 0.21, phase_name is the dict key itself # (structure.phase_name should match, but we use the key for consistency) # Check that phase exists in project - this would indicate an error in phase addition earlier if phase_name not in [p.name for p in proj.phases()]: print(f"Phase '{phase_name}' not found in project. Skipping parameterization.") continue # Now that we have the phase name, we can add phase-specific parameterization here # This will call on a few helper functions to set scale, unit cell, peak broadening, atom parameters, etc. # Each of these helper functions will take the proj, phase name (rather than phase object), and relevant parameterization dict # The phase block itself holds values and refine flags (gsasii 0.1.0) param_dict = phase_info # Keys in param dict are 'scale', 'unit_cell', 'peak_broadening', and 'atoms' for now # It is not as simple as looping over keys since some have nested dicts and others hold values or lists # Set scale factor if provided scale_param = param_dict.get('scale', None) if scale_param is not None: if any(value is not None for value in scale_param): set_phase_scale(proj, hist, phase_name, scale_param, print_info=False) # Set unit cell parameters if provided. The key values can be None, # so here we cannot just check for unit_cell_dict not being None. # We need to check if any of hte keys in unit_cell_dict are not None. unit_cell_dict = param_dict.get('unit_cell', None) if unit_cell_dict is not None: if any(value is not None for value in unit_cell_dict.values()): holds.extend(set_phase_unit_cell(proj, phase_name, unit_cell_dict, print_info=False)) # Set peak broadening if provided. Keys are "size_broadening" and "strain_broadening" # and each holds a dict with relevant parameters (size or strain) and the LG_eta # ("or {}" because model_dump emits an explicit None when the section is absent) peak_broadening_dict = param_dict.get('peak_broadening') or {} size_broadening_dict = peak_broadening_dict.get('size_broadening', None) strain_broadening_dict = peak_broadening_dict.get('strain_broadening', None) if size_broadening_dict is not None: if any(value is not None for value in size_broadening_dict.values()): set_phase_size_broadening(proj, hist, phase_name, size_broadening_dict, print_info=False) if strain_broadening_dict is not None: if any(value is not None for value in strain_broadening_dict.values()): set_phase_strain_broadening(proj, hist, phase_name, strain_broadening_dict, print_info=False) # March-Dollase preferred orientation, when the phase block states it po_dict = param_dict.get('preferred_orientation') if po_dict: set_phase_preferred_orientation(proj, hist, phase_name, po_dict, print_info=False) atoms_parameters_dict = param_dict.get('atoms', {}) if atoms_parameters_dict and any(has_active_refinement_parameter(ap) for ap in atoms_parameters_dict.values()): holds.extend(set_phase_atom_parameters(proj, phase_name, atoms_parameters_dict, print_info=False)) if print_info: print(f"Completed parameterization for phase '{phase_name}'.") if print_info: print("All phase parameterization complete. Phases in proj: ", *[p.name for p in proj.phases()], sep='\n\t') if holds: print("Hold constraints to apply (per-parameter refine flags): ", *holds, sep='\n\t') return holds # Function for setting instrument parameterization def set_instrument_parameterization(proj: Any, hist: Any, instrument_param_dict: dict, print_info: bool = False) -> None: """ Set instrument parameterization in the GSAS-II project from an instrument parameterization dictionary. Parameters: proj : GSAS-II project object The GSAS-II project containing the histogram. hist : GSAS-II histogram object The histogram associated with the instrument. instrument_param_dict : dict Dictionary containing instrument parameterization information. E.g., payload['instrument'] print_info : bool, optional If True, print information about the parameterization being set. Default is False. Returns: None """ # Note: GSAS-II's instrument parameters hold values in a list. # The indices for each parameter (e.g., 'Lam' for wavelength) are as follows: # [0] default/starting value from iprms, [1] = value, [2] = refinement flag # Wavelength wavelength_param = instrument_param_dict.get('radiation', {}).get('wavelength', None) if wavelength_param is not None: value, refine_flag, min_val, max_val = unpack_refinement_parameter(wavelength_param, "wavelength") wavelength_changed = False # track if wavelength value changed # Set wavelength value curr_wavelength = proj.data[hist.name]['Instrument Parameters'][0]['Lam'][1] if value is not None and value != curr_wavelength: proj.data[hist.name]['Instrument Parameters'][0]['Lam'][1] = value wavelength_changed = True # Set refinement flag if refine_flag is not None: proj.data[hist.name]['Instrument Parameters'][0]['Lam'][2] = refine_flag if print_info: if wavelength_changed: print(f"Set instrument wavelength to {value} with refine_flag={refine_flag}") else: print(f"No change to instrument wavelength. Refine_flag={refine_flag}") # Additional instrument parameters (polarization, broadening, corrections) would be set similarly # Implementing those follows the same pattern as above # Polarization polarization_param = instrument_param_dict.get('corrections', {}).get('polarization', None) if polarization_param is not None: value, refine_flag, min_val, max_val = unpack_refinement_parameter(polarization_param, "polarization") polarization_changed = False # track if polarization value changed curr_polarization = proj.data[hist.name]['Instrument Parameters'][0]['Polariz.'][1] # Set polarization value if value is not None and value != curr_polarization: proj.data[hist.name]['Instrument Parameters'][0]['Polariz.'][1] = value polarization_changed = True # Set refinement flag if refine_flag is not None: proj.data[hist.name]['Instrument Parameters'][0]['Polariz.'][2] = refine_flag if print_info: if polarization_changed: print(f"Set instrument polarization to {value} with refine_flag={refine_flag}") else: print(f"No change to instrument polarization. Refine_flag={refine_flag}") # Broadening parameters (U, V, W, X, Y, Z) broadening_dict = instrument_param_dict.get('broadening', {}) for param_key in broadening_dict.keys(): broadening_param = broadening_dict.get(param_key, None) if broadening_param is not None: value, refine_flag, min_val, max_val = unpack_refinement_parameter(broadening_param, f"broadening.{param_key}") broadening_changed = False # Set broadening parameter value if value is not None: proj.data[hist.name]['Instrument Parameters'][0][param_key][1] = value broadening_changed = True # Set refinement flag if refine_flag is not None: proj.data[hist.name]['Instrument Parameters'][0][param_key][2] = refine_flag if print_info: if broadening_changed: # Print changed parameter print(f"Set instrument broadening parameter '{param_key}' to {value} with refine_flag={refine_flag}") # Always print refinement flag even if value not changed else: print(f"No change to instrument broadening parameter '{param_key}'. Refine_flag={refine_flag}") # Corrections parameters (zero_shift, axial_divergence, sample_height_displacement) corrections_dict = instrument_param_dict.get('corrections', {}) for param_key in corrections_dict.keys(): correction_param = corrections_dict.get(param_key, None) if correction_param is not None: # only loop over provided params if param_key == 'zero_shift': gsas_key = 'Zero' elif param_key == 'axial_divergence': gsas_key = 'SH/L' elif param_key == 'polarization': continue # set above ('Polariz.'); it sits in corrections #elif param_key == 'sample_height_displacement': # irrelevant to area detector data, future work for Bragg-Brentano geometry # gsas_key = 'SampHt' else: raise ValueError(f"Unknown instrument correction parameter key '{param_key}'.") value, refine_flag, min_val, max_val = unpack_refinement_parameter(correction_param, param_key) correction_changed = False # Set correction parameter value if value is not None: proj.data[hist.name]['Instrument Parameters'][0][gsas_key][1] = value correction_changed = True # Set refinement flag if refine_flag is not None: proj.data[hist.name]['Instrument Parameters'][0][gsas_key][2] = refine_flag if print_info: if correction_changed: # Print changed parameter print(f"Set instrument correction parameter '{param_key}' to {value} with refine_flag={refine_flag}") # Always print refinement flag even if value not changed else: print(f"No change to instrument correction parameter '{param_key}'. Refine_flag={refine_flag}") # Print instrument parameters set and flags if requested if print_info: # Check if 1st and 2nd indices are different for any instrument parameteters # If they are all the same, then no parameters were changed params_changed = False for param in proj.data[hist.name]['Instrument Parameters'][0].values(): if param[0] != param[1]: params_changed = True break if not params_changed: print("No instrument parameters were changed from their default values.") # print refine flags for all instrument parameters print("Instrument parameter refinement flags:") for param_key, param in proj.data[hist.name]['Instrument Parameters'][0].items(): print(f" {param_key}: refine_flag={param[2]}") def set_refinement_cycles(proj: Any, num_cycles: int, print_info: bool = False) -> None: """ Set the number of refinement cycles in the GSAS-II project. Parameters: proj : GSAS-II project object The GSAS-II project to set the refinement cycles for. num_cycles : int The number of refinement cycles to set. print_info : bool, optional If True, print information about the refinement cycles being set. Default is False. Returns: None """ proj.set_Controls('cycles', num_cycles) if print_info: print(f"Set number of refinement cycles to {num_cycles}")