Skip to main content

oximedia_align/
drift_correct.rs

1//! Clock drift correction using PLL-based techniques.
2//!
3//! Provides phase-locked loop (PLL) based clock drift correction,
4//! drift rate estimation, and sample-level adjustment for A/V synchronization.
5
6#![allow(dead_code)]
7#![allow(clippy::cast_precision_loss)]
8#![allow(clippy::too_many_arguments)]
9
10/// PLL (Phase-Locked Loop) state for drift correction
11#[derive(Debug, Clone)]
12pub struct PllState {
13    /// Current phase error (in samples)
14    pub phase_error: f64,
15    /// Current frequency error (fractional)
16    pub freq_error: f64,
17    /// Loop filter state
18    pub filter_state: f64,
19    /// Proportional gain
20    pub kp: f64,
21    /// Integral gain
22    pub ki: f64,
23    /// Current correction (samples per input sample)
24    pub correction: f64,
25}
26
27impl PllState {
28    /// Create a new PLL state with given gains
29    #[must_use]
30    pub fn new(kp: f64, ki: f64) -> Self {
31        Self {
32            phase_error: 0.0,
33            freq_error: 0.0,
34            filter_state: 0.0,
35            kp,
36            ki,
37            correction: 0.0,
38        }
39    }
40
41    /// Update PLL with a new phase measurement and return correction
42    pub fn update(&mut self, measured_phase: f64, expected_phase: f64) -> f64 {
43        self.phase_error = measured_phase - expected_phase;
44        self.filter_state += self.ki * self.phase_error;
45        self.correction = self.kp * self.phase_error + self.filter_state;
46        self.freq_error = self.correction;
47        self.correction
48    }
49
50    /// Reset PLL state
51    pub fn reset(&mut self) {
52        self.phase_error = 0.0;
53        self.freq_error = 0.0;
54        self.filter_state = 0.0;
55        self.correction = 0.0;
56    }
57
58    /// Returns true if PLL is locked (error below threshold)
59    #[must_use]
60    pub fn is_locked(&self, threshold: f64) -> bool {
61        self.phase_error.abs() < threshold && self.freq_error.abs() < threshold * 0.01
62    }
63}
64
65impl Default for PllState {
66    fn default() -> Self {
67        Self::new(0.1, 0.001)
68    }
69}
70
71/// Drift rate estimator using linear regression over a window of samples
72#[derive(Debug, Clone)]
73pub struct DriftRateEstimator {
74    /// Window of (time, offset) measurements
75    measurements: Vec<(f64, f64)>,
76    /// Maximum number of measurements to keep
77    max_window: usize,
78    /// Current estimated drift rate (parts per million)
79    estimated_ppm: f64,
80}
81
82impl DriftRateEstimator {
83    /// Create a new drift rate estimator
84    #[must_use]
85    pub fn new(max_window: usize) -> Self {
86        Self {
87            measurements: Vec::new(),
88            max_window,
89            estimated_ppm: 0.0,
90        }
91    }
92
93    /// Add a new measurement (time in seconds, offset in samples)
94    pub fn add_measurement(&mut self, time: f64, offset: f64) {
95        self.measurements.push((time, offset));
96        if self.measurements.len() > self.max_window {
97            self.measurements.remove(0);
98        }
99        if self.measurements.len() >= 2 {
100            self.estimated_ppm = self.compute_drift_ppm();
101        }
102    }
103
104    /// Compute drift rate in PPM using linear regression
105    fn compute_drift_ppm(&self) -> f64 {
106        let n = self.measurements.len() as f64;
107        let sum_t: f64 = self.measurements.iter().map(|(t, _)| t).sum();
108        let sum_o: f64 = self.measurements.iter().map(|(_, o)| o).sum();
109        let sum_t2: f64 = self.measurements.iter().map(|(t, _)| t * t).sum();
110        let sum_to: f64 = self.measurements.iter().map(|(t, o)| t * o).sum();
111
112        let denom = n * sum_t2 - sum_t * sum_t;
113        if denom.abs() < f64::EPSILON {
114            return 0.0;
115        }
116        let slope = (n * sum_to - sum_t * sum_o) / denom;
117        // Convert slope (samples/second) to PPM assuming 48000 Hz
118        slope / 48.0 // slope / (sample_rate / 1_000_000)
119    }
120
121    /// Get current drift rate estimate in PPM
122    #[must_use]
123    pub fn drift_ppm(&self) -> f64 {
124        self.estimated_ppm
125    }
126
127    /// Get drift rate in samples per second (at given sample rate)
128    #[must_use]
129    pub fn drift_samples_per_second(&self, sample_rate: u32) -> f64 {
130        self.estimated_ppm * f64::from(sample_rate) / 1_000_000.0
131    }
132
133    /// Clear all measurements
134    pub fn clear(&mut self) {
135        self.measurements.clear();
136        self.estimated_ppm = 0.0;
137    }
138
139    /// Number of measurements held
140    #[must_use]
141    pub fn len(&self) -> usize {
142        self.measurements.len()
143    }
144
145    /// Returns true if no measurements
146    #[must_use]
147    pub fn is_empty(&self) -> bool {
148        self.measurements.is_empty()
149    }
150}
151
152/// Sample adjustment result after drift correction
153#[derive(Debug, Clone, Copy)]
154pub struct SampleAdjustment {
155    /// Number of samples to insert (positive) or drop (negative)
156    pub delta: i64,
157    /// Fractional part of the adjustment
158    pub fractional: f64,
159    /// Cumulative drift in samples
160    pub cumulative_drift: f64,
161}
162
163impl SampleAdjustment {
164    /// Create a new sample adjustment
165    #[must_use]
166    pub fn new(delta: i64, fractional: f64, cumulative_drift: f64) -> Self {
167        Self {
168            delta,
169            fractional,
170            cumulative_drift,
171        }
172    }
173
174    /// Returns true if any adjustment is needed
175    #[must_use]
176    pub fn needs_adjustment(&self) -> bool {
177        self.delta != 0
178    }
179}
180
181/// Drift corrector combining PLL and sample adjustment
182#[derive(Debug, Clone)]
183pub struct DriftCorrector {
184    /// PLL state
185    pll: PllState,
186    /// Drift rate estimator
187    estimator: DriftRateEstimator,
188    /// Accumulated fractional sample error
189    accumulator: f64,
190    /// Total samples processed
191    total_samples: u64,
192    /// Sample rate
193    sample_rate: u32,
194}
195
196impl DriftCorrector {
197    /// Create a new drift corrector
198    #[must_use]
199    pub fn new(sample_rate: u32) -> Self {
200        Self {
201            pll: PllState::new(0.05, 0.0005),
202            estimator: DriftRateEstimator::new(100),
203            accumulator: 0.0,
204            total_samples: 0,
205            sample_rate,
206        }
207    }
208
209    /// Process a block of samples and return the required adjustment
210    pub fn process_block(&mut self, block_size: usize, measured_offset: f64) -> SampleAdjustment {
211        let time = self.total_samples as f64 / f64::from(self.sample_rate);
212        self.estimator.add_measurement(time, measured_offset);
213
214        let drift_rate = self.estimator.drift_samples_per_second(self.sample_rate);
215        let block_drift = drift_rate * block_size as f64 / f64::from(self.sample_rate);
216
217        self.accumulator += block_drift;
218        let delta = self.accumulator.trunc() as i64;
219        self.accumulator -= delta as f64;
220
221        self.total_samples += block_size as u64;
222
223        SampleAdjustment::new(delta, self.accumulator, measured_offset + block_drift)
224    }
225
226    /// Update PLL with external reference
227    pub fn update_pll(&mut self, measured: f64, expected: f64) -> f64 {
228        self.pll.update(measured, expected)
229    }
230
231    /// Reset all state
232    pub fn reset(&mut self) {
233        self.pll.reset();
234        self.estimator.clear();
235        self.accumulator = 0.0;
236        self.total_samples = 0;
237    }
238
239    /// Get current drift estimate in PPM
240    #[must_use]
241    pub fn drift_ppm(&self) -> f64 {
242        self.estimator.drift_ppm()
243    }
244
245    /// Check if PLL is locked
246    #[must_use]
247    pub fn is_locked(&self) -> bool {
248        self.pll.is_locked(1.0)
249    }
250}
251
252/// Genlock drift estimator for multi-camera recording synchronisation.
253///
254/// In multi-camera setups, cameras nominally run at the same frame rate but
255/// their internal clocks drift relative to each other.  Genlock drift
256/// estimation measures this drift over long durations (hours) by periodically
257/// comparing synchronisation markers (e.g. audio cross-correlation offsets,
258/// timecode differences, or flash events).
259///
260/// The estimator fits a linear model to the observed offset-vs-time data,
261/// yielding a drift rate in PPM (parts per million) plus a constant offset.
262/// It also provides an uncertainty estimate based on the residuals of the fit.
263///
264/// # Usage
265///
266/// 1. Create with `GenlockDriftEstimator::new(camera_count)`.
267/// 2. Feed periodic offset measurements via `add_observation()`.
268/// 3. Query the drift model with `drift_ppm()`, `predicted_offset()`, etc.
269#[derive(Debug, Clone)]
270pub struct GenlockDriftEstimator {
271    /// Number of cameras in the multi-camera rig.
272    camera_count: usize,
273    /// Per-camera-pair observations: (time_seconds, offset_samples).
274    observations: Vec<Vec<(f64, f64)>>,
275    /// Maximum observations to keep per pair.
276    max_observations: usize,
277}
278
279/// Result of a genlock drift analysis for a single camera pair.
280#[derive(Debug, Clone)]
281pub struct GenlockDriftResult {
282    /// Camera index A.
283    pub camera_a: usize,
284    /// Camera index B.
285    pub camera_b: usize,
286    /// Estimated drift rate in parts per million.
287    pub drift_ppm: f64,
288    /// Constant offset at t=0 (in samples).
289    pub offset_at_zero: f64,
290    /// R-squared goodness of fit (1.0 = perfect linear drift).
291    pub r_squared: f64,
292    /// Root mean square residual (in samples).
293    pub rms_residual: f64,
294    /// Number of observations used.
295    pub num_observations: usize,
296}
297
298impl GenlockDriftEstimator {
299    /// Create a new genlock drift estimator for `camera_count` cameras.
300    ///
301    /// This creates storage for all `C(camera_count, 2)` unique pairs.
302    #[must_use]
303    pub fn new(camera_count: usize, max_observations: usize) -> Self {
304        let num_pairs = if camera_count >= 2 {
305            camera_count * (camera_count - 1) / 2
306        } else {
307            0
308        };
309        Self {
310            camera_count,
311            observations: vec![Vec::new(); num_pairs],
312            max_observations,
313        }
314    }
315
316    /// Add an observation of the offset between camera `cam_a` and `cam_b`
317    /// at time `time_seconds`. The offset is in samples.
318    ///
319    /// Returns `false` if the camera indices are invalid or equal.
320    pub fn add_observation(
321        &mut self,
322        cam_a: usize,
323        cam_b: usize,
324        time_seconds: f64,
325        offset_samples: f64,
326    ) -> bool {
327        if let Some(pair_idx) = self.pair_index(cam_a, cam_b) {
328            let obs = &mut self.observations[pair_idx];
329            obs.push((time_seconds, offset_samples));
330            if obs.len() > self.max_observations {
331                obs.remove(0);
332            }
333            true
334        } else {
335            false
336        }
337    }
338
339    /// Compute the drift estimate for a specific camera pair.
340    ///
341    /// Returns `None` if the pair is invalid or has fewer than 2 observations.
342    pub fn estimate_pair(&self, cam_a: usize, cam_b: usize) -> Option<GenlockDriftResult> {
343        let pair_idx = self.pair_index(cam_a, cam_b)?;
344        let obs = &self.observations[pair_idx];
345        if obs.len() < 2 {
346            return None;
347        }
348
349        let (slope, intercept, r_sq, rms) = linear_regression_with_stats(obs);
350
351        // Convert slope (samples/second) to PPM.
352        // If sample_rate is unknown, we express PPM as slope * 1e6 / nominal_rate.
353        // For a general-purpose estimator, report raw slope and let the caller
354        // convert. Here we assume 48000 Hz as a common default.
355        let drift_ppm = slope / 48.0; // slope / (48000 / 1_000_000)
356
357        Some(GenlockDriftResult {
358            camera_a: cam_a.min(cam_b),
359            camera_b: cam_a.max(cam_b),
360            drift_ppm,
361            offset_at_zero: intercept,
362            r_squared: r_sq,
363            rms_residual: rms,
364            num_observations: obs.len(),
365        })
366    }
367
368    /// Compute drift estimates for all camera pairs that have sufficient data.
369    pub fn estimate_all(&self) -> Vec<GenlockDriftResult> {
370        let mut results = Vec::new();
371        for a in 0..self.camera_count {
372            for b in (a + 1)..self.camera_count {
373                if let Some(result) = self.estimate_pair(a, b) {
374                    results.push(result);
375                }
376            }
377        }
378        results
379    }
380
381    /// Predict the offset at a future time for a camera pair.
382    ///
383    /// Returns `None` if the pair has insufficient data for a drift model.
384    pub fn predicted_offset(&self, cam_a: usize, cam_b: usize, time_seconds: f64) -> Option<f64> {
385        let pair_idx = self.pair_index(cam_a, cam_b)?;
386        let obs = &self.observations[pair_idx];
387        if obs.len() < 2 {
388            return None;
389        }
390        let (slope, intercept, _, _) = linear_regression_with_stats(obs);
391        Some(slope * time_seconds + intercept)
392    }
393
394    /// Number of observations for a camera pair.
395    pub fn observation_count(&self, cam_a: usize, cam_b: usize) -> usize {
396        self.pair_index(cam_a, cam_b)
397            .map(|i| self.observations[i].len())
398            .unwrap_or(0)
399    }
400
401    /// Number of cameras.
402    #[must_use]
403    pub fn camera_count(&self) -> usize {
404        self.camera_count
405    }
406
407    /// Clear all observations.
408    pub fn clear(&mut self) {
409        for obs in &mut self.observations {
410            obs.clear();
411        }
412    }
413
414    // -- Internal helpers --
415
416    /// Map (cam_a, cam_b) to a pair index. Returns None if invalid.
417    fn pair_index(&self, cam_a: usize, cam_b: usize) -> Option<usize> {
418        if cam_a >= self.camera_count || cam_b >= self.camera_count || cam_a == cam_b {
419            return None;
420        }
421        let (lo, hi) = if cam_a < cam_b {
422            (cam_a, cam_b)
423        } else {
424            (cam_b, cam_a)
425        };
426        // Triangular index: sum of (camera_count - 1) + (camera_count - 2) + ... for rows < lo
427        // plus (hi - lo - 1).
428        let idx = lo * (2 * self.camera_count - lo - 1) / 2 + (hi - lo - 1);
429        if idx < self.observations.len() {
430            Some(idx)
431        } else {
432            None
433        }
434    }
435}
436
437/// Fit y = slope * x + intercept via least squares, and return
438/// (slope, intercept, r_squared, rms_residual).
439fn linear_regression_with_stats(data: &[(f64, f64)]) -> (f64, f64, f64, f64) {
440    let n = data.len() as f64;
441    if n < 2.0 {
442        return (0.0, 0.0, 0.0, 0.0);
443    }
444
445    let sum_x: f64 = data.iter().map(|(x, _)| x).sum();
446    let sum_y: f64 = data.iter().map(|(_, y)| y).sum();
447    let sum_xx: f64 = data.iter().map(|(x, _)| x * x).sum();
448    let sum_xy: f64 = data.iter().map(|(x, y)| x * y).sum();
449
450    let denom = n * sum_xx - sum_x * sum_x;
451    if denom.abs() < f64::EPSILON {
452        let mean_y = sum_y / n;
453        return (0.0, mean_y, 0.0, 0.0);
454    }
455
456    let slope = (n * sum_xy - sum_x * sum_y) / denom;
457    let intercept = (sum_y - slope * sum_x) / n;
458
459    // R-squared
460    let mean_y = sum_y / n;
461    let ss_tot: f64 = data.iter().map(|(_, y)| (y - mean_y).powi(2)).sum();
462    let ss_res: f64 = data
463        .iter()
464        .map(|(x, y)| {
465            let pred = slope * x + intercept;
466            (y - pred).powi(2)
467        })
468        .sum();
469
470    let r_sq = if ss_tot > 1e-15 {
471        1.0 - ss_res / ss_tot
472    } else {
473        1.0
474    };
475
476    let rms = (ss_res / n).sqrt();
477
478    (slope, intercept, r_sq, rms)
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484
485    #[test]
486    fn test_pll_state_creation() {
487        let pll = PllState::new(0.1, 0.001);
488        assert_eq!(pll.kp, 0.1);
489        assert_eq!(pll.ki, 0.001);
490        assert_eq!(pll.phase_error, 0.0);
491        assert_eq!(pll.correction, 0.0);
492    }
493
494    #[test]
495    fn test_pll_default() {
496        let pll = PllState::default();
497        assert_eq!(pll.kp, 0.1);
498        assert_eq!(pll.ki, 0.001);
499    }
500
501    #[test]
502    fn test_pll_update_convergence() {
503        let mut pll = PllState::new(0.5, 0.01);
504        let mut phase = 10.0_f64;
505        for _ in 0..50 {
506            let correction = pll.update(phase, 0.0);
507            phase -= correction;
508        }
509        assert!(phase.abs() < 1.0, "PLL should converge: {phase}");
510    }
511
512    #[test]
513    fn test_pll_reset() {
514        let mut pll = PllState::new(0.1, 0.001);
515        pll.update(5.0, 0.0);
516        assert_ne!(pll.phase_error, 0.0);
517        pll.reset();
518        assert_eq!(pll.phase_error, 0.0);
519        assert_eq!(pll.filter_state, 0.0);
520        assert_eq!(pll.correction, 0.0);
521    }
522
523    #[test]
524    fn test_pll_lock_detection() {
525        let pll = PllState::new(0.1, 0.001);
526        // Fresh PLL with zero errors should be locked
527        assert!(pll.is_locked(1.0));
528    }
529
530    #[test]
531    fn test_drift_rate_estimator_creation() {
532        let est = DriftRateEstimator::new(50);
533        assert_eq!(est.max_window, 50);
534        assert_eq!(est.drift_ppm(), 0.0);
535        assert!(est.is_empty());
536    }
537
538    #[test]
539    fn test_drift_rate_single_measurement() {
540        let mut est = DriftRateEstimator::new(100);
541        est.add_measurement(1.0, 5.0);
542        assert_eq!(est.len(), 1);
543        // Need at least 2 for regression
544        assert_eq!(est.drift_ppm(), 0.0);
545    }
546
547    #[test]
548    fn test_drift_rate_two_measurements() {
549        let mut est = DriftRateEstimator::new(100);
550        est.add_measurement(0.0, 0.0);
551        est.add_measurement(1.0, 48.0); // 48 samples/sec drift
552        assert_ne!(est.drift_ppm(), 0.0);
553    }
554
555    #[test]
556    fn test_drift_rate_window_limit() {
557        let mut est = DriftRateEstimator::new(5);
558        for i in 0..10 {
559            est.add_measurement(i as f64, i as f64 * 2.0);
560        }
561        assert_eq!(est.len(), 5);
562    }
563
564    #[test]
565    fn test_drift_rate_clear() {
566        let mut est = DriftRateEstimator::new(100);
567        est.add_measurement(0.0, 0.0);
568        est.add_measurement(1.0, 10.0);
569        est.clear();
570        assert!(est.is_empty());
571        assert_eq!(est.drift_ppm(), 0.0);
572    }
573
574    #[test]
575    fn test_sample_adjustment_creation() {
576        let adj = SampleAdjustment::new(2, 0.3, 15.7);
577        assert_eq!(adj.delta, 2);
578        assert!((adj.fractional - 0.3).abs() < f64::EPSILON);
579        assert!(adj.needs_adjustment());
580    }
581
582    #[test]
583    fn test_sample_adjustment_no_adjustment() {
584        let adj = SampleAdjustment::new(0, 0.5, 0.5);
585        assert!(!adj.needs_adjustment());
586    }
587
588    #[test]
589    fn test_drift_corrector_creation() {
590        let dc = DriftCorrector::new(48000);
591        assert_eq!(dc.sample_rate, 48000);
592        assert_eq!(dc.total_samples, 0);
593    }
594
595    #[test]
596    fn test_drift_corrector_process_block() {
597        let mut dc = DriftCorrector::new(48000);
598        // Add some measurements first
599        dc.estimator.add_measurement(0.0, 0.0);
600        dc.estimator.add_measurement(1.0, 48.0);
601        let adj = dc.process_block(480, 48.0);
602        // Should produce some adjustment given drift
603        assert_eq!(adj.delta.abs() <= 1, true); // small block, small delta
604    }
605
606    #[test]
607    fn test_drift_corrector_reset() {
608        let mut dc = DriftCorrector::new(48000);
609        dc.estimator.add_measurement(0.0, 0.0);
610        dc.estimator.add_measurement(1.0, 100.0);
611        dc.total_samples = 1000;
612        dc.reset();
613        assert_eq!(dc.total_samples, 0);
614        assert_eq!(dc.accumulator, 0.0);
615        assert!(dc.estimator.is_empty());
616    }
617
618    #[test]
619    fn test_drift_corrector_pll_update() {
620        let mut dc = DriftCorrector::new(48000);
621        let correction = dc.update_pll(10.0, 0.0);
622        assert!(correction.abs() > 0.0);
623    }
624
625    // ── GenlockDriftEstimator ────────────────────────────────────────────────
626
627    #[test]
628    fn test_genlock_creation() {
629        let est = GenlockDriftEstimator::new(4, 1000);
630        assert_eq!(est.camera_count(), 4);
631        // 4 cameras => 6 pairs
632        assert_eq!(est.observations.len(), 6);
633    }
634
635    #[test]
636    fn test_genlock_add_observation() {
637        let mut est = GenlockDriftEstimator::new(3, 100);
638        assert!(est.add_observation(0, 1, 0.0, 0.0));
639        assert!(est.add_observation(0, 1, 1.0, 48.0));
640        assert_eq!(est.observation_count(0, 1), 2);
641        // Symmetry: (1, 0) should give same count
642        assert_eq!(est.observation_count(1, 0), 2);
643    }
644
645    #[test]
646    fn test_genlock_invalid_pair() {
647        let mut est = GenlockDriftEstimator::new(2, 100);
648        assert!(!est.add_observation(0, 0, 0.0, 0.0)); // same camera
649        assert!(!est.add_observation(0, 5, 0.0, 0.0)); // out of bounds
650    }
651
652    #[test]
653    fn test_genlock_linear_drift() {
654        let mut est = GenlockDriftEstimator::new(2, 1000);
655        // Feed perfectly linear drift: 10 samples/second
656        for i in 0..100 {
657            let t = i as f64;
658            est.add_observation(0, 1, t, t * 10.0);
659        }
660        let result = est.estimate_pair(0, 1).expect("should have enough data");
661        // slope = 10 samples/s => PPM = 10 / 48 ≈ 0.2083
662        assert!(
663            (result.drift_ppm - 10.0 / 48.0).abs() < 0.01,
664            "drift_ppm={}",
665            result.drift_ppm
666        );
667        assert!(result.r_squared > 0.999, "r²={}", result.r_squared);
668        assert!(result.rms_residual < 0.01, "rms={}", result.rms_residual);
669    }
670
671    #[test]
672    fn test_genlock_predicted_offset() {
673        let mut est = GenlockDriftEstimator::new(2, 1000);
674        est.add_observation(0, 1, 0.0, 0.0);
675        est.add_observation(0, 1, 10.0, 100.0);
676        // slope = 10, intercept = 0
677        let pred = est.predicted_offset(0, 1, 20.0).expect("should predict");
678        assert!((pred - 200.0).abs() < 1.0, "pred={pred}");
679    }
680
681    #[test]
682    fn test_genlock_insufficient_data() {
683        let mut est = GenlockDriftEstimator::new(2, 100);
684        est.add_observation(0, 1, 0.0, 0.0);
685        assert!(est.estimate_pair(0, 1).is_none());
686        assert!(est.predicted_offset(0, 1, 5.0).is_none());
687    }
688
689    #[test]
690    fn test_genlock_estimate_all() {
691        let mut est = GenlockDriftEstimator::new(3, 100);
692        // Only add data for pair (0,1)
693        est.add_observation(0, 1, 0.0, 0.0);
694        est.add_observation(0, 1, 1.0, 5.0);
695        let results = est.estimate_all();
696        assert_eq!(results.len(), 1); // only one pair has data
697        assert_eq!(results[0].camera_a, 0);
698        assert_eq!(results[0].camera_b, 1);
699    }
700
701    #[test]
702    fn test_genlock_clear() {
703        let mut est = GenlockDriftEstimator::new(2, 100);
704        est.add_observation(0, 1, 0.0, 0.0);
705        est.add_observation(0, 1, 1.0, 10.0);
706        assert_eq!(est.observation_count(0, 1), 2);
707        est.clear();
708        assert_eq!(est.observation_count(0, 1), 0);
709    }
710
711    #[test]
712    fn test_genlock_max_observations() {
713        let mut est = GenlockDriftEstimator::new(2, 5);
714        for i in 0..10 {
715            est.add_observation(0, 1, i as f64, i as f64 * 2.0);
716        }
717        assert_eq!(est.observation_count(0, 1), 5);
718    }
719
720    // ── linear_regression_with_stats ─────────────────────────────────────────
721
722    #[test]
723    fn test_linear_regression_perfect_line() {
724        let data: Vec<(f64, f64)> = (0..10).map(|i| (i as f64, 3.0 * i as f64 + 2.0)).collect();
725        let (slope, intercept, r_sq, rms) = linear_regression_with_stats(&data);
726        assert!((slope - 3.0).abs() < 1e-10);
727        assert!((intercept - 2.0).abs() < 1e-10);
728        assert!((r_sq - 1.0).abs() < 1e-10);
729        assert!(rms < 1e-10);
730    }
731
732    #[test]
733    fn test_linear_regression_constant() {
734        let data: Vec<(f64, f64)> = (0..5).map(|i| (i as f64, 7.0)).collect();
735        let (slope, intercept, _, _) = linear_regression_with_stats(&data);
736        assert!(slope.abs() < 1e-10);
737        assert!((intercept - 7.0).abs() < 1e-10);
738    }
739
740    #[test]
741    fn test_linear_regression_single_point() {
742        let data = vec![(1.0, 2.0)];
743        let (slope, _, _, _) = linear_regression_with_stats(&data);
744        assert_eq!(slope, 0.0);
745    }
746}