rf: add link budget with gain to temperature figure

This commit is contained in:
ApsisRS contributors 2026-09-26 14:38:32 +08:00
parent 32a9b1634f
commit fcc45eddbd
5 changed files with 630 additions and 0 deletions

6
Cargo.lock generated
View File

@ -101,6 +101,12 @@ version = "0.1.0"
[[package]]
name = "apsis-link"
version = "0.1.0"
dependencies = [
"apsis-antenna",
"apsis-core",
"apsis-noise",
"apsis-rf",
]
[[package]]
name = "apsis-macros"

View File

@ -11,3 +11,7 @@ publish = false
workspace = true
[dependencies]
apsis-core.workspace = true
apsis-rf.workspace = true
apsis-noise.workspace = true
apsis-antenna.workspace = true

View File

@ -0,0 +1,261 @@
//! The link budget and the figures derived from it.
use apsis_core::constant::BOLTZMANN;
use apsis_noise::NoiseTemperature;
use apsis_rf::{Decibel, Gain, Power, free_space_path_loss, wavelength};
/// The geometric part of a link: how far apart the terminals are and at what
/// frequency they operate.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LinkGeometry {
/// The distance between the terminals, in meters.
pub distance: f64,
/// The carrier frequency, in hertz.
pub frequency: f64,
}
impl LinkGeometry {
/// Returns the free space path loss for this geometry.
///
/// Returns `None` when the distance or frequency is not usable.
pub fn path_loss(self) -> Option<Decibel> {
free_space_path_loss(self.distance, self.frequency)
}
/// Returns `true` when the geometry is usable.
pub fn is_valid(self) -> bool {
self.distance.is_finite()
&& self.distance > 0.0
&& self.frequency.is_finite()
&& self.frequency > 0.0
}
}
/// A complete link budget for one direction of a radio link.
///
/// The losses are given as positive decibel values that are subtracted,
/// which is the convention in link budgets: a three decibel loss is entered
/// as three decibels, not as minus three.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LinkBudget {
/// The power delivered to the transmit antenna, in watts.
pub transmit_power: Power,
/// The transmit antenna gain on the relevant path.
pub transmit_gain: Gain,
/// Every loss between the transmitter and the transmit antenna, as a
/// positive decibel value.
pub transmit_loss: Decibel,
/// The receive antenna gain on the relevant path.
pub receive_gain: Gain,
/// Every loss between the receive antenna and the receiver input, as a
/// positive decibel value.
pub receive_loss: Decibel,
/// The system noise temperature referred to the receiver input.
pub system_temperature: NoiseTemperature,
/// The receiver noise bandwidth, in hertz.
pub bandwidth: f64,
/// The distance and frequency of the link.
pub geometry: LinkGeometry,
}
impl LinkBudget {
/// Returns the equivalent isotropically radiated power, in watts.
///
/// This is the transmit power reduced by the transmit losses and
/// increased by the transmit antenna gain. It is the power that an
/// isotropic radiator would have to radiate to produce the same flux
/// density in the direction of the receiver.
///
/// Returns `None` when any input is not usable.
pub fn eirp(&self) -> Option<Power> {
if !self.transmit_loss.is_finite() || !self.geometry.is_valid() {
return None;
}
let gain = self.transmit_gain.as_decibel()?;
let net = gain - self.transmit_loss;
self.transmit_power.scaled(net)
}
/// Returns the carrier power at the receiver input, in watts.
///
/// Returns `None` when any input is not usable.
pub fn received_power(&self) -> Option<Power> {
if !self.receive_loss.is_finite() {
return None;
}
let eirp = self.eirp()?;
let receive_gain = self.receive_gain.as_decibel()?;
let loss = self.geometry.path_loss()?;
eirp.scaled(receive_gain - loss - self.receive_loss)
}
/// Returns the noise power density at the receiver input, in watts per
/// hertz.
///
/// This is `k T`, the noise temperature converted to a power per unit
/// bandwidth. It does not depend on the receiver bandwidth, which is
/// what makes the carrier to noise density ratio a property of the link
/// rather than of the receiver.
///
/// Returns `None` when the system temperature is not usable.
pub fn noise_density(&self) -> Option<f64> {
if !self.system_temperature.is_valid() {
return None;
}
Some(BOLTZMANN * self.system_temperature.kelvin())
}
/// Returns the carrier to noise density ratio.
///
/// This is the carrier power divided by the noise power in one hertz.
/// It is the form used to compare a link against the energy per bit
/// requirement of a modulation, because the data rate is accounted for
/// separately.
///
/// Returns `None` when any input is not usable.
pub fn carrier_to_noise_density(&self) -> Option<Decibel> {
let carrier = self.received_power()?;
let density = self.noise_density()?;
if density <= 0.0 {
return None;
}
// The ratio is a power ratio, and the numerator and denominator
// carry different bandwidths, so the ratio is formed directly.
let ratio = carrier.watts() / density;
Decibel::from_ratio(ratio)
}
/// Returns the carrier to noise ratio in the receiver bandwidth.
///
/// This is the carrier to noise density ratio reduced by the bandwidth.
/// Unlike the density ratio it depends on the receiver, which is why the
/// same link can show different carrier to noise ratios in receivers of
/// different bandwidth.
///
/// Returns `None` when any input is not usable.
pub fn carrier_to_noise(&self) -> Option<Decibel> {
if !self.bandwidth.is_finite() || self.bandwidth <= 0.0 {
return None;
}
let density = self.carrier_to_noise_density()?;
Some(density - Decibel::from_ratio(self.bandwidth)?)
}
/// Returns the energy per bit to noise density ratio for a data rate.
///
/// This is the carrier to noise density ratio reduced by the data rate,
/// because energy per bit is carrier power divided by bit rate.
///
/// Returns `None` when the data rate is not positive, or when the
/// carrier to noise density ratio is not usable.
pub fn energy_per_bit_over_noise(&self, data_rate: f64) -> Option<Decibel> {
if !data_rate.is_finite() || data_rate <= 0.0 {
return None;
}
let density = self.carrier_to_noise_density()?;
Some(density - Decibel::from_ratio(data_rate)?)
}
/// Returns the rise in system noise caused by the receive antenna
/// viewing a noise source of the given temperature.
///
/// The antenna noise is added to the system temperature in power, which
/// in temperature is a sum. This is the form used to account for the
/// Earth, the Sun, or a rain cell in the beam.
///
/// Returns `None` when the added temperature is not usable.
pub fn with_added_noise(&self, added: NoiseTemperature) -> Option<Self> {
if !added.is_valid() {
return None;
}
Some(Self {
system_temperature: self.system_temperature + added,
..*self
})
}
/// Returns the wavelength of the carrier, in meters.
///
/// Returns `None` when the frequency is not usable.
pub fn carrier_wavelength(&self) -> Option<f64> {
wavelength(self.geometry.frequency)
}
/// Returns `true` when every input describes a usable link.
pub fn is_valid(&self) -> bool {
self.transmit_power.is_valid()
&& self.transmit_gain.dbi().is_some()
&& self.transmit_loss.is_finite()
&& self.receive_gain.dbi().is_some()
&& self.receive_loss.is_finite()
&& self.system_temperature.is_valid()
&& self.bandwidth.is_finite()
&& self.bandwidth > 0.0
&& self.geometry.is_valid()
}
}
/// The result of comparing a link against a requirement.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LinkMargin {
/// The ratio the link achieves, in decibels.
pub achieved: Decibel,
/// The ratio the link requires, in decibels.
pub required: Decibel,
}
impl LinkMargin {
/// Returns the margin, in decibels.
///
/// A positive margin means the link closes with room to spare; a
/// negative margin means it does not.
pub fn margin(self) -> Decibel {
self.achieved - self.required
}
/// Returns `true` when the margin is not negative.
pub fn closes(self) -> bool {
self.margin().db() >= 0.0
}
}
/// Returns the margin between an achieved ratio and a required one.
///
/// This is the comparison that decides whether a link closes.
pub fn link_margin(achieved: Decibel, required: Decibel) -> LinkMargin {
LinkMargin { achieved, required }
}
/// Returns the carrier to noise density ratio for a budget.
///
/// This is a free function form of
/// [`LinkBudget::carrier_to_noise_density`].
///
/// Returns `None` when the budget is not usable.
pub fn carrier_to_noise_density(budget: &LinkBudget) -> Option<Decibel> {
budget.carrier_to_noise_density()
}
/// Returns the gain to system temperature ratio of a receive chain, in
/// reciprocal kelvin.
///
/// This figure of merit is written `G / T` and is the standard way to
/// compare receive stations. It combines the receive antenna gain with the
/// system noise temperature, so a station with a bigger dish and a quieter
/// receiver scores higher.
///
/// Expressed in decibels, `G / T` is the receive gain in dBi minus ten times
/// the logarithm of the temperature, which is the form usually quoted.
///
/// Returns `None` when the gain or temperature is not usable.
pub fn gain_to_temperature(gain: Gain, temperature: NoiseTemperature) -> Option<f64> {
if !temperature.is_valid() || temperature.kelvin() <= 0.0 {
return None;
}
let ratio = gain.ratio() / temperature.kelvin();
if ratio.is_finite() && ratio > 0.0 {
Some(ratio)
} else {
None
}
}

