Skip to main content

eventcv_core/
analytics.rs

1//! Temporal analytics — how event activity varies over the length of a recording.
2//!
3//! The spatial counterpart already exists elsewhere: `io::SliceSource::pixel_counts` totals events
4//! per pixel across a whole file, which is the heatmap. This module is the other axis, and answers
5//! the questions that come up when a recording behaves oddly — where did the sensor saturate, where
6//! is the scene actually still, is one polarity dominating.
7//!
8//! Counts are returned rather than plotted: the library depends on nothing but `ndarray` here, and a
9//! caller who wants a figure already has matplotlib.
10
11use crate::EventStream;
12
13/// Activity over time, binned into fixed-width intervals.
14///
15/// `starts` holds each bin's left edge in the stream's own timestamp units, so it lines up with
16/// `EventStream::ts()` without conversion. The three count arrays are the same length as `starts`.
17#[derive(Clone, Debug, PartialEq, Eq)]
18pub struct EventRate {
19    /// Left edge of each bin, in stream timestamp units (µs).
20    pub starts: Vec<i64>,
21    /// Events of either polarity in each bin.
22    pub counts: Vec<u64>,
23    /// Positive-polarity events in each bin.
24    pub positive: Vec<u64>,
25    /// Negative-polarity events in each bin.
26    pub negative: Vec<u64>,
27    /// Bin width in stream timestamp units (µs).
28    pub bin_us: i64,
29}
30
31impl EventRate {
32    /// Events per second in each bin — `counts` divided by the bin width.
33    ///
34    /// Separate from `counts` because the bins are uniform: dividing is only meaningful if every bin
35    /// covers the same span, which is exactly what this binning guarantees and what a caller
36    /// aggregating by some other rule could not assume.
37    pub fn per_second(&self) -> Vec<f64> {
38        let seconds = self.bin_us as f64 / 1_000_000.0;
39        self.counts.iter().map(|&n| n as f64 / seconds).collect()
40    }
41
42    /// Number of bins.
43    pub fn len(&self) -> usize {
44        self.starts.len()
45    }
46
47    /// True when the stream was empty and there is nothing to plot.
48    pub fn is_empty(&self) -> bool {
49        self.starts.is_empty()
50    }
51}
52
53impl EventStream {
54    /// Bins the stream into fixed-width intervals of `bin_us` and counts events in each.
55    ///
56    /// Bins span the stream's own extent — from the earliest to the latest timestamp — so an empty
57    /// stream produces no bins and a stream that does not divide evenly gets a final short bin that
58    /// is still counted. A `bin_us` below 1 is clamped, since a zero-width bin has no rate.
59    ///
60    /// Does not require sorted input: bins are indexed arithmetically from the minimum timestamp
61    /// rather than by walking in order, so this is safe to call before `sort_by_time`.
62    pub fn event_rate(&self, bin_us: i64) -> EventRate {
63        let bin_us = bin_us.max(1);
64        let ts = self.ts();
65        let (Some(&t_min), Some(&t_max)) = (ts.iter().min(), ts.iter().max()) else {
66            return EventRate {
67                starts: Vec::new(),
68                counts: Vec::new(),
69                positive: Vec::new(),
70                negative: Vec::new(),
71                bin_us,
72            };
73        };
74
75        // +1 because the span is inclusive of the last event: a recording from t=0 to t=100 with
76        // 100µs bins needs two, not one.
77        let bins = ((t_max - t_min) / bin_us + 1) as usize;
78        let mut counts = vec![0u64; bins];
79        let mut positive = vec![0u64; bins];
80        let mut negative = vec![0u64; bins];
81
82        for (&t, &p) in ts.iter().zip(self.ps()) {
83            let bin = ((t - t_min) / bin_us) as usize;
84            counts[bin] += 1;
85            if p {
86                positive[bin] += 1;
87            } else {
88                negative[bin] += 1;
89            }
90        }
91
92        EventRate {
93            starts: (0..bins).map(|i| t_min + i as i64 * bin_us).collect(),
94            counts,
95            positive,
96            negative,
97            bin_us,
98        }
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use crate::{EventStream, EventStreamBuilder};
105
106    fn sample() -> EventStream {
107        // Events at t = 0, 10, 20, … 90; polarity alternates.
108        let mut builder = EventStreamBuilder::new(4, 4, 0.001);
109        for i in 0..10u16 {
110            builder.push(i % 4, i % 4, i64::from(i) * 10, i % 2 == 0);
111        }
112        builder.build()
113    }
114
115    #[test]
116    fn bins_cover_the_whole_span() {
117        let rate = sample().event_rate(50);
118        assert_eq!(rate.starts, vec![0, 50]);
119        assert_eq!(rate.counts, vec![5, 5]);
120        assert_eq!(rate.counts.iter().sum::<u64>(), 10);
121    }
122
123    #[test]
124    fn polarities_sum_to_the_total() {
125        let rate = sample().event_rate(30);
126        for i in 0..rate.len() {
127            assert_eq!(rate.positive[i] + rate.negative[i], rate.counts[i]);
128        }
129        assert_eq!(rate.positive.iter().sum::<u64>(), 5);
130        assert_eq!(rate.negative.iter().sum::<u64>(), 5);
131    }
132
133    #[test]
134    fn a_trailing_partial_bin_is_still_counted() {
135        // Span is 0..=90 with 40µs bins: 0-39, 40-79, 80-90 (short).
136        let rate = sample().event_rate(40);
137        assert_eq!(rate.len(), 3);
138        assert_eq!(rate.counts, vec![4, 4, 2]);
139    }
140
141    #[test]
142    fn per_second_scales_by_bin_width() {
143        let rate = sample().event_rate(50); // 50µs bins, 5 events each
144        assert_eq!(rate.per_second(), vec![100_000.0, 100_000.0]);
145    }
146
147    #[test]
148    fn unsorted_input_bins_identically() {
149        let sorted = sample();
150        let shuffled = sorted.time_scale(-1.0).time_scale(-1.0); // same values, rebuilt
151        assert_eq!(sorted.event_rate(30), shuffled.event_rate(30));
152    }
153
154    #[test]
155    fn zero_bin_width_is_clamped() {
156        let rate = sample().event_rate(0);
157        assert_eq!(rate.bin_us, 1);
158        assert_eq!(rate.counts.iter().sum::<u64>(), 10);
159    }
160
161    #[test]
162    fn empty_stream_has_no_bins() {
163        let empty = EventStreamBuilder::new(4, 4, 0.001).build();
164        let rate = empty.event_rate(100);
165        assert!(rate.is_empty());
166        assert_eq!(rate.len(), 0);
167        assert!(rate.per_second().is_empty());
168    }
169}