Skip to main content

koth_core/hills/
split.rs

1use std::sync::Arc;
2
3use super::smooth;
4use crate::config::HillsConfig;
5use crate::models::Hill;
6
7/// Build sub-hills by cutting `hill.intensity_profile` at the given (sorted,
8/// interior) split-point indices. Each segment's statistics are recomputed from
9/// the RAW segment intensities. Segments shorter than `min_scans` are dropped;
10/// the original hill is returned unchanged if nothing valid remains.
11fn build_sub_hills(hill: &Hill, split_points: &[usize], min_scans: usize) -> Vec<Hill> {
12    let profile = &hill.intensity_profile;
13    if split_points.is_empty() {
14        return vec![hill.clone()];
15    }
16
17    let boundaries: Vec<usize> = std::iter::once(0)
18        .chain(split_points.iter().copied())
19        .chain(std::iter::once(profile.len()))
20        .collect();
21
22    let rt_per_scan = if hill.scan_end > hill.scan_start {
23        (hill.rt_end - hill.rt_start) / (hill.scan_end - hill.scan_start) as f64
24    } else {
25        0.0
26    };
27
28    let mut result = Vec::new();
29    for window in boundaries.windows(2) {
30        let seg_start = window[0];
31        let seg_end = window[1];
32        let segment_profile: Vec<f32> = profile[seg_start..seg_end].to_vec();
33
34        if segment_profile.len() < min_scans {
35            continue;
36        }
37
38        let apex_relative = segment_profile
39            .iter()
40            .enumerate()
41            .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap())
42            .map(|(i, _)| i)
43            .unwrap_or(0);
44
45        let abs_start = hill.scan_start + seg_start;
46        let abs_end = hill.scan_start + seg_end - 1;
47        let abs_apex = abs_start + apex_relative;
48
49        let rt_start = hill.rt_start + (abs_start - hill.scan_start) as f64 * rt_per_scan;
50        let rt_end = hill.rt_start + (abs_end - hill.scan_start) as f64 * rt_per_scan;
51        let rt_apex = hill.rt_start + (abs_apex - hill.scan_start) as f64 * rt_per_scan;
52
53        let intensity_sum: f64 = segment_profile.iter().map(|&x| x as f64).sum();
54        let intensity_max = segment_profile
55            .iter()
56            .map(|&x| x as f64)
57            .fold(0.0f64, f64::max);
58        let skipped = segment_profile.iter().filter(|&&x| x == 0.0).count();
59        let hill_score = smooth::compute_hill_score(&segment_profile);
60
61        result.push(Hill {
62            hill_id: 0, // re-assigned by `assign_hill_ids` after splitting
63            mz: hill.mz,
64            mz_std: hill.mz_std,
65            // SE inherited from the parent hill. A split sub-hill has fewer
66            // contributing scans than the parent, so its true SE would be
67            // larger by √(n_parent / n_sub), but the raw peak data isn't
68            // retained past finalization. Inheritance is a conservative
69            // under-estimate that keeps Kish-aware chain extension functional.
70            mz_se: hill.mz_se,
71            rt: rt_apex,
72            rt_start,
73            rt_end,
74            rt_width: rt_end - rt_start,
75            im: hill.im,
76            im_std: hill.im_std,
77            scan_start: abs_start,
78            scan_apex: abs_apex,
79            scan_end: abs_end,
80            n_scans: segment_profile.len(),
81            skipped_scans: skipped,
82            intensity_sum,
83            intensity_max,
84            hill_score,
85            intensity_profile: Arc::from(segment_profile.as_slice()),
86            isolation_window: hill.isolation_window,
87            faims_cv: hill.faims_cv,
88        });
89    }
90
91    if result.is_empty() {
92        vec![hill.clone()]
93    } else {
94        result
95    }
96}
97
98/// Split points = the valley (minimum) index between each adjacent peak pair,
99/// retained only when both resulting segments have >= `min_scans` points.
100/// Valleys are located on whatever profile `prof` is passed (raw for the
101/// prominence splitter, smoothed for the persistence splitter).
102fn valleys_between(prof: &[f32], peaks: &[usize], min_scans: usize) -> Vec<usize> {
103    let mut split_points = Vec::new();
104    for window in peaks.windows(2) {
105        let (left_peak, right_peak) = (window[0], window[1]);
106        let valley_idx = (left_peak..=right_peak)
107            .min_by(|&a, &b| prof[a].partial_cmp(&prof[b]).unwrap())
108            .unwrap_or(left_peak);
109        if valley_idx >= min_scans && prof.len() - valley_idx >= min_scans {
110            split_points.push(valley_idx);
111        }
112    }
113    split_points
114}
115
116/// Split a hill containing multiple co-eluting peaks (the "persistence"
117/// algorithm). Designed to be robust to noisy scans while separating
118/// visually-obvious dual peaks.
119///
120/// 1. Despike (median-3) and smooth (moving average) a WORKING COPY — all peak
121///    finding runs on this; sub-hill intensities still come from the raw trace.
122/// 2. Candidate maxima above `height_frac * robust_max` (95th-percentile max,
123///    so a single noise spike can't inflate the threshold). No hard min-distance
124///    eviction — close real peaks survive.
125/// 3. Agglomeratively merge adjacent peaks unless the valley between them is
126///    BOTH relatively deep (`valley / smaller_peak <= valley_ratio`) AND
127///    absolutely deep (`smaller_peak - valley >= sigma_mult * noise_sigma`).
128/// 4. Width gate: drop surviving 1–2 scan spikes median-3 couldn't remove.
129/// 5. Split at the (smoothed) valley between adjacent survivors.
130pub fn split_hill_persistence(
131    hill: &Hill,
132    height_frac: f64,
133    valley_ratio: f64,
134    sigma_mult: f64,
135    min_scans: usize,
136) -> Vec<Hill> {
137    let profile = &hill.intensity_profile;
138    if profile.len() < min_scans * 2 {
139        return vec![hill.clone()];
140    }
141
142    // Working copy: nonlinear despike, then linear smooth (half-window 2).
143    let despiked = median3(profile);
144    let mut s = despiked.clone();
145    smooth::running_average(&mut s, 2);
146
147    let sigma = noise_sigma(&despiked).max(1e-6);
148    let floor = sigma_mult as f32 * sigma;
149    let robust_max = percentile(&s, 0.95).max(1e-6);
150    let min_h = (height_frac as f32 * robust_max).max(floor);
151
152    let mut peaks = local_maxima(&s, min_h); // deliberately NO spacing eviction
153    if peaks.len() <= 1 {
154        return vec![hill.clone()];
155    }
156
157    // Agglomeratively drop the single worst-separated peak until every
158    // surviving notch passes both the relative and absolute depth tests.
159    let ratio = valley_ratio as f32;
160    loop {
161        if peaks.len() <= 1 {
162            break;
163        }
164        let mut merge_at: Option<(usize, f32)> = None; // (index, badness)
165        for (idx, w) in peaks.windows(2).enumerate() {
166            let v = valley_min(&s, w[0], w[1]);
167            let smaller = s[w[0]].min(s[w[1]]).max(1e-6);
168            let r = v / smaller; // higher = shallower notch
169            let depth = smaller - v;
170            if r > ratio || depth < floor {
171                let badness = (r - ratio).max(0.0) + (floor - depth).max(0.0) / floor;
172                if merge_at.is_none_or(|(_, b)| badness > b) {
173                    merge_at = Some((idx, badness));
174                }
175            }
176        }
177        let Some((idx, _)) = merge_at else { break };
178        let (a, b) = (peaks[idx], peaks[idx + 1]);
179        let drop = if s[a] <= s[b] { idx } else { idx + 1 };
180        peaks.remove(drop);
181    }
182
183    // Width gate: a real chromatographic peak is several scans wide at half-max.
184    const MIN_WIDTH: usize = 4;
185    peaks.retain(|&pk| {
186        let half = 0.5 * s[pk];
187        let lo = pk.saturating_sub(8);
188        let hi = (pk + 9).min(s.len());
189        (lo..hi).filter(|&i| s[i] >= half).count() >= MIN_WIDTH
190    });
191    if peaks.len() <= 1 {
192        return vec![hill.clone()];
193    }
194
195    let split_points = valleys_between(&s, &peaks, min_scans);
196    build_sub_hills(hill, &split_points, min_scans)
197}
198
199// ---- Persistence-splitter primitives ----
200
201/// Width-3 median filter (nonlinear despike). Removes lone 1-sample spikes
202/// completely while preserving genuine peak edges — something no linear
203/// (moving-average) smoother can do. Endpoints are left untouched.
204fn median3(p: &[f32]) -> Vec<f32> {
205    let n = p.len();
206    if n < 3 {
207        return p.to_vec();
208    }
209    let mut out = p.to_vec();
210    for i in 1..n - 1 {
211        let mut t = [p[i - 1], p[i], p[i + 1]];
212        t.sort_by(|a, b| a.partial_cmp(b).unwrap());
213        out[i] = t[1];
214    }
215    out
216}
217
218/// `q`-quantile (0..=1) of a copy of `p`. Used for a robust max that a single
219/// noise spike cannot inflate.
220fn percentile(p: &[f32], q: f32) -> f32 {
221    if p.is_empty() {
222        return 0.0;
223    }
224    let mut s = p.to_vec();
225    s.sort_by(|a, b| a.partial_cmp(b).unwrap());
226    let idx = ((s.len() as f32 - 1.0) * q).round() as usize;
227    s[idx]
228}
229
230/// Robust high-frequency noise σ via the median absolute first-difference.
231/// Peaks contribute a few large diffs; the median ignores them. The √2 divisor
232/// undoes the variance inflation from differencing; 1.4826 converts MAD→σ.
233fn noise_sigma(p: &[f32]) -> f32 {
234    if p.len() < 3 {
235        return 0.0;
236    }
237    let mut d: Vec<f32> = p.windows(2).map(|w| (w[1] - w[0]).abs()).collect();
238    d.sort_by(|a, b| a.partial_cmp(b).unwrap());
239    let med = d[d.len() / 2];
240    med * 1.4826 / std::f32::consts::SQRT_2
241}
242
243/// Indices that are >= both neighbours and above `min_height`.
244fn local_maxima(p: &[f32], min_height: f32) -> Vec<usize> {
245    let n = p.len();
246    let mut out = Vec::new();
247    for i in 0..n {
248        let l = i == 0 || p[i] >= p[i - 1];
249        let r = i == n - 1 || p[i] >= p[i + 1];
250        if p[i] >= min_height && l && r {
251            out.push(i);
252        }
253    }
254    out
255}
256
257/// Lowest value in the inclusive range `[a, b]`.
258fn valley_min(p: &[f32], a: usize, b: usize) -> f32 {
259    p[a..=b].iter().cloned().fold(f32::INFINITY, f32::min)
260}
261
262/// Apply co-elution splitting to a list of hills using the persistence
263/// splitter (despike + smooth + robust-max threshold + valley merge).
264pub fn split_coeluting(hills: Vec<Hill>, config: &HillsConfig) -> Vec<Hill> {
265    let mut result = Vec::with_capacity(hills.len());
266    let mut split_count = 0usize;
267
268    for hill in &hills {
269        let parts = split_hill_persistence(
270            hill,
271            config.split_height_frac,
272            config.split_valley_ratio,
273            config.split_sigma_mult,
274            config.min_scans,
275        );
276        if parts.len() > 1 {
277            split_count += 1;
278        }
279        result.extend(parts);
280    }
281
282    log::debug!(
283        "Split {} co-eluting hills (persistence algo); total hills now: {}",
284        split_count,
285        result.len()
286    );
287    result
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    fn hill_from_profile(profile: Vec<f32>) -> Hill {
295        let n = profile.len();
296        let max = profile.iter().cloned().fold(0.0f32, f32::max) as f64;
297        Hill {
298            hill_id: 0,
299            mz: 500.0,
300            mz_std: 0.001,
301            mz_se: 0.0005,
302            rt: 0.0,
303            rt_start: 0.0,
304            rt_end: (n - 1) as f64,
305            rt_width: (n - 1) as f64,
306            im: 0.0,
307            im_std: 0.0,
308            scan_start: 0,
309            scan_apex: 0,
310            scan_end: n - 1,
311            n_scans: n,
312            skipped_scans: 0,
313            intensity_sum: profile.iter().map(|&x| x as f64).sum(),
314            intensity_max: max,
315            hill_score: 1.0,
316            intensity_profile: Arc::from(profile.as_slice()),
317            isolation_window: None,
318            faims_cv: None,
319        }
320    }
321
322    fn gaussian(n: usize, center: f32, sigma: f32, amp: f32) -> Vec<f32> {
323        (0..n)
324            .map(|i| {
325                let d = (i as f32 - center) / sigma;
326                amp * (-0.5 * d * d).exp()
327            })
328            .collect()
329    }
330
331    #[test]
332    fn median3_kills_lone_spike() {
333        let p = vec![10.0f32, 9.0, 500.0, 11.0, 10.0];
334        let m = median3(&p);
335        assert!(
336            m[2] < 12.0,
337            "spike should be replaced by a neighbour: {m:?}"
338        );
339    }
340
341    #[test]
342    fn persistence_splits_clean_dual() {
343        let mut p = gaussian(100, 35.0, 5.0, 1000.0);
344        for (i, v) in gaussian(100, 65.0, 5.0, 1000.0).into_iter().enumerate() {
345            p[i] += v;
346        }
347        let hill = hill_from_profile(p);
348        let parts = split_hill_persistence(&hill, 0.10, 0.70, 4.0, 3);
349        assert_eq!(parts.len(), 2, "clean dual should split into 2");
350    }
351
352    #[test]
353    fn persistence_keeps_single_peak() {
354        let p = gaussian(80, 40.0, 6.0, 1000.0);
355        let hill = hill_from_profile(p);
356        let parts = split_hill_persistence(&hill, 0.10, 0.70, 4.0, 3);
357        assert_eq!(parts.len(), 1, "single peak must not split");
358    }
359
360    #[test]
361    fn persistence_ignores_lone_spike() {
362        // One real peak plus a 1-sample spike taller than it: must stay 1 hill.
363        let mut p = gaussian(80, 40.0, 6.0, 1000.0);
364        p[12] += 1800.0;
365        let hill = hill_from_profile(p);
366        let parts = split_hill_persistence(&hill, 0.10, 0.70, 4.0, 3);
367        assert_eq!(parts.len(), 1, "lone spike must not create a second hill");
368    }
369}