flow_peak_detection/
lib.rs1use anyhow::{Result, bail};
4
5#[derive(Debug, Clone, Copy)]
7pub struct PeakConfig {
8 pub threshold: f64,
10 pub peak_bias: f64,
12 pub min_events: usize,
14 pub resolution: usize,
16}
17
18impl Default for PeakConfig {
19 fn default() -> Self {
20 Self {
21 threshold: 0.3,
22 peak_bias: 1.0,
23 min_events: 100,
24 resolution: 512,
25 }
26 }
27}
28
29#[derive(Debug, Clone)]
31pub struct PeakResult {
32 pub range: (f64, f64),
33 pub median: f64,
34 pub event_indices: Vec<usize>,
35 pub density: f64,
36 pub combined_score: f64,
37}
38
39pub fn detect_peaks_kde(
41 data: &[f64],
42 bandwidth: Option<f64>,
43 resolution: usize,
44 threshold: f64,
45) -> Vec<(f64, f64)> {
46 if data.is_empty() || resolution < 8 {
47 return Vec::new();
48 }
49 let finite: Vec<f64> = data.iter().copied().filter(|v| v.is_finite()).collect();
50 if finite.len() < 2 {
51 return Vec::new();
52 }
53 let min = finite.iter().copied().fold(f64::INFINITY, f64::min);
54 let max = finite.iter().copied().fold(f64::NEG_INFINITY, f64::max);
55 if !(max > min) {
56 return Vec::new();
57 }
58 let n = finite.len() as f64;
59 let mean = finite.iter().sum::<f64>() / n;
60 let var = finite.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / n;
61 let std = var.sqrt().max(f64::EPSILON);
62 let bw = bandwidth
63 .unwrap_or_else(|| 1.06 * std * n.powf(-0.2))
64 .max(f64::EPSILON);
65
66 let mut xs = Vec::with_capacity(resolution);
67 let mut dens = vec![0.0_f64; resolution];
68 for i in 0..resolution {
69 let x = min + (max - min) * (i as f64) / ((resolution - 1) as f64);
70 xs.push(x);
71 let mut d = 0.0;
72 for &v in &finite {
73 let z = (x - v) / bw;
74 d += (-0.5 * z * z).exp();
75 }
76 dens[i] = d / (n * bw * (std::f64::consts::TAU).sqrt());
77 }
78 let max_d = dens.iter().copied().fold(0.0_f64, f64::max);
79 if max_d <= 0.0 {
80 return Vec::new();
81 }
82 let cut = threshold.clamp(0.0, 1.0) * max_d;
83 let mut peaks = Vec::new();
84 for i in 1..resolution.saturating_sub(1) {
85 if dens[i] >= cut && dens[i] >= dens[i - 1] && dens[i] >= dens[i + 1] {
86 peaks.push((xs[i], dens[i]));
87 }
88 }
89 peaks
90}
91
92fn median_of(sorted: &[f64]) -> f64 {
93 if sorted.is_empty() {
94 return f64::NAN;
95 }
96 let mid = sorted.len() / 2;
97 if sorted.len() % 2 == 1 {
98 sorted[mid]
99 } else {
100 0.5 * (sorted[mid - 1] + sorted[mid])
101 }
102}
103
104#[derive(Clone, Copy)]
105enum PeakSide {
106 Positive,
107 Negative,
108}
109
110fn isolate_peak(data: &[f64], config: &PeakConfig, side: PeakSide) -> Result<PeakResult> {
111 let peaks = detect_peaks_kde(data, None, config.resolution, config.threshold);
112 if peaks.is_empty() {
113 bail!("no peaks detected above threshold {}", config.threshold);
114 }
115 let (peak_x, peak_d) = match side {
116 PeakSide::Positive => peaks
117 .iter()
118 .copied()
119 .max_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
120 .unwrap_or(peaks[0]),
121 PeakSide::Negative => peaks
122 .iter()
123 .copied()
124 .min_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
125 .unwrap_or(peaks[0]),
126 };
127
128 let finite: Vec<(usize, f64)> = data
129 .iter()
130 .enumerate()
131 .filter_map(|(i, &v)| v.is_finite().then_some((i, v)))
132 .collect();
133 if finite.is_empty() {
134 bail!("no finite events");
135 }
136 let min = finite.iter().map(|(_, v)| *v).fold(f64::INFINITY, f64::min);
137 let max = finite
138 .iter()
139 .map(|(_, v)| *v)
140 .fold(f64::NEG_INFINITY, f64::max);
141 let half = ((max - min) * 0.25).max(f64::EPSILON);
142 let mut in_window: Vec<(usize, f64)> = finite
143 .into_iter()
144 .filter(|(_, v)| (*v - peak_x).abs() <= half)
145 .collect();
146 if in_window.is_empty() {
147 bail!("no events near peak at {peak_x}");
148 }
149 in_window.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
150 let bias = config.peak_bias.clamp(0.05, 1.0);
151 let keep = ((in_window.len() as f64) * bias).ceil() as usize;
152 let keep = keep.max(1).min(in_window.len());
153 let trimmed = match side {
154 PeakSide::Positive => &in_window[in_window.len() - keep..],
155 PeakSide::Negative => &in_window[..keep],
156 };
157 if trimmed.len() < config.min_events {
158 bail!(
159 "only {} events in peak (minimum {})",
160 trimmed.len(),
161 config.min_events
162 );
163 }
164 let values: Vec<f64> = trimmed.iter().map(|(_, v)| *v).collect();
165 let lo = values[0];
166 let hi = *values.last().unwrap_or(&lo);
167 Ok(PeakResult {
168 range: (lo, hi),
169 median: median_of(&values),
170 event_indices: trimmed.iter().map(|(i, _)| *i).collect(),
171 density: peak_d,
172 combined_score: peak_d * (trimmed.len() as f64).ln_1p(),
173 })
174}
175
176pub fn isolate_positive_peak(data: &[f64], config: &PeakConfig) -> Result<PeakResult> {
178 isolate_peak(data, config, PeakSide::Positive)
179}
180
181pub fn isolate_negative_peak(data: &[f64], config: &PeakConfig) -> Result<PeakResult> {
183 isolate_peak(data, config, PeakSide::Negative)
184}
185
186pub fn isolate_positive_peak_mask(
188 data: &[f64],
189 threshold: f64,
190 peak_bias: f64,
191) -> Result<Vec<bool>> {
192 let config = PeakConfig {
193 threshold,
194 peak_bias,
195 min_events: 1,
196 ..PeakConfig::default()
197 };
198 let peak = isolate_positive_peak(data, &config)?;
199 let mut mask = vec![false; data.len()];
200 for i in peak.event_indices {
201 if i < mask.len() {
202 mask[i] = true;
203 }
204 }
205 Ok(mask)
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211
212 #[test]
213 fn detects_bimodal_and_isolates_right_peak() {
214 let mut data = Vec::new();
215 for _ in 0..400 {
216 data.push(1.0);
217 }
218 for _ in 0..400 {
219 data.push(10.0);
220 }
221 for (i, v) in data.iter_mut().enumerate() {
222 *v += (i % 7) as f64 * 0.01;
223 }
224 let peaks = detect_peaks_kde(&data, None, 256, 0.2);
225 assert!(!peaks.is_empty());
226 let pos = isolate_positive_peak(
227 &data,
228 &PeakConfig {
229 min_events: 50,
230 ..PeakConfig::default()
231 },
232 )
233 .unwrap();
234 assert!(pos.median > 5.0);
235 }
236}