pamoja_lora/budget.rs
1//! Link budgets: how much signal a LoRa link has to spend, and where it goes.
2//!
3//! A link closes while the power reaching the receiver stays above the weakest signal the
4//! receiver can still demodulate. Everything between the transmitting radio and that
5//! demodulator adds or takes away decibels: the transmit power, the antenna and cable at
6//! each end, and the path itself. [`LinkBudget`] keeps that account, and the functions
7//! here give each term on its own.
8//!
9//! Every value is a [`Decibels`], held to a hundredth of a decibel and computed with a
10//! fixed-point logarithm, so a node can budget its own link with no floating point. Each
11//! term comes from the document that defines it:
12//!
13//! - [`free_space_loss_db`] is the free-space basic transmission loss of Recommendation
14//! ITU-R P.525-5, equation (5), `20 log10(4 pi d / lambda)`.
15//! - [`fresnel_radius_mm`] is the radius of the first Fresnel ellipsoid, from
16//! Recommendation ITU-R P.526-16, equation (2).
17//! - [`noise_floor_dbm`] is the thermal noise in a channel, -174 dBm/Hz plus `10 log10` of
18//! the bandwidth, as Semtech application note AN1200.22 derives it.
19//! - [`demodulator_snr_db`] is the signal-to-noise ratio the LoRa demodulator needs at each
20//! spreading factor, from Table 6-1 of the Semtech SX1261/2 datasheet.
21//! - [`Fcc15247`] is the conducted power limit and antenna gain rule of 47 CFR 15.247(b).
22//!
23//! A receiver's sensitivity is its noise floor, raised by its noise figure and lowered by
24//! the demodulator SNR. With the 6 dB noise figure AN1200.22 takes as typical, that sum
25//! lands within about half a decibel of the SX1261/2 datasheet at 125 and 250 kHz, and
26//! within 3 dB of every SF7 to SF12 figure in it and in the SX1276 datasheet. The
27//! datasheet figures are measured, so where a deployment has them for its own radio, they
28//! are the better number.
29//!
30//! # Examples
31//!
32//! ```
33//! use pamoja_lora::budget::{self, Decibels, LinkBudget};
34//! use pamoja_lora::LinkSettings;
35//!
36//! // SF12 at 125 kHz, sent at 14 dBm between antennas with no gain.
37//! let link = LinkSettings::new(12, 125_000);
38//! let budget = LinkBudget {
39//! transmit_power_dbm: Decibels::from_db(14),
40//! ..LinkBudget::default()
41//! };
42//!
43//! // With the default 6 dB noise figure the receiver hears down to -137.03 dBm, so the
44//! // link survives 151.03 dB of path loss.
45//! assert_eq!(budget.sensitivity_dbm(link).to_string(), "-137.03");
46//! assert_eq!(budget.max_path_loss_db(link).to_string(), "151.03");
47//!
48//! // Five kilometers of free space at 868.1 MHz takes 105.20 dB of that.
49//! let loss = budget::free_space_loss_db(5_000, 868_100_000);
50//! assert_eq!(loss.to_string(), "105.20");
51//! assert_eq!(budget.margin_db(link, loss).to_string(), "45.83");
52//! ```
53
54use core::fmt;
55use core::ops::{Add, Neg, Sub};
56
57use crate::LinkSettings;
58
59// log10(2) in Q64 fixed point.
60const LOG10_2_Q64: u128 = 5_553_023_288_523_357_132;
61
62// log10(4 pi / c) in Q64 fixed point, with c in meters per second.
63const LOG10_4PI_OVER_C_Q64: i128 = -136_092_899_020_721_497_209;
64
65// The speed of light in meters per second, exact by the definition of the meter.
66const SPEED_OF_LIGHT_M_PER_S: u128 = 299_792_458;
67
68// Fraction bits of the binary logarithm; at 40 the error stays below 1e-9 of a hundredth.
69const LOG2_FRACTION_BITS: u32 = 40;
70
71// The conducted limits of 47 CFR 15.247(b)(2) and (b)(3): 1 W and 0.25 W, in dBm.
72const ONE_WATT_DBM: Decibels = Decibels::from_hundredths(3_000);
73const QUARTER_WATT_DBM: Decibels = Decibels::from_hundredths(2_398);
74
75// The antenna gain 47 CFR 15.247(b)(4) allows before the conducted power comes down.
76const FCC_GAIN_ALLOWANCE_DBI: Decibels = Decibels::from_db(6);
77
78// The typical LoRa demodulator SNR from SF5 to SF12, SX1261/2 datasheet Table 6-1.
79const DEMODULATOR_SNR: [Decibels; 8] = [
80 Decibels::from_hundredths(-250),
81 Decibels::from_hundredths(-500),
82 Decibels::from_hundredths(-750),
83 Decibels::from_hundredths(-1_000),
84 Decibels::from_hundredths(-1_250),
85 Decibels::from_hundredths(-1_500),
86 Decibels::from_hundredths(-1_750),
87 Decibels::from_hundredths(-2_000),
88];
89
90/// The thermal noise power density at room temperature, in dBm per hertz.
91///
92/// Semtech AN1200.22 derives it as `10 log10(k T 1000)` with T at 293 K, and works with
93/// -174.
94pub const THERMAL_NOISE_DBM_PER_HZ: Decibels = Decibels::from_db(-174);
95
96/// A typical noise figure for a Semtech sub-GHz LoRa radio, in dB.
97///
98/// AN1200.22 takes 6 dB as the noise figure of the receiver behind the SX1272 and SX1276
99/// datasheet sensitivities.
100pub const RADIO_NOISE_FIGURE_DB: Decibels = Decibels::from_db(6);
101
102/// A typical noise figure for a LoRaWAN gateway receiver, in dB.
103///
104/// Semtech TN1300.05 works with 3 dB for a gateway.
105pub const GATEWAY_NOISE_FIGURE_DB: Decibels = Decibels::from_db(3);
106
107/// A level, a gain, or a loss in decibels, held to a hundredth of a decibel.
108///
109/// One type carries absolute levels in dBm, antenna gains in dBi, and plain ratios in dB,
110/// because a link budget adds and subtracts all three. The name of each value in this
111/// module says which one it is. Arithmetic saturates rather than overflowing, and the
112/// value prints with two decimals and no unit.
113///
114/// # Examples
115///
116/// ```
117/// use pamoja_lora::budget::Decibels;
118///
119/// // A 2.15 dBi dipole behind half a decibel of cable.
120/// let net = Decibels::from_hundredths(215) - Decibels::from_tenths(5);
121/// assert_eq!(net.to_string(), "1.65");
122/// assert_eq!(net.round_db(), 2);
123/// ```
124#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
125pub struct Decibels(i32);
126
127impl Decibels {
128 /// Zero decibels: no gain and no loss.
129 pub const ZERO: Self = Self(0);
130
131 /// Creates a value from whole decibels.
132 ///
133 /// # Arguments
134 ///
135 /// * `db` - the value in decibels.
136 ///
137 /// # Returns
138 ///
139 /// The value, saturating at the range a hundredth-of-a-decibel `i32` holds.
140 pub const fn from_db(db: i32) -> Self {
141 Self(db.saturating_mul(100))
142 }
143
144 /// Creates a value from tenths of a decibel, the precision most antenna datasheets
145 /// publish.
146 ///
147 /// # Arguments
148 ///
149 /// * `tenths` - the value in tenths of a decibel, so `21` is 2.1 dB.
150 ///
151 /// # Returns
152 ///
153 /// The value, saturating at the range a hundredth-of-a-decibel `i32` holds.
154 pub const fn from_tenths(tenths: i32) -> Self {
155 Self(tenths.saturating_mul(10))
156 }
157
158 /// Creates a value from hundredths of a decibel, the precision it is held to.
159 ///
160 /// # Arguments
161 ///
162 /// * `hundredths` - the value in hundredths of a decibel, so `215` is 2.15 dB.
163 ///
164 /// # Returns
165 ///
166 /// The value.
167 pub const fn from_hundredths(hundredths: i32) -> Self {
168 Self(hundredths)
169 }
170
171 /// Returns the value in hundredths of a decibel.
172 ///
173 /// # Returns
174 ///
175 /// The exact value this type holds.
176 pub const fn hundredths(self) -> i32 {
177 self.0
178 }
179
180 /// Returns the value rounded to whole decibels, halves away from zero.
181 ///
182 /// # Returns
183 ///
184 /// The nearest whole decibel, for a figure read in whole decibels. A transmit power
185 /// setting under a ceiling wants [`floor_db`](Self::floor_db) instead.
186 pub const fn round_db(self) -> i32 {
187 self.0 / 100 + self.0 % 100 / 50
188 }
189
190 /// Returns the value rounded down to whole decibels.
191 ///
192 /// # Returns
193 ///
194 /// The largest whole decibel at or below the value, which is how a transmit power
195 /// setting stays under a ceiling.
196 pub const fn floor_db(self) -> i32 {
197 self.0.div_euclid(100)
198 }
199}
200
201impl Add for Decibels {
202 type Output = Self;
203
204 fn add(self, rhs: Self) -> Self {
205 Self(self.0.saturating_add(rhs.0))
206 }
207}
208
209impl Sub for Decibels {
210 type Output = Self;
211
212 fn sub(self, rhs: Self) -> Self {
213 Self(self.0.saturating_sub(rhs.0))
214 }
215}
216
217impl Neg for Decibels {
218 type Output = Self;
219
220 fn neg(self) -> Self {
221 Self(self.0.saturating_neg())
222 }
223}
224
225impl fmt::Display for Decibels {
226 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
227 let mut text = [0u8; 16];
228 let mut start = text.len();
229 let mut rest = self.0.unsigned_abs();
230 let mut place = 0;
231 while place < 3 || rest > 0 {
232 if place == 2 {
233 start -= 1;
234 text[start] = b'.';
235 }
236 start -= 1;
237 text[start] = b'0' + (rest % 10) as u8;
238 rest /= 10;
239 place += 1;
240 }
241 if self.0 < 0 {
242 start -= 1;
243 text[start] = b'-';
244 }
245 f.pad(core::str::from_utf8(&text[start..]).map_err(|_| fmt::Error)?)
246 }
247}
248
249/// The gains and losses of a link, from the transmitting radio to the receiving one.
250///
251/// The fields are the parts of a deployment a maker chooses: how hard the radio drives,
252/// which antenna goes on each end, and how much cable sits between each antenna and its
253/// radio. The methods add them up against a path and a receiver. The default is 0 dBm
254/// between two isotropic antennas with no cable, heard with [`RADIO_NOISE_FIGURE_DB`].
255///
256/// # Examples
257///
258/// ```
259/// use pamoja_lora::budget::{Decibels, LinkBudget};
260///
261/// // 14 dBm into a 2.15 dBi antenna through half a decibel of pigtail.
262/// let budget = LinkBudget {
263/// transmit_power_dbm: Decibels::from_db(14),
264/// transmit_antenna_gain_dbi: Decibels::from_hundredths(215),
265/// transmit_cable_loss_db: Decibels::from_tenths(5),
266/// ..LinkBudget::default()
267/// };
268/// assert_eq!(budget.eirp_dbm().to_string(), "15.65");
269///
270/// // Under a 16 dBm EIRP ceiling, that antenna and cable leave 14.35 dBm for the radio.
271/// let most = budget.max_transmit_power_dbm(Decibels::from_db(16));
272/// assert_eq!(most.to_string(), "14.35");
273/// ```
274#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
275pub struct LinkBudget {
276 /// The power the transmitting radio delivers at its antenna port, in dBm.
277 pub transmit_power_dbm: Decibels,
278 /// The gain of the transmitting antenna over an isotropic antenna, in dBi.
279 pub transmit_antenna_gain_dbi: Decibels,
280 /// The loss in the cable and connectors between the transmitting radio and its
281 /// antenna, in dB.
282 pub transmit_cable_loss_db: Decibels,
283 /// The gain of the receiving antenna over an isotropic antenna, in dBi.
284 pub receive_antenna_gain_dbi: Decibels,
285 /// The loss in the cable and connectors between the receiving antenna and its radio,
286 /// in dB.
287 pub receive_cable_loss_db: Decibels,
288 /// The noise figure of the receiver, in dB, such as [`RADIO_NOISE_FIGURE_DB`] for a
289 /// node or [`GATEWAY_NOISE_FIGURE_DB`] for a gateway.
290 pub noise_figure_db: Decibels,
291}
292
293impl Default for LinkBudget {
294 fn default() -> Self {
295 Self {
296 transmit_power_dbm: Decibels::ZERO,
297 transmit_antenna_gain_dbi: Decibels::ZERO,
298 transmit_cable_loss_db: Decibels::ZERO,
299 receive_antenna_gain_dbi: Decibels::ZERO,
300 receive_cable_loss_db: Decibels::ZERO,
301 noise_figure_db: RADIO_NOISE_FIGURE_DB,
302 }
303 }
304}
305
306impl LinkBudget {
307 /// Returns the equivalent isotropically radiated power, in dBm.
308 ///
309 /// # Returns
310 ///
311 /// The transmit power plus the transmitting antenna gain, less the transmitting cable
312 /// loss. This is the figure regional power ceilings limit.
313 pub fn eirp_dbm(&self) -> Decibels {
314 self.transmit_power_dbm + self.transmit_antenna_gain_dbi - self.transmit_cable_loss_db
315 }
316
317 /// Returns the power that reaches the receiving radio across a path, in dBm.
318 ///
319 /// # Arguments
320 ///
321 /// * `path_loss_db` - the loss between the two antennas, such as
322 /// [`free_space_loss_db`].
323 ///
324 /// # Returns
325 ///
326 /// The EIRP less the path loss, plus the receiving antenna gain, less the receiving
327 /// cable loss.
328 pub fn received_dbm(&self, path_loss_db: Decibels) -> Decibels {
329 self.eirp_dbm() - path_loss_db + self.receive_antenna_gain_dbi - self.receive_cable_loss_db
330 }
331
332 /// Returns the weakest signal the receiver can demodulate on a link, in dBm.
333 ///
334 /// # Arguments
335 ///
336 /// * `link` - the link settings, whose spreading factor and bandwidth set the floor.
337 ///
338 /// # Returns
339 ///
340 /// The [sensitivity](fn@sensitivity_dbm) at this budget's noise figure.
341 pub fn sensitivity_dbm(&self, link: LinkSettings) -> Decibels {
342 sensitivity_dbm(link, self.noise_figure_db)
343 }
344
345 /// Returns the most path loss the link survives, in dB.
346 ///
347 /// # Arguments
348 ///
349 /// * `link` - the link settings, whose spreading factor and bandwidth set the
350 /// sensitivity.
351 ///
352 /// # Returns
353 ///
354 /// How far the power reaching the receiver with no path loss sits above the
355 /// sensitivity. A path that loses more than this does not close.
356 pub fn max_path_loss_db(&self, link: LinkSettings) -> Decibels {
357 self.received_dbm(Decibels::ZERO) - self.sensitivity_dbm(link)
358 }
359
360 /// Returns how far above the sensitivity a signal arrives across a path, in dB.
361 ///
362 /// # Arguments
363 ///
364 /// * `link` - the link settings, whose spreading factor and bandwidth set the
365 /// sensitivity.
366 /// * `path_loss_db` - the loss between the two antennas.
367 ///
368 /// # Returns
369 ///
370 /// The link margin, which is negative where the path loses more than the link
371 /// survives.
372 pub fn margin_db(&self, link: LinkSettings, path_loss_db: Decibels) -> Decibels {
373 self.received_dbm(path_loss_db) - self.sensitivity_dbm(link)
374 }
375
376 /// Returns the most transmit power that keeps the EIRP at or under a ceiling, in dBm.
377 ///
378 /// # Arguments
379 ///
380 /// * `eirp_ceiling_dbm` - the EIRP limit, such as the `max_eirp_dbm` a channel plan
381 /// publishes for a frequency.
382 ///
383 /// # Returns
384 ///
385 /// The ceiling less the transmitting antenna gain, plus the transmitting cable loss.
386 /// A higher-gain antenna leaves less power for the radio.
387 pub fn max_transmit_power_dbm(&self, eirp_ceiling_dbm: Decibels) -> Decibels {
388 eirp_ceiling_dbm - self.transmit_antenna_gain_dbi + self.transmit_cable_loss_db
389 }
390}
391
392/// A 902-928 MHz transmitter as 47 CFR 15.247 classes it for a power limit.
393///
394/// Paragraph (b) caps the peak conducted output power, and paragraph (b)(4) bases that cap
395/// on an antenna of no more than 6 dBi: past that, the conducted power comes down by every
396/// decibel the gain exceeds 6 dBi. Which class a device belongs to follows from how it is
397/// built and certified, which this type does not decide.
398///
399/// # Examples
400///
401/// ```
402/// use pamoja_lora::budget::{Decibels, Fcc15247};
403///
404/// // A 9 dBi Yagi takes 3 dB off the 1 W limit for digital modulation.
405/// let yagi = Decibels::from_db(9);
406/// let limit = Fcc15247::DigitalModulation.max_conducted_dbm(yagi);
407/// assert_eq!(limit, Some(Decibels::from_db(27)));
408///
409/// // Paragraph (b)(2) names no limit for hopping on fewer than 25 channels.
410/// let hopping = Fcc15247::FrequencyHopping { channels: 20 };
411/// assert_eq!(hopping.max_conducted_dbm(yagi), None);
412/// ```
413#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
414pub enum Fcc15247 {
415 /// A system using digital modulation, limited to 1 W by paragraph (b)(3).
416 DigitalModulation,
417 /// A frequency hopping system, limited by paragraph (b)(2) to 1 W on at least 50
418 /// hopping channels and to 0.25 W on 25 to 49.
419 FrequencyHopping {
420 /// The number of hopping channels the system uses.
421 channels: u16,
422 },
423}
424
425impl Fcc15247 {
426 /// Returns the most conducted output power the rule allows through an antenna, in dBm.
427 ///
428 /// # Arguments
429 ///
430 /// * `antenna_gain_dbi` - the directional gain of the transmitting antenna.
431 ///
432 /// # Returns
433 ///
434 /// The limit of paragraph (b)(2) or (b)(3), less whatever the gain exceeds 6 dBi, or
435 /// `None` for a hopping system on fewer than 25 channels, which paragraph (b)(2) sets
436 /// no limit for.
437 pub fn max_conducted_dbm(self, antenna_gain_dbi: Decibels) -> Option<Decibels> {
438 let limit = match self {
439 Self::DigitalModulation => ONE_WATT_DBM,
440 Self::FrequencyHopping { channels } if channels >= 50 => ONE_WATT_DBM,
441 Self::FrequencyHopping { channels } if channels >= 25 => QUARTER_WATT_DBM,
442 Self::FrequencyHopping { .. } => return None,
443 };
444 let excess = antenna_gain_dbi - FCC_GAIN_ALLOWANCE_DBI;
445 Some(if excess > Decibels::ZERO {
446 limit - excess
447 } else {
448 limit
449 })
450 }
451}
452
453/// Returns the thermal noise power in a channel, in dBm.
454///
455/// Semtech AN1200.22 derives the floor as `10 log10(k T B 1000)`, which at room
456/// temperature is [`THERMAL_NOISE_DBM_PER_HZ`] plus `10 log10` of the bandwidth. LoRa
457/// demodulates below this floor by the processing gain its spreading factor buys, and
458/// [`demodulator_snr_db`] gives how far.
459///
460/// # Arguments
461///
462/// * `bandwidth_hz` - the channel bandwidth in hertz; `0` counts as one hertz.
463///
464/// # Returns
465///
466/// The noise power in dBm.
467///
468/// # Examples
469///
470/// ```
471/// use pamoja_lora::budget::noise_floor_dbm;
472///
473/// assert_eq!(noise_floor_dbm(125_000).to_string(), "-123.03");
474/// ```
475pub fn noise_floor_dbm(bandwidth_hz: u32) -> Decibels {
476 THERMAL_NOISE_DBM_PER_HZ + round_q64(1000 * log10_q64(u64::from(bandwidth_hz)))
477}
478
479/// Returns the signal-to-noise ratio the LoRa demodulator needs at a spreading factor, in
480/// dB.
481///
482/// These are the typical figures of Table 6-1 in the Semtech SX1261/2 datasheet, from
483/// -2.5 dB at SF5 to -20 dB at SF12, which Semtech TN1300.05 repeats per data rate. A
484/// negative ratio is a signal received below the noise, and each step up in spreading
485/// factor reaches another 2.5 dB further down.
486///
487/// # Arguments
488///
489/// * `spreading_factor` - the spreading factor, clamped to 5 to 12 as
490/// [`LinkSettings::new`] clamps it.
491///
492/// # Returns
493///
494/// The required SNR in dB.
495pub fn demodulator_snr_db(spreading_factor: u8) -> Decibels {
496 DEMODULATOR_SNR[usize::from(spreading_factor.clamp(5, 12) - 5)]
497}
498
499/// Returns the weakest signal a receiver can demodulate on a link, in dBm.
500///
501/// Sensitivity is the [noise floor](noise_floor_dbm) of the channel, raised by the noise
502/// figure of the receiver and lowered by the [demodulator SNR](demodulator_snr_db). How
503/// closely that tracks the datasheets is in the [module documentation](crate::budget).
504///
505/// # Arguments
506///
507/// * `link` - the link settings, whose spreading factor and bandwidth set the floor.
508/// * `noise_figure_db` - the noise figure of the receiver, such as
509/// [`RADIO_NOISE_FIGURE_DB`].
510///
511/// # Returns
512///
513/// The sensitivity in dBm.
514///
515/// # Examples
516///
517/// ```
518/// use pamoja_lora::budget::{sensitivity_dbm, RADIO_NOISE_FIGURE_DB};
519/// use pamoja_lora::LinkSettings;
520///
521/// let fastest = sensitivity_dbm(LinkSettings::new(7, 125_000), RADIO_NOISE_FIGURE_DB);
522/// let furthest = sensitivity_dbm(LinkSettings::new(12, 125_000), RADIO_NOISE_FIGURE_DB);
523/// assert_eq!(fastest.to_string(), "-124.53");
524/// assert_eq!(furthest.to_string(), "-137.03");
525/// ```
526pub fn sensitivity_dbm(link: LinkSettings, noise_figure_db: Decibels) -> Decibels {
527 noise_floor_dbm(link.bandwidth_hz())
528 + noise_figure_db
529 + demodulator_snr_db(link.spreading_factor())
530}
531
532/// Returns the free-space basic transmission loss between isotropic antennas, in dB.
533///
534/// This is Recommendation ITU-R P.525-5, equation (5), `20 log10(4 pi d / lambda)`: the
535/// loss with nothing in the way, which grows by 6.02 dB each time the distance doubles.
536/// Equation (6) writes the same loss in megahertz and kilometers as
537/// `32.4 + 20 log10 f + 20 log10 d`, rounding its constant down from 32.45, so it reads
538/// about 0.05 dB low. A path near the ground loses more once terrain enters the first
539/// Fresnel ellipsoid, which [`fresnel_radius_mm`] sizes.
540///
541/// # Arguments
542///
543/// * `distance_m` - the distance between the antennas in meters; `0` counts as one meter.
544/// * `frequency_hz` - the carrier frequency in hertz; `0` counts as one hertz.
545///
546/// # Returns
547///
548/// The loss in dB. The formula assumes the antennas sit in each other's far field, many
549/// wavelengths apart.
550///
551/// # Examples
552///
553/// ```
554/// use pamoja_lora::budget::free_space_loss_db;
555///
556/// assert_eq!(free_space_loss_db(1_000, 868_100_000).to_string(), "91.22");
557/// assert_eq!(free_space_loss_db(2_000, 868_100_000).to_string(), "97.24");
558/// ```
559pub fn free_space_loss_db(distance_m: u32, frequency_hz: u32) -> Decibels {
560 let ratio = log10_q64(u64::from(distance_m))
561 + log10_q64(u64::from(frequency_hz))
562 + LOG10_4PI_OVER_C_Q64;
563 round_q64(2000 * ratio)
564}
565
566/// Returns the radius of the first Fresnel ellipsoid at a point on a path, in millimeters.
567///
568/// Recommendation ITU-R P.526-16 treats a path as line of sight, with negligible
569/// diffraction, when no obstacle enters the first Fresnel ellipsoid, and starts the
570/// diffraction zone where the clearance falls to 60% of its radius. The radius is widest
571/// halfway along the path. This is equation (2), `sqrt(lambda d1 d2 / (d1 + d2))`;
572/// equation (3) in practical units rounds its constant to 550 and so reads about half a
573/// percent wide.
574///
575/// # Arguments
576///
577/// * `near_m` - the distance from one antenna to the point, in meters.
578/// * `far_m` - the distance from the point to the other antenna, in meters.
579/// * `frequency_hz` - the carrier frequency in hertz.
580///
581/// # Returns
582///
583/// The radius in millimeters, rounded to the nearest, or `0` for a path of no length or a
584/// frequency of zero.
585///
586/// # Examples
587///
588/// ```
589/// use pamoja_lora::budget::fresnel_radius_mm;
590///
591/// // Halfway along a 5 km link at 868.1 MHz the radius is 20.78 m, and 60% of it,
592/// // 12.47 m, is the clearance that keeps the path out of the diffraction zone.
593/// let radius = fresnel_radius_mm(2_500, 2_500, 868_100_000);
594/// assert_eq!(radius, 20_777);
595/// assert_eq!(radius * 6 / 10, 12_466);
596/// ```
597pub fn fresnel_radius_mm(near_m: u32, far_m: u32, frequency_hz: u32) -> u32 {
598 let path_m = u128::from(near_m) + u128::from(far_m);
599 if path_m == 0 || frequency_hz == 0 {
600 return 0;
601 }
602 let four_squared_mm2 =
603 4 * SPEED_OF_LIGHT_M_PER_S * u128::from(near_m) * u128::from(far_m) * 1_000_000
604 / (u128::from(frequency_hz) * path_m);
605 u32::try_from(four_squared_mm2.isqrt().div_ceil(2)).unwrap_or(u32::MAX)
606}
607
608/// Returns `log10(value)` in Q64 fixed point, counting zero as one.
609///
610/// The binary logarithm comes from squaring the normalized mantissa once per fraction bit,
611/// and converts to base ten through `LOG10_2_Q64`.
612fn log10_q64(value: u64) -> i128 {
613 let value = value.max(1);
614 let whole = 63 - value.leading_zeros();
615 let mut mantissa = u128::from(value) << (63 - whole);
616 let mut fraction: u128 = 0;
617 for _ in 0..LOG2_FRACTION_BITS {
618 mantissa = (mantissa * mantissa) >> 63;
619 fraction <<= 1;
620 if mantissa >= 1 << 64 {
621 mantissa >>= 1;
622 fraction |= 1;
623 }
624 }
625 let log2_q40 = (u128::from(whole) << LOG2_FRACTION_BITS) | fraction;
626 ((log2_q40 * LOG10_2_Q64) >> LOG2_FRACTION_BITS) as i128
627}
628
629/// Rounds a Q64 count of hundredths of a decibel to the nearest hundredth.
630fn round_q64(hundredths_q64: i128) -> Decibels {
631 let rounded = (hundredths_q64 + (1 << 63)) >> 64;
632 Decibels(rounded.clamp(i128::from(i32::MIN), i128::from(i32::MAX)) as i32)
633}
634
635#[cfg(test)]
636mod tests {
637 use super::*;
638 use std::f64::consts::PI;
639
640 fn hundredths_of(value: f64) -> i32 {
641 (value * 100.0).round() as i32
642 }
643
644 #[test]
645 fn the_fixed_point_logarithm_matches_the_float_one() {
646 let mut values: Vec<u64> = (1..=20_000).collect();
647 let mut power = 1u64;
648 while let Some(next) = power.checked_mul(3) {
649 values.extend([next - 1, next, next + 1]);
650 power = next;
651 }
652 values.extend([125_000, 868_100_000, 299_792_458, u64::MAX]);
653 for value in values {
654 assert_eq!(
655 round_q64(1000 * log10_q64(value)).hundredths(),
656 hundredths_of(10.0 * (value as f64).log10()),
657 "10 log10({value})"
658 );
659 }
660 }
661
662 #[test]
663 fn powers_of_ten_come_out_whole() {
664 for exponent in 0..=19u32 {
665 assert_eq!(
666 round_q64(1000 * log10_q64(10u64.pow(exponent))),
667 Decibels::from_db(10 * exponent as i32)
668 );
669 }
670 }
671
672 #[test]
673 fn the_noise_floor_is_the_an1200_22_formula() {
674 assert_eq!(noise_floor_dbm(125_000), Decibels::from_hundredths(-12_303));
675 assert_eq!(noise_floor_dbm(500_000), Decibels::from_hundredths(-11_701));
676 for bandwidth in [7_810u32, 10_420, 62_500, 125_000, 250_000, 500_000] {
677 assert_eq!(
678 noise_floor_dbm(bandwidth).hundredths(),
679 hundredths_of(-174.0 + 10.0 * f64::from(bandwidth).log10()),
680 "{bandwidth} Hz"
681 );
682 }
683 assert_eq!(noise_floor_dbm(0), THERMAL_NOISE_DBM_PER_HZ);
684 }
685
686 #[test]
687 fn the_demodulator_snr_is_the_sx1261_2_table() {
688 let table = [
689 (5, -250),
690 (6, -500),
691 (7, -750),
692 (8, -1_000),
693 (9, -1_250),
694 (10, -1_500),
695 (11, -1_750),
696 (12, -2_000),
697 ];
698 for (spreading_factor, hundredths) in table {
699 assert_eq!(
700 demodulator_snr_db(spreading_factor),
701 Decibels::from_hundredths(hundredths)
702 );
703 }
704 assert_eq!(demodulator_snr_db(4), demodulator_snr_db(5));
705 assert_eq!(demodulator_snr_db(13), demodulator_snr_db(12));
706 }
707
708 #[test]
709 fn sensitivity_lands_near_the_sx1261_2_datasheet() {
710 // SX1261/2 datasheet Rev 2.2, Table 3-8, RXS_LB, in dBm.
711 let rows = [
712 (10_420, 7, -134),
713 (10_420, 12, -148),
714 (125_000, 7, -124),
715 (125_000, 12, -137),
716 (250_000, 7, -121),
717 (250_000, 12, -134),
718 (500_000, 7, -117),
719 (500_000, 12, -129),
720 ];
721 for (bandwidth, spreading_factor, datasheet) in rows {
722 let link = LinkSettings::new(spreading_factor, bandwidth);
723 let computed = sensitivity_dbm(link, RADIO_NOISE_FIGURE_DB);
724 let gap = (computed - Decibels::from_db(datasheet)).hundredths().abs();
725 let tolerance = if bandwidth == 125_000 || bandwidth == 250_000 {
726 60
727 } else {
728 300
729 };
730 assert!(
731 gap <= tolerance,
732 "SF{spreading_factor} at {bandwidth} Hz is {computed} against {datasheet}"
733 );
734 }
735 assert_eq!(
736 sensitivity_dbm(LinkSettings::new(12, 125_000), RADIO_NOISE_FIGURE_DB),
737 Decibels::from_hundredths(-13_703)
738 );
739 }
740
741 #[test]
742 fn sensitivity_lands_near_the_sx1276_datasheet() {
743 // SX1276/77/78/79 datasheet Rev 7, RFS_L125_HF, RFS_L250_HF, and RFS_L500_HF,
744 // SF7 to SF12, in dBm.
745 let rows = [
746 (125_000, [-123, -126, -129, -132, -133, -136]),
747 (250_000, [-120, -123, -125, -128, -130, -133]),
748 (500_000, [-116, -119, -122, -125, -128, -130]),
749 ];
750 for (bandwidth, figures) in rows {
751 for (spreading_factor, datasheet) in (7..=12).zip(figures) {
752 let link = LinkSettings::new(spreading_factor, bandwidth);
753 let computed = sensitivity_dbm(link, RADIO_NOISE_FIGURE_DB);
754 let gap = (computed - Decibels::from_db(datasheet)).hundredths().abs();
755 assert!(
756 gap <= 300,
757 "SF{spreading_factor} at {bandwidth} Hz is {computed} against {datasheet}"
758 );
759 }
760 }
761 }
762
763 #[test]
764 fn a_gateway_hears_three_decibels_deeper() {
765 let link = LinkSettings::new(12, 125_000);
766 assert_eq!(
767 sensitivity_dbm(link, GATEWAY_NOISE_FIGURE_DB),
768 Decibels::from_hundredths(-14_003)
769 );
770 }
771
772 #[test]
773 fn free_space_loss_is_p525_equation_5() {
774 let distances = [
775 1u32,
776 10,
777 100,
778 1_000,
779 2_500,
780 5_000,
781 15_000,
782 100_000,
783 1_000_000,
784 u32::MAX,
785 ];
786 let frequencies = [
787 433_175_000u32,
788 868_100_000,
789 915_000_000,
790 923_200_000,
791 2_400_000_000,
792 ];
793 for distance in distances {
794 for frequency in frequencies {
795 let wavelength = 299_792_458.0 / f64::from(frequency);
796 let exact = 20.0 * (4.0 * PI * f64::from(distance) / wavelength).log10();
797 assert_eq!(
798 free_space_loss_db(distance, frequency).hundredths(),
799 hundredths_of(exact),
800 "{distance} m at {frequency} Hz"
801 );
802 }
803 }
804 assert_eq!(
805 free_space_loss_db(5_000, 868_100_000),
806 Decibels::from_hundredths(10_520)
807 );
808 assert_eq!(
809 free_space_loss_db(0, 868_100_000),
810 free_space_loss_db(1, 868_100_000)
811 );
812 }
813
814 #[test]
815 fn equation_6_rounds_its_constant_and_reads_low() {
816 let paths = [
817 (1_000u32, 868_100_000u32),
818 (10_000, 915_000_000),
819 (100_000, 433_175_000),
820 ];
821 for (distance, frequency) in paths {
822 let equation_6 = 32.4
823 + 20.0 * (f64::from(frequency) / 1e6).log10()
824 + 20.0 * (f64::from(distance) / 1e3).log10();
825 let gap =
826 free_space_loss_db(distance, frequency).hundredths() - hundredths_of(equation_6);
827 assert!(
828 (4..=5).contains(&gap),
829 "{distance} m at {frequency} Hz reads {gap} hundredths from equation (6)"
830 );
831 }
832 }
833
834 #[test]
835 fn the_fresnel_radius_is_p526_equation_2() {
836 let points = [
837 (1u32, 1u32),
838 (2_500, 2_500),
839 (1_000, 9_000),
840 (50_000, 50_000),
841 (10, 4_000_000_000),
842 ];
843 for (near, far) in points {
844 for frequency in [433_175_000u32, 868_100_000, 915_000_000, 2_483_500_000] {
845 let wavelength = 299_792_458.0 / f64::from(frequency);
846 let exact = (wavelength * f64::from(near) * f64::from(far)
847 / (f64::from(near) + f64::from(far)))
848 .sqrt()
849 * 1000.0;
850 assert_eq!(
851 i64::from(fresnel_radius_mm(near, far, frequency)),
852 exact.round() as i64,
853 "{near} m and {far} m at {frequency} Hz"
854 );
855 }
856 }
857 assert_eq!(fresnel_radius_mm(2_500, 2_500, 868_100_000), 20_777);
858 assert_eq!(fresnel_radius_mm(0, 0, 868_100_000), 0);
859 assert_eq!(fresnel_radius_mm(2_500, 2_500, 0), 0);
860 }
861
862 #[test]
863 fn equation_3_rounds_its_constant_and_reads_wide() {
864 let radius = fresnel_radius_mm(2_500, 2_500, 868_100_000);
865 let equation_3 = 550.0 * (2.5 * 2.5 / (5.0 * 868.1f64)).sqrt() * 1000.0;
866 let wide = equation_3 / f64::from(radius) - 1.0;
867 assert!(
868 (0.004..0.005).contains(&wide),
869 "equation (3) reads {wide} wide"
870 );
871 }
872
873 #[test]
874 fn a_budget_adds_up_from_radio_to_radio() {
875 let budget = LinkBudget {
876 transmit_power_dbm: Decibels::from_db(14),
877 transmit_antenna_gain_dbi: Decibels::from_hundredths(215),
878 transmit_cable_loss_db: Decibels::from_tenths(5),
879 receive_antenna_gain_dbi: Decibels::from_db(3),
880 receive_cable_loss_db: Decibels::from_db(1),
881 noise_figure_db: RADIO_NOISE_FIGURE_DB,
882 };
883 let link = LinkSettings::new(12, 125_000);
884 let path = Decibels::from_hundredths(10_520);
885 assert_eq!(budget.eirp_dbm(), Decibels::from_hundredths(1_565));
886 assert_eq!(budget.received_dbm(path), Decibels::from_hundredths(-8_755));
887 assert_eq!(
888 budget.sensitivity_dbm(link),
889 Decibels::from_hundredths(-13_703)
890 );
891 assert_eq!(
892 budget.max_path_loss_db(link),
893 Decibels::from_hundredths(15_468)
894 );
895 assert_eq!(
896 budget.margin_db(link, path),
897 Decibels::from_hundredths(4_948)
898 );
899 assert_eq!(
900 budget.max_transmit_power_dbm(Decibels::from_db(16)),
901 Decibels::from_hundredths(1_435)
902 );
903 }
904
905 #[test]
906 fn the_default_budget_is_isotropic_with_a_radio_noise_figure() {
907 let budget = LinkBudget::default();
908 assert_eq!(budget.eirp_dbm(), Decibels::ZERO);
909 assert_eq!(budget.noise_figure_db, RADIO_NOISE_FIGURE_DB);
910 }
911
912 #[cfg(feature = "eu868")]
913 #[test]
914 fn the_eu868_ceiling_leaves_less_power_for_a_higher_gain_antenna() {
915 let plan = crate::region::Region::Eu868.plan();
916 let ceiling = Decibels::from_db(i32::from(plan.max_eirp_dbm(868_100_000)));
917 let whip = LinkBudget {
918 transmit_antenna_gain_dbi: Decibels::from_hundredths(215),
919 ..LinkBudget::default()
920 };
921 let collinear = LinkBudget {
922 transmit_antenna_gain_dbi: Decibels::from_db(6),
923 ..LinkBudget::default()
924 };
925 assert_eq!(
926 whip.max_transmit_power_dbm(ceiling),
927 Decibels::from_hundredths(1_385)
928 );
929 assert_eq!(
930 collinear.max_transmit_power_dbm(ceiling),
931 Decibels::from_db(10)
932 );
933 }
934
935 #[test]
936 fn one_watt_and_a_quarter_watt_in_dbm() {
937 assert_eq!(round_q64(1000 * log10_q64(1_000)), ONE_WATT_DBM);
938 assert_eq!(round_q64(1000 * log10_q64(250)), QUARTER_WATT_DBM);
939 }
940
941 #[test]
942 fn fcc_15_247_takes_off_what_the_gain_exceeds_6_dbi() {
943 let digital = Fcc15247::DigitalModulation;
944 assert_eq!(
945 digital.max_conducted_dbm(Decibels::from_hundredths(215)),
946 Some(Decibels::from_db(30))
947 );
948 assert_eq!(
949 digital.max_conducted_dbm(Decibels::from_db(6)),
950 Some(Decibels::from_db(30))
951 );
952 assert_eq!(
953 digital.max_conducted_dbm(Decibels::from_db(9)),
954 Some(Decibels::from_db(27))
955 );
956 let hopping = |channels| Fcc15247::FrequencyHopping { channels };
957 assert_eq!(
958 hopping(64).max_conducted_dbm(Decibels::from_db(9)),
959 Some(Decibels::from_db(27))
960 );
961 assert_eq!(
962 hopping(50).max_conducted_dbm(Decibels::ZERO),
963 Some(Decibels::from_db(30))
964 );
965 assert_eq!(
966 hopping(49).max_conducted_dbm(Decibels::ZERO),
967 Some(Decibels::from_hundredths(2_398))
968 );
969 assert_eq!(
970 hopping(25).max_conducted_dbm(Decibels::from_db(8)),
971 Some(Decibels::from_hundredths(2_198))
972 );
973 assert_eq!(hopping(24).max_conducted_dbm(Decibels::ZERO), None);
974 }
975
976 #[test]
977 fn decibels_print_to_the_hundredth() {
978 assert_eq!(Decibels::from_hundredths(-13_703).to_string(), "-137.03");
979 assert_eq!(Decibels::from_hundredths(5).to_string(), "0.05");
980 assert_eq!(Decibels::from_hundredths(-50).to_string(), "-0.50");
981 assert_eq!(Decibels::ZERO.to_string(), "0.00");
982 assert_eq!(Decibels::from_tenths(1_052).to_string(), "105.20");
983 assert_eq!(
984 Decibels::from_hundredths(i32::MIN).to_string(),
985 "-21474836.48"
986 );
987 assert_eq!(
988 format!("{:>9}", Decibels::from_hundredths(-13_703)),
989 " -137.03"
990 );
991 }
992
993 #[test]
994 fn decibels_round_halves_away_from_zero() {
995 assert_eq!(Decibels::from_hundredths(13_749).round_db(), 137);
996 assert_eq!(Decibels::from_hundredths(13_750).round_db(), 138);
997 assert_eq!(Decibels::from_hundredths(-13_749).round_db(), -137);
998 assert_eq!(Decibels::from_hundredths(-13_750).round_db(), -138);
999 }
1000
1001 #[test]
1002 fn decibels_floor_toward_negative_infinity() {
1003 assert_eq!(Decibels::from_hundredths(1_435).floor_db(), 14);
1004 assert_eq!(Decibels::from_hundredths(1_400).floor_db(), 14);
1005 assert_eq!(Decibels::from_hundredths(-1).floor_db(), -1);
1006 assert_eq!(Decibels::from_hundredths(-100).floor_db(), -1);
1007 assert_eq!(Decibels::from_hundredths(-101).floor_db(), -2);
1008 }
1009
1010 #[test]
1011 fn decibel_arithmetic_saturates() {
1012 assert_eq!(Decibels::from_db(i32::MAX).hundredths(), i32::MAX);
1013 assert_eq!(
1014 (Decibels::from_hundredths(i32::MAX) + Decibels::from_db(1)).hundredths(),
1015 i32::MAX
1016 );
1017 assert_eq!(
1018 (-Decibels::from_hundredths(i32::MIN)).hundredths(),
1019 i32::MAX
1020 );
1021 assert_eq!(Decibels::from_tenths(21), Decibels::from_hundredths(210));
1022 }
1023}