"""Tests for repeater.airtime using radio preset configurations. This complements duration-focused tests by validating AirtimeManager behavior across real-world SF/BW/CR combinations from radio-presets.json. """ import json import math from pathlib import Path import pytest from repeater.airtime import AirtimeManager def _semtech_airtime_ms(payload_len: int, sf: int, bw_hz: int, cr: int, preamble: int) -> float: """Independent Semtech reference formula used as oracle in tests.""" crc = 1 h = 0 # explicit header de = 1 if (sf >= 11 and bw_hz <= 125000) else 0 t_sym = (2**sf) / (bw_hz / 1000) t_preamble = (preamble + 4.25) * t_sym 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 return t_preamble + n_payload * t_sym def _load_all_presets(): """Load preset tuples (title, sf, bw_hz, cr) from JSON.""" preset_file = Path(__file__).resolve().parents[1] / "radio-presets.json" data = json.loads(preset_file.read_text(encoding="utf-8")) entries = data["config"]["suggested_radio_settings"]["entries"] selected = [] for e in entries: selected.append( ( e["title"], int(e["spreading_factor"]), int(float(e["bandwidth"]) * 1000), int(e["coding_rate"]), ) ) return selected ALL_PRESETS = _load_all_presets() ALL_PRESET_IDS = [p[0] for p in ALL_PRESETS] def _make_mgr(sf: int, bw_hz: int, cr: int, preamble: int = 8, max_airtime_per_minute: int = 3600): cfg = { "radio": { "spreading_factor": sf, "bandwidth": bw_hz, "coding_rate": cr, "preamble_length": preamble, }, "duty_cycle": { "max_airtime_per_minute": max_airtime_per_minute, "enforcement_enabled": True, }, } return AirtimeManager(cfg) def test_all_presets_loaded(): assert ALL_PRESETS @pytest.mark.parametrize("_title,sf,bw_hz,cr", ALL_PRESETS, ids=ALL_PRESET_IDS) def test_all_presets_match_semtech_formula(_title, sf, bw_hz, cr): mgr = _make_mgr(sf, bw_hz, cr, preamble=8) for payload_len in (16, 32, 64, 128): actual = mgr.calculate_airtime(payload_len) expected = _semtech_airtime_ms(payload_len, sf=sf, bw_hz=bw_hz, cr=cr, preamble=8) assert math.isclose(actual, expected, rel_tol=1e-9), ( f"{_title} mismatch for {payload_len}B: got {actual}, expected {expected}" ) @pytest.mark.parametrize("_title,sf,bw_hz,cr", ALL_PRESETS, ids=ALL_PRESET_IDS) def test_all_presets_airtime_increases_with_payload(_title, sf, bw_hz, cr): mgr = _make_mgr(sf, bw_hz, cr, preamble=8) short = mgr.calculate_airtime(16) medium = mgr.calculate_airtime(64) long_ = mgr.calculate_airtime(128) assert short < medium < long_ def test_long_range_preset_has_higher_airtime_than_fast_preset_for_same_payload(): # EU/UK long-range profile vs US recommended profile from presets. long_mgr = _make_mgr(sf=11, bw_hz=250000, cr=5, preamble=8) fast_mgr = _make_mgr(sf=7, bw_hz=62500, cr=5, preamble=8) payload_len = 64 assert long_mgr.calculate_airtime(payload_len) > fast_mgr.calculate_airtime(payload_len) @pytest.mark.parametrize("_title,sf,bw_hz,cr", ALL_PRESETS, ids=ALL_PRESET_IDS) def test_can_transmit_blocks_after_budget_exhausted_for_each_preset(_title, sf, bw_hz, cr): mgr = _make_mgr(sf, bw_hz, cr, preamble=8, max_airtime_per_minute=600) airtime = mgr.calculate_airtime(64) # Feed TX history until just before the limit. sent = 0 while True: can_tx, _ = mgr.can_transmit(airtime) if not can_tx: break mgr.record_tx(airtime) sent += 1 # Safety guard against accidental infinite loops. assert sent < 1000 can_tx_after, wait = mgr.can_transmit(airtime) assert can_tx_after is False assert wait >= 0.0 def test_stats_report_tx_rx_airtime_totals(): mgr = _make_mgr(sf=8, bw_hz=62500, cr=8, preamble=17) tx_airtime = mgr.calculate_airtime(50) rx_airtime = mgr.calculate_airtime(20) mgr.record_tx(tx_airtime) mgr.record_rx(rx_airtime) stats = mgr.get_stats() assert stats["total_airtime_ms"] == pytest.approx(tx_airtime) assert stats["total_rx_airtime_ms"] == pytest.approx(rx_airtime) assert stats["current_airtime_ms"] == pytest.approx(tx_airtime)