kshana 0.20.0

Open, reproducible PNT-resilience simulator with quantum-sensor performance models
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
// SPDX-License-Identifier: AGPL-3.0-only
//! Advanced time-and-frequency transfer.
//!
//! [`crate::timetransfer`] models the *stochastic* error of a generic two-way link and
//! [`crate::timegeo`] gives the two geometric closed forms (Sagnac, common-view). This
//! module builds the operational transfer methods on top of them:
//!
//! 1. **TWSTFT** — Two-Way Satellite Time and Frequency Transfer with the BIPM Sagnac
//!    closed form `Δt = 2·A·ω_E/c²` (`A` the equatorial-projected area of the
//!    ground-A → satellite → ground-B loop), a transponder delay, and reciprocal-path
//!    cancellation, emitting a `T_A − T_B` series and its TDEV.
//! 2. **GNSS common-view** — two synthetic ground stations single-differencing the same
//!    satellite; the satellite clock cancels and the inter-station offset is recovered.
//! 3. **PPP time transfer** — the ionosphere-free pseudorange combination and a
//!    receiver-clock solve against an SP3-grade (here synthetic) orbit + clock truth.
//! 4. **Optical (FSO) link** — free-space optical turbulence: Rytov variance, Fried
//!    parameter, and unit-mean log-normal amplitude (scintillation) fading.
//! 5. **IEEE 1139 power-law fit** — a five-coefficient `h_α` least-squares fit of the
//!    Allan-variance curve (all five canonical noise processes simultaneously), plus the
//!    dominant process per τ-decade.
//! 6. **Clock-ensemble timescale** — an inverse-variance-weighted paper timescale whose
//!    Allan deviation is strictly below that of the best contributing clock.
//!
//! Everything here is self-contained: the validation targets are closed forms (Sagnac,
//! iono-free cancellation, the `h_α` basis) and synthetic truth, not imported reference
//! products. A real BIPM Circular-T / IGS SP3 ingest is a follow-on (see `ROADMAP.md`).

use crate::allan::{overlapping_adev, time_deviation, PowerLawNoise};
use crate::timegeo::{sagnac_correction, C_M_PER_S, OMEGA_EARTH};
use crate::types::Seconds;
use rand::{RngCore, SeedableRng};
use rand_chacha::ChaCha8Rng;
use rand_distr::{Distribution, Normal};
use std::f64::consts::PI;

type Vec3 = [f64; 3];

// ───────────────────────────────────────────────────────────────────────────
// 1. TWSTFT — Two-Way Satellite Time and Frequency Transfer
// ───────────────────────────────────────────────────────────────────────────

/// Equatorial-projected signed area (m²) of the triangle `(a, b, c)`.
fn equatorial_triangle_area(a: Vec3, b: Vec3, c: Vec3) -> f64 {
    0.5 * ((b[0] - a[0]) * (c[1] - a[1]) - (c[0] - a[0]) * (b[1] - a[1]))
}

/// TWSTFT Sagnac correction (s) for the closed loop ground-A → satellite → ground-B → A:
/// the sum of the three one-way Sagnac terms. This equals the BIPM closed form
/// `Δt = 2·A·ω_E/c²` exactly, where `A` is the equatorial-projected area of the triangle
/// (`r_a`, `r_s`, `r_b`) — see [`twstft_sagnac_bipm`].
pub fn twstft_sagnac(r_a: Vec3, r_s: Vec3, r_b: Vec3) -> f64 {
    sagnac_correction(r_a, r_s) + sagnac_correction(r_s, r_b) + sagnac_correction(r_b, r_a)
}

/// The BIPM closed form `Δt = 2·A·ω_E/c²` for the same loop, computed independently from
/// the triangle area. Provided so callers (and tests) can cross-check [`twstft_sagnac`].
pub fn twstft_sagnac_bipm(r_a: Vec3, r_s: Vec3, r_b: Vec3) -> f64 {
    let area = equatorial_triangle_area(r_a, r_s, r_b);
    2.0 * area * OMEGA_EARTH / (C_M_PER_S * C_M_PER_S)
}

/// One TWSTFT comparison campaign.
#[derive(Clone, Debug)]
pub struct TwstftScenario {
    /// ECEF position of ground station A (m).
    pub r_a: Vec3,
    /// ECEF position of the relay satellite (m).
    pub r_s: Vec3,
    /// ECEF position of ground station B (m).
    pub r_b: Vec3,
    /// True clock offset `T_A − T_B` (s).
    pub true_offset_s: f64,
    /// One-way transponder + hardware delay (s); reciprocal, cancels in the two-way sum.
    pub transponder_delay_s: f64,
    /// White measurement jitter, 1-σ per exchange (s).
    pub sigma_j: f64,
    /// Exchange interval (s).
    pub step_s: Seconds,
    /// Number of exchanges.
    pub n_steps: usize,
    /// Deterministic seed.
    pub seed: u64,
}

