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
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 #[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}