Skip to main content

harmos_signal/waveform/
edge.rs

1//! Where a waveform crossed something: a hysteresis trigger, and a plain
2//! first crossing.
3
4/// One transition of a waveform through its trigger, at an interpolated
5/// coordinate.
6#[derive(Clone, Copy, Debug, PartialEq)]
7pub struct Edge {
8    /// Where the crossing happened, in the coordinates of the `x` axis.
9    pub at: f64,
10    /// Whether the waveform was going up. A falling edge is `false`.
11    pub rising: bool,
12}
13
14/// Every edge a waveform makes through a two-level trigger.
15///
16/// `low` and `high` are fractions of the waveform's own range, so `0.1` and
17/// `0.9` are the usual ten-to-ninety-percent thresholds whatever the signal's
18/// amplitude turns out to be. Two levels rather than one is the whole point: a
19/// waveform that rings around a single threshold would trigger on every
20/// wobble, and one that has to cross the *other* level before it can trigger
21/// again cannot.
22///
23/// Each edge's coordinate is interpolated between the samples that straddle the
24/// threshold. A waveform with no range at all — a constant — has no edges.
25#[must_use]
26pub fn edges(x: &[f64], y: &[f64], low: f64, high: f64) -> Vec<Edge> {
27    let length = x.len().min(y.len());
28    if length < 2 {
29        return Vec::new();
30    }
31
32    let lowest = y[..length].iter().copied().fold(f64::INFINITY, f64::min);
33    let highest = y[..length]
34        .iter()
35        .copied()
36        .fold(f64::NEG_INFINITY, f64::max);
37    let range = highest - lowest;
38    if !range.is_finite() || range < f64::EPSILON {
39        return Vec::new();
40    }
41
42    let (low, high) = (lowest + range * low, lowest + range * high);
43    let mut state = level(y[0], low, high);
44    let mut found = Vec::new();
45
46    for index in 1..length {
47        let (previous, current) = (y[index - 1], y[index]);
48
49        match state {
50            Level::Beneath if current > high => {
51                found.push(Edge {
52                    at: crossed(x, index, high, previous, current),
53                    rising: true,
54                });
55                state = Level::Above;
56            }
57            Level::Above if current < low => {
58                found.push(Edge {
59                    at: crossed(x, index, low, previous, current),
60                    rising: false,
61                });
62                state = Level::Beneath;
63            }
64            Level::Between => state = level(current, low, high),
65            Level::Beneath | Level::Above => {}
66        }
67    }
68
69    found
70}
71
72/// The coordinate at which the waveform first reaches `target`.
73///
74/// Interpolated between the two samples that straddle it, and answered for the
75/// first straddle in either direction. A waveform that never reaches the value
76/// has no crossing.
77#[must_use]
78pub fn crossing(x: &[f64], y: &[f64], target: f64) -> Option<f64> {
79    let length = x.len().min(y.len());
80
81    (1..length).find_map(|index| {
82        let (previous, current) = (y[index - 1], y[index]);
83        let straddles =
84            (previous <= target && target <= current) || (current <= target && target <= previous);
85
86        straddles.then(|| crossed(x, index, target, previous, current))
87    })
88}
89
90/// Which side of the trigger a sample is on.
91#[derive(Clone, Copy)]
92enum Level {
93    Beneath,
94    Between,
95    Above,
96}
97
98/// The level one value sits at.
99fn level(value: f64, low: f64, high: f64) -> Level {
100    if value > high {
101        Level::Above
102    } else if value < low {
103        Level::Beneath
104    } else {
105        Level::Between
106    }
107}
108
109/// Where between samples `index - 1` and `index` the waveform met `target`.
110fn crossed(x: &[f64], index: usize, target: f64, previous: f64, current: f64) -> f64 {
111    let (before, after) = (x[index - 1], x[index]);
112    let rise = current - previous;
113
114    if rise.abs() < f64::EPSILON {
115        return before;
116    }
117
118    before + (target - previous) / rise * (after - before)
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    /// A unit-spaced axis of `count` coordinates.
126    fn axis(count: usize) -> Vec<f64> {
127        (0..count).map(|index| index as f64).collect()
128    }
129
130    #[test]
131    fn a_square_wave_makes_one_edge_per_transition() {
132        let y = vec![0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0, 1.0];
133        let found = edges(&axis(8), &y, 0.1, 0.9);
134
135        assert_eq!(found.len(), 3);
136        assert!(found[0].rising);
137        assert!(!found[1].rising);
138        assert!(found[2].rising);
139    }
140
141    #[test]
142    fn an_edge_is_placed_between_the_samples_that_straddle_it() {
143        // Rising from zero to two over one second crosses one at the half.
144        let found = edges(&axis(3), &[0.0, 2.0, 0.0], 0.25, 0.5);
145
146        assert!((found[0].at - 0.5).abs() < 1e-12, "rose at {}", found[0].at);
147        assert!(
148            (found[1].at - 1.75).abs() < 1e-12,
149            "fell at {}",
150            found[1].at
151        );
152    }
153
154    #[test]
155    fn hysteresis_refuses_to_trigger_twice_on_one_transition() {
156        // A ringing rise: without a second level, the wobble at index 3 would
157        // count as a fresh edge.
158        let y = vec![0.0, 0.0, 1.0, 0.6, 1.0, 1.0];
159        let found = edges(&axis(6), &y, 0.1, 0.9);
160
161        assert_eq!(found.len(), 1);
162        assert!(found[0].rising);
163    }
164
165    #[test]
166    fn a_constant_waveform_has_no_edges() {
167        assert!(edges(&axis(10), &[5.0; 10], 0.1, 0.9).is_empty());
168        assert!(edges(&[], &[], 0.1, 0.9).is_empty());
169        assert!(edges(&[0.0], &[1.0], 0.1, 0.9).is_empty());
170    }
171
172    #[test]
173    fn a_crossing_is_the_first_one_in_either_direction() {
174        let y = vec![0.0, 1.0, 2.0, 1.0, 0.0];
175
176        assert!(crossing(&axis(5), &y, 1.5).is_some_and(|at| (at - 1.5).abs() < 1e-12));
177        assert!(crossing(&axis(5), &y, 0.5).is_some_and(|at| (at - 0.5).abs() < 1e-12));
178    }
179
180    #[test]
181    fn a_value_the_waveform_never_reaches_is_never_crossed() {
182        assert!(crossing(&axis(5), &[0.0, 1.0, 2.0, 1.0, 0.0], 5.0).is_none());
183        assert!(crossing(&[], &[], 1.0).is_none());
184    }
185
186    #[test]
187    fn a_flat_pair_straddling_the_target_answers_the_earlier_coordinate() {
188        assert!(crossing(&axis(3), &[1.0, 1.0, 2.0], 1.0).is_some_and(|at| at.abs() < 1e-12));
189    }
190}