From 2ee015c7258ea13160029447a2d4ca9db4dc7582 Mon Sep 17 00:00:00 2001 From: agessaman Date: Mon, 21 Sep 2026 16:00:39 -0700 Subject: [PATCH] test(airtime): meter a partial radios[] entry on the node's own air settings Drives AirtimeBudgets rather than stopping at the profile dict: a radios[] entry stating only its frequency is charged at the modulation it inherits, lands on the channel that bandwidth puts it on, and is no longer metered 5x faster than it transmits. --- tests/test_per_radio_duty_cycle.py | 76 ++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/tests/test_per_radio_duty_cycle.py b/tests/test_per_radio_duty_cycle.py index 70eafee..771ddeb 100644 --- a/tests/test_per_radio_duty_cycle.py +++ b/tests/test_per_radio_duty_cycle.py @@ -1173,3 +1173,79 @@ def test_the_default_radio_adopts_its_new_modulation_once_it_is_applied(): manager._refresh_airtime_radio_params(default_radio_applied=True) assert handler.airtime_budgets.for_radio("local").bandwidth == NARROW["bandwidth"] + + +# --------------------------------------------------------------------------- +# What a partial radios[] entry is metered on +# --------------------------------------------------------------------------- +# Deliberately unlike the library fallback (62.5 kHz / SF8 / CR8) on every +# field, so a profile built from defaults cannot be mistaken for one built from +# the node's own block. +SLOW = { + "frequency": 869618000, + "bandwidth": 125000, + "spreading_factor": 12, + "coding_rate": 5, + "preamble_length": 32, +} +LIBRARY_FALLBACK = dict(SLOW, bandwidth=62500, spreading_factor=8, coding_rate=8) + + +def _inheriting_config(radios: list) -> dict: + """A node whose top-level block is the defaults its entries inherit.""" + config = _config(radios) + config["radio_type"] = "sx1262" + config["radio"] = dict(SLOW) + return config + + +PARTIAL_LINK = {"id": "link", "radio": {"frequency": 910525000}} + + +def test_a_partial_entry_is_metered_on_the_nodes_own_air_settings(): + """An entry that states only its frequency used to be metered on library + defaults rather than on the block it inherits from.""" + inherited = AirtimeBudgets(_inheriting_config([PARTIAL_LINK])) + explicit = AirtimeBudgets(_inheriting_config([_radio("link", dict(SLOW, frequency=910525000))])) + + assert inherited.for_radio("link").spreading_factor == 12 + assert inherited.for_radio("link").bandwidth == 125000 + assert inherited.for_radio("link").calculate_airtime(50) == pytest.approx( + explicit.for_radio("link").calculate_airtime(50) + ) + + +def test_a_partial_entry_is_not_metered_faster_than_it_transmits(): + """The hazard stated plainly: the old library-default profile charges a + fraction of the real time on air, and under-charging is the wrong direction + to be wrong about a legal limit.""" + inherited = AirtimeBudgets(_inheriting_config([PARTIAL_LINK])) + fallback = AirtimeBudgets( + _inheriting_config([_radio("link", dict(LIBRARY_FALLBACK, frequency=910525000))]) + ) + + charged = inherited.for_radio("link").calculate_airtime(50) + would_have_charged = fallback.for_radio("link").calculate_airtime(50) + + assert charged > would_have_charged * 4 + + +def test_a_partial_entry_lands_on_the_channel_its_inherited_bandwidth_puts_it_on(): + """Channels are keyed on (frequency, bandwidth), so an entry metered at a + defaulted bandwidth would be budgeted as a channel of its own rather than + alongside the radio it actually shares spectrum with.""" + budgets = AirtimeBudgets( + _inheriting_config( + [ + PARTIAL_LINK, + _radio("twin", dict(SLOW, frequency=910525000)), + _radio("elsewhere", dict(LIBRARY_FALLBACK, frequency=910525000)), + ] + ) + ) + + assert budgets.shares_budget("link", "twin") + assert not budgets.shares_budget("link", "elsewhere") + + budgets.for_radio("twin").record_tx(3600) + assert budgets.for_radio("link").can_transmit(10)[0] is False