mirror of
https://github.com/meshtastic/firmware.git
synced 2026-09-17 09:01:47 -04:00
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
This commit is contained in:
1 parent
bc2528b005
commit
3468af94aa
7 files changed
+647
-56
No files matched your search
+55
-11
@@ -2,7 +2,9 @@
|
||||
#include "NodeDB.h"
|
||||
#include "UptimeClock.h"
|
||||
#include "configuration.h"
|
||||
#include <algorithm>
|
||||
#include <assert.h>
|
||||
#include <cmath>
|
||||
#include <string.h>
|
||||
|
||||
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<uint32_t>(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);
|
||||
|
||||
+21
-1
@@ -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 &);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <algorithm>
|
||||
@@ -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<uint32_t>(hourlyRaw[0]) + hourlyRaw[1];
|
||||
const uint32_t older = static_cast<uint32_t>(hourlyRaw[1]) + hourlyRaw[2];
|
||||
if (older > 1 && recent > 1) {
|
||||
const uint32_t r = static_cast<uint32_t>(recent) * ACTIVITY_WEIGHT_SCALE;
|
||||
const uint32_t o = static_cast<uint32_t>(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<uint8_t>(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<unsigned>(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<unsigned>(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);
|
||||
|
||||
@@ -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<uint8_t>(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
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 <unity.h>
|
||||
@@ -8,6 +28,7 @@
|
||||
#include "gps/RTC.h"
|
||||
#include "mesh/NodeDB.h"
|
||||
#include "modules/HopScalingModule.h"
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
@@ -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<float>(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<HopScalingTestShim>(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<HopScalingTestShim>(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<float>(HopScalingModule::CONGESTION_ENGAGE_PCT);
|
||||
constexpr float kRelease = static_cast<float>(HopScalingModule::CONGESTION_RELEASE_PCT);
|
||||
|
||||
auto shim = std::unique_ptr<HopScalingTestShim>(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<HopScalingTestShim>(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<float>(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<float>(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<float>(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<HopScalingTestShim>(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<HopScalingTestShim>(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<HopScalingTestShim>(new HopScalingTestShim());
|
||||
hopScalingModule = shim.get();
|
||||
buildDenseLocalMesh();
|
||||
injectSampleTraffic(*shim, 0xA3000000 + (static_cast<uint32_t>(i) << 20), distLocal);
|
||||
shim->runOnce();
|
||||
|
||||
TEST_MSG_FMT("Infrastructure role %u: hop=%u", static_cast<unsigned>(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<HopScalingTestShim>(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<float>(HopScalingModule::CONGESTION_ENGAGE_PCT);
|
||||
constexpr float kStrict = static_cast<float>(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<float>(HopScalingModule::POLITENESS_DENOM);
|
||||
TEST_MSG_FMT("util=%u%% -> polite=%u/4", static_cast<unsigned>(bands[i].util),
|
||||
static_cast<unsigned>(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);
|
||||
|
||||
Reference in new issue
Block a user