use std::borrow::Borrow;
use nalgebra::{DMatrix, DVector};
use serde::{Deserialize, Serialize};
use super::{lcf::bounded_lstsq, spectrum_label, AnalysisInput, AnalysisSpace};
use crate::xafs::{errors::AnalysisError, xasspectrum::XASSpectrum};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McrAnchor {
pub sample: usize,
pub weights: Vec<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McrConfig {
pub space: AnalysisSpace,
pub range: Option<(f64, f64)>,
pub components: usize,
pub sum_to_one: bool,
pub nonnegative_spectra: bool,
pub max_iterations: usize,
pub tolerance: f64,
pub seed: u64,
pub initial_spectra: Option<DMatrix<f64>>,
pub anchors: Vec<McrAnchor>,
}
impl Default for McrConfig {
fn default() -> Self {
Self {
space: AnalysisSpace::Norm,
range: None,
components: 3,
sum_to_one: true,
nonnegative_spectra: false,
max_iterations: 500,
tolerance: 1e-8,
seed: 0,
initial_spectra: None,
anchors: Vec::new(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum McrTermination {
Converged,
IterationLimit,
Cancelled,
Stalled,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McrResult {
#[serde(default)]
pub e0: Option<f64>,
pub x: DVector<f64>,
pub labels: Vec<String>,
pub data: DMatrix<f64>,
pub concentrations: DMatrix<f64>,
pub spectra: DMatrix<f64>,
pub fit: DMatrix<f64>,
pub residual: DMatrix<f64>,
pub sse: f64,
pub relative_error: f64,
pub objective_history: Vec<f64>,
pub iterations: usize,
pub best_iteration: usize,
pub termination: McrTermination,
pub config: McrConfig,
pub initial_samples: Vec<usize>,
}
impl McrResult {
pub fn component_spectrum(&self, index: usize) -> Result<XASSpectrum, crate::xafs::XAFSError> {
use crate::xafs::errors::DataError;
if index >= self.spectra.nrows() {
return Err(DataError::IndexOutOfRange {
index,
length: self.spectra.nrows(),
}
.into());
}
let e0 = self.e0.ok_or_else(|| DataError::MissingData {
field:
"MCR E0; use XASSpectrum::from_prepared with an explicit E0 for historical results"
.into(),
})?;
let values: Vec<_> = self.spectra.row(index).iter().copied().collect();
let mut spectrum =
XASSpectrum::from_prepared(self.x.as_slice(), &values, self.config.space, e0)?;
spectrum.set_name(format!("MCR component {}", index + 1));
Ok(spectrum)
}
}
fn invalid(reason: impl Into<String>) -> AnalysisError {
AnalysisError::LinearAlgebra {
reason: format!("MCR-ALS: {}", reason.into()),
}
}
pub fn mcr_als<S: Borrow<XASSpectrum>>(
spectra: &[S],
config: &McrConfig,
) -> Result<McrResult, AnalysisError> {
mcr_als_with_progress(spectra, config, |_, _| true)
}
pub fn mcr_als_with_progress<S: Borrow<XASSpectrum>>(
spectra: &[S],
config: &McrConfig,
mut progress: impl FnMut(usize, f64) -> bool,
) -> Result<McrResult, AnalysisError> {
let k = config.components;
if spectra.len() < 2
|| k == 0
|| k > spectra.len()
|| config.max_iterations == 0
|| !config.tolerance.is_finite()
|| config.tolerance <= 0.0
|| config.tolerance >= 1.0
{
return Err(invalid("require at least two samples, 1..=n components, positive iterations and 0 < tolerance < 1"));
}
if !matches!(config.space, AnalysisSpace::Norm | AnalysisSpace::Flat) {
return Err(invalid("use normalized or flattened absorption"));
}
let first = AnalysisInput::with_label(spectra[0].borrow(), config.space, "sample 0".into())?;
let bounds = first.bounds(config.space, config.range)?;
let e0 = first.e0.expect("energy bounds validated the edge origin");
let (grid, first_y) = first.select(bounds, k.max(3))?;
let mut labels = Vec::with_capacity(spectra.len());
let mut data = DMatrix::zeros(spectra.len(), grid.len());
data.set_row(0, &first_y.transpose());
for (i, spectrum) in spectra.iter().enumerate() {
labels.push(spectrum_label(spectrum.borrow(), format!("sample {i}")));
if i > 0 {
let y =
AnalysisInput::with_label(spectrum.borrow(), config.space, format!("sample {i}"))?
.interpolate(&grid, bounds)?;
data.set_row(i, &y.transpose());
}
}
let data_norm = data.norm_squared();
if !data_norm.is_finite() || data_norm <= 0.0 {
return Err(invalid("data must have finite nonzero squared norm"));
}
let mut anchors = vec![None; data.nrows()];
for anchor in &config.anchors {
if anchor.sample >= data.nrows()
|| anchor.weights.len() != k
|| anchor.weights.iter().any(|w| !w.is_finite() || *w < 0.0)
|| (config.sum_to_one && (anchor.weights.iter().sum::<f64>() - 1.0).abs() > 1e-10)
{
return Err(invalid("invalid known-composition anchor"));
}
if anchors[anchor.sample].is_some() {
return Err(invalid("duplicate anchor sample"));
}
anchors[anchor.sample] = Some(DVector::from_vec(anchor.weights.clone()));
}
let (mut s, initial_samples) = if let Some(initial) = &config.initial_spectra {
if initial.shape() != (k, grid.len())
|| initial.iter().any(|v| !v.is_finite())
|| (config.nonnegative_spectra && initial.iter().any(|v| *v < 0.0))
{
return Err(invalid(
"initial spectra have invalid shape, values or nonnegativity",
));
}
(initial.clone(), Vec::new())
} else {
initialize(&data, k, config.seed, config.nonnegative_spectra)?
};
require_rank(&s.transpose(), k)?;
let mut history = Vec::new();
let mut best: Option<(DMatrix<f64>, DMatrix<f64>, f64, usize)> = None;
let mut termination = McrTermination::IterationLimit;
let mut iterations = 0;
for iteration in 1..=config.max_iterations {
iterations = iteration;
let a = s.transpose();
let mut c = DMatrix::zeros(data.nrows(), k);
for (i, anchor) in anchors.iter().enumerate() {
let weights = match anchor {
Some(weights) => weights.clone(),
None => constrained(&a, &data.row(i).transpose(), config.sum_to_one)?,
};
c.row_mut(i).copy_from(&weights.transpose());
}
require_rank(&c, k)?;
s = if config.nonnegative_spectra {
let mut next = DMatrix::zeros(k, data.ncols());
for j in 0..data.ncols() {
next.column_mut(j).copy_from(&constrained(
&c,
&data.column(j).into_owned(),
false,
)?);
}
next
} else {
let svd = c.clone().svd(true, true);
let cutoff = svd.singular_values[0] * 1e-12;
svd.solve(&data, cutoff).map_err(invalid)?
};
let sse = (&data - &c * &s).norm_squared();
if !sse.is_finite() || s.iter().any(|v| !v.is_finite()) {
return Err(invalid("nonfinite ALS iterate"));
}
let previous = history.last().copied();
if previous.is_some_and(|last| sse > last + 1e-12 * data_norm) {
termination = McrTermination::Stalled;
break;
}
history.push(sse);
if best.as_ref().is_none_or(|(_, _, value, _)| sse < *value) {
best = Some((c, s.clone(), sse, iteration));
}
if !progress(iteration, sse / data_norm) {
termination = McrTermination::Cancelled;
break;
}
if sse / data_norm <= 1e-24
|| previous.is_some_and(|last: f64| {
(last - sse).abs() <= config.tolerance * last.max(data_norm * 1e-24)
})
{
termination = McrTermination::Converged;
break;
}
}
let (concentrations, spectra, sse, best_iteration) =
best.ok_or_else(|| invalid("no complete iterate"))?;
let fit = &concentrations * &spectra;
let residual = &data - &fit;
Ok(McrResult {
e0: Some(e0),
x: grid,
labels,
data,
concentrations,
spectra,
fit,
residual,
sse,
relative_error: sse / data_norm,
objective_history: history,
iterations,
best_iteration,
termination,
config: config.clone(),
initial_samples,
})
}
fn require_rank(a: &DMatrix<f64>, k: usize) -> Result<(), AnalysisError> {
let values = a.clone().svd(false, false).singular_values;
if values.len() < k || values[0] == 0.0 || values[k - 1] <= values[0] * 1e-12 {
Err(invalid(
"rank-deficient factors; reduce components or change initialization",
))
} else {
Ok(())
}
}
fn initialize(
data: &DMatrix<f64>,
k: usize,
seed: u64,
nonnegative: bool,
) -> Result<(DMatrix<f64>, Vec<usize>), AnalysisError> {
let mut residual = data.clone();
let mut chosen = Vec::new();
let mut s = DMatrix::zeros(k, data.ncols());
for j in 0..k {
let i = if j == 0 && seed != 0 {
(seed % data.nrows() as u64) as usize
} else {
(0..data.nrows())
.max_by(|&a, &b| {
residual
.row(a)
.norm_squared()
.total_cmp(&residual.row(b).norm_squared())
})
.unwrap()
};
let mut direction = residual.row(i).transpose();
let norm = direction.norm();
if norm <= data.norm() * 1e-12 {
return Err(invalid("selected component count exceeds data rank"));
}
direction /= norm;
for row in 0..data.nrows() {
let projection = residual.row(row).transpose().dot(&direction);
for col in 0..data.ncols() {
residual[(row, col)] -= projection * direction[col];
}
}
s.row_mut(j).copy_from(&data.row(i));
chosen.push(i);
}
if nonnegative {
s.apply(|v| *v = v.max(0.0));
}
Ok((s, chosen))
}
fn constrained(
a: &DMatrix<f64>,
y: &DVector<f64>,
closure: bool,
) -> Result<DVector<f64>, AnalysisError> {
let k = a.ncols();
let weights = bounded_lstsq(
a,
y,
&vec![0.0; k],
&vec![if closure { 1.0 } else { f64::INFINITY }; k],
closure.then_some(1.0),
)?;
if weights.iter().any(|w| !w.is_finite() || *w < -1e-10)
|| (closure && (weights.sum() - 1.0).abs() > 1e-9)
{
return Err(invalid("constrained subproblem is infeasible"));
}
let gradient = a.transpose() * (a * &weights - y);
let tol = 1e-7 * (1.0 + a.norm_squared() * weights.amax() + (a.transpose() * y).amax());
let mut lower = f64::NEG_INFINITY;
let mut upper = f64::INFINITY;
for i in 0..k {
if weights[i] <= 1e-9 {
lower = lower.max(-gradient[i] - tol);
} else if closure && weights[i] >= 1.0 - 1e-9 {
upper = upper.min(-gradient[i] + tol);
} else {
lower = lower.max(-gradient[i] - tol);
upper = upper.min(-gradient[i] + tol);
}
}
if lower > upper || (!closure && (lower > 0.0 || upper < 0.0)) {
return Err(invalid(
"constrained subproblem did not satisfy optimality conditions",
));
}
Ok(weights)
}