1use std::sync::Arc;
2
3use super::smooth;
4use crate::config::HillsConfig;
5use crate::models::Hill;
6
7fn 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, mz: hill.mz,
64 mz_std: hill.mz_std,
65 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
98fn 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
116pub 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 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); if peaks.len() <= 1 {
154 return vec![hill.clone()];
155 }
156
157 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; 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; 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 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
199fn 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
218fn 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
230fn 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
243fn 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
257fn valley_min(p: &[f32], a: usize, b: usize) -> f32 {
259 p[a..=b].iter().cloned().fold(f32::INFINITY, f32::min)
260}
261
262pub 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 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}