/// Result of a TWSTFT campaign.
#[derive(Clone, Debug)]
pub struct TwstftResult {
    /// Applied geometric Sagnac correction (s).
    pub sagnac_s: f64,
    /// The independent BIPM `2Aω/c²` value (s) for cross-checking.
    pub bipm_sagnac_s: f64,
    /// Mean recovered offset `T_A − T_B` (s) after Sagnac removal.
    pub offset_est_s: f64,
    /// `(τ, TDEV)` curve of the Sagnac-corrected offset residual.
    pub tdev: Vec<(f64, f64)>,
}

/// Run a TWSTFT campaign. The two-way estimate of `T_A − T_B` retains the geometric
/// Sagnac term (the reciprocal path and transponder delays cancel); the campaign removes
/// the closed-form Sagnac and reports the recovered offset and its TDEV.
pub fn run_twstft(scn: &TwstftScenario) -> TwstftResult {
    let sagnac = twstft_sagnac(scn.r_a, scn.r_s, scn.r_b);
    let bipm = twstft_sagnac_bipm(scn.r_a, scn.r_s, scn.r_b);
    let nrm = Normal::new(0.0, scn.sigma_j).unwrap();
    let mut rng = ChaCha8Rng::seed_from_u64(scn.seed);

    // Each exchange: the raw two-way estimate carries the offset minus the loop Sagnac,
    // plus white jitter (the reciprocal transponder/path delays have cancelled). Removing
    // the closed-form Sagnac recovers the offset.
    let mut corrected = Vec::with_capacity(scn.n_steps);
    for _ in 0..scn.n_steps {
        let raw = scn.true_offset_s - sagnac + nrm.sample(&mut rng);
        corrected.push(raw + sagnac);
    }
    let mean = corrected.iter().sum::<f64>() / corrected.len() as f64;

    // TDEV of the recovered-offset series (treated as phase-time samples).
    let mut tdev = Vec::new();
    let mut m = 1usize;
    while corrected.len() >= 3 * m {
        let td = time_deviation(&corrected, scn.step_s, m);
        if td.is_finite() {
            tdev.push(((m as f64) * scn.step_s, td));
        }
        m *= 2;
    }

    TwstftResult {
        sagnac_s: sagnac,
        bipm_sagnac_s: bipm,
        offset_est_s: mean,
        tdev,
    }
}

// ───────────────────────────────────────────────────────────────────────────
// 2. GNSS common-view, two synthetic ground stations
// ───────────────────────────────────────────────────────────────────────────

/// One common-view epoch: each station's pseudorange to the shared satellite and the
/// station's computed geometric range to that satellite (m).
#[derive(Clone, Copy, Debug)]
pub struct CvEpoch {
    /// Pseudorange measured at station A (m).
    pub pr_a: f64,
    /// Geometric range A→satellite (m).
    pub range_a: f64,
    /// Pseudorange measured at station B (m).
    pub pr_b: f64,
    /// Geometric range B→satellite (m).
    pub range_b: f64,
}

/// Common-view inter-station offset series `(clock_A − clock_B)` (s) across epochs. The
/// satellite clock — common to both stations each epoch — cancels exactly.
pub fn gnss_common_view_series(epochs: &[CvEpoch]) -> Vec<f64> {
    epochs
        .iter()
        .map(|e| crate::timegeo::common_view_offset(e.pr_a, e.range_a, e.pr_b, e.range_b))
        .collect()
}

/// TDEV `(τ, TDEV)` curve of a common-view offset series sampled every `step_s`.
pub fn offset_tdev(series: &[f64], step_s: Seconds) -> Vec<(f64, f64)> {
    let mut out = Vec::new();
    let mut m = 1usize;
    while series.len() >= 3 * m {
        let td = time_deviation(series, step_s, m);
        if td.is_finite() {
            out.push(((m as f64) * step_s, td));
        }
        m *= 2;
    }
    out
}

// ───────────────────────────────────────────────────────────────────────────
// 3. PPP — ionosphere-free time transfer
// ───────────────────────────────────────────────────────────────────────────

/// GPS L1 carrier frequency (Hz).
pub const F_L1: f64 = 1_575.42e6;
/// GPS L2 carrier frequency (Hz).
pub const F_L2: f64 = 1_227.60e6;
/// GPS L5 carrier frequency (Hz), IS-GPS-705.
pub const F_L5: f64 = 1_176.45e6;

/// Ionosphere-free pseudorange combination (m):
/// `P_IF = (f1²·P1 − f2²·P2) / (f1² − f2²)`. The first-order ionospheric delay (∝ 1/f²)
/// cancels exactly, leaving geometry + clock + (troposphere) + noise.
pub fn iono_free_combination(p1: f64, p2: f64) -> f64 {
    let f1 = F_L1 * F_L1;
    let f2 = F_L2 * F_L2;
    (f1 * p1 - f2 * p2) / (f1 - f2)
}

/// PPP receiver-clock solve (s): from the iono-free pseudorange, the precise geometric
/// range, and the precise satellite clock (from SP3-grade orbits/clocks), recover the
/// receiver clock offset `dt_rx = (P_IF − range)/c + dt_sat`.
pub fn ppp_receiver_clock(p_if: f64, geom_range: f64, sat_clock_s: f64) -> f64 {
    (p_if - geom_range) / C_M_PER_S + sat_clock_s
}

