Skip to main content

phasesmith_workflows/
profile_estimation.rs

1//! Staged estimation of an effective CW starting profile from one dominant phase.
2
3use std::error::Error;
4use std::fmt::{Display, Formatter};
5
6use phasesmith_core::{ConstantWavelengthInstrument, CwProfileParameters};
7use phasesmith_execution::ExecutionPolicy;
8use phasesmith_model::PatternRecord;
9
10use crate::{
11    CovarianceMatrix, LeBailError, LeBailInput, LeBailOptions, LeBailPhase, LeBailResult,
12    build_lebail_parameter_set, build_lebail_parameter_set_with_lattice, refine_lebail,
13};
14
15const GAUSSIAN_FWHM_PER_SIGMA: f64 = 2.354_820_045_030_949_3;
16const W_PARAMETERS: [&str; 1] = ["w_deg2"];
17const UVW_PARAMETERS: [&str; 3] = ["u_deg2", "v_deg2", "w_deg2"];
18const UVWXY_PARAMETERS: [&str; 5] = ["u_deg2", "v_deg2", "w_deg2", "x_deg", "y_deg"];
19
20/// Requested complexity for effective-profile estimation.
21#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22pub enum ProfileEstimationMode {
23    /// Fit only the constant Gaussian variance `W`.
24    WOnly,
25    /// Fit `W`, then explicitly fit `U/V/W`.
26    Uvw,
27    /// Fit `W`, `U/V/W`, then explicitly fit `U/V/W/X/Y`.
28    Uvwxy,
29    /// Add `U/V` and then `X/Y` only when conservative diagnostics support them.
30    Automatic,
31}
32
33impl ProfileEstimationMode {
34    /// Return the stable scripting/wire label.
35    #[must_use]
36    pub const fn as_str(self) -> &'static str {
37        match self {
38            Self::WOnly => "w_only",
39            Self::Uvw => "uvw",
40            Self::Uvwxy => "uvwxy",
41            Self::Automatic => "automatic",
42        }
43    }
44}
45
46/// Stable name for one attempted estimation stage.
47#[derive(Clone, Copy, Debug, PartialEq, Eq)]
48pub enum ProfileEstimationStageKind {
49    /// Optional bounded lattice alignment with the profile held fixed.
50    LatticeAlignment,
51    /// Constant Gaussian variance only.
52    W,
53    /// Caglioti `U/V/W` Gaussian variance.
54    Uvw,
55    /// Full TCH `U/V/W/X/Y` effective profile.
56    Uvwxy,
57}
58
59impl ProfileEstimationStageKind {
60    /// Return the stable scripting/wire label.
61    #[must_use]
62    pub const fn as_str(self) -> &'static str {
63        match self {
64            Self::LatticeAlignment => "lattice_alignment",
65            Self::W => "w",
66            Self::Uvw => "uvw",
67            Self::Uvwxy => "uvwxy",
68        }
69    }
70}
71
72/// Validated input for one predominantly single-phase profile estimate.
73#[derive(Clone, Debug, PartialEq)]
74pub struct ProfileEstimationInput {
75    /// Integrated one-dimensional pattern. Existing masks and background are respected.
76    pub pattern: PatternRecord,
77    /// Fixed-wavelength starting profile. The wavelength is never selected for refinement.
78    pub instrument: ConstantWavelengthInstrument,
79    /// Dominant phase with independent starting reflection intensities.
80    pub phase: LeBailPhase,
81}
82
83impl ProfileEstimationInput {
84    /// Validate and own the estimation input.
85    ///
86    /// # Errors
87    ///
88    /// Returns [`ProfileEstimationError`] for invalid pattern, instrument, or phase state.
89    pub fn new(
90        pattern: PatternRecord,
91        instrument: ConstantWavelengthInstrument,
92        phase: LeBailPhase,
93    ) -> Result<Self, ProfileEstimationError> {
94        LeBailInput::new(pattern.clone(), instrument, vec![phase.clone()])?;
95        Ok(Self {
96            pattern,
97            instrument,
98            phase,
99        })
100    }
101}
102
103/// Deterministic controls for staged effective-profile estimation.
104#[derive(Clone, Debug, PartialEq)]
105pub struct ProfileEstimationOptions {
106    /// Requested model-selection behavior.
107    pub mode: ProfileEstimationMode,
108    /// Align a bounded dynamic phase cell before fitting widths.
109    pub align_lattice: bool,
110    /// Minimum fractional Rwp decrease required by an automatic complexity increase.
111    pub minimum_relative_rwp_improvement: f64,
112    /// Minimum absolute Rwp decrease required by an automatic complexity increase.
113    pub minimum_absolute_rwp_improvement: f64,
114    /// Largest permitted absolute covariance correlation for an automatic candidate.
115    pub maximum_absolute_correlation: f64,
116    /// Underlying independent-intensity extraction and profile-solver controls.
117    pub lebail: LeBailOptions,
118}
119
120impl ProfileEstimationOptions {
121    /// Construct and validate explicit estimation controls.
122    ///
123    /// # Errors
124    ///
125    /// Returns [`ProfileEstimationError::InvalidOptions`] for invalid thresholds.
126    pub fn new(
127        mode: ProfileEstimationMode,
128        align_lattice: bool,
129        minimum_relative_rwp_improvement: f64,
130        minimum_absolute_rwp_improvement: f64,
131        maximum_absolute_correlation: f64,
132        lebail: LeBailOptions,
133    ) -> Result<Self, ProfileEstimationError> {
134        let options = Self {
135            mode,
136            align_lattice,
137            minimum_relative_rwp_improvement,
138            minimum_absolute_rwp_improvement,
139            maximum_absolute_correlation,
140            lebail,
141        };
142        options.validate()?;
143        Ok(options)
144    }
145
146    /// Conservative scripting defaults with an explicit native execution policy.
147    ///
148    /// Automatic candidates must lower Rwp by at least 0.2% relative and
149    /// `1e-5` absolute, and keep every pairwise absolute covariance
150    /// correlation at or below 0.98.
151    ///
152    /// # Errors
153    ///
154    /// Returns [`ProfileEstimationError`] if the underlying controls are invalid.
155    pub fn scripting_defaults(execution: ExecutionPolicy) -> Result<Self, ProfileEstimationError> {
156        Self::new(
157            ProfileEstimationMode::Automatic,
158            false,
159            0.002,
160            1.0e-5,
161            0.98,
162            LeBailOptions::scripting_defaults(execution)?,
163        )
164    }
165
166    fn validate(&self) -> Result<(), ProfileEstimationError> {
167        if !self.minimum_relative_rwp_improvement.is_finite()
168            || self.minimum_relative_rwp_improvement < 0.0
169        {
170            return Err(ProfileEstimationError::InvalidOptions {
171                message: "minimum_relative_rwp_improvement must be non-negative and finite"
172                    .to_owned(),
173            });
174        }
175        if !self.minimum_absolute_rwp_improvement.is_finite()
176            || self.minimum_absolute_rwp_improvement < 0.0
177        {
178            return Err(ProfileEstimationError::InvalidOptions {
179                message: "minimum_absolute_rwp_improvement must be non-negative and finite"
180                    .to_owned(),
181            });
182        }
183        if !self.maximum_absolute_correlation.is_finite()
184            || !(0.0..1.0).contains(&self.maximum_absolute_correlation)
185        {
186            return Err(ProfileEstimationError::InvalidOptions {
187                message: "maximum_absolute_correlation must lie in [0, 1)".to_owned(),
188            });
189        }
190        Ok(())
191    }
192}
193
194/// Compact audit record for one attempted stage.
195#[derive(Clone, Debug, PartialEq)]
196pub struct ProfileEstimationStage {
197    /// Stable stage identity.
198    pub kind: ProfileEstimationStageKind,
199    /// Instrument parameters selected during this stage.
200    pub instrument_parameters: Vec<String>,
201    /// Whether this stage became the starting state for the next stage/final result.
202    pub accepted: bool,
203    /// Machine-displayable decision explanation.
204    pub decision: String,
205    /// Rwp obtained by this candidate.
206    pub rwp: f64,
207    /// Candidate profile, retained even when automatic selection rejects it.
208    pub instrument: ConstantWavelengthInstrument,
209    /// Largest absolute off-diagonal covariance correlation, when identifiable.
210    pub maximum_absolute_correlation: Option<f64>,
211}
212
213/// Final effective starting profile and the complete staged selection audit.
214#[derive(Clone, Debug, PartialEq)]
215pub struct ProfileEstimationResult {
216    /// Accepted profile model.
217    pub instrument: ConstantWavelengthInstrument,
218    /// Accepted instrument parameter names in stable order.
219    pub active_parameters: Vec<String>,
220    /// Final Le Bail result, including independent intensities and covariance.
221    pub lebail: LeBailResult,
222    /// Every attempted stage in execution order.
223    pub stages: Vec<ProfileEstimationStage>,
224    /// Scientific-interpretation warnings.
225    pub warnings: Vec<String>,
226}
227
228/// Construct a valid all-Gaussian starting profile from an approximate FWHM.
229///
230/// The returned `W` is `(FWHM / 2.354820045...)^2`; `U`, `V`, `X`, and `Y`
231/// are zero. This is only an optimizer seed, not a calibrated instrument model.
232///
233/// # Errors
234///
235/// Returns [`ProfileEstimationError`] for a non-positive/non-finite input.
236pub fn starting_profile_from_fwhm(
237    wavelength_angstrom: f64,
238    fwhm_deg: f64,
239) -> Result<ConstantWavelengthInstrument, ProfileEstimationError> {
240    if !wavelength_angstrom.is_finite()
241        || wavelength_angstrom <= 0.0
242        || !fwhm_deg.is_finite()
243        || fwhm_deg <= 0.0
244    {
245        return Err(ProfileEstimationError::InvalidStartingFwhm);
246    }
247    Ok(ConstantWavelengthInstrument {
248        wavelength_angstrom,
249        u_deg2: 0.0,
250        v_deg2: 0.0,
251        w_deg2: (fwhm_deg / GAUSSIAN_FWHM_PER_SIGMA).powi(2),
252        x_deg: 0.0,
253        y_deg: 0.0,
254    })
255}
256
257/// Estimate an effective CW starting profile from one dominant phase.
258///
259/// Wavelength is immutable by construction: no wavelength parameter is added
260/// to any stage, and the final bit pattern is checked against the input. The
261/// result is deliberately called *effective* because crystallite size,
262/// microstrain, pressure gradients, and other sample broadening can be folded
263/// into the fitted coefficients.
264///
265/// # Errors
266///
267/// Returns [`ProfileEstimationError`] for invalid controls, an unavailable
268/// lattice-alignment domain, or a failed Le Bail stage.
269pub fn estimate_effective_profile(
270    input: &ProfileEstimationInput,
271    options: &ProfileEstimationOptions,
272) -> Result<ProfileEstimationResult, ProfileEstimationError> {
273    options.validate()?;
274    let wavelength_bits = input.instrument.wavelength_angstrom.to_bits();
275    let mut instrument = input.instrument;
276    let mut phases = vec![input.phase.clone()];
277    let mut stages = Vec::new();
278
279    if options.align_lattice {
280        let aligned = align_lattice_stage(input, instrument, phases, options, wavelength_bits)?;
281        instrument = aligned.0;
282        phases = aligned.1;
283        stages.push(aligned.2);
284    }
285
286    let mut accepted = fit_profile_stage(
287        &input.pattern,
288        instrument,
289        phases,
290        &W_PARAMETERS,
291        &options.lebail,
292    )?;
293    ensure_fixed_wavelength(wavelength_bits, accepted.instrument)?;
294    validate_profile_domain(&input.pattern, &accepted)?;
295    stages.push(stage_record(
296        ProfileEstimationStageKind::W,
297        &W_PARAMETERS,
298        &accepted,
299        true,
300        "accepted required baseline model".to_owned(),
301    ));
302    let mut active = W_PARAMETERS.iter().map(ToString::to_string).collect();
303
304    if options.mode != ProfileEstimationMode::WOnly {
305        let uvw = fit_profile_stage(
306            &input.pattern,
307            accepted.instrument,
308            accepted.phases.clone(),
309            &UVW_PARAMETERS,
310            &options.lebail,
311        )?;
312        ensure_fixed_wavelength(wavelength_bits, uvw.instrument)?;
313        validate_profile_domain(&input.pattern, &uvw)?;
314        let (accept, decision) = candidate_decision(&accepted, &uvw, options, 3);
315        let forced = options.mode != ProfileEstimationMode::Automatic;
316        let accepted_stage = forced || accept;
317        stages.push(stage_record(
318            ProfileEstimationStageKind::Uvw,
319            &UVW_PARAMETERS,
320            &uvw,
321            accepted_stage,
322            if forced {
323                "accepted because UVW was explicitly requested".to_owned()
324            } else {
325                decision
326            },
327        ));
328        if accepted_stage {
329            accepted = uvw;
330            active = UVW_PARAMETERS.iter().map(ToString::to_string).collect();
331        } else {
332            return finish_result(accepted, active, stages, wavelength_bits);
333        }
334    }
335
336    if options.mode == ProfileEstimationMode::Uvwxy
337        || options.mode == ProfileEstimationMode::Automatic
338    {
339        let uvwxy = fit_profile_stage(
340            &input.pattern,
341            accepted.instrument,
342            accepted.phases.clone(),
343            &UVWXY_PARAMETERS,
344            &options.lebail,
345        )?;
346        ensure_fixed_wavelength(wavelength_bits, uvwxy.instrument)?;
347        validate_profile_domain(&input.pattern, &uvwxy)?;
348        let (accept, decision) = candidate_decision(&accepted, &uvwxy, options, 5);
349        let forced = options.mode == ProfileEstimationMode::Uvwxy;
350        let accepted_stage = forced || accept;
351        stages.push(stage_record(
352            ProfileEstimationStageKind::Uvwxy,
353            &UVWXY_PARAMETERS,
354            &uvwxy,
355            accepted_stage,
356            if forced {
357                "accepted because UVWXY was explicitly requested".to_owned()
358            } else {
359                decision
360            },
361        ));
362        if accepted_stage {
363            accepted = uvwxy;
364            active = UVWXY_PARAMETERS.iter().map(ToString::to_string).collect();
365        }
366    }
367
368    finish_result(accepted, active, stages, wavelength_bits)
369}
370
371fn align_lattice_stage(
372    input: &ProfileEstimationInput,
373    instrument: ConstantWavelengthInstrument,
374    phases: Vec<LeBailPhase>,
375    options: &ProfileEstimationOptions,
376    wavelength_bits: u64,
377) -> Result<
378    (
379        ConstantWavelengthInstrument,
380        Vec<LeBailPhase>,
381        ProfileEstimationStage,
382    ),
383    ProfileEstimationError,
384> {
385    if phases[0].reflection_domain().is_none() {
386        return Err(ProfileEstimationError::LatticeAlignmentRequiresDynamicPhase);
387    }
388    let parameters =
389        build_lebail_parameter_set_with_lattice(instrument, &phases, &[], false, false, true)?;
390    let result = run_stage(
391        &input.pattern,
392        instrument,
393        phases,
394        parameters,
395        &options.lebail,
396    )?;
397    ensure_fixed_wavelength(wavelength_bits, result.instrument)?;
398    let stage = ProfileEstimationStage {
399        kind: ProfileEstimationStageKind::LatticeAlignment,
400        instrument_parameters: Vec::new(),
401        accepted: true,
402        decision: "accepted bounded nuisance lattice alignment".to_owned(),
403        rwp: result.metrics.rwp,
404        instrument: result.instrument,
405        maximum_absolute_correlation: covariance_maximum_correlation(result.covariance.as_ref()),
406    };
407    Ok((result.instrument, result.phases, stage))
408}
409
410fn fit_profile_stage(
411    pattern: &PatternRecord,
412    instrument: ConstantWavelengthInstrument,
413    phases: Vec<LeBailPhase>,
414    names: &[&str],
415    options: &LeBailOptions,
416) -> Result<LeBailResult, ProfileEstimationError> {
417    let parameters = build_lebail_parameter_set(instrument, &phases, names, false, false)?;
418    run_stage(pattern, instrument, phases, parameters, options)
419}
420
421fn run_stage(
422    pattern: &PatternRecord,
423    instrument: ConstantWavelengthInstrument,
424    phases: Vec<LeBailPhase>,
425    parameters: crate::ParameterSet,
426    options: &LeBailOptions,
427) -> Result<LeBailResult, ProfileEstimationError> {
428    let request = LeBailInput::new_with_parameters(
429        pattern.clone(),
430        instrument,
431        phases,
432        parameters,
433        Vec::new(),
434    )?;
435    refine_lebail(&request, options, None).map_err(ProfileEstimationError::LeBail)
436}
437
438fn candidate_decision(
439    baseline: &LeBailResult,
440    candidate: &LeBailResult,
441    options: &ProfileEstimationOptions,
442    expected_parameters: usize,
443) -> (bool, String) {
444    let denominator = baseline.metrics.rwp.abs().max(f64::MIN_POSITIVE);
445    let absolute_improvement = baseline.metrics.rwp - candidate.metrics.rwp;
446    let improvement = absolute_improvement / denominator;
447    if absolute_improvement < options.minimum_absolute_rwp_improvement {
448        return (
449            false,
450            format!(
451                "rejected: absolute Rwp improvement {absolute_improvement:.6} is below {:.6}",
452                options.minimum_absolute_rwp_improvement
453            ),
454        );
455    }
456    if improvement < options.minimum_relative_rwp_improvement {
457        return (
458            false,
459            format!(
460                "rejected: relative Rwp improvement {improvement:.6} is below {:.6}",
461                options.minimum_relative_rwp_improvement
462            ),
463        );
464    }
465    let Some(covariance) = candidate.covariance.as_ref() else {
466        return (
467            false,
468            "rejected: candidate covariance is not identifiable".to_owned(),
469        );
470    };
471    if covariance.size != expected_parameters {
472        return (
473            false,
474            "rejected: candidate covariance dimension is inconsistent".to_owned(),
475        );
476    }
477    let Some(correlation) = covariance_maximum_correlation(Some(covariance)) else {
478        return (
479            false,
480            "rejected: candidate covariance has a non-positive variance".to_owned(),
481        );
482    };
483    if correlation > options.maximum_absolute_correlation {
484        return (
485            false,
486            format!(
487                "rejected: maximum absolute correlation {correlation:.6} exceeds {:.6}",
488                options.maximum_absolute_correlation
489            ),
490        );
491    }
492    (
493        true,
494        format!(
495            "accepted: relative Rwp improvement {improvement:.6}, maximum absolute correlation {correlation:.6}"
496        ),
497    )
498}
499
500fn covariance_maximum_correlation(covariance: Option<&CovarianceMatrix>) -> Option<f64> {
501    let covariance = covariance?;
502    if covariance.size == 0 {
503        return Some(0.0);
504    }
505    let mut maximum = 0.0_f64;
506    for row in 0..covariance.size {
507        let row_variance = covariance.values[row * covariance.size + row];
508        if !row_variance.is_finite() || row_variance <= 0.0 {
509            return None;
510        }
511        for column in 0..row {
512            let column_variance = covariance.values[column * covariance.size + column];
513            if !column_variance.is_finite() || column_variance <= 0.0 {
514                return None;
515            }
516            let correlation = covariance.values[row * covariance.size + column].abs()
517                / (row_variance * column_variance).sqrt();
518            if !correlation.is_finite() {
519                return None;
520            }
521            maximum = maximum.max(correlation);
522        }
523    }
524    Some(maximum)
525}
526
527fn stage_record(
528    kind: ProfileEstimationStageKind,
529    parameters: &[&str],
530    result: &LeBailResult,
531    accepted: bool,
532    decision: String,
533) -> ProfileEstimationStage {
534    ProfileEstimationStage {
535        kind,
536        instrument_parameters: parameters.iter().map(ToString::to_string).collect(),
537        accepted,
538        decision,
539        rwp: result.metrics.rwp,
540        instrument: result.instrument,
541        maximum_absolute_correlation: covariance_maximum_correlation(result.covariance.as_ref()),
542    }
543}
544
545fn validate_profile_domain(
546    pattern: &PatternRecord,
547    result: &LeBailResult,
548) -> Result<(), ProfileEstimationError> {
549    let mut angles = result
550        .phases
551        .iter()
552        .flat_map(|phase| phase.two_theta_deg().iter().copied())
553        .collect::<Vec<_>>();
554    if let (Some(first), Some(last)) = (pattern.x_deg.first(), pattern.x_deg.last()) {
555        if *first > 0.0 && *first < 180.0 {
556            angles.push(*first);
557        }
558        if *last > 0.0 && *last < 180.0 {
559            angles.push(*last);
560        }
561    }
562    for angle in angles {
563        CwProfileParameters::from_instrument(angle, result.instrument).map_err(|error| {
564            ProfileEstimationError::ProfileOutsideDomain {
565                angle_deg: angle,
566                message: error.to_string(),
567            }
568        })?;
569    }
570    Ok(())
571}
572
573fn ensure_fixed_wavelength(
574    expected_bits: u64,
575    instrument: ConstantWavelengthInstrument,
576) -> Result<(), ProfileEstimationError> {
577    if instrument.wavelength_angstrom.to_bits() != expected_bits {
578        return Err(ProfileEstimationError::WavelengthChanged);
579    }
580    Ok(())
581}
582
583fn finish_result(
584    accepted: LeBailResult,
585    active_parameters: Vec<String>,
586    stages: Vec<ProfileEstimationStage>,
587    wavelength_bits: u64,
588) -> Result<ProfileEstimationResult, ProfileEstimationError> {
589    ensure_fixed_wavelength(wavelength_bits, accepted.instrument)?;
590    Ok(ProfileEstimationResult {
591        instrument: accepted.instrument,
592        active_parameters,
593        lebail: accepted,
594        stages,
595        warnings: vec![
596            "effective profile may include crystallite-size, microstrain, pressure-gradient, and other sample broadening"
597                .to_owned(),
598            "mask unidentified impurity peaks or excluded regions before using this estimate"
599                .to_owned(),
600        ],
601    })
602}
603
604/// Errors from effective-profile estimation.
605#[derive(Debug)]
606pub enum ProfileEstimationError {
607    /// Invalid estimator controls.
608    InvalidOptions {
609        /// Human-readable validation detail.
610        message: String,
611    },
612    /// Approximate FWHM or wavelength cannot seed a valid profile.
613    InvalidStartingFwhm,
614    /// Lattice alignment was requested for a fixed-reflection phase.
615    LatticeAlignmentRequiresDynamicPhase,
616    /// A stage unexpectedly changed the fixed wavelength.
617    WavelengthChanged,
618    /// A fitted profile is invalid at an observed or reflection angle.
619    ProfileOutsideDomain {
620        /// Angle at which validation failed.
621        angle_deg: f64,
622        /// Underlying profile-domain failure.
623        message: String,
624    },
625    /// Underlying Le Bail validation or solve failure.
626    LeBail(LeBailError),
627}
628
629impl Display for ProfileEstimationError {
630    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
631        match self {
632            Self::InvalidOptions { message } => write!(formatter, "invalid options: {message}"),
633            Self::InvalidStartingFwhm => write!(
634                formatter,
635                "starting wavelength and approximate FWHM must be positive and finite"
636            ),
637            Self::LatticeAlignmentRequiresDynamicPhase => write!(
638                formatter,
639                "lattice alignment requires a phase with a bounded lattice reflection domain"
640            ),
641            Self::WavelengthChanged => {
642                write!(formatter, "fixed wavelength changed during estimation")
643            }
644            Self::ProfileOutsideDomain { angle_deg, message } => write!(
645                formatter,
646                "fitted profile is invalid at {angle_deg} degrees 2theta: {message}"
647            ),
648            Self::LeBail(error) => Display::fmt(error, formatter),
649        }
650    }
651}
652
653impl Error for ProfileEstimationError {
654    fn source(&self) -> Option<&(dyn Error + 'static)> {
655        match self {
656            Self::LeBail(error) => Some(error),
657            _ => None,
658        }
659    }
660}
661
662impl From<LeBailError> for ProfileEstimationError {
663    fn from(value: LeBailError) -> Self {
664        Self::LeBail(value)
665    }
666}