use super::turbulence::AtmosphericPath;
use std::f64::consts::PI;
#[derive(Debug, Clone)]
pub struct BeamWander {
pub beam_waist_m: f64,
pub wavelength: f64,
pub path: AtmosphericPath,
pub link_distance_km: f64,
}
impl BeamWander {
pub fn new(beam_waist: f64, wavelength: f64, path: AtmosphericPath, dist_km: f64) -> Self {
Self {
beam_waist_m: beam_waist.max(1e-6),
wavelength,
path,
link_distance_km: dist_km.max(0.001),
}
}
fn rayleigh_range_m(&self) -> f64 {
PI * self.beam_waist_m * self.beam_waist_m / self.wavelength
}
fn link_m(&self) -> f64 {
self.link_distance_km * 1e3
}
fn vacuum_spot_size_m(&self) -> f64 {
let z = self.link_m();
let zr = self.rayleigh_range_m();
self.beam_waist_m * (1.0 + (z / zr).powi(2)).sqrt()
}
pub fn short_term_spot_size_m(&self) -> f64 {
let w_vac = self.vacuum_spot_size_m();
let r0 = self.path.fried_parameter_m();
let sr = self.path.rytov_variance();
let w0 = self.beam_waist_m;
let ratio = (w0 / r0).powf(-1.0 / 3.0); let t_turb = 1.63 * sr.powf(6.0 / 5.0) * ratio;
let w_st2 = w_vac * w_vac + w0 * w0 * t_turb.max(0.0);
w_st2.sqrt()
}
pub fn wander_variance_m2(&self) -> f64 {
let cn2 = self.path.cn2_profile.cn2_at_height(self.path.h_start_m);
let l = self.link_m();
let w0 = self.beam_waist_m;
let k = 2.0 * PI / self.wavelength;
2.42 * cn2 * k.powf(-1.0 / 3.0) * l.powi(3) * w0.powf(-1.0 / 3.0)
}
pub fn rms_wander_m(&self) -> f64 {
self.wander_variance_m2().sqrt()
}
pub fn long_term_spot_size_m(&self) -> f64 {
let w_st = self.short_term_spot_size_m();
let wander = self.wander_variance_m2();
(w_st * w_st + wander).sqrt()
}
pub fn pointing_error_after_correction_m(&self, n_zernike_modes: usize) -> f64 {
let sigma_wander = self.rms_wander_m();
let correction_frac = match n_zernike_modes {
0 => 0.0,
1 => 0.0, 2 | 3 => 0.85, 4..=10 => 0.92,
_ => 0.97,
};
sigma_wander * (1.0_f64 - correction_frac).sqrt()
}
pub fn wander_induced_fade_db(&self, aperture_m: f64) -> f64 {
let r_ap = aperture_m / 2.0;
let sigma_sq = self.wander_variance_m2() / 2.0; let w_lt = self.long_term_spot_size_m();
if sigma_sq <= 0.0 || w_lt <= 0.0 {
return 0.0;
}
let static_capture = 1.0 - (-2.0 * r_ap * r_ap / (w_lt * w_lt)).exp();
let eta0 = static_capture;
let wander_capture = {
let n = 400;
let r_max = 6.0 * sigma_sq.sqrt();
let dr = r_max / n as f64;
let mut integral = 0.0;
for i in 0..=n {
let r = i as f64 * dr;
let rayleigh = if i == 0 {
0.0
} else {
r / sigma_sq * (-r * r / (2.0 * sigma_sq)).exp()
};
let eta_r = eta0 * (-r * r / (r_ap * r_ap)).exp();
let w = if i == 0 || i == n { 0.5 } else { 1.0 };
integral += w * rayleigh * eta_r;
}
integral * dr
};
if static_capture <= 0.0 || wander_capture <= 0.0 {
return 0.0;
}
let ratio = wander_capture / static_capture;
(-10.0 * ratio.log10()).max(0.0)
}
pub fn wander_pdf(&self, displacement_m: f64) -> f64 {
if displacement_m < 0.0 {
return 0.0;
}
let sigma_sq = self.wander_variance_m2() / 2.0;
if sigma_sq <= 0.0 {
return 0.0;
}
let r = displacement_m;
(r / sigma_sq) * (-r * r / (2.0 * sigma_sq)).exp()
}
}
#[derive(Debug, Clone)]
pub struct TipTiltCorrection {
pub wander: BeamWander,
pub update_rate_hz: f64,
pub residual_error_fraction: f64,
}
impl TipTiltCorrection {
pub fn new(wander: BeamWander, bandwidth_hz: f64) -> Self {
let bw = bandwidth_hz.max(0.1);
let cn2 = wander.path.cn2_profile.cn2_at_height(wander.path.h_start_m);
let k = 2.0 * PI / wander.wavelength;
let l = wander.link_m();
let v_wind = 10.0_f64; let f_g = 0.102 * (k * k * cn2 * v_wind * l).powf(0.6);
let residual = (f_g / bw).powf(5.0 / 3.0).min(1.0);
Self {
wander,
update_rate_hz: bw,
residual_error_fraction: residual,
}
}
pub fn residual_wander_m(&self) -> f64 {
self.wander.rms_wander_m() * self.residual_error_fraction.sqrt()
}
pub fn correction_efficiency(&self) -> f64 {
(1.0 - self.residual_error_fraction).clamp(0.0, 1.0)
}
pub fn improvement_db(&self, aperture_m: f64) -> f64 {
let fade_no_corr = self.wander.wander_induced_fade_db(aperture_m);
let mut corrected_path = self.wander.path.clone();
let cn2_eff =
self.wander.path.cn2_profile.cn2_at_height(0.0) * self.residual_error_fraction;
corrected_path.cn2_profile = super::turbulence::Cn2Profile::Constant(cn2_eff);
let corrected_wander = BeamWander::new(
self.wander.beam_waist_m,
self.wander.wavelength,
corrected_path,
self.wander.link_distance_km,
);
let fade_corrected = corrected_wander.wander_induced_fade_db(aperture_m);
(fade_no_corr - fade_corrected).max(0.0)
}
}
#[cfg(test)]
mod tests {
use super::super::turbulence::AtmosphericPath;
use super::*;
fn make_wander(cn2: f64, dist_km: f64) -> BeamWander {
let path = AtmosphericPath::new_horizontal(dist_km, cn2, 1550e-9);
BeamWander::new(0.05, 1550e-9, path, dist_km)
}
#[test]
fn test_lt_ge_st_spot_size() {
let bw = make_wander(1e-14, 2.0);
assert!(
bw.long_term_spot_size_m() >= bw.short_term_spot_size_m(),
"LT = {} ST = {}",
bw.long_term_spot_size_m(),
bw.short_term_spot_size_m()
);
}
#[test]
fn test_wander_variance_positive() {
let bw = make_wander(1e-14, 2.0);
assert!(bw.wander_variance_m2() > 0.0);
}
#[test]
fn test_wander_increases_with_cn2() {
let bw_weak = make_wander(1e-17, 2.0);
let bw_strong = make_wander(1e-13, 2.0);
assert!(bw_strong.wander_variance_m2() > bw_weak.wander_variance_m2());
}
#[test]
fn test_rayleigh_pdf_normalisation() {
let bw = make_wander(1e-14, 2.0);
let sigma = bw.rms_wander_m() / 2.0_f64.sqrt();
let n = 1000;
let r_max = 8.0 * sigma;
let dr = r_max / n as f64;
let mut integral = 0.0;
for i in 0..=n {
let r = i as f64 * dr;
let w = if i == 0 || i == n { 0.5 } else { 1.0 };
integral += w * bw.wander_pdf(r);
}
integral *= dr;
assert!(
(integral - 1.0).abs() < 0.02,
"PDF integral = {integral:.4}"
);
}
#[test]
fn test_correction_residual_decreases_with_modes() {
let bw = make_wander(1e-14, 2.0);
let e0 = bw.pointing_error_after_correction_m(0);
let e2 = bw.pointing_error_after_correction_m(2);
let e10 = bw.pointing_error_after_correction_m(10);
assert!(e0 >= e2, "e0={e0:.4e} e2={e2:.4e}");
assert!(e2 >= e10, "e2={e2:.4e} e10={e10:.4e}");
}
#[test]
fn test_tip_tilt_high_bandwidth() {
let bw = make_wander(1e-14, 2.0);
let tt = TipTiltCorrection::new(bw, 10_000.0);
assert!(
tt.correction_efficiency() > 0.5,
"efficiency = {}",
tt.correction_efficiency()
);
}
#[test]
fn test_fade_non_negative() {
let bw = make_wander(1e-14, 2.0);
assert!(bw.wander_induced_fade_db(0.2) >= 0.0);
}
#[test]
fn test_vacuum_beam_no_wander() {
let path = AtmosphericPath::new_horizontal(1.0, 1e-30, 1550e-9);
let bw = BeamWander::new(0.05, 1550e-9, path, 1.0);
let wander = bw.wander_variance_m2();
assert!(
wander < 1e-20,
"Wander in vacuum should be ~0, got {wander:.4e}"
);
}
}