Skip to main content

sidereon_core/astro/
rf.rs

1//! RF link-budget primitives.
2//!
3//! Pure physics with no system-specific assumptions: free-space path loss,
4//! EIRP, carrier-to-noise-density ratio, link margin, wavelength, and parabolic
5//! dish gain. Callers combine these with geometry outputs (slant range,
6//! elevation) to build a complete link budget for a specific system. The sidereon
7//! Elixir binding is a thin marshaling layer; no formula lives there.
8
9use crate::astro::constants::physics::SPEED_OF_LIGHT_M_S;
10use crate::validate;
11use std::f64::consts::PI;
12
13/// Free-space path-loss reference constant for kilometre range and megahertz
14/// frequency inputs (dB).
15const FSPL_KM_MHZ_CONSTANT_DB: f64 = 32.45;
16/// Decibel scaling for an amplitude (field) ratio: 20 dB per decade.
17const DB_FIELD_DECADE: f64 = 20.0;
18/// Decibel scaling for a power ratio: 10 dB per decade.
19const DB_POWER_DECADE: f64 = 10.0;
20/// dBm-to-dBW conversion offset (1 W = 30 dBm).
21const DBM_TO_DBW_OFFSET_DB: f64 = 30.0;
22/// Boltzmann constant as the positive dBW/Hz/K offset of the conventional
23/// link-budget equation (-228.6 dBW/Hz/K).
24const BOLTZMANN_K_DBW_HZ_K: f64 = 228.6;
25
26/// Error returned by RF link-budget helpers.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
28pub enum RfError {
29    /// A public RF input was non-finite or outside its physical domain.
30    #[error("invalid RF input {field}: {reason}")]
31    InvalidInput {
32        field: &'static str,
33        reason: &'static str,
34    },
35}
36
37/// Inputs to [`link_margin`], mirroring the self-documenting Elixir map.
38#[derive(Debug, Clone, Copy, PartialEq)]
39pub struct LinkBudget {
40    /// Transmitter EIRP (dBW).
41    pub eirp_dbw: f64,
42    /// Free-space path loss (dB).
43    pub fspl_db: f64,
44    /// Receiver figure of merit G/T (dB/K).
45    pub receiver_gt_dbk: f64,
46    /// Sum of miscellaneous losses (dB).
47    pub other_losses_db: f64,
48    /// Minimum C/N0 for demodulation (dB-Hz).
49    pub required_cn0_dbhz: f64,
50}
51
52/// Free-space path loss in dB for a slant range in km and frequency in MHz:
53/// `FSPL = 32.45 + 20*log10(f_MHz) + 20*log10(d_km)`.
54///
55/// Operation order (frequency term before range term) is fixed to reproduce the
56/// prior Elixir reference bit-for-bit.
57pub fn fspl(distance_km: f64, frequency_mhz: f64) -> Result<f64, RfError> {
58    let distance_km = rf_positive(distance_km, "distance_km")?;
59    let frequency_mhz = rf_positive(frequency_mhz, "frequency_mhz")?;
60    rf_finite_output(
61        FSPL_KM_MHZ_CONSTANT_DB
62            + DB_FIELD_DECADE * frequency_mhz.log10()
63            + DB_FIELD_DECADE * distance_km.log10(),
64        "fspl_db",
65    )
66}
67
68/// Effective isotropic radiated power in dBW: `EIRP = P_tx(dBm) + G_tx(dBi) - 30`.
69pub fn eirp(tx_power_dbm: f64, tx_antenna_gain_dbi: f64) -> Result<f64, RfError> {
70    let tx_power_dbm = rf_finite(tx_power_dbm, "tx_power_dbm")?;
71    let tx_antenna_gain_dbi = rf_finite(tx_antenna_gain_dbi, "tx_antenna_gain_dbi")?;
72    rf_finite_output(
73        tx_power_dbm + tx_antenna_gain_dbi - DBM_TO_DBW_OFFSET_DB,
74        "eirp_dbw",
75    )
76}
77
78/// Carrier-to-noise-density ratio (C/N0) in dB-Hz:
79/// `C/N0 = EIRP + G/T - FSPL + 228.6 - other_losses`.
80pub fn cn0(
81    eirp_dbw: f64,
82    fspl_db: f64,
83    receiver_gt_dbk: f64,
84    other_losses_db: f64,
85) -> Result<f64, RfError> {
86    let eirp_dbw = rf_finite(eirp_dbw, "eirp_dbw")?;
87    let fspl_db = rf_finite(fspl_db, "fspl_db")?;
88    let receiver_gt_dbk = rf_finite(receiver_gt_dbk, "receiver_gt_dbk")?;
89    let other_losses_db = rf_finite(other_losses_db, "other_losses_db")?;
90    rf_finite_output(
91        eirp_dbw + receiver_gt_dbk - fspl_db + BOLTZMANN_K_DBW_HZ_K - other_losses_db,
92        "cn0_dbhz",
93    )
94}
95
96/// Link margin in dB: the achieved C/N0 minus the required C/N0. Positive means
97/// the link closes.
98pub fn link_margin(budget: &LinkBudget) -> Result<f64, RfError> {
99    let cn0_dbhz = cn0(
100        budget.eirp_dbw,
101        budget.fspl_db,
102        budget.receiver_gt_dbk,
103        budget.other_losses_db,
104    )?;
105    let required_cn0_dbhz = rf_finite(budget.required_cn0_dbhz, "required_cn0_dbhz")?;
106    rf_finite_output(cn0_dbhz - required_cn0_dbhz, "link_margin_db")
107}
108
109/// Wavelength in metres for a frequency in Hz.
110pub fn wavelength(frequency_hz: f64) -> Result<f64, RfError> {
111    let frequency_hz = rf_positive(frequency_hz, "frequency_hz")?;
112    rf_finite_output(SPEED_OF_LIGHT_M_S / frequency_hz, "wavelength_m")
113}
114
115/// Parabolic-dish antenna gain in dBi: `G = 10*log10(eta * (pi*D/lambda)^2)`.
116///
117/// The squaring uses libm `pow` (`powf(2.0)`), matching the Erlang `**`
118/// operator the prior Elixir reference used, for bit-for-bit parity.
119pub fn dish_gain(diameter_m: f64, frequency_hz: f64, efficiency: f64) -> Result<f64, RfError> {
120    let diameter_m = rf_positive(diameter_m, "diameter_m")?;
121    let lambda = wavelength(frequency_hz)?;
122    let efficiency = rf_unit_efficiency(efficiency)?;
123    rf_finite_output(
124        DB_POWER_DECADE * (efficiency * (PI * diameter_m / lambda).powf(2.0)).log10(),
125        "dish_gain_dbi",
126    )
127}
128
129fn rf_finite(x: f64, field: &'static str) -> Result<f64, RfError> {
130    validate::finite(x, field).map_err(map_rf_input)
131}
132
133fn rf_positive(x: f64, field: &'static str) -> Result<f64, RfError> {
134    validate::finite_positive(x, field).map_err(map_rf_input)
135}
136
137fn rf_unit_efficiency(efficiency: f64) -> Result<f64, RfError> {
138    let efficiency = rf_positive(efficiency, "efficiency")?;
139    if efficiency <= 1.0 {
140        Ok(efficiency)
141    } else {
142        Err(invalid_rf_input("efficiency", "out of range"))
143    }
144}
145
146fn rf_finite_output(value: f64, field: &'static str) -> Result<f64, RfError> {
147    if value.is_finite() {
148        Ok(value)
149    } else {
150        Err(invalid_rf_input(field, "out of range"))
151    }
152}
153
154fn map_rf_input(error: validate::FieldError) -> RfError {
155    invalid_rf_input(error.field(), error.reason())
156}
157
158fn invalid_rf_input(field: &'static str, reason: &'static str) -> RfError {
159    RfError::InvalidInput { field, reason }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    // Frozen output bits captured from the prior Elixir `Sidereon.RF` reference
167    // (the public doctest values), proving cross-language 0-ULP parity.
168
169    #[test]
170    fn fspl_matches_frozen_elixir_bits() {
171        assert_eq!(
172            fspl(1200.0, 1616.0).unwrap().to_bits(),
173            158.20245204972383_f64.to_bits()
174        );
175    }
176
177    #[test]
178    fn eirp_matches_frozen_elixir_bits() {
179        assert_eq!(eirp(27.0, 3.0).unwrap().to_bits(), 0.0_f64.to_bits());
180    }
181
182    #[test]
183    fn cn0_matches_frozen_elixir_bits() {
184        assert_eq!(
185            cn0(0.0, 165.0, -12.0, 3.0).unwrap().to_bits(),
186            48.599999999999994_f64.to_bits()
187        );
188    }
189
190    #[test]
191    fn link_margin_matches_frozen_elixir_bits() {
192        let budget = LinkBudget {
193            eirp_dbw: 0.0,
194            fspl_db: 165.0,
195            receiver_gt_dbk: -12.0,
196            other_losses_db: 3.0,
197            required_cn0_dbhz: 35.0,
198        };
199        assert_eq!(
200            link_margin(&budget).unwrap().to_bits(),
201            13.599999999999994_f64.to_bits()
202        );
203    }
204
205    #[test]
206    fn wavelength_matches_frozen_elixir_bits() {
207        assert_eq!(
208            wavelength(1616.0e6).unwrap().to_bits(),
209            0.1855151349009901_f64.to_bits()
210        );
211    }
212
213    #[test]
214    fn dish_gain_matches_frozen_elixir_bits() {
215        assert_eq!(
216            dish_gain(1.0, 1616.0e6, 0.55).unwrap().to_bits(),
217            21.97903741903791_f64.to_bits()
218        );
219    }
220
221    #[test]
222    fn rf_helpers_reject_invalid_physical_domains() {
223        assert_invalid(
224            fspl(f64::NAN, 1616.0).unwrap_err(),
225            "distance_km",
226            "not finite",
227        );
228        assert_invalid(
229            fspl(0.0, 1616.0).unwrap_err(),
230            "distance_km",
231            "not positive",
232        );
233        assert_invalid(
234            fspl(1200.0, -1.0).unwrap_err(),
235            "frequency_mhz",
236            "not positive",
237        );
238        assert_invalid(
239            wavelength(f64::INFINITY).unwrap_err(),
240            "frequency_hz",
241            "not finite",
242        );
243        assert_invalid(wavelength(0.0).unwrap_err(), "frequency_hz", "not positive");
244        assert_invalid(
245            dish_gain(0.0, 1616.0e6, 0.55).unwrap_err(),
246            "diameter_m",
247            "not positive",
248        );
249        assert_invalid(
250            dish_gain(1.0, 1616.0e6, 0.0).unwrap_err(),
251            "efficiency",
252            "not positive",
253        );
254        assert_invalid(
255            dish_gain(1.0, 1616.0e6, 1.1).unwrap_err(),
256            "efficiency",
257            "out of range",
258        );
259        assert_invalid(
260            eirp(f64::NAN, 3.0).unwrap_err(),
261            "tx_power_dbm",
262            "not finite",
263        );
264        assert_invalid(
265            cn0(0.0, f64::INFINITY, -12.0, 3.0).unwrap_err(),
266            "fspl_db",
267            "not finite",
268        );
269
270        let budget = LinkBudget {
271            eirp_dbw: 0.0,
272            fspl_db: 165.0,
273            receiver_gt_dbk: -12.0,
274            other_losses_db: 3.0,
275            required_cn0_dbhz: f64::NEG_INFINITY,
276        };
277        assert_invalid(
278            link_margin(&budget).unwrap_err(),
279            "required_cn0_dbhz",
280            "not finite",
281        );
282    }
283
284    fn assert_invalid(error: RfError, field: &'static str, reason: &'static str) {
285        assert_eq!(error, RfError::InvalidInput { field, reason });
286    }
287}