use std::f64::consts::PI;
const NOLL: [f64; 22] = [
0.0, 1.0299, 0.582, 0.134, 0.111, 0.0880, 0.0648, 0.0587, 0.0525, 0.0463, 0.0401, 0.0377,
0.0352, 0.0328, 0.0304, 0.0279, 0.0267, 0.0255, 0.0243, 0.0231, 0.0220, 0.0208,
];
#[derive(Debug, Clone)]
pub enum Cn2Profile {
Constant(f64),
HufnagelValley {
v_rms: f64,
cn2_0: f64,
},
SlcDay,
Clear1,
Custom(Vec<(f64, f64)>),
}
impl Cn2Profile {
pub fn cn2_at_height(&self, h_m: f64) -> f64 {
match self {
Cn2Profile::Constant(c) => *c,
Cn2Profile::HufnagelValley { v_rms, cn2_0 } => {
let h_km = h_m * 1e-3;
let h5 = 1e-5 * h_m; let term1 = 5.94e-23 * (v_rms / 27.0).powi(2) * h5.powi(10) * (-h_km / 1.0).exp();
let term2 = 2.7e-16 * (-h_km / 1.5).exp();
let term3 = cn2_0 * (-h_m / 100.0).exp();
term1 + term2 + term3
}
Cn2Profile::SlcDay => {
let h = h_m * 1e-3;
if h < 0.019 {
1.703e-14
} else if h < 0.23 {
8.874e-15 * (-0.1046 * h).exp()
} else if h < 1.026 {
2.241e-15 * (0.1032 * h).exp()
} else if h < 7.0 {
1.136e-16 * (-0.1003 * h).exp()
} else if h < 15.81 {
8.474e-17 * (-0.1005 * h).exp()
} else if h < 22.91 {
3.297e-20 * (0.1005 * h).exp()
} else {
3.681e-17 * (-0.1004 * h).exp()
}
}
Cn2Profile::Clear1 => {
let h = h_m * 1e-3;
if h < 0.1 {
6.352e-7 * (-h / 0.105).exp()
} else if h < 1.5 {
6.209e-16 / (h + 0.1).powf(10.0 / 3.0)
} else if h < 7.2 {
3.981e-16 * (-h / 2.0).exp()
} else if h < 20.0 {
1.0e-16 * (-h / 6.0).exp()
} else {
1.0e-22
}
}
Cn2Profile::Custom(pts) => {
if pts.is_empty() {
return 0.0;
}
if h_m <= pts[0].0 {
return pts[0].1;
}
if h_m >= pts[pts.len() - 1].0 {
return pts[pts.len() - 1].1;
}
for i in 0..pts.len() - 1 {
let (h0, c0) = pts[i];
let (h1, c1) = pts[i + 1];
if h_m >= h0 && h_m <= h1 {
let t = (h_m - h0) / (h1 - h0);
return c0 + t * (c1 - c0);
}
}
pts[pts.len() - 1].1
}
}
}
pub fn path_integrated(&self, h_start: f64, h_end: f64, n_steps: usize) -> f64 {
let n = n_steps.max(2);
let dh = (h_end - h_start) / n as f64;
let mut sum = 0.0;
for i in 0..=n {
let h = h_start + i as f64 * dh;
let w = if i == 0 || i == n { 0.5 } else { 1.0 };
sum += w * self.cn2_at_height(h);
}
sum * dh
}
}
#[derive(Debug, Clone)]
pub struct AtmosphericPath {
pub length_km: f64,
pub cn2_profile: Cn2Profile,
pub wavelength: f64,
pub elevation_deg: f64,
pub h_start_m: f64,
}
impl AtmosphericPath {
pub fn new_horizontal(length_km: f64, cn2: f64, wavelength: f64) -> Self {
Self {
length_km,
cn2_profile: Cn2Profile::Constant(cn2),
wavelength,
elevation_deg: 0.0,
h_start_m: 0.0,
}
}
pub fn new_vertical_slant(length_km: f64, elevation_deg: f64, wavelength: f64) -> Self {
Self {
length_km,
cn2_profile: Cn2Profile::HufnagelValley {
v_rms: 21.0,
cn2_0: 1.7e-14,
},
wavelength,
elevation_deg,
h_start_m: 0.0,
}
}
fn wave_number(&self) -> f64 {
2.0 * PI / self.wavelength
}
fn length_m(&self) -> f64 {
self.length_km * 1e3
}
fn cn2_integrated(&self) -> f64 {
let l = self.length_m();
let elev_rad = self.elevation_deg.to_radians();
if elev_rad.abs() < 1e-4 {
self.cn2_profile.path_integrated(0.0, l, 1000)
} else {
let h_end = self.h_start_m + l * elev_rad.sin();
let sin_el = elev_rad.sin().abs().max(1e-6);
self.cn2_profile
.path_integrated(self.h_start_m, h_end, 1000)
/ sin_el
}
}
pub fn fried_parameter_m(&self) -> f64 {
let k = self.wave_number();
let cn2_int = self.cn2_integrated();
(0.423 * k * k * cn2_int).powf(-3.0 / 5.0)
}
pub fn rytov_variance(&self) -> f64 {
let k = self.wave_number();
let l = self.length_m();
let cn2 = self.cn2_profile.cn2_at_height(self.h_start_m);
1.23 * cn2 * k.powf(7.0 / 6.0) * l.powf(11.0 / 6.0)
}
fn log_amplitude_variance(&self) -> f64 {
0.307 * self.rytov_variance()
}
pub fn scintillation_index(&self) -> f64 {
let sr = self.rytov_variance();
if sr < 0.3 {
let sigma_chi2 = self.log_amplitude_variance();
(4.0 * sigma_chi2).exp() - 1.0
} else if sr < 5.0 {
let sigma_chi2 = self.log_amplitude_variance();
let weak_si = (4.0 * sigma_chi2).exp() - 1.0;
let correction = 1.0 - (1.0 + 0.47 * sr.powf(6.0 / 5.0)).powf(-7.0 / 6.0);
weak_si * correction / sr.sqrt()
} else {
1.0 + 2.0 / sr
}
}
pub fn coherence_length_m(&self) -> f64 {
self.fried_parameter_m()
}
pub fn isoplanatic_angle_urad(&self) -> f64 {
let r0 = self.fried_parameter_m();
let l = self.length_m();
let h_eff = l / 2.0; let theta_rad = 0.314 * r0 / h_eff.max(1.0);
theta_rad * 1e6 }
pub fn coherence_time_ms(&self, wind_speed_m_per_s: f64) -> f64 {
let r0 = self.fried_parameter_m();
let v = wind_speed_m_per_s.max(0.1);
0.314 * r0 / v * 1e3 }
pub fn regime(&self) -> TurbulenceRegime {
let sr = self.rytov_variance();
if sr < 0.3 {
TurbulenceRegime::Weak
} else if sr <= 1.0 {
TurbulenceRegime::Moderate
} else {
TurbulenceRegime::Strong
}
}
pub fn strehl_ratio_free(&self) -> f64 {
let _r0 = self.fried_parameter_m();
let d_over_r0 = 5.0_f64; let sigma2_waves = NOLL[1] * d_over_r0.powf(5.0 / 3.0); let sigma2_rad = sigma2_waves * (2.0 * PI).powi(2);
(-sigma2_rad).exp().max(0.0)
}
pub fn strehl_ratio_ao(&self, ao_order: usize, aperture_m: f64) -> f64 {
let r0 = self.fried_parameter_m();
let d_over_r0 = aperture_m / r0;
let sigma2_waves = if ao_order == 0 {
NOLL[1] * d_over_r0.powf(5.0 / 3.0)
} else if ao_order < NOLL.len() {
NOLL[ao_order] * d_over_r0.powf(5.0 / 3.0)
} else {
0.2944 * (ao_order as f64).powf(-(3.0_f64.sqrt())) * d_over_r0.powf(5.0 / 3.0)
};
let sigma2_rad2 = sigma2_waves * (2.0 * PI).powi(2);
(-sigma2_rad2).exp().max(0.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TurbulenceRegime {
Weak,
Moderate,
Strong,
}
#[derive(Debug, Clone)]
pub struct LogNormalScintillation {
pub scintillation_index: f64,
pub mean_irradiance: f64,
}
impl LogNormalScintillation {
pub fn new(sigma_i: f64, mean_i: f64) -> Self {
Self {
scintillation_index: sigma_i.clamp(1e-10, 100.0),
mean_irradiance: mean_i.max(1e-30),
}
}
fn sigma_chi2(&self) -> f64 {
0.25 * (1.0 + self.scintillation_index).ln()
}
pub fn pdf(&self, irradiance: f64) -> f64 {
if irradiance <= 0.0 {
return 0.0;
}
let sc2 = self.sigma_chi2();
let sc = sc2.sqrt();
let mu = self.mean_irradiance * (-sc2).exp(); let arg = (irradiance / mu).ln() / sc;
let norm = irradiance * sc * (2.0 * PI).sqrt();
(-0.5 * arg * arg).exp() / norm
}
pub fn cdf(&self, threshold: f64) -> f64 {
if threshold <= 0.0 {
return 0.0;
}
let sc2 = self.sigma_chi2();
let sc = sc2.sqrt();
let mu = self.mean_irradiance * (-sc2).exp();
let arg = -(threshold / mu).ln() / (sc * 2.0_f64.sqrt());
0.5 * erfc_approx(arg)
}
pub fn fade_threshold(&self, p_fade: f64) -> f64 {
let p = p_fade.clamp(1e-10, 1.0 - 1e-10);
let sc2 = self.sigma_chi2();
let sc = sc2.sqrt();
let erfinv_val = erfinv_approx(1.0 - 2.0 * p);
let ln_it_over_mean = erfinv_val * sc * 2.0_f64.sqrt() - sc2;
-10.0 * std::f64::consts::LOG10_E * ln_it_over_mean
}
pub fn mean_snr_penalty_db(&self) -> f64 {
10.0 * (1.0 + self.scintillation_index).log10()
}
}
#[derive(Debug, Clone)]
pub struct GammaGammaDistribution {
pub alpha: f64,
pub beta: f64,
pub mean_irradiance: f64,
}
impl GammaGammaDistribution {
pub fn from_scintillation_index(si: f64, mean_i: f64) -> Self {
let si_clamped = si.max(0.01);
let sr = si_clamped; let sigma_ln_x2 = 0.49 * sr / (1.0 + 1.11 * sr.powf(6.0 / 5.0)).powf(7.0 / 6.0);
let sigma_ln_y2 = 0.51 * sr / (1.0 + 0.69 * sr.powf(6.0 / 5.0)).powf(5.0 / 6.0);
let alpha = 1.0 / (sigma_ln_x2.exp() - 1.0).max(1e-6);
let beta = 1.0 / (sigma_ln_y2.exp() - 1.0).max(1e-6);
Self {
alpha: alpha.min(1e6),
beta: beta.min(1e6),
mean_irradiance: mean_i.max(1e-30),
}
}
fn gamma_fn(x: f64) -> f64 {
const G: f64 = 7.0;
const C: [f64; 9] = [
0.999_999_999_999_809_9,
676.520_368_121_885_1,
-1_259.139_216_722_402_9,
771.323_428_777_653_1,
-176.615_029_162_140_6,
12.507_343_278_686_905,
-0.138_571_095_265_720_12,
9.984_369_578_019_572e-6,
1.505_632_735_149_311_6e-7,
];
if x < 0.5 {
PI / ((PI * x).sin() * Self::gamma_fn(1.0 - x))
} else {
let z = x - 1.0;
let mut sum = C[0];
for (i, &ci) in C[1..].iter().enumerate() {
sum += ci / (z + i as f64 + 1.0);
}
let t = z + G + 0.5;
(2.0 * PI).sqrt() * t.powf(z + 0.5) * (-t).exp() * sum
}
}
fn bessel_k0(x: f64) -> f64 {
if x <= 0.0 {
return f64::INFINITY;
}
if x <= 2.0 {
let t1 = (x / 3.75) * (x / 3.75);
let i0 = 1.0
+ 3.515_622_9 * t1
+ 3.089_942_4 * t1 * t1
+ 1.206_749_2 * t1.powi(3)
+ 0.265_973_2 * t1.powi(4)
+ 0.036_076_8 * t1.powi(5)
+ 0.004_581_3 * t1.powi(6);
let t2 = x * x / 4.0;
let poly = -0.577_215_65
+ 0.422_784_20 * t2
+ 0.230_697_56 * t2 * t2
+ 0.034_885_90 * t2.powi(3)
+ 0.002_626_98 * t2.powi(4)
+ 0.000_107_50 * t2.powi(5)
+ 0.000_007_40 * t2.powi(6);
-(x / 2.0).ln() * i0 + poly
} else {
let t = 2.0 / x;
let poly = 1.253_314_14 - 0.078_323_58 * t + 0.021_895_68 * t * t
- 0.010_624_46 * t.powi(3)
+ 0.005_878_72 * t.powi(4)
- 0.002_515_40 * t.powi(5)
+ 0.000_532_08 * t.powi(6);
(-x).exp() / x.sqrt() * poly
}
}
fn bessel_k1(x: f64) -> f64 {
if x <= 0.0 {
return f64::INFINITY;
}
if x <= 2.0 {
let t1 = (x / 3.75) * (x / 3.75);
let i1_over_x = 0.5
+ 0.878_905_94 * t1
+ 0.514_988_69 * t1 * t1
+ 0.150_849_34 * t1.powi(3)
+ 0.026_587_33 * t1.powi(4)
+ 0.003_015_32 * t1.powi(5)
+ 0.000_324_11 * t1.powi(6);
let i1 = x * i1_over_x;
let t2 = x * x / 4.0;
let poly = 1.0 + 0.154_431_44 * t2
- 0.672_785_79 * t2 * t2
- 0.181_568_97 * t2.powi(3)
- 0.019_194_02 * t2.powi(4)
- 0.001_104_04 * t2.powi(5)
- 0.000_046_86 * t2.powi(6);
(1.0 / x) * poly + (x / 2.0).ln() * i1
} else {
let t = 2.0 / x;
let poly = 1.253_314_14 + 0.234_986_19 * t - 0.036_556_20 * t * t
+ 0.015_042_68 * t.powi(3)
- 0.007_803_53 * t.powi(4)
+ 0.003_256_14 * t.powi(5)
- 0.000_682_45 * t.powi(6);
(-x).exp() / x.sqrt() * poly
}
}
fn bessel_knu(nu: f64, x: f64) -> f64 {
if x <= 0.0 {
return f64::INFINITY;
}
let nu_abs = nu.abs();
let n_floor = nu_abs.floor() as usize;
let frac = nu_abs - n_floor as f64;
let k0 = Self::bessel_k0(x);
if n_floor == 0 && frac < 1e-10 {
return k0;
}
let k1 = Self::bessel_k1(x);
if n_floor == 1 && frac < 1e-10 {
return k1;
}
let mut k_prev = k0;
let mut k_curr = k1;
for n in 1..n_floor {
let k_next = k_prev + (2.0 * n as f64 / x) * k_curr;
k_prev = k_curr;
k_curr = k_next;
}
if frac < 1e-10 {
return k_curr;
}
let k_next = k_prev + (2.0 * n_floor as f64 / x) * k_curr;
k_curr + frac * (k_next - k_curr)
}
pub fn pdf(&self, irradiance: f64) -> f64 {
if irradiance <= 0.0 {
return 0.0;
}
let a = self.alpha;
let b = self.beta;
let im = self.mean_irradiance;
let i_norm = irradiance / im; let nu = (a - b).abs();
let sqrt_ab = (a * b).sqrt();
let arg = 2.0 * sqrt_ab * i_norm.sqrt();
let knu = Self::bessel_knu(nu, arg);
let gamma_a = Self::gamma_fn(a);
let gamma_b = Self::gamma_fn(b);
let prefactor = 2.0 * (a * b).powf((a + b) / 2.0) / (gamma_a * gamma_b * im);
prefactor * i_norm.powf((a + b) / 2.0 - 1.0) * knu
}
pub fn outage_probability(&self, threshold: f64) -> f64 {
if threshold <= 0.0 {
return 0.0;
}
let n = 200usize;
let eps = threshold * 1e-6;
let dt = (threshold - eps) / n as f64;
let mut sum = 0.0;
for i in 0..=n {
let t = eps + i as f64 * dt;
let w = if i == 0 || i == n {
1.0
} else if i % 2 == 1 {
4.0
} else {
2.0
};
sum += w * self.pdf(t);
}
(sum * dt / 3.0).clamp(0.0, 1.0)
}
pub fn mean_ber(&self, snr_db: f64) -> f64 {
let snr = 10.0_f64.powf(snr_db / 10.0);
let n = 500usize;
let i_max = 10.0 * self.mean_irradiance;
let eps = i_max * 1e-5;
let di = (i_max - eps) / n as f64;
let mut sum = 0.0;
for i in 0..=n {
let irr = eps + i as f64 * di;
let w = if i == 0 || i == n {
1.0
} else if i % 2 == 1 {
4.0
} else {
2.0
};
let ber_cond = 0.5 * erfc_approx(snr * irr / self.mean_irradiance / 2.0_f64.sqrt());
sum += w * ber_cond * self.pdf(irr);
}
(sum * di / 3.0).clamp(0.0, 1.0)
}
}
pub(crate) fn erfc_approx(x: f64) -> f64 {
if x < 0.0 {
return 2.0 - erfc_approx(-x);
}
let t = 1.0 / (1.0 + 0.327_591_1 * x);
let poly = t
* (0.254_829_592
+ t * (-0.284_496_736
+ t * (1.421_413_741 + t * (-1.453_152_027 + t * 1.061_405_429))));
poly * (-(x * x)).exp()
}
pub(crate) fn erfinv_approx(y: f64) -> f64 {
let y = y.clamp(-1.0 + 1e-15, 1.0 - 1e-15);
let p = (y + 1.0) / 2.0;
let ln_p = (-2.0 * p * (1.0 - p)).ln();
let t = if p < 0.5 {
(-2.0 * p.ln()).sqrt()
} else {
(-2.0 * (1.0 - p).ln()).sqrt()
};
let c0 = 2.515_517;
let c1 = 0.802_853;
let c2 = 0.010_328;
let d1 = 1.432_788;
let d2 = 0.189_269;
let d3 = 0.001_308;
let num = c0 + c1 * t + c2 * t * t;
let den = 1.0 + d1 * t + d2 * t * t + d3 * t * t * t;
let mut x = if p < 0.5 {
-(t - num / den)
} else {
t - num / den
};
let fx = libm_erf(x) - y;
let fpx = (2.0 / PI.sqrt()) * (-(x * x)).exp();
x -= fx / (fpx + x * fx);
let _ = ln_p;
x
}
fn libm_erf(x: f64) -> f64 {
1.0 - erfc_approx(x)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_fried_parameter_strong() {
let path = AtmosphericPath::new_horizontal(1.0, 1e-13, 1550e-9);
let r0 = path.fried_parameter_m();
assert!(r0 > 0.0 && r0 < 0.1, "r0 = {r0:.4e} m (expected < 10 cm)");
}
#[test]
fn test_fried_parameter_weak() {
let path = AtmosphericPath::new_horizontal(1.0, 1e-17, 1550e-9);
let r0 = path.fried_parameter_m();
assert!(
r0 > 0.1,
"r0 = {r0:.4e} m (expected > 0.1 m for weak turbulence)"
);
}
#[test]
fn test_rytov_regime_weak() {
let path = AtmosphericPath::new_horizontal(0.5, 1e-17, 1550e-9);
assert_eq!(path.regime(), TurbulenceRegime::Weak);
}
#[test]
fn test_lognormal_cdf_normalisation() {
let ln = LogNormalScintillation::new(0.2, 1.0);
let cdf_high = ln.cdf(1e6);
assert!((cdf_high - 1.0).abs() < 0.01, "CDF(∞) = {cdf_high}");
}
#[test]
fn test_gamma_gamma_pdf_integral() {
let gg = GammaGammaDistribution::from_scintillation_index(0.5, 1.0);
let n = 2000;
let i_max = 10.0;
let di = i_max / n as f64;
let mut integral = 0.0;
for i in 1..n {
integral += gg.pdf(i as f64 * di);
}
integral *= di;
assert!(
(integral - 1.0).abs() < 0.05,
"GG PDF integral = {integral:.4}"
);
}
#[test]
fn test_coherence_time_wind_dependence() {
let path = AtmosphericPath::new_horizontal(1.0, 1e-14, 1064e-9);
let tau_slow = path.coherence_time_ms(5.0);
let tau_fast = path.coherence_time_ms(20.0);
assert!(
tau_slow > tau_fast,
"Coherence time should decrease with wind speed"
);
}
#[test]
fn test_hv_profile_altitude_dependence() {
let profile = Cn2Profile::HufnagelValley {
v_rms: 21.0,
cn2_0: 1.7e-14,
};
let cn2_ground = profile.cn2_at_height(0.0);
let cn2_high = profile.cn2_at_height(20_000.0);
assert!(cn2_ground > cn2_high, "C_n² should decrease with altitude");
}
#[test]
fn test_bessel_k0() {
let k0 = GammaGammaDistribution::bessel_k0(1.0);
assert!((k0 - 0.4210).abs() < 0.01, "K_0(1) = {k0:.4}");
}
#[test]
fn test_erfc_boundary() {
assert!((erfc_approx(0.0) - 1.0).abs() < 1e-6);
assert!(erfc_approx(10.0) < 1e-20);
}
}