use super::{sat_seed, ChannelEffect, LineOfSight};
use crate::iq::simrng::SimRng;
use crate::iq::ChannelSnapshot;
use rand::Rng;
use rand_distr::StandardNormal;
use std::collections::BTreeMap;
use std::f64::consts::SQRT_2;
pub const BUTTERWORTH_1_OVER_E_X: f64 = 1.239_646_436_810_474;
const STREAM: u64 = 0x5C17_0000;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ScintParams {
pub s4: f64,
pub tau0_s: f64,
pub extra_phase_sigma_rad: f64,
}
impl ScintParams {
pub fn new(s4: f64, tau0_s: f64) -> Self {
Self {
s4,
tau0_s,
extra_phase_sigma_rad: 0.0,
}
}
pub fn diffuse_power(&self) -> f64 {
let s4 = self.s4.clamp(0.0, 1.0);
1.0 - (1.0 - s4 * s4).sqrt()
}
pub fn omega_n(&self) -> f64 {
BUTTERWORTH_1_OVER_E_X * SQRT_2 / self.tau0_s
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ScintSample {
pub amplitude: f64,
pub phase_rad: f64,
}
#[derive(Clone, Copy, Debug, Default)]
struct Bw2 {
u: [f64; 2],
}
fn transition(omega_n: f64, dt: f64) -> ([[f64; 2]; 2], [[f64; 2]; 2]) {
let x = omega_n * dt / SQRT_2;
let e = (-x).exp();
let (s, c) = x.sin_cos();
let phi = [
[e * (c + s), e * SQRT_2 * s],
[-e * SQRT_2 * s, e * (c - s)],
];
let q00 = 1.0 - phi[0][0] * phi[0][0] - phi[0][1] * phi[0][1];
let q01 = -phi[0][0] * phi[1][0] - phi[0][1] * phi[1][1];
let q11 = 1.0 - phi[1][0] * phi[1][0] - phi[1][1] * phi[1][1];
let l00 = q00.max(0.0).sqrt();
let l10 = if l00 > 0.0 { q01 / l00 } else { 0.0 };
let l11 = (q11 - l10 * l10).max(0.0).sqrt();
(phi, [[l00, 0.0], [l10, l11]])
}
impl Bw2 {
fn stationary(rng: &mut SimRng) -> Self {
Self {
u: [rng.sample(StandardNormal), rng.sample(StandardNormal)],
}
}
fn step(&mut self, phi: &[[f64; 2]; 2], l: &[[f64; 2]; 2], rng: &mut SimRng) {
let w0: f64 = rng.sample(StandardNormal);
let w1: f64 = rng.sample(StandardNormal);
let u = self.u;
self.u = [
phi[0][0] * u[0] + phi[0][1] * u[1] + l[0][0] * w0,
phi[1][0] * u[0] + phi[1][1] * u[1] + l[1][0] * w0 + l[1][1] * w1,
];
}
}
#[derive(Clone, Debug)]
struct SatState {
rng: SimRng,
re: Bw2,
im: Bw2,
ph: Bw2,
last_t: f64,
}
#[derive(Clone, Debug)]
pub struct Scintillation {
pub params: ScintParams,
seed: u64,
sats: BTreeMap<u32, SatState>,
}
impl Scintillation {
pub fn new(params: ScintParams, seed: u64) -> Self {
Self {
params,
seed,
sats: BTreeMap::new(),
}
}
pub fn sample(&mut self, sat: u32, t_s: f64) -> ScintSample {
let p = self.params;
let seed = self.seed;
let st = self.sats.entry(sat).or_insert_with(|| {
let mut rng = SimRng::seed(sat_seed(seed, sat, STREAM));
let re = Bw2::stationary(&mut rng);
let im = Bw2::stationary(&mut rng);
let ph = Bw2::stationary(&mut rng);
SatState {
rng,
re,
im,
ph,
last_t: t_s,
}
});
let dt = t_s - st.last_t;
if dt > 0.0 {
let (phi, l) = transition(p.omega_n(), dt);
st.re.step(&phi, &l, &mut st.rng);
st.im.step(&phi, &l, &mut st.rng);
st.ph.step(&phi, &l, &mut st.rng);
st.last_t = t_s;
}
let pd = p.diffuse_power();
let sigma = (pd / 2.0).sqrt();
let zr = (1.0 - pd).sqrt() + sigma * st.re.u[0];
let zi = sigma * st.im.u[0];
ScintSample {
amplitude: zr.hypot(zi),
phase_rad: zi.atan2(zr) + p.extra_phase_sigma_rad * st.ph.u[0],
}
}
pub fn series(&mut self, sat: u32, dt_s: f64, n: usize) -> Vec<ScintSample> {
let t0 = self.sats.get(&sat).map_or(0.0, |s| s.last_t);
(0..n)
.map(|k| self.sample(sat, t0 + k as f64 * dt_s))
.collect()
}
}
impl ChannelEffect for Scintillation {
fn apply(
&mut self,
sat: u32,
t_s: f64,
_geom: &LineOfSight,
_carrier_hz: f64,
snap: &mut ChannelSnapshot,
) {
let z = self.sample(sat, t_s);
for p in &mut snap.paths {
p.amplitude *= z.amplitude;
p.carrier_phase_rad += z.phase_rad;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn s4_of(xs: &[ScintSample]) -> f64 {
let n = xs.len() as f64;
let m1 = xs.iter().map(|s| s.amplitude.powi(2)).sum::<f64>() / n;
let m2 = xs.iter().map(|s| s.amplitude.powi(4)).sum::<f64>() / n;
(m2 - m1 * m1).sqrt() / m1
}
#[test]
fn butterworth_constant_solves_the_closed_form_autocorrelation() {
let x = BUTTERWORTH_1_OVER_E_X;
let r = (-x).exp() * (x.cos() + x.sin());
assert!((r - (-1f64).exp()).abs() < 1e-14);
}
#[test]
fn rice_diffuse_power_reproduces_s4_in_closed_form() {
for s4 in [0.1, 0.4, 0.7, 1.0] {
let pd = ScintParams::new(s4, 1.0).diffuse_power();
assert!(((pd * (2.0 - pd)).sqrt() - s4).abs() < 1e-12);
}
}
#[test]
fn sample_s4_matches_target_within_statistical_tolerance() {
for (k, s4) in [0.3, 0.6, 0.9].into_iter().enumerate() {
let tau0 = 0.4;
let mut sc = Scintillation::new(ScintParams::new(s4, tau0), 11 + k as u64);
let xs = sc.series(5, tau0 / 4.0, 400_000);
let got = s4_of(&xs);
assert!((got - s4).abs() / s4 < 0.04, "S4 target {s4}, got {got}");
let mean_i = xs.iter().map(|s| s.amplitude.powi(2)).sum::<f64>() / xs.len() as f64;
assert!((mean_i - 1.0).abs() < 0.02, "{mean_i}");
}
}
#[test]
fn autocorrelation_decorrelation_time_matches_tau0() {
let tau0 = 0.5;
let dt = tau0 / 20.0;
let p = ScintParams::new(0.7, tau0);
let zhat = (1.0 - p.diffuse_power()).sqrt();
let mut sc = Scintillation::new(p, 3);
let xs: Vec<(f64, f64)> = sc
.series(9, dt, 600_000)
.iter()
.map(|s| {
let (si, co) = s.phase_rad.sin_cos();
(s.amplitude * co - zhat, s.amplitude * si)
})
.collect();
let n = xs.len();
let r = |lag: usize| {
(0..n - lag)
.map(|i| xs[i].0 * xs[i + lag].0 + xs[i].1 * xs[i + lag].1)
.sum::<f64>()
/ (n - lag) as f64
};
let r0 = r(0);
let target = (-1f64).exp();
let mut prev = 1.0;
let mut lag_s = f64::NAN;
for lag in 1..200 {
let rho = r(lag) / r0;
if rho < target {
let frac = (prev - target) / (prev - rho);
lag_s = ((lag - 1) as f64 + frac) * dt;
break;
}
prev = rho;
}
assert!(
(lag_s - tau0).abs() / tau0 < 0.05,
"1/e lag {lag_s} vs τ0 {tau0}"
);
}
#[test]
fn same_seed_is_bit_identical_and_independent_of_query_order() {
let p = ScintParams {
extra_phase_sigma_rad: 0.2,
..ScintParams::new(0.5, 0.3)
};
let mut a = Scintillation::new(p, 42);
let mut b = Scintillation::new(p, 42);
let mut sa = Vec::new();
let mut sb7 = Vec::new();
let mut sb2 = Vec::new();
for k in 0..500 {
let t = k as f64 * 0.02;
sa.push((a.sample(2, t), a.sample(7, t)));
sb7.push(b.sample(7, t));
sb2.push(b.sample(2, t));
}
for k in 0..500 {
assert_eq!(sa[k].0, sb2[k]);
assert_eq!(sa[k].1, sb7[k]);
}
let mut c = Scintillation::new(p, 43);
assert_ne!(c.sample(2, 0.0), Scintillation::new(p, 42).sample(2, 0.0));
}
#[test]
fn extra_phase_term_has_its_stated_standard_deviation() {
let p = ScintParams {
extra_phase_sigma_rad: 0.3,
..ScintParams::new(0.0, 0.2)
};
let mut sc = Scintillation::new(p, 8);
let xs = sc.series(1, 0.05, 200_000);
let n = xs.len() as f64;
let m = xs.iter().map(|s| s.phase_rad).sum::<f64>() / n;
let sd = (xs.iter().map(|s| (s.phase_rad - m).powi(2)).sum::<f64>() / n).sqrt();
assert!((sd - 0.3).abs() / 0.3 < 0.04, "{sd}");
assert!(xs.iter().all(|s| (s.amplitude - 1.0).abs() < 1e-12));
}
}