diff --git a/crates/analysis/src/fit.rs b/crates/analysis/src/fit.rs index cb0bd9d..b72a5ee 100644 --- a/crates/analysis/src/fit.rs +++ b/crates/analysis/src/fit.rs @@ -312,6 +312,83 @@ pub(crate) fn param_sigma( } } +/// Covariance of fitted parameters from the inverse numerical-Jacobian normal +/// matrix and residual variance. Callers remain responsible for propagating +/// this local approximation through any parameter transforms or constraints. +pub fn parameter_covariance( + x: &[f64], + y: &[f64], + p: &[f64], + model: impl Fn(&[f64], f64) -> f64, +) -> Option>> { + let m = p.len(); + let n = x.len(); + if m == 0 || n <= m { + return None; + } + let mut jtj = vec![vec![0.0; m]; m]; + let mut ss = 0.0; + let mut trial = p.to_vec(); + for (&xi, &yi) in x.iter().zip(y) { + let residual = yi - model(p, xi); + ss += residual * residual; + let mut gradient = vec![0.0; m]; + for j in 0..m { + let h = jac_step(p[j]); + trial[j] = p[j] + h; + let plus = model(&trial, xi); + trial[j] = p[j] - h; + let minus = model(&trial, xi); + trial[j] = p[j]; + gradient[j] = (plus - minus) / (2.0 * h); + } + for a in 0..m { + for b in a..m { + jtj[a][b] += gradient[a] * gradient[b]; + } + } + } + mirror_upper(&mut jtj); + let variance = ss / (n - m) as f64; + let mut covariance = vec![vec![0.0; m]; m]; + for col in 0..m { + let mut unit = vec![0.0; m]; + unit[col] = 1.0; + let inverse_col = solve_linear(&jtj, &unit)?; + for row in 0..m { + covariance[row][col] = inverse_col[row] * variance; + } + } + Some(covariance) +} + +/// Covariance for an [`LmProblem`] that already supplies its normal equations. +/// `observations` is the residual count used to estimate the residual variance. +pub fn problem_parameter_covariance( + problem: &mut impl LmProblem, + p: &[f64], + observations: usize, +) -> Option>> { + let parameters = p.len(); + if parameters == 0 || observations <= parameters { + return None; + } + let mut normal = vec![vec![0.0; parameters]; parameters]; + let mut rhs = vec![0.0; parameters]; + problem.normal_equations(p, &mut normal, &mut rhs); + let variance = problem.cost(p) / (observations - parameters) as f64; + let mut covariance = vec![vec![0.0; parameters]; parameters]; + for column in 0..parameters { + let mut unit = vec![0.0; parameters]; + unit[column] = 1.0; + let inverse_column = solve_linear(&normal, &unit)?; + for row in 0..parameters { + covariance[row][column] = inverse_column[row] * variance; + } + } + Some(covariance) +} + /// Solve `a·x = b` for a small symmetric system by Gauss–Jordan with partial /// pivoting. Returns `None` if singular. pub fn solve_linear(a: &[Vec], b: &[f64]) -> Option> { diff --git a/crates/analysis/src/lib.rs b/crates/analysis/src/lib.rs index d696f9a..94f7a1d 100644 --- a/crates/analysis/src/lib.rs +++ b/crates/analysis/src/lib.rs @@ -21,5 +21,6 @@ mod series_reduce; pub mod stack; pub mod statistics; pub mod symmetry; +pub mod xps; pub use stack::SpectrumStack; diff --git a/crates/analysis/src/lineshape.rs b/crates/analysis/src/lineshape.rs index b856dbd..3c817c9 100644 --- a/crates/analysis/src/lineshape.rs +++ b/crates/analysis/src/lineshape.rs @@ -9,6 +9,9 @@ use crate::fit::{ }; use std::f64::consts::{FRAC_PI_2, LN_2, PI}; +mod constrained; +pub use constrained::*; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LineShape { Lorentzian, diff --git a/crates/analysis/src/lineshape/constrained.rs b/crates/analysis/src/lineshape/constrained.rs new file mode 100644 index 0000000..1e717d3 --- /dev/null +++ b/crates/analysis/src/lineshape/constrained.rs @@ -0,0 +1,517 @@ +use super::{LineShape, peak_partials}; +use crate::fit::{ + LmProblem, levenberg_marquardt_problem_cancellable, mirror_upper, problem_parameter_covariance, +}; +use std::collections::{BTreeMap, BTreeSet}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct PeakConstraintKey(pub u64); + +#[derive(Debug, Clone, PartialEq)] +pub enum PeakParameterConstraint { + Free { + initial: f64, + bounds: [f64; 2], + }, + Fixed { + value: f64, + }, + Linked { + reference: PeakConstraintKey, + scale: f64, + offset: f64, + }, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ConstrainedPeakSpec { + pub key: PeakConstraintKey, + pub position: PeakParameterConstraint, + pub fwhm: PeakParameterConstraint, + pub area: PeakParameterConstraint, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ConstrainedLineShapeOptions { + pub shape: LineShape, + pub pseudo_voigt_fraction: f64, + pub max_iterations: usize, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ConstrainedPeakResult { + pub key: PeakConstraintKey, + pub position: f64, + pub fwhm: f64, + pub area: f64, + pub hit_position_bound: bool, + pub hit_fwhm_bound: bool, + pub hit_area_bound: bool, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ConstrainedLineFit { + pub peaks: Vec, + pub components: Vec>, + pub total: Vec, + pub residual: Vec, + /// Covariance of `[position, FWHM, area]` blocks in input component order. + pub physical_covariance: Option>>, +} + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum ConstrainedLineFitError { + #[error("line-fit arrays or options are invalid")] + InvalidInput, + #[error("line-fit constraints are invalid: {0}")] + InvalidConstraints(String), + #[error("line fit was cancelled")] + Cancelled, + #[error("line fit did not converge")] + DidNotConverge, +} + +#[derive(Clone)] +struct FreeParameter { + initial: f64, + bounds: [f64; 2], +} + +struct CompiledConstraints<'a> { + peaks: &'a [ConstrainedPeakSpec], + key_index: BTreeMap, + orders: [Vec; 3], + free_index: [Vec>; 3], + free: Vec, +} + +impl<'a> CompiledConstraints<'a> { + fn new(peaks: &'a [ConstrainedPeakSpec]) -> Result { + if peaks.is_empty() { + return Err(invalid("add at least one component")); + } + let mut key_index = BTreeMap::new(); + for (index, peak) in peaks.iter().enumerate() { + if key_index.insert(peak.key, index).is_some() { + return Err(invalid("component keys must be unique")); + } + } + let orders = [ + dependency_order(peaks, &key_index, |peak| &peak.position)?, + dependency_order(peaks, &key_index, |peak| &peak.fwhm)?, + dependency_order(peaks, &key_index, |peak| &peak.area)?, + ]; + let mut free = Vec::new(); + let mut free_index = std::array::from_fn(|_| vec![None; peaks.len()]); + for (peak_index, peak) in peaks.iter().enumerate() { + for (kind, parameter) in [&peak.position, &peak.fwhm, &peak.area] + .into_iter() + .enumerate() + { + match parameter { + PeakParameterConstraint::Free { initial, bounds } => { + free_index[kind][peak_index] = + Some(push_free(&mut free, *initial, *bounds)?); + } + PeakParameterConstraint::Fixed { value } if value.is_finite() => {} + PeakParameterConstraint::Linked { scale, offset, .. } + if scale.is_finite() && offset.is_finite() => {} + _ => return Err(invalid("parameter values must be finite")), + } + } + } + Ok(Self { + peaks, + key_index, + orders, + free_index, + free, + }) + } + + fn initial(&self) -> Vec { + self.free + .iter() + .map(|parameter| from_bound(parameter.initial, parameter.bounds)) + .collect() + } + + fn decode_with_jacobian(&self, free: &[f64]) -> (Vec<[f64; 3]>, Vec>>) { + let mut values = vec![[0.0; 3]; self.peaks.len()]; + let mut jacobian = vec![vec![vec![0.0; free.len()]; 3]; self.peaks.len()]; + for kind in 0..3 { + for &peak_index in &self.orders[kind] { + let parameter = parameter(&self.peaks[peak_index], kind); + match parameter { + PeakParameterConstraint::Free { bounds, .. } => { + let free_index = self.free_index[kind][peak_index] + .expect("compiled free parameter has an index"); + values[peak_index][kind] = to_bound(free[free_index], *bounds); + jacobian[peak_index][kind][free_index] = + bound_derivative(free[free_index], *bounds); + } + PeakParameterConstraint::Fixed { value } => { + values[peak_index][kind] = *value; + } + PeakParameterConstraint::Linked { + reference, + scale, + offset, + } => { + let reference = self.key_index[reference]; + values[peak_index][kind] = values[reference][kind] * scale + offset; + let reference_derivatives = jacobian[reference][kind].clone(); + for (derivative, reference_derivative) in jacobian[peak_index][kind] + .iter_mut() + .zip(reference_derivatives) + { + *derivative = reference_derivative * scale; + } + } + } + } + } + (values, jacobian) + } +} + +struct ConstrainedProblem<'a> { + x: &'a [f64], + y: &'a [f64], + constraints: &'a CompiledConstraints<'a>, + options: ConstrainedLineShapeOptions, +} + +impl LmProblem for ConstrainedProblem<'_> { + fn cost(&mut self, free: &[f64]) -> f64 { + let (values, _) = self.constraints.decode_with_jacobian(free); + self.x + .iter() + .zip(self.y) + .map(|(&x, &y)| { + let residual = y - component_sum(x, &values, self.options); + residual * residual + }) + .sum() + } + + fn normal_equations(&mut self, free: &[f64], jtj: &mut [Vec], jtr: &mut [f64]) { + for row in jtj.iter_mut() { + row.fill(0.0); + } + jtr.fill(0.0); + let (values, physical_jacobian) = self.constraints.decode_with_jacobian(free); + let mut gradient = vec![0.0; free.len()]; + for (&x, &y) in self.x.iter().zip(self.y) { + gradient.fill(0.0); + let mut predicted = 0.0; + for (peak, jacobian) in values.iter().zip(&physical_jacobian) { + let (value, derivatives) = component_value_and_partials(x, *peak, self.options); + predicted += value; + for free_index in 0..free.len() { + for kind in 0..3 { + gradient[free_index] += derivatives[kind] * jacobian[kind][free_index]; + } + } + } + let residual = y - predicted; + for a in 0..free.len() { + jtr[a] += gradient[a] * residual; + for b in a..free.len() { + jtj[a][b] += gradient[a] * gradient[b]; + } + } + } + mirror_upper(jtj); + } +} + +pub fn validate_constrained_peaks( + peaks: &[ConstrainedPeakSpec], +) -> Result<(), ConstrainedLineFitError> { + if peaks.is_empty() { + return Ok(()); + } + let constraints = CompiledConstraints::new(peaks)?; + let initial = constraints.initial(); + let (values, _) = constraints.decode_with_jacobian(&initial); + validate_physical(&values)?; + Ok(()) +} + +pub fn fit_constrained_lineshapes( + x: &[f64], + y: &[f64], + peaks: &[ConstrainedPeakSpec], + options: ConstrainedLineShapeOptions, + cancelled: &impl Fn() -> bool, +) -> Result { + if x.len() != y.len() + || x.len() < 3 + || x.iter().chain(y).any(|value| !value.is_finite()) + || options.max_iterations == 0 + || !(0.0..=1.0).contains(&options.pseudo_voigt_fraction) + { + return Err(ConstrainedLineFitError::InvalidInput); + } + if cancelled() { + return Err(ConstrainedLineFitError::Cancelled); + } + let constraints = CompiledConstraints::new(peaks)?; + let initial = constraints.initial(); + let (initial_values, _) = constraints.decode_with_jacobian(&initial); + validate_physical(&initial_values)?; + let mut problem = ConstrainedProblem { + x, + y, + constraints: &constraints, + options, + }; + let free = if initial.is_empty() { + Vec::new() + } else { + levenberg_marquardt_problem_cancellable( + &mut problem, + &initial, + options.max_iterations, + cancelled, + ) + .map(|(parameters, _)| parameters) + .ok_or_else(|| { + if cancelled() { + ConstrainedLineFitError::Cancelled + } else { + ConstrainedLineFitError::DidNotConverge + } + })? + }; + let free_covariance = problem_parameter_covariance(&mut problem, &free, x.len()); + let (values, physical_jacobian) = constraints.decode_with_jacobian(&free); + validate_physical(&values)?; + let physical_covariance = propagate_covariance(&physical_jacobian, free_covariance.as_ref()); + let components = values + .iter() + .map(|&peak| { + x.iter() + .map(|&x| component_value_and_partials(x, peak, options).0) + .collect::>() + }) + .collect::>(); + let total = (0..x.len()) + .map(|row| components.iter().map(|component| component[row]).sum()) + .collect::>(); + let residual = y.iter().zip(&total).map(|(y, fit)| y - fit).collect(); + Ok(ConstrainedLineFit { + peaks: peaks + .iter() + .zip(&values) + .map(|(spec, values)| ConstrainedPeakResult { + key: spec.key, + position: values[0], + fwhm: values[1], + area: values[2], + hit_position_bound: hit_bound(&spec.position, values[0]), + hit_fwhm_bound: hit_bound(&spec.fwhm, values[1]), + hit_area_bound: hit_bound(&spec.area, values[2]), + }) + .collect(), + components, + total, + residual, + physical_covariance, + }) +} + +pub fn area_normalized_peak( + shape: LineShape, + pseudo_voigt_fraction: f64, + x: f64, + position: f64, + fwhm: f64, + area: f64, +) -> f64 { + component_value_and_partials( + x, + [position, fwhm, area], + ConstrainedLineShapeOptions { + shape, + pseudo_voigt_fraction, + max_iterations: 1, + }, + ) + .0 +} + +fn component_value_and_partials( + x: f64, + peak: [f64; 3], + options: ConstrainedLineShapeOptions, +) -> (f64, [f64; 3]) { + let [position, fwhm, area] = peak; + if fwhm <= 0.0 || area < 0.0 { + return (0.0, [0.0; 3]); + } + let eta = options.pseudo_voigt_fraction; + let factor = options.shape.area_factor(eta); + let height = area / (fwhm * factor); + let partials = peak_partials(options.shape, x - position, height, fwhm, eta); + let value = height * options.shape.unit(x - position, fwhm, eta); + ( + value, + [ + partials[0], + partials[2] - partials[1] * height / fwhm, + partials[1] / (fwhm * factor), + ], + ) +} + +fn component_sum(x: f64, peaks: &[[f64; 3]], options: ConstrainedLineShapeOptions) -> f64 { + peaks + .iter() + .map(|&peak| component_value_and_partials(x, peak, options).0) + .sum() +} + +fn parameter(peak: &ConstrainedPeakSpec, kind: usize) -> &PeakParameterConstraint { + match kind { + 0 => &peak.position, + 1 => &peak.fwhm, + _ => &peak.area, + } +} + +fn dependency_order( + peaks: &[ConstrainedPeakSpec], + keys: &BTreeMap, + parameter: impl Fn(&ConstrainedPeakSpec) -> &PeakParameterConstraint, +) -> Result, ConstrainedLineFitError> { + fn visit( + index: usize, + peaks: &[ConstrainedPeakSpec], + keys: &BTreeMap, + parameter: &impl Fn(&ConstrainedPeakSpec) -> &PeakParameterConstraint, + visiting: &mut BTreeSet, + visited: &mut BTreeSet, + out: &mut Vec, + ) -> Result<(), ConstrainedLineFitError> { + if visited.contains(&index) { + return Ok(()); + } + if !visiting.insert(index) { + return Err(invalid("parameter links contain a cycle")); + } + if let PeakParameterConstraint::Linked { reference, .. } = parameter(&peaks[index]) { + let target = *keys + .get(reference) + .ok_or_else(|| invalid("a parameter link targets a missing component"))?; + if target == index { + return Err(invalid("a component cannot link to itself")); + } + visit(target, peaks, keys, parameter, visiting, visited, out)?; + } + visiting.remove(&index); + visited.insert(index); + out.push(index); + Ok(()) + } + let mut visiting = BTreeSet::new(); + let mut visited = BTreeSet::new(); + let mut out = Vec::with_capacity(peaks.len()); + for index in 0..peaks.len() { + visit( + index, + peaks, + keys, + ¶meter, + &mut visiting, + &mut visited, + &mut out, + )?; + } + Ok(out) +} + +fn push_free( + free: &mut Vec, + initial: f64, + bounds: [f64; 2], +) -> Result { + if !initial.is_finite() + || bounds.iter().any(|value| !value.is_finite()) + || bounds[0] >= bounds[1] + || initial < bounds[0] + || initial > bounds[1] + { + return Err(invalid( + "free parameter bounds must be ordered and contain the initial value", + )); + } + let index = free.len(); + free.push(FreeParameter { initial, bounds }); + Ok(index) +} + +fn validate_physical(values: &[[f64; 3]]) -> Result<(), ConstrainedLineFitError> { + if values + .iter() + .any(|peak| peak.iter().any(|value| !value.is_finite()) || peak[1] <= 0.0 || peak[2] < 0.0) + { + return Err(invalid( + "linked FWHM must stay positive and linked area non-negative", + )); + } + Ok(()) +} + +fn propagate_covariance( + jacobian: &[Vec>], + covariance: Option<&Vec>>, +) -> Option>> { + let covariance = covariance?; + let rows = jacobian.len() * 3; + let free = covariance.len(); + let mut output = vec![vec![0.0; rows]; rows]; + for a in 0..rows { + for b in 0..rows { + for i in 0..free { + for j in 0..free { + output[a][b] += + jacobian[a / 3][a % 3][i] * covariance[i][j] * jacobian[b / 3][b % 3][j]; + } + } + } + } + Some(output) +} + +fn hit_bound(spec: &PeakParameterConstraint, value: f64) -> bool { + match spec { + PeakParameterConstraint::Free { bounds, .. } => { + (value - bounds[0]).abs() < 1e-3 || (value - bounds[1]).abs() < 1e-3 + } + _ => false, + } +} + +fn to_bound(value: f64, bounds: [f64; 2]) -> f64 { + bounds[0] + (bounds[1] - bounds[0]) / (1.0 + (-value).exp()) +} + +fn from_bound(value: f64, bounds: [f64; 2]) -> f64 { + let ratio = ((value - bounds[0]) / (bounds[1] - bounds[0])).clamp(1e-12, 1.0 - 1e-12); + (ratio / (1.0 - ratio)).ln() +} + +fn bound_derivative(value: f64, bounds: [f64; 2]) -> f64 { + let logistic = 1.0 / (1.0 + (-value).exp()); + (bounds[1] - bounds[0]) * logistic * (1.0 - logistic) +} + +fn invalid(message: impl Into) -> ConstrainedLineFitError { + ConstrainedLineFitError::InvalidConstraints(message.into()) +} + +#[cfg(test)] +#[path = "constrained_tests.rs"] +mod tests; diff --git a/crates/analysis/src/lineshape/constrained_tests.rs b/crates/analysis/src/lineshape/constrained_tests.rs new file mode 100644 index 0000000..fbf2b08 --- /dev/null +++ b/crates/analysis/src/lineshape/constrained_tests.rs @@ -0,0 +1,134 @@ +use super::*; + +fn free(initial: f64, bounds: [f64; 2]) -> PeakParameterConstraint { + PeakParameterConstraint::Free { initial, bounds } +} + +fn fixed(value: f64) -> PeakParameterConstraint { + PeakParameterConstraint::Fixed { value } +} + +fn options() -> ConstrainedLineShapeOptions { + ConstrainedLineShapeOptions { + shape: LineShape::PseudoVoigt, + pseudo_voigt_fraction: 0.3, + max_iterations: 500, + } +} + +fn axis() -> Vec { + (0..241).map(|index| 280.0 + index as f64 * 0.05).collect() +} + +#[test] +fn constrained_fit_preserves_keys_and_links_after_reordering() { + let x = axis(); + let first = PeakConstraintKey(11); + let second = PeakConstraintKey(22); + let y = x + .iter() + .map(|&x| { + area_normalized_peak(LineShape::PseudoVoigt, 0.3, x, 285.0, 1.2, 100.0) + + area_normalized_peak(LineShape::PseudoVoigt, 0.3, x, 288.0, 1.2, 50.0) + }) + .collect::>(); + let mut specs = vec![ + ConstrainedPeakSpec { + key: first, + position: free(285.1, [284.5, 285.5]), + fwhm: free(1.1, [0.8, 2.0]), + area: free(90.0, [0.0, 200.0]), + }, + ConstrainedPeakSpec { + key: second, + position: PeakParameterConstraint::Linked { + reference: first, + scale: 1.0, + offset: 3.0, + }, + fwhm: PeakParameterConstraint::Linked { + reference: first, + scale: 1.0, + offset: 0.0, + }, + area: PeakParameterConstraint::Linked { + reference: first, + scale: 0.5, + offset: 0.0, + }, + }, + ]; + specs.reverse(); + let result = fit_constrained_lineshapes(&x, &y, &specs, options(), &|| false).unwrap(); + let main = result.peaks.iter().find(|peak| peak.key == first).unwrap(); + let linked = result.peaks.iter().find(|peak| peak.key == second).unwrap(); + assert!((linked.position - main.position - 3.0).abs() < 1e-10); + assert!((linked.fwhm - main.fwhm).abs() < 1e-10); + assert!((linked.area / main.area - 0.5).abs() < 1e-10); + assert!(result.physical_covariance.is_some()); +} + +#[test] +fn fixed_fit_and_cancellation_use_the_common_path() { + let x = axis(); + let y = x + .iter() + .map(|&x| area_normalized_peak(LineShape::PseudoVoigt, 0.3, x, 285.0, 1.2, 100.0)) + .collect::>(); + let specs = vec![ConstrainedPeakSpec { + key: PeakConstraintKey(1), + position: fixed(285.0), + fwhm: fixed(1.2), + area: fixed(100.0), + }]; + let result = fit_constrained_lineshapes(&x, &y, &specs, options(), &|| false).unwrap(); + assert!(result.physical_covariance.is_none()); + assert!(result.residual.iter().all(|value| value.abs() < 1e-12)); + assert_eq!( + fit_constrained_lineshapes(&x, &y, &specs, options(), &|| true), + Err(ConstrainedLineFitError::Cancelled) + ); +} + +#[test] +fn invalid_graph_and_linked_physical_values_are_rejected() { + let key = PeakConstraintKey(1); + let missing = ConstrainedPeakSpec { + key, + position: PeakParameterConstraint::Linked { + reference: PeakConstraintKey(2), + scale: 1.0, + offset: 0.0, + }, + fwhm: fixed(1.0), + area: fixed(1.0), + }; + assert!(matches!( + validate_constrained_peaks(&[missing]), + Err(ConstrainedLineFitError::InvalidConstraints(_)) + )); + + let specs = vec![ + ConstrainedPeakSpec { + key, + position: fixed(1.0), + fwhm: fixed(1.0), + area: fixed(1.0), + }, + ConstrainedPeakSpec { + key: PeakConstraintKey(2), + position: fixed(2.0), + fwhm: PeakParameterConstraint::Linked { + reference: key, + scale: -1.0, + offset: 0.0, + }, + area: fixed(1.0), + }, + ]; + let x = (0..8).map(|value| value as f64).collect::>(); + assert!(matches!( + fit_constrained_lineshapes(&x, &[0.0; 8], &specs, options(), &|| false), + Err(ConstrainedLineFitError::InvalidConstraints(_)) + )); +} diff --git a/crates/analysis/src/xps.rs b/crates/analysis/src/xps.rs new file mode 100644 index 0000000..4a058fb --- /dev/null +++ b/crates/analysis/src/xps.rs @@ -0,0 +1,442 @@ +//! XPS-specific background and constrained peak analysis. + +mod bootstrap; +mod fit; + +pub use bootstrap::*; +pub use fit::*; + +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize, +)] +pub struct XpsComponentId(pub u64); + +impl XpsComponentId { + pub const fn new(value: u64) -> Self { + Self(value) + } +} + +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub enum XpsBackgroundModel { + Linear, + Shirley { + tolerance: f64, + max_iterations: usize, + }, + TougaardU2 { + b_ev2: f64, + c_ev2: f64, + }, +} + +impl Default for XpsBackgroundModel { + fn default() -> Self { + Self::Shirley { + tolerance: 1e-6, + max_iterations: 100, + } + } +} + +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct XpsBackgroundSpec { + pub model: XpsBackgroundModel, + pub window_ev: [f64; 2], + pub low_anchor_ev: [f64; 2], + pub high_anchor_ev: [f64; 2], +} + +impl XpsBackgroundSpec { + pub fn suggested(energy: &[f64]) -> Option { + if energy.len() < 3 || energy.iter().any(|value| !value.is_finite()) { + return None; + } + let low = energy.iter().copied().reduce(f64::min)?; + let high = energy.iter().copied().reduce(f64::max)?; + if high <= low { + return None; + } + let mut sorted = energy.to_vec(); + sorted.sort_by(f64::total_cmp); + let edge = sorted.len().min(3); + Some(Self { + model: XpsBackgroundModel::default(), + window_ev: [low, high], + low_anchor_ev: [sorted[0], sorted[edge - 1]], + high_anchor_ev: [sorted[sorted.len() - edge], sorted[sorted.len() - 1]], + }) + } +} + +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct XpsBackgroundResult { + pub energy_ev: Vec, + pub intensity: Vec, + pub background: Vec, + pub corrected: Vec, +} + +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub enum XpsCenterConstraint { + Free { + initial_ev: f64, + bounds_ev: [f64; 2], + }, + Fixed { + value_ev: f64, + }, + Offset { + reference: XpsComponentId, + delta_ev: f64, + }, +} + +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub enum XpsFwhmConstraint { + Free { + initial_ev: f64, + bounds_ev: [f64; 2], + }, + Fixed { + value_ev: f64, + }, + Shared { + reference: XpsComponentId, + }, +} + +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub enum XpsAreaConstraint { + Free { + initial: f64, + bounds: [f64; 2], + }, + Fixed { + value: f64, + }, + Ratio { + reference: XpsComponentId, + ratio: f64, + }, +} + +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct XpsPeakSpec { + pub id: XpsComponentId, + pub label: String, + pub center: XpsCenterConstraint, + pub fwhm: XpsFwhmConstraint, + pub area: XpsAreaConstraint, +} + +impl XpsPeakSpec { + pub fn independent( + id: XpsComponentId, + label: impl Into, + center_ev: f64, + area: f64, + ) -> Self { + Self { + id, + label: label.into(), + center: XpsCenterConstraint::Free { + initial_ev: center_ev, + bounds_ev: [center_ev - 0.8, center_ev + 0.8], + }, + fwhm: XpsFwhmConstraint::Free { + initial_ev: 1.2, + bounds_ev: [0.8, 2.5], + }, + area: XpsAreaConstraint::Free { + initial: area.max(f64::MIN_POSITIVE), + bounds: [0.0, (area.abs() * 20.0).max(1.0)], + }, + } + } +} + +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct XpsFitOptions { + pub lorentzian_fraction: f64, + pub max_iterations: usize, +} + +impl Default for XpsFitOptions { + fn default() -> Self { + Self { + lorentzian_fraction: 0.3, + max_iterations: 5_000, + } + } +} + +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct XpsFitInvocation { + pub background: XpsBackgroundSpec, + pub peaks: Vec, + pub options: XpsFitOptions, +} + +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct XpsParameterEstimate { + pub value: f64, + pub standard_error: Option, + pub confidence_95: Option<[f64; 2]>, +} + +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct XpsFittedPeak { + pub id: XpsComponentId, + pub label: String, + pub center_ev: XpsParameterEstimate, + pub fwhm_ev: XpsParameterEstimate, + pub area: XpsParameterEstimate, + pub fraction: XpsParameterEstimate, + pub hit_position_bound: bool, + pub hit_fwhm_bound: bool, + pub hit_area_bound: bool, +} + +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct XpsFitResult { + #[serde(skip)] + pub energy_ev: Vec, + #[serde(skip)] + pub intensity: Vec, + #[serde(skip)] + pub background: Vec, + #[serde(skip)] + pub envelope: Vec, + #[serde(skip)] + pub residual: Vec, + #[serde(skip)] + pub components: Vec>, + pub peaks: Vec, + pub parameter_labels: Vec, + pub parameter_correlation: Option>>, + pub r_squared: f64, + pub rmse: f64, + pub residual_lag1: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum XpsFitError { + #[error("XPS arrays are invalid")] + InvalidInput, + #[error("XPS background specification is invalid")] + InvalidBackground, + #[error("XPS peak constraints are invalid: {0}")] + InvalidConstraints(String), + #[error("XPS fit was cancelled")] + Cancelled, + #[error("XPS fit did not converge")] + DidNotConverge, +} + +pub fn compute_xps_background( + energy: &[f64], + intensity: &[f64], + spec: &XpsBackgroundSpec, +) -> Result { + let (x, y) = selected_window(energy, intensity, spec.window_ev)?; + let low = anchor_mean(&x, &y, spec.low_anchor_ev)?; + let high = anchor_mean(&x, &y, spec.high_anchor_ev)?; + let background = match spec.model { + XpsBackgroundModel::Linear => linear_background(&x, low, high), + XpsBackgroundModel::Shirley { + tolerance, + max_iterations, + } => shirley_with_levels(&x, &y, low, high, tolerance, max_iterations)?, + XpsBackgroundModel::TougaardU2 { b_ev2, c_ev2 } => { + tougaard_u2_background(&x, &y, low, high, b_ev2, c_ev2)? + } + }; + let corrected = y + .iter() + .zip(&background) + .map(|(value, base)| value - base) + .collect(); + Ok(XpsBackgroundResult { + energy_ev: x, + intensity: y, + background, + corrected, + }) +} + +pub fn shirley_background( + energy: &[f64], + intensity: &[f64], + tolerance: f64, + max_iterations: usize, +) -> Result, XpsFitError> { + let spec = XpsBackgroundSpec::suggested(energy).ok_or(XpsFitError::InvalidInput)?; + let mut spec = spec; + spec.model = XpsBackgroundModel::Shirley { + tolerance, + max_iterations, + }; + Ok(compute_xps_background(energy, intensity, &spec)?.background) +} + +fn selected_window( + energy: &[f64], + intensity: &[f64], + bounds: [f64; 2], +) -> Result<(Vec, Vec), XpsFitError> { + if energy.len() != intensity.len() + || energy.len() < 3 + || energy + .iter() + .chain(intensity) + .any(|value| !value.is_finite()) + || bounds.iter().any(|value| !value.is_finite()) + { + return Err(XpsFitError::InvalidInput); + } + let low = bounds[0].min(bounds[1]); + let high = bounds[0].max(bounds[1]); + let (x, y): (Vec<_>, Vec<_>) = energy + .iter() + .copied() + .zip(intensity.iter().copied()) + .filter(|(value, _)| *value >= low && *value <= high) + .unzip(); + if x.len() < 8 { + return Err(XpsFitError::InvalidBackground); + } + Ok((x, y)) +} + +fn anchor_mean(x: &[f64], y: &[f64], bounds: [f64; 2]) -> Result { + if bounds.iter().any(|value| !value.is_finite()) { + return Err(XpsFitError::InvalidBackground); + } + let low = bounds[0].min(bounds[1]); + let high = bounds[0].max(bounds[1]); + let values = x + .iter() + .zip(y) + .filter_map(|(&x, &y)| (x >= low && x <= high).then_some(y)) + .collect::>(); + if values.is_empty() { + return Err(XpsFitError::InvalidBackground); + } + Ok(values.iter().sum::() / values.len() as f64) +} + +fn linear_background(x: &[f64], low_level: f64, high_level: f64) -> Vec { + let low_x = x.iter().copied().fold(f64::INFINITY, f64::min); + let high_x = x.iter().copied().fold(f64::NEG_INFINITY, f64::max); + x.iter() + .map(|value| { + let t = (*value - low_x) / (high_x - low_x); + low_level + t * (high_level - low_level) + }) + .collect() +} + +fn shirley_with_levels( + energy: &[f64], + intensity: &[f64], + low_level: f64, + high_level: f64, + tolerance: f64, + max_iterations: usize, +) -> Result, XpsFitError> { + if !tolerance.is_finite() || tolerance <= 0.0 || max_iterations == 0 { + return Err(XpsFitError::InvalidBackground); + } + let flipped = energy[0] < energy[energy.len() - 1]; + let mut x = energy.to_vec(); + let mut y = intensity.to_vec(); + if flipped { + x.reverse(); + y.reverse(); + } + let n = x.len(); + let mut background = (0..n) + .map(|i| high_level + (low_level - high_level) * i as f64 / (n - 1) as f64) + .collect::>(); + for _ in 0..max_iterations { + let old = background.clone(); + let mut cumulative = vec![0.0; n]; + for i in (0..n - 1).rev() { + let dx = (x[i] - x[i + 1]).abs(); + let a = (y[i] - background[i]).max(0.0); + let b = (y[i + 1] - background[i + 1]).max(0.0); + cumulative[i] = cumulative[i + 1] + 0.5 * (a + b) * dx; + } + let total = cumulative[0]; + if total <= f64::MIN_POSITIVE { + break; + } + for i in 0..n { + background[i] = low_level + (high_level - low_level) * cumulative[i] / total; + } + let change = background + .iter() + .zip(&old) + .map(|(a, b)| (a - b).abs()) + .fold(0.0, f64::max); + if change < tolerance { + break; + } + } + if flipped { + background.reverse(); + } + Ok(background) +} + +fn tougaard_u2_background( + energy: &[f64], + intensity: &[f64], + low_level: f64, + high_level: f64, + b_ev2: f64, + c_ev2: f64, +) -> Result, XpsFitError> { + if !b_ev2.is_finite() || !c_ev2.is_finite() || b_ev2 <= 0.0 || c_ev2 <= 0.0 { + return Err(XpsFitError::InvalidBackground); + } + let ascending = energy[0] < energy[energy.len() - 1]; + let mut x = energy.to_vec(); + let mut y = intensity.to_vec(); + if !ascending { + x.reverse(); + y.reverse(); + } + let mut background = vec![low_level; x.len()]; + for i in 1..x.len() { + let mut integral = 0.0; + for j in 0..i { + let t0 = x[i] - x[j]; + let t1 = x[i] - x[j + 1]; + let k0 = tougaard_u2_kernel(t0, b_ev2, c_ev2); + let k1 = tougaard_u2_kernel(t1, b_ev2, c_ev2); + let s0 = (y[j] - low_level).max(0.0) * k0; + let s1 = (y[j + 1] - low_level).max(0.0) * k1; + integral += 0.5 * (s0 + s1) * (x[j + 1] - x[j]).abs(); + } + background[i] += integral; + } + let correction = high_level - background[background.len() - 1]; + let span = x[x.len() - 1] - x[0]; + for (value, &energy) in background.iter_mut().zip(&x) { + *value += correction * (energy - x[0]) / span; + } + if !ascending { + background.reverse(); + } + Ok(background) +} + +fn tougaard_u2_kernel(loss_ev: f64, b_ev2: f64, c_ev2: f64) -> f64 { + b_ev2 * loss_ev / (c_ev2 + loss_ev * loss_ev).powi(2) +} + +#[cfg(test)] +#[path = "xps/tests.rs"] +mod tests; diff --git a/crates/analysis/src/xps/bootstrap.rs b/crates/analysis/src/xps/bootstrap.rs new file mode 100644 index 0000000..874cf76 --- /dev/null +++ b/crates/analysis/src/xps/bootstrap.rs @@ -0,0 +1,167 @@ +use super::*; + +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct XpsBootstrapOptions { + pub samples: usize, + pub seed: u64, +} + +impl Default for XpsBootstrapOptions { + fn default() -> Self { + Self { + samples: 500, + seed: 1, + } + } +} + +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct XpsBootstrapPeak { + pub id: XpsComponentId, + pub center_ev: [f64; 3], + pub fwhm_ev: [f64; 3], + pub area: [f64; 3], + pub fraction: [f64; 3], +} + +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct XpsBootstrapResult { + pub requested: usize, + pub converged: usize, + pub seed: u64, + pub peaks: Vec, +} + +impl XpsBootstrapResult { + pub fn convergence_fraction(&self) -> f64 { + self.converged as f64 / self.requested.max(1) as f64 + } +} + +pub fn bootstrap_xps_fit( + base: &XpsFitResult, + invocation: &XpsFitInvocation, + options: &XpsBootstrapOptions, + cancelled: &impl Fn() -> bool, +) -> Result { + if !(100..=5_000).contains(&options.samples) + || base.energy_ev.len() != base.envelope.len() + || base.energy_ev.len() != base.residual.len() + { + return Err(XpsFitError::InvalidInput); + } + let mut random = XorShift64::new(options.seed); + let mut samples = invocation + .peaks + .iter() + .map(|peak| PeakSamples::new(peak.id)) + .collect::>(); + let mut converged = 0; + for _ in 0..options.samples { + if cancelled() { + return Err(XpsFitError::Cancelled); + } + let intensity = base + .envelope + .iter() + .zip(&base.residual) + .map(|(fit, residual)| { + let sign = if random.next_u64() & 1 == 0 { + -1.0 + } else { + 1.0 + }; + fit + sign * residual + }) + .collect::>(); + match fit_xps_peaks(&base.energy_ev, &intensity, invocation, cancelled) { + Ok(result) => { + converged += 1; + for (sample, peak) in samples.iter_mut().zip(&result.peaks) { + sample.center.push(peak.center_ev.value); + sample.fwhm.push(peak.fwhm_ev.value); + sample.area.push(peak.area.value); + sample.fraction.push(peak.fraction.value); + } + } + Err(XpsFitError::Cancelled) => return Err(XpsFitError::Cancelled), + Err(_) => {} + } + } + if converged == 0 { + return Err(XpsFitError::DidNotConverge); + } + Ok(XpsBootstrapResult { + requested: options.samples, + converged, + seed: options.seed, + peaks: samples + .into_iter() + .map(|sample| XpsBootstrapPeak { + id: sample.id, + center_ev: percentiles(sample.center), + fwhm_ev: percentiles(sample.fwhm), + area: percentiles(sample.area), + fraction: percentiles(sample.fraction), + }) + .collect(), + }) +} + +struct PeakSamples { + id: XpsComponentId, + center: Vec, + fwhm: Vec, + area: Vec, + fraction: Vec, +} + +impl PeakSamples { + fn new(id: XpsComponentId) -> Self { + Self { + id, + center: Vec::new(), + fwhm: Vec::new(), + area: Vec::new(), + fraction: Vec::new(), + } + } +} + +fn percentiles(mut values: Vec) -> [f64; 3] { + values.sort_by(f64::total_cmp); + [ + percentile(&values, 0.025), + percentile(&values, 0.5), + percentile(&values, 0.975), + ] +} + +fn percentile(values: &[f64], probability: f64) -> f64 { + let index = probability * (values.len() - 1) as f64; + let low = index.floor() as usize; + let high = index.ceil() as usize; + let weight = index - low as f64; + values[low] * (1.0 - weight) + values[high] * weight +} + +struct XorShift64(u64); + +impl XorShift64 { + fn new(seed: u64) -> Self { + Self(if seed == 0 { + 0x9e37_79b9_7f4a_7c15 + } else { + seed + }) + } + + fn next_u64(&mut self) -> u64 { + let mut value = self.0; + value ^= value << 13; + value ^= value >> 7; + value ^= value << 17; + self.0 = value; + value + } +} diff --git a/crates/analysis/src/xps/fit.rs b/crates/analysis/src/xps/fit.rs new file mode 100644 index 0000000..66bffaa --- /dev/null +++ b/crates/analysis/src/xps/fit.rs @@ -0,0 +1,423 @@ +use super::*; +use crate::lineshape::{ + ConstrainedLineFitError, ConstrainedLineShapeOptions, ConstrainedPeakSpec, LineShape, + PeakConstraintKey, PeakParameterConstraint, area_normalized_peak, fit_constrained_lineshapes, + validate_constrained_peaks, +}; + +pub fn gl_peak(x: f64, center: f64, fwhm: f64, area: f64, lorentzian_fraction: f64) -> f64 { + area_normalized_peak( + LineShape::PseudoVoigt, + lorentzian_fraction, + x, + center, + fwhm, + area, + ) +} + +pub fn fit_xps_peaks( + energy: &[f64], + intensity: &[f64], + invocation: &XpsFitInvocation, + cancelled: &impl Fn() -> bool, +) -> Result { + validate_options(invocation)?; + let background = compute_xps_background(energy, intensity, &invocation.background)?; + let specs = compile_specs(&invocation.peaks)?; + let fit = fit_constrained_lineshapes( + &background.energy_ev, + &background.corrected, + &specs, + line_options(&invocation.options), + cancelled, + ) + .map_err(map_fit_error)?; + + let total_area = fit.peaks.iter().map(|peak| peak.area).sum::(); + let fractions = fit + .peaks + .iter() + .map(|peak| { + if total_area > 0.0 { + peak.area / total_area + } else { + 0.0 + } + }) + .collect::>(); + let physical_covariance = xps_physical_covariance( + fit.physical_covariance.as_ref(), + &fit.peaks.iter().map(|peak| peak.area).collect::>(), + ); + let (sigma, correlation) = covariance_diagnostics(physical_covariance.as_ref()); + let estimate = |index: usize, value: f64| parameter_estimate(value, sigma.as_ref(), index); + let peaks = fit + .peaks + .iter() + .zip(&invocation.peaks) + .zip(&fractions) + .enumerate() + .map(|(index, ((result, spec), fraction))| { + let base = index * 4; + XpsFittedPeak { + id: spec.id, + label: spec.label.clone(), + center_ev: estimate(base, result.position), + fwhm_ev: estimate(base + 1, result.fwhm), + area: estimate(base + 2, result.area), + fraction: estimate(base + 3, *fraction), + hit_position_bound: result.hit_position_bound, + hit_fwhm_bound: result.hit_fwhm_bound, + hit_area_bound: result.hit_area_bound, + } + }) + .collect::>(); + let envelope = background + .background + .iter() + .zip(&fit.total) + .map(|(background, peaks)| background + peaks) + .collect::>(); + let ss_res = fit.residual.iter().map(|value| value * value).sum::(); + let mean = background.intensity.iter().sum::() / background.intensity.len() as f64; + let ss_tot = background + .intensity + .iter() + .map(|value| (value - mean).powi(2)) + .sum::(); + Ok(XpsFitResult { + energy_ev: background.energy_ev, + intensity: background.intensity, + background: background.background, + envelope, + residual: fit.residual.clone(), + components: fit.components, + peaks, + parameter_labels: invocation + .peaks + .iter() + .flat_map(|peak| { + ["center", "FWHM", "area", "fraction"] + .map(|parameter| format!("{} {parameter}", peak.label)) + }) + .collect(), + parameter_correlation: correlation, + r_squared: if ss_tot > f64::MIN_POSITIVE { + 1.0 - ss_res / ss_tot + } else { + 0.0 + }, + rmse: (ss_res / fit.residual.len() as f64).sqrt(), + residual_lag1: residual_lag1(&fit.residual), + }) +} + +/// Rebuild curve arrays for a persisted PlotX result without running the solver. +pub fn rebuild_xps_fit_curves( + energy: &[f64], + intensity: &[f64], + invocation: &XpsFitInvocation, + result: &mut XpsFitResult, +) -> Result<(), XpsFitError> { + validate_xps_fit_summary(invocation, result)?; + let background = compute_xps_background(energy, intensity, &invocation.background)?; + let fitted = invocation + .peaks + .iter() + .map(|spec| { + result + .peaks + .iter() + .find(|peak| peak.id == spec.id) + .ok_or_else(|| { + XpsFitError::InvalidConstraints( + "persisted result does not match its component IDs".into(), + ) + }) + }) + .collect::, _>>()?; + let components = fitted + .iter() + .map(|peak| { + background + .energy_ev + .iter() + .map(|&x| { + gl_peak( + x, + peak.center_ev.value, + peak.fwhm_ev.value, + peak.area.value, + invocation.options.lorentzian_fraction, + ) + }) + .collect::>() + }) + .collect::>(); + let envelope = (0..background.energy_ev.len()) + .map(|index| { + background.background[index] + + components + .iter() + .map(|component| component[index]) + .sum::() + }) + .collect::>(); + let residual = background + .intensity + .iter() + .zip(&envelope) + .map(|(observed, predicted)| observed - predicted) + .collect(); + result.energy_ev = background.energy_ev; + result.intensity = background.intensity; + result.background = background.background; + result.envelope = envelope; + result.residual = residual; + result.components = components; + Ok(()) +} + +pub fn validate_xps_fit_summary( + invocation: &XpsFitInvocation, + result: &XpsFitResult, +) -> Result<(), XpsFitError> { + validate_xps_constraints(invocation)?; + if result.peaks.len() != invocation.peaks.len() + || result.parameter_labels.len() != result.peaks.len() * 4 + || !result.r_squared.is_finite() + || !result.rmse.is_finite() + || result.rmse < 0.0 + || result.residual_lag1.is_some_and(|value| !value.is_finite()) + { + return Err(XpsFitError::InvalidInput); + } + for (spec, peak) in invocation.peaks.iter().zip(&result.peaks) { + if spec.id != peak.id + || !valid_estimate(&peak.center_ev) + || !valid_estimate(&peak.fwhm_ev) + || !valid_estimate(&peak.area) + || !valid_estimate(&peak.fraction) + || peak.fwhm_ev.value <= 0.0 + || peak.area.value < 0.0 + || !(0.0..=1.0 + 1e-9).contains(&peak.fraction.value) + { + return Err(XpsFitError::InvalidInput); + } + } + if result.parameter_correlation.as_ref().is_some_and(|matrix| { + let n = result.parameter_labels.len(); + matrix.len() != n + || matrix.iter().any(|row| { + row.len() != n + || row + .iter() + .any(|value| !value.is_finite() || value.abs() > 1.0 + 1e-6) + }) + }) { + return Err(XpsFitError::InvalidInput); + } + Ok(()) +} + +fn valid_estimate(value: &XpsParameterEstimate) -> bool { + value.value.is_finite() + && value + .standard_error + .is_none_or(|error| error.is_finite() && error >= 0.0) + && value + .confidence_95 + .is_none_or(|bounds| bounds.iter().all(|bound| bound.is_finite())) +} + +pub fn validate_xps_constraints(invocation: &XpsFitInvocation) -> Result<(), XpsFitError> { + validate_options(invocation)?; + if invocation.peaks.is_empty() { + return Ok(()); + } + let specs = compile_specs(&invocation.peaks)?; + validate_constrained_peaks(&specs).map_err(map_fit_error) +} + +fn compile_specs(peaks: &[XpsPeakSpec]) -> Result, XpsFitError> { + peaks + .iter() + .map(|peak| { + Ok(ConstrainedPeakSpec { + key: key(peak.id), + position: match peak.center { + XpsCenterConstraint::Free { + initial_ev, + bounds_ev, + } => free(initial_ev, bounds_ev), + XpsCenterConstraint::Fixed { value_ev } => fixed(value_ev), + XpsCenterConstraint::Offset { + reference, + delta_ev, + } => linked(reference, 1.0, delta_ev), + }, + fwhm: match peak.fwhm { + XpsFwhmConstraint::Free { + initial_ev, + bounds_ev, + } => free(initial_ev, bounds_ev), + XpsFwhmConstraint::Fixed { value_ev } => fixed(value_ev), + XpsFwhmConstraint::Shared { reference } => linked(reference, 1.0, 0.0), + }, + area: match peak.area { + XpsAreaConstraint::Free { initial, bounds } => free(initial, bounds), + XpsAreaConstraint::Fixed { value } => fixed(value), + XpsAreaConstraint::Ratio { reference, ratio } => linked(reference, ratio, 0.0), + }, + }) + }) + .collect() +} + +fn free(initial: f64, bounds: [f64; 2]) -> PeakParameterConstraint { + PeakParameterConstraint::Free { initial, bounds } +} + +fn fixed(value: f64) -> PeakParameterConstraint { + PeakParameterConstraint::Fixed { value } +} + +fn linked(reference: XpsComponentId, scale: f64, offset: f64) -> PeakParameterConstraint { + PeakParameterConstraint::Linked { + reference: key(reference), + scale, + offset, + } +} + +fn key(id: XpsComponentId) -> PeakConstraintKey { + PeakConstraintKey(id.0) +} + +fn line_options(options: &XpsFitOptions) -> ConstrainedLineShapeOptions { + ConstrainedLineShapeOptions { + shape: LineShape::PseudoVoigt, + pseudo_voigt_fraction: options.lorentzian_fraction, + max_iterations: options.max_iterations, + } +} + +fn validate_options(invocation: &XpsFitInvocation) -> Result<(), XpsFitError> { + if invocation.options.max_iterations == 0 + || !(0.0..=1.0).contains(&invocation.options.lorentzian_fraction) + { + return Err(XpsFitError::InvalidInput); + } + Ok(()) +} + +fn map_fit_error(error: ConstrainedLineFitError) -> XpsFitError { + match error { + ConstrainedLineFitError::InvalidInput => XpsFitError::InvalidInput, + ConstrainedLineFitError::InvalidConstraints(message) => { + XpsFitError::InvalidConstraints(message) + } + ConstrainedLineFitError::Cancelled => XpsFitError::Cancelled, + ConstrainedLineFitError::DidNotConverge => XpsFitError::DidNotConverge, + } +} + +fn xps_physical_covariance( + covariance: Option<&Vec>>, + areas: &[f64], +) -> Option>> { + let covariance = covariance?; + let source_count = areas.len() * 3; + if covariance.len() != source_count || covariance.iter().any(|row| row.len() != source_count) { + return None; + } + let target_count = areas.len() * 4; + let total = areas.iter().sum::(); + let mut transform = vec![vec![0.0; source_count]; target_count]; + for peak in 0..areas.len() { + transform[peak * 4][peak * 3] = 1.0; + transform[peak * 4 + 1][peak * 3 + 1] = 1.0; + transform[peak * 4 + 2][peak * 3 + 2] = 1.0; + if total > f64::MIN_POSITIVE { + for area in 0..areas.len() { + let numerator = if area == peak { + total - areas[peak] + } else { + -areas[peak] + }; + transform[peak * 4 + 3][area * 3 + 2] = numerator / (total * total); + } + } + } + let mut output = vec![vec![0.0; target_count]; target_count]; + for a in 0..target_count { + for b in 0..target_count { + for i in 0..source_count { + for j in 0..source_count { + output[a][b] += transform[a][i] * covariance[i][j] * transform[b][j]; + } + } + } + } + Some(output) +} + +fn covariance_diagnostics( + covariance: Option<&Vec>>, +) -> (Option>, Option>>) { + let covariance = match covariance { + Some(covariance) => covariance, + None => return (None, None), + }; + let sigma = (0..covariance.len()) + .map(|index| covariance[index][index].max(0.0).sqrt()) + .collect::>(); + let correlation = (0..covariance.len()) + .map(|row| { + (0..covariance.len()) + .map(|column| { + let scale = sigma[row] * sigma[column]; + if scale > f64::MIN_POSITIVE { + covariance[row][column] / scale + } else if row == column { + 1.0 + } else { + 0.0 + } + }) + .collect() + }) + .collect(); + (Some(sigma), Some(correlation)) +} + +fn parameter_estimate(value: f64, sigma: Option<&Vec>, index: usize) -> XpsParameterEstimate { + let standard_error = sigma.and_then(|values| values.get(index).copied()); + XpsParameterEstimate { + value, + standard_error, + confidence_95: standard_error.map(|sigma| [value - 1.96 * sigma, value + 1.96 * sigma]), + } +} + +fn residual_lag1(values: &[f64]) -> Option { + if values.len() < 3 { + return None; + } + let mean = values.iter().sum::() / values.len() as f64; + let denominator = values + .iter() + .map(|value| (value - mean).powi(2)) + .sum::(); + if denominator <= f64::MIN_POSITIVE { + return None; + } + Some( + values + .windows(2) + .map(|pair| (pair[0] - mean) * (pair[1] - mean)) + .sum::() + / denominator, + ) +} diff --git a/crates/analysis/src/xps/tests.rs b/crates/analysis/src/xps/tests.rs new file mode 100644 index 0000000..85c486f --- /dev/null +++ b/crates/analysis/src/xps/tests.rs @@ -0,0 +1,268 @@ +use super::*; + +fn axis() -> Vec { + (0..241).map(|index| 292.0 - index as f64 * 0.05).collect() +} + +fn invocation(x: &[f64]) -> XpsFitInvocation { + let first = XpsComponentId::new(1); + XpsFitInvocation { + background: XpsBackgroundSpec::suggested(x).unwrap(), + peaks: vec![ + XpsPeakSpec { + id: first, + label: "main".into(), + center: XpsCenterConstraint::Free { + initial_ev: 285.1, + bounds_ev: [284.5, 285.5], + }, + fwhm: XpsFwhmConstraint::Free { + initial_ev: 1.1, + bounds_ev: [0.8, 2.0], + }, + area: XpsAreaConstraint::Free { + initial: 100.0, + bounds: [0.0, 1_000.0], + }, + }, + XpsPeakSpec { + id: XpsComponentId::new(2), + label: "linked".into(), + center: XpsCenterConstraint::Offset { + reference: first, + delta_ev: 3.0, + }, + fwhm: XpsFwhmConstraint::Shared { reference: first }, + area: XpsAreaConstraint::Ratio { + reference: first, + ratio: 0.5, + }, + }, + ], + options: XpsFitOptions::default(), + } +} + +#[test] +fn backgrounds_are_order_independent() { + let x = axis(); + let y = x + .iter() + .map(|value| 5.0 + gl_peak(*value, 285.0, 1.2, 100.0, 0.3)) + .collect::>(); + for model in [ + XpsBackgroundModel::Linear, + XpsBackgroundModel::default(), + XpsBackgroundModel::TougaardU2 { + b_ev2: 3_000.0, + c_ev2: 1_643.0, + }, + ] { + let mut spec = XpsBackgroundSpec::suggested(&x).unwrap(); + spec.model = model; + let forward = compute_xps_background(&x, &y, &spec).unwrap(); + let mut xr = x.clone(); + let mut yr = y.clone(); + xr.reverse(); + yr.reverse(); + let mut reverse = compute_xps_background(&xr, &yr, &spec).unwrap().background; + reverse.reverse(); + assert!( + forward + .background + .iter() + .zip(reverse) + .all(|(a, b)| (a - b).abs() < 1e-8) + ); + } +} + +#[test] +fn tougaard_u2_anchored_trapezoid_regression() { + let x = (0..8).map(|value| value as f64).collect::>(); + let y = vec![2.0, 4.0, 8.0, 5.0, 3.0, 2.0, 2.0, 2.0]; + let spec = XpsBackgroundSpec { + model: XpsBackgroundModel::TougaardU2 { + b_ev2: 2.0, + c_ev2: 3.0, + }, + window_ev: [0.0, 7.0], + low_anchor_ev: [0.0, 0.0], + high_anchor_ev: [7.0, 7.0], + }; + let expected = [ + 2.0, + 1.971_363_090_560_919, + 2.192_726_181_121_837, + 2.827_354_577_805_206, + 2.833_581_613_944_356, + 2.521_034_741_628_157, + 2.193_285_389_428_038, + 2.0, + ]; + let result = compute_xps_background(&x, &y, &spec).unwrap(); + assert!( + result + .background + .iter() + .zip(expected) + .all(|(actual, expected)| (actual - expected).abs() < 1e-12) + ); +} + +#[test] +fn tougaard_u2_kernel_matches_quases_closed_form_integral() { + // QUASES-Tougaard 5.1 User's Guide, eq. (1.7): + // K(T) = B*T/(C+T^2)^2. Its integral from 0 to L is the expression below. + let b = 3_000.0; + let c = 1_643.0; + let limit: f64 = 100.0; + let points = 100_000; + let step = limit / points as f64; + let numeric = (0..points) + .map(|index| { + let low = index as f64 * step; + let high = low + step; + 0.5 * (tougaard_u2_kernel(low, b, c) + tougaard_u2_kernel(high, b, c)) * step + }) + .sum::(); + let reference = 0.5 * b * (1.0 / c - 1.0 / (c + limit.powi(2))); + assert!((numeric - reference).abs() < 1e-10); +} + +#[test] +fn gl_peak_integrates_to_requested_area() { + let x = (0..20001) + .map(|i| 190.0 + i as f64 * 0.01) + .collect::>(); + let y = x + .iter() + .map(|value| gl_peak(*value, 290.0, 1.2, 42.0, 0.3)) + .collect::>(); + let area = x + .windows(2) + .zip(y.windows(2)) + .map(|(a, b)| (a[1] - a[0]) * 0.5 * (b[0] + b[1])) + .sum::(); + assert!((area - 42.0).abs() < 0.1); +} + +#[test] +fn linked_constraints_and_covariance_are_reported() { + let x = axis(); + let y = x + .iter() + .map(|value| { + 5.0 + gl_peak(*value, 285.0, 1.3, 120.0, 0.3) + gl_peak(*value, 288.0, 1.3, 60.0, 0.3) + }) + .collect::>(); + let result = fit_xps_peaks(&x, &y, &invocation(&x), &|| false).unwrap(); + assert!( + (result.peaks[1].center_ev.value - result.peaks[0].center_ev.value - 3.0).abs() < 1e-10 + ); + assert!((result.peaks[1].fwhm_ev.value - result.peaks[0].fwhm_ev.value).abs() < 1e-10); + assert!((result.peaks[1].area.value / result.peaks[0].area.value - 0.5).abs() < 1e-10); + assert!(result.peaks[0].center_ev.standard_error.is_some()); + assert!(result.parameter_correlation.is_some()); + + let mut reordered = invocation(&x); + reordered.peaks.reverse(); + let reordered = fit_xps_peaks(&x, &y, &reordered, &|| false).unwrap(); + let linked = reordered + .peaks + .iter() + .find(|peak| peak.id == XpsComponentId::new(2)) + .unwrap(); + let main = reordered + .peaks + .iter() + .find(|peak| peak.id == XpsComponentId::new(1)) + .unwrap(); + assert!((linked.center_ev.value - main.center_ev.value - 3.0).abs() < 1e-10); +} + +#[test] +fn cyclic_constraints_are_rejected() { + let x = axis(); + let y = vec![1.0; x.len()]; + let mut invocation = invocation(&x); + invocation.peaks[0].fwhm = XpsFwhmConstraint::Shared { + reference: invocation.peaks[1].id, + }; + assert!(matches!( + fit_xps_peaks(&x, &y, &invocation, &|| false), + Err(XpsFitError::InvalidConstraints(_)) + )); +} + +#[test] +fn missing_self_and_incompatible_bounds_are_rejected() { + let x = axis(); + let mut spec = invocation(&x); + spec.peaks[1].center = XpsCenterConstraint::Offset { + reference: XpsComponentId::new(99), + delta_ev: 1.0, + }; + assert!(matches!( + validate_xps_constraints(&spec), + Err(XpsFitError::InvalidConstraints(_)) + )); + spec = invocation(&x); + spec.peaks[0].fwhm = XpsFwhmConstraint::Shared { + reference: spec.peaks[0].id, + }; + assert!(matches!( + validate_xps_constraints(&spec), + Err(XpsFitError::InvalidConstraints(_)) + )); + spec = invocation(&x); + spec.peaks[0].center = XpsCenterConstraint::Free { + initial_ev: 285.0, + bounds_ev: [286.0, 284.0], + }; + assert!(matches!( + validate_xps_constraints(&spec), + Err(XpsFitError::InvalidConstraints(_)) + )); +} + +#[test] +fn fixed_only_fit_degrades_without_covariance() { + let x = axis(); + let y = x + .iter() + .map(|value| 5.0 + gl_peak(*value, 285.0, 1.2, 100.0, 0.3)) + .collect::>(); + let mut spec = invocation(&x); + spec.peaks.truncate(1); + spec.peaks[0].center = XpsCenterConstraint::Fixed { value_ev: 285.0 }; + spec.peaks[0].fwhm = XpsFwhmConstraint::Fixed { value_ev: 1.2 }; + spec.peaks[0].area = XpsAreaConstraint::Fixed { value: 100.0 }; + let result = fit_xps_peaks(&x, &y, &spec, &|| false).unwrap(); + assert!(result.parameter_correlation.is_none()); + assert!(result.peaks[0].center_ev.standard_error.is_none()); +} + +#[test] +fn bootstrap_is_deterministic_and_cancellable() { + let x = axis(); + let invocation = invocation(&x); + let y = x + .iter() + .map(|value| { + 5.0 + gl_peak(*value, 285.0, 1.3, 120.0, 0.3) + gl_peak(*value, 288.0, 1.3, 60.0, 0.3) + }) + .collect::>(); + let fit = fit_xps_peaks(&x, &y, &invocation, &|| false).unwrap(); + let options = XpsBootstrapOptions { + samples: 100, + seed: 42, + }; + let first = bootstrap_xps_fit(&fit, &invocation, &options, &|| false).unwrap(); + let second = bootstrap_xps_fit(&fit, &invocation, &options, &|| false).unwrap(); + assert_eq!(first, second); + assert_eq!( + bootstrap_xps_fit(&fit, &invocation, &options, &|| true), + Err(XpsFitError::Cancelled) + ); +} diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 952fce4..9fbda2f 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -152,7 +152,7 @@ impl eframe::App for Shell { { *PENDING_INSTALL.lock().unwrap() = Some(plan.clone()); } - let fitting = self.app.poll_line_fit(); + let fitting = self.app.poll_line_fit() | self.app.poll_xps_fit(); let symmetry = self.app.poll_symmetry_audit(); let transforming = self.app.poll_table_transform(); ui::render( diff --git a/crates/app/src/shot.rs b/crates/app/src/shot.rs index 3ce0d97..a5a5214 100644 --- a/crates/app/src/shot.rs +++ b/crates/app/src/shot.rs @@ -29,7 +29,10 @@ use plotx_core::settings::Settings; use plotx_core::state::{ AnalysisSelection, AxisRange, DEFAULT_CANVAS_SIZE_MM, Dataset, FrameRef, LineShapeKind, Nmr2DDataset, NmrDataset, Peak2DOrigin, Peak2DPoint, Peak2DReview, PlotxApp, Region, RegionId, - Tool, region_color, + Tool, XpsDataset, region_color, +}; +use plotx_io::xps::{ + XpsEnergyKind, XpsExperiment, XpsMeasurement, XpsMeasurementId, XpsRegion, XpsRegionId, }; use plotx_io::{AxisSource, Dim, Domain, NmrData, NmrData2D, PseudoAxis, PseudoKind, QuadMode}; @@ -88,6 +91,8 @@ enum Op { RegionResult, /// Open the result's synchronized read-only values. RegionData, + XpsSetup, + XpsTab(plotx_core::state::XpsWorkbenchTab), Zoom(f32), Resize(f32, f32), } @@ -153,6 +158,26 @@ const SCENES: &[Scene] = &[ shot(6, "cursor_delta"), act(18, Op::PinSymmetry), shot(8, "symmetry_review"), + act(2, Op::Resize(1440.0, 900.0)), + act(2, Op::XpsSetup), + act( + 2, + Op::XpsTab(plotx_core::state::XpsWorkbenchTab::Background), + ), + shot(8, "xps_background"), + act( + 2, + Op::XpsTab(plotx_core::state::XpsWorkbenchTab::Components), + ), + shot(8, "xps_components"), + act(2, Op::Resize(720.0, 700.0)), + shot(10, "xps_components_narrow"), + act(2, Op::Resize(1440.0, 900.0)), + act( + 2, + Op::XpsTab(plotx_core::state::XpsWorkbenchTab::Diagnostics), + ), + shot(8, "xps_diagnostics"), ]; pub struct ShotDriver { @@ -333,6 +358,8 @@ fn run_op(op: Op, app: &mut PlotxApp, ctx: &egui::Context) -> Result<(), String> app.session.ui.sheet_open = Some(1); app.session.ui.curve_fit_task_collapsed = true; } + Op::XpsSetup => xps_setup(app, ctx)?, + Op::XpsTab(tab) => app.session.ui.xps_workbench_tab = tab, Op::Zoom(factor) => ctx.set_zoom_factor(factor), Op::Resize(w, h) => { ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(w, h))); @@ -341,6 +368,85 @@ fn run_op(op: Op, app: &mut PlotxApp, ctx: &egui::Context) -> Result<(), String> Ok(()) } +fn xps_setup(app: &mut PlotxApp, ctx: &egui::Context) -> Result<(), String> { + *app = PlotxApp::new_with_settings(Settings::default()); + let energy = (0..241) + .map(|index| 292.0 - index as f64 * 0.05) + .collect::>(); + let intensity = energy + .iter() + .map(|value| { + 150.0 + + (292.0 - value) * 2.0 + + plotx_analysis::xps::gl_peak(*value, 284.8, 1.15, 4_500.0, 0.3) + + plotx_analysis::xps::gl_peak(*value, 286.3, 1.15, 1_900.0, 0.3) + }) + .collect::>(); + let measurement = XpsMeasurementId(1); + let region = XpsRegionId(1); + let experiment = XpsExperiment { + source: "synthetic-xps.vms".into(), + measurements: vec![XpsMeasurement { + id: measurement, + label: "Location 1".into(), + position_mm: Some([0.0, 0.0, 0.0]), + metadata: Default::default(), + }], + regions: vec![XpsRegion { + id: region, + measurement, + name: "C 1s".into(), + native_energy_kind: XpsEnergyKind::Binding, + native_energy_ev: energy.clone(), + binding_energy_ev: Some(energy.clone()), + intensity_cps: intensity, + counts: None, + photon_energy_ev: Some(1486.69), + dwell_time_s: Some(0.1), + sweeps: Some(5), + imported_fit: None, + metadata: Default::default(), + }], + metadata: Default::default(), + import_warnings: Vec::new(), + }; + let mut xps = XpsDataset::load(experiment); + let workspace = xps.fit_workspaces.get_mut(®ion).unwrap(); + let first = plotx_analysis::xps::XpsComponentId::new(1); + workspace.invocation.peaks = vec![ + plotx_analysis::xps::XpsPeakSpec::independent(first, "Aromatic C", 284.8, 4_000.0), + plotx_analysis::xps::XpsPeakSpec { + id: plotx_analysis::xps::XpsComponentId::new(2), + label: "C=N / C-O".into(), + center: plotx_analysis::xps::XpsCenterConstraint::Free { + initial_ev: 286.3, + bounds_ev: [285.8, 286.8], + }, + fwhm: plotx_analysis::xps::XpsFwhmConstraint::Shared { reference: first }, + area: plotx_analysis::xps::XpsAreaConstraint::Free { + initial: 1_800.0, + bounds: [0.0, 36_000.0], + }, + }, + ]; + workspace.next_component_id = 3; + let dataset = xps.resource_id; + let action = Action::insert_dataset_with_default_canvas( + app, + Dataset::Xps(Box::new(xps)), + "Canvas 1 — synthetic XPS".into(), + DEFAULT_CANVAS_SIZE_MM, + ); + app.execute_action(action); + app.run_xps_fit(dataset, region)?; + app.session.ui.requested_tool_group = Some(plotx_core::state::ToolGroup::Xps); + app.session.secondary_sidebar_visible = true; + app.session.secondary_sidebar_width = 390.0; + app.session.active_canvas = Some(0); + crate::ui::canvas::request_board_fit(app, ctx, FrameRef::Page(0)); + Ok(()) +} + fn request_exit(app: &mut PlotxApp, ctx: &egui::Context) { // Synthetic sessions are deliberately dirty. They never represent user work, // so the harness must bypass the production Save / Discard / Cancel prompt. @@ -670,9 +776,9 @@ mod tests { #[test] fn expected_count_covers_every_scene_in_both_palettes() { let per_pass = SCENES.iter().filter(|s| s.shot.is_some()).count(); - assert_eq!(per_pass, 10, "scene list should define 10 captures"); + assert_eq!(per_pass, 14, "scene list should define 14 captures"); // Default run (no PLOTX_SHOT_THEME) replays every scene in both palettes. - assert_eq!(per_pass * 2, 20); + assert_eq!(per_pass * 2, 28); } #[test] diff --git a/crates/app/src/ui/commands.rs b/crates/app/src/ui/commands.rs index 3f1644a..3ed17c9 100644 --- a/crates/app/src/ui/commands.rs +++ b/crates/app/src/ui/commands.rs @@ -13,6 +13,8 @@ pub use super::command_exec::execute; mod identity; use identity::command_identity; pub(crate) use identity::recent_entry_label; +mod ribbon; +use ribbon::ribbon_placement; /// The published user manual; opened by `HelpManual` and linked from About. pub(crate) const MANUAL_URL: &str = "https://docs.plotx.nmrtist.space/"; @@ -370,7 +372,8 @@ pub fn describe(app: &PlotxApp, id: CommandId) -> CommandDescriptor { .is_some_and(|nmr| nmr.output_domain() == plotx_io::Domain::Time) }) }; - let has_frequency_analysis_trace = || has_trace() && !is_time_nmr(); + let has_selectable_analysis_trace = || has_trace() && !is_time_nmr(); + let has_generic_peak_fit_trace = || has_trace() && (is_frequency_nmr() || is_table()); let range = || active_dataset.and_then(|di| app.analysis_range_for(di)); let is_series = || dataset().is_some_and(Dataset::supports_region_analysis); @@ -477,7 +480,7 @@ pub fn describe(app: &PlotxApp, id: CommandId) -> CommandDescriptor { "Select an LC–MS dataset before extracting a mass spectrum.", ), CommandId::SelectRange => requires( - has_frequency_analysis_trace() + has_selectable_analysis_trace() || dataset().is_some_and(|dataset| { dataset.tool_groups().contains(&ToolGroup::MassSpectrometry) }), @@ -512,15 +515,15 @@ pub fn describe(app: &PlotxApp, id: CommandId) -> CommandDescriptor { "Select a plotted 1D spectrum or table column before detecting peaks.", ), CommandId::PeakList => requires( - has_frequency_analysis_trace(), + has_generic_peak_fit_trace(), "Plot frequency-domain or tabular 1D data before opening the peak list.", ), CommandId::LineFit => requires( - has_frequency_analysis_trace(), + has_generic_peak_fit_trace(), "Plot frequency-domain or tabular 1D data before fitting peaks.", ), CommandId::RunPeakFit => requires( - has_frequency_analysis_trace(), + has_generic_peak_fit_trace(), "Plot frequency-domain or tabular 1D data before running Peak Fit.", ) .and_then(|()| { @@ -686,88 +689,6 @@ pub fn describe(app: &PlotxApp, id: CommandId) -> CommandDescriptor { } } -fn ribbon_placement(id: CommandId) -> Option { - use Applicability::{Always, Homonuclear2dOnly, SeriesOnly, TableOnly}; - use WorkflowTab::{Analyze, Arrange, Data, Figure, Process, View}; - let (tab, group, priority, applicability) = match id { - CommandId::Tool(Tool::BrowseZoom) | CommandId::ZoomToFit | CommandId::ZoomToSelection => { - (View, "Navigate", 0, Always) - } - CommandId::TogglePrimarySidebar - | CommandId::ToggleSecondarySidebar - | CommandId::ToggleGrid - | CommandId::Present - | CommandId::Preferences => (View, "Display", 1, Always), - CommandId::OpenFile - | CommandId::ImportTable - | CommandId::OpenFolder - | CommandId::PasteTable => (Data, "Import", 0, Always), - CommandId::NewTable | CommandId::StackData => (Data, "Build", 1, Always), - CommandId::ExportData => (Data, "Export", 0, Always), - CommandId::Tool(Tool::Peaks) | CommandId::DetectPeaks | CommandId::PeakList => { - (Analyze, "Peaks", 1, Always) - } - CommandId::Tool(Tool::Symmetry) => (Analyze, "Review", 1, Homonuclear2dOnly), - CommandId::Tool(Tool::ManualPhase) => (Process, "Correct", 0, Always), - CommandId::SpectrumArithmetic | CommandId::AlignSpectra => { - (Process, "Transform", 1, Always) - } - CommandId::ApplyProcessingTemplate | CommandId::SaveProcessingTemplate => { - (Process, "Recipes", 2, Always) - } - CommandId::SelectRange | CommandId::ClearRange => (Analyze, "Range", 0, Always), - CommandId::ExtractMassSpectrum => ( - Analyze, - "Extract", - 0, - Applicability::ToolGroup(ToolGroup::MassSpectrometry), - ), - CommandId::Regions => (Analyze, "Regions", 0, SeriesOnly), - CommandId::SeriesTable => (Analyze, "Regions", 0, SeriesOnly), - CommandId::LineFit | CommandId::RunPeakFit => (Analyze, "Peak Fit", 0, Always), - CommandId::CurveFit | CommandId::RunCurveFit => (Analyze, "Curve Fit", 0, TableOnly), - CommandId::Statistics => (Analyze, "Statistics", 0, TableOnly), - CommandId::Integrate | CommandId::Multiplets => (Analyze, "Interpret", 1, Always), - CommandId::NewCanvas(_) => (Figure, "Create", 0, Always), - CommandId::ChartType => (Figure, "Chart", 0, TableOnly), - CommandId::ApplyTheme(_) | CommandId::FigureTypography | CommandId::CanvasSettings => { - (Figure, "Style", 1, Always) - } - // PNG and SVG cover the two figure endpoints (slides and publication); - // the other formats stay in the File menu and the palette. - CommandId::CopyFigure - | CommandId::Export(ExportFormat::Png) - | CommandId::Export(ExportFormat::Svg) => (Figure, "Output", 0, Always), - CommandId::Tool(Tool::Select) - | CommandId::ArrangeGrid(1, 2) - | CommandId::ArrangeGrid(2, 2) - | CommandId::SimplifyInnerAxes - | CommandId::SetSpacingMode(_) - | CommandId::SetGutterPreset(_) - | CommandId::TidyBoard => (Arrange, "Layout", 0, Always), - CommandId::Align(_) => (Arrange, "Align", 1, Always), - CommandId::Distribute(_) => (Arrange, "Distribute", 2, Always), - CommandId::ZOrder(_) => (Arrange, "Order", 2, Always), - CommandId::ToggleSnap => (Arrange, "Guides", 1, Always), - CommandId::Tool( - Tool::Text | Tool::PanelLabel | Tool::Rect | Tool::Ellipse | Tool::Line | Tool::Arrow, - ) => (Arrange, "Annotate", 3, Always), - // The group's own declaration decides where it lands, so a new group - // needs no arm here. - CommandId::PropertyGroup(section) => { - let spot = super::properties::discovery::group(section)?.ribbon; - (spot.tab, spot.group, spot.priority, Always) - } - _ => return None, - }; - Some(RibbonPlacement { - tab, - group, - priority, - applicability, - }) -} - fn tool_commands() -> [Tool; 17] { [ Tool::Select, @@ -797,3 +718,7 @@ mod tests; #[cfg(test)] #[path = "commands_mass_spec_tests.rs"] mod mass_spec_tests; + +#[cfg(test)] +#[path = "commands_xps_tests.rs"] +mod xps_tests; diff --git a/crates/app/src/ui/commands/ribbon.rs b/crates/app/src/ui/commands/ribbon.rs new file mode 100644 index 0000000..1f39f8f --- /dev/null +++ b/crates/app/src/ui/commands/ribbon.rs @@ -0,0 +1,99 @@ +use plotx_core::export::ExportFormat; +use plotx_core::state::{Tool, ToolGroup, WorkflowTab}; + +use super::{Applicability, CommandId, RibbonPlacement}; + +pub(super) fn ribbon_placement(id: CommandId) -> Option { + use Applicability::{Always, Homonuclear2dOnly, SeriesOnly, TableOnly}; + use WorkflowTab::{Analyze, Arrange, Data, Figure, Process, View}; + let (tab, group, priority, applicability) = match id { + CommandId::Tool(Tool::BrowseZoom) | CommandId::ZoomToFit | CommandId::ZoomToSelection => { + (View, "Navigate", 0, Always) + } + CommandId::TogglePrimarySidebar + | CommandId::ToggleSecondarySidebar + | CommandId::ToggleGrid + | CommandId::Present + | CommandId::Preferences => (View, "Display", 1, Always), + CommandId::OpenFile + | CommandId::ImportTable + | CommandId::OpenFolder + | CommandId::PasteTable => (Data, "Import", 0, Always), + CommandId::NewTable | CommandId::StackData => (Data, "Build", 1, Always), + CommandId::ExportData => (Data, "Export", 0, Always), + CommandId::Tool(Tool::Peaks) | CommandId::DetectPeaks | CommandId::PeakList => ( + Analyze, + "Peaks", + 1, + Applicability::ToolGroup(ToolGroup::Peaks), + ), + CommandId::Tool(Tool::Symmetry) => (Analyze, "Review", 1, Homonuclear2dOnly), + CommandId::Tool(Tool::ManualPhase) => (Process, "Correct", 0, Always), + CommandId::SpectrumArithmetic | CommandId::AlignSpectra => { + (Process, "Transform", 1, Always) + } + CommandId::ApplyProcessingTemplate | CommandId::SaveProcessingTemplate => { + (Process, "Recipes", 2, Always) + } + CommandId::SelectRange | CommandId::ClearRange => (Analyze, "Range", 0, Always), + CommandId::ExtractMassSpectrum => ( + Analyze, + "Extract", + 0, + Applicability::ToolGroup(ToolGroup::MassSpectrometry), + ), + CommandId::Regions => (Analyze, "Regions", 0, SeriesOnly), + CommandId::SeriesTable => (Analyze, "Regions", 0, SeriesOnly), + CommandId::LineFit | CommandId::RunPeakFit => ( + Analyze, + "Peak Fit", + 0, + Applicability::ToolGroup(ToolGroup::LineFit), + ), + CommandId::CurveFit | CommandId::RunCurveFit => (Analyze, "Curve Fit", 0, TableOnly), + CommandId::Statistics => (Analyze, "Statistics", 0, TableOnly), + CommandId::Integrate | CommandId::Multiplets => ( + Analyze, + "Interpret", + 1, + Applicability::ToolGroup(ToolGroup::Nmr1dAnalysis), + ), + CommandId::NewCanvas(_) => (Figure, "Create", 0, Always), + CommandId::ChartType => (Figure, "Chart", 0, TableOnly), + CommandId::ApplyTheme(_) | CommandId::FigureTypography | CommandId::CanvasSettings => { + (Figure, "Style", 1, Always) + } + // PNG and SVG cover the two figure endpoints (slides and publication); + // the other formats stay in the File menu and the palette. + CommandId::CopyFigure + | CommandId::Export(ExportFormat::Png) + | CommandId::Export(ExportFormat::Svg) => (Figure, "Output", 0, Always), + CommandId::Tool(Tool::Select) + | CommandId::ArrangeGrid(1, 2) + | CommandId::ArrangeGrid(2, 2) + | CommandId::SimplifyInnerAxes + | CommandId::SetSpacingMode(_) + | CommandId::SetGutterPreset(_) + | CommandId::TidyBoard => (Arrange, "Layout", 0, Always), + CommandId::Align(_) => (Arrange, "Align", 1, Always), + CommandId::Distribute(_) => (Arrange, "Distribute", 2, Always), + CommandId::ZOrder(_) => (Arrange, "Order", 2, Always), + CommandId::ToggleSnap => (Arrange, "Guides", 1, Always), + CommandId::Tool( + Tool::Text | Tool::PanelLabel | Tool::Rect | Tool::Ellipse | Tool::Line | Tool::Arrow, + ) => (Arrange, "Annotate", 3, Always), + // The group's own declaration decides where it lands, so a new group + // needs no arm here. + CommandId::PropertyGroup(section) => { + let spot = crate::ui::properties::discovery::group(section)?.ribbon; + (spot.tab, spot.group, spot.priority, Always) + } + _ => return None, + }; + Some(RibbonPlacement { + tab, + group, + priority, + applicability, + }) +} diff --git a/crates/app/src/ui/commands_tests.rs b/crates/app/src/ui/commands_tests.rs index e3a0bee..3b4b3fc 100644 --- a/crates/app/src/ui/commands_tests.rs +++ b/crates/app/src/ui/commands_tests.rs @@ -647,7 +647,7 @@ fn ribbon_separates_peak_and_curve_fit_tasks() { tab: WorkflowTab::Analyze, group: "Peak Fit", priority: 0, - applicability: Applicability::Always, + applicability: Applicability::ToolGroup(ToolGroup::LineFit), }) ); assert_eq!( diff --git a/crates/app/src/ui/commands_xps_tests.rs b/crates/app/src/ui/commands_xps_tests.rs new file mode 100644 index 0000000..a72771a --- /dev/null +++ b/crates/app/src/ui/commands_xps_tests.rs @@ -0,0 +1,64 @@ +use super::*; +use plotx_core::actions::Action; +use plotx_core::state::{DEFAULT_CANVAS_SIZE_MM, XpsDataset}; +use plotx_io::xps::{ + XpsEnergyKind, XpsExperiment, XpsMeasurement, XpsMeasurementId, XpsRegion, XpsRegionId, +}; + +fn app_with_xps() -> PlotxApp { + let mut app = PlotxApp::new_with_settings(plotx_core::settings::Settings::default()); + let measurement = XpsMeasurementId(1); + let experiment = XpsExperiment { + source: "commands.vms".to_owned(), + measurements: vec![XpsMeasurement { + id: measurement, + label: "Location 1".to_owned(), + position_mm: None, + metadata: Default::default(), + }], + regions: vec![XpsRegion { + id: XpsRegionId(1), + measurement, + name: "C 1s".to_owned(), + native_energy_kind: XpsEnergyKind::Binding, + native_energy_ev: vec![286.0, 285.0, 284.0], + binding_energy_ev: Some(vec![286.0, 285.0, 284.0]), + intensity_cps: vec![1.0, 3.0, 1.0], + counts: None, + photon_energy_ev: Some(1486.69), + dwell_time_s: None, + sweeps: None, + imported_fit: None, + metadata: Default::default(), + }], + metadata: Default::default(), + import_warnings: Vec::new(), + }; + let action = Action::insert_dataset_with_default_canvas( + &app, + Dataset::Xps(Box::new(XpsDataset::load(experiment))), + "Canvas — XPS".to_owned(), + DEFAULT_CANVAS_SIZE_MM, + ); + app.execute_action(action); + app +} + +#[test] +fn xps_uses_its_workbench_instead_of_generic_peak_commands() { + let app = app_with_xps(); + + for command in [ + CommandId::PeakList, + CommandId::LineFit, + CommandId::RunPeakFit, + CommandId::Integrate, + CommandId::Multiplets, + ] { + let descriptor = describe(&app, command); + assert!(!descriptor.enabled); + assert_eq!(descriptor.ribbon, None); + } + + assert!(describe(&app, CommandId::SelectRange).enabled); +} diff --git a/crates/app/src/ui/file_dialogs.rs b/crates/app/src/ui/file_dialogs.rs index 0581dc1..1448096 100644 --- a/crates/app/src/ui/file_dialogs.rs +++ b/crates/app/src/ui/file_dialogs.rs @@ -344,7 +344,7 @@ pub(crate) fn load_and_note(app: &mut PlotxApp, path: &std::path::Path) { pub(crate) fn open_file(app: &mut PlotxApp) { if let Some(paths) = rfd::FileDialog::new() .add_filter( - "All supported data (*.mzML, *.rasx, *.raw, *.spm, *.pfc, *.abf, *.jdf, fid, ser, *.zip, *.opj)", + "All supported data (*.mzML, *.rasx, *.raw, *.vms, *.txt, *.spm, *.pfc, *.abf, *.jdf, fid, ser, *.zip, *.opj)", origin::OPEN_FILE_FILTER_EXTENSIONS, ) .add_filter("Rigaku XRD (*.rasx, *.raw, *.txt)", &["rasx", "raw", "txt"]) @@ -356,6 +356,7 @@ pub(crate) fn open_file(app: &mut PlotxApp) { .add_filter("Axon Binary Format 2 (*.abf)", &["abf"]) .add_filter("JEOL Delta (*.jdf)", &["jdf"]) .add_filter("mzML mass spectrometry (*.mzML)", &["mzML"]) + .add_filter("XPS (*.vms, CasaXPS *.txt)", &["vms", "txt"]) .add_filter("Bruker TopSpin (fid, ser)", &["fid", "ser"]) .add_filter("Archive (*.zip)", &["zip"]) .add_filter("All files", &["*"]) diff --git a/crates/app/src/ui/file_dialogs/discovery.rs b/crates/app/src/ui/file_dialogs/discovery.rs index f90f5bf..a8bf720 100644 --- a/crates/app/src/ui/file_dialogs/discovery.rs +++ b/crates/app/src/ui/file_dialogs/discovery.rs @@ -22,12 +22,14 @@ pub(super) fn collect_data_files(folder: &Path, output: &mut Vec) { .extension() .and_then(|value| value.to_str()) .unwrap_or(""); - let supported_extension = ["abf", "spm", "pfc", "rasx"] + let supported_extension = ["abf", "spm", "pfc", "rasx", "vms"] .iter() .any(|supported| extension.eq_ignore_ascii_case(supported)); let recognized_raw = extension.eq_ignore_ascii_case("raw") && plotx_io::xrd::is_rigaku_raw(&path); - if supported_extension || recognized_raw { + let recognized_casaxps = + extension.eq_ignore_ascii_case("txt") && plotx_io::xps::is_casaxps_text(&path); + if supported_extension || recognized_raw || recognized_casaxps { output.push(path); } } diff --git a/crates/app/src/ui/file_dialogs/origin.rs b/crates/app/src/ui/file_dialogs/origin.rs index 0f936c0..b0182f2 100644 --- a/crates/app/src/ui/file_dialogs/origin.rs +++ b/crates/app/src/ui/file_dialogs/origin.rs @@ -21,7 +21,7 @@ pub(super) const ORIGIN_PROJECT_FILTER_LABEL: &str = "Origin projects (experimental: OPJ import; OPJU recognition only)"; pub(super) const ORIGIN_PROJECT_FILTER_EXTENSIONS: &[&str] = &["opj", "opju"]; pub(super) const OPEN_FILE_FILTER_EXTENSIONS: &[&str] = &[ - "mzML", "rasx", "raw", "spm", "pfc", "abf", "jdf", "fid", "ser", "zip", "opj", + "mzML", "rasx", "raw", "vms", "txt", "spm", "pfc", "abf", "jdf", "fid", "ser", "zip", "opj", ]; const ORIGIN_MEDIA_TYPE: &str = "application/x-origin-project"; diff --git a/crates/app/src/ui/file_dialogs/recent.rs b/crates/app/src/ui/file_dialogs/recent.rs index e15182f..217e183 100644 --- a/crates/app/src/ui/file_dialogs/recent.rs +++ b/crates/app/src/ui/file_dialogs/recent.rs @@ -9,7 +9,7 @@ use std::fs::{File, OpenOptions}; use std::io::{self, Read}; use std::path::Path; -pub(super) const OPEN_HEADER_BYTES: usize = plotx_io::origin::MAX_PROBE_BYTES; +pub(super) const OPEN_HEADER_BYTES: usize = 16 * 1024; type OpenHeader = ([u8; OPEN_HEADER_BYTES], usize); #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -225,6 +225,11 @@ fn classify_open_header(path: &Path, header: &[u8]) -> Result line_fit_group(app, di, ui), ToolGroup::Statistics => statistics::statistics_group(app, di, ui), ToolGroup::Electrophysiology => electrophysiology::electrophysiology_group(app, di, ui), + ToolGroup::Xps => xps::xps_group(app, di, ui), } } diff --git a/crates/app/src/ui/tools/processing/mod.rs b/crates/app/src/ui/tools/processing/mod.rs index e585588..f47d16a 100644 --- a/crates/app/src/ui/tools/processing/mod.rs +++ b/crates/app/src/ui/tools/processing/mod.rs @@ -179,6 +179,7 @@ fn add_step_menu(app: &mut PlotxApp, di: usize, axis: PhaseAxis, ui: &mut Ui) { | Dataset::Afm(_) | Dataset::MassSpec(_) | Dataset::Xrd(_) => return, + Dataset::Xps(_) => return, }; let Some(pipeline) = dataset.axis_pipeline(axis) else { return; @@ -329,6 +330,7 @@ fn apply_row_op(app: &mut PlotxApp, di: usize, axis: PhaseAxis, id: StepId, op: | Dataset::Afm(_) | Dataset::MassSpec(_) | Dataset::Xrd(_) => return, + Dataset::Xps(_) => return, }; let before = DatasetProcessingState::from_dataset(dataset); let mut after = before.clone(); diff --git a/crates/app/src/ui/tools/processing/surface.rs b/crates/app/src/ui/tools/processing/surface.rs index de00cf4..69913d3 100644 --- a/crates/app/src/ui/tools/processing/surface.rs +++ b/crates/app/src/ui/tools/processing/surface.rs @@ -63,6 +63,7 @@ fn surface_shape(dataset: &Dataset) -> Option { | Dataset::Afm(_) | Dataset::MassSpec(_) | Dataset::Xrd(_) => return None, + Dataset::Xps(_) => return None, }; let axes = dataset .phase_axes() diff --git a/crates/app/src/ui/tools/xps.rs b/crates/app/src/ui/tools/xps.rs new file mode 100644 index 0000000..d1dcd4d --- /dev/null +++ b/crates/app/src/ui/tools/xps.rs @@ -0,0 +1,404 @@ +mod background; +mod components; +mod diagnostics; + +use egui::{Button, ComboBox, DragValue, Ui}; +use egui_phosphor::regular as icon; +use plotx_core::state::{ + DatasetId, PlotxApp, XpsFitWorkspace, XpsWorkbenchTab, estimate_xps_charge_shift, +}; +use plotx_processing::xps::XpsStepKind; +use plotx_processing::{NormalizeMethod, SmoothMethod}; + +#[derive(Clone, Default)] +struct ShiftEdit { + value: f64, + changed: bool, +} + +#[derive(Clone)] +struct WindowDraft { + low_ev: f64, + high_ev: f64, +} + +pub(super) fn xps_group(app: &mut PlotxApp, dataset_index: usize, ui: &mut Ui) -> bool { + let Some(xps) = app + .doc + .datasets + .get(dataset_index) + .and_then(|dataset| dataset.as_xps()) + else { + return false; + }; + let dataset_id = xps.resource_id; + let active = xps.active_region().clone(); + let measurements = xps.experiment.measurements.clone(); + let regions = xps.experiment.regions.clone(); + let energy_shift_ev = xps.energy_shift(active.measurement).unwrap_or_default(); + let recipe = xps.recipe(active.id).cloned().unwrap_or_default(); + let processed = xps.displayed_region(active.id); + let workspace = xps.fit_workspaces.get(&active.id).cloned(); + let current_fit = xps.current_fit(active.id).cloned(); + let latest_fit = xps.latest_fit(active.id).cloned(); + + let mut tab = app.session.ui.xps_workbench_tab; + ui.horizontal_wrapped(|ui| { + ui.selectable_value(&mut tab, XpsWorkbenchTab::Acquisition, "Acquisition"); + ui.selectable_value(&mut tab, XpsWorkbenchTab::Background, "Background"); + ui.selectable_value(&mut tab, XpsWorkbenchTab::Components, "Components"); + ui.selectable_value(&mut tab, XpsWorkbenchTab::Diagnostics, "Diagnostics"); + }); + app.session.ui.xps_workbench_tab = tab; + ui.separator(); + + match tab { + XpsWorkbenchTab::Acquisition => acquisition_tab( + app, + dataset_index, + dataset_id, + &active, + &measurements, + ®ions, + energy_shift_ev, + &recipe, + processed.as_ref(), + ui, + ), + XpsWorkbenchTab::Background => { + if let Some(workspace) = workspace { + background::background_tab( + app, + dataset_index, + dataset_id, + &active, + processed.as_ref(), + workspace, + ui, + ); + } else { + ui.weak("A binding-energy axis is required for background analysis."); + } + } + XpsWorkbenchTab::Components => { + if let Some(workspace) = workspace { + components::components_tab( + app, + dataset_index, + dataset_id, + &active, + processed.as_ref(), + workspace, + ui, + ); + } else { + ui.weak("A binding-energy axis is required for peak fitting."); + } + } + XpsWorkbenchTab::Diagnostics => diagnostics::diagnostics_tab( + app, + dataset_id, + active.id, + current_fit.as_ref(), + latest_fit.as_ref(), + ui, + ), + } + false +} + +#[allow(clippy::too_many_arguments)] +fn acquisition_tab( + app: &mut PlotxApp, + dataset_index: usize, + dataset_id: DatasetId, + active: &plotx_io::xps::XpsRegion, + measurements: &[plotx_io::xps::XpsMeasurement], + regions: &[plotx_io::xps::XpsRegion], + energy_shift_ev: f64, + recipe: &plotx_processing::xps::XpsProcessingRecipe, + processed: Option<&plotx_processing::xps::ProcessedXpsRegion>, + ui: &mut Ui, +) { + ui.label(crate::typography::headline("Acquisition")); + let measurement_label = measurements + .iter() + .find(|measurement| measurement.id == active.measurement) + .map_or("Unknown position", |measurement| measurement.label.as_str()); + let mut selected_measurement = None; + ComboBox::from_label("Measurement position") + .selected_text(measurement_label) + .show_ui(ui, |ui| { + for measurement in measurements { + if ui + .selectable_label(measurement.id == active.measurement, &measurement.label) + .clicked() + { + selected_measurement = Some(measurement.id); + ui.close(); + } + } + }); + let mut selected_region = None; + ComboBox::from_label("Spectrum region") + .selected_text(&active.name) + .show_ui(ui, |ui| { + for region in regions + .iter() + .filter(|region| region.measurement == active.measurement) + { + if ui + .selectable_label(region.id == active.id, ®ion.name) + .clicked() + { + selected_region = Some(region.id); + ui.close(); + } + } + }); + ui.weak(format!("{} points | CPS", active.intensity_cps.len())); + + ui.separator(); + ui.label(crate::typography::headline("Charge correction")); + charge_controls(app, dataset_id, active, energy_shift_ev, ui); + + ui.separator(); + ui.label(crate::typography::headline("Processing recipe")); + processing_controls(app, dataset_id, active, recipe, processed, ui); + + if let Some(measurement) = selected_measurement { + let next = regions + .iter() + .filter(|region| region.measurement == measurement) + .find(|region| region.name.eq_ignore_ascii_case("survey")) + .or_else(|| { + regions + .iter() + .find(|region| region.measurement == measurement) + }); + if let Some(region) = next { + report(app.select_xps_region(dataset_id, region.id), app); + } + } else if let Some(region) = selected_region { + report(app.select_xps_region(dataset_id, region), app); + } + let _ = dataset_index; +} + +fn charge_controls( + app: &mut PlotxApp, + dataset: DatasetId, + active: &plotx_io::xps::XpsRegion, + energy_shift_ev: f64, + ui: &mut Ui, +) { + let shift_key = ui.make_persistent_id(("xps_shift", dataset, active.measurement)); + let mut shift = ui + .data_mut(|data| data.get_temp::(shift_key)) + .unwrap_or(ShiftEdit { + value: energy_shift_ev, + changed: false, + }); + let response = ui.add_enabled( + active.binding_energy_ev.is_some(), + DragValue::new(&mut shift.value) + .prefix("Shift ") + .suffix(" eV") + .speed(0.01), + ); + shift.changed |= response.changed(); + if (response.drag_stopped() || response.lost_focus()) && shift.changed { + report( + app.set_xps_energy_shift(dataset, active.measurement, shift.value), + app, + ); + ui.data_mut(|data| data.remove_temp::(shift_key)); + } else { + ui.data_mut(|data| data.insert_temp(shift_key, shift)); + } + + let reference_key = ui.make_persistent_id(("xps_reference", dataset)); + let mut reference = ui + .data_mut(|data| data.get_temp::(reference_key)) + .unwrap_or(284.8); + ui.horizontal(|ui| { + ui.label("C 1s reference"); + ui.add(DragValue::new(&mut reference).suffix(" eV").speed(0.01)); + }); + ui.data_mut(|data| data.insert_temp(reference_key, reference)); + let is_c1s = active + .name + .to_ascii_lowercase() + .replace(' ', "") + .contains("c1s"); + if ui + .add_enabled( + is_c1s && active.binding_energy_ev.is_some(), + Button::new(format!("{} Reference current C 1s", icon::CROSSHAIR)), + ) + .clicked() + { + let result = estimate_xps_charge_shift( + active.binding_energy_ev.as_deref().unwrap_or_default(), + &active.intensity_cps, + reference, + ) + .and_then(|shift| { + app.set_xps_energy_shift(dataset, active.measurement, shift) + .map(|_| shift) + }); + match result { + Ok(shift) => { + ui.data_mut(|data| data.remove_temp::(shift_key)); + app.session.status = format!("Applied {shift:+.2} eV to this position."); + } + Err(error) => app.session.status = error, + } + } + ui.weak("The shift applies to every region at this measurement position."); +} + +fn processing_controls( + app: &mut PlotxApp, + dataset: DatasetId, + active: &plotx_io::xps::XpsRegion, + recipe: &plotx_processing::xps::XpsProcessingRecipe, + processed: Option<&plotx_processing::xps::ProcessedXpsRegion>, + ui: &mut Ui, +) { + for (index, step) in recipe.steps.iter().enumerate() { + let label = match step.kind { + XpsStepKind::Window { low_ev, high_ev } => { + format!("Window {low_ev:.2}-{high_ev:.2} eV") + } + XpsStepKind::Smooth(_) => "Savitzky-Golay smoothing".into(), + XpsStepKind::Normalize(_) => "Normalize intensity".into(), + }; + ui.horizontal(|ui| { + let mut enabled = step.enabled; + if ui.checkbox(&mut enabled, "").changed() { + report( + app.set_xps_processing_step_enabled(dataset, active.id, step.id, enabled), + app, + ); + } + ui.label(label); + if ui.small_button(icon::ARROW_UP).clicked() && index > 0 { + report( + app.move_xps_processing_step(dataset, active.id, step.id, -1), + app, + ); + } + if ui.small_button(icon::ARROW_DOWN).clicked() && index + 1 < recipe.steps.len() { + report( + app.move_xps_processing_step(dataset, active.id, step.id, 1), + app, + ); + } + if ui.small_button(icon::TRASH).clicked() { + report( + app.remove_xps_processing_step(dataset, active.id, step.id), + app, + ); + } + }); + } + let extent = processed + .and_then(|value| { + Some(( + value.binding_energy_ev.iter().copied().reduce(f64::min)?, + value.binding_energy_ev.iter().copied().reduce(f64::max)?, + )) + }) + .unwrap_or((0.0, 1.0)); + let key = ui.make_persistent_id(("xps_processing_window", dataset, active.id)); + let mut window = ui + .data_mut(|data| data.get_temp::(key)) + .unwrap_or(WindowDraft { + low_ev: extent.0, + high_ev: extent.1, + }); + ui.horizontal(|ui| { + ui.add(DragValue::new(&mut window.low_ev).speed(0.1)); + ui.label("to"); + ui.add(DragValue::new(&mut window.high_ev).speed(0.1)); + ui.label("eV"); + }); + ui.data_mut(|data| data.insert_temp(key, window.clone())); + ui.horizontal_wrapped(|ui| { + if ui.button(format!("{} Window", icon::CROP)).clicked() { + report( + app.add_xps_processing_step( + dataset, + active.id, + XpsStepKind::Window { + low_ev: window.low_ev, + high_ev: window.high_ev, + }, + ) + .map(|_| ()), + app, + ); + } + if ui.button(format!("{} Smooth", icon::WAVE_SINE)).clicked() { + report( + app.add_xps_processing_step( + dataset, + active.id, + XpsStepKind::Smooth(SmoothMethod::DEFAULT), + ) + .map(|_| ()), + app, + ); + } + if ui + .button(format!("{} Normalize", icon::ARROWS_OUT_LINE_VERTICAL)) + .clicked() + { + report( + app.add_xps_processing_step( + dataset, + active.id, + XpsStepKind::Normalize(NormalizeMethod::MaxPeak), + ) + .map(|_| ()), + app, + ); + } + }); +} + +pub(super) fn commit_workspace( + app: &mut PlotxApp, + dataset_index: usize, + dataset: DatasetId, + region: plotx_io::xps::XpsRegionId, + workspace: XpsFitWorkspace, + continuous: bool, + finished: bool, +) { + if continuous { + app.begin_processing_session(dataset_index); + } else { + app.finish_processing_session(); + } + report(app.set_xps_fit_workspace(dataset, region, workspace), app); + if finished { + app.finish_processing_session(); + } +} + +pub(super) fn selected_range(app: &PlotxApp, dataset: DatasetId) -> Option<[f64; 2]> { + app.session + .ui + .analysis_selection + .as_ref() + .filter(|selection| selection.dataset == dataset) + .map(|selection| [selection.x_range.min, selection.x_range.max]) +} + +pub(super) fn report(result: Result<(), String>, app: &mut PlotxApp) { + if let Err(error) = result { + app.session.status = error; + } +} diff --git a/crates/app/src/ui/tools/xps/background.rs b/crates/app/src/ui/tools/xps/background.rs new file mode 100644 index 0000000..be93f2e --- /dev/null +++ b/crates/app/src/ui/tools/xps/background.rs @@ -0,0 +1,248 @@ +use super::{commit_workspace, selected_range}; +use egui::{Button, ComboBox, DragValue, Ui}; +use egui_phosphor::regular as icon; +use plotx_analysis::xps::{XpsBackgroundModel, compute_xps_background}; +use plotx_core::state::{DatasetId, PlotxApp, Tool, XpsFitWorkspace}; + +pub(super) fn background_tab( + app: &mut PlotxApp, + dataset_index: usize, + dataset: DatasetId, + region: &plotx_io::xps::XpsRegion, + processed: Option<&plotx_processing::xps::ProcessedXpsRegion>, + mut workspace: XpsFitWorkspace, + ui: &mut Ui, +) { + ui.label(crate::typography::headline("Background model")); + let before_model = model_kind(&workspace.invocation.background.model); + let mut kind = before_model; + ComboBox::from_label("Model") + .selected_text(kind.label()) + .show_ui(ui, |ui| { + ui.selectable_value(&mut kind, ModelKind::Linear, "Linear"); + ui.selectable_value(&mut kind, ModelKind::Shirley, "Shirley"); + ui.selectable_value(&mut kind, ModelKind::Tougaard, "Tougaard U2"); + }); + if kind != before_model { + workspace.invocation.background.model = kind.default_model(); + commit_workspace( + app, + dataset_index, + dataset, + region.id, + workspace.clone(), + false, + true, + ); + } + + let mut changed = false; + let mut finished = false; + match &mut workspace.invocation.background.model { + XpsBackgroundModel::Linear => {} + XpsBackgroundModel::Shirley { + tolerance, + max_iterations, + } => { + ui.horizontal(|ui| { + ui.label("Tolerance"); + let response = ui.add(DragValue::new(tolerance).speed(1e-6).range(1e-12..=1.0)); + changed |= response.changed(); + finished |= response.drag_stopped() || response.lost_focus(); + }); + ui.horizontal(|ui| { + ui.label("Max iterations"); + let response = ui.add(DragValue::new(max_iterations).range(1..=100_000)); + changed |= response.changed(); + finished |= response.drag_stopped() || response.lost_focus(); + }); + } + XpsBackgroundModel::TougaardU2 { b_ev2, c_ev2 } => { + ui.horizontal(|ui| { + ui.label("B"); + let response = ui.add( + DragValue::new(b_ev2) + .suffix(" eV²") + .speed(10.0) + .range(0.0..=f64::INFINITY), + ); + changed |= response.changed(); + finished |= response.drag_stopped() || response.lost_focus(); + }); + ui.horizontal(|ui| { + ui.label("C"); + let response = ui.add( + DragValue::new(c_ev2) + .suffix(" eV²") + .speed(10.0) + .range(f64::MIN_POSITIVE..=f64::INFINITY), + ); + changed |= response.changed(); + finished |= response.drag_stopped() || response.lost_focus(); + }); + } + } + + ui.separator(); + ui.label(crate::typography::headline("Fit window and anchors")); + let range = selected_range(app, dataset); + ui.horizontal_wrapped(|ui| { + let selecting = app.session.tool == Tool::SelectRegion; + if ui.selectable_label(selecting, "Select on plot").clicked() { + app.toggle_tool(Tool::SelectRegion); + } + if ui + .add_enabled( + range.is_some(), + Button::new(format!("{} Set window", icon::CROP)), + ) + .clicked() + { + workspace.invocation.background.window_ev = range.unwrap_or_default(); + commit_workspace( + app, + dataset_index, + dataset, + region.id, + workspace.clone(), + false, + true, + ); + } + if ui + .add_enabled(range.is_some(), Button::new("Set low anchor")) + .clicked() + { + workspace.invocation.background.low_anchor_ev = range.unwrap_or_default(); + commit_workspace( + app, + dataset_index, + dataset, + region.id, + workspace.clone(), + false, + true, + ); + } + if ui + .add_enabled(range.is_some(), Button::new("Set high anchor")) + .clicked() + { + workspace.invocation.background.high_anchor_ev = range.unwrap_or_default(); + commit_workspace( + app, + dataset_index, + dataset, + region.id, + workspace.clone(), + false, + true, + ); + } + }); + changed |= range_editor( + ui, + "Fit window", + &mut workspace.invocation.background.window_ev, + &mut finished, + ); + changed |= range_editor( + ui, + "Low-BE anchor", + &mut workspace.invocation.background.low_anchor_ev, + &mut finished, + ); + changed |= range_editor( + ui, + "High-BE anchor", + &mut workspace.invocation.background.high_anchor_ev, + &mut finished, + ); + if changed { + commit_workspace( + app, + dataset_index, + dataset, + region.id, + workspace.clone(), + true, + finished, + ); + } + + ui.separator(); + ui.label(crate::typography::headline("Preview")); + if let Some(processed) = processed { + match compute_xps_background( + &processed.binding_energy_ev, + &processed.intensity, + &workspace.invocation.background, + ) { + Ok(preview) => { + let corrected = preview + .corrected + .iter() + .copied() + .reduce(f64::max) + .unwrap_or(0.0); + ui.label(format!("{} points in fit window", preview.energy_ev.len())); + ui.weak(format!( + "Maximum background-subtracted intensity: {corrected:.4}" + )); + ui.weak("The plot shows the live background and background-subtracted trace."); + } + Err(error) => { + ui.colored_label(ui.visuals().error_fg_color, error.to_string()); + } + } + } +} + +fn range_editor(ui: &mut Ui, label: &str, range: &mut [f64; 2], finished: &mut bool) -> bool { + let mut changed = false; + ui.horizontal(|ui| { + ui.label(label); + for value in range { + let response = ui.add(DragValue::new(value).suffix(" eV").speed(0.05)); + changed |= response.changed(); + *finished |= response.drag_stopped() || response.lost_focus(); + } + }); + changed +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum ModelKind { + Linear, + Shirley, + Tougaard, +} + +impl ModelKind { + fn label(self) -> &'static str { + match self { + Self::Linear => "Linear", + Self::Shirley => "Shirley", + Self::Tougaard => "Tougaard U2", + } + } + + fn default_model(self) -> XpsBackgroundModel { + match self { + Self::Linear => XpsBackgroundModel::Linear, + Self::Shirley => XpsBackgroundModel::default(), + Self::Tougaard => XpsBackgroundModel::TougaardU2 { + b_ev2: 3000.0, + c_ev2: 1643.0, + }, + } + } +} + +fn model_kind(model: &XpsBackgroundModel) -> ModelKind { + match model { + XpsBackgroundModel::Linear => ModelKind::Linear, + XpsBackgroundModel::Shirley { .. } => ModelKind::Shirley, + XpsBackgroundModel::TougaardU2 { .. } => ModelKind::Tougaard, + } +} diff --git a/crates/app/src/ui/tools/xps/components.rs b/crates/app/src/ui/tools/xps/components.rs new file mode 100644 index 0000000..e218653 --- /dev/null +++ b/crates/app/src/ui/tools/xps/components.rs @@ -0,0 +1,512 @@ +use super::commit_workspace; +use egui::{Button, ComboBox, DragValue, Ui}; +use egui_phosphor::regular as icon; +use plotx_analysis::xps::{ + XpsAreaConstraint, XpsCenterConstraint, XpsComponentId, XpsFwhmConstraint, XpsPeakSpec, +}; +use plotx_core::state::{DatasetId, PlotxApp, XpsFitWorkspace, xps_template}; + +pub(super) fn components_tab( + app: &mut PlotxApp, + dataset_index: usize, + dataset: DatasetId, + region: &plotx_io::xps::XpsRegion, + processed: Option<&plotx_processing::xps::ProcessedXpsRegion>, + mut workspace: XpsFitWorkspace, + ui: &mut Ui, +) { + if let Some(imported) = ®ion.imported_fit { + ui.label(format!( + "Imported (CasaXPS): {} components", + imported.peaks.len() + )); + if ui + .button(format!("{} Copy Imported fit", icon::COPY)) + .clicked() + { + workspace.invocation.peaks = imported + .peaks + .iter() + .map(|peak| { + let id = allocate_id(&mut workspace); + let mut spec = XpsPeakSpec::independent( + id, + peak.label.clone(), + peak.position_ev, + peak.area, + ); + spec.fwhm = XpsFwhmConstraint::Free { + initial_ev: peak.fwhm_ev.max(0.1), + bounds_ev: [0.2, 5.0], + }; + spec + }) + .collect(); + commit_workspace( + app, + dataset_index, + dataset, + region.id, + workspace.clone(), + false, + true, + ); + } + } + + ui.horizontal_wrapped(|ui| { + let mut next = workspace.next_component_id; + let template = xps_template( + ®ion.name, + processed.map_or(&[], |value| value.intensity.as_slice()), + &mut next, + ); + if ui + .add_enabled( + template.is_some(), + Button::new(format!("{} Use template", icon::LIST_PLUS)), + ) + .clicked() + { + workspace.invocation.peaks = template.unwrap_or_default(); + workspace.next_component_id = next; + commit_workspace( + app, + dataset_index, + dataset, + region.id, + workspace.clone(), + false, + true, + ); + } + if ui.button(format!("{} Add component", icon::PLUS)).clicked() { + let center = processed + .and_then(|value| { + Some( + 0.5 * (value.binding_energy_ev.iter().copied().reduce(f64::min)? + + value.binding_energy_ev.iter().copied().reduce(f64::max)?), + ) + }) + .unwrap_or(0.0); + let area = processed.map_or(1.0, |value| { + value + .intensity + .iter() + .copied() + .reduce(f64::max) + .unwrap_or(1.0) + .max(1.0) + }); + let id = allocate_id(&mut workspace); + let label = format!("Peak {}", workspace.invocation.peaks.len() + 1); + workspace + .invocation + .peaks + .push(XpsPeakSpec::independent(id, label, center, area)); + commit_workspace( + app, + dataset_index, + dataset, + region.id, + workspace.clone(), + false, + true, + ); + } + }); + + let choices = workspace + .invocation + .peaks + .iter() + .map(|peak| (peak.id, peak.label.clone())) + .collect::>(); + let referenced_ids = choices + .iter() + .filter_map(|(id, _)| is_referenced(*id, &workspace.invocation.peaks).then_some(*id)) + .collect::>(); + let mut changed = false; + let mut finished = false; + let mut operation = None; + let count = workspace.invocation.peaks.len(); + for (index, peak) in workspace.invocation.peaks.iter_mut().enumerate() { + let referenced = referenced_ids.contains(&peak.id); + ui.group(|ui| { + ui.horizontal(|ui| { + ui.label(format!("Component {} · #{}", index + 1, peak.id.0)); + if ui + .small_button(icon::ARROW_UP) + .on_hover_text("Move up") + .clicked() + && index > 0 + { + operation = Some(Operation::Move(index, index - 1)); + } + if ui + .small_button(icon::ARROW_DOWN) + .on_hover_text("Move down") + .clicked() + && index + 1 < count + { + operation = Some(Operation::Move(index, index + 1)); + } + if ui + .small_button(icon::COPY) + .on_hover_text("Copy as linked component") + .clicked() + { + operation = Some(Operation::CopyLinked(index)); + } + if ui + .add_enabled(!referenced, Button::new(icon::TRASH)) + .on_disabled_hover_text("Another component references this component.") + .clicked() + { + operation = Some(Operation::Remove(index)); + } + }); + let response = ui.text_edit_singleline(&mut peak.label); + changed |= response.changed(); + finished |= response.lost_focus(); + changed |= center_editor(ui, peak.id, &mut peak.center, &choices, &mut finished); + changed |= fwhm_editor(ui, peak.id, &mut peak.fwhm, &choices, &mut finished); + changed |= area_editor(ui, peak.id, &mut peak.area, &choices, &mut finished); + }); + } + + if let Some(operation) = operation { + match operation { + Operation::Move(from, to) => workspace.invocation.peaks.swap(from, to), + Operation::Remove(index) => { + workspace.invocation.peaks.remove(index); + } + Operation::CopyLinked(index) => { + let reference = workspace.invocation.peaks[index].id; + let id = allocate_id(&mut workspace); + workspace.invocation.peaks.push(XpsPeakSpec { + id, + label: format!("{} linked", workspace.invocation.peaks[index].label), + center: XpsCenterConstraint::Offset { + reference, + delta_ev: 1.0, + }, + fwhm: XpsFwhmConstraint::Shared { reference }, + area: XpsAreaConstraint::Ratio { + reference, + ratio: 0.5, + }, + }); + } + } + commit_workspace( + app, + dataset_index, + dataset, + region.id, + workspace.clone(), + false, + true, + ); + } else if changed { + commit_workspace( + app, + dataset_index, + dataset, + region.id, + workspace.clone(), + true, + finished, + ); + } + + ui.separator(); + ui.label(crate::typography::headline("Fit options")); + let response = ui.add( + DragValue::new(&mut workspace.invocation.options.lorentzian_fraction) + .prefix("GL Lorentzian fraction ") + .range(0.0..=1.0) + .speed(0.01), + ); + if response.changed() { + commit_workspace( + app, + dataset_index, + dataset, + region.id, + workspace.clone(), + true, + response.drag_stopped() || response.lost_focus(), + ); + } + let progress = app + .xps_fit_progress() + .filter(|(owner, active, _)| *owner == dataset && *active == region.id); + if let Some((_, _, elapsed)) = progress { + ui.horizontal(|ui| { + ui.spinner(); + ui.label(format!("Analyzing... {:.1} s", elapsed.as_secs_f64())); + if ui.button(format!("{} Cancel", icon::X)).clicked() { + app.cancel_xps_fit(); + } + }); + } + if ui + .add_enabled( + !workspace.invocation.peaks.is_empty() && processed.is_some() && progress.is_none(), + Button::new(format!("{} Fit peaks", icon::PLAY)), + ) + .on_disabled_hover_text("Add at least one component first.") + .clicked() + && let Err(error) = app.start_xps_fit(dataset, region.id) + { + app.session.status = error; + } +} + +fn center_editor( + ui: &mut Ui, + owner: XpsComponentId, + value: &mut XpsCenterConstraint, + choices: &[(XpsComponentId, String)], + finished: &mut bool, +) -> bool { + let mut mode = match value { + XpsCenterConstraint::Free { .. } => 0, + XpsCenterConstraint::Fixed { .. } => 1, + XpsCenterConstraint::Offset { .. } => 2, + }; + let before = mode; + ComboBox::from_id_salt(("center_mode", owner.0)) + .selected_text(["Free + bounded", "Fixed", "Energy offset"][mode]) + .show_ui(ui, |ui| { + ui.selectable_value(&mut mode, 0, "Free + bounded"); + ui.selectable_value(&mut mode, 1, "Fixed"); + ui.selectable_value(&mut mode, 2, "Energy offset"); + }); + if mode != before { + *value = default_center(mode, value, owner, choices); + return true; + } + let mut changed = false; + match value { + XpsCenterConstraint::Free { + initial_ev, + bounds_ev, + } => { + let [low, high] = bounds_ev; + changed |= values(ui, "Center / bounds", [initial_ev, low, high], finished); + } + XpsCenterConstraint::Fixed { value_ev } => { + changed |= values(ui, "Center", [value_ev], finished) + } + XpsCenterConstraint::Offset { + reference, + delta_ev, + } => { + changed |= reference_picker(ui, ("center_ref", owner.0), owner, reference, choices); + changed |= values(ui, "Energy difference", [delta_ev], finished); + } + } + changed +} + +fn fwhm_editor( + ui: &mut Ui, + owner: XpsComponentId, + value: &mut XpsFwhmConstraint, + choices: &[(XpsComponentId, String)], + finished: &mut bool, +) -> bool { + let mut mode = match value { + XpsFwhmConstraint::Free { .. } => 0, + XpsFwhmConstraint::Fixed { .. } => 1, + XpsFwhmConstraint::Shared { .. } => 2, + }; + let before = mode; + ComboBox::from_id_salt(("fwhm_mode", owner.0)) + .selected_text(["Free + bounded", "Fixed", "Shared width"][mode]) + .show_ui(ui, |ui| { + ui.selectable_value(&mut mode, 0, "Free + bounded"); + ui.selectable_value(&mut mode, 1, "Fixed"); + ui.selectable_value(&mut mode, 2, "Shared width"); + }); + if mode != before { + *value = default_fwhm(mode, owner, choices); + return true; + } + match value { + XpsFwhmConstraint::Free { + initial_ev, + bounds_ev, + } => { + let [low, high] = bounds_ev; + values(ui, "FWHM / bounds", [initial_ev, low, high], finished) + } + XpsFwhmConstraint::Fixed { value_ev } => values(ui, "FWHM", [value_ev], finished), + XpsFwhmConstraint::Shared { reference } => { + reference_picker(ui, ("fwhm_ref", owner.0), owner, reference, choices) + } + } +} + +fn area_editor( + ui: &mut Ui, + owner: XpsComponentId, + value: &mut XpsAreaConstraint, + choices: &[(XpsComponentId, String)], + finished: &mut bool, +) -> bool { + let mut mode = match value { + XpsAreaConstraint::Free { .. } => 0, + XpsAreaConstraint::Fixed { .. } => 1, + XpsAreaConstraint::Ratio { .. } => 2, + }; + let before = mode; + ComboBox::from_id_salt(("area_mode", owner.0)) + .selected_text(["Free nonnegative", "Fixed", "Area ratio"][mode]) + .show_ui(ui, |ui| { + ui.selectable_value(&mut mode, 0, "Free nonnegative"); + ui.selectable_value(&mut mode, 1, "Fixed"); + ui.selectable_value(&mut mode, 2, "Area ratio"); + }); + if mode != before { + *value = default_area(mode, owner, choices); + return true; + } + let mut changed = false; + match value { + XpsAreaConstraint::Free { initial, bounds } => { + let [low, high] = bounds; + changed |= values(ui, "Area / bounds", [initial, low, high], finished); + } + XpsAreaConstraint::Fixed { value } => changed |= values(ui, "Area", [value], finished), + XpsAreaConstraint::Ratio { reference, ratio } => { + changed |= reference_picker(ui, ("area_ref", owner.0), owner, reference, choices); + changed |= values(ui, "Area ratio", [ratio], finished); + } + } + changed +} + +fn values( + ui: &mut Ui, + label: &str, + values: [&mut f64; N], + finished: &mut bool, +) -> bool { + let mut changed = false; + ui.horizontal(|ui| { + ui.label(label); + for value in values { + let response = ui.add(DragValue::new(value).speed(0.01)); + changed |= response.changed(); + *finished |= response.drag_stopped() || response.lost_focus(); + } + }); + changed +} + +fn reference_picker( + ui: &mut Ui, + salt: impl std::hash::Hash, + owner: XpsComponentId, + reference: &mut XpsComponentId, + choices: &[(XpsComponentId, String)], +) -> bool { + let before = *reference; + let label = choices + .iter() + .find(|(id, _)| id == reference) + .map_or("Choose component", |(_, label)| label); + ComboBox::from_id_salt(salt) + .selected_text(label) + .show_ui(ui, |ui| { + for (id, label) in choices.iter().filter(|(id, _)| *id != owner) { + ui.selectable_value(reference, *id, label); + } + }); + *reference != before +} + +fn other(owner: XpsComponentId, choices: &[(XpsComponentId, String)]) -> XpsComponentId { + choices + .iter() + .find(|(id, _)| *id != owner) + .map_or(owner, |(id, _)| *id) +} +fn default_center( + mode: usize, + old: &XpsCenterConstraint, + owner: XpsComponentId, + choices: &[(XpsComponentId, String)], +) -> XpsCenterConstraint { + let center = match old { + XpsCenterConstraint::Free { initial_ev, .. } => *initial_ev, + XpsCenterConstraint::Fixed { value_ev } => *value_ev, + XpsCenterConstraint::Offset { delta_ev, .. } => *delta_ev, + }; + match mode { + 0 => XpsCenterConstraint::Free { + initial_ev: center, + bounds_ev: [center - 0.8, center + 0.8], + }, + 1 => XpsCenterConstraint::Fixed { value_ev: center }, + _ => XpsCenterConstraint::Offset { + reference: other(owner, choices), + delta_ev: 1.0, + }, + } +} +fn default_fwhm( + mode: usize, + owner: XpsComponentId, + choices: &[(XpsComponentId, String)], +) -> XpsFwhmConstraint { + match mode { + 0 => XpsFwhmConstraint::Free { + initial_ev: 1.2, + bounds_ev: [0.8, 2.5], + }, + 1 => XpsFwhmConstraint::Fixed { value_ev: 1.2 }, + _ => XpsFwhmConstraint::Shared { + reference: other(owner, choices), + }, + } +} +fn default_area( + mode: usize, + owner: XpsComponentId, + choices: &[(XpsComponentId, String)], +) -> XpsAreaConstraint { + match mode { + 0 => XpsAreaConstraint::Free { + initial: 1.0, + bounds: [0.0, 20.0], + }, + 1 => XpsAreaConstraint::Fixed { value: 1.0 }, + _ => XpsAreaConstraint::Ratio { + reference: other(owner, choices), + ratio: 0.5, + }, + } +} + +fn allocate_id(workspace: &mut XpsFitWorkspace) -> XpsComponentId { + let id = XpsComponentId::new(workspace.next_component_id); + workspace.next_component_id = workspace.next_component_id.saturating_add(1); + id +} +fn is_referenced(id: XpsComponentId, peaks: &[XpsPeakSpec]) -> bool { + peaks.iter().any(|peak| { + matches!(peak.center, XpsCenterConstraint::Offset { reference, .. } if reference == id) + || matches!(peak.fwhm, XpsFwhmConstraint::Shared { reference } if reference == id) + || matches!(peak.area, XpsAreaConstraint::Ratio { reference, .. } if reference == id) + }) +} + +enum Operation { + Move(usize, usize), + Remove(usize), + CopyLinked(usize), +} diff --git a/crates/app/src/ui/tools/xps/diagnostics.rs b/crates/app/src/ui/tools/xps/diagnostics.rs new file mode 100644 index 0000000..b4f9e05 --- /dev/null +++ b/crates/app/src/ui/tools/xps/diagnostics.rs @@ -0,0 +1,255 @@ +use egui::{Button, DragValue, Ui}; +use egui_phosphor::regular as icon; +use plotx_core::state::{DatasetId, PlotxApp, StoredXpsFit}; +use plotx_io::xps::XpsRegionId; + +pub(super) fn diagnostics_tab( + app: &mut PlotxApp, + dataset: DatasetId, + region: XpsRegionId, + current: Option<&StoredXpsFit>, + latest: Option<&StoredXpsFit>, + ui: &mut Ui, +) { + ui.label(crate::typography::headline("Fit quality")); + let Some(fit) = current else { + if latest.is_some() { + ui.colored_label( + ui.visuals().warn_fg_color, + "The latest PlotX fit is stale because its input or workspace changed.", + ); + } else { + ui.weak("Fit the current workspace to calculate diagnostics."); + } + return; + }; + residual_preview(ui, &fit.result.energy_ev, &fit.result.residual); + ui.label(format!("R² {:.6}", fit.result.r_squared)); + ui.label(format!("RMSE {:.6}", fit.result.rmse)); + if let Some(lag) = fit.result.residual_lag1 { + ui.label(format!("Residual lag-1 {lag:.4}")); + } + + let bounds = fit + .result + .peaks + .iter() + .filter(|peak| peak.hit_position_bound || peak.hit_fwhm_bound || peak.hit_area_bound) + .count(); + if bounds > 0 { + ui.colored_label( + ui.visuals().warn_fg_color, + format!("{bounds} component(s) reached a parameter bound."), + ); + } + let unusual_widths = fit + .result + .peaks + .iter() + .filter(|peak| !(0.8..=2.5).contains(&peak.fwhm_ev.value)) + .count(); + if unusual_widths > 0 { + ui.colored_label( + ui.visuals().warn_fg_color, + format!("{unusual_widths} FWHM value(s) are outside 0.8-2.5 eV."), + ); + } + if let Some(correlation) = &fit.result.parameter_correlation { + let maximum = correlation + .iter() + .enumerate() + .flat_map(|(row, values)| { + values + .iter() + .enumerate() + .filter(move |(column, _)| *column != row) + .map(|(_, value)| value.abs()) + }) + .reduce(f64::max) + .unwrap_or(0.0); + ui.label(format!("Maximum parameter correlation {maximum:.3}")); + if maximum > 0.95 { + ui.colored_label( + ui.visuals().warn_fg_color, + "Strong parameter correlation; interpret individual intervals cautiously.", + ); + } + } else { + ui.colored_label( + ui.visuals().warn_fg_color, + "Covariance is unavailable because the local matrix is singular.", + ); + } + + ui.separator(); + ui.label(crate::typography::headline("Parameters")); + for peak in &fit.result.peaks { + ui.group(|ui| { + ui.label(format!("{} · #{}", peak.label, peak.id.0)); + estimate(ui, "Center", &peak.center_ev, " eV"); + estimate(ui, "FWHM", &peak.fwhm_ev, " eV"); + estimate(ui, "Area", &peak.area, ""); + estimate(ui, "Area fraction", &peak.fraction, ""); + if let Some(bootstrap) = fit + .bootstrap + .as_ref() + .and_then(|result| result.peaks.iter().find(|result| result.id == peak.id)) + { + ui.weak(format!( + "Bootstrap center 95%: {:.4} to {:.4} eV", + bootstrap.center_ev[0], bootstrap.center_ev[2] + )); + } + }); + } + + ui.separator(); + ui.label(crate::typography::headline("Wild residual Bootstrap")); + let Some(xps) = app + .doc + .datasets + .iter() + .find(|candidate| candidate.resource_id() == dataset) + .and_then(|candidate| candidate.as_xps()) + else { + return; + }; + let Some(mut workspace) = xps.fit_workspaces.get(®ion).cloned() else { + return; + }; + let dataset_index = app.doc.dataset_index(dataset).unwrap_or_default(); + let mut changed = false; + let samples = ui.add( + DragValue::new(&mut workspace.bootstrap.samples) + .prefix("Replicates ") + .range(100..=5_000), + ); + changed |= samples.changed(); + let seed = ui.add(DragValue::new(&mut workspace.bootstrap.seed).prefix("Seed (0 = auto) ")); + changed |= seed.changed(); + if changed { + super::commit_workspace( + app, + dataset_index, + dataset, + region, + workspace, + true, + samples.drag_stopped() + || samples.lost_focus() + || seed.drag_stopped() + || seed.lost_focus(), + ); + } + if let Some(result) = &fit.bootstrap { + ui.label(format!( + "{} of {} replicates converged ({:.0}%), seed {}", + result.converged, + result.requested, + result.convergence_fraction() * 100.0, + result.seed + )); + if result.convergence_fraction() < 0.8 { + ui.colored_label( + ui.visuals().warn_fg_color, + "Bootstrap convergence is below 80%; intervals were retained but may be unstable.", + ); + } + } + let progress = app + .xps_fit_progress() + .filter(|(owner, active, _)| *owner == dataset && *active == region); + if let Some((_, _, elapsed)) = progress { + ui.horizontal(|ui| { + ui.spinner(); + ui.label(format!("Analyzing... {:.1} s", elapsed.as_secs_f64())); + if ui.button(format!("{} Cancel", icon::X)).clicked() { + app.cancel_xps_fit(); + } + }); + } else if ui + .add(Button::new(format!("{} Run Bootstrap", icon::PLAY))) + .clicked() + && let Err(error) = app.start_xps_bootstrap(dataset, region) + { + app.session.status = error; + } + ui.weak("Intervals are diagnostic; R² alone does not establish chemical assignment."); +} + +fn residual_preview(ui: &mut Ui, energy: &[f64], residual: &[f64]) { + let width = ui.available_width().max(80.0); + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, 88.0), egui::Sense::hover()); + let painter = ui.painter_at(rect); + painter.rect_filled( + rect, + ui.visuals().widgets.noninteractive.corner_radius, + ui.visuals().faint_bg_color, + ); + let Some((energy_min, energy_max)) = finite_extent(energy) else { + return; + }; + let maximum = residual + .iter() + .copied() + .filter(|value| value.is_finite()) + .map(f64::abs) + .fold(0.0_f64, f64::max); + if energy_max <= energy_min || maximum <= f64::EPSILON { + return; + } + let plot = rect.shrink2(egui::vec2(5.0, 7.0)); + painter.line_segment( + [ + egui::pos2(plot.left(), plot.center().y), + egui::pos2(plot.right(), plot.center().y), + ], + egui::Stroke::new(1.0_f32, ui.visuals().widgets.noninteractive.bg_stroke.color), + ); + let points = energy + .iter() + .copied() + .zip(residual.iter().copied()) + .filter(|(x, y)| x.is_finite() && y.is_finite()) + .map(|(x, y)| { + let tx = ((energy_max - x) / (energy_max - energy_min)) as f32; + let ty = (y / maximum) as f32; + egui::pos2( + plot.left() + tx * plot.width(), + plot.center().y - ty * plot.height() * 0.5, + ) + }) + .collect::>(); + if points.len() >= 2 { + painter.add(egui::Shape::line( + points, + egui::Stroke::new(1.2_f32, egui::Color32::from_rgb(0xae, 0x2c, 0x2c)), + )); + } +} + +fn finite_extent(values: &[f64]) -> Option<(f64, f64)> { + let mut min = f64::INFINITY; + let mut max = f64::NEG_INFINITY; + for value in values.iter().copied().filter(|value| value.is_finite()) { + min = min.min(value); + max = max.max(value); + } + min.is_finite().then_some((min, max)) +} + +fn estimate( + ui: &mut Ui, + label: &str, + estimate: &plotx_analysis::xps::XpsParameterEstimate, + suffix: &str, +) { + if let Some(interval) = estimate.confidence_95 { + ui.label(format!( + "{label}: {:.5}{suffix} (95% {:.5} to {:.5})", + estimate.value, interval[0], interval[1] + )); + } else { + ui.label(format!("{label}: {:.5}{suffix}", estimate.value)); + } +} diff --git a/crates/core/src/actions/app_impl/processing.rs b/crates/core/src/actions/app_impl/processing.rs index 5c160a8..5ee8c6a 100644 --- a/crates/core/src/actions/app_impl/processing.rs +++ b/crates/core/src/actions/app_impl/processing.rs @@ -54,6 +54,24 @@ impl PlotxApp { (Dataset::Xrd(data), DatasetProcessingState::Xrd(processing)) => { data.params = *processing; } + ( + Dataset::Xps(xps), + DatasetProcessingState::Xps { + active_region, + measurement_shifts, + region_recipes, + fit_workspaces, + fits, + next_step_id, + }, + ) => { + xps.active_region = *active_region; + xps.measurement_shifts = measurement_shifts.clone(); + xps.region_recipes = region_recipes.clone(); + xps.fit_workspaces = fit_workspaces.clone(); + xps.fits = fits.clone(); + xps.next_step_id = *next_step_id; + } _ => {} } } @@ -102,9 +120,21 @@ impl PlotxApp { before: DatasetProcessingState, after: DatasetProcessingState, ) { + if let Err(error) = self.try_commit_processing_edit(dataset, before, after) { + self.session.status = error; + } + } + + pub(crate) fn try_commit_processing_edit( + &mut self, + dataset: usize, + before: DatasetProcessingState, + after: DatasetProcessingState, + ) -> Result<(), String> { let Some(dataset_id) = self.doc.datasets.get(dataset).map(Dataset::resource_id) else { - return; + return Err("The processing dataset is no longer available.".into()); }; + validate_processing_state(&self.doc.datasets[dataset], &after)?; if self .session .ui @@ -118,7 +148,7 @@ impl PlotxApp { { self.session.status = error; } - return; + return Ok(()); } if self.session.ui.proc_paused { self.set_recipe_no_recompute(dataset, &after); @@ -126,8 +156,10 @@ impl PlotxApp { self.session.ui.proc_pending = Some((dataset_id, before)); } } else { - self.execute_action(Action::update_dataset_processing(dataset_id, before, after)); + self.try_execute_action(Action::update_dataset_processing(dataset_id, before, after)) + .map_err(|error| error.to_string())?; } + Ok(()) } pub fn has_pending_processing(&self) -> bool { @@ -327,6 +359,13 @@ pub(super) fn validate_processing_state( plotx_processing::xrd::validate(*processing) .map_err(|error| format!("Cannot apply invalid XRD processing pipeline: {error}")) } + (Dataset::Xps(_), DatasetProcessingState::Xps { .. }) => { + let mut candidate = dataset.clone(); + state + .apply_to(&mut candidate) + .map(|_| ()) + .map_err(|error| format!("Cannot apply invalid XPS state: {error}")) + } _ => Ok(()), } } diff --git a/crates/core/src/actions/app_impl/validate.rs b/crates/core/src/actions/app_impl/validate.rs index 0e9068d..d65759e 100644 --- a/crates/core/src/actions/app_impl/validate.rs +++ b/crates/core/src/actions/app_impl/validate.rs @@ -49,13 +49,19 @@ pub(super) fn validate_action( return Err(ActionApplyError::StaleTarget(format!("dataset {dataset}"))); } } - Action::UpdateDatasetProcessing { dataset, after, .. } => { + Action::UpdateDatasetProcessing { + dataset, + before, + after, + } => { if !shape.has_dataset(app, *dataset) { return Err(ActionApplyError::StaleTarget(format!("dataset {dataset}"))); } if let Some(index) = app.doc.dataset_index(*dataset) { - super::processing::validate_processing_state(&app.doc.datasets[index], after) - .map_err(ActionApplyError::InvalidValue)?; + for state in [before, after] { + super::processing::validate_processing_state(&app.doc.datasets[index], state) + .map_err(ActionApplyError::InvalidValue)?; + } } } Action::SetMassSpecStream { diff --git a/crates/core/src/actions/mod.rs b/crates/core/src/actions/mod.rs index 0588d41..e81be0c 100644 --- a/crates/core/src/actions/mod.rs +++ b/crates/core/src/actions/mod.rs @@ -42,6 +42,19 @@ pub enum DatasetProcessingState { Electrophysiology(crate::state::ElectrophysiologyProcessing), Afm, Xrd(XrdProcessing), + Xps { + active_region: plotx_io::xps::XpsRegionId, + measurement_shifts: std::collections::BTreeMap, + region_recipes: std::collections::BTreeMap< + plotx_io::xps::XpsRegionId, + plotx_processing::xps::XpsProcessingRecipe, + >, + fit_workspaces: + std::collections::BTreeMap, + fits: + std::collections::BTreeMap>, + next_step_id: u64, + }, } #[derive(Clone)] diff --git a/crates/core/src/actions/processing_state.rs b/crates/core/src/actions/processing_state.rs index 6d72e08..9e86eee 100644 --- a/crates/core/src/actions/processing_state.rs +++ b/crates/core/src/actions/processing_state.rs @@ -15,7 +15,8 @@ impl DatasetProcessingState { | Self::Table | Self::Electrophysiology(_) | Self::Afm - | Self::Xrd(_) => None, + | Self::Xrd(_) + | Self::Xps { .. } => None, } } @@ -29,7 +30,11 @@ impl DatasetProcessingState { group_delay_correct, .. } => Some(group_delay_correct), - Self::Table | Self::Electrophysiology(_) | Self::Afm | Self::Xrd(_) => None, + Self::Table + | Self::Electrophysiology(_) + | Self::Afm + | Self::Xrd(_) + | Self::Xps { .. } => None, } } @@ -49,6 +54,14 @@ impl DatasetProcessingState { Dataset::Afm(_) => Self::Afm, Dataset::MassSpec(_) => Self::Table, Dataset::Xrd(data) => Self::Xrd(data.params), + Dataset::Xps(xps) => Self::Xps { + active_region: xps.active_region, + measurement_shifts: xps.measurement_shifts.clone(), + region_recipes: xps.region_recipes.clone(), + fit_workspaces: xps.fit_workspaces.clone(), + fits: xps.fits.clone(), + next_step_id: xps.next_step_id, + }, } } @@ -63,7 +76,11 @@ impl DatasetProcessingState { let pipelines: Vec<&mut AxisPipeline> = match self { Self::Nmr { pipeline, .. } => vec![pipeline], Self::Nmr2D { params, .. } => vec![&mut params.f2, &mut params.f1], - Self::Table | Self::Electrophysiology(_) | Self::Afm | Self::Xrd(_) => Vec::new(), + Self::Table + | Self::Electrophysiology(_) + | Self::Afm + | Self::Xrd(_) + | Self::Xps { .. } => Vec::new(), }; pipelines .into_iter() @@ -159,6 +176,169 @@ impl DatasetProcessingState { })?; Ok(ProcessingRebuild::Rebuilt) } + ( + Dataset::Xps(xps), + Self::Xps { + active_region, + measurement_shifts, + region_recipes, + fit_workspaces, + fits, + next_step_id, + }, + ) => { + if xps.region(*active_region).is_none() { + return Err(ProcessingStateError::InvalidXps( + "the selected XPS region no longer exists".into(), + )); + } + let expected_measurements = xps + .experiment + .measurements + .iter() + .map(|measurement| measurement.id) + .collect::>(); + let expected_workspaces = xps + .experiment + .regions + .iter() + .filter(|region| region.binding_energy_ev.is_some()) + .map(|region| region.id) + .collect::>(); + if measurement_shifts + .keys() + .copied() + .collect::>() + != expected_measurements + { + return Err(ProcessingStateError::InvalidXps( + "XPS measurement shift identities do not match the experiment".into(), + )); + } + let expected_regions = xps + .experiment + .regions + .iter() + .map(|region| region.id) + .collect::>(); + if region_recipes + .keys() + .copied() + .collect::>() + != expected_regions + { + return Err(ProcessingStateError::InvalidXps( + "XPS region recipe identities do not match the experiment".into(), + )); + } + if measurement_shifts.values().any(|shift| !shift.is_finite()) { + return Err(ProcessingStateError::InvalidXps( + "XPS measurement energy shifts must be finite".into(), + )); + } + let current_workspaces = fit_workspaces + .keys() + .copied() + .collect::>(); + if current_workspaces != expected_workspaces + || fits + .keys() + .any(|region| !expected_workspaces.contains(region)) + { + return Err(ProcessingStateError::InvalidXps( + "XPS fitting workspace identities do not match the experiment".into(), + )); + } + let mut step_ids = std::collections::BTreeSet::new(); + for recipe in region_recipes.values() { + for step in &recipe.steps { + if !step_ids.insert(step.id) || step.id.get() >= *next_step_id { + return Err(ProcessingStateError::InvalidXps( + "XPS processing step identities or allocator are invalid".into(), + )); + } + } + } + if *next_step_id == 0 { + return Err(ProcessingStateError::InvalidXps( + "XPS processing step allocator is invalid".into(), + )); + } + for region in &xps.experiment.regions { + let Some(shift) = measurement_shifts.get(®ion.measurement) else { + return Err(ProcessingStateError::InvalidXps(format!( + "measurement {} has no energy shift", + region.measurement.0 + ))); + }; + let Some(recipe) = region_recipes.get(®ion.id) else { + return Err(ProcessingStateError::InvalidXps(format!( + "region {} has no processing recipe", + region.id.0 + ))); + }; + let (energy, applied_shift) = region + .binding_energy_ev + .as_ref() + .map_or((®ion.native_energy_ev, 0.0), |binding| (binding, *shift)); + plotx_processing::xps::process_region( + energy, + ®ion.intensity_cps, + applied_shift, + recipe, + ) + .map_err(|message| ProcessingStateError::InvalidXps(message.into()))?; + if region.binding_energy_ev.is_some() { + let workspace = fit_workspaces.get(®ion.id).ok_or_else(|| { + ProcessingStateError::InvalidXps(format!( + "region {} has no fit workspace", + region.id.0 + )) + })?; + plotx_analysis::xps::validate_xps_constraints(&workspace.invocation) + .map_err(|error| ProcessingStateError::InvalidXps(error.to_string()))?; + let next = workspace + .invocation + .peaks + .iter() + .map(|peak| peak.id.0) + .max() + .unwrap_or(0) + .saturating_add(1); + if workspace.next_component_id < next { + return Err(ProcessingStateError::InvalidXps(format!( + "region {} has an invalid component allocator", + region.id.0 + ))); + } + for fit in fits.get(®ion.id).into_iter().flatten() { + if fit.region != region.id { + return Err(ProcessingStateError::InvalidXps(format!( + "region {} has mismatched fit provenance", + region.id.0 + ))); + } + plotx_analysis::xps::validate_xps_fit_summary( + &fit.invocation, + &fit.result, + ) + .map_err(|error| { + ProcessingStateError::InvalidXps(format!( + "region {} has an invalid fit: {error}", + region.id.0 + )) + })?; + } + } + } + xps.active_region = *active_region; + xps.measurement_shifts = measurement_shifts.clone(); + xps.region_recipes = region_recipes.clone(); + xps.fit_workspaces = fit_workspaces.clone(); + xps.fits = fits.clone(); + xps.next_step_id = *next_step_id; + Ok(ProcessingRebuild::Rebuilt) + } (dataset, state) => Err(ProcessingStateError::KindMismatch { dataset_kind: dataset.kind_label(), state_kind: state.kind_label(), @@ -174,6 +354,7 @@ impl DatasetProcessingState { Self::Electrophysiology(_) => "Electrophysiology", Self::Afm => "AFM", Self::Xrd(_) => "XRD", + Self::Xps { .. } => "XPS", } } } @@ -194,4 +375,6 @@ pub enum ProcessingStateError { }, #[error("cannot apply invalid {axis} processing pipeline: {details}")] InvalidPipeline { axis: &'static str, details: String }, + #[error("cannot apply invalid XPS processing state: {0}")] + InvalidXps(String), } diff --git a/crates/core/src/automation/resources.rs b/crates/core/src/automation/resources.rs index ec9b26e..f73ee75 100644 --- a/crates/core/src/automation/resources.rs +++ b/crates/core/src/automation/resources.rs @@ -7,8 +7,12 @@ use crate::state::{Dataset, PlotxApp}; use std::collections::BTreeMap; mod mass_spec; +mod statistics; +mod xps; mod xrd; +use statistics::add_statistics; + pub const KIND_DATASET: &str = "plotx.dataset"; pub const KIND_APP: &str = "plotx.app"; pub const KIND_DOCUMENT: &str = "plotx.document"; @@ -56,6 +60,7 @@ pub const CAP_FIELD_REGION_SERIES: &str = "field.region_series"; pub const CAP_FIELD_MASS_CHROMATOGRAM: &str = "field.mass_spectrometry.chromatogram"; pub const CAP_FIELD_MASS_SPECTRUM: &str = "field.mass_spectrometry.spectrum"; pub const CAP_FIELD_XRD_PATTERN: &str = "field.xrd.pattern"; +pub const CAP_FIELD_XPS_SPECTRUM: &str = "field.xps.spectrum"; /// Capability-oriented resource access. New resource types can participate by /// implementing this trait; query and tool orchestration do not dispatch on a @@ -184,6 +189,7 @@ impl<'a> ProjectResourceProvider<'a> { } Dataset::MassSpec(dataset) => mass_spec::descriptor(dataset), Dataset::Xrd(dataset) => (vec![dataset.data.len()], vec!["deg".to_owned()], Vec::new()), + Dataset::Xps(dataset) => xps::descriptor(dataset), }; children.extend( dataset @@ -678,6 +684,7 @@ fn preview_dataset( } Dataset::MassSpec(dataset) => mass_spec::preview(dataset, target, limit, &mut statistics), Dataset::Xrd(dataset) => xrd::preview(dataset, limit, &mut statistics), + Dataset::Xps(dataset) => xps::preview(dataset, target, limit, &mut statistics)?, }; let returned = total.min(limit); Ok(DataPreview { @@ -691,29 +698,6 @@ fn preview_dataset( }) } -fn add_statistics(out: &mut BTreeMap, values: &[f64]) { - let finite = values - .iter() - .copied() - .filter(|value| value.is_finite()) - .collect::>(); - if finite.is_empty() { - return; - } - out.insert( - "min".to_owned(), - finite.iter().copied().fold(f64::INFINITY, f64::min), - ); - out.insert( - "max".to_owned(), - finite.iter().copied().fold(f64::NEG_INFINITY, f64::max), - ); - out.insert( - "mean".to_owned(), - finite.iter().sum::() / finite.len() as f64, - ); -} - fn add_typed_statistics(out: &mut BTreeMap, values: &[plotx_data::ScalarValue]) { let finite = values.iter().filter_map(|value| match value { plotx_data::ScalarValue::Int64(value) => Some(*value as f64), diff --git a/crates/core/src/automation/resources/statistics.rs b/crates/core/src/automation/resources/statistics.rs new file mode 100644 index 0000000..a2e5a00 --- /dev/null +++ b/crates/core/src/automation/resources/statistics.rs @@ -0,0 +1,24 @@ +use std::collections::BTreeMap; + +pub(super) fn add_statistics(out: &mut BTreeMap, values: &[f64]) { + let finite = values + .iter() + .copied() + .filter(|value| value.is_finite()) + .collect::>(); + if finite.is_empty() { + return; + } + out.insert( + "min".to_owned(), + finite.iter().copied().fold(f64::INFINITY, f64::min), + ); + out.insert( + "max".to_owned(), + finite.iter().copied().fold(f64::NEG_INFINITY, f64::max), + ); + out.insert( + "mean".to_owned(), + finite.iter().sum::() / finite.len() as f64, + ); +} diff --git a/crates/core/src/automation/resources/xps.rs b/crates/core/src/automation/resources/xps.rs new file mode 100644 index 0000000..975d990 --- /dev/null +++ b/crates/core/src/automation/resources/xps.rs @@ -0,0 +1,44 @@ +use super::{AutomationError, ResourceRef, add_statistics}; +use crate::state::XpsDataset; +use std::collections::BTreeMap; + +pub(super) fn descriptor(dataset: &XpsDataset) -> (Vec, Vec, Vec) { + ( + vec![ + dataset.experiment.measurements.len(), + dataset.experiment.regions.len(), + ], + vec!["eV".to_owned()], + Vec::new(), + ) +} + +pub(super) fn preview( + dataset: &XpsDataset, + target: &ResourceRef, + limit: usize, + statistics: &mut BTreeMap, +) -> Result<(Vec, serde_json::Value, usize), AutomationError> { + let region = target + .local_id + .as_deref() + .and_then(|key| dataset.field_catalog.id_for_key(key)) + .and_then(|id| dataset.region_for_field(id)) + .unwrap_or_else(|| dataset.active_region()); + let processed = dataset.displayed_region(region.id).ok_or_else(|| { + AutomationError::Execution("XPS region has no displayable energy axis".into()) + })?; + add_statistics(statistics, &processed.intensity); + let rows = processed + .binding_energy_ev + .iter() + .zip(&processed.intensity) + .take(limit) + .map(|(x, y)| serde_json::json!([x, y])) + .collect::>(); + Ok(( + vec![processed.intensity.len(), 2], + serde_json::Value::Array(rows), + processed.intensity.len(), + )) +} diff --git a/crates/core/src/data_export.rs b/crates/core/src/data_export.rs index 2b2cdf6..044fe44 100644 --- a/crates/core/src/data_export.rs +++ b/crates/core/src/data_export.rs @@ -14,7 +14,8 @@ mod write; mod xlsx; use write::{ safe_name, write_1d, write_electrophysiology, write_fits, write_integrals_1d, - write_integrals_2d, write_peaks, write_pseudo_2d, write_true_2d, write_xrd, + write_integrals_2d, write_peaks, write_pseudo_2d, write_true_2d, write_xps, write_xps_fits, + write_xrd, }; pub use xlsx::delimited_sidecar_path; @@ -153,6 +154,12 @@ impl DataExportAvailability { }) { contents.push(DataExportContent::CurveFits); } + if dataset.as_xps().is_some_and(|xps| { + let region = xps.active_region(); + region.imported_fit.is_some() || xps.current_fit(region.id).is_some() + }) { + contents.push(DataExportContent::CurveFits); + } Self { has_channel_choice: matches!(dataset, Dataset::Nmr(_) | Dataset::Nmr2D(_)), has_shape_choice: matches!(dataset, Dataset::Nmr2D(_)), @@ -203,6 +210,9 @@ fn processed_data_available(dataset: &Dataset) -> bool { // option whose capture path can only return `ContentUnavailable`. Dataset::MassSpec(_) => false, Dataset::Xrd(xrd) => !xrd.processed.intensity.is_empty(), + Dataset::Xps(xps) => xps + .displayed_region(xps.active_region) + .is_some_and(|region| !region.intensity.is_empty()), } } @@ -295,6 +305,54 @@ enum SnapshotData { two_theta_deg: Vec, intensity: Vec, }, + Xps(Box), + XpsFits(Vec), +} + +#[derive(Clone)] +struct XpsDataSnapshot { + native_energy_ev: Vec, + binding_energy_ev: Option>, + raw_cps: Vec, + processed_energy_ev: Vec, + processed_cps: Vec, + fit_energy_ev: Vec, + background: Vec, + background_subtracted: Vec, + envelope: Vec, + residual: Vec, + components: Vec<(String, Vec)>, + background_model: Option, + background_window_ev: Option<[f64; 2]>, + low_anchor_ev: Option<[f64; 2]>, + high_anchor_ev: Option<[f64; 2]>, +} + +#[derive(Clone)] +struct XpsFitParameterRow { + provenance: &'static str, + label: String, + center_ev: f64, + fwhm_ev: f64, + area: f64, + fraction: Option, + r_squared: Option, + rmse: Option, + residual_lag1: Option, + hit_position_bound: Option, + hit_fwhm_bound: Option, + hit_area_bound: Option, + center_standard_error: Option, + center_confidence_95: Option<[f64; 2]>, + fwhm_standard_error: Option, + fwhm_confidence_95: Option<[f64; 2]>, + area_standard_error: Option, + area_confidence_95: Option<[f64; 2]>, + maximum_correlation: Option, + bootstrap_center: Option<[f64; 3]>, + bootstrap_fwhm: Option<[f64; 3]>, + bootstrap_area: Option<[f64; 3]>, + bootstrap_fraction: Option<[f64; 3]>, } impl DataExportSnapshot { @@ -330,13 +388,19 @@ impl DataExportSnapshot { return Err(DataExportError::ContentUnavailable); } } - DataExportContent::CurveFits => SnapshotData::Fits( - dataset - .as_table() - .ok_or(DataExportError::ContentUnavailable)? - .curve_fit_analyses - .clone(), - ), + DataExportContent::CurveFits => { + if let Some(xps) = dataset.as_xps() { + SnapshotData::XpsFits(capture_xps_fits(xps)?) + } else { + SnapshotData::Fits( + dataset + .as_table() + .ok_or(DataExportError::ContentUnavailable)? + .curve_fit_analyses + .clone(), + ) + } + } }; Ok(Self { dataset_name, @@ -405,6 +469,8 @@ impl DataExportSnapshot { two_theta_deg, intensity, } => write_xrd(&mut writer, two_theta_deg, intensity)?, + SnapshotData::Xps(data) => write_xps(&mut writer, data)?, + SnapshotData::XpsFits(rows) => write_xps_fits(&mut writer, rows)?, } Ok(()) } @@ -479,6 +545,252 @@ fn capture_processed(dataset: &Dataset) -> Result two_theta_deg: xrd.data.two_theta_deg.clone(), intensity: xrd.processed.intensity.clone(), }), + Dataset::Xps(xps) => { + let region = xps.active_region(); + let processed = xps + .displayed_region(region.id) + .ok_or(DataExportError::ContentUnavailable)?; + let current = xps.current_fit(region.id); + let imported = current + .is_none() + .then(|| xps.imported_fit_for_processed_region(region.id)) + .flatten(); + let (fit_energy_ev, background, corrected, envelope, residual, components) = + if let Some(fit) = current { + ( + fit.result.energy_ev.clone(), + fit.result.background.clone(), + fit.result + .intensity + .iter() + .zip(&fit.result.background) + .map(|(y, bg)| y - bg) + .collect(), + fit.result.envelope.clone(), + fit.result.residual.clone(), + fit.result + .components + .iter() + .enumerate() + .map(|(index, values)| { + ( + fit.result.peaks.get(index).map_or_else( + || format!("component_{}", index + 1), + |peak| peak.label.clone(), + ), + values.clone(), + ) + }) + .collect(), + ) + } else if let Some(fit) = imported { + let shift = xps.energy_shift(region.measurement).unwrap_or(0.0); + ( + region + .binding_energy_ev + .as_ref() + .map(|energy| energy.iter().map(|value| value + shift).collect()) + .unwrap_or_default(), + fit.background_cps.clone(), + region + .intensity_cps + .iter() + .zip(&fit.background_cps) + .map(|(y, bg)| y - bg) + .collect(), + fit.envelope_cps.clone(), + region + .intensity_cps + .iter() + .zip(&fit.envelope_cps) + .map(|(observed, predicted)| observed - predicted) + .collect(), + fit.components_cps + .iter() + .enumerate() + .map(|(index, values)| { + ( + fit.peaks.get(index).map_or_else( + || format!("imported_component_{}", index + 1), + |peak| peak.label.clone(), + ), + values.clone(), + ) + }) + .collect(), + ) + } else { + let preview = xps.fit_workspaces.get(®ion.id).and_then(|workspace| { + plotx_analysis::xps::compute_xps_background( + &processed.binding_energy_ev, + &processed.intensity, + &workspace.invocation.background, + ) + .ok() + }); + preview.map_or_else( + || { + ( + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + ) + }, + |preview| { + ( + preview.energy_ev, + preview.background, + preview.corrected, + Vec::new(), + Vec::new(), + Vec::new(), + ) + }, + ) + }; + let background_spec = current.map(|fit| &fit.invocation.background).or_else(|| { + imported + .is_none() + .then(|| { + xps.fit_workspaces + .get(®ion.id) + .map(|workspace| &workspace.invocation.background) + }) + .flatten() + }); + let background_model = if imported.is_some() { + Some("Imported (CasaXPS)".to_owned()) + } else { + background_spec.map(|spec| background_model_label(&spec.model)) + }; + Ok(SnapshotData::Xps(Box::new(XpsDataSnapshot { + native_energy_ev: region.native_energy_ev.clone(), + binding_energy_ev: region.binding_energy_ev.clone(), + raw_cps: region.intensity_cps.clone(), + processed_energy_ev: processed.binding_energy_ev, + processed_cps: processed.intensity, + fit_energy_ev, + background, + background_subtracted: corrected, + envelope, + residual, + components, + background_model, + background_window_ev: background_spec.map(|spec| spec.window_ev), + low_anchor_ev: background_spec.map(|spec| spec.low_anchor_ev), + high_anchor_ev: background_spec.map(|spec| spec.high_anchor_ev), + }))) + } + } +} + +fn capture_xps_fits( + xps: &crate::state::XpsDataset, +) -> Result, DataExportError> { + let region = xps.active_region(); + if let Some(fit) = xps.current_fit(region.id) { + let maximum_correlation = fit + .result + .parameter_correlation + .as_ref() + .and_then(|matrix| { + matrix + .iter() + .enumerate() + .flat_map(|(row, values)| { + values + .iter() + .enumerate() + .filter(move |(column, _)| *column != row) + .map(|(_, value)| value.abs()) + }) + .reduce(f64::max) + }); + return Ok(fit + .result + .peaks + .iter() + .map(|peak| { + let bootstrap = fit.bootstrap.as_ref().and_then(|result| { + result + .peaks + .iter() + .find(|candidate| candidate.id == peak.id) + }); + XpsFitParameterRow { + provenance: "PlotX", + label: peak.label.clone(), + center_ev: peak.center_ev.value, + fwhm_ev: peak.fwhm_ev.value, + area: peak.area.value, + fraction: Some(peak.fraction.value), + r_squared: Some(fit.result.r_squared), + rmse: Some(fit.result.rmse), + residual_lag1: fit.result.residual_lag1, + hit_position_bound: Some(peak.hit_position_bound), + hit_fwhm_bound: Some(peak.hit_fwhm_bound), + hit_area_bound: Some(peak.hit_area_bound), + center_standard_error: peak.center_ev.standard_error, + center_confidence_95: peak.center_ev.confidence_95, + fwhm_standard_error: peak.fwhm_ev.standard_error, + fwhm_confidence_95: peak.fwhm_ev.confidence_95, + area_standard_error: peak.area.standard_error, + area_confidence_95: peak.area.confidence_95, + maximum_correlation, + bootstrap_center: bootstrap.map(|value| value.center_ev), + bootstrap_fwhm: bootstrap.map(|value| value.fwhm_ev), + bootstrap_area: bootstrap.map(|value| value.area), + bootstrap_fraction: bootstrap.map(|value| value.fraction), + } + }) + .collect()); + } + let imported = region + .imported_fit + .as_ref() + .ok_or(DataExportError::ContentUnavailable)?; + let total = imported.peaks.iter().map(|peak| peak.area).sum::(); + Ok(imported + .peaks + .iter() + .map(|peak| XpsFitParameterRow { + provenance: "Imported (CasaXPS)", + label: peak.label.clone(), + center_ev: peak.position_ev, + fwhm_ev: peak.fwhm_ev, + area: peak.area, + fraction: (total > 0.0).then_some(peak.area / total), + r_squared: None, + rmse: None, + residual_lag1: None, + hit_position_bound: None, + hit_fwhm_bound: None, + hit_area_bound: None, + center_standard_error: None, + center_confidence_95: None, + fwhm_standard_error: None, + fwhm_confidence_95: None, + area_standard_error: None, + area_confidence_95: None, + maximum_correlation: None, + bootstrap_center: None, + bootstrap_fwhm: None, + bootstrap_area: None, + bootstrap_fraction: None, + }) + .collect()) +} + +fn background_model_label(model: &plotx_analysis::xps::XpsBackgroundModel) -> String { + match model { + plotx_analysis::xps::XpsBackgroundModel::Linear => "Linear".into(), + plotx_analysis::xps::XpsBackgroundModel::Shirley { .. } => "Shirley".into(), + plotx_analysis::xps::XpsBackgroundModel::TougaardU2 { b_ev2, c_ev2 } => { + format!("Tougaard U2 (B={b_ev2}, C={c_ev2})") + } } } diff --git a/crates/core/src/data_export/tests.rs b/crates/core/src/data_export/tests.rs index a9b12b0..730c4d8 100644 --- a/crates/core/src/data_export/tests.rs +++ b/crates/core/src/data_export/tests.rs @@ -1,8 +1,15 @@ use super::*; use crate::state::{ - FloatSeries, PeakOrigin, StoredCurveFitAnalysis, materialized_float_series_table, + Dataset, FloatSeries, PeakOrigin, StoredCurveFitAnalysis, XpsDataset, + materialized_float_series_table, }; use crate::{BaselineMode, IntegralMethod}; +use plotx_io::xps::{ + ImportedXpsFit, ImportedXpsPeak, XpsEnergyKind, XpsExperiment, XpsMeasurement, + XpsMeasurementId, XpsRegion, XpsRegionId, +}; +use plotx_processing::xps::{XpsProcessingStep, XpsStepKind}; +use plotx_processing::{NormalizeMethod, StepId, StepSource}; fn request(content: DataExportContent) -> DataExportRequest { DataExportRequest { @@ -46,6 +53,139 @@ fn one_dimensional_channels_use_processed_complex_values() { ); } +#[test] +fn xps_export_keeps_raw_processed_fit_and_parameter_columns() { + let data = XpsDataSnapshot { + native_energy_ev: vec![1200.0, 1201.0], + binding_energy_ev: Some(vec![286.69, 285.69]), + raw_cps: vec![10.0, 20.0], + processed_energy_ev: vec![291.79, 290.79], + processed_cps: vec![0.5, 1.0], + fit_energy_ev: vec![291.79, 290.79], + background: vec![0.1, 0.1], + background_subtracted: vec![0.4, 0.9], + envelope: vec![0.5, 1.0], + residual: vec![0.0, 0.0], + components: vec![("Aromatic C".into(), vec![0.4, 0.9])], + background_model: Some("Shirley".into()), + background_window_ev: Some([290.79, 291.79]), + low_anchor_ev: Some([290.79, 290.79]), + high_anchor_ev: Some([291.79, 291.79]), + }; + let text = snapshot( + SnapshotData::Xps(Box::new(data)), + DataExportContent::ProcessedData, + ) + .to_text(Delimiter::Comma) + .unwrap(); + assert!(text.contains("background_subtracted_cps")); + assert!(text.contains("Shirley")); + + let rows = vec![XpsFitParameterRow { + provenance: "PlotX", + label: "Aromatic C".into(), + center_ev: 284.8, + fwhm_ev: 1.2, + area: 42.0, + fraction: Some(1.0), + r_squared: Some(0.999), + rmse: Some(0.01), + residual_lag1: Some(0.1), + hit_position_bound: Some(false), + hit_fwhm_bound: Some(true), + hit_area_bound: Some(false), + center_standard_error: Some(0.02), + center_confidence_95: Some([284.76, 284.84]), + fwhm_standard_error: Some(0.03), + fwhm_confidence_95: Some([1.14, 1.26]), + area_standard_error: Some(1.0), + area_confidence_95: Some([40.0, 44.0]), + maximum_correlation: Some(0.8), + bootstrap_center: Some([284.75, 284.8, 284.85]), + bootstrap_fwhm: Some([1.1, 1.2, 1.3]), + bootstrap_area: Some([39.0, 42.0, 45.0]), + bootstrap_fraction: Some([0.9, 1.0, 1.0]), + }]; + let text = snapshot(SnapshotData::XpsFits(rows), DataExportContent::CurveFits) + .to_text(Delimiter::Tab) + .unwrap(); + assert!(text.contains("position_standard_error")); + assert!( + text.contains("PlotX\tAromatic C\t284.8\t1.2\t42\t1\t0.999\t0.01\t0.1\tfalse\ttrue\tfalse") + ); +} + +#[test] +fn processed_xps_export_omits_imported_curves_after_processing() { + let measurement = XpsMeasurementId(1); + let region = XpsRegionId(1); + let intensity = vec![1.0, 2.0, 4.0, 8.0, 7.0, 4.0, 2.0, 1.0]; + let experiment = XpsExperiment { + source: "casa.txt".into(), + measurements: vec![XpsMeasurement { + id: measurement, + label: "CasaXPS export".into(), + position_mm: None, + metadata: Default::default(), + }], + regions: vec![XpsRegion { + id: region, + measurement, + name: "C 1s".into(), + native_energy_kind: XpsEnergyKind::Binding, + native_energy_ev: (283..=290).rev().map(f64::from).collect(), + binding_energy_ev: Some((283..=290).rev().map(f64::from).collect()), + intensity_cps: intensity.clone(), + counts: None, + photon_energy_ev: None, + dwell_time_s: None, + sweeps: None, + imported_fit: Some(ImportedXpsFit { + background_cps: vec![1.0; 8], + envelope_cps: intensity, + components_cps: vec![vec![0.0, 1.0, 3.0, 7.0, 6.0, 3.0, 1.0, 0.0]], + peaks: vec![ImportedXpsPeak { + label: "Imported C 1s".into(), + position_ev: 284.8, + fwhm_ev: 1.2, + area: 10.0, + lineshape: Some("GL(30)".into()), + }], + }), + metadata: Default::default(), + }], + metadata: Default::default(), + import_warnings: Vec::new(), + }; + let mut xps = XpsDataset::load(experiment); + xps.region_recipes + .get_mut(®ion) + .unwrap() + .steps + .push(XpsProcessingStep { + id: StepId::new(1), + kind: XpsStepKind::Normalize(NormalizeMethod::MaxPeak), + enabled: true, + source: StepSource::User, + }); + let dataset = Dataset::Xps(Box::new(xps)); + + let SnapshotData::Xps(snapshot) = capture_processed(&dataset).unwrap() else { + panic!("expected XPS processed-data snapshot") + }; + assert!(snapshot.envelope.is_empty()); + assert!(snapshot.residual.is_empty()); + assert!(snapshot.components.is_empty()); + assert_ne!( + snapshot.background_model.as_deref(), + Some("Imported (CasaXPS)") + ); + assert_eq!( + capture_xps_fits(dataset.as_xps().unwrap()).unwrap()[0].provenance, + "Imported (CasaXPS)" + ); +} + #[test] fn complete_table_interleaves_sigma_and_leaves_missing_values_empty() { let typed = materialized_float_series_table( diff --git a/crates/core/src/data_export/write.rs b/crates/core/src/data_export/write.rs index 16e7fd6..c83b8f5 100644 --- a/crates/core/src/data_export/write.rs +++ b/crates/core/src/data_export/write.rs @@ -1,7 +1,7 @@ //! Delimited-record serializers for each snapshot layout, plus the file-name //! sanitizer used for suggested export names. -use super::{DataExportRequest, IntensityChannel, TableShape}; +use super::{DataExportRequest, IntensityChannel, TableShape, XpsDataSnapshot, XpsFitParameterRow}; use crate::state::{PeakOrigin, ResolvedPeak, StoredCurveFitAnalysis}; use crate::{BaselineMode, Integral2D, IntegralMethod, IntegralResult}; use num_complex::Complex64; @@ -68,6 +68,192 @@ pub(super) fn write_xrd( Ok(()) } +pub(super) fn write_xps( + writer: &mut DelimitedWriter, + data: &XpsDataSnapshot, +) -> io::Result<()> { + let mut header = vec![ + Field::Text("native_energy_ev"), + Field::Text("binding_energy_ev"), + Field::Text("raw_cps"), + Field::Text("processed_energy_ev"), + Field::Text("processed_cps"), + Field::Text("fit_energy_ev"), + Field::Text("background_cps"), + Field::Text("background_subtracted_cps"), + Field::Text("envelope_cps"), + Field::Text("residual_cps"), + Field::Text("background_model"), + Field::Text("fit_window_low_ev"), + Field::Text("fit_window_high_ev"), + Field::Text("low_anchor_low_ev"), + Field::Text("low_anchor_high_ev"), + Field::Text("high_anchor_low_ev"), + Field::Text("high_anchor_high_ev"), + ]; + header.extend(data.components.iter().map(|(label, _)| Field::Text(label))); + writer.write_record(&header)?; + let rows = data + .native_energy_ev + .len() + .max(data.processed_energy_ev.len()) + .max(data.background.len()); + for row in 0..rows { + let number = |values: &[f64]| values.get(row).copied().map_or(Field::Empty, Field::Number); + let mut fields = vec![ + number(&data.native_energy_ev), + data.binding_energy_ev + .as_deref() + .map_or(Field::Empty, &number), + number(&data.raw_cps), + number(&data.processed_energy_ev), + number(&data.processed_cps), + number(&data.fit_energy_ev), + number(&data.background), + number(&data.background_subtracted), + number(&data.envelope), + number(&data.residual), + if row == 0 { + data.background_model + .as_deref() + .map_or(Field::Empty, Field::Text) + } else { + Field::Empty + }, + if row == 0 { + data.background_window_ev + .map_or(Field::Empty, |value| Field::Number(value[0])) + } else { + Field::Empty + }, + if row == 0 { + data.background_window_ev + .map_or(Field::Empty, |value| Field::Number(value[1])) + } else { + Field::Empty + }, + if row == 0 { + data.low_anchor_ev + .map_or(Field::Empty, |value| Field::Number(value[0])) + } else { + Field::Empty + }, + if row == 0 { + data.low_anchor_ev + .map_or(Field::Empty, |value| Field::Number(value[1])) + } else { + Field::Empty + }, + if row == 0 { + data.high_anchor_ev + .map_or(Field::Empty, |value| Field::Number(value[0])) + } else { + Field::Empty + }, + if row == 0 { + data.high_anchor_ev + .map_or(Field::Empty, |value| Field::Number(value[1])) + } else { + Field::Empty + }, + ]; + fields.extend(data.components.iter().map(|(_, values)| number(values))); + writer.write_record(&fields)?; + } + Ok(()) +} + +pub(super) fn write_xps_fits( + writer: &mut DelimitedWriter, + rows: &[XpsFitParameterRow], +) -> io::Result<()> { + writer.write_record(&[ + Field::Text("provenance"), + Field::Text("assignment"), + Field::Text("position_ev"), + Field::Text("fwhm_ev"), + Field::Text("area"), + Field::Text("area_fraction"), + Field::Text("r_squared"), + Field::Text("rmse"), + Field::Text("residual_lag1"), + Field::Text("position_at_bound"), + Field::Text("fwhm_at_bound"), + Field::Text("area_at_bound"), + Field::Text("position_standard_error"), + Field::Text("position_95_low_ev"), + Field::Text("position_95_high_ev"), + Field::Text("fwhm_standard_error"), + Field::Text("fwhm_95_low_ev"), + Field::Text("fwhm_95_high_ev"), + Field::Text("area_standard_error"), + Field::Text("area_95_low"), + Field::Text("area_95_high"), + Field::Text("maximum_correlation"), + Field::Text("bootstrap_position_p2_5"), + Field::Text("bootstrap_position_p50"), + Field::Text("bootstrap_position_p97_5"), + Field::Text("bootstrap_fwhm_p2_5"), + Field::Text("bootstrap_fwhm_p50"), + Field::Text("bootstrap_fwhm_p97_5"), + Field::Text("bootstrap_area_p2_5"), + Field::Text("bootstrap_area_p50"), + Field::Text("bootstrap_area_p97_5"), + Field::Text("bootstrap_fraction_p2_5"), + Field::Text("bootstrap_fraction_p50"), + Field::Text("bootstrap_fraction_p97_5"), + ])?; + for row in rows { + let boolean = |value: Option| { + value.map_or(Field::Empty, |value| { + Field::Text(if value { "true" } else { "false" }) + }) + }; + writer.write_record(&[ + Field::Text(row.provenance), + Field::Text(&row.label), + Field::Number(row.center_ev), + Field::Number(row.fwhm_ev), + Field::Number(row.area), + row.fraction.map_or(Field::Empty, Field::Number), + row.r_squared.map_or(Field::Empty, Field::Number), + row.rmse.map_or(Field::Empty, Field::Number), + row.residual_lag1.map_or(Field::Empty, Field::Number), + boolean(row.hit_position_bound), + boolean(row.hit_fwhm_bound), + boolean(row.hit_area_bound), + row.center_standard_error + .map_or(Field::Empty, Field::Number), + interval(row.center_confidence_95, 0), + interval(row.center_confidence_95, 1), + row.fwhm_standard_error.map_or(Field::Empty, Field::Number), + interval(row.fwhm_confidence_95, 0), + interval(row.fwhm_confidence_95, 1), + row.area_standard_error.map_or(Field::Empty, Field::Number), + interval(row.area_confidence_95, 0), + interval(row.area_confidence_95, 1), + row.maximum_correlation.map_or(Field::Empty, Field::Number), + interval(row.bootstrap_center, 0), + interval(row.bootstrap_center, 1), + interval(row.bootstrap_center, 2), + interval(row.bootstrap_fwhm, 0), + interval(row.bootstrap_fwhm, 1), + interval(row.bootstrap_fwhm, 2), + interval(row.bootstrap_area, 0), + interval(row.bootstrap_area, 1), + interval(row.bootstrap_area, 2), + interval(row.bootstrap_fraction, 0), + interval(row.bootstrap_fraction, 1), + interval(row.bootstrap_fraction, 2), + ])?; + } + Ok(()) +} + +fn interval(value: Option<[f64; N]>, index: usize) -> Field<'static> { + value.map_or(Field::Empty, |value| Field::Number(value[index])) +} + pub(super) fn write_true_2d( writer: &mut DelimitedWriter, spectrum: &Spectrum2D, diff --git a/crates/core/src/project/codec.rs b/crates/core/src/project/codec.rs index c5f2dfd..b4b2ae8 100644 --- a/crates/core/src/project/codec.rs +++ b/crates/core/src/project/codec.rs @@ -259,6 +259,7 @@ pub fn write_dataset_blob( DatasetBlob::Afm(data) => super::afm_convert::write_afm(zip, data), DatasetBlob::MassSpec(dataset) => super::mass_spec_convert::write(zip, dataset), DatasetBlob::Xrd(data) => super::xrd_convert::write(zip, data), + DatasetBlob::Xps(experiment) => super::xps_convert::write(zip, experiment), } } diff --git a/crates/core/src/project/convert.rs b/crates/core/src/project/convert.rs index ca30f1c..80f11b8 100644 --- a/crates/core/src/project/convert.rs +++ b/crates/core/src/project/convert.rs @@ -13,6 +13,7 @@ pub enum DatasetBlob<'a> { Afm(&'a plotx_io::AfmData), MassSpec(&'a crate::state::MassSpecDataset), Xrd(&'a plotx_io::XrdData), + Xps(&'a plotx_io::xps::XpsExperiment), } pub struct DatasetObjects<'a> { @@ -24,7 +25,7 @@ pub struct DatasetObjects<'a> { } impl<'a> DatasetObjects<'a> { - fn primary(data: DataObject, blob: DatasetBlob<'a>, recipe: RecipeObject) -> Self { + pub(super) fn primary(data: DataObject, blob: DatasetBlob<'a>, recipe: RecipeObject) -> Self { Self { data, blob, @@ -313,6 +314,7 @@ pub fn dataset_to_objects<'a>( }; DatasetObjects::primary(data, DatasetBlob::Xrd(&xrd.data), recipe) } + Dataset::Xps(xps) => super::xps_convert::to_objects(xps, data_id, recipe_id), }) } pub fn object_to_dataset( @@ -369,6 +371,9 @@ pub fn object_to_dataset( .map_err(ProjectError::Invalid)?; return Ok(dataset); } + if super::xps_convert::matches(data) { + return super::xps_convert::from_objects(zip, data, recipe); + } // Named generic decoder functions do not satisfy the higher-ranked lifetime // required by `ZipFile`; closures let the compiler reborrow each entry. #[allow(clippy::redundant_closure)] diff --git a/crates/core/src/project/convert_views.rs b/crates/core/src/project/convert_views.rs index 5a2a2a5..fada637 100644 --- a/crates/core/src/project/convert_views.rs +++ b/crates/core/src/project/convert_views.rs @@ -146,6 +146,7 @@ pub fn canvas_to_view( Dataset::Afm(_) => "heatmap", Dataset::MassSpec(_) => "line_plot", Dataset::Xrd(_) => "line_plot", + Dataset::Xps(_) => "line_plot", }; let series = plot .binding diff --git a/crates/core/src/project/mod.rs b/crates/core/src/project/mod.rs index 5c2784e..7b57dc1 100644 --- a/crates/core/src/project/mod.rs +++ b/crates/core/src/project/mod.rs @@ -41,6 +41,7 @@ mod pipeline_conv; mod scheme; mod templates; mod typed_table; +mod xps_convert; mod xrd_convert; pub use codec::*; @@ -66,6 +67,7 @@ const STORAGE_AFM_V1: &str = "plotx_afm_v1"; const STORAGE_DOSY_V1: &str = "plotx_dosy_v1"; const STORAGE_MASS_SPEC_V1: &str = "plotx_mass_spec_v1"; const STORAGE_XRD_V1: &str = "plotx_xrd_v1"; +const STORAGE_XPS: &str = "plotx_xps"; const SNAPSHOT_KIND: &str = "editable_figure_v1"; type Result = std::result::Result; @@ -744,4 +746,6 @@ mod tests; #[cfg(test)] mod tests_charts; #[cfg(test)] +mod xps_tests; +#[cfg(test)] mod xrd_tests; diff --git a/crates/core/src/project/scheme.rs b/crates/core/src/project/scheme.rs index a6e3cc5..e44cf84 100644 --- a/crates/core/src/project/scheme.rs +++ b/crates/core/src/project/scheme.rs @@ -32,8 +32,8 @@ pub enum SchemeTargetResult { /// incompatible target may be a stale index with no dataset behind it. Compatible { dataset_id: DatasetId, - before: DatasetProcessingState, - after: DatasetProcessingState, + before: Box, + after: Box, }, Incompatible { reason: String, @@ -99,8 +99,8 @@ impl SchemeApplicationPlan { applied_targets.push(target.dataset); actions.push(Action::update_dataset_processing( *dataset_id, - before.clone(), - after.clone(), + (**before).clone(), + (**after).clone(), )); } SchemeTargetResult::Incompatible { .. } => skipped_targets.push(target.dataset), @@ -141,8 +141,8 @@ pub fn plan_scheme_application( Some(target) => match apply_scheme(scheme, target) { Ok(after) => SchemeTargetResult::Compatible { dataset_id: target.resource_id(), - before: DatasetProcessingState::from_dataset(target), - after, + before: Box::new(DatasetProcessingState::from_dataset(target)), + after: Box::new(after), }, Err(error) => SchemeTargetResult::Incompatible { reason: error.to_string(), @@ -261,6 +261,9 @@ pub fn apply_scheme( Dataset::Xrd(_) => Err(incompatible( "XRD processing recipes use XRD-specific parameters", )), + Dataset::Xps(_) => Err(incompatible( + "NMR processing templates do not apply to XPS datasets", + )), } } @@ -299,6 +302,7 @@ pub fn reset_processing(dataset: &Dataset) -> Option { Dataset::Xrd(_) => Some(DatasetProcessingState::Xrd( plotx_processing::xrd::XrdProcessing::default(), )), + Dataset::Xps(_) => None, }?; let mut next = dataset_next_step_id(dataset); match &mut state { @@ -346,6 +350,7 @@ fn scheme_from_dataset(dataset: &Dataset) -> Option { Dataset::Afm(_) => None, Dataset::MassSpec(_) => None, Dataset::Xrd(_) => None, + Dataset::Xps(_) => None, } } @@ -378,8 +383,8 @@ mod plan_tests { dataset: 2, result: SchemeTargetResult::Compatible { dataset_id: DatasetId::new(), - before: state(), - after: state(), + before: Box::new(state()), + after: Box::new(state()), }, }, SchemeApplicationTarget { diff --git a/crates/core/src/project/xps_convert.rs b/crates/core/src/project/xps_convert.rs new file mode 100644 index 0000000..c70c77e --- /dev/null +++ b/crates/core/src/project/xps_convert.rs @@ -0,0 +1,274 @@ +use super::convert::{DatasetBlob, DatasetObjects}; +use super::{ + Classification, DataObject, Dataset, Payload, ProjectError, ProjectLoadLimits, RecipeObject, + RecipeParameters, Result, STORAGE_XPS, read_entry, +}; +use plotx_io::xps::XpsExperiment; +use std::fs::File; +use std::io::{Read, Write}; + +const MAGIC: &[u8; 8] = b"PLOTXXPS"; +const MAX_METADATA_BYTES: usize = 16 * 1024 * 1024; +const MAX_VALUES: usize = 16 * 1024 * 1024; + +pub(super) fn to_objects<'a>( + xps: &'a crate::state::XpsDataset, + data_id: &str, + recipe_id: &str, +) -> DatasetObjects<'a> { + let data = DataObject { + id: data_id.to_owned(), + role: "data".to_owned(), + classification: Classification { + domain: "spectroscopy".to_owned(), + technique: Some("xps".to_owned()), + object: "experiment".to_owned(), + }, + label: xps.name.clone(), + dimensions: Vec::new(), + payload: Payload { + storage: STORAGE_XPS.to_owned(), + blob: format!("objects/{data_id}/data.bin"), + shape: vec![ + xps.experiment.measurements.len(), + xps.experiment.regions.len(), + ], + domain: "binding_energy".to_owned(), + }, + extensions: serde_json::json!({ "plotx.fields": &xps.field_catalog }), + }; + let recipe = RecipeObject { + id: recipe_id.to_owned(), + role: "recipe".to_owned(), + classification: Classification { + domain: "spectroscopy".to_owned(), + technique: Some("xps".to_owned()), + object: "processing_recipe".to_owned(), + }, + input: data_id.to_owned(), + parameters: RecipeParameters::default(), + extensions: serde_json::json!({ "plotx.xps": { + "active_region": xps.active_region.0, + "measurement_shifts": &xps.measurement_shifts, + "region_recipes": &xps.region_recipes, + "fit_workspaces": &xps.fit_workspaces, + "fits": &xps.fits, + "next_step_id": xps.next_step_id + }}), + }; + DatasetObjects::primary(data, DatasetBlob::Xps(&xps.experiment), recipe) +} + +pub(super) fn matches(data: &DataObject) -> bool { + data.classification.domain == "spectroscopy" + && data.classification.technique.as_deref() == Some("xps") +} + +pub(super) fn from_objects( + zip: &mut zip::ZipArchive, + data: &DataObject, + recipe: &RecipeObject, +) -> Result { + if data.payload.storage != STORAGE_XPS { + return Err(ProjectError::Unsupported(format!( + "XPS payload storage {}", + data.payload.storage + ))); + } + let experiment: XpsExperiment = read_entry( + zip, + &data.payload.blob, + "XPS payload", + ProjectLoadLimits::default().max_entry_bytes, + |reader| read(reader), + )?; + experiment.validate().map_err(ProjectError::Invalid)?; + let mut dataset = crate::state::XpsDataset::load(experiment); + dataset.field_catalog = super::field_catalog::read(data)?; + dataset.name = data.label.clone(); + let state = recipe + .extensions + .get("plotx.xps") + .ok_or_else(|| ProjectError::Invalid("XPS recipe state is missing".into()))?; + let active = state + .get("active_region") + .and_then(serde_json::Value::as_u64) + .ok_or_else(|| ProjectError::Invalid("XPS active region is missing".into()))?; + if !dataset.select_region(plotx_io::xps::XpsRegionId(active)) { + return Err(ProjectError::Invalid( + "XPS active region does not exist".into(), + )); + } + dataset.measurement_shifts = serde_json::from_value( + state + .get("measurement_shifts") + .cloned() + .ok_or_else(|| ProjectError::Invalid("XPS measurement shifts are missing".into()))?, + )?; + dataset.region_recipes = serde_json::from_value( + state + .get("region_recipes") + .cloned() + .ok_or_else(|| ProjectError::Invalid("XPS region recipes are missing".into()))?, + )?; + dataset.fit_workspaces = serde_json::from_value( + state + .get("fit_workspaces") + .cloned() + .ok_or_else(|| ProjectError::Invalid("XPS fit workspaces are missing".into()))?, + )?; + dataset.fits = serde_json::from_value( + state + .get("fits") + .cloned() + .ok_or_else(|| ProjectError::Invalid("XPS fits are missing".into()))?, + )?; + dataset.next_step_id = state + .get("next_step_id") + .and_then(serde_json::Value::as_u64) + .ok_or_else(|| ProjectError::Invalid("XPS step allocator is missing".into()))?; + dataset + .validate_and_rehydrate_fits() + .map_err(ProjectError::Invalid)?; + let mut dataset = Dataset::Xps(Box::new(dataset)); + let processing = crate::actions::DatasetProcessingState::from_dataset(&dataset); + processing + .apply_to(&mut dataset) + .map_err(|error| ProjectError::Invalid(error.to_string()))?; + dataset + .validate_field_catalog() + .map_err(ProjectError::Invalid)?; + Ok(dataset) +} + +pub(super) fn write(mut output: impl Write, experiment: &XpsExperiment) -> Result<()> { + let mut metadata = experiment.clone(); + for region in &mut metadata.regions { + region.native_energy_ev.clear(); + region.intensity_cps.clear(); + if let Some(values) = &mut region.binding_energy_ev { + values.clear(); + } + if let Some(values) = &mut region.counts { + values.clear(); + } + if let Some(fit) = &mut region.imported_fit { + fit.background_cps.clear(); + fit.envelope_cps.clear(); + for component in &mut fit.components_cps { + component.clear(); + } + } + } + let metadata = serde_json::to_vec(&metadata)?; + if metadata.len() > MAX_METADATA_BYTES { + return Err(ProjectError::Invalid( + "XPS metadata exceeds its binary payload limit".into(), + )); + } + output.write_all(MAGIC)?; + write_len(&mut output, metadata.len())?; + output.write_all(&metadata)?; + for region in &experiment.regions { + write_values(&mut output, ®ion.native_energy_ev)?; + if let Some(values) = ®ion.binding_energy_ev { + write_values(&mut output, values)?; + } + write_values(&mut output, ®ion.intensity_cps)?; + if let Some(values) = ®ion.counts { + write_values(&mut output, values)?; + } + if let Some(fit) = ®ion.imported_fit { + write_values(&mut output, &fit.background_cps)?; + write_values(&mut output, &fit.envelope_cps)?; + for component in &fit.components_cps { + write_values(&mut output, component)?; + } + } + } + Ok(()) +} + +pub(super) fn read(mut input: impl Read) -> Result { + let mut magic = [0_u8; 8]; + input.read_exact(&mut magic)?; + if &magic != MAGIC { + return Err(ProjectError::Invalid("XPS payload magic is invalid".into())); + } + let metadata_len = read_len(&mut input, MAX_METADATA_BYTES, "metadata")?; + let mut metadata = vec![0_u8; metadata_len]; + input.read_exact(&mut metadata)?; + let mut experiment: XpsExperiment = serde_json::from_slice(&metadata)?; + for region in &mut experiment.regions { + region.native_energy_ev = read_values(&mut input, "native energy")?; + if region.binding_energy_ev.is_some() { + region.binding_energy_ev = Some(read_values(&mut input, "binding energy")?); + } + region.intensity_cps = read_values(&mut input, "intensity")?; + if region.counts.is_some() { + region.counts = Some(read_values(&mut input, "counts")?); + } + if let Some(fit) = &mut region.imported_fit { + fit.background_cps = read_values(&mut input, "imported background")?; + fit.envelope_cps = read_values(&mut input, "imported envelope")?; + for component in &mut fit.components_cps { + *component = read_values(&mut input, "imported component")?; + } + } + } + let mut trailing = [0_u8; 1]; + if input.read(&mut trailing)? != 0 { + return Err(ProjectError::Invalid( + "XPS payload has trailing bytes".into(), + )); + } + experiment.validate().map_err(ProjectError::Invalid)?; + Ok(experiment) +} + +fn write_len(output: &mut impl Write, value: usize) -> Result<()> { + let value = u64::try_from(value) + .map_err(|_| ProjectError::Invalid("XPS payload length exceeds u64".into()))?; + output.write_all(&value.to_le_bytes())?; + Ok(()) +} + +fn read_len(input: &mut impl Read, maximum: usize, label: &str) -> Result { + let mut bytes = [0_u8; 8]; + input.read_exact(&mut bytes)?; + let value = usize::try_from(u64::from_le_bytes(bytes)) + .map_err(|_| ProjectError::Invalid(format!("XPS {label} length exceeds usize")))?; + if value > maximum { + return Err(ProjectError::Invalid(format!( + "XPS {label} exceeds its payload limit" + ))); + } + Ok(value) +} + +fn write_values(output: &mut impl Write, values: &[f64]) -> Result<()> { + if values.len() > MAX_VALUES { + return Err(ProjectError::Invalid( + "XPS array exceeds its payload limit".into(), + )); + } + write_len(output, values.len())?; + for value in values { + output.write_all(&value.to_le_bytes())?; + } + Ok(()) +} + +fn read_values(input: &mut impl Read, label: &str) -> Result> { + let count = read_len(input, MAX_VALUES, label)?; + let mut values = Vec::new(); + values + .try_reserve_exact(count) + .map_err(|_| ProjectError::Invalid(format!("could not allocate XPS {label} array")))?; + let mut bytes = [0_u8; 8]; + for _ in 0..count { + input.read_exact(&mut bytes)?; + values.push(f64::from_le_bytes(bytes)); + } + Ok(values) +} diff --git a/crates/core/src/project/xps_tests.rs b/crates/core/src/project/xps_tests.rs new file mode 100644 index 0000000..b12199f --- /dev/null +++ b/crates/core/src/project/xps_tests.rs @@ -0,0 +1,222 @@ +use super::*; +use crate::state::{StoredXpsFit, XpsDataset, xps_input_sha256}; +use plotx_analysis::xps::{ + XpsBootstrapPeak, XpsBootstrapResult, XpsComponentId, XpsPeakSpec, fit_xps_peaks, +}; +use plotx_io::xps::{ + ImportedXpsFit, ImportedXpsPeak, XpsEnergyKind, XpsExperiment, XpsMeasurement, + XpsMeasurementId, XpsRegion, XpsRegionId, +}; +use plotx_processing::xps::{XpsProcessingStep, XpsStepKind}; +use plotx_processing::{NormalizeMethod, StepId, StepSource}; +use std::collections::BTreeMap; +use std::io::Read; + +#[test] +fn xps_project_roundtrip_preserves_hierarchy_recipes_and_analyses_without_schema_bump() { + let measurement = XpsMeasurementId(41); + let region_id = XpsRegionId(73); + let energy = vec![290.0, 289.0, 288.0, 287.0, 286.0, 285.0, 284.0, 283.0]; + let intensity = vec![3.0, 4.0, 6.0, 12.0, 20.0, 60.0, 18.0, 5.0]; + let imported = ImportedXpsFit { + background_cps: vec![3.0; 8], + envelope_cps: intensity.clone(), + components_cps: vec![intensity.iter().map(|value| value - 3.0).collect()], + peaks: vec![ImportedXpsPeak { + label: "C 1s".into(), + position_ev: 284.8, + fwhm_ev: 1.2, + area: 42.0, + lineshape: Some("LA(50)".into()), + }], + }; + let experiment = XpsExperiment { + source: "synthetic.vms".into(), + measurements: vec![XpsMeasurement { + id: measurement, + label: "Location 2".into(), + position_mm: Some([1.0, 2.0, 3.0]), + metadata: BTreeMap::from([("sample".into(), "reference".into())]), + }], + regions: vec![XpsRegion { + id: region_id, + measurement, + name: "C 1s".into(), + native_energy_kind: XpsEnergyKind::Binding, + native_energy_ev: energy.clone(), + binding_energy_ev: Some(energy.clone()), + intensity_cps: intensity.clone(), + counts: Some(intensity.iter().map(|value| value * 3.0).collect()), + photon_energy_ev: Some(1486.69), + dwell_time_s: Some(1.0), + sweeps: Some(3), + imported_fit: Some(imported), + metadata: BTreeMap::new(), + }], + metadata: BTreeMap::new(), + import_warnings: Vec::new(), + }; + let mut xps = XpsDataset::load(experiment); + *xps.measurement_shifts.get_mut(&measurement).unwrap() = 0.2; + let recipe = xps.region_recipes.get_mut(®ion_id).unwrap(); + recipe.steps.push(XpsProcessingStep { + id: StepId::new(9), + kind: XpsStepKind::Normalize(NormalizeMethod::MaxPeak), + enabled: true, + source: StepSource::User, + }); + xps.next_step_id = 10; + let processed = xps.processed_region(region_id).unwrap(); + let workspace = xps.fit_workspaces.get_mut(®ion_id).unwrap(); + workspace.invocation.background = + plotx_analysis::xps::XpsBackgroundSpec::suggested(&processed.binding_energy_ev).unwrap(); + workspace.invocation.peaks = vec![XpsPeakSpec::independent( + XpsComponentId::new(1), + "Aromatic C", + 285.2, + 20.0, + )]; + workspace.next_component_id = 2; + let invocation = workspace.invocation.clone(); + let result = fit_xps_peaks( + &processed.binding_energy_ev, + &processed.intensity, + &invocation, + &|| false, + ) + .unwrap(); + let expected_r_squared = result.r_squared; + let fit = StoredXpsFit { + region: region_id, + input_sha256: xps_input_sha256( + region_id, + &processed.binding_energy_ev, + &processed.intensity, + &invocation, + ), + energy_shift_ev: xps.measurement_shifts[&measurement], + processing_recipe: xps.region_recipes[®ion_id].clone(), + invocation, + result, + bootstrap: Some(XpsBootstrapResult { + requested: 100, + converged: 96, + seed: 42, + peaks: vec![XpsBootstrapPeak { + id: XpsComponentId::new(1), + center_ev: [284.9, 285.2, 285.4], + fwhm_ev: [0.9, 1.2, 1.5], + area: [18.0, 20.0, 22.0], + fraction: [1.0, 1.0, 1.0], + }], + }), + }; + xps.fits.insert(region_id, vec![fit]); + let mut app = PlotxApp::new(); + app.doc.datasets.push(Dataset::Xps(Box::new(xps))); + let mut edited = app.doc.datasets[0].as_xps().unwrap().fit_workspaces[®ion_id].clone(); + edited.invocation.peaks[0].label = "Edited assignment".into(); + app.set_xps_fit_workspace(app.doc.datasets[0].resource_id(), region_id, edited) + .unwrap(); + assert_eq!( + app.doc.datasets[0].as_xps().unwrap().fit_workspaces[®ion_id] + .invocation + .peaks[0] + .label, + "Edited assignment" + ); + app.undo(); + assert_eq!( + app.doc.datasets[0].as_xps().unwrap().fit_workspaces[®ion_id] + .invocation + .peaks[0] + .label, + "Aromatic C" + ); + app.redo(); + assert_eq!( + app.doc.datasets[0].as_xps().unwrap().fit_workspaces[®ion_id] + .invocation + .peaks[0] + .label, + "Edited assignment" + ); + app.undo(); + let path = super::tests::temp_project("xps-roundtrip"); + let _ = std::fs::remove_file(&path); + + save_project(&app, &path, false).unwrap(); + let loaded = load_project(&path).unwrap(); + let xps = loaded.doc.datasets[0].as_xps().unwrap(); + assert_eq!(xps.experiment.measurements[0].id, measurement); + assert_eq!(xps.active_region, region_id); + assert_eq!(xps.energy_shift(measurement), Some(0.2)); + assert_eq!(xps.recipe(region_id).unwrap().steps[0].id, StepId::new(9)); + assert_eq!(xps.next_step_id, 10); + assert_eq!(xps.fit_workspaces[®ion_id].next_component_id, 2); + assert_eq!( + xps.active_region().imported_fit.as_ref().unwrap().peaks[0] + .lineshape + .as_deref(), + Some("LA(50)") + ); + assert_eq!( + xps.current_fit(region_id).unwrap().result.r_squared, + expected_r_squared + ); + assert_eq!( + xps.current_fit(region_id).unwrap().result.energy_ev.len(), + processed.binding_energy_ev.len() + ); + assert_eq!( + xps.current_fit(region_id) + .unwrap() + .bootstrap + .as_ref() + .unwrap() + .seed, + 42 + ); + + let file = std::fs::File::open(&path).unwrap(); + let mut zip = zip::ZipArchive::new(file).unwrap(); + let manifest: Manifest = read_json(&mut zip, "manifest.json").unwrap(); + assert_eq!(manifest.schema_version, 1); + let recipe_path = manifest + .objects + .iter() + .find(|entry| entry.role == "recipe") + .unwrap() + .path + .clone(); + let recipe: RecipeObject = read_json(&mut zip, &recipe_path).unwrap(); + let fits = recipe.extensions["plotx.xps"]["fits"] + .as_object() + .unwrap() + .values() + .next() + .unwrap() + .as_array() + .unwrap(); + let result_fields = fits[0]["result"].as_object().unwrap(); + for curve_field in [ + "energy_ev", + "intensity", + "background", + "envelope", + "residual", + "components", + ] { + assert!(!result_fields.contains_key(curve_field)); + } + let payload = zip + .file_names() + .find(|name| name.ends_with("/data.bin")) + .unwrap() + .to_owned(); + let mut entry = zip.by_name(&payload).unwrap(); + let mut magic = [0_u8; 8]; + entry.read_exact(&mut magic).unwrap(); + assert_eq!(&magic, b"PLOTXXPS"); + std::fs::remove_file(path).unwrap(); +} diff --git a/crates/core/src/properties/group_delay.rs b/crates/core/src/properties/group_delay.rs index b119398..9e7dd7b 100644 --- a/crates/core/src/properties/group_delay.rs +++ b/crates/core/src/properties/group_delay.rs @@ -107,7 +107,8 @@ fn dataset_context<'a>( | Dataset::Electrophysiology(_) | Dataset::Afm(_) | Dataset::MassSpec(_) - | Dataset::Xrd(_) => Err(PropertyError::NotApplicable( + | Dataset::Xrd(_) + | Dataset::Xps(_) => Err(PropertyError::NotApplicable( "Group-delay correction applies only to NMR datasets.".to_owned(), )), } diff --git a/crates/core/src/properties/processing_common.rs b/crates/core/src/properties/processing_common.rs index 0b24276..1fd90e1 100644 --- a/crates/core/src/properties/processing_common.rs +++ b/crates/core/src/properties/processing_common.rs @@ -164,7 +164,8 @@ pub(super) fn raw_point_count(dataset: &Dataset, axis: PhaseAxis) -> usize { | Dataset::Electrophysiology(_) | Dataset::Afm(_) | Dataset::MassSpec(_) - | Dataset::Xrd(_) => 0, + | Dataset::Xrd(_) + | Dataset::Xps(_) => 0, } } diff --git a/crates/core/src/properties/step_enabled.rs b/crates/core/src/properties/step_enabled.rs index 152e374..bedb24e 100644 --- a/crates/core/src/properties/step_enabled.rs +++ b/crates/core/src/properties/step_enabled.rs @@ -84,7 +84,8 @@ impl PropertyProvider for StepEnabledProvider { | crate::state::Dataset::Electrophysiology(_) | crate::state::Dataset::Afm(_) | crate::state::Dataset::MassSpec(_) - | crate::state::Dataset::Xrd(_) => { + | crate::state::Dataset::Xrd(_) + | crate::state::Dataset::Xps(_) => { return Err(PropertyError::NotApplicable( "this dataset has no spectral processing pipeline".to_owned(), )); diff --git a/crates/core/src/state/app_impl.rs b/crates/core/src/state/app_impl.rs index 46c0129..99740e5 100644 --- a/crates/core/src/state/app_impl.rs +++ b/crates/core/src/state/app_impl.rs @@ -70,6 +70,7 @@ impl PlotxApp { compute: ComputeService::new(), updates: crate::update::UpdateService::new(&settings.updates), line_fit_job: None, + xps_fit_job: None, symmetry_audit_job: None, table_transform_job: None, table_refresh_job: None, diff --git a/crates/core/src/state/app_impl_analysis_tables.rs b/crates/core/src/state/app_impl_analysis_tables.rs index 3ca1d86..c4c7a97 100644 --- a/crates/core/src/state/app_impl_analysis_tables.rs +++ b/crates/core/src/state/app_impl_analysis_tables.rs @@ -229,7 +229,8 @@ impl PlotxApp { | Dataset::Table(_) | Dataset::Afm(_) | Dataset::MassSpec(_) - | Dataset::Xrd(_) => { + | Dataset::Xrd(_) + | Dataset::Xps(_) => { return Err("The selected field does not contain an ordered series.".to_owned()); } }; diff --git a/crates/core/src/state/app_impl_figures.rs b/crates/core/src/state/app_impl_figures.rs index 5d4a628..5540ca2 100644 --- a/crates/core/src/state/app_impl_figures.rs +++ b/crates/core/src/state/app_impl_figures.rs @@ -59,18 +59,7 @@ impl PlotxApp { self.build_stacked_figure(binding, stack, size_mm) } else { if let Some(series) = binding.series.first() - && self - .doc - .dataset_by_id(series.source.resource) - .and_then(|dataset| dataset.field_descriptor(series.source.field)) - .is_some_and(|field| { - field - .capabilities - .contains(crate::automation::CAP_FIELD_MASS_CHROMATOGRAM) - || field - .capabilities - .contains(crate::automation::CAP_FIELD_MASS_SPECTRUM) - }) + && self.series_uses_encoded_curve(series) { let figure = self .build_encoded_series_figure(series) @@ -304,6 +293,21 @@ impl PlotxApp { dataset.encoded_field_figure(series.source.field, &series.encoding) } + pub(super) fn series_uses_encoded_curve(&self, series: &SeriesBinding) -> bool { + self.doc + .dataset_by_id(series.source.resource) + .and_then(|dataset| dataset.field_descriptor(series.source.field)) + .is_some_and(|field| { + [ + crate::automation::CAP_FIELD_MASS_CHROMATOGRAM, + crate::automation::CAP_FIELD_MASS_SPECTRUM, + crate::automation::CAP_FIELD_XPS_SPECTRUM, + ] + .iter() + .any(|capability| field.capabilities.contains(capability)) + }) + } + /// Materialize the worker-owned grid. Only the enqueue paths call this; /// resolving against a warm geometry cache never does. fn contour_grid( diff --git a/crates/core/src/state/app_impl_xps.rs b/crates/core/src/state/app_impl_xps.rs new file mode 100644 index 0000000..911566d --- /dev/null +++ b/crates/core/src/state/app_impl_xps.rs @@ -0,0 +1,741 @@ +use super::{DatasetId, PlotxApp, StoredXpsFit, XpsFitWorkspace}; +use crate::actions::{Action, DatasetProcessingState}; +use plotx_analysis::xps::{ + XpsBootstrapOptions, XpsBootstrapResult, XpsComponentId, XpsFitError, XpsFitInvocation, + XpsFitResult, XpsPeakSpec, bootstrap_xps_fit, fit_xps_peaks, +}; +use plotx_io::xps::{XpsMeasurementId, XpsRegionId}; +use plotx_processing::StepId; +use plotx_processing::xps::{XpsProcessingStep, XpsStepKind}; +use sha2::{Digest, Sha256}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, mpsc}; +use std::time::{Duration, Instant}; + +pub struct XpsFitWorker { + dataset: DatasetId, + epoch: u64, + region: XpsRegionId, + input_sha256: String, + energy_shift_ev: f64, + processing_recipe: plotx_processing::xps::XpsProcessingRecipe, + invocation: XpsFitInvocation, + started_at: Instant, + cancel: Arc, + rx: mpsc::Receiver>, +} + +pub struct XpsBootstrapWorker { + dataset: DatasetId, + epoch: u64, + region: XpsRegionId, + input_sha256: String, + options: XpsBootstrapOptions, + started_at: Instant, + cancel: Arc, + rx: mpsc::Receiver>, +} + +pub enum XpsFitJob { + Fit(XpsFitWorker), + Bootstrap(XpsBootstrapWorker), +} + +impl XpsFitJob { + fn common(&self) -> (DatasetId, XpsRegionId, Instant, &Arc) { + match self { + Self::Fit(job) => (job.dataset, job.region, job.started_at, &job.cancel), + Self::Bootstrap(job) => (job.dataset, job.region, job.started_at, &job.cancel), + } + } +} + +impl PlotxApp { + fn edit_xps_state( + &mut self, + dataset: DatasetId, + edit: impl FnOnce(&mut DatasetProcessingState) -> Result<(), String>, + ) -> Result<(), String> { + let index = self + .doc + .dataset_index(dataset) + .ok_or_else(|| "The XPS dataset is no longer available.".to_owned())?; + let before = DatasetProcessingState::from_dataset(&self.doc.datasets[index]); + let mut after = before.clone(); + edit(&mut after)?; + self.try_execute_action(Action::update_dataset_processing(dataset, before, after)) + .map_err(|error| error.to_string()) + } + + pub fn select_xps_region( + &mut self, + dataset: DatasetId, + region: XpsRegionId, + ) -> Result<(), String> { + let index = self + .doc + .dataset_index(dataset) + .ok_or_else(|| "The XPS dataset is no longer available.".to_owned())?; + let new_field = { + let xps = self.doc.datasets[index] + .as_xps() + .ok_or_else(|| "The selected dataset is not XPS data.".to_owned())?; + if xps.region(region).is_none() { + return Err("The selected XPS region is no longer available.".into()); + } + xps.field_for_region(region) + .ok_or_else(|| "The selected XPS region has no plot field.".to_owned())? + }; + let before = DatasetProcessingState::from_dataset(&self.doc.datasets[index]); + let mut after = before.clone(); + let DatasetProcessingState::Xps { active_region, .. } = &mut after else { + return Err("The selected dataset is not XPS data.".into()); + }; + *active_region = region; + + let mut actions = vec![Action::update_dataset_processing(dataset, before, after)]; + if let Some(canvas_index) = self.session.active_canvas + && let Some(canvas) = self.doc.canvases.get(canvas_index) + && let Some(object_id) = canvas.selected_plot_object_id() + && let Some(plot) = canvas.object(object_id).and_then(|object| object.plot()) + { + let before = plot.binding.clone(); + let mut after = before.clone(); + let candidate = after + .series + .iter() + .position(|series| series.source.resource == dataset); + if let Some(series) = candidate { + after.series[series].source.field = new_field; + actions.push(Action::set_data_binding( + canvas_index, + object_id, + before, + after, + )); + } + } + self.try_execute_action(Action::Composite(actions)) + .map_err(|error| error.to_string()) + } + + pub fn set_xps_energy_shift( + &mut self, + dataset: DatasetId, + measurement: XpsMeasurementId, + shift_ev: f64, + ) -> Result<(), String> { + if !shift_ev.is_finite() { + return Err("The XPS energy shift must be finite.".into()); + } + let index = self + .doc + .dataset_index(dataset) + .ok_or_else(|| "The XPS dataset is no longer available.".to_owned())?; + let xps = self.doc.datasets[index] + .as_xps() + .ok_or_else(|| "The selected dataset is not XPS data.".to_owned())?; + let old_shift = xps + .energy_shift(measurement) + .ok_or_else(|| "The XPS measurement is no longer available.".to_owned())?; + let affected = xps + .experiment + .regions + .iter() + .filter(|region| region.measurement == measurement) + .map(|region| region.id) + .collect::>(); + let delta = shift_ev - old_shift; + self.edit_xps_state(dataset, |state| match state { + DatasetProcessingState::Xps { + measurement_shifts, + region_recipes, + fit_workspaces, + .. + } => { + *measurement_shifts + .get_mut(&measurement) + .ok_or_else(|| "The XPS measurement is no longer available.".to_owned())? = + shift_ev; + // Selection ranges stay on the same sampled points; component + // centers remain absolute chemical binding energies. + for region in affected { + if let Some(recipe) = region_recipes.get_mut(®ion) { + shift_processing_windows(recipe, delta); + } + if let Some(workspace) = fit_workspaces.get_mut(®ion) { + shift_background_ranges(workspace, delta); + } + } + Ok(()) + } + _ => Err("The selected dataset is not XPS data.".into()), + }) + } + + pub fn set_xps_fit_workspace( + &mut self, + dataset: DatasetId, + region: XpsRegionId, + workspace: XpsFitWorkspace, + ) -> Result<(), String> { + let index = self + .doc + .dataset_index(dataset) + .ok_or_else(|| "The XPS dataset is no longer available.".to_owned())?; + let before = DatasetProcessingState::from_dataset(&self.doc.datasets[index]); + let mut after = before.clone(); + match &mut after { + DatasetProcessingState::Xps { fit_workspaces, .. } => { + if !fit_workspaces.contains_key(®ion) { + return Err("This XPS region has no binding-energy fitting workspace.".into()); + } + fit_workspaces.insert(region, workspace); + } + _ => return Err("The selected dataset is not XPS data.".into()), + } + self.try_commit_processing_edit(index, before, after) + } + + pub fn add_xps_processing_step( + &mut self, + dataset: DatasetId, + region: XpsRegionId, + kind: XpsStepKind, + ) -> Result { + let mut assigned = None; + self.edit_xps_state(dataset, |state| match state { + DatasetProcessingState::Xps { + region_recipes, + next_step_id, + .. + } => { + let id = StepId::new(*next_step_id); + *next_step_id = next_step_id.saturating_add(1); + region_recipes + .get_mut(®ion) + .ok_or_else(|| "The XPS region is no longer available.".to_owned())? + .steps + .push(XpsProcessingStep { + id, + kind, + enabled: true, + source: plotx_processing::StepSource::User, + }); + assigned = Some(id); + Ok(()) + } + _ => Err("The selected dataset is not XPS data.".into()), + })?; + assigned.ok_or_else(|| "The XPS step could not be created.".into()) + } + + pub fn remove_xps_processing_step( + &mut self, + dataset: DatasetId, + region: XpsRegionId, + step: StepId, + ) -> Result<(), String> { + self.edit_xps_state(dataset, |state| match state { + DatasetProcessingState::Xps { region_recipes, .. } => { + let steps = &mut region_recipes + .get_mut(®ion) + .ok_or_else(|| "The XPS region is no longer available.".to_owned())? + .steps; + let before = steps.len(); + steps.retain(|candidate| candidate.id != step); + if steps.len() == before { + return Err("The XPS processing step is no longer available.".into()); + } + Ok(()) + } + _ => Err("The selected dataset is not XPS data.".into()), + }) + } + + pub fn set_xps_processing_step_enabled( + &mut self, + dataset: DatasetId, + region: XpsRegionId, + step: StepId, + enabled: bool, + ) -> Result<(), String> { + self.edit_xps_state(dataset, |state| match state { + DatasetProcessingState::Xps { region_recipes, .. } => { + let candidate = region_recipes + .get_mut(®ion) + .and_then(|recipe| { + recipe + .steps + .iter_mut() + .find(|candidate| candidate.id == step) + }) + .ok_or_else(|| "The XPS processing step is no longer available.".to_owned())?; + candidate.enabled = enabled; + Ok(()) + } + _ => Err("The selected dataset is not XPS data.".into()), + }) + } + + pub fn move_xps_processing_step( + &mut self, + dataset: DatasetId, + region: XpsRegionId, + step: StepId, + offset: isize, + ) -> Result<(), String> { + self.edit_xps_state(dataset, |state| match state { + DatasetProcessingState::Xps { region_recipes, .. } => { + let steps = &mut region_recipes + .get_mut(®ion) + .ok_or_else(|| "The XPS region is no longer available.".to_owned())? + .steps; + let index = steps + .iter() + .position(|candidate| candidate.id == step) + .ok_or_else(|| "The XPS processing step is no longer available.".to_owned())?; + let target = index + .saturating_add_signed(offset) + .min(steps.len().saturating_sub(1)); + if target != index { + steps.swap(index, target); + } + Ok(()) + } + _ => Err("The selected dataset is not XPS data.".into()), + }) + } + + pub fn run_xps_fit(&mut self, dataset: DatasetId, region: XpsRegionId) -> Result<(), String> { + let index = self + .doc + .dataset_index(dataset) + .ok_or_else(|| "The XPS dataset is no longer available.".to_owned())?; + let xps = self.doc.datasets[index] + .as_xps() + .ok_or_else(|| "The selected dataset is not XPS data.".to_owned())?; + let processed = xps.processed_region(region).ok_or_else(|| { + "This region has no binding-energy axis, so fitting is unavailable.".to_owned() + })?; + let measurement = xps + .region(region) + .ok_or_else(|| "The XPS region is no longer available.".to_owned())? + .measurement; + let processing_recipe = xps + .recipe(region) + .cloned() + .ok_or_else(|| "This XPS region has no processing recipe.".to_owned())?; + let energy_shift_ev = xps + .energy_shift(measurement) + .ok_or_else(|| "This XPS measurement has no energy shift.".to_owned())?; + let invocation = xps + .fit_workspaces + .get(®ion) + .ok_or_else(|| "This XPS region has no fitting workspace.".to_owned())? + .invocation + .clone(); + let input_sha256 = xps_input_sha256( + region, + &processed.binding_energy_ev, + &processed.intensity, + &invocation, + ); + let result = fit_xps_peaks( + &processed.binding_energy_ev, + &processed.intensity, + &invocation, + &|| false, + ) + .map_err(xps_fit_error)?; + let stored = StoredXpsFit { + region, + input_sha256, + energy_shift_ev, + processing_recipe, + invocation, + result, + bootstrap: None, + }; + self.edit_xps_state(dataset, |state| match state { + DatasetProcessingState::Xps { fits, .. } => { + fits.entry(region).or_default().push(stored); + Ok(()) + } + _ => Err("The selected dataset is not XPS data.".into()), + }) + } + + pub fn start_xps_fit(&mut self, dataset: DatasetId, region: XpsRegionId) -> Result<(), String> { + if self.session.xps_fit_job.is_some() { + return Err("An XPS fit is already running; cancel it or wait for completion.".into()); + } + let index = self + .doc + .dataset_index(dataset) + .ok_or_else(|| "The XPS dataset is no longer available.".to_owned())?; + let xps = self.doc.datasets[index] + .as_xps() + .ok_or_else(|| "The selected dataset is not XPS data.".to_owned())?; + let processed = xps.processed_region(region).ok_or_else(|| { + "This region has no binding-energy axis, so fitting is unavailable.".to_owned() + })?; + let measurement = xps + .region(region) + .ok_or_else(|| "The XPS region is no longer available.".to_owned())? + .measurement; + let processing_recipe = xps + .recipe(region) + .cloned() + .ok_or_else(|| "This XPS region has no processing recipe.".to_owned())?; + let energy_shift_ev = xps + .energy_shift(measurement) + .ok_or_else(|| "This XPS measurement has no energy shift.".to_owned())?; + let invocation = xps + .fit_workspaces + .get(®ion) + .ok_or_else(|| "This XPS region has no fitting workspace.".to_owned())? + .invocation + .clone(); + let input_sha256 = xps_input_sha256( + region, + &processed.binding_energy_ev, + &processed.intensity, + &invocation, + ); + let energy = processed.binding_energy_ev; + let intensity = processed.intensity; + let worker_invocation = invocation.clone(); + let cancel = Arc::new(AtomicBool::new(false)); + let worker_cancel = Arc::clone(&cancel); + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let cancelled = || worker_cancel.load(Ordering::Relaxed); + let result = fit_xps_peaks(&energy, &intensity, &worker_invocation, &cancelled); + let _ = tx.send(result); + }); + self.session.xps_fit_job = Some(XpsFitJob::Fit(XpsFitWorker { + dataset, + epoch: self.session.dataset_epoch, + region, + input_sha256, + energy_shift_ev, + processing_recipe, + invocation, + started_at: Instant::now(), + cancel, + rx, + })); + self.session.status = "Fitting XPS peaks...".into(); + Ok(()) + } + + pub fn xps_fit_progress(&self) -> Option<(DatasetId, XpsRegionId, Duration)> { + self.session.xps_fit_job.as_ref().map(|job| { + let (dataset, region, started_at, _) = job.common(); + (dataset, region, started_at.elapsed()) + }) + } + + pub fn cancel_xps_fit(&mut self) -> bool { + let Some(job) = self.session.xps_fit_job.take() else { + return false; + }; + job.common().3.store(true, Ordering::Relaxed); + self.session.status = "XPS analysis cancelled.".into(); + true + } + + pub fn poll_xps_fit(&mut self) -> bool { + let Some(job) = &self.session.xps_fit_job else { + return false; + }; + enum Completed { + Fit(Result), + Bootstrap(Result), + } + let completed = match job { + XpsFitJob::Fit(job) => match job.rx.try_recv() { + Ok(result) => Completed::Fit(result), + Err(mpsc::TryRecvError::Empty) => return true, + Err(mpsc::TryRecvError::Disconnected) => { + Completed::Fit(Err(XpsFitError::DidNotConverge)) + } + }, + XpsFitJob::Bootstrap(job) => match job.rx.try_recv() { + Ok(result) => Completed::Bootstrap(result), + Err(mpsc::TryRecvError::Empty) => return true, + Err(mpsc::TryRecvError::Disconnected) => { + Completed::Bootstrap(Err(XpsFitError::DidNotConverge)) + } + }, + }; + let job = self.session.xps_fit_job.take().expect("job checked above"); + match (job, completed) { + (XpsFitJob::Fit(job), Completed::Fit(result)) => self.finish_xps_fit(job, result), + (XpsFitJob::Bootstrap(job), Completed::Bootstrap(result)) => { + self.finish_xps_bootstrap(job, result) + } + _ => unreachable!("job type is stable while polling"), + } + true + } + + fn finish_xps_fit(&mut self, job: XpsFitWorker, result: Result) { + match result { + Err(error) => self.session.status = xps_fit_error(error), + Ok(result) => { + let current = self + .doc + .dataset_index(job.dataset) + .and_then(|index| (job.epoch == self.session.dataset_epoch).then_some(index)) + .and_then(|index| { + let xps = self.doc.datasets[index].as_xps()?; + let processed = xps.processed_region(job.region)?; + let workspace = xps.fit_workspaces.get(&job.region)?; + (xps_input_sha256( + job.region, + &processed.binding_energy_ev, + &processed.intensity, + &workspace.invocation, + ) == job.input_sha256) + .then_some(()) + }) + .is_some(); + if !current { + self.session.status = + "The XPS input changed while fitting; the result was discarded.".into(); + return; + } + let stored = StoredXpsFit { + region: job.region, + input_sha256: job.input_sha256, + energy_shift_ev: job.energy_shift_ev, + processing_recipe: job.processing_recipe, + invocation: job.invocation, + result, + bootstrap: None, + }; + let r_squared = stored.result.r_squared; + let peaks = stored.result.peaks.len(); + if let Err(error) = self.edit_xps_state(job.dataset, |state| match state { + DatasetProcessingState::Xps { fits, .. } => { + fits.entry(job.region).or_default().push(stored); + Ok(()) + } + _ => Err("The selected dataset is not XPS data.".into()), + }) { + self.session.status = error; + } else { + self.session.status = + format!("Fitted {peaks} XPS component(s), R2 = {r_squared:.5}."); + } + } + } + } + + pub fn start_xps_bootstrap( + &mut self, + dataset: DatasetId, + region: XpsRegionId, + ) -> Result<(), String> { + if self.session.xps_fit_job.is_some() { + return Err("An XPS analysis is already running.".into()); + } + let index = self + .doc + .dataset_index(dataset) + .ok_or_else(|| "The XPS dataset is no longer available.".to_owned())?; + let xps = self.doc.datasets[index] + .as_xps() + .ok_or_else(|| "The selected dataset is not XPS data.".to_owned())?; + let fit = xps + .current_fit(region) + .ok_or_else(|| "Run the current XPS fit before Bootstrap.".to_owned())?; + let workspace = xps + .fit_workspaces + .get(®ion) + .expect("current fit has workspace"); + let mut options = workspace.bootstrap.clone(); + if options.seed == 0 { + options.seed = seed_from_hash(&fit.input_sha256); + } + let base = fit.result.clone(); + let invocation = fit.invocation.clone(); + let input_sha256 = fit.input_sha256.clone(); + let worker_options = options.clone(); + let cancel = Arc::new(AtomicBool::new(false)); + let worker_cancel = Arc::clone(&cancel); + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let cancelled = || worker_cancel.load(Ordering::Relaxed); + let result = bootstrap_xps_fit(&base, &invocation, &worker_options, &cancelled); + let _ = tx.send(result); + }); + self.session.xps_fit_job = Some(XpsFitJob::Bootstrap(XpsBootstrapWorker { + dataset, + epoch: self.session.dataset_epoch, + region, + input_sha256, + options, + started_at: Instant::now(), + cancel, + rx, + })); + self.session.status = "Running XPS Bootstrap diagnostics...".into(); + Ok(()) + } + + fn finish_xps_bootstrap( + &mut self, + job: XpsBootstrapWorker, + result: Result, + ) { + let result = match result { + Ok(result) => result, + Err(error) => { + self.session.status = xps_fit_error(error); + return; + } + }; + let current = self + .doc + .dataset_index(job.dataset) + .and_then(|index| (job.epoch == self.session.dataset_epoch).then_some(index)) + .and_then(|index| self.doc.datasets[index].as_xps()) + .and_then(|xps| xps.current_fit(job.region)) + .is_some_and(|fit| fit.input_sha256 == job.input_sha256); + if !current { + self.session.status = + "The XPS fit changed during Bootstrap; the diagnostics were discarded.".into(); + return; + } + let convergence = result.convergence_fraction(); + let edit = self.edit_xps_state(job.dataset, |state| match state { + DatasetProcessingState::Xps { fits, .. } => { + let fit = fits + .get_mut(&job.region) + .and_then(|fits| { + fits.iter_mut() + .rev() + .find(|fit| fit.input_sha256 == job.input_sha256) + }) + .ok_or_else(|| "The fitted XPS result is no longer available.".to_owned())?; + fit.bootstrap = Some(result); + Ok(()) + } + _ => Err("The selected dataset is not XPS data.".into()), + }); + self.session.status = match edit { + Err(error) => error, + Ok(()) if convergence < 0.8 => format!( + "Bootstrap completed with low convergence ({:.0}% of {} runs).", + convergence * 100.0, + job.options.samples + ), + Ok(()) => format!("Bootstrap completed ({} runs).", job.options.samples), + }; + } +} + +pub fn estimate_xps_charge_shift( + energy: &[f64], + intensity: &[f64], + reference_ev: f64, +) -> Result { + plotx_processing::xps::estimate_charge_shift(energy, intensity, reference_ev) + .map_err(|message| message.to_owned()) +} + +pub fn xps_template( + region_name: &str, + intensity: &[f64], + next_component_id: &mut u64, +) -> Option> { + let normalized = region_name.to_ascii_lowercase().replace(' ', ""); + let peaks: &[(&str, f64)] = if normalized.contains("c1s") { + &[ + ("Aromatic C", 284.8), + ("C=N / C-O", 286.2), + ("O-C=O", 288.4), + ] + } else if normalized.contains("n1s") { + &[("Porphyrinic N", 398.3), ("Imine N", 399.6), ("C-N", 401.0)] + } else if normalized.contains("o1s") { + &[("Framework O", 532.0), ("Adsorbed water", 533.2)] + } else { + return None; + }; + let height = intensity + .iter() + .copied() + .filter(|value| value.is_finite()) + .fold(0.0, f64::max); + let area = height.max(1.0); + Some( + peaks + .iter() + .map(|(label, center_ev)| { + let id = XpsComponentId::new(*next_component_id); + *next_component_id = next_component_id.saturating_add(1); + XpsPeakSpec::independent(id, *label, *center_ev, area) + }) + .collect(), + ) +} + +pub fn xps_input_sha256( + region: XpsRegionId, + energy: &[f64], + intensity: &[f64], + invocation: &XpsFitInvocation, +) -> String { + let mut digest = Sha256::new(); + digest.update(region.0.to_le_bytes()); + digest.update((energy.len() as u64).to_le_bytes()); + for value in energy.iter().chain(intensity) { + digest.update(value.to_bits().to_le_bytes()); + } + digest.update( + serde_json::to_vec(invocation).expect("serializable XPS invocation has no map keys"), + ); + format!("{:x}", digest.finalize()) +} + +fn seed_from_hash(hash: &str) -> u64 { + u64::from_str_radix(hash.get(..16).unwrap_or(hash), 16).unwrap_or(1) +} + +fn shift_processing_windows(recipe: &mut plotx_processing::xps::XpsProcessingRecipe, delta: f64) { + for step in &mut recipe.steps { + if let XpsStepKind::Window { low_ev, high_ev } = &mut step.kind { + *low_ev += delta; + *high_ev += delta; + } + } +} + +fn shift_background_ranges(workspace: &mut XpsFitWorkspace, delta: f64) { + for range in [ + &mut workspace.invocation.background.window_ev, + &mut workspace.invocation.background.low_anchor_ev, + &mut workspace.invocation.background.high_anchor_ev, + ] { + range[0] += delta; + range[1] += delta; + } +} + +fn xps_fit_error(error: XpsFitError) -> String { + match error { + XpsFitError::Cancelled => "The XPS fit was cancelled.".into(), + XpsFitError::DidNotConverge => { + "The XPS fit did not converge. Review initial peaks and constraints.".into() + } + _ => error.to_string(), + } +} + +#[cfg(test)] +#[path = "app_impl_xps_tests.rs"] +mod tests; diff --git a/crates/core/src/state/app_impl_xps_tests.rs b/crates/core/src/state/app_impl_xps_tests.rs new file mode 100644 index 0000000..c37ce48 --- /dev/null +++ b/crates/core/src/state/app_impl_xps_tests.rs @@ -0,0 +1,466 @@ +use super::*; +use crate::state::{CanvasDocument, Dataset, ObjectFrame, XpsDataset}; +use plotx_analysis::xps::{ + XpsCenterConstraint, XpsComponentId, XpsFwhmConstraint, XpsPeakSpec, fit_xps_peaks, +}; +use plotx_io::Acquisition; +use plotx_io::xps::{ + ImportedXpsFit, ImportedXpsPeak, XpsEnergyKind, XpsExperiment, XpsMeasurement, + XpsMeasurementId, XpsRegion, XpsRegionId, +}; +use std::collections::BTreeMap; + +fn two_region_experiment() -> (XpsExperiment, XpsMeasurementId, XpsRegionId, XpsRegionId) { + let measurement = XpsMeasurementId(1); + let c1s = XpsRegionId(10); + let o1s = XpsRegionId(11); + let region = |id, name: &str, energy: Vec| XpsRegion { + id, + measurement, + name: name.into(), + native_energy_kind: XpsEnergyKind::Binding, + native_energy_ev: energy.clone(), + binding_energy_ev: Some(energy), + intensity_cps: vec![1.0, 2.0, 4.0, 8.0, 7.0, 4.0, 2.0, 1.0], + counts: None, + photon_energy_ev: Some(1486.69), + dwell_time_s: None, + sweeps: None, + imported_fit: None, + metadata: BTreeMap::new(), + }; + ( + XpsExperiment { + source: "multi-region.vms".into(), + measurements: vec![XpsMeasurement { + id: measurement, + label: "Location 1".into(), + position_mm: None, + metadata: BTreeMap::new(), + }], + regions: vec![ + region(c1s, "C 1s", (283..=290).rev().map(f64::from).collect()), + region(o1s, "O 1s", (531..=538).rev().map(f64::from).collect()), + ], + metadata: BTreeMap::new(), + import_warnings: Vec::new(), + }, + measurement, + c1s, + o1s, + ) +} + +#[test] +fn processing_steps_are_region_specific_while_charge_shift_is_measurement_wide() { + let (experiment, measurement, c1s, o1s) = two_region_experiment(); + let mut app = PlotxApp::new(); + let mut xps = XpsDataset::load(experiment); + let dataset = xps.resource_id; + let background_before = xps.fit_workspaces[&c1s].invocation.background.clone(); + let workspace = xps.fit_workspaces.get_mut(&c1s).unwrap(); + workspace.invocation.peaks = + xps_template("C 1s", &[1.0, 2.0], &mut workspace.next_component_id).unwrap(); + app.doc.datasets.push(Dataset::Xps(Box::new(xps))); + + app.add_xps_processing_step( + dataset, + c1s, + XpsStepKind::Window { + low_ev: 284.5, + high_ev: 288.5, + }, + ) + .unwrap(); + app.set_xps_energy_shift(dataset, measurement, 0.5).unwrap(); + + let xps = app.doc.datasets[0].as_xps().unwrap(); + assert_eq!(xps.recipe(c1s).unwrap().steps.len(), 1); + assert!(xps.recipe(o1s).unwrap().steps.is_empty()); + assert_eq!( + xps.processed_region(c1s).unwrap().binding_energy_ev.len(), + 4 + ); + assert_eq!( + xps.processed_region(o1s).unwrap().binding_energy_ev.len(), + 8 + ); + assert_eq!( + xps.processed_region(c1s).unwrap().binding_energy_ev[0], + 288.5 + ); + assert_eq!( + xps.processed_region(o1s).unwrap().binding_energy_ev[0], + 538.5 + ); + let XpsStepKind::Window { low_ev, high_ev } = xps.recipe(c1s).unwrap().steps[0].kind else { + panic!("expected an XPS processing window") + }; + assert_eq!([low_ev, high_ev], [285.0, 289.0]); + let workspace = &xps.fit_workspaces[&c1s]; + assert_eq!( + workspace.invocation.background.window_ev, + background_before.window_ev.map(|value| value + 0.5) + ); + assert!(matches!( + workspace.invocation.peaks[0].center, + XpsCenterConstraint::Free { + initial_ev: 284.8, + .. + } + )); + + app.undo(); + let xps = app.doc.datasets[0].as_xps().unwrap(); + assert_eq!(xps.energy_shift(measurement), Some(0.0)); + assert_eq!(xps.recipe(c1s).unwrap().steps.len(), 1); + let XpsStepKind::Window { low_ev, high_ev } = xps.recipe(c1s).unwrap().steps[0].kind else { + panic!("expected an XPS processing window") + }; + assert_eq!([low_ev, high_ev], [284.5, 288.5]); + assert_eq!( + xps.fit_workspaces[&c1s].invocation.background, + background_before + ); + app.undo(); + assert!( + app.doc.datasets[0] + .as_xps() + .unwrap() + .recipe(c1s) + .unwrap() + .steps + .is_empty() + ); + app.redo(); + app.redo(); + assert_eq!( + app.doc.datasets[0] + .as_xps() + .unwrap() + .energy_shift(measurement), + Some(0.5) + ); +} + +#[test] +fn imported_fit_curves_are_hidden_after_processing() { + let (mut experiment, _, c1s, _) = two_region_experiment(); + experiment.regions[0].imported_fit = Some(ImportedXpsFit { + background_cps: vec![1.0; 8], + envelope_cps: vec![1.0, 2.0, 4.0, 8.0, 7.0, 4.0, 2.0, 1.0], + components_cps: vec![vec![0.0, 1.0, 3.0, 7.0, 6.0, 3.0, 1.0, 0.0]], + peaks: vec![ImportedXpsPeak { + label: "Imported C 1s".into(), + position_ev: 284.8, + fwhm_ev: 1.2, + area: 10.0, + lineshape: Some("GL(30)".into()), + }], + }); + let mut app = PlotxApp::new(); + let xps = XpsDataset::load(experiment); + let dataset = xps.resource_id; + let field = xps.field_for_region(c1s).unwrap(); + app.doc.datasets.push(Dataset::Xps(Box::new(xps))); + + let figure = app.doc.datasets[0] + .as_xps() + .unwrap() + .field_figure(field) + .unwrap(); + assert!( + figure + .series + .iter() + .any(|series| series.name == "Imported envelope") + ); + + app.add_xps_processing_step( + dataset, + c1s, + XpsStepKind::Normalize(plotx_processing::NormalizeMethod::MaxPeak), + ) + .unwrap(); + let figure = app.doc.datasets[0] + .as_xps() + .unwrap() + .field_figure(field) + .unwrap(); + assert!( + figure + .series + .iter() + .all(|series| !series.name.starts_with("Imported")) + ); +} + +#[test] +fn selecting_region_updates_selected_chart_field_and_undoes_atomically() { + let (experiment, _, c1s, o1s) = two_region_experiment(); + let mut app = PlotxApp::new(); + let xps = XpsDataset::load(experiment); + let dataset = xps.resource_id; + let c1s_field = xps.field_for_region(c1s).unwrap(); + let o1s_field = xps.field_for_region(o1s).unwrap(); + app.doc.datasets.push(Dataset::Xps(Box::new(xps))); + let mut canvas = CanvasDocument::new("XPS".into(), [120.0, 80.0]); + let [width, height] = canvas.size_pt(); + let object = canvas.allocate_object_id(); + canvas.objects.push(app.build_plot_object( + 0, + ObjectFrame::new(0.0, 0.0, width, height), + object, + "XPS spectrum".into(), + )); + canvas.selected_object = Some(object); + app.doc.canvases.push(canvas); + app.session.active_canvas = Some(0); + + app.select_xps_region(dataset, o1s).unwrap(); + assert_eq!(app.doc.datasets[0].as_xps().unwrap().active_region, o1s); + let plot = app.doc.canvases[0].object(object).unwrap().plot().unwrap(); + assert_eq!(plot.binding.series[0].source.field, o1s_field); + assert!( + plot.figure().x.min > 500.0, + "the bound O 1s field must be rendered" + ); + + app.undo(); + assert_eq!(app.doc.datasets[0].as_xps().unwrap().active_region, c1s); + assert_eq!( + app.doc.canvases[0] + .object(object) + .unwrap() + .plot() + .unwrap() + .binding + .series[0] + .source + .field, + c1s_field + ); + app.redo(); + assert_eq!(app.doc.datasets[0].as_xps().unwrap().active_region, o1s); + assert_eq!( + app.doc.canvases[0] + .object(object) + .unwrap() + .plot() + .unwrap() + .binding + .series[0] + .source + .field, + o1s_field + ); +} + +#[test] +fn kinetic_only_region_rejects_an_empty_processing_window() { + let measurement = XpsMeasurementId(1); + let region = XpsRegionId(1); + let experiment = XpsExperiment { + source: "kinetic.vms".into(), + measurements: vec![XpsMeasurement { + id: measurement, + label: "Location 1".into(), + position_mm: None, + metadata: BTreeMap::new(), + }], + regions: vec![XpsRegion { + id: region, + measurement, + name: "Auger".into(), + native_energy_kind: XpsEnergyKind::Kinetic, + native_energy_ev: (93..=100).rev().map(f64::from).collect(), + binding_energy_ev: None, + intensity_cps: vec![1.0; 8], + counts: None, + photon_energy_ev: None, + dwell_time_s: None, + sweeps: None, + imported_fit: None, + metadata: BTreeMap::new(), + }], + metadata: BTreeMap::new(), + import_warnings: Vec::new(), + }; + let mut app = PlotxApp::new(); + let xps = XpsDataset::load(experiment); + let dataset = xps.resource_id; + app.doc.datasets.push(Dataset::Xps(Box::new(xps))); + + let error = app + .add_xps_processing_step( + dataset, + region, + XpsStepKind::Window { + low_ev: 200.0, + high_ev: 201.0, + }, + ) + .unwrap_err(); + assert!(error.contains("fewer than two points")); + assert!( + app.doc.datasets[0] + .as_xps() + .unwrap() + .recipe(region) + .unwrap() + .steps + .is_empty() + ); +} + +#[test] +#[ignore = "requires PLOTX_XPS_REFERENCE_DIR"] +fn location_two_charge_shift_is_shared_by_all_regions() { + let root = std::env::var_os("PLOTX_XPS_REFERENCE_DIR").expect("reference directory"); + let path = std::path::Path::new(&root).join("WBG250331.vms"); + let loaded = plotx_io::xps::load_vamas(&path).unwrap(); + let Acquisition::Xps(experiment) = loaded.acquisition else { + panic!("expected XPS") + }; + let measurement = experiment + .measurements + .iter() + .find(|measurement| measurement.label.ends_with(": 2")) + .unwrap() + .id; + let c1s = experiment + .regions + .iter() + .find(|region| region.measurement == measurement && region.name == "C 1s") + .unwrap(); + let shift = estimate_xps_charge_shift( + c1s.binding_energy_ev.as_deref().unwrap(), + &c1s.intensity_cps, + 284.8, + ) + .unwrap(); + // VAMAS ordinate extrema are metadata, not the first two intensity points. + // With the payload aligned to its regular ruler this reference is near +4.8 eV. + assert!((shift - 4.80).abs() < 0.15, "shift={shift}"); + + let raw = experiment + .regions + .iter() + .filter(|region| region.measurement == measurement) + .map(|region| (region.id, region.binding_energy_ev.as_ref().unwrap()[0])) + .collect::>(); + let mut app = PlotxApp::new(); + let xps = XpsDataset::load(*experiment); + let dataset = xps.resource_id; + app.doc.datasets.push(Dataset::Xps(Box::new(xps))); + app.set_xps_energy_shift(dataset, measurement, shift) + .unwrap(); + let xps = app.doc.datasets[0].as_xps().unwrap(); + for (region, original) in raw { + let processed = xps.processed_region(region).unwrap(); + assert!((processed.binding_energy_ev[0] - original - shift).abs() < 1e-10); + assert_eq!( + xps.region(region) + .unwrap() + .binding_energy_ev + .as_ref() + .unwrap()[0], + original + ); + } +} + +#[test] +fn completed_fit_is_discarded_after_workspace_changes() { + let measurement = XpsMeasurementId(1); + let region = XpsRegionId(1); + let energy = vec![290.0, 289.0, 288.0, 287.0, 286.0, 285.0, 284.0, 283.0]; + let intensity = vec![3.0, 4.0, 6.0, 12.0, 20.0, 60.0, 18.0, 5.0]; + let experiment = XpsExperiment { + source: "memory.vms".into(), + measurements: vec![XpsMeasurement { + id: measurement, + label: "Location 1".into(), + position_mm: None, + metadata: BTreeMap::new(), + }], + regions: vec![XpsRegion { + id: region, + measurement, + name: "C 1s".into(), + native_energy_kind: XpsEnergyKind::Binding, + native_energy_ev: energy.clone(), + binding_energy_ev: Some(energy), + intensity_cps: intensity, + counts: None, + photon_energy_ev: Some(1486.69), + dwell_time_s: None, + sweeps: None, + imported_fit: None, + metadata: BTreeMap::new(), + }], + metadata: BTreeMap::new(), + import_warnings: Vec::new(), + }; + let mut xps = XpsDataset::load(experiment); + let dataset = xps.resource_id; + let processed = xps.processed_region(region).unwrap(); + let workspace = xps.fit_workspaces.get_mut(®ion).unwrap(); + workspace.invocation.peaks.push(XpsPeakSpec::independent( + XpsComponentId::new(1), + "C 1s", + 285.0, + 50.0, + )); + workspace.next_component_id = 2; + let invocation = workspace.invocation.clone(); + let input_sha256 = xps_input_sha256( + region, + &processed.binding_energy_ev, + &processed.intensity, + &invocation, + ); + let result = fit_xps_peaks( + &processed.binding_energy_ev, + &processed.intensity, + &invocation, + &|| false, + ) + .unwrap(); + xps.fit_workspaces + .get_mut(®ion) + .unwrap() + .invocation + .peaks[0] + .label = "Changed while fitting".into(); + let mut app = PlotxApp::new(); + app.doc.datasets.push(Dataset::Xps(Box::new(xps))); + let (_tx, rx) = std::sync::mpsc::channel(); + let job = XpsFitWorker { + dataset, + epoch: app.session.dataset_epoch, + region, + input_sha256, + energy_shift_ev: app.doc.datasets[0].as_xps().unwrap().measurement_shifts[&measurement], + processing_recipe: app.doc.datasets[0].as_xps().unwrap().region_recipes[®ion].clone(), + invocation, + started_at: Instant::now(), + cancel: Arc::new(AtomicBool::new(false)), + rx, + }; + app.finish_xps_fit(job, Ok(result)); + assert!(app.doc.datasets[0].as_xps().unwrap().fits.is_empty()); + assert!(app.session.status.contains("discarded")); + + let mut invalid = app.doc.datasets[0].as_xps().unwrap().fit_workspaces[®ion].clone(); + invalid.invocation.peaks[0].fwhm = XpsFwhmConstraint::Fixed { value_ev: -1.0 }; + app.session.ui.proc_paused = true; + assert!(app.set_xps_fit_workspace(dataset, region, invalid).is_err()); + assert!(matches!( + app.doc.datasets[0].as_xps().unwrap().fit_workspaces[®ion] + .invocation + .peaks[0] + .fwhm, + XpsFwhmConstraint::Free { .. } + )); +} diff --git a/crates/core/src/state/charts.rs b/crates/core/src/state/charts.rs index 3ed708d..f4b557e 100644 --- a/crates/core/src/state/charts.rs +++ b/crates/core/src/state/charts.rs @@ -14,6 +14,7 @@ pub enum DataDomain { Afm, MassSpectrometry, Xrd, + Xps, } /// How a domain's datasets combine when several are stacked onto one plot: @@ -36,6 +37,7 @@ impl DataDomain { | DataDomain::Electrophysiology | DataDomain::MassSpectrometry => Some(StackKind::Line), DataDomain::Xrd => Some(StackKind::Line), + DataDomain::Xps => Some(StackKind::Line), DataDomain::Nmr2d => Some(StackKind::Field), DataDomain::PseudoNmr | DataDomain::Afm => None, } @@ -93,6 +95,17 @@ static CHART_TYPES: &[ChartDescriptor] = &[ needs_column: false, build: build_xrd_pattern, }, + ChartDescriptor { + id: "xps_spectrum", + name: "XPS spectrum", + recommended_domains: &[DataDomain::Xps], + required_capabilities: &[ + crate::automation::CAP_FIELD_CURVE_1D, + crate::automation::CAP_FIELD_XPS_SPECTRUM, + ], + needs_column: false, + build: build_xps_spectrum, + }, ChartDescriptor { id: "mass_chromatogram", name: "Mass chromatogram", @@ -409,6 +422,11 @@ fn build_xrd_pattern(dataset: &Dataset, _ctx: &ChartContext) -> Option
{ Some(dataset.as_xrd()?.figure()) } +fn build_xps_spectrum(dataset: &Dataset, _ctx: &ChartContext) -> Option
{ + let xps = dataset.as_xps()?; + xps.field_figure(xps.default_field()?) +} + fn build_mass_chromatogram(dataset: &Dataset, _ctx: &ChartContext) -> Option
{ let dataset = dataset.as_mass_spec()?; let id = dataset diff --git a/crates/core/src/state/dataset_identity.rs b/crates/core/src/state/dataset_identity.rs index 7a7c9b6..8b232f1 100644 --- a/crates/core/src/state/dataset_identity.rs +++ b/crates/core/src/state/dataset_identity.rs @@ -10,6 +10,7 @@ impl Dataset { Dataset::Afm(dataset) => dataset.resource_id, Dataset::MassSpec(dataset) => dataset.resource_id, Dataset::Xrd(dataset) => dataset.resource_id, + Dataset::Xps(dataset) => dataset.resource_id, } } @@ -22,6 +23,7 @@ impl Dataset { Dataset::Afm(dataset) => dataset.resource_id = id, Dataset::MassSpec(dataset) => dataset.resource_id = id, Dataset::Xrd(dataset) => dataset.resource_id = id, + Dataset::Xps(dataset) => dataset.resource_id = id, } } } diff --git a/crates/core/src/state/dataset_trace.rs b/crates/core/src/state/dataset_trace.rs index b215a56..f002fde 100644 --- a/crates/core/src/state/dataset_trace.rs +++ b/crates/core/src/state/dataset_trace.rs @@ -26,6 +26,7 @@ impl Dataset { Self::Afm(_) => String::new(), Self::MassSpec(_) => "min".into(), Self::Xrd(_) => "deg".into(), + Self::Xps(_) => "eV".into(), } } @@ -46,6 +47,7 @@ impl Dataset { Self::Afm(_) => false, Self::MassSpec(_) => true, Self::Xrd(_) => true, + Self::Xps(data) => data.displayed_region(data.active_region).is_some(), } } @@ -94,6 +96,14 @@ impl Dataset { ys: data.processed.intensity.clone(), x_reversed: false, }), + Self::Xps(data) => { + let processed = data.displayed_region(data.active_region)?; + Some(Trace1d { + xs: processed.binding_energy_ev, + ys: processed.intensity, + x_reversed: data.active_region().binding_energy_ev.is_some(), + }) + } } } } diff --git a/crates/core/src/state/datasets.rs b/crates/core/src/state/datasets.rs index 52cb686..10d548e 100644 --- a/crates/core/src/state/datasets.rs +++ b/crates/core/src/state/datasets.rs @@ -449,6 +449,7 @@ pub enum Dataset { Afm(Box), MassSpec(Box), Xrd(Box), + Xps(Box), } fn set_pipeline_pivot_frac(pipe: &mut AxisPipeline, frac: f64) { diff --git a/crates/core/src/state/datasets_dispatch.rs b/crates/core/src/state/datasets_dispatch.rs index 97455af..8224405 100644 --- a/crates/core/src/state/datasets_dispatch.rs +++ b/crates/core/src/state/datasets_dispatch.rs @@ -1,5 +1,19 @@ use super::*; impl Dataset { + pub fn as_xps(&self) -> Option<&XpsDataset> { + match self { + Dataset::Xps(data) => Some(data), + _ => None, + } + } + + pub fn as_xps_mut(&mut self) -> Option<&mut XpsDataset> { + match self { + Dataset::Xps(data) => Some(data), + _ => None, + } + } + pub fn as_afm(&self) -> Option<&AfmDataset> { match self { Dataset::Afm(data) => Some(data), @@ -51,6 +65,7 @@ impl Dataset { Dataset::Afm(_) => "AFM", Dataset::MassSpec(_) => "LC–MS", Dataset::Xrd(_) => "XRD", + Dataset::Xps(_) => "XPS", } } @@ -67,6 +82,7 @@ impl Dataset { Dataset::Afm(_) => DataDomain::Afm, Dataset::MassSpec(_) => DataDomain::MassSpectrometry, Dataset::Xrd(_) => DataDomain::Xrd, + Dataset::Xps(_) => DataDomain::Xps, } } @@ -81,6 +97,7 @@ impl Dataset { Dataset::Afm(d) => d.name.clone(), Dataset::MassSpec(d) => d.name.clone(), Dataset::Xrd(d) => d.name.clone(), + Dataset::Xps(d) => d.name.clone(), }; custom.unwrap_or_else(|| format!("[{}] {}", self.kind_label(), self.summary())) } @@ -94,6 +111,7 @@ impl Dataset { Dataset::Afm(d) => d.name = name, Dataset::MassSpec(d) => d.name = name, Dataset::Xrd(d) => d.name = name, + Dataset::Xps(d) => d.name = name, } } @@ -106,6 +124,7 @@ impl Dataset { Dataset::Afm(d) => d.name.clone(), Dataset::MassSpec(d) => d.name.clone(), Dataset::Xrd(d) => d.name.clone(), + Dataset::Xps(d) => d.name.clone(), } } @@ -149,6 +168,11 @@ impl Dataset { d.data.two_theta_deg.first().copied().unwrap_or(0.0), d.data.two_theta_deg.last().copied().unwrap_or(0.0) ), + Dataset::Xps(d) => format!( + "{} locations · {} regions", + d.experiment.measurements.len(), + d.experiment.regions.len() + ), } } @@ -204,6 +228,7 @@ impl Dataset { Dataset::Afm(_) => None, Dataset::MassSpec(_) => None, Dataset::Xrd(_) => None, + Dataset::Xps(_) => None, } } @@ -216,6 +241,7 @@ impl Dataset { Dataset::Afm(_) => None, Dataset::MassSpec(_) => None, Dataset::Xrd(_) => None, + Dataset::Xps(_) => None, } } @@ -229,6 +255,7 @@ impl Dataset { Dataset::Afm(_) => &[], Dataset::MassSpec(_) => &[], Dataset::Xrd(_) => &[], + Dataset::Xps(_) => &[], } } @@ -241,6 +268,7 @@ impl Dataset { Dataset::Afm(_) => None, Dataset::MassSpec(_) => None, Dataset::Xrd(_) => None, + Dataset::Xps(_) => None, } } @@ -253,6 +281,7 @@ impl Dataset { Dataset::Afm(_) => None, Dataset::MassSpec(_) => None, Dataset::Xrd(_) => None, + Dataset::Xps(_) => None, } } @@ -301,6 +330,7 @@ impl Dataset { | Dataset::Afm(_) | Dataset::MassSpec(_) | Dataset::Xrd(_) => false, + Dataset::Xps(_) => false, } } @@ -372,6 +402,7 @@ impl Dataset { Dataset::Afm(_) => &[], Dataset::MassSpec(_) => &[ToolGroup::MassSpectrometry], Dataset::Xrd(_) => &[ToolGroup::Processing], + Dataset::Xps(_) => &[ToolGroup::Xps], } } @@ -388,6 +419,7 @@ impl Dataset { Dataset::Afm(_) => &[], Dataset::MassSpec(_) => &[], Dataset::Xrd(_) => &[], + Dataset::Xps(_) => &[], } } diff --git a/crates/core/src/state/field.rs b/crates/core/src/state/field.rs index 7a6e3c0..fe601b1 100644 --- a/crates/core/src/state/field.rs +++ b/crates/core/src/state/field.rs @@ -9,11 +9,11 @@ use crate::automation::{ CAP_FIELD_FORCE_CURVE, CAP_FIELD_LOCATION_SCALE, CAP_FIELD_MASS_CHROMATOGRAM, CAP_FIELD_MASS_SPECTRUM, CAP_FIELD_NMR_CONTOUR, CAP_FIELD_NMR_SIGNAL, CAP_FIELD_NMR_STACK, CAP_FIELD_NOISE_SCALE, CAP_FIELD_REGION_SERIES, CAP_FIELD_SCALAR_GRID_2D_REGULAR, - CAP_FIELD_SIGNED, CAP_FIELD_SWEEP_COLLECTION, CAP_FIELD_TABLE, CapabilityId, + CAP_FIELD_SIGNED, CAP_FIELD_SWEEP_COLLECTION, CAP_FIELD_TABLE, CAP_FIELD_XPS_SPECTRUM, + CapabilityId, }; use plotx_figure::{ - ColorSource, ContourBasePolicy, ContourLevelSpec, ContourSpec, ContourStyle, - EstimatorSelection, HeatmapSpec, ImageSpec, LineEncoding, PositiveFiniteF64, SeriesEncoding, + ContourBasePolicy, ContourStyle, EstimatorSelection, PositiveFiniteF64, SeriesEncoding, UnitInterval, }; use std::collections::{BTreeMap, BTreeSet}; @@ -333,6 +333,32 @@ impl super::Dataset { fields } Self::Xrd(dataset) => dataset.field_descriptors(), + Self::Xps(dataset) => dataset + .experiment + .regions + .iter() + .filter_map(|region| { + let id = dataset.field_for_region(region.id)?; + let measurement = dataset + .experiment + .measurements + .iter() + .find(|candidate| candidate.id == region.measurement); + let name = measurement.map_or_else( + || region.name.clone(), + |m| format!("{} — {}", m.label, region.name), + ); + Some(descriptor( + id, + &super::xps_region_key(region.id), + &name, + capabilities(id, &[CAP_FIELD_XPS_SPECTRUM]), + vec![region.intensity_cps.len()], + vec!["eV".to_owned()], + "line", + )) + }) + .collect(), } } @@ -419,6 +445,12 @@ impl super::Dataset { | SeriesEncoding::Image(_) => None, }, Self::Xrd(dataset) => dataset.encoded_field_figure(encoding), + Self::Xps(dataset) => match encoding { + SeriesEncoding::Line(_) => dataset.field_figure(id), + SeriesEncoding::Contour(_) + | SeriesEncoding::Heatmap(_) + | SeriesEncoding::Image(_) => None, + }, } } @@ -434,7 +466,12 @@ impl super::Dataset { match self { Self::Nmr2D(nmr) => nmr.contour_figure_from_geometry(id, geometry, style), Self::Afm(afm) => afm.contour_figure_from_geometry(id, geometry, style), - _ => None, + Self::Nmr(_) + | Self::Table(_) + | Self::Electrophysiology(_) + | Self::MassSpec(_) + | Self::Xrd(_) + | Self::Xps(_) => None, } } @@ -455,6 +492,7 @@ impl super::Dataset { Self::Afm(dataset) => &dataset.field_catalog, Self::MassSpec(dataset) => &dataset.field_catalog, Self::Xrd(dataset) => &dataset.field_catalog, + Self::Xps(dataset) => &dataset.field_catalog, } } @@ -486,6 +524,12 @@ impl super::Dataset { .collect(), Self::MassSpec(dataset) => mass_spec_dataset_field_keys(dataset), Self::Xrd(_) => vec!["xrd.intensity".to_owned()], + Self::Xps(dataset) => dataset + .experiment + .regions + .iter() + .map(|region| super::xps_region_key(region.id)) + .collect(), } } } @@ -697,100 +741,6 @@ fn absolute_base(peak: PeakMagnitude<'_>) -> PositiveFiniteF64 { .unwrap_or_else(|| PositiveFiniteF64::new(1.0).expect("literal base is valid")) } -/// Materialize the complete persisted encoding for a newly created series. -/// This is the sole default-policy factory; it never dispatches on `DataDomain`. -pub fn default_encoding( - source_capabilities: &FieldCapabilities, - semantic_metadata: &FieldMetadata, - requested_chart: RequestedChart, - presentation_profile: &PresentationProfile, - peak: PeakMagnitude<'_>, -) -> SeriesEncoding { - let requested_chart = match requested_chart { - RequestedChart::Auto => presentation_profile - .preferred_encoding - .or_else(|| match semantic_metadata.recommended_encoding() { - Some("line") => Some(RequestedChart::Line), - Some("contour") => Some(RequestedChart::Contour), - Some("heatmap") => Some(RequestedChart::Heatmap), - Some("image") => Some(RequestedChart::Image), - _ => None, - }) - .unwrap_or_else(|| { - if source_capabilities.supports(&[CAP_FIELD_COLORED_RASTER_2D]) { - RequestedChart::Image - } else if source_capabilities.supports(&[CAP_FIELD_SCALAR_GRID_2D_REGULAR]) { - RequestedChart::Heatmap - } else { - RequestedChart::Line - } - }), - concrete => concrete, - }; - - match requested_chart { - RequestedChart::Contour - if source_capabilities.supports(&[CAP_FIELD_SCALAR_GRID_2D_REGULAR]) => - { - SeriesEncoding::Contour(default_contour_spec(source_capabilities, peak)) - } - RequestedChart::Heatmap - if source_capabilities.supports(&[CAP_FIELD_SCALAR_GRID_2D_REGULAR]) => - { - SeriesEncoding::Heatmap(HeatmapSpec::default()) - } - RequestedChart::Image if source_capabilities.supports(&[CAP_FIELD_COLORED_RASTER_2D]) => { - SeriesEncoding::Image(ImageSpec::default()) - } - RequestedChart::Line if source_capabilities.contains(CAP_FIELD_CURVE_1D) => { - SeriesEncoding::Line(LineEncoding::default()) - } - // A stale explicit request must still materialize to a complete, - // applicable document encoding rather than carrying Auto forward. - RequestedChart::Auto - | RequestedChart::Line - | RequestedChart::Contour - | RequestedChart::Heatmap - | RequestedChart::Image - if source_capabilities.supports(&[CAP_FIELD_COLORED_RASTER_2D]) => - { - SeriesEncoding::Image(ImageSpec::default()) - } - RequestedChart::Auto - | RequestedChart::Line - | RequestedChart::Contour - | RequestedChart::Heatmap - | RequestedChart::Image - if source_capabilities.supports(&[CAP_FIELD_SCALAR_GRID_2D_REGULAR]) => - { - SeriesEncoding::Heatmap(HeatmapSpec::default()) - } - RequestedChart::Auto - | RequestedChart::Line - | RequestedChart::Contour - | RequestedChart::Heatmap - | RequestedChart::Image => SeriesEncoding::Line(LineEncoding::default()), - } -} - -/// Pick the base policy this field's capabilities anchor best, most specific -/// first, and fall back to a peak-anchored absolute level when none of them do. -pub fn default_contour_base_kind(capabilities: &FieldCapabilities) -> &'static str { - if capabilities.contains(CAP_FIELD_NOISE_SCALE) { - CONTOUR_BASE_NOISE_FLOOR - } else if capabilities.contains(CAP_FIELD_LOCATION_SCALE) { - CONTOUR_BASE_BACKGROUND_SCALE - } else if capabilities.contains(CAP_FIELD_BOUNDED) { - CONTOUR_BASE_FRACTION_OF_RANGE - } else { - CONTOUR_BASE_ABSOLUTE - } -} - -#[path = "field_defaults.rs"] -mod defaults; -pub use defaults::default_contour_spec; - #[cfg(test)] #[path = "field_tests.rs"] mod tests; diff --git a/crates/core/src/state/field_defaults.rs b/crates/core/src/state/field_defaults.rs index 4727720..cd849a7 100644 --- a/crates/core/src/state/field_defaults.rs +++ b/crates/core/src/state/field_defaults.rs @@ -1,4 +1,102 @@ -use super::*; +use super::{ + CONTOUR_BASE_ABSOLUTE, CONTOUR_BASE_BACKGROUND_SCALE, CONTOUR_BASE_FRACTION_OF_RANGE, + CONTOUR_BASE_NOISE_FLOOR, FieldCapabilities, FieldMetadata, PeakMagnitude, PresentationProfile, + RequestedChart, contour_base_policy, +}; +use crate::automation::{ + CAP_FIELD_BOUNDED, CAP_FIELD_COLORED_RASTER_2D, CAP_FIELD_CURVE_1D, CAP_FIELD_LOCATION_SCALE, + CAP_FIELD_NOISE_SCALE, CAP_FIELD_SCALAR_GRID_2D_REGULAR, CAP_FIELD_SIGNED, +}; +use plotx_figure::{ + ColorSource, ContourLevelSpec, ContourSpec, ContourStyle, HeatmapSpec, ImageSpec, LineEncoding, + PositiveFiniteF64, SeriesEncoding, +}; + +/// Materialize the complete persisted encoding for a newly created series. +/// This is the sole default-policy factory; it never dispatches on `DataDomain`. +pub fn default_encoding( + source_capabilities: &FieldCapabilities, + semantic_metadata: &FieldMetadata, + requested_chart: RequestedChart, + presentation_profile: &PresentationProfile, + peak: PeakMagnitude<'_>, +) -> SeriesEncoding { + let requested_chart = match requested_chart { + RequestedChart::Auto => presentation_profile + .preferred_encoding + .or_else(|| match semantic_metadata.recommended_encoding() { + Some("line") => Some(RequestedChart::Line), + Some("contour") => Some(RequestedChart::Contour), + Some("heatmap") => Some(RequestedChart::Heatmap), + Some("image") => Some(RequestedChart::Image), + _ => None, + }) + .unwrap_or_else(|| { + if source_capabilities.supports(&[CAP_FIELD_COLORED_RASTER_2D]) { + RequestedChart::Image + } else if source_capabilities.supports(&[CAP_FIELD_SCALAR_GRID_2D_REGULAR]) { + RequestedChart::Heatmap + } else { + RequestedChart::Line + } + }), + concrete => concrete, + }; + + match requested_chart { + RequestedChart::Contour + if source_capabilities.supports(&[CAP_FIELD_SCALAR_GRID_2D_REGULAR]) => + { + SeriesEncoding::Contour(default_contour_spec(source_capabilities, peak)) + } + RequestedChart::Heatmap + if source_capabilities.supports(&[CAP_FIELD_SCALAR_GRID_2D_REGULAR]) => + { + SeriesEncoding::Heatmap(HeatmapSpec::default()) + } + RequestedChart::Image if source_capabilities.supports(&[CAP_FIELD_COLORED_RASTER_2D]) => { + SeriesEncoding::Image(ImageSpec::default()) + } + RequestedChart::Line if source_capabilities.contains(CAP_FIELD_CURVE_1D) => { + SeriesEncoding::Line(LineEncoding::default()) + } + RequestedChart::Auto + | RequestedChart::Line + | RequestedChart::Contour + | RequestedChart::Heatmap + | RequestedChart::Image + if source_capabilities.supports(&[CAP_FIELD_COLORED_RASTER_2D]) => + { + SeriesEncoding::Image(ImageSpec::default()) + } + RequestedChart::Auto + | RequestedChart::Line + | RequestedChart::Contour + | RequestedChart::Heatmap + | RequestedChart::Image + if source_capabilities.supports(&[CAP_FIELD_SCALAR_GRID_2D_REGULAR]) => + { + SeriesEncoding::Heatmap(HeatmapSpec::default()) + } + RequestedChart::Auto + | RequestedChart::Line + | RequestedChart::Contour + | RequestedChart::Heatmap + | RequestedChart::Image => SeriesEncoding::Line(LineEncoding::default()), + } +} + +pub fn default_contour_base_kind(capabilities: &FieldCapabilities) -> &'static str { + if capabilities.contains(CAP_FIELD_NOISE_SCALE) { + CONTOUR_BASE_NOISE_FLOOR + } else if capabilities.contains(CAP_FIELD_LOCATION_SCALE) { + CONTOUR_BASE_BACKGROUND_SCALE + } else if capabilities.contains(CAP_FIELD_BOUNDED) { + CONTOUR_BASE_FRACTION_OF_RANGE + } else { + CONTOUR_BASE_ABSOLUTE + } +} pub fn default_contour_spec( capabilities: &FieldCapabilities, diff --git a/crates/core/src/state/field_payload.rs b/crates/core/src/state/field_payload.rs index 6c7a702..5cb64c9 100644 --- a/crates/core/src/state/field_payload.rs +++ b/crates/core/src/state/field_payload.rs @@ -133,6 +133,20 @@ impl super::Dataset { }) }) } + Self::Xps(dataset) => { + let region = dataset.region_for_field(id)?; + let processed = dataset.displayed_region(region.id)?; + Some(FieldPayload::Curve1D(Curve1D { + x: Arc::from(processed.binding_energy_ev), + values: Arc::from( + processed + .intensity + .into_iter() + .map(|value| value as f32) + .collect::>(), + ), + })) + } Self::MassSpec(dataset) => dataset.field_values(id).map(|(_, _, _, points, _)| { FieldPayload::Curve1D(Curve1D { x: Arc::from(points.iter().map(|point| point[0]).collect::>()), @@ -212,6 +226,9 @@ impl super::Dataset { Self::Xrd(dataset) => { (dataset.field_id() == Some(id)).then_some(FieldRepresentation::Curve1D) } + Self::Xps(dataset) => dataset + .region_for_field(id) + .map(|_| FieldRepresentation::Curve1D), } } @@ -269,6 +286,7 @@ impl super::Dataset { version: 1, }), ), + Self::Xps(dataset) => (dataset.experiment.source.as_str(), None), }; FieldCatalog::make_provenance(source, id, algorithm) }) diff --git a/crates/core/src/state/field_tests.rs b/crates/core/src/state/field_tests.rs index dfd573f..c1625bd 100644 --- a/crates/core/src/state/field_tests.rs +++ b/crates/core/src/state/field_tests.rs @@ -1,5 +1,9 @@ use super::*; -use crate::state::{AfmDataset, Dataset, ElectrophysiologyDataset, Nmr2DDataset, ToolGroup}; +use crate::state::{ + AfmDataset, Dataset, ElectrophysiologyDataset, Nmr2DDataset, ToolGroup, default_contour_spec, + default_encoding, +}; +use plotx_figure::HeatmapSpec; use std::sync::Arc; /// Every field of every dataset variant must derive the same capabilities from diff --git a/crates/core/src/state/lineage.rs b/crates/core/src/state/lineage.rs index 41b49d7..eb9071b 100644 --- a/crates/core/src/state/lineage.rs +++ b/crates/core/src/state/lineage.rs @@ -74,6 +74,7 @@ impl Dataset { Dataset::Afm(data) => data.lineage.as_ref(), Dataset::MassSpec(data) => data.lineage.as_ref(), Dataset::Xrd(data) => data.lineage.as_ref(), + Dataset::Xps(data) => data.lineage.as_ref(), } } @@ -86,6 +87,7 @@ impl Dataset { Dataset::Afm(data) => data.lineage = lineage, Dataset::MassSpec(data) => data.lineage = lineage, Dataset::Xrd(data) => data.lineage = lineage, + Dataset::Xps(data) => data.lineage = lineage, } } } diff --git a/crates/core/src/state/mod.rs b/crates/core/src/state/mod.rs index 5372238..36a472b 100644 --- a/crates/core/src/state/mod.rs +++ b/crates/core/src/state/mod.rs @@ -43,6 +43,7 @@ mod app_impl_statistics; #[cfg(test)] mod app_impl_statistics_tests; mod app_impl_symmetry; +mod app_impl_xps; mod app_state; mod axis_overrides; mod board; @@ -63,6 +64,7 @@ mod electrophysiology; mod field; mod field_cache; mod field_catalog; +mod field_defaults; mod field_payload; mod field_runtime; mod fit_selection; @@ -105,6 +107,7 @@ mod ui_drag; mod ui_state; mod units; mod workflow_tab; +mod xps; mod xrd; pub use afm::*; @@ -112,6 +115,7 @@ pub use app_impl::*; pub use app_impl_align::*; pub use app_impl_analysis::validate_ilt_params; pub use app_impl_linefit::LineFitJob; +pub use app_impl_xps::{XpsFitJob, estimate_xps_charge_shift, xps_input_sha256, xps_template}; pub use app_state::*; pub use axis_overrides::*; pub use board::*; @@ -140,6 +144,7 @@ pub(crate) use field_catalog::{ electrophysiology_channel_keys, electrophysiology_field_catalog_for_keys, nmr_field_catalog, nmr2d_field_catalog, table_field_catalog, }; +pub use field_defaults::*; pub(crate) use field_payload::nmr_scalar_grid; pub use field_runtime::*; pub use identity::*; @@ -175,6 +180,7 @@ pub use ui_drag::*; pub use ui_state::*; pub use units::*; pub use workflow_tab::WorkflowTab; +pub use xps::*; pub use xrd::*; /// Points per millimetre (72 pt/inch ÷ 25.4 mm/inch), for sizing print figures. diff --git a/crates/core/src/state/stack.rs b/crates/core/src/state/stack.rs index 40c7d42..a9e07aa 100644 --- a/crates/core/src/state/stack.rs +++ b/crates/core/src/state/stack.rs @@ -60,19 +60,10 @@ impl PlotxApp { let domain = self.doc.datasets[primary].domain(); let line_chart = ChartSpec::default_for(domain); // The primary's line figure supplies the axis labels and orientation. - let primary_is_encoded_curve = binding.series.first().is_some_and(|series| { - self.doc - .dataset_by_id(series.source.resource) - .and_then(|dataset| dataset.field_descriptor(series.source.field)) - .is_some_and(|field| { - field - .capabilities - .contains(crate::automation::CAP_FIELD_MASS_CHROMATOGRAM) - || field - .capabilities - .contains(crate::automation::CAP_FIELD_MASS_SPECTRUM) - }) - }); + let primary_is_encoded_curve = binding + .series + .first() + .is_some_and(|series| self.series_uses_encoded_curve(series)); let mut fig = if primary_is_encoded_curve { binding .series @@ -98,18 +89,7 @@ impl PlotxApp { if !sb.visible { continue; } - let encoded_curve = self - .doc - .dataset_by_id(sb.source.resource) - .and_then(|dataset| dataset.field_descriptor(sb.source.field)) - .is_some_and(|field| { - field - .capabilities - .contains(crate::automation::CAP_FIELD_MASS_CHROMATOGRAM) - || field - .capabilities - .contains(crate::automation::CAP_FIELD_MASS_SPECTRUM) - }); + let encoded_curve = self.series_uses_encoded_curve(sb); let part = if encoded_curve { self.build_encoded_series_figure(sb) .map(|figure| self.normalize_binding_figure(figure, size_mm)) diff --git a/crates/core/src/state/ui_state.rs b/crates/core/src/state/ui_state.rs index d53c595..351d67c 100644 --- a/crates/core/src/state/ui_state.rs +++ b/crates/core/src/state/ui_state.rs @@ -5,6 +5,10 @@ use std::collections::HashSet; use std::ops::{Deref, DerefMut}; use std::sync::Arc; +#[path = "ui_state_xps.rs"] +mod xps; +pub use xps::{PropertyTextEditState, XpsWorkbenchTab}; + mod task_dock; pub use task_dock::TaskDockTab; @@ -266,21 +270,13 @@ impl PropertyFocus { } } -/// Persistent buffer for one catalog text control. It is keyed by the exact -/// target selection so changing objects cannot carry uncommitted text across. -pub struct PropertyTextEditState { - pub property: crate::properties::PropertyId, - pub targets: Vec, - pub text: String, - pub editing: bool, -} - pub struct UiState { /// The single in-flight direct-manipulation gesture; see [`Interaction`]. pub interaction: Interaction, /// 2D axis targeted by the Phase panel and canvas drag; re-clamped when rendered. pub phase_axis: PhaseAxis, pub analysis_selection: Option, + pub xps_workbench_tab: XpsWorkbenchTab, /// Which table column the Peaks tool targets (ignored by single-trace domains). pub peak_column: Option, pub wheel_zoom: Option, @@ -495,6 +491,7 @@ impl Default for UiState { interaction: Interaction::Idle, phase_axis: PhaseAxis::F2, analysis_selection: None, + xps_workbench_tab: XpsWorkbenchTab::default(), peak_column: None, wheel_zoom: None, wheel_property: None, @@ -681,6 +678,7 @@ pub struct Session { /// Background update checker/downloader. Not serialized. pub updates: crate::update::UpdateService, pub line_fit_job: Option, + pub xps_fit_job: Option, pub symmetry_audit_job: Option, pub table_transform_job: Option, pub table_refresh_job: Option, diff --git a/crates/core/src/state/ui_state_xps.rs b/crates/core/src/state/ui_state_xps.rs new file mode 100644 index 0000000..06ea1b3 --- /dev/null +++ b/crates/core/src/state/ui_state_xps.rs @@ -0,0 +1,17 @@ +/// Persistent buffer for one catalog text control. It is keyed by the exact +/// target selection so changing objects cannot carry uncommitted text across. +pub struct PropertyTextEditState { + pub property: crate::properties::PropertyId, + pub targets: Vec, + pub text: String, + pub editing: bool, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum XpsWorkbenchTab { + #[default] + Acquisition, + Background, + Components, + Diagnostics, +} diff --git a/crates/core/src/state/units.rs b/crates/core/src/state/units.rs index b4df0c2..d83a310 100644 --- a/crates/core/src/state/units.rs +++ b/crates/core/src/state/units.rs @@ -286,6 +286,7 @@ pub enum ToolGroup { LineFit, Statistics, Electrophysiology, + Xps, } impl ToolGroup { @@ -301,6 +302,7 @@ impl ToolGroup { ToolGroup::LineFit => "Peak Fit", ToolGroup::Statistics => "Statistics", ToolGroup::Electrophysiology => "Patch clamp", + ToolGroup::Xps => "XPS", } } } diff --git a/crates/core/src/state/xps.rs b/crates/core/src/state/xps.rs new file mode 100644 index 0000000..9036748 --- /dev/null +++ b/crates/core/src/state/xps.rs @@ -0,0 +1,483 @@ +use super::{DatasetId, DatasetLineage, FieldCatalog, FieldId, OVERLAY_PALETTE}; +use plotx_figure::{Axis, Color, Figure, Series}; +use plotx_io::xps::{ImportedXpsFit, XpsExperiment, XpsMeasurementId, XpsRegion, XpsRegionId}; +use plotx_processing::xps::{ProcessedXpsRegion, XpsProcessingRecipe, process_region}; +use std::collections::BTreeMap; +use std::sync::Arc; + +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct XpsFitWorkspace { + pub invocation: plotx_analysis::xps::XpsFitInvocation, + pub next_component_id: u64, + /// A zero seed requests deterministic derivation from the fit input hash. + pub bootstrap: plotx_analysis::xps::XpsBootstrapOptions, +} + +impl XpsFitWorkspace { + fn suggested(energy: &[f64]) -> Option { + Some(Self { + invocation: plotx_analysis::xps::XpsFitInvocation { + background: plotx_analysis::xps::XpsBackgroundSpec::suggested(energy)?, + peaks: Vec::new(), + options: plotx_analysis::xps::XpsFitOptions::default(), + }, + next_component_id: 1, + bootstrap: plotx_analysis::xps::XpsBootstrapOptions { + samples: 500, + seed: 0, + }, + }) + } +} + +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct StoredXpsFit { + pub region: XpsRegionId, + pub input_sha256: String, + pub energy_shift_ev: f64, + pub processing_recipe: XpsProcessingRecipe, + pub invocation: plotx_analysis::xps::XpsFitInvocation, + pub result: plotx_analysis::xps::XpsFitResult, + pub bootstrap: Option, +} + +#[derive(Clone)] +pub struct XpsDataset { + pub resource_id: DatasetId, + pub field_catalog: FieldCatalog, + pub experiment: Arc, + pub name: Option, + pub lineage: Option, + pub active_region: XpsRegionId, + pub measurement_shifts: BTreeMap, + pub region_recipes: BTreeMap, + pub fit_workspaces: BTreeMap, + pub fits: BTreeMap>, + pub next_step_id: u64, +} + +impl XpsDataset { + pub fn load(experiment: XpsExperiment) -> Self { + let active_region = experiment + .regions + .iter() + .find(|region| region.name.eq_ignore_ascii_case("survey")) + .or_else(|| experiment.regions.first()) + .expect("validated XPS experiment has a region") + .id; + let mut field_catalog = FieldCatalog::for_keys( + experiment + .regions + .iter() + .map(|region| xps_region_key(region.id)), + ); + field_catalog.attach_provenance(&experiment.source, None); + let measurement_shifts = experiment + .measurements + .iter() + .map(|measurement| (measurement.id, 0.0)) + .collect(); + let region_recipes = experiment + .regions + .iter() + .map(|region| (region.id, XpsProcessingRecipe::default())) + .collect(); + let fit_workspaces = experiment + .regions + .iter() + .filter_map(|region| { + let energy = region.binding_energy_ev.as_deref()?; + Some((region.id, XpsFitWorkspace::suggested(energy)?)) + }) + .collect(); + Self { + resource_id: DatasetId::new(), + field_catalog, + experiment: Arc::new(experiment), + name: None, + lineage: None, + active_region, + measurement_shifts, + region_recipes, + fit_workspaces, + fits: BTreeMap::new(), + next_step_id: 1, + } + } + + pub fn active_region(&self) -> &XpsRegion { + self.region(self.active_region) + .expect("active XPS region identity is valid") + } + + pub fn region(&self, id: XpsRegionId) -> Option<&XpsRegion> { + self.experiment + .regions + .iter() + .find(|region| region.id == id) + } + + pub fn select_region(&mut self, id: XpsRegionId) -> bool { + if self.region(id).is_none() { + return false; + } + self.active_region = id; + true + } + + pub fn energy_shift(&self, measurement: XpsMeasurementId) -> Option { + self.measurement_shifts.get(&measurement).copied() + } + + pub fn recipe(&self, region: XpsRegionId) -> Option<&XpsProcessingRecipe> { + self.region_recipes.get(®ion) + } + + pub fn processed_region(&self, id: XpsRegionId) -> Option { + let region = self.region(id)?; + let binding = region.binding_energy_ev.as_ref()?; + let shift = self.energy_shift(region.measurement)?; + let recipe = self.recipe(id)?; + process_region(binding, ®ion.intensity_cps, shift, recipe).ok() + } + + pub fn displayed_region(&self, id: XpsRegionId) -> Option { + let region = self.region(id)?; + if region.binding_energy_ev.is_some() { + return self.processed_region(id); + } + let recipe = self.recipe(id)?; + process_region(®ion.native_energy_ev, ®ion.intensity_cps, 0.0, recipe).ok() + } + + pub(crate) fn imported_fit_for_processed_region( + &self, + id: XpsRegionId, + ) -> Option<&ImportedXpsFit> { + let recipe = self.recipe(id)?; + if recipe.steps.iter().any(|step| step.enabled) { + return None; + } + self.region(id)?.imported_fit.as_ref() + } + + pub fn field_for_region(&self, id: XpsRegionId) -> Option { + self.field_catalog.id_for_key(&xps_region_key(id)) + } + + pub fn region_for_field(&self, field: FieldId) -> Option<&XpsRegion> { + self.experiment + .regions + .iter() + .find(|region| self.field_for_region(region.id) == Some(field)) + } + + pub fn field_figure(&self, field: FieldId) -> Option
{ + let region = self.region_for_field(field)?; + let processed = self.displayed_region(region.id)?; + let points = processed + .binding_energy_ev + .iter() + .copied() + .zip(processed.intensity.iter().copied()) + .map(|(x, y)| [x, y]) + .collect::>(); + let (xmin, xmax) = extent(&processed.binding_energy_ev)?; + let (ymin, ymax) = extent(&processed.intensity)?; + let measurement = self + .experiment + .measurements + .iter() + .find(|candidate| candidate.id == region.measurement); + let title = measurement.map_or_else( + || region.name.clone(), + |m| format!("{} — {}", m.label, region.name), + ); + let normalized = self.recipe(region.id).is_some_and(|recipe| { + recipe.steps.iter().any(|step| { + step.enabled + && matches!(step.kind, plotx_processing::xps::XpsStepKind::Normalize(_)) + }) + }); + let intensity_label = if normalized { + "Normalized intensity" + } else { + "Intensity (CPS)" + }; + let energy_label = if region.binding_energy_ev.is_some() { + "Binding energy (eV)" + } else { + "Kinetic energy (eV)" + }; + let mut figure = Figure::new( + title.clone(), + Axis::new(energy_label, xmin, xmax).reversed(region.binding_energy_ev.is_some()), + Axis::new(intensity_label, ymin, ymax), + ) + .with_series(Series::line(title, points).colored(OVERLAY_PALETTE[0])); + if let Some(fit) = self.current_fit(region.id) { + let mut background = Series::line( + "Fit background", + curve_points(&fit.result.energy_ev, &fit.result.background), + ) + .colored(Color::rgb(0x6b, 0x70, 0x75)); + background.width = 0.8; + figure.series.push(background); + let mut envelope = Series::line( + "Fit envelope", + curve_points(&fit.result.energy_ev, &fit.result.envelope), + ) + .colored(OVERLAY_PALETTE[1]); + envelope.width = 1.6; + figure.series.push(envelope); + for (index, component) in fit.result.components.iter().enumerate() { + let label = fit.result.peaks.get(index).map_or_else( + || format!("Component {}", index + 1), + |peak| peak.label.clone(), + ); + let mut series = Series::line( + label, + fit.result + .energy_ev + .iter() + .copied() + .zip( + component + .iter() + .zip(&fit.result.background) + .map(|(value, bg)| value + bg), + ) + .map(|(x, y)| [x, y]) + .collect(), + ) + .colored(OVERLAY_PALETTE[(index + 2) % OVERLAY_PALETTE.len()]); + series.width = 0.8; + figure.series.push(series); + } + let mut residual = Series::line( + "Residual", + curve_points(&fit.result.energy_ev, &fit.result.residual), + ) + .colored(OVERLAY_PALETTE[7]); + residual.width = 0.7; + figure.series.push(residual); + } else { + if let Some(imported) = self.imported_fit_for_processed_region(region.id) { + let shifted = self.energy_shift(region.measurement).unwrap_or(0.0); + let energy = region + .binding_energy_ev + .as_ref()? + .iter() + .map(|value| value + shifted) + .collect::>(); + figure.series.push( + Series::line( + "Imported background", + curve_points(&energy, &imported.background_cps), + ) + .colored(Color::rgb(0x6b, 0x70, 0x75)), + ); + figure.series.push( + Series::line( + "Imported envelope", + curve_points(&energy, &imported.envelope_cps), + ) + .colored(OVERLAY_PALETTE[1]), + ); + for (index, component) in imported.components_cps.iter().enumerate() { + let label = imported.peaks.get(index).map_or_else( + || format!("Imported component {}", index + 1), + |peak| peak.label.clone(), + ); + figure.series.push( + Series::line(label, curve_points(&energy, component)) + .colored(OVERLAY_PALETTE[(index + 2) % OVERLAY_PALETTE.len()]), + ); + } + } + if let Some(workspace) = self.fit_workspaces.get(®ion.id) + && let Ok(preview) = plotx_analysis::xps::compute_xps_background( + &processed.binding_energy_ev, + &processed.intensity, + &workspace.invocation.background, + ) + { + figure.series.push( + Series::line( + "Background preview", + curve_points(&preview.energy_ev, &preview.background), + ) + .colored(Color::rgb(0x6b, 0x70, 0x75)), + ); + figure.series.push( + Series::line( + "Background-subtracted preview", + curve_points(&preview.energy_ev, &preview.corrected), + ) + .colored(OVERLAY_PALETTE[1]), + ); + } + } + figure.series_colors_are_semantic = figure.series.len() > 1; + Some(figure) + } + + pub fn default_field(&self) -> Option { + self.field_for_region(self.active_region) + } + + pub fn current_fit(&self, region: XpsRegionId) -> Option<&StoredXpsFit> { + let processed = self.processed_region(region)?; + let workspace = self.fit_workspaces.get(®ion)?; + let hash = super::xps_input_sha256( + region, + &processed.binding_energy_ev, + &processed.intensity, + &workspace.invocation, + ); + self.fits + .get(®ion)? + .iter() + .rev() + .find(|fit| fit.input_sha256 == hash) + } + + pub fn latest_fit(&self, region: XpsRegionId) -> Option<&StoredXpsFit> { + self.fits.get(®ion)?.last() + } + + pub(crate) fn validate_and_rehydrate_fits(&mut self) -> Result<(), String> { + let region_ids = self.fits.keys().copied().collect::>(); + for region_id in region_ids { + let region = self + .region(region_id) + .ok_or_else(|| format!("fit history references missing region {}", region_id.0))?; + if region.binding_energy_ev.is_none() { + return Err(format!( + "kinetic-only region {} cannot contain PlotX fits", + region_id.0 + )); + } + let binding = region + .binding_energy_ev + .as_deref() + .expect("checked above") + .to_vec(); + let intensity = region.intensity_cps.clone(); + let fits = self + .fits + .get_mut(®ion_id) + .expect("region ID came from fit map"); + for fit in fits { + if fit.region != region_id + || fit.input_sha256.len() != 64 + || !fit + .input_sha256 + .bytes() + .all(|byte| byte.is_ascii_hexdigit()) + { + return Err(format!("region {} has invalid fit provenance", region_id.0)); + } + plotx_analysis::xps::validate_xps_fit_summary(&fit.invocation, &fit.result) + .map_err(|error| format!("region {} has invalid fit: {error}", region_id.0))?; + validate_bootstrap(fit)?; + let processed = process_region( + &binding, + &intensity, + fit.energy_shift_ev, + &fit.processing_recipe, + ) + .map_err(|error| { + format!("region {} fit recipe is invalid: {error}", region_id.0) + })?; + let hash = super::xps_input_sha256( + region_id, + &processed.binding_energy_ev, + &processed.intensity, + &fit.invocation, + ); + if hash != fit.input_sha256 { + return Err(format!( + "region {} fit provenance hash does not match its inputs", + region_id.0 + )); + } + fit.result.energy_ev.clear(); + fit.result.intensity.clear(); + fit.result.background.clear(); + fit.result.envelope.clear(); + fit.result.residual.clear(); + fit.result.components.clear(); + plotx_analysis::xps::rebuild_xps_fit_curves( + &processed.binding_energy_ev, + &processed.intensity, + &fit.invocation, + &mut fit.result, + ) + .map_err(|error| { + format!( + "region {} fit curves cannot be rebuilt: {error}", + region_id.0 + ) + })?; + } + } + Ok(()) + } +} + +fn validate_bootstrap(fit: &StoredXpsFit) -> Result<(), String> { + let Some(bootstrap) = &fit.bootstrap else { + return Ok(()); + }; + if !(100..=5_000).contains(&bootstrap.requested) + || bootstrap.converged == 0 + || bootstrap.converged > bootstrap.requested + || bootstrap.peaks.len() != fit.result.peaks.len() + { + return Err("XPS Bootstrap summary is invalid".into()); + } + for (peak, expected) in bootstrap.peaks.iter().zip(&fit.result.peaks) { + let intervals = [peak.center_ev, peak.fwhm_ev, peak.area, peak.fraction]; + if peak.id != expected.id + || intervals.iter().any(|interval| { + interval.iter().any(|value| !value.is_finite()) + || interval[0] > interval[1] + || interval[1] > interval[2] + }) + { + return Err("XPS Bootstrap peak summary is invalid".into()); + } + } + Ok(()) +} + +fn curve_points(x: &[f64], y: &[f64]) -> Vec<[f64; 2]> { + x.iter() + .copied() + .zip(y.iter().copied()) + .map(|(x, y)| [x, y]) + .collect() +} + +fn extent(values: &[f64]) -> Option<(f64, f64)> { + let min = values + .iter() + .copied() + .filter(|v| v.is_finite()) + .reduce(f64::min)?; + let max = values + .iter() + .copied() + .filter(|v| v.is_finite()) + .reduce(f64::max)?; + Some(if min == max { + (min - 0.5, max + 0.5) + } else { + (min, max) + }) +} + +pub(crate) fn xps_region_key(id: XpsRegionId) -> String { + format!("xps.region.{}", id.0) +} diff --git a/crates/core/src/workflow.rs b/crates/core/src/workflow.rs index e3ba23e..64005ac 100644 --- a/crates/core/src/workflow.rs +++ b/crates/core/src/workflow.rs @@ -15,6 +15,8 @@ use std::path::{Path, PathBuf}; use std::time::Duration; #[path = "workflow/mass_spec_layout.rs"] mod mass_spec_layout; +#[path = "workflow/xps.rs"] +mod xps; pub const INSPECTION_SCHEMA: &str = "plotx.inspect.v1"; #[derive(Clone, Debug, Serialize)] pub struct InspectionReport { @@ -32,6 +34,8 @@ pub struct InspectionReport { pub mass_spectrometry: Option, #[serde(skip_serializing_if = "Option::is_none")] pub xrd: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub xps: Option, } #[derive(Clone, Debug, Serialize)] @@ -43,6 +47,16 @@ pub struct XrdReport { pub point_count: usize, } +#[derive(Clone, Debug, Serialize)] +pub struct XpsReport { + pub measurement_count: usize, + pub region_count: usize, + pub point_count: usize, + pub binding_energy_region_count: usize, + pub kinetic_only_region_count: usize, + pub regions: Vec, +} + #[derive(Clone, Debug, Serialize)] pub struct MassSpecReport { pub instrument: Option, @@ -263,6 +277,13 @@ pub fn dataset_from_acquisition_with_equal_scale_preference( source, ) } + Acquisition::Xps(data) => { + let source = data.source.clone(); + ( + Dataset::Xps(Box::new(crate::state::XpsDataset::load(*data))), + source, + ) + } } } @@ -293,6 +314,10 @@ pub fn dataset_title(dataset: &Dataset) -> String { .name .clone() .unwrap_or_else(|| short_name(&data.data.source)), + Dataset::Xps(data) => data + .name + .clone() + .unwrap_or_else(|| short_name(&data.experiment.source)), } } @@ -550,6 +575,7 @@ fn inspection_report( afm: None, mass_spectrometry: None, xrd: None, + xps: None, }; } Acquisition::Afm(data) => { @@ -588,6 +614,7 @@ fn inspection_report( }), mass_spectrometry: None, xrd: None, + xps: None, }; } Acquisition::MassSpec(run) => { @@ -625,6 +652,7 @@ fn inspection_report( .collect(), }), xrd: None, + xps: None, }; } Acquisition::Xrd(data) => { @@ -656,8 +684,12 @@ fn inspection_report( ], point_count: data.len(), }), + xps: None, }; } + Acquisition::Xps(experiment) => { + return xps::inspection_report(format, provenance, warnings, experiment); + } }; InspectionReport { schema: INSPECTION_SCHEMA, @@ -675,10 +707,11 @@ fn inspection_report( afm: None, mass_spectrometry: None, xrd: None, + xps: None, } } -fn warning_report(warning: &LoadWarning) -> WarningReport { +pub(super) fn warning_report(warning: &LoadWarning) -> WarningReport { let code = match warning.code { LoadWarningCode::ArchiveEntryFailed => "archive-entry-failed", LoadWarningCode::OptionalImaginaryMissing => "optional-imaginary-missing", @@ -713,87 +746,5 @@ fn short_name(source: &str) -> String { } #[cfg(test)] -mod tests { - use super::*; - use num_complex::Complex64; - - fn acquisition() -> Acquisition { - Acquisition::D1(plotx_io::NmrData { - points: vec![Complex64::new(1.0, 0.0); 8], - domain: Domain::Frequency, - spectral_width_hz: 4_000.0, - observe_freq_mhz: 400.0, - carrier_ppm: 4.7, - nucleus: "1H".to_owned(), - source: "sample.dx".to_owned(), - group_delay: 0.0, - }) - } - - fn homonuclear_2d_acquisition() -> Acquisition { - let dimension = plotx_io::Dim { - spectral_width_hz: 4_000.0, - observe_freq_mhz: 400.0, - carrier_ppm: 4.7, - nucleus: "1H".to_owned(), - group_delay: 0.0, - }; - Acquisition::D2(Box::new(plotx_io::NmrData2D { - data: vec![Complex64::new(1.0, 0.0); 16], - rows: 4, - cols: 4, - domain: Domain::Frequency, - direct: dimension.clone(), - indirect: dimension, - quad: plotx_io::QuadMode::Complex, - indirect_conjugate: false, - experiment: Some("cosy".to_owned()), - pseudo_axis: None, - diffusion: None, - nus: None, - source: "cosy".to_owned(), - })) - } - - #[test] - fn canonical_conversion_and_default_canvas_share_dataset_identity() { - let (dataset, source) = dataset_from_acquisition(acquisition()); - assert_eq!(dataset.kind_label(), "NMR 1D"); - let canvas = build_default_canvas(&dataset, &source); - assert_eq!(canvas.dataset_ids(), vec![dataset.resource_id()]); - assert_eq!(canvas.objects.len(), 1); - } - - #[test] - fn import_preference_seeds_one_persistent_plot_override() { - for (preference, expected) in [(true, true), (false, false)] { - let (dataset, source) = dataset_from_acquisition_with_equal_scale_preference( - homonuclear_2d_acquisition(), - preference, - ); - let canvas = build_default_canvas(&dataset, &source); - let plot = canvas.objects[0].plot().expect("default plot"); - assert_eq!(plot.axis_overrides.lock_aspect, Some(expected)); - assert_eq!(plot.figure().lock_aspect, expected); - } - } - - #[test] - fn inspection_contract_reports_canonical_shape_and_domain() { - let report = inspection_report( - DataFormat::JcampDx1D, - &Provenance { - selected_path: "sample.dx".into(), - data_path: "sample.dx".into(), - parameter_paths: Vec::new(), - companion_paths: Vec::new(), - }, - &[], - &acquisition(), - ); - assert_eq!(report.schema, INSPECTION_SCHEMA); - assert_eq!(report.dimension.count, 1); - assert_eq!(report.dimension.shape, vec![8]); - assert_eq!(report.domain, "frequency"); - } -} +#[path = "workflow_tests.rs"] +mod tests; diff --git a/crates/core/src/workflow/xps.rs b/crates/core/src/workflow/xps.rs new file mode 100644 index 0000000..74ec17f --- /dev/null +++ b/crates/core/src/workflow/xps.rs @@ -0,0 +1,59 @@ +use super::{ + DimensionReport, INSPECTION_SCHEMA, InspectionReport, ProvenanceReport, XpsReport, + warning_report, +}; +use plotx_io::{DataFormat, LoadWarning, Provenance, xps::XpsExperiment}; + +pub(super) fn inspection_report( + format: DataFormat, + provenance: &Provenance, + warnings: &[LoadWarning], + experiment: &XpsExperiment, +) -> InspectionReport { + let points = experiment + .regions + .iter() + .map(|region| region.intensity_cps.len()) + .sum(); + let binding = experiment + .regions + .iter() + .filter(|region| region.binding_energy_ev.is_some()) + .count(); + InspectionReport { + schema: INSPECTION_SCHEMA, + format: format.as_str().to_owned(), + provenance: ProvenanceReport { + selected_path: provenance.selected_path.clone(), + data_path: provenance.data_path.clone(), + parameter_paths: provenance.parameter_paths.clone(), + companion_paths: provenance.companion_paths.clone(), + }, + dimension: DimensionReport { + count: 3, + shape: vec![ + experiment.measurements.len(), + experiment.regions.len(), + points, + ], + }, + domain: "xps".into(), + warnings: warnings.iter().map(warning_report).collect(), + electrophysiology: None, + afm: None, + mass_spectrometry: None, + xrd: None, + xps: Some(XpsReport { + measurement_count: experiment.measurements.len(), + region_count: experiment.regions.len(), + point_count: points, + binding_energy_region_count: binding, + kinetic_only_region_count: experiment.regions.len() - binding, + regions: experiment + .regions + .iter() + .map(|region| region.name.clone()) + .collect(), + }), + } +} diff --git a/crates/core/src/workflow_tests.rs b/crates/core/src/workflow_tests.rs new file mode 100644 index 0000000..f8ae452 --- /dev/null +++ b/crates/core/src/workflow_tests.rs @@ -0,0 +1,82 @@ +use super::*; +use num_complex::Complex64; + +fn acquisition() -> Acquisition { + Acquisition::D1(plotx_io::NmrData { + points: vec![Complex64::new(1.0, 0.0); 8], + domain: Domain::Frequency, + spectral_width_hz: 4_000.0, + observe_freq_mhz: 400.0, + carrier_ppm: 4.7, + nucleus: "1H".to_owned(), + source: "sample.dx".to_owned(), + group_delay: 0.0, + }) +} + +fn homonuclear_2d_acquisition() -> Acquisition { + let dimension = plotx_io::Dim { + spectral_width_hz: 4_000.0, + observe_freq_mhz: 400.0, + carrier_ppm: 4.7, + nucleus: "1H".to_owned(), + group_delay: 0.0, + }; + Acquisition::D2(Box::new(plotx_io::NmrData2D { + data: vec![Complex64::new(1.0, 0.0); 16], + rows: 4, + cols: 4, + domain: Domain::Frequency, + direct: dimension.clone(), + indirect: dimension, + quad: plotx_io::QuadMode::Complex, + indirect_conjugate: false, + experiment: Some("cosy".to_owned()), + pseudo_axis: None, + diffusion: None, + nus: None, + source: "cosy".to_owned(), + })) +} + +#[test] +fn canonical_conversion_and_default_canvas_share_dataset_identity() { + let (dataset, source) = dataset_from_acquisition(acquisition()); + assert_eq!(dataset.kind_label(), "NMR 1D"); + let canvas = build_default_canvas(&dataset, &source); + assert_eq!(canvas.dataset_ids(), vec![dataset.resource_id()]); + assert_eq!(canvas.objects.len(), 1); +} + +#[test] +fn import_preference_seeds_one_persistent_plot_override() { + for (preference, expected) in [(true, true), (false, false)] { + let (dataset, source) = dataset_from_acquisition_with_equal_scale_preference( + homonuclear_2d_acquisition(), + preference, + ); + let canvas = build_default_canvas(&dataset, &source); + let plot = canvas.objects[0].plot().expect("default plot"); + assert_eq!(plot.axis_overrides.lock_aspect, Some(expected)); + assert_eq!(plot.figure().lock_aspect, expected); + } +} + +#[test] +fn inspection_contract_reports_canonical_shape_and_domain() { + let report = inspection_report( + DataFormat::JcampDx1D, + &Provenance { + selected_path: "sample.dx".into(), + data_path: "sample.dx".into(), + parameter_paths: Vec::new(), + companion_paths: Vec::new(), + }, + &[], + &acquisition(), + ); + assert_eq!(report.schema, INSPECTION_SCHEMA); + assert_eq!(report.dimension.count, 1); + assert_eq!(report.dimension.shape, vec![8]); + assert_eq!(report.domain, "frequency"); +} diff --git a/crates/io/src/bruker.rs b/crates/io/src/bruker.rs index f9c2a45..b0c4da6 100644 --- a/crates/io/src/bruker.rs +++ b/crates/io/src/bruker.rs @@ -624,6 +624,7 @@ mod tests { Acquisition::Electrophysiology(_) => panic!("expected NMR"), Acquisition::Afm(_) => panic!("expected NMR"), Acquisition::MassSpec(_) | Acquisition::Xrd(_) => panic!("expected NMR"), + Acquisition::Xps(_) => panic!("expected NMR"), }; let from_dir = unwrap1d(read_bruker(&dir).unwrap()); let from_file = unwrap1d(read_bruker(&dir.join("fid")).unwrap()); @@ -673,6 +674,7 @@ mod tests { Acquisition::Electrophysiology(_) => panic!("expected NMR"), Acquisition::Afm(_) => panic!("expected NMR"), Acquisition::MassSpec(_) | Acquisition::Xrd(_) => panic!("expected NMR"), + Acquisition::Xps(_) => panic!("expected NMR"), }; assert_eq!((two.cols, two.rows), (2, 2)); assert_eq!( diff --git a/crates/io/src/jcamp_dx/tests.rs b/crates/io/src/jcamp_dx/tests.rs index 3cbb4be..419f01d 100644 --- a/crates/io/src/jcamp_dx/tests.rs +++ b/crates/io/src/jcamp_dx/tests.rs @@ -28,6 +28,7 @@ fn data(text: &str) -> NmrData { Acquisition::Electrophysiology(_) => panic!("expected NMR"), Acquisition::Afm(_) => panic!("expected NMR"), Acquisition::MassSpec(_) | Acquisition::Xrd(_) => panic!("expected NMR"), + Acquisition::Xps(_) => panic!("expected NMR"), } } diff --git a/crates/io/src/jeol/tests.rs b/crates/io/src/jeol/tests.rs index 1a7da09..87495ef 100644 --- a/crates/io/src/jeol/tests.rs +++ b/crates/io/src/jeol/tests.rs @@ -144,6 +144,7 @@ fn round_trips_a_hand_built_1d_le_file() { Acquisition::Electrophysiology(_) => panic!("expected NMR"), Acquisition::Afm(_) => panic!("expected NMR"), Acquisition::MassSpec(_) | Acquisition::Xrd(_) => panic!("expected NMR"), + Acquisition::Xps(_) => panic!("expected NMR"), }; assert_eq!(data.len(), 4); // FID conjugated on read (imaginary channel negated). @@ -206,6 +207,7 @@ fn uses_real_point_count_over_padded_count_for_1d() { Acquisition::Electrophysiology(_) => panic!("expected NMR"), Acquisition::Afm(_) => panic!("expected NMR"), Acquisition::MassSpec(_) | Acquisition::Xrd(_) => panic!("expected NMR"), + Acquisition::Xps(_) => panic!("expected NMR"), }; assert_eq!(data.len(), nreal, "FID truncated to the real point count"); assert_eq!(data.points[0], Complex64::new(1.0, -5.0)); @@ -352,6 +354,7 @@ fn de_tiles_a_hand_built_2d_across_tile_blocks() { Acquisition::Electrophysiology(_) => panic!("expected NMR"), Acquisition::Afm(_) => panic!("expected NMR"), Acquisition::MassSpec(_) | Acquisition::Xrd(_) => panic!("expected NMR"), + Acquisition::Xps(_) => panic!("expected NMR"), }; assert_eq!((two.cols, two.rows), (cols_real, rows_real)); assert_eq!(two.data.len(), cols_real * rows_real); @@ -434,6 +437,7 @@ fn de_tiles_a_hand_built_hypercomplex_2d() { Acquisition::Electrophysiology(_) => panic!("expected NMR"), Acquisition::Afm(_) => panic!("expected NMR"), Acquisition::MassSpec(_) | Acquisition::Xrd(_) => panic!("expected NMR"), + Acquisition::Xps(_) => panic!("expected NMR"), }; assert_eq!(two.quad, QuadMode::States); assert_eq!(two.cols, cols_real); diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 40e400f..a5e27e4 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -12,6 +12,7 @@ pub mod nanoscope; pub mod origin; pub mod waters; pub mod xlsx; +pub mod xps; pub mod xrd; pub use mass_spec::*; @@ -36,6 +37,8 @@ pub enum DataFormat { RigakuRasx, RigakuRaw, RigakuProfile, + VamasXps, + CasaXpsText, } impl DataFormat { @@ -54,6 +57,8 @@ impl DataFormat { Self::RigakuRasx => "rigaku-rasx", Self::RigakuRaw => "rigaku-raw-fi", Self::RigakuProfile => "rigaku-profile", + Self::VamasXps => "vamas-xps", + Self::CasaXpsText => "casaxps-text", } } } @@ -354,6 +359,7 @@ pub enum Acquisition { Afm(Box), MassSpec(Box), Xrd(Box), + Xps(Box), } /// A one-dimensional powder X-ray diffraction pattern. @@ -606,6 +612,9 @@ pub enum IoError { #[error("invalid or unsupported mzML: {0}")] InvalidMzMl(String), + + #[error("invalid XPS data: {0}")] + InvalidXps(String), } /// Load a dataset, auto-detecting the format from the path. A Bruker @@ -613,6 +622,12 @@ pub enum IoError { /// `ser` file inside it; other files dispatch by extension, then by content. pub fn detect_format(path: impl AsRef) -> Result { let path = path.as_ref(); + if xps::is_vamas_xps(path) { + return Ok(DataFormat::VamasXps); + } + if xps::is_casaxps_text(path) { + return Ok(DataFormat::CasaXpsText); + } if waters::is_masslynx_raw(path) { return Ok(DataFormat::WatersMassLynxRaw); } @@ -680,5 +695,7 @@ pub fn load_path(path: impl AsRef) -> Result { DataFormat::RigakuRasx => xrd::load_rasx(path), DataFormat::RigakuRaw => xrd::load_raw(path), DataFormat::RigakuProfile => xrd::load_profile(path), + DataFormat::VamasXps => xps::load_vamas(path), + DataFormat::CasaXpsText => xps::load_casaxps(path), } } diff --git a/crates/io/src/xps.rs b/crates/io/src/xps.rs new file mode 100644 index 0000000..2590f3e --- /dev/null +++ b/crates/io/src/xps.rs @@ -0,0 +1,558 @@ +use crate::{ + Acquisition, DataFormat, IoError, LoadResult, LoadWarning, LoadWarningCode, Provenance, +}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::path::Path; + +mod vamas; + +const VAMAS_MAGIC: &str = "VAMAS Surface Chemical Analysis Standard Data Transfer Format"; +const MAX_TEXT_BYTES: u64 = 128 * 1024 * 1024; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct XpsMeasurementId(pub u64); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct XpsRegionId(pub u64); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum XpsEnergyKind { + Binding, + Kinetic, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ImportedXpsPeak { + pub label: String, + pub position_ev: f64, + pub fwhm_ev: f64, + pub area: f64, + pub lineshape: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ImportedXpsFit { + pub background_cps: Vec, + pub envelope_cps: Vec, + pub components_cps: Vec>, + pub peaks: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct XpsRegion { + pub id: XpsRegionId, + pub measurement: XpsMeasurementId, + pub name: String, + pub native_energy_kind: XpsEnergyKind, + pub native_energy_ev: Vec, + pub binding_energy_ev: Option>, + pub intensity_cps: Vec, + pub counts: Option>, + pub photon_energy_ev: Option, + pub dwell_time_s: Option, + pub sweeps: Option, + pub imported_fit: Option, + pub metadata: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct XpsMeasurement { + pub id: XpsMeasurementId, + pub label: String, + pub position_mm: Option<[f64; 3]>, + pub metadata: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct XpsExperiment { + pub source: String, + pub measurements: Vec, + pub regions: Vec, + pub metadata: BTreeMap, + pub import_warnings: Vec, +} + +impl XpsExperiment { + pub fn validate(&self) -> Result<(), String> { + if self.regions.is_empty() { + return Err("experiment has no readable XPS regions".into()); + } + let mut measurement_ids = std::collections::BTreeSet::new(); + for measurement in &self.measurements { + if measurement.id.0 == 0 || !measurement_ids.insert(measurement.id) { + return Err("experiment has invalid or duplicate measurement IDs".into()); + } + if measurement + .position_mm + .is_some_and(|position| position.iter().any(|value| !value.is_finite())) + { + return Err(format!( + "measurement {} contains an invalid position", + measurement.label + )); + } + } + let mut region_ids = std::collections::BTreeSet::new(); + for region in &self.regions { + if region.id.0 == 0 || !region_ids.insert(region.id) { + return Err("experiment has invalid or duplicate region IDs".into()); + } + if !measurement_ids.contains(®ion.measurement) { + return Err(format!( + "region {} references a missing measurement", + region.name + )); + } + let n = region.native_energy_ev.len(); + if n < 2 || region.intensity_cps.len() != n { + return Err(format!("region {} has inconsistent arrays", region.name)); + } + if region + .binding_energy_ev + .as_ref() + .is_some_and(|values| values.len() != n) + || region + .counts + .as_ref() + .is_some_and(|values| values.len() != n) + || region.native_energy_ev.iter().any(|v| !v.is_finite()) + || region.intensity_cps.iter().any(|v| !v.is_finite()) + || region + .binding_energy_ev + .as_ref() + .is_some_and(|values| values.iter().any(|v| !v.is_finite())) + || region + .counts + .as_ref() + .is_some_and(|values| values.iter().any(|v| !v.is_finite())) + || region + .photon_energy_ev + .is_some_and(|value| !value.is_finite() || value <= 0.0) + || region + .dwell_time_s + .is_some_and(|value| !value.is_finite() || value <= 0.0) + || region.sweeps == Some(0) + { + return Err(format!("region {} contains invalid values", region.name)); + } + if let Some(fit) = ®ion.imported_fit { + let arrays_valid = fit.background_cps.len() == n + && fit.envelope_cps.len() == n + && fit.components_cps.len() == fit.peaks.len() + && fit.components_cps.iter().all(|values| values.len() == n) + && fit + .background_cps + .iter() + .chain(&fit.envelope_cps) + .chain(fit.components_cps.iter().flatten()) + .all(|value| value.is_finite()); + let peaks_valid = fit.peaks.iter().all(|peak| { + peak.position_ev.is_finite() + && peak.fwhm_ev.is_finite() + && peak.fwhm_ev > 0.0 + && peak.area.is_finite() + && peak.area >= 0.0 + }); + if !arrays_valid || !peaks_valid { + return Err(format!( + "region {} has an invalid imported fit", + region.name + )); + } + } + } + Ok(()) + } +} + +fn read_text(path: &Path) -> Result { + let metadata = std::fs::metadata(path)?; + if metadata.len() > MAX_TEXT_BYTES { + return Err(IoError::InvalidXps(format!( + "{} exceeds the 128 MiB input limit", + path.display() + ))); + } + String::from_utf8(std::fs::read(path)?) + .map_err(|_| IoError::InvalidXps("XPS text is not valid UTF-8".into())) +} + +fn prefix(path: &Path, max: usize) -> Option { + use std::io::Read; + let mut file = std::fs::File::open(path).ok()?; + let mut bytes = vec![0; max]; + let read = file.read(&mut bytes).ok()?; + bytes.truncate(read); + String::from_utf8(bytes).ok() +} + +pub fn is_vamas_xps(path: &Path) -> bool { + path.is_file() && prefix(path, 256).is_some_and(|text| is_vamas_content(&text)) +} + +pub fn is_casaxps_text(path: &Path) -> bool { + if !path.is_file() { + return false; + } + let Some(text) = prefix(path, 16 * 1024) else { + return false; + }; + is_casaxps_content(&text) +} + +pub fn is_vamas_content(text: &str) -> bool { + text.starts_with(VAMAS_MAGIC) +} + +pub fn is_casaxps_content(text: &str) -> bool { + let lines = text.lines().take(8).collect::>(); + lines.len() == 8 + && lines[0].starts_with("Cycle ") + && lines[2].starts_with("Name\t") + && lines[3].starts_with("Position\t") + && lines[4].starts_with("FWHM\t") + && lines[5].starts_with("Area\t") + && lines[6].starts_with("Lineshape\t") + && lines[7].contains("\tB.E.\tCPS\t") +} + +pub fn load_vamas(path: &Path) -> Result { + let text = read_text(path)?; + let experiment = parse_vamas(&text, path.display().to_string())?; + load_result(path, DataFormat::VamasXps, experiment) +} + +pub fn load_casaxps(path: &Path) -> Result { + let text = read_text(path)?; + let experiment = parse_casaxps(&text, path.display().to_string())?; + load_result(path, DataFormat::CasaXpsText, experiment) +} + +fn load_result( + path: &Path, + format: DataFormat, + experiment: XpsExperiment, +) -> Result { + experiment.validate().map_err(IoError::InvalidXps)?; + let warnings = experiment + .import_warnings + .iter() + .map(|message| LoadWarning { + code: LoadWarningCode::UnsupportedFunction, + message: message.clone(), + path: Some(path.to_owned()), + }) + .collect(); + Ok(LoadResult { + acquisition: Acquisition::Xps(Box::new(experiment)), + format, + provenance: Provenance { + selected_path: path.into(), + data_path: path.into(), + parameter_paths: Vec::new(), + companion_paths: Vec::new(), + }, + warnings, + }) +} + +fn parse_numbers(line: &str, label: &str) -> Result, IoError> { + line.split('\t') + .skip(1) + .filter(|v| !v.trim().is_empty()) + .map(|value| { + value + .trim() + .parse::() + .map_err(|_| IoError::InvalidXps(format!("invalid {label} value {value:?}"))) + }) + .collect() +} + +pub fn parse_casaxps(text: &str, source: String) -> Result { + let lines = text.lines().collect::>(); + if lines.len() < 9 || !lines[0].starts_with("Cycle ") || !lines[7].contains("\tB.E.\tCPS\t") { + return Err(IoError::InvalidXps( + "not a structured CasaXPS text export".into(), + )); + } + let labels = lines[2] + .split('\t') + .skip(2) + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(str::to_owned) + .collect::>(); + let positions = parse_numbers(lines[3], "peak position")?; + let fwhms = parse_numbers(lines[4], "FWHM")?; + let areas = parse_numbers(lines[5], "area")?; + let shapes = lines[6] + .split('\t') + .skip(2) + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(str::to_owned) + .collect::>(); + let n_peaks = positions.len(); + if fwhms.len() != n_peaks + || areas.len() != n_peaks + || labels.len() != n_peaks + || shapes.len() != n_peaks + { + return Err(IoError::InvalidXps( + "CasaXPS peak header lengths disagree".into(), + )); + } + let header = lines[7].split('\t').collect::>(); + let be_col = header + .iter() + .position(|v| *v == "B.E.") + .ok_or_else(|| IoError::InvalidXps("CasaXPS B.E. column is missing".into()))?; + let cps_col = be_col + 1; + let comp_start = cps_col + 1; + let bg_col = comp_start + n_peaks; + let env_col = bg_col + 1; + let mut be = Vec::new(); + let mut cps = Vec::new(); + let mut bg = Vec::new(); + let mut envelope = Vec::new(); + let mut components = vec![Vec::new(); n_peaks]; + for (row, line) in lines[8..].iter().enumerate() { + if line.trim().is_empty() { + continue; + } + let fields = line.split('\t').collect::>(); + if fields.len() <= env_col { + return Err(IoError::InvalidXps(format!( + "CasaXPS data row {} is truncated", + row + 1 + ))); + } + let value = |col: usize| { + fields[col].trim().parse::().map_err(|_| { + IoError::InvalidXps(format!( + "invalid CasaXPS numeric value on data row {}", + row + 1 + )) + }) + }; + be.push(value(be_col)?); + cps.push(value(cps_col)?); + bg.push(value(bg_col)?); + envelope.push(value(env_col)?); + for (index, component) in components.iter_mut().enumerate() { + component.push(value(comp_start + index)?); + } + } + if be.len() < 2 { + return Err(IoError::InvalidXps( + "CasaXPS export contains fewer than two data rows".into(), + )); + } + let region_name = labels.first().cloned().unwrap_or_else(|| { + lines[0] + .rsplit(':') + .next() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("XPS") + .to_owned() + }); + let peaks = positions + .into_iter() + .enumerate() + .map(|(i, position_ev)| ImportedXpsPeak { + label: labels + .get(i) + .cloned() + .unwrap_or_else(|| format!("Peak {}", i + 1)), + position_ev, + fwhm_ev: fwhms[i], + area: areas[i], + lineshape: shapes.get(i).cloned(), + }) + .collect(); + Ok(XpsExperiment { + source, + measurements: vec![XpsMeasurement { + id: XpsMeasurementId(1), + label: "CasaXPS export".into(), + position_mm: None, + metadata: BTreeMap::new(), + }], + regions: vec![XpsRegion { + id: XpsRegionId(1), + measurement: XpsMeasurementId(1), + name: region_name, + native_energy_kind: XpsEnergyKind::Binding, + native_energy_ev: be.clone(), + binding_energy_ev: Some(be), + intensity_cps: cps, + counts: None, + photon_energy_ev: None, + dwell_time_s: None, + sweeps: None, + imported_fit: Some(ImportedXpsFit { + background_cps: bg, + envelope_cps: envelope, + components_cps: components, + peaks, + }), + metadata: BTreeMap::new(), + }], + metadata: BTreeMap::new(), + import_warnings: Vec::new(), + }) +} + +pub use vamas::parse_vamas; + +#[cfg(test)] +mod tests { + use super::*; + + fn vamas_block(technique: &str, photon: &str, payload: &[&str]) -> String { + format!( + "C 1s\n1\n2025\n1\n1\n0\n0\n0\n0\n0\n{technique}\nAl\n{photon}\n75\n1e+037\n1e+037\n1e+037\n1e+037\nFAT\n20\n1e+037\n-4.5\n1e+037\n1e+037\n1e+037\n1e+037\n1e+037\nC\n1s\n-1\nKinetic energy\neV\n1200\n1\n2\nIntensity\nd\nTransmission\nd\npulse counting\n1\n1\n0\n1e+037\n1e+037\n1e+037\n0\n4\n0\n20\n1\n1\n{}\n", + payload.join("\n") + ) + } + + fn vamas(blocks: &str) -> String { + let block_count = blocks.matches("\n1\n2025\n").count(); + format!( + "{VAMAS_MAGIC} 1988 May 4\nsynthetic\ninstrument\noperator\nexperiment\n0\nNORM\nREGULAR\n1\n0\n0\n0\n0\n0\n{block_count}\n{blocks}" + ) + } + + #[test] + fn rejects_generic_delimited_text_as_casaxps() { + let dir = std::env::temp_dir(); + let path = dir.join(format!("plotx-xps-generic-{}.txt", std::process::id())); + std::fs::write(&path, "energy,intensity\n1,2\n").unwrap(); + assert!(!is_casaxps_text(&path)); + std::fs::remove_file(path).unwrap(); + } + + #[test] + fn casaxps_rejects_truncated_data_rows() { + let text = "Cycle 1:1:C 1s\nmetadata\nName\t\tC 1s\nPosition\t\t284.8\nFWHM\t\t1.2\nArea\t\t10\nLineshape\t\tGL(30)\nK.E.\tCounts\tC 1s\tBackground\tEnvelope\t\tB.E.\tCPS\tC 1s\tBackground CPS\tEnvelope CPS\n1200\t10\n"; + assert!(parse_casaxps(text, "truncated.txt".into()).is_err()); + } + + #[test] + fn vamas_ke_without_photon_energy_remains_kinetic_only() { + let experiment = parse_vamas( + &vamas(&vamas_block("XPS", "1e+037", &["10", "1", "20", "1"])), + "memory.vms".into(), + ) + .unwrap(); + assert_eq!( + experiment.regions[0].native_energy_kind, + XpsEnergyKind::Kinetic + ); + assert!(experiment.regions[0].binding_energy_ev.is_none()); + } + + #[test] + fn vamas_rejects_truncation_and_non_finite_ordinates() { + let truncated = vamas(&vamas_block("XPS", "1486.69", &["10", "1"])); + assert!(parse_vamas(&truncated, "truncated.vms".into()).is_err()); + let non_finite = vamas(&vamas_block("XPS", "1486.69", &["NaN", "1", "20", "1"])); + assert!(parse_vamas(&non_finite, "nan.vms".into()).is_err()); + } + + #[test] + fn vamas_skips_unknown_technique_but_requires_one_readable_xps_block() { + let unknown = vamas_block("AES", "1486.69", &["10", "1", "20", "1"]); + assert!(parse_vamas(&vamas(&unknown), "aes.vms".into()).is_err()); + let mixed = format!( + "{unknown}{}", + vamas_block("XPS", "1486.69", &["10", "1", "20", "1"]) + ); + let experiment = parse_vamas(&vamas(&mixed), "mixed.vms".into()).unwrap(); + assert_eq!(experiment.regions.len(), 1); + assert_eq!(experiment.import_warnings.len(), 1); + } + + #[test] + #[ignore = "requires PLOTX_XPS_REFERENCE_DIR"] + fn reads_external_reference_files() { + let root = std::env::var_os("PLOTX_XPS_REFERENCE_DIR").expect("reference directory"); + let root = Path::new(&root); + let vamas = load_vamas(&root.join("WBG250331.vms")).unwrap(); + let Acquisition::Xps(experiment) = vamas.acquisition else { + panic!("expected XPS"); + }; + eprintln!( + "measurements={} regions={} points={:?}", + experiment.measurements.len(), + experiment.regions.len(), + experiment + .regions + .iter() + .map(|r| (&r.name, r.intensity_cps.len())) + .collect::>() + ); + assert_eq!(experiment.measurements.len(), 5); + assert!( + experiment + .regions + .iter() + .any(|region| region.name == "Survey") + ); + assert!( + experiment + .regions + .iter() + .all(|region| region.photon_energy_ev == Some(1486.69)) + ); + assert_eq!( + experiment.regions[0] + .metadata + .get("anode") + .map(String::as_str), + Some("Al") + ); + assert_eq!(experiment.regions[0].counts.as_ref().unwrap()[0], 2078.0); + assert!((experiment.regions[0].intensity_cps[0] - 2078.0 / 0.088_495).abs() < 1e-9); + assert_eq!( + experiment.measurements[0] + .metadata + .get("sample") + .map(String::as_str), + Some("WBG") + ); + + let casa = load_casaxps(&root.join("cof_1_0001.txt")).unwrap(); + let Acquisition::Xps(experiment) = casa.acquisition else { + panic!("expected XPS"); + }; + let region = &experiment.regions[0]; + assert_eq!( + region.intensity_cps.len(), + region.binding_energy_ev.as_ref().unwrap().len() + ); + assert!( + region + .imported_fit + .as_ref() + .is_some_and(|fit| !fit.peaks.is_empty()) + ); + let raw_casa = load_casaxps(&root.join("cof_2_0002.txt")).unwrap(); + let Acquisition::Xps(raw_experiment) = raw_casa.acquisition else { + panic!("expected XPS"); + }; + assert_eq!(raw_experiment.regions[0].name, "N 1s"); + assert!( + raw_experiment.regions[0] + .imported_fit + .as_ref() + .is_some_and(|fit| fit.peaks.is_empty()) + ); + } +} diff --git a/crates/io/src/xps/vamas.rs b/crates/io/src/xps/vamas.rs new file mode 100644 index 0000000..e93065c --- /dev/null +++ b/crates/io/src/xps/vamas.rs @@ -0,0 +1,474 @@ +use super::{ + IoError, VAMAS_MAGIC, XpsEnergyKind, XpsExperiment, XpsMeasurement, XpsMeasurementId, + XpsRegion, XpsRegionId, +}; +use std::collections::BTreeMap; + +const VAMAS_SENTINEL: f64 = 1.0e36; + +struct Header { + variable_labels: Vec, + block_count: usize, + block_start: usize, +} + +struct Cursor<'a> { + lines: &'a [&'a str], + position: usize, + block: &'a str, +} + +impl<'a> Cursor<'a> { + fn new(lines: &'a [&'a str], position: usize, block: &'a str) -> Self { + Self { + lines, + position, + block, + } + } + + fn line(&mut self, label: &str) -> Result<&'a str, IoError> { + let line = self.lines.get(self.position).copied().ok_or_else(|| { + IoError::InvalidXps(format!( + "VAMAS block {:?} is truncated before {label}", + self.block + )) + })?; + self.position += 1; + Ok(line.trim()) + } + + fn usize(&mut self, label: &str) -> Result { + self.line(label)?.parse().map_err(|_| { + IoError::InvalidXps(format!( + "VAMAS block {:?} has an invalid {label}", + self.block + )) + }) + } + + fn f64(&mut self, label: &str) -> Result { + let value = self.line(label)?.parse::().map_err(|_| { + IoError::InvalidXps(format!( + "VAMAS block {:?} has an invalid {label}", + self.block + )) + })?; + if value.is_finite() { + Ok(value) + } else { + Err(IoError::InvalidXps(format!( + "VAMAS block {:?} has a non-finite {label}", + self.block + ))) + } + } + + fn skip(&mut self, count: usize, label: &str) -> Result<(), IoError> { + for _ in 0..count { + self.line(label)?; + } + Ok(()) + } +} + +pub fn parse_vamas(text: &str, source: String) -> Result { + let lines = text + .lines() + .map(|line| line.trim_end_matches('\r')) + .collect::>(); + let header = parse_header(&lines)?; + let starts = block_starts(&lines, &header)?; + let mut measurements = BTreeMap::::new(); + let mut regions = Vec::new(); + let mut warnings = Vec::new(); + + for (index, &start) in starts.iter().enumerate() { + let end = starts.get(index + 1).copied().unwrap_or(lines.len()); + let block = &lines[start..end]; + match parse_block( + block, + &header.variable_labels, + &mut measurements, + regions.len(), + )? { + Some(region) => regions.push(region), + None => warnings.push(format!("Skipped non-XPS VAMAS block {}", index + 1)), + } + } + let experiment = XpsExperiment { + source, + measurements: measurements.into_values().collect(), + regions, + metadata: BTreeMap::new(), + import_warnings: warnings, + }; + experiment.validate().map_err(IoError::InvalidXps)?; + Ok(experiment) +} + +fn parse_header(lines: &[&str]) -> Result { + if lines + .first() + .is_none_or(|line| !line.starts_with(VAMAS_MAGIC)) + { + return Err(IoError::InvalidXps("not an ISO 14976 VAMAS file".into())); + } + let mut cursor = Cursor::new(lines, 1, "header"); + cursor.skip(4, "header identity")?; + let comments = cursor.usize("header comment count")?; + cursor.skip(comments, "header comment")?; + if cursor.line("experiment mode")? != "NORM" || cursor.line("scan mode")? != "REGULAR" { + return Err(IoError::InvalidXps( + "only NORM/REGULAR VAMAS experiments are supported".into(), + )); + } + cursor.line("spectral region count")?; + let variable_count = cursor.usize("experimental variable count")?; + let mut variable_labels = Vec::with_capacity(variable_count); + for _ in 0..variable_count { + variable_labels.push(cursor.line("experimental variable label")?.to_owned()); + cursor.line("experimental variable unit")?; + } + let included = cursor.usize("block inclusion count")?; + cursor.skip(included, "included block")?; + let excluded = cursor.usize("block exclusion count")?; + cursor.skip(excluded, "excluded block")?; + let future_experiment = cursor.usize("future experiment entry count")?; + cursor.skip(future_experiment, "future experiment entry")?; + let future_block_entries = cursor.usize("future block entry count")?; + if future_block_entries != 0 { + return Err(IoError::InvalidXps( + "VAMAS future block entries are not supported".into(), + )); + } + let block_count = cursor.usize("block count")?; + if block_count == 0 { + return Err(IoError::InvalidXps("VAMAS file contains no blocks".into())); + } + Ok(Header { + variable_labels, + block_count, + block_start: cursor.position, + }) +} + +fn block_starts(lines: &[&str], header: &Header) -> Result, IoError> { + let mut starts = Vec::new(); + for index in header.block_start..lines.len().saturating_sub(9) { + let number = |offset: usize| lines[index + offset].trim().parse::().ok(); + let calendar_header = number(2).is_some_and(|year| (1900..=2200).contains(&year)) + && number(3).is_some_and(|month| (1..=12).contains(&month)) + && number(4).is_some_and(|day| (1..=31).contains(&day)) + && number(5).is_some_and(|hour| hour <= 23) + && number(6).is_some_and(|minute| minute <= 59) + && number(7).is_some_and(|second| second <= 60); + if calendar_header { + starts.push(index); + } + } + if starts.len() != header.block_count || starts.first().copied() != Some(header.block_start) { + return Err(IoError::InvalidXps(format!( + "VAMAS declares {} blocks but {} trusted block boundaries were found", + header.block_count, + starts.len() + ))); + } + Ok(starts) +} + +fn parse_block( + block: &[&str], + variable_labels: &[String], + measurements: &mut BTreeMap, + region_index: usize, +) -> Result, IoError> { + let name = block.first().map_or("", |line| line.trim()); + let mut cursor = Cursor::new(block, 1, name); + let sample_id = cursor.line("sample identifier")?.to_owned(); + cursor.skip(6, "date and time")?; + cursor.line("GMT offset")?; + let comment_count = cursor.usize("block comment count")?; + let comments = (0..comment_count) + .map(|_| cursor.line("block comment")) + .collect::, _>>()?; + let technique = cursor.line("technique")?; + if technique != "XPS" { + return Ok(None); + } + let variable_values = variable_labels + .iter() + .map(|label| cursor.f64(label)) + .collect::, _>>()?; + let source_label = cursor.line("analysis source label")?.to_owned(); + let photon = physical(cursor.f64("analysis source energy")?); + let source_strength = cursor.f64("analysis source strength")?; + cursor.skip(4, "analysis source geometry")?; + let analyser_mode = cursor.line("analyser mode")?.to_owned(); + let pass_energy = physical(cursor.f64("pass energy")?); + cursor.skip(1, "analyser magnification")?; + let work_function = present(cursor.f64("work function")?); + cursor.skip(5, "analyser geometry")?; + let species = cursor.line("species label")?.to_owned(); + let transition = cursor.line("transition label")?.to_owned(); + cursor.line("species charge")?; + let energy_label = cursor.line("abscissa label")?.to_owned(); + let energy_unit = cursor.line("abscissa unit")?; + if !energy_unit.eq_ignore_ascii_case("eV") { + return Err(IoError::InvalidXps(format!( + "VAMAS block {name:?} uses unsupported energy unit {energy_unit:?}" + ))); + } + let start_ev = cursor.f64("abscissa start")?; + let step_ev = cursor.f64("abscissa increment")?; + if step_ev == 0.0 { + return Err(IoError::InvalidXps(format!( + "VAMAS block {name:?} has a zero energy increment" + ))); + } + let ordinate_count = cursor.usize("ordinate variable count")?; + if ordinate_count == 0 { + return Err(IoError::InvalidXps(format!( + "VAMAS block {name:?} has no ordinate variables" + ))); + } + let mut ordinates = Vec::with_capacity(ordinate_count); + for _ in 0..ordinate_count { + ordinates.push(( + cursor.line("ordinate label")?.to_owned(), + cursor.line("ordinate unit")?.to_owned(), + )); + } + let signal_mode = cursor.line("signal mode")?.to_owned(); + let dwell = physical(cursor.f64("dwell time")?); + let scans_value = cursor.f64("scan count")?; + let sweeps = (scans_value >= 1.0 + && scans_value <= u32::MAX as f64 + && scans_value.fract().abs() <= f64::EPSILON) + .then_some(scans_value.round() as u32); + cursor.skip(4, "sample timing and orientation")?; + let additional = cursor.usize("additional parameter count")?; + cursor.skip( + additional + .checked_mul(3) + .ok_or_else(|| IoError::InvalidXps("VAMAS additional parameter overflow".into()))?, + "additional parameter", + )?; + let payload_count = cursor.usize("ordinate value count")?; + if payload_count % ordinate_count != 0 { + return Err(IoError::InvalidXps(format!( + "VAMAS block {name:?} ordinate count is not divisible by its variables" + ))); + } + cursor.skip( + ordinate_count + .checked_mul(2) + .ok_or_else(|| IoError::InvalidXps("VAMAS ordinate extrema overflow".into()))?, + "ordinate extrema", + )?; + let payload = (0..payload_count) + .map(|_| cursor.f64("ordinate value")) + .collect::, _>>()?; + if let Some((offset, value)) = block[cursor.position..] + .iter() + .enumerate() + .find(|(_, line)| { + let value = line.trim(); + !value.is_empty() && !value.eq_ignore_ascii_case("end of experiment") + }) + { + return Err(IoError::InvalidXps(format!( + "VAMAS block {name:?} contains an unparsed field {:?} at block row {}", + value.trim(), + cursor.position + offset + 1 + ))); + } + let point_count = payload_count / ordinate_count; + if point_count < 2 { + return Err(IoError::InvalidXps(format!( + "VAMAS block {name:?} contains fewer than two points" + ))); + } + if let Some(steps) = comment_usize(&comments, "Number Steps :") + && steps.checked_add(1) != Some(point_count) + { + return Err(IoError::InvalidXps(format!( + "VAMAS block {name:?} point count disagrees with Number Steps" + ))); + } + let first_ordinate = payload + .chunks_exact(ordinate_count) + .map(|values| values[0]) + .collect::>(); + let native = (0..point_count) + .map(|index| start_ev + index as f64 * step_ev) + .collect::>(); + let kind = match energy_label.to_ascii_lowercase().as_str() { + "binding energy" => XpsEnergyKind::Binding, + "kinetic energy" => XpsEnergyKind::Kinetic, + _ => { + return Err(IoError::InvalidXps(format!( + "VAMAS block {name:?} has unsupported abscissa {energy_label:?}" + ))); + } + }; + let binding = match kind { + XpsEnergyKind::Binding => Some(native.clone()), + XpsEnergyKind::Kinetic => photon.map(|hv| native.iter().map(|ke| hv - ke).collect()), + }; + let ordinate_text = format!("{} {}", ordinates[0].0, ordinates[0].1).to_ascii_lowercase(); + let rate = ["cps", "c/s", "count/s", "counts/s", "s-1"] + .iter() + .any(|marker| ordinate_text.contains(marker)); + let counted = !rate && signal_mode.to_ascii_lowercase().contains("pulse"); + if !rate && !counted { + return Err(IoError::InvalidXps(format!( + "VAMAS block {name:?} does not identify its intensity as counts or a count rate" + ))); + } + let divisor = counted + .then(|| dwell.zip(sweeps)) + .flatten() + .map(|(dwell, scans)| dwell * f64::from(scans)) + .filter(|value| *value > 0.0); + if counted && divisor.is_none() { + return Err(IoError::InvalidXps(format!( + "VAMAS block {name:?} lacks dwell time or scan count for CPS conversion" + ))); + } + let intensity_cps = first_ordinate + .iter() + .map(|value| divisor.map_or(*value, |scale| *value / scale)) + .collect(); + let counts = counted.then_some(first_ordinate); + let position = variable_position(variable_labels, &variable_values) + .or_else(|| comment_position(&comments)); + let label = comment(&comments, "Location ID :") + .map(str::to_owned) + .unwrap_or_else(|| { + position.map_or_else( + || sample_id.clone(), + |position| { + format!( + "{} @ {:.6}, {:.6}, {:.6} mm", + sample_id, position[0], position[1], position[2] + ) + }, + ) + }); + let measurement_id = XpsMeasurementId(measurements.len() as u64 + 1); + let measurement = measurements + .entry(label.clone()) + .or_insert_with(|| XpsMeasurement { + id: measurement_id, + label, + position_mm: position, + metadata: metadata_from_comments( + &comments, + &[ + ("sample", "Sample :"), + ("charge_neutraliser", "Charge Neutraliser :"), + ("filament_current", "Filament Current :"), + ("filament_bias", "Filament Bias :"), + ("charge_balance", "Charge Balance :"), + ], + ), + }) + .id; + let mut metadata = metadata_from_comments( + &comments, + &[ + ("excitation_mode", "Mode :"), + ("xray_power", "X-ray Power :"), + ("lens_mode", "Lens :"), + ], + ); + metadata.insert("anode".into(), source_label); + metadata.insert("analyser_mode".into(), analyser_mode); + metadata.insert("signal_mode".into(), signal_mode); + metadata.insert("ordinate_label".into(), ordinates[0].0.clone()); + metadata.insert("ordinate_unit".into(), ordinates[0].1.clone()); + metadata.insert("source_strength".into(), source_strength.to_string()); + if let Some(value) = pass_energy { + metadata.insert("pass_energy".into(), value.to_string()); + } + if let Some(value) = work_function { + metadata.insert("work_function".into(), value.to_string()); + } + if !species.is_empty() { + metadata.insert("species".into(), species); + } + if !transition.is_empty() { + metadata.insert("transition".into(), transition); + } + Ok(Some(XpsRegion { + id: XpsRegionId(region_index as u64 + 1), + measurement, + name: name.to_owned(), + native_energy_kind: kind, + native_energy_ev: native, + binding_energy_ev: binding, + intensity_cps, + counts, + photon_energy_ev: photon, + dwell_time_s: dwell, + sweeps, + imported_fit: None, + metadata, + })) +} + +fn physical(value: f64) -> Option { + (value.abs() < VAMAS_SENTINEL && value > 0.0).then_some(value) +} + +fn present(value: f64) -> Option { + (value.abs() < VAMAS_SENTINEL).then_some(value) +} + +fn comment<'a>(comments: &'a [&str], prefix: &str) -> Option<&'a str> { + comments + .iter() + .find_map(|line| line.trim().strip_prefix(prefix).map(str::trim)) +} + +fn comment_usize(comments: &[&str], prefix: &str) -> Option { + comment(comments, prefix)?.parse().ok() +} + +fn comment_position(comments: &[&str]) -> Option<[f64; 3]> { + let value = comment(comments, "Description : (")?.strip_suffix(")mm")?; + let values = value + .split(',') + .map(|number| number.trim().parse::()) + .collect::, _>>() + .ok()?; + (values.len() == 3).then(|| [values[0], values[1], values[2]]) +} + +fn variable_position(labels: &[String], values: &[f64]) -> Option<[f64; 3]> { + let find = |axis: char| { + labels + .iter() + .position(|label| { + let normalized = label + .to_ascii_lowercase() + .chars() + .filter(|character| character.is_ascii_alphanumeric()) + .collect::(); + normalized.starts_with(&format!("position{axis}")) + || normalized.starts_with(&format!("{axis}position")) + }) + .and_then(|index| values.get(index).copied()) + }; + Some([find('x')?, find('y')?, find('z')?]) +} + +fn metadata_from_comments(comments: &[&str], fields: &[(&str, &str)]) -> BTreeMap { + fields + .iter() + .filter_map(|&(key, prefix)| { + comment(comments, prefix).map(|value| (key.to_owned(), value.to_owned())) + }) + .collect() +} diff --git a/crates/io/tests/bruker_processed.rs b/crates/io/tests/bruker_processed.rs index 7c46297..849dfe8 100644 --- a/crates/io/tests/bruker_processed.rs +++ b/crates/io/tests/bruker_processed.rs @@ -51,6 +51,7 @@ fn loads_big_endian_scaled_1r_from_experiment_directory() { Acquisition::Electrophysiology(_) => panic!("expected NMR"), Acquisition::Afm(_) => panic!("expected NMR"), Acquisition::MassSpec(_) | Acquisition::Xrd(_) => panic!("expected NMR"), + Acquisition::Xps(_) => panic!("expected NMR"), }; assert_eq!(data.domain, Domain::Frequency); assert_eq!( @@ -93,6 +94,7 @@ fn loads_2rr_and_reverses_both_frequency_axes() { Acquisition::Electrophysiology(_) => panic!("expected NMR"), Acquisition::Afm(_) => panic!("expected NMR"), Acquisition::MassSpec(_) | Acquisition::Xrd(_) => panic!("expected NMR"), + Acquisition::Xps(_) => panic!("expected NMR"), }; assert_eq!(data.domain, Domain::Frequency); assert_eq!((data.rows, data.cols), (2, 3)); diff --git a/crates/processing/src/cleanup.rs b/crates/processing/src/cleanup.rs index 98e0448..22b9716 100644 --- a/crates/processing/src/cleanup.rs +++ b/crates/processing/src/cleanup.rs @@ -12,6 +12,35 @@ pub fn smooth(spec: &mut Spectrum, method: SmoothMethod) { } } +/// Gaussian smoothing for real-valued detection helpers that need a stable, +/// symmetric kernel but are not persisted processing steps. +pub fn gaussian_smooth_real(values: &[f64], sigma: f64) -> Option> { + if values.is_empty() + || !sigma.is_finite() + || sigma <= 0.0 + || values.iter().any(|value| !value.is_finite()) + { + return None; + } + let radius = (3.0 * sigma).ceil() as isize; + Some( + (0..values.len()) + .map(|index| { + let mut weighted = 0.0; + let mut total = 0.0; + for offset in -radius..=radius { + let source = + (index as isize + offset).clamp(0, values.len() as isize - 1) as usize; + let weight = (-0.5 * (offset as f64 / sigma).powi(2)).exp(); + weighted += values[source] * weight; + total += weight; + } + weighted / total + }) + .collect(), + ) +} + fn moving_average(values: &mut Vec, window: usize) { let n = values.len(); let w = (window.max(3) | 1).min(if n % 2 == 1 { n } else { n.saturating_sub(1) }); diff --git a/crates/processing/src/lib.rs b/crates/processing/src/lib.rs index 78de1c7..ef84708 100644 --- a/crates/processing/src/lib.rs +++ b/crates/processing/src/lib.rs @@ -13,6 +13,7 @@ pub mod phase; mod preview; pub mod slice; pub mod timeseries; +pub mod xps; pub mod xrd; pub use output::{Processed1D, TimeTrace}; @@ -244,7 +245,7 @@ pub struct ReferenceParams { pub target_ppm: f64, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum SmoothMethod { MovingAverage { window: u16, @@ -263,7 +264,7 @@ impl SmoothMethod { }; } -#[derive(Debug, Clone, Copy, PartialEq)] +#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)] pub enum NormalizeMethod { /// Scale so the tallest peak magnitude is 1. MaxPeak, @@ -313,7 +314,7 @@ impl StepId { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum StepSource { Default, User, diff --git a/crates/processing/src/xps.rs b/crates/processing/src/xps.rs new file mode 100644 index 0000000..c3b2c1a --- /dev/null +++ b/crates/processing/src/xps.rs @@ -0,0 +1,177 @@ +use crate::{NormalizeMethod, SmoothMethod, StepId, StepSource}; +use num_complex::Complex64; + +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub enum XpsStepKind { + Window { low_ev: f64, high_ev: f64 }, + Smooth(SmoothMethod), + Normalize(NormalizeMethod), +} + +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct XpsProcessingStep { + pub id: StepId, + pub kind: XpsStepKind, + pub enabled: bool, + pub source: StepSource, +} + +#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct XpsProcessingRecipe { + pub steps: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ProcessedXpsRegion { + pub binding_energy_ev: Vec, + pub intensity: Vec, +} + +pub fn process_region( + binding_energy_ev: &[f64], + intensity: &[f64], + energy_shift_ev: f64, + recipe: &XpsProcessingRecipe, +) -> Result { + if binding_energy_ev.len() != intensity.len() + || binding_energy_ev.len() < 2 + || !energy_shift_ev.is_finite() + || binding_energy_ev + .iter() + .chain(intensity) + .any(|value| !value.is_finite()) + { + return Err("XPS energy and intensity arrays must have the same non-trivial length"); + } + let mut energy = binding_energy_ev + .iter() + .map(|value| value + energy_shift_ev) + .collect::>(); + let mut values = intensity.to_vec(); + for step in recipe.steps.iter().filter(|step| step.enabled) { + match step.kind { + XpsStepKind::Window { low_ev, high_ev } => { + if !low_ev.is_finite() || !high_ev.is_finite() { + return Err("XPS processing window bounds must be finite"); + } + let (low, high) = if low_ev <= high_ev { + (low_ev, high_ev) + } else { + (high_ev, low_ev) + }; + let mut next_energy = Vec::new(); + let mut next_values = Vec::new(); + for (&x, &y) in energy.iter().zip(&values) { + if x >= low && x <= high { + next_energy.push(x); + next_values.push(y); + } + } + energy = next_energy; + values = next_values; + } + XpsStepKind::Smooth(method) => { + values = smooth_values(&energy, &values, method); + } + XpsStepKind::Normalize(method) => { + if matches!( + method, + NormalizeMethod::Constant { divisor } + if !divisor.is_finite() || divisor.abs() <= f64::MIN_POSITIVE + ) { + return Err("XPS normalization divisor must be finite and non-zero"); + } + let mut spectrum = crate::Spectrum { + ppm: energy.clone(), + values: values + .iter() + .map(|value| Complex64::new(*value, 0.0)) + .collect(), + hz_per_point: 1.0, + observe_freq_mhz: 1.0, + nucleus: "XPS".into(), + }; + crate::cleanup::normalize(&mut spectrum, method); + values = spectrum.values.into_iter().map(|value| value.re).collect(); + } + } + if values.iter().any(|value| !value.is_finite()) { + return Err("XPS processing produced non-finite intensity values"); + } + } + if energy.len() < 2 { + return Err("XPS processing window contains fewer than two points"); + } + Ok(ProcessedXpsRegion { + binding_energy_ev: energy, + intensity: values, + }) +} + +pub fn estimate_charge_shift( + energy_ev: &[f64], + intensity: &[f64], + reference_ev: f64, +) -> Result { + if energy_ev.len() != intensity.len() + || energy_ev.len() < 8 + || !reference_ev.is_finite() + || energy_ev + .iter() + .chain(intensity) + .any(|value| !value.is_finite()) + { + return Err("the C 1s reference region is invalid"); + } + let edge = 3.min(energy_ev.len() / 4); + let smoothed = crate::cleanup::gaussian_smooth_real(intensity, 3.0) + .ok_or("the C 1s reference region cannot be smoothed")?; + let index = smoothed[edge..smoothed.len() - edge] + .iter() + .enumerate() + .max_by(|(_, left), (_, right)| left.total_cmp(right)) + .map(|(index, _)| index + edge) + .ok_or("the C 1s reference region has no intensity maximum")?; + Ok(reference_ev - energy_ev[index]) +} + +fn smooth_values(energy: &[f64], values: &[f64], method: SmoothMethod) -> Vec { + let mut spectrum = crate::Spectrum { + ppm: energy.to_vec(), + values: values + .iter() + .map(|value| Complex64::new(*value, 0.0)) + .collect(), + hz_per_point: 1.0, + observe_freq_mhz: 1.0, + nucleus: "XPS".into(), + }; + crate::cleanup::smooth(&mut spectrum, method); + spectrum.values.into_iter().map(|value| value.re).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shift_and_window_leave_raw_inputs_unchanged() { + let x = vec![5.0, 4.0, 3.0, 2.0]; + let y = vec![1.0, 2.0, 3.0, 4.0]; + let recipe = XpsProcessingRecipe { + steps: vec![XpsProcessingStep { + id: StepId::new(1), + kind: XpsStepKind::Window { + low_ev: 4.0, + high_ev: 5.0, + }, + enabled: true, + source: StepSource::User, + }], + }; + let result = process_region(&x, &y, 1.0, &recipe).unwrap(); + assert_eq!(result.binding_energy_ev, vec![5.0, 4.0]); + assert_eq!(result.intensity, vec![2.0, 3.0]); + assert_eq!(x[0], 5.0); + } +} diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 2e65364..d5c5afe 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -56,6 +56,7 @@ export default defineConfig({ { slug: 'guides/2d-integration' }, { slug: 'guides/symmetry-review' }, { slug: 'guides/electrophysiology' }, + { slug: 'guides/xps' }, ], }, ], diff --git a/docs/src/content/docs/guides/exporting.md b/docs/src/content/docs/guides/exporting.md index 891a97b..77049a6 100644 --- a/docs/src/content/docs/guides/exporting.md +++ b/docs/src/content/docs/guides/exporting.md @@ -57,6 +57,14 @@ row: `f1_ppm,f2_ppm,intensity` for true 2D, or the named series axis with its unit, `ppm`, and `intensity` for pseudo-2D. Large exports are generated in the background. +For XPS, **Processed data** includes native, binding-energy, processed, and fit +axes; raw and processed CPS; the selected background model, fit window and +anchors; background-subtracted intensity; envelope, residual, and every fit +component. **Curve-fit parameters** adds standard errors, approximate 95% +intervals, maximum correlation, RMSE, and optional Bootstrap quantiles. CasaXPS +rows remain labelled `Imported (CasaXPS)` rather than being presented as PlotX +fits. + A CSV or TSV exported from a data table comes with a companion `.plotx-schema.json` file, and an XLSX export keeps the same information on a hidden worksheet. The visible columns open normally in Excel, Origin, or Prism, diff --git a/docs/src/content/docs/guides/importing-data.md b/docs/src/content/docs/guides/importing-data.md index d12d6e2..836595c 100644 --- a/docs/src/content/docs/guides/importing-data.md +++ b/docs/src/content/docs/guides/importing-data.md @@ -3,7 +3,7 @@ title: Importing data description: Supported file formats and how to open them. --- -PlotX reads vendor LC–MS, NMR, AFM, and electrophysiology formats directly — +PlotX reads vendor LC–MS, NMR, XPS, AFM, and electrophysiology formats directly — no conversion step is needed. ## Supported formats @@ -18,6 +18,8 @@ no conversion step is needed. | Bruker NanoScope AFM | `.spm` / `.pfc` | Images, force curves, force-volume and PeakForce Capture cubes | | JCAMP-DX | `.dx` / `.jdx` / `.jcamp` | 1D frequency-domain NMR spectra | | Axon Binary Format 2 | `.abf` | int16/float32, multiple channels and sweeps, embedded DAC/epoch stimuli | +| VAMAS XPS | `.vms` | ISO 14976 `NORM` / `REGULAR` XPS blocks, including multiple measurement positions and regions | +| CasaXPS text | `.txt` | Structured eight-line export with raw spectrum, background, envelope, components, and fitted parameters | | Tabular data | `.csv`, `.tsv`, `.txt`, `.xlsx` | Column types and empty cells preserved; one table per XLSX worksheet | | Origin project (experimental) | `.opj`, `.opju` | Worksheets from the verified Origin 7.0552 and Origin 9.51 OPJ profiles; graphs are not imported, and `.opju` is detection-only. See [compatibility details](/reference/file-formats/). | | Zip archive | `.zip` | An archived dataset folder | @@ -31,10 +33,13 @@ TopSpin and Waters MassLynx RAW), *Open Project…*, or *Import Table…*. Each imported dataset appears in the Primary Side Bar and is placed on the board automatically. The file picker accepts several ABF files at once. Opening a folder recursively -imports every `.abf`, `.spm`, `.pfc`, and recognized `.raw` bundle below it. +imports every `.abf`, `.spm`, `.pfc`, `.vms`, structured CasaXPS `.txt`, and recognized `.raw` bundle below it. A `.raw` directory is imported once as a complete run; its internal files are not treated as separate datasets. For ABF files, each immediate parent folder becomes the initial, editable cell ID. +CasaXPS `.txt` files are recognized from their structured header, not from the +extension alone. Other `.txt` files continue through table import. See the +[XPS workflow](/guides/xps/) for energy-axis and fitting details. ## mzML diff --git a/docs/src/content/docs/guides/processing.md b/docs/src/content/docs/guides/processing.md index de63ecd..155d68a 100644 --- a/docs/src/content/docs/guides/processing.md +++ b/docs/src/content/docs/guides/processing.md @@ -11,6 +11,11 @@ recomputed and previewed live. Large 2D spectra recompute without blocking the app — you can keep zooming, panning, and editing while the updated spectrum appears moments later. +XPS uses its own ordered recipe per spectrum region rather than the NMR +pipeline. It contains an energy window, smoothing, and normalization, while +charge correction is shared by all regions at one measurement position. See +[XPS](/guides/xps/). + ## A typical 1D spectrum A newly imported time-domain 1D dataset already carries the standard pipeline — diff --git a/docs/src/content/docs/guides/xps.md b/docs/src/content/docs/guides/xps.md new file mode 100644 index 0000000..1fea07e --- /dev/null +++ b/docs/src/content/docs/guides/xps.md @@ -0,0 +1,109 @@ +--- +title: XPS +description: Import, charge-reference, process, fit, and export XPS spectra. +--- + +PlotX imports one VAMAS file as one XPS experiment. The Data browser keeps its +measurement-position → spectrum-region hierarchy, and every Survey or core +level is a stable curve field that can be plotted alone or overlaid across +positions. The default plot uses the first Survey, or the first readable +region when no Survey exists. Binding energy runs high-to-low from left to +right and intensity is shown in CPS. + +## Supported input + +- ISO 14976 VAMAS `.vms`: the first release supports `NORM` / `REGULAR` XPS + blocks with a regular energy ruler. Unknown non-XPS blocks produce warnings + while readable XPS blocks still import. Truncation, inconsistent point + counts, non-finite ordinates, or no readable XPS block reject the file. +- Structured CasaXPS `.txt`: PlotX recognizes the eight-line CasaXPS header + and preserves BE/CPS, background, envelope, components, line shapes, and + original peak parameters. Generic two-column text remains a table import. + +The native energy axis is always retained. A binding-energy axis is used +directly when supplied. For kinetic energy, PlotX derives `BE = hν - KE` only +when photon energy is present. A kinetic-only region can still be viewed and +exported, but charge correction and peak fitting are disabled. + +VAMAS ordinate descriptors determine how the payload is decoded. PlotX does +not treat stored ordinate minima and maxima as spectrum points. A pulse-counted +intensity ordinate is retained as Counts and converted to CPS with the block +dwell time and scan count; an ordinate already labelled as a count rate is not +rescaled. + +## Charge correction and processing + +Select a C 1s region, keep the default reference at 284.8 eV or enter another +explicit value, then choose **Reference current C 1s**. PlotX locates the +smoothed C 1s maximum once and applies the resulting shift to every region at +that measurement position. Processing windows and background ranges follow the +same sampled points when the shift changes, while component centers remain +absolute chemical binding energies. The original arrays never change. + +Each spectrum region has its own ordered, undoable processing recipe. Add an +energy window, Savitzky–Golay smoothing, or maximum normalization, then enable, +reorder, or delete steps in **Dataset tools → XPS**. The measurement-level +charge shift remains shared by every region at that position. A changed recipe +makes earlier PlotX fits stale; it does not rewrite their provenance. + +## Background and peak fitting + +The XPS workbench has **Acquisition**, **Background**, **Components**, and +**Diagnostics** tabs. Background is part of the fit invocation rather than the +processing recipe. Choose Linear, iterative Shirley, or Tougaard U2, then +preview and edit the fit window and low-/high-BE anchor bands before fitting. +The plot range-selection tool can fill any of those ranges; numerical inputs +remain available for exact values. + +Tougaard U2 uses `K(T) = B T / (C + T²)²`, with editable defaults +`B = 3000 eV²` and `C = 1643 eV²`. It models an inelastic-loss tail; it is not +automatically preferable for every region, and its result remains sensitive to +window and anchor choices. PlotX applies this kernel as an anchored, +finite-window peak background; it is not a replacement for complete QUASES +depth-profile analysis. + +Add and reorder components manually, explicitly choose the C 1s, N 1s, or O 1s +template, or copy a component as a linked component. Templates seed candidates +only; PlotX does not assert that a chemical assignment is correct. Stable +component identities keep energy offsets, shared widths, and area ratios +attached to the same component after reordering. Center, FWHM, and area can +each be free and bounded, fixed, or linked where applicable. Missing, self, and +cyclic links are rejected. Free areas have explicit editable lower and upper +bounds, and diagnostics plus data export report area-bound hits. + +The default line shape is area-normalized GL(30) pseudo-Voigt. Fitting runs in +a cancellable background job. Results include the background, envelope, +components, residual, peak parameters, area fractions, R², RMSE, residual +lag-1, bound and correlation diagnostics, input hash, energy shift, and the +complete invocation. Numerical covariance is propagated through linked +parameters to standard errors and approximate 95% intervals. A singular local +matrix leaves those intervals unavailable instead of inventing certainty. + +Optional wild residual Bootstrap runs 100–5000 replicates (500 by default) in +a cancellable job. Seed `0` derives a deterministic seed from the fit input; +enter another seed for an explicit repeatable sequence. PlotX stores the 2.5%, +50%, and 97.5% quantiles. Fewer than 80% converged replicates produces a clear +warning but does not discard the converged distribution. Bootstrap can be +computationally expensive, especially for highly coupled component sets. + +R² and width/bound warnings are diagnostics, not a chemical-validity verdict. +CasaXPS fits remain `Imported`; they can be inspected and exported without +being represented as PlotX recomputations. Their original curves are overlaid +and included with processed-data exports only while the region has no enabled +processing steps; after windowing, smoothing, or normalization, PlotX hides +those incompatible raw-CPS curves but keeps their imported parameters. + +## Scope + +Survey spectra can be viewed, compared, annotated, and exported. PlotX does +not calculate Survey elemental atomic percentages in this release. + +Use **Export Data…** for raw and processed axes/intensity, the background model, +window and anchors, background-subtracted intensity, envelope, residual, +components, parameter intervals, correlation diagnostics, and Bootstrap +intervals. PDF, SVG, PNG, TIFF, and JPEG remain available through figure +export. The `.plotx` project stores the experiment hierarchy, raw arrays, +active region, measurement charge shifts, per-region processing recipes and fit +workspaces, Imported results, and PlotX fit and Bootstrap provenance. PlotX fit curves are rebuilt from their +invocation and fitted parameters when a project loads; only Imported CasaXPS +results retain their original curve arrays. diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index 311e1ae..596a47c 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -26,6 +26,8 @@ plotx-cli process --scheme --output [--format stable machine-readable report for scripting. For ABF2 recordings it also reports the ABF version, channel names and units, sample rate, sweep count, and protocol name. +For XPS it reports measurement, region and point counts, the region names, and +how many regions have a binding-energy axis or remain kinetic-only. `process` is the convenience path for a single import, one [processing recipe](/guides/templates/), and one figure export. When diff --git a/docs/src/content/docs/reference/file-formats.md b/docs/src/content/docs/reference/file-formats.md index b46c58b..727cb52 100644 --- a/docs/src/content/docs/reference/file-formats.md +++ b/docs/src/content/docs/reference/file-formats.md @@ -52,6 +52,22 @@ are covered in [the command line](/reference/cli/). A workflow is not a recipe: a recipe holds one processing pipeline, while a workflow describes a whole run and may reference a recipe as one of its steps. +## XPS import + +VAMAS `.vms` support is intentionally limited to content-signed ISO 14976 +`NORM` / `REGULAR` XPS blocks with regular energy rulers. One file remains one +experiment with stable measurement and region identities. PlotX retains native +energy, counts, CPS, photon energy, dwell time, sweeps, position, acquisition +conditions, and free metadata. Non-XPS blocks can be skipped with warnings only +when their declared block boundaries are trusted; malformed XPS payloads reject +the file. Ordinate extrema are metadata rather than data points, and ordinate +labels plus signal mode determine whether pulse counts are converted to CPS. + +CasaXPS `.txt` is recognized by its eight-line structure, not by `.txt` alone. +Its source arrays and fitted parameters are stored as an `Imported` result. +Unstructured text continues through table import. See [XPS](/guides/xps/) for +the energy conversion, processing, fitting, and export contract. + ## Origin project import (experimental) Origin project import is experimental. Successful import is limited to two diff --git a/docs/src/content/docs/zh-cn/guides/exporting.md b/docs/src/content/docs/zh-cn/guides/exporting.md index 72b6a4e..9b63f26 100644 --- a/docs/src/content/docs/zh-cn/guides/exporting.md +++ b/docs/src/content/docs/zh-cn/guides/exporting.md @@ -48,6 +48,12 @@ F1/ppm 或伪 2D 的系列轴;Long 每行一个观测值,真实 2D 使用 `f1_ppm,f2_ppm,intensity`,伪 2D 则使用带名称和单位的系列轴、`ppm` 与 `intensity`。大型导出在后台生成。 +对 XPS,**Processed data** 包含原生轴、结合能轴、处理轴与拟合轴,原始/处理后 CPS, +所选背景模型、拟合窗口与锚点,扣背景强度、拟合包络、残差和每个组件; +**Curve-fit parameters** 还包含标准误、近似 95% 区间、最大相关性、RMSE 与可选 +Bootstrap 分位数。CasaXPS 结果始终标为 `Imported (CasaXPS)`,不会伪装成 PlotX +拟合。 + 从数据表导出的 CSV 或 TSV 会附带一个配套的 `.plotx-schema.json` 文件,XLSX 导出 则把同样的信息保存在隐藏工作表中。可见的列在 Excel、Origin 或 Prism 中正常打开, 而配套信息让 PlotX 日后能连同列类型、单位和误差棒一起重新打开该表。导出的 XLSX diff --git a/docs/src/content/docs/zh-cn/guides/importing-data.md b/docs/src/content/docs/zh-cn/guides/importing-data.md index 99f4e53..7934785 100644 --- a/docs/src/content/docs/zh-cn/guides/importing-data.md +++ b/docs/src/content/docs/zh-cn/guides/importing-data.md @@ -3,7 +3,7 @@ title: 导入数据 description: 支持的文件格式及打开方式。 --- -PlotX 直接读取厂商 LC–MS、NMR、AFM 与电生理格式,无需任何转换步骤。 +PlotX 直接读取厂商 LC–MS、NMR、XPS、AFM 与电生理格式,无需任何转换步骤。 ## 支持的格式 @@ -17,6 +17,8 @@ PlotX 直接读取厂商 LC–MS、NMR、AFM 与电生理格式,无需任何 | Bruker NanoScope AFM | `.spm` / `.pfc` | 图像、力曲线、Force Volume 与 PeakForce Capture 数据立方体 | | JCAMP-DX | `.dx` / `.jdx` / `.jcamp` | 1D 频域 NMR 谱 | | Axon Binary Format 2 | `.abf` | int16/float32、多通道、多 sweep,以及文件内 DAC/epoch 刺激 | +| VAMAS XPS | `.vms` | ISO 14976 `NORM` / `REGULAR` XPS 区块,支持多个测量位置与谱区 | +| CasaXPS 文本 | `.txt` | 含原始谱、背景、包络、组件和拟合参数的结构化八行头导出 | | 表格数据 | `.csv`、`.tsv`、`.txt`、`.xlsx` | 保留列类型与空单元格;每个 XLSX 工作表导入为独立数据表 | | Origin 项目(实验性) | `.opj`、`.opju` | 经验证的 Origin 7.0552 与 Origin 9.51 OPJ 配置中的工作表;不导入图形,`.opju` 仅作识别。见[兼容性详情](/zh-cn/reference/file-formats/)。 | | Zip 压缩包 | `.zip` | 打包的数据文件夹 | @@ -29,9 +31,11 @@ PlotX 直接读取厂商 LC–MS、NMR、AFM 与电生理格式,无需任何 *Open Project…* 或 *Import Table…*。每个导入的数据集会出现在主侧栏中, 并自动放置到画板上。 文件选择器可以一次选择多个 ABF。打开文件夹时会递归导入其中所有 `.abf`、 -`.spm`、`.pfc` 和已识别的 `.raw` 数据包。每个 `.raw` 目录会作为一次完整采集 +`.spm`、`.pfc`、`.vms`、结构化 CasaXPS `.txt` 和已识别的 `.raw` 数据包。每个 `.raw` 目录会作为一次完整采集 导入一次,其中的内部文件不会被当作独立数据集。对 ABF 文件,每个文件的直接 父目录名会成为可编辑的初始 cell ID。 +CasaXPS `.txt` 按结构头内容识别,而不是只看扩展名;其他 `.txt` 仍进入表格导入。 +能量轴与拟合细节见 [XPS 工作流](/zh-cn/guides/xps/)。 ## mzML diff --git a/docs/src/content/docs/zh-cn/guides/processing.md b/docs/src/content/docs/zh-cn/guides/processing.md index 73c35ce..d776587 100644 --- a/docs/src/content/docs/zh-cn/guides/processing.md +++ b/docs/src/content/docs/zh-cn/guides/processing.md @@ -9,6 +9,9 @@ PlotX 的处理是应用于原始数据的**有序步骤列表**。步骤可随 并预览。大型 2D 谱图的重算不会卡住界面——期间可以继续缩放、平移和编辑, 更新后的谱图稍后自动显示。 +XPS 为每个谱区使用独立的有序 recipe,而不是 NMR 管线。recipe 包含能量窗口、平滑和 +归一化;电荷校正由同一测量位置的所有谱区共享。详见 [XPS](/zh-cn/guides/xps/)。 + ## 典型的 1D 谱 新导入的时域 1D 数据集已带有标准管线——切趾、零填充、FFT、相位校正、基线 diff --git a/docs/src/content/docs/zh-cn/guides/xps.md b/docs/src/content/docs/zh-cn/guides/xps.md new file mode 100644 index 0000000..fe7692a --- /dev/null +++ b/docs/src/content/docs/zh-cn/guides/xps.md @@ -0,0 +1,81 @@ +--- +title: XPS +description: 导入、荷电校正、处理、拟合并导出 XPS 谱。 +--- + +PlotX 把一个 VAMAS 文件导入为一个 XPS 实验。数据浏览器保留“测量位置 → 谱区” +层级,每个 Survey 或核心能级区块都是稳定曲线字段,可单独绘制或跨位置叠加。 +默认图优先显示第一个 Survey;没有 Survey 时显示第一个可读区块。结合能轴从左侧 +高能到右侧低能,强度单位为 CPS。 + +## 支持的输入 + +- ISO 14976 VAMAS `.vms`:首版支持具有规则能量标尺的 `NORM` / `REGULAR` XPS + 区块。未知的非 XPS 区块产生警告,其余可读 XPS 继续导入;文件截断、点数不符、 + ordinate 非有限或没有可读 XPS 区块时拒绝整个文件。 +- 结构化 CasaXPS `.txt`:PlotX 按八行结构头识别,保留 BE/CPS、背景、包络、组件、 + lineshape 与原始峰参数。普通两列文本仍按数据表导入。 + +原生能量轴始终保留。原文件给出结合能时直接使用;给出动能时,只有存在光子能量 +才按 `BE = hν - KE` 派生结合能。仅有动能的谱区仍可查看和导出,但禁用荷电校正与 +峰拟合。 + +VAMAS payload 按区块声明的 ordinate 描述解析;ordinate 的最小值和最大值不会被 +误当作谱图数据点。脉冲计数强度同时保留 Counts,并按区块中的 dwell time 和扫描 +次数换算为 CPS;已经标记为计数率的 ordinate 不会再次缩放。 + +## 荷电校正与处理 + +选择 C 1s 谱区,保留默认 284.8 eV 或明确输入其他参考值,然后选择 +**Reference current C 1s**。PlotX 对平滑后的 C 1s 最大值定位一次,并把所得 shift +应用到同一测量位置的所有谱区。shift 改变时,processing window 与背景范围仍跟随 +同一批采样点,组件峰位则保持为绝对化学结合能;原始数组永不改变。 + +每个谱区都有独立、有顺序且可撤销的 processing recipe。可在 **Dataset tools → XPS** +添加能量窗口、Savitzky–Golay 平滑或最大值归一化,并启用、重排或删除步骤;位置级 +荷电校正仍由该位置的所有谱区共享。recipe 改变后,旧的 PlotX 拟合会标为过期,而 +不会改写其 provenance。 + +## 背景与峰拟合 + +XPS 工作台包含 **Acquisition**、**Background**、**Components** 和 **Diagnostics** +四页。背景属于拟合调用,而不进入 processing recipe。可选择 Linear、迭代 Shirley +或 Tougaard U2,并在拟合前预览和修改拟合窗口以及低/高 BE 锚点带。画布已有的范围 +选择工具可把选区写入任一范围,同时保留精确数值输入。 + +Tougaard U2 使用 `K(T) = B T / (C + T²)²`,可编辑默认值为 +`B = 3000 eV²`、`C = 1643 eV²`。它用于描述非弹性损失尾部,但并非对每个谱区都 +自动优于其他背景,结果仍依赖窗口与锚点选择。PlotX 在有限拟合窗口内以锚点约束 +方式应用该核函数;它不替代完整的 QUASES 深度分布分析。 + +可以手动添加和重排组件、明确选择 C 1s/N 1s/O 1s 模板,或复制为关联组件。模板 +只给出候选初值,PlotX 不宣称化学归属正确。稳定组件 ID 使能量差、共享宽度和面积比 +在重排后仍指向原组件。峰位、FWHM 与面积可分别设为自由有界、固定或适用的关联模式; +缺失引用、自引用与循环引用会被拒绝。自由面积具有可编辑的明确上下界;诊断和数据 +导出都会报告面积触界。 + +默认线形为面积归一化 GL(30) pseudo-Voigt。拟合在可取消的后台任务中运行。结果保存 +背景、包络、组件、残差、峰参数、面积比例、R²、RMSE、残差 lag-1、触界与相关性 +诊断、输入 hash、能量 shift 和完整调用参数。数值协方差通过关联参数传播为标准误和 +近似 95% 区间;局部矩阵奇异时区间明确不可用,不会给出虚假的确定性。 + +可选 wild residual Bootstrap 在可取消后台任务中运行 100–5000 次,默认 500 次。 +种子为 `0` 时由拟合输入确定性派生,也可输入显式种子重复计算。PlotX 保存 2.5%、 +50% 和 97.5% 分位数;收敛率低于 80% 时保留已收敛分布并明确警告。高度联动的组件 +可能显著增加 Bootstrap 成本。 + +R²、宽度和触界警告只是诊断,不是化学有效性的自动判决。CasaXPS 拟合始终保持 +`Imported`,可查看和导出,但不会表示成 PlotX 重新计算结果。仅当该谱区没有启用 +processing step 时,原始拟合曲线才会叠加并随 processed-data 导出;启用裁剪、平滑或 +归一化后,PlotX 会隐藏这些与处理后数据不兼容的原始 CPS 曲线,但仍保留导入参数。 + +## 功能边界 + +Survey 可查看、比较、标注和导出。当前版本不计算 Survey 元素原子百分比。 + +使用 **Export Data…** 导出原始/处理后轴与强度、背景模型、窗口和锚点、扣背景强度、 +包络、残差、组件、参数区间、相关性诊断和 Bootstrap 区间。PDF、SVG、PNG、TIFF 与 +JPEG 仍由图形导出负责。`.plotx` 项目保存实验层级、原始数组、活动谱区、位置荷电校正、 +逐谱区 processing recipe 与拟合工作区、Imported 结果以及 PlotX 拟合与 Bootstrap provenance。项目加载 +时,PlotX 拟合曲线由调用参数和拟合参数重建;只有 Imported CasaXPS 结果保留原始 +曲线数组。 diff --git a/docs/src/content/docs/zh-cn/reference/cli.md b/docs/src/content/docs/zh-cn/reference/cli.md index cd1a415..dd90a05 100644 --- a/docs/src/content/docs/zh-cn/reference/cli.md +++ b/docs/src/content/docs/zh-cn/reference/cli.md @@ -23,6 +23,8 @@ plotx-cli process --scheme --output [--format `inspect` 检测、加载并描述一个受支持的数据集;`--json` 输出稳定的机器 可读报告,便于脚本使用。对 ABF2 记录还会报告 ABF 版本、通道名称与单位、 采样率、扫描数和协议名。 +对 XPS 还会报告测量位置数、谱区数、总点数、谱区名称,以及具有结合能轴或仅有 +动能轴的谱区数量。 `process` 是"一次导入、一个[处理配方](/zh-cn/guides/templates/)、一次 图形导出"的便捷路径。省略 `--format` 时按输出文件扩展名推断格式。 diff --git a/docs/src/content/docs/zh-cn/reference/file-formats.md b/docs/src/content/docs/zh-cn/reference/file-formats.md index 0674892..d2b6026 100644 --- a/docs/src/content/docs/zh-cn/reference/file-formats.md +++ b/docs/src/content/docs/zh-cn/reference/file-formats.md @@ -44,6 +44,19 @@ PlotX(或相反)时,文件会被拒绝并给出明确的"不支持的版 工作流不是配方:配方保存一条处理管线,而工作流描述一整次运行,可以把 配方作为其中一个步骤引用。 +## XPS 导入 + +VAMAS `.vms` 支持明确限定为按内容签名识别、具有规则能量标尺的 ISO 14976 +`NORM` / `REGULAR` XPS 区块。一个文件始终是一个实验,并保留稳定的测量位置与 +谱区 ID。PlotX 保存原生能量、Counts、CPS、光子能量、dwell time、sweep、位置、 +采集条件和自由元数据。只有区块边界可信时才会警告并跳过非 XPS 区块;XPS payload +结构异常时拒绝整个文件。ordinate 极值属于元数据而不是数据点;ordinate 标签和 +信号模式共同决定脉冲计数是否换算为 CPS。 + +CasaXPS `.txt` 按八行结构识别,而不是仅按 `.txt` 扩展名识别。源数组与拟合参数 +以 `Imported` 结果保存;非结构化文本继续走表格导入。能量换算、处理、拟合与导出 +约定见 [XPS](/zh-cn/guides/xps/)。 + ## Origin 项目导入(实验性) Origin 项目导入仍属实验性。成功导入仅限两种通过文件内容精确识别的 OPJ