1use crate::astro::constants::physics::SPEED_OF_LIGHT_M_S;
10use crate::validate;
11use std::f64::consts::PI;
12
13const FSPL_KM_MHZ_CONSTANT_DB: f64 = 32.45;
16const DB_FIELD_DECADE: f64 = 20.0;
18const DB_POWER_DECADE: f64 = 10.0;
20const DBM_TO_DBW_OFFSET_DB: f64 = 30.0;
22const BOLTZMANN_K_DBW_HZ_K: f64 = 228.6;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
28pub enum RfError {
29 #[error("invalid RF input {field}: {reason}")]
31 InvalidInput {
32 field: &'static str,
33 reason: &'static str,
34 },
35}
36
37#[derive(Debug, Clone, Copy, PartialEq)]
39pub struct LinkBudget {
40 pub eirp_dbw: f64,
42 pub fspl_db: f64,
44 pub receiver_gt_dbk: f64,
46 pub other_losses_db: f64,
48 pub required_cn0_dbhz: f64,
50}
51
52pub 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
68pub 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
78pub 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
96pub 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
109pub 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
115pub 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
129pub 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
140pub 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 #[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}