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, TofPatternRecord};
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    evaluate_residual_arrays(
63        pattern.sample_count(),
64        pattern.observed_y.as_deref(),
65        pattern.uncertainty.as_deref(),
66        pattern.mask.as_deref(),
67        calculated_y,
68        options,
69    )
70}
71
72/// Evaluate residuals on an explicitly microsecond-domain TOF pattern.
73///
74/// This is deliberately separate from [`evaluate_residuals`]: callers cannot
75/// reinterpret TOF coordinates as constant-wavelength angles to reach shared
76/// residual mathematics.
77///
78/// # Errors
79///
80/// Returns [`ResidualError`] for an invalid pattern, absent observations, or
81/// invalid calculated values.
82pub fn evaluate_tof_residuals(
83    pattern: &TofPatternRecord,
84    calculated_y: &[f64],
85    options: ResidualOptions,
86) -> Result<ResidualEvaluation, ResidualError> {
87    pattern.validate().map_err(ResidualError::Pattern)?;
88    evaluate_residual_arrays(
89        pattern.sample_count(),
90        pattern.observed_y.as_deref(),
91        pattern.uncertainty.as_deref(),
92        pattern.mask.as_deref(),
93        calculated_y,
94        options,
95    )
96}
97
98fn evaluate_residual_arrays(
99    sample_count: usize,
100    observed_y: Option<&[f64]>,
101    uncertainty: Option<&[f64]>,
102    mask: Option<&[bool]>,
103    calculated_y: &[f64],
104    options: ResidualOptions,
105) -> Result<ResidualEvaluation, ResidualError> {
106    let observed_y = observed_y.ok_or(ResidualError::MissingObservations)?;
107    if calculated_y.len() != sample_count {
108        return Err(ResidualError::CalculatedLengthMismatch {
109            expected: sample_count,
110            actual: calculated_y.len(),
111        });
112    }
113    if let Some(index) = calculated_y.iter().position(|value| !value.is_finite()) {
114        return Err(ResidualError::NonFiniteCalculated { index });
115    }
116    let included = mask.map_or_else(|| vec![true; sample_count], <[bool]>::to_vec);
117    let residual = calculated_y
118        .iter()
119        .zip(observed_y)
120        .map(|(calculated, observed)| calculated - observed)
121        .collect::<Vec<_>>();
122    let uncertainty = options.use_uncertainty.then_some(uncertainty).flatten();
123    let weighted_residual = match uncertainty {
124        Some(uncertainty) => residual
125            .iter()
126            .zip(uncertainty)
127            .map(|(value, sigma)| value / sigma)
128            .collect(),
129        None => residual.clone(),
130    };
131
132    let mut included_count = 0_usize;
133    let mut absolute_residual_sum = 0.0;
134    let mut absolute_observed_sum = 0.0;
135    let mut weighted_observed_square_sum = 0.0;
136    let mut chi_square = 0.0;
137    for index in 0..sample_count {
138        if !included[index] {
139            continue;
140        }
141        included_count += 1;
142        absolute_residual_sum += residual[index].abs();
143        absolute_observed_sum += observed_y[index].abs();
144        chi_square += weighted_residual[index] * weighted_residual[index];
145        weighted_observed_square_sum += match uncertainty {
146            Some(uncertainty) => {
147                let sigma = uncertainty[index];
148                (1.0 / (sigma * sigma)) * (observed_y[index] * observed_y[index])
149            }
150            None => observed_y[index] * observed_y[index],
151        };
152    }
153    let rp = if absolute_observed_sum == 0.0 {
154        f64::INFINITY
155    } else {
156        absolute_residual_sum / absolute_observed_sum
157    };
158    let rwp = if weighted_observed_square_sum == 0.0 {
159        f64::INFINITY
160    } else {
161        (chi_square / weighted_observed_square_sum).sqrt()
162    };
163    let degrees_of_freedom = included_count.checked_sub(options.parameter_count);
164    let reduced_chi_square = match degrees_of_freedom {
165        Some(degrees) if degrees > 0 => chi_square / count_as_f64(degrees),
166        _ => f64::INFINITY,
167    };
168    Ok(ResidualEvaluation {
169        included,
170        residual,
171        weighted_residual,
172        rp,
173        rwp,
174        chi_square,
175        reduced_chi_square,
176    })
177}
178
179// IEEE-754 conversion follows Python/NumPy's metric convention. Counts above
180// 2^53 may round, but cannot arise without an already-impossible dense array.
181#[allow(clippy::cast_precision_loss)]
182fn count_as_f64(value: usize) -> f64 {
183    value as f64
184}
185
186/// Invalid input to native residual evaluation.
187#[derive(Debug)]
188pub enum ResidualError {
189    /// The live pattern failed domain validation.
190    Pattern(DomainError),
191    /// Observed intensity is absent.
192    MissingObservations,
193    /// Calculated intensity length differs from the pattern.
194    CalculatedLengthMismatch {
195        /// Expected sample count.
196        expected: usize,
197        /// Received sample count.
198        actual: usize,
199    },
200    /// One calculated sample is non-finite.
201    NonFiniteCalculated {
202        /// Rejected sample index.
203        index: usize,
204    },
205}
206
207impl Display for ResidualError {
208    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
209        match self {
210            Self::Pattern(error) => Display::fmt(error, formatter),
211            Self::MissingObservations => {
212                formatter.write_str("observed_y is required for residual evaluation")
213            }
214            Self::CalculatedLengthMismatch { expected, actual } => write!(
215                formatter,
216                "calculated_y length {actual} does not match pattern length {expected}"
217            ),
218            Self::NonFiniteCalculated { index } => {
219                write!(
220                    formatter,
221                    "calculated_y contains a non-finite value at index {index}"
222                )
223            }
224        }
225    }
226}
227
228impl Error for ResidualError {
229    fn source(&self) -> Option<&(dyn Error + 'static)> {
230        match self {
231            Self::Pattern(error) => Some(error),
232            _ => None,
233        }
234    }
235}