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`.
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}