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 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,
}
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,
};
let r = link_budget(&p, self.required_eb_n0_db);
let 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,
});
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
);
}
}