use num_traits::Float;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Polarity {
#[default]
Positive,
Negative,
Both,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DistanceRule {
#[default]
Scipy,
LocallyExclusive,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Interval<T> {
pub min: Option<T>,
pub max: Option<T>,
}
impl<T> Default for Interval<T> {
fn default() -> Self {
Self { min: None, max: None }
}
}
impl<T: Float> Interval<T> {
pub fn at_least(min: T) -> Self {
Self { min: Some(min), max: None }
}
fn is_set(&self) -> bool {
self.min.is_some() || self.max.is_some()
}
fn contains(&self, v: T) -> bool {
self.min.is_none_or(|m| m <= v) && self.max.is_none_or(|m| v <= m)
}
}
pub const DEFAULT_REL_HEIGHT: f64 = 0.5;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PeakOptions<T> {
pub height: Interval<T>,
pub threshold: Interval<T>,
pub distance: Option<usize>,
pub distance_rule: DistanceRule,
pub prominence: Interval<T>,
pub width: Interval<T>,
pub wlen: Option<usize>,
pub rel_height: T,
}
impl<T: Float> Default for PeakOptions<T> {
fn default() -> Self {
Self {
height: Interval::default(),
threshold: Interval::default(),
distance: None,
distance_rule: DistanceRule::default(),
prominence: Interval::default(),
width: Interval::default(),
wlen: None,
rel_height: T::from(DEFAULT_REL_HEIGHT).expect("0.5 is representable"),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Peaks<T> {
pub indices: Vec<usize>,
pub signs: Vec<i8>,
pub prominences: Option<Vec<T>>,
pub left_bases: Option<Vec<usize>>,
pub right_bases: Option<Vec<usize>>,
pub widths: Option<Vec<T>>,
pub left_ips: Option<Vec<T>>,
pub right_ips: Option<Vec<T>>,
}
impl<T> Default for Peaks<T> {
fn default() -> Self {
Self {
indices: Vec::new(),
signs: Vec::new(),
prominences: None,
left_bases: None,
right_bases: None,
widths: None,
left_ips: None,
right_ips: None,
}
}
}
pub fn local_extrema<T: Float>(x: &[T], polarity: Polarity) -> Vec<(usize, i8)> {
let mut out: Vec<(usize, i8)> = Vec::new();
for (sign, s) in signs_of(polarity) {
out.extend(local_maxima(x, s).into_iter().map(|p| (p, sign)));
}
out.sort_by_key(|&(p, _)| p);
out
}
fn signs_of<T: Float>(polarity: Polarity) -> Vec<(i8, T)> {
match polarity {
Polarity::Positive => vec![(1, T::one())],
Polarity::Negative => vec![(-1, -T::one())],
Polarity::Both => vec![(1, T::one()), (-1, -T::one())],
}
}
fn local_maxima<T: Float>(x: &[T], sign: T) -> Vec<usize> {
let s = |i: usize| sign * x[i];
let mut out = Vec::new();
let n = x.len();
if n < 3 {
return out;
}
let i_max = n - 1;
let mut i = 1;
while i < i_max {
if s(i - 1) < s(i) {
let mut ahead = i + 1;
while ahead < i_max && s(ahead) == s(i) {
ahead += 1;
}
if s(ahead) < s(i) {
out.push((i + ahead - 1) / 2);
i = ahead;
}
}
i += 1;
}
out
}
pub fn select_by_distance<T: Float>(peaks: &[usize], priority: &[T], distance: usize, rule: DistanceRule) -> Vec<bool> {
let n = peaks.len();
let beats = |a: usize, b: usize| priority[a] > priority[b] || (priority[a] == priority[b] && a < b);
if rule == DistanceRule::LocallyExclusive {
return (0..n)
.map(|j| {
let left = (0..j).rev().take_while(|&k| peaks[j] - peaks[k] < distance);
let right = (j + 1..n).take_while(|&k| peaks[k] - peaks[j] < distance);
!left.chain(right).any(|k| beats(k, j))
})
.collect();
}
let mut keep = vec![true; n];
let mut order: Vec<usize> = (0..n).collect();
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 });
for j in order {
if !keep[j] {
continue;
}
let mut k = j;
while k > 0 && peaks[j] - peaks[k - 1] < distance {
keep[k - 1] = false;
k -= 1;
}
let mut k = j + 1;
while k < n && peaks[k] - peaks[j] < distance {
keep[k] = false;
k += 1;
}
}
keep
}
fn prominence<T: Float>(s: &dyn Fn(usize) -> T, n: usize, p: usize, wlen: Option<usize>) -> (T, usize, usize) {
let (mut i_min, mut i_max) = (0, n - 1);
if let Some(w) = wlen.filter(|&w| w >= 2) {
i_min = p.saturating_sub(w / 2);
i_max = (p + w / 2).min(n - 1);
}
let (mut left_base, mut left_min) = (p, s(p));
let mut i = p as isize;
while i >= i_min as isize && s(i as usize) <= s(p) {
if s(i as usize) < left_min {
left_min = s(i as usize);
left_base = i as usize;
}
i -= 1;
}
let (mut right_base, mut right_min) = (p, s(p));
let mut i = p;
while i <= i_max && s(i) <= s(p) {
if s(i) < right_min {
right_min = s(i);
right_base = i;
}
i += 1;
}
(s(p) - left_min.max(right_min), left_base, right_base)
}
fn width<T: Float>(s: &dyn Fn(usize) -> T, p: usize, prom: T, left_base: usize, right_base: usize, rel_height: T) -> (T, T, T) {
let height = s(p) - prom * rel_height;
let mut i = p;
while left_base < i && height < s(i) {
i -= 1;
}
let mut left = T::from(i).expect("index");
if s(i) < height {
left = left + (height - s(i)) / (s(i + 1) - s(i));
}
let mut i = p;
while i < right_base && height < s(i) {
i += 1;
}
let mut right = T::from(i).expect("index");
if s(i) < height {
right = right - (height - s(i)) / (s(i - 1) - s(i));
}
(right - left, left, right)
}
pub fn find_peaks<T: Float>(x: &[T], polarity: Polarity, options: &PeakOptions<T>) -> Peaks<T> {
let n = x.len();
let mut cand: Vec<(usize, T)> = Vec::new();
for (_, sign) in signs_of::<T>(polarity) {
for p in local_maxima(x, sign) {
let v = sign * x[p];
if !options.height.contains(v) {
continue;
}
if options.threshold.is_set() {
let (l, r) = (v - sign * x[p - 1], v - sign * x[p + 1]);
if !(options.threshold.min.is_none_or(|m| m <= l.min(r)) && options.threshold.max.is_none_or(|m| l.max(r) <= m)) {
continue;
}
}
cand.push((p, sign));
}
}
cand.sort_by_key(|&(p, _)| p);
if let Some(d) = options.distance.filter(|&d| d > 1) {
let idx: Vec<usize> = cand.iter().map(|&(p, _)| p).collect();
let priority: Vec<T> = cand.iter().map(|&(p, s)| s * x[p]).collect();
let keep = select_by_distance(&idx, &priority, d, options.distance_rule);
cand = cand.into_iter().zip(keep).filter_map(|(c, k)| k.then_some(c)).collect();
}
let mut out = Peaks::default();
let need_prominence = options.prominence.is_set() || options.width.is_set();
let mut props: Vec<(T, usize, usize, T, T, T)> = Vec::new();
let mut kept: Vec<(usize, T)> = Vec::new();
for (p, sign) in cand {
let s = |i: usize| sign * x[i];
let (mut prom, mut lb, mut rb) = (T::zero(), p, p);
if need_prominence {
(prom, lb, rb) = prominence(&s, n, p, options.wlen);
if !options.prominence.contains(prom) {
continue;
}
}
let (mut w, mut li, mut ri) = (T::zero(), T::zero(), T::zero());
if options.width.is_set() {
(w, li, ri) = width(&s, p, prom, lb, rb, options.rel_height);
if !options.width.contains(w) {
continue;
}
}
kept.push((p, sign));
props.push((prom, lb, rb, w, li, ri));
}
out.indices = kept.iter().map(|&(p, _)| p).collect();
out.signs = kept.iter().map(|&(_, s)| if s > T::zero() { 1 } else { -1 }).collect();
if need_prominence {
out.prominences = Some(props.iter().map(|p| p.0).collect());
out.left_bases = Some(props.iter().map(|p| p.1).collect());
out.right_bases = Some(props.iter().map(|p| p.2).collect());
}
if options.width.is_set() {
out.widths = Some(props.iter().map(|p| p.3).collect());
out.left_ips = Some(props.iter().map(|p| p.4).collect());
out.right_ips = Some(props.iter().map(|p| p.5).collect());
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn opts() -> PeakOptions<f64> {
PeakOptions::default()
}
#[test]
fn local_maxima_and_plateaus() {
let x = [0.0, 2.0, 0.0, 3.0, 3.0, 3.0, 0.0, 1.0, 1.0, 0.0];
assert_eq!(find_peaks(&x, Polarity::Positive, &opts()).indices, vec![1, 4, 7]);
assert!(find_peaks(&[5.0, 1.0, 5.0], Polarity::Positive, &opts()).indices.is_empty());
}
#[test]
fn height_threshold_distance() {
let x = [0.0, 5.0, 0.0, 3.0, 0.0, 4.0, 3.5, 0.0];
let o = PeakOptions { height: Interval::at_least(3.5), ..opts() };
assert_eq!(find_peaks(&x, Polarity::Positive, &o).indices, vec![1, 5]);
let o = PeakOptions { threshold: Interval::at_least(1.0), ..opts() };
assert_eq!(find_peaks(&x, Polarity::Positive, &o).indices, vec![1, 3], "peak 5 is only 0.5 above sample 6");
let o = PeakOptions { distance: Some(3), ..opts() };
assert_eq!(find_peaks(&x, Polarity::Positive, &o).indices, vec![1, 5]);
}
#[test]
fn distance_rules_differ_on_chains() {
let (peaks, priority) = ([0usize, 2, 4], [10.0, 9.0, 8.0]);
assert_eq!(select_by_distance(&peaks, &priority, 3, DistanceRule::Scipy), vec![true, false, true]);
assert_eq!(select_by_distance(&peaks, &priority, 3, DistanceRule::LocallyExclusive), vec![true, false, false]);
for rule in [DistanceRule::Scipy, DistanceRule::LocallyExclusive] {
assert_eq!(select_by_distance(&[5usize, 6], &[1.0, 1.0], 2, rule), vec![true, false]);
}
}
#[test]
fn prominence_and_width_match_scipy() {
let x = [0.0, 1.0, 0.0, 4.0, 1.0, 2.0, 0.0];
let o = PeakOptions { prominence: Interval::at_least(0.0), width: Interval::at_least(0.0), ..opts() };
let p = find_peaks(&x, Polarity::Positive, &o);
assert_eq!(p.indices, vec![1, 3, 5]);
assert_eq!(p.prominences.unwrap(), vec![1.0, 4.0, 1.0]);
assert_eq!(p.left_bases.unwrap(), vec![0, 2, 4]);
assert_eq!(p.right_bases.unwrap(), vec![2, 6, 6]);
let w = p.widths.unwrap();
assert!((w[1] - 7.0 / 6.0).abs() < 1e-12, "{w:?}");
}
#[test]
fn troughs_and_both_polarities() {
let x = [0.0, -5.0, 0.0, 4.0, 0.0, -1.0, 0.0];
let neg = find_peaks(&x, Polarity::Negative, &PeakOptions { height: Interval::at_least(2.0), ..opts() });
assert_eq!((neg.indices, neg.signs), (vec![1], vec![-1]));
let both = find_peaks(&x, Polarity::Both, &PeakOptions { height: Interval::at_least(2.0), ..opts() });
assert_eq!((both.indices, both.signs), (vec![1, 3], vec![-1, 1]));
let o = PeakOptions { height: Interval::at_least(2.0), distance: Some(3), ..opts() };
assert_eq!(find_peaks(&x, Polarity::Both, &o).indices, vec![1]);
}
}