From 3468af94aa0f79e93c9bf041244bf230161fc704 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Tue, 15 Sep 2026 12:06:19 +0000 Subject: [PATCH] fix(hopscale): gate hop scaling on measured channel congestion (#11826) * fix(hopscale): gate hop scaling on measured channel congestion * fix(hopscale): rename test tripping trufflehog and release congestion at the threshold * test(hopscale): pin the unscaled precondition in the busy-channel gate test * fix(hopscale): floor infrastructure roles and engage below the polite gate * test(hopscale): name the symbol under test and reset the gate in clear() * fix(hopscale): drop the unneeded congestion reset in clear() * refactor(hopscale): drop the unused utilization accessor and duplicated log fields * refactor(hopscale): drive the politeness extension from measured congestion (#11831) * refactor(hopscale): drive the politeness extension from measured congestion * refactor(airtime): own the smoothed channel utilization (#11832) * refactor(airtime): own the smoothed channel utilization * refactor(airtime): cut comments to the two-line limit * fix(airtime): fold the smoothed utilization once per crossed bucket * fix(hopscale): compare the congestion thresholds at whole-percent resolution --- src/airtime.cpp | 66 +++++- src/airtime.h | 22 +- src/mesh/Default.h | 11 + src/modules/HopScalingModule.cpp | 112 +++++++--- src/modules/HopScalingModule.h | 44 +++- test/test_airtime/test_main.cpp | 114 ++++++++++ test/test_hop_scaling/test_main.cpp | 334 ++++++++++++++++++++++++++++ 7 files changed, 647 insertions(+), 56 deletions(-) diff --git a/src/airtime.cpp b/src/airtime.cpp index aaacefb092..154014cf8e 100644 --- a/src/airtime.cpp +++ b/src/airtime.cpp @@ -2,7 +2,9 @@ #include "NodeDB.h" #include "UptimeClock.h" #include "configuration.h" +#include #include +#include #include AirTime *airTime = NULL; @@ -60,7 +62,7 @@ uint8_t AirTime::Windows::getPeriodUtilHour(const Held &) return (secSinceBoot / 60) % MINUTES_IN_HOUR; } -void AirTime::Windows::syncNow(const Held &) +void AirTime::Windows::syncNow(const Held &held) { // Monotonic uptime, not RTC/network time: a user, GPS, or NTP clock change must not move // airtime accounting. Pure read; the main loop publishes the wrap carry it derives from. @@ -112,13 +114,16 @@ void AirTime::Windows::syncNow(const Held &) // Channel utilization is a rolling 60-second view split into six 10-second buckets. // Clear every bucket crossed while asleep so old airtime decays by real elapsed time. uint32_t elapsedUtilPeriods = (this->secSinceBoot / 10) - (oldSecSinceBoot / 10); - if (elapsedUtilPeriods >= CHANNEL_UTILIZATION_PERIODS) { - memset(this->channelUtilization, 0, sizeof(this->channelUtilization)); - } else { - for (uint32_t i = 1; i <= elapsedUtilPeriods; i++) { - this->channelUtilization[((oldSecSinceBoot / 10) + i) % CHANNEL_UTILIZATION_PERIODS] = 0; - } + // Fold one reading per crossed bucket, each before that bucket is cleared, so one delayed sync + // lands where the same number of 10 s syncs would have. Bounded: six clears empty the window. + const uint32_t steppedUtilPeriods = std::min(elapsedUtilPeriods, CHANNEL_UTILIZATION_PERIODS); + for (uint32_t i = 1; i <= steppedUtilPeriods; i++) { + foldChannelUtil(channelUtilizationPercentRaw(held), 1, held); + this->channelUtilization[((oldSecSinceBoot / 10) + i) % CHANNEL_UTILIZATION_PERIODS] = 0; } + // Anything past a full window is elapsed time against an already-empty ring, so it folds as + // idle in closed form rather than looping over a sleep that may have lasted days. + foldChannelUtil(0.0f, elapsedUtilPeriods - steppedUtilPeriods, held); // TX utilization is a rolling 60-minute view used by duty-cycle checks. uint32_t elapsedUtilTXPeriods = (this->secSinceBoot / 60) - (oldSecSinceBoot / 60); @@ -154,11 +159,8 @@ bool AirTime::Windows::airtimeReport(reportTypes reportType, uint32_t *out, size return true; } -float AirTime::Windows::channelUtilizationPercent(const Held &held) +float AirTime::Windows::channelUtilizationPercentRaw(const Held &) { - // Gate decisions should see buckets that have decayed across light-sleep time. - syncNow(held); - uint32_t sum = 0; for (uint32_t i = 0; i < CHANNEL_UTILIZATION_PERIODS; i++) { sum += this->channelUtilization[i]; @@ -167,6 +169,42 @@ float AirTime::Windows::channelUtilizationPercent(const Held &held) return (float(sum) / float(CHANNEL_UTILIZATION_PERIODS * 10 * 1000)) * 100; } +float AirTime::Windows::channelUtilizationPercent(const Held &held) +{ + // Gate decisions should see buckets that have decayed across light-sleep time. + syncNow(held); + + return channelUtilizationPercentRaw(held); +} + +void AirTime::Windows::foldChannelUtil(float sample, uint32_t steps, const Held &) +{ + if (steps == 0) + return; + + if (!hasChannelUtilSample) { + // Seed from the first reading, or a node booting onto a busy channel reports it quiet + // for a whole time constant. + channelUtilAvg = sample; + hasChannelUtilSample = true; + steps--; + } + + if (steps > 0) { + const float retained = powf(1.0f - 1.0f / float(CHANNEL_UTILIZATION_EMA_DIVISOR), float(steps)); + channelUtilAvg = sample + (channelUtilAvg - sample) * retained; + } +} + +float AirTime::Windows::smoothedChannelUtilizationPercent(const Held &held) +{ + syncNow(held); + + // Nothing folded yet before the first bucket crossing, and 0 would read as an idle channel + // rather than as no data. + return hasChannelUtilSample ? channelUtilAvg : channelUtilizationPercentRaw(held); +} + float AirTime::Windows::utilizationTXPercent(const Held &held) { // Duty-cycle checks use this value, so keep it current even outside the periodic thread. @@ -242,6 +280,12 @@ float AirTime::channelUtilizationPercent() return w.channelUtilizationPercent(held); } +float AirTime::smoothedChannelUtilizationPercent() +{ + Held held(this); + return w.smoothedChannelUtilizationPercent(held); +} + float AirTime::utilizationTXPercent() { Held held(this); diff --git a/src/airtime.h b/src/airtime.h index b1e1172a76..e789aa62b5 100644 --- a/src/airtime.h +++ b/src/airtime.h @@ -34,6 +34,8 @@ OUTPUTS: channelUtilizationPercent() % of the last 60s busy, all three types + smoothedChannelUtilizationPercent() + the same, behind a ~21 min EMA folded per bucket utilizationTXPercent() % of the last hour we transmitted isTxAllowedChannelUtil() gate on the former, 40% or 25% "polite" isTxAllowedAirUtil() gate on the latter, at HALF the duty cycle @@ -77,6 +79,9 @@ */ #define CHANNEL_UTILIZATION_PERIODS 6 +// EMA weight per crossed 10 s bucket: 1/128 is a time constant of about 21 minutes, so one busy +// or quiet minute cannot move the smoothed figure far. +#define CHANNEL_UTILIZATION_EMA_DIVISOR 128 #define SECONDS_PER_PERIOD 3600 #define PERIODS_TO_LOG 8 #define MINUTES_IN_HOUR 60 @@ -130,6 +135,9 @@ class AirTime : private concurrency::OSThread void logAirtime(reportTypes reportType, uint32_t airtime_ms); float channelUtilizationPercent(); + /// channelUtilizationPercent() behind an EMA advanced by elapsed time, for a caller that must + /// judge load from a trend rather than from one 60-second window. + float smoothedChannelUtilizationPercent(); float utilizationTXPercent(); /// Compatibility shim: no caller in the tree, kept for out-of-tree ones. @@ -182,7 +190,12 @@ class AirTime : private concurrency::OSThread // Modular rings: index is absolute phase, (uptime secs / period) % N, never age. uint32_t channelUtilization[CHANNEL_UTILIZATION_PERIODS] = {0}; // 6 x 10s - uint32_t utilizationTX[MINUTES_IN_HOUR] = {0}; // 60 x 60s, our TX only + + // EMA over channelUtilization, folded in syncNow() once per crossed 10 s bucket so its + // time constant follows elapsed time rather than how often a caller happens to ask. + float channelUtilAvg = 0.0f; + bool hasChannelUtilSample = false; + uint32_t utilizationTX[MINUTES_IN_HOUR] = {0}; // 60 x 60s, our TX only // Hour crossings rotated but not yet traced. The core cannot log its own rotations: it // only ever runs under the lock, and DEBUG_PORT.log() blocks on a UART write. runOnce() @@ -198,6 +211,13 @@ class AirTime : private concurrency::OSThread void logAirtime(reportTypes reportType, uint32_t airtime_ms, const Held &); float channelUtilizationPercent(const Held &); + /// The bucket sum alone. syncNow() folds the EMA and cannot reach it through + /// channelUtilizationPercent(), which would re-enter syncNow(). + float channelUtilizationPercentRaw(const Held &); + float smoothedChannelUtilizationPercent(const Held &); + /// Fold `steps` readings of `sample` into channelUtilAvg. Closed form, not a loop, so a + /// multi-day sleep decays by the time elapsed at the cost of one powf. + void foldChannelUtil(float sample, uint32_t steps, const Held &); float utilizationTXPercent(const Held &); bool airtimeReport(reportTypes reportType, uint32_t *out, size_t count, const Held &); uint8_t getSilentMinutes(float txPercent, float dutyCycle, const Held &); diff --git a/src/mesh/Default.h b/src/mesh/Default.h index 4f6c000f84..b6d93eb218 100644 --- a/src/mesh/Default.h +++ b/src/mesh/Default.h @@ -58,6 +58,17 @@ enum class TrafficType { POSITION, TELEMETRY }; #define default_hop_scaling_min_target_nodes_floor 5 // minimum allowed min_target_nodes #define default_hop_scaling_max_target_nodes_ceiling 512 // maximum allowed max_target_nodes +// Congestion gate: hop scaling only applies while the channel is measurably busy. Engage sits below +// AirTime::polite_channel_util_percent (25) so hops ease back before the polite gate withholds traffic. +#define default_hop_scaling_congestion_engage_pct 20 // smoothed channel utilization that engages scaling +#define default_hop_scaling_congestion_release_pct 12 // smoothed channel utilization that releases it +#define default_hop_scaling_congestion_confirm_runs 3 // consecutive 5-min samples needed to flip either way +// Above this the hop walk's one-hop extension is cut to its strictest setting: the radio is +// already withholding metadata at the polite gate, so an extra relay is the wrong thing to spend. +#define default_hop_scaling_congestion_strict_pct 25 // = AirTime::polite_channel_util_percent +// Hop floor for the infrastructure roles Router.cpp already groups for zero-cost hops. +#define default_hop_scaling_infrastructure_hop_floor 3 + #ifdef USERPREFS_RINGTONE_NAG_SECS #define default_ringtone_nag_secs USERPREFS_RINGTONE_NAG_SECS #else diff --git a/src/modules/HopScalingModule.cpp b/src/modules/HopScalingModule.cpp index 758715f971..18d143e447 100644 --- a/src/modules/HopScalingModule.cpp +++ b/src/modules/HopScalingModule.cpp @@ -7,6 +7,7 @@ #include "FSCommon.h" #include "NodeDB.h" #include "SPILock.h" +#include "airtime.h" #include "concurrency/LockGuard.h" #include "mesh-pb-constants.h" #include @@ -245,23 +246,15 @@ void HopScalingModule::rollHour() } lastPerHopCounts = counts; - // 1b. Compute politeness factor from the 0-2 h vs 1-3 h activity ratio. - { - const uint32_t recent = static_cast(hourlyRaw[0]) + hourlyRaw[1]; - const uint32_t older = static_cast(hourlyRaw[1]) + hourlyRaw[2]; - if (older > 1 && recent > 1) { - const uint32_t r = static_cast(recent) * ACTIVITY_WEIGHT_SCALE; - const uint32_t o = static_cast(older); - if (r < o * ACTIVITY_WEIGHT_GENEROUS_MAX_NUMER) - lastPoliteNumer = POLITENESS_GENEROUS; - else if (r > o * ACTIVITY_WEIGHT_STRICT_MIN_NUMER) - lastPoliteNumer = POLITENESS_STRICT; - else - lastPoliteNumer = POLITENESS_DEFAULT; - } else { - lastPoliteNumer = POLITENESS_DEFAULT; - } - } + // 1b. Pick the politeness factor from measured channel utilization. How far the walk may + // stretch and whether it is applied at all now read the same signal, so a node cannot be + // told the mesh is filling up by node counts while the channel says it is idle. + if (smoothedUtilPct() >= CONGESTION_STRICT_PCT) + lastPoliteNumer = POLITENESS_STRICT; + else if (smoothedUtilPct() >= CONGESTION_ENGAGE_PCT) + lastPoliteNumer = POLITENESS_DEFAULT; + else + lastPoliteNumer = POLITENESS_GENEROUS; // 1c. Scale and cache trend stats (denominatorHistory already advanced above). { @@ -424,6 +417,34 @@ void HopScalingModule::trimIfNeeded() } } +float HopScalingModule::channelUtil() +{ +#ifdef PIO_UNIT_TESTING + return s_testChannelUtil; +#else + return airTime ? airTime->smoothedChannelUtilizationPercent() : 0.0f; +#endif +} + +void HopScalingModule::updateCongestion() +{ + // AirTime folds its own EMA once per 10 s bucket, so this reads a figure that already covers + // the whole interval between ticks rather than only the 60 s before each one. + utilizationAvg = channelUtil(); + + // Separate engage/release thresholds, each confirmed over several ticks, so a mesh sitting + // near a threshold does not flap the hop limit between rolls. + const uint8_t util = smoothedUtilPct(); + const bool wantsFlip = congested ? (util <= CONGESTION_RELEASE_PCT) : (util >= CONGESTION_ENGAGE_PCT); + congestionConfirmRuns = wantsFlip ? static_cast(congestionConfirmRuns + 1u) : 0u; + if (congestionConfirmRuns >= CONGESTION_CONFIRM_RUNS) { + congested = !congested; + congestionConfirmRuns = 0; + LOG_INFO("[HOPSCALE] Congestion %s at chanUtil=%u%%", congested ? "engaged" : "released", + static_cast(utilizationAvg)); + } +} + void HopScalingModule::logStatusReport(bool didHourlyUpdate) const { const bool histActive = (histogramRollCount > 0 && count > 0); @@ -431,10 +452,11 @@ void HopScalingModule::logStatusReport(bool didHourlyUpdate) const const uint8_t runsRemaining = didHourlyUpdate ? RUNS_PER_HOUR : (RUNS_PER_HOUR - runsSinceLastHourlyUpdate); const uint8_t minsUntilRollover = runsRemaining * (RUN_INTERVAL_MS / (60 * 1000UL)); - LOG_INFO("[HOPSCALE] hop=%u histActive=%u fill=%u%% samp=1/%u filt=1/%u entries=%u lastCounted=%u polite=%u/4 " - "nextRoll=%umin", - lastRequiredHop, histActive ? 1u : 0u, getFillPercentage(), samplingDenominator, filteringDenominator, count, - histCounts.total, lastPoliteNumer, minsUntilRollover); + LOG_INFO("[HOPSCALE] hop=%u congested=%u chanUtil=%u%% histActive=%u fill=%u%% samp=1/%u filt=1/%u entries=%u " + "lastCounted=%u polite=%u/4 nextRoll=%umin", + lastRequiredHop, congested ? 1u : 0u, static_cast(utilizationAvg), histActive ? 1u : 0u, + getFillPercentage(), samplingDenominator, filteringDenominator, count, histCounts.total, lastPoliteNumer, + minsUntilRollover); LOG_INFO("[HOPSCALE] nodes perHop: [%u %u %u %u %u %u %u %u]", histCounts.perHop[0], histCounts.perHop[1], histCounts.perHop[2], histCounts.perHop[3], histCounts.perHop[4], histCounts.perHop[5], histCounts.perHop[6], @@ -449,6 +471,9 @@ int32_t HopScalingModule::runOnce() const bool isFirstRun = !hasCompletedInitialRun; bool didHourlyUpdate = false; + // Sampled every tick, not only on a roll, so the gate reacts within minutes of a change. + updateCongestion(); + if (isFirstRun) { hasCompletedInitialRun = true; runsSinceLastHourlyUpdate = 0; @@ -466,23 +491,36 @@ int32_t HopScalingModule::runOnce() } if (didHourlyUpdate) { - uint8_t suggested = (histogramRollCount > 0 && count > 0) ? lastSuggestedHop : HOP_MAX; - // Role-based hop floor: TRACKER/TAK_TRACKER always reach at least 2 hops, - // SENSOR reaches at least 1, so these reporting roles remain reachable even - // on a dense mesh where the histogram recommends a lower hop count. - uint8_t roleFloor = 0; - switch (config.device.role) { - case meshtastic_Config_DeviceConfig_Role_TRACKER: - case meshtastic_Config_DeviceConfig_Role_TAK_TRACKER: - roleFloor = 2; - break; - case meshtastic_Config_DeviceConfig_Role_SENSOR: - roleFloor = 1; - break; - default: - break; + if (!congested) { + // Density alone is not a reason to throttle. Hand a hop back per roll rather than + // jumping to HOP_MAX, so a mesh that just quietened does not un-throttle all at once. + if (lastRequiredHop < HOP_MAX) + lastRequiredHop++; + } else { + uint8_t suggested = (histogramRollCount > 0 && count > 0) ? lastSuggestedHop : HOP_MAX; + // Role-based hop floor: TRACKER/TAK_TRACKER always reach at least 2 hops, SENSOR reaches + // at least 1, and the infrastructure roles reach INFRASTRUCTURE_HOP_FLOOR, so these + // reporting roles remain reachable even on a dense mesh recommending fewer hops. + // The infrastructure set matches the one Router.cpp uses for zero-cost hops. + uint8_t roleFloor = 0; + switch (config.device.role) { + case meshtastic_Config_DeviceConfig_Role_ROUTER: + case meshtastic_Config_DeviceConfig_Role_ROUTER_LATE: + case meshtastic_Config_DeviceConfig_Role_CLIENT_BASE: + roleFloor = INFRASTRUCTURE_HOP_FLOOR; + break; + case meshtastic_Config_DeviceConfig_Role_TRACKER: + case meshtastic_Config_DeviceConfig_Role_TAK_TRACKER: + roleFloor = 2; + break; + case meshtastic_Config_DeviceConfig_Role_SENSOR: + roleFloor = 1; + break; + default: + break; + } + lastRequiredHop = std::max(suggested, roleFloor); } - lastRequiredHop = std::max(suggested, roleFloor); } logStatusReport(didHourlyUpdate); diff --git a/src/modules/HopScalingModule.h b/src/modules/HopScalingModule.h index 70d87428a5..b8b6786d39 100644 --- a/src/modules/HopScalingModule.h +++ b/src/modules/HopScalingModule.h @@ -105,16 +105,21 @@ class HopScalingModule : private concurrency::OSThread static constexpr uint8_t POLITENESS_DEFAULT = 2u; // 2/4 = 0.50 static constexpr uint8_t POLITENESS_STRICT = 1u; // 1/4 = 0.25 - // Activity weight thresholds (ratio of 0-2 h window vs 1-3 h window). - // Cross-multiply form: recent * ACTIVITY_WEIGHT_SCALE vs older * threshold_numer. - // GENEROUS if recent*10 < older*9 (ratio < 0.9); STRICT if recent*10 > older*12 (ratio > 1.2) - static constexpr uint8_t ACTIVITY_WEIGHT_SCALE = 10u; - static constexpr uint8_t ACTIVITY_WEIGHT_GENEROUS_MAX_NUMER = 9u; - static constexpr uint8_t ACTIVITY_WEIGHT_STRICT_MIN_NUMER = 12u; - // Scheduling: number of 5-minute runOnce() ticks that make up one hourly rollover static constexpr uint8_t RUNS_PER_HOUR = 12; + // Congestion gate. Scaling is applied only while the smoothed channel utilization says the + // channel is busy; below the release threshold the recommendation is not applied at all. + static constexpr uint8_t CONGESTION_ENGAGE_PCT = default_hop_scaling_congestion_engage_pct; + static constexpr uint8_t CONGESTION_RELEASE_PCT = default_hop_scaling_congestion_release_pct; + static constexpr uint8_t CONGESTION_CONFIRM_RUNS = default_hop_scaling_congestion_confirm_runs; + // Upper band for the politeness numerator, which reads the same smoothed utilization as the + // gate: STRICT at or above this, DEFAULT from CONGESTION_ENGAGE_PCT, GENEROUS below it. + static constexpr uint8_t CONGESTION_STRICT_PCT = default_hop_scaling_congestion_strict_pct; + + // Hop floor for the infrastructure roles, so a remote site's own telemetry still reaches operators. + static constexpr uint8_t INFRASTRUCTURE_HOP_FLOOR = default_hop_scaling_infrastructure_hop_floor; + // ----------------------------------------------------------------------- // Types // ----------------------------------------------------------------------- @@ -179,6 +184,7 @@ class HopScalingModule : private concurrency::OSThread const PerHopCounts &getLastPerHopCounts() const { return lastPerHopCounts; } uint8_t getLastSuggestedHop() const { return lastSuggestedHop; } const MeshTrendStats &getLastTrendStats() const { return lastTrendStats; } + bool isCongested() const { return congested; } // Compatibility accessors used by tests uint8_t getCompactHistogramEntryCount() const { return getEntryCount(); } @@ -200,6 +206,8 @@ class HopScalingModule : private concurrency::OSThread // Writable from tests as HopScalingModule::s_testNowMs; drives nowMs() in PIO_UNIT_TESTING builds. inline static uint32_t s_testNowMs = 0; /// Override the per-session hash seed. Use in tests that need a specific sampling distribution. + // Drives channelUtilizationPercent() in PIO_UNIT_TESTING builds, as s_testNowMs drives nowMs(). + inline static float s_testChannelUtil = 0.0f; void setHashSeed(uint16_t seed) { hashSeed = seed; } uint16_t getHashSeed() const { return hashSeed; } /// Expose hashNodeId for tests that need to compute which node IDs pass a given denominator. @@ -223,6 +231,19 @@ class HopScalingModule : private concurrency::OSThread /// filteringDenominator once toward samplingDenominator per rollHour() call. /// 6. Shifts all seen bitmaps left by one hour slot. void rollHour(); + + /// Cache the smoothed channel utilization and flip the congestion state once the engage or + /// release threshold has held for CONGESTION_CONFIRM_RUNS consecutive runOnce() ticks. + void updateCongestion(); + + /// Smoothed channel utilization percent, or 0 when AirTime is not up yet. + static float channelUtil(); + + /// utilizationAvg to the nearest whole percent. The thresholds are whole percents and the + /// underlying 60 s window is far coarser than a float ULP, so comparing at full float + /// precision only creates dead zones: an EMA converging on a threshold from above settles one + /// ULP off it (12.00006103515625 for a sustained 12%) and an inclusive test never fires. + uint8_t smoothedUtilPct() const { return static_cast(std::min(utilizationAvg + 0.5f, 255.0f)); } // ----------------------------------------------------------------------- // Persistence // ----------------------------------------------------------------------- @@ -309,6 +330,15 @@ class HopScalingModule : private concurrency::OSThread uint8_t lastRequiredHop = HOP_MAX; uint8_t histogramRollCount = 0; + // ----------------------------------------------------------------------- + // Congestion state + // ----------------------------------------------------------------------- + // Cached once per runOnce() from AirTime, so the hourly roll and the status log read one + // consistent value without re-taking the AirTime lock. + float utilizationAvg = 0.0f; + bool congested = false; + uint8_t congestionConfirmRuns = 0; + // ----------------------------------------------------------------------- // Scheduler state // ----------------------------------------------------------------------- diff --git a/test/test_airtime/test_main.cpp b/test/test_airtime/test_main.cpp index 6edab96fb3..330c9668c3 100644 --- a/test/test_airtime/test_main.cpp +++ b/test/test_airtime/test_main.cpp @@ -137,6 +137,115 @@ void test_channel_utilization_decays_once_the_60s_window_passes() TEST_ASSERT_FLOAT_WITHIN(0.01f, 0.0f, a.channelUtilizationPercent()); } +// channelUtilizationPercent() is a 60-second window, so one reading says almost nothing about +// load: a consumer that samples it every few minutes sees only the minute before each sample. +// The smoothed figure folds that window into an EMA once per crossed 10 s bucket, so it advances +// with real elapsed time rather than with how often somebody asks. + +void test_smoothed_channel_utilization_starts_from_the_raw_window() +{ + Time::setTestMillis(0); + AirTime a; + + a.logAirtime(RX_LOG, 30000); // 30 s of a 60 s window + + // Nothing has been folded yet. Reporting 0 here would read as an idle channel rather than as + // "no history", so the raw window stands in until the first bucket crossing. + TEST_ASSERT_FLOAT_WITHIN(0.01f, a.channelUtilizationPercent(), a.smoothedChannelUtilizationPercent()); +} + +void test_smoothed_channel_utilization_lags_a_sudden_spike() +{ + Time::setTestMillis(0); + AirTime a; + a.smoothedChannelUtilizationPercent(); // seed from an idle window + + Time::advanceTestMillis(10u * 1000u); + Time::serviceMonotonic(); + a.logAirtime(RX_LOG, 30000); + + Time::advanceTestMillis(10u * 1000u); + Time::serviceMonotonic(); + + const float raw = a.channelUtilizationPercent(); + const float smoothed = a.smoothedChannelUtilizationPercent(); + TEST_ASSERT_TRUE_MESSAGE(raw > 40.0f, "a 30 s burst should show up strongly in the 60 s window"); + TEST_ASSERT_TRUE_MESSAGE(smoothed < raw, "one busy bucket must not drag the smoothed figure with it"); +} + +void test_smoothed_channel_utilization_converges_on_a_sustained_level() +{ + Time::setTestMillis(0); + AirTime a; + a.smoothedChannelUtilizationPercent(); + + // Hold the channel at a steady load for well past the ~21 min time constant, refilling each + // 10 s bucket as it is cleared, and the smoothed figure should walk up to meet the window. + for (uint32_t tick = 0; tick < 400; tick++) { + Time::advanceTestMillis(10u * 1000u); + Time::serviceMonotonic(); + a.logAirtime(RX_LOG, 5000); // 5 s busy in every 10 s bucket -> 50% + a.smoothedChannelUtilizationPercent(); + } + + const float raw = a.channelUtilizationPercent(); + const float smoothed = a.smoothedChannelUtilizationPercent(); + TEST_ASSERT_FLOAT_WITHIN_MESSAGE(5.0f, raw, smoothed, "sustained load should converge on the raw window"); +} + +// The raw window is already required to be independent of how often the scheduler runs (see +// test_channel_utilization_is_independent_of_scheduler_rate, and the rotation-on-access contract in +// src/airtime.h). The smoothed figure inherits that requirement: folding one reading for a whole +// delayed sync instead of one per crossed bucket would make the EMA a function of call frequency, +// so two identical nodes would disagree purely because one of them slept. +void test_smoothed_channel_utilization_is_independent_of_sync_rate() +{ + Time::setTestMillis(0); + AirTime stepped; + AirTime delayed; + + // Identical airtime history: one burst, then a full window of silence. Only the rate at which + // each instance is asked for the figure differs. + stepped.logAirtime(RX_LOG, 30000); + delayed.logAirtime(RX_LOG, 30000); + + for (uint32_t bucket = 0; bucket < CHANNEL_UTILIZATION_PERIODS; bucket++) { + Time::advanceTestMillis(10u * 1000u); + Time::serviceMonotonic(); + stepped.smoothedChannelUtilizationPercent(); // sampled every bucket + } + + const float steppedPct = stepped.smoothedChannelUtilizationPercent(); + const float delayedPct = delayed.smoothedChannelUtilizationPercent(); // asked once, at the end + + TEST_ASSERT_FLOAT_WITHIN_MESSAGE(0.01f, steppedPct, delayedPct, + "the smoothed figure must not depend on how often it is sampled"); +} + +void test_smoothed_channel_utilization_decays_across_a_long_sleep() +{ + Time::setTestMillis(0); + AirTime a; + + for (uint32_t tick = 0; tick < 400; tick++) { + Time::advanceTestMillis(10u * 1000u); + Time::serviceMonotonic(); + a.logAirtime(RX_LOG, 5000); + a.smoothedChannelUtilizationPercent(); + } + const float busy = a.smoothedChannelUtilizationPercent(); + TEST_ASSERT_TRUE(busy > 10.0f); + + // A sleep longer than the whole window clears every bucket. The fold is capped at one window + // of steps, so the figure drops sharply without being reset outright. + Time::advanceTestMillis(10u * 60u * 1000u); + Time::serviceMonotonic(); + + const float afterSleep = a.smoothedChannelUtilizationPercent(); + TEST_ASSERT_EQUAL_FLOAT(0.0f, a.channelUtilizationPercent()); + TEST_ASSERT_TRUE_MESSAGE(afterSleep < busy, "a long idle gap must pull the smoothed figure down"); +} + void test_isTxAllowedChannelUtil_blocks_once_over_threshold() { Time::setTestMillis(0); @@ -1211,6 +1320,11 @@ void setup() RUN_TEST(test_period_history_clears_when_asleep_longer_than_the_whole_log); RUN_TEST(test_channel_utilization_reflects_recent_airtime); RUN_TEST(test_channel_utilization_decays_once_the_60s_window_passes); + RUN_TEST(test_smoothed_channel_utilization_starts_from_the_raw_window); + RUN_TEST(test_smoothed_channel_utilization_lags_a_sudden_spike); + RUN_TEST(test_smoothed_channel_utilization_converges_on_a_sustained_level); + RUN_TEST(test_smoothed_channel_utilization_is_independent_of_sync_rate); + RUN_TEST(test_smoothed_channel_utilization_decays_across_a_long_sleep); RUN_TEST(test_isTxAllowedChannelUtil_blocks_once_over_threshold); RUN_TEST(test_tx_utilization_decays_once_the_60_minute_window_passes); RUN_TEST(test_syncNow_survives_millis_wrap); diff --git a/test/test_hop_scaling/test_main.cpp b/test/test_hop_scaling/test_main.cpp index aaa4e9ad2e..88db48d10f 100644 --- a/test/test_hop_scaling/test_main.cpp +++ b/test/test_hop_scaling/test_main.cpp @@ -1,3 +1,23 @@ +// Unit tests for HopScalingModule in src/modules/HopScalingModule.{h,cpp} - the sampled hop +// histogram and the hop limit it recommends for this node's own routine broadcasts. +// +// What is pinned: +// - HopScalingModule::rollHour() walks the scaled per-hop buckets and recommends the smallest +// hop limit that still reaches default_hop_scaling_min_target_nodes, extended by at most one +// hop when the politeness envelope allows it. +// - HopScalingModule::runOnce() applies that recommendation only while the congestion gate is +// engaged, floors it by the sending node's role, and hands a hop back per hourly roll once +// congestion clears. Router.cpp reads the result through getLastRequiredHop() and only ever +// lowers a packet below the user's configured hop_limit. +// - The sampling/filtering denominator state machine, which keeps the 128-entry histogram +// bounded while leaving the population estimate invariant. +// +// The regression guarded: before the congestion gate, the recommendation was driven by node +// density alone, so a dense but idle mesh was throttled exactly as hard as a saturated one and +// remote routers on a near-idle MEDIUM_SLOW mesh went silent (meshtastic/firmware#11794). Delete +// or relax the gate assertions and that returns: scaling engages on node count, with no reference +// to whether the channel is actually busy. + #include "MeshTypes.h" #include "TestUtil.h" #include @@ -8,6 +28,7 @@ #include "gps/RTC.h" #include "mesh/NodeDB.h" #include "modules/HopScalingModule.h" +#include #include #include #include @@ -80,6 +101,20 @@ class HopScalingTestShim : public HopScalingModule } uint8_t getFilteringDenomHoldRollsRemaining() const { return filteringDenomHoldRollsRemaining; } + /// Put the congestion gate directly into a state, bypassing the confirm counter, and seed the + /// EMA to a value consistent with it so the next runOnce() does not immediately count toward + /// the opposite flip. + void forceCongestion(bool value) + { + congested = value; + congestionConfirmRuns = 0; + utilizationAvg = value ? static_cast(CONGESTION_ENGAGE_PCT) : 0.0f; + } + + /// Set the smoothed utilization directly, so a test can sit on a band boundary without + /// pumping the EMA there sample by sample. + void setSmoothedChannelUtilization(float pct) { utilizationAvg = pct; } + /// Insert an entry with an explicit hash, bypassing the sampling filter. /// Used to fill the histogram to a known state without depending on hashNodeId distribution. void forceInsertEntry(uint16_t hash, uint8_t hops) @@ -130,6 +165,11 @@ static void injectSampleTraffic(HopScalingTestShim &shim, uint32_t baseId, const { shim.setHistogramDenominator(HopScalingModule::DENOM_MIN); + // The scenario suites below all assert on an applied hop limit, which only happens while the + // congestion gate is engaged. Put the channel at a busy reading and engage it up front. + HopScalingModule::s_testChannelUtil = 45.0f; + shim.forceCongestion(true); + for (uint8_t roll = 0; roll < numRolls; ++roll) { mockTime += ONE_HOUR_MS; @@ -145,6 +185,15 @@ static void injectSampleTraffic(HopScalingTestShim &shim, uint32_t baseId, const } } +// Drive N runOnce() ticks with AirTime reporting a fixed smoothed utilization. +// The gate reads it once per tick, so this is how a test moves it through the confirm counter. +static void pumpRuns(HopScalingTestShim &shim, float channelUtilPct, int runs) +{ + HopScalingModule::s_testChannelUtil = channelUtilPct; + for (int i = 0; i < runs; i++) + shim.runOnce(); +} + static void assertCompactHistogramActive(HopScalingTestShim &shim) { TEST_ASSERT_GREATER_THAN_UINT8(0, shim.getCompactHistogramEntryCount()); @@ -494,6 +543,281 @@ void test_startup_blank_state() hopScalingModule = nullptr; } +// --------------------------------------------------------------------------- +// Tests - Congestion gate +// --------------------------------------------------------------------------- + +// Pins the fix for meshtastic/firmware#11794: hop scaling used to trigger on node density alone, +// so a dense but idle mesh was throttled exactly as hard as a saturated one. The reporter's +// MEDIUM_SLOW mesh sat at 10-15% channel utilization with spikes into the high teens and went +// silent. A node that is not congested must leave hop_limit alone no matter how dense it is. +void test_congestion_gate_idle_channel_does_not_scale() +{ + TEST_MESSAGE("=== Congestion gate: dense mesh, idle channel ==="); + TEST_MESSAGE("Topology: the dense 110-node mesh that scales to <= 3 hops when the channel is busy."); + TEST_MESSAGE("Expectation: with the channel reading 10%, nothing is applied and hop returns to HOP_MAX."); + + auto shim = std::unique_ptr(new HopScalingTestShim()); + hopScalingModule = shim.get(); + buildDenseLocalMesh(); + const uint16_t distA[HOP_MAX + 1] = {25, 30, 15, 5, 10, 15, 10, 0}; + injectSampleTraffic(*shim, 0x9E000000, distA); + + // The histogram recommendation itself is unchanged - it is the application that is gated. + shim->runOnce(); + TEST_ASSERT_TRUE(shim->isCongested()); + const uint8_t scaledWhileBusy = shim->getLastRequiredHop(); + TEST_MSG_FMT("While congested: hop=%u", scaledWhileBusy); + TEST_ASSERT_TRUE(scaledWhileBusy <= 3); + + // 10% is inside the band the reporter measured; it must release and stay released. + pumpRuns(*shim, 10.0f, HopScalingModule::RUNS_PER_HOUR * 8); + + TEST_MSG_FMT("After idle channel: congested=%u hop=%u", shim->isCongested() ? 1u : 0u, shim->getLastRequiredHop()); + TEST_ASSERT_FALSE(shim->isCongested()); + TEST_ASSERT_EQUAL_UINT8(HOP_MAX, shim->getLastRequiredHop()); + TEST_ASSERT_TRUE(shim->getLastSuggestedHop() <= 3); // recommendation still warm, just not applied + + hopScalingModule = nullptr; +} + +// The complement of the test above: the gate must still engage on a genuinely busy channel, driven +// through the real EMA and confirm counter rather than forced, or the scaler is dead code. +void test_congestion_gate_scales_on_busy_channel() +{ + TEST_MESSAGE("=== Congestion gate: dense mesh, busy channel ==="); + TEST_MESSAGE("Expectation: a sustained 45% channel reading engages the gate and applies the hop walk."); + + auto shim = std::unique_ptr(new HopScalingTestShim()); + hopScalingModule = shim.get(); + buildDenseLocalMesh(); + const uint16_t distA[HOP_MAX + 1] = {25, 30, 15, 5, 10, 15, 10, 0}; + injectSampleTraffic(*shim, 0x9F000000, distA); + + shim->forceCongestion(false); + HopScalingModule::s_testChannelUtil = 0.0f; + TEST_ASSERT_FALSE(shim->isCongested()); + // Pin the precondition: injectSampleTraffic() drives rollHour() directly and never runOnce(), + // so nothing has been applied yet. Without this the final assertion could pass vacuously. + TEST_ASSERT_EQUAL_UINT8(HOP_MAX, shim->getLastRequiredHop()); + + pumpRuns(*shim, 45.0f, HopScalingModule::RUNS_PER_HOUR * 2); + + TEST_MSG_FMT("After busy channel: congested=%u hop=%u", shim->isCongested() ? 1u : 0u, shim->getLastRequiredHop()); + TEST_ASSERT_TRUE(shim->isCongested()); + TEST_ASSERT_TRUE(shim->getLastRequiredHop() <= 3); + + hopScalingModule = nullptr; +} + +// HopScalingModule::updateCongestion() in src/modules/HopScalingModule.cpp. +// A mesh idling near a threshold would otherwise toggle the gate - and therefore hop_limit - on +// every roll. Smoothing lives in AirTime now, so what this pins is the confirm counter alone: +// readings that cross a threshold on alternate ticks never hold it for CONGESTION_CONFIRM_RUNS +// in a row, so the state must not flip in either direction. Delete the counter and it flaps. +void test_congestion_gate_does_not_flap_at_threshold() +{ + TEST_MESSAGE("=== Congestion gate: no flapping around the thresholds ==="); + TEST_MESSAGE("Phase 1: released gate, samples alternating either side of the engage threshold."); + TEST_MESSAGE("Phase 2: engaged gate, samples alternating either side of the release threshold."); + + // Straddle each threshold rather than hard-coding percentages, so the test follows Default.h. + constexpr float kStraddle = 4.0f; + constexpr float kEngage = static_cast(HopScalingModule::CONGESTION_ENGAGE_PCT); + constexpr float kRelease = static_cast(HopScalingModule::CONGESTION_RELEASE_PCT); + + auto shim = std::unique_ptr(new HopScalingTestShim()); + hopScalingModule = shim.get(); + buildDenseLocalMesh(); + const uint16_t distA[HOP_MAX + 1] = {25, 30, 15, 5, 10, 15, 10, 0}; + injectSampleTraffic(*shim, 0xA0000000, distA); + + shim->forceCongestion(false); + HopScalingModule::s_testChannelUtil = 0.0f; + for (int i = 0; i < 60; i++) { + HopScalingModule::s_testChannelUtil = (i % 2) ? kEngage - kStraddle : kEngage + kStraddle; + shim->runOnce(); + TEST_ASSERT_FALSE_MESSAGE(shim->isCongested(), "gate engaged on samples whose average stays below the threshold"); + } + + shim->forceCongestion(true); + for (int i = 0; i < 60; i++) { + HopScalingModule::s_testChannelUtil = (i % 2) ? kRelease - kStraddle : kRelease + kStraddle; + shim->runOnce(); + TEST_ASSERT_TRUE_MESSAGE(shim->isCongested(), "gate released on a dip that never held for the confirm window"); + } + + hopScalingModule = nullptr; +} + +// HopScalingModule::updateCongestion() in src/modules/HopScalingModule.cpp. +// Both threshold tests are inclusive - at exactly CONGESTION_ENGAGE_PCT the gate engages, at +// exactly CONGESTION_RELEASE_PCT it releases - and nothing else pins that. It is worth pinning +// because the comparison is made on a float average: AirTime's EMA converging on a threshold from +// above settles one ULP off it (12.00006103515625 for a sustained 12%), so comparing at full float +// precision left an inclusive test that could never fire and a gate that never released. +// smoothedUtilPct() rounds to the whole percent the thresholds are declared in; drop that rounding +// and a node sitting exactly on the release threshold stays throttled forever. +void test_congestion_gate_thresholds_are_inclusive() +{ + TEST_MESSAGE("=== Congestion gate: engage and release thresholds are inclusive ==="); + TEST_MESSAGE("Expectation: exactly the engage percent engages; exactly the release percent releases."); + + auto shim = std::unique_ptr(new HopScalingTestShim()); + hopScalingModule = shim.get(); + buildDenseLocalMesh(); + const uint16_t distA[HOP_MAX + 1] = {25, 30, 15, 5, 10, 15, 10, 0}; + injectSampleTraffic(*shim, 0xA5000000, distA); + + shim->forceCongestion(false); + pumpRuns(*shim, static_cast(HopScalingModule::CONGESTION_ENGAGE_PCT), HopScalingModule::CONGESTION_CONFIRM_RUNS); + TEST_MSG_FMT("At exactly %u%%: congested=%u", HopScalingModule::CONGESTION_ENGAGE_PCT, shim->isCongested() ? 1u : 0u); + TEST_ASSERT_TRUE_MESSAGE(shim->isCongested(), "sitting exactly on the engage threshold must engage"); + + pumpRuns(*shim, static_cast(HopScalingModule::CONGESTION_RELEASE_PCT), HopScalingModule::CONGESTION_CONFIRM_RUNS); + TEST_MSG_FMT("At exactly %u%%: congested=%u", HopScalingModule::CONGESTION_RELEASE_PCT, shim->isCongested() ? 1u : 0u); + TEST_ASSERT_FALSE_MESSAGE(shim->isCongested(), "sitting exactly on the release threshold must release"); + + // The dead zone itself: one ULP above the threshold is where AirTime's EMA actually settles + // when it converges on it from above, and a raw float compare reads that as "still congested" + // forever. Rounding to the whole percent is what makes it releasable. + shim->forceCongestion(true); + const float justAbove = std::nextafterf(static_cast(HopScalingModule::CONGESTION_RELEASE_PCT), 100.0f); + pumpRuns(*shim, justAbove, HopScalingModule::CONGESTION_CONFIRM_RUNS); + TEST_ASSERT_FALSE_MESSAGE(shim->isCongested(), "one ULP above the release threshold must still release"); + + hopScalingModule = nullptr; +} + +// Releasing the gate must not hand every node its full hop_limit back in the same roll - that turns +// a mesh that just quietened into a broadcast storm. Recovery is one hop per hourly roll. +void test_congestion_release_ramps_one_hop_per_roll() +{ + TEST_MESSAGE("=== Congestion gate: release ramps one hop per hourly roll ==="); + TEST_MESSAGE("Expectation: after release, hop rises by exactly 1 per rollover, not straight to HOP_MAX."); + + auto shim = std::unique_ptr(new HopScalingTestShim()); + hopScalingModule = shim.get(); + buildDenseLocalMesh(); + const uint16_t distA[HOP_MAX + 1] = {25, 30, 15, 5, 10, 15, 10, 0}; + injectSampleTraffic(*shim, 0xA1000000, distA); + + shim->runOnce(); + uint8_t previous = shim->getLastRequiredHop(); + TEST_MSG_FMT("Engaged at hop=%u", previous); + TEST_ASSERT_TRUE(previous < HOP_MAX); + + shim->forceCongestion(false); + HopScalingModule::s_testChannelUtil = 0.0f; + + for (uint8_t roll = 0; roll < 3; roll++) { + for (int run = 0; run < HopScalingModule::RUNS_PER_HOUR; run++) + shim->runOnce(); + const uint8_t now = shim->getLastRequiredHop(); + TEST_MSG_FMT("Roll %u: hop=%u", roll + 1, now); + TEST_ASSERT_EQUAL_UINT8(previous + 1, now); + previous = now; + } + + hopScalingModule = nullptr; +} + +// HopScalingModule::runOnce() in src/modules/HopScalingModule.cpp. +// The role floor is keyed on the sending node's own role, so this lets a remote site's telemetry +// travel without loosening anything for client nodes. Operators read that telemetry to know a +// mountain-top site is alive; issue #11794 is a report of exactly those routers going quiet. +// +// The floored set is the same one Router.cpp groups for zero-cost hops: ROUTER, ROUTER_LATE and +// CLIENT_BASE. REPEATER is deliberately absent - it is deprecated and AdminModule demotes it to +// CLIENT on config set, so a floor keyed on it could never fire. +void test_infrastructure_role_floor_applies_when_congested() +{ + TEST_MESSAGE("=== Role floor: infrastructure roles keep a minimum hop count ==="); + TEST_MESSAGE("Topology: 200 nodes at hop 0, so the hop walk recommends 0 for an unfloored role."); + TEST_MESSAGE("Expectation: CLIENT scales below the floor, ROUTER/ROUTER_LATE/CLIENT_BASE sit on it."); + + const uint16_t distLocal[HOP_MAX + 1] = {200, 60, 20, 5, 3, 2, 2, 1}; + const meshtastic_Config_DeviceConfig_Role savedRole = config.device.role; + + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + uint8_t clientHop = HOP_MAX; + { + auto shim = std::unique_ptr(new HopScalingTestShim()); + hopScalingModule = shim.get(); + buildDenseLocalMesh(); + injectSampleTraffic(*shim, 0xA2000000, distLocal); + shim->runOnce(); + clientHop = shim->getLastRequiredHop(); + hopScalingModule = nullptr; + } + TEST_MSG_FMT("CLIENT: hop=%u", clientHop); + TEST_ASSERT_TRUE(clientHop < HopScalingModule::INFRASTRUCTURE_HOP_FLOOR); + + const meshtastic_Config_DeviceConfig_Role infraRoles[] = {meshtastic_Config_DeviceConfig_Role_ROUTER, + meshtastic_Config_DeviceConfig_Role_ROUTER_LATE, + meshtastic_Config_DeviceConfig_Role_CLIENT_BASE}; + for (size_t i = 0; i < sizeof(infraRoles) / sizeof(infraRoles[0]); i++) { + config.device.role = infraRoles[i]; + auto shim = std::unique_ptr(new HopScalingTestShim()); + hopScalingModule = shim.get(); + buildDenseLocalMesh(); + injectSampleTraffic(*shim, 0xA3000000 + (static_cast(i) << 20), distLocal); + shim->runOnce(); + + TEST_MSG_FMT("Infrastructure role %u: hop=%u", static_cast(infraRoles[i]), shim->getLastRequiredHop()); + TEST_ASSERT_EQUAL_UINT8(HopScalingModule::INFRASTRUCTURE_HOP_FLOOR, shim->getLastRequiredHop()); + hopScalingModule = nullptr; + } + + config.device.role = savedRole; +} + +// The one-hop extension used to be graded by a density trend (0-2 h vs 1-3 h node counts) while the +// gate that decides whether the walk applies at all reads measured airtime. A node could therefore +// be told the mesh was filling up by node counts while the channel sat idle, which is the same +// mismatch issue #11794 reports one level up. Both now read the smoothed channel utilization. +// +// The three regimes PR #10176 defined are preserved, read from airtime instead of node counts: +// GENEROUS while the channel is quiet or clearing, DEFAULT once past the gate's engage point, and +// STRICT at the polite gate, where the radio is already withholding metadata traffic. +void test_politeness_tracks_channel_utilization() +{ + TEST_MESSAGE("=== Politeness: graded by measured utilization, not by node-count trend ==="); + TEST_MESSAGE("Expectation: 4/4 below the engage point, 2/4 from it, 1/4 from the strict point."); + + auto shim = std::unique_ptr(new HopScalingTestShim()); + hopScalingModule = shim.get(); + buildDenseLocalMesh(); + const uint16_t distA[HOP_MAX + 1] = {25, 30, 15, 5, 10, 15, 10, 0}; + injectSampleTraffic(*shim, 0xA4000000, distA); + + struct Band { + float util; + uint8_t numer; + }; + // forceCongestion() seeds the EMA, so each band is reached without pumping it there sample by + // sample; rollHour() then reads utilizationAvg directly. + constexpr float kEngage = static_cast(HopScalingModule::CONGESTION_ENGAGE_PCT); + constexpr float kStrict = static_cast(HopScalingModule::CONGESTION_STRICT_PCT); + // Both band edges are inclusive, so each is probed exactly and one below. + const Band bands[] = { + {0.0f, HopScalingModule::POLITENESS_GENEROUS}, {kEngage - 1.0f, HopScalingModule::POLITENESS_GENEROUS}, + {kEngage, HopScalingModule::POLITENESS_DEFAULT}, {kStrict - 1.0f, HopScalingModule::POLITENESS_DEFAULT}, + {kStrict, HopScalingModule::POLITENESS_STRICT}, {100.0f, HopScalingModule::POLITENESS_STRICT}}; + + for (size_t i = 0; i < sizeof(bands) / sizeof(bands[0]); i++) { + shim->setSmoothedChannelUtilization(bands[i].util); + shim->rollHourTest(); + + const float expected = bands[i].numer / static_cast(HopScalingModule::POLITENESS_DENOM); + TEST_MSG_FMT("util=%u%% -> polite=%u/4", static_cast(bands[i].util), + static_cast(shim->getPoliteness() * HopScalingModule::POLITENESS_DENOM)); + TEST_ASSERT_EQUAL_FLOAT(expected, shim->getPoliteness()); + } + + hopScalingModule = nullptr; +} + // --------------------------------------------------------------------------- // Tests - Denominator state machine // --------------------------------------------------------------------------- @@ -761,6 +1085,16 @@ void setup() RUN_TEST(test_intermediate_status); RUN_TEST(test_startup_blank_state); + printf("\n=== Congestion gate ===\n"); + RUN_TEST(test_congestion_gate_idle_channel_does_not_scale); + RUN_TEST(test_congestion_gate_scales_on_busy_channel); + RUN_TEST(test_congestion_gate_does_not_flap_at_threshold); + RUN_TEST(test_congestion_gate_thresholds_are_inclusive); + RUN_TEST(test_congestion_release_ramps_one_hop_per_roll); + RUN_TEST(test_infrastructure_role_floor_applies_when_congested); + + RUN_TEST(test_politeness_tracks_channel_utilization); + printf("\n=== Denominator state machine ===\n"); RUN_TEST(test_denominator_rises_on_overflow); RUN_TEST(test_filtering_denom_hold_counts_down);