prns-runtime 0.3.4

Runtime-neutral Personal Reticulum node contracts and manifold kernel
Documentation
//! Actual wall-clock airtime usage, used primarily for duty cycle control on constrained interfaces.

use crate::engine::InstantMillis;
use crate::interfaces::{AirtimeUtilization, BitrateBps};
use crate::manifold::window_ring::WindowRing;

const SHORT_BUCKET_MS: u64 = 1_000;
const SHORT_BUCKETS: usize = 15;
const LONG_BUCKET_MS: u64 = 60_000;
const LONG_BUCKETS: usize = 60;
const AIRTIME_SHORT_WINDOW_MS: u64 = SHORT_BUCKET_MS * SHORT_BUCKETS as u64;
const AIRTIME_LONG_WINDOW_MS: u64 = LONG_BUCKET_MS * LONG_BUCKETS as u64;

pub fn frame_airtime_us(frame_bytes: usize, bitrate: BitrateBps) -> u64 {
    (frame_bytes as u64).saturating_mul(8_000_000) / bitrate.get()
}

pub struct AirtimeLedger {
    short: WindowRing<SHORT_BUCKETS>,
    long: WindowRing<LONG_BUCKETS>,
}

impl Default for AirtimeLedger {
    fn default() -> Self {
        Self::new()
    }
}

impl AirtimeLedger {
    #[must_use]
    pub const fn new() -> Self {
        Self {
            short: WindowRing::new(SHORT_BUCKET_MS),
            long: WindowRing::new(LONG_BUCKET_MS),
        }
    }

    pub fn record_tx(&mut self, now: InstantMillis, airtime_us: u64) -> AirtimeUtilization {
        self.short.record(now, airtime_us);
        self.long.record(now, airtime_us);
        self.utilization(now)
    }

    pub fn utilization(&mut self, now: InstantMillis) -> AirtimeUtilization {
        AirtimeUtilization {
            short_per_mille: per_mille(self.short.total(now), AIRTIME_SHORT_WINDOW_MS),
            long_per_mille: per_mille(self.long.total(now), AIRTIME_LONG_WINDOW_MS),
        }
    }

    /// Utilization if `airtime_us` were transmitted now, without recording it.
    ///
    /// Duty admission must ask this question before a transmission. Looking
    /// only at the current ledger permits a single slow frame to cross the
    /// configured limit.
    pub fn projected_utilization(
        &mut self,
        now: InstantMillis,
        airtime_us: u64,
    ) -> AirtimeUtilization {
        AirtimeUtilization {
            short_per_mille: projected_per_mille(
                self.short.total(now).saturating_add(airtime_us),
                AIRTIME_SHORT_WINDOW_MS,
            ),
            long_per_mille: projected_per_mille(
                self.long.total(now).saturating_add(airtime_us),
                AIRTIME_LONG_WINDOW_MS,
            ),
        }
    }
}

fn per_mille(total_us: u64, window_ms: u64) -> u16 {
    let window_us = window_ms * 1_000;
    (total_us.saturating_mul(1_000) / window_us).min(1_000) as u16
}

fn projected_per_mille(total_us: u64, window_ms: u64) -> u16 {
    let window_us = window_ms * 1_000;
    total_us
        .saturating_mul(1_000)
        .saturating_add(window_us.saturating_sub(1))
        .saturating_div(window_us)
        .min(1_000) as u16
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn frame_airtime_spans_the_bitrate_extremes() {
        assert_eq!(
            frame_airtime_us(167, BitrateBps::guess(5_000)),
            267_200,
            "a typical announce on a slow LoRa-class link costs about a quarter second",
        );
        assert_eq!(
            frame_airtime_us(500, BitrateBps::guess(1_000_000_000)),
            4,
            "a full BROADCAST_MTU frame on a gigabit link still registers, in microseconds",
        );
    }

    #[test]
    fn utilization_reflects_what_was_recorded_in_the_window() {
        let mut ledger = AirtimeLedger::new();
        let report = ledger.record_tx(InstantMillis(1_000), 1_500_000);
        assert_eq!(
            report.short_per_mille, 100,
            "1.5s of airtime in a 15s window is 10%",
        );
        assert_eq!(
            report.long_per_mille, 0,
            "the same burst rounds to under a per-mille of an hour",
        );
    }

    #[test]
    fn the_short_window_forgets_and_the_long_window_remembers() {
        let mut ledger = AirtimeLedger::new();
        ledger.record_tx(InstantMillis(1_000), 3_600_000);
        let later = ledger.utilization(InstantMillis(1_000 + AIRTIME_SHORT_WINDOW_MS));
        assert_eq!(later.short_per_mille, 0, "the burst aged out of 15s");
        assert_eq!(later.long_per_mille, 1, "3.6s of an hour is one per-mille");

        let much_later = ledger.utilization(InstantMillis(1_000 + AIRTIME_LONG_WINDOW_MS));
        assert_eq!(much_later.long_per_mille, 0, "and out of the hour too");
    }

    #[test]
    fn buckets_accumulate_within_and_age_individually() {
        let mut ledger = AirtimeLedger::new();
        ledger.record_tx(InstantMillis(500), 750_000);
        ledger.record_tx(InstantMillis(900), 750_000);
        assert_eq!(
            ledger.utilization(InstantMillis(900)).short_per_mille,
            100,
            "same bucket accumulates",
        );

        ledger.record_tx(InstantMillis(5_000), 1_500_000);
        assert_eq!(
            ledger.utilization(InstantMillis(5_000)).short_per_mille,
            200,
            "two live buckets sum across the window",
        );
        assert_eq!(
            ledger.utilization(InstantMillis(16_200)).short_per_mille,
            100,
            "the first bucket aged out, the second still counts",
        );
    }

    #[test]
    fn utilization_is_clamped_at_full() {
        let mut ledger = AirtimeLedger::new();
        let report = ledger.record_tx(InstantMillis(1_000), 60_000_000);
        assert_eq!(
            report.short_per_mille, 1_000,
            "a frame longer than the window cannot read past 100%",
        );
    }

    #[test]
    fn a_long_idle_gap_clears_the_whole_ring() {
        let mut ledger = AirtimeLedger::new();
        ledger.record_tx(InstantMillis(1_000), 10_000_000);
        let report = ledger.utilization(InstantMillis(1_000 + 100 * AIRTIME_LONG_WINDOW_MS));
        assert_eq!(report.short_per_mille, 0);
        assert_eq!(report.long_per_mille, 0);
    }

    #[test]
    fn projected_utilization_accounts_for_the_candidate_without_recording_it() {
        let mut ledger = AirtimeLedger::new();
        ledger.record_tx(InstantMillis(1_000), 1_350_000);

        let projected = ledger.projected_utilization(InstantMillis(1_000), 300_000);
        assert_eq!(projected.short_per_mille, 110);
        assert_eq!(
            ledger.utilization(InstantMillis(1_000)).short_per_mille,
            90,
            "asking about a candidate does not spend its airtime"
        );
    }

    #[test]
    fn projected_utilization_rounds_up_so_admission_cannot_hide_fractional_overage() {
        let mut ledger = AirtimeLedger::new();
        let projected = ledger.projected_utilization(InstantMillis(1_000), 1_500_001);
        assert_eq!(
            projected.short_per_mille, 101,
            "10.000006% cannot be admitted as exactly 10%"
        );
        assert_eq!(ledger.utilization(InstantMillis(1_000)).short_per_mille, 0);
    }
}