Skip to main content

phasesmith_workflows/
phase_scale_estimation.rs

1//! Deterministic weighted non-negative initialization of Rietveld phase scales.
2//!
3//! The estimator evaluates every structural phase at unit scale on the input's
4//! exact finite-support convention. For each mask-included sample `i`, it solves
5//!
6//! `min_(s >= 0) Σ_i ((y_obs,i - y_background,i - Σ_p A_ip s_p) / σ_i)^2`,
7//!
8//! where `A_ip` is phase `p`'s unit-scale profile. One-sigma uncertainties are
9//! used only when the supplied calculation options request them. The complete
10//! pattern grid remains unchanged; the result owns a cloned [`RietveldInput`]
11//! whose phase definitions contain the estimated scales.
12
13use std::error::Error;
14use std::fmt::{Display, Formatter};
15
16use nalgebra::{DMatrix, DVector};
17
18use crate::{RietveldCalculationOptions, RietveldError, RietveldInput, calculate_rietveld_pattern};
19
20/// Diagnostics and restart-ready input produced by phase-scale initialization.
21#[derive(Clone, Debug, PartialEq)]
22pub struct PhaseScaleEstimationResult {
23    /// Cloned input with the estimated scale installed in every phase.
24    pub input: RietveldInput,
25    /// Estimated non-negative phase scales in input phase order.
26    pub scales: Vec<f64>,
27    /// Number of mask-included observations used by the solve.
28    pub included_points: usize,
29    /// Number of strictly positive fitted phase scales.
30    pub active_phases: usize,
31    /// Number of deterministic outer active-set iterations.
32    pub iterations: usize,
33    /// Final weighted residual sum of squares of the linear initialization.
34    pub weighted_residual_sum_squares: f64,
35}
36
37/// Failure to build or solve the weighted phase-scale initialization problem.
38#[derive(Debug)]
39pub enum PhaseScaleEstimationError {
40    /// The input has no observation array.
41    MissingObservations,
42    /// The mask excludes every observation.
43    EmptyDomain,
44    /// Matrix allocation size overflowed `usize`.
45    AllocationOverflow,
46    /// A native Rietveld calculation or phase replacement failed.
47    Rietveld(RietveldError),
48    /// An active least-squares subproblem was singular or non-finite.
49    LinearSolve,
50    /// The bounded deterministic active-set solve did not converge.
51    DidNotConverge,
52}
53
54impl Display for PhaseScaleEstimationError {
55    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
56        match self {
57            Self::MissingObservations => {
58                formatter.write_str("phase-scale estimation requires observed intensities")
59            }
60            Self::EmptyDomain => {
61                formatter.write_str("phase-scale estimation has no mask-included observations")
62            }
63            Self::AllocationOverflow => {
64                formatter.write_str("phase-scale estimation matrix allocation overflow")
65            }
66            Self::Rietveld(error) => Display::fmt(error, formatter),
67            Self::LinearSolve => {
68                formatter.write_str("phase-scale active least-squares solve failed")
69            }
70            Self::DidNotConverge => {
71                formatter.write_str("phase-scale active-set solve did not converge")
72            }
73        }
74    }
75}
76
77impl Error for PhaseScaleEstimationError {
78    fn source(&self) -> Option<&(dyn Error + 'static)> {
79        match self {
80            Self::Rietveld(error) => Some(error),
81            _ => None,
82        }
83    }
84}
85
86impl From<RietveldError> for PhaseScaleEstimationError {
87    fn from(value: RietveldError) -> Self {
88        Self::Rietveld(value)
89    }
90}
91
92/// Estimate and install deterministic weighted non-negative phase scales.
93///
94/// # Errors
95///
96/// Returns [`PhaseScaleEstimationError`] when observations are absent, the
97/// mask excludes every sample, native profile evaluation fails, or an active
98/// least-squares subproblem cannot be solved deterministically.
99pub fn estimate_initial_phase_scales(
100    input: &RietveldInput,
101    options: &RietveldCalculationOptions,
102) -> Result<PhaseScaleEstimationResult, PhaseScaleEstimationError> {
103    input.validate()?;
104    let observed = input
105        .pattern
106        .observed_y
107        .as_ref()
108        .ok_or(PhaseScaleEstimationError::MissingObservations)?;
109    let mut unit_input = input.clone();
110    unit_input.phases = input
111        .phases
112        .iter()
113        .map(|phase| {
114            let mut definition = phase.definition().clone();
115            definition.scale = 1.0;
116            phase.with_definition(definition)
117        })
118        .collect::<Result<Vec<_>, _>>()?;
119    let calculation = calculate_rietveld_pattern(&unit_input, options)?;
120    let included_indices = (0..input.pattern.sample_count())
121        .filter(|index| input.pattern.mask.as_ref().is_none_or(|mask| mask[*index]))
122        .collect::<Vec<_>>();
123    if included_indices.is_empty() {
124        return Err(PhaseScaleEstimationError::EmptyDomain);
125    }
126    let rows = included_indices.len();
127    let columns = calculation.phases.len();
128    let capacity = rows
129        .checked_mul(columns)
130        .ok_or(PhaseScaleEstimationError::AllocationOverflow)?;
131    let mut design = Vec::with_capacity(capacity);
132    let mut target = Vec::with_capacity(rows);
133    for index in included_indices {
134        let sigma = if options.use_uncertainty {
135            input
136                .pattern
137                .uncertainty
138                .as_ref()
139                .map_or(1.0, |values| values[index])
140        } else {
141            1.0
142        };
143        for phase in &calculation.phases {
144            design.push(phase.result.accumulation.y[index] / sigma);
145        }
146        target.push((observed[index] - calculation.background_y[index]) / sigma);
147    }
148    let matrix = DMatrix::from_row_slice(rows, columns, &design);
149    let target = DVector::from_vec(target);
150    let (scales, iterations) = solve_non_negative_least_squares(&matrix, &target)?;
151    let residual = &matrix * &scales - target;
152    let weighted_residual_sum_squares = residual.dot(&residual);
153    let scales = scales.as_slice().to_vec();
154    if !weighted_residual_sum_squares.is_finite()
155        || scales
156            .iter()
157            .any(|scale| !scale.is_finite() || *scale < 0.0)
158    {
159        return Err(PhaseScaleEstimationError::LinearSolve);
160    }
161    let mut estimated_input = input.clone();
162    estimated_input.phases = input
163        .phases
164        .iter()
165        .zip(&scales)
166        .map(|(phase, scale)| {
167            let mut definition = phase.definition().clone();
168            definition.scale = *scale;
169            phase.with_definition(definition)
170        })
171        .collect::<Result<Vec<_>, _>>()?;
172    estimated_input.validate()?;
173    Ok(PhaseScaleEstimationResult {
174        input: estimated_input,
175        active_phases: scales.iter().filter(|scale| **scale > 0.0).count(),
176        included_points: rows,
177        scales,
178        iterations,
179        weighted_residual_sum_squares,
180    })
181}
182
183fn solve_non_negative_least_squares(
184    matrix: &DMatrix<f64>,
185    target: &DVector<f64>,
186) -> Result<(DVector<f64>, usize), PhaseScaleEstimationError> {
187    let columns = matrix.ncols();
188    let gram = matrix.transpose() * matrix;
189    let correlation = matrix.transpose() * target;
190    let tolerance = 1.0e-12
191        * correlation
192            .iter()
193            .fold(1.0_f64, |maximum, value| maximum.max(value.abs()));
194    let maximum_iterations = columns
195        .checked_mul(columns)
196        .and_then(|value| value.checked_mul(10))
197        .and_then(|value| value.checked_add(10))
198        .ok_or(PhaseScaleEstimationError::AllocationOverflow)?;
199    let mut solution = DVector::zeros(columns);
200    let mut passive = vec![false; columns];
201    let mut iterations = 0usize;
202
203    loop {
204        let gradient = &correlation - &gram * &solution;
205        let candidate = (0..columns)
206            .filter(|index| !passive[*index] && gradient[*index] > tolerance)
207            .max_by(|left, right| {
208                gradient[*left]
209                    .total_cmp(&gradient[*right])
210                    .then_with(|| right.cmp(left))
211            });
212        let Some(candidate) = candidate else {
213            return Ok((solution, iterations));
214        };
215        passive[candidate] = true;
216        iterations += 1;
217        if iterations > maximum_iterations {
218            return Err(PhaseScaleEstimationError::DidNotConverge);
219        }
220
221        loop {
222            let active = passive
223                .iter()
224                .enumerate()
225                .filter_map(|(index, selected)| selected.then_some(index))
226                .collect::<Vec<_>>();
227            // A boundary step can remove every passive variable. Restart the
228            // outer search instead of asking nalgebra to decompose an m x 0
229            // matrix; SVD deliberately rejects empty matrices.
230            if active.is_empty() {
231                break;
232            }
233            let active_matrix = DMatrix::from_fn(matrix.nrows(), active.len(), |row, column| {
234                matrix[(row, active[column])]
235            });
236            let active_solution = active_matrix
237                .svd(true, true)
238                .solve(target, f64::EPSILON)
239                .map_err(|_| PhaseScaleEstimationError::LinearSolve)?;
240            if active_solution.iter().any(|value| !value.is_finite()) {
241                return Err(PhaseScaleEstimationError::LinearSolve);
242            }
243            let mut candidate_solution = DVector::zeros(columns);
244            for (active_index, value) in active.into_iter().zip(active_solution.iter()) {
245                candidate_solution[active_index] = *value;
246            }
247            // Feasibility is a condition on the scale itself. The dual
248            // gradient tolerance above has different units and can be very
249            // large for high-count diffraction patterns, so it must not be
250            // reused as a lower bound on phase scales.
251            if (0..columns).all(|index| !passive[index] || candidate_solution[index] > 0.0) {
252                solution = candidate_solution;
253                break;
254            }
255            let alpha = (0..columns)
256                .filter(|index| passive[*index] && candidate_solution[*index] <= 0.0)
257                .map(|index| solution[index] / (solution[index] - candidate_solution[index]))
258                .fold(1.0_f64, f64::min);
259            solution += alpha * (candidate_solution - &solution);
260            for index in 0..columns {
261                if passive[index] && solution[index] <= 0.0 {
262                    solution[index] = 0.0;
263                    passive[index] = false;
264                }
265            }
266            iterations += 1;
267            if iterations > maximum_iterations {
268                return Err(PhaseScaleEstimationError::DidNotConverge);
269            }
270        }
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    #[test]
279    fn high_count_design_accepts_a_small_positive_scale() {
280        let matrix = DMatrix::from_row_slice(1, 1, &[1.0e8]);
281        let target = DVector::from_vec(vec![1.0]);
282
283        let (solution, iterations) =
284            solve_non_negative_least_squares(&matrix, &target).expect("bounded scale solve");
285
286        assert_eq!(iterations, 1);
287        assert!((solution[0] - 1.0e-8).abs() < 1.0e-20);
288    }
289}