diff --git a/Cargo.lock b/Cargo.lock index 86c8e10..916569c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/crates/rf/apsis-link/Cargo.toml b/crates/rf/apsis-link/Cargo.toml index 9c5c725..44c5e67 100644 --- a/crates/rf/apsis-link/Cargo.toml +++ b/crates/rf/apsis-link/Cargo.toml @@ -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 diff --git a/crates/rf/apsis-link/src/budget.rs b/crates/rf/apsis-link/src/budget.rs new file mode 100644 index 0000000..df03fc8 --- /dev/null +++ b/crates/rf/apsis-link/src/budget.rs @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 + } +} diff --git a/crates/rf/apsis-link/src/lib.rs b/crates/rf/apsis-link/src/lib.rs index 7e865d9..062b2df 100644 --- a/crates/rf/apsis-link/src/lib.rs +++ b/crates/rf/apsis-link/src/lib.rs @@ -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, +}; diff --git a/crates/rf/apsis-link/tests/budget.rs b/crates/rf/apsis-link/tests/budget.rs new file mode 100644 index 0000000..eec7c03 --- /dev/null +++ b/crates/rf/apsis-link/tests/budget.rs @@ -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()); +}