From 64100d631145190c401adfa9e15ce4dc0ec30c58 Mon Sep 17 00:00:00 2001 From: dmduran12 Date: Sun, 18 Jan 2026 17:16:21 -0800 Subject: [PATCH] fix: use correct Semtech LoRa airtime formula Replace simplified airtime calculation with the proper Semtech reference formula that accounts for: - Coding rate (CR) - CRC overhead - Explicit/implicit header mode - Low data rate optimization (SF11/SF12 at 125kHz) The previous formula significantly underestimated airtime, especially at higher spreading factors, leading to inaccurate duty cycle tracking. Reference: https://www.semtech.com/design-support/lora-calculator Co-Authored-By: Warp --- repeater/airtime.py | 55 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 47 insertions(+), 8 deletions(-) diff --git a/repeater/airtime.py b/repeater/airtime.py index f456613..7dfef85 100644 --- a/repeater/airtime.py +++ b/repeater/airtime.py @@ -1,4 +1,5 @@ import logging +import math import time from typing import Tuple @@ -22,16 +23,54 @@ class AirtimeManager: payload_len: int, spreading_factor: int = 7, bandwidth_hz: int = 125000, + coding_rate: int = 5, + preamble_len: int = 8, + crc_enabled: bool = True, + explicit_header: bool = True, ) -> float: - + """ + Calculate LoRa packet airtime using the Semtech reference formula. + + Reference: https://www.semtech.com/design-support/lora-calculator + + Args: + payload_len: Payload length in bytes + spreading_factor: SF7-SF12 (default: 7) + bandwidth_hz: Bandwidth in Hz (default: 125000) + coding_rate: CR denominator, 5=4/5, 6=4/6, 7=4/7, 8=4/8 (default: 5) + preamble_len: Preamble symbols (default: 8) + crc_enabled: Whether CRC is enabled (default: True) + explicit_header: Whether explicit header mode is used (default: True) + + Returns: + Airtime in milliseconds + """ + sf = spreading_factor bw_khz = bandwidth_hz / 1000 - symbol_time = (2**spreading_factor) / bw_khz - preamble_time = 8 * symbol_time - payload_symbols = (payload_len + 4.25) * 8 - payload_time = payload_symbols * symbol_time - - total_ms = preamble_time + payload_time - return total_ms + cr = coding_rate + crc = 1 if crc_enabled else 0 + h = 0 if explicit_header else 1 # H=0 for explicit, H=1 for implicit + + # Low data rate optimization: required for SF11/SF12 at 125kHz + de = 1 if (sf >= 11 and bandwidth_hz <= 125000) else 0 + + # Symbol time in milliseconds: T_sym = 2^SF / BW_kHz + t_sym = (2 ** sf) / bw_khz + + # Preamble time: T_preamble = (n_preamble + 4.25) * T_sym + t_preamble = (preamble_len + 4.25) * t_sym + + # Payload symbol calculation (Semtech formula): + # n_payload = 8 + ceil(max(8*PL - 4*SF + 28 + 16*CRC - 20*H, 0) / (4*(SF - 2*DE))) * CR + numerator = max(8 * payload_len - 4 * sf + 28 + 16 * crc - 20 * h, 0) + denominator = 4 * (sf - 2 * de) + n_payload = 8 + math.ceil(numerator / denominator) * cr + + # Payload time + t_payload = n_payload * t_sym + + # Total packet airtime + return t_preamble + t_payload def can_transmit(self, airtime_ms: float) -> Tuple[bool, float]: enforcement_enabled = self.config.get("duty_cycle", {}).get("enforcement_enabled", True)