use std::sync::Arc;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum Polarity {
#[default]
Positive,
Negative,
}
pub const PROTON_MASS: f64 = 1.007_276_466_621;
impl Polarity {
pub fn neutral_mass(&self, mz: f64, charge: u8) -> f64 {
let z = charge as f64;
match self {
Self::Positive => mz * z - z * PROTON_MASS,
Self::Negative => mz * z + z * PROTON_MASS,
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct Peak {
pub mz: f32,
pub intensity: f32,
pub ion_mobility: f32,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct IsolationWindow {
pub target: f64,
pub lower: f64,
pub upper: f64,
}
impl IsolationWindow {
pub fn key(&self) -> (i64, i64, i64) {
(
(self.target * 10_000.0).round() as i64,
(self.lower * 10_000.0).round() as i64,
(self.upper * 10_000.0).round() as i64,
)
}
}
impl PartialEq for IsolationWindow {
fn eq(&self, other: &Self) -> bool {
self.key() == other.key()
}
}
impl Eq for IsolationWindow {}
#[derive(Debug, Clone)]
pub struct Spectrum {
pub scan_index: usize,
pub retention_time: f64,
pub peaks: Vec<Peak>,
pub ms_level: u8,
pub isolation_window: Option<IsolationWindow>,
pub faims_cv: Option<f32>,
}
impl Spectrum {
pub fn has_ion_mobility(&self) -> bool {
self.peaks.iter().any(|p| p.ion_mobility != 0.0)
}
}
#[derive(Debug, Clone)]
pub struct Hill {
pub hill_id: u64,
pub mz: f64,
pub mz_std: f64,
pub mz_se: f64,
pub rt: f64,
pub rt_start: f64,
pub rt_end: f64,
pub rt_width: f64,
pub im: f64,
pub im_std: f64,
pub scan_start: usize,
pub scan_apex: usize,
pub scan_end: usize,
pub n_scans: usize,
pub skipped_scans: usize,
pub intensity_sum: f64,
pub intensity_max: f64,
pub hill_score: f64,
pub intensity_profile: Arc<[f32]>,
pub isolation_window: Option<IsolationWindow>,
pub faims_cv: Option<f32>,
}
impl Hill {
pub fn intensity_at_scan(&self, scan_idx: usize) -> f64 {
if scan_idx < self.scan_start || scan_idx > self.scan_end {
return 0.0;
}
let rel = scan_idx - self.scan_start;
if rel < self.intensity_profile.len() {
self.intensity_profile[rel] as f64
} else {
0.0
}
}
pub fn overlaps(&self, other: &Hill) -> bool {
!(self.scan_end < other.scan_start || other.scan_end < self.scan_start)
}
}
#[derive(Debug, Clone)]
pub struct Feature {
pub hills: Vec<Hill>,
pub charge: u8,
pub cosine_score: f64,
pub ppm_error: f64,
}
impl Feature {
pub fn monoisotopic_hill(&self) -> &Hill {
&self.hills[0]
}
pub fn monoisotopic_mz(&self) -> f64 {
self.hills[0].mz
}
pub fn faims_cv(&self) -> Option<f32> {
self.hills.first().and_then(|h| h.faims_cv)
}
pub fn monoisotopic_neutral_mass(&self, polarity: Polarity) -> Option<f64> {
if self.charge == 0 {
return None;
}
Some(polarity.neutral_mass(self.monoisotopic_mz(), self.charge))
}
pub fn rt_apex(&self) -> f64 {
self.hills
.iter()
.max_by(|a, b| a.intensity_max.partial_cmp(&b.intensity_max).unwrap())
.map(|h| h.rt)
.unwrap_or(0.0)
}
pub fn rt_start(&self) -> f64 {
self.hills
.iter()
.map(|h| h.rt_start)
.fold(f64::INFINITY, f64::min)
}
pub fn rt_end(&self) -> f64 {
self.hills
.iter()
.map(|h| h.rt_end)
.fold(f64::NEG_INFINITY, f64::max)
}
pub fn im_apex(&self) -> f64 {
self.hills
.iter()
.max_by(|a, b| a.intensity_max.partial_cmp(&b.intensity_max).unwrap())
.map(|h| h.im)
.unwrap_or(0.0)
}
pub fn total_intensity(&self) -> f64 {
self.hills.iter().map(|h| h.intensity_sum).sum()
}
pub fn n_scans_total(&self) -> usize {
self.hills.iter().map(|h| h.n_scans).sum()
}
pub fn elution_profile(&self) -> (usize, usize, Vec<f64>) {
let min_scan = self.hills.iter().map(|h| h.scan_start).min().unwrap_or(0);
let max_scan = self.hills.iter().map(|h| h.scan_end).max().unwrap_or(0);
let scan_range = max_scan - min_scan + 1;
let mut profile = vec![0.0f64; scan_range];
for hill in &self.hills {
for (i, &intensity) in hill.intensity_profile.iter().enumerate() {
let idx = hill.scan_start + i - min_scan;
if idx < scan_range {
profile[idx] += intensity as f64;
}
}
}
(min_scan, max_scan, profile)
}
pub fn total_intensity_at_apex(&self) -> f64 {
let (_, _, profile) = self.elution_profile();
profile.iter().cloned().fold(0.0f64, f64::max)
}
pub fn apex_scan(&self) -> usize {
let (min_scan, _, profile) = self.elution_profile();
profile
.iter()
.enumerate()
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
.map(|(i, _)| min_scan + i)
.unwrap_or(0)
}
pub fn isotope_profile_apex(&self) -> Vec<f64> {
let apex = self.apex_scan();
self.hills
.iter()
.map(|h| h.intensity_at_scan(apex))
.collect()
}
pub fn mono_scan_list(&self) -> Vec<usize> {
let h = &self.hills[0];
(h.scan_start..=h.scan_end).collect()
}
pub fn mono_intensity_list(&self) -> &[f32] {
&self.hills[0].intensity_profile
}
}
#[derive(Debug, Clone)]
pub struct ScoredFeature {
pub feature: Feature,
pub neutron_offset: i8,
pub isotope_score: f64,
pub cosine_score: f64,
pub combined_score: f64,
pub theoretical_pattern: Vec<f64>,
}
impl ScoredFeature {
pub fn monoisotopic_mz(&self) -> f64 {
if self.feature.charge == 0 {
return self.feature.monoisotopic_mz();
}
const C13_NEUTRON: f64 = 1.003_354_835;
self.feature.monoisotopic_mz()
- (self.neutron_offset as f64 * C13_NEUTRON / self.feature.charge as f64)
}
pub fn monoisotopic_neutral_mass(&self, polarity: Polarity) -> Option<f64> {
let base = self.feature.monoisotopic_neutral_mass(polarity)?;
const C13_NEUTRON: f64 = 1.003_354_835;
Some(base - self.neutron_offset as f64 * C13_NEUTRON)
}
}