Skip to main content

dspk_ops/crossings/
mod.rs

1//! Threshold crossings as events: where each channel goes past a level (above it, below it, or
2//! either) and where it comes back. One tool for any signal: muscle contractions on an EMG
3//! envelope (band-pass → rectify → low-pass → crossings), excursions of a raw trace past ±level,
4//! a temperature above a limit, a trigger line going high.
5//!
6//! - An event **starts** (`onset`) where the signal crosses `level` and **ends** (`offset`) where
7//!   it crosses back past `release` (hysteresis: a release between the baseline and the level
8//!   keeps noise around the level from splitting one event into many; by default `release =
9//!   level`, a plain threshold crossing).
10//! - While an event is open further crossings are part of it; a new event needs a fresh crossing.
11//! - Events shorter than `min_duration` are dropped; after an event, onsets within `refractory`
12//!   of its end are ignored (the signal must then cross the level again).
13//! - Each event reports its peak (the largest value; the lowest for a crossing downwards) and
14//!   where it is.
15//!
16//! The work is split as `find_peak_candidates` is: kernels find the crossings of every channel and
17//! compact them on the device, and the extremum of each stretch between crossings; only those are
18//! downloaded. [`CrossingTracker`] then pairs them into events on the host, window after window, so
19//! a long recording streams through in chunks and events may span chunks: the result does not
20//! depend on the chunk size.
21//!
22//! - [`detect_crossings`] (host): a whole `[channels, samples]` buffer in memory.
23//! - [`detect_crossings_on_device`] (device): a whole device buffer.
24//! - [`CrossingTracker::push_device_window`]: one window of a longer run (what pipelines use).
25
26mod device;
27pub mod kernels;
28
29use crate::error::{OpsError, OpsResult};
30
31pub use device::detect_crossings_on_device;
32
33fn invalid(msg: String) -> OpsError {
34    OpsError::Core(dspk_core::DspError::InvalidConfig(msg))
35}
36
37/// Which way the signal goes past the level at an onset.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
39pub enum Direction {
40    /// Up through `level` (a level above the baseline; envelopes, power).
41    #[default]
42    Rising,
43    /// Down through `level` (a level below the baseline).
44    Falling,
45    /// Past `+|level|` upwards or `−|level|` downwards (deflections either way on a raw trace);
46    /// `release` is a magnitude too. An event's sign is its peak's.
47    Both,
48}
49
50/// Options of [`detect_crossings`] and [`CrossingTracker`].
51#[derive(Debug, Clone, PartialEq)]
52pub struct CrossingOptions {
53    /// Level an onset crosses, in the signal's unit: one value for every channel, or one per
54    /// channel (`Both`: magnitudes).
55    pub levels: Vec<f32>,
56    /// Level the signal crosses back past to end the event (one, or one per channel); empty:
57    /// `levels`. Rising: at most the level; falling: at least the level; both: a magnitude at most
58    /// the level's.
59    pub releases: Vec<f32>,
60    pub direction: Direction,
61    /// Events shorter than this many samples are dropped (default 0: none).
62    pub min_duration_samples: u64,
63    /// Onsets within this many samples after an event's end are ignored (default 0).
64    pub refractory_samples: u64,
65}
66
67impl CrossingOptions {
68    /// One level for every channel, released at the same level, rising.
69    pub fn new(level: f32) -> Self {
70        Self { levels: vec![level], releases: Vec::new(), direction: Direction::Rising, min_duration_samples: 0, refractory_samples: 0 }
71    }
72
73    /// The sides to watch: each a sign and per-channel levels and releases in signed space
74    /// (`sign · value`), checked.
75    fn sides(&self, channels: usize) -> OpsResult<Vec<Side>> {
76        let spread = |v: &[f32], what: &str| -> OpsResult<Vec<f32>> {
77            match v.len() {
78                1 => Ok(vec![v[0]; channels]),
79                n if n == channels => Ok(v.to_vec()),
80                n => Err(invalid(format!("crossings: {n} {what} for {channels} channels (give one, or one per channel)"))),
81            }
82        };
83        let levels = spread(&self.levels, "levels")?;
84        let releases = if self.releases.is_empty() { levels.clone() } else { spread(&self.releases, "releases")? };
85        for (ch, (&l, &r)) in levels.iter().zip(&releases).enumerate() {
86            if !l.is_finite() || !r.is_finite() {
87                return Err(invalid(format!("crossings: channel {ch}: level and release must be finite")));
88            }
89            let (bad, rule) = match self.direction {
90                Direction::Rising => (r > l, "at most"),
91                Direction::Falling => (r < l, "at least"),
92                Direction::Both => (r.abs() > l.abs(), "in magnitude at most"),
93            };
94            if bad {
95                return Err(invalid(format!("crossings: channel {ch}: the release ({r}) must be {rule} the level ({l})")));
96            }
97        }
98        let side = |sign: f32, magnitude: bool| {
99            let to = |v: &f32| if magnitude { v.abs() } else { sign * v };
100            Side::new(sign, levels.iter().map(to).collect(), releases.iter().map(to).collect())
101        };
102        Ok(match self.direction {
103            Direction::Rising => vec![side(1.0, false)],
104            Direction::Falling => vec![side(-1.0, false)],
105            Direction::Both => vec![side(1.0, true), side(-1.0, true)],
106        })
107    }
108}
109
110/// One event of one channel, in samples of the whole run.
111#[derive(Debug, Clone, Copy, PartialEq)]
112pub struct CrossingEvent {
113    pub channel: usize,
114    /// First sample at or past the level.
115    pub onset: u64,
116    /// First sample back past the release; `None` when the run ended during the event.
117    pub offset: Option<u64>,
118    /// Where the peak is (the first sample of the largest value; the lowest for a crossing
119    /// downwards).
120    pub peak_sample: u64,
121    pub peak_value: f32,
122}
123
124/// Where one channel is between windows.
125#[derive(Debug, Clone, Copy)]
126struct ChannelState {
127    on: bool,
128    onset: u64,
129    /// Signed peak so far and where.
130    peak: f32,
131    peak_at: u64,
132    /// No onset before this sample (refractory).
133    ready_at: u64,
134}
135
136/// One direction watched: a sign, levels and releases in signed space, each channel's state.
137#[derive(Debug, Clone)]
138struct Side {
139    sign: f32,
140    levels: Vec<f32>,
141    releases: Vec<f32>,
142    state: Vec<ChannelState>,
143}
144
145impl Side {
146    fn new(sign: f32, levels: Vec<f32>, releases: Vec<f32>) -> Self {
147        let idle = ChannelState { on: false, onset: 0, peak: f32::NEG_INFINITY, peak_at: 0, ready_at: 0 };
148        let n = levels.len();
149        Self { sign, levels, releases, state: vec![idle; n] }
150    }
151}
152
153/// Pairs crossings into events, window after window (see the module docs).
154#[derive(Debug, Clone)]
155pub struct CrossingTracker {
156    /// One side per direction watched (two for `Both`).
157    sides: Vec<Side>,
158    min_duration: u64,
159    refractory: u64,
160    /// Last value of each channel in the previous window (NaN before the first).
161    prev: Vec<f32>,
162    events: Vec<CrossingEvent>,
163}
164
165/// Crossings of one channel in one window: local sample and kind
166/// ([`kernels::CROSSING_UP`] / [`kernels::CROSSING_DOWN`]).
167type Crossings<'a> = &'a [(u32, u32)];
168/// Signed extremum and its local sample of each stretch: before the first crossing, then after
169/// each crossing (one more than the crossings).
170type Stretches<'a> = &'a [(f32, u32)];
171
172impl CrossingTracker {
173    pub fn new(channels: usize, options: &CrossingOptions) -> OpsResult<Self> {
174        Ok(Self {
175            sides: options.sides(channels)?,
176            min_duration: options.min_duration_samples,
177            refractory: options.refractory_samples,
178            prev: vec![f32::NAN; channels],
179            events: Vec::new(),
180        })
181    }
182
183    pub fn channels(&self) -> usize {
184        self.prev.len()
185    }
186
187    /// Takes one channel's crossings on side `side` of a window whose local sample 0 is sample
188    /// `origin` of the run.
189    fn take(&mut self, side: usize, ch: usize, origin: u64, crossings: Crossings, stretches: Stretches) {
190        debug_assert_eq!(stretches.len(), crossings.len() + 1);
191        let sign = self.sides[side].sign;
192        let st = &mut self.sides[side].state[ch];
193        let grow = |st: &mut ChannelState, (v, t): (f32, u32)| {
194            if v > st.peak {
195                (st.peak, st.peak_at) = (v, origin + t as u64);
196            }
197        };
198        if st.on {
199            grow(st, stretches[0]);
200        }
201        for (k, &(t, kind)) in crossings.iter().enumerate() {
202            let at = origin + t as u64;
203            let after = stretches[k + 1];
204            if kind == kernels::CROSSING_UP {
205                if st.on {
206                    grow(st, after);
207                } else if at >= st.ready_at {
208                    (st.on, st.onset, st.peak, st.peak_at) = (true, at, f32::NEG_INFINITY, at);
209                    grow(st, after);
210                }
211            } else if st.on {
212                st.on = false;
213                if at - st.onset >= self.min_duration {
214                    self.events.push(CrossingEvent { channel: ch, onset: st.onset, offset: Some(at), peak_sample: st.peak_at, peak_value: sign * st.peak });
215                    st.ready_at = at + self.refractory;
216                }
217            }
218        }
219    }
220
221    /// A window held on the host: `rows` is `[channels, samples]`, local sample 0 is sample
222    /// `origin` of the run, and `scan` the local samples this window owns (later windows continue
223    /// from `scan.end`).
224    pub fn push_host_window(&mut self, rows: &[f32], samples: usize, scan: std::ops::Range<usize>, origin: u64) {
225        let mut crossings = Vec::new();
226        let mut stretches = Vec::new();
227        for side in 0..self.sides.len() {
228            for ch in 0..self.channels() {
229                let row = &rows[ch * samples..(ch + 1) * samples];
230                let Side { sign: s, ref levels, ref releases, .. } = self.sides[side];
231                let (l, r) = (levels[ch], releases[ch]);
232                crossings.clear();
233                for t in scan.clone() {
234                    let p = if t > 0 { row[t - 1] } else { self.prev[ch] };
235                    let (sp, sv) = (s * p, s * row[t]);
236                    if sp < l && sv >= l {
237                        crossings.push((t as u32, kernels::CROSSING_UP));
238                    } else if sp >= r && sv < r {
239                        crossings.push((t as u32, kernels::CROSSING_DOWN));
240                    }
241                }
242                stretches.clear();
243                let bounds: Vec<u32> = std::iter::once(scan.start as u32).chain(crossings.iter().map(|c| c.0)).chain(std::iter::once(scan.end as u32)).collect();
244                for w in bounds.windows(2) {
245                    let mut best = (-3.0e38f32, w[0]);
246                    for t in w[0]..w[1] {
247                        let v = s * row[t as usize];
248                        if v > best.0 {
249                            best = (v, t);
250                        }
251                    }
252                    stretches.push(best);
253                }
254                self.take(side, ch, origin, &crossings, &stretches);
255            }
256        }
257        if scan.end > 0 {
258            for ch in 0..self.channels() {
259                self.prev[ch] = rows[ch * samples + scan.end - 1];
260            }
261        }
262    }
263
264    /// Ends the run: events still open get no offset. Returns every event, by onset then channel.
265    pub fn finish(mut self) -> Vec<CrossingEvent> {
266        for side in &self.sides {
267            for (ch, st) in side.state.iter().enumerate() {
268                if st.on {
269                    self.events.push(CrossingEvent { channel: ch, onset: st.onset, offset: None, peak_sample: st.peak_at, peak_value: side.sign * st.peak });
270                }
271            }
272        }
273        self.events.sort_by(|a, b| (a.onset, a.channel).cmp(&(b.onset, b.channel)).then(a.peak_value.total_cmp(&b.peak_value)));
274        self.events
275    }
276}
277
278/// Threshold-crossing events of every channel of a `[channels, samples]` buffer in memory.
279pub fn detect_crossings(rows: &[f32], channels: usize, samples: usize, options: &CrossingOptions) -> OpsResult<Vec<CrossingEvent>> {
280    if rows.len() != channels * samples {
281        return Err(invalid(format!("crossings: {} values for {channels} × {samples}", rows.len())));
282    }
283    let mut tracker = CrossingTracker::new(channels, options)?;
284    tracker.push_host_window(rows, samples, 0..samples, 0);
285    Ok(tracker.finish())
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    /// Two bumps on channel 0 (the second dips to 4 in the middle), one on channel 1.
293    fn signal() -> (Vec<f32>, usize) {
294        let n = 40;
295        let mut x = vec![0.0f32; 2 * n];
296        for (t, v) in [(5, 6.0), (6, 9.0), (7, 7.0), (8, 1.0), (20, 8.0), (21, 4.0), (22, 10.0), (23, 6.0)] {
297            x[t] = v;
298        }
299        for t in 30..35 {
300            x[n + t] = 3.0 + t as f32 - 30.0;
301        }
302        (x, n)
303    }
304
305    #[test]
306    fn events_with_hysteresis_and_peaks() {
307        let (x, n) = signal();
308        let mut o = CrossingOptions::new(5.0);
309        o.releases = vec![2.0];
310        let ev = detect_crossings(&x, 2, n, &o).unwrap();
311        let got: Vec<_> = ev.iter().map(|e| (e.channel, e.onset, e.offset, e.peak_sample, e.peak_value)).collect();
312        // The dip to 4 stays above the release: one event 20..24, peak 10 at 22
313        assert_eq!(got, vec![(0, 5, Some(8), 6, 9.0), (0, 20, Some(24), 22, 10.0), (1, 32, Some(35), 34, 7.0)]);
314        // Without hysteresis the dip splits the second event
315        let ev = detect_crossings(&x, 2, n, &CrossingOptions::new(5.0)).unwrap();
316        assert_eq!(ev.iter().filter(|e| e.channel == 0).count(), 3);
317    }
318
319    #[test]
320    fn min_duration_refractory_and_open_events() {
321        let (x, n) = signal();
322        let mut o = CrossingOptions::new(5.0);
323        o.min_duration_samples = 4;
324        let ev = detect_crossings(&x, 2, n, &o).unwrap();
325        assert!(ev.iter().all(|e| e.offset.unwrap() - e.onset >= 4), "{ev:?}");
326        let mut o = CrossingOptions::new(5.0);
327        o.releases = vec![2.0];
328        o.refractory_samples = 20;
329        let ev = detect_crossings(&x[..n], 1, n, &o).unwrap();
330        assert_eq!(ev.len(), 1, "the second bump starts within 20 samples of the first's end");
331        // A run ending inside an event
332        let ev = detect_crossings(&x[..21], 1, 21, &CrossingOptions::new(5.0)).unwrap();
333        assert_eq!(ev.last().map(|e| (e.onset, e.offset)), Some((20, None)));
334    }
335
336    #[test]
337    fn falling_and_chunks_give_the_same_events() {
338        let (x, n) = signal();
339        let neg: Vec<f32> = x.iter().map(|v| -v).collect();
340        let mut o = CrossingOptions::new(-5.0);
341        o.releases = vec![-2.0];
342        o.direction = Direction::Falling;
343        let whole = detect_crossings(&neg, 2, n, &o).unwrap();
344        assert_eq!(whole.iter().map(|e| e.peak_value).collect::<Vec<_>>(), vec![-9.0, -10.0, -7.0]);
345        // Fed in windows of 7 samples (rows of each window copied out)
346        let mut t = CrossingTracker::new(2, &o).unwrap();
347        for start in (0..n).step_by(7) {
348            let end = (start + 7).min(n);
349            let w = end - start;
350            let rows: Vec<f32> = (0..2).flat_map(|c| neg[c * n + start..c * n + end].to_vec()).collect();
351            t.push_host_window(&rows, w, 0..w, start as u64);
352        }
353        assert_eq!(t.finish(), whole);
354    }
355
356    #[test]
357    fn both_directions_at_once() {
358        let (x, n) = signal();
359        // Channel 0 as is, plus a negative bump at 30..33
360        let mut y = x[..n].to_vec();
361        for t in 30..33 {
362            y[t] = -8.0;
363        }
364        let mut o = CrossingOptions::new(5.0);
365        o.releases = vec![2.0];
366        o.direction = Direction::Both;
367        let ev = detect_crossings(&y, 1, n, &o).unwrap();
368        let got: Vec<_> = ev.iter().map(|e| (e.onset, e.offset, e.peak_value)).collect();
369        assert_eq!(got, vec![(5, Some(8), 9.0), (20, Some(24), 10.0), (30, Some(33), -8.0)]);
370        // A negative level is taken as its magnitude
371        o.levels = vec![-5.0];
372        assert_eq!(detect_crossings(&y, 1, n, &o).unwrap(), ev);
373    }
374
375    #[test]
376    fn bad_options_are_refused() {
377        assert!(detect_crossings(&[0.0; 4], 2, 2, &CrossingOptions { levels: vec![1.0, 2.0, 3.0], ..CrossingOptions::new(0.0) }).is_err());
378        let mut o = CrossingOptions::new(5.0);
379        o.releases = vec![6.0];
380        assert!(detect_crossings(&[0.0; 4], 2, 2, &o).is_err(), "a rising release above the level");
381    }
382}