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