/// First-order ionospheric group delay (m) on a carrier of frequency `f` (Hz) for a slant
/// TEC of `tec` TECU: `40.3 · 10¹⁶ · TEC / f²`.
pub fn iono_delay_m(tec_tecu: f64, f_hz: f64) -> f64 {
    40.3 * 1.0e16 * tec_tecu / (f_hz * f_hz)
}

// ───────────────────────────────────────────────────────────────────────────
// 4. Optical free-space link — turbulence
// ───────────────────────────────────────────────────────────────────────────

/// Plane-wave Rytov variance `σ_R² = 1.23 · Cn² · k^(7/6) · L^(11/6)` (dimensionless),
/// with `k = 2π/λ`. The weak-turbulence scintillation index ≈ `σ_R²`.
pub fn rytov_variance(cn2: f64, wavelength_m: f64, path_len_m: f64) -> f64 {
    let k = 2.0 * PI / wavelength_m;
    1.23 * cn2 * k.powf(7.0 / 6.0) * path_len_m.powf(11.0 / 6.0)
}

/// Plane-wave Fried parameter `r₀ = (0.423 · k² · Cn² · L)^(−3/5)` (m), the coherence
/// length of the turbulent wavefront.
pub fn fried_parameter(cn2: f64, wavelength_m: f64, path_len_m: f64) -> f64 {
    let k = 2.0 * PI / wavelength_m;
    (0.423 * k * k * cn2 * path_len_m).powf(-3.0 / 5.0)
}

/// A unit-mean log-normal amplitude (scintillation) fading factor for weak turbulence:
/// `I/⟨I⟩ = exp(2χ)` with the log-amplitude `χ ~ N(−σ_χ², σ_χ²)` and `σ_χ² = σ_R²/4`. The
/// mean offset `−σ_χ²` makes `E[I/⟨I⟩] = 1`; the variance is `exp(σ_R²) − 1 ≈ σ_R²`.
pub fn lognormal_fading(sigma_r2: f64, rng: &mut dyn RngCore) -> f64 {
    let sig_chi2 = sigma_r2 / 4.0;
    let nrm = Normal::new(-sig_chi2, sig_chi2.sqrt()).unwrap();
    (2.0 * nrm.sample(rng)).exp()
}

// ───────────────────────────────────────────────────────────────────────────
// 5. IEEE 1139 power-law fit (five coefficients)
// ───────────────────────────────────────────────────────────────────────────

/// The five fitted power-law PSD coefficients `S_y(f) = Σ h_α f^α` and the dominant
/// process per τ-decade.
#[derive(Clone, Debug)]
pub struct PowerLawFit {
    /// White PM coefficient `h₂` (α = +2).
    pub h2: f64,
    /// Flicker PM coefficient `h₁` (α = +1).
    pub h1: f64,
    /// White FM coefficient `h₀` (α = 0).
    pub h0: f64,
    /// Flicker FM coefficient `h₋₁` (α = −1).
    pub hm1: f64,
    /// Random-walk FM coefficient `h₋₂` (α = −2).
    pub hm2: f64,
    /// Dominant noise process at the centre of each τ-decade spanned by the data.
    pub dominant_per_decade: Vec<(f64, PowerLawNoise)>,
}

/// The Allan-variance contribution of each canonical noise type per unit `h_α`, evaluated
/// at averaging time `tau` with phase-noise high cutoff `f_h` (Hz). Order:
/// `[h₂, h₁, h₀, h₋₁, h₋₂]`. (NIST SP1065 / IEEE 1139 conversions.)
fn avar_basis(tau: f64, f_h: f64) -> [f64; 5] {
    let fourpi2 = 4.0 * PI * PI;
    [
        3.0 * f_h / (fourpi2 * tau * tau), // WPM
        (1.038 + 3.0 * (2.0 * PI * f_h * tau).ln()) / (fourpi2 * tau * tau), // FPM
        1.0 / (2.0 * tau),                 // WFM
        2.0 * (2.0_f64).ln(),              // FFM
        2.0 * PI * PI * tau / 3.0,         // RWFM
    ]
}

/// Solve the linear system `A·x = b` (square, `n×n`) by Gaussian elimination with partial
/// pivoting. Returns `None` if singular.
fn solve_lin(mut a: Vec<Vec<f64>>, mut b: Vec<f64>) -> Option<Vec<f64>> {
    let n = b.len();
    for col in 0..n {
        let mut piv = col;
        for r in (col + 1)..n {
            if a[r][col].abs() > a[piv][col].abs() {
                piv = r;
            }
        }
        if a[piv][col].abs() < 1e-300 {
            return None;
        }
        a.swap(col, piv);
        b.swap(col, piv);
        let pivot_row = a[col].clone();
        let b_col = b[col];
        let akk = pivot_row[col];
        for r in (col + 1)..n {
            let f = a[r][col] / akk;
            for (slot, &pv) in a[r][col..n].iter_mut().zip(pivot_row[col..n].iter()) {
                *slot -= f * pv;
            }
            b[r] -= f * b_col;
        }
    }
    let mut x = vec![0.0; n];
    for i in (0..n).rev() {
        let mut s = b[i];
        for c in (i + 1)..n {
            s -= a[i][c] * x[c];
        }
        x[i] = s / a[i][i];
    }
    Some(x)
}

