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}