Skip to main content

pamoja_ffi/
lora.rs

1//! The C ABI for LoRa link math.
2//!
3//! These functions wrap [`pamoja_lora`] for callers that reach the SDK through the
4//! flat C boundary: the time a transmission spends on air, and the silence a
5//! duty-cycle limit then forces. Both are what a long-range node needs to stay
6//! inside its regional budget, and both are pure arithmetic.
7//!
8//! A link is only scalars, so it crosses by value as [`PamojaLoraLink`] rather
9//! than as a handle, which keeps the whole capability free of allocation.
10
11use pamoja_lora::budget::{self, Decibels, Fcc15247, LinkBudget};
12use pamoja_lora::LinkSettings;
13
14/// The radio settings of a LoRa link.
15///
16/// Build one with [`pamoja_lora_link_default`] and adjust the fields that differ
17/// from the defaults. Values outside the ranges LoRa defines are clamped when the
18/// link is used: the spreading factor to 5-12, the coding-rate denominator to 5-8,
19/// and a bandwidth of 0 to one hertz.
20#[repr(C)]
21#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
22pub struct PamojaLoraLink {
23    /// The channel bandwidth in hertz, such as `125000`.
24    pub bandwidth_hz: u32,
25    /// The preamble length in symbols; the LoRa default is 8.
26    pub preamble_symbols: u16,
27    /// The spreading factor, 5 (fastest) to 12 (longest range).
28    pub spreading_factor: u8,
29    /// The coding-rate denominator, 5 to 8, for 4/5 to 4/8.
30    pub coding_rate_denominator: u8,
31    /// `1` for an explicit header, `0` to omit the header symbols.
32    pub explicit_header: u8,
33    /// `1` to append the frame CRC, `0` to leave it off.
34    pub crc: u8,
35}
36
37/// Returns the settings for a spreading factor and bandwidth, with LoRa defaults.
38///
39/// The defaults are coding rate 4/5, an eight-symbol preamble, an explicit header,
40/// and CRC on, which is a typical uplink.
41///
42/// # Arguments
43///
44/// * `spreading_factor` - the spreading factor, clamped to 5-12.
45/// * `bandwidth_hz` - the channel bandwidth in hertz; `0` counts as one hertz.
46///
47/// # Returns
48///
49/// The link settings, with the spreading factor already clamped.
50#[no_mangle]
51pub extern "C" fn pamoja_lora_link_default(
52    spreading_factor: u8,
53    bandwidth_hz: u32,
54) -> PamojaLoraLink {
55    let settings = LinkSettings::new(spreading_factor, bandwidth_hz);
56    PamojaLoraLink {
57        bandwidth_hz: settings.bandwidth_hz(),
58        preamble_symbols: 8,
59        spreading_factor: settings.spreading_factor(),
60        coding_rate_denominator: 5,
61        explicit_header: 1,
62        crc: 1,
63    }
64}
65
66/// Returns the duration of one symbol on a link, in microseconds.
67///
68/// # Arguments
69///
70/// * `link` - the link settings.
71///
72/// # Returns
73///
74/// The symbol time in microseconds.
75#[no_mangle]
76pub extern "C" fn pamoja_lora_symbol_time_us(link: PamojaLoraLink) -> u64 {
77    settings(link).symbol_time_us()
78}
79
80/// Reports whether a link uses low data rate optimization.
81///
82/// It is on when a symbol lasts longer than 16 ms, which is SF11 and SF12 at 125 kHz and
83/// SF12 at 250 kHz. The airtime assumes it, so a radio set up from these settings must
84/// turn it on too.
85///
86/// # Arguments
87///
88/// * `link` - the link settings.
89///
90/// # Returns
91///
92/// `true` when the symbol time exceeds 16 ms.
93#[no_mangle]
94pub extern "C" fn pamoja_lora_low_data_rate_optimization(link: PamojaLoraLink) -> bool {
95    settings(link).low_data_rate_optimization()
96}
97
98/// Returns the time on air of a payload, in microseconds.
99///
100/// This is the channel occupancy a transmission costs: how long the radio holds
101/// the air, which sets both the duty-cycle budget and most of the energy the
102/// transmission spends.
103///
104/// # Arguments
105///
106/// * `link` - the link settings.
107/// * `payload_len` - the payload length in bytes.
108///
109/// # Returns
110///
111/// The time on air in microseconds, held at `UINT64_MAX` rather than overflowing.
112#[no_mangle]
113pub extern "C" fn pamoja_lora_airtime_us(link: PamojaLoraLink, payload_len: usize) -> u64 {
114    settings(link).airtime_us(payload_len)
115}
116
117/// Returns the minimum silence after a transmission to honor a duty-cycle limit.
118///
119/// # Arguments
120///
121/// * `link` - the link settings.
122/// * `payload_len` - the payload length in bytes.
123/// * `duty_cycle_permille` - the limit in parts per thousand, so `10` is 1%.
124///
125/// # Returns
126///
127/// The required off time in microseconds: `UINT64_MAX` if the limit is zero, which
128/// forbids transmitting at all, and `0` for a limit of 1000 or more.
129#[no_mangle]
130pub extern "C" fn pamoja_lora_min_off_time_us(
131    link: PamojaLoraLink,
132    payload_len: usize,
133    duty_cycle_permille: u32,
134) -> u64 {
135    settings(link).min_off_time_us(payload_len, duty_cycle_permille)
136}
137
138/// Returns how many transmissions of a payload fit in an hour under a duty-cycle limit.
139///
140/// A transmission really costs its airtime plus the silence the limit forces after it.
141///
142/// # Arguments
143///
144/// * `link` - the link settings.
145/// * `payload_len` - the payload length in bytes.
146/// * `duty_cycle_permille` - the limit in parts per thousand, so `10` is 1%.
147///
148/// # Returns
149///
150/// The number of whole transmissions an hour, or `0` when the limit is zero.
151#[no_mangle]
152pub extern "C" fn pamoja_lora_messages_per_hour(
153    link: PamojaLoraLink,
154    payload_len: usize,
155    duty_cycle_permille: u32,
156) -> u64 {
157    settings(link).messages_per_hour(payload_len, duty_cycle_permille)
158}
159
160/// The gains and losses of a LoRa link, from the transmitting radio to the receiving one.
161///
162/// Every field is in hundredths of a decibel, so `1400` is 14 dBm and `215` is 2.15 dBi.
163/// Build one with [`pamoja_lora_link_budget_default`] and set the fields a deployment
164/// chooses: the transmit power, the antenna and cable at each end, and the noise figure of
165/// the receiver.
166#[repr(C)]
167#[derive(Clone, Copy, Debug, PartialEq, Eq)]
168pub struct PamojaLoraLinkBudget {
169    /// The power the transmitting radio delivers at its antenna port, in hundredths of a
170    /// dBm.
171    pub transmit_power_centi_dbm: i32,
172    /// The gain of the transmitting antenna over an isotropic antenna, in hundredths of a
173    /// dBi.
174    pub transmit_antenna_gain_centi_dbi: i32,
175    /// The loss in the cable and connectors between the transmitting radio and its
176    /// antenna, in hundredths of a dB.
177    pub transmit_cable_loss_centi_db: i32,
178    /// The gain of the receiving antenna over an isotropic antenna, in hundredths of a
179    /// dBi.
180    pub receive_antenna_gain_centi_dbi: i32,
181    /// The loss in the cable and connectors between the receiving antenna and its radio,
182    /// in hundredths of a dB.
183    pub receive_cable_loss_centi_db: i32,
184    /// The noise figure of the receiver, in hundredths of a dB.
185    pub noise_figure_centi_db: i32,
186}
187
188/// Returns a link budget of 0 dBm between isotropic antennas with no cable loss.
189///
190/// # Returns
191///
192/// The budget, heard with the 6 dB noise figure typical of a Semtech sub-GHz radio.
193#[no_mangle]
194pub extern "C" fn pamoja_lora_link_budget_default() -> PamojaLoraLinkBudget {
195    let budget = LinkBudget::default();
196    PamojaLoraLinkBudget {
197        transmit_power_centi_dbm: budget.transmit_power_dbm.hundredths(),
198        transmit_antenna_gain_centi_dbi: budget.transmit_antenna_gain_dbi.hundredths(),
199        transmit_cable_loss_centi_db: budget.transmit_cable_loss_db.hundredths(),
200        receive_antenna_gain_centi_dbi: budget.receive_antenna_gain_dbi.hundredths(),
201        receive_cable_loss_centi_db: budget.receive_cable_loss_db.hundredths(),
202        noise_figure_centi_db: budget.noise_figure_db.hundredths(),
203    }
204}
205
206/// Returns the equivalent isotropically radiated power of a budget.
207///
208/// # Arguments
209///
210/// * `budget` - the link budget.
211///
212/// # Returns
213///
214/// The transmit power plus the transmitting antenna gain, less the transmitting cable
215/// loss, in hundredths of a dBm.
216#[no_mangle]
217pub extern "C" fn pamoja_lora_link_budget_eirp_centi_dbm(budget: PamojaLoraLinkBudget) -> i32 {
218    link_budget(budget).eirp_dbm().hundredths()
219}
220
221/// Returns the power that reaches the receiving radio across a path.
222///
223/// # Arguments
224///
225/// * `budget` - the link budget.
226/// * `path_loss_centi_db` - the loss between the two antennas, in hundredths of a dB.
227///
228/// # Returns
229///
230/// The received power in hundredths of a dBm.
231#[no_mangle]
232pub extern "C" fn pamoja_lora_link_budget_received_centi_dbm(
233    budget: PamojaLoraLinkBudget,
234    path_loss_centi_db: i32,
235) -> i32 {
236    link_budget(budget)
237        .received_dbm(Decibels::from_hundredths(path_loss_centi_db))
238        .hundredths()
239}
240
241/// Returns the weakest signal the receiver of a budget can demodulate on a link.
242///
243/// # Arguments
244///
245/// * `budget` - the link budget, whose noise figure applies.
246/// * `link` - the link settings, whose spreading factor and bandwidth set the floor.
247///
248/// # Returns
249///
250/// The sensitivity in hundredths of a dBm.
251#[no_mangle]
252pub extern "C" fn pamoja_lora_link_budget_sensitivity_centi_dbm(
253    budget: PamojaLoraLinkBudget,
254    link: PamojaLoraLink,
255) -> i32 {
256    link_budget(budget)
257        .sensitivity_dbm(settings(link))
258        .hundredths()
259}
260
261/// Returns the most path loss a link survives.
262///
263/// # Arguments
264///
265/// * `budget` - the link budget.
266/// * `link` - the link settings, whose spreading factor and bandwidth set the sensitivity.
267///
268/// # Returns
269///
270/// The loss in hundredths of a dB above which the link does not close.
271#[no_mangle]
272pub extern "C" fn pamoja_lora_link_budget_max_path_loss_centi_db(
273    budget: PamojaLoraLinkBudget,
274    link: PamojaLoraLink,
275) -> i32 {
276    link_budget(budget)
277        .max_path_loss_db(settings(link))
278        .hundredths()
279}
280
281/// Returns how far above the sensitivity a signal arrives across a path.
282///
283/// # Arguments
284///
285/// * `budget` - the link budget.
286/// * `link` - the link settings, whose spreading factor and bandwidth set the sensitivity.
287/// * `path_loss_centi_db` - the loss between the two antennas, in hundredths of a dB.
288///
289/// # Returns
290///
291/// The link margin in hundredths of a dB, which is negative where the path loses more
292/// than the link survives.
293#[no_mangle]
294pub extern "C" fn pamoja_lora_link_budget_margin_centi_db(
295    budget: PamojaLoraLinkBudget,
296    link: PamojaLoraLink,
297    path_loss_centi_db: i32,
298) -> i32 {
299    link_budget(budget)
300        .margin_db(
301            settings(link),
302            Decibels::from_hundredths(path_loss_centi_db),
303        )
304        .hundredths()
305}
306
307/// Returns the most transmit power that keeps the EIRP of a budget at or under a ceiling.
308///
309/// # Arguments
310///
311/// * `budget` - the link budget, whose transmitting antenna and cable apply.
312/// * `eirp_ceiling_centi_dbm` - the EIRP limit, such as the ceiling a channel plan
313///   publishes for a frequency, in hundredths of a dBm.
314///
315/// # Returns
316///
317/// The ceiling less the transmitting antenna gain, plus the transmitting cable loss, in
318/// hundredths of a dBm.
319#[no_mangle]
320pub extern "C" fn pamoja_lora_link_budget_max_transmit_power_centi_dbm(
321    budget: PamojaLoraLinkBudget,
322    eirp_ceiling_centi_dbm: i32,
323) -> i32 {
324    link_budget(budget)
325        .max_transmit_power_dbm(Decibels::from_hundredths(eirp_ceiling_centi_dbm))
326        .hundredths()
327}
328
329/// Returns the thermal noise power in a channel.
330///
331/// # Arguments
332///
333/// * `bandwidth_hz` - the channel bandwidth in hertz; `0` counts as one hertz.
334///
335/// # Returns
336///
337/// -174 dBm/Hz plus `10 log10` of the bandwidth, in hundredths of a dBm.
338#[no_mangle]
339pub extern "C" fn pamoja_lora_noise_floor_centi_dbm(bandwidth_hz: u32) -> i32 {
340    budget::noise_floor_dbm(bandwidth_hz).hundredths()
341}
342
343/// Returns the signal-to-noise ratio the LoRa demodulator needs at a spreading factor.
344///
345/// # Arguments
346///
347/// * `spreading_factor` - the spreading factor, clamped to 5-12.
348///
349/// # Returns
350///
351/// The typical figure from Table 6-1 of the SX1261/2 datasheet, in hundredths of a dB.
352#[no_mangle]
353pub extern "C" fn pamoja_lora_demodulator_snr_centi_db(spreading_factor: u8) -> i32 {
354    budget::demodulator_snr_db(spreading_factor).hundredths()
355}
356
357/// Returns the free-space basic transmission loss between isotropic antennas.
358///
359/// This is Recommendation ITU-R P.525-5, equation (5).
360///
361/// # Arguments
362///
363/// * `distance_m` - the distance between the antennas in meters; `0` counts as one meter.
364/// * `frequency_hz` - the carrier frequency in hertz; `0` counts as one hertz.
365///
366/// # Returns
367///
368/// The loss in hundredths of a dB.
369#[no_mangle]
370pub extern "C" fn pamoja_lora_free_space_loss_centi_db(distance_m: u32, frequency_hz: u32) -> i32 {
371    budget::free_space_loss_db(distance_m, frequency_hz).hundredths()
372}
373
374/// Returns the radius of the first Fresnel ellipsoid at a point on a path.
375///
376/// This is Recommendation ITU-R P.526-16, equation (2). The radius is widest halfway
377/// along the path.
378///
379/// # Arguments
380///
381/// * `near_m` - the distance from one antenna to the point, in meters.
382/// * `far_m` - the distance from the point to the other antenna, in meters.
383/// * `frequency_hz` - the carrier frequency in hertz.
384///
385/// # Returns
386///
387/// The radius in millimeters, or `0` for a path of no length or a frequency of zero.
388#[no_mangle]
389pub extern "C" fn pamoja_lora_fresnel_radius_mm(near_m: u32, far_m: u32, frequency_hz: u32) -> u32 {
390    budget::fresnel_radius_mm(near_m, far_m, frequency_hz)
391}
392
393/// Returns the most conducted power 47 CFR 15.247 allows a digitally modulated
394/// 902-928 MHz transmitter through an antenna.
395///
396/// # Arguments
397///
398/// * `antenna_gain_centi_dbi` - the directional gain of the transmitting antenna, in
399///   hundredths of a dBi.
400///
401/// # Returns
402///
403/// The 1 W limit of paragraph (b)(3), less whatever the gain exceeds 6 dBi, in hundredths
404/// of a dBm.
405#[no_mangle]
406pub extern "C" fn pamoja_lora_fcc_digital_max_conducted_centi_dbm(
407    antenna_gain_centi_dbi: i32,
408) -> i32 {
409    Fcc15247::DigitalModulation
410        .max_conducted_dbm(Decibels::from_hundredths(antenna_gain_centi_dbi))
411        .map_or(i32::MIN, Decibels::hundredths)
412}
413
414/// Returns the most conducted power 47 CFR 15.247 allows a 902-928 MHz frequency hopping
415/// transmitter through an antenna.
416///
417/// # Arguments
418///
419/// * `hopping_channels` - the number of hopping channels the system uses.
420/// * `antenna_gain_centi_dbi` - the directional gain of the transmitting antenna, in
421///   hundredths of a dBi.
422///
423/// # Returns
424///
425/// The limit of paragraph (b)(2), less whatever the gain exceeds 6 dBi, in hundredths of a
426/// dBm, or `INT32_MIN` for fewer than 25 channels, which that paragraph sets no limit for.
427#[no_mangle]
428pub extern "C" fn pamoja_lora_fcc_hopping_max_conducted_centi_dbm(
429    hopping_channels: u16,
430    antenna_gain_centi_dbi: i32,
431) -> i32 {
432    Fcc15247::FrequencyHopping {
433        channels: hopping_channels,
434    }
435    .max_conducted_dbm(Decibels::from_hundredths(antenna_gain_centi_dbi))
436    .map_or(i32::MIN, Decibels::hundredths)
437}
438
439/// Rebuilds the Rust link budget from the fields that crossed the boundary.
440///
441/// # Arguments
442///
443/// * `budget` - the budget as the caller supplied it.
444///
445/// # Returns
446///
447/// The equivalent [`LinkBudget`].
448pub(crate) fn link_budget(budget: PamojaLoraLinkBudget) -> LinkBudget {
449    LinkBudget {
450        transmit_power_dbm: Decibels::from_hundredths(budget.transmit_power_centi_dbm),
451        transmit_antenna_gain_dbi: Decibels::from_hundredths(
452            budget.transmit_antenna_gain_centi_dbi,
453        ),
454        transmit_cable_loss_db: Decibels::from_hundredths(budget.transmit_cable_loss_centi_db),
455        receive_antenna_gain_dbi: Decibels::from_hundredths(budget.receive_antenna_gain_centi_dbi),
456        receive_cable_loss_db: Decibels::from_hundredths(budget.receive_cable_loss_centi_db),
457        noise_figure_db: Decibels::from_hundredths(budget.noise_figure_centi_db),
458    }
459}
460
461/// Rebuilds the Rust link settings from the fields that crossed the boundary.
462///
463/// # Arguments
464///
465/// * `link` - the settings as the caller supplied them.
466///
467/// # Returns
468///
469/// The equivalent [`LinkSettings`], with every value clamped to its LoRa range.
470pub(crate) fn settings(link: PamojaLoraLink) -> LinkSettings {
471    let mut settings = LinkSettings::new(link.spreading_factor, link.bandwidth_hz)
472        .with_coding_rate(link.coding_rate_denominator)
473        .with_preamble(link.preamble_symbols);
474    if link.explicit_header == 0 {
475        settings = settings.implicit_header();
476    }
477    if link.crc == 0 {
478        settings = settings.without_crc();
479    }
480    settings
481}
482
483/// Flattens link settings for the boundary.
484///
485/// # Arguments
486///
487/// * `link` - the settings.
488///
489/// # Returns
490///
491/// The settings as a caller reads them.
492#[cfg(feature = "radios")]
493pub(crate) fn link_of(link: LinkSettings) -> PamojaLoraLink {
494    PamojaLoraLink {
495        bandwidth_hz: link.bandwidth_hz(),
496        preamble_symbols: link.preamble_symbols(),
497        spreading_factor: link.spreading_factor(),
498        coding_rate_denominator: link.coding_rate_denominator(),
499        explicit_header: u8::from(link.explicit_header()),
500        crc: u8::from(link.crc()),
501    }
502}
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507
508    #[test]
509    fn the_defaults_are_a_typical_uplink() {
510        let link = pamoja_lora_link_default(12, 125_000);
511        assert_eq!(link.spreading_factor, 12);
512        assert_eq!(link.bandwidth_hz, 125_000);
513        assert_eq!(link.coding_rate_denominator, 5);
514        assert_eq!(link.preamble_symbols, 8);
515        assert_eq!(link.explicit_header, 1);
516        assert_eq!(link.crc, 1);
517    }
518
519    #[test]
520    fn a_spreading_factor_beyond_lora_is_clamped() {
521        assert_eq!(pamoja_lora_link_default(15, 125_000).spreading_factor, 12);
522        assert_eq!(pamoja_lora_link_default(2, 125_000).spreading_factor, 5);
523    }
524
525    #[test]
526    fn airtime_matches_the_rust_crate() {
527        let link = pamoja_lora_link_default(12, 125_000);
528        assert_eq!(
529            pamoja_lora_airtime_us(link, 10),
530            LinkSettings::new(12, 125_000).airtime_us(10)
531        );
532    }
533
534    #[test]
535    fn a_one_percent_duty_cycle_costs_ninety_nine_times_the_airtime() {
536        let link = pamoja_lora_link_default(12, 125_000);
537        let airtime = pamoja_lora_airtime_us(link, 20);
538        assert_eq!(pamoja_lora_min_off_time_us(link, 20, 10), airtime * 99);
539    }
540
541    #[test]
542    fn a_zero_duty_cycle_forbids_transmitting() {
543        let link = pamoja_lora_link_default(7, 125_000);
544        assert_eq!(pamoja_lora_min_off_time_us(link, 20, 0), u64::MAX);
545        assert_eq!(pamoja_lora_messages_per_hour(link, 20, 0), 0);
546    }
547
548    #[test]
549    fn an_hour_holds_thirty_six_ten_byte_readings_at_sf12_and_one_percent() {
550        let link = pamoja_lora_link_default(12, 125_000);
551        assert_eq!(pamoja_lora_messages_per_hour(link, 10, 10), 36);
552    }
553
554    #[test]
555    fn only_symbols_longer_than_sixteen_milliseconds_need_the_optimization() {
556        assert!(pamoja_lora_low_data_rate_optimization(
557            pamoja_lora_link_default(12, 125_000)
558        ));
559        assert!(pamoja_lora_low_data_rate_optimization(
560            pamoja_lora_link_default(11, 125_000)
561        ));
562        assert!(!pamoja_lora_low_data_rate_optimization(
563            pamoja_lora_link_default(10, 125_000)
564        ));
565        assert!(pamoja_lora_low_data_rate_optimization(
566            pamoja_lora_link_default(12, 250_000)
567        ));
568    }
569
570    #[test]
571    fn a_zero_bandwidth_is_one_hertz_rather_than_a_division_by_zero() {
572        let link = pamoja_lora_link_default(12, 0);
573        assert_eq!(link.bandwidth_hz, 1);
574        assert_eq!(pamoja_lora_symbol_time_us(link), 4_096_000_000);
575    }
576
577    #[test]
578    fn the_default_budget_is_isotropic_with_a_radio_noise_figure() {
579        let budget = pamoja_lora_link_budget_default();
580        assert_eq!(budget.transmit_power_centi_dbm, 0);
581        assert_eq!(budget.transmit_antenna_gain_centi_dbi, 0);
582        assert_eq!(budget.noise_figure_centi_db, 600);
583        assert_eq!(pamoja_lora_link_budget_eirp_centi_dbm(budget), 0);
584    }
585
586    #[test]
587    fn a_budget_matches_the_rust_crate() {
588        let budget = PamojaLoraLinkBudget {
589            transmit_power_centi_dbm: 1_400,
590            transmit_antenna_gain_centi_dbi: 215,
591            transmit_cable_loss_centi_db: 50,
592            receive_antenna_gain_centi_dbi: 600,
593            receive_cable_loss_centi_db: 150,
594            noise_figure_centi_db: 300,
595        };
596        let link = pamoja_lora_link_default(12, 125_000);
597        assert_eq!(pamoja_lora_link_budget_eirp_centi_dbm(budget), 1_565);
598        assert_eq!(
599            pamoja_lora_link_budget_received_centi_dbm(budget, 10_520),
600            -8_505
601        );
602        assert_eq!(
603            pamoja_lora_link_budget_sensitivity_centi_dbm(budget, link),
604            -14_003
605        );
606        assert_eq!(
607            pamoja_lora_link_budget_max_path_loss_centi_db(budget, link),
608            16_018
609        );
610        assert_eq!(
611            pamoja_lora_link_budget_margin_centi_db(budget, link, 10_520),
612            5_498
613        );
614        assert_eq!(
615            pamoja_lora_link_budget_max_transmit_power_centi_dbm(budget, 1_600),
616            1_435
617        );
618    }
619
620    #[test]
621    fn the_range_terms_match_the_rust_crate() {
622        assert_eq!(pamoja_lora_noise_floor_centi_dbm(125_000), -12_303);
623        assert_eq!(pamoja_lora_demodulator_snr_centi_db(12), -2_000);
624        assert_eq!(
625            pamoja_lora_free_space_loss_centi_db(5_000, 868_100_000),
626            10_520
627        );
628        assert_eq!(
629            pamoja_lora_fresnel_radius_mm(2_500, 2_500, 868_100_000),
630            20_777
631        );
632    }
633
634    #[test]
635    fn the_fcc_rule_takes_off_gain_past_6_dbi() {
636        assert_eq!(pamoja_lora_fcc_digital_max_conducted_centi_dbm(215), 3_000);
637        assert_eq!(pamoja_lora_fcc_digital_max_conducted_centi_dbm(900), 2_700);
638        assert_eq!(
639            pamoja_lora_fcc_hopping_max_conducted_centi_dbm(64, 900),
640            2_700
641        );
642        assert_eq!(
643            pamoja_lora_fcc_hopping_max_conducted_centi_dbm(25, 0),
644            2_398
645        );
646        assert_eq!(
647            pamoja_lora_fcc_hopping_max_conducted_centi_dbm(24, 0),
648            i32::MIN
649        );
650    }
651}