sidereon-core 0.8.0

The complete Sidereon engine: numerical astrodynamics propagation core plus the GNSS domain layer (SP3, broadcast ephemeris, multi-GNSS positioning, RTK/PPP, ionosphere/troposphere, DOP) behind a default-on gnss feature
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 sidereon
//! Elixir binding is a thin marshaling layer; no formula lives there.

use crate::astro::constants::physics::SPEED_OF_LIGHT_M_S;
use crate::validate;
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;

/// Error returned by RF link-budget helpers.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum RfError {
    /// A public RF input was non-finite or outside its physical domain.
    #[error("invalid RF input {field}: {reason}")]
    InvalidInput {
        field: &'static str,
        reason: &'static str,
    },
}

/// 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) -> Result<f64, RfError> {
    let distance_km = rf_positive(distance_km, "distance_km")?;
    let frequency_mhz = rf_positive(frequency_mhz, "frequency_mhz")?;
    rf_finite_output(
        FSPL_KM_MHZ_CONSTANT_DB
            + DB_FIELD_DECADE * frequency_mhz.log10()
            + DB_FIELD_DECADE * distance_km.log10(),
        "fspl_db",
    )
}

/// 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) -> Result<f64, RfError> {
    let tx_power_dbm = rf_finite(tx_power_dbm, "tx_power_dbm")?;
    let tx_antenna_gain_dbi = rf_finite(tx_antenna_gain_dbi, "tx_antenna_gain_dbi")?;
    rf_finite_output(
        tx_power_dbm + tx_antenna_gain_dbi - DBM_TO_DBW_OFFSET_DB,
        "eirp_dbw",
    )
}

/// 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,
) -> Result<f64, RfError> {
    let eirp_dbw = rf_finite(eirp_dbw, "eirp_dbw")?;
    let fspl_db = rf_finite(fspl_db, "fspl_db")?;
    let receiver_gt_dbk = rf_finite(receiver_gt_dbk, "receiver_gt_dbk")?;
    let other_losses_db = rf_finite(other_losses_db, "other_losses_db")?;
    rf_finite_output(
        eirp_dbw + receiver_gt_dbk - fspl_db + BOLTZMANN_K_DBW_HZ_K - other_losses_db,
        "cn0_dbhz",
    )
}

/// Link margin in dB: the achieved C/N0 minus the required C/N0. Positive means
/// the link closes.
pub fn link_margin(budget: &LinkBudget) -> Result<f64, RfError> {
    let cn0_dbhz = cn0(
        budget.eirp_dbw,
        budget.fspl_db,
        budget.receiver_gt_dbk,
        budget.other_losses_db,
    )?;
    let required_cn0_dbhz = rf_finite(budget.required_cn0_dbhz, "required_cn0_dbhz")?;
    rf_finite_output(cn0_dbhz - required_cn0_dbhz, "link_margin_db")
}

/// Wavelength in metres for a frequency in Hz.
pub fn wavelength(frequency_hz: f64) -> Result<f64, RfError> {
    let frequency_hz = rf_positive(frequency_hz, "frequency_hz")?;
    rf_finite_output(SPEED_OF_LIGHT_M_S / frequency_hz, "wavelength_m")
}

/// 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) -> Result<f64, RfError> {
    let diameter_m = rf_positive(diameter_m, "diameter_m")?;
    let lambda = wavelength(frequency_hz)?;
    let efficiency = rf_unit_efficiency(efficiency)?;
    rf_finite_output(
        DB_POWER_DECADE * (efficiency * (PI * diameter_m / lambda).powf(2.0)).log10(),
        "dish_gain_dbi",
    )
}

fn rf_finite(x: f64, field: &'static str) -> Result<f64, RfError> {
    validate::finite(x, field).map_err(map_rf_input)
}

fn rf_positive(x: f64, field: &'static str) -> Result<f64, RfError> {
    validate::finite_positive(x, field).map_err(map_rf_input)
}

fn rf_unit_efficiency(efficiency: f64) -> Result<f64, RfError> {
    let efficiency = rf_positive(efficiency, "efficiency")?;
    if efficiency <= 1.0 {
        Ok(efficiency)
    } else {
        Err(invalid_rf_input("efficiency", "out of range"))
    }
}

fn rf_finite_output(value: f64, field: &'static str) -> Result<f64, RfError> {
    if value.is_finite() {
        Ok(value)
    } else {
        Err(invalid_rf_input(field, "out of range"))
    }
}

fn map_rf_input(error: validate::FieldError) -> RfError {
    invalid_rf_input(error.field(), error.reason())
}

fn invalid_rf_input(field: &'static str, reason: &'static str) -> RfError {
    RfError::InvalidInput { field, reason }
}

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

    // Frozen output bits captured from the prior Elixir `Sidereon.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).unwrap().to_bits(),
            158.20245204972383_f64.to_bits()
        );
    }

    #[test]
    fn eirp_matches_frozen_elixir_bits() {
        assert_eq!(eirp(27.0, 3.0).unwrap().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).unwrap().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).unwrap().to_bits(),
            13.599999999999994_f64.to_bits()
        );
    }

    #[test]
    fn wavelength_matches_frozen_elixir_bits() {
        assert_eq!(
            wavelength(1616.0e6).unwrap().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).unwrap().to_bits(),
            21.97903741903791_f64.to_bits()
        );
    }

    #[test]
    fn rf_helpers_reject_invalid_physical_domains() {
        assert_invalid(
            fspl(f64::NAN, 1616.0).unwrap_err(),
            "distance_km",
            "not finite",
        );
        assert_invalid(
            fspl(0.0, 1616.0).unwrap_err(),
            "distance_km",
            "not positive",
        );
        assert_invalid(
            fspl(1200.0, -1.0).unwrap_err(),
            "frequency_mhz",
            "not positive",
        );
        assert_invalid(
            wavelength(f64::INFINITY).unwrap_err(),
            "frequency_hz",
            "not finite",
        );
        assert_invalid(wavelength(0.0).unwrap_err(), "frequency_hz", "not positive");
        assert_invalid(
            dish_gain(0.0, 1616.0e6, 0.55).unwrap_err(),
            "diameter_m",
            "not positive",
        );
        assert_invalid(
            dish_gain(1.0, 1616.0e6, 0.0).unwrap_err(),
            "efficiency",
            "not positive",
        );
        assert_invalid(
            dish_gain(1.0, 1616.0e6, 1.1).unwrap_err(),
            "efficiency",
            "out of range",
        );
        assert_invalid(
            eirp(f64::NAN, 3.0).unwrap_err(),
            "tx_power_dbm",
            "not finite",
        );
        assert_invalid(
            cn0(0.0, f64::INFINITY, -12.0, 3.0).unwrap_err(),
            "fspl_db",
            "not finite",
        );

        let budget = LinkBudget {
            eirp_dbw: 0.0,
            fspl_db: 165.0,
            receiver_gt_dbk: -12.0,
            other_losses_db: 3.0,
            required_cn0_dbhz: f64::NEG_INFINITY,
        };
        assert_invalid(
            link_margin(&budget).unwrap_err(),
            "required_cn0_dbhz",
            "not finite",
        );
    }

    fn assert_invalid(error: RfError, field: &'static str, reason: &'static str) {
        assert_eq!(error, RfError::InvalidInput { field, reason });
    }
}