koth_core/hills/
tic_norm.rs1use crate::models::Spectrum;
21
22#[derive(Debug, Clone, Copy)]
24pub enum RefMode {
25 Tic,
27 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
40pub 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 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 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 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 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 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}