Skip to main content

koth_core/
models.rs

1use std::sync::Arc;
2
3use serde::{Deserialize, Serialize};
4
5/// Ion polarity: which way the charge-carrying adduct shifts m/z, and so which
6/// way to undo it when recovering a neutral mass.
7///
8/// Positive mode (protonation, `M + zH`) is the default and covers peptides.
9/// Nucleic acids are run in negative mode (deprotonation, `M − zH`), where
10/// subtracting the protons instead of adding them mis-masses every feature by
11/// `2·z·1.00728` Da — 8 Da on a 4-charged oligonucleotide.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
13#[serde(rename_all = "snake_case")]
14pub enum Polarity {
15    /// `M + zH`: neutral mass = `mz·z − z·proton`.
16    #[default]
17    Positive,
18    /// `M − zH`: neutral mass = `mz·z + z·proton`.
19    Negative,
20}
21
22/// Mass of a proton, in Da.
23pub const PROTON_MASS: f64 = 1.007_276_466_621;
24
25impl Polarity {
26    /// Neutral mass of an ion at `mz` carrying `charge` adducts of this polarity.
27    pub fn neutral_mass(&self, mz: f64, charge: u8) -> f64 {
28        let z = charge as f64;
29        match self {
30            Self::Positive => mz * z - z * PROTON_MASS,
31            Self::Negative => mz * z + z * PROTON_MASS,
32        }
33    }
34}
35
36/// A single centroided MS1 peak.
37#[derive(Debug, Clone, Copy)]
38pub struct Peak {
39    pub mz: f32,
40    pub intensity: f32,
41    /// Ion mobility value (1/K0 for timsTOF). 0.0 means not available.
42    pub ion_mobility: f32,
43}
44
45/// Precursor isolation window for an MS2 spectrum (mzML coordinates).
46///
47/// All values in m/z. `lower` and `upper` are absolute boundaries (already
48/// expanded from any target offset in the source file).
49#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
50pub struct IsolationWindow {
51    pub target: f64,
52    pub lower: f64,
53    pub upper: f64,
54}
55
56impl IsolationWindow {
57    /// Stable integer key for grouping MS2 scans that share an isolation window.
58    /// Encodes (target, lower, upper) to 4 decimal places.
59    pub fn key(&self) -> (i64, i64, i64) {
60        (
61            (self.target * 10_000.0).round() as i64,
62            (self.lower * 10_000.0).round() as i64,
63            (self.upper * 10_000.0).round() as i64,
64        )
65    }
66}
67
68impl PartialEq for IsolationWindow {
69    fn eq(&self, other: &Self) -> bool {
70        self.key() == other.key()
71    }
72}
73
74impl Eq for IsolationWindow {}
75
76/// A single mass spectrum with all its peaks.
77#[derive(Debug, Clone)]
78pub struct Spectrum {
79    /// 0-based sequential index (scan number in the run)
80    pub scan_index: usize,
81    /// Retention time in minutes
82    pub retention_time: f64,
83    /// Peaks sorted by mz ascending
84    pub peaks: Vec<Peak>,
85    /// 1 for MS1, 2 for MS2, etc.
86    pub ms_level: u8,
87    /// Precursor isolation window (only set for MS2 spectra)
88    pub isolation_window: Option<IsolationWindow>,
89    /// FAIMS compensation voltage for this scan, in volts. `None` when the
90    /// acquisition does not use FAIMS or the source format does not expose it.
91    pub faims_cv: Option<f32>,
92}
93
94impl Spectrum {
95    pub fn has_ion_mobility(&self) -> bool {
96        self.peaks.iter().any(|p| p.ion_mobility != 0.0)
97    }
98}
99
100/// A finalized chromatographic hill (elution peak for a single m/z trace).
101///
102/// Column names match the Python zenith_feature_finder hills.tsv output exactly.
103#[derive(Debug, Clone)]
104pub struct Hill {
105    /// Stable identifier assigned at the end of hill detection. Matches the
106    /// `hill_id` column in `hills.{tsv,parquet}` and the entries in the
107    /// `hill_ids` column of `features.{tsv,parquet}`. IDs are unique within a
108    /// single hills file (MS1 and MS2 each have their own ID space).
109    pub hill_id: u64,
110    pub mz: f64,
111    pub mz_std: f64,
112    /// Standard error of the intensity-weighted m/z estimate (Kish-effective
113    /// sample size). Zero for hills with <2 real peaks. Written to the hills
114    /// output as a per-hill m/z-confidence diagnostic.
115    pub mz_se: f64,
116    pub rt: f64,
117    pub rt_start: f64,
118    pub rt_end: f64,
119    pub rt_width: f64,
120    pub im: f64,
121    pub im_std: f64,
122    pub scan_start: usize,
123    pub scan_apex: usize,
124    pub scan_end: usize,
125    pub n_scans: usize,
126    pub skipped_scans: usize,
127    pub intensity_sum: f64,
128    pub intensity_max: f64,
129    /// Monotonicity-fraction shape score (0–1). 1.0 = perfectly hill-shaped.
130    pub hill_score: f64,
131    /// Per-scan intensity profile (length == n_scans, zeros where gaps)
132    pub intensity_profile: Arc<[f32]>,
133    /// Isolation window the hill was built from (None for MS1 hills,
134    /// Some for MS2/DIA hills).
135    pub isolation_window: Option<IsolationWindow>,
136    /// FAIMS compensation voltage shared by every scan in this hill.
137    /// `None` for non-FAIMS data and for formats without FAIMS metadata.
138    pub faims_cv: Option<f32>,
139}
140
141impl Hill {
142    /// Intensity at a specific absolute scan index, or 0.0 if out of range.
143    pub fn intensity_at_scan(&self, scan_idx: usize) -> f64 {
144        if scan_idx < self.scan_start || scan_idx > self.scan_end {
145            return 0.0;
146        }
147        let rel = scan_idx - self.scan_start;
148        if rel < self.intensity_profile.len() {
149            self.intensity_profile[rel] as f64
150        } else {
151            0.0
152        }
153    }
154
155    /// Returns true if this hill's scan range overlaps with another.
156    pub fn overlaps(&self, other: &Hill) -> bool {
157        !(self.scan_end < other.scan_start || other.scan_end < self.scan_start)
158    }
159}
160
161/// A detected isotope feature: a group of hills forming an isotope envelope.
162///
163/// `hills` are sorted by mz (monoisotopic first).
164/// `charge == 0` means unknown charge (single peak, no isotope partners found).
165#[derive(Debug, Clone)]
166pub struct Feature {
167    pub hills: Vec<Hill>,
168    pub charge: u8,
169    /// Mean **chromatographic** cosine similarity (in scan/RT space) between
170    /// adjacent isotope hills in the chain. Bounded [0, 1]. Measures whether
171    /// the isotope hills co-elute in time — *not* whether their intensities
172    /// match an averagine pattern.
173    pub cosine_score: f64,
174    pub ppm_error: f64,
175}
176
177impl Feature {
178    pub fn monoisotopic_hill(&self) -> &Hill {
179        &self.hills[0]
180    }
181
182    pub fn monoisotopic_mz(&self) -> f64 {
183        self.hills[0].mz
184    }
185
186    /// FAIMS compensation voltage of this feature, inherited from its
187    /// monoisotopic hill. Feature assembly guarantees all member hills share
188    /// this value.
189    pub fn faims_cv(&self) -> Option<f32> {
190        self.hills.first().and_then(|h| h.faims_cv)
191    }
192
193    /// Neutral monoisotopic mass, undoing `polarity`'s adduct (`mz·z ∓ z·proton`).
194    pub fn monoisotopic_neutral_mass(&self, polarity: Polarity) -> Option<f64> {
195        if self.charge == 0 {
196            return None;
197        }
198        Some(polarity.neutral_mass(self.monoisotopic_mz(), self.charge))
199    }
200
201    pub fn rt_apex(&self) -> f64 {
202        // RT at the hill with the highest intensity_max
203        self.hills
204            .iter()
205            .max_by(|a, b| a.intensity_max.partial_cmp(&b.intensity_max).unwrap())
206            .map(|h| h.rt)
207            .unwrap_or(0.0)
208    }
209
210    pub fn rt_start(&self) -> f64 {
211        self.hills
212            .iter()
213            .map(|h| h.rt_start)
214            .fold(f64::INFINITY, f64::min)
215    }
216
217    pub fn rt_end(&self) -> f64 {
218        self.hills
219            .iter()
220            .map(|h| h.rt_end)
221            .fold(f64::NEG_INFINITY, f64::max)
222    }
223
224    pub fn im_apex(&self) -> f64 {
225        self.hills
226            .iter()
227            .max_by(|a, b| a.intensity_max.partial_cmp(&b.intensity_max).unwrap())
228            .map(|h| h.im)
229            .unwrap_or(0.0)
230    }
231
232    pub fn total_intensity(&self) -> f64 {
233        self.hills.iter().map(|h| h.intensity_sum).sum()
234    }
235
236    pub fn n_scans_total(&self) -> usize {
237        self.hills.iter().map(|h| h.n_scans).sum()
238    }
239
240    /// Combined elution profile: sum of all isotope hill intensities per scan.
241    /// Returns (min_scan, max_scan, profile_vec).
242    pub fn elution_profile(&self) -> (usize, usize, Vec<f64>) {
243        let min_scan = self.hills.iter().map(|h| h.scan_start).min().unwrap_or(0);
244        let max_scan = self.hills.iter().map(|h| h.scan_end).max().unwrap_or(0);
245        let scan_range = max_scan - min_scan + 1;
246        let mut profile = vec![0.0f64; scan_range];
247        for hill in &self.hills {
248            for (i, &intensity) in hill.intensity_profile.iter().enumerate() {
249                let idx = hill.scan_start + i - min_scan;
250                if idx < scan_range {
251                    profile[idx] += intensity as f64;
252                }
253            }
254        }
255        (min_scan, max_scan, profile)
256    }
257
258    /// Total intensity at the apex scan (summed across all isotope hills).
259    pub fn total_intensity_at_apex(&self) -> f64 {
260        let (_, _, profile) = self.elution_profile();
261        profile.iter().cloned().fold(0.0f64, f64::max)
262    }
263
264    /// Apex scan index: scan where the sum of all isotope hill intensities is highest.
265    pub fn apex_scan(&self) -> usize {
266        let (min_scan, _, profile) = self.elution_profile();
267        profile
268            .iter()
269            .enumerate()
270            .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
271            .map(|(i, _)| min_scan + i)
272            .unwrap_or(0)
273    }
274
275    /// Isotope profile at the apex scan: one intensity per hill.
276    pub fn isotope_profile_apex(&self) -> Vec<f64> {
277        let apex = self.apex_scan();
278        self.hills
279            .iter()
280            .map(|h| h.intensity_at_scan(apex))
281            .collect()
282    }
283
284    /// Scan list for monoisotopic hill.
285    pub fn mono_scan_list(&self) -> Vec<usize> {
286        let h = &self.hills[0];
287        (h.scan_start..=h.scan_end).collect()
288    }
289
290    /// Intensity list for monoisotopic hill.
291    pub fn mono_intensity_list(&self) -> &[f32] {
292        &self.hills[0].intensity_profile
293    }
294}
295
296/// Feature with isotope pattern scoring applied.
297///
298/// Carries three distinct quality scores so downstream stages (alignment,
299/// consensus grouping, LFQ filters) can filter on whichever makes sense for
300/// their purpose. All three are bounded `[0, 1]`; higher = better.
301#[derive(Debug, Clone)]
302pub struct ScoredFeature {
303    pub feature: Feature,
304    /// Neutron offset applied: -1, 0, or 1.
305    /// Non-zero means the observed monoisotopic peak is actually M+|offset|.
306    pub neutron_offset: i8,
307    /// **Isotope-pattern** score: Bhattacharyya coefficient between the
308    /// observed apex-scan isotope intensities and the theoretical averagine
309    /// template. Measures "does the isotope envelope look like a peptide?".
310    pub isotope_score: f64,
311    /// **Chromatographic cosine** score: mirror of `feature.cosine_score` —
312    /// the mean cosine similarity (in time domain) between adjacent isotope
313    /// hills. Measures "do the isotope hills co-elute?".
314    pub cosine_score: f64,
315    /// **Combined** score: `isotope_score × cosine_score`. The default
316    /// quality knob used by alignment, consensus grouping, and feature
317    /// retention. High requires both a good pattern match AND tight co-elution.
318    pub combined_score: f64,
319    /// Theoretical averagine isotope pattern (normalized to sum=1)
320    pub theoretical_pattern: Vec<f64>,
321}
322
323impl ScoredFeature {
324    /// Corrected monoisotopic m/z (adjusted by neutron_offset).
325    pub fn monoisotopic_mz(&self) -> f64 {
326        if self.feature.charge == 0 {
327            return self.feature.monoisotopic_mz();
328        }
329        const C13_NEUTRON: f64 = 1.003_354_835;
330        self.feature.monoisotopic_mz()
331            - (self.neutron_offset as f64 * C13_NEUTRON / self.feature.charge as f64)
332    }
333
334    /// Corrected neutral monoisotopic mass.
335    pub fn monoisotopic_neutral_mass(&self, polarity: Polarity) -> Option<f64> {
336        let base = self.feature.monoisotopic_neutral_mass(polarity)?;
337        const C13_NEUTRON: f64 = 1.003_354_835;
338        Some(base - self.neutron_offset as f64 * C13_NEUTRON)
339    }
340}