From 73546ea8b2bc0f37ef4828d0aa4cfaf1948eb5b9 Mon Sep 17 00:00:00 2001 From: ApsisRS contributors Date: Sat, 26 Sep 2026 14:32:58 +0800 Subject: [PATCH] rf: add decibel, power, gain, and free space primitives --- Cargo.lock | 3 + crates/rf/apsis-rf/Cargo.toml | 1 + crates/rf/apsis-rf/src/decibel.rs | 126 +++++++++++++++++++++ crates/rf/apsis-rf/src/free_space.rs | 78 +++++++++++++ crates/rf/apsis-rf/src/gain.rs | 149 +++++++++++++++++++++++++ crates/rf/apsis-rf/src/lib.rs | 47 ++++++++ crates/rf/apsis-rf/src/power.rs | 137 +++++++++++++++++++++++ crates/rf/apsis-rf/tests/decibel.rs | 95 ++++++++++++++++ crates/rf/apsis-rf/tests/free_space.rs | 129 +++++++++++++++++++++ crates/rf/apsis-rf/tests/gain.rs | 117 +++++++++++++++++++ crates/rf/apsis-rf/tests/power.rs | 117 +++++++++++++++++++ 11 files changed, 999 insertions(+) create mode 100644 crates/rf/apsis-rf/src/decibel.rs create mode 100644 crates/rf/apsis-rf/src/free_space.rs create mode 100644 crates/rf/apsis-rf/src/gain.rs create mode 100644 crates/rf/apsis-rf/src/power.rs create mode 100644 crates/rf/apsis-rf/tests/decibel.rs create mode 100644 crates/rf/apsis-rf/tests/free_space.rs create mode 100644 crates/rf/apsis-rf/tests/gain.rs create mode 100644 crates/rf/apsis-rf/tests/power.rs diff --git a/Cargo.lock b/Cargo.lock index 274b747..a48f856 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -141,6 +141,9 @@ version = "0.1.0" [[package]] name = "apsis-rf" version = "0.1.0" +dependencies = [ + "apsis-core", +] [[package]] name = "apsis-routing" diff --git a/crates/rf/apsis-rf/Cargo.toml b/crates/rf/apsis-rf/Cargo.toml index 26d8bf9..ddbe0a0 100644 --- a/crates/rf/apsis-rf/Cargo.toml +++ b/crates/rf/apsis-rf/Cargo.toml @@ -11,3 +11,4 @@ publish = false workspace = true [dependencies] +apsis-core.workspace = true diff --git a/crates/rf/apsis-rf/src/decibel.rs b/crates/rf/apsis-rf/src/decibel.rs new file mode 100644 index 0000000..7f08659 --- /dev/null +++ b/crates/rf/apsis-rf/src/decibel.rs @@ -0,0 +1,126 @@ +//! The decibel scale. + +use core::fmt; + +/// A ratio expressed in decibels. +/// +/// A decibel is ten times the base ten logarithm of a power ratio, so a +/// value of zero is unity gain or loss, a value of three is very close to a +/// doubling, and a value of minus three is very close to a halving. +/// +/// The type stores the decibel value rather than the ratio, because a chain +/// of gains and losses is then a sum, which is both faster and less prone to +/// overflow than a product of ratios. +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Default)] +pub struct Decibel(f64); + +impl Decibel { + /// The value for a ratio of one, which is zero decibels. + pub const UNITY: Self = Self(0.0); + + /// The value for a ratio of two, which is about 3.0103 decibels. + pub const DOUBLING: Self = Self(3.010_299_956_639_812); + + /// Creates a value from a decibel reading. + pub const fn from_db(decibels: f64) -> Self { + Self(decibels) + } + + /// Creates a value from a power ratio. + /// + /// Returns `None` when the ratio is not positive and finite, because the + /// logarithm is not defined there. + pub fn from_ratio(ratio: f64) -> Option { + if ratio > 0.0 && ratio.is_finite() { + Some(Self(10.0 * ratio.log10())) + } else { + None + } + } + + /// Creates a value from an amplitude ratio. + /// + /// An amplitude, unlike a power, scales the decibel value by twenty + /// rather than ten, because power is proportional to amplitude squared. + /// Use this for a voltage or field ratio. + /// + /// Returns `None` when the ratio is not positive and finite. + pub fn from_amplitude_ratio(ratio: f64) -> Option { + if ratio > 0.0 && ratio.is_finite() { + Some(Self(20.0 * ratio.log10())) + } else { + None + } + } + + /// Returns the reading in decibels. + pub const fn db(self) -> f64 { + self.0 + } + + /// Returns the power ratio this reading represents. + pub fn ratio(self) -> f64 { + 10f64.powf(self.0 / 10.0) + } + + /// Returns the amplitude ratio this reading represents. + pub fn amplitude_ratio(self) -> f64 { + 10f64.powf(self.0 / 20.0) + } + + /// Returns the sum of two readings. + /// + /// Adding decibel values multiplies the ratios they represent, which is + /// how a cascade of gains and losses is combined. + pub const fn plus(self, other: Self) -> Self { + Self(self.0 + other.0) + } + + /// Returns the difference of two readings. + /// + /// Subtracting decibel values divides the ratios they represent, which + /// is how a loss is removed from a gain. + pub const fn minus(self, other: Self) -> Self { + Self(self.0 - other.0) + } + + /// Returns this reading negated, which inverts the ratio. + pub const fn negated(self) -> Self { + Self(-self.0) + } + + /// Returns `true` when the reading is finite. + pub const fn is_finite(self) -> bool { + self.0.is_finite() + } +} + +impl fmt::Display for Decibel { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} dB", self.0) + } +} + +impl core::ops::Add for Decibel { + type Output = Self; + + fn add(self, rhs: Self) -> Self { + self.plus(rhs) + } +} + +impl core::ops::Sub for Decibel { + type Output = Self; + + fn sub(self, rhs: Self) -> Self { + self.minus(rhs) + } +} + +impl core::ops::Neg for Decibel { + type Output = Self; + + fn neg(self) -> Self { + self.negated() + } +} diff --git a/crates/rf/apsis-rf/src/free_space.rs b/crates/rf/apsis-rf/src/free_space.rs new file mode 100644 index 0000000..f8c7cf6 --- /dev/null +++ b/crates/rf/apsis-rf/src/free_space.rs @@ -0,0 +1,78 @@ +//! Free space propagation. + +use crate::decibel::Decibel; +use crate::gain::Gain; +use apsis_core::constant::SPEED_OF_LIGHT; +use core::f64::consts::PI; + +/// Returns the wavelength of a frequency in vacuum, in meters. +/// +/// The relation is `lambda = c / f`, with `c` exact by the definition of the +/// metre. +/// +/// Returns `None` when the frequency is not positive and finite. +pub fn wavelength(frequency: f64) -> Option { + if frequency > 0.0 && frequency.is_finite() { + Some(SPEED_OF_LIGHT / frequency) + } else { + None + } +} + +/// Returns the free space path loss between two isotropic antennas, as a +/// ratio in decibels. +/// +/// The ratio is positive, so it represents a loss: the received power is the +/// transmitted power divided by this ratio. The relation is +/// +/// ```text +/// L = (4 pi d / lambda)^2 +/// ``` +/// +/// which is the spreading loss of a spherical wave over a distance `d` +/// combined with the effective aperture of an isotropic receive antenna. +/// +/// The loss grows with frequency at a fixed distance, because the effective +/// aperture of the receive antenna falls as the square of the wavelength. +/// +/// The distance must be positive. A zero distance is not a case this +/// expression covers: the ratio would be zero, which is infinite attenuation +/// in decibels, and the underlying far field assumption does not hold. +/// +/// Returns `None` when the distance is not positive, or when either argument +/// is not finite. +pub fn free_space_path_loss(distance: f64, frequency: f64) -> Option { + if !distance.is_finite() || distance <= 0.0 { + return None; + } + let lambda = wavelength(frequency)?; + let ratio = 4.0 * PI * distance / lambda; + Decibel::from_ratio(ratio * ratio) +} + +/// Returns the received power ratio between two antennas in free space. +/// +/// The received power is the transmitted power multiplied by the two antenna +/// gains and divided by the free space path loss, which in decibels is the +/// sum +/// +/// ```text +/// P_r / P_t = G_t G_r / L +/// ``` +/// +/// The result is the ratio in decibels, so a positive value means the +/// receive antenna collects more than was transmitted, which happens when +/// the two gains exceed the spreading loss. +/// +/// Returns `None` when the distance or frequency is not usable. +pub fn free_space_received_ratio( + distance: f64, + frequency: f64, + transmitter: Gain, + receiver: Gain, +) -> Option { + let loss = free_space_path_loss(distance, frequency)?; + let transmitter = transmitter.as_decibel()?; + let receiver = receiver.as_decibel()?; + Some(transmitter + receiver - loss) +} diff --git a/crates/rf/apsis-rf/src/gain.rs b/crates/rf/apsis-rf/src/gain.rs new file mode 100644 index 0000000..c3b3bf0 --- /dev/null +++ b/crates/rf/apsis-rf/src/gain.rs @@ -0,0 +1,149 @@ +//! Antenna gain. + +use crate::decibel::Decibel; +use core::f64::consts::PI; +use core::fmt; + +/// The gain of a half wave dipole relative to an isotropic radiator, in +/// decibels. +/// +/// The exact directivity of an ideal half wave dipole is 1.6409, which is +/// 2.1509 dB. Engineering practice rounds this to 2.15 dB, and that rounded +/// value is what the `dBd` and `dBi` conventions are defined against. This +/// crate uses the conventional value so that a conversion between the two +/// named forms is exact, and it derives the linear ratio from it. +pub const DIPOLE_OVER_ISOTROPIC_DB: f64 = 2.15; + +/// The linear gain ratio of a half wave dipole, `10^(2.15 / 10)`. +/// +/// This is about 1.64059, within 0.02 percent of the exact directivity of an +/// ideal dipole, which is 1.6409. +pub const HALF_WAVE_DIPOLE_RATIO: f64 = 1.640_589_773_199_539_4; + +/// An antenna gain expressed as a ratio to an isotropic radiator. +/// +/// The type stores the linear ratio. The two named decibel forms are offered +/// as conversions, because the choice is a matter of convention rather than +/// of the gain itself: +/// +/// - `dBi` is the ratio to an isotropic radiator, which radiates equally in +/// every direction. +/// - `dBd` is the ratio to a half wave dipole, which is +/// [`DIPOLE_OVER_ISOTROPIC_DB`] decibels above an isotropic radiator. +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] +pub struct Gain(f64); + +impl Default for Gain { + /// Returns unit gain, matching [`Gain::ISOTROPIC`]. + /// + /// This is written by hand rather than derived, because the default for + /// a ratio type is not its zero but its unit. + fn default() -> Self { + Self::ISOTROPIC + } +} + +impl Gain { + /// Unit gain, as an isotropic radiator has. + pub const ISOTROPIC: Self = Self(1.0); + + /// The gain of a half wave dipole, about 1.6406. + pub const HALF_WAVE_DIPOLE: Self = Self(HALF_WAVE_DIPOLE_RATIO); + + /// Creates a gain from a linear ratio. + pub const fn from_ratio(ratio: f64) -> Self { + Self(ratio) + } + + /// Creates a gain from a value in decibels relative to an isotropic + /// radiator. + /// + /// A gain ratio is never negative, so any finite reading is accepted. + pub fn from_dbi(dbi: f64) -> Option { + if !dbi.is_finite() { + return None; + } + Some(Self(Decibel::from_db(dbi).ratio())) + } + + /// Creates a gain from a value in decibels relative to a half wave + /// dipole. + /// + /// Returns `None` when the reading is not finite. + pub fn from_dbd(dbd: f64) -> Option { + if !dbd.is_finite() { + return None; + } + Self::from_dbi(dbd + DIPOLE_OVER_ISOTROPIC_DB) + } + + /// Returns the linear ratio to an isotropic radiator. + pub const fn ratio(self) -> f64 { + self.0 + } + + /// Returns this gain in decibels relative to an isotropic radiator. + /// + /// Returns `None` when the ratio is not positive. + pub fn dbi(self) -> Option { + if self.0 > 0.0 && self.0.is_finite() { + Some(10.0 * self.0.log10()) + } else { + None + } + } + + /// Returns this gain in decibels relative to a half wave dipole. + /// + /// Returns `None` when the ratio is not positive. + pub fn dbd(self) -> Option { + self.dbi().map(|dbi| dbi - DIPOLE_OVER_ISOTROPIC_DB) + } + + /// Returns this gain as a ratio in decibels. + /// + /// Returns `None` when the ratio is not positive. + pub fn as_decibel(self) -> Option { + Decibel::from_ratio(self.0) + } + + /// Returns the effective aperture of an antenna with this gain at the + /// given wavelength, in square meters. + /// + /// The relation is `A = G lambda^2 / (4 pi)`. It follows from the + /// requirement that an antenna in a thermal bath receive as much power + /// as it radiates. + /// + /// Returns `None` when the wavelength is not positive and finite. + pub fn effective_aperture(self, wavelength: f64) -> Option { + if wavelength > 0.0 && wavelength.is_finite() && self.0.is_finite() { + Some(self.0 * wavelength * wavelength / (4.0 * PI)) + } else { + None + } + } + + /// Returns the gain of an antenna with this effective aperture at the + /// given wavelength. + /// + /// This is the inverse of [`Gain::effective_aperture`]. + /// + /// Returns `None` when the wavelength is not positive and finite, or + /// when the aperture is negative. + pub fn from_effective_aperture(aperture: f64, wavelength: f64) -> Option { + if wavelength > 0.0 && wavelength.is_finite() && aperture >= 0.0 && aperture.is_finite() { + Some(Self(4.0 * PI * aperture / (wavelength * wavelength))) + } else { + None + } + } +} + +impl fmt::Display for Gain { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.dbi() { + Some(dbi) => write!(f, "{dbi} dBi"), + None => write!(f, "{} (ratio)", self.0), + } + } +} diff --git a/crates/rf/apsis-rf/src/lib.rs b/crates/rf/apsis-rf/src/lib.rs index d76548d..098f8a1 100644 --- a/crates/rf/apsis-rf/src/lib.rs +++ b/crates/rf/apsis-rf/src/lib.rs @@ -1 +1,48 @@ //! Core radio frequency primitives. +//! +//! This crate holds the quantities that the rest of the radio frequency +//! layer is written in: power, gain, and the decibel scale that both are +//! usually quoted on, together with the two relations that every link +//! calculation starts from, the wavelength of a frequency and the free +//! space path loss. +//! +//! Units are SI throughout. Power is in watts, frequency in hertz, distance +//! in meters, and temperature in kelvin. Decibel values are power ratios, +//! computed as ten times the base ten logarithm of the ratio. +//! +//! Two conventions are worth stating because they are easy to mix up: +//! +//! - A decibel is a ratio between two powers. A gain expressed in `dBi` is a +//! ratio to an isotropic radiator and one in `dBd` is a ratio to a half +//! wave dipole, which is 2.15 dB more directive than an isotropic +//! radiator. [`Gain`] carries the ratio and both named forms. +//! - The free space path loss increases with frequency as well as with +//! distance, because both the effective aperture of a fixed gain antenna +//! and the solid angle it subtends shrink as the wavelength shortens. +//! +//! ``` +//! use apsis_rf::{Power, wavelength}; +//! +//! // A noise power of -204 dBW is about 4e-21 W. +//! let noise = Power::from_dbw(-204.0).expect("the reading is finite"); +//! assert!(noise.watts() > 0.0); +//! +//! // A one meter wavelength corresponds to a frequency of 299.792 MHz. +//! let lambda = wavelength(299_792_458.0).expect("the frequency is positive"); +//! assert!((lambda - 1.0).abs() < 1e-12); +//! ``` + +#![forbid(unsafe_code)] +#![deny(missing_docs)] + +mod decibel; +mod free_space; +mod gain; +mod power; + +pub use decibel::Decibel; +pub use free_space::{free_space_path_loss, free_space_received_ratio, wavelength}; +pub use gain::{DIPOLE_OVER_ISOTROPIC_DB, Gain, HALF_WAVE_DIPOLE_RATIO}; +pub use power::{DBM_REFERENCE, DBW_REFERENCE, Power}; + +pub use apsis_core::constant::{BOLTZMANN, SPEED_OF_LIGHT}; diff --git a/crates/rf/apsis-rf/src/power.rs b/crates/rf/apsis-rf/src/power.rs new file mode 100644 index 0000000..dc1385f --- /dev/null +++ b/crates/rf/apsis-rf/src/power.rs @@ -0,0 +1,137 @@ +//! Radio frequency power. + +use crate::decibel::Decibel; +use core::fmt; + +/// The reference power of the decibel watt, one watt. +/// +/// A power quoted in dBW is a ratio to this value. +pub const DBW_REFERENCE: f64 = 1.0; + +/// The reference power of the decibel milliwatt, one milliwatt. +/// +/// A power quoted in dBm is a ratio to this value, so a value in dBm is +/// thirty decibels above the same value in dBW. +pub const DBM_REFERENCE: f64 = 1.0e-3; + +/// A power level in watts. +/// +/// The type stores watts, which is the unit the decibel references are +/// defined against, and offers conversion to and from the two decibel forms +/// used in link budgets. +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Default)] +pub struct Power(f64); + +impl Power { + /// The reference power of zero dBW, one watt. + pub const WATT: Self = Self(1.0); + + /// The reference power of zero dBm, one milliwatt. + pub const MILLIWATT: Self = Self(1.0e-3); + + /// Creates a power level from a value in watts. + /// + /// A power may be zero, which represents no power, but not negative. + pub const fn from_watts(watts: f64) -> Self { + Self(watts) + } + + /// Creates a power level from a value in milliwatts. + pub const fn from_milliwatts(milliwatts: f64) -> Self { + Self(milliwatts * 1.0e-3) + } + + /// Creates a power level from a value in decibel watts. + /// + /// A zero or negative reading cannot be expressed as a finite power, so + /// no validation is needed. + pub fn from_dbw(decibel_watts: f64) -> Option { + if !decibel_watts.is_finite() { + return None; + } + Some(Self(DBW_REFERENCE * 10f64.powf(decibel_watts / 10.0))) + } + + /// Creates a power level from a value in decibel milliwatts. + /// + /// Returns `None` when the reading is not finite. + pub fn from_dbm(decibel_milliwatts: f64) -> Option { + if !decibel_milliwatts.is_finite() { + return None; + } + Some(Self(DBM_REFERENCE * 10f64.powf(decibel_milliwatts / 10.0))) + } + + /// Returns this power in watts. + pub const fn watts(self) -> f64 { + self.0 + } + + /// Returns this power in milliwatts. + pub const fn milliwatts(self) -> f64 { + self.0 * 1.0e3 + } + + /// Returns this power in decibel watts. + /// + /// Returns `None` when the power is not positive, because the logarithm + /// is not defined there. + pub fn dbw(self) -> Option { + if self.0 > 0.0 && self.0.is_finite() { + Some(10.0 * (self.0 / DBW_REFERENCE).log10()) + } else { + None + } + } + + /// Returns this power in decibel milliwatts. + /// + /// Returns `None` when the power is not positive. + pub fn dbm(self) -> Option { + if self.0 > 0.0 && self.0.is_finite() { + Some(10.0 * (self.0 / DBM_REFERENCE).log10()) + } else { + None + } + } + + /// Returns the ratio of `self` to `rhs`, in decibels. + /// + /// This is the form used in a link budget to express a loss or a gain + /// between two power levels. + /// + /// Returns `None` when either power is not positive. + pub fn ratio_in_decibels(self, rhs: Self) -> Option { + if self.0 > 0.0 && rhs.0 > 0.0 { + Decibel::from_ratio(self.0 / rhs.0) + } else { + None + } + } + + /// Returns this power scaled by a ratio in decibels. + /// + /// Returns `None` when the resulting power is not finite, which happens + /// for a very large gain. + pub fn scaled(self, ratio: Decibel) -> Option { + if self.0 == 0.0 { + return Some(self); + } + let scaled = self.0 * ratio.ratio(); + scaled.is_finite().then_some(Self(scaled)) + } + + /// Returns `true` when the power is finite and not negative. + pub const fn is_valid(self) -> bool { + self.0.is_finite() && self.0 >= 0.0 + } +} + +impl fmt::Display for Power { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.dbw() { + Some(dbw) => write!(f, "{dbw} dBW"), + None => write!(f, "{} W", self.0), + } + } +} diff --git a/crates/rf/apsis-rf/tests/decibel.rs b/crates/rf/apsis-rf/tests/decibel.rs new file mode 100644 index 0000000..6aaf2d6 --- /dev/null +++ b/crates/rf/apsis-rf/tests/decibel.rs @@ -0,0 +1,95 @@ +//! Tests for the decibel scale. + +use apsis_rf::Decibel; + +const TOLERANCE: f64 = 1e-12; + +#[test] +fn unity_and_doubling_have_the_expected_readings() { + assert_eq!(Decibel::UNITY.db(), 0.0); + assert!((Decibel::UNITY.ratio() - 1.0).abs() < TOLERANCE); + // Three decibels is a ratio of 1.9953, not exactly two, which is the + // reason the doubling constant is an approximation in practice. + assert!((Decibel::DOUBLING.db() - 3.010_299_956_639_812).abs() < TOLERANCE); + assert!((Decibel::DOUBLING.ratio() - 2.0).abs() < TOLERANCE); +} + +#[test] +fn ratio_and_reading_round_trip() { + for reading in [-30.0, -10.0, -3.0, 0.0, 1.5, 10.0, 30.0, 100.0] { + let value = Decibel::from_db(reading); + let ratio = value.ratio(); + let recovered = Decibel::from_ratio(ratio).expect("ratio is positive"); + assert!((recovered.db() - reading).abs() < 1e-9, "reading {reading}"); + } +} + +#[test] +fn a_ratio_of_ten_is_ten_decibels() { + assert!((Decibel::from_ratio(10.0).expect("positive").db() - 10.0).abs() < TOLERANCE); + assert!((Decibel::from_ratio(100.0).expect("positive").db() - 20.0).abs() < TOLERANCE); + assert!((Decibel::from_ratio(0.001).expect("positive").db() + 30.0).abs() < TOLERANCE); +} + +#[test] +fn amplitude_ratios_use_twenty_per_decade() { + // A voltage ratio of ten is twenty decibels, because power is + // proportional to the square of the amplitude. + let value = Decibel::from_amplitude_ratio(10.0).expect("positive"); + assert!((value.db() - 20.0).abs() < TOLERANCE); + assert!((value.amplitude_ratio() - 10.0).abs() < 1e-9); +} + +#[test] +fn non_positive_ratios_are_rejected() { + assert_eq!(Decibel::from_ratio(0.0), None); + assert_eq!(Decibel::from_ratio(-1.0), None); + assert_eq!(Decibel::from_ratio(f64::NAN), None); + assert_eq!(Decibel::from_ratio(f64::INFINITY), None); + assert_eq!(Decibel::from_amplitude_ratio(0.0), None); +} + +#[test] +fn addition_multiplies_the_ratio() { + // The exact doubling constant added to itself quadruples the ratio. + let total = Decibel::DOUBLING + Decibel::DOUBLING; + assert!((total.ratio() - 4.0).abs() < 1e-9); + + // Two ten decibel gains make a gain of one hundred. + let decades = Decibel::from_db(10.0) + Decibel::from_db(10.0); + assert!((decades.db() - 20.0).abs() < TOLERANCE); + assert!((decades.ratio() - 100.0).abs() < 1e-9); +} + +#[test] +fn subtraction_divides_the_ratio() { + let net = Decibel::from_db(10.0) - Decibel::from_db(3.0); + assert!((net.db() - 7.0).abs() < TOLERANCE); + let ratio = Decibel::from_db(10.0).ratio() / Decibel::from_db(3.0).ratio(); + assert!((net.ratio() - ratio).abs() < 1e-9); +} + +#[test] +fn negation_inverts_the_ratio() { + let value = Decibel::from_db(7.5); + assert!((-value).db() + value.db() < TOLERANCE); + assert!(((-value).ratio() * value.ratio() - 1.0).abs() < 1e-12); + assert_eq!((-value).db(), value.negated().db()); +} + +#[test] +fn finiteness_is_reported() { + assert!(Decibel::from_db(1.0).is_finite()); + assert!(!Decibel::from_db(f64::INFINITY).is_finite()); + assert!(!Decibel::from_db(f64::NAN).is_finite()); +} + +#[test] +fn display_includes_the_unit() { + assert_eq!(Decibel::from_db(3.5).to_string(), "3.5 dB"); +} + +#[test] +fn default_is_unity() { + assert_eq!(Decibel::default(), Decibel::UNITY); +} diff --git a/crates/rf/apsis-rf/tests/free_space.rs b/crates/rf/apsis-rf/tests/free_space.rs new file mode 100644 index 0000000..506a7f8 --- /dev/null +++ b/crates/rf/apsis-rf/tests/free_space.rs @@ -0,0 +1,129 @@ +//! Tests for free space propagation. + +use apsis_core::constant::SPEED_OF_LIGHT; +use apsis_rf::{Gain, free_space_path_loss, free_space_received_ratio, wavelength}; + +const TOLERANCE: f64 = 1e-9; + +#[test] +fn wavelength_is_the_speed_of_light_over_frequency() { + assert!((wavelength(SPEED_OF_LIGHT).expect("valid") - 1.0).abs() < TOLERANCE); + // A frequency of 300 MHz has a wavelength of about one meter. + assert!((wavelength(300.0e6).expect("valid") - 0.999_308).abs() < 1e-5); + // The same relation holds for a microwave frequency. + assert!((wavelength(12.0e9).expect("valid") - 0.024_982_7).abs() < 1e-6); +} + +#[test] +fn wavelength_rejects_invalid_frequencies() { + assert_eq!(wavelength(0.0), None); + assert_eq!(wavelength(-1.0), None); + assert_eq!(wavelength(f64::NAN), None); + assert_eq!(wavelength(f64::INFINITY), None); +} + +#[test] +fn path_loss_matches_the_closed_form() { + // Attenuation over one meter at one meter wavelength: the ratio is + // (4 pi)^2, about 21.98 dB. + let loss = free_space_path_loss(1.0, SPEED_OF_LIGHT).expect("valid arguments"); + let expected = 10.0 * (4.0 * core::f64::consts::PI).powi(2).log10(); + assert!((loss.db() - expected).abs() < 1e-9, "loss {}", loss.db()); + assert!((loss.db() - 21.984).abs() < 0.001); +} + +#[test] +fn path_loss_matches_the_engineering_form() { + // The form used in link budgets, with distance in kilometers and + // frequency in megahertz, + // + // L = 32.44 + 20 log10(d_km) + 20 log10(f_MHz) + // + // has a constant that follows from the speed of light rounded to + // 299 792 458 m/s, and agrees with the expression used here to the + // precision of that constant. + let distance_km = 1_000.0; + let frequency_mhz = 12_000.0; + let loss = free_space_path_loss(distance_km * 1_000.0, frequency_mhz * 1.0e6) + .expect("valid arguments"); + let engineering = 32.44 + 20.0 * distance_km.log10() + 20.0 * frequency_mhz.log10(); + assert!( + (loss.db() - engineering).abs() < 0.01, + "loss {} engineering {}", + loss.db(), + engineering + ); +} + +#[test] +fn a_larger_distance_increases_the_loss_by_six_decibels_per_doubling() { + let near = free_space_path_loss(1_000.0, 1.0e9).expect("valid"); + let far = free_space_path_loss(2_000.0, 1.0e9).expect("valid"); + assert!((far.db() - near.db() - 6.020_6).abs() < 1e-3); +} + +#[test] +fn a_higher_frequency_increases_the_loss() { + let low = free_space_path_loss(1_000.0, 1.0e9).expect("valid"); + let high = free_space_path_loss(1_000.0, 2.0e9).expect("valid"); + assert!(high.db() > low.db()); + assert!((high.db() - low.db() - 6.020_6).abs() < 1e-3); +} + +#[test] +fn a_zero_or_negative_distance_is_rejected() { + // A zero distance is outside the far field range where the expression + // applies, and would give an infinite loss in decibels. + assert_eq!(free_space_path_loss(0.0, 1.0e9), None); + assert_eq!(free_space_path_loss(-1.0, 1.0e9), None); +} + +#[test] +fn invalid_arguments_are_rejected() { + assert_eq!(free_space_path_loss(-1.0, 1.0e9), None); + assert_eq!(free_space_path_loss(f64::NAN, 1.0e9), None); + assert_eq!(free_space_path_loss(1_000.0, 0.0), None); + assert_eq!(free_space_path_loss(1_000.0, -1.0), None); +} + +#[test] +fn received_ratio_combines_gains_and_loss() { + let distance = 1_000_000.0; + let frequency = 12.0e9; + let transmitter = Gain::from_dbi(30.0).expect("valid"); + let receiver = Gain::from_dbi(40.0).expect("valid"); + let ratio = + free_space_received_ratio(distance, frequency, transmitter, receiver).expect("valid"); + let loss = free_space_path_loss(distance, frequency).expect("valid"); + assert!((ratio.db() - (30.0 + 40.0 - loss.db())).abs() < 1e-9); +} + +#[test] +fn a_large_receive_gain_can_exceed_the_spreading_loss() { + // Two dishes of 40 dBi at ten meters and 12 GHz. The spreading loss + // there is about 74 dB, so the 80 dB of combined gain leaves a positive + // ratio. This is the near field of a large antenna pair and is the + // reason a link budget cannot be read without also checking the + // distance regime. + let ratio = free_space_received_ratio( + 10.0, + 12.0e9, + Gain::from_dbi(40.0).expect("valid"), + Gain::from_dbi(40.0).expect("valid"), + ) + .expect("valid"); + assert!(ratio.db() > 0.0, "ratio {}", ratio.db()); + assert!((ratio.db() - 5.969).abs() < 0.01, "ratio {}", ratio.db()); +} + +#[test] +fn invalid_received_ratio_arguments_are_rejected() { + assert_eq!( + free_space_received_ratio(-1.0, 1.0e9, Gain::ISOTROPIC, Gain::ISOTROPIC), + None + ); + assert_eq!( + free_space_received_ratio(1_000.0, 0.0, Gain::ISOTROPIC, Gain::ISOTROPIC), + None + ); +} diff --git a/crates/rf/apsis-rf/tests/gain.rs b/crates/rf/apsis-rf/tests/gain.rs new file mode 100644 index 0000000..e0a90a6 --- /dev/null +++ b/crates/rf/apsis-rf/tests/gain.rs @@ -0,0 +1,117 @@ +//! Tests for antenna gain. + +use apsis_rf::{DIPOLE_OVER_ISOTROPIC_DB, Gain, HALF_WAVE_DIPOLE_RATIO}; +use core::f64::consts::PI; + +const TOLERANCE: f64 = 1e-9; + +#[test] +fn named_gains_match_their_definitions() { + assert_eq!(Gain::ISOTROPIC.ratio(), 1.0); + assert_eq!(Gain::ISOTROPIC.dbi(), Some(0.0)); + assert!(Gain::ISOTROPIC.dbd().expect("positive") < 0.0); + + // The linear ratio and the decibel offset are derived from each other, + // so the two must agree. + assert!((Gain::HALF_WAVE_DIPOLE.ratio() - HALF_WAVE_DIPOLE_RATIO).abs() < 1e-15); + assert!( + (Gain::HALF_WAVE_DIPOLE.dbi().expect("positive") - DIPOLE_OVER_ISOTROPIC_DB).abs() < 1e-12 + ); + assert!(Gain::HALF_WAVE_DIPOLE.dbd().expect("positive").abs() < 1e-9); +} + +#[test] +fn a_dipole_gain_is_zero_dbd_and_positive_dbi() { + let gain = Gain::from_dbd(0.0).expect("finite reading"); + assert!((gain.ratio() - Gain::HALF_WAVE_DIPOLE.ratio()).abs() < 1e-12); + assert!((gain.dbd().expect("positive")).abs() < 1e-12); + assert!((gain.dbi().expect("positive") - DIPOLE_OVER_ISOTROPIC_DB).abs() < 1e-9); +} + +#[test] +fn decibel_forms_round_trip() { + for dbi in [-10.0, -3.0, 0.0, 3.0, 20.0, 45.0] { + let gain = Gain::from_dbi(dbi).expect("finite reading"); + assert!( + (gain.dbi().expect("positive") - dbi).abs() < 1e-9, + "dbi {dbi}" + ); + } + for dbd in [-5.0, 0.0, 10.0, 20.0] { + let gain = Gain::from_dbd(dbd).expect("finite reading"); + assert!( + (gain.dbd().expect("positive") - dbd).abs() < 1e-9, + "dbd {dbd}" + ); + } +} + +#[test] +fn dbd_and_dbi_differ_by_the_dipole_offset() { + let gain = Gain::from_dbi(12.0).expect("finite reading"); + assert!( + (gain.dbi().expect("positive") - gain.dbd().expect("positive") - DIPOLE_OVER_ISOTROPIC_DB) + .abs() + < 1e-9 + ); +} + +#[test] +fn non_finite_readings_are_rejected() { + assert_eq!(Gain::from_dbi(f64::NAN), None); + assert_eq!(Gain::from_dbi(f64::INFINITY), None); + assert_eq!(Gain::from_dbd(f64::NAN), None); +} + +#[test] +fn effective_aperture_follows_the_standard_relation() { + // A gain of one has an aperture of lambda^2 / (4 pi). + let gain = Gain::ISOTROPIC; + let aperture = gain.effective_aperture(0.3).expect("valid wavelength"); + assert!((aperture - 0.3 * 0.3 / (4.0 * PI)).abs() < TOLERANCE); +} + +#[test] +fn a_larger_wavelength_needs_a_larger_aperture() { + let gain = Gain::from_dbi(30.0).expect("finite reading"); + let small = gain.effective_aperture(0.01).expect("valid"); + let large = gain.effective_aperture(0.1).expect("valid"); + assert!(large > small); + // The aperture grows with the square of the wavelength. + assert!((large / small - 100.0).abs() < 1e-6); +} + +#[test] +fn aperture_and_gain_round_trip() { + let gain = Gain::from_dbi(33.0).expect("finite reading"); + let wavelength = 0.025; + let aperture = gain.effective_aperture(wavelength).expect("valid"); + let recovered = Gain::from_effective_aperture(aperture, wavelength).expect("valid"); + assert!((recovered.ratio() - gain.ratio()).abs() < 1e-6); +} + +#[test] +fn invalid_aperture_arguments_are_rejected() { + assert_eq!(Gain::ISOTROPIC.effective_aperture(0.0), None); + assert_eq!(Gain::ISOTROPIC.effective_aperture(-1.0), None); + assert_eq!(Gain::ISOTROPIC.effective_aperture(f64::NAN), None); + assert_eq!(Gain::from_effective_aperture(1.0, 0.0), None); + assert_eq!(Gain::from_effective_aperture(-1.0, 1.0), None); +} + +#[test] +fn decibel_conversion_is_available() { + let gain = Gain::from_dbi(10.0).expect("finite reading"); + let decibel = gain.as_decibel().expect("positive"); + assert!((decibel.db() - 10.0).abs() < 1e-9); +} + +#[test] +fn default_is_isotropic() { + assert_eq!(Gain::default(), Gain::ISOTROPIC); +} + +#[test] +fn display_prefers_dbi() { + assert_eq!(Gain::ISOTROPIC.to_string(), "0 dBi"); +} diff --git a/crates/rf/apsis-rf/tests/power.rs b/crates/rf/apsis-rf/tests/power.rs new file mode 100644 index 0000000..9b3ea75 --- /dev/null +++ b/crates/rf/apsis-rf/tests/power.rs @@ -0,0 +1,117 @@ +//! Tests for power levels. + +use apsis_rf::{Decibel, Power}; + +const TOLERANCE: f64 = 1e-12; + +#[test] +fn reference_powers_match_their_definitions() { + assert_eq!(Power::WATT.watts(), 1.0); + assert_eq!(Power::MILLIWATT.watts(), 1.0e-3); + assert_eq!(Power::WATT.dbw(), Some(0.0)); + assert_eq!(Power::MILLIWATT.dbm(), Some(0.0)); + // One watt is thirty decibel milliwatts. + assert!((Power::WATT.dbm().expect("positive") - 30.0).abs() < TOLERANCE); +} + +#[test] +fn decibel_forms_round_trip() { + for dbw in [-150.0, -100.0, -20.0, 0.0, 10.0, 53.0] { + let power = Power::from_dbw(dbw).expect("finite reading"); + assert!( + (power.dbw().expect("positive") - dbw).abs() < 1e-9, + "dbw {dbw}" + ); + } + for dbm in [-120.0, -60.0, 0.0, 30.0, 60.0] { + let power = Power::from_dbm(dbm).expect("finite reading"); + assert!( + (power.dbm().expect("positive") - dbm).abs() < 1e-9, + "dbm {dbm}" + ); + } +} + +#[test] +fn dbw_and_dbm_differ_by_thirty() { + let power = Power::from_watts(2.0); + assert!((power.dbm().expect("positive") - power.dbw().expect("positive") - 30.0).abs() < 1e-9); +} + +#[test] +fn known_power_levels_convert_correctly() { + // Ten watts is ten decibel watts and forty decibel milliwatts. + let ten_watts = Power::from_watts(10.0); + assert!((ten_watts.dbw().expect("positive") - 10.0).abs() < 1e-12); + assert!((ten_watts.dbm().expect("positive") - 40.0).abs() < 1e-12); + + // One milliwatt is one milliwatt. + assert!((Power::from_milliwatts(1.0).watts() - 1.0e-3).abs() < 1e-18); + assert!((Power::from_milliwatts(1.0).milliwatts() - 1.0).abs() < 1e-12); +} + +#[test] +fn zero_and_negative_power_have_no_decibel_form() { + assert_eq!(Power::from_watts(0.0).dbw(), None); + assert_eq!(Power::from_watts(-1.0).dbw(), None); + assert_eq!(Power::from_watts(0.0).dbm(), None); +} + +#[test] +fn non_finite_readings_are_rejected() { + assert_eq!(Power::from_dbw(f64::NAN), None); + assert_eq!(Power::from_dbw(f64::INFINITY), None); + assert_eq!(Power::from_dbm(f64::NAN), None); + assert_eq!(Power::from_dbm(f64::NEG_INFINITY), None); +} + +#[test] +fn a_ratio_between_powers_is_a_decibel() { + let high = Power::from_watts(100.0); + let low = Power::from_watts(1.0); + let ratio = high.ratio_in_decibels(low).expect("both are positive"); + assert!((ratio.db() - 20.0).abs() < 1e-12); + + // The reverse ratio is the negative. + let inverse = low.ratio_in_decibels(high).expect("both are positive"); + assert!((inverse.db() + 20.0).abs() < 1e-12); +} + +#[test] +fn a_ratio_is_not_defined_against_zero_power() { + let power = Power::from_watts(1.0); + assert_eq!(power.ratio_in_decibels(Power::from_watts(0.0)), None); + assert_eq!(Power::from_watts(0.0).ratio_in_decibels(power), None); +} + +#[test] +fn scaling_by_a_gain_multiplies_the_power() { + let power = Power::from_dbw(0.0).expect("finite reading"); + let amplified = power + .scaled(Decibel::from_db(10.0)) + .expect("result is finite"); + assert!((amplified.dbw().expect("positive") - 10.0).abs() < 1e-9); + assert!((amplified.watts() - 10.0).abs() < 1e-9); +} + +#[test] +fn scaling_zero_stays_zero() { + let zero = Power::from_watts(0.0); + let scaled = zero.scaled(Decibel::from_db(20.0)).expect("zero is finite"); + assert_eq!(scaled.watts(), 0.0); +} + +#[test] +fn validity_is_reported() { + assert!(Power::from_watts(1.0).is_valid()); + assert!(Power::from_watts(0.0).is_valid()); + assert!(!Power::from_watts(-1.0).is_valid()); + assert!(!Power::from_watts(f64::NAN).is_valid()); +} + +#[test] +fn display_prefers_decibel_watts() { + assert_eq!(Power::from_watts(1.0).to_string(), "0 dBW"); + // A zero power has no decibel form, so the linear value is shown. + assert_eq!(Power::from_watts(0.0).to_string(), "0 W"); +}