Skip to main content

pamoja_lora/
link.rs

1//! LoRa link settings and the time-on-air they imply.
2
3// The symbol time above which low-data-rate optimization is required, in
4// microseconds. Above 16 ms per symbol (SF11 and SF12 at 125 kHz) the LoRa modem
5// turns it on, which the airtime formula accounts for.
6const LOW_DATA_RATE_THRESHOLD_US: u64 = 16_000;
7
8/// The radio settings of a LoRa link, enough to compute its time-on-air.
9///
10/// A LoRa transmission's duration is fixed by the spreading factor, the bandwidth,
11/// the coding rate, and the frame options, not by the data itself beyond its length.
12/// This struct gathers those settings and computes the two numbers a long-range
13/// deployment lives by: the [`airtime`](LinkSettings::airtime_us) of a payload, and
14/// the [`off time`](LinkSettings::min_off_time_us) a duty-cycle limit then forces
15/// before the next transmission.
16///
17/// A higher spreading factor reaches much further but spends far longer on air, so the
18/// same payload that takes tens of milliseconds at SF7 can take most of a second at
19/// SF12, with a correspondingly longer mandatory silence. The arithmetic is exact and
20/// integer-only, so it runs on the smallest node.
21///
22/// # Examples
23///
24/// ```
25/// use pamoja_lora::LinkSettings;
26///
27/// // The default European long-range setup: SF12, 125 kHz, coding rate 4/5.
28/// let link = LinkSettings::new(12, 125_000);
29///
30/// // A 10-byte payload takes just under a second on air at SF12.
31/// assert_eq!(link.airtime_us(10), 991_232);
32/// ```
33#[derive(Clone, Copy, Debug, PartialEq, Eq)]
34pub struct LinkSettings {
35    spreading_factor: u8,
36    bandwidth_hz: u32,
37    cr_denominator: u8,
38    preamble_symbols: u16,
39    explicit_header: bool,
40    crc: bool,
41}
42
43impl LinkSettings {
44    /// Creates link settings from a spreading factor and bandwidth, with LoRa defaults.
45    ///
46    /// The defaults are coding rate 4/5, an 8-symbol preamble, an explicit header, and
47    /// CRC on, matching a typical uplink.
48    ///
49    /// # Arguments
50    ///
51    /// * `spreading_factor` - the spreading factor; clamped to the LoRa range 5 to 12.
52    ///   SF5 and SF6 carry the data rates RP002-1.0.5 added to several regions.
53    /// * `bandwidth_hz` - the channel bandwidth in hertz, such as `125_000`; `0` counts as one
54    ///   hertz, as it does across [`budget`](crate::budget).
55    ///
56    /// # Returns
57    ///
58    /// The link settings.
59    pub fn new(spreading_factor: u8, bandwidth_hz: u32) -> Self {
60        Self {
61            spreading_factor: spreading_factor.clamp(5, 12),
62            bandwidth_hz: bandwidth_hz.max(1),
63            cr_denominator: 5,
64            preamble_symbols: 8,
65            explicit_header: true,
66            crc: true,
67        }
68    }
69
70    /// Sets the coding rate by its denominator, from 4/5 to 4/8.
71    ///
72    /// # Arguments
73    ///
74    /// * `denominator` - the coding-rate denominator, clamped to 5 to 8 for 4/5 to 4/8.
75    ///
76    /// # Returns
77    ///
78    /// The updated settings, for chaining.
79    pub fn with_coding_rate(mut self, denominator: u8) -> Self {
80        self.cr_denominator = denominator.clamp(5, 8);
81        self
82    }
83
84    /// Sets the number of preamble symbols.
85    ///
86    /// # Arguments
87    ///
88    /// * `symbols` - the preamble length in symbols; the LoRa default is 8.
89    ///
90    /// # Returns
91    ///
92    /// The updated settings, for chaining.
93    pub fn with_preamble(mut self, symbols: u16) -> Self {
94        self.preamble_symbols = symbols;
95        self
96    }
97
98    /// Uses an implicit header, which omits the header symbols from each frame.
99    ///
100    /// # Returns
101    ///
102    /// The updated settings, for chaining.
103    pub fn implicit_header(mut self) -> Self {
104        self.explicit_header = false;
105        self
106    }
107
108    /// Turns the frame CRC off.
109    ///
110    /// # Returns
111    ///
112    /// The updated settings, for chaining.
113    pub fn without_crc(mut self) -> Self {
114        self.crc = false;
115        self
116    }
117
118    /// Returns the spreading factor.
119    ///
120    /// # Returns
121    ///
122    /// The spreading factor, from 5 to 12.
123    pub fn spreading_factor(&self) -> u8 {
124        self.spreading_factor
125    }
126
127    /// Returns the channel bandwidth in hertz.
128    ///
129    /// # Returns
130    ///
131    /// The bandwidth in hertz.
132    pub fn bandwidth_hz(&self) -> u32 {
133        self.bandwidth_hz
134    }
135
136    /// Returns the coding-rate denominator.
137    ///
138    /// # Returns
139    ///
140    /// The denominator, from 5 to 8, for coding rates 4/5 to 4/8.
141    pub fn coding_rate_denominator(&self) -> u8 {
142        self.cr_denominator
143    }
144
145    /// Returns the preamble length.
146    ///
147    /// # Returns
148    ///
149    /// The number of preamble symbols.
150    pub fn preamble_symbols(&self) -> u16 {
151        self.preamble_symbols
152    }
153
154    /// Reports whether each frame carries an explicit header.
155    ///
156    /// # Returns
157    ///
158    /// `true` for an explicit header, `false` when the header is implicit.
159    pub fn explicit_header(&self) -> bool {
160        self.explicit_header
161    }
162
163    /// Reports whether each frame carries a CRC.
164    ///
165    /// # Returns
166    ///
167    /// `true` when the frame CRC is on.
168    pub fn crc(&self) -> bool {
169        self.crc
170    }
171
172    /// Reports whether the link uses low data rate optimization.
173    ///
174    /// It is on when a symbol lasts longer than 16 ms, which is SF11 and SF12 at
175    /// 125 kHz and SF12 at 250 kHz. The airtime assumes it, so a radio configured from
176    /// these settings must enable it too.
177    ///
178    /// # Returns
179    ///
180    /// `true` when the symbol time exceeds 16 ms.
181    pub fn low_data_rate_optimization(&self) -> bool {
182        self.symbol_time_us() > LOW_DATA_RATE_THRESHOLD_US
183    }
184
185    /// Returns the duration of one symbol in microseconds.
186    ///
187    /// # Returns
188    ///
189    /// The symbol time, `2^spreading_factor / bandwidth`, in microseconds.
190    pub fn symbol_time_us(&self) -> u64 {
191        (1u64 << self.spreading_factor) * 1_000_000 / u64::from(self.bandwidth_hz)
192    }
193
194    // The number of symbols in the payload portion of the frame.
195    fn payload_symbols(&self, payload_len: usize) -> u128 {
196        let sf = i128::from(self.spreading_factor);
197        let low_data_rate = self.low_data_rate_optimization();
198        let de = i128::from(low_data_rate);
199        let ih = i128::from(!self.explicit_header);
200        let crc = i128::from(self.crc);
201
202        let numerator = 8 * payload_len as i128 - 4 * sf + 28 + 16 * crc - 20 * ih;
203        let denominator = 4 * (sf - 2 * de); // always positive: sf >= 5, de <= 1
204        let term = if numerator > 0 {
205            let groups = (numerator as u128).div_ceil(denominator as u128);
206            groups * u128::from(self.cr_denominator)
207        } else {
208            0
209        };
210        8 + term
211    }
212
213    /// Returns the time on air of a payload in microseconds.
214    ///
215    /// This is the channel occupancy the transmission costs: how long the radio holds
216    /// the air, which sets both the duty-cycle budget and a large part of the energy
217    /// the transmission spends.
218    ///
219    /// # Arguments
220    ///
221    /// * `payload_len` - the payload length in bytes. One LoRa frame carries at most 255;
222    ///   the count runs on past that for a caller who asks.
223    ///
224    /// # Returns
225    ///
226    /// The time on air in microseconds, held at [`u64::MAX`] rather than overflowing.
227    pub fn airtime_us(&self, payload_len: usize) -> u64 {
228        let payload_symbols = self.payload_symbols(payload_len);
229        // Work in quarter-symbols so the preamble's 4.25-symbol tail stays exact.
230        let quarter_symbols = (4 * u128::from(self.preamble_symbols) + 17) + 4 * payload_symbols;
231        let symbol_units = quarter_symbols << self.spreading_factor;
232        let micros = symbol_units * 1_000_000 / (4 * u128::from(self.bandwidth_hz));
233        u64::try_from(micros).unwrap_or(u64::MAX)
234    }
235
236    /// Returns the minimum silence after a transmission to honor a duty-cycle limit.
237    ///
238    /// A duty-cycle limit caps the fraction of time a node may transmit, so after a
239    /// transmission of a given airtime the node must stay quiet for long enough that
240    /// the airtime is no more than that fraction of the whole cycle.
241    ///
242    /// # Arguments
243    ///
244    /// * `payload_len` - the payload length in bytes.
245    /// * `duty_cycle_permille` - the duty-cycle limit in parts per thousand, so `10`
246    ///   is 1%.
247    ///
248    /// # Returns
249    ///
250    /// The required off time in microseconds: [`u64::MAX`] if the limit is zero, and `0`
251    /// for a limit of 1000 or more, the whole of the time.
252    pub fn min_off_time_us(&self, payload_len: usize, duty_cycle_permille: u32) -> u64 {
253        if duty_cycle_permille == 0 {
254            return u64::MAX;
255        }
256        if duty_cycle_permille >= 1000 {
257            return 0;
258        }
259        let airtime = u128::from(self.airtime_us(payload_len));
260        let permille = u128::from(duty_cycle_permille);
261        u64::try_from(airtime * (1000 - permille) / permille).unwrap_or(u64::MAX)
262    }
263
264    /// Returns how many transmissions of a payload fit in an hour under a duty-cycle limit.
265    ///
266    /// A transmission really costs its airtime plus the silence the limit forces after
267    /// it, so this is the message budget a deployment plans against.
268    ///
269    /// # Arguments
270    ///
271    /// * `payload_len` - the payload length in bytes.
272    /// * `duty_cycle_permille` - the duty-cycle limit in parts per thousand, so `10` is
273    ///   1%.
274    ///
275    /// # Returns
276    ///
277    /// The number of whole transmissions an hour, or `0` when the limit is zero and so
278    /// forbids transmitting.
279    ///
280    /// # Examples
281    ///
282    /// ```
283    /// use pamoja_lora::LinkSettings;
284    ///
285    /// // Ten bytes at SF12 hold the air for just under a second, and 1% of the time
286    /// // leaves room for thirty-six of them an hour.
287    /// let link = LinkSettings::new(12, 125_000);
288    /// assert_eq!(link.messages_per_hour(10, 10), 36);
289    /// assert_eq!(link.messages_per_hour(10, 0), 0);
290    /// ```
291    pub fn messages_per_hour(&self, payload_len: usize, duty_cycle_permille: u32) -> u64 {
292        if duty_cycle_permille == 0 {
293            return 0;
294        }
295        let airtime = u128::from(self.airtime_us(payload_len));
296        let silence = u128::from(self.min_off_time_us(payload_len, duty_cycle_permille));
297        let each = (airtime + silence).max(1);
298        u64::try_from(3_600_000_000 / each).unwrap_or(u64::MAX)
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    #[test]
307    fn the_settings_report_what_a_radio_is_configured_with() {
308        let link = LinkSettings::new(9, 250_000)
309            .with_coding_rate(8)
310            .with_preamble(12)
311            .implicit_header()
312            .without_crc();
313        assert_eq!(link.coding_rate_denominator(), 8);
314        assert_eq!(link.preamble_symbols(), 12);
315        assert!(!link.explicit_header());
316        assert!(!link.crc());
317        assert!(!link.low_data_rate_optimization());
318        assert!(LinkSettings::new(11, 125_000).low_data_rate_optimization());
319        assert!(LinkSettings::new(12, 250_000).low_data_rate_optimization());
320        assert!(!LinkSettings::new(10, 125_000).low_data_rate_optimization());
321    }
322
323    #[test]
324    fn airtime_matches_the_reference_sf12() {
325        // The widely published reference value for SF12, BW125, CR4/5, 8-symbol
326        // preamble, explicit header, CRC on, 10-byte payload.
327        let link = LinkSettings::new(12, 125_000);
328        assert_eq!(link.airtime_us(10), 991_232);
329    }
330
331    #[test]
332    fn airtime_matches_the_reference_sf7() {
333        let link = LinkSettings::new(7, 125_000);
334        assert_eq!(link.airtime_us(10), 41_216);
335    }
336
337    #[test]
338    fn a_higher_spreading_factor_is_slower() {
339        let slow = LinkSettings::new(12, 125_000);
340        let fast = LinkSettings::new(7, 125_000);
341        assert!(slow.airtime_us(20) > fast.airtime_us(20) * 10);
342    }
343
344    #[test]
345    fn symbol_time_follows_spreading_factor_and_bandwidth() {
346        assert_eq!(LinkSettings::new(12, 125_000).symbol_time_us(), 32_768);
347        assert_eq!(LinkSettings::new(7, 125_000).symbol_time_us(), 1_024);
348        assert_eq!(LinkSettings::new(7, 250_000).symbol_time_us(), 512);
349    }
350
351    #[test]
352    fn the_spreading_factor_is_clamped_to_the_lora_range() {
353        assert_eq!(LinkSettings::new(3, 125_000).spreading_factor(), 5);
354        assert_eq!(LinkSettings::new(20, 125_000).spreading_factor(), 12);
355    }
356
357    #[test]
358    fn the_fastest_spreading_factors_survive_unclamped() {
359        // RP002-1.0.5 added SF6 and SF5 data rates to several regions, so
360        // clamping them up to SF7 would report the wrong airtime for them.
361        assert_eq!(LinkSettings::new(6, 125_000).spreading_factor(), 6);
362        assert_eq!(LinkSettings::new(5, 125_000).spreading_factor(), 5);
363        assert!(
364            LinkSettings::new(5, 125_000).airtime_us(10)
365                < LinkSettings::new(7, 125_000).airtime_us(10),
366            "a lower spreading factor is faster"
367        );
368    }
369
370    #[test]
371    fn a_one_percent_duty_cycle_forces_ninety_nine_times_the_airtime() {
372        let link = LinkSettings::new(7, 125_000);
373        let airtime = link.airtime_us(10);
374        assert_eq!(link.min_off_time_us(10, 10), airtime * 99);
375    }
376
377    #[test]
378    fn a_zero_duty_cycle_never_allows_another_send() {
379        let link = LinkSettings::new(7, 125_000);
380        assert_eq!(link.min_off_time_us(10, 0), u64::MAX);
381    }
382
383    #[test]
384    fn implicit_header_and_no_crc_shorten_the_frame() {
385        let full = LinkSettings::new(9, 125_000);
386        let lean = LinkSettings::new(9, 125_000)
387            .implicit_header()
388            .without_crc();
389        assert!(lean.airtime_us(20) < full.airtime_us(20));
390    }
391
392    #[test]
393    fn a_zero_byte_payload_still_costs_the_preamble_and_header() {
394        // Even with no payload, the preamble and header occupy the air.
395        let link = LinkSettings::new(7, 125_000);
396        assert!(link.airtime_us(0) > 0);
397        assert!(link.airtime_us(0) < link.airtime_us(10));
398    }
399
400    #[test]
401    fn airtime_grows_with_the_payload() {
402        let link = LinkSettings::new(10, 125_000);
403        assert!(link.airtime_us(1) <= link.airtime_us(10));
404        assert!(link.airtime_us(10) < link.airtime_us(64));
405    }
406
407    #[test]
408    fn a_wider_bandwidth_is_faster() {
409        // Doubling the bandwidth roughly halves the time on air.
410        let narrow = LinkSettings::new(9, 125_000).airtime_us(20);
411        let wide = LinkSettings::new(9, 250_000).airtime_us(20);
412        assert!(wide < narrow);
413    }
414
415    #[test]
416    fn a_zero_bandwidth_counts_as_one_hertz() {
417        let link = LinkSettings::new(12, 0);
418        assert_eq!(link.bandwidth_hz(), 1);
419        assert_eq!(link.symbol_time_us(), 4_096_000_000);
420        assert_eq!(
421            link.airtime_us(10),
422            LinkSettings::new(12, 125_000).airtime_us(10) * 125_000
423        );
424    }
425
426    #[test]
427    fn a_payload_past_one_frame_keeps_counting_up() {
428        let link = LinkSettings::new(12, 125_000);
429        let most = link.airtime_us(255);
430        assert!(link.airtime_us(1 << 29) > most);
431        assert!(link.airtime_us(u32::MAX as usize) > link.airtime_us(1 << 29));
432        assert_eq!(link.airtime_us(usize::MAX), u64::MAX);
433    }
434
435    #[test]
436    fn a_limit_of_the_whole_time_owes_no_silence() {
437        let link = LinkSettings::new(12, 125_000);
438        assert_eq!(link.min_off_time_us(10, 1000), 0);
439        assert_eq!(link.min_off_time_us(10, 1001), 0);
440        assert_eq!(link.min_off_time_us(10, u32::MAX), 0);
441        assert_eq!(link.min_off_time_us(10, 0), u64::MAX);
442    }
443
444    #[test]
445    fn the_hourly_budget_is_the_hour_over_what_each_transmission_costs() {
446        let link = LinkSettings::new(12, 125_000);
447        let each = link.airtime_us(10) + link.min_off_time_us(10, 10);
448        assert_eq!(link.messages_per_hour(10, 10), 3_600_000_000 / each);
449        assert_eq!(
450            link.messages_per_hour(10, 1000),
451            3_600_000_000 / link.airtime_us(10),
452            "the whole of the time owes no silence"
453        );
454        assert_eq!(
455            link.messages_per_hour(10, 0),
456            0,
457            "a zero limit forbids sending"
458        );
459        assert_eq!(link.messages_per_hour(usize::MAX, 10), 0);
460        let instant = LinkSettings::new(5, u32::MAX);
461        assert_eq!(instant.airtime_us(0), 0);
462        assert_eq!(instant.messages_per_hour(0, 1000), 3_600_000_000);
463    }
464}