use crate::holdover::coast_phase_variance;
use crate::integrity::kir::k_running_max;
#[derive(Clone, Copy, Debug)]
pub struct HoldoverEnvelope {
pub q_wf: f64,
pub q_rw: f64,
pub q_drift: f64,
pub d_aging: f64,
pub flicker_floor: f64,
pub p0_phase_var_s2: f64,
}
impl HoldoverEnvelope {
pub fn stochastic_variance(&self, tau_s: f64) -> f64 {
self.p0_phase_var_s2
+ coast_phase_variance(self.q_wf, self.q_rw, self.q_drift, tau_s)
+ (self.flicker_floor * tau_s).powi(2)
}
pub fn aging_bias(&self, tau_s: f64) -> f64 {
0.5 * self.d_aging * tau_s * tau_s
}
}
pub fn hpl(env: &HoldoverEnvelope, ir: f64, tau_s: f64) -> f64 {
env.aging_bias(tau_s) + k_running_max(ir) * env.stochastic_variance(tau_s).sqrt()
}
pub fn composed_pl(tpl_handover_s: f64, env: &HoldoverEnvelope, ir: f64, tau_s: f64) -> f64 {
hpl(env, ir, tau_s).max(tpl_handover_s)
}
pub fn is_lower_semicontinuous_at_handover(
tpl_handover_s: f64,
env: &HoldoverEnvelope,
ir: f64,
) -> bool {
composed_pl(tpl_handover_s, env, ir, 0.0) >= tpl_handover_s
}
#[cfg(test)]
mod tests {
use super::*;
fn env() -> HoldoverEnvelope {
HoldoverEnvelope {
q_wf: 1e-24,
q_rw: 1e-30,
q_drift: 1e-38,
d_aging: 1e-18,
flicker_floor: 1e-13,
p0_phase_var_s2: 4e-20, }
}
#[test]
fn hpl_is_monotone_increasing_in_tau() {
let e = env();
let mut prev = hpl(&e, 1e-5, 0.0);
for &t in &[1.0, 10.0, 100.0, 1000.0, 1e4, 1e5] {
let h = hpl(&e, 1e-5, t);
assert!(h >= prev, "HPL must be non-decreasing in τ (t={t})");
prev = h;
}
}
#[test]
fn p0_seed_floors_hpl_at_tau_zero() {
let e = env();
let h0 = hpl(&e, 1e-5, 0.0);
assert!(h0 > 0.0);
let mut unseeded = e;
unseeded.p0_phase_var_s2 = 0.0;
unseeded.d_aging = 0.0;
assert_eq!(hpl(&unseeded, 1e-5, 0.0), 0.0);
}
#[test]
fn running_max_multiplier_is_used_not_pointwise() {
use crate::integrity::kir::{k_ir_two_sided, k_running_max};
let e = env();
let t = 1000.0;
let var = e.p0_phase_var_s2
+ crate::holdover::coast_phase_variance(e.q_wf, e.q_rw, e.q_drift, t)
+ (e.flicker_floor * t).powi(2);
let bias = 0.5 * e.d_aging * t * t;
let pointwise = bias + k_ir_two_sided(1e-5) * var.sqrt();
let running = bias + k_running_max(1e-5) * var.sqrt();
assert!((hpl(&e, 1e-5, t) - running).abs() < 1e-18);
assert!(hpl(&e, 1e-5, t) > pointwise);
}
#[test]
fn phase_variance_exponents_are_tau_tau3_tau5() {
use crate::holdover::coast_phase_variance;
let t = 100.0;
let wf = coast_phase_variance(1e-24, 0.0, 0.0, 2.0 * t)
/ coast_phase_variance(1e-24, 0.0, 0.0, t);
let rw = coast_phase_variance(0.0, 1e-30, 0.0, 2.0 * t)
/ coast_phase_variance(0.0, 1e-30, 0.0, t);
let dr = coast_phase_variance(0.0, 0.0, 1e-38, 2.0 * t)
/ coast_phase_variance(0.0, 0.0, 1e-38, t);
assert!((wf - 2.0).abs() < 1e-6, "white-FM phase variance ∝ τ");
assert!((rw - 8.0).abs() < 1e-6, "RW-FM phase variance ∝ τ³");
assert!((dr - 32.0).abs() < 1e-6, "drift phase variance ∝ τ⁵");
}
#[test]
fn aging_bias_carried_separately_from_stochastic_sup() {
let e = HoldoverEnvelope {
q_wf: 0.0,
q_rw: 0.0,
q_drift: 0.0,
d_aging: 1e-16,
flicker_floor: 0.0,
p0_phase_var_s2: 0.0,
};
let t = 500.0;
assert!((hpl(&e, 1e-5, t) - 0.5 * 1e-16 * t * t).abs() < 1e-24);
}
#[test]
fn pl_is_lower_semicontinuous_across_handover() {
let e = env();
for &tpl in &[1e-9, 5e-9, 20e-9, 100e-9] {
let pl_plus = composed_pl(tpl, &e, 1e-5, 0.0);
assert!(pl_plus >= tpl, "PL(0⁺) must be ≥ TPL_handover (tpl={tpl})");
assert!(is_lower_semicontinuous_at_handover(tpl, &e, 1e-5));
}
}
#[test]
fn unseeded_raw_hpl_would_drop_but_composed_pl_does_not() {
let mut unseeded = env();
unseeded.p0_phase_var_s2 = 0.0;
unseeded.d_aging = 0.0;
let tpl = 30e-9;
assert!(hpl(&unseeded, 1e-5, 0.0) < tpl); assert_eq!(composed_pl(tpl, &unseeded, 1e-5, 0.0), tpl); }
#[test]
fn composed_pl_is_non_decreasing_over_coast() {
let e = env();
let tpl = 10e-9;
let mut prev = composed_pl(tpl, &e, 1e-5, 0.0);
for &t in &[1.0, 100.0, 1e4, 1e5, 1e6] {
let pl = composed_pl(tpl, &e, 1e-5, t);
assert!(pl >= prev, "composed PL must be non-decreasing (t={t})");
prev = pl;
}
}
}