View File

@ -1 +1,74 @@
//! Link budget computation.
//!
//! A link budget adds up every gain and loss between a transmitter and a
//! receiver and compares the resulting carrier power with the noise that
//! competes with it. This crate computes that sum and the figures derived
//! from it: the carrier to noise ratio, the energy per bit to noise density
//! ratio, and the link margin.
//!
//! The order of operations matters, so the chain is stated explicitly:
//!
//! 1. The transmitter power is reduced by the transmit losses and increased
//! by the transmit antenna gain, giving the equivalent isotropically
//! radiated power.
//! 2. That is divided by the free space path loss and increased by the
//! receive antenna gain, giving the carrier power at the receiver input.
//! 3. The system noise temperature is converted to a noise density by
//! multiplying by the Boltzmann constant.
//! 4. The carrier to noise density ratio is the carrier power divided by
//! that density, and the carrier to noise ratio divides it further by the
//! receiver bandwidth.
//!
//! Sources:
//!
//! - The link equation in this form is the one used throughout satellite
//! communications practice, as described for example in the ITU-R
//! Handbook on Satellite Communications.
//! - The Boltzmann constant and the speed of light come from the SI
//! definitions, as re-exported by `apsis-rf`.
//!
//! ```
//! use apsis_link::{LinkBudget, LinkGeometry};
//! use apsis_rf::{Gain, Power};
//!
//! let budget = LinkBudget {
//! transmit_power: Power::from_dbw(10.0).expect("finite"),
//! transmit_gain: Gain::from_dbi(30.0).expect("finite"),
//! transmit_loss: apsis_rf::Decibel::from_db(1.0),
//! receive_gain: Gain::from_dbi(35.0).expect("finite"),
//! receive_loss: apsis_rf::Decibel::from_db(0.5),
//! system_temperature: apsis_noise::NoiseTemperature::from_kelvin(150.0),
//! bandwidth: 36.0e6,
//! geometry: LinkGeometry {
//! distance: 38_000_000.0,
//! frequency: 12.0e9,
//! },
//! };
//!
//! // The carrier to noise density ratio is available directly. For this
//! // budget it is about 74.7 dB, which is typical of a Ku band downlink.
//! let ratio = budget.carrier_to_noise_density().expect("valid budget");
//! assert!(ratio.db() > 70.0);
//! assert!(ratio.db() < 80.0);
//! ```
#![forbid(unsafe_code)]
#![deny(missing_docs)]
mod budget;
pub use budget::{
LinkBudget, LinkGeometry, LinkMargin, carrier_to_noise_density, gain_to_temperature,
link_margin,
};
pub use apsis_antenna::{
ElementPattern, ParabolicAperture, Pattern, beam_width, mask_gain, off_axis_attenuation,
};
pub use apsis_noise::{
NoiseTemperature, REFERENCE_TEMPERATURE_K, Stage, cascade_temperature, thermal_noise_power,
};
pub use apsis_rf::{
BOLTZMANN, DIPOLE_OVER_ISOTROPIC_DB, Decibel, Gain, Power, SPEED_OF_LIGHT,
free_space_path_loss, wavelength,
};

