use std::sync::Arc;
use super::smooth;
use crate::config::HillsConfig;
use crate::models::Hill;
fn build_sub_hills(hill: &Hill, split_points: &[usize], min_scans: usize) -> Vec<Hill> {
let profile = &hill.intensity_profile;
if split_points.is_empty() {
return vec![hill.clone()];
}
let boundaries: Vec<usize> = std::iter::once(0)
.chain(split_points.iter().copied())
.chain(std::iter::once(profile.len()))
.collect();
let rt_per_scan = if hill.scan_end > hill.scan_start {
(hill.rt_end - hill.rt_start) / (hill.scan_end - hill.scan_start) as f64
} else {
0.0
};
let mut result = Vec::new();
for window in boundaries.windows(2) {
let seg_start = window[0];
let seg_end = window[1];
let segment_profile: Vec<f32> = profile[seg_start..seg_end].to_vec();
if segment_profile.len() < min_scans {
continue;
}
let apex_relative = segment_profile
.iter()
.enumerate()
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap())
.map(|(i, _)| i)
.unwrap_or(0);
let abs_start = hill.scan_start + seg_start;
let abs_end = hill.scan_start + seg_end - 1;
let abs_apex = abs_start + apex_relative;
let rt_start = hill.rt_start + (abs_start - hill.scan_start) as f64 * rt_per_scan;
let rt_end = hill.rt_start + (abs_end - hill.scan_start) as f64 * rt_per_scan;
let rt_apex = hill.rt_start + (abs_apex - hill.scan_start) as f64 * rt_per_scan;
let intensity_sum: f64 = segment_profile.iter().map(|&x| x as f64).sum();
let intensity_max = segment_profile
.iter()
.map(|&x| x as f64)
.fold(0.0f64, f64::max);
let skipped = segment_profile.iter().filter(|&&x| x == 0.0).count();
let hill_score = smooth::compute_hill_score(&segment_profile);
result.push(Hill {
hill_id: 0, mz: hill.mz,
mz_std: hill.mz_std,
mz_se: hill.mz_se,
rt: rt_apex,
rt_start,
rt_end,
rt_width: rt_end - rt_start,
im: hill.im,
im_std: hill.im_std,
scan_start: abs_start,
scan_apex: abs_apex,
scan_end: abs_end,
n_scans: segment_profile.len(),
skipped_scans: skipped,
intensity_sum,
intensity_max,
hill_score,
intensity_profile: Arc::from(segment_profile.as_slice()),
isolation_window: hill.isolation_window,
faims_cv: hill.faims_cv,
});
}
if result.is_empty() {
vec![hill.clone()]
} else {
result
}
}
fn valleys_between(prof: &[f32], peaks: &[usize], min_scans: usize) -> Vec<usize> {
let mut split_points = Vec::new();
for window in peaks.windows(2) {
let (left_peak, right_peak) = (window[0], window[1]);
let valley_idx = (left_peak..=right_peak)
.min_by(|&a, &b| prof[a].partial_cmp(&prof[b]).unwrap())
.unwrap_or(left_peak);
if valley_idx >= min_scans && prof.len() - valley_idx >= min_scans {
split_points.push(valley_idx);
}
}
split_points
}
pub fn split_hill_persistence(
hill: &Hill,
height_frac: f64,
valley_ratio: f64,
sigma_mult: f64,
min_scans: usize,
) -> Vec<Hill> {
let profile = &hill.intensity_profile;
if profile.len() < min_scans * 2 {
return vec![hill.clone()];
}
let despiked = median3(profile);
let mut s = despiked.clone();
smooth::running_average(&mut s, 2);
let sigma = noise_sigma(&despiked).max(1e-6);
let floor = sigma_mult as f32 * sigma;
let robust_max = percentile(&s, 0.95).max(1e-6);
let min_h = (height_frac as f32 * robust_max).max(floor);
let mut peaks = local_maxima(&s, min_h); if peaks.len() <= 1 {
return vec![hill.clone()];
}
let ratio = valley_ratio as f32;
loop {
if peaks.len() <= 1 {
break;
}
let mut merge_at: Option<(usize, f32)> = None; for (idx, w) in peaks.windows(2).enumerate() {
let v = valley_min(&s, w[0], w[1]);
let smaller = s[w[0]].min(s[w[1]]).max(1e-6);
let r = v / smaller; let depth = smaller - v;
if r > ratio || depth < floor {
let badness = (r - ratio).max(0.0) + (floor - depth).max(0.0) / floor;
if merge_at.is_none_or(|(_, b)| badness > b) {
merge_at = Some((idx, badness));
}
}
}
let Some((idx, _)) = merge_at else { break };
let (a, b) = (peaks[idx], peaks[idx + 1]);
let drop = if s[a] <= s[b] { idx } else { idx + 1 };
peaks.remove(drop);
}
const MIN_WIDTH: usize = 4;
peaks.retain(|&pk| {
let half = 0.5 * s[pk];
let lo = pk.saturating_sub(8);
let hi = (pk + 9).min(s.len());
(lo..hi).filter(|&i| s[i] >= half).count() >= MIN_WIDTH
});
if peaks.len() <= 1 {
return vec![hill.clone()];
}
let split_points = valleys_between(&s, &peaks, min_scans);
build_sub_hills(hill, &split_points, min_scans)
}
fn median3(p: &[f32]) -> Vec<f32> {
let n = p.len();
if n < 3 {
return p.to_vec();
}
let mut out = p.to_vec();
for i in 1..n - 1 {
let mut t = [p[i - 1], p[i], p[i + 1]];
t.sort_by(|a, b| a.partial_cmp(b).unwrap());
out[i] = t[1];
}
out
}
fn percentile(p: &[f32], q: f32) -> f32 {
if p.is_empty() {
return 0.0;
}
let mut s = p.to_vec();
s.sort_by(|a, b| a.partial_cmp(b).unwrap());
let idx = ((s.len() as f32 - 1.0) * q).round() as usize;
s[idx]
}
fn noise_sigma(p: &[f32]) -> f32 {
if p.len() < 3 {
return 0.0;
}
let mut d: Vec<f32> = p.windows(2).map(|w| (w[1] - w[0]).abs()).collect();
d.sort_by(|a, b| a.partial_cmp(b).unwrap());
let med = d[d.len() / 2];
med * 1.4826 / std::f32::consts::SQRT_2
}
fn local_maxima(p: &[f32], min_height: f32) -> Vec<usize> {
let n = p.len();
let mut out = Vec::new();
for i in 0..n {
let l = i == 0 || p[i] >= p[i - 1];
let r = i == n - 1 || p[i] >= p[i + 1];
if p[i] >= min_height && l && r {
out.push(i);
}
}
out
}
fn valley_min(p: &[f32], a: usize, b: usize) -> f32 {
p[a..=b].iter().cloned().fold(f32::INFINITY, f32::min)
}
pub fn split_coeluting(hills: Vec<Hill>, config: &HillsConfig) -> Vec<Hill> {
let mut result = Vec::with_capacity(hills.len());
let mut split_count = 0usize;
for hill in &hills {
let parts = split_hill_persistence(
hill,
config.split_height_frac,
config.split_valley_ratio,
config.split_sigma_mult,
config.min_scans,
);
if parts.len() > 1 {
split_count += 1;
}
result.extend(parts);
}
log::debug!(
"Split {} co-eluting hills (persistence algo); total hills now: {}",
split_count,
result.len()
);
result
}
#[cfg(test)]
mod tests {
use super::*;
fn hill_from_profile(profile: Vec<f32>) -> Hill {
let n = profile.len();
let max = profile.iter().cloned().fold(0.0f32, f32::max) as f64;
Hill {
hill_id: 0,
mz: 500.0,
mz_std: 0.001,
mz_se: 0.0005,
rt: 0.0,
rt_start: 0.0,
rt_end: (n - 1) as f64,
rt_width: (n - 1) as f64,
im: 0.0,
im_std: 0.0,
scan_start: 0,
scan_apex: 0,
scan_end: n - 1,
n_scans: n,
skipped_scans: 0,
intensity_sum: profile.iter().map(|&x| x as f64).sum(),
intensity_max: max,
hill_score: 1.0,
intensity_profile: Arc::from(profile.as_slice()),
isolation_window: None,
faims_cv: None,
}
}
fn gaussian(n: usize, center: f32, sigma: f32, amp: f32) -> Vec<f32> {
(0..n)
.map(|i| {
let d = (i as f32 - center) / sigma;
amp * (-0.5 * d * d).exp()
})
.collect()
}
#[test]
fn median3_kills_lone_spike() {
let p = vec![10.0f32, 9.0, 500.0, 11.0, 10.0];
let m = median3(&p);
assert!(
m[2] < 12.0,
"spike should be replaced by a neighbour: {m:?}"
);
}
#[test]
fn persistence_splits_clean_dual() {
let mut p = gaussian(100, 35.0, 5.0, 1000.0);
for (i, v) in gaussian(100, 65.0, 5.0, 1000.0).into_iter().enumerate() {
p[i] += v;
}
let hill = hill_from_profile(p);
let parts = split_hill_persistence(&hill, 0.10, 0.70, 4.0, 3);
assert_eq!(parts.len(), 2, "clean dual should split into 2");
}
#[test]
fn persistence_keeps_single_peak() {
let p = gaussian(80, 40.0, 6.0, 1000.0);
let hill = hill_from_profile(p);
let parts = split_hill_persistence(&hill, 0.10, 0.70, 4.0, 3);
assert_eq!(parts.len(), 1, "single peak must not split");
}
#[test]
fn persistence_ignores_lone_spike() {
let mut p = gaussian(80, 40.0, 6.0, 1000.0);
p[12] += 1800.0;
let hill = hill_from_profile(p);
let parts = split_hill_persistence(&hill, 0.10, 0.70, 4.0, 3);
assert_eq!(parts.len(), 1, "lone spike must not create a second hill");
}
}