* asdf * Implement SphereOfInfluenceModule for traffic management and eviction tracking * Implement Sphere of Influence module for dynamic hop limit adjustment and role-based floor * Update SAMPLING_DENOMINATOR to improve mesh size estimation accuracy * Add debug logging for scale factor estimation and per-hop node counts in SphereOfInfluenceModule * Enable variable hop limits and role-based hop floors in Sphere of Influence module * Respond to copilot review * Disable variable hop limits and role-based hop floors in Sphere of Influence module * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Implement adaptive sampling for unique node ID tracking in Sphere of Influence module * Add state persistence for Sphere of Influence module * Enhance Sphere of Influence module with state management and adaptive sampling adjustments * Refactor hop scaling functionality: remove SphereOfInfluenceModule and introduce HopScalingModule - Deleted SphereOfInfluenceModule.h, consolidating its responsibilities into the new HopScalingModule. - Added HopScalingModule.h and HopScalingModule.cpp to manage hop scaling logic, including eviction tracking and sampling-based mesh size estimation. - Implemented methods for recording evictions and packet senders, estimating scale factors, and computing required hops based on node activity. - Introduced state persistence for hop scaling parameters to maintain continuity across reboots. - Enhanced thread safety and modularity by utilizing concurrency features. * Guard out STM32. Sowwy. * Refactor HopScalingModule: enhance sampling logic and improve state management * Add unit tests for HopScalingModule: implement mock database and various test scenarios * Refactor test output in HopScalingModule tests: replace printf with TEST_MESSAGE for better integration with Unity * Refactor HopScalingModule logging: replace lastStatusMode with descriptive mode names for improved readability * Refactor test_main.cpp: change NodeNum variable to static and improve comments for clarity * Remove unnecessary delay in setup function for improved test performance * Add missing include for MeshTypes in test_main.cpp * Refactor HopScalingModule tests: enhance mesh topology scenarios and improve test clarity * Update HopScalingModule tests: flesh out node scenarios and improve clarity for dense and sparse mesh cases * Fix politeness factor calculations in HopScalingModule and update related test scenarios for clarity. Remove outdated design doc. * Enhance HopScalingModule: add sampled estimate for scaling decisions and refactor initial run state management * Add sample traffic injection for HopScaling tests to enhance sampledEst visibility * Enhance HopScalingModule: adjust windowFraction calculation for early triggers and improve test output formatting * Enhance HopScalingModule: add jitter functionality to sampling denominator and update tests for consistent behavior * Enhance HopScalingModule: implement adaptive sampling denominator adjustment and add reset functionality for tests * Enhance HopScalingTestShim: add test-only clock and window helpers, update injectSampleTraffic for adaptive sampling, and improve scenario summary output * Enhance HopScalingModule: add detailed documentation for functions, improve clarity of jitter and sampling logic, and reset functionality in tests * Enhance HopScalingModule: add evictionEstimate parameter to estimateScaleFactor and update related logging for improved mesh size estimation * Enhance HopScalingModule: adjust effective rolls calculation for improved accuracy, add eviction estimate logic, responding to all copilot review points * Implement CompactHistogram for parallel hop scaling sampling - Added CompactHistogram class to track node hop distances with bitwise sampling. - Integrated CompactHistogram into HopScalingModule for independent packet sampling. - Updated NodeDB to feed both the hop scaling module and the new histogram sampler. - Enhanced HopScalingModule with methods to sample packets for the histogram and retrieve hop distribution statistics. - Implemented tests for CompactHistogram functionality, including sampling, window rolling, and adaptive denominator scaling. - Updated existing tests to validate the integration of the new histogram sampling mechanism. * Enhance CompactHistogram and HopScalingModule: add per-hop distribution functionality, improve time handling for unit tests, and refine test setup for deterministic behavior * CompactHistogram: add mesh size estimation, improve entry replacement logic, and update logging for per-hop distribution * Refactor HopScalingModule and CompactHistogram integration - Removed the suggestedHopFromCompactHistogram function to streamline hop suggestion logic. - Updated HopScalingModule to directly utilize CompactHistogram's internal methods for hop suggestions and sampling. - Enhanced logging in HopScalingModule to provide detailed histogram statistics. - Modified test cases to ensure comprehensive coverage of new histogram behaviors and sampling logic. - Improved node ID distribution in tests to better exercise sampling mechanisms. - Ensured that filtering denominators are held for 12 hours before dropping, enhancing stability in sampling. * Refactor CompactHistogram to support 13-hour activity tracking and introduce politeness regimes - Updated the bitfield structure to accommodate 13-hour seen tracking. - Changed the logic in rollHour() to analyze activity over the last 0-2 hours vs. 1-3 hours for politeness factor calculation. - Introduced three politeness levels: GENEROUS, DEFAULT, and STRICT based on recent activity ratios. - Adjusted filtering and sampling logic to reflect the new 13-hour tracking period. - Updated unit tests to validate new behavior and ensure proper functionality of politeness regimes. * Enhance CompactHistogram and HopScalingModule for improved sampling and decision-making - Introduced a session-specific hash seed in CompactHistogram to reduce bias in node ID sampling. - Updated sampling logic to use hashed node IDs instead of raw IDs for filtering and entry management. - Added histogram rollover tracking in HopScalingModule to ensure proper decision-making after initial data collection. - Adjusted logging to reflect the active state of the histogram and its comparison with NodeDB advisory hops. - Enhanced unit tests to validate new sampling logic and memory layout changes. * Expose hashNodeId for testing in CompactHistogram * Add mesh trend statistics to CompactHistogram for enhanced activity tracking * Implement histogram state persistence in CompactHistogram with save and load functions * Refactor CompactHistogram to improve entry management and enhance rollHour logging * feat: add HopScalingModule for adaptive hop limit recommendations Introduces HopScalingModule, a sampled hop-distance histogram that recommends the minimum hop limit needed to reach ~40 nodes, and automatically reducing the hops as the mesh grows. Key design: - 512-byte packed histogram (128 × 4-byte Record entries) embedded in a new HopScalingModule. - Each Record: 16-bit node hash, 3-bit hop distance, 13-bit seen bitmap - Sampling filter: only nodes where (hash & (denom-1)) == 0 are kept; denominator doubles on overflow and halves when utilisation is low - Hourly rollHour(): tallies per-hop counts, walks scaled buckets to find the minimum hop satisfying TARGET_AFFECTED_NODES (40), applies a politeness extension based on recent/older activity ratio, shifts all seen bitmaps, and persists state to /prefs/hopScalingState.bin - Hop recommendation gated by bootstrap (requires >=1 rollHour before overriding HOP_MAX) - NodeDB calls samplePacketForHistogram() on every non-MQTT rx packet - Module also estimates total mesh size and logs useful information about mesh characteristics. Changes: - src/modules/HopScalingModule.h/.cpp: new module - src/mesh/NodeDB.cpp: wire up samplePacketForHistogram - src/mesh/Router.cpp: consume getLastRequiredHop() - test/test_hop_scaling/: 12-test suite covering all mesh topologies and anticipated operational requirements * test: increase run iterations in sparse to dense transition test * feat: refactor HopScalingModule to use RUNS_PER_HOUR constant and improve logging * feat: enhance HopScalingModule with filtering denominator management and add tests for state transitions * refactor: remove CompactHistogram module and related files * address copilot review comments * Tweak: packet sampling only lora * ove role-based hop floor logic and related definitions into the module - keep it in one place. * Refactor MockNodeDB to use nodeInfoLiteSetBit for MQTT flag setting * Refactor hop scaling parameters and logic to integer maths and put default values in defaults.h. Small flash size reduction, no functional impact. * Update unit test preprocessor directives to PIO_UNIT_TESTING for consistency * refactor: improve test output organization and clarity in hop scaling tests --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
494 lines
21 KiB
C++
494 lines
21 KiB
C++
#include "HopScalingModule.h"
|
|
#include "SafeFile.h"
|
|
#include "meshUtils.h"
|
|
|
|
#if HAS_VARIABLE_HOPS
|
|
|
|
#include "FSCommon.h"
|
|
#include "NodeDB.h"
|
|
#include "SPILock.h"
|
|
#include "concurrency/LockGuard.h"
|
|
#include "mesh-pb-constants.h"
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
#include <cstring>
|
|
|
|
namespace
|
|
{
|
|
// Module scheduling
|
|
constexpr uint32_t INITIAL_DELAY_MS = 30 * 1000UL; // Startup grace period before first run
|
|
constexpr uint32_t RUN_INTERVAL_MS = 5 * 60 * 1000UL; // Emit micro-summary every 5 minutes
|
|
// RUNS_PER_HOUR is a public class constant in HopScalingModule.h
|
|
|
|
// Persistence
|
|
// Note: this only needs incrementing if the published arrangement changes. For testing purposes, or prior to widespread release,
|
|
// it can stay the same even if the internal layout changes.
|
|
constexpr uint32_t HISTOGRAM_STATE_MAGIC = 0x48535432; // 'HST2' — layout v2
|
|
constexpr uint8_t HISTOGRAM_STATE_VERSION = 1;
|
|
constexpr const char *HISTOGRAM_STATE_FILE = "/prefs/hopScalingState.bin";
|
|
|
|
#pragma pack(push, 1)
|
|
struct PersistedHistogram {
|
|
uint32_t magic;
|
|
uint8_t version;
|
|
uint8_t samplingDenominator;
|
|
uint8_t filteringDenominator;
|
|
uint8_t filterDenomHoldRollsRemaining; // rollHour() calls remaining in the hold; 0 when expired/not active
|
|
uint16_t hashSeed;
|
|
Record entries[HopScalingModule::CAPACITY]; // full 512-byte array; count derived on load
|
|
};
|
|
#pragma pack(pop)
|
|
|
|
} // namespace
|
|
|
|
HopScalingModule *hopScalingModule;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Lifecycle
|
|
// ---------------------------------------------------------------------------
|
|
|
|
HopScalingModule::HopScalingModule() : concurrency::OSThread("HopScaling")
|
|
{
|
|
clear();
|
|
loadFromDisk();
|
|
setIntervalFromNow(INITIAL_DELAY_MS);
|
|
}
|
|
|
|
void HopScalingModule::clear()
|
|
{
|
|
memset(entries, 0, sizeof(entries));
|
|
count = 0;
|
|
samplingDenominator = DENOM_MIN;
|
|
filteringDenominator = DENOM_MIN;
|
|
filteringDenomHoldRollsRemaining = 0;
|
|
lastPerHopCounts = {};
|
|
lastSuggestedHop = MAX_HOP;
|
|
lastPoliteNumer = POLITENESS_DEFAULT;
|
|
lastTrendStats = {};
|
|
memset(denominatorHistory, DENOM_MIN, sizeof(denominatorHistory));
|
|
#ifndef PIO_UNIT_TESTING
|
|
hashSeed = static_cast<uint16_t>(random());
|
|
#else
|
|
hashSeed = 0; // deterministic in unit tests
|
|
#endif
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Persistence
|
|
// ---------------------------------------------------------------------------
|
|
|
|
void HopScalingModule::saveToDisk() const
|
|
{
|
|
#ifdef FSCom
|
|
FSCom.mkdir("/prefs");
|
|
PersistedHistogram state{};
|
|
state.magic = HISTOGRAM_STATE_MAGIC;
|
|
state.version = HISTOGRAM_STATE_VERSION;
|
|
state.samplingDenominator = samplingDenominator;
|
|
state.filteringDenominator = filteringDenominator;
|
|
state.filterDenomHoldRollsRemaining = filteringDenomHoldRollsRemaining;
|
|
state.hashSeed = hashSeed;
|
|
// Save all CAPACITY slots; count is reconstructed on load by scanning seenHoursAgo.
|
|
memcpy(state.entries, entries, sizeof(state.entries));
|
|
auto file = SafeFile(HISTOGRAM_STATE_FILE, true);
|
|
const size_t written = file.write(reinterpret_cast<const uint8_t *>(&state), sizeof(state));
|
|
if (file.close() && written == sizeof(state)) {
|
|
LOG_DEBUG("[HOPSCALE] Saved: count=%u samp=1/%u filt=1/%u holdRollsRemaining=%u", count, samplingDenominator,
|
|
filteringDenominator, state.filterDenomHoldRollsRemaining);
|
|
} else {
|
|
LOG_WARN("[HOPSCALE] Failed to write %s (%u of %u bytes)", HISTOGRAM_STATE_FILE, static_cast<unsigned>(written),
|
|
static_cast<unsigned>(sizeof(state)));
|
|
}
|
|
#endif
|
|
}
|
|
|
|
void HopScalingModule::loadFromDisk()
|
|
{
|
|
#ifdef FSCom
|
|
concurrency::LockGuard g(spiLock);
|
|
auto file = FSCom.open(HISTOGRAM_STATE_FILE, FILE_O_READ);
|
|
if (!file)
|
|
return;
|
|
PersistedHistogram state{};
|
|
const bool readOk = (file.read(reinterpret_cast<uint8_t *>(&state), sizeof(state)) == sizeof(state));
|
|
file.close();
|
|
// Validate magic, version, denom range, denom power-of-two invariant, and hold counter.
|
|
if (!readOk || state.magic != HISTOGRAM_STATE_MAGIC || state.version != HISTOGRAM_STATE_VERSION ||
|
|
state.samplingDenominator < DENOM_MIN || state.samplingDenominator > DENOM_MAX ||
|
|
state.filteringDenominator < state.samplingDenominator || state.filteringDenominator > DENOM_MAX ||
|
|
!is_pow_of_2(state.samplingDenominator) || !is_pow_of_2(state.filteringDenominator) ||
|
|
state.filterDenomHoldRollsRemaining > FILTER_DENOM_HOLD_ROLLS) {
|
|
LOG_DEBUG("[HOPSCALE] No valid persisted state (magic=%08x ver=%u samp=%u filt=%u hold=%u), starting fresh", state.magic,
|
|
state.version, state.samplingDenominator, state.filteringDenominator, state.filterDenomHoldRollsRemaining);
|
|
return;
|
|
}
|
|
// Derive count by scanning: active entries have seenHoursAgo != 0; pack them to the front.
|
|
|
|
uint8_t restored = 0;
|
|
for (uint8_t i = 0; i < CAPACITY && restored < CAPACITY; i++) {
|
|
if (state.entries[i].seenHoursAgo != 0u) {
|
|
entries[restored++] = state.entries[i];
|
|
}
|
|
}
|
|
count = restored;
|
|
samplingDenominator = state.samplingDenominator;
|
|
filteringDenominator = state.filteringDenominator;
|
|
filteringDenomHoldRollsRemaining = state.filterDenomHoldRollsRemaining;
|
|
// denominatorHistory can't be recovered; initialise all slots to filteringDenominator so
|
|
// the first few post-reboot scaledPerHour values use a safe (slightly conservative) multiplier.
|
|
memset(denominatorHistory, filteringDenominator, sizeof(denominatorHistory));
|
|
hashSeed = state.hashSeed;
|
|
LOG_INFO("[HOPSCALE] Restored: count=%u samp=1/%u filt=1/%u holdRollsRemaining=%u", count, samplingDenominator,
|
|
filteringDenominator, state.filterDenomHoldRollsRemaining);
|
|
#endif
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Core API
|
|
// ---------------------------------------------------------------------------
|
|
|
|
void HopScalingModule::samplePacketForHistogram(uint32_t nodeId, uint8_t hopCount)
|
|
{
|
|
const uint16_t hash = hashNodeId(nodeId);
|
|
|
|
if (!passesFilter(hash, samplingDenominator))
|
|
return;
|
|
|
|
hopCount = std::min(hopCount, MAX_HOP);
|
|
|
|
// Update an existing entry
|
|
Record *entry = nullptr;
|
|
for (uint8_t i = 0; i < count; i++) {
|
|
if (entries[i].nodeHash == hash) {
|
|
entry = &entries[i];
|
|
break;
|
|
}
|
|
}
|
|
if (entry) {
|
|
entry->hops_away = hopCount;
|
|
markCurrentHour(*entry);
|
|
return;
|
|
}
|
|
|
|
// New node: trim if necessary before allocating a slot
|
|
if (getFillPercentage() >= FILL_HIGH_PCT) {
|
|
trimIfNeeded();
|
|
}
|
|
|
|
if (count < CAPACITY) {
|
|
entries[count].nodeHash = hash;
|
|
entries[count].hops_away = hopCount;
|
|
entries[count].seenHoursAgo = 1u; // mark current hour
|
|
count++;
|
|
} else {
|
|
LOG_WARN("[HOPSCALE] Histogram full at samp=1/%u (DENOM_MAX=%u); dropping node hash=0x%04x; hop recommendation may be "
|
|
"skewed!!!",
|
|
samplingDenominator, DENOM_MAX, hash);
|
|
}
|
|
}
|
|
|
|
void HopScalingModule::rollHour()
|
|
{
|
|
// Advance denominatorHistory before the tally so each slot h holds the filteringDenominator
|
|
// that was active when seenHoursAgo bit h was set. hourlyRaw[h] is then gated per-slot by
|
|
// denominatorHistory[h], giving a correct population estimate for each historical hour even
|
|
// when filteringDenominator changes between rolls. Scale-up backfills the entire array so
|
|
// the invariant holds retroactively (see trimIfNeeded()).
|
|
for (uint8_t h = 12; h > 0; h--)
|
|
denominatorHistory[h] = denominatorHistory[h - 1];
|
|
denominatorHistory[0] = filteringDenominator;
|
|
|
|
// 1. Tally per-hop counts and per-slot hourly activity in one pass.
|
|
// hourlyRaw[h]: gated per-slot by denominatorHistory[h] so the raw count and its
|
|
// multiplier are always consistent, even across filteringDenominator transitions.
|
|
// counts.*: gated uniformly by the current filteringDenominator for a consistent
|
|
// population estimate used by the hop-walk recommendation (step 2).
|
|
PerHopCounts counts{};
|
|
uint16_t hourlyRaw[13] = {};
|
|
uint16_t trendNewThisHour = 0;
|
|
uint16_t trendReturning = 0;
|
|
uint16_t trendLapsed = 0;
|
|
uint16_t trendOlderThan4h = 0;
|
|
uint16_t trendAgingOut = 0;
|
|
for (uint8_t i = 0; i < count; i++) {
|
|
const uint16_t hash = entries[i].nodeHash;
|
|
const uint32_t seen = entries[i].seenHoursAgo;
|
|
|
|
// Per-slot hourly activity: gate each slot by its own denominator.
|
|
for (uint8_t h = 0; h < 13; h++) {
|
|
if ((seen & (1u << h)) && passesFilter(hash, denominatorHistory[h]))
|
|
hourlyRaw[h]++;
|
|
}
|
|
|
|
// Hop counts and trend stats: uniform current-denominator gate.
|
|
if (!passesFilter(hash, filteringDenominator))
|
|
continue;
|
|
|
|
if (seenInLast13h(entries[i])) {
|
|
counts.perHop[entries[i].hops_away]++;
|
|
counts.total++;
|
|
}
|
|
const bool heardThisHour = (seen & 1u) != 0u;
|
|
const bool heardLastHour = (seen & 2u) != 0u;
|
|
const bool hasOlderHistory = (seen >> 1u) != 0u;
|
|
const bool recentlySilent = (seen & 0xFu) == 0u;
|
|
if (heardThisHour && !hasOlderHistory)
|
|
trendNewThisHour++;
|
|
else if (heardThisHour && hasOlderHistory)
|
|
trendReturning++;
|
|
if (!heardThisHour && heardLastHour)
|
|
trendLapsed++;
|
|
if (recentlySilent && (seen & 0x1FF0u) != 0u)
|
|
trendOlderThan4h++;
|
|
if (seen == (1u << 12u))
|
|
trendAgingOut++;
|
|
}
|
|
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;
|
|
}
|
|
}
|
|
|
|
// 1c. Scale and cache trend stats (denominatorHistory already advanced above).
|
|
{
|
|
MeshTrendStats t{};
|
|
for (uint8_t h = 0; h < 13; h++) {
|
|
const uint32_t s = static_cast<uint32_t>(hourlyRaw[h]) * denominatorHistory[h];
|
|
t.scaledPerHour[h] = static_cast<uint16_t>(std::min<uint32_t>(s, UINT16_MAX));
|
|
}
|
|
auto scale = [&](uint16_t raw) -> uint16_t {
|
|
return static_cast<uint16_t>(std::min<uint32_t>(static_cast<uint32_t>(raw) * filteringDenominator, UINT16_MAX));
|
|
};
|
|
t.newThisHour = scale(trendNewThisHour);
|
|
t.returningThisHour = scale(trendReturning);
|
|
t.lapsedSinceLastHour = scale(trendLapsed);
|
|
t.olderThan4h = scale(trendOlderThan4h);
|
|
t.agingOut = scale(trendAgingOut);
|
|
lastTrendStats = t;
|
|
}
|
|
|
|
// 2. Walk scaled hop buckets to produce a hop-limit recommendation.
|
|
// effectiveMin: walk threshold — first hop whose cumulative count reaches this.
|
|
// effectiveMax: ceiling on the one-hop extension check with GENEROUS politeness.
|
|
const uint16_t effectiveMin = TARGET_AFFECTED_NODES;
|
|
const uint16_t effectiveMax = MAX_TARGET_NODES;
|
|
uint8_t suggested = MAX_HOP;
|
|
if (counts.total > 0) {
|
|
uint32_t cumulative = 0;
|
|
for (uint8_t hop = 0; hop <= MAX_HOP; hop++) {
|
|
cumulative += static_cast<uint32_t>(counts.perHop[hop]) * filteringDenominator;
|
|
if (cumulative >= effectiveMin) {
|
|
suggested = hop;
|
|
break;
|
|
}
|
|
}
|
|
if (suggested < MAX_HOP) {
|
|
const uint32_t atNext = static_cast<uint32_t>(counts.perHop[suggested + 1]) * filteringDenominator;
|
|
// politeLimit = effectiveMin + gap * politeNumer / POLITENESS_DENOM
|
|
// Multiply both sides by POLITENESS_DENOM to stay in integers.
|
|
const uint32_t gap = static_cast<uint32_t>(effectiveMax) - static_cast<uint32_t>(effectiveMin);
|
|
if ((cumulative + atNext) * POLITENESS_DENOM <=
|
|
static_cast<uint32_t>(effectiveMin) * POLITENESS_DENOM + gap * lastPoliteNumer) {
|
|
suggested++;
|
|
}
|
|
}
|
|
}
|
|
lastSuggestedHop = suggested;
|
|
|
|
// 3. Log scaled per-hop counts and recommendation.
|
|
{
|
|
uint16_t scaled[MAX_HOP + 1];
|
|
for (uint8_t h = 0; h <= MAX_HOP; h++) {
|
|
const uint32_t s = static_cast<uint32_t>(counts.perHop[h]) * filteringDenominator;
|
|
scaled[h] = static_cast<uint16_t>(std::min<uint32_t>(s, UINT16_MAX));
|
|
}
|
|
const uint32_t scaledTotal = static_cast<uint32_t>(counts.total) * filteringDenominator;
|
|
memcpy(lastScaledPerHop, scaled, sizeof(lastScaledPerHop));
|
|
LOG_INFO("[HOPSCALE] rollHour: entries=%u/128 samp=1/%u filt=1/%u counted=%u est=%u suggestedHop=%u polite=%u/4", count,
|
|
samplingDenominator, filteringDenominator, counts.total, static_cast<unsigned>(scaledTotal), suggested,
|
|
lastPoliteNumer);
|
|
|
|
const auto &ts = lastTrendStats;
|
|
LOG_INFO("[HOPSCALE] scaledSeenPerHour (h0=now): [%u %u %u %u %u %u %u %u %u %u %u %u %u]", ts.scaledPerHour[0],
|
|
ts.scaledPerHour[1], ts.scaledPerHour[2], ts.scaledPerHour[3], ts.scaledPerHour[4], ts.scaledPerHour[5],
|
|
ts.scaledPerHour[6], ts.scaledPerHour[7], ts.scaledPerHour[8], ts.scaledPerHour[9], ts.scaledPerHour[10],
|
|
ts.scaledPerHour[11], ts.scaledPerHour[12]);
|
|
LOG_INFO("[HOPSCALE] trend: new=%u returning=%u lapsed=%u olderThan4h=%u agingOut=%u", ts.newThisHour,
|
|
ts.returningThisHour, ts.lapsedSinceLastHour, ts.olderThan4h, ts.agingOut);
|
|
}
|
|
|
|
// 4. Scale-down check: if fewer than FILL_LOW_PCT% of capacity pass the filteringDenominator
|
|
// gate and are active, halve samplingDenominator to admit more nodes.
|
|
// Note: during a filteringDenominator hold period, lowering samplingDenominator does not
|
|
// immediately improve counts.total (new admissions don't pass the elevated
|
|
// filteringDenominator). On a genuinely quieting mesh this check can therefore fire on
|
|
// consecutive hours, cascading samplingDenominator toward DENOM_MIN. This is intentional:
|
|
// rapid re-admission allows quick recovery if the mesh returns. The hop recommendation
|
|
// stays conservative (MAX_HOP) throughout because filteringDenominator remains elevated;
|
|
// step 5 below re-synchronises the denominators once the hold expires.
|
|
if (counts.total * 100u < static_cast<uint32_t>(CAPACITY) * FILL_LOW_PCT) {
|
|
if (samplingDenominator > DENOM_MIN) {
|
|
samplingDenominator = static_cast<uint8_t>(samplingDenominator / 2u);
|
|
LOG_INFO("[HOPSCALE] Scale-down: sampling denom halved to %u (filter denom=%u)", samplingDenominator,
|
|
filteringDenominator);
|
|
}
|
|
}
|
|
|
|
// 5. Tick down the hold counter; once it reaches zero, halve filteringDenominator toward
|
|
// samplingDenominator once per rollHour() (= once per hour) rather than a single jump:
|
|
// avoids a sudden large change in the hop-walk count when samplingDenominator cascaded
|
|
// down significantly during the hold period. No new hold is placed on each step — the
|
|
// 13-roll hold already guaranteed that re-admitted nodes have full seenHoursAgo history;
|
|
// further pacing is provided naturally by the 1-step-per-hour rate. denominatorHistory
|
|
// is updated automatically by the shift at the top of rollHour(), so no backfill here.
|
|
if (filteringDenominator > samplingDenominator) {
|
|
if (filteringDenomHoldRollsRemaining > 0)
|
|
filteringDenomHoldRollsRemaining--;
|
|
if (filteringDenomHoldRollsRemaining == 0) {
|
|
const uint8_t stepped = static_cast<uint8_t>(filteringDenominator / 2u);
|
|
filteringDenominator = (stepped > samplingDenominator) ? stepped : samplingDenominator;
|
|
LOG_INFO("[HOPSCALE] Filter denom stepped to %u (samp=1/%u)", filteringDenominator, samplingDenominator);
|
|
}
|
|
}
|
|
|
|
// 6. Shift all seen bitmaps left by one slot (opens a fresh slot for the new hour).
|
|
for (uint8_t i = 0; i < count; i++) {
|
|
rollSeenBits(entries[i]);
|
|
}
|
|
|
|
if (histogramRollCount < 255)
|
|
histogramRollCount++;
|
|
|
|
saveToDisk();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Internal helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
void HopScalingModule::trimIfNeeded()
|
|
{
|
|
// Step 1: evict stale entries (not seen in any of the past 13 hours).
|
|
uint8_t newCount = 0;
|
|
for (uint8_t i = 0; i < count; i++) {
|
|
if (seenInLast13h(entries[i])) {
|
|
if (i != newCount) {
|
|
entries[newCount] = entries[i];
|
|
}
|
|
newCount++;
|
|
}
|
|
}
|
|
count = newCount;
|
|
|
|
// Step 2: if still too full, double the sampling denominator and remove non-matching entries.
|
|
if (getFillPercentage() >= FILL_HIGH_PCT && samplingDenominator < DENOM_MAX) {
|
|
samplingDenominator = static_cast<uint8_t>(
|
|
std::min<uint16_t>(static_cast<uint16_t>(samplingDenominator) * 2u, static_cast<uint16_t>(DENOM_MAX)));
|
|
filteringDenominator = std::max(filteringDenominator, samplingDenominator);
|
|
filteringDenomHoldRollsRemaining = FILTER_DENOM_HOLD_ROLLS;
|
|
// Raise any denominatorHistory slot that is below the new filteringDenominator.
|
|
// Slots already above it (recorded during a prior scale-up that hasn't fully stepped
|
|
// down yet) are left untouched: eviction at samplingDenominator retains exactly those
|
|
// entries, so the old higher gate remains accurate for those historical hours.
|
|
// Slots below the new value must be raised because the eviction removed entries that
|
|
// had been admitted at the looser old gate — the remaining entries represent a 1/N
|
|
// subsample where N is the new filteringDenominator, not the old smaller value.
|
|
for (uint8_t h = 0; h < 13; h++)
|
|
denominatorHistory[h] = std::max(denominatorHistory[h], filteringDenominator);
|
|
LOG_INFO("[HOPSCALE] Scale-up: samp denom doubled to %u (filt=%u)", samplingDenominator, filteringDenominator);
|
|
|
|
newCount = 0;
|
|
for (uint8_t i = 0; i < count; i++) {
|
|
if (passesFilter(entries[i].nodeHash, samplingDenominator)) {
|
|
if (i != newCount) {
|
|
entries[newCount] = entries[i];
|
|
}
|
|
newCount++;
|
|
}
|
|
}
|
|
count = newCount;
|
|
}
|
|
}
|
|
|
|
void HopScalingModule::logStatusReport(bool didHourlyUpdate) const
|
|
{
|
|
const bool histActive = (histogramRollCount > 0 && count > 0);
|
|
const auto &histCounts = lastPerHopCounts;
|
|
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] 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],
|
|
histCounts.perHop[7]);
|
|
LOG_INFO("[HOPSCALE] last scaled perHop: [%u %u %u %u %u %u %u %u]", lastScaledPerHop[0], lastScaledPerHop[1],
|
|
lastScaledPerHop[2], lastScaledPerHop[3], lastScaledPerHop[4], lastScaledPerHop[5], lastScaledPerHop[6],
|
|
lastScaledPerHop[7]);
|
|
}
|
|
|
|
int32_t HopScalingModule::runOnce()
|
|
{
|
|
const bool isFirstRun = !hasCompletedInitialRun;
|
|
bool didHourlyUpdate = false;
|
|
|
|
if (isFirstRun) {
|
|
hasCompletedInitialRun = true;
|
|
runsSinceLastHourlyUpdate = 0;
|
|
didHourlyUpdate = true;
|
|
} else {
|
|
runsSinceLastHourlyUpdate++;
|
|
if (runsSinceLastHourlyUpdate >= RUNS_PER_HOUR) {
|
|
runsSinceLastHourlyUpdate = 0;
|
|
didHourlyUpdate = true;
|
|
}
|
|
}
|
|
|
|
if (didHourlyUpdate && !isFirstRun) {
|
|
rollHour();
|
|
}
|
|
|
|
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;
|
|
}
|
|
lastRequiredHop = std::max(suggested, roleFloor);
|
|
}
|
|
|
|
logStatusReport(didHourlyUpdate);
|
|
|
|
return RUN_INTERVAL_MS;
|
|
}
|
|
|
|
#endif
|