mod optimizer;
mod profiles;
mod solver;
#[cfg(test)]
mod tests;
use crate::xafs::{
analysis::metrics::{AxisOrigin, Measurement, MeasurementSpace},
fitting::{FitVariable, FitVariables, Param},
xasspectrum::XASSpectrum,
};
pub use profiles::PeakShape;
use serde::{Deserialize, Serialize};
pub use solver::{PeakContribution, PeakFitResult, PeakTermination};
use std::{
collections::{BTreeMap, BTreeSet},
ops::RangeInclusive,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum PeakRole {
Peak,
Baseline,
Edge,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PeakComponent {
pub name: String,
pub shape: PeakShape,
pub role: PeakRole,
pub parameters: BTreeMap<String, String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PeakFit {
pub range: [f64; 2],
pub space: MeasurementSpace,
pub origin: AxisOrigin,
pub exclude: Vec<[f64; 2]>,
pub components: Vec<PeakComponent>,
pub parameters: FitVariables,
pub max_iterations: usize,
pub tolerance: f64,
}
#[derive(Debug, thiserror::Error)]
pub enum PeakFitError {
#[error("Invalid peak fit: {0}")]
Invalid(String),
#[error("Peak-fit preparation: {0}")]
Preparation(String),
#[error("Peak-fit numerical error: {0}")]
Numerical(String),
}
impl PeakFit {
pub fn new(range: RangeInclusive<f64>) -> Self {
Self {
range: [*range.start(), *range.end()],
space: MeasurementSpace::Norm,
origin: AxisOrigin::E0,
exclude: Vec::new(),
components: Vec::new(),
parameters: FitVariables::new(),
max_iterations: 200,
tolerance: 1e-10,
}
}
pub fn flat(mut self) -> Self {
self.space = MeasurementSpace::Flat;
self
}
pub fn raw_mu(mut self) -> Self {
self.space = MeasurementSpace::Mu;
self
}
pub fn absolute(mut self) -> Self {
self.origin = AxisOrigin::Absolute;
self
}
pub fn reference(mut self, energy_ev: f64) -> Self {
self.origin = AxisOrigin::Reference { energy_ev };
self
}
pub fn exclude(mut self, range: RangeInclusive<f64>) -> Self {
self.exclude.push([*range.start(), *range.end()]);
self
}
pub fn gaussian(self, name: impl Into<String>, center: f64, area: f64, fwhm: f64) -> Self {
self.component(
name.into(),
PeakShape::Gaussian,
PeakRole::Peak,
&[center, area, fwhm],
)
}
pub fn lorentzian(self, name: impl Into<String>, center: f64, area: f64, fwhm: f64) -> Self {
self.component(
name.into(),
PeakShape::Lorentzian,
PeakRole::Peak,
&[center, area, fwhm],
)
}
pub fn pseudo_voigt(
self,
name: impl Into<String>,
center: f64,
area: f64,
fwhm: f64,
fraction: f64,
) -> Self {
self.component(
name.into(),
PeakShape::PseudoVoigt,
PeakRole::Peak,
&[center, area, fwhm, fraction],
)
}
pub fn voigt(
self,
name: impl Into<String>,
center: f64,
area: f64,
gaussian_fwhm: f64,
lorentzian_fwhm: f64,
) -> Self {
self.component(
name.into(),
PeakShape::Voigt,
PeakRole::Peak,
&[center, area, gaussian_fwhm, lorentzian_fwhm],
)
}
pub fn erf_step(self, name: impl Into<String>, center: f64, height: f64, scale: f64) -> Self {
self.component(
name.into(),
PeakShape::ErfStep,
PeakRole::Edge,
&[center, height, scale],
)
}
pub fn arctan_step(
self,
name: impl Into<String>,
center: f64,
height: f64,
scale: f64,
) -> Self {
self.component(
name.into(),
PeakShape::ArctanStep,
PeakRole::Edge,
&[center, height, scale],
)
}
pub fn constant_baseline(self, offset: f64) -> Self {
self.component(
"baseline".into(),
PeakShape::Constant,
PeakRole::Baseline,
&[offset],
)
}
pub fn linear_baseline(self, offset: f64, slope: f64) -> Self {
self.component(
"baseline".into(),
PeakShape::Linear,
PeakRole::Baseline,
&[offset, slope, 0.],
)
}
pub fn parameter(mut self, parameter: Param) -> Self {
self.parameters
.insert(parameter.name.clone(), parameter.to_fit_variable());
self
}
fn component(mut self, name: String, shape: PeakShape, role: PeakRole, values: &[f64]) -> Self {
let mut parameters = BTreeMap::new();
for (&key, &value) in shape.parameter_names().iter().zip(values) {
let symbol = format!("{name}_{key}");
let (min, max) = match key {
"center" => (Some(self.range[0]), Some(self.range[1])),
"area" => (Some(0.), None),
"width" | "lorentz_width" if shape == PeakShape::Voigt => (Some(0.), None),
"width" | "scale" => (Some(1e-8), None),
"fraction" => (Some(0.), Some(1.)),
_ => (None, None),
};
self.parameters.insert(
symbol.clone(),
FitVariable::new(value, key != "reference").with_bounds(min, max),
);
parameters.insert(key.into(), symbol);
}
self.components.push(PeakComponent {
name,
shape,
role,
parameters,
});
self
}
pub fn fit(&self, spectrum: &XASSpectrum) -> Result<PeakFitResult, PeakFitError> {
self.fit_with_errors(spectrum, None)
}
pub fn fit_with_errors(
&self,
spectrum: &XASSpectrum,
errors: Option<&[f64]>,
) -> Result<PeakFitResult, PeakFitError> {
self.validate()?;
let arrays = self
.measurement()
.arrays(spectrum)
.map_err(|e| PeakFitError::Preparation(e.to_string()))?;
self.fit_prepared(&arrays.axis, &arrays.signal, arrays.e0_ev, errors)
}
pub fn initialize_baseline(
&self,
spectrum: &XASSpectrum,
peak_intervals: &[RangeInclusive<f64>],
) -> Result<Self, PeakFitError> {
self.initialize_baseline_with_progress(spectrum, peak_intervals, |_, _| true)
}
pub fn initialize_baseline_with_progress(
&self,
spectrum: &XASSpectrum,
peak_intervals: &[RangeInclusive<f64>],
progress: impl FnMut(usize, f64) -> bool,
) -> Result<Self, PeakFitError> {
self.validate()?;
let mut baseline = self.clone();
baseline.components.retain(|c| c.role == PeakRole::Baseline);
let names: BTreeSet<_> = baseline
.components
.iter()
.flat_map(|c| c.parameters.values().cloned())
.collect();
for (name, value) in &mut baseline.parameters.vars {
if !names.contains(name) {
value.vary = false;
}
}
baseline
.exclude
.extend(peak_intervals.iter().map(|r| [*r.start(), *r.end()]));
let fit = baseline.fit_with_progress(spectrum, progress)?;
if !matches!(
fit.termination,
PeakTermination::Converged | PeakTermination::FixedModel
) {
return Err(PeakFitError::Numerical(format!(
"baseline initialization: {}",
fit.termination_detail
)));
}
let mut initialized = self.clone();
for name in names {
let variable = initialized.parameters.vars.get_mut(&name).unwrap();
variable.value = fit.parameters.vars[&name].value;
variable.init_value = variable.value;
variable.stderr = None;
}
Ok(initialized)
}
pub fn fit_prepared(
&self,
energy: &[f64],
signal: &[f64],
e0: Option<f64>,
errors: Option<&[f64]>,
) -> Result<PeakFitResult, PeakFitError> {
self.fit_prepared_with_progress(energy, signal, e0, errors, |_, _| true)
}
pub fn fit_prepared_with_progress(
&self,
energy: &[f64],
signal: &[f64],
e0: Option<f64>,
errors: Option<&[f64]>,
mut progress: impl FnMut(usize, f64) -> bool,
) -> Result<PeakFitResult, PeakFitError> {
solver::fit(self, energy, signal, e0, errors, &mut progress)
}
pub fn fit_with_progress(
&self,
spectrum: &XASSpectrum,
progress: impl FnMut(usize, f64) -> bool,
) -> Result<PeakFitResult, PeakFitError> {
self.validate()?;
let arrays = self
.measurement()
.arrays(spectrum)
.map_err(|e| PeakFitError::Preparation(e.to_string()))?;
self.fit_prepared_with_progress(&arrays.axis, &arrays.signal, arrays.e0_ev, None, progress)
}
fn measurement(&self) -> Measurement {
let mut measurement = Measurement::mean(self.range[0]..=self.range[1]);
measurement.space = self.space;
measurement.origin = self.origin;
measurement
}
pub fn evaluate(&self, energy: &[f64], e0: Option<f64>) -> Result<Vec<f64>, PeakFitError> {
self.validate()?;
let origin = match self.origin {
AxisOrigin::Absolute => Some(0.),
AxisOrigin::Reference { energy_ev } => Some(energy_ev),
AxisOrigin::E0 => e0,
}
.filter(|e| e.is_finite())
.ok_or_else(|| PeakFitError::Invalid("finite E₀ is required".into()))?;
let values = self
.parameters
.resolve_values()
.map_err(|e| PeakFitError::Invalid(e.to_string()))?;
let components = self
.components
.iter()
.map(|c| Ok((c.shape, c.values(&values)?)))
.collect::<Result<Vec<_>, PeakFitError>>()?;
energy
.iter()
.map(|&e| {
let y = components
.iter()
.map(|(shape, p)| profiles::evaluate(*shape, e - origin, p))
.sum::<f64>();
if e.is_finite() && (e - origin).is_finite() && y.is_finite() {
Ok(y)
} else {
Err(PeakFitError::Numerical(
"nonfinite preview energy or model".into(),
))
}
})
.collect()
}
pub fn fit_batch(&self, spectra: &[XASSpectrum]) -> Vec<Result<PeakFitResult, PeakFitError>> {
spectra.iter().map(|s| self.fit(s)).collect()
}
pub fn validate(&self) -> Result<(), PeakFitError> {
let invalid = |s: &str| PeakFitError::Invalid(s.into());
if !self.range.iter().all(|v| v.is_finite()) || self.range[0] >= self.range[1] {
return Err(invalid("fit range must have finite increasing bounds"));
}
if matches!(self.origin, AxisOrigin::Reference { energy_ev } if !energy_ev.is_finite()) {
return Err(invalid("reference energy must be finite"));
}
if !matches!(
self.space,
MeasurementSpace::Mu | MeasurementSpace::Norm | MeasurementSpace::Flat
) {
return Err(invalid("peak fitting requires Mu, Norm or Flat"));
}
if self.components.is_empty()
|| self.max_iterations == 0
|| !self.tolerance.is_finite()
|| self.tolerance <= 0.
{
return Err(invalid(
"add components and use positive finite solver settings",
));
}
if self
.exclude
.iter()
.any(|r| !r.iter().all(|v| v.is_finite()) || r[0] > r[1])
{
return Err(invalid("excluded intervals need finite ordered bounds"));
}
let identifier = |name: &str| {
!name.is_empty()
&& name.len() <= 100
&& name.bytes().enumerate().all(|(i, b)| {
b == b'_' || b.is_ascii_alphabetic() || (i > 0 && b.is_ascii_digit())
})
};
let mut names = BTreeSet::new();
for component in &self.components {
if !identifier(&component.name) || !names.insert(&component.name) {
return Err(invalid("component names must be unique ASCII identifiers"));
}
if component.parameters.len() != component.shape.parameter_names().len()
|| component
.shape
.parameter_names()
.iter()
.any(|key| !component.parameters.contains_key(*key))
{
return Err(invalid("component parameter roles do not match its shape"));
}
if component.shape.is_step() && component.role != PeakRole::Edge
|| component.shape.is_peak() && component.role == PeakRole::Edge
|| matches!(component.shape, PeakShape::Constant | PeakShape::Linear)
&& component.role != PeakRole::Baseline
{
return Err(invalid(
"steps require Edge roles and polynomials require Baseline roles",
));
}
}
for (name, value) in &self.parameters.vars {
if !identifier(name)
|| !value.value.is_finite()
|| !value.init_value.is_finite()
|| value.stderr.is_some_and(|v| !v.is_finite() || v < 0.)
|| value
.min
.into_iter()
.chain(value.max)
.any(|v| !v.is_finite())
|| value
.min
.zip(value.max)
.is_some_and(|(a, b)| a > b || (a == b && value.vary && value.expr.is_none()))
{
return Err(invalid(
"parameter values/bounds must be finite, ordered and identifiable",
));
}
}
let resolved = self
.parameters
.resolve_values()
.map_err(|e| PeakFitError::Invalid(e.to_string()))?;
self.validate_values(&resolved)
}
pub(crate) fn validate_values(
&self,
values: &BTreeMap<String, f64>,
) -> Result<(), PeakFitError> {
for (name, variable) in &self.parameters.vars {
let value = *values
.get(name)
.ok_or_else(|| PeakFitError::Invalid(format!("missing parameter {name}")))?;
if !value.is_finite()
|| variable.min.is_some_and(|min| value < min)
|| variable.max.is_some_and(|max| value > max)
{
return Err(PeakFitError::Invalid(format!(
"parameter {name} is nonfinite or outside its bounds"
)));
}
}
for component in &self.components {
let p = component.values(values)?;
let shape = component.shape;
if shape.is_peak() && p[1] < 0.
|| (shape.is_step()
|| matches!(
shape,
PeakShape::Gaussian | PeakShape::Lorentzian | PeakShape::PseudoVoigt
))
&& p[2] <= 0.
|| shape == PeakShape::PseudoVoigt && !(0.0..=1.0).contains(&p[3])
|| shape == PeakShape::Voigt && (p[2] < 0. || p[3] < 0. || p[2] + p[3] <= 0.)
{
return Err(PeakFitError::Invalid(format!(
"invalid physical parameters for {}",
component.name
)));
}
}
Ok(())
}
}
impl XASSpectrum {
pub fn fit_peaks(&self, model: &PeakFit) -> Result<PeakFitResult, PeakFitError> {
model.fit(self)
}
}
impl PeakComponent {
pub(crate) fn values(&self, values: &BTreeMap<String, f64>) -> Result<Vec<f64>, PeakFitError> {
self.shape
.parameter_names()
.iter()
.map(|key| {
let name = self
.parameters
.get(*key)
.ok_or_else(|| PeakFitError::Invalid(format!("missing role {key}")))?;
values
.get(name)
.copied()
.ok_or_else(|| PeakFitError::Invalid(format!("undefined parameter {name}")))
})
.collect()
}
}