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}