View File

@ -0,0 +1,286 @@
//! Tests for the link budget.
//!
//! The expected values are computed by hand from the link equation and
//! recorded here so that a change to any part of the chain shows up as a
//! failing assertion rather than as a silently different budget.
use apsis_link::{
Decibel, Gain, LinkBudget, LinkGeometry, NoiseTemperature, Power, carrier_to_noise_density,
gain_to_temperature, link_margin,
};
const TOLERANCE: f64 = 1e-9;
/// A representative Ku band downlink: a ten watt transmitter, a three meter
/// ground station, a 36 MHz transponder, and a 150 K system temperature.
fn downlink() -> LinkBudget {
LinkBudget {
transmit_power: Power::from_dbw(10.0).expect("finite"),
transmit_gain: Gain::from_dbi(30.0).expect("finite"),
transmit_loss: Decibel::from_db(1.0),
receive_gain: Gain::from_dbi(35.0).expect("finite"),
receive_loss: Decibel::from_db(0.5),
system_temperature: NoiseTemperature::from_kelvin(150.0),
bandwidth: 36.0e6,
geometry: LinkGeometry {
distance: 38_000_000.0,
frequency: 12.0e9,
},
}
}
#[test]
fn eirp_sums_the_power_gain_and_loss() {
let budget = downlink();
// 10 dBW + 30 dBi - 1 dB = 39 dBW.
let eirp = budget.eirp().expect("valid budget");
assert!((eirp.dbw().expect("positive") - 39.0).abs() < TOLERANCE);
}
#[test]
fn the_path_loss_matches_the_closed_form() {
let budget = downlink();
let loss = budget.geometry.path_loss().expect("valid geometry");
// 4 pi d / lambda with d = 38 000 km and lambda = 0.02498 m gives
// 205.627 dB.
assert!(
(loss.db() - 205.627_080_075_172_1).abs() < 1e-9,
"{}",
loss.db()
);
}
#[test]
fn the_carrier_power_matches_the_hand_computation() {
let budget = downlink();
let carrier = budget.received_power().expect("valid budget");
// 39 dBW - 205.627 dB + 35 dBi - 0.5 dB = -132.127 dBW.
assert!(
(carrier.dbw().expect("positive") + 132.127_080_075_172_1).abs() < 1e-9,
"{}",
carrier.dbw().expect("positive")
);
}
#[test]
fn the_noise_density_is_the_boltzmann_constant_times_temperature() {
let budget = downlink();
let density = budget.noise_density().expect("valid temperature");
let expected = apsis_link::BOLTZMANN * 150.0;
assert!((density - expected).abs() < 1e-40);
// In decibels this is -206.838 dBW/Hz.
let db = 10.0 * density.log10();
assert!((db + 206.838_254_582_660_85).abs() < 1e-9, "{db}");
}
#[test]
fn the_carrier_to_noise_density_matches_the_hand_computation() {
let budget = downlink();
let ratio = budget.carrier_to_noise_density().expect("valid budget");
assert!(
(ratio.db() - 74.711_174_507_488_75).abs() < 1e-9,
"{}",
ratio.db()
);
}
#[test]
fn the_carrier_to_noise_matches_the_hand_computation() {
let budget = downlink();
let ratio = budget.carrier_to_noise().expect("valid budget");
assert!(
(ratio.db() + 0.851_850_500_184_127_7).abs() < 1e-9,
"{}",
ratio.db()
);
}
#[test]
fn the_energy_per_bit_matches_the_hand_computation() {
let budget = downlink();
let ratio = budget
.energy_per_bit_over_noise(10.0e6)
.expect("valid budget");
assert!(
(ratio.db() - 4.711_174_507_488_749).abs() < 1e-9,
"{}",
ratio.db()
);
}
#[test]
fn the_carrier_to_noise_density_helper_matches_the_method() {
let budget = downlink();
assert_eq!(
carrier_to_noise_density(&budget),
budget.carrier_to_noise_density()
);
}
#[test]
fn a_wider_bandwidth_lowers_the_carrier_to_noise() {
let narrow = LinkBudget {
bandwidth: 1.0e6,
..downlink()
};
let wide = LinkBudget {
bandwidth: 10.0e6,
..downlink()
};
let narrow_ratio = narrow.carrier_to_noise().expect("valid");
let wide_ratio = wide.carrier_to_noise().expect("valid");
// A factor of ten in bandwidth is ten decibels.
assert!((narrow_ratio.db() - wide_ratio.db() - 10.0).abs() < 1e-9);
// The carrier to noise density ratio does not depend on bandwidth.
assert_eq!(
narrow.carrier_to_noise_density(),
wide.carrier_to_noise_density()
);
}
#[test]
fn a_higher_data_rate_lowers_the_energy_per_bit() {
let budget = downlink();
let slow = budget.energy_per_bit_over_noise(1.0e6).expect("valid");
let fast = budget.energy_per_bit_over_noise(10.0e6).expect("valid");
assert!((slow.db() - fast.db() - 10.0).abs() < 1e-9);
}
#[test]
fn added_noise_raises_the_system_temperature() {
let budget = downlink();
let warmed = budget
.with_added_noise(NoiseTemperature::from_kelvin(50.0))
.expect("valid temperature");
assert_eq!(warmed.system_temperature.kelvin(), 200.0);
// The carrier to noise density ratio falls by 10 log10(200 / 150).
let before = budget.carrier_to_noise_density().expect("valid");
let after = warmed.carrier_to_noise_density().expect("valid");
let expected_drop = 10.0 * (200.0_f64 / 150.0).log10();
assert!((before.db() - after.db() - expected_drop).abs() < 1e-9);
}
#[test]
fn the_carrier_wavelength_matches_the_frequency() {
let budget = downlink();
let lambda = budget.carrier_wavelength().expect("valid frequency");
assert!((lambda - 299_792_458.0 / 12.0e9).abs() < 1e-12);
}
#[test]
fn validity_is_reported() {
assert!(downlink().is_valid());
let bad_geometry = LinkBudget {
geometry: LinkGeometry {
distance: 0.0,
frequency: 12.0e9,
},
..downlink()
};
assert!(!bad_geometry.is_valid());
assert_eq!(bad_geometry.eirp(), None);
assert_eq!(bad_geometry.received_power(), None);
let bad_temperature = LinkBudget {
system_temperature: NoiseTemperature::from_kelvin(-1.0),
..downlink()
};
assert!(!bad_temperature.is_valid());
assert_eq!(bad_temperature.noise_density(), None);
assert_eq!(bad_temperature.carrier_to_noise_density(), None);
let bad_bandwidth = LinkBudget {
bandwidth: -1.0,
..downlink()
};
assert!(!bad_bandwidth.is_valid());
assert_eq!(bad_bandwidth.carrier_to_noise(), None);
}
#[test]
fn an_invalid_data_rate_is_rejected() {
let budget = downlink();
assert_eq!(budget.energy_per_bit_over_noise(0.0), None);
assert_eq!(budget.energy_per_bit_over_noise(-1.0), None);
assert_eq!(budget.energy_per_bit_over_noise(f64::NAN), None);
}
#[test]
fn a_margin_reports_whether_the_link_closes() {
let achieved = Decibel::from_db(75.0);
let required = Decibel::from_db(70.0);
let margin = link_margin(achieved, required);
assert!((margin.margin().db() - 5.0).abs() < TOLERANCE);
assert!(margin.closes());
// A link that exactly meets the requirement closes with zero margin.
let exact = link_margin(required, required);
assert_eq!(exact.margin().db(), 0.0);
assert!(exact.closes());
// A link below the requirement does not close.
let short = link_margin(Decibel::from_db(65.0), required);
assert!(!short.closes());
assert!((short.margin().db() + 5.0).abs() < TOLERANCE);
}
#[test]
fn the_gain_to_temperature_ratio_uses_reciprocal_kelvin() {
let gain = Gain::from_dbi(35.0).expect("finite");
let temperature = NoiseTemperature::from_kelvin(150.0);
let ratio = gain_to_temperature(gain, temperature).expect("valid");
let expected = 10f64.powf(3.5) / 150.0;
assert!((ratio - expected).abs() < 1e-9);
// In decibels this is 35 - 10 log10(150), which is 13.239087 dB/K.
let db = 10.0 * ratio.log10();
assert!((db - 13.239_087_409_443_187).abs() < 1e-9, "{db}");
}
#[test]
fn a_hotter_receiver_lowers_the_gain_to_temperature_ratio() {
let gain = Gain::from_dbi(35.0).expect("finite");
let cold = gain_to_temperature(gain, NoiseTemperature::from_kelvin(100.0)).expect("valid");
let hot = gain_to_temperature(gain, NoiseTemperature::from_kelvin(200.0)).expect("valid");
assert!(cold > hot);
// Doubling the temperature halves the ratio, which is three decibels.
assert!((10.0 * (cold / hot).log10() - 3.010_299_956_639_812).abs() < 1e-9);
}
#[test]
fn a_zero_temperature_has_no_gain_to_temperature_ratio() {
let gain = Gain::from_dbi(35.0).expect("finite");
assert_eq!(
gain_to_temperature(gain, NoiseTemperature::from_kelvin(0.0)),
None
);
assert_eq!(
gain_to_temperature(gain, NoiseTemperature::from_kelvin(-1.0)),
None
);
}
#[test]
fn a_budget_can_be_used_with_a_computed_antenna_pattern() {
// The receive gain of a three meter dish at 12 GHz, rounded to a single
// decibel, is used here to confirm the budget accepts a gain derived
// from the antenna layer.
use apsis_link::ParabolicAperture;
let dish = ParabolicAperture {
diameter: 3.0,
efficiency: 0.65,
};
let wavelength = 0.025;
let gain = dish.peak_gain(wavelength).expect("valid dish");
let budget = LinkBudget {
receive_gain: gain,
geometry: LinkGeometry {
distance: 38_000_000.0,
frequency: 12.0e9,
},
..downlink()
};
assert!(budget.is_valid());
assert!(budget.carrier_to_noise_density().is_some());
}