/// Fit all five power-law coefficients `h_α` simultaneously to an Allan-deviation curve
/// `(τ, ADEV)` by least squares on the Allan *variance* (`σ_y²`), using `f_h` for the
/// phase-noise basis terms. Returns the coefficients (negatives clamped to zero on report)
/// and the dominant process per τ-decade. Needs at least five points.
pub fn fit_power_law_psd(curve: &[(f64, f64)], f_h: f64) -> Option<PowerLawFit> {
    if curve.len() < 5 {
        return None;
    }
    // Normal equations AᵀA x = Aᵀy with rows = avar_basis(τ_i), y = σ_y²(τ_i).
    let mut ata = vec![vec![0.0; 5]; 5];
    let mut aty = vec![0.0; 5];
    for &(tau, adev) in curve {
        let row = avar_basis(tau, f_h);
        let y = adev * adev;
        for i in 0..5 {
            aty[i] += row[i] * y;
            for j in 0..5 {
                ata[i][j] += row[i] * row[j];
            }
        }
    }
    let h = solve_lin(ata, aty)?;

    // Dominant process per τ-decade: the α whose basis·coefficient term is largest at the
    // decade centre.
    let types = [
        PowerLawNoise::WhitePm,
        PowerLawNoise::FlickerPm,
        PowerLawNoise::WhiteFm,
        PowerLawNoise::FlickerFm,
        PowerLawNoise::RandomWalkFm,
    ];
    let tau_min = curve.iter().map(|p| p.0).fold(f64::INFINITY, f64::min);
    let tau_max = curve.iter().map(|p| p.0).fold(0.0_f64, f64::max);
    let dec_lo = tau_min.log10().floor() as i32;
    let dec_hi = tau_max.log10().floor() as i32;
    let mut dominant = Vec::new();
    for d in dec_lo..=dec_hi {
        let tau_c = 10f64.powi(d) * (10f64).sqrt(); // geometric centre of the decade
        let basis = avar_basis(tau_c, f_h);
        let mut best = 0usize;
        let mut best_val = f64::NEG_INFINITY;
        for i in 0..5 {
            let contrib = (h[i].max(0.0)) * basis[i];
            if contrib > best_val {
                best_val = contrib;
                best = i;
            }
        }
        dominant.push((tau_c, types[best]));
    }

    Some(PowerLawFit {
        h2: h[0],
        h1: h[1],
        h0: h[2],
        hm1: h[3],
        hm2: h[4],
        dominant_per_decade: dominant,
    })
}

// ───────────────────────────────────────────────────────────────────────────
// 6. Clock-ensemble timescale (paper time)
// ───────────────────────────────────────────────────────────────────────────

/// Inverse-variance-weighted ensemble (paper) timescale phase series from `n` clock phase
/// series and per-clock weights `w_j ∝ 1/σ_j²`. With independent clocks this is the
/// minimum-variance combination, so its Allan deviation falls below the best single clock.
pub fn ensemble_timescale(clocks: &[Vec<f64>], weights: &[f64]) -> Vec<f64> {
    assert_eq!(clocks.len(), weights.len());
    assert!(!clocks.is_empty());
    let len = clocks[0].len();
    let wsum: f64 = weights.iter().sum();
    (0..len)
        .map(|k| {
            clocks
                .iter()
                .zip(weights)
                .map(|(c, &w)| w * c[k])
                .sum::<f64>()
                / wsum
        })
        .collect()
}

/// Overlapping ADEV at the base averaging time `τ₀` of a phase series (convenience for
/// comparing the ensemble against individual clocks).
pub fn adev_tau0(phase: &[f64], tau0: Seconds) -> f64 {
    overlapping_adev(phase, tau0, 1)
}

#[cfg(test)]
mod tests {
    use super::*;

    const RE: f64 = 6_378_137.0;
    const GEO: f64 = 4.2164e7;

    // ── 1. TWSTFT ──────────────────────────────────────────────────────────

    #[test]
    fn twstft_sagnac_equals_the_bipm_2a_omega_over_c2_form() {
        // Three equatorial points; the loop sum must equal 2·A·ω/c² exactly.
        let a = [RE, 0.0, 0.0];
        let s = [GEO * 0.5, GEO * 0.866_025_403_8, 0.0]; // 60° longitude
        let b = [RE * 0.5, RE * 0.866_025_403_8, 0.0]; // station 60° east
        let loop_sum = twstft_sagnac(a, s, b);
        let bipm = twstft_sagnac_bipm(a, s, b);
        assert!(
            (loop_sum - bipm).abs() < 1e-18,
            "loop {loop_sum} vs BIPM {bipm}"
        );
    }

