1#[derive(Debug, Clone, Copy, PartialEq)]
32pub struct SignTuple {
33 pub norm: f64,
35 pub drift: f64,
37 pub slew: f64,
39}
40
41impl SignTuple {
42 #[inline]
44 #[must_use]
45 pub const fn new(norm: f64, drift: f64, slew: f64) -> Self {
46 debug_assert!(norm.is_finite() || norm.is_nan(), "norm must be finite or NaN");
47 Self { norm, drift, slew }
48 }
49
50 #[inline]
52 #[must_use]
53 pub const fn zero() -> Self {
54 Self { norm: 0.0, drift: 0.0, slew: 0.0 }
55 }
56
57 #[inline]
59 #[must_use]
60 pub fn is_outward_drift(&self) -> bool {
61 self.drift > 0.0
62 }
63
64 #[inline]
67 #[must_use]
68 pub fn is_abrupt_slew(&self, delta_s: f64) -> bool {
69 debug_assert!(delta_s >= 0.0, "delta_s must be non-negative");
70 crate::math::abs_f64(self.slew) > delta_s
71 }
72}
73
74impl Default for SignTuple {
75 fn default() -> Self {
76 Self::zero()
77 }
78}
79
80pub struct SignWindow<const W: usize> {
86 norms: [f64; W],
87 prev_drift: f64,
88 head: usize,
89 count: usize,
91}
92
93impl<const W: usize> SignWindow<W> {
94 #[must_use]
96 pub const fn new() -> Self {
97 Self { norms: [0.0; W], prev_drift: 0.0, head: 0, count: 0 }
98 }
99
100 pub fn push(&mut self, norm: f64, below_floor: bool) -> SignTuple {
107 debug_assert!(W > 0, "SignWindow<0> is degenerate — W must be ≥ 1");
108 debug_assert!(self.head < W.max(1), "head invariant violated");
109
110 if W == 0 {
111 return SignTuple::zero();
115 }
116
117 self.norms[self.head] = norm;
118 self.head = (self.head + 1) % W;
119 if self.count < W {
120 self.count += 1;
121 }
122
123 if below_floor || self.count < 2 {
124 self.prev_drift = 0.0;
125 return SignTuple::new(norm, 0.0, 0.0);
126 }
127
128 let filled = self.count.min(W);
130 let mut sum_diff = 0.0_f64;
131 let mut n_diffs = 0_usize;
132 let mut i = 1_usize;
133 while i < filled {
134 let cur = (self.head + W - 1 - (i - 1)) % W;
135 let prev = (self.head + W - 1 - i) % W;
136 sum_diff += self.norms[cur] - self.norms[prev];
137 n_diffs += 1;
138 i += 1;
139 }
140
141 let drift = if n_diffs > 0 { sum_diff / n_diffs as f64 } else { 0.0 };
142 let slew = drift - self.prev_drift;
143 self.prev_drift = drift;
144 SignTuple::new(norm, drift, slew)
145 }
146
147 pub fn reset(&mut self) {
150 self.norms = [0.0; W];
151 self.prev_drift = 0.0;
152 self.head = 0;
153 self.count = 0;
154 }
155
156 #[inline]
158 #[must_use]
159 pub fn count(&self) -> usize {
160 self.count
161 }
162}
163
164impl<const W: usize> Default for SignWindow<W> {
165 fn default() -> Self {
166 Self::new()
167 }
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173
174 #[test]
175 fn zero_tuple_is_rest() {
176 let s = SignTuple::zero();
177 assert_eq!(s.norm, 0.0);
178 assert!(!s.is_outward_drift());
179 assert!(!s.is_abrupt_slew(0.01));
180 }
181
182 #[test]
183 fn outward_drift_is_positive_drift() {
184 assert!(SignTuple::new(0.1, 0.01, 0.0).is_outward_drift());
185 assert!(!SignTuple::new(0.1, 0.0, 0.0).is_outward_drift());
186 assert!(!SignTuple::new(0.1, -0.01, 0.0).is_outward_drift());
187 }
188
189 #[test]
190 fn abrupt_slew_threshold_is_absolute() {
191 assert!(SignTuple::new(0.1, 0.0, 0.1).is_abrupt_slew(0.05));
192 assert!(SignTuple::new(0.1, 0.0, -0.1).is_abrupt_slew(0.05));
193 assert!(!SignTuple::new(0.1, 0.0, 0.01).is_abrupt_slew(0.05));
194 }
195
196 #[test]
197 fn window_sub_floor_forces_zero_drift() {
198 let mut w = SignWindow::<5>::new();
199 for i in 0..5u32 {
200 let s = w.push(i as f64 * 0.1, true);
201 assert_eq!(s.drift, 0.0);
202 assert_eq!(s.slew, 0.0);
203 }
204 }
205
206 #[test]
207 fn window_monotone_increase_has_positive_drift() {
208 let mut w = SignWindow::<5>::new();
209 for i in 0..8u32 {
210 let s = w.push(i as f64 * 0.01, false);
211 if i >= 2 {
212 assert!(s.drift > 0.0, "expected positive drift, got {}", s.drift);
213 }
214 }
215 }
216
217 #[test]
218 fn window_constant_input_has_zero_drift() {
219 let mut w = SignWindow::<5>::new();
220 let mut last = None;
221 for _ in 0..8 {
222 last = Some(w.push(0.42, false));
223 }
224 let s = last.expect("pushed at least once");
225 assert!(crate::math::abs_f64(s.drift) < 1e-12, "drift = {}", s.drift);
226 }
227
228 #[test]
229 fn window_reset_clears_state() {
230 let mut w = SignWindow::<5>::new();
231 for i in 0..5u32 {
232 w.push(i as f64 * 0.1, false);
233 }
234 w.reset();
235 assert_eq!(w.count(), 0);
236 let s = w.push(0.5, false);
237 assert_eq!(s.drift, 0.0);
238 }
239}