subetha_cxc/
phase_estimator.rs1use std::time::{Duration, Instant};
22
23#[derive(Debug, Clone, Copy)]
25pub struct PhaseConfig {
26 pub alpha: f64,
29 pub cv_engage: f64,
32 pub cv_disengage: f64,
36 pub min_samples: u64,
38}
39
40impl Default for PhaseConfig {
41 fn default() -> Self {
42 Self {
43 alpha: 0.2,
44 cv_engage: 0.25,
45 cv_disengage: 0.40,
46 min_samples: 16,
47 }
48 }
49}
50
51pub struct PhaseEstimator {
53 cfg: PhaseConfig,
54 period_ns: Option<f64>,
57 cv: f64,
60 last_arrival: Option<Instant>,
62 samples: u64,
64 engaged: bool,
65}
66
67impl PhaseEstimator {
68 pub fn new(cfg: PhaseConfig) -> Self {
69 Self {
70 cfg,
71 period_ns: None,
72 cv: 1.0,
73 last_arrival: None,
74 samples: 0,
75 engaged: false,
76 }
77 }
78
79 pub fn record(&mut self, now: Instant) {
83 if let Some(last) = self.last_arrival {
84 let delta_ns = now.saturating_duration_since(last).as_nanos() as f64;
85 match self.period_ns {
86 Some(p) if p > 0.0 => {
87 let rel_err = (delta_ns - p).abs() / p;
88 self.cv = (1.0 - self.cfg.alpha) * self.cv + self.cfg.alpha * rel_err;
89 self.period_ns =
90 Some((1.0 - self.cfg.alpha) * p + self.cfg.alpha * delta_ns);
91 }
92 _ => {
93 self.period_ns = Some(delta_ns);
97 }
98 }
99 self.samples += 1;
100 self.update_engaged();
101 }
102 self.last_arrival = Some(now);
103 }
104
105 fn update_engaged(&mut self) {
106 if self.engaged {
107 if self.cv > self.cfg.cv_disengage || self.samples < self.cfg.min_samples {
108 self.engaged = false;
109 }
110 } else if self.cv < self.cfg.cv_engage && self.samples >= self.cfg.min_samples {
111 self.engaged = true;
112 }
113 }
114
115 pub fn predict_next(&self) -> Option<Instant> {
118 match (self.last_arrival, self.period_ns) {
119 (Some(last), Some(p)) if p > 0.0 => {
120 Some(last + Duration::from_nanos(p as u64))
121 }
122 _ => None,
123 }
124 }
125
126 pub fn engaged(&self) -> bool {
129 self.engaged
130 }
131
132 pub fn period(&self) -> Option<Duration> {
134 self.period_ns.map(|p| Duration::from_nanos(p as u64))
135 }
136
137 pub fn cv(&self) -> f64 {
139 self.cv
140 }
141
142 pub fn samples(&self) -> u64 {
144 self.samples
145 }
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151
152 fn feed(est: &mut PhaseEstimator, base: Instant, offsets_ns: &[u64]) {
154 for &off in offsets_ns {
155 est.record(base + Duration::from_nanos(off));
156 }
157 }
158
159 #[test]
160 fn perfect_period_converges_and_engages() {
161 let mut est = PhaseEstimator::new(PhaseConfig::default());
162 let base = Instant::now();
163 let offsets: Vec<u64> = (0..40).map(|i| i * 10_000).collect(); feed(&mut est, base, &offsets);
165 assert!(est.engaged(), "a perfectly periodic series must engage");
166 let p = est.period().unwrap().as_nanos() as f64;
167 assert!((p - 10_000.0).abs() / 10_000.0 < 0.05,
168 "period within 5% of 10us, got {p}ns");
169 assert!(est.cv() < 0.05, "CV must be near zero, got {}", est.cv());
170 }
171
172 #[test]
173 fn mild_jitter_still_engages_within_5pct() {
174 let mut est = PhaseEstimator::new(PhaseConfig::default());
175 let base = Instant::now();
176 let mut t = 0u64;
178 let mut offsets = Vec::new();
179 for i in 0..64u64 {
180 let jit = if i % 2 == 0 { 9_500 } else { 10_500 };
181 t += jit;
182 offsets.push(t);
183 }
184 feed(&mut est, base, &offsets);
185 assert!(est.engaged(), "mild jitter must still engage");
186 let p = est.period().unwrap().as_nanos() as f64;
187 assert!((p - 10_000.0).abs() / 10_000.0 < 0.05,
188 "period within 5%, got {p}ns");
189 }
190
191 #[test]
192 fn high_variance_does_not_engage() {
193 let mut est = PhaseEstimator::new(PhaseConfig::default());
194 let base = Instant::now();
195 let mut t = 0u64;
197 let mut offsets = Vec::new();
198 for i in 0..64u64 {
199 t += if i % 2 == 0 { 2_000 } else { 18_000 };
200 offsets.push(t);
201 }
202 feed(&mut est, base, &offsets);
203 assert!(!est.engaged(), "high-variance cadence must not engage");
204 }
205
206 #[test]
207 fn disengages_when_cadence_breaks_down() {
208 let mut est = PhaseEstimator::new(PhaseConfig::default());
209 let base = Instant::now();
210 let clean: Vec<u64> = (0..40).map(|i| i * 10_000).collect();
212 feed(&mut est, base, &clean);
213 assert!(est.engaged());
214 let mut t = 40 * 10_000u64;
217 let mut chaos = Vec::new();
218 for i in 0..40u64 {
219 t += if i % 2 == 0 { 1_000 } else { 30_000 };
220 chaos.push(t);
221 }
222 feed(&mut est, base, &chaos);
223 assert!(!est.engaged(), "broken cadence must disengage");
224 }
225
226 #[test]
227 fn does_not_engage_before_min_samples() {
228 let cfg = PhaseConfig { min_samples: 20, ..PhaseConfig::default() };
229 let mut est = PhaseEstimator::new(cfg);
230 let base = Instant::now();
231 let few: Vec<u64> = (0..10).map(|i| i * 10_000).collect();
232 feed(&mut est, base, &few);
233 assert!(!est.engaged(), "must not engage before min_samples deltas");
234 assert!(est.samples() < 20);
235 }
236
237 #[test]
238 fn predict_next_is_last_plus_period() {
239 let mut est = PhaseEstimator::new(PhaseConfig::default());
240 let base = Instant::now();
241 let offsets: Vec<u64> = (0..40).map(|i| i * 10_000).collect();
242 feed(&mut est, base, &offsets);
243 let last = base + Duration::from_nanos(39 * 10_000);
244 let predicted = est.predict_next().unwrap();
245 let expected = last + est.period().unwrap();
246 let diff = predicted.saturating_duration_since(expected)
247 + expected.saturating_duration_since(predicted);
248 assert!(diff < Duration::from_nanos(100), "prediction = last + period");
249 }
250}