Skip to main content

oaat_core/
clock.rs

1/// EMA-filtered clock synchronization state.
2///
3/// Uses a PTP-inspired 4-timestamp exchange:
4///   t1: endpoint sends request
5///   t2: controller receives request
6///   t3: controller sends response
7///   t4: endpoint receives response
8///
9/// offset = ((t2 - t1) + (t3 - t4)) / 2
10/// rtt = (t4 - t1) - (t3 - t2)
11pub struct ClockState {
12    alpha: f64,
13    offset_ns: f64,
14    rtt_ns: f64,
15    samples: u32,
16    bootstrap_count: u32,
17}
18
19impl ClockState {
20    pub fn new() -> Self {
21        Self {
22            alpha: 0.125,
23            offset_ns: 0.0,
24            rtt_ns: 0.0,
25            samples: 0,
26            bootstrap_count: 10,
27        }
28    }
29
30    pub fn update(&mut self, t1: u64, t2: u64, t3: u64, t4: u64) {
31        let offset = ((t2 as i128 - t1 as i128) + (t3 as i128 - t4 as i128)) as f64 / 2.0;
32        let rtt = ((t4 - t1) - (t3 - t2)) as f64;
33
34        if self.samples == 0 {
35            self.offset_ns = offset;
36            self.rtt_ns = rtt;
37        } else {
38            let alpha = if self.samples < self.bootstrap_count {
39                0.5
40            } else {
41                self.alpha
42            };
43            self.offset_ns = self.offset_ns * (1.0 - alpha) + offset * alpha;
44            self.rtt_ns = self.rtt_ns * (1.0 - alpha) + rtt * alpha;
45        }
46        self.samples += 1;
47    }
48
49    pub fn offset_ns(&self) -> i64 {
50        self.offset_ns.round() as i64
51    }
52
53    pub fn rtt_ns(&self) -> u64 {
54        self.rtt_ns.round() as u64
55    }
56
57    pub fn is_bootstrapped(&self) -> bool {
58        self.samples >= self.bootstrap_count
59    }
60
61    pub fn samples(&self) -> u32 {
62        self.samples
63    }
64
65    /// Convert a local timestamp to controller clock domain.
66    pub fn local_to_controller(&self, local_ns: u64) -> u64 {
67        (local_ns as i64 + self.offset_ns()) as u64
68    }
69
70    /// Convert a controller timestamp to local clock domain.
71    pub fn controller_to_local(&self, controller_ns: u64) -> u64 {
72        (controller_ns as i64 - self.offset_ns()) as u64
73    }
74}
75
76impl Default for ClockState {
77    fn default() -> Self {
78        Self::new()
79    }
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85
86    #[test]
87    fn zero_offset_symmetric() {
88        let mut clock = ClockState::new();
89        // Symmetric trip: 10us each way, no offset
90        clock.update(1000, 1010, 1015, 1025);
91        assert_eq!(clock.offset_ns(), 0);
92        assert_eq!(clock.rtt_ns(), 20);
93    }
94
95    #[test]
96    fn positive_offset() {
97        let mut clock = ClockState::new();
98        // Controller clock is 100ns ahead
99        clock.update(1000, 1110, 1115, 1025);
100        assert_eq!(clock.offset_ns(), 100);
101    }
102
103    #[test]
104    fn ema_convergence() {
105        let mut clock = ClockState::new();
106        for _ in 0..100 {
107            // Consistent 50ns offset, 20ns RTT
108            clock.update(1000, 1060, 1065, 1025);
109        }
110        let offset = clock.offset_ns();
111        assert!(
112            (offset - 50).abs() < 2,
113            "offset should converge to ~50, got {offset}"
114        );
115    }
116
117    #[test]
118    fn bootstrap_phase() {
119        let clock = ClockState::new();
120        assert!(!clock.is_bootstrapped());
121    }
122}