1use std::cmp::Ordering;
4use std::collections::VecDeque;
5use std::time::{Duration, Instant, SystemTime};
6
7use tracing::trace;
8
9#[derive(Debug, Clone)]
12pub struct TimeSync {
13 a: f64,
15 b: f64,
17 freq_hz: Option<f64>,
19 window: VecDeque<(u64, Instant)>,
21 cap: usize,
23 origin_instant: Option<Instant>,
25 origin_system: Option<SystemTime>,
27}
28
29impl TimeSync {
30 pub fn new(cap: usize) -> Self {
32 Self {
33 a: 0.0,
34 b: 0.0,
35 freq_hz: None,
36 window: VecDeque::with_capacity(cap),
37 cap,
38 origin_instant: None,
39 origin_system: None,
40 }
41 }
42
43 pub fn update(&mut self, dev_ticks: u64, host: Instant) {
45 if self.origin_instant.is_none() {
46 self.origin_instant = Some(host);
47 self.origin_system = Some(SystemTime::now());
48 }
49 if self.window.len() == self.cap {
50 self.window.pop_front();
51 }
52 self.window.push_back((dev_ticks, host));
53 }
54
55 pub fn len(&self) -> usize {
57 self.window.len()
58 }
59
60 pub fn is_empty(&self) -> bool {
62 self.window.is_empty()
63 }
64
65 pub fn samples(&self) -> impl Iterator<Item = (u64, Instant)> + '_ {
67 self.window.iter().copied()
68 }
69
70 pub fn capacity(&self) -> usize {
72 self.cap
73 }
74
75 pub fn origin_instant(&self) -> Option<Instant> {
77 self.origin_instant
78 }
79
80 pub fn origin_system(&self) -> Option<SystemTime> {
82 self.origin_system
83 }
84
85 pub fn coefficients(&self) -> (f64, f64) {
87 (self.a, self.b)
88 }
89
90 pub fn freq_hz(&self) -> Option<f64> {
92 self.freq_hz
93 }
94
95 pub fn sample_bounds(&self) -> Option<((u64, Instant), (u64, Instant))> {
97 let first = *self.window.front()?;
98 let last = *self.window.back()?;
99 Some((first, last))
100 }
101
102 pub fn fit(&mut self, freq_hz: Option<f64>) -> Option<(f64, f64)> {
106 if self.window.len() < 2 {
107 return None;
108 }
109 if let Some(freq) = freq_hz {
110 self.freq_hz = Some(freq);
111 }
112 let origin = self.origin_instant?;
113 let base_tick = self.window.front()?.0 as f64;
114 let samples: Vec<(f64, f64)> = self
115 .window
116 .iter()
117 .map(|(ticks, host)| {
118 let x = (*ticks as f64) - base_tick;
119 let y = host.duration_since(origin).as_secs_f64();
120 (x, y)
121 })
122 .collect();
123
124 let (mut slope, mut intercept_rel) = compute_fit(&samples)?;
125 if samples.len() >= 10 {
126 let mut residuals: Vec<(usize, f64)> = samples
127 .iter()
128 .enumerate()
129 .map(|(idx, (x, y))| {
130 let predicted = slope * *x + intercept_rel;
131 (idx, y - predicted)
132 })
133 .collect();
134 residuals.sort_by(|a, b| match a.1.partial_cmp(&b.1) {
135 Some(order) => order,
136 None => Ordering::Equal,
137 });
138 let trim = ((residuals.len() as f64) * 0.1).floor() as usize;
139 if trim > 0 && residuals.len() > trim * 2 {
140 let trimmed_samples: Vec<(f64, f64)> = residuals[trim..residuals.len() - trim]
141 .iter()
142 .map(|(idx, _)| samples[*idx])
143 .collect();
144 if let Some((s, i)) = compute_fit(&trimmed_samples) {
145 slope = s;
146 intercept_rel = i;
147 }
148 }
149 }
150
151 let intercept = intercept_rel - slope * base_tick;
152 self.a = slope;
153 self.b = intercept;
154
155 for (ticks, host) in &self.window {
156 let predicted = self.a * (*ticks as f64) + self.b;
157 let actual = host.duration_since(origin).as_secs_f64();
158 trace!(
159 ticks = *ticks,
160 predicted_s = predicted,
161 actual_s = actual,
162 residual_s = actual - predicted,
163 "timestamp fit residual"
164 );
165 }
166
167 Some((self.a, self.b))
168 }
169
170 pub fn to_host_time(&self, dev_ticks: u64) -> SystemTime {
172 let Some(origin) = self.origin_system else {
173 return SystemTime::now();
174 };
175 let secs = self.a * (dev_ticks as f64) + self.b;
176 if !secs.is_finite() || secs <= 0.0 {
177 return origin;
178 }
179 match Duration::try_from_secs_f64(secs) {
180 Ok(duration) => origin + duration,
181 Err(_) => origin,
182 }
183 }
184}
185
186fn compute_fit(samples: &[(f64, f64)]) -> Option<(f64, f64)> {
187 if samples.len() < 2 {
188 return None;
189 }
190 let mut sum_x = 0.0;
191 let mut sum_y = 0.0;
192 for (x, y) in samples {
193 sum_x += x;
194 sum_y += y;
195 }
196 let n = samples.len() as f64;
197 let mean_x = sum_x / n;
198 let mean_y = sum_y / n;
199 let mut denom = 0.0;
200 let mut numer = 0.0;
201 for (x, y) in samples {
202 let dx = x - mean_x;
203 let dy = y - mean_y;
204 denom += dx * dx;
205 numer += dx * dy;
206 }
207 if denom.abs() < f64::EPSILON {
208 return None;
209 }
210 let slope = numer / denom;
211 let intercept = mean_y - slope * mean_x;
212 Some((slope, intercept))
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218
219 #[test]
220 fn synthetic_fit_handles_jitter() {
221 let mut sync = TimeSync::new(64);
222 let freq_hz = 150_000_000.0;
223 let start = Instant::now();
224 for i in 0..64u64 {
225 let ticks = i * 150_000;
226 let ideal = start + Duration::from_secs_f64((i as f64) * 0.001);
227 let jitter = (fastrand::f64() - 0.5) * 400e-6;
228 let jitter_duration = Duration::from_secs_f64(jitter.abs());
229 let host = if jitter >= 0.0 {
230 ideal + jitter_duration
231 } else {
232 ideal.checked_sub(jitter_duration).unwrap_or(ideal)
233 };
234 sync.update(ticks, host);
235 }
236 sync.fit(Some(freq_hz));
237 let (a, b) = sync.coefficients();
238 let origin = sync.origin_instant().unwrap();
239 let max_error = sync
240 .samples()
241 .map(|(ticks, host)| {
242 let predicted = a * (ticks as f64) + b;
243 let actual = host.duration_since(origin).as_secs_f64();
244 (predicted - actual).abs()
245 })
246 .fold(0.0, f64::max);
247 assert!(max_error < 5e-4, "max error {max_error} exceeds tolerance");
248 }
249
250 #[test]
251 fn compute_fit_returns_none_for_single_sample() {
252 let mut sync = TimeSync::new(4);
253 sync.update(100, Instant::now());
254 assert!(sync.fit(None).is_none());
255 }
256}