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
129/// Batch free-space path loss wrapper.
130///
131/// Each output element is produced by [`fspl`] with the corresponding distance
132/// and shared frequency, so every element is bit-identical to the scalar helper.
133pub fn fspl_batch(distances_km: &[f64], frequency_mhz: f64) -> Vec<Result<f64, RfError>> {
134    distances_km
135        .iter()
136        .map(|&distance_km| fspl(distance_km, frequency_mhz))
137        .collect()
138}
139
140/// Batch link-margin wrapper.
141///
142/// Each output element is produced by [`link_margin`] with the corresponding
143/// budget, so every element is bit-identical to the scalar helper.
144pub fn link_margin_batch(budgets: &[LinkBudget]) -> Vec<Result<f64, RfError>> {
145    budgets.iter().map(link_margin).collect()
146}
147
148fn rf_finite(x: f64, field: &'static str) -> Result<f64, RfError> {
149    validate::finite(x, field).map_err(map_rf_input)
150}
151
152fn rf_positive(x: f64, field: &'static str) -> Result<f64, RfError> {
153    validate::finite_positive(x, field).map_err(map_rf_input)
154}
155
156fn rf_unit_efficiency(efficiency: f64) -> Result<f64, RfError> {
157    let efficiency = rf_positive(efficiency, "efficiency")?;
158    if efficiency <= 1.0 {
159        Ok(efficiency)
160    } else {
161        Err(invalid_rf_input("efficiency", "out of range"))
162    }
163}
164
165fn rf_finite_output(value: f64, field: &'static str) -> Result<f64, RfError> {
166    if value.is_finite() {
167        Ok(value)
168    } else {
169        Err(invalid_rf_input(field, "out of range"))
170    }
171}
172
173fn map_rf_input(error: validate::FieldError) -> RfError {
174    invalid_rf_input(error.field(), error.reason())
175}
176
177fn invalid_rf_input(field: &'static str, reason: &'static str) -> RfError {
178    RfError::InvalidInput { field, reason }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    // Frozen output bits captured from the prior Elixir `Sidereon.RF` reference
186    // (the public doctest values), proving cross-language 0-ULP parity.
187
188    #[test]
189    fn fspl_matches_frozen_elixir_bits() {
190        assert_eq!(
191            fspl(1200.0, 1616.0).unwrap().to_bits(),
192            158.20245204972383_f64.to_bits()
193        );
194    }
195
196    #[test]
197    fn eirp_matches_frozen_elixir_bits() {
198        assert_eq!(eirp(27.0, 3.0).unwrap().to_bits(), 0.0_f64.to_bits());
199    }
200
201    #[test]
202    fn cn0_matches_frozen_elixir_bits() {
203        assert_eq!(
204            cn0(0.0, 165.0, -12.0, 3.0).unwrap().to_bits(),
205            48.599999999999994_f64.to_bits()
206        );
207    }
208
209    #[test]
210    fn link_margin_matches_frozen_elixir_bits() {
211        let budget = LinkBudget {
212            eirp_dbw: 0.0,
213            fspl_db: 165.0,
214            receiver_gt_dbk: -12.0,
215            other_losses_db: 3.0,
216            required_cn0_dbhz: 35.0,
217        };
218        assert_eq!(
219            link_margin(&budget).unwrap().to_bits(),
220            13.599999999999994_f64.to_bits()
221        );
222    }
223
224    #[test]
225    fn wavelength_matches_frozen_elixir_bits() {
226        assert_eq!(
227            wavelength(1616.0e6).unwrap().to_bits(),
228            0.1855151349009901_f64.to_bits()
229        );
230    }
231
232    #[test]
233    fn dish_gain_matches_frozen_elixir_bits() {
234        assert_eq!(
235            dish_gain(1.0, 1616.0e6, 0.55).unwrap().to_bits(),
236            21.97903741903791_f64.to_bits()
237        );
238    }
239
240    #[test]
241    fn rf_helpers_reject_invalid_physical_domains() {
242        assert_invalid(
243            fspl(f64::NAN, 1616.0).unwrap_err(),
244            "distance_km",
245            "not finite",
246        );
247        assert_invalid(
248            fspl(0.0, 1616.0).unwrap_err(),
249            "distance_km",
250            "not positive",
251        );
252        assert_invalid(
253            fspl(1200.0, -1.0).unwrap_err(),
254            "frequency_mhz",
255            "not positive",
256        );
257        assert_invalid(
258            wavelength(f64::INFINITY).unwrap_err(),
259            "frequency_hz",
260            "not finite",
261        );
262        assert_invalid(wavelength(0.0).unwrap_err(), "frequency_hz", "not positive");
263        assert_invalid(
264            dish_gain(0.0, 1616.0e6, 0.55).unwrap_err(),
265            "diameter_m",
266            "not positive",
267        );
268        assert_invalid(
269            dish_gain(1.0, 1616.0e6, 0.0).unwrap_err(),
270            "efficiency",
271            "not positive",
272        );
273        assert_invalid(
274            dish_gain(1.0, 1616.0e6, 1.1).unwrap_err(),
275            "efficiency",
276            "out of range",
277        );
278        assert_invalid(
279            eirp(f64::NAN, 3.0).unwrap_err(),
280            "tx_power_dbm",
281            "not finite",
282        );
283        assert_invalid(
284            cn0(0.0, f64::INFINITY, -12.0, 3.0).unwrap_err(),
285            "fspl_db",
286            "not finite",
287        );
288
289        let budget = LinkBudget {
290            eirp_dbw: 0.0,
291            fspl_db: 165.0,
292            receiver_gt_dbk: -12.0,
293            other_losses_db: 3.0,
294            required_cn0_dbhz: f64::NEG_INFINITY,
295        };
296        assert_invalid(
297            link_margin(&budget).unwrap_err(),
298            "required_cn0_dbhz",
299            "not finite",
300        );
301    }
302
303    #[test]
304    fn fspl_batch_equals_scalar() {
305        let distances_km = [1200.0, 1.0, 0.0, 42.5];
306        let frequency_mhz = 1616.0;
307
308        let batch = fspl_batch(&distances_km, frequency_mhz);
309
310        assert_eq!(batch.len(), distances_km.len());
311        for (actual, &distance_km) in batch.iter().zip(&distances_km) {
312            let expected = fspl(distance_km, frequency_mhz);
313            assert_rf_result_bits_eq(*actual, expected);
314        }
315    }
316
317    #[test]
318    fn link_margin_batch_equals_scalar() {
319        let budgets = [
320            LinkBudget {
321                eirp_dbw: 0.0,
322                fspl_db: 165.0,
323                receiver_gt_dbk: -12.0,
324                other_losses_db: 3.0,
325                required_cn0_dbhz: 35.0,
326            },
327            LinkBudget {
328                eirp_dbw: 8.0,
329                fspl_db: 155.5,
330                receiver_gt_dbk: -8.25,
331                other_losses_db: 1.5,
332                required_cn0_dbhz: 40.0,
333            },
334            LinkBudget {
335                eirp_dbw: 0.0,
336                fspl_db: 165.0,
337                receiver_gt_dbk: -12.0,
338                other_losses_db: 3.0,
339                required_cn0_dbhz: f64::NAN,
340            },
341        ];
342
343        let batch = link_margin_batch(&budgets);
344
345        assert_eq!(batch.len(), budgets.len());
346        for (actual, budget) in batch.iter().zip(&budgets) {
347            let expected = link_margin(budget);
348            assert_rf_result_bits_eq(*actual, expected);
349        }
350    }
351
352    fn assert_invalid(error: RfError, field: &'static str, reason: &'static str) {
353        assert_eq!(error, RfError::InvalidInput { field, reason });
354    }
355
356    fn assert_rf_result_bits_eq(actual: Result<f64, RfError>, expected: Result<f64, RfError>) {
357        match (actual, expected) {
358            (Ok(actual), Ok(expected)) => assert_eq!(actual.to_bits(), expected.to_bits()),
359            (Err(actual), Err(expected)) => assert_eq!(actual, expected),
360            _ => panic!("actual {actual:?} did not match expected {expected:?}"),
361        }
362    }
363}