use crate::slot_timing::ClockNoise;
use rand::SeedableRng;
use rand_chacha::ChaCha8Rng;
use rand_distr::{Distribution, Normal};
use serde::{Deserialize, Serialize};
pub const WEEK_S: f64 = 604_800.0;
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct UtcParams {
#[serde(default)]
pub a0_s: f64,
#[serde(default)]
pub a1_s_per_s: f64,
#[serde(default)]
pub t_ot_s: f64,
#[serde(default)]
pub wn_t: i64,
#[serde(default)]
pub delta_t_ls_s: f64,
#[serde(default)]
pub sigma_s: f64,
}
pub fn utc_offset_s(p: &UtcParams, t_e: f64, wn: i64) -> f64 {
p.delta_t_ls_s + p.a0_s + p.a1_s_per_s * (t_e - p.t_ot_s + WEEK_S * (wn - p.wn_t) as f64)
}
pub fn system_to_utc_s(p: &UtcParams, t_e: f64, wn: i64) -> f64 {
(t_e - utc_offset_s(p, t_e, wn)).rem_euclid(86_400.0)
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct TimeTransferStats {
pub rms_s: f64,
pub p95_s: f64,
pub max_abs_s: f64,
pub median_predicted_sigma_s: f64,
pub max_predicted_sigma_s: f64,
pub fraction_in_view: f64,
pub longest_gap_s: f64,
pub rms_normalised: f64,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
pub struct TimeTransferEpoch {
pub t_s: f64,
pub error_ns: f64,
pub sigma_ns: f64,
pub in_view: bool,
}
pub fn simulate(
meas_sigma_s: &[Option<f64>],
dt: f64,
clock: &ClockNoise,
sigma_utc_s: f64,
seed: u64,
) -> Result<TimeTransferStats, String> {
simulate_traced(meas_sigma_s, dt, clock, sigma_utc_s, seed).map(|(s, _)| s)
}
pub fn simulate_traced(
meas_sigma_s: &[Option<f64>],
dt: f64,
clock: &ClockNoise,
sigma_utc_s: f64,
seed: u64,
) -> Result<(TimeTransferStats, Vec<TimeTransferEpoch>), String> {
if !(dt.is_finite() && dt > 0.0) {
return Err("the epoch spacing must be positive".into());
}
if meas_sigma_s.is_empty() {
return Err("no epochs".into());
}
let mut rng = ChaCha8Rng::seed_from_u64(seed);
let n01 = Normal::new(0.0, 1.0).map_err(|e| e.to_string())?;
let (qwf, qrw) = (clock.q_wf, clock.q_rw);
let q11 = qwf * dt + qrw * dt.powi(3) / 3.0;
let q12 = qrw * dt * dt / 2.0;
let q22 = qrw * dt;
let l11 = q11.max(0.0).sqrt();
let l21 = if l11 > 0.0 { q12 / l11 } else { 0.0 };
let l22 = (q22 - l21 * l21).max(0.0).sqrt();
let utc_bias = sigma_utc_s * n01.sample(&mut rng);
let mut truth = [1.0e-3 * n01.sample(&mut rng), 1.0e-9 * n01.sample(&mut rng)];
let mut x = [0.0, 0.0];
let mut p = [[1.0, 0.0], [0.0, 1.0e-18]];
let mut started = false;
let mut errs = Vec::new();
let mut sig = Vec::new();
let mut norm_sq = Vec::new();
let mut trace = Vec::new();
let (mut in_view, mut gap, mut longest_gap) = (0usize, 0.0_f64, 0.0_f64);
for (k, m) in meas_sigma_s.iter().enumerate() {
if k > 0 {
let (w1, w2) = (n01.sample(&mut rng), n01.sample(&mut rng));
truth = [
truth[0] + truth[1] * dt + l11 * w1,
truth[1] + l21 * w1 + l22 * w2,
];
x = [x[0] + x[1] * dt, x[1]];
let p00 = p[0][0] + 2.0 * dt * p[0][1] + dt * dt * p[1][1] + q11;
let p01 = p[0][1] + dt * p[1][1] + q12;
let p11 = p[1][1] + q22;
p = [[p00, p01], [p01, p11]];
}
match m {
Some(s) if s.is_finite() && *s > 0.0 => {
in_view += 1;
gap = 0.0;
let z = truth[0] + s * n01.sample(&mut rng);
let r = s * s;
let sv = p[0][0] + r;
let (k0, k1) = (p[0][0] / sv, p[0][1] / sv);
let y = z - x[0];
x = [x[0] + k0 * y, x[1] + k1 * y];
p = [
[(1.0 - k0) * p[0][0], (1.0 - k0) * p[0][1]],
[(1.0 - k0) * p[0][1], p[1][1] - k1 * p[0][1]],
];
started = true;
}
_ => {
gap += dt;
longest_gap = longest_gap.max(gap);
}
}
if started {
let e = truth[0] - x[0] + utc_bias;
let s = (p[0][0].max(0.0) + sigma_utc_s * sigma_utc_s).sqrt();
trace.push(TimeTransferEpoch {
t_s: k as f64 * dt,
error_ns: e * 1e9,
sigma_ns: s * 1e9,
in_view: m.is_some_and(|s| s.is_finite() && s > 0.0),
});
errs.push(e);
sig.push(s);
if s > 0.0 {
norm_sq.push((e / s).powi(2));
}
}
}
if errs.is_empty() {
return Err("no satellite was ever in view: the receiver never acquired time".into());
}
let mut abs: Vec<f64> = errs.iter().map(|e| e.abs()).collect();
abs.sort_by(|a, b| a.total_cmp(b));
let p95 = abs[((abs.len() as f64 * 0.95).ceil() as usize).clamp(1, abs.len()) - 1];
Ok((
TimeTransferStats {
rms_s: super::geom::rms(&errs).unwrap_or(0.0),
p95_s: p95,
max_abs_s: *abs.last().unwrap_or(&0.0),
median_predicted_sigma_s: super::geom::median(sig.clone()).unwrap_or(0.0),
max_predicted_sigma_s: sig.iter().copied().fold(0.0, f64::max),
fraction_in_view: in_view as f64 / meas_sigma_s.len() as f64,
longest_gap_s: longest_gap,
rms_normalised: (norm_sq.iter().sum::<f64>() / norm_sq.len().max(1) as f64).sqrt(),
},
trace,
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::clock_state::ClockClass;
#[test]
fn the_utc_offset_follows_the_is_gps_200_expression() {
let p = UtcParams {
a0_s: 5e-9,
a1_s_per_s: 1e-14,
t_ot_s: 405_504.0,
wn_t: 2300,
delta_t_ls_s: 18.0,
sigma_s: 0.0,
};
let d = utc_offset_s(&p, 1000.0, 2301);
assert!((d - (18.0 + 5e-9 + 2.00296e-9)).abs() < 1e-15, "{d}");
let u = system_to_utc_s(&p, 1000.0, 2301);
assert!((u - (86_400.0 + 1000.0 - d)).abs() < 1e-9 || (u - (1000.0 - d)).abs() < 1e-9);
}
#[test]
fn continuous_tracking_approaches_the_measurement_floor_and_gaps_cost_time() {
let clock = ClockNoise::from_class(ClockClass::Ocxo);
let full: Vec<Option<f64>> = vec![Some(10e-9); 600];
let a = simulate(&full, 1.0, &clock, 0.0, 1).unwrap();
assert!(a.rms_s < 10e-9, "{}", a.rms_s);
let gappy: Vec<Option<f64>> = (0..3600)
.map(|k| if k % 1200 < 60 { Some(10e-9) } else { None })
.collect();
let b = simulate(&gappy, 1.0, &clock, 0.0, 1).unwrap();
assert!(b.max_predicted_sigma_s > a.max_predicted_sigma_s);
assert!((b.longest_gap_s - 1140.0).abs() < 1e-9);
}
#[test]
fn the_filter_is_consistent_over_seeds() {
let clock = ClockNoise::from_class(ClockClass::Csac);
let meas: Vec<Option<f64>> = (0..1800)
.map(|k| if k % 600 < 200 { Some(20e-9) } else { None })
.collect();
let mut sum = 0.0;
let seeds = 40;
for s in 0..seeds {
let r = simulate(&meas, 1.0, &clock, 3e-9, s).unwrap();
sum += r.rms_normalised.powi(2);
}
let mean = sum / seeds as f64;
assert!((0.6..1.5).contains(&mean), "{mean}");
}
#[test]
fn the_utc_offset_uncertainty_sets_a_floor() {
let clock = ClockNoise::from_class(ClockClass::Rafs);
let full: Vec<Option<f64>> = vec![Some(1e-9); 300];
let r = simulate(&full, 1.0, &clock, 20e-9, 5).unwrap();
assert!(r.median_predicted_sigma_s >= 20e-9);
}
}