wickra_core/indicators/
median_channel.rs1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::indicators::rolling_quantile::quantile_sorted;
7use crate::traits::Indicator;
8
9#[derive(Debug, Clone, Copy, PartialEq)]
11pub struct MedianChannelOutput {
12 pub upper: f64,
14 pub middle: f64,
16 pub lower: f64,
18}
19
20#[derive(Debug, Clone)]
52pub struct MedianChannel {
53 period: usize,
54 multiplier: f64,
55 window: VecDeque<f64>,
56 scratch: Vec<f64>,
57 deviations: Vec<f64>,
58}
59
60impl MedianChannel {
61 pub fn new(period: usize, multiplier: f64) -> Result<Self> {
68 if period == 0 {
69 return Err(Error::PeriodZero);
70 }
71 if period > crate::error::MAX_PERIOD {
72 return Err(Error::InvalidPeriod {
73 message: crate::error::PERIOD_ABOVE_MAX,
74 });
75 }
76 if !multiplier.is_finite() || multiplier <= 0.0 {
77 return Err(Error::NonPositiveMultiplier);
78 }
79 Ok(Self {
80 period,
81 multiplier,
82 window: VecDeque::with_capacity(period),
83 scratch: Vec::with_capacity(period),
84 deviations: Vec::with_capacity(period),
85 })
86 }
87
88 pub const fn period(&self) -> usize {
90 self.period
91 }
92
93 pub const fn multiplier(&self) -> f64 {
95 self.multiplier
96 }
97}
98
99impl Indicator for MedianChannel {
100 type Input = f64;
101 type Output = MedianChannelOutput;
102
103 #[inline]
104 fn update(&mut self, value: f64) -> Option<MedianChannelOutput> {
105 if !value.is_finite() {
106 return None;
107 }
108 if self.window.len() == self.period {
109 self.window.pop_front();
110 }
111 self.window.push_back(value);
112 if self.window.len() < self.period {
113 return None;
114 }
115 self.scratch.clear();
116 self.scratch.extend(self.window.iter().copied());
117 self.scratch.sort_by(f64::total_cmp);
118 let median = quantile_sorted(&self.scratch, 0.5);
119
120 self.deviations.clear();
121 for &v in &self.window {
122 self.deviations.push((v - median).abs());
123 }
124 self.deviations.sort_by(f64::total_cmp);
125 let mad = quantile_sorted(&self.deviations, 0.5);
126 let offset = self.multiplier * mad;
127
128 Some(MedianChannelOutput {
129 upper: median + offset,
130 middle: median,
131 lower: median - offset,
132 })
133 }
134
135 fn reset(&mut self) {
136 self.window.clear();
137 self.scratch.clear();
138 self.deviations.clear();
139 }
140
141 #[inline]
142 fn warmup_period(&self) -> usize {
143 self.period
144 }
145
146 #[inline]
147 fn is_ready(&self) -> bool {
148 self.window.len() == self.period
149 }
150
151 #[inline]
152 fn name(&self) -> &'static str {
153 "MedianChannel"
154 }
155}
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160 use crate::traits::BatchExt;
161 use approx::assert_relative_eq;
162
163 #[test]
164 fn rejects_zero_period() {
165 assert!(matches!(MedianChannel::new(0, 2.0), Err(Error::PeriodZero)));
166 assert!(MedianChannel::new(1, 2.0).is_ok());
167 }
168
169 #[test]
170 fn rejects_non_positive_multiplier() {
171 assert!(matches!(
172 MedianChannel::new(20, 0.0),
173 Err(Error::NonPositiveMultiplier)
174 ));
175 assert!(matches!(
176 MedianChannel::new(20, -1.0),
177 Err(Error::NonPositiveMultiplier)
178 ));
179 assert!(matches!(
180 MedianChannel::new(20, f64::NAN),
181 Err(Error::NonPositiveMultiplier)
182 ));
183 }
184
185 #[test]
186 fn accessors_and_metadata() {
187 let mc = MedianChannel::new(20, 2.0).unwrap();
188 assert_eq!(mc.period(), 20);
189 assert_relative_eq!(mc.multiplier(), 2.0, epsilon = 1e-12);
190 assert_eq!(mc.warmup_period(), 20);
191 assert_eq!(mc.name(), "MedianChannel");
192 assert!(!mc.is_ready());
193 }
194
195 #[test]
196 fn warms_up_then_emits() {
197 let mut mc = MedianChannel::new(5, 2.0).unwrap();
198 for v in [1.0, 2.0, 3.0, 4.0] {
199 assert!(mc.update(v).is_none());
200 }
201 assert!(mc.update(5.0).is_some());
202 assert!(mc.is_ready());
203 }
204
205 #[test]
206 fn known_channel() {
207 let mut mc = MedianChannel::new(5, 2.0).unwrap();
210 let out = mc.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
211 let last = out[4].unwrap();
212 assert_relative_eq!(last.middle, 3.0, epsilon = 1e-12);
213 assert_relative_eq!(last.upper, 5.0, epsilon = 1e-12);
214 assert_relative_eq!(last.lower, 1.0, epsilon = 1e-12);
215 }
216
217 #[test]
218 fn robust_to_outlier() {
219 let mut mc = MedianChannel::new(5, 2.0).unwrap();
222 let out = mc.batch(&[1.0, 2.0, 3.0, 4.0, 1_000.0]);
223 assert_relative_eq!(out[4].unwrap().middle, 3.0, epsilon = 1e-12);
224 }
225
226 #[test]
227 fn rolling_window_evicts_oldest() {
228 let mut mc = MedianChannel::new(5, 2.0).unwrap();
231 let out = mc.batch(&[10.0, 10.0, 10.0, 10.0, 10.0, 1.0, 2.0, 3.0, 4.0, 5.0]);
232 let last = out[9].unwrap();
233 assert_relative_eq!(last.middle, 3.0, epsilon = 1e-12);
234 assert_relative_eq!(last.upper, 5.0, epsilon = 1e-12);
235 assert_relative_eq!(last.lower, 1.0, epsilon = 1e-12);
236 }
237
238 #[test]
239 fn reset_clears_state() {
240 let mut mc = MedianChannel::new(5, 2.0).unwrap();
241 for v in [1.0, 2.0, 3.0, 4.0, 5.0] {
242 mc.update(v);
243 }
244 assert!(mc.is_ready());
245 mc.reset();
246 assert!(!mc.is_ready());
247 assert!(mc.update(1.0).is_none());
248 }
249}