use std::time::{Duration, Instant};
use donglora_client::LoRaBandwidth;
pub struct RateLimiter {
tokens: f64,
max_tokens: f64,
refill_rate: f64, last_refill: Instant,
}
const DEFAULT_BURST: f64 = 3.0;
const REFERENCE_PAYLOAD_BYTES: usize = 50;
const TARGET_DUTY_CYCLE: f64 = 0.5;
impl RateLimiter {
#[must_use]
pub fn from_radio_config(sf: u8, bw: LoRaBandwidth, cr: u8, preamble: u16, override_pps: Option<f64>) -> Self {
let refill_rate = override_pps.map_or_else(
|| {
let air_time = lora_air_time(REFERENCE_PAYLOAD_BYTES, sf, bw, cr, preamble);
let secs = air_time.as_secs_f64();
if secs > 0.0 { TARGET_DUTY_CYCLE / secs } else { 10.0 }
},
|pps| pps.max(0.01),
);
Self { tokens: DEFAULT_BURST, max_tokens: DEFAULT_BURST, refill_rate, last_refill: Instant::now() }
}
pub fn try_acquire(&mut self) -> bool {
self.refill();
if self.tokens >= 1.0 {
self.tokens -= 1.0;
true
} else {
false
}
}
#[must_use]
pub const fn rate_pps(&self) -> f64 {
self.refill_rate
}
fn refill(&mut self) {
let now = Instant::now();
let elapsed = now.duration_since(self.last_refill).as_secs_f64();
self.tokens = elapsed.mul_add(self.refill_rate, self.tokens).min(self.max_tokens);
self.last_refill = now;
}
}
#[must_use]
pub fn lora_air_time(payload_bytes: usize, sf: u8, bw: LoRaBandwidth, cr: u8, preamble: u16) -> Duration {
let sf_f = f64::from(sf);
let bw_hz = bandwidth_hz(bw);
let preamble_symbols = if preamble == 0 { 16.0 } else { f64::from(preamble) };
let t_sym = sf_f.exp2() / bw_hz;
let t_preamble = (preamble_symbols + 4.25) * t_sym;
let de: f64 = if sf >= 11 && bw_hz <= 125_000.0 { 1.0 } else { 0.0 };
#[allow(clippy::cast_precision_loss)] let payload_f = payload_bytes as f64;
let numerator = 8.0f64.mul_add(payload_f, (-4.0f64).mul_add(sf_f, 44.0));
let denominator = 4.0f64.mul_add(sf_f, -8.0 * de);
let n_payload = (numerator / denominator).ceil().max(0.0).mul_add(f64::from(cr), 8.0);
let t_payload = n_payload * t_sym;
let total = t_preamble + t_payload;
Duration::from_secs_f64(total)
}
const fn bandwidth_hz(bw: LoRaBandwidth) -> f64 {
match bw {
LoRaBandwidth::Khz7 => 7_800.0,
LoRaBandwidth::Khz10 => 10_400.0,
LoRaBandwidth::Khz15 => 15_600.0,
LoRaBandwidth::Khz20 => 20_800.0,
LoRaBandwidth::Khz31 => 31_250.0,
LoRaBandwidth::Khz41 => 41_700.0,
LoRaBandwidth::Khz62 => 62_500.0,
LoRaBandwidth::Khz125 => 125_000.0,
LoRaBandwidth::Khz250 => 250_000.0,
LoRaBandwidth::Khz500 => 500_000.0,
LoRaBandwidth::Khz200 => 200_000.0,
LoRaBandwidth::Khz400 => 400_000.0,
LoRaBandwidth::Khz800 => 800_000.0,
LoRaBandwidth::Khz1600 => 1_600_000.0,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn air_time_sf7_bw125() {
let t = lora_air_time(50, 7, LoRaBandwidth::Khz125, 5, 16);
assert!(t.as_millis() > 50, "air time too short: {t:?}");
assert!(t.as_millis() < 300, "air time too long: {t:?}");
}
#[test]
fn air_time_sf12_bw125() {
let t = lora_air_time(50, 12, LoRaBandwidth::Khz125, 5, 16);
assert!(t.as_secs() >= 1, "air time too short: {t:?}");
assert!(t.as_secs() < 10, "air time too long: {t:?}");
}
#[test]
fn rate_limiter_allows_burst() {
let mut rl = RateLimiter::from_radio_config(7, LoRaBandwidth::Khz125, 5, 16, None);
assert!(rl.try_acquire());
assert!(rl.try_acquire());
assert!(rl.try_acquire());
assert!(!rl.try_acquire());
}
#[test]
fn rate_limiter_override() {
let rl = RateLimiter::from_radio_config(7, LoRaBandwidth::Khz125, 5, 16, Some(42.0));
assert!((rl.rate_pps() - 42.0).abs() < f64::EPSILON);
}
#[test]
fn air_time_zero_preamble_uses_default() {
let t_default = lora_air_time(50, 7, LoRaBandwidth::Khz125, 5, 0);
let t_explicit = lora_air_time(50, 7, LoRaBandwidth::Khz125, 5, 16);
assert_eq!(t_default, t_explicit);
}
#[test]
fn from_radio_config_calculates_reasonable_rate() {
let rl = RateLimiter::from_radio_config(7, LoRaBandwidth::Khz125, 5, 16, None);
let rate = rl.rate_pps();
assert!(rate > 0.1, "rate too low: {rate}");
assert!(rate < 100.0, "rate too high: {rate}");
assert!((rate - 10.0).abs() > 0.5, "rate should not be the 10.0 fallback: {rate}");
assert!(rate > 4.0, "rate should be near 4.73: {rate}");
assert!(rate < 5.5, "rate should be near 4.73: {rate}");
}
#[test]
fn from_radio_config_sf12_rate_much_slower() {
let rl = RateLimiter::from_radio_config(12, LoRaBandwidth::Khz125, 5, 16, None);
let rate = rl.rate_pps();
assert!(rate > 0.1, "rate too low: {rate}");
assert!(rate < 0.5, "rate too high: {rate}");
assert!((rate - 10.0).abs() > 1.0, "rate should not be the fallback: {rate}");
}
#[test]
fn rate_limiter_refills_after_waiting() {
let mut rl = RateLimiter::from_radio_config(7, LoRaBandwidth::Khz125, 5, 16, Some(10.0));
assert!(rl.try_acquire());
assert!(rl.try_acquire());
assert!(rl.try_acquire());
assert!(!rl.try_acquire(), "burst should be exhausted");
std::thread::sleep(Duration::from_millis(200));
assert!(rl.try_acquire(), "should succeed after refill");
}
#[test]
fn air_time_exact_sf7_bw125() {
let t = lora_air_time(50, 7, LoRaBandwidth::Khz125, 5, 16);
let ms = t.as_secs_f64() * 1000.0;
assert!((ms - 105.728).abs() < 0.01, "SF7/BW125 air time should be ~105.728ms, got {ms:.3}ms");
}
#[test]
fn air_time_exact_sf12_bw125() {
let t = lora_air_time(50, 12, LoRaBandwidth::Khz125, 5, 16);
let ms = t.as_secs_f64() * 1000.0;
assert!((ms - 2564.096).abs() < 0.01, "SF12/BW125 air time should be ~2564.096ms, got {ms:.3}ms");
}
#[test]
fn air_time_ldro_sf11_bw125_vs_sf10_bw125() {
let t11 = lora_air_time(50, 11, LoRaBandwidth::Khz125, 5, 16);
let t10 = lora_air_time(50, 10, LoRaBandwidth::Khz125, 5, 16);
let ms11 = t11.as_secs_f64() * 1000.0;
let ms10 = t10.as_secs_f64() * 1000.0;
assert!((ms11 - 1445.888).abs() < 0.01, "SF11/BW125 should be ~1445.888ms, got {ms11:.3}ms");
assert!((ms10 - 681.984).abs() < 0.01, "SF10/BW125 should be ~681.984ms, got {ms10:.3}ms");
assert!(t11 > t10 * 2, "SF11 with LDRO should be >2x SF10");
}
#[test]
fn air_time_ldro_not_triggered_high_bw() {
let t11_250 = lora_air_time(50, 11, LoRaBandwidth::Khz250, 5, 16);
let ms = t11_250.as_secs_f64() * 1000.0;
assert!((ms - 641.024).abs() < 0.01, "SF11/BW250 should be ~641.024ms (no LDRO), got {ms:.3}ms");
let t11_125 = lora_air_time(50, 11, LoRaBandwidth::Khz125, 5, 16);
assert!(t11_125 > t11_250 * 2, "SF11/BW125 (LDRO on) should be >2x SF11/BW250 (LDRO off)");
}
#[test]
fn air_time_preamble_scaling() {
let t8 = lora_air_time(50, 7, LoRaBandwidth::Khz125, 5, 8);
let t16 = lora_air_time(50, 7, LoRaBandwidth::Khz125, 5, 16);
let t32 = lora_air_time(50, 7, LoRaBandwidth::Khz125, 5, 32);
let diff_8_to_16 = t16.as_secs_f64() - t8.as_secs_f64();
let diff_16_to_32 = t32.as_secs_f64() - t16.as_secs_f64();
let t_sym = 128.0 / 125_000.0;
let expected_8 = 8.0f64.mul_add(t_sym, -diff_8_to_16).abs();
assert!(expected_8 < 1e-9, "preamble 8->16 should add 8*t_sym, got {diff_8_to_16:.6}s");
let expected_16 = 16.0f64.mul_add(t_sym, -diff_16_to_32).abs();
assert!(expected_16 < 1e-9, "preamble 16->32 should add 16*t_sym, got {diff_16_to_32:.6}s");
}
#[test]
fn air_time_sensible_range_all_sf_bw() {
let bandwidths = [
LoRaBandwidth::Khz7,
LoRaBandwidth::Khz10,
LoRaBandwidth::Khz15,
LoRaBandwidth::Khz20,
LoRaBandwidth::Khz31,
LoRaBandwidth::Khz41,
LoRaBandwidth::Khz62,
LoRaBandwidth::Khz125,
LoRaBandwidth::Khz250,
LoRaBandwidth::Khz500,
];
for sf in 7..=12u8 {
for &bw in &bandwidths {
let t = lora_air_time(50, sf, bw, 5, 16);
let ms = t.as_secs_f64() * 1000.0;
assert!(ms > 1.0, "SF{sf}/BW{bw:?}: air time too short: {ms:.3}ms");
assert!(ms < 300_000.0, "SF{sf}/BW{bw:?}: air time too long: {ms:.3}ms");
}
}
}
mod proptests {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn air_time_always_positive(
payload in 0usize..256,
sf in 7u8..=12u8,
cr in 5u8..=8u8,
preamble in 0u16..=64u16,
) {
for bw in [LoRaBandwidth::Khz7, LoRaBandwidth::Khz62, LoRaBandwidth::Khz125, LoRaBandwidth::Khz500] {
let t = lora_air_time(payload, sf, bw, cr, preamble);
prop_assert!(t > Duration::ZERO, "air time must be positive");
prop_assert!(t < Duration::from_secs(600), "air time should be < 600s");
}
}
}
}
}