Skip to main content

labstream_time/
lib.rs

1//! Timestamp post-processing for LSL. SPEC.md 8.5.
2//!
3//! Three stages run in a fixed order: clock sync, then jitter removal, then
4//! monotonic clamping (`src/time_postprocessor.cpp:60-105`).
5//!
6//! # Float discipline
7//!
8//! The jitter filter is a recursive least squares fit. Every operation here
9//! keeps the order of the C++ source, because a different order gives a
10//! different result in the last bits.
11//!
12//! Three rules hold for every line of the filter:
13//!
14//! 1. Do not reassociate an expression to make it read better.
15//! 2. Do not use `mul_add`, because the source uses a separate multiply and add.
16//! 3. Keep the integer baseline subtraction.
17//!
18//! liblsl builds with plain IEEE semantics, and no fast-math flag appears in
19//! its build configuration. Bit-exact agreement is therefore a target. It is
20//! not a promise across every platform.
21
22#![forbid(unsafe_code)]
23#![deny(missing_docs)]
24
25/// The default half-time of the jitter filter, in seconds.
26///
27/// `src/api_config.cpp:325` reads it as a `float`.
28pub const DEFAULT_SMOOTHING_HALFTIME: f64 = 90.0;
29
30/// How many samples pass between two clock offset queries.
31///
32/// `src/time_postprocessor.cpp:18`.
33pub const SAMPLES_BETWEEN_CLOCKSYNCS: u8 = 50;
34
35/// The shortest time between two clock offset queries, in seconds.
36///
37/// `src/time_postprocessor.cpp:80` adds this to the clock.
38pub const MIN_CLOCKSYNC_INTERVAL: f64 = 0.5;
39
40/// The post-processing stages, as a bit set.
41///
42/// The values match `include/lsl/common.h:103-126`.
43pub mod flags {
44    /// Apply no stage. This is the default.
45    pub const NONE: u32 = 0;
46    /// Add the measured clock offset.
47    pub const CLOCKSYNC: u32 = 1;
48    /// Remove jitter with a recursive least squares fit.
49    pub const DEJITTER: u32 = 2;
50    /// Never let a timestamp go backward.
51    pub const MONOTONIZE: u32 = 4;
52    /// Guard the state with a lock.
53    pub const THREADSAFE: u32 = 8;
54    /// Every stage.
55    pub const ALL: u32 = 1 | 2 | 4 | 8;
56}
57
58/// The recursive least squares filter that removes jitter.
59///
60/// The filter fits `t = w0 + w1 * n` over the sample index `n`. SPEC.md 8.5.
61#[derive(Debug, Clone, PartialEq)]
62pub struct Dejitterer {
63    /// The first timestamp, truncated to a whole number.
64    ///
65    /// liblsl stores this in an unsigned integer
66    /// (`src/time_postprocessor.h:18`). The truncation is part of the
67    /// behavior, because the value comes back at the end of every call.
68    pub t0: u32,
69    /// The number of samples since `t0`.
70    pub samples_since_t0: u32,
71    /// The intercept of the fit.
72    pub w0: f64,
73    /// The slope of the fit.
74    pub w1: f64,
75    /// The inverse covariance element P00.
76    pub p00: f64,
77    /// The inverse covariance element P11.
78    pub p11: f64,
79    /// The off-diagonal inverse covariance element.
80    pub p01: f64,
81    /// The forgetting factor.
82    pub lam: f64,
83}
84
85impl Default for Dejitterer {
86    /// The state before the first sample.
87    ///
88    /// `src/time_postprocessor.h:20-26` gives every initial value.
89    fn default() -> Self {
90        Dejitterer {
91            t0: 0,
92            samples_since_t0: 0,
93            w0: 0.0,
94            w1: 0.0,
95            p00: 1e10,
96            p11: 1e10,
97            p01: 0.0,
98            lam: 0.0,
99        }
100    }
101}
102
103impl Dejitterer {
104    /// Build a filter for a stream.
105    ///
106    /// `src/time_postprocessor.cpp:104-110`. A rate of zero or less leaves the
107    /// forgetting factor at zero, and the filter then returns every timestamp
108    /// unchanged.
109    pub fn new(t0: f64, srate: f64, halftime: f64) -> Self {
110        let mut d = Dejitterer {
111            t0: t0 as u32,
112            ..Default::default()
113        };
114        if srate > 0.0 {
115            d.w1 = 1. / srate;
116            d.lam = 2f64.powf(-1. / (srate * halftime));
117        }
118        d
119    }
120
121    /// True once a first timestamp has set the baseline.
122    ///
123    /// liblsl tests the baseline against zero
124    /// (`src/time_postprocessor.h:35`). A first timestamp below 1.0 therefore
125    /// truncates to zero, and the filter reads as uninitialized.
126    pub fn is_initialized(&self) -> bool {
127        self.t0 != 0
128    }
129
130    /// True when the filter changes a timestamp.
131    pub fn smoothing_applicable(&self) -> bool {
132        self.lam > 0.0
133    }
134
135    /// Take one timestamp and return the fitted value.
136    ///
137    /// The operation order matches `src/time_postprocessor.cpp:112-131` line
138    /// for line. Do not tidy this function.
139    pub fn dejitter(&mut self, t: f64) -> f64 {
140        if !self.smoothing_applicable() {
141            return t;
142        }
143
144        // Remove the baseline for numerical accuracy.
145        let t = t - self.t0 as f64;
146
147        let u1 = self.samples_since_t0 as f64;
148        self.samples_since_t0 = self.samples_since_t0.wrapping_add(1);
149
150        let pi0 = self.p00 + u1 * self.p01;
151        let pi1 = self.p01 + u1 * self.p11;
152        let al = t - (self.w0 + u1 * self.w1);
153        let g_inv = 1. / (self.lam + pi0 + pi1 * u1);
154        let il_ = 1. / self.lam;
155
156        self.p00 = il_ * (self.p00 - pi0 * pi0 * g_inv);
157        self.p01 = il_ * (self.p01 - pi0 * pi1 * g_inv);
158        self.p11 = il_ * (self.p11 - pi1 * pi1 * g_inv);
159        self.w0 += al * (self.p00 + self.p01 * u1);
160        self.w1 += al * (self.p01 + self.p11 * u1);
161
162        self.w0 + u1 * self.w1 + self.t0 as f64
163    }
164
165    /// Move the sample counter forward for samples that never arrived.
166    ///
167    /// `src/time_postprocessor.cpp:133-135`.
168    pub fn skip_samples(&mut self, skipped: u32) {
169        self.samples_since_t0 = self.samples_since_t0.wrapping_add(skipped);
170    }
171}
172
173/// Reads the clock offset that the time sync channel measured.
174///
175/// The post-processor asks for a value instead of reading a clock, so a test
176/// drives it with fixed numbers.
177pub trait OffsetSource {
178    /// The current offset between the two clocks.
179    fn correction(&mut self) -> f64;
180    /// The nominal rate of the stream.
181    fn srate(&mut self) -> f64;
182    /// True when the connection reset since the last call.
183    fn was_reset(&mut self) -> bool;
184    /// The local clock, in seconds.
185    fn clock(&mut self) -> f64;
186}
187
188/// Applies the three stages to every timestamp. SPEC.md 8.5.
189pub struct PostProcessor {
190    options: u32,
191    halftime: f64,
192    dejitter: Dejitterer,
193    samples_since_last_clocksync: u8,
194    next_query_time: f64,
195    last_offset: f64,
196    last_value: f64,
197}
198
199impl PostProcessor {
200    /// Build a post-processor with no stage enabled.
201    ///
202    /// `src/time_postprocessor.cpp:21-27` starts the last value at the lowest
203    /// possible number, so the first sample always passes the clamp.
204    pub fn new() -> Self {
205        PostProcessor {
206            options: flags::NONE,
207            halftime: DEFAULT_SMOOTHING_HALFTIME,
208            dejitter: Dejitterer::default(),
209            samples_since_last_clocksync: SAMPLES_BETWEEN_CLOCKSYNCS,
210            next_query_time: 0.0,
211            last_offset: 0.0,
212            last_value: f64::MIN,
213        }
214    }
215
216    /// Set the half-time of the jitter filter.
217    pub fn set_halftime(&mut self, halftime: f64) {
218        self.halftime = halftime;
219    }
220
221    /// The current options.
222    pub fn options(&self) -> u32 {
223        self.options
224    }
225
226    /// The state of the jitter filter.
227    pub fn dejitterer(&self) -> &Dejitterer {
228        &self.dejitter
229    }
230
231    /// Choose the stages.
232    ///
233    /// A change to a stage clears the state of that stage
234    /// (`src/time_postprocessor.cpp:45-58`).
235    pub fn set_options(&mut self, options: u32) {
236        let changed = self.options ^ options;
237        if changed & flags::DEJITTER != 0 {
238            self.dejitter = Dejitterer::default();
239        }
240        if changed & flags::MONOTONIZE != 0 {
241            self.last_value = f64::MIN;
242        }
243        self.options = options;
244    }
245
246    /// Take one timestamp and return the processed value.
247    ///
248    /// The order is clock sync, then jitter removal, then the clamp
249    /// (`src/time_postprocessor.cpp:60-105`).
250    pub fn process(&mut self, value: f64, src: &mut impl OffsetSource) -> f64 {
251        let mut value = value;
252
253        if self.options & flags::CLOCKSYNC != 0 {
254            // The offset refreshes every 50 samples, and never more than twice
255            // per second.
256            self.samples_since_last_clocksync = self.samples_since_last_clocksync.saturating_add(1);
257            if self.samples_since_last_clocksync > SAMPLES_BETWEEN_CLOCKSYNCS
258                && src.clock() > self.next_query_time
259            {
260                self.last_offset = src.correction();
261                self.samples_since_last_clocksync = 0;
262                if src.was_reset() {
263                    self.last_offset = src.correction();
264                    self.last_value = f64::MIN;
265                    self.dejitter = Dejitterer::default();
266                }
267                self.next_query_time = src.clock() + MIN_CLOCKSYNC_INTERVAL;
268            }
269            value += self.last_offset;
270        }
271
272        if self.options & flags::DEJITTER != 0 {
273            if !self.dejitter.is_initialized() {
274                let srate = src.srate();
275                self.dejitter = Dejitterer::new(value, srate, self.halftime);
276            }
277            value = self.dejitter.dejitter(value);
278        }
279
280        if self.options & flags::MONOTONIZE != 0 {
281            if value < self.last_value {
282                value = self.last_value;
283            } else {
284                self.last_value = value;
285            }
286        }
287
288        value
289    }
290
291    /// Tell the filter that samples were skipped.
292    pub fn skip_samples(&mut self, skipped: u32) {
293        if self.options & flags::DEJITTER != 0 && self.dejitter.smoothing_applicable() {
294            self.dejitter.skip_samples(skipped);
295        }
296    }
297}
298
299impl Default for PostProcessor {
300    fn default() -> Self {
301        Self::new()
302    }
303}
304
305/// A source with values that a test sets by hand.
306#[derive(Debug, Clone)]
307pub struct FixedSource {
308    /// The offset to return.
309    pub offset: f64,
310    /// The rate to return.
311    pub srate: f64,
312    /// The reset flag to return.
313    pub reset: bool,
314    /// The clock to return. A test moves it forward by hand.
315    pub now: f64,
316}
317
318impl OffsetSource for FixedSource {
319    fn correction(&mut self) -> f64 {
320        self.offset
321    }
322    fn srate(&mut self) -> f64 {
323        self.srate
324    }
325    fn was_reset(&mut self) -> bool {
326        self.reset
327    }
328    fn clock(&mut self) -> f64 {
329        self.now
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336
337    fn src(srate: f64) -> FixedSource {
338        FixedSource {
339            offset: 0.0,
340            srate,
341            reset: false,
342            now: 1e9,
343        }
344    }
345
346    #[test]
347    fn no_stage_changes_a_timestamp() {
348        let mut p = PostProcessor::new();
349        let mut s = src(1.0);
350        for t in [2.0, 3.1, 3.0, 5.0, 5.9, 7.1] {
351            assert_eq!(p.process(t, &mut s), t);
352        }
353    }
354
355    #[test]
356    fn clock_sync_adds_the_offset() {
357        // The upstream test uses these values (`testing/int/postproc.cpp`).
358        let mut p = PostProcessor::new();
359        p.set_options(flags::CLOCKSYNC);
360        let mut s = src(1.0);
361        s.offset = -50.0;
362        for t in [2.0, 3.1, 3.0, 5.0, 5.9, 7.1] {
363            assert!((p.process(t, &mut s) - (t - 50.0)).abs() < 1e-12);
364        }
365    }
366
367    #[test]
368    fn the_clamp_never_lets_a_timestamp_go_backward() {
369        // The upstream test expects this exact list.
370        let mut p = PostProcessor::new();
371        p.set_options(flags::MONOTONIZE);
372        let mut s = src(1.0);
373        let input = [2.0, 3.1, 3.0, 5.0, 5.9, 7.1];
374        let want = [2.0, 3.1, 3.1, 5.0, 5.9, 7.1];
375        for (i, t) in input.iter().enumerate() {
376            assert!(
377                (p.process(*t, &mut s) - want[i]).abs() < 1e-12,
378                "sample {i}"
379            );
380        }
381    }
382
383    #[test]
384    fn a_stage_change_clears_that_state() {
385        let mut p = PostProcessor::new();
386        p.set_options(flags::MONOTONIZE);
387        let mut s = src(1.0);
388        p.process(100.0, &mut s);
389        // Turning the clamp off and on again clears the running maximum.
390        p.set_options(flags::NONE);
391        p.set_options(flags::MONOTONIZE);
392        assert_eq!(p.process(2.0, &mut s), 2.0);
393    }
394
395    #[test]
396    fn a_rate_of_zero_leaves_the_filter_off() {
397        let d = Dejitterer::new(1000.0, 0.0, 90.0);
398        assert!(!d.smoothing_applicable());
399        let mut d = d;
400        assert_eq!(d.dejitter(1234.5), 1234.5);
401    }
402
403    #[test]
404    fn the_baseline_truncates_to_a_whole_number() {
405        // `src/time_postprocessor.h:18` holds an unsigned integer.
406        let d = Dejitterer::new(1000.75, 100.0, 90.0);
407        assert_eq!(d.t0, 1000);
408        assert!(d.is_initialized());
409    }
410
411    #[test]
412    fn a_first_timestamp_below_one_reads_as_uninitialized() {
413        // A real trap. The baseline truncates to zero, and the test for an
414        // initialized filter compares against zero.
415        let d = Dejitterer::new(0.5, 100.0, 90.0);
416        assert_eq!(d.t0, 0);
417        assert!(!d.is_initialized());
418    }
419
420    #[test]
421    fn the_filter_converges_on_a_clean_ramp() {
422        let srate = 100.0;
423        let t0 = 5000.0;
424        let mut d = Dejitterer::new(t0, srate, 90.0);
425        d.dejitter(t0);
426        for i in 0..2000 {
427            let t = t0 + i as f64 / srate;
428            d.dejitter(t);
429        }
430        // The upstream test asserts the same two bounds.
431        assert!((d.w1 - 1.0 / srate).abs() < 1e-6, "slope {}", d.w1);
432        assert!(d.w0.abs() < 0.1, "intercept {}", d.w0);
433    }
434
435    #[test]
436    fn the_filter_removes_a_constant_latency_from_the_slope() {
437        // A constant offset moves the intercept and leaves the slope alone.
438        let srate = 100.0;
439        let t0 = 5000.0;
440        let latency = 0.05;
441        let mut d = Dejitterer::new(t0, srate, 90.0);
442        d.dejitter(t0);
443        for i in 0..5000 {
444            d.dejitter(t0 + i as f64 / srate + latency);
445        }
446        assert!((d.w1 - 1.0 / srate).abs() < 1e-6);
447        assert!((d.w0 - latency).abs() < 0.1, "intercept {}", d.w0);
448    }
449
450    #[test]
451    fn skipped_samples_move_the_counter() {
452        let mut d = Dejitterer::new(1000.0, 100.0, 90.0);
453        d.dejitter(1000.0);
454        let before = d.samples_since_t0;
455        d.skip_samples(7);
456        assert_eq!(d.samples_since_t0, before + 7);
457    }
458
459    #[test]
460    fn clock_sync_refreshes_at_most_twice_per_second() {
461        let mut p = PostProcessor::new();
462        p.set_options(flags::CLOCKSYNC);
463        let mut s = src(1.0);
464        s.offset = 1.0;
465        s.now = 100.0;
466        // The first sample queries, because the counter starts at the limit.
467        p.process(0.0, &mut s);
468        s.offset = 99.0;
469        // The next 50 samples must not query again.
470        for _ in 0..50 {
471            let got = p.process(0.0, &mut s);
472            assert!((got - 1.0).abs() < 1e-12, "the offset changed too early");
473        }
474        // The counter is ready, but the clock has not moved.
475        assert!((p.process(0.0, &mut s) - 1.0).abs() < 1e-12);
476        // Move the clock past the interval.
477        s.now = 101.0;
478        assert!((p.process(0.0, &mut s) - 99.0).abs() < 1e-12);
479    }
480
481    #[test]
482    fn a_reset_gives_the_filter_a_new_baseline() {
483        // A reset means the source restarted, so the old fit describes a
484        // process that no longer exists. The filter takes a new baseline from
485        // the first timestamp after the reset.
486        let mut p = PostProcessor::new();
487        p.set_options(flags::CLOCKSYNC | flags::DEJITTER);
488        let mut s = src(100.0);
489        s.now = 100.0;
490
491        p.process(5000.0, &mut s);
492        assert_eq!(p.dejitterer().t0, 5000);
493
494        // Move the counter past the limit while the clock stays put, so no
495        // query fires yet.
496        for i in 1..60 {
497            p.process(5000.0 + i as f64 / 100.0, &mut s);
498        }
499        assert_eq!(p.dejitterer().t0, 5000, "no query fired yet");
500
501        // Now the clock passes the interval and the source reports a reset.
502        s.reset = true;
503        s.now = 101.0;
504        p.process(9000.0, &mut s);
505        assert_eq!(p.dejitterer().t0, 9000, "the reset gives a new baseline");
506    }
507
508    #[test]
509    fn the_clamp_state_clears_on_a_reset() {
510        // The clearing is visible only when the resetting sample itself
511        // carries a lower value. Any later sample sets the maximum again.
512        //
513        // The clock must stay put while the counter fills, so that the query
514        // fires on the call that carries the low value and on no earlier one.
515        let mut p = PostProcessor::new();
516        p.set_options(flags::CLOCKSYNC | flags::MONOTONIZE);
517        let mut s = src(100.0);
518        s.now = 100.0;
519
520        p.process(500.0, &mut s);
521        for _ in 0..55 {
522            p.process(500.0, &mut s);
523        }
524        // The counter is ready and the clock has not moved, so no query fired.
525        // A lower value is still clamped.
526        assert!((p.process(10.0, &mut s) - 500.0).abs() < 1e-12);
527
528        // Move the clock past the interval and report a reset. The query now
529        // fires on this call, and the clamp state clears before the clamp runs.
530        s.reset = true;
531        s.now = 101.0;
532        assert!((p.process(10.0, &mut s) - 10.0).abs() < 1e-12);
533    }
534}