Skip to main content

koth_core/hills/
tic_norm.rs

1//! Per-scan anomaly normalisation applied before hill detection.
2//!
3//! Computes a per-scan reference quantity (TIC or median peak intensity),
4//! takes a centred rolling median over a wide window, and scales each
5//! scan's peak intensities by `clamp(local_median / scan_reference,
6//! min_scale, max_scale)`. This pulls back peak intensities in scans
7//! whose reference dipped sharply relative to the local elution context
8//! (e.g., brief ESI dropouts the instrument's AGC didn't fully
9//! compensate) without touching scans whose reference matches local
10//! elution.
11//!
12//! Reference mode `tic` (sum of peak intensities) is dominated by a few
13//! intense peaks — real elution apices spike the TIC and get
14//! systematically scaled down by the normaliser, flattening apex shape.
15//! Reference mode `median` (median peak intensity per scan) is robust
16//! to a handful of intense peaks: an apex elution adds tall peaks but
17//! barely moves the median, while a dropout drops the median for every
18//! peak in the scan. `median` is the preferred mode for Orbitrap DDA.
19
20use crate::models::Spectrum;
21
22/// Which per-scan quantity to use as the rolling reference.
23#[derive(Debug, Clone, Copy)]
24pub enum RefMode {
25    /// Sum of all peak intensities (TIC).
26    Tic,
27    /// Median peak intensity per scan.
28    Median,
29}
30
31impl RefMode {
32    pub fn parse(s: &str) -> Self {
33        match s.to_ascii_lowercase().as_str() {
34            "tic" => RefMode::Tic,
35            _ => RefMode::Median,
36        }
37    }
38}
39
40/// Apply rolling-median TIC anomaly normalisation to a slice of MS1 spectra
41/// in place. Non-MS1 spectra are not modified.
42///
43/// `window`: full centred window size in scans (e.g., 100 = ±50 scans).
44///           Must be > 0; `0` means "off" and the caller should not call.
45/// `min_scale, max_scale`: clamping bounds on the per-scan multiplier.
46///                         Typical (0.5, 3.0).
47pub fn normalize_tic_in_place(
48    spectra: &mut [Spectrum],
49    window: usize,
50    min_scale: f64,
51    max_scale: f64,
52    mode: RefMode,
53) {
54    if window == 0 || spectra.len() < 2 {
55        return;
56    }
57
58    // Indices of MS1 spectra and per-scan reference quantities.
59    let ms1_idx: Vec<usize> = spectra
60        .iter()
61        .enumerate()
62        .filter_map(|(i, s)| if s.ms_level == 1 { Some(i) } else { None })
63        .collect();
64    if ms1_idx.len() < 2 {
65        return;
66    }
67
68    let mut tics: Vec<f64> = Vec::with_capacity(ms1_idx.len());
69    let mut buf: Vec<f64> = Vec::new();
70    for &i in &ms1_idx {
71        let val = match mode {
72            RefMode::Tic => spectra[i].peaks.iter().map(|p| p.intensity as f64).sum(),
73            RefMode::Median => {
74                buf.clear();
75                buf.extend(spectra[i].peaks.iter().map(|p| p.intensity as f64));
76                if buf.is_empty() {
77                    0.0
78                } else {
79                    buf.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
80                    let m = buf.len();
81                    if m % 2 == 1 {
82                        buf[m / 2]
83                    } else {
84                        0.5 * (buf[m / 2 - 1] + buf[m / 2])
85                    }
86                }
87            }
88        };
89        tics.push(val);
90    }
91
92    let half = window / 2;
93    let n = tics.len();
94
95    // Rolling median over centred window. Reuses a sortable scratch Vec.
96    let mut scratch: Vec<f64> = Vec::with_capacity(window + 1);
97    let mut log_scales: Vec<f64> = Vec::with_capacity(n);
98    let mut n_clamped_low = 0usize;
99    let mut n_clamped_high = 0usize;
100    let mut sum_abs_log_scale = 0.0f64;
101
102    for i in 0..n {
103        let lo = i.saturating_sub(half);
104        let hi = (i + half + 1).min(n);
105        scratch.clear();
106        scratch.extend_from_slice(&tics[lo..hi]);
107        scratch.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
108        let m = scratch.len();
109        let median = if m == 0 {
110            tics[i].max(1.0)
111        } else if m % 2 == 1 {
112            scratch[m / 2]
113        } else {
114            0.5 * (scratch[m / 2 - 1] + scratch[m / 2])
115        };
116
117        let raw_scale = if tics[i] > 0.0 {
118            median / tics[i]
119        } else {
120            max_scale
121        };
122        let clamped = raw_scale.clamp(min_scale, max_scale);
123        if raw_scale < min_scale {
124            n_clamped_low += 1;
125        } else if raw_scale > max_scale {
126            n_clamped_high += 1;
127        }
128        log_scales.push(clamped.ln());
129        sum_abs_log_scale += clamped.ln().abs();
130
131        if (clamped - 1.0).abs() > 1e-9 {
132            let sp_idx = ms1_idx[i];
133            let s = clamped as f32;
134            for p in &mut spectra[sp_idx].peaks {
135                p.intensity *= s;
136            }
137        }
138    }
139
140    let mean_abs_log = sum_abs_log_scale / n as f64;
141    let mean_distort = (mean_abs_log.exp() - 1.0) * 100.0;
142    let mode_name = match mode {
143        RefMode::Tic => "tic",
144        RefMode::Median => "median",
145    };
146    log::info!(
147        "TIC normalisation: mode={} window={} scans, ({}/{}) clamped low/high, \
148         mean |Δlog scale| = {:.4} (~{:.1}% mean intensity distortion)",
149        mode_name,
150        window,
151        n_clamped_low,
152        n_clamped_high,
153        mean_abs_log,
154        mean_distort,
155    );
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use crate::models::{Peak, Spectrum};
162
163    fn spec(rt: f64, intensities: &[f32]) -> Spectrum {
164        Spectrum {
165            scan_index: 0,
166            ms_level: 1,
167            retention_time: rt,
168            peaks: intensities
169                .iter()
170                .enumerate()
171                .map(|(i, &v)| Peak {
172                    mz: 100.0 + i as f32,
173                    intensity: v,
174                    ion_mobility: 0.0,
175                })
176                .collect(),
177            isolation_window: None,
178            faims_cv: None,
179        }
180    }
181
182    #[test]
183    fn off_when_window_is_zero() {
184        let mut s = vec![spec(0.0, &[1.0, 2.0]), spec(1.0, &[1.0, 2.0])];
185        let orig = s.iter().map(|sp| sp.peaks[0].intensity).collect::<Vec<_>>();
186        normalize_tic_in_place(&mut s, 0, 0.5, 3.0, RefMode::Tic);
187        let after = s.iter().map(|sp| sp.peaks[0].intensity).collect::<Vec<_>>();
188        assert_eq!(orig, after);
189    }
190
191    #[test]
192    fn boosts_anomalously_low_scan_tic_mode() {
193        let mut s: Vec<Spectrum> = (0..21)
194            .map(|i| {
195                let t = if i == 10 { 10.0 } else { 100.0 };
196                spec(i as f64, &[t])
197            })
198            .collect();
199        normalize_tic_in_place(&mut s, 11, 0.5, 3.0, RefMode::Tic);
200        assert!((s[10].peaks[0].intensity - 30.0).abs() < 1e-3);
201        assert!((s[5].peaks[0].intensity - 100.0).abs() < 1e-3);
202    }
203
204    #[test]
205    fn median_mode_ignores_apex_but_corrects_dropout() {
206        // 21 scans. Most have peaks [10, 20, 30] (median 20). Scan 10 has
207        // a giant apex peak: [10, 20, 30, 10000] (median ~25, similar to
208        // baseline) — TIC mode would scale apex DOWN here; median mode
209        // should leave it alone. Scan 15 is a uniform dropout: [1, 2, 3]
210        // (median 2, 10× lower) — both modes should boost it.
211        let mut s: Vec<Spectrum> = (0..21)
212            .map(|i| {
213                if i == 10 {
214                    spec(i as f64, &[10.0, 20.0, 30.0, 10000.0])
215                } else if i == 15 {
216                    spec(i as f64, &[1.0, 2.0, 3.0])
217                } else {
218                    spec(i as f64, &[10.0, 20.0, 30.0])
219                }
220            })
221            .collect();
222        normalize_tic_in_place(&mut s, 11, 0.5, 3.0, RefMode::Median);
223        // Apex scan median is 25 vs window median 20 → scale ≈ 0.8.
224        // Apex peak 10000 should be barely changed (within ~25%).
225        assert!(
226            s[10].peaks[3].intensity > 7000.0,
227            "apex peak should not be dragged down too much in median mode (got {})",
228            s[10].peaks[3].intensity
229        );
230        // Dropout scan median is 2 vs window median 20 → ratio 10×, clamped to 3.0.
231        // Peak 3.0 should become 9.0.
232        assert!(
233            (s[15].peaks[2].intensity - 9.0).abs() < 0.5,
234            "dropout peak should be boosted ~3× (got {})",
235            s[15].peaks[2].intensity
236        );
237    }
238}