Skip to main content

harmos_signal/waveform/
reduce.rs

1//! Choosing which samples to keep when there are more than anything downstream
2//! can use.
3//!
4//! Every reducer answers the *indices* it selected, strictly increasing, and
5//! never the samples themselves. A chart wants two columns, a store wants four,
6//! and a caller that got indices back reads whichever of its own columns it
7//! keeps — which is also what makes these compose:
8//!
9//! ```
10//! use harmos_signal::{above_floor, lttb};
11//!
12//! let frequency: Vec<f64> = (0..1_000).map(|bin| f64::from(bin) * 100.0).collect();
13//! let magnitude: Vec<f64> =
14//!     (0..1_000).map(|bin| if bin == 100 || bin == 500 { 100.0 } else { 0.1 }).collect();
15//!
16//! // Drop the noise floor, then thin what is left to fifty points.
17//! let kept = above_floor(&magnitude, 40.0);
18//! let x: Vec<f64> = kept.iter().map(|bin| frequency[*bin]).collect();
19//! let y: Vec<f64> = kept.iter().map(|bin| magnitude[*bin]).collect();
20//! let picked: Vec<usize> = lttb(&x, &y, 50).iter().map(|index| kept[*index]).collect();
21//!
22//! assert!(picked.contains(&100) && picked.contains(&500));
23//! ```
24
25/// Thins `(x, y)` to `count` samples by largest-triangle-three-buckets.
26///
27/// LTTB keeps the shape a naive stride throws away: each bucket contributes the
28/// sample forming the largest triangle with the previously kept sample and the
29/// mean of the next bucket, so peaks and inflections survive. The first and
30/// last samples are always kept.
31///
32/// Asking for at least as many samples as there are, or for fewer than three,
33/// keeps everything.
34#[must_use]
35pub fn lttb(x: &[f64], y: &[f64], count: usize) -> Vec<usize> {
36    let length = x.len().min(y.len());
37    if length <= count || count < 3 {
38        return (0..length).collect();
39    }
40
41    let mut kept = Vec::with_capacity(count);
42    kept.push(0);
43
44    let bucket = (length - 2) as f64 / (count - 2) as f64;
45    let mut previous = 0;
46
47    for index in 0..count - 2 {
48        let start = 1 + (index as f64 * bucket).floor() as usize;
49        let end = (1 + ((index + 1) as f64 * bucket).floor() as usize).min(length - 1);
50        let ahead = if index < count - 3 {
51            (1 + ((index + 2) as f64 * bucket).floor() as usize).min(length)
52        } else {
53            length
54        };
55
56        let (mean_x, mean_y) = mean_of(x, y, end..ahead).unwrap_or((x[length - 1], y[length - 1]));
57
58        let picked = (start..end)
59            .max_by(|left, right| {
60                area(x[previous], y[previous], x[*left], y[*left], mean_x, mean_y).total_cmp(&area(
61                    x[previous],
62                    y[previous],
63                    x[*right],
64                    y[*right],
65                    mean_x,
66                    mean_y,
67                ))
68            })
69            .unwrap_or(start);
70
71        kept.push(picked);
72        previous = picked;
73    }
74
75    kept.push(length - 1);
76    kept
77}
78
79/// Thins `(x, y)` to at most `count` samples with logarithmically spaced buckets.
80///
81/// The same selection as [`lttb`], with the buckets laid out along `ln(x)` and
82/// the triangle measured there too. On a spectrum drawn against a logarithmic
83/// frequency axis this is the difference between the first decade getting one
84/// sample and getting its share: linear buckets spend nearly every point on the
85/// top decade, where the axis has the least room for them.
86///
87/// A bucket holding no sample contributes none, so the answer may be shorter
88/// than `count`. An axis that does not increase, or holds no positive value to
89/// take a logarithm of, is reduced by [`lttb`] instead of refused.
90#[must_use]
91pub fn lttb_log(x: &[f64], y: &[f64], count: usize) -> Vec<usize> {
92    let length = x.len().min(y.len());
93    if length <= count || count < 3 {
94        return (0..length).collect();
95    }
96
97    let (first, last) = (positive(x[0]), x[length - 1]);
98    if !last.is_finite() || last <= first {
99        return lttb(x, y, count);
100    }
101
102    let (low, span) = (first.ln(), last.ln() - first.ln());
103    if span <= 0.0 {
104        return lttb(x, y, count);
105    }
106
107    let buckets = count - 2;
108    let edge = |bucket: usize| (low + (bucket as f64 / buckets as f64) * span).exp();
109
110    let mut kept = Vec::with_capacity(count);
111    kept.push(0);
112    let mut previous = 0;
113
114    for bucket in 0..buckets {
115        let start = x[..length]
116            .partition_point(|at| *at < edge(bucket))
117            .min(length - 1);
118        let end = x[..length]
119            .partition_point(|at| *at < edge(bucket + 1))
120            .clamp(start + 1, length);
121        let ahead = if bucket < buckets - 1 {
122            x[..length]
123                .partition_point(|at| *at < edge(bucket + 2).min(last))
124                .clamp(end, length)
125        } else {
126            length
127        };
128
129        let (mean_x, mean_y) = mean_of(x, y, end..ahead).unwrap_or((x[length - 1], y[length - 1]));
130        // The triangle is measured where the eye sees it: along ln(x).
131        let (previous_x, mean_x) = (positive(x[previous]).ln(), positive(mean_x).ln());
132
133        let picked = (start..end)
134            .max_by(|left, right| {
135                let of = |bin: usize| {
136                    area(
137                        previous_x,
138                        y[previous],
139                        positive(x[bin]).ln(),
140                        y[bin],
141                        mean_x,
142                        mean_y,
143                    )
144                };
145                of(*left).total_cmp(&of(*right))
146            })
147            .unwrap_or(start);
148
149        kept.push(picked);
150        previous = picked;
151    }
152
153    kept.push(length - 1);
154    kept.dedup();
155    kept
156}
157
158/// Keeps every `n`th index of a run of `length`, for at most `count` of them.
159///
160/// The cheap reduction, and the one that loses peaks: a stride knows nothing
161/// about the samples it skips. It needs neither column, only how many there
162/// are.
163#[must_use]
164pub fn decimate(length: usize, count: usize) -> Vec<usize> {
165    if length == 0 || count == 0 {
166        return Vec::new();
167    }
168
169    let stride = (length as f64 / count as f64).ceil() as usize;
170    (0..length).step_by(stride.max(1)).collect()
171}
172
173/// Keeps the lowest and highest sample of each of `buckets` equal spans.
174///
175/// The envelope reduction: up to two indices per bucket, in sample order, so a
176/// waveform drawn from the answer still touches every extreme the full run
177/// touched. A bucket whose lowest and highest are the same sample contributes
178/// it once.
179#[must_use]
180pub fn extrema(values: &[f64], buckets: usize) -> Vec<usize> {
181    if values.is_empty() || buckets == 0 {
182        return Vec::new();
183    }
184
185    let span = ((values.len() as f64 / buckets as f64).ceil() as usize).max(1);
186    let mut kept = Vec::with_capacity(buckets * 2);
187
188    for (bucket, samples) in values.chunks(span).enumerate() {
189        // Strict comparisons on purpose: a bucket whose samples never differ
190        // leaves both ends on the first one, and contributes it alone.
191        let (mut lowest, mut highest) = (0, 0);
192        for (index, value) in samples.iter().enumerate() {
193            if *value < samples[lowest] {
194                lowest = index;
195            }
196            if *value > samples[highest] {
197                highest = index;
198            }
199        }
200
201        let base = bucket * span;
202        kept.push(base + lowest.min(highest));
203        if lowest != highest {
204            kept.push(base + lowest.max(highest));
205        }
206    }
207
208    kept
209}
210
211/// Keeps the samples within `db_below_peak` decibels of the largest one.
212///
213/// The noise-floor cut: a spectrum's floor is what a display spends most of its
214/// points drawing, and forty decibels below the peak is rarely signal. Amplitude
215/// decibels, so the threshold is `peak · 10^(-db / 20)`.
216///
217/// A run whose largest sample is not positive keeps nothing.
218#[must_use]
219pub fn above_floor(values: &[f64], db_below_peak: f64) -> Vec<usize> {
220    let peak = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
221    if peak.is_nan() || peak <= 0.0 {
222        return Vec::new();
223    }
224
225    let floor = peak * 10.0_f64.powf(-db_below_peak / 20.0);
226    values
227        .iter()
228        .enumerate()
229        .filter(|(_, value)| **value >= floor)
230        .map(|(index, _)| index)
231        .collect()
232}
233
234/// The value, or the smallest positive number when it is not one.
235///
236/// Only the logarithmic reducer needs it, and only so that a zero or negative
237/// coordinate has a logarithm rather than a `NaN` that would poison a
238/// comparison.
239fn positive(at: f64) -> f64 {
240    at.max(f64::MIN_POSITIVE)
241}
242
243/// Twice the area of the triangle three points make.
244fn area(x0: f64, y0: f64, x1: f64, y1: f64, x2: f64, y2: f64) -> f64 {
245    ((x0 - x2) * (y1 - y0) - (x0 - x1) * (y2 - y0)).abs()
246}
247
248/// The mean point of a range of samples, when the range holds any.
249fn mean_of(x: &[f64], y: &[f64], range: std::ops::Range<usize>) -> Option<(f64, f64)> {
250    let count = range.len();
251    if count == 0 {
252        return None;
253    }
254
255    let sum_x: f64 = x[range.clone()].iter().sum();
256    let sum_y: f64 = y[range].iter().sum();
257
258    Some((sum_x / count as f64, sum_y / count as f64))
259}
260
261#[cfg(test)]
262mod tests {
263    use std::f64::consts::TAU;
264
265    use super::*;
266
267    /// A run of `count` samples of `f`, on a unit-spaced axis.
268    fn run(count: usize, f: impl Fn(f64) -> f64) -> (Vec<f64>, Vec<f64>) {
269        (0..count)
270            .map(|index| (index as f64, f(index as f64)))
271            .unzip()
272    }
273
274    #[test]
275    fn lttb_keeps_the_endpoints_and_the_count_asked_for() {
276        let (x, y) = run(1_000, |at| (at * 0.01).sin());
277
278        let kept = lttb(&x, &y, 100);
279
280        assert_eq!(kept.len(), 100);
281        assert_eq!(kept[0], 0);
282        assert_eq!(kept[99], 999);
283    }
284
285    #[test]
286    fn lttb_answers_strictly_increasing_indices() {
287        let (x, y) = run(1_000, |at| (at * 0.05).sin());
288
289        let kept = lttb(&x, &y, 137);
290
291        assert!(kept.windows(2).all(|pair| pair[0] < pair[1]));
292    }
293
294    #[test]
295    fn lttb_keeps_a_spike_a_stride_would_miss() {
296        let (x, y) = run(100, |at| if (at - 53.0).abs() < 0.5 { 100.0 } else { 0.0 });
297
298        let kept = lttb(&x, &y, 20);
299
300        assert!(
301            kept.contains(&53),
302            "the spike at 53 was thinned away: {kept:?}"
303        );
304        assert!(
305            !decimate(100, 20).contains(&53),
306            "a stride of five steps straight over it"
307        );
308    }
309
310    #[test]
311    fn asking_for_more_than_there_is_keeps_everything() {
312        let (x, y) = run(10, |at| at);
313
314        assert_eq!(lttb(&x, &y, 50), (0..10).collect::<Vec<_>>());
315        assert_eq!(lttb(&x, &y, 2), (0..10).collect::<Vec<_>>());
316        assert_eq!(lttb_log(&x, &y, 50), (0..10).collect::<Vec<_>>());
317        assert!(lttb(&[], &[], 10).is_empty());
318    }
319
320    #[test]
321    fn log_buckets_spend_points_on_the_low_decades() {
322        // 1 kHz to 10 MHz: four decades, of which the top one holds nine tenths
323        // of the samples.
324        let x: Vec<f64> = (1..=10_000).map(|bin| f64::from(bin) * 1_000.0).collect();
325        let y: Vec<f64> = x.iter().map(|at| (at.ln() * 0.5).sin()).collect();
326
327        let linear = lttb(&x, &y, 100);
328        let logarithmic = lttb_log(&x, &y, 100);
329
330        let below = |kept: &[usize]| kept.iter().filter(|bin| x[**bin] < 1e6).count();
331
332        assert!(below(&logarithmic) > below(&linear));
333        assert!(logarithmic.len() <= 100);
334        assert_eq!(logarithmic[0], 0);
335        assert_eq!(logarithmic[logarithmic.len() - 1], 9_999);
336    }
337
338    #[test]
339    fn log_buckets_answer_increasing_indices() {
340        let x: Vec<f64> = (1..=5_000).map(|bin| f64::from(bin) * 1_000.0).collect();
341        let y: Vec<f64> = x.iter().map(|at| 1_000.0 / at.sqrt()).collect();
342
343        let kept = lttb_log(&x, &y, 50);
344
345        assert!(kept.windows(2).all(|pair| pair[0] < pair[1]), "{kept:?}");
346    }
347
348    #[test]
349    fn an_axis_with_no_logarithmic_span_falls_back_to_linear_buckets() {
350        let x = vec![5.0; 100];
351        let y: Vec<f64> = (0..100).map(f64::from).collect();
352
353        assert_eq!(lttb_log(&x, &y, 10), lttb(&x, &y, 10));
354    }
355
356    #[test]
357    fn decimation_strides_and_never_overruns() {
358        assert_eq!(decimate(10, 5), vec![0, 2, 4, 6, 8]);
359        assert_eq!(decimate(100, 10).len(), 10);
360        assert!(decimate(0, 10).is_empty());
361        assert!(decimate(10, 0).is_empty());
362    }
363
364    #[test]
365    fn extrema_keep_both_ends_of_every_bucket() {
366        let (_, y) = run(100, |at| (at * 0.1 * TAU).sin());
367
368        let kept = extrema(&y, 10);
369
370        assert!(kept.len() <= 20);
371        assert!(kept.windows(2).all(|pair| pair[0] < pair[1]), "{kept:?}");
372
373        let highest = y.iter().copied().fold(f64::NEG_INFINITY, f64::max);
374        let lowest = y.iter().copied().fold(f64::INFINITY, f64::min);
375        assert!(kept.iter().any(|index| (y[*index] - highest).abs() < 1e-12));
376        assert!(kept.iter().any(|index| (y[*index] - lowest).abs() < 1e-12));
377    }
378
379    #[test]
380    fn a_flat_bucket_contributes_one_sample() {
381        assert_eq!(extrema(&[1.0, 1.0, 1.0, 1.0], 2), vec![0, 2]);
382        assert!(extrema(&[], 4).is_empty());
383        assert!(extrema(&[1.0], 0).is_empty());
384    }
385
386    #[test]
387    fn the_floor_keeps_what_is_within_the_stated_decibels() {
388        let mut values = vec![1.0; 100];
389        values[50] = 100.0;
390
391        // Forty decibels below a peak of 100 is 1.0, which the floor keeps.
392        assert_eq!(above_floor(&values, 40.0).len(), 100);
393        // Twenty decibels below it is 10.0, which only the peak clears.
394        assert_eq!(above_floor(&values, 20.0), vec![50]);
395    }
396
397    #[test]
398    fn a_run_with_no_positive_sample_has_no_floor() {
399        assert!(above_floor(&[0.0, 0.0], 40.0).is_empty());
400        assert!(above_floor(&[-1.0, -2.0], 40.0).is_empty());
401        assert!(above_floor(&[], 40.0).is_empty());
402    }
403}