Skip to main content

phasesmith_workflows/
residuals.rs

1//! Masked residual arrays and standard powder-diffraction metrics.
2
3use std::error::Error;
4use std::fmt::{Display, Formatter};
5
6use phasesmith_model::{DomainError, PatternRecord};
7
8/// Weighting and degrees-of-freedom controls for residual evaluation.
9#[derive(Clone, Copy, Debug, PartialEq, Eq)]
10pub struct ResidualOptions {
11    /// Use available one-sigma uncertainties when true.
12    pub use_uncertainty: bool,
13    /// Number of fitted free parameters subtracted from included samples.
14    pub parameter_count: usize,
15}
16
17impl Default for ResidualOptions {
18    fn default() -> Self {
19        Self {
20            use_uncertainty: true,
21            parameter_count: 0,
22        }
23    }
24}
25
26/// Full residual arrays and standard masked powder metrics.
27#[derive(Clone, Debug, PartialEq)]
28pub struct ResidualEvaluation {
29    /// Sample-aligned inclusion mask; true means included.
30    pub included: Vec<bool>,
31    /// Sample-aligned `calculated - observed` values.
32    pub residual: Vec<f64>,
33    /// Residual divided by one-sigma uncertainty when requested and available.
34    pub weighted_residual: Vec<f64>,
35    /// Unweighted profile residual fraction.
36    pub rp: f64,
37    /// Weighted profile residual fraction.
38    pub rwp: f64,
39    /// Sum of squared selected weighted residuals.
40    pub chi_square: f64,
41    /// Chi-square divided by positive residual degrees of freedom.
42    pub reduced_chi_square: f64,
43}
44
45/// Evaluate masked residual arrays and standard powder residual metrics.
46///
47/// Residual is `calculated - observed`. If enabled and present, uncertainty is
48/// interpreted as one standard deviation. Zero `Rp`/`Rwp` denominators and
49/// non-positive residual degrees of freedom produce positive infinity, matching
50/// the scripting contract.
51///
52/// # Errors
53///
54/// Returns [`ResidualError`] if the pattern is invalid, has no observations,
55/// or `calculated_y` has the wrong length or contains a non-finite sample.
56pub fn evaluate_residuals(
57    pattern: &PatternRecord,
58    calculated_y: &[f64],
59    options: ResidualOptions,
60) -> Result<ResidualEvaluation, ResidualError> {
61    pattern.validate().map_err(ResidualError::Pattern)?;
62    let observed_y = pattern
63        .observed_y
64        .as_deref()
65        .ok_or(ResidualError::MissingObservations)?;
66    if calculated_y.len() != pattern.sample_count() {
67        return Err(ResidualError::CalculatedLengthMismatch {
68            expected: pattern.sample_count(),
69            actual: calculated_y.len(),
70        });
71    }
72    if let Some(index) = calculated_y.iter().position(|value| !value.is_finite()) {
73        return Err(ResidualError::NonFiniteCalculated { index });
74    }
75    let included = pattern
76        .mask
77        .clone()
78        .unwrap_or_else(|| vec![true; pattern.sample_count()]);
79    let residual = calculated_y
80        .iter()
81        .zip(observed_y)
82        .map(|(calculated, observed)| calculated - observed)
83        .collect::<Vec<_>>();
84    let uncertainty = options
85        .use_uncertainty
86        .then_some(pattern.uncertainty.as_deref())
87        .flatten();
88    let weighted_residual = match uncertainty {
89        Some(uncertainty) => residual
90            .iter()
91            .zip(uncertainty)
92            .map(|(value, sigma)| value / sigma)
93            .collect(),
94        None => residual.clone(),
95    };
96
97    let mut included_count = 0_usize;
98    let mut absolute_residual_sum = 0.0;
99    let mut absolute_observed_sum = 0.0;
100    let mut weighted_observed_square_sum = 0.0;
101    let mut chi_square = 0.0;
102    for index in 0..pattern.sample_count() {
103        if !included[index] {
104            continue;
105        }
106        included_count += 1;
107        absolute_residual_sum += residual[index].abs();
108        absolute_observed_sum += observed_y[index].abs();
109        chi_square += weighted_residual[index] * weighted_residual[index];
110        weighted_observed_square_sum += match uncertainty {
111            Some(uncertainty) => {
112                let sigma = uncertainty[index];
113                (1.0 / (sigma * sigma)) * (observed_y[index] * observed_y[index])
114            }
115            None => observed_y[index] * observed_y[index],
116        };
117    }
118    let rp = if absolute_observed_sum == 0.0 {
119        f64::INFINITY
120    } else {
121        absolute_residual_sum / absolute_observed_sum
122    };
123    let rwp = if weighted_observed_square_sum == 0.0 {
124        f64::INFINITY
125    } else {
126        (chi_square / weighted_observed_square_sum).sqrt()
127    };
128    let degrees_of_freedom = included_count.checked_sub(options.parameter_count);
129    let reduced_chi_square = match degrees_of_freedom {
130        Some(degrees) if degrees > 0 => chi_square / count_as_f64(degrees),
131        _ => f64::INFINITY,
132    };
133    Ok(ResidualEvaluation {
134        included,
135        residual,
136        weighted_residual,
137        rp,
138        rwp,
139        chi_square,
140        reduced_chi_square,
141    })
142}
143
144// IEEE-754 conversion follows Python/NumPy's metric convention. Counts above
145// 2^53 may round, but cannot arise without an already-impossible dense array.
146#[allow(clippy::cast_precision_loss)]
147fn count_as_f64(value: usize) -> f64 {
148    value as f64
149}
150
151/// Invalid input to native residual evaluation.
152#[derive(Debug)]
153pub enum ResidualError {
154    /// The live pattern failed domain validation.
155    Pattern(DomainError),
156    /// Observed intensity is absent.
157    MissingObservations,
158    /// Calculated intensity length differs from the pattern.
159    CalculatedLengthMismatch {
160        /// Expected sample count.
161        expected: usize,
162        /// Received sample count.
163        actual: usize,
164    },
165    /// One calculated sample is non-finite.
166    NonFiniteCalculated {
167        /// Rejected sample index.
168        index: usize,
169    },
170}
171
172impl Display for ResidualError {
173    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
174        match self {
175            Self::Pattern(error) => Display::fmt(error, formatter),
176            Self::MissingObservations => {
177                formatter.write_str("observed_y is required for residual evaluation")
178            }
179            Self::CalculatedLengthMismatch { expected, actual } => write!(
180                formatter,
181                "calculated_y length {actual} does not match pattern length {expected}"
182            ),
183            Self::NonFiniteCalculated { index } => {
184                write!(
185                    formatter,
186                    "calculated_y contains a non-finite value at index {index}"
187                )
188            }
189        }
190    }
191}
192
193impl Error for ResidualError {
194    fn source(&self) -> Option<&(dyn Error + 'static)> {
195        match self {
196            Self::Pattern(error) => Some(error),
197            _ => None,
198        }
199    }
200}