rf: add decibel, power, gain, and free space primitives

This commit is contained in:
ApsisRS contributors 2026-09-26 14:32:58 +08:00
parent bc1ebe6994
commit cd1cdb7810
11 changed files with 999 additions and 0 deletions

3
Cargo.lock generated
View File

@ -141,6 +141,9 @@ version = "0.1.0"
[[package]]
name = "apsis-rf"
version = "0.1.0"
dependencies = [
"apsis-core",
]
[[package]]
name = "apsis-routing"

View File

@ -11,3 +11,4 @@ publish = false
workspace = true
[dependencies]
apsis-core.workspace = true

View File

@ -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<Self> {
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<Self> {
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()
}
}

View File

@ -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<f64> {
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<Decibel> {
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<Decibel> {
let loss = free_space_path_loss(distance, frequency)?;
let transmitter = transmitter.as_decibel()?;
let receiver = receiver.as_decibel()?;
Some(transmitter + receiver - loss)
}

View File

@ -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<Self> {
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<Self> {
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<f64> {
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<f64> {
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> {
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<f64> {
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<Self> {
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),
}
}
}

View File

@ -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};

View File

@ -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<Self> {
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<Self> {
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<f64> {
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<f64> {
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<Decibel> {
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<Self> {
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),
}
}
}

View File

@ -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);
}

View File

@ -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
);
}

View File

@ -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");
}

View File

@ -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");
}