astrodynamics 0.12.0

Numerical astrodynamics engine for orbit propagation, force models, and flight-dynamics primitives
Documentation
//! RF link-budget primitives.
//!
//! Pure physics with no system-specific assumptions: free-space path loss,
//! EIRP, carrier-to-noise-density ratio, link margin, wavelength, and parabolic
//! dish gain. Callers combine these with geometry outputs (slant range,
//! elevation) to build a complete link budget for a specific system. The orbis
//! Elixir binding is a thin marshaling layer; no formula lives there.

use crate::constants::physics::SPEED_OF_LIGHT_M_S;
use std::f64::consts::PI;

/// Free-space path-loss reference constant for kilometre range and megahertz
/// frequency inputs (dB).
const FSPL_KM_MHZ_CONSTANT_DB: f64 = 32.45;
/// Decibel scaling for an amplitude (field) ratio: 20 dB per decade.
const DB_FIELD_DECADE: f64 = 20.0;
/// Decibel scaling for a power ratio: 10 dB per decade.
const DB_POWER_DECADE: f64 = 10.0;
/// dBm-to-dBW conversion offset (1 W = 30 dBm).
const DBM_TO_DBW_OFFSET_DB: f64 = 30.0;
/// Boltzmann constant as the positive dBW/Hz/K offset of the conventional
/// link-budget equation (-228.6 dBW/Hz/K).
const BOLTZMANN_K_DBW_HZ_K: f64 = 228.6;

/// Inputs to [`link_margin`], mirroring the self-documenting Elixir map.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LinkBudget {
    /// Transmitter EIRP (dBW).
    pub eirp_dbw: f64,
    /// Free-space path loss (dB).
    pub fspl_db: f64,
    /// Receiver figure of merit G/T (dB/K).
    pub receiver_gt_dbk: f64,
    /// Sum of miscellaneous losses (dB).
    pub other_losses_db: f64,
    /// Minimum C/N0 for demodulation (dB-Hz).
    pub required_cn0_dbhz: f64,
}

/// Free-space path loss in dB for a slant range in km and frequency in MHz:
/// `FSPL = 32.45 + 20*log10(f_MHz) + 20*log10(d_km)`.
///
/// Operation order (frequency term before range term) is fixed to reproduce the
/// prior Elixir reference bit-for-bit.
pub fn fspl(distance_km: f64, frequency_mhz: f64) -> f64 {
    FSPL_KM_MHZ_CONSTANT_DB
        + DB_FIELD_DECADE * frequency_mhz.log10()
        + DB_FIELD_DECADE * distance_km.log10()
}

/// Effective isotropic radiated power in dBW: `EIRP = P_tx(dBm) + G_tx(dBi) - 30`.
pub fn eirp(tx_power_dbm: f64, tx_antenna_gain_dbi: f64) -> f64 {
    tx_power_dbm + tx_antenna_gain_dbi - DBM_TO_DBW_OFFSET_DB
}

/// Carrier-to-noise-density ratio (C/N0) in dB-Hz:
/// `C/N0 = EIRP + G/T - FSPL + 228.6 - other_losses`.
pub fn cn0(eirp_dbw: f64, fspl_db: f64, receiver_gt_dbk: f64, other_losses_db: f64) -> f64 {
    eirp_dbw + receiver_gt_dbk - fspl_db + BOLTZMANN_K_DBW_HZ_K - other_losses_db
}

/// Link margin in dB: the achieved C/N0 minus the required C/N0. Positive means
/// the link closes.
pub fn link_margin(budget: &LinkBudget) -> f64 {
    cn0(
        budget.eirp_dbw,
        budget.fspl_db,
        budget.receiver_gt_dbk,
        budget.other_losses_db,
    ) - budget.required_cn0_dbhz
}

/// Wavelength in metres for a frequency in Hz.
pub fn wavelength(frequency_hz: f64) -> f64 {
    SPEED_OF_LIGHT_M_S / frequency_hz
}

/// Parabolic-dish antenna gain in dBi: `G = 10*log10(eta * (pi*D/lambda)^2)`.
///
/// The squaring uses libm `pow` (`powf(2.0)`), matching the Erlang `**`
/// operator the prior Elixir reference used, for bit-for-bit parity.
pub fn dish_gain(diameter_m: f64, frequency_hz: f64, efficiency: f64) -> f64 {
    let lambda = wavelength(frequency_hz);
    DB_POWER_DECADE * (efficiency * (PI * diameter_m / lambda).powf(2.0)).log10()
}

#[cfg(test)]
mod tests {
    use super::*;

    // Frozen output bits captured from the prior Elixir `Orbis.RF` reference
    // (the public doctest values), proving cross-language 0-ULP parity.

    #[test]
    fn fspl_matches_frozen_elixir_bits() {
        assert_eq!(
            fspl(1200.0, 1616.0).to_bits(),
            158.20245204972383_f64.to_bits()
        );
    }

    #[test]
    fn eirp_matches_frozen_elixir_bits() {
        assert_eq!(eirp(27.0, 3.0).to_bits(), 0.0_f64.to_bits());
    }

    #[test]
    fn cn0_matches_frozen_elixir_bits() {
        assert_eq!(
            cn0(0.0, 165.0, -12.0, 3.0).to_bits(),
            48.599999999999994_f64.to_bits()
        );
    }

    #[test]
    fn link_margin_matches_frozen_elixir_bits() {
        let budget = LinkBudget {
            eirp_dbw: 0.0,
            fspl_db: 165.0,
            receiver_gt_dbk: -12.0,
            other_losses_db: 3.0,
            required_cn0_dbhz: 35.0,
        };
        assert_eq!(
            link_margin(&budget).to_bits(),
            13.599999999999994_f64.to_bits()
        );
    }

    #[test]
    fn wavelength_matches_frozen_elixir_bits() {
        assert_eq!(
            wavelength(1616.0e6).to_bits(),
            0.1855151349009901_f64.to_bits()
        );
    }

    #[test]
    fn dish_gain_matches_frozen_elixir_bits() {
        assert_eq!(
            dish_gain(1.0, 1616.0e6, 0.55).to_bits(),
            21.97903741903791_f64.to_bits()
        );
    }
}