use crate::models::Spectrum;
#[derive(Debug, Clone, Copy)]
pub enum RefMode {
Tic,
Median,
}
impl RefMode {
pub fn parse(s: &str) -> Self {
match s.to_ascii_lowercase().as_str() {
"tic" => RefMode::Tic,
_ => RefMode::Median,
}
}
}
pub fn normalize_tic_in_place(
spectra: &mut [Spectrum],
window: usize,
min_scale: f64,
max_scale: f64,
mode: RefMode,
) {
if window == 0 || spectra.len() < 2 {
return;
}
let ms1_idx: Vec<usize> = spectra
.iter()
.enumerate()
.filter_map(|(i, s)| if s.ms_level == 1 { Some(i) } else { None })
.collect();
if ms1_idx.len() < 2 {
return;
}
let mut tics: Vec<f64> = Vec::with_capacity(ms1_idx.len());
let mut buf: Vec<f64> = Vec::new();
for &i in &ms1_idx {
let val = match mode {
RefMode::Tic => spectra[i].peaks.iter().map(|p| p.intensity as f64).sum(),
RefMode::Median => {
buf.clear();
buf.extend(spectra[i].peaks.iter().map(|p| p.intensity as f64));
if buf.is_empty() {
0.0
} else {
buf.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let m = buf.len();
if m % 2 == 1 {
buf[m / 2]
} else {
0.5 * (buf[m / 2 - 1] + buf[m / 2])
}
}
}
};
tics.push(val);
}
let half = window / 2;
let n = tics.len();
let mut scratch: Vec<f64> = Vec::with_capacity(window + 1);
let mut log_scales: Vec<f64> = Vec::with_capacity(n);
let mut n_clamped_low = 0usize;
let mut n_clamped_high = 0usize;
let mut sum_abs_log_scale = 0.0f64;
for i in 0..n {
let lo = i.saturating_sub(half);
let hi = (i + half + 1).min(n);
scratch.clear();
scratch.extend_from_slice(&tics[lo..hi]);
scratch.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let m = scratch.len();
let median = if m == 0 {
tics[i].max(1.0)
} else if m % 2 == 1 {
scratch[m / 2]
} else {
0.5 * (scratch[m / 2 - 1] + scratch[m / 2])
};
let raw_scale = if tics[i] > 0.0 {
median / tics[i]
} else {
max_scale
};
let clamped = raw_scale.clamp(min_scale, max_scale);
if raw_scale < min_scale {
n_clamped_low += 1;
} else if raw_scale > max_scale {
n_clamped_high += 1;
}
log_scales.push(clamped.ln());
sum_abs_log_scale += clamped.ln().abs();
if (clamped - 1.0).abs() > 1e-9 {
let sp_idx = ms1_idx[i];
let s = clamped as f32;
for p in &mut spectra[sp_idx].peaks {
p.intensity *= s;
}
}
}
let mean_abs_log = sum_abs_log_scale / n as f64;
let mean_distort = (mean_abs_log.exp() - 1.0) * 100.0;
let mode_name = match mode {
RefMode::Tic => "tic",
RefMode::Median => "median",
};
log::info!(
"TIC normalisation: mode={} window={} scans, ({}/{}) clamped low/high, \
mean |Δlog scale| = {:.4} (~{:.1}% mean intensity distortion)",
mode_name,
window,
n_clamped_low,
n_clamped_high,
mean_abs_log,
mean_distort,
);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{Peak, Spectrum};
fn spec(rt: f64, intensities: &[f32]) -> Spectrum {
Spectrum {
scan_index: 0,
ms_level: 1,
retention_time: rt,
peaks: intensities
.iter()
.enumerate()
.map(|(i, &v)| Peak {
mz: 100.0 + i as f32,
intensity: v,
ion_mobility: 0.0,
})
.collect(),
isolation_window: None,
faims_cv: None,
}
}
#[test]
fn off_when_window_is_zero() {
let mut s = vec![spec(0.0, &[1.0, 2.0]), spec(1.0, &[1.0, 2.0])];
let orig = s.iter().map(|sp| sp.peaks[0].intensity).collect::<Vec<_>>();
normalize_tic_in_place(&mut s, 0, 0.5, 3.0, RefMode::Tic);
let after = s.iter().map(|sp| sp.peaks[0].intensity).collect::<Vec<_>>();
assert_eq!(orig, after);
}
#[test]
fn boosts_anomalously_low_scan_tic_mode() {
let mut s: Vec<Spectrum> = (0..21)
.map(|i| {
let t = if i == 10 { 10.0 } else { 100.0 };
spec(i as f64, &[t])
})
.collect();
normalize_tic_in_place(&mut s, 11, 0.5, 3.0, RefMode::Tic);
assert!((s[10].peaks[0].intensity - 30.0).abs() < 1e-3);
assert!((s[5].peaks[0].intensity - 100.0).abs() < 1e-3);
}
#[test]
fn median_mode_ignores_apex_but_corrects_dropout() {
let mut s: Vec<Spectrum> = (0..21)
.map(|i| {
if i == 10 {
spec(i as f64, &[10.0, 20.0, 30.0, 10000.0])
} else if i == 15 {
spec(i as f64, &[1.0, 2.0, 3.0])
} else {
spec(i as f64, &[10.0, 20.0, 30.0])
}
})
.collect();
normalize_tic_in_place(&mut s, 11, 0.5, 3.0, RefMode::Median);
assert!(
s[10].peaks[3].intensity > 7000.0,
"apex peak should not be dragged down too much in median mode (got {})",
s[10].peaks[3].intensity
);
assert!(
(s[15].peaks[2].intensity - 9.0).abs() < 0.5,
"dropout peak should be boosted ~3× (got {})",
s[15].peaks[2].intensity
);
}
}