genicam/
time.rs

1//! Helpers for synchronising device tick counters with host wall-clock time.
2
3use std::cmp::Ordering;
4use std::collections::VecDeque;
5use std::time::{Duration, Instant, SystemTime};
6
7use tracing::trace;
8
9/// Maintains a sliding window of timestamp samples and computes a linear model
10/// mapping device ticks to host time.
11#[derive(Debug, Clone)]
12pub struct TimeSync {
13    /// Linear fit slope (seconds per tick).
14    a: f64,
15    /// Linear fit intercept (seconds).
16    b: f64,
17    /// Optional device tick frequency when reported by the camera.
18    freq_hz: Option<f64>,
19    /// Sample window storing device ticks and host instants.
20    window: VecDeque<(u64, Instant)>,
21    /// Maximum number of samples retained in the window.
22    cap: usize,
23    /// Host instant corresponding to the first recorded sample.
24    origin_instant: Option<Instant>,
25    /// Host system time captured alongside the origin instant.
26    origin_system: Option<SystemTime>,
27}
28
29impl TimeSync {
30    /// Create a new synchroniser retaining up to `cap` samples.
31    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    /// Record a new `(device_ticks, host_instant)` sample.
44    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    /// Number of samples retained in the sliding window.
56    pub fn len(&self) -> usize {
57        self.window.len()
58    }
59
60    /// Check if samples window is empty
61    pub fn is_empty(&self) -> bool {
62        self.window.is_empty()
63    }
64
65    /// Iterator over the samples contained in the sliding window.
66    pub fn samples(&self) -> impl Iterator<Item = (u64, Instant)> + '_ {
67        self.window.iter().copied()
68    }
69
70    /// Maximum number of samples stored in the sliding window.
71    pub fn capacity(&self) -> usize {
72        self.cap
73    }
74
75    /// Access the origin instant if at least one sample has been recorded.
76    pub fn origin_instant(&self) -> Option<Instant> {
77        self.origin_instant
78    }
79
80    /// Access the origin system time if available.
81    pub fn origin_system(&self) -> Option<SystemTime> {
82        self.origin_system
83    }
84
85    /// Retrieve the linear fit coefficients.
86    pub fn coefficients(&self) -> (f64, f64) {
87        (self.a, self.b)
88    }
89
90    /// Retrieve the reported device tick frequency.
91    pub fn freq_hz(&self) -> Option<f64> {
92        self.freq_hz
93    }
94
95    /// Return the first and last sample retained in the window.
96    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    /// Fit a linear model mapping device ticks to host seconds relative to the
103    /// first recorded instant. Returns the updated `(a, b)` coefficients when
104    /// enough samples are available.
105    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    /// Convert device ticks into a [`SystemTime`] using the fitted model.
171    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}