    #[test]
    fn twstft_sagnac_within_5pct_for_a_continental_baseline() {
        // A realistic Europe-style baseline via a GEO relay: loop and BIPM agree far
        // tighter than the 5% the milestone asks for.
        let a = [RE * 0.7, RE * 0.7, 0.0];
        let s = [GEO * 0.2, GEO * 0.97, 0.0];
        let b = [RE * 0.4, RE * 0.9, 0.0];
        let loop_sum = twstft_sagnac(a, s, b);
        let bipm = twstft_sagnac_bipm(a, s, b);
        assert!(bipm.abs() > 1e-9, "Sagnac should be tens of ns: {bipm}");
        assert!((loop_sum - bipm).abs() / bipm.abs() < 0.05);
    }

    #[test]
    fn twstft_sagnac_is_zero_for_a_degenerate_radial_loop() {
        // All three points along +x: no enclosed equatorial area ⇒ no Sagnac.
        let z = twstft_sagnac([RE, 0.0, 0.0], [2.0 * RE, 0.0, 0.0], [3.0 * RE, 0.0, 0.0]);
        assert!(z.abs() < 1e-20, "z = {z}");
    }

    #[test]
    fn twstft_campaign_recovers_the_offset_after_sagnac_removal() {
        let scn = TwstftScenario {
            r_a: [RE, 0.0, 0.0],
            r_s: [GEO * 0.5, GEO * 0.866, 0.0],
            r_b: [RE * 0.5, RE * 0.866, 0.0],
            true_offset_s: 2.5e-8, // 25 ns
            transponder_delay_s: 1.2e-7,
            sigma_j: 2e-10, // 0.2 ns per exchange
            step_s: 60.0,
            n_steps: 1440, // 1 day at 1-min cadence
            seed: 7,
        };
        let r = run_twstft(&scn);
        // Mean of 1440 white-jitter samples → σ/√N ≈ 0.2 ns / 38 ≈ 5 ps; allow 5×.
        assert!(
            (r.offset_est_s - scn.true_offset_s).abs() < 5.0 * scn.sigma_j / (1440.0_f64).sqrt(),
            "recovered {} vs true {}",
            r.offset_est_s,
            scn.true_offset_s
        );
        assert!((r.sagnac_s - r.bipm_sagnac_s).abs() < 1e-18);
    }

    #[test]
    fn twstft_campaign_emits_a_finite_tdev_curve() {
        let scn = TwstftScenario {
            r_a: [RE, 0.0, 0.0],
            r_s: [GEO * 0.5, GEO * 0.866, 0.0],
            r_b: [RE * 0.5, RE * 0.866, 0.0],
            true_offset_s: 0.0,
            transponder_delay_s: 0.0,
            sigma_j: 1e-10,
            step_s: 60.0,
            n_steps: 1440,
            seed: 3,
        };
        let r = run_twstft(&scn);
        assert!(r.tdev.len() >= 3, "expected a multi-point TDEV curve");
        assert!(r
            .tdev
            .iter()
            .all(|&(t, d)| t.is_finite() && d.is_finite() && d > 0.0));
        // White (uncorrelated) offset noise ⇒ TDEV decreases with τ.
        assert!(r.tdev.first().unwrap().1 > r.tdev.last().unwrap().1);
    }

    // ── 2. GNSS common-view ──────────────────────────────────────────────────

    fn cv_epoch(dt_a: f64, dt_b: f64, sv: f64, ra: f64, rb: f64) -> CvEpoch {
        // Pseudorange = geometric range + c·(rx_clock − sv_clock).
        CvEpoch {
            pr_a: ra + C_M_PER_S * (dt_a - sv),
            range_a: ra,
            pr_b: rb + C_M_PER_S * (dt_b - sv),
            range_b: rb,
        }
    }

    #[test]
    fn common_view_recovers_offset_and_cancels_sat_clock() {
        let (dt_a, dt_b) = (1.0e-7, -3.0e-8);
        for &sv in &[0.0, 1e-6, -5e-7] {
            let e = cv_epoch(dt_a, dt_b, sv, 2.10e7, 2.13e7);
            let est = gnss_common_view_series(&[e])[0];
            assert!((est - (dt_a - dt_b)).abs() < 1e-15, "sv {sv}: {est}");
        }
    }

    #[test]
    fn common_view_series_is_constant_for_a_constant_offset() {
        let (dt_a, dt_b) = (5e-8, 5e-9);
        let epochs: Vec<CvEpoch> = (0..50)
            .map(|k| {
                cv_epoch(
                    dt_a,
                    dt_b,
                    1e-6 * (k as f64).sin(),
                    2.0e7 + 1e3 * k as f64,
                    2.05e7,
                )
            })
            .collect();
        let s = gnss_common_view_series(&epochs);
        assert!(s.iter().all(|v| (v - (dt_a - dt_b)).abs() < 1e-15));
    }

    #[test]
    fn common_view_emits_a_tdev_curve() {
        let epochs: Vec<CvEpoch> = (0..100)
            .map(|k| cv_epoch(1e-7, 0.0, 1e-6 * (k as f64), 2.0e7, 2.0e7))
            .collect();
        let s = gnss_common_view_series(&epochs);
        let td = offset_tdev(&s, 30.0);
        assert!(td.len() >= 3 && td.iter().all(|&(t, d)| t.is_finite() && d.is_finite()));
    }

