Skip to main content

ph_curves/
stabilize.rs

1//! Fixed-memory temporal stabilization for caller-supplied samples.
2//!
3//! These primitives are deterministic data processors. They do not acquire
4//! samples, read clocks, choose a sampling cadence, or interact with hardware.
5//!
6//! Decision helpers [`Hysteresis`] and [`Debounce`] sit beside the filter /
7//! detector family: they latch application-level boolean decisions from
8//! sample-count cadence only, without GPIO or wall-clock ownership.
9
10use crate::round::div_nearest_ties_away;
11
12mod sealed {
13    pub trait Sealed {}
14}
15
16/// Integer sample type supported by temporal stabilization primitives.
17///
18/// This trait is sealed and implemented for `u16`, `i32`, and `u32`. Callers
19/// cannot add implementations.
20///
21/// [`MovingAverage`] keeps an `i64` running sum, so each type declares the
22/// largest `N` for which `N` copies of its widest sample still fit. For `u16`
23/// and `i32` that bound is at least `usize::MAX` on 32-bit targets, so those
24/// implementations cap at `usize::MAX` there. Every addressable window also
25/// fits for `u32` on 16-bit-pointer targets. On wider targets its bound is
26/// `floor(i64::MAX / u32::MAX) = 2_147_483_648`. On a 32-bit target, arrays
27/// near that formal ceiling are already too large for a usable Rust value;
28/// the explicit arithmetic bound nevertheless keeps the accumulator contract
29/// target-independent instead of relying on a separate layout rejection.
30///
31/// The bound is an accumulator-safety ceiling, not a recommended window.
32/// Storage is `[T; N]` plus the `i64` sum — `2_147_483_648` `u32` samples
33/// occupy 8 GiB. Firmware chooses `N` from available RAM.
34pub trait TemporalSample: sealed::Sealed + Copy + Ord {
35    /// Zero value used to initialize fixed storage.
36    #[doc(hidden)]
37    const ZERO: Self;
38    /// Largest safe moving-average window for this sample type.
39    #[doc(hidden)]
40    const MAX_WINDOW: usize;
41
42    /// Convert to the shared signed accumulator representation.
43    #[doc(hidden)]
44    fn to_i64(self) -> i64;
45    /// Convert a proven-in-range result from the accumulator representation.
46    #[doc(hidden)]
47    fn from_i64(value: i64) -> Self;
48}
49
50impl sealed::Sealed for u16 {}
51
52impl TemporalSample for u16 {
53    const ZERO: Self = 0;
54    const MAX_WINDOW: usize = if usize::BITS > 32 {
55        (i64::MAX / u16::MAX as i64) as usize
56    } else {
57        usize::MAX
58    };
59
60    fn to_i64(self) -> i64 {
61        i64::from(self)
62    }
63
64    fn from_i64(value: i64) -> Self {
65        debug_assert!((0..=i64::from(u16::MAX)).contains(&value));
66        value as u16
67    }
68}
69
70impl sealed::Sealed for i32 {}
71
72impl TemporalSample for i32 {
73    const ZERO: Self = 0;
74    const MAX_WINDOW: usize = if usize::BITS > 32 {
75        (i64::MAX / 2_147_483_648) as usize
76    } else {
77        usize::MAX
78    };
79
80    fn to_i64(self) -> i64 {
81        i64::from(self)
82    }
83
84    fn from_i64(value: i64) -> Self {
85        debug_assert!((i64::from(i32::MIN)..=i64::from(i32::MAX)).contains(&value));
86        value as i32
87    }
88}
89
90impl sealed::Sealed for u32 {}
91
92impl TemporalSample for u32 {
93    const ZERO: Self = 0;
94    // Every window representable by a 16-bit `usize` fits the accumulator. On
95    // 32/64-bit targets, preserve the exact mathematical accumulator cap even
96    // though a 32-bit target cannot materialize arrays near that size.
97    const MAX_WINDOW: usize = if usize::BITS < 32 {
98        usize::MAX
99    } else {
100        (i64::MAX / u32::MAX as i64) as usize
101    };
102
103    fn to_i64(self) -> i64 {
104        i64::from(self)
105    }
106
107    fn from_i64(value: i64) -> Self {
108        debug_assert!((0..=i64::from(u32::MAX)).contains(&value));
109        value as u32
110    }
111}
112
113// This is deliberately a compile-time target guard: host unit tests cannot
114// execute the 16-bit branch, while the MSP430 core-only CI build can.
115#[cfg(target_pointer_width = "16")]
116const _: () = assert!(<u32 as TemporalSample>::MAX_WINDOW == usize::MAX);
117
118/// Output from a temporal filter.
119#[derive(Copy, Clone, Debug, Eq, PartialEq)]
120pub enum FilterOutput<T> {
121    /// The fixed window has not yet received enough samples.
122    WarmingUp {
123        /// Number of samples currently retained.
124        samples: usize,
125        /// Number of samples required for a ready output.
126        required: usize,
127    },
128    /// The filter has enough state to produce a value.
129    Ready(T),
130}
131
132impl<T> FilterOutput<T> {
133    /// Return the ready value, or `None` while warming up.
134    pub fn ready(self) -> Option<T> {
135        match self {
136            Self::WarmingUp { .. } => None,
137            Self::Ready(value) => Some(value),
138        }
139    }
140}
141
142/// Common interface for caller-driven temporal filters.
143pub trait TemporalFilter<T> {
144    /// Push one sample and return the current filter state.
145    fn update(&mut self, value: T) -> FilterOutput<T>;
146    /// Discard all retained history.
147    fn reset(&mut self);
148}
149
150/// Exact fixed-window moving average.
151///
152/// Updates are `O(1)` using a checked-range `i64` running sum. Output begins
153/// only after all `N` samples have been supplied.
154///
155/// The per-type window cap on [`TemporalSample`] is an accumulator-safety
156/// ceiling so `N` copies of the widest sample still fit in `i64`. It is not a
157/// practical size: storage is `[T; N]` plus that sum, and firmware chooses `N`
158/// from available RAM.
159#[derive(Clone, Debug)]
160pub struct MovingAverage<T: TemporalSample, const N: usize> {
161    samples: [T; N],
162    sum: i64,
163    next: usize,
164    len: usize,
165}
166
167impl<T: TemporalSample, const N: usize> MovingAverage<T, N> {
168    /// Construct an empty moving average.
169    ///
170    /// # Panics
171    ///
172    /// Panics for a zero-sized window or a window larger than the
173    /// accumulator-safety ceiling of `T` (see [`TemporalSample`]). That ceiling
174    /// is not a recommended window size; `N` is fixed storage.
175    pub const fn new() -> Self {
176        assert!(N > 0);
177        assert!(N <= T::MAX_WINDOW);
178        Self {
179            samples: [T::ZERO; N],
180            sum: 0,
181            next: 0,
182            len: 0,
183        }
184    }
185
186    /// Number of retained samples.
187    pub const fn len(&self) -> usize {
188        self.len
189    }
190
191    /// Whether no samples are retained.
192    pub const fn is_empty(&self) -> bool {
193        self.len == 0
194    }
195}
196
197impl<T: TemporalSample, const N: usize> Default for MovingAverage<T, N> {
198    fn default() -> Self {
199        Self::new()
200    }
201}
202
203impl<T: TemporalSample, const N: usize> TemporalFilter<T> for MovingAverage<T, N> {
204    fn update(&mut self, value: T) -> FilterOutput<T> {
205        if self.len == N {
206            self.sum -= self.samples[self.next].to_i64();
207        } else {
208            self.len += 1;
209        }
210
211        self.samples[self.next] = value;
212        self.sum += value.to_i64();
213        self.next += 1;
214        if self.next == N {
215            self.next = 0;
216        }
217
218        if self.len < N {
219            FilterOutput::WarmingUp {
220                samples: self.len,
221                required: N,
222            }
223        } else {
224            FilterOutput::Ready(T::from_i64(div_nearest_ties_away(self.sum, N as i64)))
225        }
226    }
227
228    fn reset(&mut self) {
229        self.samples = [T::ZERO; N];
230        self.sum = 0;
231        self.next = 0;
232        self.len = 0;
233    }
234}
235
236/// Fixed-window median filter for small odd window sizes.
237///
238/// The retained window is copied and insertion-sorted on each update, making
239/// this most appropriate for small windows used to reject isolated spikes.
240#[derive(Clone, Debug)]
241pub struct MedianFilter<T: TemporalSample, const N: usize> {
242    samples: [T; N],
243    next: usize,
244    len: usize,
245}
246
247impl<T: TemporalSample, const N: usize> MedianFilter<T, N> {
248    /// Construct an empty median filter.
249    ///
250    /// # Panics
251    ///
252    /// Panics unless `N` is nonzero and odd.
253    pub const fn new() -> Self {
254        assert!(N > 0 && N % 2 == 1);
255        Self {
256            samples: [T::ZERO; N],
257            next: 0,
258            len: 0,
259        }
260    }
261
262    /// Number of retained samples.
263    pub const fn len(&self) -> usize {
264        self.len
265    }
266
267    /// Whether no samples are retained.
268    pub const fn is_empty(&self) -> bool {
269        self.len == 0
270    }
271}
272
273impl<T: TemporalSample, const N: usize> Default for MedianFilter<T, N> {
274    fn default() -> Self {
275        Self::new()
276    }
277}
278
279impl<T: TemporalSample, const N: usize> TemporalFilter<T> for MedianFilter<T, N> {
280    fn update(&mut self, value: T) -> FilterOutput<T> {
281        self.samples[self.next] = value;
282        self.next += 1;
283        if self.next == N {
284            self.next = 0;
285        }
286        if self.len < N {
287            self.len += 1;
288        }
289        if self.len < N {
290            return FilterOutput::WarmingUp {
291                samples: self.len,
292                required: N,
293            };
294        }
295
296        let mut sorted = self.samples;
297        let mut index = 1;
298        while index < N {
299            let value = sorted[index];
300            let mut insert = index;
301            while insert > 0 && sorted[insert - 1] > value {
302                sorted[insert] = sorted[insert - 1];
303                insert -= 1;
304            }
305            sorted[insert] = value;
306            index += 1;
307        }
308        FilterOutput::Ready(sorted[N / 2])
309    }
310
311    fn reset(&mut self) {
312        self.samples = [T::ZERO; N];
313        self.next = 0;
314        self.len = 0;
315    }
316}
317
318/// Constant-memory exponential smoother.
319///
320/// `alpha` is an unsigned Q0.16-like blend weight: `0` retains the initialized
321/// value and `65535` follows each new sample exactly. The first sample
322/// initializes the smoother and is immediately ready.
323///
324/// Updates use nearest integer division:
325/// `adjustment = round(delta * alpha / 65535)`.
326/// When `|delta| * alpha < 32768`, the adjustment is zero, so light smoothing
327/// can ignore small steps until the gap is large enough. Choose `alpha` with
328/// that quantization floor in mind.
329#[derive(Copy, Clone, Debug)]
330pub struct ExponentialSmoother<T: TemporalSample> {
331    alpha: u16,
332    value: T,
333    initialized: bool,
334}
335
336impl<T: TemporalSample> ExponentialSmoother<T> {
337    /// Construct an uninitialized smoother with the supplied blend weight.
338    pub const fn new(alpha: u16) -> Self {
339        Self {
340            alpha,
341            value: T::ZERO,
342            initialized: false,
343        }
344    }
345
346    /// Return the configured Q0.16-like blend weight.
347    pub const fn alpha(&self) -> u16 {
348        self.alpha
349    }
350
351    /// Return the current value, or `None` before the first sample.
352    pub const fn value(&self) -> Option<T> {
353        if self.initialized {
354            Some(self.value)
355        } else {
356            None
357        }
358    }
359}
360
361impl<T: TemporalSample> TemporalFilter<T> for ExponentialSmoother<T> {
362    fn update(&mut self, value: T) -> FilterOutput<T> {
363        if !self.initialized {
364            self.value = value;
365            self.initialized = true;
366            return FilterOutput::Ready(value);
367        }
368
369        let current = self.value.to_i64();
370        let delta = value.to_i64() - current;
371        let adjustment = div_nearest_ties_away(delta * i64::from(self.alpha), i64::from(u16::MAX));
372        self.value = T::from_i64(current + adjustment);
373        FilterOutput::Ready(self.value)
374    }
375
376    fn reset(&mut self) {
377        self.value = T::ZERO;
378        self.initialized = false;
379    }
380}
381
382/// Classification returned by a [`StabilityDetector`].
383#[derive(Copy, Clone, Debug, Eq, PartialEq)]
384pub enum Stability<T> {
385    /// The detector has not yet received a full window.
386    WarmingUp {
387        /// Number of samples currently retained.
388        samples: usize,
389        /// Number of samples required for classification.
390        required: usize,
391    },
392    /// The full window exceeds the configured range threshold.
393    Unstable {
394        /// Minimum retained sample.
395        minimum: T,
396        /// Maximum retained sample.
397        maximum: T,
398        /// Difference between maximum and minimum in sample quanta.
399        span: u64,
400    },
401    /// The full window is within the configured range threshold.
402    Stable {
403        /// Minimum retained sample.
404        minimum: T,
405        /// Maximum retained sample.
406        maximum: T,
407        /// Difference between maximum and minimum in sample quanta.
408        span: u64,
409    },
410}
411
412/// Fixed-window range-based stability detector.
413///
414/// Classification begins only after all `N` samples are present. The detector
415/// reports retained extrema and never substitutes a stale last-good value.
416#[derive(Clone, Debug)]
417pub struct StabilityDetector<T: TemporalSample, const N: usize> {
418    samples: [T; N],
419    threshold: u64,
420    next: usize,
421    len: usize,
422}
423
424impl<T: TemporalSample, const N: usize> StabilityDetector<T, N> {
425    /// Construct an empty detector with a maximum stable range in sample
426    /// quanta.
427    ///
428    /// # Panics
429    ///
430    /// Panics for a zero-sized window.
431    pub const fn new(threshold: u64) -> Self {
432        assert!(N > 0);
433        Self {
434            samples: [T::ZERO; N],
435            threshold,
436            next: 0,
437            len: 0,
438        }
439    }
440
441    /// Return the maximum range classified as stable.
442    pub const fn threshold(&self) -> u64 {
443        self.threshold
444    }
445
446    /// Number of retained samples.
447    pub const fn len(&self) -> usize {
448        self.len
449    }
450
451    /// Whether no samples are retained.
452    pub const fn is_empty(&self) -> bool {
453        self.len == 0
454    }
455
456    /// Push one sample and classify the retained window.
457    pub fn update(&mut self, value: T) -> Stability<T> {
458        self.samples[self.next] = value;
459        self.next += 1;
460        if self.next == N {
461            self.next = 0;
462        }
463        if self.len < N {
464            self.len += 1;
465        }
466        if self.len < N {
467            return Stability::WarmingUp {
468                samples: self.len,
469                required: N,
470            };
471        }
472
473        let mut minimum = self.samples[0];
474        let mut maximum = self.samples[0];
475        let mut index = 1;
476        while index < N {
477            minimum = minimum.min(self.samples[index]);
478            maximum = maximum.max(self.samples[index]);
479            index += 1;
480        }
481        let span = (maximum.to_i64() - minimum.to_i64()) as u64;
482        if span <= self.threshold {
483            Stability::Stable {
484                minimum,
485                maximum,
486                span,
487            }
488        } else {
489            Stability::Unstable {
490                minimum,
491                maximum,
492                span,
493            }
494        }
495    }
496
497    /// Discard all retained history.
498    pub fn reset(&mut self) {
499        self.samples = [T::ZERO; N];
500        self.next = 0;
501        self.len = 0;
502    }
503}
504
505/// Schmitt-trigger latch over integer samples.
506///
507/// Values at or above `high` latch on; values at or below `low` latch off.
508/// Samples strictly between the thresholds hold the previous latch. When
509/// `low == high`, the band collapses to a simple threshold with no hold
510/// region. Cadence is caller-driven sample count — this type never reads a
511/// clock or GPIO.
512#[derive(Copy, Clone, Debug, Eq, PartialEq)]
513pub struct Hysteresis<T: TemporalSample> {
514    low: T,
515    high: T,
516    latched: bool,
517    initial: bool,
518}
519
520impl Hysteresis<i32> {
521    /// Construct a hysteresis latch that starts off.
522    ///
523    /// # Panics
524    ///
525    /// Panics if `low > high`.
526    pub const fn new(low: i32, high: i32) -> Self {
527        assert!(low <= high);
528        Self {
529            low,
530            high,
531            latched: false,
532            initial: false,
533        }
534    }
535}
536
537impl Hysteresis<u16> {
538    /// Construct a hysteresis latch that starts off.
539    ///
540    /// # Panics
541    ///
542    /// Panics if `low > high`.
543    pub const fn new(low: u16, high: u16) -> Self {
544        assert!(low <= high);
545        Self {
546            low,
547            high,
548            latched: false,
549            initial: false,
550        }
551    }
552}
553
554impl Hysteresis<u32> {
555    /// Construct a hysteresis latch that starts off.
556    ///
557    /// # Panics
558    ///
559    /// Panics if `low > high`.
560    pub const fn new(low: u32, high: u32) -> Self {
561        assert!(low <= high);
562        Self {
563            low,
564            high,
565            latched: false,
566            initial: false,
567        }
568    }
569}
570
571impl<T: TemporalSample> Hysteresis<T> {
572    /// Set the initial latch used before the first crossing and after
573    /// [`reset`](Self::reset).
574    pub const fn with_initial(mut self, on: bool) -> Self {
575        self.latched = on;
576        self.initial = on;
577        self
578    }
579
580    /// Return the configured low threshold.
581    pub const fn low(&self) -> T {
582        self.low
583    }
584
585    /// Return the configured high threshold.
586    pub const fn high(&self) -> T {
587        self.high
588    }
589
590    /// Push one sample and return the current latched level.
591    ///
592    /// The first sample inside the open band `(low, high)` holds the initial
593    /// latch (default `false`). Applications that need an explicit unknown
594    /// state should track `Option` separately.
595    pub fn update(&mut self, value: T) -> bool {
596        if value >= self.high {
597            self.latched = true;
598        } else if value <= self.low {
599            self.latched = false;
600        }
601        self.latched
602    }
603
604    /// Return the current latched level without consuming a sample.
605    pub const fn state(&self) -> bool {
606        self.latched
607    }
608
609    /// Restore the latch to the value supplied by [`with_initial`](Self::with_initial)
610    /// (or `false` when that builder was not used).
611    pub fn reset(&mut self) {
612        self.latched = self.initial;
613    }
614}
615
616/// Output from a sample-count [`Debounce`].
617///
618/// No latched level is reported until `N` consecutive agreeing samples have
619/// been observed. After arming, [`Steady`](DebounceOutput::Steady) holds the
620/// previous latch while a new candidate accumulates, and
621/// [`Edge`](DebounceOutput::Edge) reports only confirmed level changes.
622#[derive(Copy, Clone, Debug, Eq, PartialEq)]
623pub enum DebounceOutput {
624    /// Fewer than `N` consecutive agreeing samples have been seen since start
625    /// or the last candidate change, and no level has latched yet.
626    WarmingUp {
627        /// Consecutive samples agreeing on the current candidate.
628        streak: usize,
629        /// Samples required to latch (`N`).
630        required: usize,
631    },
632    /// Latched level is unchanged on this sample.
633    Steady(bool),
634    /// Latched level changed on this sample (including the first latch).
635    Edge {
636        /// Newly latched level.
637        level: bool,
638    },
639}
640
641/// Sample-count contact debounce for boolean inputs.
642///
643/// Latches after `N` consecutive agreeing samples. A candidate flip mid-streak
644/// resets the streak to one on the new candidate. Timing is entirely in sample
645/// counts — callers that think in milliseconds must convert duration to `N`
646/// themselves. This type never owns GPIO, EXTI, or clocks.
647#[derive(Copy, Clone, Debug, Eq, PartialEq)]
648pub struct Debounce<const N: usize> {
649    candidate: bool,
650    streak: usize,
651    latched: bool,
652    armed: bool,
653}
654
655impl<const N: usize> Debounce<N> {
656    /// Construct an unarmed debounce.
657    ///
658    /// # Panics
659    ///
660    /// Panics when `N == 0`.
661    pub const fn new() -> Self {
662        assert!(N > 0);
663        Self {
664            candidate: false,
665            streak: 0,
666            latched: false,
667            armed: false,
668        }
669    }
670
671    /// Push one boolean sample and return the debounce state.
672    pub fn update(&mut self, sample: bool) -> DebounceOutput {
673        if self.streak == 0 || sample != self.candidate {
674            self.candidate = sample;
675            self.streak = 1;
676        } else if self.streak < N {
677            self.streak += 1;
678        }
679
680        if self.streak < N {
681            if self.armed {
682                DebounceOutput::Steady(self.latched)
683            } else {
684                DebounceOutput::WarmingUp {
685                    streak: self.streak,
686                    required: N,
687                }
688            }
689        } else if !self.armed {
690            self.armed = true;
691            self.latched = self.candidate;
692            DebounceOutput::Edge {
693                level: self.latched,
694            }
695        } else if self.candidate != self.latched {
696            self.latched = self.candidate;
697            DebounceOutput::Edge {
698                level: self.latched,
699            }
700        } else {
701            DebounceOutput::Steady(self.latched)
702        }
703    }
704
705    /// Return the latched level after the first confirmed latch, or `None`
706    /// while still warming up.
707    pub const fn state(&self) -> Option<bool> {
708        if self.armed { Some(self.latched) } else { None }
709    }
710
711    /// Discard streak and latch state.
712    pub fn reset(&mut self) {
713        self.candidate = false;
714        self.streak = 0;
715        self.latched = false;
716        self.armed = false;
717    }
718}
719
720impl<const N: usize> Default for Debounce<N> {
721    fn default() -> Self {
722        Self::new()
723    }
724}
725
726#[cfg(test)]
727mod tests {
728    extern crate std;
729
730    use super::*;
731
732    #[test]
733    fn moving_average_warms_up_and_rolls() {
734        let mut filter = MovingAverage::<i32, 3>::new();
735        assert_eq!(
736            filter.update(3),
737            FilterOutput::WarmingUp {
738                samples: 1,
739                required: 3
740            }
741        );
742        assert_eq!(filter.update(6).ready(), None);
743        assert_eq!(filter.update(9), FilterOutput::Ready(6));
744        assert_eq!(filter.update(12), FilterOutput::Ready(9));
745    }
746
747    #[test]
748    fn moving_average_rounds_signed_ties_away() {
749        let mut positive = MovingAverage::<i32, 2>::new();
750        positive.update(0);
751        assert_eq!(positive.update(1), FilterOutput::Ready(1));
752
753        let mut negative = MovingAverage::<i32, 2>::new();
754        negative.update(0);
755        assert_eq!(negative.update(-1), FilterOutput::Ready(-1));
756    }
757
758    #[test]
759    fn moving_average_handles_integer_extremes() {
760        let mut signed = MovingAverage::<i32, 2>::new();
761        signed.update(i32::MIN);
762        assert_eq!(signed.update(i32::MAX), FilterOutput::Ready(-1));
763
764        let mut unsigned = MovingAverage::<u16, 2>::new();
765        unsigned.update(0);
766        assert_eq!(unsigned.update(u16::MAX), FilterOutput::Ready(32_768));
767    }
768
769    #[test]
770    fn median_rejects_isolated_spike() {
771        let mut filter = MedianFilter::<u16, 5>::new();
772        for value in [1000, 1001, 4095, 999] {
773            assert!(filter.update(value).ready().is_none());
774        }
775        assert_eq!(filter.update(1002), FilterOutput::Ready(1001));
776    }
777
778    #[test]
779    fn exponential_smoother_has_explicit_step_response() {
780        let mut filter = ExponentialSmoother::<i32>::new(32_768);
781        assert_eq!(filter.update(0), FilterOutput::Ready(0));
782        assert_eq!(filter.update(1000), FilterOutput::Ready(500));
783        assert_eq!(filter.update(1000), FilterOutput::Ready(750));
784        assert_eq!(filter.value(), Some(750));
785    }
786
787    #[test]
788    fn exponential_smoother_quantization_floor_ignores_small_steps() {
789        let mut filter = ExponentialSmoother::<i32>::new(100);
790        filter.update(0);
791        // |delta| * alpha = 327 * 100 = 32700 < 32768, so adjustment rounds to 0.
792        assert_eq!(filter.update(327), FilterOutput::Ready(0));
793        // |delta| * alpha = 328 * 100 = 32800 >= 32768, so adjustment becomes 1.
794        assert_eq!(filter.update(328), FilterOutput::Ready(1));
795    }
796
797    #[test]
798    fn reset_restores_warmup_or_uninitialized_state() {
799        let mut average = MovingAverage::<u16, 2>::new();
800        average.update(10);
801        average.update(20);
802        average.reset();
803        assert!(average.is_empty());
804        assert!(average.update(30).ready().is_none());
805
806        let mut exponential = ExponentialSmoother::<i32>::new(1000);
807        exponential.update(42);
808        exponential.reset();
809        assert_eq!(exponential.value(), None);
810        assert_eq!(exponential.update(-7), FilterOutput::Ready(-7));
811    }
812
813    #[test]
814    fn detector_distinguishes_warm_stable_and_unstable() {
815        let mut detector = StabilityDetector::<i32, 3>::new(4);
816        assert!(matches!(detector.update(100), Stability::WarmingUp { .. }));
817        assert!(matches!(detector.update(102), Stability::WarmingUp { .. }));
818        assert_eq!(
819            detector.update(104),
820            Stability::Stable {
821                minimum: 100,
822                maximum: 104,
823                span: 4
824            }
825        );
826        assert_eq!(
827            detector.update(110),
828            Stability::Unstable {
829                minimum: 102,
830                maximum: 110,
831                span: 8
832            }
833        );
834    }
835
836    #[test]
837    fn detector_span_handles_full_i32_range() {
838        let mut detector = StabilityDetector::<i32, 2>::new(u64::MAX);
839        detector.update(i32::MIN);
840        assert_eq!(
841            detector.update(i32::MAX),
842            Stability::Stable {
843                minimum: i32::MIN,
844                maximum: i32::MAX,
845                span: u64::from(u32::MAX)
846            }
847        );
848    }
849
850    #[test]
851    fn invalid_window_sizes_are_rejected() {
852        assert!(std::panic::catch_unwind(MovingAverage::<i32, 0>::new).is_err());
853        assert!(std::panic::catch_unwind(MedianFilter::<i32, 2>::new).is_err());
854        assert!(std::panic::catch_unwind(|| StabilityDetector::<i32, 0>::new(0)).is_err());
855    }
856
857    #[test]
858    fn hysteresis_latches_with_hold_band() {
859        let mut hyst = Hysteresis::<i32>::new(10, 20);
860        assert!(!hyst.update(15));
861        assert!(hyst.update(20));
862        assert!(hyst.update(15));
863        assert!(!hyst.update(10));
864        assert!(!hyst.update(15));
865    }
866
867    #[test]
868    fn hysteresis_equal_thresholds_are_simple_threshold() {
869        let mut hyst = Hysteresis::<u16>::new(100, 100);
870        assert!(!hyst.update(99));
871        // `value >= high` wins when low == high, so the threshold itself latches on.
872        assert!(hyst.update(100));
873        assert!(hyst.update(100));
874        assert!(!hyst.update(99));
875    }
876
877    #[test]
878    fn hysteresis_with_initial_and_reset() {
879        let mut hyst = Hysteresis::<i32>::new(-5, 5).with_initial(true);
880        assert!(hyst.state());
881        assert!(hyst.update(0));
882        assert!(!hyst.update(-5));
883        hyst.reset();
884        assert!(hyst.state());
885    }
886
887    #[test]
888    fn hysteresis_rejects_inverted_band() {
889        assert!(std::panic::catch_unwind(|| Hysteresis::<i32>::new(2, 1)).is_err());
890        assert!(std::panic::catch_unwind(|| Hysteresis::<u16>::new(2, 1)).is_err());
891    }
892
893    #[test]
894    fn debounce_warms_up_then_edges_on_change() {
895        let mut deb = Debounce::<3>::new();
896        assert_eq!(
897            deb.update(true),
898            DebounceOutput::WarmingUp {
899                streak: 1,
900                required: 3
901            }
902        );
903        assert_eq!(
904            deb.update(true),
905            DebounceOutput::WarmingUp {
906                streak: 2,
907                required: 3
908            }
909        );
910        assert_eq!(deb.update(true), DebounceOutput::Edge { level: true });
911        assert_eq!(deb.state(), Some(true));
912        assert_eq!(deb.update(true), DebounceOutput::Steady(true));
913        assert_eq!(deb.update(false), DebounceOutput::Steady(true));
914        assert_eq!(deb.update(false), DebounceOutput::Steady(true));
915        assert_eq!(deb.update(false), DebounceOutput::Edge { level: false });
916        assert_eq!(deb.update(false), DebounceOutput::Steady(false));
917    }
918
919    #[test]
920    fn debounce_candidate_flip_resets_streak() {
921        let mut deb = Debounce::<3>::new();
922        assert!(matches!(
923            deb.update(true),
924            DebounceOutput::WarmingUp { streak: 1, .. }
925        ));
926        assert!(matches!(
927            deb.update(false),
928            DebounceOutput::WarmingUp { streak: 1, .. }
929        ));
930        assert!(matches!(
931            deb.update(false),
932            DebounceOutput::WarmingUp { streak: 2, .. }
933        ));
934        assert_eq!(deb.update(false), DebounceOutput::Edge { level: false });
935    }
936
937    #[test]
938    fn debounce_n_one_is_passthrough_with_edges() {
939        let mut deb = Debounce::<1>::new();
940        assert_eq!(deb.update(false), DebounceOutput::Edge { level: false });
941        assert_eq!(deb.update(false), DebounceOutput::Steady(false));
942        assert_eq!(deb.update(true), DebounceOutput::Edge { level: true });
943        assert_eq!(deb.update(true), DebounceOutput::Steady(true));
944    }
945
946    #[test]
947    fn debounce_reset_returns_to_warmup() {
948        let mut deb = Debounce::<2>::new();
949        deb.update(true);
950        deb.update(true);
951        assert_eq!(deb.state(), Some(true));
952        deb.reset();
953        assert_eq!(deb.state(), None);
954        assert!(matches!(
955            deb.update(false),
956            DebounceOutput::WarmingUp { .. }
957        ));
958    }
959
960    #[test]
961    fn debounce_rejects_zero_window() {
962        assert!(std::panic::catch_unwind(Debounce::<0>::new).is_err());
963    }
964
965    #[test]
966    fn hysteresis_into_debounce_composition() {
967        let mut hyst = Hysteresis::<i32>::new(10, 20);
968        let mut deb = Debounce::<2>::new();
969        let mut edges = 0u8;
970        for sample in [0, 25, 25, 15, 5, 5, 5] {
971            let level = hyst.update(sample);
972            if matches!(deb.update(level), DebounceOutput::Edge { .. }) {
973                edges += 1;
974            }
975        }
976        assert_eq!(edges, 2);
977        assert_eq!(deb.state(), Some(false));
978    }
979
980    #[test]
981    fn u32_moving_average_warms_up_rolls_and_resets() {
982        let mut filter = MovingAverage::<u32, 3>::new();
983        assert!(filter.is_empty());
984        assert_eq!(
985            filter.update(3),
986            FilterOutput::WarmingUp {
987                samples: 1,
988                required: 3
989            }
990        );
991        assert_eq!(filter.len(), 1);
992        assert_eq!(filter.update(6).ready(), None);
993        assert_eq!(filter.update(9), FilterOutput::Ready(6));
994        assert_eq!(filter.len(), 3);
995        assert_eq!(filter.update(12), FilterOutput::Ready(9));
996        filter.reset();
997        assert!(filter.is_empty());
998        assert_eq!(filter.len(), 0);
999        assert!(filter.update(30).ready().is_none());
1000    }
1001
1002    #[test]
1003    fn u32_moving_average_handles_zero_max_and_ties() {
1004        let mut filter = MovingAverage::<u32, 2>::new();
1005        filter.update(0);
1006        assert_eq!(filter.update(u32::MAX), FilterOutput::Ready(2_147_483_648));
1007
1008        let mut both_max = MovingAverage::<u32, 2>::new();
1009        both_max.update(u32::MAX);
1010        assert_eq!(both_max.update(u32::MAX), FilterOutput::Ready(u32::MAX));
1011
1012        let mut zeros = MovingAverage::<u32, 2>::new();
1013        zeros.update(0);
1014        assert_eq!(zeros.update(0), FilterOutput::Ready(0));
1015    }
1016
1017    #[test]
1018    fn u32_moving_average_window_bound_is_accumulator_limited() {
1019        const MAX: usize = <u32 as TemporalSample>::MAX_WINDOW;
1020        assert_eq!(MAX, 2_147_483_648);
1021        assert_eq!(MAX as i64, i64::MAX / i64::from(u32::MAX));
1022        assert!((MAX as i64).checked_mul(i64::from(u32::MAX)).is_some());
1023        assert!(
1024            ((MAX as i64) + 1)
1025                .checked_mul(i64::from(u32::MAX))
1026                .is_none()
1027        );
1028    }
1029
1030    #[test]
1031    fn u32_median_covers_full_unsigned_width() {
1032        let mut filter = MedianFilter::<u32, 3>::new();
1033        assert!(filter.update(0).ready().is_none());
1034        assert!(filter.update(u32::MAX).ready().is_none());
1035        assert_eq!(filter.update(1), FilterOutput::Ready(1));
1036        assert_eq!(filter.update(u32::MAX), FilterOutput::Ready(u32::MAX));
1037    }
1038
1039    #[test]
1040    fn u32_exponential_smoother_extreme_transitions() {
1041        let mut up = ExponentialSmoother::<u32>::new(u16::MAX);
1042        assert_eq!(up.update(0), FilterOutput::Ready(0));
1043        assert_eq!(up.update(u32::MAX), FilterOutput::Ready(u32::MAX));
1044
1045        let mut down = ExponentialSmoother::<u32>::new(u16::MAX);
1046        assert_eq!(down.update(u32::MAX), FilterOutput::Ready(u32::MAX));
1047        assert_eq!(down.update(0), FilterOutput::Ready(0));
1048    }
1049
1050    #[test]
1051    fn u32_detector_span_covers_full_unsigned_range() {
1052        let mut detector = StabilityDetector::<u32, 2>::new(u64::MAX);
1053        detector.update(0);
1054        assert_eq!(
1055            detector.update(u32::MAX),
1056            Stability::Stable {
1057                minimum: 0,
1058                maximum: u32::MAX,
1059                span: u64::from(u32::MAX)
1060            }
1061        );
1062    }
1063
1064    #[test]
1065    fn u32_hysteresis_latches_at_unsigned_boundaries() {
1066        let mut hyst = Hysteresis::<u32>::new(0, u32::MAX);
1067        assert!(!hyst.update(1));
1068        assert!(hyst.update(u32::MAX));
1069        assert!(hyst.update(1));
1070        assert!(!hyst.update(0));
1071        assert!(!hyst.update(1));
1072    }
1073
1074    #[test]
1075    fn u32_hysteresis_equal_thresholds_at_boundaries() {
1076        let mut at_zero = Hysteresis::<u32>::new(0, 0);
1077        assert!(at_zero.update(0));
1078
1079        let mut at_max = Hysteresis::<u32>::new(u32::MAX, u32::MAX);
1080        assert!(!at_max.update(u32::MAX - 1));
1081        assert!(at_max.update(u32::MAX));
1082        assert!(at_max.update(u32::MAX));
1083        assert!(!at_max.update(u32::MAX - 1));
1084    }
1085
1086    #[test]
1087    fn u32_hysteresis_rejects_inverted_band() {
1088        assert!(std::panic::catch_unwind(|| Hysteresis::<u32>::new(2, 1)).is_err());
1089        assert!(std::panic::catch_unwind(|| Hysteresis::<u32>::new(u32::MAX, 0)).is_err());
1090    }
1091}