Skip to main content

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}