    #[test]
    fn common_view_handles_differing_ranges() {
        // Different geometry at the two stations must not bias the offset.
        let e = cv_epoch(2e-8, -2e-8, 7e-7, 2.0e7, 2.4e7);
        let est = gnss_common_view_series(&[e])[0];
        assert!((est - 4e-8).abs() < 1e-15);
    }

    #[test]
    fn common_view_white_noise_averages_down() {
        // N independent noisy single-differences average toward the true offset.
        let nrm = Normal::new(0.0, 1.0).unwrap(); // 1 m pseudorange noise
        let mut rng = ChaCha8Rng::seed_from_u64(11);
        let n = 4000;
        let mut series = Vec::new();
        for _ in 0..n {
            let na = nrm.sample(&mut rng);
            let nb = nrm.sample(&mut rng);
            let e = CvEpoch {
                pr_a: 2.0e7 + C_M_PER_S * 1e-7 + na,
                range_a: 2.0e7,
                pr_b: 2.0e7 + nb,
                range_b: 2.0e7,
            };
            series.push(gnss_common_view_series(&[e])[0]);
        }
        let mean = series.iter().sum::<f64>() / n as f64;
        // True offset 100 ns; mean error ≈ √2·1m/c/√N ≈ 7e-11 s; allow 5×.
        assert!((mean - 1e-7).abs() < 5.0 * 2f64.sqrt() / C_M_PER_S / (n as f64).sqrt());
    }

    // ── 3. PPP ───────────────────────────────────────────────────────────────

    #[test]
    fn iono_free_cancels_first_order_ionosphere_exactly() {
        let (rho, dt_rx, dt_sat, tec) = (2.15e7, 3e-7, 1e-9, 25.0);
        let common = rho + C_M_PER_S * (dt_rx - dt_sat);
        let p1 = common + iono_delay_m(tec, F_L1);
        let p2 = common + iono_delay_m(tec, F_L2);
        let p_if = iono_free_combination(p1, p2);
        assert!((p_if - common).abs() < 1e-5, "p_if {p_if} vs {common}");
    }

    #[test]
    fn ppp_recovers_receiver_clock_exactly_when_noiseless() {
        let (rho, dt_rx, dt_sat, tec) = (2.0e7, -4.2e-7, 5e-10, 40.0);
        let common = rho + C_M_PER_S * (dt_rx - dt_sat);
        let p1 = common + iono_delay_m(tec, F_L1);
        let p2 = common + iono_delay_m(tec, F_L2);
        let p_if = iono_free_combination(p1, p2);
        let est = ppp_receiver_clock(p_if, rho, dt_sat);
        assert!((est - dt_rx).abs() < 1e-13, "est {est} vs {dt_rx}");
    }

    #[test]
    fn iono_free_combination_coefficients_sum_to_unity_on_geometry() {
        // The combination preserves any frequency-independent term (geometry, clock): the
        // two coefficients (f1²/(f1²−f2²)) and (−f2²/(f1²−f2²)) sum to 1.
        let f1 = F_L1 * F_L1;
        let f2 = F_L2 * F_L2;
        let c1 = f1 / (f1 - f2);
        let c2 = -f2 / (f1 - f2);
        assert!((c1 + c2 - 1.0).abs() < 1e-12);
        assert!(c1 > 2.5 && c1 < 2.6); // ≈ 2.546 for L1/L2
    }

    #[test]
    fn iono_delay_is_larger_on_l2_than_l1() {
        // The 1/f² scaling makes the L2 delay (f2 < f1) the larger one.
        let d1 = iono_delay_m(30.0, F_L1);
        let d2 = iono_delay_m(30.0, F_L2);
        assert!(d2 > d1 && d1 > 0.0);
        // Ratio = (f1/f2)² ≈ 1.647.
        assert!(((d2 / d1) - (F_L1 / F_L2).powi(2)).abs() < 1e-9);
    }

    #[test]
    fn ppp_noisy_clock_series_emits_tdev_and_stays_unbiased() {
        let nrm = Normal::new(0.0, 0.3).unwrap(); // 0.3 m iono-free noise
        let mut rng = ChaCha8Rng::seed_from_u64(21);
        let (rho, dt_rx, dt_sat, tec) = (2.1e7, 6e-8, 0.0, 15.0);
        let mut series = Vec::new();
        for _ in 0..600 {
            let common = rho + C_M_PER_S * (dt_rx - dt_sat);
            let p1 = common + iono_delay_m(tec, F_L1) + nrm.sample(&mut rng);
            let p2 = common + iono_delay_m(tec, F_L2) + nrm.sample(&mut rng);
            let p_if = iono_free_combination(p1, p2);
            series.push(ppp_receiver_clock(p_if, rho, dt_sat));
        }
        let mean = series.iter().sum::<f64>() / series.len() as f64;
        assert!((mean - dt_rx).abs() < 1e-9, "mean {mean} vs {dt_rx}");
        let td = offset_tdev(&series, 30.0);
        assert!(td.len() >= 3 && td.iter().all(|&(_, d)| d.is_finite()));
    }

    // ── 4. Optical FSO ───────────────────────────────────────────────────────

