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`.
54 ///
55 /// # Returns
56 ///
57 /// The link settings.
58 pub fn new(spreading_factor: u8, bandwidth_hz: u32) -> Self {
59 Self {
60 spreading_factor: spreading_factor.clamp(5, 12),
61 bandwidth_hz,
62 cr_denominator: 5,
63 preamble_symbols: 8,
64 explicit_header: true,
65 crc: true,
66 }
67 }
68
69 /// Sets the coding rate by its denominator, from 4/5 to 4/8.
70 ///
71 /// # Arguments
72 ///
73 /// * `denominator` - the coding-rate denominator, clamped to 5 to 8 for 4/5 to 4/8.
74 ///
75 /// # Returns
76 ///
77 /// The updated settings, for chaining.
78 pub fn with_coding_rate(mut self, denominator: u8) -> Self {
79 self.cr_denominator = denominator.clamp(5, 8);
80 self
81 }
82
83 /// Sets the number of preamble symbols.
84 ///
85 /// # Arguments
86 ///
87 /// * `symbols` - the preamble length in symbols; the LoRa default is 8.
88 ///
89 /// # Returns
90 ///
91 /// The updated settings, for chaining.
92 pub fn with_preamble(mut self, symbols: u16) -> Self {
93 self.preamble_symbols = symbols;
94 self
95 }
96
97 /// Uses an implicit header, which omits the header symbols from each frame.
98 ///
99 /// # Returns
100 ///
101 /// The updated settings, for chaining.
102 pub fn implicit_header(mut self) -> Self {
103 self.explicit_header = false;
104 self
105 }
106
107 /// Turns the frame CRC off.
108 ///
109 /// # Returns
110 ///
111 /// The updated settings, for chaining.
112 pub fn without_crc(mut self) -> Self {
113 self.crc = false;
114 self
115 }
116
117 /// Returns the spreading factor.
118 ///
119 /// # Returns
120 ///
121 /// The spreading factor, from 5 to 12.
122 pub fn spreading_factor(&self) -> u8 {
123 self.spreading_factor
124 }
125
126 /// Returns the channel bandwidth in hertz.
127 ///
128 /// # Returns
129 ///
130 /// The bandwidth in hertz.
131 pub fn bandwidth_hz(&self) -> u32 {
132 self.bandwidth_hz
133 }
134
135 /// Returns the duration of one symbol in microseconds.
136 ///
137 /// # Returns
138 ///
139 /// The symbol time, `2^spreading_factor / bandwidth`, in microseconds.
140 pub fn symbol_time_us(&self) -> u64 {
141 (1u64 << self.spreading_factor) * 1_000_000 / u64::from(self.bandwidth_hz)
142 }
143
144 // The number of symbols in the payload portion of the frame.
145 fn payload_symbols(&self, payload_len: usize) -> u32 {
146 let sf = i32::from(self.spreading_factor);
147 let low_data_rate = self.symbol_time_us() > LOW_DATA_RATE_THRESHOLD_US;
148 let de = i32::from(low_data_rate);
149 let ih = i32::from(!self.explicit_header);
150 let crc = i32::from(self.crc);
151
152 let numerator = 8 * payload_len as i32 - 4 * sf + 28 + 16 * crc - 20 * ih;
153 let denominator = 4 * (sf - 2 * de); // always positive: sf >= 5, de <= 1
154 let term = if numerator > 0 {
155 let groups = (numerator as u32).div_ceil(denominator as u32);
156 groups * u32::from(self.cr_denominator)
157 } else {
158 0
159 };
160 8 + term
161 }
162
163 /// Returns the time on air of a payload in microseconds.
164 ///
165 /// This is the channel occupancy the transmission costs: how long the radio holds
166 /// the air, which sets both the duty-cycle budget and a large part of the energy
167 /// the transmission spends.
168 ///
169 /// # Arguments
170 ///
171 /// * `payload_len` - the payload length in bytes.
172 ///
173 /// # Returns
174 ///
175 /// The time on air in microseconds.
176 pub fn airtime_us(&self, payload_len: usize) -> u64 {
177 let payload_symbols = u64::from(self.payload_symbols(payload_len));
178 // Work in quarter-symbols so the preamble's 4.25-symbol tail stays exact.
179 let quarter_symbols = (4 * u64::from(self.preamble_symbols) + 17) + 4 * payload_symbols;
180 let symbol_units = quarter_symbols * (1u64 << self.spreading_factor);
181 symbol_units * 1_000_000 / (4 * u64::from(self.bandwidth_hz))
182 }
183
184 /// Returns the minimum silence after a transmission to honor a duty-cycle limit.
185 ///
186 /// A duty-cycle limit caps the fraction of time a node may transmit, so after a
187 /// transmission of a given airtime the node must stay quiet for long enough that
188 /// the airtime is no more than that fraction of the whole cycle.
189 ///
190 /// # Arguments
191 ///
192 /// * `payload_len` - the payload length in bytes.
193 /// * `duty_cycle_permille` - the duty-cycle limit in parts per thousand, so `10`
194 /// is 1%.
195 ///
196 /// # Returns
197 ///
198 /// The required off time in microseconds, or [`u64::MAX`] if the limit is zero.
199 pub fn min_off_time_us(&self, payload_len: usize, duty_cycle_permille: u32) -> u64 {
200 if duty_cycle_permille == 0 {
201 return u64::MAX;
202 }
203 let airtime = self.airtime_us(payload_len);
204 let permille = u64::from(duty_cycle_permille);
205 airtime * (1000 - permille) / permille
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212
213 #[test]
214 fn airtime_matches_the_reference_sf12() {
215 // The widely published reference value for SF12, BW125, CR4/5, 8-symbol
216 // preamble, explicit header, CRC on, 10-byte payload.
217 let link = LinkSettings::new(12, 125_000);
218 assert_eq!(link.airtime_us(10), 991_232);
219 }
220
221 #[test]
222 fn airtime_matches_the_reference_sf7() {
223 let link = LinkSettings::new(7, 125_000);
224 assert_eq!(link.airtime_us(10), 41_216);
225 }
226
227 #[test]
228 fn a_higher_spreading_factor_is_slower() {
229 let slow = LinkSettings::new(12, 125_000);
230 let fast = LinkSettings::new(7, 125_000);
231 assert!(slow.airtime_us(20) > fast.airtime_us(20) * 10);
232 }
233
234 #[test]
235 fn symbol_time_follows_spreading_factor_and_bandwidth() {
236 assert_eq!(LinkSettings::new(12, 125_000).symbol_time_us(), 32_768);
237 assert_eq!(LinkSettings::new(7, 125_000).symbol_time_us(), 1_024);
238 assert_eq!(LinkSettings::new(7, 250_000).symbol_time_us(), 512);
239 }
240
241 #[test]
242 fn the_spreading_factor_is_clamped_to_the_lora_range() {
243 assert_eq!(LinkSettings::new(3, 125_000).spreading_factor(), 5);
244 assert_eq!(LinkSettings::new(20, 125_000).spreading_factor(), 12);
245 }
246
247 #[test]
248 fn the_fastest_spreading_factors_survive_unclamped() {
249 // RP002-1.0.5 added SF6 and SF5 data rates to several regions, so
250 // clamping them up to SF7 would report the wrong airtime for them.
251 assert_eq!(LinkSettings::new(6, 125_000).spreading_factor(), 6);
252 assert_eq!(LinkSettings::new(5, 125_000).spreading_factor(), 5);
253 assert!(
254 LinkSettings::new(5, 125_000).airtime_us(10)
255 < LinkSettings::new(7, 125_000).airtime_us(10),
256 "a lower spreading factor is faster"
257 );
258 }
259
260 #[test]
261 fn a_one_percent_duty_cycle_forces_ninety_nine_times_the_airtime() {
262 let link = LinkSettings::new(7, 125_000);
263 let airtime = link.airtime_us(10);
264 assert_eq!(link.min_off_time_us(10, 10), airtime * 99);
265 }
266
267 #[test]
268 fn a_zero_duty_cycle_never_allows_another_send() {
269 let link = LinkSettings::new(7, 125_000);
270 assert_eq!(link.min_off_time_us(10, 0), u64::MAX);
271 }
272
273 #[test]
274 fn implicit_header_and_no_crc_shorten_the_frame() {
275 let full = LinkSettings::new(9, 125_000);
276 let lean = LinkSettings::new(9, 125_000)
277 .implicit_header()
278 .without_crc();
279 assert!(lean.airtime_us(20) < full.airtime_us(20));
280 }
281
282 #[test]
283 fn a_zero_byte_payload_still_costs_the_preamble_and_header() {
284 // Even with no payload, the preamble and header occupy the air.
285 let link = LinkSettings::new(7, 125_000);
286 assert!(link.airtime_us(0) > 0);
287 assert!(link.airtime_us(0) < link.airtime_us(10));
288 }
289
290 #[test]
291 fn airtime_grows_with_the_payload() {
292 let link = LinkSettings::new(10, 125_000);
293 assert!(link.airtime_us(1) <= link.airtime_us(10));
294 assert!(link.airtime_us(10) < link.airtime_us(64));
295 }
296
297 #[test]
298 fn a_wider_bandwidth_is_faster() {
299 // Doubling the bandwidth roughly halves the time on air.
300 let narrow = LinkSettings::new(9, 125_000).airtime_us(20);
301 let wide = LinkSettings::new(9, 250_000).airtime_us(20);
302 assert!(wide < narrow);
303 }
304}