use crate::jamming::{j_over_s_db, C_M_PER_S};
use crate::lunar::R_MOON_M;
use crate::sweep::SweepAxis;
use serde::Serialize;
use std::f64::consts::PI;
pub const DEFAULT_APERTURE_EFFICIENCY: f64 = 0.60;
pub const AFS_RX_SIGNAL_DBW: f64 = -140.6;
pub const CAPTURE_THRESHOLD_DB: f64 = 3.0;
pub fn bessel_j1(x: f64) -> f64 {
let ax = x.abs();
if ax <= 3.0 {
let t2 = (x / 3.0) * (x / 3.0);
x * (0.5
+ t2 * (-0.562_499_85
+ t2 * (0.210_935_73
+ t2 * (-0.039_542_89
+ t2 * (0.004_433_19 + t2 * (-0.000_317_61 + t2 * 0.000_011_09))))))
} else {
let t = 3.0 / ax;
let f1 = 0.797_884_56
+ t * (0.000_001_56
+ t * (0.016_596_67
+ t * (0.000_171_05
+ t * (-0.002_495_11 + t * (0.001_136_53 + t * (-0.000_200_33))))));
let theta1 = ax - 2.356_194_49
+ t * (0.124_996_12
+ t * (0.000_056_50
+ t * (-0.006_378_79
+ t * (0.000_743_48 + t * (0.000_798_24 + t * (-0.000_291_66))))));
let mag = f1 * theta1.cos() / ax.sqrt();
if x < 0.0 {
-mag
} else {
mag
}
}
}
pub fn boresight_gain_dbi(diameter_m: f64, freq_hz: f64, efficiency: f64) -> f64 {
let lambda = C_M_PER_S / freq_hz;
let g_lin = efficiency * (PI * diameter_m / lambda).powi(2);
10.0 * g_lin.log10()
}
pub fn pattern_gain_dbi(diameter_m: f64, freq_hz: f64, efficiency: f64, theta_rad: f64) -> f64 {
let g0 = boresight_gain_dbi(diameter_m, freq_hz, efficiency);
let lambda = C_M_PER_S / freq_hz;
let x = PI * diameter_m / lambda * theta_rad.sin();
let factor = if x.abs() < 1e-12 {
1.0
} else {
2.0 * bessel_j1(x) / x
};
g0 + 10.0 * (factor * factor).max(1e-30).log10()
}
pub const UNIFORM_APERTURE_HPBW_COEFF: f64 = 1.02;
pub fn half_power_beamwidth_rad(diameter_m: f64, freq_hz: f64) -> f64 {
let lambda = C_M_PER_S / freq_hz;
UNIFORM_APERTURE_HPBW_COEFF * lambda / diameter_m
}
pub fn first_null_angle_rad(diameter_m: f64, freq_hz: f64) -> Option<f64> {
let lambda = C_M_PER_S / freq_hz;
let s = 1.22 * lambda / diameter_m;
if s > 1.0 {
None
} else {
Some(s.asin())
}
}
pub const HALF_POWER_DROP_DB: f64 = 3.010_299_956_639_812;
pub const SYMMETRIC_GAIN_BEAMWIDTH_CONST_DEG2: f64 = 31_000.0;
pub fn symmetric_beamwidth_rad(gain_dbi: f64) -> f64 {
let g_lin = 10.0_f64.powf(gain_dbi / 10.0);
(SYMMETRIC_GAIN_BEAMWIDTH_CONST_DEG2 / g_lin)
.sqrt()
.to_radians()
}
pub fn symmetric_relation_implied_efficiency(beamwidth_coeff_rad: f64) -> f64 {
let k_deg = beamwidth_coeff_rad.to_degrees();
SYMMETRIC_GAIN_BEAMWIDTH_CONST_DEG2 / (k_deg * k_deg * PI * PI)
}
pub fn within_half_power_beam(
diameter_m: f64,
freq_hz: f64,
efficiency: f64,
theta_rad: f64,
) -> bool {
let g0 = boresight_gain_dbi(diameter_m, freq_hz, efficiency);
pattern_gain_dbi(diameter_m, freq_hz, efficiency, theta_rad) >= g0 - HALF_POWER_DROP_DB
}
#[derive(Clone, Copy, Debug)]
pub struct FootprintParams {
pub altitude_m: f64,
pub p_tx_dbw: f64,
pub diameter_m: f64,
pub freq_hz: f64,
pub efficiency: f64,
pub afs_rx_signal_dbw: f64,
pub capture_threshold_db: f64,
pub n_grid: usize,
}
impl FootprintParams {
pub fn new(
altitude_m: f64,
p_tx_dbw: f64,
diameter_m: f64,
freq_hz: f64,
n_grid: usize,
) -> Self {
Self {
altitude_m,
p_tx_dbw,
diameter_m,
freq_hz,
efficiency: DEFAULT_APERTURE_EFFICIENCY,
afs_rx_signal_dbw: AFS_RX_SIGNAL_DBW,
capture_threshold_db: CAPTURE_THRESHOLD_DB,
n_grid: n_grid.max(2),
}
}
}
#[derive(Clone, Copy, Debug, Serialize)]
pub struct FootprintPoint {
pub central_angle_rad: f64,
pub off_boresight_rad: f64,
pub slant_range_m: f64,
pub gain_dbi: f64,
pub js_db: f64,
pub captured: bool,
}
#[derive(Clone, Debug, Serialize)]
pub struct FootprintResult {
pub points: Vec<FootprintPoint>,
pub horizon_central_angle_rad: f64,
pub boresight_gain_dbi: f64,
pub captured_fraction: f64,
pub limb_captured: bool,
}
pub fn limb_central_angle_rad(altitude_m: f64) -> f64 {
(R_MOON_M / (R_MOON_M + altitude_m)).acos()
}
fn footprint_point_at(p: &FootprintParams, gamma: f64) -> (FootprintPoint, f64) {
let r = R_MOON_M;
let tx_z = r + p.altitude_m;
let (sg, cg) = gamma.sin_cos();
let sx = r * sg;
let sz = r * cg;
let dx = sx; let dz = sz - tx_z;
let slant = (dx * dx + dz * dz).sqrt();
let cos_theta = ((tx_z - r * cg) / slant).clamp(-1.0, 1.0);
let theta = cos_theta.acos();
let gain = pattern_gain_dbi(p.diameter_m, p.freq_hz, p.efficiency, theta);
let js = j_over_s_db(
p.p_tx_dbw,
gain,
0.0,
slant,
p.freq_hz,
p.afs_rx_signal_dbw,
0.0,
);
let captured = js >= p.capture_threshold_db;
(
FootprintPoint {
central_angle_rad: gamma,
off_boresight_rad: theta,
slant_range_m: slant,
gain_dbi: gain,
js_db: js,
captured,
},
sg,
)
}
pub fn limb_point(p: &FootprintParams) -> FootprintPoint {
let n = p.n_grid.max(2);
let gamma = limb_central_angle_rad(p.altitude_m) * ((n - 1) as f64) / ((n - 1) as f64);
footprint_point_at(p, gamma).0
}
pub fn capture_footprint(p: &FootprintParams) -> FootprintResult {
let gamma_max = limb_central_angle_rad(p.altitude_m);
let g0 = boresight_gain_dbi(p.diameter_m, p.freq_hz, p.efficiency);
let n = p.n_grid.max(2);
let mut points = Vec::with_capacity(n);
let mut weight_sum = 0.0;
let mut weight_captured = 0.0;
for i in 0..n {
let gamma = gamma_max * (i as f64) / ((n - 1) as f64);
let (pt, sin_gamma) = footprint_point_at(p, gamma);
let w = sin_gamma;
weight_sum += w;
if pt.captured {
weight_captured += w;
}
points.push(pt);
}
let captured_fraction = if weight_sum > 0.0 {
weight_captured / weight_sum
} else {
0.0
};
let limb_captured = points.last().map(|p| p.captured).unwrap_or(false);
FootprintResult {
points,
horizon_central_angle_rad: gamma_max,
boresight_gain_dbi: g0,
captured_fraction,
limb_captured,
}
}
const LIMB_BISECT_ITERS: usize = 100;
#[derive(Clone, Copy, Debug, Serialize)]
pub struct FootprintSweepPoint {
pub altitude_m: f64,
pub diameter_m: f64,
pub hpbw_rad: f64,
pub hpbw_deg: f64,
pub boresight_gain_dbi: f64,
pub horizon_central_angle_rad: f64,
pub captured_fraction: f64,
pub limb_js_db: f64,
pub limb_captured: bool,
pub limb_margin_db: f64,
pub limb_capture_tx_power_dbw: f64,
}
#[derive(Clone, Debug, Serialize)]
pub struct LimbCrossing {
pub axis: String,
pub value: f64,
pub hpbw_deg: f64,
pub held_parameter: String,
pub held_value: f64,
pub limb_js_db: f64,
}
#[derive(Clone, Debug, Serialize)]
pub struct LimbThreshold {
pub reached: bool,
pub statement: String,
pub crossings: Vec<LimbCrossing>,
pub best_limb_js_db: f64,
pub best_limb_shortfall_db: f64,
pub best_altitude_m: f64,
pub best_diameter_m: f64,
pub best_hpbw_deg: f64,
pub best_limb_capture_tx_power_dbw: f64,
}
#[derive(Clone, Debug, Serialize)]
pub struct FootprintSweepResult {
pub axes: Vec<SweepAxis>,
pub shape: Vec<usize>,
pub altitude_m_values: Vec<f64>,
pub diameter_m_values: Vec<f64>,
pub hpbw_deg_values: Vec<f64>,
pub freq_hz: f64,
pub p_tx_dbw: f64,
pub capture_threshold_db: f64,
pub points: Vec<FootprintSweepPoint>,
pub limb_threshold: LimbThreshold,
}
fn axis_midpoint(a: f64, b: f64, log: bool) -> f64 {
if log {
((a.ln() + b.ln()) * 0.5).exp()
} else {
(a + b) * 0.5
}
}
fn bisect_crossing<F: Fn(f64) -> f64>(mut lo: f64, mut hi: f64, log: bool, f: F) -> f64 {
let f_lo = f(lo);
for _ in 0..LIMB_BISECT_ITERS {
let mid = axis_midpoint(lo, hi, log);
if mid <= lo || mid >= hi {
break; }
if (f(mid) >= 0.0) == (f_lo >= 0.0) {
lo = mid;
} else {
hi = mid;
}
}
axis_midpoint(lo, hi, log)
}
pub fn capture_footprint_sweep(
base: &FootprintParams,
altitude_axis: &SweepAxis,
diameter_axis: &SweepAxis,
) -> FootprintSweepResult {
let alt_log = altitude_axis.scale == "log";
let dia_log = diameter_axis.scale == "log";
let alts = altitude_axis.values();
let dias = diameter_axis.values();
let thr = base.capture_threshold_db;
let at = |h: f64, d: f64| FootprintParams {
altitude_m: h,
diameter_m: d,
..*base
};
let limb_margin = |h: f64, d: f64| limb_point(&at(h, d)).js_db - thr;
let mut points = Vec::with_capacity(alts.len() * dias.len());
let mut best = f64::NEG_INFINITY;
let mut best_idx = 0usize;
for &h in &alts {
for &d in &dias {
let p = at(h, d);
let res = capture_footprint(&p);
let limb = res
.points
.last()
.copied()
.expect("capture_footprint emits at least two points");
let hpbw = half_power_beamwidth_rad(d, base.freq_hz);
if limb.js_db > best {
best = limb.js_db;
best_idx = points.len();
}
points.push(FootprintSweepPoint {
altitude_m: h,
diameter_m: d,
hpbw_rad: hpbw,
hpbw_deg: hpbw.to_degrees(),
boresight_gain_dbi: res.boresight_gain_dbi,
horizon_central_angle_rad: res.horizon_central_angle_rad,
captured_fraction: res.captured_fraction,
limb_js_db: limb.js_db,
limb_captured: res.limb_captured,
limb_margin_db: limb.js_db - thr,
limb_capture_tx_power_dbw: base.p_tx_dbw + (thr - limb.js_db),
});
}
}
let mut crossings: Vec<LimbCrossing> = Vec::new();
for &h in &alts {
for w in dias.windows(2) {
let (d0, d1) = (w[0], w[1]);
let (m0, m1) = (limb_margin(h, d0), limb_margin(h, d1));
if (m0 >= 0.0) == (m1 >= 0.0) {
continue;
}
let d = bisect_crossing(d0, d1, dia_log, |d| limb_margin(h, d));
let hpbw = half_power_beamwidth_rad(d, base.freq_hz);
crossings.push(LimbCrossing {
axis: diameter_axis.parameter.clone(),
value: d,
hpbw_deg: hpbw.to_degrees(),
held_parameter: altitude_axis.parameter.clone(),
held_value: h,
limb_js_db: limb_margin(h, d) + thr,
});
}
}
for &d in &dias {
let hpbw_deg = half_power_beamwidth_rad(d, base.freq_hz).to_degrees();
for w in alts.windows(2) {
let (h0, h1) = (w[0], w[1]);
let (m0, m1) = (limb_margin(h0, d), limb_margin(h1, d));
if (m0 >= 0.0) == (m1 >= 0.0) {
continue;
}
let h = bisect_crossing(h0, h1, alt_log, |h| limb_margin(h, d));
crossings.push(LimbCrossing {
axis: altitude_axis.parameter.clone(),
value: h,
hpbw_deg,
held_parameter: diameter_axis.parameter.clone(),
held_value: d,
limb_js_db: limb_margin(h, d) + thr,
});
}
}
crossings.dedup_by(|a, b| {
a.axis == b.axis
&& a.held_parameter == b.held_parameter
&& a.value.to_bits() == b.value.to_bits()
&& a.held_value.to_bits() == b.held_value.to_bits()
});
let any_sampled = points.iter().any(|p| p.limb_captured);
let reached = any_sampled || !crossings.is_empty();
let b = points[best_idx]; let shortfall = thr - b.limb_js_db;
let (a_lo, a_hi) = (
alts.iter().copied().fold(f64::INFINITY, f64::min),
alts.iter().copied().fold(f64::NEG_INFINITY, f64::max),
);
let hp: Vec<f64> = dias
.iter()
.map(|&d| half_power_beamwidth_rad(d, base.freq_hz).to_degrees())
.collect();
let (b_lo, b_hi) = (
hp.iter().copied().fold(f64::INFINITY, f64::min),
hp.iter().copied().fold(f64::NEG_INFINITY, f64::max),
);
let grid = format!(
"altitude {a_lo:.0}-{a_hi:.0} m x beamwidth {b_lo:.3}-{b_hi:.3} deg, {} points",
points.len()
);
let statement = if reached {
format!(
"limb capture IS reached on this grid ({grid}): {} of {} sampled operating points \
capture the limb and {} axis crossing(s) were located; the strongest limb J/S \
sampled is {:.3} dB at altitude {:.0} m / beamwidth {:.3} deg, against a {:.1} dB \
capture threshold.",
points.iter().filter(|p| p.limb_captured).count(),
points.len(),
crossings.len(),
b.limb_js_db,
b.altitude_m,
b.hpbw_deg,
thr
)
} else {
format!(
"limb capture is NOT reached anywhere on this grid ({grid}) - not zero capture, \
but no limb capture: the best limb J/S is {:.3} dB at altitude {:.0} m / beamwidth \
{:.3} deg, {:.3} dB short of the {:.1} dB capture threshold; that operating point \
would capture the limb at {:.3} dBW transmit power against the {:.3} dBW flown.",
b.limb_js_db,
b.altitude_m,
b.hpbw_deg,
shortfall,
thr,
b.limb_capture_tx_power_dbw,
base.p_tx_dbw
)
};
FootprintSweepResult {
axes: vec![altitude_axis.clone(), diameter_axis.clone()],
shape: vec![alts.len(), dias.len()],
altitude_m_values: alts,
diameter_m_values: dias,
hpbw_deg_values: hp,
freq_hz: base.freq_hz,
p_tx_dbw: base.p_tx_dbw,
capture_threshold_db: thr,
points,
limb_threshold: LimbThreshold {
reached,
statement,
crossings,
best_limb_js_db: b.limb_js_db,
best_limb_shortfall_db: shortfall,
best_altitude_m: b.altitude_m,
best_diameter_m: b.diameter_m,
best_hpbw_deg: b.hpbw_deg,
best_limb_capture_tx_power_dbw: b.limb_capture_tx_power_dbw,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bessel_j1_matches_published_values() {
assert_eq!(bessel_j1(0.0), 0.0);
assert!(
(bessel_j1(1.0) - 0.440_050_585_7).abs() < 1e-7,
"J1(1)={}",
bessel_j1(1.0)
);
assert!(
bessel_j1(3.831_705_97).abs() < 1e-4,
"J1(j11)={}",
bessel_j1(3.831_705_97)
);
assert!((bessel_j1(-1.0) + bessel_j1(1.0)).abs() < 1e-12);
let h = 1e-4;
assert!(
(bessel_j1(h) / h - 0.5).abs() < 1e-6,
"J1'(0)~{}",
bessel_j1(h) / h
);
assert!((bessel_j1(3.0 - 1e-6) - bessel_j1(3.0 + 1e-6)).abs() < 1e-6);
}
#[test]
fn boresight_gain_closed_form() {
let lambda = C_M_PER_S / 2.4e9;
let expect = 10.0 * (0.6 * (PI * 1.0 / lambda).powi(2)).log10();
let got = boresight_gain_dbi(1.0, 2.4e9, 0.6);
assert!((got - expect).abs() < 1e-9);
assert!((got - 25.79).abs() < 0.05, "G0 = {got} dBi");
}
#[test]
fn pattern_hpbw_and_first_null() {
let (d, f, eff) = (1.0, 2.4e9, 0.6);
let g0 = boresight_gain_dbi(d, f, eff);
let half = half_power_beamwidth_rad(d, f) / 2.0;
let g_half = pattern_gain_dbi(d, f, eff, half);
assert!(
(g_half - g0 + 3.0).abs() < 0.2,
"HPBW/2 rel gain = {} dB",
g_half - g0
);
let null = first_null_angle_rad(d, f).expect("aperture > 1.22 lambda");
let g_null = pattern_gain_dbi(d, f, eff, null);
assert!(
g_null - g0 < -40.0,
"first-null rel gain = {} dB",
g_null - g0
);
assert!((pattern_gain_dbi(d, f, eff, 0.0) - g0).abs() < 1e-9);
assert!(first_null_angle_rad(0.05, f).is_none());
}
#[test]
fn the_pattern_matches_the_published_airy_anchors_and_is_monotone_in_the_main_lobe() {
let (d, f, eff) = (1.0, 2.4e9, 0.6);
let lambda = C_M_PER_S / f;
let g0 = boresight_gain_dbi(d, f, eff);
assert!((pattern_gain_dbi(d, f, eff, 0.0) - g0).abs() < 1e-12);
let rel = |theta: f64| pattern_gain_dbi(d, f, eff, theta) - g0;
let (mut lo, mut hi) = (0.0_f64, first_null_angle_rad(d, f).unwrap());
for _ in 0..200 {
let mid = 0.5 * (lo + hi);
if rel(mid) > -HALF_POWER_DROP_DB {
lo = mid;
} else {
hi = mid;
}
}
let theta_half = 0.5 * (lo + hi);
let x_half = PI * d / lambda * theta_half.sin();
assert!(
(x_half - 1.616_34).abs() < 2e-4,
"half-power crossing at x = {x_half}, published Airy value is 1.61634"
);
let exact_coeff = 2.0 * theta_half.sin() / (lambda / d);
assert!(
(exact_coeff - 1.028_99).abs() < 2e-4,
"exact HPBW coefficient {exact_coeff}, published 1.02899"
);
assert_eq!(UNIFORM_APERTURE_HPBW_COEFF, 1.02);
let at_engine_half = rel(half_power_beamwidth_rad(d, f) / 2.0);
assert!(
(at_engine_half + 2.9546).abs() < 5e-3,
"1.02·λ/D half-angle sits at {at_engine_half} dB; the honest figure is -2.955 dB"
);
assert!(
UNIFORM_APERTURE_HPBW_COEFF < exact_coeff,
"the rounded coefficient is narrow, not wide"
);
let null = first_null_angle_rad(d, f).unwrap();
let mut prev = f64::INFINITY;
for i in 0..=400 {
let theta = null * (i as f64) / 400.0;
let g = pattern_gain_dbi(d, f, eff, theta);
assert!(
g < prev,
"pattern not monotone inside the main lobe at theta = {theta}: {g} >= {prev}"
);
prev = g;
}
}
#[test]
fn the_symmetric_relation_carries_an_aperture_efficiency_of_about_064() {
let eta_70 = symmetric_relation_implied_efficiency(70.0_f64.to_radians());
assert!(
(eta_70 - 0.641_012).abs() < 1e-5,
"the 70·λ/D pairing implies eta = {eta_70}, expected 0.6410"
);
let eta_uniform = symmetric_relation_implied_efficiency(UNIFORM_APERTURE_HPBW_COEFF);
assert!(
(eta_uniform - 0.919_637).abs() < 1e-5,
"the 1.02·λ/D pairing implies eta = {eta_uniform}, expected 0.9196"
);
let (d, f) = (1.0, 2.4e9);
let g0 = boresight_gain_dbi(d, f, eta_uniform);
let hpbw = half_power_beamwidth_rad(d, f);
assert!(
(symmetric_beamwidth_rad(g0) - hpbw).abs() < 1e-12,
"round trip {} vs {hpbw}",
symmetric_beamwidth_rad(g0)
);
let g0_real = boresight_gain_dbi(d, f, DEFAULT_APERTURE_EFFICIENCY);
let ratio = symmetric_beamwidth_rad(g0_real) / hpbw;
assert!(
(ratio - (eta_uniform / DEFAULT_APERTURE_EFFICIENCY).sqrt()).abs() < 1e-12,
"ratio {ratio} must be sqrt(eta_implied/eta)"
);
assert!(
(ratio - 1.238_034).abs() < 1e-5,
"at eta = 0.60 the symmetric beam is {ratio}x the real one, expected 1.2380"
);
}
#[test]
fn the_half_power_beam_test_brackets_the_true_crossing() {
let (d, f, eff) = (1.0, 2.4e9, DEFAULT_APERTURE_EFFICIENCY);
let theta_c = (0.5 * 1.028_994 * (C_M_PER_S / f) / d).asin();
assert!(within_half_power_beam(d, f, eff, theta_c * 0.999));
assert!(!within_half_power_beam(d, f, eff, theta_c * 1.001));
assert!(within_half_power_beam(d, f, eff, 0.0));
let null = first_null_angle_rad(d, f).unwrap();
for i in 1..=50 {
let theta = theta_c * 1.002 + (null - theta_c * 1.002) * (i as f64) / 50.0;
assert!(
!within_half_power_beam(d, f, eff, theta),
"main lobe re-entered the half-power beam at {theta}"
);
}
}
#[test]
fn footprint_captures_cap_not_hemisphere() {
let p_tx = 10.0 * (40.0_f64).log10(); let params = FootprintParams::new(100_000.0, p_tx, 1.0, 2.4e9, 400);
let res = capture_footprint(¶ms);
let expect_gamma = (R_MOON_M / (R_MOON_M + 100_000.0)).acos();
assert!((res.horizon_central_angle_rad - expect_gamma).abs() < 1e-6);
let nadir = res.points.first().expect("at least one point");
assert!(nadir.off_boresight_rad < 1e-9);
assert!(
nadir.captured && nadir.js_db > 30.0,
"nadir J/S = {} dB",
nadir.js_db
);
let limb = res.points.last().expect("at least one point");
assert!(
limb.off_boresight_rad > 1.0,
"limb theta = {} rad",
limb.off_boresight_rad
);
assert!(
!limb.captured && limb.js_db < 0.0,
"limb J/S = {} dB",
limb.js_db
);
assert!(!res.limb_captured);
assert!(
res.captured_fraction > 0.0,
"captured fraction = {}",
res.captured_fraction
);
assert!(
res.captured_fraction < 0.3,
"captured fraction = {}",
res.captured_fraction
);
assert!(res.points[0].captured);
}
fn baseline_params() -> FootprintParams {
FootprintParams::new(100_000.0, 10.0 * (40.0_f64).log10(), 1.0, 2.4e9, 400)
}
fn axis(parameter: &str, start: f64, stop: f64, steps: usize, scale: &str) -> SweepAxis {
SweepAxis {
parameter: parameter.to_string(),
start,
stop,
steps,
scale: scale.to_string(),
}
}
fn default_axes() -> (SweepAxis, SweepAxis) {
(
axis("transmitter_altitude_m", 20_000.0, 500_000.0, 7, "linear"),
axis("antenna_diameter_m", 0.25, 4.0, 5, "log"),
)
}
#[test]
fn limb_point_is_bit_identical_to_the_full_sweeps_last_point() {
for (h, d) in [
(100_000.0, 1.0),
(20_000.0, 0.25),
(500_000.0, 4.0),
(1_500.0, 1.0),
] {
let p = FootprintParams {
altitude_m: h,
diameter_m: d,
..baseline_params()
};
let full = *capture_footprint(&p).points.last().expect("points");
let only = limb_point(&p);
assert_eq!(
full.central_angle_rad.to_bits(),
only.central_angle_rad.to_bits()
);
assert_eq!(full.slant_range_m.to_bits(), only.slant_range_m.to_bits());
assert_eq!(full.gain_dbi.to_bits(), only.gain_dbi.to_bits());
assert_eq!(
full.js_db.to_bits(),
only.js_db.to_bits(),
"limb J/S differs at h={h} D={d}: {} vs {}",
full.js_db,
only.js_db
);
assert_eq!(full.captured, only.captured);
}
}
#[test]
fn footprint_sweep_grid_is_complete_long_form_and_bounded() {
let (a_axis, d_axis) = default_axes();
let s = capture_footprint_sweep(&baseline_params(), &a_axis, &d_axis);
assert_eq!(s.shape, vec![7, 5]);
assert_eq!(s.points.len(), 35, "7 altitudes × 5 beamwidths");
assert_eq!(s.altitude_m_values.len(), 7);
assert_eq!(s.diameter_m_values.len(), 5);
assert_eq!(s.hpbw_deg_values.len(), 5);
assert_eq!(s.axes.len(), 2);
assert_eq!(s.axes[0].parameter, "transmitter_altitude_m");
assert_eq!(s.axes[1].parameter, "antenna_diameter_m");
for (i, p) in s.points.iter().enumerate() {
assert_eq!(p.altitude_m, s.altitude_m_values[i / 5], "row {i} altitude");
assert_eq!(p.diameter_m, s.diameter_m_values[i % 5], "row {i} diameter");
assert!(
(0.0..=1.0).contains(&p.captured_fraction),
"row {i} captured fraction {} outside [0,1]",
p.captured_fraction
);
let expect = half_power_beamwidth_rad(p.diameter_m, s.freq_hz);
assert!((p.hpbw_rad - expect).abs() < 1e-15);
assert!((p.hpbw_deg - expect.to_degrees()).abs() < 1e-12);
assert!(
(p.limb_capture_tx_power_dbw - (s.p_tx_dbw - p.limb_margin_db)).abs() < 1e-9,
"row {i} limb capture power is not the threshold power"
);
assert_eq!(p.limb_captured, p.limb_margin_db >= 0.0);
}
for w in s.hpbw_deg_values.windows(2) {
assert!(w[0] > w[1], "beamwidth axis is not strictly ordered");
}
}
#[test]
fn footprint_sweep_captured_fraction_is_monotone_in_transmit_power() {
let (a_axis, d_axis) = default_axes();
let base = baseline_params();
let lo = capture_footprint_sweep(&base, &a_axis, &d_axis);
for delta in [3.0_f64, 6.0, 12.0] {
let hi = capture_footprint_sweep(
&FootprintParams {
p_tx_dbw: base.p_tx_dbw + delta,
..base
},
&a_axis,
&d_axis,
);
assert_eq!(lo.points.len(), hi.points.len());
for (l, h) in lo.points.iter().zip(&hi.points) {
assert_eq!((l.altitude_m, l.diameter_m), (h.altitude_m, h.diameter_m));
assert!(
h.captured_fraction >= l.captured_fraction,
"+{delta} dB LOWERED capture at h={} m, HPBW={:.3} deg: {} -> {}",
l.altitude_m,
l.hpbw_deg,
l.captured_fraction,
h.captured_fraction
);
assert!(
(h.limb_js_db - l.limb_js_db - delta).abs() < 1e-9,
"limb J/S did not track transmit power one-for-one"
);
}
assert!(
lo.points
.iter()
.zip(&hi.points)
.any(|(l, h)| h.captured_fraction > l.captured_fraction),
"+{delta} dB changed nothing anywhere on the grid"
);
}
}
#[test]
fn footprint_sweep_captured_fraction_is_not_monotone_in_beamwidth_or_altitude() {
let (a_axis, d_axis) = default_axes();
let s = capture_footprint_sweep(&baseline_params(), &a_axis, &d_axis);
let f = |ai: usize, di: usize| s.points[ai * 5 + di].captured_fraction;
assert!(
f(2, 1) > f(2, 0),
"expected the beamwidth non-monotonicity at 180 km: {} then {}",
f(2, 0),
f(2, 1)
);
assert!(f(1, 0) > f(2, 0), "100 km should beat 180 km at 29.2 deg");
assert!(f(6, 0) > f(2, 0), "500 km should beat 180 km at 29.2 deg");
}
#[test]
fn footprint_sweep_reproduces_the_baseline_operating_point_exactly() {
let base = baseline_params();
let (a_axis, d_axis) = default_axes();
let s = capture_footprint_sweep(&base, &a_axis, &d_axis);
let direct = capture_footprint(&base);
let rows: Vec<&FootprintSweepPoint> = s
.points
.iter()
.filter(|p| p.altitude_m == base.altitude_m && p.diameter_m == base.diameter_m)
.collect();
assert_eq!(
rows.len(),
1,
"the baseline point must be a unique grid node"
);
let r = rows[0];
assert_eq!(
r.captured_fraction.to_bits(),
direct.captured_fraction.to_bits(),
"grid {} vs direct {}",
r.captured_fraction,
direct.captured_fraction
);
assert_eq!(r.captured_fraction, 0.030_202_685_056_276_844);
assert_eq!(r.limb_captured, direct.limb_captured);
assert!(!r.limb_captured);
}
#[test]
fn footprint_sweep_limb_threshold_is_explicitly_not_reached_on_the_orbital_grid() {
let base = baseline_params();
let (a_axis, d_axis) = default_axes();
let s = capture_footprint_sweep(&base, &a_axis, &d_axis);
let lt = &s.limb_threshold;
assert!(!lt.reached);
assert!(lt.crossings.is_empty());
assert!(s.points.iter().all(|p| !p.limb_captured));
assert!(
lt.statement.contains("NOT reached anywhere on this grid"),
"statement must say so in words: {}",
lt.statement
);
assert!(lt.statement.contains("20000-500000 m"));
assert!(lt.best_limb_shortfall_db > 0.0);
assert!(
(lt.best_limb_shortfall_db - (base.capture_threshold_db - lt.best_limb_js_db)).abs()
< 1e-12
);
assert!(
(lt.best_limb_shortfall_db - 3.775).abs() < 0.01,
"best limb shortfall {} dB",
lt.best_limb_shortfall_db
);
assert_eq!(lt.best_altitude_m, 20_000.0);
assert_eq!(lt.best_diameter_m, 0.25);
assert!(
(lt.best_limb_capture_tx_power_dbw - (base.p_tx_dbw + lt.best_limb_shortfall_db)).abs()
< 1e-12
);
let max = s
.points
.iter()
.map(|p| p.limb_js_db)
.fold(f64::NEG_INFINITY, f64::max);
assert_eq!(lt.best_limb_js_db, max);
}
#[test]
fn footprint_sweep_limb_threshold_is_a_real_crossing_when_one_exists() {
let base = baseline_params();
let s = capture_footprint_sweep(
&base,
&axis("transmitter_altitude_m", 500.0, 4_000.0, 8, "linear"),
&axis("antenna_diameter_m", 1.0, 1.0, 2, "linear"),
);
let lt = &s.limb_threshold;
assert!(lt.reached, "{}", lt.statement);
assert!(lt.statement.contains("IS reached on this grid"));
assert!(s.points.iter().any(|p| p.limb_captured));
assert!(s.points.iter().any(|p| !p.limb_captured));
let alt_crossings: Vec<&LimbCrossing> = lt
.crossings
.iter()
.filter(|c| c.axis == "transmitter_altitude_m")
.collect();
assert_eq!(
alt_crossings.len(),
1,
"one boundary, reported once: {:?}",
lt.crossings
);
assert_eq!(lt.crossings.len(), 1);
for c in &alt_crossings {
assert!(
(c.limb_js_db - base.capture_threshold_db).abs() < 1e-9,
"located crossing at {} m has limb J/S {} dB, not the {} dB threshold",
c.value,
c.limb_js_db,
base.capture_threshold_db
);
let at = FootprintParams {
altitude_m: c.value,
diameter_m: c.held_value,
..base
};
assert!((limb_point(&at).js_db - base.capture_threshold_db).abs() < 1e-9);
let inside = FootprintParams {
altitude_m: c.value * 0.99,
..at
};
let outside = FootprintParams {
altitude_m: c.value * 1.01,
..at
};
assert!(
limb_point(&inside).captured,
"below the crossing must capture"
);
assert!(
!limb_point(&outside).captured,
"above the crossing must not capture"
);
assert!(
(c.value - 1_980.3).abs() < 1.0,
"limb-capture altitude ceiling {} m (expected ~1980 m for a 1 m dish at 40 W)",
c.value
);
assert!((c.hpbw_deg - 7.300).abs() < 0.01);
}
}
}