    #[test]
    fn rytov_variance_matches_hand_calculation() {
        // λ=1.064 µm, Cn²=1e-16 m^-2/3, L=10 km ⇒ σ_R² ≈ 0.210 (weak turbulence).
        let s = rytov_variance(1e-16, 1.064e-6, 1.0e4);
        assert!((s - 0.210).abs() < 0.006, "σ_R² = {s}");
    }

    #[test]
    fn fried_parameter_matches_hand_calculation() {
        // Same conditions ⇒ r₀ ≈ 0.199 m.
        let r0 = fried_parameter(1e-16, 1.064e-6, 1.0e4);
        assert!((r0 - 0.199).abs() < 0.006, "r₀ = {r0}");
    }

    #[test]
    fn lognormal_fading_has_unit_mean() {
        let sigma_r2 = 0.2;
        let mut rng = ChaCha8Rng::seed_from_u64(99);
        let n = 200_000;
        let mean: f64 = (0..n)
            .map(|_| lognormal_fading(sigma_r2, &mut rng))
            .sum::<f64>()
            / n as f64;
        assert!((mean - 1.0).abs() < 0.02, "mean = {mean}");
    }

    #[test]
    fn lognormal_fading_variance_tracks_rytov() {
        let sigma_r2 = 0.2;
        let mut rng = ChaCha8Rng::seed_from_u64(100);
        let n = 200_000;
        let xs: Vec<f64> = (0..n)
            .map(|_| lognormal_fading(sigma_r2, &mut rng))
            .collect();
        let mean = xs.iter().sum::<f64>() / n as f64;
        let var = xs.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / n as f64;
        // Var = exp(σ_R²) − 1 ≈ 0.221 for σ_R²=0.2.
        let expected = (sigma_r2).exp() - 1.0;
        assert!(
            (var - expected).abs() / expected < 0.08,
            "var {var} vs {expected}"
        );
    }

    #[test]
    fn stronger_turbulence_increases_rytov_and_shrinks_fried() {
        let weak = rytov_variance(1e-17, 1.55e-6, 1.0e4);
        let strong = rytov_variance(1e-15, 1.55e-6, 1.0e4);
        assert!(strong > weak);
        let r0_weak = fried_parameter(1e-17, 1.55e-6, 1.0e4);
        let r0_strong = fried_parameter(1e-15, 1.55e-6, 1.0e4);
        assert!(r0_strong < r0_weak);
    }

    // ── 5. IEEE 1139 power-law fit ───────────────────────────────────────────

    /// Octave-spaced τ grid and an analytic ADEV curve from given coefficients.
    fn synthetic_curve(h2: f64, h1: f64, h0: f64, hm1: f64, hm2: f64, f_h: f64) -> Vec<(f64, f64)> {
        (0..10)
            .map(|i| {
                let tau = (1u64 << i) as f64; // 1, 2, 4, … 512 s
                let b = avar_basis(tau, f_h);
                let avar = h2 * b[0] + h1 * b[1] + h0 * b[2] + hm1 * b[3] + hm2 * b[4];
                (tau, avar.sqrt())
            })
            .collect()
    }

    #[test]
    fn fit_recovers_pure_white_fm() {
        let h0 = 1e-22;
        let curve = synthetic_curve(0.0, 0.0, h0, 0.0, 0.0, 0.5);
        let f = fit_power_law_psd(&curve, 0.5).unwrap();
        assert!((f.h0 - h0).abs() / h0 < 1e-6, "h0 {} vs {h0}", f.h0);
        assert!(f.hm2.abs() < 1e-28 && f.hm1.abs() < 1e-26);
    }

    #[test]
    fn fit_recovers_pure_random_walk_fm() {
        let hm2 = 3e-30;
        let curve = synthetic_curve(0.0, 0.0, 0.0, 0.0, hm2, 0.5);
        let f = fit_power_law_psd(&curve, 0.5).unwrap();
        assert!((f.hm2 - hm2).abs() / hm2 < 1e-6, "hm2 {} vs {hm2}", f.hm2);
    }

    #[test]
    fn fit_recovers_pure_flicker_fm() {
        let hm1 = 2e-24;
        let curve = synthetic_curve(0.0, 0.0, 0.0, hm1, 0.0, 0.5);
        let f = fit_power_law_psd(&curve, 0.5).unwrap();
        assert!((f.hm1 - hm1).abs() / hm1 < 1e-6, "hm1 {} vs {hm1}", f.hm1);
    }

    #[test]
    fn fit_recovers_a_three_process_mix() {
        let (h0, hm1, hm2) = (1e-22, 5e-25, 2e-30);
        let curve = synthetic_curve(0.0, 0.0, h0, hm1, hm2, 0.5);
        let f = fit_power_law_psd(&curve, 0.5).unwrap();
        assert!((f.h0 - h0).abs() / h0 < 1e-5);
        assert!((f.hm1 - hm1).abs() / hm1 < 1e-5);
        assert!((f.hm2 - hm2).abs() / hm2 < 1e-5);
    }

    #[test]
    fn fit_recovers_white_pm_with_cutoff() {
        let h2 = 4e-26;
        let f_h = 10.0;
        let curve = synthetic_curve(h2, 0.0, 0.0, 0.0, 0.0, f_h);
        let f = fit_power_law_psd(&curve, f_h).unwrap();
        assert!((f.h2 - h2).abs() / h2 < 1e-4, "h2 {} vs {h2}", f.h2);
    }

