Skip to main content

phasesmith_workflows/
quantitative.rs

1//! Quantitative crystalline phase fractions from compatible Rietveld scales.
2
3use std::error::Error;
4use std::fmt::{Display, Formatter};
5
6/// Metadata required by the Hill--Howard scale relation.
7#[derive(Clone, Debug, PartialEq)]
8pub struct QuantitativePhase {
9    /// Stable phase identity.
10    pub phase_id: String,
11    /// Compatible non-negative refined phase scale.
12    pub scale: f64,
13    /// Formula units per crystallographic unit cell.
14    pub formula_units_per_cell: f64,
15    /// Formula mass in grams per mole.
16    pub formula_mass_g_mol: f64,
17    /// Unit-cell volume in cubic ångströms.
18    pub cell_volume_angstrom3: f64,
19}
20
21impl QuantitativePhase {
22    /// Validate one quantitative phase record.
23    ///
24    /// # Errors
25    ///
26    /// Returns [`QuantitativeError`] for empty identity, negative scale, or
27    /// non-positive/non-finite physical metadata.
28    pub fn new(
29        phase_id: impl Into<String>,
30        scale: f64,
31        formula_units_per_cell: f64,
32        formula_mass_g_mol: f64,
33        cell_volume_angstrom3: f64,
34    ) -> Result<Self, QuantitativeError> {
35        let result = Self {
36            phase_id: phase_id.into(),
37            scale,
38            formula_units_per_cell,
39            formula_mass_g_mol,
40            cell_volume_angstrom3,
41        };
42        result.validate()?;
43        Ok(result)
44    }
45
46    fn validate(&self) -> Result<(), QuantitativeError> {
47        if self.phase_id.is_empty() {
48            return Err(QuantitativeError::EmptyPhaseId);
49        }
50        if !self.scale.is_finite() || self.scale < 0.0 {
51            return Err(QuantitativeError::InvalidScale);
52        }
53        if [
54            self.formula_units_per_cell,
55            self.formula_mass_g_mol,
56            self.cell_volume_angstrom3,
57        ]
58        .into_iter()
59        .any(|value| !value.is_finite() || value <= 0.0)
60        {
61            return Err(QuantitativeError::InvalidMetadata);
62        }
63        Ok(())
64    }
65}
66
67/// One normalized crystalline weight fraction.
68#[derive(Clone, Debug, PartialEq)]
69pub struct PhaseWeightFraction {
70    /// Stable phase identity.
71    pub phase_id: String,
72    /// Fraction of the supplied crystalline phases in `[0, 1]`.
73    pub weight_fraction: f64,
74}
75
76/// Calculate Hill--Howard crystalline weight fractions in input order.
77///
78/// `W_p = S_p (Z M V)_p / sum_i S_i (Z M V)_i`.
79///
80/// # Errors
81///
82/// Returns [`QuantitativeError`] for empty, duplicate, invalid, overflowing,
83/// or all-zero phase records.
84pub fn quantitative_phase_analysis(
85    phases: &[QuantitativePhase],
86) -> Result<Vec<PhaseWeightFraction>, QuantitativeError> {
87    if phases.is_empty() {
88        return Err(QuantitativeError::EmptyPhases);
89    }
90    let mut identities = std::collections::BTreeSet::new();
91    let mut contributions = Vec::with_capacity(phases.len());
92    for phase in phases {
93        phase.validate()?;
94        if !identities.insert(&phase.phase_id) {
95            return Err(QuantitativeError::DuplicatePhaseId);
96        }
97        let contribution = phase.scale
98            * phase.formula_units_per_cell
99            * phase.formula_mass_g_mol
100            * phase.cell_volume_angstrom3;
101        if !contribution.is_finite() {
102            return Err(QuantitativeError::ContributionOverflow);
103        }
104        contributions.push(contribution);
105    }
106    let total = contributions.iter().sum::<f64>();
107    if !total.is_finite() || total <= 0.0 {
108        return Err(QuantitativeError::ZeroTotal);
109    }
110    Ok(phases
111        .iter()
112        .zip(contributions)
113        .map(|(phase, contribution)| PhaseWeightFraction {
114            phase_id: phase.phase_id.clone(),
115            weight_fraction: contribution / total,
116        })
117        .collect())
118}
119
120/// Invalid quantitative-phase input.
121#[derive(Clone, Copy, Debug, PartialEq, Eq)]
122pub enum QuantitativeError {
123    /// At least one phase is required.
124    EmptyPhases,
125    /// Phase identity must not be empty.
126    EmptyPhaseId,
127    /// Phase identities must be unique.
128    DuplicatePhaseId,
129    /// Scale must be finite and non-negative.
130    InvalidScale,
131    /// Z, mass, and cell volume must be positive and finite.
132    InvalidMetadata,
133    /// A scale-times-metadata product overflowed.
134    ContributionOverflow,
135    /// At least one phase scale must be positive.
136    ZeroTotal,
137}
138
139impl Display for QuantitativeError {
140    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
141        formatter.write_str(match self {
142            Self::EmptyPhases => "quantitative phase analysis requires at least one phase",
143            Self::EmptyPhaseId => "quantitative phase IDs must not be empty",
144            Self::DuplicatePhaseId => "quantitative phase IDs must be unique",
145            Self::InvalidScale => "quantitative phase scales must be finite and non-negative",
146            Self::InvalidMetadata => "quantitative Z, mass, and volume must be positive and finite",
147            Self::ContributionOverflow => "quantitative phase contribution overflowed",
148            Self::ZeroTotal => "at least one quantitative phase scale must be positive",
149        })
150    }
151}
152
153impl Error for QuantitativeError {}