use crate::radiometric::Band;
use crate::timegeo::C_M_PER_S;
use std::f64::consts::PI;
pub const BOLTZMANN_DBW_PER_K_PER_HZ: f64 = -228.599_1;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Profile {
Transfer,
Orbital,
Lander,
Surface,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LinkParams {
pub band: Band,
pub eirp_dbw: f64,
pub g_over_t_db: f64,
pub range_m: f64,
pub data_rate_bps: f64,
pub other_losses_db: f64,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LinkResult {
pub fsl_db: f64,
pub cn0_dbhz: f64,
pub eb_n0_db: f64,
pub margin_db: f64,
pub closes: bool,
}
pub fn band_frequency_hz(band: Band) -> f64 {
band.downlink_hz()
}
pub fn free_space_loss_db(range_m: f64, freq_hz: f64) -> f64 {
20.0 * (4.0 * PI * range_m * freq_hz / C_M_PER_S).log10()
}
pub fn link_budget(p: &LinkParams, required_eb_n0_db: f64) -> LinkResult {
let freq_hz = band_frequency_hz(p.band);
let fsl_db = free_space_loss_db(p.range_m, freq_hz);
let cn0_dbhz =
p.eirp_dbw - fsl_db - p.other_losses_db + p.g_over_t_db - BOLTZMANN_DBW_PER_K_PER_HZ;
let eb_n0_db = cn0_dbhz - 10.0 * p.data_rate_bps.log10();
let margin_db = eb_n0_db - required_eb_n0_db;
LinkResult {
fsl_db,
cn0_dbhz,
eb_n0_db,
margin_db,
closes: margin_db >= 0.0,
}
}
pub fn required_g_over_t_db(p: &LinkParams, required_eb_n0_db: f64) -> f64 {
let fsl_db = free_space_loss_db(p.range_m, band_frequency_hz(p.band));
fsl_db + p.other_losses_db - p.eirp_dbw
+ BOLTZMANN_DBW_PER_K_PER_HZ
+ 10.0 * p.data_rate_bps.log10()
+ required_eb_n0_db
}
pub fn link_constant_dbw(p: &LinkParams, required_eb_n0_db: f64) -> f64 {
p.eirp_dbw - p.other_losses_db - required_eb_n0_db
}
pub fn rx_gain_from_g_over_t_dbi(g_over_t_db: f64, tsys_k: f64) -> Option<f64> {
if !tsys_k.is_finite() || tsys_k <= 0.0 {
return None;
}
Some(g_over_t_db + 10.0 * tsys_k.log10())
}
pub fn received_signal_power_dbw(
eirp_dbw: f64,
rx_gain_dbi: f64,
range_m: f64,
freq_hz: f64,
) -> f64 {
eirp_dbw + rx_gain_dbi - free_space_loss_db(range_m, freq_hz)
}
pub fn power_deficit_db(reference_dbw: f64, received_dbw: f64) -> f64 {
reference_dbw - received_dbw
}
pub fn deficit_power_factor(deficit_db: f64) -> f64 {
10.0_f64.powf(deficit_db / 10.0)
}
#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize)]
pub struct DeficitBand {
pub band_lo_db: f64,
pub band_hi_db: f64,
pub band_lo_factor: f64,
pub band_hi_factor: f64,
pub nominal_deficit_db: f64,
pub nominal_factor: f64,
pub nominal_factor_whole_db: f64,
}
#[allow(clippy::too_many_arguments)]
pub fn deficit_sensitivity_band(
ref_lo_dbw: f64,
ref_hi_dbw: f64,
eirp_lo_dbw: f64,
eirp_hi_dbw: f64,
rx_gain_dbi: f64,
range_lo_m: f64,
range_hi_m: f64,
freq_hz: f64,
steps: usize,
) -> DeficitBand {
let axis = |name: &str, start: f64, stop: f64| crate::sweep::SweepAxis {
parameter: name.to_string(),
start,
stop,
steps,
scale: "linear".to_string(),
};
let ref_vals = axis("gnss_reference_dbw", ref_lo_dbw, ref_hi_dbw).values();
let eirp_vals = axis("afs_eirp_dbw", eirp_lo_dbw, eirp_hi_dbw).values();
let range_vals = axis("afs_slant_range_m", range_lo_m, range_hi_m).values();
let mut lo = f64::INFINITY;
let mut hi = f64::NEG_INFINITY;
for &r in &ref_vals {
for &eirp in &eirp_vals {
for &range in &range_vals {
let received = received_signal_power_dbw(eirp, rx_gain_dbi, range, freq_hz);
let d = power_deficit_db(r, received);
lo = lo.min(d);
hi = hi.max(d);
}
}
}
let nominal_ref = ref_lo_dbw.max(ref_hi_dbw);
let nominal_eirp = eirp_lo_dbw.max(eirp_hi_dbw);
let nominal_range = range_lo_m.min(range_hi_m);
let nominal_received =
received_signal_power_dbw(nominal_eirp, rx_gain_dbi, nominal_range, freq_hz);
let nominal_deficit_db = power_deficit_db(nominal_ref, nominal_received);
DeficitBand {
band_lo_db: lo,
band_hi_db: hi,
band_lo_factor: deficit_power_factor(lo),
band_hi_factor: deficit_power_factor(hi),
nominal_deficit_db,
nominal_factor: deficit_power_factor(nominal_deficit_db),
nominal_factor_whole_db: deficit_power_factor(nominal_deficit_db.trunc()),
}
}
pub fn default_params(
band: Band,
profile: Profile,
range_m: f64,
data_rate_bps: f64,
) -> LinkParams {
let band_gain_db = match band {
Band::S => 0.0,
Band::X => 6.0,
Band::Ka => 14.0,
};
let (eirp_base_dbw, other_losses_db) = match profile {
Profile::Transfer => (55.0, 4.0),
Profile::Orbital => (60.0, 4.0),
Profile::Lander => (42.0, 5.0),
Profile::Surface => (38.0, 6.0),
};
let g_over_t_db = match profile {
Profile::Transfer | Profile::Orbital => 47.0 + band_gain_db,
Profile::Lander | Profile::Surface => 12.0 + band_gain_db,
};
LinkParams {
band,
eirp_dbw: eirp_base_dbw + band_gain_db,
g_over_t_db,
range_m,
data_rate_bps,
other_losses_db,
}
}
fn lb_default_band() -> String {
"x".to_string()
}
fn lb_default_eirp() -> f64 {
55.0
}
fn lb_default_gt() -> f64 {
53.0
}
fn lb_default_range_km() -> f64 {
2000.0
}
fn lb_default_rate() -> f64 {
1.0e6
}
fn lb_default_other() -> f64 {
3.0
}
fn lb_default_req() -> f64 {
4.5
}
#[derive(serde::Deserialize)]
pub struct LinkBudgetScenario {
#[serde(default = "lb_default_band")]
pub band: String,
#[serde(default = "lb_default_eirp")]
pub eirp_dbw: f64,
#[serde(default = "lb_default_gt")]
pub g_over_t_db: f64,
#[serde(default = "lb_default_range_km")]
pub range_km: f64,
#[serde(default = "lb_default_rate")]
pub data_rate_bps: f64,
#[serde(default = "lb_default_other")]
pub other_losses_db: f64,
#[serde(default = "lb_default_req")]
pub required_eb_n0_db: f64,
pub tsys_k: Option<f64>,
}
impl LinkBudgetScenario {
pub fn run_json(&self) -> Result<(String, String), String> {
let band = match self.band.to_ascii_lowercase().as_str() {
"s" => Band::S,
"x" => Band::X,
"ka" => Band::Ka,
other => return Err(format!("unknown band '{other}' (expected s|x|ka)")),
};
if !self.range_km.is_finite() || self.range_km <= 0.0 {
return Err("range_km must be finite and positive".to_string());
}
if !self.data_rate_bps.is_finite() || self.data_rate_bps <= 0.0 {
return Err("data_rate_bps must be finite and positive".to_string());
}
if !self.other_losses_db.is_finite() || self.other_losses_db < 0.0 {
return Err("other_losses_db must be finite and >= 0".to_string());
}
let p = LinkParams {
band,
eirp_dbw: self.eirp_dbw,
g_over_t_db: self.g_over_t_db,
range_m: self.range_km * 1000.0,
data_rate_bps: self.data_rate_bps,
other_losses_db: self.other_losses_db,
};
if let Some(t) = self.tsys_k {
if !t.is_finite() || t <= 0.0 {
return Err("tsys_k must be finite and positive when supplied".to_string());
}
}
let r = link_budget(&p, self.required_eb_n0_db);
let mut json = serde_json::json!({
"kind": "link-budget",
"label": "One-way link budget over the CCSDS 401 / DSN 810-005 link \
equation (EIRP − FSPL − L_other + G/T − k); a deterministic \
engineering calculation from the supplied inputs, NOT a \
calibrated terminal datasheet",
"band": self.band.to_ascii_lowercase(),
"range_km": self.range_km,
"data_rate_bps": self.data_rate_bps,
"free_space_loss_db": r.fsl_db,
"cn0_dbhz": r.cn0_dbhz,
"eb_n0_db": r.eb_n0_db,
"required_eb_n0_db": self.required_eb_n0_db,
"margin_db": r.margin_db,
"closes": r.closes,
"link_constants": {
"carrier_frequency_hz": band_frequency_hz(band),
"eirp_dbw": self.eirp_dbw,
"g_over_t_db": self.g_over_t_db,
"other_losses_db": self.other_losses_db,
"boltzmann_dbw_per_k_per_hz": BOLTZMANN_DBW_PER_K_PER_HZ,
"link_constant_dbw": link_constant_dbw(&p, self.required_eb_n0_db),
},
"required_g_over_t_db": required_g_over_t_db(&p, self.required_eb_n0_db),
"margin_definition": "margin_db = eb_n0_db - required_eb_n0_db, with \
eb_n0_db = eirp_dbw - free_space_loss_db - other_losses_db \
+ g_over_t_db - boltzmann_dbw_per_k_per_hz \
- 10*log10(data_rate_bps); the link closes when margin_db >= 0",
"units": {
"free_space_loss_db": {"unit": "dB", "provenance": "computed"},
"cn0_dbhz": {"unit": "dB-Hz", "provenance": "computed"},
"eb_n0_db": {"unit": "dB", "provenance": "computed"},
"margin_db": {"unit": "dB", "provenance": "computed"},
"required_g_over_t_db": {"unit": "dB/K", "provenance": "computed", "note": "the G/T at which margin_db is zero"},
"link_constants.carrier_frequency_hz": {"unit": "Hz", "provenance": "computed", "note": "DSN downlink band centre for the selected band"},
"link_constants.eirp_dbw": {"unit": "dBW", "provenance": "input"},
"link_constants.g_over_t_db": {"unit": "dB/K", "provenance": "input"},
"link_constants.other_losses_db": {"unit": "dB", "provenance": "input", "note": "lumped pointing + polarisation + atmosphere + implementation; this engine does not resolve the split, and no bandwidth or system noise temperature enters the equation at all"},
"link_constants.boltzmann_dbw_per_k_per_hz": {"unit": "dBW/K/Hz", "provenance": "constant", "note": "10*log10 of the SI 2019 fixed Boltzmann constant"},
"link_constants.link_constant_dbw": {"unit": "dBW", "provenance": "computed", "note": "eirp_dbw - other_losses_db - required_eb_n0_db; the only combination of the three that required_g_over_t_db depends on"},
"required_eb_n0_db": {"unit": "dB", "provenance": "input"},
"range_km": {"unit": "km", "provenance": "input"},
"data_rate_bps": {"unit": "bit/s", "provenance": "input"},
},
});
if let Some(tsys_k) = self.tsys_k {
if let Some(gain) = rx_gain_from_g_over_t_dbi(self.g_over_t_db, tsys_k) {
json["receive_terminal"] = serde_json::json!({
"tsys_k": tsys_k,
"rx_gain_dbi": gain,
"g_over_t_to_gain_offset_db": 10.0 * tsys_k.log10(),
});
json["units"]["receive_terminal.tsys_k"] =
serde_json::json!({"unit": "K", "provenance": "input"});
json["units"]["receive_terminal.rx_gain_dbi"] = serde_json::json!({"unit": "dBi", "provenance": "computed", "note": "g_over_t_db + 10*log10(tsys_k)"});
json["units"]["receive_terminal.g_over_t_to_gain_offset_db"] =
serde_json::json!({"unit": "dB", "provenance": "computed"});
}
}
let summary = format!(
"link-budget: {}-band, {:.0} km, {:.0} bit/s -> FSPL {:.1} dB, Eb/N0 {:.1} dB, \
margin {:.1} dB ({})",
self.band.to_ascii_lowercase(),
self.range_km,
self.data_rate_bps,
r.fsl_db,
r.eb_n0_db,
r.margin_db,
if r.closes { "closes" } else { "does NOT close" }
);
let json = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
Ok((json, summary))
}
}
#[cfg(test)]
mod tests {
use super::*;
const AU_M: f64 = 1.495_978_707e11;
#[test]
fn free_space_loss_matches_hand_value() {
let range_m = 1.0e8;
let freq_hz = 8.42e9;
let c = 299_792_458.0_f64;
let arg = 4.0 * std::f64::consts::PI * range_m * freq_hz / c;
let hand_db = 20.0 * arg.log10();
assert!(
(hand_db - 210.96).abs() < 0.05,
"hand FSL {hand_db} dB not ≈ 211.0 dB at R=1e8 m, f=8.42 GHz"
);
let got = free_space_loss_db(range_m, freq_hz);
assert!(
(got - hand_db).abs() < 1e-6,
"free_space_loss_db {got} dB vs hand {hand_db} dB (Δ = {} dB)",
(got - hand_db).abs()
);
}
#[test]
fn link_equation_reproduces_descanso_galileo_dct() {
let range_m = 9.529e11; let freq_hz = 8.42043e9;
let fsl = free_space_loss_db(range_m, freq_hz);
assert!(
(fsl - 290.54).abs() < 0.05,
"FSPL {fsl} dB vs published 290.54 dB"
);
let g_over_t = 71.7 - 10.0 * 26.30_f64.log10();
let p = LinkParams {
band: Band::X, eirp_dbw: 60.3,
g_over_t_db: g_over_t,
range_m,
data_rate_bps: 134_400.0, other_losses_db: 1.24,
};
let r = link_budget(&p, 2.31); assert!(
(r.cn0_dbhz - 54.6).abs() < 0.2,
"C/N0 {} dB-Hz vs published 54.6 dB-Hz",
r.cn0_dbhz
);
assert!((2.290e9..=2.300e9).contains(&band_frequency_hz(Band::S)));
assert!((8.400e9..=8.450e9).contains(&band_frequency_hz(Band::X)));
assert!((31.800e9..=32.300e9).contains(&band_frequency_hz(Band::Ka)));
}
#[test]
fn band_frequencies_are_dsn() {
let fs = band_frequency_hz(Band::S);
let fx = band_frequency_hz(Band::X);
let fka = band_frequency_hz(Band::Ka);
assert!(
(2.2e9..=2.4e9).contains(&fs),
"S-band {fs} Hz not in the ~2.3 GHz DSN band"
);
assert!(
(8.3e9..=8.5e9).contains(&fx),
"X-band {fx} Hz not in the ~8.4 GHz DSN band"
);
assert!(
(31.0e9..=33.0e9).contains(&fka),
"Ka-band {fka} Hz not in the ~32 GHz DSN band"
);
assert!(
fs < fx && fx < fka,
"band frequencies must increase S < X < Ka"
);
}
#[test]
fn x_band_orbital_link_closes() {
let data_rate = 1.0e6; let required = 2.0;
let near = 1.67 * AU_M; let p_near = default_params(Band::X, Profile::Orbital, near, data_rate);
let r_near = link_budget(&p_near, required);
assert!(
r_near.closes && r_near.margin_db > 0.0,
"X-band orbital link must close at {near:.3e} m: margin {} dB, Eb/N0 {} dB, FSL {} dB",
r_near.margin_db,
r_near.eb_n0_db,
r_near.fsl_db
);
let far = 2.7 * AU_M; let p_far = default_params(Band::X, Profile::Orbital, far, data_rate);
let r_far = link_budget(&p_far, required);
assert!(
!r_far.closes && r_far.margin_db < 0.0,
"X-band orbital link must break at {far:.3e} m: margin {} dB, Eb/N0 {} dB, FSL {} dB",
r_far.margin_db,
r_far.eb_n0_db,
r_far.fsl_db
);
assert!(
r_far.fsl_db > r_near.fsl_db,
"longer range must have larger FSL: far {} dB vs near {} dB",
r_far.fsl_db,
r_near.fsl_db
);
}
#[test]
fn ka_higher_loss_than_x_same_range() {
let range_m = 2.0e11;
let fsl_x = free_space_loss_db(range_m, band_frequency_hz(Band::X));
let fsl_ka = free_space_loss_db(range_m, band_frequency_hz(Band::Ka));
assert!(
fsl_ka > fsl_x,
"Ka FSL {fsl_ka} dB must exceed X FSL {fsl_x} dB at the same range"
);
let expected_gap =
20.0 * (band_frequency_hz(Band::Ka) / band_frequency_hz(Band::X)).log10();
assert!(
(fsl_ka - fsl_x - expected_gap).abs() < 1e-6,
"FSL gap {} dB must equal 20·log10(f_Ka/f_X) = {} dB",
fsl_ka - fsl_x,
expected_gap
);
}
#[test]
fn profile_relative_eirp_and_margin() {
let band = Band::X;
let range_m = 1.5 * AU_M;
let data_rate = 1.0e4; let required = 2.0;
let mk = |prof| {
let p = default_params(band, prof, range_m, data_rate);
(p.eirp_dbw, link_budget(&p, required))
};
let (eirp_orb, r_orb) = mk(Profile::Orbital);
let (eirp_xfer, r_xfer) = mk(Profile::Transfer);
let (eirp_land, r_land) = mk(Profile::Lander);
let (eirp_surf, r_surf) = mk(Profile::Surface);
assert!(
eirp_orb >= eirp_xfer && eirp_xfer > eirp_land && eirp_land > eirp_surf,
"EIRP must order orbital≥transfer>lander>surface: {eirp_orb}, {eirp_xfer}, {eirp_land}, {eirp_surf}"
);
assert!(
r_orb.margin_db >= r_xfer.margin_db
&& r_xfer.margin_db > r_land.margin_db
&& r_land.margin_db > r_surf.margin_db,
"margin must order orbital≥transfer>lander>surface: {} {} {} {}",
r_orb.margin_db,
r_xfer.margin_db,
r_land.margin_db,
r_surf.margin_db
);
assert!(
r_orb.closes,
"orbital link should close at 1.5 AU / 10 kbit/s"
);
assert!(
r_orb.margin_db - r_surf.margin_db > 20.0,
"orbiter should beat surface by >20 dB: Δ = {} dB",
r_orb.margin_db - r_surf.margin_db
);
}
#[test]
fn carrier_figures_compose() {
let p = LinkParams {
band: Band::X,
eirp_dbw: 60.0,
g_over_t_db: 53.0,
range_m: 2.0e11,
data_rate_bps: 1.0e5,
other_losses_db: 4.0,
};
let required = 2.0;
let r = link_budget(&p, required);
let eb_hand = r.cn0_dbhz - 10.0 * p.data_rate_bps.log10();
assert!(
(r.eb_n0_db - eb_hand).abs() < 1e-9,
"Eb/N0 {} dB vs C/N0 − 10log10(Rb) {} dB",
r.eb_n0_db,
eb_hand
);
assert!((r.margin_db - (r.eb_n0_db - required)).abs() < 1e-9);
assert_eq!(r.closes, r.margin_db >= 0.0);
let cn0_hand =
p.eirp_dbw - r.fsl_db - p.other_losses_db + p.g_over_t_db - BOLTZMANN_DBW_PER_K_PER_HZ;
assert!(
(r.cn0_dbhz - cn0_hand).abs() < 1e-9,
"C/N0 {} dB vs hand {} dB",
r.cn0_dbhz,
cn0_hand
);
}
#[test]
fn afs_received_power_and_deficit_reproduce_p1() {
let p_rx = received_signal_power_dbw(26.0, 3.0, 3.0e6, 2.4e9);
assert!(
(p_rx - (-140.6)).abs() < 0.1,
"P_rx = {p_rx} dBW (want −140.6)"
);
assert!(
(power_deficit_db(-125.0, p_rx) - 15.6).abs() < 0.1,
"deficit"
);
}
const P1_RANGE_HI_M: f64 = 3.0e6 * 1.318_256_738_556_407;
#[test]
fn deficit_band_reproduces_p1_12_to_18_via_sweep() {
let b = deficit_sensitivity_band(
-128.5,
-125.0, 26.0,
26.0, 3.0, 3.0e6,
P1_RANGE_HI_M, 2.4e9,
8,
);
assert!(
b.band_lo_db > 12.0 && b.band_lo_db < 12.3,
"band_lo = {} dB (want ≈12.1)",
b.band_lo_db
);
assert!(
b.band_hi_db > 17.9 && b.band_hi_db < 18.1,
"band_hi = {} dB (want ≈18.0)",
b.band_hi_db
);
assert!(
(b.nominal_deficit_db - 15.6).abs() < 0.1,
"nominal = {} dB",
b.nominal_deficit_db
);
assert!(
(b.band_lo_factor - 16.2).abs() < 0.6,
"lo× = {}",
b.band_lo_factor
);
assert!(
(b.band_hi_factor - 63.1).abs() < 1.0,
"hi× = {}",
b.band_hi_factor
);
}
#[test]
fn deficit_factor_reconciles_32x_rounded_and_36x_unrounded() {
assert!(
(deficit_power_factor(15.0) - 31.62).abs() < 0.05,
"15 dB → {}× (want ≈32)",
deficit_power_factor(15.0)
);
assert!(
(deficit_power_factor(15.6) - 36.31).abs() < 0.05,
"15.6 dB → {}× (want ≈36)",
deficit_power_factor(15.6)
);
let b = deficit_sensitivity_band(
-128.5,
-125.0,
26.0,
26.0,
3.0,
3.0e6,
P1_RANGE_HI_M,
2.4e9,
8,
);
assert!(
(b.nominal_factor_whole_db - 31.62).abs() < 0.05,
"whole-dB factor = {}× (want ≈32)",
b.nominal_factor_whole_db
);
assert_eq!(b.nominal_factor_whole_db.round(), 32.0);
assert!(
(b.nominal_factor - 36.3).abs() < 1.0,
"full factor = {}× (want ≈36)",
b.nominal_factor
);
}
#[test]
fn deficit_band_is_monotone_in_received_power() {
let base = deficit_sensitivity_band(
-128.5,
-125.0,
26.0,
26.0,
3.0,
3.0e6,
P1_RANGE_HI_M,
2.4e9,
8,
);
let weaker_eirp = deficit_sensitivity_band(
-128.5,
-125.0,
22.0,
22.0,
3.0,
3.0e6,
P1_RANGE_HI_M,
2.4e9,
8,
);
assert!(
weaker_eirp.band_lo_db > base.band_lo_db + 3.9
&& weaker_eirp.band_hi_db > base.band_hi_db + 3.9,
"4 dB weaker EIRP must raise the band ~4 dB: base [{:.1},{:.1}] vs [{:.1},{:.1}]",
base.band_lo_db,
base.band_hi_db,
weaker_eirp.band_lo_db,
weaker_eirp.band_hi_db
);
}
#[test]
fn the_report_states_every_term_of_its_own_margin() {
let cases = [
("x", 55.0, 53.0, 2000.0, 1.0e6, 3.0, 4.5),
("s", 38.0, 12.0, 400_000.0, 100.0, 6.0, 2.0),
("ka", 74.0, 61.0, 3.6e8, 1.0e5, 4.0, 1.0),
("x", 0.0, 0.0, 12_499.0, 1000.0, 0.0, 0.0),
];
for (band, eirp, gt, range_km, rate, other, req) in cases {
let src = format!(
"band = \"{band}\"\neirp_dbw = {eirp}\ng_over_t_db = {gt}\n\
range_km = {range_km}\ndata_rate_bps = {rate}\n\
other_losses_db = {other}\nrequired_eb_n0_db = {req}\n"
);
let scn: LinkBudgetScenario = toml::from_str(&src).expect("parse");
let (json, _) = scn.run_json().expect("run");
let v: serde_json::Value = serde_json::from_str(&json).expect("json");
let f = |p: &str| -> f64 {
let mut cur = &v;
for seg in p.split('.') {
cur = &cur[seg];
}
cur.as_f64()
.unwrap_or_else(|| panic!("report has no numeric field {p}"))
};
let fsl = 20.0
* (4.0 * PI * f("range_km") * 1000.0 * f("link_constants.carrier_frequency_hz")
/ C_M_PER_S)
.log10();
assert!(
(fsl - f("free_space_loss_db")).abs() < 1e-9,
"{band}: FSL from the report's own constants {fsl} vs reported {}",
f("free_space_loss_db")
);
let eb_n0 = f("link_constants.eirp_dbw")
- f("free_space_loss_db")
- f("link_constants.other_losses_db")
+ f("link_constants.g_over_t_db")
- f("link_constants.boltzmann_dbw_per_k_per_hz")
- 10.0 * f("data_rate_bps").log10();
let margin = eb_n0 - f("required_eb_n0_db");
assert!(
(margin - f("margin_db")).abs() < 1e-9,
"{band}: margin recomputed from the report {margin} dB vs reported {} dB",
f("margin_db")
);
}
}
#[test]
fn required_g_over_t_is_the_figure_of_merit_that_zeroes_the_margin() {
for (band, range_m, rate, eirp, other, req) in [
(Band::X, 2.0e6, 1.0e6, 55.0, 3.0, 4.5),
(Band::S, 4.0e8, 100.0, 38.0, 6.0, 2.0),
(Band::Ka, 3.6e11, 1.0e5, 74.0, 4.0, 1.0),
] {
let p = LinkParams {
band,
eirp_dbw: eirp,
g_over_t_db: 17.0, range_m,
data_rate_bps: rate,
other_losses_db: other,
};
let need = required_g_over_t_db(&p, req);
let at_need = link_budget(
&LinkParams {
g_over_t_db: need,
..p
},
req,
);
assert!(
at_need.margin_db.abs() < 1e-9,
"margin at the required G/T is {} dB, not 0",
at_need.margin_db
);
let actual = link_budget(&p, req);
assert!(
(p.g_over_t_db - actual.margin_db - need).abs() < 1e-9,
"G/T − margin = {} vs required {need}",
p.g_over_t_db - actual.margin_db
);
}
}
#[test]
fn the_requirement_sees_only_the_link_constant() {
let base = |eirp: f64, other: f64, req: f64| {
(
LinkParams {
band: Band::X,
eirp_dbw: eirp,
g_over_t_db: 0.0,
range_m: 1.2499e7,
data_rate_bps: 1000.0,
other_losses_db: other,
},
req,
)
};
let same = [
base(20.79, 0.0, 0.0),
base(55.0, 30.0, 4.21),
base(30.79, 6.0, 4.0),
];
let mut reqs = Vec::new();
for (p, r) in &same {
assert!(
(link_constant_dbw(p, *r) - 20.79).abs() < 1e-12,
"test setup: link constant is not 20.79"
);
reqs.push(required_g_over_t_db(p, *r));
}
for w in reqs.windows(2) {
assert!(
(w[0] - w[1]).abs() < 1e-12,
"equal link constants must give an equal requirement: {} vs {}",
w[0],
w[1]
);
}
let (p, r) = base(21.79, 0.0, 0.0);
assert!(
(required_g_over_t_db(&p, r) - (reqs[0] - 1.0)).abs() < 1e-12,
"1 dB more link constant must lower the requirement by exactly 1 dB"
);
}
#[test]
fn the_gain_split_is_absent_unless_the_noise_temperature_is_stated() {
let run = |extra: &str| -> serde_json::Value {
let src = format!(
"band = \"x\"\neirp_dbw = 20.79\ng_over_t_db = 0.0\nrange_km = 12499.0\n\
data_rate_bps = 1000.0\nother_losses_db = 0.0\nrequired_eb_n0_db = 0.0\n{extra}"
);
let scn: LinkBudgetScenario = toml::from_str(&src).expect("parse");
let (json, _) = scn.run_json().expect("run");
serde_json::from_str(&json).expect("json")
};
let without = run("");
assert!(
without.get("receive_terminal").is_none(),
"no noise temperature was stated, so no gain split may be reported"
);
let warm = run("tsys_k = 300.0\n");
let offset = warm["receive_terminal"]["g_over_t_to_gain_offset_db"]
.as_f64()
.expect("offset");
assert!(
(offset - 10.0 * 300.0_f64.log10()).abs() < 1e-12,
"300 K offset is {offset} dB"
);
assert!(
(offset - 24.771_212_5).abs() < 1e-6,
"a 300 K terminal's G/T-to-gain offset is 24.77 dB, got {offset}"
);
assert!(
(warm["receive_terminal"]["rx_gain_dbi"].as_f64().unwrap() - offset).abs() < 1e-12,
"at G/T = 0 dB/K the gain is exactly the offset"
);
let cold = run("tsys_k = 29.0\n");
let cold_offset = cold["receive_terminal"]["g_over_t_to_gain_offset_db"]
.as_f64()
.unwrap();
assert!(
(cold_offset - 10.0 * 29.0_f64.log10()).abs() < 1e-12
&& (cold_offset - offset).abs() > 10.0,
"29 K offset {cold_offset} must differ from the 300 K offset {offset}"
);
let src = "band = \"x\"\ntsys_k = 0.0\n";
let scn: LinkBudgetScenario = toml::from_str(src).expect("parse");
assert!(
scn.run_json().is_err(),
"a non-positive noise temperature must be an error"
);
}
#[test]
fn the_units_block_describes_only_fields_that_exist() {
for extra in ["", "tsys_k = 300.0\n"] {
let src = format!(
"band = \"ka\"\neirp_dbw = 74.0\ng_over_t_db = 61.0\nrange_km = 3.6e8\n\
data_rate_bps = 1.0e5\nother_losses_db = 4.0\nrequired_eb_n0_db = 1.0\n{extra}"
);
let scn: LinkBudgetScenario = toml::from_str(&src).expect("parse");
let (json, _) = scn.run_json().expect("run");
let v: serde_json::Value = serde_json::from_str(&json).expect("json");
let units = v["units"].as_object().expect("a units block");
assert!(!units.is_empty());
for (field, meta) in units {
let mut cur = &v;
for seg in field.split('.') {
cur = cur.get(seg).unwrap_or_else(|| {
panic!("units names {field}, which the report does not emit")
});
}
assert!(
meta.get("unit").and_then(|u| u.as_str()).is_some(),
"{field} must state a unit"
);
assert!(
meta.get("provenance").and_then(|u| u.as_str()).is_some(),
"{field} must state a provenance class"
);
}
assert_eq!(
units.contains_key("receive_terminal.rx_gain_dbi"),
v.get("receive_terminal").is_some(),
"the units block and the report must agree on whether a gain split exists"
);
}
}
}