Skip to main content

dspk_ops/peaks/
host.rs

1//! `scipy.signal.find_peaks` on the host, plus troughs and both polarities.
2
3use num_traits::Float;
4
5/// Which extrema count as peaks.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
7pub enum Polarity {
8    /// Local maxima of `x` (scipy).
9    #[default]
10    Positive,
11    /// Local minima of `x` (maxima of `−x`).
12    Negative,
13    /// Both; conditions apply to the magnitude `|x|` of each extremum.
14    Both,
15}
16
17/// How `distance` resolves peaks closer than it.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
19pub enum DistanceRule {
20    /// scipy `_select_by_peak_distance`: the largest peak is kept and every peak nearer than
21    /// `distance` removed, then the next largest remaining, … A removed peak removes nothing, so a
22    /// peak's fate can depend on peaks farther than `distance` (a chain).
23    #[default]
24    Scipy,
25    /// A peak is kept when no larger peak lies nearer than `distance` (SpikeInterface
26    /// `locally_exclusive`). Depends only on peaks within `distance`, so chunked processing with
27    /// `distance` of context on each side equals the whole-signal result.
28    LocallyExclusive,
29}
30
31/// `min ≤ value ≤ max`, either side optional (scipy's `(min, max)` arguments).
32#[derive(Debug, Clone, Copy, PartialEq)]
33pub struct Interval<T> {
34    pub min: Option<T>,
35    pub max: Option<T>,
36}
37
38impl<T> Default for Interval<T> {
39    fn default() -> Self {
40        Self { min: None, max: None }
41    }
42}
43
44impl<T: Float> Interval<T> {
45    /// At least `min`.
46    pub fn at_least(min: T) -> Self {
47        Self { min: Some(min), max: None }
48    }
49
50    fn is_set(&self) -> bool {
51        self.min.is_some() || self.max.is_some()
52    }
53
54    fn contains(&self, v: T) -> bool {
55        self.min.is_none_or(|m| m <= v) && self.max.is_none_or(|m| v <= m)
56    }
57}
58
59/// `scipy.signal.relative_height` default of [`PeakOptions::rel_height`].
60pub const DEFAULT_REL_HEIGHT: f64 = 0.5;
61
62/// Conditions of [`find_peaks`], as `scipy.signal.find_peaks` (all off by default). Heights,
63/// thresholds and prominences are of the signed signal (`x` for maxima, `−x` for minima).
64#[derive(Debug, Clone, Copy, PartialEq)]
65pub struct PeakOptions<T> {
66    /// Peak height.
67    pub height: Interval<T>,
68    /// Vertical distance to both neighbouring samples (`min` against the nearer, `max` against the
69    /// farther, as scipy).
70    pub threshold: Interval<T>,
71    /// Minimal sample distance between peaks (≥ 1), resolved by `distance_rule`.
72    pub distance: Option<usize>,
73    pub distance_rule: DistanceRule,
74    pub prominence: Interval<T>,
75    /// Width (samples) at `rel_height` of the prominence.
76    pub width: Interval<T>,
77    /// Window (samples) the prominence searches; `None` = whole signal.
78    pub wlen: Option<usize>,
79    pub rel_height: T,
80}
81
82impl<T: Float> Default for PeakOptions<T> {
83    fn default() -> Self {
84        Self {
85            height: Interval::default(),
86            threshold: Interval::default(),
87            distance: None,
88            distance_rule: DistanceRule::default(),
89            prominence: Interval::default(),
90            width: Interval::default(),
91            wlen: None,
92            rel_height: T::from(DEFAULT_REL_HEIGHT).expect("0.5 is representable"),
93        }
94    }
95}
96
97/// Peaks found by [`find_peaks`], with the properties the conditions needed.
98#[derive(Debug, Clone, PartialEq)]
99pub struct Peaks<T> {
100    /// Sample of each peak, ascending (the middle sample of a flat peak, rounded down).
101    pub indices: Vec<usize>,
102    /// `+1` for a maximum, `−1` for a minimum.
103    pub signs: Vec<i8>,
104    /// Prominence and its bases (when `prominence` or `width` was asked).
105    pub prominences: Option<Vec<T>>,
106    pub left_bases: Option<Vec<usize>>,
107    pub right_bases: Option<Vec<usize>>,
108    /// Width at `rel_height` and its interpolated ends (when `width` was asked).
109    pub widths: Option<Vec<T>>,
110    pub left_ips: Option<Vec<T>>,
111    pub right_ips: Option<Vec<T>>,
112}
113
114impl<T> Default for Peaks<T> {
115    fn default() -> Self {
116        Self {
117            indices: Vec::new(),
118            signs: Vec::new(),
119            prominences: None,
120            left_bases: None,
121            right_bases: None,
122            widths: None,
123            left_ips: None,
124            right_ips: None,
125        }
126    }
127}
128
129/// Local extrema of `x` of `polarity` as `(index, sign)` ascending: maxima of `sign · x` (flat
130/// peaks: middle sample, rounded down), as scipy `_local_maxima_1d`; edges never count.
131pub fn local_extrema<T: Float>(x: &[T], polarity: Polarity) -> Vec<(usize, i8)> {
132    let mut out: Vec<(usize, i8)> = Vec::new();
133    for (sign, s) in signs_of(polarity) {
134        out.extend(local_maxima(x, s).into_iter().map(|p| (p, sign)));
135    }
136    out.sort_by_key(|&(p, _)| p);
137    out
138}
139
140fn signs_of<T: Float>(polarity: Polarity) -> Vec<(i8, T)> {
141    match polarity {
142        Polarity::Positive => vec![(1, T::one())],
143        Polarity::Negative => vec![(-1, -T::one())],
144        Polarity::Both => vec![(1, T::one()), (-1, -T::one())],
145    }
146}
147
148fn local_maxima<T: Float>(x: &[T], sign: T) -> Vec<usize> {
149    let s = |i: usize| sign * x[i];
150    let mut out = Vec::new();
151    let n = x.len();
152    if n < 3 {
153        return out;
154    }
155    let i_max = n - 1;
156    let mut i = 1;
157    while i < i_max {
158        if s(i - 1) < s(i) {
159            let mut ahead = i + 1;
160            while ahead < i_max && s(ahead) == s(i) {
161                ahead += 1;
162            }
163            if s(ahead) < s(i) {
164                out.push((i + ahead - 1) / 2);
165                i = ahead;
166            }
167        }
168        i += 1;
169    }
170    out
171}
172
173/// Which of `peaks` (ascending samples) to keep so none is nearer than `distance` to a larger one
174/// by `priority`, under `rule` (equal priorities: the earlier peak counts as larger). Returns a
175/// keep mask.
176pub fn select_by_distance<T: Float>(peaks: &[usize], priority: &[T], distance: usize, rule: DistanceRule) -> Vec<bool> {
177    let n = peaks.len();
178    // `a` outranks `b`
179    let beats = |a: usize, b: usize| priority[a] > priority[b] || (priority[a] == priority[b] && a < b);
180    if rule == DistanceRule::LocallyExclusive {
181        return (0..n)
182            .map(|j| {
183                let left = (0..j).rev().take_while(|&k| peaks[j] - peaks[k] < distance);
184                let right = (j + 1..n).take_while(|&k| peaks[k] - peaks[j] < distance);
185                !left.chain(right).any(|k| beats(k, j))
186            })
187            .collect();
188    }
189    let mut keep = vec![true; n];
190    let mut order: Vec<usize> = (0..n).collect();
191    order.sort_by(|&a, &b| if beats(a, b) { std::cmp::Ordering::Less } else if beats(b, a) { std::cmp::Ordering::Greater } else { std::cmp::Ordering::Equal });
192    for j in order {
193        if !keep[j] {
194            continue;
195        }
196        let mut k = j;
197        while k > 0 && peaks[j] - peaks[k - 1] < distance {
198            keep[k - 1] = false;
199            k -= 1;
200        }
201        let mut k = j + 1;
202        while k < n && peaks[k] - peaks[j] < distance {
203            keep[k] = false;
204            k += 1;
205        }
206    }
207    keep
208}
209
210/// Prominence of the peak at `p` of `s` and its left / right bases (scipy `_peak_prominences`).
211fn prominence<T: Float>(s: &dyn Fn(usize) -> T, n: usize, p: usize, wlen: Option<usize>) -> (T, usize, usize) {
212    let (mut i_min, mut i_max) = (0, n - 1);
213    if let Some(w) = wlen.filter(|&w| w >= 2) {
214        i_min = p.saturating_sub(w / 2);
215        i_max = (p + w / 2).min(n - 1);
216    }
217    let (mut left_base, mut left_min) = (p, s(p));
218    let mut i = p as isize;
219    while i >= i_min as isize && s(i as usize) <= s(p) {
220        if s(i as usize) < left_min {
221            left_min = s(i as usize);
222            left_base = i as usize;
223        }
224        i -= 1;
225    }
226    let (mut right_base, mut right_min) = (p, s(p));
227    let mut i = p;
228    while i <= i_max && s(i) <= s(p) {
229        if s(i) < right_min {
230            right_min = s(i);
231            right_base = i;
232        }
233        i += 1;
234    }
235    (s(p) - left_min.max(right_min), left_base, right_base)
236}
237
238/// Width of the peak at `p` at `rel_height` of its prominence, and the interpolated left / right
239/// crossing positions (scipy `_peak_widths`).
240fn width<T: Float>(s: &dyn Fn(usize) -> T, p: usize, prom: T, left_base: usize, right_base: usize, rel_height: T) -> (T, T, T) {
241    let height = s(p) - prom * rel_height;
242    let mut i = p;
243    while left_base < i && height < s(i) {
244        i -= 1;
245    }
246    let mut left = T::from(i).expect("index");
247    if s(i) < height {
248        left = left + (height - s(i)) / (s(i + 1) - s(i));
249    }
250    let mut i = p;
251    while i < right_base && height < s(i) {
252        i += 1;
253    }
254    let mut right = T::from(i).expect("index");
255    if s(i) < height {
256        right = right - (height - s(i)) / (s(i - 1) - s(i));
257    }
258    (right - left, left, right)
259}
260
261/// Peaks of `x` meeting `options`, as `scipy.signal.find_peaks` (`polarity` [`Polarity::Positive`])
262/// — conditions applied in scipy's order: height, threshold, distance, prominence, width. For
263/// [`Polarity::Negative`] the minima of `x` are searched as maxima of `−x`; for [`Polarity::Both`]
264/// both, with `distance` ranking peaks by magnitude.
265pub fn find_peaks<T: Float>(x: &[T], polarity: Polarity, options: &PeakOptions<T>) -> Peaks<T> {
266    let n = x.len();
267    // (index, sign) of every local extremum passing height and threshold, ascending
268    let mut cand: Vec<(usize, T)> = Vec::new();
269    for (_, sign) in signs_of::<T>(polarity) {
270        for p in local_maxima(x, sign) {
271            let v = sign * x[p];
272            if !options.height.contains(v) {
273                continue;
274            }
275            if options.threshold.is_set() {
276                let (l, r) = (v - sign * x[p - 1], v - sign * x[p + 1]);
277                if !(options.threshold.min.is_none_or(|m| m <= l.min(r)) && options.threshold.max.is_none_or(|m| l.max(r) <= m)) {
278                    continue;
279                }
280            }
281            cand.push((p, sign));
282        }
283    }
284    cand.sort_by_key(|&(p, _)| p);
285
286    if let Some(d) = options.distance.filter(|&d| d > 1) {
287        let idx: Vec<usize> = cand.iter().map(|&(p, _)| p).collect();
288        let priority: Vec<T> = cand.iter().map(|&(p, s)| s * x[p]).collect();
289        let keep = select_by_distance(&idx, &priority, d, options.distance_rule);
290        cand = cand.into_iter().zip(keep).filter_map(|(c, k)| k.then_some(c)).collect();
291    }
292
293    let mut out = Peaks::default();
294    let need_prominence = options.prominence.is_set() || options.width.is_set();
295    let mut props: Vec<(T, usize, usize, T, T, T)> = Vec::new();
296    let mut kept: Vec<(usize, T)> = Vec::new();
297    for (p, sign) in cand {
298        let s = |i: usize| sign * x[i];
299        let (mut prom, mut lb, mut rb) = (T::zero(), p, p);
300        if need_prominence {
301            (prom, lb, rb) = prominence(&s, n, p, options.wlen);
302            if !options.prominence.contains(prom) {
303                continue;
304            }
305        }
306        let (mut w, mut li, mut ri) = (T::zero(), T::zero(), T::zero());
307        if options.width.is_set() {
308            (w, li, ri) = width(&s, p, prom, lb, rb, options.rel_height);
309            if !options.width.contains(w) {
310                continue;
311            }
312        }
313        kept.push((p, sign));
314        props.push((prom, lb, rb, w, li, ri));
315    }
316    out.indices = kept.iter().map(|&(p, _)| p).collect();
317    out.signs = kept.iter().map(|&(_, s)| if s > T::zero() { 1 } else { -1 }).collect();
318    if need_prominence {
319        out.prominences = Some(props.iter().map(|p| p.0).collect());
320        out.left_bases = Some(props.iter().map(|p| p.1).collect());
321        out.right_bases = Some(props.iter().map(|p| p.2).collect());
322    }
323    if options.width.is_set() {
324        out.widths = Some(props.iter().map(|p| p.3).collect());
325        out.left_ips = Some(props.iter().map(|p| p.4).collect());
326        out.right_ips = Some(props.iter().map(|p| p.5).collect());
327    }
328    out
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334
335    fn opts() -> PeakOptions<f64> {
336        PeakOptions::default()
337    }
338
339    #[test]
340    fn local_maxima_and_plateaus() {
341        // scipy: find_peaks([0, 2, 0, 3, 3, 3, 0, 1, 1, 0]) -> [1, 4, 7]
342        let x = [0.0, 2.0, 0.0, 3.0, 3.0, 3.0, 0.0, 1.0, 1.0, 0.0];
343        assert_eq!(find_peaks(&x, Polarity::Positive, &opts()).indices, vec![1, 4, 7]);
344        // Edges are never peaks
345        assert!(find_peaks(&[5.0, 1.0, 5.0], Polarity::Positive, &opts()).indices.is_empty());
346    }
347
348    #[test]
349    fn height_threshold_distance() {
350        let x = [0.0, 5.0, 0.0, 3.0, 0.0, 4.0, 3.5, 0.0];
351        let o = PeakOptions { height: Interval::at_least(3.5), ..opts() };
352        assert_eq!(find_peaks(&x, Polarity::Positive, &o).indices, vec![1, 5]);
353        let o = PeakOptions { threshold: Interval::at_least(1.0), ..opts() };
354        assert_eq!(find_peaks(&x, Polarity::Positive, &o).indices, vec![1, 3], "peak 5 is only 0.5 above sample 6");
355        // scipy: find_peaks(x, distance=3) -> [1, 5] (5 before 3)
356        let o = PeakOptions { distance: Some(3), ..opts() };
357        assert_eq!(find_peaks(&x, Polarity::Positive, &o).indices, vec![1, 5]);
358    }
359
360    #[test]
361    fn distance_rules_differ_on_chains() {
362        // 10 at 0 removes 9 at 2 (scipy); 8 at 4 then survives under scipy (its larger neighbour 9
363        // was removed) but not locally exclusive (9 is larger and nearer than 3)
364        let (peaks, priority) = ([0usize, 2, 4], [10.0, 9.0, 8.0]);
365        assert_eq!(select_by_distance(&peaks, &priority, 3, DistanceRule::Scipy), vec![true, false, true]);
366        assert_eq!(select_by_distance(&peaks, &priority, 3, DistanceRule::LocallyExclusive), vec![true, false, false]);
367        // Ties: the earlier peak wins under both rules
368        for rule in [DistanceRule::Scipy, DistanceRule::LocallyExclusive] {
369            assert_eq!(select_by_distance(&[5usize, 6], &[1.0, 1.0], 2, rule), vec![true, false]);
370        }
371    }
372
373    #[test]
374    fn prominence_and_width_match_scipy() {
375        // scipy: x = [0, 1, 0, 4, 1, 2, 0]; find_peaks(x, prominence=0) -> peaks [1, 3, 5],
376        // prominences [1, 4, 1], left_bases [0, 2, 4], right_bases [2, 6, 6]
377        let x = [0.0, 1.0, 0.0, 4.0, 1.0, 2.0, 0.0];
378        let o = PeakOptions { prominence: Interval::at_least(0.0), width: Interval::at_least(0.0), ..opts() };
379        let p = find_peaks(&x, Polarity::Positive, &o);
380        assert_eq!(p.indices, vec![1, 3, 5]);
381        assert_eq!(p.prominences.unwrap(), vec![1.0, 4.0, 1.0]);
382        assert_eq!(p.left_bases.unwrap(), vec![0, 2, 4]);
383        assert_eq!(p.right_bases.unwrap(), vec![2, 6, 6]);
384        // peak_widths at 0.5: peak 3 crosses height 2 at 2.5 and 4 − 1/3 -> width 7/6
385        let w = p.widths.unwrap();
386        assert!((w[1] - 7.0 / 6.0).abs() < 1e-12, "{w:?}");
387    }
388
389    #[test]
390    fn troughs_and_both_polarities() {
391        let x = [0.0, -5.0, 0.0, 4.0, 0.0, -1.0, 0.0];
392        let neg = find_peaks(&x, Polarity::Negative, &PeakOptions { height: Interval::at_least(2.0), ..opts() });
393        assert_eq!((neg.indices, neg.signs), (vec![1], vec![-1]));
394        let both = find_peaks(&x, Polarity::Both, &PeakOptions { height: Interval::at_least(2.0), ..opts() });
395        assert_eq!((both.indices, both.signs), (vec![1, 3], vec![-1, 1]));
396        // distance ranks by magnitude: the trough (5) beats the peak (4)
397        let o = PeakOptions { height: Interval::at_least(2.0), distance: Some(3), ..opts() };
398        assert_eq!(find_peaks(&x, Polarity::Both, &o).indices, vec![1]);
399    }
400}