    #[test]
    fn fit_reports_dominant_process_per_decade() {
        // White FM dominates short τ, random-walk FM dominates long τ. The crossover
        // τ² = 3·h₀/(4π²·h₋₂) ≈ (27.6 s)² sits well inside the 1–512 s grid.
        let curve = synthetic_curve(0.0, 0.0, 1e-22, 0.0, 1e-26, 0.5);
        let f = fit_power_law_psd(&curve, 0.5).unwrap();
        assert!(!f.dominant_per_decade.is_empty());
        let first = f.dominant_per_decade.first().unwrap().1;
        let last = f.dominant_per_decade.last().unwrap().1;
        assert_eq!(first, PowerLawNoise::WhiteFm);
        assert_eq!(last, PowerLawNoise::RandomWalkFm);
    }

    // ── 6. Clock ensemble ────────────────────────────────────────────────────

    /// A synthetic white-FM clock phase series: phase is the running integral of white
    /// fractional frequency `y_k ~ N(0, σ_y)` over steps of `tau0`.
    fn white_fm_clock(sigma_y: f64, tau0: f64, n: usize, seed: u64) -> Vec<f64> {
        let nrm = Normal::new(0.0, sigma_y).unwrap();
        let mut rng = ChaCha8Rng::seed_from_u64(seed);
        let mut phase = 0.0;
        let mut out = Vec::with_capacity(n);
        for _ in 0..n {
            out.push(phase);
            phase += nrm.sample(&mut rng) * tau0;
        }
        out
    }

    #[test]
    fn ensemble_of_three_equal_clocks_beats_each_single() {
        let tau0 = 1.0;
        let n = 2048;
        let c1 = white_fm_clock(1e-11, tau0, n, 1);
        let c2 = white_fm_clock(1e-11, tau0, n, 2);
        let c3 = white_fm_clock(1e-11, tau0, n, 3);
        let ens = ensemble_timescale(&[c1.clone(), c2.clone(), c3.clone()], &[1.0, 1.0, 1.0]);
        let a1 = adev_tau0(&c1, tau0);
        let a2 = adev_tau0(&c2, tau0);
        let a3 = adev_tau0(&c3, tau0);
        let ae = adev_tau0(&ens, tau0);
        let min_single = a1.min(a2).min(a3);
        assert!(
            ae < min_single,
            "ensemble {ae} not below best single {min_single}"
        );
        // Three independent equal clocks ⇒ variance/3 ⇒ ADEV ≈ mean/√3.
        let mean_single = (a1 + a2 + a3) / 3.0;
        assert!(
            ae < 0.75 * mean_single && ae > 0.40 * mean_single,
            "ae {ae} mean {mean_single}"
        );
    }

    #[test]
    fn inverse_variance_weighting_beats_the_best_clock() {
        let tau0 = 1.0;
        let n = 2048;
        // Three clocks of differing quality; weight ∝ 1/σ².
        let (s1, s2, s3) = (1e-11, 2e-11, 4e-11);
        let c1 = white_fm_clock(s1, tau0, n, 10);
        let c2 = white_fm_clock(s2, tau0, n, 20);
        let c3 = white_fm_clock(s3, tau0, n, 30);
        let w = [1.0 / (s1 * s1), 1.0 / (s2 * s2), 1.0 / (s3 * s3)];
        let ens = ensemble_timescale(&[c1.clone(), c2.clone(), c3.clone()], &w);
        let ae = adev_tau0(&ens, tau0);
        let best = adev_tau0(&c1, tau0); // c1 is the best clock
        assert!(ae < best, "ensemble {ae} not below best clock {best}");
    }

    #[test]
    fn ensemble_is_reproducible() {
        let c1 = white_fm_clock(1e-11, 1.0, 512, 7);
        let c2 = white_fm_clock(1e-11, 1.0, 512, 8);
        let a = ensemble_timescale(&[c1.clone(), c2.clone()], &[1.0, 1.0]);
        let b = ensemble_timescale(&[c1, c2], &[1.0, 1.0]);
        assert_eq!(a, b);
    }

    #[test]
    fn ensemble_weights_are_a_convex_combination() {
        // Equal-phase clocks ⇒ ensemble equals that common phase regardless of weights.
        let c = vec![1.0, 2.0, 3.0, 4.0];
        let ens = ensemble_timescale(&[c.clone(), c.clone(), c.clone()], &[0.2, 0.5, 1.3]);
        assert_eq!(ens, c);
    }

    #[test]
    fn single_clock_ensemble_is_the_identity() {
        let c = white_fm_clock(1e-11, 1.0, 256, 5);
        let ens = ensemble_timescale(std::slice::from_ref(&c), &[3.7]);
        // w·c/w is c up to one multiply/divide ULP — identical to well under a femtosecond.
        assert!(ens
            .iter()
            .zip(&c)
            .all(|(e, x)| (e - x).abs() <= 1e-24 + 1e-12 * x.abs()));
    }
}